一、8086 的段寄存器:16 位的寄存器,怎么够到 1MB 内存

16 阅读29分钟

一、8086 的段寄存器:16 位的寄存器,怎么够到 1MB 内存


系列说明:这是"x86 的内存管理是怎么一步步演进来的"系列第六篇。整个系列想把三样东西从 CPU 硬件的视角讲透:段寄存器机制GDT / TSS 这些 CPU 层面的表和寄存器页表的硬件翻译机制。主线是一条演进史——从 1978 年的 8086,到 80386 的保护模式,再到今天的 x86-64。

六篇的安排是:第一篇(本文) 8086 实模式的段寄存器(为什么会有"段"这东西);第二篇 保护模式的段(选择子、GDT、段描述符的位结构)第三篇 特权级与门,以及 TSS 当年的"本来用途"第四篇 x86-64 的简化(段基本废弃,FS/GS 为什么留下)第五篇 内核里的 GS / swapgs 与现代 TSS第六篇 页表的 CPU 机制(CR3、page walk、PTE、KPTI)


如果你写过几年代码,"段寄存器"这个词大概率是从某次崩溃信息、某段反汇编、或者某篇讲虚拟内存的文章里瞥到的。CS、DS、SS、ES、FS、GS——六个名字,听起来像某种很底层、很硬核、和你日常没关系的东西。

但段寄存器的出身一点都不硬核。它最早就是个补丁,为了解决一个非常具体、非常"物理"的麻烦:寄存器位数不够,够不着那么多内存。

这一篇我们回到 1978 年的 8086,把这个麻烦摆出来,看 Intel 当年是怎么用"段"这个土办法绕过去的。理解了这个起点,后面保护模式、x86-64 给段寄存器叠的那些复杂身份,才有个可以挂靠的根。

实验环境:我这台是 arm64 的 Mac,本身连 x86 都不是,但实模式照样能真跑——靠 Docker 拉一个 x86-64 容器(--platform linux/amd64,底层走 QEMU 模拟),在里面装 nasm + qemu-system-i386,编一段 16 位的引导扇区(bootsector)丢给 QEMU 引导,就能让 CPU 真的去执行实模式指令。需要先说清楚:QEMU 跑的不是真 8086,而是现代 x86 CPU 工作在实模式——实模式的地址算术 段值<<4+偏移 和真 8086 完全一致,用它验证这套算术没问题;至于 dump 里那些 286 以后才有的细节,第三节会专门点破,不让它误导你对 8086 的认知。所以这一篇既有原理图,也有 QEMU 引导实模式代码的实测。环境怎么搭、bootsector 代码、复现命令,第三节和文末都给全了。

另外,"段:偏移怎么拼出物理地址"这一步纯粹是算术——左移、相加、截断——任何机器都能算。所以我还写了一小段可移植的 C 程序去模拟这个算术,把段重叠、地址回绕这些现象用真实输出钉死,gcc seg8086.c && ./seg8086 在任意机器上都能复现。

一、矛盾:16 位的寄存器,只数得到 64KB

8086 是一颗 16 位的 CPU。"16 位"这件事,最直接的后果体现在它的寄存器上:AX、BX、CX、DX、SI、DI、BP、SP——这些通用寄存器全是 16 位宽,一个寄存器只能装下一个 0 到 65535 的数。

而内存地址,本质上就是个数——"内存里第几号字节"。所以一个 16 位寄存器当地址用,最多能数到:

   2^16 = 65536 = 64 KB

也就是说,光靠一个 16 位寄存器去指内存,你最多够得着 64KB。第 65536 号字节往后,这个寄存器就数不到了——它已经到顶(0xFFFF)了。

问题来了:8086 这颗芯片,物理上能接多少内存?

答案是 1MB。8086 的地址总线有 20 根(A0 到 A19)。地址总线是 CPU 和内存条之间那一束物理导线,有几根线,就能表示几位地址。20 根线能表示的地址范围是:

   2^20 = 1048576 = 1 MB

