ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Shell脚本实战指南:从基础语法到高级特性与避坑技巧

Shell脚本实战指南:从基础语法到高级特性与避坑技巧

在服务器上待久了就会发现,Shell脚本是绕不开的日常。哪怕现在自动化平台、配置管理工具已经很成熟,但你每天敲的别名、定时任务、发布脚本、日志清理,十有八九最后都落在“一段linux shell写法”上。这篇文章的由来也很简单:把Shell的基础结构、数据处理、流程控制、函数与高级特性按实际使用频率重新过一遍,每一段都给出可以直接复制去改的写法,而不是把手册抄一遍。刚接触Shell的同学可以按顺序跟一遍,写过一阵子的老手可以直接翻到函数和高级特性部分看,重点放在那些“平时没留意、一踩一个坑”的细节上。文中脚本默认在Bash环境跑,版本不挑,5.x和3.x差不了太多,个别语法我会专门标出来。

1. 基础结构:一个脚本长什么样

1.1 第一行写什么,决定了脚本的脾气

开头这行叫shebang,作用是告诉内核用哪个解释器来跑这个文件。我见得最多的是两种写法:

#!/bin/bash #!/usr/bin/env bash

第一种直接指定路径,简单粗暴,绝大多数Linux发行版都能用。第二种通过env去PATH里找bash,好处是兼容性好,尤其在macOS或者某些自定义安装路径的环境里,不用关心bash装在哪。副作用也不是没有:如果PATH被改了,可能找到的不是系统自带的那个bash,反而出问题。我的建议是服务器上够用就行,用#!/bin/bash;如果是写给别人用、可能跨系统的工具脚本,用#!/usr/bin/env bash。

第二行起手,老手基本人手一套:

set -euo pipefail

这三个选项解释一下:

  • -e:脚本里任何一条命令返回非零状态,立刻退出。避免一条命令失败了,后面继续往下跑,最后报一个八竿子打不着的错误。
  • -u:使用未定义变量直接报错退出。拼错变量名是Shell脚本里最高频事故,这个选项能救你无数次。
  • -o pipefail:管道中只要有一个命令失败,整条管道的退出状态就是失败的。默认情况下管道只看最后一条命令的结果,比如cmd1 | cmd2,如果cmd1挂了但cmd2正常结束,Shell会当无事发生。

注意-e有个反直觉的地方:放在if条件里、或者放在&&、||左边的命令,即使失败了也不会中断脚本。这是Shell专门留的口子,因为这类命令本来就是拿来测状态的。理解了这点,你就能明白为什么有时候加了set -e,某些“看起来该退出”的场景却没退出,这不是Bug,是设计。

1.2 变量、引号和位置参数

Shell变量本身没有类型,存进去的是字符串。赋值不能加空格,读取要加$:

name="zhang3" echo "$name"

这里最容易翻车的是引号。加双引号,变量和命令替换照常工作,但可以防止空格把参数拆成多段;加单引号,里面是什么就是什么,$不解析。我个人的习惯是:变量几乎永远用双引号包起来,尤其是"$file"这种路径场景。你永远不知道文件名里会不会冒出个空格,不加引号一拆两段,后面所有逻辑全乱。

脚本跑起来以后,命令行传入的参数在这几个特殊变量里:

  • $0:脚本本身路径
  • $1、$2...:第1、第2个参数
  • $#:参数个数
  • $@:所有参数,每个参数独立
  • $?:上一条命令的退出状态码

参数展开也是一门手艺,最常用的是带默认值:

env=${1:-production}

意思是:如果第一个参数传了就用它,没传就给个production。这样写脚本的人不需要记一堆判断逻辑。还有一个常用写法是${var:?错误信息},变量为空或者没定义就直接报错退出,特别适合配置检查。

1.3 一个可以直接抄的骨架

新脚本我通常按这个模板起头:

#!/usr/bin/env bash set -euo pipefail # 全局变量 readonly APP_NAME="demo" # 函数定义放前面 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" } # 主逻辑 main() { log "start" # ... log "done" } main "$@"

把main "$@"写在最后,整个脚本的执行路径是清晰的:定义工具函数,然后调用main。即使脚本后面长了,也不会出现“上半段定义、下半段散着执行、中间穿插函数”这种面条结构。开头那段set -euo pipefail不是仪式感,我见过太多生产事故,最终都能追溯到“某个脚本在中间某一步静默失败了,后面还在继续跑”。加一行,成本为零。

