本篇位置 虚拟化内存的机制篇之一。这一篇的两个方案都被淘汰了,但它们失败的原因决定了分页的样子
运行环境 容器里的 Linux,gcc

一、翻译这件事,最省事能省到什么程度

第 8 篇留了一个问题:进程手上的地址是假的,那谁来翻译成真的

先立一个硬约束:这个翻译发生在每一次内存访问上。取指令要翻译,读变量要翻译,压栈要翻译。一条指令可能触发两三次。

所以这件事必须由硬件做,而且必须极快。任何"内核介入一下"的方案都是不可能的——那会慢几百倍。

那硬件最省事能做到什么程度?两个寄存器:

基址(base):这个进程的内存被放在物理内存的哪个位置。 界限(bounds):它有多大。

翻译规则一行:

物理地址 = 虚拟地址 + 基址      (前提:虚拟地址 < 界限,否则报错)

一个加法,一个比较。这套东西叫 MMU(内存管理单元),它就在 CPU 里。

打个比方

接着第 8 篇那家酒店。

基址加界限,就是最简单的那种安排:「你们团住 501 到 520 房。」

  • 团员手上的行程单从 1 号房编到 20 号房(虚拟地址)。
  • 前台的规则:你说的号 + 500 = 实际房号(基址 = 500)。
  • 超过 20 就是瞎报(界限 = 20),保安直接拦下。

⭐ 前台不需要一本厚账本,只要记住两个数:从哪开始、有多少间。翻译快到几乎不花时间。

二、这套东西已经足够好用了

别小看两个寄存器。它把两件大事一次解决了:

一、重定位。 编译器生成代码时可以假装程序从 0 开始。装载时内核把基址一填,程序就跑在物理内存的任意位置上——程序自己毫不知情。第 8 篇说的"每个程序都能假设自己从 0 开始",就是这么来的。

二、保护。 界限检查由硬件做。进程 A 说的任何地址都会被加上 A 的基址,永远落不到 B 的范围里——A 连"表达"访问 B 的意图都做不到

而且切换进程只要换两个寄存器。 上下文切换里那部分几乎不花钱。

内核这边要配合三件事

硬件只管加和比。剩下的是内核的活:

  1. 找地方。 进程启动时,在物理内存里找一块够大的连续空闲区。(这件事比听上去难,第 11 篇专门讲。)
  2. 换人时换寄存器。 把上一个进程的基址界限存进它的 PCB,把下一个的装进寄存器。
  3. 处理越界。 硬件发现越界,触发异常陷入内核(第 4 篇那条路),内核通常就把这个进程杀了。

第 3 条今天还在用,我们让它演一遍。

三、跑一遍看看:硬件报出精确到字节的越权

现在的 Linux 早就不用基址界限了(用分页),但**“硬件发现越权 → 陷入内核 → 内核发信号”**这条路一模一样。

我们申请一页只读内存,读它没问题,写它试试:

static void handler(int sig, siginfo_t *si, void *ctx) {
    char buf[256];
    int n = snprintf(buf, sizeof buf,
        "  ★ 内核送来 SIGSEGV,出错地址 %p\n"
        "  ★ 我那一页的起始地址   %p\n"
        "  ★ 相差 %ld 字节 —— 硬件精确报出了是哪一个字节越了权\n",
        si->si_addr, (void *)page, (long)((char *)si->si_addr - page));
    write(1, buf, n);
    _exit(0);
}

