07 - 输入输出与重定向
07 - 输入输出与重定向
7.1 文件描述符
每个进程都有文件描述符(File Descriptor, FD),用于标识打开的文件或数据流:
| FD | 名称 | 说明 | 默认设备 |
|---|---|---|---|
| 0 | stdin | 标准输入 | 键盘 |
| 1 | stdout | 标准输出 | 终端 |
| 2 | stderr | 标准错误 | 终端 |
# 查看当前进程的文件描述符
ls -la /proc/self/fd
# 自定义文件描述符
exec 3>/tmp/mylog.txt # 打开 FD 3 用于写入
echo "hello" >&3 # 写入 FD 3
exec 3>&- # 关闭 FD 3
exec 4</etc/hosts # 打开 FD 4 用于读取
while IFS= read -r line <&4; do
echo "$line"
done
exec 4<&- # 关闭 FD 4
7.2 输出重定向
# 覆盖写入
echo "hello" > file.txt
# 追加写入
echo "world" >> file.txt
# 标准输出重定向
ls /etc > ls_output.txt 2>/dev/null # stdout 到文件,stderr 丢弃
# 标准错误重定向
ls /nonexistent 2> error.txt
# stdout 和 stderr 合并重定向到同一文件
command > output.txt 2>&1 # 传统写法
command &> output.txt # Bash 简写(推荐)
# stdout 和 stderr 分别重定向
command > stdout.txt 2> stderr.txt
# 丢弃所有输出
command &>/dev/null
command > /dev/null 2>&1 # 等价
# stderr 重定向到 stdout
command 2>&1 | grep "error"
# stdout 重定向到 stderr
echo "错误信息" 1>&2
echo "错误信息" >&2 # 等价简写
重定向速查表
| 操作 | 语法 | 说明 |
|---|---|---|
| 输出覆盖 | > file | stdout → file |
| 输出追加 | >> file | stdout → file(追加) |
| 错误覆盖 | 2> file | stderr → file |
| 错误追加 | 2>> file | stderr → file(追加) |
| 合并输出 | &> file | stdout + stderr → file |
| 合并追加 | &>> file | stdout + stderr → file(追加) |
| stderr→stdout | 2>&1 | stderr 合并到 stdout |
| stdout→stderr | 1>&2 | stdout 合并到 stderr |
| 输入重定向 | < file | file → stdin |
| 丢弃输出 | >/dev/null | 丢弃 stdout |
| 丢弃错误 | 2>/dev/null | 丢弃 stderr |
| 丢弃全部 | &>/dev/null | 丢弃全部输出 |
7.3 输入重定向
# 从文件读取输入
wc -l < /etc/hosts
# 读取文件到变量
config=$(< /etc/hosts)
# 逐行读取文件
while IFS= read -r line; do
echo "$line"
done < /etc/hosts
# 从字符串读取
read -r first rest <<< "hello world foo"
echo "第一个词: $first"
echo "剩余: $rest"
7.4 管道(Pipe)
# 基本管道:前一个命令的 stdout 作为后一个命令的 stdin
ls -la | grep "\.txt$"
# 多级管道
cat /var/log/syslog | grep "error" | awk '{print $1, $2, $3, $NF}' | sort | uniq -c | sort -rn | head -10
# 统计当前目录下代码行数
find . -name "*.sh" -type f | xargs wc -l | sort -rn | head -20
# 管道与进程退出码
# $? 是管道中最后一个命令的退出码
false | true
echo $? # 输出: 0(true 的退出码)
# 使用 PIPESTATUS 获取管道中每个命令的退出码
false | true | false
echo "${PIPESTATUS[@]}" # 输出: 1 0 1
echo "${PIPESTATUS[0]}" # 第一个命令: 1
# set -o pipefail:管道中任一命令失败则整个管道失败
set -o pipefail
false | true
echo $? # 输出: 1
⚠️ 注意:管道中每个命令都在独立的子 Shell 中执行。在管道中修改变量不会影响父 Shell。
7.5 tee:同时输出到屏幕和文件
# 基本用法:stdout 同时输出到终端和文件
echo "hello" | tee output.txt
# 追加模式
echo "world" | tee -a output.txt
# 管道中使用
ls -la | tee file_list.txt | grep ".txt"
# 同时记录 stdout 和 stderr
./script.sh 2>&1 | tee output.log
# 写入多个文件
echo "hello" | tee file1.txt file2.txt file3.txt
# 仅写入文件,不输出到终端
echo "hello" | tee output.txt > /dev/null
7.6 Here Document
Here Document 允许在脚本中嵌入多行文本。
# 基本用法
cat << EOF
Hello, World!
这是一个 Here Document。
当前时间: $(date)
当前用户: $(whoami)
EOF
# 不展开变量(引号包裹标记)
cat << 'EOF'
$name 不会被展开
$(date) 也不会执行
EOF
# 去除前导 Tab(<<-)
if true; then
cat <<- EOF
这行前面的 Tab 会被删除
方便在缩进代码中使用
EOF
fi
# 用 Here Document 创建文件
cat > /tmp/config.ini << 'EOF'
[server]
host = 0.0.0.0
port = 8080
[database]
host = localhost
port = 5432
EOF
# 用 Here Document 传递多行输入
mysql -u root -p << 'SQL'
CREATE DATABASE IF NOT EXISTS myapp;
USE myapp;
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(255) UNIQUE
);
SQL
# 用 Here Document 生成脚本
generate_deploy_script() {
local env="$1"
local version="$2"
cat << EOF
#!/bin/bash
# 自动生成的部署脚本
# 环境: $env
# 版本: $version
# 时间: $(date '+%Y-%m-%d %H:%M:%S')
set -euo pipefail
echo "正在部署 $env 环境..."
echo "版本: $version"
docker pull myapp:$version
docker compose -f docker-compose.$env.yml up -d
echo "部署完成"
EOF
}
generate_deploy_script "prod" "2.1.0" > deploy_prod.sh
chmod +x deploy_prod.sh
7.7 Here String
# Here String:<<< 将字符串作为命令的 stdin
read -r first last <<< "John Doe"
echo "名: $first, 姓: $last"
# 用于 while read
while IFS=: read -r user _ uid; do
[[ $uid -ge 1000 ]] && echo "$user ($uid)"
done <<< "$(getent passwd)"
# 用于 grep
grep "error" <<< "this line has an error in it"
# 用于 awk
awk '{print $2}' <<< "hello world foo bar"
7.8 read 命令详解
# 基本读取
read -rp "请输入姓名: " name
echo "你好, $name"
# 多变量读取
read -rp "请输入姓名和年龄: " name age
echo "$name 今年 $age 岁"
# 读取密码(不回显)
read -rsp "请输入密码: " password
echo
echo "密码长度: ${#password}"
# 带超时
if read -t 5 -rp "5秒内输入: " answer; then
echo "你输入了: $answer"
else
echo "超时了"
fi
# 读取单个字符
read -rn 1 -p "按任意键继续..."
# 指定分隔符
IFS=: read -r user _ uid gid _ home < <(getent passwd root)
echo "用户=$user UID=$uid HOME=$home"
# 从数组读取
mapfile -t lines < /etc/hosts
echo "文件有 ${#lines[@]} 行"
# 读取到数组(带行号)
mapfile -t lines < <(head -5 /etc/passwd)
for ((i = 0; i < ${#lines[@]}; i++)); do
printf "第%d行: %s\n" $((i + 1)) "${lines[$i]}"
done
7.9 业务场景:日志分析脚本
#!/bin/bash
# analyze_log.sh —— Nginx 日志分析
set -euo pipefail
readonly LOG_FILE="${1:-/var/log/nginx/access.log}"
readonly REPORT_FILE="/tmp/log_report_$(date +%Y%m%d_%H%M%S).txt"
if [[ ! -f "$LOG_FILE" ]]; then
echo "❌ 日志文件不存在: $LOG_FILE" >&2
exit 1
fi
echo "正在分析日志: $LOG_FILE" | tee "$REPORT_FILE"
echo "生成时间: $(date '+%Y-%m-%d %H:%M:%S')" | tee -a "$REPORT_FILE"
echo "========================================" | tee -a "$REPORT_FILE"
# 总请求数
total=$(wc -l < "$LOG_FILE")
echo "" | tee -a "$REPORT_FILE"
echo "📊 总请求数: $total" | tee -a "$REPORT_FILE"
# HTTP 状态码分布
echo "" | tee -a "$REPORT_FILE"
echo "📊 HTTP 状态码分布:" | tee -a "$REPORT_FILE"
awk '{print $9}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
while read -r count code; do
pct=$(awk "BEGIN {printf \"%.1f\", $count * 100 / $total}")
printf " %6d (%5s%%) HTTP %s\n" "$count" "$pct" "$code"
done | tee -a "$REPORT_FILE"
# 访问最多的 IP
echo "" | tee -a "$REPORT_FILE"
echo "📊 访问 Top 10 IP:" | tee -a "$REPORT_FILE"
awk '{print $1}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
while read -r count ip; do
printf " %8d %s\n" "$count" "$ip"
done | tee -a "$REPORT_FILE"
# 最热门的 URL
echo "" | tee -a "$REPORT_FILE"
echo "📊 访问 Top 10 URL:" | tee -a "$REPORT_FILE"
awk '{print $7}' "$LOG_FILE" | sort | uniq -c | sort -rn | head -10 | \
while read -r count url; do
printf " %8d %s\n" "$count" "$url"
done | tee -a "$REPORT_FILE"
echo "" | tee -a "$REPORT_FILE"
echo "✅ 报告已保存到: $REPORT_FILE"
7.10 注意事项
| 陷阱 | 说明 | 解决方案 |
|---|---|---|
| 管道中的子 Shell | 变量修改丢失 | < <() 或 lastpipe |
> 覆盖危险 | > 会清空文件 | set -o noclobber |
| 未加引号的重定向 | 文件名有空格时出错 | >"$filename" |
| EOF 缩进 | << 不去除 tab | 使用 <<- |
| 管道退出码 | 只取最后一个命令的码 | set -o pipefail + PIPESTATUS |