信号与 ptrace:从异步通知到受控停止

本页目录

kill() 返回成功,处理函数却还没执行;几个子进程同时退出,父进程只收到一次 SIGCHLD。理解这些现象,需要把事件产生、待决状态、实际递送分开。

ptrace 再增加一层停止协议:跟踪者可以在特定位置观察目标,但必须识别停止原因,才能正确恢复它。

你遇到的现象阅读位置
信号发出后没有反应,或只有某个线程接收第 1 节:待决与线程选择
发送次数与处理次数不同第 2 节:合并、排队和实验
handler 卡住,或等待超时不断延长第 3~4 节:异步进入与 EINTR
调试器恢复后目标异常退出第 5~6 节:停止分类与访问边界

1. 产生、待决、递送,是三个不同的阶段

发送接口成功,通常说明信号请求通过检查并被内核接受,不是接收者已执行处理函数的确认。接收线程可能正在屏蔽该信号、尚未被调度,或处于暂时无法响应的内核路径。若业务需要确认某项操作完成,应设计显式应答,而非把 kill() 的返回值当作完成通知。

先确认状态属于谁:

状态所属范围意义
处置:默认、忽略、处理函数通常在线程组内共享收到后怎样处理
屏蔽字每线程独立该线程现在是否接收
线程定向待决信号指定线程等目标线程能够接收
进程定向待决信号可保存在组共享状态中从合适线程中选择接收者

kill(pid, sig) 通常向进程发送;pthread_kill() 指定线程。⁠目标线程屏蔽信号时,线程定向信号不能改交别人。

进程定向信号选择接收线程,不向所有线程广播 kill(PID, SIGUSR1) 进程定向产生 线程组 shared pending 尚未递送的信号 线程 A 屏蔽 SIGUSR1 线程 B 未屏蔽,可被选中 线程 C 未屏蔽,可被选中 二者选择其一 线程定向信号进入指定线程的待决状态;即使该线程屏蔽,也不能改交别的线程。

读上图时,先看 A 的屏蔽字,再看 B、C 的接收资格:A 屏蔽 SIGUSR1,B 或 C 可以被选中,但不会两者各执行一次。若三个线程都屏蔽,信号便保持待决,直到有合适的递送机会。

改变发送目标与屏蔽字,再在线程上尝试递送。共享与私有 pending 分别显示;手动选线程用于比较可能的接收者,不承诺内核的选择顺序。

正在呈现知识画面
发给进程,还是指定线程?

进程定向信号可以由一个合适线程接收;线程定向信号只能留给指定线程。处置通常共享,屏蔽字与私有 pending 各有归属。

要集中处理信号,应在创建工作线程前先屏蔽目标集合,让新线程继承屏蔽字,再由专门线程同步消费。这样接收位置由程序安排,不必依赖某次恰好选中了哪个线程。

SIGKILL 和 SIGSTOP 不能安装处理函数或被忽略,尝试用 sigaction() 修改处置会失败;把它们加入屏蔽集合也不能真的屏蔽。它们仍受执行状态约束:SIGKILL 不是让 CPU 任意抛弃持锁内核栈的操作,任务可能要等到能够处理致命信号的路径。TASK_KILLABLE 等等待设计决定相应等待能否被致命信号打断。signal(7)

2. 标准信号为什么不能当成事件计数器

把 SIGUSR1 屏蔽后连续发送五次,解除屏蔽时不能期待处理函数执行五次:同号标准信号已经待决时,后续产生通常合并。它保留“这类事件需要关注”的状态。

同样多次发送,待决状态的保留方式不同 标准信号:同号已待决时合并 S S S S S 一个待决 SIGUSR1 同号实时信号:保存各次排队的值 10 11 12 10 11 12 前提:信号被屏蔽且未消费,实时排队成功;队列有上限,并非无限可靠消息通道。

SIGCHLD:通知一次,回收可能需要多次

SIGCHLD 的正确消费方式由此可推导出来:一次通知到来后,应反复调用适当的非阻塞 wait,取出当前所有可回收子进程的状态,直到没有更多结果。若只回收一个,另外几个可能已经退出,却没有独立的后续 SIGCHLD 留着提醒你。即使信号发生合并,退出状态仍由内核的子进程等待机制保存;通知和事实记录属于不同对象。

