脚本第一行报错、字符串比不相等、diff 整文件变红:BOM 与换行符的排查

2026-07-28

数据截至 2026-07。文中的命令行选项可用性、各工具的默认编码与换行行为都会随版本变化(例如比较老的 Git 没有 --ignore-cr-at-eol 这个选项),执行前以你本机版本的 --help 输出和官方最新文档为准。

这三个现象看着毫不相干,八成是同一个原因:你的文件里有你看不见的字节。 大多数人第一反应是去改脚本语法、怀疑判断逻辑写错了、或者骂工具生成的代码有问题,然后在源码层面来回改半小时——而源码在文本编辑器里显示得完全正确,因为出问题的东西按设计就是不显示的。文件开头的 UTF-8 BOM(三个字节 EF BB BF)和行尾的回车符 CR(\r)都属于这一类:它们参与字节比较、参与解析、参与 diff,但不参与显示。

所以这类问题的排查顺序天生是反直觉的:先看字节,再看代码。你只要把”打开文件看一眼”换成”打印前几个字节看一眼”,绝大部分情况会在两分钟内定性。下面按现象分因、验证、动作、兜底的顺序过一遍。

顺带说清本篇和站内两篇邻近文章的分工:如果你的症状是终端里、日志里、网页上显示成方块或问号,那是解码链路和字体的问题,去看 中文乱码与输出异常排查;如果你的症状是同一份代码本地跑得通、容器或 CI 里跑不通,先怀疑环境差异那条线,去看 运行环境不一致排查。本篇只管一件事:文件里客观存在、但肉眼看不见的那几个字节,如何把它们揪出来并从流程里清掉。

一、三类症状分别对应什么机制

症状一:脚本第一行就报错。 你写了 #!/usr/bin/env bash,执行时系统告诉你解释器不存在。这几乎可以断定不是 shebang 写错了,而是内核在解析这一行时看到的不是你想的内容。两种可能:一是文件开头有 BOM,那三个字节挡在 #! 前面,内核根本认不出这是一个 shebang,于是按别的方式处理,报出的错误信息里往往会带上一串八进制转义;二是这一行是 CRLF 结尾,于是解释器路径实际变成了 bash\r,系统去找一个名字带回车的可执行文件,当然找不到。第二种在 Windows 上编辑、Linux 或容器里执行的场景里最常见。

同一个机制会牵连到别处:.env 文件如果是 CRLF,某些加载方式会把 \r 当成值的一部分,于是你的密钥末尾多了一个回车,请求发出去直接 401——你会以为密钥过期或者被停用了,其实只是尾巴多了一个字节。这也是为什么鉴权类故障值得先验一遍环境变量的字节长度,而不是急着换密钥。

症状二:字符串比较不相等。 你从文件里读一行,跟一个常量比,眼看着一模一样却返回 false。逐字符打印也看不出差别。这里有两种典型来源:读文件时按行切分只处理了 \n\r 留在了字符串尾部;或者这是文件的第一行、第一个字段,BOM 变成了一个不可见的  字符粘在最前面。后者在读 CSV 时特别爱咬人:表头第一列的名字实际是 id,你按 id 取值就是 KeyError,而打印表头看到的就是 id。JSON 也一样,开头有 BOM 时,严格的解析器会在第 0 个位置就报解析失败,错误信息指向”意外字符”,你去检查括号配对是白费功夫。

症状三:diff 整个文件全红。 你只改了三行,git diff 却把整份文件标成全部重写。这说明每一行的行尾都变了,也就是有人(编辑器、格式化工具、生成代码的助手、某个脚本的重定向输出)整体改写了这个文件的换行风格或者顺手加了 BOM。这类改动最大的代价不是它本身,而是它把真实改动埋进了几百行噪声里,让审阅失去意义。AI 编程工具在改动范围失控时经常连带这一手,怎么把改动面按住是另一个话题,见 控制 AI 改动范围

二、判别表:从现象反推成因

下面这张表按”看到什么”直接对到”验什么”,不用先建立完整认知:

现象大概率成因怎么验证处置动作
执行脚本报解释器不存在,第一行看着没错开头有 BOM,或首行 CRLFhead -c 3 f.sh | od -An -tx1 看是否 ef bb bffile f.sh 是否提示 CRLF去 BOM 并转 LF,重新赋可执行位
读出的字符串跟常量比不相等,长度多 1行尾残留 \r 或首字符是 Python 里 print(repr(s)),或 printf '%s' "$s" | od -c读取时用 utf-8-sig,切行用能吃掉 \r 的方式
JSON/YAML 在第一个字符处解析失败文件开头 BOM同上验前三字节;或用二进制方式读首字节比对写入端统一输出无 BOM;读取端按 utf-8-sig 容错
CSV 第一列取不到值,表头打印正常表头首字段带 print(list(row.keys())[0].encode()) 看有没有 \xef\xbb\xbf读取端用 utf-8-sig
带密钥的请求返回 401/403,密钥肉眼正确.env 是 CRLF,值尾部含 \rprintf '%s' "$TOKEN" | od -c | tail -2 看结尾.env 转成 LF,或加载时 strip
git diff 整文件全红,实际只改几行全文行尾被改写,或新增了 BOMgit diff --ignore-cr-at-eol 后是否只剩真实改动;git ls-files --eol -- f撤销这次全文重写,走仓库级规范化单独提交
容器里启动脚本报格式或找不到,宿主机上正常镜像里拷进去的脚本是 CRLF进容器 od -c 验首行,或在构建前 file 一遍构建前统一转 LF,并在 .gitattributes 固定

表里只有两条验证手段是真正必要的:看字节odreprfile)和问 Git 它眼里的行尾是什么git ls-files --eol)。其余都是这两条的变体。

三、动作:改哪一层决定了会不会复发

第一层,先把当前这个文件救活。 单文件去 CR,GNU 环境(多数 Linux 发行版)下是 sed -i 's/\r$//' f.sh。这条命令不要照抄到 macOS:系统自带的是 BSD sed,两处都不一样——-i 必须跟一个参数(原地改就写空串 sed -i '' ...),而且它不把 s/\r$// 里的 \r 当回车看,于是这条命令会去删掉行尾的字母 r,把文件静默改坏而且不报错。要在 macOS 上干这件事,可以借 shell 的 $'...' 先把真回车展开出来:sed -i '' $'s/\r$//' f.sh;更省心的是不用 sed,用 tr -d '\r' < f.sh > f.new && mv f.new f.shtr 各平台行为一致。dos2unix 也能用,但别在文档或 CI 里假定它一定装了。想一次把 BOM 和 CRLF 都处理掉,用 Python 更稳,因为它在哪都一样:

python -c "import sys,pathlib
p=pathlib.Path(sys.argv[1]); b=p.read_bytes()
if b[:3]==b'\xef\xbb\xbf': b=b[3:]
p.write_bytes(b.replace(b'\r\n',b'\n'))" script.sh

第二层,改读取端,让它对脏输入免疫。 这一层比第一层重要。Python 里读可能带 BOM 的文本,把 encoding='utf-8' 换成 encoding='utf-8-sig' 就行:有 BOM 时自动吃掉,没有也不出错。所以对于所有”来自外部、你控制不了写入端”的文件(用户上传的 CSV、别人导出的 JSON、Windows 同事提交的配置),默认就该用 utf-8-sig 读,而不是等出事再改。有个反向的坑要一起记住:utf-8-sig 只适合用在读的一侧,拿它去写文件会主动在开头加上 BOM,那就等于你自己变成了污染源,写入端一律用 utf-8。逐行处理时,如果你用 for line in frstrip('\n'),换成 rstrip('\r\n')\r 就不会漏过去;不带参数的 rstrip() 也能解决换行,但它会连尾部的空格和制表符一起吃掉,字段值本身允许尾随空格时就别用它。

