先问个问题:你在Shell里敲过的最长一条命令是什么?是一长串管道,还是带了一堆awk的ps aux | grep xxx?如果你能熟练拆解这些命令,却始终写不出一份像样的Shell脚本,那大概率卡在同一个小坎上——你一直把Shell当作那个"黑框框",而不是一门真正的编程语言。
我见过太多开发者的Linux技能树是断的:命令敲得飞起,脚本写得抓狂。一个用来批量处理几百个日志文件的sh,写得像拼图一样勉强能跑,文件名一旦带空格、变量忘了加引号、管道中间某段失败了,立刻崩给你看。这篇我不打算给你列命令字典,而是把Shell脚本这条线从头捋一遍:先搞清楚Shell到底是什么,再把for循环、shift、引号和退出码这些"老熟人"掰开揉碎,接着带你看一场真实的排错事故现场,最后把hbase shell、adb shell、EFI Shell这些藏在特定工具里的Shell变体也一并讲透。适合刚入门脚本的新手,也适合那些"命令会敲、脚本不会写"的准老手。
1. Shell到底是什么:一个被你用成"黑框框"的编程语言
1.1 本质:你和内核之间的那个"传话筒"
很多人对Shell的理解停留在"一个可以敲命令的窗口",这是最要命的误解。Shell的准确身份有两个。第一个身份,它是用户态的一个程序,负责接收你输入的字符串,解析成命令,然后拉起别的执行进程,再把结果交还给你。换句话说,你敲的ls -l,实际上是Shell替你fork出了一个ls进程,并给它传了-l参数。
第二个身份,它是一门完整的编程语言。这门语言里可以定义变量、使用数组、切分字符串、声明函数、写条件和循环,甚至在Bash里还有关联数组(哈希表)。你完全可以把几十个文件操作、网络请求、文本处理命令编排在一起,让它自动跑完。一句话总结:Shell既是命令执行的环境,也是把这些命令编织成逻辑的线。我习惯把这两个身份称为"调度员"和"胶水"——它自己不怎么干活,但所有干活的人都要听它安排。
这里有个很多人没意识到的点:你在Shell里敲mpv video.mp4,以为Shell在播放视频,其实Shell只是拉起了一个mpv进程。视频解码、音频输出全是mpv干的,Shell唯一的功劳是把"启动mpv"这个意图传达给了内核。这个概念在后面讲hbase shell和adb shell时还会用到,只要环境里存在"交互式命令解释器",不管它长在Linux、Android还是数据库里,你的Shell思维都能直接迁移过去。
1.2 Bash是老大,但它不是唯一的Shell
Linux世界默认的Shell是Bash,但你在网上查资料时常会看到sh、zsh、dash、fish、PowerShell,它们都是Shell,差异还挺大。我整理了一个简表:
| Shell | 特点 | 典型场景 |
|---|---|---|
| Bash | Linux默认,语法功能全面,有数组、关联数组、[[ ]] | 绝大多数Linux脚本首选 |
| Zsh | 交互体验好,补全能力强,向下兼容Bash | macOS默认Shell、日常终端使用 |
| Dash | 轻量,POSIX兼容,没有Bash的高级语法 | Debian/Ubuntu系的/bin/sh |
| Fish | 开箱即用,语法更友好 | 日常交互,不推荐做跨机器脚本 |
| PowerShell | 面向对象,输出是对象而非文本 | Windows自动化运维 |
最容易踩的坑是shebang。脚本第一行写#!/bin/bash和#!/bin/sh,在绝大多数Linux发行版上都能跑,但在Debian系(Ubuntu就是)里,/bin/sh其实是指向dash的软链。dash是一个只支持POSIX语法的最小Shell,它没有Bash的数组、没有[[ ]]、没有${var//替换}这些扩展语法。我当年在Ubuntu上写过一个脚本,本地跑得好好的,发给同事一执行就报[[: not found,查了半天才发现同事机器上/bin/sh是dash,而我的脚本用了Bash专有的[[ ]]。从那以后我自己的脚本一律写#!/bin/bash,除非明确要求POSIX兼容。
顺带回答一个Windows下很常见的问题:"Windows环境用什么Shell工具好?"如果只是想在Windows上写点跨平台脚本,Git Bash最省事;如果有Linux开发、部署、AI工具链需求,WSL才是正解,它给你的是一个完整的Linux用户态。PowerShell是另一条路线,对象管道设计很惊艳,但和本文讲的Bash脚本语法不通用,你只需要知道它也是一个Shell即可。
1.3 "Shell"这个词,在不同语境下是好几张脸
你搜"Shell"时经常会被完全无关的内容带偏,我一次给你区分清楚:
第一张脸是命令行Shell,也就是本文主角。第二张脸是特定软件的内嵌Shell,比如hbase shell、adb shell、EFI Shell、vCenter/ESXi Shell,它们的本质是"某个软件自带的命令交互环境",语法不一定和Bash一样,但交互逻辑完全相通。第三张脸是名字里恰好带Shell、但跟命令行八竿子打不着的东西,最典型的就是GNOME桌面里的Tiling Shell扩展,那是一个增强型平铺窗口管理器,核心定位是让GNOME原生的窗口平铺逻辑更接近专业平铺WM,解决的是键盘流用户"窗口排列全靠拖鼠标"的痛点,适合喜欢纯键盘操作桌面的人——它跟Shell脚本是两个世界。
还有一个情况:你在FreeCAD这类三维建模软件里看到"The reference marker of an extrusion, revolution, or shell must belong"这种报错,这里的shell是"壳体"特征,是建模术语,同样和脚本无关。再比如有些在线平台明确提示"does not provide shell access",意思是这个环境没给你开交互式Shell权限,你只能通过配置文件或接口去操作,别想着能拿到一个终端慢慢敲命令。搞清楚这些语境,能省下很多翻资料的冤枉时间。
2. 从for循环到shift命令:把脚本写成"能用十年"的样子
2.1 脚本骨架:不是把命令抄进文件就叫脚本
先解决最基础的一件事:一段能跑的脚本长什么样。它通常包含三部分:解释器声明、变量定义、主逻辑。比如你想检查某几个服务是否在运行,可以这样写:
#!/bin/bash name="nginx" if systemctl is-active "$name" >/dev/null 2>&1; then echo "$name 正在运行" else echo "$name 未运行" fi这里的#!/bin/bash是shebang,告诉内核用什么解释器执行这份文件。>/dev/null 2>&1表示把标准输出和错误输出都丢弃,因为我们只关心命令的退出状态码。if systemctl is-active "$name"这句话看起来是在判断文本,其实是在判断退出码,systemctl is-active如果服务在运行,返回0,否则返回非0。Shell里的if判断的永远是命令的返回状态,不是布尔表达式本身,这个认知是新手的第一个分水岭。
执行时有两种姿势:bash script.sh或者先chmod +x script.sh再./script.sh。前者不管文件有没有执行权限都能跑,后者更接近"独立可执行程序"的感觉。脚本不一定非要加.sh后缀,我很多脚本直接就放在~/bin目录下,起名叫ipinfo、deploy这种,放进PATH里就能像普通命令一样调用,这个习惯我强烈推荐。
2.2 for循环四种写法,以及为什么我很少用ls遍历
for循环是Shell脚本里出现频率最高的结构,基本可以归纳成四种写法:
# 1. C风格:用数字控制次数 for ((i = 1; i <= 10; i++)); do echo "第 $i 次" done # 2. 显式列表:遍历固定集合 for env in dev test prod; do echo "环境: $env" done # 3. 通配符展开:遍历当前目录下的.log文件 for f in /var/log/*.log; do echo "处理 $f" done # 4. 命令替换:不推荐,后面讲原因 for f in $(ls /var/log/*.log); do echo "处理 $f" done第一种适合精确控制循环次数,第二种适合枚举固定环境名,第三种适合处理文件集合,第四种是我最不推荐的——$(ls ...)会把ls的输出交给Shell再做一次词切分和路径展开,等于对结果做了二次处理。文件名里一旦有空格,第四种会直接把一个文件名拆成多个"词",导致循环体拿到残缺路径。在命令行上,ls是为了给人类看的;在脚本里,直接使用通配符或find才是正经做法。
顺带说一个遍历文件内容的小技巧:很多人写for line in $(cat file),这个同样是词切分陷阱,正确姿势是while IFS= read -r line; do ...; done < file,IFS=是为了避免行首行尾空白被吞,-r是为了防止反斜杠被转义。这个写法初看有点别扭,但它是处理按行读取的"标准答案",值得直接背下来。
2.3 shift命令:让脚本优雅地消费参数
脚本的定位参数从$1开始,一共$#个,$0是脚本名,$@是所有参数组成的列表。shift的语义非常直观:每次执行,所有位置参数左移一位,原来的$2变成新的$1,$#减一。它和while组合在一起,就能实现一个最朴素的参数解析器。
#!/bin/bash while [ $# -gt 0 ]; do case "$1" in -h|--help) echo "usage: $0 --env <dev|prod>" exit 0 ;; --env) env="$2" shift 2 ;; *) echo "未知参数: $1" exit 1 ;; esac done echo "当前环境: ${env:-dev}"注意这里有个关键细节:--env dev用了shift 2,一次性跳过两个参数,因为dev已经被env="$2"消费掉了。如果不小心只写shift 1,下一次循环就会把dev当成新的$1,掉进*分支。我刚开始写参数解析时犯过这个错,表现是"明明传对了参数,程序却报未知参数"。
如果脚本参数结构很复杂,建议直接用getopts,它支持-a、-b value这种短选项语法,解析规则更规范。但getopts不支持长选项,如果你需要--env这种风格,树fudge一个case+shift的方案,或者用zsh/外部工具,但就我个人经验,90%的脚本用shift就能解决,而且可读性更好。
2.4 实战:用Shell批量重命名文件,附空格文件名避坑
日常里最常用的场景就是批量重命名。假设你有一堆照片要加前缀:
#!/bin/bash cd ~/photos for f in *.jpg; do mv "$f" "2025_${f}" done这段代码能跑,但有两个前提:一是照片文件确实在~/photos目录下,二是不存在"2025_A.jpg"这种已经处理过的文件,否则会重复加前缀。实际上我更推荐在写任何"处理文件"的循环前,先打印一遍将要执行的命令,确认无误再真正执行:
#!/bin/bash for f in *.jpg; do echo "mv '$f' '2025_${f}'" done多了一个echo,就能防止九成的手滑误操作。这种"先预演再执行"的思路在删文件、改权限、批量上传等危险操作上尤其重要,我已经把它变成了肌肉记忆。
批量改名还有一个更强力的工具:rename命令。这里的rename是Perl版,在Debian/Ubuntu上要单独装,语法是正则替换:
rename 's/\.jpg$/\.png/' *.jpg它在批量替换后缀、批量加编号、按正则过滤文件名时比手写for+mv高效得多。但正则可读性差,如果你只是简单加前缀,写for循环反而更清楚。另外注意:文件名以-开头时,mv会把文件名当成选项,正确姿势是mv -- "$f" "2025_${f}",--表示"后面不再是选项"。这个细节在脚本里很容易被忽略,等线上出问题才想起来,那已经是爆过的雷了。
3. 踩过的那些Shell坑:一场与空格、退出码和引号的战争
3.1 坑一:变量没加双引号,等价于让Shell对你做"词切割"
先做一个思想实验:
dir="/home/me/My Photos" cd $dir你会得到报错,因为不加引号的$dir展开后,Shell按空白字符把它切成了两个词/home/me/My和Photos,cd只收到第一个词,自然找不到目录。加上引号cd "$dir",它才被当成一个完整的路径。
这个切割规则叫IFS(内部字段分隔符),默认是空格、制表符、换行。Shell世界里,所有变量展开、命令替换、算术展开的结果,都会在"执行"前经历一次IFS切割。所以"变量要加双引号"这条规则不是洁癖,是保命。当年我在生产环境写过一个清理临时文件的脚本,文件名里带日期和空格,rm $file直接把我同事好几个重要文件挪进了"错误目标",还好没发生灾难性后果,但从那以后我写任何变量引用都默认加引号,包括$1、$?这种看着"不可能有空格"的。
处理文件名的标准姿势是这样的:
# 错误:路径带空格就炸 for f in $(find . -name "*.mp4"); do ffmpeg -i "$f" ... done # 正确:用read按行接收,IFS留空 find . -name "*.mp4" -print0 | while IFS= read -r f; do ffmpeg -i "$f" ... done-print0让find用\0结尾而不是换行,read再按\0切分,这样不管文件名里有什么妖魔鬼怪,都能被完整传递。
3.2 坑二:管道返回的退出码,可能根本不是你想的那个
写脚本判断服务日志里有没有关键字时,常见写法是:
grep "ERROR" /var/log/app.log | head -n 5 echo $?这段代码返回的$?其实是head的退出码,不是grep的。head成功读到数据就返回0,哪怕grep一条都没匹配到,管道整体看起来还是"成功"。于是你精心设计的"检测到错误就告警"逻辑,在日志没有ERROR时反而静悄悄,等于告警系统形同虚设。
两个解法:一是set -o pipefail,它让管道的最终退出码变成最后一个非零退出码,整条管道只要有命令失败就失败;二是用${PIPESTATUS[@]}单独取每个命令的状态,但这只在当前管道执行后立刻读取才准确。
set -o pipefail if grep "ERROR" /var/log/app.log | head -n 5 >/dev/null; then echo "找到ERROR记录" else echo "没有ERROR记录或已处理" fi加上pipefail之后,grep失败、head即使成功读取,整个if判断也走false分支。这个坑特别隐蔽,因为它不会报错,只是"静默地给出错误结论",在告警、计数、数据校验场景里危害巨大。
3.3 坑三:set -e的"好心办坏事"
很多教程都在吹脚本第一行写set -e,让脚本遇错即停。但set -e有一个很反直觉的行为:它会在普通命令失败时退出,却不会在if判断条件里退出,也不会在while、until条件里退出。这本身是合理设计,却导致一个经典翻车:
set -e grep "PATTERN" app.log echo "脚本继续干活"如果grep没匹配到,返回非0,set -e会让脚本直接退出,后面的echo永远不执行。你心想"没匹配到就继续走,匹配到才处理",结果变成了"没匹配到就直接退出",完全相反。
解决办法不是不用set -e,而是把"允许失败"的意图写清楚:
set -e if grep -q "PATTERN" app.log; then echo "匹配到了" fi echo "无论是否匹配,脚本都继续"或者用grep -q "PATTERN" app.log || true,|| true把这个失败吞掉。我的习惯是:默认开set -e,凡是明确"失败了也无所谓"的命令,统一补上|| true或者包一层if,不让Shell替我做决定。这个取舍搞明白后,set -e就从"捣乱鬼"变成了真正的保险丝。
3.4 完整排查链路:一个批量重命名脚本从报错到修复
所有坑凑在一起时会是什么样?我拿一个真实发生过的小事故给你完整走一遍排查链路。
需求很朴素:把当前目录下所有包含中文"未命名"字样的文件,统一改名为2025_前缀。第一版脚本长这样:
#!/bin/bash for f in $(ls); do mv $f 2025_$f done实际目录里有这些文件:
未命名 文档.txt 未命名-方案.md readme.md执行后报错,而且只有部分文件被改名。
排查第一步:用bash -x rename.sh看执行轨迹。-x会打印每条命令展开后的真实样子,这是Shell调试最重要的手段。屏幕上立刻暴露问题:
+ ls + mv 未命名 文档.txt 2025_未命名 文档.txt mv: 目标 '文档.txt' 不是目录两个问题同时出现:$(ls)把"未命名 文档.txt"按空格切成了两个词;2025_$f同样被切割,导致mv收到了五个参数,把它当成"把多个源文件移动到不存在的目标目录",直接报错。
排查第二步:确认是IFS在作祟之后,把遍历方式从$(ls)改成通配符展开:
#!/bin/bash for f in *.txt *.md; do mv "$f" "2025_${f}" done然后保留bash -x再跑一次,看到:
+ for f in *.txt *.md + mv '未命名 文档.txt' '2025_未命名 文档.txt'单引号出现了,说明这次Shell把整个文件名当成了一串,引号保护生效。文件也成功改名。
但真正的教训还没结束——由于第一次脚本已经部分执行过,目录里可能残留了2025_未命名-方案.md这种半成品。修复脚本前,我先用ls盘点了所有文件,手动把之前误改名、误操作产生的文件调整回原样,再重新跑修正版脚本。这种"先修复现场,再重跑逻辑"的顺序,比直接闷头改脚本重要得多。从那次以后,我处理批量操作的第一原则是:先备份,再预演,后执行。
4. 出了Linux,Shell还有一堆变体:hbase shell、adb shell和藏在固件里的Shell
4.1 hbase shell:HBase数据库的"专属遥控器"
HBase是一个分布式列族数据库,它自带一个交互式Shell,名字就叫hbase shell。注意,这个Shell不是Bash,是一个用JRuby实现的交互环境,语法更接近Ruby和Java的内部DSL。你启动它:
hbase shell然后执行建表、查看region、切分表等操作。
最值得讲的是预分区与自动拆分。HBase一张表的数据是按rowkey范围分布在多个region上的,如果所有数据都写同一个region,那就是典型的"热点",机器再快也扛不住。手动预分区可以在建表时指定:
create 'user_info', 'cf', {NUMREGIONS => 10, SPLITALGO => 'HexStringSplit'}NUMREGIONS => 10表示预建10个region,SPLITALGO指定拆分算法。这里有个选型细节,新手最容易抄错:
| 拆分算法 | 适用rowkey类型 | 说明 |
|---|---|---|
| HexStringSplit | 十六进制字符串 | 最常见,适合MD5类散列rowkey |
| DecimalStringSplit | 十进制数字字符串 | 适合自增数字类的rowkey |
| UniformSplit | 二进制字节 | 通用,但分布均匀性一般 |
如果你在建表之后发现某个region还是过大,可以手动切分:
split 'user_info', 'rowkey_10000'这条命令会把包含rowkey_10000这个边界的region在指定rowkey处切开。我见过不少人在HBase里"建了表就万事大吉",等压测时某个region写穿磁盘才反应过来预分区的重要性。写数据前按业务量估算好region数量,是HBase Shell实操里最值钱的一个习惯。
4.2 adb shell:Android调试桥里的Shell世界
adb是Android调试桥,adb shell可以让你进入Android设备内置的命令行环境,通常是一个叫toybox的轻量工具集,语法接近但不完全等于BusyBox。进入之后,很多在Linux上跑得飞起的Bash高级语法在这里都不存在,写循环、变量展开时要保守一点。
热搜里有个词是"adb shell vm",这里必须提个醒:正确命令是adb shell wm,不是vm。wm是window manager(窗口管理器)的缩写,它负责控制Android窗口系统;vm在IT里一般指虚拟机,这里根本没有这个命令。
实操中最常用的是用wm命令模拟屏幕分辨率和密度,做适配测试:
# 设置分辨率为1080x1920 adb shell wm size 1080x1920 # 恢复默认分辨率 adb shell wm size reset # 设置DPI为440,模拟高密度屏幕 adb shell wm density 440 # 恢复默认DPI adb shell wm density reset热搜里还有"adb shell wm设置应用屏幕方向",控制方向的关键是旋转设置:
# 关闭自动旋转 adb shell settings put system accelerometer_rotation 0 # 设置为横屏,rotation值:0竖屏、1横屏(逆时针90度)、2竖屏反向、3横屏反向 adb shell settings put system user_rotation 1这套操作在跑UI自动化、做多机型适配测试时是刚需。你可以在命令行直接执行adb shell settings put ...,不用先进Shell再敲,但理解"adb shell后面跟的是目标设备里的命令"这一点很重要。它的内部逻辑和Bash脚本完全不同,但"进入环境、执行命令、拿到输出"的交互模型,还是我们熟悉的Shell思维。
4.3 EFI Shell、vCenter Shell这些"藏在角落里的大哥"
除了数据库和Android,很多固件和虚拟化平台也有自己的Shell。EFI Shell就是UEFI固件内置的迷你Shell,老X79主板的时代特别流行,刷BIOS、加载NVMe驱动、看内存映射表都靠它。你开机按特定键进入UEFI Shell后,看到的命令是ls、map、fs0:、edit这种,只能访问挂在固件下的设备,但它依然是一个"命令解释器+脚本执行器",同样有变量、有循环,只是语法精简到了极限。如果你没接触过,记住一点:在EFI Shell里map是查看文件系统映射的,fs0:是切换到第一个文件系统,逻辑和Linux的挂载点切换没什么本质区别。
虚拟化场景里也经常碰Shell。vCenter/ESXi主机默认不开Shell,需要先在服务里启用SSH或ESXi Shell。如果你在操作时遇到starting with root...can't open root shell, try again...still not :(这种报错,大概率是TSM-SSH相关服务没启动,或者当前用户没有shell访问权限。命令行里能用的工具主要是esxcli和vsish,前者负责配置管理,后者负责深度的内核态信息查看。它们的语法和Bash完全不同,但你能明显感觉到是同一套"交互式命令解释"的骨架。
5. 从"能跑"到"不容易跑坏":脚本习惯与调试三板斧
5.1 我的工作流:先在命令行试通,再变脚本
很多人写脚本是打开编辑器直接写一堆命令,写完一遍跑,报错再改,来回折腾。我的习惯刚好相反:所有逻辑都先在命令行里手工敲一遍,确认输出符合预期,再搬到脚本里。
具体流程是这样的:
- 在终端手敲命令,注意观察每一步输出和报错。
- 用
history或Ctrl+R找回刚才成功的命令,把它复制到脚本文件。 - 把硬编码的路径、IP、文件名抽象成变量。
- 在脚本开头加上
set -euo pipefail和错误分支。 - 用
bash -n做语法检查,跑一遍shellcheck,最后放一个带空格的测试文件实际跑一次。
这套流程最大的价值在于:它把"命令是否可行"和"脚本逻辑是否正确"两个问题拆开了。如果你在脚本里试命令,错误一来你就分不清是命令本身有问题,还是脚本结构有问题;在命令行试通后再搬,脚本阶段专注处理变量、循环、退出码,排错范围一下子缩小一半。
5.2 给脚本加保险丝:set -euo pipefail到底做了什么
很多脚本模板第一行是#!/bin/bash,第二行是set -euo pipefail,这三个开关各管一摊:
| 开关 | 作用 | 典型场景 |
|---|---|---|
-e | 有命令返回非0就退出 | 防止中间失败后继续执行造成连锁错误 |
-u | 使用未定义变量时报错 | 防止变量名拼错后静默变成空串 |
-o pipefail | 管道中任一段失败都算整体失败 | 防止grep被head吞掉退出码 |
-u这个开关很多人忽略,但它特别有用。比如你本想用$directoy,写成了$directory,Shell在默认情况下会把未定义变量当成空字符串,脚本继续跑,直到某条命令因为目录不存在而失败,你还要反向跟踪到底是哪里拼错了。开了-u,它当场报错直接指出,省下大量排查时间。
我推荐的标准模板是:
#!/bin/bash set -Eeuo pipefail trap 'echo "出错位置: $LINENO, 命令: $BASH_COMMAND"' ERRtrap ... ERR是另一个保险丝:脚本一旦出错,会打印出错行号和执行到一半的命令。在复杂脚本里,这行代码能让你从"对着报错反复猜"直接跳到"打开对应行看看"。不过要记住,set -e不是万能的,它管不了"命令确实失败了,但恰好被if接住了"这种场景,所以你要做的不是依赖它,而是理解它,在允许失败的命令后面显式补充|| true或if。
5.3 shellcheck与bash -n:写脚本也要像写代码一样做体检
bash -n yourscript.sh可以快速做语法检查,只检查语法,不执行任何命令,写在管道前的变量有没有闭合引号、括号有没有配对都能查出来。但它不检查逻辑,也不检查你引号用得对不对,所以真正有重大价值的是shellcheck。
shellcheck是一个静态分析工具,Debian系直接apt install shellcheck,macOS用brew install shellcheck。安装后执行:
shellcheck yourscript.sh它会以警告的形式告诉你脚本哪里有问题。最常见的警告是SC2086:
In rename.sh line 5: mv $f 2025_$f ^-- SC2086: Double quote to prevent globbing and word splitting.这条警告就是我在3.1里讲的引号问题。shellcheck还有好几百条规则,但新手期只要盯住SC2086(变量加引号)、SC2045(不要解析ls)、SC2164(cd失败后要检查)这三条,脚本质量就已经超过大部分网上的示例了。我现在的流程是:写完脚本先shellcheck,它报零警告了才敢进测试。这个习惯帮我拦下了无数个"看起来能跑、一跑就炸"的雷。
5.4 调试三板斧:echo、set -x和PS4
工具再全,真正的调试还是靠这三板斧。
第一斧是echo。想确认某段逻辑有没有执行?在关键位置加一行echo "debug: 当前文件名=$f",跑完看输出。这个打法虽然土,但效率极高,尤其适合逻辑分支多的脚本。
第二斧是set -x,它会打印每一条命令展开后的真实样子,变量值、引号、路径全部展示出来。你可以在脚本顶部加,也可以用bash -x script.sh临时启用,不用改文件。3.4节里我就是靠它定位到mv命令参数被拆成五段的。set -x输出有时候很吵,但调试时那条最刺眼的输出往往就是问题本身。
第三斧是PS4,它配合set -x使用,给输出的每一行加前缀。我常用的配置是:
export PS4='+${LINENO}: '这样-x打印的每一行都会带上行号,你能直接定位到脚本的哪一行执行出了问题。如果再配合5.2节的trap ERR,一个脚本出错时,行号、命令、上下文全部齐活,基本不用猜。
我个人不太推荐在脚本里用read -p中断式调试,就是那种"脚本跑一半停下来等你按键"的玩法。它看起来直观,但调试完很容易忘记删,脚本一旦交给cron定时任务跑,卡在那里等输入,直接挂到天荒地老。真要调试复杂数据流,优先考虑把中间结果写临时文件,或者直接用bash -x看轨迹,而不是在脚本里埋交互点。
4.2节提到过Python的交互式解释器也自带一个"playground",那个技术上也叫Python Shell。你可以在里面输入import webbrowser、webbrowser.open('https://example.com')来拉起默认浏览器,本质上就是用Python Shell作为入口去调用外部程序,这和我们在Bash里输入mpv video.mp4调用播放器一模一样——Shell不负责干活,它只负责按你的指令去请人干活。想明白这个模型,以后你碰到任何新的Shell都敢上手。