ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Shell基础与脚本实战:从命令行到健壮自动化

Shell基础与脚本实战:从命令行到健壮自动化

很多人第一次打开终端,黑底白字,光标一闪一闪,随手敲了个命令回车,屏幕上哗啦啦滚出一堆英文,然后本能地关掉窗口,心里默念"这玩意儿不适合我"。我特别理解这种感觉——命令行给人的第一印象确实不太友好,像一扇没有把手的门。但只要跨过最初那半小时的心理门槛,你会发现 shell 是少数几个"投入产出比高得离谱"的技能:它不挑行业,做开发、做运维、做数据、做测试、甚至只是日常整理文件,都能立刻用上;它也不需要你学一整门语言,掌握十几个命令加几套固定套路,就能把大量重复劳动压缩成一行命令。这篇内容我打算把 shell 基础从头到尾捋一遍,包括它到底是什么、变量和引号为什么这么反直觉、重定向和管道怎么理解、脚本怎么写才不至于把自己坑死。写给谁看?完全没碰过命令行的小白能顺着往下读,用过几年但一直靠搜索拼命令的老手,也能在中间几节找到一些"原来这里一直理解错了"的细节。

1. 先搞清楚:shell 到底是个什么角色

1.1 用"翻译官"这个类比建立第一层认知

我习惯把 shell 理解成一个翻译官。你在终端窗口里敲的那串东西,本质上是一种给人看的、语法松散的自然语言式指令,而操作系统内核只认系统调用这种"机器语言"。中间必须有个角色负责把你说的话翻译成内核能执行的动作,这个角色就是 shell。终端窗口只是"电话机"——它提供你和翻译官对话的通道,本身不做任何翻译工作;bash、zsh、dash 这些才是具体的"翻译官",性格不同、方言略有差异,但基本词汇是互通的。

这层区分很关键,因为它直接解释了很多新手困惑。为什么教程里写ls,你敲出来也正常?因为ls是一个独立的小程序,放在 PATH 目录里,任何 shell 都能调用它。为什么[[ ]]在某个环境里报错?因为那是 bash 特有的语法,换了个"翻译官"就听不懂了。为什么同一个脚本在你的机器上跑得欢,换台机器就崩?大概率是翻译官的版本或设置不一样。

所以学 shell 的第一课不是背命令,而是分清三个层次:终端负责显示和输入,shell负责解析和执行,外部命令是 shell 调用的一个个独立工具。绝大多数"命令"其实既不是终端的功能,也不是 shell 内置的语法,而是一个个躺在磁盘上的可执行文件。你敲type ls就能看到它到底属于哪一类——type这个内置命令是排查问题的第一把钥匙,后面我还会反复用到它。

1.2 学它能换来什么:三个真实场景

我不想吹"shell 是程序员的必修课"这种空话,直接说三个我自己反复受益的场景。

第一个是批量操作的降维打击。文件夹里有两千个后缀混乱的图片,要按修改日期重命名并归到不同子目录。图形界面里这是一下午的活,命令行里就是一行for循环加一个mv。这个能力没有替代品,因为你没法用鼠标"表达逻辑"。

第二个是环境的一致性。一个新环境拿到手,装依赖、改配置、拉代码、跑初始化,十几次点击和复制粘贴,每次都可能漏一步。写成脚本之后,别人跑和你跑的流程完全一致,出了问题也有据可查。这也是为什么 CI 流水线里到处是 shell 片段——它是最没有依赖的胶水。

第三个是排障的底层视角。服务起不来,日志只说"失败",图形化工具往往只能告诉你结果,命令行能告诉你过程。进程在不在、端口通不通、文件权限对不对、环境变量有没有传进去,四条命令查完,90% 的问题就定位了。

适合谁?做服务端开发的、做测试的、做数据的、管机器的,这是刚需;做前端、做客户端的,写构建脚本、批量处理素材也用得上;不写代码的人,学个文件整理和文本搜索也值回票价。反过来讲,如果你的工作完全不涉及重复操作和远程环境,那不学也没损失,别被焦虑推着走。

1.3 学习路线的取舍:别一上来就啃语法手册

我见过太多人卡在这一步:找一份几百页的 shell 教程,从"什么是操作系统"开始读,读到第三章遇到正则表达式就放弃了。shell 是一门实用优先的技能,正确路径是"用起来——遇到问题——补基础",不是"学完再做"。

