字符编码:内存里没有文字,只有一张对照表
内存里只有 0 和 1,编码就是 01 的排列组合与人类语言之间的映射关系。
为什么需要编码
计算机的内存和磁盘,能稳定保存的只有 0 和 1。8 个比特位(bit)组成 1 个字节(byte),一个字节有 256 种状态:无符号解释是 0~255,有符号解释是 -128~127。除此之外,硬件不认识别的东西。
而文字是由符号构成的。a、中、€、🙂 这些符号本身无法直接写进内存。于是必须有一条规则,把符号换成整数存进去,显示时再换回来。这条规则就是编码(Encoding):
编码 = 符号与值之间的映射。
存的时候查表:符号 → 值。显示的时候查表:值 → 符号。内存里从头到尾只有值。
这里要先把三个经常被混为一谈的概念分开:
| 概念 | 回答的问题 | 例子 |
|---|---|---|
| 字符集(Character Set) | 有哪些字符,每个字符编号是多少 | ASCII、Unicode |
| 编码方式(Encoding Form) | 这个编号怎样写成字节序列 | UTF-8、UTF-16、GBK |
| 字体 / 字形(Font / Glyph) | 这个字符画出来长什么样 | 宋体、黑体、Consolas |
一句话:字符集决定"是谁",编码方式决定"怎么存",字体决定"怎么画"。三者互不等价。编码只负责编号和字节,不负责笔画。
ASCII:128 个位置装下英文
ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)是最早、也最简单的一套方案。它用 7 个比特位,共 128 个码位。
| 范围 | 内容 |
|---|---|
| 0--31、127 | 控制字符:换行、回车、制表、退格、DEL 等 |
| 32 | 空格 |
| 48--57 | '0'--'9' |
| 65--90 | 'A'--'Z' |
| 97--122 | 'a'--'z' |
26 个大写字母、26 个小写字母、10 个数字,62 个核心符号就位;剩下的位置分给标点、括号、运算符。后来 IBM PC 等扩展字符集又把 128--255 拿去放表情、扑克牌花色、制表符和音乐符号。至此,一个字节的 256 个状态被安排得七七八八。
几个必须记住的编号:
'0'是 48,'9'是 57。数字字符的编号是连续的。'A'是 65,'Z'是 90。'a'是 97,'z'是 122。- 大写和小写相差 32,也就是二进制的第 5 位(从 0 数起)不同。
显示字符,本质是查表
C 语言里的 char 就是一个字节的小整数。字符字面量 'a' 的类型是 char,它的值就是 97。
c
#include <stdio.h>
int main(void)
{
char word[] = "apple";
/* 内存里存的是整数,不是字母 */
printf("%d %d %d %d %d\n",
word[0], word[1], word[2], word[3], word[4]);
/* 97 112 112 108 101 */
/* %c 才触发"查表显示" */
printf("%c%c%c%c%c\n",
word[0], word[1], word[2], word[3], word[4]);
/* apple */
printf("sizeof = %zu\n", sizeof word); /* 6:5 个字符 + '\0' */
return 0;
}
printf("%d", word[0]) 打印 97,因为 %d 把字节当整数解释;printf("%c", word[0]) 打印 a,因为 %c 会去 ASCII 表里查 97 对应的符号。所谓"显示字符",本质是一次查表。
ASCII 只定义了 0--127,最高位始终是 0。一个字节还剩 128 个状态。后来各国把这些空位拿去做自己的编码,乱码的种子从这时就埋下了。
Unicode:先给每个字符一个编号
英文用 128 个位置够了,中文不够。汉字有数万个,韩文、日文、阿拉伯文、印度各语种都要进来。每个国家各搞一张表,同一串字节在不同表里含义不同,冲突不可避免。
Unicode(统一码,俗称"万国码")的做法是:不再按国家分表,而是给全世界所有字符统一编号。每个字符对应一个码位(Code Point) ,写作 U+XXXX。
- Unicode 一共划分了 17 个平面,每个平面 65536 个码位,理论容量 1,114,112 个。
- 第 0 平面叫 BMP(Basic Multilingual Plane,基本多文种平面),覆盖 U+0000--U+FFFF。
- 常用汉字在
U+4E00--U+9FFF,共 20,992 个码位;加上扩展区,CJK 汉字总数超过 9 万。
关键的一点:Unicode 本身只是"编号表",它没有规定这个编号在内存里占几个字节。把 U+6BD4 存成 E6 AF 94 还是 B1 C8,是编码方式的事。
⚠️ 把 Unicode 和 UTF-8 当成同义词,是后面一切乱码问题的起点。Unicode 是字符集,UTF-8 是它的一种编码方式。
UTF-8:变长、兼容 ASCII
UTF-8(Unicode Transformation Format, 8-bit)是目前互联网上最常用的 Unicode 编码方式。它以字节为单位,长度可变,一个字符占 1 到 4 个字节。
| 字节数 | 位模式 | 可表示范围 | 典型内容 |
|---|---|---|---|
| 1 | 0xxxxxxx |
U+0000--U+007F | ASCII 字符 |
| 2 | 110xxxxx 10xxxxxx |
U+0080--U+07FF | 拉丁扩展、希腊字母、西里尔字母 |
| 3 | 1110xxxx 10xxxxxx 10xxxxxx |
U+0800--U+FFFF | 常用汉字、日文、韩文 |
| 4 | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
U+10000--U+10FFFF | emoji、扩展区生僻字 |
这套位模式解决了三个问题:
- 兼容 ASCII。单字节模式的最高位是 0,和 ASCII 完全一致。纯英文文本用 UTF-8 存,字节和 ASCII 一模一样。
- 能判断长度 。首字节的前缀
0、110、1110、11110直接告诉你这个字符占几个字节。 - 能重新同步 。所有续字节都以
10开头。即使从中间开始读,也能很快找到下一个字符的边界。这叫自同步(Self-synchronizing)。
汉字在 UTF-8 里占几个字节
常用汉字在 UTF-8 里是 3 个字节。以"比"和"特"为例:
| 字符 | Unicode 码位 | UTF-8 字节 | GBK 字节 |
|---|---|---|---|
| 比 | U+6BD4 | E6 AF 94(3 字节) |
B1 C8(2 字节) |
| 特 | U+7279 | E7 89 B9(3 字节) |
CC D8(2 字节) |
| 比特 | --- | 6 字节 | 4 字节 |
推导"比"的 UTF-8 编码:U+6BD4 的二进制是 0110 1011 1101 0100,按 3 字节模式拆成 1110 0110、10 101111、10 010100,即 E6 AF 94。
⚠️ "UTF-8 的汉字占 2 字节"是一个流传很广的错误说法。UTF-8 的常用汉字是 3 字节;2 字节是 GBK/GB2312,或者 UTF-16 的说法。
UTF-16 与 UTF-32:另外两种存法
UTF-16 以 2 字节为一个码元,BMP 内的字符直接用一个码元,超出 BMP 的用代理对(Surrogate Pair)拼成 4 字节。UTF-32 则是每个码位固定 4 字节,简单、可直接下标,但空间浪费明显。
| 维度 | UTF-8 | UTF-16 | UTF-32 |
|---|---|---|---|
| 码元 | 1 字节,变长 1--4 | 2 字节,代理对时 4 字节 | 4 字节定长 |
| 兼容 ASCII | 是 | 否 | 否 |
| 常用汉字 | 3 字节 | 2 字节 | 4 字节 |
| 优点 | 省空间、兼容 ASCII、自同步 | BMP 紧凑,Windows/Java 内部使用 | 定长,随机访问简单 |
| 缺点 | 变长,按下标取字符困难 | 有代理对与字节序问题 | 空间浪费大 |
GBK:中文 Windows 的默认答案
GBK 是国标编码("GB"即国标),前身是 GB2312。它不追求覆盖全世界,主要解决中文以及中日韩汉字的问题。
- ASCII 部分仍然是单字节,0--127 不变。
- 汉字用双字节表示:首字节
0x81--0xFE,尾字节0x40--0xFE(跳过0x7F)。 - GB2312 的汉字区大致按拼音排序,所以同音字往往相邻。GBK 在其基础上扩充了生僻字、繁体字、日韩汉字等。
在中文 Windows 上,系统默认的 ANSI 代码页是 936,也就是 GBK。Visual Studio 新建源文件时,如果不主动选择 UTF-8,中文源码通常按 GBK 保存;MSVC 也就按 GBK 解释源文件和执行字符集。
为什么调试器里的字节是负数
这解释了一个常见的困惑:在 VS 调试器里看到"比特"只占 4 个字节,字节是 B1 C8 CC D8,而不是 6 个字节。那不是 UTF-8,是 GBK。调试器把 char 显示成 -79 -56 -52 -40,也不是数据错了,而是 char 在常见 x86 平台上是有符号的:0xB1 按有符号解释就是 -79。
c
#include <stdio.h>
int main(void)
{
/* "比"的 GBK 编码是 B1 C8 */
char gbk[] = "\xB1\xC8";
unsigned char *raw = (unsigned char *)gbk;
printf("char: %d %d\n", gbk[0], gbk[1]); /* -79 -56 */
printf("unsigned: %u %u\n", (unsigned)raw[0], (unsigned)raw[1]); /* 177 200 */
return 0;
}
同一串字节,用 GBK 查表得到"比",用 UTF-8 查表就会失败或得到别的符号。字节本身不携带"我是什么编码"的信息------这是理解乱码最重要的一句话。
乱码:编码和解码用了两张表
乱码(Mojibake) 几乎总是同一个原因:写入时用 A 表编码,读取时用 B 表解码。
| 现象 | 常见原因 |
|---|---|
| 满屏生僻字,像古书 | UTF-8 字节被按 GBK 两两切开 |
满屏方块、� 或问号 |
GBK 字节被按 UTF-8 解析,非法序列被替换 |
| 满屏带符号的拉丁字母 | 字节被按 ISO-8859-1(Latin-1)一对一映射 |
| 网页中文乱码 | HTTP Content-Type 或 <meta charset> 与实际编码不符 |
| 终端输出乱码 | 终端代码页(Windows 的 chcp)与文件编码不符 |
| 数据库里变成问号 | 连接字符集不是 utf8mb4,四字节字符被截断 |
形状不同,是因为三张表的切法不同:UTF-8 变长(1--4 字节),GBK 固定两字节,Latin-1 一个字节一个字符。同一串字节按不同规则切,字符数量和边界都会变。
⚠️ 字节不知道自己的编码。每次假设"它应该是 UTF-8",都是一次失控的开始。

