
开头先说个背景我最近在整理部门里那堆“祖传脚本”时发现很多从网上直接抄下来的shell脚本一跑就报错。问题大多不是语法看不懂而是被各种隐藏细节坑得死去活来。做系统自动化和设备测试这些年我对shell脚本的态度可以说是又爱又恨。不少朋友都问过shell脚本到底应该怎么入门我觉得它真没那么玄乎——本质上就是把你平时在终端里敲的命令按照一定的逻辑写进一个文件然后让电脑自动帮你一条条执行。今天这篇文章围绕shell脚本的核心用法做一个比较完整的复盘顺便把几个真实用过的场景摊开讲批量重命名文件、Android设备老化测试的自动执行、MySQL定时执行SQL脚本还有那些最让人抓狂的常见坑。1. shell脚本能干什么先搞明白定位1.1 不是多高深的技术是一套“能自己跑完的流程”shell本身是用户和操作系统内核之间的一层翻译官常见的有bash、sh、zsh。shell脚本就是把这层翻译官要执行的命令串起来配合变量、判断、循环和函数让它从“敲一行执行一行”变成“一次执行一整串”。真正让它值钱的地方不是命令本身而是流程的自动化。举一个我日常常见的例子要统计今天中午某个服务日志里所有HTTP 5xx错误来源IP的Top 10再用邮件发告警。如果打开日志文件人工筛做完大概要半小时如果用shell一条管道命令就搞定比如grep 2025-06-01T12: /var/log/nginx/access.log \ | awk $9 ~ /^5[0-9][0-9]$/ {print $1} \ | sort | uniq -c | sort -rn | head -10把这条命令加个循环定时执行、再配合邮件发送工具一个基础的监控告警系统就有了。这就是shell脚本在生产环境里的典型价值——小成本、高效率别人手动操作一天脚本几分钟干完。另外shell脚本还有一层好处可复用性。只要把命令固化到文件里后续任何环境只要满足依赖就能复现相同结果避免了“人肉操作容易漏步骤”的风险。1.2 适合什么场景哪类场景别硬上基于我自己的经验下面这些场景用shell脚本非常顺手系统日常巡检磁盘空间、内存占用、CPU负载、进程数量。日志分析过滤、排序、去重、统计配合awk和sed能处理绝大多数文本。批量操作批量改文件名、批量分发配置、批量检查服务健康。服务发布拉代码、编译、备份、启停服务、回滚这在部署流程里极常见。定时任务配合cron实现周期性执行。移动设备和嵌入式设备测试通过adb或者ssh远程执行命令实现自动化测试。但shell脚本不是万能的。如果是非常复杂的结构化数据处理比如要频繁操作JSON对象、做复杂的状态机或者业务逻辑动辄上千行还要写单元测试建议还是用Python、Go这类更完整的语言。shell的优势在于“胶水”把各种现成工具粘在一起而不是什么都自己实现。如果你发现自己为了解析一个嵌套JSON写了上百行awk那大概率是选错了语言。还有一点很容易被新手误解日常说的“游戏脚本”“网页用户脚本”比如Roblox的lua脚本、浏览器里的Tampermonkey用户脚本本质上是JavaScript或者其他宿主语言跟shell脚本完全是两码事。搜索资料时看到这类词要留意别把它们当成shell脚本的学习素材。2. 从零写一个标准shell脚本语法、变量和参数2.1 脚本骨架与解释器声明写shell脚本的第一步是创建一个普通文本文件然后在第一行写清楚用哪个解释器来执行它。最常见的是#!/usr/bin/env bash这行叫shebang作用是告诉系统这个文件要交给bash解析。之所以用/usr/bin/env bash而不是直接写死/bin/bash是因为很多发行版bash的路径不完全一样用env可以自动去PATH里找兼容性更好。之后建议立刻加一段“安全帽”设置set -euo pipefail这三条分别代表的含义是set -e脚本执行过程中只要某条命令返回非零状态码立即退出避免带着错误继续往下跑。set -u使用未定义的变量时报错退出防止拼错变量名导致静默异常。set -o pipefail管道的最终返回状态由最后一条失败命令决定。没开启的话即使前一条命令失败后面命令成功整体退出码仍然是0问题很容易被掩盖。光凭这个习惯就能过滤掉脚本里一大半“奇怪但不报错”的问题。我接手过的很多“祖传脚本”就是没加这行导致失败路径根本没被感知到。接下来最简单的脚本结构是#!/usr/bin/env bash set -euo pipefail LOG_FILE${1:-/tmp/app.log} echo 开始处理日志文件: $LOG_FILE tail -100 $LOG_FILE保存后用chmod x file.sh赋予执行权限再运行./file.sh即可。2.2 变量、位置参数和shift命令shell里定义变量的格式是变量名值有个容易忽略的细节等号两边不能有空格。写成name fooshell会把它解析成三条命令直接报错。引用变量时建议加双引号例如$name这样能防止变量内容里包含空格时被拆成多个字段。位置参数是脚本接收外部传参的主要方式$1、$2分别对应第一个、第二个参数$#表示参数个数$表示所有参数。比如一个脚本调用方式为./deploy.sh prod 20脚本内部就可以用$1拿到prod、$2拿到20。很多初学者不知道shift的用途其实它是我最喜欢的参数处理工具之一。shift的作用是把位置参数整体左移一位原来的$2变成$1$3变成$2。配合while [ $# -gt 0 ]循环可以写出灵活解析选项参数的逻辑。举个例子#!/usr/bin/env bash set -euo pipefail VERBOSE0 OUTPUT usage() { echo 用法: $0 [-v] [-o output_file] } while [ $# -gt 0 ]; do case $1 in -v|--verbose) VERBOSE1 shift ;; -o|--output) OUTPUT$2 shift 2 ;; -h|--help) usage exit 0 ;; *) echo 未知参数: $1 2 exit 1 ;; esac done echo VERBOSE$VERBOSE echo OUTPUT${OUTPUT:-未设置}这里shift 2表示一次消费掉-o和它后面的值两个参数。注意case分支里的*是处理所有未匹配参数的兜底方便使用时尽早发现拼错的参数。2表示把错误信息输出到标准错误便于和其他正常输出区分开。2.3 条件判断和for循环是脚本的发动机我见过的shell脚本里出现频率最高的语法一是if判断二是for循环。两者组合起来就能写出很多像样的自动化任务。先看条件判断。shell的if语法长这样if [ -n $USER_NAME ]; then echo 用户名为: $USER_NAME else echo USER_NAME 为空 fi-n判断字符串非空-z判断字符串为空。这里有个非常经典的坑[ -n $USER_NAME ]如果忘了给变量加引号当变量为空时shell实际执行的是[ -n ]而这会判定为一个非空字符串-n永远返回真。所以请务必写成[ -n $USER_NAME ]。整数比较用-gt、-lt、-eq文件存在判断用-f、-d。再看for循环。常用的几种写法# 方式一遍历数字序列 for i in $(seq 1 5); do echo 第 $i 次循环 done # 方式二遍历目录下的文件 for f in /tmp/logs/*.log; do echo 处理文件: $f done # 方式三C风格循环 for ((i 0; i 5; i)); do echo index$i done方式二有个容易踩的坑如果/tmp/logs/下根本没有.log文件*.log不会自动展开成空列表而是原样传入循环体导致脚本把/tmp/logs/*.log当成一个字面字符串去处理。更好的写法是在脚本里先加shopt -s nullglob开启“无匹配时展开为空”或者用find配合while read去逐行处理。3. 三个能直接落地的实战场景3.1 用shell脚本批量重命名文件批量重命名是几乎每个人都遇到过的需求。我之前有一批备份文件命名规则比较乱类似backup1.tar.gz、backup2.tar.gz需要统一改成“日期_序号”格式还要把后缀从.tar.gz改成.tgz。最朴素的思路是用for循环加mv#!/usr/bin/env bash set -euo pipefail i1 for f in backup*.tar.gz; do newname$(date %Y%m%d)_${i}.tgz mv -- $f $newname i$((i 1)) done那行mv -- $f里的--容易被忽略但它很重要如果文件名正好以减号开头mv会把-a这类参数误认成选项加--表示后面的内容都当作文件名处理。如果目录下面文件很多或者文件名本身带空格、换行for循环就不够稳了。文件名带空格时for f in *.tar.gz会把每个空格都当成分隔符循环体里拿到的根本不是完整的文件名。这种情况我建议用while配合find的-print0find /data/backup -type f -name backup*.tar.gz -print0 | while IFS read -r -d f; do newname$(date %Y%m%d)_$(basename $f .tar.gz).tgz mv -- $f $(dirname $f)/$newname done-print0把文件名之间用空字符分隔read -d 按空字符读取这样无论文件名里有什么特殊字符都能完整保留。这个写法在处理几百个文件时也几乎没有性能问题。3.2 adb shell设备老化测试全自动执行脚本设备老化测试是我最近做得比较多的场景。很多App在上线前需要验证长时间运行是否稳定手工操作一测就是一整天人盯在那儿太浪费。用shell脚本配合adb就能很轻松地把测试流程自动化。先说清楚adb shell到底能干哪些事。手机通过USB连接电脑后adb shell命令可以直接在手机端执行Linux命令比如adb devices查看当前连接的设备列表。adb shell pm list packages列出所有安装的包名。adb shell am start -n 包名/Activity名启动指定应用。adb shell input keyevent 26模拟按键事件比如电源键。adb shell cat /sys/class/thermal/thermal_zone0/temp读取电池或CPU温度。下面是简化版的老化测试脚本框架。它做的事情是循环启动微信停留一段时间读取温度和内存记录到日志文件如果温度超过预先设定的阈值就通知相关人员并做降温处理。#!/usr/bin/env bash set -euo pipefail DEVICE_SERIAL${1:-} APP_PACKAGEcom.tencent.mm MAIN_ACTIVITYcom.tencent.mm.ui.LauncherUI LOOP_COUNT50 TEMP_LIMIT45 LOG_FILE/tmp/aging_test.log if [ -z $DEVICE_SERIAL ]; then echo 请传入设备序列号例如: $0 emulator-5554 2 exit 1 fi check_devices() { adb devices | grep -q $DEVICE_SERIAL } check_temp() { local t t$(adb -s $DEVICE_SERIAL shell cat /sys/class/thermal/thermal_zone0/temp 2/dev/null | tr -d \r) # 部分设备直接返回毫摄氏度这里统一转成摄氏度 if [ $t -gt 1000 ]; then t$((t / 1000)) fi echo $t } start_app() { adb -s $DEVICE_SERIAL shell am start -n ${APP_PACKAGE}/${MAIN_ACTIVITY} } sleep_sec() { sleep $1 } for ((i 1; i LOOP_COUNT; i)); do if ! check_devices; then echo [$i] 设备未连接 | tee -a $LOG_FILE exit 1 fi start_app sleep_sec 15 temp$(check_temp) mem$(adb -s $DEVICE_SERIAL shell dumpsys meminfo $APP_PACKAGE | grep TOTAL | awk {print $2}) echo $(date %F %T) 第${i}轮 temp${temp}C mem${mem}kB | tee -a $LOG_FILE if [ $temp -ge $TEMP_LIMIT ]; then echo [$i] 温度超过阈值 $TEMP_LIMIT C | tee -a $LOG_FILE # 常见处理亮屏、按HOME、杀掉进程降温 adb -s $DEVICE_SERIAL shell input keyevent 3 adb -s $DEVICE_SERIAL shell am force-stop $APP_PACKAGE sleep_sec 30 fi adb -s $DEVICE_SERIAL shell input keyevent 3 sleep_sec 3 done echo 老化测试结束结果见 $LOG_FILE重点说一下几个容易被忽略的细节adb shell在Windows和部分设备上输出带有\r回车符所以读温度时要用tr -d \r清理否则数值比较会把回车符也算进去导致条件判断出错。用tee -a把输出同时打到屏幕和日志文件方便观察现场又保留记录。温度阈值和循环次数这两个参数非常重要建议在脚本开头单独定义而不是散落在循环体里到处魔数。如果测试需要随机滑动页面还可以用adb shell input swipe x1 y1 x2 y2 duration模拟手势需要截图取证就用adb exec-out screencap -p screen.png。把这些动作封装成函数老化脚本很快就丰满起来。3.3 shell脚本驱动MySQL定时执行SQL脚本再来看服务端这边的一个常见场景。运营需要每天凌晨2点批量更新一批数据人工去数据库执行SQL不现实用shell脚本加cron是最合适的方案。第一种方式是把SQL语句写进脚本里通过heredoc传给mysql命令#!/usr/bin/env bash set -euo pipefail DB_HOST127.0.0.1 DB_USERreport_user DB_PASS$(cat /etc/mysql/report_user.pass) DB_NAMEreport_db export MYSQL_PWD$DB_PASS mysql -h $DB_HOST -u $DB_USER $DB_NAME SQL UPDATE user_stat SET last_active_date CURRENT_DATE WHERE last_active_date IS NULL; INSERT INTO daily_summary (summary_date, total_users) SELECT CURRENT_DATE, COUNT(*) FROM user_stat; SQL unset MYSQL_PWD这里两个细节值得注意我故意不把密码写在命令行参数中因为ps能看到所有进程的完整命令行密码会泄露。更稳妥的方式是用MYSQL_PWD环境变量或者用--defaults-extra-file指定一个权限为600的配置文件。heredoc的定界符SQL带了单引号SQL表示这个heredoc内部的$符号不要被脚本展开。如果SQL里恰好有$之类的字符就能避开各种解析问题。第二种方式是让shell脚本去执行外部的.sql文件。比如把表结构变更脚本放在/opt/sql/目录下按版本号排序脚本负责循环执行#!/usr/bin/env bash set -euo pipefail SQL_DIR/opt/sql/upgrades LOG_FILE/var/log/db_upgrade.log for sql_file in $(ls $SQL_DIR/*.sql | sort); do echo $(date %F %T) 执行 $sql_file $LOG_FILE mysql --defaults-file/etc/mysql_deploy.cnf $sql_file if [ $? -eq 0 ]; then mv $sql_file ${sql_file}.done else echo 执行失败请检查: $sql_file $LOG_FILE exit 1 fi done实战中还有一个强烈建议每条SQL执行完都要确认退出码并写日志记录哪个文件执行成功、哪个失败。没有日志的数据库变更脚本一旦出问题排查成本会非常高。4. shell脚本的常见坑与排查技巧4.1 空格、通配符、换行符为什么总是坑人我发现新手脚本报错有一半以上和空白字符有关。shell的字段分隔符默认包含空格、Tab和换行所以$var不加引号时变量里的空格会把内容拆成多个参数传给命令。比如file_nameMy Report.txt touch $file_name实际执行的是touch My Report.txt创建了两个文件My和Report.txt。想要以整个字符串作为一个文件名必须touch $file_name。另一个高频坑是if判断里忘了留空格。[ $a $b ]里面括号两侧、等号两侧都必须有空格写成[$a$b]会被当成一个奇怪的命令直接报错。尤其是从Windows习惯转过来的朋友特别喜欢省这些空格结果就是各种command not found。还有前面提到的“没有匹配文件时通配符原样传入”的问题。处理办法是shopt -s nullglob让bash把无匹配的模式展开成空。我习惯在所有批量处理文件名的脚本开头都打开这个选项。4.2 管道、重定向和子shell的边界问题管道能让你把多个命令串起来但也隐藏着一个严重问题管道连接的命令组会运行在子shell里子shell里对变量的修改无法作用到父shell。这个坑我当年排查过很久count0 cat /var/log/nginx/access.log | while read line; do count$((count 1)) done echo 总行数: $count # 结果总是0这不是语法错误而是while作为管道的一部分跑在子shell中它修改的count只是子shell里的副本。等管道结束父shell里的count依然是0。解决方案是改用输入重定向count0 while read line; do count$((count 1)) done /var/log/nginx/access.log echo 总行数: $count同理如果你在脚本里用| tee -a log后面的变量赋值也可能遇到类似问题。所以我的经验是需要让循环结果影响脚本后续逻辑时优先用输入重定向而不是管道。4.3 Windows下执行shell脚本为什么总是“识别不了命令”很多热词搜索指向一个非常典型的Windows报错pnpm : 无法将“pnpm”项识别为 cmdlet、函数、脚本文件或可运行程序的名称。这个报错跟pnpm本身关系不大本质上是Windows的PowerShell不知道pnpm命令在哪里。排查思路从简单到深入这么走先确认到底有没有安装在PowerShell里执行where.exe pnpm如果提示找不到说明没装或者环境变量没生效。用Get-Command pnpm再看一遍确认当前会话是否加载了PATH。检查Node.js安装路径。pnpm如果是通过npm install -g pnpm装的全局bin目录一般要添加到用户PATH比如C:\Users\你的用户名\AppData\Roaming\npm。如果是新版Node自带的pnpm直接执行corepack enable就能启用。至于在Windows上运行Linux风格的shell脚本常见做法是装Git Bash、WSL或者用Cmder。Git Bash能模拟一个bash环境可直接执行bash deploy.sh。如果不装这些环境PowerShell里直接敲./deploy.sh几乎必然失败。另外Windows脚本命令闪退的问题多半是PowerShell执行策略限制可以用Set-ExecutionPolicy -Scope CurrentUser RemoteSigned放开但要注意这会修改本机安全策略别在不信任的机器上乱开。4.4 脚本能打开但执行却报错权限、编码和坏掉的换行另一种特别典型的“看起来对跑起来废”的情况是脚本文件从Windows传到Linux后执行报类似-bash: ./check.sh: /bin/bash^M: bad interpreter: No such file or directory。原因是Windows用\r\n换行Linux用\n换行。Linux执行时把每行末尾的\r也当成内容的一部分shebang那行就变成了#!/bin/bash\r。Linux去找/bin/bash\r这个解释器当然找不到。解决办法要么用dos2unix check.sh转换换行符要么在编辑器里设置保存为LF格式要么脚本内部提前处理sed -i s/\r$// check.sh还有一个低级但致命的问题就是脚本文件没有执行权限。很多新手写完脚本直接./test.sh结果提示Permission denied还以为自己被系统针对了其实只要chmod x test.sh就能解决。5. 给新手的几条实操建议5.1 写脚本前先想清楚三条边界我在带人写脚本时经常重复三句话这个脚本要解决什么问题输入是什么输出是什么。很多人一上来就写循环写到最后发现变量作用域乱成一团这跟“没有设计就编码”没有本质区别。具体来说脚本的边界包括单次执行还是重复执行重复执行的话要考虑同一份数据会不会被重复处理。需要登录远程主机还是本地执行涉及远程执行时网络中断、凭据过期都要有错误处理。失败后怎么办是立即退出还是记录日志继续跑下一条没有统一策略的话你会发现每个分支都在写一堆雷同的错误处理。有了边界脚本里每个函数干什么才能分得清楚。5.2 调试工具远比眼瞪屏幕高效shell脚本不像高级语言那样有集成开发环境断点但很多问题可以通过简单手段定位。我推荐三个命令bash -n script.sh只做语法检查不执行。写完脚本先跑一遍能过滤掉大部分语法错误。bash -x script.sh逐条显示执行的命令和展开后的变量值。这是排查逻辑错误的神器看到哪一行展开结果和预期不一致问题基本就锁定了。trap echo line $LINENO: $BASH_COMMAND DEBUG在脚本开头加上会在每条命令执行前打印行号和命令内容比逐行加echo更省事。下面是一个小示例展示如何用trap输出关键变量#!/usr/bin/env bash set -euo pipefail trap echo line $LINENO: pkg$pkg DEBUG for pkg in com.android.settings com.android.systemui; do adb shell pm enable $pkg || echo failed: $pkg done这样即使中间某一步失败退出也能从输出里看到最后一次处理的是哪个包名比猜来的快很多。5.3 我最常用的“防呆”写法汇总最后分享几个我在生产环境里用得最顺手的技巧都是平时踩坑换来的。一是善用trap做清理。脚本执行中被CtrlC中断或者因为某条命令失败退出时临时文件往往来不及清理。用trap rm -rf $tmp_dir EXIT可以在脚本退出时自动清掉临时目录。一定要放在定义tmp_dir之后否则变量未定义时反而会出错。二是所有路径都要能容忍目录带空格。写脚本时路径变量记得都加双引号。这跟前面提到的引号问题是同一个道理但很多人只注意文件名忘了路径里也可能有空格。三是尽量少用ls去循环文件。for f in $(ls *.txt)这种写法在文件名为空格、中文、特殊符号时会出各种诡异问题。正确姿势是让bash自己展开通配符或者用find加-print0配合while read。ls的输出适合肉眼观察不适合机器处理。四是给脚本起个有意义的名字并且加上版本和注释。不要像我部门里那种老脚本全叫test1.sh、test2.sh过了半年没人敢动。尤其当你写的是shell脚本这类偏自动化的工具将来维护的人很可能不是自己留下一两句关键注释能省掉后面一整个下午的排查时间。五是保留好离线记录。脚本执行完以后把日志文件和输出结果统一归档到/var/log/或者logs/目录文件名带上日期。别觉得这一步多余真要出问题时从日志里定位原因的速度往往比当场复现问题快得多。我自己在维护这些脚本时还有个小习惯每写完一个脚本都用最小范围的测试数据跑一遍比如只处理3个文件而不是全部文件确认逻辑没问题后再放开全量跑。别嫌麻烦这一步反而不费时间因为它省掉的是排查脚本跑坏线上数据的时间。shell脚本这门手艺说破天也就是“把该做的事用最快的方式自动完成”但真正值钱的是那些让它在各种突发状况下都能稳住的细节。