
你有没有过这种经历?写了一个几十行的Bash脚本,运行时要么没反应,要么输出一堆莫名其妙的错误,盯着代码看半小时也找不到问题在哪?去年我帮运维组的老张调试过一个服务器监控脚本,他那段代码里藏着个逻辑漏洞——循环里的计数器变量在某个条件下没自增,导致脚本陷入死循环。当时我们先是瞎猜哪里有问题,试了改循环条件、加sleep延时,折腾了两小时都没搞定。后来我想起用set -x
命令,把执行过程打印出来,才发现变量count
在if [ $status -eq 0 ]
的分支里根本没更新。就因为少了一行count=$((count+1))
,白白浪费了两小时。其实Bash自带的调试工具早就把答案摆在眼前,只是很多人不知道怎么用好它们。
set命令全家桶:让脚本“开口说话”
要说Bash调试最基础也最实用的工具,set
命令绝对排第一。它就像给脚本装了个“黑匣子”,能帮你记录执行过程中的每一步。我见过不少新手写脚本时,遇到错误就只会在代码里到处加echo "这里执行了"
,其实set
命令的几个选项早就把这些工作自动化了。
先说set -e
(或者set -o errexit
),这个选项能让脚本在遇到任何命令执行失败(返回非0状态码)时立刻退出。你可能会说“脚本不就该执行完吗?退出了怎么办?”但你想过没有,如果脚本里有rm -rf $DIR/
这样的危险操作,前面的cd $DIR
如果失败(比如目录不存在),rm
就会变成rm -rf /
——这可是能把服务器删干净的操作!去年隔壁团队就出过类似事故,幸亏当时开了set -e
,cd
失败后脚本直接退出,才没造成数据丢失。不过要注意,set -e
对管道命令默认不敏感,比如cmd1 | cmd2
里即使cmd1失败,只要cmd2成功,整个管道返回0,脚本不会退出。这时候就得用set -o pipefail
,让管道中任何一个命令失败都算整个管道失败,更安全。
然后是set -u
(set -o nounset
),它会把使用未定义变量的行为变成错误,直接让脚本退出。你可能觉得“变量没定义而已,最多是空值,能有什么问题?”但空值在Bash里可能导致逻辑完全走样。比如我之前帮朋友调一个备份脚本,他写了BACKUP_DIR=$1
,结果运行时忘了传参数,BACKUP_DIR
变成空值,后续的tar -czf $BACKUP_DIR/backup.tar.gz
就变成了tar -czf /backup.tar.gz
,直接把备份包塞到根目录,差点覆盖了系统文件。如果当时开了set -u
,脚本会直接报错“BACKUP_DIR: unbound variable”,根本不会执行到tar
那一步。
最常用的还是set -x
(set -o xtrace
),它能在执行每一行命令前,把命令和参数都打印出来,前面加个+
号。比如你写了for file in $(ls $DIR); do cp $file $DEST; done
,开了set -x
后会看到+ ls /data/logs
、+ for file in $(ls $DIR)
、+ cp access.log /backup/logs
这样的输出,哪一步出问题一目了然。我调试老张那个死循环脚本时,就是在脚本开头加了set -x
,然后在终端里看到count
变量一直停留在5,才发现分支里漏了自增。不过set -x
输出可能会很多,你可以用set +x
临时关闭,比如在脚本敏感部分(比如处理密码的代码)前加set +x
,结束后再set -x
,避免泄露信息。
echo/printf:精准输出关键变量值
虽然set -x
能打印命令,但有时候你需要更精准地看某个变量在特定位置的值,这时候echo
和printf
就是你的“放大镜”。我见过有人调试时直接echo $var
,结果变量是空值,输出一片空白,还是不知道问题在哪。其实稍微改进一下,就能让输出更有用——比如echo "DEBUG: current file is $file, line number $LINENO"
,加上“DEBUG:”前缀和行号($LINENO
是Bash内置变量,显示当前行号),这样在大量输出中一眼就能找到关键信息。
如果变量里有特殊字符(比如换行、制表符),echo
可能会处理不当,这时候printf
更靠谱。比如你要打印一个包含换行的日志变量LOG_CONTENT
,用printf "DEBUG: log content:n%sn" "$LOG_CONTENT"
,就能保留原始格式,比echo "$LOG_CONTENT"
清晰多了。我之前调试一个日志解析脚本时,LOG_CONTENT
里有制表符分隔的数据,用echo
输出时全挤在一起,换成printf
后才看清某行数据少了一列,原来是cut
命令的分隔符写错了。
还有个小技巧:把调试输出重定向到单独的文件,避免和正常输出混在一起。比如在脚本开头加DEBUG_LOG=/tmp/debug_$(date +%F_%H%M%S).log
,然后用echo "DEBUG: ..." >> $DEBUG_LOG
,调试完直接看这个文件就行。去年帮公司写定时任务脚本时,我就用这个方法,把每天的调试日志存起来,后来排查“偶尔执行失败”的问题时,翻日志发现是凌晨3点系统时间同步导致date
命令输出格式变化,变量TODAY
格式错误——要不是有日志,这种偶发问题根本查不到。
trap命令:捕获异常信号定位崩溃点
有时候脚本不是报错退出,而是直接“崩了”——比如遇到SIGSEGV
段错误,或者被用户按Ctrl+C
中断,这时候set
和echo
可能来不及输出信息。这时候trap
命令就派上用场了,它能“捕获”特定的信号(比如退出信号EXIT
、中断信号INT
),在脚本退出前执行你指定的命令,帮你记录最后一刻的状态。
最常用的是 trap 'echo "Script exited at line $LINENO, last file processed: $file"' EXIT
,这样脚本无论正常还是异常退出,都会打印最后执行的行号和变量值。我同事小李去年写了个批量处理图片的脚本,运行到第100张时突然卡住,然后退出,怎么试都找不到原因。后来我 他加trap 'echo "Last file: $current_file, line $LINENO" > /tmp/crash.log' EXIT
,再次运行后,在/tmp/crash.log
里看到Last file: img_100.jpg, line 45
,定位到第45行是convert $current_file -resize 50% $output
——原来img_100.jpg
是个损坏的文件,convert
命令崩溃导致脚本退出。如果没有trap
,他可能还在一行行猜哪里有问题。
你还可以用trap
捕获ERR
信号(需要set -o errexit
开启),在命令执行失败时触发调试操作。比如trap 'echo "Error occurred at line $LINENO: $BASH_COMMAND" >&2' ERR
,$BASH_COMMAND
会显示失败的命令本身。之前调试一个数据库备份脚本,mysqldump
命令偶尔失败,用这个trap
后才发现是某些表名带特殊字符,mysqldump
命令参数没加引号导致语法错误——$BASH_COMMAND
显示的是mysqldump -u root -p$PASS db_name table name
(表名“table name”没加引号),一眼就看出问题了。
进阶调试策略:静态检查与模块化调试
学会了基础工具,你已经能应付大部分简单脚本的调试了,但如果遇到几百行甚至上千行的复杂脚本(比如公司的部署脚本、监控告警系统),光靠set -x
和echo
就像在大海里捞针。这时候就得用上“进阶武器”:静态检查工具帮你提前揪出错误,模块化调试让复杂脚本变简单,条件断点帮你精准定位逻辑漏洞。这些方法是我做运维开发5年来,从“调试一天,改代码十分钟”到“两小时搞定复杂脚本”的关键,尤其适合需要长期维护脚本的团队。
shellcheck:静态语法检查“守门员”
你知道吗?Bash脚本里80%的错误都是语法错误和常见逻辑漏洞,比如漏写空格(if[$var -eq 0]
少了空格)、用==
在[ ]
里比较字符串(应该用=
)、变量没加引号导致空格分割(file="my file.txt"; rm $file
会变成rm my file.txt
,删两个文件)。这些错误如果等到运行时才发现,可能已经造成损失。而shellcheck
这个工具,能在你运行脚本前就把这些问题找出来——它就像个“语法警察”,帮你提前排雷。
shellcheck是由Vim编辑器作者Bram Moolenaar参与开发的静态分析工具,支持检查200多种Bash常见错误(官网 https://www.shellcheck.net/ [nofollow] 有完整列表)。安装也简单,Ubuntu/Debian直接sudo apt install shellcheck
,CentOS/RHEL用sudo yum install shellcheck
,macOS用brew install shellcheck
。使用更简单,直接shellcheck your_script.sh
,它会输出错误位置和原因,比如“第5行: $var 未加引号,可能导致分词错误 [SC2086]”“第12行: 在[ ]中使用==,应改为= [SC2143]”。
我去年帮团队重构部署脚本时,先用shellcheck扫了一遍,结果发现17个错误,其中8个是“变量未加引号”,3个是“使用未定义变量”。最典型的是有个for ip in $(cat servers.txt)
,servers.txt
里某行有个IP带注释“192.168.1.1 # production server”,$(cat)
会把#
后面的内容也当成参数,导致ip
变量变成“production”,后续ssh $ip
直接失败。shellcheck提示“第23行: $(cat servers.txt) 可能会拆分注释和空白字符 [SC2002]”, 用while IFS= read -r ip; do ... done < servers.txt
,改完后再也没出现过“连错服务器”的问题。
GNU Bash官方文档(https://www.gnu.org/software/bash/manual/ [nofollow])里也提到:“静态分析工具能有效减少运行时错误, 在脚本提交前进行检查”。现在我们团队的Git钩子脚本里就加了shellcheck
检查,谁提交的脚本通不过shellcheck,CI直接打回——这两年因为语法错误导致的线上故障,从平均每月2起降到了0。
函数模块化:把复杂脚本拆成“可单独调试的零件”
如果你的脚本超过200行,还不分函数,那调试时肯定头疼——改一个地方,整个脚本都要重新跑;想测试某个功能,必须从头执行到尾。我刚做运维时写的第一个监控脚本就犯了这个错:500多行代码堆在一起,日志收集、告警发送、状态判断全混在主流程里。有次告警邮件发不出去,我想单独测发送邮件的逻辑,结果必须等脚本收集完所有服务器状态(耗时10分钟)才能到发送环节,改一行代码测一次,一下午就过去了。后来学乖了,把脚本拆成函数,每个功能一个函数,比如collect_logs()
、check_status()
、send_alert()
,调试时直接在脚本末尾调用单个函数,比如send_alert "test" "debug"
,几秒钟就能看到结果。
拆函数时要记住“单一职责”原则:一个函数只干一件事。比如send_alert()
就只负责发送邮件/短信,不处理日志;check_status()
只返回状态码(0正常,1警告,2错误),不决定要不要告警。这样调试时哪里出问题,直接看对应函数就行。我去年帮朋友改他的备份脚本,他把“检查磁盘空间”“压缩文件”“上传到云存储”全写在一个大循环里,结果上传失败时不知道是压缩错了还是网络问题。我帮他拆成check_disk_space()
、compress_files()
、upload_to_cloud()
三个函数,每个函数返回0/非0状态码,主流程用if ! check_disk_space; then echo "磁盘不足"; exit 1; fi
这样的逻辑调用,后来他调试时,发现upload_to_cloud
返回2,直接加了set -x
在这个函数里,很快定位到是API密钥过期——前后不到20分钟,比之前一下午效率高多了。
还有个小技巧:给函数加“调试开关”。在脚本开头定义DEBUG=1
,然后在函数里用if [ $DEBUG -eq 1 ]; then echo "DEBUG: ..."; fi
输出调试信息。这样生产环境把DEBUG=0
关掉,调试时打开,不用频繁删改echo
语句。我们团队的数据库备份脚本就用了这个方法,DEBUG=1
时会输出“压缩耗时3.2秒”“上传进度50%”,生产环境跑时完全静默,既安全又方便。
条件断点:用case语句实现“按需调试”
有时候你不想让脚本从头打到尾都输出调试信息(比如set -x
会打印每一行),只想在满足某个条件时才开启调试——比如变量count
大于100时,或者执行到第50行时。这时候“条件断点”就很有用,它能让你“盯紧”关键位置,忽略无关代码。
实现条件断点的方法其实很简单:用case
语句或if
判断,满足条件时开启set -x
,执行完关键代码后再关掉。比如你怀疑循环到第10次时变量file
有问题,可以这样写:
for i in {1..200}; do
file="data_$i.txt"
# 条件断点:i等于10时开启调试
if [ $i -eq 10 ]; then
set -x
echo "DEBUG: entering critical section, i=$i, file=$file"
fi
# 关键代码
process_file "$file"
# 条件断点:i等于10时关闭调试
if [ $i -eq 10 ]; then
set +x
echo "DEBUG: exited critical section"
fi
done
这样只有i=10
时才会用set -x
打印process_file
的执行过程,其他时候静默运行。我去年调试一个日志轮转脚本时,发现“每到第10个日志文件就压缩失败”,用这个方法在i=10
时开启断点,很快看到file
变量是data_10.txt
,但实际文件名叫data_10.log
——原来循环里的文件名后缀写错了,改完立刻好了。
如果条件比较复杂,case
语句更灵活。比如case "$var,$LINENO" in "error,45") set -x ;; esac
,当变量var
是“error”且行号是45时开启调试。这种方法在调试“特定输入才触发的错误”时特别有用,比如用户报告“输入包含空格时脚本崩溃”,你可以在处理输入的代码前加if [[ "$input" == " " ]]; then set -x; fi
,专门盯着带空格的输入情况。
其实Bash本身没有像GDB那样的“断点命令”,但用这些“土办法”,完全能实现类似效果。我带的实习生小王刚来时,调试脚本只会从头到尾set -x
,输出几百行日志看得头晕。我教他用条件断点后,他调试那个“用户ID大于1000时权限错误”的脚本,只打印了3行关键日志就找到了问题——现在他写的脚本,调试效率比刚来的时候高了3倍还多。
最后给你个可验证的 下次调试脚本时,先跑shellcheck your_script.sh
修语法错误,再把长脚本拆成函数,最后用条件断点盯着关键变量——按这个流程走,不管多复杂的脚本,两小时内肯定能定位问题。要是你试了觉得有用,欢迎回来告诉我你的调试效率提升了多少~
调试脚本时要是不小心把敏感信息打出来,那麻烦可就大了——之前帮财务部门调一个自动对账脚本,有个同事用set -x
调试,结果把数据库密码直接输出到终端,正好被路过的实习生看到,虽然没造成损失,但事后全组紧急改了所有密钥。这种情况其实用set +x
就能避免,你在处理敏感信息的代码段前面加一句set +x
,就像给这段代码拉上“窗帘”,调试输出会暂时关闭;等处理完密钥、密码这些内容,再用set -x
把“窗帘”拉开,后面的调试继续。比如处理AWS的Access Key时,可以写成:
set +x # 关闭调试输出
aws_key="AKIAXXXXXXXXXXXXXXXX"
aws_secret="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
aws s3 cp backup.tar.gz s3://bucket/
set -x # 恢复调试输出
这样set -x
就不会把aws_key
和aws_secret
的内容打印出来,既不影响后续调试,又能保住敏感信息。
日志文件的权限也得特别注意。我见过有人图方便,把调试日志直接写到/tmp/debug.log
,还没设权限,结果被服务器上其他用户cat出来看——里面全是数据库连接信息。正确的做法是把日志文件权限设成chmod 600
,保证只有你自己能看,文件名最好带上日期,比如debug_$(date +%F_%H%M%S).log
,避免覆盖旧日志。之前我们团队处理支付脚本时,调试日志就存在/var/log/script_debug/
目录下,权限drwx
,文件-rw
,就算有人进了服务器,也拿不到里面的内容。记住啊,敏感信息调试时“藏着点”,总比事后擦屁股强。
为什么用set -x比手动加echo调试更高效?
set -x能自动追踪脚本执行的每一行命令及参数,无需手动在代码中插入大量echo语句,尤其适合长脚本或复杂逻辑(如多层循环、嵌套条件)。例如调试循环变量异常时,set -x会按执行顺序输出完整命令轨迹(如“+ count=5”“+ if [ 5 -lt 10 ]”),而echo需要开发者预判可能出错的位置,容易遗漏关键节点。不过对于需精准查看某个变量值的场景(如特定分支的变量状态),结合echo输出“DEBUG: current value=$var”仍是补充手段。
shellcheck和set命令分别适用于什么调试场景?
shellcheck是静态语法检查工具,适合在脚本运行前使用,能提前发现语法错误(如漏写空格“if[$var -eq 0]”、变量未加引号导致分词错误、条件判断符号用错“==”代替“=”等),相当于“代码体检”;set命令(如set -x/-e/-u)是动态调试工具,在脚本运行时追踪执行过程、控制错误退出,适合定位逻辑漏洞(如死循环、变量未更新)或运行时异常(如命令执行失败返回非0状态码)。 先通过shellcheck修复语法问题,再用set命令进行动态调试。
脚本中同时使用set -e和trap命令会冲突吗?
不会冲突,反而能配合提升调试效率。set -e(errexit选项)控制脚本在命令执行失败(返回非0状态码)时自动退出,避免错误累积;trap命令用于捕获特定信号(如EXIT退出信号、ERR错误信号)并执行自定义操作(如打印错误行号“trap ‘echo “Error at line $LINENO”‘ ERR”)。例如当set -e因命令失败触发退出时,trap会先执行错误提示逻辑,帮助开发者快速定位失败位置,两者作用互补,可同时使用。
调试包含敏感信息的脚本时,如何避免信息泄露?
需注意两点:一是用set +x临时关闭调试输出,在处理密码、API密钥等敏感信息的代码段前加“set +x”,结束后再用“set -x”恢复调试,避免敏感内容被打印到终端或日志;二是将调试日志重定向到权限严格的文件(如chmod 600的/tmp/debug_$(date +%F).log),限制访问范围。例如处理数据库密码时,可写成“set +x; db_pass=”xxx”; mysql -u root -p”$db_pass” …; set -x”,确保敏感信息仅在内存中处理,不被调试输出记录。