前言:从“强制终止”到“异步通知”

        在前面的章节中,我们实现了进程管理、COW和堆内存。但目前的OS有一个致命缺陷:无法优雅地处理异常与控制流。按下Ctrl+C毫无反应;除零错误直接触发内核panic;子进程崩溃后父进程一无所知。一个不支持信号的操作系统,就像没有神经系统的生物——它能运动,但感觉不到疼痛,也无法对外界刺激做出条件反射。

        本章我们将实现Unix信号机制的最小可行版本(MVP):

  • SIGINT:键盘中断(Ctrl+C)终止前台进程
  • SIGTERM:软件请求终止
  • SIG_DFL / SIG_IGN:默认处理与忽略
  • 用户态Handler:注册自定义信号处理函数

        这是让你的OS从“批处理机器”进化为“交互式系统”的关键一步。

本章里程碑:

  • ✅ 在PCB中添加信号掩码、挂起信号集与handler表
  • ✅ 实现sys_signal与sys_kill系统调用
  • ✅ 在IRQ1中检测Ctrl+C并发送SIGINT
  • ✅ 在iret返回用户态前检查并投递挂起信号
  • ✅ 构建用户态signal trampoline,安全恢复上下文
  • ✅ 验证信号中断、忽略与自定义处理的正确性

核心概念:信号的异步本质与安全投递窗口
信号不是函数调用,而是“受控的异常注入”

        普通函数调用是同步的:调用者知道何时发生、栈帧连续。信号是异步的:它可能在任意指令之间插入,打断正在执行的代码。这意味着:

  • 不能假设被中断的代码处于任何特定状态
  • Handler执行完毕后必须精确恢复被中断时的寄存器、EFLAGS、EIP
  • 如果Handler本身被另一个信号中断(嵌套),栈帧管理将极其复杂

⚠️ 本章简化:禁止信号嵌套。在执行Handler期间自动屏蔽该信号,避免递归投递导致的栈溢出。生产级内核使用独立的signal stack(sigaltstack)解决此问题。

投递时机:为什么必须在iret前?

        信号不能在ISR中直接执行用户态Handler——此时CPU处于Ring 0,CS:EIP指向内核代码。正确的做法是:

  1. ISR或syscall中仅标记信号为pending
  2. 在每次从内核返回用户态之前(trap/syscall exit path),检查是否有未处理的pending信号
  3. 若有,修改即将iret的trap frame,使EIP指向trampoline而非原断点
  4. Trampoline在用户态调用Handler,结束后通过sys_sigreturn恢复原始trap frame

        这个设计保证了信号处理完全发生在Ring 3,且上下文切换对内核透明。

Ctrl+C的特殊性:TTY层 vs 键盘驱动层

        Ctrl+C不是普通的字符输入,而是一个控制事件。它不应进入行编辑缓冲区供Shell读取,而应在键盘驱动层立即识别并转换为SIGINT发送给前台进程组。本章简化:将“前台进程”定义为当前正在等待输入的Shell子进程(通过全局变量追踪)。


实战代码
PCB扩展与信号数据结构
// process.h 补充
#define NSIG        8       // 支持信号1-7
#define SIGINT      2
#define SIGTERM     15
#define SIG_DFL     ((void (*)(int))0)
#define SIG_IGN     ((void (*)(int))1)

typedef struct process {
    // ... 原有字段 ...
    
    // ★ 信号相关
    uint32_t sig_pending;           // 位图:挂起的信号
    uint32_t sig_mask;              // 位图:当前屏蔽的信号
    void (*sig_handlers[NSIG])(int); // 每个信号的handler
    
    // 用于sigreturn恢复的原始上下文
    interrupt_frame_t saved_frame;  
    int has_saved_frame;            
} process_t;
sys_signal与sys_kill
// syscall.c - 信号部分
int sys_signal(interrupt_frame_t *frame) {
    int sig = frame->ebx;
    void (*handler)(int) = (void (*)(int))frame->ecx;
    
    if (sig <= 0 || sig >= NSIG) return -1;
    if (sig == SIGKILL || sig == SIGSTOP) return -1; // 不可捕获
    
    current_process->sig_handlers[sig] = handler;
    return 0;
}

int sys_kill(interrupt_frame_t *frame) {
    int pid = frame->ebx;
    int sig = frame->ecx;
    
    if (sig <= 0 || sig >= NSIG) return -1;
    
    process_t *target = find_process(pid);
    if (!target || target->state == PROC_UNUSED) return -1;
    
    // 设置pending位
    target->sig_pending |= (1 << sig);
    
    // 如果目标进程正在WAITING/SLEEPING,唤醒它以检查信号
    if (target->state == PROC_WAITING || target->state == PROC_SLEEPING) {
        target->state = PROC_READY;
    }
    
    return 0;
}
Ctrl+C检测与SIGINT生成
// keyboard.c 补充
#include "process.h"

