分发一个安装脚本,形态是最常见的那条:

终端窗口
irm https://example.com/install.ps1 | iex

用户跑完,屏幕上是:

Udesk æçå·¥å·
æ£å¨æ£æ¥å®è£ç®å½...

我的第一反应和大多数人一样——加个 BOM。

加完之后,脚本直接报错了:

'#' 不是可识别的标记

这就是 PowerShell 5.1 编码地狱的入口:「中文乱码」不是一个问题,是四个不同阶段的四个问题,而且修好其中一个往往会弄坏另一个。

先分清是哪一步坏的

阶段谁在解码坏了长什么样
① 下载irm / iwr 按 HTTP 响应头的 charset拉下来的字符串本身就是乱的
② 从磁盘读PowerShell 按系统 ANSI 代码页整行代码消失,一堆语法错误
③ 渲染输出控制台按当前代码页脚本对,屏幕上是乱的
④ .cmd 解析CMD 按启动前生效的代码页命令被从汉字中间劈开

BOM 只对 ② 有用,而且它会弄坏 ①。下面一个个说。

① 下载阶段:irm 在进 iex 之前就已经解码了

这是最反直觉的一层。

irm 拿到响应之后,在下载阶段就把字节解码成字符串了,依据是响应头里的 charset。而多数服务器给 .ps1 返回的 Content-Type 是 application/x-powershell——没有 charset。没有 charset 时 .NET 的 HTTP 栈回落到 ISO-8859-1,于是 UTF-8 的中文字节被逐字节当成拉丁字母。

等管道流到 iex 的时候,它拿到的已经是一个乱码字符串了。脚本里做任何事都救不回来——BOM 也不行,因为 BOM 是给「读字节」的人看的,而 irm 早就读完了。

三种修法,按可靠度排:

a. 服务器给 .ps1 加上 charset(根治)

@ps1 path *.ps1
header @ps1 {
Content-Type "text/plain; charset=utf-8"
Cache-Control "no-store, must-revalidate"
}

加完 irm … | iex 中文立刻正常。如果你能改服务器,做这个就够了,下面两条都不用。

b. 客户端显式指定解码(改不了服务器时)

终端窗口
powershell -NoProfile -ExecutionPolicy Bypass -Command "$w=[Net.WebClient]::new(); $w.Encoding=[Text.Encoding]::UTF8; iex ($w.DownloadString('https://example.com/install.ps1'))"

WebClient 允许你显式指定 .Encoding,不看响应头。代价是这条命令长得没法让人手打——只能做成可复制的一行,或者藏进 .cmd 引导器里。

c. 脚本顶部设控制台编码(这条只管 ③,不管 ①)

终端窗口
[Console]::OutputEncoding = [Text.Encoding]::UTF8
$OutputEncoding = [Text.Encoding]::UTF8

这两行治的是「脚本内容是对的,但旧版控制台按 GBK 渲染」。它救不了下载阶段已经坏掉的内容。两层要一起做,但别指望其中一层能顶替另一层。

② 从磁盘读:GBK 会把你的代码一行一行吃掉

这一层的杀伤力最大,现象也最离奇。

Windows PowerShell 5.1 从磁盘读一个没有 BOM 的 .ps1 时,按系统 ANSI 代码页解码。中文 Windows 上那是 GBK(936)。

GBK 是双字节编码。当某行中文的最后一个 UTF-8 字节恰好落在 GBK 的前导字节区间,解码器会认为「这是一个双字节字符的前半个」,于是把下一个字节——也就是行尾的换行符——一起吃掉。

换行符没了,下一行真代码就被并进了上一行的注释里,整行消失。

实测三个脚本被吞掉的行数:58 行 / 40 行 / 22 行。

唯一的好消息:它不会静默半执行

吞掉几十行之后,语法必然崩坏(实测 48 / 47 / 30 处语法错误)。而 PowerShell 是先整体解析、再执行——所以结果是「大声报错、一行都不执行」,而不是装到一半留下个坏现场。

这一点很重要,它决定了一件事:

「在脚本里加一道运行期的自纠正闸门」是无效方案。 解析阶段就崩了,闸门根本没机会跑。

我试过,撤掉了。别再想这条路。

那加 BOM 不就完了?

加 BOM 确实治好了 ②——但它会让 ① 那条 irm | iex 报 '#' 不是可识别的标记,因为 BOM 字符跟着进了脚本正文。

于是你被夹在中间:

irm | iex双击 / 从磁盘跑
有 BOM❌ 报错✅ 正常
无 BOM✅ 正常❌ 吞行

没有两全其美的编码。所以真正的解法不在编码上,而在这两条:

解法一:本地运行一律走 .cmd 引导器。 引导器显式按 UTF-8 解码再交给 PowerShell,绕开磁盘读取那条路。规矩就一句:别双击 .ps1。

解法二:新脚本写成纯 ASCII。 一个中文字符都不留,所有中文提示由别处输出或用英文。从根上让 ② 不可能发生。

后来新增的脚本我都走第二条,并且加了一道可执行的检查:

终端窗口
# 非 ASCII 字节数必须为 0
LC_ALL=C grep -cP '[^\x00-\x7F]' installer/install-asr*.ps1

把「必须遵守」变成「能被检查出来」——这比在文件头写一段警告有用得多。

顺带:行尾也别「统一」

我们两个安装脚本的行尾本来就不一样:一个 CRLF,一个 LF。看起来像是历史遗留的脏东西,很想顺手统一一下。

别动。 它们各自在各自的分发路径上验证过。git 的自动行尾转换会在你毫不知情的时候改掉文件内容——而这类脚本的问题全都是「改完当时看不出来,装到用户机器上才炸」。

用 .gitattributes 把它们钉死:

