数据与 IO 处理
# 第 3 章 数据与 IO 处理
# 目录介绍
# 3.1 数组操作
# 3.1.1 索引数组——定义/访问/遍历
Bash 支持一维索引数组——索引从 0 开始,用 () 定义、空格分隔:
#!/bin/bash
# ===== 定义方式 =====
# 方式 1:直接赋值
fruits=("苹果" "香蕉" "橘子" "葡萄")
nums=(1 2 3 4 5)
mixed=("hello" 42 "world") # 可以混类型(但不推荐)
# 方式 2:逐个赋值
fruits[4]="西瓜" # 追加到索引 4
# 方式 3:从命令输出构造
files=($(ls *.txt)) # 把 ls 输出的每行作为数组元素
# ===== 访问元素 =====
echo "${fruits[0]}" # 苹果
echo "${fruits[1]}" # 香蕉
echo "${fruits[-1]}" # 西瓜(最后一个——bash 4.2+)
# ===== 遍历数组 =====
echo "--- 遍历全部元素 ---"
echo "${fruits[@]}" # 苹果 香蕉 橘子 葡萄 西瓜
echo "${fruits[*]}" # 苹果 香蕉 橘子 葡萄 西瓜
echo "--- for 循环遍历 ---"
for fruit in "${fruits[@]}"; do # 安全方式——保留元素内空格
echo " 水果:$fruit"
done
echo "--- 带索引遍历 ---"
for i in "${!fruits[@]}"; do # ${!array[@]} 展开为所有索引
echo " [$i] = ${fruits[$i]}"
done
# ===== 添加到末尾 =====
fruits+=("芒果" "草莓") # 追加多个元素
echo "${fruits[@]}" # 苹果 香蕉 橘子 葡萄 西瓜 芒果 草莓
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
🔑 ${array[@]} vs ${array[*]}——引号下的关键差异:
#!/bin/bash
items=("a b" "c" "d e")
echo "=== @ ==="
for item in "${items[@]}"; do # 每个元素独立——正确
echo " [$item]"
done
# 输出:
# [a b]
# [c]
# [d e]
echo "=== * ==="
for item in "${items[*]}"; do # 所有元素合并成一个字符串
echo " [$item]"
done
# 输出:
# [a b c d e] ← 全拼在一起了!
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
📌 铁律:遍历数组永远用
"${array[@]}"(双引号 + @)。
# 3.1.2 关联数组——Shell 的"字典"
Bash 4.0+ 支持关联数组——类似 Python 的 dict,需要 declare -A 声明:
#!/bin/bash
# ===== 声明与赋值 =====
declare -A student # 必须先 declare -A!
student["name"]="张三"
student["age"]="25"
student["city"]="深圳"
# 一次性赋值
declare -A config=(
["host"]="localhost"
["port"]="8080"
["user"]="admin"
)
# ===== 访问 =====
echo "姓名:${student[name]}" # 张三(key 可以加引号也可以不加)
echo "年龄:${student[age]}" # 25
# ===== 遍历键值对 =====
for key in "${!student[@]}"; do # ${!array[@]} → 所有键
echo " $key = ${student[$key]}"
done
# 输出:
# name = 张三
# age = 25
# city = 深圳
# ===== 判断 key 是否存在 =====
if [[ -v "student[phone]" ]]; then # -v 检查变量/键是否存在(bash 4.2+)
echo "有手机号"
else
echo "没有手机号"
fi
# ===== 删除某个键 =====
unset "student[age]"
echo "删除后:${!student[@]}" # name city
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
text="the quick brown fox jumps over the lazy dog the fox"
declare -A freq
for word in $text; do
((freq[$word]++)) # 自动计数——第一次访问时值=0,++后=1
done
for word in "${!freq[@]}"; do
echo " $word → ${freq[$word]}"
done
# 输出:
# over → 1
# quick → 1
# the → 3
# ...
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 3.1.3 数组长度、切片与 CRUD
#!/bin/bash
fruits=("苹果" "香蕉" "橘子" "葡萄" "西瓜" "芒果" "草莓")
# ===== 数组长度 =====
echo "元素个数:${#fruits[@]}" # 7
echo "索引个数:${#fruits[*]}" # 7
echo "第一个元素长度:${#fruits[0]}" # 2("苹果"占 2 个字符)
# ===== 切片 =====
echo "${fruits[@]:1:3}" # 香蕉 橘子 葡萄(索引1开始,取3个)
echo "${fruits[@]:3}" # 葡萄 西瓜 芒果 草莓(索引3到最后)
echo "${fruits[*]:2:2}" # 橘子 葡萄
# ===== 替换元素 =====
fruits[1]="火龙果" # 替换索引 1
echo "${fruits[@]}" # 苹果 火龙果 橘子 葡萄 西瓜 芒果 草莓
# ===== 删除元素 =====
unset fruits[4] # 删除索引 4(西瓜)
echo "${fruits[@]}" # 苹果 火龙果 橘子 葡萄 芒果 草莓
echo "索引列表:${!fruits[@]}" # 0 1 2 3 5 6 ← 索引不连续了
# ===== 清空数组 =====
fruits=() # 全部清空
echo "清空后:${#fruits[@]}" # 0
# ===== 合并数组 =====
a=(1 2 3)
b=(4 5 6)
combined=("${a[@]}" "${b[@]}")
echo "${combined[@]}" # 1 2 3 4 5 6
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
🔑 数组 CRUD 速查表:
| 操作 | 语法 | 说明 |
|---|---|---|
| 定义 | arr=(a b c) | 小括号 + 空格分隔 |
| 访问 | "${arr[i]}" | 索引从 0 开始 |
| 遍历 | for e in "${arr[@]}" | 安全遍历 |
| 长度 | "${#arr[@]}" | 元素个数 |
| 追加 | arr+=("x") | 自动追加到末尾 |
| 切片 | "${arr[@]:i:n}" | 从索引 i 取 n 个 |
| 删除 | unset arr[i] | 删除后索引不重排 |
| 清空 | arr=() | 全部清空 |
| 拷贝 | copy=("${arr[@]}") | 复制数组 |
# 3.2 字符串处理
# 3.2.1 长度/截取/替换/删除
Shell 的 ${} 提供了丰富的字符串内建操作——不需要调用外部命令:
#!/bin/bash
str="Hello, World! Welcome to Shell."
# ===== 1. 字符串长度 =====
echo "长度:${#str}" # 30
echo "长度:$(echo -n "$str" | wc -c)" # 30(外部命令方式——慢)
# ===== 2. 子串截取 =====
echo "${str:0:5}" # Hello(从 0 取 5 个字符)
echo "${str:7:5}" # World(从 7 取 5 个)
echo "${str:7}" # World! Welcome to Shell.(从 7 到结尾)
echo "${str:(-6)}" # Shell.(从末尾往前 6 个)
echo "${str:(-6):3}" # She(从末尾往前 6 个取 3 个)
# ===== 3. 字符串替换 =====
# 替换第一个匹配
echo "${str/World/宇宙}" # Hello, 宇宙! Welcome to Shell.
# 替换所有匹配
text="foo foo foo bar"
echo "${text//foo/bar}" # bar bar bar bar
# 行首替换
echo "${str/#Hello/Hi}" # Hi, World! Welcome to Shell.
echo "${str/#Hi/Hi}" # 不匹配——原样输出
# 行尾替换
echo "${str/%Shell/Bash}" # Hello, World! Welcome to Bash.
echo "${str/%Bash/Bash}" # 不匹配——原样输出
# ===== 4. 字符串删除(模式匹配)=====
file="backup_2025-06-07.tar.gz"
# 从开头删除最短匹配(#)
echo "${file#backup_}" # 2025-06-07.tar.gz
echo "${file#*/}" # 如果文件有路径...不匹配,原样
# 从开头删除最长匹配(##)
echo "${file##*_}" # 2025-06-07.tar.gz(和上一个一样——因为最短/最长一致)
url="https://example.com/path/file.txt"
echo "${url##*/}" # file.txt(删除最后一个 / 前的所有)
echo "${url#*/}" # /example.com/path/file.txt(删除第一个 / 前的所有)
# 从末尾删除最短匹配(%)
echo "${file%.tar.gz}" # backup_2025-06-07
echo "${file%.*}" # backup_2025-06-07.tar(最短匹配——删掉 .gz)
# 从末尾删除最长匹配(%%)
echo "${file%%.*}" # backup_2025-06-07(最长匹配——删掉 .tar.gz)
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
50
🔑 #/% 模式删除速查表:
| 语法 | 含义 | 示例:"abc.def.ghi" |
|---|---|---|
${var#pattern} | 行首最短删除 | ${var#*.} → def.ghi |
${var##pattern} | 行首最长删除 | ${var##*.} → ghi |
${var%pattern} | 行尾最短删除 | ${var%.*} → abc.def |
${var%%pattern} | 行尾最长删除 | ${var%%.*} → abc |
# ===== 实战:批量修改文件扩展名 =====
for file in *.jpeg; do
mv "$file" "${file%.jpeg}.jpg" # .jpeg → .jpg
done
# 实战:提取文件名和路径
fullpath="/home/user/data/file.txt"
filename="${fullpath##*/}" # file.txt
dir="${fullpath%/*}" # /home/user/data
basename="${filename%.*}" # file
ext="${filename##*.}" # txt
echo "目录:$dir"
echo "文件名:$filename"
echo "不含扩展名:$basename"
echo "扩展名:$ext"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 3.2.2 大小写转换与模式匹配
#!/bin/bash
# ===== 大小写转换(bash 4.0+)=====
msg="Hello World"
echo "${msg,,}" # hello world(全小写)
echo "${msg^^}" # HELLO WORLD(全大写)
echo "${msg,}" # hello World(首字母小写)
echo "${msg^}" # Hello World(首字母大写)
# ===== 模式匹配——[[ ]] 的 == 和 =~ =====
phone="13812345678"
email="user@example.com"
ip="192.168.1.1"
# 通配符匹配
if [[ "$phone" == 138* ]]; then
echo "是联通号码"
fi
# 正则匹配
if [[ "$email" =~ ^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$ ]]; then
echo "有效邮箱"
fi
if [[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then
echo "有效的 IP 地址"
fi
# ===== 检查包含关系 =====
if [[ "$msg" == *"World"* ]]; then
echo "包含 World"
fi
# ===== 多条件模式匹配 =====
user_input="yes"
case "${user_input,,}" in # 转小写后再匹配
y | yes | yep | yeah)
echo "确认"
;;
n | no | nope)
echo "取消"
;;
*)
echo "输入有误"
;;
esac
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
# 3.2.3 Here Document 与 Here String
Here Document(<<)——在脚本里嵌入多行文本:
#!/bin/bash
# ===== 基础用法 =====
cat <<EOF
这是第一行
这是第二行
当前用户是 $USER
EOF
# ===== 阻止变量展开——定界符加引号 =====
cat <<'EOF' # EOF 加引号——不展开变量!
变量不会被展开:$USER # 输出:$USER(原样)
$(date) # 输出:$(date)(原样)
EOF
# ===== <<- 自动忽略行首 Tab =====
if true; then
cat <<-EOF
这一行的 Tab 缩进会被忽略
包括这一行也是
EOF
fi
# 输出(无缩进):
# 这一行的 Tab 缩进会被忽略
# 包括这一行也是
# ===== 实战:生成配置文件 =====
port=8080
db_name="myapp"
cat > config.yaml <<EOF
server:
port: $port
database:
name: $db_name
host: localhost
EOF
# ===== 实战:生成 HTML 报告 =====
title="系统巡检报告"
timestamp=$(date '+%Y-%m-%d %H:%M:%S')
cat > report.html <<HTML_END
<!DOCTYPE html>
<html>
<head><title>$title</title></head>
<body>
<h1>$title</h1>
<p>生成时间:$timestamp</p>
<p>主机:$(hostname)</p>
</body>
</html>
HTML_END
# ===== 输出到变量(而不是文件)=====
html_content=$(cat <<EOF
<ul>
<li>$USER</li>
<li>$(hostname)</li>
</ul>
EOF
)
echo "$html_content"
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
50
51
52
53
54
55
56
57
58
59
60
61
Here String(<<<)——把字符串当作 stdin 输入:
#!/bin/bash
# ===== 基础 =====
# 不用 echo + 管道
echo "hello world" | wc -c # 12(包括换行符)
# 用 Here String——更简洁
wc -c <<< "hello world" # 12(同样包括换行符)
# ===== 配合 read 使用 =====
read -r first rest <<< "apple banana orange"
echo "first=$first, rest=$rest" # first=apple, rest=banana orange
# ===== 配合 grep/sed/awk =====
grep -o '[0-9]\+' <<< "abc123def456" # 123\n456
sed 's/foo/bar/g' <<< "foo foo foo" # bar bar bar
awk '{print $2, $1}' <<< "name=张三 age=25"
# ===== 字符串分割到数组 =====
csv="apple,banana,orange"
IFS=',' read -ra items <<< "$csv" # -r 保留反斜杠,-a 读入数组
echo "items[0]=${items[0]}" # apple
echo "items[1]=${items[1]}" # banana
echo "items[2]=${items[2]}" # orange
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 3.2.4 printf 格式化输出
echo 够用但不够灵活——printf 是 Shell 的格式化神器(和 C 语言的 printf 语法一致):
#!/bin/bash
# ===== 基础——%s 字符串 / %d 整数 / %f 浮点 =====
printf "姓名:%s,年龄:%d\n" "张三" 25
# 姓名:张三,年龄:25
# ===== 宽度与对齐 =====
printf "|%-10s|%10s|\n" "左对齐" "右对齐"
# |左对齐 | 右对齐|
# ===== 浮点数精度 =====
printf "圆周率:%.2f\n" 3.14159 # 圆周率:3.14
printf "百分比:%.1f%%\n" 85.678 # 百分比:85.7%
# ===== 多个参数重复使用格式 =====
printf "%s\n" 苹果 香蕉 橘子 # 每个一行
# 苹果
# 香蕉
# 橘子
# ===== 实战:表格输出 =====
header="%-10s %-8s %-10s\n"
row="%-10s %-8s %-10s\n"
printf "$header" "姓名" "年龄" "城市"
printf "$header" "------" "----" "--------"
printf "$row" "张三" "25" "深圳"
printf "$row" "李四" "30" "北京"
printf "$row" "王五" "28" "上海"
# 姓名 年龄 城市
# ------ ---- --------
# 张三 25 深圳
# 李四 30 北京
# 王五 28 上海
# ===== 实战:对齐数字 =====
for i in 1 12 123 1234; do
printf "序号:%4d\n" "$i" # 右对齐,占 4 位
done
# 序号: 1
# 序号: 12
# 序号: 123
# 序号:1234
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
🔑 echo vs printf 对比:
| 特性 | echo | printf |
|---|---|---|
| 换行 | 默认加(-n 取消) | 需要 \n |
| 格式化 | ❌ 不支持 | ✅ %s %d %f 等 |
| 对齐 | ❌ | ✅ 宽度 + 左右对齐 |
| 转义 | -e 才能启用 | 原生支持 \n \t |
| 跨平台 | macOS/Linux 行为不同 | ✅ 行为一致 |
📌 打印变量用
echo,格式化输出/表格用printf。
# 3.3 重定向与管道
# 3.3.1 stdin/stdout/stderr——三剑客
每个 Linux 进程启动时都有三个标准文件描述符——它们是 Shell 重定向的基石:
┌──────────────┐
stdin (0) ←──────│ │
(键盘输入) │ 进程 │──────→ stdout (1) ——正常输出(屏幕)
│ │──────→ stderr (2) ——错误输出(屏幕)
└──────────────┘
2
3
4
5
# 查看标准输入/输出/错误对应的设备
ls -l /dev/stdin # lrwxrwxrwx ... /dev/stdin -> /proc/self/fd/0
ls -l /dev/stdout # lrwxrwxrwx ... /dev/stdout -> /proc/self/fd/1
ls -l /dev/stderr # lrwxrwxrwx ... /dev/stderr -> /proc/self/fd/2
2
3
4
🔑 三者用途:
| 描述符 | 编号 | 默认去向 | 用途 |
|---|---|---|---|
| stdin | 0 | 键盘 | 读取输入 |
| stdout | 1 | 屏幕 | 正常输出 |
| stderr | 2 | 屏幕 | 错误/诊断信息(与正常输出分离) |
为什么 stderr 和 stdout 要分开?
#!/bin/bash
# 假设这个脚本正常输出和处理结果,错误输出诊断信息
echo "文件处理完成" # stdout → 结果报告
echo "处理了 100 条记录" >> /var/log/app.log # 追加到日志
ls /nonexistent >/dev/null # stderr → 错误信息(默认显示到屏幕)
# 分开的意义:
# ① 用户可以在屏幕上看到错误,同时把结果重定向到文件
# ② 管道只传递 stdout——stderr 不会被意外塞进下游
# ③ CI/CD 中可以根据 stderr 是否为空判断构建状态
2
3
4
5
6
7
8
9
10
11
# 3.3.2 > / >> / < / << 四大家族
#!/bin/bash
# ===== > —— 覆盖写入(截断后写入)=====
echo "第一行" > /tmp/test.txt # 写入/覆盖
# 文件内容:第一行
# ===== >> —— 追加写入 =====
echo "第二行" >> /tmp/test.txt # 追加
# 文件内容:
# 第一行
# 第二行
# ===== < —— 从文件读取 =====
cat < /tmp/test.txt # 把文件内容作为 cat 的输入
# ===== 2> —— 重定向 stderr =====
ls /nonexistent 2> /dev/null # 错误信息进黑洞——屏幕干净了
# ===== 2>&1 —— 合并 stderr 到 stdout =====
cmd="ls /existing /nonexistent"
$cmd > /tmp/output.txt 2>&1 # stdout 和 stderr 都写入同一个文件
# ===== 现代写法(更清晰)=====
$cmd > /tmp/output.txt 2>&1 # 传统写法
$cmd &> /tmp/output.txt # bash 4.0+ 简写——等价
# ===== 追加 stderr 到 stdout =====
$cmd >> /tmp/output.txt 2>&1 # 追加模式
$cmd &>> /tmp/output.txt # bash 4.0+ 简写
# ===== 丢弃所有输出 =====
$cmd > /dev/null 2>&1
$cmd &> /dev/null # 等价简写
# ===== 只保留 stderr,丢弃 stdout =====
$cmd > /dev/null # stdout → 黑洞,stderr → 屏幕
# ===== 管道只传 stdout,忽略 stderr =====
$cmd 2>&1 | grep "error" # 如果 stderr 里也有 error,先合并再 grep
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
🔑 重定向语法速查表:
| 语法 | 含义 | 示例 |
|---|---|---|
> | stdout 覆盖写入 | echo hi > file |
>> | stdout 追加写入 | echo hi >> file |
< | 从文件读取 stdin | cat < file |
2> | stderr 覆盖写入 | cmd 2> err.log |
2>> | stderr 追加写入 | cmd 2>> err.log |
2>&1 | stderr 合并到 stdout | cmd > all.log 2>&1 |
&> | stdout+stderr 覆盖 | cmd &> all.log |
&>> | stdout+stderr 追加 | cmd &>> all.log |
# ===== 实战:正确分离正常输出和错误 =====
script_runner() {
echo "开始处理..." # stdout
some_command_that_might_fail
if [[ $? -ne 0 ]]; then
echo "处理失败!" >&2 # 显式写到 stderr
return 1
fi
echo "处理完成" # stdout
}
# 调用——用户可以这样:
script_runner > result.log 2> error.log
# result.log:开始处理...\n处理完成
# error.log:处理失败!(如果有的话)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 3.3.3 管道 | ——命令协作的艺术
管道(|)把前一个命令的 stdout 连接到后一个命令的 stdin——
cmd1 │ cmd2 │ cmd3
stdin → │ stdin → │ stdin → 最终输出
2
#!/bin/bash
# ===== 入门三件套 =====
grep "ERROR" /var/log/app.log | wc -l # 统计错误行数
cat /etc/passwd | cut -d: -f1 | sort # 排序所有用户名
ps aux | grep nginx | awk '{print $2}' # 获取 nginx 进程 PID
# ===== 长管道 =====
# 找出访问量 Top 10 的 IP
cat access.log \
| awk '{print $1}' \
| sort \
| uniq -c \
| sort -rn \
| head -10
# ===== 管道 + xargs ——把前一个的输出作为后一个的参数 =====
# find + xargs(比 find -exec 更高效——批量传参)
find /var/log -name "*.log" -mtime +7 | xargs rm -f # 删除7天前的日志
# xargs 的 -n 控制每次传几个参数
echo "a b c d e f" | xargs -n 3 echo # 每次 echo 3 个
# a b c
# d e f
# xargs 的 -P 并行执行
find . -name "*.jpg" -print0 | xargs -0 -P 4 -I {} convert {} {}.png
# -print0 / -0:空字符分割(应对文件名含空格/换行)
# -P 4:4 个进程并行
# -I {}:用 {} 占位符
# ===== 管道 + tee:同时输出到屏幕和文件 =====
echo "启动服务..." | tee -a /var/log/setup.log
# ===== 管道 + 子 Shell:在子 Shell 中处理 =====
# 统计当前目录的文件大小分布
find . -type f -size +10M | while read -r f; do
echo "大文件:$f"
done
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
⚠️ 管道陷阱——set -o pipefail:
set -e # 遇到错误就退出
# ❌ 管道中只有最后的命令决定退出码
cat nonexistent.txt | wc -l # cat 失败了,但 wc -l 成功(输出 0)
echo "还会继续执行——危险!" # ← set -e 没拦截
# ✅ 加 pipefail——管道中任何命令失败都算失败
set -eo pipefail
cat nonexistent.txt | wc -l # 立即退出
echo "不会执行到这里"
2
3
4
5
6
7
8
9
10
# 3.3.4 tee 分流与 /dev/null 黑洞
tee——T 型分流器,数据一份给屏幕、一份给文件:
#!/bin/bash
# ===== 基础:同时输出到屏幕和文件 =====
echo "Hello, World!" | tee output.txt
# 屏幕显示:Hello, World!
# 文件 output.txt:Hello, World!
# ===== -a 追加模式 =====
echo "第一行" | tee output.txt # 覆盖
echo "第二行" | tee -a output.txt # 追加
# ===== 写入多个文件 =====
echo "重要日志" | tee log1.txt log2.txt log3.txt
# ===== 结合 sudo——tee 解决权限问题 =====
# echo 后面跟着 > 重定向——但当前用户没有写权限时:
echo "nameserver 8.8.8.8" | sudo tee -a /etc/resolv.conf
# ===== 保留 stderr =====
cmd 2>&1 | tee output.txt # 标准输出和错误都进文件和屏幕
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/dev/null——Linux 的"黑洞":
#!/bin/bash
# ===== 丢弃不需要的输出 =====
find / -name "*.log" 2>/dev/null # 只显示找得到的,忽略权限错误
# ===== 丢弃所有输出 =====
some_noisy_command > /dev/null 2>&1 # 静默执行
# ===== 测试命令是否成功(只看退出码,不看输出)=====
if grep -q "pattern" /var/log/app.log 2>/dev/null; then
echo "找到匹配"
fi
# ===== 创建空文件(清空文件内容)=====
> /tmp/empty_file.txt # 等价于 truncate -s 0
# ===== 验证 /dev/null 的特性 =====
cat /dev/null | wc -c # 0——永远是空的
dd if=/dev/zero of=/dev/null bs=1M count=1000 # 写 1GB 到黑洞——瞬间完成
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 3.3.5 文件描述符——深入理解 0/1/2
除了标准三剑客(0/1/2),Shell 还允许你操作额外的文件描述符:
#!/bin/bash
# ===== 在脚本内重定向自己的 stdin/stdout/stderr =====
exec 3> /tmp/debug.log # 打开文件描述符 3 用于写入
echo "调试信息" >&3 # 写入 fd 3 → debug.log
exec 3>&- # 关闭 fd 3
# ===== exec 全局重定向 =====
# 脚本内所有 stdout 都写入文件
exec > /tmp/build.log 2>&1 # 自此以后所有输出都去文件
echo "这行不会出现在屏幕" # → /tmp/build.log
ls /nonexistent # 错误也去文件
echo "构建结束"
# ===== 交换文件描述符 =====
# 临时保存 stdout,重定向到 stderr
exec 3>&1 # 把当前 stdout 存到 fd 3
exec 1>&2 # stdout → stderr(都去错误输出)
echo "这行是错误输出" # 这行现在去 stderr
exec 1>&3 # 恢复 stdout
echo "这行恢复正常" # 回到正常 stdout
exec 3>&- # 关闭临时 fd 3
# ===== 实战:在脚本内同时输出到屏幕和文件 =====
exec 3>&1 # 保存 stdout 到 fd 3
exec > >(tee -a /var/log/setup.log) # stdout → tee → 同时去屏幕+文件
echo "安装依赖..."
echo "启动服务..."
exec 1>&3 # 恢复 stdout
exec 3>&- # 关闭 fd 3
echo "脚本结束"
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
🔑 文件描述符操作速查:
| 语法 | 含义 |
|---|---|
exec N> file | 打开 fd N 写入文件 |
exec N< file | 打开 fd N 读取文件 |
exec N>&M | fd N 复制 fd M 的写入端 |
exec N<&M | fd N 复制 fd M 的读取端 |
exec N>&- | 关闭 fd N(写入) |
exec N<&- | 关闭 fd N(读取) |
# ===== 高级实战:读取文件同时保留 stdin =====
# 场景:脚本想从一个文件读取配置,同时还要接受用户输入
exec 3< /etc/config.cfg # 用 fd 3 打开配置文件
read -r line <&3 # 从 fd 3 读配置
exec 3<&- # 关闭
read -r -p "请输入操作:" user_input # stdin(键盘)没受影响
echo "配置行:$line"
echo "用户输入:$user_input"
2
3
4
5
6
7
8
9
# 3.4 综合案例:日志分析器
把本章全部知识——数组 + 关联数组 + 字符串处理 + 重定向 + 管道——串联成一个生产级日志分析脚本:
#!/bin/bash
# log_analyzer.sh —— 日志分析器
# 用法:./log_analyzer.sh [日志文件] [选项]
# 示例:./log_analyzer.sh /var/log/nginx/access.log --top-ip --top-url
set -euo pipefail
# ===== 1. 配置 =====
LOG_FILE="${1:-/var/log/nginx/access.log}"
shift || true
# 选项标志
TOP_IP=false
TOP_URL=false
SHOW_ERRORS=false
OUTPUT_FILE=""
# ===== 2. 函数定义 =====
# 彩色输出
RED='\033[0;31m'; GREEN='\033[0;32m'
YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log_info() { echo -e "${GREEN}[INFO]${NC} $*"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
log_error() { echo -e "${RED}[ERROR]${NC} $*" >&2; }
usage() {
cat <<EOF
用法:$0 <日志文件> [选项]
选项:
--top-ip 显示访问量 Top 10 的 IP
--top-url 显示访问量 Top 10 的 URL
--errors 显示 4xx/5xx 错误统计
--output FILE 将报告输出到文件
-h, --help 显示帮助
EOF
exit 0
}
# ===== 3. 参数解析 =====
while [[ $# -gt 0 ]]; do
case "$1" in
--top-ip) TOP_IP=true; shift ;;
--top-url) TOP_URL=true; shift ;;
--errors) SHOW_ERRORS=true; shift ;;
--output) OUTPUT_FILE="$2"; shift 2 ;;
-h|--help) usage ;;
*) log_warn "忽略未知选项:$1"; shift ;;
esac
done
# ===== 4. 检查文件 =====
if [[ ! -f "$LOG_FILE" ]]; then
log_error "日志文件不存在:$LOG_FILE"
exit 1
fi
if [[ ! -r "$LOG_FILE" ]]; then
log_error "日志文件不可读:$LOG_FILE"
exit 1
fi
# ===== 5. 主分析逻辑 =====
# 5.1 基本信息
total_lines=$(wc -l < "$LOG_FILE")
file_size=$(du -h "$LOG_FILE" | cut -f1)
log_info "分析文件:$LOG_FILE(${file_size},${total_lines} 行)"
# 生成报告的临时目录
REPORT_DIR=$(mktemp -d /tmp/log_analyzer.XXXXXX)
trap 'rm -rf "$REPORT_DIR"' EXIT # 脚本退出时自动清理
# 5.2 Top IP 分析
if $TOP_IP; then
log_info "正在统计 Top IP..."
# 提取 IP -> 排序 -> 去重计数 -> 按数量排序 -> Top 10
awk '{print $1}' "$LOG_FILE" \
| sort \
| uniq -c \
| sort -rn \
| head -10 \
> "$REPORT_DIR/top_ip.txt"
echo ""
echo "━━━ Top 10 访问 IP ━━━━━━━━━━━━━━━━━━━"
printf "%-6s %-18s %s\n" "排名" "IP 地址" "访问次数"
printf "%-6s %-18s %s\n" "────" "────────────────" "──────"
rank=1
while read -r count ip; do
printf "%-6d %-18s %s\n" "$rank" "$ip" "$count"
((rank++))
done < "$REPORT_DIR/top_ip.txt"
fi
# 5.3 Top URL 分析
if $TOP_URL; then
log_info "正在统计 Top URL..."
# 假设 Nginx 日志格式:$remote_addr - $remote_user [$time_local] "$request" $status $body_bytes_sent
# $request 在第 7 列(用引号括着),我们用 awk 提取第 7 列作为 URL
awk '{print $7}' "$LOG_FILE" \
| sort \
| uniq -c \
| sort -rn \
| head -10 \
> "$REPORT_DIR/top_url.txt"
echo ""
echo "━━━ Top 10 访问 URL ━━━━━━━━━━━━━━━━━━━"
printf "%-6s %-50s %s\n" "排名" "URL" "访问次数"
printf "%-6s %-50s %s\n" "────" "─────────────────────────────────────" "──────"
rank=1
while read -r count url; do
# URL 可能很长——截断显示
display_url="${url:0:50}"
printf "%-6d %-50s %s\n" "$rank" "$display_url" "$count"
((rank++))
done < "$REPORT_DIR/top_url.txt"
fi
# 5.4 错误分析
if $SHOW_ERRORS; then
log_info "正在统计 HTTP 错误..."
# 统计 4xx 和 5xx 状态码
declare -A error_codes
while IFS= read -r line; do
# 提取状态码(Nginx 第 9 列)
status_code=$(echo "$line" | awk '{print $9}')
if [[ "$status_code" =~ ^[45][0-9]{2}$ ]]; then
((error_codes["$status_code"]++))
fi
done < "$LOG_FILE"
echo ""
echo "━━━ HTTP 错误统计 ━━━━━━━━━━━━━━━━━━━━━"
if [[ ${#error_codes[@]} -eq 0 ]]; then
echo " 没有发现 4xx/5xx 错误 ✅"
else
printf "%-8s %s\n" "状态码" "次数"
printf "%-8s %s\n" "──────" "────"
# 按错误码排序输出
for code in "${!error_codes[@]}"; do
printf "%-8s %d\n" "$code" "${error_codes[$code]}"
done | sort -k2 -rn
# 总错误数
total_errors=0
for count in "${error_codes[@]}"; do
((total_errors += count))
done
echo ""
echo "总错误数:$total_errors"
error_rate=$(echo "scale=2; $total_errors * 100 / $total_lines" | bc)
echo "错误率:${error_rate}%"
fi
fi
# ===== 6. 生成报告文件 =====
if [[ -n "$OUTPUT_FILE" ]]; then
{
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo " 日志分析报告"
echo " 文件:$LOG_FILE"
echo " 时间:$(date '+%Y-%m-%d %H:%M:%S')"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "总行数:$total_lines | 文件大小:$file_size"
echo ""
if [[ -f "$REPORT_DIR/top_ip.txt" ]]; then
echo "━━ Top IP ━━━━━━━━━━━━━━━━━━━━━━━━━━"
cat "$REPORT_DIR/top_ip.txt" | while read -r count ip; do
printf " %-18s → %s\n" "$ip" "$count"
done
fi
if [[ -f "$REPORT_DIR/top_url.txt" ]]; then
echo "━━ Top URL ━━━━━━━━━━━━━━━━━━━━━━━━━"
cat "$REPORT_DIR/top_url.txt" | while read -r count url; do
printf " %-50s → %s\n" "$url" "$count"
done
fi
} > "$OUTPUT_FILE"
log_info "报告已保存至:$OUTPUT_FILE"
fi
echo ""
log_info "分析完成"
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
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
案例知识融合:这个脚本覆盖了本章全部核心技术——索引数组(error_codes 关联数组)、字符串处理(${url:0:50} 截断、${1:-default} 默认值、set -euo pipefail)、Here Document(usage 函数的帮助文本、trap 清理函数)、重定向与管道(> "$OUTPUT_FILE" 写入报告、>> 追加、>&2 错误输出、2>/dev/null 静默、trap 清理、最后生成报告的 { ... } > "$OUTPUT_FILE" 多行重定向、mktemp 创建临时目录);同时使用了 set -euo pipefail 安全执行、函数封装与 local 局部变量、数组操作(关联数组统计错误码)、管道链(awk | sort | uniq -c | sort -rn | head 数据分析流水线)。
# 3.5 新手陷阱 Top 5
| # | 陷阱 | 说明 |
|---|---|---|
| 1 | if 里把 [ ] 和 [[ ]] 混用 | [ "$a" > "$b" ] 的 > 被当作重定向!创建了一个叫 $b] 的文件 |
| 2 | 数组遍历忘加引号 | for f in ${files[@]}——如果元素包含空格会被拆散。永远用 "${files[@]}" |
| 3 | 管道让 while 变量丢失 | ls \| while read f; do count=$((count+1)); done——count 在子 Shell 里 |
| 4 | 2>&1 的位置错误 | cmd 2>&1 > file——先合并到当前 stdout(屏幕),然后重定向 stdout 到文件 |
| 5 | $* 和 $@ 混淆 | "$*" 把所有参数合成一个字符串——如果用 for arg in "$*" 只会循环一次 |
陷阱 1 详解——> 被当作重定向符:
# ❌ 灾难
if [ "$a" > "$b" ]; then # > 被当作输出重定向!
echo "大于"
fi
# 这行代码:
# 1. 创建了一个名为 "$b" 的空文件
# 2. 把空字符串写入该文件
# 3. [ 命令只收到一个参数 "$a"——永远为 true!
# ✅ 正确
if [[ "$a" > "$b" ]]; then # [[ 内 > 是字符串比较
echo "大于"
fi
if [[ "$a" -gt "$b" ]]; then # 数值比较——更安全
echo "大于"
fi
if (( a > b )); then # 或算术比较
echo "大于"
fi
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
陷阱 3 详解——修复管道子 Shell 问题:
# ❌ 管道——子 Shell 变量丢失
count=0
cat /etc/hosts | while read -r line; do
((count++))
done
echo "$count" # 0——count 在子 Shell 里修改,外面不知道!
# ✅ 方案一:输入重定向
count=0
while read -r line; do
((count++))
done < /etc/hosts
echo "$count" # 正确
# ✅ 方案二:进程替换
count=0
while read -r line; do
((count++))
done < <(cat /etc/hosts)
echo "$count" # 正确
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
陷阱 4 详解——正确的重定向顺序:
# ❌ 错误顺序——看似合并了,实际没合并
cmd 2>&1 > file
# 1. 2>&1:stderr 指向当前的 stdout(屏幕)
# 2. > file:stdout 改为指向 file
# 结果:stdout → file,stderr → 屏幕(没有合并!)
# ✅ 正确顺序
cmd > file 2>&1
# 1. > file:stdout 指向 file
# 2. 2>&1:stderr 指向当前 stdout(即 file)
# 结果:stdout + stderr → file(正确合并)
# ✅ 现代简写
cmd &> file # 更清晰——推荐
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.6 综合思考题
${arr[@]}vs${arr[*]}的本质差异:两个写法在双引号中行为完全不同——"${arr[@]}"保留元素边界,"${arr[*]}"把 IFS 的第一个字符作为连字符合并。这个设计起源于 1970 年代 Unix 的命令行参数传递方式——为什么@分开、*合并?如果你是 Shell 设计者,会设计第三种写法吗?# %模式删除的性能与可读性:Shell 内置的${var#pattern}比sed 's/^pattern//'快约 100 倍(不需要 fork 子进程)。但代价是什么?对于超过 100MB 的大字符串,Shell 内置操作的性能如何?什么时候应该放弃 Shell 内置操作,转而用awk/sed甚至 Python?文件描述符
>&2的设计哲学:为什么 Shell 选择>&2而不是2&>或2>这样更直观的语法?2>&1中间的&到底代表什么?(提示:>&的语法源自> filevs>& fd的区分——&表示"后面是文件描述符而不是文件名")。这个设计在哪种场景下会救命?Here Document 的 heredoc EOF vs file:
cat <<EOF > file和cat > file <<EOF在功能上等价——但它们背后的执行顺序完全不同(先重定向再输出 vs. 先构造 heredoc 再重定向)。在什么极端情况下这两种写法会产生不同的结果?(提示:考虑文件已存在的情况,以及set -o noclobber的影响)管道 vs 临时文件的性能取舍:
cmd1 | cmd2是流式处理——cmd1 一产生输出,cmd2 就能开始消费。而cmd1 > /tmp/tmp; cmd2 < /tmp/tmp需要等 cmd1 完全结束后 cmd2 才开始。在什么情况下管道的流式特性反而会成为问题?(提示:考虑一个 cmd1 产生了 100GB 输出、cmd2 需要随机访问的场景——以及如何使用sort命令的-T选项找到出路)