编程语言研究书库

中断与异常 — 特权级、门描述符与两条通往内核的电话线

中断与异常 — 特权级、门描述符与两条通往内核的电话线

这一章讲什么: 全书最后一章,回答「操作系统凭什么管得住所有程序」。 两件硬件机制:特权级(把 CPU 分成"管事的 0 级"和"干活的 3 级", 3 级碰不到硬件和内核内存)与中断/异常(设备、错误、乃至程序自己, 都能按下 CPU 的暂停键,经由一张门描述符表转交给操作系统处理)。 落点是一个能跑的最小程序:不用 C 库、不用 main,直接 syscall 叫内核替我们写字。 主线例子: 裸 syscall 版 "Hello, World!"——从 myStart 到屏幕,中间只有 系统调用编号 1(write)和六个寄存器。

1. 特权级:操作系统控制权的硬件根基

4 级特权,实际只用两头

CPU 任意时刻处于 4 种特权级之一,书里给了全表1:

级别能干什么谁住这
0直接访问所有硬件(含 I/O、内存管理)操作系统内核、设备驱动
1受限硬件访问理论上给部分库/软件,很少用
2更多限制同上
3不直接访问任何硬件应用程序(及其调用的库)

大多数操作系统只用 0 和 3,分别叫监管模式(kernel mode)和用户模式 (user mode)。书里特意提醒:别把 CPU 特权级和操作系统文件权限混为一谈—— 前者是硬件特性,后者是软件特性1。

门描述符:每次内存访问的通行证

CPU 每次访问内存都经手一个 16 字节的门描述符,里面记着那个内存页的特权级: CPU 只能访问特权级「等于或低于」自己当前级别的内存2。

开机时:操作系统拿到 0 级内存,CPU 也运行在 0 级;I/O 设备的内存也是 0 级。 加载应用程序时:操作系统给它分配 3 级内存,然后把 CPU 切到 3 级交出去—— 从此应用程序够不到操作系统和设备的任何内存;想改特权级?那条指令只能在 0 级执行。应用程序想求内核办事,只剩一条合法通道:经门描述符发起的中断/异常2。 ——第 11 章「系统调用是打电话叫内核」那句比喻,这里有了硬件根据。

把「加载一个程序」的完整动作序列摆出来,特权级切换的时机就具体了2:

操作系统(0 级)                 应用程序(3 级)
    │                                │
    ├─ 给它分配 3 级内存              │
    ├─ 把程序代码拷进去               │
    ├─ CPU 切到 3 级,rip 指向入口 ──→│
    │                                ├─ 正常运行,碰不到任何 0 级资源
    │←──── int/syscall(经门描述符)── ┤
    ├─ 升到 0 级,处理,iret/sysret ──→│ 降回 3 级,继续

注意表格里 1、2 级两行的措辞几乎是「可能」「也许」——这是为设想中的场景预留、 实际几乎没人用的两级。Linux 和 Windows 一样,世界里只有 0 和 31。 这个「设计了四级、用了两级」的事实本身就很能说明问题:硬件给的可能性, 比软件生态愿意消费的要多得多。

一个贯穿全书的回环

值得停一秒的是:这一章的每个零件,都是前面某章的"应用题"。

  • 门描述符把关内存访问 → 用的是第 20 章的虚拟内存/页机制;
  • 中断压栈 rip → 压的就是第 11 章那个"函数回家的唯一凭据"的同一张栈;
  • 保存 rflags → 存的是第 3 章溢出标志、第 13 章条件跳转赖以生存的那份"化验单";
  • int 0x80 / syscall 的参数 → 正是第 14 章 17 条规则那套传参约定的"内核版"。

本书从「计算机有三个子系统」起步,到「应用程序按一个按钮就能升到内核特权级」收尾—— 中间的每一章,都是这两句话之间的一级台阶。

2. 中断与异常:CPU 的暂停键

定义与四步响应

中断或异常 = 让 CPU 暂停当前指令流、去调用操作系统处理程序的事件。 处理程序是操作系统的一部分(内核内置或模块加载)3。

