BOM (字节顺序标记)
文件开头的字节序列,用于标识编码类型。UTF-8 为 EF BB BF,UTF-16 为 FF FE 或 FE FF。
BOM (Byte Order Mark) 是放置在文本文件开头的特殊字节序列,用于标识编码的种类和字节顺序 (端序)。它是 Unicode 字符 U+FEFF (ZERO WIDTH NO-BREAK SPACE) 的编码形式,为打开文件的应用程序提供自动判定编码的线索。BOM 不是文件的内容本身,而是承担元信息的角色。
BOM 的具体字节序列因编码而异。UTF-8 为 EF BB BF 的 3 个字节,UTF-16 大端序 (BE) 为 FE FF 的 2 个字节,UTF-16 小端序 (LE) 为 FF FE 的 2 个字节,UTF-32 BE 为 00 00 FE FF、UTF-32 LE 为 FF FE 00 00 的 4 个字节。UTF-16 和 UTF-32 中,多字节数值究竟按哪种字节顺序存放是个问题,因此靠 BOM 判别端序不可或缺。另一方面,UTF-8 不具有字节顺序的概念,其 BOM 纯粹用于编码识别。
自己动手写判定 BOM 的代码时,铁则是按字节序列从长到短依次比对。UTF-32 LE 的 BOM (FF FE 00 00) 开头 2 个字节与 UTF-16 LE 的 BOM (FF FE) 完全一致,若从短的先比,就会把 UTF-32 LE 的文件误判为 UTF-16 LE,之后的正文全都会被读成夹杂 NUL 的字符串。同理,UTF-8 的 EF BB BF 只有 3 个字节齐全才算 BOM,中途截断的字节序列属于非法序列。判定顺序要按 UTF-32 (4 个字节) → UTF-8 (3 个字节) → UTF-16 (2 个字节) 来组织。
实务中最常出问题的是 UTF-8 BOM 的处理。UTF-8 BOM (EF BB BF) 以文件开头 3 个字节的形式存在,但许多程序和工具都把它当作多余的字节对待。例如 Shell 脚本开头有 BOM,shebang 行 (#!/bin/bash) 就无法被正确识别,执行随之失败。PHP 文件中,BOM 会在 HTML 输出之前被发送,成为 headers already sent 错误的原因。CSV 文件中,BOM 的有无会改变在 Excel 里是否出现乱码。
该加还是不该加,就 UTF-8 而言规范一侧已经给出了答案。Unicode 标准的立场是:UTF-8 的 BOM 可以容许,但既非必须也非推荐。HTML5 一侧则要求"浏览器要识别 UTF-8 的 BOM 并用于页面的编码判定",而且 BOM 优先于 HTTP 头部的编码声明。也就是说,加上 BOM 判定确实能通过,但那终究是浏览器一侧的救济,并不是被推荐的写法。写好 <meta charset="utf-8"> 并以无 BOM 保存,在 Web 上才是顺理成章的选择。JSON 更为明确,RFC 8259 规定不得在通过网络传送的 JSON 文本开头加上 BOM (允许解析器一侧跳过它)。
Windows 的记事本长年以来在保存为 UTF-8 时会自动附加 BOM,但从 Windows 10 的版本 1903 起,默认改为无 BOM 的 UTF-8。打开旧文件时 BOM 仍有残留的情况至今仍在,因此重新保存既有文件时,每次都要确认 BOM 的有无。另一方面,在日语环境的 Excel 中双击打开 UTF-8 的 CSV 时,若没有 BOM,就可能被按系统的默认字符编码解释而出现乱码,所以刻意加上 BOM 再输出是惯用做法。让用户从网站下载的 CSV 带 BOM,在程序之间传递的数据不带 BOM,按出口分别制定方针是实务上的折中办法。Visual Studio Code 和 Sublime Text 等现代编辑器可以在状态栏确认和更改编码以及 BOM 的有无。
在字符计数方面,BOM 是容易被漏看的陷阱。BOM 是零宽度的不可见字符,画面上不会显示出来,但会影响文件大小。带 UTF-8 BOM 的文件比无 BOM 的文件大 3 个字节。此外,把文本作为字符串读入时,若 BOM 残留在字符串开头,就会成为字符数多算 1 个、或字符串比较出现不一致的原因。把 {"a":1} 这个 7 个字符的 JSON 保存为带 BOM 的 UTF-8,字节数会从 7 个字节增加到 10 个字节,单纯读入后的字符串长度会从 7 个字符增加到 8 个字符。多出来的那 1 个字符就是 U+FEFF,在画面上连空白都看不见。
这 1 个字符会以比较或解析失败的形式延后浮现。把带 BOM 的文件当作普通 UTF-8 读入再解析 JSON,在 Python 中会以"Unexpected UTF-8 BOM"的错误中断。配置文件的键名对不上、CSV 只有第 1 列的表头对照失败,这类症状也出自同一原因。读入的一侧,要么使用能识别并去掉 BOM 的编码指定 (Python 则是 utf-8-sig),要么先显式去除开头的 U+FEFF 再交给后续处理。制作统计字符数的功能时,在上限判定和差异比较之前把 BOM 去掉更为安全。反过来说,在为 Excel 输出 CSV 的处理中会刻意加上 BOM,因此去除与附加哪一个才正确,取决于是入口还是出口。