字符编码是计算机存储和传输文字的规则。理解它不是为了”学理论”,而是为了解决一定会遇到的乱码、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-16 | U+10000 ~ U+10FFFF | Emoji、罕见汉字、历史文字 |
Unicode 只是一个对照表。它告诉你”中”的码点是 U+4E2D,却没规定如何存储为字节。UTF-8、UTF-16、UTF-32 才是编码实现。
5. UTF-8:互联网的事实标准
UTF-8 是 Unicode 最流行的编码方式,核心设计是变长编码(1-4 字节):
| 码点范围 | 字节数 | 字节模板 |
|---|---|---|
| U+0000 ~ U+007F | 1 | 0xxxxxxx |
| U+0080 ~ U+07FF | 2 | 110xxxxx 10xxxxxx |
| U+0800 ~ U+FFFF | 3 | 1110xxxx 10xxxxxx 10xxxxxx |
| U+10000 ~ U+10FFFF | 4 | 11110xxx 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 ADUTF-8 为什么赢了?
- 兼容 ASCII:英文文本迁移成本为零
- 自同步(Self-synchronizing):
10xxxxxx一定是后续字节,不会”迷失”在字节流中 - 无字节序问题:以字节为单位,不存在大端/小端
- 空间效率:英文 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-8 | EF BB BF | 可选,通常不推荐 |
| UTF-16 BE | FE FF | 大端 |
| UTF-16 LE | FF 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 -I、hexdump -C、原始输入和转换命令有价值。
Base64 不是加密:看到 Base64 时先判断它是不是只是传输包装。JWT、Data URL、邮件附件都可能用它,但安全性来自签名或加密层,不来自 Base64 本身。
用户可见字符不等于码点数量:Emoji、组合字符、变体选择器会让长度判断变复杂。涉及用户名、短信长度、UI 截断时,要按字形簇而不是简单 len() 思考。
延伸阅读
- The Absolute Minimum Every Software Developer Must Know About Unicode — Joel Spolsky 经典文章
- UTF-8 Everywhere Manifesto — 为什么所有系统都应该默认使用 UTF-8
- Unicode 官方标准 — 最权威的 Unicode 规范文档
- What Every Programmer Must Know About Character Encodings — 面向程序员的编码深入讲解