南京大学 操作系统 (JYY) 学习笔记:终端、进程组与 UNIX Shell 的前世今生
写在前面:这是本系列的第八篇。
前面的课程中,我们已经知道如何用“文件描述符”相关的系统调用访问操作系统中的对象(
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 甚至用
AppArmor+seccomp+namespaces把程序关在绝对隔离的沙箱里运行。
信号与 Ctrl-C 的底层代码演示
当操作系统决定向前台进程发送信号时,底层发生了什么?
- 注册处理程序 (signal / sigaction): 告诉操作系统,如果收到某个信号,请去执行函数
f。 - 发送信号 (kill): 强行在程序的正常执行流中,插入一个向
f的跳转。(这就是为什么信号能打断死循环)。
代码演示:
#include <stdio.h>
#include <stdlib.h>
#include <signal.h>
#include <unistd.h>
void handler(int signum) {
switch (signum) {
case SIGINT:
printf("Received SIGINT!\n");
break;
case SIGQUIT:
printf("Received SIGQUIT!\n");
exit(0);
break;
}
}
void cleanup() {
printf("atexit() cleanup\n");
}
int main() {
// 拦截 Ctrl-C (SIGINT) 和 Ctrl-\ (SIGQUIT)
signal(SIGINT, handler);
signal(SIGQUIT, handler);
atexit(cleanup);
while (1) {
char buf[4096];
int nread = read(STDIN_FILENO, buf, sizeof(buf));
buf[nread - 1] = '\0';
printf("[%d] Got: %s\n", getpid(), buf);
if (nread < 0) {
perror("read");
exit(1);
}
sleep(1);
}
}
运行结果:你会发现按下 Ctrl-C 时程序不会退出了,而是打印出我们自定义的语句。只有按下 Ctrl-\ 才会触发 SIGQUIT 退出。
root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec8/signal# ./signal
^CReceived SIGINT!
[1927] Got:
s
[1927] Got: s
^\Received SIGQUIT!
atexit() cleanup
UNIX Shell 编程语言
UNIX 的用户可都是 Hackers!所以 UNIX Shell 被设计成了一种基于文本替换的极简编程语言。
- 只有一种类型:字符串。
- 语言机制极其简洁:预处理
$(), 重定向>, 顺序结构&&||, 管道|。
底层核心揭秘:重定向 (>) 是怎么实现的?
利用子进程继承文件描述符的特性! 配合 dup2 移花接木:
// 1. 父进程先以只读/只写的方式,打开好输入/输出文件
int fd_in = open(..., O_RDONLY | O_CLOEXEC);
int fd_out = open(..., O_WRONLY | O_CLOEXEC);
int pid = fork();
if (pid == 0) {
// 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 的加持),底层完全是依靠刚才讲的这些系统调用手搓出来的。
这很牛逼,但也告诉我们,底层的系统编程没有魔法,只有一层一层严谨的系统调用抽象。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)