跳转至

终端与终端 I/O — termios、行纪律与规范模式

这一章讲什么: 前面每章都在和终端打交道,却没人解释 它为什么那样行为。为什么程序必须等你敲回车才看得到你 打的字?为什么输密码时字符不显示?为什么 CTRL-C 是中断 而不是个字符?答案全在一个软件组件——行纪律——和 它的配置接口 termios。vi、emacs、top 的本质就是「退出 规范模式,把终端改装成自己想要的样子」。本章教你自己动手改。

1. 顶层全景:一次击键的旅程

  键盘 → 设备驱动 → 输入队列(容量 N_TTY_BUF_SIZE=4096)
      → 行纪律:回显(开 ECHO 就抄一份到输出队列)?
                编辑字符处理(erase=^?、kill=^U、werase=^W)?
                特殊字符(INTR=^C → SIGINT、susp=^Z → SIGTSTP)?
                规范模式(ICANON):攒够一行(CR/NL)才交给 read
      → read 缓冲区 → 进程
  进程 write → 输出队列 → 行纪律输出处理(opost/onlcr:NL→CR+NL)
      → 设备驱动 → 屏幕
  配置接口:stty(命令行看/改)→ termios 结构(程序里改)

图说:你敲的每个字都先过行纪律这一关——它决定什么显示、 什么编辑、什么变成信号、什么时候放行。 结构上分两层: 设备驱动(搬运字符,和 xterm 打交道)+ 行纪律 (tty_ldisc,管处理;输入/输出队列都在它的管辖下)1。

2. 规范模式:为什么非要回车

开场实验 upcopychars:逐字符 read+回显的程序,敲 abcde 屏幕毫无反应,回车才整行蹦出来——C 库缓冲不背锅(用的 write/read),是终端攒行。好处:行编辑(abcde 退两格 再 xyz → 程序收到 abcxyz);echo 由内核做(SSH 卡顿时 打字不显示,就是远端的回显慢了);特殊字符直接变信号2。 这套「行是输入基本单位」的默认行为叫规范模式 (canonical mode);vi 那种不要任何处理的模式叫raw3。

两个容量数字:输入队列 4096 字节(内核源码 N_TTY_BUF_SIZE 4096,实测用 16,384 字符粘贴法验证: 超过即丢);MAX_INPUT = 255(POSIX 规定的规范行最大长度, pathconf(ttyname(0), _PC_MAX_INPUT) 可查)4。

3. stty:仪表盘先认一遍

stty -a 全览,三种行:符号值(cs8=8 位、tab0 等延迟位—— Linux 已忽略)、开关(icanon、echo、前面带 - 为关)、 变量 = 值(rows/columns、intr=^C、erase=^?...)。六个类别: 特殊字符 / 特殊设置(min、time)/ 输入(i 开头)/ 输出(o 开头)/ 控制 / 本地5。救命三键:stty sane( sane 恢复默认)、 reset、回显关了盲打时 CTRL-J 当回车6。

两个必做实验:icrnl(输入 CR→NL)——关掉后回车只光标 回行首不换行,得 CTRL-J 才能提交;onlcr(输出 NL→CR+NL) ——关掉后 \n 只剩换行不回行首,cat 的输出一路向右跑 出屏幕7。这一进一出两个开关解释了终端「魔法」的全部。

4. termios:程序里改装终端

一张 man 页管全部:tcgetattr(fd, &t) 读、tcsetattr(fd, TCSANOW, &t) 写。结构五个成员8:

struct termios {
    tcflag_t c_iflag;    /* 输入模式:ICRNL、IXON… */
    tcflag_t c_oflag;    /* 输出模式:OPOST、ONLCR… */
    tcflag_t c_cflag;    /* 控制模式:波特率、校验、流控 */
    tcflag_t c_lflag;    /* 本地模式:ISIG、ICANON、ECHO… */
    cc_t     c_cc[NCCS]; /* 特殊字符:VEOF、VINTR、VMIN、VTIME… */
};

本地三位总开关(本章的主角):

开关 关掉后
ICANON 退出规范模式:输入不再攒行,read 立刻拿到字符;VMIN/VTIME 接管
ECHO 回显关闭——密码输入的原理
ISIG CTRL-C/CTRL-\ 不再发信号,只是普通字符

非规范模式的 read 语义由 VMIN/VTIME 决定(c_cc 数组里): VMIN=1/VTIME=0 = 每个字符立刻返回(vi 的做法); VMIN=0/VTIME=n = 最多等 n/10 秒,超时也返(「等输入但 别傻等」);VMIN=n/VTIME=0 = 攒够 n 个才返。一个反直觉点: VMIN/VTIME 与 VEOF/VEOL 共用下标,因为规范和非规范 互斥,同一格装谁不冲突9。

