第7章 输入/输出、文件与命令执行

7.1 标准IO基础

进程打开一个文件、管道、终端之后,内核会给它分配一个小整数编号,这个数字就叫文件描述符。进程通过这个数字,来读写对应的打开的资源(终端、普通文件、管道)。每一个运行的 shell 进程,手里都保存一套自己的文件描述符表。3 个固定标准文件描述符(0、1、2),每个程序一启动就自带。

fd编号 名称 说明
0 标准输入 stdin 读数据,默认键盘
1 标准输出 stdout 正常输出,默认屏幕
2 标准错误 stderr 报错信息,默认屏幕

过滤器程序:从标准输入读,处理后输出到标准输出,适合管道串联。

7.2 read 读取输入行

语法

1
read [-r] var1 var2 var3...
  • -r原始模式,不把行末尾\当成续行符,写脚本尽量带上‑r
  • 依据$IFS(字段分隔符),把输入行切分成单词。
  • 规则:
    1. 第1个单词赋值给var1,第2个给var2
    2. 单词数量 > 变量数量,剩下全部内容一股脑给最后一个变量
    3. 读到文件结束EOF,read返回非0退出码;常用于while read循环终止。

示例:交互式输入

1
2
printf "Enter name rank: "
read -r name rank

经典:while read 逐行读文件

读取/etc/passwd,冒号作为分隔符。

while后面重定向< /etc/passwd写在done后面,不是写在read后面!

1
2
3
4
while IFS=: read -r user pass uid gid fullname homedir shell
do
  echo "用户:$user"
done < /etc/passwd

IFS=: 写在read命令前面(while 关键字之后),是临时环境变量,只作用于本次 read,不改变脚本全局 IFS

错误写法(每次循环重新打开文件,永远读第一行,死循环)

1
2
3
4
while IFS=: read -r ... < /etc/passwd
do
  ...
done

⚠坑:管道后面的while read会运行在子shell,循环内部修改的变量,循环结束父shell拿不到值。

1
2
3
4
5
cat file.txt | while read -r line
do
    a=100
done
echo "$a"   # a还是空!子shell内部变量带不出来

7.3 重定向大全

基础重定向复习

符号 作用
< file 标准输入来自文件
> file 标准输出覆盖写入文件
>> file 标准输出追加写入文件

set -C noclobber 防止意外覆盖

1
2
set -C          # 开启noclobber,>遇到已存在文件直接报错,禁止覆盖
>| file         # 强制覆盖,绕过noclobber限制

here‑doc <<EOF 嵌入文档,脚本内写多行输入

1
2
3
4
5
mail -s "提醒" tom <<EOF
你好tom
系统磁盘占用过高,请清理文件。
谢谢
EOF
  1. 定界符EOF不加引号:shell执行变量替换、命令替换
  2. 定界符加引号 <<'EOF':原样文本,不做任何替换
1
2
3
4
i=10
cat <<'EOF'
变量i的值是 $i      # $i不会展开,原样输出$i
EOF
  1. <<‑EOF:会删除内容行开头的tab制表符,方便脚本缩进美化,只删tab,空格不动

文件描述符高级重定向

1
2
3
4
5
6
7
8
make > results 2>ERRS       # 1输出到results,2错误输出到ERRS
make > results 2>/dev/null  # 丢弃错误信息

make > results 2>&1        # stdout和stderr全部输出到同一个文件
# ⚠顺序很重要!!!
make 2>&1 > results        # 错误:先把2指向终端,再改1到文件,错误依旧打印屏幕

make 2>&1 | less           # stdout+stderr一起丢给管道

2>&1 不要加空格;含义:把fd2指向fd1当前指向的地方。

exec 修改shell本身的文件描述符

exec不加命令,只写重定向,就修改当前shell的文件描述符。

1
2
3
exec 3< data.txt        # 打开fd3读取文件
read -r line <&3        # 从fd3读取
exec 3<&-               # 关闭fd3

保存、恢复标准错误示例:

1
2
3
4
5
exec 5>&2               # 把原始stderr保存到fd5
exec 2> /tmp/log.txt    # stderr重定向到日志
# 中间执行一堆命令……
exec 2>&5               # 恢复stderr
exec 5>&-               # 关闭临时fd5

exec后面带上程序:直接替换当前shell进程,执行完不会返回脚本

1
2
exec ls -l
echo "这行永远不会执行"

7.4 printf完整详解(优先于echo,可移植)

语法:

1
printf 格式串 [参数1 参数2 ...]
  • 不会自动换行,换行必须手动写\n
  • 参数多于格式占位符:循环复用格式字符串(shell printf特有,C语言printf不一样)

常用格式指示符

符号 含义
%s 字符串
%d 十进制整数
%c 单个字符
%b 解析参数字符串里面的转义字符\n \t
%% 输出字面的百分号

