资讯动态

Shell脚本高级特性实战:从变量、重定向到进程管理的自动化核心能力

发布时间:2026/9/10 6:59:05 来源:尧图企业网站定制
Shell 脚本高级特性这个词听着像一本八百页的工具书实际上却是每一位运维、测试和搞自动化的同学都绕不开的能力分水岭。我刚入行时觉得Shell就是敲命令后来发现真正的高级特性能让一个几百行的脚本缩到几十行还能把错误处理、并发、日志这些工程问题一并解决。这篇文章我会直接讲干货从变量、参数、流程控制这些“地基”特性到重定向、文件描述符、进程管理这些容易劝退的进阶内容再帮你梳理Shell脚本里最常见的坑和调试手段最后用一个设备老化测试全自动执行脚本的实战案例把前面这些特性串起来。不管你是刚入门想学shell脚本还是已经写过一阵子但总感觉不得要领这篇都值得照着敲一遍。1. 从基础到进阶Shell脚本的核心设计思路1.1 脚本不是命令的堆砌先想清楚再动手很多初学者写Shell脚本习惯把命令一行行罗列出来跑通就交差。这种脚本最大的问题是换台机器、换个目录、或者某条命令失败整个脚本就原地爆炸。高级特性和普通用法的区别往往不是你会不会写某个语法而是你有没有把脚本当成一个真正的软件产品来设计。我自己的习惯是动手前先回答四个问题这个脚本的输入是什么输出是什么处理逻辑分几步出现错误时应该怎么办想清楚这四件事再决定用哪些特性。比如要遍历一批日志文件你先别急着写for循环而是想清楚文件名从哪里来、是否需要过滤、每一行怎么解析、结果写到哪个文件、中途失败要不要中断。这些决策最终都会体现在参数处理、管道、重定向和错误控制上。另外Shell脚本是解释执行的语言命令之间通过退出码、标准输入输出、管道这些机制协作。高级特性本质上就是在教你更好地控制这三个东西。你一旦理解了“每个命令其实都是一个有输入、有输出、有退出状态的小函数”再看重定向、进程替换、管道这些概念就不会觉得它们是一堆孤立的技巧了。1.2 变量、引号与通配符高级用法的地基Shell的变量看似简单但高级用法都在细节里。先说你每天都在用的变量扩展${var:-default}可以在变量为空时给默认值${var:default}是给变量本身赋默认值${var:?message}在变量为空时直接报错退出${var:alternate}则在变量非空时输出替代值。这四个操作符能省掉一整片if判断。还有字符串处理那一套${#var}取长度${var#pattern}从开头删最短匹配${var##pattern}删最长匹配${var%pattern}从结尾删最短匹配${var%%pattern}删最长匹配${var/old/new}替换第一个匹配${var//old/new}替换全部匹配。比如你要提取文件名去掉后缀一行${file%.*}就搞定根本不需要调用basename再sed。引号是另一个重灾区。很多人分不清单引号、双引号和反引号的区别。单引号里的内容原样输出双引号里变量会展开而通配符不会展开反引号和$()用于命令替换。写脚本时我建议统一用$()而不是反引号因为反引号对嵌套的处理非常别扭比如$(echo \$(which sh))这种场景反引号会把转义搞乱。双引号最大的作用是防止空格分词比如$和$*的区别$把每个参数当成独立个体$*把所有参数拼成一个字符串。这两个在循环里表现完全不同稍不留神就会出错。通配符这块很多人以为*只能匹配文件名其实它是在路径名展开阶段完成的。如果你不想让通配符生效可以用set -f临时关闭这在处理包含特殊字符的字符串时非常有用。还有一个容易踩的细节[ -n $var ]和[ $var ]看着差不多但变量为空时你少写双引号可能连[这个命令都报错因为展开后变成了空参数。所有变量引用尤其是用[ ]进行判断时一律加双引号这是我从入行就被前辈反复强调的规矩。1.3 函数化与模块化让脚本可维护脚本一旦超过五十行不复用函数就会变成灾难。Shell函数定义很简单func_name() { ... }或者function func_name { ... }我习惯用前者因为可移植性更好。函数内部可以用local定义局部变量这个一定要养成习惯不然函数里的变量会污染全局命名空间。这在复杂脚本里会出现特别诡异的问题比如你写了个循环变量i函数里也用i函数一调用外层循环就崩了。函数还有一个容易被忽略的关键点函数的退出状态。函数return的值会作为整个函数命令的退出状态你在调用函数后可以通过$?拿到。如果函数里最后一条命令执行失败但你没显式return函数本身也可能返回失败。写高级脚本时我会刻意在函数末尾return 0或return非零值让调用方可以统一处理错误而不是靠“碰运气”。模块化方面Shell虽然没有真正的包管理但你可以用source来复用公共函数。把日志函数、错误处理函数、通用解析函数抽到一个lib.sh里然后source ./lib.sh引入。我曾经在一个项目里维护了十几个脚本公共部分就靠这样一个lib文件改一处所有脚本生效。文件开头判断[ -f $LIB_PATH ] || exit 1做个保护再配合readlink -f定位脚本真实路径这样不管从哪个目录调用都能找到依赖文件。2. 高级特性逐个拆解你每天都会用到的硬核能力2.1 参数处理与shift彻底搞懂脚本怎么接参数参数处理是Shell脚本的门面。$0是脚本名$1、$2是位置参数$#是参数个数$和$*是全部参数。很多人写脚本只会用$1一旦参数变多就手忙脚乱。这时候shift就派上用场了。shift的作用是把位置参数左移一位原来的$2变成$1$#减一。配合while循环可以逐个吃掉参数非常适合处理命令行的动作指令。比如你想写一个支持start、stop、restart参数的脚本就可以用case $1 in加shift的模式第一个参数是动作执行后shift剩下的参数作为附加选项交给函数处理。另一种更规范的方式是用getopts它支持-a、-b value这种短选项还能自动处理-ab合并写法。我写面向他人的脚本时一般优先用getopts因为它自带错误提示比如a)后面加个:表示该选项需要参数没传参时它会提示。当然getopts不支持长选项如果你需要--verbose这种就得自己解析了。还有一个词容易被忽略$IFS也就是内部字段分隔符。默认是空格、Tab、换行。当你用for i in $时其实是在按IFS分词。如果你手动设置IFS,就能把一个逗号分隔的字符串轻松切成数组或逐项遍历。但注意改了IFS一定要记得恢复否则全局的分词行为全变了。我通常会在子shell里改IFS比如(IFS,; for ...; done)这样不影响外部环境。2.2 流程控制for、while、case的进阶玩法for循环大概是shell脚本里最常见的结构但高级用法比你想的多。最简单的for i in 1 2 3遍历列表for i in {1..10}生成连续数字for file in *.log遍历匹配的文件。注意{1..10}这个花括号扩展在变量场景下不生效你要遍历1到$n得用seq 1 $n或者for ((i1; in; i))。C风格的for循环在Shell里完全可用只是写着写着容易忘掉双括号。while循环最常用的场景是逐行读取文件while IFS read -r line; do ... done file.txt。这里read -r表示不要处理反斜杠转义IFS表示不修剪行首行尾的空白字符这两个组合能保证每一行内容原样读取。如果你直接写while read line遇到带空格、反斜杠的文本就容易出问题。顺便说很多人觉得cat file | while read也行但管道会让while跑在子shell里循环内对变量的修改不会带出循环外。用 file重定向就没这个问题。case语句是替代多层if的利器模式匹配支持通配符。常见写法是case $var in然后每个分支用括号结尾。比如根据错误码分类处理ok) ... ;;、warn|error) ... ;;、*) ... ;;。这里*)是默认分支几乎所有case都要写防止传入未预期的值。case还有一个冷门用法正则表达式匹配在bash里可以用[[ $var ~ ^[0-9]$ ]]这个和case是两回事但效果类似适合做格式校验。2.3 重定向与文件描述符操作输入输出的一把梭重定向是Shell脚本高级特性里最硬核的部分。标准输入、标准输出、标准错误分别对应文件描述符0、1、2。是重定向标准输出2重定向标准错误或2表示把两者合并。是追加。是输入重定向。真正高级的玩法是用exec修改当前Shell的文件描述符。比如你想把脚本里所有输出同时写到屏幕和日志文件可以这么写先exec 31把文件描述符3指向屏幕再用exec (tee logfile)把标准输出交给tee这样日志文件和屏幕都有输出。再比如你想临时把标准错误发送到日志exec 2error.log之后所有命令的报错都会进这个文件。记住要恢复的话得先exec 21或者在子shell里操作。进程替换是bash的一个隐藏大招。(command)可以把命令输出当成一个临时文件来引用(command)则把内容喂给命令作为输入。比如你想对比两个命令的输出可以用diff (sort file1) (sort file2)而不需要先写临时文件。这在写脚本时非常实用尤其是多个命令结果需要同时参与运算的场景。要注意的是进程替换在POSIX sh里是不支持的脚本如果用#!/bin/sh这种写法会报错得明确用bash运行。管道是Shell命令组合的经典机制但它会引入子shell。这就是为什么管道后面修改变量经常无效。如果需要管道后的结果参与后续逻辑可以用进程替换、临时文件或者借助shopt -s lastpipe让最后一个管道命令在当前Shell执行。这个选项在bash 4.2支持但要注意它只对最后一个命令生效。2.4 进程管理与后台任务脚本里的并发与协作Shell脚本能管理多进程这是很多人小看它的地方。最简单的并发就是把命令放到后台command 然后把进程ID存到变量里pid$!。多个后台任务可以用wait $pid1 $pid2等待全部完成。这里要小心wait只能等待当前Shell的子进程如果你用管道包着一个命令那它其实是在子shell里运行的$!拿不到正确的PID。陷阱之一后台进程的输出会混在一起经常出现日志错乱。解法是给每个后台任务分别重定向到不同日志文件最后再合并。另一个陷阱脚本退出时后台任务可能还在跑。你可以在脚本末尾统一wait或者用trap捕获退出信号把残留进程杀掉。trap kill $pid 2/dev/null EXIT是很常见的写法。更高级的并发控制是用命名管道做队列。比如你要并发处理10个文件但只想同时跑3个任务可以创建一个fifo写3个令牌进去每个子进程从fifo读令牌处理完再放回。这样能有效控制资源占用。命名管道在Shell里用mkfifo创建读写时注意阻塞问题没数据时读端会阻塞所以令牌数量要控制好用完echo回管道避免死锁。进程管理还有两个实用命令jobs查看当前Shell的后台任务列表kill发送信号。配合set -m开启作业控制后你甚至能用fg、bg把任务在前台后台之间切换。写比较严肃的脚本时我还会用wait -n等待任意一个后台任务结束这在并发批处理里很有用。3. 写脚本必踩的坑与排查技巧3.1 那些年我们踩过的Shell坑Shell脚本的坑十个里有八个是空格和引号引起的。a 1和a1完全不同前者是执行a命令带上等号和1两个参数。if [ $var yes ]如果var为空展开后就是if [ yes ]直接报错。所有变量引用都加双引号这条规则我已经说了第二遍因为它太重要了。还有一个是在[ ]里是bash扩展POSIX shell只认所以老实用比较字符串。set -e也是个爱恨交织的选项。它的作用是一旦任何命令返回非零就立即退出。听起来很美好但用起来会坑人。比如cmd1 cmd2这种如果cmd1失败整条表达式返回非零set -e会触发退出但如果它出现在if条件里set -e反而不生效。更坑的是管道默认只看最后一条命令的退出码前面的命令失败了你根本不知道。所以我会在set -e基础上加上set -o pipefail让管道中任何一个命令失败都导致整条管道非零退出。整数运算的坑也很多。在POSIX shell里let a$a1或者a$((a1))才是算术直接写aa1会把字符串原样赋值。expr 1 2里的空格不能省。还有除法默认是整数除法echo $((1/2))会输出0。如果要保留小数得借助awk或bc。另外一个常见问题是变量名里有空格或特殊字符用${var}总比$var安全。3.2 调试三板斧set -x、trap、shellcheck调试Shell脚本我首先上set -x。它会在执行每条命令前先把展开后的命令打印到标准错误前缀是。这个输出能让你一眼看出变量展开成了什么、哪一步开始出错。如果脚本太长了不需要全程打印可以在可疑段落前后用set -x和set x包起来。配合PS4变量可以自定义调试前缀比如PS4 $LINENO: 会在每条调试行前显示行号定位问题快很多。trap是非常强大的调试和清理工具。trap echo error at line $LINENO ERR可以在任何命令返回非零时打印出错行号配合set -e能快速定位失败点。trap cleanup EXIT保证脚本无论正常退出还是异常退出都会执行清理函数。做临时目录、临时文件的清理用这个再合适不过。还有trap ... INT TERM能处理CtrlC和终止信号避免脚本被杀掉后留下半成品。再说shellcheck。这是我在团队里强制要求使用的静态检查工具能抓到大量Shell脚本的隐性错误比如变量忘记加引号、[误写、cd后未判断是否成功、grep后接q等。虽然它不能保证你的逻辑完全正确但至少能消除一大批低级问题。安装很简单主流发行版都有包也可以在线跑。我建议把shellcheck集成进Git提交钩子凡是脚本更新必须通过检查能省掉很多review时间。3.3 错误处理策略忽略错误继续执行 vs 立即退出写脚本时最难决定的一件事到底要不要出错就立刻停很多刚上手的人习惯用set -e然后发现脚本动不动就挂掉。其实高级脚本里错误处理是有策略的不是一刀切。如果你在执行一连串彼此不依赖的清理操作比如删除临时文件、清空缓存、压缩老日志其中一个失败不应该阻止其他步骤。这时候可以用command || true来吞掉非零退出码但前提是你确认这个失败不影响后续逻辑。也可以用if command; then ... fi把命令包在判断里这样set -e不会退出你还能根据结果决定后续行为。如果你在处理一连串有依赖关系的步骤比如先创建目录再写入文件再启动服务那任何一个步骤失败都会导致后面白做甚至产生脏数据。这种情况下我建议用set -e加pipefail并在关键步骤后手动检查退出码或文件状态。还有一类是“期望某些命令失败”的场景比如检查某个进程是否已存在pgrep没找到会返回1这不算真正的错误那么pgrep ... || true是合理的。总结下来我的经验是不用全局set -e但在每个函数开头单独声明set -euo pipefail这样函数内严格把控外层脚本却可以灵活处理不同模块的失败。这个技巧在复杂脚本里特别管用。顺带说一句set -u一定要开它能在使用未定义变量时直接报错而不是静默当作空字符串否则哪天拼错变量名找半天都不知道问题在哪。4. 实战一个设备老化测试全自动执行脚本的拆解4.1 需求场景与脚本设计纸上谈兵没意思我拿一个真实的场景来串一遍高级特性。之前做设备老化测试需要长时间压测一台安卓设备的耗电和唤醒状态。测试脚本要自动完成连接设备、清空历史数据、设置电池统计、循环执行压力操作、定时抓取电量曲线、收集最终报告。整个过程不能中断还要在出问题时自动重启基础服务。这里就涉及了好几个刚才提到的点全自动执行需要后台运行与错误恢复设备的电池状态统计要用到Android自带的batterystats工具核心命令是adb shell dumpsys batterystats --enable full-wake-history它会开启详细的唤醒历史记录后续拉取数据、解析报告又需要解析命令输出的能力。脚本分三层最外层是主入口负责任务调度和日志中间层是各个具体的测试动作比如启停App、模拟操作、等待系统空闲最底层是公共函数库处理adb连接、时间戳、日志等。这样分层之后以后要换测试动作只需要改中间层函数主逻辑不用动。4.2 核心代码与细节解析先看主入口的骨架。#!/usr/bin/env bash set -uo pipefail SCRIPT_DIR$(cd $(dirname ${BASH_SOURCE[0]}) pwd) source ${SCRIPT_DIR}/lib/common.sh DEVICE_SERIAL${1:-} TEST_DURATION${2:-3600} REPORT_DIR${3:-$(date %Y%m%d_%H%M%S)} init_report_dir $REPORT_DIR check_device $DEVICE_SERIAL setup_batterystats $DEVICE_SERIAL run_loop0 while (( run_loop TEST_DURATION )); do run_test_cycle $DEVICE_SERIAL $REPORT_DIR run_loop$((run_loop CYCLE_INTERVAL)) done pull_report $DEVICE_SERIAL $REPORT_DIR finalize_report $REPORT_DIR这段代码里set -uo pipefail的意思是未定义变量报错、管道失败检测但不用set -e因为每个测试周期都有自己的错误容忍逻辑。${1:-}给设备序列号提供了空值保护没传参数也不会直接炸。$(date %Y%m%d_%H%M%S)用于自动生成报告目录这种时间戳变量在自动化脚本里太常见了。setup_batterystats函数里关键就是刚才提到的命令setup_batterystats() { local serial$1 adb -s $serial shell dumpsys batterystats --enable full-wake-history || { log ERROR 无法启用full-wake-history尝试重置 adb -s $serial shell dumpsys batterystats --reset sleep 2 return 1 } }这里用local保护变量用|| { ... }做错误分支没有让整个脚本退出而是记录错误并尝试恢复。log ERROR函数来自公共库会往日志文件和屏幕同时输出带时间戳的内容这个就是通过exec重定向机制实现的我在2.3节提过。run_test_cycle是一个压测周期里面会启动App、模拟滑动、等待一段时间并定时抓取当前的电量和唤醒状态。抓取命令会拼上--enable full-wake-history相关的设置在测试开始前开好周期结束再把batterystats的dump拉到本地归档。整个循环用while ((...))控制时长循环体内用run_loop$((...))做累加注意不能用run_loop这种bash扩展吗其实bash支持((run_loop))但为了和POSIX兼容我还是写了展开式。4.3 从脚本到工程化日志、告警、重试脚本能跑只是第一步到工程化层面日志和告警才是主角。我在公共库里定义了一个简单日志函数log() { local level$1 local message$2 local ts ts$(date %Y-%m-%d %H:%M:%S) echo [$ts] [$level] $message | tee -a $LOG_FILE }tee -a同时输出到屏幕和日志文件这就是用管道和重定向实现的双写。再配合日志轮转脚本启动时检查日志文件大小超过10M就重命名成.1然后再建新文件这是很多自动化脚本会忽略的细节。不然老化测试连续跑几天日志分分钟把磁盘塞满。告警方面我一般会在每个关键步骤后轮询检查失败次数。如果连续3次check_device失败就说明设备断连了脚本会发送一个系统通知或者调用企业微信的Webhook接口。这个发送动作本身也是Shell代码用curl加上json模板简单可靠。重试逻辑则用状态变量和count计数比如adb命令偶发超时我会让它重试3次每次间隔5秒超过阈值再报警。老化测试的特殊场景是测试时间很长跑完一个周期可能几百秒。我特别注重“可恢复性”每次周期开始前写一个状态文件记录当前进度脚本中途被误杀下次启动时先读状态文件判断要不要跳过已经完成的周期。这个状态管理用到的就是trap和文件读写。我觉得自动化脚本能做到这一步Basic的Shell脚本才算是真正上了档次。5. 进阶扩展Shell生态与岗位技能地图5.1 常见工具链与命令组合只懂Shell语法还不够Shell的魅力在于和外部工具的组合。文本处理三剑客里grep负责匹配sed负责替换和流编辑awk负责结构化数据和报告生成。我写脚本时最常干的事就是先用find找到文件再用xargs并发处理最后用awk汇总结果。比如统计一堆日志里某个关键字的出现次数一行就能搞定cat *.log | grep -c ERROR但更严谨的是grep -h ERROR *.log | wc -l区别在于grep加-c会对每个文件单独计数。xargs是一个容易被低估的命令。它能把前一个命令的输出变成后一个命令的参数支持按行、按数量分批还支持-P参数做并发。比如你要把一批图片压缩find . -name *.png -print0 | xargs -0 -P 4 -I {} sh -c convert {} {}.jpg这里-0配合-print0处理文件名中的空格-P 4同时跑4个进程-I {}重命名占位符。这种组合在数据处理脚本里几乎每天都会用到。JSON和YAML解析在现代Shell脚本里也越来越常见。jq是JSON处理的瑞士军刀支持查询、修改、管道操作。比如从接口返回里取某个字段curl -s $api | jq -r .data[].name。YAML解析虽然麻烦但可以用yq工具。这些工具并不属于Shell语言本身但掌握了它们能让你的脚本一下子脱离“只会调命令”的层次。5.2 Linux Shell编程岗位到底需要什么经常有人问Linux shell编程的岗位需求到底是什么。从招聘信息和我自己带人的经验看大概分三层。第一层是基础熟练使用常见命令能写简单的脚本处理文件备份、日志筛选、批量操作。第二层是进阶掌握变量、函数、流程控制、参数处理、文本处理工具组合能独立写几十到几百行的自动化脚本处理cron定时任务、系统监控、应用部署。第三层是高级能把脚本工程化有良好的错误处理、日志、并发控制、可维护性意识能设计多模块脚本系统还能用Shell封装其他工具或接口。面试的时候我一般不会只问语法更爱让对方手写一个脚本比如“统计Nginx访问日志里Top10的IP并计算每个IP的请求总量”。这道题看似简单但能考察管道、awk、sort、uniq这些工具的组合能力还能看出候选人对字段抽取和排序去重的理解。如果再追问一句“如果日志文件很大怎么办”就能判断对方有没有性能意识。所以学习Shell脚本千万别停在背诵语法要在真实场景里多练数据处理和异常处理。岗位技能这块建议优先级最高的是文本处理命令、Shell语法、系统管理命令、网络工具和日志系统。其次是根据业务场景自己造轮子的能力。比如用Shell写一个定时巡检脚本检查磁盘、内存、CPU把结果格式化输出。自己动手做过一遍比刷十遍文档都有用。5.3 如何持续提升自己的脚本能力提升Shell脚本能力最笨也最有效的方法就是大量阅读别人的脚本然后自己改写。GitHub上有好多开源项目里面全是高质量的Shell代码比如一些安装脚本、初始化脚本、CI流程脚本。读的时候多问几个为什么为什么这里用set -e而那里不用为什么管道后面能拿到变量为什么这个函数要放在单独的文件里看懂之后再改成自己的版本把日志模块和错误处理模块拆出来复用。还有一个习惯我特别推荐遇到重复操作先停一下想想能不能用脚本解决。比如我经常从Windows和Linux之间切换有时候在Windows命令行遇到“git无法识别”“pip无法识别”这类问题大家第一反应是手动去配PATH但我会写个小脚本一次性把常用工具路径都加到当前会话。这类小工具写多了你的Shell感觉会越来越好。代码评审也很重要。给同事看脚本的时候让对方指出你哪里写得不清楚、哪里容易崩。如果团队里没有这个习惯自己给自己定个规矩每写一个脚本都过一遍shellcheck再故意用错误参数跑几次看看错误信息是否友好。我见过太多脚本最后挂在“参数没传”、“路径不存在”这种低级问题上而这些只要有意识地设计错误提示就能解决。我的个人体会是Shell脚本的高级特性不是一次性学完的而是一个一个在实际需求里慢慢积累出来的。每次踩坑、每次翻文档、每次把脚本重构得更精简都是在给能力添砖加瓦。最后再分享一个小技巧每写完一个脚本花五分钟想想如果一个月后自己再来看能不能一眼明白这段逻辑。如果能说明你的脚本已经具备了基本的高级特性素养如果不能就继续调整注释、函数划分和命名。这个习惯比收藏任何学习资料都管用。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价