ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Linux信号机制底层原理:从kill到setup_rt_frame完整路径

Linux信号机制底层原理:从kill到setup_rt_frame完整路径 很多年后我依然记得第一次被Linux信号问题折磨的场景一个服务进程在深夜毫无征兆地消失了日志里连一行“Fatal”都没留下只剩下systemd重启它之后的一行记录。那时候我以为信号就是“软中断”以为signal(SIGINT, handler)就能捕获一切。直到我把SIGPIPE、SIGCHLD、EINTR一个个摸清楚又一路追到内核源码的kernel/signal.c才明白信号这个机制远不是“软中断”三个字能概括的。这篇文章想带你走一条完整的底层路径从用户态调用kill()开始进入内核态之后信号如何被存储、如何被延迟递送、最终又是怎么搭好一个“跳板”回到用户态执行你的handler。适合那些想彻底搞懂signal/rt_sigaction真实语义、排查程序莫名退出或者EINTR问题的朋友也适合准备Linux内核方向面试的人。1. 我们以为的“软中断”内核里其实是另一套流程1.1 从glibc的kill()到sys_kill一段被很多人当作黑盒的路径很多文章一上来就讲task_struct、讲signal_struct但很少有人说清楚你写的kill(pid, SIGUSR1)内核到底是从哪一行代码开始处理的。用户态这边glibc的kill()是个系统调用包装函数。它把系统调用号SYS_kill放进寄存器执行syscall指令陷入内核。x86_64架构下kill的系统调用号是62但我不建议你死记编号因为架构不同编号就不同arm64、riscv都不一样。重要的是这条路径用户态 kill(pid, sig) - syscall 指令进入内核 - entry_SYSCALL_64 (x86_64入口) - __x64_sys_kill - kill_something_info - group_send_sig_info - __send_signal__x64_sys_kill只是架构相关的系统调用壳子真正的逻辑在kernel/signal.c里。kill_something_info会根据pid参数决定把信号送给谁pid 0发送给指定pid对应的线程组pid 0发送给调用者所在进程组的所有成员pid -1发送给系统中所有有权限接收的进程但要跳过调用者自身、跳过PID 1这样的守护进程pid -1发送给进程组-pid。这一步很多人容易忽略但它决定了“你以为发给一个进程实际可能发给了整个进程组”。我自己就踩过这个坑写测试程序时用kill(-pid, SIGTERM)本意是发给某个子进程结果把同一进程组的服务全干掉了。1.2 check_kill_permission内核凭什么放行这次发送进入group_send_sig_info之后内核并不是直接排队而是先做权限检查核心函数是check_kill_permission。权限判断的大致逻辑是这样发送者和目标进程的credential主要是euid/uid是否匹配或者发送者和目标进程是否有ptrace关系。调试器能向被调试进程发信号靠的就是这条ptrace关系。普通用户通常是没法向别人的进程发信号的权限不足时kill()返回EPERM。这里还有一个特殊保护内核不会允许普通进程随便向init进程或者其他受保护进程发送致命信号。容器场景下容器里的PID 1也有类似的保护逻辑。我见过不少人写清理脚本时用kill -9 1在宿主机上直接把自己搞崩溃——这个细节值得反复强调。权限检查通过之后__send_signal才开始干正事分配信号队列节点、把信号挂到目标线程组的pending链上然后在合适的时机唤醒目标线程。2. 信号暂存区大揭秘位图、链表和task_struct里的三张“滤镜”2.1 sigset_t位图与标准信号的“丢信号”宿命信号进入内核之后并不会立刻跑去打断进程而是先存在task_struct关联的数据结构里。每个线程的task_struct里有pending字段线程组共享的signal_struct里还有一份shared_pending。前者的作用是支持pthread_kill这种线程定向信号后者承接kill()这种进程级信号。存储的核心数据结构之一是sigset_t本质上就是一个64位的位图。编号为n的信号在位图的第n-1位上。sigaddset、sigdelset、sigismember这些用户态API最终都是对一串位做|、、~运算。这个位图设计直接解释了标准信号的经典行为合并或者说丢信号。内核里legacy_queue()函数会判断如果当前要发送的是标准信号编号小于SIGRTMIN即小于32而且位图上这一位已经置了那新到的信号直接放弃不再分配新的队列节点。你连续给一个进程发两次SIGUSR1如果第一次还在pending没有递送第二次就丢了。这正是“标准信号不可靠”的底层原因。不是网络丢包也不是调度器乱来而是内核在设计上就用位图做了去重。2.2 实时信号为什么需要sigqueue链表标准信号用位图就够了但实时信号编号32到64要求“每个实例独立不能合并”位图根本表达不了“同一个信号来了三次”所以内核引入了struct sigqueue链表。每次发送实时信号__send_signal都会分配一个新的sigqueue节点挂到pending链表尾部。递送时按编号从小到大、同编号按先进先出顺序取。这也是SIGRTMIN这类信号在实时性场景赖以生存的保证。排队是有代价的所以内核设了RLIMIT_SIGPENDING限制排队信号的数量。当排队信号数超过限制时__sigqueue_alloc可能返回失败典型表现就是某些被阻塞的实时信号在等待过程中丢失sigqueue()返回EAGAIN。标准信号和实时信号的差异我整理成了表对比项标准信号1~31实时信号32~64是否合并是位图去重否每个实例独立排队排队能力不排队只置位按sigqueue链表排队递送顺序无严格顺序按位图扫描编号小的优先同编号FIFO典型用途SIGINT、SIGTERM、SIGKILL自定义实时通信可携带si_value数据2.3 blocked、ignored、自定义动作三张“滤镜”同时生效除了pending队列信号相关的还有两张关键掩码和一张动作表。blocked掩码表示当前线程屏蔽哪些信号sighand_struct里的action[]数组则记录每个信号的处置方式SIG_IGN、SIG_DFL还是自定义handler。三者的关系可以这么理解blocked只是“暂时别递送”不是丢弃。解除屏蔽后如果信号还在pending里会立刻触发递送SIG_IGN是“直接丢弃”根本不会进pending队列自定义handler则意味着“先排队等递送时跳到用户态执行”。必须记住的硬规则是SIGKILL和SIGSTOP既不能被block也不能被忽略更不能被捕获。无论你怎么折腾这两个信号该发生的还是会发生。SIGKILL对应进程立刻终止SIGSTOP对应进程立刻暂停。3. 内核为什么不急着执行你的handler递送时机的真哲学3.1 返回用户态的那道“门闸”TIF_SIGPENDING与检查点很多初学者的最大误解是信号一到内核handler就应该马上跑。事实完全不是这样。Linux的信号递送是延后的延后到CPU即将从内核态返回用户态的那一瞬间。为什么最直接的原因是信号处理函数是用户态代码内核态里没法执行而且内核经常拿着锁、开着中断屏障运行如果信号随时插入整个内核就没法保证原子性了。所以内核的设计哲学是信号只负责“标记”和“唤醒”不负责“打断”。这个标记就是线程上的TIF_SIGPENDING标志位。__send_signal在合适时机调用signal_wake_up给选中的线程设置TIF_SIGPENDING并唤醒它如果线程正在用户态运行则利用IPI触发一次调度让它尽快走进内核再返回用户态以此触发信号检查。当进程从系统调用、中断或异常返回用户态时内核在exit_to_user_mode_loop这类入口会检查TIF_SIGPENDING如果置位则调用arch_do_signal_or_restart进而执行do_signal和get_signal。整个过程是统一收口的不区分你是被定时器中断打断还是从read()系统调用里醒来。3.2 被blocked的信号只是“推迟”不是“消失”很多人在sigprocmask上犯迷糊我用SIG_BLOCK屏蔽了SIGTERM为什么过一会儿取消屏蔽进程还是收到了SIGTERM因为blocked掩码只是把递送延后了。信号在pending队列里一直躺着一旦解除屏蔽内核扫描blocked位图时发现不再屏蔽信号就在下一次返回用户态时被递送。这也解释了为什么sigwait、sigwaitinfo这类同步等待信号函数要求先把信号block掉——block的动作本质上是把信号从“异步递送通道”挪到“同步等待通道”。但要注意pending队列不是无限的。标准信号靠位图合并还好实时信号如果长时间被block并且高频发送排队数量可能触及RLIMIT_SIGPENDING导致部分信号被丢弃。3.3 睡眠中的进程怎么被信号叫醒进程在等待socket数据、等待条件变量、等待定时器时大概率处于TASK_INTERRUPTIBLE可中断睡眠。信号发过来时signal_wake_up会设置TIF_SIGPENDING并调用wake_up_state把线程从睡眠队列里拎出来。线程被唤醒后返回用户态第一件事就是检查信号进入handler处理。所以你会看到这样的现象进程本来阻塞在read()上收到SIGUSR1后read()返回了-1errno是EINTR。这不是内核bug而是“信号打断了慢系统调用”的标准语义。如果是TASK_UNINTERRUPTIBLE睡眠例如某些磁盘IO场景进程通常不会被信号唤醒。这也是为什么ps看到进程状态是D时拿SIGKILL也杀不掉——内核根本不给它递送信号的机会。真正的解决方向是修复触发D状态的底层驱动或IO问题而不是反复kill。4. 一张精密的跳板setup_rt_frame、默认动作与信号执行回路4.1 默认动作链当信号没有用户态handler时内核替你做了什么如果目标信号没有注册自定义handlerget_signal()会走默认动作分支。默认动作大致分五类终止进程SIGTERM、SIGINT、SIGHUP这类终止并core dumpSIGSEGV、SIGBUS、SIGABRT这类停止进程SIGSTOP、SIGTSTP这类继续进程SIGCONT忽略SIGCHLD默认就是忽略。以最常见的SIGSEGV为例默认动作不只是“退出”而是触发core dump机制。内核会根据/proc/sys/kernel/core_pattern的配置生成core文件然后走do_group_exit终止整个线程组。这也是为什么空指针解引用会让整个进程瞬间消失而不是单个线程退场。SIGCHLD的默认忽略值得一提虽然它“忽略”了但内核仍会通过release_task等一系列机制回收子进程状态。如果你不希望子进程变成僵尸又不想自己写waitpid可以用signal(SIGCHLD, SIG_IGN)配合SA_NOCLDWAIT语义让内核直接丢弃。很多服务器代码里就是这么干的。4.2 setup_rt_frame在内核栈和用户栈之间搭桥当信号有自定义handler时内核进入setup_rt_frame。这是整个信号机制最精妙的一步。内核要做的事情用大白话说就是伪造一个用户态调用现场。它把当前用户态的寄存器、旧的信号掩码、siginfo等信息打包进一个叫rt_sigframe的结构放到用户态栈上然后把用户态的指令指针改成handler地址把参数寄存器设置成信号编号、siginfo指针、ucontext指针。x86_64上大致是这样/* 结构示意实际实现见 arch/x86/kernel/signal.c */ struct rt_sigframe { char *pretcode; /* 返回地址指向__kernel_rt_sigreturn */ struct ucontext uc; /* 保存的寄存器上下文和旧sigmask */ struct siginfo info; /* 给handler用的信号信息 */ /* ... 还有浮点保存区等 */ };内核设置完寄存器后handler就开始执行了。等handler执行完ret会跳到栈上预埋的返回地址。这个返回地址不是普通代码而是__kernel_rt_sigreturn它最终会执行rt_sigreturn系统调用。内核收到这个系统调用后从用户栈上的rt_sigframe里恢复全部寄存器、恢复旧的sigmask于是程序就像从来没被信号打断一样继续跑。这也是为什么signal handler执行期间同种信号默认会被自动屏蔽因为setup_rt_frame保存的旧掩码里没有这个信号内核在handler运行期间会临时压上这个信号直到rt_sigreturn才恢复。如果你想允许重入就要用SA_NODEFER。4.3 SA_RESTART、SA_ONSTACK、SA_SIGINFO三个flag的真实行为sigaction的flag不只是语法糖每一个都对应内核路径上的分支选择。SA_RESTART解决的是系统调用被打断后的行为。进程阻塞在read()上来了信号handler执行完按默认行为read()返回EINTR。但如果handler注册时带了SA_RESTART内核会尝试让这个系统调用重新执行。底层实现里内核会利用进程的restart_block保存原系统调用的参数并在rt_sigreturn之后重新发起一次调用。需要强调的是并不是所有系统调用都买SA_RESTART的账网络编程里的accept()、connect()等接口在不同内核版本下行为有差异稳妥做法是显式处理EINTR不能全靠flag。SA_ONSTACK则是配合sigaltstack使用的。进程栈溢出时比如递归过深导致栈顶撞到guard page内核想搭rt_sigframe却没有栈空间可用。这时如果程序提前用sigaltstack准备了一块备用栈内核会把rt_sigframe放在备用栈上handler得以运行。很多生产级的栈溢出检测、崩溃上报工具就是靠这个机制活下来的。SA_SIGINFO则让handler变成三参数版本void handler(int sig, siginfo_t *info, void *ucontext);内核在setup_rt_frame时会把info填充得更详细信号的si_code、来源进程、来源uid、SIGSEGV的si_addr、实时信号附带的si_value等。这也是sigqueue发实时信号能带数据的底层原因。5. 多线程、调试器与信号线程组里到底谁抢到了信号5.1 kill、pthread_kill、tgkill三种发送方式的落点差异多线程程序里信号路由是最容易翻车的地方。先看发送端kill(pid, sig)进程级信号进入线程组共享的shared_pending由“某个线程”最终处理pthread_kill(tid, sig)内部调用tgkill进入指定线程的私有pending只针对那一个线程raise(sig)等价于发给当前线程自己通常也是走tgkill路径。所以给一个多线程进程发SIGTERM你无法预先确定哪个线程会执行handler。最坏情况下如果handler里有非线程安全的全局状态就会出现诡异竞态。5.2 complete_signal的线程挑选逻辑进程级信号进入shared_pending后complete_signal会决定唤醒哪个线程。逻辑大致是优先选择当前正在运行的线程如果当前线程被block了该信号就遍历线程组找第一个“没block这个信号、且当前没有其他信号pending”的线程如果所有线程都block了那就不唤醒任何人信号继续躺在共享队列里等某个线程解除block才有机会递送。这解释了另一个常见现象你以为“主线程”会处理SIGTERM做优雅退出实际可能是某个工作线程的handler先跑了然后顺手把整个线程组退掉。凡是依赖“特定线程处理进程级信号”的设计都应该重新审视——要么在handler里做线程无关的清理要么干脆用signalfd把信号转成事件让专属的线程通过epoll去消费。5.3 ptrace介入调试器看到了哪一层信号用gdb调试时经常看到Program received signal SIGSEGV这里有个底层细节被调试进程的信号递送内核会先交给调试器过目。当目标线程是ptrace跟踪状态get_signal相关流程里会触发ptrace_stop向调试器报告“我收到了这个信号”。调试器可以决定放行、屏蔽或者改写。gdb的handle命令和catch signal命令就是围绕这个机制实现的。所以当你用strace看信号、用gdb追信号时看到的其实是“经过调试器过滤后的样子”并不完全等同于进程自己感知的信号流。排查问题时如果发现信号行为跟代码对不上先想想是不是调试器在中间做了手脚。6. 我在实际排查信号问题时踩过的坑和通用处理手法6.1 handler里调用printf/malloc引发的死锁复盘这是信号处理里最经典的坑。我在一个服务的崩溃处理逻辑里做过类似的事SIGUSR1的handler里调了printf输出调试信息看起来很正常。直到线上某个时段出现假死strace一看所有线程都卡在futex上。复盘后真相大白主线程可能正在printf它已经拿到了stdio内部锁此时SIGUSR1到达handler又开始printf第二个printf尝试拿同一把锁可是主线程被信号打断后一直没机会释放锁于是handler永远等锁整个进程假死。POSIX为此定义了一个严格的“async-signal-safe”函数集合在handler里能安全调用的只有write、read、_exit、sigaction这一小撮。printf、malloc、free都不在其中。处理办法也很直接handler里只写volatile sig_atomic_t标志位主循环轮询或者用write()直接写一个自管管道把事件丢给事件循环更干净的做法是后面的signalfd方案。static volatile sig_atomic_t g_reload_flag 0; static void on_signal(int sig) { g_reload_flag 1; /* 只写标志不做任何库函数调用 */ }6.2 EINTR与SA_RESTART为什么慢系统调用会“抽风”没有SA_RESTART时一个阻塞在socket上的read()被信号打断会返回-1、errnoEINTR。很多新手看到这个错误码就以为是网络断了其实不是。我建议在所有长期运行的IO循环里显式处理EINTR而不是盲目依赖SA_RESTARTssize_t ret; do { ret read(fd, buf, sizeof(buf)); } while (ret 0 errno EINTR);理由有两个。第一SA_RESTART并不能覆盖所有系统调用尤其是一些已经产生部分副作用的调用内核无法安全地重放。第二显式处理EINTR能让你精确控制“被打断后要不要重试”比如你正在写数据库事务信号打断后重试可能带来重复写入这时候你可能希望直接上报错误而不是默默重试。6.3 signalfd替代handler把异步问题变成同步事件处理信号最稳妥的思路其实是让信号别来打断你而是变成你事件循环里的一个普通事件。Linux的signalfd就是为了这个场景存在的。核心步骤是先用sigprocmask把关心的信号block掉再创建signalfd并把它丢进epoll。信号一旦到达由于被block它会停留在pending队列里同时signalfd变成可读你的事件循环读到struct signalfd_siginfo就知道发生了什么信号。sigset_t mask; sigemptyset(mask); sigaddset(mask, SIGTERM); sigaddset(mask, SIGINT); sigprocmask(SIG_BLOCK, mask, NULL); int sfd signalfd(-1, mask, SFD_NONBLOCK | SFD_CLOEXEC); /* 之后把sfd加入epoll */为什么要先block因为只有保持pending状态的信号才会通过signalfd通知你。如果你不block信号可能已经被默认动作处理掉了signalfd根本收不到。这个方案的额外好处是你可以在主循环里统一处理“退出”“重新加载配置”等逻辑完全规避handler竞态。现代Linux服务里这比“handler里设标志轮询”更优雅。6.4 用/proc/PID/status和strace看信号全貌最后分享一个排查信号的实操技巧先看/proc/PID/status。文件里有SigQ、SigPnd、ShdPnd、SigBlk、SigIgn、SigCgt几个字段分别表示排队信号数、线程私有挂起信号、线程组共享挂起信号、屏蔽信号、忽略信号和捕获信号都是十六进制位图。想看SIGPIPE编号13就检查对应位grep SigIgn /proc/1234/status grep SigCgt /proc/1234/status我实际排查过这样一个案例一个服务每隔一段时间就无日志退出最后看status发现它没有忽略SIGPIPE再用strace -e tracesignal -p PID一观察发现正是往一个对端已关闭的socket写入时触发了SIGPIPE把进程干掉。修复方式就一行signal(SIGPIPE, SIG_IGN)问题彻底消失。strace看信号也很直观strace -e tracesignal -p PID它能抓到tgkill、rt_sigaction等信号相关系统调用配合/proc里的位图基本能定位绝大多数信号问题。排查顺序我建议是先看status确认信号有没有被block/ignore/catch再用strace看系统调用路径最后才去看用户态handler逻辑。不要一上来就怀疑编译器或者运行时库——信号问题的底层线索几乎都在内核这条链路上。写在最后的一点个人体会做了这么多年Linux服务开发和疑难问题排查我越来越觉得信号的底层逻辑不是“抢占”而是“协作”。内核没有义务随时打断你的代码它只负责在你返回用户态前把该给你的东西递到你手里。理解透了这个哲学很多诡异现象就能顺理成章地解释为什么handler执行时机不确定、为什么系统调用会EINTR、为什么handler里不能乱调用库函数。如果这篇文章能给你一个抓手那就是下次再遇到信号相关问题别只看signal()那一行注册代码。顺着系统调用下去看看pending位图、blocked掩码、action[]数组再抬头看看TIF_SIGPENDING和setup_rt_frame——你会发现从用户态到内核态再回到用户态这条环形链路其实没有黑魔法只有一个接一个结构清晰的决策点。
返回列表