2. 数据处理:管道是Shell的灵魂

2.1 输入输出和重定向,先搞懂数据流

Shell处理数据的方式和普通编程语言不一样:它不是声明变量然后传递,而是用管道把一条命令的输出直接喂给下一条命令。三个标准通道要记住:标准输入(0)、标准输出(1)、标准错误(2)。

重定向就是把它们改道:

# 标准输出覆盖写文件 command > file.log # 追加写 command >> file.log # 标准错误单独存 command 2> error.log # 输出和错误都进同一个文件 command > all.log 2>&1

2>&1的意思是“把错误输出重定向到当前标准输出所在的地方”。很多新手写command 2> all.log 1> all.log,结果文件被后打开的那个覆盖,内容互相冲掉,两行字的事非写成四个重定向。需要同时看输出和错误,又要留底,用tee:

command 2>&1 | tee run.log

屏幕上能看到完整内容,文件里也存了一份,这个组合在跑部署脚本时特别实用。

2.2 文本处理三件套实战

数据处理的常规操作就是过滤、切分、替换。三件套各管一摊:

  • grep:按行筛选
  • sed:按行做替换、删除、插入
  • awk:按列切分并做计算

看个实际场景,假设有一份访问日志,要统计访问次数最多的前10个IP:

cat access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -10

一步步拆:awk按空格切列,第一列是IP;sort把相同的IP排到一起;uniq -c统计每个IP出现次数;sort -rn按数字倒序;head -10截取前十。这条命令不需要写任何编程逻辑,全是现成的小工具拼出来的,这就是Shell的风格。

再看一个需求:提取所有返回码为404的行,并且统计数量:

# 筛选 grep '" 404 ' access.log # 统计 grep -c '" 404 ' access.log

如果要顺手算一下404请求占总请求的百分比,就轮到awk出场:

awk '{code=$9} code==404 {n404++} {total++} END {printf "404占比: %.2f%%\n", n404*100/total}' access.log

这里awk把每行第9列当成状态码,两个计数器分别累加,最后在END块里算百分数。awk和grep、sed不一样的地方在于它能记住状态、能做累加,本质上是一个微型的逐行处理引擎。处理大文件时它不会一次性把文件载入内存,所以几百兆的日志用awk处理并不会把机器内存打爆。

2.3 管道思维和隐藏的子Shell坑

用管道有个容易踩的坑:管道右侧的命令是在一个子Shell里跑的。这句话的含义是,你在管道里给变量赋值,等管道结束,这个变量的值不会保留。看这段:

count=0 cat data.txt | while read -r line; do count=$((count + 1)) done echo "$count"

输出还是0,因为在while里改的是子Shell里的count,外面的count没动。想要保留循环里算出的值,改用进程替换:

count=0 while read -r line; do count=$((count + 1)) done < data.txt echo "$count"

< data.txt直接把文件导给while循环,整个循环跑在当前Shell里,变量修改就能带回主流程。凡是遇到“管道里的循环统计结果丢了”这类问题,先想想是不是子Shell的锅。

3. 流程控制:判断和循环怎么组织

3.1 判断条件:[]、[[]]到底用哪个

Shell的条件判断有三个常见写法:[ ]、[[ ]]、(( ))。三者的定位完全不同。

[ ]是对test命令的调用,是可移植写法,POSIX兼容,但里面用到的变量如果不加引号容易出问题,对字符串做模式匹配也麻烦。

[[ ]]是Bash的关键字,不用额外创建子进程,支持&&、||直接写在括号里,支持正则和模式匹配。能写[[ $var == "foo" ]],甚至写[[ $var =~ ^[0-9]+$ ]]。

(( ))专门做算术判断,里面的变量不用加$,写(( count > 5 ))很自然,适合数字场景。

给个实际例子,检查文件是否存在并且大小大于0:

if [[ -f "$file" && -s "$file" ]]; then echo "文件存在且非空" fi

字符串比较要特别注意:[[ "$a" == "$b" ]]比[[ $a == $b ]]稳妥,因为一旦$a里带有空格或者通配符,不加引号会被拆开。文件测试那一组最常用的是:

  • -f:是普通文件
  • -d:是目录
  • -x:可执行
  • -e:存在
  • -s:非空

