在 macOS 上写脚本、部署到 Linux 服务器执行,是很常见的工作流。两边的命令行工具看着同名同姓,实际血统不同:macOS 那套来自 BSD,Linux 上通常是 GNU coreutils。
差异分两类。报错的那类不可怕——你立刻就知道要改。真正危险的是静默产生不同结果的那类。
下面每一条都在 macOS 26.5 与 Alibaba Cloud Linux 3 上实际跑过。
最危险的一条:sed 没有非贪婪量词
想从 HTML 里剥掉所有 <s>...</s>,很自然会写:
echo '<s>X</s>mid<s>Y</s>' | sed -E 's#<s>.*?</s>#_#g'
期望得到 _mid_。实际上:
# macOS (BSD sed)
sed: 1: "s#<s>.*?</s>#_#g": RE error: repetition-operator operand invalid
# Linux (GNU sed)
_
BSD sed 直接拒绝——? 跟在 * 后面在 POSIX ERE 里是非法的重复运算符组合。GNU sed 却安静地接受了,把 .*? 当成贪婪的 .*,于是从第一个 <s> 一路匹配到最后一个 </s>,中间的 mid 被一起吃掉,结果是单独一个 _。
这就是最坏的情况:在开发机上报错你会去修,在服务器上不报错你根本不会发现。而且它的错误方式是「吃掉太多」,在剥离脚本标签、提取字段这类场景里,往往表现为「内容莫名其妙少了一大块」。
根本原因是 POSIX 的正则语法里就没有非贪婪量词。要非贪婪,换工具:
perl -0777 -pe 's{<s>.*?</s>}{_}gs'
Perl 在两个平台上行为一致,而且 -0777 让它一次性读入整个文件,跨行匹配也不用先 tr '\n' ' '。写跨平台脚本时,凡是涉及非贪婪或跨行的正则,直接上 perl,别在 sed 上纠缠。
sed -i 的备份后缀
sed -i 's/a/b/' file # GNU:原地修改
sed -i '' 's/a/b/' file # BSD:必须显式给一个空的备份后缀
把 GNU 的写法拿到 macOS 上跑,-i 会把后面的 s/a/b/ 当成备份后缀,然后把 file 当成脚本去解析,报一句莫名其妙的错。反过来把 BSD 写法拿到 Linux,'' 会被当成脚本,同样出错。
这条至少两边都会报错。可移植的做法是绕开 -i:
sed 's/a/b/' file > file.tmp && mv file.tmp file
date 的日期运算
date -d '+1 day' +%F # GNU
date -v+1d +%F # BSD
两边互不认识对方的选项,会直接报 illegal option / invalid option。
日期计算在脚本里很常见(算日志保留期、算证书到期日),如果要跨平台,要么检测一次分支,要么干脆用 python3——现在两个平台都默认带。
stat 的格式化选项
stat -c%s file # GNU:文件大小
stat -f%z file # BSD:文件大小
同样是互相报错。要拿文件大小,wc -c < file 在哪都能用,虽然它要读一遍文件。
xargs 的空输入行为
这条是反直觉的——BSD 的默认行为反而更安全:
printf '' | xargs echo "RAN"
GNU 会输出 RAN(命令照跑一次,只是没有参数),BSD 什么都不输出。
所以 find ... | xargs rm 这种管道,在 Linux 上如果 find 没匹配到任何东西,rm 仍会被执行一次。GNU 提供 -r(--no-run-if-empty)来抑制,但这个选项 BSD 老版本不认。
最稳的写法是完全绕过 xargs:
find . -name '*.tmp' -delete
find . -name '*.tmp' -exec rm {} +
顺带一提:macOS 的 bash 停在 3.2
$ bash --version
GNU bash, version 3.2.57(1)-release
macOS 26 了,/bin/bash 还是 2007 年的 3.2.57——因为 bash 4 换成了 GPLv3,Apple 不再随系统分发。
这意味着你在 Linux 上习以为常的东西在 macOS 的 /bin/bash 里全都没有:关联数组(declare -A)、${var^^} 大小写转换、mapfile / readarray、&>> 追加重定向。
还有个更隐蔽的:在某些 locale 下,bash 3.2 会把多字节字符当作变量名的一部分。
set -u
TITLE="标题"
echo "取到了「$TITLE」" # bash 3.2: TITLE」: unbound variable
它把 」 一起算进了变量名。写中文提示信息时,紧邻中文的变量引用一律加花括号:
echo "取到了「${TITLE}」"
这个习惯值得无条件养成,反正加了没坏处。
实用的应对策略
不必把所有脚本都写成完美可移植的,那样成本太高。按场景选:
只在服务器上跑的脚本,就假定 GNU,开头写清楚 #!/usr/bin/env bash 并在注释里注明目标平台。别为了假想的可移植性把代码写复杂。
两边都要跑的脚本,避开差异点:非贪婪正则和跨行处理交给 perl,复杂的文本处理交给 python3,日期运算也交给 python3。sed 只用来做最简单的单行替换。
在 macOS 上想要 GNU 的行为,装 coreutils 即可,但注意它默认加 g 前缀:
brew install coreutils gnu-sed
gsed --version
gdate -d '+1 day'
想去掉前缀需要手动把 gnubin 加进 PATH。我个人不建议这么干——你的开发机行为和别人的不一样,脚本在你这跑通了不代表在同事那能跑通,反而更容易出问题。
小结
清单可以慢慢背,但有一条判断值得记住:同一段代码在两个平台上都不报错,不代表结果相同。 涉及文本处理的地方尤其如此。
真要确认,最快的办法是把那一行拿到两个平台上各跑一次看输出,比查文档快得多。