
写 shell 脚本这件事写到第三篇基本就到了分水岭。前两篇如果还停留在变量、条件判断、函数这些语法层面那这一篇我们直接进入“每天干活真正会用到的东西”for 循环批量处理文件、shift 处理复杂入参、SFTP 自动传文件、adb shell 往安卓设备下发脚本以及那些我踩过无数次、每次都能让脚本静默出错的高频坑。这篇内容适合两类人一是刚把 shell 语法学完、想看看真实脚本长什么样的新手二是写了段时间脚本、但经常被各种诡异行为折磨的运维和开发。我会尽量把每一个“为什么”讲透不只是给一段能跑的命令而是让你知道它背后的逻辑和边界在哪里。1. 先把执行环境搞明白sh、bash、shebang 与调试三板斧1.1 你的脚本到底跑在哪个 shell 里很多人脚本写失败第一坑就出在执行环境上。Linux 下 /bin/sh 通常是指向 dash 或 bash 的软链接但不同发行版指向不同。Ubuntu 默认把 sh 指向 dash而 CentOS 上 sh 是指向 bash 的。这就导致同一个脚本在 Ubuntu 上跑得好好的换到 CentOS 上就报语法错误或者反过来。你可以在终端里执行ls -l /bin/sh看一下当前系统的真实情况。我见过不少同事在脚本第一行写#!/bin/sh实际却用了数组、[[ ]]这种只有 bash 才支持的特性脚本在开发机上没事一上生产就炸。这种事发生一次就长记性了。我的习惯是#!/usr/bin/env bash用 env 而不是直接写死/bin/bash是因为 env 会从 PATH 里找 bash兼容性更好尤其在 macOS、各种精简容器镜像里bash 的位置不一定是 /bin/bash。写脚本第一步先明确解释器然后再谈逻辑。1.2 调试三板斧bash -x、set -e、set -u我见过太多人在脚本里加一堆 echo 来排查问题其实完全没必要。bash 自带一套调试机制用好这三板斧大多数问题都能快速定位。第一板斧是bash -x your_script.sh。这个命令会把脚本执行的每一步展开显示变量展开后的实际值。比如脚本里有if [ $name tom ]-x 模式会直接告诉你[ tom tom ]到底成立不成立非常直观。我排查问题第一件事永远是 -x比看半天代码快得多。第二板斧是在脚本开头加set -e set -uset -e表示任何一条命令返回非零状态码就立刻退出。它的价值在于防止错误滚雪球。比如你先创建了一个目录然后 cd 进去再执行后面的操作如果创建目录失败cd 会失败后面的操作全在错误目录里执行最后你都不知道哪里出了问题。有了set -e第一步就停下来。set -u表示变量没定义就报错。shell 里引用一个不存在的变量默认是一个空字符串很多拼写错误就是这样被悄悄吞掉的。比如把$filename写成$file_name脚本不会报错但行为完全不对。这个坑我年轻时踩过找一个变量拼写错误找了两个小时。第三板斧是set -x它和 bash -x 效果一样写在脚本里可以只对后半段生效不需要整个脚本都开 trace。配合set x可以精准调试某一段set -x # 这段会被详细输出 ping -c 1 192.168.1.1 set x顺便说一句生产环境脚本里不要长期保留set -x它会把敏感信息也打到日志里密钥、密码、Token 全暴露很危险。2. for 循环实战从批量重命名到批量服务检查2.1 四种 for 写法别只会一种shell 的 for 循环写法我见过至少四种每种都有适合的场景。先看最常见的遍历列表for name in tom jack mary; do echo Hello, $name done这种适合小集合直接把值写在 in 后面。第二种是遍历命令输出for file in $(ls *.log); do echo 处理 $file done但这里我强烈建议不要用ls用通配符更好因为文件名带空格的话上面这段会炸for file in *.log; do echo 处理 $file done第三种是 C 语言风格的 for适合确定次数的循环for ((i1; i10; i)); do echo 第 $i 次 done第四种是配合 find 命令适合遍历嵌套目录里的文件find /data/logs -name *.log -type f | while read -r file; do echo 文件: $file done需要注意的是第四种写法里 while 处于管道子 shell 中循环里修改变量循环外拿不到新值。这个问题我在第 4 节会详细说。2.2 批量重命名文件一个真实场景网上搜 shell 教程经常能看到“批量重命名文件”的例子。很多人抄下来发现为什么我的文件没改成我举个例子把当前目录下所有.txt文件改成.bakfor file in *.txt; do mv $file ${file%.txt}.bak done关键点在于${file%.txt}这是 shell 的字符串截取语法%表示从右往左删除最短匹配。${file%.txt}就是把变量 file 末尾的 .txt 删掉得到文件名主体部分再拼上 .bak。这里最容易踩的两个坑一个是for file in *.txt如果目录下没有任何 .txt 文件通配符不会被展开循环会执行一次此时 file 的值就是字面量*.txt然后 mv 会把文件改名为*.bak笑死。解决办法是在循环里加个判断for file in *.txt; do if [ -e $file ]; then mv $file ${file%.txt}.bak fi done另一个是文件名里的空格。很多人写mv $file ${file%.txt}.bak不加引号文件名是my file.txt就会被拆成两个词mv 直接报错。所有涉及变量展开的地方都要用双引号包住这是 shell 脚本最基础也最重要的习惯。2.3 批量检查服务存活状态再分享一个我常用的小脚本批量检查多台服务器端口是否存活#!/usr/bin/env bash servers( 192.168.1.10 22 192.168.1.11 80 192.168.1.12 3306 ) for item in ${servers[]}; do ip${item% *} port${item#* } if timeout 3 bash -c echo /dev/tcp/$ip/$port 2/dev/null; then echo $ip:$port 存活 else echo $ip:$port 不通 fi done这段脚本里有两个知识点值得单独说。一个是timeout 3给连接操作设置 3 秒超时。如果没有 timeout遇到一个不通的 IPTCP 连接可能卡很久整个脚本就被拖住了。另一个是/dev/tcp/$ip/$port。这是 bash 自带的一个虚拟设备可以直接用来做 TCP 连接测试不需要 nc、telnet 这些外部工具。但要注意这个特性只存在于 bashdash 里不行所以脚本第一行必须写 bash这也是我强调解释器重要的原因。3. 处理位置参数与 shift脚本入参不再靠脑记3.1 位置参数$0、$1、$、$# 到底是啥写脚本总会遇到要传参数的情况。比如我有个部署脚本./deploy.sh staging api-server$0是./deploy.sh$1是staging$2是api-server。$#是参数个数这里是 2。$是所有参数$*也是所有参数但两者有微妙区别。这个区别体现在引号上。假设你有三个参数a b c d$会展开成三个独立的词a b、c、d而$*会把所有参数拼成一个字符串a b c d。大多数场景下你应该用$因为原始拆分不会被破坏。遍历所有参数的标准写法for arg in $; do echo 参数: $arg done3.2 shift参数逐个往后挪shift 命令的作用是把位置参数向左移动。默认 shift 一次$2变成$1$3变成$2以此类推。也可以 shift n 一次移动 n 个位置。我举一个真实场景。前段时间我需要写一个脚本它的参数形式比较特殊./analyze.sh --input /data/app.log --level ERROR --output /tmp/result.txt如果不用 shift你就要写一堆 case 判断$1是不是--input然后取$2但是$2的语义会随着用户传参顺序的变化而完全错乱。用 shift 可以做出一个经典的参数解析循环#!/usr/bin/env bash input level output while [ $# -gt 0 ]; do case $1 in --input) input$2 shift 2 ;; --level) level$2 shift 2 ;; --output) output$2 shift 2 ;; *) echo 未知参数: $1 exit 1 ;; esac done echo input$input echo level$level echo output$output这段脚本的思路是每处理一个参数就用 shift 把它和它的值挪走这样$1永远指向下一个待处理的选项。循环条件$# -gt 0参数没处理完就继续。用 shift 的好处是参数顺序无所谓./analyze.sh --level DEBUG --output /tmp/a.txt --input /data/b.log不管用户先传哪个最终都能正确解析。这在写面向他人使用的工具脚本时特别重要因为你不能要求每个使用者都严格按你定义的顺序传参。3.3 更规范的选择getopts参数多了以后手动 shift case 会变得冗长这时候可以考虑 getopts。它是 shell 内置的选项解析命令支持短选项和参数绑定#!/usr/bin/env bash while getopts i:l:o:h opt; do case $opt in i) input$OPTARG ;; l) level$OPTARG ;; o) output$OPTARG ;; h) echo 用法: $0 -i input -l level -o output; exit 0 ;; \?) echo 无效选项; exit 1 ;; esac donegetopts 的选项字符串i:l:o:h中带冒号表示该选项需要接一个参数值$OPTARG就是那个值。它还能自动处理-i value和-ivalue两种写法比手写 case 省事很多。但 getopts 不支持长选项比如--input这种写法它只认-i。如果你的脚本既要长选项又要健壮的解析通常用 shift case 方案就够了如果项目复杂度高其实可以考虑直接用 Python 的 argparse不必硬啃 shell。4. 高频坑位总结这些坑我全踩过4.1 空格、引号和文件名通配符shell 脚本对空格极其敏感。if 语句的条件判断里[后面必须有空格]前面必须有空格变量和运算符之间也要有空格否则语法错误# 错误变量和 [ 之间没有空格 if[$name tom ]; then # 正确 if [ $name tom ]; then从这里又牵出另一个大坑测试语句里到底是赋值还是比较在[ ]里是比较字符串在[[ ]]里也支持但一旦不加空格比如if [$name tom]shell 会把[$name当成一个命令来执行直接报 command not found。这种错误看报错信息很容易懵因为你根本没写错逻辑只是少了个空格。文件名的处理同样容易翻车。假设目录下有report final.txt和report_draft.txt两个文件for f in *.txt; do wc -l $f done这个脚本遇到report final.txt时wc -l会收到两个参数report和final.txt然后报表文件不存在。解决办法就是引号wc -l $f另外[ ]和[[ ]]也有区别。[[ ]]是 bash 的扩展语法支持正则匹配、支持和||而不需要转义更强大。但它只在 bash 中可用。[ ]是 POSIX 标准兼容 sh。在写脚本前先想清楚你到底需要兼容性还是需要功能。我的习惯是自己内部工具、明确用 bash 的脚本直接上[[ ]]要分发给不同环境的通用脚本老老实实用[ ]。4.2 管道与子 shell变量怎么丢的这是个非常经典的坑。很多人写count0 cat data.txt | while read -r line; do count$((count 1)) done echo 总行数: $count然后发现 count 永远是 0。原因在于管道两端的命令各自运行在独立的子 shell 里while 循环对 count 的所有修改都发生在子 shell 中子 shell 结束后修改就丢了父 shell 里的 count 从未被改过。这背后是 shell 的进程模型管道符|会创建一个子进程来执行管道另一端的命令而子进程对变量的修改无法影响父进程。解决办法有三个。第一个用进程替换count0 while read -r line; do count$((count 1)) done (cat data.txt) echo 总行数: $count (...)的写法把命令输出作为文件句柄传入但整个 while 循环在父 shell 中执行变量修改保留。第二个把循环结果写到临时文件再读回来丑但可靠。第三个把整个处理逻辑放进子 shell 里在子 shell 内部完成统计和输出cat data.txt | { count0 while read -r line; do count$((count 1)) done echo 总行数: $count }这种在子 shell 内部输出结果就不存在父进程拿不到变量的问题了。最推荐第一种方案代码干净且逻辑清晰。4.3 set -e 与 cd 的交互坑很多人习惯set -e但没意识到它在某些场景会伤人。比如下面的脚本set -e cd /tmp/maybe-not-exist rm -rf something如果/tmp/maybe-not-exist不存在cd 返回非零状态码set -e 立刻终止脚本。看起来没问题但其实你想要的行为可能是“目录不存在就创建它再进去”。所以更稳健的写法是mkdir -p /tmp/maybe-not-exist cd /tmp/maybe-not-exist另一个更隐蔽的场景是条件表达式里的命令。比如set -e if [ -d /tmp/test ]; then rm -rf /tmp/test/* fi这没问题因为 if 条件里的命令即使返回非零也不会触发 set -e。但是如果你写grep -q pattern file.txt # 如果没匹配到grep 返回 1set -e 会直接退出grep 没匹配到内容就退出这经常不符合预期。解决办法是显式处理返回值if grep -q pattern file.txt; then echo 找到了 else echo 没找到 fi或者临时关掉 set -eset e grep -q pattern file.txt status$? set -e if [ $status -eq 0 ]; then echo 找到了 fiset -e 是有价值的但它不是银弹。我的建议是重要操作之前想清楚这条命令失败后应该做什么而不是盲目依赖 set -e 兜底。4.4 登录 shell 与交互 shellshell -l 到底干了什么热词里有shell -l这其实指bash -l或login shell模式。很多人在.bashrc里配置了环境变量和别名结果写脚本用 cron 定时执行时发现那些变量都不存在就是因为 cron 执行脚本时不是登录 shell、也不是交互 shell它只加载一个非常基础的环境。bash -l会模拟登录 shell逐个加载/etc/profile、~/.bash_profile、~/.bashrc等文件把用户完整环境拉起来。所以当你在 crontab 里要跑一个依赖复杂环境的脚本时可以在脚本开头加一句source ~/.bashrc或者用bash -lc /path/to/script.sh来触发登录 shell 环境。但反过来也有坑交互式配置里的别名、函数、一堆无关环境变量全被加载脚本的执行环境变得不可预测。我更推荐的做法是在脚本开头显式 export 你需要的环境变量不依赖 .bashrc。脚本应该是自包含的这一点对可维护性非常重要。还有一类问题是交互 shell 专属的。比如 CtrlC 的信号处理、作业控制job control这些在非交互模式里不生效你在终端里验证脚本没问题一放到后台或定时任务里行为就变了多半是交互模式和非交互模式的差异导致的。4.5 ADB Shell 环境特有坑针对安卓开发场景adb shell 里的环境是个精简版 shell很多命令和工具不存在。最典型的几个坑第一sh /storage/emulated/0/xxx/up.sh这种写法。adb shell 默认登录到设备后当前目录是/你要执行的脚本路径如果带空格或者特殊字符必须先处理干净。安卓 shell 一般只提供 mksh不是完整 bash所以[[ ]]、${var/x/y}这些 bash 特性可能不可用。第二安卓的echo命令默认不支持-e转义参数。你在 Linux 里写echo -e a\nb能得到两行在 adb shell 里可能直接原样输出-e a\nb。跨设备执行脚本时这种差异很容易让人崩溃。第三权限问题。安卓的/data目录通常需要 root 权限才能访问。很多用户写的 up.sh 脚本放在/storage/emulated/0/android/data/下这个路径本身是应用私有目录shell 访问时要注意权限不足导致读不了文件或者执行不了脚本。一般需要先chmod 755再用 adb shell 的 root 模式执行adb root。关于 adb shell 的更多内容我放到第 6 节专门写这里先记住一个核心原则跨端执行脚本时永远要假设目标环境的命令集合和你本机不一样。5. 自动连接 SFTP 传输文件的脚本模板运维工作里经常要做定时从远程服务器拉取文件、或者推送本地文件到远程的操作。SFTP 是 SSH 协议家族的一员数据加密安全性好是这类需求的首选。这里分享一个有实际使用价值的脚本模板。5.1 先解决认证问题写 SFTP 脚本之前先决定怎么认证。最省事的方式是配 SSH 密钥生成一对密钥后把公钥放到远程机器的~/.ssh/authorized_keys里之后脚本连接就不需要输密码也不涉及密码明文存储是运维里最推荐的方式ssh-keygen -t ed25519 -C deploy-bot ssh-copy-id user192.168.1.20如果你没有权限配置密钥只能用密码那么需要借助 sshpass 这个工具。注意 sshpass 里的密码是明文存在的脚本文件本身要小心保管sshpass -p your_password sftp -o StrictHostKeyCheckingno user192.168.1.205.2 SFTP 批处理模式的核心-b 参数SFTP 支持批处理模式通过-b指定一个命令文件里面写 sftp 子命令相当于自动在 sftp 交互界面里逐条执行。举个例子拉取远程日志文件#!/usr/bin/env bash HOST192.168.1.20 USERbackup REMOTE_DIR/var/log/myapp LOCAL_DIR/data/backup # 生成批处理命令文件 cat /tmp/sftp_cmds.txt EOF cd $REMOTE_DIR lcd $LOCAL_DIR mget *.log* bye EOF sftp -b /tmp/sftp_cmds.txt $USER$HOSTcd是切远程目录lcd是切本地目录mget是批量下载。对应的上传是mput。bye退出 sftp。这里的核心思想是把一组命令先写好再一次性丢给 sftp 执行。脚本内容很容易理解维护成本低。5.3 一个更完整的定时拉取脚本把上面的思路扩展一下做一个完整的、带日志和异常退出检查的脚本#!/usr/bin/env bash set -euo pipefail HOST192.168.1.20 USERbackup REMOTE_DIR/data/app/logs LOCAL_DIR/data/backup/$(date %Y%m%d) mkdir -p $LOCAL_DIR cat /tmp/sftp_cmds.txt EOF cd $REMOTE_DIR lcd $LOCAL_DIR mget *$(date %Y%m%d)*.log bye EOF if sftp -b /tmp/sftp_cmds.txt -o ConnectTimeout10 -o BatchModeyes $USER$HOST; then echo $(date %Y-%m-%d %H:%M:%S) 拉取成功 /var/log/sftp_backup.log else echo $(date %Y-%m-%d %H:%M:%S) 拉取失败 /var/log/sftp_backup.log exit 1 fi几个值得注意的参数-o ConnectTimeout10是 SSH 参数控制连接超时防止网络黑洞把脚本卡死。-o BatchModeyes是批处理模式如果密钥认证失败不会卡在密码输入界面而是直接报错退出这对自动化脚本特别重要不至于人不在的时候脚本挂在交互输入上。set -euo pipefail是 bash 脚本的集大成者pipefail表示管道中任意一个命令失败整条管道就算失败这个在 sftp 场景里很有价值避免 mget 失败但管道最后一个命令退出码为 0 的情况。另外sftp 批处理模式下 mget 如果没匹配到任何文件sftp 可能不会报错。如果业务上要求“必须拉到文件”建议在 mget 之后加一步检查file_count$(find $LOCAL_DIR -type f | wc -l) if [ $file_count -eq 0 ]; then echo 警告没有拉取到任何文件 exit 1 fi6. adb shell把脚本下发到安卓设备的正确姿势6.1 单条命令与脚本文件两种方式adb shell 是安卓开发调试和自动化测试中绕不开的工具它可以让你在电脑上直接操作安卓设备。常见的使用方式有两种。一种是直接执行单条命令adb shell ls /data/local/tmp adb shell pm list packages | grep com.example另一种是把脚本文件推送到设备上再执行adb push your_script.sh /data/local/tmp/ adb shell chmod 755 /data/local/tmp/your_script.sh adb shell sh /data/local/tmp/your_script.sh如果你经常用模拟器做自动化触控、模拟手机晃动、批量安装包这类操作写一个基础的上传脚本会大大提升效率。网上常看到adb shell sh /storage/emulated/0/android/data/com.omarea.vtools/up.sh这类命令本质就是先推文件再拉起执行只是路径通常在应用私有目录里涉及应用沙箱权限。6.2 一个实用的跨设备执行脚本这里分享一个我常用的模板作用是往安卓设备推送并执行脚本#!/usr/bin/env bash DEVICE_SCRIPTcheck_env.sh REMOTE_PATH/data/local/tmp/$DEVICE_SCRIPT # 1. 检查设备是否连接 adb devices | grep -w device || { echo 未检测到已连接的设备 exit 1 } # 2. 推送脚本 adb push $DEVICE_SCRIPT $REMOTE_PATH || { echo 推送失败 exit 1 } # 3. 执行脚本 adb shell sh $REMOTE_PATH这段脚本的重点在第 1 步。adb devices的输出第一行是表头List of devices attached第二行是设备序列号加状态用grep -w device匹配状态为 device 的行。如果不做这个检查adb push 到一个不存在的设备上会报 error: device not found虽然也会报错但通过自定义输出能让你更快定位问题。模拟器场景里有时候会看到adb shell sh /storage/emulated/0/android/data/.../up.sh这种执行方式。直接执行sh 文件路径的好处是不需要先 chmod x因为 sh 命令直接读取并运行脚本文件与权限位无关。这点在处理从电脑拷贝过去的脚本时非常方便因为默认拷贝过去的文件可能没有执行权限。6.3 跨端执行时的适配注意事项安卓 mksh 与桌面 bash 的环境差异是跨端脚本最容易翻车的地方我有几个经验换行符问题。Windows 上写的脚本通常带 CRLF 换行推到安卓后执行会报bad substitution或者\r: not found因为 mksh 把\r当成命令的一部分。解决方法是先用 dos2unix 转换或者用 sed 去掉换行符sed -i s/\r$// your_script.sh命令缺失问题。安卓 shell 精简了很多命令比如nproc、column、tree这些大概率不存在find的参数也和支持的版本不同。写脚本前先确认目标环境有哪些命令可以用which 命令检查。echo 的行为差异。前面提过安卓的 echo 默认不解释\n转义如果你要输出多行文本建议用 printfprintf 第一行\n第二行\n这个在桌面环境同样有效所以我现在的习惯是无脑用 printf 代替 echo。路径权限问题。/data/local/tmp是 adb shell 常规推荐的可写目录。/storage/emulated/0/android/data/是应用专属目录不同应用之间不可随意访问除非你的 adb shell 具备 root 权限。如果提示 permission denied先执行adb root再重试。7. 最后再聊点我自己的经验写 shell 脚本这些年我最深的感受是shell 的优势从来不是处理复杂逻辑而是把一堆命令行工具粘合在一起快速完成批量化、自动化的事情。它的判定标准是“用起来省事”而不是“功能最全”。如果一个脚本逻辑开始变得绕你为了绕过 shell 的各种限制写出一堆奇技淫巧这时候就该考虑换 Python 或者 Go 了不要硬撑。还有一个建议是平时多做案例积累。网上的“shell 脚本编程 100 例”之类的合集我建议不要只是收藏拿到手以后逐行跑一遍看每段脚本覆盖了哪个知识点再改成你自己工作里的真实场景。别人写一百个例子不如你自己改出五个能用的脚本更能建立手感。最后分享一个小技巧写脚本时先花两分钟想清楚最可能失败的三个点提前把 if 判断写上。比如删文件前判断文件是否存在连远程前判断网络是否通执行命令后判断退出码是多少。习惯成自然之后你的脚本出错率会肉眼可见地下降这在生产环境里真的很重要。