3.2case做命令分发,比一长串if好看

当判断条件是多选一的时候,case比if链好读得多。经典的应用是命令行工具分发:

case "$1" in start|restart) echo "启动服务" ;; stop) echo "停止服务" ;; status) echo "查看状态" ;; *) echo "用法: $0 {start|stop|restart|status}" exit 1 ;; esac

注意每个分支末尾的两个分号,这是结束标志,漏掉一个后面全乱。*分支是兜底的,处理未知参数再合适不过。这种写法在旧式服务管理脚本里到处都是,现在你写自己的工具脚本,拿来处理“传一个动作参数分发到不同逻辑”的场景,依然干净利落。

3.3 循环:for、while、until

for最常见的形态是遍历列表:

for env in dev test prod; do echo "当前环境: $env" done

遍历文件:

for file in *.log; do echo "处理 $file" done

这里要感谢前面的引号习惯,如果文件名是“access log 2024.log”这种带空格的,for file in *.log也能安全处理,因为通配符展开时每个文件名是独立一个单词,但后续所有使用$file的地方还是必须加双引号,否则空格又会搞事。

while处理按行读取最顺手:

while read -r line; do echo "$line" done < config.txt

-r参数保留反斜杠的原始含义,不做的转义,读配置文件时建议一直带上。

循环控制就是break和continue,意义和别的语言一样。不过Shell里没有break n以外的奇技淫巧,break直接跳出当前循环,continue跳过本次迭代。写循环时我最常犯的错是忘了给变量做更新,导致死循环。为了防止脚本挂在那,一般会加个最大尝试次数:

attempt=0 until ping_success || [[ $attempt -ge 5 ]]; do attempt=$((attempt + 1)) sleep 1 done

until和while相反,条件为真时退出循环,适合“等到某个状态成立”的场景,抽奖等待、服务就绪检查都很常用。

3.4 批量重命名文件小实战

写一个批量重命名脚本,把当前目录下所有*.txt改成*.md:

#!/usr/bin/env bash set -euo pipefail for file in *.txt; do [[ -e "$file" ]] || continue new_file="${file%.txt}.md" mv -- "$file" "$new_file" echo "已重命名: $file -> $new_file" done

这里有两个细节。第一,*.txt如果没有匹配到文件,通配符会原样保留*.txt这个字符串,循环就会拿一个不存在的文件名进来看,所以先加[[ -e "$file" ]] || continue守一道,这种方式比设置nullglob更保守,代码在哪都能跑。第二,mv -- "$file"里的--是让后面带-开头的文件名不被当成选项,文件名千奇百怪,这种防御值得养成习惯。

4. 函数设计:把脚本写成可以组装的样子

4.1 函数定义和参数获取

Shell函数定义有两种写法:

function my_func { echo "hello" } my_func() { echo "hello" }

推荐第二种,少一个关键字,POSIX兼容性更好。函数调用和普通命令一样,不用加括号:

my_func my_func arg1 arg2

函数里的参数和脚本参数是同一套机制:$1、$2、$#、$@。但要注意,在函数内部$@是函数的参数,不是整个脚本的参数。想在函数里拿到调用者传的脚本原始参数,需要在进函数之前先复制一份,这在写复杂工具时是个常见的坑:

