1. 虚拟地址空间

虚拟地址空间是一个非常抽象的概念,先根据字面意思进行解释:

  • 它可以用来加载程序数据(数据可能被加载到物理内存上,空间不够就加载到虚拟内存中)
  • 它对应着一段连续的内存地址,起始位置为 0。
  • 之所以说虚拟是因为这个起始的0地址是被虚拟出来的, 不是物理内存的 0地址。

虚拟地址空间的大小也由操作系统决定,32位的操作系统虚拟地址空间的大小为 232 字节,也就是4G,64位的操作系统虚拟地址空间大小为264 字节,这是一个非常大的数,感兴趣可以自己计算一下。当我们运行磁盘上一个可执行程序, 就会得到一个进程,内核会给每一个运行的进程创建一块属于自己的虚拟地址空间,并将应用程序数据装载到虚拟地址空间对应的地址上。

进程在运行过程中,程序内部所有的指令都是通过CPU处理完成的,CPU只进行数据运算并不具备数据存储的能力,其处理的数据都加载自物理内存,那么进程中的数据是如何进出入到物理内存中的呢?其实是通过CPU中的内存管理单元MMU(Memory Management Unit)从进程的虚拟地址空间中映射过去的。

1.1 存在的意义

通过上边的介绍大家会感觉到一头雾水, 为什么操作系统不直接将数据加载到物理内存中而是将数据加载到虚拟地址空间中,在通过CPU的MMU映射到物理内存中呢?

先来看一下如果直接将数据加载到物理内存会发生什么事情:

假设计算机的物理内存大小为1G, 进程A需要100M内存因此直接在物理内存上从0地址开始分配100M, 进程B启动需要250M内存, 因此继续在物理内存上为其分配250M内存, 并且进程A和进程B占用的内存是连续的。之后再启动其他进程继续按照这种方法进行物理内存的分配。。。

使用这种方式分配内存会有如下几个问题:

  1. 每个进程的地址不隔离,有安全风险。

    由于程序都是直接访问物理内存,所以恶意程序可以通过内存寻址随意修改别的进程对应的内存数据,以达到破坏的目的。虽然有些时候是非恶意的,但是有些存在 bug 的程序可能不小心修改了其它程序的内存数据,就会导致其它程序的运行出现异常。

  2. 内存效率低。

    如果直接使用物理内存的话,一个进程对应的内存块就是作为一个整体操作的,如果出现物理内存不够用的时候,我们一般的办法是将不常用的进程拷贝到磁盘的交换分区(虚拟内存)中,以便腾出内存,因此就需要将整个进程一起拷走,如果数据量大,在内存和磁盘之间拷贝时间就会很长,效率低下。

  3. 进程中数据的地址不确定,每次都会发生变化。

    由于物理内存的使用情况一直在动态的变化,我们无法确定内存现在使用到哪里了,如果直接将程序数据加载到物理内存,内存中每次存储数据的起始地址都是不一样的,这样数据的加载都需要使用相对地址,加载效率低(静态库是使用绝对地址加载的)。

有了虚拟地址空间之后就可以完美的解决上边提到的所有问题了,虚拟地址空间就是一个中间层,相当于在程序和物理内存之间设置了一个屏障,将二者隔离开来。程序中访问的内存地址不再是实际的物理内存地址,而是一个虚拟地址,然后由操作系统将这个虚拟地址映射到适当的物理内存地址上。这样,只要操作系统处理好虚拟地址到物理内存地址的映射,就可以保证不同的程序最终访问的内存地址位于不同的区域,彼此没有重叠,就可以达到内存地址空间隔离的效果。

1.2 分区

从操作系统层级上看,虚拟地址空间主要分为两个部分内核区用户区

  • 内核区:
    • 内核空间为内核保留,不允许应用程序读写该区域的内容或直接调用内核代码定义的函数。
    • 内核总是驻留在内存中,是操作系统的一部分。
    • 系统中所有进程对应的虚拟地址空间的内核区都会映射到同一块物理内存上(系统内核只有一个)。
  • 用户区:存储用户程序运行中用到的各种数据。

我们先来看一下进程对应的虚拟地址空间的各个分区,再来详细介绍用户区的组成(以32位系统的虚拟地址空间为例)。

每个进程的虚拟地址空间都是从0地址开始的,我们在程序中打印的变量地址也其在虚拟地址空间中的地址,程序是无法直接访问物理内存的。虚拟地址空间中用户区地址范围是 0~3G,里边分为多个区块:

  • 保留区: 位于虚拟地址空间的最底部,未赋予物理地址。任何对它的引用都是非法的,程序中的空指针(NULL)指向的就是这块内存地址。
  • .text段: 代码段也称正文段或文本段,通常用于存放程序的执行代码(即CPU执行的机器指令),代码段一般情况下是只读的,这是对执行代码的一种保护机制。
  • .data段: 数据段通常用于存放程序中已初始化且初值不为0的全局变量和静态变量。数据段属于静态内存分配(静态存储区),可读可写。
  • .bss段: 未初始化以及初始为0的全局变量和静态变量,操作系统会将这些未初始化变量初始化为0
  • 堆(heap):用于存放进程运行时动态分配的内存。
    • 堆中内容是匿名的,不能按名字直接访问,只能通过指针间接访问。
    • 堆向高地址扩展(即“向上生长”),是不连续的内存区域。这是由于系统用链表来存储空闲内存地址,自然不连续,而链表从低地址向高地址遍历。
  • 内存映射区(mmap):作为内存映射区加载磁盘文件,或者加载程序运作过程中需要调用的动态库。
  • 栈(stack): 存储函数内部声明的非静态局部变量,函数参数,函数返回地址等信息,栈内存由编译器自动分配释放。栈和堆相反地址“向下生长”,分配的内存是连续的。
  • 命令行参数:存储进程执行的时候传递给main()函数的参数,argc,argv[]
  • 环境变量: 存储和进程相关的环境变量, 比如: 工作路径, 进程所有者等信息

