
我刚开始学 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 VARdefault 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_NAMEdemo # 仅当前 shell 可见 export APP_NAME # 标记为环境变量平时可以合并成一行export APP_NAMEdemo。反向操作是export -n APP_NAME去掉继承标记彻底删除变量用unset。这里有个容易混淆的点就算你 export 了变量如果之后修改它的值不需要重新 export子进程拿到的永远是变量最新值因为环境变量的传递发生在 fork 那一刻。readonly 等于给变量上锁readonly CONFIG_DIR/etc/myapp之后再想改 CONFIG_DIRshell 会直接报错。这个特性适合保护那些“整个脚本生命周期内都不得变更”的值比如安装路径、应用端口等。但它也有副作用函数里如果尝试给 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 $HOMEeval 会原样执行。用 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 是终止整个 shellreturn 是退出当前函数或 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 决定新文件的默认权限。它不是“给你加权限”而是“从默认权限里减掉”。默认创建文件是 666rw-rw-rw-创建目录是 777rwxrwxrwx。假设 umask 是 022那么创建文件的权限就是 666 减去 022得到 644rw-r--r--创建目录是 777 减去 022得到 755rwxr-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临时禁用内置版本仅 Bashenable -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 命令最初偷懒用了字符串拼接加 evalcmdrsync -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就翻出以前写过的脚本重新审视一遍哪些地方可以用更合适的内建命令替代。这个复盘过程比反复读十遍文档都管用。