字符编码
乱码不是"文件坏了",而是同一串字节被按错误的规则解释了一遍。搞清楚字符、码位、字节这三层,绝大多数问题都能在五分钟内定位。
三个层次
| 概念 | 是什么 | 例子 |
|---|---|---|
| 字符 | 人看到的符号 | 中、A、😀 |
| 码位(code point) | 字符在 Unicode 里的编号,写作 U+XXXX | 中 是 U+4E2D,A 是 U+0041 |
| 编码(encoding) | 把码位变成字节的规则 | UTF-8 把 U+4E2D 写成 E4 B8 AD |
Unicode 只规定"哪个编号对应哪个字符",不规定怎么存。UTF-8、UTF-16、UTF-32 是三套不同的存储方案。UTF-8 不是 Unicode 的别名,这一点是很多混乱的源头。
UTF-8 的字节结构
UTF-8 是变长编码,一个字符用 1 到 4 个字节,靠首字节的高位标记长度:
| 字节数 | 码位范围 | 字节模式 | 例子 |
|---|---|---|---|
| 1 | U+0000 – U+007F | 0xxxxxxx | A → 41 |
| 2 | U+0080 – U+07FF | 110xxxxx 10xxxxxx | é → C3 A9 |
| 3 | U+0800 – U+FFFF | 1110xxxx 10xxxxxx 10xxxxxx | 中 → E4 B8 AD |
| 4 | U+10000 – U+10FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx | 😀 → F0 9F 98 80 |
两个直接推论:
- ASCII 范围内的字符在 UTF-8 里就是一个字节,值不变。所以纯英文文件用 UTF-8 和用 ASCII 存出来完全一样,这也是 UTF-8 能平滑取代 ASCII 的原因。
- 后续字节一律以
10开头,永远不会和首字节混淆。因此从任意位置开始读,只要看到10开头就知道"这是某个字符的中间部分",跳过即可。这个设计让 UTF-8 能在字节流里自我同步。
验证方式:中 的码位是 U+4E2D,二进制 0100 1110 0010 1101,按 4/6/6 位拆开填入三字节模板,得到 1110 0100、10 111000、10 101101,也就是 E4 B8 AD。
其他常见编码
| 编码 | 特点 | 现状 |
|---|---|---|
| ASCII | 7 位,128 个字符 | UTF-8 的子集,仍是最安全的公共子集 |
| Latin-1(ISO-8859-1) | 8 位,256 个字符,字节与码位一一对应 | 老系统和 HTTP 默认值的遗留 |
| GBK / GB18030 | 中文双字节编码,GB18030 是其超集 | 旧版 Windows 中文环境常见 |
| UTF-16 | 基本平面 2 字节,辅助平面用代理对(4 字节) | Windows 内部、Java 字符串、部分文件格式 |
Latin-1 有个危险特性:任何字节序列都能"成功解码",不会报错。这让按 Latin-1 解码的乱码看起来只是"奇怪的字符",而不是明显的失败,排查时容易被忽略。
同一段中文在不同编码下的字节数差别很大:中文 在 UTF-8 里是 6 字节(E4 B8 AD E6 96 87),在 GBK 里是 4 字节(D6 D0 CE C4)。所以"文件大小突然变了"有时就是编码转换的副作用。
BOM
BOM 是文件开头的几个字节,用来提示编码。UTF-8 的 BOM 是 EF BB BF,UTF-16 小端是 FF FE。它在 UTF-8 里没有存在的必要(编码可以由字节模式自证),但 Windows 的记事本长期默认写入,于是留下了很多带 BOM 的 UTF-8 文件。
BOM 造成的问题很具体:#!/usr/bin/env bash 前面多了三个字节,内核就找不到解释器,报 bad interpreter;JSON 解析器可能在第一个字符处报语法错误;某些模板引擎会把 BOM 当成正文输出,在响应体最前面多出几个不可见字符。
# 看文件开头有没有 BOM(前三个字节是 ef bb bf 就是有)
head -c 3 file | od -An -tx1
# 去掉 BOM
sed -i '1s/^\xEF\xBB\xBF//' file
乱码的三种成因
1. 用单字节编码解释多字节内容
UTF-8 的 café(63 61 66 C3 A9)按 Latin-1 读,C3 和 A9 各自变成一个字符,于是显示成 café。这种"é"" "的组合是 UTF-8 被当成 Latin-1 的典型特征,一眼就能认出来。
2. 用另一种多字节编码解释
UTF-8 的中文按 GBK 读,会得到一串看起来像汉字的乱字——因为它恰好也能凑成合法的 GBK 双字节。这种情况比第一种更难发现,因为输出"看起来像正常文字",只是内容不对。判断方法是看字节数:UTF-8 的中文是 3 字节一组,GBK 是 2 字节一组,长度对不上就会在末尾留下残字节。
3. 多字节字符被截断
按字节切分字符串(例如截取前 100 字节做摘要)会把一个 3 字节的汉字切成两半。解码时遇到不完整的序列,多数库会替换成 �(U+FFFD),或者直接抛异常。这也是为什么"截断摘要"必须按字符而不是按字节做。
排查口诀:先看字节,再看声明,最后看谁在解释。把原始字节打出来(od 或 xxd),确认它到底是 UTF-8 还是 GBK;然后检查文件、HTTP 首部、数据库连接、终端这四处声明的编码是否一致。
Python 里怎么处理
Python 3 把"文本"和"字节"分得很清楚:str 是码位序列,bytes 是字节序列。两者之间的转换只有两个方向:
text = "中文"
raw = text.encode("utf-8") # str -> bytes: b'\xe4\xb8\xad\xe6\x96\x87'
back = raw.decode("utf-8") # bytes -> str: '中文'
len(text) # 2 字符数
len(raw) # 6 字节数
读写文件时显式指定编码,不要依赖系统默认值(它随平台和 locale 变化):
with open("data.txt", encoding="utf-8") as f:
content = f.read()
with open("out.txt", "w", encoding="utf-8", newline="\n") as f:
f.write(content)
newline="\n" 是为了让 Windows 上也写出 LF 而不是 CRLF,跨平台生成的文件才一致。
处理来路不明的字节时,errors= 决定解码失败怎么办:
| 取值 | 行为 | 适合 |
|---|---|---|
strict(默认) | 抛 UnicodeDecodeError | 数据应当合法,出错就该暴露 |
replace | 换成 � | 展示用途,允许有损 |
ignore | 直接丢掉 | 几乎不推荐,会静默改变内容 |
surrogateescape | 把非法字节映射到代理区,可原样编码回去 | 需要无损往返时(如处理文件名) |
命令行里怎么查
locale # 当前语言与字符集设置
echo $LANG $LC_ALL
file -i report.csv # 猜测文件编码,例如 text/plain; charset=utf-8
iconv -f GBK -t UTF-8 in.txt > out.txt
iconv -f UTF-8 -t UTF-8 -c in.txt # 只清理非法字节
head -c 32 file | od -An -tx1 # 看原始字节
file -i 只是猜测,短文件经常猜错,重要场合还是直接看字节。iconv 遇到非法序列会中途停止,加 -c 会跳过它们——但跳过意味着丢数据,只在确认可以丢的时候用。
终端本身的编码也常是问题所在:SSH 到一台 LANG=C 的机器,即使文件是 UTF-8,输出也可能变成问号。临时解决是 export LANG=C.UTF-8,长期则应该写进 shell 配置。Python 在非交互环境下的标准输出编码还受 PYTHONIOENCODING 影响。
NFC 与 NFD:看起来一样,其实不同
有些字符可以有两种表示:一个预组合字符(NFC),或者"基字符 + 组合附加符号"(NFD)。é 可以是 U+00E9 一个码位,也可以是 e 加 U+0301 两个码位。它们显示出来几乎一样,但字节不同、比较也不相等。
import unicodedata
a = "\u00e9" # é 预组合
b = "e\u0301" # e + 组合重音
a == b # False
len(a), len(b) # (1, 2)
unicodedata.normalize("NFC", b) == a # True
macOS 的文件系统在历史上会把文件名转成 NFD,Linux 一般原样保存。于是"从 Mac 传上去的文件按名字找不到"这类问题,根源往往在这里。做法是在比较和入库前统一规范化:
key = unicodedata.normalize("NFC", name).casefold()
排查顺序
- 确认源头字节。用
od -An -tx1或xxd看前几十个字节,判断是 1 字节一组、2 字节一组还是 3 字节一组。 - 确认声明。文件有没有 BOM、HTTP 首部里的
charset是什么、数据库连接的字符集是什么、终端LANG是什么。四处只要有一处不一致就会出问题。 - 找出错的那一跳。在"读取 → 处理 → 输出"的每一步打印一次
repr()(Python)或字节长度,第一个出现异常字符的位置就是问题所在。 - 统一到 UTF-8。存储、传输、接口一律 UTF-8,只在必须对接旧系统时在边界处转换。
- 不要用"看起来对"验收。用
len()、字节数、十六进制对比,肉眼对形近字和不可见字符没有分辨力。