es.exe 在中文 Windows 上必然返回乱码:三个隐藏参数的排查全过程
一句话结论:
es.exe必须带-cp 65001,这个参数-h里不显示。
不带的话,
spawn出来的管道进程没有 console,GetConsoleCP()返回 0,WideCharToMultiByte(0, ...)退化成系统 ANSI 代码页(中文 Windows 上是 GBK),中文路径全变U+FFFD——不可恢复的替换字符,不是显示问题。
起因
我想给一个 Node 程序加「全盘文件名秒查」。Everything + es.exe 是 Windows 上唯一的正确答案:Everything 用 NTFS 的 USN Journal 维护全盘索引,es.exe 是它的命令行客户端,百万级文件里查一个名字只要几十毫秒。
装完跑了第一次测试,就撞墙了。
症状
从 Node 里接 stdout,拿到的路径里中文全部变成了替换字符 U+FFFD(下面用 ? 代替,因为它本身就是个"信息已丢失"的占位符,复制不出来,也不该复制):
C:\Users\balon\.dsh\tools\_tmp\??????_2026-10-24.csv
C:\Users\balon\Documents\01_workspace\02_CODE\??????\??????_2026-10-24.csv注意这不是终端显示的问题——我是从 Node 里接 stdout 接出来的,落进变量就已经是这个样子了。
更关键的一点:U+FFFD 是解码时产生的替换字符,原始字节信息已经丢了。这意味着你没法靠"换个编码再解一次"救回来——必须在字节层面查根因。
第一反应是:Node 的流式解码把多字节字符切断了。这是很常见的坑(按 chunk 逐块 toString('utf8') 会让跨块边界的中文变成 U+FFFD)。
第一步:先排除自己写错
翻代码,发现解码部分其实是对的:
child.stdout.setEncoding('utf8') // ← 已经是流式解码,不是裸 toStringsetEncoding() 会正确处理跨 chunk 的多字节边界。既然解码没问题,那问题就在上游给出来的字节本身不是 UTF-8。
验证一下:把原始字节 dump 出来看。
es.exe -n 1 "记忆总清单" | xxd | head -3真实输出(我的实测,_tmp\ 后面那 10 个字节就是「记忆总清单」):
00000010: 6473 685c 746f 6f6c 735c 5f74 6d70 5cbc dsh\tools\_tmp\.
00000020: c7d2 e4d7 dcc7 e5b5 a55f 3230 3236 2d31 ........._2026-1bc c7 d2 e4 d7 dc c7 e5 b5 a5 —— 5 个汉字占 10 字节,这是 GBK 的特征(GBK 双字节,UTF-8 的中文是 3 字节)。
加上 -cp 65001 之后,同样的位置:
00000010: 6473 685c 746f 6f6c 735c 5f74 6d70 5ce8 dsh\tools\_tmp\.
00000020: aeb0 e5bf 86e6 80bb e6b8 85e5 8d95 5f32 .............._2e8 ae b0 e5 bf 86 e6 80 bb e6 b8 85 e5 8d 95 —— 15 字节,UTF-8。10 → 15,字节数变了,这就是"编码不同"最直接的物证。
所以结论明确:是 es.exe 输出的编码不对,不是接收端的问题。
第二步:为什么是 GBK?
es.exe 是闭源分发的,但作者很厚道——把 cli.c 源码一起打进压缩包了。这就是突破口。
unzip -l ES-1.1.0.27.zip
# 138832 es.exe
# 152787 cli.c ← 关键在 cli.c 里搜编码相关:
UINT es_cp = 0; // current code page
...
es_cp = GetConsoleCP(); // ← 第 2475 行根因就在这里。
GetConsoleCP() 取的是当前控制台的输入代码页。但如果你用 spawn() 起一个子进程、把 stdout 接到管道上,这个进程根本没有 console——它是被当成 GUI 风格的进程创建的。
没有 console,GetConsoleCP() 返回 0。
然后:
len = WideCharToMultiByte(es_cp, 0, text, wlen, 0, 0, 0, 0);WideCharToMultiByte 的 CodePage 参数传 0 会怎样?MSDN 上写着:传 0 等价于 CP_ACP,也就是「系统当前 ANSI 代码页」。中文 Windows 上 CP_ACP = 936 = GBK。
所以整条链路是:
无 console 的 spawn 进程
→ GetConsoleCP() 返回 0
→ WideCharToMultiByte(0, ...) 走 CP_ACP
→ 中文 Windows 上 CP_ACP = GBK
→ 输出 GBK 字节
→ 下游按 UTF-8 解 → U+FFFD → 不可恢复这也解释了为什么在 cmd 里手动敲命令是正常的——那时候有 console,GetConsoleCP() 返回 936 或者 65001,行为符合预期。只有在被程序调用时才坏,这是最坑的地方:手工测试一切正常,一集成到代码里就炸。
第三步:找开关
问题清楚了,但怎么改?es.exe -h 里没有编码相关的参数:
es.exe -h | grep -i "utf\|encoding\|cp"
# -utf8-bom
# Store a UTF-8 byte order mark at the start of the exported file.只有个跟导出文件相关的 -utf8-bom,跟 stdout 无关。
回到源码里翻。找 es_cp 的赋值点,除了初始化那处,还有第二处:
else
if (es_check_param(es_argv,L"cp"))
{
command_line = es_get_command_argv(command_line);
if (!command_line)
{
es_fatal(ES_ERROR_EXPECTED_SWITCH_PARAMETER);
}
es_cp = es_wstring_to_int(es_argv); // ← 直接指定代码页
}找到了:-cp <n>。
这是个未文档化的隐藏参数——-h 里完全不列,只有读源码才知道。传 -cp 65001 就是 UTF-8:
es.exe -cp 65001 -n 1 "记忆总清单"
# C:\Users\balon\.dsh\tools\_tmp\记忆总清单_2026-10-24.csv ← 正常了验证一下字节层面确实是 UTF-8 了:
// 用 Node 实测,判据是 U+FFFD 是否存在
const a = await run(['-n','1','记忆总清单']); // 有 U+FFFD
const b = await run(['-cp','65001','-n','1','记忆总清单']); // 无 U+FFFD
console.log(b.includes(String.fromCharCode(0xFFFD))); // false顺带撞出的另外三个坑
修好编码之后继续测试,发现还有三个参数陷阱,全都是静默失败——不报错、exit code 0、就是没结果。这类问题比报错难查十倍。
坑 1:-n 后面必须跟数字
es.exe -n 'AGENTS' # ← 少了数字,把搜索词当成了 -n 的值
# stdout: 空
# stderr: 空
# exit: 0 ← 完全静默正确写法:
es.exe -n 5 'AGENTS' # 搜索词作为独立的位置参数教训:拿不准的时候,先分别打 es.exe -n 3 xxx 和 es.exe xxx 对比输出。有输出 vs 没输出,立刻定位。
坑 2:-ext 不是扩展名过滤,是「显示哪些列」
这个反直觉程度最高。看帮助文档:
-extension, -ext看起来就是 filter 吧?实际行为:
es.exe -cp 65001 -ext md -n 2 'AGENTS'
# md C:\Users\...\agents-snippet.md ← 注意开头的 "md "
# md C:\Users\...\AGENTS.local.md它做的是「把扩展名列加进输出」,所以返回的每一行前面多了个 md 前缀。如果你直接按行当路径用,路径就是错的。
真正的扩展名过滤要用 Everything 自己的查询语法 ext:md,而且——
坑 3:多个搜索条件是 AND,但必须各自独立成 argv
# 拼成单个参数:恒 0 条
es.exe -cp 65001 -n 3 'AGENTS ext:md'
# (空)
# 拆成两个参数:正常命中
es.exe -cp 65001 -n 3 'AGENTS' 'ext:md'
# C:\Users\...\AGENTS.local.md
# C:\Users\...\agents-snippet.md这个跟 shell 的引号直觉完全相反。因为 es.exe 用 CommandLineToArgvW 解析命令行,一个 argv 就是一段完整搜索词;塞进同一个 argv 里,它会把 AGENTS ext:md 当成「文件名里含这段字符串」去匹配——当然找不到。
对应到 Node:
// ❌ 错
const terms = [query, extFilter].join(' ');
args.push(terms);
// ✅ 对
if (query) args.push(query);
for (const ext of exts) args.push(`ext:${ext}`);还有一个与 es.exe 无关、但更危险的坑
修好上面四个之后,我在复盘时发现一个独立的风险,值得单独说:
我修改的那个 .js 文件,是 pnpm store 的硬链接。
什么是硬链接
一个文件是「一箱货」,文件名是「一个门牌」。硬链接 = 同一箱货挂多个门牌,不占额外空间。
关键在于:从任何一个门牌进去改货,改的都是同一箱。
怎么发现的
改之前先查了一下:
fsutil hardlink list "C:\...\node_modules\some-package\lib\index.js"输出五条:
\Users\balon\AppData\Local\pnpm\store\v11\files\f2\187024d3...
\Users\balon\AppData\Local\pnpm\store\v11\tmp\_tmp_16248_...\lib\index.js
\Users\balon\.dsh\profiles\desktop\node_modules\some-package\lib\index.js
\Users\balon\AppData\Local\pnpm\store\v11\tmp\_tmp_36720_...\lib\index.js
\Users\balon\AppData\Local\pnpm\store\v11\tmp\_tmp_rEEprn\lib\index.jslinks=5,其中一条是全局 store。如果我直接原地改,pnpm 的全局缓存就被污染了。
后果不是「文件坏了」这么简单:
- 下次任何
pnpm install可能报完整性校验失败 - 以后任何新项目装同一个包,复制出来的都是被改过的版本
- 但
package.json里写的版本号还是原版——等于给全局缓存投毒,且无痕
一句话总结:改 node_modules 里的文件 = 改 store = 改这台机器上所有引用它的地方。
正确做法:断链
「复制 → 删原 → 改名」,得到一个全新的、只有自己一个门牌的副本:
cp 原文件 tmp # 复制内容,产生新 inode
rm 原文件 # 只删这一个门牌,货还在(store 还有 4 个门牌撑着)
mv tmp 原文件名 # 把门牌挂到新箱子上验证:
stat -c '%i links=%h' 文件 # inode 变了、links=1
sha256sum store里那份 # 必须与改之前一致这个手法不新鲜——sed -i、prettier --write、git 写对象,底层全是这套。新鲜的是「改之前先查 hardlink 数」这个习惯。
一条命令就能查清:
fsutil hardlink list "<path>"可复现的验证清单
如果你也在踩这些坑,按顺序跑一遍就能定位:
# 1. 编码:直接看字节是 UTF-8 还是 GBK
es.exe -n 1 "中文关键词" | xxd | head -2
es.exe -cp 65001 -n 1 "中文关键词" | xxd | head -2
# 2. 参数位置:对比有无 -n 数字
es.exe "AGENTS" # 应该有结果
es.exe -n "AGENTS" # 静默空 → 就是坑 1
# 3. -ext 的真实行为
es.exe -cp 65001 -ext md -n 2 "AGENTS" # 看有没有 "md " 前缀
# 4. 多条件拼接
es.exe -cp 65001 -n 3 'AGENTS' 'ext:md' # 有结果
es.exe -cp 65001 -n 3 'AGENTS ext:md' # 空 → 就是坑 3
# 5. 改 node_modules 之前
fsutil hardlink list "<目标文件>"顺带:Everything 索引需要管理员权限
装 Everything 便携版之后,es.exe 报:
Error 8: Everything IPC window not found. Please make sure Everything is running.启动了 everything.exe,它会弹一个对话框:
"Everything" 需要管理员权限来索引 NTFS 卷
因为读 NTFS 的 MFT 需要管理员权限。 三种选择:
| 方案 | 后果 |
|---|---|
| 每次以管理员身份运行 | 每次重启都得重来,且只有那个进程有权 |
| 安装 Everything 服务 ← 推荐 | 服务以 SYSTEM 跑,之后任何非管理员进程都能查索引 |
| 禁止索引 NTFS 卷 | 索引为空,等于没用 |
装服务:
Start-Process everything.exe -ArgumentList '-install-service' -Verb RunAsUAC 点「是」,然后确认:
Get-Service Everything # Running / Automatic
es.exe -get-result-count # 2889080 ← 索引已建装一次服务,永久生效,之后 es.exe 从普通权限进程调也正常。这比每次提权优雅得多。
方法论小结
回头看,这几个坑有共同特征:
- 全都是静默失败。不报错、exit 0、就是没结果。所以「跑通了」和「跑出了正确结果」是两件事。
- 手工测试正常,程序调用才坏。
GetConsoleCP()就是典型——有 console 和没 console 是两种行为。测 CLI 工具时,必须用和最终集成方式相同的方式测(spawn + 管道,不要只在终端里敲)。 - 帮助文档会漏掉关键参数。
-cp完全没写。带源码的工具,先翻源码再猜。 - 反直觉的默认行为要实测。
-ext的名字骗了我;单参数 AND 违反了 shell 引号直觉。
一句话:先怀疑量具,再怀疑产品。 我第一次遇到乱码时先怀疑自己的解码代码,而不是怀疑 es.exe 输出的是 GBK——但如果当时就用 xxd 看一眼字节,能省掉一轮。