修饰符:宽度、对齐、精度

  1. %10s:占10字符宽度,右对齐
  2. %-10s:占10字符宽度,左对齐,减号代表左对齐
  3. %.5d:数字,不足5位前面补0
  4. %.10s:字符串最多打印前10个字符

示例

1
2
3
4
printf "|%10s|\n" hello     # |     hello|
printf "|%-10s|\n" hello    # |hello     |
printf "%.5d\n" 15          # 00015
printf "%.10s\n" "longtextstring" # longtextst

注意:浮点%f %g属于bash/ksh扩展,POSIX shell标准不强制支持,脚本浮点计算改用awk。

7.5 波浪号展开 & 通配符globbing

波浪号 ~

1
2
~               # 当前用户家目录 $HOME
~tom            # 用户tom的家目录

⚠可移植脚本不建议用~,部分老旧sh不支持,优先写$HOME

shell通配符(只匹配文件名)

符号 含义
* 匹配0个或任意多个字符,不匹配文件名开头的.隐藏文件
? 匹配任意单个字符
[abc] 匹配括号中任意一个字符
[!abc] 匹配不在括号的任意字符

注意:[a‑z]受locale环境影响;可移植脚本尽量用POSIX字符集,不要依赖字母范围。 文件名开头.代表隐藏文件;*不会匹配隐藏文件;要匹配隐藏必须显式写.*

7.6 命令替换:把命令输出当作参数

