第14章 Shell 可移植性议题与扩展

背景 POSIX Shell是标准基准;bash、ksh93 在POSIX之上增加大量扩展语法。二者功能大量重叠,但不完全兼容。 写可移植脚本要遵循POSIX;写便捷脚本可以使用bash/ksh93扩展,但要注意跨shell坑点。

14.1 迷思(常见坑点 gotchas)

这些是实际写脚本高频踩坑,很多是bash/ksh行为不一致。

1.存储shell状态

POSIX没有标准方法保存函数定义;bash/ksh93可以用下面代码导出完整shell状态(选项、变量、函数、trap)到文件。

1
2
3
4
5
6
7
8
9
{
    set +o                          # shell选项
    (shopt -p) 2>/dev/null          # bash专属选项,ksh会静默忽略
    set                             # 普通变量
    export -p                       # 导出的环境变量
    readonly -p                     # 只读变量
    trap                            # 信号捕捉设置
    typeset -f                      # 函数定义(非POSIX,bash/ksh)
} > /tmp/shell.state

⚠注意:bash、ksh函数语法不一样,导出的状态文件不能直接跨shell恢复。

2. echo不可移植

不同系统内置echo对\n \t、-n行为差异巨大。
可移植方案:优先用printf,不要依赖echo转义与选项。

3. getopts的OPTIND

ksh93支持函数内部把OPTIND做本地局部变量,函数内部调用getopts不会破坏外部OPTIND;bash不支持该特性。

4. ${var:?message} 变量检查

语法:变量未设置则打印提示信息并退出shell。

1
2
echo ${somevar:?somevar is not set}
echo still running

行为差异:

执行方式 bash ksh93
bash x.sh 打印信息,脚本退出,后面不执行 打印信息,脚本退出
. x.sh(source) 打印信息,继续执行后续代码 打印信息,直接退出shell

风险:source执行脚本时,ksh会直接把交互式终端退出。如果使用该语法,出错后最好手动写exit。

5. for循环空列表

1
2
3
4
for i in $a $b $c
do
    echo "$i"
done

当$a $b $c全部为空,展开得到空列表。

  • 新版bash/ksh:合法,循环一次都不执行
  • 古老原始Bourne Shell:报语法错误。

6. DEBUG trap行为不一致

  • ksh88:DEBUG trap 在每条命令执行之后触发
  • ksh93 /现代bash:DEBUG trap 在每条命令执行之前触发

⚠可移植脚本禁止使用DEBUG trap。

7.set选项总表

set -xxx,set -o 长名字两种写法等价。

