分发一个安装脚本,形态是最常见的那条:
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 *.ps1header @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 字节数必须为 0LC_ALL=C grep -cP '[^\x00-\x7F]' installer/install-asr*.ps1把「必须遵守」变成「能被检查出来」——这比在文件头写一段警告有用得多。
顺带:行尾也别「统一」
我们两个安装脚本的行尾本来就不一样:一个 CRLF,一个 LF。看起来像是历史遗留的脏东西,很想顺手统一一下。
别动。 它们各自在各自的分发路径上验证过。git 的自动行尾转换会在你毫不知情的时候改掉文件内容——而这类脚本的问题全都是「改完当时看不出来,装到用户机器上才炸」。
用 .gitattributes 把它们钉死:
installer/*.ps1 -textinstaller/*.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 commandchcp 65001 救不了它自己所在的这个文件——解析发生在它执行之前。
chcp 65001 仍然要写,但它的作用只是让后面 .ps1 输出的中文在这个控制台窗口里渲染正常。
铁律 2:别在注释里粘完整命令行(但原因可能不是你以为的那个)
我们曾因为注释里写了完整的 irm ... | iex 示例,得到过这样一条报错:
'ringhost.org' is not recognized as an internal or external command当时的结论是「rem 不保护元字符,管道符照样被解析」,并把它写成了一条铁律。
写这篇文章时我去复现,没复现出来。 在 Windows 11 的 CMD 上,rem 行里的 |、&、>、< 四种字符,顶层和括号块内各试一遍,全都被正常保护,没有任何副作用:
@echo offrem see irm https://example.com/x.ps1 | iexecho OKOK ← 没有报错,没有多出文件再看那条报错本身:'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 字节数必须为 0LC_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,先问「有没有弹窗,点了吗」,再去查代码。
回头看,这一整套麻烦的根源是:一个文本文件,在它的生命周期里会被至少四个不同的东西解码,而它们各自的默认值互不相同,且都不告诉你用了什么。
所以排查的第一步永远不是「改编码试试」,而是确定坏在哪一步:是拉下来就乱了,还是读文件时少了行,还是只有屏幕上显示不对。这三种现象的修法完全不同,而它们在用户口中都叫「乱码」。