一个下午被两个假象骗了两次:构建脚本秒回,让我以为编译完成了;strings | grep 零命中,又让我以为二进制里根本没有我刚加的那行中文。两个坑单独看都不难,叠在一起就足够让人开始怀疑自己写的代码。

🧩 场景:确认新二进制里有没有那句话

给一个 Go 写的小服务加了一行中文日志,编译完想确认「跑起来的确实是新的二进制」。最直觉的验证方式是去二进制里翻这串字面量:

$ strings ./demo | grep -c "同步完成"
0

零。第一反应当然是:构建没生效,跑的还是旧的。于是我掉头去查构建脚本——方向就此错了半小时。

🔍 strings 默认只认 ASCII

真相是这条中文在二进制里,好好地待着。换个工具立刻就能看见:

$ grep -a -c "同步完成" ./demo
1
$ strings -e S ./demo | grep -c "同步完成"
1

GNU strings 默认的编码是 -e s,也就是「7 位 ASCII 的可打印字符序列」。UTF-8 的中文每个字节都 ≥ 0x80,在这个规则下统统算不可打印。于是中文不仅不会被输出,还会充当分隔符,把一条完整的日志切成两段。

拿一条混合串 [sync-worker] 同步完成 done=3 skipped=0 做对照,默认模式下它是这样出来的:

[sync-worker] 
 done=3 skipped=0

strings -e S(单字节 8 位编码)看到的是原样的一整条:

[sync-worker] 同步完成 done=3 skipped=0

这也解释了为什么我当时那么容易被骗:grep "sync-worker" 是有命中的,grep "done=3" 也有——ASCII 部分全都在,唯独我拿去当证据的那半句中文不见了。工具没坏,是我拿它做了它默认不做的事。

顺便提一个容易踩到的连带问题:strings 默认只输出长度 ≥ 4 的序列(-n 4)。被中文切碎之后的残片如果不足 4 个字符,会直接被丢掉,连碎片都看不到。

🧱 顺便说说 Go 二进制里的字符串长什么样

既然已经打开了,索性多看一眼。Go 不给字符串常量留结尾的 NUL——长度存在 header 里,字节紧挨着排。所以整块只读数据在 strings 眼里是一条条巨长的血肉模糊的东西,我这个只有一行代码的程序里,最长的一条有三千多个字符,全是互不相关的 runtime 报错首尾相接:

runqsteal: runq overflowdouble traceGCSweepStartbad use of trace.seqlock…

这带来一个实际影响:别指望靠 strings 的换行来判断字符串边界。想确认边界就用精确整串去匹配,顺手让 grep 报出字节偏移:

$ grep -abo "同步完成" ./demo
660537:同步完成

-b 给偏移,配合 xxd -s <offset> -l 64 就能直接去现场看上下文。

🛠 三种靠得住的验证方式

按省事程度排序:

  1. grep -a:把二进制当文本按字节匹配,中文原样可搜,-c 数次数、-o 打印命中内容。验证「某个字面量在不在」这件事,这是最短路径。这里还有个小陷阱:不加 -a 的时候,GNU grep 匹配上了也不会把那一行打给你,只在 stderr 甩一句 binary file matches。命中和没命中在屏幕上长得几乎一样,肉眼扫过去很容易读成「没有」——要么加 -a,要么老老实实看退出码。
  2. strings -e S:仍然想要「提取所有字符串」的语义时用它,把编码从 7 位换成单字节 8 位,UTF-8 就完整了。
  3. 别只用内容当证据:更可靠的是产物本身的指纹——ls -l 看 mtime 是不是这次构建的时间,sha256sum 比对,或者干脆在构建时埋一个版本串:
go build -ldflags "-X main.buildID=$(date +%s)" -o demo .

有了这个,验证就变成 ./demo -version,不用再对二进制做考古。

⏱ 上游那个坑:秒回不等于干完了

回头说被我冤枉的构建脚本。它开头有这么一手,把自己丢到后台再立刻返回:

_BG=1 nohup bash "$0" "$@" &
echo "[build] 后台启动 PID=$!"

命令行秒回,实际上后台还在拉依赖、编三个二进制,前后几十秒。我在它返回的瞬间就去 grep 产物,于是拿到了一个「真·假阴性」——那一刻新二进制确实还没落盘。两个假象叠起来,指向了一个完全不存在的 bug。

对付异步脚本,别靠感觉,轮询等一个明确的完成信号:

for i in $(seq 1 10); do
  sleep 7
  grep -aq "同步完成" ./demo && { echo "已包含新字面量"; break; }
done

或者 tail -f 构建日志等那句「编译完成」。更好的做法是让脚本自己提供 --wait / 前台模式,把「什么时候算干完」写进接口,而不是留给使用者猜。

收束

两条经验,都不新,但值得贴在显示器边上:

  • 工具的默认值是历史,不是常识。 strings 的默认编码来自还没有 UTF-8 的年代,它没错,只是不为你今天的用法服务。
  • 「没找到」有两种:真的没有,和你的工具看不见。 所以排查的第一步不是分析结论,而是自证工具可见性——先拿一条你百分之百确信存在的串做对照实验。这次只要顺手 grep 一下同一行里的 done=3,半小时就省下来了。

顺带一提:这个坑对任何非 ASCII 字面量都成立,日文、韩文、emoji、带重音的拉丁字母一样。如果你的项目里有中文提示语,把 strings | grep 从验证流程里删掉吧。