矛盾就这么直愣愣地摆在这儿:

   ┌─────────────────────────────────────────────────┐
   │                                                  │
   │   一个 16 位寄存器  ──►  最多数到  64 KB          │
   │                                                  │
   │   可地址总线有 20 根 ──►  内存能有  1 MB          │
   │                                                  │
   │   差了 16 倍。寄存器根本装不下一个能指向          │
   │   整个 1MB 空间的地址。                           │
   │                                                  │
   └─────────────────────────────────────────────────┘

64KB 和 1MB,差了整整 16 倍(2^4)。一个 16 位的寄存器,连这块芯片自己能接的内存都指不全。你用一个寄存器存地址,最多摸到内存最低的那 64KB,剩下 15/16 的空间,根本够不着。

这就是"段"诞生的全部背景。它不是什么内存保护的伟大设计,就是 Intel 工程师面对"寄存器比地址总线窄"这个尴尬,硬想出来的一个凑数办法。

二、Intel 的解法:拿两个 16 位数,拼出一个 20 位地址

差 4 个二进制位(16 倍 = 2^4)。怎么补上这 4 位?

Intel 的思路很直接:一个寄存器不够,那就用两个。 拿一个额外的 16 位寄存器当"高位",另一个 16 位寄存器当"低位",两个拼起来去够那 20 位。

但不是简单地把两个 16 位接成 32 位(那也不对,地址总线就 20 根)。具体拼法是这样的:

  • 拿一个寄存器作段基址(segment),把它左移 4 位(也就是乘以 16),得到一个 20 位的"段的起点"。
  • 再拿另一个寄存器作偏移(offset),加到这个起点上。
  • 相加的结果,就是 20 位的物理地址

写成公式:

   物理地址 = 段基址 × 16 + 偏移
            = (segment << 4) + offset

为什么是"左移 4 位"?因为差的正好是 4 个二进制位。把段寄存器的 16 位往左挪 4 位,它就占据了地址的第 4 到第 19 位(高 16 位),空出来的低 4 位由偏移补上——偏移的 16 位则覆盖第 0 到第 15 位。两者一叠加,正好拼满 20 位:

   段基址 0x1234,偏移 0x5678,怎么拼成 20 位物理地址:

                bit:  19 ......... 4  3 ... 0
   段基址<<4 :  [   0x1234           ][ 0 0 0 0 ]   ← 段左移4位,占高16位
   偏移      :       [        0x5678            ]   ← 偏移占低16位
                              +
              ─────────────────────────────────
   物理地址  :  [        0x179B8               ]   ← 两者相加,20位

   竖式算一遍:
        0x1234 << 4  =  0x12340
                      +  0x05678
                      ───────────
                         0x179B8     ← 最终物理地址(20 位)

注意中间那段是重叠相加的:段左移后的低 4 位是 0,偏移的高 4 位(第 1619 位)……等等,偏移只有 16 位,它最高只到第 15 位。所以段(占 419 位)和偏移(占 015 位)在第 415 位这一段是重叠的,会发生进位相加。这个"重叠"是后面段能互相覆盖的根源,先记住。

我用一段 C 程序把这个算术原样模拟出来(phys() 就是 (seg<<4)+off 再截到 20 位):

#include <stdio.h>

// 模拟 8086 实模式的地址生成:物理地址 = (段 << 4) + 偏移
// 8086 地址总线 20 根,物理地址只有 20 位有效,所以最后对 2^20 取模(即截到 20 位)
static unsigned long phys(unsigned seg, unsigned off){
    unsigned long a = ((unsigned long)seg << 4) + off;
    return a & 0xFFFFF;   // 20 位,模拟 8086 只有 20 根地址线
}

static void show(unsigned seg, unsigned off){
    printf("  seg=0x%04X : off=0x%04X  ->  (0x%04X<<4)+0x%04X = 0x%05lX\n",
           seg, off, seg, off, phys(seg, off));
}

跑几个例子(这是真实输出):

  seg=0x1234 : off=0x5678  ->  (0x1234<<4)+0x5678 = 0x179B8
  seg=0x0000 : off=0x0400  ->  (0x0000<<4)+0x0400 = 0x00400
  seg=0xF000 : off=0xFFF0  ->  (0xF000<<4)+0xFFF0 = 0xFFFF0

