三个跨平台的 shell 编码陷阱:零宽字符、locale 与 bash 版本差异

一、环境与结论先行

环境:macOS(自带 bash 3.2.57)、CI 多为 Linux + LC_ALL=C、部分 /bin/sh 是 dash。同一段脚本在三者间行为可能不同。

先把三类陷阱的结论放前面:

  1. 零宽字符 :从网页 / AI 对话 / PDF 复制会夹带 U+200B 等不可见字符,命令"看着对跑不对";
  2. locale :CI 默认 C locale,让 sort / ls / 大小写转换按字节序工作,和本地 UTF-8 结果不一致;
  3. bash 版本 / sh≠bash :macOS bash 3.2 缺 mapfile / ${x,,} / declare -A;CI 的 /bin/sh 常是 dash,无 [[ ]]、<( )、数组。

后面逐条给成因、实测与修法,并在结尾给复现清单。


二、陷阱一:零宽字符,复制即中毒

字符间可能夹着 U+200B(零宽空格)、U+200C / U+200D、U+FEFF(BOM)。宽度为零,肉眼不可见。

bash 复制代码
​ls -la ~/notes     # l 与 s 之间塞了 U+200B,shell 当成三个令牌

表现:command not found、变量莫名清空、diff 看着一样却不相等。

显形与清除(macOS 自带 grep 没有 -P,用下面任一):

bash 复制代码
# 检测
perl -ne 'print if /\x{200B}/' s.sh
grep -n $'\xe2\x80\x8b' s.sh
hexdump -C s.sh | grep 'e2 80 8b'

![fig2.png](https://p6-xtjj-sign.byteimg.com/tos-cn-i-73owjymdk6/ec840aae48fc444faca932c80a2f8559~tplv-73owjymdk6-jj-mark-v1:0:0:0:0:5o6Y6YeR5oqA5pyv56S-5Yy6IEAgYWxvbmdsb25n:q75.awebp?rk3s=f64ab15b&x-expires=1792676122&x-signature=lFBqtBnElKJy8qIysq5I6Iej1lQ%3D)
# 清除(就地)
perl -CSD -i -pe 's/[\x{200B}-\x{200D}\x{FEFF}]//g' s.sh

修法:编辑器开"显示不可见字符";跨系统粘贴走纯文本中转;仓库加提交前扫描。


三、陷阱二:locale,同样的 sort 排出了不同顺序

输入 apple / Banana / cherry / Äpfel,仅切换 LC_ALL:

区域设置 首行 排序依据 完整输出
en_US.UTF-8 Äpfel 语言学排序(忽略大小写 / 变音) Äpfel / apple / Banana / cherry
LC_ALL=C Banana 字节序(A-Z 在前,0x41<0x61,Ä 的 0xC3 最后) Banana / apple / cherry / Äpfel

依赖"排序后取第一项"的脚本,在两种环境拿到的是不同的那一个文件 。同样受影响的还有:ls 排列、[A-Z]/[a-z] 字符类范围、大小写转换 ${x,,}(C 下只处理 ASCII)。

修法:脚本开头钉死 export LC_ALL=C.UTF-8,并写进 CI 配置,让本地与线上一致。


四、陷阱三:bash 版本 / sh ≠ bash

两个常被忽略的事实:

  • macOS 自带 /bin/bash = 3.2.57 ,而 mapfile/readarray、${x,,}/${x^^}、declare -A 都是 bash 4.0+。本机实测:mapfile → command not found,${x,,} → bad substitution。
  • CI 的 /bin/sh 常是 dash :无 [[ ]]、<( )、数组、local -n。

兼容矩阵(一眼看清谁有谁没有):

最典型翻车:头部 #!/bin/bash,却用 sh script.sh 跑------dash 把 [[ ]]、<( ) 报成语法错;或本地 bash 5 写、服务器 bash 3.2,declare -A 静默失效(检查项空跑却报成功)。

修法:

bash 复制代码
# 版本探测,fail-fast
[ "${BASH_VERSINFO[0]}" -ge 4 ] || { echo "need bash 4+"; exit 1; }
command -v bash >/dev/null && bash --version | head -1

# 要么写 POSIX sh(只用 case / while / test),要么声明 bash 并校验版本

五、验证方法论:检查脚本自己也必须被检查

用来防患的检查本身也会骗你:零宽字符检测正则写错一个字符 → 永远匹配不到 → 什么都没拦;set -u 下脚本中途崩 → 前半段打勾、后半段跳过 → 全绿假象。

给每类陷阱配最小自检:

  • 零宽字符:提交前 perl -ne 'print if /\x{200B}/' 跑一遍,空输出才算过;
  • locale:CI 显式 export LC_ALL=C.UTF-8,不靠默认;
  • 版本:开头探测 BASH_VERSINFO,不支持就立刻退,别带病运行。

总纲:检查脚本自己,也必须被检查。


六、复现清单

文件 行数 职责
precheck-publish.sh 271 本文门禁范本:bash 3.2 兼容写法(字符串列表 + case,不用关联数组)

关键写法(bash 3.2 兼容):

bash 复制代码
# 不用 declare -A;用字符串列表 + case
FILES=(
  "precheck-publish.sh|/path/to/precheck-publish.sh"
)
# 取真实行数
real=$(wc -l < "${entry##*|}" 2>/dev/null | tr -d ' ')

# 版本探测 fail-fast
[ "${BASH_VERSINFO[0]}" -ge 4 ] || { echo "need bash 4+"; exit 1; }

# 固定 locale
export LC_ALL=C.UTF-8

# 零宽字符提交前扫描
perl -ne 'print "ZW at $.: $_" if /\x{200B}/' "$0"
相关推荐
zyl7651 小时前
从 PPO 到 DPO、IPO 与 GRPO:RLHF 训练流程与公式推导
人工智能
老牛日常随笔1 小时前
74B 塞进 24GB:NVIDIA 新开源模型,把本地智能体的门槛砍了一刀
人工智能·ai
QinHsiu1 小时前
ChatGPT还在写作文,Jev已开始做选择题:快193倍、便宜445倍,硅谷集体杀入“第二赛道”
人工智能
y = xⁿ1 小时前
分享一下自己总结的RAG评测流程
人工智能·python
ADark1 小时前
FDE 入门 · 11|数据那一摊:项目卡死的地方通常在这儿
人工智能·agent
每日商业生活1 小时前
个人开发者希望将创意开发为产品,推荐选择哪些低门槛的 AI 和云服务?
人工智能
橘饴1 小时前
当我给 AI Agent 装上记忆:第二轮 SRC 实验全记录
人工智能
sorry3551 小时前
从零实现 nanoGPT(六):完善训练流程,实现模型评估与断点续训
人工智能
派小心.1 小时前
网站统计多域名数据隔离配置
大数据·数据库·人工智能·小程序·数据分析