Appearance
地址空间
- 写作时间:
2026-03-30 - 当前字符:
10308
上一章讨论的是并发:多个执行流怎样共享数据,怎样避免竞态,怎样在事件循环或内核里组织并发。但这些讨论一直默认了一件更底层的事情:线程能读写变量,进程有自己的地址空间,锁保护的是一块明确可寻址的内存。现在要再往下追一层:这些地址本身是从哪里来的?
内存管理这一章会沿着这条问题链展开。本课先回答地址从哪来:程序中的地址怎样和物理内存建立关联,以及为什么早期的连续分配和分段方案都无法令人满意。下一课进入分页,用固定大小的页消除碎片,再用多级页表把页表本身的开销降下来。之后是虚拟内存的动态行为:按需分页、缺页异常和页面替换。再往后是内存映射,把 mmap、文件映射、交换和 OOM 串起来。最后进入内核内存分配,看内核自己怎样管理页帧和小对象。
地址绑定
程序运行时,CPU 最终要访问物理内存,而物理内存中的每个位置由一个物理地址(physical address)标识。但程序中的地址并不是物理地址:源代码用变量名和函数名,编译后变成数字地址,这些数字地址仍然不一定对应物理内存中的真实位置。从程序中的地址到物理地址,必须在某个环节完成转换。这个转换过程叫做地址绑定(address binding)。三种绑定方式本质都是在解决同一个问题:什么时候将程序中的地址"翻译"成真正能起作用的物理地址。程序从源代码到运行经历编译、加载、执行三个阶段,翻译可以发生在其中任何一个阶段,时机的不同决定了绑定方式的不同。最终我们会看到,主流的操作系统都选择了在运行时进行地址绑定。而更值得深思的是:内核为什么要提供这样一套运行时翻译地址的机制?
编译时绑定(compile-time binding)在编译阶段就确定程序使用的物理地址。编译器直接生成包含绝对物理地址的代码,因此程序只能加载到编译时预设的内存位置,否则所有地址都是错的。早期的 MS-DOS 程序和嵌入式系统固件就是这样工作的:编译器知道程序会被加载到固定地址(比如 0x0000),因此直接在代码中使用该地址。这种方式的局限性很明显:如果有两个程序都被编译为加载到地址 0x0000,它们就不能同时运行。
用 objdump 反汇编一个编译好的程序,可以直接看到编译时绑定的效果1:
c
// hello.c
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}bash
$ gcc -o hello hello.c
$ objdump -d hello | grep -A5 '<main>'
0000000000401126 <main>:
401126: 55 push %rbp
401127: 48 89 e5 mov %rsp,%rbp
40112a: bf 04 20 40 00 mov $0x402004,%edi
40112f: e8 fc fe ff ff call 401030 <puts@plt>
401134: b8 00 00 00 00 mov $0x0,%eaxmain 函数的地址 0x401126 在编译时就已经确定,写死在了二进制文件中。0x402004(字符串 "hello" 的地址)也是写死的绝对地址。这个程序必须被加载到这些地址对应的内存位置,否则所有引用都是错的。这就是编译时绑定:地址在编译阶段就已经决定了,没有任何调整余地。
加载时绑定(load-time binding)在程序加载到内存时确定地址。编译器不再生成绝对地址,而是生成相对于程序起始位置的偏移量。加载器在把程序载入内存时,根据实际加载位置修正所有地址引用,这个修正过程叫重定位(relocation)。用 -fPIE -pie 编译同一个程序,可以看到区别:
bash
$ gcc -fPIE -pie -o hello_pie hello.c
$ objdump -d hello_pie | grep -A5 '<main>'
0000000000001139 <main>:
1139: 55 push %rbp
113a: 48 89 e5 mov %rsp,%rbp
113d: 48 8d 05 c0 0e 00 00 lea 0xec0(%rip),%rax
1144: 48 89 c7 mov %rax,%rdi
1147: e8 e4 fe ff ff call 1030 <puts@plt>main 的地址变成了 0x1139。对比前面编译时绑定版本的 0x401126,这个数字小得不正常,因为它不是一个真正的内存地址,而是相对于程序起始位置的偏移量。程序实际运行时,加载器会选择一个基址(比如 0x555555554000),然后把偏移量加上去:0x555555554000 + 0x1139 = 0x555555555139,这才是 main 在内存中的真正地址。每次运行时加载器选的基址可能不同,所以同一个 PIE 程序每次运行 main 的地址都会变。加载时绑定比编译时绑定灵活:同一个程序可以加载到不同的内存位置。但加载完成后,程序在运行期间不能再移动。如果操作系统想把一个正在运行的进程移到另一块物理内存(比如做内存紧凑),就必须重新修正所有地址,开销很大。
加载器怎么知道 0x1139 是偏移量而不是绝对地址?
objdump 输出的 0x401126 和 0x1139 看起来都只是十六进制数字,格式上没有任何区别。CPU 执行指令时也不区分"这是偏移量"还是"这是绝对地址",它拿到什么地址就访问什么地址。
既然 CPU 不区分,那谁来区分?答案是加载器。编译器在生成二进制文件时,会在 ELF 文件头中写入一个类型标记。用 file 命令可以直接看到这个区别:
bash
$ file hello
hello: ELF 64-bit LSB executable, x86-64, ...
$ file hello_pie
hello_pie: ELF 64-bit LSB pie executable, x86-64, ...executable 表示文件中的地址是最终的虚拟地址,加载器直接把程序放到 ELF 头指定的位置。pie executable 表示文件中的地址是偏移量,加载器需要自己选择一个基址,然后加上偏移量得到真正的虚拟地址。加载器完成这些调整之后,CPU 执行指令时看到的已经是正确的虚拟地址了,整个过程对 CPU 透明。
运行时绑定(run-time binding)把地址翻译推迟到程序执行的每一条指令。前两种绑定方式都在程序开始执行之前就确定了最终的物理地址,而运行时绑定不同:程序运行过程中,CPU 发出的每一个内存地址都不是物理地址,而是虚拟地址(virtual address),也叫逻辑地址(logical address)。虚拟地址和物理地址之间的翻译在每次内存访问时实时发生。
虚拟地址是 CPU 执行指令时生成的地址。程序中的所有地址,无论是指针值、函数地址还是全局变量地址,都是虚拟地址。物理地址(physical address)是内存硬件(DRAM 芯片)实际使用的地址,标识物理内存条上的具体位置。两者不是同一个东西:程序看到的地址 0x401126 在物理内存中可能存储在完全不同的位置。
负责翻译的硬件叫做 MMU(Memory Management Unit,内存管理单元)。MMU 位于 CPU 和物理内存之间,CPU 发出虚拟地址后,MMU 查询一张映射表把它翻译成物理地址,然后用物理地址去访问内存。这个翻译过程对程序完全透明,程序不知道也不需要知道自己的虚拟地址对应哪个物理地址。
CPU ──虚拟地址──→ [ MMU ] ──物理地址──→ 物理内存
↑
映射表MMU 的映射表由操作系统维护,后面会逐步深入这张表的设计。操作系统可以给不同进程配置不同的映射表,这样两个进程使用相同的虚拟地址(比如都在 0x401126 处有 main 函数),但 MMU 会把它们翻译成不同的物理地址,各自指向各自的代码。这就是进程地址空间隔离的硬件基础。
运行时绑定的核心优势在于:操作系统可以在程序运行过程中自由调整映射关系。要把一个进程的数据移到另一块物理内存,只需要复制数据并修改映射表,进程继续使用同样的虚拟地址,完全不知道底层发生了什么。回忆一下进程生命周期一课介绍的 Copy-on-Write,它正是利用了这个能力:内核在不改变虚拟地址的前提下,通过修改映射关系来控制物理内存的共享和分离。
为什么现代系统都选择运行时绑定?
编译时绑定要求预知加载地址,多个程序无法共存。加载时绑定允许灵活加载,但程序运行后不能移动。只有运行时绑定允许操作系统在程序运行过程中自由调整物理内存的使用:可以把不常用的页换出到磁盘(虚拟内存),可以在 fork 后共享物理页直到写入才复制(COW),可以让不同进程的同一份共享库代码只在物理内存中保留一份。
运行时绑定的代价是需要硬件支持。每次内存访问都要做地址翻译,如果靠软件完成,开销不可接受。MMU 把翻译做到了硬件中,并且 CPU 内部有一块叫做 TLB(Translation Lookaside Buffer) 的高速缓存,专门缓存最近用过的地址翻译结果,使得绝大多数翻译不需要真正去查页表,把每次翻译的开销降到接近零。这个硬件投入换来了整个虚拟内存体系:进程隔离、按需分页、共享内存,全部建立在运行时绑定之上。
地址绑定的三种时机是递进关系:编译时绑定最简单但最不灵活,加载时绑定增加了灵活性但仍有限制,运行时绑定最灵活但需要硬件支持。确定了运行时绑定是现代系统的选择,接下来的问题是:运行时绑定的硬件怎么设计?最简单的方案是给每个进程分配一块连续的物理内存。
连续内存分配
连续内存分配(contiguous memory allocation)是为每个进程分配一块连续的物理内存区域,进程的所有代码和数据都存放在这一块区域之中。
运行时地址翻译的最简单实现是两个硬件寄存器:基址寄存器(base register)和限长寄存器(limit register)。基址寄存器存放进程在物理内存中的起始地址,限长寄存器存放进程的地址空间长度。从物理内存的角度看,进程占据 [base, base+limit) 这段区域;但进程自身看到的虚拟地址从 0 开始,所以硬件只需做两件事:第一,检查虚拟地址是否小于 limit(越界则触发异常);第二,把虚拟地址加上 base,得到物理地址。
c
// 基址/限长寄存器的地址翻译和保护检查(伪代码)
void translate(uint32_t virtual_addr, uint32_t base, uint32_t limit) {
if (virtual_addr >= limit) {
trap(SEGMENTATION_FAULT); // 越界:触发异常
return;
}
uint32_t physical_addr = base + virtual_addr;
access_memory(physical_addr); // 合法:访问物理地址
}
// 进程 A: base = 0x10000, limit = 0x5000
// 虚拟地址 0x1234 → 物理地址 0x10000 + 0x1234 = 0x11234 ✓
// 虚拟地址 0x6000 → 0x6000 >= 0x5000,触发异常 ✗这就是运行时绑定的最简单形式。每个进程有自己的 base 和 limit 值,上下文切换时内核切换这两个寄存器。进程 A 的虚拟地址 0x1234 指向物理地址 0x11234,而进程 B 的同一个虚拟地址 0x1234 会指向不同的物理位置,具体取决于 B 的 base 值。
连续分配需要操作系统管理一组大小不等的空闲内存块。当一个新进程需要内存时,操作系统从空闲块中选择一个足够大的分配给它。选择策略有三种。
首次适配(first-fit)从头扫描空闲块列表,分配第一个够大的块。速度最快,因为找到就停。但靠前的大块容易被小请求切碎,产生很多小碎片。
最佳适配(best-fit)扫描所有空闲块,选择能满足请求的最小块。目标是尽量减少浪费。但它每次都要遍历整个列表,而且切出来的剩余部分非常小,产生大量几乎不可用的微小碎片。
最差适配(worst-fit)选择最大的空闲块。理由是切完之后剩余的块还足够大,可以满足后续请求。同样需要遍历整个列表。
进程不断创建和退出后,内存中会出现很多小的空闲块,散布在已分配的块之间。这些空闲块加起来总量可能足够,但因为不连续,无法满足一个需要大块连续内存的请求。这就是外部碎片(external fragmentation)。
已分配 空闲 已分配 空闲 已分配 空闲
[ A ] [ 8KB ] [ B ] [ 12KB ] [ C ] [ 4KB ]
新进程需要 20KB 连续内存
空闲总量 = 8 + 12 + 4 = 24KB > 20KB
但没有任何一块连续空闲区域 >= 20KB → 分配失败外部碎片是连续分配方案的根本缺陷。无论使用哪种适配算法,只要进程的内存大小不同,创建和退出的顺序不确定,碎片就会不断累积。既然问题出在"空闲块被打散了",一个直接的补救办法就是把已分配的块往一端挪,把分散的空闲区域重新并成一个大块,这个过程叫紧凑(compaction)。
紧凑前:[A] [空] [B] [空] [C] [空]
紧凑后:[A] [B] [C] [ 大空闲块 ]紧凑的问题是开销很大。移动进程的内存意味着复制大量数据,而且在移动过程中必须更新所有指向这块内存的引用(base 寄存器、进程内部的指针等)。如果使用运行时绑定,只需要修改 base 寄存器就行,因为进程的虚拟地址不变。但即使如此,物理内存的复制开销仍然很难接受。也就是说,紧凑只能缓解连续分配的后果,却没有消除"必须连续"这个约束本身,所以它很难成为真正令人满意的解法。
与外部碎片对应的还有内部碎片(internal fragmentation):分配给进程的内存块大于进程实际需要的大小,多出来的空间在块内部被浪费。内部碎片在后面分页一课还会再次出现。
最佳适配为什么不一定最好?
最佳适配的目标是最小化每次分配的浪费。但从全局来看,这个局部最优策略往往导致全局最差的结果。
每次分配后切出来的剩余块都很小(因为选的是刚好够大的块)。这些微小碎片太小,几乎不可能被后续请求使用。经过一段时间,内存中充满了这些不可用的碎片。相比之下,首次适配虽然每次可能浪费更多,但剩余块往往足够大,还能满足后续分配。
实际测量表明,首次适配和最佳适配在内存利用率上的差距不大,但首次适配的分配速度更快(不需要遍历完整列表)。这是一个典型的"看起来最精确的策略不一定最有效"的案例:系统的整体行为取决于分配和释放的时间序列,而不是单次分配的精确度。
连续分配真正卡住系统的地方,不只是"会产生碎片",而是"整个进程必须占据一整块连续物理内存"。下一节先看一个过渡方案:把进程拆成代码、数据、栈这些逻辑部分,允许它们分别放到不同的物理位置。这就是分段。它确实放松了"整个进程必须连续"的约束,但我们也会马上看到,它仍然没有真正消除外部碎片。
分段
分段(segmentation)是将进程的地址空间按照逻辑功能划分成若干段(如代码段、数据段、栈段),其中每个段可以独立地映射到物理内存的不同位置。
从程序的视角看,地址空间本来就不是一整块均质的内存,而是天然带着不同的逻辑区域。低地址通常是代码段(text),存放机器指令和只读常量;后面是已初始化数据段(data)和未初始化数据段(BSS),存放全局变量和静态变量;再往上是堆(heap),用于运行时动态分配,它的边界通常沿着更高地址的方向扩展;高地址则是栈(stack),保存函数调用帧、局部变量和返回地址,它的边界通常沿着更低地址的方向扩展。
低地址
┌────────┐
│ text │ 代码 / 只读常量
├────────┤
│ data │ 已初始化全局变量
├────────┤
│ BSS │ 未初始化全局变量
├────────┤
│ heap │ 大小沿高地址方向扩展
│ ↑ │
│ ... │
│ ↓ │
│ stack │ 大小沿低地址方向扩展
└────────┘
高地址分段的思路正是顺着这种程序语义走:代码、数据、堆、栈不必再挤在同一块连续的物理内存里,而是每个段分别分配、分别保护,边界也可以各自独立扩展。代码段可以放在物理地址 0x10000,数据段可以放在 0x50000,栈段可以放在 0x80000。这样一来,操作系统看到的不再是"给整个进程找一整块连续内存",而是"给几个逻辑上不同的段分别找位置"。
为了支持分段,硬件维护一张段表(segment table)。段表的每个条目记录一个段的基址(base)和限长(limit)。虚拟地址被拆分为两部分:段号(segment number)和段内偏移(offset)。
段号只回答一个问题:这次访问落在哪一段。段内偏移则回答另一个问题:它是这一段内部的第几个字节。假设代码段的基址是 0x10000,长度是 0x4000,那么虚拟地址 (代码段, 0x1234) 的意思就是"代码段起点之后 0x1234 字节"。硬件先用段号找到代码段的 base 和 limit,再检查 0x1234 < 0x4000 是否成立;如果成立,物理地址就是 0x10000 + 0x1234 = 0x11234。如果 offset 超过了这一段的 limit,说明访问已经跑出了段边界,硬件就会触发异常。
分段之后,不同进程的代码段不需要放在一起。操作系统只需要保证"每个段自己连续",不需要保证"所有进程的代码段排成一片"。进程 A 的代码段可以在物理地址 0x10000,进程 B 的代码段也可以在 0x90000,中间完全可以夹着别的进程的数据段、栈段或空洞。每个进程都有自己的段表,所以同样的虚拟地址 (0, 0x1234),在 A 里可以翻译到 0x11234,在 B 里也可以翻译到 0x91234。
c
// 段表结构与地址翻译(伪代码)
struct segment_entry {
uint32_t base; // 段的物理起始地址
uint32_t limit; // 段的长度
uint8_t protection; // 权限:读/写/执行
};
struct segment_entry segment_table[] = {
[0] = { .base = 0x10000, .limit = 0x4000, .protection = RX }, // 代码段
[1] = { .base = 0x50000, .limit = 0x2000, .protection = RW }, // 数据段
[2] = { .base = 0x80000, .limit = 0x8000, .protection = RW }, // 栈段
};
// 翻译:虚拟地址 = (段号, 段内偏移)
void translate_segmented(uint16_t seg_num, uint32_t offset) {
struct segment_entry *seg = &segment_table[seg_num];
if (offset >= seg->limit)
trap(SEGMENTATION_FAULT);
uint32_t physical_addr = seg->base + offset;
access_memory(physical_addr);
}分段相比连续分配有两个优势。第一,每个段可以独立设置保护属性:代码段只读可执行,数据段可读写,栈段可读写不可执行。第二,段可以独立增长:堆段需要更多内存时,操作系统只扩展堆段的 limit,不影响其他段。
但分段并没有解决外部碎片。每个段仍然是一块连续的物理内存。段的大小因进程而异、因段而异,段的创建和销毁会在物理内存中留下大小不等的空洞,和连续分配面临的碎片问题完全一样。
小结
| 概念 | 说明 |
|---|---|
| 地址绑定(address binding) | 程序中的地址与物理内存建立对应关系的过程,分为编译时、加载时、运行时三种 |
| MMU | 负责运行时地址翻译的硬件单元 |
| 连续内存分配(contiguous allocation) | 为每个进程分配一块连续的物理内存,用基址/限长寄存器做翻译和保护 |
| 外部碎片(external fragmentation) | 空闲内存总量足够但不连续,无法满足需要连续内存的请求 |
| 分段(segmentation) | 按逻辑功能把地址空间分成段,每段独立映射到物理内存,但仍有外部碎片 |
分段允许进程的不同部分分散存放,但每个段内部仍然是连续的,外部碎片依然存在。要真正摆脱这个问题,就得把分配单位从"整段"继续缩小到固定大小的块。下一课的分页就是沿着这个方向继续推进。
本课的编译和反汇编输出基于 x86-64 Linux 环境(Docker 镜像
gcc:14,GCC 14.2,GNU binutils 2.43)。该环境下 GCC 默认生成位置相关代码(非 PIE),需要显式指定-fPIE -pie才能生成位置无关可执行文件。其他发行版(如 Ubuntu、Fedora)的 GCC 可能默认开启 PIE,输出地址会有所不同。 ↩︎