第三层,改写入端。 这一层是根治。CRLF 和 BOM 不会自己长出来,一定有某个环节生成了它们。常见的源头有三个:一是在 Windows 上用 Windows PowerShell 5.1 做重定向或写文件,它的默认编码不是无 BOM 的 UTF-8,同一段脚本在 PowerShell 7 上行为又不一样,所以跨机器复用写文件脚本时必须显式指定编码而不要吃默认值;二是编辑器按平台默认换行风格保存新文件;三是某些格式化或代码生成工具按自己的默认值输出。定位源头的办法很朴素:找一个刚被污染的文件,看它是哪个动作之后变的。

第四层,把规则写进仓库。 在仓库根放 .gitattributes

* text=auto eol=lf
*.sh text eol=lf
*.bat text eol=crlf
*.png binary

含义是:文本文件在仓库里一律存 LF,.bat 这类必须 CRLF 的按平台需要单独指定,二进制文件不做任何转换(漏掉这条会让 Git 去”规范化”图片,后果比 CRLF 严重得多)。配好之后跑一次 git add --renormalize .,把历史里存错行尾的文件一次性拉平,单独提交,不要和功能改动混在一起。同时用 .editorconfig 约束编辑器:

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true

再顺手让每个人在自己机器上跑一次 git config --show-origin core.autocrlf,把结果对一遍——这条命令只读得到本机配置,别指望在一台机器上查出全组的设置。core.autocrlf 就是按机器生效的,各人不一样就会互相污染;.gitattributes 是随仓库走的,所以永远优先用后者定规则,前者只当兜底。

四、整文件变红之后,仓库层面怎么收拾

先分清两件事:这次全文重写是不是必须的。如果是你有意做的规范化,那就让它单独成一个提交,提交信息写清楚是纯行尾规范化,然后把这个提交的完整哈希写进仓库根的 .git-blame-ignore-revs(一行一个哈希),再让本地 blame 认这个名单:git config blame.ignoreRevsFile .git-blame-ignore-revs。这样以后追代码来源不会全部指向这一次机械改动。这个配置是本机级的,所以要在 README 或上手文档里写一句,让新人克隆后补上;托管平台是否自动识别该文件,各家不一样,不要当成默认生效。如果不是有意的,就地撤销:只保留你真正改的那几行,别把噪声一起提交,git diff --ignore-cr-at-eol 能帮你看清真实改动到底是哪几行。

行尾不一致的仓库还有一个隐藏成本:合并时冲突面会被放大到整个文件,因为两边每一行都”不同”。这种冲突不是逻辑冲突,人工逐行看毫无意义,交给助手处理也很容易让它顺手重写无关代码——所以正确顺序是先把两边行尾统一,再做合并,而不是在冲突里硬扛。合并冲突本身怎么用 AI 辅助收拾、哪些冲突不该交给它,见 Git 冲突与 AI 辅助解决

还有一种误判要提醒:如果你的现象是日志或命令输出”看起来少了一截”,那可能根本不是编码问题。带 \r 的输出在终端里会把光标拉回行首,后面的内容覆盖前面的内容,于是你以为内容丢了,实际它写出来了只是被盖掉——把输出重定向到文件再用 od -c 看就一清二楚。如果确认输出确实被截断,那是另一条排查线,见 输出被截断的排查

五、什么情况下别再折腾

这类问题的止损点比较清晰,因为它的验证成本极低:

  • 超过十五分钟还没验过字节,就说明你走错路了。 不是”再看看代码”,而是立刻停下来跑一次 od -An -tx1file。如果字节层面确认干净(无 BOM、纯 LF),那问题跟本篇无关,果断转去查逻辑或环境,不要继续在编码上打转。这是本篇最有用的一条:它同时是排查入口和排他依据。
  • 不要试图修历史提交里的行尾。 重写历史的代价远大于收益,团队每个人都要重新对齐本地分支。正确做法是从今天起立规则,历史里的脏数据用一次性规范化提交加 blame 忽略来消化。
  • 改动已经被搅成一锅粥时,回滚重做比拆解更快。 如果一次提交里既有全文行尾重写又有功能改动,且改动本身不大,直接回到污染前的提交、重新做一遍那几行改动,通常比用 git add -p 从几百行噪声里挑真实改动更省时间,也更不容易挑漏。判断依据就一条:真实改动的行数是不是比你拆解要看的行数少一个量级。
  • 超出你控制范围的写入端,别追求根治,转做防御。 第三方每天推给你的文件带 BOM,你改不了对方;那就在入口处统一做一次规范化,让下游代码永远只看到干净输入。把精力花在自己那一侧的确定性上,比反复交涉划算。
  • 同一个坑第二次出现,就该加自动检查而不是再修一次。 手工修第三遍是纯浪费,那时候该做的是加钩子。

