行以后,调试程序时,我们经常用到 INT 3,也就是熟悉的 CC 断点。但这条指令执行以后,CPU 去哪里寻找处理程序?如果从 3 环进入了 0 环,原来的代码位置和堆栈又保存在哪里?
这两节课围绕这些问题展开:先在 32 位环境下认识中断门,构造描述符并观察返回栈,再到 Win11 x64 中完成一次进入和返回实验。本文按视频里的顺序,把每一步及其对应结果整理出来。
IDT 是中断描述符表,IDTR 是记录这张表位置和界限的寄存器。第一课先在 WinDbg 中查看 IDTR,再用 dq 读取表中的内容。
r idtr dq @idtr
32 位环境中的门描述符占 8 字节,dq 显示的一个 8 字节数就对应一个表项。执行 INT 3 时,使用的是编号 3,也就是 IDT[3]。表项从 0 开始计数,不能把屏幕上的第三个数直接当成它。

图1:第一课通过编号3定位IDT中的门描述符
这与前面的调用门查找有所不同:调用门需要先拆段选择子,再根据索引查表;这里的中断编号直接用于定位 IDT 表项。
但找到表项,还没有得到完整的跳转信息。表项里存放的是门描述符,CPU 还要继续解释其中的字段。
第一课把中断门与调用门的结构放在一起理解。阅读中断门时,重点看三部分:入口偏移、段选择子,以及权限和类型字段。
32 位入口偏移被分成高、低两个 16 位字段,把它们拼接起来,才得到完整的 32 位偏移。段选择子则指向目标代码段,需要继续到 GDT 中查找相应描述符。
因此,查找过程可以连成一条线:
中断编号 → IDT中的门 → 门内的段选择子 → GDT中的目标代码段 └→ 拼接入口偏移,确定处理代码的位置
构造实验用的门时,视频填写的属性字节是 EEh:P 为 1、DPL 为 3,类型为 Eh,对应本实验的中断门。门的 DPL 与目标代码段的权限要分开看,不能因为门的 DPL 是 3,就认为处理代码也只能在 3 环执行。
例如,第一课的目标选择子是 0008。按前面课程学过的拆法,它指向 GDT 中索引为1的表项。这样,门里的选择子、GDT里的代码段描述符,以及最后执行处理代码的位置,才建立起对应关系。只拼出函数地址,却没有看选择子指向哪里,仍然没有把整个跳转过程读完整。
这也是为什么实验还要填写目标代码段选择子:门解决入口问题,选择子进一步指向要进入的代码段。
第一课接下来强调的是换栈。对于课中从 3 环进入 0 环的情况,CPU 会切换到内核栈,其位置来自 TSS。课程在这里先说明来源,没有继续展开 TSS 的内部结构。
换栈以后,返回原程序仍然需要旧的栈位置;跳转以后,也需要知道应该回到哪条指令。因此,这些信息要先保存到新栈中。

图2:32位跨级进入时,内核栈保存的返回信息
图中从栈顶向下,依次为返回 EIP、旧 CS、旧 EFLAGS、旧 ESP、旧 SS。它们分别保留了返回地址、原代码段、标志寄存器和原栈位置。
理解这里,不妨联系普通函数调用:调用后要能返回,就必须留下返回信息。中断门涉及的状态更多,跨级时还换了栈,所以保存的内容也更多。
与调用门对照时,课程特别指出了 EFLAGS。中断返回需要恢复这部分状态,不能直接套用调用门的返回方式。第一课实际代码使用 IRETD,与这里的 32 位返回栈对应。
实验目标是自己构造一个中断门,让程序通过它进入自定义函数。
先查看 IDT 的范围。视频中表界限为 0x7FF,对应大小为 0x800 字节;每项 8 字节,因此共有 0x100,也就是 256 项。
这里的界限是最后一个有效字节的偏移,计算总大小时要加1。编号从0开始,256项对应的编号范围是00h到FFh。视频先展开整张表,再选择实验位置,正是为了把表的范围、表项大小和编号关系放在一起看。
随后,课程在当时的表中选取偏移 0x100 的位置。这里要区分“字节偏移”和“中断编号”:
表项编号 = 0x100 ÷ 8 = 0x20
所以程序写的是 INT 20h。它要找的是 IDT[20h],该项在这张32位表中的位置才是“基址加0x100”。
接着运行测试程序,打印 GetRegister 的地址,并等待输入。等待期间,在 WinDbg 中填写门描述符。视频中的处理函数核心代码是:
void __declspec(naked) GetRegister()
{
__asm {
int 3
mov tmp, 1
iretd
}
}其中 INT 3 用于进入后停住观察,mov 是处理函数里的操作,IRETD 用于中断返回。主程序在等待结束后执行 INT 20h。
主程序的顺序也值得对照:先打印处理函数和 main 的地址,再打印初始 tmp,通过 getchar() 等待,之后触发中断,最后再次打印 tmp。这个等待点给调试器留下了修改IDT的时间;处理函数地址尚未取得时,就无法完成入口字段的填写。
本次32位实验的入口为 010A1000,拆分后,高16位是 010A,低16位是 1000。再填入属性 EE、中间的 00 和目标选择子 0008,得到:
010A EE 00 0008 1000
合起来就是 010AEE0000081000。将它写入目标表项后,再读回确认,检查地址与属性是否填对。这里的地址来自视频当次运行,自己实验时应使用自己程序打印的地址。
读回时也按相同方向拆解:010A和末尾的1000还原入口,中间的0008对应选择子,EE对应属性。这样检查,能够把“命令写进去了”和“写进去的内容正确”区分开,避免只看到内存数值变化就继续执行。
视频还读取并调整了 CR4,关闭该实验涉及的 SMEP 保护,再恢复运行。随后调试器在自定义函数中的 INT 3 处停住,反汇编显示的正是 INT 3、变量赋值和 IRETD。