CPU 响应时做四件事(书里编号 1–4)4:

  1. rflags 压栈(比函数调用多压的这一份,是中断的签名——外部中断随时可能 插进来改写标志,回来时必须还原);
  2. rip 压栈(返回地址;异常处理后可能不返回);
  3. 特权级切到门描述符指定的级别(处理程序几乎总在 0 级跑;必要时还要换栈);
  4. 门描述符里的处理程序地址装入 rip,从那里继续执行。

返回用 iret(不是 ret):依次恢复 rflags、特权级、栈指针,最后恢复 rip。 有些异常处理后干脆不回去——打印错误、把程序移交操作系统,极端时操作系统自己停4。

门描述符表(IDT):256 个槽位的总机

所有门描述符存在一张**中断描述符表(IDT,也叫向量表)**里,中断号就是数组下标。 x86-64 共 256 个(0–255):前 32 个被 CPU 硬件预定(0 号是除零异常),剩下 224 个 留给操作系统安排外部中断和软件中断5。

三种按铃方式

书里按 Intel/AMD 手册的口径分三类6:

  • 外部中断:CPU 之外的硬件发起(键盘按键),经控制总线送达,与指令时序不同步—— 但 CPU 只在两条指令之间响应。书里给了一个教科书级的时序推演,假设按键恰好 插在这两条指令之间7:

    cmp  byte ptr [ebx], 0    ← 按键在这一条执行时到达
    je   allDone              ← 中断处理后要回到这里

    两条事实叠在一起,答案就唯一了:一是 CPU 只能在两条指令之间响应, cmp 必须执行完; 二是 cmp 执行完时 rip 已更新成下一条(je)的地址(第 9 章的取指节拍)。 所以压栈的返回地址指向 je——处理完,程序从 je 接着跑,好像什么都没发生。 而键盘中断处理程序几乎必然改写 rflags,je 依靠的是 cmp 留下的标志, 不是处理程序留下的。rflags 为什么必须保存,这里有了具体的答案; 这个例子还顺带解释了第 20 章的悬念:「设备好了叫 CPU」的这个"叫", 就是在任意两条指令之间插进一整套保存现场、查表、升级、跳转—— 一次按键到一次 read 返回之间,隔着几百次这样的暂停与恢复,只是快得看不出来;

  • 异常:CPU 自己撞上处理不了的状况——除以 0、访问无效地址、无效指令。 经典用户体验:Segmentation fault (core dumped)。但并非所有异常都是错误: 缺页异常就是正常的(第 20 章的页面故障),处理程序从磁盘把页读进来, 恢复 rip/rflags 继续跑,用户毫无察觉8;

  • 软件中断:用指令故意触发(int n)——目的只有一个:求操作系统服务。 3 级程序不能直接 call 内核函数,但中断机制自带"切特权级"功能, 于是一条指令既叫了人、又升了级,控制权始终在操作系统手里9。

4. 三种按铃方式的对照与两个容易混的点

把三种方式并排收拢,差别一目了然6:

外部中断异常软件中断
谁发起CPU 之外的硬件(键盘、磁盘)CPU 自己撞上错误程序用 int n 故意触发
时序与指令不同步,指令之间响应执行到错的那条指令时执行到 int 的那条指令时
典型例子键盘输入除零、无效地址、无效指令int 0x80 请求内核服务
目的设备要 CPU 处理数据把错误交给操作系统3 级程序合法地"升维"求内核

两个容易混的点,书里都点过:

  • 「中断」和「异常」的用法没有统一意见——本章按 Intel/AMD 手册口径分三类, 你在别的文献里可能看到不同的切法(比如把一切统称为"中断")6;
  • 异常不一定意味着 bug:缺页异常就是操作系统主动利用的正常机制—— 处理程序悄悄从磁盘读页、恢复现场,程序完全无感。判断标准是 「处理完能不能回到出事的现场继续跑」8。