// 全局前台进程PID(由Shell在fork/exec时设置)
volatile int foreground_pid = -1;

static void keyboard_irq_handler(interrupt_frame_t *frame) {
    // ... 原有扫描码读取逻辑 ...
    
    // ★ 检测Ctrl+C (Set1: Ctrl=0x1D, C=0x2E)
    // 简化:检测到scancode 0x2E且Ctrl按下标志为真
    static int ctrl_pressed = 0;
    
    if (scancode == 0x1D) ctrl_pressed = 1;          // Left Ctrl press
    else if (scancode == 0x9D) ctrl_pressed = 0;     // Left Ctrl release
    else if (scancode == 0x2E && ctrl_pressed) {     // 'C' while Ctrl held
        // 不写入输入缓冲区!直接发信号
        if (foreground_pid > 0) {
            process_t *target = find_process(foreground_pid);
            if (target && target->state != PROC_UNUSED) {
                target->sig_pending |= (1 << SIGINT);
                if (target->state == PROC_WAITING || 
                    target->state == PROC_SLEEPING) {
                    target->state = PROC_READY;
                }
            }
        }
        return; // 消费掉这个按键,不回显不入缓冲
    }
    
    // ... 原有字符入队逻辑 ...
}
信号投递:iret前的检查与Trampoline
// signal.c
#include "process.h"
#include "interrupt.h"

// 用户态trampoline地址(链接脚本固定在0xFFFFF000附近或由内核映射)
extern void signal_trampoline(void);

// ★ 在每次iret返回用户态前调用
void check_and_deliver_signal(interrupt_frame_t *frame) {
    process_t *proc = current_process;
    
    // 计算可投递信号 = pending & ~mask
    uint32_t deliverable = proc->sig_pending & ~proc->sig_mask;
    if (deliverable == 0) return;
    
    // 找最低位的信号
    int sig = __builtin_ctz(deliverable);
    
    void (*handler)(int) = proc->sig_handlers[sig];
    
    // 清除pending位
    proc->sig_pending &= ~(1 << sig);
    
    if (handler == SIG_IGN) return; // 忽略
    
    if (handler == SIG_DFL) {
        // 默认动作:SIGINT/SIGTERM → 终止
        if (sig == SIGINT || sig == SIGTERM) {
            kprintf("[SIG] PID %d killed by signal %d\n", proc->pid, sig);
            process_exit(128 + sig);
            // ★ process_exit会schedule,不会返回到这里
        }
        return; // 其他信号默认忽略
    }
    
    // === 自定义Handler:修改trap frame跳转到trampoline ===
    
    // 保存原始上下文以供sigreturn恢复
    proc->saved_frame = *frame;
    proc->has_saved_frame = 1;
    
    // 屏蔽当前信号防止嵌套
    proc->sig_mask |= (1 << sig);
    
    // ★ 重写trap frame:iret后将执行trampoline
    frame->eip = (uint32_t)signal_trampoline;
    // 传递参数:sig号放在eax,handler地址放在ecx
    // trampoline负责将它们转为C调用约定
    frame->eax = sig;
    frame->ecx = (uint32_t)handler;
    
    // ESP保持不变(trampoline内部自行调整栈)
}
用户态Signal Trampoline与sigreturn
; user/lib/signal_tramp.s
; 这段代码运行在Ring 3,由内核通过修改trap frame跳转而来
; 进入时: EAX=sig, ECX=handler_addr

global signal_trampoline
extern sys_sigreturn  ; syscall number

signal_trampoline:
    ; 保存被中断时的完整寄存器状态到用户栈
    push ebp
    mov ebp, esp
    push edi
    push esi
    push ebx
    push edx
    push ecx        ; handler addr (临时保存)
    push eax        ; sig number
    
    ; 调用handler(sig)
    push eax        ; arg: sig
    call ecx        ; call handler
    add esp, 4      ; cleanup arg
    
    ; ★ 通过syscall恢复原始上下文
    mov eax, 11     ; SYS_SIGRETURN
    int 0x80
    ; sys_sigreturn不会返回;它直接restore saved_frame并iret
    
    ; 如果sigreturn意外返回,安全退出
    mov eax, 2      ; SYS_EXIT
    mov ebx, 1
    int 0x80
// syscall.c - sigreturn
int sys_sigreturn(interrupt_frame_t *frame) {
    process_t *proc = current_process;
    
    if (!proc->has_saved_frame) return -1;
    
    // ★ 用保存的原始上下文覆盖当前trap frame
    *frame = proc->saved_frame;
    proc->has_saved_frame = 0;
    
    // 注意:不清除sig_mask!Handler返回后信号仍被屏蔽
    // 直到用户显式调用signal()重新设置
    // (简化语义;POSIX要求sigreturn恢复旧mask)
    
    return 0; // 返回值被frame->eax覆盖,实际无意义
}

关键细节解析

