Python 处理中文编码:一个 65 字节的文件,为什么读出来是 38 个字符

举报
yd_263929376 发表于 2026/11/04 13:26:41 2026/11/04
【摘要】 Python 处理中文编码:一个 65 字节的文件,为什么读出来是 38 个字符上周把一篇 Markdown 正文送进剪贴板,粘到网页编辑器后满屏都是「閰嶇疆鏂囦欢宸插浠斤細」。源文件本身没问题,用浏览器打开是正常中文。问题出在中转的那一步:一个 65 字节的 UTF-8 文件,被读成了 38 个字符。正确读法是 29 个字符。多出来的 9 个不是凭空产生的,是 27 个双字节汉字被当成 ...

Python 处理中文编码:一个 65 字节的文件,为什么读出来是 38 个字符

上周把一篇 Markdown 正文送进剪贴板,粘到网页编辑器后满屏都是「閰嶇疆鏂囦欢宸插浠斤細」。源文件本身没问题,用浏览器打开是正常中文。问题出在中转的那一步:一个 65 字节的 UTF-8 文件,被读成了 38 个字符。

正确读法是 29 个字符。多出来的 9 个不是凭空产生的,是 27 个双字节汉字被当成 54 个单字节重新配对的结果。

先量一下这 9 个字符是怎么多出来的

固定一个 65 字节的 UTF-8 探针文件,内容两行:

配置文件已备份:D:\备份\配置.json
第二行:中文

在 Windows PowerShell 5.1.19041.3803 下读同一个文件,只改一个参数:

$d = Get-Content $f -Raw                 # 不写 -Encoding
$u = Get-Content $f -Raw -Encoding UTF8
"$($d.Length) vs $($u.Length)"
$d.Contains([char]65533)                 # U+FFFD
$d.Contains([char]37197)                 # 「配」

输出是 38 vs 29、False、False。

这三行输出值得逐条看。字符数从 29 涨到 38,膨胀率 31%;文本里一个 U+FFFD 都没有;原文出现频率最高的「配」字,在错误结果里一次都不存在。也就是说,用「检查有没有乱码符号」来判断读取是否成功,在这个场景下会 100% 漏判——它看起来只是一段你从不认识的中文,而不是一段坏掉的中文。

剪贴板会把错误原样带走。Set-Clipboard -Value $d 之后再 Get-Clipboard -Raw,长度仍是 38,U+FFFD 仍是 0。粘贴到编辑器里,编辑器不会报错,读者只会觉得作者语文不好。

open() 的默认编码不是常量,是环境变量

Python 这边同样的坑,表现形式不同。同一个 open(p, "w"),在两台机器上写出的字节可以完全不一样:

import locale, os
print(locale.getpreferredencoding(False))   # 决定 open() 的默认编码
print(os.environ.get("PYTHONUTF8"))

本机实测两组结果:PYTHONUTF8=1 时输出 utf-8;把它关掉后输出 cp936。两种模式下执行同一句 open("x.txt", "w").write(text),落盘的是 49 字节、以 c5 e4 d6 c3 开头的 GBK 字节流,按 UTF-8 解码直接抛 invalid continuation byte at position 0。

49 这个数字本身还能再拆一层:这段文本 GBK 编码是 47 字节,多出的 2 字节来自换行符翻译——open() 默认 newline=None,写盘时把 \n 改写成 \r\n。同一段文本 UTF-8 是 65 字节,所以「文件突然变小了」在中文项目里不是优化,是编码换了,比值 1.38。

这就是「在我机器上是好的」的典型来源:代码里没有一行跟编码有关的字,行为却由启动环境决定。Python 从 3.10 起给了个开关,可以把这类隐式行为变成硬错误:

python -X warn_default_encoding -W error::EncodingWarning script.py