实时信号:有序排队仍需处理资源上限

实时信号可通过 sigqueue() 附带值,同一种信号的多个实例按发送次序递送。图中保留了 10、11、12 三个值,但队列容量有限,发送可能以 EAGAIN 失败。

不同实时信号编号还有优先顺序,不能把所有编号合成一个全局 FIFO。使用 SIGRTMIN + n 时应检查 SIGRTMAX;glibc 可为线程库保留实时信号,SIGRTMIN 不能写死为 34。signal(7)

连续发送后分别取出两类信号。图中特意用小容量暴露发送失败;它不是 Linux 的固定队列上限。

正在呈现知识画面
发了三次,就能取出三次吗?

同号普通信号合并成一个 pending 状态,同号实时信号分别排队并按序消费。队列资源有限,发送失败的值不会入队。

实验:一次待决与三个排队值

下面在单线程进程内屏蔽信号,分别验证五次标准信号合并与三个同号实时信号的有序取出。它不安装异步处理函数,也不向其他进程发送信号。保存为 pending.c,编译时使用 cc -std=c11 -O2 -Wall -Wextra -Werror pending.c -o pending,不要定义 NDEBUG。

#define _POSIX_C_SOURCE 200809L
#include <assert.h>
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>

int main(void) {
    const int rt = SIGRTMIN;
    assert(rt <= SIGRTMAX);
    sigset_t all, old, standard, realtime;
    sigemptyset(&all);
    sigaddset(&all, SIGUSR1);
    sigaddset(&all, rt);
    assert(sigprocmask(SIG_BLOCK, &all, &old) == 0);
    struct sigaction sa = { .sa_handler = SIG_DFL };
    sigemptyset(&sa.sa_mask);
    assert(sigaction(SIGUSR1, &sa, NULL) == 0);
    assert(sigaction(rt, &sa, NULL) == 0);
    for (int i = 0; i < 5; ++i)
        assert(kill(getpid(), SIGUSR1) == 0);
    for (int i = 10; i < 13; ++i) {
        union sigval value = { .sival_int = i };
        assert(sigqueue(getpid(), rt, value) == 0);
    }
    sigemptyset(&standard);
    sigaddset(&standard, SIGUSR1);
    sigemptyset(&realtime);
    sigaddset(&realtime, rt);
    struct timespec zero = {0};
    siginfo_t info;
    assert(sigtimedwait(&standard, &info, &zero) == SIGUSR1);
    errno = 0;
    assert(sigtimedwait(&standard, &info, &zero) == -1 && errno == EAGAIN);
    for (int expected = 10; expected < 13; ++expected) {
        assert(sigtimedwait(&realtime, &info, &zero) == rt);
        assert(info.si_code == SI_QUEUE);
        assert(info.si_value.sival_int == expected);
    }
    errno = 0;
    assert(sigtimedwait(&realtime, &info, &zero) == -1 && errno == EAGAIN);
    assert(sigprocmask(SIG_SETMASK, &old, NULL) == 0);
    puts("standard: 1 pending; realtime payloads: 10, 11, 12");
}

零超时意味着只取此刻已经待决的匹配信号,没有则返回 EAGAIN。程序在恢复原屏蔽字前把自己发送的信号全部消费掉。实验假设没有外部发送者且排队配额足够;正式程序必须处理 sigqueue 的资源不足,不能依赖断言承担恢复策略。sigtimedwait(2)

3. 处理函数如何进入,又如何回到被打断的位置

对于需要调用用户处理函数的递送,内核在适当的用户态返回边界准备信号帧:保存恢复所需的用户寄存器、屏蔽字和相关上下文,选择普通用户栈或满足条件的备用信号栈,再把用户执行入口改为处理函数。它运行在接收线程的用户态上下文中,不是内核中断处理函数,也不是内核临时创建的新线程。

处理函数返回后,经适用的用户态返回跳板进入 rt_sigreturn,内核验证并恢复相应状态,继续被打断的执行。帧布局和扩展寄存器细节依体系结构及 ABI 而异,不能照着一段假定固定大小的 C 结构体随意解释所有架构。默认终止、停止或忽略动作也不需要沿这条“调用用户处理函数”的路径执行。

