本文要点
- 乱码的本质是「字节没变、解读方式变了」:同一个「编」字,UTF-8 存成
e7 bc 96三个字节,GBK 存成b1 e0两个字节——用错的编码去读,自然是一屏莫名其妙的中文 file -i会把 GBK 文件误判成iso-8859-1(本文实测),不能盲信:它是靠启发式猜的,判断真实编码要结合文件来源、字节数和hexdumpiconv -f GBK -t UTF-8一行完成转码;方向写反会报illegal input sequence at position 6并返回非零——这个报错本身就是「编码判断错了」的强信号- locale 不只管界面语言:实测同一条
sort,LC_ALL=C得到B C a b(按字节值),LC_ALL=en_US.utf8得到a b B C(按字典序),结果完全不同 - 演示机默认只有
C / C.utf8 / POSIX / en_US.utf8四种 locale,locale-gen zh_CN.UTF-8本地生成(约 3MB 写入 locale 归档)后date立刻输出「2026年 09月 10日 星期四」
Linux 字符编码与乱码排查:从 iconv 转换到 locale 配置
从 Windows 导出的 GBK 编码 CSV 传到 Linux 服务器上,cat 出来是一屏鬼画符;脚本里明明写了中文排序,结果顺序怎么都不对;date 输出的星期是英文,前端同事说要中文——这三个看似不相干的问题,根子上是同一件事:字符编码和 locale。
本文在一台 Ubuntu 22.04 云服务器(KVM,内核 5.15.0-30-generic)上把这两件事从原理到命令实测一遍:先用 hexdump 看清乱码到底乱在哪,再用 iconv 把文件转回来,最后搞清楚 locale 如何悄悄影响排序、日期甚至报错信息。文中所有命令与输出均为真实执行结果,截图即真实终端。
乱码的本质:字节没变,解读方式变了
要理解乱码,先分清两个概念:
- 字符集(charset):一套「字符 ↔ 编号」的对照表。Unicode 给每个字符分配一个码点,比如「编」是
U+7F16。 - 编码(encoding):把码点变成实际字节的规则。UTF-8 把
U+7F16写成三个字节e7 bc 96;GBK 则把「编」写成两个字节b1 e0。
文件在磁盘上永远只是一串字节,没有地方标注「我是用什么编码写的」。读文件时用哪套规则解码,完全由读取方决定。规则对上了就是正常中文,对不上就是乱码——字节从头到尾没变过。
先把同一个字符串分别用两种编码写成文件,看看字节层面差在哪:
mkdir -p /tmp/encdemo && cd /tmp/encdemo
printf 'Linux 编码教程\n' > u.txt
iconv -f UTF-8 -t GBK u.txt > g.txt
printf 'B\na\nC\nb\n' > t.txt两个文件内容完全一样,只是编码不同。用 file 看识别结果、用 hexdump 看真实字节:
cd /tmp/encdemo && file -i u.txt g.txt && hexdump -C u.txt && hexdump -C g.txt
对着输出逐段读:
u.txt: charset=utf-8:file认出了 UTF-8,正确。g.txt: charset=iso-8859-1:这是误判。GBK 文件被认成了西欧的 Latin-1。file的编码判断来自 libmagic 的启发式规则,对 GBK/GB18030 这类双字节中文编码支持很弱,只要高位字节密集就倾向猜 Latin-1。所以不要把file的输出当结论。- UTF-8 的字节:
e7 bc 96(编)e7 a0 81(码)e6 95 99(教)e7 a8 8b(程),每个中文字符 3 字节,加上Linux和换行,文件共00000013= 19 字节。 - GBK 的字节:
b1 e0(编)c2 eb(码)bd cc(教)b3 cc(程),每个中文字符 2 字节,文件共0000000f= 15 字节。
同一个字符串,UTF-8 比 GBK 多出 4 个字节——这也是「中文 CSV 转成 UTF-8 后文件变大」的原因。
判断编码时,比 file 更可靠的思路是:看来源(Windows 简体中文环境导出的多半是 GBK,网页和 Linux 本地文件多半是 UTF-8)+ 看字节特征(hexdump 里每个中文是否恒为 3 字节一组、高位字节是否呈 UTF-8 的 1110xxxx 10xxxxxx 10xxxxxx 模式)。
顺带记一个 .mime 形式的写法,输出更干净,适合塞进脚本做条件判断:
file --mime-encoding u.txt g.txticonv:一行完成转码
确认了 g.txt 是 GBK,转换只需要 iconv——-f 是来源编码(from),-t 是目标编码(to):
cd /tmp/encdemo && iconv -f GBK -t UTF-8 g.txt && python3 -c "print(open('u.txt','rb').read().decode('gbk').strip())"
第一行 Linux 编码教程 是 iconv -f GBK -t UTF-8 g.txt 的输出:GBK 文件被正确翻译成 UTF-8,中文正常显示。
第二行 Linux 缂栫爜鏁欑▼ 是故意读错的结果:把 UTF-8 文件 u.txt 的字节硬按 GBK 解码(open('u.txt','rb').read().decode('gbk'))。这就是乱码的真身——每一组字节都不是合法 UTF-8 序列,但恰好是合法的 GBK 序列,于是被「翻译」成了一批毫不相干、但确实存在的汉字。所以乱码往往看起来「像中文但读不通」,而不是一堆问号。
如果反过来,把 GBK 文件当成 UTF-8 去转,iconv 会直接拒绝:
iconv -f UTF-8 -t GBK g.txtLinux iconv: illegal input sequence at position 6position 6 正是指向 Linux (6 字节)之后的第一个字节 b1——它不符合 UTF-8 的字节模式。这条报错极有价值:只要 iconv 报 illegal input sequence,基本可以断定 -f 指定的来源编码错了,换成 GBK 或 GB18030 再试。注意命令返回码是 1,脚本里可以用 || 捕获。
万一文件里真的混着个别坏字节(日志被截断、转码转了一半),可以用 -c 静默跳过非法序列,保证整体能转出来:
printf '\xff\xfeabc\n' | iconv -c -f UTF-8 -t UTF-8abciconv 支持的编码有 1179 种(iconv -l | wc -l),常用的 GBK、GB18030、BIG5、LATIN1、UTF-16 都在列。查完整清单直接跑 iconv -l。
批量转换:把整个目录的 GBK 文件洗成 UTF-8
单个文件好办,一整个目录的历史文件才是常态。iconv 只能写到标准输出,所以批量转换要「先转存临时文件、再覆盖回去」:
for f in batch/*.txt; do iconv -f GBK -t UTF-8 "$f" > "$f.u8" && mv "$f.u8" "$f"; done这里用 && 而不是 ; 是关键:只有转换成功才覆盖原文件。如果 iconv 失败,$f.u8 是空文件,&& 会让 mv 不执行,原文件得以保全。实测两个 GBK 文件全部转换成功后,file 的识别结果从 iso-8859-1 变成了 utf-8。
生产环境建议再加一层保险:转换前用 cp -a 备份整个目录,转换后抽查几个文件的中文是否正常,再删备份。
locale:决定排序、日期和报错语言
编码解决的是「字节怎么读」,locale 解决的是「读出来之后按什么规矩办事」。它是三个环境变量按优先级叠加的结果:
LC_ALL > LC_*(单项) > LANG(兜底)
LANG 是大方向,LC_* 可以逐项覆盖(LC_TIME 管日期、LC_COLLATE 管排序、LC_MESSAGES 管报错语言),LC_ALL 一票否决全部。
locale 设置最容易踩的坑是排序。看下面这条实测——同一个文件 t.txt(内容 B a C b),只换 LC_ALL:
cd /tmp/encdemo && LC_ALL=C sort t.txt | tr '\n' ' '; echo; LC_ALL=en_US.utf8 sort t.txt | tr '\n' ' '; echo; LC_TIME=zh_CN.UTF-8 date
LC_ALL=C得到B C a b:C locale 是「无语言」的裸字节序,按 ASCII 码值排——B(0x42) <C(0x43) <a(0x61) <b(0x62)。所以大写字母全部排在小写字母前面。LC_ALL=en_US.utf8得到a b B C:英文 locale 按字典序,且忽略大小写差异,a/B/b/C交替排列。
同一个 sort,两种结果,谁都没报错。这就是为什么「脚本在服务器上跑出来的顺序和本地不一样」——多半是两边的 LANG/LC_ALL 不同。写脚本时如果依赖排序结果,务必显式指定 LC_ALL=C(比如比较校验和、处理机器生成的 ID 列表),否则换台机器就可能出岔子。
生成缺失的 locale
第三行输出 2026年 09月 10日 星期四 显示中文,是因为显式指定了 LC_TIME=zh_CN.UTF-8。但这里有个前提:这台机器原本没有 zh_CN 这个 locale。
查看本机已安装的 locale:
locale -aC
C.utf8
POSIX
en_US.utf8只有四种——这是云服务器最小化安装的典型状态(默认 LANG=C.UTF-8,用 localectl status 可以看到 System Locale: LANG=C.UTF-8)。
好消息是生成 locale 不需要联网下载,系统的 locale 源文件就在 /usr/share/i18n/locales/ 下,locale-gen 直接本地编译:
locale-gen zh_CN.UTF-8Generating locales (this might take a while)...
zh_CN.UTF-8... done
Generation complete.编译结果写进 /usr/lib/locale/locale-archive,生成后该文件为 6.0M——加一个中文 locale 的代价很小。之后 LC_TIME=zh_CN.UTF-8 date 就能输出上面的中文日期。
要让它对所有会话持久生效,用 localectl(不要手改 /etc/default/locale,容易写错格式):
localectl set-locale LANG=zh_CN.UTF-8注意:把系统 LANG 整体改成中文后,很多命令的报错信息和手册页也会变中文。服务器上更稳妥的做法是保持 LANG=C.UTF-8(英文报错便于搜索),只在确实需要的程序里单独指定 LC_TIME、LC_COLLATE。
排查清单
遇到中文乱码,按这个顺序走一遍基本都能定位:
| 现象 | 首选排查 | 命令 | |
|---|---|---|---|
| 文件内容乱码 | 确认文件真实编码 | `hexdump -C 文件 \ | head、file -i 文件` |
| 乱码「像中文但读不通」 | 大概率是编码读错,不是文件坏 | iconv -f GBK -t UTF-8 try 一遍 | |
iconv 报 illegal input sequence | -f 写错了 | 换成 GBK / GB18030 | |
| 个别坏字节导致整体转不出 | 跳过非法序列 | iconv -c -f ... -t ... | |
| 排序结果和预期不符 | locale 作祟 | 显式加 LC_ALL=C | |
| 日期/星期是英文 | 缺中文 locale | locale-gen zh_CN.UTF-8 | |
程序报错 Cannot set LC_ALL to default locale | locale 未生成 | locale -a 核对 + locale-gen |
一句话总结:乱码先看字节(hexdump)再定编码(别信 file),转换用 iconv 且方向报错就是信号;locale 则记得 LC_ALL > LC_* > LANG,脚本里排序一律显式 LC_ALL=C。
参考资料:
评论 (0)
暂无评论,快来抢沙发吧!