我的建议是分三段走。第一段只学十个命令:cd、ls、cat、less、cp、mv、rm、grep、find、chmod,配合 Tab 补全和历史记录,这一阶段的目标是"能自己操作文件"。第二段学管道、重定向和变量,目标是"能把两三个命令串起来解决问题"。第三段才碰流程控制和函数,目标是"能写出一个自己愿意重复用的脚本"。每一段大约几个小时就能走过,真正花时间的是后面在真实任务里的反复使用。

顺带提一个心态问题:不要试图记住所有参数。我自己写 shell 写了这么多年,tar的解压参数每次还得查。真正需要记住的是思路——"这件事应该用什么工具组合完成",参数交给man和--help。把记忆负担放在思维模型上,才是可持续的。

2. 环境准备:十分钟把地基打对

2.1 术语对照表,先把概念对齐

在动手之前,用一张表把常见名词的位置关系钉死,后面看文档就不会晕。

名词它是什么类比你需要注意的点
终端 / Terminal输入输出的窗口程序电话机关闭窗口,前台进程可能被终止
shell解析命令的程序翻译官bash、zsh、dash 语法有差异
bash最常见的一种 shell某位具体的翻译官不同版本特性不同,老版本没有关联数组
命令 / Command内置函数或磁盘上的可执行文件词典里的词条靠type区分是内置还是外部
脚本 / Script存成文件的命令序列写好的台词稿需要执行权限和正确的首行声明
环境变量传给程序的键值配置翻译官的工作备忘子进程继承,父进程不受影响

表格里最后一行那个"子进程继承"是很多人踩坑的根源。你在脚本里export一个变量,脚本内部的所有子命令都能看到;但脚本执行完退出,你当前的 shell 里什么也没变。这就是为什么"我在脚本里改了 PATH,怎么出来还是老样子"——因为它改的是自己那份副本。

2.2 配置文件到底谁在什么时候加载

这是 shell 基础里最容易被含糊带过、又最容易出问题的一块。你往~/.bashrc里写了一堆别名,结果通过某些方式执行脚本时全部失效,就是没搞清加载时机。

bash 启动时按"是不是登录 shell""是不是交互式"两个维度分四种情况,加载的配置文件不同:

  • 交互式登录 shell(比如刚登入系统、或者用登录方式切换用户):依次读/etc/profile,然后从~/.bash_profile、~/.bash_login、~/.profile中读第一个存在的,读完就停。
  • 交互式非登录 shell(在图形界面里新开一个终端窗口通常属于这类):读/etc/bash.bashrc(部分发行版才有这个文件),再读~/.bashrc。
  • 非交互式 shell(执行脚本时):一般什么都不读,只会去看环境变量BASH_ENV指向的文件,没设置就跳过。
  • sh 兼容模式:读$ENV指向的文件。

这套规则带来的实际后果是:如果你只在~/.bash_profile里写了别名和函数,那么在图形界面新打开的终端里它们是不生效的。业界通行的做法是在~/.bash_profile里加一句[ -f ~/.bashrc ] && . ~/.bashrc,把两套入口合并到~/.bashrc一个文件里维护。这个技巧几乎所有"配置为什么偶尔不生效"的问题都能解决。

注意:脚本里不要依赖~/.bashrc里定义的东西。脚本执行时是非交互式 shell,不读这些文件,别名更是默认不展开。别名只服务于你在终端里手敲的场景,写进脚本就是定时炸弹。

2.3 交互式操作的五组快捷键,效率翻倍

环境弄好之后,先别急着写脚本,把交互操作练熟,日常收益最大。

第一组是Tab 补全。按一下补全,按两下列出所有候选。路径、命令名、甚至某些命令的参数都能补,这是减少打字错误最有效的手段。

第二组是历史检索。history看全部,!$取上一条命令的最后一个参数,!!重跑上一条,Ctrl+R反向增量搜索。Ctrl+R这个我每天用几十次,敲几个字母就能翻出几个月前用过的那条长命令。

第三组是行内编辑。Ctrl+A跳行首,Ctrl+E跳行尾,Ctrl+U删到行首,Ctrl+K删到行尾,Ctrl+W删一个词。改长命令中间某个参数时,比按几十下方向键舒服太多。

