计算机系统基础:异常、中断与 I/O 的完整脉络

摘要:程序并不会永远沿着 main 函数一路走到底------缺页、除零、键盘中断、进程切换,随时会把控制流带离正轨。这篇博文沿着 IA-32/Linux 这条具体的技术路线,把进程与上下文切换、异常与中断的硬件机制、Linux 的门与信号、系统调用、用户空间 I/O、总线互连、三种 I/O 控制方式与内核 I/O 软件串成一条完整的链路,并附上我自己的踩坑复盘。适合正在学计算机系统基础、想把零散知识点连成网的同学。

背景:控制流为什么会"出轨"

学到这里我意识到,前面所有章节其实都在回答同一个问题:CPU 按什么顺序执行指令。顺序取下一条指令、或者通过 CALL/RET/Jcc/JMP 跳转,这两种方式得到的控制流是"正常控制流"。但真实机器上,程序随时可能被打断:访问了不在内存的页面、执行了除以零的指令、键盘敲了一个字符、操作系统决定让别的进程上处理器跑一会儿。

这些意外打断形成的控制流,就是异常控制流(Exceptional Control of Flow,ECF)。它的来源可以分四类:CPU 内部的异常(缺页、越权、除零、溢出)、CPU 外部的中断(Ctrl-C、DMA 结束、打印缺纸)、操作系统层的进程上下文切换,以及应用层的进程间信号。这篇文章主要消化前两类------它们发生在硬件层,也是理解操作系统和 I/O 的地基。

一、进程:独占处理器的幻觉

程序是静态的,进程是动态的。 程序是按某种方式组织起来的代码和数据集合,躺在磁盘上不动;进程则是程序关于某个数据集合的一次运行活动。同一个 winword.exe 可以被启动多次,得到多个处理不同数据的进程------所以"一个程序对应多个进程"是完全正常的。Linux 内核把进程称为任务(task),每个进程用一个进程描述符 task_struct 描述,所有进程串在一条双向循环链表上。

进程这个抽象给了程序两样东西:一个独立的逻辑控制流 ,让程序员以为自己独占处理器;一个私有的虚拟地址空间,让程序员以为自己独占存储器。编程、编译、链接、加载的整套复杂度都被这两个幻觉大幅简化了。

图1:逻辑控制流与并发。 图中把单处理器的物理控制流按时间轴切成三行,分别属于进程 p1、p2、p3。p1 的地址序列是 A11A13、A11A14、A15~A16,在 A12 处被打断过一次;p2 在 A24 处被打断过一次;p3 从头到尾没被打断。关键在两点:第一,被打断不会改变一个进程的逻辑控制流,它还能回到断点处接着走,仿佛什么都没发生过;第二,并发不是并行------p1 和 p2 的控制流在时间上交错重叠,所以它们是并发的,而 p1 和 p3 在时间上完全不搭界,就不算并发。单核机器上"同时"运行的错觉,这张图一图道尽。

不同进程交替使用处理器,靠的是上下文切换(context switching)。

图2:进程与上下文切换。 右侧时间线里,shell 进程和 hello 进程交替执行,而每一次切换都必须经过内核态:用户态 → 内核态 → 另一个进程的用户态。这解释了一个容易被忽略的现象------命令行下运行 ./hello,感觉到的耗时长于真实执行时间,因为时间被调度、切换这些"看不见的工作"吃掉了。还有一个细节:处理器调度事件本身就会打断用户进程的正常执行,所以上下文切换本身也是异常控制流的一种,而且是操作系统层面最常见的一种。

进程的上下文分三层:用户级上下文(程序块、数据块、堆、用户栈)、系统级上下文(进程标识、现场信息、控制信息、内核栈)、寄存器上下文(各寄存器内容,即现场信息)。切换时,操作系统把换下进程的寄存器上下文保存到它系统级上下文的现场信息位置,再恢复换上进程的------这就是"保存现场、恢复现场"的本质。

二、进程的地址空间与存储映像

IA-32/Linux 下每个进程拥有独立的私有虚拟地址空间,而且布局完全相同:从低地址到高地址依次是只读代码段、读写数据段、运行时堆、共享库区域、用户栈,最顶上是内核虚存区。操作系统用 vm_area_struct 结构的链表来描述这些区域,task_struct 里的 mm 字段(mm_struct)指向这条链。