对没写 encoding 的 open(),直接抛 EncodingWarning: 'encoding' argument not specified;补上 encoding="utf-8" 后不再报。这个开关适合放进 CI 或提交前检查,比靠代码评审盯 open( 可靠。

U+FFFD 是墓碑,不是路标

解码失败时 errors="replace" 会塞一个 U+FFFD 进去,让程序继续跑。代价是信息当场销毁,且不可逆:

try:
    "\ufffd".encode("gbk")
except UnicodeEncodeError as e:
    print(e)          # 'gbk' codec can't encode character '\ufffd'
print("\ufffd".encode("gbk", "replace"))   # b'?'

写回 GBK 时它变成 ?。原文那个字是什么,从这一刻起没人知道。

字节层面更隐蔽。把 65 字节的 UTF-8 文本按偏移硬切,切口正好落在三字节汉字的中间:

切到第几字节 严格解码 errors="replace"
8 UnicodeDecodeError: unexpected end of data, 0xe6 at 6, need 2 bytes '配置�',1 个 U+FFFD
9 成功,配置文 0 个 U+FFFD
10 UnicodeDecodeError: unexpected end of data, 0xe4 at 9, need 1 bytes '配置文�',1 个 U+FFFD

分页读取、按字节截断日志、read(1024) 循环,都可能踩在这条缝上。切在 8 还是 9,只差一个字节,一个静默丢字,一个完好无损。

顺带解释一个老梗。锟斤拷 不是随机乱码,它是两次错误叠加的产物:第一次解码失败产生 U+FFFD,第二次把 U+FFFD 按 UTF-8 编码成 ef bf bd,第三次再按 GBK 读——两个 U+FFFD 连在一起就是 ef bf bd ef bf bd,解出来正好是「锟斤拷」。同源的还有 烫烫烫(0xCC 填充)和 屯屯屯(0xCD 填充),都是 Debug 版堆栈里的填充字节被当成 GBK 念了出来。

三个字节的 BOM,一句很难懂的报错

UTF-8 文件可以带 BOM(ef bb bf),也可以不带。带不带都能被浏览器和编辑器正确显示,但 open(..., encoding="utf-8") 会把 BOM 当成一个正文字符 \ufeff:

s = open("bom.json", encoding="utf-8").read()
s.startswith("{")        # False,首字符是 '\ufeff'
json.loads(s)            # JSONDecodeError: Unexpected UTF-8 BOM (decode using utf-8-sig)

报错信息其实已经给了答案。读入口一律用 utf-8-sig:没有 BOM 时它等价于 utf-8,有 BOM 时自动吞掉;写出时一律用 utf-8,不要给下游添麻烦。这条规则对 Python 和 PowerShell 都成立,成本是零。

用 try/except 猜编码,方向反了就是灾难

常见写法是「试着按 A 解,失败就按 B 解」。问题在于 GB18030 几乎不会失败——它是双字节加四字节的超集,任意两个字节凑一起基本都能凑出一个合法字符。实测把上面那段 UTF-8 字节按 GB18030 解码:成功,得到 38 个字符,样本 閰嶇疆鏂囦欢宸插,其中还夹着一个私用区字符 U+E62C,汉字占比 0.632。

0.632 这个数字意味着「汉字比例够高就算解码成功」这类启发式同样会放过它。真正的判据只有一个:这段字节严格按 UTF-8 解得开吗。解得开就是 UTF-8,解不开才轮到 GB18030,而且必须记一笔「低置信度」。

按这个顺序写体检函数,本机 17 个 Markdown 文件(含中文文件名)跑下来:17 个全部 utf-8,BOM 数量 0,汉字数 402–1683。没有一个落到 gb18030? 分支——这也说明,中文项目里真正需要兼容 GBK 的存量文件,比想象中少得多。

把约定写进代码,别写进记忆

落到工程上,只有四条,全都不需要额外依赖:

# 1. 读:显式指定,且容 BOM
text = open(path, "r", encoding="utf-8-sig").read()

# 2. 写:显式指定,不带 BOM
open(path, "w", encoding="utf-8", newline="\n").write(text)

# 3. 二进制判型,别猜
raw = open(path, "rb").read()
kind = "utf-8-bom" if raw[:3] == b"\xef\xbb\xbf" else "utf-8" if _try(raw) else "gb18030?"

# 4. 跨进程传递,用哈希验收而不是肉眼
import hashlib
hashlib.sha256(text.encode("utf-8")).hexdigest()

第 4 条是这次真正救命的。剪贴板、管道、临时文件这三个中转站都不保证编码,肉眼也分不清「閰嶇疆」和「配置」——它们都长得像正常中文。把源文件正文和目标端的字节各算一次 SHA256 对比,一致才算送达;不一致时长度差(38 vs 29)通常能第一时间指出问题在哪一段。

PowerShell 侧对应一句:Get-Content 和 Set-Clipboard 都要显式 -Encoding UTF8,Windows PowerShell 5.1 的默认值是系统 ANSI 代码页,不是 UTF-8,也不会因为你终端显示正常就改变。

结论

中文编码问题里最贵的不是乱码,是不报错的乱码:字符数变了、内容变了、没有任何异常,程序一路绿灯跑到底。判断一段处理链是否安全,标准不是「输出看起来对不对」,而是「每一个读写点是否显式声明了编码,以及有没有一条字节级的验收」。凡是靠默认值、靠肉眼、靠 errors="replace" 兜底的地方,都埋着一次不会被告知的静默丢字。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。