两种写法,优先使用$(...),不要用反引号```

  1. 老式反引号 \cmd``:嵌套转义非常痛苦
  2. 新式 $(cmd):支持嵌套,可读性好,POSIX标准。
1
2
3
files=$(ls *.txt)
# 嵌套示例
result=$(echo $(date))

注意:命令替换结果会做单词分割、通配符展开;如果内容有换行空格,建议用双引号包裹 "$(...)"

expr(老旧算术工具,新脚本尽量不要用)

旧时代shell没有$(( ))的时候做整数运算。

1
2
i=1
i=`expr $i + 1`

现代脚本直接用shell内置算术替换 $((i+1))

7.7 引用/引号机制(重中之重,解决各种变量坑)

1)反斜杠 \

转义单个元字符,把特殊符号变成普通字面。

1
echo \*     # 输出 *,不会通配符展开

2)单引号 ' 内容 '

强引用:引号内部所有字符原样字面,不做任何展开替换;内部不能再放单引号。

1
echo '$var $(ls) *'   # 原样输出 $var $(ls) *

3)双引号 "内容"

弱引用:禁止通配符、单词分割;但是允许$变量替换、$(...)命令替换、\转义。

1
2
name="jack"
echo "my name is $name"
引号类型 变量$ 命令替换$() 通配符*? 单词分割
单引号' ' 不展开 不展开 原样 关闭
双引号" " 展开 展开 原样 关闭
无引号 展开 展开 展开 分割

通用最佳实践:变量尽量用双引号包裹 "$var",防止空格换行带来的分词bug

7.8 shell命令行完整处理流程(理解bug根源)

shell读取一行命令,严格按顺序执行这一套处理:

  1. 将命令分割成token:用元字符空格、; | < > &切分成一个个单词;识别重定向。
  2. 别名展开(交互式)。
  3. 波浪号~展开。
  4. 变量替换$var
  5. 命令替换$(...)
  6. 算术替换$(( ))
  7. IFS分词:对4‑6步骤得到的结果,用$IFS再次切割单词(这一步是很多空格bug来源)。
  8. 通配符globbing * ? []文件名展开
  9. 执行重定向。
  10. 查找命令(内建→函数→PATH外部程序),运行。

eval:把参数再完整重新跑一遍整套处理流程,二次解析。慎用,容易安全漏洞。

7.9 子shell ( ) vs 代码块 { }

项目 子shell ( cmd1;cmd2 ) 代码块 { cmd1;cmd2; }
是否新建进程 新子进程 当前shell,不新建进程
cd改变目录 只影响子进程,父shell不受影响 会改变当前shell工作目录
变量修改 子shell内修改,父shell看不见 直接修改当前shell变量
exit行为 只会退出子shell 直接终止整个脚本
语法 括号内任意位置;结尾分号可选 {后面必须空格;最后一条命令必须带;

示例子shell

1
2
tar -cf - . | (cd /tmp/newdir; tar -xpf -)
# 括号里面cd只影响子shell,外层脚本目录不变

示例代码块

1
2
3
4
cd /tmp || {
    echo "切换目录失败" >&2
    exit 1
}

7.10 内建命令 & set选项

shell内建命令运行在shell进程内部,不会fork新进程。

command命令:绕过shell函数,调用真正原始内建命令。

示例:

我们自定义一个名字叫cd的 shell 函数,想在 cd 切换目录之后,顺便修改提示符:

1
2
3
4
5
cd () {
    cd "$@"       # ❗这里会无限递归!调用的还是我们自己写的cd函数
    x=$(pwd)
    PS1="${x##*/}$ "
}

command解决,绕过函数,调用真正原始的 cd 内建:

1
2
3
4
5
cd () {
    command cd "$@"   # command:跳过cd函数,调用shell原生cd内建
    x=$(pwd)
    PS1="${x##*/}$ "
}
  1. POSIX 特殊内建命令
命令 简要功能说明
: 空命令,什么都不做;仅做参数展开;常用于占位
. 点命令,读取并在当前shell执行脚本(bash中等价source
break 跳出 for/while/until 循环
continue 直接进入循环的下一轮迭代
eval 对参数再次完整执行一遍shell命令行解析流程
exec ①带程序:替换当前shell进程;②仅加重定向:修改当前shell文件描述符
exit 退出当前shell/脚本,可指定退出码
export 把变量导出为环境变量,可供子进程继承
readonly 设置变量为只读,不可修改、unset删除
return 从shell函数返回,带返回状态码,仅用于函数内
set 设置shell选项;修改位置参数$1 $2…;打印全部shell变量
shift 丢弃前面的位置参数,参数整体左移
times 打印shell及子进程累计CPU使用时间
trap 设置信号捕获,脚本收到信号执行指定命令
unset 删除变量、函数,清除其定义
  1. POSIX普通内建命令
命令 简要功能说明
alias 设置命令别名,多用于交互式shell
bg 将作业放到后台运行(作业控制,交互)
cd 切换shell当前工作目录,必须为内建
command 绕过shell函数,执行原始内建/外部命令;command -p使用默认PATH
false 返回非0失败退出码,不做实际工作
fc 命令历史编辑修复(交互模式)
fg 把后台作业调至前台运行(交互)
getopts 解析脚本的命令行选项参数
jobs 列出后台作业列表(交互)
kill 向进程/作业发送信号
newgrp 切换用户组ID
pwd 打印当前工作目录
read 读取标准输入,拆分赋值给shell变量,‑r原始读取
true 返回0成功退出码,不做实际工作
umask 设置新建文件的权限掩码
wait 等待后台进程/作业结束,获取退出状态

补充:echoprintftest[ ] POSIX建议实现为内建,但不属于上面两类。

  1. bash专属扩展内建(非POSIX,可移植脚本不要用)
命令 简要功能说明
shopt 设置bash专属行为选项
declare 声明变量类型(整数、数组等)
local 函数内部局部变量,仅函数内生效
history 操作命令历史记录
help 查看bash内建帮助
bind 修改readline键盘绑定

set常用选项(短选项,-开启,+关闭)

短选项 长选项 作用
‑C noclobber 禁止>覆盖已存在文件,>
‑e errexit 命令非0退出码,脚本直接退出
‑u nounset 使用未定义变量,脚本报错退出
‑x xtrace 打开调试跟踪,打印执行的命令
‑f noglob 关闭通配符* ? []文件名展开
1
2
set -eux      # 脚本调试常用组合,开启e u x
set +eux      # 关闭

.(source)内建命令:在当前shell进程读取执行脚本文件,不会新建进程,变量、函数全部继承到当前shell。

1
./lib.sh

小结

  1. while read读文件,重定向写在done <file;不要写在read后面。管道后的while read,变量修改无法带回父shell。
  2. 2>&1顺序不能颠倒;要合并stdout/stderr,必须>file 2>&1
  3. printf参数多于占位符,循环复用格式串;浮点不要依赖%f
  4. 引号区分:单引号完全字面;双引号允许变量替换;变量建议 "$var"双引号包裹。
  5. $(...)优先,淘汰反引号\cmd``。
  6. ( )子shell,内部改动不影响父脚本;{ }代码块直接修改当前shell状态。
  7. shell处理有固定顺序:变量/命令替换之后才会做IFS分词、通配符展开,很多怪异bug都来自这个流程。
  8. set -eu写脚本强烈推荐,捕获未定义变量、命令失败问题。

速查小卡片

命令/语法 作用
read -r a b 读取一行到变量,‑r关闭反斜杠续行
while IFS=: read ...; do ... done < file 逐行读取文件
cmd >a 2>&1 标准输出+错误输出写入同一个文件
<<'EOF' here‑doc,完全原样文本,不替换变量
printf "%‑10s\n" "hi" 格式化输出,左对齐
$(command) 命令替换,获取命令输出
'字符串' 强引用,完全字面
"$var" 弱引用,保留变量,防止分词
(cmd1;cmd2) 子shell,不污染父shell
{ cmd1;cmd2; } 代码块,当前shell执行
set -eux 脚本开启错误检查、调试跟踪
./script.sh source,当前shell执行脚本