6.4 进程虚存空间分布
6.4.1 ELF文件链接视图和执行视图
前面例子的可执行文件中只有一个代码段,所以它被操作系统装载至进程地址空间之后,相对应的只有一个VMA。不过实际情况会比这复杂得多,在一个正常的进程中,可执行文件中包含的往往不止代码段,还有数据段、BSS等,所以映射到进程虚拟空间的往往不止一个段。
当段的数量增多时,就会产生空间浪费的问题。因为我们知道,ELF文件被映射时,是以系统的页长度作为单位的,那么每个段在映射时的长度应该都是系统页长度的整数倍;如果不是,那么多余部分也将占用一个页。一个ELF文件中往往有十几个段,那么内存空间的浪费是可想而知的。有没有办法尽量减少这种内存浪费呢?
当我们站在操作系统装载可执行文件的角度看问题时,可以发现它实际上并不关心可执行文件各个段所包含的实际内容,操作系统只关心一些跟装载相关的问题,最主要的是段的权限(可读、可写、可执行)。ELF文件中,段的权限往往只有为数不多的几种组合,基本上是三种:
- 以代码段为代表的权限为可读可执行的段。
- 以数据段和BSS段为代表的权限为可读可写的段。
- 以只读数据段为代表的权限为只读的段。
那么我们可以找到一个很简单的方案就是:对于相同权限的段,把它们合并到一起当作一个段进行映射。比如有两个段分别叫".text"和".init",它们包含的分别是程序的可执行代码和初始化代码,并且它们的权限相同,都是可读并且可执行的。假设.text为4 097字节,.init为512字节,这两个段分别映射的话就要占用三个页面,但是,如果将它们合并成一起映射的话只须占用两个页面,如图6-7所示。

