Linux 基础体系 · 第 44/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

Bash 函数与 Trap:退出码、错误传播、临时资源和信号清理

在 Bash 中,一个函数调用同时涉及三件容易混淆的事情:

  1. 函数内部每条命令都有自己的退出状态;
  2. 函数整体也必须向调用者返回一个退出状态;
  3. trap 可以在特定事件发生时插入处理逻辑,但它不会自动替你传播错误、恢复状态或保证资源清理成功。

如果把这三者混为一谈,就会出现“函数明明失败了却返回 0”“开启了 set -e 仍然继续执行”“临时目录没有清理”“收到 SIGTERM 后进程仍然残留”等问题。

本文使用 Bash 5.x 常见行为说明机制。trap、函数和退出状态属于 Bash 语言能力;mktemp、信号编号和 /tmp 的权限则依赖具体系统与实现。

1. 退出状态:命令、函数和脚本之间如何传递结果

1.1 $? 是上一条命令的退出状态

Bash 命令的退出状态是一个整数,通常取值范围为 0255

  • 0 表示成功;
  • 0 表示失败或特殊状态;
  • 对于被信号终止的进程,Shell 通常使用 128 + 信号编号 作为状态,例如 SIGINT 为 2,常见结果是 130
true
printf 'status=%s\n' "$?"

false
printf 'status=%s\n' "$?"

预期输出:

status=0
status=1

$? 不是一个可以长期保存的日志变量。任何命令都会改变它,包括 printflocal[[ ... ]] 以及 echo。因此,必须在需要时立即保存:

do_work() {
    some_command
    local rc=$?

    printf 'some_command returned %s\n' "$rc" >&2
    return "$rc"
}

下面的写法存在风险:

do_work() {
    some_command
    printf 'command failed\n' >&2
    return "$?"
}

printf 通常返回 0,所以这里的 return "$?" 很可能返回的是 printf 的状态,而不是 some_command 的状态。

1.2 函数默认返回最后一条命令的状态

函数没有显式执行 return 时,其退出状态等于函数体中最后执行的命令的退出状态:

f() {
    false
    printf 'failure was ignored\n'
}

f
printf 'function status=%s\n' "$?"

输出:

failure was ignored
function status=0

false 的失败状态被后面的 printf 覆盖了。函数并不是“自动传播最早发生的错误”,而是只向调用者暴露最终状态。

正确的显式传播方式是:

f() {
    false || return
    printf 'this line is reached only after success\n'
}

这里 return 不带参数时,会返回紧邻它之前的命令状态。在更复杂的代码中,显式保存更清楚:

f() {
    false
    local rc=$?
    if (( rc != 0 )); then
        printf 'f: operation failed, rc=%d\n' "$rc" >&2
        return "$rc"
    fi
}

return N 只应在函数或被函数调用的脚本环境中使用。它的状态会按 Shell 的退出状态规则转换到 8 位范围;应用协议如果需要表达更多错误信息,应使用输出、日志或结构化结果,而不要试图把任意大整数塞进退出码。

1.3 returnexit 和自然结束不是一回事

  • return 结束当前函数,控制权回到调用者;
  • exit 结束当前 Shell 进程;
  • 函数自然执行到末尾,则返回最后一条命令的状态;
  • 脚本自然执行到末尾,也以最后一条命令的状态作为脚本状态。
inner() {
    return 7
    printf 'unreachable\n'
}

outer() {
    inner
    printf 'inner status was overwritten\n'
}

outer
printf 'outer status=%s\n' "$?"

inner 返回 7,但 outer 最后的 printf 成功,因此 outer 返回 0。

如果函数是包装器,目标通常是“成功则继续,失败则原样返回”:

run_task() {
    prepare_environment || return
    execute_task || return
    publish_result
}

这段代码中,每个 || return 都让失败沿函数调用链向上传播。

1.4 local 和命令替换可能遮蔽退出状态

这是 Bash 中很常见的错误:

bad() {
    local output=$(false)
    printf 'status=%s\n' "$?"
}

local 是一个 Bash 内建命令,它本身通常返回成功;因此,$(false) 的状态被 local 的状态遮蔽。

应分成两步:

good() {
    local output
    if ! output=$(false); then
        printf 'command substitution failed\n' >&2
        return 1
    fi
}

更一般地说,凡是需要检查某条命令的状态,都不要把它嵌入另一个可能成功的命令中。

命令替换还会创建执行环境,并删除替换结果末尾的换行符:

