Skip to content

第二十五章:信号处理

筑基中期

涉及内核源码:

林小源开始研究信号处理器的注册和执行。

用户态程序通过 系统调用来注册信号处理器。处理器是一个函数指针——当信号到达时,内核会把进程的执行流重定向到这个函数。处理器执行完毕后,进程通过 系统调用返回到之前被中断的位置。

c
/*
 * sigaction 结构体:
 * sa_handler — 信号处理函数(或 SIG_IGN/SIG_DFL)
 * sa_flags   — 标志位
 * sa_mask    — 处理器执行期间额外阻塞的信号
 */

#define SIG_DFL   ((void (*)(int))0)
#define SIG_IGN   ((void (*)(int))1)
#define SA_RESTART 0x10000000
#define SA_SIGINFO 0x00000004

struct sigaction {
    void (*sa_handler)(int);
    unsigned long sa_flags;
    unsigned long sa_mask;
};

struct signal_table {
    struct sigaction actions[32];  /* 每个信号一个处理器 */
    unsigned long blocked;         /* 阻塞的信号掩码 */
    unsigned long pending;         /* 待处理的信号 */
};

void sigterm_handler(int sig) {
    printf("  [处理器] 收到 SIGTERM,优雅退出\n");
}

void sigchld_handler(int sig) {
    printf("  [处理器] 收到 SIGCHLD,回收子进程\n");
}

struct signal_table sigtable;
memset(&sigtable, 0, sizeof(sigtable));

printf("=== 信号处理器注册 ===\n\n");

/* 注册 SIGTERM 处理器 */
sigtable.actions[15].sa_handler = sigterm_handler;
sigtable.actions[15].sa_flags = SA_RESTART;
printf("注册 SIGTERM 处理器: sigterm_handler\n");
printf("  SA_RESTART: 被信号中断的系统调用自动重启\n\n");

/* 注册 SIGCHLD 处理器 */
sigtable.actions[17].sa_handler = sigchld_handler;
sigtable.actions[17].sa_flags = SA_SIGINFO;
printf("注册 SIGCHLD 处理器: sigchld_handler\n");
printf("  SA_SIGINFO: 传递详细的 siginfo_t 信息\n\n");

/* 模拟信号处理 */
printf("--- 模拟信号到达 ---\n");
int sig = 15;  /* SIGTERM */
if (sigtable.actions[sig].sa_handler != SIG_DFL &&
    sigtable.actions[sig].sa_handler != SIG_IGN) {
    sigtable.actions[sig].sa_handler(sig);
}

printf("\n--- 信号处理流程 ---\n");
printf("1. 进程从内核态返回用户态\n");
printf("2. 检查 pending 信号\n");
printf("3. 如果有处理器 → 调用处理器\n");
printf("4. 处理器执行完毕 → sigreturn()\n");
printf("5. 返回到之前被中断的位置\n");

林小源望着信号处理器的注册流程,脑海中浮现出一幅画面:每个进程都有一个信号表,表中有 32 个槽位(对应 32 种信号),每个槽位可以放一个函数指针。当信号到达时,内核查表找到对应的函数,然后把进程的执行流"劫持"到那个函数。

"劫持?"剑侍不知何时又出现了,听到林小源的话,纠正道,"不是劫持,是重定向。内核修改进程的用户态栈,把返回地址指向信号处理器,把处理器的参数设好,然后返回用户态。进程一回到用户态,就发现自己在信号处理器里了。处理器执行完后,调用 ,内核恢复原来的栈和寄存器,进程继续执行之前被中断的地方。"

"听起来很复杂。"

"确实复杂。"剑侍说,"但这是必要的复杂——信号处理器必须在用户态执行,不能在内核态。而且处理器执行完毕后,必须能无缝地回到之前的执行位置——就像什么都没发生过一样。"

林小源在信号处理器的执行机制中看到了一种"上下文切换"的精妙——不是进程间的上下文切换,而是同一个进程内部的上下文切换。内核保存当前的执行状态,重定向到信号处理器,处理器执行完后恢复状态继续执行。