短选项 长选项 POSIX 说明
-a allexport ✅ 后续定义变量自动export导出
-b notify ✅ 后台任务完成立刻打印提示(交互用)
-C noclobber ✅ 禁止>覆盖已存在文件;`>
-e errexit ✅ 命令非0退出则shell直接退出
-f noglob ✅ 关闭通配符* ? []展开
-h hashall ✅ 函数内命令提前缓存路径
-m monitor ✅ 开启作业控制,交互shell默认打开
-n noexec ✅ 只检查语法,不执行脚本
-u nounset ✅ 使用未定义变量直接报错退出
-v verbose ✅ 原样打印输入行
-x xtrace ✅ 打印展开之后的命令,调试用

bash独有:-B braceexpand、-H histexpand;ksh独有:-A数组赋值、bgnice后台自动降nice优先级。

14.2 bash 的shopt命令

shopt 仅bash可用,ksh不支持,专门控制bash独有行为。 语法:

1
2
3
shopt -s 选项名   # -s set:开启
shopt -u 选项名   # -u unset:关闭
shopt -p          # 打印所有选项,可以重定向保存

高频实用选项:

  1. extglob:开启扩展模式匹配(重要!14.3.3会讲)
  2. nullglob:通配符匹配不到文件时,展开为空字符串,保留原样
  3. dotglob:文件名通配匹配包含.开头隐藏文件
  4. cdspell:交互式,cd自动修正目录拼写错误
  5. histappend:历史命令追加到文件而不是覆盖
  6. checkwinsize:命令执行后自动更新窗口行列变量LINES、COLUMNS

示例:

1
shopt -s extglob nullglob

14.3 bash与ksh93共同扩展(两者都支持,非POSIX标准)

14.3.1 select 交互式选择菜单

作用:快速生成数字选择菜单,bash/ksh特有。

语法

1
2
3
4
5
select 变量 in 选项1 选项2 选项3
do
    #循环体
    break #退出循环必须手动break;Ctrl+D也可以退出
done

内置特殊变量:

  • PS3:select的提示符,默认#?
  • REPLY:保存用户输入的数字

示例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
PS3="请选择终端类型:"
select term in vt100 xterm linux
do
    if [[ -n "$term" ]];then
        export TERM="$term"
        echo "TERM设置为 $TERM"
        break
    else
        echo "无效输入,请重新选择"
    fi
done

运行输出:

1
2
3
4
1) vt100
2) xterm
3) linux
请选择终端类型:

进阶:显示友好中文菜单,用REPLY拿到数字做数组下标

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
terminame=(vt100 xterm linux)
PS3="请选择:"
select showname in "VT100老式终端" "Xterm图形终端" "Linux控制台"
do
    if [[ -n "$showname" ]];then
        TERM=${terminame[REPLY-1]} #select从1编号,几个REPLY就是几;数组下标从0
        echo TERM=$TERM
        break
    fi
done

数组定义:

语法 作用
arr=(a b c) 定义 / 赋值数组,圆括号,一次性装入多个元素
arr[2]="hello" 给下标 2 位置赋值,方括号指定下标
${arr[2]} 读取下标 2 的元素,方括号访问元素
${arr[@]} 取出数组全部元素(推荐双引号 "${arr[@]}")

补充变量TMOUT:设置秒数,select超时无输入自动退出循环。

14.3.2 扩展测试 [[ 条件 ]]

[[ ]]是shell关键字,不是外部命令test,bash/ksh支持,POSIX sh不支持。

  1. 内部不会发生单词分割、通配符展开,字符串一般不需要加双引号
  2. 支持&& ||逻辑运算符直接写括号内;支持> <字符串比较
  3. ==支持shell通配符模式匹配
1
2
3
4
5
6
str="hello123"
#模式匹配,==后面可以写* ?
[[ $str == hello* ]] && echo "字符串以hello开头"

#逻辑组合 && ||
[[ -f "/tmp/a.txt" && $str != "" ]]

常用运算符(和[ test ]大部分一致)

  • -e file 文件存在;-d目录;-f普通文件;-r可读 -w可写 -x可执行
  • -z str字符串空;-n str字符串非空
  • fileA -nt fileB A比B新;fileA -ot fileBA比B旧;-ef指向同一个硬链接

⚠可移植脚本用[ ];写bash/ksh脚本优先[[ ]]。

14.3.3 扩展模式匹配 extglob

ksh默认开启;bash必须执行shopt -s extglob才生效。 类似正则表达式,shell文件名匹配,对比egrep正则:

shell模式 等价egrep 含义
*(exp) exp* 0次或多次匹配exp
+(exp) exp+ 至少1次exp
?(exp) exp? 0或1次exp
@(a| b |c) a | b | c 匹配a或者b或者c
!(exp) 无 匹配除exp以外所有内容

示例

1
2
3
4
5
6
7
8
9
shopt -s extglob
#匹配文件名字是 bob / dave /fred
echo @(bob|dave|fred)

#匹配全部文件,除了*.log
echo !(*.log)

#匹配1个以上数字
echo +([0-9])

14.3.4 大括号展开 brace expansion

生成批量字符串,csh继承过来。

1
2
3
4
5
6
echo file-{a,b,c}.txt
#输出 file-a.txt file-b.txt file-c.txt

#支持嵌套
echo cpp-{args,l{e,o}x,parse}.c
#cpp-args.c cpp-lex.c cpp-lox.c cpp-parse.c

14.3.5 进程替换 Process substitution

语法:<(命令) 、>(命令) 原理:生成临时命名管道,把命令输出伪装成文件,传给别的程序。Linux/ksh/bash支持。

输入替换 <(cmd):把cmd输出当成一个“文件”给程序读

1
2
#同时处理两组命令输出,awk读取两个数据流
awk '{print}' <(seq 1 3) <(seq 100 102)

输出替换 >(cmd):把数据重定向输入到cmd

1
2
#一份原始数据,同时多路分发:存原始文件、排序输出、发邮件
cat data.txt | tee >(sort > sorted.txt) >(mail -s "data log" root) raw.txt

14.3.6 索引数组(一维数组)

bash/ksh支持一维数组;下标从0开始;稀疏数组(中间下标可以跳过不赋值)

三种赋值方式

1
2
3
4
5
6
7
8
#方式1:直接下标赋值(稀疏数组,可以跳过下标)
arr[2]="apple"
arr[5]="banana"

#方式2:复合赋值(推荐)
arr=(java python shell go)

#⚠注意:set -A arr val1 val2 仅ksh支持;bash不识别set -A

读取数组

1
2
3
4
${arr[0]}    #取下标0元素
${arr[@]}    #全部元素,保留元素内部空格(推荐双引号 "${arr[@]}")
${arr[*]}    #全部元素,用IFS第一个字符拼接成一个字符串
${#arr[@]}   #获取数组元素总个数

示例,select菜单搭配数组:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
term_arr=(vt100 xterm linux)
PS3="选择终端编号:"
select dummy in vt100 xterm linux
do
    if [[ -n $dummy ]];then
        TERM=${term_arr[REPLY-1]}
        echo $TERM
        break
    fi
done

⚠坑:$arr等价${arr[0]},只会拿第0个元素。

14.3.7 其他零散扩展(bash、ksh共同)

  1. 波浪号扩展增强
1
2
~+   #等价 $PWD 当前目录
~-   #等价 $OLDPWD 上一次目录
  1. (( 算术 )) 算术命令(非POSIX)
1
2
3
4
5
6
7
8
x=1
((x=x+10))
echo $x

#支持判断,直接用于if while
while (( x < 100 ));do
    ((x++))
done
  • $(( ))是算术展开(输出值);(( ))是shell语句,有退出状态,不需要$。
  • 1 <= 3 这个比较,算术逻辑结果是 1(代表真),(( )) 返回退出状态 0
  • 4 <= 3 这个比较,算术逻辑结果是 0(代表假),,(( )) 返回退出状态 1
  • while、if 完全只看命令的「退出状态 (返回码)」,不看什么布尔逻辑值。
  1. C风格for算术循环
1
2
3
4
for ((i=1;i<=10;i++))
do
    echo $i
done
  1. here-string <<< 把字符串作为命令标准输入,省去管道产生子进程
1
2
var="hello world"
tr 'a-z' 'A-Z' <<< "$var"
  1. $'字符串' C风格转义字符串
1
2
echo $'name\tage\njack\t20'
# \t制表符 \n换行,解析转义字符
  1. trap -p:打印当前trap捕获设置,可以重定向保存。

14.4 bash与ksh93编译构建

实际工作极少需要,简单总结

  1. bash:官网下载源码包,下载补丁,./configure && make && make install
  2. ksh93:AT&T ast项目源码包,专用package工具编译。

14.5 其他Bourne家族Shell

  1. pdksh:公开实现ksh88,功能有限。
  2. zsh:交互能力极强,大量扩展语法,兼容ksh/bash部分语法。

查看各个shell版本命令

1
2
3
bash --version
ksh --version
echo $ZSH_VERSION # zsh内部变量

14.7 Shell初始化与终止文件(高频面试&实操)

关键区分4种shell类型:

  1. 登录shell:ssh登录、本机登录;会读取profile系列
  2. 交互式非登录shell:终端直接敲bash/zsh,新终端窗口
  3. 非交互式shell:执行脚本 ./xxx.sh,几乎不读取rc配置
  4. shell以-开头的$0代表登录shell。

sh(原始Bourne shell)

  • 登录shell:读 /etc/profile → ~/.profile
  • 非登录shell:不读取任何启动文件
  • 退出:没有自动logout脚本,只能自己写trap '. ~/.logout' EXIT

ksh93

  • 登录shell:/etc/profile → ~/.profile
  • 交互式shell(登录/非登录):读取环境变量$ENV指向文件
  • ⚠非交互式ksh执行脚本:不读取$ENV

bash

shell类型 读取配置文件
登录shell /etc/profile → 优先.bash_profile,没有就读.bash_login,再没有读.profile;退出执行.bash_logout
交互式非登录shell(新开终端敲bash) 只读取 ~/.bashrc
非交互式执行脚本 不读.bashrc;如果设置BASH_ENV,就读取该变量指向文件

重点坑:脚本执行时,.bashrc / .bash_profile 不会自动加载。脚本不能依赖用户的别名、自定义变量,脚本内部要自己设置PATH。

zsh

启动文件最复杂:zshenv(所有shell都会加载)→ zprofile → zshrc → zlogin;登录退出读取.zlogout。

14.8 本章小结

  1. POSIX是可移植脚本基准;bash/ksh提供大量扩展,但存在行为差异,写通用脚本尽量少用扩展。
  2. bash专属工具shopt,extglob/nullglob写脚本很常用。
  3. bash/ksh共有重要扩展:
    • select交互式菜单;
    • [[ ]]高级条件测试;
    • extglob扩展模式匹配;
    • 进程替换<() >();
    • 一维数组;
    • (( ))算术运算,C风格for循环;
    • here-string <<<;
  4. shell启动文件规则一定要分清登录/交互式/非交互式:脚本执行属于非交互式,不会加载用户rc配置,脚本必须自给自足,不能依赖用户环境。
  5. echo移植性差,可移植脚本优先printf。