installer/*.ps1 -text
installer/*.cmd -text

-text 是「完全不做行尾转换」。加上之后 git 不再有机会替你做决定。

③ .cmd 引导器的三条铁律

CMD 用户没有 irm,所以要给一个双击就能跑的 .cmd。它只做一件事:转手调 PowerShell 跑同一份 .ps1。

业务逻辑必须留在 .ps1 里,引导器零业务逻辑。 batch 里没有 ConvertFrom-Json、没有 P/Invoke、没有 SendKeys 的对应物,硬翻一份必然行为漂移,然后你就有两个入口需要同步维护。

三条铁律,全是实测炸出来的:

铁律 1:ASCII only,一个中文都不能有

CMD 用文件开始运行之前就已经生效的代码页逐行解析。中文 Windows 上是 GBK。

UTF-8 的中文是多字节,而其中某些字节恰好撞上 CMD 的元字符——0x7C 是 |,0x26 是 &。GBK 的读法会把一行从汉字中间劈开,后半段当命令执行:

'切分这些字符:曾因注释里写了完整的' is not recognized as an internal or external command

chcp 65001 救不了它自己所在的这个文件——解析发生在它执行之前。

chcp 65001 仍然要写,但它的作用只是让后面 .ps1 输出的中文在这个控制台窗口里渲染正常。

铁律 2:别在注释里粘完整命令行(但原因可能不是你以为的那个)

我们曾因为注释里写了完整的 irm ... | iex 示例,得到过这样一条报错:

'ringhost.org' is not recognized as an internal or external command

当时的结论是「rem 不保护元字符,管道符照样被解析」,并把它写成了一条铁律。

写这篇文章时我去复现,没复现出来。 在 Windows 11 的 CMD 上,rem 行里的 |、&、>、< 四种字符,顶层和括号块内各试一遍,全都被正常保护,没有任何副作用:

终端窗口
@echo off
rem see irm https://example.com/x.ps1 | iex
echo OK
OK ← 没有报错,没有多出文件

再看那条报错本身:'ringhost.org' 是从 turinghost.org 中间劈开的。这个断点不落在任何元字符上——它落在 URL 的字母中间。

而「把一行从中间劈开」正是铁律 1 的特征:那一行有中文注释,某个非 ASCII 字节在 GBK 下被吃掉,行就断在了那里。

所以更可能的真相是:当初那次失败是铁律 1,被归因成了铁律 2。

(我无法在自己机器上完成最后一步验证——原故障发生在默认 GBK 控制台的中文 Windows 上,而我这台默认 UTF-8,在 cmd /c 里临时 chcp 936 并不是一个忠实的复现条件。所以上面这段是「已排除的部分 + 一个更合理的解释」,不是定论。)

实践上的建议不变:别在 .cmd 的注释里粘完整的命令行。但理由换成了——

  • 那一行往往又长又带非 ASCII,正好是铁律 1 最容易命中的地方;
  • 而且它没什么用,读注释的人不会去复制它。

要写就写纯文字描述,或者把示例放进 .ps1(那边不受这套解析影响)。

这条留在这里不删,因为它本身是个例子:一条「实测出来的」铁律,也可能只是对现象的一个未经验证的解释。 而这类错误特别顽固——它有一个真实的故障做背书,所以没人会去质疑它。

铁律 3:但真正的操作符不能转义

终端窗口
rem 正确:这里的 | 和 && 是真操作符,必须是裸的
echo %CMDCMDLINE% | find /i "/c" >nul && pause

把它们「保险起见」转义成 ^&^&,find 会把 && 和 pause 当成文件名,报 File not found - &&。

这条和铁律 2 正好是一对:注释里的元字符大概率无害,而代码里的操作符转义了必然有害。 「保险起见都转义一下」是个很自然但错误的直觉。

一份能跑的验证清单

改完这些文件,光看一眼是没用的——上面每一个坑都是「当时看着没问题」。

终端窗口
# 1) 语法能解析(不执行)
powershell -NoProfile -Command "[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path 'installer/install.ps1').Path, [ref]$null, [ref]$errs); $errs.Count"
# 2) 没有被偷偷加上 BOM(前三字节不能是 efbbbf)
head -c 3 installer/install.ps1 | xxd -p
# 3) .cmd 的非 ASCII 字节数必须为 0
LC_ALL=C grep -cP '[^\x00-\x7F]' installer/i.cmd
# 4) 扫注释里的裸元字符(现代 CMD 大多能保护,但这条能顺带揪出
# 「注释里粘了完整命令行」这种铁律 1 的高危写法)
grep -nE '^(rem|echo)' installer/i.cmd | grep -E '[^^][|&<>]' | grep -v '>nul'

但这四条全过也只说明「没坏在已知的地方」。 还得真机跑一次完整安装——语法过 ≠ 装得上。

最后一条:不是所有报错都是脚本的错

CMD 那条引导链路报过一个 Access is denied(rc=5)。

我顺着脚本查了六七轮:怀疑 chcp、怀疑 iex、怀疑控制台编码、怀疑安全软件拦截——全是错方向。

真相是:UAC 弹窗没点「是」。

后来在给用户的说明里直接写了这一条。而给我自己的教训是:

见到 Access is denied,先问「有没有弹窗,点了吗」,再去查代码。


回头看,这一整套麻烦的根源是:一个文本文件,在它的生命周期里会被至少四个不同的东西解码,而它们各自的默认值互不相同,且都不告诉你用了什么。

所以排查的第一步永远不是「改编码试试」,而是确定坏在哪一步:是拉下来就乱了,还是读文件时少了行,还是只有屏幕上显示不对。这三种现象的修法完全不同,而它们在用户口中都叫「乱码」。