第四组是进程控制。Ctrl+C发中断信号,Ctrl+Z挂起到后台,jobs看后台任务,fg %1拿回前台,bg让它在后台继续跑。

第五组是清屏与纠错。Ctrl+L清屏(比clear快),Ctrl+D发 EOF 退出当前 shell。这两个动作每天几百次,值得形成肌肉记忆。

3. 变量与引号:shell 最反直觉的一块

3.1 三种引号的区别,值得单独背下来

如果说 shell 有一个"最该搞懂"的知识点,那就是引号。搞不清引号,你会遇到一堆"看起来完全一样但结果不同"的诡异现象。

先看定义。不加引号的字符串会经历分词(按空格切开)、通配符展开(*、?被文件名替换)、变量替换。双引号保留整体为一个参数,只做变量替换和命令替换,不做分词和通配符展开。单引号里的一切都是字面量,连$都不解释。

具体差别看这段:

name="hello world" printf '[%s]\n' $name # 输出两行:[hello] 和 [world] printf '[%s]\n' "$name" # 输出一行:[hello world] printf '[%s]\n' '$name' # 输出一行:[$name]

第一种写法里,变量展开后产生的空格被当作参数分隔符,一个变量变成了两个参数。这就是为什么脚本里有一条铁律:变量引用永远加双引号。我见过的脚本 bug 里,因为漏写双引号导致路径含空格时崩掉的,占了相当大的比例。文件名里带空格在桌面环境里太常见了,"我的文档"这种目录名就是最好的例子。

提示:需要输出字面量$时,用单引号最省事;单引号内部没法再嵌套单引号,遇到这种情况用'\''这种拼接写法,或者改用双引号加反斜杠转义。

3.2 参数展开:让变量"有默认值"

光会$var是不够的。真实脚本里,变量可能为空、可能未定义,你需要给它兜底。bash 提供了一整套参数展开语法,我把最常用的四个整理成表:

写法含义典型用法
${var:-默认}var 为空或未定义时用默认值,var 本身不变取环境变量的兜底值
${var:=默认}同上,但会把默认值赋值给 var需要后续复用该值时
${var:?错误信息}var 为空时打印错误并退出必填参数校验
${var:+替代值}var 非空时才用替代值条件拼接

我最常用的是第二个和第三个。写脚本时开头放几行参数校验,比后面出错再排查舒服得多:

: "${OUTPUT_DIR:=./output}" : "${THREADS:?必须通过环境变量 THREADS 指定并发数}"

第一行如果没传就用默认目录,第二行如果没传直接报错退出,错误信息清楚,使用者一看就知道缺什么。这比后面在某个深层逻辑里报一个莫名其妙的"文件不存在"友好一百倍。

3.3 命令替换、算术与数组

命令替换是把一个命令的输出当作字符串使用,两种写法:老式的反引号`cmd`和现代的$(cmd)。我强烈建议只用后者,因为前者不能嵌套,而且和单引号容易混淆。today=$(date +%F)这种写法你应该很熟悉,注意赋值号两边不能有空格,这是 shell 的硬规定,a = 1会被理解成"执行名为 a 的命令,参数是 = 和 1"。

算术运算有两种。$(( ))用于求值,(( ))用于条件判断。要特别注意 shell 默认只做整数运算,$((1/3))结果是 0 不是 0.33。需要小数就得请出awk或者bc:

echo "scale=2; 10/3" | bc awk 'BEGIN{printf "%.2f\n", 10/3}'

数组分索引数组和关联数组。索引数组arr=(a b c),取全部元素用"${arr[@]}"——这里的双引号和[@]组合是标准写法,能保证每个元素独立成参;写成"${arr[*]}"会被拼成一个字符串。关联数组需要先declare -A map声明,然后像字典一样用map[key]=value。关联数组在老版本 bash 里不支持,这就是前面提到的"版本差异"会咬人的地方,脚本里用之前最好确认一下目标环境的版本。

4. 流程控制:让脚本开始有逻辑

4.1 if 的三种判断写法,别混着用

shell 里做条件判断有四种括号,新手最容易在这里迷失。我按推荐程度排个序。