存储器映射 (memory mapping)就是把虚拟地址空间中的一个区域与磁盘上的一个对象建立关联:mmap(start, length, prot, flags, fd, offset) 把文件 fd 中偏移 offset 开始、长 length 的一段映射到虚拟空间 start 处。prot 决定权限(可读/可写/可执行),flags 决定映射对象的类型------这是理解图3的钥匙。

图3:Linux 虚拟地址空间中的区域。 图中从下往上看:只读代码段(.text)和已初始化数据段(.data)来自可执行文件这个普通文件,映射类型是 MAP_PRIVATE;未初始化数据(.bss)、运行时堆和用户栈对应的是 MAP_PRIVATE | MAP_ANON------匿名文件由内核创建,磁盘上根本没有这个文件,对应"请求零页";中间的共享库区域则是 MAP_SHARED。普通文件与匿名文件的分界、私有与共享的分界,这张图一次讲清。映射进内存的页,之后就在主存页框和磁盘交换分区(swap space)之间调进调出。

私有对象用写时拷贝(copy-on-write)来省内存。

图4:写时拷贝。 同一个可执行文件被两个进程加载时,内核只分配一套页框,两个进程的页表项都指向它,且全部标记为只读。只要大家都只读,内存里就永远只有一个副本。图中那道红色弯箭头是点睛之笔:当进程 2 试图写某个页时,硬件先报访问违例,内核查明原因是"写私有的写时拷贝页",于是分配一个新页框、把内容拷过去、改进程 2 的页表项------图右侧那个标着"私有的写时拷贝页的写操作"的新页框,就是这一系列动作的结果。这样既保证只读代码区只有一份,又让可写数据区占用最少的页框。

最后补一条加载链路:shell 解析命令行构造 argv 和 envp → fork() 创建子进程 → 子进程调用 execve() 启动加载器 → 加载器根据可执行文件的程序头表建立存储映像(注意:并不真正从磁盘拷贝代码和数据,只是改映射数据结构)→ PC 指向 _start,最终进入 main(argc, argv, envp)。

三、异常与中断:硬件层的意外事件

在硬件层面,打断程序执行的事件分两类:内部异常 发生在 CPU 内部、由指令执行引起(溢出、缺页、非法指令、除数为 0......);外部中断 发生在 CPU 外部、与当前指令无关(时钟、外设就绪、DMA 结束......),所以中断也叫异步异常。

异常按处理方式分三类,区分它们的关键是"断点在哪里":

  • 故障(fault) :由执行指令引起。可补救的(缺页、TLB 缺失)处理后回到发生故障的那条指令重新执行;不可补救的(除零、溢出、非法指令、保护违例)直接终止进程,即著名的段故障(segmentation fault)。
  • 自陷(trap) :预先安排的"地雷",处理完回到下一条指令继续。系统调用、断点、单步跟踪都属于这类,是一种自愿中断。
  • 终止(abort):硬故障(电源掉电、内存校验错),随机发生,连是哪条指令引起的都说不清,只能重启。

有一类故障值得单独展开:页故障。我印象最深的是那个三行代码的例子:

c 复制代码
int a[1000];
int x;
a[10]    = 1;   // 0x8049028
a[1000]  = 3;   // 0x8049fa0
a[10000] = 4;   // 0x8052c40

页大小 4KB,三条指令本身同处一个页面,取指不会缺页。数据访问时三种结局:a[10] 所在页第一次被访问、还不在内存,触发缺页,处理完回到这条 movl 重新执行,一切照旧------这是可恢复故障的标准剧本;a[1000] 已经越界,但地址 0x8049fa0 恰好还落在 a 所在的页面里,编译器又不检查数组边界,于是这次写入可能静默地把变量 x 改成了 3,什么异常都不发生;a[10000] 的地址偏离数组首址 40004 字节、跨了 9 个页面,很可能撞出可读写区域,这时页故障处理程序发 SIGSEGV 信号,进程被"段错误"终止。越界写入会不会崩,取决于它落在哪个页、那个页的权限,而不是语法对不对------这是我从这个例子里学到的最硬的一课。另外,地址转换由 MMU 硬件完成,所以页故障是硬件发现的;所有异常和中断事件都由硬件检测。

