Skip to content
返回博客
教程

换行符 CRLF 与 LF 的区别:四种故障形态和 Git 配置

带 CRLF 的脚本常常正常跑完、退出码还是 0,问题藏在变量里。看清四种故障形态、各 shell 的报错文案差异,以及 core.autocrlf 三个值真正存进仓库的东西。

14 分钟

换行符 CRLF 与 LF 的区别:四种故障形态和 Git 配置

换行符 CRLF 与 LF 的差别只有一个字节。LF 是单独一个 \n(0x0A),Linux 和 macOS 用它结束一行;CRLF 是两个字节 \r\n(0x0D 0x0A),Windows 用它结束一行。

大多数文章在下面这点上都写错了:用 CRLF 换行的 shell 脚本通常不会报错。它照常运行,打印出你预期的内容,退出码是 0。坏掉的是变量里的那个值,赋值语句把结尾的 \r 一起收了进去,整个过程一声不吭。

实测下来,故障分成四种形态,按发现难度从高到低排列:

  1. 静默成功:输出看着没问题,某个变量里藏着一个看不见的 \r,退出码 0。
  2. 报错但什么都没改变:空行上抛出 : command not found,脚本照样跑到最后,退出码 0。
  3. 语法错误iffor 和函数定义全部失效,退出码 2。
  4. bad interpreter:shebang 行带上了 \r,退出码 126。

动手排查之前先跑一句 file yourfile,一行输出就能告诉你这个文件用的是 CRLF 还是 LF。

下文所有结果均在 macOS(Darwin arm64)上实测,环境为 bash 3.2.57(1)-release、zsh 5.9、dash、git 2.55.0、node v26.7.0 与 Python 3.14.6,并通过 docker run --rm bash:5(即 GNU bash 5.3.15(1)-release,aarch64-unknown-linux-musl)在 Linux 侧交叉验证。

1. CRLF、LF 与 CR:换行符到底是哪几个字节

CRLF 是 carriage return line feed(回车 + 换行)的缩写,字面意思就是两个字符粘在一起:

名称转义写法字节
回车(carriage return)\r0x0D
换行(line feed)\n0x0A
CRLF\r\n0x0D 0x0A

各平台默认写入哪一种:

平台换行符
WindowsCRLF(\r\n
Linux、现代 macOSLF(\n
Mac Classic、OS 9 及更早单独的 CR(\r

这两个名字来自机械动作。在电传打字机上,回车把打印头推回左边距,换行把纸卷上滚一行。Windows 保留了两个动作,于是有了两个字节;Unix 认为一个就够。单独的 CR 至今仍会出现在一些老系统的导出文件里,而这样的文件在大多数 Unix 工具眼里就是一整行超长文本。

一条命令就能看出你手上是哪种

file 会读取字节并直接说出行终止符的类型:

$ file d1.txt
d1.txt: ASCII text, with CRLF line terminators

$ file d2.txt
d2.txt: ASCII text no suffix means pure LF

$ file d3.txt
d3.txt: ASCII text, with CRLF, LF line terminators mixed, file lists both

第三种情况最值得记住。混合换行符意味着两个配置不同的工具先后写过这个文件:编辑器保存成 LF,脚本又追加了 CRLF,或者一次合并把两个版本缝在了一起。od -c 能逐字节看清分界:

$ od -c d3.txt
0000000   a  \r  \n   b  \n
0000005

换行符的问题出在字节层,和字符编码挨在一起;再上一层的坑可以看 UTF-8 与 UTF-16 编码指南

2. 四种故障形态:从静默成功到退出码 126

同样是 \r\n 结尾的文件,行内容不同,结果也完全不同:

脚本内容实际发生了什么退出码
echo hello 之类的简单命令静默成功,输出完全正确0
X=abc 赋值静默成功,但值的结尾多了一个 \r0
空行、结尾带 \ 的续行: command not found脚本继续跑到最后0
if/fifor/do/donef() {syntax error near unexpected token2
shebang 行带 \rbad interpreter: No such file or directory126

前两行是本文要纠正的地方。「CRLF 会导致 command not found」这句话到处都在流传,但简单命令根本不会有任何抱怨。

最危险的是静默成功

$ printf 'echo hello\r\necho world\r\n' > win.sh && bash win.sh
hello
world
[exit=0]

两行进,两行出,退出码 0。没有东西可调试,日志里也 grep 不出线索。但只要让同一个脚本做一次比较,画风立刻就变了:

$ printf 'V=1.2.3\r\ntest "$V" = "1.2.3" && echo MATCH || echo NO-MATCH\r\n' > v.sh
$ bash v.sh
NO-MATCH
[exit=0]

$V 里存的是 1.2.3\r,不是 1.2.3。macOS 与 Linux 上结果一致。版本闸门、if [ "$ENV" = "prod" ]、从文件里读出来的功能开关,全都会悄无声息地走进错误的分支,退出码依然是 0。这是 CI 里最难找的换行符 bug,构建是绿的,日志是干净的。

拦不住任何东西的报错

在 CRLF 文件里,空行的真身是一行只包含 \r 的内容,而 shell 会把它当成一条要执行的命令。执行失败,打印一句提示,然后脚本继续往下走,最后以退出码 0 结束。具体文案取决于你用的是哪个 shell,下一节把四种都列了出来。

结尾带反斜杠的续行也是同一种坏法。\r 卡在反斜杠和换行之间,于是续行不再是续行,下一行变成了独立执行的命令。

语法错误,以及为什么 fi 不等于 fi

c_if.sh: line 5: syntax error: unexpected end of file
[exit=2]

同一个文件,Linux 上的 bash 5.3.15 说得更详细:

i.sh: line 5: syntax error: unexpected end of file from `if' command on line 2

循环和函数定义则是在第 1 行就失败:

c_for.sh: line 1: syntax error near unexpected token `do'
c_for.sh: line 1: `for i in 1 2; do'

c_func.sh: line 1: syntax error near unexpected token `{'
c_func.sh: line 1: `f() {'

机制清楚之后,这一整类错误都变得可预测。bash 读到的结束词是 fi\r 而不是 fifi\r 不是关键字 fi,所以 if 块永远没有闭合,bash 会一直读到文件耗尽,报错于是指向最后一行,而不是真出问题的那一行。

bad interpreter 与退出码 126

$ ./s.sh
bash: ./s.sh: /bin/bash^M: bad interpreter: No such file or directory
[exit=126]

macOS 和 Linux 打印的文案完全一样,退出码都是 126。注意看提示里的那个路径:/bin/bash^M。内核会把 #! 之后直到换行为止的全部内容当作解释器路径,而 \r 也在其中。这样的文件当然不存在,于是 exec 在你的脚本执行第一行之前就失败了。

退出码 126 同时也覆盖「文件存在但不可执行」,所以脚本起不来不一定就是换行符的问题,Linux 文件权限也会产生同一族的失败。区分两者的关键,是路径里有没有那个 ^M

为什么你永远看不见那个 \r

光看输出没用,这个字节没有可见的形状,也没法用鼠标选中。你得想办法把它逼出来:

$ bash d.sh                    # script: HOST=example.com / echo "connecting to $HOST:8080"
connecting to example.com:8080 what you get

$ bash d.sh | cat -v
connecting to example.com^M:8080^M what is actually there

$ bash d.sh | od -c
0000000   c   o   n   n   e   c   t   i   n   g       t   o       e   x
0000020   a   m   p   l   e   .   c   o   m  \r   :   8   0   8   0  \r
0000040  \n

两个 \r 字节,在正常输出里完全隐形,而它们都落在一个即将被当作主机名使用的字符串里。有时候这个字节会顺着参数传下去,最后由一个毫不相干的工具来背锅:

$ bash v.sh          # the script pipes through: ... | head -2
head: illegal line count -- 2\r

head 的行为完全正确,是脚本递给它的参数本来就是 2\r。报错里引用的那个值只要多带一个 \r,那就是这个 bug 顶着别人的名字出现。

3. 为什么你的报错和网上搜到的完全不一样

同一个文件(printf 'echo a\r\n\r\necho b\r\n',其中第 2 行是只含 \r 的空行),分别放到四个 shell 下执行:

Shell版本报错原文
bash(macOS 自带)3.2.57(1)-releases_blank.sh: line 2: : command not found
bash(主流 Linux 发行版)5.3.15(1)-releaset.sh: line 2: $'\r': command not found
zsh5.9s_blank.sh:2: command not found: ^M
dash——s_blank.sh: 2: : not found

几乎所有搜索结果引用的都是第二行。bash 4 和 5 会用 ANSI-C quoting 打印不可见字符,于是回车被写成 $'\r'。macOS 自带的 bash 是 3.2,不做这件事,所以你看到的是一个冒号、一个空格,中间什么都没有。zsh 打印 ^M。dash 则干脆把 command 这个词都省掉了。

同一个故障,四种文案。如果你把自己那条报错原样粘进搜索框却什么有用的都搜不到,原因就在这里。光秃秃的 : command not found 和那条广为流传的报错是同一个 bug。

4. Git:core.autocrlf 到底往仓库里存了什么

多数 git autocrlf 的讲解停在定义层面,可你要知道的是另一件事:提交里最终存的是什么,队友检出时拿到的又是什么?下表用 git cat-file -p HEAD:f.txt 查仓库里的 blob,用 rm f.txt && git checkout -- f.txt 查工作区副本,逐一实测:

core.autocrlf源文件仓库里的 blob检出后的工作区副本
trueCRLFLFCRLF
trueLFLFCRLF
inputCRLFLFLF
inputLFLFLF
falseCRLFCRLFCRLF
falseLFLFLF

这张表能直接推出三个结论:

  1. trueinput 都能保证仓库里存的是 LF,区别只在检出:true 会转回 CRLF,input 则原样不动。
  2. 只有 false 会把 CRLF 写进提交。 当有人追问那些回车符究竟是谁提交的,答案就是这一行。
  3. 第二行最反直觉:在 true 下,磁盘上原本是 LF 的文件,检出之后会变成 CRLF。

Git 会在改写发生之前发出预告,形式有两种:

warning: in the working copy of 'f.txt', LF will be replaced by CRLF the next time Git touches it
warning: in the working copy of 'f.txt', CRLF will be replaced by LF the next time Git touches it

「我什么都没改,git 却说整个文件都变了」

还是第二行。在 git config core.autocrlf true 下,检出会把工作区里的 LF 文件改写成 CRLF。此时每一行都和 blob 差了一个字节,于是 git diff 把每一行都报成已修改,pull request 里一个没人碰过的文件显示为全文重写。动手的是检出过滤器。

反过来一样吵:一位队友用 false 提交了 CRLF,而你用的是 input,于是你从没打开过的文件出现在了你的 diff 里。

5. .gitattributes 才是团队层面的答案

core.autocrlf 是单台机器上的设置,别人看不见。.gitattributes 是仓库里的一个文件,会随着每一次克隆一起走。两者冲突时,attributes 获胜。四种写法全部实测过:

.gitattributescore.autocrlf源文件blob检出后胜出方
* text=autofalseCRLFLFLFattributes
* text eol=crlfinputLFLFCRLFattributes
* -texttrueCRLFCRLFCRLFattributes
* text eol=lftrueCRLFLFLFattributes

第三行值得记住:-text 会彻底关掉转换,并且照样压过 core.autocrlf=true。需要让字节原封不动地存活下来的文件,就靠它来保护。

一份可以直接抄走的 .gitattributes

* text=auto

*.sh      text eol=lf
*.bash    text eol=lf
Makefile  text eol=lf

*.bat     text eol=crlf
*.cmd     text eol=crlf
*.ps1     text eol=crlf

*.png     -text
*.jpg     -text
*.pdf     -text
*.zip     -text

text=auto 会把 Git 判定为文本的内容在仓库里统一成 LF。显式的 eol=lf 那几行负责那些无论谁写都必须是 LF 的文件,因为一个 CRLF 的 .sh 就是一次等着发生的退出码 126。Windows 那几类脚本反过来给 eol=crlf,二进制模式则用 -text,让转换完全不发生。

core.safecrlfcore.eol

这两个设置常和 core.autocrlf 一起出现,做的却是别的事:

  • core.safecrlf 是一道守卫,不是转换器。当某次转换无法往返还原时(比如混合换行符的文件,归一化会丢信息),true 会拒绝这次操作,warn 则放行并给出警告。它从不改变最终存下来的字节,只是拒绝无声无息地做那些有损的转换。
  • core.eol 决定的是:当 core.autocrlffalse 时,Git 在工作区里给标记为 text 的文件写哪种行尾。取值是 lfcrlfnativecore.autocrlf 会覆盖它,所以在 autocrlf 还开着的机器上改 core.eol,看起来像是什么都没发生。

改了 .gitattributes,已经提交的文件不会自己变好

attributes 只在 Git 写入或读取文件时生效,所以已有内容会一直保持原样,直到有什么东西去重写它。这一遍得你自己强制跑:

$ git add --renormalize .
$ git commit -m "Normalize line endings"

diff 会很大,这本来就是目的。放在独立分支上做,用一个提交合进去,落地之前先通知所有人。

6. 把 CRLF 转成 LF,以及反向转换

四种做法,在 Darwin 上均已验证能去掉 \r

命令结果
tr -d '\r' < f > f.out有效
perl -pi -e 's/\r\n/\n/g' f有效
sed -i '' -e 's/\r$//' f(BSD 写法)有效
sed -i -e 's/\r$//' f(GNU 写法,在 macOS 上运行)有效,但会留下一个垃圾文件

macOS 上的 GNU sed -i 陷阱

BSD 的 sed 要求 -i 后面必须跟一个备份后缀。原样照抄一篇 Linux 教程,BSD sed 就会把 -e 当成这个后缀吞掉。你的编辑照样生效,同时还多出这么一样东西:

a5.txt
a5.txt-e

a5.txt-e 是一份备份副本,因为 sed-e 读成了你指定的后缀。macOS 上正确的写法是显式传一个空字符串:sed -i '' -e 's/\r$//' f。一个仓库里要是提交进了以 -e 结尾的文件,那就说明有人在 Mac 上跑了 GNU 版的一行命令。

macOS 上没有 dos2unix

在原装 macOS 系统上,command -v dos2unix 什么都不会返回,这个二进制得靠 brew install dos2unix 装。所以网上被抄得最多的那个答案,在很多人手头的那台机器上根本跑不通。tr -d '\r' 不需要安装,做的是同一件事。

反方向也一样,unix2dos 有同样的可用性问题,替代写法是 sed -e 's/$/\r/'

文件恢复成干净的 LF 之后,才能重新当成一份行列表来用。凡是把整行当字符串来比较的场景都受这一点影响:文本行排序在线删除重复行都会把 value\rvalue 读成两行不同的内容,于是一个多余的回车就悄悄让去重失效了。

在编辑器里

VS Code 会在右下角状态栏显示当前文件的换行符是 CRLF 还是 LF,点一下就能切换。files.eol 设置控制新建文件的默认值,按工作区配置它可以让平台混杂的团队保持一致。其他编辑器提供的是同样两个开关,只是名字不同。容易被忽略的是:单文件的指示器和默认设置是两回事,改动其中一个不会碰到另一个。

7. 在代码里处理换行符

同一个文件 line1\r\nline2\r\n,用七种入口去读:

入口读到的内容\r
Node fs.readFileSync(f,"utf8")"line1\r\nline2\r\n"保留
Node,同一个值再 .split("\n")["line1\r","line2\r",""]每一行都保留
Node readlinecrlfDelay:Infinity["line1","line2"]已剥除
Python open(f),默认模式'line1\nline2\n'已转换
Python open(f).readlines()['line1\n','line2\n']已转换
Python open(f, newline="")'line1\r\nline2\r\n'保留
Python open(f,"rb")b'line1\r\nline2\r\n'保留

这张表能直接结掉一类常见的 bug 报告:同样的东西在 Python 里好好的,到 Node 里就坏了。Python 的默认文本模式启用了 universal newlines,在你看到之前就把 \r\n 翻译成了 \n;Node 则原样把字节交给你。两者都没错,但读同一个文件,结果立刻就不一样。

rstrip("\n") 会把 \r 留在原地

original: 'line1\r\n'  | rstrip("\n"): 'line1\r'  | strip(): 'line1'

rstrip("\n") 只删除你列出来的那些字符,而 \r 不在列表里。结果就是它和本该相等的一切都比不上号。「我明明 strip 过了,怎么还是不相等」,答案就在这里。改用 strip(),或者不带参数的 rstrip(),所有结尾空白字符都会被去掉,回车也在其中。

在两个平台上都安全的行拆分

在 JavaScript 里,用一个同时容纳两种行尾的模式来切分:text.split(/\r?\n/)。在 Python 里,要么留在默认文本模式让 universal newlines 去处理,要么调用 splitlines(),它能同时应付 \r\n\n 和单独的 \r

写入是另外一半。Node 会原样写下你给它的字节,所以字符串用 \n 拼,落到磁盘上是什么形态交给 .gitattributes 决定。Python 的 open(path, "w") 会把 \n 翻译成当前平台的行尾,除非你传 newline=""。CSV 写入器要求加这个参数,正是出于这个原因。

行尾的回车和文件开头的字节顺序标记(BOM)是同一类 bug 的两端:一个看不见的字节,能在复制粘贴里活下来,让相等判断失败。另一端可以看 UTF-8 BOM 排查指南

8. 换行符还会在哪些地方咬人

CSV 与 Excel。 RFC 4180 规定 CRLF 是记录分隔符,这让 CSV 成为少数几个 CRLF 属于正确而非缺陷的场合。只按 LF 输入编写的解析器会在每一行的最后一个字段上留下 \r,所以如果 CSV 转 JSON 出来的值看着没问题、比较却对不上,先查这个字节。

Docker。 把一个 CRLF 的 .sh 复制进镜像,就是第四种故障形态。COPY 会原样保留字节,shebang 留着它的 \r,容器以 126 退出。在 .gitattributes 里加一行 *.sh text eol=lf,就能挡掉这一整类问题。

Diff 与 pull request。 一个没有任何可见改动、却被标成全文变更的文件,就是第 4 节那套机制走到了代码评审里。用文本对比在线工具比一下两个版本,几秒钟就能确认,文本对比工具指南讲了怎么读比对结果。

混合换行符的文件。 ASCII text, with CRLF, LF line terminators 说明有两个配置不同的写入方。要归一化就整个文件一起来,别只处理你恰好改到的那几行,否则下一次 diff 一样吵。

FAQ

CRLF 和 LF 有什么区别?

CRLF 是两个字节 \r\n(0x0D 0x0A),LF 是一个字节 \n(0x0A)。Windows 写 CRLF,Linux 和 macOS 写 LF,两者都表示一行的结束。文本在编辑器里看起来完全一样,差别只在字节、字符串比较和 diff 里才现形。

我的脚本是 CRLF 换行,为什么没有报错?

因为简单命令能带着这个多余的字节活下来。结尾带 \recho hello 照样执行并退出 0。只有在解析器在意的地方才会报错:空行、fi 这类关键字,或者 shebang。危险的是赋值语句,它们会成功,并把 \r 存进变量里。

$'\r': command not found 是什么意思?为什么我没看到这条?

它表示 shell 试图执行一行只有回车的内容。bash 4 和 5 用 ANSI-C quoting 打印这个字符,于是得到 $'\r'。macOS 自带的 bash 3.2 在两个冒号之间什么都不打印,zsh 打印的则是 ^M。同一个故障,三种不同的文案。

core.autocrlf 应该设成 trueinput 还是 false

Linux 和 macOS 上用 input,Windows 上用 true,但比这两个设置都更该优先的是 .gitattributes。实测结果是:trueinput 都会在仓库里存 LF,只有 false 会让 CRLF 进入提交;true 还会在检出时把工作区里的 LF 文件改写成 CRLF。

.gitattributescore.autocrlf 冲突时,谁说了算?

.gitattributes 说了算。实测的四种写法(* text=auto* text eol=crlf* -text* text eol=lf)全部压过了本地的 core.autocrlf 取值。这正是该用它的理由:attributes 会被提交,对所有人生效;而 core.autocrlf 是一台机器上的设置,你既看不见也强制不了。

怎么检查一个文件用的是 CRLF 还是 LF?

file yourfile。CRLF 会打印 ASCII text, with CRLF line terminators,纯 LF 打印 ASCII text 且完全没有后缀,混合文件则打印 with CRLF, LF line terminators。想在字节层面确认,就跑 od -c,看每个 \n 前面是不是坐着一个 \r

到底什么时候应该用 CRLF?

当某个格式或协议要求它的时候。RFC 4180 把 CRLF 定为 CSV 的记录分隔符,HTTP 头部和 SMTP 在传输层面也是同样的规定。Windows 的批处理和 PowerShell 文件用 CRLF 也更安全。剩下的一切,源代码、shell 脚本、配置文件,都用 LF。

标签: line-endings crlf git cross-platform shell