[[ ]]是 bash 的增强版条件表达式,首选。它内部不做分词和通配符展开,所以变量不加引号通常也安全,支持&&、||、=~正则匹配,写法自然。缺点是 POSIX 标准里没有,只有 bash、zsh、ksh 这类支持。

[ ]是 POSIX 标准的 test 命令,需要兼容 sh 时用。它本质上是一个外部命令(虽然多数 shell 内置了),所以对空格极其敏感——[ "$a" = "$b" ]里每个空格都不能少,$a必须加引号,否则变量为空时直接报语法错误。

(( ))专用于整数比较和算术,写if (( a > b ))比[ "$a" -gt "$b" ]直观得多,而且变量名前面不用加$。

test是[ ]的另一种写法,功能完全一样,没什么理由主动用它。

字符串和数字的比较符号也不一样,这是新手高频错误:字符串用=、!=、-z(空)、-n(非空);数字用-eq、-ne、-gt、-lt、-ge、-le。写成[ "$a" == "$b" ]在 bash 里能用但在 sh 里不行,写成[ "$a" -eq "$b" ]而 a 是字符串就会报错。我的习惯是:数字比较一律用(( )),字符串比较一律用[[ ]],两套泾渭分明,不容易错。

文件测试也是高频项:-f普通文件、-d目录、-e存在、-r可读、-w可写、-x可执行、-s非空。这几个配合if能做大量的前置校验。

4.2 三种循环的适用场景

for是遍历型循环,有两种写法。C 语言风格for ((i=0; i<10; i++))适合计数;for item in "${list[@]}"适合遍历列表或数组。要注意默认的for f in *在文件名含空格时会出问题,虽然 shell 通配符展开本身是安全的(每个文件名独立成参),但如果你在循环体里又做了$(ls)这种操作,就会重新引入分词风险——永远别用ls的输出做循环,这是 shell 脚本的经典反模式。

while适合"条件满足就一直做",最典型的用法是逐行读文件:

while IFS= read -r line; do printf '处理: %s\n' "$line" done < input.txt

这段代码有三个细节值得说。IFS=清空内部字段分隔符,防止行首尾空格被吃掉;-r禁止反斜杠转义,防止路径里的\被吞;重定向写在done后面而不是| while,是为了避免子 shell 问题——后面第 5 节会专门讲这个坑。

case适合多分支匹配,比一长串if-elif清爽得多,还支持通配符:

case "$1" in start|run) do_start ;; stop) do_stop ;; *.log) echo "日志文件参数" ;; *) echo "未知参数: $1" >&2; exit 2 ;; esac

注意最后那个*)分支一定要写,并且一定要exit非零码,否则参数传错时脚本会"默默成功",这是运维脚本里最危险的一类问题。

4.3 函数、局部变量与"返回值"的真相

shell 函数语法简单:name() { ... }。但有两个和主流语言完全不同的地方,必须提前说清。

第一,函数没有参数列表声明。调用myfunc a b c之后,函数内部用$1、$2、$3取参数,用$#取个数,用"$@"取全部。这带来一个隐患:函数内会覆盖外层的$1、$2,所以脚本主流程如果依赖位置参数,调用函数后要小心。

第二,return返回的不是值,是退出状态码,只能 0 到 255。想要真正"返回值",得用echo输出然后命令替换接住,或者用全局变量。这是个设计上的历史包袱,习惯就好:

get_size() { local f="$1" [[ -f "$f" ]] || { echo 0; return 1; } wc -c < "$f" | tr -d ' ' } size=$(get_size ./data.csv)

local关键字一定要加,用来声明函数内局部变量。不加的话所有变量默认全局,函数之间互相污染,脚本一长就没法维护了。我见过一个脚本里三个函数都用了i做循环变量,结果互相踩,排查了半小时——加上local一行搞定。

另外注意return之后的数字,0 表示成功,非 0 表示失败,这个约定和 C 语言相反,也是 shell 脚本里最容易写反的地方。if command; then判断的正是退出码为 0。

5. 重定向与管道:shell 的灵魂所在

5.1 文件描述符与重定向完整地图

Linux 下每个进程默认开三个文件描述符:0 标准输入、1 标准输出、2 标准错误。重定向就是把这几个"水管"接到别的地方去。