图3:32位实验进入自定义函数,源代码与反汇编相互对应
跳进函数以后,第一课继续执行:
dd esp
这一步把前面的栈示意图变成了实际数据。视频中的栈内容如下:
| 栈中的值 | 对应内容 |
|---|---|
| 010A1116 | 返回 EIP |
| 0000001B | 旧 CS |
| 00000212 | 旧 EFLAGS |
| 004FF7C0 | 旧 ESP |
| 00000023 | 旧 SS |
接着执行 u 010a1116,查看返回地址处的代码,再对照触发中断的位置,就能看出程序返回后会从哪里继续。
这里不要混淆两个地址:返回EIP指向要继续执行的代码,旧ESP指向进入之前的栈。前者用反汇编命令查看,后者则按内存内容查看。第一课正是把这些值一个个拿出来核对,而不是仅凭数值看起来像地址,就认定返回现场已经理解了。

图4:读取ESP指向的内容,并反汇编栈中保存的返回地址
旧 CS、旧 ESP 和旧 SS 同样不是无关数据。进入处理程序时,代码段和栈已经发生变化,返回时必须恢复原状态,才能继续执行原来的程序。
第一课演示到这里,重点是观察入口和返回栈。视频保留了用于调试的 INT 3,并说明完整体验返回流程时,应去掉该断点、重新编译再做实验;不能把这次停住观察直接写成已经展示了完整返回。
第二课换到 Win11 x64,实验思路延续上一课,但门描述符扩展为16字节。

图5:64位门描述符,入口偏移分成三个字段
入口偏移由低16位、中间16位和高32位组成。视频打印的函数地址为 00007FF60EBC11A0,因此拆分为:
Offset 15..0 :11A0 Offset 31..16 :0EBC Offset 63..32 :00007FF6
结构中还出现了 IST 字段。本次实验将其设为0,课程先使用已有的内核栈完成流程,不展开 IST 的其他用法。
代码方面,汇编改为单独的 ASM 文件。第二课去掉了处理函数中的断点和变量赋值,将其简化为:
GetRegister PROC iretq GetRegister ENDP DoInt20 PROC int 20h ret DoInt20 ENDP
这次精简是课程主动限定实验范围:先验证描述符和返回流程。视频说明,继续加回断点、变量访问,会牵涉后续的代码段和页表内容,因此本课没有沿着这些问题展开。把32位函数中的操作原样搬过来,就改变了第二课实际演示的内容。
这里有两次不同的返回:IRETQ 从中断处理代码返回到 INT 20h 后面;随后 RET 再从 DoInt20 返回调用它的C代码。两条指令对应的是不同的进入方式。
同样执行 INT 20h,现在每项占 0x10 字节,所以目标位置变为:
IDT基址 + 0x20 × 0x10 = IDT基址 + 0x200
视频直接使用表达式查看:
dq @idtr+20*10
这里按视频中的十六进制口径计算。不能继续沿用32位实验的 0x100 偏移。
另一个重点是 dq 的显示方式。它显示的每个数仍是8字节,因此两个相邻的数才组成一个16字节门描述符。低地址的8字节在前,高地址的8字节在后;不能照着结构图从上往下直接抄进命令。
填选择子时也重新核对:上一课是 0008,本次x64实验使用 0010。结合三段入口偏移、属性和保留字段,视频构造出的两个8字节数为:
低地址8字节:0EBCEE00001011A0 高地址8字节:0000000000007FF6
按这个顺序写入,再用 dq 读回:
eq @idtr+20*10 0ebcee00001011a0 0000000000007ff6 dq @idtr+20*10
核对时,把两个数重新拆回字段,检查入口地址是否仍是程序打印的值,选择子、属性和 IST 是否与本次构造一致。
完成描述符修改后,第二课同样检查并调整 CR4。画面中的值从 170678 改为 70678,随后读回确认,再继续运行。
这一步与写门描述符是分开的:前者调整本次实验的处理器保护设置,后者决定中断入口。视频两处都进行了读回检查。文中的函数地址、选择子与CR4数值描述的是当次虚拟机实验,不是所有机器都可以照抄的一组固定值。
控制台先前已经输出一次 tmp=0;执行 INT 20h 后,又出现第二次 tmp=0。

图6:Win11实验结束,描述符读回结果与第二次输出
为什么没有变成1?因为第二课已经去掉变量赋值,处理函数只剩 IRETQ。这次实验观察的是进入之后能否返回、主程序能否继续输出,而不是验证变量被修改。
至此,两课的过程对应起来了:32位课从 INT 3 引出IDT,构造自己的门,再用返回栈解释“怎样回来”;Win11课重新核对16字节结构和读写顺序,用精简代码跑通进入与返回。学习时把编号、表项、函数地址、返回栈逐个对上,才能真正读懂调试器里的这些数值。