iret 与 ret 的对照:为什么处理程序不能简单 ret

普通函数返回用 ret:弹出 rip 就走。中断处理程序的返回要多两道工序: 恢复 rflags(中断可能改写过标志,而被打断的代码正要靠它做下一条判断—— cmp/je 的例子就是证明)和恢复特权级(处理完要降回 3 级,把 CPU 还给程序)。 iret 一条指令把这三样(rip、rflags、特权级)从栈里一并还原4。 由此也解释了为什么 CPU 要多压一个 rflags:这笔开销不是设计冗余, 是「随时可能被打断」这个现实付的税。

5. 系统调用:两条通往内核的电话线

你已经用过系统调用了

第 11 章起所有程序都走 C 库包装函数(write/read/printf),它们内部干的就是 本节的事:把参数按约定塞进寄存器,发起一次中断/快速中断,替你把特权级从 3 换到 0。 区别在于:C 包装函数负责这个转换;现在我们不要包装,直接打10。

老线路:int 0x80(32 位遗产)

int n 调用 IDT 里第 n 号处理程序。Linux 用 128 号(0x80)做"总机": eax 放系统调用编号(0x80 的编号表在 unistd_32.h,共 300 多个), 参数走 ebx、ecx、edx、esi、edi、ebp——注意这套寄存器和函数调用完全不同11:

操作eaxebxecxedx
read3文件描述符存输入的地址字节数
write4文件描述符输出首字节地址字节数
exit1———

因为这套约定是 32 位时代的,64 位下虽仍可用,但官方推荐新线路11。

清单 21-1 是 int 0x80 版的 "Hello, World!",和 syscall 版对照着看最有收获11:

myStart:
    mov  edx, msgLength       # 参数3:长度(注意是 edx,不是 rdx 系的约定不同……
    lea  ecx, message[rip]    # 参数2:地址——ecx!不是 rsi
    mov  ebx, STDOUT          # 参数1:屏幕——ebx!不是 rdi
    mov  eax, WRITE           # 编号 4(write 的 32 位编号)
    int  0x80
    mov  eax, EXIT            # 编号 1
    int  0x80

三处和 syscall 版的差异,全是 ABI 差异而非语义差异:参数寄存器整套换了 (ebx/ecx/edx vs rdi/rsi/rdx)、编号换了(write 4 vs 1、exit 1 vs 60)、 用的寄存器宽度只有 32 位(这套机制为 32 位环境设计)。同一件事、两套暗号—— 这也是为什么查系统调用手册时必须分清自己查的是哪张表11。 入口名 myStart 随你起,但不能用 gcc 链接(它假定 main),要显式 as --gstabs -o … + ld -e myStart -o …——链接器 -e 选项指定"从哪里开始跑"。

新线路:syscall(64 位快速通道)

syscall 是 64 位指令集的新增,绕过 IDT、不动栈,全部操作在寄存器里完成—— 这就是"快速"的来源12:

syscall:rip → rcx;rflags → r11;LSTAR 寄存器里的处理程序地址 → rip;特权级 → 0
sysret :r11 → rflags;rcx → rip;特权级 → 3

(LSTAR 是 CPU 的特殊寄存器,操作系统开机时把 syscall 处理程序地址存进去。) 省掉了好几次内存读写,代价是处理程序必须自己保住 rcx 和 r11。 64 位的寄存器约定也不一样:rax 放编号,参数走 rdi、rsi、rdx、r10、r8、r9 (r10 而不是 rcx——因为 rcx 被 syscall 拿去存返回地址了); 编号表在 unistd_64.h,write=1、exit=6013。

主线例子:裸 syscall 的 "Hello, World!"

清单 21-2 是全书最浓缩的程序,逐行走14:

        .equ  WRITE, 1          # syscall 的编号和 int 0x80 不同!write 是 1
        .equ  EXIT, 60          # 对应 int 0x80 的 4 和 1
