这一章要讲的是计算机里最重要的机制之一:中断(interrupt)和异常(exception)。先从最基本的问题开始:CPU 为什么需要它们。

1. CPU 是个"死心眼"

CPU 的工作方式非常简单:

  1. 看指令指针(IP)指向哪个内存地址
  2. 取出那里的指令,执行
  3. IP 往后移,回到第 1 步
    它不会思考,也不会判断"这段内存是不是真的代码"。你把 IP 指向哪里,它就老老实实执行哪里的内容,一条接一条,一直跑下去。这就像一个人认准一条路,顺着跑到天黑。

取指令
地址在 IP

执行指令

IP 指向下一条

问题来了:如果 CPU 只会一路往下跑,那么下面这些事就都做不成:

  • 你按了键盘,CPU 怎么知道?
  • 程序里写了 10 / 0,CPU 总不能硬算下去吧?
  • 调试器想让程序停在某一行,怎么停?
  • 系统同时跑很多程序,谁来把 CPU 从一个程序手里"抢"回来交给另一个?
    要解决这些问题,必须让 CPU 能够暂时放下手头的事,去处理一件突发的事,处理完再回来接着干。这个机制就叫中断和异常。

2. 中断和异常有什么区别

两者做的事情一样:打断当前执行流,跳去执行一段专门的处理代码。区别在于"是谁引起的"。

对比项中断(interrupt)异常(exception)
来源CPU 外部的硬件设备CPU 自己执行指令时发现的情况
与当前指令的关系无关,随时可能来就是当前这条指令引起的
同步性异步,什么时候来不确定同步,同一条指令同样条件下必然触发
典型例子时钟滴答、键盘按下、网卡收到数据除以零、访问不存在的内存、断点指令

可以打个比方:你正在写作业(CPU 执行程序)。

  • 门铃响了,是外面的人引起的,和你写到哪道题没关系,这就是中断
  • 你写到一道题发现算不出来、必须停下来想办法,是这道题本身引起的,这就是异常

2.1 异常再细分

异常按"处理完之后回到哪里"分成三类(x86 的分法):

类型保存的返回地址处理完后例子
错误(fault)出错的那条指令重新执行这条指令除零、缺页
陷阱(trap)出事那条指令的下一条接着往下执行断点、单步调试
中止(abort)不一定有意义一般无法恢复严重硬件错误、双重错误

为什么要区分?看缺页就明白了:程序访问一个还没加载进内存的页,CPU 触发缺页错误;操作系统把页调入内存;这时必须重新执行刚才那条访问指令才对,所以 fault 保存的是出错指令本身。而断点是程序员主动埋的,处理完如果还回到断点指令本身,就会无限触发,所以 trap 保存的是下一条。
整体分类如下:

打断正常执行流的事件

中断 interrupt
异步 来自外部设备

异常 exception
同步 由指令引起

可屏蔽中断
键盘 网卡 时钟

不可屏蔽中断 NMI
严重硬件故障

错误 fault
返回出错指令 例 除零 缺页

陷阱 trap
返回下一条 例 断点 系统调用

中止 abort
通常无法恢复

3. 一次中断或异常是怎么处理的

不管是哪种事件,CPU 的处理步骤基本一致:

  1. 识别:给每种事件编一个号,叫向量号。比如 x86 里 0 号是除零错误,3 号是断点,14 号是缺页
  2. 查表:CPU 用向量号去一张中断向量表里查,找到对应处理程序的入口
  3. 保存现场:把返回地址、标志位等压入栈,这样处理完才能回到原处
  4. 跳转执行:进入处理程序(通常在内核里),处理事件
  5. 返回:执行专门的返回指令(x86 叫 iret),从栈里恢复现场,继续原来的工作
    这里有一个关键细节:中断只在指令之间响应。一条指令执行到一半不会被打断,每执行完一条,CPU 检查一下有没有等待处理的中断请求,有并且允许响应(IF 标志为 1)才跳过去。

3.1 一个和数学有关的例子:时钟中断

操作系统常让时钟芯片以固定频率 fff 发出中断,相邻两次的间隔为:
T=1f T = \frac{1}{f} T=f1​
比如 f=100 Hzf = 100\,\text{Hz}f=100Hz,则 T=10 msT = 10\,\text{ms}T=10ms。每过 10ms,CPU 就被"敲一下",操作系统趁机更新时间,并判断当前程序是否用完了时间片,用完就切换到别的程序。多任务就是这样实现的,所以说任务切换、时间更新都依赖中断。

4. 动手写一个玩具 CPU

下面的 C++17 代码用一个很小的"玩具 CPU"演示上面的全部流程:

  • 取指执行循环
  • 向量表,把向量号映射到处理程序
  • 除零 fault:处理后重试同一条指令
  • 断点 trap:处理后继续下一条
  • 时钟中断:每执行 4 条指令来一次,只在指令边界响应
  • 保存现场和 iret 恢复现场
    保存为 cpu_interrupt_demo.cpp,编译运行:
g++ -std=c++17 -O2 -o cpu_interrupt_demo cpu_interrupt_demo.cpp
./cpu_interrupt_demo
// 中断与异常的最小演示 (C++17)
// 用一个"玩具 CPU"展示:取指执行循环、中断向量表、保存现场、处理程序、iret 返回
// 编译: g++ -std=c++17 -O2 -o cpu_interrupt_demo cpu_interrupt_demo.cpp
#include <array>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <vector>
// ---------------------------------------------------------------
// 1. 指令集:只有几条最简单的指令
// ---------------------------------------------------------------
enum class Op { MOV, ADD, DIV, INT3, NOP, HALT };
struct Instr {
    Op  op;
    int a;      // 目的寄存器编号
    int b;      // 立即数,或源寄存器编号(DIV 用)
};
const char* op_name(Op op) {
    switch (op) {
        case Op::MOV:  return "MOV";
        case Op::ADD:  return "ADD";
        case Op::DIV:  return "DIV";
        case Op::INT3: return "INT3";
        case Op::NOP:  return "NOP";
        case Op::HALT: return "HALT";
    }
    return "?";
}
// ---------------------------------------------------------------
// 2. 向量号约定(参考 x86):0 号是除零错误,3 号是断点,32 号是时钟中断
// ---------------------------------------------------------------
constexpr int VEC_DIVIDE_ERROR = 0;
constexpr int VEC_BREAKPOINT   = 3;
constexpr int VEC_TIMER        = 32;
// ---------------------------------------------------------------
// 3. CPU:寄存器、标志位、栈、向量表
// ---------------------------------------------------------------
struct CPU;
using Handler = std::function<void(CPU&)>;
struct CPU {
    uint32_t ip = 0;                 // 指令指针:下一条要执行的指令下标
    int      reg[4] = {0, 0, 0, 0};  // 通用寄存器
    bool     if_flag = true;         // 中断允许标志,相当于 x86 的 IF
    bool     halted = false;
    std::vector<uint32_t> stack;     // 内核栈:保存现场用
    std::array<Handler, 256> idt{};  // 中断向量表:向量号 -> 处理程序
    bool     pending_timer = false;  // 时钟芯片是否发出了中断请求
    // 进入中断/异常:把返回地址和标志位压栈,关中断,再跳到处理程序
    void dispatch(int vector, uint32_t saved_ip) {
        std::printf("    [CPU] 响应向量 %d: 保存 ip=%u 和 IF=%d, 关中断, 跳到处理程序\n",
                    vector, saved_ip, if_flag ? 1 : 0);
        stack.push_back(saved_ip);
        stack.push_back(if_flag ? 1u : 0u);
        if_flag = false;
        idt[vector](*this);          // 执行处理程序
        // iret:按相反顺序弹出,恢复现场
        if_flag = stack.back() != 0; stack.pop_back();
        ip = stack.back();           stack.pop_back();
        std::printf("    [CPU] iret: 恢复 ip=%u, IF=%d\n", ip, if_flag ? 1 : 0);
    }
};
// ---------------------------------------------------------------
// 4. 主循环:每执行完一条指令,检查有没有中断请求
// ---------------------------------------------------------------
void run(CPU& cpu, const std::vector<Instr>& prog, int timer_period) {
    int executed = 0;   // 已成功执行的指令条数,用来模拟时钟
    while (!cpu.halted) {
        const Instr& in = prog[cpu.ip];
        std::printf("ip=%u  %-4s  r0=%d r1=%d\n", cpu.ip, op_name(in.op), cpu.reg[0], cpu.reg[1]);
        bool completed = true;   // 这条指令是否顺利执行完
        switch (in.op) {
            case Op::MOV:  cpu.reg[in.a] = in.b; cpu.ip++; break;
            case Op::ADD:  cpu.reg[in.a] += in.b; cpu.ip++; break;
            case Op::NOP:  cpu.ip++; break;
            case Op::HALT: cpu.halted = true; break;
            case Op::DIV:
                if (cpu.reg[in.b] == 0) {
                    // 异常(fault):指令没有执行成功,保存的是"出错那条"的地址,
                    // 处理完返回后会重新执行这条指令
                    std::printf("  !! 除数为 0, 触发异常\n");
                    cpu.dispatch(VEC_DIVIDE_ERROR, cpu.ip);
                    completed = false;
                } else {
                    cpu.reg[in.a] /= cpu.reg[in.b]; cpu.ip++;
                }
                break;
            case Op::INT3:
                // 陷阱(trap):指令执行完才触发,保存的是"下一条"的地址
                std::printf("  !! 执行断点指令, 触发陷阱\n");
                cpu.ip++;
                cpu.dispatch(VEC_BREAKPOINT, cpu.ip);
                break;
        }
        // 每完成 timer_period 条指令,时钟芯片就发出一次中断请求
        if (completed && ++executed % timer_period == 0) cpu.pending_timer = true;
        // 中断是"异步"的:只在指令边界检查,并且要 IF = 1 才响应
        if (cpu.pending_timer && cpu.if_flag && !cpu.halted) {
            cpu.pending_timer = false;
            std::printf("  !! 时钟中断到来\n");
            cpu.dispatch(VEC_TIMER, cpu.ip);
        }
    }
}
int main() {
    CPU cpu;
    // 5. 安装处理程序(相当于操作系统初始化中断向量表)
    int ticks = 0;
    cpu.idt[VEC_DIVIDE_ERROR] = [](CPU& c) {
        std::printf("    [处理程序] 除零错误: 把除数 r1 改成 1, 让重试能成功\n");
        c.reg[1] = 1;
    };
    cpu.idt[VEC_BREAKPOINT] = [](CPU& c) {
        std::printf("    [处理程序] 断点: 调试器此时可以查看寄存器 r0=%d\n", c.reg[0]);
    };
    cpu.idt[VEC_TIMER] = [&ticks](CPU&) {
        ++ticks;
        std::printf("    [处理程序] 时钟滴答 #%d: 更新系统时间, 可在此切换任务\n", ticks);
    };
    // 6. 要运行的程序
    std::vector<Instr> prog = {
        {Op::MOV, 0, 10},    // 0: r0 = 10
        {Op::MOV, 1, 0},     // 1: r1 = 0
        {Op::DIV, 0, 1},     // 2: r0 /= r1   (除零, 会触发异常)
        {Op::ADD, 0, 5},     // 3: r0 += 5
        {Op::INT3, 0, 0},    // 4: 断点
        {Op::ADD, 0, 1},     // 5: r0 += 1
        {Op::NOP, 0, 0},     // 6
        {Op::NOP, 0, 0},     // 7
        {Op::HALT, 0, 0},    // 8: 停机
    };
    run(cpu, prog, 4);       // 每 4 条指令来一次时钟中断
    std::printf("结束: r0=%d, 时钟中断共 %d 次\n", cpu.reg[0], ticks);
    return (cpu.reg[0] == 16 && ticks == 2) ? 0 : 1;
}

运行结果:

ip=0  MOV   r0=0 r1=0
ip=1  MOV   r0=10 r1=0
ip=2  DIV   r0=10 r1=0
  !! 除数为 0, 触发异常
    [CPU] 响应向量 0: 保存 ip=2 和 IF=1, 关中断, 跳到处理程序
    [处理程序] 除零错误: 把除数 r1 改成 1, 让重试能成功
    [CPU] iret: 恢复 ip=2, IF=1
ip=2  DIV   r0=10 r1=1
ip=3  ADD   r0=10 r1=1
  !! 时钟中断到来
    [CPU] 响应向量 32: 保存 ip=4 和 IF=1, 关中断, 跳到处理程序
    [处理程序] 时钟滴答 #1: 更新系统时间, 可在此切换任务
    [CPU] iret: 恢复 ip=4, IF=1
ip=4  INT3  r0=15 r1=1
  !! 执行断点指令, 触发陷阱
    [CPU] 响应向量 3: 保存 ip=5 和 IF=1, 关中断, 跳到处理程序
    [处理程序] 断点: 调试器此时可以查看寄存器 r0=15
    [CPU] iret: 恢复 ip=5, IF=1
ip=5  ADD   r0=15 r1=1
ip=6  NOP   r0=16 r1=1
ip=7  NOP   r0=16 r1=1
  !! 时钟中断到来
    [CPU] 响应向量 32: 保存 ip=8 和 IF=1, 关中断, 跳到处理程序
    [处理程序] 时钟滴答 #2: 更新系统时间, 可在此切换任务
    [CPU] iret: 恢复 ip=8, IF=1
ip=8  HALT  r0=16 r1=1
结束: r0=16, 时钟中断共 2 次

对照输出可以看到几个要点:

  • ip=2 DIV 出现了两次:第一次除零触发 fault,保存的 ip 是 2,处理完回到 2 重新执行,第二次成功
  • 断点 trap 保存的 ip 是 5(下一条),所以处理完不会重复执行 INT3
  • 时钟中断都发生在两条指令之间,从不会打断一条指令的中间
  • 进入处理程序时 IF 被清零(关中断),iret 之后恢复为 1,避免处理程序被新的中断反复打断

5. 代码对应的时序图

处理程序栈CPU程序处理程序栈CPU程序时钟芯片发出中断请求在指令边界检查到执行 DIV r0, r1 (r1 为 0)发现除数为 0, 触发向量 0 (fault)压入 ip=2 和 IF=1关中断, 跳到除零处理程序把 r1 改成 1处理完毕iret 弹出 IF 和 ip=2回到 ip=2, 重新执行 DIV执行 ADD r0, 5 (第 4 条指令完成)压入 ip=4 和 IF=1关中断, 跳到时钟处理程序时钟滴答加一 可在此切换任务处理完毕iret 弹出 IF 和 ip=4执行 INT3ip 先加一 再触发向量 3 (trap)压入 ip=5 和 IF=1跳到断点处理程序调试器查看寄存器处理完毕iret 弹出 IF 和 ip=5继续执行下一条 ADD

6. 为什么说中断改变了软件世界

中断机制出现之前,程序只能自顾自地跑。有了中断,才出现了一类全新的软件:专门负责响应中断的代码,也就是操作系统内核。
因为处理中断的代码要管理硬件、调度程序,不能让普通程序随便改动,所以硬件又配套提供了特权级和内存保护,把内存分成内核空间和用户空间。于是软件世界一分为二:

中断和异常机制

出现专门处理中断的系统软件

需要保护 开辟内核空间

软件世界分成两个部分

内核态
操作系统 驱动

用户态
普通应用程序

用户程序想做特权操作(读写文件、联网)时,就通过一个主动触发的"陷阱"(系统调用)请求内核帮忙,这也是 trap 的一个重要用途。

7. 后面的学习路线

本章先讲 x86 架构的中断和异常机制,包括向量表的结构、各类事件的细节、调试相关的异常等,然后再扩展到 ARM 架构。ARM 的思路相同,只是术语和实现不同,比如 ARM(AArch32)把事件叫做:

事件含义
Reset复位
Undefined Instruction遇到无法识别的指令
SVC主动发起的系统调用
Prefetch Abort / Data Abort取指或访问数据时出错,如缺页
IRQ / FIQ普通中断 / 快速中断

有了本节的整体印象,后面学具体细节就不会迷路了。

8. 小结

  1. CPU 只会顺序取指执行,要处理突发事件必须有"打断"机制
  2. 中断来自外部设备,异步;异常由指令引起,同步
  3. 异常分 fault(重试出错指令)、trap(继续下一条)、abort(通常无法恢复)
  4. 处理流程:向量号、查向量表、保存现场、执行处理程序、iret 返回
  5. 中断只在指令边界响应,且受 IF 标志控制
  6. 时钟中断的周期 T=1/fT = 1/fT=1/f,是多任务切换和计时的基础
  7. 正是有了中断,才有了内核,进而把软件世界分成内核态和用户态

中断与异常(从零理解)

1. 先看整体:CPU 会被哪些事件"打断"

CPU 正常是一条一条地执行指令。但有些时候,它会被迫放下手头的事,转去处理别的任务。这类"打断"有两大来源:

  • 中断:来自 CPU 外部,由外部设备(硬件)发起。
  • 异常:来自 CPU 内部,是 CPU 执行指令时自己检测到某种情况而产生的。
    全部分类如下:

打断 CPU 的事件

中断
来自外部设备,CPU 被动

异常
来自 CPU 自身,同步产生

可屏蔽中断
INTR 引脚,受 IF 位控制

不可屏蔽中断 NMI
NMI 引脚,不受 IF 位控制

错误 fault
可纠正,返回后重新执行出错指令

陷阱 trap
指令已执行完,返回到下一条指令

中止 abort
严重错误,不可恢复

2. 中断

2.1 中断是什么

中断通常由 CPU 外部的输入输出设备触发,相当于设备在对 CPU 喊:"我这边有事,需要你处理!"所以中断又叫中断请求(interrupt request)。
CPU 收到请求后,会暂时停下当前正在执行的程序,转去执行这个请求对应的中断处理例程(Interrupt Service Routine,简称 ISR),处理完再回来。

2.2 能不能不被打扰:CLI 与 IF 位

有些任务不能被打断。这时可以执行 CLI 指令,把标志寄存器的 IF 位清零,让 CPU 暂时"免打扰"。
但有个例外:CLI 只能挡住可屏蔽中断。不可屏蔽中断(Non-Maskable Interrupt,NMI)一旦发生,CPU 还是要立刻去处理。不过 NMI 很少发生,不可被打断的代码通常也很短,所以多数情况下不会出问题。
两类中断的信号走的是不同的引脚:

类型英文信号引脚能否被 CLI 屏蔽
可屏蔽中断maskable interruptINTR能
不可屏蔽中断NMINMI不能

2.3 中断为什么是"宝贵资源"

有了中断,CPU 不用频繁地去问外部设备"你好了没"(这叫轮询),设备有事自己通知就行,通信效率很高。
但如果太多设备都来打断 CPU,CPU 就得在各个 ISR 之间来回奔波,正常程序反而跑不好。打个比方:手机号只告诉熟人,不然总被各种电话打断工作。所以给一个设备分配中断资源,等于赋予了它随时打断 CPU 的权力,中断因此是系统里非常宝贵的资源。

2.4 中断控制器与 IRQ 号

在硬件层面,中断由一块专门的芯片管理,叫中断控制器(Interrupt Controller)。它负责分配中断资源,并管理各个设备发来的请求。
为了区分每一路请求信号,约定用 IRQ(Interrupt ReQuest)加数字来表示,比如 IRQ0、IRQ1。沿用最初 IBM PC 的约定,常见分配如下:

IRQ 号传统用途
IRQ0系统时钟
IRQ1键盘
IRQ3、IRQ4串口(串口 1、串口 2)
IRQ6软盘驱动器

2.5 看图 3-1:一台真实机器的 IRQ 分配

图 3-1 是 Windows 设备管理器里"Interrupt request (IRQ)"一栏的截图,能看到这台机器上各个 IRQ 的实际使用情况:

IRQ 号总线设备
0ISASystem timer(系统时钟)
1ISAPS/2 键盘
3ISAIBM ThinkPad 红外端口
4ISACOM1 通信端口(串口)
6ISA软盘控制器
8ISA系统 CMOS / 实时时钟
9ISAACPI 兼容系统
12ISAThinkPad 触控点(鼠标类设备)
13ISA数值数据处理器(协处理器)
14、15ISA主 IDE 通道、副 IDE 通道
9PCI调制解调器、显卡、4 个 USB 控制器、网卡、无线网卡、声卡、2 个 CardBus 控制器、IEEE 1394 控制器
11PCISMBus 控制器

从图里可以得到两个结论:

  • IRQ0、IRQ1、IRQ4、IRQ6 的用途,仍然和最初 PC 的约定一致。
  • IRQ9 被很多设备共享。这是因为 PCI 总线标准支持多个 PCI 设备共用同一路中断请求信号。

3. 异常

3.1 异常是什么

和中断不同,异常是 CPU 在执行指令时,检测到某个预先定义好的条件而产生的同步事件。"同步"的意思是:它和正在执行的那条指令直接相关,是指令执行过程中产生的。

3.2 异常的三种来源


来源说明例子
程序错误CPU 执行指令时发现操作数有错,或遇到指令规范里定义的非法情况除法时除数为零;在用户模式下执行特权指令
特殊指令这些指令的预期行为就是产生异常,是"故意"的、在预料之内INT 3(产生断点异常,让 CPU 中断进调试器);还有 INTO、INT n、BOUND
机器检查异常由奔腾 CPU 引入。CPU 执行指令期间检测到 CPU 内部或外部的硬件错误硬件故障

4. 中断和异常的区别

最根本的差异是:

  • 异常来自 CPU 本身,是 CPU 主动产生的。
  • 中断来自外部设备,是中断源发起的,CPU 是被动的。

对比项中断异常
来源外部设备CPU 内部,正在执行的指令
CPU 的角色被动接受主动产生
与指令的关系异步,随时可能到来同步,由某条指令引起
能否用 CLI 屏蔽可屏蔽中断可以,NMI 不行不能

两个容易混淆的点

