写在前面:这是本系列的第八篇。

前面的课程中,我们已经知道如何用“文件描述符”相关的系统调用访问操作系统中的对象(open, read, write, lseek, close),也知道了如何用 pipe 创建管道对象。

但操作系统中的对象远不止于此。今天,我们要深入一个每天都在用,但细思恐极的对象——终端 (Terminal)。从机械打字机开始,一探究竟 Ctrl-C 到底做了什么,并在此基础上,剖析 UNIX Shell 的底层原理。

在这里插入图片描述

终端的历史:从机械打字机到显示器

我们今天在键盘上敲击的每一个按键,在终端里看到的每一次换行,都刻满了 100 多年前机械时代的烙印。

打字机时代遗产 (Typewriter, 1860s)

  • QWERTY 键盘:
    这其实是为了降低打字速度而设计的防卡纸方案。因为早期的纯机械结构,按键太快会导致字锤卡在一起。
  • Shift 键:
    按住它,机械结构会使字锤或字模向上移动一段距离,从而切换字符集(打出大写字母或符号)。毕竟大小写各占一个物理按键太浪费空间了。
  • 回车与换行 (CR & LF):
    • \r CR (Carriage Return - 回车): 将物理打印头移回行首。
    • \n LF (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 引入了两个令人头疼的编号:

  1. Session ID (会话,大分组):
    • 子进程会继承父进程的 Session ID。
    • 一个 Session 关联一个控制终端 (controlling terminal)。
    • 当 Leader(比如你的 SSH 登录窗口)退出时,全体进程都会收到 Hang Up (SIGHUP) 信号,集体自杀。
  2. 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,到底先执行重定向还是先执行管道?bashzsh 的行为甚至都不一样!

致敬经典:
课程最后提到了 xv6-riscv 的 shell 源码。一个真正能用的微型操作系统 Shell(没有 libc 的加持),底层完全是依靠刚才讲的这些系统调用手搓出来的。
这很牛逼,但也告诉我们,底层的系统编程没有魔法,只有一层一层严谨的系统调用抽象。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