林小源在研究信号处理器的过程中,发现了一个微妙的问题。

如果信号处理器正在执行时,又来了一个新的信号怎么办?

答案是:取决于信号掩码。 字段定义了处理器执行期间额外阻塞的信号。如果新来的信号在 中,它会被推迟到处理器执行完毕后再处理。

剑侍解释道:"默认情况下,处理器正在处理 时,新的 会被阻塞——内核会自动把正在处理的信号加入掩码。这防止了信号处理器被自己中断——你想想,如果处理器正在处理 ,又来了一个 ,处理器被中断,重新进入同一个处理器——这就是递归,可能会导致栈溢出。"

"那 呢?"林小源问。

" 告诉内核不要自动阻塞正在处理的信号。"剑侍说,"这在某些特殊场景下有用——比如你需要在处理器中处理同一个信号的多次到达。但大多数情况下,你不需要它。"

林小源又问:"那 呢?"

"好问题。"剑侍的眼睛亮了起来," 解决了一个很实际的问题。假设你的进程正在 read() 一个文件,突然来了一个信号。如果没有 read() 会返回 -EINTR——'被信号中断'。你的代码必须检查这个错误,然后重新调用 read()。这很烦人。有了 ,内核会自动重启被中断的系统调用——你的代码根本不需要知道有信号发生过。"

"所以 是一个便利性标志?"

"可以这么说。但不是所有系统调用都支持自动重启—— 等就不支持。所以你不能完全依赖它。"

信号处理是一门精细的学问。每一个标志、每一个掩码位、每一个默认行为,都有它的道理。

林小源在观察信号处理的过程中,注意到了一个有趣的现象。

shell 小妹注册了 (Ctrl+C)的处理器。当用户按下 Ctrl+C 时,内核向 shell 小妹发送 。shell 小妹的处理器不是直接退出——它检查当前是否有前台进程在运行。如果有,它把 转发给前台进程;如果没有,它打印一个新的提示符。

林小源把这个观察告诉剑侍。剑侍说:"这就是 shell 的信号转发模式。shell 是进程组的领导者——它管理着一组前台进程。当用户按 Ctrl+C 时,信号不是发给某个具体的进程,而是发给前台进程组。shell 收到信号后,决定怎么处理——通常是转发给前台进程。"

"所以 Ctrl+C 能中断正在运行的命令,是因为 shell 把 SIGINT 转发给了那个命令?"

"对。"剑侍说,"命令退出后,shell 回收它,然后打印新的提示符。整个流程:用户按 Ctrl+C → 内核发送 SIGINT → shell 的处理器转发给前台进程 → 前台进程退出 → shell 回收 → 新的提示符。"

林小源感叹道:"shell 小妹比她看起来要聪明。"

"她当然聪明。"剑侍说,"她不懂内核的深处,但她把信号处理玩得炉火纯青。每一个 shell 脚本、每一个管道、每一个重定向,背后都有信号处理的影子。她是 Unix 世界中最精巧的用户态程序之一。"

林小源望着 shell 小妹忙碌的身影,心中升起一股敬意。她不懂内核的深处,但她的每一次信号处理都是对 Unix 哲学的完美诠释。


道藏笔记

内核启示

信号处理器是用户态程序处理信号的方式。

系统调用注册信号处理器,结构体里三个关键字段各管一摊: 放处理函数(也可以是 ), 放标志位( 等), 指定处理器执行期间额外阻塞哪些信号。

整个信号处理流程是这样的:进程从内核态返回用户态时,内核先检查有没有 pending 信号,如果有注册的处理器就调用它,处理器执行完后调 ,内核恢复原来的栈和寄存器,进程接着干之前被中断的事。

标志让被信号中断的系统调用自动重启——这避免了 错误的处理。

信号处理是进程与外界交互的窗口。


破关试炼

信号之试

用户按下 Ctrl+C 时,本章讲到 shell 通常会向前台进程送出哪一个信号?

答对后才能继续滑动和进入下一章。

以修仙之名,悟内核之道