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 与终端模拟器
终端(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. 读取 | 行规程把整行交给 readline | Ctrl-D 退出、Ctrl-C 取消当前行 |
| 2. 解析 | 按 ;、` | 、&&` 切成命令,识别重定向与引号 |
| 3. 展开 | 别名、大括号、波浪号、变量、命令替换、算术、通配符 | 通配符没匹配时字面保留(默认) |
| 4. 查找 | 判断内建/函数/外部命令,在 $PATH 中找可执行文件 | command not found |
| 5. 执行 | 管道两端各 fork 一个子进程,dup2 接好 fd,execve | Permission 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 先创建两个子进程,各自把 stdin 或 stdout 接到管道的一端,再分别 execve。父 Shell 自己不参与数据搬运,只负责等待。这样 grep 与 head 的并发、退出码、信号都独立,Shell 不必理解文本内容。
0.2.3 命令查找顺序:alias、函数、内建、PATH
输入一个名字后,bash 按固定顺序解析:
- 别名(alias):仅交互式 Shell 默认展开;脚本里除非
shopt -s expand_aliases,否则不生效。 - 函数:当前 Shell 定义的函数,优先于外部命令。
- 内建命令(builtin):
cd、echo、printf、type、read、export等。 - 哈希表:bash 缓存上次找到的路径;
hash -r清空。 $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) |
四个常见困惑都能用这张表解释:
ssh host 'echo $PATH'的 PATH 和登录后不一样。非交互非登录 Shell 不读/etc/profile,只继承 sshd 给的环境。要固定环境,在~/.ssh/environment(需PermitUserEnvironment yes)或命令里显式source。~/.bashrc写了export,cron 任务里却没有。cron 不启动交互 Shell,也不读.bashrc。在脚本里显式设置PATH,或source一个环境文件。su user后别名没了。su user不带-是非登录 Shell,不读/etc/profile;su - user才是登录 Shell。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 | 当前目录的 basename | app |
\t | 24 小时时间 | 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」——终端模拟器负责渲染与 pty,Shell 只是 pty slave 上的一个用户进程。终端卡死时,Shell 可能还活着。
- 「alias 写进
.bashrc,脚本里也能用」——非交互 Shell 默认不展开别名。脚本里用函数或显式命令,别依赖 alias。 - 「
ssh host cmd和登录后执行一样」——前者是非交互非登录 Shell,不读.bashrc/.profile,PATH与别名都可能不同。 - 「
Ctrl-Z等于结束进程」——它只是挂起(SIGTSTP),进程还在。要结束用kill,要后台继续用bg。 - 「关掉终端,后台任务一定继续跑」——默认会收到
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 类似) |
三个安全习惯:
- 先看再改。打开文件先
:set number看行号,确认自己在改哪一段。 - 不确定就
u。vim 的撤销树很强,连续u能退回很多步;Ctrl-R重做。 - 权限不够不要硬写。
: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 方向键 |
| 放大当前 pane | Ctrl-B z |
| 关闭 pane | Ctrl-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
逐段读:
| 行 | 字段 | 含义 | 改它会怎样 |
|---|---|---|---|
| 1 | speed / rows / columns | 波特率与窗口大小 | stty rows 50 cols 160 可临时改 |
| 2 | intr / quit / susp | 生成 SIGINT/SIGQUIT/SIGTSTP 的键 | 改成 ^A 会让 Ctrl-C 失效 |
| 2 | erase / kill / werase | 退格、删整行、删词 | erase = ^H 适配老终端 |
| 2 | eof | 行首按它产生 EOF | Ctrl-D 不是「关闭」,是「读返回 0」 |
| 2 | min / time | 非规范模式下的读语义 | min 1 time 0 是逐字节阻塞读 |
| 3 | -icanon | 关闭行缓冲 | 每个字符都交给 read,Shell 行编辑失效 |
| 3 | echo / -echo | 是否回显 | 读密码时关掉 |
| 3 | isig | 是否把 intr 等键变成信号 | 关掉后 Ctrl-C 只是字节 0x03 |
| 3 | ixon | Ctrl-S/Ctrl-Q 流控 | 关掉后 Ctrl-S 可用于前向搜索 |
| 4 | opost / onlcr | 输出时把 \n 转成 \r\n | 关掉后输出会「阶梯状」 |
| 4 | tostop | 后台进程写终端是否发 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 是反向工具:把某个条目的能力逐项列出,排查「为什么这个键在我的终端里不工作」时很有用。
目的:用同一段判断在三种 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) 判断。
- 「
Ctrl-D是关闭终端」——它是行规程的eof字符;在行首让read返回 0,Shell 据此退出。终端没关,pty 还在。 - 「
Ctrl-C一定发SIGINT」——-isig或 raw 模式下它只是字节0x03,由程序自己解释;vim插入模式就是这种状态。 - 「
stty -echo只影响当前命令」——它改的是终端设备状态;进程异常退出没恢复,整个终端都不回显,要stty sane或reset。 - 「
TERM只是给ls上色用的」——less、vim、tmux、top都按TERM选转义序列;设错会让全屏程序花屏。 - 「管道里的程序行为和终端里一样」——
isatty为假时,缓冲模式、颜色、行编辑、信号键全部可能不同。
0.2.10 readline 键位表与练习
这一节回答:除了方向键,命令行还有哪些「不离开主键区」的编辑动作;怎样把这些动作变成肌肉记忆。
readline 是 bash 的行编辑器。它不关心命令内容,只负责「光标、删除、历史、补全」。下面这张表按动作分组,键位是 bash 5.2 的默认值,可用 bind -P 核对本机:
| 类别 | 按键 | 函数 | 作用 |
|---|---|---|---|
| 移动 | Ctrl-A / Ctrl-E | beginning/end-of-line | 行首 / 行尾 |
| 移动 | Alt-B / Alt-F | backward/forward-word | 按词后退 / 前进 |
| 移动 | Ctrl-B / Ctrl-F | backward/forward-char | 按字符移动 |
| 移动 | Ctrl-X Ctrl-X | exchange-point-and-mark | 在光标与行首间跳(连按两次) |
| 删除 | Ctrl-U | unix-line-discard | 删到行首 |
| 删除 | Ctrl-K | kill-line | 删到行尾 |
| 删除 | Ctrl-W | unix-word-rubout | 删前一个词(以空白分界) |
| 删除 | Alt-Backspace | backward-kill-word | 删前一个词(以标点分界) |
| 删除 | Ctrl-H / Backspace | backward-delete-char | 删一个字符 |
| 删除 | Ctrl-D | delete-char | 删光标处字符;空行时是 EOF |
| 粘贴 | Ctrl-Y | yank | 粘回最近删除的内容 |
| 粘贴 | Alt-Y | yank-pop | 在 kill ring 里轮换 |
| 转置 | Ctrl-T | transpose-chars | 交换光标前两个字符 |
| 转置 | Alt-T | transpose-words | 交换两个词 |
| 大小写 | Alt-U / Alt-L / Alt-C | upcase/downcase/capitalize-word | 词转大写/小写/首字母大写 |
| 历史 | Ctrl-P / Ctrl-N | previous/next-history | 上一条 / 下一条 |
| 历史 | Ctrl-R / Ctrl-S | reverse/forward-search-history | 反向 / 正向增量搜索 |
| 历史 | Alt-. | yank-last-arg | 插入上一条命令的最后一个参数 |
| 历史 | Ctrl-O | operate-and-get-next | 执行当前行并取历史下一条 |
| 历史 | Alt-# | insert-comment | 行首插入 # 并回车(注释掉) |
| 补全 | Tab | complete | 补全 |
| 补全 | 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-E | edit-and-execute-command | 用 $EDITOR 编辑当前命令 |
| 编辑 | Ctrl-V / Ctrl-Q | quoted-insert | 插入下一个字符的字面值 |
| 编辑 | Ctrl-_ / Ctrl-X Ctrl-U | undo | 撤销 |
| 编辑 | Ctrl-L | clear-screen | 清屏并保留当前行 |
| 编辑 | Ctrl-] | character-search | 搜索某个字符出现的位置 |
| 编辑 | Ctrl-G | abort | 取消当前搜索/输入 |
| 编辑 | Ctrl-X Ctrl-R | re-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 会退化为普通插入。
目的:不背表,用五个小任务把动作连成肌肉记忆。每个任务都在新终端里做,做完 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(含分隔符) | 例子 |
|---|---|---|---|
| 词 | iw | aw | ciw 改当前词,光标可在词中任意位置 |
| 引号 | i"、i'、i` | a"、a' | ci" 改双引号内的字符串 |
| 括号 | i(、i[、i{、i< | a(、a[、a{ | di( 删括号内内容,保留括号 |
| 标签 | it | at | cit 改 HTML/XML 标签之间的文本 |
| 段落 | ip | ap | dap 删整段(含前后空行) |
| 句子 | is | as | das 删一句 |
| 缩进块 | ii | ai | 删/改同缩进级别的一块 |
这张表说明:文本对象让「选到哪里」不依赖光标位置。di" 在行内任何位置都能删掉引号里的内容;用 dt" 就必须先把光标放到引号前。改配置文件、改代码字符串时,这个差别很大。
操作符本身也有一张短表:
| 操作符 | 作用 | 与动作组合 | 与文本对象组合 |
|---|---|---|---|
d | 删除(进寄存器) | d$、dG、d} | diw、da( |
c | 删除并进入插入模式 | ct,、c$ | ciw、ci" |
y | 复制 | y$、yG | yap、yi} |
> / < | 增减缩进 | >j、>G | >ap |
= | 自动缩进 | gg=G 全文重排 | =ap |
gU / gu / g~ | 大写/小写/翻转大小写 | gU$ | gUiw |
! | 用外部命令过滤 | !}sort | !ip 后接命令 |
gq / gw | 重排段落 | gqap | gqip |
这张表说明:操作符可以接动作,也可以接文本对象;> 和 = 常被忽略,但改 YAML、JSON、代码块时比手动空格快得多。gg=G 是「全文按 shiftwidth 重排」,前提是 filetype indent on 或至少设置了 shiftwidth。
再给一组「第二次看会惊讶」的操作,按使用频率排序:
| # | 按键/命令 | 语义 | 练习 |
|---|---|---|---|
| 1 | . | 重复上一次修改(含插入内容) | dw 后按 . 删下一个词 |
| 2 | 5. | 重复 5 次 | 删 5 个词 |
| 3 | & | 在当前行重复上一次 :s | :s/a/b/ 后按 & |
| 4 | g& | 全文重复上一次 :s | 等价于 :%s//b/g |
| 5 | gv | 重新选中上次可视区域 | 缩进后调整 |
| 6 | gi | 回到上次插入位置并进入插入 | 改完别处再补一句 |
| 7 | Ctrl-A / Ctrl-X | 光标下数字加一/减一 | 改端口号、版本号 |
| 8 | g Ctrl-A | 可视模式下生成递增序列 | 选中 10 行,生成 1..10 |
| 9 | ciw | 改当前词 | 改变量名 |
| 10 | ci" | 改引号内字符串 | 改配置值 |
| 11 | ca{ | 改花括号内外(含括号) | 改函数体 |
| 12 | dit | 删标签内文本 | 改 HTML |
| 13 | dap | 删整段 | 删一段说明 |
| 14 | d% | 删到匹配括号 | 光标在 { 上 |
| 15 | gUiw | 当前词转大写 | 改常量 |
| 16 | >ap | 整段缩进 | 调整 YAML |
| 17 | gg=G | 全文自动缩进 | 粘贴后重排 |
| 18 | gqap | 整段按 textwidth 重排 | 排注释 |
| 19 | !}sort | 从光标到段尾交给 sort | 排序列表 |
| 20 | :g/^$/d | 删所有空行 | 清空行 |
| 21 | :g/^#/d | 删所有注释行 | 清配置 |
| 22 | :v/keep/d | 删所有不含 keep 的行 | 反向匹配 |
| 23 | :g/pat/norm A; | 对匹配行执行普通模式命令 | 批量加行尾分号 |
| 24 | :'<,'>norm I# | 给选中行加注释 | 选中后 : 自动带范围 |
| 25 | Ctrl-V I | 块选择后行首插入,Esc 应用到所有行 | 批量注释 |
| 26 | Ctrl-V A | 块选择后行尾追加 | 批量加分号 |
| 27 | Ctrl-V r | 块内所有字符替换为同一字符 | 画分隔线 |
| 28 | qa ... q | 录制宏到寄存器 a | 录制一次修改 |
| 29 | @a / @@ | 重放宏 / 重放上次宏 | 连续执行 |
| 30 | 5@a | 重放 5 次 | 批量处理 5 行 |
| 31 | :reg a | 查看寄存器 a | 检查宏内容 |
| 32 | ma / 'a | 设标记 / 跳到标记行 | 在大文件里往返 |
| 33 | Ctrl-O / Ctrl-I | 跳转列表后退/前进 | 查定义后跳回 |
| 34 | g; / g, | 变更列表后退/前进 | 回到上次改动处 |
| 35 | :earlier 10m | 回到 10 分钟前的编辑状态 | 撤销过头 |
| 36 | zf / zo / zR | 创建折叠/打开/全开 | 折叠长函数 |
| 37 | :r !date | 插入命令输出 | 插入时间戳 |
| 38 | :%!jq . | 整个缓冲区交给 jq 格式化 | 格式化 JSON |
| 39 | :w !sudo tee % | 用 sudo 写回当前文件 | 改 /etc 下文件 |
| 40 | K | 对光标下词执行 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 次、查看寄存器内容。不要在命令后面接 " 注释:在普通模式里 " 是「重放寄存器」,会被一起录进宏。
宏的三个边界:
- 录制会包含所有按键,包括错误的退格与
Esc。录错就q重录,或:let @a='...'直接编辑寄存器内容。 - 宏里的
@a会递归。如果录制时按了@a,重放会无限递归;vim 会在报错后中止,但可能已经改坏文件。用:normal @a逐行执行更可控。 - 宏按「物理行」移动。文件里有折行、空行、不同缩进时,
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 -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 会话能扛重启」——不能。它扛的是 SSH 断线与终端关闭;机器重启后会话全丢。要扛重启,用 systemd 服务或持久化任务队列。
- 「
send-keys等于执行命令」——它只是把按键送进 pane,命令是否执行取决于有没有回车、Shell 是否在等待输入。脚本里统一加C-m。 - 「
capture-pane能抓到全部历史」——默认只有当前屏幕;历史深度由history-limit决定,超出部分抓不到。 - 「退出登录后 tmux 一定还在」——systemd-logind 的
KillUserProcesses=yes会在最后一个会话结束时杀掉用户进程;需要loginctl enable-linger <user>或改配置。
0.2.13 文档工作流:man、info、help 的检索路径
这一节回答:面对一个不认识的命令或系统调用,按什么顺序查最快;为什么 man 有时「查不到」;/usr/share/doc 里有什么是 man 没有的。
0.1.9 讲的是 man 的分区与「哪一层归谁写」。这一节讲操作:从「我知道一个词」到「拿到一条可执行结论」的最短路径。
先记一条检索顺序,别一上来就搜网页:
- Shell 内建 →
help <name>、help -d <name>。cd、read、printf、trap没有独立 man page。 - 命令的
--help→cmd --help,看选项与默认值。它比 man 短,适合确认 flag 拼写。 - man page →
man <name>,系统调用加区号man 2,配置文件加man 5。 - info 文档 → GNU 项目(coreutils、grep、sed、gawk)的 info 通常比 man 完整。
- 包文档 →
/usr/share/doc/<pkg>/,含 README、changelog、发行版补丁说明。 - 源码/上游 → 最后才看,且要固定版本。
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 是许可证。这三样在排查「为什么行为与上游文档不一致」时最有用。
目的:用固定顺序在 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 与文档检索中最容易踩的八个坑集中起来,每条给现象、原因与修复。
- 「终端按
Ctrl-S后卡死」——ixon流控把Ctrl-S当成暂停输出;按Ctrl-Q恢复,或stty -ixon永久关掉。 - 「脚本崩了,终端不回显」——
stty -echo没恢复;盲打stty sane回车,或reset。写脚本时先trap 'stty "$saved"' EXIT。 - 「粘贴多行命令,执行到一半就跑了」——没有括号粘贴,readline 把换行当执行;升级 bash 5.1+ 并确认
enable-bracketed-paste on,或在 vim 里:set paste。 - 「vim 提示 swap file 已存在」——另一个会话在编辑,或上次崩溃;先
vim -r file恢复,确认后再删.swp,不要直接rm。 - 「
:w !sudo tee %保存后,再:w又失败」——vim 仍认为文件只读;保存后执行:e!重新加载,再继续编辑。 - 「tmux 里 vim 的
Esc有延迟」——escape-time在等组合键;设set -g escape-time 10。 - 「ssh 进 tmux 后颜色/按键错乱」——
TERM不匹配;在.tmux.conf设set -g default-terminal "screen-256color",并确认远端 terminfo 有该条目。 - 「
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-R | 0.2.5 |
| 管理前台/后台/挂起 | 会用 jobs、fg、bg、disown | 0.2.6 |
| 在服务器上安全编辑文件 | 会 :wq、:q!、查找替换、撤销 | 0.2.7 |
| 断线后恢复现场 | 会 tmux new/attach/detach | 0.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/--help | 0.2.13 |
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 章 vim | p.206–258、p.455–487 | 0.2.1、0.2.4、0.2.7、0.2.11 | 完整(tty、启动、vim) |
| S Shotts | 第 1–2 章 Shell 与导航、第 8 章 高级键盘技巧、第 12 章 vi | p.22–30、p.85–91、p.132–147 | 0.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–212 | 0.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-pages | termios(3)、tty_ioctl(4)、readline(3) | man | 0.2.9–0.2.13 | 完整(终端与文档工作流) |
下一站:0.3 文件系统导航与文件操作进入目录树与 inode;本章的
cd、ls、find会在那里得到解释。