内存管理基础:地址空间、重定位与保护

你写一个 C 程序,打印某个全局变量的地址,可能会看到一个像 0x601040 这样的值。但你心里清楚,机器上插着的那条内存,物理上根本没有专门给你这个程序留下 0x601040 这个格子。更奇怪的是,你把同一个程序跑两遍、甚至同时跑十遍,每个进程打印出来的地址可能一模一样——可它们明明各自占着不同的物理内存,谁也没踩到谁。

程序里写下的地址,和物理内存芯片上的真实位置,根本不是一回事。

这中间发生了什么?是谁把"程序眼里的地址"翻译成了"内存条上的真实位置"?又是谁保证了十个进程用着相同的地址却互不干扰?这一篇就把这层最底层的机制讲清楚。它是后面连续分配、分页、分段、虚拟内存所有内容的共同地基——所有那些花哨的机制,本质上都是在回答同一个问题:逻辑地址到物理地址,这一步该怎么做、做得多聪明。

一、先看一个最朴素的世界:程序直接用物理地址

要理解为什么需要"地址空间"这层抽象,最好的办法是先看看没有它会怎样。

设想一台早期的简单机器,没有任何地址转换机制。程序在编译时,编译器就得把每条指令、每个变量钉死在某个物理地址上。比如某个跳转指令写死了"跳到物理地址 0x3000",某个变量就住在物理地址 0x5000。程序一加载进内存,就必须放在它被编译时假定的那个位置,一个字节都不能错。

这套做法在只跑一个程序时勉强能用,但只要想同时跑两个程序,麻烦立刻就来了:

  • 程序 A 假定自己从 0x0 开始,程序 B 也假定自己从 0x0 开始。它们俩没法同时待在物理内存里——一个搬走,另一个就得让位。
  • 就算手工把 B 挪到 0x8000 开始的地方,B 内部所有写死的地址(跳转目标、变量位置)全都错位了,程序直接崩。
  • 更可怕的是,A 程序里一个野指针写到了 0x5000,而那里恰好是 B 的关键数据——B 莫名其妙地就被改坏了,却找不到凶手。

这三件事,对应着内存管理必须解决的三个核心诉求:地址要能解耦、加载要能重定位、进程之间要有保护。 下面我们一个一个看机制。

二、地址空间:给每个进程一套"独立的坐标系"

解决上面困境的第一步,是引入一个关键抽象——地址空间(address space)

它的核心思想是:

不让程序直接面对物理内存,而是给每个进程发一套属于它自己的、从 0 开始的连续地址坐标系。

进程在这套坐标系里写下的地址,叫逻辑地址(也叫虚拟地址、相对地址);内存条上真正的物理位置,叫物理地址。两者从此脱钩。

这样一来,程序 A 和程序 B 可以各自都从逻辑地址 0x0 开始编写,互不知道、也无需知道对方的存在。每个进程都活在"整台机器只有我一个程序"的幻觉里。而把这套幻觉变成现实的,是后面要讲的地址转换机制——它负责在每次访存时,把进程的逻辑地址翻译成真实的物理地址。

理解这一点非常关键:地址空间不是一个内存区域,而是一套地址的编号规则。它是个抽象、是个承诺:进程只管按自己的坐标系访问,剩下的翻译工作系统全包了。

三、重定位:逻辑地址到底在哪一步被"安置"

有了逻辑地址和物理地址的分离,下一个问题就是:进程的逻辑地址 0x0,到底对应物理内存的哪里?把逻辑地址安置到某段真实物理内存上的过程,就叫重定位(relocation)

重定位可以发生在不同时机,理解这几种时机的差别,就理解了内存管理的演进脉络:

  • 编译/链接时重定位:编译时就把地址写死。前面说的朴素世界就是这种,程序加载位置完全不能变,最僵硬。
  • 加载时重定位(静态重定位):程序加载进内存的那一刻,由加载器把所有逻辑地址统一加上一个起始偏移,改写成物理地址。这样程序能放到任意位置,但一旦放下就不能再移动——因为地址已经被改死在代码里了。
  • 运行时重定位(动态重定位):这是现代系统的做法。程序里的地址始终保持为逻辑地址不变,每一次访存的瞬间,由硬件实时把逻辑地址翻译成物理地址

第三种是重点。它意味着翻译这件事不是"一次性改写",而是"每次访问都现场做一遍"。这看似多此一举,实则是后面一切灵活性的来源——因为地址没被改死,进程可以被随时整体搬到内存别处,只要改一下翻译的参数就行;也正因为每次访问都过一遍翻译,才有机会在翻译时顺便做权限检查。

现代内存管理的精髓,就藏在"动态重定位"这四个字里:地址转换不是加载时做一次,而是每次访存都由硬件实时完成。

四、最小的转换机制:基址寄存器与界限寄存器

