
本文面向已经接触 C、Linux 系统调用、pthread 或 RTOS,但对错误码如何产生、传递和隔离仍有疑问的开发者。文章从用户态接口、Linux 内核、C 运行库和 RTOS 四个层次,逐步说明
errno的宏展开、线程局部存储、系统调用错误转换,以及不同操作系统的错误返回模型。
errsv = errno;这行代码不会在执行时向 Linux 内核查询最新错误码。
在 glibc 环境中,errno 通常是一个宏:
#define errno (*__errno_location())因此,代码近似展开为:
errsv = *__errno_location();__errno_location() 返回当前线程专属的 errno 存储地址,解引用后得到该线程当前保存的错误码。
这个错误码通常已经由下列某一方写入:
errno 模型的兼容层。所以最准确的理解是:
errsv = errno;是把当前线程中已经保存的错误码复制到局部变量errsv,以防后续函数调用覆盖它。
还要先明确一个边界:glibc 是运行在用户态的 C 运行库,不是 Linux 内核的一部分。Linux 内核、glibc、pthread 和各种 RTOS 可以采用不同的错误传递约定;errno 只是其中一种面向 C/POSIX 接口的错误报告机制,并不是所有内核都会维护一个名为 errno 的变量。
更准确地说,glibc 并不是在线程函数的栈上创建一个普通“局部变量”,而是为每个线程提供独立的 TLS 对象。__errno_location() 根据当前线程定位该对象,因此不同线程可以同时保存不同的错误码。
先看最常见的错误处理:
int fd = open("/not/exist", O_RDONLY);
if (fd == -1) {
int errsv = errno;
fprintf(stderr, "open failed: %s\n", strerror(errsv));
}这里包含两个不同层次的信息:
fd == -1:说明 open() 失败;errno == ENOENT:说明失败原因是目标路径不存在。也就是说,传统 Unix 接口经常把错误报告拆成两部分:
errno |
因此,不能只读取 errno 判断函数是否失败。应先检查函数返回值,再在文档规定 errno 有效时读取它。
Linux errno(3) 手册明确指出:成功调用也允许改变 errno;只有返回值表明调用失败时,errno 的值才通常具有诊断意义。
errno 看起来像变量,实际上可以是宏应用程序包含:
#include<errno.h>在 glibc 的公开头文件中,可以看到类似定义:
externint *__errno_location(void) __THROW __attribute_const__;
#define errno (*__errno_location())因此:
errsv = errno;经过预处理后,逻辑上等价于:
errsv = *__errno_location();进一步拆开:
int *errno_ptr = __errno_location();
errsv = *errno_ptr;而下面的写操作:
errno = EINVAL;逻辑上等价于:
*__errno_location() = EINVAL;这说明 errno 虽然不是普通全局变量,但仍然表现为一个可读、可写的 int 左值。
ISO C 对 errno 的要求就是“类型为 int 的可修改左值”,并没有要求它必须实现成一个普通全局变量。GNU C Library 文档也明确说明,在 GNU/Linux 上,这个左值可以通过类似 *__errno_location() 的宏展开实现。
假设 errno 是一个进程内所有线程共享的普通全局变量:
int errno;两个线程可能出现下面的交错执行:
线程 A:open() 失败,errno = ENOENT
线程 B:write() 失败,errno = EPIPE
线程 A:读取 errno,得到 EPIPE线程 A 原本应该看到 ENOENT,却被线程 B 覆盖。
这会让多线程程序中的错误诊断变得不可靠。解决方法是把 errno 放入线程局部存储,即 Thread-Local Storage,简称 TLS。
概念上可以理解为:
线程 A 的 TLS:errno = ENOENT
线程 B 的 TLS:errno = EPIPE
线程 C 的 TLS:errno = EAGAIN各线程访问的变量名相同,但底层存储位置不同。
Linux errno(3) 手册将 errno 描述为线程局部对象:一个线程修改自己的 errno,不会影响其他线程。
__errno_location() 到底返回什么在 glibc 源码中,__errno_location() 的核心逻辑非常直接:
int *
__errno_location(void)
{
return &errno;
}这段代码容易产生一个疑问:既然返回的是 &errno,为什么不同线程不会得到同一个地址?
关键在于 glibc 内部的 errno 不是普通对象,而是线程局部对象。内部声明可见类似形式:
extern __thread int errno;__thread 是 GNU 工具链使用的线程局部存储声明方式之一。编译器和运行时会为每个线程建立独立实例。
因此:
return &errno;实际含义是:
返回当前执行线程对应的那个
errno实例的地址。
可以把 __errno_location() 理解为 glibc 暴露出来的“当前线程错误码地址查询函数”。
使用访问函数和宏有几个好处:
errno 左值语义;extern int errno;,破坏线程局部语义和 ABI。因此,应用程序只应:
#include<errno.h>不要自行声明:
externint errno; /* 不应这样做。 */线程局部存储不是“每次访问都遍历线程表查找变量”。主流 ABI 会提供高效的线程指针和 TLS 地址计算机制。
以 Linux x86-64 的 glibc 系统调用错误处理代码为例,源码中可以看到通过 %fs 段相关地址写入线程局部 errno 的逻辑。其关键动作可以概括为:
errno 的 TLS 偏移计算地址;在不同架构上,具体寄存器和指令不同,例如某些架构使用专门的 thread pointer 寄存器。应用程序不需要关心这些差异,因为编译器、动态链接器和 glibc 已经共同完成了 TLS 地址解析。
需要区分两层概念:
errno、__thread、__errno_location();errno 的值是谁写进去的errno 并不只由内核产生。实际来源主要有三类。
以 open() 为例,完整路径可简化为:
Linux 内核系统调用 ABI 通常使用负值表达错误,例如:
-ENOENT
-EINVAL
-ENOMEM而用户态 C API 通常采用另一套约定:
返回 -1
errno = 正的错误码在 glibc 的 x86-64 系统调用包装代码中,错误值范围按 -1 到 -4095 识别。包装层发现返回值属于该范围后,会:
errno 值;-1。伪代码可以写成:
long ret = kernel_syscall(...);
if (ret >= -4095 && ret < 0) {
errno = (int)-ret;
return-1;
}
return ret;真实实现通常是架构相关汇编和宏,不一定真的生成上述 C 代码,但语义一致。
不是所有失败都需要进入内核。例如:
库函数可以直接执行:
errno = EINVAL;由于 errno 是宏,这仍然是向当前线程的 TLS 写入。
应用程序也能写:
errno = 0;这通常用于某些返回值本身无法区分成功和失败的 API。例如某些函数可能在成功时也合法返回 -1,调用者需要:
errno = 0;
long value = some_function();
if (value == -1 && errno != 0) {
/* 确认发生错误。 */
}但不要把 errno = 0 当成每次调用前都必须执行的固定模板。是否需要清零,应以目标函数文档规定的错误检测方式为准。
讨论“其他内核怎样传递错误”时,不能只比较它们有没有 errno。需要先把下面三个问题分开:
errno 主要解决的是第三个问题,并配合返回值完成第二个问题。很多 RTOS API 直接把具体错误码作为返回值,因此根本不需要额外保存一个“最近错误”。
Linux 内核内部大量函数采用下面的约定:
int ret = do_something();
if (ret < 0)
return ret;典型语义是:
0 或非负值:成功结果
-EINVAL:参数无效
-ENOMEM:内存不足
-EBUSY:资源忙这里的错误码直接沿调用链返回,不需要内核维护一个全局或线程局部 errno。这样做的优点是错误归属明确,调用者拿到的就是本次调用的结果。
当函数正常返回值是指针时,Linux 内核还经常使用错误指针:
structobject *obj = create_object();
if (IS_ERR(obj))
return PTR_ERR(obj);其中:
ERR_PTR(-ENOMEM):把负错误码编码到指针值;IS_ERR(ptr):判断是否为错误指针;PTR_ERR(ptr):取回负错误码。这仍然属于“错误码通过返回值传播”,并不是读取某个隐藏的 errno。
系统调用跨越内核态和用户态边界时,Linux 也通常在返回寄存器中放置负错误码。以 x86-64 glibc 包装层为例,它识别 -1 到 -4095 范围内的内核错误返回,再将错误号取反写入当前线程的 errno,最后向应用程序返回 -1。
因此,errno 是用户态接口适配后的结果,不是 Linux 内核内部传递错误的主要方式。
glibc 的公开头文件采用:
#define errno (*__errno_location())内部则把对应对象声明为线程局部对象:
extern __thread int errno;__errno_location() 返回当前线程对应实例的地址:
int *__errno_location(void)
{
return &errno;
}这里的“线程局部”需要准确理解:
所以可以把 glibc 的实现概括为:
每个线程拥有独立的
errno存储实例,glibc 通过 TLS 定位机制让同一个宏名访问当前线程的实例。
rt_err_t,同时提供线程级 errno 兼容层RT-Thread 的大量内核 API 使用 rt_err_t 直接返回状态。常见约定是:
RT_EOK:成功
-RT_ETIMEOUT:超时
-RT_EINVAL:参数无效
-RT_ENOMEM:内存不足也就是说,RT-Thread 原生接口通常可以直接从函数返回值获得具体失败原因。
同时,RT-Thread 还提供类似 POSIX 的 errno 兼容机制:
#define errno (*_rt_errno())当前实现中,_rt_errno() 在普通线程上下文返回当前线程控制块中的 thread->error 地址:
return (int *)&thread->error;这与 glibc 的目标相同,都是让不同线程拥有独立错误值,但具体存储位置不同:
error 字段。RT-Thread 还必须处理“当前没有正常线程”的场景。在中断上下文或尚未取得当前线程时,它会退回到一个全局 __rt_errno。这也说明一个重要限制:线程局部错误变量只适用于具有明确当前线程的上下文。在 ISR 中更推荐通过函数返回值、事件或显式状态对象传递错误,而不要依赖“当前线程最后错误”。
Zephyr 的许多原生内核 API 直接返回 0 或负的 errno 风格错误码。例如互斥锁获取可能返回:
0:成功
-EBUSY:非阻塞获取时资源忙
-EAGAIN:等待超时这与 Linux 内核内部的风格接近,错误原因直接包含在返回值中。
当启用 errno 支持时,Zephyr 又可以根据配置选择不同的保存方式:
errno;Z_THREAD_LOCAL 声明 TLS 变量;errno_var;errno_var。因此,Zephyr 同时支持“直接返回负错误码”和“为 POSIX/C 库接口提供线程级 errno”,两者服务于不同 API 层。
FreeRTOS 内核本身没有把统一的 POSIX errno 作为所有 API 的主要错误通道。核心 API 常见的返回形式包括:
pdPASS / pdFAIL
pdTRUE / pdFALSE
errQUEUE_EMPTY / errQUEUE_FULL
句柄或 NULL这些返回值的具体语义由各 API 定义。例如队列操作返回失败状态,任务创建可能返回内存分配失败状态。FreeRTOS 头文件中虽然也定义了 pdFREERTOS_ERRNO_*,但源码注释明确说明这些值供 FreeRTOS+ 组件使用,而不是 FreeRTOS 内核自身的统一错误机制。
如果工程使用 Newlib,并启用 configUSE_NEWLIB_REENTRANT,FreeRTOS 会把 struct _reent 作为任务的 C 运行时 TLS 块,并在任务切换时更新 Newlib 的 _impure_ptr:
#define configTLS_BLOCK_TYPE struct _reent
#define configSET_TLS_BLOCK(xTLSBlock) (_impure_ptr = &(xTLSBlock))Newlib 的 errno 位于该重入结构中,因此每个任务可以拥有独立的 C 库错误状态。
需要注意:这不是 FreeRTOS 核心把所有内核错误都写入 errno,而是 FreeRTOS 为 Newlib 提供每任务重入环境,使 strtok()、rand()、errno 等 C 库状态能够按任务隔离。
osStatus_t 枚举直接返回CMSIS-RTOS2 采用显式状态枚举:
typedefenum {
osOK = 0,
osError = -1,
osErrorTimeout = -2,
osErrorResource = -3,
osErrorParameter = -4,
osErrorNoMemory = -5,
osErrorISR = -6,
osErrorSafetyClass = -7
} osStatus_t;调用者直接检查函数返回的 osStatus_t。对象创建类 API 则通常返回对象 ID,失败时返回 NULL。这种接口不依赖隐藏的“最近一次错误”变量,适合 RTOS 中强调显式状态和可预测控制流的场景。
0/-Exxx | errno 形式提供 | ||
errno | |||
errno | |||
rt_err_tRT_EOK/-RT_Exxx | error,特殊上下文退回全局值 | ||
0/-Exxx | |||
pdPASS/pdFAIL | |||
osStatus_tNULL |
与隐藏的“最近错误”状态相比,直接返回错误码通常更适合嵌入式和实时系统:
return ret; 原样传播错误;但 POSIX 文件、网络和 C 库接口已经广泛采用 errno,所以支持 POSIX 的 RTOS 往往同时存在两层接口:
RTOS 原生层:直接返回状态码
POSIX/C 库层:失败返回值 + 每线程 errno这两种模型没有绝对优劣,关键是不能混用判断方式。必须根据具体 API 文档确定:错误是在返回值中、在 errno 中,还是通过输出参数返回。
errsv = errno下面的代码存在隐患:
if (open(path, O_RDONLY) == -1) {
log_something();
fprintf(stderr, "open failed: %s\n", strerror(errno));
}log_something() 内部可能调用其他系统调用或库函数,从而修改 errno。此时打印出的错误不一定仍然属于 open()。
更稳妥的写法是:
if (open(path, O_RDONLY) == -1) {
int errsv = errno;
log_something();
fprintf(stderr, "open failed: %s\n", strerror(errsv));
}Linux errno(3) 手册专门给出了这种保存方式。
信号处理函数也可能影响 errno。常见防护形式是:
staticvoidsignal_handler(int signo)
{
int saved_errno = errno;
/* 仅执行异步信号安全操作。 */
errno = saved_errno;
}这保证信号处理函数不会破坏被中断代码原本准备读取的错误码。
很多 pthread API 不采用“返回 -1 并设置 errno”的模型,而是:
0;例如:
int errsv = pthread_mutexattr_init(&attr);
if (errsv != 0) {
fprintf(stderr, "pthread_mutexattr_init failed: %s\n",
strerror(errsv));
}Linux pthread_mutexattr_init(3) 手册明确说明:成功返回 0,错误时返回正的错误号。Linux errno(3) 手册也指出,POSIX 线程 API 通常不在失败时设置 errno,而是把错误号作为函数返回值。
因此下面的写法不可靠:
if (pthread_mutexattr_init(&attr) != 0) {
int errsv = errno; /* 可能读到旧值。 */
}正确做法是直接保存返回值:
int errsv = pthread_mutexattr_init(&attr);
if (errsv != 0) {
/* errsv 本身就是错误码。 */
}open()read() 等传统接口 | -1 | errno | |
0 |
不要根据函数名称猜测错误模型,必须查阅该函数文档。
mtx_init() 为什么最后执行 errno = errsv考虑下面这种包装逻辑:
int
mtx_init(mtx_t *mtx, int type)
{
int errsv;
pthread_mutexattr_t attr;
errsv = pthread_mutexattr_init(&attr);
if (errsv)
goto error_mutexattr_init;
/* 设置属性并初始化 mutex。 */
return thrd_success;
error_mutex_init:
error_mutexattr_settype:
pthread_mutexattr_destroy(&attr);
error_mutexattr_init:
errno = errsv;
return thrd_error;
}这里发生的是错误模型转换。
底层 pthread 接口返回:
0
EINVAL
ENOMEM
EAGAIN
...而包装后的 C11 风格线程接口只想向上层返回:
thrd_success
thrd_error如果只返回 thrd_error,具体失败原因就会丢失。因此包装层执行:
errno = errsv;
return thrd_error;把 pthread 返回的具体错误码转存到当前线程的 errno 中。
完整转换关系是:
所以:
errsv = errno;表示从当前线程错误区读取错误码;
而:
errno = errsv;表示把错误码写入当前线程错误区。
__THROW 和 __attribute_const__ 是什么原声明中还有两个容易引起疑问的标记:
externint *__errno_location(void) __THROW __attribute_const__;__THROW在 glibc 的 sys/cdefs.h 中,__THROW 会根据语言模式和编译器能力展开。
在 C + GCC/Clang 环境下,它通常表示函数不会抛出异常,并可能带有 leaf 属性;在 C++11 及以后环境下,可能展开为:
noexcept(true)它主要服务于 ABI 声明和编译器优化,不参与 errno 的存储逻辑。
__attribute_const__在支持该属性的 GCC/Clang 环境下,它通常展开为:
__attribute__((__const__))这是给编译器的优化提示。对调用者而言,最重要的是不要误解为“返回的 int 不可修改”。
__errno_location() 返回的是 int *,指向的错误码仍然可以通过 errno = value 修改。
这个属性修饰的是函数行为模型,不是把目标对象声明成 C 语言的 const int。
下面的程序打印两个线程中的 __errno_location() 地址和值:
#define _GNU_SOURCE
#include<errno.h>
#include<pthread.h>
#include<stdio.h>
#include<string.h>
staticvoid *worker(void *arg)
{
int value = *(int *)arg;
errno = value;
printf("thread=%lu errno_addr=%p errno=%d (%s)\n",
(unsignedlong)pthread_self(),
(void *)__errno_location(),
errno,
strerror(errno));
returnNULL;
}
intmain(void)
{
pthread_t thread1;
pthread_t thread2;
int err1 = ENOENT;
int err2 = EPIPE;
int ret;
ret = pthread_create(&thread1, NULL, worker, &err1);
if (ret != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(ret));
return1;
}
ret = pthread_create(&thread2, NULL, worker, &err2);
if (ret != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(ret));
return1;
}
pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return0;
}编译:
cc -Wall -Wextra -O2 -pthread errno_tls_demo.c -o errno_tls_demo典型输出会呈现以下特征:
thread=... errno_addr=0x... errno=2 (No such file or directory)
thread=... errno_addr=0x... errno=32 (Broken pipe)两个线程的 errno_addr 通常不同,且各自保存不同错误值。
注意:__errno_location() 是 glibc 接口,不是跨所有 C 库都保证存在的 ISO C API。可移植业务代码应直接使用 errno;该函数更适合教学、调试或分析 glibc 实现。
错误:
some_function();
if (errno != 0) {
/* 不能据此确认本次调用失败。 */
}正确:
if (some_function() == -1) {
int errsv = errno;
/* 处理 errsv。 */
}错误:
if (open(path, O_RDONLY) == -1) {
printf("open failed\n");
handle_error(errno);
}正确:
if (open(path, O_RDONLY) == -1) {
int errsv = errno;
printf("open failed\n");
handle_error(errsv);
}错误:
if (pthread_mutexattr_init(&attr) != 0) {
fprintf(stderr, "%s\n", strerror(errno));
}正确:
int ret = pthread_mutexattr_init(&attr);
if (ret != 0) {
fprintf(stderr, "%s\n", strerror(ret));
}不推荐:
if (errno == 2) {
/* ... */
}推荐:
if (errno == ENOENT) {
/* ... */
}错误码数值可能因系统或架构而异,应使用符号常量。
不要写:
externint errno;应写:
#include<errno.h>可以把 Linux、glibc 与 RTOS 的错误处理记成下面八句话:
errno 是 C/POSIX 接口的一种错误报告机制,不是所有内核统一维护的变量;errno 是一个可修改的 int 左值,但在 glibc 中通常由宏实现;__errno_location() 访问当前线程的 TLS 错误码对象;-Exxx,指针接口还可能使用 ERR_PTR();-1 + errno;errno;errno;errno 取决于所集成的 C 运行库。用一段伪代码总结:
/* 传统系统调用包装模型。 */
kernel_ret = syscall(...);
if (is_kernel_error(kernel_ret)) {
errno = -kernel_ret;
return-1;
}
/* pthread 模型。 */
errsv = pthread_xxx(...);
if (errsv != 0) {
return errsv;
}
/* Lely 等兼容包装层可能执行的转换。 */
errsv = pthread_xxx(...);
if (errsv != 0) {
errno = errsv;
return thrd_error;
}所以,对下面这行代码可以得到一个非常精确的回答:
errsv = errno;不是“现在去内核取错误码”,而是:
调用
__errno_location()定位当前线程的errno存储单元,并把其中已经保存的错误码复制到局部变量errsv。
以下资料用于核对本文中的接口语义和实现细节,核心结论优先依据官方手册和 glibc 源码。