很多人刚打开 Linux 终端时,第一个困惑往往不是某个命令怎么写,而是“我到底在跟谁说话”。你说它叫 shell,可系统里明明又有个 bash;Windows 上装了 Git Bash,打开以后又发现跟 Linux 的终端长得差不多。折腾两天,命令记住了不少,回头问一句“shell 到底是什么”,还是答不上来。这篇文章就打算把这件事讲透:你敲进去的每一行字经历了什么、bash 在其中扮演什么角色、写脚本时那些总出问题的细节到底卡在哪儿。不管你是准备转运维的新人、写前端的同学想在 Windows 下配环境,还是刚接触 EDA 工具、每天要面对 dc shell 这类交互环境的工程师,看完整篇,再遇到命令行也不会觉得是在“盲敲”。
1. 先从“什么是 Shell”说起
1.1 内核、命令与“翻译官”的关系
每次都有人把 shell 和内核搞混,这俩其实差得很远。操作系统真正干活的核心叫内核,它管着 CPU、内存、硬盘、网卡这些硬件,但你没法直接跟内核对话,因为内核只认识系统调用那套接口,不理解人类语言。Shell 就是夹在中间的那层翻译官:你把ls -l敲进去,shell 把这句话拆开、识别、执行对应的程序,然后把结果重新翻译成你能看懂的字符流吐回终端。这个过程和你找外国朋友帮忙跟本地人问路一模一样——你说中文,朋友翻译成当地语言,对方回答后朋友再翻回中文给你。没有 shell,你就只能对着二进制文件发愁了。
所以广义上的 shell 不只是命令行,它本质上是一类“人机接口”程序。你在 Windows 上用的资源管理器其实也是一种 shell,叫 GUI shell;我们平时说的命令行 shell 是文本式的。常见的命令行 shell 有好几个:Linux 上最流行的是 Bash,还有 Zsh、Fish、Ksh;Windows 上有 CMD 和 PowerShell;macOS 默认从 Catalina 开始也切到了 Zsh。虽然它们长得不太一样,但干的事情都是同一件:读取输入、解析命令、执行程序、输出结果。
1.2 终端、Shell、Bash 这三者到底什么关系
很多翻车事故就是从这个概念不清开始的。终端(Terminal)是你眼前那个窗口,它负责显示字符、接收键盘输入,本质上是操作系统里的一个虚拟设备。Shell 跑在这个终端里面,负责把输入内容解释给内核听。Bash 只是众多 shell 中的一种,全称 Bourne Again SHell,是 GNU 项目对经典 Bourne Shell(sh)的重写和增强。
用一句话理清这三者:你打开 Windows Terminal 或者 macOS 的 Terminal.app,只是一个“窗口”;窗口里默认运行的那个解释器是 bash;你说的每一句“话”(命令)由 bash 解释后交给系统执行。Linux 发行版默认把 bash 配给用户当登录 shell,所以大家一提 shell 脑子里自然就蹦出 bash,这很正常,但你要清楚 bash 不是 shell 的全部。后面我会提到 Android 的 adb shell,它又是一个完全不同的环境,这就是“都是 shell,但此 shell 非彼 shell”的典型例子。
1.3 为什么 Linux 生态里 Bash 成了默认选择
这里有个历史合理性。Bourne Shell(sh)是 Unix 早期最成功的脚本解释器,几乎所有系统管理脚本都基于它写。Bash 写了大量兼容 sh 的特性——变量赋值、函数、流程控制、管道、重定向这些语法跟 sh 保持一致,所以老的 sh 脚本拿 bash 也能跑,生态不会断裂。同时 Bash 又补了一堆 sh 没有的实用功能:数组、[[条件表达式、${var:-default}这类字符串操作、$((算术展开))等等。
另一个现实原因:Bash 是自由软件,几乎所有 Linux 发行版都默认内置,你换个发行版、换台服务器,脚本大概率还能跑。Zsh 和 Fish 虽然交互体验更好,自动补全更强,但兼容性不如 Bash。如果你在服务器上写脚本,最稳妥的选择仍是 Bash。再说,很多工具提供了“某种 shell”风格的交互界面,比如 EDA 领域的 dc shell、synopsys 那套工具链、还有数据库的 sqlplus,它们的精神内核跟 Bash 是相通的:都是一套“读入-解析-执行”的循环。理解了 Bash,你再去摸那些专用 shell,上手会快很多。
2. Bash 到底是怎么工作的
2.1 一行命令进去之后发生了哪些事
很多人以为 bash 拿到一行字符就是“找到程序塞进去”,其实它先要做一连串的解析和展开。比如你敲:
echo "当前目录:$PWD,文件数量:$(ls | wc -l)"bash 不是直接把双引号里的内容原样传给 echo,而是先识别出$PWD要替换成当前目录的路径,$(ls | wc -l)要先执行这个管道命令、把标准输出替换进去,然后才把最终组装好的字符串交给 echo 打印。
展开的顺序有讲究:先做花括号展开{1..10}和波浪号展开~,然后是参数展开$var、命令替换$(...)、算术展开$((...)),接着按 IFS 做单词分割,再做路径通配符展开*.log,最后才执行。很多新手踩的坑,比如“变量值里有空格怎么传参”“为什么 $var 会被拆成多个词”,根源都在这个展开顺序上。理解展开的顺序,比背二十个命令都有用。
2.2 命令的四种类型和查找顺序
bash 里能执行的“命令”其实分四种,优先级从高到低:别名(alias)、函数(function)、内建命令(builtin)、外部命令(外部程序,按$PATH找)。举个例子,你自定义了一个叫ls的别名,那么你敲ls时执行的就不是/bin/ls,而是别名定义里头那条命令;如果你把别名删了,又定义了一个叫ls的函数,那么先跑函数;函数删了,再用 bash 内建的ls? 不,bash 没有内建 ls,所以去哪找?去$PATH里找/usr/bin/ls。
年代久一些的服务器上时不时出现这种诡异事:明明装了某个软件,敲命令却提示command not found。原因通常就是那个软件的安装路径没加到$PATH。$PATH是一堆用冒号分隔的目录列表,bash 会从左往右在这些目录里找一个可执行文件,找到了就用,找不到就报错。排查这种问题有两个好工具:type 命令名能告诉你这个命令最终会被解释成什么;command -v 命令名在脚本里判断命令是否存在非常好用。我写脚本时习惯先command -v curl || { echo "缺少curl"; exit 1; },比which更规范。
2.3 退出码:脚本逻辑的真正“幕后裁判”
命令行里经常出现$?这个变量,它保存上一条命令的退出码。Linux 约定:0 表示成功,非 0 表示失败,具体非 0 的值由程序自己定义。这个机制是 bash 里if能做判断的基础——你写:
if grep -q "error" /var/log/app.log; then echo "日志里有 error" fiif后面跟的其实不是“真假条件”,而是一条命令;判断标准就是这条命令的退出码是不是 0。grep 找到了匹配就返回 0,没找到就返回 1,于是 if 分支是否执行就直接跟 grep 的退出码挂钩了。很多教程一上来就讲[[ ... ]]怎么写,却不讲这个底层逻辑,导致很多人想不通为什么if grep能成立。把退出码这个概念记牢,写脚本时会少走很多弯路。
3. 变量、引号与展开:脚本里的基本功
3.1 变量定义最容易犯的错
bash 定义变量的语法是变量名=值,注意等号两边绝对不允许有空格。name = "Tom"在 bash 里不是赋值,而是执行一个叫name的命令,后面带着两个参数,于是又回到 command not found 的坑里。这是新手写脚本报错率最高的一幕,没有之一。
变量名区分大小写,传统约定是普通变量用大写或小写都行,但环境变量和常量一般用大写,比如PATH、HOME、MY_CONFIG。读取变量时用$var或${var},两者在单独使用的时候没区别,但当变量后面连着别的字符时差别就大了:$var_suffix会被解析成变量var_suffix,而${var}_suffix才是你想要的var的值后面加下划线和 suffix。这也是为什么老手在脚本里几乎永远写${var}而不是$var。
3.2 单引号、双引号、反引号,一次搞清楚
这个知识点几乎人人迷惑过。简单记:单引号包裹的内容是“全字面量”,里面不管写$、反引号还是*,统统原样输出,不做任何展开;双引号包裹的内容里,$变量、$(命令)和反引号内的内容依旧会被展开,但通配符*不会被展开;反引号的意思是把里面的内容当作命令执行,然后把标准输出替换到当前位置。
| 写法 | 作用 | 示例输出(假设 var=world) |
|---|---|---|
'hello $var' | 单引号全字面 | hello $var |
"hello $var" | 双引号中文值 | hello world |
echo $(date) | 命令替换 | 当前系统时间 |
echo \date`` | 老式命令替换 | 当前系统时间 |
现在$(...)是更推荐的做法,因为反引号在嵌套时极其痛苦而且容易看错。双引号包裹变量还有一个重要作用:防止变量值里的空格被拆成多个参数。比如一个文件名叫my file.txt,你直接rm $file会被拆成rm my file.txt两条独立参数,而写成rm "$file"才会当作一个整体名字。
3.3 ${} 和 $():一个管变量,一个管命令
热词里专门有人搜这条,说明这俩太容易混了。用一句话区分:${}是变量展开,取的是一个“值”;$()是命令替换,取的是一条命令的“输出”。比如:
version="1.2.3" echo "${version}_beta" # 输出 1.2.3_beta echo "$(echo hello)" # 先执行 echo hello,得到 hello,再输出 hello${}还有一堆高级用法,是写脚本时很省力的工具:
${var:-默认值}:变量为空时用默认值,很适合给配置项兜底。${var:=默认值}:变量为空时不仅用默认值,还会把默认值重新赋值给变量。${#var}:取出变量的字符长度。${var//旧/新}:把变量值里所有“旧”替换成“新”,常用于字符串清洗。${var:0:5}:取子串,类似切片。
这些语法和$()长得像但用途完全不同,接受这一点之后,再写脚本时思维就清晰了。
3.4 位置参数、$@、$* 和 shift
脚本执行时可以带参数,比如bash deploy.sh prod us-east-1。在脚本内部,第一个参数放进$1,第二个放进$2,以此类推;$0是脚本本身的名字;$#是参数个数;$@和$*都表示“所有参数”,区别在细节:$@把每个参数当作独立的词,而$*把所有参数合并成一个字符串。实战中遍历参数我从来只用for arg in "$@",不会碰$*,因为带空格的参数会被拆开,这是老油条的共识。
shift命令的作用是“把所有位置参数集体左移一位”,shift之后原来的$2变成$1,$3变成$2,以此类推,$#也跟着减一。它特别适合用来写“边取参数边消费”的循环。比如某个脚本接收多个文件路径,每个路径后面可以跟一个-o指定输出文件名,那结构就这样:
while [ "$#" -gt 0 ]; do case "$1" in -o) output="$2" shift 2 ;; *) files+=("$1") shift ;; esac done4. 写脚本:从一行命令到一个文件
4.1 Shebang 到底该怎么写
脚本文件第一行通常写#!/bin/bash,这一行叫 shebang(井号加感叹号)。它不是一个注释那么简单,它的作用是告诉操作系统:当你用./script.sh直接执行这个文件时,应该调用哪个解释器来运行它。你在这个目录下敲脚本路径,内核看到第一行是 shebang,就会去启动对应的解释器。
有人会写#!/usr/bin/env bash,这种方式是先去找env程序,再在PATH里定位bash。好处是兼容那些 bash 装在不同路径下的系统,比如某些 macOS 上 bash 在/usr/local/bin/bash而不是/bin/bash。但注意env方式在跨平台脚本里也有翻车风险——有些系统env路径不是/usr/bin/env。我个人的偏好:如果脚本只在自己公司的 Linux 服务器上跑,直接写死#!/bin/bash更稳妥;如果要发给别人在不同环境用,用/usr/bin/env bash更通用。
另外强调一下:Debian 系发行版里/bin/sh通常指向 dash,不是 bash。如果你脚本第一行写#!/bin/sh,那么 bash 专有的语法(数组、[[ ]]、${var//}等)全会报错。很多人把#!/bin/sh和#!/bin/bash混着用,最后在 Ubuntu 上炸了锅,其实根源就在这里。
4.2 三种执行方式,差别比你想象的大
执行一个脚本有三种常见方式,它们的行为被很多新手忽略:
bash script.sh:显式用 bash 解释执行。此时脚本不一定需要可执行权限,shebang 也被忽略(因为已经指定了解释器)。./script.sh:需要文件有可执行权限,系统根据第一行的 shebang 找到解释器。source script.sh或. script.sh:在当前 bash 进程里直接执行脚本,而不是新开一个子进程。
第 3 种方式最关键的区别是:source不会创建新进程,脚本里的变量、函数、目录切换会直接影响你当前这个 shell。这也就是为什么你改完.bashrc必须source ~/.bashrc才能立刻生效,因为.bashrc是给当前 shell 会话用的,如果你直接bash ~/.bashrc,那份改动只会发生在子进程里,子进程退出后一切还原。同理,如果你想把某个.env文件里的变量加载进来,也得用source。
4.3 For 循环:最常用也最容易出错的写法
十个人搜“shell 脚本 for 循环”,九个人在找语法。bash 里的 for 循环常见有三种形态:
第一种,遍历一组静态元素:
for env in dev test prod; do echo "deploy to $env" done第二种,遍历一个命令的输出或者文件的每一行:
for ip in $(cat servers.txt); do ping -c 1 "$ip" > /dev/null && echo "$ip ok" || echo "$ip down" done这里要注意:$(cat servers.txt)出来的内容会按照 IFS(默认是空格、制表符、换行)被拆成多个词。如果文件每一行是一个包含空格的路径,那这种写法就会炸。遇到这种情况,更稳的方式是用while IFS= read -r line; do ...; done < servers.txt。
第三种,C 风格的 for 循环,适合纯数字区间:
for ((i=1; i<=10; i++)); do echo "第 $i 次" doneC 风格循环里没有$符号,变量i直接写,i++也是 C 语言语法。很多人把$i带进去写for (($i=1; $i<10; $i++))然后报错,原因就是没搞明白这里已经是“另一套方言”了。
4.4 If、While、Case 和函数的基本盘
if 语句的核心是判断退出码,前面说过。而判断条件本身常用[ ]或者[[ ]]。[ ]是 test 命令的别名形式,里面要有空格:[ -f "$file" ],变量加引号是必须的,否则文件名为空时整个表达式会出问题。[[ ]]是 bash 的关键字,支持模式匹配和正则,还不需要给变量加引号,更安全。写 bash 脚本时我几乎只用[[ ]],只有在需要兼容 sh 环境时才会退回[ ]。
while 循环最常用的场景之一是按行读取文件:
while IFS= read -r line; do echo "读到:$line" done < /etc/hostsIFS=的意思是这一行不拆词,-r防止反斜杠被转义,两个细节都能避免脏数据破坏逻辑。case 语句做菜单选择非常直观:
case "$1" in start) echo "启动...";; stop) echo "停止...";; restart) echo "重启...";; *) echo "用法: $0 {start|stop|restart}"; exit 1;; esac函数的定义有两种等价写法:function foo {}和foo() {}。我习惯用后者,因为它在 sh 里也可用。函数里可以用return返回退出码,注意return只能影响当前函数退出码,不能直接退出整个脚本;想退出整个脚本得用exit。
4.5 Grep、Find、xargs 在脚本里的配合
单独用 grep 谁都会,但要放在脚本里做判断时,记住两条经验:一条是 grep 默认会把“匹配到的行内容”打印到标准输出,如果你只想知道“有没有匹配”,一定加-q静默模式,省去大量无意义的输出。另一条是 grep 在管道里配合-E用扩展正则,否则你得写一堆转义符,非常痛苦。
find 处理文件列表时,优先用find . -name "*.log" -exec rm {} \;或者-delete,而不是在管道里接 xargs。原因很简单:文件名可能带空格,管道拆词会有问题。xargs 虽然能通过-0配合 find 的-print0规避这个坑,但对新手来说,-exec的写法更不容易出错。不过,如果要对海量文件执行复杂逻辑,xargs 的并发能力还是有优势的。一句话总结:简单清理用 find -exec,批量处理后再走 xargs。
5. 几个高频问题深挖:shift、expect、set 与后台任务
5.1 Shift 在参数处理中到底怎么用
前面提过 shift 是左移位置参数,但它真正的价值在于让“参数解析”可以写成循环。比如你写一个安装脚本,想支持--prefix=/opt/app、--debug、--yes这类参数,每次判断完一个参数就把这批参数整体移走,循环就能始终只用$1来判当前参数,逻辑会非常清爽。
while [ "$#" -gt 0 ]; do case "$1" in --prefix=*) prefix="${1#*=}" shift ;; --debug) debug=true shift ;; --) shift break ;; *) echo "未知参数: $1" >&2 exit 1 ;; esac done注意这里用了${1#*=}这个字符串操作,意思是从左边删除匹配到=的部分,剩下的就是=后面的值。这种写法比先--prefix */再单独读$2要稳健得多。
5.2 Expect:处理交互式密码输入的现状
热词里有“shell 脚本要在后台执行,还要交互式输入密码”,这个需求看起来矛盾,其实解决思路就是expect。Expect 是一个专门用来“对话”的工具,它可以启动一个子程序,监听它的输出,然后按预设规则回送输入。核心语法就四件事:spawn启动目标程序,expect等待期望的字符串出现,send发送输入,interact把控制权交回用户。
一个常见的入门示例:
#!/usr/bin/expect set timeout 30 spawn ssh user@host "ls /var/log" expect { "password:" { send "mypassword\r" } "yes/no" { send "yes\r" exp_continue } } expect eof写 expect 脚本有几点心得:第一,expect后面跟的花括号块里,每条匹配规则后面要加exp_continue,表示匹配完继续等下一个输出;第二,密码硬编码在脚本里非常危险,建议从环境变量读取,比如send "$env(SSH_PASS)\r";第三,expect 像其他 shell 工具一样,调试时先用exp_internal 1打开内部日志,你会看到它到底卡在哪一步。注意,expect 在 macOS 上默认不自带,需要brew install expect,服务器上一般也要单独装。
5.3 Set -euxo pipefail:这套参数到底值不值得开
很多脚本第一行就写set -euxo pipefail,把这几个参数全开了。它们的意思分别是:-e任何命令返回非 0 立即退出脚本;-u使用未定义变量直接报错;-x打印每条实际执行的命令,方便调试;-o pipefail让管道中任何一条命令失败都让整条管道返回失败。听着全是好事,但我在生产环境见过因为set -e引发的事故:某条命令返回了非 0,但脚本以为它已经华丽结束,直接往下跑却什么也没跑成,排查起来极度恼火。
我的建议是:脚本里可以开-u和-o pipefail,但-e要谨慎。如果确实想用-e,那么一定配合“明确预期失败”的地方,比如set -e下想处理 grep 查不到任何行的情况,就要写成if grep -q ...; then ...; else ...; fi,把失败包进 if 判断里,这样-e不会误杀。-x只在开发调试时开,正式脚本里留着就是噪音。
5.4 后台执行与输出重定向的规范姿势
后台执行最原始的方式是命令后面加&,例如bash deploy.sh > deploy.log 2>&1 &。这里>是把标准输出重定向到文件,2>&1是说把标准错误也指向标准输出所在的位置,也就是同一个文件。如果你漏掉2>&1,脚本报错信息会直接丢掉,或者产生一个空的输出文件却不知道发生了什么。
后台任务有两个常用命令:jobs查看当前终端挂起的后台任务列表,fg把一个后台任务拉回前台。如果你希望关闭终端后任务继续跑,就得上nohup command &,nohup 的意思是“忽略挂断信号”,配合&让进程脱离当前终端的会话控制。现在更推荐的方式是systemd-run --user或者 tmux,但 nohup 在所有环境里都可用,作为基础技能还是得会。
“后台执行还要交互式输入密码”这个需求,本质是把交互从“人机交互”转成“程序化交互”,前面的 expect 就是干这个的。还有个别场景(比如 sudo)可以临时用sudo -S从标准输入读密码,但安全性和可控性都不如 expect。
6. 常见问题与排查技巧实录
6.1 Bash: xxx: command not found 到底怎么回事
这类报错的原因分三类,排查思路也对应三种。第一类:命令真的没装,比如你要用nslookup,基础系统里没有这个包,得用系统包管理器装。第二类:命令装了,但可执行文件所在的目录不在你的$PATH里,比如手动编译安装的软件通常放在/usr/local/bin,如果这个目录没被加进 PATH 就会找不到。第三类:命令在 Windows 和 Linux 环境下的名字不一样,比如打开 git bash 敲ll也许可用,换成ls -l则通用,再比如 Windows 下你明明装了某个工具,但它只在 cmd 里可用,bash 里则找不到,因为 git bash 不会自动继承所有 Windows 的 PATH 项目。
排查办法很简单:先type 命令名看看 bash 怎么解释它;再echo "$PATH"确认路径;最后ls /usr/bin/命令名看看目标是否存在。热词里提到过某个安装脚本执行完以后,紧接着敲命令却提示 not found,这种通常是安装脚本把可执行文件放到了~/.local/bin或者别的目录,但你的 PATH 没加。解决办法就是去安装日志里找实际路径,再手动补 PATH。
6.2 命令找不到?先确认参数放在哪个位置
还有一种很迷惑的“未找到命令”,比如你写了类似这样的执行语句:
bash --apiserver-advertise-address=192.168.0.109bash 会回报 “未找到命令”。为什么?因为 bash 自己并不认识这个长参数,它把--apiserver-advertise-address=192.168.0.109当成了 bash 的命令行参数,而不是脚本的参数。这类问题的本质是:哪条命令接收这些参数?如果你要执行的是某个安装脚本,参数应该放在脚本名后面,比如bash install.sh --apiserver-advertise-address=192.168.0.109,这样参数才会传给 install.sh。先分清“命令自己用的参数”和“命令后面那个脚本用的参数”,比一遍遍换引号管用。
6.3 Git Bash 下 cp、ls 报 no such file 的经典坑
Windows 上装 Git Bash 后,很多人发现执行cp 11.txt /tmp/时报cannot stat '11.txt': no such file or directory,但明明当前目录下就有这个文件。问题多半出在路径和当前目录上:Git Bash 启动时的默认工作目录不一定是你的项目目录,比如它可能停在C:/Users/你的用户名,而你心里以为自己在桌面上那个项目里。先跑pwd看看实际当前目录,再ls看看文件在不在,基本就能破案。
另一个坑是 Windows 路径风格。在 Git Bash 里,C:\Users\test这种盘符路径经常要写成/c/Users/test或者/cygdrive/c/Users/test,否则命令无法识别。写脚本时如果需要动态拼接路径,别直接写死反斜杠,尽量用变量和相对路径。顺便说一句,Git Bash 处理跨平台换行符也有坑:Windows 上文本文件是 CRLF 换行,Linux 工具链期望 LF,直接把 Windows 里编辑过的脚本拖到 Linux 跑,经常报$'\r': command not found,这是换行符污染了命令,用sed -i 's/\r$//' script.sh清一下就行。
6.4 下载脚本执行时反复 Retrying 该怎么做
热词里有curl -fsSL ... | bash反复 retrying 的状况。这种“直接管道给 bash 执行”的安装方式确实方便,但问题不少:一是网络下行不稳定,管道传输一半断掉,另一端 bash 可能已经执行了半个脚本;二是这种安装方式对用户极不透明,你根本不知道下载了什么代码,就直接以你的权限跑了。我对这种做法的态度很明确:能用官方包管理器装的,就用包管理器;非要用脚本,也要先下载到本地,看一眼内容,再决定是否执行。命令可以拆成两步:curl -fL -o install.sh 地址和bash install.sh。这样如果下载过程中反复 retrying,你至少知道是网络的问题,重试的是 curl,而不是在自己不知情的情况下反复执行一个残缺的脚本。
6.5 ADB Shell 不是普通 Bash,别硬套
热词里好几个人问 adb shell 下某些命令用不了,比如uiautomator dump或者dumpsys battery set usb 0。这里要理解一个很关键的概念:Android 设备内部的 shell 通常不是 GNU Bash,而是 mksh 或者 toybox 提供的一套精简 shell 和命令集。它支持基础的管道、变量、循环,但没有grep -P、没有 GNU find 那一堆选项,很多在 PC Linux 上能用的参数在设备上不可用。
遇到 adb shell 命令“用不了”,先分清是哪个环节的问题:是 adb 这个客户端没连上设备,还是设备上的 shell 不认这个命令,还是命令格式不对。比如uiautomator dump需要设备亮屏、解锁状态,否则会卡住或报错;dumpsys battery set usb 0依赖系统服务是否存在,某些定制 ROM 或新版本 Android 直接把写能力禁掉了。排查时先跑一个最简单的adb shell echo ok,确认链路通,再逐层递进。跨环境调试的核心原则就一句话:默认一切都不一样,然后逐个验证。
6.6 Bash 里 export 不生效的真相
有人反馈“bash 里不能用 export”,其实 export 是 bash 的内建命令,不可能不存在。真正遇到的往往是:在脚本里 export 了一个变量,脚本结束后再到终端 echo,发现变量没生效。这是进程模型的问题——你执行脚本时,bash 会 fork 一个子进程来跑,脚本里的 export 作用域只在这个子进程里,子进程一退出,变量就跟着销毁,影响不到父进程。解决方法是source script.sh而不是bash script.sh,让脚本在当前进程里执行。另一种常见情况是在函数里 export,想让它影响当前 shell,这的确可以,但前提是函数在父进程里被调用,而不是在子进程的脚本里被调用。理解“子进程继承环境、但不能反向修改父进程环境”这个规则之后,所有关于 export 的困惑都会迎刃而解。
最后再分享一个我自己调试 bash 脚本的小习惯:凡是脚本逻辑稍微复杂一点,我都会先开bash -x script.sh,观察每一行命令展开后的真实形态。很多时候你以为脚本在跑 A,实际 bash 展开之后跑的是 B,肉眼看不出来,-x一开立刻原形毕露。再配合 shellcheck(一个静态检查工具),几乎能把 90% 的引号、空格、变量引用问题拦在运行之前。学 bash 这件事,真不用背指令手册,把“展开、查找、退出码、进程模型”这四个概念刻在脑子里,你就已经从“会敲命令”进阶到“理解 shell”了。