陷阱是系统调用的载体。 用户程序调用 open(filename, options),libc 里的封装函数最终执行 int $0x80 这条陷阱指令陷入内核,内核完成"找到或创建文件、准备好读写"的服务后,返回一个文件描述符。陷阱也是调试器的实现原理:IA-32 里 int 3 的机器码是 CCH,调试器在断点处塞一条 int 3,执行到它就暂停并转调试程序;把 TF 标志置 1 后每条指令都变成地雷,执行完就发 1 号"调试"异常,这就是单步跟踪。平时在 IDE 里按下的断点和单步按钮,底下全是这套机制。

图5:异常和中断的处理。 这张图回答了一个我以前没想过的问题:处理异常/中断的代码到底是什么身份?它不是一个进程,而是内核控制路径------代表当前进程在内核态执行的一段独立指令序列,比进程"轻"得多,上下文信息少得多。图左侧用户进程的正常控制流里插入了这么一段:事件发生 → 响应 → 具体处理 → 返回。而返回位置有三种,正好对应前面三类异常:回到当前指令(可恢复故障)、回到下条指令(陷阱)、终止(abort)。上下文切换和内核控制路径的区别也在这里:切换后 CPU 跑的是另一个进程,而内核控制路径处理完还是回到原进程。

外部中断 的响应流程与异常略有不同:外设通过中断请求信号线向 CPU 请求,CPU 每执行完一条指令就查看中断请求引脚,若有效则进入中断响应------保存断点和机器状态、关中断、从数据总线读取中断类型号、跳转对应的中断服务程序。Intel 把中断分成两类:通过 INTR 引脚请求的可屏蔽中断 (可用屏蔽字挡掉)和通过 NMI 引脚请求的不可屏蔽中断(电源掉电这类紧急硬件故障,一旦产生必须立即处理)。

四、IA-32 的向量中断机制

IA-32 一共支持 256 种异常和中断,每个都有唯一的中断类型号(向量号):0 号是除法错 #DE,2 号是 NMI,14 号是缺页 #PF。前 32 个(0~31)保留给 CPU 使用,32~255 由操作系统自行定义。

实地址模式 (Real Mode)是 80286 之后的处理器为兼容 8086 保留的模式:寻址空间 1MB,指令地址 = CS<<4 + IP,没有分页。这个模式下用中断向量表(IVT):表位于内存 0000H~03FFH,共 256 组表项,每组 4 字节,存放 CS:IP。

图6:实地址模式下的中断向量表。 查表方法简单粗暴:类型号 × 4 就是表项地址------除法错类型号 0,查 0x0 处;NMI 类型号 2,查 0x8 处。每个表项就是对应服务程序的入口地址 CS:IP,称为中断向量。开机后系统先在实模式下工作,BIOS 程序负责检测设备、建立这张表和相应的服务程序,BIOS 自己也靠执行 INT 指令干活(比如 int 0x19 把引导扇区装入内存)。一旦进入保护模式,BIOS 和这张表就都退场了。

保护模式 改用中断描述符表 (IDT):OS 内核中的一个表,256 个表项、每项 8 字节、共占 2KB,首地址放在 IDTR 寄存器中。每个表项是一个门描述符------中断门、陷阱门或任务门。

图7:IDT 与中断门描述符。 图中把 8 字节门描述符摊开了:两段偏移地址(A31~A16 和 A15~A0)拼出 32 位偏移;中间夹着段选择符,用来去 GDT 里找处理程序所在段的描述符;P 位 Linux 总置 1;DPL 是访问本段要求的最低特权级;TYPE 区分门的种类(1110 中断门、1111 陷阱门、0101 任务门)。DPL 字段的存在感很强:它专门防止恶意程序用 INT n 指令伪造异常混进内核------不是随便哪个用户程序都能通过 INT 指令敲开内核的门。

CPU 响应异常/中断的完整流程可以压缩成八步:确定类型号 i → 从 IDTR 指向的 IDT 取第 i 项 → 按段选择符从 GDT 取段描述符(Linux 下就是内核代码段,DPL=0、基址 0)→ 特权级检查(防伪造型异常的 13 号异常就埋在这一步)→ 若 CPL≠DPL 则切换到内核栈(读 TR 访问 TSS,装入内核栈的 SS:ESP,并保存原用户栈指针)→ 若是故障,把故障指令地址写回 CS:EIP → 依次把 EFLAGS、CS、EIP 压入内核栈,有硬件出错码也压入 → 把门描述符里的段选择符和偏移装入 CS:EIP。下一个时钟周期,CPU 就在执行异常/中断处理程序了。

