
Shell 编程第二篇来了。上篇我们把环境、变量、if/for 这些基础语法过了一遍能写跑得通的脚本了。但如果你写的脚本开始超过二三十行、要处理真实业务数据你会发现很多“差一点但就是不对”的情况变量没赋值时脚本照样往下跑、函数里改了个变量外面也跟着变、日志明明没打印完却分不清是哪行出的错。这篇我们从这些问题出发专门讲 Shell 编程里最容易卡住新手的那几个坎参数展开、流程控制的进阶写法、函数作用域与返回值、数组批量处理以及脚本调试和错误处理的实操套路。适合刚入门一两个月的 Linux 新手也适合写过不少脚本但总觉得不稳的人——里面有不少我踩过、也经常在别人脚本里看到的坑建议收藏起来慢慢对照。1. 参数展开字符串处理的最佳捷径1.1 默认值与必填校验写脚本不再满屏 if大多数新手第一次处理“变量可能没赋值”的场景时写出来是这样的if [ -z $MY_DIR ]; then MY_DIR/tmp/data fi其实一个参数展开就能解决MY_DIR${MY_DIR:-/tmp/data}这个${var:-default}表示如果var不存在或值为空就用default否则用原值。它还有一个兄弟写法${var:default}区别在于后者除了返回值之外还会把默认值重新赋给变量本身。用:-则不会发生赋值。unset HOST echo ${HOST:-localhost} # 输出 localhostHOST 仍然未定义 echo ${HOST:localhost} # 输出 localhost此时 HOST 已经被赋值为 localhost这个写法的适用场景太常见了。比如脚本解析命令行参数想让端口默认是 8080、用户不传也能跑PORT${1:-8080}一行代码就实现了“给默认值”这件事不再需要先 if 后 else。配合另一个格式${var:?错误提示}还能做必填参数校验: ${DB_HOST:?请通过环境变量传入 DB_HOST}开头那个冒号是空命令作用是只触发参数展开、不执行别的操作当DB_HOST没赋值时bash 会把提示打到标准错误并且脚本直接以非 0 状态退出不会带着一个空变量往下跑出一堆奇怪报错。启动脚本、部署脚本里我几乎必用。1.2 截断、切片与替换用一行代码搞定路径处理字符串截断是另一个高频需求。比如拿到一个文件路径/var/log/nginx/access.log想取出文件名或去掉扩展名。新手第一反应是 cut、awk 去切其实 bash 内置参数展开就能做full_path/var/log/nginx/access.log echo ${full_path##*/} # access.log去掉最后一个 / 前面的所有内容 echo ${full_path%.log} # /var/log/nginx/access去掉结尾的 .log echo ${full_path#*/} # var/log/nginx/access.log去掉第一个 / 前面的内容这里需要分清两组符号的含义写法含义典型场景${var:-default}var 未定义或为空时用默认值命令行参数兜底${var:default}同上但会把默认值赋给 var一次性初始化${var:?message}var 为空时直接报错退出必填参数校验${#var}字符串长度判断输入长度${var:offset:len}字符串切片截取固定格式字段${var#pattern}从开头删最短匹配剥掉路径前缀${var##pattern}从开头删最长匹配取文件名${var%pattern}从结尾删最短匹配去末尾后缀${var%%pattern}从结尾删最长匹配剥完整扩展名${var/pat/rep}替换首个匹配简单清洗${var//pat/rep}替换全部匹配批量替换注意#和##的差异单个符号是“偷懒模式”匹配到最短的片段就停双符号是“贪婪模式”连续吞掉所有能匹配的内容。比如urlhttps://example.com/api/v1用${url#*/}得到的是example.com/api/v1因为只删掉了第一个/之前的部分而${url##*/}直接得到v1因为删到了最后一个/。实战里容易搞混我的记忆口诀是写几个符号就看你要不要“留最右”。字符串替换同样高频urlhttps://example.com/api/v1 echo ${url/http/https} # https 只替换第一处 echo ${url//example/api} # 所有 example 都替换成 api echo ${url:8} # 从第 8 个字符开始截取 echo ${url:8:11} # 从第 8 个字符开始共取 11 个字符这些特性在 bash 4.2 以上都默认支持现在主流发行版完全没问题。至于${url^^}${url,,}这种大小写转换bash 4.0 以后也有处理用户输入时比较省事。1.3 参数展开的引号细节参数展开用起来方便但引号问题不能马虎。一个原则拿不准的时候给整个展开加双引号。比如${full_path%/*}如果路径里包含空格双引号能保证结果被当成一个整体而不是被拆成多个词。另一个细节容易被忽略模式匹配里的通配符在双引号内只是普通字符该匹配还是匹配这一点和文件名的通配符不太一样。真正会翻车的是变量未定义却想当然地当作空串用这时候${var:-}能救你但更复杂的场景建议直接用set -u去兜底后文讲调试时会展开。2. 流程控制的几种实用形态2.1 [[ ]] 与 [ ] 到底该怎么选上篇我介绍条件判断时例子多半用的是[ ]或test。这篇说一个我教新人时反复强调的结论在 bash 里写脚本条件判断优先用[[ ]]。原因有几个。第一性能略好因为[[是 bash 内建语法[通常是外部命令/usr/bin/[每执行一次都多一次进程调用。第二[[ ]]里的支持通配符匹配if [[ $host web-*.example.com ]]; then echo 这是业务前端 fi同样一句话放[ ]里写法就得绕来绕去而且容易碰到 glob 先展开再比较的问题。第三正则匹配天然支持if [[ $status ~ ^[0-9]$ ]]; then echo 状态码是纯数字 fi注意~后面的正则在[[ ]]里不加引号加了会被当成普通字符串而不是正则。这个判断用户输入的场景脚本里简直天天用。那么[ ]还有没有存在价值有在需要最大兼容性、目标环境可能用 dash 之类的 POSIX shell 时就得退回[ ]的写法。但既然是“Linux 新手学习 Bash”默认环境就是 bash直接用[[ ]]能少踩很多坑。2.2 case 分支的可读性重构多条件分支的场景新手习惯写一层层 if else if else代码又长又难读。比如判断文件类型case $filename in *.tar.gz|*.tgz) echo tar 归档 ;; *.zip) echo zip 归档 ;; *) echo 未知类型 ;; esaccase 的模式规则和路径通配符一致用|分隔多个匹配项。这里有两个进阶操作符值得知道;;正常结束分支;结束当前分支但继续执行下一个分支的命令不判断模式是否匹配;;结束当前分支继续判断后续分支是否匹配。实战里;用得不多但如果你希望几个分支共享一份后处理逻辑它能省不少重复代码。而*兜底分支必须保留——用户输入的千奇百怪总会跑到你预想不到的场景去。case 搭配命令行参数解析是我写工具的默认姿势。2.3 select 交互菜单给脚本一点“人味”另一个常被忽略的流程控制是select。它适合写交互式菜单帮同事运维时特别好用PS3请选择操作编号 select opt in 启动服务 停止服务 查看状态 退出; do case $opt in 启动服务) systemctl start nginx ;; 停止服务) systemctl stop nginx ;; 查看状态) systemctl status nginx ;; 退出) break ;; *) echo 无效输入 ;; esac doneselect会自动把菜单打印成带编号的列表并把用户选中的项存到变量opt。注意两点一是PS3是 select 专用的提示符变量不改的话默认显示成#?非常丑二是 select 循环不会自己退出必须在 case 分支里通过break跳出。我把这类菜单脚本发给同事后他们再也没来找我背过命令使用体验好了很多。3. 函数的返回值与变量作用域3.1 不加 local 的变量真的会“核泄漏”很多新手第一次写函数时变量随手一放init_config() { config_file/etc/myapp.conf } init_config echo $config_file # 输出 /etc/myapp.conf刚学时觉得很方便函数里定义的变量外面也能访问简直“全局共享”。可脚本一复杂这个共享就成了定时炸弹。比如函数内部循环用了一个临时变量i结果它把外层脚本的i也改了后面循环直接少跑几轮排查半天都找不到原因。我的规则很简单函数内部的临时变量一律用local声明。init_config() { local config_file/etc/myapp.conf echo $config_file } init_config echo $config_file # 输出为空变量没有泄漏出去local是 bash 的保留字仅函数内有效保证变量不会污染全局。我现在带人写脚本代码评审第一关就看“函数里的变量有没有都加 local”。加了整体代码质量上一个台阶不加等线上出了问题再回来查成本至少翻十倍。3.2 return 不是用来返回字符串的这是初学者最容易误解的地方很多人以为函数里return hello能像 Python 一样返回字符串结果拿到一堆奇怪的数字或报错。return的语义是通知调用方“这个函数执行得怎么样”只能返回 0255 的整数状态码。想返回字符串正确姿势是用 echo 配合命令替换get_version() { local version1.2.3 echo $version } current_version$(get_version)调用方用$(...)捕获函数输出到标准输出的内容这是 Bash 里“函数返回字符串”的标准模式。但这里有个连带问题如果函数中间还有其他输出也会混进$(...)里。想打印日志又不想污染返回值就把日志写进 stderrget_version() { echo 开始获取版本号... 2 echo 1.2.3 } current_version$(get_version) echo 版本是$current_version2把日志重定向到标准错误$(...)默认只抓标准输出所以最终拿到的就是干净的结果。这个技巧在封装日志、处理耗时任务时几乎天天用。3.3 $ 和 $* 的引号差异函数参数和脚本参数一样都有$、$*两种写法。很多入门教程会说“两者没区别”这在绝大多数场景下是错的。直接看例子print_args() { for arg in $; do echo arg: [$arg] done } print_args hello world second在$情况下循环会得到两个元素hello world和second。如果把$换成$*同样加了引号$*会把所有参数拼成一个带空格的字符串hello world second循环只转一圈。所以结论是想让每个参数保持独立永远用$想把参数们拼成一个字符串才考虑$*。我在封装命令、把函数参数转发给另一个程序时踩过这个坑转发后文件名带空格的参数被拆得稀碎最后用了$才恢复正常。4. 数组批量处理脚本效率翻倍4.1 索引数组的声明与遍历Bash 数组的写法和别的语言很不一样但掌握后非常实用。声明方式services(nginx mysql redis) services[3]docker echo ${services[]} # nginx mysql redis docker echo ${#services[]} # 4数组元素个数遍历for svc in ${services[]}; do echo 服务$svc done注意直接写echo $services它默认只输出第一个元素nginx不是整个数组。数组适合处理“数量不固定、同类数据”的场景比如从文件里读出来的主机名单、需要批量处理的文件名列表。4.2 关联数组给数据加上“名字”bash 4.0 开始支持关联数组相当于 Python 的字典用起来更直观declare -A ip_map ip_map[web01]192.168.1.11 ip_map[db01]192.168.1.21 echo ${ip_map[web01]} for key in ${!ip_map[]}; do echo $key - ${ip_map[$key]} done注意必须用declare -A声明漏掉这一行bash 会把它当成普通索引数组赋值行为直接走样。取所有键用${!ip_map[]}那个感叹号不是取反是“取键集合”新手很容易懵。关联数组最常见的用法是“映射关系查表”比如主机名对应 IP 和端口。开会排障时一条维护列表就能替代好几层 if 判断。4.3 多主机检查的批量场景数组最好用的场景就是批量检查服务器状态。以前我一个个手动敲 nc 命令后来改成这样hosts(192.168.1.11 192.168.1.12 192.168.1.13) for host in ${hosts[]}; do if nc -z -w 2 $host 3306 /dev/null; then echo $host: MySQL端口正常 else echo $host: MySQL端口不通 fi done再进阶一步把 IP 和要检查的端口放进关联数组declare -A checks checks[192.168.1.11]3306 6379 checks[192.168.1.12]22 80 for ip in ${!checks[]}; do for port in ${checks[$ip]}; do if nc -z -w 2 $ip $port /dev/null; then echo $ip:$port 正常 else echo $ip:$port 不通 fi done done这里有个引号细节checks[$ip]的值3306 6379在内部循环里不能加引号包裹因为需要把它拆成两个端口号而遍历键集合时${!checks[]}的引号不能省否则键里包含空格时会碎掉。Shell 里引号该加的不加、不该加的加了都会翻车理解了原理自然知道怎么取舍。5. 调试三板斧让脚本学会解释自己5.1 语法检查与执行追踪写脚本最烦的是一运行就报一堆错而且不知道错在哪。我的习惯是写完先执行bash -n yourscript.sh只做语法检查不运行脚本解析错误立刻告诉你第几行有问题出逻辑问题时跑bash -x yourscript.sh它会打印每一步实际执行的命令和展开后的参数相当于把脚本“翻译”了一遍执行过程定位问题非常高效。bash -x的输出开始看会觉得很吵每行前面那个是命令执行的标记。如果脚本太长只想看中间某段也可以局部开关set -x # 从这里开始跟踪 # 中间是可疑的逻辑 set x # 到这里关闭我调试某个解析配置文件的函数时先用-n排除语法问题再用-x盯着变量是怎么变形、参数是怎么传递的十分钟内就能定位问题。这套组合是我写脚本的标配。5.2 set 系列帮你兜底一个新的真相bash 默认不因为“某条命令失败”就停止脚本。比如rm /tmp/aaa echo 删除完成如果/tmp/aaa不存在rm 返回非 0但脚本照样打印“删除完成”。这在敏感操作场景里会掩盖问题。稳妥做法是在脚本开头加上set -e # 命令出错立即退出 set -u # 使用未定义变量时报错而不是当作空字符串 set -o pipefail # 管道中任何一条命令失败整条管道都算失败这三行被合称为“防御三件套”现在很多项目模板里都会写。但set -e不是银弹它管不到 if 条件、循环条件里的命令也不会因为管道左侧失败而自动中断所以才需要配合pipefail。set -u会让以前“未定义变量当空串”的代码直接报错退出。比如有的脚本依赖${var:-}这种写法加了set -u后反而要调整。这不是说明set -u不好而是你要清楚自己的脚本里哪些地方是“故意用空值”哪些是拼写错误前者需要显式加默认值处理。5.3 trap 做清理与记录脚本运行到一半被中断临时文件残留是最常见的问题。用 trap 挂一个清理动作cleanup() { rm -f /tmp/myapp_tmp_$$ } trap cleanup EXIT这样无论正常退出还是被 CtrlC 中断bash 走到 EXIT 时都会先执行 cleanup。$$是当前进程 PID用 PID 做临时文件名可以避免多个脚本实例同时跑时互相覆盖。再配合一个技巧记录错误退出位置trap echo 出错行号: $LINENO ERR$LINENO是 bash 提供的当前行号长脚本排错时这行能让你直接锁定出错行不用去数日志。trap 的坑在于信号处理函数执行完默认不会继续执行原信号的处理逻辑所以如果你希望保留 CtrlC 的默认行为需要自己在函数里手动exit。写过几个健壮脚本后你会明白什么叫“处理完该处理的再体面退出”。6. 把上面的东西串起来一个可用的运维小脚本6.1 逐行拆解一个综合脚本语法单独记很容易忘我们把前面所有内容塞进一个真实的小脚本里接收一个服务名检查本机该服务是否存在并运行同时支持--port参数做端口探活。#!/usr/bin/env bash set -euo pipefail service_name${1:?用法: $0 服务名 [--port 端口]} port # 响应 CtrlC 时给出提示并退出 trap echo 脚本被中断尝试清理; exit 1 INT check_port() { local host$1 port$2 if nc -z -w 3 $host $port /dev/null; then echo 端口 ${port} 探活成功 else echo 端口 ${port} 探活失败 return 1 fi } # 解析可选参数 while [[ $# -gt 0 ]]; do case $1 in --port) port$2 shift 2 ;; *) shift ;; esac done if systemctl is-active --quiet $service_name; then echo 服务 ${service_name} 正在运行 else echo 服务 ${service_name} 未运行 fi if [[ -n $port ]]; then if check_port 127.0.0.1 $port; then echo 本机端口探活通过 else echo 本机端口探活未通过请检查监听地址 exit 1 fi fi这个脚本每一段都踩在前面讲的套路里set -euo pipefail是护身符防止中途继续跑出隐藏错误${1:?...}用参数展开做必填校验不传服务名直接退出check_port函数里全部用local变量return 1明确返回失败状态while case是手工解析可选参数的常见写法律之一结构清晰、容易扩展调用函数时直接if check_port 127.0.0.1 $port利用返回状态码做判断不需要额外搞全局变量存结果逻辑干净。6.2 这套模板怎么扩展有了这个模板继续扩展的思路非常丰富把单机探活改成批量主机用数组存主机列表循环调用 check_port把函数内的日志统一写到 stderr真正的结果输出留到 stdout这样整个脚本可以被grep、jq或其他程序继续处理天然适合管道用关联数组记录不同服务的健康检查方法根据 service_name 动态选择探活手段trap 清理临时文件和恢复现场遇到异常还能发告警通知。新手最容易在扩展时翻车的点一是漏写local导致函数之间的临时变量互相污染二是循环里没有考虑空数组的情况set -u下空数组遍历可能直接报错三是引号在复杂变量嵌套时随意省略。这些问题一旦踩中排查到后期的效率会非常低。最后分享一个我个人的习惯每次写完脚本先bash -n筛一遍低级错误再跑bash -x对着输出过一遍关键路径确认无误后再正式部署。别嫌麻烦这十分钟能省下第二天排查事故的几个小时。