value=$(printf 'a\n\n')
printf '<%s>\n' "$value"

输出通常是:

<a>

这属于 Bash 展开和引用问题:退出状态由命令替换中的最后一条命令决定,文本结果则经过命令替换规则处理,二者是不同的数据流。

2. 组合命令和管道的退出状态

2.1 ifwhile&&|| 都会检查状态

这些结构并不是“忽略错误”,而是明确把命令状态作为条件使用:

if create_file; then
    printf 'created\n'
else
    printf 'create failed\n' >&2
fi

if 条件中的命令失败并不自动导致脚本退出,因为失败正是分支判断的一部分。

step_a && step_b
step_a || recover

对于 A && B

  • 如果 A 返回非 0,B 不执行,整体状态是 A 的状态;
  • 如果 A 成功,整体状态是 B 的状态。

对于 A || B

  • 如果 A 成功,B 不执行,整体状态通常是 0;
  • 如果 A 失败,执行 B,整体状态是 B 的状态。

因此:

false || echo "recovered"
printf 'status=%s\n' "$?"

最后状态是 echo 的 0,而不是 false 的 1。如果恢复动作本身不应改变原始失败,需要单独保存状态。

2.2 管道默认只报告最后一段命令

false | true
printf 'status=%s\n' "$?"

默认输出:

status=0

管道状态等于最后一条命令 true 的状态,前面的 false 被隐藏。启用 pipefail 后,规则变为:

  • 如果所有命令成功,管道状态为 0;
  • 否则返回管道中最右侧失败命令的状态。
set -o pipefail
false | true
printf 'status=%s\n' "$?"

结果为非 0。

如果需要诊断每个阶段,必须立即读取 PIPESTATUS,因为下一条命令会覆盖它:

set -o pipefail

producer | transformer | consumer
pipeline_rc=$?
stage_statuses=("${PIPESTATUS[@]}")

printf 'pipeline=%s stages=%s\n' \
    "$pipeline_rc" "${stage_statuses[*]}" >&2

PIPESTATUS 是当前 Shell 最近一次前台管道中各命令的状态数组。保存 $? 和保存 PIPESTATUS 都应紧接管道执行;中间插入其他命令会使诊断结果失真。

3. set -e:自动退出不是完整的错误传播机制

3.1 errexit 的基本目标

set -e
false
printf 'unreachable in this simple case\n'

在最简单的顶层场景中,false 会使 Shell 退出。

但 Bash 的 errexit 不是“任何非 0 都退出”。为了支持条件判断,以下位置通常属于例外:

  • ifwhile 的条件命令;
  • &&|| 链中不处于最终决定位置的命令;
  • ! 后面的命令;
  • 某些管道成员,除非启用 pipefail
  • 被调用函数位于这些条件上下文中时,其内部命令也可能受到该上下文影响。

例如:

set -e

if false; then
    printf 'not reached\n'
fi

false || printf 'failure was handled\n'
printf 'script continues\n'

输出:

failure was handled
script continues

这里的失败被 Shell 视为控制流的一部分。

3.2 函数调用处的条件上下文会影响函数体

这是 set -e 最容易产生意外的地方:

set -e

work() {
    false
    printf 'work continued\n'
}

if work; then
    printf 'success\n'
fi

在常见 Bash 行为中,work 整体位于 if 条件中,因此函数体内的 false 也可能不触发 errexit,于是会打印:

work continued
success

这不是函数自动吞掉了错误,而是调用上下文告诉 Bash:这个函数的失败正在被条件结构检查。

所以不能仅凭“脚本开了 set -e”推断每次失败都立即终止。可靠的核心逻辑仍应使用显式检查:

work() {
    false || {
        printf 'work: failed\n' >&2
        return 1
    }

    printf 'work continued\n'
}

3.3 命令替换中的 errexit

传统 Bash 行为中,命令替换 $(...) 内部通常会清除 -e,除非启用了 inherit_errexit 或处于 POSIX 模式:

set -e

value=$(false; printf 'still running inside substitution\n')
printf 'outer shell continues\n'

不同 Bash 版本、启动选项和 POSIX 模式可能改变此行为。即使开启:

shopt -s inherit_errexit

也不应把复杂错误处理建立在隐含继承规则上。更清楚的方式是让命令替换直接成为被检查的命令:

if ! value=$(generate_value); then
    printf 'generate_value failed\n' >&2
    exit 1
fi

3.4 推荐的错误传播模型

