1. 一次线上崩溃背后的信号真相
排查一个跑了很久的C++服务突然挂掉时,你大概率会看到类似"Segmentation fault (core dumped)"的提示,或者日志里最后一行停在一个莫名其妙的read调用附近。这时候很多人第一反应是打开gdb,然后盯着core文件里那个调用栈看到天亮。但如果你对Linux SIG信号机制没有完整的认知,这个排查过程往往会走很多弯路。
我之前接手过一个网关程序,崩溃前的日志明明还在正常打印业务数据,gdb里看到的栈却指向一个完全不相关的函数,怎么都对不上。后来查清楚才发现,崩溃根本不在栈顶那个函数里,而是SIGSEGV在信号处理函数执行期间又触发了二次错误,把现场完全搅乱了。从那以后我养成了一个习惯:遇到任何异常退出,先问自己三个问题——信号的默认行为是什么?谁收到了这个信号?处理函数执行期间系统处于什么状态?
这篇文章我想把SIG信号的截获与调试从原理到实战完整梳理一遍,适合三类读者:一是刚接触Linux服务端开发、对各种core dump和信号退出一脸懵的新手;二是写网络服务或嵌入式程序、经常被SIGPIPE、SIGCHLD这类信号困扰的中级开发者;三是在用gdb调试复杂崩溃问题时,想系统理解"信号如何影响调试过程"的人。我会先把信号的底层机制讲透,再给出不同场景下信号截获的代码级做法,最后用几个高频真实场景来演示完整的调试链路。
信号机制是整个Linux进程模型里最容易被低估的部分。它既不像多线程那样需要显式同步,也不像文件系统那样有明确的API调用轨迹,但它却在一个进程被异步事件打断时决定了程序是继续跑、还是停住、还是直接消失。理解它,本质上就是理解Linux如何管理"进程的意外"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 信号的本质与分类:可靠信号和不可靠信号不是一回事
2.1 信号从内核到用户态的完整旅程
信号本质上是内核向进程发送的一种异步通知。触发方式五花八门:硬件异常(除零、非法内存访问)、终端操作(Ctrl+C产生SIGINT)、定时器到期(SIGALRM)、子进程状态变化(SIGCHLD)、用户主动kill等等。但不管来源是什么,信号在内核里的处理流程是一致的:内核维护了一个per-process的信号pending集合,在进程从内核态切回用户态时检查是否有未处理信号,如果有,就强制进程跳转到对应的处理函数。
这里有个关键点:信号不是立即被处理的,而是"在合适的时机被处理"。也就是进程正在内核态执行系统调用时,信号会先被记录,等系统调用返回、进程切回用户态前才真正触发处理逻辑。这导致了一个经典现象:如果你在信号处理函数里调用一个不可重入的函数(比如printf),而主程序刚好也在printf的执行流里被打断,那么信号处理函数和主程序就同时进入了同一个函数,内存里的缓冲区状态就全乱了。后面我会细说这个问题。
2.2 信号编号、默认行为与不可靠信号的局限性
经典信号编号从1到31,Linux的glibc还引入了32到64的实时信号。划分标准是:1到31是标准信号,其中有一批被称为"不可靠信号";32到64是POSIX实时信号。二者的本质区别在于:
标准信号不支持排队。如果一个信号在进程处理它之前被同类型的信号再次触发,内核只会在pending集合里记录一次,后续同类型信号会被丢弃。实时信号则支持排队,每次发送都会被独立记录和处理。
我用一个实际例子来说这个问题的杀伤力。我之前写过一个基于timerfd + SIGEV_THREAD的程序,本意是每个定时事件都触发一次SIGUSR1信号,程序里用一个计数器统计触发次数。结果在高负载下计数器总是比实际触发次数少。原因就是SIGUSR1是标准信号,内核在短时间内把多次触发合并成了一两次。换成SIGRTMIN+1后计数器才准确。
标准信号的另一个局限是默认行为粗糙。理解默认行为是调试信号问题的基础,我把高频的几个整理成一张表:
| 信号 | 编号 | 默认行为 | 常见触发场景 |
|---|---|---|---|
| SIGINT | 2 | 终止进程 | Ctrl+C |
| SIGSEGV | 11 | 终止进程+生成core | 非法内存访问 |
| SIGPIPE | 13 | 终止进程 | 写已关闭的socket |
| SIGALRM | 14 | 终止进程 | alarm()器定时到期 |
| SIGCHLD | 17 | 忽略 | 子进程停止或退出 |
| SIGTERM | 15 | 终止进程 | kill命令默认信号 |
| SIGUSR1 | 10 | 终止进程 | 用户自定义逻辑 |
| SIGRTMIN | 34 | 终止进程 | 用户自定义逻辑(可排队) |
不可靠信号这个名字实际上有两个层面:一是可能丢失(不排队),二是signal()函数注册的handler在部分旧实现中,信号处理完成后会重置为SIG_DFL,导致后续同类信号直接按默认行为处理。虽然现代glibc里signal()底层已经用sigaction实现了,但跨平台移植时这个坑依然存在。所以我的建议是:新代码里一律使用sigaction(),signal()只适合在极简单的脚本式demo里用。
3. 信号截获的完整姿势:从signal到sigaction再到信号集操作
3.1 signal()函数的正确用法与它为什么不够用
signal()的声明极其简单:
c复制#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
注册一个handler,然后给handler传一个int参数(信号编号),看起来一目了然。但实际使用中有几个容易忽略的点:
第一,handler的签名必须是void (int),不能返回任何值。如果你需要更多的上下文信息(比如导致SIGSEGV的内存地址),signal()给不了你。这个时候需要用sigaction配合siginfo_t。第二,signal()在处理函数的自动重置问题上,不同平台行为不一致,写可移植代码时就成了隐性炸弹。第三,也是最容易忽略的:signal()无法精确控制信号处理期间的屏蔽行为,比如某个信号正在被处理时,同类型的新信号来了是丢弃还是挂起,signal()的语义在不同的system call实现里有差异。
这段代码演示了signal()的基本用法,但注意它只适用于教学演示:
c复制#include <stdio.h>
#include <signal.h>
#include <unistd.h>
static void on_sigint(int sig) {
// 这里只是简单打印
write(STDOUT_FILENO, "Caught SIGINT, exit now.\n", 25);
_exit(0);
}
int main(void) {
signal(SIGINT, on_sigint);
while (1) {
pause();
}
return 0;
}
我用write而不是printf写输出,就是在规避信号处理函数中的可重入性问题。这一点我后面专门展开。
3.2 sigaction():控制信号处理的工业级方案
sigaction是POSIX定义的接口,也是我在所有正式项目里唯一使用的信号注册函数。它的完整结构体定义值得仔细看看:
c复制#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);
};
sa_handler和sa_sigaction是同一个位置的两份解释,取决于sa_flags里是否设置了SA_SIGINFO。如果设置了,回调就会走sa_sigaction,能拿到siginfo_t结构体——这里面有触发信号的具体地址(si_addr)、发送者PID(si_pid)、导致的系统调用号码等关键信息。SA_SIGINFO这个标志位在调试段错误时价值极大,因为你可以直接在handler里打印si_addr,快速判断是不是空指针解引用。
sa_mask则是用于设置"信号处理期间要额外屏蔽哪些信号"。注意语义:在handler执行期间,当前正在处理的信号默认会被屏蔽(除非sa_flags里设置了SA_NODEFER),sa_mask指定的是在handler执行期间还需要额外屏蔽的其他信号。比如你正在处理SIGUSR1,还想确保SIGTERM不会打断这段逻辑,就把SIGTERM加进sa_mask。
sa_flags里除了SA_SIGINFO,还有几个高频使用的标志位:
- SA_RESTART:被信号打断的慢速系统调用(如read、pause、wait)在内核完成信号处理后自动重启。没有这个标志,read可能返回EINTR错误,很多人的网络服务莫名其妙出错,根因就是忘了设置它。
- SA_NOCLDSTOP:在SIGCHLD处理时,只有子进程退出才触发handler,子进程因信号停止或恢复时不触发。如果你的SIGCHLD handler逻辑里包含waitpid操作,加上这个标志可以避免在子进程尚未真正退出时做无效的回收尝试。
- SA_NODEFER:处理信号期间不屏蔽同类型信号。默认情况下,你正在执行handler时再来一个同类型信号,它会挂起等待处理完成后才进来;设置这个标志后,同类型信号可以立即闯入,递归触发handler,这通常会导致栈溢出,没事别用。
3.3 一个可复用的完整信号截获模板
我经常在很多项目里复用下面这个模板,它既兼顾了可靠性,又能拿到完整的信号上下文:
c复制#include <stdio.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
static void log_signal(int sig, siginfo_t *info, void *ucontext) {
// 异步安全原则:这里只使用write / _exit,不用printf
char msg[128];
int len = snprintf(msg, sizeof(msg),
"Signal %d caught, si_addr=%p, si_pid=%d\n",
sig, info->si_addr, info->si_pid);
write(STDOUT_FILENO, msg, len);
_exit(1);
}
static void install_handler(int sig) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_sigaction = log_signal;
sa.sa_flags = SA_SIGINFO | SA_RESTART;
sigemptyset(&sa.sa_mask);
if (sigaction(sig, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
}
int main(void) {
install_handler(SIGSEGV);
install_handler(SIGILL);
install_handler(SIGFPE);
// 模拟一次非法内存访问
int *p = NULL;
*p = 42;
return 0;
}
这段代码跑起来,程序会在访问空指针时被截获,输出类似"Signal 11 caught, si_addr=0x0, si_pid=12345"的信息。调试时可以一眼看出段错误发生在地址0。
3.4 信号集操作:阻塞而不是忽略
前面提到的sa_mask是sigset_t类型,而对信号集的操作有一整套API:sigemptyset、sigfillset、sigaddset、sigdelset、sigismember。另一个高频场景是用sigprocmask在进程级别临时屏蔽一组信号。
屏蔽和忽略是两个完全不同的概念。忽略是告诉内核"收到这个信号就当没发生",屏蔽是告诉内核"先记账,等解除屏蔽后再处理"。我在写多进程协作程序时经常先屏蔽SIGCHLD,然后处理一系列子进程启动逻辑,全部启动完成后解除屏蔽,一次性回收所有退出状态。这样既不会漏掉某个子进程退出事件,也不需要在子进程启动的每个阶段都处理SIGCHLD。
典型用法:
c复制#include <signal.h>
#include <stdio.h>
#include <unistd.h>
int main(void) {
sigset_t set, oldset;
sigemptyset(&set);
sigaddset(&set, SIGCHLD);
// 屏蔽SIGCHLD
sigprocmask(SIG_BLOCK, &set, &oldset);
// 这里启动多个子进程
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程直接退出
_exit(0);
}
}
// 解除屏蔽,挂起的SIGCHLD信号会被立即处理
sigprocmask(SIG_SETMASK, &oldset, NULL);
pause();
return 0;
}
这种"先屏蔽、批量操作、再恢复"的思路,在需要保证某个时间段内信号不会打断关键操作的场景下特别好用。
4. 信号处理函数内的生死线:可重入函数与异步安全
4.1 为什么printf在信号处理函数里是危险操作
信号的异步特性决定了它会在任意指令处打断主流程,这种打断是整个排查链条里最微妙的一环。如果主流程正在执行printf,它内部已经锁定了stdout相关的缓冲区;此时SIGINT到来,handler也调用printf,就会去尝试获取同一个锁——结果就是程序直接卡死,或者更糟:缓冲区状态错乱,输出内容乱码。
问题的本质是"信号处理函数执行在进程的哪个上下文里":它没有独立栈,也没有独立的errno和缓冲区状态,它是在主流的线程栈上临时插入了一段执行代码。因此,所有依赖全局状态、锁、动态内存分配的函数,都不能保证在信号处理函数里安全调用。
4.2 异步信号安全函数清单(实战版)
POSIX定义了一整套async-signal-safe函数清单。常用的安全函数包括:read、write、open、close、dup、fcntl、lstat、fstat、stat、access、getpid、getppid、wait、waitpid、kill、raise、signal、sigaction、sigprocmask、sigpending、sigsuspend、_exit、_Exit、abort、sleep、usleep、execve、mmap、munmap等。
不安全函数包括:printf、fprintf、sprintf、snprintf、malloc、free、new、delete、pthread_*系列、time、localtime、gettimeofday(在某些内核版本上存在争议,但一般不建议用)、任何C++标准库容器操作。
我在实际项目里一直坚持几条铁律:
- 信号处理函数里只做一件事:设置一个volatile sig_atomic_t标志位,或者用write输出极简日志,然后立刻返回。
- 所有复杂的业务逻辑(重读配置、清理缓存、通知其他线程)一律放到主循环的某个检查点里做,handler只是"踢一脚"。
- 如果必须知道errno,先保存再恢复,避免干扰主流程的错误判断。
一个从我生产代码里摘出来的经典写法:
c复制#include <signal.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
static volatile sig_atomic_t g_signal_flag = 0;
static void set_flag_handler(int sig) {
g_signal_flag = sig;
}
int main(void) {
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = set_flag_handler;
sigemptyset(&sa.sa_mask);
sigaction(SIGUSR1, &sa, NULL);
while (1) {
if (g_signal_flag != 0) {
int current_errno = errno;
printf("Got signal: %d\n", g_signal_flag);
fflush(stdout);
errno = current_errno;
g_signal_flag = 0;
}
// 主逻辑
sleep(1);
}
return 0;
}
g_signal_flag的类型必须是volatile sig_atomic_t,目的是让编译器不对这个变量的读写做优化缓存,保证每次读到的都是最新值。这里加上了errno的保存与恢复,是因为printf和fflush这类操作可能修改errno。
4.3 volatile sig_atomic_t与编译器优化陷阱
给信号标志变量加volatile只是最低要求。还有一个细节是:sig_atomic_t是int型的typedef,它保证读写是原子的,但并不是说标志位越多越好。如果你在handler里做多标志位的组合判断和赋值,还是存在数据竞争的可能。在单线程进程里没有太大问题,但在多线程进程里,信号可能被发送给任意一个线程,处理函数跑在未知线程上,这个标志位就需要用真正的原子操作或锁来保护。
我踩过一个很深的坑:程序里用了一个非volatile的bool标志位作为"请求退出"的标志,主循环里检查它来决定是否安全退出,编译器开了-O2后,主循环里读取这个值被优化成了寄存器缓存,导致信号都处理完了,主循环却一直感受不到标志变化。最后加上volatile才恢复。这就是典型的编译器优化陷阱。
4.4 信号与线程的纠缠:pthread_sigmask和线程定向投递
多线程程序的信号处理比单进程复杂得多。每个线程有独立的signal mask,但handler是进程级共享的。默认情况下,一个信号发给进程后,内核会把信号投递给"任意一个没有屏蔽该信号的线程"。如果你希望某个特定线程处理某个信号,就需要用pthread_sigmask在主线程里屏蔽信号,然后让专门的处理线程用sigwait来接收。
sigwait的模型比handler干净得多:它把一个异步事件变成同步等待,完全避开了可重入性问题。回调函数里那些小心翼翼的限制,在sigwait方案里都不存在了:
c复制#include <signal.h>
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
void *signal_worker(void *arg) {
int sig;
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigaddset(&set, SIGTERM);
while (1) {
sigwait(&set, &sig);
printf("Worker thread got signal: %d\n", sig);
if (sig == SIGTERM) {
break;
}
}
return NULL;
}
int main(void) {
sigset_t set;
sigemptyset(&set);
sigaddset(&set, SIGUSR1);
sigaddset(&set, SIGTERM);
pthread_sigmask(SIG_BLOCK, &set, NULL);
pthread_t tid;
pthread_create(&tid, NULL, signal_worker, NULL);
pthread_join(tid, NULL);
return 0;
}
这种模式特别适合守护进程里的"配置重载"场景:主线程完全不接触信号,一个专门的线程阻塞在sigwait上,收到SIGHUP就执行热加载配置,收到SIGTERM就走优雅退出流程。可靠性比handler方案高一个数量级。
5. 用gdb从信号的视角拆解崩溃现场
5.1 崩溃时的第一现场:gdb的信号捕获机制
gdb本身就内置了信号捕获的机制。当被调试程序收到一个信号而gdb没有单独配置时,gdb会默认停下来,把控制权交给你。很多新手在这里会困惑:为什么程序跑着跑着突然"卡"在GDB提示符里?其实不是卡,是信号来了,gdb替你刹车了。
你可以在gdb里通过handle命令控制对每个信号的处理策略。三个核心选项:
- nostop:信号到来时不暂停程序,只通知
- noprint:不打印信号信息
- pass/nopass:是否把信号交给程序自己的handler处理
我最常用的配置是在调试段错误时,让gdb在发生SIGSEGV时停下来并打印详细信息:
code复制(gdb) handle SIGSEGV stop print pass
设置之后,程序发生段错误时gdb会在第一时间拦截,打印信号信息,并定位到触发指令所在的那一行。这个时候用bt看调用栈,能直接看到是哪个函数里出的事。
但注意一个关键问题:如果程序里已经用sigaction注册了SIGSEGV的handler,默认gdb会把信号交给程序处理,此时gdb不暂停。这会导致你看到一个诡异的现象——程序崩了,core也吐了,但gdb里没有断住。解决方案是显式加上stop:
code复制(gdb) handle SIGSEGV stop print nopass
nopass是关键,它让gdb永远不把SIGSEGV交给程序处理,全部由gdb接管。这样你才能在handler跑之前看到第一现场。
5.2 catch signal:主动在信号触发时设置断点
handle关注的是"信号来了以后gdb怎么做",而catch signal关注的是"当某个信号触发时直接进入断点状态"。区别在于catch更主动,它适合调试"信号可能没有被程序注册handler,但还是让程序状态异常"的场景。
实际使用示例:
code复制(gdb) catch signal SIGPIPE
(gdb) run
程序一收到SIGPIPE,gdb就停下来。此时bt看调用栈,能直接定位到是哪一行做了一个write/send操作导致触发SIGPIPE。
我第一次用这套方法定位SIGPIPE问题时,显示的是close了socket后,另一个线程还在往这个fd上write。如果不用catch,程序只会无声无息地死掉——因为SIGPIPE的默认行为就是直接终止进程,连core都不一定留下。有了catch signal SIGPIPE,问题现场一目了然。
5.3 SIGCHLD与僵尸进程:waitpid时机的调试视角
SIGCHLD信号在服务端程序里出场率极高。子进程退出时内核会发送SIGCHLD给父进程,而父进程的handler里通常是waitpid回收子进程状态。这个链路有一个经典调试场景:子进程退出了,但父进程没有收到任何信号。
很多人的第一反应是检查handler有没有注册,其实更常见的根因有这几个:
第一,子进程和父进程的进程组不同,信号可能被发给进程组而不是父进程。第二,SIGCHLD被父进程的sigprocmask屏蔽了,处于挂起状态。第三,handler里waitpid调用后,其他子进程也退出了,但信号合并导致只回收了一个。
我用gdb排查这类问题时,一般会在handler和父进程的主循环里都设置断点,然后触发子进程退出。如果断点只停在主循环不停在handler,就说明信号根本没进handler。接着查看pending信号集:
code复制(gdb) p sigpending(&set)
不过在gdb里直接调用sigpending比较麻烦,我通常直接看/proc里的信息。更快的做法是在gdb里info signals:
code复制(gdb) info signals
输出表格里会标注每个信号当前的默认处理、程序自设处理、是否被屏蔽。一眼看出SIGCHLD的状态,是处理中还是挂起还是被忽略。
5.4 SIGSEGV深挖:用siginfo_t和ucontext还原崩溃细节
回归文章开头的那个经典问题:栈不对,崩溃点魔幻。其实借助SA_SIGINFO和ucontext可以还原更多信息。
在sa_sigaction回调里,第三个参数是ucontext_t*,它是一个平台相关的结构体,包含信号触发瞬间的寄存器状态。你可以直接在handler里提取指令指针寄存器和栈指针寄存器。在x86_64平台上:
c复制#include <signal.h>
#include <stdio.h>
#include <ucontext.h>
static void segv_handler(int sig, siginfo_t *info, void *ctx) {
ucontext_t *uc = (ucontext_t *)ctx;
// x86_64下寄存器在uc_mcontext.gregs[REG_RIP]
unsigned long rip = uc->uc_mcontext.gregs[REG_RIP];
unsigned long rax = uc->uc_mcontext.gregs[REG_RAX];
fprintf(stderr, "SIGSEGV at RIP=0x%lx, RAX=0x%lx, addr=0x%lx\n",
rip, rax, (unsigned long)info->si_addr);
_exit(1);
}
这个输出里,rip是触发崩溃的机器指令地址,si_addr是访问的非法内存地址。如果si_addr是0x0,就是典型的空指针解引用;如果si_addr是一个很小的值如0x28,往往是访问了某个结构体里偏移量很大的字段,而这个结构体指针本身是NULL。这个判断技巧在解核心公司的崩溃日志时经常救场。
6. 三个高频场景的完整调试链路复盘
6.1 场景一:SIGPIPE让网络服务无故退出
现象:一个TCP服务器每过一段时间就悄无声息地退出,没有core,日志没有任何异常。
排查链路:
- 先确认是不是被外部kill。用dmesg查看内核日志,看有无"Killed"信息。如果没有,大概率是进程自杀或者信号默认行为。
- 打开gdb,设置catch signal SIGPIPE,复现问题。发现gdb在send()那行停下,证明信号来自往已关闭连接写入数据。
- 确认根因:客户端提前断开,服务端没感知到,还在往这个fd上write,触发SIGPIPE默认终止进程。
修复方案:在服务端的socket上设置信号忽略,或全局忽略SIGPIPE:
c复制signal(SIGPIPE, SIG_IGN);
但忽略信号有个副作用:如果程序把send的返回值当作可靠结果,忽略SIGPIPE后send会返回EPIPE错误,代码必须显式处理。我在生产代码里更推荐的做法是:每次send后用errno判断是否EPIPE,而不是全局忽略,这样可以精确区分"对端关闭"和"其他网络错误"。
6.2 场景二:SIGCHLD挂了但子进程变成僵尸
现象:父进程fork了很多子进程,子进程退出后父进程的SIGCHLD handler没有回收,系统里出现大量僵尸进程。
排查链路:
- 用ps -ef | grep defunct确认僵尸进程数量。
- 查看父进程代码,确认SIGCHLD handler是否注册。如果注册了但没生效,用gdb attach到父进程,查看handler状态。
- 用info signals查看SIGCHLD是否被屏蔽。
- 检查handler代码:如果handler里用的是wait而不是waitpid,在子进程较多时,handler只回收一个子进程就立即返回,后续的SIGCHLD又因标准信号不排队而完全丢失,导致剩余子进程一直不被回收。
修复方案:在handler里循环调用waitpid,直到没有子进程可回收:
c复制static void reap_children(int sig) {
int saved_errno = errno;
pid_t pid;
while ((pid = waitpid(-1, NULL, WNOHANG)) > 0) {
// 回收一个
}
errno = saved_errno;
}
这里用WNOHANG非阻塞等待,是为了不让handler卡住,毕竟它是在异步上下文里执行的。
6.3 场景三:SIGSEGV产生core文件但gdb打不开
现象:程序崩溃生成core文件,用gdb打开core文件时提示"no such file or directory"或"not in executable format"。
排查链路:
- 检查core文件是否完整——ls -l core,如果文件大小为0,说明系统配置了不落盘。
- 检查当前会话的ulimit -c,如果为0则crash时不会生成core。
- 用file core查看core文件的格式,确认是不是匹配当前架构。
修复方案:设置core file size上限:
bash复制ulimit -c unlimited
注意:这只对当前shell会话有效。正式服务器上建议在服务启动脚本里加上ulimit -c unlimited,并把core文件的命名规则改成带进程名和PID的形式:
bash复制echo '/tmp/core_%e_%p' > /proc/sys/kernel/core_pattern
没有core文件时,还可以用coredumpctl(systemd环境)查找和提取core:
bash复制coredumpctl list
coredumpctl info <pid>
这套方法在Linux系统上用起来比直接找core文件更可靠,因为systemd默认会把core存到自己的日志目录里。
7. 深入trap:信号处理中难以察觉的定时炸弹
信号调试与截获最让人头疼的地方在于:信号处理函数本身可能就是一个bug制造机。哪怕你的handler里只是简单设置了一个标志位,也可能在特定条件下出问题,而这些条件往往在低负载测试时根本不会暴露。
7.1 EINTR与慢速系统调用的重启策略
设置SA_RESTART能解决大部分EINTR问题,但并不意味着所有系统调用都能被自动重启。poll、epoll_wait、select这类调用在某些内核版本和flag组合下,即使设置了SA_RESTART也可能返回EINTR。尤其是在代码里手动用signal()而不是sigaction()注册handler时,SA_RESTART默认是未设置的,EINTR问题会频繁出现。
我处理这类问题有一个习惯:把所有可能返回EINTR的系统调用封装成一个带重试的helper。比如写一个safe_read,循环读直到读到数据或真正的错误:
c复制ssize_t safe_read(int fd, void *buf, size_t count) {
ssize_t ret;
do {
ret = read(fd, buf, count);
} while (ret == -1 && errno == EINTR);
return ret;
}
这个helper写好后,整个项目的读写路径都可以统一用它,避免在每个调用点单独处理EINTR。
7.2 SA_NODEFER与信号递归触发的栈灾难
处理SIGSEGV时默认同类型信号在handler里是被屏蔽的,这是好事。但如果你手贱设置了SA_NODEFER,问题就来了:在handler执行过程中,如果再次触发SIGSEGV,handler会再次被调用,最终导致栈溢出,然后无限递归,程序直接卡死。
更隐蔽的场景是:你在SIGSEGV handler里调用了不可重入函数,这个函数内部又访问了非法内存,触发了第二次SIGSEGV——由于当前信号已经被屏蔽,第二次信号会挂起,但handler并不会重复执行。这个状态其实很尴尬:你的handler已经部分执行,程序处于不可知状态。所以我在生产项目里从不把SIGSEGV注册成handler,而是统一走core dump+gdb分析路线。对于任何内存类的致命错误,让内核生成core文件,然后用调试器分析,远比自己写handler处理可靠得多。
7.3 信号处理中的dbg与timestamp问题
调试信号时容易遇到的一个诡异情况是:程序在gdb下跑得好好的,裸跑就崩。这种差异经常被归咎于"时序变了",但合理的解释是gdb默认屏蔽了一些信号(比如SIGSEGV、SIGILL),或者修改了信号处理的时机。如果你在gdb下用handle命令显式让它不拦截某个信号,才能复现裸跑时的问题。
另一个细节点是:handler里如果用time()或gettimeofday()取时间记录日志,这些函数都不是async-signal-safe(time在glibc文档里是safe的,但localtime等格式化函数完全不行)。如果想要时间戳,直接在handler里用write输出内核时间戳是不现实的,比较可靠的替代方案是:在主循环里周期性记录当前时间,handler只设置标志位,主循环检测到标志位后用安全的方式写日志。
7.4 用sigaltstack给信号处理函数一个独立栈
前面提到handler跑在进程的栈上,这意味着如果栈空间本身已经耗尽(比如递归过深),信号到达时handler根本无法执行,因为连handler需要的栈空间都没有了。这种情况有一个专门的信号:SIGSEGV本身可能就是栈溢出导致的,此时handler再往栈上压东西就直接双击崩溃。
sigaltstack可以解决这个问题。它允许信号处理函数在一个独立的、预分配的栈上执行:
c复制#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static char alt_stack[SIGSTKSZ];
static void segv_handler(int sig) {
// 现在我们有独立栈了,可以做一些简单的恢复操作
write(STDERR_FILENO, "Segfault on alt stack.\n", 23);
_exit(1);
}
int main(void) {
stack_t ss;
ss.ss_sp = alt_stack;
ss.ss_size = sizeof(alt_stack);
ss.ss_flags = 0;
sigaltstack(&ss, NULL);
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sa.sa_handler = segv_handler;
sa.sa_flags = SA_ONSTACK; // 关键
sigaction(SIGSEGV, &sa, NULL);
// 主动触发栈溢出
volatile int arr[1024 * 1024 * 1024];
arr[0] = 1;
return 0;
}
SA_ONSTACK告诉内核,处理这个信号时切换到备用栈。有了这个机制,即使主栈已经爆了,handler也能正常执行。我在调试"递归深度导致崩溃"的问题时,这个技巧给了我很大的帮助——崩在递归深处的段错误本来很难看到完整栈信息,有了备用栈,至少能在handler里输出一个可靠的日志,或者转储一份核心信息,让后续的调试有迹可循。
8. 我沉淀的一套信号调试自查清单
经历过多次线上事故后,我把信号相关的排障流程固定成了一套清单,每次遇到异常退出都按这个顺序过一遍:
- 确认进程退出方式:是被信号终止、被kill、还是内部异常。优先看dmesg,再查systemd journal。
- 确认是哪个信号:通过core文件的信号编号,或者程序自己日志里留下的信号记录。
- 确认信号来源:是内核发的(SIGSEGV、SIGILL、SIGBUS),还是用户态进程发的(SIGTERM、SIGUSR1)。
- 确认handler是否注册、是否正确:用gdb的info signals,或者代码审查。
- 确认handler执行时是否碰到安全红线:可重入性、锁、内存分配。
- 确认有没有被屏蔽的信号:用sigprocmask的逻辑,结合/proc/
/status里的SigBlk位图。 - 还原触发信号前的现场:用ucontext获取寄存器、指针地址,结合源码定位。
- 修复后再构造同类场景做压力验证,确认同一问题不再复发。
这套清单我贴在了工位显示器上,排查问题时按顺序打勾,很少漏掉关键线索。
信号机制是整个Linux进程控制里最接近"玄学"的部分,但只要把信号从产生、投递、处理到返回的完整路径理解了,很多看似无解的崩溃其实都能在几分钟内定位到根因。我写这些,是想让这篇指南成为你排查信号问题时的一本地图——它不替你走完每一步,但能让你清楚知道自己现在站在哪里、下一步该往哪个方向走。