写法效果
cmd > file标准输出覆盖写入文件
cmd >> file标准输出追加
cmd 2> file标准错误覆盖写入
cmd &> file标准输出和错误都写入(bash 写法)
cmd > file 2>&1同上,POSIX 写法
cmd < file从文件读标准输入
cmd 2>&1 > file顺序陷阱,错误仍打到屏幕

最后一行是经典面试题,也是真实会踩的坑。重定向是从左到右依次生效的:2>&1先把"错误"指向"当前的标准输出"(此时还是屏幕),然后> file才把标准输出改到文件。所以错误依然在屏幕上。要合并到文件里,必须> file 2>&1,先定好输出目标,再让错误跟着走。理解这个顺序,比背结论重要。

还有exec的用法值得单独提一句。exec > "$logfile" 2>&1会把当前 shell 自身的输出重定向掉,之后脚本里所有命令的输出都进日志,不用每条都写重定向。这个技巧在写"整个脚本输出都落盘"的场景里非常好用。

Here Document用来在脚本里嵌入多行文本:cmd <<EOF ... EOF。默认会做变量替换,写成<<'EOF'(加引号)就关闭替换,适合嵌入代码模板。Here String是<<<,把字符串当标准输入,grep foo <<< "$text"比echo "$text" | grep foo少一个进程。这两个语法用熟了,脚本可读性会上一个台阶。

5.2 管道、xargs 与那个著名的子 shell 坑

管道|把前一个命令的标准输出接到后一个命令的标准输入。它的价值在于"组合"——每个工具只做一件事,串起来就能完成复杂任务。这也是 shell 哲学的核心:不追求一个大而全的工具,追求一堆能拼装的小工具。

但管道有个必须知道的副作用:管道中的每一段都在子 shell 里执行。子 shell 是当前 shell 的副本,里面的变量修改不会传回父 shell。看这段:

count=0 cat data.txt | while read -r line; do ((count++)) done echo "$count" # 输出 0,不是行数

这段代码的结果永远是 0,因为while在子 shell 里跑,count++改的是副本。修复方式有三种:把重定向写到done后面(done < data.txt)、用进程替换(done < <(cat data.txt))、或者改用mapfile/awk。我首选第一种,最清晰。

xargs是把标准输入转成命令行参数的工具,配合find用:

find . -name '*.tmp' -print0 | xargs -0 rm -f

-print0和-0这对组合是为了处理含空格和换行的文件名,用\0而不是换行做分隔符。不加的话遇到"我的 文件.tmp"就会被拆成两个参数,删错东西。这种细节不注意,某天就会在真实数据上出事。

5.3 grep / sed / awk 的最小可用集

这三个工具合称文本处理三剑客,其实普通人 20% 的用法就能覆盖 80% 的需求,不必一上来啃完整手册。

grep负责"找"。-i忽略大小写,-v反向匹配,-n显示行号,-r递归目录,-c只计数,-E用扩展正则,-o只输出匹配部分。grep -rn "关键词" .大概是我职业生涯敲得最多的一条命令。

sed负责"改"。核心就一个替换语法s/旧/新/标志,标志g表示全局,不加只替换每行第一个。sed -i 's/foo/bar/g' file.txt原地修改(注意加上备份后缀更安全:sed -i.bak)。-n加p可以只打印匹配行,-e可以串联多个表达式。sed 的坑在于正则方言和替换内容里的特殊字符,路径里的/会冲突,通常改用|或#当分隔符:sed 's|/old/path|/new/path|g'。

awk负责"按列处理"。它的模型是"逐行读入,按分隔符切字段",$1、$2是第几列,$0是整行,NR是行号,NF是字段数,-F指定分隔符。

awk -F, 'NR>1 && $3 > 100 {sum+=$3; n++} END{printf "平均: %.2f\n", sum/n}' data.csv

这一行就完成了"跳过表头、筛选第三列大于 100 的行、求平均"。同样的逻辑用其他语言写要七八行。awk 的价值在于它天生就是为"表格型文本"设计的,日志分析、CSV 统计这类活儿用它最省事。

除了三剑客,还有几个小工具值得知道:cut按字符或分隔符取列、sort排序(-n数字序、-k指定列、-u去重)、uniq -c统计相邻重复、tr字符替换、wc -l计数、tee分流到文件和屏幕。这些组合起来的表达力非常强,比如统计访问量 Top 10:

