08 - Terminal and UNIX Shell

08 - 终端和 UNIX Shell

我们已经知道如何用 “文件描述符” 相关的系统调用访问操作系统中的对象:open, read, write, lseek, close。操作系统也提供了 mount, pipe, mkfifo (很多其他 API)这些系统调用能 “创建” 操作系统中的对象。当然,我们也知道操作系统中的对象远不止于此,还有很多有趣的对象我们还没有深入了解过——终端就让人细思恐极。

本讲内容:从大家每天用的终端模拟器开始,一探究竟 Ctrl-C 到底做了什么——在此基础上,我们就可以实现自己的 “多任务管理器” 了。

1. 终端

1.1. 打字机 Typewriter

QWERTY 键盘 (1860s)

  • 为降低打字速度设计的防卡纸方案

    • 毕竟机械结构,每一下都需要足够的力量

1.2. 打字机时代遗产

Shift

  • 使字锤或字模向上移动一段距离,切换字符集

  • 因为大小写都各占一个键也太浪费了(((

CR & LF

  • CR (Carriage Return): 回车,将打印头移回行首

    • print('Hel\rlo')

  • LF (Line Feed): 换行,将纸张向上移动一行

    • UNIX 的 同时包含 CR 和 LF

    • 而 windows 是 \n\r...

Tab & Backspace

  • 位置移动 (Backspace + 减号 = 错了划掉)

  • 因为打在纸上就去不掉了

1.3. 电传打字机(Teletypewrite)

为了发电报设计 (收发两端同时打印)

  • Telex (teleprinter exchange): 1920s,早于计算机

    • 使用 Baudot Code (5-bit code)

    • 很自然地也能用在计算机上

1.4. VT100: 封神之路

Video Teletypewriter (DEC, 1978)

  • 成为事实上的行业标准

    • 首个完整实现 ANSI Escape Sequence 的终端

    • 80×2480×24 字符显示成为标准布局

1.5. 计算机终端:原理

作为输出设备

  • 接受 UART 信号并显示 (Escape Sequence 就非常自然了)

作为输入设备

  • 把按键的 ASCII 码输出到 UART (所以有很多控制字符)

1.6. 今天:伪终端(Pseudo Terminal)

你在系统里可以随意创建这些终端...

一对 “管道” 提供双向通信通道

  • 主设备 (PTY Master): 连接到终端模拟器

  • 从设备 (PTY Slave): 连接到 shell 或其他程序

    • 例如 /dev/pts/0

伪终端经常被创建

  • ssh, tmux new-window, ctrl-alt-t, ...

  • openpty(): 通过 /dev/ptmx 申请一个新终端

    • 返回两个文件描述符 (master/slave)

    • (感受到 “操作系统对象” 的恐怖体量了吧)

1.7. 终端模拟器(Terminal Emulator)

这下你也会实现了

  • openpty + fork

    • 子进程:stdin/stdout/stderr 指向 slave

    • 父进程:从 master 读取输出显示到屏幕; 将键盘输入写入 master

甚至可以扩展 Escape Sequence 来显示图片

  • Kitty: \033[60C\_ 开头,\033\ 结尾

    • 允许控制大小、位置、动画等

    • kitten icat img.png | cat

  • 应该内嵌 WebView 的

  • 期待这个革命性的产品

1.8. 终端

终端模式

  • Canonical Mode: 按行处理

    • 回车发送数据 (终端提供行编辑功能)

  • Non-canonical Mode: 按字符处理

    • 每个字符立即发送给程序

    • 用于实现交互式程序: vim, ssh sshtron.zachlatta.com

终端属性控制

  • tcgetattr/tcsetattr (terminal control)

  • 可以控制终端的各种行为:回显、信号处理、特殊字符等

    • (你输密码的时候关闭了终端的回显)

2. 终端和操作系统

2.1. 程序终端配对

用户登录的起点

  • 系统启动 (内核 → init → getty)

  • 远程登录 (sshd → fork → openpty)

    • stdin, stdout, stderr 都会指向分配的终端

  • vscode (fork → openpty)

login 程序继承分配的终端

  • (是的,login 是一个程序)

  • fork() 会继承文件描述符 (指针)

    • 因此,子进程也会指向同一个终端

2.2. 非图形化终端

UNIX Shell: “终端” 时代的经典设计

  • “Command-line interface” (CLI) 的巅峰

2.3. 进程管理:要解决的问题

我们有那么大一棵进程树,都指向同一个终端,有的在前台,有的在后台,Ctrl-C 到底终止哪个进程?

答案:终端才不管呢

  • 它只管传输字符

    • Ctrl-C: End of Text (ETX), \x03

    • Ctrl-D: End of Transmission (EOT), \x04

    • stty -a: 你可以看到按键绑定 (奇怪的知识增加了)

  • 操作系统收到了这个字符

    • 就可以对 “当前” 的进程采取行动

create-tree.c: 和上个单元差不多...?

2.4. 终端上的“当前进程”

作为操作系统的设计者,需要在收到 Ctrl-C 的时候找到一个 “当前进程”

你会怎么做?

  • fork() 会产生树状结构

    • (还有托孤行为)

  • Ctrl-C 应该终止所有前台的 “进程们”

    • 但不能误伤后台的 “进程们”

2.5. 会话(Session)和进程组(Process Group)

给进程引入一个额外编号 (Session ID,大分组)

  • 子进程会继承父进程的 Session ID

    • 一个 Session 关联一个控制终端 (controlling terminal)

    • Leader 退出时,全体进程收到 Hang Up (SIGHUP)

再引入另一个编号 (Process Group ID,小分组)

  • 只能有一个前台进程组

  • 操作系统收到 Ctrl-C,向前台进程组所有进程发送 SIGINT

    • (真累……但你也想不到更好的设计了)

2.6. API(那些不优雅的)

太不优雅了

  • setsid/getsid

    • setsid 会脱离 controlling terminal

  • setpgid/getpgid

  • tcsetpgrp/tcgetpgrp

    • 迷惑 API

以及……uid, effective uid (?), saved uid (???)

2.7. Job Control

窗口和多任务:终端可以有 “一个前台进程组”

  • “最小化” = Ctrl-Z (SIGTSTP)

    • SIGTSTP 默认行为暂停进程,收到 SIGCONT 后恢复

  • “切换” = fg/bg (tcsetpgrp)

为了实现 “窗口栏上的按钮”,还很是大费周章

  • 还不如 tmux 管理多个 pty 呢 (选择性 “绘制” 在终端上)

    • 那是因为发明 session/pg 的时候还没有 pty 呢……

2.8. 历史的糟粕

但是,这是 POSIX 的一部分……

  • 几乎任何人都无法预知 “软件” 的未来

回头看这个问题

  • 我们不需要 “绑定进程到设备”

  • 管理程序 (tmux, gnome, ...) 去模拟就行

    • Window Manager: 只需要 “进程组” 就行了

      • 关窗口,全部 删除 一个不留

    • Android: 每个 app 都是不同的用户

      • 强行终止 = 杀掉属于这个用户的所有进程

    • Snap: 程序在隔离的沙箱运行

      • AppArmor + seccomp + namespaces (真狠)

2.9. 学会去未来回头看现在未来回头看现在

人机交互的方式根本不应该是这样的

  • 我们很少能清醒地认识到

    • 我要做 XX

    • 应该分解成 Y→(Z,W)→TY→(Z,W)→T

  • 因此,坐在电脑前的大部分时间都浪费了

软件是如此,万物都如此,不可能等到完美再去做,去做了才知道哪些地方不完美,无需焦虑...无需等待,慢慢修改即可!

2.10. Ctrl + C 做了什么?

signal

  • 注册一个信号的 “处理程序” f

    • 操作系统会记下这个 f

kill

  • 在程序从操作系统返回时,强制加一个向 f的跳转

    • 程序 = 状态机

    • 只要 “模拟” 调用 f的行为即可

今天有更可靠的版本 (sigaction)

  • 让 AI 帮你解释吧

#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() {
    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);
    }
}
  • SIGINT (通常由 Ctrl+C 触发):

    • 输出 Received SIGINT!

    • 但不会退出程序。

  • SIGQUIT (通常由 Ctrl+\ 触发):

    • 输出 Received SIGQUIT!

    • 然后调用 exit(0) 退出程序。

root@LAPTOP-GT06V0GS:/mnt/d/CSLab/osCourse/lec8/signal# ./signal
^CReceived SIGINT!
^CReceived SIGINT!
[1927] Got: �
[1927] Got: �
^A^A
[1927] Got:
s
[1927] Got: s
d
[1927] Got: d
^\Received SIGQUIT!
atexit() cleanup

3. UNIX Shell 编程语言

3.1. 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, ...)