图6-7 ELF Segment
ELF可执行文件引入了一个概念叫做"Segment",一个"Segment"包含一个或多个属性类似的"Section"。正如我们上面的例子中看到的,如果将".text"段和".init"段合并在一起看作是一个"Segment",那么装载的时候就可以将它们看作一个整体一起映射,也就是说映射以后在进程虚存空间中只有一个相对应的VMA,而不是两个,这样做的好处是可以很明显地减少页面内部碎片,从而节省了内存空间。
我们很难将"Segment"和"Section"这两个词从中文的翻译上加以区分,因为很多时候Section也被翻译成"段",回顾第2章,我们也没有很严格区分这两个英文词汇和两个中文词汇"段"和"节"之间的相互翻译。很明显,从链接的角度看,ELF文件是按"Section"存储的,事实也的确如此;从装载的角度看,ELF文件又可以按照"Segment"划分。我们在这里就对"Segment"不作翻译,一律按照原词。
"Segment"的概念实际上是从装载的角度重新划分了ELF的各个段。在将目标文件链接成可执行文件的时候,链接器会尽量把相同权限属性的段分配在同一空间。比如可读可执行的段都放在一起,这种段的典型是代码段;可读可写的段都放在一起,这种段的典型是数据段。在ELF中把这些属性相似的、又连在一起的段叫做一个"Segment",而系统正是按照"Segment"而不是"Section"来映射可执行文件的。
下面的例子是一个很小的程序,程序本身是不停地循环执行"sleep"操作,除非用户发信号给它,否则就一直运行。它的源代码如下:
#include <stdlib.h>
int main()
{
while(1) {
sleep(1000);
}
return 0;
}
我们使用静态连接的方式将其编译连接成可执行文件,然后得到的可执行文件"SectionMapping.elf"是一个Linux下很典型的可执行文件:
$gcc -static SectionMapping.c -o SectionMapping.elf
使用readelf可以看到,这个可执行文件中总共有33个段(Section):
$readelf -S SectionMapping.elf
There are 33 section headers, starting at offset 0x74594:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .note.ABI-tag NOTE 080480d4 0000d4 000020 00 A 0 0 4
[ 2] .init PROGBITS 080480f4 0000f4 000017 00 AX 0 0 4
[ 3] .text PROGBITS 08048110 000110 055948 00 AX 0 0 16
[ 4] __libc_freeres_fn PROGBITS 0809da60 055a60 000a8b 00 AX 0 0 16
[ 5] .fini PROGBITS 0809e4ec 0564ec 00001c 00 AX 0 0 4
[ 6] .rodata PROGBITS 0809e520 056520 0169e8 00 A 0 0 32
[ 7] __libc_subfreeres PROGBITS 080b4f08 06cf08 00002c 00 A 0 0 4
[ 8] __libc_atexit PROGBITS 080b4f34 06cf34 000004 00 A 0 0 4
[ 9] .eh_frame PROGBITS 080b4f38 06cf38 003a0c 00 A 0 0 4
[10] .gcc_except_table PROGBITS 080b8944 070944 0000a1 00 A 0 0 1
[11] .tdata PROGBITS 080b99e8 0709e8 000010 00 WAT 0 0 4
[12] .tbss NOBITS 080b99f8 0709f8 000018 00 WAT 0 0 4
[13] .ctors PROGBITS 080b99f8 0709f8 000008 00 WA 0 0 4
[14] .dtors PROGBITS 080b9a00 070a00 00000c 00 WA 0 0 4
[15] .jcr PROGBITS 080b9a0c 070a0c 000004 00 WA 0 0 4
[16] .data.rel.ro PROGBITS 080b9a10 070a10 00002c 00 WA 0 0 4
[17] .got PROGBITS 080b9a3c 070a3c 000008 04 WA 0 0 4
[18] .got.plt PROGBITS 080b9a44 070a44 00000c 04 WA 0 0 4
[19] .data PROGBITS 080b9a60 070a60 000720 00 WA 0 0 32
[20] .bss NOBITS 080ba180 071180 001ad4 00 WA 0 0 32
[21] __libc_freeres_pt NOBITS 080bbc54 071180 000014 00 WA 0 0 4
[22] .comment PROGBITS 00000000 071180 002df0 00 0 0 1
[23] .debug_aranges PROGBITS 00000000 073f70 000058 00 0 0 8
[24] .debug_pubnames PROGBITS 00000000 073fc8 000025 00 0 0 1
[25] .debug_info PROGBITS 00000000 073fed 0001ad 00 0 0 1
[26] .debug_abbrev PROGBITS 00000000 07419a 000066 00 0 0 1
[27] .debug_line PROGBITS 00000000 074200 00013d 00 0 0 1
[28] .debug_str PROGBITS 00000000 07433d 0000bb 01 MS 0 0 1
[29] .debug_ranges PROGBITS 00000000 0743f8 000048 00 0 0 8
[30] .shstrtab STRTAB 00000000 074440 000152 00 0 0 1
[31] .symtab SYMTAB 00000000 074abc 007ab0 10 32 898 4
[32] .strtab STRTAB 00000000 07c56c 006e68 00 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings)
I (info), L (link order), G (group), x (unknown)
O (extra OS processing required) o (OS specific), p (processor specific)
我们可以使用readelf命令来查看ELF的"Segment"。正如描述"Section"属性的结构叫做段表,描述"Segment"的结构叫程序头(Program Header),它描述了ELF文件该如何被操作系统映射到进程的虚拟空间:
$ readelf -l SectionMapping.elf
Elf file type is EXEC (Executable file)
Entry point 0x8048110
There are 5 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
LOAD 0x000000 0x08048000 0x08048000 0x709e5 0x709e5 R E 0x1000
LOAD 0x0709e8 0x080b99e8 0x080b99e8 0x00798 0x02280 RW 0x1000
NOTE 0x0000d4 0x080480d4 0x080480d4 0x00020 0x00020 R 0x4
TLS 0x0709e8 0x080b99e8 0x080b99e8 0x00010 0x00028 R 0x4
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00 .note.ABI-tag .init .text __libc_freeres_fn .fini .rodata __libc_subfreeres __libc_atexit .eh_frame .gcc_except_table
01 .tdata .ctors .dtors .jcr .data.rel.ro .got .got.plt .data .bss __libc_freeres_ptrs
02 .note.ABI-tag
03 .tdata .tbss
04
我们可以看到,这个可执行文件中共有5个Segment。从装载的角度看,我们目前只关心两个"LOAD"类型的Segment,因为只有它是需要被映射的,其他的诸如"NOTE"、"TLS"、"GNU_STACK"都是在装载时起辅助作用的,我们在这里不详细展开。可以用图6-8来表示"SectionMapping.elf"可执行文件的段与进程虚拟空间的映射关系。
由图6-8可以发现,"SectionMapping.elf"被重新划分成了三个部分,有一些段被归入可读可执行的,它们被统一映射到一个VMA0;另外一部分段是可读可写的,它们被映射到了VMA1;还有一部分段在程序装载时没有被映射的,它们是一些包含调试信息和字符串表等段,这些段在程序执行时没有用,所以不需要被映射。很明显,所有相同属性的"Section"被归类到一个"Segment",并且映射到同一个VMA。

