写程序这些年,我见过太多新手在错误处理上栽跟头。打开文件失败那一下,系统返回的只是一个简单的-1,可你要是直接把它当成普通数字打印出来,永远找不到真正原因。真正的问题定位线索,藏在errno这个伴随几乎所有系统调用的特殊变量里;等到了shell脚本里,事情又变成另一套玩法:每个命令结束后,$?里躺着一个退出码,0代表成功,127表示命令不存在,130则是被Ctrl+C中途杀掉。这套“错误返回值”机制,是Linux下不管是C/C++还是shell脚本,都必须吃透的基础之一。这篇文章我打算把底层逻辑、常用错误码、实际用法和排查手段一次性梳理清楚,顺便把我这些年踩过的坑也一并交代。
1. 错误返回值到底分几层——先搞清三个层面的约定
1.1 函数返回值只是“结果码”,真正的线索在errno里
在Linux上,几乎每个系统调用都有两个层面的反馈。第一个层面是函数本身的返回值,比如open()失败时返回-1,read()失败时返回-1,socket()失败时返回-1。这个-1是一个“统一定义的失败信号”,但它不告诉你为什么失败。第二个层面才是errno。它相当于系统写在一张便签上的错误原因编号,你需要在函数返回失败之后立刻去读它。我把这种设计理解成“错误返回码 + 错误明细”的组合:返回值负责告诉你“出事了”,errno负责告诉你“出的是什么事”。
你可能会问:为什么不能直接返回一个负数编号,比如-2代表文件不存在,-13代表权限不足?我最早也这么想,后来才意识到,函数返回值的语义并不统一。有的函数返回值表示长度,有的表示指针,有的表示状态,如果强行把错误码塞进返回值,会破坏正常结果的表达空间。Linux选择保留一个特殊值表示“失败”,再把详细原因丢到errno里,由调用方自己决定怎么处理。这是一种把“结果”和“原因”解耦的做法,很多库函数也沿用类似思路。
1.2 errno不是普通全局变量——线程安全与宏展开
严格来说,errno不是一个真正的全局变量。在glibc实现里,errno是一个宏,通常展开成函数调用,比如会返回一个当前线程的私有存储地址。这意味着在多线程程序里,两个线程同时执行read(),各自拿到的errno互不影响。这一点非常关键,否则一个线程的错误状态会污染另一个线程的判断逻辑。
但这个设计也带来了一个隐藏注意事项。errno在系统调用成功时不会被主动清零,它只会“残留”上次的值。如果你在函数返回成功之后还去检查errno,看到的可能是很久以前某个失败操作留下的错误码,然后你会误判。所以标准姿势永远是:先判断函数的返回值,只有返回值为失败标志时,才去读errno。需要用errno做后续处理时,最好第一时间把它复制到一个局部变量里,因为任何一次printf、malloc、open都有可能会修改errno,尤其在你打印错误信息的瞬间,errno可能已经被printf内部的调用改掉了。这个问题我确实踩过。
1.3 shell层退出码:0代表成功,非0不代表任何坏事
从shell视角来看,每个程序结束时会向父进程返回一个退出状态码,bash里用$?取到的是一个0到255之间的整数。退出码的核心约定是0代表成功,非0代表失败。但这个非0是有讲究的。当一个程序被某个信号杀死时,shell会把退出码变成“128 + 信号编号”,这不是程序自己返回的,而是操作系统的约定。另外,不同的程序可能会用不同的退出码表示不同类型的失败:有些程序1表示参数错误,2表示运行时错误,3表示依赖缺失。此时你在脚本里判断失败,就不能只看结果是否非零,还要结合具体语义来看。
理解这三个层面之后,再去看errno和退出码就不会觉得混乱。下面把最常用的errno列成速查表,对照着用会顺手很多。
2. 常用错误码速查表——把errno翻译成人话
2.1 文件、权限、路径类错误码
这类错误码在开发中最常见,因为文件操作是所有程序的地基。我按日常出现频率整理了下面这张表,表格里“常见场景”那一列基本就是你踩过的坑。
| 名称 | errno | 含义 | 常见场景 |
|---|---|---|---|
| EPERM | 1 | 操作不被允许 | 对自己没有权限的进程执行kill,或者修改一个只读文件的所有者 |
| ENOENT | 2 | 没有这个文件或目录 | open一个不存在的路径,或者路径的某个父目录不存在 |
| EIO | 5 | I/O错误 | 磁盘损坏、设备无响应、底层存储读写失败 |
| EBADF | 9 | 文件描述符无效 | 对已经close的fd调用read/write |
| EACCES | 13 | 权限拒绝 | 没有读、写、执行权限,比如普通用户读root文件 |
| EEXIST | 17 | 文件已存在 | open指定O_CREAT|O_EXCL,文件已存在时会报这个错 |
| ENOTDIR | 20 | 路径中的某个成分不是目录 | 把普通文件当成一级目录去拼接路径 |
| EISDIR | 21 | 目标是一个目录 | 对目录文件调用read(),或者open目录准备读取数据 |
| EMFILE | 24 | 进程打开文件数量超限 | 进程忘了关闭fd,打开了几千个文件后达到上限 |
| ENOSPC | 28 | 设备上没有空间 | 磁盘写满,或者inode耗尽,创建文件失败 |
| EROFS | 30 | 文件系统只读 | 往只读挂载点写入文件 |
| ENAMETOOLONG | 36 | 文件路径名过长 | 文件名超过文件系统限制,比如255字节 |
| ENOTEMPTY | 39 | 目录非空 | rmdir一个还有内容的目录 |
| ELOOP | 40 | 符号链接循环 | 一个软链接指向自身,或者递归嵌套太深 |
EPERM和EACCES的区别值得单独说一下。二者都是权限问题,但侧重点不同:EACCES通常是你对某个资源没有访问权,比如没有文件权限;EPERM则更偏向“系统不允许你执行这类操作”,比如普通用户调用chmod想修改root拥有的文件。看到EACCES先检查权限位,看到EPERM先检查操作本身是否超出你的能力范围。
2.2 进程、内存与资源类错误码
进程和资源相关的错误码,多出现在fork、exec、wait、内存分配这些系统调用里。很多新手会在这里被EAGAIN和EINTR搞晕,我单独把它们摘出来讲。
| 名称 | errno | 含义 | 常见场景 |
|---|---|---|---|
| ESRCH | 3 | 找不到对应进程 | kill一个不存在的PID |
| EINTR | 4 | 系统调用被信号中断 | 阻塞中的read被SIGINT/SIGALRM打断 |
| E2BIG | 7 | 参数列表过长 | exec时argv或envp的总长度超出限制 |
| ENOEXEC | 8 | 可执行文件格式错误 | exec一个没有有效文件头的二进制,或脚本缺少解释器 |
| ECHILD | 10 | 没有子进程 | waitpid等待一个不存在的子进程 |
| EAGAIN | 11 | 资源暂时不可用 | fork达到进程数上限,非阻塞IO模式下read没数据 |
| ENOMEM | 12 | 内存不足 | malloc失败,mmap无法分配足够虚拟内存 |
| EFAULT | 14 | 地址非法 | 传了一个非法的指针给系统调用 |
| EBUSY | 16 | 设备或资源忙 | 挂载点未卸下,某些rename操作不满足条件 |
| EINVAL | 22 | 参数无效 | 给系统调用传入了不支持的值,比如负数的offset |
| ENFILE | 23 | 系统级打开文件数超限 | 整个系统打开的文件描述符总数达到上限 |
| EPIPE | 32 | 管道已破裂 | 向没有读端的管道写数据,进程通常会先收到SIGPIPE |
| ERANGE | 34 | 数值结果越界 | 一些库函数转换结果超出可表示范围 |
| EOVERFLOW | 75 | 值过大 | 文件偏移、inode数量等超过对应类型范围 |
EAGAIN这个名字确实有迷惑性。“资源暂时不可用”到底是什么意思?在非阻塞IO里最常见:你把fd设成了O_NONBLOCK,然后去read(),缓冲区里没有数据,read不会阻塞等着,而是直接返回-1并把errno设置为EAGAIN,意思是“现在没有数据,你稍后再来”。在fork场景下,EAGAIN表示进程数量或线程数量达到了系统上限,暂时创建不了。看到EAGAIN先不要急着当成“资源耗尽死路一条”,它经常意味着“可以重试”或“应该换个非阻塞策略”。
2.3 网络与socket相关错误码
网络这块的错误码跟TCP/IP协议栈底层行为强相关,排查时经常需要结合对端的表现来判断。下面这些是写socket程序时最常碰到的。
| 名称 | errno | 含义 | 常见场景 |
|---|---|---|---|
| EADDRINUSE | 98 | 地址已被占用 | bind时端口被占用,TIME_WAIT连接过多导致端口不释放 |
| EADDRNOTAVAIL | 99 | 地址不可用 | 绑定的IP不是本机任一网卡的地址 |
| ENETUNREACH | 101 | 网络不可达 | 路由表里根本没有去往目标网络的路径 |
| ETIMEDOUT | 110 | 连接超时 | 对端无响应,或中间防火墙静默丢弃数据 |
| ECONNREFUSED | 111 | 连接被拒绝 | 目标端口没有服务监听,对端直接回了RST |
| ECONNRESET | 104 | 连接被重置 | 对端进程突然退出,或收到非预期的RST包 |
| ECONNABORTED | 103 | 连接被中止 | accept时对端已经断开 |
| ENOTCONN | 107 | 连接未建立 | 在没有建立连接的socket上调用send/recv |
| EISCONN | 106 | 已经连接 | 对已经处于连接状态的socket再次connect |
| EHOSTUNREACH | 113 | 主机不可达 | 目标地址无法在本地网络解析到链路 |
| EINPROGRESS | 115 | 操作进行中 | 非阻塞connect正在建立连接 |
| EAFNOSUPPORT | 97 | 地址族不支持 | 创建socket时协议族和套接字类型不匹配 |
ECONNREFUSED和ETIMEDOUT是两回事:端口没监听,对端直接回RST,你会拿到ECONNREFUSED;如果有防火墙拦着或者对端根本连不上,往往就是ETIMEDOUT。看到ETIMEDOUT先别怀疑代码,多半是网络环境问题,先去确认对端状态和防火墙策略。
3. C/C++实操:怎么把errno变成真正能用的错误处理
3.1 先看返回值,再看errno,顺序不能反
一个很经典的错误处理反例是这样写的:
if (errno != 0) { perror("something"); return -1; }这个写法看着严谨,实际上非常危险。errno在成功的时候根本不会清零,它保留的是最近一次错误的值。如果你的程序之前某个地方已经设置过errno,比如一次成功的open()之前的失败调用,你后面再执行一个成功的read(),然后被这个if接住,程序就会“莫须有”地报一个错误。所以,错误处理的顺序必须是:
- 检查函数返回值,看是否等于失败标志;
- 只有确认失败,才去读errno。
我在实际工作中见过太多因为顺序搞反导致的诡异bug:文件明明读到了,日志却报着“Operation not permitted”,查了半天发现是errno残留。这个规则看起来简单,但一旦函数调用多起来、代码嵌套变深,就会有人不自觉地把errno当“状态量”用,然后掉进坑里。
3.2 一个完整的配置文件读取示例
下面这个函数是一个比较标准的配置读取场景,涉及open、循环read、EINTR处理三大要点。你可以直接当成模板用。
#include <stdio.h> #include <string.h> #include <errno.h> #include <fcntl.h> #include <unistd.h> int load_config(const char *path, char *out, size_t out_size) { if (path == NULL || out == NULL || out_size == 0) { errno = EINVAL; return -1; } int fd = open(path, O_RDONLY); if (fd < 0) { fprintf(stderr, "[config] open %s failed: %s (%d)\n", path, strerror(errno), errno); return -1; } char *p = out; size_t remaining = out_size - 1; ssize_t n; while (remaining > 0) { n = read(fd, p, remaining); if (n < 0) { if (errno == EINTR) { continue; } fprintf(stderr, "[config] read %s failed: %s (%d)\n", path, strerror(errno), errno); close(fd); return -1; } if (n == 0) { break; } p += n; remaining -= n; } *p = '\0'; close(fd); return 0; }这个例子有几个细节值得展开。失败时打印的错误信息里同时包含步骤、路径、错误字符串和错误数字。错误字符串是给人看的,错误数字是给代码检索和日志原文比对用的。你写“No such file or directory (2)”,比干巴巴地写“open failed”有用得多。
read()返回0表示读到了文件末尾,这是正常情况,不代表出错。read()返回正数时,不保证一次能读完你要的字节数,所以代码里用循环读取,一直读到填满缓冲区或者碰到文件结尾。read()被信号中断时返回-1且errno为EINTR,此时不应该退出,应该重新发起读取。
close(fd)的返回值我在这里没有检查,这是故意简化的。按严格的错误处理标准,close失败在部分网络文件系统场景下是真实存在的,写缓冲区的错误可能拖到close时才报出来。如果你的业务数据落盘要求严格,应该单独检查close的返回值,而不是直接忽略。
3.3 封装通用错误打印宏与统一收尾模式
写错误处理久了,你会发现在每个出错分支里写fprintf非常啰嗦,而且很容易漏信息。我喜欢在最开始就封装一个打印宏,把步骤名、文件名、行号、错误码全部塞进一条日志里。
#define LOG_ERR(step, err_no) \ fprintf(stderr, "%s failed at %s:%d: %s (%d)\n", \ (step), __FILE__, __LINE__, \ strerror(err_no), (err_no))配合统一的失败收尾模式,效果会更好:
int init_app(const char *cfg_path) { int fd = -1; char buf[4096]; fd = open(cfg_path, O_RDONLY); if (fd < 0) { LOG_ERR("open config", errno); goto fail; } if (load_config(cfg_path, buf, sizeof(buf)) != 0) { goto fail; } if (start_worker() != 0) { LOG_ERR("start worker", errno); goto fail; } return 0; fail: if (fd >= 0) { close(fd); } return -1; }这种写法的核心是:成功路径一路往下走,失败路径统一收尾到fail标签,资源释放逻辑只写一遍。很多Linux系统程序里都能看到这种风格。它不强求你必须用goto,但会比在每个错误分支里重复写close(fd)要清爽得多。
最开始我总觉得goto不优雅,结果在一个函数里写了七八个close(fd)加日志。后来一次误删了其中一个close,fd就开始泄漏,程序跑两天就撞上EMFILE。最后改成这种集中收尾的写法,错误分支越多,它带来的收益越明显。
3.4 EINTR:最容易被当成“硬错误”的伪错误
慢系统调用(比如阻塞的read、write、open、wait、sleep)在进程收到信号时会被中断,此时系统调用返回-1,errno被设置为EINTR。从语义上说,这不算“系统层面的失败”,更像是“系统调用被别人打断了”,正确的处理是重新发起调用。
最典型的例子是read和write循环:
ssize_t r; do { r = read(fd, buf, len); } while (r < 0 && errno == EINTR);EINTR如果不做重试处理,程序可能表现出一种很微妙的假死:明明没出错,却少读了几十个字节,或者一次sleep被信号提前唤醒,后续流程全乱。我自己遇到过的比较隐蔽的一次,是alarm()定时器触发的EINTR,程序里所有read都做了重试,唯独另一处阻塞调用没有处理,结果业务线程每分钟都被打断一次,排了好几天才定位到。
需要特别说明的是connect()。如果非阻塞connect被EINTR打断,你不能简单地对同一个fd重新调用connect(),这会直接报EINPROGRESS或者EADDRINUSE。正确做法是用select/poll等待这个fd变为可写,再通过getsockopt(SO_ERROR)取出连接结果。这部分逻辑比较绕,建议先把普通read/write的EINTR重试循环养成习惯,遇到connect再单独查资料,不要照搬read的重试写法。
4. shell脚本场景:$?、退出码和信号终止
4.1 $?怎么取、怎么判断才不踩坑
bash里每次执行完一条命令,退出码就会立刻写入$?。这意味着你只能用一次。一个常见失误是把$?打印出来之后再去判断,结果判断的时候读到的是echo自己的退出码。比如:
some_command echo $? if [ $? -ne 0 ]; then echo "failed" fi这段代码如果some_command失败,echo $?确实打印了非0值,但echo自己也成功了,if里的$?是0,导致失败分支永远不会触发。正确做法是先把$?存到变量,或者直接对$?做判断:
some_command ret=$? if [ $ret -ne 0 ]; then echo "command failed, exit code: $ret" fi还有一个容易忽略的点:如果脚本开启了set -e,上面这个写法也可能有问题,因为some_command是单独执行的,没有被if保护,set -e遇到它失败会立刻终止整个脚本,根本轮不到后面的if。想在set -e下捕获失败,应该这样写:
if ! some_command; then echo "command failed" exit 1 fi让if结构本身去管理退出码的副作用,是shell脚本里最稳的姿势。
4.2 126、127和128+n:这些退出码到底在说什么
shell脚本里常碰到的几个特殊退出码,很多新人见到就懵。下面把它们整理成一张速查表。
| 退出码 | 含义 | 典型触发场景 |
|---|---|---|
| 0 | 成功 | 程序正常结束 |
| 1 | 一般错误 | 绝大多数工具的错误分支 |
| 2 | 严重错误 | shell内建命令或脚本本身有语法错误 |
| 126 | 命令找到了,但无法执行 | 没有执行权限,或者文件依赖的解释器不存在 |
| 127 | 命令找不到 | PATH中没有这个命令,或者文件名拼错 |
| 128+n | 命令被信号n终止 | 130表示被SIGINT(2)终止,137表示被SIGKILL(9)终止,143表示被SIGTERM(15)终止 |
| 130 | 128+2 | 前台进程被Ctrl+C发送的SIGINT杀掉 |
| 137 | 128+9 | 被kill -9强制杀掉,或系统里内存监控触发OOM |
| 143 | 128+15 | 被kill或服务管理工具通过SIGTERM正常终止 |
128+n这个规律非常实用。脚本里如果看到退出码超过128,第一反应应该是:这个命令被信号杀死了,而不是程序内部逻辑出错。结合130是SIGINT、137是SIGKILL、143是SIGTERM,基本能判断出是谁动的手。
还有一点要注意,程序通过exit()返回负数时,shell会对退出码做取模。比如exit(-1)实际展示出来是255,这不是什么特殊约定,只是shell把-1按无符号方式对256取模的结果。看到255先想想是不是代码里exit了一个负数。
4.3 set -e的坑与PIPESTATUS的排查价值
set -e会让脚本在任意命令返回非零退出码时立刻退出,看起来省事,但它的判断规则其实很微妙:被if、while、until条件判断包裹的命令,以及&&、||左右两侧的命令,都不会触发退出。这意味着你以为是安全的保护,实际上脚本可能在你没预料到的地方继续执行。
举个例子:
set -e false || { echo "handled" } echo "continues"false失败但被||后面的块兜住了,所以不会退出。这个特性本身没问题,但它会让脚本的行为变得更难预测,特别是当你的条件判断很长,里面有赋值又有管道的时候。
再一个经典坑是管道。默认情况下,管道的退出码等于最后一个命令的退出码,前面的命令即使失败,脚本也可能毫无感知。想要严谨一点,可以开启pipefail:
set -o pipefail开启之后,管道里的任何一个命令非零退出,整个管道都会记为失败。排查时则用PIPESTATUS数组查看每个环节的退出码:
cmd1 | cmd2 | cmd3 echo "${PIPESTATUS[@]}"这三个数字分别对应cmd1、cmd2、cmd3的退出码。我写过一个打包脚本,tar后面接gzip,有时候tar打包中把某些文件读坏了,gzip仍然正常退出,导致脚本判断成功。后来加上pipefail、再看PIPESTATUS,才把问题暴露出来。这种坑要是没有PIPESTATUS,排查起来相当费劲。
5. 排错实战:拿到错误码之后的完整排查套路
5.1 strace看系统调用:让错误无处可藏
当代码不打日志,或者日志描述含糊的时候,strace是排查系统调用错误的第一工具。它能展示进程发起的每个系统调用、参数、返回值和errno。实际操作中,我会先这样跑:
strace -f -o /tmp/trace.log ./your_program-f参数跟踪子进程,-o把输出写到文件。然后查日志里所有带了失败返回值的行:
grep '= -1' /tmp/trace.log如果程序在启动阶段出错,看到类似open("/etc/xxx.conf", O_RDONLY) = -1 ENOENT (No such file or directory),问题就一目了然了。
strace还可以过滤特定类型的系统调用,比如-e trace=open,openat,read,write,这样日志不会太杂。需要注意的是,strace会显著拖慢程序执行速度,尤其是频繁IO的程序,别在生产环境跑太久。
还有一个很好的用法:验证程序是否真的走了你预期的分支。有次我排查一个程序报连接失败退出,代码里明明写了失败重试三次,但用strace看,发现connect只调用了一次,根本没有进入重试循环。原因是上层调用的包装逻辑把整个重试机制绕过去了。这说明查错不能只看代码朗读,strace记录的是内核级真实行为,比人脑推演可靠得多。
5.2 日志记录:错误数字和错误字符串一个都别省
很多程序日志里只记录errno=22,或者只记录Invalid argument,后期排查都很别扭。只记录数字,你得翻表;只记录字符串,遇到意思相近的错误容易混淆。我的习惯是写成:
open config failed: No such file or directory (2) read socket failed: Connection reset by peer (104)数字和文本同时出现,既方便人读,也方便在代码里用errno精确匹配。日志里还应该带上正在处理的对象名,比如文件路径、socket描述符对应的地址、子进程的PID,否则错误发生在哪一层都找不到。有人会嫌啰嗦,但排过一次长达两天的故障后,你就会觉得这些信息每条都值得留。
shell脚本里的日志也是同样道理。不要在捕获到失败后只记一句话“command failed”,要把具体命令写在日志里:
cp "$src" "$dst" >/dev/null 2>&1 ret=$? if [ $ret -ne 0 ]; then echo "[$(date)] copy failed: $src -> $dst, exit=$ret" >> error.log fi这里把$?先存进ret变量,是因为进入if分支之后,$?的内容已经可能被前面的[ ]判断命令覆盖了。这是shell脚本里最典型的退出码丢失场景。
5.3 一些实操习惯与整理
写到这里,我想把日常会用到的一些习惯做个整理。首先,在C代码里,判定失败后立刻保存errno,不要等打印日志的时候再取。其次,习惯用宏打印“位置+步骤+错误内容+数字”,别嫌麻烦。第三,shell脚本里永远不要对外部工具的输出做假设,先看退出码,再把“命令+退出码+标准错误”按一条日志记录。第四,每次看到格式奇怪的退出码,比如130、137,第一时间想到信号,而不是程序内在逻辑。
我个人还有一个偏好,是在自己写的函数里主动设置errno。如果发现入参非法,我会显式errno = EINVAL再返回-1。这样外层的错误处理逻辑更统一,日志里也能直接看到合法错误码。这个习惯在多人协作时特别好用,因为同事拿到错误码后不需要猜测到底是谁设置的。
这些都是“磨刀不误砍柴工”的事情。Linux错误返回值体系初看烦琐,踩过几次坑之后,你会慢慢发现它其实非常一致:返回值是接口契约,errno是原因明细,退出码是进程与外界对话的语言。把这三层都处理好,你写的程序才不会在错误信息上让下一个维护者抓狂。
如果你刚开始接触Linux编程,我的建议是别背错误码表,用多了自然就记住了。但有三条要刻在脑子里:先判返回值再看errno,EINTR要重试,errno要就地保存。这三条能挡住绝大多数错误处理的坑。shell脚本那边,记得$?只能用一次,set -e不保护if下的失败,管道要开启pipefail再看PIPESTATUS。我见过太多上线前的小问题,最后查出来都是这类基础细节。希望这篇整理能帮你少走几段弯路。