
搞Linux开发信号是绕不开的东西。按下CtrlC派发SIGINT、后台进程被kill掉收到SIGTERM、访问野指针直接给一个SIGSEGV——这些场景大家都见过但很少有人认为地追问一个问题信号处理函数真正执行的时候它用的是哪一块栈空间是在原本的主调用栈上跑还是内核会单独划一条栈给信号用这个问题的答案就是Linux信号栈signal stack也被称为altstack存在的全部理由。这篇文章准备先把信号机制串一遍再把信号栈的原理、配置方式、内核实现和常见坑逐个捋清楚适合刚接触Linux信号编程的初学者也适合写过不少信号处理、却没深究过栈细节的工程师。1. 先把信号机制讲透处理函数默认跑在哪块栈上1.1 信号处理函数的执行流程内核替你伪造了一层调用很多人以为信号处理函数是操作系统调用的所以它应该跑在内核栈上。这个理解是错的。信号处理函数的代码在用户态执行和普通函数没有本质区别它用的栈也是用户态的栈。具体流程是进程或者线程收到信号后内核并不会在中断上下文里直接跑到用户态的handler里而是等当前任务从内核态返回用户态之前检查一下有没有pending信号。如果有内核会在用户栈上构造一个signal frame信号帧把当前寄存器状态、信号编号、siginfo等信息都保存进去再修改返回地址让用户态代码跳到处理器函数。这个过程看起来非常像一次普通函数调用但入口和出口都埋了机关。入口是内核修好的返回地址出口是signal frame里藏着的sigreturn系统调用序列。handler执行完会走sigreturn这条特殊路径恢复之前保存的上下文然后程序从被打断的指令继续往下跑。所以从逻辑上讲默认情况下handler和普通函数共享同一条用户栈内核只是在一条栈上额外压了一整帧上下文而已。1.2 主栈是有限资源溢出时信号处理会雪上加霜用户栈并不是无限的。大多数Linux进程的主线程栈受RLIMIT_STACK限制默认通常是8MB而且栈向低地址方向动态增长。每个线程也有自己独立的用户栈大小可以在创建线程时通过pthread_attr_setstacksize配置。无论哪种情况主栈都有明确的边界一旦栈指针跑到未映射的地址区域就会触发缺页异常进而产生SIGSEGV。问题在于如果程序是因为栈溢出而触发的SIGSEGV这时候栈指针已经指向非法地址内核再想往这条栈上压一个signal frame就会二次触发异常。结果有两个可能要么当前信号递送失败进程直接被干掉要么在错误路径上又触发了新的SIGSEGV形成一种双重故障连core dump都未必留得全。我刚开始接触信号编程时也天真地以为只要注册了一个SIGSEGV handler程序崩溃就总能进来打印日志。实际上在纯栈溢出场景下没有独立信号栈的话这个handler很可能压根没机会执行。1.3 信号栈的引入一条专门给信号处理的独立栈为了解决主栈不可用handler还得有地方跑的矛盾POSIX标准很早就提供了一套机制——替代信号栈alternate signal stack也就是我们常说的信号栈。它的设计思路很简单进程可以提前向内核声明一块独立的内存区域专门用于信号处理函数的栈空间。通过sigaltstack()系统调用注册然后给具体的信号处理函数打上SA_ONSTACK标志内核在递送这些信号时就会把执行环境整体切换到替代栈上而不是使用当前线程的主栈。打个比方主栈是日常起居的房间信号栈是防火避难室。平时你可能完全用不到避难室但一旦发生火灾避难室能保证你有一块绝对安全的地方做善后处理。信号栈的注册不改变信号处理逻辑本身它只改变处理函数运行时使用的栈指针但就这么一个细小的差别在栈溢出类崩溃场景里就是生与死的距离。2. 信号栈到底能解决什么问题三个典型使用场景2.1 栈溢出时的最后防线无限递归的崩溃现场这应该是最直观的场景。程序里某个函数递归没有出口栈帧一层层叠上去最后把栈空间耗尽触发SIGSEGV。没有信号栈时内核尝试递送SIGSEGV但当前栈指针已经跑到非法区域signal frame根本构造不起来。有了信号栈与SA_ONSTACK标志内核发现主栈指针不对就会把处理环境搬到替代栈上。handler照常执行至少能打印崩溃现场的寄存器信息、尝试写日志、把关键状态吐出来。我参与过一个缓存服务的问题定位那个服务偶尔在高峰期崩溃但日志几乎是空的核心原因就是栈溢出后handler起不来。后来加上信号栈马上就能打出当时RSP/RIP问题定位从靠猜变成了直接看答案。这行配置平时完全无感但在灾难现场就是救命的。2.2 捕捉崩溃现场在信号栈里拿到关键诊断信息第二个场景在线上服务里更常见。很多服务会在进程崩溃前注册SIGSEGV、SIGBUS的兜底捕获函数目的是输出一段崩溃现场、备份关键状态或者做优雅退出。这种思路本身没错但不配合信号栈就有一个致命缺陷如果崩溃原因是栈溢出handler自己会被压死在主栈上导致既没抓到日志还把进程退出的机会一起带走了。如果handler注册时带了SA_ONSTACK同时进程提前声明了替代栈那情况就完全不同。handler可以在干净的空间里读取ucontext参数拿到崩溃瞬间的指令指针、栈指针甚至可以通过siginfo_t拿到触发信号的具体地址。这些信息在排查段错误时价值极高因为大多数情况下你只能从core dump里慢慢挖现在崩溃当场就有了。2.3 递归与私有栈隔离给handler一个宽裕的自有空间第三类场景相对容易被忽略但真实存在。有些业务递归很深虽然不至于完全溢出但每次调用只留下很少的剩余栈空间。这种情况下内核在主栈上压一个signal frame剩余空间就更局促了handler里随便声明一个较大的局部数组、或者一调用函数就把栈撑爆。替代栈的内存是由用户自己分配并通过sigaltstack()注册的大小由你说了算和主栈互不干扰。这让handler有了一个完全独立、空间宽裕的执行环境无论主栈状态如何handler都有自己能站稳的一亩三分地。这种隔离设计在容错、监控类程序里非常实用。3. 手把手实操五分钟配置一套信号栈3.1 三个核心API一次说清要使用信号栈只需要掌握三样东西stack_t结构体、sigaltstack()系统调用、sigaction()里的SA_ONSTACK标志。API/结构体作用stack_t描述一块内存区域包含起始地址、大小、状态标志sigaltstack()注册或查询当前线程的替代信号栈SA_ONSTACK在注册信号处理函数时声明表示该信号在替代栈上执行stack_t的结构定义很简洁typedef struct { void *ss_sp; /* 栈区域起始地址 */ int ss_flags; /* SS_DISABLE 或 0 */ size_t ss_size; /* 栈区域大小 */ } stack_t;调用sigaltstack()时ss_flags通常填0表示启用替代栈填SS_DISABLE表示禁用。sigaltstack()签名int sigaltstack(const struct sigaltstack *ss, struct sigaltstack *old_ss);ss是新的配置old_ss用于查询旧配置二者都不是必须的。但要注意只有sigaction()注册信号处理函数时设置SA_ONSTACK内核才会在对这个信号递送时走替代栈。如果只设了sigaltstack()却忘了SA_ONSTACK替代栈形同虚设这是个极其容易踩的坑。3.2 一个可运行的完整示例带崩溃信息打印这里给一个我自己调试用的完整示例它演示了如何配置独立信号栈、如何在SIGSEGV处理器里打印崩溃现场信息、以及如何制造一次栈溢出。代码量不大但覆盖了所有关键点#define _GNU_SOURCE #include stdio.h #include stdlib.h #include string.h #include signal.h #include unistd.h #include ucontext.h #define ALTSTACK_SIZE (64 * 1024) /* 三参数版本的信号处理函数能拿到 siginfo 和 ucontext */ static void segv_handler(int sig, siginfo_t *si, void *ctx) { ucontext_t *uc (ucontext_t *)ctx; char msg[256]; int len; len snprintf(msg, sizeof(msg), [altstack] Caught signal %d, si_code%d, fault addr%p\n, sig, si-si_code, si-si_addr); write(STDERR_FILENO, msg, (size_t)len); #if defined(__x86_64__) len snprintf(msg, sizeof(msg), [altstack] RIP0x%llx RSP0x%llx\n, (unsigned long long)uc-uc_mcontext.gregs[REG_RIP], (unsigned long long)uc-uc_mcontext.gregs[REG_RSP]); write(STDERR_FILENO, msg, (size_t)len); #endif len snprintf(msg, sizeof(msg), [altstack] handler local addr%p (should be inside altstack)\n, (void *)msg); write(STDERR_FILENO, msg, (size_t)len); _exit(128 sig); } /* 无限递归主动制造栈溢出 */ static void overflow_stack(void) { volatile char buf[1024]; (void)buf; overflow_stack(); } int main(void) { stack_t ss; struct sigaction sa; /* 1. 分配一块独立的内存作为替代信号栈 */ ss.ss_sp malloc(ALTSTACK_SIZE); if (!ss.ss_sp) { perror(malloc); return 1; } ss.ss_size ALTSTACK_SIZE; ss.ss_flags 0; if (sigaltstack(ss, NULL) -1) { perror(sigaltstack); return 1; } /* 2. 注册 SIGSEGV 处理器关键SA_ONSTACK */ memset(sa, 0, sizeof(sa)); sa.sa_sigaction segv_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_SIGINFO | SA_ONSTACK; if (sigaction(SIGSEGV, sa, NULL) -1) { perror(sigaction); return 1; } fprintf(stderr, Press Enter to trigger stack overflow...\n); getchar(); /* 3. 触发栈溢出 */ overflow_stack(); return 0; }代码逻辑很直接。第一步用malloc申请64KB内存作为替代栈第二步注册SIGSEGV处理函数并带上SA_ONSTACK和SA_SIGINFO第三步喂一次栈溢出。编译运行gcc -O0 -o altstack_demo altstack_demo.c ./altstack_demo按回车之后程序会进入无限递归栈一路爆掉最终触发SIGSEGV。这时候handler能在替代栈上正常工作打印出崩溃现场的RIP和RSP。3.3 验证处理函数确实跑在独立栈上很多人配置完了总会怀疑替代栈真的生效了吗方法很简单——在信号处理函数里声明一个局部变量打印它的地址再和主函数里某个局部变量的地址对比。x86-64下栈向下生长两个地址如果相差巨大主栈通常在高地址区域而malloc分配的内存在堆或mmap区域并且handler内变量的地址落在你注册的替代栈内存附近就说明切换成功了。上面示例里我故意打印了handler局部变量msg的地址结果通常是一个离malloc(ALTSTACK_SIZE)返回地址不远的值。如果在没设SA_ONSTACK的情况下去测试你会看到msg的地址落在主栈附近这就是没生效的铁证。我调试过好几台机器这个验证方法百试百灵。4. 深入内核信号栈是怎么被内核用起来的4.1 每个线程都有独立的sas_ss字段既然说信号栈绕不开内核实现。首先要知道替代栈不是进程级的而是线程级的。在内核的task_struct里每个线程都保存着属于自己的替代栈信息核心字段就三个struct task_struct { ... unsigned long sas_ss_sp; /* 替代栈基地址 */ size_t sas_ss_size; /* 替代栈大小 */ unsigned int sas_ss_flags; /* 替代栈状态标志 */ ... };用户调用sigaltstack()时最终进入内核的do_sigaltstack()函数它负责校验用户空间传进来的stack_t参数然后把ss_sp、ss_size、ss_flags分别写入当前线程的sas_ss_sp、sas_ss_size、sas_ss_flags。校验逻辑包括栈大小不能小于MINSIGSTKSZx86-64上一般为2048字节栈地址不能是非法地址等等。校验没过返回EINVAL。有一个细节很容易让人困惑用户传进去的是内存区域的起始地址也就是最低地址而x86-64上栈向低地址生长真正的栈顶其实在区域末尾。所以内核在切换时计算出来的初始栈指针是sas_ss_sp sas_ss_size再减去signal frame和一些对齐填充得到最终的RSP。这个过程不需要用户操心只要内存块有效、大小足够即可。4.2 get_sigframe选择主栈还是替代栈的决策点信号递送过程中体系结构相关的代码会调用get_sigframe()来决定signal frame构造在哪条栈上。核心逻辑大概是这样的伪代码static void __user *get_sigframe(struct ksignal *ksig, struct pt_regs *regs, size_t frame_size) { unsigned long sp regs-sp; /* 如果 handler 要求使用替代栈并且当前线程注册了替代栈 */ if (ksig-ka.sa.sa_flags SA_ONSTACK) { /* sas_ss_flags(sp) 0 表示“信号栈可用且当前不在其上运行” */ if (sas_ss_flags(sp) 0) sp current-sas_ss_sp current-sas_ss_size; } /* 再往下做对齐、预留 frame_size 空间并返回 */ ... return (void __user *)sp; }这里有个值得注意的判断条件sas_ss_flags(sp) 0。它保证的是如果当前线程已经在替代栈上执行信号处理函数了新到的信号就不会再往替代栈上叠——同一个栈上再压一个信号帧很容易把替代栈也压爆。这个判断在内核里很关键工程上是很有启发的防递归保护设计。4.3 x86与ARM在信号栈处理上的差异Linux对信号栈的支持是架构无关的但具体实现有细节差异。x86-64上signal frame是一个rt_sigframe结构体里面保存了基础的寄存器现场包括通用寄存器、FPU状态等。get_sigframe()算好栈指针后__setup_rt_frame()按布局填充这个结构体把handler入口地址写到返回地址位置让用户态跳转过去。ARM64上同样是rt_sigframe但栈对齐要求严格得多通常要求16字节对齐FP/SIMD上下文的保存策略也随内核配置变化。同时ARM64对信号栈的选择逻辑基本一致但寄存器现场布局差异很大。如果你需要写跨架构的崩溃捕获代码不能依赖特定架构的ucontext布局尽量使用标准字段。5. 实战避坑指南信号栈用错的代价5.1 常见问题快速排查表现象可能原因解决办法handler还是跑在主栈上忘记在sigaction中设置SA_ONSTACK检查sa_flags补上SA_ONSTACKsigaltstack()返回EINVALss_size太小或ss_sp不是有效地址使用malloc分配大小至少SIGSTKSZ设置成功后handler仍崩溃替代栈本身空间不够加大分配建议64KB以上多线程程序信号栈“时灵时不灵”每个线程需要单独调用sigaltstack()在线程入口统一注册进程退出时内存未释放漏了free先SS_DISABLE再释放内存替代栈上核心解码异常signal frame与ucontext布局不匹配避免硬编码架构字段用标准宏5.2 线程与信号栈的关系一个经常搞混的点很多人以为信号栈是进程级的设一次全进程都能用。这是误解。sigaltstack()操作的是当前线程的task_struct所以多线程程序里每个线程要使用替代栈都得在自身执行上下文中调用sigaltstack()。一个线程设置了替代栈其他线程是蹭不到的因为sas_ss_sp/sas_ss_size/sas_ss_flags都保存在单条线程自己的结构里。反过来的坑也一样线程A设置替代栈后退出但如果线程B还活着并且也注册了这个内存地址线程B退出时去释放这块内存程序很可能踩空。这种跨线程共享替代栈内存的做法我建议直接禁止省下来的不过是几十KB内存失去的却是崩溃现场。实战上如果要在多线程服务里统一启用信号栈最好写一个工具函数在每个线程入口调用一次sigaltstack()内存由线程自己管理退出时再自行禁用与释放。5.3 内存管理与处理函数内的二次故障替代栈通常用malloc申请但你不一定非要等到进程退出才考虑释放。规范做法是线程准备退出时先调用sigaltstack()把ss_flags设为SS_DISABLE禁用替代栈再free内存。这里有个细节禁用替代栈时如果恰好有另一个信号处理函数正在该栈上运行直接释放内存会踩到使用中的栈帧。处理方式一般是在信号处理函数里不释放或者在禁用后加一个线程间的同步点确保没有handler在跑。另一个容易忽略的问题是二次故障。handler本身也是用户代码它同样可能触发段错误、非法指令等。如果handler还没执行完新信号又被递送内核会尝试再次压栈。这时替代栈空间如果太小就可能把替代栈也压爆。我建议替代栈大小至少给64KB如果handler里有比较重的诊断逻辑比如dump内存、写文件甚至上百KB都不算多。这条路是保命用的不必太抠。6. 我在实际项目中用信号栈的一点体会最后分享一次真实经历。前阵子排查一个线上服务偶发崩溃日志只显示SIGSEGV崩溃点每次都不一样开了core dump也看不出规律。后来怀疑是栈溢出于是在SIGSEGV处理器里加上SA_ONSTACK并且打印了崩溃时的RSP和RIP。结果立刻发现RSP已经跑到栈底边界之外很远确认就是某个插件递归过深把栈吃光了。没有信号栈时这种场景基本只能靠反复压测撞运气有了它一次就能把现场拍下来。所以我现在写任何带信号处理功能的C/C程序习惯性都会做两件事注册SIGSEGV/SIGBUS捕获函数时顺手加上SA_ONSTACK同时在线程初始化时调用一次sigaltstack分配独立栈。平时这几行代码毫无存在感但真到崩溃现场它就是我手里最可靠的一张底牌。信号栈这个东西用不上最好一旦用上你会庆幸自己提前把这条逃生通道挖好了。