SCRIPT_ARGS=("$@") handle_args() { # 这里访问 ${SCRIPT_ARGS[@]} }

4.2 return、输出和作用域

return只能返回0到255之间的整数,通常用作状态码而不是返回值。要返回一段文本,得靠echo:

get_config_path() { echo "/etc/myapp/config.ini" } config_file=$(get_config_path)

这里用$( )把函数输出捕获到变量里,等价于函数的“返回值”。这种模式在Shell里非常常见,几乎可以模拟其他语言的字符串返回。

但有个坑:函数里如果同时有日志输出和真正的返回内容,日志也会混进结果里。比如:

get_ip() { echo "获取IP中..." >&2 echo "192.168.1.10" }

一行输出到标准错误,一行输出到标准输出。只有标准输出的内容会被$( )捕获,>&2那行只是打在屏幕上。这条经验在封装通用函数时很关键,不然你会发现变量里莫名其妙多了几行不该有的话。

作用域方面,函数内部定义变量默认是全局的。想让变量只在函数内生效,必须用local:

compute() { local temp="临时值" echo "$temp" }

不做local,函数里赋值的变量会污染整个脚本,脚本一长,排查这种问题极其痛苦。我的习惯是:函数内所有临时变量一律local,不管是循环计数器还是中间计算值。

4.3 函数库怎么组织

脚本多了以后,重复代码会累积:日志函数、检查依赖函数、格式化输出函数,几乎每个脚本里都有。这时候应该抽成一个公共文件,按需导入:

# lib/common.sh log_info() { echo "[INFO] $*"; } log_error() { echo "[ERROR] $*" >&2; } check_command() { local cmd="$1" if ! command -v "$cmd" >/dev/null 2>&1; then echo "缺少命令: $cmd" >&2 return 1 fi }

使用方:

source "$(dirname "$0")/lib/common.sh" log_info "开始部署" check_command "curl"

用source还是.来导入,效果一样,我个人更喜欢写source,语义更清楚。注意source会把文件内容直接粘到当前位置执行,所以公共文件里不要放那些跑起来有副作用的逻辑,只放函数定义和常量,否则每次导入都会执行一遍不该执行的代码。

还有一点要自觉:公共函数命名尽量加前缀,比如log_info、http_get,不要起info、get这种通用名,不然很容易和别的函数撞车。Shell没有命名空间,全靠自己约定。

5. 高级特性:实用才算高级

5.1 数组和关联数组

Bash支持两种数组。普通数组按索引排:

files=("a.log" "b.log" "c.log") echo "${files[0]}" echo "${files[@]}" # 全部元素 echo "${#files[@]}" # 元素个数

遍历:

for f in "${files[@]}"; do echo "$f" done

关联数组需要先用declare -A声明,键值对方式组织,适合做映射表:

declare -A ports=( [web]=8080 [api]=9090 ) echo "${ports[web]}"

遍历关联数组更要注意,${!ports[@]}取的是所有键,不是值:

for key in "${!ports[@]}"; do echo "$key -> ${ports[$key]}" done

数组一个高频用途是收集多个分组文件:configs=($(ls conf.d/))看着很爽,但文件名带空格会出问题,不如用通配符加引号的方式构造。能用数组表达的逻辑,别用一堆独立变量拼,尤其是数量不定的东西。

5.2 参数扩展:字符串处理的高级操作

Bash在处理字符串上有一组隐藏技能,都在${}里面。看几个最常用的:

url="https://example.com/api/v2/users" # 从开头匹配删除 "/" 后第一个点之前的内容 echo "${url#*/}" # example.com/api/v2/users echo "${url##*/}" # users # 从结尾匹配删除 echo "${url%/*}" # https://example.com/api/v2 echo "${url%%/*}" # https: # 替换 echo "${url/v3/v4}" # https://example.com/api/v4/users # 截取 echo "${url:0:8}" # https://

#是删除最短匹配,##删除最长匹配,%从尾部删最短,%%删最长。做路径解析、文件扩展名替换的时候,这组操作比起外部调用basename、dirname和sed更轻量,还不会引入子进程。日常脚本里${file%.log}这种写法,比echo "$file" | sed 's/\.log$//'快得多也干净得多。

5.3 算术运算和printf输出

整数运算用$(( )):

total=$((count1 + count2))

(( ))单独用是当命令,能直接在if里判断:

if (( total > 100 )); then echo "超过100" fi

自增自减写成:

((count++))

老式的expr和外部的let能不用就不用,纯粹是历史包袱。

输出方面,echo够用但不够精细。格式化场景交给printf:

printf "%-10s %5d\n" "name" 42

%s字符串、%d整数、%-10s左对齐占10位、%5d右对齐占5位。写表格、打报告的时候比echo拼接强太多。还有一点,printf不会自动加换行,格式串里的\n才是换行,所以常看到printf "\n"这行,是真的有用。

5.4 并行执行和防止重复运行

脚本里要并行跑任务,最直接的写法是把任务丢到后台然后wait:

for url in "${urls[@]}"; do curl -s "$url" -o "page_$(basename "$url")" & done wait echo "全部下载完成"

&把任务放后台,wait等所有后台任务结束。这种写法比串行快好几倍,但要注意资源:一次性开几百个后台任务会打满CPU或文件句柄,稳妥的做法是用xargs -P限并发数:

cat url_list.txt | xargs -P 5 -I {} curl -s {} -o page_$(basename {})

-P 5表示同时最多跑5个,比裸&更可控。

防止定时任务重复执行,也有个标准思路:文件锁。利用flock:

exec 9>/tmp/my_script.lock if ! flock -n 9; then echo "脚本已在运行,退出" exit 1 fi

exec 9>打开一个文件描述符,flock -n非阻塞地尝试拿锁,拿不到说明已经有实例在跑。比起用pgrep数进程,文件锁不会误伤同名命令,也不会出现“进程还在收尾、但新任务已经启动”的竞争问题。

6. 常见问题与避坑速查

6.1 高频报错对照表

把这些年遇到的典型报错整理成一张速查表,比单独讲更容易定位:

报错或现象原因解决办法
command not found命令不在PATH,或者脚本在Windows编辑过有CRLFcommand -v cmd检查;dos2unix转行符
No such file or directory执行脚本时路径不对,或缺少./前缀用绝对路径;同目录执行用./script.sh
变量值总是带个尾巴文件是CRLF,\r混进字符串用tr -d '\r'清理,或用s/\r$//
unary operator expected[ ]测试里变量为空或没加引号换成[[ ]],变量加引号
整数表达式错误拿空字符串做算术运算先给变量默认值,如${count:-0}
管道统计结果丢失子Shell里改了变量改用进程替换或重定向
$?不是预期值中间命令成功但判断命令返回了别的状态在判断语句前单独取$?保存
脚本开头报错shebang写错或文件没有执行权限chmod +x;shebang不带空格

6.2 调试三板斧

一旦脚本行为不符合预期,我按这个顺序排查:

  1. 语法检查
bash -n script.sh

有语法错误会直接报出来,但逻辑错误它管不了。

  1. 跟踪执行
bash -x script.sh

会把每条命令展开后的实际内容打出来,变量值、引号情况一目了然。觉得输出太吵,可以在脚本里用set -x和set +x把跟踪范围圈在怀疑的段落。

  1. 加调试钩子
export PS4='+ $LINENO: ' bash -x script.sh

PS4是-x模式下的提示符前缀,默认是+,加上$LINENO后每个命令会带行号,半小时就能定位问题,不用看花眼。脚本写得多的人一般会把PS4写进~/.bashrc,省的每次都要临时导出。

6.3 工程化习惯

最后聊点“脚本之外”的东西。Shell脚本虽然短,但也是代码。我自己坚持几条规矩:

第一,函数尽量短,超过50行说明逻辑该拆了。第二,成体量的脚本至少要有日志输出,时间戳打印到固定文件,别让错误信息只出现在终端,定时任务的报错尤其依赖这个。第三,写完后跑一遍shellcheck做静态检查,它抓出来的问题基本一半都是真实事故隐患。第四,脚本命名避免用test.sh这种通用名,你会在某个午后把自己绕晕的。

还有一条值得单独说:脚本也一样要测试。至少准备一个空输入、一个正常输入、一个带异常输入,跑通三个场景再丢到生产。Shell没有编译器帮你兜底,你写出来的每一行都是直接执行的生产代码。我见过一次非常惨痛的教训,某核心清理脚本因为没测试空目录场景,在没有任何文件的分区上跑,结果执行了rm -rf ${path}/*,变量为空展开了,等于清空了一个不该碰的目录。这种事故不是靠技巧能避免的,靠的就是“重要命令之前先判空”这套工程习惯。

写Shell这些年,我自己最大的体会是:大多数脚本难点不在语法,而在你要想清楚“数据怎么流”。命令与命令之间靠管道衔接,变量与函数之间靠引号守门,文本处理本质上是识别模式、切分字段、汇总统计。这三件事想通,剩下的就是熟练度问题。常见的写法骨架就摆在这,多写几个小工具,把本文最后的模板抄下来改一改,很快你就会发现,之前零散记住的grep参数、awk语法、数组操作,会慢慢串成一条线,最后变成你工具箱里随时能用的顺手家伙。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进