文章目录
-
- [0. 从一个实际问题出发](#0. 从一个实际问题出发)
- [1. 预备知识:五个零件](#1. 预备知识:五个零件)
-
- [1.1 `()\` ------ 命令替换](#1.1 `()` —— 命令替换)
- [1.2 `{BASH_SOURCE\[0\]}\` ------ 脚本自己的路径](#1.2 `{BASH_SOURCE[0]}` —— 脚本自己的路径)
- [1.3 `dirname` ------ 取目录部分](#1.3
dirname—— 取目录部分) - [1.4 `cd 目录 && pwd` ------ 相对路径转绝对路径的经典技巧](#1.4
cd 目录 && pwd—— 相对路径转绝对路径的经典技巧) - [1.5 `..` ------ 上一级目录](#1.5
..—— 上一级目录)
- [2. 正式拆解三行代码](#2. 正式拆解三行代码)
-
- [第 1 行:算出脚本自己住在哪](#第 1 行:算出脚本自己住在哪)
- [第 2 行:从脚本目录往上跳两级,得到项目根](#第 2 行:从脚本目录往上跳两级,得到项目根)
- [第 3 行:真正切换过去](#第 3 行:真正切换过去)
- [3. 完整流程演示](#3. 完整流程演示)
- [4. 延伸话题](#4. 延伸话题)
-
- [4.1 `set -euo pipefail` 是什么](#4.1
set -euo pipefail是什么) - [4.2 为什么变量要加双引号](#4.2 为什么变量要加双引号)
- [4.3 替代方案对比](#4.3 替代方案对比)
- [4.4 已知局限](#4.4 已知局限)
- [4.1 `set -euo pipefail` 是什么](#4.1
- [5. 总结](#5. 总结)
0. 从一个实际问题出发
本项目里有这样一个 Shell 脚本 read.sh,它调用 Python 脚本去读取一个
Markdown 文件:
bash
python src/read_file.py dataset/file.md
注意这里用的都是相对路径 :src/read_file.py 和 dataset/file.md。
相对路径有一个非常容易踩坑的特性:它不是相对脚本文件本身,而是相对
"你运行命令时所在的目录"来解析的 。于是同一个脚本,在不同位置运行,
命运完全不同:
你在哪里运行 read.sh |
dataset/file.md 实际被解析为 |
结果 |
|---|---|---|
| 项目根目录 | 项目根/dataset/file.md |
✅ 找得到 |
家目录 ~ |
~/dataset/file.md |
❌ 不存在 |
/tmp |
/tmp/dataset/file.md |
❌ 不存在 |
script/linux/ 目录内 |
script/linux/dataset/file.md |
❌ 不存在 |
只要用户不是"恰好"在项目根目录运行,脚本就会报错。解决方案思路很直接:
脚本启动时,先想办法 cd 到一个绝对可靠的位置(项目根目录),再执行
后续命令。问题转化为------脚本怎么知道项目根目录在哪?
这就是本文的主角,read.sh 的前三行:
bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
cd "$PROJECT_ROOT"
下面逐层拆解。
1. 预备知识:五个零件
这三行代码只用到五个 Shell 基础构件,先把它们单独认清。
1.1 $() ------ 命令替换
bash
VAR="$(命令)"
执行括号里的命令,把标准输出捕获为字符串,赋给变量。例如:
bash
NOW="$(date '+%H:%M:%S')" # NOW 的值形如 14:23:05
$() 支持嵌套,内层先执行。本文的三行代码都用了嵌套:
$(cd "$(dirname ...)" && pwd) 里,dirname ... 先算出来,再交给外层
的 cd 使用。
1.2 ${BASH_SOURCE[0]} ------ 脚本自己的路径
它是 Bash 的特殊数组变量,值为当前正在执行的脚本文件被调用时写的
路径,就是你敲在命令行上的那串字符:
bash
# 在项目根目录运行
$ bash script/linux/read.sh
# 脚本内 ${BASH_SOURCE[0]} 的值 = "script/linux/read.sh"
# 在 /tmp 用绝对路径运行
$ bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
# 脚本内 ${BASH_SOURCE[0]} 的值 = "/home/jie/.../script/linux/read.sh"
注意两个特点:
- 它可能是相对路径 (如
script/linux/read.sh),取决于用户怎么调用; - 它指向脚本文件本身,这正是我们需要的锚点。
为什么不用更常见的 $0?$0 是"当前 Shell 进程的名字",在脚本被
source(点号)加载、或作为函数库被复用时,$0 会指向外层调用者,
而 BASH_SOURCE[0] 始终指向当前这份脚本文件,更可靠 。写成
${BASH_SOURCE[0]} 而不是 $BASH_SOURCE 是显式取数组第 0 个元素,
并明确了变量名的边界。
1.3 dirname ------ 取目录部分
一个外部命令,去掉路径的最后一层,保留目录部分:
bash
dirname /home/jie/project/script/linux/read.sh # -> /home/jie/project/script/linux
dirname script/linux/read.sh # -> script/linux
dirname ./read.sh # -> .
1.4 cd 目录 && pwd ------ 相对路径转绝对路径的经典技巧
pwd(print working directory)打印当前目录的绝对路径。于是:
bash
cd script/linux && pwd
# -> /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux
先进到那个目录,再让 pwd 报出它的"全名"。无论输入是 ./x、
x/../y 还是符号链接的各种写法,cd + pwd 都会给出一条规范化的
绝对路径。这是 Shell 里把任意路径"洗"成绝对路径的最常用手段。
一个重要细节:$( cd ... && pwd ) 里的 cd 发生在子 Shell 中,
只影响 $() 内部,不会改变你终端或脚本当前所在的目录 ------纯粹是
借它来问路,不改变位置。
1.5 .. ------ 上一级目录
路径中每个 .. 表示向上跳一级:
项目根/script/linux/../.. 等价于 项目根/
项目根/script/linux/../read.sh 等价于 项目根/script/read.sh
2. 正式拆解三行代码
零件备齐,现在把三行代码合起来看。
第 1 行:算出脚本自己住在哪
bash
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
执行顺序(由内向外):
${BASH_SOURCE[0]} "script/linux/read.sh" (用户怎么调用就长什么样)
|
v
dirname ... "script/linux" (去掉文件名)
|
v
cd ... && pwd "/home/jie/github/ouc_projects/sar_isar_mosaic/script/linux"
| (洗成绝对路径)
v
SCRIPT_DIR /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux
三种典型的调用方式,得到的 SCRIPT_DIR 完全一致:
| 调用方式 | BASH_SOURCE[0] |
最终 SCRIPT_DIR |
|---|---|---|
项目根目录下 bash script/linux/read.sh |
script/linux/read.sh |
同一个绝对路径 |
/tmp 下 bash /home/jie/.../read.sh |
/home/jie/.../read.sh |
同一个绝对路径 |
script/linux/ 内 bash read.sh |
read.sh |
同一个绝对路径 |
这一行的产出:一个与调用位置无关的"脚本所在目录"绝对路径。
第 2 行:从脚本目录往上跳两级,得到项目根
bash
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
本项目的目录结构决定了"脚本目录往上两级"就是项目根:
/home/jie/github/ouc_projects/sar_isar_mosaic <- PROJECT_ROOT(项目根)
└── script
└── linux <- SCRIPT_DIR(脚本在这里)
└── read.sh
把 $SCRIPT_DIR/../.. 代入,cd + pwd 再次把它规范化:
/home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/../..
-> /home/jie/github/ouc_projects/sar_isar_mosaic
按需调整跳级数 :脚本若放在项目根下,用
$SCRIPT_DIR/..;放在
tools/xxx/yyy/这种三层深处,则用$SCRIPT_DIR/../../..。原则:数一下脚本目录到项目根隔几层,就写几个
..。
第 3 行:真正切换过去
bash
cd "$PROJECT_ROOT"
这次是不带 $() 的裸 cd,作用于脚本自身的 Shell 进程。此后脚本里
所有相对路径都以项目根为基准:
bash
python src/read_file.py dataset/file.md
# └── 以项目根为基准,任何位置运行都指向同一个文件
3. 完整流程演示
read.sh 全文:
bash
set -euo pipefail # ① 出错即停
# 定位项目根目录,保证相对路径(dataset/...、src/...)与运行位置无关
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # ② 脚本在哪
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" # ③ 项目根在哪
cd "$PROJECT_ROOT" # ④ 切过去
python src/read_file.py dataset/file.md # ⑤ 干活
分别在两个不相干的目录运行,结果完全一致:
console
$ cd ~ && bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
This is file.md!
$ cd /tmp && bash /home/jie/github/ouc_projects/sar_isar_mosaic/script/linux/read.sh
This is file.md!
输出 This is file.md! 正是 dataset/file.md 的全部内容------尽管两次
运行的起点目录里既没有 src/ 也没有 dataset/。
4. 延伸话题
4.1 set -euo pipefail 是什么
read.sh 第一行的三个安全开关,建议所有脚本的固定开头都带上:
| 选项 | 作用 | 没有它会怎样 |
|---|---|---|
-e |
任何命令失败立即退出 | cd 失败后继续用错误的相对路径执行后续命令 |
-u |
引用未定义变量报错退出 | 变量名打错(如 $SCRIPT_DIR 写成 $SCIRPT_DIR)静默变成空串,路径错乱 |
-o pipefail |
管道中任一环失败即整体失败 | `命令A |
对本文场景,-e 尤其重要:如果 cd "$PROJECT_ROOT" 失败(比如目录
被移动),脚本应立即停下,而不是带着错误的当前目录继续跑。
4.2 为什么变量要加双引号
bash
cd "$SCRIPT_DIR/../.." && pwd # ✅ 加引号
cd $SCRIPT_DIR/../.. && pwd # ❌ 不加引号
不加引号时,Shell 会把变量值按空格拆分再展开。路径一旦含空格
(如 /home/jie/my projects/script),不加引号的版本会把
my 和 projects 当成两个词,cd 直接失败。路径变量永远加双引号
是无条件的习惯。
4.3 替代方案对比
定位项目根不止一种写法,各有适用场景:
① 本文方案:BASH_SOURCE + dirname + cd/pwd
bash
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
- 优点:零外部依赖,POSIX 习惯一致,任何装有 bash 的机器都能跑;
- 缺点:跳级数
../..与脚本所在深度耦合,移动脚本要改。
② 用 realpath 一步到位
bash
PROJECT_ROOT="$(realpath "$(dirname "${BASH_SOURCE[0]}")/../..")"
realpath 直接输出规范化绝对路径,省去 cd && pwd。缺点是它是较新的
外部命令,老系统(如 CentOS 7 的精简环境)可能没有。
③ Git 项目专用:git rev-parse --show-toplevel
bash
PROJECT_ROOT="$(git rev-parse --show-toplevel)"
直接问 Git 仓库的根目录在哪,不受脚本存放深度影响。缺点:必须在
Git 仓库内运行,且不适用于未纳入 Git 管理的项目。
④ 让 Python 自己找
既然最终执行的是 Python,也可以把定位问题下放给 read_file.py,
Shell 里只写相对调用。常见做法是从 __file__ 出发:
python
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parent.parent # src/ 的上一级
优点是逻辑跟代码走;缺点是 Shell 里若还有 cp、rm 等其他相对路径
操作,仍然需要 Shell 层面的定位。
本项目选择了方案 ① :脚本全部放在 script/linux/,深度固定,
且对运行环境零依赖,教学上也最适合逐层拆解。
4.4 已知局限
- 符号链接 :通过软链接调用脚本时,
BASH_SOURCE指向链接路径而非
真实文件,cd/pwd不会解析链接。若需要穿透软链接,把pwd换成
pwd -P,或改用realpath; - source 加载 :被
source read.sh加载时,BASH_SOURCE[0]指向
read.sh本身(这正是它比$0可靠的原因),行为依然正确;但第 3 行
的cd会改变 source 它的那个 Shell 的当前目录 ,这是 source
与执行(subprocess)的本质区别,使用时需留意。
5. 总结
三行代码,一句话概括:
先用
BASH_SOURCE+dirname+cd/pwd算出脚本自身的绝对位置,
再向上数..得到项目根,最后cd过去------此后一切相对路径都有了
统一且唯一的基准。
记忆模板(任何项目直接套用):
bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/<跳到根目录需要的 ../ 数>" && pwd)"
cd "$PROJECT_ROOT"