我刚开始学 Bash 的时候,心里其实挺不以为然的:cd、echo、pwd 这些命令不是天天在用吗,跟普通的 ls、grep 有什么区别?直到有一天我需要在一个脚本里给上百个变量做默认值,顺手用了几十个外部命令,跑完一看耗时,被狠狠教育了一顿。这才认真去翻 Bash 文档里那章 Shell Builtin Commands,尤其是第 4 章第 1 节讲的 Bourne Shell Builtins——也就是从 Bourne Shell 时期就传下来、所有 POSIX 系 shell 几乎都认的那批内建命令。
这一节的内容算不上酷炫,但绝对是 Bash 技能的“地基”。很多你写了多年脚本都没弄明白的诡异现象,比如为什么 source 和 bash xx.sh 效果不一样、为什么 exit 在函数里会连整个脚本一起退出、为什么有时候明明装了新版工具却调用的是旧版,根子都在这些内建命令身上。这篇文章我就按自己啃这一节时的思路,把 Bourne Shell Builtins 拆开揉碎讲清楚,顺便夹带一些实际写脚本才会踩到的坑。
1. 内建命令到底是什么:为什么 shell 自己还要留一手
很多初学者把 shell 当成一个简单的“命令搬运工”,你输入什么,它就去系统里找什么可执行文件来运行。这个理解不完整。一个合格的解释器,必然有一部分命令是自己直接实现、不需要去 PATH 里找外部程序、也不创建子进程的,这些命令就叫内建命令(builtin)。Bash 文档把 builtin 分成几类,其中“Bourne Shell Builtins”是整个体系里最核心、最老的家族。
1.1 内建命令存在的三个理由
第一,有些事在架构上必须由 shell 自己干。比如 cd,如果它是一个外部程序,那么它修改的只是外部程序自己的工作目录,shell 父进程的工作目录完全不受影响。你敲完 cd /tmp 之后 shell 还是站在原地,这命令就没有任何意义了。同样,export 设置环境变量、umask 修改文件权限掩码、ulimit 调整进程资源限制,这些全都得在 shell 自己的进程空间里完成,外部程序替代不了。
第二,性能。每次执行外部命令,shell 都要 fork 一个子进程,再 exec 对应程序。一次两次无所谓,在循环里跑几百次、几千次,fork 和 exec 的系统调用开销就非常扎眼了。内建命令直接在当前进程里执行,等于省掉了一次“生孩子再换脑子”的完整流程。
第三,交互与脚本控制。像 break、continue、return、exit 这种控制命令,天生就要影响当前 shell 的执行流,外部程序根本无从插手。你不可能让一个 /usr/bin/break 去终止旁边 shell 的循环,这就跟游戏里 A 玩家没法直接关掉 B 玩家的客户端一样。
1.2 命令查找的先后顺序
理解了 builtin 的存在,你还要知道 shell 在收到一条命令时到底按什么顺序找它。Bash 的执行顺序大致是这样的:先看是不是别名(alias),再看是不是函数(function),然后查内核建(关键字,比如 if、for、while),接着查 builtin,最后才去 PATH 里翻外部命令。这个顺序决定了同名命令之间的“优先级”,很多线上脚本的诡异 bug 都是在这里埋下的。
想亲眼确认一个命令是不是 builtin,可以用 type 命令。比如:
$ type cd cd is a shell builtin $ type ls ls is /usr/bin/ls如果某个命令同时有内建和外部两个版本,type -a 会把所有可能的路径都列出来,这个后面第 4 节还会专门讲。
2. Bourne Shell Builtins 全家福:这一节到底讲了哪些命令
Bash 官方文档列的 Bourne Shell Builtins 数量不算特别多,但每一个都有明确的用途。初看会觉得零零散散,实际上按它们服务的对象分类,很快就记住了。
2.1 核心命令清单
我把这一节涉及的常用命令整理了一下,附上“一句话用途”,方便你对照查漏:
| 命令 | 一句话用途 | 使用频率 |
|---|---|---|
: | 空命令,永远返回 0,常用于占位和死循环 | 高 |
.(source) | 在当前 shell 里执行文件内容 | 高 |
break | 跳出循环 | 高 |
cd | 切换当前工作目录 | 极高 |
continue | 跳过本轮循环进入下一轮 | 高 |
eval | 把参数拼成命令再执行 | 低但危险 |
exec | 用新程序替换当前 shell 进程 | 中 |
exit | 退出 shell,可带状态码 | 极高 |
export | 把变量标记成环境变量 | 极高 |
readonly | 把变量标记为只读 | 中 |
return | 从函数或 source 文件中返回 | 高 |
set | 设置/取消 shell 选项或位置参数 | 高 |
shift | 左移位置参数 | 中 |
test([]) | 做条件判断 | 极高 |
times | 显示累计 CPU 时间 | 极低 |
trap | 设置信号和退出时的处理动作 | 中 |
umask | 设置新建文件的权限掩码 | 中 |
unset | 删除变量或函数 | 高 |
2.2 和 Bash 特有 builtin 的关系
这一节标题特意写了“Bourne Shell Builtins”,意思是这些命令是 Bash 从 Bourne Shell 那里继承过来的“祖传正品”。Bash 自己还有很多额外 builtin,比如 alias、bind、history、compgen 等,它们属于“Bash 扩展”,不是这一节的内容。
很多资料里把[单独列一个 builtin,这里我把它和 test 归在一起,因为你平时写if [ "$x" = "y" ],那个[实际上就是 test 命令的另一种写法,要求最后一个参数必须是]。这条规则解释了为什么[的元素之间一定要抠空格,少了任何一个,shell 要么把它当普通字符,要么直接报语法错误。
3. 高频命令逐个过:会敲是一回事,懂它为什么这么设计是另一回事
这一节我想捡最常用的几个命令展开讲,重点放在“原理——用法——坑”三段式上,而不是把手册里的参数罗列一遍。
3.1 冒号和点号:看起来不起眼,实际是基石
先说:。它在 Bourne Shell Builtins 里的存在感极低,但它有个不可替代的价值:永远返回真(退出码 0)。最常见的用法有两个。一个是写死循环:
while : do # 服务监控逻辑 sleep 30 done另一个是给变量设默认值但不做多余操作。你可能会写:
if [ -z "$VAR" ]; then VAR="default" fi但如果你只是想让某个变量在为空时有个默认值,而且不想引入第三方工具,可以用${VAR:-default}配合:来做一个“计算但无输出”的动作:
: "${VAR:=default}"这里:=会在 VAR 为空时给它赋值,而:负责把这个赋值动作“吞掉”,不让结果输出到屏幕。这个写法在自动化配置脚本里非常实用。
再来说点号.,也就是 source。它是 Bourne Shell Builtins 里最容易被低估的命令。source和直接执行脚本有着本质区别:source 是在当前 shell 进程里逐行执行的,不新建子进程。这意味着被 source 的文件里声明的变量、定义的函数,source 完之后在当前 shell 里仍然存在。这一点是配置文件的根基。
# 假设 myfunc.sh 里定义了函数 hello source ./myfunc.sh hello如果没有 source,而是bash ./myfunc.sh,子进程里的函数定义和变量在脚本退出后全部消失,调用方什么都拿不到。明白了这个原理,你就能理解为什么修改 ~/.bashrc 之后要source ~/.bashrc而不需要重启终端。
坑也埋在这里:如果被 source 的文件里有exit,它退出的就不是“这个文件”而是你整个 shell。很多初学者写工具脚本时顺手在公共配置里写了一句 exit 1,然后发现自己的终端莫名其妙被关掉,就是这个原因。公共配置文件里应该用return而不是exit。
3.2 cd 和路径管理的细节
cd 看着简单,其实有几个值得注意的小技巧。第一,CDPATH 环境变量。如果你设置了它,cd 在找不到相对路径时会依次在 CDPATH 里找。这个特性在脚本里很容易造成“咦怎么切到别的地方去了”的困惑,所以脚本里用到 cd 建议直接写绝对路径,或者先cd "$(dirname "$0")"定位脚本所在目录。
第二,cd -可以回到上一次所在的目录,这在手动排查问题时非常方便,但它依赖 shell 内部对“上一次目录”的记录,如果你在一个新 shell 里第一次执行cd -,通常只会看到OLDPWD未设置的提示。
第三,路径包含空格时一定要加引号:
cd "/tmp/My Project/docs"不加引号的话 cd 会接到多个参数,虽然某些历史版本会容忍这种行为,但在严格的 POSIX 模式下会报错。我见过不止一次因为路径有空格导致部署脚本失败的案例,根因都是这里。
3.3 export、readonly、unset:变量的生命周期管理
export 的本质是给变量打一个“允许子进程继承”的标记。没有这个标记的变量叫 shell 变量,只在当前 shell 里可见;打过标记的变量叫环境变量,会随 fork 的子进程一起传给外部命令。
APP_NAME="demo" # 仅当前 shell 可见 export APP_NAME # 标记为环境变量平时可以合并成一行:export APP_NAME="demo"。反向操作是export -n APP_NAME,去掉继承标记;彻底删除变量用unset。这里有个容易混淆的点:就算你 export 了变量,如果之后修改它的值,不需要重新 export,子进程拿到的永远是变量最新值,因为环境变量的传递发生在 fork 那一刻。
readonly 等于给变量上锁:
readonly CONFIG_DIR="/etc/myapp"之后再想改 CONFIG_DIR,shell 会直接报错。这个特性适合保护那些“整个脚本生命周期内都不得变更”的值,比如安装路径、应用端口等。但它也有副作用:函数里如果尝试给 readonly 变量赋值,脚本会在运行时直接报错而不是跳过,所以别把它用在会被函数复用的变量上。
3.4 shift 和位置参数:手写参数解析的核心
写脚本解析命令行参数时,shift 几乎是绕不开的工具。它的作用是把位置参数整体左移:$2变$1,$3变$2,原来的$1被丢弃。你可以指定一次性移几位,比如shift 2,这在解析“参数+值”的成对结构时非常高效。
最常见的循环模式:
while [ $# -gt 0 ]; do case "$1" in -f|--file) FILE="$2" shift 2 ;; -h|--help) usage exit 0 ;; *) echo "未知参数: $1" exit 1 ;; esac done这里shift 2一次跳过一个选项加它的值,比shift两次更清晰。还有一个配套命令是set --,它可以重新设置位置参数列表。比如把一行文本按空白拆开,可以用:
set -- $(cat /etc/hosts) echo "第一个字段:$1"不过这个写法依赖 shell 分词规则,如果内容里有通配符,通配符会被展开,这一点要小心。
3.5 eval 和 exec:两个“高压工具”
eval 的作用是把参数先拼成一个字符串,然后当成命令再执行一遍。它非常灵活,也非常危险。
什么时候真的需要 eval?我遇到过的典型场景是动态变量名。比如你想循环设置 VAR_1、VAR_2……一直到 VAR_10,直接用普通赋值表达式无法把“变量名”当成变量来用,纯 Bash 可以用declare "VAR_$i=$value"做到类似效果,但如果你手里只有一套严格的 POSIX 环境,eval 就是最直接的选择:
eval "VAR_$i=$value"隐患在于,如果 $value 或用户输入包含恶意内容,比如; rm -rf "$HOME",eval 会原样执行。用 eval 前要对所有外部输入做合法性校验,否则等于把 shell 的枪交到别人手里。安全替代方案是printf %q先把字符串转义成 shell 可安全解析的格式,再交给 eval。
exec 则是“替换”而不是“新建”。脚本改成:
exec python3 app.py它的效果是用 python3 直接覆盖当前 shell 进程,前面跑过的 shell 不再存在,也就省了一个残留进程。这个写法适合脚本最后一行的“启动常驻程序”。但如果你在 exec 后面的代码还想继续跑,那就不可能了,因为 shell 已经没了。另一种常见用法是配合文件描述符:exec > logfile会把之后所有的 stdout 都重定向到文件,这个比在每条命令后面写>> logfile干净很多,但别忘了最后用exec > /dev/tty恢复,否则后续正常输出都会消失。
3.6 exit、return、break、continue:控制流的四种出口
这四个命令管着脚本和函数的“生命线”。exit 是终止整个 shell,return 是退出当前函数或 source 文件,break 是跳出循环,continue 是跳到循环的下一轮。
函数里的坑非常经典:
func() { exit 1 } func echo "这行永远不会执行"哪怕 exit 1 写在函数里,它也会直接把整个 shell 终止。如果你只想让函数出错并返回,应该用return 1。这一点在写公共函数库时尤其重要:库里任何函数都不应该擅自 exit,调用方会一脸懵。
break 和 continue 后面可以跟数字,表示作用于第几层循环。比如break 2一次跳出两层循环,在多层嵌套循环处理错误时很省事。continue 同理,continue 2让外层循环直接进入下一次迭代。
3.7 trap:信号处理和资源清理
trap 是那种“平时不起眼,出事就救命”的命令。基本格式是:
trap '动作' 信号信号可以是 HUP、INT、TERM 等,也可以是一些 Bash 内置的“伪信号”,最常用的是 EXIT。EXIT 信号的意思是“不管脚本怎么结束,正常退出也好,出错退出也好,都会执行这里指定的动作”。这最适合做清理操作,比如删除临时文件:
tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT这样哪怕脚本在中途因为某个错误崩溃退出,临时目录也会被自动清掉。另一个好用的伪信号是 ERR,在任何命令返回非 0 退出码时触发。配合set -e,可以在脚本出错时打印调试信息。
注意 trap 的动作是一个字符串,shell 会在信号发生时重新解析它。如果动作里有变量,请确保它们在执行时还在作用域内,否则取到的可能不是预期值。
3.8 umask 和 ulimit:系统资源的“隐形开关”
umask 决定新文件的默认权限。它不是“给你加权限”,而是“从默认权限里减掉”。默认创建文件是 666(rw-rw-rw-),创建目录是 777(rwxrwxrwx)。假设 umask 是 022,那么创建文件的权限就是 666 减去 022,得到 644(rw-r--r--),创建目录是 777 减去 022,得到 755(rwxr-xr-x)。
umask 022 # 常见值:文件 644,目录 755 umask 077 # 更严格:文件 600,目录 700很多人以为 umask 077 会让文件变成 700,其实文件没有执行权限位的话是 600。还有一个坑:如果你在脚本开头设置了 umask,它会影响后面所有由该 shell 创建的文件,包括重定向产生的日志文件。我见过某服务日志权限太高被安全扫描的风险评估点名,最后发现不是程序的问题,而是启动脚本里 umask 漏设了。
ulimit 则用于控制当前 shell 能拿到的资源,比如:
ulimit -n 4096 # 文件描述符上限 ulimit -c 0 # 禁用 core dump ulimit -v unlimited # 虚拟内存不限制注意 ulimit 修改的是“当前 shell 和它的子进程”的限制。交互式终端里调低 ulimit 之后,想调高可能被系统硬上限拦住,没生效还会报错。
4. 内建命令和外部命令“同名打架”时,到底听谁的
Bourne Shell Builtins 里有不少命令和外部程序同名,比如 echo、pwd、test、true、false、kill、wait、printf。你可能一直以为你在用外部命令,实际上你早就在跟内建版本打交道了。
4.1 怎么确认当前实际用的是哪一个
用 type 就能看到:
$ type -a echo echo is a shell builtin echo is /usr/bin/echo输出结果说明了两个候选都在。按照前面说的查找顺序,shell 会优先用内建版本。所以要验证行为差异,很简单:
$ builtin echo "hello" # 强制用内建 $ /usr/bin/echo "hello" # 强制用外部程序4.2 行为差异导致的经典翻车
echo 是最典型的例子。System V 风格的 sh 和某些外部 echo 默认会解析转义字符,比如\n会变成换行;Bash 内建 echo 默认不解析,需要显式echo -e才解析。一个脚本如果是按 Bash 写的,换到严格 POSIX 的 sh 下跑,echo 的转义行为可能悄悄变样,输出格式就全乱了。
pwd 也有两个版本,区别在于-L和-P。内建 pwd 默认输出的是“逻辑路径”,也就是你 cd 时经过的带符号链接的路径;/bin/pwd默认输出“物理路径”,会追到底层真实路径。在一个软链目录里跑脚本时,用错版本可能导致配置文件路径解析错误。
test 的经典坑主要在字符串比较。在 POSIX 里,[ "$x" = "$y" ]是比较字符串,[ "$x" == "$y" ]则不是标准写法,某些旧 shell 不认。另外,如果 $x 没加引号且为空,[ $x = y ]会变成[ = y ],直接语法错。这就是为什么所有 test 里的变量都要加引号。
4.3 强制指定版本的手段
如果你就是想用外部命令,有几个正规打法:
- 用
command /usr/bin/echo或直接绝对路径调用 - 用
env echo绕开 shell builtin - 用
enable -n echo临时禁用内置版本(仅 Bash)
enable -n是 Bash 特有的,它把指定的 builtin 关掉,之后敲 echo 就会走外部命令。这个操作在当前 shell 生效,新开的 shell 不受影响。反过来要用enable echo重新打开。
这里多说一句:Bash 还提供builtin命令,专门用来“只执行内建版本”,想搞清楚某个行为到底是不是 builtin 造成的,可以用它做实验。
4.4 命令哈希表可能坑你
Bash 为了提高效率,会把查过的外部命令路径缓存到哈希表里。如果你在运行期间重新安装或移动了某个程序,哈希表里可能还是旧路径,甚至你删了旧文件它还在硬找。解决办法是hash -r清空哈希表,或者用hash -d 命令名删除某一条具体记录。排查“明明装了新版却调旧版”的问题时,头一步就是hash -r,省得白折腾。
5. 实战复盘:几个“表面正常却翻车”的场景和排查思路
写脚本踩过的坑远比看文档记得牢。这一节我复盘几个真实遇到过的案例,它们都和 Bourne Shell Builtins 的行为有关。
5.1 source 了一个配置,shell 突然退出
某次部署脚本里,主脚本 source 了一个公共环境配置文件,文件尾部有失败检查的逻辑:
if ! check_env; then echo "环境检查失败" exit 1 fi结果主脚本在 source 完这个文件后直接终止,连它自己的日志都没来得及打完。后来查下去,发现配置文件里的 exit 1 执行后“退出的”不是配置文件,而是整个 shell 进程。文件被 source 的时候,它和主脚本是在同一个进程空间里跑的,exit 的语义就是“结束当前 shell”,不分你是谁 source 的。
修正办法很简单:公共配置文件里的失败出口一律用return 1。如果不知道当前是脚本还是手动 source,还可以用return之前判断一下:
if [ "$0" != "$BASH_SOURCE" ]; then return 1 fi exit 1这个习惯能避免很多“公共检材脚本把父进程带崩”的事故。
5.2 eval 拼接命令,被文件名里的特殊字符坑了
一个备份脚本需要把多个文件路径拼成长 rsync 命令,最初偷懒用了字符串拼接加 eval:
cmd="rsync -av" for f in "${files[@]}"; do cmd="$cmd \"$f\"" done eval "$cmd"平时一切正常,直到某个文件名里出现了反引号和 $ 符号,eval 重新解析后直接执行了那些字符里的内容。排查的时候通过 bash -x 看到展开后的命令,差点吓出一身汗。后来改成了裸调 rsync 加数组参数,彻底放弃 eval 拼命令:
rsync -av "${files[@]}" "$dest"这个经验总结成一句话:能用数组传参就不要拼字符串;真要 eval,先用printf '%q'对所有外部内容转义。
5.3 umask 设置导致日志权限异常
另一个案例是某后台服务的启动脚本,为了“稳妥”在脚本开头加了umask 077,结果服务输出的日志文件全变成 600,程序内部再加日志轮转时,新进程读不了旧进程的日志文件,直接报权限错误。
排查时我先用ls -l看了日志权限,再反推脚本里的 umask,最后确认是启动脚本过度收紧掩码。合理做法是给日志目录单独设置合适的属主和权限,而不是一个 umask 管所有输出。顺带提一句,如果你在调试一个“启动后文件权限不对”的问题,第一件事永远是看当前进程的 umask 值:cat /proc/<pid>/status | grep Umask,或者ps -o umask -p <pid>。
5.4 循环里放了一堆外部命令,慢到怀疑人生
写一个数据处理脚本时,我在循环里对每一行都执行了 grep、sed、awk,导致处理 10 万行数据跑了十来分钟。后来把一部分处理改成纯 Bash 内置写法:用 read 循环、参数展开、字符串比较,性能直接提升了几个量级。
Bourne Shell Builtins 在这种场景下的价值就是“省钱”:省掉 fork 和 exec 的时间。当然不是让你什么都用内置实现,毕竟外部工具的文本处理能力确实更强。我的原则是:循环次数上万且逻辑简单,优先用内置;逻辑复杂、需要正则或者多字段重组,保持外部工具,但尽量把循环次数降下来,比如先把数据集中交给 awk 一次处理。
6. 这一节学完,我认为最值得记住的三条经验
第一,遇到“命令行为跟文档不一致”的疑问,第一反应不是怀疑系统坏了,而是先看 type。很多问题是内建和外部命令版本差异造成的,type -a 能把它们全暴露出来。
第二,凡是涉及退出、返回、循环控制,先问自己“这条命令到底影响的是函数、循环还是整个 shell”。exit、return、break 三者选错,后果千差万别。
第三,source 和 eval 是两个“在当前 shell 里干活”的高权限命令,使用前想清楚作用域和风险。公用的配置文件里多用 return、少用 exit;动态拼接命令前,优先考虑数组、函数、参数展开这些更安全的替代方案。
Bourne Shell Builtins 这一节本身内容不长,但它几乎支撑起了所有 POSIX 脚本的骨架。把这批命令的语义吃透,再回头看你以前写的脚本,很多“玄学”都会变成“原来如此”。我自己的习惯是每学完一类 builtin,就翻出以前写过的脚本重新审视一遍哪些地方可以用更合适的内建命令替代。这个复盘过程比反复读十遍文档都管用。