字符编码是计算机存储和传输文字的规则。理解它不是为了”学理论”,而是为了解决一定会遇到的乱码、BOM、跨平台换行、Emoji 长度和日志解析问题。当你看到 中 而不是 时,应该知道哪里可能把字节解释错了。

1. 编码的本质

编码的本质是一个映射约定:把人类使用的字符对应到计算机能处理的数字。

摩尔斯电码中 S = ···O = ---——收发双方必须事先约定规则。计算机编码同理,只是多了一层转换:

字符 → 码点(数字) → 字节(二进制) → 存储/传输

乱码的根源:同一个字节序列,不同编码规则会解释成不同字符。发送方用编码 A 把字符变成字节,接收方用编码 B 解读——A ≠ B 就是乱码。

你迟早会遇到:打开文件看到 中文,或者代码抛出 UnicodeDecodeError。理解编码,就是为了在这些时刻知道该做什么。

2. ASCII:一切的起点

ASCII 用 7 位表示 128 个字符(0-127):

范围内容示例
0-31控制字符LF(0x0A)、CR(0x0D)、TAB(0x09)
32-47标点符号空格 (0x20)、!/
48-57数字0=48, 9=57
65-90大写字母A=65, Z=90
97-122小写字母a=97, z=122

为什么 A=65 而 a=97?ASCII 把大写和小写分成两个连续块,中间隔了 6 个符号。大小写转换只需翻转第 5 位:'a' - 'A' = 32

控制字符中最常遇到的是 LF(换行,Unix 行结束符)CR(回车,Windows 用 CR+LF)TAB(制表符)

ASCII 的局限:128 个字符只够覆盖英文。但它的遗产延续至今——所有现代编码的前 128 个码位都与 ASCII 完全兼容,纯英文文本在任何编码下看起来都一样。

3. 编码的战国时代

ASCII 不够用,各地区开始”自建编码”:

编码地区/语言特点
GB2312 / GBK中文简体双字节,GBK 扩展了 GB2312
Big5中文繁体台湾地区标准
Shift-JIS日文日本工业标准
EUC-KR韩文韩国标准
Latin-1西欧8 位,扩展了 ASCII

问题:同一份文件在不同系统上显示不同内容。“你好”在不同编码下的字节序列完全不同:

echo -n "你好" > hello.txt
hexdump -C hello.txt        # UTF-8: e4 bd a0 e5 a5 bd  (6 字节)
iconv -f UTF-8 -t GBK hello.txt | hexdump -C  # GBK: c4 e3 ba c3  (4 字节)

把 GBK 编码的文件放到 UTF-8 终端里打开,终端用 UTF-8 规则解读 C4 E3 BA C3,结果就是乱码。这种混乱催生了统一方案——Unicode

4. Unicode:字符集,不是编码

Unicode 是字符集(Character Set),不是编码方式(Encoding)。

Unicode 给每个字符分配一个唯一编号,叫做码点(Code Point)

中 → U+4E2D    A → U+0041    🎉 → U+1F389

码点空间划分为 17 个平面(Plane)

平面范围内容
BMP(基本多文种平面)U+0000 ~ U+FFFF常用字符:拉丁字母、中日韩汉字、常见符号
辅助平面 1-16U+10000 ~ U+10FFFFEmoji、罕见汉字、历史文字

Unicode 只是一个对照表。它告诉你”中”的码点是 U+4E2D,却没规定如何存储为字节。UTF-8、UTF-16、UTF-32 才是编码实现。

5. UTF-8:互联网的事实标准

UTF-8 是 Unicode 最流行的编码方式,核心设计是变长编码(1-4 字节):

码点范围字节数字节模板
U+0000 ~ U+007F10xxxxxxx
U+0080 ~ U+07FF2110xxxxx 10xxxxxx
U+0800 ~ U+FFFF31110xxxx 10xxxxxx 10xxxxxx
U+10000 ~ U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

0 开头 = 单字节(ASCII 兼容),110/1110/11110 开头 = 多字节序列首字节,10 开头 = 后续字节。

“中”(U+4E2D)的编码过程:

U+4E2D = 0100 1110 0010 1101 → 落在 3 字节范围
模板:1110xxxx 10xxxxxx 10xxxxxx
填入:1110 0100  10 111000  10 101101
结果:E4        B8          AD

UTF-8 为什么赢了?

  1. 兼容 ASCII:英文文本迁移成本为零
  2. 自同步(Self-synchronizing)10xxxxxx 一定是后续字节,不会”迷失”在字节流中
  3. 无字节序问题:以字节为单位,不存在大端/小端
  4. 空间效率:英文 1 字节,中文 3 字节

HTML、JSON、HTTP 默认字符集都是 UTF-8。2026 年写新系统,默认选择应当是 UTF-8,并且在文件、HTTP 头、数据库连接、脚本运行环境里保持一致。

6. UTF-16 和 UTF-32

UTF-16 使用 2 或 4 字节:BMP 内字符 2 字节,辅助平面字符通过**代理对(Surrogate Pair)**用 4 字节。BMP 中 U+D800 ~ U+DFFF 预留不分配字符,专门用于代理对——高代理(U+D800 ~ U+DBFF)+ 低代理(U+DC00 ~ U+DFFF)。

UTF-32 定长 4 字节,简单但浪费——一个英文字母也占 4 字节。

字节序(Byte Order) 是 UTF-16/UTF-32 必须面对的问题:

  • 大端(Big-Endian):高位字节在前,00 41 = U+0041
  • 小端(Little-Endian):低位字节在前,41 00 = U+0041

