分段机制详解:段表、段选择子、逻辑地址到线性地址的转换
好,咱们今天来聊聊分段机制。说实话,分段这个概念,现在很多做应用开发的程序员几乎碰不到了。但如果你搞内核、搞虚拟化,或者像我一样调过几年内存管理的bug,那你一定会对它又爱又恨。
分段,说白了就是给内存“切块”。每个块有独立的属性,比如可读、可写、可执行。你想想看,如果没有分段,整个内存空间就是一整块,代码和数据混在一起,那多危险?
逻辑地址与线性地址
先理清两个概念:逻辑地址和线性地址。
- 逻辑地址:程序里看到的地址。比如你写
mov eax, [0x1234],这个0x1234就是逻辑地址。 - 线性地址:经过分段转换后得到的地址。如果没开启分页,线性地址就等于物理地址。
转换过程很简单:逻辑地址 → 分段单元 → 线性地址。嗯,就这么一步。
段选择子:16位的“身份证”
段选择子是个16位的结构。我刚开始学的时候,总觉得它很小,能装多少信息?其实它不装地址,它装的是索引和权限。
| 位域 | 名称 | 说明 |
|---|---|---|
| 15-3 | Index | 段表索引,共13位,支持8192个段描述符 |
| 2 | TI | 0=GDT,1=LDT |
| 1-0 | RPL | 请求特权级,0最高,3最低 |
举个例子:段选择子 0x0013,二进制是 0000 0000 0001 0011。拆开看:Index=2,TI=0(GDT),RPL=3。说白了,它指向GDT中的第2个段描述符,并且请求的是用户态权限。
核心要点:段选择子不直接包含地址,它只是一个“钥匙”,用来在段表里找到真正的段描述符。
段描述符:8字节的“户口本”
段描述符才是真正描述段信息的结构。每个段描述符8个字节,里面包含了段的基地址、段限长、访问权限等。
我记得有一次调试一个内核模块,发现段限长设错了,导致访问越界时没有触发异常。查了两天才找到原因——原来是段描述符的G位(粒度位)没设置对。G位为0时,段限长单位是字节;G位为1时,单位是4KB。你想想看,如果忘了设G位,段限长最大只有1MB,稍微大点的程序就跑飞了。
段描述符的结构比较复杂,我直接给个简化版:
段描述符(8字节):
- 段基地址(32位):分三段存放,位置比较分散
- 段限长(20位):同样分两段
- 访问权限字节(8位):包括P位(存在位)、DPL(描述符特权级)、S位(系统/代码数据)、类型位等
- 其他标志位:G位(粒度)、D/B位(默认操作数大小)、AVL位(软件可用)
我的小技巧:写内核代码时,我习惯用结构体来操作段描述符,而不是手动拼位域。Linux内核里就有现成的 struct desc_struct,直接用就好,别自己造轮子。
段表:GDT与LDT
段表就是段描述符的数组。x86架构下有两种段表:
- GDT(全局描述符表):全局共享,所有进程都能访问。通常包含内核代码段、数据段,以及一些系统段。
- LDT(局部描述符表):每个进程可以有自己的LDT。不过现代操作系统基本不用LDT了,Linux里也只在早期版本用过。
GDT的地址存储在 GDTR 寄存器中。系统启动时,内核会初始化GDT。我记得在写Bootloader时,第一步就是设置GDT,否则CPU连保护模式都进不去。
注意:GDT的第一个描述符必须是空描述符(全0)。这是硬性规定,用来捕获段选择子未初始化的情况。我曾经见过有人忘了这个,结果系统一启动就三重故障——嗯,那种感觉,就像你刚出门发现钥匙忘带了。
逻辑地址到线性地址的转换流程
好了,现在我们把整个流程串起来。假设CPU执行了一条指令:
mov eax, [0x1234]
这里的 0x1234 是逻辑地址的偏移部分。段寄存器 DS 里存的是段选择子。转换步骤如下:
- CPU从段寄存器中取出段选择子(比如
0x0013)。 - 检查TI位:0表示用GDT,1表示用LDT。这里TI=0,所以查GDT。
- 用Index=2去GDT中找第2个段描述符(注意,GDT索引从0开始)。
- 从段描述符中取出基地址(比如
0x00000000)。 - 检查偏移量
0x1234是否超过段限长。如果超过,触发#GP异常(通用保护故障)。 - 通过!线性地址 = 基地址 + 偏移量 =
0x00000000 + 0x1234 = 0x00001234。
你看,整个过程其实不复杂。但为什么很多人觉得分段难?因为细节太多。比如段限长检查、特权级检查、DPL与RPL的比对……这些都是在硬件层面自动完成的,但一旦出错,调试起来非常痛苦。
避坑指南:我曾经在移植一个RTOS时,发现用户态程序一运行就崩溃。查了半天,原来是段描述符的DPL设成了0(内核态),而段选择子的RPL是3(用户态)。硬件检查发现DPL < RPL,直接拒绝访问。嗯,这种低级错误,犯过一次就再也不会忘了。
现代操作系统的分段策略
说实话,现在的操作系统基本把分段“架空”了。Linux和Windows都采用平坦内存模型:
- 所有段的基地址都设为0
- 段限长都设为4GB(32位)或更大(64位)
- 逻辑地址 ≈ 线性地址
为什么这么做?因为分页机制已经足够灵活了,分段反而增加了复杂度。64位模式下,分段更是被大幅弱化,段基地址和段限长基本被忽略。
但注意,分段并没有完全消失。比如 FS 和 GS 段寄存器在用户态仍然有用,常用于线程本地存储(TLS)。Windows里 FS:[0] 指向线程环境块(TEB),Linux里 GS 用于per-CPU变量。这些用法,说白了就是把分段当成了一个“快捷寻址”工具。
我的建议:学习分段机制时,不要死记硬背段描述符的每个位。理解它的设计思想——权限隔离、地址转换、边界检查——比记住具体布局更重要。等你真正需要调一个段相关的bug时,再回头查手册也不迟。
好,分段机制就讲到这里。下一节我们会聊分页机制,那才是现代内存管理的重头戏。到时候你会发现,分页和分段其实是互补的——分段管“逻辑隔离”,分页管“物理映射”。两者配合,才能构建出稳定高效的内存系统。