awk '{print $1}' access.log | sort | uniq -c | sort -rn | head -10

一行命令,五个工具,每个都只做一件事——这就是 shell 组合思想的完整体现。

6. 写出能上生产的脚本:健壮性比语法重要

6.1 set -euo pipefail 到底做了什么

我见过太多"能跑就行"的脚本,出问题的时候一片静默,查半天才发现是某个中间命令失败了但脚本继续往下跑。脚本的专业度,很大程度上体现在"出错时是否及时停下并说清楚"。

set -euo pipefail这行应该出现在每个正式脚本的第二行(第一行是 shebang)。拆开看:

  • -e:任何命令返回非零就退出。听起来很好,但有陷阱——它在if、while、&&、||语境里不生效,因为那些地方本来就允许失败。另外((i++))当 i 为 0 时返回码是 1,会误触发退出,需要写成((i++)) || true或者前置i=0后用i=$((i+1))。
  • -u:使用未定义变量时报错退出。这个太有用了,能防住大量拼写错误。要用"可能为空"的变量时,改成${var:-}显式声明默认空值。
  • -o pipefail:管道的退出码取最右边非零的那个。默认情况下管道只看最后一个命令,curl ... | head里前面的网络错误会被head的成功掩盖。加上这个开关,中间任何一段失败都能被发现。

我个人的经验是:-e和-o pipefail用来兜住"意外失败",-u用来兜住"手滑拼错"。三个一起上,脚本的可靠性立刻不一样。如果某个命令允许失败,就显式写成cmd || true或放进if里,让意图明确。

6.2 trap:临时文件与信号处理

脚本里创建临时文件、开锁、改配置,最怕中途被打断留下垃圾。trap就是用来注册"退出时一定要做的事":

#!/usr/bin/env bash set -euo pipefail tmp=$(mktemp) lockfile="/tmp/myscript.lock" cleanup() { rm -f "$tmp" "$lockfile" } trap cleanup EXIT INT TERM exec 9>"$lockfile" flock -n 9 || { echo "已有实例在运行" >&2; exit 1; }

这段做了三件事:用mktemp生成唯一临时文件名(绝不要用固定的/tmp/tmp.txt,多个实例会互相覆盖)、用trap ... EXIT保证无论正常退出还是异常退出都清理、用flock加文件锁防止同一脚本被并发跑两遍。

trap能捕获的信号里,EXIT最常用(shell 退出时触发,包括 Ctrl+C 后退出),INT对应 Ctrl+C,TERM对应kill。捕获到之后可以选择清理再退出,也可以忽略某些信号。定时任务里尤其要注意加锁——两个实例同时跑同一份数据处理,结果往往是数据错乱,比不跑还糟。

6.3 参数解析:别让脚本只能靠记忆使用

用命令行工具最烦的是"参数全靠记"。给自己的脚本加一个usage,是专业度的直接体现。参数少的时候手写while就够了:

usage() { cat <<'EOF' 用法: backup.sh -s 源目录 -d 目标目录 [-k 保留份数] -s 需要备份的源目录 -d 备份文件存放目录 -k 保留最近几份,默认 5 EOF exit "${1:-0}" } while getopts ":s:d:k:h" opt; do case "$opt" in s) src="$OPTARG" ;; d) dst="$OPTARG" ;; k) keep="$OPTARG" ;; h) usage 0 ;; :) echo "选项 -$OPTARG 需要参数值" >&2; usage 2 ;; \?) echo "未知选项 -$OPTARG" >&2; usage 2 ;; esac done [[ -n "${src:-}" && -n "${dst:-}" ]] || usage 2

getopts的几个细节:选项字符串开头加冒号表示"自己处理错误",这样:)和\?)分支才能拿到自定义错误信息;OPTARG是当前选项的值;OPTIND记录解析位置,需要重置时手动设回 1。这套写法不算优雅,但足够稳,比一堆手工if [ "$1" = "-s" ]可靠得多。

6.4 调试三板斧:bash -x、PS4、shellcheck

脚本不按预期跑的时候,别靠猜。第一招bash -x script.sh,它会打印每条命令执行前的展开结果,一眼就能看到变量到底展开成了什么。输出太杂可以配合PS4加上行号和函数名:

PS4='+[${LINENO}:${FUNCNAME[0]:-main}] '