选择主程序的执行位置,再在此处递送信号。TID 保持不变,从 PC、临时保存帧和 mask 观察这次转入与返回。

正在呈现知识画面
处理函数借用谁的执行线程?

处理函数在接收线程的用户态上下文中执行。signal frame 保存中断现场,rt_sigreturn 恢复 PC 和 mask,处理函数对标志的修改仍然保留。

处理函数为什么不能照常打印日志

设原线程正在执行 stdio,内部状态尚未更新完,信号处理函数此时插入执行。handler 再调用 printf,便可能重入同一份未完成的状态;若试图获取原线程已持有的锁,还可能等待自己解锁。

这正是异步信号安全限制的来源。处理函数应只做允许的操作,例如设置 volatile sig_atomic_t 标志,或写入具备非阻塞/容量策略的通知通道,再交给正常执行流处理。signal-safety(7)

把复杂处理移回正常线程

sig_atomic_t 适合规定范围内的信号处理通信,却不自动提供多线程数据发布所需的 release/acquire 协议。如果需求本来就是集中处理退出、重载等异步请求,可以使用 sigwaitinfo() 或 Linux signalfd(),把处理安排在正常线程执行流中;多线程下要让相关线程一致屏蔽这些信号,避免被其他线程先递送。由非法访存等同步异常产生的 SIGSEGV 则不能机械地套用这种集中消费模式来恢复程序。

4. EINTR、重启与“检查后再睡”的竞态

一次等待被信号打断后,先判断接口实际返回了什么:

结果后续处理依据
返回 EINTR按接口语义决定重试、取消或返回上层
read/write 返回正的短计数先记入已完成字节,再处理剩余部分
接口按规则自动重启调用可能继续等待;SA_RESTART 的范围仍依接口而定

短返回可能已经完成部分工作,不能统一按“没有做任何事”重新提交。

超时重试:保留原截止时间

SA_RESTART 不是全局“永不 EINTR”开关。例如 nanosleep 和多种等待接口有自己的中断规则。带超时的循环还要保留原始截止时间:若每次中断都重新等待完整 5 秒,频繁信号可能让总等待远超 5 秒。使用单调时钟计算剩余预算,或使用支持绝对截止时间的接口,才能维持端到端时限。

等待竞态:标志已变真,pause 却刚开始

考虑这个顺序:主程序检查标志为假;信号处理函数随后把它置真;主程序最后才调用 pause()。通知已经过去,主程序却会等待下一次信号。

通常应先屏蔽目标信号并检查标志,再用 sigsuspend() 原子地替换屏蔽字并等待;返回后重新检查条件。它与条件等待保护的是同一个窗口:从检查条件到进入睡眠。signal(7)

5. ptrace:先识别停止原因,再决定如何恢复

ptrace 的跟踪对象在 Linux 中是线程。对一个 TID 建立跟踪关系,不意味着其他线程同时停止;调试器要构造整个进程的一致视图,还需要管理线程发现、创建事件和每条线程的停止状态。

PTRACE_ATTACH 建立关系并发送停止请求,但调用返回不意味着目标已经进入可检查的停止,跟踪者应等待相应状态。PTRACE_SEIZE 本身不停止目标,之后可用 PTRACE_INTERRUPT 请求停止。跟踪循环不能只写成“wait 返回了就读寄存器”,因为 wait 还可能报告最终退出或其他事件。

ptrace 是跟踪者与被跟踪线程之间的停止协议 建立跟踪关系 ATTACH 请求停止;SEIZE 本身不停止目标 1 等待并分类停止 用 wait 状态区分信号递送、系统调用和 ptrace 事件 2 检查或修改允许访问的状态 寄存器、内存、事件信息;同时考虑其他线程是否仍在运行 3 选择恢复动作 CONT / SYSCALL / SINGLESTEP,以及是否注入真正的信号 4 处理下一次事件或最终退出 循环驱动协议,退出状态不再当成普通停止 5 停止原因决定恢复方式;把所有 SIGTRAP 都转发回目标会改变程序行为。

可以按下表建立最基本的分类,再处理选项、线程组和内核版本差异:

