概述
Shell本身不做"计算",它只做两件事- 启动进程
- 把进程的输入输出接起来
- 管道
就是把前一个进程的stdout接到下一个进程的stdin - 重定向就是把
stdout/stderr重新指向文件
命令行基本
文件系统导航
pwdcdcd并不是一个独立程序,而是shell的内建命令- 如果它是外部程序,切换目录只会影响那个子进程,父
shell的目录不会变
ls
文件操作
touchmkdircpmvmv之所以既能"移动"又能"重命名",是因为在Linux文件系统层面,重命名本质就是"在同一目录下移动- 文件名只是目录里指向
inode(真正的数据结构)的一个标签而已 - 这也解释了为什么
mv在同一分区内几乎是瞬间完成的(不管文件多大),而跨分区mv会变慢(因为要真的复制数据再删除源)
rm
查看内容
cat- 一次性输出整个文件
less- 分页查看(可上下翻、搜索
/关键字、按q退出)
- 分页查看(可上下翻、搜索
head- 看文件开头
-n 20看前20行
tail- 看文件结尾
-n 20看后20行-f实时追踪(看日志必备)
- 本质
- 这几个命令都遵循同一个模式——读文件、把内容变成一串"字符流"往
stdout(标准输出)里写
- 这几个命令都遵循同一个模式——读文件、把内容变成一串"字符流"往
|
1 2 3 4 5 6 7 8 |
seq 1 1000 > numbers.txt # 生成一个有1000行的文件 head -5 numbers.txt tail -5 numbers.txt less numbers.txt # 按 / 搜索 "500",按 q 退出 # 模拟实时日志 ( for i in {1..5}; do echo "log line $i"; sleep 1; done ) >> live.log & tail -f live.log # 观察它实时刷新,Ctrl+C 结束 |
重定向
- 先理解三个"文件描述符":每个进程默认打开三个通道:
0 = stdin(标准输入)1 = stdout(标准输出)2 = stderr(标准错误)
- 它们默认都连着你的终端
- 键盘是输入,屏幕是输出
- 重定向和管道做的事情,就是把这些通道从"终端"改接到别的地方
- 重定向符号
| 符号 | 作用 |
> |
把 stdout 写入文件(覆盖) |
>> |
把 stdout 追加到文件末尾 |
< |
把文件内容作为 stdin |
2> |
把 stderr 重定向到文件 |
2>&1 |
把 stderr 重定向到"stdout 现在指向的地方"(顺序很关键) |
&> |
把 stdout 和 stderr 一起重定向(bash 简写) |
管道
- 把左边命令的
stdout,直接接到右边命令的stdin,两个进程之间不落地到文件,靠内核里的一个缓冲区传数据
|
1 2 3 |
command1 > file # 输出去文件 command1 < file # 输入来自文件 command1 | command2 # command1 的输出 = command2 的输入,两者同时运行 |
通配符
| 符号 | 含义 |
* |
匹配任意长度(含0)的任意字符 |
? |
匹配恰好一个任意字符 |
[abc] |
匹配方括号中任意一个字符 |
[a-z] |
匹配范围内任意一个字符 |
[!abc] 或 [^abc] |
匹配不在集合内的字符 |
- 本质
- 通配符匹配是
shell自己在命令执行前做的文本替换,跟被调用的程序毫无关系 - 也就是说,
ls *.txt这行命令,shell会先把*.txt展开成当前目录下所有匹配的文件名 - 然后才把展开后的结果
ls file1.txt file2.txt真正传给ls程序执行 - 这也是为什么在双引号里通配符不会展开(双引号只保留变量展开,关闭了通配符和空格分词),而单引号里通配符和变量都不展开
- 通配符匹配是
语法核心
变量与引用
- 定义与引用规则
- 变量在
shell里没有类型,全部是字符串(数值运算是"临时"把字符串当数字解析) $NAME和${NAME}是同一件事,花括号只是为了帮shell明确"变量名到哪里结束",跟后面文字紧挨着时必须用花括号
- 变量在
|
1 2 3 4 |
NAME="Alice" # 定义:等号两边绝对不能有空格 echo $NAME # 引用:Alice echo ${NAME} # 推荐写法,边界更清晰 echo "${NAME}12" # 不加花括号会变成找变量 NAME12(不存在),加了就是 Alice12 |
- 单引号 vs 双引号 vs 反引号
|
1 2 3 4 5 6 7 8 |
NAME="World" echo "Hello $NAME" # 输出: Hello World —— 双引号:展开变量,不展开通配符 echo 'Hello $NAME' # 输出: Hello $NAME —— 单引号:完全不展开,原样输出 FILES="*.txt" echo "$FILES" # 输出: *.txt —— 双引号里通配符不展开 echo $FILES # 输出: 实际匹配到的文件名列表 —— 不加引号时会被展开+分词 |
- 命令替换(把命令的输出捕获成字符串)
$(command)会先开一个子shell跑这个命令,把它的标准输出(去掉末尾换行)当成字符串,替换到原来的位置
|
1 2 3 4 5 6 |
TODAY=$(date +%F) # 推荐写法 TODAY2=`date +%F` # 老写法,反引号,不推荐(不易嵌套) echo "今天是 $TODAY" FILE_COUNT=$(ls | wc -l) echo "当前目录有 $FILE_COUNT 个文件" |
- 只读与取消定义
|
1 2 |
readonly PI=3.14 # 之后不能再修改 unset NAME # 删除变量 |
参数与输入
- 位置参数
$1 $2 ...是shell在调用脚本时自动帮你把命令行后面的字符串按空格切好装进去的"内建数组"(严格说不是数组,是一组特殊变量)$*和$@的区别只在加双引号遍历时才显现出来"$@"是唯一能正确处理"参数里带空格"情况的写法,写脚本时几乎永远用"$@"而不是$*或不加引号的$@
|
1 2 3 4 5 6 7 8 |
#!/bin/bash # 保存为 greet.sh,运行: ./greet.sh Tom 18 echo "脚本名: $0" echo "第一个参数: $1" echo "第二个参数: $2" echo "参数个数: $#" echo "所有参数(整体): $*" echo "所有参数(逐个): $@" |
|
1 2 |
for arg in "$@"; do echo "参数: $arg"; done # 正确保留每个参数原样(含空格的参数不会被拆开) for arg in "$*"; do echo "参数: $arg"; done # 把所有参数拼成一个字符串,只循环一次 |
read读取用户输入read从标准输入(stdin)逐行读取一行文本,按空格分割赋值给变量- 如果只给一个变量名,整行(含空格)都会赋给它
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/bin/bash echo "请输入你的名字:" read NAME echo "你好, $NAME!" # 一行内提示 + 读取 read -p "请输入年龄: " AGE echo "你的年龄是 $AGE" # 静默输入(比如密码) read -sp "请输入密码: " PWD echo # 换行 echo "密码长度: ${#PWD}" |
- 环境变量
export
条件判断
test / [ ] / [[ ]]三种写法(本质相同,推荐用[[ ]])
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
# 数值比较:-eq -ne -gt -lt -ge -le NUM=10 if [ "$NUM" -gt 5 ]; then echo "大于5" fi # 字符串比较:= != -z(空) -n(非空) NAME="Tom" if [[ "$NAME" == "Tom" ]]; then echo "是Tom" fi if [[ -z "$UNSET_VAR" ]]; then echo "变量是空的" fi # 文件测试:-f(是文件) -d(是目录) -e(存在) -x(可执行) -r(可读) -w(可写) if [ -f "/etc/passwd" ]; then echo "文件存在" fi if [ -d "/tmp" ]; then echo "目录存在" fi |
- 多分支
if / elif / else
|
1 2 3 4 5 6 7 8 9 |
#!/bin/bash read -p "输入分数: " SCORE if [ "$SCORE" -ge 90 ]; then echo "优秀" elif [ "$SCORE" -ge 60 ]; then echo "及格" else echo "不及格" fi |
- 逻辑组合
&&
|
1 2 3 4 5 6 7 |
if [ -f "config.ini" ] && [ -r "config.ini" ]; then echo "配置文件存在且可读" fi if [ "$1" == "-h" ] || [ "$1" == "--help" ]; then echo "显示帮助信息" fi |
case语句- 多分支字符串匹配,比一长串
elif更清晰
- 多分支字符串匹配,比一长串
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/bin/bash read -p "输入 y/n: " ANSWER case "$ANSWER" in y|Y|yes) echo "你选择了是" ;; n|N|no) echo "你选择了否" ;; *) echo "无效输入" ;; esac |
- 本质
[ 条件 ]实际上是在调用一个叫test的程序([本身就是这个程序的另一个名字)- 它执行完会返回一个"退出状态码"给
shell:0表示条件为真(成功),非0表示假(失败) if语句其实判断的是紧跟着它的命令的退出状态码,不是"布尔值"这种东西[[ ]]是bash的扩展语法,支持&&直接写在里面、支持正则匹配=~,比[ ]更安全好用,能用就优先用它
循环
for循环 —— 三种常见形态
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
# 1. 遍历列表 for color in red green blue; do echo "颜色: $color" done # 2. 遍历文件(利用通配符展开) for file in /tmp/demo/*.txt; do echo "处理文件: $file" done # 3. 数字范围 for i in {1..5}; do echo "第 $i 次" done # 4. C风格(适合需要步长/复杂条件的场景) for ((i=0; i<10; i+=2)); do echo "i = $i" done |
while循环 —— 最常用于逐行读文件IFS=(清空字段分隔符)防止read吃掉行首行尾空格-r防止反斜杠被转义误处理
|
1 2 3 4 |
# 逐行读取文件(最推荐、最不容易出问题的写法) while IFS= read -r line; do echo "读到一行: $line" done < input.txt |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
# 计数器型 while COUNT=1 while [ "$COUNT" -le 5 ]; do echo "计数: $COUNT" COUNT=$((COUNT + 1)) done # 无限循环 + break(常用于监控/守护脚本) while true; do echo "检查中..." sleep 2 # 假设满足某条件就退出 if [ -f "/tmp/stop_signal" ]; then break fi done |
until- 与
while相反,条件为假时才继续
- 与
|
1 2 3 4 5 |
COUNT=1 until [ "$COUNT" -gt 5 ]; do echo "计数: $COUNT" COUNT=$((COUNT + 1)) done |
break / continuefor...in本质是对一个"词列表"逐个赋值执行- 这个列表可以来自手写的字面量、通配符展开、命令替换
while本质是不断执行"条件命令",只要它的退出状态码是0就继续循环体
|
1 2 3 4 5 6 7 8 9 |
for i in {1..10}; do if [ "$i" -eq 3 ]; then continue # 跳过本次 fi if [ "$i" -eq 7 ]; then break # 直接终止循环 fi echo "$i" done |
函数
- 定义与调用
- 函数内部的
$1 $2 $@ $#指的是"调用这个函数时给的参数" - 跟脚本级别的
$1 $2是两套独立的东西
- 函数内部的
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
#!/bin/bash # 两种定义写法,效果一样 greet() { echo "Hello, $1!" } function greet2 { echo "Hi, $1!" } greet "Tom" # 调用,传参跟脚本传参一模一样,用 $1 $2 接收 greet2 "Jerry" |
- 返回值:只能是
0-255的整数状态码
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
is_even() { if [ $(( $1 % 2 )) -eq 0 ]; then return 0 # 0 表示"真/成功" else return 1 fi } is_even 4 if [ $? -eq 0 ]; then # $? 拿到上一条命令的退出状态码 echo "4 是偶数" fi # 更常见的直接用法 if is_even 6; then echo "6 是偶数" fi |
- 想返回"数据"(不是状态码)—— 用
echo+ 命令替换return在shell里从设计上就是给"退出状态码"用的,不是给"数据"用的- 这跟大多数编程语言的函数返回值语义不一样
|
1 2 3 4 5 6 7 |
add() { local sum=$(( $1 + $2 )) echo "$sum" # 不是打印给人看,是"返回"给调用者 } RESULT=$(add 3 5) echo "结果是 $RESULT" |
local关键字(变量作用域)- 如果不加
local,函数内部修改的就是外层的全局变量
- 如果不加
|
1 2 3 4 5 6 7 8 9 10 |
COUNT=100 modify() { local COUNT=1 # 加了 local,只在函数内部生效 COUNT=$((COUNT + 1)) echo "函数内 COUNT = $COUNT" } modify echo "函数外 COUNT = $COUNT" # 依然是 100,没被函数改动 |
- 用
source复用函数库source会把lib.sh的内容"原地展开"到当前shell里执行,而不是开子进程- 所以里面定义的函数和变量在
main.sh里能直接用
|
1 2 3 4 5 6 7 |
# lib.sh log_info() { echo "[INFO $(date +%T)] $1" } log_error() { echo "[ERROR $(date +%T)] $1" >&2 } |
|
1 2 3 4 |
# main.sh source lib.sh # 或写作: . lib.sh log_info "任务开始" log_error "出错了" |
数组
- 索引数组
|
1 2 3 4 5 6 7 8 9 10 11 |
FRUITS=("apple" "banana" "cherry") echo "${FRUITS[0]}" # apple —— 索引从0开始 echo "${FRUITS[@]}" # apple banana cherry —— 所有元素 echo "${#FRUITS[@]}" # 3 —— 数组长度 FRUITS+=("date") # 追加元素 echo "${FRUITS[@]}" # apple banana cherry date unset FRUITS[1] # 删除 banana echo "${FRUITS[@]}" # apple cherry date(注意:索引不会自动补齐) |
- 遍历数组
|
1 2 3 4 5 6 7 8 |
for fruit in "${FRUITS[@]}"; do echo "水果: $fruit" done # 带索引遍历 for i in "${!FRUITS[@]}"; do echo "索引 $i -> ${FRUITS[$i]}" done |
- 关联数组(
bash 4+,类似其他语言的字典/map)- 普通变量是"一个格子存一个字符串",数组是"一串编号连续(或不连续)的格子"
- 索引数组的下标是数字,关联数组的下标是字符串(本质是哈希表)
"${arr[@]}"加引号后,数组里每个元素会被当成独立的一个词处理(哪怕元素本身含空格),这跟"$@"的道理完全一致
|
1 2 3 4 5 6 7 8 9 10 11 |
declare -A AGES # 必须先声明为关联数组 AGES["Tom"]=18 AGES["Jerry"]=20 echo "${AGES["Tom"]}" # 18 echo "${!AGES[@]}" # 所有的 key: Tom Jerry echo "${AGES[@]}" # 所有的 value: 18 20 for name in "${!AGES[@]}"; do echo "$name 的年龄是 ${AGES[$name]}" done |
grep
本质
- 逐行扫描输入,用正则表达式做匹配,匹配成功就把整行打印出来(默认粒度是"行",不是"字符")
基本用法
|
1 2 3 4 5 6 7 |
grep "error" app.log # 找出包含 error 的行 grep -i "error" app.log # 忽略大小写 grep -v "error" app.log # 反向匹配:打印不包含 error 的行 grep -c "error" app.log # 只统计匹配的行数,不打印内容 grep -n "error" app.log # 打印时带上行号 grep -r "TODO" ./src # 递归搜索目录下所有文件 grep -l "error" *.log # 只打印"包含匹配内容的文件名",不打印具体行 |
正则相关
|
1 2 3 4 5 6 7 |
grep -E "error|warning" app.log # -E 启用扩展正则,支持 | 表示"或" grep -o -E "[0-9]{1,3}(\.[0-9]{1,3}){3}" app.log # -o 只打印匹配到的部分(这里是提取IP) # 常用正则符号 grep "^ERROR" app.log # ^ 表示行首 grep "failed$" app.log # $ 表示行尾 grep "[0-9]\{3\}" app.log # 基础正则里 {} 要转义;-E 模式下不用转义 |
单词边界(避免误匹配子串)
|
1 2 |
echo "cat category" | grep "cat" # 匹配两行都命中(category里也有cat) echo "cat category" | grep -w "cat" # -w 只匹配完整单词,只命中 "cat" |
sed
本质
- 逐行读取输入,对每一行按你给的"指令"做变换(替换、删除、插入),然后把结果输出
- 它不是交互式编辑器,是"批处理式"改写工具,特别适合脚本里自动化修改配置文件
最常用:替换 s/查找/替换/标志
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
echo "hello world" | sed 's/world/bash/' # hello bash # 只替换每行第一个匹配(默认行为) echo "aaa" | sed 's/a/b/' # baa # g 标志:替换该行所有匹配 echo "aaa" | sed 's/a/b/g' # bbb # 直接修改文件(-i = in-place) sed -i 's/debug=false/debug=true/' config.ini # 修改前先备份(推荐做法,-i.bak 会生成 config.ini.bak) sed -i.bak 's/debug=false/debug=true/' config.ini |
按行号/行范围操作
|
1 2 3 4 5 |
sed -n '5p' file.txt # 只打印第5行(-n 关闭默认打印,配合 p 才不会重复) sed -n '5,10p' file.txt # 打印第5到10行 sed '3d' file.txt # 删除第3行 sed '/^$/d' file.txt # 删除所有空行(正则匹配空行) sed '1,3d' file.txt # 删除第1到3行 |
按正则匹配做替换
|
1 2 3 |
sed 's/^/> /' file.txt # 每行开头加上 "> "(引用格式) sed 's/[0-9]\+/NUM/g' file.txt # 把所有数字替换成 NUM(基础正则要转义+) sed -E 's/[0-9]+/NUM/g' file.txt # -E 扩展正则,+ 不用转义,更直观 |
多条指令组合
|
1 2 3 |
sed -e 's/foo/bar/' -e 's/hello/hi/' file.txt # 或用分号 sed 's/foo/bar/; s/hello/hi/' file.txt |
其他
sed里&代表"整个匹配到的内容"\1 \2代表正则里用括号()捕获的分组,这在做"结构化替换"时非常有用:
|
1 2 |
echo "2024-01-15" | sed -E 's/([0-9]{4})-([0-9]{2})-([0-9]{2})/\3\/\2\/\1/' # 输出: 15/01/2024 —— 把年-月-日 转换成 日/月/年 |
awk
本质
awk把每一行自动按分隔符(默认是空格/制表符)切成若干"字段",可以直接用$1 $2 $3...引用第几列,$0代表整行- 本质上它是一个精简的"面向列"的编程语言,特别适合处理表格类、日志类数据
内置变量
| 变量 | 含义 |
$0 |
整行内容 |
$1, $2... |
第1、2...个字段 |
NF |
当前行的字段数(Number of Fields) |
NR |
当前是第几行(Number of Records) |
FS |
输入字段分隔符(默认是空格) |
OFS |
输出字段分隔符 |
基础用法
|
1 2 3 4 5 6 7 8 9 |
echo "Tom 18 Beijing" | awk '{print $1}' # Tom echo "Tom 18 Beijing" | awk '{print $1, $3}' # Tom Beijing echo "Tom 18 Beijing" | awk '{print NF}' # 3(字段个数) # 打印每行的行号和内容 awk '{print NR, $0}' file.txt # 打印最后一个字段(不用管一共几列) awk '{print $NF}' file.txt |
指定分隔符(处理 CSV / 冒号分隔的 /etc/passwd 等)
|
1 2 3 4 |
echo "tom,18,beijing" | awk -F',' '{print $2}' # 18 # 处理 /etc/passwd(冒号分隔),打印所有用户名和默认shell awk -F':' '{print $1, $NF}' /etc/passwd |
条件过滤(awk 也能当 grep 用,但更强)
|
1 2 3 4 5 6 7 8 |
# 只打印第2列大于18的行 echo -e "Tom 16\nJerry 20\nAnn 25" | awk '$2 > 18 {print $1}' # 结合正则匹配某一列 awk -F':' '$1 ~ /^root/' /etc/passwd # 打印用户名以root开头的行 # 打印字段数大于3的行 awk 'NF > 3' file.txt |
BEGIN 和 END 块(初始化和收尾统计)
BEGIN{}在读任何一行之前只执行一次(常用来做初始化、打印表头),主体{}对每一行都执行一次END{}在所有行处理完之后只执行一次(常用来做汇总输出)
|
1 2 3 4 |
echo -e "1\n2\n3\n4" | awk '{sum += $1} END {print "总和:", sum}' # 输出: 总和: 10 echo -e "1\n2\n3" | awk 'BEGIN{print "开始统计"} {sum+=$1} END{print "合计:", sum}' |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
# 统计日志里各类型出现次数 cat <<EOF > app.log INFO start service ERROR db connection failed INFO request handled WARNING slow query ERROR timeout INFO request handled EOF awk '{print $1}' app.log | sort | uniq -c # 3 INFO # 2 ERROR # 1 WARNING |
进程、信号与脚本健壮性
退出状态码 $?
Linux里几乎所有程序执行完都会返回一个0-255的整数给操作系统,0表示成功,非0表示各种类型的失败- 这是整个
Shell世界"判断对错"的唯一依据
|
1 2 3 4 5 6 7 8 9 10 11 12 |
ls /tmp echo $? # 0,因为 ls 成功了 ls /not_exist echo $? # 2(或其他非0值),因为目录不存在,ls 失败了 # $? 只保存"上一条命令"的状态码,用完就要立刻用,否则会被覆盖 grep "error" app.log STATUS=$? # 立刻保存下来,方便之后使用 if [ $STATUS -ne 0 ]; then echo "没有找到 error" fi |
- 自己的脚本里也可以主动设置退出码
|
1 2 3 4 5 6 7 |
#!/bin/bash if [ ! -f "$1" ]; then echo "错误:文件不存在" >&2 # 错误信息应该输出到 stderr,不是 stdout exit 1 # 非0,告诉调用者这次执行失败了 fi echo "文件存在,继续处理" exit 0 |
set 家族——让脚本"该死的时候赶紧死"
- 默认行为的"危险性"
Bash脚本默认情况下,就算某一行命令失败了,也会硬着头皮继续往下执行,这在自动化场景里非常危险
|
1 2 3 4 5 6 |
set -e # 任何命令返回非0状态码,立刻终止整个脚本 set -u # 使用了未定义的变量,立刻报错终止(防止拼写错误导致的静默bug) set -o pipefail # 管道中任意一环失败,整个管道的状态码就算失败(默认只看最后一个命令的状态码) # 生产脚本标准开头,强烈建议每个脚本都这样开头 set -euo pipefail |
set -e的例子
|
1 2 3 4 5 6 7 8 9 |
#!/bin/bash # 没有 set -e 时 cd /path/not/exist # 失败,但脚本继续往下跑! rm -rf ./* # 灾难:这其实是在当前目录(不是预期目录)执行删除 # 加了 set -e 后 set -e cd /path/not/exist # 失败,脚本立刻终止,不会往下执行危险操作 rm -rf ./* |
set -u的例子
|
1 2 3 4 5 |
#!/bin/bash set -u echo "用户名: $USRE_NAME" # 拼写错误!应该是 USER_NAME # 没有 -u:会静默输出 "用户名: "(空字符串),不容易发现bug # 有 -u:脚本立刻报错 "USRE_NAME: unbound variable",马上暴露问题 |
pipefail的例子
|
1 2 3 4 5 6 7 8 |
# 没有 pipefail 时,管道的"整体状态码"只看最后一个命令 false | true echo $? # 0(因为 true 成功了,尽管 false 失败了,但整体判定"成功") # 加了 pipefail set -o pipefail false | true echo $? # 1(只要管道里有一环失败,整体就算失败) |
- 一个真实场景
|
1 2 3 4 5 |
#!/bin/bash set -euo pipefail # 如果 grep 没找到任何内容(这本身也算"失败"),配合 pipefail 能被正确捕捉到 grep "ERROR" app.log | wc -l |
trap——捕获信号,保证清理工作一定执行
trap让你的脚本能"监听"某些信号或事件,一旦触发就执行你指定的清理函数- 最常见用途是保证脚本不管是正常结束、出错退出、还是被用户
Ctrl+C中断,都会执行清理临时文件等收尾工作
- 最常见用途是保证脚本不管是正常结束、出错退出、还是被用户
| 信号 | 触发场景 |
EXIT |
脚本任何原因结束时(这不是真正的Unix信号,是bash特有的"伪信号",最常用) |
INT |
用户按 Ctrl+C |
TERM |
被 kill 命令正常终止 |
ERR |
命令返回非0状态码时(需配合 set -e) |
- 最常用模式:
EXIT陷阱做清理
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#!/bin/bash set -euo pipefail TMP_DIR=$(mktemp -d) # 创建一个临时目录 cleanup() { echo "正在清理临时文件..." rm -rf "$TMP_DIR" } trap cleanup EXIT # 不管脚本怎么结束,退出前都会先跑一遍 cleanup echo "临时目录: $TMP_DIR" echo "some data" > "$TMP_DIR/data.txt" # 模拟中间出错 false # 这一行会因为 set -e 触发退出 echo "这行不会被执行" |
- 捕获
Ctrl+C
|
1 2 3 4 5 6 7 8 |
#!/bin/bash trap 'echo "收到中断信号,正在安全退出..."; exit 1' INT echo "开始长时间任务,按 Ctrl+C 试试" for i in {1..100}; do echo "处理中 $i" sleep 1 done |
- 同时处理多种情况
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#!/bin/bash LOCK_FILE="/tmp/myscript.lock" cleanup() { echo "释放锁文件" rm -f "$LOCK_FILE" } trap cleanup EXIT INT TERM # 无论正常退出、Ctrl+C、还是被kill,都执行cleanup if [ -f "$LOCK_FILE" ]; then echo "脚本已在运行中" >&2 exit 1 fi touch "$LOCK_FILE" echo "执行主逻辑..." sleep 5 echo "完成" |
后台任务与 wait
- 在命令末尾加
&,shell会立刻返回,把这个命令扔到"后台"作为一个独立的子进程运行,当前shell不会被阻塞,可以继续往下执行其他命令
|
1 2 3 4 5 |
sleep 5 & # 后台跑一个5秒的任务 echo "这行会立刻打印,不用等5秒" jobs # 查看当前有哪些后台任务 wait # 阻塞,直到所有后台任务都结束 echo "所有后台任务都完成了" |
- 并发跑多个任务,最后统一等待(提速的常见手段)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#!/bin/bash # 假设要同时ping多台机器,串行做很慢,改成并行 SERVERS=("192.168.1.1" "192.168.1.2" "192.168.1.3") for ip in "${SERVERS[@]}"; do ( if ping -c 1 -W 1 "$ip" &> /dev/null; then echo "$ip 可达" else echo "$ip 不可达" fi ) & # 每个ping都扔到后台并行执行 done wait # 等所有后台任务都跑完,脚本才继续/结束 echo "全部检测完成" |
- 获取后台进程的
PID和退出状态
|
1 2 3 4 5 |
sleep 3 & PID=$! # $! 是刚才那条后台命令的进程号 echo "后台任务PID: $PID" wait "$PID" echo "任务退出状态: $?" |
子 shell() vs 代码块 {}
()会fork出一个全新的子进程去执行里面的命令,这个子进程有自己独立的一份变量副本,执行完就消失,不会"泄漏"任何状态回父进程{}只是把多条命令"打包"在当前进程里顺序执行,没有开新进程,所以变量修改是"真实"的、会保留下来
|
1 2 3 4 5 6 7 8 9 |
# () 圆括号:在子shell里执行,内部变量修改不影响外部 VAR="外部" ( VAR="内部"; echo "子shell里: $VAR" ) echo "外部依然是: $VAR" # 外部:外部(没被子shell影响) # {} 花括号:在当前shell里执行,共享同一个环境 VAR="外部" { VAR="修改后"; echo "花括号里: $VAR"; } echo "外部现在是: $VAR" # 外部现在是:修改后(被真正修改了) |
一个"生产级"的备份脚本
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
#!/bin/bash set -euo pipefail SOURCE_DIR="/tmp/demo" BACKUP_DIR="/tmp/backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) TMP_WORK_DIR=$(mktemp -d) # 清理函数:无论成功失败都要删除临时工作目录 cleanup() { local exit_code=$? echo "清理临时目录: $TMP_WORK_DIR" rm -rf "$TMP_WORK_DIR" if [ $exit_code -ne 0 ]; then echo "脚本异常退出,状态码: $exit_code" >&2 fi exit $exit_code } trap cleanup EXIT INT TERM echo "开始备份任务 $(date)" if [ ! -d "$SOURCE_DIR" ]; then echo "错误:源目录不存在: $SOURCE_DIR" >&2 exit 1 fi mkdir -p "$BACKUP_DIR" # 复制到临时目录先做校验,再移动到正式目录(避免半成品文件) cp -r "$SOURCE_DIR"/* "$TMP_WORK_DIR/" tar -czf "$TMP_WORK_DIR/backup.tar.gz" -C "$TMP_WORK_DIR" . FINAL_FILE="$BACKUP_DIR/backup_${TIMESTAMP}.tar.gz" mv "$TMP_WORK_DIR/backup.tar.gz" "$FINAL_FILE" echo "备份完成: $FINAL_FILE" ls -lh "$FINAL_FILE" |
调试与工程规范
bash -x —— 单步跟踪执行过程
-x会在脚本每执行一行之前,先把"这行实际展开后的样子"打印到 stderr,前面带一个+号做标记
|
1 2 3 4 5 |
+ NAME=Tom + AGE=18 + '[' 18 -ge 18 ']' + echo 'Tom 是成年人' Tom 是成年人 |
- 只在脚本内部某一段开启调试(不用整个脚本都跟踪,避免输出太多干扰)
|
1 2 3 4 5 6 7 8 9 10 11 |
#!/bin/bash echo "开始,这段不追踪" set -x # 从这里开始追踪 PATH_VAR="/tmp/test" if [ -d "$PATH_VAR" ]; then echo "目录存在" fi set +x # 到这里关闭追踪 echo "这段之后又不追踪了" |
- 给追踪输出加更多信息(默认只有一个
+,脚本嵌套多层函数时容易分不清层级)
|
1 2 |
export PS4='+ ${BASH_SOURCE}:${LINENO}:${FUNCNAME[0]:-main}: ' bash -x script.sh |
shellcheck —— 静态代码检查
shellcheck是一个专门分析shell脚本的静态检查工具,它不运行你的脚本,而是通过分析代码文本,提前发现常见的语法陷阱、引用错误、可移植性问题
|
1 |
shellcheck script.sh |
脚本工程规范
- 标准脚本头部
|
1 2 3 4 5 6 7 8 9 |
#!/bin/bash # # 脚本名称: backup.sh # 功能说明: 备份指定目录并打包成带时间戳的压缩文件 # 用法: ./backup.sh <源目录> <目标目录> # 作者: xxx # 日期: 2026-09-08 # set -euo pipefail |
- 参数校验 +
--help
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
#!/bin/bash set -euo pipefail usage() { cat <<EOF 用法: $0 [选项] <目录> 选项: -h, --help 显示本帮助信息 -v, --verbose 显示详细输出 示例: $0 /tmp/data $0 -v /tmp/data EOF } VERBOSE=false # 简易参数解析 while [ $# -gt 0 ]; do case "$1" in -h|--help) usage exit 0 ;; -v|--verbose) VERBOSE=true shift ;; -*) echo "未知选项: $1" >&2 usage exit 1 ;; *) break ;; esac done if [ $# -lt 1 ]; then echo "错误:缺少必需参数<目录>" >&2 usage exit 1 fi TARGET_DIR="$1" echo "处理目录: $TARGET_DIR (verbose=$VERBOSE)" |
声明:本文为原创文章,版权归Aet所有,欢迎分享本文,转载请保留出处!
你可能也喜欢
- ♥ Linux_ 命令大全 Windows System03/16
- ♥ Linux 高性能服务器编程:高性能定时器12/18
- ♥ Cef:沙盒、CefApp、CefClient02/29
- ♥ Linux fork&&守护进程03/30
- ♥ Cef:介绍06/29
- ♥ Linux 高性能服务器编程:TCP二11/24