第一行就是上面竖式那个例子:0x1234:0x5678 拼出 0x179B8。第二行 0x0000:0x04000x400,段是 0 时偏移原样就是物理地址。第三行 0xF000:0xFFF00xFFFF0,这个地址很特殊——它在 1MB 空间的最顶端附近,正是 8086 复位后第一条指令所在的地方(CS:IP 复位值 = 0xF000:0xFFF0),BIOS 的入口就在这儿。

这套"段:偏移"的写法,在汇编和老 DOS 程序里随处可见,记法是 段:偏移,比如 1234:5678。每次访问内存,CPU 都自动做这个"取出对应段寄存器、左移 4 位、加偏移"的动作,不用你手动算。

打个生活里的比方:你寄快递写地址,"杭州市 文三路 100 号"——"杭州市 文三路"是大范围(段),"100 号"是这条路上的门牌(偏移)。光给门牌号"100 号"没用,全国每条路都有 100 号;光给"文三路"也送不到,得两个拼起来才能精确定位一个点。8086 的段:偏移做的是一模一样的事——段框定一个大致的起点,偏移在这个起点里再走多远。区别只是它的拼接方式是"段乘 16 再加偏移"这种很机械的算术。

三、四个段寄存器:取指、数据、栈,各管一段

既然访问内存要用"段:偏移",那"用哪个段"是谁说了算?8086 给不同用途的内存访问,配了专门的段寄存器。8086 时代有四个:

  • CS(Code Segment,代码段):CPU 取指令时用。CPU 执行到哪条指令,是由 CS:IP 决定的——CS 是代码段基址,IP 是指令指针(偏移)。
  • DS(Data Segment,数据段):读写数据时默认用。你访问一个变量、读一个数组,地址默认按 DS:偏移 算。
  • SS(Stack Segment,栈段):压栈、弹栈、函数调用时用。SS:SP 指向栈顶。
  • ES(Extra Segment,附加段):备用数据段,常用于字符串操作(比如把一块内存拷到另一块,源用 DS、目的用 ES)。

为什么要分这么多个?因为程序天然就有几块互不相邻的内存:代码在一块、数据在一块、栈在另一块。如果只有一个段寄存器,每次在"取指令"和"读数据"之间切换,都得重新设一遍段,太麻烦。分成 CS/DS/SS/ES,CPU 取指令自动用 CS、读数据自动用 DS、碰栈自动用 SS,各走各的,互不打扰。

这样,一个典型的 8086 程序在 1MB 物理内存里的布局,大致是这样:

   8086 实模式下,一个程序的几个段在 1MB 物理内存里(示意)

   物理地址
   0x00000  ┌──────────────────────┐
            │  中断向量表 (1KB)     │  ← 256 个中断处理入口,固定在最低端
   0x00400  ├──────────────────────┤
            │  BIOS 数据区          │
            ├──────────────────────┤
            │       ......          │
            ├──────────────────────┤◄── CS 指向这里:代码段
            │   代码 (你的指令)     │     CPUCS:IP 取指令
            ├──────────────────────┤◄── DS 指向这里:数据段
            │   数据 (变量/数组)    │     读写数据按 DS:偏移
            ├──────────────────────┤◄── SS 指向这里:栈段
            │   栈 (向下增长)       │     压栈/弹栈按 SS:SP
            ├──────────────────────┤
            │       ......          │
   0xA0000  ├──────────────────────┤
            │  显存 (VGA 等)        │  ← 这一段地址映射到显卡,不是普通内存
   0xB8000  │  文本模式显存         │     往这写字节,屏幕上就出字符
   0xC0000  ├──────────────────────┤
            │  扩展 ROM / BIOS ROM0xF0000  │  系统 BIOS            │  ← 复位入口 0xF000:0xFFF0 在这附近
   0xFFFFF  └──────────────────────┘  ← 1MB 顶端

每个段最大多大?偏移是 16 位,最多 0xFFFF,所以一个段最多覆盖 64KB。这就是 64KB 这个数字在 DOS 时代阴魂不散的根源——单个段就这么大,大于 64KB 的数据结构得跨段处理,当年无数程序员为此头疼。

