
在脚本里处理字符串大小写很多人第一反应是tr或者sed再不就是awk。但如果你用的是 Bash 4.0 以上的版本其实根本不用拉起外部命令——参数扩展Parameter Expansion里内置了一套大小写转换操作符用起来又快又干净。这篇东西我会把${var^^}、${var,,}、${var^}、${var,}这几个操作符的用法、坑点、性能对比全部讲透再带上几个可以直接抄的实战脚本。想把 Bash 脚本写得干净利落、少依赖外部工具的这篇就是给你准备的。1. 先搞清楚参数扩展到底是什么1.1 参数扩展家族不只是$var很多初学者写脚本对变量的理解就是${变量名}和${变量名:-默认值}。但 Bash 的参数扩展远不止这两个它是一套内置的字符串处理机制不需要调用任何外部命令直接在 shell 内部完成操作。先快速过一遍家族里的常见成员因为后面的大小写转换操作符会和它们混着用就像一把螺丝刀配合不同的批头${var:-默认值}变量为空或未定义时展开为默认值${var:默认值}变量为空或未定义时赋值为默认值${var#前缀}/${var##前缀}从开头去掉最短/最长匹配的前缀${var%后缀}/${var%%后缀}从结尾去掉最短/最长匹配的后缀${var:偏移量:长度}取子串${var/旧/新}替换第一处匹配${var//旧/新}替换所有匹配${#var}取字符串长度这套语法是 POSIX 标准里就有的吗不一定Bash 在标准基础上扩展了很多。而大小写转换操作符就是 Bash 自家的扩展所以对版本有硬性要求。搞清楚这个你就知道为什么在有些机器上明明写了正确的语法却报错说bad substitution。1.2 版本门槛为什么先检查 Bash 版本大小写转换操作符是在Bash 4.0引入的。macOS 自带的 Bash 常年停在 3.2 版本很多 Linux 发行版默认预装的是 4.x 或 5.x所以你在网上抄来的脚本在自己机器上跑得欢换到另一台机器就翻车了大概率就是版本问题。检测方式很简单一行命令的事bash --version | head -1或者写进脚本里做版本校验if [ ${BASH_VERSINFO[0]} -lt 4 ]; then echo 需要 Bash 4.0 或更高版本 2 exit 1 fiBASH_VERSINFO是个数组下标 0 是主版本号下标 1 是次版本号这个信息在脚本里做兼容性判断特别好用。2. 四个大小写转换操作符用法与差异2.1${var^^}与${var,,}全量转换这两个是最常用的操作符语义一目了然。${var^^}把所有字符转成大写${var,,}把所有字符转成小写我来跑几个例子$ nameHeLLo WoRLD $ echo ${name^^} HELLO WORLD $ echo ${name,,} hello world注意一个细节这两个操作符不改变变量本身的内容它们只做展开。上面的命令里name还是原来的HeLLo WoRLD。如果你想把结果存回去必须手动赋值name${name,,} echo $name这个特性跟${var#前缀}是一致的属于字符串处理类操作符的通性。想原地修改就靠赋值运算符把展开结果写回变量。全量转换的实际价值在于标准化。用户输入的字符串可能有各种大小写混合比如用户名、邮箱、验证码、文件名统一转成小写或大写之后再做比较和匹配能省掉很多判断分支。2.2${var^}与${var,}首字母控制这两个操作符只动第一个字符${var^}首字母转大写${var,}首字母转小写$ wordhello $ echo ${word^} Hello $ wordWORLD $ echo ${word,} wORLD注意看第二个例子转小写只影响首字母后面的原样保留所以WORLD变成了wORLD而不是world。很多新手在这里想当然以为它会转成驼峰或全小写结果踩了坑。想实现“每个单词首字母大写”的效果也就是英语里的标题大小写如hello world变成Hello World单靠${var^}做不到它只认字符串开头不解析单词边界。后面第 3 部分我会给一个变通方案。2.3 模式化转换只处理你选中的字符大小写转换操作符还有一个进阶玩法语法是${var^^pattern}和${var,,pattern}。这里的pattern是一个 shell 模式只有匹配到的字符才会被转换。用字符类来控制范围特别方便$ hexaf3c9b $ echo ${hex^^[a-f]} AF3C9B $ mixedHello123 $ echo ${mixed,,[:alpha:]} hello123第一个例子只把a-f范围内的字母转成大写数字和g-z的字符不受影响十六进制颜色值规范化很好用。第二个例子只转字母数字保持原样。pattern支持[...]和[[:class:]]这种 POSIX 字符类。常见的字符类有[[:alpha:]]字母[[:digit:]]数字[[:alnum:]]字母和数字[[:lower:]]小写字母[[:upper:]]大写字母这个特性是 Bash 4.4 才做得比较完整的老版本对模式匹配的支持不够稳定用之前最好确认版本。3. 实战文件批量重命名与输入规范化3.1 场景一配置文件里的 Key 统一处理我在做某跨平台系统的配置模块时需要把用户提供的配置文件里的 key 全部转成小写再和默认配置合并。配置格式是经典的KeyValue但用户的书写习惯五花八门有Timeout30、有timeout30、还有TIMEOUT30。处理逻辑很简单读每一行用${line%%*}取等号左边的部分即 key做小写转换再和原来的 value 拼回去normalize_config() { local line key value while IFS read -r line || [ -n $line ]; do # 跳过空行和注释 [[ -z $line || $line \#* ]] continue key${line%%*} value${line#*} key${key,,} # 核心key 统一转小写 printf %s%s\n $key $value done $1 }注意两处细节。第一read -r必须加-r防止反斜杠被当转义符吞掉。第二循环条件里的[ -n $line ]用来处理最后一行没有换行符的情况不然会漏掉最后一行的处理。这是读文件时最容易踩的隐蔽 bug。3.2 场景二批量重命名文件为全小写有一回我收到一批素材文件命名全是大小写混合加空格比如Photo_2024_Demo_Final_v2.JPG。要批量转成带下划线的全小写文件名photo_2024_demo_final_v2.jpg。用参数扩展做这个事非常顺for file in *.JPG *.jpeg; do [ -e $file ] || continue newname${file,,} # 全小写 newname${newname// /_} # 空格换成下划线 if [ $file ! $newname ]; then mv -v -- $file $newname fi done${file,,}是核心操作一步到位转全小写。${newname// /_}把所有空格替换成下划线最终得到干净的文件名。有一点必须提醒mv之前一定要做[ $file ! $newname ]的判断不然系统里已经存在同名文件时直接mv会把目标覆盖掉。再加上操作前先打印一遍要执行的命令、确认无误再加mv批量操作才稳妥。更稳的做法是把命令输出到一个日志文件里回头可查。3.3 场景三用户输入标准化与 slug 生成交互式脚本里经常要接收用户的确认输入比如y/n。直接比较read进来的原始字符串很容易出问题因为用户可能输入Y、YES、yes、y。统一转小写后只需判断小写值read -r -p 是否继续(y/N): answer case ${answer,,} in y|yes) proceed ;; *) abort ;; esac${answer,,}一行代码解决了所有大小写变体问题case 分支只需要罗列小写形式。生成 URL slug 是另一个高频场景。把标题转小写、把空格替换成短横线一套组合拳下来很干净titleBash Parameter Expansion Guide slug${title,,} slug${slug// /-} echo $slug # 输出bash-parameter-expansion-guide如果标题里还带特殊字符可以再加一步过滤。用模式化转换或者正则替换把非字母数字字符去掉但这一步按需处理不要为了炫技把所有内容都塞进一行可读性才是第一位的。4. 性能对比与隐藏陷阱4.1 内建操作符 vs 外部命令性能账本参数扩展最大的优势是不需要 fork 子进程。tr、sed、awk这些都是外部命令每次调用都要创建新进程、加载程序、再退出开销肉眼可见。在脚本里偶尔用几次没感觉但放到循环里差距一下就出来了。我写过一个批量处理一万个字符串的测试对比${var,,}和tr [:upper:] [:lower:]两种写法参数扩展版本的执行时间大约是外部命令版本的十分之一左右。测试方法很简单# 生成一万条测试数据 for i in $(seq 1 10000); do echo TEST_STRING_${i} /tmp/test_data.txt done然后用/usr/bin/time -p分别跑两种处理方式结果差距很直观。这个差距的根源就是 fork/exec 的系统调用开销——外部命令每次执行都要经历进程创建、程序加载、退出清理三个阶段而内建操作符只是 shell 内部的一次字符串遍历。所以在循环体、流式处理、高频调用的函数里参数扩展是首选方案。它不仅仅是为了少打几个字而是实打实的性能优化。4.2 locale、非 ASCII 字符与转换边界大小写转换操作符的转换行为受 locale 影响。默认情况下很多系统是C/POSIXlocale这种环境下只有 ASCII 字符会被转换é、ü、ñ 这类带重音的字母不会被处理。$ export LC_ALLC $ wordcafé $ echo ${word^^} CAfé # 注意 é 没有变成 É $ export LC_ALLen_US.UTF-8 $ echo ${word^^} CAFÉ这个坑在中文环境里不太容易触发但一旦内容包含西文字符、法语、德语、北欧语言转换结果就会跟预期不一致。处理多语言文本时脚本开头设置LC_ALLen_US.UTF-8或更通用的C.UTF-8可以保证行为一致。另外需要明确大小写转换操作符只适用于字符串。对数字没用${num^^}不会把123变成什么魔法值对数组的处理方式倒是值得注意——${arr[]^^}会逐一转换每个元素。如果一个数组专门存小写单词、想统一转大写去匹配这一行就能搞定非常省事。但对关联数组直接这样操作会有问题需要先讨论清楚需求再动手。4.3 老版本 Bash 的降级方案写一个兼容函数如果你的脚本需要跑在 Bash 3.2 的机器上比如某些 macOS 的旧默认环境又必须用大小写转换那就得做一层封装检测版本后自动选择实现方式。to_lower() { if [ ${BASH_VERSINFO[0]} -ge 4 ]; then printf %s\n ${1,,} else printf %s\n $1 | tr [:upper:] [:lower:] fi } to_upper() { if [ ${BASH_VERSINFO[0]} -ge 4 ]; then printf %s\n ${1^^} else printf %s\n $1 | tr [:lower:] [:upper:] fi }这样写的好处是脚本主体逻辑只用to_lower $var不用到处判断版本可读性和兼容性都保住了。用printf而不是echo是为了防止字符串以-n、-e开头时被echo解释成选项这个属于任何时候都值得遵守的脚本习惯。5. 常见问题与避坑心得5.1 常见报错与排查速查表现象可能原因解决方案bad substitutionBash 版本低于 4.0升级 Bash 或使用降级函数转换后结果不对重音字母没变locale 设置不对设置LC_ALLC.UTF-8或en_US.UTF-8${var^}没有起到标题大写效果误解了首字母转大写的语义确认需求用循环或sed处理多单词变量内容没变忘记把结果赋值回变量${var,,}只做展开不修改原变量在数组上操作报错或结果异常数组遍历的写法不对使用${arr[]^^}语法逐个元素处理对空变量转换报错变量未定义${var:-}先给默认值再用转换操作符速查表里每条我都遇到过。特别说一下bad substitution这个报错它出现的场景不只是版本问题——某些时候你在双引号里写${var^^}没问题但在 heredoc 里或者某些引号嵌套场景中会被 shell 解析成别的意思也会报这个错。排查思路很简单拿到报错信息先确认 shell 是 Bash 而不是sh再看版本最后检查引号嵌套。5.2 我踩过的几个坑第一个坑是${var^^}和${var^}区别没记牢写完就忘结果在循环里跑出来的数据全是首字母大写而不是全大写导致后续的大小写匹配全失败。处理办法在脚本里加注释明明白白写清这个字段是转全大写还是转首字母给别人维护也轻松。第二个坑是外部命令迁移。早期写的脚本全用tr做转换后来为了性能把核心路径切到参数扩展但有几个地方漏改了导致一部分字符串是内建转换、一部分是tr转换。本身功能没问题但代码风格割裂后面统一重构时才意识到要么全部用内建要么全部用外部命令混着写是给自己找麻烦。第三个坑是标题大小写。我原以为${var^}能把hello world变成Hello World结果发现只有一个H是大写。后来要用标题大小写去生成报告标题不得不老老实实写循环titleize() { local words($1) # 简单按空格切分 local out() local w for w in ${words[]}; do out(${w^}) done printf %s ${out[*]} }这个函数的缺点是没有处理边界的工程细节比如分隔符。但作为快速脚本已经够用了。第四个坑其实不算坑是使用习惯内建参数扩展虽然快但是过于一行流的写法会牺牲可读性。比如newname${file##*/}混着几个${...}嵌套在赋值里短时间能看懂隔两个月回来看就会卡壳。我的建议是复杂的转换拆成两步加注释说明每一步在干什么脚本的可维护性比少写两行代码重要得多。5.3 和关联工具搭配的进阶思路参数扩展大小写转换最适合的定位是作为字符串处理流程的第一道工序。先规范化大小写再做比较、匹配、替换、拼接整体逻辑会清晰很多。举一个比较综合的例子解析用户传入的--verbose和--quiet参数时把参数值统一转小写后进 casemode$(printf %s ${2,,}) case $mode in verbose) set -x ;; quiet) exec 2/dev/null ;; *) echo 未知模式$mode 2; exit 1 ;; esac另外提一个容易忽略的组合${var^^[a-z]}这种模式化转换配合变量名的间接展开可以在不写循环的情况下对一批变量做统一处理。但这个是进阶玩法用到的时候自然能找到合适场景现阶段把基础四个操作符用好已经能解决一大半问题了。我个人在脚本里对大小写转换的原则很简单能用 Bash 内建解决的绝不拉外部命令需要复杂正则的才交给sed/awk需要在多字节字符上做精确转换的考虑perl或 Python。参数扩展是那个“80% 场景里最顺手”的答案希望这篇把它的边界和用法讲清楚了你可以放心地把它写进自己的脚本工具箱里。