3.2. Example:实现重定向

利用子进程继承文件描述符的特性

  • 在父进程打开好文件,到子进程里腾挪

    • 发现还是 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);
}

3.3. 读一读手册 - 为数不多还值得读的手册

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).

3.4. UNIX Shell:优点

优点:高效、简介、精确

  • 一种 “自然编程语言”:一行命令,协同多个程序

    • make -nB | grep ...

    • 最适合 quick & dirty 的 hackers

AI 时代的 UNIX Philosophy

  • man tcsetpgrp | ag -q 帮我生成新手友好的教程

  • 出了问题还可以 fxxk

    • 带过一个同学毕设改进 fxxk (现在他已经在 DeepSeek 开源周发代码了); 再想改进的时候,ChatGPT 来了

3.5. 缺点

无奈的取舍

  • Shell 的设计被 “1970s 的算力、算法和工程能力” 束缚了

    • 后人只好将错就错 (PowerShell: 我好用,但没人用 )

例子:操作的 “优先级”?

  • ls > a.txt | cat

    • 我已经重定向给 a.txt 了,cat 是不是就收不到输入了?

  • bash/zsh 的行为是不同的

    • 所以脚本用 #!/bin/bash 甚至 #!/bin/sh 保持兼容

  • 文本数据 “责任自负”

    • 空格 = 灾难

3.6. Shell

// Linux port of xv6-riscv shell (no libc)

一个 xv6 的外壳???这么牛逼的东西...显得我很呆...老师却是一脸轻松的样子.

Last updated