set -e 可以作为遗漏检查的辅助保护,但不应取代函数契约。一个明确的函数通常满足:

fetch() {
    local destination=$1

    curl --fail --silent --show-error \
         --output "$destination" \
         'https://example.invalid/data' ||
        return
}

main() {
    local file

    file=$1
    fetch "$file" || {
        printf 'fetch failed: %s\n' "$file" >&2
        return 1
    }

    process "$file" || return
}

main "$@"

这里每一层都定义了失败边界:

  1. curl 失败,fetch 返回失败;
  2. main 发现 fetch 失败后记录上下文;
  3. 顶层调用者决定脚本最终退出码。

相比“所有地方都依赖 set -e”,这种方式更容易测试,也更容易区分可恢复错误和不可恢复错误。

4. ERR Trap:记录错误,不等于处理错误

4.1 ERR 何时触发

ERR trap 会在某些命令返回非 0 时执行,但它遵循与 errexit 类似的条件例外。也就是说,以下失败通常不会触发 ERR

if false; then
    :
fi

false || :

因此,ERR 不是“所有失败事件的全局审计钩子”。

一个基础诊断器如下:

#!/usr/bin/env bash

set -E

trap 'printf "ERR: status=%d command=%q function=%s line=%s\n" \
    "$?" "$BASH_COMMAND" "${FUNCNAME[1]:-main}" "${BASH_LINENO[0]:-?}" >&2' ERR

fail() {
    false
}

fail

set -E 等价于 set -o errtrace,使 ERR trap 继承到函数、命令替换和子 Shell 等执行环境。没有它时,ERR trap 默认不会以同样方式继承到函数调用层次中。

处理器中必须先保存 $?。例如:

trap 'rc=$?; printf "failed rc=%d\n" "$rc" >&2; return "$rc"' ERR

这段代码并不适合作为通用顶层 trap:在不同执行环境中使用 return 可能没有合法的函数上下文。更稳妥的诊断 trap 通常只记录信息,不主动改变控制流:

on_err() {
    local rc=$?
    printf 'error: rc=%d command=%q\n' \
        "$rc" "$BASH_COMMAND" >&2
}

trap on_err ERR

如果同时使用 set -e,失败后是否退出由 errexit 决定,而不是由 ERR trap 的存在决定。

4.2 ERRerrexitpipefail 的关系

set -E
set -o pipefail

on_err() {
    local rc=$?
    printf 'ERR: rc=%d command=%q\n' "$rc" "$BASH_COMMAND" >&2
}

trap on_err ERR

false | true

没有 pipefail 时,管道整体为 0,通常不会触发 ERR;启用 pipefail 后,管道整体失败,才有失败事件可供 ERR 观察。

这说明错误信息沿着状态计算传播:

各管道成员状态
        ↓
pipefail 计算管道状态
        ↓
ERR / errexit 根据管道状态决定是否动作

ERR 记录的是 Shell 认为失败的命令或组合命令,不一定等价于底层每个进程的独立状态。因此,复杂管道仍应结合 PIPESTATUS 诊断。

5. trap 的基本模型:在事件发生时执行 Shell 代码

5.1 安装和取消 Trap

trap 'printf "shell is exiting\n" >&2' EXIT
trap - EXIT

trap action signal... 为一个或多个事件安装处理动作,trap - signal... 恢复默认行为。

建议使用单引号安装包含变量的动作:

trap 'printf "file=%s\n" "$file"' EXIT

此时 $file 在 trap 真正执行时展开。若使用双引号:

trap "printf 'file=%s\n' '$file'" EXIT

变量可能在安装 trap 的瞬间就被展开,路径中的特殊字符也可能改变最终 Shell 代码。更安全、可维护的方式是让 trap 调用函数:

cleanup() {
    printf 'cleaning up\n' >&2
}

trap cleanup EXIT

函数名作为 trap 动作不会提前展开复杂数据,也便于单独测试。

5.2 EXIT Trap 不是信号处理器

EXIT 在当前 Bash Shell 即将退出时执行,常用于释放临时资源:

trap cleanup EXIT

它覆盖的是正常结束、显式 exit,以及许多由 Shell 自己触发的退出路径。但它不能拦截所有终止方式:

  • SIGKILL(9)不能被捕获或忽略;
  • SIGSTOP(19,在 Linux 上)不能被捕获或忽略;
  • 机器掉电、内核崩溃、强制容器销毁等情况不会给 Bash 执行清理代码的机会;
  • 进程被 kill -9 终止时,EXIT 不会运行。