返回用 IRET:从栈中弹出出错码(若有)、EIP、CS、EFLAGS;若发现响应前后特权级变了,再从内核栈弹出 SS:ESP 恢复用户栈;最后还有一步安全检查------若 DS/ES/FS/GS 里残留着 DPL=0 的段选择符就清零,防止用户程序捡起内核用过的段寄存器去摸内核地址空间。处处是防线。

五、Linux 的异常与中断处理

Linux 把三种门用出了明确的分工:陷阱门处理异常,中断门处理中断,任务门只给 8 号双重故障 #DF 用(处理缺页时又冒出非法指令或栈溢出,这种绝望场景只能换一套 TSS 从头再来)。所有中断门和陷阱门的段选择符都是 0x60,指向 GDT 中的内核代码段。

中断门和陷阱门的行为差异值得记住:穿过中断门 时 CPU 会清 EFLAGS 的 IF 位(关中断),所以外部中断不支持嵌套;穿过陷阱门 时 IF 不变,所以内部异常支持嵌套。实际部署的五种门里,有两个特地把 DPL 设成 3 来给用户态开方便之门:系统门 (into、bound、int $0x80 三条指令)和系统中断门(int 3 调试断点)。其余陷阱门 DPL=0,用户程序用 INT n 模拟异常只会吃 13 号异常。

异常处理程序是统一的三段式:准备阶段用汇编在内核栈保存通用寄存器现场;处理阶段进 C 函数(do_ 前缀命名,如 do_overflow),大部分的处理动作就是"保存出错码和类型号,然后向当前进程发一个信号";恢复阶段恢复现场、切回用户态、返回断点。为什么要用信号把处理推回用户态?因为内核态的异常处理拖得越长,嵌套异常的可能性越大,代价越高。

这个设计解释了一个让我困惑很久的现象:除法错为什么报"浮点异常"。试着跑这两段代码:

c 复制代码
int a = 0x80000000;
int b = a / -1;      // 输出 -2147483648,无事发生
int c = a / b;       // Floating point exception

第一段没炸,是因为编译器把"除以 -1"优化成了取负指令 neg,根本没走除法器;第二段走 IDIV,而 IDIV 不生成 OF 标志,溢出是靠 #DE(除法错,类型 0) 异常发现的------0x80000000 / -1 的结果超出 int 范围,触发 #DE,Linux 对 #DE 发的信号恰好叫 SIGFPE。"浮点异常"处理的是整数除法错,纯粹是信号名的历史包袱。判断异常到底是什么,得下到机器级看反汇编,C 源码会骗人。

I/O 中断这边的接线方式:每个能发中断的外设控制器占一条 IRQ 线,全部接到可编程中断控制器 PIC;PIC 对所有请求按优先级排队,有未被屏蔽的请求就向 CPU 的 INTR 引脚发信号;IRQi 关联的中断类型号约定为 32+i(比如 IRQ0 键盘中断就是类型 32)。

六、系统调用:穿过用户态与内核态的那扇门

前面所有机制,最终都收敛到一个日常动作:系统调用。Linux 提供几百种系统调用,覆盖进程控制、文件操作、内存管理、网络等;调用号就是跳转表的索引,表里放着各服务例程的首址。

调用约定非常朴素:参数全走寄存器------EAX 放调用号,EBX、ECX、EDX、ESI、EDI、EBP 依次放最多 6 个参数;返回值也是整数,非负为成功,负数为出错码。系统调用被封装成 libc 函数,封装函数的机器码结构高度统一:若干条传参指令 + 一条陷阱指令。以 write(1, "hello, world!\n", 14) 为例:

asm 复制代码
movl  $4, %eax      # 调用号 4(write)送 EAX
movl  $1, %ebx      # stdout 的文件描述符 1 送 EBX
movl  $string, %ecx # 字符串首址送 ECX
movl  $14, %edx     # 长度送 EDX
int   $0x80         # 陷入内核

从 printf() 到内核的完整链路是这张图:

图8:printf() 的执行过程。 左侧虚线框是用户空间:main 调 printf,printf 一路调下去最终调到 write 封装函数,封装函数执行 int $0x80;右侧虚线框是内核空间:system_call 是所有系统调用的总入口,它查调用号找到 sys_write 这个服务例程。红色实线箭头是调用方向,红色虚线箭头是层层返回。图底部那行问题------system_call 怎么知道该转 sys_write?------答案就写在图里:EAX 里的调用号。

