跑 busybox sh 时发现一个诡异的问题:Ctrl+C 能杀掉进程,但 handler 执行完之后程序直接崩了——寄存器全乱了。调查下去,发现 ch53 的信号系统有三个根本缺陷。

问题一:寄存器没有保存

ch53 的 signal_dispatch 只保存了 user_ripuser_rsp

uint64_t new_rsp = *user_rsp - 8;
*(uint64_t *)(phys + ...) = *user_rip;  // 仅压返回地址
*user_rsp = new_rsp;
*user_rip = handler;

handler 执行期间会用到 r15, r14, …, rbx, rbp——这些全都没有保存。handler 返回时弹出的 rip 确实是原来的位置,但所有调用者保存的寄存器都被 handler 破坏了,接下来的代码跑的是垃圾数据。

问题二:sigaction 是空 stub

case SYS_SIGACTION:
case SYS_SIGPROCMASK:
case SYS_SIGRETURN:
    return 0;   // 什么都没做

musl 的 signal() 底层调用 sigaction,返回 0 让它以为设置成功了,实际上 handler 根本没有注册进去。

问题三:没有 signal mask

handler 执行期间,如果同一个信号再次到来,会再次触发 dispatch,handler 递归执行,栈很快就溢出了。


解法:signal_frame_t + 真正的 sigaction

核心数据结构

把所有寄存器打包成一个结构体,压到用户栈:

typedef struct {
    uint64_t r15, r14, r13, r12, r11, r10, r9, r8;
    uint64_t rdi, rsi, rdx, rcx, rbx, rbp, rax;
    uint64_t rip, rsp, rflags;
    sigset_t saved_mask;   // 恢复用
    uint32_t sig;
} signal_frame_t;

进程结构体换掉旧的 signal_handlers[]

sigset_t      sig_mask;
k_sigaction_t sig_actions[NSIG];   // handler + sa_flags + sa_mask

syscall_entry.asm:传整个帧

原来只传两个指针,现在传整个内核栈帧:

push rax                    ; 保存 syscall 返回值
lea rdi, [rsp + 8]          ; frame* 指向 r9 slot
lea rsi, [rsp + 0]          ; &retval
call signal_dispatch_frame
pop rax

内核栈从低到高的布局是 r9, r8, r10, rdx, rsi, rdi, r15, r14, r13, r12, rbx, rbp, rflags, rip, user_rsp——正好对应一个 kframe_t 结构体,C 代码直接读写。

signal_dispatch_frame

void signal_dispatch_frame(kframe_t *kf, uint64_t *retval) {
    uint32_t deliverable = current->pending_signals & ~current->sig_mask;
    // ...

    // 计算用户栈上的 frame 位置(16 字节对齐)
    uint64_t frame_sp = (kf->user_rsp - sizeof(signal_frame_t)) & ~0xFULL;

    // 把所有寄存器复制到用户栈
    signal_frame_t frame = { .r15=kf->r15, ..., .rip=kf->rip, ... };
    frame.saved_mask = current->sig_mask;
    copy_to_user_bytes(current->pml4, frame_sp, &frame, sizeof(frame));

    // 屏蔽当前信号(默认不递归)
    current->sig_mask |= sa->sa_mask | (1u << sig);

    // 如果有 sa_restorer(musl 的 __restore_rt),压到返回地址
    if (sa->sa_restorer) {
        uint64_t ret_sp = frame_sp - 8;
        // 写 sa_restorer 到 [ret_sp]
        kf->user_rsp = ret_sp;
    }

    kf->rip = handler;
    kf->rdi = sig;   // handler 的第一个参数
}

SYS_SIGRETURN:还原

musl 的 __restore_rt 会执行 syscall SYS_SIGRETURN,内核从用户栈上读回 signal_frame_t,把所有寄存器写回内核栈帧:

case SYS_SIGRETURN: {
    kframe_t *kf = (kframe_t *)syscall_frame;
    signal_frame_t uf;
    copy_from_user((uint8_t *)&uf, kf->user_rsp, sizeof(uf));

    kf->r15 = uf.r15; /* ... 全部还原 */
    kf->rip      = uf.rip;
    kf->user_rsp = uf.rsp;
    kf->rflags   = uf.rflags;
    current->sig_mask = uf.saved_mask;   // 还原 mask
    return uf.rax;   // 还原 syscall 返回值
}

sysret 之后,进程在被信号打断的那条指令继续执行,所有寄存器都回来了。


SIGCHLD + 僵尸进程自动回收

子进程退出时自动发 SIGCHLD 给父进程:

// proc_exit 里
k_sigaction_t *sa = &procs[ppid].sig_actions[SIGCHLD];
if (sa->sa_flags & SA_NOCLDWAIT) {
    current->state = PROC_UNUSED;   // 父进程不需要 wait,直接回收
} else {
    current->state = PROC_ZOMBIE;
    signal_send(ppid, SIGCHLD);     // 通知父进程
}

shell 注册 SA_NOCLDWAIT 后,sh -c "cmd" 的子进程退出时自动回收,不会堆积僵尸。


SIGPIPE

之前用硬编码数字 13,改成用宏,顺便修返回值:

if (p->read_fds == 0) {
    signal_send(current->pid, SIGPIPE);
    return -EPIPE;   // 原来返回 -1,现在返回正确的错误码
}

程序可以 signal(SIGPIPE, SIG_IGN) 忽略它,让 write 直接返回 -EPIPE 而不是崩掉。


完整信号流程

kill(pid, SIGUSR1)
  → signal_send: procs[pid].pending |= (1<<SIGUSR1)

syscall 返回前 signal_dispatch_frame:
  → 计算 frame_sp = user_rsp - sizeof(signal_frame_t)
  → 把所有寄存器写入用户栈 frame_sp
  → sig_mask |= (1<<SIGUSR1)(防递归)
  → kf->rip = handler, kf->rdi = sig
  → [ret_sp] = sa_restorer

sysret → 跳到 handler(SIGUSR1)
  → handler 执行,可以用所有寄存器
  → 执行完,ret 弹出 sa_restorer 地址

sa_restorer(musl __restore_rt):
  → syscall SYS_SIGRETURN

SYS_SIGRETURN:
  → 从用户栈读 signal_frame_t
  → 还原所有寄存器 + sig_mask
  → sysret → 回到被中断的指令

验收

/ # kill -USR1 $$    ← 发信号给 shell 自身
caught SIGUSR1       ← handler 打印
/ #                  ← shell 继续正常工作,寄存器没乱

/ # ls | grep bin    ← SIGPIPE 正常处理,不崩溃
bin