补充shell的一些容易搞错的地方代码执行是不是都要经过shell不是。程序代码执行完全可以不经过 shell。shell 只是一个 “用来启动别的程序” 的工具不是所有程序运行的必经层。下面把两种路径拆开讲结合你编译、make、buildroot 的场景。1. 两条完全不同的执行路径路径 A经过 shell你敲命令、shell 脚本gcc test.c你在 bashshell这个进程里输入命令bash 解析字符串分词、通配符*.c、变量替换$VAR、管道、重定向 out.txtbash 调用fork()创建子进程然后在子进程里调用exec()加载gcc程序gcc 开始运行。 这里 shell 的工作解析命令字符串然后启动目标程序。gcc 本身的代码运行一旦 exec 成功就和 shell 没关系了只是父子进程关系。路径 B不经过 shell直接 execC 代码、make 内部调用、很多底层脚本C 语言里// 直接启动 gcc不经过任何shell char *argv[] {gcc, test.c, -o, test, NULL}; execvp(gcc, argv);流程当前进程直接execvp传入程序名 参数数组内核加载 gcc直接跑没有 shell 参与不会做通配符、变量替换、管道解析。make 在执行 Makefile 命令时默认行为如果命令行里没有特殊 shell 元字符$ | * ;等make 会尝试直接exec不启动 shell如果检测到元字符make 才会启动/bin/sh把命令交给 shell 解析。2. fork /exec 核心模型重点Linux 启动新程序底层永远是fork()→ 创建子进程复制环境变量、内存exec()→ 在子进程里把原有程序替换成新程序gcc/make/ 你的程序fork/exec 是系统调用属于内核不是 shell 提供的shell 只是调用 forkexec 的众多程序之一。任何 C/Python/go 程序都可以直接调用 forkexec不需要 shell。3. 几个容易混淆的场景✅ 场景 1运行编译出来的二进制程序./a.out你在终端敲./a.out由 shell 解析这个命令字符串forkexec 启动 a.outa.out 运行时它自己的代码执行完全不需要 shellshell 只负责把它启动起来。a.out 跑起来之后shell 只是父进程等待它退出wait。a.out 内部逻辑和 shell 无关。✅ 场景 2make 执行 Makefile 里的命令all: gcc test.cmake 本身是一个独立二进制程序。make 读到gcc test.c判断有没有 shell 特殊符号没有就直接 exec gcc不启动 shell。如果写成gcc test.c log.txt里面有make 会调用/bin/sh -c gcc test.c log.txt此时会启动 shell来处理重定向。✅ 场景 3shell 脚本xxx.sh脚本里每一行命令gcc test.cshellbash读到这一行forkexec gcc这里有 shell 参与启动 gcc。✅ 场景 4Python / Go 代码启动程序import subprocess subprocess.run([gcc, test.c]) # 列表形式直接exec不启动shell subprocess.run(gcc test.c, shellTrue) # 字符串形式启动shell去解析这就是为什么 Python subprocess 推荐传列表避免 shell 注入也少一层开销。4. 环境变量传递两种方式都能传不管有没有 shell 参与启动进程fork 的时候子进程都会复制父进程的环境变量。父进程是 bashfork 出来的子进程继承 bash 环境父进程是 makefork 出来的子进程继承 make 进程环境父进程是 gccgcc 再 fork 子进程子进程继承 gcc 环境环境变量的传递是 fork 系统调用自带的能力和 shell 无关。shell 只是帮你方便设置环境变量它不是环境变量传递的必要条件。5. 关键区分「启动程序」 vs 「程序运行」shell 负责解析命令字符串、通配符、变量替换、管道重定向可选环节可以跳过内核负责fork 创建子进程、exec 加载二进制、传递环境变量必须环节二进制程序gcc/make/a.out加载后CPU 直接跑机器码完全不需要 shell 在场6. 结合你 buildroot 的场景总结buildroot 是一堆 Makefile shell 脚本混合。buildroot 脚本里很多命令是 shell 去启动 make、gccmake 内部很多编译命令直接 exec gcc不再额外嵌套 shellgcc 编译源代码生成目标文件整个编译过程gcc 本身运行不需要 shellshell 只出现在解析脚本字符串、处理重定向 / 管道这些需要文本解析的地方。7. 一个简单记忆口诀shell 是 “命令翻译器 启动器”不是程序运行的虚拟机。程序跑起来不需要 shell只有当你需要解析命令字符串的时候才需要 shell。shell脚本本质是不是就是shell命令行的集合大体可以这么理解但不完全准确shell 脚本 写在文件里、按顺序执行的 shell 代码不只是简单 “命令行复制粘贴集合”。1. 相似点你直觉里的那部分你在终端手动敲的每一行 bash 命令绝大多数直接复制进.sh文件就能跑。# test.sh pwd echo hello export CFLAGS-O2 lsbash test.sh就会依次执行这几行就像你一行一行手动敲。从这个角度脚本就是把一堆命令行保存成文件批量自动执行。2. 但是脚本 ≠ 单纯命令行集合有几个关键区别区别 1默认是新进程执行最关键和环境变量强相关你在终端敲命令在当前交互式 shell 进程执行bash test.sh会新建一个子 shell 进程来执行脚本里所有代码。脚本里面export的变量只在这个子进程里面脚本跑完就消失不会污染你外面终端的环境。这就是前面讲的环境变量作用范围而source test.sh才是不新建进程直接在当前交互式 shell 里面跑脚本里的代码和手动敲完全一样。区别 2脚本有语法能力交互式命令行也能用但脚本更适合写复杂逻辑shell 不只是只能跑命令它是一门完整编程语言分支if ... then ... else循环for / while函数、变量、数组、比较判断这些代码交互式终端也可以一行一行敲但写在文件里维护更方便。# 脚本里可以写这种逻辑终端也能敲但是写文件里方便复用 for file in *.c; do echo $file done所以shell 既是交互式命令解释器你敲命令的终端又是脚本编程语言解释器读 sh 文件执行同一个程序bash干两件事。区别 3解析规则细节有差异交互 shell vs 非交互 shellbash 分两种模式交互式 shell你打开终端有提示符$可以手动输入会加载~/.bashrc非交互式 shellbash test.sh跑脚本不会加载.bashrc启动环境不一样。 所以有些命令在终端直接跑正常放到脚本里跑就环境不一样PATH、变量不一样这是嵌入式 /buildroot 脚本非常常见的坑。区别 4脚本默认有参数$1,$2,$可以接收外部传入参数# test.sh echo 第一个参数$1执行bash test.sh abc脚本拿到abc参数。交互式命令行也能写但脚本把参数传递封装得更常用。3. 澄清一个容易混淆的点shell 脚本里面不全是外部命令pwd、ls、gcc一部分是 shell内置命令export、cd、if、echo不需要 fork 新进程shell 自己直接执行。另一部分是外部二进制程序ls、pwd、make需要 forkexec 启动子进程。4. 极简总结✅简单脚本就是一堆命令行写到文件批量执行❌但不只是命令的简单集合它可以带分支循环、函数并且默认跑在独立子进程环境隔离底层bash 程序是同一个只是运行模式不同交互模式终端 / 非交互模式执行脚本文件。5. 联系你前面的知识点手动终端敲当前 shell 进程export 变量保留在终端bash xxx.sh新建子 shell 进程脚本内修改环境变量脚本结束全部消失source xxx.sh在当前 shell 执行脚本代码等价于你手动一行一行敲命令变量保留在当前终端。要不要我举个小例子对比bash test.sh和source test.sh直观看到环境变量的差别为啥默认要创建子进程去跑脚本然后又要设置source允许在当前环境跑默认开子进程跑脚本是安全隔离source 是专门用来打破隔离的 “手动特权模式”只在你明确想要修改当前 shell 环境时才用。一、为什么bash xxx.sh默认新建子进程设计初衷想象场景你下载了一个别人写的 shell 脚本。如果脚本直接在你当前终端 shell 里跑脚本随便执行export直接篡改你当前终端的环境变量、PATH脚本执行cd /你终端的当前目录直接被改到根目录脚本甚至可以exit直接把你的终端窗口关掉脚本出错、环境乱掉你的终端环境直接被污染。操作系统和 bash 设计者的思路默认隔离保护你当前交互式 shell 环境。子进程方案脚本在独立子 shell 里面随便折腾。脚本里改变量、cd、export全部只在子进程副本脚本跑完子进程销毁所有改动全部丢弃你的终端原样不受影响副作用脚本内部设置的环境变量无法自动带回父 shell。 这是安全优先的设计不信任脚本默认隔离不让它随便污染你的交互式会话。举个直观例子# test.sh export CFLAGS-O3 cd /tmp echo 脚本里面$CFLAGSbash test.sh新建子 shell 运行。脚本结束回到你的终端echo $CFLAGS # 空当前目录也没变脚本的改动全部消失你的终端安然无恙。二、那为什么要有source.命令source 是一个显式开关代表你信任这个脚本主动同意让脚本修改你当前 shell 环境。典型使用场景就是你 buildroot 的场景source output/env.sh这个脚本的目的就是专门用来修改你当前终端的环境变量设置交叉编译器 PATH、ARCH、CROSS_COMPILE 等。你希望脚本执行完这些环境变量留在你当前终端后续敲 make/gcc 直接生效。所以source的行为不 fork 新进程直接在当前 shell 进程内逐行执行脚本里面的代码。脚本里的 export、cd全部作用在你正在用的终端 shell。⚠️ 代价此时脚本拥有修改你当前环境的全部能力脚本里写 exit 会直接关掉你的终端。所以这是你主动选择的不是默认行为。三、对比两种模式方式是否新建进程环境修改范围适用场景风险bash xxx.sh✅ 新建子 shell只在子进程执行完丢弃执行自动化编译脚本、运行第三方脚本低不会污染你的终端source xxx.sh/. xxx.sh❌ 不新建进程直接修改当前 shell 环境加载环境配置脚本envsetup.sh、env.sh高脚本会改动你当前终端四、补充一个容易混淆的点为什么命令行敲一行命令不会开子 shell你在终端敲export CFLAGS123 cd xxx这些是内置命令bash 直接在当前进程执行不会 fork。外部命令ls/make才会 fork 子进程。而整个脚本文件当你用bash test.sh调用时是启动一个全新 bash 实例子进程来解释整个脚本。五、和你之前的知识串起来fork 会复制一份环境变量副本子进程修改副本无法传回父进程。所以普通脚本哪怕脚本里面 export只能改子进程那份副本脚本结束就销毁。source 不走 fork直接在当前进程执行代码修改当前 shell 的环境变量所以变量保留。六、一句话记忆默认子进程隔离保护防止脚本搞坏你的终端环境source手动关闭隔离专门用来加载环境变量。小思考题buildroot 的env.sh如果不用 source而是用bash env.sh执行会发生什么答案脚本里面设置的交叉编译环境变量全部在子进程里脚本结束就消失你当前终端完全没有变化后面 make 找不到交叉编译器什么是内部命令什么是外部命令都有哪些为啥会有这种区分内部命令builtinbash 程序自己自带的代码不需要单独的可执行文件外部命令独立的二进制程序文件存在磁盘上需要 forkexec 加载运行。区分的根源性能 必要性。一、内部命令Shell 内置命令代码直接写在 bash 这个程序里面不需要去磁盘找单独程序。执行时不 fork 新进程就在当前 shell 进程直接跑。常见内部命令cd、export、set、unset、source(.)、exit、read、echo、pwd、type、break、continue if/for/while 这些语法关键字也属于shell内置逻辑验证一个命令是不是内置type 命令名type cd # cd is a shell builtin type export # export is a shell builtin关键特点执行快不用 fork、不用读磁盘加载程序直接修改当前 shell 进程状态最重要cd修改当前 shell 的工作目录export修改当前 shell 的环境变量exit直接退出当前 shell 进程 这类操作不能放到独立子进程里跑如果 cd 是外部命令fork 子进程去执行 cd只会改变子进程的目录执行完子进程退出你当前终端目录完全不变cd 就毫无意义。这就是为什么 cd、export 必须做成内置命令。二、外部命令是独立的二进制可执行文件放在磁盘一般/bin/usr/bin。执行流程shell 调用fork()创建子进程 → 在子进程exec()加载这个磁盘上的程序运行。常见外部命令ls、pwd、cat、ps、grep、make、gcc、rm、cp、mv验证type ls # ls is /usr/bin/ls注意少数命令既有内置版本又有外部独立程序比如pwd、echo。默认优先用内置。你可以用/usr/bin/pwd强制调用外部版本。三、为什么要分成内部、外部命令设计原因1. 一部分操作必须操作 shell 自身状态不能放在子进程最核心像cd、export、source它们修改的是当前 shell 进程内部的数据当前工作目录、环境变量。如果 fork 子进程执行子进程修改副本执行完就销毁父 shell 完全不受影响命令就失效。 所以这类必须做成内置命令在当前进程运行。2. 简单小命令做成内置减少 fork 开销提升速度echo、pwd这类简单操作如果每次都 forkexec 去磁盘加载程序频繁调用会很耗资源。内置版本直接在 shell 内部完成更快。3. 功能解耦复杂工具做成独立外部程序ls、gcc、make、grep逻辑庞大不适合全部塞进 bash 里。bash 只负责命令解析、进程管理复杂功能交给独立二进制。好处可以单独升级 ls/gcc不用替换整个 bash其他程序python、go也可以直接调用 ls不依赖 shell四、核心对比表项目内部命令 builtin外部命令代码位置嵌入 bash 程序内部磁盘上独立文件/usr/bin/xxx是否 fork 新进程❌ 不 fork当前进程直接执行✅ forkexec新建子进程运行能否修改当前 shell 环境✅ 可以cd、export、set❌ 只能修改子进程副本不影响父 shell查看方式type cmd→shell builtintype cmd→ 显示文件路径执行速度快慢需要创建进程、读磁盘五、容易踩坑的例子pwd既有内置也有外部程序/usr/bin/pwdtype pwd # pwd is a shell builtin /usr/bin/pwd # 强制调用外部pwd会fork子进程区分cd永远是内置没有/bin/cd这个文件which cd # 不会输出任何东西因为cd没有独立可执行文件六、结合你前面环境变量知识串起来export是内置命令在当前 shell 进程修改环境变量make是外部命令敲 makeshell fork 子进程复制一份当前环境变量给 makemake 内部修改环境只影响 make 子进程source是内置命令在当前 shell 逐行执行脚本不创建子进程所以脚本里 export 的变量留在当前终端。补充哪怕是内置命令如果你放在()子 shell 里执行依然会新建进程修改只在子 shell 内。(export TEST123) echo $TEST # 空括号开了子shell小练习你可以在终端执行这几条自己验证type cd type export type ls type make type pwd which ls which cd管道|带来的子 shell 陷阱一句话重点管道|默认会把管道每一段命令都放到独立子 shell 里面执行。哪怕里面是内置命令cd、export也会被放到子进程修改不会影响外面的 shell1. 先回忆基础内置命令本来是在当前进程执行不 fork。但是只要放在管道|的任意一段bash 就会创建子 shell 去跑这一段代码。子 shell 一个 bash 子进程复制一份环境变量。在子 shell 里面改环境变量、cd只会修改副本退出之后全部消失父 shell 完全不受影响。2. 经典坑示例export 管道# 管道左边执行export内置命令 export TESTold echo new123 | read key val echo $TEST echo $key $val你会发现$key和$val是空的原因拆解echo new123是外部命令开子进程输出字符串read是内置命令但是因为在管道|的右侧bash 依然新开一个子 shell 执行read在子 shell 里面read 拿到值赋值给key val子 shell 执行完毕直接销毁子 shell 里的变量消失回到父 shellkey变量不存在。很多人以为 read 是内置就可以直接改父 shell 变量但是管道让它进了子 shell赋值带不出来。3. 再举 cd 的例子pwd echo /tmp | cd pwd执行完当前目录不变cd是内置命令但是在管道的右边运行在子 shell 里面。子 shell 里面 cd 到 /tmp子进程结束销毁父 shell 目录完全没变。4. 不止管道还有哪些语法会产生子 shell除了|下面这些写法都会创建子 shell内置命令也会隔离( command )圆括号(export ABC123) echo $ABC # 空括号里面是子shell管道|部分 bash 版本的后台执行cmd 也会新建子 shell对比{ command; }大括号不会创建子 shell注意大括号前后要有空格末尾分号不能漏{ export ABC123; } echo $ABC # 能打印出123在当前shell执行5. 为什么 bash 管道要设计成子 shell管道本质左边程序的标准输出作为右边程序的标准输入。两边是两个独立的程序流内核管道需要两个独立进程来读写。所以 bash 最简单的实现方案管道每一段都生成独立子进程。有例外bash 有个选项lastpipe可以让管道最后一条命令跑在当前 shell不是子 shell。但是这个选项默认不开启而且只有非交互 shell脚本里才生效终端交互模式无效所以不要依赖它。Shell 变量 / 环境变量创建、导出、删除核心区分shell 局部变量仅当前 bash 进程可见子进程看不到env命令看不到环境变量当前 shell 后续 fork 出来的子进程都能继承env、printenv可以查到一、设置【Shell 局部变量】语法变量名值等号两边不能有空格# 示例 MY_VARhello NUM123 # 带空格的值必须加引号 MSGhello world✅ 查看局部变量echo $MY_VAR # 或者一次性列出所有shell变量包含局部环境 set❌env看不到局部变量子进程也拿不到。错误写法等号左右空格会报错MY_VAR hello # 不行shell会把MY_VAR当成命令执行二、设置【环境变量】子进程可以继承两种写法效果一样方式 1先定义局部变量再 export 导出推荐MY_VARtest export MY_VAR 导出之后MY_VAR就升级成环境变量env、子进程都能读取。方式 2定义的时候直接 exportexport MY_VARtest✅ 查看环境变量env # 或者 printenv MY_VAR临时一次性给单个命令设置环境变量只对这一条命令生效不改变当前 shell# 只在执行env这个子进程时临时带上MY_TMP999 MY_TMP999 env这条不会修改你当前 shell 的变量仅本次子进程生效。三、删除变量unset 内置命令unset是 shell 内置命令不管是局部变量还是环境变量都可以删掉# 删除变量 unset MY_VAR执行完echo $MY_VAR→ 空env里面也消失⚠️ 注意unset不能删除只读变量。四、完整演示你可以直接复制到终端测试# 1. 创建局部变量 MYE1 echo $MYE # 输出1 env | grep MYE # 找不到因为只是局部变量 # 2. 导出为环境变量 export MYE env | grep MYE # MYE1 出现子进程可以读取 # 3. 删除变量 unset MYE echo $MYE # 空 env | grep MYE # 消失五、持久化可选重启终端还保留上面的方法都是临时的关闭当前终端变量就消失。想要永久生效写到配置文件用户级别只当前用户~/.bashrc或者~/.profilevim ~/.bashrc # 在文件末尾添加 export MYE1保存后加载配置生效source ~/.bashrc全局所有用户/etc/profile需要 root 权限注意set和unset不是一对正反操作很多人直觉上以为set 设置变量unset 删除变量这是望文生义的误解。bash 里命名不是这么配对的两个命令的本源完全不一样。1. unset名字本身就是「取消设置」unset是 POSIX 标准内置命令单词含义un-set 撤销已经 set 好的东西。它的职责删除变量 / 函数。变量一旦被创建就在 shell 内存里unset xxx把这个变量从 shell 内存里拿掉。但是unset 对应的 “set”并不是创建普通变量。2. set 到底是干嘛的远古历史来源回到最早的Bourne Shellsh1979。在最原始 Unix shell 里set的原始含义设置 shell 的内部状态、选项、位置参数 ($1,$2,$3...)不是用来创建普通自定义变量。set 的 3 个完全独立功能同一个命令多重用途不带参数set打印当前 shell 所有变量局部 环境变量set -e / set -x / set -o设置 shell 的运行选项开启 / 关闭 shell 行为set arg1 arg2设置位置参数 $1、$2、$3set apple banana echo $1 # apple echo $2 # banana 你刚才踩坑的set MYE1就是这个功能把$1赋值为字符串MYE1根本不会新建 MYE 变量。✅ 在 sh/bash 的设计里自定义变量根本不需要一个叫 set 的命令来创建直接VARvalue就可以赋值这是 shell 的语法本身不是调用内置命令。重点区分VARxxxshell 语法解析一行命令的时候直接在内存创建 / 更新变量不调用 set 命令set单独内置命令用来修改 shell 状态、位置参数3. 为什么会产生这种违和感误区来源英文单词set 设置unset 取消设置你看到名字自然脑补set 变量新建变量unset 变量删除变量。但 shell 的设计者的语境里set操作的对象是shell 选项、位置参数unset操作的对象是变量、函数set 第二种用法set -e / set -x / set -o xxx一句话概括set 用来修改 bash 自身的全局行为开关shell 选项控制脚本怎么跑、报错怎么处理只对当前 shell 生效子 shell 不受影响。这是写 shell 脚本最常用的 set 用法buildroot 里面大量脚本都会写set -e、set -x。两个写法等价# 短选项最常用 set -e set -x # 长选项写法用 -o set -o errexit set -o xtrace-o就是 option 的缩写set -o 名字短选项是简写。 4 个最核心、最常用选项1.set -e等价set -o errexiterrexiterror exit含义如果一条命令返回非 0命令执行失败shell 直接退出脚本。默认行为不加 set -e# 默认就算cat找不到文件报错脚本继续往下跑 cat nofile.txt echo 继续执行执行cat 报错但是 echo 依然打印。加上set -e之后set -e cat nofile.txt echo 继续执行cat失败脚本直接终止后面 echo 不会执行。⚠️ 坑管道、if 判断里面的命令失败set -e有时候不会触发这个是经典坑。2.set -x等价set -o xtracextrace追踪打印含义执行每一行命令前把解析后的命令打印出来调试神器。打印的行前面会带。示例脚本set -x A100 echo $A输出 A100 echo 100 100buildroot 编译脚本经常用set -x方便看脚本到底执行了什么命令。关闭set x注意是加号关闭用开启用-3.set -u等价set -o nounsetnounset未定义变量报错含义使用一个没有定义的变量直接报错退出。默认情况引用不存在变量当成空字符串不报错echo $NOT_EXIST输出空脚本继续跑。开启set -uset -u echo $NOT_EXIST直接报错bash: NOT_EXIST: unbound variable脚本退出。写严谨脚本必开防止手敲变量名写错。4.set -o pipefail非常重要管道专用默认 bash 管道只看管道最后一条命令的返回码# 管道cat不存在的文件 | grep xxx cat nofile.txt | grep abc echo $?默认cat 失败但是 grep 成功$?返回 0你看不到前面 cat 出错。set -o pipefail开启之后管道里任意一个命令失败整个管道返回失败码。buildroot、CI 脚本几乎都会带上这个。组合写法工程最经典#!/bin/bash set -euo pipefail这一行是 shell 脚本的 “安全三板斧”很多开源脚本开头第一句就写这个。查看所有可选选项set -o会列出所有 shell 选项当前是 on 还是 off。开启 / 关闭规则set -xxx开启该选项set xxx关闭该选项这里不是加是关闭这个地方新手超级容易搞混作用范围只对当前 shell 进程生效。写在脚本里脚本内部生效脚本执行完退出终端恢复原样。在交互式终端敲set -e当前终端生效新开终端不受影响。子 shell括号()里面不会继承父 shell 已经开启的 set 选项。小实验你直接在终端试set -x echo hello set x你会看到执行 echo 那行会打印带的追踪日志set x 关闭追踪。