Skip to content

资料库 · 终端与 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 及终端文本输入的复杂性
  • 解释不同程序处理输入的方式差异

我的补充:答案是:行编辑功能不是终端提供的,是每个程序自己实现的。有三种情况:

  1. 链接了 GNU readline(bash、gdb、python 的旧 REPL)—— 有历史、有 Ctrl+R 搜索、有 emacs 键位,配置在 ~/.inputrc
  2. 自己实现了(zsh 的 ZLE、fish、Node 的 REPL、ipython)—— 功能各异,配置方式各异
  3. 什么都没有cat、简单的 read 循环、很多小工具的交互模式)—— 方向键直接吐出 ^[[A 这种转义序列,退格键可能变成 ^H

第三种是"按方向键出乱码"的全部原因。急救办法:用 rlwrap 把任何程序包一层

bash
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+C SIGINT 中断 · Ctrl+\ SIGQUIT(还会生成 core dump,见第 93 条)
  • Ctrl+Z SIGTSTP 挂起(见第 68 条)· Ctrl+D 不是信号,是 EOF
  • Ctrl+S / Ctrl+Q 流控制的暂停/恢复。这个是终端"卡死"的经典原因——手滑按了 Ctrl+S,屏幕不动了,看起来像挂了。按 Ctrl+Q 恢复。想彻底避免:stty -ixon 关掉流控制(顺带把 Ctrl+S 让给 readline 的正向搜索)。

Ctrl+DCtrl+C 的区别值得说清:Ctrl+C 是"杀掉它",Ctrl+D 是"输入结束了"。在 cat > file 里按 Ctrl+C 文件可能不完整,按 Ctrl+D 才是正常收尾。

阅读原文 →

65. Terminal colours are tricky

来源:Julia Evans,jvns.ca,2024 年 10 月。

读它解决什么:为什么同一个配色方案在不同终端里看起来不一样,为什么有时候字看不见。

要点

  • 讲终端配色的多层机制与不一致
  • 涉及 16 色、256 色、真彩色的差异

我的补充:终端颜色有三套互不兼容的机制叠在一起,这是所有混乱的根源:

  1. 原始 16 色 —— 程序说的是"第 1 号颜色",实际渲染成什么完全由终端的主题决定。这是设计如此,不是 bug。
  2. 256 色 —— 一个固定调色板,跨终端基本一致。
  3. 真彩色(24-bit) —— 直接给 RGB,所见即所得,但需要终端支持(现在主流都支持了)。

"字看不见"这个问题几乎全部出在第 1 套上:程序输出"第 4 号颜色"(蓝),在深色主题上是亮蓝,在浅色主题上可能是几乎看不见的深蓝。所以责任在程序——它不该假设背景色。

给写工具的人:要么用真彩色并同时提供浅色/深色两套,要么只用 16 色里最保守的那几个(不要用蓝色系做正文)。检测背景色理论上有办法(OSC 11 查询),但支持不全,不值得依赖。

给用户的实用建议:

  • 遇到某个工具颜色难看,先找它自己的配色配置(LS_COLORSBAT_THEMEdelta 的主题),而不是改终端主题——改终端会影响所有程序
  • 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 | mytoolmytool 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 就"卡住"了——不是卡住,是在等攒够一个缓冲区。日志量小的话可能几分钟才出一批。

解法按场景:

bash
# 通用:用 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 -uprint(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+Zfgbgjobs 这套东西在什么时候真的有用。

要点

  • 说明 job control 的实际用途,而不只是语法
  • 属于"人人都见过但少有人用"的功能

我的补充Ctrl+Z 大多数人只知道"能把程序停住",但不知道停住之后能干什么。几个真正实用的场景:

一、临时跳出编辑器。 vim 里 Ctrl+Z 挂起,在 shell 里跑个命令,fg 回来,光标位置全都在。比开新窗口快。

二、抢救忘了加 & 的长任务。

bash
./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
# ~/.bash_profile
[ -f ~/.bashrc ] && . ~/.bashrc
# ~/.bashrc 里放真正的 PATH 设置
export PATH="$HOME/.local/bin:$PATH"

另外三件事值得知道:

  1. 顺序决定优先级,靠前的赢。 想覆盖系统命令就往前放,想兜底就往后($PATH:$HOME/bin)。
  2. hash -r(bash)或 rehash(zsh)—— shell 会缓存命令位置。你刚装了个新版本但跑的还是旧的,多半是缓存。这比"重开终端"精确。
  3. 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 不可能是外部程序。同理 exportsource
  • $?$$$! 都是 shell 的变量,程序看不到。

搞清这条分界线之后,写脚本时"这一步是谁在处理"就是个可以回答的问题,而不是靠试。

阅读原文 →

71. Bash scripting quirks & safety tips

来源:Julia Evans,jvns.ca,2017 年 3 月。

读它解决什么:Bash 脚本里那些会静默出错的地方,以及怎么防。

要点

  • 列举 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 前面永远加一层校验

bash
[[ -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 1
  • cmd1 && 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 然后完全不知道哪个配置在干什么。

我自己的分层和优先级(按投入产出比排):

  1. 终端模拟器 —— 换一个 GPU 渲染的(WezTerm、Alacritty、Ghostty、Kitty)。收益立刻可见:滚动和大量输出不卡。成本几乎为零,先做这个。
  2. 复用会话 —— tmux 或者终端自带的 session 恢复。SSH 断了不丢工作,这个价值很高。
  3. shell —— 见上一条。中等成本。
  4. 提示符 —— starship 之类,一个配置文件跨 shell 通用,比自己写 PS1 转义省事得多。注意提示符里塞太多东西(尤其是 git 状态)会让每次回车都变慢,大仓库里尤其明显。
  5. 核心工具替换 —— fd(找文件)、rg(搜内容)、bat(看文件)、delta(看 diff)、ezals)、zoxidecd)。这批的收益很实在,但别在别人的机器和生产服务器上依赖它们——你得同时会用原版的 find/grep
  6. 模糊查找 —— fzf。这一个我认为是列表里单项收益最高的:Ctrl+R 搜历史、Ctrl+T 找文件,改变的是交互方式而不只是速度。

一条建议:dotfiles 要自己一行一行加,别整套抄。 抄来的配置里有 90% 你用不上,剩下 10% 出问题时你不知道从哪查。我的做法是遇到一次不方便,才加一行配置解决它。

阅读原文 →

74. Some terminal frustrations

来源:Julia Evans,jvns.ca,2025 年 2 月。

读它解决什么:终端这套东西哪些地方是真的设计得不好,而不是你没学会。

要点

  • 列举终端生态中确实存在的设计问题
  • 2025 年 2 月发表,与上一条同期

我的补充:这一篇有心理层面的用处:确认"终端很多地方确实是烂的"。这不是抱怨,它有实际作用——你在某个地方卡住时,能更快判断该继续钻还是该绕过去。

我认为最本质的几个问题:

  • 没有统一的能力协商。 程序只能靠 TERMCOLORTERM 这些环境变量猜终端支持什么,而这些变量经常在 SSH、tmux、容器里丢失或者说谎(tmuxTERM=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 的取舍思路一致:

  1. 先教"怎么查" —— man--helptldr。这一条最重要:教会查,就不用教命令了。
  2. 再教管道和重定向 —— 这是命令行的核心抽象,理解了它,任意组合就成为可能。
  3. 然后教 5 个命令就够起步lscdcatgrepfind。别一次给更多。
  4. 最后教安全网 —— Ctrl+CCtrl+DCtrl+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,结构不同但思路一致)。

日常最有用的是这几个观察工具,排障时能直接用:

bash
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 构建要保留符号(-gseparate-debug-info),否则拿到的回溯只有地址。

二、理解栈溢出的两种含义。 一个是递归太深RecursionErrorstack 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 观察动态链接器的库搜索过程
  • 是上一条那类问题的通用解法

我的补充:动态链接器的搜索顺序是固定的,知道顺序就知道该改哪一处

  1. LD_PRELOAD 里指定的
  2. 可执行文件里的 DT_RPATH(老)
  3. LD_LIBRARY_PATH 环境变量
  4. 可执行文件里的 DT_RUNPATH(新,优先于 LD_LIBRARY_PATH 的行为有细节差异)
  5. /etc/ld.so.cache(由 ldconfig 生成)
  6. /lib/usr/lib 这些默认路径

三个诊断命令,按精确度递增:

bash
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 里启动的所有程序,而且极易造成"我这里能跑"的不可复现环境。正确的做法按优先级:

  1. 用包管理器装到标准位置(最好)
  2. 构建时设 RUNPATHgcc -Wl,-rpath,'$ORIGIN/../lib'$ORIGIN 是相对于可执行文件自身的路径,可以做成便携的目录结构)
  3. 静态链接,把问题消灭掉
  4. 容器,把整个运行环境固定住

阅读原文 →

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 打开,看当时的栈、变量、每个线程在干什么。它解决的是最难的那类问题:偶发崩溃——你没法复现,但只要它崩过一次,你就有了完整现场。

问题是默认几乎总是关着的,等你真需要的时候才发现没开。三步打开:

bash
ulimit -c unlimited              # 允许生成(默认 0,即不生成)
cat /proc/sys/kernel/core_pattern   # 看 dump 往哪写
# 现代发行版这里通常是 |/usr/lib/systemd/systemd-coredump
coredumpctl list                 # systemd 环境用这个查
coredumpctl gdb <PID或程序>     # 直接开 gdb 加载

用 gdb 分析:

bash
gdb ./prog core.1234
(gdb) bt full        # 完整栈回溯,含局部变量
(gdb) thread apply all bt    # 所有线程的栈(死锁排查靠这个)
(gdb) info registers

容器和 Kubernetes 里的额外坑,这几条我踩过:

  1. core_pattern 是宿主机内核的全局设置,容器里改不了(/proc/sys 是只读挂载的)。要改得在宿主机上改。
  2. ulimit -c 要在容器的启动进程里设,Dockerfile 里 RUN ulimit -c unlimited 完全无效(那只影响构建那一层的那个 shell)。用 --ulimit core=-1 或者在 entrypoint 脚本里设。
  3. dump 写到容器的可写层里,容器一销毁就没了。要挂个 volume 出来。
  4. core 文件可能非常大(等于进程的内存占用),一个吃了 8GB 内存的进程崩一次就是 8GB 文件。磁盘会被打满,要么限制大小,要么配轮转。
  5. 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 规模化、工作流与安全 · 下一卷: ⑤ 调试方法论与实战 →