int $0x80 本身的执行走的就是前面那套异常响应流程(128 号门是系统门、DPL=3,用户态可以用),要过一连串一致性和安全性检查,所以速度不快。从 Pentium II 起 Intel 补了 sysenter/sysexit 指令做用户态与内核态的快速切换------机制不变,纯粹是给这扇门提速。

七、用户空间 I/O:文件、描述符与缓冲

有了系统调用这扇门,I/O 子系统的全貌可以铺开了:

图9:I/O 子系统概述。 自顶向下四层软件夹着一层硬件:用户空间的 I/O 请求和运行时系统(printf/scanf 这些标准库住在这里);内核空间的与设备无关 I/O 软件、设备驱动程序、中断服务程序;最底下是 I/O 硬件。图底部那行红字是全篇的枢纽------从用户 I/O 软件进入内核 I/O 软件的唯一办法就是异常机制(系统调用/自陷)。前面五节铺垫的所有东西,到这里全部汇合。

用户程序提出 I/O 请求有两条路:高级语言的标准 I/O 库函数(fopen/fread/printf/scanf,移植性好),或操作系统直接提供的系统调用封装函数(Unix/Linux 的 open/read/write/close)。标准库有四个先天不足:不保证文件安全(无加解锁)、所有 I/O 同步串行、不提供读元数据的函数(文件大小、创建时间)、网络编程时容易缓冲区溢出。所以底层代码里常见直接用系统调用。

Linux 的哲学是一切皆文件 :所有 I/O 操作通过读写文件实现,网络、终端、磁盘统统抽象成字节序列。键盘和显示器构成终端,对应标准输入(fd=0)、标准输出(fd=1)、标准错误(fd=2)三个自动打开的文件;其余文件用 open/creat 显式打开,返回的文件描述符(fd)从此代表这个文件。配套的六个基本调用:read(从当前位置读 n 字节,到 EOF 返回 0)、write、lseek(定位)、stat/fstat(查元数据)、close。注意 read/write 是不带缓冲的------直接跟磁盘打交道;而且实际读写字节数少于请求量并不算错误,读终端、管道、套接字时这是常态。

标准库在系统调用之上加了一层流缓冲,这是 stdio.h 里 FILE 结构的核心机密:

图10:带缓冲 I/O 的实现(输入方向)。 FILE 结构里有三个关键字段:base 指向缓冲区起点,ptr 指向下一个可读位置,cnt 是未读字节数。第一次 getc 时 _fillbuf 一次性从文件读入 BUFSIZ=1024 字节填满缓冲,之后每次 getc 只是 cnt 减 1、ptr 加 1------直到 cnt 归零才再次触发系统调用。输出方向对称:putc 先往缓冲里攒,遇到换行符或攒满 1024 字节才由 _flushbuf 一次写回文件。系统调用的开销很大,缓冲的本质就是用一次系统调用摊薄 1024 次字符操作。

这层缓冲直接导致 stdout 和 stderr 行为不同:stderr 被定义为 _WRITE | _UNBUF(无缓冲),stdout 带缓冲。于是有了这个反直觉的输出顺序:

c 复制代码
fprintf(stdout, "hello ");
fprintf(stderr, "world!");
// 屏幕输出:world!hello

"hello " 还躺在 stdout 的缓冲里等换行或攒满,无缓冲的 "world!" 已经直达屏幕了。加上 ./hello > out.txt 2> err.txt 这类重定向组合,两个流可以各自去往不同的目的地------调试程序输出错乱时,先想想两个流的缓冲策略。

同一个文件复制功能,用 getc/putc 实现系统调用次数约为 n/512,用 read/write 每字节一对则是 2n 次------差三个数量级。实现方式没有对错,只有开销的权衡。

八、总线与系统互连:I/O 的物理通路

软件层次再漂亮,I/O 请求最终要变成电信号跑在物理通路上。Intel 体系里,北桥芯片把处理器--存储器总线分成两段:CPU 与北桥之间的处理器总线 (系统总线/前端总线),北桥与内存之间的存储器总线。总线由数据线、地址线、控制线构成(有些总线地址数据复用同一组线):数据线宽度决定一次传多少位,地址线宽度决定最大寻址空间,控制线跑时钟、复位、总线请求/允许、中断请求/回答、存储器读写、I/O 读写这些信号。

