为什么Windows上C语言的printf会输出乱码?
乱码的直接原因和解决方法
Windows上的cmd控制台本质上也是一个程序,我的C语言代码最后也是一个程序(假设UTF-8编码)。我的C语言程序会调用Windows中的C语言运行库CRT,但是CRT有一个很重的历史包袱:它死活不承认 UTF-8 是一种合法的本地化(Locale)环境,如果你强行给它传 UTF-8,一旦涉及到稍微复杂的字符处理(比如宽字符 wchar_t 转多字节、正则表达式、时间格式化等),这个CRT就会按照它默认的 ANSI(大陆是GBK,台湾是Big5)规则去胡乱解释你的 UTF-8 数据,到传给控制台的时候程序已经是GBK转码后的数据了 这自然就乱码了。
到了 2018 年(Windows 10 1803 版本),微软终于把CRT重构为了通用C 运行库 (UCRT),加入了对 UTF-8 的全面支持。也就是说我们终于可以从setlocale(LC_ALL, "");变成setlocale(LC_ALL, "zh_CN.UTF-8");了。现在只要你的源代码是UTF-8且setlocale("UTF-8"),你的程序就可以正确输出了。而原来只能是源代码GBK+setlocale("")才能正确输出,这种做法是跨区域特别麻烦,因为其它地区的系统不一定是GBK编码 也有可能是Big5码。也就是说你下载了一个源代码项目,但你还需要对他转换字符编码为你那个地区的字符编码后才能正常显示,真离谱。
乱码的根本原因:为什么CRT死活不承认UTF-8呢?
1. Windows的误判
Windows NT 的核心架构设计始于 1989 年。Unicode 联盟在 1991 年 发布了 Unicode 1.0.0 标准(即 16 位的定长编码UCS-2)。微软当时作为技术先锋,立刻决定将这个最新的全球化标准写入操作系统内核。而 UTF-8 是大名鼎鼎的 Ken Thompson 和 Rob Pike(Go 语言的创造者)在 1992 年 9 月 的一个新泽西州的餐厅餐巾纸上刚刚设计出来的。很明显,1989年的微软无法预判到1992年的UTF-8。
- 所以在1989年~1991年的微软看来,UTF-16 才是内存中处理字符的正统格式,而 UTF-8 仅仅是一种全新的字符格式,这种字符格式只能用于“网络传输和文件存储的序列化格式”,不应该用作程序的运行时内部编码。简而言之就是,你可以存储和传输UTF-8的文件,但是Windows系统内部不能直接使用UTF-8,除非你转成UTF-16。
- 另一个很明显的一个现实考虑是,定长编码在字符串的计算上显然会更快,时间复杂度O(1)。对于操作系统的内核和底层 API 来说这简直是完美的方案。微软当时为了追求极致的内核性能,拥抱这个“定长”的乌托邦似乎也是合理的。
分隔符岔开的这段我们讲点历史往事,不归属于“乱码的根本原因”这一节:
今天我们知道 6 万多个位置根本不够用,但在 1990 年代初,语言学家和计算机科学家们做过盘点,认为把世界上所有正在使用的语言塞进去,65,536 个坑位绰绰有余(当时甚至还预留了一万多个空位)。
但注意:这不是微软一家的误判,而是整个科技行业的集体误判。
如果你去看看那个年代诞生的其他伟大的技术,你会发现他们全都做出了和微软一模一样的选择,将 16 位字符作为底层标准:
Apple 的 Mac OS X 底层核心框架(Core Foundation / Cocoa 的 NSString 也是基于 16 位的)
Java 语言(1995年发布,内部使用 char 为 16 位)
JavaScript 语言(1995年发布,至今 String 依然是 16 位序列)
后来的事情大家都知道了,学者们要求把古代的死文字(如甲骨文、古埃及象形文字)加进去、中日韩的生僻字比想象中多得多(各种罕见人名、地名)、Emoji(表情符号)的爆发。Unicode 的 65,536 个坑位迅速爆满。Unicode 联盟为了扩充容量,被迫违背了最初“纯定长”的承诺,引入了 “代理对 (Surrogate Pairs)” 的概念。也就是说,原本 2 个字节能表示的就用 2 个字节,超出的部分用 4 个字节来表示。这标志着 原本定长的UCS-2 正式变异成了 不定长的UTF-16。
这一变,直接击碎了微软当初的梦想: UTF-16 变成了和 UTF-8 一样的“变长编码”,彻底失去了 O(1) 快速索引的性能优势;同时,它又比 UTF-8 更加浪费内存(纯英文文本用 UTF-8 只占 1 字节,用 UTF-16 依然占 2 字节),还附带了大小端(Endianness)的麻烦问题。
当时微软的情况是 内核是定长的。如果强行把内核换成 UTF-8(8 位基础单位),所有原有的指针运算、内存分配全都会出错。这相当于要把整个 Windows 操作系统和其上运行的几十年积累的软件全部推翻重写。即便是微软,也承受不起这种级别的断代(参考当年 Python 2 升级 Python 3 的惨烈程度,而操作系统的难度要大上成百上千倍)。既然内核没法改,微软最终选择了一条“曲线救国”的道路——Windows 采用了“表里不一”的架构:
“表” (应用层与 C 运行库): 全面拥抱 UTF-8。通过 Manifest 清单机制和新版 UCRT,微软在应用层和内核之间铺设了一条高速翻译通道。
“里” (内核与系统 API): 依然死死守着 UTF-16。只要你是和 Windows 底层打交道,系统内部流转的数据依然是 UTF-16(变长),到今天依然是这样。
那其它系统如Apple、Java、JavaScript,他们是如何面对这种改革的阵痛的呢?
Java 和 JavaScript至今还在流血
Java 和 JavaScript 诞生于 1995 年左右,那时候它们把底层的字符类型(char)死死地定义为 16 位。当 UTF-16 被迫引入“代理对”(用 4 个字节/2个 char 来表示一个 Emoji)后,这两个语言的基础 API 直接“半身不遂”了。
最经典的痛点就是“长度算不准”和“字符串被劈开”,如果你在 JavaScript 或老 Java 代码里处理 Emoji,会发生极其反直觉的事情。
长度谬误: 你输入一个简单的苹果 Emoji 🍎,系统会告诉你它的长度是 2。JavaScriptconsole.log('🍎'.length); // 输出 2!
更离谱的组合 Emoji: 像 👨👩👧👦(一家四口)这种 Emoji,实际上是由 4 个单人 Emoji 加上几个零宽连字符拼起来的。在 JS 里,'👨👩👧👦'.length 的结果竟然是 11!
字符串截取灾难: 如果你限制用户输入 10 个字,用户输入了 9 个中文字符 + 1 个 🍎,系统会判定长度为 11,直接拒绝。如果你强行截取前 10 个字符(相当于把 🍎 从中间劈开),最后那个字符就会变成一个乱码问号(“)。
他们的补救措施(打补丁): 没法推倒重来,只能硬着头皮加新 API。
Java 引入了一大堆带 codePoint 字眼的新方法(比如 codePointCount),告诉程序员:“别用老方法 length() 算长度了,用新方法才能把 Emoji 算作 1 个字。”
JavaScript (ES6) 则引入了 for...of 循环、Array.from() 等新语法来正确识别这种 4 字节字符。但哪怕是今天,无数没经验的前端程序员依然会在 Emoji 上栽跟头。
苹果 (Apple):从痛苦到“壮士断腕”的重生
苹果早期的核心框架(Foundation 里的 NSString)也是基于 16 位的。在很长一段时间里,Objective-C 程序员开发 iOS 和 Mac 软件时,面对 Emoji 也是痛苦不堪,经常出现截取字符串导致 App 崩溃的 Bug。但苹果做了一件微软和 Java 都不敢做的事情:借着推出新语言 Swift 的机会,彻底推翻重来。
苹果因为对生态有极强的控制力,成功通过 Swift 完成了从 UTF-16 到 UTF-8 的完美转身。
为什么 Linux 没这些破事?
在微软、Java、苹果都在 16 位泥潭里挣扎的时候,Linux/Unix 系统却躺赢了。因为 Unix 诞生得极早(70年代),它的哲学极其简单粗暴:“我不管你是什么字符,在我眼里全是一串一串的字节(8位)(也就是 C 语言的 char*)。”当 UTF-8 这种基于 8 位变长的编码出现时,Linux 简直是无缝衔接。Linux 系统连底层 API 都不用改,直接把传进来的字节流当成 UTF-8 解析就行了。这也是为什么开源世界、互联网服务器(大部分运行在 Linux 上)从一开始就自然而然地拥抱了 UTF-8,几乎没有经历过 Windows 那种痛苦的“编码阵痛”。
从某种角度来看,用户喜欢苹果,开发者喜欢Linux。似乎一切都很合理。
2. A版与W版 API 的“双轨制”
为了让早期的程序员的 ANSI 程序(使用本地编码,如 GBK 的 CP936、Big5码)能在纯 UTF-16 的 Windows NT 上运行,微软设计了一套双轨制 API:
- W 版 (Wide): 接收 UTF-16 字符串(如
MessageBoxW),直接与内核交互。 - A 版 (ANSI): 接收
char*字符串(如MessageBoxA)。系统会根据当前的活动代码页 (Active Code Page),将其翻译成 UTF-16,然后再调用 W 版。
Windows 的C语言运行库CRT(如 printf、fopen 等接收 char* 的标准 C 函数)底层调用的正是 A 版 API 。由于微软官方的态度一直是“如果你想写国际化程序,就请使用 W 版 API 和宽字符”,所以他们长期没有动力去让基于A版的CRT去完美兼容 UTF-8。
3. 向后兼容性的“紧箍咒”
既然 A 版 API 依赖“活动代码页”,而 Windows 其实早就定义了代表 UTF-8 的代码页(CP_UTF8,即 65001),那为什么不能直接把系统代码页设为 65001 呢?
如果系统强制返回变长的 UTF-8(一个汉字 3 个字节),会导致无数银行、企业系统和老旧游戏直接崩溃或出现严重乱码。为了极其严格的向后兼容性,微软长期“锁死”了将全局默认代码页设置为 UTF-8 的能力,CRT 自然也就无法通过 setlocale 正常使用 UTF-8。这就是为什么你可以通过chcp命令设置一次cmd的代码页,但是却不能一次性设置cmd代码页,你每一次运行程序都必须手动chcp来设置代码页。
世界上有海量的 Windows 遗留软件。这些软件在编写时,死板地假设了UTF-16“一个中文字符固定占用 2 个字节(GBK)”或者“非英文字符最高位为 1”。如果系统强制返回变长的 UTF-8(一个汉字 3 个字节),会导致无数银行、企业系统和老旧游戏直接崩溃或出现严重乱码。为了极其严格的向后兼容性,微软长期“锁死”了将全局默认代码页设置为 UTF-8 的能力,CRT 自然也就无法通过 setlocale 正常使用 UTF-8。
我们真的解决了乱码吗?
随着互联网的发展,UTF-8 彻底统一了天下,跨平台开源项目(默认使用 UTF-8)在 Windows 上编译运行时的乱码问题让开发者怨声载道。微软最终妥协,并提供了彻底的解决方案:
1. Universal C Runtime (UCRT) 的原生支持 从 Windows 10 引入的 UCRT 开始,CRT 终于支持了 UTF-8 区域设置。现在你可以在代码中直接调用:
setlocale(LC_ALL, ".UTF8");
执行后,标准 C 函数(如 fopen 处理路径,或 printf 打印内容)就能正确将 char* 中的 UTF-8 数据处理并转换为系统需要的 UTF-16 了。
2. 应用程序清单 (Manifest) 声明 从 Windows 10 (Build 1903) 开始,开发者可以在程序的 .manifest 文件中强制指定当前程序使用 UTF-8 代码页,而无需改变系统全局设置。
XML
<activeCodePage>UTF-8</activeCodePage>
只要加了这一句,程序中所有 A 版 API 和 CRT 函数都会自动将 char* 视为 UTF-8,跨平台 C/C++ 代码终于可以在 Windows 上无缝编译运行了。
3. 全局 Beta 选项 在 Windows 10/11 的“区域设置”中,微软终于加入了一个选项:“Beta版:使用 Unicode UTF-8 提供全球语言支持”。勾选后,系统全局的活动代码页就会变成 65001。虽然这可能会让一些二十年前的老软件乱码,但对于现代开发来说是一个巨大的福音。
写到最后
我们作为后来的编程者,当然可以对那些不合理的东西说“不”,但这不意味着我们可以肆意的给那些前辈贴上罪大恶极的标签,因为我们站在当时历史的十字路口,也未必就能做得比他们更好。而历史会给出最终的评价。
“改变”是一件前辈与当代流血而后辈有福的事。只要还能改就还有机会。
