目录

es.exe 在中文 Windows 上必然返回乱码:三个隐藏参数的排查全过程

11

一句话结论: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')   // ← 已经是流式解码,不是裸 toString

setEncoding() 会正确处理跨 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-1

bc 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  .............._2

e8 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.js

links=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 RunAs

UAC 点「是」,然后确认:

Get-Service Everything    # Running / Automatic
es.exe -get-result-count  # 2889080  ← 索引已建

装一次服务,永久生效,之后 es.exe 从普通权限进程调也正常。这比每次提权优雅得多。

方法论小结

回头看,这几个坑有共同特征:

  1. 全都是静默失败。不报错、exit 0、就是没结果。所以「跑通了」和「跑出了正确结果」是两件事。
  2. 手工测试正常,程序调用才坏。GetConsoleCP() 就是典型——有 console 和没 console 是两种行为。测 CLI 工具时,必须用和最终集成方式相同的方式测(spawn + 管道,不要只在终端里敲)。
  3. 帮助文档会漏掉关键参数。-cp 完全没写。带源码的工具,先翻源码再猜。
  4. 反直觉的默认行为要实测。-ext 的名字骗了我;单参数 AND 违反了 shell 引号直觉。

一句话:先怀疑量具,再怀疑产品。 我第一次遇到乱码时先怀疑自己的解码代码,而不是怀疑 es.exe 输出的是 GBK——但如果当时就用 xxd 看一眼字节,能省掉一轮。