2. 文件描述符

2.1 文件描述符

在Linux操作系统中的一切都被抽象成了文件,那么一个打开的文件是如何与应用程序进行对应呢?解决方案是使用文件描述符(file descriptor,简称fd),当在进程中打开一个现有文件或者创建一个新文件时,内核向该进程返回一个文件描述符,用于对应这个打开/新建的文件。这些文件描述符都存储在内核为每个进程维护的一个文件描述符表中。

在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

在Linux系统中一切皆文件,系统中一切都被抽象成了文件。对这些文件的读写都需要通过文件描述符来完成。标准C库的文件IO函数使用的文件指针FILE*在Linux中也需要通过文件描述符的辅助才能完成读写操作。FILE其实是一个结构体,其内部有一个成员就是文件描述符(下面结构体的第25行)。

FILE结构体在Linux头文件中的定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// linux c FILE结构体定义: /usr/include/libio.h
struct _IO_FILE {
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
#define _IO_file_flags _flags

/* The following pointers correspond to the C++ streambuf protocol. */
/* Note: Tk uses the _IO_read_ptr and _IO_read_end fields directly. */
char* _IO_read_ptr; /* Current read pointer */
char* _IO_read_end; /* End of get area. */
char* _IO_read_base; /* Start of putback+get area. */
char* _IO_write_base; /* Start of put area. */
char* _IO_write_ptr; /* Current put pointer. */
char* _IO_write_end; /* End of put area. */
char* _IO_buf_base; /* Start of reserve area. */
char* _IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */

struct _IO_marker *_markers;

struct _IO_FILE *_chain;

int _fileno; // 文件描述符
#if 0
int _blksize;
#else
int _flags2;
#endif
_IO_off_t _old_offset; /* This used to be _offset but it's too small. */

#define __HAVE_COLUMN /* temporary */
/* 1+column number of pbase(); 0 is unknown. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];

/* char* _save_gptr; char* _save_egptr; */

_IO_lock_t *_lock;
#ifdef _IO_USE_OLD_IO_FILE
};

// 在文件: /usr/include/stdio.h
typedef struct _IO_FILE FILE;

2.2 文件描述符表

前面讲到启动一个进程就会得到一个对应的虚拟地址空间,这个虚拟地址空间分为两大部分,在内核区有专门用于进程管理的模块。Linux的进程控制块PCB(process control block)本质是一个叫做task_struct的结构体,里边包括管理进程所需的各种信息,其中有一个结构体叫做file ,我们将它叫做文件描述符表,里边有一个整形索引表,用于存储文件描述符。

内核为每一个进程维护了一个文件描述符表,索引表中的值都是从0开始的,所以在不同的进程中你会看到相同的文件描述符,但是它们指向的不一定是同一个磁盘文件。

知识小科普:

Linux中用户操作的每个终端都被视作一个设备文件, 当前操作的终端文件可以使用 /dev/tty表示。

  • 打开的最大文件数

    每一个进程对应的文件描述符表能够存储的打开的文件数是有限制的, 默认为1024个,这个默认值是可以修改的,支持打开的最大文件数据取决于操作系统的硬件配置。

  • 默认分配的文件描述符

    当一个进程被启动之后,内核PCB的文件描述符表中就已经分配了三个文件描述符,这三个文件描述符对应的都是当前启动这个进程的终端文件(Linux中一切皆文件,终端就是一个设备文件,在 /dev 目录中)

    • STDIN_FILENO:标准输入,可以通过这个文件描述符将数据输入到终端文件中,宏值为0。
    • STDOUT_FILENO:标准输出,可以通过这个文件描述符将数据通过终端输出出来,宏值为1。
    • STDERR_FILENO:标准错误,可以通过这个文件描述符将错误信息通过终端输出出来,宏值为2。

    这三个默认分配的文件描述符是可以通过close()函数关闭掉,但是关闭之后当前进程也就不能和当前终端进行输入或者输出的信息交互了。

  • 给新打开的文件分配文件描述符

    • 因为进程启动之后,文件描述符表中的0,1,2就被分配出去了,因此从3开始分配
    • 在进程中每打开一个文件,就会给这个文件分配一个新的文件描述符,比如:
      • 通过open()函数打开 /hello.txt,文件描述符 3 被分配给了这个文件,保持这个打开状态,再次通过open()函数打开 /hello.txt,文件描述符 4 被分配给了这个文件,也就是说一个进程中不同的文件描述符打开的磁盘文件可能是同一个。
      • 通过open()函数打开 /hello.txt,文件描述符 3 被分配给了这个文件,将打开的文件关闭,此时文件描述符3就被释放了。再次通过open()函数打开 /hello.txt,文件描述符 3 被分配给了这个文件,也就是说打开的新文件会关联文件描述符表中最小的没有被占用的文件描述符。

    总结:

    1. 每个进程对应的文件描述符表默认支持打开的最大文件数为 1024,可以修改
    2. 每个进程的文件描述符表中都已经默认分配了三个文件描述符,对应的都是当前终端文件(/dev/tty)
    3. 每打开新的文件,内核会从进程的文件描述符表中找到一个空闲的没有别占用的文件描述符与其进行关联
    4. 文件描述符表中不同的文件描述符可以对应同一个磁盘文件
    5. 每个进程文件描述符表中的文件描述符值是唯一的,不会重复
    文件描述符的创建和使用