
日常写脚本、临时排查服务器问题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 ((i0;i10;i))很多人知道它存在但不清楚它到底属于 Bash 扩展在sh或dash里可能直接报错又比如[[ ]]和[ ]的区别经常被一句话带过但实际直接影响空变量、正则匹配逻辑。这些内容只有浓缩成速查小条才能发挥“手册”的作用。2. 变量、参数与表达式先搞懂 Shell 的数据底盘2.1 变量定义与引用的关键细节变量的定义本身很简单但有些人第一次就被空格坑了。Shell 语法规定变量名、等号、值之间不能有空格。所以name zhang会直接报错正确写法是namezhang。这个设计原因纯属历史习惯但后果很严重——有空格会被解析成三条独立的词name被当成一个命令去执行。取值引用用$变量名但凡是真实的日用场景我更推荐全部写成${变量名}。花括号不是摆设它定义了变量名的边界。举个例子filereport 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这种直觉正确姿势是$(( ))双括号算术表达式a10 b20 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 找到匹配会返回 0if 分支自然成立。但注意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 ((i1; i10; 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 支持通配符模式*是兜底分支多个候选模式用|分隔。每个分支的结尾是两个分号最后结束用esaccase 的反拼。这个语法的好处是把多分支的等于判断压成一块清晰结构而且天然支持“若匹配则跳过后续分支”的语义比一串 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 21 # 输出和错误都写同一个文件21的顺序有讲究。它表示把标准错误重定向到当前标准输出指向的目标。如果写成21 out.log顺序反了标准错误会指到屏幕而不是日志文件。还有更简单的写法 out.log但为了可移植性我习惯写完整的 out.log 21。临时丢弃输出用/dev/null。比如只关心命令退出码不想看到任何输出ping -c 1 -W 1 127.0.0.1 /dev/null 21 echo 网络正常4.3 管道与子 Shell 的隐藏陷阱管道|把前一个命令的标准输出接到后一个命令的标准输入这是 Unix 哲学的基石。但它带来一个非常隐蔽的障碍管道后面的命令运行在子 Shell 中子 Shell 里的变量修改不会传回父 Shell。举一个我反复见过的反例count0 seq 1 10 | while read num; do count$((count 1)) done echo 总数: $count期望输出 10实际输出 0。原因就是 while 在子 Shell 中执行count 的修改全部丢失。排查方案有两个一是用进程替换把管道改成 (...)count0 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 输出用-print0xargs 接收用-0find /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 默认支持基础正则表达式BREgrep -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/passwdawk -F: {print $1} /etc/passwdawk 也支持累加求和比如统计某个日志文件里响应时间的总和awk {sum $NF} END {print sum} access.logEND块在所有行处理完后执行非常适合聚合统计场景。awk 的文本处理能力和 sed、grep 结合使用能覆盖绝大多数日志分析需求。5.4 正则表达式语法对照简表正则表达式在各工具里尤其是 grep、sed都有基础正则和扩展正则之分。这个对照表值得收藏含义BREEREgrep -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 -ddate -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) verbose1 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 argumentstest 内变量值含空格未加引号变量加双引号管道后变量值丢失子 Shell 作用域问题改用进程替换 ( )或写到文件文件名含空格被拆开未加引号 / 未使用-print0统一加引号find xargs 用-0脚本执行报 permission denied可执行权限不足chmod x script.sh与/bin/sh语法不一致sh 不是 Bash脚本头改用#!/bin/bash$file和后续字符串拼接为空缺少花括号边界写成${file}_suffixfind 的-exec少一个转义分号分号被 shell 误解使用\;或改用 xargs我自己有一个长期使用的习惯把这份速查内容整理成一个带详细注释的 shell 文件命名为shell_cheat.sh平时放在~/.cheatsheets/目录下。要用哪个语法时grep -A 10 for 循环 ~/.cheatsheets/shell_cheat.sh直接看片段比翻书或查博客更快。而且这个文件本身就是可执行的有些片段可以直接去掉注释跑一遍验证行为。你在实际使用中也可以照这个思路维护一份自己的速查文件因为每个人踩过的坑不一样你记录下的坑才是对你最值钱的速查条目。