第8讲 · 终端和 UNIX Shell:从打字机到一门编程语言

2026 南京大学《操作系统原理》第八讲(Aside)· 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1nQXsBuEUz/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 https://jyywiki.cn/OS/2026/lect8.md
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第8讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成,术语、章节骨架与参考链接已按课程官方讲义(lect8.md)校准。讲师的口语风格、类比与案例被尽量保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:一个「非常傻」的设备,撑起整个交互世界

(参考时间: 00:00)

终端(terminal)是我们每天工作都要用的东西。这一讲要回到 1970 年代——那时候还没有高像素的图形显示器——去看看人们是如何完成今天我们能做的几乎一切事情的。答案的核心,是一个极其简单的接口:

终端只做一件事:和操作系统交换字符。 你送一个字节流给它,它把字节流送回来。

而围绕这个「傻设备」,UNIX 演化出了一整套令人叹为观止的机制:伪终端、信号、会话(session)、进程组(process group)、作业控制(job control),最终还有一门编程语言——Shell。


一、从键盘到终端:一段平滑的演化史

1.1 「键盘」的老祖宗

(参考时间: 00:00)

「键盘」的历史比大家想象的长得多:

这条线索的关键在于:按键—机械—发声这套「擒纵机构」会被不断复用。

水风琴与管风琴:键盘的老祖宗

📷 水风琴与管风琴:键盘的老祖宗
回到原视频 (00:01:00)

1.2 打字机:终端所有「怪键」的由来

(参考时间: 00:03)

把锤子从「打琴弦」换成「打色带」,你就得到了打字机(typewriter):

打字机给今天的终端留下了大量遗产,几乎解释了键盘上每一个怪键:

遗产 含义
Shift / Caps Lock shift 把「字锤/字模」向上移动一段距离,切换字符集(打大写);Caps Lock 就是把这个档位锁住
CR / LF \r(Carriage Return,回车)= 把打印头移回行首;\n(Line Feed,换行)= 把纸向上移一行
Space / Backspace 互逆操作:向右 / 向左移一格(打错了就 backspace 再打个 - 划掉)
Tab 移到下一个 tabstop 以实现垂直对齐,所以叫「制表符」
等宽字体(monospace) 因为每次移动的距离完全确定

编者拓展|为什么 UNIX 用 \n、Windows 用 \r\n:打字机上「回车」和「换行」是两个独立的机械动作:先把打印头拉回行首(CR),再把纸卷上一行(LF)。早期电传打字机需要显式发两个控制字符 \r\n。后来 UNIX 选择只用一个 \n,由终端驱动/程序去完成「回到行首 + 换行」的语义;而 DOS/Windows 保留了 \r\n。这个差异至今仍是跨平台文本处理的一大坑(dos2unix、Git 的 core.autocrlf 都为此而生)。

你甚至可以在 Python 里验证 CR 的行为:print('Hel\rlo') 会打印出 llo——\r 把光标移回行首,后面的字符覆盖了前面的。

编者拓展|打字机上的「所见即所得」与 emoji 的归宿:讲师提了个有趣的问题——emoji 现在像个「词」,有含义、在 token space 里有自己的 embedding,可你很难把它念出来。如果有一天 emoji 被收进新华字典,拼音该怎么标?这其实是「符号」与「语言」关系的现代注脚。

1.3 电气化:电传打字机与 Baudot 码

(参考时间: 00:11)

从机械打字机走到电气化,就有了电传打字机(Teletypewriter)——功能还是一样的「打字」,但有一个关键区别:按下一个键,字会打在导线的另一端。这根导线可以非常远,甚至可以跨越大陆。

Baudot Code:5 位编码的电传打字机字符集

📷 Baudot Code:5 位编码的电传打字机字符集
回到原视频 (00:12:00)

1.4 Video Terminal:VT100 与 ASCII

(参考时间: 00:13)

计算机早期直接以机器码运行、把输出戳成二进制孔(穿孔纸带/卡片),对人类极不友好。于是我们希望计算机的输出能以人眼可读的方式呈现——这就诞生了:

VT100(DEC, 1978):ANSI Escape Sequence 与 80×24 成为标准

📷 VT100(DEC, 1978):ANSI Escape Sequence 与 80×24 成为标准
回到原视频 (00:17:30)