图6-8 ELF可执行文件与进程虚拟空间映射关系
所以总的来说,"Segment"和"Section"是从不同的角度来划分同一个ELF文件。这个在ELF中被称为不同的视图(View),从"Section"的角度来看ELF文件就是链接视图(Linking View),从"Segment"的角度来看就是执行视图(Execution View)。当我们在谈到ELF装载时,"段"专门指"Segment";而在其他的情况下,"段"指的是"Section"。
ELF可执行文件中有一个专门的数据结构叫做程序头表(Program Header Table)用来保存"Segment"的信息。因为ELF目标文件不需要被装载,所以它没有程序头表,而ELF的可执行文件和共享库文件都有。跟段表结构一样,程序头表也是一个结构体数组,它的结构体如下:
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;
Elf32_Phdr结构体的几个成员与前面我们使用"readelf -l"打印文件头表显示的结果一一对应。我们来看Elf32_Phdr结构的各个成员的基本含义,如表6-2所示。

表6-2
对于"LOAD"类型的"Segment"来说,p_memsz的值不可以小于p_filesz,否则就是不符合常理的。但是,如果p_memsz的值大于p_filesz又是什么意思呢?如果p_memsz大于p_filesz,就表示该"Segment"在内存中所分配的空间大小超过文件中实际的大小,这部分"多余"的部分则全部填充为"0"。这样做的好处是,我们在构造ELF可执行文件时不需要再额外设立BSS的"Segment"了,可以把数据"Segment"的p_memsz扩大,那些额外的部分就是BSS。因为数据段和BSS的唯一区别就是:数据段从文件中初始化内容,而BSS段的内容全都初始化为0。这也就是我们在前面的例子中只看到了两个"LOAD"类型的段,而不是三个,BSS已经被合并到了数据类型的段里面。
6.4.2 堆和栈
在操作系统里面,VMA除了被用来映射可执行文件中的各个"Segment"以外,它还可以有其他的作用,操作系统通过使用VMA来对进程的地址空间进行管理。我们知道进程在执行的时候它还需要用到栈(Stack)、堆(Heap)等空间,事实上它们在进程的虚拟空间中的表现也是以VMA的形式存在的,很多情况下,一个进程中的栈和堆分别都有一个对应的VMA。在Linux下,我们可以通过查看"/proc"来查看进程的虚拟空间分布:
$ ./SectionMapping.elf &
[1] 21963
$ cat /proc/21963/maps
08048000-080b9000 r-xp 00000000 08:01 2801887 ./SectionMapping.elf
080b9000-080bb000 rwxp 00070000 08:01 2801887 ./SectionMapping.elf
080bb000-080de000 rwxp 080bb000 00:00 0 [heap]
bf7ec000-bf802000 rw-p bf7ec000 00:00 0 [stack]
ffffe000-fffff000 r-xp 00000000 00:00 0 [vdso]
上面的输出结果中:第一列是VMA的地址范围;第二列是VMA的权限,"r"表示可读,"w"表示可写,"x"表示可执行,"p"表示私有(COW, Copy on Write),"s"表示共享。第三列是偏移,表示VMA对应的Segment在映像文件中的偏移;第四列表示映像文件所在设备的主设备号和次设备号;第五列表示映像文件的节点号。最后一列是映像文件的路径。
我们可以看到进程中有5个VMA,只有前两个是映射到可执行文件中的两个Segment。另外三个段的文件所在设备主设备号和次设备号及文件节点号都是0,则表示它们没有映射到文件中,这种VMA叫做匿名虚拟内存区域(Anonymous Virtual Memory Area)。我们可以看到有两个区域分别是堆(Heap)和栈(Stack),它们的大小分别为140 KB和88 KB。这两个VMA几乎在所有的进程中存在,我们在C语言程序里面最常用的malloc()内存分配函数就是从堆里面分配的,堆由系统库管理,我们在第10章会详细介绍关于堆的内容。栈一般也叫做堆栈,我们知道每个线程都有属于自己的堆栈,对于单线程的程序来讲,这个VMA堆栈就全都归它使用。另外有一个很特殊的VMA叫做"vdso",它的地址已经位于内核空间了(即大于0xC0000000的地址),事实上它是一个内核的模块,进程可以通过访问这个VMA来跟内核进行一些通信,这里我们就不具体展开了,有兴趣的读者可以去参考一些关于Linux内核模块的资料。
通过上面的例子,让我们小结关于进程虚拟地址空间的概念:操作系统通过给进程空间划分出一个个VMA来管理进程的虚拟空间;基本原则是将相同权限属性的、有相同映像文件的映射成一个VMA;一个进程基本上可以分为如下几种VMA区域:
- 代码VMA,权限只读、可执行;有映像文件。
- 数据VMA,权限可读写、可执行;有映像文件。
- 堆VMA,权限可读写、可执行;无映像文件,匿名,可向上扩展。
- 栈VMA,权限可读写、不可执行;无映像文件,匿名,可向下扩展。
当我们在讨论进程虚拟空间的"Segment"的时候,基本上就是指上面的几种VMA。现在再让我们来看一个常见进程的虚拟空间是怎么样的,如图6-9所示。

