运行时内存布局(Runtime Memory Layout)
运行时内存布局(Runtime Memory Layout)
运行时内存的布局直接影响着我们如何生成代码、调试程序以及管理对象的生命周期。例如,全局变量的地址如何确定?函数返回后,局部变量为什么不能继续使用?malloc 返回的内存又由谁释放?这些问题都与运行时内存有关。
现代操作系统通常为每个进程提供独立的虚拟地址空间。程序使用的是虚拟地址,由处理器和操作系统共同完成到物理内存的映射。因此,两个进程中数值相同的地址,通常并不指向同一份数据;同一进程中的线程则共享地址空间。
本文以 Linux 上采用 ELF 格式的 C/C++ 程序为主要示例。其他操作系统、处理器和语言运行时的布局可能不同,下面的区域名称、权限和地址顺序也不是语言标准的保证。
内存布局总览
理解内存布局时,要先区分三个层次:
- ELF 节(section):编译器和链接器组织文件内容的单位,例如
.text、.rodata、.data、.bss。 - ELF 加载段(segment):加载器根据程序头将内容映射到内存的单位。一个
PT_LOAD段可以包含多个节,加载器并不是逐节加载程序。 - 运行时内存区域:进程实际拥有的虚拟内存映射,包括可执行文件、堆、栈、共享库和匿名映射等。日常所说的“代码段”“数据段”往往是对这类用途的概括。
先通过交互图观察各区域:点击区域可查看其内容、权限和生命周期,也可以从下拉框选择具体对象,对照指针本身与所指对象的位置。
点击内存区域查看详情,或选择一个 C 对象观察它通常存放在哪里。也可用 Tab 和 Enter 操作。
代码区
保存编译后的机器指令,处理器通过控制流转移执行函数。
- 存储内容
- 函数的机器指令。
- 常见权限
- 通常可读、可执行,不可写。
- 生命周期
- 通常随所属可执行文件或共享库映射存在。
- 典型示例
int add(int a, int b) { return a + b; }
理解要点:调用函数不会把函数指令复制到栈上。JIT 生成的代码也可能位于匿名映射中。
R = 读,W = 写,X = 执行。图中高度不表示容量,顺序和增长方向仅为典型示意;真实地址受平台、链接方式和 ASLR 影响,区域之间也可能有空洞。
交互图中的顺序只用于建立直观印象,不能据此计算地址。地址空间中还存在未映射的空洞,ASLR(地址空间布局随机化)也会改变各区域的位置。尤其是 64 位系统,地址空间很大,堆和栈并不是两个必然相向增长、最终相遇的连续区域。
| 区域 | 典型内容 | 常见权限 | 生命周期与管理者 |
|---|---|---|---|
代码区 .text | 编译生成的机器指令 | 读、执行 | 随可执行文件或共享库映射存在 |
只读数据区 .rodata | 字符串字面量、部分常量和常量表 | 只读 | 通常随所属模块存在 |
已初始化数据区 .data | 带非零常量初值的全局变量、静态变量 | 读、写 | 主程序中的对象通常具有程序级存储期 |
零初始化区 .bss | 未显式初始化或零初始化的全局、静态变量 | 读、写 | 与其他静态存储对象相同 |
| 堆及动态分配区域 | malloc、new 等分配的对象 | 通常读、写 | 由分配器和程序管理,或由垃圾回收器管理 |
| 栈 | 栈帧、溢出到内存的局部值、部分调用数据 | 通常读、写 | 每个线程独立使用,由调用过程维护 |
| 内存映射区域 | 共享库、映射文件、匿名内存等 | 由映射属性决定 | 由加载器、运行时或 mmap/munmap 管理 |
| 线程局部存储 TLS | 每个线程独有的静态存储对象 | 通常读、写 | 与所属线程关联 |
这些权限是常见配置。例如,重定位完成后,加载器可以把部分原本可写的数据页改为只读;JIT 编译器也需要专门管理动态生成代码的可写与可执行权限。
代码区:程序要执行的指令
代码区通常对应 ELF 中的 .text 节,保存编译后的机器指令。调用一个普通函数,主要是按照调用约定准备参数,再将控制流转移到该函数的指令地址,并不会把函数代码复制到栈上。
代码页通常可读、可执行而不可写,以避免普通数据写入意外破坏指令。同一个可执行文件或共享库的只读代码页,可以由多个进程共享底层物理页,而各进程仍保有独立的虚拟地址映射。
代码区通常随模块加载而建立,随模块卸载或进程结束而撤销。JIT 生成的指令则可能位于匿名映射中,并不一定属于文件的 .text 节。
只读数据区:字面量与常量表
只读数据区通常对应 .rodata 节,可以保存字符串字面量、查找表以及不需要修改的静态数据。
const char *message = "hello";
char buffer[] = "hello";
如果这两个定义位于文件作用域,message 本身是一个可修改的全局指针,通常放在可写的数据区域;它指向的字符串字面量通常在只读区域。buffer 则是一个可写数组,其各个字符属于数组自身,通常在 .data 中。
因此,指针存在哪里,与它指向的对象存在哪里,是两个问题。试图修改字符串字面量属于未定义行为,常见结果是触发内存保护异常。
const 也不等于“一定存放在 .rodata”。局部 const 对象可能位于栈上、寄存器中,或者被优化掉;某些包含地址且需要重定位的只读对象,也可能先置于可写区域,再由加载器收紧权限。
已初始化数据区:带初值的可写对象
.data 通常保存具有静态存储期、带非零常量初值的可写对象,例如:
int global_count = 10;
static int file_count = 20;
void count(void) {
static int calls = 1;
++calls;
}
这些初始值需要记录在可执行文件中,加载时据此建立内存中的初始内容。程序运行后可以修改这些对象,普通私有数据映射中的修改不会直接改写磁盘上的可执行文件。
函数内的 static 变量也不是普通栈变量。calls 的名字只能在该函数的作用域内使用,但其存储会跨越多次函数调用保留。这里要区分作用域与存储期:前者决定名字在哪里可见,后者决定存储存在多久。
C++ 中需要动态初始化的全局或静态对象,不能简单理解为“完整初值都写在 .data 里”。其存储可能先被零初始化,再由启动代码或首次执行到声明处时的初始化代码完成构造。
零初始化区:.bss 为什么节省文件空间
.bss 通常保存没有显式初始化,或者初始化为零的全局变量和静态变量:
int total;
static int errors = 0;
static char workspace[1024 * 1024];
这类对象在程序开始使用前会被零初始化。为了表示一百万个零,没有必要在可执行文件里真的写入一百万个零字节。ELF 的 .bss 通常采用 SHT_NOBITS 类型,记录大小和属性,但不保存对应的数据字节;加载段的内存大小可以大于其文件内容大小,由加载过程提供额外的零填充空间。
因此,较大的 .bss 会增加运行时所需的虚拟内存,却不会等量增加可执行文件大小。物理页通常按需建立,声明一个大数组也不意味着启动时立即独占同等大小的物理内存。
这里的零初始化规则不适用于普通未初始化的自动局部变量。读取这类局部变量的未确定值可能导致未定义行为,不能假定它们也是零。
堆:由程序控制生命周期的动态对象
当对象大小在运行时才确定,或者对象需要在创建它的函数返回后继续存在时,通常会使用动态分配。
int *make_value(void) {
int *p = malloc(sizeof *p); /* 需要包含 <stdlib.h> */
if (p != NULL) {
*p = 42;
}
return p;
}
局部指针变量 p 通常位于寄存器或栈上;它指向的 int 位于动态分配区域。函数返回后,局部变量的生命周期结束,但分配的对象仍然存在,调用者最终需要用 free 释放它。
从语言和分配器的角度,这些对象通常统称为“堆对象”。但在 Linux 的内存映射中,它们不一定全部位于标记为 [heap] 的区域:分配器可以扩展传统 brk 堆,也可以通过 mmap 获得独立的匿名映射,再把内存切分成小块。
分配器往往会复用空闲块,所以每次 malloc 不一定执行系统调用,每次 free 也不一定立即把内存归还给操作系统。具体策略由分配器实现、配置和运行状态决定,不能用一个固定的申请大小概括所有情况。
常见错误包括忘记释放造成的内存泄漏、释放后继续访问、重复释放,以及越过分配范围读写。C++ 中的 new/delete 还涉及对象构造与析构,应配对使用对应的分配和释放方式。更多分配细节见堆。
栈:组织函数调用的临时存储
每个线程通常都有自己的栈。函数调用时,编译器根据调用约定生成代码,在需要时调整栈指针,建立栈帧;返回时恢复调用者的状态。这个过程通常不需要操作系统为每次调用单独分配一块内存。
栈帧可能包含局部数组、无法一直保存在寄存器中的临时值、保存的寄存器、部分参数,以及返回所需的信息。具体内容取决于处理器和 ABI:例如,x86 的调用指令会把返回地址压栈,而另一些体系结构会先将返回地址放入链接寄存器。
局部变量、参数和返回值并不一定存放在栈上。它们可能通过寄存器传递或被优化掉,叶子函数甚至可能不需要常规栈帧。返回值的传递位置也由调用约定决定。
普通自动局部对象的生命周期通常在离开所属作用域时结束。返回它的地址,或者把该地址保存到外部供以后使用,会留下悬空指针。递归过深或局部数组过大,则可能超过栈的限制,引发栈溢出。
在常见的 x86/x86-64 Linux 环境中,栈向低地址增长,但这不是所有平台的统一规定。线程栈常配有不可访问的保护页,用于帮助发现越界。不同线程的栈虽然分别使用,却仍处于同一进程地址空间,并没有进程级隔离。具体调用过程见栈帧。
内存映射区域:文件、共享库和匿名内存
Linux 的 mmap 可以在进程地址空间中建立新的映射。映射区域并不等同于“共享内存”,它有多种用途:
- 文件映射:把文件内容映射到虚拟地址,通过内存访问读写对应内容。
- 共享库映射:动态加载器分别映射共享库的代码、只读数据和可写数据,按需设置权限。
- 匿名映射:没有普通文件作为后备存储,可用于动态分配、线程栈及运行时内部数据等。
- 共享内存:多个进程映射同一共享对象,访问同一份底层数据,用于进程间通信。
MAP_PRIVATE 建立私有映射,写入通常通过**写时复制(Copy-on-Write)**形成私有页面,不会把这些修改写回原文件。MAP_SHARED 则允许对同一映射对象的修改被其他相应映射观察到;文件回写时机是另一个问题,不能把普通内存写入等同于数据已经持久化到磁盘。
共享内存只解决“访问同一份数据”的问题,并不自动解决并发问题。多个进程同时读写时,仍然需要适当的同步机制。
映射通常通过 munmap 撤销,或在进程退出时由操作系统清理。解除映射后,原来指向该范围的指针也就失效了。
线程局部存储:每个线程一份的变量
全局变量通常由进程内的线程共享,但有些状态需要每个线程独立保存。C11 的 _Thread_local 和 C++ 的 thread_local 就用于表达这类需求。
_Thread_local int thread_error;
每个线程访问 thread_error 时,得到的是自己的实例。它既不是所有线程共用的一份普通全局变量,也不是每次函数调用重新创建的自动局部变量。
ELF 常用 .tdata 和 .tbss 描述 TLS 的初始化模板及零初始化部分,由加载器和线程运行时为各线程建立实际存储。TLS 不必表现为地址图中一个独立、连续的大区域;访问它还需要编译器生成符合平台 TLS 模型的寻址代码。
内核空间:受权限保护的系统内存
内核代码、内核数据和设备相关映射由操作系统管理。用户程序不能像访问自己的数组一样直接读写内核地址,而是通过系统调用请求内核执行文件操作、创建映射等服务。
传统布局图经常在进程地址空间顶部画出内核区域,用来表示用户态与内核态的边界。但这并不意味着每个进程都有一份完整的内核副本,也不意味着用户态页表始终映射了整个内核。启用 KPTI 等机制时,用户态与内核态可以使用不同的页表映射范围。
因此,内核空间应理解为受特权级保护的系统地址范围,不能与用户程序的代码、数据、堆、栈视为同样可访问的内存。
虚拟地址如何对应物理内存
操作系统通过页表描述虚拟页到物理页的映射,以及页面的读、写、执行和访问级别等权限。现代平台通常使用多级页表,处理器的 MMU 完成地址翻译,TLB 缓存近期使用的翻译结果。
一次虚拟内存访问不一定需要完整遍历页表:TLB 命中时可以快速获得映射。但 TLB 未命中会增加地址翻译开销;如果页面尚未建立映射或访问违反权限,还可能产生缺页异常(page fault)。
缺页异常不一定表示程序出错。操作系统可能按需分配零页、从文件载入页面,或处理写时复制后继续执行;非法地址或权限错误才可能导致进程收到 SIGSEGV 等信号。因此,虚拟地址空间大小、已映射大小和实际驻留的物理内存大小不是同一个指标。
实验:观察变量、ELF 节与进程映射
下面的程序把几类对象放到一起。保存为 layout.c,在 Linux 上使用 GCC 编译:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int initialized = 42;
int zero_initialized;
static const char readonly_text[] = "read only";
_Thread_local int thread_value = 7;
int main(void) {
int local = 1;
static int static_local = 2;
int *dynamic = malloc(sizeof *dynamic);
if (dynamic == NULL) {
return EXIT_FAILURE;
}
*dynamic = 3;
printf("pid = %ld\n", (long)getpid());
printf("initialized = %p\n", (void *)&initialized);
printf("zero_initialized = %p\n", (void *)&zero_initialized);
printf("readonly_text = %p\n", (void *)readonly_text);
printf("static_local = %p\n", (void *)&static_local);
printf("thread_value = %p\n", (void *)&thread_value);
printf("local = %p\n", (void *)&local);
printf("dynamic (pointer) = %p\n", (void *)&dynamic);
printf("dynamic (object) = %p\n", (void *)dynamic);
puts("Press Enter to exit.");
(void)getchar();
free(dynamic);
return EXIT_SUCCESS;
}
gcc -std=c11 -O0 -g -Wall -Wextra layout.c -o layout
readelf -S layout # 查看节表:.text、.rodata、.data、.bss、TLS 等
readelf -l layout # 查看程序头,以及节与加载段的对应关系
size layout # 查看 text、data、bss 等大小的汇总
./layout
程序等待输入时,在另一个终端执行 cat /proc/<pid>/maps,把 <pid> 替换为程序输出的进程号,就可以对照各地址所属的映射。权限字段中的 r、w、x 分别表示读、写、执行,p、s 分别表示私有与共享映射。
观察时可以重点比较:
initialized与zero_initialized对应的 ELF 节不同,但可能出现在同一个可写映射中,不能只靠maps判断节边界。static_local不随函数的普通局部变量一起放在栈上;dynamic指针自身与它指向的对象也通常位于不同区域。readonly_text所在页面通常不可写;TLS 变量有自己的寻址方式,不能仅凭它靠近哪个地址就认定它属于普通堆或栈。- 多次运行时,ASLR 可能使地址变化。编译优化、链接选项和分配器实现也会影响观察结果,示例中的地址关系不应成为程序的依赖。
对于编译器实现,这些区别最终会落实为不同的代码生成方式:函数生成指令,全局和静态对象生成数据及初始化代码,自动局部值参与寄存器分配或栈帧布局,动态对象通过运行时分配器创建,线程局部对象则使用 TLS 寻址。理解对象的存储期与访问方式,才能正确选择它在运行时的表示。