因此,清理逻辑只能提供“尽力而为”的资源回收,不能替代幂等设计、启动时过期资源回收和外部生命周期管理。

6. 临时资源清理:创建、登记、释放和保留原始状态

6.1 使用 mktemp 创建私有临时目录

tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXXXX") || {
    printf 'cannot create temporary directory\n' >&2
    exit 1
}

mktemp -d 会请求创建一个新的临时目录,模板末尾的 X 由实现替换为随机字符。主流 Linux 发行版通常提供 util-linux 的 mktemp;它不是所有历史 Shell 环境都保证存在的 POSIX Shell 内建命令。

使用它而不是手工拼接:

tmpdir="/tmp/myapp.$$"
mkdir "$tmpdir"

原因是 PID 可预测,目录可能被提前创建或被符号链接替换。mktemp 的原子创建能力可以降低这类竞态风险,但仍需注意:

  • 不要把用户可控字符串直接拼入 rm -rf
  • 必须始终引用路径;
  • 应验证临时路径确实由当前进程创建;
  • 临时目录中可能包含敏感数据,权限和挂载点需要结合部署环境检查;
  • /tmp 可能被定期清理,也可能是内存文件系统,不能假设永久存在。

6.2 EXIT 清理函数应保持幂等

幂等意味着清理执行一次或多次,最终结果相同。典型实现:

#!/usr/bin/env bash

set -u

tmpdir=

cleanup() {
    local rc=$?

    if [[ -n ${tmpdir:-} && -d $tmpdir ]]; then
        rm -rf -- "$tmpdir" || {
            printf 'warning: cannot remove %q\n' "$tmpdir" >&2
        }
    fi

    return "$rc"
}

trap cleanup EXIT

tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/myapp.XXXXXXXX") || {
    printf 'cannot create temporary directory\n' >&2
    exit 1
}

printf 'temporary directory: %s\n' "$tmpdir"
printf 'result\n' >"$tmpdir/result.txt"

这里的关键步骤是:

  1. 先将 tmpdir 初始化为空;
  2. 安装 trap,使后续失败也能触发清理;
  3. 成功创建后再写入目录变量;
  4. 清理时检查变量非空且路径是目录;
  5. 使用 -- 防止以 - 开头的路径被解释为选项;
  6. 保存进入 cleanup 时的退出状态;
  7. 清理失败只记录警告,不覆盖原始业务错误。

如果 rm 失败后直接让 cleanup 返回 rm 的状态,脚本原本的业务失败可能被清理失败覆盖,或者原本成功的脚本因为清理异常变成失败。是否让清理失败改变最终状态,应由业务要求明确决定,而不能偶然由最后一条命令决定。

6.3 只在资源确实属于当前进程时删除

下面这种写法风险极高:

rm -rf "$tmpdir"

如果变量为空、被覆盖,或者包含错误路径,后果可能是灾难性的。最少应做到:

if [[ ${tmpdir:-} == /tmp/myapp.* && -d ${tmpdir:-} ]]; then
    rm -rf -- "$tmpdir"
fi

不过字符串前缀检查不是完整的安全边界。生产代码还应避免让路径变量被外部输入直接控制,并在创建后保存明确的资源所有权信息。高权限脚本尤其不能把 TMPDIR、命令行参数或配置文件中的目录未经校验地交给递归删除命令。

7. 信号清理:从收到信号到退出的完整路径

7.1 常见信号与处理限制

常见信号包括:

信号 常见编号 含义 能否捕获
SIGHUP 1 终端挂断或会话结束
SIGINT 2 交互中断,通常来自 Ctrl-C
SIGTERM 15 请求进程优雅终止
SIGKILL 9 强制终止 不能
SIGSTOP 19 强制暂停 不能

信号编号在 Linux 上通常如表所示,但程序中应优先使用信号名,而不是假设数字在所有 Unix 系统上都相同。

清理的通常流程如下:

flowchart TD
    A[进程运行] --> B{收到事件}
    B -->|正常结束或 exit| C[EXIT trap]
    B -->|SIGINT/SIGTERM/SIGHUP| D[信号 trap]
    D --> E[设置业务退出码]
    E --> C
    C --> F[删除临时资源]
    F --> G[进程结束]
    B -->|SIGKILL/掉电| H[无法执行 Shell 清理]

