1 分钟阅读 次阅读

概要:介绍中断描述符表(IDT)与 IDTR 寄存器:256 个门描述符的结构(中断门、陷阱门、任务门)、CS:EIP 定位与 DPL 权限检查,以及现代操作系统通过“跳板函数 + 软件中断表”的二次转发设计。

1 IDT 到底藏在哪?(IDTR 寄存器)

在内存里,IDT 纯粹就是一个包含了 256 个元素的数组(表)。 但 CPU 怎么知道这个表在内存的哪里呢?这里有个专门的硬件寄存器叫 IDTR(IDT Register)。 操作系统在开机初始化的时候,会在内存里建好这个表,然后用一条特权指令(lidt)把这个表的起始物理内存地址表的总长度塞进 IDTR 寄存器里。以后 CPU 只要一遇到中断,直接去 IDTR 指向的地址找就行了。

2 这 256 扇门背后是什么?(门描述符 Gate Descriptor)

表里的每一个条目(占 8 个字节或 16 个字节),术语叫作“门”。咱们前面说的目标 %cs%eip 以及门禁权限 DPL,全都被打包塞在这个数据结构里。

常见的门其实分这几种:

  • 中断门(Interrupt Gate):专门用来对接外设硬件的中断(比如键盘敲击、网卡收发、你搞硬件很熟悉的那些定时器脉冲)。走这扇门的特点是:CPU 进去办事的时候,会自动屏蔽其他同级或低级硬件中断(把 EFLAGS 寄存器里的 IF 标志位清零),防止处理到一半又被打断。
  • 陷阱门(Trap Gate):主要处理软件异常(比如除零错误、缺页)或系统调用。它和中断门最大的区别是,走陷阱门不会自动屏蔽其他中断,内核还能继续响应外设硬件的请求。
  • 任务门(Task Gate):这是 x86 早期设计用来做硬件级任务切换的,但后来 Linux 等现代操作系统嫌它太笨重低效,基本不用它了,现在都是靠软件自己搞定上下文切换。

这是 32 位中断门描述符的内存布局(上面是高 32 位,下面是低 32 位):

32 位 IDT 门描述符 (8 字节 / 64 比特)

63                               48 47 46  45 44 43    40 39        32
+----------------------------------+--+------+--+--------+-------------+
|                                  |P | DPL  |S |  Type  |  Reserved   |
|      Offset [31:16] (EIP高位)    |  |      |  |        |   (全 0)    |
+----------------------------------+--+------+--+--------+-------------+
31                               16 15                             0
+----------------------------------+-------------------------------+
|                                  |                               |
|    Segment Selector (CS 段选择子)|     Offset [15:0] (EIP低位)   |
+----------------------------------+-------------------------------+

为什么 Offset(也就是 %eip 的地址)被硬生生劈成了两半,分别放在两头?

这纯粹是 Intel 为了“祖传代码”付出的代价。当年 16 位 CPU(比如 80286)的描述符只有低 32 位的内容。后来升级到 32 位 CPU 时,为了兼容老系统,Intel 不敢动原来的低 32 位排版,只好把多出来的 16 位地址空间硬塞到了高 32 位的最左边。

2.1 怎么找到要执行的代码?(CS : EIP)

  • Segment Selector(16 到 31 位):占 16 位。这玩意儿就是直接往 %cs 寄存器里塞的值。它告诉 CPU,中断处理函数在内核的哪一个代码段里。
  • Offset(0到15位 + 48到63位):拼起来正好 32 位。这玩意儿就是直接往 %eip 寄存器里塞的值。它告诉 CPU,中断处理函数在这个内核代码段里的具体偏移位置。
  • 总结:发生中断时,CPU 硬件会自动把这两块数据扣出来,拼成你要跳转的 CS:EIP

2.2 怎么控制权限?(安检区)