这里要特别点出一件事,它是后面所有故事的关键:实模式下,段寄存器里装的就是"段基址本身"(的高 16 位),CPU 直接拿它左移 4 位就用。 段寄存器 = 一个能直接参与地址计算的数。没有任何检查,没有任何表,没有权限的概念。你往 CS 里写个 0x2000,CPU 下一条指令就老老实实去 0x20000 + IP 那里取——哪怕那块内存是别的程序的、是显存、是 BIOS,它都照取不误。

记住这一点。到了下一篇的保护模式,段寄存器里装的就不再是基址,而变成了一个"查表的编号",这是整个演进里最大的一次转身。

真机实测:CPU 真的把"段×16"算好了

前面"段×16"一直是我在纸上算。它到底是不是 CPU 硬件在做?我们跑一段实模式代码,再把段寄存器的内部状态 dump 出来,亲眼验证。

环境就是开头说的:Docker 拉一个 x86-64 容器,装 nasm + qemu-system-i386。写一段引导扇区,往三个段寄存器里分别装入不同的段值,然后停机。

严格说一句(这点很重要,后面会展开):qemu-system-i386 模拟的不是一颗真 8086,而是一颗现代 x86 CPU 工作在实模式下。实模式的地址算术 段值<<4 + 偏移 和真 8086 一模一样,所以用它验证这套算术完全可靠;但 dump 出来的某些细节(下面会看到的那几列"隐藏"字段)是 286 以后才有的,我会专门点出来,不让它误导你对 8086 的理解。

; boot.asm —— 16 位实模式引导扇区
BITS 16
org 0x7c00
start:
    mov ax, 0xb800
    mov es, ax            ; ES = 0xB800(文本显存段)
    mov ax, 0x1000
    mov ds, ax            ; DS = 0x1000
    mov ax, 0x9000
    mov ss, ax            ; SS = 0x9000
    mov sp, 0xFFFE
    cli
.hang:
    hlt                   ; 停在这,方便我们 dump 寄存器
    jmp .hang
times 510-($-$$) db 0
dw 0xaa55                 ; 引导扇区魔数
nasm -f bin boot.asm -o boot.img
# 后台引导,跑到 hlt 后用 QEMU monitor 的 info registers dump 段寄存器
qemu-system-i386 -drive format=raw,file=boot.img -display none \
    -monitor tcp:127.0.0.1:4444,server,nowait &
sleep 2
exec 3<>/dev/tcp/127.0.0.1/4444
printf 'info registers\nquit\n' >&3
cat <&3 | grep -iE "^(CS|DS|ES|SS) "

真实输出:

ES =b800 000b8000 0000ffff 00009300
DS =1000 00010000 0000ffff 00009300
SS =9000 00090000 0000ffff 00009300
CS =0000 00000000 0000ffff 00009b00

这四列分别是什么意思,逐列标清楚(以 ES 那行为例):

   ES   =   b800      000b8000    0000ffff    00009300
            ┌──┘       ┌──┘        ┌──┘        ┌──┘
            │          │           │           │
        ① 段值     ② 隐藏 base  ③ 隐藏 limit  ④ 隐藏属性
        (16 位)   (32 位)     (32 位)     (32 位)
        你写进     CPU 算的      段有多大      读写权限/类型
        ES 的值    段值<<4      =64KB-1       等标志位

   ① b800     = 我们 mov 进 ES 的那 16 位,这才是"段寄存器"本体
   ② 000b8000 = 0xb800 << 4,CPU 算出来的线性基址(前面补 0 凑满 32 位)
   ③ 0000ffff = 0xffff = 65535 = 64KB-1,实模式下段界限恒定 64KB
   ④ 00009300 = 属性,有意义的是 0x93(数据段)/ 0x9b(代码段,见 CS 行)

只有第①列那 16 位是你在汇编里能写、能读的"段寄存器"。后面三列又长又满,是因为它们是按 32 位宽打印的内部字段——但这里有个大坑要先讲清楚

