前段时间整理一份老文章的正文,遇到一个特别诡异的现象:文件用 vim 打开一切正常,浏览器的开发者工具里也看不出问题,但页面标题前面始终有一小块空白,而且 header() 一直报「headers already sent」。查了半小时,最后用 xxd 看文件头三个字节:EF BB BF —— 一个 BOM。
BOM(Byte Order Mark)这东西平时几乎碰不到,但一旦碰上,症状极其分散:可能是页头多一行空行,可能是 CSS 第一条规则失效,可能是 PHP 的 session 突然不工作,也可能是拼接出来的 JSON 解析失败。这篇文章把这几种症状的成因讲清楚,再给一套检测和批量清理的办法,最后说说什么时候该保留它。
一、BOM 到底是什么
BOM 是 UTF-8/UTF-16/UTF-32 编码文本开头的几个字节,用来标识字节序和编码类型。不同编码的 BOM 长度不同:
| 编码 | BOM 字节 | 长度 |
|---|---|---|
| UTF-8 | EF BB BF | 3 字节 |
| UTF-16 LE | FF FE | 2 字节 |
| UTF-16 BE | FE FF | 2 字节 |
| UTF-32 LE | FF FE 00 00 | 4 字节 |
这里有个容易被忽略的事实:UTF-8 其实不需要 BOM。UTF-8 是单字节流,不存在字节序问题,BOM 对它是完全冗余的。U+FEFF 这个码点在 UTF-8 里编码成 EF BB BF,它出现在文件开头时被解释为 BOM,出现在中间时被解释为「零宽不换行空格」(ZWNBSP)。
这个双重身份是很多怪事的根源:一个文件中间如果混进了 U+FEFF,编辑器通常不显示它(毕竟它「零宽」),但浏览器和解析器会把它当成一个真实字符,于是字符串比较失败、正则匹配不上、JSON 解析报 Unexpected token。
Windows 的记事本在很长时间里默认给 UTF-8 文件加 BOM,这是 BOM 污染最常见的来源。现在 Windows 10 1903 之后默认不带 BOM 了,但从旧系统、旧软件、Office 导出、Excel 另存为 CSV 这条链路上流出来的文件,仍然大量带 BOM。
二、六种典型症状,对应的都是同一个字节
下面这些现象我在自己的站点上至少遇到过四种,共同点是「看着毫无关联,实际都是那三个字节」。遇到时第一反应就该是查 BOM。
症状一:页面顶部多一行空白,或 headers already sent
这是最经典的。PHP 在输出任何内容之前不能发 HTTP 头,而一个 BOM 就是「内容」——它会被直接输出到响应体里。于是 session_start()、header('Location: ...')、setcookie() 全部报 warning,跳转失效。
麻烦在于这个 BOM 往往出现在 include 进来的某个文件里,而不是主入口。所以报错行号指向的 header() 调用完全无辜,真正的元凶在几百行外的一个配置文件里。
症状二:JSON 解析失败
接口返回的数据前面多三个不可见字节,JSON.parse 直接抛错。用 curl 看到的是「看起来完全正常的 JSON」,只有 xxd | head -1 才看得出。
症状三:CSS 第一条规则失效
CSS 文件带 BOM 时,解析器遇到 EF BB BF 加上后面的 @charset "utf-8";,容易把 @charset 判定为非法位置而忽略。更常见的表现是文件开头的注释或第一条规则被吞掉,页面样式「莫名其妙少了一点」。
值得说明的是:按规范,CSS 是允许 BOM 的,解析器应该跳过它。但实际浏览器行为并不一致,尤其是压缩、合并之后。所以工程上的结论是:CSS 一律不带 BOM。
症状四:CSV 导入第一列变成乱码字段名
用 Excel 导出的 CSV 带 BOM,PHP 用 fgetcsv 读第一行,第一个字段名就变成 \xEF\xBB\xBFid,于是 $row['id'] 取不到值。这个 bug 特别隐蔽,因为打印出来看字段名是「id」,肉眼看不出前面多了东西。
症状五:Shell 脚本报「command not found」但命令明明存在
#!/bin/bash 前面多了 BOM,内核找不到名为 \xEF\xBB\xBF#/bin/bash 的解释器,报 bad interpreter: No such file or directory。这个错误信息经常误导人往权限、换行符(CRLF)方向查。
症状六:字符串比较 / 去重结果异常
同一批数据里,有的字段带 BOM 有的不带,用 GROUP BY 或 array_unique 会得到两个「看起来一样」的组。这类问题排查成本极高,因为你在任何打印输出里都看不出差别。
三、检测:三条命令,从粗到细
不要靠编辑器判断。很多编辑器会自动识别并隐藏 BOM,看起来是干净的文件,实际还带着。判断只有一条路:看字节。
# 1) 看前三个字节(最直接)
xxd -l 3 -p /path/to/file.php
# 输出 efbbbf 就是带 BOM
# 2) file 命令的提示(不是所有版本都报)
file /path/to/file.php
# 带 BOM 时会显示 "UTF-8 Unicode (with BOM) text"
# 3) grep 按字节匹配(适合批量扫目录)
grep -rlI $'\xEF\xBB\xBF' /data/www/example.com/
# -I 跳过二进制文件,-l 只列文件名,-r 递归第 3 条是唯一适合「整站体检」的写法。$'\xEF\xBB\xBF' 是 bash 的 ANSI-C 引用,才会把字节真正传进去;写成普通的 '\xEF\xBB\xBF' 会变成字面四个字符,永远匹配不到。
不过要注意:grep 默认按行匹配,BOM 出现在文件中间时,如果该行有换行也会被匹配到(这正是我们想要的)。但要排除误报——有些二进制文件(图片、压缩包)恰好包含这三个字节,所以一定要加 -I。
更精确的做法是写一个小脚本,只检查文件开头:
import os, sys
def has_bom(path):
with open(path, 'rb') as f:
return f.read(3) == b'\xef\xbb\xbf'
def scan(root, exts=('.php', '.html', '.css', '.js', '.json', '.xml', '.svg')):
bad = []
for dirpath, _, files in os.walk(root):
for name in files:
if not name.lower().endswith(exts):
continue
p = os.path.join(dirpath, name)
if has_bom(p):
bad.append(p)
return bad
for p in scan(sys.argv[1]):
print(p)只读文件头三个字节,不管文件多大都是 O(1),扫几千个文件不到一秒。扫描之前提醒一句:先看这个文件是不是第三方库或 vendor/ 目录。改第三方文件会在下次更新时被覆盖,而且可能破坏对方签名校验,正确做法是更新依赖而不是改它的字节。
四、清理:怎么删才不出新问题
删 BOM 看着简单,实际上有三类坑:删错了地方、删了不该删的、删完文件权限/编码变了。
单个文件:安全的两行 Python
p = '/data/www/example.com/includes/config.php'
with open(p, 'rb') as f:
data = f.read()
if data.startswith(b'\xef\xbb\xbf'):
with open(p, 'wb') as f:
f.write(data[3:])
print('stripped')用二进制模式读写,不涉及任何编码解码,不会改变文件里的其它字节。这一点很重要——用文本模式(open(p, 'r', encoding='utf-8-sig') 然后再写回)虽然也能去掉 BOM,但会把整个文件重新编码一遍,如果原文件里有非法 UTF-8 序列(比如 GBK 残留),可能被替换成 ? 或直接抛异常。
批量:先备份、先 dry-run
cp -a /data/www/example.com /data/backup/example_$(date +%Y%m%d)改之前一定留一份。然后按「列清单 → 逐条确认 → 批量删」的顺序,不要一条 find -exec 直接上去。批量删除时尤其要注意 .htaccess、.user.ini 这类隐藏文件——它们的 BOM 会导致 Apache 直接 500,而清掉之后就正常了,所以扫的时候别漏了(grep -r 默认会扫隐藏文件,find 默认不会)。
三个容易踩的附加坑
- 文件权限变了。用
sed -i或重定向写入会按 umask 新建文件,原来的644 www-data:www-data可能变成600 root:root,站点立刻 500。Python 的原地读写(rb/wb)不改权限,是我推荐它的主要原因,但用了「写临时文件再mv」的套路就要手动chown/chmod。 - Git diff 会显示整个文件被改。文件几百行但 diff 显示从头到尾全变,说明编辑器在保存时把行尾也改了(LF → CRLF),不只是删 BOM。这种情况要回头确认
.gitattributes和编辑器的fileformat设置。 - 只删开头不够。拼接生成的文件(比如模板 include 之后整体输出、或者 CSV 合并)中间会有多个 BOM。删完开头要再扫一遍全文的
\xEF\xBB\xBF,把中间的换成空字符串——但这一步要非常小心,正文里真有可能存在 U+FEFF(比如从 Word 复制的内容),删之前要先定位确认。
五、从源头堵住:让 BOM 不再产生
清理是治标,源头才是治本。我在几个环节上都做了约定:
编辑器层面。VS Code 底部状态栏会显示 UTF-8 with BOM,看到就点一下切回 UTF-8,并在设置里关掉自动检测:"files.encoding": "utf8"、"files.autoGuessEncoding": false。vim 用 :set bomb? 查看,:set nobomb 关闭。Notepad++ 是「编码 → UTF-8」(不带 BOM 的那个),不是「UTF-8-BOM」。
构建/部署层面。在 CI 或发布脚本里加一道检查,带 BOM 直接失败:
if grep -rlI $'\xEF\xBB\xBF' ./src ./templates; then
echo "发现 BOM,构建中止" >&2
exit 1
fi这道闸门的价值远超它三行的成本。BOM 问题最大的特点是「本地好好的,上线才炸」,靠在生产环境排查,成本是这个的几十倍。
数据导入层面。读 CSV 时统一用 utf-8-sig 编码,Python 会自动跳过 BOM:
with open('import.csv', encoding='utf-8-sig', newline='') as f:
for row in csv.DictReader(f):
...utf-8-sig 在读取时容忍 BOM,写入时会加上 BOM——所以只在读的时候用它,写的时候用 utf-8。这个区别很多人搞反,写出了一堆带 BOM 的 CSV 又回流到系统里。
六、什么时候 BOM 是必须的
说了这么多缺点,BOM 也有它不可替代的场合,别一刀切全删:
- UTF-16 / UTF-32 文件。这是 BOM 唯一真正必要的场景——没有 BOM 就无法判断字节序,解析器只能猜。如果你的系统里有这类文件(Windows 下的某些日志、注册表导出、部分 Office 中间格式),不要删。
- 给 Excel 看的 CSV。Excel 打开不带 BOM 的 UTF-8 CSV 时,中文会按本地编码(GBK)解释,直接乱码。要让用户用 Excel 双击就能正常看,必须带 BOM。这种文件是「给人用的导出」,不是「给程序吃的接口」,两者要分开。
- 某些老旧的 XML 解析器在没有
<?xml encoding="utf-8"?>声明时,靠 BOM 判断编码。现在的主流解析器都能按声明走,但如果你对接的是老系统,别擅自删。
判断标准很清楚:这个文件是给机器解析的(代码、配置、JSON、接口响应),一律不带 BOM;是给人类软件(Excel)打开的,带 BOM 反而更友好。
七、排查 BOM 问题的通用思路
把前面的内容压缩成一个流程,下次遇到类似症状可以照着走:
- 先怀疑字节,不要先怀疑逻辑。凡是「内容看着对但就是不对」的症状(多空行、解析失败、比较不等、第一条规则失效),第一件事是
xxd看文件头。 - 检查响应体的最前面三个字节。用
curl -s URL | xxd -l 8,看到efbbbf就锁定是 BOM,不用再看 PHP 代码。 - 顺着 include 链往上找。PHP 的 BOM 通常不在报错的那个文件里,而在它 include 的第一个文件里。用
grep -rl扫整站比逐个文件打开快得多。 - 区分「开头 BOM」和「中间 U+FEFF」。前者只能用二进制方式去掉;后者的处理要更谨慎,先确认它是不是有意义的零宽字符。
- 改之前备份,改之后验证。验证就一条:
curl -s URL | xxd -l 3不再是efbbbf,同时页面内容完整(用字数或关键段落比对,不要只看首页能不能打开)。
八、小结
BOM 是三个字节的问题,却能引发从 PHP 头部报错到 CSS 失效、CSV 乱码、Shell 脚本无法执行的一大串症状。它的隐蔽性来自「不可见」——编辑器不显示、打印输出看不出、只有比对字节才现形。所以处理它不靠细心,靠工具:xxd -l 3 用于单点确认,grep -rlI $'\xEF\xBB\xBF' 用于整站体检,构建闸门用于永久防复发。
清理时的两条纪律是:备份 + 二进制方式读写(不动权限、不重新编码)。判断保留还是删除的标准是文件的「消费者」——给机器解析的一律不带,给 Excel 打开的建议带。
最后说一句我在这次排查里最深的体会:当错误信息和实际原因完全不匹配时,先看字节,再看代码。「headers already sent」这种报错能把你引到 session 配置、输出缓冲、编码设置上一整圈,而真相就在文件的前三个字节里。