动态重定位听起来玄乎,但最朴素的实现极其简单,只需要两个寄存器就能跑起来。理解它,是理解后面分页 MMU 的最佳跳板。

这两个寄存器是:

  • 基址寄存器(base register):存放当前进程在物理内存里的起始物理地址。
  • 界限寄存器(limit register):存放当前进程地址空间的长度(最大合法逻辑地址)。

每次 CPU 要访问一个逻辑地址时,硬件自动做两件事:

  1. 保护检查:判断 逻辑地址 < 界限寄存器 是否成立。如果逻辑地址超出了界限,说明进程要访问的位置超出了它自己的地盘,硬件立即触发异常(在 Linux 上,这通常就体现为大家熟悉的段错误 Segmentation Fault)。
  2. 地址转换:如果检查通过,就计算 物理地址 = 基址寄存器 + 逻辑地址,用这个物理地址去访问内存。

我们把这个流程画出来:

flowchart TD A["CPU 发出逻辑地址 LA"] --> B{"LA < 界限寄存器?"} B -->|否, 越界| C["触发异常<br/>(段错误)"] B -->|是, 合法| D["物理地址 PA = 基址寄存器 + LA"] D --> E["用 PA 访问物理内存"]

这张图就是动态重定位最朴素的全貌。注意两个细节:第一,检查在转换之前,越界的访问根本走不到访存那一步,保护是硬性的;第二,整个过程纯由硬件完成,一次加法、一次比较,快到可以忽略,所以才敢"每次访存都做一遍"。

而进程切换时,操作系统只要把新进程的基址、界限值装进这两个寄存器,整套坐标系就切换过去了。同一个逻辑地址 0x100,在进程 A 当道时被翻译成 基址A + 0x100,在进程 B 当道时被翻译成 基址B + 0x100——这就解释了开篇那个谜题:为什么十个进程用着相同的逻辑地址,却落在完全不同的物理内存上、互不干扰。

五、保护:为什么它不是"附加功能"而是"内建机制"

很多人把"保护"理解成一个额外加上去的安全特性,好像可以选择开或不开。但从上面的机制能看出,保护根本不是附加的——它和地址转换是同一套硬件、同一个动作里顺手完成的。

界限检查就嵌在转换流程的第一步。既然每次访存都必须经过硬件转换,那顺带做一次"是否越界"的比较几乎不花额外成本。这是一个非常典型的系统设计智慧:

把保护检查嵌进本就必经的高频路径里,让"安全"变成"免费",而不是事后另起炉灶。

进程间的隔离也是同理。进程 A 的逻辑地址再大,加上基址、过了界限检查,落点也只可能在 A 自己那段物理区域内,物理上就够不着 B 的内存。隔离不是靠"约定大家别乱碰",而是靠硬件机制让你根本碰不到。这种"机制保证"远比"规则约定"可靠,也是操作系统安全的基本信条。

还要分清两类保护:一类是越界保护(别访问不属于你的地址),靠界限寄存器;另一类是权限保护(这段内存只读还是可写、可不可执行),需要在转换信息里额外携带权限位。基址/界限模型只解决了前者,后者要等到分段、分页里给每个段/页带上权限位才能精细实现——这也是后面机制要补的功课。

六、为什么基址/界限不够用,必须继续进化

基址/界限模型优雅又便宜,但它有个致命缺陷,正是这个缺陷把内存管理一路逼向了分页和分段。

缺陷在于:它要求每个进程占用一整段连续的物理内存。因为转换公式是简单的 基址 + 逻辑地址,这就隐含假定了进程的物理内存是从基址开始、连续铺开的一整块。

这个"连续"的要求,带来一连串问题:

  • 进程必须找到一段足够大的连续空闲物理内存才能装入,哪怕系统空闲总量远大于它的需求;
  • 进程不断创建和销毁,会把物理内存切成一堆零散小块,明明加起来够,却拼不出一段连续的——这就是外部碎片
  • 整个进程的地址空间必须全部装入内存才能运行,没法只装一部分。

这些正是下一篇《连续分配策略》要展开的内容。你会看到首次适应、最佳适应这些算法如何在"找连续空间"上各显神通,又如何都绕不开碎片这个结构性难题。而再往后的分页机制,则干脆从根上放弃了"连续"这个假设——把转换公式从"一个基址"升级成"一张映射表",这才彻底解开了连续分配的死结。

所以你看,从基址/界限到分页,并不是凭空跳跃,而是同一条主线的自然延伸:地址转换从"加一个偏移"演进到"查一张表"。 抓住这条主线,后面的内容就都串起来了。

七、和工程实践 / 后端开发的联系

