一个用 C 写成的小工具,负责在部署流程里调用几条外部命令。判断逻辑很朴素:system 的返回值不是 0,就认为命令失败,打印错误退出。
这套判断跑了两个月,期间有一类失败从来没被拦下过:命令确实失败了,工具却报成功,后面的步骤继续往下走,直到产物校验阶段才炸。
排查时打了个断点,发现失败那次返回的值是 256。命令的退出码是 1,但返回值不是 1。
返回值到底是什么?
man 页在 RETURN VALUE 一节把几种情况分开写了,其中最关键的是这句:
If all system calls succeed, then the return value is the termination status of the child shell used to execute command. (The termination status of a shell is the termination status of the last command it executes.)
「一切系统调用成功时,返回值是执行命令那个子 shell 的终止状态。」注意是「终止状态」,不是「退出码」。
紧接着的一句把用法写死了:
In the last two cases, the return value is a "wait status" that can be examined using the macros described in waitpid(2). (i.e., WIFEXITED(), WEXITSTATUS(), and so on).
返回值是一个 wait status,要用 waitpid 那一套宏去解码。所以 256 不是错误码,而是「退出码 1」被编码到状态字高位之后的结果。
顺带说清楚一件事:system 并不直接执行你给的命令。它先 fork 一个子进程,再在子进程里 execl 一个 shell,把整个字符串交给 shell 解释。所以你等的从来不是那条命令,而是执行它的那个 shell 进程。
三种返回值分别从哪来?
文档列的是三种来源,套在一起看才完整:
| 返回值 | 来源 | 处理方式 |
|---|---|---|
-1 |
子进程没建起来,或状态取不到 | 看 errno,与命令本身无关 |
| 状态字 | 命令正常结束 | WIFEXITED 判断,WEXITSTATUS 取值 |
| 状态字 | 命令被信号终止 | WIFSIGNALED 判断,WTERMSIG 取值 |
| 状态字 | 子 shell 压根没起来 | 形状等同于「以 127 退出」 |
第一行最容易处理,因为它是负数,和正常状态字的范围不重叠。真正的坑在后三行------它们都长得像「一个正整数」,只有解码之后才知道是正常退出、被信号杀掉,还是 shell 根本没起来。
c
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
int run(const char *cmd) {
int st = system(cmd);
if (st == -1) {
return -1; /* fork 或 wait 失败,看 errno */
}
if (WIFEXITED(st)) {
return WEXITSTATUS(st); /* 真正的退出码 */
}
if (WIFSIGNALED(st)) {
return 128 + WTERMSIG(st); /* 被信号终止,用常见约定编码 */
}
return -1; /* 其它情况:暂停、继续等 */
}
这段代码里最容易被省略的是最后那行兜底。WIFEXITED 和 WIFSIGNALED 都不成立时,状态字仍然是个合法值,直接当成功会漏掉一类失败。
为什么 127 有两种来源?
文档里有一句专门提醒这件事的实现后果:
It is possible for the shell command to terminate with a status of 127, which yields a system() return value that is indistinguishable from the case where a shell could not be executed in the child process.
命令自己返回 127,和子进程里连 shell 都没起来,这两种情况的返回值分不出来。
127 在 shell 约定里表示「命令找不到」,所以命令本身返回 127 是常见的事------脚本里写错一个命令名就会这样。而 shell 起不来(比如 /bin/sh 不存在、被 chroot 之后路径断掉)也编码成 127。
如果想区分,system 这个接口给不了你答案,得换成自己 fork + exec:exec 失败时子进程可以自己选一个约定的退出码,或者在 fork 之后、exec 之前用管道把错误回传。代价是要自己处理信号屏蔽和 waitpid。
文档还留了一个小口子:参数传 NULL 时,返回值只用来说明系统上有没有可用的 shell,非零表示有。适合在程序启动时做一次前置检查,把「shell 不可用」和「命令失败」在源头分开。不过这一条有个版本说明------glibc 2.1.3 之前并不会真的去检查,而是假定一定可用、直接返回 1。
循环里调用为什么按 Ctrl+C 不退出?
文档在 DESCRIPTION 里提到一句:
During execution of the command, SIGCHLD will be blocked, and SIGINT and SIGQUIT will be ignored, in the process that calls system().
调用方进程在执行命令期间,SIGCHLD 被阻塞,SIGINT 和 SIGQUIT 被忽略。这两个恰好是终端里 Ctrl+C 和 Ctrl+\ 发出的信号。
所以一个在循环里不断调用 system 的程序,按下 Ctrl+C 之后可能没有任何反应------信号被忽略了,而循环还在继续。文档随后给了一句更直白的提示:
As mentioned, system() ignores SIGINT and SIGQUIT. This may make programs that call it from a loop uninterruptible, unless they take care themselves to check the exit status of the child.
「可能在循环里变得不可中断,除非调用方自己检查子进程的退出状态。」文档给的写法是:在循环里检查 WIFSIGNALED 和 WTERMSIG,如果子进程是被 SIGINT 或 SIGQUIT 终止的,就跳出循环。
这条对交互式工具影响很大。想在长时间运行的循环里保留「按一下就能停」的能力,除了上面那个检查,另一条路是自己装信号处理器并置一个标志位,在每轮循环开头判断。
安全上还有哪几处?
文档的 Caveats 一节列了三条,都跟环境有关。
一条是关于特权程序的:
Do not use system() from a privileged program (a set-user-ID or set-group-ID program, or a program with capabilities) because strange values for some environment variables might be used to subvert system integrity.
带特殊权限的程序里不要用它,因为环境变量可以被篡改成指向别的可执行文件。文档推荐的替代是 exec 系列,但明确排除了 execlp 和 execvp------它们同样靠 PATH 找程序。
另一条是关于输入清洗的:命令里拼接了用户输入时必须严格过滤,否则可以注入额外的 shell 命令或选项。特权程序里这个风险更严重。
第三条在 BUGS 一节,比较冷:如果命令名本身以连字符开头,shell 会把它当成选项解释,行为变成未定义。文档给的绕法是前面加一个空格。
小结
system 的返回值是 wait status,不是退出码;!= 0 只能粗略区分「成功」和「非成功」,拿不到具体失败原因。要拿到退出码必须走 WEXITSTATUS,要区分「正常失败」和「被信号杀掉」必须走 WIFSIGNALED。
127 这个值有两义,想分辨得放弃 system 自己 fork。循环里调用会屏蔽 Ctrl+C,需要在循环体里自己检查。
这套解码表我整理进了项目的编码约定,按「返回值形态、判断宏、取值宏、处理动作」四列排,几个工具共用一份。底稿是用 AI墨衍 从 man 页和几次误判的排查记录里抽出来的,新增一条只需要改一处。
真正值得记的一点是:!= 0 在很多场景下「碰巧能用」,因为失败时状态字几乎不会是 0。这种碰巧能用的写法,往往要等到需要打印具体退出码、或者需要按 127 做分支的那天,才暴露出来。