Linux/glibc 中 errno 的原理、线程隔离与实现路径
@[toc]
本文面向已经接触 C、Linux 系统调用、pthread 或 RTOS,但对错误码如何产生、传递和隔离仍有疑问的开发者。文章从用户态接口、Linux 内核、C 运行库和 RTOS 四个层次,逐步说明
errno的宏展开、线程局部存储、系统调用错误转换,以及不同操作系统的错误返回模型。
结论先行
1 | errsv = errno; |
这行代码不会在执行时向 Linux 内核查询最新错误码。
在 glibc 环境中,errno 通常是一个宏:
1 |
因此,代码近似展开为:
1 | errsv = *__errno_location(); |
__errno_location() 返回当前线程专属的 errno 存储地址,解引用后得到该线程当前保存的错误码。
这个错误码通常已经由下列某一方写入:
- glibc 的系统调用包装层;
- 某个纯用户态库函数;
- 应用程序自身;
- 某个将其他错误模型转换为
errno模型的兼容层。
所以最准确的理解是:
errsv = errno;是把当前线程中已经保存的错误码复制到局部变量errsv,以防后续函数调用覆盖它。
还要先明确一个边界:glibc 是运行在用户态的 C 运行库,不是 Linux 内核的一部分。Linux 内核、glibc、pthread 和各种 RTOS 可以采用不同的错误传递约定;errno 只是其中一种面向 C/POSIX 接口的错误报告机制,并不是所有内核都会维护一个名为 errno 的变量。
更准确地说,glibc 并不是在线程函数的栈上创建一个普通“局部变量”,而是为每个线程提供独立的 TLS 对象。__errno_location() 根据当前线程定位该对象,因此不同线程可以同时保存不同的错误码。
1. 为什么一个错误码需要专门保存
先看最常见的错误处理:
1 | int fd = open("/not/exist", O_RDONLY); |
这里包含两个不同层次的信息:
fd == -1:说明open()失败;errno == ENOENT:说明失败原因是目标路径不存在。
也就是说,传统 Unix 接口经常把错误报告拆成两部分:
| 信息 | 传递位置 |
|---|---|
| 是否失败 | 函数返回值 |
| 失败原因 | errno |
因此,不能只读取 errno 判断函数是否失败。应先检查函数返回值,再在文档规定 errno 有效时读取它。
Linux errno(3) 手册明确指出:成功调用也允许改变 errno;只有返回值表明调用失败时,errno 的值才通常具有诊断意义。
2. errno 看起来像变量,实际上可以是宏
应用程序包含:
1 |
在 glibc 的公开头文件中,可以看到类似定义:
1 | extern int *__errno_location(void) __THROW __attribute_const__; |
因此:
1 | errsv = errno; |
经过预处理后,逻辑上等价于:
1 | errsv = *__errno_location(); |
进一步拆开:
1 | int *errno_ptr = __errno_location(); |
而下面的写操作:
1 | errno = EINVAL; |
逻辑上等价于:
1 | *__errno_location() = EINVAL; |
这说明 errno 虽然不是普通全局变量,但仍然表现为一个可读、可写的 int 左值。
ISO C 对 errno 的要求就是“类型为 int 的可修改左值”,并没有要求它必须实现成一个普通全局变量。GNU C Library 文档也明确说明,在 GNU/Linux 上,这个左值可以通过类似 *__errno_location() 的宏展开实现。
3. 为什么不能使用一个普通全局变量
假设 errno 是一个进程内所有线程共享的普通全局变量:
1 | int errno; |
两个线程可能出现下面的交错执行:
1 | 线程 A:open() 失败,errno = ENOENT |
线程 A 原本应该看到 ENOENT,却被线程 B 覆盖。
这会让多线程程序中的错误诊断变得不可靠。解决方法是把 errno 放入线程局部存储,即 Thread-Local Storage,简称 TLS。
概念上可以理解为:
1 | 线程 A 的 TLS:errno = ENOENT |
各线程访问的变量名相同,但底层存储位置不同。
1 | flowchart LR |
Linux errno(3) 手册将 errno 描述为线程局部对象:一个线程修改自己的 errno,不会影响其他线程。
4. __errno_location() 到底返回什么
在 glibc 源码中,__errno_location() 的核心逻辑非常直接:
1 | int * |
这段代码容易产生一个疑问:既然返回的是 &errno,为什么不同线程不会得到同一个地址?
关键在于 glibc 内部的 errno 不是普通对象,而是线程局部对象。内部声明可见类似形式:
1 | extern __thread int errno; |
__thread 是 GNU 工具链使用的线程局部存储声明方式之一。编译器和运行时会为每个线程建立独立实例。
因此:
1 | return &errno; |
实际含义是:
返回当前执行线程对应的那个
errno实例的地址。
可以把 __errno_location() 理解为 glibc 暴露出来的“当前线程错误码地址查询函数”。
4.1 为什么不直接把 TLS 变量暴露给应用程序
使用访问函数和宏有几个好处:
- 对外维持 ISO C 所要求的
errno左值语义; - 隐藏不同平台的 TLS 实现差异;
- 允许 glibc 在不同架构、动态链接器阶段和运行环境下使用不同的内部实现;
- 避免应用程序错误地写出
extern int errno;,破坏线程局部语义和 ABI。
因此,应用程序只应:
1 |
不要自行声明:
1 | extern int errno; /* 不应这样做。 */ |
5. TLS 在机器层面如何工作
线程局部存储不是“每次访问都遍历线程表查找变量”。主流 ABI 会提供高效的线程指针和 TLS 地址计算机制。
以 Linux x86-64 的 glibc 系统调用错误处理代码为例,源码中可以看到通过 %fs 段相关地址写入线程局部 errno 的逻辑。其关键动作可以概括为:
- 找到当前线程的 TLS 基址;
- 根据
errno的 TLS 偏移计算地址; - 将错误码写入该地址。
在不同架构上,具体寄存器和指令不同,例如某些架构使用专门的 thread pointer 寄存器。应用程序不需要关心这些差异,因为编译器、动态链接器和 glibc 已经共同完成了 TLS 地址解析。
需要区分两层概念:
- C 源码层:
errno、__thread、__errno_location(); - ABI/机器层:线程指针、TLS 重定位、段寄存器或专用寄存器。
6. errno 的值是谁写进去的
errno 并不只由内核产生。实际来源主要有三类。
6.1 系统调用失败:glibc 包装层写入
以 open() 为例,完整路径可简化为:
1 | sequenceDiagram |
Linux 内核系统调用 ABI 通常使用负值表达错误,例如:
1 | -ENOENT |
而用户态 C API 通常采用另一套约定:
1 | 返回 -1 |
在 glibc 的 x86-64 系统调用包装代码中,错误值范围按 -1 到 -4095 识别。包装层发现返回值属于该范围后,会:
- 对负错误码取反,得到正的
errno值; - 写入当前线程的 TLS;
- 将函数返回值改为
-1。
伪代码可以写成:
1 | long ret = kernel_syscall(...); |
真实实现通常是架构相关汇编和宏,不一定真的生成上述 C 代码,但语义一致。
6.2 纯用户态库函数自行设置
不是所有失败都需要进入内核。例如:
- 参数检查失败;
- 字符串转换溢出;
- 数学函数定义域或值域错误;
- glibc 内部状态检查失败。
库函数可以直接执行:
1 | errno = EINVAL; |
由于 errno 是宏,这仍然是向当前线程的 TLS 写入。
6.3 应用程序自行设置
应用程序也能写:
1 | errno = 0; |
这通常用于某些返回值本身无法区分成功和失败的 API。例如某些函数可能在成功时也合法返回 -1,调用者需要:
1 | errno = 0; |
但不要把 errno = 0 当成每次调用前都必须执行的固定模板。是否需要清零,应以目标函数文档规定的错误检测方式为准。
7. Linux 内核、glibc 与常见 RTOS 如何传递错误
讨论“其他内核怎样传递错误”时,不能只比较它们有没有 errno。需要先把下面三个问题分开:
| 层次 | 要回答的问题 | 常见实现 |
|---|---|---|
| 错误产生 | 谁发现了错误 | 内核、驱动、C 库、协议栈或应用模块 |
| 错误传递 | 调用者怎样获得失败原因 | 直接返回错误码、特殊返回值、输出参数、错误指针或异常 |
| 错误保存 | 是否需要保存“最近一次错误” | TLS、线程控制块字段、任务重入结构、全局变量或完全不保存 |
errno 主要解决的是第三个问题,并配合返回值完成第二个问题。很多 RTOS API 直接把具体错误码作为返回值,因此根本不需要额外保存一个“最近错误”。
7.1 Linux 内核:优先通过返回值传递负错误码
Linux 内核内部大量函数采用下面的约定:
1 | int ret = do_something(); |
典型语义是:
1 | 0 或非负值:成功结果 |
这里的错误码直接沿调用链返回,不需要内核维护一个全局或线程局部 errno。这样做的优点是错误归属明确,调用者拿到的就是本次调用的结果。
当函数正常返回值是指针时,Linux 内核还经常使用错误指针:
1 | struct object *obj = create_object(); |
其中:
ERR_PTR(-ENOMEM):把负错误码编码到指针值;IS_ERR(ptr):判断是否为错误指针;PTR_ERR(ptr):取回负错误码。
这仍然属于“错误码通过返回值传播”,并不是读取某个隐藏的 errno。
系统调用跨越内核态和用户态边界时,Linux 也通常在返回寄存器中放置负错误码。以 x86-64 glibc 包装层为例,它识别 -1 到 -4095 范围内的内核错误返回,再将错误号取反写入当前线程的 errno,最后向应用程序返回 -1。
1 | flowchart LR |
因此,errno 是用户态接口适配后的结果,不是 Linux 内核内部传递错误的主要方式。
7.2 glibc:每线程一个 TLS 错误码对象
glibc 的公开头文件采用:
1 |
内部则把对应对象声明为线程局部对象:
1 | extern __thread int errno; |
__errno_location() 返回当前线程对应实例的地址:
1 | int *__errno_location(void) |
这里的“线程局部”需要准确理解:
- 不是线程函数栈上的自动局部变量;
- 不是所有线程共享的普通全局变量;
- 逻辑上属于当前线程的 TLS 区域;
- 同一线程多次访问通常定位到同一个实例;
- 不同线程访问时,通常得到不同地址和不同值;
- 线程被调度到另一个 CPU 后,仍然访问该线程自己的 TLS,而不是某个 CPU 固定的变量。
所以可以把 glibc 的实现概括为:
每个线程拥有独立的
errno存储实例,glibc 通过 TLS 定位机制让同一个宏名访问当前线程的实例。
7.3 RT-Thread:返回 rt_err_t,同时提供线程级 errno 兼容层
RT-Thread 的大量内核 API 使用 rt_err_t 直接返回状态。常见约定是:
1 | RT_EOK:成功 |
也就是说,RT-Thread 原生接口通常可以直接从函数返回值获得具体失败原因。
同时,RT-Thread 还提供类似 POSIX 的 errno 兼容机制:
1 |
当前实现中,_rt_errno() 在普通线程上下文返回当前线程控制块中的 thread->error 地址:
1 | return (int *)&thread->error; |
这与 glibc 的目标相同,都是让不同线程拥有独立错误值,但具体存储位置不同:
- glibc:使用通用 TLS ABI 和线程局部对象;
- RT-Thread:可以直接使用线程控制块中的
error字段。
RT-Thread 还必须处理“当前没有正常线程”的场景。在中断上下文或尚未取得当前线程时,它会退回到一个全局 __rt_errno。这也说明一个重要限制:线程局部错误变量只适用于具有明确当前线程的上下文。在 ISR 中更推荐通过函数返回值、事件或显式状态对象传递错误,而不要依赖“当前线程最后错误”。
7.4 Zephyr:原生 API 返回负错误码,errno 存储方式可配置
Zephyr 的许多原生内核 API 直接返回 0 或负的 errno 风格错误码。例如互斥锁获取可能返回:
1 | 0:成功 |
这与 Linux 内核内部的风格接近,错误原因直接包含在返回值中。
当启用 errno 支持时,Zephyr 又可以根据配置选择不同的保存方式:
- 由所选 C 库提供
errno; - 使用
Z_THREAD_LOCAL声明 TLS 变量; - 使用当前线程对象中的
errno_var; - 用户态线程使用其 userspace local data 中的
errno_var。
因此,Zephyr 同时支持“直接返回负错误码”和“为 POSIX/C 库接口提供线程级 errno”,两者服务于不同 API 层。
7.5 FreeRTOS:核心 API 主要返回状态值,errno 属于组件或 C 库适配
FreeRTOS 内核本身没有把统一的 POSIX errno 作为所有 API 的主要错误通道。核心 API 常见的返回形式包括:
1 | pdPASS / pdFAIL |
这些返回值的具体语义由各 API 定义。例如队列操作返回失败状态,任务创建可能返回内存分配失败状态。FreeRTOS 头文件中虽然也定义了 pdFREERTOS_ERRNO_*,但源码注释明确说明这些值供 FreeRTOS+ 组件使用,而不是 FreeRTOS 内核自身的统一错误机制。
如果工程使用 Newlib,并启用 configUSE_NEWLIB_REENTRANT,FreeRTOS 会把 struct _reent 作为任务的 C 运行时 TLS 块,并在任务切换时更新 Newlib 的 _impure_ptr:
1 |
Newlib 的 errno 位于该重入结构中,因此每个任务可以拥有独立的 C 库错误状态。
需要注意:这不是 FreeRTOS 核心把所有内核错误都写入 errno,而是 FreeRTOS 为 Newlib 提供每任务重入环境,使 strtok()、rand()、errno 等 C 库状态能够按任务隔离。
7.6 CMSIS-RTOS2:通过 osStatus_t 枚举直接返回
CMSIS-RTOS2 采用显式状态枚举:
1 | typedef enum { |
调用者直接检查函数返回的 osStatus_t。对象创建类 API 则通常返回对象 ID,失败时返回 NULL。这种接口不依赖隐藏的“最近一次错误”变量,适合 RTOS 中强调显式状态和可预测控制流的场景。
7.7 常见系统的错误传递方式对比
| 系统或接口层 | 主要错误传递方式 | 是否提供线程级 errno | 典型存储位置 |
|---|---|---|---|
| Linux 内核内部 | 0/-Exxx、错误指针 |
不以用户态 errno 形式提供 |
错误直接在返回值中传播 |
| Linux 系统调用 ABI | 返回寄存器中的负错误码 | 内核本身不写用户态 TLS | 返回寄存器 |
| glibc/POSIX 接口 | 特殊失败返回值加 errno |
是 | 当前线程 TLS |
| pthread API | 直接返回正错误码 | 通常不设置 errno |
函数返回值 |
| RT-Thread 原生 API | rt_err_t,通常为 RT_EOK/-RT_Exxx |
另有兼容层 | 线程控制块 error,特殊上下文退回全局值 |
| Zephyr 原生 API | 0/-Exxx |
可选 | C 库、TLS 或线程对象字段 |
| FreeRTOS 核心 API | pdPASS/pdFAIL、布尔值、句柄或 API 专用状态 |
核心不统一依赖 | 返回值;Newlib 模式下 C 库状态位于任务重入结构 |
| CMSIS-RTOS2 | osStatus_t、对象 ID 或 NULL |
不作为主要 API 模型 | 函数返回值 |
7.8 为什么很多 RTOS 更倾向直接返回错误码
与隐藏的“最近错误”状态相比,直接返回错误码通常更适合嵌入式和实时系统:
- 错误与本次调用一一对应,不容易被后续调用覆盖;
- 不依赖 TLS,适合资源受限系统;
- 在中断上下文中更容易定义行为;
- 静态分析和单元测试更直接;
- 调用链可以使用
return ret;原样传播错误; - 不需要为每个线程额外维护 C 库兼容状态。
但 POSIX 文件、网络和 C 库接口已经广泛采用 errno,所以支持 POSIX 的 RTOS 往往同时存在两层接口:
1 | RTOS 原生层:直接返回状态码 |
这两种模型没有绝对优劣,关键是不能混用判断方式。必须根据具体 API 文档确定:错误是在返回值中、在 errno 中,还是通过输出参数返回。
8. 为什么必须尽快执行 errsv = errno
下面的代码存在隐患:
1 | if (open(path, O_RDONLY) == -1) { |
log_something() 内部可能调用其他系统调用或库函数,从而修改 errno。此时打印出的错误不一定仍然属于 open()。
更稳妥的写法是:
1 | if (open(path, O_RDONLY) == -1) { |
Linux errno(3) 手册专门给出了这种保存方式。
8.1 信号处理函数中的额外注意事项
信号处理函数也可能影响 errno。常见防护形式是:
1 | static void signal_handler(int signo) |
这保证信号处理函数不会破坏被中断代码原本准备读取的错误码。
9. pthread 接口为什么是例外
很多 pthread API 不采用“返回 -1 并设置 errno”的模型,而是:
- 成功返回
0; - 失败直接返回正的错误码。
例如:
1 | int errsv = pthread_mutexattr_init(&attr); |
Linux pthread_mutexattr_init(3) 手册明确说明:成功返回 0,错误时返回正的错误号。Linux errno(3) 手册也指出,POSIX 线程 API 通常不在失败时设置 errno,而是把错误号作为函数返回值。
因此下面的写法不可靠:
1 | if (pthread_mutexattr_init(&attr) != 0) { |
正确做法是直接保存返回值:
1 | int errsv = pthread_mutexattr_init(&attr); |
9.1 两种错误模型对比
| API 类型 | 成功 | 失败返回值 | 失败原因位置 |
|---|---|---|---|
open()、read() 等传统接口 |
非负值或指定成功值 | 通常为 -1 |
errno |
| 多数 pthread 接口 | 0 |
正错误码 | 返回值本身 |
不要根据函数名称猜测错误模型,必须查阅该函数文档。
10. Lely mtx_init() 为什么最后执行 errno = errsv
考虑下面这种包装逻辑:
1 | int |
这里发生的是错误模型转换。
底层 pthread 接口返回:
1 | 0 |
而包装后的 C11 风格线程接口只想向上层返回:
1 | thrd_success |
如果只返回 thrd_error,具体失败原因就会丢失。因此包装层执行:
1 | errno = errsv; |
把 pthread 返回的具体错误码转存到当前线程的 errno 中。
完整转换关系是:
1 | flowchart TD |
所以:
1 | errsv = errno; |
表示从当前线程错误区读取错误码;
而:
1 | errno = errsv; |
表示把错误码写入当前线程错误区。
11. __THROW 和 __attribute_const__ 是什么
原声明中还有两个容易引起疑问的标记:
1 | extern int *__errno_location(void) __THROW __attribute_const__; |
11.1 __THROW
在 glibc 的 sys/cdefs.h 中,__THROW 会根据语言模式和编译器能力展开。
在 C + GCC/Clang 环境下,它通常表示函数不会抛出异常,并可能带有 leaf 属性;在 C++11 及以后环境下,可能展开为:
1 | noexcept(true) |
它主要服务于 ABI 声明和编译器优化,不参与 errno 的存储逻辑。
11.2 __attribute_const__
在支持该属性的 GCC/Clang 环境下,它通常展开为:
1 | __attribute__((__const__)) |
这是给编译器的优化提示。对调用者而言,最重要的是不要误解为“返回的 int 不可修改”。
__errno_location() 返回的是 int *,指向的错误码仍然可以通过 errno = value 修改。
这个属性修饰的是函数行为模型,不是把目标对象声明成 C 语言的 const int。
12. 一个实验:验证不同线程拥有不同 errno 地址
下面的程序打印两个线程中的 __errno_location() 地址和值:
1 |
|
编译:
1 | cc -Wall -Wextra -O2 -pthread errno_tls_demo.c -o errno_tls_demo |
典型输出会呈现以下特征:
1 | thread=... errno_addr=0x... errno=2 (No such file or directory) |
两个线程的 errno_addr 通常不同,且各自保存不同错误值。
注意:__errno_location() 是 glibc 接口,不是跨所有 C 库都保证存在的 ISO C API。可移植业务代码应直接使用 errno;该函数更适合教学、调试或分析 glibc 实现。
13. 常见错误用法
13.1 不检查返回值,直接判断 errno
错误:
1 | some_function(); |
正确:
1 | if (some_function() == -1) { |
13.2 在读取 errno 前调用其他函数
错误:
1 | if (open(path, O_RDONLY) == -1) { |
正确:
1 | if (open(path, O_RDONLY) == -1) { |
13.3 对 pthread 返回值读取 errno
错误:
1 | if (pthread_mutexattr_init(&attr) != 0) { |
正确:
1 | int ret = pthread_mutexattr_init(&attr); |
13.4 依赖错误码的具体数字
不推荐:
1 | if (errno == 2) { |
推荐:
1 | if (errno == ENOENT) { |
错误码数值可能因系统或架构而异,应使用符号常量。
13.5 手工声明 errno
不要写:
1 | extern int errno; |
应写:
1 |
14. 最终心智模型
可以把 Linux、glibc 与 RTOS 的错误处理记成下面八句话:
errno是 C/POSIX 接口的一种错误报告机制,不是所有内核统一维护的变量;errno是一个可修改的int左值,但在 glibc 中通常由宏实现;- glibc 通过
__errno_location()访问当前线程的 TLS 错误码对象; - Linux 内核内部通常直接返回
-Exxx,指针接口还可能使用ERR_PTR(); - glibc 系统调用包装层把内核负错误码转换成用户态的
-1 + errno; - pthread API 通常直接返回正错误码,不依赖
errno; - RT-Thread、Zephyr 等 RTOS 原生 API 通常直接返回状态码,同时可以为 POSIX/C 库接口提供线程级
errno; - FreeRTOS、CMSIS-RTOS2 等系统更强调 API 专用状态或状态枚举,是否存在任务级
errno取决于所集成的 C 运行库。
用一段伪代码总结:
1 | /* 传统系统调用包装模型。 */ |
所以,对下面这行代码可以得到一个非常精确的回答:
1 | errsv = errno; |
不是“现在去内核取错误码”,而是:
调用
__errno_location()定位当前线程的errno存储单元,并把其中已经保存的错误码复制到局部变量errsv。
参考资料
以下资料用于核对本文中的接口语义和实现细节,核心结论优先依据官方手册和 glibc 源码。
- GNU C Library Manual, Checking for Errors
https://www.gnu.org/software/libc/manual/html_node/Checking-for-Errors.html - Linux man-pages, errno(3)
https://man7.org/linux/man-pages/man3/errno.3.html - Linux man-pages, syscall(2)
https://man7.org/linux/man-pages/man2/syscall.2.html - glibc public header, stdlib/errno.h
https://codebrowser.dev/glibc/glibc/stdlib/errno.h.html - glibc internal header, include/errno.h
https://codebrowser.dev/glibc/glibc/include/errno.h.html - glibc source, csu/errno-loc.c
https://codebrowser.dev/glibc/glibc/csu/errno-loc.c.html - glibc x86-64 syscall wrapper, sysdeps/unix/sysv/linux/x86_64/sysdep.h
https://codebrowser.dev/glibc/glibc/sysdeps/unix/sysv/linux/x86_64/sysdep.h.html - glibc declarations, misc/sys/cdefs.h
https://codebrowser.dev/glibc/glibc/misc/sys/cdefs.h.html - Linux man-pages, pthread_mutexattr_init(3)
https://man7.org/linux/man-pages/man3/pthread_mutexattr_init.3.html - Linux man-pages, pthread_mutex_init(3)
https://man7.org/linux/man-pages/man3/pthread_mutex_init.3.html - Linux Kernel Documentation, Error Pointers
https://docs.kernel.org/core-api/kernel-api.html#error-pointers - RT-Thread source, include/klibc/kerrno.h
https://github.com/RT-Thread/rt-thread/blob/master/include/klibc/kerrno.h - RT-Thread source, src/klibc/kerrno.c
https://github.com/RT-Thread/rt-thread/blob/master/src/klibc/kerrno.c - Zephyr source, lib/libc/common/source/errno/errno.c
https://github.com/zephyrproject-rtos/zephyr/blob/main/lib/libc/common/source/errno/errno.c - Zephyr source, kernel/mutex.c
https://github.com/zephyrproject-rtos/zephyr/blob/main/kernel/mutex.c - FreeRTOS Kernel source, include/projdefs.h
https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/include/projdefs.h - FreeRTOS Kernel source, include/newlib-freertos.h
https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/include/newlib-freertos.h - Arm CMSIS-RTOS2 source, cmsis_os2.h
https://github.com/ARM-software/CMSIS_6/blob/main/CMSIS/RTOS2/Include/cmsis_os2.h