这层最底层的机制,会一路渗透到你日常的排障和设计里。

  • 看懂段错误Segmentation Fault 字面意思就是"越过了内存段的边界"。当你的程序解引用了空指针或野指针,硬件的界限/权限检查拦下了这次非法访问并触发异常。理解了第四节那张图,你就明白段错误不是玄学,而是保护机制在正常工作。
  • 理解进程隔离的根基:为什么一个进程崩溃通常不会拖垮其他进程?因为地址空间隔离让它物理上够不着别人的内存。容器(如 Docker)虽然主要靠 namespace 和 cgroups 做隔离,但进程级的内存隔离这层地基,仍是它能安全运行的前提。
  • 理解只读段与不可执行栈:可执行文件里代码段被标记为只读+可执行、数据段为可读写不可执行,这些权限位正是第五节说的"权限保护"。很多安全防护(如防止栈上代码注入执行)就建立在这层机制上。
  • 解释"地址随机化":现代系统的 ASLR(地址空间布局随机化)每次运行都把进程的代码、栈、堆放到不同逻辑位置,正是利用了"逻辑地址与物理地址解耦、可以随意重定位"这一能力来增加攻击难度。

一句话:地址空间不是教科书名词,它是你理解"程序为什么这么跑、为什么会崩、为什么是安全的"的第一把钥匙。

八、动手做一个小实验:观察进程地址空间布局

理解地址空间最直接的方式,是亲眼看一看真实进程的布局。在 Linux 上,这个实验只需要几条命令。

随便找一个正在运行的进程(比如它的 PID 是 1234),执行:

1
cat /proc/1234/maps

你会看到一行行的输出,每一行是进程地址空间里的一个区域,包含逻辑地址范围、权限(r/w/x)、以及它映射的文件。重点观察这几件事:

  1. 找到代码段:它的权限通常是 r-xp(可读、可执行、不可写)——这印证了代码段被保护为只读。
  2. 找到栈区域:标记为 [stack],权限通常是 rw-p(可读写、不可执行)——这就是"不可执行栈"。
  3. 同一个程序跑两个实例,对比两份 maps:你会发现逻辑地址布局高度相似(甚至因 ASLR 而各有随机偏移),但它们各自映射到完全不同的物理内存,互不影响。

做完这个实验,第二节"独立坐标系"、第五节"权限保护"就从抽象概念变成了你亲眼看到的东西。比背十遍定义都管用。

学习这一部分最容易踩的坑

1. 把逻辑地址和物理地址混为一谈

程序里打印出来的地址几乎都是逻辑地址,不是物理位置。两个进程打印出相同地址毫不矛盾,因为它们活在各自独立的坐标系里。分不清这两者,后面分页、虚拟内存全都理解不了。

2. 以为重定位是加载时做一次就完了

静态重定位确实是加载时改写一次,但现代系统用的是动态重定位——每次访存都由硬件实时翻译。正是这个"每次都做",才换来了进程可随意搬移、可只装一部分、可顺带做权限检查的全部灵活性。

3. 把保护当成可选的附加功能

保护检查嵌在地址转换的必经路径里,和转换是同一套硬件动作。它不是事后加的安全补丁,而是多程序环境能够成立的硬性前提。没有保护,多个进程同时运行就是灾难。

4. 忽略"连续"假设带来的局限

基址/界限模型简单,但它隐含要求进程占用连续物理内存,这正是外部碎片和"装不进"问题的根源,也是分页机制要解决的对象。把这个局限放进因果链里,你才懂分页为什么必须出现。

总结

内存管理的起点,是在程序和物理内存之间插入一层翻译。这一层把"程序眼里的地址"和"机器真实的位置"彻底解耦,并在翻译的同时顺手完成了隔离与保护。真正要带走的是下面几点:

  • 地址空间是给每个进程的一套独立坐标系,进程用逻辑地址,硬件负责翻译成物理地址;
  • 重定位是把逻辑地址安置到物理内存的过程,现代系统采用动态重定位——每次访存由硬件实时翻译;
  • 最朴素的转换机制是基址寄存器 + 界限寄存器:先查界限做保护,再加基址做转换,一次比较一次加法搞定;
  • 保护不是附加功能,而是嵌在转换必经路径里的硬件机制,让安全变得"免费";进程隔离靠的是物理上够不着,而非约定;
  • 基址/界限模型隐含的"连续物理内存"假设是它的天花板,这正是连续分配的碎片难题与分页机制的出发点。

把这一篇吃透,你就握住了贯穿整个内存管理系列的那条主线——地址转换如何从"加一个偏移"一步步演进成"查一张映射表"。下一篇我们顺着"连续分配"往下走,亲眼看看碎片问题是怎么逼出分页的。

参考资源

  • 《现代操作系统》(Andrew S. Tanenbaum)
  • 《操作系统导论》(Operating Systems: Three Easy Pieces)地址转换章节
  • OSTEP - Address Translation
  • 《深入理解计算机系统》虚拟内存章节