myStart:
        mov   rdx, msgLength    # 参数3:长度
        lea   rsi, message[rip] # 参数2:字符串地址
        mov   rdi, STDOUT       # 参数1:屏幕
        mov   rax, WRITE        # 系统调用编号
        syscall                 # 叫内核干活
        mov   rax, EXIT
        syscall

三个要点14:

  • 没有 main、没有 C 运行时:入口自己起名 myStart,链接用 as --gstabs -o … + ld -e myStart -o …(gcc 那条路假定有 main);
  • 参数寄存器和 C 调用约定几乎一样(rdi/rsi/rdx),只有第 4 个起分岔(r10)—— 所以读 C 库 write 的手册页能推 syscall 的参数;
  • 两次 syscall 之间没有任何库代码:屏幕上那行字,是 CPU 从 3 级到 0 级、 内核代码写字、再回 3 级的完整一圈——全书 21 章的全部铺垫,就为看懂这一圈。

判断(我们的,不是书里的): 本章把「为什么系统调用必须 special」讲到了根: 不是 ABI 习惯,而是特权级的硬件门槛——3 级代码连内核函数的地址都碰不到, 唯有中断/异常这条自带"升级"的通道是合法的。理解了这一层,再看任何操作系统的 API 设计(不只 Linux),都会意识到它们全是"如何在护栏内安全地按下门铃"的 不同方案。 如果错,会错在: 微内核与形式化验证等新路线试图缩小 0 级代码量, 但"用户态够不到硬件、经受控通道请求服务"这个格局没有变——变的是通道的 宽窄和护栏的位置。

还有一组对比值得留:同一个 write,走 C 库和裸 syscall 的差别。

C 库版:  call write@PLT            # 经 PLT/GOT 跳到 libc 的 write 包装函数
         …(包装函数内部:检查参数、设置 errno、再 syscall)…
裸版:    mov rax, 1; syscall       # 自己塞寄存器,一步到位

书里的例子刻意选了裸版,理由在 11.6 节就说过:想看的就是特权级切换本身, 包装函数每多一层,你看到的核心动作就少一层。真实的程序还是要走 C 库—— errno、信号、线程取消点这些"售后服务"都在包装里——但在学习时, 剥掉它们才看得清 syscall 这一跳的形状10。

判断(我们的,不是书里的): int 0x80 与 syscall 的差异,是理解 「兼容性是架构债务」的最佳小案例:新指令更快(全程寄存器、零内存访问), 但老指令不能删——32 位二进制还要跑。结果每个 64 位 Linux 内核同时维护 两张系统调用表、两套编号。架构演进的真实约束从来不是技术优劣,而是 已部署代码的重量。 如果错,会错在: 有些新架构(如 ARM64)没有历史包袱,只保留一条 syscall 类 指令——所以「两条电话线」是 x86 的局部风景,不是系统调用的普遍形态。

4. 作者的判断与证据

说法性质
特权级 0/3 分野、门描述符把关内存访问x86-64 硬件机制,书里按手册口径讲12
中断响应四步、iret 返回CPU 行为,作者做了编号式整理4
键盘插在 cmp/je 之间的例子作者构造的教学演示,机制上成立7
缺页异常是「正常的异常」机制事实(第 20 章已铺垫)8
int 0x80 / syscall 的寄存器约定Linux ABI 事实,编号表位置如实给出1113
「中断和异常两个术语如何使用,目前没有统一意见」作者对术语现状的如实说明6
处理程序编程超出本书范围明确的边界声明15

5. 边界与局限

  • 中断/异常处理程序的编写本身不讲(作者明言超出范围),第 20 章的 UART 轮询 是「裸」I/O 的极限了。
  • 只讲了机制的骨架:8259/APIC 中断控制器、中断屏蔽、嵌套优先级、 系统调用入口的完整内核路径,全在门外。
  • 术语「中断」与「异常」的界限按 Intel/AMD 口径采用,别的文献分类可能不同6。
  • int 0x80 在 64 位系统上是兼容路径,性能差于 syscall;书里如实并列两者, 但没有量化对比(补充,不在书里:上下文保存的差异即主要开销)。

