Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

0.2 终端、Shell 与命令行工作流

本章回答

终端、tty、pty、Shell 是什么关系?你按下回车后,一条命令经过哪些步骤才变成进程?为什么 alias 在脚本里不生效,为什么 ssh host cmd 读不到 ~/.bashrc?怎样用历史、vim、tmux 把命令行变成可持续的工作台?

读完你会

画出「键盘 → pty → Shell → 子进程」的链路;用 stty 解释终端行为、用 readline 键位少按方向键;按登录/交互类型解释启动文件;用 jobs 管理前后台;用 vim 的文本对象与宏批量编辑;用 tmux 脚本复现工作现场,并按 help → man → info 的顺序查文档。

上一章把系统分成内核、libc、应用三层。这一章把镜头拉近到最上面一层:你面对的终端窗口,以及窗口里那个等待输入的 Shell。

很多人把「终端」和「Shell」当成一回事。实际排障时这个混淆很贵:终端卡死、Shell 配置错、命令本身失败,是三件不同的事。分清楚之后,你会知道该重启窗口、改 .bashrc,还是去查程序日志。

本章还会建立一套可持续的工作流。命令行的高效不来自背更多命令,而来自三件事:少打字(历史与补全)、不丢上下文(tmux)、能安全地试错(作业控制与可恢复的编辑)。

0.2.1 键盘到进程:tty、pty 与终端模拟器

定义 0.2.1 · 终端、tty 与 pty

终端(terminal)原本是带键盘和屏幕的硬件设备,负责把人输入的字符合成字节流,并把收到的字节流显示出来。tty 是内核里的终端设备抽象(Teletype 的历史名字),它负责行缓冲、回显、信号生成(如 Ctrl-C)等行规程(line discipline)。pty(pseudo-terminal)是一对虚拟设备:master 端由终端模拟器持有,slave 端表现得像一个真实终端,Shell 与程序打开的就是 slave。

今天的「终端窗口」其实是终端模拟器(GNOME Terminal、iTerm2、Windows Terminal、VS Code 内置终端)。它做三件事:把按键写成字节送到 pty master;从 master 读回字节并渲染;处理窗口大小变化并通知内核。Shell 并不知道自己在窗口里,它只知道自己的 stdin/stdout 是一个 tty。

键盘/屏幕                用户态                        内核                        用户态
┌──────────┐   按键   ┌────────────────┐   write   ┌──────────────┐   read   ┌─────────┐
│ 终端模拟器 │ ───────→ │ pty master     │ ────────→ │ pty slave    │ ───────→ │ bash    │
│ (GUI)     │ ←─────── │ /dev/pts/ptmx  │ ←──────── │ /dev/pts/N   │ ←─────── │ + 子进程 │
└──────────┘   渲染   └────────────────┘   读取    │ + 行规程      │   写出   └─────────┘
                                                   └──────────────┘
                                          行规程负责:回显、行缓冲、Ctrl-C → SIGINT、
                                          Ctrl-Z → SIGTSTP、Ctrl-D → EOF

这张图的关键在中间的内核部分:终端行为不是 Shell 实现的。你按 Ctrl-C,是行规程把字节 0x03 翻译成 SIGINT 发给前台进程组;你按 Ctrl-D,是行规程把「缓冲区为空时的 EOF」交给 read。Shell 只是这些机制的受益者。这也解释了为什么在 vim 里 Ctrl-C 不一定退出:vim 把终端切到原始模式,行规程的特殊字符处理被关掉了。

看自己的进程挂在哪个终端上:

$ tty
/dev/pts/0
$ ps -o pid,ppid,tty,stat,cmd -p $$
    PID    PPID TTY      STAT CMD
   4127    4120 pts/0    Ss   bash
$ ps -o pid,ppid,tty,stat,cmd -t $(tty)
    PID    PPID TTY      STAT CMD
   4127    4120 pts/0    Ss   bash
   4381    4127 pts/0    R+   ps -o pid,ppid,tty,stat,cmd -t /dev/pts/0
$ ls -l /proc/$$/fd/0
lrwx------ 1 wang wang 64 Oct 17 10:12 /proc/4127/fd/0 -> /dev/pts/0

tty 输出 /dev/pts/0,说明这是一个 pty slave;如果在物理控制台上登录,会看到 /dev/tty1 这类名字。ps 的 TTY 列是 pts/0,STAT 里的 Ss 表示「会话首进程、可中断睡眠」。/proc/$$/fd/0 指向 /dev/pts/0,证明 Shell 的 stdin 就是这个终端。

远程登录多了一层:SSH 客户端在本地终端与远端 sshd 之间转发字节,sshd 在远端分配一个 pty,远端的 Shell 看到的是 /dev/pts/N。所以「本地终端卡住」和「远端 Shell 卡住」需要分别排查,中间可能断在 SSH、网络或远端 pty。

0.2.2 一条命令从回车到输出

以 grep -n root /etc/passwd | head -3 为例,交互式 bash 大致做六件事:

步骤做什么出问题时你看到
1. 读取行规程把整行交给 readlineCtrl-D 退出、Ctrl-C 取消当前行
2. 解析按 ;、`、&&` 切成命令,识别重定向与引号
3. 展开别名、大括号、波浪号、变量、命令替换、算术、通配符通配符没匹配时字面保留(默认)
4. 查找判断内建/函数/外部命令,在 $PATH 中找可执行文件command not found
5. 执行管道两端各 fork 一个子进程,dup2 接好 fd,execvePermission denied、Exec format error
6. 等待父 Shell wait4,收集退出码,设置 $?退出码非零,但你看不到原因

两个常被忽略的事实:

  • 内建命令不 fork。cd、export、umask、read 必须改变当前 Shell 的状态,所以由 Shell 自己执行。/bin/cd 不存在,command -v cd 会告诉你它是 builtin。
  • 管道两端是并发执行的。grep 与 head 同时启动,head 读够 3 行就退出,grep 后续的 write 会收到 SIGPIPE。这不是错误,是管道的工作方式,详见 0.5。

strace -f 能看到 5、6 两步的真实系统调用:

# -f 跟子进程;-e 只看进程创建与执行
strace -f -e trace=clone,execve,wait4 bash -c 'grep -n root /etc/passwd | head -3'

输出里会有两次 clone(管道两端)、两次 execve(grep 与 head),以及父 Shell 的 wait4。这比任何文字描述都更能说明「管道不是一个命令,而是两个进程加一个内核缓冲区」。

为什么 Shell 要 fork 两次

管道需要两个进程同时读写同一个内核缓冲区。Shell 先创建两个子进程,各自把 stdin 或 stdout 接到管道的一端,再分别 execve。父 Shell 自己不参与数据搬运,只负责等待。这样 grep 与 head 的并发、退出码、信号都独立,Shell 不必理解文本内容。

0.2.3 命令查找顺序:alias、函数、内建、PATH

输入一个名字后,bash 按固定顺序解析:

  1. 别名(alias):仅交互式 Shell 默认展开;脚本里除非 shopt -s expand_aliases,否则不生效。
  2. 函数:当前 Shell 定义的函数,优先于外部命令。
  3. 内建命令(builtin):cd、echo、printf、type、read、export 等。
  4. 哈希表:bash 缓存上次找到的路径;hash -r 清空。
  5. $PATH 中的可执行文件:从左到右找第一个。
