首页 / 资讯中心 / 文章详情

深入理解异常控制流:从硬件异常到进程信号机制

深入理解异常控制流:从硬件异常到进程信号机制 ★ FEATURED ARTICLE
1. 为什么异常控制流值得单独拎出来讲如果你正在啃计算机系统这门课大概率已经翻过了前面几章——数据表示、汇编、处理器架构、存储器层次。到了第八章画风突然一转开始讲“异常控制流”。很多同学第一次看这个标题会懵前面都在讲硬件和指令怎么突然聊起“异常”和“控制流”了其实这一章解决的是一个非常根本的问题程序计数器除了按顺序加一或者跳转还能怎么变前面几章讲的跳转、调用、返回都是程序自己“主动”决定下一步去哪。但现实世界里程序从来不是活在真空中的——键盘敲了一下、网卡收到一个包、除零错误发生了、定时器到点了这些事件都会打断当前正在执行的指令流强制处理器把控制权交给一段特定的代码去处理。这就是异常控制流Exceptional Control Flow简称 ECF要讲的东西。我个人的理解是这一章是整门课从“单机程序”走向“系统程序”的分水岭。前面你写的代码基本是自娱自乐从第八章开始你才真正开始理解操作系统、进程、信号这些东西是怎么在硬件和软件的夹缝里运转起来的。8.2 到 8.4 这三节恰好覆盖了异常控制流的三个层次硬件层的异常和中断、操作系统层的进程上下文切换、以及软件层的信号机制。这三层是层层递进的关系缺了任何一层后面的并发、虚拟内存、I/O 都会变成空中楼阁。这篇文章我打算按自己的学习笔记重新梳理一遍不照搬教材的叙述顺序而是按“为什么需要—怎么实现—实际怎么用”的逻辑来组织。适合正在学这门课、准备考试或者想真正搞懂进程和信号的同学。我会尽量把抽象概念落到具体场景里该补的底层细节补上该提醒的坑也一并说了。2. 异常与中断硬件层面的控制流劫持2.1 异常的本质是一张跳转表教材里对异常的定义是处理器状态中发生的变化导致控制流转移到操作系统内核中的某段代码。这句话读起来很绕我用一个更直白的说法处理器在执行指令的过程中会维护一个内部状态。当某个事件发生时这个状态里会出现一个非零的“异常号”。处理器一看哦有异常号了那就别按原来的顺序执行了去查一张表表里第 N 项写着处理这个异常的代码地址跳过去执行。这张表叫异常表本质上就是一个函数指针数组。异常号就是数组下标。处理器在检测到异常后会做几件事保存当前进程的部分状态比如返回地址、标志寄存器根据异常号查表跳到对应的处理程序。处理程序执行完之后根据异常类型的不同可能返回到原指令、返回到下一条指令或者直接终止当前进程。这里有个容易混淆的点异常和过程调用长得很像但本质完全不同。过程调用是你写代码时主动 call 的返回地址是压在你自己的栈上异常是硬件强制触发的返回地址和状态保存在内核栈或者特定的寄存器里。过程调用是同步的、可预测的异常是异步的、随时可能发生的。你可以把异常理解成“硬件级别的强制 call”而且被调用的那段代码运行在更高的特权级上。2.2 四类异常中断、陷阱、故障、终止教材把异常分成四类这个分类非常重要考试必考实际理解系统行为也离不开它。我用一张表来对比类别触发原因同步/异步返回行为典型例子中断来自外部 I/O 设备的信号异步返回下一条指令定时器中断、键盘输入陷阱程序主动执行的特殊指令同步返回下一条指令系统调用、断点调试故障指令执行过程中出错但可能可恢复同步可能返回当前指令缺页异常、除零错误终止不可恢复的致命错误同步不返回硬件校验错误中断是唯一异步的异常。什么叫异步就是它跟当前执行的指令没有任何关系随时可能插进来。定时器中断就是典型操作系统靠它来定期夺回 CPU 控制权实现进程调度。没有定时器中断一个死循环程序就能把整个系统卡死。陷阱是程序自己故意触发的最典型的就是系统调用。你写read()、write()、fork()这些函数底层其实是一条syscall或者int 0x80指令故意制造一个异常让控制权从用户态切到内核态。所以系统调用不是普通的函数调用它是一次特权级的跨越。故障最有意思因为它“可能可恢复”。缺页异常就是最好的例子你访问一个虚拟地址页表里没有对应的物理页硬件触发缺页异常操作系统把磁盘上的页加载到内存更新页表然后重新执行刚才那条指令。对程序来说这条指令好像什么都没发生一样但实际上中间已经经历了一次完整的异常处理。除零错误也是故障但通常不可恢复操作系统会直接给进程发信号。终止就简单了硬件出了没法挽救的问题直接终止当前进程控制权交给内核的终止处理程序。注意故障和陷阱的返回行为容易搞混。陷阱返回下一条指令因为陷阱指令本身已经执行完了故障返回当前指令因为出错的指令需要重新执行。这个区别在理解缺页处理和系统调用时非常关键。2.3 系统调用用户态和内核态的桥梁系统调用是陷阱类异常最重要的应用。很多同学学到这里会问为什么不能直接调用内核函数答案很简单权限不够。处理器有特权级的概念用户态代码运行在低特权级不能直接访问硬件、不能修改页表、不能关中断。想干这些事必须通过系统调用“请求”内核帮忙。系统调用的过程大致是这样的用户程序把系统调用号放到某个寄存器比如 x86-64 的%rax把参数放到其他寄存器然后执行syscall指令。处理器切换到内核态根据调用号查系统调用表执行对应的内核函数。执行完之后通过sysret指令返回用户态返回值放在%rax里。这里有个实操层面的细节系统调用的参数传递和普通函数调用不一样。普通函数调用用栈传参系统调用用寄存器传参。因为切换特权级的时候栈也会切换用栈传参太麻烦。x86-64 下系统调用的前六个参数分别放在%rdi、%rsi、%rdx、%r10、%r8、%r9返回值放在%rax。如果你写汇编或者看反汇编代码看到这几个寄存器被赋值然后跟一条syscall基本就是在准备系统调用。我当初学的时候踩过一个坑以为printf是系统调用。其实不是printf是 C 标准库函数它内部会调用write系统调用但printf本身运行在用户态做了大量格式化处理。真正跨越特权级的只有write那一下。理解这一点对后面分析程序性能、理解缓冲区机制都有帮助。3. 进程异常控制流在操作系统层的体现3.1 进程是什么一个正在执行的程序的抽象到了 8.2 节的后半部分教材引入了进程的概念。我给一个自己的定义进程是操作系统对一个正在运行的程序的抽象它让程序感觉自己独占 CPU 和内存。这个“感觉”非常重要因为实际上多个进程在交替执行每个进程只能拿到一小段 CPU 时间但操作系统通过上下文切换和虚拟内存让每个进程都以为自己一直在跑。进程给程序提供了两个关键幻觉独占处理器和独占内存。独占处理器靠上下文切换实现独占内存靠虚拟内存实现。这两个机制都建立在异常控制流的基础上——定时器中断触发调度缺页异常触发页面加载。所以你看异常控制流不是孤立的知识点它是整个操作系统运转的底层支撑。进程的另一个重要特性是并发流。两个进程的指令流在时间上重叠就叫并发。注意并发和并行的区别并发是宏观上同时、微观上交替并行是真正的同时执行需要多核硬件支持。教材在 8.2 节末尾提到这个概念是为后面讲并发编程做铺垫。3.2 上下文切换进程切换的完整过程上下文切换是操作系统最核心的机制之一也是性能分析中经常被提到的开销来源。它的过程可以拆成几步保存当前进程的上下文包括通用寄存器、程序计数器、栈指针、标志寄存器等。这些信息保存在进程控制块PCB或者内核栈里。选择新进程调度器根据某种策略比如时间片轮转、优先级从就绪队列里挑一个进程。恢复新进程的上下文把之前保存的寄存器值加载回处理器。切换地址空间更新页表基址寄存器让新进程看到自己的虚拟内存。跳转到新进程的执行位置通常是之前被中断的地方。整个过程发生在内核态由定时器中断或者系统调用触发。上下文切换的开销主要来自两方面直接开销是保存和恢复寄存器、切换页表间接开销是缓存和 TLB 被污染新进程开始执行时命中率下降。所以高性能服务器编程里减少上下文切换是一个重要的优化方向比如用线程池代替频繁创建销毁线程用 I/O 多路复用代替阻塞 I/O。实操心得如果你在 Linux 下用time命令测程序运行时间real和user差距很大往往说明程序在等 I/O 或者被频繁调度。vmstat命令里的cs列就是上下文切换次数可以作为系统负载的一个参考指标。3.3 进程控制fork、execve、waitpid教材在 8.3 和 8.4 节详细讲了进程控制相关的系统调用这是本章最实操的部分。我按自己的理解重新组织一下。fork 函数是 Unix 进程创建的基石。调用一次返回两次父进程里返回子进程的 PID子进程里返回 0。这个“一次调用两次返回”的特性是很多初学者的噩梦但理解之后会觉得非常优雅。fork 创建的子进程几乎是父进程的完整副本——代码段、数据段、堆、栈、文件描述符表都复制一份。但现代操作系统用写时复制Copy-On-Write优化只有真正写入的时候才复制物理页所以 fork 本身的开销并不大。execve 函数负责加载并运行一个新程序。它接收路径名、参数数组和环境变量数组把当前进程的代码段、数据段、堆栈全部替换成新程序的内容。注意 execve 调用成功后不返回因为原来的程序已经被替换掉了只有出错时才返回 -1。fork 和 execve 通常配合使用先 fork 一个子进程然后在子进程里 execve 加载新程序这样父进程可以继续做自己的事。waitpid 函数让父进程等待子进程结束并回收子进程的资源。如果父进程不调用 waitpid子进程结束后会变成僵尸进程占用内核里的进程表项。如果父进程先于子进程结束子进程会被 init 进程收养不会变成孤儿。这里有个细节waitpid 的status参数不是简单的退出码而是一个编码后的整数需要用WIFEXITED、WEXITSTATUS这些宏来解析。函数作用关键返回值常见坑fork创建子进程父进程返回子 PID子进程返回 0忘记判断返回值父子进程执行相同逻辑execve加载新程序成功不返回失败返回 -1参数数组必须以 NULL 结尾waitpid等待子进程返回子进程 PID失败返回 -1不处理 status 宏直接当退出码用exit终止进程不返回和 _exit 的区别exit 会刷新缓冲区我当初写过一个 shell 的简化版用 fork execve waitpid 实现命令执行。踩的最大的坑是文件描述符继承fork 之后子进程会继承父进程打开的所有文件描述符如果父进程打开了某个文件没关子进程 execve 之后那个文件还是开着的。这在写 shell 重定向的时候特别容易出问题必须在 execve 之前把不需要的描述符关掉。4. 信号软件层面的异常控制流4.1 信号是什么进程间的一种异步通知机制信号是 8.4 节的核心内容也是异常控制流在软件层面最直接的体现。我给一个通俗的定义信号是操作系统发给进程的一种通知告诉它发生了某件事。比如你按 CtrlC内核会给前台进程发 SIGINT子进程结束内核会给父进程发 SIGCHLD除零错误内核会给进程发 SIGFPE。信号和异常的关系很密切很多信号就是由硬件异常触发的。缺页异常不会直接变成信号但除零错误会变成 SIGFPE非法内存访问会变成 SIGSEGV。操作系统在异常处理程序里根据异常类型决定是修复问题比如缺页还是给进程发信号比如段错误。信号的生命周期分三个阶段发送、接收、处理。发送是内核或者某个进程调用kill函数把信号标记到目标进程的待处理信号集合里。接收是目标进程被调度执行时内核检查待处理信号集合发现有信号就触发处理。处理有三种方式忽略、默认动作、自定义处理函数。默认动作包括终止进程、终止并转储核心、暂停进程、继续进程等。注意信号不排队。如果同一个信号在阻塞期间产生了多次待处理集合里只会记录一次。这意味着信号会丢失不能用来做精确计数。需要计数的话得用其他机制比如管道或者共享内存。4.2 信号的发送与接收kill、alarm、signal、sigaction发送信号最常用的函数是kill虽然名字叫 kill但它可以发送任何信号不一定是终止信号。alarm函数让内核在指定秒数后给当前进程发 SIGALRM常用来实现超时控制。接收信号需要先注册处理函数老式 API 是signal新式 API 是sigaction。sigaction比signal更可控因为它允许你指定信号处理期间的阻塞信号集合还能设置各种标志位。比如SA_RESTART标志可以让被信号中断的系统调用自动重启避免处理完信号后系统调用返回 EINTR 错误。我个人的建议是新代码一律用 sigaction别用 signal。signal 在不同 Unix 变体上的行为不一致可移植性差。信号处理函数运行在什么上下文答案是运行在用户态但由内核在从内核态返回用户态之前调用。具体过程是内核处理完中断或异常准备返回用户态发现有待处理信号且没有被阻塞就把返回地址改成信号处理函数的地址。信号处理函数执行完后通过sigreturn系统调用回到内核内核再恢复原来的上下文继续执行被中断的指令。这个机制导致一个重要的限制信号处理函数必须可重入。因为信号可能在任意时刻打断主程序如果处理函数调用了不可重入的函数比如 malloc、printf可能破坏主程序的数据结构。安全的做法是在处理函数里只设置一个全局标志位主程序循环检查这个标志位再决定做什么。4.3 信号阻塞与待处理状态sigprocmask 和 sigpending每个进程都有一个信号掩码表示当前阻塞了哪些信号。被阻塞的信号不会立即被处理而是进入待处理集合等解除阻塞后再处理。sigprocmask用来修改信号掩码sigpending用来查看当前有哪些待处理信号。这个机制在实际编程中很有用。比如你正在更新一个全局数据结构不想被信号打断就可以先阻塞相关信号更新完再解除阻塞。但要注意阻塞信号不等于忽略信号。忽略是直接丢弃阻塞是暂缓处理。如果信号在阻塞期间产生了多次解除阻塞后只会处理一次。还有一个容易忽略的点fork 和 execve 对信号状态的影响。fork 之后子进程继承父进程的信号掩码和待处理集合但待处理集合会被清空。execve 之后被自定义处理函数处理的信号会恢复默认处理被忽略的信号继续保持忽略被阻塞的信号继续保持阻塞。这些细节在写多进程程序时经常导致诡异 bug需要特别留意。操作函数作用注意事项发送信号kill给指定进程发信号需要权限只能给同用户或子进程发设置定时器alarm指定秒数后发 SIGALRM精度是秒级重复调用会覆盖注册处理函数sigaction设置信号处理方式比 signal 更可控推荐使用阻塞信号sigprocmask修改信号掩码阻塞不等于忽略信号会待处理查看待处理sigpending返回待处理信号集合需要在阻塞状态下调用才有意义4.4 信号处理的实际应用优雅退出和子进程回收信号在实际项目里最常见的两个用途是优雅退出和子进程回收。优雅退出的典型场景是服务器程序。你按 CtrlC 发送 SIGINT如果程序直接终止可能正在处理的请求就丢了临时文件也没清理。正确的做法是注册 SIGINT 处理函数在里面设置一个running 0的标志位主循环检查到这个标志位后先完成当前任务、关闭监听套接字、清理资源然后再退出。这样既响应了退出请求又保证了数据一致性。子进程回收是另一个经典场景。父进程 fork 出子进程后如果不管它子进程结束后会变成僵尸。正确的做法是注册 SIGCHLD 处理函数在函数里调用waitpid回收子进程。但这里有个坑SIGCHLD 不排队如果多个子进程同时结束可能只收到一个 SIGCHLD。所以处理函数里应该用循环调用waitpid(-1, status, WNOHANG)直到没有更多已结束的子进程为止。void sigchld_handler(int sig) { int olderrno errno; while (waitpid(-1, NULL, WNOHANG) 0) { // 回收所有已结束的子进程 } errno olderrno; }这段代码里保存和恢复errno是一个好习惯。因为信号处理函数可能打断正在进行的系统调用而系统调用失败时会设置errno。如果处理函数里调用了其他可能修改errno的函数返回后主程序看到的errno就不对了。实操心得写信号处理函数时尽量只做最简单的事——设置标志位、写一个字节到管道、调用异步信号安全的函数。复杂的逻辑放到主循环里做。异步信号安全的函数列表在 man 手册的signal-safety页面里有常见的write、read、open、close、fork、waitpid都是安全的但printf、malloc、free不是。5. 常见问题与排查技巧实录5.1 僵尸进程和孤儿进程怎么发现、怎么处理僵尸进程是已经终止但还没被父进程回收的进程。用ps命令看到状态是Z的就是僵尸。僵尸进程本身不占 CPU 和内存但占着进程表项数量多了会导致系统无法创建新进程。排查方法是找到僵尸进程的父进程看它为什么没调用 waitpid。处理方法是给父进程发信号让它回收或者直接终止父进程让 init 接管。孤儿进程是父进程先于子进程结束的进程。孤儿进程会被 init 进程收养init 会负责回收它们所以孤儿进程通常不是问题。但如果你在写守护进程需要主动 fork 两次来避免僵尸这个技巧在写服务端程序时很常用。5.2 信号丢失和竞态条件为什么我的处理函数没被调用信号丢失的根源是信号不排队。如果同一个信号在阻塞期间产生了多次待处理集合只记录一次。解决办法是用其他机制来计数比如管道、共享内存、或者signalfdLinux 特有。另一个常见原因是竞态条件信号在注册处理函数之前就到达了导致默认动作被执行。解决办法是在注册处理函数之前先阻塞信号注册完再解除阻塞。sigset_t mask, prev; sigemptyset(mask); sigaddset(mask, SIGCHLD); sigprocmask(SIG_BLOCK, mask, prev); // 先阻塞 // 注册处理函数 sigprocmask(SIG_SETMASK, prev, NULL); // 再解除这个模式叫信号阻塞保护在需要确保信号处理函数注册和某些初始化操作之间没有竞态时非常有用。5.3 系统调用被信号中断EINTR 错误的处理慢速系统调用比如read、write、accept在执行过程中如果被信号打断会返回 -1 并设置errno为EINTR。这不是真正的错误只是系统调用没完成。处理方式有两种一是用SA_RESTART标志让内核自动重启系统调用二是在代码里判断errno EINTR然后重新调用。while ((n read(fd, buf, size)) 0) { if (errno ! EINTR) { // 真正的错误 perror(read); break; } // 被信号打断重试 }我个人的经验是网络编程里最好显式处理 EINTR不要依赖SA_RESTART。因为有些系统调用即使设置了SA_RESTART也不会自动重启比如select、poll、epoll_wait。显式处理更可控。5.4 常见问题速查表问题现象可能原因排查方法解决方案进程状态为 Z父进程未调用 waitpidps aux查看状态父进程注册 SIGCHLD 处理函数信号处理函数没执行信号被阻塞或丢失sigpending查看待处理检查信号掩码避免依赖信号计数read 返回 -1 且 errno 为 EINTR系统调用被信号中断打印 errno重试或设置 SA_RESTARTfork 后子进程行为异常文件描述符继承ls -l /proc/PID/fdexecve 前关闭不需要的描述符信号处理函数导致崩溃调用了非异步信号安全函数检查函数调用只设置标志位复杂逻辑放主循环6. 从异常控制流到并发编程的衔接学完 8.2 到 8.4你应该能理解一个核心观点异常控制流是操作系统实现并发的基础。没有定时器中断就没有进程调度没有缺页异常就没有虚拟内存没有信号就没有异步事件通知。后面章节讲的线程、并发、同步本质上都是在异常控制流提供的机制上做文章。我个人的学习体会是这一章的内容不能只靠看书一定要动手写代码。写一个简单的 shell用 fork execve waitpid 执行命令写一个信号处理程序实现优雅退出用 alarm 和 SIGALRM 实现超时控制。这些练习做下来对进程和信号的理解会深刻很多。光看教材上的代码示例很容易产生“我懂了”的错觉一动手就发现到处都是坑。最后分享一个我常用的调试技巧用strace命令跟踪程序的系统调用和信号。strace -f -e tracesignal ./your_program可以看到所有信号的发送和接收排查信号相关问题时非常有用。-f选项会跟踪子进程fork 之后的行为也能看到。这个工具在分析进程行为和系统调用时几乎是必备的。
阅读完成 · 觉得有帮助?
咨询建站