理清 Windows 的文字编码 - 乱码为什么会发生,尤其是和 Linux 搭配时容易出问题的地方
· 小村 豪 · Windows, 乱码, UTF-8, CP932, Linux, PowerShell, Unicode
Windows 的乱码,并不是因为日文难处理才发生的。大多数情况下,原因是 把同一组 bytes 当成了另一种文字编码来读,或者 把误读后的结果又用另一种编码保存了回去。
尤其是当 Windows 和 Linux 交叉使用时,Windows 一侧同时残留着 CP932、UTF-8、UTF-16、console 的 code page、PowerShell 版本差异等多重语境,而 Linux 一侧大多以 UTF-8 为前提运转,于是平时不曾显现出来的前提差异,就会一次性浮现出来。
与其说这是日文处理本身的难度问题,不如说这是「以什么前提在处理 bytes」是否统一的问题。本文从「为什么会发生乱码」这个角度整理 Windows 的文字编码相关知识,并特别把与 Linux 搭配时容易出事的要点,按实务角度归纳出来。
1. 先要把握的要点
先把要点写在前面,重要的是下面这 6 点:
- 乱码不是「文字」的问题,而是「字节序列被如何解释」的问题。
- Windows 中 Unicode 系与 legacy code page 系并存,同一台机器内,不同语境下的前提也不一样。
- Linux 一侧以 UTF-8 为前提的情况较多,所以一旦混入 Windows 侧的 CP932 或 UTF-16,就容易出事。
- 只是显示错乱的阶段 和 把已损坏的内容保存下来的阶段 应该分开来看。
- 新建文本以 UTF-8 为第一候选,既有的 legacy 文件在有明确迁移任务之前先维持现状是比较安全的做法。
- 文件的 encoding、编辑器的 encoding、console 的 code page、应用内部的字符串格式是不同的层。把这些混为一谈,调查就会迷路。
单说「在 Windows 上乱码了」这句话本身无法确定原因。至少需要分清楚下面哪一层出现了偏差:
- 文件本身的文字编码
- 保存时的文字编码
- 编辑器的解读
- console 的 input/output code page
- 应用内部的字符串格式
- Linux 一侧的 locale 与预期的 encoding
2. 乱码的本质
乱码的本质相当单纯。
- 把字符串以某种文字编码 encode 成字节序列
- 把该字节序列以某种文字编码 decode 还原成字符串
- 如果 encode 与 decode 的前提不一致,就会被读成另一个字符串
例如,把 あ 以 UTF-8 保存,字节序列会是:
E3 81 82
如果把这个字节序列当作 UTF-8 读取,就是 あ,但如果在 CP932 的语境下读取,就会看成 縺� 这样完全不同的字符串。这就是乱码。
重要的是,这里发生的并不是「日文本身坏了」,而只是 对同一组 bytes 的解释产生了偏差。
2.1 只是显示错乱的话,有时还能挽回
乱码中存在还能挽回的阶段。例如,只要 原本的 bytes 没有改变,用正确的 encoding 重新打开,有时是可以还原的。
反过来危险的是下面这样的流程:
- 把 UTF-8 的文件误读为 CP932
- 画面上看起来像
縺� - 就这样把「看到的字符串」保存下来
- 原本的 UTF-8 bytes 就丢失了
一旦进入这个阶段,就不再是单纯的显示错乱,而是 数据损坏。
2.2 更危险的是把「无法表示的字符」写入狭窄的 code page 时
另一个典型事故,是把 Unicode 字符串写入 CP932 这类 legacy code page 的时候。
例如,如果包含了对方 code page 中不存在的字符,就会发生:
- 被替换成
? - 混入替换字符
� - 被转换成相近的其他字符
- 转换失败
这类情况。
这个事故不应只看 能不能读,而应该看 往返转换后能否还原。一旦丢失的字符,之后即便知道了正确的 encoding,也无法复原。
3. 为什么 Windows 上容易变得复杂
Windows 之所以复杂,并不只是因为它历史悠久。而是因为 Unicode 的世界和 legacy code page 的世界,至今仍然共存。
3.1 Windows API 中 Unicode 系与 code page 系并存
Windows API 大致分为两大系统:
W系:wide character,用 UTF-16 处理 Unicode 的系列A系:被称为 ANSI 的 code page 系列
也就是说,在 Windows 内部,从一开始就同时存在「用 Unicode 处理」和「用当时的 active code page 处理」这两条路。因此,即使在同一台 Windows 机器上,也会因为 经过了哪个 API 或哪个工具 而使前提发生变化。
3.2 「Windows 的日文」并非只有一种
在 Windows 的日文相关问题上,实务中经常混杂的是以下这 4 种:
- CP932:常见于日文 Windows 的 legacy text
- UTF-8:在新的文本资产、web、跨平台系统中越来越多
- UTF-16LE:在 Windows 系工具或 API 的语境中,现在依然普遍出现
- console 的 code page:影响
cmd.exe和部分 console 工具输入输出的另一层
这里重要的是,并不是执行了 chcp 65001 之后文件就变成 UTF-8 了。改变 console 的 code page 和现有文件的 bytes 是什么,是两个不同的问题。
另外,虽然常常把日文 Windows 的 legacy text 笼统地称为「Shift_JIS」,但在实务中,用 CP932 这个名称来意识它,交流起来会更不容易出偏差。至少能明确表示「现在讨论的是源自 Windows 的日文 legacy encoding」。
3.3 文件名和文件内容是两个不同的问题
在 Windows 上,如果日文文件名显示得很正常,就容易让人觉得「那内容应该也没问题吧」。这一点很危险。
- 处理 path / 文件名的层
- 读取文件内容的层
- 在 console 中显示的层
这 3 者是不同的。
举例来说,即使日文 path 能够正常处理,但如果文件内容以 CP932 保存,而在 Linux 侧被当作 UTF-8 读取,内容仍然会损坏。反过来,即使文件内容是 UTF-8,如果 console 的 code page 不匹配,也只会导致显示错乱。
3.4 PowerShell 及周边工具的默认值也并不统一
在 Windows 上悄悄增加事故的一个原因是,即使自认为写的是同一份「文本」,输出的 bytes 也会因路径不同而不同。
特别要注意的是这些:
- Windows PowerShell 5.1 的默认 encoding 并不统一
- 部分 cmdlet 或 redirection 会生成 UTF-16LE
- 另一些路径则使用 active ANSI code page
- PowerShell 7 及以后 默认是 UTF-8 no BOM
也就是说,仅凭「用 PowerShell 输出的文本」这一点,是无法确定 encoding 的。必须看清楚是哪个版本、哪个 cmdlet、走了哪条写入路径。
4. 与 Linux 组合使用时的典型事故
单独在 Windows 上运转还算凑合的东西,一旦夹入 Linux 就突然坏掉,这并不罕见。理由很简单,因为 Linux 一侧 以 UTF-8 为前提的情况居多。
4.1 Windows 以 CP932 保存的文本被 Linux 当作 UTF-8 读取
这是最常见的事故。
- Windows 的 legacy app 或旧有运维流程以 CP932 写入 CSV / TXT / log
- Linux 一侧的脚本或工具按照 locale,以 UTF-8 为前提读取
- 结果出现 decode error、
�、莫名其妙的字符串
这时并不是 Linux 一侧的工具有问题,根本原因在于 接收到的 bytes 没有附带 encoding 的约定。
4.2 Linux / VS Code 制作的 UTF-8 no BOM 被 Windows 一侧当作 ANSI
反方向的事故也存在。
- 在 Linux 或 VS Code 上制作 UTF-8 no BOM 的脚本 / 配置 / 文本
- Windows PowerShell 5.1 或 legacy 工具把没有 BOM 的文件当作 ANSI 侧的 code page
- 只有包含日文或 non-ASCII 的行会损坏
这里容易被当成「罪魁祸首」的是 UTF-8,但实际原因是 混入了无法正确推断无 BOM UTF-8 的读取方。
4.3 Windows 一侧写入 UTF-16LE,Linux 一侧「看起来不像文本」
这种情况也相当常见。
- Windows PowerShell 5.1 的部分输出或 legacy 工具写入 UTF-16LE
- Linux 一侧的文本工具假设的是 UTF-8 的单字节 stream
- 结果变成混杂大量 NUL byte 的「类似二进制的文本」
UTF-16LE 本身并没有问题。只是很多场合下,它与直接流入 Linux 文本处理工具的前提并不吻合。
4.4 是否带 BOM 也会引起摩擦
BOM 本身并不是 encoding,但在实务中影响相当大。
- Windows 一侧的部分工具,有 BOM 会更省心
- Linux 一侧的部分工具,会把 BOM 当作开头多出来的字节
- 结果就是第一列或第一行开头损坏、混入看不见的垃圾字符、比较结果产生偏差
尤其在 UTF-8 中,即使同样是 UTF-8,有无 BOM 的 bytes 也是完全不同的东西。仅仅「统一成 UTF-8」,作为运维规则也才决定了一半。
4.5 只相信 console 的显示会迷失方向
在跨越 Windows 和 Linux 时,另一个危险的地方是 console。
- Windows console 有 input / output 的 code page
- Linux terminal 一侧大多按照 UTF-8 locale 运转
- 经过 WSL、SSH、container、CI 时,显示路径会增多
在这种状态下,「因为 console 能读,所以文件也没问题」或「因为 console 显示乱了,所以文件坏了」这样的判断很容易出错。画面上看到的东西是否损坏,和保存下来的 bytes 是否损坏,应该分开确认,这样更安全。
4.6 把典型事故整理成表格
| 场景 | 实际的 bytes | 读取方的假设 | 典型症状 |
|---|---|---|---|
| Windows 的 legacy app 保存的 CSV | CP932 | Linux 一侧假设为 UTF-8 | �、decode error、莫名其妙的日文 |
| 在 Linux / VS Code 制作的文件 | UTF-8 no BOM | Windows PowerShell 5.1 当作 ANSI 处理 | 只有日文行损坏 |
| Windows PowerShell 5.1 的部分输出 | UTF-16LE 或 ANSI | Linux 一侧期待 UTF-8 文本 | 混入 NUL byte,行为类似二进制 |
| 带 BOM 的 UTF-8 文件 | UTF-8 + BOM | Unix 系工具假设为纯 UTF-8 | 只有开头列损坏,混入多余字符 |
| 只相信 console 的显示 | 文件与 console 的前提不同 | 调查者仅凭显示做判断 | 原因排查方向出错 |
5. 用这 4 个问题推进乱码调查
如果在乱码调查中迷失方向,回到下面这 4 个问题是最快的方式。
5.1 原本的 bytes 是什么
最先应该看的是「这个文件现在究竟是什么 bytes」。需要具备看 bytes 而不是看外观的意识。
- 是 UTF-8 吗
- 是 UTF-8 with BOM 吗
- 是 CP932 吗
- 是 UTF-16LE 吗
- 中途是否被重新保存而变成了别的东西
5.2 最初是谁、以什么前提写入的
接下来,要确定「最初的书写者」。
- 是 Windows 的 legacy app 吗
- 是 PowerShell 5.1 还是 7
- 是 Linux 的脚本吗
- 是 VS Code 吗
- 是来自 Excel 的 export 吗
- 是某种 middleware / batch / CI 吗
如果这里含糊不清,encoding 的推断就会变成靠运气。
5.3 现在是谁、以什么前提读取的
除了书写者之外,读取者的前提也需要确认。
- 编辑器是否在自动检测(auto-detect)
- PowerShell 是否在看 BOM
- Linux 一侧是否按照 locale 当作 UTF-8 处理
- library 是否使用了默认 encoding
- 是否明确指定了
Encoding.UTF8或cp932
乱码几乎都在这一环节发生。
5.4 读错的内容是否已经被保存
最后,要确认 损害是否止步于显示层面。
- bytes 是否仍然保持原样
- 是否有人把看起来已经损坏的内容保存了下来
- 差分中是否混入了
?或� - 全文是否已经被用另一种 encoding 重写
只要把这 4 个问题都填满,大多数情况下原因就能看清楚。
6. 减少事故的运维规则
接下来是偏实务的内容。在跨越 Windows 和 Linux 的项目中,如果一开始就先定好下面这些规则,事故会明显减少。
6.1 新建文件以 UTF-8 为第一候选
新建的文本文件,先以 UTF-8 为第一候选是比较稳妥的做法。但不能止步于此,还需要连 BOM 怎么处理一起决定好。
推荐的决定方式是:
- 主要由 Linux 一侧读取的文本:以 UTF-8 no BOM 为基本原则
- Windows 的 legacy 工具或 Windows PowerShell 5.1 会读取的脚本:按对方的需要明确指定是否带 BOM
- 确实有明确需要 UTF-16LE 的对象时,就把这个要求写进规格说明里
只写「统一成 UTF-8」,之后一定会在 BOM 上产生争议。
6.2 既有的 legacy 文件维持现状,直到有明确的迁移任务
如果既有文件是 CP932,不要在日常功能修改时顺手转换成 UTF-8,这样更安全。
运维上比较安全的形式是:
- 既有文件维持 原本的 encoding / BOM / 换行符
- encoding 的变更,作为 独立的迁移任务 分离出来
- 先确认转换对象、影响范围、下游 consumer,再进行统一转换
许多乱码事故,都是从善意的「顺手统一成 UTF-8」开始的。
6.3 把 encoding 当作 interface 的一部分来对待
CSV、TXT、log、配置文件、简易 protocol,不仅内容本身,encoding 本身就是一种 interface。
例如,至少应该把下面这些写进规格说明里:
- 这个文件是 UTF-8 / CP932 / UTF-16LE 中的哪一种
- 如果是 UTF-8,是否带 BOM
- 换行符是 LF 还是 CRLF
- Linux / Windows 哪一方是 producer / consumer
- 中途的 batch 或 ETL 是否会重新保存
「用 text 传递」本身不能算作规格。
6.4 不要相信默认值,写入时要明确指定
无论是在代码里还是脚本里,明确指定 encoding 都会更安全。
危险的想法是这样的:
- 保持默认设置直接保存
- 觉得配合 OS 大概就没问题了
- 觉得 console 能读,文件应该也没问题
- 觉得有 auto-detect 所以没问题
默认值会因 Windows / Linux、PowerShell 5.1 / 7、编辑器、runtime 的不同而普遍发生变化。除非明确指定,否则很容易只是「碰巧能用」而已。
6.5 把 console 和文件分开确认
一个不起眼但相当有效的规则是这个。
- 在 console 上的显示确认
- 重新打开文件后的确认
把这两者分开。
即使 chcp 或 terminal 的显示是对的,如果保存下来的文件是另一种 encoding,那也没有意义。反过来,即使文件本身正常,如果 console 显示用的 code page 不匹配,也只会导致外观上的错乱。
6.6 Git 不会替你修正 encoding
这一点不起眼,但很重要。
Git 基本上只会 追踪 bytes。也就是说,即使是损坏的 bytes,它也会原样认真地记入历史。
因此,当出现:
- 明明什么都没改却出现巨大差分
- 只有日文行出现莫名其妙的差分
- 只有开头行发生变化
- 换行符和 encoding 同时发生变化
这类情况时,应该先怀疑 re-encoding 事故,而不是先怀疑内容本身发生了变化。
7. 最低限度的检查清单
在混合 Windows 与 Linux 的项目中,先把想固定下来的检查清单放在这里。
7.1 编辑前
- 这个文件目前的 encoding 是什么
- 是否带 BOM
- 换行符是 LF 还是 CRLF
- 是否已记录 2〜3 行有代表性的日文内容
- 是否清楚最终 consumer 是 Linux 一侧还是 Windows 一侧
7.2 编辑中
- 是否依赖默认 encoding 进行了写入
- 是否把 save 交给了 auto-detect
- 是否随意使用了 PowerShell 或 shell redirection 的路径
- 是否仅凭「显示能读」就放心了
7.3 编辑后
- 保存后是否重新打开确认过
- Linux 一侧和 Windows 一侧的代表行是否都没有损坏
- 差分中
?或�是否有增加 - 是否只有开头行或开头列损坏
- 是否变成了只有 BOM / 换行符的巨大差分
7.4 应该作为迁移任务处理的事项
- CP932 → UTF-8 的批量转换
- 统一 UTF-8 BOM policy
- 盘点以 PowerShell 5.1 为前提的脚本
- 明文化经过 CI / container / WSL / SSH 的文本传递路径
- 统一编辑器 / formatter / batch 的保存设置
8. 总结
如果用一句话概括 Windows 的文字编码问题,本质就是 Unicode 的世界和 legacy code page 的世界,至今仍然共存。
而与 Linux 搭配时事故增多的原因,是因为 Linux 一侧大多以 UTF-8 为前提运转,于是 Windows 一侧的 CP932、UTF-16、console code page、PowerShell 版本差异,就会一次性浮现出来。
想要记住的是下面这 5 点:
- 乱码是 bytes 解释上的偏差
- 显示错乱和数据损坏是两件不同的事
- 在 Windows 上,要把文件 / 编辑器 / console / API 这几层分开来考虑
- 与 Linux 交互的文本以 UTF-8 为第一候选
- 既有 legacy 文件的转换,要和常规改修分开处理
如果直接处理「在 Windows 上乱码了」这句话,话题会太宽泛。但只要用:
- 原本的 bytes 是什么
- 是谁、怎么写的
- 是谁、怎么读的
- 是否已经被保存
这 4 个问题去切分,就能大幅理清思路。
文字编码看起来不起眼,但在 Windows 与 Linux 之间,它就是 I/O 契约本身。不让这一点变得含糊,才是最有效的对策。
9. 参考资料
Windows / Microsoft
-
[Code Pages - Win32 apps Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/intl/code-pages) -
[Code Page Identifiers - Win32 apps Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/intl/code-page-identifiers) -
[Unicode in the Windows API - Win32 apps Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/intl/unicode-in-the-windows-api) -
[Console Code Pages - Windows Console Microsoft Learn](https://learn.microsoft.com/en-us/windows/console/console-code-pages) -
[chcp Microsoft Learn](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/chcp) -
[Use UTF-8 code pages in Windows apps Microsoft Learn](https://learn.microsoft.com/en-us/windows/apps/design/globalizing/use-utf8-code-page)
PowerShell / VS Code
-
[about_Character_Encoding Microsoft Learn](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_character_encoding) -
[Understanding file encoding in VS Code and PowerShell Microsoft Learn](https://learn.microsoft.com/en-us/powershell/scripting/dev-cross-plat/vscode/understanding-file-encoding)
GNU / Linux locale
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Windows 的文字编码与换行符 - 乱码与 CRLF/LF 的基础
本文整理 Windows 上容易混淆的 Shift_JIS / UTF-8 / UTF-16、乱码、CRLF / LF 之间的区别,用便于实务判断的方式呈现。
在 Windows 环境下减少 Codex 乱码事故的最佳实践 - 先把『指示方式』钉住,再谈环境整备
本文整理在 Windows 上让 Codex 安全处理日文文件的指示原则:读文件前先确认 encoding 与 BOM,疑似乱码禁止臆测保存,现有文件维持原状,仅新建采用 UTF-8,并在写入后重新读取代表性日文行验证,把规则沉淀进 AGENTS.md 以减少事故。
任务计划程序的任务不执行、以 0x1 结束 ── 原因排查与安全的运维设计
本文整理 Windows 任务计划程序的执行账户与登录类型、「不管用户是否登录都要运行」的含义、以 0x1 结束的典型原因、启用历史记录、防止多重启动、PowerShell 脚本的正确调用方式,直到把定期执行真正落实到运维上的设计方针。
在 C#(CSharp)中执行 PowerShell 并以对象形式接收结果
本文从实务角度整理如何从 C# 启动 PowerShell,并以 PSObject 而非字符串来接收结果,涵盖 PowerShell SDK、AddCommand、AddParameter、BaseObject、Properties 直至错误处理。
用 Pester 完善 PowerShell 测试 ── 让运维脚本不易损坏的实务方法
本文整理用 Pester v5 测试 PowerShell 脚本的实务步骤,涵盖日期处理、文件操作、删除处理、Mock,一直到 CI 执行,帮助安全地打好测试基础。
常见问题
汇总了咨询这一主题时常见的问题。
- Windows 上发生乱码的原因是什么?
- 大多数原因是把同一组 bytes 当成了另一种文字编码来读取,或者把误读后的结果又用另一种编码保存了回去。例如「あ」以 UTF-8 保存的字节序列(E3 81 82),如果在 CP932 的语境下读取,就会看成「縺」这样完全不同的字符串。这并不是因为日文难处理才发生的,其本质是 encode 和 decode 的前提不一致。
- 乱码的文件还能恢复吗?
- 只要原本的字节序列没有被改变,用正确的 encoding 重新打开有时是可以恢复的。危险的是把误读后看起来已经损坏的内容直接保存回去,一旦进入这个阶段,就不再是显示错乱,而是数据损坏了。另外,如果把 Unicode 字符串写入 CP932 这类狭窄的 code page,变成「?」或替换字符,那些一旦丢失的字符,即使之后知道了正确的 encoding 也无法恢复。
- 执行 chcp 65001 之后文件就会变成 UTF-8 吗?
- 不会。改变 console 的 code page,和现有文件的字节序列本身是什么,是两个不同的问题。在 Windows 上,文件本身的文字编码、编辑器的解读、console 的 input/output code page、应用内部的字符串格式是各自独立的层,需要分开来考虑。「因为 console 能读,所以文件也没问题」这种判断很容易出错,因此应该把 console 显示的确认,和重新打开文件的确认分开进行。
- 在 Windows 和 Linux 之间交换文本时,有哪些安全的规则?
- 新建文件以 UTF-8 为第一候选,并要连 BOM 的处理方式一起决定好。主要由 Linux 侧读取的文本,以 UTF-8 no BOM 为基本原则;如果是 Windows PowerShell 5.1 等 legacy 工具会读取的情况,就按对方的需要明确指定是否带 BOM。现有的 CP932 文件不要在日常改修时顺手转换,而应该作为明确的迁移任务单独处理。另外,CSV 和日志的 encoding 本身就是一种 interface,把 encoding、BOM、换行符写进规格说明里,能减少事故。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。