6. 可带走的

  1. 特权级是硬件特性:0 级内核、3 级应用;内存访问按门描述符的级别把关;
  2. 应用程序合法访问内核服务的唯一通道:中断/异常机制;
  3. IDT = 门描述符数组,中断号即下标;0–31 号归 CPU 硬件;
  4. 中断响应比函数调用多压一份 rflags;返回用 iret,异常可能不返回;
  5. 三种按铃:外部中断(硬件、指令之间响应)、异常(CPU 撞上错误,缺页是正常的)、 软件中断(程序故意,int n);
  6. int 0x80:eax 编号 + ebx/ecx/edx/esi/edi/ebp,32 位遗产;
  7. syscall:绕开 IDT、全程寄存器、rip→rcx、rflags→r11;rax 编号 + rdi/rsi/rdx/r10/r8/r9;
  8. 64 位编号表在 unistd_64.h(write=1、exit=60),32 位在 unistd_32.h(write=4、exit=1);
  9. 裸程序:入口名随意 + ld -e 指定;两次 syscall 就是 helloWorld 的全部;
  10. Segmentation fault = 异常 → 处理程序 → 移交操作系统的标准剧本。

7. 原文地图

主题原书章原文位置
特权级表与两种模式第21章 中断与异常text/27-ch21.txt:24(搜「统来实现的,操作系统使用」) · text/27-ch21.txt:41(搜「监管模式」)
门描述符与内存访问控制第21章 中断与异常text/27-ch21.txt:45(搜「门描述符」) · text/27-ch21.txt:49(搜「最低特权级 3」)
中断定义与四步响应第21章 中断与异常text/27-ch21.txt:56(搜「暂停执行当前指令流」) · text/27-ch21.txt:72(搜「总结了 CPU 响应」) · text/27-ch21.txt:80(搜「iret」)
IDT 与 256 个槽位第21章 中断与异常text/27-ch21.txt:65(搜「中断描述符表」) · text/27-ch21.txt:66(搜「256 种」)
键盘插在 cmp/je 之间第21章 中断与异常text/27-ch21.txt:94(搜「两条指令」) · text/27-ch21.txt:94(搜「多久会被按下。例如,假设」)
异常与缺页第21章 中断与异常text/27-ch21.txt:108(搜「异常」) · text/27-ch21.txt:117(搜「Segmentation fault」) · text/27-ch21.txt:125(搜「页面故障」)
软件中断的动机第21章 中断与异常text/27-ch21.txt:130(搜「软件中断」) · text/27-ch21.txt:134(搜「无法直接调用」)
int 0x80 与寄存器表第21章 中断与异常text/27-ch21.txt:155(搜「int —— 调用中断处理程序」) · text/27-ch21.txt:168(搜「128」) · text/27-ch21.txt:176(搜「系统调用编号」)
int 0x80 版 helloWorld第21章 中断与异常text/27-ch21.txt:194(搜「清单 21-1」) · text/27-ch21.txt:197(搜「-e myStart」)
syscall 与 LSTAR第21章 中断与异常text/27-ch21.txt:238(搜「快速系统调用」) · text/27-ch21.txt:246(搜「LSTAR」) · text/27-ch21.txt:249(搜「不在栈内保存」)
syscall 寄存器表与编号第21章 中断与异常text/27-ch21.txt:259(搜「表 21-4」) · text/27-ch21.txt:264(搜「unistd_64.h」) · text/27-ch21.txt:272(搜「write 1」)
syscall 版 helloWorld第21章 中断与异常text/27-ch21.txt:278(搜「清单 21-2」) · text/27-ch21.txt:230(搜「as --gstabs」)

