资料库 · 终端与 Shell 机制
第 ④ 卷,条目 61–80。全部来自 jvns.ca(Julia Evans)。
这一卷讲的是你每天用几百次但几乎没人系统学过的那一层:按下一个键之后发生了什么、管道为什么会卡住、PATH 到底怎么解析、脚本里那些诡异行为的成因。
排法是按数据流走的:
- 61–66 输入与显示 —— 键盘到进程、进程到屏幕,中间那些协议
- 67–71 进程与数据流 —— 管道、作业控制、PATH、Shell 到底干什么
- 72–75 终端体验 —— 配置、选型、心态
- 76–80 可执行文件与加载 ——
./a.out之后系统做了什么,以及怎么观察
第四段有点偏底层,但它解决的是一类极其常见的困惑:"明明装了这个程序,却说 command not found / file not found"。第 79 条就是那个问题的答案。
输入与显示:键盘到屏幕之间的协议
61. What happens when you press a key in your terminal?
来源:Julia Evans,jvns.ca,2022 年 7 月。
读它解决什么:从按键到程序收到字符,中间经过了哪些环节。
要点:
- 讲伪终端(pseudoterminal,pty)的工作方式
- 追踪一次按键从终端模拟器到 shell 的完整路径
我的补充:这一篇是整卷的地基。核心事实是:你的终端里没有终端。 有的是一对虚拟设备——终端模拟器(iTerm、Windows Terminal、GNOME Terminal)持有 pty 的 master 端,shell 持有 slave 端,中间有一层内核里的行规程(line discipline)。
那层行规程干的事比你想的多,而且它是很多"怪现象"的答案:
- 回显是它做的,不是 shell。 所以
read -s读密码时不显示,是它把回显关了。 Ctrl+C不是发给程序的字符。 行规程看到0x03就把SIGINT发给前台进程组。程序收到的是信号,不是字符。- 默认是行缓冲的(canonical mode)。 你输入的字符在按回车之前不会到 shell,退格键的处理也在这一层。这就是为什么
vim这类程序要主动切到 raw mode——它要逐个字符收。
一个实用的诊断:程序崩了之后终端变得乱七八糟(不回显、回车不换行),就是它把 pty 设成 raw mode 之后没恢复。reset 或者 stty sane 能修好。知道原理之后你不会再关掉终端窗口重开。
62. Entering text in the terminal is complicated
来源:Julia Evans,jvns.ca,2024 年 7 月。
读它解决什么:为什么有些程序里方向键能用、有些里按下去出来一串乱码。
要点:
- 讲 readline 及终端文本输入的复杂性
- 解释不同程序处理输入的方式差异
我的补充:答案是:行编辑功能不是终端提供的,是每个程序自己实现的。有三种情况:
- 链接了 GNU readline(bash、gdb、python 的旧 REPL)—— 有历史、有
Ctrl+R搜索、有 emacs 键位,配置在~/.inputrc - 自己实现了(zsh 的 ZLE、fish、Node 的 REPL、ipython)—— 功能各异,配置方式各异
- 什么都没有(
cat、简单的read循环、很多小工具的交互模式)—— 方向键直接吐出^[[A这种转义序列,退格键可能变成^H
第三种是"按方向键出乱码"的全部原因。急救办法:用 rlwrap 把任何程序包一层:
rlwrap ./my-repl # 立刻有了历史和行编辑这个小工具救过我很多次,尤其是用一些自制的或者很老的交互式程序时。
顺带一个 readline 技巧,知道的人不多但每天能用上:Ctrl+X Ctrl+E 会把当前命令行扔进 $EDITOR 里编辑,存盘退出后执行。写长命令、多行脚本的时候比在一行里挪光标舒服得多。
63. Standards for ANSI escape codes
来源:Julia Evans,jvns.ca,2025 年 3 月。
读它解决什么:终端里的颜色、光标移动、清屏这些控制序列有没有标准,标准到什么程度。
要点:
- 梳理 ANSI 转义序列的相关标准与实际支持情况
- 指出"标准"与终端实现之间的落差
我的补充:这一篇的结论对写 CLI 工具的人很重要:转义序列有标准(ECMA-48 / ISO 6429),但真实世界的支持是碎片化的。 基础的那一批(颜色、光标移动、清屏)到处都能用;稍微高级一点的(真彩色、同步更新、鼠标上报、超链接)就看终端。
写 CLI 输出时,我的三条规则:
一、别硬编码转义序列,也别假设支持。 检测方法:stdout 不是 TTY 就完全不输出颜色(isatty()),这样管道和重定向里不会出现 ^[[32m 这种垃圾。这一条几乎所有人第一次写 CLI 都会踩——mytool | grep foo 之后 grep 匹配不上,因为字符串里夹了转义序列。
二、尊重 NO_COLOR 环境变量。 这是个社区约定(no-color.org):只要这个变量存在(不管值是什么),就别输出颜色。实现成本几行代码,但对用户很有用。
三、TERM=dumb 时退化成纯文本。 CI 日志、emacs 的 shell、一些嵌入式环境是这种情况。
一个额外的实用知识:转义序列会污染宽度计算。你用 printf "%-20s" 对齐带颜色的字符串,对不齐——因为那些不可见字符也算进了长度。要么先算裸字符串的宽度再补空格,要么用现成的表格库。
64. ASCII control characters in my terminal
来源:Julia Evans,jvns.ca,2024 年 10 月。
读它解决什么:Ctrl+ 各个键分别对应什么、干什么。
要点:
- 逐个梳理终端里 ASCII 控制字符的行为
- 与伪终端那一篇(第 61 条)配套
我的补充:Ctrl+字母 的对应关系有个简单规律:取字母的 ASCII 码,把高三位清零。A 是 65(0x41),Ctrl+A 就是 1(0x01);C 是 67,Ctrl+C 是 3。所以 Ctrl+I 就是 9,也就是 Tab——Tab 和 Ctrl+I 在终端里是完全同一个字节,没有任何程序能区分它们。同理 Ctrl+M 就是回车(13),Ctrl+J 是换行(10)。
这解释了几个日常困惑:为什么终端里 Ctrl+I 会触发补全,为什么有些程序里回车和 Ctrl+M 行为一样。
最实用的几个(前四个是行规程处理的,跟 shell 无关):
Ctrl+CSIGINT 中断 ·Ctrl+\SIGQUIT(还会生成 core dump,见第 93 条)Ctrl+ZSIGTSTP 挂起(见第 68 条)·Ctrl+D不是信号,是 EOFCtrl+S/Ctrl+Q流控制的暂停/恢复。这个是终端"卡死"的经典原因——手滑按了Ctrl+S,屏幕不动了,看起来像挂了。按Ctrl+Q恢复。想彻底避免:stty -ixon关掉流控制(顺带把Ctrl+S让给 readline 的正向搜索)。
Ctrl+D 和 Ctrl+C 的区别值得说清:Ctrl+C 是"杀掉它",Ctrl+D 是"输入结束了"。在 cat > file 里按 Ctrl+C 文件可能不完整,按 Ctrl+D 才是正常收尾。
65. Terminal colours are tricky
来源:Julia Evans,jvns.ca,2024 年 10 月。
读它解决什么:为什么同一个配色方案在不同终端里看起来不一样,为什么有时候字看不见。
要点:
- 讲终端配色的多层机制与不一致
- 涉及 16 色、256 色、真彩色的差异
我的补充:终端颜色有三套互不兼容的机制叠在一起,这是所有混乱的根源:
- 原始 16 色 —— 程序说的是"第 1 号颜色",实际渲染成什么完全由终端的主题决定。这是设计如此,不是 bug。
- 256 色 —— 一个固定调色板,跨终端基本一致。
- 真彩色(24-bit) —— 直接给 RGB,所见即所得,但需要终端支持(现在主流都支持了)。
"字看不见"这个问题几乎全部出在第 1 套上:程序输出"第 4 号颜色"(蓝),在深色主题上是亮蓝,在浅色主题上可能是几乎看不见的深蓝。所以责任在程序——它不该假设背景色。
给写工具的人:要么用真彩色并同时提供浅色/深色两套,要么只用 16 色里最保守的那几个(不要用蓝色系做正文)。检测背景色理论上有办法(OSC 11 查询),但支持不全,不值得依赖。
给用户的实用建议:
- 遇到某个工具颜色难看,先找它自己的配色配置(
LS_COLORS、BAT_THEME、delta的主题),而不是改终端主题——改终端会影响所有程序 COLORTERM=truecolor这个环境变量是程序判断"能不能用真彩色"的主要依据,SSH 到远端时它经常丢,导致远端的工具退化成 256 色。SendEnv/AcceptEnv可以带过去
66. "Rules" that terminal programs follow
来源:Julia Evans,jvns.ca,2024 年(URL 路径为 11 月,站点列表显示 12 月)。
读它解决什么:终端程序之间有哪些不成文的约定,为什么打破它们会让人难受。
要点:
- 梳理终端程序普遍遵守的隐性约定
- 这些约定没有正式规范,靠惯例维持
我的补充:这一篇是写 CLI 工具之前应该读的。终端生态没有 UI 规范文档,但有一整套隐性约定,违反了用户就会觉得"这工具很怪"却说不出为什么。我自己的检查清单:
--help要能用,而且输出到 stdout(不是 stderr),退出码 0- 错误信息去 stderr,正常输出去 stdout。 这一条最常被违反,后果很具体:
mytool > out.txt之后看不到错误,或者mytool | jq因为错误信息混进 JSON 而崩掉 - stdout 不是 TTY 时关掉颜色和进度条(见第 63 条)
Ctrl+C要能立刻退出,别捕获了 SIGINT 然后继续跑- 退出码:成功 0,失败非 0。 不要"打印了 Error 但退出码是 0"——CI 里这会静默通过
- 能读 stdin 就读,让
cat x | mytool和mytool x都能用 - 不要把配置写死在
~/.mytoolrc,尊重XDG_CONFIG_HOME - 长时间运行的操作要有进度反馈,但检测到非交互环境时降级成偶尔一行日志
违反这些的后果不是报错,是用户没法把你的工具接进管道和脚本——而这是 CLI 工具存在的全部意义。
进程与数据流
67. Why pipes sometimes get "stuck": buffering
来源:Julia Evans,jvns.ca,2024 年 11 月。
读它解决什么:tail -f log | grep x 为什么半天不出东西。
要点:
- 讲标准 IO 库的缓冲策略如何导致管道看起来卡住
- 区分行缓冲与全缓冲
我的补充:这是本卷最该记住的一条。 我见过太多人以为程序挂了,实际只是在攒缓冲区。
规则来自 C 标准库(几乎所有语言的运行时都沿用了):
- stdout 是 TTY → 行缓冲,每行立刻出来
- stdout 是管道或文件 → 全缓冲,攒满 4KB 或 8KB 才写
所以 ./prog 直接跑输出正常,./prog | grep x 就"卡住"了——不是卡住,是在等攒够一个缓冲区。日志量小的话可能几分钟才出一批。
解法按场景:
# 通用:用 stdbuf 强制行缓冲
stdbuf -oL ./prog | grep x
# grep 自己也会缓冲,加 --line-buffered
tail -f log | grep --line-buffered ERROR | tee out.txt
# 假装是 TTY(对付那些死活不肯行缓冲的程序)
unbuffer ./prog | grep x # expect 包里的
script -qc './prog' /dev/null | grep x各语言里主动控制的办法:Python python -u 或 print(flush=True);Node 的 console.log 本来就是同步的(对管道也是),不用管;Go 用 bufio.Writer 的话要自己 Flush();C 里 setvbuf(stdout, NULL, _IOLBF, 0)。
这一条在容器和 CI 里格外重要:容器的 stdout 是管道,所以你的应用日志会攒着不出来,docker logs 里看不到——排查线上问题时这非常误导。Python 服务的 Dockerfile 里加 ENV PYTHONUNBUFFERED=1 基本是标配,就是为了这个。
68. Reasons to use your shell's job control
来源:Julia Evans,jvns.ca,2024 年 7 月。
读它解决什么:Ctrl+Z、fg、bg、jobs 这套东西在什么时候真的有用。
要点:
- 说明 job control 的实际用途,而不只是语法
- 属于"人人都见过但少有人用"的功能
我的补充:Ctrl+Z 大多数人只知道"能把程序停住",但不知道停住之后能干什么。几个真正实用的场景:
一、临时跳出编辑器。 vim 里 Ctrl+Z 挂起,在 shell 里跑个命令,fg 回来,光标位置全都在。比开新窗口快。
二、抢救忘了加 & 的长任务。
./long-task # 跑起来才发现要等一小时
# Ctrl+Z
bg # 转后台继续跑
disown -h %1 # 脱离当前 shell,关终端也不会被杀disown 这一步是关键——bg 只是放后台,终端一关(SIGHUP)它还是会死。这个组合能救回一个已经跑了半小时的任务,不用重来。
三、jobs -l 看 PID,比 ps aux | grep 准(不会匹配到 grep 自己)。
要注意的坑:
- 挂起的进程会一直占着资源,尤其是数据库连接和文件锁。
jobs里躺着的东西别忘了。 Ctrl+Z对 SSH 会话是发给远端程序的,想挂起 ssh 本身用~^Z(回车后打~再Ctrl+Z)。- 在脚本里 job control 默认是关的(非交互 shell),所以
fg/bg在脚本里不能用。脚本里管后台任务用wait和$!。
不过说实话,在有 tmux 的环境里这套的价值下降了很多——多开一个 pane 更直观。job control 真正不可替代的场景是登在别人的机器上、没有 tmux 也不好装的时候。
69. How to add a directory to your PATH
来源:Julia Evans,jvns.ca,2025 年 2 月。
读它解决什么:改 PATH 该改哪个文件,为什么改了没生效。
要点:
- 讲 PATH 的设置方式与各种 shell 配置文件的加载时机
- 面向"改了但不生效"这个具体困惑
我的补充:"改了没生效"几乎总是同一个原因:改错了文件。 关键区别是登录 shell 与交互 shell 读不同的文件:
| Shell | 登录时读 | 交互(非登录)时读 |
|---|---|---|
| bash | ~/.bash_profile(存在就不读 .profile) | ~/.bashrc |
| zsh | ~/.zprofile → ~/.zshrc | ~/.zshrc |
| fish | ~/.config/fish/config.fish | 同 |
bash 那一行是最大的坑:~/.bash_profile 存在时 ~/.profile 完全不读,而很多教程让你改 .profile。macOS 上更乱——终端里每开一个窗口都是登录 shell(读 .bash_profile),Linux 上通常不是(读 .bashrc)。所以同一份配置在两个系统上行为不同。
我的做法是只在一个地方定义,其他地方 source 它:
# ~/.bash_profile
[ -f ~/.bashrc ] && . ~/.bashrc
# ~/.bashrc 里放真正的 PATH 设置
export PATH="$HOME/.local/bin:$PATH"另外三件事值得知道:
- 顺序决定优先级,靠前的赢。 想覆盖系统命令就往前放,想兜底就往后(
$PATH:$HOME/bin)。 hash -r(bash)或rehash(zsh)—— shell 会缓存命令位置。你刚装了个新版本但跑的还是旧的,多半是缓存。这比"重开终端"精确。- GUI 程序不读 shell 配置。 从 Dock / 开始菜单启动的 IDE 拿不到你在
.bashrc里设的PATH——这就是"终端里能跑,IDE 里说找不到命令"的原因。解法是从终端启动 IDE,或者用 IDE 自己的环境配置。
诊断命令:type -a python(列出所有同名命令及来源,含别名和函数)比 which python 有用得多。
70. Day 1: What does a shell even do?
来源:Julia Evans,jvns.ca,2013 年 9 月。
读它解决什么:Shell 的职责边界——哪些是它做的,哪些是内核做的。
要点:
- 作者早期的探索笔记,从零开始追问 shell 的工作方式
- 涉及 fork/exec 这类基础机制
我的补充:Shell 干的事可以精确列出来,而知道边界在哪能让一大批问题变得可推理:
Shell 负责:解析命令行(分词、引号、通配符展开、变量替换)、建立管道和重定向(pipe() + dup2())、fork + exec、作业控制、维护环境变量。
内核负责:进程创建、文件描述符、信号投递、pty 的行规程(第 61 条)。
都不负责:命令自己的参数解析。 这条最重要,因为它解释了一批"为什么":
*是 shell 展开的,程序收到的是展开后的文件名列表。 所以rm *在文件多的时候会 "Argument list too long"(超过ARG_MAX),而find . -delete不会——后者不经过 shell 展开。>是 shell 做的,在 exec 之前。 所以sudo echo x > /root/f会失败:重定向由当前用户的 shell 执行,sudo只作用于echo。正确写法echo x | sudo tee /root/f。cd必须是内建命令。 子进程改自己的工作目录对父进程无效,所以cd不可能是外部程序。同理export、source。$?、$$、$!都是 shell 的变量,程序看不到。
搞清这条分界线之后,写脚本时"这一步是谁在处理"就是个可以回答的问题,而不是靠试。
71. Bash scripting quirks & safety tips
来源:Julia Evans,jvns.ca,2017 年 3 月。
读它解决什么:Bash 脚本里那些会静默出错的地方,以及怎么防。
要点:
- 列举 Bash 的陷阱与相应的安全写法
- 属于"写脚本前应该先读"的那类内容
我的补充:Bash 的默认行为对写脚本极不友好:出错继续跑、未定义变量当空串、管道里前面挂了后面照样成功。这三条加起来能让一个坏掉的脚本看起来完全正常。
每个脚本开头都该有的:
#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'-e命令失败就退出。注意它有一堆例外(if条件里、&&左边、函数被||接住时都不触发),所以它不是万能的。-u用未定义变量就报错。这一条防住了最危险的一类 bug:rm -rf "$DIR/"里$DIR是空的,变成rm -rf /。-o pipefail管道里任何一段失败,整个管道就算失败。没有它,curl bad-url | jq .的退出码是jq的(可能是 0)。IFS限制分词只按换行和制表符,避免文件名里的空格把一个路径切成两个。
除此之外我的硬规则:
一、所有变量引用都加引号。 "$var" 不是风格问题,是正确性问题。rm $file 遇到带空格的文件名就删错东西。
二、上 shellcheck。 这是我见过投入产出比最高的工具之一——它能静态发现上面绝大多数问题,包括漏掉的引号。装上,加进 CI。
三、超过 100 行就换语言。 Bash 在"串联几个命令"这件事上无可替代,但一旦需要数据结构、错误处理、单元测试,它的成本就超过 Python 了。判断标准:开始需要数组和关联数组的时候,就是该换的时候。
四、rm -rf 前面永远加一层校验:
[[ -n "${DIR:-}" && "$DIR" != "/" ]] || { echo "bad DIR" >&2; exit 1; }
rm -rf "${DIR:?DIR is unset}"${DIR:?} 这个写法在变量为空时直接报错退出,是最后一道防线。
终端体验:配置与选型
72. The fish shell is awesome
来源:Julia Evans,jvns.ca,2017 年 4 月。
读它解决什么:换 shell 值不值得,fish 具体好在哪。
要点:
- 作者早期对 fish shell 的评价
- 与运维资料库里 2024 年那篇《Reasons I still love fish》是同一条线的前后两篇,相隔七年
我的补充:把这两篇隔七年的文章放在一起看很有意思——七年后作者还在用 fish,理由基本没变。这本身就是个信号:交互式 shell 的选择是长期决定,值得认真做一次。
fish 的核心差异是它默认就有你要的东西:语法高亮、自动建议(根据历史补全整条命令)、基于 man page 生成的参数补全。zsh 能做到这些,但要装 oh-my-zsh 加一堆插件,然后你就有了一个每次启动都慢半秒、每年要修一次的配置。
但有个必须知道的取舍:fish 不兼容 POSIX。 具体影响:
export A=1→ fish 里是set -x A 1cmd1 && cmd2能用,但$(...)之外的一些语法不同- 网上抄来的
curl ... | bash安装脚本片段经常直接失败 source ~/some-bash-script.sh用不了
我的解法(也是我实际在用的):交互用 fish,脚本一律 #!/usr/bin/env bash。两者职责完全分开,互不干扰。这样既拿到了交互体验,又不用担心脚本的可移植性。
顺带说 nushell 也值得看一眼——它把管道里传的东西从文本变成了结构化数据,ls | where size > 1mb 这种能直接写。方向很对,但生态还小,我目前只在特定场景用。
73. What's involved in getting a "modern" terminal setup?
来源:Julia Evans,jvns.ca,2025 年 1 月。
读它解决什么:想要一个"现代"的终端环境,具体要配哪些层。
要点:
- 拆解终端环境的组成部分与各自的配置成本
- 2025 年 1 月发表
我的补充:这一篇的价值是把"配终端"这件事拆成了独立的层,让你能一层一层来,而不是照抄一份 dotfiles 然后完全不知道哪个配置在干什么。
我自己的分层和优先级(按投入产出比排):
- 终端模拟器 —— 换一个 GPU 渲染的(WezTerm、Alacritty、Ghostty、Kitty)。收益立刻可见:滚动和大量输出不卡。成本几乎为零,先做这个。
- 复用会话 —— tmux 或者终端自带的 session 恢复。SSH 断了不丢工作,这个价值很高。
- shell —— 见上一条。中等成本。
- 提示符 —— starship 之类,一个配置文件跨 shell 通用,比自己写
PS1转义省事得多。注意提示符里塞太多东西(尤其是 git 状态)会让每次回车都变慢,大仓库里尤其明显。 - 核心工具替换 ——
fd(找文件)、rg(搜内容)、bat(看文件)、delta(看 diff)、eza(ls)、zoxide(cd)。这批的收益很实在,但别在别人的机器和生产服务器上依赖它们——你得同时会用原版的find/grep。 - 模糊查找 ——
fzf。这一个我认为是列表里单项收益最高的:Ctrl+R搜历史、Ctrl+T找文件,改变的是交互方式而不只是速度。
一条建议:dotfiles 要自己一行一行加,别整套抄。 抄来的配置里有 90% 你用不上,剩下 10% 出问题时你不知道从哪查。我的做法是遇到一次不方便,才加一行配置解决它。
74. Some terminal frustrations
来源:Julia Evans,jvns.ca,2025 年 2 月。
读它解决什么:终端这套东西哪些地方是真的设计得不好,而不是你没学会。
要点:
- 列举终端生态中确实存在的设计问题
- 2025 年 2 月发表,与上一条同期
我的补充:这一篇有心理层面的用处:确认"终端很多地方确实是烂的"。这不是抱怨,它有实际作用——你在某个地方卡住时,能更快判断该继续钻还是该绕过去。
我认为最本质的几个问题:
- 没有统一的能力协商。 程序只能靠
TERM、COLORTERM这些环境变量猜终端支持什么,而这些变量经常在 SSH、tmux、容器里丢失或者说谎(tmux里TERM=screen-256color,于是真彩色被误判为不支持)。 - 一切都是无结构的字节流。 管道里传文本,所以每一环都要解析和重新格式化,中间任何一步的格式变化都会让下游断掉。这是 Unix 哲学的代价,nushell 那类项目在试图改这一点。
- 错误处理靠约定。 退出码是个 8 位整数,除了"0 是成功"没有任何标准,
man页也很少写清楚各个非零码是什么意思。 - 状态藏在不可见的地方。 环境变量、shell 选项、
stty设置、umask——出问题的时候你不知道该看哪。 - 回滚困难。 终端里的操作大多没有 undo,
rm就是真的删了。
从这些问题能推出一条实际的工作习惯:破坏性操作前先做一次只读的预演。rm 之前先 ls 同样的 glob;find -delete 之前先不带 -delete 跑一次;sed -i 之前先不带 -i 看输出;rsync --delete 之前先 --dry-run。这个习惯的价值来自终端"没有 undo"这个固有缺陷,不是因为你不够小心。
75. New zine: Bite Size Command Line!
来源:Julia Evans,jvns.ca,2018 年 8 月。
读它解决什么:想要一份精简的命令行入门材料,给自己或者给刚上手的人。
要点:
- 作者的命令行主题 zine 发布公告
- 公告里说明了内容的组织方式与取舍
我的补充:收这一条是因为它示范了教命令行该教什么。这个问题比它看起来重要——大部分命令行教程的失败方式是"列了 50 个命令,每个讲三个参数",学的人记不住,因为没有组织结构。
我自己带人的顺序是这样,跟 zine 的取舍思路一致:
- 先教"怎么查" ——
man、--help、tldr。这一条最重要:教会查,就不用教命令了。 - 再教管道和重定向 —— 这是命令行的核心抽象,理解了它,任意组合就成为可能。
- 然后教 5 个命令就够起步:
ls、cd、cat、grep、find。别一次给更多。 - 最后教安全网 ——
Ctrl+C、Ctrl+D、Ctrl+Z(第 64、68 条)、history。知道怎么脱身,人才敢试。
顺序里最关键的是第 4 条前置得足够早。新手不敢用命令行的主要原因是"不知道出错了怎么办",而这个恐惧一旦解除,学习速度会突然加快。
可执行文件与加载:./a.out 之后发生了什么
76. How is a binary executable organized? Let's explore it!
来源:Julia Evans,jvns.ca,2014 年 9 月。
读它解决什么:一个编译产物内部是什么结构,为什么 file 能认出它。
要点:
- 探索可执行文件(ELF)的内部组织
- 作者早期的探索式笔记
我的补充:这一篇讲的是 ELF 格式(Linux 和大多数 Unix 的可执行格式;macOS 是 Mach-O,Windows 是 PE,结构不同但思路一致)。
日常最有用的是这几个观察工具,排障时能直接用:
file ./prog # 什么格式、多少位、动态还是静态链接
ldd ./prog # 依赖哪些动态库,有没有 "not found"
readelf -d ./prog # 看 RPATH / RUNPATH(库的搜索路径)
nm -D ./prog | head # 动态符号表
strings ./prog | grep -i ver # 硬编码的字符串,常能挖出版本号和路径
objdump -d ./prog | less # 反汇编ldd 是最常用的那个,因为"程序装了但跑不起来"十次里有八次是它能答的:某个 .so 显示 not found,或者版本对不上。
一个具体的场景:你在 Ubuntu 22.04 上编译的二进制,拷到 CentOS 7 上跑,报 GLIBC_2.34 not found。这不是你的代码问题——glibc 的符号是带版本的,新系统编的东西链到了老系统没有的符号版本。解法有三条:在目标系统上编、静态链接(或用 musl)、或者用容器把运行环境一起带走。知道 ELF 里有版本化的符号表,这个错误就从玄学变成了可理解的。
77. Messing around with the stack in C
来源:Julia Evans,jvns.ca,2013 年 9 月。
读它解决什么:栈在内存里长什么样,局部变量和返回地址怎么摆的。
要点:
- 作者早期用 C 实验栈布局的笔记
- 涉及函数调用时栈帧的组织
我的补充:栈的布局知识在两个地方有实际用处,都跟"看懂报错"有关。
一、读栈回溯。 崩溃日志里那一串函数名是靠栈帧串起来的(每个帧存着上一帧的地址和返回地址)。知道这一点你就知道栈回溯什么时候不可信:编译器开了尾调用优化或者 -fomit-frame-pointer 时,中间的帧会消失,回溯里出现莫名的跳跃。所以生产环境的 Release 构建要保留符号(-g 加 separate-debug-info),否则拿到的回溯只有地址。
二、理解栈溢出的两种含义。 一个是递归太深(RecursionError、stack overflow 崩溃)——这个所有语言都有,默认栈通常 8MB(ulimit -s 看)。另一个是缓冲区溢出(C/C++ 里写越界,覆盖了返回地址)——这个是内存安全问题,也是为什么 Rust / Go 这类语言的存在有意义。
两者名字像但完全不同:前者是逻辑 bug(改算法或者加大栈),后者是安全漏洞。
顺带一个实用的:线程的栈比主线程小得多(pthread 默认 8MB 但很多运行时会调小,Go 的 goroutine 起始只有几 KB 但会自动增长)。所以"主线程能跑的递归在工作线程里崩了"是可能的,不是玄学。
78. Debugging a weird 'file not found' error
来源:Julia Evans,jvns.ca,2021 年 11 月。
读它解决什么:文件明明在那,系统却说找不到——这类错误的成因。
要点:
- 一次具体的排查记录
- "file not found" 指向的往往不是你以为的那个文件
我的补充:这一条是本卷最实用的一条,因为这个错误的误导性极强,而成因是可以枚举的。核心事实:"file not found" 说的经常不是你执行的那个文件,而是它依赖的东西。
按发生频率排:
一、shebang 里的解释器不存在。 ./script.sh 报 "No such file or directory",但 script.sh 明明在。原因是第一行 #!/bin/bash 里的 /bin/bash 在这个系统上没有(Alpine 容器里只有 /bin/sh),或者路径写错了。内核报的是解释器找不到,但错误信息里显示的是脚本名,所以完全指错了方向。
二、shebang 里有 Windows 换行符。 文件从 Windows 传过来,第一行实际是 #!/bin/bash\r,于是内核去找一个名叫 bash\r 的程序。这个尤其阴——你 cat 看不出来,ls 也正常。诊断:file script.sh 会说 "with CRLF line terminators",或者 head -1 script.sh | xxd 看结尾字节。修复:dos2unix 或者 sed -i 's/\r$//'。在 Git 仓库里根治要靠 .gitattributes 声明 *.sh text eol=lf。
三、动态库缺失。 二进制跑不起来说 "No such file or directory",实际是某个 .so 没找到。ldd ./prog 一眼就能看出来(见第 76 条)。
四、容器里的架构不匹配。 在 arm64 机器上跑 amd64 镜像,也会得到类似的模糊错误。file 能看出二进制的架构。
统一的诊断方法:用 strace -f -e trace=execve,openat ./thing 2>&1 | tail -30,直接看内核实际去打开了哪个路径、哪一次返回 ENOENT。这比猜快一个数量级。macOS 上用 dtruss(要关 SIP)或者 fs_usage。
79. Debugging shared library problems with strace
来源:Julia Evans,jvns.ca,2014 年 3 月。
读它解决什么:动态库找不到 / 版本不对时,怎么定位它到底在找哪。
要点:
- 用
strace观察动态链接器的库搜索过程 - 是上一条那类问题的通用解法
我的补充:动态链接器的搜索顺序是固定的,知道顺序就知道该改哪一处:
LD_PRELOAD里指定的- 可执行文件里的
DT_RPATH(老) LD_LIBRARY_PATH环境变量- 可执行文件里的
DT_RUNPATH(新,优先于LD_LIBRARY_PATH的行为有细节差异) /etc/ld.so.cache(由ldconfig生成)/lib、/usr/lib这些默认路径
三个诊断命令,按精确度递增:
ldd ./prog # 最终解析结果,看有没有 not found
LD_DEBUG=libs ./prog 2>&1 | head -50 # 链接器自己讲它在找什么、找到了哪个
strace -e trace=openat ./prog 2>&1 | grep '\.so' # 每一次尝试打开的路径LD_DEBUG=libs 是这里最好用的一个,很多人不知道它存在。它直接输出链接器的决策过程(trying file=...、found ...),不用从 strace 的噪音里挑。LD_DEBUG=help 能看所有可选的调试类别。
一条重要的实践建议:别用 LD_LIBRARY_PATH 解决问题。它是全局的,会影响这个 shell 里启动的所有程序,而且极易造成"我这里能跑"的不可复现环境。正确的做法按优先级:
- 用包管理器装到标准位置(最好)
- 构建时设 RUNPATH(
gcc -Wl,-rpath,'$ORIGIN/../lib',$ORIGIN是相对于可执行文件自身的路径,可以做成便携的目录结构) - 静态链接,把问题消灭掉
- 容器,把整个运行环境固定住
80. How to get a core dump for a segfault on Linux
来源:Julia Evans,jvns.ca,2018 年 4 月。
读它解决什么:程序段错误了,怎么拿到 core dump 去事后分析。
要点:
- 讲 Linux 上 core dump 的配置与获取
- 面向"崩了但不知道为什么"的场景
我的补充:core dump 是崩溃时刻整个进程内存的快照,事后能用 gdb 打开,看当时的栈、变量、每个线程在干什么。它解决的是最难的那类问题:偶发崩溃——你没法复现,但只要它崩过一次,你就有了完整现场。
问题是默认几乎总是关着的,等你真需要的时候才发现没开。三步打开:
ulimit -c unlimited # 允许生成(默认 0,即不生成)
cat /proc/sys/kernel/core_pattern # 看 dump 往哪写
# 现代发行版这里通常是 |/usr/lib/systemd/systemd-coredump
coredumpctl list # systemd 环境用这个查
coredumpctl gdb <PID或程序名> # 直接开 gdb 加载用 gdb 分析:
gdb ./prog core.1234
(gdb) bt full # 完整栈回溯,含局部变量
(gdb) thread apply all bt # 所有线程的栈(死锁排查靠这个)
(gdb) info registers容器和 Kubernetes 里的额外坑,这几条我踩过:
core_pattern是宿主机内核的全局设置,容器里改不了(/proc/sys是只读挂载的)。要改得在宿主机上改。ulimit -c要在容器的启动进程里设,Dockerfile 里RUN ulimit -c unlimited完全无效(那只影响构建那一层的那个 shell)。用--ulimit core=-1或者在 entrypoint 脚本里设。- dump 写到容器的可写层里,容器一销毁就没了。要挂个 volume 出来。
- core 文件可能非常大(等于进程的内存占用),一个吃了 8GB 内存的进程崩一次就是 8GB 文件。磁盘会被打满,要么限制大小,要么配轮转。
- core dump 里有进程的全部内存,包括密钥、token、用户数据。 它和日志一样是敏感数据,权限和留存策略要按敏感数据来定,别随便往共享目录写。
本卷小结:61–66 条是输入与显示(第 61 条的 pty 模型是地基,第 63、65、66 条是写 CLI 工具前该读的);67–71 条是进程与数据流(第 67 条的缓冲问题最值得记住,它是"程序看起来卡住了"最常见的原因;第 71 条的 set -euo pipefail 和 shellcheck 是硬要求);72–75 条是配置与选型;76–80 条是可执行文件与加载(第 78 条那个 shebang / CRLF 的坑迟早会遇到,提前知道能省几小时)。
上一卷: ③ Git 规模化、工作流与安全 · 下一卷: ⑤ 调试方法论与实战 →