信号 handler 通常不直接承担全部清理工作,而是设置退出路径,让统一的 EXIT trap 执行一次清理。

7.2 一个可运行的信号清理示例

#!/usr/bin/env bash

set -u

tmpdir=

cleanup() {
    local rc=$?

    if [[ -n ${tmpdir:-} && -d $tmpdir ]]; then
        rm -rf -- "$tmpdir" || {
            printf 'warning: cleanup failed for %q\n' "$tmpdir" >&2
        }
    fi

    return "$rc"
}

trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/signal-demo.XXXXXXXX") || {
    printf 'cannot create temporary directory\n' >&2
    exit 1
}

printf 'pid=%d dir=%s\n' "$$" "$tmpdir"
printf 'press Ctrl-C or run: kill -TERM %d\n' "$$"

while :; do
    sleep 10
done

运行后,从另一个终端发送:

kill -TERM <pid>

预期行为是:

  1. Bash 执行 TERM trap;
  2. exit 143 设置退出状态;
  3. Bash 执行 EXIT trap;
  4. cleanup 删除临时目录;
  5. 进程以状态 143 结束。

之所以不能只写:

trap cleanup TERM

是因为清理函数返回后,进程可能继续执行原来的业务逻辑。信号处理器必须明确决定“继续”“重新发送信号”还是“以某个状态退出”。

7.3 sleepwait 与信号竞态

如果脚本正在等待子进程:

wait "$child_pid"

收到被捕获的信号后,wait 可能因信号中断并返回大于 128 的状态;具体表现还与 Bash 版本、信号到达时机和子进程状态有关。不要把任何大于 128 的状态都简单视为子进程业务失败,必须区分:

  • 子进程自身退出;
  • 当前 Shell 被信号打断等待;
  • 当前 Shell 即将退出。

生产脚本常见的策略是:

  1. 信号 handler 设置停止标志或直接退出;
  2. 终止仍在运行的子进程;
  3. wait 等待它们退出;
  4. 最后由 EXIT trap 清理临时资源。

如果子进程还会继续创建孙进程,单独杀掉一个 PID 可能不够,需要进程组、服务管理器或容器运行时提供的生命周期控制。

8. Trap 的作用域、子 Shell 和并发边界

8.1 函数共享当前 Shell 的 Trap

函数通常在当前 Shell 中执行。函数内安装的 trap 会影响当前 Shell 后续行为:

set_trap() {
    trap 'printf "changed\n"' EXIT
}

set_trap
exit

因此,库式脚本或被其他脚本 source 的文件不应随意修改调用者的全局 trap,除非明确记录并恢复原状态。

8.2 子 Shell 不是独立的资源所有者

以下结构会创建子 Shell 或类似隔离执行环境:

(
    command
)

value=$(command)

变量修改通常不会回写父 Shell:

x=parent
(
    x=child
    printf '%s\n' "$x"
)
printf '%s\n' "$x"

输出为:

child
parent

但外部资源不是变量副本。子 Shell 和父 Shell 仍可能访问同一个临时目录、文件描述符或锁文件。如果父 Shell和子 Shell都安装删除同一目录的清理逻辑,就可能出现:

  • 一个 Shell 先删除目录,另一个清理时收到“文件不存在”;
  • 子 Shell 删除了父 Shell仍在使用的文件;
  • 并发清理与业务写入发生竞态。

因此,临时资源应明确由一个生命周期主体拥有。并发 worker 最好只处理目录中的文件,不要让每个 worker 都执行顶层目录删除。

Bash 对不同 trap 类型在函数、子 Shell、命令替换中的继承规则并不完全相同;ERR 是否继承明确受 errtrace 影响,信号 trap 也会受子 Shell 执行环境影响。涉及复杂并发时,应使用最小实验验证当前 Bash 版本,而不是凭“trap 是全局的”或“子 Shell 完全隔离”推断。

9. 常见错误写法与诊断方式

9.1 错误:在函数末尾无意中返回成功

check() {
    test -f "$1"
    echo "checked"
}

test 失败后,echo 成功,函数返回 0。

修正:

check() {
    test -f "$1" || return
    echo "checked"
}

9.2 错误:把 set -e 当作事务机制

set -e

begin_operation
may_fail
write_marker

set -e 不会回滚 begin_operation 已产生的副作用,也无法保证所有错误路径都退出。若需要回滚,必须显式记录状态:

changed=0

if make_change; then
    changed=1
else
    exit 1
fi

