国际化与本地化:让程序说世界语
从 wprintf 输出为空与 strlen("中文")==6 两个现场开题,讲清 locale 机制、wchar_t 的平台分裂、多字节转换状态机与 UTF-8 优先的现代纪律,收口于 gettext 翻译流程与五则调试实录。
前置知识
分工说明:本篇讲 C 语言层的 i18n 机制(locale、宽字符、多字节转换)与翻译流程。编码字节序与整数宽度的平台差异在 数据类型 与 内存对齐;Windows/POSIX 的系统层差异在 跨平台编程;C23 的
char8_t关键字事实以 C23 上手 为准,本篇只讲它在 i18n 里的用法。
学习目标
读完本文你将能够:
- 解释
strlen("中文")为什么等于 6,说清「字节」与「字符」在 C 里的区别; - 说出 C 程序的默认 locale 是什么、
setlocale(LC_ALL, "")改变了什么,以及受影响的函数族; - 给出
wchar_t在 Linux 与 Windows 上的宽度与编码差异,并据此解释「宽字符序列化跨平台必炸」; - 使用
mbrtowc等多字节转换函数,说清mbstate_t状态机为什么不能跨字符串共享; - 走通 gettext 翻译流程:提取、编译、运行时切换语言。
预计 45 到 70 分钟,含 3 组动手实验与 2 道练习。
1. 问题引入:两个「莫名其妙」的现场
现场一:宽字符打印不出来。
/* wide_fail.c */
#include <wchar.h>
int main(void) {
wprintf(L"你好\n"); /* 输出:空,或乱码 */
return 0;
}现场二:长度不对劲。
/* len_surprise.c */
#include <stdio.h>
#include <string.h>
int main(void) {
printf("%zu\n", strlen("中文")); /* 输出 6,不是 2 */
return 0;
}两个现场指向同一件事:C 语言的核心类型 char 只有一个字节,它从来就不认识「字」。一个汉字在 UTF-8 里占 3 个字节(strlen("中文") == 6),wprintf 的失败则牵出 locale 与宽字符两套机制。把这两个现场修好,本篇的内容就学完了一半。
2. 字节与字符:编码地图
C 的字符串是「以 \0 结尾的字节序列」,strlen 数的是字节。一个「字符」占几个字节,取决于编码:
| 编码 | ASCII | 常用汉字 | 特点 |
|---|---|---|---|
| ASCII | 1 字节 | 不支持 | 7 位,最早的英语世界标准 |
| Latin-1 (ISO-8859-1) | 1 字节 | 不支持 | 8 位,西欧 |
| GBK | 1 字节 | 2 字节 | 中文 Windows 的历史遗留 |
| UTF-8 | 1 字节 | 3 字节 | 变长 1 到 4 字节,互联网事实标准 |
| UTF-16 | 2 字节 | 2 或 4 字节(代理对) | Windows 内部编码 |
| UTF-32 | 4 字节 | 4 字节 | 定长,空间浪费大 |
UTF-8 的设计妙处在兼容:纯 ASCII 文本同时是合法 UTF-8,char 数组、strlen、fopen 一套老 API 全部照用——只是「一个字符一个字节」的假设不再成立。由此推出两条日常纪律:
strlen是字节数不是字符数:截断字符串到「20 个字」不能buf[20] = '\0',可能把一个汉字拦腰截断(截在多字节序列中间),产生非法字节序列;- 容量按字节算:
char buf[8]装不下「国际化与本地化」中的任意三个汉字还带\0。
3. locale:程序的语言环境开关
3.1 默认就是「不说人话」
每个 C 程序启动时都处于 "C" locale:ASCII-only、小数点是 .、没有千分位、消息就是源代码里的英文。它保证程序行为绝对确定,代价是不理会用户的语言与习惯。下面的实验看开关前后的差别:
/* locale_demo.c */
#include <stdio.h>
#include <locale.h>
int main(void) {
printf("A: %f\n", 1234.5); /* C locale: 1234.500000 */
setlocale(LC_ALL, "");
printf("B: %f\n", 1234.5); /* 跟随环境(如 de_DE: 1234,500000) */
printf("C: %s\n", setlocale(LC_NUMERIC, NULL)); /* 查询当前设置 */
return 0;
}setlocale(category, "") 的空串含义是「采用环境变量指定的 locale」(LC_ALL 优先,其次 LC_*,最后 LANG);传具体名字(如 "zh_CN.UTF-8")则强制指定,但名字本身不可移植(见第 8 节实录二)。category 可以只切一类:
| 类别 | 管什么 |
|---|---|
LC_ALL | 一次性全管(同名环境变量优先级最高) |
LC_CTYPE | 字符分类与编码转换(isalpha、多字节转换) |
LC_NUMERIC | 非货币数字格式(小数点、千分位) |
LC_TIME | strftime 的日期时间格式 |
LC_COLLATE | 字符串比较排序(strcoll) |
LC_MESSAGES | 消息语言(gettext 的依据,POSIX 扩展) |
受影响的函数族包括 printf 的 %f、isalpha(对重音字母的判定)、strftime、strcoll、多字节转换族——它们的行为随 locale 变,这是「同一个程序两台机器输出不同」的常见来源。
3.2 localeconv 与静态缓冲
localeconv() 返回指向静态缓冲区的 struct lconv *(小数点字符、货币符号等)。两次调用之间结果会被覆盖,且多线程共用同一缓冲——用的时候立刻抄走需要的字段,不要长期持有指针。
4. wchar_t:一张平台分裂的宽字符牌
wchar_t 是「足够放下系统支持的最大字符集」的类型,但两个大平台的选择不同:
| 平台 | sizeof(wchar_t) | 编码 |
|---|---|---|
| Linux / macOS | 4 字节 | UTF-32 |
| Windows | 2 字节 | UTF-16 |
这直接导致两条规则:
- 宽字符串不能跨平台序列化:4 字节宽字符写进文件,2 字节平台读回来全错——宽字符只适合内存内处理;
- 宽度相关代码不可移植:假设
sizeof(wchar_t) == 2或== 4的代码必炸一边。需要定宽时用 C11 的char16_t(UTF-16 码元)/char32_t(UTF-32 码元),它们不随平台变。
宽字符的配套 API 是另一套:wprintf/wcslen(在 <wchar.h>)。还有一个隐蔽规则:流定向——一个流被 fprintf 用过就是字节定向,再用 wprintf 是未定义行为,反之亦然(实录三)。宽字符能精确表示「一个字符占一个 wchar_t」,代价是与所有窄字符串 API、UTF-8 数据割裂。
5. 多字节转换:mbstate_t 状态机
窄(UTF-8/GBK)与宽(wchar_t)之间靠转换函数连接:
/* mb_demo.c:UTF-8 字节串转宽字符串再数「字」 */
#include <stdio.h>
#include <stdlib.h>
#include <wchar.h>
#include <locale.h>
int main(void) {
setlocale(LC_ALL, "");
const char *utf8 = "中文";
wchar_t wide[16];
size_t n = mbstowcs(wide, utf8, 16); /* 简便版:整体转换 */
if (n == (size_t)-1) { perror("mbstowcs"); return 1; }
printf("字节数 %zu,字符数 %zu\n", strlen(utf8), n); /* 6 与 2 */
return 0;
}mbstowcs 是「一把梭」版本;工程代码更常用可恢复、可增量喂入的 mbrtowc:
mbstate_t st;
memset(&st, 0, sizeof st); /* 状态必须先清零 */
size_t r = mbrtowc(&wc, src + off, remaining, &st);
/* 返回 0:读到宽空字符;(size_t)-1:非法字节序列(errno = EILSEQ);
(size_t)-2:不完整,需要更多输入;其他:消耗了 r 个字节得一个宽字符 */三条纪律:
mbstate_t是续传状态:从网络流分片到达的字节可以带着同一个状态继续转换;但一个状态只能服务一个字符串流,换字符串必须重置——「状态污染」会把上一个字符串的半截汉字记到下一个里;(size_t)-2不是错误:是「给更多输入再说」,分片读取的正常分支;(size_t)-1是真错误:字节序列在当前 locale 的编码下非法(EILSEQ)。
6. 现代纪律:内部全 UTF-8,边界才转换
Windows API 用 UTF-16 宽字符、老式 GUI 框架各有偏好,但互联网与大部分现代基础设施(文件名、JSON、协议、数据库)已经统一在 UTF-8。跨平台工程的主流做法:
- 程序内部统一持有 UTF-8 窄字符串:
char *+ UTF-8,所有数据结构、日志、存储都是字节安全且平台无关的; - 只在平台边界转换:调 Windows 宽字符 API 前转一次,回来再转一次(
MultiByteToWideChar等系统调用,见 跨平台编程); - 显示长度时明确语义:给用户的「还能输入多少字」按码点算,内部校验按字节算,两套函数分开命名。
C23 在这条路上补了类型地基:char8_t 成为 u8 字符与字面量的类型(u8"中文" 的类型从 char[] 变为 char8_t[]),并新增 mbrtoc8/c8rtomb 转换函数——类型系统终于能区分「UTF-8 字节」与「本地编码字节」(关键字事实见 C23 上手)。BOM(字节序标记 EF BB BF)一句:UTF-8 没有字节序问题,BOM 可有可无,写文件时不建议添加。
7. gettext:标准翻译流程
国际化(i18n)是「把程序设计成可适配语言」,本地化(l10n)是「具体某种语言的适配物」。C 世界的标准工具链是 gettext:源代码里用 gettext("Hello") 包住所有用户可见文本,构建与运行时按语言换出译文。
/* hello_i18n.c */
#include <stdio.h>
#include <libintl.h>
#include <locale.h>
#define PACKAGE "hello"
#define LOCALEDIR "./locale"
int main(void) {
setlocale(LC_ALL, "");
bindtextdomain(PACKAGE, LOCALEDIR); /* 告诉 gettext 去哪找译文 */
textdomain(PACKAGE); /* 用哪个域的译文 */
printf("%s\n", gettext("Hello, world!"));
return 0;
}四步流水线(以 zh_CN 为例):
xgettext -d hello -o hello.pot hello_i18n.c # 1. 提取所有待译字符串
msginit -l zh_CN -i hello.pot # 2. 生成 zh_CN.po,人工填翻译
msgfmt -c -o locale/zh_CN/LC_MESSAGES/hello.mo hello.po # 3. 编译为 .mo
LC_ALL=zh_CN.UTF-8 ./hello_i18n # 4. 运行时按 locale 换出译文po 文件是给译者看的纯文本(原文/译文成对),mo 是运行时查的二进制表。复数规则(英语 1 book / 2 books,俄语有三种形式)用 ngettext("one book", "%d books", n) 处理。这套流程与业务代码零耦合:不加翻译文件时 gettext 原样返回入参,程序照常运行。
8. 常见错误与调试实录
实录一:忘了 setlocale,宽字符静默变空。 开篇的 wprintf(L"你好") 就是它:C locale 下宽字符流无法完成到输出编码的转换,输出为空且不报错。修法一行:setlocale(LC_ALL, "")。所有依赖 locale 的程序在 main 开头先设一次,是同 free 后置 NULL 一样的固定动作。
实录二:locale 名写死导致移植失败。 setlocale(LC_ALL, "zh_CN.UTF-8") 在 Debian 上工作,在 musl 容器里返回 NULL(musl 的 locale 名与 glibc 不一致,且不支持运行时组合)。教训:界面语言永远 setlocale(LC_ALL, "") 跟随环境;程序自身行为依赖的 locale(比如要求数字必须用 . 解析)显式 setlocale(LC_NUMERIC, "C") 锁死。
实录三:一个流混用两种 printf。 先 fprintf(fp, ...) 再 wprintf(fp, ...):流的定向(字节/宽)已定,再换方向是未定义行为,glibc 下常见表现为后半段输出消失。一个流从一而终;fwide 可查询定向。
实录四:mbstate_t 跨字符串复用。 逐行解析文件时为了省事复用一个 mbstate_t:上一行末尾的半个多字节字符被记进下一行的转换,之后每一行都 EILSEQ。每条独立字节流一个状态,重开一条流就 memset 清零。
实录五:假设 wchar_t 宽度。 把 wchar_t 数组 fwrite 进配置文件,Linux 写 4 字节单元、Windows 读 2 字节单元,数据全毁。宽字符不出进程边界;要落盘走 UTF-8 窄字符串。
9. 实际项目中的使用场景
- 数据库与网络库(如 SQLite)同时提供 UTF-8 与 UTF-16 两套接口,内部以 UTF-8 为真源——与本篇第 6 节纪律同构;
- Linux 内核完全不用 locale(内核空间没有这个概念),i18n 是纯用户态事务;
- 服务端程序的日志通常刻意保持英文与
Clocale:日志要进检索系统,格式必须与机器区域设置无关——「给用户的界面」与「给机器的输出」走两套 locale 是常态。
10. 小练习
预测题(5 分钟):下面程序在 LANG=de_DE.UTF-8 的环境输出什么?
#include <stdio.h>
int main(void) {
printf("%f\n", 1234.5);
return 0;
}参考答案(先写再看):1234.500000。程序没有调用 setlocale,永远停在 "C" locale——环境变量只影响调用了 setlocale(LC_ALL, "") 的程序。很多人在这里误判「locale 环境变了程序就变」,机制上要经过这一次显式切换。
修改题(10 分钟):下面的函数想把 UTF-8 字符串截断到最多 max_bytes 字节,它可能产生什么坏结果?给出安全版。
void truncate_utf8(char *s, size_t max_bytes) {
s[max_bytes] = '\0';
}参考答案:两处坏——先越界(strlen(s) 可能小于 max_bytes,这是越界写);后截断(max_bytes 若落在多字节序列中间,产生非法 UTF-8,下游渲染乱码或校验失败)。安全版先取 len = strlen(s) 与 max_bytes 中的小者,再从该位置向前回退,直到落在 UTF-8 序列起始字节上(0b10xxxxxx 是续字节;起始字节是 0b0xxxxxxx 或 0b11xxxxxx)。
11. 与之前和之后的知识的关系
- 往前:数组 的字节视图与 文件 I/O 的流是本篇一切 API 的载体;安全函数 的边界纪律在多字节场景要按字节计;
- 旁支:宽窄两套 API 的平台差异总表在 跨平台编程;
char16_t/char32_t的定宽保证在 数据类型; - 往后:C23 的
char8_t全家与编译期特性见 C23 上手 与 C23 深水区。
12. 官方文档
- cppreference C 本地化(setlocale/localeconv 语义):https://zh.cppreference.com/w/c/locale
- cppreference C 多字节/宽字符工具:https://zh.cppreference.com/w/c/string/multibyte
- GNU gettext 手册(翻译流程权威文档):https://www.gnu.org/software/gettext/manual/
- Unicode 标准与 UTF-8 规范:https://www.unicode.org/versions/
13. 自我检查
- 能解释
strlen("中文") == 6与wprintf输出为空的机制,并各给一行修复; - 能默画「字节 vs 字符 vs 码点」三层关系,说出 UTF-8 变长规则对截断与容量的影响;
- 能说出
wchar_t在两大平台的宽度与编码,解释为什么宽字符不能落盘; - 能走通 gettext 的四步流水线,说清
bindtextdomain与textdomain各管什么。
本章总结
C 的 char 是字节,strlen 数字节,「字符」是编码之上的概念:UTF-8 里中文三字节、GBK 两字节,一切长度与容量计算必须先问「按什么编码」。locale 决定程序的语言环境,默认 "C" locale 保证确定但不说人话,setlocale(LC_ALL, "") 是跟随用户的开关——忘了它,宽字符静默变空。wchar_t 在 Linux 是 4 字节 UTF-32、在 Windows 是 2 字节 UTF-16,宽字符只适合内存内处理,落盘与跨机走 UTF-8。窄宽转换靠 mbstowcs/mbrtowc,mbstate_t 一条流一个状态。现代工程纪律一条主线:内部全 UTF-8、边界才转换、长度语义分清字节与字符;翻译交给 gettext 四步流水线。
下一步
进入 构建系统:源码里多了 locale/ 目录、.mo 产物与生成步骤,手工 gcc 命令已经管不过来——从 Makefile 到 CMake,把构建本身工程化。