辅助件:isatty(fd) 判是否终端、ttyname(fd) 拿设备路径、 cfmakeraw() 一键全脱(raw 模式)、ioctl 的 TIOCGWINSZ 拿窗口行列(resize 时收 SIGWINCH)。第 19 章的 ncurses 就是 把这些封装成库。

改 ECHO 的最小闭环(toggle_echo,十几行):isatty 拦住重定向 → tcgetattr 读 → 查 c_lflag & ECHO 反着改 → tcsetattr(TCSANOW) 立刻写回。运行一次关回显,再运行一次 开回来11。它的加长版是模拟登录:输密码前关掉 ECHO、 读完立刻还原——「用完必还原」这条纪律的出处,登录程序世世代代 这么写12。

ioctl:装不进通用模型的,归它

ioctl = input/output control:1979 年 System 7 的万能钥匙, 最初管终端与串口,后来什么设备都往里塞。POSIX 嫌它难用,把 终端控制单独抽成 termios API 收进标准,其余设备控制留在 ioctl、不进标准(glibc 一直带)13。两条承重事实:

  1. 多数 termios 函数底层最终都是一次 ioctl 调用——你在 第 12 章用 TIOCGWINSZ 拿窗口尺寸,用的就是它;
  2. 通用 I/O 模型装不下的设备专属杂活归它:换终端字体、 磁带倒带、弹出光盘、网络接口路由表……一个变参函数, 请求码的含义散在各设备自己的 man 页里13。

5. 项目:spl_stty——表驱动的 stty 克隆

解决什么问题: 命令行上的开关名是小写的(icrnl、erase), termios 里的掩码是大写宏(ICRNL、VERASE)——两张皮; 且 stty 的形态有六种:摘要、size/speed、-a 全览、 [-]switch 开关、delay-const 延迟位、var value 赋值14。 硬写 if-else 链的话,几十个开关没法治。

承重决策:五张同构表。 定义一个 {int mask; char *name;} 的 maskmap 结构,为 c_iflag/c_oflag/c_cflag/c_lflag 各建 一张数组、c_cc 再建一张,每行一对「宏 ↔ 名字」,哨兵 {-1, NULL} 收尾(遍历到 mask 为负即停)。波特率同理,一张 {B9600, "9600"} 的 baudmap——只是 speed_t 无符号,哨兵 改用不可能的 9999;输出延迟位再配一张 dlymask_map14。

同构的代价立即兑现:「打印某个 flagset 的当前值」与「修改 任一开关或变量」各自只需要一套函数——遍历数组、按名字查到 掩码、在 termios 结构副本上置位或清位,tcsetattr(TCSANOW) 写回。数据流一行讲完:命令行词 → 表里查名 → 得掩码 → 改副本 → 写回驱动15。

如果错,会错在: ① 以为 c_cc 那张表和其余四张一个玩法—— VERASE 存的是字符不是位,改它要动 c_cc[VERASE] 的值; ② 改完忘了 tcsetattr——termios 是「读副本、改副本、整包写回」 的协议,只改内存里的结构,驱动纹丝不动15。

6. 判断/边界/可带走

作者的判断: 「终端 I/O 是操作系统里最乱的部分」原话开头 ——历史、BSD/SysV 分裂、缺标准三因;学习路径「先懂驱动结构 再编程」是本章骨架10。spl_stty 的表驱动是本章最实用的 范式:开关名与掩码两张皮,靠同构表 + 哨兵收拢成两套函数15。 边界: 伪终端(pty,伪终端对 master/slave)只在网络登录处点到;波特率/校验/硬件流控 (c_cflag)整组略过;termios 的 TCSADRAIN/TCSAFLUSH 动作 区别未展开;ioctl 的请求码清单散在各设备 man 页,原书只立 了定位没枚举13。可带走的: ① 终端 = 设备驱动 + 行纪律, 队列在行纪律;② 规范模式攒行 + 回显 + 特殊字符;③ 输入 队列 4096/MAX_INPUT 255;④ stty 三形式六类别 + sane; ⑤ icrnl/onlcr 一进一出;⑥ termios 五成员 + 三总开关 + VMIN/VTIME 三组搭配;⑦ cfmakeraw/isatty/TIOCGWINSZ; ⑧ 改终端属性 = 读副本→改副本→tcsetattr 写回,用完必还原; ⑨ ioctl 的定位:termios 装不下的设备专属操作,且 termios 底层大多是它;⑩ 表驱动收拢「名字 ↔ 掩码」两张皮。