几个性能指标的计算值得亲手算一遍:总线带宽 B = W × F / N (W 宽度、F 时钟频率、N 每次传送所需时钟周期数)。注意工作频率和时钟频率不是一回事------FSB 用 quad pumped 技术每个时钟周期传 4 次数据,标称 1333MT/s 的实际时钟只有 333MHz,带宽 = 1333M × 8B = 10.5GB/s。突发传送 (burst)先发一个地址、后连传多个数据,后续地址自动增量,适合成块传输。定时方式分同步(时钟定步)、异步(握手)、半同步;传输方式上并行的位与位之间要同步、越宽越难做快,串行一位一位传反而能跑出高频率------所以现代总线的设计趋势是点对点、串行。

Core i7 时代北桥被搬进了 CPU 芯片,整个互连结构变了样:

图11:Core i7 系列处理器的互连结构。 CPU 内置三通道 DDR3 存储器控制器直连内存条(每条 64 位 @ 1066MT/s = 8.5GB/s);CPU 与 IOH 芯片(X58)之间走 QPI------基于包交换的串行点对点连接,4.8GT/s × 2B × 2 = 25.6GB/s;IOH 向下提供最多 36 条 PCIe 2.0 通道,再通过 DMI(2GB/s)连到南桥 ICH10,挂 SATA、USB、声卡、网卡这些外设。CPU--内存走最宽的路,I/O 设备按速度分层挂在不同的桥上------互连结构本身就是一张按性能分层的网。

I/O 总线的换代史很能说明问题:第一代 ISA/EISA 淘汰,第二代 PCI/AGP 逐渐淘汰,第三代 PCI-Express(串行)成为主流。两个 PCIe 设备之间是一条链路(link),含 1~32 条通路(lane),每条通路全双工、每字节编码成 10 位传输;PCIe 1.0 每条 lane 单向 2.5Gb/s,所以 ×n 链路带宽 ≈ 0.5GB/s × n,×16 就是 8GB/s。

CPU 与设备的接口是设备控制器(I/O 控制器/I/O 模块/I/O 接口):

图12:设备控制器的结构。 左半边是主机侧,通过系统总线的数据线、地址线、控制线接入,核心是三类 CPU 可访问的寄存器:数据缓冲寄存器、状态/控制寄存器,加上地址译码和 I/O 控制逻辑;右半边是设备侧,通过接口电缆与外设交换数据、状态、控制三种信号。CPU 能访问的这些寄存器统称 I/O 端口------对外设的全部控制都归结为:向控制寄存器发命令字、从状态寄存器读状态、跟数据缓冲寄存器交换数据。所有设备都能抽象成这个模型,这一层抽象把千奇百怪的外设收编成了"几个寄存器"。

I/O 端口的编号方式有两种:统一编址 (内存映射,端口和主存共用地址空间,RISC 常用)和独立编址(独立 I/O 地址空间,需要专门的 I/O 指令,Intel 用这种:in/ins 从端口取数到通用寄存器,out/outs 反向输出)。这些 I/O 指令和后面要遇到的开/关中断指令都是特权指令,只能在内核态执行------用户程序想碰设备,请走系统调用。

九、三种 I/O 控制方式:查询、中断与 DMA

一切就绪,看 CPU 到底怎么驾驭设备。以 printf("hello, world\n") 为例:字符串经系统调用进了内核的 sys_write,真正把字符送到打印机的实现有三种,一种比一种聪明。

**程序查询方式(轮询,Polling)**最直白:

c 复制代码
copy_string_to_kernel(strbuf, kernelbuf, n);  // 字符串复制进内核缓冲区
for (i = 0; i < n; i++) {
    while (printer_status != READY);          // 忙等:反复读状态寄存器
    *printer_data_port = kernelbuf[i];        // 向数据端口写一个字符
    *printer_control_port = START;            // 发"启动打印"命令
}

图13:程序查询 I/O 方式。 时间线上 CPU 在"启动"之后、设备"完成"之前反复打转,标注的就是那个形象的词------踏步 。此时 CPU 并没有停机,它在不知疲倦地执行 IN--TEST--JE 三条指令,学名忙等待(busy waiting)。查询期间可以一直独占式查询,也可以定时查询(但必须保证不丢数据)。特点三句话:简单、易控制、接口逻辑少;CPU 与外设完全串行、效率低;CPU 用 100% 的时间为 I/O 服务,查询开销极大。慢速设备凑合能用,高速设备就是灾难。