编者拓展|ANSI Escape Sequence 与「终端里的图形」:VT100 引入的转义序列以 ESC(ASCII 27,八进制 033)开头,用来告诉终端「接下来不是要打印的字符,而是一条指令」——比如移动光标、改变颜色、滚动屏幕。今天你在终端里看到的一切「炫酷界面」,本质上都是在字符流里夹带转义序列。

有了 Unicode 之后,终端能玩的花活就更多了:用 ▁▂▃▄▅▆▇█ 拼出柱状图、用方框/线条字符画出窗口、用 Powerline 拼出带箭头的状态栏。讲师甚至能用 Unicode 字符叠出动物图案和 3D 效果。

1.5 终端:非常「傻」的输入设备

(参考时间: 00:20)

终端不仅能输出,也能输入。但它的输入模型同样简单:

按 Ctrl-C 会发生什么? 你会得到一个编码为 03 的字符——这正是 ASCII 里 ETX(End of Text)的编码!同理:

用 Unicode 字符拼出的图形与 Powerline 状态栏

📷 用 Unicode 字符拼出的图形与 Powerline 状态栏
回到原视频 (00:19:30)

Ctrl-C → 0x03 (ETX, End of Text)
Ctrl-D → 0x04 (EOT, End of Transmission)
回车    → \r (CR)

这就是 ASCII 里那些「不知道干什么」的控制字符的来历——它们是为电传打字机设计的。讲师还演示了一个「退不出去」的程序:它把终端配置成「所有字符都直接给我」,于是 Ctrl-C、Ctrl-Z 都失效了;幸好代码里留了「按 Q 退出」的后门,否则只能再开一个终端把进程杀掉。

