Skip to content

读书笔记-高级C&C++编译技术:进程内存模型

约 1439 字大约 5 分钟

CC++

2025-08-07

进程的内存模型

无论在什么平台上,一个完整的进程都应该具有以下部分:

  • 代码段(.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相互冲突,则按照链接顺序选择!

单例问题

显而易见地,我们不应当将单例放置于静态库中,因为这会导致不可避免的单例重复,放置在动态库中,因其保证了内存中每个动态库只有一份,所以单例也只有一份。