重要澄清:真 8086 没有这些"隐藏"列,它们是后来才有的

你看到后三列那么长,第一反应大概是:"1978 年的 8086 哪有这么多位?它的段寄存器不就 16 位、只装个段值吗?"

你是对的。 真正的 8086,段寄存器就是 16 位,里面只有第①列那个段值。它没有隐藏 base、没有隐藏 limit、没有属性位。8086 每次访问内存,是当场用组合电路算 段值<<4 + 偏移,算完即弃,不在任何地方"存"那个基址。所以在一颗真 8086 上,你根本 dump 不出后面那三列——它们压根不存在。

那这三列哪来的?答案是:我们用 qemu-system-i386 跑的,根本不是一颗真 8086,而是一颗现代的 32 位 x86 CPU 工作在"实模式"下。 后三列那个"隐藏部分",正式名字叫段描述符缓存(descriptor cache),是 80286 才引入的东西(为保护模式查表加速用,下一篇详谈)。从 286 往后的所有 x86,哪怕跑在实模式,也都保留着这个隐藏缓存——只不过实模式下它的 base 就简单填成 段值<<4、limit 填 64KB。QEMU 模拟的是现代 CPU,所以 dump 才看得到。

   真 8086(1978)          286 及以后的 x86(含 QEMU 模拟的)
   ┌──────────────┐         ┌──────────────┬─────────────────────────┐
   │  ES: 16 位   │         │  ES: 16 位   │  隐藏缓存(你碰不到)    │
   │  [ 段值 ]    │         │  [ 段值 ]    │  [ base | limit | 属性 ] │
   └──────────────┘         └──────────────┴─────────────────────────┘
   每次访存当场算            实模式:隐藏 base 自动 = 段值<<4
   seg<<4,不存             (所以 dump 能看到它,值就是 seg<<4)

所以这个实测要正确地解读成两层:

  1. 段值<<4 是硬件实打实算的——这点对 8086 和现代 CPU 都成立,dump 第②列 = 段值<<4 就是铁证。前面竖式里的左移相加,确实是 CPU 的电路动作,不是比喻。
  2. 但"把结果缓存进隐藏寄存器"是 286+ 的行为,不是 8086 的。8086 算完就扔;286 以后才把它存下来(这样保护模式下查一次 GDT、把 base 缓存住,后续访存就不必反复查表)。我们能 dump 出这一列,恰恰是因为跑的是现代 CPU。

一句话:8086 的段寄存器只有 16 位、只有段值,这点你的直觉完全正确。 后三列是它的后代(286 起)补上的"隐藏缓存",被 QEMU 如实地显示了出来。我们借现代 CPU 的这个缓存,反过来验证了 8086 当年那套 段值<<4 的算术——但要记得,缓存本身是演进的产物。

记住这个"隐藏缓存"——它在实模式下只是个存着 段值<<4 的小跟班,毫不起眼。可到了下一篇的保护模式,正是它摇身一变,缓存了从 GDT 里查出来的整个段描述符(base、limit、权限),成了段机制的核心。8086 的 16 位段值,怎么一步步长出这个隐藏缓存、再演变成保护模式的主角,正是这个系列要讲的主线。

彩蛋:复位瞬间,CS 的段值和隐藏 base 故意"对不上"

上面那个 dump 里还藏着一行值得说道的:

CS =0000 00000000 0000ffff 00009b00

CS=0000、隐藏 base 也是 0——可我的代码明明在物理 0x7c00 处跑。这是因为 BIOS 把 bootsector 加载到 0000:7c00(CS 段值=0,IP=0x7c00),段为 0、纯靠偏移定位,所以 base=0 合理。

但如果在 QEMU 刚复位、一条指令都还没执行时去 dump,会看到一个反直觉的结果。把 QEMU 用 -S 冻结在复位状态再 dump(脚本见文末附录),真实输出:

CS =f000 ffff0000 0000ffff 00009b00      ← 段值 f000,但隐藏 base = 0xffff0000
ES =0000 00000000 0000ffff 00009300
SS =0000 00000000 0000ffff 00009300
DS =0000 00000000 0000ffff 00009300

