写在前面这是本系列的第八篇。前面的课程中我们已经知道如何用“文件描述符”相关的系统调用访问操作系统中的对象open,read,write,lseek,close也知道了如何用pipe创建管道对象。但操作系统中的对象远不止于此。今天我们要深入一个每天都在用但细思恐极的对象——终端 (Terminal)。从机械打字机开始一探究竟Ctrl-C到底做了什么并在此基础上剖析 UNIX Shell 的底层原理。终端的历史从机械打字机到显示器我们今天在键盘上敲击的每一个按键在终端里看到的每一次换行都刻满了 100 多年前机械时代的烙印。打字机时代遗产 (Typewriter, 1860s)QWERTY 键盘:这其实是为了降低打字速度而设计的防卡纸方案。因为早期的纯机械结构按键太快会导致字锤卡在一起。Shift 键:按住它机械结构会使字锤或字模向上移动一段距离从而切换字符集打出大写字母或符号。毕竟大小写各占一个物理按键太浪费空间了。回车与换行 (CR LF):\rCR (Carriage Return - 回车):将物理打印头移回行首。\nLF (Line Feed - 换行):滚筒转动将纸张向上移动一行。在 UNIX 中\n在逻辑上同时包含了 CR 和 LF 的动作而在 Windows 中历史遗留问题导致换行依然严格保留着\r\n两个字符。Tab Backspace:它们在早期本质上是“位置移动”指令。在纸上打错字是去不掉的所以早期的 Backspace 配合减号使用退格然后打个横线表示“错了划掉”。封神之路VT100 终端 (1978)为了发电报人们发明了电传打字机 (Teletypewriter, 即 TTY)。后来加入了屏幕进化成了 Video Teletypewriter。DEC 公司在 1978 年推出的VT100终端成为了事实上的行业标准。它是首个完整实现ANSI Escape Sequence (转义序列)的终端。80 列 × 24 行的字符显示成为了至今深深影响终端窗口的标准布局。今天我们用的终端伪终端 (Pseudo Terminal)你在系统里比如 VSCode、Tmux、SSH可以随意创建无数个黑框框它们其实都是伪终端 (PTY)。PTY一对双向通信的“管道”伪终端在底层是成对出现的主设备 (PTY Master):连接到你看到的那个“终端模拟器”黑框框 GUI 界面。从设备 (PTY Slave):连接到 Shell 或其他后台程序例如/dev/pts/0。终端模拟器是怎么实现的现在利用学过的系统调用你也可以自己写一个终端模拟器了调用openpty()函数通过/dev/ptmx向操作系统申请一个新终端。系统会返回两个文件描述符Master 和 Slave。调用fork()创建子进程。子进程把stdin/stdout/stderr全部重定向指向 Slave然后execve执行bash。父进程从 Master 读取子进程的输出渲染画到屏幕上把你敲击的键盘按键写入 Master 传给子进程。极客扩展既然是通过转义序列 (Escape Sequence) 渲染字符那能不能渲染图片现代终端如 Kitty扩展了转义序列允许控制大小、位置甚至动画例如\033[60C_开头\033\结尾传递图像数据。未来终端内嵌 WebView 也是大势所趋。终端的模式与属性终端的两种模式Canonical Mode (规范模式 / 按行处理):你敲击键盘时程序读不到数据终端自己提供行编辑功能比如退格修改。直到你按下回车整行数据才会被发送给程序。Non-canonical Mode (非规范模式 / 按字符处理):你按下的每一个字符都会立即发送给程序。用于实现交互式程序比如 Vim、SSH。终端属性控制 (tcgetattr / tcsetattr)这些 API 可以控制终端的各种行为回显、信号处理、特殊字符拦截等。比如当你在终端输入密码时看不见字符底层就是调用了这个 API 临时关闭了“终端回显”功能。进程管理与 Job Control (作业控制)当系统里有一大棵复杂的进程树有的在前台有的在后台这时你按下Ctrl-C到底该终止哪个进程答案终端才不管呢终端非常蠢它只管传输字符。Ctrl-C本质上只是发送了一个 End of Text (ETX) 控制字符\x03。Ctrl-D只是发送了 End of Transmission (EOT) 字符\x04。(你可以用stty -a命令看到当前终端的所有按键绑定奇怪的知识又增加了)操作系统如何处理 Ctrl-C当操作系统收到\x03这个字符时它需要对“当前进程”采取行动。但fork()产生了那么庞大的树状结构谁才是“当前进程”为了解决这个问题UNIX 引入了两个令人头疼的编号Session ID (会话大分组):子进程会继承父进程的 Session ID。一个 Session 关联一个控制终端 (controlling terminal)。当 Leader比如你的 SSH 登录窗口退出时全体进程都会收到 Hang Up (SIGHUP) 信号集体自杀。Process Group ID (进程组小分组):一个会话里可以有多个进程组但只能有一个前台进程组。当操作系统收到Ctrl-C带来的\x03字符时它会向前台进程组里的所有进程发送SIGINT(中断信号)不能误伤后台进程。无奈的妥协千疮百孔的 API为了管理这些组诞生了setsid,getpgid,tcsetpgrp等极不优雅的 API。再加上各种uid,effective uid,saved uid的权限管理……几乎任何人、任何软件都无法预知未来的走向历史的糟粕就这样成了 POSIX 标准的一部分。学会去未来回头看现在如果抛开历史包袱人机交互的方式根本不应该是这样的。现代 GUI 管理器Window Manager只需要“进程组”就够了。关掉窗口强行杀掉该组所有进程一个不留。Android 系统里干脆把每个 App 当作不同的 User 处理。Snap 甚至用AppArmorseccompnamespaces把程序关在绝对隔离的沙箱里运行。信号与 Ctrl-C 的底层代码演示当操作系统决定向前台进程发送信号时底层发生了什么注册处理程序 (signal / sigaction):告诉操作系统如果收到某个信号请去执行函数f。发送信号 (kill):强行在程序的正常执行流中插入一个向f的跳转。这就是为什么信号能打断死循环。代码演示#includestdio.h#includestdlib.h#includesignal.h#includeunistd.hvoidhandler(intsignum){switch(signum){caseSIGINT:printf(Received SIGINT!\n);break;caseSIGQUIT:printf(Received SIGQUIT!\n);exit(0);break;}}voidcleanup(){printf(atexit() cleanup\n);}intmain(){// 拦截 Ctrl-C (SIGINT) 和 Ctrl-\ (SIGQUIT)signal(SIGINT,handler);signal(SIGQUIT,handler);atexit(cleanup);while(1){charbuf[4096];intnreadread(STDIN_FILENO,buf,sizeof(buf));buf[nread-1]\0;printf([%d] Got: %s\n,getpid(),buf);if(nread0){perror(read);exit(1);}sleep(1);}}运行结果你会发现按下Ctrl-C时程序不会退出了而是打印出我们自定义的语句。只有按下Ctrl-\才会触发SIGQUIT退出。rootLAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec8/signal# ./signal^CReceived SIGINT![1927]Got: s[1927]Got: s ^\Received SIGQUIT!atexit()cleanupUNIX Shell 编程语言UNIX 的用户可都是 Hackers所以 UNIX Shell 被设计成了一种基于文本替换的极简编程语言。只有一种类型字符串。语言机制极其简洁预处理$(), 重定向, 顺序结构||, 管道|。底层核心揭秘重定向 () 是怎么实现的利用子进程继承文件描述符的特性配合dup2移花接木// 1. 父进程先以只读/只写的方式打开好输入/输出文件intfd_inopen(...,O_RDONLY|O_CLOEXEC);intfd_outopen(...,O_WRONLY|O_CLOEXEC);intpidfork();if(pid0){// 2. 子进程中利用 dup2 将标准输入(0) 和标准输出(1) 重定向到文件 fd 上dup2(fd_in,0);dup2(fd_out,1);// 3. 执行目标程序目标程序根本不知道自己被重定向了它只管傻傻地往 0 读往 1 写。execve(...);}else{// 4. 父进程关闭自己用不到的 fd等待子进程执行完毕close(fd_in);close(fd_out);waitpid(pid,status,0);}这几十行代码就是你敲下ls out.txt背后操作系统发生的所有秘密。Shell 的反思优点与缺点优点高效、简洁、精确。它是一种“自然编程语言”一行命令协同调度多个程序。最适合 quick dirty 的 hackers。AI 时代遇到不懂的命令直接man tcsetpgrp | ag -q 帮我生成新手友好的教程或者出了打错字的命令直接用开源神器thefuck自动纠正。缺点文本数据的“责任自负”。Shell 的设计被“1970s 的算力、算法和工程能力”严重束缚。空格 灾难文件名里带个空格Shell 脚本随时崩溃。优先级混乱ls a.txt | cat到底先执行重定向还是先执行管道bash和zsh的行为甚至都不一样致敬经典课程最后提到了xv6-riscv的 shell 源码。一个真正能用的微型操作系统 Shell没有 libc 的加持底层完全是依靠刚才讲的这些系统调用手搓出来的。这很牛逼但也告诉我们底层的系统编程没有魔法只有一层一层严谨的系统调用抽象。