今天我们来讲一讲大家很熟悉的、平时工作中使用的终端,以及终端背后的一整个世界。有点像回到 1970 年代——那时我们还没有高像素的图形显示器,大家就已经能完成今天能做的所有事情。这到底是怎么做到的?我们就从终端开始讲起。
我们把时间再往前倒一倒,来讲讲键盘的老祖宗。键盘的历史可能比大家想象的要长得多。当我们说 keyboard 的时候,你们第一反应肯定是笔记本上的那个键盘。但实际上,最早的 keyboard 不是用来干这件事的。最早可以追溯到公元前三世纪的 water organ(水风琴),再后来有管风琴,声音很好听。
到 1500 年左右,出现了最早的钢琴原型——一根一根的琴弦,按下按键的时候有一个锤子会打到琴弦上。钢琴是一种击弦键盘乐器,按下 keyboard 上的按键,就听到了非常悦耳的声音。这就是 keyboard。
这个发明距今已经有 500 多年了。甚至更早的水风琴时代,它就已经有按键了——你按下一个按键,它就能发出相应的声音。
随着时间的推移,你一定会想到这样的机构:按下去的时候,可以用一个弹簧蓄力;松开的时候,弹簧蓄的力可以把一个机械结构向右移动固定的一小段。每次按下一个键,就有一个小锤子弹上来。不过现在不是打到琴弦上发出声音,而是打到一个浸了墨水的色带上。
色带你们可能没有见过——现在应该是用类似橡胶的材料做的。一个字模往色带上面一打,所有按键击打都是同一个位置,就可以在色带上打到纸上留下一个字。松开的时候,机构可以让字符再向右移动一格。于是你就实现了打字——不需要用手写,想到什么打什么。
因为这样的机械结构,当两个按键同时或接近同时按下的时候,要同时击打中间的位置就很容易卡住。所以在 1860 年代发明了我们现在的 QWERTY 键盘。你们刚开始学这个键盘的时候都觉得很奇怪,必须强行记住——从 ASDF 开始,用肌肉记忆的方式记下来。但实际上这个键盘的设计,恰恰是为了降低打字速度。
我甚至在 2020 年的时候玩过真正的打字机。我能看到色带支持两种颜色——一边黑色、一边红色,可以调整色带的上下位置来切换。为了把字模推到色带上并且击中它,还要有足够的能量推动光标移到下一个位置,所以打字机按起来是很累的,需要相当大的力量才能按下去,不像我们现在的键盘按得很轻松。
打字机给我们现在的终端留下了很多遗产。比如 Shift 和 Caps Lock——大家都知道按 Shift 可以打出大写字母,按 Caps Lock 可以锁定大写。这两个键其实都有对应的机械结构:按下 Shift 的时候,整体的字锤会向上移动错开一格。上下有两个字母,通过这样简单的机械结构实现大小写切换。Caps Lock 就是把档位锁定在上面的位置。
再比如 Enter(回车)。你们在 printf 的时候有两个特殊字符:\r 和 \n——一个叫 Carriage Return,一个叫 Line Feed。\r 实际上就是回车,来自打字机——将打印头移回行首。打字机每按一个键就会往右走,你可以用手直接把它推回去,推回去的过程同时也是给弹簧蓄力。
那如果打印一个 "Hello",中间加上一个 \r 会发生什么呢?打印了几个字符以后光标回到行首,再打就会覆盖掉之前的内容。对于打字机来说,打上去就不能再擦掉了。那打错了怎么办呢?
所以就有了几个控制光标的键:Space 和 Backspace。你们有没有想过,退格键为什么叫 Backspace 而不叫 Delete?因为它和 Space 是互逆的操作——Space 把当前位置向右移一个,Backspace 把当前位置向左移一个。打错的话就不停 Backspace,然后打印若干个斜杠,就像画掉了那些字。如果你看到一些老照片上打字机打出来的文稿,会用这种方式把字杠掉,甚至可以换一个颜色的色带用红色杠掉。
你们键盘上还有一个叫 Tab 的按键。比如用 Ctrl+Tab 或者 Alt+Tab 来切换窗口。Tab 就是制表符。在打字机上有一些可以设定的 Tab Stop——按下 Tab 的时候,光标会一直往右走,直到碰到一个 Tab Stop 为止。这样就可以很容易地实现对齐。比如要制一个表格,有 Name、ID 这些列,不应该用空格做 Padding,而是按 Tab。无论名字多长,只要按 Tab,它就会跳到下一个 Tab Stop 的位置,对齐就实现了。
打字机也带来了 monospace(等宽字体)——因为每次打字后向后移动的距离都是完全确定的。这个习惯一直延续到今天,我们在 terminal 里依然用等宽字体。也许你们可以想象做一个不是等宽字体的终端,也许会有非常有趣的效果。
好,键盘的雏形基本上就奠定了。从机械化时代走到电气化时代以后,就有了一种叫 teletypewriter 的电传打字机。它和打字机的功能其实一样,但有一个很关键的区别:当我按下一个"a"的时候,不是在本地打出字来,而是可以在导线的另一端打出字来,而这个导线可以非常远,甚至在大陆的另一端。
这就是跨时代的设计。如果用打字机,我打出一张纸,然后请邮差送到另一个地方。但如果有 teletypewriter,只要有一根维护良好的导线(中间有很多中继站),我只需要给每一个字符编一个码。这是一个 5-bit 的编码,1920 年就有了。编码了字母、数字,还有一些特殊符号——比如你看没有美元符号,很明显这不是由美国人设计的机器。
这里有一张老照片,有兴趣的同学可以去读一下我链接里的 Teletype Model 28 的技术数据手册。那是 1951 年的产品,文档非常清楚,以非常高的标准记载了整个系统是怎么构建的、有哪些零件和部件、工作原理是什么、内部电路的波形和特性。读这些东西的时候,你可能会有一点小小的震撼——1950 年代人类的科技就已经发展到这样的程度了。
经过这些演进,终于有一天,计算机从最早的程序以机器码直接运行,运行完不停地戳孔输出二进制。这对人类来说实在太不友好了。当计算机变得更快以后,我们就很想让计算机的输出以人类肉眼可读的方式直接呈现出来,也不想再像 Fortran 时代那样去穿孔打卡了。
跨时代的产品出现了——video terminal(视频终端),也就是我们现在看到的 tty 名字的由来。tty 就是 teletypewriter。从打字机继承了最重要的功能:从计算机硬件或操作系统的角度来看,它给我们的接口就是 putchar——计算机生成一个字符,送给终端。就像往一个 memory-mapped I/O 端口里写一个字符,打字机收到后就把它打出来。如果送一个 \r,打字机就回到行首;送一个 \t,就到下一个 Tab Stop。在那个时代就制定了很多标准。
所以你们学计算机的时候会学一个叫 ASCII 码的东西——一个国际标准,确定一个二进制数字对应哪一个字符。你们有没有困惑过:大写的 A 是 65,小写的 a 是 97,为什么是这么大的数字?前面都是些什么呢?这其实就是 video terminal 设计中最有趣的地方,我今天会给大家解开所有关于终端的谜题。
到 1978 年,VT100 是一个里程碑式的产品,成为了事实上的行业标准。它支持了完整的 ANSI escape sequence(escape code),可以控制颜色、屏幕滚动等功能。就像打字机可以滚纸一样,video terminal 也可以滚——想象成一张无限长的纸,可以往上滚、往下滚。那个时候 80×24 个字符的显示就成为了标准 terminal 的布局——你们在 Windows 里写代码时那个黑色窗口默认的大小就是这样。
随着 teletypewriter 最终演变成高清显示器,tty 终端并没有完全消失。尤其是有了 Unicode 以后就可以玩更多花样了。比如你看到一个三角形——不是画出来的,而是把很多 Unicode 字符叠加起来的。Unicode 规定了大量字符,包括 emoji。这些 emoji 就像一个词一样,被赋予含义,在 token space 里有自己的 embedding——但你很难把它说出来。
Unicode 里有不同高度的矩形块、不同高度的线条、空心的实心的。当这些组合足够多的时候,就可以用 Unicode 字符拼出任何一个形状。
终端从打字机一路演化到今天——最早是一个像钢琴一样的击弦机构,然后一点一点非常平滑地演化。每一步都是一个非常小的改动,但积少成多就变成了今天非常好用的东西。比如大家都喜欢做一个 Powerline 的状态栏,定制自己的 zsh,通过 Unicode 和前景色、背景色调出不同风格的状态显示。
终端作为输出设备,它和操作系统或软件之间的接口是接受一个 putchar,收到字符后就把字符打印出来,就像 typewriter 一样。
但因为有键盘,它还可以作为电脑的输入设备。当我按下一个"a"的时候,video terminal 把"a"这个字符送到了计算机里,由操作系统看到这个"a"。至于到底要不要把"a"打印出来,还是打印个"b"给你,这都是由操作系统和软件决定的。terminal 只是一个比较"傻"的设备——按下一个键就给你一个字符。比如按下 Esc,它真的是给你一个 Esc 的 ASCII 码(\033)。操作系统和应用程序共同决定应该怎么处理。
所以终端就是一个非常简单的、很像 typewriter 的设备。按下 Shift 的时候,虽然键被按下了,但其实没有任何数据被送到计算机里——Shift 的状态是在终端上的,就像打字机上按 Shift 以后字锤没有敲下去,只有真正按下"a"的时候,才把一个大写的"A"送进去。所以 Shift 是不会被送到计算机里面的。
我让 AI 帮我写了一段程序:直接读取终端上面所有按键码,把终端送给操作系统的所有字符原封不动地打印出来,不做任何处理。
这里有"Press Q to exit",所以按 Q 会退出。按 A、B、C、D、E、F 显示的是正常的 ASCII 码。按 Shift 的时候没有收到任何按键;只有按下"A"的时候才真正发送数据。
按下 Ctrl+C 的时候会发生什么?不是程序被杀掉,也不是得到一个代表 Ctrl 的 ASCII 码再加上一个"C"——而是得到了一个编号为 03 的 ASCII 码。这就解释了 ASCII 码里那些你不知道是干什么的编码。ASCII 码的设计就是和 teletypewriter 共同完成的。按 Ctrl+D 得到 04。
更有意思的是,ASCII 码里没有上下左右键。那在键盘上按下方向键会怎样?回车给的是 \r(和 typewriter 上的行为一样);Backspace 可能被解释成 Delete;Tab 给一个 \t。
按"上"键——会收到不止一个字节!因为 ASCII 码里没有"上",它被编码成了一个 escape code。所以不仅可以送 escape code 给终端,也可以从终端收到 escape code。上、下、左、右正好是 41、42、43——它们都是对应的 escape code。
这样你就完全清楚终端和操作系统之间的关系了:它就是非常傻的东西,就像打字机一样。有意思的是,在这个模式下 Ctrl+C 已经退出不了了,Ctrl+Z 也退出不了。幸好我写代码时留了一个 backdoor——按 Q 可以退出。如果把这行代码注释掉,你就真的退出不了了,只能另开一个终端把这个进程杀掉。
VT100 是一个真正的终端——可以向它写字符、写 escape code,也可以从它那读字符、读到 escape code。但在现在的计算机系统里,终端想要多少就有多少。每按一下就有一个新终端,每个都不一样,都有 /dev/pts 下的编号。
我们的操作系统有一个非常有趣的能力:可以无限制地分配像 VT100 那样的虚拟终端——要一个就给你变一个,结束时再销毁,下一个还可以复用这个名字。这就叫 pseudo terminal(伪终端)。
我直接用 vibe coding 写了一个叫 minitty 的程序——就像 tmux 一样。tmux 里可以分屏,分出多个终端,每一个都不一样。如果让你们实现一个 tmux,你们能做到吗?
我在当前终端里再创建一个 /dev/pts 下的伪终端。外面的终端是 /dev/pts/3,运行 minitty 后再看 tty,它就认为自己是 /dev/pts/4。相当于向操作系统要了一个新的 VT100,把它的大小配置成比屏幕小一点,然后画出来,在里面可以干任何事。
这是怎么办到的?你们可以在 AI 的帮助下直接读我的代码。原理是:外面的程序收到终端里的输出,把输入推到里面的终端;我需要把 pty3 的输入转发到 pty4 里,再把 pty4 的输出拿出来放到 pty3 里。
这涉及一系列操作系统 API。我们的操作系统里有一个特殊设备叫 /dev/ptmx。上次课讲的 UNIX 里 "everything is a file"——终端也是一个 file。创建终端就是创建一个新文件,向 /dev/ptmx 请求操作系统分配一个新的终端。首先打开 pty master 设备(open("/dev/ptmx")),然后用 grantpt、unlockpt,最后得到路径和文件描述符。一旦你作为 master 持有这个 pty,就可以读写里面的数据了。
因为可以随便申请伪终端,就可以干很多有趣的事。比如有个命令行工具叫 ttyrec——录制终端操作。运行 ttyrec 后,它偷偷创建了一个新的 pseudo terminal,把所有终端的输入和输出都截获了。然后用 ttyplay 回放,每一个字符的时间戳都记下来,就可以完整恢复一次终端操作。
网上有很多工具可以把终端的 trace 渲染成 HTML 上的 SVG,嵌入到网页里,让你可以看到终端里发生的一切。我觉得最终这个基础设施可以让 AI 帮我生成演示脚本、想好剧本,然后在真实环境里执行,一边运行一边讲解。记下来的是 ttyplay 格式的文件,里面的字符和 color code 都在日志里。
如果我们不满足于一个只能打印字符的终端呢?我的终端模拟器是可以显示图片的——货真价实的彩色图片。随着集成电路技术的发展,终端本来只能显示 24×80 个字符,后来可以做更大,但再大还是字符终端。
如果想真的在终端上显示图片呢?可以在 escape code 上做一个小小的扩展。DEC 早在 VT200 的时候就有一个叫 Sixel 的编码方式——可以在终端上显示一个高度为 6 个像素的单色块。2 的 6 次方正好可以被所有可显示的 ASCII 字符覆盖。比如问号是全白的,at 是一个黑的,a、b、c、d……这样就可以用像素块拼出图像。
后来这个标准被扩展了——可以加调色板,每一块的颜色是什么,就变成一个 mask。今天很多现代终端(我平时用的 Kitty、现在用的 Foot,包括更新后的 Windows Terminal)都支持在终端中显示 Sixel 图像。
大家要记得,无论你看到的是什么——Ctrl+C、Ctrl+D,还是在终端上看到的图像——它的接口都是非常原始的:我可以送一个字节流给终端,终端也可以把字节流送回给操作系统或应用程序。
你们感受到的终端是这样的:运行 cat,它会执行 read 系统调用,从 stdin 里读数据。但按"a"的时候它没有马上返回——read 还没有返回。我可以退格修改,只有按下回车键的时候 read 才会返回。
这不和我的模型矛盾了吗?不矛盾——终端实际上会把字符送给操作系统,而不是直接送给应用程序。操作系统会提供一个行编辑模式。终端模拟器(Terminal Emulator)模拟了一个终端设备,它的功能和操作系统一起完成你看到的 Canonical Mode。
终端是人机交互的第一个设备。从 1970 年代 VT100 的时代起,它就是最重要的和人打交道的 I/O 设备。操作系统启动的时候——固件运行,操作系统代码开始运行,完成一定的初始化以后,就会把终端设备扫描好、初始化好。
然后 init 进程会调用一个叫 getty 的程序——它打开一个 TTY 端口,提示用户输入登录名,然后调用 login 命令。系统刚启动时,第一个进程的文件描述符 0、1、2 什么都没有。init 一直也不知道自己处于哪个终端上,因为系统可能有五六个终端。
getty 会负责在第一个终端上启动程序,在第二个、第三个终端上也启动程序。在 Linux 里,按 Ctrl+Alt+F1 到 F6,每一个都可以看到一个提示用户登录的界面。比如我现在在 Ctrl+Alt+F7,图形用户界面运行在 TTY7 上面。
这里有一个有意思的选项叫 Baud Rate(波特率)——就是接口真实传输的速率。如果你真的接了一个物理终端,就需要给它一个有意义的数字,操作系统才能正确地和终端握手。现在是模拟出来的,所以这个数字不是那么重要,但你查看当前伪终端的时候,它依然模拟出一个 38400 的 baud rate。
远程登录的情况稍有不同:SSHD 会分配一个 pseudo terminal,fork 一个子进程,创建伪终端并完成初始化,再执行登录和权限切换。
无论用哪种方式登录,都会有一个终端和你当前的 Session 关联起来。你就相当于坐在这个终端前面,所有的操作都是和这个终端交互的。
在终端上可以构建一整个用户交互的世界。这里推荐一个 Python 库叫 Textual——AI 也用得很好。这是一个完全的命令行终端界面,没有用 Sixel 显示图片,但你看到的效果就像一个 Windows 窗口一样:可以用鼠标滚轮控制,有 Checkbox、Radio Button、滚动条、表格,可以更换主题,甚至可以做动画。这和你们在 Windows 上看到的界面没有任何本质区别,只不过是用字符和 Unicode 拼出来的。它自带的拼版游戏——把一段 Python 代码 shuffle 后拼回来——就是华容道,可以在多项式时间内完成。
你们头上始终有一朵乌云没有想清楚:按下 Ctrl+C 的时候到底发生了什么?讲了终端以后你们觉得更费解了——因为你甚至可以像我一开始那样让操作系统直接把终端收到的所有字符都给你,这时按 Ctrl+C 就没有任何反应,它只是一个字符。
这一切其实都是操作系统搞的鬼。终端收到的每一个字符——比如 Ctrl+C——它的行为就是送一个对应的字符。如果是 Shift,设备不会发送任何数据,直到比如按下"A",才发送一个大写的"A"给驱动。驱动会根据应用程序对终端的配置来决定行为。
在默认的 Canonical Mode 下,操作系统的驱动程序会模拟出行编辑的状态,内部有个 buffer。按下"A"的时候不会马上到进程——"A"会到驱动的缓冲区里。按 Backspace 的时候驱动会把最后一个字符去掉。驱动还可以送字符给终端做回显——可以打开回显或关闭回显。你们用 sudo 输密码的时候不显示字符,就是这个原理。
当你按下 Ctrl+C(End of Text)或 Ctrl+D(End of Transmission)的时候,这个字符会被操作系统解读成另一个含义送给进程。取决于你怎么配置终端驱动。如果配置成所有字符驱动都不管、直接送到程序里,你就会看到之前那样——无论按什么都退出不了。
可以用 stty -a 查看当前终端支持的配置,比如 intr = ^C、quit = ^\。当 Ctrl+C 无法退出的时候,你们可以用 Ctrl+\ 来退出(SIGQUIT)。Ctrl+C 是正常退出,Ctrl+\ 是异常退出。还有 eof = ^D(End of File)等等。
在 Canonical Mode 下按 Ctrl+C,操作系统知道这个字符后会给进程发送一个 SIGINT(interrupt)。代码上,我们可以用 signal() 注册 handler:对 SIGINT 注册一个 handler,在收到信号时打印 "receive SIGINT" 但不退出;对 SIGQUIT 则打印 "receive SIGQUIT" 并执行 exit(),且用 atexit() 注册了 cleanup 函数。
无论程序在干什么(比如 sleep),收到 SIGINT 它马上就能打印出来。按 Ctrl+\ 给它一个 SIGQUIT,就能正常退出。如果不注册 signal handler,收到 SIGINT 后默认行为就是退出——这是打断你的默认行为。
还可以用 kill 命令主动给另一个进程发送信号:kill -SIGINT 18834,效果和在终端上按 Ctrl+C 完全一样,因为调用的是同一份代码。
更麻烦的问题:Ctrl+C 这个 SIGINT 到底发给谁了?如果有两个程序不停地向终端输出,按 Ctrl+C 是不是无论你创建多少个进程都被杀掉了?为什么 VSCode 毫发无损?如果一个程序 fork 出子进程,按 Ctrl+C 的时候是杀掉一个还是全部?
Fork 会产生树状的结构,还可能脱钩——比如 104 号进程的父进程 103 号死掉后,104 号变成 1 号进程的孩子。但按 Ctrl+C 时,104 号还是应该被杀掉,因为它们都是由同一个程序 fork 出来的。
UNIX 为了确定到底应该杀掉谁,做了一个大费周章的设计——Session 和 Process Group。
每当启动一个进程,它都会继承父进程的 Session。登录或 SSH 连接时都会有一个 Session,每个 Session 都有一个 controlling terminal(控制终端)。在 Session 的基础上还有 Process Group(进程组)。
Bash 有一个和 Windows 多任务几乎完全相同的功能。比如用 Vim 打开一个文件,按 Ctrl+Z 就把它"最小化"了——程序没有终止,只是暂停。可以再编辑另一个文件,用 jobs 命令查看已打开的任务(就像 Alt+Tab 轮换窗口),用 bg 让某个任务在后台继续运行,用 fg 把它挪到前台。这不就和 Windows 里启动应用、在窗口之间切换完全一模一样吗?只不过终端里一次只能显示一个应用而已。
背后就是 Session 和 Process Group 的概念。在 Bash 里启动一个新程序的时候,会为它分配一个新的进程组(就像 Windows 双击时给一个新窗口)。默认情况下,子进程会继承父进程的进程组。操作系统有系统调用可以改变进程组。
Ctrl+C 的行为就是:操作系统给前台进程组的所有进程都发送 SIGINT。 后台的进程不会收到。如果没有前台进程,按 Ctrl+C 没有任何程序会受影响。
如果你们租过云主机,在上面跑一个 long-running program,网络连接断了以后程序就终止了。这是因为登录时会引入一个 Session,子进程继承父进程的 Session ID,每个 Session 关联一个控制终端。当 Session leader 退出时,整个 Session 里所有进程都会收到 SIGHUP 信号,默认行为就是退出。
所以你们知道有个命令叫 nohup——请忽略 SIGHUP 信号,才可以继续执行。或者用 tmux——tmux 不会收到 SIGHUP 信号,所以可以继续在后台运行。
相应的操作系统 API 有 setsid、getsid、setpgid、tcsetpgrp 等。随着 UNIX 系统的增长,为了解决各种问题,打了无穷多的补丁——UID、effective UID、saved UID 等等。
这是一个历史遗留问题,甚至成为了 POSIX 标准的一部分。任何声称 POSIX 兼容的系统必须实现这个机制。但如果回头来想,你并不需要绑定进程到设备。比如 Android 的设计——每个 app 都是一个不同的用户,借用 Linux 的权限隔离。"强行终止"就是遍历属于这个用户的所有进程,全部发送 SIGKILL。不需要什么 controlling terminal。
我们今天有容器、虚拟机、namespace、cgroups——一个程序启动就在一个虚拟机里,想杀掉就把虚拟机关掉。但当时设计 Session 和 Process Group 的时候还没想到未来会有 Docker 容器,所以做了一个 workaround,这个 workaround 一直活到了现在。
如果有机会重新设计系统,可以做得更好。我觉得在 AI 时代,所有同学都可以去突围——大厂必须遵守旧标准,但如果你有胆量把旧的世界整个破坏掉,建一个新的秩序,我觉得可能会有机会。
以上就是终端和 Job Control。你们的 first principle 是:终端只是和计算机操作系统交换字符,其他所有行为都是你可以定义的——或者说你作为操作系统设计者可以定义的。最终操作系统设计者收敛到了这样一个方案,仅此而已。
有了终端以后就可以讲讲 Shell。就是你现在看到的以一个美元符号提示你输入命令的小程序。这个程序真的是每个人都能写出来的,比你想的要简单。如果不要 Job Control 那种复杂功能,它是一个同步的流程:while(1) 循环,先 printf 一个提示符,然后用 read 系统调用从 stdin 得到输入,然后执行命令。最早的 Shell 真的就是这样。
我今天其实有一份示例代码——一个最小的、零依赖的 UNIX Shell,从 xv6 里面提取出来的,连一个库函数都不依赖。
Shell 不只是一个命令行工具。它是 UNIX philosophy 的载体,是 UNIX philosophy 背后的编程语言。UNIX philosophy 说 "Keep it simple, stupid"——让每一个工具做一件事情并且做好,然后用文本接口、用管道把一个程序的输出交给另一个程序做输入。背后这件事情很大程度是用 UNIX Shell 编程语言支撑的。
有一个程序员笑话:当你坐在 Linux 面前(VT100 面前),按下的每一个字符都是在编程。因为你输入的每一个命令,在 UNIX Shell 看来都是一个表达式或程序。
Shell 是一个基于文本替换的极简编程语言。它的设计和 C 完全不一样,甚至连整数都没有——没有数据类型。如果在最早期的 Shell 里要算加法怎么办?先把两个数字变成字符串,然后调用一个外部命令 expr(expression)。比如 expr 1 + 2 会告诉你 3。注意必须用空格分隔,因为只有空格时它才会把 1、+、2 分成三个字符串放到 argv 里。
就像 C 里面的 #define 做字面意义上的文本替换一样,Shell 也做文本替换。可以用 $(...) 把一个程序的结果以字符串形式贴进来,甚至可以把一个结果包装成文件描述符,用 <(...) 生成路径。
这非常有用。比如有些数据是动态生成的,用 <(echo 1) 生成一个路径(像 /dev/fd/63),然后 cat 读这个路径就能得到里面的输出。这样就可以做 diff——有的工具必须输入文件(如 diff 需要两个文件),没办法都放到标准输入里。这也算是 UNIX 设计的一个缺陷,但被各种 workaround 解决掉了。
重定向就是把文件描述符换一个位置。因为 execve 会继承父进程的文件描述符,所以 Shell 先 fork 一份,把编号为 1 的文件描述符替换成指向 a.txt 的文件描述符,然后执行 execve——不管执行的是什么命令,往编号为 1 的文件描述符写数据就会写到文件里。
用 > 重定向标准输出,用 < 重定向标准输入,用 2>&1 这种语法重定向标准错误输出。
Shell 也支持控制流。可以用 ; 在命令 1 运行完后运行命令 2。还可以用短路求值:&& 在 C 语言里如果左边是 false 就不算右边;|| 如果左边是 true 就不算右边。所以常见写法是 cmd1 && cmd2(左边成功了才执行右边),或者用 || 做 fallback——尝试一个工具能不能用,不行就试下一个,直到最后能用为止。
上一节课讲的:通过 pipe 系统调用创建两个文件描述符——一个指向管道的读口、一个指向管道的写口。Fork 以后分别把一个进程的读口关掉、一个进程的写口关掉,再 dup 为相应的文件描述符,就可以把一个程序的输出接给另一个程序做输入。
虽然 AI 时代学什么都容易了,但学什么就成了一个大问题。我每年在操作系统课都推荐大家去读 man sh(command line interpreter)。里面有无穷多的宝藏——比如追加写入用 >>,2>> 追加标准错误输出等等。
我强烈建议大家把手册打印出来,管道给一个 AI agent——这也是 UNIX philosophy 在这个时代还在发挥余热。让 AI 帮你 summary,在 AI 的帮助下阅读手册,你就知道什么事情是能干的、什么事情是不能干的。
在这个时代你们最需要的就是有一个清晰的概念——什么事情是能办到的。人没有办法做自己想象不到的东西,而扩展想象力的方式就是去看别人想到了什么。
UNIX Shell 也不是绝对好的——它是 1970 年代算力、算法和工程能力限制下的无奈取舍,后人将错就错。比如 ls > a.txt | cat——输出已经重定向了,再管道给 cat,这是未定义行为。不同的 Shell 实现不一样:在 fish 和 bash 里什么也看不到,但在 zsh 里它会像 tee 一样既写文件也在标准输出显示。
写脚本时,头上一般是 #!/bin/bash 或 #!/bin/sh,不会用 fish 作为脚本解释器,因为它不是特别标准。还有一些有意思的例子——比如 sudo 在某些情况下不起作用,你们回去可以想一想,想不通就让大语言模型帮你。
最后给大家准备了一个彩蛋:一个没有任何依赖的 freestanding Shell,包括系统调用都是用汇编写的,大家可以去看。