第8章:异常控制流(Exceptional Control Flow)
一、导读
异常控制流(ECF, Exceptional Control Flow)是计算机系统的基本机制,它允许程序在执行过程中响应各种事件。从最简单的函数调用到复杂的操作系统多任务处理,ECF无处不在。
学习目标
通过本章的学习,你将掌握以下核心能力:
理解异常控制流的概念:了解控制流的各种形式和层次
掌握异常的分类:中断、陷阱、故障和终止的区别
理解进程的概念:进程是什么,如何创建和管理进程
掌握进程控制的基本操作:fork、exec、wait等系统调用
理解进程的地址空间:私有地址空间的概念和含义
掌握进程间通信的基本方法:信号、管道等机制
能够编写健壮的多进程程序:正确处理竞态条件和信号
核心问题
- 什么是异常控制流?它与正常的程序执行有什么不同?
- 操作系统如何利用异常机制实现多任务?
- 进程和程序有什么区别?
- 如何创建子进程?子进程和父进程有什么关系?
- 进程之间如何通信和同步?
- 信号是什么?如何正确处理信号?
重要性
异常控制流是操作系统的基础,理解它对于以下方面至关重要:
- 理解操作系统的基本工作原理
- 编写可靠的系统级程序
- 处理并发和竞态条件
- 实现进程间通信
- 诊断和调试系统级程序错误
- 理解shell、守护进程等系统程序的工作原理
二、核心概念详解
2.1 异常(Exceptions)
异常是异常控制流的最基本形式。它发生在指令执行过程中,当处理器检测到某种事件时,控制流突然发生改变。
异常的分类
异常可以分为四类,每种类型有不同的来源和处理方式:
1. 中断(Interrupt)
中断是来自I/O设备的外部信号,异步发生(与当前执行的指令无关)。
处理器执行指令流
│
├── 指令1
├── 指令2
├── 指令3
│ ← 此时I/O设备发出中断信号
├── 指令4 ← 中断处理完成后继续
└── ...处理过程:
I/O设备向处理器发送中断信号
处理器在当前指令完成后检测到中断
保存当前状态(程序计数器、寄存器等)
跳转到中断处理程序
处理完成后恢复状态,继续执行
2. 陷阱(Trap)
陷阱是有意的异常,通常由程序执行 syscall 指令触发,用于从用户态切换到内核态。
// 用户程序中的系统调用
int fd = open("file.txt", O_RDONLY);
// 编译后生成 trap 指令,陷入内核执行 open 系统调用陷阱的典型用途:
- 系统调用(read、write、fork等)
- 调试断点(int 3)
3. 故障(Fault)
故障是由指令执行产生的错误引起的,可以被修正并重新执行。
常见故障类型:
- 缺页故障(Page Fault):访问的虚拟页面不在物理内存中
- 除零错误:在修正前需要检查(某些架构上可修正)
- 保护故障:违反内存保护规则
处理过程:
处理器检测到故障
将控制权转移到故障处理程序
处理程序尝试修正条件
重新执行导致故障的指令
如果无法修正,处理程序终止进程
4. 终止(Abort)
终止是不可恢复的致命错误,通常导致进程被终止。
常见终止类型:
- 硬件故障(如内存奇偶校验错误)
- 非法指令
- 看门狗超时
异常处理的硬件机制
异常处理需要硬件和操作系统的协作:
┌─────────────────────────────────────┐
│ 异常/中断发生 │
├─────────────────────────────────────┤
│ 1. 控制单元检测到异常/中断 │
│ 2. 保存当前程序计数器(返回地址) │
│ 3. 更新程序计数器为异常处理程序地址 │
│ 4. 跳转到异常处理程序 │
│ 5. 处理程序执行 │
│ 6. 执行 iret 指令返回 │
│ 7. 恢复程序计数器,继续执行 │
└─────────────────────────────────────┘异常表(Exception Table)
操作系统在初始化时设置异常表,将每种异常类型映射到对应的处理程序:
异常号 → 处理程序地址
0 → 除零处理程序
13 → 通用保护故障处理程序
14 → 缺页故障处理程序
...2.2 进程(Processes)
进程是操作系统中最重要的抽象之一。它为程序提供了一个假象:似乎程序独占地使用整个处理器和内存。
进程的定义
进程是正在执行的一个程序实例。它包含以下要素:
- 程序的代码和数据
- 处理器的状态(寄存器、程序计数器等)
- 堆(动态分配的内存)
- 栈(函数调用的活动记录)
- 内核数据结构中的信息(打开的文件、信号状态等)
进程的逻辑控制流
进程的控制流是一系列有序的指令地址序列:
时间 →
进程A: p1 → p2 → p3 → p4 → p5 → p6
进程B: q1 → q2 → q3 → q4
进程C: r1 → r2 → r3 → r4 → r5每个进程都有自己的逻辑控制流,在任意时刻只有一个指令在执行。
进程的地址空间
每个进程都有自己的私有地址空间,进程间的地址空间是隔离的:
进程A的地址空间 进程B的地址空间
┌──────────────┐ ┌──────────────┐
│ 栈 │ │ 栈 │
│ ↓ │ │ ↓ │
│ │ │ │
│ ↑ │ │ ↑ │
│ 堆 │ │ 堆 │
├──────────────┤ ├──────────────┤
│ 共享库 │ │ 共享库 │
├──────────────┤ ├──────────────┤
│ .bss │ │ .bss │
├──────────────┤ ├──────────────┤
│ .data │ │ .data │
├──────────────┤ ├──────────────┤
│ .text │ │ .text │
└──────────────┘ └──────────────┘
0x00400000 0x00400000
(相同的虚拟地址,不同的物理映射)2.3 进程控制
操作系统通过一系列系统调用来管理进程的生命周期。
fork:创建子进程
fork 系统调用创建一个新的进程(子进程),它是调用进程(父进程)的几乎完全相同的副本。
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
// fork失败
perror("fork failed");
exit(1);
} else if (pid == 0) {
// 子进程
printf("Hello from child (pid=%d)\n", getpid());
exit(0);
} else {
// 父进程
printf("Hello from parent (pid=%d, child=%d)\n", getpid(), pid);
}
return 0;
}fork的关键特性:
调用一次,返回两次:在父进程中返回子进程的PID,在子进程中返回0
子进程是父进程的副本:包括代码、数据、堆、栈、打开的文件等
独立的地址空间:父子进程有各自的地址空间,修改互不影响
共享打开的文件:子进程继承父进程的所有打开文件描述符
fork() 调用前:
父进程(PID=1000)
├── 代码段
├── 数据段:x = 5
├── 堆
└── 栈
fork() 调用后:
父进程(PID=1000) 子进程(PID=1001)
├── 代码段(相同) ├── 代码段(相同)
├── 数据段:x = 5 ├── 数据段:x = 5
├── 堆(独立副本) ├── 堆(独立副本)
└── 栈 └── 栈
└── fork返回1001 └── fork返回0写时复制(Copy-on-Write)
现代操作系统使用写时复制技术优化 fork 的性能:
fork 时,子进程的页面表指向父进程相同的物理页面
所有页面标记为只读
当任一进程尝试写入时,触发缺页故障
操作系统捕获故障,为该进程创建私有副本
更新页面表指向新的物理页面
fork后(写时复制):
父进程页面表 ──┐
├──→ 物理页面(只读)
子进程页面表 ──┘
父进程写入后:
父进程页面表 ──→ 物理页面A(可写,父进程私有副本)
子进程页面表 ──→ 物理页面B(可写,子进程原有页面)exec:加载新程序
exec 系列函数用新程序替换当前进程的映像:
#include <unistd.h>
// 最常用的形式
int execlp(const char *file, const char *arg0, ..., NULL);
int execvp(const char *file, char *const argv[]);
// 示例
int main() {
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
// 如果execvp成功,这行代码永远不会执行
perror("execvp failed");
return 1;
}exec的关键特性:
替换当前进程:不创建新进程,而是替换当前进程的代码和数据
保持PID不变:进程ID、父进程ID等保持不变
继承某些属性:打开的文件描述符(除非设置了CLOEXEC)、信号掩码等
返回值:成功时不返回,失败时返回-1
exec前: exec后:
┌──────────────┐ ┌──────────────┐
│ 父进程代码 │ │ ls程序代码 │
│ 父进程数据 │ ──exec──→│ ls程序数据 │
│ 父进程堆栈 │ │ ls堆栈 │
└──────────────┘ └──────────────┘
PID = 1000 PID = 1000(不变)wait/waitpid:等待子进程
wait 和 waitpid 用于父进程等待子进程终止:
#include <sys/wait.h>
// 等待任意子进程
pid_t wait(int *status);
// 等待特定子进程
pid_t waitpid(pid_t pid, int *status, int options);waitpid的参数:
| pid值 | 含义 | ||
|---|---|---|---|
| pid > 0 | 等待PID等于pid的子进程 | ||
| pid = -1 | 等待任意子进程(等同于wait) | ||
| pid = 0 | 等待同进程组的任意子进程 | ||
| pid < -1 | 等待进程组ID等于 | pid | 的任意子进程 |
waitpid的选项:
| 选项 | 含义 |
|---|---|
| WNOHANG | 如果没有子进程终止,立即返回0 |
| WUNTRACED | 报告停止的子进程 |
| WCONTINUED | 报告继续的子进程 |
检查终止状态:
int status;
pid_t pid = waitpid(-1, &status, 0);
if (WIFEXITED(status)) {
// 正常退出
int exit_code = WEXITSTATUS(status);
printf("Exited with code %d\n", exit_code);
} else if (WIFSIGNALED(status)) {
// 被信号终止
int sig = WTERMSIG(status);
printf("Killed by signal %d\n", sig);
} else if (WIFSTOPPED(status)) {
// 被信号停止
int sig = WSTOPSIG(status);
printf("Stopped by signal %d\n", sig);
}2.4 信号(Signals)
信号是Unix系统中进程间通信的最古老机制之一。它用于通知进程某个事件已经发生。
信号的来源
信号可以由以下事件产生:
硬件事件:除零、非法内存访问等
用户输入:按 Ctrl+C(SIGINT)、Ctrl+Z(SIGTSTP)
系统调用:kill()、raise()
内核事件:子进程终止(SIGCHLD)、alarm到期(SIGALRM)
其他进程:通过kill命令发送
常见信号类型
| 信号 | 编号 | 默认动作 | 说明 |
|---|---|---|---|
| SIGINT | 2 | 终止 | 中断(Ctrl+C) |
| SIGQUIT | 3 | 终止+核心转储 | 退出(Ctrl+\) |
| SIGILL | 4 | 终止+核心转储 | 非法指令 |
| SIGTRAP | 5 | 终止+核心转储 | 断点陷阱 |
| SIGABRT | 6 | 终止+核心转储 | abort()调用 |
| SIGFPE | 8 | 终止+核心转储 | 浮点异常 |
| SIGKILL | 9 | 终止 | 杀死(不可捕获) |
| SIGSEGV | 11 | 终止+核心转储 | 段错误 |
| SIGPIPE | 13 | 终止 | 管道破裂 |
| SIGALRM | 14 | 终止 | 定时器到期 |
| SIGTERM | 15 | 终止 | 终止 |
| SIGCHLD | 17 | 忽略 | 子进程状态改变 |
| SIGCONT | 18 | 继续 | 继续执行 |
| SIGSTOP | 19 | 停止 | 停止(不可捕获) |
| SIGTSTP | 20 | 停止 | 终端停止(Ctrl+Z) |
信号的默认动作
每个信号都有预定义的默认动作:
- 终止:进程立即退出
- 终止+核心转储:进程退出并生成核心转储文件
- 停止:进程暂停执行(可被SIGCONT恢复)
- 继续:恢复停止的进程
- 忽略:信号被丢弃
信号处理
进程可以通过 signal() 或 sigaction() 改变信号的默认行为:
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
// 信号处理函数
void sigint_handler(int sig) {
printf("\nCaught SIGINT! Signal number: %d\n", sig);
}
int main() {
// 注册信号处理函数
if (signal(SIGINT, sigint_handler) == SIG_ERR) {
perror("signal error");
return 1;
}
printf("Press Ctrl+C to send SIGINT...\n");
// 无限循环,等待信号
while (1) {
sleep(1);
}
return 0;
}sigaction:更可靠的信号处理
sigaction 比 signal 更可靠,提供了更多的控制选项:
#include <signal.h>
#include <stdio.h>
#include <string.h>
void handler(int sig) {
printf("Caught signal %d\n", sig);
}
int main() {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = handler;
sa.sa_flags = SA_RESTART; // 自动重启被中断的系统调用
// 清空信号掩码(不阻塞其他信号)
sigemptyset(&sa.sa_mask);
if (sigaction(SIGINT, &sa, NULL) == -1) {
perror("sigaction");
return 1;
}
while (1) {
sleep(1);
}
return 0;
}信号掩码
进程可以阻塞某些信号,使其在解除阻塞前不会被递送:
#include <signal.h>
sigset_t mask, old_mask;
// 初始化信号集
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGQUIT);
// 阻塞信号
sigprocmask(SIG_BLOCK, &mask, &old_mask);
// 临界区代码...
// 恢复原来的信号掩码
sigprocmask(SIG_SETMASK, &old_mask, NULL);2.5 进程间通信
由于每个进程都有独立的地址空间,进程间需要特殊的机制来交换数据。
管道(Pipes)
管道是最简单的IPC机制,提供单向的数据流:
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main() {
int pipefd[2];
char buf[100];
// 创建管道
if (pipe(pipefd) == -1) {
perror("pipe");
return 1;
}
// pipefd[0] 是读端,pipefd[1] 是写端
pid_t pid = fork();
if (pid == 0) {
// 子进程:写数据
close(pipefd[0]); // 关闭读端
const char *msg = "Hello from child!";
write(pipefd[1], msg, strlen(msg));
close(pipefd[1]); // 关闭写端,通知EOF
exit(0);
} else {
// 父进程:读数据
close(pipefd[1]); // 关闭写端
read(pipefd[0], buf, sizeof(buf));
printf("Received: %s\n", buf);
close(pipefd[0]);
}
return 0;
}命名管道(FIFO)
命名管道允许不相关的进程之间通信:
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
// 创建FIFO
mkfifo("/tmp/myfifo", 0666);
// 写进程
int fd = open("/tmp/myfifo", O_WRONLY);
write(fd, "data", 4);
close(fd);
// 读进程
int fd = open("/tmp/myfifo", O_RDONLY);
char buf[100];
read(fd, buf, sizeof(buf));
close(fd);三、重要知识点
3.1 进程的完整生命周期
一个进程从创建到终止经历以下阶段:
创建(fork)
│
▼
运行(执行指令)
│
├──→ 等待I/O ──→ 就绪 ──→ 运行
│
├──→ 被信号停止(SIGSTOP/SIGTSTP)
│ │
│ ▼
│ 继续(SIGCONT)──→ 运行
│
▼
终止(exit/被信号杀死)
│
▼
僵尸状态(等待父进程wait)
│
▼
完全终止(父进程wait后)僵尸进程
当子进程终止但父进程尚未调用 wait 时,子进程成为僵尸进程:
- 进程已终止,不再执行任何代码
- 内核仍保留其进程控制块(PCB),记录退出状态
- 占用一个进程表项
- 父进程调用
wait后,僵尸进程被完全清除
// 避免僵尸进程的方法
void sigchld_handler(int sig) {
// 循环wait所有已终止的子进程
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
int main() {
signal(SIGCHLD, sigchld_handler);
// ... 创建子进程
}3.2 fork的高级用法
在shell中实现fork+exec
shell程序的核心就是 fork + exec + wait 的组合:
// 简化的shell实现
void eval(char *cmdline) {
char *argv[MAXARGS];
int bg = parseargs(cmdline, argv); // 解析命令行
pid_t pid = fork();
if (pid == 0) {
// 子进程
if (!bg) {
// 前台进程,加入前台进程组
setpgid(0, 0);
}
execve(argv[0], argv, environ);
// exec失败
printf("%s: Command not found\n", argv[0]);
exit(0);
}
// 父进程
if (bg) {
// 后台进程
printf("[%d] %d\n", getpgid(pid), pid);
} else {
// 前台进程,等待其终止
waitfg(pid);
}
}利用fork实现并发
// 并发服务器模型
int main() {
int listenfd = open_listenfd(PORT);
while (1) {
struct sockaddr_storage clientaddr;
socklen_t clientlen = sizeof(clientaddr);
int connfd = accept(listenfd, (SA*)&clientaddr, &clientlen);
pid_t pid = fork();
close(listenfd); // 父进程关闭监听套接字
if (pid == 0) {
// 子进程处理连接
close(listenfd);
doit(connfd);
close(connfd);
exit(0);
}
// 父进程继续监听
close(connfd);
}
}3.3 信号处理的深入理解
信号的递送和处理过程
信号产生
│
▼
内核设置进程信号掩码中对应位
│
▼
内核检查信号是否被阻塞
│
├── 被阻塞 → 等待解除阻塞
│
└── 未阻塞 → 递送信号
│
▼
中断当前执行流
│
▼
调用信号处理函数
│
▼
处理函数返回
│
▼
恢复执行(可能重启系统调用)信号处理的注意事项
1. 信号处理函数应该是可重入的
// 错误示例:使用非可重入函数
void handler(int sig) {
printf("Caught signal %d\n", sig); // printf不是可重入的!
}
// 正确示例:使用可重入函数
void handler(int sig) {
char msg[] = "Caught signal\n";
write(STDOUT_FILENO, msg, strlen(msg)); // write是可重入的
}2. 保存和恢复errno
void handler(int sig) {
int saved_errno = errno;
// 处理信号...
errno = saved_errno;
}3. 阻塞信号处理函数中的信号
在信号处理函数执行期间,当前信号默认被阻塞,防止递归调用。但其他信号不会被阻塞,可能导致嵌套的信号处理。
3.4 同步与竞态条件
竞态条件的示例
// 存在竞态条件的程序
unsigned int nextpid = 1;
void sigchld_handler(int sig) {
// 危险:在信号处理函数中访问共享变量
printf("Child %d terminated\n", nextpid);
}
void fork_children() {
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) exit(0);
nextpid = pid; // 竞态:信号处理函数可能在赋值前执行
}
}使用信号掩码避免竞态
void fork_children() {
sigset_t mask, old_mask;
// 阻塞SIGCHLD
sigemptyset(&mask);
sigaddset(&mask, SIGCHLD);
sigprocmask(SIG_BLOCK, &mask, &old_mask);
for (int i = 0; i < 5; i++) {
pid_t pid = fork();
if (pid == 0) exit(0);
nextpid = pid; // 安全:SIGCHLD被阻塞
}
// 解除阻塞,递送排队的信号
sigprocmask(SIG_SETMASK, &old_mask, NULL);
}3.5 进程组与会话
进程组
每个进程属于一个进程组,用于作业控制:
#include <unistd.h>
// 获取进程组ID
pid_t getpgrp(void);
pid_t getpgid(pid_t pid);
// 设置进程组ID
int setpgid(pid_t pid, pid_t pgid);会话
会话是一组相关进程组的集合:
// 创建新会话
pid_t setsid(void);会话的特性:
- 会话有一个控制终端
- 会话首进程是创建会话的进程
- 会话中的所有进程组共享同一个控制终端
前台和后台进程组
# 前台作业
$ sleep 100
^Z # 按Ctrl+Z停止
[1]+ Stopped sleep 100
# 放到后台
$ bg %1
[1]+ sleep 100 &
# 放到前台
$ fg %1
sleep 100四、常见误区与难点
4.1 误区一:fork后父子进程共享变量
错误理解:认为 fork 后父子进程共享内存,修改一个变量会影响另一个。
正确理解:
fork创建的是地址空间的副本,不是共享- 父子进程有独立的变量副本
- 写时复制确保修改互不影响
int x = 1;
int main() {
pid_t pid = fork();
if (pid == 0) {
x = 2; // 只修改子进程的副本
}
// 父进程的x仍然是1
printf("x = %d\n", x);
return 0;
}
// 输出:
// x = 2 (子进程)
// x = 1 (父进程)4.2 误区二:fork返回两次相同的值
错误理解:认为 fork 在父进程和子进程中返回相同的值。
正确理解:
- 在父进程中,
fork返回新创建的子进程的PID(正整数) - 在子进程中,
fork返回0 - 如果失败,
fork返回-1
4.3 误区三:信号处理函数可以安全使用任何函数
错误理解:认为信号处理函数中可以调用任何C库函数。
正确理解:
- 信号处理函数只能调用可重入(reentrant)函数
- 不可重入函数(如printf、malloc)可能导致死锁或数据损坏
- 应使用write等系统调用代替
4.4 难点:理解wait和waitpid的区别
wait:
- 阻塞等待任意子进程终止
- 返回终止子进程的PID
- 简单但不够灵活
waitpid:
- 可以等待特定PID的子进程
- 支持WNOHANG选项(非阻塞)
- 可以等待进程组中的任意子进程
- 更灵活但更复杂
4.5 难点:信号队列和丢失
问题:Linux不排队同类信号。如果信号A被阻塞,期间发送了3个SIGINT,解除阻塞后只会收到1个SIGINT。
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL);
// 此时发送3个SIGINT
// kill(getpid(), SIGINT);
// kill(getpid(), SIGINT);
// kill(getpid(), SIGINT);
// 解除阻塞后,只处理1次SIGINT
sigprocmask(SIG_SETMASK, &old_mask, NULL);4.6 难点:exec后文件描述符的处理
默认行为:exec 后,打开的文件描述符保持打开状态。
CLOEXEC标志:设置FD_CLOEXEC标志的文件描述符会在 exec 后自动关闭。
int fd = open("file.txt", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
// exec后fd会自动关闭五、实践应用
5.1 实现一个简单的Shell
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>
#define MAXLINE 1024
#define MAXARGS 128
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0)
;
}
void eval(char *cmdline) {
char *argv[MAXARGS];
int argc = 0;
int bg = 0;
// 解析命令行
char *token = strtok(cmdline, " \n");
while (token != NULL) {
if (strcmp(token, "&") == 0) {
bg = 1;
} else {
argv[argc++] = token;
}
token = strtok(NULL, " \n");
}
argv[argc] = NULL;
if (argc == 0) return;
pid_t pid = fork();
if (pid == 0) {
// 子进程
if (execvp(argv[0], argv) < 0) {
printf("%s: Command not found\n", argv[0]);
exit(1);
}
}
if (!bg) {
// 前台进程,等待
int status;
waitpid(pid, &status, 0);
} else {
printf("[%d] %d\n", ++job_counter, pid);
}
}
int main() {
char cmdline[MAXLINE];
signal(SIGCHLD, sigchld_handler);
while (1) {
printf("> ");
fgets(cmdline, MAXLINE, stdin);
if (feof(stdin)) exit(0);
eval(cmdline);
}
return 0;
}5.2 实现进程管道
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
void pipe_example() {
int pipefd[2];
pid_t pid1, pid2;
if (pipe(pipefd) == -1) {
perror("pipe");
exit(1);
}
// 第一个进程:ls
pid1 = fork();
if (pid1 == 0) {
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[0]);
close(pipefd[1]);
execlp("ls", "ls", "-l", NULL);
exit(1);
}
// 第二个进程:grep
pid2 = fork();
if (pid2 == 0) {
dup2(pipefd[0], STDIN_FILENO);
close(pipefd[0]);
close(pipefd[1]);
execlp("grep", "grep", ".c", NULL);
exit(1);
}
// 父进程关闭管道
close(pipefd[0]);
close(pipefd[1]);
// 等待子进程
waitpid(pid1, NULL, 0);
waitpid(pid2, NULL, 0);
}5.3 安全的信号处理
#include <signal.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>
volatile sig_atomic_t child_count = 0;
void sigchld_handler(int sig) {
int saved_errno = errno;
pid_t pid;
// 循环回收所有已终止的子进程
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
child_count++;
}
errno = saved_errno;
}
int main() {
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, NULL);
for (int i = 0; i < 10; i++) {
pid_t pid = fork();
if (pid == 0) {
sleep(1);
exit(0);
}
}
// 主循环做其他工作
while (child_count < 10) {
printf("Children terminated: %d\n", child_count);
sleep(1);
}
printf("All children terminated\n");
return 0;
}5.4 使用alarm和信号实现超时
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <setjmp.h>
static jmp_buf env;
void alarm_handler(int sig) {
longjmp(env, 1);
}
int read_with_timeout(int fd, char *buf, int size, int timeout) {
struct sigaction sa;
sa.sa_handler = alarm_handler;
sa.sa_flags = 0; // 不使用SA_RESTART,让read被中断
sigemptyset(&sa.sa_mask);
sigaction(SIGALRM, &sa, NULL);
if (setjmp(env) == 0) {
alarm(timeout);
int n = read(fd, buf, size);
alarm(0); // 取消定时器
return n;
} else {
// 超时
return -1;
}
}
int main() {
char buf[100];
printf("Enter something (5 second timeout): ");
int n = read_with_timeout(STDIN_FILENO, buf, sizeof(buf), 5);
if (n < 0) {
printf("\nTimeout!\n");
} else {
buf[n] = '\0';
printf("\nYou entered: %s", buf);
}
return 0;
}六、本章小结
核心要点回顾
异常控制流是计算机系统中普遍存在的机制,发生在所有层次上
异常分为中断、陷阱、故障和终止四类,由硬件和操作系统协作处理
进程是正在执行的程序实例,提供独占使用处理器的假象
fork创建子进程,是父进程的副本;exec用新程序替换当前进程
wait/waitpid用于父进程等待子进程终止,回收资源
信号是进程间通信的异步通知机制,用于处理异步事件
进程间通信需要特殊机制,如管道、FIFO、共享内存等
信号处理需要特别注意可重入性和竞态条件
关键系统调用
| 系统调用 | 功能 |
|---|---|
fork() | 创建子进程 |
execve() | 加载并执行新程序 |
wait() / waitpid() | 等待子进程终止 |
exit() | 终止当前进程 |
kill() | 向进程发送信号 |
signal() / sigaction() | 设置信号处理 |
sigprocmask() | 操作信号掩码 |
pipe() | 创建管道 |
dup2() | 复制文件描述符 |
进程状态转换图
fork
│
┌──────▼──────┐
│ 运行中 │◄────┐
└──────┬──────┘ │
│ │ SIGCONT
┌─────┼─────┐ │
│ │ │ │
▼ ▼ ▼ │
exit signal I/O │
│ stop wait │
│ │ │ │
▼ ▼ ▼ │
┌────┐┌────┐┌────┐ │
│终止││停止││等待│───┘
└──┬─┘└──┬─┘└────┘
│ │
▼ └─────► 被信号杀死
┌────┐
│僵尸│──wait──► 完全终止
└────┘学习建议
- 动手编写并运行本章的所有代码示例
- 使用
strace工具观察系统调用 - 完成CSAPP配套的Shell Lab实验
- 阅读
/proc文件系统了解进程信息 - 练习使用
gdb调试多进程程序