
跨平台的批量替换脚本,在 Linux 上跑得好好的,搬到 macOS 第一次执行就退出。命令是 sed -i 's/aaa/bbb/' f2.txt,退出码 1,文件一个字节都没动,报错把文件名当成脚本念了出来。顺着这条报错往下查,同一行 sed 在两套实现之间有几处写法不一样,其中一处还跟流传的说法相反。
1 --version 要不到版本号,只能看 usage
判断实现的第一反应是问版本号。GNU 那边回一段版本信息,macOS 自带的这一版直接拒绝,退出码 1,后面跟出两行 usage。第二行写着 -i extension,这个 extension 就是后面所有麻烦的源头。顺手看了一眼 command -v gsed,返回 1,本机没有装 GNU 版,另一侧的对照物不存在,凡是没有实测的地方只能注明。
sed --version; echo "退出码 $?"
command -v gsed || echo "没有装 gsed"
== 1 版本号这一栏是空的 ==
$ sed --version
sed: illegal option -- -
usage: sed script [-EHalnru] [-i extension] [file ...]
sed [-EHalnu] [-i extension] [-e script] ... [-f script_file] ... [file ...]
退出码 1
--- 有没有装 GNU 版 ---
$ command -v gsed
退出码 1
$ ls -l /usr/bin/sed
-rwxr-xr-x 1 root wheel 170320 Nov 22 2025 /usr/bin/sed
退出码 0
检查点:--version 走不通,认实现要看 usage 里那句 -i extension
看到 illegal option -- - 和那段以 sed script 开头的 usage,就基本可以确定手上这版是 BSD 系。这条路走不通,后面只能靠行为反推。
2 少一个参数,文件名被顶到脚本位置
按 GNU 的写法直接跑。-i 后面的 s/aaa/bbb/ 被当成备份后缀吃掉,剩下的第一个参数 f2.txt 落到本该放脚本的位置,字符 f 不是 sed 的命令,于是报 invalid command code f。文件内容没变,目录里也没有多出任何备份,看起来像什么都没发生。
printf '%s\n' 'aaa one' 'aaa two' > f2.txt
sed -i 's/aaa/bbb/' f2.txt; echo "退出码 $?"
cat f2.txt
== 2 -i 后面少一个参数 ==
$ cat f2.txt
aaa one
aaa two
$ sed -i s/aaa/bbb/ f2.txt
sed: 1: "f2.txt
": invalid command code f
退出码 1
$ cat f2.txt 文件有没有被改
aaa one
aaa two
--- 后面跟两个文件时,第一个文件被当成脚本 ---
$ sed -i 's/aaa/bbb/' f2.txt g2.txt
sed: 1: "f2.txt
": invalid command code f
退出码 1
$ ls -a
.
..
f2.txt
g2.txt
检查点:报错之后文件没变,目录里也没有多出备份
参数位置被整体推了一格。后面跟两个文件时也一样,死在第一个文件上,第二个连碰都没碰。批量脚本里这种错法很容易被日志吞掉,因为退出码没人看。
3 补一个空字符串,位置就对上了
把备份后缀显式写成空串,位置重新对齐。sed -i '' 's/aaa/bbb/' f3.txt 退出码 0,内容换成 bbb,目录里依旧只有原文,没有多余文件。空串必须是一个独立的参数,写成 -i'' 紧贴在选项后面,shell 交给 sed 的还是一个光秃秃的 -i,报错跟第 2 步一字不差。
sed -i '' 's/aaa/bbb/' f3.txt; echo "退出码 $?"
cat f3.txt
sed -i'' 's/aaa/bbb/' f3b.txt; echo "退出码 $?"
== 3 空字符串补上去就过了 ==
$ sed -i s/aaa/bbb/ f3.txt
退出码 0
$ cat f3.txt
bbb one
bbb two
$ ls -a
.
..
f2.txt
f3.txt
g2.txt
--- 空字符串紧贴在 -i 后面并不算数 ---
$ sed -i'' 's/aaa/bbb/' f3b.txt
sed: 1: "f3b.txt
": invalid command code f
退出码 1
$ cat f3b.txt
aaa one
检查点:空字符串必须是一个独立的参数,贴在一起不算
两个单引号中间什么都不写,这是 BSD 上唯一能表达「不要备份」的说法,省不掉。
4 后缀写进 -i,备份真的落在磁盘上
-i.bak 把后缀贴在选项后面,这一侧能过,代价是目录里多一份 f4.txt.bak,里面是替换之前的原文,diff 出来正好两行改动。备份文件会被后续的文件名匹配扫到,批量改名时经常在这里翻车。BSD 还有一个 -I,man 里说它把多个文件当成一条连续的地址空间,$ 只匹配最后一个文件的末行;实测拿 1d 跑两个文件,-i 把两个文件各自的首行删掉,-I 只删了整条流的第一行,第二个文件一行没少。
sed -i.bak 's/aaa/bbb/' f4.txt; echo "退出码 $?"
cat f4.txt f4.txt.bak
diff f4.txt.bak f4.txt
sed -i '' '1d' i1.txt i2.txt
sed -I '' '1d' i1.txt i2.txt
== 4 后缀写进 -i,备份落在磁盘上 ==
$ sed -i.bak s/aaa/bbb/ f4.txt
退出码 0
$ cat f4.txt
bbb one
bbb two
$ cat f4.txt.bak
aaa one
aaa two
$ diff f4.txt.bak f4.txt
1,2c1,2
< aaa one
< aaa two
---
> bbb one
> bbb two
--- BSD 特有的 -I,把多个文件当成一条流处理 ---
$ sed -i '' '1d' i1.txt i2.txt 后
退出码 0
i1.txt:
L2
i2.txt:
M2
$ sed -I '' '1d' i1.txt i2.txt 后
退出码 0
i1.txt:
L2
i2.txt:
M1
M2
检查点:-i.bak 留下旧内容,-I 把多个文件串成一条流
-I 在 GNU 的手册里没有对应项,这一句凭记忆写的,本机没法对证。多文件批处理时用 -i 更接近直觉。
5 -E 能用,-r 也能用
流传的说法是 BSD 只认 -E,-r 是 GNU 的东西。这台机器的 man 页里,-r 那一行写着 Same as -E for compatibility with GNU sed,实测两边都对扩展正则生效:a|b 在 -E 和 -r 下都把 ab 换成了 XX,换成基本正则则原样输出,竖线只当字面量。所以这条差异在 macOS 26 上已经不存在,老机器是什么情况没有机器可试。
sed -E 's/a|b/X/g' f5.txt
sed -r 's/a|b/X/g' f5.txt
sed 's/a|b/X/g' f5.txt
== 5 扩展正则 -E 和 -r ==
$ man sed 里这两行
-E Interpret regular expressions as extended (modern) regular
expressions rather than basic regular expressions (BRE's). The
-r Same as -E for compatibility with GNU sed.
$ sed -E s/([0-9]+)/[\1]/ f5.txt
order-[1234]-x
ab
退出码 0
$ sed -r s/([0-9]+)/[\1]/ f5.txt
order-[1234]-x
ab
退出码 0
$ sed -E 's/a|b/X/g' f5.txt
order-1234-x
XX
退出码 0
$ sed -r 's/a|b/X/g' f5.txt
order-1234-x
XX
退出码 0
$ sed 's/a|b/X/g' f5.txt 基本正则下竖线是字面量
order-1234-x
ab
退出码 0
$ sed -r -i s/([0-9]+)/[\1]/ f5.txt
退出码 0
$ head -1 f5.txt
order-[1234]-x
检查点:-E 与 -r 在本机等价,BRE 下竖线是字面量
判断「-r 在 Mac 上一定报错」之前,先本机跑一次。写进文档的经验,过了几个系统大版本就可能是错的。
6 替换串里的反斜杠 n 展开了
这一条我原来也信:BSD sed 不把 \n 当换行,得在脚本里敲一个真实的换行。实测反过来,hexdump 里是 78 0a 79,x 和 y 之间是真正的换行字节;\t 同理,落盘是 09。想要字面量得写双反斜杠,字节变成 5c 6e。脚本里直接敲换行的写法和 \n 的输出完全重合,两种写法在这台机器上等价。
sed 's/a first/x\ny/' f6.txt | hexdump -C
sed 's/a first/x\\ny/' f6.txt | hexdump -C
sed 's/a first/x\ty/' f6.txt | hexdump -C
== 6 替换串里的反斜杠 n ==
$ sed 's/a first/x\ny/' f6.txt
x
y
a second
退出码 0
$ sed 's/a first/x\ny/' f6.txt | hexdump -C
00000000 78 0a 79 0a 61 20 73 65 63 6f 6e 64 0a |x.y.a second.|
0000000d
$ 换成双反斜杠 | hexdump -C
00000000 78 5c 6e 79 0a 61 20 73 65 63 6f 6e 64 0a |x\ny.a second.|
0000000e
$ 换成反斜杠 t | hexdump -C
00000000 78 09 79 0a 61 20 73 65 63 6f 6e 64 0a |x.y.a second.|
0000000d
--- 脚本里直接敲一个换行 ---
$ sed 脚本里写真实换行 | hexdump -C
00000000 78 0a 79 0a 61 20 73 65 63 6f 6e 64 0a |x.y.a second.|
0000000d
检查点:单反斜杠展开成 0a,双反斜杠才保留字面量
终端回显会把两者显示得一模一样,判断只能落在字节上。这条结论跟旧资料冲突,照抄经验的人会写错。
7 换行这件事交给 perl 也行
不想在方言上纠缠时,跨行替换直接用 perl -0pi -e:-0 把整个文件读成一条记录,替换串里的 \n 直接就是换行,不用额外参数。od 出来的结果跟第 6 步 sed 落盘的字节完全一致,两种写法在文件上分不出区别。系统自带的是 perl 5.34.1,不用另外装。
perl -0pi -e 's/a first/x\ny/' f7.txt
od -c f7.txt
== 7 换行交给 perl 处理 ==
$ perl -0pi -e s/a first/x\ny/ f7.txt
退出码 0
$ od -c f7.txt
0000000 x \n y \n a s e c o n d \n
0000015
--- sed 原地改同一处,字节对比 ---
$ od -c f7b.txt
0000000 x \n y \n a s e c o n d \n
0000015
$ perl -v 前两行
This is perl 5, version 34, subversion 1 (v5.34.1) built for darwin-thread-multi-2level
检查点:perl 与 sed 落盘字节一致
字节一样,选哪个就看团队里谁读得懂。
8 路径里的斜杠,换分隔符不等于换写法
处理 /usr/local 这种自带斜杠的路径,转义写法能过,s|/usr/local|/opt| 也能过,结果相同。换了分隔符并不等于换了写法,那个空参数照样少不得:sed -i 's|/usr/local|/opt|' sub/f8.txt 里后缀成了带斜杠的 s|/usr/local|/opt|,报错变成 unterminated substitute pattern,文件没动。路径进 sed 就换分隔符,空串照旧写在 -i 后面,这两件事得同时做。
sed -i '' 's/\/usr\/local/\/opt/' sub/f8.txt
sed -i '' 's|/usr/local|/opt|' sub/f8.txt
sed -i 's|/usr/local|/opt|' sub/f8.txt
== 8 路径里的斜杠要换分隔符 ==
$ sed -i '' 's/\/usr\/local/\/opt/' sub/f8.txt
退出码 0
$ cat sub/f8.txt
path=/opt/bin/a
$ sed -i '' 's|/usr/local|/opt|' sub/f8.txt
退出码 0
$ cat sub/f8.txt
path=/opt/bin/a
--- 少写那个空参数,路径被当成后缀 ---
$ sed -i 's|/usr/local|/opt|' sub/f8.txt
sed: 1: "sub/f8.txt
": unterminated substitute pattern
退出码 1
$ cat sub/f8.txt
path=/usr/local/bin/a
$ ls -a sub
.
..
f8.txt
检查点:换分隔符不代替那个空参数
同一份脚本要在两套 sed 上跑,稳妥的办法是开头认一次实现,把 -i 的写法收进一个函数,调用方只管传文件和表达式。一年只改一次配置的话,记住后缀要显式写就够了。