图6-9 ELF与Linux进程虚拟空间映射关系
细心的读者可能已经发现,我们在Linux的"/proc"目录里面看到的VMA2的结束地址跟原先预测的不一样,按照计算应该是0x080bc000,但实际上显示出来的是0x080bb000。这是怎么回事呢?这是因为Linux在装载ELF文件时实现了一种"Hack"的做法,因为Linux的进程虚拟空间管理的VMA的概念并非与"Segment"完全对应,Linux规定一个VMA可以映射到某个文件的一个区域,或者是没有映射到任何文件;而我们这里的第二个"Segment"要求是,前面部分映射到文件中,而后面一部分不映射到任何文件,直接为0,也就是说前面的从".tdata"段到".data"段部分要建立从虚拟空间到文件的映射,而".bss"和"__libcfreeres_ptrs"部分不要映射到文件。这样这两个概念就不完全相同了,所以Linux实际上采用了一种取巧的办法,它在映射完第二个"Segment"之后,把最后一个页面的剩余部分清0,然后调用内核中的do_brk(),把".bss"和"__libcfreeres_ptrs"的剩余部分放到堆段中。不过这种具体实现问题中的细节不是很关键,有兴趣的读者可以阅读位于Linux内核源代码"fs/Binfmt_elf.c"中的"load_elf_interp()"和"elf_map()"两个函数。
6.4.3 堆的最大申请数量
Linux下虚拟地址空间分给进程本身的是3GB(Windows默认是2GB),那么程序真正可以用到的有多少呢?我们知道,一般程序中使用malloc()函数进行地址空间的申请,那么malloc()到底最大可以申请多少内存呢?用下面这个小程序可以测试malloc最大内存申请数量:
#include <stdio.h>
#include <stdlib.h>
unsigned maximum = 0;
int main(int argc, char *argv[])
{
unsigned blocksize[] = { 1024 * 1024, 1024, 1 };
int i, count;
for(i = 0; i < 3; i++) {
for(count = 1;; count++) {
void *block = malloc( maximum + blocksize[i] * count);
if (block) {
maximum = maximum + blocksize[i] * count;
free(block);
} else {
break;
}
}
}
printf("maximum malloc size = %u bytes\n", maximum);
}
在我的Linux机器上,运行上面这个程序的结果大概是2.9 GB左右的空间;在Windows下运行这个程序的结果大概是1.5 GB。那么malloc的最大申请数量会受到哪些因素的影响呢?实际上,具体的数值会受到操作系统版本、程序本身大小、用到的动态/共享库数量、大小、程序栈数量、大小等,甚至有可能每次运行的结果都会不同,因为有些操作系统使用了一种叫做随机地址空间分布的技术(主要是出于安全考虑,防止程序受恶意攻击),使得进程的堆空间变小。关于进程的堆的相关内容,在本书的第4部分还会详细介绍。
6.4.4 段地址对齐
可执行文件最终是要被操作系统装载运行的,这个装载的过程一般是通过虚拟内存的页映射机制完成的。在映射过程中,页是映射的最小单位。对于Intel 80x86系列处理器来说,默认的页大小为4 096字节,也就是说,我们要映射将一段物理内存和进程虚拟地址空间之间建立映射关系,这段内存空间的长度必须是4 096的整数倍,并且这段空间在物理内存和进程虚拟地址空间中的起始地址必须是4 096的整数倍。由于有着长度和起始地址的限制,对于可执行文件来说,它应该尽量地优化自己的空间和地址的安排,以节省空间。我们就拿下面这个例子来看看,可执行文件在页映射机制中如何节省空间。假设我们有一个ELF可执行文件,它有三个段(Segment)需要装载,我们将它们命名为SEG0、SEG1和SEG2。每个段的长度、在文件中的偏移如表6-3所示。

表6-3
这是很常见的一种情况,就是每个段的长度都不是页长度的整数倍,一种最简单的映射办法就是每个段分开映射,对于长度不足一个页的部分则占一个页。通常ELF可执行文件的起始虚拟地址为0x08048000,那么按照这样的映射方式,该ELF文件中的各个段的虚拟地址和长度如表6-4所示。

