一个 shell 脚本,你在 Windows 上写完、测试通过,信心满满地 scp 到服务器上跑。结果各种诡异:命令被拼错、#!/bin/bash 像是失效了、偶尔还报一句莫名其妙的「找不到命令」。你盯着服务器上的文件看,内容明明和本地一模一样。
现象
常见的几种表现:
- 执行时报
$'\r': command not found或command not found: ...,但报错的命令名看起来是对的; - 脚本开头那行 shebang 似乎没生效,被当成普通命令执行;
- 脚本能跑,但传到子脚本的变量带了看不见的尾巴,导致字符串比较永远不相等;
- 更隐蔽的:
case语句、if [ "$x" = "y" ]判断总是走进错误分支。
最让人抓狂的是——用编辑器打开,两边的文件看起来完全相同。
根因
差别在行尾那些看不见的字符。
Windows 的文本文件默认用 CRLF(\r\n)作为行结束符,也就是回车(Carriage Return,\r,字节 0x0D)加换行(Line Feed,\n,字节 0x0A)。而 Unix/Linux 用 LF(\n)单字节。
把 CRLF 文件搬到 Linux 后,每一行末尾都多了一个 \r。Linux 的 shell 把它当成行内容的一部分,而不是行结束符。于是:
- 变量赋值
FOO=bar实际变成了FOO=bar\r,字符串比较、命令参数都会带上这个尾巴; - shebang 行
#!/bin/bash实际是#!/bin/bash\r,内核去找一个名字里带\r的解释器,当然找不到,于是 shebang 失效; - 命令行的最后被塞进一个
\r,解析时就可能出现「命令名是ls\r」这种「看着对、其实找不到」的错误。
解决
上传后先去掉回车再运行:
sed -i 's/\r$//' script.sh
这行的意思是:把每一行末尾的 \r(正则 \r$,$ 表示行尾)替换成空。-i 表示原地修改文件。
如果文件多,可以批量处理:
find . -name '*.sh' -exec sed -i 's/\r$//' {} +
更根本的办法是在源头就存成 LF。 主流的编辑器都支持设置默认换行符:
- VS Code:右下角点击
CRLF,切换为LF;也可以在设置里把files.eol设为\n; - 在仓库里加一个
.gitattributes,让 Git 自动规范化:
* text=auto eol=lf
*.sh text eol=lf
这样即使本地是 Windows,检出到 Linux 时也是 LF。
还有个更省事的做法:用 dos2unix / unix2dos 工具,名字就是专门干这个的:
dos2unix script.sh
延伸与预防
值得单独说一句的是——这个坑最狠的地方在于它不一定立刻报错。
\r 混进变量之后,很多场景下程序「照常运行」,只是结果错得离谱。比如文件名比较、状态判断、字符串拼接,都可能在带 \r 的情况下静默地走错分支。你以为脚本逻辑有问题,其实是每一行的末尾多了一个看不见的字节。所以凡是遇到「逻辑明明对、结果却不对」的 shell 脚本,除了查逻辑,也要查一遍行尾。
把「换行符」当成文本文件的第一属性来管理,而不是事后补救。
一个实用的判断技巧:当你在 Linux 上看到「文件看起来对、但命令找不到或判断异常」时,第一反应就该是查行尾。快速确认的办法:
file script.sh # 会提示 "with CRLF line terminators"
cat -A script.sh | head # 行尾的 ^M 就是 \r
cat -A 会把不可见字符显示成可见形式,^M 就代表回车。
顺带说,同样的坑反过来也成立:Linux 脚本传到 Windows、或者在 Windows 上被某些工具读取时,缺了 CRLF 也可能出问题(比如前一篇讲的批处理)。所以跨系统搬运文本,永远先确认一件事:这份文件的换行符,接收方认不认。