08

异常控制流

操作系统:唯一的使命就是帮助程序运行

阅读量:7 · 预计 12 分钟读完

异常进程系统调用信号
阅读进度2%

第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 指令触发,用于从用户态切换到内核态。

c
// 用户程序中的系统调用
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 系统调用创建一个新的进程(子进程),它是调用进程(父进程)的几乎完全相同的副本。

c
#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 系列函数用新程序替换当前进程的映像:

c
#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:等待子进程

waitwaitpid 用于父进程等待子进程终止:

c
#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报告继续的子进程

检查终止状态

c
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命令发送

常见信号类型

信号编号默认动作说明
SIGINT2终止中断(Ctrl+C)
SIGQUIT3终止+核心转储退出(Ctrl+\)
SIGILL4终止+核心转储非法指令
SIGTRAP5终止+核心转储断点陷阱
SIGABRT6终止+核心转储abort()调用
SIGFPE8终止+核心转储浮点异常
SIGKILL9终止杀死(不可捕获)
SIGSEGV11终止+核心转储段错误
SIGPIPE13终止管道破裂
SIGALRM14终止定时器到期
SIGTERM15终止终止
SIGCHLD17忽略子进程状态改变
SIGCONT18继续继续执行
SIGSTOP19停止停止(不可捕获)
SIGTSTP20停止终端停止(Ctrl+Z)

信号的默认动作

每个信号都有预定义的默认动作:

  • 终止:进程立即退出
  • 终止+核心转储:进程退出并生成核心转储文件
  • 停止:进程暂停执行(可被SIGCONT恢复)
  • 继续:恢复停止的进程
  • 忽略:信号被丢弃

信号处理

进程可以通过 signal()sigaction() 改变信号的默认行为:

c
#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:更可靠的信号处理

sigactionsignal 更可靠,提供了更多的控制选项:

c
#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;
}

信号掩码

进程可以阻塞某些信号,使其在解除阻塞前不会被递送:

c
#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机制,提供单向的数据流:

c
#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)

命名管道允许不相关的进程之间通信:

c
#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 后,僵尸进程被完全清除
c
// 避免僵尸进程的方法
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 的组合:

c
// 简化的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实现并发

c
// 并发服务器模型
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. 信号处理函数应该是可重入的

c
// 错误示例:使用非可重入函数
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

c
void handler(int sig) {
    int saved_errno = errno;
    // 处理信号...
    errno = saved_errno;
}

3. 阻塞信号处理函数中的信号

在信号处理函数执行期间,当前信号默认被阻塞,防止递归调用。但其他信号不会被阻塞,可能导致嵌套的信号处理。

3.4 同步与竞态条件

竞态条件的示例

c
// 存在竞态条件的程序
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;  // 竞态:信号处理函数可能在赋值前执行
    }
}

使用信号掩码避免竞态

c
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 进程组与会话

进程组

每个进程属于一个进程组,用于作业控制:

c
#include <unistd.h>

// 获取进程组ID
pid_t getpgrp(void);
pid_t getpgid(pid_t pid);

// 设置进程组ID
int setpgid(pid_t pid, pid_t pgid);

会话

会话是一组相关进程组的集合:

c
// 创建新会话
pid_t setsid(void);

会话的特性:

  • 会话有一个控制终端
  • 会话首进程是创建会话的进程
  • 会话中的所有进程组共享同一个控制终端

前台和后台进程组

bash
# 前台作业
$ sleep 100
^Z                    # 按Ctrl+Z停止
[1]+  Stopped         sleep 100

# 放到后台
$ bg %1
[1]+ sleep 100 &

# 放到前台
$ fg %1
sleep 100

四、常见误区与难点

4.1 误区一:fork后父子进程共享变量

错误理解:认为 fork 后父子进程共享内存,修改一个变量会影响另一个。

正确理解

  • fork 创建的是地址空间的副本,不是共享
  • 父子进程有独立的变量副本
  • 写时复制确保修改互不影响
c
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。

c
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 后自动关闭。

c
int fd = open("file.txt", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
// exec后fd会自动关闭

五、实践应用

5.1 实现一个简单的Shell

c
#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 实现进程管道

c
#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 安全的信号处理

c
#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和信号实现超时

c
#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 调试多进程程序