这样每行前面都带行号,定位很快。第二招局部调试:在脚本里插set -x和set +x包住可疑的那几行,比全量输出清爽。第三招静态检查,有个社区工具叫 shellcheck,能提前发现大量引用未加引号、变量未定义、不可移植语法之类的问题。我每次写完稍长的脚本都会过一遍,它指出的问题里大概有一半是我确实会犯的。

再补一个习惯:写脚本时用#!/usr/bin/env bash而不是#!/bin/bash。前者会走 PATH 找 bash,可移植性更好;后者在 bash 装在非标准路径的环境里会直接失败。

7. 常见问题与排查技巧实录

7.1 高频报错速查表

下面这张表是我这几年被问最多、自己也踩过的问题,按出现频率排序。

现象根本原因解决方式
command not found命令不在 PATH,或拼写错误type、which确认;检查 PATH 拼接
Permission denied文件没有执行权限chmod +x script.sh
bad interpreter: No such fileshebang 写错,或文件是 Windows 换行改首行;dos2unix转换换行符
syntax error near unexpected token引号、括号不配对,或中文字符混入检查配对;确认没有全角符号
变量赋值后循环里取不到管道导致子 shell把重定向移到done后
文件名含空格时出错变量引用缺双引号全面加"$var",用-print0
unbound variable开了-u但变量可能为空改用${var:-}
脚本在终端能跑,定时任务不行环境不同,PATH 不完整用绝对路径,脚本内显式设置 PATH
argument list too long通配符展开参数过多改用find ... -exec或xargs

倒数第二条值得展开说。定时任务执行时的环境极其干净,PATH 可能只有/usr/bin:/bin,你在交互式 shell 里能直接用的命令,在定时任务里就找不到。我的习惯是脚本开头显式写一行export PATH=/usr/local/bin:/usr/bin:/bin:$PATH,省去大量"为什么手动跑可以、定时跑不行"的排查时间。

7.2 几个只有踩过才知道的细节

关于删除。写rm -rf的脚本,变量拼错一次就可能是不可逆的事故。我现在的固定写法是先做非空校验:[[ -n "$dir" && "$dir" != "/" ]] || exit 1,再从rm -rf "$dir"换成一个更克制的操作。这类"防御性编程"在 shell 里不是过度设计,而是必需品,因为 shell 给你的保护本来就少。

关于通配符与隐藏文件。*默认不匹配以点开头的文件,需要.*;但.*又会匹配到.和..这两个特殊目录,复制删除时可能出事。安全写法是shopt -s dotglob之后仍用*,或者干脆用find精确控制。

关于cd失败。cd /some/path && rm -rf *这种写法里,如果cd失败,&&会阻止后面的命令,这是安全习惯。但更稳妥的是在脚本开头用cd "$(dirname "$0")"切到脚本所在目录,让所有相对路径都有确定基准,避免"从哪里调用脚本"影响结果。

关于 locale 与排序。sort的排序结果受LC_ALL影响,数字和大小写顺序可能和你想的不一样。做数据处理时在脚本里临时设LC_ALL=C,能得到稳定可预期的字节序结果,这在跨环境比对时特别重要。

关于输出流向。正常信息打到标准输出,错误信息打到标准错误,这个约定要守住。echo "处理完成"是输出,echo "文件不存在" >&2是错误。守住这个边界,脚本才能被别的脚本安全地调用和组合——你把错误混进标准输出,下游的管道就会收到脏数据。

关于可读性。shell 是少数"写完三天自己都看不懂"的语言。我的经验是:超过 50 行的脚本就加函数拆分,每个函数做一件事;变量名写全拼不缩写;关键逻辑加一行注释说明"为什么这么做"而不是"这行做了什么"。这些看似拖慢速度的习惯,在三个月后回来改脚本时会加倍回报给你。

我自己在实际操作中的体会是,shell 这东西的瓶颈从来不在语法,而在"对系统的理解"。同样一个需求,有人写五行的管道,有人写五十行的循环,差别不在谁背的命令多,而在谁更清楚每个工具到底在做什么、数据在每一步变成了什么形状。所以别急着收集"一百个实用命令",找三五个自己每天都要做的重复劳动,用脚本把它们干掉,遇到坑就查、就试、就记录,三四轮下来,基础自然就扎实了。

返回列表