一个在 Linux 上跑得好好的 Shell 脚本,拷到同事的 Windows 机器上再传回来,突然开始报错:bash: $'\r': command not found。脚本内容一个字都没改。类似的情况还有很多——用记事本打开一个配置文件,整个文件挤成一行;Git 提交后 diff 显示全文件都被修改。这些现象背后是同一个原因:换行符在不同平台上不是同一个字符。
同一个「回车」,三种写法
在 ASCII 控制字符表里,有两个字符与换行有关:CR(Carriage Return,码值 13,十六进制 0x0D)和 LF(Line Feed,码值 10,十六进制 0x0A)。不同系统用不同的组合表示一行结束:
| 系统 | 换行表示 | 字节序列 |
|---|---|---|
| Unix / Linux / 现代 macOS | LF | 0x0A |
| Windows | CR + LF | 0x0D 0x0A |
| 经典 Mac OS(9 及更早) | CR | 0x0D |
这三种写法在文本编辑器里看起来完全一样,只有把文件按十六进制打开才能看出区别。
为什么会有两个字符
CR 和 LF 的区分来自打字机时代。打字机的换行动作实际上是两个独立的机械操作:Carriage Return 把字车推回行首,Line Feed 把纸张向上卷一行。这两个动作可以由两个不同的按键分别触发,也可以组合执行。
电传打字机继承了这个设计,把两个动作对应成两个控制字符。Unix 的设计者后来认为,行末只需要一个字符就够表达「换行」这个语义,于是简化为单个 LF;Windows 的前身沿用了 CRLF 的组合,一直保留到今天;早期的 Mac OS 则选择了单独的 CR。
三个分支各自延续,就形成了今天的兼容问题。
踩坑时的典型表现
换行符差异造成的故障,表现形式很有辨识度。
最常见的是「整个文件变成一行」。用 Windows 记事本打开一个只有 LF 的文件时,老版本记事本不把单独的 LF 当作换行,于是所有内容连成一片。反过来,用某些 Unix 工具打开 CRLF 文件时,\r 会被当成行内的普通字符。
第二种是「每行末尾多一个怪符号」。\r 在很多终端和编辑器里显示为 ^M 或者一个方块。用 vim 打开文件看到满屏 ^M,就是 CRLF 被当成了行内字符。
第三种是脚本执行报错。Shell 解析脚本时按 LF 切行,行末残留的 \r 被当作命令的一部分,于是出现 $'\r': command not found 或者参数莫名多出一个字符。这类错误尤其容易出现在「Windows 上编辑、Linux 上执行」的部署流程里。
第四种是版本控制的噪声。团队里有人用 Windows、有人用 Linux,如果没有统一配置,每次提交都可能把整个文件的行尾改一遍,diff 里所有行都变成「已修改」,真正的改动被淹没。
怎么排查和修复
排查的第一步是确认文件的行尾到底是什么。命令行下有几个好用的工具:cat -A 会把行尾显示为 $,如果看到 ^M$ 就说明是 CRLF;file 命令会直接给出「with CRLF line terminators」的提示;hexdump -C 或者 od -c 可以按字节查看,直接看行尾是 0d 0a 还是 0a。
修复也很简单。系统里通常有 dos2unix 和 unix2dos 两个工具,分别用于把 CRLF 转成 LF 和反向转换。如果环境里没有,用 sed -i 's/\r$//' 文件名 也能去掉行尾的 CR。
对于团队协作,更根本的解法是在仓库层面统一。Git 提供了 core.autocrlf 配置,也可以把规范写进 .gitattributes,例如对脚本文件声明 *.sh text eol=lf,强制检出时使用 LF。这样无论开发者本地是什么系统,仓库里保存的行尾都是一致的。
编辑器层面也建议打开「显示行尾符」的选项。Visual Studio Code、Sublime Text、Notepad++ 都支持显示 CRLF 或 LF 标记,一眼就能看出文件用的是哪种。
检查清单
- 记住三种行尾:LF 用于 Unix 与 Linux,CRLF 用于 Windows,CR 用于经典 Mac OS
- 用
cat -A、file、hexdump -C确认文件真实行尾,不要靠编辑器外观判断 - 转换用
dos2unix、unix2dos或sed -i 's/\r$//' - 脚本类文件统一使用 LF,避免跨平台执行时报
$'\r'错误 - 在仓库里用
.gitattributes固定行尾规范,减少 diff 噪声 - 打开编辑器的行尾显示选项,让问题在写入前就暴露