盯着第一行:CS 的段值是 0xf000,但它的隐藏 base 不是 0xf000<<4 = 0xf0000,而是 0xffff0000 这俩按实模式的 <<4 规则根本对不上。

为什么?因为现代 x86 复位后,CPU 要去执行 BIOS 的第一条指令,而 BIOS ROM 被映射在 4GB 地址空间的最顶端。为了让复位后立刻能取到 BIOS,Intel 直接把 CS 的隐藏 base 硬预置成 0xffff0000,配上复位时 IP=0xFFF0,第一条指令就从 0xffff0000 + 0xfff0 = 0xfffffff0 取——正好是 4GB 顶端往下 16 字节,BIOS 入口。这时段值 0xf000 纯粹是个"占位",和隐藏 base 不满足 <<4 关系。直到执行了第一条远跳转(重新加载 CS),隐藏 base 才会被刷成 段值<<4,回到实模式的常规关系。

又一处 8086 与现代 CPU 的差异:真 8086 复位时 CS=0xFFFF、IP=0x0000,物理地址 = 0xFFFF<<4 + 0 = 0xFFFF0,落在 1MB 顶端往下 16 字节——因为 8086 地址空间就 1MB,BIOS 只能放那。而 286 以后地址空间变大、BIOS 挪到了 4GB 顶端,才有了上面这个 0xffff0000 的隐藏 base 把戏。这个 dump 抓的是现代 CPU,所以是后者。

这个"段值和隐藏 base 暂时对不上"的特例,恰恰最有力地证明了前面的结论:真正决定访存地址的,永远是那个隐藏 base,而不是你在汇编里看到、能写的那 16 位段值。 平时它俩满足 <<4 关系,让你误以为"段值就是一切";复位这一刻硬件故意把它们错开,隐藏 base 的主导地位就藏不住了。

这也顺带预告了下一篇的核心:到了保护模式,"段值"变成"选择子",而隐藏 base 不再是简单的 <<4,而是 CPU 从 GDT 里查出来、塞进去的一个任意 32 位基址。隐藏部分从"小跟班"转正成"主角",正是从这里开始的。

四、段重叠:同一个物理地址,有好多种写法

"段×16 + 偏移"这个拼法,有个很别扭的副作用:同一个物理地址,可以由许多组不同的 段:偏移 拼出来。

为什么?因为段和偏移在中间那 12 位(第 4~15 位)是重叠相加的。段往右挪一点、偏移往左补一点,结果可以不变。

拿物理地址 0x10000 举例,下面这几组 段:偏移 算出来都是它(真实输出):

  物理地址 0x10000 的几种写法:
  seg=0x1000 : off=0x0000  ->  (0x1000<<4)+0x0000 = 0x10000
  seg=0x0FFF : off=0x0010  ->  (0x0FFF<<4)+0x0010 = 0x10000
  seg=0x0800 : off=0x8000  ->  (0x0800<<4)+0x8000 = 0x10000
   同一个物理地址 0x10000,三种段:偏移 拼法:

   0x1000 : 0x0000   段在 0x10000,偏移 0      
   0x0FFF : 0x0010   段在 0x0FFF0,偏移往后 16 ├─► 全都落到 0x10000
   0x0800 : 0x8000   段在 0x08000,偏移往后 32K┘

   段起点不同,但配上不同的偏移,殊途同归指到同一个字节。

你看 0x0800:0x8000:段基址才 0x08000,但偏移给到 0x8000(32KB),一加,照样到 0x10000。段起点差了好远,靠偏移补回来了。

这就是"段重叠":相邻的段在物理内存上是大面积交叠的。段 0x1000 覆盖 0x10000~0x1FFFF,段 0x1001 覆盖 0x10010~0x2000F——它俩绝大部分是重合的,只差了 16 个字节。每往上数一个段号,段的起点才挪 16 字节(这 16 字节叫一个"节",paragraph),可段本身有 64KB 大。所以成百上千个段彼此层层叠叠铺在那 1MB 上。

