日常写脚本、临时排查服务器问题,Shell 语法永远是绕不开的那道坎。shell命令行里一个循环写错,可能让批量任务跑偏;一句变量引用没加花括号,可能让字符串拼接变成灾难。我见过太多开发转运维、运维转自动化的朋友,不是不会写 Linux 操作,而是被shell语法这种看似零散、实则敏感的细节卡住。这份速查手册就是冲着这个来的:不搞长篇大论,而是把shell脚本入门阶段最常用、最反直觉、最容易踩坑的语法规则,整理成可以直接抄作业的片段。
它能解决的典型问题包括:for 循环到底有几种写法、if条件里的空格为什么致命、管道后面接xargs还是while更稳、sed 和 awk 怎么组合才不绕弯子。适合正在学shell脚本的开发者、被自动化巡检折磨的运维、想把批处理写得更安全的数据分析师,甚至是在 Windows 环境下尝试 Git Bash、WSL 练习shell命令行工具的朋友。读完你会得到一套“清水得能直接 grep 出来用”的速查模板,而不是一本需要从头啃到尾的技术手册。
1. 为什么你需要一份Shell语法速查手册
1.1 解决的实际问题
日常工作中大量任务本质上是“重复执行 + 条件判断 + 文本处理”。小到批量改文件名,大到检测服务的存活状态并告警,用 Shell 写几行脚本通常比打开编程语言甚至图形化工具更快。但 Shell 的问题在于:平时用得少的人,一旦需要写脚本,就会被各种细节绊住。
印象很深的一次:有个同事写脚本清理旧日志,逻辑很简单——找到三天前的.log文件,删掉。他用了find . -mtime +3 -exec rm {}这条命令,结果目录被删空了一部分,原因是变量里放了路径,但分号转义没做好,exec rm {}后面的\;写成了;,导致他以为删的是文件,实际删的是整个目录列表。这类事故在shell命令行里太典型了:语法看着眼熟,但关键字符一错,语义全变。
所以手边放一份速查手册,不是为了记不住命令,而是为了在五秒钟内确认“我写的这个语法到底对不对”,尤其是那些反直觉的部分。
1.2 速查手册和完整教程的差异
完整教程会从 Bash 的历史讲起,讲变量展开的内部机制、进程替换的实现原理、子 shell 的 fork 行为,这当然很好,但不适合干活时查。速查手册的价值在于:把高频语法浓缩成可直接对照的“例句 + 语法快照”,把容易出错的边界条件标注出来,把反直觉的行为用一两句话点破。
这也是我坚持在下面每一节给出“语法快照”和“坑”的原因。比如 for 循环的 C 风格写法for ((i=0;i<10;i++)),很多人知道它存在,但不清楚它到底属于 Bash 扩展,在sh或dash里可能直接报错;又比如[[ ]]和[ ]的区别,经常被一句话带过,但实际直接影响空变量、正则匹配逻辑。这些内容只有浓缩成速查小条,才能发挥“手册”的作用。
2. 变量、参数与表达式:先搞懂 Shell 的数据底盘
2.1 变量定义与引用的关键细节
变量的定义本身很简单,但有些人第一次就被空格坑了。Shell 语法规定:变量名、等号、值之间不能有空格。所以name = "zhang"会直接报错,正确写法是name="zhang"。这个设计原因纯属历史习惯,但后果很严重——有空格会被解析成三条独立的词,name被当成一个命令去执行。
取值引用用$变量名,但凡是真实的日用场景,我更推荐全部写成${变量名}。花括号不是摆设,它定义了变量名的边界。举个例子:
file="report" echo "${file}_2024.log" echo "$file_2024.log"第一行输出report_2024.log,第二行输出空,因为 Shell 把file_2024当成变量名去找。这种问题在shell脚本里非常隐蔽,尤其拼接路径、文件名时,花括号习惯直接决定脚本的稳定性。
双引号和单引号的差异更要记牢:双引号里的变量和命令替换会执行,单引号里的内容原样输出。所以echo '$HOME'打出来的就是$HOME这个字符串,echo "$HOME"才是真实路径。这条规则在拼接 SQL、拼 API 参数时非常有用,但也非常容易漏。
2.2 环境变量、位置参数与特殊变量
变量分两类:环境变量和普通 Shell 变量。环境变量会传给子进程,普通变量不会。导出环境变量用export。排查问题的场景里,常见做法是用ENV带参数运行脚本。
位置参数是脚本接收命令行参数的入口:$0是脚本名,$1、$2是第 1、2 个传入参数,$#是参数个数,$@是所有参数。$@和$*的差别微妙:不加双引号时两者一样,加双引号时"$@"会把每个参数当成独立个体,"$*"会把所有参数拼成一个字符串。一句话总结:遍历参数时永远用"$@"。
特殊情况字符速查:
| 变量 | 含义 | 常见用途 |
|---|---|---|
$? | 上一条命令的退出状态码 | 判断命令是否执行成功 |
$$ | 当前 Shell 的进程 PID | 生成临时文件名,防止多进程冲突 |
$! | 后台最近一个任务的 PID | 配合 wait 处理并行任务 |
$0 | 当前脚本名 | 脚本内部做帮助提示、路径定位 |
退出码逻辑很重要。命令成功返回 0,失败返回非 0。可以配合&&和||做短路运算,比如:
mkdir -p /logs/nginx && echo "创建成功" || echo "创建失败"这条写法的逻辑是:左边命令成功才执行&&后面的,失败才执行||后面的。日常脚本里这种写法很常用,能少写很多 if 嵌套。但要注意“命令成功”的判断基于退出码,有些命令即使业务逻辑出错,退出码也可能是 0,这属于工具的行为差异,不能只看退出码。
2.3 算术运算与条件比较:别用错一百遍
Shell 里的算术运算不能用let a = b + c这种直觉,正确姿势是$(( ))双括号算术表达式:
a=10 b=20 sum=$((a + b)) echo $sum(( ))内部可以不算$前缀,变量名直接裸写也行。这地方新手常犯的错是写成sum=$(a + b),实际这会当成命令执行子 shell,报command not found。还有let a++这类写法,虽然可用,但没必要——统一用$(( ))。的算术判断在 if 里也常用:if ((sum > 30)); then`。
条件比较更是重灾区。看到一篇文章里写if [ $status = "success" ],没问题,但如果$status为空字符串,这个判断会演变成[ = "success" ],直接语法错误。解决方式是给变量套双引号:if [ "$status" = "success" ]; then。也可以直接用更坚固的[[ ]]扩展语法,这是 Bash 特有的,内部不会发生词分割和路径扩展,空变量默认当作空字符串,不用加引号也不会炸。
两者比较:
| 场景 | [ ](POSIX test) | [[ ]](Bash 扩展) |
|---|---|---|
| 变量为空时是否安全 | 需要加引号 | 安全 |
| 支持正则匹配 | 不支持 | 支持=~ |
支持&&/ ` | ` | |
| 习惯适用 | 可移植脚本 | 现代 Linux 默认 Bash 环境 |
字符串比较用=或==,数字比较用-eq -ne -gt -lt -ge -le。很多人一上来在 if 里写if [ "$count" -eq 3 ],这是对的;但如果潜意识里写if [ "$count" = 3 ],在 Shell 里比较的是字符串,不是数字,极容易出脏数据。逻辑混乱的根源是没分清两种比较体系。
3. 流程控制语法:分支与循环的规范写法
3.1 if-elif-else 语法细节
if 的基本结构是固定的:
if 命令或判断条件; then 执行分支 elif 另一个条件; then 另一个分支 else 兜底分支 fi三个细节值得单独记住。第一,if后面的条件本质上是一个命令列表,then前面必须有分号或换行。第二,fi是 if 的结尾标记,漏写 fi 是脚本解析时最常见的报错之一。第三,if grep -q "error" /var/log/app.log这种直接拿命令当条件,比先执行再判断退出码更简洁——grep 找到匹配会返回 0,if 分支自然成立。
但注意if条件里的空格没有网传那么玄乎。真正的问题不是“必须有空格”,而是[ ]和[[ ]]的语法要求内部每个 token 之间用空格隔开。[$a-eq 3]缺少空格会报错,正确写法[ "$a" -eq 3 ]。把这个习惯记牢,比死记“等号两边必须有空格”更本质。
3.2 for 循环:三种高频写法
for 循环是shell脚本里最常用的流程结构。第一种写法,遍历一个显式列表:
for env in dev test prod; do echo "环境: $env" done第二种写法,遍历命令输出的结果,比如从find或ls得到文件列表:
for file in $(find /data/backup -name "*.tar.gz"); do tar -tzf "$file" > /dev/null && echo "$file 可正常读取" done这里的坑是:$( )命令替换会按换行和空格分割输出。如果文件名里有空格,循环会被拆坏。真实项目里优先建议用find ... -print0 | while read -r -d '' file这种方式,或者直接for file in /data/backup/*.tar.gz,让通配符帮你展开文件列表,反而最安全。
第三种写法是 C 风格:
for ((i=1; i<=10; i++)); do echo "$i" done这个语法只适合 Bash / Zsh,不适合纯 POSIX sh。所以我写脚本时只在脚本头声明#!/bin/bash时才使用。写#!/bin/sh的脚本里出现了for (( )),在 Ubuntu 的 dash 下直接报 syntax error。
3.3 while 与 until 循环
while 在 Shell 里最常见的一个用途是逐行读取文件,这比 for 遍历文件内容更稳:
while IFS= read -r line; do echo "内容: $line" done < /path/to/fileIFS=防止行首尾空白被吃掉,-r防止反斜杠被当转义符处理。这两个选项看着不起眼,但处理日志、CSV 文件时缺一个都会出现数据错位。需要特别提醒:不要在管道cat file后接 while,那样子 shell 里的变量修改会丢失。让 while 从文件重定向读取,才能安全地在循环内计数。
until 的语义和 while 相反:条件为假时执行循环体。但日常脚本里 until 用得少,知道它存在即可:
until [ -f /tmp/ready ]; do sleep 1 done这个写法可以模拟“等待某个文件生成”的阻塞逻辑,比盲目 sleep 指定时长更靠谱。
3.4 case 分支:比 if 链更清晰的匹配
case 语法在启动脚本、参数解析里非常优雅:
case "$mode" in start) echo "启动服务" ;; stop) echo "停止服务" ;; restart|reload) echo "重启服务" ;; *) echo "用法: $0 {start|stop|restart|reload}" exit 1 ;; esaccase 支持通配符模式,*是兜底分支,多个候选模式用|分隔。每个分支的结尾是两个分号,最后结束用esac(case 的反拼)。这个语法的好处是把多分支的等于判断压成一块清晰结构,而且天然支持“若匹配则跳过后续分支”的语义,比一串 if-elif 干净得多。
4. 函数、重定向与管道:把命令拼成流水线
4.1 函数定义与返回值陷阱
函数定义有三种写法,本质都一样:
myfunc() { echo "hello"; } function myfunc { echo "hello"; } function myfunc() { echo "hello"; }日常我只用第一种:函数名后跟空括号,再跟花括号块。function关键字是额外负担,在 Bash 里没问题,但不必要。
函数内部return返回值是整数状态码,不是字符串。如果你期望函数返回一段文本,不要直接return "字符串",那不是 Shell 的设计。正确做法是用echo输出,调用方通过$(函数名)捕获:
get_weekday() { date +%u } today=$(get_weekday)函数内部可以使用默认作用域的变量,也就是说函数里修改的变量在函数外也变了。如果想隔离,在函数里用local声明:
myfunc() { local tmp_file tmp_file="/tmp/cache_$$" }4.2 重定向:标准输入输出与文件描述符
Shell 的输入输出默认接在三个文件描述符:0 标准输入、1 标准输出、2 标准错误。常用重定向:
command > file # 标准输出写文件,覆盖 command >> file # 标准输出追加到文件 command 2> err.log # 错误输出写文件 command > out.log 2>&1 # 输出和错误都写同一个文件2>&1的顺序有讲究。它表示把标准错误重定向到当前标准输出指向的目标。如果写成2>&1 > out.log,顺序反了,标准错误会指到屏幕而不是日志文件。还有更简单的写法&> out.log,但为了可移植性,我习惯写完整的> out.log 2>&1。
临时丢弃输出用/dev/null。比如只关心命令退出码,不想看到任何输出:
ping -c 1 -W 1 127.0.0.1 > /dev/null 2>&1 && echo "网络正常"4.3 管道与子 Shell 的隐藏陷阱
管道|把前一个命令的标准输出接到后一个命令的标准输入,这是 Unix 哲学的基石。但它带来一个非常隐蔽的障碍:管道后面的命令运行在子 Shell 中,子 Shell 里的变量修改不会传回父 Shell。
举一个我反复见过的反例:
count=0 seq 1 10 | while read num; do count=$((count + 1)) done echo "总数: $count"期望输出 10,实际输出 0。原因就是 while 在子 Shell 中执行,count 的修改全部丢失。排查方案有两个:一是用进程替换,把管道改成< <(...):
count=0 while read num; do count=$((count + 1)) done < <(seq 1 10) echo "总数: $count"二是在管道里直接把结果输出到文件,循环结束后再读出来。前者更干净,但需要 Bash 支持进程替换,默认 Linux 的 Bash 一般没问题。
4.4 xargs:管道后面的收件员
xargs 解决的核心问题是:管道把“标准输入的文本”传给命令,但很多命令不接受标准输入,只接受命令行参数。xargs 就是把标准输入拆成参数,再喂给命令的执行者。
常见用途是批量删除:
find /tmp -name "*.tmp" | xargs rm -f但文件名带空格时,这个写法会割裂文件名。解决方式:find 输出用-print0,xargs 接收用-0:
find /tmp -name "*.tmp" -print0 | xargs -0 rm -f同时建议用-I {}做占位符替换,它会把每行输入作为一个整体替换到{}出现的位置,逻辑更直观:
find /data -name "*.log" | xargs -I {} mv {} /backup/5. 高频文本处理命令实战速查
5.1 grep:按模式找行
grep最简单也最常用。基本语法:grep [选项] 模式 文件。常用选项:-i忽略大小写,-v反向匹配,-r递归目录,-o只输出匹配部分而不是整行,-q安静模式只看状态码,-n显示行号。
在实际日志排查里,-B5(前 5 行)、-A5(后 5 行)、-C5(前后各 5 行)非常实用。比如查异常崩溃点:
grep -n "Exception" -B3 -A10 app.loggrep 默认支持基础正则表达式(BRE),grep -E才使用扩展正则(ERE)。日常写量词+和?时,语法不同最坑人:基础正则里+要转义成\+,扩展正则里直接写+。
5.2 sed:按规则替换编辑
sed 最核心的功能是“流式编辑”,尤其适合批量替换。基本替换语法:
sed 's/旧/新/g' files表示替换,g表示行内全部匹配。不加g只会替换每行第一个匹配项。后缀-i是原地修改,新手最容易忘记这个选项导致替换不生效。
替换内容的经典坑:替换目标或源数据里带/路径时,直接用斜杠作分隔符会冲突,比如替换 Nginx 配置里的路径。解决方式:把分隔符换成了别的字符,比如#:
sed -i 's#/usr/local#/opt#g' nginx.conf删除特定行、输出指定区间行也是高频操作:
sed -i '/^#/d' config.conf # 删除以 # 开头的注释行 sed -n '100,200p' app.log # 只打印第 100 到 200 行5.3 awk:按字段做结构化处理
awk 本质是一个小型语言,但速查级用法只需要掌握几个内建变量:$0整行,$1、$2按分隔符切分后的字段,NF字段总数,NR当前行号,FS输入字段分隔符(默认空格),OFS输出字段分隔符。
最经典的用法是取某一列:
ps aux | awk '{print $2}'如果想取第二列同时保留表头功能,可以用条件分支:
awk 'NR > 1 {print $1, $3}'按冒号分隔处理/etc/passwd:
awk -F: '{print $1}' /etc/passwdawk 也支持累加求和,比如统计某个日志文件里响应时间的总和:
awk '{sum += $NF} END {print sum}' access.logEND块在所有行处理完后执行,非常适合聚合统计场景。awk 的文本处理能力和 sed、grep 结合使用,能覆盖绝大多数日志分析需求。
5.4 正则表达式语法对照简表
正则表达式在各工具里,尤其是 grep、sed,都有基础正则和扩展正则之分。这个对照表值得收藏:
| 含义 | BRE | ERE(grep -E / sed -E / awk) |
|---|---|---|
| 任意单个字符 | . | . |
| 字符组 | [abc] | [abc] |
| 前项出现 0 次或多次 | * | * |
| 前项出现 1 次或多次 | \+ | + |
| 前项出现 0 或 1 次 | \? | ? |
| n 次重复 | \{n\} | {n} |
| 行首锚点 | ^ | ^ |
| 行尾锚点 | $ | $ |
| 分组 | \(\) | () |
| 或逻辑 | | | |(ERE 里通常也不需要转义) |
一个实际例子:提取时间戳2024-05-20 10:30:59里的日期部分,写成[0-9]{4}-[0-9]{2}-[0-9]{2}在 ERE 里可以直接用,但在 grep 默认模式里需要\{4\}这种转义大括号。这就是为什么我强烈建议:做匹配统一用grep -E,做替换统一用sed -E,不要纠结默认 BRE 的转义细节。
6. 从需求到脚本:4个高频场景完整推导
6.1 Linux 用 Shell 重命名一批文件
假设/data/photos目录下有一堆IMG_20240401_123.jpg这类文件,现在要把日期从 YYYYMMDD 改成 YYYY-MM-DD。做法是用bash的参数扩展和 mv 命令:
for file in /data/photos/IMG_*.jpg; do base=${file##*/} newname=$(echo "$base" | sed -E 's/([0-9]{4})([0-9]{2})([0-9]{2})/\1-\2-\3/') mv "$file" "/data/photos/$newname" done${file##*/}是去掉路径部分,只留文件名;sed -E使用扩展正则捕获三组数字再拼接。这个场景能同时练到变量扩展、命令替换、循环、sed 四个语法点,是很好的综合案例。注意 mv 命令的两侧参数都加双引号,防止意外空格拆词。
6.2 获取当前日期时间并转换为数字串
脚本里经常需要生成带时间戳的文件名或告警信息。date的强大之处在于格式化输出几乎可以满足任意需求:
current=$(date "+%Y%m%d_%H%M%S") echo "备份文件 backup_${current}.tar.gz"这个%Y%m%d_%H%M%S就是日期时间直接转成数字串,排序上也天然是时间序。如果想从某个固定格式的字符串反转回去,用date -d:
date -d "2024-05-20 10:30:59" "+%Y%m%d%H%M%S"在 macOS 上-d换成-j -f语法略不同,但 Linux 服务器场景用-d就够了。
6.3 for 循环遍历目录统计文件大小
需求:统计/var/log下每个子目录占用空间,输出超过 1GB 的目录告警。用 du 命令循环即可:
for dir in /var/log/*/; do size=$(du -sm "$dir" 2>/dev/null | cut -f1) if [ "$size" -gt 1024 ]; then echo "$dir 超过 1GB,当前 ${size}MB" fi done这里du -sm会输出“大小 路径”两列,用cut -f1只取大小。数值比较用-gt,判断空值风险时加了双引号和兜底。这个脚本能挡住日志分区被占满的常见事故。
6.4 shift 命令与命令行参数解析
shift的作用是把位置参数向左移动:执行一次后,原来的$2变成$1,$#减一。结合 while 可以写一个轻量参数解析器:
while [ $# -gt 0 ]; do case "$1" in -f|--file) filename=$2 shift 2 ;; -v|--verbose) verbose=1 shift ;; -h|--help) usage exit 0 ;; *) echo "未知参数: $1" shift ;; esac done这个模式适合脚本参数数量不多、不想引入 getopt 或第三方库的场景。我在写部署工具时,经常把它封装成一个独立函数放脚本头部,后面主流程只使用解析好的变量。
7. 常见坑与排查技巧实录
7.1 变量赋值空格与命令替换
之前提过赋值不能有空格,但还有一个常见版本:误用命令替换语法。比如timestamp = $(date)报错,反应是“会不会是命令替换语法错了”,但问题还是等号旁边的空格。排查思路:先删掉等号空格,再确认命令本身。
$( )和反引号`的差异:反引号是传统 POSIX 写法,$( )是 Bash 推荐写法,优势是可以嵌套、可读性好、自动处理引号规则。如果一个$( )内又需要命令替换,推荐用$( )而非反引号。
7.2 if 判断里空格与引号的隐性规则
最常见的报错是[: too many arguments。这通常因为变量值里有空格或空行,[ ]展开后把条件拆分了。严格给变量加引号基本能解决。另一个隐蔽问题是if [ "$count" == 3 ]——单等号在 test 里是字符串比较,数字场景该用-eq。
7.3 通配符与文件名中的空格
通配符展开(globbing)发生在命令执行前,所以rm *.log会把所有匹配的文件名展开后逐个删除。但如果文件名里带空格,shell 会按空格拆词。处理这类文件的通用原则:非交互脚本里所有变量和命令替换结果必须加双引号,通配符本身保留给 shell 展开。需要逐条处理时,用for f in *.log; do echo "$f"; done。
7.4 脚本执行权限与 shell 解释器差异
写完脚本后直接./script.sh,可能报 Permission denied。排查顺序:先ls -l script.sh检查是否有执行权限,再确认脚本头部#!行指向的解释器是否正确。Ubuntu 的/bin/sh指向 dash,不兼容一些 Bash 扩展语法;逻辑上严格只依赖 POSIX 特性时可以用#!/bin/sh,否则全部用#!/bin/bash。
还有一个常见场景:脚本里调用source或.加载其他文件,如果路径里有空格,记得写成source "/path/with space/config.sh"。
7.5 常见问题对照速查表
| 现象 | 可能原因 | 排查/修复 |
|---|---|---|
脚本报command not found | 变量赋值等号旁有空格 | 去掉空格,检查命令是否存在 |
[: too many arguments | test 内变量值含空格未加引号 | 变量加双引号 |
| 管道后变量值丢失 | 子 Shell 作用域问题 | 改用进程替换< <( )或写到文件 |
| 文件名含空格被拆开 | 未加引号 / 未使用-print0 | 统一加引号,find + xargs 用-0 |
| 脚本执行报 permission denied | 可执行权限不足 | chmod +x script.sh |
与/bin/sh语法不一致 | sh 不是 Bash | 脚本头改用#!/bin/bash |
$file和后续字符串拼接为空 | 缺少花括号边界 | 写成${file}_suffix |
find 的-exec少一个转义分号 | 分号被 shell 误解 | 使用\;或改用 xargs |
我自己有一个长期使用的习惯:把这份速查内容整理成一个带详细注释的 shell 文件,命名为shell_cheat.sh,平时放在~/.cheatsheets/目录下。要用哪个语法时,grep -A 10 "for 循环" ~/.cheatsheets/shell_cheat.sh直接看片段,比翻书或查博客更快。而且这个文件本身就是可执行的,有些片段可以直接去掉注释跑一遍验证行为。你在实际使用中也可以照这个思路维护一份自己的速查文件,因为每个人踩过的坑不一样,你记录下的坑才是对你最值钱的速查条目。