写这篇速查手册的起因,是我这几年经常要跨机器、跨项目地临时写脚本——处理日志、批量改文件名、检查服务状态、定时备份数据。Shell 的语法说简单也简单,说复杂也复杂,大多数时候就是变量、循环、判断加上几条常用命令拼装,但真正到了写的时候,总有细节记不牢:$@和$*到底差在哪、shift怎么配合参数解析用、字符串截取有哪几种写法、set -e为什么会把好好的脚本搞挂。每次卡住都得翻旧脚本或者现查文档,特别浪费时间。所以我把高频用到的 Shell 语法整理成了一份速查笔记,这篇文章就是这份笔记的公开版,按变量、流程、字符串、函数、调试、排坑这几个维度组织,方便直接当手册查阅。
适合谁看?如果你刚开始做 shell 脚本入门,这份手册能帮你快速搭起语法框架,搞清楚每个语法点在实际场景里的用途;如果你已经写了不少脚本,这里整理的细节和坑大概率能帮你省下一些调试时间,尤其是 2.2 节 for 循环的几种形态和 6.1 节的排坑表,都是我自己踩过之后才写进去的。
1. 变量与参数传递基础语法
1.1 变量定义与使用的关键细节
Shell 变量定义有个新手第一眼就容易踩的规则:等号两边不能有空格。name = "hello"会被解析成执行命令name,传入参数=和"hello",然后报command not found。正确写法是name="hello"。这个规则不是编译器限制,而是 shell 的 token 切分逻辑决定的——空格在命令行里天然是参数分隔符,所以等号紧贴着变量名才能被识别成赋值语句。
变量使用时要加$前缀,写echo $name或者更严谨的${name}都行。这里建议从第一天起就养成写${name}的习惯。原因很实际:当变量后面紧跟着其他字符时,比如要拼接字符串${name}_2024.log,如果你写成$name_2024.log,shell 会尝试找名为name_2024的变量,结果为空,脚本就静默出错。这种错误不会直接报错,排查起来特别费劲。
除了普通变量,还有几个常用的位置参数需要熟悉:
$0 # 脚本本身的名称 $1 $2 # 第一个、第二个位置参数 $# # 参数个数 $@ # 所有参数,每个参数独立 $* # 所有参数,合并成一个字符串 $? # 上一条命令的退出码 $$ # 当前进程 PID$@和$*这对是最容易被忽视的。不加引号时两者表现一样,但加了引号就有本质区别:"$@"会把每个参数原样保留,等价于分别传递每个参数;"$*"会把所有参数拼成一个带空格的字符串。写脚本转发参数时一定要用"$@",不然参数里的空格会被二次拆分,导致语义变化。举个真实例子:我写过批量处理脚本,参数里带有空格的文件名,一开始用的"$*"传给下游函数,结果文件名全被拆成了两半,改成"$@"后问题立刻消失。
1.2 shift 命令的用途与参数解析
shift命令的作用是让所有位置参数向左移动一位,原来$2变成$1,$1被丢弃,同时$#减一。这玩意儿单独看很简单,但它是手写参数解析的核心工具。
最常见的场景是解析带选项的参数,比如./deploy.sh -e prod -v 2.1.0。用shift配合case可以写出清晰的解析逻辑:
#!/bin/bash env="dev" version="latest" while [ $# -gt 0 ]; do case "$1" in -e|--env) env="$2" shift 2 ;; -v|--version) version="$2" shift 2 ;; -h|--help) echo "Usage: $0 [-e env] [-v version]" exit 0 ;; *) echo "Unknown option: $1" exit 1 ;; esac doneshift 2表示一次移动两位,把选项名和它的值一起跳过。这里要注意边界情况:如果用户只写了-e而没有给值,$2就是空的,判断时容易漏。稳妥的做法是在取$2之前先确认$# -ge 2,或者用getopts来兜底。对于简单场景我习惯手写while加shift,因为可读性好,逻辑完全可控;参数多了才换getopts这类工具。还有一个实用细节:shift可以带数字直接跳过多个参数,但跳过的数量不能超过当前参数个数,否则脚本会报shift count out of range,循环里使用时要留意。
2. 条件判断与流程控制语法
2.1 if 判断的写法与常见坑
Shell 的if判断写起来和其他语言风格差异很大,核心是[ ]这个测试命令和[[ ]]这个关键字。先说推荐:写新脚本用[[ ]],它是 bash 的内置关键字,支持&&、||、正则匹配=~,而且对空变量更宽容,不会出现[在变量为空时报语法错的经典问题。
if [[ -f "$file" ]]; then echo "文件存在" elif [[ -z "$var" ]]; then echo "变量为空" else echo "其他情况" fi几个常用判断操作符做个速查:
| 操作符 | 含义 | 场景示例 |
|---|---|---|
-f | 是否为普通文件 | 判断配置文件是否存在 |
-d | 是否为目录 | 判断输出目录是否已创建 |
-z | 字符串长度是否为 0 | 判断参数是否为空 |
-n | 字符串长度是否非 0 | 判断变量是否有值 |
-eq | 数字相等 | 数值比较 |
-lt | 数字小于 | 数值比较 |
= | 字符串相等 | 字符串比较([[ ]]里用==也行) |
-e | 文件或目录是否存在 | 通用存在性检查 |
这里最隐蔽的坑是数字和字符串的比较不能混用。[ "$a" -eq "$b" ]是数字比较,[ "$a" = "$b" ]是字符串比较。如果把数字比较写成[ "$a" = "$b" ],在a=10、b=2时结果是"不相等",这没问题;但如果写反了,用-eq比较字符串"abc"和"abc",shell 会尝试把abc解析成数字,报integer expression expected的错。还有一点:[ ]里面每个操作符、变量、符号之间必须用空格隔开,[ "$a"="$b" ]这样连着写会被当成一个整体字符串,判断结果永远是 false。这个错误我在初学者代码里见过不下十次。
2.2 for 循环的几种形态
for是 Shell 脚本里出现频率最高的循环,它的形态比大多数语言丰富,我按使用频次排个序:
第一种,遍历列表:
for ip in 192.168.1.1 192.168.1.2 192.168.1.3; do ping -c 1 "$ip" >/dev/null 2>&1 && echo "$ip 可达" || echo "$ip 不可达" done第二种,遍历命令输出的结果:
for log in $(ls *.log); do echo "处理 $log" done这里要提醒一个经典问题:$(ls *.log)这种方式一旦文件名里有空格,就会被拆成多个元素,导致逻辑出错。更稳的写法是用通配符直接展开:
for log in *.log; do echo "处理 $log" done通配符展开是在 shell 层面完成的,每个文件名天然作为一个整体,空格不会造成干扰。如果日志文件在不同目录层级,可以配合find加while read来做,这个我在 2.3 节展开。
第三种,C 风格的数值循环:
for ((i=1; i<=10; i++)); do echo "$i" done这种适合明确知道循环次数、需要用到计数器变量的场景,比如重试机制、生成序号文件。我在写重试逻辑时经常用它:循环里执行命令,判断退出码,成功就 break,失败就sleep一段时间继续,最多重试 5 次。
还有一个容易被忽略的写法是for i in {1..10},花括号展开可以生成连续序列。{1..10}和{a..z}都支持,还支持步长{1..10..2}。不过要注意花括号展开不能用于变量,for i in {$start..$end}是不生效的,这种情况就得用seq命令或者 C 风格循环。
2.3 while 与 case 的实际用法
while最典型的两个场景:一个是按行读取文件,另一个是配合shift解析参数(1.2 节已经写过)。
按行读取文件的标准写法是:
while IFS= read -r line; do echo "当前行: $line" done < input.txt这里IFS=的意思是清空内部字段分隔符,防止行首行尾的空白被修剪;-r防止反斜杠被当作转义字符处理。这两个细节缺一不可,否则读出来的内容和原文不一致。如果你要处理的是find的输出,安全做法是:
find . -name "*.log" -print0 | while IFS= read -r -d '' file; do echo "发现文件: $file" done-print0和-d ''搭配,用空字符而不是换行分隔,可以处理任意怪异的文件名,包括带换行符的文件名——虽然这种文件很少见,但防御一下没坏处。
case在 Shell 里的地位相当于其他语言里的switch。它的一个优势是支持通配符模式,可以让匹配逻辑非常简洁:
case "$1" in start) echo "启动服务" ;; stop|kill) echo "停止服务" ;; restart) echo "重启服务" ;; *) echo "用法: $0 {start|stop|restart}" exit 1 ;; esac写case时最容易漏的是;;,每个分支结束必须带两个分号,最后那个*通配分支用来兜底处理未匹配的情况。另外case配合shift做参数解析,就是很多命令行工具内部的实际实现方式,理解了这个组合,读开源脚本会容易很多。
until和while是反着的,条件为假时执行循环体,条件为真时退出。实际用得少,但有一个场景挺合适:等待某个服务就绪。
until curl -s http://localhost:8080/health >/dev/null 2>&1; do echo "服务未就绪,等待 2 秒..." sleep 2 done这种写法比while !可读性好一些,语义上"直到服务就绪才继续"更自然。
3. 字符串处理与运算语法
3.1 字符串截取、替换与拼接
字符串操作是 Shell 语法里性价比最高的部分,掌握几个核心写法几乎能覆盖日常所有需求。
截取子串,用的是${var:起始位置:长度},从 0 开始计数:
str="2024-11-15_nginx.log" echo "${str:0:10}" # 输出 2024-11-15 echo "${str:11:5}" # 输出 nginx删除前缀后缀、替换,用的是#、%和/这些符号,速查如下:
file="archive.tar.gz" echo "${file%.tar.gz}" # 删除后缀,输出 archive echo "${file%%.*}" # 从最后一个点开始删,输出 archive echo "${file#*.}" # 删除第一个点之前的内容,输出 tar.gz echo "${file##*.}" # 从最后一个点开始删前缀,输出 gz echo "${file/.tar.gz/.zip}" # 替换第一个匹配,输出 archive.zip echo "${file//./_}" # 替换所有匹配,输出 archive_tar_gz这组符号的规律很好记:#是从左边(开头)开始删除匹配,%是从右边(结尾)开始删除匹配;单个符号是最短匹配,双符号是最长匹配。「最短匹配」的含义是让模式匹配到的内容尽可能少,所以"${file%.*}"删掉的是最后一个点之后的内容,得到archive.tar,而"${file%%.*}"用最长匹配,删掉从第一个点开始的所有内容。知道这个规律,碰到类似需求基本不用查表。
变量默认值处理也是高频需求。部署脚本里经常要"环境变量没设置就给默认值":
env="${ENV:-dev}"${var:-默认值}在变量未设置或为空时使用默认值,${var:=默认值}除了使用默认值,还会把默认值赋值给变量。这两个的差别很小,但有时候就靠它省好几行判断。还有${var:?错误信息},如果变量为空就打印错误信息并退出,适合关键参数检查。
3.2 日期时间处理与文件名拼接
脚本里处理时间戳和日期特别频繁,尤其在写日志归档、备份任务的时候。date命令是核心工具,先看几个常用格式:
date +"%Y-%m-%d" # 2024-11-15,日期 date +"%Y-%m-%d %H:%M:%S" # 2024-11-15 14:30:22,带时间 date +"%Y%m%d%H%M%S" # 20241115143022,纯数字串 date +%s # 1729056622,Unix 时间戳前面搜索词里提到的"shell脚本获取当前日期时间并转换为数字串",其实就是上面第三行的写法。数字串的好处是适合直接拼进文件名做排序键,比如backup_20241115143022.tar.gz,按字典序排序就是按时间排序,归档管理非常方便。
取昨天的日期、一周前的时间,用-d:
date -d "yesterday" +"%Y-%m-%d" date -d "7 days ago" +"%Y-%m-%d" date -d "2024-11-15 -1 day" +"%Y-%m-%d"在 Linux 上这些写法都能用,但 macOS 的date是 BSD 风格,-d参数不存在,需要用date -v-1d +"%Y-%m-%d"。跨平台脚本里我会先判断系统类型再走不同分支,或者干脆统一用python3 -c来生成日期,不过为了一个小功能引入 Python 有点重,大多数时候还是分平台处理。
时间戳和日期互转也是常用操作:
date -d "@1729056622" +"%Y-%m-%d %H:%M:%S"这个在解析日志文件里那种纯数字时间戳时很好用。注意如果你的日志时间戳是毫秒级,要先除以 1000 或者用awk截掉后三位,再喂给date。还有时区问题,服务器默认 UTC 的话,转出来的时间会和本地时间差 8 个小时,生产环境脚本里要么统一用TZ环境变量指定:
TZ="Asia/Shanghai" date +"%Y-%m-%d %H:%M:%S"要么在文档里写明服务器的时区假设,不然排查线上问题时会绕很大弯子。
3.3 数值运算与算术扩展
Shell 里做整数运算,标准写法是$(( )):
total=$((count + 1)) bytes=$((size * 1024))注意几点:算术表达式里的变量不需要加$前缀,$((count + 1))和$(( $count + 1 ))都可以,但前者更简洁。除法是整数除法,echo $((7/2))结果是 3,不会给你 3.5。要做小数运算就得用awk或者bc:
echo "scale=2; 7/2" | bc # 输出 3.50bc的scale控制小数位数,但除法必须显式设scale才生效,默认是 0,这又是一个容易踩的细节。
自增自减写成((i++))或((i+=1))。老实说 Shell 的数值运算能力比较弱,超过简单的计数和字节换算,我基本就直接调awk或python3了,硬要用 Shell 写复杂计算是跟自己的时间过不去。不过有一个技巧值得提:printf可以做数字格式化补零:
printf "%02d" 3 # 输出 03配合循环生成带序号的零填充文件名,比如image_001.jpg到image_099.jpg,比手工拼字符串清爽得多。
4. 函数、退出码与脚本调试
4.1 函数定义与返回值处理
Shell 函数的定义很简单,两种写法等价:
function log_info { echo "[INFO] $1" } log_error() { echo "[ERROR] $1" >&2 }函数里的$1、$2是函数的参数,不是脚本的参数,这个要分清楚。如果函数想用脚本的全局参数,得在外面先存成全局变量,或者在调用时显式传进去。
函数的"返回值",很多人一开始会被误导。Shell 函数里的return只能返回整数退出码(0 到 255),不能返回字符串。想要函数输出一个字符串并让调用方拿到,靠的是标准输出配合命令替换:
get_env_name() { if [[ "$APP_ENV" == "" ]]; then echo "dev" else echo "$APP_ENV" fi } env_name=$(get_env_name)这里echo输出的内容会被$( )捕获并赋值给变量。一个常见的坑是:如果函数里除了主要输出之外,还有别的echo(比如调试信息),这些信息也会被$( )一起捕获,导致变量内容变脏。调试信息只用>&2输出到标准错误,或者用set +x临时关掉跟踪,就不会污染返回值。
return的退出码怎么用?配合if直接判断:
check_port() { local port=$1 if ss -tln | grep -q ":$port "; then return 0 fi return 1 } if check_port 8080; then echo "端口已被占用" fi这种写法比if [[ $(check_port 8080) == "0" ]]优雅多了,因为if判断的就是命令的退出码。写函数时养成"成功return 0,失败return 1"的习惯,整个脚本的可组合性会大大提升。
4.2 退出码与 set 选项
$?记录上一条命令的退出码,0表示成功,非0表示失败。这个机制很基础,但用好它能写出健壮得多的脚本。我见过太多人写了脚本,中间命令失败了还硬着头皮继续跑,最后产出的结果全是错的。解决办法就是set -e。
set -e的含义是:脚本中任何一条命令返回非 0 退出码,立即终止执行。它让脚本从"尽力执行"变成"失败即停",部署、备份这类脚本尤其需要。但set -e也有两个反直觉的点:
第一,被if条件、while条件、&&/||连接的命令不受影响。if grep -q "error" "$log"; then里,grep 即使没匹配到(返回 1),脚本也不会退出。这是设计使然,但新人不了解的话,会以为set -e失灵了。
第二,set -e在某些管道组合下会失效,除非配合set -o pipefail。pipefail让管道返回最后一个非 0 退出码,而不是默认记录的管道最后一条命令的退出码。
set -e set -o pipefail这两行加在脚本开头,是很多生产环境脚本的标配。再加一个set -u,变量未定义即报错退出,能揪出一堆拼写错误的变量名。我这几年写脚本的固定开头是:
#!/bin/bash set -euo pipefail-u有个小不便:$#为 0 时访问$1会报错,所以涉及参数判断的脚本,要在set -u后面先做好参数数量检查,或者用${1:-}提供默认值。
4.3 脚本调试技巧
调试 Shell 脚本不是靠猜的,bash -x是最好用的工具。执行时加上-x,每一行命令执行前都会把展开后的内容打印出来,等于给你一张"它到底执行了什么"的全过程记录。
bash -x deploy.sh更精细一点的,可以在脚本内部用set -x临时开启、set +x关闭跟踪,只跟踪问题段落。另一个常用的是bash -n,只做语法检查不执行,写完长脚本先跑一遍bash -n能把语法错误提前暴露。配合echo打印关键变量值、用>&2输出调试信息到标准错误,排查思路就清晰多了。
我个人的调试习惯是三步走:先bash -n检查语法,再bash -x跑一遍看完整执行轨迹,最后在可疑的赋值语句后面临时加一行echo "DEBUG: var=$var"确认中间值。三个步骤下来,九成的问题都能定位。还有一个小技巧,trap可以在脚本退出或报错时打印信息:
trap 'echo "脚本在 line $LINENO 出错, 退出码: $?"' ERR配合set -e使用,脚本出错时会自动打出出错的行号和退出码,这在跑长任务时特别救命,不用等任务失败后再翻日志找原因。
5. 文本处理常用语法组合
5.1 grep、awk、sed 三件套速用
写 Shell 脚本绕不开文本处理,而文本处理的三件套就是grep、awk、sed。这三个工具的语法都不复杂,但组合起来能高效解决大量问题,比用 Python 写一小段处理要省事得多。
grep负责筛选行。常用参数-E开启扩展正则、-v反向匹配、-q安静模式只判断是否有匹配、-c统计匹配行数。最常用的组合是日志里过滤关键字并排除干扰项:
grep -E "ERROR|FATAL" app.log | grep -v "heartbeat"awk负责按列处理。默认按空格切分列,$1、$2分别代表第一列、第二列,NF是列数,NR是行号。一个经典需求"打印日志里响应时间超过 5 秒的请求":
awk '$NF > 5 {print $1, $2, $NF}' access.logawk的语法本质上是一门小语言,有BEGIN、END、条件表达式、内置变量。不过对大多数脚本场景来说,会写"按列取值 + 数值比较 + print"已经能解决八成需求。需要小心的是列分隔符,日志里如果是用|或,分隔,要用-F指定:
awk -F'|' '{print $1, $3}' data.txtsed负责行内替换和删除。最常见的用法是去掉首尾空格、替换字符串:
sed -i 's/old/new/g' config.conf echo " hello " | sed 's/^[ \t]*//;s/[ \t]*$//'-i是原地修改文件,在 macOS 上要写成sed -i '',否则会报错。替换里的g表示全局替换,不加只替换每行第一个匹配。还有一个很实用的小技巧,sed -n '10,20p'可以只查看文件的第 10 到 20 行,排查长配置文件时省得开编辑器。
5.2 管道与重定向的正确打开方式
管道|把前一个命令的输出传给后一个命令作为输入。理解它的关键是:管道传的是"文本流",而不是"变量"。所以你不能写echo "$@" | var=$(cat)这样试图把管道输出赋值给同一条命令里的变量的代码,因为管道的左右两边一般会被 shell 放在不同的子进程里执行,赋值不会传回父进程。
如果你需要在处理中保存中间结果,用临时文件或者进程替换<( ):
diff <(grep "node1" hosts.txt) <(grep "node2" hosts.txt)这个写法在多文件对比的场景中很舒服,<(命令)会把命令的输出当成一个虚拟文件传给 diff。
重定向这块最基本的三个:>覆盖写文件、>>追加、2>&1把标准错误合并到标准输出。2>&1的顺序有讲究,command >log.txt 2>&1是标准写法,如果反过来command 2>&1 >log.txt,stderr 会跑到终端而不是文件里。原理是重定向按从左到右处理,2>&1把 stderr 指到了当前 stdout 的位置,后面 stdout 重定向到文件就不影响 stderr 了。这个顺序问题我在帮别人排查脚本时见过好几次,值得记牢。
丢弃不需要的输出常见写法是>/dev/null 2>&1,比如前面 ping 的例子,运行探测类命令时不希望输出刷屏,就把它整个丢进黑洞。
6. 常见问题与避坑实录
6.1 那些最容易翻车的语法细节
把脚本写废的情况,大多集中在少数几个细节上。我整理了一张高频坑位表,按"我见过多少次"排的:
| 问题现象 | 根本原因 | 正确做法 |
|---|---|---|
command not found: = | 变量赋值等号两边有空格 | 去掉空格:name="x" |
integer expression expected | 用-eq比较了非数字字符串 | 字符串比较改用= |
if条件恒真或恒假 | [ "$a"="$b" ]少了空格 | [ "$a" = "$b" ],空格不能省 |
| 文件名被错误拆分 | for f in $(ls *.log)遇到空格 | 直接用通配符*.log |
shift count out of range | shift跳过数量超过剩余参数 | 循环前先判断$# |
| 脚本静默失败但继续跑 | 没有set -e | 加set -euo pipefail |
| 变量名拼错没报错 | 未开启set -u,空变量被当成空串 | 开启set -u或使用${var:-} |
| 回车符导致命令尾部多余字符 | Windows 编辑保存的 CRLF 换行 | 使用dos2unix转换或用 LF 保存 |
最后一个 CRLF 的问题现在还很常见。很多人用 Windows 上的编辑器写脚本,传到 Linux 上一执行就报$'\r': command not found。处理办法是执行一次sed -i 's/\r$//' script.sh或者安装dos2unix批量转换。写脚本工具建议直接选支持 LF 换行的编辑器,从源头避免这个问题。
还有两个隐蔽坑值得单独说。第一个是通配符没有匹配时的行为:for f in *.log当目录下没有.log文件时,*.log不会展开成空,而是保留字面量*.log,循环体就执行了根本没意义的内容。加一句shopt -s nullglob让不匹配的通配符变成空,能避免很多莫名其妙的 bug。
第二个是while循环里的管道子 shell 问题。如果你写:
count=0 cat file.txt | while read line; do count=$((count + 1)) done echo "$count" # 输出 0这里的count不会被累计,因为while跑在管道右侧的子 shell 里,变量修改传不回父 shell。正确做法是用重定向而不是管道:while read line; do ... done < file.txt。这个坑的隐蔽性在于脚本不报错,结果却是错的,很多人排查半天找不到原因。
6.2 一份可以直接抄的脚本模板
最后给一份我日常写脚本的固定模板,你可以直接复制改:
#!/bin/bash set -euo pipefail # 日志函数:信息输出到 stdout,错误输出到 stderr log_info() { echo "$(date +'%Y-%m-%d %H:%M:%S') [INFO] $*" } log_error() { echo "$(date +'%Y-%m-%d %H:%M:%S') [ERROR] $*" >&2 } # 默认值 env="${ENV:-dev}" start_ts=$(date +%s) if [[ $# -lt 1 ]]; then log_error "缺少参数。用法: $0 {app|db|all}" exit 1 fi target="$1" case "$target" in app|db|all) log_info "开始处理 $target, 环境: $env" ;; *) log_error "未知目标: $target" exit 1 ;; esac # 核心逻辑 for i in {1..3}; do log_info "第 $i 次尝试..." # 这里放实际处理命令 break done end_ts=$(date +%s) log_info "任务完成, 耗时 $((end_ts - start_ts)) 秒"模板里包含了set -euo pipefail保证出错即停、日志函数统一输出格式、参数检查和退出码控制。实际业务逻辑直接嵌在case下面就行。这套模板我用了好几年,从简单的备份脚本到上百行的部署脚本,都是先拿它打底,再往里面填业务。
6.3 更现代的 Shell 编写习惯
写到这,顺便说一个提升体验的习惯:用shellcheck做静态检查。
shellcheck deploy.shshellcheck能查出未引用的变量、set -e的坑、$(ls)之类的危险写法,甚至还会给出修改建议。它的定位相当于 Shell 界的代码评审工具。我现在的习惯是脚本写完先跑一遍shellcheck,再跑一遍bash -n,最后才放到目标机器上执行。前两步加起来可能只需要十几秒,但能挡掉前面表格里一半以上的问题。shellcheck支持安装到主流系统上,也支持在 VS Code 里做实时提示,非常推荐装一个。
另外,脚本启动时的当前工作目录通常不是你期望的地方,尤其是被 cron 调度时,pwd可能落在用户家目录。稳妥做法是脚本开头显式cd到脚本所在目录:
cd "$(dirname "$0")"配合$(dirname "$0")拿到脚本路径,再配合cd保证后续相对路径都基于脚本目录计算。这个细节对 crontab 任务特别重要,我早期吃过不少"手动跑没问题、定时跑全挂"的亏。如果你在 macOS 上想让这个逻辑更稳,可以用${BASH_SOURCE[0]}配合dirname做一层处理,不过大部分 Linux 环境下$0已经够用。
我个人这几年写 shell 脚本的体会是:它的语法体系不复杂,但每一条规则背后都藏着 token 切分、子 shell、退出码这类底层机制,理解这些机制比背语法重要得多。遇到"为什么这样写就不行"的问题,先拆解一下 shell 是怎么解析这一行的,答案往往自己就出来了。这份速查手册把高频场景和踩过的坑都压缩在里面了,建议你收藏后按需查阅,更建议你在自己的机器上把每个例子敲一遍——代码这种东西,动手写过一遍才真正算自己的。比如我这段时间在服务器上清理历史日志,从date +%Y%m%d生成归档文件名到find ... -mtime +30 -delete清理过期文件,整个流程就是在这些基础语法上叠加出来的。你多写几个真实场景的脚本之后,会发现这份手册里的大部分条目都会变成肌肉记忆,到时候真正需要查的,反而是那些一个月才用一次的边角语法。