顺便澄清一个容易踩的点:偏移最大是 0xFFFF。所以 0x0000:0xFFFF 这一组:

  seg=0x0000 : off=0xFFFF  ->  (0x0000<<4)+0xFFFF = 0x0FFFF

它到的是 0x0FFFF,差一点点够不到 0x10000——段是 0 的时候,单靠偏移最远只能摸到 0x0FFFF(64KB 减 1)。想够到 0x10000 及以上,段就必须非零。这也再次说明了第一节那个矛盾:偏移(16 位)自己确实只够 64KB,必须靠段补高位。

段重叠这件事,在当年是个真实的麻烦:两个看起来不一样的指针(段:偏移不同),可能指向同一块内存,比较指针、判断别名变得很绕。这也是"段"作为一个临时补丁,先天不干净的地方之一。

五、地址回绕:算出来超过 1MB,怎么办?

还有一个更刺激的边界情况。段和偏移都能取到最大值,那 0xFFFF:0xFFFF 拼出来是多少?

按公式算:

   0xFFFF << 4  =  0xFFFF0
                +    0xFFFF
                ───────────
                   0x10FFEF     ← 21 位了!超过了 1MB(0xFFFFF 是上限)

0x10FFEF 需要 21 位才能表示,可 8086 的地址总线只有 20 根。第 20 位(值 0x100000 那一位)压根没有对应的物理导线,它就这么被丢弃了。结果就是高位被截断,地址"绕回"到内存最低端:

   理论值   0x10FFEF   (21 位)
            ┌─ 第20位,无导线,丢弃
            ▼
   实际值  [1]0FFEF  ──►  0x0FFEF   (只剩低 20 位)

C 程序里 & 0xFFFFF 模拟的就是这个"只有 20 根线"的截断(真实输出):

  seg=0xFFFF : off=0xFFFF  ->  (0xFFFF<<4)+0xFFFF = 0x0FFEF
  0xFFFF:0xFFFF 理论值 = 0x10FFEF,但 20 位地址线只能表示到 0xFFFFF,
  高位被丢弃,实际访问 = 0x0FFEF(绕回了内存最底端)

0xFFFF:0xFFFF 本想指向 1MB 顶上再往外一点的地方,结果绕回了 0x0FFEF——内存最底端。这叫地址回绕(address wrap-around)

这个看似 bug 的行为,后来变成了一个真实存在、必须兼容的特性。当年有些 DOS 程序就故意利用这个回绕(比如用高段地址访问低端内存的某些技巧)。结果到了 80286、再到后来的机器,地址总线变宽了(24 根、32 根……),第 20 位真的有导线了,回绕不再自动发生——那些依赖回绕的老程序就崩了。

为了兼容,PC 上搞出了一个臭名昭著的硬件补丁:A20 Gate(A20 门)。它是一个能强行把第 20 根地址线(A20)钳成 0 的开关,专门用来模拟 8086 的回绕行为。开机默认关着 A20(强制回绕,兼容老程序),操作系统想用 1MB 以上的内存时,得先想办法把 A20 打开。这个开关的控制方式还特别奇葩(早期是通过键盘控制器的一个引脚),折磨了整整一代写引导程序的人。

A20 这段历史,是"段:偏移"这个临时补丁留下的技术债里最出名的一笔——一个 1978 年的地址截断行为,硬是被后续几十年的硬件背在身上。

六、收尾:段,本是一块凑高位的补丁

把这一篇收一下。8086 的段寄存器,从头到尾就是为了解决一个物理矛盾:

  • 矛盾:寄存器是 16 位的,只数得到 64KB;可地址总线有 20 根,内存能有 1MB。一个寄存器装不下一个能指全 1MB 的地址。
  • 解法:拿两个 16 位寄存器,一个当段基址(左移 4 位凑高位)、一个当偏移,段×16 + 偏移 拼出 20 位物理地址。CS/DS/SS/ES 分别给取指、数据、栈、附加数据用。
  • 副作用:段大面积重叠(同一物理地址有多种 段:偏移 写法);算过头会地址回绕(催生了 A20 Gate 这种历史包袱)。
  • 本质:实模式下,段寄存器里装的就是段基址本身,CPU 拿来直接左移相加,没有任何检查、没有表、没有权限概念。