1. 为什么信号检查必须在iret前而非ISR中?

        ISR运行在Ring 0的内核栈上,直接跳转到用户态Handler会导致特权级违规(#GP)。更重要的是,ISR的栈帧布局与用户态期望的完全不同。只有trap frame才是内核与用户态之间的契约接口。通过修改trap frame,我们利用CPU自身的iret机制完成特权级切换,无需手动构造TSS或远跳转。

2. 为什么需要独立的trampoline而不能直接跳handler?

        Handler是普通C函数,遵循cdecl调用约定,期望栈上有返回地址。如果我们直接把EIP设为handler地址,iret后CPU会将handler的第一条指令当作返回地址弹出,导致灾难。Trampoline充当了“内核注入的胶水代码”:它构造合法的C调用栈帧、调用handler、然后通过sigreturn安全恢复。Trampoline是信号机制的安全阀。

3. Ctrl+C为什么不在Shell中处理而在键盘驱动中?

        如果Ctrl+C作为普通字符进入缓冲区,Shell必须在readline中轮询检测——这意味着只有当Shell主动读取输入时才能响应。而真正的Ctrl+C应该立即中断正在运行的前台子进程,即使该进程正在进行CPU密集计算而不读取stdin。这要求信号生成点尽可能靠近硬件层,与I/O缓冲解耦。


调试Checklist:信号机制排查
症状可能原因排查方法
Ctrl+C无任何反应foreground_pid未设置/键盘驱动未识别组合键kprintf在keyboard IRQ中dump ctrl_pressed和scancode;确认Shell在exec前设置了foreground_pid
信号pending但Handler未执行check_and_deliver_signal未在exit path调用/mask误设确认所有iret路径(trap return/syscall return)都调用了check函数;dump sig_pending和sig_mask
Handler执行后#GPF/#PFtrampoline栈未对齐/handler地址无效/sigreturn未正确恢复在trampoline入口dump ESP/EAX/ECX;确认handler是有效的用户态地址;dump saved_frame验证完整性
Handler中被同一信号再次中断sig_mask未在投递时设置确认check_and_deliver_signal中sig_mask |= (1<<sig)在修改frame之前执行
sigreturn后EIP错误saved_frame被覆盖/trap frame拷贝不完整确认saved_frame是值拷贝而非指针;验证interrupt_frame_t大小与实际pusha+error code布局一致
SIG_IGN的信号仍杀死进程DFL/IGN判断顺序错误/handler表未初始化确认进程创建时sig_handlers全部初始化为SIG_DFL;检查比较运算符优先级

🔧 黄金法则:信号调试的最大敌人是不确定性。同一个bug可能99次正常、第100次崩溃。解决方案:①在测试程序中故意制造确定性触发(如kill(getpid(), SIGINT)而非依赖手动按键);②在check_and_deliver_signal入口添加无条件kprintf记录sig/pid/eip;③禁用调度器抢占(cli/sti包裹关键段)排除并发干扰后再逐步放开。


本章小结与下一步

        今天我们赋予了进程“神经系统”:

  • ✅ 实现了信号的生成(kill/Ctrl+C)、投递(iret前检查)与处理(trampoline+sigreturn)完整链路
  • ✅ 支持SIG_DFL、SIG_IGN与自定义Handler三种处置方式
  • ✅ 通过saved_frame机制实现了安全的异步上下文保存与恢复
  • ✅ 将Ctrl+C从字符流提升为控制事件,实现了真正的前台进程中断

        从此,你的操作系统拥有了基本的进程控制语义。当你第一次按下Ctrl+C看到正在运行的程序干净退出、Shell恢复提示符时,你体验到的是Unix五十年来人机交互设计的精髓。

下一章预告:《终端子系统:TTY、行规程与作业控制》

        当前的键盘输入和串口输出是分离的裸设备访问。下一章将统一为TTY抽象层:实现行规程(line discipline)、回显控制、作业控制(fg/bg/jobs),让你的Shell获得完整的终端体验,为运行真实shell程序铺平道路。


参考资料
  • Linux Kernel: kernel/signal.c, arch/x86/kernel/signal.c
  • The Design of the UNIX Operating System (Bach), Chapter 11
  • xv6 Source: kernel/proc.c (usertrap, sigreturn)
  • OSDev Wiki - Signals / Interrupts
  • 本系列完整代码:[你的GitHub仓库链接](Commit: s1g2n3l)

📝 作者注:这是《从零手写操作系统》系列的第16篇。信号是整个教程中概念密度最高、调试难度最大的章节之一。如果你花了三天才让sigreturn正确恢复EIP,请记住:BSD的信号机制重构花了整整两年,POSIX标准为此争论了十年。建议先实现最简单的SIG_DFL终止语义,确认pending/deliver链路无误后再加入Handler和trampoline。永远不要同时调试多个信号特性。下一章,我们让终端变得“聪明”起来!

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