四种读错
速查表把中文乱码按"长相"分了类:古文码、口字码、符号码、拼音码、问句码、锟拷码。名字是记忆用的,实质只有一件事------谁在读谁。
古文码:UTF-8 的字节被 GBK 读。 "比特"的 UTF-8 是 E6 AF 94 E7 89 B9。GBK 两字节一切,得到"姣旂壒"。三个字节的汉字被两个字节的规则切开,切点从此错位,落进 GBK 的生僻字区;GBK 本身收了大量非常用字和日韩汉字,所以看起来像古书。
口字码:GBK 的字节被 UTF-8 读。 "比特"的 GBK 是 B1 C8 CC D8。B1 的二进制是 10110001,以 10 开头,在 UTF-8 里是续字节,前面却没有首字节。非法序列只能输出替换字符 U+FFFD,控制台再把它画成方块或问号。两个汉字、四个字节,通常放大成四个占位符。
符号码 / 拼音码:字节被 Latin-1 读。 Latin-1 把 0--255 每个值都映射成一个字符,永远不报错。UTF-8 字节读出来是 æ¯"特;GBK 字节读出来是 ±ÈÌØ,字母带着类似声调的符号。看起来"像字",其实每个字符只是字节的另一种画法,没有任何语义。
💡 背景补充:浏览器里标着 ISO-8859-1 的页面,实际按 Windows-1252 解码。两者的差别只在 0x80--0x9F:Latin-1 那里是控制字符,1252 那里是
€、"、‰等可打印符号。乱码里能看见引号和千分号,就是这个原因。
错两次:锟斤拷从哪来
第一层错误如果继续流转,会叠成第二层。GBK 字节按 UTF-8 读,每个非法字节变成 U+FFFD,编码成 UTF-8 是 EF BF BD;这串字节再按 GBK 读,EF BF、BD EF、BF BD 恰好都是合法双字节,两两切分,正好拼出"锟斤拷"。
text
EF BF BD EF BF BD --GBK--> 锟斤拷
EF BF BD --GBK--> 不完整序列,只能给替换字符
问号从哪来。 GBK 一次读两个字节。字节总数是奇数时,最后一个字节找不到搭档,解码器只能输出替换字符,控制台把它显示成 ?。速查表里"偶数正确、奇数末尾变问号"说的就是这件事:问题不在内容,在字节总数。
到这里可以下一个判断:能看见的乱码,说明字节已经被某张表解释过;解释不了的地方,被替换字符吃掉了。
烫烫烫与屯屯屯:这不是编码问题
Debug 输出里常能看到"烫烫烫烫"和"屯屯屯屯"。它们和编码转换无关,是内存没初始化。
MSVC 的 Debug 运行库把未初始化的栈空间填成 0xCC,把未初始化的堆空间填成 0xCD。GBK 里 CC CC 正好是"烫",CD CD 正好是"屯"。一段没写满的 char 缓冲区被当成字符串打印,就会显示成一串"烫"。
c
#include <stdio.h>
int main(void)
{
char buf[8]; /* 错误示范:未初始化 */
buf[0] = 'a';
buf[1] = 'b';
printf("%s\n", buf); /* 没有 '\0',会继续读后面的填充字节 */
return 0;
}
问题不在编码,在两件事:缓冲区没有初始化,printf("%s") 只认 '\0'。Release 模式不做填充,输出变成随机字节,看起来没那么整齐,bug 仍然在。
⚠️ 看见"烫"先查内存,不要查编码。字节本身就是错的,换哪张表都救不回来。
乱码能救回来吗
取决于原始字节还在不在。
能救的:一对一映射。 Latin-1(浏览器里实际是 Windows-1252)和 GBK 都是一对一的字节映射;只要解码时没碰到非法序列,把显示出来的字符串按同一张表重新编码,就能拿回原始字节,再换正确的表解码。
python
# 诊断脚本,不是生产代码
s = "æ¯"特" # UTF-8 字节被当成 Windows-1252
print(s.encode("cp1252").decode("utf-8")) # 比特
s = "姣旂壒" # UTF-8 字节被当成 GBK
print(s.encode("gbk").decode("utf-8")) # 比特
救不回的:替换字符。 一旦链路上出现过 U+FFFD 或 ?,原始字节已经被丢掉,只剩下占位符。把"锟斤拷"按 GBK 编码回去,得到 EF BF BD EF BF BD;再按 UTF-8 解码,只有两个 U+FFFD,回不到"比特"。中间多存一次盘,可能就多一层不可逆的替换。
实际操作。 编辑器一般有"Reopen with Encoding"(按指定编码重新打开)和"Save with Encoding";命令行用 iconv -f GBK -t UTF-8;chcp 只改终端代码页,不动文件。
⚠️ 修复的前提是知道"它被谁读错了"。不知道方向,就只能猜,每猜一次还可能再存一次盘。
在 C++ 里:char 只是字节
C++ 的字符类型,本质上都是不同宽度的整数:
| 类型 | 常见大小 | 用途 | 引入标准 |
|---|---|---|---|
char |
1 字节 | 单字节 / UTF-8 码元 | C++98 |
wchar_t |
Windows 2 字节,Linux 4 字节 | 平台相关的宽字符 | C++98 |
char16_t |
2 字节 | UTF-16 码元 | C++11 |
char32_t |
4 字节 | UTF-32 码位 | C++11 |
char8_t |
1 字节 | 显式 UTF-8 码元 | C++20 |
标准库的 std::string 不是独立类型,而是 basic_string<char> 的别名。几组常见的对应关系:
| 类型 | 实际类型 | 对应的编码方式 |
|---|---|---|
std::string |
std::basic_string<char> |
UTF-8 字节序列最常见 |
std::wstring |
std::basic_string<wchar_t> |
平台相关(Windows 为 UTF-16) |
std::u16string |
std::basic_string<char16_t> |
UTF-16 |
std::u32string |
std::basic_string<char32_t> |
UTF-32 |
basic_string 写成模板,是因为"字符"不一定是 1 字节。容器逻辑(扩容、拷贝、查找)与码元宽度无关,把码元类型抽成模板参数即可复用。std::string 选择 char,而 UTF-8 恰好以字节为单位,两者天然匹配。
代价是:std::string 的所有下标和长度,都是字节,不是字符。
字节下标不是字符下标
cpp
#include <cstdio>
#include <string>
int main()
{
// 源文件需保存为 UTF-8;MSVC 需加 /utf-8
std::string s = "比特";
std::printf("size = %zu\n", s.size()); // 6,不是 2
std::printf("s[0] = 0x%02X\n", (unsigned)(unsigned char)s[0]); // 0xE6
int code_points = 0;
for (unsigned char c : s)
if ((c & 0xC0) != 0x80) // 跳过 10xxxxxx 续字节
++code_points;
std::printf("code points = %d\n", code_points); // 2
return 0;
}
s.size() 返回 6,因为"比特"在 UTF-8 里是 6 个字节。s[0] 取到的是第一个字节 0xE6,不是"比"这个字符。s.substr(0, 1) 会切出一个残缺的字节序列,得到非法 UTF-8。按字节下标操作多字节文本,必须知道自己在字节层面做什么。
统计"字符数"时,跳过所有 10xxxxxx 续字节,得到的是**码位(Code Point)数量。它仍然不等于人眼看到的"字":组合字符、带变体选择符的 emoji、ZWJ 序列,都可能由多个码位组成。真正按"用户感知字符"计数需要字素簇(Grapheme Cluster)**规则,标准库不直接提供。
常见误区
- "内存里存的是字符。" 内存里存的是值。字符只存在于"值 + 编码表"的解释结果里。
- "Unicode 就是 UTF-8。" Unicode 是字符集,UTF-8/16/32 是编码方式。同一个 Unicode 字符可以有不同的字节表示。
- "UTF-8 的汉字都是 2 字节。" 常用汉字是 3 字节。2 字节的是 GBK,或 UTF-16 的 BMP 字符。
- "
std::string::size()是字符数。" 是字节数。UTF-8 下"比特"的size()是 6。 - "乱码说明文件坏了。" 大多数情况是编码和解码用了不同的表,字节本身没坏。
- "编码决定字体。" 编码只给字符编号;显示成宋体还是黑体,是字体和渲染器的事。生僻字显示成方框,往往是字体缺字形,不是编码错了。
- "改一个字节就能安全地改字。" GBK 的首字节和尾字节有范围约束,UTF-8 的续字节必须匹配前缀。随手改字节,很容易造出非法序列。
- "烫烫烫是编码问题。" 是未初始化内存被当成字符串打印:
0xCCCC在 GBK 里恰好是"烫"。先查内存,别换编码。 - "乱码总能修回来。" 只有一对一的映射能反推;出现过 U+FFFD 或
?,原始字节就已经丢了。
💡 背景补充:UTF-8 文件开头的
EF BB BF是 BOM(Byte Order Mark)。UTF-8 本身没有字节序问题,BOM 属于历史遗留;它会让某些脚本、配置文件的第一行多出不可见字符。跨平台项目通常约定"UTF-8 无 BOM"。
本节要点
- 编码是符号与值的映射。内存存值,显示查表;字节本身不携带编码信息。
- ASCII 用 7 位表示 128 个字符 ,
'0'=48、'A'=65、'a'=97,大小写相差 32。 - Unicode 是字符集,UTF-8/16/32 是编码方式。UTF-8 变长 1--4 字节、兼容 ASCII、常用汉字 3 字节。
- GBK 是中文 Windows 的默认双字节编码,汉字 2 字节;VS 里看到 2 字节汉字,多半是 GBK 而非 UTF-8。
std::string存的是字节 ,size()和下标都按字节计;切分多字节文本前,先确认边界。- 同一串字节按不同表切,长相不同:UTF-8 读成 GBK 是生僻字,GBK 读成 UTF-8 是方块,Latin-1 读是带符号的拉丁字母。
- 替换字符不可逆 :U+FFFD 和
?一旦出现,原始字节就没了;能反推的只有一对一的映射。 - "烫""屯"不是编码问题 :MSVC Debug 用
0xCC/0xCD填充未初始化内存。