编者拓展|箭头键为什么会送来一串字符:ASCII 里没有上下左右。于是终端只好把它们编码成转义序列:↑ 是 ESC [ A、↓ 是 ESC [ B、→ 是 ESC [ C、← 是 ESC [ D。所以「方向键」在程序看来就是一串以 ESC 开头的字节——这也是为什么早期程序处理方向键会出各种怪问题。


二、伪终端:想要多少就有多少

(参考时间: 00:28)

真实的 VT100 只有一台。而在现代系统里,终端想要多少就有多少——每次你开一个新窗口,操作系统就给你变出一台「虚拟终端」。这就是伪终端(pseudo terminal, PTY)。

graph LR
    App["终端模拟器<br>(如 foot / iTerm / Windows Terminal)"] -->|"open /dev/ptmx"| M["PTY Master<br>(主设备)"]
    M <-->|"双向字符通道"| S["PTY Slave<br>(从设备,/dev/pts/N)"]
    S -->|"stdin/stdout/stderr"| Sh["shell / 其它程序"]
mini-tty:终端中的终端(向操作系统申请一个新的 PTY)

📷 mini-tty:终端中的终端(向操作系统申请一个新的 PTY)
回到原视频 (00:29:30)

讲师现场用 mini-tty(让 AI 写的)演示了「终端中的终端」:在自己的终端里再创建一个 PTY,把输入转发进去、把输出取出来再显示到自己的终端——「这不就是 tmux 吗」。他还提到 ttyrec / ttyplay:因为终端之间只是交换字符,所以只要记录下所有流出 PTY 的字符和时间戳,就能把终端改造成一个「录屏器」,甚至渲染成网页里可回放的 SVG。

编者拓展|PTY 的内部一串操作:在 Linux 上创建一个 PTY 的典型流程是: 1. int mfd = open("/dev/ptmx", O_RDWR);——/dev/ptmx 是「多路复用」入口; 2. grantpt(mfd)、unlockpt(mfd); 3. ptsname(mfd) 得到从设备路径(如 /dev/pts/7); 4. open(ptsname) 打开从设备,把它设为子进程的 0/1/2。

这一整套,正是 sshd、tmux、script(1)、以及你用的终端模拟器在背后做的事。

如果我们不满足于「只能显示字符」呢? DEC 早在 VT200 时代就做了扩展——Sixel:一个字符编码一个 6×1 的「是/否」像素块,配合调色板就能拼出图形。今天的许多终端(Kitty、Windows Terminal 等)都支持它。讲师现场演示:在不支持的终端上什么也看不到,在支持的终端上确实出现了一小块像素图像。

Sixel:在终端里显示像素图像

📷 Sixel:在终端里显示像素图像
回到原视频 (00:38:30)


三、终端与操作系统

3.1 登录:getty、session 与控制终端

(参考时间: 00:42)

终端是「人机交互的第一个设备」。系统启动时:

graph LR
    K["内核初始化<br>(扫描并初始化终端设备)"] --> I["init 进程"]
    I -->|"为每个终端启动一个 getty"| G["getty<br>(open tty,提示输入用户名)"]
    G --> L["login<br>(校验并建立会话)"]
在虚拟控制台上由 getty 启动的 login 提示(Ctrl+Alt+F1…)

📷 在虚拟控制台上由 getty 启动的 login 提示(Ctrl+Alt+F1…)
回到原视频 (00:45:00)

编者拓展|getty 与波特率:getty 打开终端时会配置一系列参数,其中包括 波特率(baud rate)——如果是真的 VT100,这个值必须是对方能握手的有效值。今天终端是模拟出来的,波特率其实无关紧要,但你仍然能在当前伪终端上看到它模拟出来的 38400。

3.2 canonical mode:为什么 read 没有立刻返回?

(参考时间: 00:40)

你很熟悉的这个行为,其实不是 C 库做的,而是操作系统在「搞鬼」:

这是因为终端默认工作在 canonical mode(规范模式):终端驱动内部维护一个行缓冲区,并模拟出行编辑:

编者拓展|stty -a 里藏着你的终端配置:stty -a 会列出当前终端的全部配置,包括按键绑定:intr = ^C、quit = ^\、eof = ^D、susp = ^Z……这些都可以改。它还显示 icanon(是否规范模式)、echo(是否回显)等标志位。讲师建议:善用 stty -a 和 Ctrl-\——当 Ctrl-C 退不出去时,试试 Ctrl-\(SIGQUIT)。

3.3 Ctrl-C 到底发生了什么:信号机制

(参考时间: 00:59)

前面 demo 里,Ctrl-C 只是送来了一个字符 0x03。那为什么它常常能「终止程序」?答案是操作系统把某些字符「翻译」成了信号:

讲师演示了:注册 SIGINT / SIGQUIT 的 handler 后,Ctrl-C 会触发 receive SIGINT,而 Ctrl-\ 触发 receive SIGQUIT 并退出;也可以用另一个进程 kill -SIGINT <pid>,效果与在终端里按 Ctrl-C 完全一样——因为走的是同一份代码。

编者拓展|常见的终端相关信号: | 信号 | 触发 | 默认行为 | |---|---|---| | SIGINT | Ctrl-C(^C) | 终止 | | SIGQUIT | Ctrl-\(^\) | 终止并 core dump | | SIGTSTP | Ctrl-Z(^Z) | 暂停进程 | | SIGCONT | fg / bg / kill -CONT | 恢复已暂停的进程 | | SIGHUP | 控制终端关闭 / 会话 leader 退出 | 终止 |

现代编程应优先用 sigaction(2)(signal(2) 的语义在不同系统上不一致)。这也是为什么「写一个能捕获信号、优雅退出的服务」是系统编程的基本功。

3.4 信号发给谁?会话与进程组

(参考时间: 01:04)

真正麻烦的问题来了:

你按下 Ctrl-C 时,操作系统里的进程是一棵大树(还有「托孤」行为)。到底该杀谁? vs code 为什么安然无恙?如果 yes 又 fork 了一堆子进程,是杀一个还是全部?后台的进程又怎么办?

UNIX 为此做了一个大费周章的设计:

graph TD
    S["Session(一个控制终端)"] --> G1["Process Group 1(前台)<br>← Ctrl-C 只发给它"]
    S --> G2["Process Group 2(后台)<br>← 不受 Ctrl-C 影响"]
    S --> G3["Process Group 3(后台)"]

对应的 API 是:

编者拓展|SIGHUP、nohup 与 tmux,以及「云主机上跑长任务」的坑:因为「会话 leader 退出时全 session 收到 SIGHUP」,所以你的 SSH 连接一断,正在跑的长任务就会收到 SIGHUP 而终止。这就是为什么: - 要用 nohup(忽略 SIGHUP,把输出重定向)来跑长任务; - 或者用 tmux / screen:它们在服务器上维护一个不随 SSH 断开而退出的 session(tmux 里的程序不回收到那个 SIGHUP),你重新连上再 attach 回去。

租云主机训练模型、结果网络一抖程序就没了——根源就在这里。

3.5 Job Control:终端里的「窗口管理器」

(参考时间: 01:06)

有了 session / process group,终端就能实现作业控制(job control)——本质上是在只有一个屏幕的终端里,模拟 Windows 的窗口管理:

Windows 终端
双击打开程序 shell 为命令分配一个新的进程组
最小化 Ctrl-Z(SIGTSTP,暂停)
Alt-Tab 切换窗口 jobs 列出作业;fg / bg 前后台切换(tcsetpgrp)
关闭窗口 Ctrl-C(向前台进程组发 SIGINT)
关闭全部窗口 关掉整个 session / 杀进程组
job control:jobs / fg / bg / Ctrl-Z 在终端里模拟窗口管理

📷 job control:jobs / fg / bg / Ctrl-Z 在终端里模拟窗口管理
回到原视频 (01:07:30)

讲师现场演示了:Ctrl-Z 把程序「最小化」、jobs 查看、bg 让它到后台继续跑、fg 拉回前台、Ctrl-C 杀掉。他感叹:「这背后就是那个很讨厌的 session / process group 机制。」

编者拓展|这是一段「历史的糟粕」,但它是 POSIX 的一部分:讲师毫不客气地评价这套设计「很不优雅」「是历史的糟粕」,但它成了 POSIX 标准——任何声称兼容 POSIX 的系统都必须实现它,否则跑不了依赖这套接口的软件(如 bash)。回头看,其实并不需要「把进程绑定到设备」: - Android:每个 app 是不同的用户,借 Linux 权限隔离;「强行终止」就是遍历并杀掉该用户的所有进程; - Snap:程序跑在隔离沙箱里(AppArmor + seccomp + namespaces); - Windows Window Manager:只需要「进程组」——关窗口就全杀掉。

发明 session/pg 时,人们还没想到容器、cgroups、namespaces。讲师说:如果有机会重新设计系统,可以做得更好——这也是 AI 时代「有胆量破坏旧秩序、建新秩序」的机会。

编者拓展|uid / euid / saved uid 的坑:讲义在这里顺带提了一嘴 uid、effective uid、saved uid——这是 UNIX 权限模型里最容易被误用的一组概念。USENIX 有一篇经典论文 Setuid Demystified 专门讲「为什么 setuid 系列调用在不同平台上语义不一致、极易写出提权漏洞」。任何软件都很难逃脱千疮百孔的设计。


四、UNIX Shell:一门「只有字符串」的编程语言

(参考时间: 01:18)

有了终端,我们就可以讲 Shell——你面前那个 $ 提示符后面等命令的小程序。

4.1 Shell 真的很好写

(参考时间: 01:18)

while (1) {
    printf("$ ");
    read(0, buf, size);      // 从 stdin 读一行命令
    execve(command, argv, envp);  // 执行它
}

最早的 shell 真的就这么简单——一个 while 循环、一次 read、一次 execve。讲师展示了一份零依赖的 UNIX Shell(从 xv6 里「偷」出来的),用 -ffreestanding 编译,连一个库函数都不依赖——这正是「可以在系统调用之上,创建整个操作系统应用世界」的真正含义。

编者拓展|Shebang #!:为了让脚本能像可执行文件一样直接运行,UNIX 早期实现了以 #! 开头的「可执行文件」。内核看到文件开头是 #!,就会执行第一行里写的程序(如 /bin/bash),并把脚本文件路径作为参数传进去。这就是为什么脚本开头要写 #!/bin/bash、#!/usr/bin/env python3。

4.2 Shell 是 UNIX Philosophy 背后的编程语言

(参考时间: 01:20)

UNIX Philosophy 的 KISS 三原则(做一件事并做好、协同工作、用文本流作通用接口)——背后很大程度上是靠 Shell 这门编程语言支撑的。

有一个程序员笑话很传神:

当你坐在 VT100 面前,你按下的每一个字符都是在编程。哪怕你只是在整理文件,你输出的每一条命令在 shell 看来都是一个表达式或程序。

Shell 是一门基于文本替换的极简编程语言,它甚至连整数都没有:

它做的事情只有一件:把你的一条命令,翻译成一组系统调用的序列(fork / execve / waitpid / pipe / open / dup2……)。它的语法也都围绕这个思路:

graph TD
    C["一条命令"] --> P["预处理 / 命令替换<br>$()、&lt;()"]
    C --> R["重定向<br>&gt;、&lt;、2&gt;/dev/null"]
    C --> S["顺序与逻辑<br>;、&amp;&amp;、||"]
    C --> PP["管道<br>|"]
    P --> SC["系统调用序列"]
    R --> SC
    S --> SC
    PP --> SC

编者拓展|从命令到系统调用:重定向是怎么实现的:利用「子进程继承文件描述符」这一特性——父进程先打开好文件,在子进程里「腾挪」: c int fd_in = open("in.txt", O_RDONLY | O_CLOEXEC); int fd_out = open("out.txt", O_WRONLY | O_CLOEXEC); int pid = fork(); if (pid == 0) { dup2(fd_in, 0); // 把 0 号(stdin)换成 fd_in dup2(fd_out, 1); // 把 1 号(stdout)换成 fd_out execve(...); // 执行命令;它写 1 号就是写文件 } else { close(fd_in); close(fd_out); waitpid(pid, &status, 0); } $() 命令替换、<() 进程替换、&& / || 短路、| 管道,最终都会被翻译成类似这样的一组系统调用。讲师甚至用 <() 配合 diff(因为 diff 需要两个文件,没法把两个都塞进标准输入)——「这也是 UNIX 设计的一个缺陷,但被各种 workaround 解决了」。

4.3 有优点就有缺点

(参考时间: 01:31)

Shell 的设计被1970 年代的算力、算法和工程能力束缚了,后人只好「将错就错」:

编者拓展|AI 时代为什么还要读手册:讲师强烈推荐大家读 man sh(dash 的 command interpreter (shell))。理由是:AI 时代「学什么」成了难题,你容易进入「什么都能学、于是什么都不用学」的虚无状态;而真正稀缺的是「知道什么事能办到」——因为人无法做成自己想象不到的事。扩展想象力的方式,就是去看别人想到了什么。

他的具体做法堪称「AI 时代的 UNIX Philosophy」: bash man -P cat sh | claude "Make a comprehensive summary" 「Read the friendly manual!」——把手册管道给 AI,让它在几秒内帮你建立起清晰的概念。UNIX 的文本接口设计,再一次发挥余热。


总结


附:官方参考与延伸阅读

本讲的讲义见课程主页,以下链接按书中出现顺序整理:

主题 链接
1500s「击奏弦鸣乐器」前身(视频) https://www.bilibili.com/video/BV1Zg411P7GQ/
Teletype Model 28 technical data sheet http://www.samhallas.co.uk/repository/telegraph/teletype_28_tech_data.pdf
Powerline status bar https://github.com/powerline/powerline
终端 Demo:tty(终端就是一个字符设备) https://jyywiki.cn/OS/demos/virtualization/tty
信号处理 Demo https://jyywiki.cn/OS/demos/virtualization/signal
Shebang Demo https://jyywiki.cn/OS/demos/virtualization/shebang
Setuid Demystified(USENIX Security) https://www.usenix.org/conference/11th-usenix-security-symposium/setuid-demystified
xv6(教学操作系统,零依赖 shell 的来源) https://pdos.csail.mit.edu/6.828/2023/xv6.html
Textual(终端里的 TUI 框架) https://github.com/Textualize/textual
tmux https://github.com/tmux/tmux
ttyrec / ttyplay https://manpages.debian.org/testing/ttyrec/ttyrec.1.en.html
OSTEP:Operating Systems: Three Easy Pieces https://pages.cs.wisc.edu/~remzi/OSTEP/
stty(1) 手册 https://man7.org/linux/man-pages/man1/stty.1.html
tty(4) / pty(7) 手册 https://man7.org/linux/man-pages/man7/pty.7.html
signal(7) 手册(各类信号一览) https://man7.org/linux/man-pages/man7/signal.7.html
termios(3) 手册(canonical mode 等) https://man7.org/linux/man-pages/man3/termios.3.html
session(2) / setpgid(2) 手册 https://man7.org/linux/man-pages/man2/setsid.2.html

阅读材料:man sh(dash 的手册)——讲师建议把它非交互式地打印出来再管道给 AI 做总结,「在 AI 的帮助下读手册,建立正确的概念」。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。