如果你已经跟着这份Bash学习系列走到第3章“Basic Shell Features”,大概率已经熟悉了变量、通配符、引号这些基础语法。但我要说,第7节“Executing Commands”才是真正把shell和其他编程语言区分开来的分水岭。这一节表面上在讲“怎么运行命令”,实际上讲的是:当你按下回车键之后,shell到底对你的输入做了什么、命令是怎么被找到的、进程是怎么被拉起来的、环境是怎么传下去的。
搞懂这些,你才算真正开始“理解”命令行,而不只是“会用”命令行。后面排查command not found、写复杂的管道、调试诡异的脚本行为,靠的都是这一节打底。
这篇文章我会把这节内容拆开揉碎,结合我实际使用中的经验和踩过的坑,把这套命令执行的完整机制讲透。不管你是刚接触Linux的学生,还是工作中要和构建脚本、自动化部署打交道的开发者,这篇都值得收藏读三遍。
1. Executing Commands到底在讲什么:一条命令的完整生命周期
很多人学了几个月的Bash,问“Shell是怎么执行命令的”,只能答出“输入命令然后回车”。实际上从你敲下回车到命令真正运行,中间隔着一整套完整机制,这一节就是把这套机制从头到尾讲清楚。
一次命令执行,shell内部至少经历了这几个阶段:读取输入、解析与分词、展开(expansion)、重定向处理、命令定位、执行。每一环都有单独的规则和坑,这里我把它们拆开来看。
1.1 Bash拿到一行输入之后发生的六件事
先给一个整体视角。你在终端里敲下:
ls -l /tmp > output.txt然后按下回车。这一瞬间Bash做的不是“把字符串传给系统”那么无脑,它内部经历了这些步骤:
读取并分词(tokenization):Bash把整行输入按照元字符切分成一个个单词。这里的元字符包括空格、Tab、换行,以及
|、&、;、(、)、<、>这些特殊符号。所以ls -l /tmp > output.txt会被拆成ls、-l、/tmp、>、output.txt这几个token。解析命令结构(parsing):Bash依据语法规则判断这些token之间的关系。比如
>出现在两个命令参数之间,那它就是一个重定向操作符,而不是文件名;|表示管道;&&和||表示条件执行。这里如果语法不对,直接抛错,后面的步骤根本不会发生。展开(expansion):这一步是最容易出问题的。Bash会按照固定顺序做花括号展开
{}、波浪号展开~、参数展开$VAR、命令替换$(cmd)、算术展开$((expr))、分词、通配符展开*和?、引号去除。顺序极其重要,比如通配符展开发生在参数展开之后,所以$VAR/*能正常匹配文件,而反过来就会把*当字面量。重定向准备:Bash处理
<、>、>>这些符号,打开对应的文件,把文件描述符准备好,等待命令进程继承。命令定位:判断要执行的命令是内建命令、函数、别名还是外部可执行文件。这一步的完整机制是这节的核心,下面专门讲。
执行:通过
fork + exec或者直接调内建逻辑,把命令跑起来,等它结束,拿到退出码。
这个流程不是我编的,Bash手册里就是这么排序的。为什么理解这个顺序很重要?因为很多脚本bug就是“我以为先展开再解析,结果它是先解析再展开”造成的。比如echo $((1+2)),如果先展开$((1+2))为3再分词,那echo收到的就是3;如果先分词,语法解析阶段就会卡住。Bash选择在解析阶段区分“算术表达式”这个token,再在展开阶段计算,这就保证了结果正确。
1.2 这一节在整章里的位置:从语法到机制的过渡
如果回头翻Bash的文档结构,会发现“Basic Shell Features”这一章是从Shell语法(Shell Syntax)讲起的,包括引号、注释、转义这些。Executing Commands这一节恰好是转折点:前半章告诉你“命令长什么样”,这一节告诉你“命令怎么真正跑起来”。
后面紧接着的是“Shell Functions”“Shell Parameters”“Shell Expansions”这些主题,全部建立在命令执行的机制之上。函数本质上是给一串命令起个名字,参数展开发生在命令执行的第3步,通配符展开也是在这个阶段。所以这一节如果没有吃透,后面的内容学起来全是空中楼阁。
我见过不少朋友学Bash,变量、循环、判断写得飞起,一遇到“为什么这个命令找不到”“为什么这个管道行为不对”就懵。根子就在命令执行机制这块缺课了。所以别觉得这一节枯燥,它是整本Bash学习里性价比最高的一节。
2. 命令查找机制:PATH、hash表、内建命令与外部命令
这一节里最实用、也最容易踩坑的就是命令查找机制。搞清楚“Bash怎么知道你要执行的是哪个程序”,你就能解决至少一半的shell报错问题。
2.1 PATH到底是怎么工作的:一个被讲烂但没被讲透的概念
PATH是环境变量,里面存了一堆目录,用冒号分隔。当你在Bash里输入一个不带路径的命令名时,Bash会按顺序去这些目录里找同名可执行文件。找到就执行,全部找不到就报command not found。
这个“按顺序”非常关键。如果你的PATH是/usr/local/bin:/usr/bin:/bin,那Bash会先去/usr/local/bin找,找不到再去/usr/bin,最后去/bin。所以如果两个目录里有同名程序,永远是你PATH里靠前的那个胜出。
我在实际项目里就遇到过这种问题。服务器上同时装了系统自带的Python 3.6(在/usr/bin/python3)和手动编译的Python 3.11(在/usr/local/bin/python3),因为/usr/local/bin在PATH里靠前,用户执行python3时永远用的是3.11。当时有个部署脚本以为自己在跑3.6,结果调用了3.11才有的API,行为完全错乱。排查半天才发现是PATH顺序问题。
有个很实用的排查技巧:用type -a可以列出某个命令会被解析成哪些路径,按顺序展示。比如:
$ type -a python3 python3 is /usr/local/bin/python3 python3 is /usr/bin/python3这比直接which python3看到的更全面,因为它把Bash内部能找到的所有定义都列出来了,而which只看PATH。
提醒:
which这个命令只看PATH,而且它是个外部命令,不是Bash内建的。更可靠的做法是用Bash内建的type、command -v或者hash,这些工具理解Bash的完整查找规则,不会因为别名、函数、hash缓存而给出误导性结果。
2.2 hash缓存:为什么“改了PATH还是找不到”或者“指向旧版”
每次执行外部命令都去PATH里全盘扫描一遍太慢了。Bash做了个优化:用一张hash表记录“命令名 → 完整路径”的映射。第一次执行某个命令时,Bash去PATH里找到它,然后把路径存进hash表,下次再用就直接从hash表里取,跳过查找过程。
这个优化平时无感,但在某些场景会坑人。典型场景:你用包管理器刚装了一个新程序,或者手动把某个程序装到了PATH里的另一个目录,然后执行命令,发现还是在跑旧版本。因为Bash的hash表里已经缓存了旧路径。
有两个解法:
hash -r # 清空整个hash缓存 hash -d 命令名 # 只删除某一条缓存我习惯在安装完软件之后顺手执行hash -r,避免这种“它是新装的为什么还在跑旧的”的诡异问题。尤其是在自动化脚本里,如果脚本过程中可能安装了新软件并要立即调用它,记得先hash -r。
你还能用hash命令直接查看当前的缓存内容,长这样:
$ hash hits command 1 /usr/bin/ls 3 /usr/bin/git左边的hits是缓存命中次数,命中多说明这个命令用得很频繁。
2.3 内建命令 vs 外部命令:同样是“命令”,命运完全不同
Bash对命令类型的判断优先级是严格的:别名 → 函数 → 内建命令 → 外部可执行文件。别名和函数排在前面,这意味着你可以“覆盖”一个外部命令的行为,比如给ls起个别名加上颜色参数。但这也意味着,如果你定义了一个和外部命令同名的函数,外部命令就完全被遮蔽了。
内建命令(builtin)是Bash自带的功能,比如cd、echo、export、read、test,它们不需要启动新的进程,直接在shell进程内部执行。这就是为什么cd没法被写成外部程序——外部程序改的是自己的当前目录,不可能影响调起它的shell进程的工作目录。而ls这种外部命令则必须fork出一个子进程,等它跑完再把退出码交回来。
如何判断一个命令是内建还是外部?用type:
$ type cd cd is a shell builtin $ type ls ls is hashed (/usr/bin/ls)如果是函数,type会显示函数定义;如果是别名,会显示别名展开结果。这个命令是排查“为什么行为跟预期不一样”的第一利器。
2.4 command、builtin、enable:三条绕开“遮蔽”的逃生通道
既然别名、函数会遮蔽内建命令和外部命令,那真到了需要绕过它们的时候怎么办?Bash提供了三个工具:
command 命令名:绕过别名和函数查找,直接找内建命令或外部命令。在函数里调用同名外部命令时极有用。比如你写了个函数叫ls,内部又想调用真正的ls,就写command ls -l。builtin 命令名:只执行内建版本,完全跳过函数和别名。比如你定义了一个叫cd的函数,里面想用真正的cd,就写builtin cd "$dir"。enable -n 命令名:关闭某个内建命令,让同名外部命令有机会被找到。这项用到得少,一般不建议随便关。
我在写稍微复杂点的shell函数时,几乎一定会用到command来调用外部命令,避免用户环境里可能存在的同名别名干扰。比如在函数里写command grep "pattern" file,就能保证用户就算有grep --color=always的别名,也不会污染函数逻辑。
3. 命令执行方式:前台、后台、管道、重定向与逻辑控制符
Bash最强大的地方之一,就是能用简洁的符号把多个命令组合成复杂的工作流。这一节是重头戏,组合方式就是在执行阶段落地的。
3.1 前台执行与后台执行:&、wait 与 job control
默认情况下,命令在前台(foreground)执行,shell会阻塞等待它结束。你输入的ls、grep、python都是这样。把&放在命令末尾,命令就进入后台(background)执行,shell立即返回提示符,不等待它跑完。
sleep 5 & echo "马上就能执行,不用等5秒"后台执行的命令会占用终端吗?分情况。如果命令的标准输出和标准错误都还连着终端,那它的输出照样会打到屏幕上,只是不阻塞你输入新命令。这就是为什么用&跑长任务时,通常会顺手做重定向:python long_task.py > log.txt 2>&1 &,把日志写进文件,避免终端被刷屏。
管理后台任务有三件套:jobs查看当前shell的后台任务列表,fg %编号把后台任务调回前台,bg %编号让暂停的后台任务继续运行。wait命令也很有用,它可以让shell停下来等所有后台任务跑完再继续。在脚本里,如果你后台起了多个任务,最后想统一等它们结束,就写:
task1 & task2 & wait echo "两个任务都跑完了"注意wait等待的是当前shell的“子进程”,不是随便什么进程。调wait 12345可以等指定PID的子进程。
实操心得:脚本里用
&起后台任务,一定要考虑终端的tty归属问题。如果脚本本身是在非交互式环境中跑的,后台任务的stdin可能是空的,某些依赖输入的程序会直接报错。这种情况下给足重定向是个好习惯。
3.2 管道:不只是“连接命令”,而是进程间通信
管道(pipe)用|把左边命令的stdout接到右边命令的stdin。注意,两边命令是同时(并发)启动的,不是等左边全跑完再喂给右边。这点出乎很多新手的意料。比如:
cat huge_file.txt | grep "error"你以为cat读完整个文件才启动grep?不是的。cat一边读,grep一边处理。这带来的直接好处是内存占用小、速度快,尤其适合处理大文件。也正因如此,管道里的命令会互相影响——如果grep提前退出,cat会收到SIGPIPE信号直接被终止。
每个管道命令实际运行在一个子shell里,而且Bash会为管道中的每个命令各启动一个子shell。这意味着你在管道里修改变量,在管道外面是看不到变化的。这是新手最容易犯的错:
cat data.txt | while read line; do count=$((count + 1)); done echo $count # 输出0,还是初始值子shell里的修改带不出来。后面我会讲怎么绕开这个限制。
3.3 重定向:标准输入、标准输出、标准错误的细节操作
三个标准文件描述符要刻在脑子里:0是stdin,1是stdout,2是stderr。重定向的本质就是“把某个fd指到某个文件去”。
常见的不废话了,挑几个容易出错的细节讲。
2>&1和2>1完全不是一回事。2>&1是把stderr重定向到“stdout当前指向的位置”,2>1则是创建一个名为1的文件并把stderr写进去。没有&,1会被当作文件名,这是最经典的笔误。
顺序敏感:> file 2>&1和2>&1 > file结果不同。第一条是“先把stdout指向file,再把stderr指向stdout(也就是同一个file)”,两条都进文件。第二条是“先把stderr指向当前stdout(终端),再把stdout指向file”,结果是stdout进文件、stderr还在终端。逻辑没错,顺序错了,效果天差地别。
heredoc和here-string也是重定向的变种,不过那是输入重定向的进阶玩法。遇到需要给命令喂多行输入的场景,<<EOF会比echo拼接爽得多。
3.4;、&&、||:按退出码决定流程
这三个分隔符是shell脚本控制流的基础。;只是顺序执行:不管前一条成不成功,下一条都会跑。&&是“前一条成功才执行后一条”,||是“前一条失败才执行后一条”。
false || echo "上一条失败了,执行这句" true && echo "上一条成功了,执行这句"退出码0表示成功,非0表示失败。这个约定是Unix文化的一部分,理解它不仅对掌握&&和||有用,对理解整个shell脚本的if判断也至关重要。
有个使用细节:cmd1 && cmd2 || cmd3这种写法,看起来是“cmd1成功则cmd2,否则cmd3”,但实际上如果cmd2执行失败,cmd3也会被触发。因为cmd1 && cmd2整体退出码为失败时,||会接管。要避免这个连锁反应,用明确的if语句更稳妥。
if cmd1; then cmd2 else cmd3 fi这种写法不会出现“cmd2失败导致cmd3误触发”的意外。等我写脚本时,逻辑稍微复杂一律用if,绝不硬凑&&和||链。
3.5 curl ... | bash:为什么这种执行方式让人又爱又怕
热搜词里有个典型的curl -fSSL https://.../install.sh | bash,这种下载脚本直接执行的模式在安装各类工具时极其常见。它的原理本质上就是管道:curl把远程脚本内容打到stdout,bash把stdin读进来当脚本执行。优点是方便,缺点是危险。
这条管道里有两个隐患要心里有数。第一,curl出错不一定能被|后的bash感知。所以我更推荐先下载到文件、检查内容、再执行,三步分开。第二,管道里的bash从stdin读脚本时,它在子shell里执行,有一些行为和非交互式加脚本文件路径的方式不同。比如某些需要读取stdin的交互式脚本逻辑,在curl | bash模式下会被脚本内容占用stdin而表现异常。
如果只是想临时用一个远程脚本,我一般先下载、快速扫一眼内容、确认无害再执行:
curl -fSSL https://example.com/install.sh -o /tmp/install.sh less /tmp/install.sh bash /tmp/install.sh多花半分钟,少踩一个坑。
4. 扩大执行边界:命令替换、子shell与外部命令的协作
这一节要拓展命令执行的边界:shell怎么把一个命令的输出变成另一个命令的参数?怎么在子shell里跑一段命令?怎么处理那些“看起来是命令执行、其实不是那么回事”的场景?
4.1 命令替换:$() 和反引号的选择
命令替换(command substitution)就是“把命令的输出当作文本嵌入到另一个命令中”。
today=$(date +%Y-%m-%d) echo "今天是 $today"$(...)里的命令会在子shell里执行,它的stdout会被捕获,末尾的换行会被剥离,然后嵌入到当前位置。$()支持嵌套,这是老式反引号做不到的:
now=$(date -d "$(date +%Y-%m-01) + 1 month" +%Y-%m)反引号cmd是历史遗留写法,嵌套时需要转义,可读性差,我建议一律用$()。
命令替换发生在展开阶段,也就是命令真正执行之前。这意味着$(cmd)里命令的输出会先被做分词和通配符处理吗?答案是:命令替换的结果会参与后续的分词和通配符展开。这有个很常见的坑:
var=$(cat file) # 假如file内容是 "a b *" echo $var # 多个空格被压缩,*被展开成当前目录文件如果不想让输出被分词和通配符展开影响,记得给变量加引号:echo "$var"。这条经验极其重要,很多脚本输出异常(空格被吞、*变成文件名列表)都是吃了这个亏。
4.2 子shell与圆括号:什么时候需要“开个小号”
用圆括号把一组命令包起来,这组命令会放到子shell里执行。子shell是当前shell的一个子进程,环境变量、工作目录的修改都不会影响父shell。
(cd /tmp && echo "进入了/tmp" && pwd) pwd # 还是在原来的目录这在脚本里很方便:你想在某个目录下干一串事,又不想污染脚本主流程的工作目录,包个括号就行。同理,临时设置环境变量、临时遮蔽变量,也可以放在子shell里做。
注意cd在子shell里改了目录不影响父shell,这条特性在命令行里经常被用来做“定向操作”。比如在某个项目目录里执行git命令,把整个操作包起来比较干净。
(cd /path/to/project && git pull && git status)4.3 exec:替换当前shell进程的那把“狠刀”
exec命令不是“执行完再回来”,而是“用新程序替换当前shell进程”。如果是在交互式shell里执行exec ls,整个shell会被ls替代,等ls结束,你的终端会话就结束了(或者挂起)。
在脚本里,exec的典型用途是“以后都不需要再回到脚本主体了,直接换成另一个程序跑”,常用于启动服务或做日志重定向。比如:
exec python3 /opt/app/main.py这条命令执行后,脚本剩下的内容不会再跑,当前进程的PID不变,但运行的代码变成python3了。这种做法的好处是信号处理:信号会直接发给python3,而不是先发给shell再转发,避免一些信号传递的延迟和丢失。
4.4sourcevs 子shell:为什么source可以在当前shell改环境变量
source file(简写.)和bash file的区别,是理解命令执行的关键。
source不会启动子进程,它把脚本内容读入当前shell,逐行执行。所以脚本里定义的变量、函数、cd、export,在当前shell里全部生效。修改~/.bashrc后执行source ~/.bashrc,新配置立刻生效,就是因为这个。
bash file则是在一个新的子shell进程里执行脚本,里面的所有环境变化只对子shell可见,不影响当前shell。
cat > /tmp/test.sh <<'EOF' export MY_VAR="hello" EOF source /tmp/test.sh echo $MY_VAR # hello,source真的把环境变量设置到当前shell了 bash /tmp/test.sh echo $MY_VAR # 空,子shell里设置的变量带不出来写脚本时,如果你的脚本需要修改调用方的环境(比如给用户设置环境变量),要么让用户source它,要么让脚本把需要导出的变量通过eval等方式输出给调用方处理。这是shell生态里一个很重要的约定。
5. 错误排查实战:把这一节知识用到热搜场景里
如果把Executing Commands这一节知识应用起来,你会发现那些常见的shell报错大多都能归类到“命令查找失败”“执行权限不足”“展开结果异常”三类里。结合网络热词里的几个典型报错场景,我做个实战对照。
5.1 command not found:不止“没安装”这一个解释
bash: telnet: command not found是热搜里出现过的经典报错。很多人第一反应是telnet没装,但command not found其实有四种常见原因:
- 程序确实没安装。检查方式是
apt list --installed | grep telnet、rpm -qa | grep telnet这种包管理器查询。 - 程序装了,但安装目录不在PATH里。比如很多软件装到
/opt/xxx/bin,你没把目录加进PATH。检查方式是ls /opt/xxx/bin/telnet看看文件在不在,在的话就考虑加PATH或软链。 - 输入的命令名不对,或者命令名带路径但路径写错。比如
./script.sh写成script.sh,如果当前目录不在PATH里(一般都不在),那就找不到。 - PATH环境变量本身被搞坏了,比如没有包含
/usr/bin,导致所有外部命令都找不到。这种故障很凶,排查时用绝对路径调用命令来修复:/usr/bin/export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin。
command not found具体是哪一种,先看命令是否真实存在、再看PATH、再看hash缓存三步排查。这一节学过的type、hash、PATH知识刚好全部用上。
5.2 permission denied:不只是“文件没有执行权限”
热搜里有条bash: /home/xtest/.bashrc: permission denied,这个看着像.bashrc没有执行权限,其实是另一个坑。.bashrc是通过source加载的,source不要求执行权限,只要求可读权限。如果报permission denied,大概率是.bashrc文件的所有者不是你,而你对它没有读取权限。
排查方式:
ls -l /home/xtest/.bashrc如果所有者是root,权限是-rw-------,你是xtest用户,那就读不了。修复方法是让root改权限,或者把文件给你加读权限。
这类问题在共享主机上尤其常见。别人把脚本放在你的家目录下但权限没放开,你在shell里一启动就报错。理解了“source只需要读权限,不需要执行权限”这个细节,排查方向就对了。
5.3 Bash执行脚本“自己报错”:脚本文件与解释器的权限
执行一个./script.sh得到Permission denied,那真的是执行权限没开。处理方式:
chmod +x script.sh但还有一类情况是脚本文件有执行权限,她却报bad interpreter,意思是脚本的第一行解释器路径写错了。比如#!/usr/bin/python,但系统里Python装在/usr/local/bin/python。这种错误的本质就是“命令查找机制”在解释器上的体现——内核帮你找解释器,找不到就报错。
写跨平台脚本时,第一行尽量写通用的#!/usr/bin/env bash或#!/usr/bin/env python3,让环境去PATH里找解释器,而不是写死绝对路径。env命令从这里切入,帮你做了一次“PATH查找”,大大提高了脚本的可移植性。
5.4 cmake执行bash命令:工具链里的命令查找依赖
CMAKE里经常有execute_process(COMMAND bash -c "...")这种需求,本质上还是bash在执行命令。但这里有个特殊点:execute_process启动bash时的环境可能和你在终端里看到的不一样。CMake会清理或修改环境变量,PATH可能被精简,某些用户自定义的别名、函数自然不会存在。
所以在CMake里写bash -c "xxx"时,不要依赖用户shell的.bashrc配置,尽量用绝对路径、显式设置环境变量。这又回到命令查找机制的本质:你把执行环境的边界想清楚,就知道该在哪个层面补齐依赖。
CMake场景下的“bash命令执行失败”排查顺序是:先手动跑一遍同款bash命令确认逻辑没问题,再看CMake传给bash的环境有没有缺PATH、缺HOME,最后看工作目录对不对。这套思路用到的还是Executing Commands这一节的底子。
5.5 Mac用户升级本地bash:Homebrew路径与登录shell的变迁
macOS自带的bash是3.2版本(因为许可证原因一直停留在老版本),很多新语法比如${var,,}、**通配符、mapfile都不支持。于是很多人用Homebrew装新bash:brew install bash,新shell装到了/usr/local/bin/bash或/opt/homebrew/bin/bash。
装完之后最大的坑是:终端默认跑的仍然是系统旧bash。你必须修改登录shell:
sudo chsh -s /opt/homebrew/bin/bash或者到终端的设置里改shell路径。改完之后重新开终端,echo $BASH_VERSION确认新版生效。
但这里还有一层更深的坑:chsh能改登录shell,可很多脚本在开头写#!/bin/bash,这个/bin/bash仍然是系统旧版。所以即使你把交互shell换成新版,脚本里的#!/bin/bash还是用旧版解释器。想让脚本也用新版,得把shebang改成#!/opt/homebrew/bin/bash,或者用/usr/bin/env bash并确保新版bash在PATH前面。
这类“我明明升级了bash,为什么语法还是不支持”的问题,本质就是“你到底在用哪个bash”没有搞清楚。用which bash看交互shell用的哪个,用head -1 script.sh看脚本指定的哪个,用bash --version验证版本。这条链路想通了,命令查找机制就算掌握了一大半。
6. 实操复盘:从命令查找到脚本输出的完整示例
掌握了原理,最终还是要落到具体操作。这一节我用一个完整的实战场景,把Executing Commands这节的知识串起来演示一遍,顺便给出可以直接套用的脚本模板。
6.1 实战场景:一个“检查并清理磁盘”的小脚本
假设你想写一个脚本,检查当前磁盘使用率,超过阈值就清理/tmp下的临时文件并记录日志。这个脚本会用到命令查找、重定向、管道、条件执行、命令替换这几个核心机制。
#!/usr/bin/env bash log_file="/var/log/disk_clean.log" threshold=80 # 获取根分区使用率的数字部分,比如 85% usage=$(df -h / | awk 'NR==2 {gsub("%", "", $5); print $5}') if [[ "$usage" -gt "$threshold" ]]; then echo "$(date): usage ${usage}% exceeds ${threshold}%, cleaning /tmp" >> "$log_file" # 清理超过3天未改动的临时文件 find /tmp -type f -mtime +3 -exec rm -f {} \; echo "$(date): clean-up done, current usage $(df -h / | awk 'NR==2 {print $5}')" >> "$log_file" else echo "Current disk usage is ${usage}%, below threshold ${threshold}%. No action needed." fi拆开来看,这里面用到了不少这一节的知识点:
#!/usr/bin/env bash:用env到PATH里找bash,避免写死路径。df -h / | awk '...':管道连接,awk从df的输出里抽取第五列,gsub把%去掉再打印数字。$(...):命令替换把df的处理结果赋值给usage变量。[[ "$usage" -gt "$threshold" ]]:条件判断。注意这里加了引号,防止分词出错。find ... -exec ... {} \;:查找文件并执行删除。\;是告诉find“每条结果都执行一次命令”,+是“批量执行”。>>重定向:追加写日志,保留历史。
6.2 脚本编写时的三条命令执行原则
写shell脚本有个伴随始终的原则:能加引号就加引号。变量展开后的内容如果可能包含空格、通配符、特殊字符,不引起来就会被二次分词和展开,这是脚本bug的头号来源。上面脚本里我把"$usage"和"$log_file"全部加了引号,就是防止意外。
第二条原则是:**分清“什么时候展开”。**命令替换、变量展开发生在命令执行前,重定向发生在展开后。这意味着> "$log_file"这个重定向的目标文件名,会先展开再建文件,所以文件名里的变量能用。但如果你写$(cat file)这种嵌套命令替换,要特别注意双引号放在哪里,因为它影响展开结果是否继续分词。
第三条原则:**写脚本前先想清楚进程模型。**哪些命令是内建、哪些是外部、哪些会进入子shell、哪些会阻塞。比如cd是内建所以能在脚本里改工作目录;find是外部进程所以启动有开销,不适合在循环里频繁调用;$(...)和管道都在子shell里执行,里面的状态改不到外部。心里有这张图,写复杂脚本才不会“猜”。
6.3 一条命令同时拿返回码和输出:规避子shell的边界
$(...)能拿到命令输出,但拿不到退出码。如果你想同时判断命令是否成功并且获取它的输出,最简单粗暴的写法蠢得可爱:
output=$(command) status=$?但在某些极严格环境(比如set -e)下,command本身失败导致脚本直接退出,你根本没机会查$?。如果你必须容忍失败并处理输出,可以这样:
output=$(command) || true status=$?这个|| true的语义是“如果命令失败,整个表达式的退出码算成功”,于是脚本不会退出,$?里存的还是command真正的退出码(注意,是command的退出码,不是true的)。这条技巧在写健壮的自动化脚本时极有用。
再补一个场景:如果既要输出又要在子shell里维持状态,可以考虑把中间状态写到临时文件,而不是依赖子shell里的变量。这也是为什么很多shell脚本里会有那么多/tmp/xxx.$$临时文件——这就是子shell边界逼出来的常用招法。
7. 避坑清单与常用速查:Executing Commands核心要点
这节最后,我把Bash命令执行最值得记住的要点和坑整理成速查,方便你回头翻。
7.1 命令执行顺序速查:第一次看也得记住的一张表
| 阶段 | 作用 | 典型例子 | 踩坑提示 |
|---|---|---|---|
| 读取与分词 | 把输入切成token | ls -l | 空格、引号影响分词 |
| 解析语法 | 判断命令结构 | cmd 2>&1 | 2>1不是重定向stderr而是创建文件1 |
| 展开 | 变量、通配符、命令替换 | $VAR、*.txt、$(date) | 引号会阻止分词和通配符展开 |
| 重定向 | 改文件描述符指向 | > file、2>&1 | 顺序影响重定向目标 |
| 命令查找 | PATH、hash、内建 | ls、cd | hash -r可清缓存 |
| 执行 | fork或内建运行 | 外部命令fork、cd内建 | 管道和$()在子shell中运行 |
7.2 内存级避坑清单
- 引号是亲妈。变量展开、命令替换不加双引号,空格、
*、换行都可能引发灾难。不确定就加。 - 管道排在子shell里执行。管道里改变量带不出来,任务处理多个值要小心。
2>&1位置很重要。写反了,“正确”的重定向变成“标准错误还在终端”。&&||链不是if的替代品。右侧命令失败时可能误触发||,逻辑复杂就用if。command not found不等于没安装。检查PATH、hash、类型三个方向。source只要求读权限,不要求执行权限。.bashrc的permission denied基本是读权限问题。- 升级bash之后别忘记改登录shell和shebang。否则新语法照样不生效。
curl | bash方便但有风险。先下载、看一眼、再执行。对不可信脚本要保持敬畏。
最后的提醒:这一节“Executing Commands”学得扎不扎实,直接决定你写脚本时脑子里有没有那张“进程图和展开时序图”。我个人经验是,很多工作了五六年的工程师,排查命令问题还在靠“试”,就是因为当年跳过了这套机制的学习。用一晚上把这节吃透,换来的是此后排查shell问题时的一种“透视感”——你不再对着报错瞎猜,而是能一步步推理出它在执行的哪一环出了差错。这大概就是这节内容最值钱的地方。