中断 I/O 方式把等待变成了并行:

图14:中断 I/O 方式。 对比图13,时间线彻底改观:sys_write 启动外设输出第一个字符后就把用户进程 P 阻塞、调度进程 Q 上处理器;外设慢悠悠工作的同时 CPU 在跑 Q 的程序;外设完成时发中断请求,硬件完成中断响应(执行中断隐指令),CPU 转入中断服务程序,输出下一个字符、再次启动外设,然后返回 Q 继续跑。启动之后到完成之前的那一大段时间,CPU 和外设是并行的------这是中断方式的全部意义。程序切换(中断响应)由硬件完成,切换到哪个进程由调度器决定。

中断响应要满足三个条件:CPU 处于开中断状态、一条指令刚刚执行完、至少有一个未被屏蔽的请求。注意它与异常的时点差异:异常在指令执行过程中 一旦发现立即处理,中断在指令执行结束时统一采样响应。中断控制器里的中断请求寄存器、屏蔽寄存器、判优线路和类型号形成线路各司其职,类型号(32+i)走数据总线送给 CPU------所以中断类型号必须上数据线。

中断服务程序(多重中断版)分三段:先行段 在关中断状态下保护现场和旧屏蔽字、设置新屏蔽字、然后开中断;本体段 做具体处理,此时处于开中断状态,可以被更高级的中断打断;结束段 再次关中断、恢复现场和旧屏蔽字、清除中断请求、开中断、中断返回。单重中断最简单:全程关中断,不需要屏蔽字。这里有个关键区分:中断响应优先级 由硬件排队线路决定(同时请求时先响应谁),中断处理优先级由屏蔽字动态设定(处理过程中谁可以打断我)------比如处理优先级 3# > 2# > 1#,意味着 1# 的屏蔽字屏蔽 2# 和 3#,2# 只对 3# 开放。

轮询和中断到底差多少?算一笔账:数据从主机到接口缓冲器要 1μs,从接口到设备要 1ms,指令周期 1μs。

图15:轮询 vs 中断。 上半张轮询方式:查询程序 10 条指令,数据传输率 1/(1000+5)μs ≈ 995 个/秒,主机占用率 100% ------CPU 全程陪跑。下半张中断方式:服务程序 30 条(第 20 条启动设备),传输率 1/(1000+1+20)μs ≈ 979 个/秒,主机占用率 (1+30)/(1000+1+20) = 3%。传输率几乎没损失,CPU 占用率从 100% 砍到 3%。图底部还回答了一个天然疑问:中断服务程序为什么反而比查询程序长?因为多了保存现场、换屏蔽字、开中断这些额外开销------用每次的固定开销,换整个等待期 freedom。这个交换在慢速设备上是暴赚。

但轮到高速设备,中断方式也会翻车------这就是引入 DMA(Direct Memory Access) 的原因。磁盘这类高速设备成批地直接和主存交换数据,传送过程由专门的 DMA 控制器接管总线,CPU 完全不参与数据搬运。以读一个磁盘扇区为例:

图16:DMA 第一步。 CPU 只干一件事:把传送方向(读)、传送数据个数、磁盘逻辑块号、主存起始地址这些参数写进 DMA 控制器------图中可以看到"传送数据个数被送到计数器中"------然后发"启动 DMA 传送"命令就撒手不管了。

图17:DMA 第二步。 青色的传输路径说明一切:磁盘控制器读出扇区数据,DMA 控制器控制总线把数据直接送进主存,全程绕开 CPU------此时 CPU 正在执行其他进程。每传一个数据计数器减 1,典型的"周期挪用法":设备每准备好一个数据,就挪用一个存储周期完成一次总线事务。第三步图中没放:计数器减到 0,DMA 控制器向 CPU 发"DMA 结束中断",CPU 再回来做校验等后处理。DMA 从不摆脱中断,只是把中断的频率从"每 16 字节一次"降到"每 8000 字节一次"。

收益算出来很惊人:500MHz 处理器、磁盘 4MB/s、缓存 16B。用中断方式,每秒 250k 次中断 × 500 时钟 = 125M 周期,占 CPU 的 25%(磁盘仅 5% 时间传送时占 1.25%);改用 DMA,每秒 500 次传送 × (1000 初始化 + 500 后处理) = 750k 周期,即使磁盘 100% 时间在传,CPU 占用率也只有 0.15%。两个数量级的差距。CPU 只在初始化和收尾时出场,数据高速公路上没有它的事。