Footnotes

  1. 出处:「第21章 中断与异常」第 22–44 段(text/27-ch21.txt:24,搜「统来实现的,操作系统使用」;text/27-ch21.txt:41,搜「监管模式」;text/27-ch21.txt:43,搜「硬件特性」)。 ↩ ↩2 ↩3 ↩4

  2. 出处:「第21章 中断与异常」第 45–52 段(text/27-ch21.txt:45,搜「16 字节」;text/27-ch21.txt:46,搜「等于或小于」;text/27-ch21.txt:52,搜「只能通过门描述符」)。 ↩ ↩2 ↩3 ↩4

  3. 出处:「第21章 中断与异常」第 55–63 段(text/27-ch21.txt:56,搜「暂停执行当前指令流」;text/27-ch21.txt:59,搜「内核中」)。 ↩

  4. 出处:「第21章 中断与异常」第 72–84 段(四步响应,text/27-ch21.txt:73,搜「rflags 寄存器的内容压入栈」;iret 见第 80–82 段,text/27-ch21.txt:80,搜「简单 ret 指令是不管用的」)。 ↩ ↩2 ↩3 ↩4

  5. 出处:「第21章 中断与异常」第 64–71 段(text/27-ch21.txt:65,搜「中断描述符表」;text/27-ch21.txt:66,搜「256 种」;text/27-ch21.txt:67,搜「前 32 个」)。 ↩

  6. 出处:「第21章 中断与异常」第 85–87 段(text/27-ch21.txt:85,搜「没有统一的意见」)。 ↩ ↩2 ↩3 ↩4 ↩5

  7. 出处:「第21章 中断与异常」第 89–106 段(键盘例子,text/27-ch21.txt:94,搜「以下两条指令」;text/27-ch21.txt:94,搜「多久会被按下。例如,假设」;text/27-ch21.txt:105,搜「操作依赖于 cmp 指令」)。 ↩ ↩2

  8. 出处:「第21章 中断与异常」第 108–128 段(异常,text/27-ch21.txt:117,搜「Segmentation fault」;缺页见第 124–128 段,text/27-ch21.txt:125,搜「页面故障异常」)。 ↩ ↩2 ↩3

  9. 出处:「第21章 中断与异常」第 130–139 段(text/27-ch21.txt:133,搜「请求操作系统的服务」;text/27-ch21.txt:136,搜「切换到操作系统的较高特权级」)。 ↩

  10. 出处:「第21章 中断与异常」第 141–150 段(text/27-ch21.txt:143,搜「C 包装函数」;text/27-ch21.txt:146,搜「没有 C 运行时环境」)。 ↩ ↩2

  11. 出处:「第21章 中断与异常」第 152–191 段(int 0x80,text/27-ch21.txt:157,搜「int n」;128 号见第 168–170 段,text/27-ch21.txt:168,搜「128」;寄存器表见第 176 段,text/27-ch21.txt:177,搜「ebp」;编号表位置见第 179–180 段,text/27-ch21.txt:179,搜「unistd_32.h」)。 ↩ ↩2 ↩3 ↩4 ↩5

  12. 出处:「第21章 中断与异常」第 240–254 段(syscall,text/27-ch21.txt:243,搜「绕过了中断描述符表」;text/27-ch21.txt:246,搜「LSTAR」;text/27-ch21.txt:249,搜「不在栈内保存信息」;text/27-ch21.txt:253,搜「sysret」)。 ↩

  13. 出处:「第21章 中断与异常」第 255–275 段(寄存器约定,text/27-ch21.txt:262,搜「r10」;编号表见第 264–265 段,text/27-ch21.txt:264,搜「unistd_64.h」;write/exit 编号见第 270 段,text/27-ch21.txt:272,搜「write 1」)。 ↩ ↩2

  14. 出处:「第21章 中断与异常」第 276–311 段(清单 21-2,text/27-ch21.txt:285,搜「WRITE, 1」;text/27-ch21.txt:301,搜「syscall」;入口与链接见第 296–309 段,text/27-ch21.txt:296,搜「myStart」;text/27-ch21.txt:197,搜「ld -e myStart」)。 ↩ ↩2

  15. 出处:「第21章 中断与异常」第 138–139 段(text/27-ch21.txt:138,搜「超出了本书的范围」)。 ↩

On this page