
很多朋友第一次写完 shell 脚本保存成 demo.sh然后兴冲冲在终端敲一行demo.sh回车结果屏幕上直接来一句command not found。于是跑去搜“shell脚本 执行方式”一搜就出来七八种bash script.sh、./script.sh、source script.sh、bash -c ...、管道喂给 bash、sh -c、exec……看完更懵不都是“执行 shell 脚本”吗至于搞这么多花样吗至于而且差别还不小。搞懂这几种执行方式其实不需要背命令只需要抓住三个关键词子进程、权限、环境变量。这三样想明白了以后拿到任何一种执行方式你自己就能推出它的行为轨迹。本文就用同一个脚本把这 5 种最常见的执行方式逐个跑一遍边跑边验证差异再用一张表做横向对比最后讲讲我踩过的几个坑和日常选型习惯。适合刚入门 shell 脚本、正在看“shell脚本基础知识”的同学也适合写过一阵子脚本但一直没把执行机理弄清楚的兄弟。1. 先回答那个最常被问的问题为什么不能直接敲 demo.sh新手问得最多的不是“有哪几种执行方式”而是“我明明就在当前目录为啥敲demo.sh会报 command not found”。这个问题搞明白了后面一大半概念就通了。1.1 PATH 和当前目录一个必踩的 command not found终端里敲名字执行程序时shell 不会原地瞎找而是按照环境变量PATH里定义的目录列表一个一个目录去找有没有对应的可执行文件。你可以看一下自己机器上的 PATH$ echo $PATH /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin注意这里面没有当前目录.。这是 Linux/Unix 从很早开始就有的安全设计如果默认把当前目录加进 PATH那恶意文件放在你下载目录里你一敲某个重名命令可能就执行到了不该执行的东西。所以哪怕脚本就躺在你脚边你不告诉 shell 它具体在哪shell 就不会主动找。想执行当前目录下的脚本就得明确写出路径最典型的就是$ ./demo.sh./就是在告诉 shell“在当前目录下找这个文件”。你也可以用绝对路径比如/home/you/demo.sh效果一样。1.2 可执行位和 shebang直接跑一个脚本要过两道关写清楚./demo.sh之后新手会遇到第二个报错$ ./demo.sh -bash: ./demo.sh: Permission denied这是因为系统在执行一个文件前会检查它的权限位特别是“可执行”权限。Linux 上的可执行不是看文件后缀.sh而是看x权限位。解决办法就是给文件加上执行权限$ chmod x demo.sh这时候再执行./demo.sh就顺了。但这个“顺”还有一个隐藏前提文件开头那行#!/bin/bash也就是 shebang必须是正确的。当你在终端里执行./demo.sh时内核看到这个以#!开头的文本文件就会调用/bin/bash来解释执行它。所以./demo.sh这种方式对文件有两个硬性要求文件要有x可执行权限文件开头的 shebang 要么存在且指向正确的解释器要么系统能用默认方式兜底。这两个条件任何一个出问题都会直接卡壳。后面第 5 章我会专门讲 Windows 下编辑脚本导致的 shebang 坑这里先点到为止。1.3 执行方式背后的分水岭子进程与当前进程初学者最容易忽略的一个概念是执行脚本时究竟是“原来的 shell 进程去跑脚本”还是“新开一个子进程去跑脚本”绝大多数执行方式是前者——新起一个子进程。脚本里定义的变量、函数、cd 目录变化只存在于子进程里脚本跑完子进程退出一切归零不会污染你当前的终端环境。只有少数方式比如 source是“当前 shell 进程直接去读脚本内容执行”。这种情况下脚本里设置的环境变量会留在你的终端里脚本里cd会真的切换你当前目录脚本里写exit会真的把你的终端干退出。这两种模型就是整个“执行方式”问题的核心。下面进入实操环节。2. 五种执行方式逐个跑一遍同样的脚本不同的结果为了让你看得更直观我先准备一个脚本里面故意放上 PID 打印、目录切换、环境变量导出、时间戳转换和 for 循环方便后面观察五种方式的差别#!/bin/bash # exec_demo.sh 用于演示不同执行方式的差异 echo 脚本内 PID$$ echo 脚本名 argv0$0 echo 启动目录$(pwd) TS$(date %Y%m%d%H%M%S) echo 当前时间戳$TS cd /tmp export DEMO_ENVscript-set-value for i in 1 2 3; do echo for 循环第 $i 次PID$$ done echo 脚本执行完毕这个脚本里用到了date %Y%m%d%H%M%S把当前日期时间转成数字串这是日常写日志文件名特别常用的套路for循环则是 shell 脚本基础里最常碰到的结构。接下来五种方式全都用它测试。2.1 方式一bash demo.sh——用指定解释器临时跑一次命令长这样$ bash exec_demo.sh这种方式的本质是你把脚本文件路径当作参数传给 bash 这个程序由 bash 自己去读取并解释文件内容。好处非常明显不需要可执行权限只要能读文件就行不依赖文件首行的 shebang哪怕文件第一行是空的或者根本没有人写过#!/bin/bash你也能跑行为稳定指定了解释器就按它的语法走不受“默认 shell 是谁”的影响。实测输出大致是$ bash exec_demo.sh 脚本内 PID27321 脚本名 argv0exec_demo.sh 启动目录/home/user 当前时间戳20250108153012 for 循环第 1 次PID27321 for 循环第 2 次PID27321 for 循环第 3 次PID27321 脚本执行完毕注意 PID 是 27321这和你终端 shell 的 PID 大概率不是同一个。脚本在里面怎么折腾目录、怎么 export 变量跑完就结束了你的终端环境一点不受影响。如果你想在 Ubuntu 上临时跑个脚本又不想管权限问题bash script.sh是最省心的选择。但提醒一句sh script.sh和bash script.sh不是一回事在 Debian/Ubuntu 系列上/bin/sh通常指向 dash语法能力比 bash 弱比如不支持数组、source这种写法脚本一旦用了 bash 特有语法sh跑就会报错。2.2 方式二./demo.sh——最接近“运行程序”的体验先给脚本加执行权限然后$ chmod x exec_demo.sh $ ./exec_demo.sh输出大体一样但有一个细节值得留心脚本里的$0会显示成./exec_demo.sh而不是exec_demo.sh。$0就是脚本被调用时的 argv0这个值在实际工程里很有用因为你可以靠它反推出脚本所在的目录DIR$(cd $(dirname $0) pwd)这样不管脚本从哪个目录被调用都能找到自己“老家”的位置后面引用同目录的配置文件、模板文件就不会跑偏。这是写正式脚本时非常重要的一招。./demo.sh这种方式本质上依赖于内核解析 shebang 并拉起解释器。它的优点是执行机制和运行一个系统命令一致最符合“命令”的直觉用户不需要关心解释器到底叫 bash 还是 zsh脚本自己指定脚本可直接放进/usr/local/bin后在任意位置敲名字执行。缺点是门槛高权限没加不行shebang 错了不行。而且如果换目录执行./的路径写法也可能导致找不到文件。如果文件没有 shebang但你又给了它可执行权限执行时系统会返回 ENOEXECbash 会尝试用当前 shell 去解释这个文本文件。不同 shell 行为不完全一致所以正式脚本一定别省 shebang 这一行。2.3 方式三source demo.sh——在当前 Shell 里加载执行命令写法有两种完全等价$ source exec_demo.sh # 或者 $ . exec_demo.sh.是 POSIX 标准写法source是 bash 的扩展。执行之后再看输出你会发现一个非常关键的差异——PID 和你的当前 shell 完全相同$ source exec_demo.sh 脚本内 PID27012 脚本名 argv0bash 启动目录/home/user ...如果你在执行前先看下当前 shell PID$ echo $$ 27012发现一模一样。这就是 source 的本质它不是一个新开进程去跑脚本而是把脚本内容一行一行读进来像是你直接在终端里敲了这些命令一样。好处也在这里脚本里export DEMO_ENVscript-set-value执行完之后这个变量会残留在你当前终端里$ echo $DEMO_ENV script-set-value同时脚本里的cd /tmp也让你的当前目录真的变成了 /tmp$ pwd /tmp这种“加载式”执行最典型的应用就是加载配置和公共函数库。比如每次安装完新软件你大概率执行过source ~/.bashrc或. /etc/profile本质就是把配置文件里的环境变量、alias 加载到当前 shell 里来。但 source 是一把双刃剑脚本里如果有exit 3你不是退出一个子进程而是直接把这个终端会话给关了。我在第 5 章会专门讲这个坑。2.4 方式四bash -c ...——把脚本内容当字符串传给解释器前面三种方式的核心对象都是“文件”但 bash 其实可以直接执行一个字符串参数$ bash -c echo 当前身份是 $0; echo PID 是 $$; echo 时间戳是 $(date %Y%m%d%H%M%S)实测输出当前身份是 bash PID 是 27534 时间戳是 20250108153012这里的重点是引号到底用单还是双。如果你用双引号$ bash -c echo $(date)你会发现这个$(date)是在当前 shell 先被展开然后把结果字符串传给 bash -c 去执行。有时候这会造成“到底谁在执行这条命令”的困惑。想确保里面的变量、命令替换都在新的 bash 子进程里解析就尽量用单引号把整段命令包起来。这种方式在日常工作中太常用了。最常见的场景是远程执行ssh userhost bash -c echo hi在管道、cron 里临时拼一段动态命令想在一个干净的 bash 环境里跑一段代码避免当前 shell 环境干扰。如果你只是想把一个脚本文件跑起来一般不用绕这一层直接在bash -c里再调用脚本文件即可$ bash -c ./exec_demo.sh本质上和直接./exec_demo.sh行为一致但多包了一层 bash 子解释器。2.5 方式五cat demo.sh | bash——从标准输入执行最后一种更“野”的方式是把脚本内容通过标准输入喂给 bash$ cat exec_demo.sh | bash或者用输入重定向$ bash exec_demo.sh两者效果基本一样bash 会从 stdin 读取命令来执行。它同样不需要可执行权限也不需要 shebang 有多正确因为解释器你自己指定了。这种方式最大的价值在于脚本甚至可以不存在于本地磁盘上。比如我们常看到的$ curl -sL https://example.com/install.sh | bash还有一个日常调试非常好用的兄弟命令配合 heredoc 使用$ bash -s EOF echo heredoc 里的命令 TS$(date %Y%m%d%H%M%S) echo 时间戳: $TS EOFbash -s表示“从标准输入读脚本”后面的参数会作为脚本的位置参数传进去。heredoc 里的EOF加了单引号能确保$TS这些变量在当前 shell 阶段不做展开完整交给 bash 子进程解析。这种写法在快速验证一段逻辑、或者写自动化流水线的时候非常顺手。3. 一张表看懂差异进程、权限、环境变量如何联动五种方式都跑完信息量有点大我用一张表把核心差异收敛起来。这张表建议你直接存下来比死记硬背命令有用得多。3.1 核心差异对照表执行方式是否新起子进程需要 x 权限依赖文件 shebang脚本内 $0变量/函数能否保留到当前 Shell读取文件方式bash demo.sh是否否脚本路径否bash 直接读文件./demo.sh是是是./demo.sh否内核按 shebang 拉起解释器source demo.sh/. demo.sh否否无关当前 shell 名是当前 shell 逐行加载bash -c ……是不直接涉及文件不直接涉及文件默认是 bash否解释字符串参数cat demo.sh | bash/bash demo.sh是否否通常显示 bash否通过标准输入读取这里要补充一个容易混淆的点表格中bash -c ……那一行的特性指的是“直接执行字符串”的情况如果你在字符串里再调用./exec_demo.sh那被调用的脚本仍然按照自己的方式去执行。所以判断执行方式时要看“最终那一步脚本内容是怎么被解释的”。3.2 从 PID、退出码和环境变量验证差异理论说一千遍不如亲手验证一遍。我常用的验证组合有三个。第一看 PID。脚本里打印$$终端里也打印$$然后分别用bash demo.sh和source demo.sh执行差异一目了然。第二看退出码。先造一个退出脚本cat exit_demo.sh EOF echo 我要退出啦 exit 3 EOF然后用bash exit_demo.sh脚本跑完你的终端还活着而且能拿到退出码$ bash exit_demo.sh 我要退出啦 $ echo $? 3如果用source exit_demo.sh那就是当场把终端送走建议你放在子 shell 里安全测试$ ( source exit_demo.sh ) 我要退出啦 $ echo $? 3括号( ... )会新起一个子 shell在子 shell 里 source 脚本退出码被外面捕获当前终端毫发无损。这个技巧本身也很实用。第三看环境变量。脚本里export DEMO_ENVscript-set-value用bash exec_demo.sh跑完之后$ echo $DEMO_ENV 空用source exec_demo.sh跑完之后$ echo $DEMO_ENV script-set-value这个现象后面隐藏的规则是环境变量的传递方向是父进程传给子进程子进程里怎么改都传不回父进程。子进程拿到的是父进程环境的“复印件”在复印件上涂抹原件不会有任何变化。source 的本质不是复印件是直接拿原件书写所以改动留了下来。3.3 环境变量“穿透”和“隔离”的实际影响理解了“复印件”和“原件”的区别很多烦心事就能提前避开。比如你写了一个部署脚本里面export JAVA_HOME/opt/jdk17。如果你用它bash deploy.sh跑完你在终端里敲java -version用的还是原来的版本。这不是脚本没生效是它生效在了一个和你终端无关的子进程里。反过来如果你用source deploy.sh那脚本里cd、export、unset、alias、umask全部会“穿透”到你当前环境改目录、改变量、改默认权限掩码。有时候这正是你想要的比如配置开发环境但有时候你会被自己坑到——脚本里一个无意的cd就能让你的下一个操作在完全不同的目录里执行。我这里的原则很简单要隔离就 bash要穿透就 source。拿不准的时候默认用 bash。4. 实战选型哪种方式对应哪种场景讲完原理聊点实在的日常写脚本、调试脚本、部署脚本到底该用哪种4.1 日常验证和临时测试首选 bash如果你正在起草一个脚本或者需要快速验证一段逻辑能不能跑通bash script.sh是我的默认选择。原因就俩字省事。不需要chmod不需要管 shebang写错了直接改改了再跑没有任何额外负担。哪怕是正式脚本在没有确定最终交付形态之前用 bash 执行来调试也完全没有问题。开发期和生产期可以分开开发期用 bash 脚本路径交付期再配好 shebang 和权限。4.2 加载配置和复用函数必须 source凡是“影响当前环境”的需求就要毫不犹豫地 source。场景很具体加载/etc/profile、~/.bashrc、~/.profile让环境变量立即生效加载公共函数库比如source /opt/lib/common.sh然后直接调用里面定义的函数初始化当前 shell 的开发环境比如设置 Python 虚拟环境、切 Node 版本、加载 SDK 路径。这个场景下如果误用了bash config.sh那所有变量和函数都只活在那个短暂子进程里等脚本退出一看一切照旧白跑。这也是为什么很多人第一次写环境初始化脚本总觉得“没生效”多半就是执行方式选错了。4.3 作为系统命令分发用 ./ shebang当脚本要正式交付给其他人使用或者自己打算把它放进~/bin、/usr/local/bin全局使用这时候就不要再用bash script.sh了而是走标准流程chmod x myscript.sh sudo cp myscript.sh /usr/local/bin/myscript之后在任意目录直接敲myscript就能运行。这里的./script.sh只用于测试真正让它“命令化”的关键是可执行权限、shebang、以及把它放到 PATH 能找到的目录里。脚本首行#!/usr/bin/env bash比#!/bin/bash更通用因为它会去 PATH 里找 bash在 macOS 这类环境上容错性更好但前提是 PATH 里确实有 bash。这点在 cron 这类最小环境里要小心所以 cron 里我反而更喜欢写绝对路径。4.4 远程、管道和自动化场景用 bash -c / stdin写自动化时你经常没有权限或者没有意愿在目标机器上保存一个脚本文件。这时候bash -c和 stdin 方式的优势就体现出来了$ ssh userhost cd /var/log grep ERROR app.log | tail -20这就是一个典型的“远程 字符串执行”。想传更复杂的多行脚本给远程机器可以利用 stdin 方式$ ssh userhost bash -s local_script.sh本地文件 local_script.sh 的内容会被远程 bash 从 stdin 读取执行本地脚本还能拿到远程机器上相对路径的环境。这种方法我在批量运维时经常用省去先上传再执行、执行完再清理的工作量。4.5 特殊方式 exec 和后台执行严格说exec demo.sh不算这 5 种主流的并列项但它值得单独了解因为很多新手会把exec和bash搞混。exec是非常特殊的执行它会用一个新的程序替换掉当前进程不额外创建子进程进程 PID 不变。你在终端里敲exec bash终端直接变成一个新的 bash 会话你在脚本最后写exec ./deploy.sh则是让当前脚本进程被 deploy.sh 顶替掉。这种行为在容器启动脚本、登录脚本里很常见设计意图是“进程不退出使命交接”。和 source 最大的区别是source 是当前 shell 继续跑exec 是当前 shell 彻底换人。后台执行则是另一个维度的问题和“用什么解释器”正交$ nohup ./start.sh run.log 21 nohup让脚本在终端关闭之后还能继续跑放到后台 run.log 21把输出落盘。注意这里我仍然用的是./start.sh说明后台执行不改变执行方式的进程模型它只是“放到后台调度”而已。5. 最容易翻车的几个细节每条都是真金白银的教训这章写的是我实际踩过、或者看别人踩过最多的坑。每一条都很碎但碎坑最致命。5.1 source 一个含 exit 的脚本终端直接没了这个坑我提过好几次因为它是五级地震级别的体验。有次我在一台服务器上写环境初始化脚本脚本中间有一行exit 1用来做错误中断。我用source init_env.sh跑想让环境变量留在终端里。结果脚本执行到exit 1整个 SSH 会话立刻断开连接中断我一脸懵地坐在本地终端前。原因非常直接source 是在当前 shell 里执行脚本里的exit就等同于当前 shell 自己退出。一个普通子进程跑exit只是子进程退出父 shell 毫发无损但 source 没有“子进程”这层护城河。正确做法是如果需要加载环境变量同时还要处理错误退出脚本内部不要直接写exit而是返回退出码让调用方决定怎么办# 写法一脚本里用 returnsource 后不会退出当前终端 if [ ! -f config.sh ]; then echo 配置文件不存在 return 1 fi # 写法二调用方放在子 shell 里测试避免被清场 ( source exit_demo.sh )5.2 脚本里的 cd执行方式不同目录走向天差地别脚本里带cd是很常见的事但这个cd的影响范围完全取决于你怎么执行它。我用前面的 exec_demo.sh 演示过bash exec_demo.sh跑完当前终端目录不变因为 cd 发生在子进程里source exec_demo.sh跑完当前终端目录变成 /tmp因为每一条命令都在当前 shell 里生效。切换到子目录去操作还好说要是脚本里写了cd /或者cd ~source 完之后你都不知道自己现在身在何处。所以我写脚本时有个习惯无论脚本预期用哪种方式执行凡是会改变目录的操作尽量放在子 shell 里执行防止意外污染调用方环境。# 在子 shell 中切换到目标目录执行不影响当前目录 ( cd /target/dir || exit 1 ./do_something.sh )5.3 Windows 下编辑脚本导致的 CRLF/BOM 问题这个坑很多人第一次遇到时完全摸不着头脑。在 Windows 上拿记事本或某些编辑器写脚本保存后传到 Linux 执行文件里每行末尾都会带着一个\r回车符。你执行./demo.sh时内核去读 shebang 那一行看到的不是#!/bin/bash而是#!/bin/bash\r于是报错说解释器不存在-bash: ./demo.sh: /bin/bash^M: bad interpreter: No such file or directory^M就是那个\r的显示形态。排查方法是用file命令看文件类型$ file demo.sh demo.sh: Bourne-Again shell script, ASCII text executable, with CRLF line terminators看到with CRLF line terminators就说明是 Windows 风格换行了。处理方式很无脑但有效$ sed -i s/\r$// demo.sh跑完再file一下如果不再显示 CRLF就正常了。防止这个问题的根源办法编辑器统一开“保存为 Unix 换行”的选项别让你的脚本文件在 Windows 和 Linux 之间反复横跳。另外还有一个少见但更隐蔽的问题如果文件开头带了 BOMUnicode 字节序标记内核同样解析不了 shebang。所以脚本文件务必用纯 UTF-8 无 BOM 保存别让编辑器画蛇添足。5.4 cron、nohup 里执行脚本的注意事项定时任务和后台任务里执行脚本常见翻车点都是环境变量。cron 默认的 PATH 通常只有/usr/bin:/bin这种最小集而你在交互终端里正常使用的那些目录、函数、aliascron 一概不认。所以 crontab 里写命令时我的习惯是脚本内部显式写#!/bin/bash不给系统猜的机会crontab 里写绝对路径不要写./demo.sh如果脚本依赖自定义环境变量在脚本开头显式 export或者 source 对应的环境配置文件输出重定向到日志文件比如 /var/log/myscript.log 21不然出问题连报错都看不到。nohup 后台跑也有类似情况。以前我用nohup ./start.sh 开启服务然后关掉 SSH以为万事大吉结果发现服务一分钟后就退出了。原因往往不是 nohup 没用而是脚本里的某个命令依赖了当前 shell 的某个环境变量终端一关环境没了命令失败。正确姿势是把环境变量和启动逻辑都写进脚本里让脚本自包含然后再 nohup 执行$ nohup /opt/app/start.sh /opt/app/run.log 21 5.5 我个人的习惯一个极少写进文档的实用组合压轴分享一个我常用的纯个人习惯组合用 heredoc bash -s 做快速稿代替一次一次创建临时文件。比如我需要临时拼一段逻辑不想为了十行代码专门建一个 .sh 文件也不想去按上下箭头找回写过的历史就写$ bash -s EOF TS$(date %Y%m%d%H%M%S) DIR/tmp/logs mkdir -p $DIR echo 当前时间戳: $TS echo 日志目录: $DIR for file in $DIR/*.log; do [ -e $file ] echo 处理 $file done EOF每次敲完bash 子进程干净执行环境不残留调试迭代很快。等逻辑确认为正式脚本再把它抽成一个带 shebang、带权限、带参数校验的真正文件。还有一个调用技巧想让 heredoc 里的变量在当前 shell 先展开一部分另一部分留给 bash 子进程解析可以在 heredoc 标记里做文章。EOF是全部不做展开EOF是全部先让当前 shell 展开一次。一定要想清楚自己到底要哪一边这个细节很微妙但用对了能省很多转义功夫。说实话执行方式这件事折腾明白之后回头看真的没什么高深内容。它不像算法题需要智商更像是一张地图知道了哪个方式对应哪条路前面有什么陷阱剩下就是肌肉记忆了。希望这篇把“子进程、权限、环境变量”讲透的文章能帮你少走几次我当年走过的弯路。