机器检查异常:它有时是外部设备通过设置 CPU 的特殊管脚(BUSCHK#、BINIT#、MCERR#)触发的。但从"产生"的角度看,是 CPU 检测到管脚信号后自己产生了异常,所以它仍然是在 CPU 内部产生的,属于异常。
软件中断:很多资料,尤其是早期资料,把 INT n 指令产生的异常叫"软件中断",把来自外部硬件的中断叫"外部中断"。这个说法今天仍然常见,但严格来说,INT n 导致的"软件中断"不是中断而是异常,因为它产生于 CPU 内部,来源就是正在执行的这条指令。后文如果不特别说明,"中断"就是指来自 CPU 外部的硬件中断。

5. 异常的分类

IA-32 CPU 根据两点把异常分成三类:一是 CPU 报告异常的方式,二是导致异常的指令能否被安全地重新执行。三类分别是:错误(fault)、陷阱(trap)、中止(abort)。

5.1 错误类异常(fault)

导致错误类异常的情况通常可以被纠正,纠正之后程序可以无损失地恢复执行。
最常见的例子就是缺页异常(也叫页错误、缺页错误),它是虚拟内存机制的重要基础。过程是这样的:

  1. 为了节省物理内存,操作系统会把暂时不用的内存按"页"为单位,换出到外部存储器(通常是硬盘)。
  2. 程序访问到一个已被换出的页对应的地址时,CPU 产生缺页异常,转去执行异常处理程序。
  3. 处理程序调用内存管理器,把这一页换回物理内存。
  4. CPU 返回到导致异常的那条指令,重新执行。这一次页已经在内存里了,就不会再出现缺页。
硬盘内存管理器缺页异常处理程序CPU程序硬盘内存管理器缺页异常处理程序CPU程序访问某个内存地址发现该页不在物理内存,产生缺页异常保存 CS 和 EIP(指向出错指令),转去处理请求把对应页换回内存读取该页返回页内容页已在物理内存异常返回重新执行刚才出错的指令,这次成功
看图 3-2:缺页异常有多频繁

在 Windows 这样的系统里,缺页异常每秒要发生很多次。按 Ctrl + Shift + Esc 打开任务管理器,通过 View → Select Columns 打开 PF Delta 列,就能观察。PF Delta 表示两次刷新之间新增的缺页次数,默认每秒刷新一次。Page Faults 列则是该进程缺页异常的累计次数。
图 3-2 是按 Page Faults 列排序的任务管理器,排在前三的都是支付宝相关的进程:

进程PIDCPU 时间Page Faults 累计
AlipaySecSvc.exe19641:20:32451,127,382
Alipaybsm.exe64680:14:07137,538,485
TaobaoProtect.exe64200:28:2884,667,839

处理每次缺页要执行比较复杂的逻辑,所以缺页次数高,往往也意味着 CPU 使用率高。图里 AlipaySecSvc 进程用掉的 CPU 总时间高达 1 小时 20 分 32 秒,正好印证了这一点。

错误类异常的返回地址

CPU 报告错误类异常时,会把状态恢复成导致异常的指令执行之前的样子。在转去执行异常处理程序之前,压入栈里的 CS 和 EIP 指向的是导致异常的这条指令本身(不是下一条)。
所以处理程序返回后,CPU 执行的第一条指令仍然是刚才出错的那条。如果出错的原因还没消除,就会再次产生异常。

5.2 陷阱类异常(trap)

和错误类不同,CPU 报告陷阱类异常时,导致异常的指令已经执行完毕。压栈的 CS 和 EIP(异常处理程序的返回地址)指向的是该指令执行之后,紧接着要执行的下一条指令。
注意这个"下一条"不一定是内存里相邻的那条。如果出错的指令本身是跳转指令或函数调用指令,下一条指令可能在内存里相隔很远。
陷阱类异常通常也能无损失地恢复执行。典型例子是 INT 3 的断点异常:它让 CPU 中断进调试器,从调试器返回后,被调试的程序可以继续往下执行。

5.3 中止类异常(abort)

中止类异常用来报告严重错误,比如硬件错误,或者系统表里出现非法值、状态不一致。这类异常不允许恢复继续执行,原因有两个:

  • CPU 不一定能保证它报告的出错指令地址是精确的。
  • 出于安全考虑,这类异常可能是程序执行了非法操作造成的,应该强制让它退出。

5.4 三类异常对比(表 3-1)


分类报告时间保存的 CS 和 EIP 指向可恢复性
错误(fault)开始执行导致异常的指令时导致异常的那条指令可以恢复执行(有例外,见下文)
陷阱(trap)执行完导致异常的指令时导致异常的那条指令的下一条指令可以恢复执行
中止(abort)不确定不确定不可以

用一条指令序列直观看返回地址的区别,假设"指令 B"导致了异常:

指令序列:   [指令A]   [指令B:导致异常]   [指令C]
错误类:     返回地址 指向 指令B    返回后重新执行 B
陷阱类:     返回地址 指向 指令C    B 已经执行完,接着执行 C

5.5 一个特例:不可恢复的错误类异常

并不是所有错误类异常都能恢复。比如执行 POPAD(弹出所有通用寄存器)指令时,栈指针 ESP 超出了栈所在段的边界,CPU 会报告栈错误异常。
这时异常处理例程看到的 CS 和 EIP,仍然被恢复成"好像 POPAD 没有被执行过"。但处理器内部的状态其实已经变了,某些通用寄存器的值可能已经被改掉。这种情况多半是程序用栈不当造成的,比如压栈和弹出操作不匹配。所以操作系统应当把它当作程序错误处理,直接终止这个程序。

6. 用代码模拟:中断、错误、陷阱

下面用一段可以直接编译运行的 C++ 代码,做一个简易 CPU 来模拟前面的内容。它会演示三件事:

  1. 缺页(错误类):保存的 EIP 指向出错指令本身,处理后重新执行。
  2. INT 3(陷阱类):保存的 EIP 指向下一条指令。
  3. 中断与 IF 位:CLI 之后中断被挂起,STI 之后才被响应,并且只在两条指令之间响应。
#include <iostream>
#include <string>
#include <vector>
// 指令种类
enum class Op { Nop, Load, Cli, Sti, Int3, Halt };
// 一条指令:种类、访问的页号(仅 Load 用)、给人看的文字
struct Instr {
    Op op;
    int page;
    std::string text;
};
int main() {
    // 简易程序,下标就是"EIP"
    std::vector<Instr> prog = {
        {Op::Nop,  0, "NOP"},         // 0
        {Op::Load, 5, "LOAD [页5]"},  // 1  页5不在内存里,会触发缺页(错误类异常)
        {Op::Cli,  0, "CLI"},         // 2  清除 IF,屏蔽可屏蔽中断
        {Op::Nop,  0, "NOP"},         // 3  执行它之前,外部设备发来中断请求
        {Op::Sti,  0, "STI"},         // 4  恢复 IF,允许中断
        {Op::Int3, 0, "INT 3"},       // 5  断点(陷阱类异常)
        {Op::Halt, 0, "HALT"}         // 6
    };
    bool pagePresent[8] = {true, true, true, true, true, false, true, true};
    int eip = 0;                // 下一条要执行的指令
    bool ifFlag = true;         // 标志寄存器里的 IF 位
    bool pendingIrq = false;    // 是否有未处理的中断请求
    bool irqRaised = false;     // 中断请求是否已发出过(只发一次)
    while (true) {
        // 外部设备在 eip 第一次到达 3 时发出中断请求
        if (eip == 3 && !irqRaised) {
            irqRaised = true;
            pendingIrq = true;
            std::cout << "[设备] 发出中断请求 IRQ0" << std::endl;
        }
        // 中断只在两条指令之间被响应,并且要求 IF=1
        if (pendingIrq && ifFlag) {
            std::cout << "[中断] IF=1,开始执行 ISR,处理完回到 eip="
                      << eip << " 继续" << std::endl;
            pendingIrq = false;
        } else if (pendingIrq) {
            std::cout << "[中断] IF=0,请求被挂起,暂不响应" << std::endl;
        }
        const Instr& ins = prog[eip];
        std::cout << "执行 eip=" << eip << " : " << ins.text << std::endl;
        if (ins.op == Op::Halt) {
            std::cout << "程序结束" << std::endl;
            break;
        }
        if (ins.op == Op::Load && !pagePresent[ins.page]) {
            // 错误类异常:保存的 EIP 指向"出错的这条指令"本身
            int savedEip = eip;
            std::cout << "[缺页异常] 保存的EIP=" << savedEip
                      << ",指向出错指令本身" << std::endl;
            pagePresent[ins.page] = true;  // 处理程序把页换进内存,纠正错误
            std::cout << "[缺页处理] 页" << ins.page << " 已换入内存" << std::endl;
            eip = savedEip;                // 返回后重新执行这条指令
            continue;
        }
        if (ins.op == Op::Int3) {
            // 陷阱类异常:指令已执行完,保存的 EIP 指向"下一条指令"
            int savedEip = eip + 1;
            std::cout << "[断点异常] 保存的EIP=" << savedEip
                      << ",指向下一条指令" << std::endl;
            eip = savedEip;
            continue;
        }
        if (ins.op == Op::Cli) ifFlag = false;
        if (ins.op == Op::Sti) ifFlag = true;
        ++eip;
    }
    return 0;
}

运行结果

执行 eip=0 : NOP
执行 eip=1 : LOAD [页5]
[缺页异常] 保存的EIP=1,指向出错指令本身
[缺页处理] 页5 已换入内存
执行 eip=1 : LOAD [页5]
执行 eip=2 : CLI
[设备] 发出中断请求 IRQ0
[中断] IF=0,请求被挂起,暂不响应
执行 eip=3 : NOP
[中断] IF=0,请求被挂起,暂不响应
执行 eip=4 : STI
[中断] IF=1,开始执行 ISR,处理完回到 eip=5 继续
执行 eip=5 : INT 3
[断点异常] 保存的EIP=6,指向下一条指令
执行 eip=6 : HALT
程序结束

代码逐段讲解

  • 指令和程序:用 Instr 结构体表示一条指令,用 vector 存放整个程序,数组下标就充当 EIP。这样"EIP 指向哪条指令"就一目了然。
  • pagePresent 数组:模拟每一页是否在物理内存里,第 5 页一开始是 false,所以 LOAD [页5] 会出错。
  • 缺页(错误类):发现页不在内存时,savedEip = eip,也就是保存出错指令自己的位置。处理程序把页标记为已换入,然后把 eip 设回 savedEip 并 continue,回到循环开头,于是同一条 LOAD 被执行了第二次,这次成功。运行结果里 eip=1 出现两次,就是"返回后重新执行"。
  • 断点(陷阱类):INT 3 本身已经执行完了,所以 savedEip = eip + 1,保存的是下一条指令的位置,返回后不会再执行 INT 3,直接到 HALT。
  • 中断与 IF:循环开头先检查有没有挂起的中断。中断请求在 eip=3 时到来,而此时 CLI 已把 ifFlag 清零,所以只能挂起;直到 STI 把 ifFlag 恢复,下一轮循环开头才响应。同时,响应动作写在"取指令之前",对应"中断只在两条指令之间被响应"。这里为了简单没有模拟 NMI,真实的 NMI 不受 ifFlag 控制。

代码执行流程(mermaid 时序图)

中断处理例程缺页处理外部设备模拟CPU主循环中断处理例程缺页处理外部设备模拟CPU主循环执行 eip=0 NOP执行 eip=1 LOAD 页5,发现页不在内存缺页异常,保存 EIP=1(出错指令本身)页5 已换入,返回 eip=1重新执行 eip=1 LOAD,成功执行 eip=2 CLI,IF 清零eip=3 时发出中断请求 IRQ0IF=0,请求挂起执行 eip=3 NOP执行 eip=4 STI,IF 置位下一条指令前 IF=1,响应中断处理完毕,回到 eip=5执行 eip=5 INT 3陷阱异常,保存 EIP=6(下一条指令)执行 eip=6 HALT,程序结束

7. 小结


要点一句话总结
中断外部设备发起,CPU 被动,可屏蔽中断受 IF 位控制,NMI 不受控制
异常CPU 执行指令时自己产生,与指令同步
错误 fault可纠正,返回地址是出错指令本身,会重新执行(典型:缺页)
陷阱 trap指令已执行完,返回地址是下一条指令(典型:INT 3 断点)
中止 abort严重错误,不可恢复,地址也不一定精确
INT n严格说是异常,不是中断,"软件中断"这个叫法并不严谨

IA-32 异常详解:从零理解除零异常

这一篇从最基础的概念讲起,先弄清楚"异常"是什么,再看 CPU 定义了哪些异常,最后用一个除零的小程序,完整走一遍异常从产生到被处理的全过程。

1. 先建立直觉:什么是异常

CPU 正常工作时,就是一条一条地执行指令。但有时候某条指令"没法正常执行",比如:

  • 除数是 0,结果根本算不出来
  • 访问了一块不存在的内存
  • 执行了 CPU 不认识的指令
    这时 CPU 不会硬着头皮往下走,而是暂停当前指令,跳去执行一段专门处理这种情况的代码。这种"意外情况 + 跳转处理"的机制,就叫异常(Exception)。
    和异常很像的还有中断(Interrupt),比如键盘按下、网卡收到数据。粗略的区别是:
  • 异常:由 CPU 自己执行指令时发现的问题引起,和当前指令直接相关
  • 中断:由外部设备"打断"CPU 引起,和当前指令无关

2. 向量号:给每种异常编个号

CPU 要区分"到底发生了什么",办法很朴素:给每种中断和异常编一个整数编号,这个编号叫向量号(Vector No.)。CPU 和操作系统都靠向量号来识别事件,并据此找到对应的处理程序。
IA-32 对 256 个向量号(0 到 255)做了分工:

向量号范围个数归谁使用
0 ~ 3132CPU 设计者(Intel 等)预留
32 ~ 255224操作系统、OEM 厂商或其他软硬件开发商

可用于自定义的向量个数=255−32+1=224 \text{可用于自定义的向量个数} = 255 - 32 + 1 = 224 可用于自定义的向量个数=255−32+1=224

3. 异常与中断列表

下面是 PC 上最常见的中断和异常。"助记符"是 Intel 手册里的简写,以 # 开头,读代码和手册时会经常遇到。

向量号助记符类型含义触发来源
0#DE错误除零错误DIV、IDIV 指令
1#DB错误/陷阱调试异常,用于软件调试任何代码或数据引用
2无中断NMI,不可屏蔽的外部中断外部 NMI 信号
3#BP陷阱断点INT 3 指令
4#OF陷阱溢出INTO 指令
5#BR错误数组越界BOUND 指令
6#UD错误无效指令(未定义的指令)UD2 指令(奔腾 Pro 引入)或任何保留指令
7#NM错误数学协处理器不存在或不可用浮点指令或 WAIT/FWAIT 指令
8#DF中止双重错误(Double Fault)任何可能产生异常的指令、NMI 或可屏蔽中断
9无错误向协处理器传送操作数时检测到页错误或段不存在。自从 486 把协处理器集成进 CPU,此异常保留不用浮点指令
10#TS错误无效 TSS任务切换或访问 TSS
11#NP错误段不存在加载段寄存器或访问系统段
12#SS错误栈段错误栈操作或加载 SS 寄存器
13#GP错误通用保护异常:操作违反了保护模式规定,且不属于其他异常时产生任何内存引用和保护性检查
14#PF错误页错误任何内存引用
15无保留保留无
16#MF错误浮点错误浮点或 WAIT/FWAIT 指令
17#AC错误对齐检查内存数据引用(486 引入)
18#MC中止机器检查,错误代码和来源与型号有关奔腾 CPU 引入
19#XF错误SIMD 浮点异常SIMD 浮点指令(奔腾 III 引入)
20 ~ 31无保留保留无
32 ~ 255无中断用户定义的可屏蔽中断来自 INTR 的外部中断,或 INT n 指令

3.1 错误、陷阱、中止:三种类型有什么不同

表里的"类型"列很重要,它决定了异常处理完之后,CPU 回到哪里继续执行。

类型保存的返回地址处理完后能否重新执行典型例子
错误(Fault)指向引发异常的那条指令本身可以,修复问题后原指令重新执行除零、缺页
陷阱(Trap)指向引发异常指令的下一条指令直接接着往下执行断点(INT 3)、溢出(INTO)
中止(Abort)不一定能确定准确位置通常无法恢复双重错误、机器检查

用一个生活类比:

  • 错误像"做菜发现缺盐",补上盐之后,这一步重新做
  • 陷阱像"做完一步后喊一声",喊完接着做下一步
  • 中止像"厨房着火",没法接着做了
    下面用 Mermaid 画一棵分类树,方便整体记忆:

中断与异常

异常 由指令执行引起

中断 由外部事件引起

错误 Fault
返回地址指向出错指令

陷阱 Trap
返回地址指向下一条指令

中止 Abort
通常无法恢复

NMI 不可屏蔽中断
向量号 2

可屏蔽中断
向量号 32 到 255

除零 DE

页错误 PF

通用保护 GP

断点 BP

溢出 OF

双重错误 DF

机器检查 MC

4. 错误代码:CPU 顺手留下的"现场线索"

有些异常发生时,CPU 除了保存现场,还会往栈里额外压入一个 32 位的错误代码,用来告诉处理程序"具体是哪里出了问题"。

注意:并不是所有异常都有错误代码。比如前面要演示的除零异常(向量 0)就没有。常见带错误代码的有 #TS、#NP、#SS、#GP、#PF、#AC、#DF。

4.1 错误代码的位布局


位名称含义
0EXTExternal Event。为 1 表示由外部事件导致
1IDTDescriptor Location。为 1 表示选择子索引指向 IDT 中的门描述符;为 0 表示指向 GDT 或 LDT 中的描述符
2TI仅在 IDT 位为 0 时有效。为 1 表示索引指向 LDT;为 0 表示指向 GDT
3 ~ 15段选择子索引与错误相关的描述符,在对应表(IDT、LDT 或 GDT)里的下标
16 ~ 31保留未使用

怎么用这几位判断"出错的描述符在哪张表里":

IDT 位TI 位索引指向的表
1任意IDT
01LDT
00GDT

想从错误代码里取出索引值,右移 3 位即可(因为低 3 位是 EXT、IDT、TI 三个标志):
索引=ErrorCode≫3 \text{索引} = \text{ErrorCode} \gg 3 索引=ErrorCode≫3
举个例子,假设错误代码为 0x0000002A0x0000002A0x0000002A,二进制低 8 位是 0010 10100010\ 10100010 1010:

  • 位 0(EXT)= 0,不是外部事件
  • 位 1(IDT)= 1,索引指向 IDT
  • 位 2(TI)= 0,(IDT 为 1 时这一位无意义)
  • 索引 =0x2A≫3=5= 0x2A \gg 3 = 5=0x2A≫3=5,即 IDT 里第 5 项

页错误(#PF)的错误代码格式不同于上面这个,它有自己的一套位含义,这里不展开。

5. 用小程序走一遍:故意除零

道理讲完了,我们写个程序,故意让 CPU 遇到除零,看看整个过程是怎么发生的。
这个程序用的是 Windows 的**结构化异常处理(SEH)**机制。你可以先把它理解成 Windows 提供的"异常版 try/catch":

  • __try { ... }:被保护的代码块,里面出了异常会被捕获
  • __except( 过滤表达式 ) { ... }:异常发生后,系统先计算"过滤表达式",根据结果决定下一步怎么办

5.1 过滤表达式的三种返回值


宏名称实际值含义
EXCEPTION_CONTINUE_SEARCH0这个保护块不处理该异常,继续往外层找别的处理者
EXCEPTION_EXECUTE_HANDLER1执行本 __except 块里的处理代码
EXCEPTION_CONTINUE_EXECUTION-1异常已被消除,回到出错指令处继续执行

本例会用到第三种:把除数从 0 改成 1,再让 CPU 回到除法指令重新执行。这正好对应上面说的"错误(Fault)"类异常的特点:返回地址指向出错指令本身,修好问题就能重来。

5.2 完整可运行的代码

编译环境:Windows + Visual Studio(MSVC)。
在"开发者命令提示符"里编译:

cl /utf-8 /Od fault.c

两个选项都有用:

  • /utf-8:让编译器正确识别源文件里的中文注释
  • /Od:关闭优化。这点很关键,原因见后面的"常见坑"
// fault.c
// 通过"除零异常"理解 "错误(Fault)类异常" 的处理过程
// 平台:Windows,编译器:MSVC(使用了 SEH 扩展关键字 __try / __except)
#include <stdio.h>      // printf、getchar
#include <windows.h>    // GetExceptionCode、EXCEPTION_* 常量
// 宏:打印三个变量当前的值,方便观察程序运行到哪一步、变量变成了什么
// 末尾的反斜杠表示宏定义还没结束,下一行继续
#define VAR_WATCH() printf("nDividend=%d, nDivisor=%d, nResult=%d.\n", \
                           nDividend, nDivisor, nResult)
int main(int argc, char *argv[])
{
    // 被除数 22,除数 0(故意的),结果初始为 100(用来观察它有没有被改写)
    int nDividend = 22, nDivisor = 0, nResult = 100;
    __try   // 受保护的代码块:块内发生的异常会交给 __except 处理
    {
        // 第一步:除法之前先打印一次,此时 nResult 还是 100
        printf("Before div in __try block: ");
        VAR_WATCH();
        // 第二步:触发异常的关键一行
        // 对应的汇编是 idiv,CPU 执行时发现除数为 0,产生 #DE 异常(向量号 0)
        // 异常发生后,这一行会被"中断",等处理完再回到这里重新执行
        nResult = nDividend / nDivisor;
        // 第三步:除法成功之后才会执行到这里
        // 如果异常被成功修复,会看到 nResult 变成了 22
        printf("After div in __try block: ");
        VAR_WATCH();
    }
    // __except 的括号里是"过滤表达式",用逗号运算符把多个动作串起来:
    // 逗号运算符会从左到右依次计算,整个表达式的值等于最后一项的值
    __except (
        // 动作 1:打印"进入过滤表达式",并输出当时的变量值
        printf("In __except block: "), VAR_WATCH(),
        // 动作 2:根据异常代码判断到底是不是除零异常
        GetExceptionCode() == EXCEPTION_INT_DIVIDE_BY_ZERO
            // 是除零异常:做三件事,整体的值是最后一个
            ? (nDivisor = 1,                                    // 把除数修正为 1(消除错误原因)
               printf("Divide Zero exception detected: "),      // 打印提示
               VAR_WATCH(),                                     // 打印修正后的变量值
               EXCEPTION_CONTINUE_EXECUTION)                    // 返回 -1:回到出错指令继续执行
            // 不是除零异常:这里不处理,让系统继续找别的处理者
            : EXCEPTION_CONTINUE_SEARCH)
    {
        // 因为过滤表达式返回的是 CONTINUE_EXECUTION,
        // 并不会进入这个 handler 块,所以这行永远不会打印
        printf("In handler block.\n");
    }
    // 等待按键,避免窗口一闪而过
    return getchar();
}

5.3 对应的汇编:异常到底发生在哪

C 语言那一行 nResult = nDividend / nDivisor; 编译后大致是这样(地址和偏移因编译环境会有差异):

; 18:  nResult = nDividend / nDivisor;
00401087  8B 45 E4        mov   eax, dword ptr [ebp-1Ch]   ; 把被除数 nDividend 读进 EAX
0040108A  99              cdq                              ; 把 EAX 符号扩展到 EDX:EAX(32 位扩成 64 位)
0040108B  F7 7D E0        idiv  eax, dword ptr [ebp-20h]   ; 有符号除法:EDX:EAX 除以除数 nDivisor  <-- 异常在这里发生
0040108E  89 45 DC        mov   dword ptr [ebp-24h], eax   ; 把商(EAX)存回 nResult

这里为什么要先 cdq?因为 idiv 的被除数是 64 位的 EDX:EAX\text{EDX:EAX}EDX:EAX,而我们的被除数只有 32 位,所以要先把符号扩展出来:
EDX:EAX=SignExtend(EAX) \text{EDX:EAX} = \text{SignExtend}(\text{EAX}) EDX:EAX=SignExtend(EAX)
idiv 执行时的内部逻辑,在 Intel 手册里开头几行是:

IF SRC = 0
    THEN #DE;    (* Divide error *)
FI;
......

意思很直白:先检查除数是不是 0,是的话就产生 #DE 异常。
另外补充一点,除零不是 #DE 的唯一来源。当商太大、装不进目标寄存器时,也会触发 #DE。对 32 位除法来说,商必须落在下面的范围内:
−231≤商≤231−1 -2^{31} \le \text{商} \le 2^{31} - 1 −231≤商≤231−1

5.4 一步步看发生了什么

对本例来说,当 CPU 执行到地址 0040108B 的 idiv 时,因为除数是 0,会依次发生:

  1. CPU 检测到除数为 0,报告 #DE 异常
  2. CPU 把 EFLAGS、CS、EIP 三个寄存器的内容压入栈保存。因为是"错误"类异常,这里保存的 EIP 指向的是 idiv 指令本身,而不是它的下一条
  3. CPU 根据向量号 0,找到除零异常的处理程序并跳转过去(如何找到,会在讲 IDT 的章节里说明)
  4. 操作系统的异常处理程序做一轮检查和预处理,然后调用 __except 里的过滤表达式
  5. 过滤表达式打印信息,发现是除零,把 nDivisor 改成 1,返回 EXCEPTION_CONTINUE_EXECUTION
  6. 系统据此恢复现场,CPU 回到 0040108B 重新执行 idiv。这次除数是 1,顺利算出 22÷1=2222 \div 1 = 2222÷1=22
  7. 程序接着往下走,打印 “After div”,此时 nResult 已经是 22
    可以看到,这个设计的精髓就在第 2 步:保存的是出错指令的地址,所以"修好问题后重试"才有意义。

5.5 时序图

下面这张图把上面七步画成时序图(对应上面完整代码的执行过程):

过滤表达式系统异常分发CPU程序 main过滤表达式系统异常分发CPU程序 main进入 try 块打印 Before divnDivisor=0 nResult=100执行 idiv 指令检查除数发现等于 0把 EFLAGS CS EIP 压栈EIP 指向 idiv 本身按向量号 0 跳转到处理程序检查与预处理计算 except 的过滤表达式打印 In except block判断异常码是除零异常把 nDivisor 改为 1打印 Divide Zero exception detected返回 EXCEPTION_CONTINUE_EXECUTION恢复现场回到 idiv 指令重新执行 idiv22 除以 1 等于 22商 22 写入 nResult打印 After divnDivisor=1 nResult=22

5.6 运行结果

实际运行,会看到下面四行输出:

Before div in __try block: nDividend=22, nDivisor=0, nResult=100.
In __except block: nDividend=22, nDivisor=0, nResult=100.
Divide Zero exception detected: nDividend=22, nDivisor=1, nResult=100.
After div in __try block: nDividend=22, nDivisor=1, nResult=22.

逐行对照:

输出行说明nDivisornResult
第 1 行除法之前,一切正常0100
第 2 行异常已发生,进入过滤表达式,变量还没改0100
第 3 行过滤表达式把除数修正为 11100
第 4 行回到 idiv 重新执行成功,结果写入122

注意第 3 行和第 4 行的 nResult:第 3 行时除法还没真正完成,所以还是 100;回到 idiv 重做之后,才变成 22。这个变化正好印证了:异常修复后,出错的那条指令确实被重新执行了一遍。

5.7 常见坑

  1. 一定要关闭优化(/Od)。开启优化后,编译器可能把 nDivisor 放在寄存器里。过滤表达式改的是内存里的那份,寄存器里的还是 0,idiv 重新执行时又除以 0,于是不断触发异常,陷入死循环
  2. 这个写法只适用于"错误类"异常。如果异常是陷阱类(如断点),返回地址已经是下一条指令,"重新执行"就没有这个意思了
  3. __try / __except 是 MSVC 的扩展,用 GCC 或 Clang 的普通模式编译不了

6. 小结

  • 异常是 CPU 执行指令时遇到问题,暂停并跳转到处理程序的机制,每种异常都有一个向量号
  • 0 到 31 号向量归 CPU 设计者使用,32 到 255 号留给操作系统和硬件厂商
  • 异常分为错误、陷阱、中止三类,区别在于返回地址指向哪里,以及能否恢复
  • 部分异常会额外压入错误代码,用 EXT、IDT、TI 三个标志位加上索引,指出问题描述符所在的位置
  • 除零属于"错误"类异常:保存的 EIP 指向出错的 idiv,所以修复除数之后,让 CPU 回去重做就能成功
  • SEH 的过滤表达式用三个返回值(0、1、-1)告诉系统:继续找、执行处理块、还是回去重试

IA-32 异常详解:从零理解除零异常

这一篇从最基础的概念讲起,先弄清楚"异常"是什么,再看 CPU 定义了哪些异常,最后用一个除零的小程序,完整走一遍异常从产生到被处理的全过程。

1. 先建立直觉:什么是异常

CPU 正常工作时,就是一条一条地执行指令。但有时候某条指令"没法正常执行",比如:

  • 除数是 0,结果根本算不出来
  • 访问了一块不存在的内存
  • 执行了 CPU 不认识的指令
    这时 CPU 不会硬着头皮往下走,而是暂停当前指令,跳去执行一段专门处理这种情况的代码。这种"意外情况 + 跳转处理"的机制,就叫异常(Exception)。
    和异常很像的还有中断(Interrupt),比如键盘按下、网卡收到数据。粗略的区别是:
  • 异常:由 CPU 自己执行指令时发现的问题引起,和当前指令直接相关
  • 中断:由外部设备"打断"CPU 引起,和当前指令无关

2. 向量号:给每种异常编个号

CPU 要区分"到底发生了什么",办法很朴素:给每种中断和异常编一个整数编号,这个编号叫向量号(Vector No.)。CPU 和操作系统都靠向量号来识别事件,并据此找到对应的处理程序。
IA-32 对 256 个向量号(0 到 255)做了分工:

向量号范围个数归谁使用
0 ~ 3132CPU 设计者(Intel 等)预留
32 ~ 255224操作系统、OEM 厂商或其他软硬件开发商

可用于自定义的向量个数=255−32+1=224 \text{可用于自定义的向量个数} = 255 - 32 + 1 = 224 可用于自定义的向量个数=255−32+1=224

3. 异常与中断列表

下面是 PC 上最常见的中断和异常。"助记符"是 Intel 手册里的简写,以 # 开头,读代码和手册时会经常遇到。

向量号助记符类型含义触发来源
0#DE错误除零错误DIV、IDIV 指令
1#DB错误/陷阱调试异常,用于软件调试任何代码或数据引用
2无中断NMI,不可屏蔽的外部中断外部 NMI 信号
3#BP陷阱断点INT 3 指令
4#OF陷阱溢出INTO 指令
5#BR错误数组越界BOUND 指令
6#UD错误无效指令(未定义的指令)UD2 指令(奔腾 Pro 引入)或任何保留指令
7#NM错误数学协处理器不存在或不可用浮点指令或 WAIT/FWAIT 指令
8#DF中止双重错误(Double Fault)任何可能产生异常的指令、NMI 或可屏蔽中断
9无错误向协处理器传送操作数时检测到页错误或段不存在。自从 486 把协处理器集成进 CPU,此异常保留不用浮点指令
10#TS错误无效 TSS任务切换或访问 TSS
11#NP错误段不存在加载段寄存器或访问系统段
12#SS错误栈段错误栈操作或加载 SS 寄存器
13#GP错误通用保护异常:操作违反了保护模式规定,且不属于其他异常时产生任何内存引用和保护性检查
14#PF错误页错误任何内存引用
15无保留保留无
16#MF错误浮点错误浮点或 WAIT/FWAIT 指令
17#AC错误对齐检查内存数据引用(486 引入)
18#MC中止机器检查,错误代码和来源与型号有关奔腾 CPU 引入
19#XF错误SIMD 浮点异常SIMD 浮点指令(奔腾 III 引入)
20 ~ 31无保留保留无
32 ~ 255无中断用户定义的可屏蔽中断来自 INTR 的外部中断,或 INT n 指令

3.1 错误、陷阱、中止:三种类型有什么不同

表里的"类型"列很重要,它决定了异常处理完之后,CPU 回到哪里继续执行。

类型保存的返回地址处理完后能否重新执行典型例子
错误(Fault)指向引发异常的那条指令本身可以,修复问题后原指令重新执行除零、缺页
陷阱(Trap)指向引发异常指令的下一条指令直接接着往下执行断点(INT 3)、溢出(INTO)
中止(Abort)不一定能确定准确位置通常无法恢复双重错误、机器检查

用一个生活类比:

  • 错误像"做菜发现缺盐",补上盐之后,这一步重新做
  • 陷阱像"做完一步后喊一声",喊完接着做下一步
  • 中止像"厨房着火",没法接着做了
    下面用 Mermaid 画一棵分类树,方便整体记忆:

中断与异常

异常 由指令执行引起

中断 由外部事件引起

错误 Fault
返回地址指向出错指令

陷阱 Trap
返回地址指向下一条指令

中止 Abort
通常无法恢复

NMI 不可屏蔽中断
向量号 2

可屏蔽中断
向量号 32 到 255

除零 DE

页错误 PF

通用保护 GP

断点 BP

溢出 OF

双重错误 DF

机器检查 MC

4. 错误代码:CPU 顺手留下的"现场线索"

有些异常发生时,CPU 除了保存现场,还会往栈里额外压入一个 32 位的错误代码,用来告诉处理程序"具体是哪里出了问题"。

注意:并不是所有异常都有错误代码。比如前面要演示的除零异常(向量 0)就没有。常见带错误代码的有 #TS、#NP、#SS、#GP、#PF、#AC、#DF。

4.1 错误代码的位布局


位名称含义
0EXTExternal Event。为 1 表示由外部事件导致
1IDTDescriptor Location。为 1 表示选择子索引指向 IDT 中的门描述符;为 0 表示指向 GDT 或 LDT 中的描述符
2TI仅在 IDT 位为 0 时有效。为 1 表示索引指向 LDT;为 0 表示指向 GDT
3 ~ 15段选择子索引与错误相关的描述符,在对应表(IDT、LDT 或 GDT)里的下标
16 ~ 31保留未使用

怎么用这几位判断"出错的描述符在哪张表里":

IDT 位TI 位索引指向的表
1任意IDT
01LDT
00GDT

想从错误代码里取出索引值,右移 3 位即可(因为低 3 位是 EXT、IDT、TI 三个标志):
索引=ErrorCode≫3 \text{索引} = \text{ErrorCode} \gg 3 索引=ErrorCode≫3
举个例子,假设错误代码为 0x0000002A0x0000002A0x0000002A,二进制低 8 位是 0010 10100010\ 10100010 1010:

  • 位 0(EXT)= 0,不是外部事件
  • 位 1(IDT)= 1,索引指向 IDT
  • 位 2(TI)= 0,(IDT 为 1 时这一位无意义)
  • 索引 =0x2A≫3=5= 0x2A \gg 3 = 5=0x2A≫3=5,即 IDT 里第 5 项

页错误(#PF)的错误代码格式不同于上面这个,它有自己的一套位含义,这里不展开。

4.2 用 C++ 动手拆一个错误代码

光看位图容易忘,写个小程序拆一遍就清楚了。这个程序是标准 C++,Windows 和 Linux 都能编译:

g++ -std=c++17 -o decode decode_error_code.cpp
./decode
// decode_error_code.cpp
// 把 32 位异常错误代码拆成 EXT、IDT、TI 和段选择子索引
// 纯标准 C++,Windows、Linux 都能编译运行
#include <cstdint>   // uint32_t:固定 32 位无符号整数
#include <cstdio>    // std::printf
// 解析并打印一个错误代码
void DecodeErrorCode(std::uint32_t errorCode)
{
    // 位 0:EXT,取最低一位
    const bool ext = (errorCode & 0x1u) != 0;
    // 位 1:IDT,右移 1 位后取最低一位
    const bool idt = ((errorCode >> 1) & 0x1u) != 0;
    // 位 2:TI,右移 2 位后取最低一位(仅当 IDT 为 0 时有意义)
    const bool ti = ((errorCode >> 2) & 0x1u) != 0;
    // 位 3 到 15:段选择子索引,右移 3 位后只保留 13 位(0x1FFF 是 13 个 1)
    const std::uint32_t index = (errorCode >> 3) & 0x1FFFu;
    // 决定索引指向哪张表:IDT 位优先,其次看 TI 位
    const char *table = idt ? "IDT" : (ti ? "LDT" : "GDT");
    std::printf("ErrorCode=0x%08X  EXT=%d  IDT=%d  TI=%d  -> %s 表第 %u 项\n",
                static_cast<unsigned>(errorCode), ext, idt, ti, table,
                static_cast<unsigned>(index));
}
int main()
{
    DecodeErrorCode(0x0000002A);  // 文中示例:IDT 第 5 项
    DecodeErrorCode(0x0000002B);  // 同上,但由外部事件引起
    DecodeErrorCode(0x0000001C);  // LDT 第 3 项
    DecodeErrorCode(0x00000010);  // GDT 第 2 项
    return 0;
}

运行结果:

ErrorCode=0x0000002A  EXT=0  IDT=1  TI=0  -> IDT 表第 5 项
ErrorCode=0x0000002B  EXT=1  IDT=1  TI=0  -> IDT 表第 5 项
ErrorCode=0x0000001C  EXT=0  IDT=0  TI=1  -> LDT 表第 3 项
ErrorCode=0x00000010  EXT=0  IDT=0  TI=0  -> GDT 表第 2 项

这里取索引时多做了一次掩码,保证只留下 13 位:
index=(ErrorCode≫3) & 0x1FFF \text{index} = (\text{ErrorCode} \gg 3)\ \&\ \text{0x1FFF} index=(ErrorCode≫3) & 0x1FFF
下面是这个程序的时序图:

printfDecodeErrorCodemainprintfDecodeErrorCodemain传入 0x2A取位 0 得到 EXT 等于 0取位 1 得到 IDT 等于 1取位 2 得到 TI 等于 0右移 3 位并掩码得到 index 等于 5IDT 为 1 所以选择 IDT 表输出一行结果返回返回依次传入 0x2B 0x1C 0x10重复同样步骤全部完成

5. 用小程序走一遍:故意除零

道理讲完了,我们写个程序,故意让 CPU 遇到除零,看看整个过程是怎么发生的。
这个程序用的是 Windows 的**结构化异常处理(SEH)**机制。你可以先把它理解成 Windows 提供的"异常版 try/catch":

  • __try { ... }:被保护的代码块,里面出了异常会被捕获
  • __except( 过滤表达式 ) { ... }:异常发生后,系统先计算"过滤表达式",根据结果决定下一步怎么办

5.1 过滤表达式的三种返回值


宏名称实际值含义
EXCEPTION_CONTINUE_SEARCH0这个保护块不处理该异常,继续往外层找别的处理者
EXCEPTION_EXECUTE_HANDLER1执行本 __except 块里的处理代码
EXCEPTION_CONTINUE_EXECUTION-1异常已被消除,回到出错指令处继续执行

本例会用到第三种:把除数从 0 改成 1,再让 CPU 回到除法指令重新执行。这正好对应上面说的"错误(Fault)"类异常的特点:返回地址指向出错指令本身,修好问题就能重来。

5.2 完整可运行的代码

编译环境:Windows + Visual Studio(MSVC)。
在"开发者命令提示符"里编译:

cl /utf-8 /Od /EHsc fault.cpp

两个选项都有用:

  • /utf-8:让编译器正确识别源文件里的中文注释
  • /Od:关闭优化。这点很关键,原因见后面的"常见坑"
  • /EHsc:启用标准 C++ 异常处理,C++ 项目的常规选项
// fault.cpp
// 通过"除零异常"理解 "错误(Fault)类异常" 的处理过程
// 平台:Windows,编译器:MSVC(使用了 SEH 扩展关键字 __try / __except)
#include <cstdio>       // std::printf、std::getchar
#include <windows.h>    // GetExceptionCode、EXCEPTION_* 常量
// 宏:打印三个变量当前的值,方便观察程序运行到哪一步、变量变成了什么
// 末尾的反斜杠表示宏定义还没结束,下一行继续
#define VAR_WATCH() std::printf("nDividend=%d, nDivisor=%d, nResult=%d.\n", \
                                nDividend, nDivisor, nResult)
int main()
{
    // 被除数 22,除数 0(故意的),结果初始为 100(用来观察它有没有被改写)
    // 注意:这几个变量不要声明为 const,否则编译器可能直接在编译期算除法
    int nDividend = 22, nDivisor = 0, nResult = 100;
    __try   // 受保护的代码块:块内发生的异常会交给 __except 处理
    {
        // 第一步:除法之前先打印一次,此时 nResult 还是 100
        std::printf("Before div in __try block: ");
        VAR_WATCH();
        // 第二步:触发异常的关键一行
        // 对应的汇编是 idiv,CPU 执行时发现除数为 0,产生 #DE 异常(向量号 0)
        // 异常发生后,这一行会被"中断",等处理完再回到这里重新执行
        nResult = nDividend / nDivisor;
        // 第三步:除法成功之后才会执行到这里
        // 如果异常被成功修复,会看到 nResult 变成了 22
        std::printf("After div in __try block: ");
        VAR_WATCH();
    }
    // __except 的括号里是"过滤表达式",用逗号运算符把多个动作串起来:
    // 逗号运算符会从左到右依次计算,整个表达式的值等于最后一项的值
    __except (
        // 动作 1:打印"进入过滤表达式",并输出当时的变量值
        std::printf("In __except block: "), VAR_WATCH(),
        // 动作 2:根据异常代码判断到底是不是除零异常
        GetExceptionCode() == EXCEPTION_INT_DIVIDE_BY_ZERO
            // 是除零异常:做三件事,整体的值是最后一个
            ? (nDivisor = 1,                                         // 把除数修正为 1(消除错误原因)
               std::printf("Divide Zero exception detected: "),      // 打印提示
               VAR_WATCH(),                                          // 打印修正后的变量值
               EXCEPTION_CONTINUE_EXECUTION)                         // 返回 -1:回到出错指令继续执行
            // 不是除零异常:这里不处理,让系统继续找别的处理者
            : EXCEPTION_CONTINUE_SEARCH)
    {
        // 因为过滤表达式返回的是 CONTINUE_EXECUTION,
        // 并不会进入这个 handler 块,所以这行永远不会打印
        std::printf("In handler block.\n");
    }
    // 等待按键,避免窗口一闪而过
    return std::getchar();
}

5.3 对应的汇编:异常到底发生在哪

C 语言那一行 nResult = nDividend / nDivisor; 编译后大致是这样(地址和偏移因编译环境会有差异):

; 18:  nResult = nDividend / nDivisor;
00401087  8B 45 E4        mov   eax, dword ptr [ebp-1Ch]   ; 把被除数 nDividend 读进 EAX
0040108A  99              cdq                              ; 把 EAX 符号扩展到 EDX:EAX(32 位扩成 64 位)
0040108B  F7 7D E0        idiv  eax, dword ptr [ebp-20h]   ; 有符号除法:EDX:EAX 除以除数 nDivisor  <-- 异常在这里发生
0040108E  89 45 DC        mov   dword ptr [ebp-24h], eax   ; 把商(EAX)存回 nResult

这里为什么要先 cdq?因为 idiv 的被除数是 64 位的 EDX:EAX\text{EDX:EAX}EDX:EAX,而我们的被除数只有 32 位,所以要先把符号扩展出来:
EDX:EAX=SignExtend(EAX) \text{EDX:EAX} = \text{SignExtend}(\text{EAX}) EDX:EAX=SignExtend(EAX)
idiv 执行时的内部逻辑,在 Intel 手册里开头几行是:

IF SRC = 0
    THEN #DE;    (* Divide error *)
FI;
......

意思很直白:先检查除数是不是 0,是的话就产生 #DE 异常。
另外补充一点,除零不是 #DE 的唯一来源。当商太大、装不进目标寄存器时,也会触发 #DE。对 32 位除法来说,商必须落在下面的范围内:
−231≤商≤231−1 -2^{31} \le \text{商} \le 2^{31} - 1 −231≤商≤231−1

5.4 一步步看发生了什么

对本例来说,当 CPU 执行到地址 0040108B 的 idiv 时,因为除数是 0,会依次发生:

  1. CPU 检测到除数为 0,报告 #DE 异常
  2. CPU 把 EFLAGS、CS、EIP 三个寄存器的内容压入栈保存。因为是"错误"类异常,这里保存的 EIP 指向的是 idiv 指令本身,而不是它的下一条
  3. CPU 根据向量号 0,找到除零异常的处理程序并跳转过去(如何找到,会在讲 IDT 的章节里说明)
  4. 操作系统的异常处理程序做一轮检查和预处理,然后调用 __except 里的过滤表达式
  5. 过滤表达式打印信息,发现是除零,把 nDivisor 改成 1,返回 EXCEPTION_CONTINUE_EXECUTION
  6. 系统据此恢复现场,CPU 回到 0040108B 重新执行 idiv。这次除数是 1,顺利算出 22÷1=2222 \div 1 = 2222÷1=22
  7. 程序接着往下走,打印 “After div”,此时 nResult 已经是 22
    可以看到,这个设计的精髓就在第 2 步:保存的是出错指令的地址,所以"修好问题后重试"才有意义。

5.5 时序图

下面这张图把上面七步画成时序图(对应上面 fault.cpp 的执行过程):

过滤表达式系统异常分发CPU程序 main过滤表达式系统异常分发CPU程序 main进入 try 块打印 Before divnDivisor=0 nResult=100执行 idiv 指令检查除数发现等于 0把 EFLAGS CS EIP 压栈EIP 指向 idiv 本身按向量号 0 跳转到处理程序检查与预处理计算 except 的过滤表达式打印 In except block判断异常码是除零异常把 nDivisor 改为 1打印 Divide Zero exception detected返回 EXCEPTION_CONTINUE_EXECUTION恢复现场回到 idiv 指令重新执行 idiv22 除以 1 等于 22商 22 写入 nResult打印 After divnDivisor=1 nResult=22

5.6 运行结果

实际运行,会看到下面四行输出:

Before div in __try block: nDividend=22, nDivisor=0, nResult=100.
In __except block: nDividend=22, nDivisor=0, nResult=100.
Divide Zero exception detected: nDividend=22, nDivisor=1, nResult=100.
After div in __try block: nDividend=22, nDivisor=1, nResult=22.

逐行对照:

输出行说明nDivisornResult
第 1 行除法之前,一切正常0100
第 2 行异常已发生,进入过滤表达式,变量还没改0100
第 3 行过滤表达式把除数修正为 11100
第 4 行回到 idiv 重新执行成功,结果写入122

注意第 3 行和第 4 行的 nResult:第 3 行时除法还没真正完成,所以还是 100;回到 idiv 重做之后,才变成 22。这个变化正好印证了:异常修复后,出错的那条指令确实被重新执行了一遍。

5.7 常见坑

  1. 一定要关闭优化(/Od)。开启优化后,编译器可能把 nDivisor 放在寄存器里。过滤表达式改的是内存里的那份,寄存器里的还是 0,idiv 重新执行时又除以 0,于是不断触发异常,陷入死循环
  2. 这个写法只适用于"错误类"异常。如果异常是陷阱类(如断点),返回地址已经是下一条指令,"重新执行"就没有这个意思了
  3. __try / __except 是 MSVC 的扩展,用 GCC 或 Clang 的普通模式编译不了
  4. 同一个函数里,__try 不能和需要析构的 C++ 局部对象混用(会报 C2712)。本例只用了 int,所以没问题;真实项目里通常把 __try 单独放进一个小函数

6. 小结

  • 异常是 CPU 执行指令时遇到问题,暂停并跳转到处理程序的机制,每种异常都有一个向量号
  • 0 到 31 号向量归 CPU 设计者使用,32 到 255 号留给操作系统和硬件厂商
  • 异常分为错误、陷阱、中止三类,区别在于返回地址指向哪里,以及能否恢复
  • 部分异常会额外压入错误代码,用 EXT、IDT、TI 三个标志位加上索引,指出问题描述符所在的位置
  • 除零属于"错误"类异常:保存的 EIP 指向出错的 idiv,所以修复除数之后,让 CPU 回去重做就能成功
  • SEH 的过滤表达式用三个返回值(0、1、-1)告诉系统:继续找、执行处理块、还是回去重试

x86 中断与异常处理——从零理解

一、先建立整体印象

中断(Interrupt)和异常(Exception)产生的原因不一样:

  • 中断多来自外部,比如键盘按下、时钟到点、网卡收到数据;
  • 异常多来自 CPU 执行指令时遇到的问题,比如除以 0、访问了不存在的页。
    但 CPU 和操作系统处理它们的办法是统一的,核心思路只有一句话:每种中断/异常都有一个编号(叫向量号),CPU 拿着这个编号去查一张表,表里记着"该去哪里执行处理代码",然后跳过去执行,执行完再回到被打断的地方。
    这张"表"在不同的 CPU 工作模式下长得不一样:

工作模式核心表格表格位置每项大小表格总大小
实模式IVT(中断向量表)固定在物理地址 0 到 10234 字节1KB
保护模式(32 位)IDT(中断描述符表)可变,由 IDTR 寄存器指出8 字节2KB
IA-32e 模式(64 位)IDT可变,由 IDTR 寄存器指出16 字节4KB

三种模式的表项数量都是 256 个,和 256 个中断向量一一对应:
4×256=1024 字节,8×256=2048 字节,16×256=4096 字节4 \times 256 = 1024 \text{ 字节},\quad 8 \times 256 = 2048 \text{ 字节},\quad 16 \times 256 = 4096 \text{ 字节}4×256=1024 字节,8×256=2048 字节,16×256=4096 字节
下面按模式一个一个讲。

二、实模式:最简单的 IVT

2.1 IVT 长什么样

实模式下,中断向量表 IVT 是一张线性表,固定放在物理内存最开头的 1KB(地址 0 到 1023)。每个表项 4 个字节,里面存着这个中断处理例程的入口地址,分成两半:

表项中的位置内容
低 2 字节中断例程的偏移地址
高 2 字节中断例程的段地址

实模式下的地址是"段地址 + 偏移地址"的形式,真实的 20 位物理地址这样算(段地址左移 4 位,等于乘 16):
物理地址=段地址×16+偏移地址物理地址 = 段地址 \times 16 + 偏移地址物理地址=段地址×16+偏移地址
向量号 nnn 对应的表项在内存里的位置也很好算:
表项地址=n×4+IVT基地址表项地址 = n \times 4 + IVT 基地址表项地址=n×4+IVT基地址
因为 IVT 固定在地址 0,所以实际上就是 n×4n \times 4n×4。
举个例子(数值是为了演示假设的):某个向量对应的表项里,段地址是 0xF000,偏移地址是 0xF065,那么中断例程的物理地址就是:
0xF000×16+0xF065=0xF0000+0xF065=0xFF0650xF000 \times 16 + 0xF065 = 0xF0000 + 0xF065 = 0xFF0650xF000×16+0xF065=0xF0000+0xF065=0xFF065

2.2 CPU 响应中断的步骤

中断或异常发生时,CPU 按下面的步骤处理:

  1. 把当前的代码段寄存器 CS 和指令指针 IP(EIP 的低 16 位)压入栈,记住"被打断时执行到哪了";
  2. 把标志寄存器 EFLAGS 的低 16 位压入栈,记住被打断时的状态;
  3. 清除标志寄存器的 IF 位,禁止其他可屏蔽中断,避免处理过程中又被打断;
  4. 清除 TF(单步陷阱)、RF(恢复标志)、AC(对齐检查)标志;
  5. 用向量号 nnn 作为索引,在 IVT 里找到对应表项(地址 n×4+IVT基地址n \times 4 + IVT 基地址n×4+IVT基地址);
  6. 把表项里的段地址和偏移地址分别装进 CS 和 EIP,CPU 就开始执行中断例程了。
    补充一个细节:真实硬件的压栈次序是先压标志寄存器、再压 CS 和 IP,IRET 弹出时顺序正好反过来。这里记住"三样东西都要保存"就够了。
    中断例程总是以 IRET 指令结束。IRET 会把栈里保存的 IP、CS、标志寄存器依次弹出来,于是 CPU 就回到了被打断的程序,接着往下执行。

2.3 时序图

中断例程IVT(地址0到1023)堆栈CPU设备或指令中断例程IVT(地址0到1023)堆栈CPU设备或指令产生中断或异常(向量号 n)保存 CS、IP保存 FLAGS(低16位)清除 IF、TF、RF、AC读取地址 n*4 处的4字节偏移地址(低2字节)和段地址(高2字节)装入 CS 和 EIP,开始执行执行 IRET弹出 IP、CS、FLAGS回到被打断的程序继续执行

三、保护模式:IDT 登场

3.1 IDT 和 IVT 的几点不同

保护模式下负责这件事的表叫 IDT(Interrupt Descriptor Table,中断描述符表)。作用和 IVT 一样,但有几处关键区别:

  • 位置不固定。IVT 永远在地址 0,IDT 可以放在内存的任意位置,CPU 靠一个专门的寄存器 IDTR 来知道它在哪;
  • 表项更大。32 位模式下每项 8 字节,总共 8×256=20488 \times 256 = 20488×256=2048 字节;
  • 表项内容更丰富。每个表项叫"门描述符",里面除了地址,还有特权级、类型等信息。

3.2 IDTR 寄存器与 LIDT、SIDT 指令

IDTR 一共 48 位:

位范围内容
高 32 位IDT 的基地址
低 16 位IDT 的长度(limit)

为了读写 IDTR,CPU 专门提供了两条指令:

指令全称作用
LIDTLoad IDT把指定的基地址和长度装进 IDTR,也就是改写 IDTR
SIDTStore IDT把 IDTR 的内容存到内存变量里,也就是读出 IDTR

这两条指令只能在实模式,或者保护模式的最高特权级(Ring 0)下执行。这是为了防止普通用户程序随便改 IDT,把系统搞崩。在内核调试时,可以用 r idtr 和 r idtl 命令分别观察 IDTR 里的基地址和长度。
系统软件(操作系统或 BIOS 固件)通常在初始化阶段就把中断处理例程和 IDT 准备好,再用 LIDT 把 IDT 的位置告诉 CPU。

3.3 Windows 里 IDT 是怎么建立的

以 Windows 为例,大致过程如下:

  1. 加载程序(NTLDR 或 WinLoad)还在实模式时,先准备好一块内存作为 IDT;
  2. 执行 CLI 指令关闭中断;
  3. 执行 LIDT 把 IDT 的位置和长度告诉 CPU;
  4. 把 CPU 从实模式切换到保护模式;
  5. 把执行权交给内核入口函数 KiSystemStartup;
  6. 内核里的处理器初始化函数用 SIDT 读出 IDT 信息,做必要调整,再作为参数传给 KiInitializePcr 函数;
  7. KiInitializePcr 把这些信息记录到描述处理器的 PCR(Processor Control Region)和 PRCB(Processor Control Block)里。
KiInitializePcr内核(KiSystemStartup)CPU加载程序(NTLDR或WinLoad)KiInitializePcr内核(KiSystemStartup)CPU加载程序(NTLDR或WinLoad)在实模式下准备好IDT内存块CLI 关闭中断LIDT 加载IDT的基地址和长度切换到保护模式移交执行权SIDT 读取IDT信息对IDT信息做必要调整传入IDT信息记录到PCR和PRCB

上面这些都发生在 0 号处理器上,它被称为 BSP(Bootstrap Processor,引导处理器)。因为即使是多 CPU 的机器,启动初期也只有 BSP 一个 CPU 在跑。等 BSP 完成内核初始化和执行体的阶段 0 初始化后,在阶段 1 才会调用 KeStartAllProcessors 函数去启动其他 CPU,这些 CPU 叫 AP(Application Processor)。对每个 AP,这个函数会给它建立单独的处理器状态区,包括它自己的 IDT:KiInitProcessor 函数会照着启动 CPU 的 IDT 复制一份,再做必要修改。

3.4 用调试器看 PCR

在内核调试会话里,用 !pcr 命令可以看 CPU 的 PCR 内容,下面是 Windows Vista 上 0 号 CPU 的输出,几个关键字段的含义如下:

字段含义
KPCR for Processor 0 at 81969a00KPCR 结构所在的线性地址
SelfPcr这个结构自己的起始地址
PrcbKPRCB 结构的地址
IrqlCPU 当前的中断请求级别
IDTIDT 的基地址(本例是 834da400)
GDTGDT 的基地址(本例是 834da000)
TSS任务状态段(TSS)的地址
CurrentThread当前正在执行的线程(ETHREAD 地址)
NextThread下一个准备执行的线程
IdleThread空闲线程的 ETHREAD 地址

因为内核结构体 KPCR 描述了 PCR 的布局,也可以用 dt nt!_KPCR 81969a00 命令按结构体格式查看。

3.5 门描述符:IDT 的表项

理论上 IDT 长度可变,但通常固定设计成 256 项。每一项叫门描述符(Gate Descriptor)。叫"门",是因为它的作用就像一扇门:引导 CPU 从一个空间走到另一个空间去执行,穿过这扇门时,CPU 会做必要的安全检查和准备工作。
IDT 里可以放三种门:

门类型用途
任务门(task gate)用于任务切换,里面存的是任务状态段(TSS)的段选择子。通过它,CPU 能切换到另一个任务去执行
中断门(interrupt gate)描述中断处理例程的入口
陷阱门(trap gate)描述异常处理例程的入口

三种门格式很相似,32 位下每项 8 字节,可以分成低 4 字节和高 4 字节:

所在位置位范围字段说明
低 4 字节31 到 16段选择子选择 GDT 或 LDT 里的一个段描述符
低 4 字节15 到 0偏移地址低 16 位段内偏移的低半部分
高 4 字节31 到 16偏移地址高 16 位段内偏移的高半部分
高 4 字节15P段存在标志
高 4 字节14 到 13DPL描述符特权级,用于特权级控制
高 4 字节12 到 8类型共 5 位,区分是哪种门

对中断门和陷阱门来说,"段选择子 + 偏移"合起来就是处理例程的地址;对任务门来说,段选择子指向的是 TSS 段。
系统靠类型字段(高 4 字节的第 8 到 12 位,共 5 位)区分门的种类,其中 D 位表示是 16 位门(0)还是 32 位门(1):

门类型类型字段(二进制)
任务门00101
中断门0D110
陷阱门0D111

3.6 CPU 怎么用 IDT 找处理函数

整体流程是:先用向量号和 IDTR 里的基地址找到门描述符,再看类型,不同类型走不同的路。
向量号 nnn 对应的门描述符地址是:
门描述符地址=IDT基地址+n×8门描述符地址 = IDT 基地址 + n \times 8门描述符地址=IDT基地址+n×8

任务门

中断门

陷阱门

中断或异常发生
得到向量号 n

用 IDTR 基地址 + n*8
找到门描述符

门类型

硬件方式的任务切换
切到新任务的TSS去执行

在当前任务里调用处理例程
自动清除 IF 位

在当前任务里调用处理例程
不清除 IF 位

下面分别讲任务门,和中断门/陷阱门两种情况。

3.7 任务门:切换到一个新任务

任务门指向的是一个 TSS 段。TSS(任务状态段)是一块内存,里面按 CPU 规定的格式存着一个任务的关键信息:段寄存器、通用寄存器、控制寄存器,以及一组很重要的字段 SS0 到 SS2、ESP0 到 ESP2,它们记录这个任务在不同特权级运行时应当使用哪个栈(SSx 选择栈所在的段,ESPx 是栈指针)。
CPU 通过任务门切换任务的过程是:

  1. 通过任务门里的段选择子找到 TSS 段描述符;
  2. 做一系列检查,比如 P 标志必须是 1、段界限要大于 0x67、B(Busy)标志不能是 1;
  3. 检查都通过后,把当前任务的状态保存到当前任务的 TSS 里,并把 TSS 段描述符的 B 标志置 1;
  4. 把新任务的段选择子加载到 TR 寄存器;
  5. 把新任务的寄存器信息装进物理寄存器;
  6. CPU 开始执行新任务。
动手看一个例子:双误异常 #DF

双误异常(Double Fault,#DF)指的是"处理一个异常的过程中又发生了异常"。这时原来的线程环境可能已经不可靠了(比如栈已经用光),所以它用任务门,换一个全新的环境去处理。
在 Windows Vista 的内核调试会话里观察它:
第一步,取得 IDT 基地址:

kd> r idtr
idtr=834da400

第二步,找到 8 号表项。 #DF 的向量号是 8,每项 8 字节:
834da400+8×8=834da400+0x40=834da440834da400 + 8 \times 8 = 834da400 + 0x40 = 834da440834da400+8×8=834da400+0x40=834da440

kd> db 834da400+8*8 l8
834da440  00 00 50 00 00 85 00 00

这 8 个字节从 0 开始数:

  • 第 2、3 字节组成的 WORD 是段选择子:0x0050;
  • 第 5 字节 0x85,二进制是 1000 0101:最高位 P = 1,接下来 2 位 DPL = 00,低 5 位类型 = 00101,正好是任务门。
    第三步,看这个选择子指向的段描述符:
kd> dg 50
Sel    Base     Limit      Type    ...
0050   81967000 00000068   TSS32 Avl ...

说明这个 TSS 段从地址 0x81967000 开始,长度 0x68 字节,类型是 32 位 TSS,状态是可用(Avl,不是 Busy)。
第四步,看 TSS 的内容:

kd> dd 81967000
81967000  00000000 81964000 00000010 00000000
81967010  00000000 00000000 00000000 00122000
81967020  8193f0a0 00000000 00000000 00000000
81967030  00000000 00000000 81964000 00000000
81967040  00000000 00000000 00000023 00000008
81967050  00000010 00000023 00000030 00000000
81967060  00000000 20ac0000 00000000 81964000
81967070  00000010 00000000 00000000 00000000

TSS 偏移内容含义
0x0481964000在特权级 0 执行时使用的栈指针(ESP0)
0x0800000010在特权级 0 执行时使用的栈选择子(SS0)
0x1C00122000页目录基地址寄存器 PDBR(也就是 CR3)
0x208193f0a0EIP,CPU 切换到这个任务时从这里开始执行
0x4800000023ES 寄存器
0x4C00000008CS 寄存器,即这个任务代码段的选择子
0x5000000010SS 寄存器,即栈段选择子
0x54、0x58、0x5C0x23、0x30、0DS、FS、GS 寄存器
0x6420ac0000最低位是 T 标志(0);高 16 位 0x20ac 是 I/O 映射区基地址相对 TSS 基地址的偏移

第五步,看 EIP 指向哪个函数:

kd> ln 8193f0a0
(8193f0a0)   nt!KiTrap08 ...

结论:发生 #DF 时,CPU 会切换到这个 TSS 所描述的任务,并在其中执行内核函数 KiTrap08。
类似地,代表紧急任务的不可屏蔽中断(NMI)和代表严重硬件错误的机器检查异常(MCE)也是用任务门处理的。除了这 3 个向量,其他绝大多数中断和异常都用中断门或陷阱门处理。

3.8 中断门和陷阱门:在当前任务里处理

CPU 先根据门描述符里的段选择子找到段描述符,做一系列检查,然后判断要不要切换栈:

  • 如果处理例程所在代码段的特权级比当前更高(数值更小),就必须切换栈。做法是从当前任务 TSS 里读出新栈的 SS 和 ESP,装进 SS 和 ESP 寄存器,然后把被打断过程的旧 SS 和旧 ESP 压进新栈;
  • 如果特权级相同,就不用切换栈。
    接下来两种情况都要做:
  1. 把 EFLAGS、CS、EIP 压入栈(CS 和 EIP 代表被打断时 CPU 执行到的位置);
  2. 如果是异常,而且这个异常带错误代码,就把错误代码也压入栈。
    发生特权级切换时,新栈里的内容大致是这样(栈从高地址往低地址增长,最上面是最后压入的):
低地址(栈顶)
+------------------+
|  错误代码(如有)   |
+------------------+
|       EIP        |
+------------------+
|        CS        |
+------------------+
|      EFLAGS      |
+------------------+
|  旧 ESP          |
+------------------+
|  旧 SS           |
+------------------+
高地址

是

否

是

否

根据门描述符的段选择子
找到并检查目标代码段

目标代码段特权级
比当前更高?

从当前 TSS 读出新的 SS 和 ESP
装入 SS 和 ESP 寄存器

把旧的 SS 和 ESP 压入新栈

不切换栈

压入 EFLAGS、CS、EIP

异常且带错误代码?

再压入错误代码

跳转到处理例程执行

3.9 Windows 里的 TSS 到底有几个

观察 TR 寄存器(保存当前任务 TSS 的段选择子):

kd> r tr
tr=00000028

做内核调试时会发现 TR 的值大多数时候不变。原因是:Windows 的 TSS 个数跟 CPU 个数有关,而不是跟线程个数有关。启动时,Windows 为每个 CPU 创建 3 到 4 个 TSS:

  • 一个处理 NMI;
  • 一个处理 #DF 异常;
  • 一个处理机器检查异常(跟版本有关,XP SP1 中存在);
  • 一个供所有普通线程共享。
    普通线程切换时,Windows 只是把当前线程状态复制到那个共享的 TSS 里,并不真的切换 TSS。只有发生 NMI 或 #DF 这种特殊情况才会切换 TSS,这就是所谓"用软件方式切换线程"。

3.10 Windows 的 IDT 设置一览

用 !idt -a 可以列出 IDT 的各个表项(它做了翻译,显示的不是门描述符的原始格式)。Windows 8(32 位)的 IDT 设置大致如下,其他版本或硬件配置下个别表项可能不同:

向量号门类型处理例程或 TSS 选择子说明
00中断门nt!KiTrap00除零错误
01中断门nt!KiTrap01调试异常
02任务门0x0058不可屏蔽中断 NMI,切换到系统线程处理,使用函数 KiTrap02
03中断门nt!KiTrap03断点
04中断门nt!KiTrap04溢出
05中断门nt!KiTrap05数组越界
06中断门nt!KiTrap06无效指令
07中断门nt!KiTrap07数学协处理器不存在或不可用
08任务门0x0050双误 #DF,切换到系统线程处理,执行 KiTrap08
09中断门nt!KiTrap09协处理器段溢出
0a中断门nt!KiTrap0A无效的 TSS
0b中断门nt!KiTrap0B段不存在
0c中断门nt!KiTrap0C栈段错误
0d中断门nt!KiTrap0D一般保护错误
0e中断门nt!KiTrap0E页错误
0f中断门nt!KiTrap0F保留,会引发 0x7F 号蓝屏
10中断门nt!KiTrap10浮点错误
11中断门nt!KiTrap11内存对齐
12任务门0x00A0机器检查,切换到新线程,执行 hal!HalpMcaExceptionHandlerWrapper
13中断门nt!KiTrap13SIMD 浮点错误
14 到 1f中断门nt!KiTrap0F保留,会引发 0x7F 号蓝屏
20 到 29无NULL未使用
2a中断门nt!KiGetTickCount获取时钟滴答数
2b中断门nt!KiCallbackReturn从逆向调用返回
2c中断门nt!KiRaiseAssertion断言
2d中断门nt!KiDebugService调试服务
2e中断门nt!KiSystemService系统服务
2f中断门nt!KiTrap0F保留
30中断门hal!Halp8254ClockInterruptIRQ0 时钟中断
31 到 3F中断门驱动程序通过 KINTERRUPT 结构注册的处理例程IRQ1 到 IRQ15,其他硬件设备的中断
40 到 FD中断门nt!KiUnexpectedInterruptX没有使用

从这张表可以看出,Windows 8 的 IDT 只用了任务门和中断门两种,没有用陷阱门。

3.11 中断门和陷阱门到底差在哪

二者行为非常像,只差一点:

比较项中断门陷阱门
进入处理函数前对 IF 位的处理CPU 自动清除 IF 位,也就是屏蔽中断不会自动清除 IF 位

这点差别带来一个实际影响。Windows 的系统调用处理函数 KiSystemService 里有一条 sti 指令(重新打开中断),因为通过中断门进来后 IF 位被自动清掉了,而系统调用可能执行很久,为了及时响应其他中断,得手动重新置位 IF。
Linux 的做法更贴合硬件特点:系统调用向量 SYSCALL_VECTOR(常量 0x80)直接用陷阱门,进入时 IF 位不会被清除,就不用再手动开中断了。相关源代码:

// arch/x86/kernel/traps.c
// 把系统调用向量设置成陷阱门,处理函数是 system_call
set_system_trap_gate(SYSCALL_VECTOR, &system_call);
// arch/x86/include/asm/irq_vectors.h
// 系统调用使用的向量号
# define SYSCALL_VECTOR            0x80

奔腾 II 之后的 x86 CPU 还支持专门的快速系统调用指令,这是另一套机制,这里不展开。

3.12 动手:在 Linux 里观察 IDT

可以在 Linux 虚拟机里用 GDB 做本地内核调试,观察 IDT:

  1. 打开终端,启动 GDB:sudo gdb --core /proc/kcore;
  2. 加载内核符号文件:symbol-file /usr/src/kernels/linux-2.6.35.9/vmlinux;
  3. Linux 内核用全局数组 idt_table 作为 IDT,打印它的地址:print /x &idt_table,结果类似 $6 = 0xc16ff000,这就是 IDT 的线性地址;
  4. 打印第 0 个表项:print /x idt_table[0],得到类似下面的内容:
$10 = {{{a = 0x601cec, b = 0xc14b8e00}, {limit0 = 0x1cec, base0 = 0x60, ...
     type = 0xe, s = 0x0, dpl = 0x0, p = 0x1, ... }}}

这里的 a 是表项的低 4 字节,b 是高 4 字节。按前面的门描述符布局来解读:

  • 处理函数地址 = b 的高 16 位 + a 的低 16 位 = 0xc14b 和 0x1cec 拼起来 = 0xc14b1cec;
  • 用 i symbol 0xc14b1cec 可以查到它对应 divide_error,正好是除 0 异常的处理函数;
  • b 的低 16 位是 0x8e00,其中 P = 1,DPL = 0,类型 = 0xe,也就是 32 位中断门。
  1. 也可以用 x /2x &idt_table[0] 直接看原始数据;
  2. 看用于系统调用的 0x80 号表项:x /2x &idt_table[0x80],得到 0x00601720 0xc14bef00。处理函数地址是 0xc14b1720,对应 system_call;b 的低 16 位是 0xef00,类型是 0xf,DPL = 3,说明是陷阱门,而且允许用户态(Ring 3)程序通过 int 0x80 触发;
  3. 用 x /512x &idt_table[0] 可以把整个 IDT 的原始数据显示出来,看完用 q 退出 GDB。

四、IA-32e 模式(64 位)

64 位模式下,IDT 仍然是处理中断异常的核心,但有两处主要改动。

4.1 表项变大了

每个 IDT 表项扩展到 16 字节,多出来的两个 DWORD:

  • 低地址的那个,存处理函数地址的高 32 位;
  • 高地址的那个,保留未用。
    于是 IDT 总长度变成:
    16×256=4096 字节(4KB)16 \times 256 = 4096 \text{ 字节(4KB)}16×256=4096 字节(4KB)
    刚好是一个普通内存页的大小。

4.2 没有任务门了,换成 IST

x64 架构不再支持硬件方式的任务切换,所以 IDT 里不再有任务门。但有个问题:某些异常发生时,当前线程的栈可能已经用光了,比如栈溢出导致的双误异常。原来靠任务门换一个全新环境解决,现在没有任务门了怎么办?
x64 引入了 IST(Interrupt Stack Table,中断栈表) 机制,让 CPU 在处理异常时可以自动切换栈:

项目说明
IST 位置在 x64 新格式的 TSS 里
IST 表项每项 64 位(8 字节),内容是一个指向栈的指针
最大表项数7 个,索引号 1 到 7
如何使用新格式门描述符的第二个 WORD 的 Bit 0 到 2,共 3 位,用来索引 IST
索引 0 的含义不需要切换栈,不指向任何有效的 IST 表项
32 位门描述符中的这个位置保留未用

调试 64 位 Windows 10 时,!idt 输出里带有 Stack = xxx 的项,说明它使用了非 0 的 IST 索引,CPU 在发生这类中断或异常时会先切换栈。
总体看,64 位模式下的 IDT 变简单了:没有任务门,只剩中断门和陷阱门,二者都能通过 IST 位域指定是否切换栈。

五、用代码把"门描述符"解析一遍

下面这段 C++ 程序,把前面讲的内容用代码验证一遍:

  • 实模式:根据段地址、偏移地址计算物理地址;
  • 32 位保护模式:解析 8 字节的门描述符,用的就是前面 Linux 实验和 Windows #DF 实验里的真实数值;
  • 64 位模式:解析 16 字节的门描述符,并取出 IST 索引(这一项的数值是为演示而构造的)。
#include <cstdint>   // 使用固定宽度整数类型,如 uint16_t、uint32_t
#include <cstdio>    // 使用 printf 打印十六进制结果
// ---------- 第一部分:实模式地址计算 ----------
// 实模式下:物理地址 = 段地址 * 16 + 偏移地址
// 左移 4 位等价于乘以 16
std::uint32_t RealModeAddress(std::uint16_t segment, std::uint16_t offset) {
    return (static_cast<std::uint32_t>(segment) << 4) + offset;
}
// ---------- 第二部分:门类型的名字 ----------
// type5 是门描述符高4字节里第8到12位组成的5位类型字段。
// 任务门     = 00101
// 中断门     = 0D110
// 陷阱门     = 0D111
// 其中 D 位(第3位)表示16位门(0)还是32位门(1)。
// 判断时先用掩码 0x17(10111) 把 D 位屏蔽掉,再和标准值比较。
const char* GateTypeName(std::uint32_t type5) {
    std::uint32_t t = type5 & 0x17;
    if (t == 0x05) return "任务门";
    if (t == 0x06) return "中断门";
    if (t == 0x07) return "陷阱门";
    return "未知类型";
}
// ---------- 第三部分:解析32位门描述符 ----------
// low  = 门描述符的低4字节:高16位是段选择子,低16位是偏移地址低16位
// high = 门描述符的高4字节:高16位是偏移地址高16位,
//        bit15 是 P,bit14~13 是 DPL,bit12~8 是类型
void ParseGate32(const char* title, std::uint32_t low, std::uint32_t high) {
    std::uint32_t selector = low >> 16;                       // 段选择子
    std::uint32_t offset   = (high & 0xFFFF0000u) | (low & 0xFFFFu); // 拼出32位偏移
    std::uint32_t present  = (high >> 15) & 0x1;               // P 标志
    std::uint32_t dpl      = (high >> 13) & 0x3;               // 描述符特权级
    std::uint32_t type5    = (high >> 8) & 0x1F;               // 5位类型字段
    std::uint32_t dBit     = (type5 >> 3) & 0x1;               // D位:1表示32位门
    std::printf("[%s]\n", title);
    std::printf("  原始数据   : 低4字节=0x%08X 高4字节=0x%08X\n", low, high);
    std::printf("  门类型     : %s (类型字段=0x%02X)\n", GateTypeName(type5), type5);
    std::printf("  P=%u DPL=%u\n", present, dpl);
    if ((type5 & 0x17) == 0x05) {
        // 任务门:高16位的偏移字段没有意义,段选择子指向的是 TSS 段描述符
        std::printf("  TSS 段选择子 = 0x%04X\n", selector);
    } else {
        std::printf("  %s位门,段选择子=0x%04X 处理函数地址=0x%08X\n",
                    dBit ? "32" : "16", selector, offset);
    }
    std::printf("\n");
}
// ---------- 第四部分:解析64位门描述符 ----------
// 16字节拆成4个DWORD:
// d0: 高16位段选择子 | 低16位偏移[15:0]
// d1: 高16位偏移[31:16] | P | DPL | 类型 | ... | 低3位 IST
// d2: 偏移[63:32]
// d3: 保留
void ParseGate64(const char* title, std::uint32_t d0, std::uint32_t d1,
                 std::uint32_t d2, std::uint32_t d3) {
    (void)d3;  // 第4个DWORD保留未用
    std::uint32_t selector = d0 >> 16;
    std::uint64_t offset =
        (static_cast<std::uint64_t>(d2) << 32) |    // 偏移[63:32]
        (d1 & 0xFFFF0000u) |                         // 偏移[31:16]
        (d0 & 0xFFFFu);                              // 偏移[15:0]
    std::uint32_t present = (d1 >> 15) & 0x1;
    std::uint32_t dpl     = (d1 >> 13) & 0x3;
    std::uint32_t type5   = (d1 >> 8) & 0x1F;
    std::uint32_t ist     = d1 & 0x7;               // 第二个WORD的 Bit0~2,IST索引
    std::printf("[%s]\n", title);
    std::printf("  门类型     : %s\n", GateTypeName(type5));
    std::printf("  P=%u DPL=%u\n", present, dpl);
    std::printf("  段选择子=0x%04X 处理函数地址=0x%016llX\n",
                selector, static_cast<unsigned long long>(offset));
    if (ist == 0) {
        std::printf("  IST 索引 = 0,不切换栈\n");
    } else {
        std::printf("  IST 索引 = %u,CPU 会自动切换到 IST 第 %u 项指向的栈\n", ist, ist);
    }
    std::printf("\n");
}
int main() {
    // 1. 实模式:演示用的段地址和偏移地址
    std::printf("===== 实模式地址计算 =====\n");
    std::uint32_t phys = RealModeAddress(0xF000, 0xF065);
    std::printf("0xF000:0xF065 -> 物理地址 0x%05X\n\n", phys);
    // 2. 32位保护模式:解析 Linux 实验中看到的真实数据
    std::printf("===== 32位保护模式门描述符 =====\n");
    // idt_table[0]:除零异常,a=0x00601cec b=0xc14b8e00
    ParseGate32("Linux idt_table[0] 除零异常", 0x00601cec, 0xc14b8e00);
    // idt_table[0x80]:系统调用,a=0x00601720 b=0xc14bef00
    ParseGate32("Linux idt_table[0x80] 系统调用", 0x00601720, 0xc14bef00);
    // Windows 8号表项 #DF:字节 00 00 50 00 00 85 00 00
    // 按小端序排成两个DWORD:低4字节=0x00500000 高4字节=0x00008500
    ParseGate32("Windows 8号表项 双误异常", 0x00500000, 0x00008500);
    // 3. 64位模式:构造一个使用 IST 的中断门
    std::printf("===== 64位模式门描述符 =====\n");
    // 处理函数地址 0xFFFFF80012345678,选择子 0x0010,P=1 DPL=0 类型=0x0E,IST=1
    ParseGate64("构造的64位中断门", 0x00105678, 0x12348E01, 0xFFFFF800, 0x00000000);
    return 0;
}

代码是怎么实现的

  • RealModeAddress 直接实现前面的公式 段地址×16+偏移地址段地址 \times 16 + 偏移地址段地址×16+偏移地址,左移 4 位就是乘 16。
  • GateTypeName 把 5 位类型字段和三种门的标准值比较。中断门和陷阱门的类型里有个 D 位,所以先用掩码 0x17(二进制 10111)把 D 位清零,再比较,这样 16 位门和 32 位门都能识别。
  • ParseGate32 按门描述符的位布局,用移位和按位与把各个字段取出来:段选择子在低 4 字节的高 16 位;偏移地址由"高 4 字节的高 16 位"和"低 4 字节的低 16 位"拼成;P、DPL、类型都在高 4 字节的低 16 位里。任务门要单独处理,因为它的段选择子指向的是 TSS,偏移字段没有意义。
  • Windows 双误表项那组数据:原始字节是 00 00 50 00 00 85 00 00,内存里按小端序存放,所以低 4 字节读出来是 0x00500000(段选择子 0x0050 在高 16 位),高 4 字节读出来是 0x00008500(0x85 在第 8 到 15 位)。
  • ParseGate64 多取了一个 IST 索引:它就是第二个 DWORD 的低 3 位。处理函数地址由三段拼成:低 16 位、中间 16 位、高 32 位。
    运行结果:
0xF000:0xF065 -> 物理地址 0xFF065 ===== 32位保护模式门描述符 =====
[Linux idt_table[0] 除零异常]
  原始数据   : 低4字节=0x00601CEC 高4字节=0xC14B8E00
  门类型     : 中断门 (类型字段=0x0E)
  P=1 DPL=0
  32位门,段选择子=0x0060 处理函数地址=0xC14B1CEC
[Linux idt_table[0x80] 系统调用]
  原始数据   : 低4字节=0x00601720 高4字节=0xC14BEF00
  门类型     : 陷阱门 (类型字段=0x0F)
  P=1 DPL=3
  32位门,段选择子=0x0060 处理函数地址=0xC14B1720
[Windows 8号表项 双误异常]
  原始数据   : 低4字节=0x00500000 高4字节=0x00008500
  门类型     : 任务门 (类型字段=0x05)
  P=1 DPL=0
  TSS 段选择子 = 0x0050 ===== 64位模式门描述符 =====
[构造的64位中断门]
  门类型     : 中断门
  P=1 DPL=0
  段选择子=0x0010 处理函数地址=0xFFFFF80012345678
  IST 索引 = 1,CPU 会自动切换到 IST 第 1 项指向的栈

对应的调用过程时序图:

GateTypeNameParseGate64ParseGate32RealModeAddressmainGateTypeNameParseGate64ParseGate32RealModeAddressmain段地址0xF000 偏移0xF065物理地址0xFF065除零异常门(a=0x00601cec b=0xc14b8e00)类型字段0x0E中断门打印选择子和处理函数地址系统调用门(a=0x00601720 b=0xc14bef00)类型字段0x0F陷阱门打印 DPL=3 等信息双误门(0x00500000 0x00008500)类型字段0x05任务门打印TSS段选择子构造的64位门类型字段0x0E中断门打印地址和IST索引

六、整体小结

中断与异常处理

实模式

保护模式 32位

IA-32e 模式 64位

IVT: 固定在地址0
256项 x 4字节 = 1KB

段地址x16+偏移地址
得到例程物理地址

IDT: 位置由IDTR给出
256项 x 8字节 = 2KB

LIDT 写入IDTR
SIDT 读出IDTR
仅Ring 0可用

三种门: 任务门 中断门 陷阱门

IDT: 256项 x 16字节 = 4KB

没有任务门

IST: 最多7项
用来自动切换栈

用 ASCII 再把重点串一遍:

中断与异常处理
├── 实模式
│   ├── IVT
│   │   ├── 固定在物理地址 0 到 1023
│   │   └── 每项 4 字节 (高2字节段地址, 低2字节偏移)
│   └── 响应步骤
│       ├── 保存 CS、IP、FLAGS
│       ├── 清除 IF、TF、RF、AC
│       └── 查表跳转,IRET 返回
├── 保护模式 (32位)
│   ├── IDT
│   │   ├── 位置可变,由 IDTR 指出
│   │   └── 每项 8 字节,称为门描述符
│   ├── 任务门
│   │   └── 切换到新任务 (例: #DF、NMI、机器检查)
│   ├── 中断门
│   │   └── 进入时自动清除 IF
│   └── 陷阱门
│       └── 进入时不清除 IF (例: Linux 的 0x80 系统调用)
└── IA-32e 模式 (64位)
    ├── 每项 16 字节,IDT 共 4KB
    ├── 没有任务门
    └── IST 机制 (最多 7 项,3 位索引)

对比项实模式保护模式IA-32e 模式
核心表格IVTIDTIDT
表格位置固定在地址 0IDTR 指出IDTR 指出
表项大小4 字节8 字节16 字节
表格总大小1KB2KB4KB
表项内容段地址和偏移地址门描述符门描述符(含 IST 索引)
任务门没有有没有
切换栈的方式无任务门或特权级切换时读 TSSIST 机制

ARM 架构的异常机制:从零理解

前面学的是 x86 的异常,这一篇换到 ARM 架构,重点看两者哪里不一样。先把概念理顺,再看 Windows on ARM(简称 WoA)是怎么把异常处理函数"登记"给 CPU 的,最后用一个可以直接运行的 C++ 小程序,把整个查表和跳转的过程模拟一遍。

1. 先看最大的区别:异常的范围

x86 和 ARM 对"中断"和"异常"的划分方式不同:

  • x86:中断和异常是并列的两个概念
  • ARM:中断只是异常的一种,被包含在异常里面
    换句话说,ARM 把所有"让 CPU 离开正常执行流程"的软硬件原因,统一叫作异常。

1.1 ARM 的五类异常


序号类别通俗解释
1复位(Reset)上电或重启,CPU 从头开始
2中断(Interrupt)外部设备找 CPU 办事,比如键盘、网卡
3内存系统中止(Memory System Abort)访问内存出了问题,比如缺页、权限不对
4未定义的指令(Undefined Instruction)CPU 遇到了不认识的指令
5系统调用类(SVC、SMC、HVC)程序主动请求更高特权级的服务

第五类里的三个名字,区别在于"请求谁来帮忙":

缩写全称请求对象
SVCSupervisor Call操作系统内核
HVCHypervisor Call虚拟机监控程序
SMCSecure Monitor Call安全监视器(安全世界)

用一张分类图把它们放在一起:

ARM 异常

复位 Reset

中断 Interrupt

内存系统中止
Memory System Abort

未定义的指令
Undefined Instruction

系统调用类

IRQ 普通中断

FIQ 高优先级中断

取指中止
Prefetch Abort

数据中止
Data Abort

SVC 调用内核

HVC 调用虚拟机监控程序

SMC 调用安全监视器

1.2 与 x86 的对照


对比项x86ARM
中断与异常的关系并列的两个概念中断是异常的一种
登记处理函数的结构IDT(中断描述符表)异常向量表
表项个数最多 256 项,靠向量号索引8 项,每种异常占固定位置
表项里放的内容门描述符一条指令(或处理函数地址)
告诉 CPU 表在哪IDTR 寄存器VBAR 寄存器

2. 异常向量表是什么

在 x86 里,CPU 有 256 个向量号,要靠一张大表(IDT)去查。ARM 简单得多:只有 8 个固定位置,每种异常发生时,CPU 直接跳到"表基址 + 固定偏移"的位置去执行里面的指令。
这 8 个位置按顺序是:

向量号偏移异常类型
00x00复位
10x04未定义的指令
20x08系统调用(SVC)
30x0C取指中止(Prefetch Abort)
40x10数据中止(Data Abort)
50x14保留
60x18普通中断(IRQ)
70x1C高优先级中断(FIQ)

注意一个关键点:每个位置放的不是处理函数的地址,而是一条指令。CPU 出异常后直接执行这条指令,所以这条指令通常是一条跳转,把 CPU 带去真正的处理函数。

3. Windows on ARM 如何设置向量表

3.1 告诉 CPU 表在哪:VBAR 寄存器

CPU 怎么知道向量表放在内存的什么位置?靠一个专门的寄存器 VBAR(Vector Base Address Register,向量基址寄存器),它位于系统控制协处理器 CP15 中。
WoA 启动时,内核会把准备好的向量表地址写进 VBAR。关键的三条汇编如下(地址和偏移可能因版本不同而不同):

; 把变量里存的向量表地址读进 R3
; 这个地址末位是 1,表示 Thumb 指令集状态(符号地址末位为 1 是 Thumb 代码的惯例)
810dc7ae  4b08      ldr   r3, =nt!KiArmExceptionVectors+0x1   ; R3 = 0x81037701
; 把 R3 的最低位清零,因为向量表的真实地址必须对齐,不能带这个标志位
810dc7b0  f0230301  bic   r3, r3, #1                          ; R3 = R3 & ~1
; 把 R3 写入协处理器 CP15 的 VBAR 寄存器,从此 CPU 就知道向量表在哪了
810dc7b4  ee0c3f10  mcr   p15, #0, r3, c12, c0                ; VBAR = R3

bic 的意思是"位清除"(Bit Clear),清掉第三个操作数里为 1 的那些位。用公式表示,就是:
R3←R3 & ∼1 R3 \leftarrow R3 \ \&\ \sim 1 R3←R3 & ∼1
代入具体数字,0x810377010x810377010x81037701 的最低位是 1,清零之后:
0x81037701 & ∼1=0x81037700 0x81037701 \ \&\ \sim 1 = 0x81037700 0x81037701 & ∼1=0x81037700
所以最终写入 VBAR 的值是 0x810377000x810377000x81037700。

3.2 向量表里到底放了什么

用调试器的 dds(显示内存中的字和符号)命令查看这块内存,会发现前 8 个表项内容完全相同,都是 f01cf8df。
这并不奇怪,用 u 命令反汇编一下就明白了:它是一条 ldr 指令的机器码:

ldr     pc, [pc, #VECTOR_OFFSET]

这条指令的含义是:以当前 pc 为基础,加上一个固定偏移,取出那个地址里存的内容,装进 pc。往 pc 里装一个新地址,就等于跳到那个地址执行,所以它是一种特殊的跳转。

3.3 为什么 8 条指令的机器码一模一样

可以把整张表看成一个有 16 个元素的数组,每个元素 4 字节:

数组下标内容
0 到 78 条 ldr pc, [pc, #0x1C] 指令
8 到 158 个处理函数的地址(没用到的填全 F,即 -1)

第 iii 条指令放在字节位置 4i4i4i,处理函数地址放在字节位置 32+4i32 + 4i32+4i。
在 Thumb 状态下,读取 pc 得到的是"当前指令地址加 4",所以 ldr 执行时 pc 的值是 4i+44i + 44i+4。需要的偏移就是:
偏移=(32+4i)−(4i+4)=28=0x1C \text{偏移} = (32 + 4i) - (4i + 4) = 28 = 0x1C 偏移=(32+4i)−(4i+4)=28=0x1C
注意算式里 iii 被抵消了,偏移与 iii 无关,永远是 0x1C0x1C0x1C。这正是 8 条指令机器码完全相同的原因。
再看机器码 0xF01CF8DF0xF01CF8DF0xF01CF8DF:它由两个 16 位半字组成(低半字 0xF8DF0xF8DF0xF8DF 是操作码,高半字 0xF01C0xF01C0xF01C 里,高 4 位 0xF0xF0xF 表示目标寄存器是 pc,低 12 位 0x01C0x01C0x01C 就是偏移)。对上了。
用 Mermaid 画出这个"间接跳转"的结构:

后半段 8 个函数地址

前半段 8 条 ldr 指令

下标 0
ldr pc, pc+0x1C

下标 1
ldr pc, pc+0x1C

下标 3
ldr pc, pc+0x1C

下标 8
全 F 未使用

下标 9
未定义指令处理函数

下标 11
取指中止处理函数

4. WoA 的六个异常处理函数

向量表后半段里,实际用到的有 6 个处理函数,全 F 的项表示没有使用。它们和前面讲的五类异常大致对应:

处理函数负责处理的事
KiUndefinedInstructionException未定义的指令
KiSWIException系统调用
KiPrefetchAbortException内存系统中止:访问代码时出的问题,比如缺页
KiDataAbortException内存系统中止:访问数据时出的问题
KiInterruptException所有中断的统一入口,内部判断中断源后再分发
KiFIQException高优先级中断(FIQ)的入口

两点值得注意:

  1. 内存中止被拆成两个函数,分工很明确:取指令时出问题走 Prefetch,读写数据时出问题走 Data
  2. WoA 并不支持 FIQ,所以一旦进入 KiFIQException,它就会主动触发蓝屏,让系统崩溃

5. 动手:用 C++ 模拟向量表

理论讲完,下面用一个普通 C++ 程序把"CPU 出异常 → 取向量表指令 → 算偏移 → 跳到处理函数"这一整套流程模拟出来。程序不依赖 ARM 硬件,在任何电脑上都能编译运行。
编译运行:

g++ -std=c++17 -o vector_sim vector_sim.cpp
./vector_sim

5.1 完整代码

// vector_sim.cpp
// 用普通 C++ 模拟 ARM 异常向量表的工作方式(Thumb-2 风格)
// 向量表共 16 个 32 位字:前 8 个是 ldr 指令,后 8 个是处理函数"地址"(这里用编号代替)
// 编译:g++ -std=c++17 -o vector_sim vector_sim.cpp
#include <cstdint>   // uint32_t
#include <cstdio>    // std::printf
// ---------- 一、几个"处理函数" ----------
void OnUndefined() { std::printf("    -> 执行 KiUndefinedInstructionException,处理未定义指令\n"); }
void OnSvc()       { std::printf("    -> 执行 KiSWIException,处理系统调用\n"); }
void OnPrefetch()  { std::printf("    -> 执行 KiPrefetchAbortException,处理取指时的内存异常\n"); }
void OnDataAbort() { std::printf("    -> 执行 KiDataAbortException,处理访问数据时的内存异常\n"); }
void OnInterrupt() { std::printf("    -> 执行 KiInterruptException,所有中断的统一入口\n"); }
void OnFiq()       { std::printf("    -> 执行 KiFIQException,系统不支持 FIQ,这里会蓝屏\n"); }
// 处理函数编号到函数的映射;向量表里存编号,相当于真实系统里存函数地址
using Handler = void (*)();
const Handler kHandlers[] = { OnUndefined, OnSvc, OnPrefetch, OnDataAbort, OnInterrupt, OnFiq };
// 向量表里"没用到"的表项,用全 F 表示
const uint32_t kUnused = 0xFFFFFFFFu;
// 8 个向量的名字,下标就是向量号
const char *kVectorName[8] = {
    "Reset", "Undefined Instruction", "Supervisor Call", "Prefetch Abort",
    "Data Abort", "Reserved", "IRQ", "FIQ"
};
// ---------- 二、模拟的向量表:16 个 32 位字 ----------
uint32_t g_table[16];
// 初始化向量表
void BuildTable()
{
    // 前 8 个字:每个都是同一条机器码 0xF01CF8DF,即 ldr pc, [pc, #0x1C]
    for (int i = 0; i < 8; ++i) g_table[i] = 0xF01CF8DFu;
    // 后 8 个字:和前 8 个一一对应,存处理函数编号,没用到的存全 F
    g_table[8 + 0] = kUnused;  // 0 Reset:没用到
    g_table[8 + 1] = 0;        // 1 未定义指令 -> OnUndefined
    g_table[8 + 2] = 1;        // 2 系统调用   -> OnSvc
    g_table[8 + 3] = 2;        // 3 取指中止   -> OnPrefetch
    g_table[8 + 4] = 3;        // 4 数据中止   -> OnDataAbort
    g_table[8 + 5] = kUnused;  // 5 保留:没用到
    g_table[8 + 6] = 4;        // 6 IRQ 中断   -> OnInterrupt
    g_table[8 + 7] = 5;        // 7 FIQ 中断   -> OnFiq
}
// ---------- 三、模拟 CPU 的取向量动作 ----------
// vec 是向量号 0 到 7;CPU 会跳到"向量表基址 + 4 * vec"那条指令去执行
void RaiseException(int vec)
{
    std::printf("[向量 %d] %s\n", vec, kVectorName[vec]);
    // 1) 取出这个位置的指令(模拟 CPU 取指)
    uint32_t insn = g_table[vec];
    // 2) 解码 ldr 指令:Thumb-2 的 32 位指令由两个 16 位半字组成
    //    小端读出来后,高 16 位是第二个半字 0xF01C,低 12 位 0x01C 就是偏移
    uint32_t imm12 = (insn >> 16) & 0xFFFu;
    // 3) 计算 pc:Thumb 状态下读取 pc,得到的是"当前指令地址 + 4"
    uint32_t pcValue = 4u * vec + 4u;
    // 4) 目标地址 = pc + 偏移,它指向表后半段中对应的那个字
    uint32_t target = pcValue + imm12;
    std::printf("    指令=0x%08X  偏移=0x%X  pc=%u  目标地址=%u(字下标 %u)\n",
                insn, imm12, pcValue, target, target / 4u);
    // 5) 把目标位置的内容"加载到 pc",也就是跳转
    uint32_t entry = g_table[target / 4u];
    if (entry == kUnused) {
        std::printf("    -> 这一项是全 F,没有使用,不会有处理函数\n");
        return;
    }
    kHandlers[entry]();
}
int main()
{
    BuildTable();
    // ---- 演示 VBAR 的设置:最低位是 Thumb 标志,写入前要清零 ----
    uint32_t r3 = 0x81037701u;           // 符号地址 + 1(最低位 1 表示 Thumb)
    r3 = r3 & ~1u;                       // 相当于汇编里的 bic r3, r3, #1
    std::printf("写入 VBAR 的值 = 0x%08X\n\n", r3);
    // ---- 依次触发 8 个向量 ----
    for (int vec = 0; vec < 8; ++vec) RaiseException(vec);
    return 0;
}

5.2 运行结果

写入 VBAR 的值 = 0x81037700
[向量 0] Reset
    指令=0xF01CF8DF  偏移=0x1C  pc=4  目标地址=32(字下标 8)
    -> 这一项是全 F,没有使用,不会有处理函数
[向量 1] Undefined Instruction
    指令=0xF01CF8DF  偏移=0x1C  pc=8  目标地址=36(字下标 9)
    -> 执行 KiUndefinedInstructionException,处理未定义指令
[向量 2] Supervisor Call
    指令=0xF01CF8DF  偏移=0x1C  pc=12  目标地址=40(字下标 10)
    -> 执行 KiSWIException,处理系统调用
[向量 3] Prefetch Abort
    指令=0xF01CF8DF  偏移=0x1C  pc=16  目标地址=44(字下标 11)
    -> 执行 KiPrefetchAbortException,处理取指时的内存异常
[向量 4] Data Abort
    指令=0xF01CF8DF  偏移=0x1C  pc=20  目标地址=48(字下标 12)
    -> 执行 KiDataAbortException,处理访问数据时的内存异常
[向量 5] Reserved
    指令=0xF01CF8DF  偏移=0x1C  pc=24  目标地址=52(字下标 13)
    -> 这一项是全 F,没有使用,不会有处理函数
[向量 6] IRQ
    指令=0xF01CF8DF  偏移=0x1C  pc=28  目标地址=56(字下标 14)
    -> 执行 KiInterruptException,所有中断的统一入口
[向量 7] FIQ
    指令=0xF01CF8DF  偏移=0x1C  pc=32  目标地址=60(字下标 15)
    -> 执行 KiFIQException,系统不支持 FIQ,这里会蓝屏

观察几个细节:

  • 8 个向量的指令都是 0xF01CF8DF0xF01CF8DF0xF01CF8DF,偏移都是 0x1C0x1C0x1C,与前面的推导一致
  • 随着向量号 iii 增加,pc 每次增 4,目标位置也每次增 4,两者的差永远不变
  • 向量 0(复位)和向量 5(保留)是全 F,没有处理函数
    字下标 =目标地址/4= \text{目标地址} / 4=目标地址/4,例如向量 1 的目标地址是 36,对应字下标是 36/4=936 / 4 = 936/4=9。

5.3 时序图

下面是这个程序处理其中一个异常(以未定义指令为例)的过程:

处理函数g_tableRaiseExceptionBuildTablemain处理函数g_tableRaiseExceptionBuildTablemain计算 R3 与 ~1得到 VBAR 值 0x81037700初始化向量表前 8 个字写入 0xF01CF8DF后 8 个字写入函数编号或全 F触发向量 1 未定义指令读取下标 1 处的指令返回 0xF01CF8DF解码出偏移 0x1Cpc 等于 4 乘 1 加 4 等于 8目标地址等于 8 加 28 等于 36读取下标 9 处的内容返回处理函数编号 0调用 OnUndefined返回返回再依次触发其余向量全部完成

6. 实际系统中如何观察已登记的中断

在 WoA 目标上,用 WinDBG 的 !idt -a 命令可以列出系统里登记的中断处理函数,输出类似下面这样:

0: kd> !idt -a
Dumping IDT: 8122912c
Dumping Extended IDT: 00000000
Dumping Secondary IDT: 8542b000
1000:KeyButton+0x388c (KMDF) (KINTERRUPT 8b05fe00)
1003:FT5X06+0x6cc8 (KMDF) (KINTERRUPT 8b05fd00)
1004:VirtualCodec+0x6350 (KMDF) (KINTERRUPT 8b05fc00)
1005:sdport!SdPortWriteRegisterUshort+0x38c8 (KINTERRUPT 8b05f900)

这里要特别小心:这个 “IDT” 和 x86 的 IDT 完全不是一回事。

对比项x86 的 IDTWoA 上的 IDT
谁定义和维护CPU 硬件规定格式完全由操作系统软件定义和维护
作用CPU 查表找处理函数给驱动程序(KMDF 和 WDM 驱动)提供一致的开发接口和调试信息
CPU 是否直接使用是否,CPU 用的是向量表

简单说,WoA 借用了 IDT 这个名字,目的是让为 x86 写的驱动和调试习惯能无缝迁移过来,底层真正干活的还是前面讲的异常向量表。

7. 整个过程串起来

以"程序执行了一条未定义指令"为例,完整的流程如下:

内核处理函数异常向量表CPU正在运行的程序内核处理函数异常向量表CPU正在运行的程序执行到一条未定义指令判定为异常保存现场信息读取 VBAR 得到表基址跳到基址加 0x04 的位置执行 ldr pc, pc+0x1C跳转到 KiUndefinedInstructionException检查并处理处理完成后恢复执行或终止程序

8. 小结

  • ARM 把中断也算作异常的一种,共分五类:复位、中断、内存系统中止、未定义的指令、系统调用类(SVC、SMC、HVC)
  • ARM 没有 IDT,而是使用异常向量表:只有 8 个固定位置,位置由异常类型决定,不由向量号查表
  • 向量表的基址由 VBAR 寄存器记录,WoA 启动时把地址写进去,写入前要用 bic 把最低位清零
  • 向量表里每个位置放的是一条指令。WoA 的做法是:前 8 项都是 ldr pc, [pc, #0x1C],后 8 项存处理函数地址,偏移恒为 0x1C0x1C0x1C,所以前 8 项机器码都一样
  • WoA 用到 6 个处理函数,其中内存中止分为"取指"和"数据"两类,所有中断共用一个入口,FIQ 不被支持,进入就蓝屏
  • WoA 里 !idt -a 看到的 IDT 是软件维护的,目的是兼容驱动开发,和 x86 的 IDT 有根本区别
    回顾整章的脉络:先理解中断和异常是什么,再认识 x86 定义的各种异常,接着弄清楚它们的优先级以及 CPU 如何响应和处理,最后看 ARM 架构如何用不同的结构做同样的事。异常和调试关系非常密切,后续还会从操作系统和编译器(程序语言)两个角度继续深入。

软件断点:INT 3 是怎么让程序停下来的

1. 先想一个问题

你在调试器里点一下某一行代码左边,出现一个红点,运行程序,它就"神奇"地停在了那一行。
CPU 本来只会一条接一条地往下执行指令,它怎么知道要在你指定的那一行停下来?调试器又是怎么在不修改源代码的情况下做到的?
答案出人意料地简单:调试器偷偷把那个位置的指令换成了一条特殊指令 INT 3。 CPU 执行到它,就会"主动喊停",把控制权交给调试器。
这一整套机制叫软件断点。这篇文章从零开始,把它的每一步都讲清楚,并且用可以直接运行的 C++ 代码亲手验证。

2. 先打好基础

2.1 指令和机器码

CPU 执行的程序,在内存里就是一串字节。每条指令对应一个或多个字节,叫机器码。比如:

  • nop(什么都不做)的机器码是 0x90,1 个字节
  • push 0x0042201c 的机器码是 68 1c 20 42 00,5 个字节
    指令的长度不固定,这一点后面会反复用到。

2.2 指令指针

CPU 里有一个专门的寄存器,记录"下一条要执行的指令在哪个地址",叫指令指针:32 位下叫 EIP,64 位下叫 RIP。
一条基本原则:指令指针总是指向即将要执行的那条指令的第一个字节。

2.3 陷阱类异常

CPU 执行指令时遇到特殊情况,会暂停当前程序,转去执行一段由操作系统或调试器预先登记好的处理程序,这叫异常。其中有一类叫陷阱(Trap),特点是:指令已经执行完了,才通知处理程序,所以保存下来的指令指针指向"下一条指令"。
断点异常就属于陷阱,英文简称 #BP。记住这一点,后面会有一个有趣的"矛盾"需要解释。

3. INT 3:专门为调试设计的一条指令

3.1 它长什么样

INT 3 的机器码只有一个字节:
INT 3=0xCC=110011002 \text{INT 3} = \text{0xCC} = \text{11001100}_2 INT 3=0xCC=110011002​
它从最早的 8086 处理器开始就存在,唯一的使命就是:让 CPU 中断(break)到调试器。

3.2 为什么非要设计成 1 个字节

这是整个设计里最巧妙的地方。
设置断点,要把原位置的字节替换成 0xCC;取消断点,要把原来的字节还原。指令的长度不固定,有的 1 字节、有的 5 字节。如果断点指令也不固定长度,替换时就可能覆盖掉下一条指令的一部分。
只要断点指令是 1 个字节,不管原指令多长,都只需要保存和恢复 1 个字节,简单又安全。

3.3 它和 INT n 不是一回事

INT n 是通用的软件中断指令,机器码是 0xCD 加一个字节的编号 n。如果 n 等于 3,机器码就是 0xCD 0x03,占 2 个字节。它和 INT 3 在功能上相近,但系统对待它们并不相同:

对比项INT 3INT n(n=3 时)
机器码0xCC0xCD 0x03
长度1 字节2 字节
用途专为断点设计通用软件中断
系统待遇有特殊优待,比如在虚拟 8086 模式下不受 IOPL 检查按普通软件中断处理
编译器行为看到 INT 3 一律生成 0xCC不会被编译成 INT 3

4. 调试器怎样设置断点

4.1 两步走:存原字节,写 0xCC

当你在某行代码上设置断点,调试器做的事情只有两步:

  1. 找到这一行对应的第一条指令的地址,把那里的第一个字节保存起来
  2. 把那个字节替换成 0xCC
    用一个具体的例子看看字节层面发生了什么。假设地址 0x00401029 处是一条 5 字节的指令 push 0x0042201c:

地址设置断点前设置断点后
0x0040102968CC(原字节 68 被调试器保存起来)
0x0040102A1C1C
0x0040102B2020
0x0040102C4242
0x0040102D0000

只有第一个字节被改动,后面 4 个字节原封不动。

4.2 “落实断点”:为什么有时能在注释行设断点

你可能注意过:在某些调试器里,没开始调试时,甚至能把断点设在注释行上,开始调试时才弹出一个警告。
原因是:没有运行程序时,调试器只是把"哪个文件第几行有断点"这个信息记在项目的配置里,并没有真的去改内存,因为此时根本没有可以修改的内存映像。
等你点"开始调试",程序加载到内存后,调试器才把这些记录一个个取出来,把对应位置的第一个字节保存并替换成 0xCC。这个过程叫落实断点(resolve breakpoint)。如果某条记录对应不到代码段里(比如那是注释行),就无法落实,于是弹出警告。
整个生命周期可以画成下面这样:

设置断点
只记录文件和行号

开始调试
程序载入内存

落实断点
保存原字节并写入 0xCC

程序运行
执行到 0xCC

断点命中
中断到调试器

用户查看现场
交互调试

恢复执行
还原原字节并单步走出

重新落实断点

5. 断点命中:CPU 到底做了什么

5.1 CPU 层面:保存现场,跳到处理程序

CPU 执行到 0xCC,就会产生断点异常。以最简单的实模式为例,CPU 按顺序做这几件事:

步骤CPU 的动作为什么要这样做
1检查中断向量表有没有越界向量号 3 对应的表项地址是 3×4=123 \times 4 = 123×4=12,要确认它在表的范围内,否则产生一般保护异常
2检查栈上有没有至少 6 字节的空间马上要保存 6 字节的现场,空间不够就产生栈异常
3把标志寄存器低 16 位压栈保存现场,之后要原样恢复
4清除 IF、TF、AC 三个标志位进入处理程序后不能被别的中断或单步打断
5把当前的 CS 和 IP 压栈保存"回家的路",也就是被打断的位置
6从向量表里取出处理程序的段和偏移,装入 CS 和 IP之后 CPU 就去执行处理程序了

实模式下向量表每个表项占 4 个字节:2 字节偏移加 2 字节段。表项地址的计算公式是:
表项地址=n×4 \text{表项地址} = n \times 4 表项地址=n×4
其中 nnn 是中断向量号,断点异常的 n=3n=3n=3,所以它的表项在地址 12(即 0x0C)处。
保护模式下,CPU 改为到 IDT(中断描述符表)里找处理函数,并多做一些有效性检查,但"保存现场,然后跳转到处理程序"的原理完全一样。

5.2 操作系统层面:把事件转交给调试器

在 Windows 这样的多任务系统里,断点异常的处理函数是内核函数 KiTrap03。因为断点指令在用户程序里,CPU 会先从用户模式切到内核模式,然后:

  1. 内核发现这个异常来自一个"正在被调试"的进程
  2. 内核把它包装成一个调试事件,发给用户态的调试器
  3. 内核等待调试器的回复
  4. 调试器处理完毕,内核再用中断返回指令(IRET)让被调试程序继续运行

5.3 调试器层面:这是不是"我的"断点

调试器收到调试事件后,会拿事件里的地址到自己的断点列表中查找:

  • 找到了:说明这是我自己设置的断点,准备好现场,让用户交互调试
  • 没找到:说明这条 INT 3 不是调试器写进去的,而是程序里本来就有的(比如手工插入的),这时调试器通常会提示"某个用户断点被触发"
    把上面三层串起来,就是一次完整的断点命中过程:
操作系统内核CPU被调试程序调试器用户操作系统内核CPU被调试程序调试器用户在某一行设置断点保存原字节,写入 0xCC点击继续运行恢复运行执行到 0xCC,即 INT 3产生断点异常保存现场,查表跳转到处理例程把指令指针减 1以调试事件通知调试器在自己的断点列表里查找该地址命中,显示现场,等待操作

6. 一个有趣的"矛盾":指令指针指向哪里

前面说过,断点异常是陷阱,保存的指令指针应该指向 INT 3 的下一个字节。可是你在 Windows 调试器里看到的,却是指向 INT 3 本身的位置。这是怎么回事?

6.1 内核偷偷减了 1

Windows 的断点异常处理函数在分发异常之前,会把指令指针减 1:
EIP调试器看到=EIP陷入−1 \mathrm{EIP}_{\text{调试器看到}} = \mathrm{EIP}_{\text{陷入}} - 1 EIP调试器看到​=EIP陷入​−1
因为 INT 3 只有 1 个字节,陷入时的 EIP 就是 A+1A+1A+1(AAA 是 INT 3 的地址),减 1 之后刚好回到 AAA。

6.2 为什么要这样做

有两个理由:

  1. 断点只替换 1 个字节。 如果原来是一条多字节指令,A+1 指向的是那条指令的第 2 个字节,根本不是一条完整的指令的开头,从这里继续执行会出大问题。
  2. 被调试的那条指令还没有执行。 “指令指针总是指向即将执行的指令”,既然原指令还没执行,指针就应该指回它的起点。
    在 Linux 上的做法略有不同:内核直接把 A+1A+1A+1 交给调试器,由调试器自己减 1。下面的第 8 节你会亲眼看到这一点。

7. 恢复执行:为什么要"单步走出断点"

用户看完现场,想让程序继续跑。这里藏着一个小难题。
大多数调试器在断点命中后,会先把所有断点位置的 0xCC 还原成原字节(这样用户看到的是原来的代码)。恢复运行前,还要把断点重新落实一遍。问题来了:

  • 如果刚命中的这个断点也立刻写回 0xCC,程序一执行就又触发断点,永远走不出去
  • 如果不写回,这个断点就失效了,下次执行到这里不会停,而用户并不知道
    调试器的标准做法是:
  1. 把原指令还原,指令指针指向它
  2. 设置 CPU 的单步标志(TF),让 CPU 只执行这一条指令就再次中断
  3. 单步执行完这条原指令,再次中断到调试器(这一次不通知用户)
  4. 调试器趁机把断点重新写成 0xCC
  5. 正式恢复运行
    这个过程一般叫"单步走出断点"。下一节的代码就完整实现了它。

8. 动手:用 C++ 亲眼看到这一切

下面两个程序都能直接在 Linux x86-64 上编译运行。

8.1 示例一:验证"陷入时指令指针指向下一个字节"

这个程序在自己身上执行一条 INT 3,用 SIGTRAP 信号处理函数观察被打断时的 RIP。
保存为 int3_trap_demo.cpp,编译运行:

g++ -O0 -std=c++17 int3_trap_demo.cpp -o int3_trap_demo && ./int3_trap_demo
// int3_trap_demo.cpp
// 目的:亲眼看到"INT 3 是陷阱类异常:陷入处理程序时,指令指针指向 INT 3 的下一个字节"。
// 编译:g++ -O0 -std=c++17 int3_trap_demo.cpp -o int3_trap_demo
// 平台:Linux x86-64
#include <csignal>      // sigaction, SIGTRAP
#include <cstdint>      // uintptr_t
#include <cstdio>       // printf
#include <cstdlib>      // _exit
#include <ucontext.h>   // ucontext_t, REG_RIP
// 汇编里记录下来的两个地址(用全局变量,信号处理函数里才能读到)
static volatile uintptr_t g_int3_addr  = 0;  // INT 3 指令自己的地址
static volatile uintptr_t g_after_addr = 0;  // INT 3 的下一个字节的地址
static volatile sig_atomic_t g_hit     = 0;  // 处理函数被调用的次数
// SIGTRAP 处理函数:CPU 执行 INT 3 后,内核把它转成 SIGTRAP 交给我们
static void on_sigtrap(int, siginfo_t*, void* ctx_raw) {
    auto* ctx = static_cast<ucontext_t*>(ctx_raw);
    // 从被打断现场里取出当时的 RIP(64 位的指令指针)
    uintptr_t rip = static_cast<uintptr_t>(ctx->uc_mcontext.gregs[REG_RIP]);
    // 注意:这里只做简单输出,演示用
    std::printf("[handler] 进入 SIGTRAP 处理函数\n");
    std::printf("[handler] INT 3 的地址      = %#lx\n", (unsigned long)g_int3_addr);
    std::printf("[handler] 现场里的 RIP      = %#lx\n", (unsigned long)rip);
    std::printf("[handler] RIP - INT3 地址   = %ld (为 1 表示指向下一个字节)\n",
                (long)(rip - g_int3_addr));
    std::printf("[handler] RIP == 下一字节?  = %s\n",
                rip == g_after_addr ? "是" : "否");
    g_hit = g_hit + 1;
    // 这里不修改 RIP:返回后程序正好从 INT 3 的下一条指令继续,不会重复触发
}
int main() {
    // 1. 注册 SIGTRAP 处理函数
    struct sigaction sa {};
    sa.sa_sigaction = on_sigtrap;
    sa.sa_flags = SA_SIGINFO;          // 要拿到 ucontext,必须带这个标志
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTRAP, &sa, nullptr);
    std::printf("[main] 即将执行 INT 3\n");
    // 2. 内嵌汇编:先把标号 2(INT 3 自己)和标号 1(INT 3 之后)的地址存起来,再执行 INT 3
    asm volatile(
        "lea 2f(%%rip), %%rax\n\t"   // rax = 标号 2 的地址,即 INT 3 所在位置
        "mov %%rax, %0\n\t"          // 存进 g_int3_addr
        "lea 1f(%%rip), %%rax\n\t"   // rax = 标号 1 的地址,即 INT 3 的下一字节
        "mov %%rax, %1\n\t"          // 存进 g_after_addr
        "2: int3\n\t"                // 单字节指令 0xCC,触发断点异常
        "1:\n\t"
        : "=m"(g_int3_addr), "=m"(g_after_addr)
        :
        : "rax", "memory");
    std::printf("[main] INT 3 之后程序正常继续,处理函数调用次数 = %d\n", (int)g_hit);
    return 0;
}

运行结果(地址每次运行会不同,这是系统的地址随机化造成的,不影响结论):

[main] 即将执行 INT 3
[handler] 进入 SIGTRAP 处理函数
[handler] INT 3 的地址      = 0x5597945f034e
[handler] 现场里的 RIP      = 0x5597945f034f
[handler] RIP - INT3 地址   = 1 (为 1 表示指向下一个字节)
[handler] RIP == 下一字节?  = 是
[main] INT 3 之后程序正常继续,处理函数调用次数 = 1

关键是那一行"RIP - INT3 地址 = 1":CPU 陷入时,指令指针确实指向了 INT 3 的下一个字节,这正是前面说的"陷阱"特征。

8.2 示例二:迷你调试器,走完软件断点的完整生命周期

这个程序用 ptrace 实现一个只有一百多行的迷你调试器。它会:

  1. 在被调试函数 target 入口写入 0xCC
  2. 等待断点命中,把 RIP 减 1
  3. 还原原字节,单步执行一条原指令
  4. 重新写入 0xCC,继续运行,所以第二次调用 target 时断点能再次命中
    保存为 mini_debugger.cpp,编译运行:
g++ -O0 -std=c++17 mini_debugger.cpp -o mini_debugger && ./mini_debugger
// mini_debugger.cpp
// 目的:用不到 150 行代码,完整走一遍软件断点的生命周期:
//   设置(写入0xCC) -> 命中(SIGTRAP) -> 回退指令指针 -> 还原原字节
//   -> 单步走出断点 -> 重新落实断点 -> 继续运行
// 编译:g++ -O0 -std=c++17 mini_debugger.cpp -o mini_debugger
// 平台:Linux x86-64(用 ptrace 当调试器)
#include <sys/ptrace.h>   // ptrace
#include <sys/wait.h>     // waitpid
#include <sys/user.h>     // user_regs_struct
#include <unistd.h>       // fork
#include <csignal>        // raise, SIGSTOP, SIGTRAP
#include <cstdint>
#include <cstdio>
#include <cstdlib>
// ---------------- 被调试程序:一个不会被内联的小函数 ----------------
extern "C" __attribute__((noinline)) int target(int x) {
    volatile int y = x * 2;          // volatile 防止编译器优化掉
    return y + 1;
}
// ---------------- 调试器:读写被调试进程内存的小工具 ----------------
// 读 pid 进程中 addr 处的 1 个字节
static uint8_t read_byte(pid_t pid, uintptr_t addr) {
    long word = ptrace(PTRACE_PEEKTEXT, pid, (void*)addr, nullptr);
    return static_cast<uint8_t>(word & 0xFF);       // 小端:最低字节就是 addr 处的字节
}
// 把 addr 处的 1 个字节改成 value,其余 7 个字节保持不变
static void write_byte(pid_t pid, uintptr_t addr, uint8_t value) {
    long word = ptrace(PTRACE_PEEKTEXT, pid, (void*)addr, nullptr);
    word = (word & ~0xFFL) | value;                 // 只替换最低字节
    ptrace(PTRACE_POKETEXT, pid, (void*)addr, (void*)word);
}
// ---------------- 断点结构:保存地址和被替换掉的原字节 ----------------
struct Breakpoint {
    uintptr_t addr;
    uint8_t   saved;      // 被 0xCC 覆盖前的原始字节
    bool      enabled = false;
    void enable(pid_t pid) {            // 落实断点:先存原字节,再写 0xCC
        saved = read_byte(pid, addr);
        write_byte(pid, addr, 0xCC);
        enabled = true;
    }
    void disable(pid_t pid) {           // 取消断点:把原字节写回去
        write_byte(pid, addr, saved);
        enabled = false;
    }
};
int main() {
    // 关闭 stdout 缓冲:调试器和被调试程序的输出就会按真实发生顺序显示
    std::setvbuf(stdout, nullptr, _IONBF, 0);
    pid_t pid = fork();
    if (pid == 0) {
        // ===== 子进程:被调试程序 =====
        ptrace(PTRACE_TRACEME, 0, nullptr, nullptr);  // 声明"请父进程来调试我"
        raise(SIGSTOP);                               // 先停下,等调试器设置好断点
        for (int i = 1; i <= 2; ++i) {                // 调用两次,演示断点能再次命中
            int r = target(i);
            std::printf("[被调试程序] target(%d) 返回 %d\n", i, r);
            std::fflush(stdout);
        }
        _exit(0);
    }
    // ===== 父进程:迷你调试器 =====
    int status = 0;
    waitpid(pid, &status, 0);                         // 等子进程停在 SIGSTOP
    Breakpoint bp{reinterpret_cast<uintptr_t>(&target)};
    std::printf("[调试器] 断点地址 = %#lx,原始字节 = %#x\n",
                (unsigned long)bp.addr, read_byte(pid, bp.addr));
    bp.enable(pid);
    std::printf("[调试器] 写入断点后,该地址的字节 = %#x (0xCC 即 INT 3)\n",
                read_byte(pid, bp.addr));
    ptrace(PTRACE_CONT, pid, nullptr, nullptr);       // 让被调试程序跑起来
    int hits = 0;
    while (true) {
        waitpid(pid, &status, 0);
        if (WIFEXITED(status)) break;                 // 子进程结束,调试结束
        if (!(WIFSTOPPED(status) && WSTOPSIG(status) == SIGTRAP)) {
            ptrace(PTRACE_CONT, pid, nullptr, nullptr);
            continue;
        }
        // ---- 命中断点:此时 RIP 指向 INT 3 的下一个字节 ----
        user_regs_struct regs{};
        ptrace(PTRACE_GETREGS, pid, nullptr, &regs);
        std::printf("[调试器] 第 %d 次命中,RIP = %#llx\n", ++hits, regs.rip);
        if (regs.rip - 1 == bp.addr) {
            regs.rip -= 1;                            // 关键:把 RIP 倒退 1 字节,回到原指令起点
            ptrace(PTRACE_SETREGS, pid, nullptr, &regs);
            std::printf("[调试器] RIP 回退到 %#llx,指向原指令起点\n", regs.rip);
            bp.disable(pid);                          // 还原原字节,让原指令能真正执行
            ptrace(PTRACE_SINGLESTEP, pid, nullptr, nullptr);   // 单步执行这一条原指令
            waitpid(pid, &status, 0);                 // 等单步完成(又会收到 SIGTRAP)
            bp.enable(pid);                           // 重新落实断点,下次调用还能命中
            std::printf("[调试器] 已单步走出断点并重新落实\n");
        }
        ptrace(PTRACE_CONT, pid, nullptr, nullptr);   // 恢复运行
    }
    std::printf("[调试器] 被调试程序结束,断点共命中 %d 次\n", hits);
    return 0;
}

运行结果:

[调试器] 断点地址 = 0x56476cfd0269,原始字节 = 0xf3
[调试器] 写入断点后,该地址的字节 = 0xcc (0xCC 即 INT 3)
[调试器] 第 1 次命中,RIP = 0x56476cfd026a
[调试器] RIP 回退到 0x56476cfd0269,指向原指令起点
[调试器] 已单步走出断点并重新落实
[被调试程序] target(1) 返回 3
[调试器] 第 2 次命中,RIP = 0x56476cfd026a
[调试器] RIP 回退到 0x56476cfd0269,指向原指令起点
[调试器] 已单步走出断点并重新落实
[被调试程序] target(2) 返回 5
[调试器] 被调试程序结束,断点共命中 2 次

输出里有几个细节值得留意:

  • 设置断点前,目标地址的字节是 0xf3(这是现代编译器在函数开头放的一条 endbr64 指令的第一个字节),设置之后变成 0xcc
  • 命中时的 RIP 比断点地址大 1,调试器把它减 1 回到了断点地址
  • target 被调用了两次,断点命中了两次,说明"单步走出断点并重新落实"成功了

8.3 对应的时序图

下面是迷你调试器运行的时序图,和代码里的步骤一一对应:

被调试子进程调试器父进程被调试子进程调试器父进程PTRACE_TRACEME 后 raise SIGSTOP 停下读取 target 入口的原字节写入 0xCC,落实断点PTRACE_CONT 恢复运行第 1 次调用 target,执行到 0xCC收到 SIGTRAP,RIP 等于断点地址加 1PTRACE_SETREGS,RIP 减 1还原原字节PTRACE_SINGLESTEP 单步执行原指令单步完成,再次 SIGTRAP重新写入 0xCCPTRACE_CONT 恢复运行第 2 次调用 target,再次执行到 0xCC收到 SIGTRAP,重复上面的流程程序结束,调试器退出

9. INT 3 的几个特殊用途

9.1 为什么调试版本里到处是"烫烫烫"

调试程序时,你可能见过刚分配的内存或数组里全是 0xCC。在中文环境下,连续的 0xCCCC 恰好显示成汉字"烫",于是出现一大片"烫烫烫"。
这不是巧合,是编译器故意的:调试版本会把刚分配的缓冲区和栈空间全部填成 0xCC。如果因为缓冲区溢出或其他 bug,程序指针意外跑进了这些区域,CPU 一执行就遇到 INT 3,立刻中断到调试器,bug 当场暴露。

9.2 用作内存对齐的填充

编译器还会用 0xCC 填充函数之间、代码段末尾的空闲字节,起到内存对齐的作用。这也解释了为什么有时你没有手工写过任何断点,调试器仍然会弹出"断点被触发"的提示。

9.3 在代码里主动插入断点

不想用调试器设置,也可以直接在代码里写一个断点,常用的方法有:

场景方法
Windows 用户态DebugBreak() 或 MSVC 的 __debugbreak()
Windows 内核态DbgBreakPoint() 或 DbgBreakPointWithStatus()
直接内嵌汇编_asm INT 3(MSVC 32 位)或 asm volatile("int3")(GCC)
Linuxraise(SIGTRAP) 或内嵌汇编 int3

这些 API 在 x86 上本质上都只是对 INT 3 指令的简单封装。DbgBreakPointWithStatus 多做了一件事:把一个整数参数放进 eax 再执行 INT 3,让调试器能读到这个参数。
一个只能在 Windows 上编译运行的最小例子:

// debugbreak_demo.cpp  仅限 Windows,用 MSVC 编译
#include <windows.h>
#include <cstdio>
int main() {
    std::printf("断点之前\n");
    DebugBreak();                  // 等价于执行一条 INT 3:有调试器就停下,没有就弹出异常对话框
    std::printf("断点之后\n");     // 调试器恢复运行后才会执行到这里
    return 0;
}

10. 一个反面教材:0xCD03 导致的"错位"

前面说内核会把指令指针减 1,这对 1 字节的 INT 3 恰到好处。如果有人手工插入了 2 字节的 0xCD 0x03(也就是 INT n 且 n=3),会发生什么?
设这条指令位于地址 0x00401038,后面跟着两个 nop(0x90):

地址字节含义
0x00401038CDINT n 的操作码
0x0040103903n = 3
0x0040103A90nop
0x0040103B90nop

CPU 执行完这条 2 字节的指令,陷入时的指令指针是 A+2A+2A+2:
EIP陷入=0x00401038+2=0x0040103A \mathrm{EIP}_{\text{陷入}} = \text{0x00401038} + 2 = \text{0x0040103A} EIP陷入​=0x00401038+2=0x0040103A
内核照例减 1:
EIP调试器看到=0x0040103A−1=0x00401039 \mathrm{EIP}_{\text{调试器看到}} = \text{0x0040103A} - 1 = \text{0x00401039} EIP调试器看到​=0x0040103A−1=0x00401039
结果指向了 0xCD03 的第二个字节 03。继续执行,CPU 会把 03 90 90 8B ... 当成一条新的指令来解释,也就是一条莫名其妙的 add 指令,它访问的地址又是非法的,于是程序紧接着崩溃(访问违例)。
解决办法是手工把指令指针改到下一条指令:把 EIP 设成 0x0040103A,程序就能继续了。
这个例子很好地说明:"减 1"这个修正是专门为单字节的 0xCC 设计的,换成别的长度就不对了,这也是系统要给 INT 3 开小灶的原因之一。

11. 为什么在调试器里看不到 0xCC

既然内存里真的被写进了 0xCC,为什么我们在反汇编窗口里从来没见过它?
因为调试器把它藏起来了,常见做法有两种:

  1. 命中断点时,先把所有 0xCC 恢复成原字节,再交给用户。 用户看到的永远是原来的代码。
  2. 不做恢复的调试器,在反汇编和内存查看功能里做专门处理,显示的时候自动把 0xCC 替换成原字节。
    想亲眼看看真实的 0xCC,可以用另一个调试器以"非侵入式"的方式附加到同一进程上查看内存。这时会看到那条 5 字节的 push 指令变了样:

看到的内容反汇编结果
正确的原始字节 68 1C 20 42 00push 0x0042201c
被断点覆盖后的 CC 1C 20 42 00int 3,然后 1C 20 被误当成 sbb al,20h,42 被误当成 inc edx,后面全是乱码

原因很简单:第一个字节被换成了单字节的 int 3,反汇编程序就以为下一条指令从第二个字节开始,整串指令全部"错位"了。

12. 软件断点的局限和注意事项

12.1 局限


局限原因替代方案
只能用于代码,不能监视数据或 I/O 端口它靠"执行到某条指令"触发硬件断点
无法在只读存储器中使用(如 BIOS、固件)目标内存不能写入 0xCC硬件断点
IDT 或中断向量表没准备好或被破坏时不能用断点异常需要查表才能跳转到处理程序硬件级调试工具

虽然有这些局限,软件断点依然被广泛使用,因为它使用方便,而且数量没有限制。硬件断点需要专门的寄存器来记录地址,数量是有限的。

12.2 两个容易踩的坑

坑一:不能把断点设在指令中间。
调试器只会替换 1 个字节,不会检查这个位置是不是指令的开头。假设有这样一条指令:

7c90d9b0  b8 b7 00 00 00   mov eax, 0B7h

如果在 7c90d9b1(第 2 个字节)设置断点,字节 b7 就被换成了 cc,指令变成了 mov eax, 0CCh。这个断点永远不会命中,而且程序的行为被悄悄改变了:原来的 0xB7 是系统服务编号,现在变成了 0xCC,进入内核后会调用一个完全不同的系统服务,结果非常诡异。调试器通常不会给任何警告。
坑二:不能把软件断点设在变量上。
如果把变量的地址当作断点地址,变量的第一个字节会被换成 0xCC,变量的值被悄悄篡改。例如一个全局变量原来是 0x00416890,设置断点后变成了 0x004168CC。同样不会有警告,后果严重且不易察觉。
记住一个原则:软件断点只能设在一条指令的第一个字节上。 想监视变量,请使用硬件断点(数据断点)。

13. 小结

一句话总结:软件断点就是调试器把目标位置的第一个字节换成 0xCC(INT 3),CPU 执行到它就陷入异常,操作系统把事件转给调试器,调试器再把原字节还原并单步走出断点。
关键点回顾:

  1. INT 3 只有 1 个字节,所以设置和还原只需要处理 1 个字节
  2. 断点异常是陷阱,陷入时指令指针指向 INT 3 的下一个字节,调试器(或内核)会把它减 1 修正回来
  3. 恢复执行时要先单步走出断点,再重新落实,否则会重复触发或断点失效
  4. 调试器会隐藏 0xCC,所以你平时看不到它
  5. 软件断点只能设在指令开头,不能设在指令中间或变量上,也不能用于只读内存

14. 自测

  1. 为什么 INT 3 被设计成 1 个字节,而不是和 INT n 一样 2 个字节?
  2. 断点命中后,为什么调试器看到的指令指针是断点本身的地址,而不是下一个字节?
  3. 如果恢复执行时,调试器直接把刚命中的断点写回 0xCC 再继续运行,会发生什么?
  4. 为什么软件断点不能用来监视一个变量被谁修改?
    参考答案:
  5. 只替换 1 个字节就不会破坏相邻指令,设置和还原都只需要保存和恢复 1 个字节。
  6. 内核(或调试器)把陷入时的指令指针减了 1。因为原指令还没有执行,而且断点只覆盖了 1 个字节,指针必须回到原指令的起点。
  7. 程序一恢复运行,立刻又执行到 0xCC,再次命中同一个断点,永远走不出去。所以必须先还原原字节,单步执行一条,再重新写入。
  8. 软件断点靠"执行到某条指令"触发,变量只是数据,不会被执行;而且把 0xCC 写到变量上还会改坏变量的值。要监视数据,应使用硬件断点。
Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