1. 为什么需要内核视角?
前两篇从历史演进和分工协作的角度理解了 terminal / shell / tty / pty. 现在下沉一层,回答一个问题: 当运行 stty -echo 关掉回显,或者 Vim 启动时把终端切到 raw mode--内核到底改了什么?
答案藏在 termios 这个内核数据结构里. 它是一组开关,控制着终端 I/O 的每一个细节. 理解它,就真正拥有了 "操纵终端行为" 的能力.
termios 就像飞机驾驶舱里的控制面板--几百个拨杆和旋钮,每个控制一个具体行为: 回显开/关, 缓冲行/字符, Ctrl+C 转信号/当普通字符...
Shell 和 Vim 的区别,本质上就是它们拨动了不同组合的开关.
2. termios: 终端行为的 "开关面板"
Unix 内核用 struct termios 来描述一个终端的所有行为配置. 它包含四组标志位(flag),外加一些特殊字符的定义:
| 成员 | 全称 | 控制什么 |
|---|---|---|
c_iflag |
Input Flags | 输入端如何处理收到的字符 |
c_oflag |
Output Flags | 输出端如何处理要发送的字符 |
c_cflag |
Control Flags | 硬件控制(波特率, 数据位, 校验位等) |
c_lflag |
Local Flags | 最核心的一组--回显, 规范模式, 信号生成 |
c_cc[] |
Control Characters | 特殊字符定义(哪些按键触发 EOF, SIGINT, SUSP 等) |
用 ioctl(fd, TCGETS, &termios) 读出当前配置,改几个位,再 ioctl(fd, TCSETS, &termios) 写回去. 这就是 stty 命令和 Vim 的 raw mode 在底层做的事.
2.1 c_lflag - 决定终端 "像不像终端"
c_lflag 是四个标志组里最重要的一组. 它控制的正是我们反复提到的那些 "终端感" 行为:
| 标志 | 含义 | 关掉会怎样 |
|---|---|---|
ECHO |
回显: 敲的字符显示在屏幕上 | 敲键盘什么都看不见(密码输入就是这样) |
ICANON |
规范模式: 内核按行缓冲,Enter 才提交 | 每个按键立刻送达程序(raw mode 的核心) |
ISIG |
信号生成: Ctrl+C → SIGINT 等 | Ctrl+C 变成普通字符(ASCII 3),不杀进程 |
ECHOE |
按退格键时擦除屏幕上最后一个字符 | 退格只是发一个字符,屏幕不更新 |
ECHOK |
按 Ctrl+U 杀行后显示视觉反馈(如换行) | 行被清了但看不到 |
IEXTEN |
扩展功能: Ctrl+V(逐字输入),Ctrl+W(删词) | 这些特殊编辑组合键失效 |
可以同时关掉 ECHO + ICANON + ISIG,但保留其他位--这就是精细控制. 实际的 raw mode 不是 "全关",而是一组精心选择的默认值.
2.2 c_iflag - 输入端怎么处理收到的字符
| 标志 | 含义 | 关掉会怎样 |
|---|---|---|
ICRNL |
收到的回车(CR, \r)转为换行(NL, \n) | 程序看到的是 \r 而不是 \n |
IXON |
软件流控: Ctrl+S 暂停输出,Ctrl+Q 恢复 | Ctrl+S 失效(不会意外冻结终端了) |
INLCR |
收到的 \n 转为 \r | 程序直接收到 \n |
IGNCR |
忽略收到的 \r | \r 正常传递给程序 |
BRKINT |
收到 BREAK 信号时发送 SIGINT | BREAK 被忽略 |
Ctrl+S 的陷阱
几乎所有终端新用户都踩过这个坑: 不小心按了 Ctrl+S,终端突然 "卡住" 了--敲什么都没反应. 其实不是卡了,是 IXON 标志让内核暂停了输出. 按 Ctrl+Q 恢复. 这就是 c_iflag 在日常中的实际影响.
2.3 c_oflag - 输出端怎么处理要发的字符
| 标志 | 含义 |
|---|---|
OPOST |
启用输出后处理(这个关了,下面所有标志都失效) |
ONLCR |
输出 \n 时自动在前面加 \r(\n → \r\n) |
OCRNL |
输出 \r 时转为 \n |
TAB3 |
Tab 转为空格输出 |
ONLCR 是日常影响最大的标志. 想想为什么 Shell 输出 \n 就能让光标到下一行行首--这其实是内核帮忙补了 \r. 如果关掉 ONLCR,终端会变成 "楼梯效果":
|
3. stty: 不用写代码就能操作 termios
stty(set tty)是查看和修改 termios 的命令行工具. 它直接调用 ioctl 读写 termios 结构.
3.1 查看当前设置
|
这就是当前终端的完整 termios 配置. 每一行对应一组标志--lflags, iflags, oflags, cflags, 和 c_cc[] 特殊字符定义.
stty -a 中 - 前缀表示该标志已关闭,没有前缀表示已开启. 例如: icanon(开) vs -echonl(关).
3.2 修改终端行为
|
3.3 救命命令: stty sane
当折腾终端设置导致乱套后(比如运行了 cat 二进制文件导致终端状态混乱),可能会看到: 敲什么键都不回显, 换行错乱, Ctrl+C 失效. 此时需要:
|
stty sane 把终端恢复到 "合理" 的默认状态. reset 做了同样的事,还额外清屏和重置光标.
4. 规范模式 vs 原始模式
上一篇反复提到这两个模式. 现在有了 termios 的知识,可以精确理解它们各自拨动了哪些开关.
4.1 规范模式: 内核替程序做的三件事
规范模式(ICANON=1)下,一个 read(stdin, buf, n) 调用是这样的:
|
在这个模式里,程序在 read() 上阻塞,对用户的按键毫不知情. 所有编辑(退格, Ctrl+U 删行, Ctrl+W 删词)都是内核代劳的.
4.2 原始模式: 全部交还给程序
原始模式不是某一个标志,而是一组标志的组合关闭. 没有 "raw mode" 这个单一位--只是约定俗成地叫 "raw":
| 关闭的标志 | 效果 |
|---|---|
ICANON |
不再行缓冲--每个字符立刻送达程序 |
ECHO |
不再回显--程序自己决定什么时候显示什么 |
ISIG |
Ctrl+C / Ctrl+Z 不再生成信号,变成普通字符 |
IEXTEN |
Ctrl+V / Ctrl+W 等扩展编辑键失效 |
ICRNL |
\r 不再转为 \n,程序自己处理 |
IXON |
Ctrl+S / Ctrl+Q 软件流控失效 |
OPOST |
输出后处理失效(包括 ONLCR--\n 不再自动补 \r) |
全关之后,终端变成了一根透明的管道: 每个字节原样进出,没有任何加工. Vim 就是这么用的--它需要精确控制每一个按键, 每一个光标位置, 每一帧画面.
raw ≠ 全关
严格来说有两种模式: raw(几乎全关,包括 ICRNL 和 OPOST)和 cbreak(只关 ICANON,保留 ISIG 和信号处理). cbreak 是一个中间状态: 程序逐字符接收输入,但 Ctrl+C 仍然能终止它. 大多数场景下其实用的是 cbreak 而非完全的 raw.
5. PTY 创建: 从应用开发者视角
前两篇反复说 "终端模拟器打开 /dev/ptmx, 内核创建 PTY 对". 现在来看具体的系统调用.
5.1 四步创建 PTY
创建一对 PTY 需要四个步骤,三个系统调用:
|
拿到 master fd 和 slave 名字后,传统的 fork + exec 流程:
|
setsid() 是关键: 它让子进程脱离原来的会话,创建一个新的会话. 此时该进程没有控制终端--然后打开 slave 设备,slave 自动成为这个新会话的控制终端.
5.2 SSH 如何使用 PTY
当 ssh user@remote 时,SSH 服务端做的事情和终端模拟器一模一样:
|
SSH 服务端在中间充当了 "桥梁": 一端是网络 socket(连着本地),另一端是 PTY master(连着远程 Shell). 它从 socket 读到按键 → 写到 master → Shell 输出 → master 读出 → 发回 socket. 这就是为什么 SSH 登录后感觉 "就是在用本地终端"--因为远程 Shell 确实连着一个 PTY,和本地的体验完全一样.
ssh 的 -t 参数
如果运行 ssh remote "ls"(不带 -t),SSH 不会分配 PTY--命令的 stdin/stdout 直接连到 socket. 如果运行 ssh -t remote "vim",SSH 会强制分配 PTY--因为 vim 需要一个终端才能正常工作. 不带 -t 时运行 vim 会报 Vim: Warning: Output is not to a terminal.
6. 终端信号全家福
以下是与终端相关的完整信号列表. 这些信号全由内核 TTY 层触发,不是 Shell 发的:
| 信号 | 触发条件 | 默认行为 | 谁在处理 |
|---|---|---|---|
SIGINT |
Ctrl+C(来自 c_cc[VINTR]) | 终止进程 | 内核 Line Discipline(需 ISIG 开启) |
SIGQUIT |
Ctrl+(来自 c_cc[VQUIT]) | 终止 + core dump | 内核 Line Discipline(需 ISIG 开启) |
SIGTSTP |
Ctrl+Z(来自 c_cc[VSUSP]) | 挂起进程 | 内核 Line Discipline(需 ISIG 开启) |
SIGWINCH |
窗口大小改变 | 忽略 | 内核 TTY 层(终端模拟器发 ioctl 后触发) |
SIGHUP |
终端断开(窗口关闭,SSH 断连) | 终止进程 | 内核 TTY 层(master 端 close 时触发) |
SIGTTIN |
后台进程尝试读终端 | 挂起进程 | 内核 TTY 层 |
SIGTTOU |
后台进程尝试写终端 | 挂起进程 | 内核 TTY 层 |
SIGPIPE |
向已关闭的管道/socket 写入 | 终止进程 | 内核 pipe/socket 层 |
这个全家福里有一个关键的区分: 前三行仅在 ISIG 开启时生效--关掉 ISIG, Ctrl+C 就变成了普通字符. 但 SIGHUP 和 SIGWINCH 不受 ISIG 影响--它们是 TTY 层的核心机制,关不掉.
SIGHUP 的实际意义
当关闭终端窗口,内核检测到 PTY master 端被 close → 向 slave 端的会话领头进程发送 SIGHUP → Shell 收到 SIGHUP 后终止,并向自己的子进程(所有后台作业)也发 SIGHUP. 这就是为什么 "关窗口 = 杀掉里面所有程序". 可以用 nohup 让进程忽略 SIGHUP 来避免这种情况.
快速回顾
- termios = 内核维护的终端行为配置,四组标志位(lflag/iflag/oflag/cflag) + 特殊字符表(c_cc)
- c_lflag 最重要: ECHO(回显), ICANON(规范模式), ISIG(信号生成)--这是 "终端感" 的三大支柱
- stty 是 termios 的命令行接口,
stty -a查看,stty -echo修改,stty sane救命 - 规范模式: 内核缓冲整行,处理退格/Ctrl+U/Ctrl+W,Enter 后提交给程序
- 原始模式: ICANON+ECHO+ISIG+ICRNL+OPOST 等全关,终端变成透明管道,每个字节原样进出
- PTY 创建四步: posix_openpt → grantpt → unlockpt → ptsname,然后 fork + setsid + dup2 + exec
- SSH 在远程创建 PTY,服务端充当 socket ↔ master 的桥梁
- 终端信号: SIGINT/SIGQUIT/SIGTSTP 依赖 ISIG; SIGHUP/SIGWINCH 不依赖 ISIG
动手练习
- 查看 termios: 运行
stty -a,找到intr,eof,susp这三个特殊字符的定义 - 关闭回显: 运行
stty -echo; read -p "输入密码:" p; stty echo; echo "密码是 $p",体验关回显的效果 - 逐字符模式: 运行
stty -icanon -echo; cat进入逐字符模式(按 Ctrl+D 退出),和规范模式的cat对比行为差异 - 关闭信号: 运行
stty -isig; cat,然后按 Ctrl+C--观察发生了什么?(Ctrl+D 退出后立即stty isig恢复) - 终端是设备文件: 运行
tty记下终端名,再运行echo hello > /dev/ttysXXX向自己的终端写数据--验证终端就是一个可读写的字符设备 - ONLCR 开关: 尝试
stty -onlcr; printf "a\nb\nc\n"; stty onlcr,观察 ONLCR 开关对输出的影响