什么时候遇到 UTF-16? Windows 内部文本(wchar_t)、Java/JavaScript 字符串内部表示。这就是为什么 JavaScript 中 "𠮷".length === 2——辅助平面汉字需要代理对。

7. BOM:字节序标记

BOM(Byte Order Mark)是文件开头的特殊字节序列,用于标识编码和字节序:

编码BOM 字节含义
UTF-8EF BB BF可选,通常不推荐
UTF-16 BEFE FF大端
UTF-16 LEFF FE小端

BOM 导致的实际问题:带 BOM 的 shell 脚本执行失败——EF BB BF 被当作 shebang 的一部分,系统试图执行 #!/bin/bash。前端中带 BOM 的 HTML 文件可能在页面开头渲染出不可见字符 

建议:UTF-8 文件不加 BOM。 UTF-8 没有字节序问题,BOM 只会带来麻烦。

8. Base64:二进制的文本外衣

Base64 不是字符编码,而是数据编码:把任意二进制数据转为可打印 ASCII 字符。

原理:每 3 字节(24 位)分成 4 组,每组 6 位,映射到 64 个字符(A-Z, a-z, 0-9, +, /)。不足 3 字节时用 = 填充。

echo -n "Hello" | base64     # SGVsbG8=
echo -n "Hello World" | base64  # SGVsbG8gV29ybGQ=

常见场景:MIME 邮件附件、Data URL(data:image/png;base64,...)、JWT Token(Base64URL 变体:+-/_)。

Base64 不是加密! 任何人都能解码。代价是体积增加约 33%。

9. 乱码诊断与修复

诊断流程:确认当前编码 → 推测原始编码 → 用正确编码重新解读。

# 检测文件编码
 
$ file -I hello.txt
hello.txt: text/plain; charset=utf-8
 
# 查看原始字节
 
$ hexdump -C hello.txt
00000000  e4 bd a0 e5 a5 bd               |......|
 
# 编码转换:GBK → UTF-8
 
$ iconv -f GBK -t UTF-8 input_gbk.txt > output_utf8.txt

常见乱码模式:

你看到的原因修复
中UTF-8 字节被当作 Latin-1 解读用 UTF-8 重新打开
涓枃UTF-8 字节被当作 GBK 解读用 UTF-8 重新打开
锟斤拷双重错误转码原始数据已丢失
UTF-8 BOM 被当作 Latin-1 解读去掉 BOM

Python 编码错误:

>>> open("chinese.txt", encoding="ascii").read()
UnicodeDecodeError: 'ascii' codec can't decode byte 0xe4
 
>>> open("chinese.txt", encoding="utf-8").read()  # 修复
'你好世界'
 
>>> open("out.txt", "w", encoding="ascii").write("你好")
UnicodeEncodeError: 'ascii' codec can't encode characters
 
>>> open("out.txt", "w", encoding="utf-8").write("你好")  # 修复
4

现代编辑器都支持”以指定编码重新打开”。看到乱码时,切换编码通常能解决。

10. Emoji 与扩展字符

Emoji 看起来是一个”字符”,实际可能是多个码点的序列

简单 Emoji 是单码点(🎉 = U+1F389),复杂 Emoji 通过 ZWJ(Zero Width Joiner, U+200D) 组合:

👨‍👩‍👧 = 👨(U+1F468) + ZWJ(U+200D) + 👩(U+1F469) + ZWJ(U+200D) + 👧(U+1F467)

这是 5 个码点组成的一个”字符”。还有肤色修饰符(U+1F3FB ~ U+1F3FF)和变体选择器(U+FE0F):

👍 → U+1F44D(默认)    👍🏻 → U+1F44D U+1F3FB(浅色肤色)

为什么 len("👨‍👩‍👧") 在不同语言中返回不同值?

>>> len("👨‍👩‍👧")          # Python 按码点计数
5
>>> "👨‍👩‍👧".length       # JavaScript 按 UTF-16 代码单元计数
11

处理用户输入时不能简单用 len() 判断”字符数”,需要考虑字形簇(Grapheme Cluster)——用户眼中看到的一个”字符”。

11. 诊断原则

编码问题不要靠猜。先确认字节,再确认解释规则,最后再决定是否转换。

# 查看 UTF-8 中文文件的原始字节
echo -n "你好" > hello.txt && hexdump -C hello.txt
# 6 个字节:e4 bd a0 e5 a5 bd
 
# 编码转换:GBK -> UTF-8
iconv -f GBK -t UTF-8 input_gbk.txt > output_utf8.txt
 
# 解码 JWT Token 的 header
echo "eyJhbGciOiJIUzI1NiJ9" | base64 -d
# 输出:{"alg":"HS256"}
 
# 查看 Emoji 的码点组成
python3 -c "print([hex(ord(c)) for c in '👨‍👩‍👧'])"
# 输出:['0x1f468', '0x200d', '0x1f469', '0x200d', '0x1f467']

默认 UTF-8,不加 BOM:新文件、新接口、新脚本都应尽量统一到 UTF-8。Windows 或旧系统边界另行处理。

错误信息保留原始字节线索:乱码截图不如 file -Ihexdump -C、原始输入和转换命令有价值。

Base64 不是加密:看到 Base64 时先判断它是不是只是传输包装。JWT、Data URL、邮件附件都可能用它,但安全性来自签名或加密层,不来自 Base64 本身。

用户可见字符不等于码点数量:Emoji、组合字符、变体选择器会让长度判断变复杂。涉及用户名、短信长度、UI 截断时,要按字形簇而不是简单 len() 思考。

延伸阅读