7. 原文地图

主题 原书章 原文位置
攒行实验与解释 Chapter 18: Terminals and Terminal I/O text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:124(搜「nothing happens」) · :149(搜「fundamental unit」)
终端驱动两层结构 同上 :197(搜「two subcomponents」) · :212(搜「input queue」)
队列容量 4096/255 同上 :253(搜「MAX_INPUT」) · :312(搜「N_TTY_BUF_SIZE」)
stty 输出三形式/六类 同上 :357(搜「three kinds」) · :444(搜「Categories」)
sane/reset 救命 同上 :394(搜「reset」)
icrnl/onlcr 实验 同上 :514(搜「icrnl switch」) · :598(搜「onlcr switch」)
termios 结构与 API 同上 :651(搜「tcgetattr」) · :673(搜「struct termios」)
c_lflag 三位开关 同上 :748(搜「Table 18-3」)
VMIN/VTIME 同上 :802(搜「VMIN」)
toggle_echo/模拟登录 同上 :900(搜「toggles the terminal’s echo state」) · :1016(搜「simulated login program」)
spl_stty 用法/表驱动 同上 :1032(搜「Writing an spl_stty Command」) · :1051(搜「Print a brief summary」) · :1089(搜「typedef struct _maskmap」) · :1129(搜「mask member is negative」)
spl_stty 波特率/打印 同上 :1130(搜「facilitate writing a single」) · :1137(搜「typedef struct _baudmap」) · :1257(搜「Functions That Display Attributes」)
ioctl 定位 同上 :1681(搜「The ioctl() System Call」) · :1706(搜「ultimately make a call to ioctl」)

  1. 出处:「Chapter 18: Terminals and Terminal I/O」第 194–222 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:197,搜「two subcomponents」) · :212(搜「input queue」)。 ↩

  2. 出处:「Chapter 18: Terminals and Terminal I/O」第 141–183 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:149,搜「fundamental unit」)。 ↩

  3. 出处:「Chapter 18: Terminals and Terminal I/O」第 184–192 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:191,搜「raw mode」)。 ↩

  4. 出处:「Chapter 18: Terminals and Terminal I/O」第 251–313 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:253,搜「MAX_INPUT」) · :312(搜「N_TTY_BUF_SIZE」)。 ↩

  5. 出处:「Chapter 18: Terminals and Terminal I/O」第 356–508 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:357,搜「three kinds」) · :444(搜「Categories」)。 ↩

  6. 出处:「Chapter 18: Terminals and Terminal I/O」第 394–398 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:394,搜「reset」)。 ↩

  7. 出处:「Chapter 18: Terminals and Terminal I/O」第 510–614 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:514,搜「icrnl switch」) · :598(搜「onlcr switch」)。 ↩

  8. 出处:「Chapter 18: Terminals and Terminal I/O」第 641–691 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:651,搜「tcgetattr」) · :673(搜「struct termios」)。 ↩

  9. 出处:「Chapter 18: Terminals and Terminal I/O」第 781–817 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:802,搜「VMIN」)。 ↩

  10. 出处:「Chapter 18: Terminals and Terminal I/O」第 52–79 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:52,搜「messiest」)。 ↩

  11. 出处:「Chapter 18: Terminals and Terminal I/O」第 898–928 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:900,搜「toggles the terminal’s echo state」) · :910(搜「Do not redirect standard input」)。 ↩

  12. 出处:「Chapter 18: Terminals and Terminal I/O」第 930–1031 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:930,搜「A Program that Changes and Restores」) · :953(搜「Set the echo bit in tt」) · :1016(搜「simulated login program」)。 ↩

  13. 出处:「Chapter 18: Terminals and Terminal I/O」第 1681–1760 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:1681,搜「The ioctl() System Call」) · :1684(搜「input/output control」) · :1689(搜「difficult to use」) · :1706(搜「ultimately make a call to ioctl」)。 ↩↩↩

  14. 出处:「Chapter 18: Terminals and Terminal I/O」第 1032–1130 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:1032,搜「Writing an spl_stty Command」) · :1051(搜「Print a brief summary」) · :1089(搜「typedef struct _maskmap」) · :1100(搜「const maskmap input_flags」) · :1129(搜「mask member is negative」)。 ↩↩

  15. 出处:「Chapter 18: Terminals and Terminal I/O」第 1130–1310 段(text/24-ch18-chapter-18-terminals-and-terminal-i-o.txt:1130,搜「facilitate writing a single」) · :1137(搜「typedef struct _baudmap」) · :1147(搜「NO_SUCH_BAUD」) · :1257(搜「Functions That Display Attributes」)。 ↩↩↩