int main(void) {
    struct sigaction sa; memset(&sa, 0, sizeof sa);
    sa.sa_sigaction = handler; sa.sa_flags = SA_SIGINFO;
    sigaction(SIGSEGV, &sa, NULL);

    page = mmap(NULL, 4096, PROT_READ, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    printf("  申请了一页「只读」内存,在 %p\n", (void *)page);
    printf("  读它:第 100 个字节是 %d —— 没问题\n", page[100]);
    printf("  现在往同一个位置写……\n");
    fflush(stdout);
    page[100] = 'X';
    printf("  写成功了?—— 不该看到这一行\n");
}
  申请了一页「只读」内存,在 0xffff83021000
  读它:第 100 个字节是 0 —— 没问题
  现在往同一个位置写……
  ★ 内核送来 SIGSEGV,出错地址 0xffff83021064
  ★ 我那一页的起始地址   0xffff83021000
  ★ 相差 100 字节 —— 硬件精确报出了是哪一个字节越了权

三件事值得注意:

  1. 同一个地址,读成功,写失败。 权限不是"能不能访问",是"能怎么访问"。
  2. 出错地址精确到字节0x...064 = 起始 + 100。这是 MMU 在故障时存进一个专用寄存器的,内核只是把它转手交给你(si_addr)。
  3. 整条路径是第 4 篇那条:硬件发现问题 → 陷入内核 → 内核查这个进程该收什么信号 → 发给它(第 3 篇第六节讲了信号是怎么被送到的)。跟系统调用走的是同一条隧道,只是方向反过来。

四、这套方案的毛病:中间那片空地

回到第 8 篇的图:堆在低地址往上长,栈在高地址往下长,中间是一大片空地

用一对基址界限,你的"界限"必须覆盖到栈的最高处。于是中间那片什么都没有的空地,也被算进了你占的物理内存

一个程序申请 1 GB 的地址空间,实际只用了 20 MB,它照样要占掉 1 GB 连续的物理内存

这个浪费叫内部碎片——分给你的地里,你没用上的部分。用一对寄存器,这个浪费大到不可接受。

五、分段:一对不够,那就三对

既然问题出在"用一对寄存器盖住整个地址空间",那就给每个逻辑段配一对

代码段   基址 = 0x8000   界限 = 6 KB     权限 = 读+执行
堆       基址 = 0x2000   界限 = 4 KB     权限 = 读+写
栈       基址 = 0x9000   界限 = 2 KB     权限 = 读+写,向下增长

这就是分段(segmentation)。地址的高几位表示"这是哪个段",剩下的位是段内偏移。

三个好处一次到手:

  1. 中间的空地不用占物理内存了——三个段各自紧凑放置。
  2. 权限可以按段设。代码段设成不可写,栈段设成不可执行——第 8 篇那张 maps 表里的 r-xprw-p,思想就是从这儿来的。
  3. 共享变得容易。两个进程的代码段基址指向同一块物理内存,就共享了。这就是第 8 篇思考题 1 的答案:机器上所有进程的 libc 代码,物理内存里只有一份。

⚠️ 分段还需要硬件知道"栈是向下长的"——栈段的偏移要反着算。硬件里真的有这么一个标志位。

六、⭐ 但它引来了一个更麻烦的问题

三个段各自紧凑,物理内存里就有了很多大小不一的洞

进程来了又走,留下 3 KB、17 KB、200 KB 的空隙。现在来了一个要 20 KB 连续空间的段——空闲总量明明有 220 KB,却没有一块连续的 20 KB

这叫外部碎片:空间是有的,但它是碎的。

对付它有两个办法,都不好:

  • 紧凑(compaction):把所有段搬到一起,挤出连续空间。要暂停进程、要复制大量内存、还要更新所有基址寄存器。太贵。
  • 挑得聪明点:最先适配、最佳适配、最差适配……只能缓解,不能消除。这是第 11 篇的主题。

分段的死穴是"段的大小是任意的"。 只要允许任意大小,外部碎片就消不掉。

那反过来想:如果所有块都一样大呢?

一样大的话,任何一个空闲块都能装下任何一个请求。外部碎片从定义上就不存在了。

这就是分页。第 12 篇。

回到酒店:分段是"给每个团按人数安排一整排连续的房间"。团来团走,走廊上就剩下一堆凑不到一起的零散空房——想进一个 20 人的团,明明空着 22 间,却没有连续的 20 间。而分页的做法是:不管你多少人,一律按标准间发,哪间空发哪间。

七、代价与取舍

基址界限换来了什么: 用两个寄存器同时实现了重定位和保护,翻译只花一个加法。这个思路今天还活着——它是所有地址转换的最小形态。

分段换来了什么: 消灭了空地的浪费,带来了按段设权限和按段共享。这两条今天都还在maps 里的权限位、共享库只存一份)。

它们花了什么、放弃了什么:

  • 基址界限放弃了稀疏地址空间——你必须为最高的那个地址付全款。
  • 分段放弃了物理内存的可用性——外部碎片让"总量够但装不下"成为常态,而唯一的根治办法(紧凑)贵到不可行。

为什么它们被淘汰了: 不是因为慢,而是因为它们都要求物理内存里有一块连续的空间。这个要求本身就是问题的根源。分页把它取消了——这才是分页真正的贡献,比"页表"这个数据结构重要得多

八、小结

  • 地址转换必须由硬件做,因为它发生在每一次内存访问上。
  • 基址 + 界限物理 = 虚拟 + 基址,且 虚拟 < 界限。一个加法一个比较,同时给出重定位保护,切换进程只换两个寄存器。
  • 实测:同一个地址读成功写失败,硬件报出精确到字节的出错地址(起始 + 100),走的是第 4 篇那条陷入内核的路,只是方向相反。
  • 基址界限的毛病:堆栈之间的空地也得占物理内存(内部碎片)。
  • 分段给每个段一对寄存器,解决了空地问题,还带来了按段设权限按段共享(所以全机器的 libc 代码只有一份)——这两条今天还在用。
  • 分段的死穴是段的大小任意,于是产生外部碎片:总量够,但没有一块连续的够。紧凑太贵,挑选策略只能缓解。
  • 两者共同的错误是要求物理内存连续。 取消这个要求,就是分页。

思考题

  1. 第三节的演示里,读成功写失败。如果这一页设成 PROT_NONE(什么都不许),读也会失败。那么,一个地址在 maps 里存在、但权限是 ---p,这种段有什么用?(第 8 篇那张表里真的有几个。)
  2. 分段允许按段共享。两个进程共享代码段没问题,那能不能共享段?会出什么事?
  3. 外部碎片的严重程度和什么有关?如果所有请求的大小都是 2 的幂,情况会好转吗?(这个想法真的有人用了,叫伙伴系统,第 11 篇讲。)
  4. 酒店那个比方里,“紧凑"对应"把所有团都请出房间重新安排一遍”。除了折腾客人,这个操作在操作系统里还有一个比方里没有的额外代价——是什么?(提示:进程手上的指针。)

延伸