rollback() {
    local rc=$?
    if (( changed )); then
        undo_change || printf 'rollback failed\n' >&2
    fi
    return "$rc"
}

trap rollback EXIT

这里仍需考虑回滚本身失败、信号中断和重复执行。Shell 没有自动事务语义。

9.3 错误:Trap 中覆盖了原始状态

cleanup() {
    rm -rf -- "$tmpdir"
    printf 'cleanup done\n'
}
trap cleanup EXIT

如果脚本原本以状态 7 退出,清理函数中的命令可能让最终观察到的状态变得不明确,尤其当 trap 中显式执行 exit 或发生新的失败时。

应先保存状态,并让清理动作尽量不改变它:

cleanup() {
    local rc=$?

    rm -rf -- "$tmpdir" || :
    printf 'cleanup done\n' >&2

    return "$rc"
}

9.4 错误:信号 handler 只清理、不退出

trap 'rm -rf -- "$tmpdir"' TERM

收到 SIGTERM 后,Shell 可能继续执行原逻辑。更完整的路径应当是:

trap 'exit 143' TERM
trap cleanup EXIT

若清理需要先通知子进程、等待其退出,再退出,则应在专门的信号函数中实现,并防止重复进入。

9.5 用 ShellCheck 和故障注入验证

ShellCheck 能发现许多与本文直接相关的问题,例如:

  • 未引用变量;
  • local x=$(cmd) 造成状态遮蔽;
  • trap 字符串中的变量提前展开;
  • rm 参数和数组展开存在风险;
  • 未检查命令替换结果。

可以把脚本保存为 demo.sh 后运行:

shellcheck demo.sh
bash -n demo.sh

bash -n 只检查语法,不执行命令;ShellCheck 也不能证明信号竞态和资源生命周期一定正确。

实际测试还应故意制造故障:

bash demo.sh
printf 'exit=%s\n' "$?"

# 另一个终端中发送:
kill -TERM <pid>

验证项目包括:

  1. 业务失败是否保留原始退出码;
  2. ERR 日志是否包含正确的 BASH_COMMAND
  3. SIGTERM 后是否以 143 结束;
  4. 临时目录是否被删除;
  5. 清理失败时是否仍保留业务错误;
  6. 重复执行 cleanup 是否安全;
  7. SIGKILL 时是否留下资源,以及下一次启动能否识别并回收过期资源。

对于更复杂的函数行为,可以使用 Bats 测试函数的标准输出、标准错误和退出状态;对于临时目录、伪造命令和权限故障,则应使用独立 fixture,避免测试直接破坏真实系统目录。

10. 一个完整的函数、错误传播和清理骨架

下面示例把这些机制组合起来:

#!/usr/bin/env bash

set -u
set -o pipefail

tmpdir=

cleanup() {
    local rc=$?

    if [[ -n ${tmpdir:-} && -d $tmpdir ]]; then
        rm -rf -- "$tmpdir" || {
            printf 'warning: failed to remove %q\n' "$tmpdir" >&2
        }
    fi

    return "$rc"
}

on_err() {
    local rc=$?
    printf 'error: rc=%d command=%q line=%s\n' \
        "$rc" "$BASH_COMMAND" "${BASH_LINENO[0]:-?}" >&2
}

trap cleanup EXIT
trap on_err ERR
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM

make_input() {
    local path=$1

    printf 'input\n' >"$path" || {
        printf 'cannot write input: %s\n' "$path" >&2
        return 1
    }
}

process_input() {
    local input=$1
    local output=$2

    if ! tr '[:lower:]' '[:upper:]' <"$input" >"$output"; then
        printf 'processing failed\n' >&2
        return 1
    fi
}

main() {
    local input output

    tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/pipeline-demo.XXXXXXXX") || {
        printf 'cannot create work directory\n' >&2
        return 1
    }

    input="$tmpdir/input"
    output="$tmpdir/output"

    make_input "$input" || return
    process_input "$input" "$output" || return

    cat "$output"
}

main "$@"

其状态流为:

make_input
    └─失败 → return 非 0
process_input
    └─失败 → return 非 0
main
    └─向顶层返回失败
EXIT trap
    └─保存失败状态并清理临时目录

这个骨架没有依赖 set -e 来推断函数内部控制流,而是显式定义每个函数的失败出口;ERR 只负责诊断;EXIT 负责资源生命周期;信号 trap 负责把终止请求转换为明确退出路径。四者职责分离后,脚本的行为才容易推导、测试和恢复。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。