所以"段"在 8086 这里,纯粹是个寻址补丁——位数不够,拿另一个寄存器凑高位,仅此而已。它和"内存保护"、"权限隔离"这些后来挂在它身上的概念,一点关系都没有。那时候根本没有"保护":任何程序都能往任何段寄存器写任何值,然后访问 1MB 里的任何一个字节,包括别人的代码、数据、甚至 BIOS。一个程序跑飞了,能把整个系统连同别的程序一起带走。

这套"裸奔"的模式,在单任务的 DOS 年代还能凑合。可一旦要同时跑多个程序、要保护操作系统不被应用程序搞崩,它就彻底不够用了。

于是到了 80286、特别是 80386,Intel 给段寄存器来了一次彻底的"变身":进入保护模式后,段寄存器里装的不再是段基址,而变成一个选择子(selector)——一个指向"段描述符表(GDT/LDT)"的编号。CPU 拿这个编号去查一张表,表里的段描述符才真正记着这个段的基址、有多大(界限)、需要什么权限才能访问。段第一次有了"边界"和"特权级","保护"二字才名副其实。

下一篇我们就进保护模式,把选择子的位结构GDT 长什么样、以及那个 64 位的段描述符逐位全拆开——这是理解后面 TSS、特权级、乃至 x86-64 为什么能把段"基本废掉"的根基。

附:实验环境与完整复现

1)搭一个能跑 x86 实模式的容器。 本机是不是 x86 都无所谓,Docker 用 QEMU 帮你模拟:

# Dockerfile.x86
FROM debian:bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
    nasm qemu-system-x86 gcc gdb binutils file ca-certificates \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /work
docker build --platform linux/amd64 -t x86lab -f Dockerfile.x86 .

这个 x86lab 镜像(约 200MB)后面整个系列都用得上。本文 QEMU 实测的 NASM 是 2.16.01、QEMU 是 7.2。

2)实模式段寄存器实测(第三节那段):把上面的 boot.asm 和跑 QEMU 的脚本放进容器执行即可,info registers 输出里每个段寄存器的第二列就是隐藏 base,对照段值能看到 base = 段值<<4

2')复位态彩蛋(第三节末尾那段 CS=f000 base=ffff0000):关键是给 QEMU 加 -S,让 CPU 冻结在复位瞬间、一条指令都不执行,再 dump:

# 镜像随便给一个可引导的就行,反正冻结在复位、根本不会执行它
qemu-system-i386 -drive format=raw,file=boot.img -display none -S \
    -monitor tcp:127.0.0.1:4466,server,nowait &
sleep 2
exec 3<>/dev/tcp/127.0.0.1/4466
printf 'info registers\nquit\n' >&3
cat <&3 | grep -iE "^(CS|DS|ES|SS) "

3)段:偏移算术模拟程序(第二、四、五节用的 seg8086.c,任意机器 gcc seg8086.c -o seg8086 && ./seg8086 都能跑):

#include <stdio.h>

// 模拟 8086 实模式地址生成:物理地址 = (段 << 4) + 偏移,再截到 20 位
static unsigned long phys(unsigned seg, unsigned off){
    unsigned long a = ((unsigned long)seg << 4) + off;
    return a & 0xFFFFF;   // 20 位地址线
}
static void show(unsigned seg, unsigned off){
    printf("  seg=0x%04X : off=0x%04X  ->  (0x%04X<<4)+0x%04X = 0x%05lX\n",
           seg, off, seg, off, phys(seg, off));
}
int main(void){
    show(0x1234, 0x5678);   // 基本拼接
    show(0xF000, 0xFFF0);   // 复位入口附近
    show(0x1000, 0x0000);   // 段重叠:以下三组都到 0x10000
    show(0x0FFF, 0x0010);
    show(0x0800, 0x8000);
    show(0xFFFF, 0xFFFF);   // 地址回绕:理论 0x10FFEF,截断成 0x0FFEF
    return 0;
}

下一篇:保护模式的段——选择子、GDT 与 64 位段描述符