[OS_8] 终端和 UNIX Shell | 会话和进程组 | sigaction | dash
- 我们已经知道如何用 “文件描述符” 相关的系统调用访问操作系统中的对象:open, read, write, lseek, close。
- 操作系统也提供了 mount, pipe, mkfifo 这些系统调用能 “创建” 操作系统中的对象。
当然,我们也知道操作系统中的对象远不止于此,还有很多有趣的对象我们还没有深入了解过——终端就让人细思恐极。
本文内容:从我们每天用的终端模拟器开始,一探究竟 Ctrl-C 到底做了什么——在此基础上,我们就可以实现自己的 “多任务管理器” 了。
8.1 终端
打字机 (Typewriter)
QWERTY 键盘 (1860s)
- 为降低打字速度设计的防卡纸方案
-
- 毕竟机械结构,每一下都需要足够的力量
打字机时代的遗产
Shift
- 使字锤或字模向上移动一段距离,切换字符集
CR & LF
\r
CR (Carriage Return): 回车,将打印头移回行首
-
print('Hel\rlo')
\n
LF (Line Feed): 换行,将纸张向上移动一行
-
- UNIX 的
\n
同时包含 CR 和 LF
- UNIX 的
Tab & Backspace
- 位置移动 (Backspace + 减号
= 错了划掉)
电传打字机 (Teletypewriter)
为了发电报设计 (收发两端同时打印)
- Telex (teleprinter exchange): 1920s,早于计算机
-
- 使用 Baudot Code (5-bit code)
- 很自然地也能用在计算机上
- 应用场景:早期股票同步交易
电传打字机 (cont'd)
- Teletype Model 28 (1951); technical data sheet
VT100: 封神之路
Video Teletypewriter(视频电传打字机) (DEC, 1978)
- 成为事实上的行业标准
-
- 首个完整实现 ANSI Escape Sequence 的终端
- 80×2480×24 字符显示成为标准布局
计算机终端:原理
输出设备(比如屏幕)
想象你对着对讲机念一串咒语:"变红#大家好#恢复颜色"。虽然你听到的是乱码,但对讲机那头的人听到后会立刻把文字变成红色显示"大家好",再恢复颜色。
这里的UART信号就像咒语,Escape Sequence(例如\033[31m
)就是告诉屏幕何时变色、换行的魔法指令。
输入设备(比如键盘)
你按键盘的"回车键",键盘不会大喊"回车",而是悄悄告诉电脑一个暗号(比如ASCII码13)。电脑听到暗号后,就知道该执行回车操作了。
控制字符(如换行符、退格符)就是这些约定好的"暗号系统"
一对 “管道” 提供双向通信通道
- 主设备 (PTY Master): 连接到终端模拟器
- 从设备 (PTY Slave): 连接到 shell 或其他程序
-
- 例如
/dev/pts/0
- 例如
在 Linux 系统中,tty(Teletype 的缩写)是一个与终端交互相关的核心概念,本质上是终端设备的抽象表示
伪终端经常被创建
- ssh, tmux new-window, ctrl-alt-t, ...
openpty()
: 通过/dev/ptmx
申请一个新终端
-
- 返回两个文件描述符 (master/slave)
- (感受到 “操作系统对象” 的恐怖体量了吧)
终端打开接口
- xterm.js vscode 三端 兼容的实现
终端模拟器 (Terminal Emulator)
这下你也会实现了
- openpty + fork
-
- 子进程:stdin/stdout/stderr 指向 slave 文件
- 父进程:从 master 读取输出显示到屏幕; 将键盘输入写入 master 文件
甚至可以扩展 Escape Sequence 来显示图片
- Kitty:
\033[60C\_
开头,\033\
结尾
-
- 允许控制大小、位置、动画等
kitten icat img.png | cat
- 应该内嵌 WebView 的 期待这个革命性的产品
终端:更多功能
终端模式
- Canonical Mode: 按行处理
-
- 回车发送数据 (终端提供行编辑功能)
- Non-canonical Mode: 按字符处理
-
- 每个字符立即发送给程序
- 用于实现交互式程序: vim, ssh sshtron.zachlatta.com
终端属性控制
- tcgetattr/tcsetattr (terminal control)
- 可以控制终端的各种行为:回显、信号处理、特殊字符等
-
- (你输密码的时候关闭了终端的回显)
8.2 终端和操作系统
程序是怎么和终端 “配对” 的?
用户登录的起点
- 系统启动 (内核 → init → getty)
- 远程登录 (sshd → fork → openpty)
-
- stdin, stdout, stderr 都会指向分配的终端
- vscode (fork → openpty)
login 程序继承分配的终端
- (是的,login 是一个程序)
- fork() 会继承文件描述符 (指针)
-
- 因此,子进程也会指向同一个终端
终于,我们有了足够的机制实现 “用户界面”
- 终端是一个人机交互的东西
当然,不是图形的
UNIX Shell: “终端” 时代的经典设计
- “Command-line interface” (CLI) 的巅峰
进程管理:要解决的问题
我们有那么大一棵进程树,都指向同一个终端,有的在前台,有的在后台,Ctrl-C 到底终止哪个进程?
答案:终端才不管呢
- 它只管传输字符
-
- Ctrl-C: End of Text (ETX),
\x03
- Ctrl-D: End of Transmission (EOT),
\x04
- stty -a: 你可以看到按键绑定 (奇怪的知识增加了)
- Ctrl-C: End of Text (ETX),
- 但操作系统收到了这个字符
-
- 就可以对 “当前” 的进程采取行动
ctrl+z 实现 切换为后台
作为操作系统的设计者,需要在收到 Ctrl-C 的时候找到一个 “当前进程”
你会怎么做?
- fork() 会产生树状结构
-
- (还有托孤行为)
- Ctrl-C 应该终止所有前台的 “进程们”
-
- 但不能误伤后台的 “进程们”
shell 终端知道前后台,但是操作系统怎么知道前后台呢?
会话 (Session) 和进程组 (Process Group)
给进程引入一个额外编号 (Session ID,大分组)
- 子进程会继承父进程的 Session ID
-
- 一个 Session 关联一个控制终端 (controlling terminal)
- Leader 退出时,全体进程收到 Hang Up (SIGHUP)
再引入另一个编号 (Process Group ID,小分组)
- 一个会话 只能有一个前台进程组
- 操作系统收到 Ctrl-C,向前台进程组所有进程发送 SIGINT
-
- (真累……但你也想不到更好的设计了)
会话组 vs 进程组 —— 为什么分开?
想象一个 公司部门(会话组) 里有很多 项目小组(进程组):
- 分工明确
-
- 部门负责整体事务(比如与总部对接、分配办公电脑)
- 小组专注具体任务(比如开发、测试)
*👉 终端需要会话组管理登录、设备权限;进程组专注执行具体命令(如ls | grep txt
)*
- 灵活调整
-
- 部门可以合并或拆分(例如关闭终端时,自动结束所有关联进程)
- 小组可以独立运作(例如
Ctrl+Z
暂停一个进程组,不影响其他小组)
*👉 分开后,信号(如Ctrl+C
)可以精准发送到指定进程组*
为什么只有一个前台进程组?
想象 超市收银台(终端) 的规则:
- 避免混乱
-
- 同一时间只能有一个顾客(前台进程组)在结账
- 其他顾客(后台进程组)需要排队等待
*👉 终端输入(键盘、信号)同一时刻只能交给一个进程组处理*
- 快速切换
-
- 你正在结账(前台),突然电话响了,可以暂停当前操作去接电话(切换到新前台进程组)
*👉 用fg
/bg
命令切换进程组的前后台状态*
- 你正在结账(前台),突然电话响了,可以暂停当前操作去接电话(切换到新前台进程组)
实际场景例子
假设你在终端运行:
$ sleep 100 | sleep 200 & # 后台进程组
$ vi file.txt # 前台进程组
Ctrl+C
会杀死vi
(前台进程组),而不会影响后台的sleep
- 关闭终端时,会话组会向所有进程组发送终止信号
总结:分层设计让管理更精细,就像公司需要部门和小组的分工协作。
会话和进程组:API
太不优雅了
- setsid/getsid
-
- setsid 会脱离 controlling terminal
- setpgid/getpgid
- tcsetpgrp/tcgetpgrp
-
- 迷惑 API
以及……uid, effective uid (?), saved uid (???)
- 任何软件都很难逃脱千疮百孔的设计
- 一些解决:Setuid Demystified
- 对于很多接口我们只能增加很难删减
终于能实现 Job Control 了
窗口和多任务:终端可以有 “一个前台进程组”
- “最小化” = Ctrl-Z (SIGTSTP)
-
- SIGTSTP 默认行为暂停进程,收到 SIGCONT 后恢复
- “切换” = fg/bg (tcsetpgrp)
为了实现 “窗口栏上的按钮”,还很是大费周章
- 还不如 tmux 管理多个 pty 呢 (选择性 “绘制” 在终端上)
-
- 那是因为发明 session/pg 的时候还没有 pty 呢……
是的,历史的糟粕
但是,这是 POSIX 的一部分……
- 几乎任何人都无法预知 “软件” 的未来
回头看这个问题
- 我们不需要 “绑定进程到设备”
- 管理程序 (tmux, gnome, ...) 去模拟就行
-
- Window Manager: 只需要 “进程组” 就行了
-
-
- 关窗口,全部 一个不留
-
-
- Android: 每个 app 都是不同的用户
-
-
- 强行终止 = 杀掉属于这个用户的所有进程
-
-
- Snap: 程序在隔离的沙箱运行
-
-
- AppArmor + seccomp + namespaces (真狠)
-
不同操作系统(Unix-like vs. Windows)的设计理念差异
比喻:公司管理模式对比
- Unix-like(会话+进程组)
像一家强调 精细分工 的科技公司:
-
- 每个部门(会话)负责一个独立业务线(如终端登录、后台服务)
- 部门内有多个项目组(进程组),比如开发组、测试组、运维组
- 前台项目组(前台进程组)才能直接对接客户(终端输入/输出)
- Windows(仅进程组)
像一家强调 统一管理 的传统企业:
-
- 所有员工(进程)都属于公司(系统)直接管理
- 通过工牌权限(安全标识符 SID)和部门标签(Job Object)区分职责
- 没有严格的“前台项目组”概念,所有窗口(进程)均可交互
为什么 Windows 可以“没有会话”?
- 设计目标不同
-
- Windows 早期注重 单用户图形交互,进程天然与桌面绑定
- Unix 源自 多用户命令行 场景,需要会话管理终端(如 SSH 登录)
- 替代机制
- 窗口站(Window Station):隔离不同安全级别的进程(类似会话)
- 服务控制管理器(SCM):管理后台服务(类似守护进程与会话解绑)
- Job Object:将多个进程绑定为组,统一管理资源(CPU/内存限制)
- 用户场景差异
-
- Windows 服务程序通常不与终端关联,无需会话控制输入输出
- Unix 的
ssh/tmux/screen
等工具严重依赖会话的终端绑定能力
会话的核心作用(以 Unix 为例)
- 终端绑定
-
- 会话与一个终端(如 SSH 连接、物理控制台)关联
- 只有前台进程组能接收终端输入(如键盘信号)
- 信号管理
-
Ctrl+C
仅杀死前台进程组,而非整个会话- 关闭终端时,会话向所有进程组广播
SIGHUP
(默认终止进程)
- 资源隔离
-
- 会话退出时自动释放终端设备、共享内存等资源
- Windows 通过对象句柄继承和 Job Object 实现类似功能
实际案例对比
- Unix 场景
你在 SSH 会话中运行:
$ python server.py & # 后台进程组
$ vim file.txt # 前台进程组
-
- 按下
Ctrl+C
仅终止vim
,python
继续运行 - 断开 SSH 后,会话销毁,
python
进程收到SIGHUP
终止
- 按下
- Windows 场景
你打开两个命令行窗口:
-
- 窗口1运行
notepad.exe
(普通进程组) - 窗口2运行
powershell.exe
(另一个进程组) - 关闭窗口1不会影响窗口2,因为无会话绑定
- 窗口1运行
为什么需要会话?
- 精细控制:对进程组的生命周期、信号、终端交互实现分层管理
- 多用户支持:隔离不同登录用户(或终端连接)的资源
- 作业控制:
fg/bg
、jobs
等命令依赖会话的前后台机制
Windows 并非“不需要会话”,而是通过 窗口站/Job Object/SCM 等机制实现了类似功能
但抽象层级和实现方式不同。就像公司管理模式没有绝对优劣,只有适合场景的区别。
在未来回头看现在
人机交互的方式根本不应该是这样的
- 我们很少能清醒地认识到
-
- 我要做 X
- 应该分解成 Y→(Z,W)→T
- 因此,坐在电脑前的大部分时间都浪费了
Prompt: 在有了 pty 机制之后,我们可以用 session 的机制,为每个 “窗口” 分配一个新的 pty,这样可以简化系统调用,例如 process group 的概念可以消除。是否可以这样设计新的操作系统?
理论上,这种设计是可行的,但需要谨慎权衡利弊:
1. 可行性分析
- PTY与会话的1:1绑定确实能简化部分终端管理逻辑
- 进程组原有的作业控制功能(如信号广播、终端抢占)可被会话继承
- 现代容器化趋势下,会话级的资源隔离更符合最小化原则
2. 潜在优势
- 减少进程组/会话组ID的维护开销
- 窗口崩溃时可通过会话机制自动回收资源
- 更清晰的层次结构:终端→会话→进程树
3. 关键挑战
- POSIX兼容性问题:许多程序依赖setsid()/tcsetpgrp()等系统调用
- 精细控制需求:某些场景仍需进程组级别的信号传递
- 内核调度影响:会话级cgroup可能比进程组更重量级
4. 折中建议
- 保留进程组作为可选机制
- 在用户空间抽象会话管理(如systemd-style)
- 通过命名空间隔离, 增强会话安全性
这种设计在嵌入式/专用系统中有应用前景,但通用系统需保持向后兼容。当前Linux的cgroups v2已部分实现类似理念,通过进程树替代进程组管理资源。
最后:Ctrl-C 到底做了什么?
signal
- 注册一个信号的 “处理程序” f
-
- 操作系统会记下这个 f
kill
- 在程序从操作系统返回时,强制加一个向 ff 的跳转
-
- 程序 = 状态机
- 只要 “模拟” 调用 ff 的行为即可
今天有更可靠的版本 (sigaction)
- 让 AI 解释 一下吧
Prompt: 为什么我们需要 sigaction 替代 UNIX 的信号机制?
使用 sigaction
替代传统的 UNIX signal
机制,主要出于以下原因:
1. 行为可靠性
- 传统
signal
的缺陷:早期signal
函数在信号处理完成后,自动将信号处理程序重置为默认行为(如 SIG_DFL),需要手动重新注册,易导致信号丢失或竞态条件。 sigaction
改进:通过SA_RESTART
标志支持系统调用自动重启,避免因信号中断导致的系统调用意外终止,提升程序健壮性。
2. 精确控制信号行为
- 屏蔽信号:
sigaction
允许在信号处理期间,通过sa_mask
字段阻塞特定信号,防止嵌套信号干扰,确保处理逻辑的原子性。 - 上下文信息:通过
siginfo_t
结构体可获取信号的来源信息(如发送进程 PID、错误地址等),增强调试能力。
3. 可移植性
- 传统
signal
函数在不同 UNIX 系统中的实现不一致(如 BSD 和 System V)。sigaction
是 POSIX 标准化的接口,提供统一行为,避免跨平台兼容性问题。
4. 避免信号丢失
sigaction
的SA_NODEFER
和SA_RESETHAND
标志允许更灵活控制信号递送,避免传统机制中因信号处理重置导致的多次信号丢失。
示例对比
// 传统 signal 的脆弱实现
void handler(int sig) {signal(SIGINT, handler); // 必须手动重新注册// 处理逻辑
}
signal(SIGINT, handler);// 使用 sigaction 的可靠实现
struct sigaction sa;
sa.sa_handler = handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; // 自动重启系统调用
sigaction(SIGINT, &sa, NULL);
总结
sigaction
通过提供原子性注册、阻塞控制、上下文信息和标准化行为,解决了传统 signal
的不可靠性和平台依赖问题,是现代 UNIX 系统编程中推荐使用的信号处理机制。
8.3 UNIX Shell 编程语言
“多任务” 不是人机交互的全部
Windows 3.2 (1992): “多窗口管理器” 并不那么好用
The Shell Programming Language
UNIX 的用户可都是 hackers
- UNIX Shell: 基于文本替换的极简编程语言
-
- 只有一种类型:字符串
- 算术运算?对不起,我们不支持 (但可以 expr 1 + 2)
语言机制
- 预处理:
$()
,<()
- 重定向:
cmd > file < file 2> /dev/null
- 顺序结构:
cmd1; cmd2
,cmd1 && cmd2
,cmd1 || cmd2
- 管道:
cmd1 | cmd2
-
- 这些命令被翻译成系统调用序列 (open, dup, pipe, fork, execve, waitpid, ...)
例子:实现重定向
利用子进程继承文件描述符的特性
- 在父进程打开好文件,到子进程里腾挪
-
- 发现还是 Windows API 更 “优雅”
int fd_in = open(..., O_RDONLY | O_CLOEXEC);
int fd_out = open(..., O_WRONLY | O_CLOEXEC);int pid = fork();
if (pid == 0) {dup2(fd_in, 0);dup2(fd_out, 1);execve(...);
} else {close(fd_in);close(fd_out);waitpid(pid, &status, 0);
}
读一读手册 (为数不多还值得读的手册)
man sh: dash — command interpreter (shell)
- dash is the standard command interpreter for the system. The current version of dash is in the process of being changed to conform with the POSIX 1003.2 and 1003.2a specifications for the shell.
- The shell is a command that reads lines from either a file or the terminal, interprets them, and generally executes other commands. It is the program that is running when a user logs into the system (although a user can select a different shell with the chsh(1) command).
我们可以借助 AI 来读
UNIX Shell: 优点
优点:高效、简介、精确
- 一种 “自然编程语言”:一行命令,协同多个程序
-
make -nB | grep ...
- 最适合 quick & dirty 的 hackers
AI 时代的 UNIX Philosophy
man tcsetpgrp | ag -q 帮我生成新手友好的教程
- 出了问题还可以 fxxk
UNIX Shell: 有优点就有缺点
无奈的取舍
- Shell 的设计被 “1970s 的算力、算法和工程能力” 束缚了
-
- 后人只好将错就错 (PowerShell: 我好用,但没人用 )
- PowerShell 了解一下,面向对象的管道
- 因为市场上的人们已经习惯了shell,这告诉我们宣传和抢占市场也很重要
例子:操作的 “优先级”?
ls > a.txt | cat
-
- 我已经重定向给 a.txt 了,cat 是不是就收不到输入了?
- bash/zsh 的行为是不同的
-
- 所以脚本用
#!/bin/bash
甚至#!/bin/sh
保持兼容
- 所以脚本用
- 文本数据 “责任自负”
-
- 空格 = 灾难
另一个有趣的例子
$ echo hello > /etc/a.txt
bash: /etc/a.txt: Permission denied$ sudo echo hello > /etc/a.txt
A Zero-dependency UNIX Shell
真正体现 “Shell 是 Kernel 之外的 壳”
- 来自 xv6
- 完全基于系统调用 API,零库函数依赖
-
- -ffreestanding 编译、ld 链接
支持的功能
- 重定向/管道
ls > a.txt
,ls | wc -l
- 后台执行
ls &
- 命令组合
(echo a ; echo b) | wc -l
展望未来
Open question
- Shell (CLI/GUI) 的未来是什么?
一些想法
- 是一个 “半结构化” 的 “编程语言”
-
- project files | filter out generated files | zip them
- make a slides of UNIX shell | play it
- 我们就可以退休啦
8.4 总结
Take-away Messages:
通过 freestanding 的 shell,我们阐释了 “可以在系统调用上创建整个操作系统应用世界” 的真正含义:操作系统的 API 和应用程序是互相成就、螺旋生长的:有了新的应用需求,就有了新的操作系统功能。
而 UNIX 为我们提供了一个非常精简、稳定的接口 (fork, execve, exit, pipe ,...),纵然有沉重的历史负担,它在今天依然工作得很好。