用过命令行的人都按过 Ctrl-C。但它到底干了什么?常见的误解是「Ctrl-C 给程序发了个字符」。真相更有意思:Ctrl-C 产生的那个字节,程序根本收不到——它半路就被 tty 层截胡,翻译成了另一种东西:信号。
信号:内核递给进程的小纸条
信号(signal)是 Unix 里内核通知进程「出事了」的机制。它不是数据流,不带内容,只有一个编号和名字,像一张写着暗号的小纸条:SIGINT(2 号,「打断一下」)、SIGTERM(15 号,「请收拾东西退出」)、SIGHUP(1 号,「终端挂了」)、SIGKILL(9 号,「立即处决,不得申诉」)。
进程收到信号有三条路:按默认动作办(大多是终止);捕获它,执行自己登记的处理函数(比如先把文件存盘再退出);或者忽略它。只有两个例外:SIGKILL 和 SIGSTOP 既不能被捕获也不能被忽略——它们由内核直接执行,这就是 kill -9 是「最后手段」的原因:被杀的进程连一句遗言、一次清理都没机会说,可能留下半截写坏的文件。优雅的顺序永远是:先 15(请退),不听再 9(枪毙)。
Ctrl-C 的完整路径
现在把链路走一遍。你在规范模式的终端里按 Ctrl-C:
- 键盘送出数字 3 的字节;
- tty 层(第二章那位打字机幽灵)一看:3 是配置里的「打断键」,于是不把它交给程序,而是让内核给「前台进程组」所有成员各发一张 SIGINT;
- 程序没登记处理函数,按默认动作终止。shell 发现孩子死了,打印下一个提示符。
三个推论立刻可解释:
- 为什么 vim / less 里按 Ctrl-C 杀不死它?因为它们把终端切成了原始模式,tty 层不再做翻译,数字 3 的字节原样送到 vim 手里,vim 把它当普通命令处理(vim 里 Ctrl-C 约等于 Esc)。「翻译官被解雇了,暗号失效」。
- 为什么 Ctrl-C 能杀掉一整条管道?注意上面说的是「前台进程组」——shell 执行
a | b | c时把三个进程编成一个组放前台,一张 SIGINT 全员有份。这是「进程组」这个设计的用途:让「当前正在占用终端的那摊活」可以被一锅端。 - 为什么有的程序按 Ctrl-C 死得很干净?它登记了处理函数:收到 SIGINT,存好状态、删掉临时文件,再体面退出。区别全在有没有写这段代码。
Ctrl-Z 与后台戏法
Ctrl-Z(字节 26)同理,被翻译成 SIGTSTP「请暂停」。程序被当场冻住,shell 拿回前台。然后:
jobs:看看手头冻了几个程序;bg %1:让 1 号在后台继续跑(解冻但不回前台);fg %1:拉回前台。
启动时就加 &(如 python train.py &)是「生下来就在后台」。注意边界:后台程序往终端写输出还是可以的(会糊你一脸),但它想读键盘时会被自动冻结——终端的键盘只归前台进程组用。这也再次印证第八章:后台程序仍挂在这个终端的 pty 上,ssh 一断照样被 SIGHUP 连坐——后台 ≠ 免疫,免疫要靠 tmux 或 nohup。
窗口拉大拉小时,程序怎么知道
你拖动 iTerm2 窗口的边缘把它拉宽,vim 立刻重排内容填满新宽度。谁通知它的?又是一张纸条:模拟器把新尺寸告诉 pty,内核给前台进程组发 SIGWINCH(window change)。程序收到后重新查询尺寸、重画。这就是「窗口尺寸」为什么也是 pty 的属性之一(第七章提过 pty 附赠全套打字机行为,这是其中一件)。顺便说,stty size 能看到当前的行列数。
退出码:死后的验尸报告
程序死了还留一句话:退出码。echo $? 看上一个命令的。约定:0 = 成功,非 0 = 各种失败;如果一个程序是被信号杀死的,退出码是 128 + 信号编号——所以看到 130,就是 128+2,被 Ctrl-C(SIGINT)打死的;看到 137,128+9,是被 kill -9 处决的(内存超限被系统杀掉时也常是它)。看日志时这两个数字是最好的线索。
一句话总结:Ctrl-C/Z 不是字符,是 tty 层翻译成信号发给「前台进程组」的小纸条;信号可以被捕获或忽略(9 号和 STOP 除外);窗口缩放靠 SIGWINCH;退出码 128+n 告诉你程序死于哪张纸条。
原理部分到这就齐了。下一章换个姿势:不往下挖了,往上走——有了这套心智模型,登录一台陌生服务器后,工程师到底怎么干活。