1. 为什么脚本会在 zsh 下跑挂
一句话总结: 脚本在 bash 里正常、换到 zsh 就报错或算出错误结果,根源几乎都是数组下标、词分裂与 glob 这三处语义差异。
macOS 默认登录 shell 从 bash 换成了 zsh,不少 CI 镜像、容器基础镜像和开发机也默认用 zsh。于是一个「本地写、服务器跑」或者「本地跑、CI 跑」的脚本,很容易在两种 shell 之间翻车。
# 同一行代码,两种 shell 结果不同
servers=(web1 web2 web3)
echo "${servers[1]}"
# bash:第二个元素 web2
# zsh :第一个元素 web1
这不是玄学,而是 zsh 明确的设计选择:数组下标从 1 开始(延续 ksh 传统),而 bash 从 0 开始。
1.1 先确认脚本到底跑在哪个 shell
不要用 $SHELL 判断,它只表示「登录 shell」,和当前脚本的解释器无关。
#!/usr/bin/env bash
set -euo pipefail
# 判断当前解释器(仅用于诊断,不要用它做逻辑分支)
if [ -n "${BASH_VERSION:-}" ]; then
echo "running under bash $BASH_VERSION"
elif [ -n "${ZSH_VERSION:-}" ]; then
echo "running under zsh $ZSH_VERSION"
else
echo "running under an unknown shell"
fi
一句话总结:
$BASH_VERSION与$ZSH_VERSION是各自的指纹变量,而$SHELL只反映登录配置,不能用来判断脚本解释器。
1.2 差异从哪里来
移植问题的三大源头,按踩坑频率排序:
| 差异点 | bash | zsh |
|---|---|---|
| 数组下标 | 0 开始 | 1 开始 |
$arr 的含义 | 第一个元素 | 全部元素 |
| 未加引号的变量展开 | 词分裂 | 不分裂 |
| 无匹配 glob | 保持字面量 | 报错退出 |
| 关联数组语法 | declare -A | typeset -A |
把这份表当成迁移前的检查清单,逐条对照脚本里是否出现。
2. 数组与关联数组的差异
一句话总结: 索引数组的起点、
$arr的语义、${#arr}的含义在两种 shell 中全都不同,跨 shell 脚本应尽量只使用"${arr[@]}"这种两边一致的形式。
2.1 索引数组的下标起点
#!/usr/bin/env bash
set -euo pipefail
arr=(alpha beta gamma)
# 两边都正确的写法:整体展开
printf '%s\n' "${arr[@]}"
# 两边都正确的写法:元素个数
echo "count=${#arr[@]}"
# 危险写法:下标含义不同
echo "${arr[0]}" # bash: alpha zsh: (空)
echo "${arr[1]}" # bash: beta zsh: alpha
# 危险写法:${#arr} 含义不同
echo "${#arr}" # bash: alpha 的长度 5 zsh: 元素个数 3
结论很简单:数组一律用 "${arr[@]}" 遍历、"${#arr[@]}" 求长度、"${!arr[@]}" 取下标,这三种写法在 bash 与 zsh 中语义完全一致。
一句话总结: 只要避开裸下标与
${#arr},索引数组的读写就是可移植的。
2.2 关联数组的声明与访问
bash 用 declare -A,zsh 用 typeset -A;两者都能识别对方的关键字(bash 的 typeset 是 declare 的同义词,zsh 也接受 declare),所以 typeset -A 反而更通用。
#!/usr/bin/env bash
set -euo pipefail
# 通用写法:typeset -A 两边都认
typeset -A config
config[host]="127.0.0.1"
config[port]="8080"
# 访问元素:必须用 ${},不能写 $config[host]
echo "${config[host]}"
# 遍历键(两边一致)
for key in "${!config[@]}"; do
printf '%s=%s\n' "$key" "${config[$key]}"
done
zsh 还允许用 print -r -- $config[host] 直接引用,但这类写法在 bash 里会报错,跨 shell 脚本一律不要用。同理,typeset -i(整数)、typeset -l(小写)等修饰在两种 shell 中语义细节不同,也应避免。
3. glob 展开与空匹配处理
一句话总结: 无匹配时 bash 默认保留字面量、zsh 默认报错退出,这会让
for f in *.log在空目录下表现出完全不同的行为。
3.1 无匹配时的默认行为
#!/usr/bin/env bash
set -euo pipefail
# 假设目录里没有任何 .log 文件
for f in *.log; do
echo "处理 $f"
done
# bash:循环一次,f 就是字面量 "*.log"(极危险的 bug)
# zsh :直接报错 no matches found
一句话总结: bash 的「字面量兜底」最危险,它会让脚本悄悄处理一个不存在的文件名;zsh 的报错反而更安全。
3.2 统一行为:nullglob 与 NOMATCH
两种 shell 都提供开关,让无匹配时展开为空列表,这样循环体一次都不执行。
#!/usr/bin/env bash
set -euo pipefail
# bash:开启 nullglob,无匹配时循环体一次都不执行
shopt -s nullglob
for f in *.log; do echo "处理 $f"; done
#!/usr/bin/env zsh
set -euo pipefail
# zsh:开启 NULL_GLOB(注意命名风格不同)
setopt NULL_GLOB
for f in *.log; do print -r -- "处理 $f"; done
真正可移植的写法是不依赖任何开关,直接用 find 生成 NUL 分隔列表:
while IFS= read -r -d '' f; do
echo "处理 $f"
done < <(find . -maxdepth 1 -name '*.log' -print0)
递归 glob 也有差异:zsh 默认支持 **/,bash 需要 shopt -s globstar。
4. 参数扩展与修饰符
一句话总结: 替换、截断、默认值这三类扩展两边一致,但「大小写转换」「间接引用」「切片」的语法完全不同,是移植时最容易漏掉的部分。
4.1 两边一致的部分
#!/usr/bin/env bash
set -euo pipefail
path="/var/log/nginx/access.log"
echo "${path##*/}" # access.log 去头(最长匹配)
echo "${path%/*}" # /var/log/nginx 去尾(最短匹配)
echo "${path%%.*}" # /var/log/nginx/access
echo "${path//\//-}" # -var-log-nginx-access.log 全部替换
echo "${path:-default}" # 有值则取值
echo "${#path}" # 长度
上面这些写法在 bash 与 zsh 中结果完全一致,可以放心使用。
一句话总结:
#/%//三类扩展语法在两种 shell 中同源,是最值得优先使用的可移植子集。
4.2 语法不同的部分
# 大小写转换
name="Web-Server"
echo "${name^^}" # bash:WEB-SERVER
echo "${name,,}" # bash:web-server
# zsh 对应写法
print -r -- "${(U)name}" # WEB-SERVER
print -r -- "${(L)name}" # web-server
# 间接引用
key="HOME"
echo "${!key}" # bash:/root
print -r -- "${(P)key}" # zsh :/root
# 字符串切片
s="abcdefgh"
echo "${s:1:3}" # bash:bcd
print -r -- "${s[2,4]}" # zsh :bcd(1 起始,闭区间)
移植策略是:需要这些能力时,用 tr、cut 等外部命令代替,或干脆显式声明 #!/usr/bin/env bash。
#!/usr/bin/env bash
set -euo pipefail
# 可移植的大小写转换:交给 tr
upper=$(printf '%s' "Web-Server" | tr '[:lower:]' '[:upper:]')
echo "$upper" # WEB-SERVER
5. shebang 与启动文件的选择
一句话总结: shebang 决定脚本用哪个解释器执行,启动文件决定交互环境;把这两件事混为一谈是很多「本地能跑、CI 报错」问题的根因。
5.1 三种 shebang 的取舍
#!/bin/sh # POSIX 子集,Debian/Ubuntu 上是 dash,最快但功能最少
#!/usr/bin/env bash # 明确要 bash 特性(数组、[[ ]]、mapfile)
#!/usr/bin/env zsh # 明确要 zsh 特性(setopt、1 起始数组)
选择规则:
- 只用 POSIX 子集(
[ ]、无数组、无local)→ 用#!/bin/sh,兼容性最好; - 用了
[[ ]]、数组、${var^^}、shopt→ 必须#!/usr/bin/env bash; - 用了
setopt、${(P)var}、**/递归 glob → 必须#!/usr/bin/env zsh。
#!/bin/sh
# 反面教材:/bin/sh 下这段会报错
# arr=(a b c) # dash 不支持数组
关键原则:脚本内部出现的每一处非 POSIX 语法,都必须在 shebang 中显式声明对应的解释器。 不要依赖「用户可能正好用 bash」。
一句话总结: shebang 是脚本的功能声明,用了什么方言就写什么解释器,绝不写
#!/bin/sh再用 bashism。
5.2 启动文件的加载顺序
交互式 shell 会读一堆启动文件,非交互式脚本通常一个都不读——这正是「我在终端里手动跑没事」的原因。
| 场景 | bash | zsh |
|---|---|---|
| 交互登录 | /etc/profile → ~/.bash_profile | /etc/zprofile → ~/.zprofile → ~/.zshrc |
| 交互非登录 | ~/.bashrc | ~/.zshrc |
| 非交互脚本 | $BASH_ENV(若设置) | ~/.zshenv |
| 登出 | ~/.bash_logout | ~/.zlogout |
#!/usr/bin/env bash
set -euo pipefail
# 脚本里不要指望 ~/.bashrc 生效,PATH 要自己补
export PATH="/usr/local/bin:/usr/bin:/bin:$PATH"
注意 zsh 的 ~/.zshenv 对所有 zsh 进程都生效,包括非交互脚本。如果它里面写了 setopt 或改了 PATH,脚本行为就会随用户环境漂移——这是排查「同一脚本换个人就挂」的第一现场。
6. 严格模式的行为差异
一句话总结:
set -euo pipefail在两种 shell 里都可用,但-e的触发点、-u对空数组的处理、以及 zsh 默认不做词分裂,会让同一段代码走出不同分支。
6.1 set -e 的触发点
# -e 不生效的位置(两种 shell 都如此)
if grep -q foo /etc/hostname; then echo "found"; fi
while false; do :; done
! false
# -e 生效的位置
grep -q foo /etc/hostname # 找不到直接退出
差异在于函数与子 shell 的传播规则、以及管道中最右命令失败时的判定。bash 从 4.4 起对空数组展开更严格,set -u 下 "${args[@]}" 为空也可能报错,而 zsh 不会。
#!/usr/bin/env bash
set -euo pipefail
# bash 4.4+ 的坑:空数组展开触发 unbound variable
args=()
# printf '%s\n' "${args[@]}" # 某些版本下会因 -u 报错
printf '%s\n' ${args[@]+"${args[@]}"} # 安全写法
一句话总结:
-e的边界情况太多,与其背规则,不如在关键命令后显式写|| exit 1。
6.2 词分裂:最大的隐性差异
这是 zsh 与 bash 最容易造成「静默算错」的差异:zsh 默认不对未加引号的变量做词分裂。
# bash
line="one two three"
for w in $line; do echo "[$w]"; done
# 输出 [one] [two] [three]
# zsh(默认不做词分裂,只循环一次)
for w in $line; do print -r -- "[$w]"; done
# 输出 [one two three]
可移植写法是永远不依赖分裂,改用 read -ra 显式切分:
#!/usr/bin/env bash
set -euo pipefail
read -r -a words <<<"one two three"
printf '[%s]\n' "${words[@]}"
7. 可移植写法清单
一句话总结: 把「只用两边一致的子集」当成默认策略,把方言特性限制在脚本头部声明的解释器范围内,移植成本就会趋近于零。
7.1 推荐使用的安全子集
# 安全:两边语义一致
printf '%s\n' "${arr[@]}" # 数组整体展开
printf '%s\n' "${#arr[@]}" # 元素个数
printf '%s\n' "${!arr[@]}" # 下标列表
echo "${path##*/}" # 去头
echo "${path%%.*}" # 去尾
echo "${var:-default}" # 默认值
read -r line # 读一行
case "$x" in a|b) ;; esac # 分支
while IFS= read -r -d '' f; do :; done < <(find . -print0)
避免使用的方言特性:
| 特性 | bash | zsh | 替代方案 |
|---|---|---|---|
| 大小写转换 | ${v^^} | ${(U)v} | tr |
| 间接引用 | ${!v} | ${(P)v} | eval 或关联数组 |
| 字符串切片 | ${v:1:3} | ${v[2,4]} | cut / printf |
| 递归 glob | globstar | 默认支持 | find |
| 空匹配处理 | nullglob | NULL_GLOB | find + while read |
| 数组声明 | declare -a | typeset -a | typeset -a |
一句话总结: 上表右列的可移植替代方案全部基于 POSIX 工具,任何 shell 都能跑,是跨平台脚本的兜底策略。
7.2 迁移检查流程
#!/usr/bin/env bash
set -euo pipefail
# 1. 静态检查:让 shellcheck 指认方言
shellcheck -s bash myscript.sh
# 2. 两种 shell 各做一次语法检查
bash -n myscript.sh && zsh -n myscript.sh
# 3. 关键路径分别执行并比对输出(真正的行为回归)
bash ./myscript.sh > /tmp/out.bash 2>&1 || true
zsh ./myscript.sh > /tmp/out.zsh 2>&1 || true
diff /tmp/out.bash /tmp/out.zsh && echo "行为一致"
zsh -n 与 bash -n 只做语法检查,不执行;diff 步骤才是行为比对,建议在 CI 中针对跨平台脚本保留。
8. 总结
| 环节 | 要点 |
|---|---|
| 差异根源 | 数组下标起点、词分裂、glob 空匹配是三大高频坑 |
| 数组 | 统一用 "${arr[@]}"、"${#arr[@]}"、"${!arr[@]}" |
| 关联数组 | typeset -A 两边通用,访问必须加 ${} |
| glob | bash 用 shopt -s nullglob、zsh 用 setopt NULL_GLOB,可移植写法用 find |
| 参数扩展 | #/%// 一致;大小写、切片、间接引用需替换方案 |
| shebang | 用了什么方言就声明什么解释器,绝不 #!/bin/sh 加 bashism |
| 严格模式 | set -euo pipefail 通用,但空数组与管道细节有差异 |
| 清单 | 优先 POSIX 子集,方言特性收敛到头部声明 |
兼容性问题的本质是「隐式假设」:脚本默默假设了下标从 0 开始、假设了无匹配时保留字面量、假设了变量会词分裂。把这些假设全部显式化——要么写进 shebang,要么换成可移植写法——脚本就能在任何 shell 下给出同样结果。下一步我们给这些脚本补上自动化测试与静态检查,让兼容性回归变成 CI 里的一次红灯。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。