Shell 文本处理

日常用得最多的三类操作:把命令的输出接起来、从文本里挑出想要的行、把行拆成字段再做统计。文中命令除特别标注外,GNU 与 macOS 自带版本都可运行。

管道与重定向

写法作用备注
cmd > file标准输出写入文件,覆盖写入前就截断,命令失败也会留下空文件
cmd >> file追加
cmd 2> err.log标准错误写入文件文件描述符 2 是 stderr
cmd &> all.logstdout 与 stderr 合并写入bash / zsh 支持;POSIX 写法是 >all.log 2>&1
cmd 2>&1 >outstderr 跟随原来的 stdout顺序写反结果完全不同,见下面的坑
cmd1 | cmd2管道:前者的 stdout 成为后者的 stdin默认只传 stdout,stderr 直接进终端
cmd | tee file同时写屏幕和文件-a 追加
cmd < file把文件接到 stdin
cmd <<'EOF' … EOFhere-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)。