编程 Bash 5.3 的 ${ cmd; }:在 PS0 里改当前 shell 变量,做 preexec 与命令计时

2026-10-21 21:06:11

Bash 5.3 的 ${ cmd; }:在 PS0 里改当前 shell 变量,做 preexec 与命令计时

Bash 5.3 引入了两种新的命令替换写法:${ command; } 与 ${|command;}。它们和 $(...) 的关键区别在于,命令是在当前 shell 的执行上下文里运行的,不会 fork 出子 shell。两者分工不同:前者读取命令的输出,后者不读 stdout,期望结果写进 REPLY 变量。

这让 PS0 的用途变得不一样了。在此之前,PS0 想挂钩子只能靠命令替换,而命令替换总是在子 shell 里跑,改过的变量带不回父 shell,只能靠临时文件或 IPC 把状态传出来。有了 ${ cmd; },PS0 里可以直接给当前 shell 的变量赋值,状态自然延续到后面的提示符展开阶段。

用 PS0 与 PS1 量一条命令的耗时

在 Bash 5.3 下,给命令计时变得很简单:

PS0='${ __cmd_start=$BASH_MONOSECONDS;}'
PS1="[ \$((BASH_MONOSECONDS - __cmd_start))s ] \u@\h:\w\$ "

BASH_MONOSECONDS 是 5.3 新增的 dynamic variable,返回系统单调时钟的值(如果系统提供)。PS0 在命令执行前把起点记进 __cmd_start,PS1 展开时再算一次差值,中间覆盖的就是一条命令从开始到结束的时间。变量是当前 shell 的,不需要任何文件或子进程参与。

preexec 与 precmd

同样的机制可以直接搭出 preexec / precmd 两个钩子:

preexec () {
    echo "Running right before the command and with pid $BASHPID"
}

precmd () {
    echo "Running right after the command and with pid $BASHPID"
}

PS0='${ preexec;}'
PROMPT_COMMAND="precmd"

运行 echo $BASHPID,会依次看到 preexec、命令本身、precmd 三段输出,三次打印的 pid 是同一个,说明它们都跑在当前 shell 里,没有哪一步被丢进子 shell。

展开与执行顺序

在 bash 5.2.21 与 5.3.0 上观察到的顺序如下:

  1. 整行命令打印到 stdout(仅当开启 verbose,即 set -v 或 bash -v)
  2. PS0 展开,含参数与命令替换。命令不受 DEBUG trap 影响,因为它是提示符字符串
  3. DEBUG trap 求值(因为命令)
  4. 命令求值
  5. DEBUG trap 求值(因为 PROMPT_COMMAND)
  6. PROMPT_COMMAND 求值
  7. PS1 展开,含参数与命令替换。同样不受 DEBUG trap 影响

注意第 2 步和第 7 步的位置:PS0 在 DEBUG trap 之前,PS1 在 PROMPT_COMMAND 之后,而这两步本身都属于提示符展开,不在 DEBUG trap 的触发范围内。

为什么计时要用 PS0 和 PS1

如果想要的是「从失去控制到重新获得控制」的完整区间,两端就应该放在 PS0(早于 DEBUG trap)和 PS1(晚于 PROMPT_COMMAND)。原因是它们完全不受 DEBUG trap 影响:trap 在自己的求值阶段额外执行的那些命令,不会被算进计时区间里,区间边界也不会因为 trap 里做了什么而漂移。

传统做法 bash-preexec 依赖 DEBUG trap。但 DEBUG trap 的触发面很宽:任何 simple command、for、case、select、每个算术 for 之前都会触发,shell 函数里第一条命令前也会触发,并且在 PROMPT_COMMAND 内同样生效(提示符展开时除外)。因此基于它的实现必须非常小心地处理重入和状态;而且一旦占用了 DEBUG trap,自己就没办法再用它做别的事。

版本限制

${ cmd; } 是 Bash 5.3 才有的语法。Debian 12、Ubuntu 22.04 等发行版自带的是 bash 5.2,直接写会报语法错误,BASH_MONOSECONDS 同样不存在。部署到这类环境前,可以用 $BASH_VERSINFO 判断:

if (( BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3) )); then
    PS0='${ __cmd_start=$BASH_MONOSECONDS;}'
    PS1="[ \$((BASH_MONOSECONDS - __cmd_start))s ] \u@\h:\w\$ "
fi

在 5.2 及更早的版本上,只能退回 $(...) 配合临时文件,或者继续用 DEBUG trap 那一套方案。

复制全文 生成海报 Shell Bash 终端配置

推荐文章

程序员茄子在线接单