
先纠正一个拼写问题标题里的singal应该是signal这是 Linux 下信号处理最常用的接口之一。我在不少新人的代码里见过这个拼写编译直接报错因为头文件里根本没有这个符号。signal函数虽然看起来只有一行声明但背后牵扯到信号的产生、递送、处理时机、内核态与用户态切换还有一大堆历史包袱。这篇文章就把signal函数从声明到实战、从入门到避坑完整过一遍适合刚接触 Linux C 编程的读者也适合写过几天 signal 但没细抠语义的老哥。你完全可以把这篇文章当成一份“可以直接抄作业”的笔记遇到问题翻到对应小节就行。1. 项目概述与核心需求解析1.1 signal 函数解决什么问题signal函数解决的核心问题只有一个让进程在运行过程中能够对来自内核或其他进程的“紧急通知”做出反应。我习惯把信号理解成“软件层面的中断”。进程平时可能正在执行一段循环、等待用户输入、读写文件内核可以在任意时刻打断它告诉它“有事情发生了”。这个“事情”可能是用户按下了 CtrlC可能是定时器到期了也可能是某个子进程退出了。signal函数的作用就是提前告诉内核“当这个信号到来时你要去调用我这个函数。”如果你不给signal注册任何处理函数内核会执行对应信号的默认动作。绝大多数信号的默认动作是终止进程比如SIGINT默认终止前台进程SIGTERM默认终止进程SIGSEGV默认产生核心转储并终止。很多初学者会问那我不注册信号处理函数程序不也能正常退出吗确实能退出但那是“粗暴退出”进程没有机会做清理工作。真实项目里进程退出前通常要保存配置、释放资源、通知其他模块这些都需要你在信号处理函数里自己控制。1.2 谁需要认真学 signal如果你是 Linux C/C 开发、嵌入式开发、服务端开发或者平时经常写守护进程、网络服务、命令行工具那signal属于基本功。哪怕你的日常工作只是写业务逻辑只要程序里用到了kill命令、CtrlC、nohup底层都离不开信号机制。运维和测试同学我也建议稍微看一下。你不需要精通sigaction但至少要知道信号处理函数可能是异步执行的不能在里面做太多事情。否则看到“程序偶发崩溃”但又不知道原因时会非常痛苦。我见过一个真实案例某同事在信号处理函数里直接调用printf平时测试没问题一旦服务压力上来就随机卡死最后查了半天才知道是信号处理函数和主流程同时操作同一个文件描述符引发了内部锁冲突。1.3 先立边界signal 能做什么、不能做什么signal能做的是让一个信号到来时执行你指定的函数。但它不能做的或者说很难自然做好的事情也要提前知道。第一SIGKILL和SIGSTOP这两个信号不能被捕获也不能被忽略。signal(SIGKILL, handler)这种代码看起来合法但运行起来没有效果进程照样会被kill -9杀掉。第二signal本身不提供“排队”能力。如果一个信号在第一个处理函数还没执行完时就再次到达默认情况下这次信号会被合并、丢失你感知不到它曾经发生过。第三信号处理函数是异步插入到正常流程中的它不知道当前主程序执行到了哪一行因此不能随便访问全局数据结构否则容易产生竞态条件。先立下这三条边界后面再展开就不会觉得复杂了。2. signal 函数基础使用与原理拆解2.1 函数原型与最简注册流程signal函数的原型定义在signal.h中形式如下#include signal.h typedef void (*sighandler_t)(int); sighandler_t signal(int signum, sighandler_t handler);它接收两个参数第一个是信号编号第二个是处理函数指针。处理函数的返回值是void唯一的参数是int表示当前收到的信号编号。signal的返回值是“之前对该信号设置的处理函数指针”如果出错则返回SIG_ERR。最简单的注册流程就是三步void my_handler(int sig) { // 处理信号 } signal(SIGINT, my_handler);注册成功后当进程收到SIGINT时内核会中断当前执行流转而调用my_handler。my_handler执行完毕后进程通常会从原来的地方继续执行。我用一个比较生活化的类比signal就像给公司前台留了一张“紧急联系人名单”谁来了喊谁但名单不会改变公司日常的办公流程。2.2 三种 handler 特殊值SIG_IGN、SIG_DFL、自定义函数除了自定义函数signal的第二个参数还支持两个特殊宏SIG_IGN和SIG_DFL。SIG_IGN表示忽略这个信号。忽略不等于没有信号而是收到后不采取任何动作。比如你不想让程序被CtrlC打断可以调用signal(SIGINT, SIG_IGN)。SIG_DFL表示恢复默认行为。如果你之前改过某个信号的处理方式想恢复原始默认动作就把SIG_DFL传回去。这三位之间的关系可以用一张表格快速理解handler 值行为自定义函数信号到达时异步调用该函数SIG_IGN忽略信号不对进程产生任何影响SIG_DFL恢复系统默认动作多数是终止进程这里有个容易踩的坑SIG_IGN和自定义函数的“忽略”是两回事。自定义函数里如果你什么都不写看起来像忽略了信号但函数本身仍会被调用会有开销而且如果信号反复到来你的处理函数反复执行轻则浪费时间重则产生不可预期的副作用。想忽略信号直接用SIG_IGN才是干净做法。2.3 内核如何调用处理函数理解signal背后的触发链路对排查问题很有帮助。信号产生后并不一定会立刻执行处理函数而是先被内核标记为“pending 状态”。真正执行处理函数的时机是进程从内核态返回用户态的时候。我简单说下这个流程进程在用户态执行普通代码如果此时发生一次系统调用、定时器中断、硬件异常或者被其他进程发送了信号内核都会在返回用户态前检查该进程有没有待处理的信号。如果这个信号有处理函数内核会修改用户态栈让函数返回后先跳转到你的处理函数而不是回到原来的用户代码地址。处理函数执行完后再返回用户态继续执行原来的代码。这一点特别重要因为这意味着信号处理函数本质上是在“用户的栈上”运行的不是在独立线程里运行的。所以你在处理函数里用到的全局变量可能正在被主流程修改。这也是为什么后面会说可重入函数、volatile sig_atomic_t这些东西。2.4 返回值别丢掉之前的 handlersignal的返回值经常被忽略但它在动态修改信号策略时非常有用。很多服务端程序会先保存旧的处理函数等某段危险代码执行完后再恢复。比如void (*old_handler)(int); old_handler signal(SIGUSR1, temp_handler); // 执行需要临时接管 SIGUSR1 的逻辑 signal(SIGUSR1, old_handler);如果你不关心旧 handler也至少要检查返回值是不是SIG_ERR。注册失败可能意味着信号编号非法或者当前环境不允许修改该信号的处理方式。检查代码如下if (signal(SIGINT, my_handler) SIG_ERR) { perror(signal); return 1; }不检查返回值的问题在于你根本不知道注册有没有成功。表面上代码继续跑实际上信号来了还是走默认行为程序被直接终止排查时还以为是信号处理函数写错了。3. 实战最常见的 signal 处理场景3.1 优雅退出处理 SIGINT 与 SIGTERM最实际的场景就是“优雅退出”。用户按 CtrlC 时终端会向前台进程组发送SIGINT使用kill pid不发参数时默认发送SIGTERM。如果不做处理进程直接终止可能丢失数据。更好的做法是设置一个标志位让主循环自己退出。#include stdio.h #include stdlib.h #include signal.h #include unistd.h static volatile sig_atomic_t keep_running 1; static void on_signal(int sig) { keep_running 0; } int main(void) { if (signal(SIGINT, on_signal) SIG_ERR) { perror(signal SIGINT); exit(EXIT_FAILURE); } if (signal(SIGTERM, on_signal) SIG_ERR) { perror(signal SIGTERM); exit(EXIT_FAILURE); } printf(PID%d, 按 CtrlC 退出\n, getpid()); while (keep_running) { pause(); } puts(收到退出信号准备清理资源并退出); return 0; }这里有两个细节要注意。第一keep_running必须声明成volatile sig_atomic_t。volatile防止编译器把这个变量优化到寄存器里导致主循环永远看到旧值sig_atomic_t保证读写这个变量是原子的不会读到写了一半的中间状态。第二信号处理函数里只做“设置标志位”这一件事不要释放内存、不要打开文件那些操作都放到主流程里做。3.2 定时器配合用 SIGALRM 做超时控制SIGALRM由alarm()函数触发默认动作是终止进程。如果我们需要在一段操作上做超时控制可以先设置一个SIGALRM处理函数再调用alarm()。时间一到处理函数就会执行。#include stdio.h #include unistd.h #include signal.h static void on_alarm(int sig) { write(STDOUT_FILENO, timeout\n, 8); } int main(void) { signal(SIGALRM, on_alarm); alarm(2); pause(); // 等待信号 return 0; }这个例子虽然简单但有几个细节值得说。第一alarm()的单位是秒粒度比较粗需要更精确的超时建议使用timer_create()配合SIGALRM或者直接用setitimer()。第二一个进程只能有一个alarm()闹钟。如果你在同一个进程里连续调用两次alarm()第二次会覆盖第一次返回值是上一次闹钟剩余的秒数。第三SIGALRM和sleep()之间有冲突很多老系统里sleep()底层依赖SIGALRM同时使用会出现“睡过头”或“被提前唤醒”的怪现象。我在实际项目中更推荐用alarm()做“段级别”的超时保护而不是“毫秒级”的精确计时。比如等待某个子进程或外部资源时设置一个兜底闹钟防止整个进程卡死。兜底的意思是“差不多就行”精确计时应该交给其他机制。3.3 清理子进程SIGCHLD 与僵尸进程SIGCHLD是子进程状态变化时发给父进程的信号。子进程退出、被信号暂停、恢复运行时父进程都会收到。如果不处理SIGCHLD子进程退出后它的进程表项可能残留下来变成僵尸进程。僵尸进程既不占用 CPU也不能被再次调度但会占用进程表资源。常见的处理思路是在父进程里注册SIGCHLD处理函数在函数中调用waitpid()回收子进程状态#include signal.h #include sys/wait.h #include unistd.h static void on_child(int sig) { while (waitpid(-1, NULL, WNOHANG) 0) { // 循环回收所有已退出的子进程 } } void setup_sigchld(void) { signal(SIGCHLD, on_child); }为什么这里要用while循环因为多个子进程可能在同一时刻退出而SIGCHLD信号并不保证每个子进程都产生一次独立递送。如果只调用一次waitpid()可能只回收了其中一个剩下的子进程仍然变成僵尸。用waitpid(-1, NULL, WNOHANG)配合while循环可以一次性把所有已退出状态的子进程都回收掉。WNOHANG的作用是如果没有已退出的子进程立刻返回 0不阻塞父进程。3.4 主动发信号kill 和 raise 的配合signal负责“接收”而发信号的工作通常由kill()和raise()完成。raise()是进程给自己发信号kill()是进程给另一个进程或进程组发信号。二者的头文件都是signal.h。#include signal.h #include stdio.h int main(void) { raise(SIGUSR1); return 0; }如果自己进程里已经用signal(SIGUSR1, handler)注册了处理函数raise(SIGUSR1)就会触发它。kill()的原型是int kill(pid_t pid, int sig)其中pid参数有好几个特殊含义。我整理了一下pid 参数含义 0发给指定进程0发给当前进程所在进程组的所有进程-1发给当前进程有权发送的所有进程 -1发给进程组 ID 为-pid的所有进程这里容易犯错的地方是kill()的pid如果是进程组 ID需要传负数。比如想给进程组 1234 发送SIGUSR1要写kill(-1234, SIGUSR1)。另外普通用户只能给自己的进程发信号给别的用户的进程发信号会返回EPERM。还有一个很容易被忽略的问题kill()成功返回并不代表信号已经送达并执行了。它只代表“信号已成功加入目标进程的待处理集合”。如果目标进程已经把这个信号忽略了那么kill()同样会返回成功但什么都不会发生。我在后端排查问题时遇到过“明明调用了 kill 但程序没退”的情况很大概率就是目标进程之前调用了signal(SIGTERM, SIG_IGN)。4. 信号屏蔽、可重入与竞态问题4.1 为什么不能觉得“信号来了再说”信号是异步的它可能在任何指令边界之间到达。如果在主流程执行到一半时信号处理函数突然跑进来而且它访问了主流程正在修改的数据那么两个执行流之间就会出现经典竞态。举个例子主流程正在写一个链表刚完成头节点的插入还没更新尾节点指针此时信号到达处理函数也读取这个链表准备打印内容。它可能看到头尾不一致的中间状态轻则打印错误重则直接崩溃。这里不是“信号处理函数本身写错”而是它和主流程共享了内存却没有同步机制。要解决这个问题第一个思路是信号处理函数尽量简单只设置标志位。第二个思路是在关键临界区前后临时屏蔽某些信号保证这段代码执行完之前相关信号不会被递送。第三个思路是用sigaction配合阻塞掩码精确控制处理函数执行期间哪些信号需要被屏蔽。4.2 使用 sigprocmask 正确屏蔽信号signal函数本身不提供屏蔽能力屏蔽信号需要用sigprocmask()。它操作的是一个sigset_t集合先把要屏蔽的信号加进集合再通过SIG_BLOCK选项告诉内核。常见用法如下#include signal.h #include stdio.h void setup_block(void) { sigset_t block_set, old_set; sigemptyset(block_set); sigaddset(block_set, SIGUSR1); sigprocmask(SIG_BLOCK, block_set, old_set); // 此时 SIGUSR1 被屏蔽注册处理函数不用担心被立即打断 signal(SIGUSR1, on_signal); // 恢复原来的掩码解除屏蔽 sigprocmask(SIG_SETMASK, old_set, NULL); }这里要说清楚一个细节信号被屏蔽后不是“消失”了而是进入 pending 状态。解除屏蔽后如果该信号确实发生过内核会立刻把它递送出去。所以我们可以利用这个特性构造一个“先准备好再开闸”的流程确保信号处理函数不会在一个尴尬的时间点插入。在真实的网络服务中sigprocmask通常是和sigaction一起用的因为sigaction的sa_mask字段可以指定“处理函数执行期间”额外屏蔽哪些信号。这是一个很实用的能力比如在处理SIGUSR1时不希望SIGTERM再打断当前处理函数可以在sa_mask里加上SIGTERM。4.3 可重入函数handler 里的红线信号处理函数必须调用“异步信号安全函数”async-signal-safe functions。这个概念听起来绕翻译成大白话就是这些函数在信号处理函数里被调用时不会产生不可预料的锁冲突或数据损坏。printf就是典型的危险函数。它内部会加锁保护缓冲区如果信号在主流程执行printf的过程中到达处理函数又调用一次printf就可能发生死锁。malloc和free也有类似问题因为内存分配器内部有全局状态。strtok、localtime这类使用静态变量的函数更不用说极不安全。安全的做法是尽量只调用系统调用级别的函数比如write()、read()、open()、close()、waitpid()、getpid()、_exit()等。如果只是设置标志位那连write都可以不要直接操作volatile sig_atomic_t变量就够了。有些教程会写“在信号处理函数里用 printf 没关系我试过没问题”。确实小 demo 可能跑一万次都不崩但并发环境下这是不定时炸弹。能不用就不用这是我在生产环境里吃过亏之后最坚持的一条原则。4.4 signal 的跨平台语义不一致signal这个函数历史悠久但不同操作系统的实现细节并不一致。早期 System V 的语义是信号处理函数第一次被调用时内核会把该信号的处理方式重置为SIG_DFL。也就是说如果你的处理函数执行时间较长期间又收到了同一个信号第二次到达时可能不会再次进入你的处理函数而是直接按默认动作把进程终止。BSD 的语义则不同处理函数不会自动重置系统会自动屏蔽当前正在处理的信号避免递归调用。Linux 的 glibc 在大多数情况下走的是 BSD 语义但不少老代码、嵌入式环境或特定编译选项下仍可能表现出 System V 语义。这就是我强烈建议新代码用sigaction而不要依赖signal的原因之一。sigaction的行为由sa_flags和sa_mask明确指定不再依赖操作系统吃不吃历史包袱。5. 常见问题与排查技巧实录5.1 为什么 handler 里不能随便用 printf很多人在自己的第一个 signal 程序里都会用printf验证处理函数有没有被调用。一开始很顺利后来程序变得很怪甚至卡死。原因我之前提过printf不是异步信号安全的。为了更直观我写一段“问题代码”void handler(int sig) { printf(got signal %d\n, sig); }这段代码在某些情况下确实能工作但它存在两个问题。第一如果主流程恰好也在执行printf主流程拿到了 stdout 内部的锁信号处理函数再次调用printf时会尝试获取同一把锁。这把锁已经被同一个线程在更早的上下文里持有了而信号处理函数又是在这个线程的栈上运行的本质上是一个线程两次连续获取同一把不可重入的锁于是死锁。第二printf内部可能调用mallocmalloc的锁同样不可重入。所以正确做法是在 handler 里用write()直接写文件描述符或者只设置标志位。write()是系统调用不经过用户态锁相对安全得多。提示如果你确实需要在 handler 里输出调试信息可以先write(STDOUT_FILENO, msg\n, 4)这样写别用printf。但也要注意write只适合短小信息复杂格式化建议放到主循环处理。5.2 信号“没反应”的排查顺序遇到“signal 注册了但没生效”的情况不要急着改代码按下面的顺序排查最快。第一先确认信号编号对不对。在终端执行kill -l可以列出所有信号名和编号。SIGTERM是 15SIGKILL是 9SIGUSR1是 10不同架构上可能有差异但大多数 x86/ARM Linux 一致。第二确认进程真的收到了这个信号。可以在另一个终端执行ps -o pid,stat,cmd -p pid查看进程状态或者用strace -e tracesignal -p pid查看信号相关系统调用。如果strace没安装也可以临时在 handler 里加一个write到标准错误看有没有输出。第三检查是不是有其他地方把该信号设置成了SIG_IGN。很多高级库、框架初始化时会擅自忽略某些信号导致你之后再用signal()注册不生效或者生效也被先前的忽略覆盖。signal注册顺序很重要你以为注册成功了实际上后面又被别的模块改掉了。最后确认是不是进程已经崩溃或退出。kill返回成功不一定代表进程还活着因为 Linux 对不存在的进程发送信号时可能返回ESRCH但也可能因为比赛条件刚好返回成功。一定要结合进程状态来判断。5.3 handler 执行时间过长怎么处理信号处理函数不应该执行耗时操作但有时候我们又确实想在收到信号后做一些清理工作比如把内存中的数据写盘。如果直接在 handler 里写文件万一文件系统有问题整个进程会被卡住。推荐的思路是“handler 只是闹钟处理逻辑放主循环”。具体做法可以是在 handler 里只设置一个g_should_flush 1标志主循环每次迭代检查这个标志发现为 1 才去执行真正的落盘逻辑。这样信号处理函数本身执行时间极短不会阻塞系统也不会引入复杂锁。还有种更工程化的做法是 self-pipe trickhandler 里往一个管道写一个字节主循环用poll或epoll监听这个管道。管道有数据就说明有信号发生再在主循环里安全地处理。Linux 下还可以用signalfd把信号变成文件描述符可读事件和网络事件统一处理。这些都属于进阶玩法但思路都是同一个信号处理函数里能少做就少做。5.4 常用排查命令与问题速查表在 Linux 终端排查信号问题下面几个命令非常实用kill -l查看信号名字和编号。kill -信号编号 pid向指定进程发送信号。ps -o pid,stat,cmd -p pid查看进程状态和启动命令。cat /proc/pid/status | grep -i sig查看进程的信号相关位图。strace -e tracesignal -p pid追踪该进程收到的所有信号。现象可能原因排查方向信号处理函数不执行信号编号错误或处理方式被设为忽略kill -l核对编号检查是否有SIG_IGN程序收到信号后立刻退出注册失败或返回了SIG_ERR检查signal()返回值打印perrorhandler 执行后死锁在 handler 内调用printf、malloc改用write或只设置标志位子进程变成僵尸SIGCHLD处理不完整handler 内用while循环waitpid同一信号第二次不生效老 System V 语义把 handler 重置为默认改用sigaction避免平台差异全局标志失效循环不退出变量缺少volatile或类型非sig_atomic_t声明为volatile sig_atomic_t这个表格是我自己在项目和社区排障中总结出来的覆盖了新手期最常见的六类问题。如果你遇到的现象不在表里先别急着改代码把信号产生侧和接收侧分开排查。6. 从 signal 到 sigaction进阶路线与经验补充6.1 sigaction 到底强在哪sigaction是 POSIX 标准推荐的信号处理接口它比signal复杂一点但带来的控制力完全值回票价。函数原型长这样#include signal.h struct sigaction { void (*sa_handler)(int); void (*sa_sigaction)(int, siginfo_t *, void *); sigset_t sa_mask; int sa_flags; void (*sa_restorer)(void); }; int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);使用sigaction注册处理函数时需要先把struct sigaction里的字段填完整。一个常见的示例#include signal.h #include stdio.h #include string.h static void handler(int sig) { // 只做最安全的事 } void setup_handler(int sig) { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; if (sigaction(sig, sa, NULL) -1) { perror(sigaction); } }这里有两个关键点。第一sa_flags可以设置SA_RESTART表示某些被信号打断的系统调用在 handler 返回后自动重新启动。signal在 glibc 下的实现通常会带上这个标志但如果你用sigaction就必须显式去设置。第二sa_mask可以指定在 handler 执行期间额外屏蔽哪些信号。比如你只想在处理SIGUSR1时顺带屏蔽SIGTERM就把SIGTERM加进sa_mask。sa_sigaction和SA_SIGINFO标志配合时可以在 handler 里拿到更详细的信号来源信息比如哪个进程发送的信号、用户态还是内核态产生的、触发地址等。对于复杂排障场景这个能力非常有用。6.2 我踩过的几个坑这些年我写过不少信号相关代码也踩过不少坑。挑三个印象最深的分享出来给读者当反面教材。第一个坑是全局变量没加volatile。当时我写了一个守护进程主循环里用while (running)等待退出信号。本地编译测试一切正常放到生产环境后发SIGTERM居然没反应。后来才发现是编译优化级别不同running变量被优化到了寄存器里信号处理函数更新了内存中的值但主循环还在读寄存器。加上volatile sig_atomic_t后才正常。第二个坑是在 handler 里调用了printf。demo 阶段没问题一旦线上请求量上来就出现随机卡死。最后用gdb挂上去看到各线程阻塞在_IO_file_lock上才意识到是 stdout 锁发生了重入冲突。从那以后我给自己定了一条铁律handler 里只允许设置标志位最多加一个write。第三个坑是处理SIGCHLD时只调用了一次waitpid()。当时子进程数量少感觉不出来问题。后来子进程一多出现了大量僵尸进程。加上while循环后问题瞬间消失。这也说明信号处理代码往往在压力测试阶段才显形而不是功能开发阶段。6.3 后面可以继续扩展的方向signal函数本身只是敲门砖。如果你对 Linux 信号机制感兴趣下一步我建议按这个顺序继续深入先掌握sigaction的完整参数然后学习siginfo_t结构体了解SA_SIGINFO接着看多线程下的信号处理了解pthread_sigmask和sigwait再往后可以接触signalfd、eventfd、pidfd这些现代机制把信号和事件循环统一起来。还有一个很实用的扩展方向是alarm、setitimer、timer_create这套定时器信号体系。很多超时控制、心跳检测都依赖它们。理解了这些你对 Linux 系统编程的理解就不再停留在“函数调用”层面而是深入到“内核如何协调异步事件”的层面。我个人在实际操作中的体会是信号处理代码写得越少越好越朴素越好。它能在你需要的时候救急但只要逻辑一复杂就会变成最难排查的灾难现场。新代码优先用sigaction读老代码时看到signal多留一份心这比背下一个函数原型重要得多。