主要看高 32 位中间那一小撮属性位(40 到 47 位):

  • DPL(45 到 46 位):占 2 位。这就是咱们之前说的“房间门上的安全要求”。如果是 00(Ring 0),说明只有内核能主动调这扇门;如果是 11(Ring 3),说明普通打工人程序也能进(比如系统调用)。
  • P (Present,第 47 位):占 1 位。表示这个中断处理代码现在在不在物理内存里。正常情况下必须是 1,如果是 0,CPU 一跳过去就会立刻触发缺页异常。

2.3 这是个什么门?

  • Type(40 到 43 位):占 4 位。这里用来标识这扇门的类型。
  • 如果是 1110(十六进制 0xE):说明这是个中断门(Interrupt Gate)。CPU 走这扇门时,会顺手把硬件中断给关了(清空 IF 位),防止在处理比如硬盘中断时,又被鼠标中断给搅和了。
  • 如果是 1111(十六进制 0xF):说明这是个陷阱门(Trap Gate)。走这扇门不关硬件中断,通常用来做系统调用。

顺带一提,如果你现在搞的是 64 位的系统(x86_64 / AMD64),因为内存地址变成了 64 位,原来的 8 字节根本塞不下新的 %rip(64位指令指针)。所以 64 位系统下的描述符直接扩展到了 16 个字节(128 比特),在上面这个结构的基础上,又在顶部硬加了 8 个字节,专门用来放高 32 位的地址,其他的核心逻辑和 32 位是一模一样的。

3 中断处理程序的地址会不会改变

3.1 从纯硬件(CPU)角度看:它随时可以被改

只要你有 Ring 0 的权限(也就是拿着内核的门禁卡),你随时可以往 IDTR 寄存器指向的那块内存里写入新的数据。 你把第 0x80 个表项里的 Offset 换成另一个地址,CPU 下次遇到系统调用时,就会毫不犹豫地往新地址跳。这是硬件底层的物理机制,它是完全允许被修改的。黑客搞内核 Rootkit 提权,最经典的招数之一就是“IDT Hooking(挂钩)”——偷偷把某个关键门里的地址替换成木马代码的地址。

3.2 从现代操作系统(Linux/Windows)角度看:它通常被“焊死”了

虽然硬件允许你改,但Linux 觉得频繁去改 IDT 这个底层硬件表太危险、太不灵活了。

所以现代 OS 玩了一个“偷梁换柱”的套路:

  • 开机时“焊死”第一落脚点:Linux 在开机初始化时,会给 IDT 里的门统一填上系统自己写好的“通用跳板函数”(Stub/Trampoline)的地址。一旦填好,这扇门里的地址在这次开机运行期间就基本不再改变了。
  • 软件层面的“二级查表”:当硬件中断(比如网卡来数据了)触发时,CPU 走 IDT 门,跳到了那个“通用跳板函数”。这个跳板函数会干嘛呢?它会去查 Linux 内核自己维护的一个软件数组(比如 irq_desc 中断描述符数组)
  • 真正的改变发生在这里:当你插拔外设,或者加载卸载驱动模块时,Linux 不会去改 IDT 门里的地址,而是去改那个纯软件的 irq_desc 数组里的指针。

3.3 为什么要搞这么复杂(二级转发)?

  • 中断号不够用(IRQ 共享):x86 的硬件外设中断号非常有限。很多时候,网卡和 USB 控制器可能会被迫共享同一个硬件中断号。如果 IDT 门里直接写死具体某个驱动的地址,那就没法共享了。有了跳板函数,内核就可以在这个函数里把所有共享该中断号的驱动轮询叫醒一遍。
  • 安全与稳定:IDT 是整个系统的命门,把它设为只读页面(Read-Only),能极大防御恶意篡改。驱动程序的加载和卸载只在软件表里折腾,不碰硬件表,系统会稳定得多。

总结一下:门里存储的确实是处理程序的入口地址。但在现代操作系统中,为了安全和灵活,门里的地址通常指向一个万年不变的“前台接待员(跳板函数)”,而真正会随着驱动加载而改变的,是前台接待员手里的“内部通讯录(软件中断表)”。

留下评论