表6-4
可以看到这种对齐方式在文件段的内部会有很多内部碎片,浪费磁盘空间。整个可执行文件的三个段的总长度只有12 014字节,却占据了5个页,即20 480字节,空间使用率只有58.6%。

图6-10 可执行文件段未合并情况
为了解决这种问题,有些UNIX系统采用了一个很取巧的办法,就是让那些各个段接壤部分共享一个物理页面,然后将该物理页面分别映射两次(见图6-10)。比如对于SEG0和SEG1的接壤部分的那个物理页,系统将它们映射两份到虚拟地址空间,一份为SEG0,另外一份为SEG1,其他的页都按照正常的页粒度进行映射。而且UNIX系统将ELF的文件头也看作是系统的一个段,将其映射到进程的地址空间,这样做的好处是进程中的某一段区域就是整个ELF文件的映像,对于一些须访问ELF文件头的操作(比如动态链接器就须读取ELF文件头)可以直接通过读写内存地址空间进行。从某种角度看,好像是整个ELF文件从文件最开始到某个点结束,被逻辑上分成了以4 096字节为单位的若干个块,每个块都被装载到物理内存中,对于那些位于两个段中间的块,它们将会被映射两次。现在让我们来看看在这种方法下,上面例子中ELF文件的映射方式如表6-5所示。

表6-5
在这种情况下,内存空间得到了充分的利用,我们可以看到,本来要用到5个物理页面,也就是20 480字节的内存,现在只有3个页面,即12 288字节。这种映射方式下,对于一个物理页面来说,它可能同时包含了两个段的数据,甚至可能是多于两个段,比如文件头、代码段、数据段和BSS段的长度加起来都没超过4 096字节,那么一个物理页面可能包含文件头、代码段、数据段和BSS段(见图6-11)。

图6-11 ELF文件段合并情况
因为段地址对齐的关系,各个段的虚拟地址就往往不是系统页面长度的整数倍了,有兴趣的读者也可以结合前面的例子思考一下,这些虚拟地址是怎么计算出来的。比如我们拿前面的程序"SectionMapping.elf"做例子,看看各个段的虚拟地址是怎么计算出来的。为什么VMA1的起始地址是0x080B99E8?而不是0x080B89E8或干脆是0x080B9000?
VMA0的起始地址是0x08048000,长度是0x709E5,所以它的结束地址是0x080B89E5。而VMA1因为跟VMA0的最后一个虚拟页面共享一个物理页面,并且映射两遍,所以它的虚拟地址应该是0x080B99E5,又因为段必须是4字节的倍数,则向上取整至0x080B99E8。
根据上面的段对齐方案,由此我们可以推算出一个规律那就是,在ELF文件中,对于任何一个可装载的"Segment",它的p_vaddr除以对齐属性的余数等于p_offset除以对齐属性的余数。比如前面例子中,第二个"Segment"的p_vaddr为0x080b99e8,对齐属性为0x1000字节,所以0x080b99e8 % 0x1000 = 0x9e8;而p_offset为0x0709e8,所以0x0709e8 % 0x1000 = 0x9e8。如何能推导出这条规律?请有兴趣的读者对照前面的对齐规则计算一下应该很快能得出结论。
6.4.5 进程栈初始化
我们知道进程刚开始启动的时候,须知道一些进程运行的环境,最基本的就是系统环境变量和进程的运行参数。很常见的一种做法是操作系统在进程启动前将这些信息提前保存到进程的虚拟空间的栈中(也就是VMA中的Stack VMA)。让我们来看看Linux的进程初始化后栈的结构,我们假设系统中有两个环境变量:
HOME=/home/user
PATH=/usr/bin
比如我们运行该程序的命令行是:
$ prog 123
并且我们假设堆栈段底部地址为0xBF802000,那么进程初始化后的堆栈就如图6-12所示。

图6-12 Linux进程初始堆栈
栈顶寄存器esp指向的位置是初始化以后堆栈的顶部,最前面的4个字节表示命令行参数的数量,我们的例子里面是两个,即"prog"和"123",紧接的就是分布指向这两个参数字符串的指针;后面跟了一个0;接着是两个指向环境变量字符串的指针,它们分别指向字符串"HOME=/home/user"和"PATH=/usr/bin";后面紧跟一个0表示结束。
进程在启动以后,程序的库部分会把堆栈里的初始化信息中的参数信息传递给main()函数,也就是我们熟知的main()函数的两个argc和argv两个参数,这两个参数分别对应这里的命令行参数数量和命令行参数字符串指针数组。