六、避坑清单

  • 在 Windows 上写、在 Linux 上跑的脚本,默认就会中招。 为什么会踩:编辑器和 shell 的默认换行风格不一致,而这个差异在保存时静默发生。怎么避:仓库里放 .gitattributes*.sh 钉成 eol=lf,同时放 .editorconfig,两者一个管仓库一个管编辑器,缺一不可。
  • > 重定向生成脚本或配置文件。 为什么会踩:重定向的编码取决于当前 shell 的默认值,跨平台跨版本不一样,有的会写出带 BOM 或 UTF-16 的文件,而你以为它是 UTF-8。怎么避:生成文件一律走显式指定编码的写入方式,别吃默认值;生成完立刻 file 验一次。
  • 只在提交前用眼睛看 diff。 为什么会踩:BOM 和 CR 在 diff 视图里通常不显示,你看到的”没问题”是渲染层的结论,不是字节层的结论。怎么避:加一个提交前检查,扫描文本文件的前三字节和是否含 \r,命中就拒绝提交。规则简单到十几行脚本就能写完,比事后排查便宜得多。
  • .gitattributes 里写了 * text=auto 却没给二进制文件加 binary 为什么会踩:Git 的类型推断偶尔会把二进制文件误判成文本,然后对它做行尾转换,文件当场损坏。怎么避:显式列出项目里的二进制扩展名,尤其是图片、字体、打包产物、模型权重、数据库文件。
  • 读外部文件时硬写 encoding='utf-8' 为什么会踩:你控制不了对方的导出工具,而 utf-8 遇到 BOM 不报错,只是悄悄多给你一个字符,问题会延迟到下游某次比较或取值时才炸。怎么避:所有外部输入统一用 utf-8-sig 读,成本为零。
  • 让代码助手做跨文件批量改写时不限定行尾。 为什么会踩:写入端换了个工具,默认值就换了,一次批量操作可以污染几十个文件,而每个文件的真实改动只有一两行。怎么避:批量改写后先看 git diff --stat,如果改动行数远大于预期,先查行尾再看内容;仓库里有 .gitattributes 时提交环节还能兜一层。
  • 把这个问题当成”编码问题”一锅端。 为什么会踩:显示乱码和 BOM/CRLF 是两条不同的链路,混着查会互相干扰——前者动的是解码方式和字体,后者动的是文件字节本身。怎么避:先用一个问题分流,“是显示不对,还是程序行为不对”,前者去查解码,后者查字节。

收束

这类故障的性价比极高:定性成本是两条命令,根治成本是两个配置文件,而不去处理的话,它会以三种互不相似的面目反复消耗你。我的判断是,任何多人协作、跨平台、又有代码生成参与的仓库,都该在第一天就把 .gitattributes.editorconfig 放进去,这属于地基而不是优化。

下次遇到”明明是对的却不工作”,按这五步走:

  1. fileod -An -tx1 前三字节,确认有没有 BOM;
  2. file 输出或 od -c 确认行尾是 LF 还是 CRLF;
  3. 可疑字符串用 reprod -c 打印,看首尾有没有多出来的东西;
  4. git ls-files --eol 确认 Git 眼里这个文件是什么行尾,跟工作区是否一致;
  5. 以上全部干净,立刻结束本条排查线,转去查逻辑或环境。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。