
做了这么多年 Linux 下的 C 开发我印象里最早被“信号保存”这个主题折磨到崩溃是在一个嵌入式设备上。设备跑得好好的突然业务全部假死SSH 还能登录进程也活着但就像是被人掐住了脖子怎么等都缓不过来。最后定位到问题就出在有人用 CtrlC 顺手改写了信号处理函数另一个组件却还按旧的指针去保存状态两边一冲突整个进程的恢复逻辑全乱套了。今天不聊 kill 和 CtrlC 这种入门操作把“信号保存”这件事从原理到实操掰开揉碎讲清楚。1. 信号为什么需要“保存”一次覆盖引发的进程假死1.1 我当时遇到的真实故障先讲我那个故障。设备上有两个线程线程 A 负责接收外部指令线程 B 负责执行设备动作。为了在调试时能随时中断动作开发者在初始化时给 SIGINT 注册了一个自定义 handler用来触发动作队列的存档和状态复位。问题出在后续版本里另一个同学为了“模拟终端退出”直接在代码里又调用了一次signal(SIGINT, SIG_DFL)。这一下自定义 handler 被悄悄覆盖成了默认行为。下次生产环境里有人 CtrlC进程收到的 SIGINT 不再触发存档回调而是直接终止。代码里所有依赖“信号保存”逻辑的地方全部失效进程看起来像假死实际上是被信号干掉了主循环但又没人处理退出状态。这个事故的根源就是没人认真对待“信号处理函数是可替换的”这件事。你以为注册了一次 handler 就一劳永逸但在 Linux 里信号的处置方式是一个可以随时被读、被写、被覆盖的属性。谁也不知道你引用的第三方库、你自己代码的另一个模块、甚至是某个调试工具会不会在某一行把 handler 替换成别的。1.2 信号处理机制的三个层级要理解信号保存首先得看 Linux 是怎么记录信号的。站在进程的角度信号分三个层级信号处置方式disposition即当前对某个信号要执行的动作是默认行为、忽略还是调用自定义 handler。这个动作由signal()或sigaction()设置也可以被随时替换。信号屏蔽字signal mask进程当前阻塞了哪些信号。被阻塞的信号不会触发处置动作只是排队等待。未决信号集pending set已经收到但还没有实际交付给进程的信号集合。信号保存最核心的一句话就是你在给进程安装一个新动作之前必须先把旧动作完整读出来、存好将来要用的时候再放回去。但这并不是简单的存一个函数指针那么简单Linux 下面要存的是一整套动作上下文包括 handler 指针、信号屏蔽字、处理标志位。1.3 为什么不能只保存 handler 指针刚开始接触时我也干过蠢事把signal()的返回值存下来以为这就是完整保存。可惜signal()这个接口本身太粗糙了它在不同的 Unix 衍生系统上语义还不一样有的系统调用完 handler 后会自动复位成 SIG_DFL有的不会。我们做嵌入式或者服务器开发代码将来可能跑在不同的系统上用signal()保存旧动作基本等于把程序的生死交给平台差异。现代 Linux 开发里保存和安装信号动作的标准接口是sigaction()。它会把我上面说的处置方式、屏蔽字、处理标志完整地写进一个struct sigaction结构体里用哪个就清晰明了了。2. 从 signal() 到 sigaction()到底保存了哪些信息2.1 sigaction 结构体的真实面貌直接看代码更直观。下面是保存 SIGINT 旧动作并替换成自定义动作的典型写法#include stdio.h #include string.h #include signal.h #include unistd.h static void my_int_handler(int sig) { write(STDOUT_FILENO, custom handler\n, 15); } int main(void) { struct sigaction new_act; struct sigaction old_act; memset(new_act, 0, sizeof(new_act)); new_act.sa_handler my_int_handler; sigemptyset(new_act.sa_mask); new_act.sa_flags 0; if (sigaction(SIGINT, new_act, old_act) -1) { perror(sigaction); return 1; } /* old_act 里现在就保存了安装之前 SIGINT 的完整动作 */ pause(); /* 恢复旧动作 */ if (sigaction(SIGINT, old_act, NULL) -1) { perror(sigaction restore); return 1; } return 0; }old_act里保存了三个关键字段sa_handler旧的处理函数指针可能是自定义函数也可能是SIG_DFL或SIG_IGN。sa_mask在处理该信号期间进程额外要阻塞的信号集合。sa_flags控制处理行为的标志位比如SA_RESTART让被中断的系统调用自动重试、SA_SIGINFO传递更多信号信息、SA_RESETHAND处理完一次后自动恢复默认动作。2.2 你没注意到的“隐式保存”sa_mask 的传递很多教程只强调sa_handler但实际项目里sa_mask才是容易坑人的地方。假设你的程序在处理 SIGUSR1 的 handler 里不希望 SIGTERM 突然插进来打断逻辑。那你在安装 SIGUSR1 的 handler 时就要把 SIGTERM 加入sa_mask。Linux 在处理信号时会自动把当前正在处理的信号本身加入阻塞集合防止同一个信号嵌套触发。但信号 A 的 handler 执行期间信号 B 是否会被阻塞完全取决于你设置的sa_mask。当你把旧动作保存进old_act再在别处重新装回去时sa_mask也会一并恢复。如果你只保存了 handler 指针而丢了sa_mask恢复出来的效果就是“行为变了但你看不出来”。我后来做代码 review 时都会特意看一眼sa_mask有没有被正确处理因为这里出 bug 特别隐蔽。2.3 signal() 和 sigaction() 怎么选我这里给一个非常实际的结论新代码一律用sigaction()signal()只适合写教学 demo。原因很简单sigaction()是 POSIX 明确规范的接口语义稳定signal()在不同平台上行为有差异甚至同一个 Linux 发行版的不同 libc 版本都可能不一样。下面这张表是我平时培训时常用的对比项signal()sigaction()保存旧动作返回函数指针信息量不足通过 struct sigaction 完整保存跨平台一致性差历史遗留语义混乱好POSIX 标准化处理期间屏蔽其他信号通常只能屏蔽当前信号可通过 sa_mask 自由控制获取信号附带信息不能配合 SA_SIGINFO 可以实现系统调用自动重试依赖平台行为通过 SA_RESTART 明确控制别觉得这是小事。一个 save/restore 逻辑如果用了signal()在 A 平台上可能正常换到 B 平台 handler 执行完之后自动被复位成默认动作第二次信号进来进程直接退出。这种问题在运维和嵌入式现场极难排查看起来完全没逻辑实际上是 API 语义差异。3. 信号屏蔽阻塞、未决与原子等待的完整链路3.1 “保存”不止保存 handler还要保存屏蔽字很多做应用开发的人对信号的理解停留在“收到信号就调用 handler”。这忽略了一个重要事实信号还可以被阻塞。被阻塞的信号不会凭空消失它会进入未决状态等屏蔽解除后再交付。所以信号保存还有第二层含义保存当前的信号屏蔽字然后按照业务需要临时修改屏蔽字用完之后再恢复原状。这个模式在多线程和嵌入式开发里太常用了。sigset_t new_mask, old_mask; sigemptyset(new_mask); sigaddset(new_mask, SIGUSR1); sigaddset(new_mask, SIGTERM); /* 保存当前屏蔽字同时阻塞这两个信号 */ if (sigprocmask(SIG_BLOCK, new_mask, old_mask) -1) { perror(sigprocmask); return -1; } /* 这里做临界区操作SIGUSR1 和 SIGTERM 都不会进来 */ /* 恢复之前保存的屏蔽字 */ if (sigprocmask(SIG_SETMASK, old_mask, NULL) -1) { perror(sigprocmask restore); return -1; }核心逻辑就在第二次sigprocmaskold_mask里保存的是调用之前整套屏蔽状态SIG_SETMASK会把进程的屏蔽字完全恢复成之前的样子。这样不管外面之前阻塞了哪些信号我这里临时做的修改都不影响全局。3.2 sigpending 是“保存”的未决信号清单如果信号被阻塞了进程怎么知道有哪些信号在排队答案是sigpending()。它把当前未决信号集合读出来你可以逐个检查。sigset_t pending; sigemptyset(pending); if (sigpending(pending) -1) { perror(sigpending); return -1; } if (sigismember(pending, SIGTERM)) { /* 说明 SIGTERM 已经到达但仍被阻塞 */ write(STDOUT_FILENO, SIGTERM pending\n, 16); }这在程序退出的流程设计上特别有用。比如你想优雅退出先把 SIGTERM 阻塞收到外部停止指令后不立刻处理等当前事务提交完再统一退出。这时sigpending()可以帮你确认“确实有人来过”。3.3 sigsuspend 的原子等待避免竞态窗口继续加深一层。假设你写了一个等待信号的主循环sigset_t block_mask, old_mask; int done 0; sigemptyset(block_mask); sigaddset(block_mask, SIGUSR1); sigprocmask(SIG_BLOCK, block_mask, old_mask); while (!done) { /* 这里有可能被信号打断吗 */ sigsuspend(old_mask); }pause()的问题是它有竞态你先检查某个标志位发现没满足然后调用pause()睡眠这期间如果信号来了就会永久错过。而sigsuspend()是原子的它先把你传进来的屏蔽字设置为当前屏蔽字然后挂起等待信号来时执行 handler返回后再恢复之前的屏蔽字。整个过程不会出现“检查标志和睡眠之间被信号插队”的窗口期。我写网络服务和嵌入式状态机时经常用sigsuspend()替代sleep()来等待信号。它比pause()安全比轮询高效。注意sigsuspend()返回时进程的屏蔽字又恢复成了调用之前的状态也就是阻塞了block_mask如果要在循环里反复等待你得每次都传入之前保存好的old_mask。4. 处理函数内部的代码纪律保存的不只是数据4.1 为什么不能用 printf 和 malloc很多初学者会在 handler 里写printf(got signal\n)这在大一课程设计里没事但在生产环境就是埋雷。原因是信号处理函数的执行时刻完全不可预知它可能正好发生在printf自己内部、malloc内部或者任何库函数内部。如果 handler 里调用了不可重入函数轻则输出错乱重则死锁崩溃。Linux 明确规定信号处理函数里只能调用异步信号安全函数async-signal-safe functions。write是安全的printf不安全read是安全的fgets不安全_exit安全exit不安全sigaction安全malloc/free不安全。我踩过最疼的一次是在一个多线程程序里handler 里用了malloc去申请内存保存现场信息。结果主线程序恰好也在malloc里两个地方同时操作堆的元数据直接内存损坏。那个 bug 一个月后才浮出来排查到想砸电脑。4.2 volatile sig_atomic_t 的正确姿势处理函数里要改共享标志应该用volatile sig_atomic_t。sig_atomic_t是 C 标准保证读取和写入原子的整数类型编译器不会把它优化成多步操作。配合volatile防止编译器把变量缓存在寄存器里。static volatile sig_atomic_t g_reload_flag 0; static volatile sig_atomic_t g_stop_flag 0; static void signal_handler(int sig) { if (sig SIGHUP) { g_reload_flag 1; } else if (sig SIGTERM) { g_stop_flag 1; } }仅靠两个变量就能让 handler 和主循环完成安全通信。主循环每次检查标志位置位后马上到安全点执行真正的业务逻辑。4.3 errno 的保存与恢复这是最容易被忽视的一条write或read在信号打断后会把errno设置为EINTR但 handler 内部的某个安全函数也可能修改errno。线程本来保存好的错误码被 handler 一冲就丢了。严谨的做法是在 handler 开头保存errno结尾恢复。static void signal_handler(int sig) { int saved_errno errno; if (sig SIGTERM) { g_stop_flag 1; } errno saved_errno; }别嫌这麻烦。线上排查错误时最恼火的就是errno莫名其妙变成 4导致业务逻辑走错分支而真正的错误码在若干微秒前被 handler 吞了。5. 实操案例做一个可热重载、可优雅退出的守护进程5.1 核心设计思路把前面所有知识点串起来我给一个可以直接改来用的守护进程框架。它实现了三个常用能力SIGTERM / SIGINT设置停止标志进程在合适的安全点退出。SIGHUP设置重载标志触发配置文件热更新。信号处理函数里只用异步信号安全函数主循环做真正的业务。同时我保存了旧动作。为什么保存旧动作因为在运行时可能有些第三方库或业务插件要向进程注入自己的信号处理逻辑主程序必须先存旧动作等退出时全部恢复避免进程退出清理阶段还把别的模块的信号处理器一并干掉导致二次崩溃。5.2 完整代码框架#include stdio.h #include string.h #include signal.h #include unistd.h #include errno.h #include stdlib.h static volatile sig_atomic_t g_stop_flag 0; static volatile sig_atomic_t g_reload_flag 0; static struct sigaction g_old_term_act; static struct sigaction g_old_hup_act; static void daemon_signal_handler(int sig) { int saved_errno errno; if (sig SIGTERM || sig SIGINT) { g_stop_flag 1; } else if (sig SIGHUP) { g_reload_flag 1; } errno saved_errno; } static int install_signal_handlers(void) { struct sigaction new_act; memset(new_act, 0, sizeof(new_act)); new_act.sa_handler daemon_signal_handler; sigemptyset(new_act.sa_mask); /* SA_RESTART 让 read/write 不会被信号打断减少 EINTR 处理 */ new_act.sa_flags SA_RESTART; if (sigaction(SIGTERM, new_act, g_old_term_act) -1) { perror(sigaction SIGTERM); return -1; } if (sigaction(SIGINT, new_act, g_old_term_act) -1) { perror(sigaction SIGINT); return -1; } /* 大多数守护进程里 SIGHUP 默认是终止进程这里改成重载配置 */ if (sigaction(SIGHUP, new_act, g_old_hup_act) -1) { perror(sigaction SIGHUP); return -1; } return 0; } static void restore_signal_handlers(void) { sigaction(SIGTERM, g_old_term_act, NULL); sigaction(SIGINT, g_old_term_act, NULL); sigaction(SIGHUP, g_old_hup_act, NULL); } static int reload_config(void) { /* 这里做真正的配置加载假设耗时 50 毫秒 */ write(STDOUT_FILENO, config reloaded\n, 16); return 0; } int main(void) { if (install_signal_handlers() -1) { return 1; } while (!g_stop_flag) { if (g_reload_flag) { g_reload_flag 0; reload_config(); } /* 模拟业务处理短睡 1 秒 */ sleep(1); } restore_signal_handlers(); write(STDOUT_FILENO, daemon exit clean\n, 18); return 0; }编译和内联测试gcc -Wall -Wextra -o daemon_demo daemon_demo.c ./daemon_demo另开一个终端试试kill -HUP $(pgrep daemon_demo) # 看到 config reloaded kill -TERM $(pgrep daemon_demo) # 看到 daemon exit clean要点在我特意加了SA_RESTART。如果你不叫这个标志那么sleep()、read()这类调用被信号打扰后会直接返回 -1 并置errno为EINTR主循环里就得多一堆EINTR判断逻辑。加了SA_RESTART之后很多系统调用会自动重新发起代码清爽得多。但注意sigsuspend()、poll()、epoll_wait()这类调用就算有SA_RESTART也可能照样返回EINTR所以业务代码里始终要有处理EINTR的习惯。5.3 保存旧动作的实际收益这个框架里restore_signal_handlers()值得我们单独解释一下。假设你作为基础框架被一个插件系统加载插件可能另外注册了自己的 SIGHUP 处理逻辑。进程退出时如果你不恢复旧动作而是直接exit()那插件的 handler 就会被默默抹掉。更复杂的情况是插件在退出阶段还要依赖 SIGHUP 来清理资源主程序的退出序列里旧动作已经恢复插件就能正常收到自己期待的信号。在嵌入式设备上保存旧动作的能力尤其重要。设备管理器可能在初始化时就把 SIGTERM 设置成“同步落盘”模式你的业务进程如果不保存直接覆盖设备断电时数据就丢了。保存旧动作意味着你可以安全地“借用”信号通道用完再还回去。6. 我这些年踩过的信号坑和最后的几条经验6.1 别在 handler 里做重活哪怕它是安全的有人看到异步信号安全函数列表后就放心地在 handler 里写一大堆write()这仍然不行。信号处理函数执行期间整个进程的逻辑都停在那里你拖得越久主流程被卡的时间越长其他信号也被连带阻塞因为当前信号在 handler 执行期间会自动加入屏蔽字。正确做法是 handler 里只置标志位所有昂贵操作全部挪到主循环。我以前见过一位同事在 SIGCHLD handler 里直接用waitpid()收子进程这个函数本身是异步信号安全的听着没问题。但他收完子进程后又在 handler 里做日志写入和统计更新结果一次多子进程同时退出handler 被反复进入主流程卡了好几毫秒高并发下直接导致服务雪崩。6.2 信号保存和线程的纠缠比你想的复杂多线程程序里sigaction()设置的是整个进程的信号处置方式对所有线程都生效但sigprocmask()只在调用它的线程内生效其他线程的屏蔽字不受影响。这意味着如果你想用某个线程固定接收所有信号就得在那个线程里单独设置屏蔽字并把其他线程的屏蔽字全部阻塞掉。一个可复用的模式是创建一个专用信号线程阻塞所有信号然后在主线程里用sigwait()统一接收。这种模式下“保存”和“恢复”的概念就变成了维护线程各自的屏蔽字。如果你直接用 handler 加标志多线程下还必须考虑原子性和内存可见性volatile sig_atomic_t只能保证单变量的原子访问不能保证多变量之间的顺序。需要传递复杂状态时用sigwait()往往比 handler 更省心。6.3 测试信号逻辑不要只靠 CtrlC认真做信号相关开发时我会专门写一个信号测试脚本覆盖以下场景信号在进程忙时到达handler 是否会丢。信号在 handler 执行期间再次到达是否会嵌套进入。多个不同信号几乎同时到达顺序是否可控。sigprocmask临时阻塞后未决信号在屏蔽解除瞬间的行为。我见过太多项目上线前只在终端里手动敲几次 CtrlC看起来没问题就发了。结果高负载下一轮压测信号路径的竞态问题全暴露了。信号机制是内核负责的它本身很可靠人们对它的误用才导致问题。把行为搞清楚比临场用一堆printf去“调”要靠谱得多。6.4 最后的个人建议如果你现在刚接触信号保存我建议你按这个顺序练手先把sigaction的保存和恢复写熟把struct sigaction三个核心字段吃透然后用sigprocmask做一次完整的临界区保护再用sigsuspend替代一次pause体会原子等待的价值最后才是写一个像上面那样的完整守护进程框架。每一步都亲手编译、运行、用strace看一下系统调用比看十篇博客都管用。信号保存不是那种学完就能炫技的知识它平时藏在底层但一旦你在生产环境里碰到一次“进程莫名假死”或者“退出逻辑被打乱”你就知道当初老老实实把 handler、屏蔽字、标志位都存好是多值得的一件事。