2682 字
13 分钟
查了一小时杀软,根因是 98 个 LF 换行符

一个 launch.bat,双击之后黑框一闪而过,同目录下的 launch_log.txt 一个字节都没有。

这个脚本干的事情很简单:先启动一个本地单机游戏,等三秒,再启动 Cheat Engine 7.5 便携版并把配套的 CT 表当参数传进去。我把它挂在 Steam 的非 Steam 快捷方式上,点”游玩”就能一次性拉起两个程序。

脚本是我在 claude.ai 网页端生成的,下载下来直接放进游戏目录。它从来没有成功执行过一次。

症状:日志文件是空的#

这个脚本的第一件事就是往日志里写一行时间戳:

Terminal window
echo [%date% %time%] script start>>"%LOG_FILE%"

日志文件不存在。这个事实比任何报错都有信息量——它把”脚本跑了但中途失败”这个可能性整个排除掉了。三个路径变量(游戏 exe、Cheat Engine、CT 表)的检查逻辑全都排在这一行后面,既然这一行没执行,那么路径写没写对就完全不重要。

我先核对了磁盘上的实际文件,三个路径全部真实存在,文件名一个字都没错。然后我检查了 Mark-of-the-Web,Unblock-File 已经执行过,Zone.Identifier 数据流已经被移除干净。我还检查了 BOM,文件头三个字节是 40 65 63,也就是 @ec,没有 BOM。

顺带发现一件事:交接文档里反复提到的 launch.vbs(那个用来隐藏黑框的包装脚本)在磁盘上根本不存在。它在文档里被讨论过、被设计过、被写进了排查清单,唯独没有被保存下来。

第一次误判:Smart App Control#

我去翻 Windows 的 Code Integrity 日志,翻到了这个:

16:47:01 Event ID 3033
Code Integrity determined that a process (\Device\HarddiskVolume3\Windows\explorer.exe)
attempted to load \Device\HarddiskVolume3\Windows\System32\cmd.exe
that did not meet the Enterprise signing level requirements.
16:47:01 Event ID 3118
Smart App Control Block Deteails

时间戳跟用户双击的时刻严丝合缝。注册表里 VerifiedAndReputablePolicyState 的值是 1,确认 Smart App Control 处于强制状态。事件 ID 3118 的官方描述里那个 Deteails 拼写错误是微软自己写的,我原样抄下来了。

这条链路讲得通:你双击 bat,explorer.exe 需要拉起 cmd.exe 来解释它,Smart App Control 在这一步把整条调用链拦下来。日志写不出来,因为 cmd.exe 压根没机会读第一行。

讲得通,并且是错的。

这两条事件的时间戳是 16:47,而后面的每一次测试都发生在 16:50 之后,再也没有产生过任何新的 CodeIntegrity 事件。一个只在最初出现过两次、之后再也不复现的拦截记录,解释不了一个每次都失败的脚本。我当时把它当成了根因。

中间插曲:Steam 的引号#

排查过程中确实挖到一个真实的独立 bug。我去读了 Steam 的 shortcuts.vdf,把二进制里的引号和 NUL 字节标出来之后,对比很明显:

另一个能正常工作的条目: Exe = "D:\CustomGame\MPT\MyPrettyToy.exe"
这次配的条目: Exe = D:\CustomGame\<Game> v1.1.1\launch.bat

前者带引号,后者不带。而这个路径里有空格。Steam 解析一个不带引号、又含空格的路径时会在第一个空格处截断,于是它去找一个叫 D:\CustomGame\<Game> 的可执行文件,找不到,静默失败。

正常通过 Steam 的”添加非 Steam 游戏”图形界面添加时,Steam 会自动补上引号。这条记录没有引号,说明它是被手动或者被脚本写进去的。

用户在 Steam 属性里把引号加回去之后,vdf 里的 Exe 字段确实变成了带引号的形式。启动依然失败。这个 bug 是真的,修复也是真的,但它不是主线。

第二次误判:火绒#

我列了一遍进程,看到 HipsDaemonHipsTray。这是火绒安全软件的主动防御模块。

这个假设当时看起来非常有说服力,因为它能解释所有”查不到”的现象:火绒有自己的内核级驱动,拦截动作不写进 Windows 自带的 Defender 或 CodeIntegrity 日志,所以我们前面翻的所有系统日志全是空的。而”启动游戏 + 启动 Cheat Engine + 加载 CT 表”这个组合,在行为特征上跟外挂启动器几乎无法区分,被主动防御静默杀掉完全合理。

用户关掉了 HIPS。脚本照样一闪而过。

这里有一个我自己造成的问题需要说清楚。我在排查时用自己的终端环境跑过 Start-Process 去启动这个 bat,结果是零痕迹。我把这个结果当成了”用户机器上的证据”。但我在更早的时候已经测过一个全新建的、内容无害的 zz_test.bat,它在同一个环境里报了同样的错——我的终端环境本身就跑不了任何批处理文件。这是我自己沙盒的限制。我拿一个受污染的对照组当证据用,然后据此让用户去关了一次杀软。

根因:98 个 LF,0 个 CRLF#

最后我去数了换行符:

Terminal window
$b = [System.IO.File]::ReadAllBytes($path)
$lf=0; $crlf=0
for ($i=0; $i -lt $b.Length; $i++) {
if ($b[$i] -eq 0x0A) {
if ($i -gt 0 -and $b[$i-1] -eq 0x0D) { $crlf++ } else { $lf++ }
}
}

结果是 CRLF 零个,LF-only 九十八个。

这是一个彻头彻尾的 Unix 风格文本文件,而它的扩展名是 .bat