观察到的状态含义与后续动作
正常退出或被信号终止消费最终状态,清理跟踪记录;不再发恢复请求
普通信号递送停止可检查 siginfo,选择压制或在恢复时注入该信号
系统调用停止启用 TRACESYSGOOD 后常以 SIGTRAP | 0x80 标识;这是分类标记,不是真正要转发的信号
ptrace 事件停止解码 wait 状态中的事件,例如 clone、exec、exit;事件停止与最终退出仍不同
作业控制 group-stop需要与信号递送停止区分;SEIZE 模式下可结合 LISTEN 等协议处理

下面用独立的停止快照比较恢复决策。只有真实 SIGUSR1 递送停止提供转发选择,目标线程的处理结果会在恢复后保留。

正在呈现知识画面
看到 SIGTRAP,就该转发 SIGTRAP 吗?

ptrace 恢复前须区分真实信号递送、调试事件与最终退出。真实信号可在恢复时转发或抑制;调试标记不等于应转发的普通信号。

恢复决策:系统调用停止与信号递送不同

PTRACE_SYSCALL 让目标继续到后续适用的系统调用停止。若简单维护一个布尔值,在每次停止时交替认为“入口/出口”,遇到信号、exec、seccomp 事件或其他停止就可能错位。支持时可用 PTRACE_GET_SYSCALL_INFO 获取入口、出口或 seccomp 等信息;仍须先识别停止类型,不能对所有 wait 结果一概读取系统调用号。

一次常见的错误是把所有 SIGTRAP 都在恢复时原样注入。调试器自己请求的单步或系统调用事件并不都代表目标本应接收一个 SIGTRAP;错误注入可能使原本正常的程序执行默认终止动作。反过来,把真正的业务信号全部压制,也会改变目标行为。跟踪器的恢复决策属于程序语义的一部分。ptrace(2)

6. 读内存与 seccomp:可观察不等于一致或安全

PTRACE_PEEKDATA 可能合法读到值 -1。判断失败时应先清零 errno,再结合返回值与 errno,而不是看到 -1 就认定地址非法。使用 /proc/PID/mem 或 process_vm_readv() 也不会绕过访问控制;凭据、dumpable、用户 namespace、Yama 或其他 LSM 策略等都会影响允许的操作。

一致性:停住一条线程,其他写入者还在

权限通过也不代表读到一致快照。假设跟踪者只停住线程 A,线程 B 仍在修改链表;先读 head、再读 next 可能跨过两次结构变化。即使停止了所有普通线程,共享内存的其他进程或设备仍可能改变数据。分析工具应明确需要的是尽力采样,还是由程序协作提供的一致快照。

接口边界:TRACE 与 USER_NOTIF

seccomp 的 SECCOMP_RET_TRACE 可以在相应 ptrace 选项下产生 seccomp 跟踪事件;SECCOMP_RET_USER_NOTIF 则通过专门 listener fd 交给用户态监督程序,⁠它不是 ptrace 接口⁠。

二者可以出现在同一个复杂系统里,但通知收取、响应及任务生命周期处理各有协议。seccomp 过滤器

用户态监督者读取系统调用参数中的指针,再让原系统调用继续,会面临典型 TOCTOU:在检查之后,其他线程可能修改指针指向的内存。仅“检查过路径字符串”不足以保证目标最终操作同一对象。

ptrace 本身也不是可信的沙箱边界;应把观察、代执行、对象引用和访问控制的职责分开设计,而不是从“能拦截系统调用”直接推导“能安全授权任意系统调用”。seccomp 用户通知

7. 快速定位时应追问的下一层

现场现象下一步检查
信号没有反应发送结果、待决状态、目标线程屏蔽字、处置及运行机会
子进程持续显示 ZSIGCHLD 之后是否循环消费全部可回收状态
调试恢复后异常退出停止分类,以及是否误把调试事件作为 SIGTRAP 注入

这些入口把“发生了什么”连接到具体状态;随后再沿源码查找状态变化的位置。

阅读内核实现可从 kernel/signal.c 的产生/递送路径、kernel/ptrace.c 的关系与权限管理,再到架构信号帧和 ptrace 寄存器操作。内部信号集合和用户态 sigset_t 的布局不是同一个 ABI,也无需为理解机制复制一份容易随版本过时的结构定义。

进程与线程资源关系回到生命周期,信号唤醒后的 CPU 竞争回到调度器。