Shell 文本处理
日常用得最多的三类操作:把命令的输出接起来、从文本里挑出想要的行、把行拆成字段再做统计。文中命令除特别标注外,GNU 与 macOS 自带版本都可运行。
管道与重定向
| 写法 | 作用 | 备注 |
|---|---|---|
cmd > file | 标准输出写入文件,覆盖 | 写入前就截断,命令失败也会留下空文件 |
cmd >> file | 追加 | |
cmd 2> err.log | 标准错误写入文件 | 文件描述符 2 是 stderr |
cmd &> all.log | stdout 与 stderr 合并写入 | bash / zsh 支持;POSIX 写法是 >all.log 2>&1 |
cmd 2>&1 >out | stderr 跟随原来的 stdout | 顺序写反结果完全不同,见下面的坑 |
cmd1 | cmd2 | 管道:前者的 stdout 成为后者的 stdin | 默认只传 stdout,stderr 直接进终端 |
cmd | tee file | 同时写屏幕和文件 | -a 追加 |
cmd < file | 把文件接到 stdin | |
cmd <<'EOF' … EOF | here-document | 引号包住定界符可阻止变量展开 |
引号:三条规则
Shell 里大部分"命令行为诡异"的问题都出在引号上。记住三条就够:
- 单引号内一切都是字面量,包括
$、\、反引号。单引号里没法再放单引号。 - 双引号内保留字面量,但
$变量、$(命令)、反引号仍会展开。 - 不加引号会做分词和通配符展开,还会把连续空白压成一个。所以变量几乎总要加双引号。
name="two spaces"
printf '%s\n' $name # 输出: two spaces (分词后空白被压缩)
printf '%s\n' "$name" # 输出: two spaces (原样保留)
# 需要单引号又需要变量时,拼接即可
echo 'user is '"$USER"' on '"$(hostname)"
文件名里有空格时,不加引号会让一个路径变成两个参数。批量处理文件尤其危险,见下文 find -print0。
变量与参数展开
${var:-default} # var 为空或未设置时用 default,不修改 var
${var:=default} # 同上,并把 default 赋给 var
${var:?message} # var 为空时报错退出,常用于必填参数
${#var} # 长度
${var%.*} # 去掉最后一个 . 及其右边(最短匹配)
${var%%.*} # 去掉第一个 . 及其右边(最长匹配)
${var##*/} # 去掉最后一个 / 及其左边,得到文件名
${var//old/new} # 全部替换(bash / zsh)
脚本开头建议固定这几行,很多隐蔽错误会在这里直接暴露:
#!/usr/bin/env bash
set -euo pipefail
# -e 任一命令失败即退出;-u 使用未定义变量即报错;-o pipefail 管道中任一段失败都算失败
注意 set -e 在 if 条件、&& 左侧、! 取反等位置不生效,这是它的设计而不是 bug。需要精确控制时,显式写 if ! cmd; then … fi。
退出码
约定:0 表示成功,非 0 表示失败。上一条命令的退出码在 $? 里,但任何命令(包括 echo)都会覆盖它,所以要立刻保存:
curl -fsS -o /dev/null http://example.com/health
rc=$?
if [ "$rc" -ne 0 ]; then
echo "health check failed: $rc" >&2
fi
grep:挑行
grep -n 'pattern' file # 带行号
grep -rn --include='*.py' 'todo' . # 递归,只搜 .py
grep -iv '^#' file # 忽略大小写、排除注释行
grep -c 'ERROR' app.log # 计数
grep -o 'id=[0-9]*' file # 只输出匹配部分
grep -A3 -B1 'Exception' app.log # 前后各带若干行
grep -l 'pattern' *.conf # 只列出命中的文件名
grep -F 'a.b.c' file # 按字面量匹配,不当正则
默认是 BRE(基本正则),-E 才是扩展正则(+ ? | ( ) 不用转义)。如果只是想找一段固定文本(含 .、[ 这类字符),用 -F 最省事也最快。
sed:改行
sed 's/old/new/' file # 每行第一处
sed 's/old/new/g' file # 每行全部
sed -E 's/([0-9]{4})-([0-9]{2})/\2\/\1/' file # 分组与反向引用
sed '/^$/d' file # 删空行
sed -n '10,20p' file # 只打印 10–20 行
sed '/^#/d; s/[[:space:]]*$//' file # 多条命令串联
sed -i 的用法两个平台不同。GNU(Linux)是 sed -i 's/a/b/' file;macOS 自带的 BSD sed 要求给备份后缀,不想要备份就传空串:sed -i '' 's/a/b/' file。写成 GNU 形式在 macOS 上会把后面的表达式当成后缀,然后报"命令找不到"。
awk:按字段处理
awk 把每一行按分隔符切成字段,$1 是第一列,$0 是整行,NF 是字段数,NR 是行号。
awk '{print $1, $3}' file # 默认按空白切分
awk -F: '{print $1}' /etc/passwd # 指定分隔符
awk -F, '$3 > 100 {print $1, $3}' data.csv # 条件过滤
awk 'NR > 1' file # 跳过表头
awk '{sum += $2} END {print sum}' file # 求和
awk '{count[$1]++} END {for (k in count) print count[k], k}' file | sort -rn
最后一条是"按某列分组计数"的通用模板,换成日志里的状态码就是下面的实例。sort -rn 按数字倒序,不加 -n 会按字符串排序,出现 9 排在 10 后面的情况。
find 与 xargs
find . -name '*.log' -mtime +7 -print # 7 天前的日志
find . -type f -size +100M -print
find . -name '*.tmp' -delete # 先去掉 -delete 确认列表
find . -type f -name '*.png' -print0 | xargs -0 -n 20 optipng
find . -name '*.bak' -exec rm {} + # 一次传给多个文件,比 \; 快
-print0 配 xargs -0 是为了处理带空格和换行的文件名:默认的换行分隔在这种情况下会把一个路径拆成两个。-exec … + 与 xargs 作用类似,但不需要额外进程,也更不容易被特殊字符坑到。
实例:从访问日志里读流量
下面是一条常见的访问日志(combined 格式),后面所有统计都以它为例。示例里的地址与域名都是文档专用值:
203.0.113.42 - - [02/Nov/2026:10:12:33 +0800] "GET /api/items?page=2 HTTP/1.1" 200 5123 "https://example.com/list" "Mozilla/5.0"
198.51.100.7 - - [02/Nov/2026:10:12:34 +0800] "POST /api/login HTTP/1.1" 401 231 "-" "curl/8.5.0"
203.0.113.42 - - [02/Nov/2026:10:12:35 +0800] "GET /static/app.js HTTP/1.1" 304 0 "https://example.com/list" "Mozilla/5.0"
状态码分布
awk '{print $9}' access.log | sort | uniq -c | sort -rn
1203 200
184 304
27 404
5 401
只看 5xx,并带上请求行
awk '$9 ~ /^5/ {print $4, $7, $9}' access.log
每分钟请求数(取前 10 个高峰)
awk '{print substr($4, 2, 17)}' access.log | sort | uniq -c | sort -rn | head -10
$4 形如 [02/Nov/2026:10:12:33,substr 从第 2 个字符起取 17 个,正好截到分钟。这类"从固定格式里截一段"的活,substr 往往比正则更直观。
统计独立访客数(按第一列去重)
awk '{print $1}' access.log | sort -u | wc -l
常见坑
1. 解析 ls 的输出
ls 是给人看的,列宽、颜色、换行都可能变,文件名里的空格会让后续处理错位。要遍历文件就用 for f in *.txt,或者 find … -print0。
2. for 循环读文件会丢内容
while IFS= read -r line; do
printf '%s\n' "$line"
done < input.txt
三个细节缺一不可:IFS= 保留行首尾空白,-r 不把反斜杠当转义,重定向写在 done 后面(写在 done < file 之前会让循环体里的 read 抢走输入)。
3. 2>&1 的顺序
cmd >out 2>&1 表示"stdout 去 out,stderr 跟随 stdout,也去 out";cmd 2>&1 >out 表示"stderr 跟随当前 stdout(还是终端),然后 stdout 改去 out"。后者 stderr 仍然打在屏幕上。重定向从左往右生效,顺序不能凭感觉写。
4. 通配符没匹配到任何文件
在 bash 默认设置下,rm *.log 在没有匹配时会把 *.log 这个字面量传给 rm,于是报"文件不存在"。配合 set -f 或先判断更安全:shopt -s nullglob 让无匹配时展开为空。
5. 命令替换丢了换行
$(…) 会去掉结尾的换行,这在拼接多行内容时经常出问题。需要保留时改用 printf 或临时文件,不要指望变量里存住行尾。
6. 在管道里 cd
管道的每一段都在子 shell 里执行,cmd | while read …; do cd /tmp; done 里的 cd 不会影响外层目录。需要改变外层状态时用进程替换:while …; done < <(cmd)。