读书笔记-高级C&C++编译技术:进程内存模型
进程的内存模型
无论在什么平台上,一个完整的进程都应该具有以下部分:
- 代码段(
.text*
) - 数据段(包括
.data
、.bss
和.rodata
等) - 堆区
- 栈区
- 内核区域
在Linux中,进程的内存空间看起来是这样的(由高地址到低地址):
区域 | 内容 |
---|---|
内核 | 控制程序执行的操作系统功能 |
栈 | 环境变量、argv、argc、main函数的局部变量,其他函数的局部变量(向下增长) |
闲置 | 栈的增长空间 |
共享内存 | 动态链接库 |
闲置 | 堆的增长空间 |
堆 | 需要申请的大内存空间(向上增长) |
数据段 | 初始化数据,未初始化数据 |
代码段 | 静态链接库函数、其他程序函数、main函数、启动例程(crt0.o) |
我们可以用一个例程简单验证一下上面的模型:
#include <stdio.h>
#include <stdlib.h>
static int gval = 10;
int
main(int argc, char** argv)
{
int* hval = (int*)malloc(sizeof(int));
printf("in stack:\n");
printf("&argc: %p\n", &argc);
printf("&argv: %p\n", argv);
printf("&&hval: %p\n", &hval);
printf("in shared:\n");
printf("&printf: %p\n", printf);
printf("in heap:\n");
printf("&hval: %p\n", hval);
printf("in data:\n");
printf("&gval: %p\n", &gval);
printf("in text:\n");
printf("&main: %p\n", main);
free(hval);
}
在无优化、调试模式(-O0 -g
)下编译,运行结果可能是(注意,这里的&符号不代表我们取实际程序中的符号地址):
in stack:
&argc: 0x7ffe19d8478c
&argv: 0x7ffe19d848b8
&&hval: 0x7ffe19d84790
in shared:
&printf: 0x7fdd6685b6c0
in heap:
&hval: 0x372202a0
in data:
&gval: 0x404010
in text:
&main: 0x401166
可以看到,他们的地址确实是按照上述模型分布的。
有读者可能会疑问为什么
argv
的地址更高,其实是因为我们传递argc
使用的是值传递,所以实际上argc
的生命周期是main
函数,创建的时间自然比argv
要晚,如果我们查看的是argv
这个指针的地址,那么此时三者应当是按顺序的。
静态库
事实上,静态库就是打包的目标文件集合,链接一个静态库的含义就是将这些目标文件重新拆开,与当前项目的目标文件一起链接,我们可以使用ar
工具从目标文件创建静态库:
ar rcs libxxx.a filea.o fileb.o ...
也可以使用ar
对静态库进行修改:
ar t libxxx.a # 查看静态库中的文件
ar x libxxx.a # 提取静态库中的文件
ar r libxxx.a filen.o # 加入新文件到静态库
ar d libxxx.a filex.o # 从静态库中删除文件
动态库
静态库有显而易见的二进制膨胀问题,为了解决该问题,我们需要引入动态库,它在程序运行时动态加载到进程的内存空间中(使用内存映射),要实现动态库的动态装载,我们需要保证:
- 用户程序可以找到动态库的ABI符号
- 动态库可以找到自己的ABI符号
为此,我们实现了两种技术:
装载时重定位(LTR)
在加载动态库时修补动态库的.text
段,硬编码内存中外部符号的位置,其会产生如下问题:
- 因为进程内存空间的不同,针对每一个程序都需要将动态库修补成不同的样子(使用不同的绝对地址),导致多个相同的动态库被载入内存
- 对于动态库中的每一个符号引用都需要进行修补,在用户程序引入很多动态库时,加载时间陡然增长
- 可写的
.text
段会造成安全问题
位置无关代码(PIC)
现在,我们引入一个GOT
表(全局偏移量表):对所有外部符号,我们都不直接访问符号,而是通过GOT
表作为跳板访问符号(GOT
表和代码段的距离在编译期已知)。这样,我们在装载时只需要修改这个GOT
表即可,避免了修改.text
段的危险行为,除此之外,我们将变化封装在了GOT表内,因此我们可以简单地复用动态库的其他部分,包括代码段和数据段等。
为了正确调用外部函数,我们还引入了
PLT
表,在第一次调用外部函数时,我们会先跳转到PLT
表,其负责解析外部函数的实际位置,并设置好GOT
表对应的符号关系,并在下次调用时经由PLT
直接使用GOT
表项。
除此之外,PIC
既适用于解析自己的符号,也适用于解析来自其加载的库的引用,可以应用同一套方案解决我们的两个问题。
动态库的符号冲突
当链接动态库的程序发生符号冲突时,链接器一般会按照一定优先级抉择符号:
- 用户二进制文件符号
- 动态库可见符号
- 静态符号
事实上只有前二者会发生冲突,如果冲突发生在用户二进制文件符号之间(非static
),那么毋庸置疑,编译期即会出现错误,如果用户二进制文件符号与动态库ABI冲突,则优先选择用户二进制文件符号,如果两个动态库的ABI相互冲突,则按照链接顺序选择!
单例问题
显而易见地,我们不应当将单例放置于静态库中,因为这会导致不可避免的单例重复,放置在动态库中,因其保证了内存中每个动态库只有一份,所以单例也只有一份。