十、内核空间 I/O 软件:read 的内核之旅

三种 I/O 方式之下,内核 I/O 软件分三层:设备无关软件层、设备驱动程序层、中断服务程序层,后两层与硬件直接相关。

设备无关层干的是"一视同仁"的活:给所有驱动程序定统一接口,新设备按接口规范写驱动就能即插即用;管理公共的内核缓冲区;提供与设备无关的错误报告框架(请求了不存在的设备、参数无效这类错误直接挡在这一层);统一逻辑块大小,让高层软件面对的是简化的抽象设备。设备驱动层则千差万别:驱动程序作者要熟知设备的寄存器布局、控制/状态位含义、通信协议,但无需了解机械特性;同一个设备用不同 I/O 方式,驱动的行为也不同------程序直接控制时用户进程干等不阻塞,中断和 DMA 方式下启动 I/O 后当前进程被阻塞、调度别的进程,等中断回来再继续。中断服务程序在最底层收尾:中断方式下从数据缓存器取数/存数并再次启动外设,DMA 方式下做数据校验这类后处理。

把前面所有层叠起来,看一次 read() 到底穿过了什么:

图18:read 的内核路径。 应用层的 read 经 int 0x80 触发系统调用后,先落进文件系统层的 sys_read,随后是 fget → vfs_read → generic_file_read → 页高速缓存查找(page_cache_read)→ mpage_readpage → submit_bio,一路向下的函数调用链清清楚楚;然后穿过通用块设备层(blk_partition_remap 做分区重映射)、I/O 调度层(make_request 排队调度),最后到达物理设备驱动层的 Device_access。图上那行标注很重要:这一趟在 Linux 内核中单向调用 20 次以上。一次看似简单的 read,背后是四个软件层的接力------分层的代价是调用深度,收益是每一层都可以独立演化、每一类设备都能被统一对待。

总结

回头看,这七章其实是一条主线的七段展开:CPU 的控制流总会遇到意外,而整个系统用层层机制把意外消化掉。硬件负责检测与响应(异常/中断的发现、断点保存、向量跳转),操作系统负责处理与恢复(门描述符、内核控制路径、信号、调度),进程这个抽象负责让用户程序对一切无感知(逻辑控制流不变、地址空间私有)。I/O 是这条主线上最大的"意外制造者"------所以 I/O 子系统沿用了同一套分层哲学:用户空间用文件和缓冲统一接口,穿越边界靠系统调用这扇唯一的门,内核空间用设备无关层、驱动层、中断服务层逐级下沉,最底层用查询、中断、DMA 三种方式按设备速度各就其位。

理解了这条链,再回头看很多"常识"会有新的质感:断点是 int 3 埋的地雷,段错误是 MMU 发现的越权访问被翻译成 SIGSEGV 信号,printf 的开销大头在缓冲刷新策略和系统调用次数,DMA 的价值是把 CPU 从数据搬运工的位置上彻底解放。计算机系统没有魔法,只有一层接一层的抽象,和每一层为了下一层付出的、可以被计算出来的代价。

相关推荐
doujiaozh19 小时前
我在 Win7 老电脑上从零手搓了一个操作系统内核:32位分页、中断接管与 Shell 终端
操作系统·汇编语言
打不了嗝 ᥬ᭄2 天前
计算机系统基础:程序的表示、转换与链接
操作系统·计算机系统基础
Coisinier3 天前
docker命令管理镜像及容器
linux·运维·nginx·docker·容器·操作系统
云计算练习生4 天前
什么是内存分配算法?首次适应、最佳适应、最坏适应
操作系统·内存·内存分配算法·内存算法
imDwAaY4 天前
进程之间如何“对话”?一篇讲清六种进程间通信方式
后端·操作系统
MinterFusion4 天前
如何在开放麒麟(openKylin)下安装GTK(v0.1.0)
linux·操作系统·软件安装·gtk·明德融创·openkylin·linux图形软件开发
Teecertlabs4 天前
【机密计算顶会解读】15:LDR——面向嵌入式TEE系统的高效安全Linux驱动
linux·操作系统·机密计算·可信执行环境
云计算练习生5 天前
什么是内存映射(mmap)?文件和内存之间的桥梁
操作系统·内存·内存映射
寻道模式5 天前
【运维心得】完整教程:绕过Win11限制让老机焕发新生
运维·操作系统·win11