字符编码

乱码不是"文件坏了",而是同一串字节被按错误的规则解释了一遍。搞清楚字符、码位、字节这三层,绝大多数问题都能在五分钟内定位。

三个层次

概念是什么例子
字符人看到的符号中、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 个字节,靠首字节的高位标记长度:

字节数码位范围字节模式例子
1U+0000 – U+007F0xxxxxxxA → 41
2U+0080 – U+07FF110xxxxx 10xxxxxxé → C3 A9
3U+0800 – U+FFFF1110xxxx 10xxxxxx 10xxxxxx中 → E4 B8 AD
4U+10000 – U+10FFFF11110xxx 10xxxxxx 10xxxxxx 10xxxxxx😀 → F0 9F 98 80

两个直接推论:

验证方式:中 的码位是 U+4E2D,二进制 0100 1110 0010 1101,按 4/6/6 位拆开填入三字节模板,得到 1110 0100、10 111000、10 101101,也就是 E4 B8 AD。

其他常见编码

编码特点现状
ASCII7 位,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()

排查顺序

  1. 确认源头字节。用 od -An -tx1 或 xxd 看前几十个字节,判断是 1 字节一组、2 字节一组还是 3 字节一组。
  2. 确认声明。文件有没有 BOM、HTTP 首部里的 charset 是什么、数据库连接的字符集是什么、终端 LANG 是什么。四处只要有一处不一致就会出问题。
  3. 找出错的那一跳。在"读取 → 处理 → 输出"的每一步打印一次 repr()(Python)或字节长度,第一个出现异常字符的位置就是问题所在。
  4. 统一到 UTF-8。存储、传输、接口一律 UTF-8,只在必须对接旧系统时在边界处转换。
  5. 不要用"看起来对"验收。用 len()、字节数、十六进制对比,肉眼对形近字和不可见字符没有分辨力。