叠在上面的是第二个问题:文件是 UTF-8 无 BOM 编码,里面写满了中文注释和中文 echo 提示。中文系统的 cmd.exe 默认用 936(GBK)码页读字节,UTF-8 的多字节中文序列在 GBK 下会被解读成另一批完全不同的字符。

这两个问题合起来,正好就是脚本”一闪而过、零输出”的完整解释。

为什么 cmd.exe 这么脆弱#

我搜了一圈,这个失败模式有非常准确的公开描述。opencode 仓库的 issue #31224 里写得一字不差:

Cmd.exe requires CRLF (\r\n) line endings; LF-only causes the batch file to exit immediately with no visible error.

“立刻退出,没有任何可见错误”——这就是黑框一闪而过。

具体的机制在 Server Fault 的这个问题下面有解释。cmd.exe 的标签解析器按 512 字节分块扫描文件,而它计算块边界时假定行尾是 CRLF 两个字节。当行尾只有 LF 一个字节时,这个边界计算会产生 off-by-one 错误,解析器会跳过某些标签,甚至把注释或者随机文本误认成标签。有人在那个帖子里描述过一个典型症状:

I came across an issue where call didn’t jump to a label, but the same call later on the file did jump to the label. The file in question was using \n line endings.

同一个 call :label,在文件的一个位置能跳,在另一个位置就跳不了。这种失败没有任何规律可循,因为它取决于标签落在第几个 512 字节块的什么偏移上。

这里有个值得记一笔的巧合。我重写脚本时,把原来的 if not exist (...) 括号块全部改成了 goto :label 跳转,理由是括号块在 LF 下最容易炸。而按照上面的机制,goto 和标签恰恰是 LF 换行下最脆弱的结构。如果我只改了控制流没改换行符,这个脚本会坏得更隐蔽、更没有规律。

这个 bug 有编号#

同一个仓库的 issue #31276 把两个问题一起报了,标题直接写着 write tool 在 Windows 上生成 LF-only 的 .bat/.cmd 并且不处理非 UTF-8 码页。里面对编码那一半的描述是这样的:

UTF-8 encoded non-ASCII text, but cmd.exe interprets the bytes using the system’s active code page (e.g., 936 GBK for zh-CN)

这个 issue 还特意说明了影响范围:

Both issues affect all non-English Windows users regardless of language.

日语 932、韩语 949、俄语 1251,一个都跑不掉。

两个 issue 都开在 2026 年 6 月 7 日,都已经关闭。#31224 里给出的根因分析是:write 工具直接把内容传给写文件函数,没有做任何行尾归一化,而同一个代码库里的 edit 工具早就实现了 detectLineEnding()convertToLineEnding() 两个辅助函数。同一个仓库,同一件事,一个工具做了,另一个工具没做。

我这个 launch.bat 是 AI 生成的。它带着 LF 换行和 UTF-8 编码落到一台中文 Windows 上,然后静默失败。接下来一个小时里,同一类工具带着我依次怀疑了 Mark-of-the-Web、Smart App Control、Steam 的引号、火绒的主动防御。写坏它的和查错它的,是同一套东西。

修复#

用 CRLF 换行加 GBK 编码重写,控制流用标签跳转,所有面向用户的提示文字改成纯 ASCII:

Terminal window
$content = ($lines -join "`r`n") + "`r`n"
$gbk = [System.Text.Encoding]::GetEncoding(936)
[System.IO.File]::WriteAllText($path, $content, $gbk)

重写后的文件是 2140 字节,CRLF 六十五个,LF-only 零个。

编码这一半也可以走另一条路。issue #31276 给出的方案是在 .bat 的第二行插一句 chcp 65001 >nul,把 cmd.exe 的码页切到 UTF-8,这样源文件可以保持 UTF-8。我选了 GBK 加 ASCII 提示文字,因为它不依赖脚本运行时能成功切换码页。

日志文件立刻有了内容,两次触发都跑完了全流程:

[2026/08/06 17:10:42.90] script start
[2026/08/06 17:10:42.90] game found: ...
[2026/08/06 17:10:42.90] cheat engine found: ...
[2026/08/06 17:10:42.90] CT table found: ...
[2026/08/06 17:10:43.00] starting game...
[2026/08/06 17:10:46.19] starting Cheat Engine with CT table
[2026/08/06 17:10:46.40] all launched OK

17:10:42 那次是双击触发的,17:11:13 那次是从 Steam 点”游玩”触发的。

经验#

空日志是一条强证据,不是”没有证据”。 脚本第一行就写日志,日志为空,就说明第一行没执行。这个信息足以把所有关于脚本内容的假设一次性排除掉,而我在确认这一点之后还是花了很久去查脚本内容之外的东西。

排查时先验证你的测试工具本身。 我用一个跑不了 bat 的环境去测 bat,然后把测试结果当证据。对照组必须先自己通过对照。

日志里查到的东西,要对得上时间。 Smart App Control 那两条拦截记录是真实的、内容也确实相关,但它们只在 16:47 出现过两次,而故障每次都复现。一条不能覆盖全部故障时刻的证据,不能当根因用。

扩展名是 .bat 的文件,先看字节。 换行符和编码是文本文件的两个隐藏属性,在编辑器里完全看不出来,而 cmd.exe 对这两项的容忍度接近于零。ReadAllBytes 数一遍,比读一百遍脚本内容都快。

查了一小时杀软,根因是 98 个 LF 换行符
https://blog.lishuyu.app/posts/2026-08-06-cmd-bat-lf-crlf-silent-failure/
作者
猫猫魔女
发布于
2026-08-06
许可协议
CC BY-NC-SA 4.0