段机制的演进与段选择子 —— 从 1MB 实模式到"扁平但带权限"的 64 位长模式
想看明白 x86-64 的段管理,得先知道它从哪来。本篇讲两件事:一是段机制的三次重构(实模式 → 32 位保护模式 → 64 位长模式),为什么地址翻译功能被彻底废弃、权限功能却被完整保留;二是长模式下仅存的那点东西——16 位段选择子(Segment Selector)的结构与取值,回答"CS=0x33、DS=0x18 这些数字到底怎么来的、是什么意思"。
相关:段选择子指向的 GDT 描述符、权限检查与特权级切换见 segment-management-gdt,观测调试见 segment-management-observation,分页与寻址实例见 segment-management-addressing;总纲见 segment-management。涉及的寄存器速查见 x86-64-registers。
一、x86 段管理的历史演进(实模式 → 32位保护模式 → 64位长模式)
要理解"为什么 x86-64 的段管理是这样子",必须先知道它从哪来。x86 的段机制经历了三次重大重构,每一次都为了解决上一代的痛。
1.1 实模式(16位):极简分段,无保护
这是 x86 CPU 上电后的初始状态,为兼容 8086 处理器而保留,关闭所有内存保护、分页机制、权限检查。
段寄存器直接存段基址(没有"描述符"概念),每个寄存器仅 16 位(CS/DS/SS/ES),寻址公式极其简单:
物理地址 = 段寄存器值 × 16 + 偏移量(16位)
= 段寄存器值 << 4 + 偏移量- 地址总线 20 位,最大寻址空间仅 1MB(0x00000~0xFFFFF)
- 偏移量 16 位,单段最大 64KB
- 段基址按 16 字节对齐,段之间可重叠,完全无内存隔离
这种设计的"好处"是简单——CPU 上电就能跑,适合 BIOS 和系统引导阶段。代价是没有保护、没有隔离,一个程序写飞就能搞垮整个系统。
1.2 32位保护模式:完整段描述符 + 两级分页
32位保护模式彻底重构了内存管理,核心变化:
- 段寄存器不再直接存段基址,改为存 16 位段选择子(Segment Selector)
- 真正的段属性(基址、界限、权限、类型)存在 GDT/LDT 的段描述符中,每描述符 8 字节
- 引入特权级机制(Ring 0~3),内核与用户态隔离
- 可选开启两级分页(页目录 + 页表),实现虚拟内存
32位寻址完整流程:
逻辑地址(段选择子:32位偏移量)
→ 解析选择子 → 索引 GDT/LDT → 读段描述符(基址+界限+权限)
→ 权限校验(偏移不越界、CPL/RPL ≤ DPL)
→ 线性地址 = 段基址 + 偏移量
→ [若开分页] 两级页表转换(CR3 → PDT → PT)
→ 物理地址- 地址总线 32 位,最大寻址 4GB
- 段描述符关键字段:32位基址、20位界限、粒度位 G(G=1 时单位 4KB,最大段 4GB)、DPL(0~3)
- GDT 全局唯一(内核维护)、LDT 每进程私有
1.3 64位长模式:段地址翻译废弃,权限保留
到了 x86-64,CPU 设计师做了一个大胆决策——段的地址翻译功能彻底扁平化:
- CS/DS/ES/SS 的基址强制置 0、界限强制全覆盖,不再参与地址计算
- 逻辑地址直接等价为线性地址(段基址 0 + 偏移量 = 偏移量本身)
- 只保留 DPL 权限校验和段选择子机制——这就是段管理在长模式下的全部内容
- 分页升级为四级页表(PML4 → PDPT → PDT → PT),48 位有效虚拟地址
为什么这样设计? 因为:
- 32 位保护模式的段+页双重翻译增加了硬件复杂度,大多数 OS 本质上只用"平坦模型"(段基址=0,界限=4GB)
- 64 位地址空间巨大,用分段做地址隔离已经没必要——分页的 U/S 位 + 四级页表足够
- 但权限这一层不能丢——内核/用户态的边界必须由硬件保证,所以 DPL 检查被完整保留
1.4 三模式段机制速览
| 维度 | 实模式(16位) | 32位保护模式 | 64位长模式 |
|---|---|---|---|
| 段寄存器存什么 | 直接存段基址(16位) | 16位段选择子(索引+TI+RPL) | 同32位,选择子 |
| 段属性存哪里 | 无——寄存器即属性 | GDT/LDT 中 8 字节描述符 | GDT 描述符(基址/界限忽略) |
| 地址翻译 | 段×16+偏移 → 物理地址 | 基址+偏移→线性地址(→分页→物理) | 偏移量=线性地址(扁平) |
| 界限检查 | 无(仅偏移 16 位限制) | 有(20位界限+粒度位) | 无(强制最大值) |
| 权限检查 | 无 | CPL/RPL ≤ DPL | CPL/RPL ≤ DPL 严格执行 |
| 分页 | 无 | 两级(PDT+PT) | 四级(PML4+PDPT+PDT+PT) |
| 最大寻址 | 1MB | 4GB | 256TB(48位虚拟地址) |
演进脉络:无保护单一寻址 → 段页结合保护寻址 → 分页主导、权限辅助寻址。核心目标是提升内存容量、安全性、多任务适配能力。
二、先破除一个常见误解:x86-64 并没有"去掉"段
很多资料说"x86-64 长模式下分段被废弃了,只靠分页做保护"。这个说法不完全对——从上节的演进可以看到:
- 确实,长模式下段的基址和界限被硬件强制为 0 和最大值(即"平坦模型"),分段不做地址翻译。
- 但段描述符中的 DPL(Descriptor Privilege Level)字段和段选择子中的 RPL(Requested Privilege Level)仍然被 CPU 严格执行,构成第一层保护。
- 此外,
syscall/sysret指令依赖段选择子来切换特权级——没有段机制,根本进不了内核。
段地址翻译功能的演进:
实模式 32 位保护模式 64 位长模式
─────────────────────────────────────────────────────────────────────────────
段寄存器=基址(16位) 选择子→描述符→基址(32位) 基址强制为 0,不做翻译
无界限检查 界限检查(20位+粒度) 界限强制为最大值,不检查
无权限检查 DPL 权限检查 ✓ DPL 权限检查 ✓ 仍然执行
无分页 两级分页(可选) 四级分页(必须)
段选择子+RPL 段选择子+RPL ✓ 仍然执行
CS.L 位区分 32/64 位模式一句话:x86-64 废掉了分段的地址翻译功能,但保留了它的权限管理功能。
三、段选择子:16 位的"身份令牌"
每个段寄存器(CS/DS/SS/ES/FS/GS)实际存储的是一个 16 位的段选择子(Segment Selector),它告诉 CPU"我想用 GDT/LDT 中第几个描述符,以什么身份(特权级)去访问"。
3.1 段选择子的结构
15 3 2 1 0
├──────────────────────────────────┬───┬───────┤
│ Index (13 bits) │ TI│ RPL │
│ GDT/LDT 中的表项索引 │ │ │
└──────────────────────────────────┴───┴───────┘
TI (Table Indicator): 0 = GDT, 1 = LDT
RPL (Requested Privilege Level): 00 = ring 0, 11 = ring 3以 CS = 0x33 为例拆解:
0x33 = 0b 0000 0000 0011 0011
│ ││ └── RPL = 11 (ring 3)
│ │└── TI = 0 (GDT)
│ └── Index = 6 → 指向 GDT[6]
└── 高 13 位 = 索引3.2 Linux x86-64 中所有段选择子的值
| 寄存器 | 用户态值 | 内核态值 | 对应 GDT 索引 | DPL | 含义 |
|---|---|---|---|---|---|
| CS | 0x33 | 0x10 | 用户 6 / 内核 2 | 3 / 0 | 代码段,CPL 由 CS 的 RPL 决定 |
| SS | 0x2B | 0x18 | 用户 5 / 内核 3 | 3 / 0 | 栈段 |
| DS | 0x0 (被忽略) | 0x18 | — / 内核 3 | — / 0 | 数据段 |
| ES | 0x0 (被忽略) | 0x18 | — / 内核 3 | — / 0 | 附加数据段 |
| FS | 用户 TLS 值 | 内核 per-CPU 值 | 各自不同 | 3 / 0 | 线程局部存储 / per-CPU |
| GS | 用户 TLS 值 | 内核 per-CPU 值 | 各自不同 | 3 / 0 | 同 FS,swapgs 切换 |
注意:用户态的 DS/ES 经常显示为
0x0,因为长模式下硬件不检查 DS/ES 的基址和界限,但它们的隐性属性(DPL=3)仍然由 CPU 内部记住。
3.3 为什么这些值是 0x10、0x18、0x33、0x2B
GDT 索引 × 8 = 段选择子中 Index 部分对应的偏移
0x10 = GDT[2] → 内核代码段 (2 × 8 = 0x10, RPL=00)
0x18 = GDT[3] → 内核数据段 (3 × 8 = 0x18, RPL=00)
0x33 = GDT[6] → 用户代码段 (6 × 8 = 0x30, RPL=11 → 0x33)
0x2B = GDT[5] → 用户数据段 (5 × 8 = 0x28, RPL=11 → 0x2B)段选择子的低 3 位(TI + RPL)不参与索引计算,所以:
- GDT[2] → Index=2 → 选择子基值
0x10,加 RPL 后:内核态用0x10(RPL=0),用户态不会用 - GDT[6] → Index=6 → 选择子基值
0x30,加 RPL=3 后:0x33
规律:所有段选择子都是"GDT 索引 × 8 的基值 + 低 3 位(TI 恒为 0 用 GDT,RPL 按用户态 3 / 内核态 0)"。0x10/0x18/0x33/0x2B 这四个数是 Linux x86-64 的 ABI 常量,用户态代码的
CS=0x33、SS=0x2B在 gdb 里随时能看到(见观测调试篇的实测快照)。
一句话总结
x86 段机制三次重构的脉络是"地址翻译逐渐退场、权限管理走到台前":实模式段寄存器直接存基址、无任何保护;32 位保护模式引入 GDT/LDT 描述符和 Ring 特权级,段参与地址计算;64 位长模式把基址强制为 0、界限强制全覆盖,地址翻译扁平化,但 DPL/RPL 权限检查被完整保留——"x86-64 去掉段"的说法不准确,去掉的只是地址翻译功能。长模式下一个段寄存器存的是 16 位段选择子:高 13 位是 GDT/LDT 索引、TI 位选表、低 2 位是 RPL;Linux 里用户态
CS=0x33(GDT[6] 索引 6×8=0x30 + RPL=3)、SS=0x2B(GDT[5] 0x28 + 3)、内核态CS=0x10(GDT[2])、DS/SS=0x18(GDT[3]),这四个值是 ABI 常量永不变。选择子只是"令牌",令牌背后描述符里的权限字段(DPL)才是保护的关键——详见GDT 与权限检查篇。