尧图精选

《从零手写操作系统 (16):信号机制初探——让进程学会“感受痛苦”》

🕒 发布时间:2026/10/1 7:11:28 📁 来源:尧图网络
前言从“强制终止”到“异步通知”在前面的章节中我们实现了进程管理、COW和堆内存。但目前的OS有一个致命缺陷无法优雅地处理异常与控制流。按下CtrlC毫无反应除零错误直接触发内核panic子进程崩溃后父进程一无所知。一个不支持信号的操作系统就像没有神经系统的生物——它能运动但感觉不到疼痛也无法对外界刺激做出条件反射。本章我们将实现Unix信号机制的最小可行版本MVPSIGINT键盘中断CtrlC终止前台进程SIGTERM软件请求终止SIG_DFL / SIG_IGN默认处理与忽略用户态Handler注册自定义信号处理函数这是让你的OS从“批处理机器”进化为“交互式系统”的关键一步。本章里程碑✅ 在PCB中添加信号掩码、挂起信号集与handler表✅ 实现sys_signal与sys_kill系统调用✅ 在IRQ1中检测CtrlC并发送SIGINT✅ 在iret返回用户态前检查并投递挂起信号✅ 构建用户态signal trampoline安全恢复上下文✅ 验证信号中断、忽略与自定义处理的正确性核心概念信号的异步本质与安全投递窗口信号不是函数调用而是“受控的异常注入”普通函数调用是同步的调用者知道何时发生、栈帧连续。信号是异步的它可能在任意指令之间插入打断正在执行的代码。这意味着不能假设被中断的代码处于任何特定状态Handler执行完毕后必须精确恢复被中断时的寄存器、EFLAGS、EIP如果Handler本身被另一个信号中断嵌套栈帧管理将极其复杂⚠️本章简化禁止信号嵌套。在执行Handler期间自动屏蔽该信号避免递归投递导致的栈溢出。生产级内核使用独立的signal stacksigaltstack解决此问题。投递时机为什么必须在iret前信号不能在ISR中直接执行用户态Handler——此时CPU处于Ring 0CS:EIP指向内核代码。正确的做法是ISR或syscall中仅标记信号为pending在每次从内核返回用户态之前trap/syscall exit path检查是否有未处理的pending信号若有修改即将iret的trap frame使EIP指向trampoline而非原断点Trampoline在用户态调用Handler结束后通过sys_sigreturn恢复原始trap frame这个设计保证了信号处理完全发生在Ring 3且上下文切换对内核透明。CtrlC的特殊性TTY层 vs 键盘驱动层CtrlC不是普通的字符输入而是一个控制事件。它不应进入行编辑缓冲区供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; }CtrlC检测与SIGINT生成// keyboard.c 补充 #include process.h // 全局前台进程PID由Shell在fork/exec时设置 volatile int foreground_pid -1; static void keyboard_irq_handler(interrupt_frame_t *frame) { // ... 原有扫描码读取逻辑 ... // ★ 检测CtrlC (Set1: Ctrl0x1D, C0x2E) // 简化检测到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 frameiret后将执行trampoline frame-eip (uint32_t)signal_trampoline; // 传递参数sig号放在eaxhandler地址放在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跳转而来 ; 进入时: EAXsig, ECXhandler_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_maskHandler返回后信号仍被屏蔽 // 直到用户显式调用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而不能直接跳handlerHandler是普通C函数遵循cdecl调用约定期望栈上有返回地址。如果我们直接把EIP设为handler地址iret后CPU会将handler的第一条指令当作返回地址弹出导致灾难。Trampoline充当了“内核注入的胶水代码”它构造合法的C调用栈帧、调用handler、然后通过sigreturn安全恢复。Trampoline是信号机制的安全阀。3. CtrlC为什么不在Shell中处理而在键盘驱动中如果CtrlC作为普通字符进入缓冲区Shell必须在readline中轮询检测——这意味着只有当Shell主动读取输入时才能响应。而真正的CtrlC应该立即中断正在运行的前台子进程即使该进程正在进行CPU密集计算而不读取stdin。这要求信号生成点尽可能靠近硬件层与I/O缓冲解耦。调试Checklist信号机制排查症状可能原因排查方法CtrlC无任何反应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_maskHandler执行后#GPF/#PFtrampoline栈未对齐/handler地址无效/sigreturn未正确恢复在trampoline入口dump ESP/EAX/ECX确认handler是有效的用户态地址dump saved_frame验证完整性Handler中被同一信号再次中断sig_mask未在投递时设置确认check_and_deliver_signal中sig_mask | (1sig)在修改frame之前执行sigreturn后EIP错误saved_frame被覆盖/trap frame拷贝不完整确认saved_frame是值拷贝而非指针验证interrupt_frame_t大小与实际pushaerror 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/CtrlC、投递iret前检查与处理trampolinesigreturn完整链路✅ 支持SIG_DFL、SIG_IGN与自定义Handler三种处置方式✅ 通过saved_frame机制实现了安全的异步上下文保存与恢复✅ 将CtrlC从字符流提升为控制事件实现了真正的前台进程中断从此你的操作系统拥有了基本的进程控制语义。当你第一次按下CtrlC看到正在运行的程序干净退出、Shell恢复提示符时你体验到的是Unix五十年来人机交互设计的精髓。下一章预告《终端子系统TTY、行规程与作业控制》当前的键盘输入和串口输出是分离的裸设备访问。下一章将统一为TTY抽象层实现行规程line discipline、回显控制、作业控制fg/bg/jobs让你的Shell获得完整的终端体验为运行真实shell程序铺平道路。参考资料Linux Kernel:kernel/signal.c,arch/x86/kernel/signal.cThe Design of the UNIX Operating System (Bach), Chapter 11xv6 Source:kernel/proc.c(usertrap,sigreturn)OSDev Wiki - Signals / Interrupts本系列完整代码[你的GitHub仓库链接]Commit:s1g2n3l作者注这是《从零手写操作系统》系列的第16篇。信号是整个教程中概念密度最高、调试难度最大的章节之一。如果你花了三天才让sigreturn正确恢复EIP请记住BSD的信号机制重构花了整整两年POSIX标准为此争论了十年。建议先实现最简单的SIG_DFL终止语义确认pending/deliver链路无误后再加入Handler和trampoline。永远不要同时调试多个信号特性。下一章我们让终端变得“聪明”起来
上一篇/下一篇内容由系统自动关联 返回资讯列表 →