$ type -a ls
ls is aliased to `ls --color=auto'
ls is /usr/bin/ls
ls is /bin/ls
$ type -a cd
cd is a shell builtin
$ command -v grep
/usr/bin/grep
$ command -V grep
grep is /usr/bin/grep
$ type -a python3
python3 is /usr/bin/python3
python3 is /bin/python3

type -a 列出所有匹配,顺序就是解析顺序;command -v 只给「会被执行的那个」,适合脚本判断;command -V 给人类可读的描述。不要用 which 做脚本判断:which 是外部命令,不认别名和函数,在不同发行版上行为还不一致。

$PATH 的两个安全细节:

  • 不要把 . 放进 PATH。否则在 /tmp 下执行 ls,可能运行攻击者放的 ./ls。确实要运行当前目录的程序,写 ./prog。
  • 空路径分量等于当前目录。PATH=/usr/bin: 末尾的空项会被解释为当前目录,等价于加了 .。用 PATH=${PATH%:} 之类的清理,或直接用绝对路径。

别名适合交互,不适合脚本。下面这段 .bashrc 是常见配置,注意它只在交互式 Shell 生效:

# ~/.bashrc 片段:仅交互式 Shell 会读到这里
shopt -s checkwinsize          # 窗口大小变化后更新 LINES/COLUMNS
shopt -s histappend            # 多个终端追加历史,而不是互相覆盖

alias ll='ls -alF'
alias grep='grep --color=auto'
alias ..='cd ..'

# 函数优先于同名外部命令;给 mkdir 加「自动建父目录」
mkcd() {
    mkdir -p -- "$1" && cd -- "$1"
}

alias grep='grep --color=auto' 只影响交互输出,不影响脚本;--color=auto 在输出到管道时自动关闭颜色,避免把 ANSI 转义写进文件。这个细节在第 0.5 章讲缓冲与 TTY 时会再出现。

0.2.4 登录、非登录、交互、非交互:四象限与启动文件

bash 的启动文件由两个维度决定:是不是登录 Shell、是不是交互式。

类型典型场景读哪些文件(Ubuntu 24.04)
登录 + 交互控制台登录、ssh host、su -/etc/profile → ~/.bash_profile 或 ~/.bash_login 或 ~/.profile
非登录 + 交互打开终端窗口、bash、tmux 新 pane/etc/bash.bashrc → ~/.bashrc
非登录 + 非交互bash script.sh、ssh host cmd不读启动文件;只看 BASH_ENV
登录 + 非交互ssh host 'bash -l -c cmd'读登录文件,但不读 .bashrc(除非 .profile 主动 source)

四个常见困惑都能用这张表解释:

  1. ssh host 'echo $PATH' 的 PATH 和登录后不一样。非交互非登录 Shell 不读 /etc/profile,只继承 sshd 给的环境。要固定环境,在 ~/.ssh/environment(需 PermitUserEnvironment yes)或命令里显式 source。
  2. ~/.bashrc 写了 export,cron 任务里却没有。cron 不启动交互 Shell,也不读 .bashrc。在脚本里显式设置 PATH,或 source 一个环境文件。
  3. su user 后别名没了。su user 不带 - 是非登录 Shell,不读 /etc/profile;su - user 才是登录 Shell。
  4. sudo cmd 找不到自己编译的程序。sudo 默认重置 PATH(secure_path),用绝对路径或 sudo -E(有风险)。

判断当前 Shell 的类型:

# 登录 Shell 返回 login;非登录返回 non-login
shopt -q login_shell && echo login || echo non-login
# 交互式返回 i,非交互返回非 i
case $- in *i*) echo interactive ;; *) echo non-interactive ;; esac
# 当前 Shell 的 PID 与父进程
ps -o pid,ppid,cmd -p $$ -p $PPID

一个可维护的 .bashrc 结构:把「环境变量」和「交互体验」分开。环境变量放 ~/.profile(登录时读一次),别名、提示符、补全放 ~/.bashrc(每个交互 Shell 读)。这样 ssh host cmd 与非交互脚本不会因为加载了交互配置而变慢或报错。

0.2.5 提示符、历史与高效编辑

提示符 $PS1 是一段会被展开的字符串。常用转义:

转义含义例子
\u用户名wang
\h短主机名lab
\w当前目录(~ 缩写)~/src/app
\W当前目录的 basenameapp
\t24 小时时间14:03:22
\!历史编号1042
\$普通用户 $,root #$

一个不花哨但信息够用的配置:

# ~/.bashrc:提示符与历史
PS1='\[\e[32m\]\u@\h\[\e[0m\]:\[\e[34m\]\w\[\e[0m\]\$ '
PS2='> '
# 命令耗时超过 2 秒时,在提示符前打印耗时
PROMPT_COMMAND='dur=$?; if (( dur != 0 )); then printf "[exit %d] " "$dur"; fi'

HISTSIZE=50000
HISTFILESIZE=100000
HISTCONTROL=ignoreboth:erasedups   # 忽略空格开头与重复命令
HISTIGNORE='ls:ll:cd:pwd:history'
shopt -s histappend cmdhist lithist

PS1 里的 \[ \] 告诉 readline 这一段不占可见宽度,否则长命令换行会错位。PROMPT_COMMAND 在每次显示提示符前执行,适合放轻量逻辑;不要在里面跑 git status 之类的重命令,大仓库会明显拖慢每次回车。

历史展开是提效最多的一组快捷键:

写法展开成说明
!!上一条命令sudo !! 是最常用的组合
!$上一条命令的最后一个参数mkdir a && cd !$
!*上一条命令的全部参数
!^上一条命令的第一个参数
!n历史编号 n!1042
!-n倒数第 n 条!-2
!string最近一条以 string 开头的命令!git
^old^new把上一条命令中的 old 换成 new^/tmp^/var/tmp

Ctrl-R 是反向增量搜索:按一次进入,输入片段,再按 Ctrl-R 跳上一个匹配,Enter 执行,Ctrl-G 取消。比翻历史快得多。Alt-.(或 Esc .)插入上一条命令的最后一个参数,连续按可以往前翻。

历史是文件,不是内存。默认退出时才写 ~/.bash_history,多个终端会互相覆盖。shopt -s histappend 改成追加;history -a 立即追加当前会话的新命令;history -w 覆盖写整个历史。要删掉刚打错的一条(比如忘了加引号的密码),用 history -d <n>,再 history -w。

除了历史,readline 还提供一组光标与删除键;它们不进入历史,但能少按很多次退格:

按键作用
Ctrl-A / Ctrl-E跳到行首 / 行尾
Alt-B / Alt-F按词后退 / 前进
Ctrl-U / Ctrl-K删除光标到行首 / 行尾
Ctrl-W删除光标前的一个词
Ctrl-Y粘回最近一次删除的内容
Ctrl-L清屏并保留当前行
Tab / Tab Tab补全;第二次列出所有候选
Alt-?列出当前位置的可能补全

Tab 补全由 bash 的补全系统提供,能补命令名、路径、变量、用户名和主机名;complete -p git 看某个命令的补全规则。补全行为可以在 ~/.inputrc 里调,例如 set completion-ignore-case on 让路径补全忽略大小写,set show-all-if-ambiguous on 让第一次 Tab 就列出多个候选。set -o vi 则把行编辑切成 vi 键位,适合已经熟悉 vim 的人。

0.2.6 作业控制最小集

作业控制让一个终端同时管理多个进程。核心只有五个动作:

动作命令/按键效果
后台启动cmd &子进程进入后台,Shell 立即返回作业号与 PID
挂起前台Ctrl-Z向前台进程组发 SIGTSTP,进程进入 T 状态
查看作业jobs -l列出作业号、状态、PID、命令
切到前台fg %1把作业 1 放到前台,继续运行
后台继续bg %1让停止的作业在后台继续
$ sleep 300 &
[1] 4512
$ jobs -l
[1]+  4512 Running                 sleep 300 &
$ sleep 600
^Z
[2]+  Stopped                 sleep 600
$ jobs
[1]-  Running                 sleep 300 &
[2]+  Stopped                 sleep 600
$ bg %2
[2]+ sleep 600 &
$ fg %1
sleep 300
^C
$ jobs
[2]+  Running                 sleep 600 &

jobs 输出里的 + 是「当前作业」,- 是「上一个作业」,不带 % 时 fg/bg 默认操作 +。^Z 是终端回显的 Ctrl-Z,不是命令。

作业控制有几个边界:

  • 只对当前 Shell 的作业有效。在另一个终端里 jobs 看不到这些进程;要跨终端管理,用 pgrep/ps 或 tmux。
  • 非交互 Shell 默认关闭作业控制。脚本里 cmd & 仍然能后台运行,但没有 jobs/fg 的交互语义;需要时 set -m。
  • 关闭终端会发 SIGHUP。登录 Shell 退出时,会向它启动的作业发送挂断信号。nohup cmd & 让进程忽略 SIGHUP 并把输出写到 nohup.out;disown -h %1 只标记「shell 退出时不发 SIGHUP」,作业仍在作业表里(jobs 可见、kill %1 可用);disown %1(不带 -h)才从作业表移除。setsid cmd 让进程进入新会话,彻底脱离控制终端。
  • Ctrl-Z 不是终止。进程只是停止,仍占内存与 PID;不想要了要 kill %1(默认 SIGTERM)。
关于 Shell 工作流的五个误解
  1. 「终端就是 Shell」——终端模拟器负责渲染与 pty,Shell 只是 pty slave 上的一个用户进程。终端卡死时,Shell 可能还活着。
  2. 「alias 写进 .bashrc,脚本里也能用」——非交互 Shell 默认不展开别名。脚本里用函数或显式命令,别依赖 alias。
  3. 「ssh host cmd 和登录后执行一样」——前者是非交互非登录 Shell,不读 .bashrc/.profile,PATH 与别名都可能不同。
  4. 「Ctrl-Z 等于结束进程」——它只是挂起(SIGTSTP),进程还在。要结束用 kill,要后台继续用 bg。
  5. 「关掉终端,后台任务一定继续跑」——默认会收到 SIGHUP。需要 nohup、disown 或 setsid,或者放进 tmux/systemd。

0.2.7 vim 生存指南:够用的三十个操作

服务器上不一定有图形编辑器,vi 通常一定在。目标不是成为 vim 高手,而是能在不改坏文件的前提下完成常见编辑。

vim 有模式。按 Esc 回到普通模式,所有移动与操作命令都在这里;按 i 进入插入模式;按 : 进入命令行模式。

类别操作说明
打开/保存vim file、:w、:q、:wq、:q!:q! 放弃修改;:w !sudo tee % 提权保存
移动h j k l、w、b、0、$、gg、G、:42字符/词/行首尾/文件首尾/第 42 行
翻页Ctrl-F、Ctrl-B、Ctrl-D、Ctrl-U前后翻页、半页
插入i、a、o、O前插、后插、下方新行、上方新行
删除x、dd、dw、d$、3dd删字符、删行、删词、删到行尾、删 3 行
复制粘贴yy、yw、p、P复制行/词,粘贴到后/前
撤销重做u、Ctrl-R撤销、重做
查找/pattern、n、N、?pattern向下/向上查找,跳下一个/上一个
替换:%s/old/new/g、:%s/old/new/gc全文替换;c 逐条确认
可视选择v、V、Ctrl-V字符/行/块选择,再配合 d/y
退出ZZ、:x保存并退出(与 :wq 类似)

三个安全习惯:

  1. 先看再改。打开文件先 :set number 看行号,确认自己在改哪一段。
  2. 不确定就 u。vim 的撤销树很强,连续 u 能退回很多步;Ctrl-R 重做。
  3. 权限不够不要硬写。:w !sudo tee % 用 sudo 把当前缓冲区写到文件,适合改 /etc 下的配置;写完后 vim 会提示文件已变,按 L 重新加载。

替换是最高频的批量操作。:%s/old/new/g 的范围是全文,:%s/old/new/gc 每条确认;:'<,'>s/old/new/g 只替换可视选择的行;:%s/\s\+$//e 删除行尾空白。正则语法与 grep/sed 有差异(vim 默认 magic 模式),复杂表达式先在副本上试。

vimtutor 是官方交互教程,花 30 分钟能覆盖上面大部分操作。比背命令表有效。

0.2.8 tmux 最小集:让工作现场可恢复

SSH 断线、终端崩溃、需要同时看日志和敲命令——tmux 解决这三件事。它创建一个会话(session),会话里有窗口(window),窗口里可以切分面板(pane)。Shell 跑在 pane 里,与终端窗口解耦;终端关了,会话还在。

目的命令
新建命名会话tmux new -s work
列出会话tmux ls
重新接入tmux attach -t work
断开但保留Ctrl-B 然后 d
杀死会话tmux kill-session -t work
新建窗口Ctrl-B c
切换窗口Ctrl-B n / p / 0–9
重命名窗口Ctrl-B ,
垂直/水平分屏Ctrl-B % / Ctrl-B "
在 pane 间移动Ctrl-B 方向键
放大当前 paneCtrl-B z
关闭 paneCtrl-B x
进入复制模式Ctrl-B [,方向键/PageUp 翻页,q 退出

一个典型工作流:

# 在跳板机上开一个持久会话
tmux new -s api-debug
# 窗口 0 跑日志
journalctl -u api -f
# Ctrl-B c 新建窗口 1,跑测试
curl -sS localhost:8080/health
# Ctrl-B % 分屏,右边开一个 shell 查进程
ps -eo pid,stat,pcpu,cmd --sort=-pcpu | head
# 下班或断网前:Ctrl-B d 断开;第二天 tmux attach -t api-debug

Ctrl-B 是默认前缀(prefix)。所有 tmux 快捷键都是「按前缀,再按一个键」。前缀可以改,但默认值在大多数机器上不用配置就能用。

一个够用的 ~/.tmux.conf:

# ~/.tmux.conf:最小可用配置
set -g mouse on                 # 允许鼠标选 pane、滚轮翻页
set -g history-limit 50000      # 每个 pane 的滚动缓冲
set -g base-index 1             # 窗口从 1 开始编号
setw -g pane-base-index 1       # pane 从 1 开始编号
set -g escape-time 10           # 降低 vim 中 Esc 的延迟
set -g status-left '[#S] '
set -g status-right '%H:%M %d-%b'

escape-time 值得单独说:tmux 需要等待一小段时间来判断你按的是 Esc 还是某个以 Esc 开头的组合键。默认值偏大时,vim 里按 Esc 会有明显延迟;调到 10 ms 通常就够。

tmux 不是终端复用器的唯一选择(screen、zellij、abduco 都有),但它是 Ubuntu 仓库里默认可用、资料最多、与 systemd 不冲突的一个。把长任务放进 tmux 或 systemd,而不是 nohup,是更可维护的做法:前者能恢复现场,后者能自动重启与收集日志。

0.2.9 终端能力:TERM、stty 与行规程

这一节回答:为什么同一个程序在 SSH、tmux、管道里的行为不同;Ctrl-C 什么时候是信号、什么时候只是字节;怎么在脚本里安全地读密码。

0.2.1 把 pty 画成一条链路,但链路中间那层行规程(line discipline)才是行为差异的来源。行规程由 termios 参数控制,stty 是它的命令行界面。vim、less、ssh 登录、sudo 读密码,全都在改这组参数。

先看一台 Ubuntu 24.04 的真实 stty -a:

$ stty -a
speed 38400 baud; rows 40; columns 120; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
eol2 = <undef>; swtch = <undef>; start = ^Q; stop = ^S; susp = ^Z;
rprnt = ^R; werase = ^W; lnext = ^V; discard = ^O; min = 1; time = 0;
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
-ignbrk brkint -ignpar -parmrk -inpck -istrip -inlcr -igncr icrnl ixon -ixoff
-iuclc -ixany imaxbel iutf8
opost -olcuc -ocrnl onlcr -onocr -onlret -ofill -ofdel nl0 cr0 tab0 bs0 vt0 ff0
isig icanon iexten echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
echoctl echoke -flusho -extproc

逐段读:

行字段含义改它会怎样
1speed / rows / columns波特率与窗口大小stty rows 50 cols 160 可临时改
2intr / quit / susp生成 SIGINT/SIGQUIT/SIGTSTP 的键改成 ^A 会让 Ctrl-C 失效
2erase / kill / werase退格、删整行、删词erase = ^H 适配老终端
2eof行首按它产生 EOFCtrl-D 不是「关闭」,是「读返回 0」
2min / time非规范模式下的读语义min 1 time 0 是逐字节阻塞读
3-icanon关闭行缓冲每个字符都交给 read,Shell 行编辑失效
3echo / -echo是否回显读密码时关掉
3isig是否把 intr 等键变成信号关掉后 Ctrl-C 只是字节 0x03
3ixonCtrl-S/Ctrl-Q 流控关掉后 Ctrl-S 可用于前向搜索
4opost / onlcr输出时把 \n 转成 \r\n关掉后输出会「阶梯状」
4tostop后台进程写终端是否发 SIGTTOU默认关,打开会停住后台输出

这张表说明:Shell 的行编辑、Ctrl-C、密码不回显,都是 termios 参数,不是 Shell 的魔法。vim 进入全屏时把终端切成 raw 模式,就是一次性关掉 icanon、echo、isig、opost 等一组标志;退出时再恢复。它保存与恢复的正是 stty -g 输出的那串十六进制。

stty -g 是「保存现场」的标准做法:

#!/usr/bin/env bash
# ask-pass.sh —— 读一个不回显的输入,退出时恢复终端
set -euo pipefail
saved=$(stty -g)
trap 'stty "$saved"' EXIT INT TERM
stty -echo
printf 'token: '
IFS= read -r token
printf '\n'
stty "$saved"
trap - EXIT INT TERM
[[ -n $token ]] || { echo "empty token" >&2; exit 1; }
printf 'read %d bytes\n' "${#token}"

关键点是 trap 要在 stty -echo 之前安装:脚本被 Ctrl-C 打断时,EXIT 与 INT 都会触发恢复;否则终端会一直不回显,用户只能 reset。IFS= read -r 保留反斜杠与首尾空格;read -s 是 bash 的语法糖,效果类似,但它不会替你处理异常退出的恢复。

TERM 决定程序发送哪些转义序列。TERM=dumb 时,tput 与颜色库会退化;TERM 写错时,less 翻页会乱码。

printf 'TERM=%s\n' "${TERM:-unset}"
tput colors          # 256 或 8;dumb 下报错
tput cup 3 10        # 光标移到第 3 行第 10 列
tput el              # 清到行尾
infocmp -1 xterm-256color | sed -n '1,12p'
toe -a | head        # 系统里有哪些 terminfo 条目

tput 读 terminfo 数据库,不直接写死转义。写跨终端脚本时用它,而不是 printf '\033[2J';后者在 TERM=dumb 或非 ANSI 终端上会打印乱码。infocmp 是反向工具:把某个条目的能力逐项列出,排查「为什么这个键在我的终端里不工作」时很有用。

实验 0.2-A · 看清 tty、管道与 pty 的差别

目的:用同一段判断在三种 stdin 下运行,证明「是不是终端」是文件描述符属性,不是 Shell 的猜测。

#!/usr/bin/env bash
# tty-check.sh —— 判断 stdin/stdout 是不是终端
set -euo pipefail
for fd in 0 1 2; do
    if [[ -t $fd ]]; then
        printf 'fd%d=tty(%s) ' "$fd" "$(readlink -f "/proc/$$/fd/$fd")"
    else
        printf 'fd%d=notty ' "$fd"
    fi
done
printf '\n'

三种跑法:

./tty-check.sh                       # 终端里:三个 fd 都是 tty
./tty-check.sh < /dev/null           # stdin 不是 tty
./tty-check.sh | cat                 # stdout 不是 tty
script -qec './tty-check.sh' /dev/null   # 在伪终端里跑,stdout 又是 tty
stty -a < /dev/null                  # 报 Inappropriate ioctl for device

预期:第一行三个 tty;第二行 fd0=notty;第三行 fd1=notty(cat 那一端是管道);script 那行恢复成全 tty;最后一条 stty 直接失败,因为 /dev/null 不是终端,没有 termios 可读。这个实验解释了 ls --color=auto、grep --color=auto、git 的分页为什么在管道里自动关闭:它们都在做 isatty(1) 判断。

终端层的五个误解
  1. 「Ctrl-D 是关闭终端」——它是行规程的 eof 字符;在行首让 read 返回 0,Shell 据此退出。终端没关,pty 还在。
  2. 「Ctrl-C 一定发 SIGINT」——-isig 或 raw 模式下它只是字节 0x03,由程序自己解释;vim 插入模式就是这种状态。
  3. 「stty -echo 只影响当前命令」——它改的是终端设备状态;进程异常退出没恢复,整个终端都不回显,要 stty sane 或 reset。
  4. 「TERM 只是给 ls 上色用的」——less、vim、tmux、top 都按 TERM 选转义序列;设错会让全屏程序花屏。
  5. 「管道里的程序行为和终端里一样」——isatty 为假时,缓冲模式、颜色、行编辑、信号键全部可能不同。

0.2.10 readline 键位表与练习

这一节回答:除了方向键,命令行还有哪些「不离开主键区」的编辑动作;怎样把这些动作变成肌肉记忆。

readline 是 bash 的行编辑器。它不关心命令内容,只负责「光标、删除、历史、补全」。下面这张表按动作分组,键位是 bash 5.2 的默认值,可用 bind -P 核对本机:

类别按键函数作用
移动Ctrl-A / Ctrl-Ebeginning/end-of-line行首 / 行尾
移动Alt-B / Alt-Fbackward/forward-word按词后退 / 前进
移动Ctrl-B / Ctrl-Fbackward/forward-char按字符移动
移动Ctrl-X Ctrl-Xexchange-point-and-mark在光标与行首间跳(连按两次)
删除Ctrl-Uunix-line-discard删到行首
删除Ctrl-Kkill-line删到行尾
删除Ctrl-Wunix-word-rubout删前一个词(以空白分界)
删除Alt-Backspacebackward-kill-word删前一个词(以标点分界)
删除Ctrl-H / Backspacebackward-delete-char删一个字符
删除Ctrl-Ddelete-char删光标处字符;空行时是 EOF
粘贴Ctrl-Yyank粘回最近删除的内容
粘贴Alt-Yyank-pop在 kill ring 里轮换
转置Ctrl-Ttranspose-chars交换光标前两个字符
转置Alt-Ttranspose-words交换两个词
大小写Alt-U / Alt-L / Alt-Cupcase/downcase/capitalize-word词转大写/小写/首字母大写
历史Ctrl-P / Ctrl-Nprevious/next-history上一条 / 下一条
历史Ctrl-R / Ctrl-Sreverse/forward-search-history反向 / 正向增量搜索
历史Alt-.yank-last-arg插入上一条命令的最后一个参数
历史Ctrl-Ooperate-and-get-next执行当前行并取历史下一条
历史Alt-#insert-comment行首插入 # 并回车(注释掉)
补全Tabcomplete补全
补全Alt-?possible-completions列出候选
补全Alt-/complete-filename只按文件名补全
补全Ctrl-X /possible-filename-completions文件名候选
补全Alt-!complete-command只补命令名
补全Alt-~complete-username补用户名
补全Alt-@complete-hostname补主机名
补全Alt-$complete-variable补变量名
编辑Ctrl-X Ctrl-Eedit-and-execute-command用 $EDITOR 编辑当前命令
编辑Ctrl-V / Ctrl-Qquoted-insert插入下一个字符的字面值
编辑Ctrl-_ / Ctrl-X Ctrl-Uundo撤销
编辑Ctrl-Lclear-screen清屏并保留当前行
编辑Ctrl-]character-search搜索某个字符出现的位置
编辑Ctrl-Gabort取消当前搜索/输入
编辑Ctrl-X Ctrl-Rre-read-init-file重新读 .inputrc

这张表说明:高频动作只有十几组,其余是补全与历史。先练「移动 + 删除 + 粘贴 + 搜索」四组,就足以覆盖日常 90% 的编辑。方向键能用,但手移出主键区的那一下,就是效率差距。

几个容易忽略的细节:

  • Ctrl-W 与 Alt-Backspace 的分词规则不同。前者按空白切,后者按 readline 的 word 定义(标点也算分隔),删路径时结果常不一样。
  • Ctrl-Y 粘的是 kill ring,不是系统剪贴板。Ctrl-U、Ctrl-K、Ctrl-W 都会把删掉的内容放进去;连续 Alt-Y 可以轮换更早的内容。
  • Ctrl-S 默认被终端流控(ixon)吃掉,按下去像卡住。要启用前向搜索,先 stty -ixon,或把 ixon 关掉写进 .bashrc。
  • Ctrl-X Ctrl-E 会调用 $VISUAL/$EDITOR,适合写长命令、复杂 for 循环;保存退出后命令回到提示符,不会立即执行,还能再改。
  • Alt-# 把当前行变成注释并回车,适合「先记下来,稍后再跑」。

.inputrc 是 readline 的配置,对所有使用 readline 的程序生效(bash、psql、python 的 REPL 等):

# ~/.inputrc —— readline 行为
# 补全忽略大小写
set completion-ignore-case on
# 第一次 Tab 就列候选,而不是响铃
set show-all-if-ambiguous on
set menu-complete-display-prefix on
# 不要响铃;补全列表带颜色
set bell-style none
set colored-stats on
# 粘贴多行时不立即执行(bash 5.1+ 默认)
set enable-bracketed-paste on
set history-size 50000
# 组合键等待时间,毫秒
set keyseq-timeout 50

# 让 Ctrl-S 做前向搜索,而不是被流控吃掉
"\C-s": forward-search-history

# 宏:Ctrl-X Ctrl-S 在行首插入 sudo(不要占用 Ctrl-J,它是回车)
"\C-x\C-s": "\C-asudo \C-e"

bind -x 与宏不同:bind -x 是 bash 命令,要写在 .bashrc 里(例如 bind -x '"\C-g": git status --short'),执行 shell 命令并把输出插到提示符上方,不修改当前行;.inputrc 里只能定义按键宏。"\C-x\C-s": "\C-asudo \C-e" 会先跳到行首、插入 sudo 、再回行尾。改完 .inputrc 后按 Ctrl-X Ctrl-R 立即生效,不用重开终端。

set enable-bracketed-paste on 值得单独说:它让终端用 ESC[200~/ESC[201~ 包裹粘贴内容,readline 知道这是一次粘贴,不会把其中的换行当成「执行」。关掉它,粘贴多行命令会一行行执行,粘错一个字符就可能执行到一半。老终端或 screen 不支持括号粘贴时,readline 会退化为普通插入。

实验 0.2-B · 五分钟键位练习

目的:不背表,用五个小任务把动作连成肌肉记忆。每个任务都在新终端里做,做完 Ctrl-C 清行。

# 1) 移动与删除:把下面这行改写成只保留 /var/log/nginx
echo /etc/nginx /var/log/nginx /usr/share/nginx
#   用 Alt-B 跳到 /usr/share/nginx 前,Ctrl-K 删掉;再 Alt-B 到 /etc/nginx,Ctrl-W 删掉
# 2) kill ring:删三段,再按顺序粘回
printf 'alpha beta gamma\n'
#   Ctrl-W 三次,再 Ctrl-Y 三次(或 Alt-Y 轮换),观察粘回顺序
# 3) 历史参数:复用上一条命令的最后一个参数
ls -lh /var/log/syslog
cat Alt-.          # 插入 /var/log/syslog,不用手打
# 4) 编辑长命令:用编辑器写一个循环
Ctrl-X Ctrl-E
for f in /var/log/*.log; do printf '%s %s\n' "$(wc -l <"$f")" "$f"; done
#   保存退出,命令回到提示符;确认无误再回车
# 5) 搜索:Ctrl-R 输入 "tar",反复 Ctrl-R 跳到更早的匹配,Ctrl-G 取消

验收:第 1 题不碰方向键完成;第 2 题能说出 Ctrl-Y 粘回的顺序与 Alt-Y 的作用;第 3 题知道 Alt-. 取的是「上一条命令的最后一个参数」而不是「上一条命令」;第 4 题能解释为什么命令没有立即执行;第 5 题能在 Ctrl-G 后回到空行。

0.2.11 vim 进阶:操作符、文本对象与宏

这一节回答:为什么 vim 高手按的键更少;ciw 这类组合为什么能记住;怎样把重复编辑录成一次可重放的宏。

0.2.7 给的是「能改文件」的生存集。这一节给的是语法:vim 的命令大多不是孤立的快捷键,而是「操作符 + 动作/文本对象」的组合。记住 6 个操作符和 8 个动作,就能推出上百条命令,不用逐条背。

[数量] 操作符 [数量] 动作/文本对象
  3      d        i      w        → 删 3 个词内内容
         c        a      "        → 改一段引号内外内容
         y        i      t        → 复制标签内内容

这张图是 vim 的核心语法。d、c、y 是操作符;w、$、j、} 是动作;iw、a"、it 是文本对象。文本对象以 i(inner)或 a(around)开头,后面跟一个对象字符。i 不含分隔符,a 含。

常用文本对象:

对象i(内部)a(含分隔符)例子
词iwawciw 改当前词,光标可在词中任意位置
引号i"、i'、i`a"、a'ci" 改双引号内的字符串
括号i(、i[、i{、i<a(、a[、a{di( 删括号内内容,保留括号
标签itatcit 改 HTML/XML 标签之间的文本
段落ipapdap 删整段(含前后空行)
句子isasdas 删一句
缩进块iiai删/改同缩进级别的一块

这张表说明:文本对象让「选到哪里」不依赖光标位置。di" 在行内任何位置都能删掉引号里的内容;用 dt" 就必须先把光标放到引号前。改配置文件、改代码字符串时,这个差别很大。

操作符本身也有一张短表:

操作符作用与动作组合与文本对象组合
d删除(进寄存器)d$、dG、d}diw、da(
c删除并进入插入模式ct,、c$ciw、ci"
y复制y$、yGyap、yi}
> / <增减缩进>j、>G>ap
=自动缩进gg=G 全文重排=ap
gU / gu / g~大写/小写/翻转大小写gU$gUiw
!用外部命令过滤!}sort!ip 后接命令
gq / gw重排段落gqapgqip

这张表说明:操作符可以接动作,也可以接文本对象;> 和 = 常被忽略,但改 YAML、JSON、代码块时比手动空格快得多。gg=G 是「全文按 shiftwidth 重排」,前提是 filetype indent on 或至少设置了 shiftwidth。

再给一组「第二次看会惊讶」的操作,按使用频率排序:

#按键/命令语义练习
1.重复上一次修改(含插入内容)dw 后按 . 删下一个词
25.重复 5 次删 5 个词
3&在当前行重复上一次 :s:s/a/b/ 后按 &
4g&全文重复上一次 :s等价于 :%s//b/g
5gv重新选中上次可视区域缩进后调整
6gi回到上次插入位置并进入插入改完别处再补一句
7Ctrl-A / Ctrl-X光标下数字加一/减一改端口号、版本号
8g Ctrl-A可视模式下生成递增序列选中 10 行,生成 1..10
9ciw改当前词改变量名
10ci"改引号内字符串改配置值
11ca{改花括号内外(含括号)改函数体
12dit删标签内文本改 HTML
13dap删整段删一段说明
14d%删到匹配括号光标在 { 上
15gUiw当前词转大写改常量
16>ap整段缩进调整 YAML
17gg=G全文自动缩进粘贴后重排
18gqap整段按 textwidth 重排排注释
19!}sort从光标到段尾交给 sort排序列表
20:g/^$/d删所有空行清空行
21:g/^#/d删所有注释行清配置
22:v/keep/d删所有不含 keep 的行反向匹配
23:g/pat/norm A;对匹配行执行普通模式命令批量加行尾分号
24:'<,'>norm I# 给选中行加注释选中后 : 自动带范围
25Ctrl-V I块选择后行首插入,Esc 应用到所有行批量注释
26Ctrl-V A块选择后行尾追加批量加分号
27Ctrl-V r块内所有字符替换为同一字符画分隔线
28qa ... q录制宏到寄存器 a录制一次修改
29@a / @@重放宏 / 重放上次宏连续执行
305@a重放 5 次批量处理 5 行
31:reg a查看寄存器 a检查宏内容
32ma / 'a设标记 / 跳到标记行在大文件里往返
33Ctrl-O / Ctrl-I跳转列表后退/前进查定义后跳回
34g; / g,变更列表后退/前进回到上次改动处
35:earlier 10m回到 10 分钟前的编辑状态撤销过头
36zf / zo / zR创建折叠/打开/全开折叠长函数
37:r !date插入命令输出插入时间戳
38:%!jq .整个缓冲区交给 jq 格式化格式化 JSON
39:w !sudo tee %用 sudo 写回当前文件改 /etc 下文件
40K对光标下词执行 man查函数

这张表说明:前 10 条覆盖大部分重复劳动;20–27 是「批量编辑」的核心;28–30 是宏;33–35 是「跳转与撤销」的导航。不要一次学完,按场景取三条练到不用想。

寄存器与宏

vim 的删除/复制不是只进一个剪贴板,而是进寄存器(register)。" 后面跟寄存器名:

寄存器内容写入方式
"0最近一次复制(yank)yy、yw 自动写
"1–"9删除历史,"1 最新dd、dw 自动写
"-最近一次小删除(行内)x、dw
"a–"z命名寄存器"ayy、"ap
"_黑洞,删除不进寄存器"_dd
"+ / "*系统剪贴板"+yy、"+p(需 +clipboard)
"/最近一次搜索/pattern 自动写
":最近一次命令行: 自动写
"%当前文件名自动
".最近插入的文本自动

这张表说明:dd 会覆盖 "1,但不会覆盖 "0;所以「复制一段,删几行,再粘贴」仍然粘的是复制的那段。要保留多次复制,用命名寄存器 "a、"b。"_dd 是「删了但不污染寄存器」的干净删除。

宏是「把一段按键录进寄存器」。流程固定:

qa
A;
j
q
5@a
:reg a

六条命令依次是:开始录制、行尾加分号、下一行、停止录制、重放 5 次、查看寄存器内容。不要在命令后面接 " 注释:在普通模式里 " 是「重放寄存器」,会被一起录进宏。

宏的三个边界:

  1. 录制会包含所有按键,包括错误的退格与 Esc。录错就 q 重录,或 :let @a='...' 直接编辑寄存器内容。
  2. 宏里的 @a 会递归。如果录制时按了 @a,重放会无限递归;vim 会在报错后中止,但可能已经改坏文件。用 :normal @a 逐行执行更可控。
  3. 宏按「物理行」移动。文件里有折行、空行、不同缩进时,j 的落点可能不是你想的那一行;复杂场景改用 :g/pat/norm ...,让 vim 自己遍历匹配行。

:g 是「按模式批量执行」,比宏更适合规则明确的场景:

:g/^$/d                  " 删所有空行
:g/^#/d                  " 删所有以 # 开头的行
:v/keep/d                " 删所有不含 keep 的行
:g/error/norm A;         # 对含 error 的行行尾加分号
:g/^server/.,+3s/old/new/g   " 对每个 server 块的后 3 行替换

最后一条展示了 :g 的「范围」能力:.,+3 是「当前行到后 3 行」。批量改配置块时,比写宏稳。

三个练习

练习 1:改一份 fstab 副本。 目标:把 /dev/sdb1 换成 UUID=...,给 /data 加 nofail,删除一行注释。

cp /etc/fstab /tmp/fstab.lab
vim /tmp/fstab.lab

要求:用 dd 删注释行;用 /dev/sdb1 搜索;用 ciw 改设备名;用 A 在行尾加 ,nofail;用 :w 保存,不要 :wq,留出检查时间;最后 :q。验收:findmnt --verify --tab-file /tmp/fstab.lab 不报错(没有对应设备的行会被标出,属正常)。

练习 2:宏批量加后缀。 生成 20 行 item,给每行加 ;:

seq 1 20 | sed 's/^/item/' > /tmp/items.txt
vim /tmp/items.txt

要求:qa 开始录制,A;、Esc、j,q 结束;19@a 处理剩余行;:wq。验收:grep -c ';$' /tmp/items.txt 输出 20。再用 u 撤销,改用 :%norm A; 一次完成,比较两者。

练习 3:块注释与取消。 选中 5 行,Ctrl-V 进入块选择,I# 插入,Esc 应用到所有行;取消时 Ctrl-V 选中同样的 2 个字符,x 删除。

printf 'a=1\nb=2\nc=3\nd=4\ne=5\n' > /tmp/conf.txt
vim /tmp/conf.txt

验收:注释后每行以 # 开头;取消后恢复原样;用 :set list 能看到行尾没有多余空格。

vim 的「安全模式」与恢复

vim -u NONE -N 不读任何配置,用 vim 的默认行为启动,适合排查「是不是插件/配置导致的问题」;vim --clean 更彻底,连插件与 viminfo 都不读。编辑中断(SSH 断线、终端崩溃)后,目录里会留下 .file.swp;再次打开会提示 Swap file ... already exists。此时不要直接删 .swp:用 vim -r file 恢复,确认内容后再删。vimdiff a b 或 vim -d a b 是内置的差异视图,]c/[c 跳下一个/上一个差异,do/dp 取/推当前差异。

0.2.12 tmux 脚本化:把布局写成可复现的命令

这一节回答:怎样让「打开三个 pane、各自跑固定命令」变成一条命令;怎样把 pane 输出留成日志。

0.2.8 的 tmux 表是交互操作。真正的效率来自脚本化:会话可以创建、切分、发送命令、抓取输出、录制日志,全部非交互完成。CI、远程排障、教学演示都用得上。

先记四个「无头」命令:

# 创建分离会话(不 attach),指定工作目录
tmux new-session -d -s work -c "$PWD"
# 在会话里开新窗口并运行命令
tmux new-window -t work:1 -n logs 'journalctl -fu api'
# 水平/垂直分屏
tmux split-window -h -t work:1
# 向指定 pane 发送按键(C-m 是回车)
tmux send-keys -t work:1.0 'curl -sS localhost:8080/health' C-m

-d 是「不要抢占当前终端」;没有它,new-session 会直接 attach,脚本就卡住了。-t 的目标格式是 会话:窗口.pane,如 work:1.0。send-keys 默认发送字面字符串,C-m、C-c、Enter 这类键名要单独作为参数。

脚本化布局的价值在于可复现。下面这个脚本每次都在同一目录建同样的三 pane:

#!/usr/bin/env bash
# dev-layout.sh —— 建一个 1 窗口 3 pane 的开发会话
set -euo pipefail

session=${1:-dev}
dir=${2:-$PWD}

if tmux has-session -t "$session" 2>/dev/null; then
    echo "session $session exists; attaching"
    exec tmux attach -t "$session"
fi

tmux new-session -d -s "$session" -c "$dir" -n edit
tmux split-window -h -t "$session:edit" -c "$dir"
tmux split-window -v -t "$session:edit.1" -c "$dir"
tmux select-layout -t "$session:edit" main-vertical

# pane 0:编辑;pane 1:测试;pane 2:日志
tmux send-keys -t "$session:edit.0" 'vim .' C-m
tmux send-keys -t "$session:edit.1" 'while :; do clear; date; sleep 2; done' C-m
tmux send-keys -t "$session:edit.2" 'tail -f /var/log/syslog' C-m

tmux select-pane -t "$session:edit.0"
exec tmux attach -t "$session"

关键点是 has-session 的幂等判断:会话已存在就 attach,不重复创建;select-layout main-vertical 让主 pane 占左侧大部分宽度;send-keys 里的命令要加 C-m 才会执行。把 dir 作为参数传入,脚本就能在不同项目里复用。

抓取输出与录制日志是排障时的两个高频需求:

# 抓取 pane 最近 200 行(-p 输出到 stdout,-S 指定起始行)
tmux capture-pane -p -t work:1.0 -S -200 > /tmp/pane.txt

# 把 pane 的后续输出追加到文件(-o 表示只开一次,避免重复 pipe)
tmux pipe-pane -o -t work:1.0 'cat >> /tmp/pane.log'

# 关闭 pipe
tmux pipe-pane -t work:1.0

capture-pane 抓的是当前屏幕缓冲区,不是完整历史;-S -200 从往前 200 行开始,-E 指定结束行。pipe-pane 是持续管道,适合「让同事复现时把日志留下」;它不会自动轮转,长跑要配合 logrotate 或写到带时间戳的文件。

列出 pane 的元数据,能把「哪个 pane 在跑什么」变成可解析的输出:

tmux list-panes -a -F '#{session_name}:#{window_index}.#{pane_index} pid=#{pane_pid} cmd=#{pane_current_command} path=#{pane_current_path}'
tmux display-message -p '#{socket_path}'     # 当前 tmux server 的 socket
tmux -L lab new -d -s isolated               # 用独立 socket,不干扰默认会话

-L 指定 socket 名,适合测试或隔离环境;-f /dev/null 跳过配置文件,适合排查「是不是配置写坏了」。

tmux 的四个边界
  1. 「tmux 会话能扛重启」——不能。它扛的是 SSH 断线与终端关闭;机器重启后会话全丢。要扛重启,用 systemd 服务或持久化任务队列。
  2. 「send-keys 等于执行命令」——它只是把按键送进 pane,命令是否执行取决于有没有回车、Shell 是否在等待输入。脚本里统一加 C-m。
  3. 「capture-pane 能抓到全部历史」——默认只有当前屏幕;历史深度由 history-limit 决定,超出部分抓不到。
  4. 「退出登录后 tmux 一定还在」——systemd-logind 的 KillUserProcesses=yes 会在最后一个会话结束时杀掉用户进程;需要 loginctl enable-linger <user> 或改配置。

0.2.13 文档工作流:man、info、help 的检索路径

这一节回答:面对一个不认识的命令或系统调用,按什么顺序查最快;为什么 man 有时「查不到」;/usr/share/doc 里有什么是 man 没有的。

0.1.9 讲的是 man 的分区与「哪一层归谁写」。这一节讲操作:从「我知道一个词」到「拿到一条可执行结论」的最短路径。

先记一条检索顺序,别一上来就搜网页:

  1. Shell 内建 → help <name>、help -d <name>。cd、read、printf、trap 没有独立 man page。
  2. 命令的 --help → cmd --help,看选项与默认值。它比 man 短,适合确认 flag 拼写。
  3. man page → man <name>,系统调用加区号 man 2,配置文件加 man 5。
  4. info 文档 → GNU 项目(coreutils、grep、sed、gawk)的 info 通常比 man 完整。
  5. 包文档 → /usr/share/doc/<pkg>/,含 README、changelog、发行版补丁说明。
  6. 源码/上游 → 最后才看,且要固定版本。

man 的四个实用开关:

命令作用场景
man -w name只打印文件路径,不显示内容确认查到的是哪个页
man -a name依次显示所有同名页man -a passwd 看命令与文件格式
man -K pattern在所有 man page 正文里搜只记得一句话,不记得命令名
man -P 'less +/pattern' name用指定 pager 打开并跳转直接定位到关键词

man -w 是最被低估的:它告诉你「系统里到底有没有这一页、在哪个区」。man -w passwd 通常返回 /usr/share/man/man1/passwd.1.gz(改密码的命令),而 /etc/passwd 的格式在 man 5 passwd。要看系统调用就显式写 man 2 open,不要依赖默认的区号顺序。

MANPAGER 决定用什么分页器。默认 less,可以加 -R 支持颜色:

export MANPAGER='less -R'
export MANWIDTH=100          # 控制换行宽度;不设则按终端宽度
man 2 write | col -b | sed -n '1,60p'   # col -b 去掉退格加粗,便于粘贴

col -b 去掉 man 的 overstrike 格式(b^Hb 表示加粗),把输出变成纯文本;需要把 man 内容贴进工单或代码注释时用它。man -t 生成 PostScript,可转 PDF:

man -t 7 signal | ps2pdf - /tmp/signal.pdf

info 是另一套导航模型:节点(node)而不是页。常用键:

键作用
n / p下一个 / 上一个节点
Up上一层
l返回上一个节点
g按名字跳节点
s在文档内搜索
q退出

命令行直接定位节点:

info coreutils                          # 顶层菜单
info coreutils 'ls invocation'          # 直接到 ls 的调用说明
info --output=- coreutils 'ls invocation' | head -40   # 输出到 stdout

GNU 的 info 文件在 /usr/share/info/*.info.gz,info 会读 dir 索引。新装的包如果 info 找不到,跑 install-info --info-dir=/usr/share/info <file> 或重装包。

--help 的边界:它由程序自己实现,格式、退出码、是否支持 -h 都不统一。grep --help 退出 0,ls --help 退出 0,但有些程序把 --help 当错误参数返回 2。脚本里不要解析 --help 的输出,用 man 或版本化接口。

发行版文档在 /usr/share/doc:

dpkg -L coreutils | grep '^/usr/share/doc'
zless /usr/share/doc/coreutils/changelog.Debian.gz
ls /usr/share/doc/nginx/           # README.Debian、copyright、examples

README.Debian 解释「Debian/Ubuntu 改了什么」,changelog.Debian.gz 记录补丁与 CVE 修复,copyright 是许可证。这三样在排查「为什么行为与上游文档不一致」时最有用。

实验 0.2-C · 一条命令查清一个陌生命令

目的:用固定顺序在 30 秒内回答「它是什么、怎么用、默认值是什么、属于哪个包」。

#!/usr/bin/env bash
# doc.sh NAME —— 按 help → man → info → --help 的顺序找文档
set -euo pipefail
name=${1:?usage: doc.sh NAME}
if help -d "$name" >/dev/null 2>&1; then
    echo "== builtin: $name"; help -d "$name"; exit 0
fi
if path=$(man -w "$name" 2>/dev/null); then
    echo "== man: $path"
    MANPAGER=cat man "$name" 2>/dev/null | col -b | sed -n '1,40p'
    exit 0
fi
if info --output=- "$name" >/dev/null 2>&1; then
    echo "== info: $name"; info --output=- "$name" | sed -n '1,40p'; exit 0
fi
if command -v "$name" >/dev/null; then
    echo "== --help: $name"; "$name" --help 2>&1 | sed -n '1,40p'; exit 0
fi
echo "no documentation found for $name" >&2
exit 1

三种跑法:

./doc.sh cd            # 命中 builtin
./doc.sh passwd        # 命中 man 1 passwd;/etc/passwd 的格式要 man 5 passwd
./doc.sh ls            # 命中 man,输出前 40 行
./doc.sh nosuchcmd     # 退出 1

预期:cd 走 builtin 分支,不出现 man;open 打印 man 路径与前 40 行;MANPAGER=cat 让 man 不走交互分页,脚本才能继续。man -w 的命中顺序受 MANSECT 影响,要固定区号就显式写 man 2,不要依赖默认。

0.2.14 八个终端与编辑陷阱

这一节把终端、readline、vim、tmux 与文档检索中最容易踩的八个坑集中起来,每条给现象、原因与修复。

终端与编辑的八个陷阱
  1. 「终端按 Ctrl-S 后卡死」——ixon 流控把 Ctrl-S 当成暂停输出;按 Ctrl-Q 恢复,或 stty -ixon 永久关掉。
  2. 「脚本崩了,终端不回显」——stty -echo 没恢复;盲打 stty sane 回车,或 reset。写脚本时先 trap 'stty "$saved"' EXIT。
  3. 「粘贴多行命令,执行到一半就跑了」——没有括号粘贴,readline 把换行当执行;升级 bash 5.1+ 并确认 enable-bracketed-paste on,或在 vim 里 :set paste。
  4. 「vim 提示 swap file 已存在」——另一个会话在编辑,或上次崩溃;先 vim -r file 恢复,确认后再删 .swp,不要直接 rm。
  5. 「:w !sudo tee % 保存后,再 :w 又失败」——vim 仍认为文件只读;保存后执行 :e! 重新加载,再继续编辑。
  6. 「tmux 里 vim 的 Esc 有延迟」——escape-time 在等组合键;设 set -g escape-time 10。
  7. 「ssh 进 tmux 后颜色/按键错乱」——TERM 不匹配;在 .tmux.conf 设 set -g default-terminal "screen-256color",并确认远端 terminfo 有该条目。
  8. 「man xxx 说 No manual entry」——包没装 man page(如 man-db 缺失)、mandb 索引过期,或该命令是内建;分别用 apt install man-db、sudo mandb、help xxx 处理。

0.2.15 本章出口检查

检查项达标线没达标去哪
画出键盘 → pty → Shell → 子进程能指出行规程在哪一层0.2.1 的布局图
说出命令查找的五步顺序能解释 alias 为什么不进脚本0.2.3
判断登录/交互类型能解释 ssh host cmd 读哪些文件0.2.4 的四象限表
用 $PS1 与历史提效至少会用 !!、!$、Ctrl-R0.2.5
管理前台/后台/挂起会用 jobs、fg、bg、disown0.2.6
在服务器上安全编辑文件会 :wq、:q!、查找替换、撤销0.2.7
断线后恢复现场会 tmux new/attach/detach0.2.8
解释 stty -a 的关键标志能说出 icanon/isig/echo 的作用0.2.9
用 readline 键位完成编辑不碰方向键完成移动、删除、粘贴、搜索0.2.10
用文本对象与宏批量编辑能写出 ciw、:%norm、qa 宏0.2.11
脚本化一个 tmux 布局能创建会话、发送命令、抓取输出0.2.12
按顺序查文档能区分 help/man/info/--help0.2.13
即时自测 · 0.2

Q1 你在 ~/.bashrc 里写了 alias ll='ls -alF',然后执行 ssh host 'll',报 ll: command not found。解释原因,并给出两种修复方式。

Q2 sleep 300 & 之后关闭终端窗口,进程还会在吗?如果换成 nohup sleep 300 & 呢?再换成 tmux 里运行呢?

Q3 type -a ls 输出三行,实际执行的是哪一个?command -v ls 与 which ls 的区别是什么?

Q4 为什么 Ctrl-C 能终止前台进程,却常常不能终止 vim 里的插入模式?这与 pty 的哪一层有关?

Q5 stty -a 显示 -icanon,这对 read 的行为意味着什么?哪些程序需要这种模式?

Q6 man -w printf 返回的是 /usr/share/man/man1/printf.1.gz,但你想看 C 库的 printf(3)。怎么办?

答案

A1 ssh host 'll' 启动的是非交互非登录 Shell,不读 ~/.bashrc,别名也不展开。修复:① 在远端命令里显式 source ~/.bashrc 或用完整命令 ls -alF;② 把 ll 定义成函数并放进被 BASH_ENV 指向的文件,或直接使用绝对路径的包装脚本。生产脚本不应依赖交互配置。

A2 关闭终端时,登录 Shell 会向作业发送 SIGHUP,sleep 默认被终止。nohup 让进程忽略 SIGHUP 并把 stdout 重定向到 nohup.out,进程继续;tmux 里的 Shell 不属于当前终端会话,终端关闭不影响它,重新 attach 即可看到。disown -h 也能让作业忽略 HUP,但不如 tmux/systemd 可维护。

A3 实际执行的是别名展开后的 ls --color=auto;如果没有别名,则是 $PATH` 中第一个 `/usr/bin/ls`(Ubuntu 上 `/bin` 是 `/usr/bin` 的符号链接)。`command -v` 是 Shell 内建,认别名、函数、内建,输出适合脚本判断;`which` 是外部程序,通常只查 `$PATH,不认别名与函数,不同实现行为还不一致。

A4 Ctrl-C 由行规程识别为 SIGINT,发给前台进程组。vim 在插入模式下把终端切到 raw 模式,关闭了 ISIG 等行规程处理,Ctrl-C 只作为普通字节交给 vim,由 vim 自己决定是否处理。退出插入模式要先按 Esc,再 :q! 或 ZZ。

A5 -icanon 关闭行缓冲,read 不再等整行,而是按 min/time 的语义返回当前可用字节;Shell 的退格、行编辑、Ctrl-C 信号键也随之失效或改由程序处理。vim、less、top 这类全屏程序需要它来逐键响应。

A6 显式指定区号:man 3 printf。man -w 只报告默认搜索顺序里的第一个命中,MANSECT 决定顺序;man -a printf 可以依次看所有同名页,但脚本里更可靠的是直接写区号。

母本对应

母本章节PDF 页本书落点程度
G 鸟哥第 4 章 首次登录与在线求助、第 9 章 vimp.206–258、p.455–4870.2.1、0.2.4、0.2.7、0.2.11完整(tty、启动、vim)
S Shotts第 1–2 章 Shell 与导航、第 8 章 高级键盘技巧、第 12 章 vip.22–30、p.85–91、p.132–1470.2.2–0.2.7、0.2.10–0.2.11完整(查找顺序、历史、vim)
B shell-book第 2 章 走进 shell、第 5 章 理解 shell、第 10 章 文本编辑器p.35–53、p.107–125、p.204–2120.2.1、0.2.4、0.2.7精选(父子 Shell、启动文件)
K Kerrisk第 2 章 基本概念(shell、pty、会话)—0.2.1、0.2.6背景(术语与模型)
C CSAPP第 1 章 计算机系统漫游(Shell 与进程)—0.2.2背景(程序到进程)
man-pagestermios(3)、tty_ioctl(4)、readline(3)man0.2.9–0.2.13完整(终端与文档工作流)

下一站:0.3 文件系统导航与文件操作进入目录树与 inode;本章的 cd、ls、find 会在那里得到解释。