Linux/glibc 中 errno 的原理、线程隔离与实现路径

在这里插入图片描述
@[toc]

本文面向已经接触 C、Linux 系统调用、pthread 或 RTOS,但对错误码如何产生、传递和隔离仍有疑问的开发者。文章从用户态接口、Linux 内核、C 运行库和 RTOS 四个层次,逐步说明 errno 的宏展开、线程局部存储、系统调用错误转换,以及不同操作系统的错误返回模型。

结论先行

1
errsv = errno;

这行代码不会在执行时向 Linux 内核查询最新错误码

在 glibc 环境中,errno 通常是一个宏:

1
#define errno (*__errno_location())

因此,代码近似展开为:

1
errsv = *__errno_location();

__errno_location() 返回当前线程专属的 errno 存储地址,解引用后得到该线程当前保存的错误码。

这个错误码通常已经由下列某一方写入:

  1. glibc 的系统调用包装层;
  2. 某个纯用户态库函数;
  3. 应用程序自身;
  4. 某个将其他错误模型转换为 errno 模型的兼容层。

所以最准确的理解是:

errsv = errno; 是把当前线程中已经保存的错误码复制到局部变量 errsv,以防后续函数调用覆盖它。

还要先明确一个边界:glibc 是运行在用户态的 C 运行库,不是 Linux 内核的一部分。Linux 内核、glibc、pthread 和各种 RTOS 可以采用不同的错误传递约定;errno 只是其中一种面向 C/POSIX 接口的错误报告机制,并不是所有内核都会维护一个名为 errno 的变量。

更准确地说,glibc 并不是在线程函数的栈上创建一个普通“局部变量”,而是为每个线程提供独立的 TLS 对象。__errno_location() 根据当前线程定位该对象,因此不同线程可以同时保存不同的错误码。


1. 为什么一个错误码需要专门保存

先看最常见的错误处理:

1
2
3
4
5
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 的值才通常具有诊断意义。


2. errno 看起来像变量,实际上可以是宏

应用程序包含:

1
#include <errno.h>

在 glibc 的公开头文件中,可以看到类似定义:

1
2
extern int *__errno_location(void) __THROW __attribute_const__;
#define errno (*__errno_location())

因此:

1
errsv = errno;

经过预处理后,逻辑上等价于:

1
errsv = *__errno_location();

进一步拆开:

1
2
int *errno_ptr = __errno_location();
errsv = *errno_ptr;

而下面的写操作:

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
2
3
线程 A:open() 失败,errno = ENOENT
线程 B:write() 失败,errno = EPIPE
线程 A:读取 errno,得到 EPIPE

线程 A 原本应该看到 ENOENT,却被线程 B 覆盖。

这会让多线程程序中的错误诊断变得不可靠。解决方法是把 errno 放入线程局部存储,即 Thread-Local Storage,简称 TLS。

概念上可以理解为:

1
2
3
线程 A 的 TLS:errno = ENOENT
线程 B 的 TLS:errno = EPIPE
线程 C 的 TLS:errno = EAGAIN

各线程访问的变量名相同,但底层存储位置不同。

1
2
3
4
5
6
7
8
flowchart LR
A[线程 A] --> A1[线程 A 的 TLS]
B[线程 B] --> B1[线程 B 的 TLS]
C[线程 C] --> C1[线程 C 的 TLS]

A1 --> EA[errno = ENOENT]
B1 --> EB[errno = EPIPE]
C1 --> EC[errno = EAGAIN]

Linux errno(3) 手册将 errno 描述为线程局部对象:一个线程修改自己的 errno,不会影响其他线程。


4. __errno_location() 到底返回什么

在 glibc 源码中,__errno_location() 的核心逻辑非常直接:

1
2
3
4
5
int *
__errno_location(void)
{
return &errno;
}

这段代码容易产生一个疑问:既然返回的是 &errno,为什么不同线程不会得到同一个地址?

关键在于 glibc 内部的 errno 不是普通对象,而是线程局部对象。内部声明可见类似形式:

1
extern __thread int errno;

__thread 是 GNU 工具链使用的线程局部存储声明方式之一。编译器和运行时会为每个线程建立独立实例。

因此:

1
return &errno;

实际含义是:

返回当前执行线程对应的那个 errno 实例的地址。

可以把 __errno_location() 理解为 glibc 暴露出来的“当前线程错误码地址查询函数”。

4.1 为什么不直接把 TLS 变量暴露给应用程序

使用访问函数和宏有几个好处:

  1. 对外维持 ISO C 所要求的 errno 左值语义;
  2. 隐藏不同平台的 TLS 实现差异;
  3. 允许 glibc 在不同架构、动态链接器阶段和运行环境下使用不同的内部实现;
  4. 避免应用程序错误地写出 extern int errno;,破坏线程局部语义和 ABI。

因此,应用程序只应:

1
#include <errno.h>

不要自行声明:

1
extern int errno; /* 不应这样做。 */

5. TLS 在机器层面如何工作

线程局部存储不是“每次访问都遍历线程表查找变量”。主流 ABI 会提供高效的线程指针和 TLS 地址计算机制。

以 Linux x86-64 的 glibc 系统调用错误处理代码为例,源码中可以看到通过 %fs 段相关地址写入线程局部 errno 的逻辑。其关键动作可以概括为:

  1. 找到当前线程的 TLS 基址;
  2. 根据 errno 的 TLS 偏移计算地址;
  3. 将错误码写入该地址。

在不同架构上,具体寄存器和指令不同,例如某些架构使用专门的 thread pointer 寄存器。应用程序不需要关心这些差异,因为编译器、动态链接器和 glibc 已经共同完成了 TLS 地址解析。

需要区分两层概念:

  • C 源码层:errno__thread__errno_location()
  • ABI/机器层:线程指针、TLS 重定位、段寄存器或专用寄存器。

6. errno 的值是谁写进去的

errno 并不只由内核产生。实际来源主要有三类。

6.1 系统调用失败:glibc 包装层写入

open() 为例,完整路径可简化为:

1
2
3
4
5
6
7
8
9
10
11
12
13
sequenceDiagram
participant App as 应用程序
participant Libc as glibc open() 包装层
participant Kernel as Linux 内核
participant TLS as 当前线程 TLS

App->>Libc: open(path, flags)
Libc->>Kernel: 发起系统调用
Kernel-->>Libc: 返回 -ENOENT
Libc->>Libc: 判断返回值属于错误范围
Libc->>TLS: errno = ENOENT
Libc-->>App: 返回 -1
App->>TLS: 读取 errno

Linux 内核系统调用 ABI 通常使用负值表达错误,例如:

1
2
3
-ENOENT
-EINVAL
-ENOMEM

而用户态 C API 通常采用另一套约定:

1
2
返回 -1
errno = 正的错误码

在 glibc 的 x86-64 系统调用包装代码中,错误值范围按 -1-4095 识别。包装层发现返回值属于该范围后,会:

  1. 对负错误码取反,得到正的 errno 值;
  2. 写入当前线程的 TLS;
  3. 将函数返回值改为 -1

伪代码可以写成:

1
2
3
4
5
6
7
8
long ret = kernel_syscall(...);

if (ret >= -4095 && ret < 0) {
errno = (int)-ret;
return -1;
}

return ret;

真实实现通常是架构相关汇编和宏,不一定真的生成上述 C 代码,但语义一致。

6.2 纯用户态库函数自行设置

不是所有失败都需要进入内核。例如:

  • 参数检查失败;
  • 字符串转换溢出;
  • 数学函数定义域或值域错误;
  • glibc 内部状态检查失败。

库函数可以直接执行:

1
errno = EINVAL;

由于 errno 是宏,这仍然是向当前线程的 TLS 写入。

6.3 应用程序自行设置

应用程序也能写:

1
errno = 0;

这通常用于某些返回值本身无法区分成功和失败的 API。例如某些函数可能在成功时也合法返回 -1,调用者需要:

1
2
3
4
5
6
errno = 0;
long value = some_function();

if (value == -1 && errno != 0) {
/* 确认发生错误。 */
}

但不要把 errno = 0 当成每次调用前都必须执行的固定模板。是否需要清零,应以目标函数文档规定的错误检测方式为准。


7. Linux 内核、glibc 与常见 RTOS 如何传递错误

讨论“其他内核怎样传递错误”时,不能只比较它们有没有 errno。需要先把下面三个问题分开:

层次 要回答的问题 常见实现
错误产生 谁发现了错误 内核、驱动、C 库、协议栈或应用模块
错误传递 调用者怎样获得失败原因 直接返回错误码、特殊返回值、输出参数、错误指针或异常
错误保存 是否需要保存“最近一次错误” TLS、线程控制块字段、任务重入结构、全局变量或完全不保存

errno 主要解决的是第三个问题,并配合返回值完成第二个问题。很多 RTOS API 直接把具体错误码作为返回值,因此根本不需要额外保存一个“最近错误”。

7.1 Linux 内核:优先通过返回值传递负错误码

Linux 内核内部大量函数采用下面的约定:

1
2
3
int ret = do_something();
if (ret < 0)
return ret;

典型语义是:

1
2
3
4
0 或非负值:成功结果
-EINVAL:参数无效
-ENOMEM:内存不足
-EBUSY:资源忙

这里的错误码直接沿调用链返回,不需要内核维护一个全局或线程局部 errno。这样做的优点是错误归属明确,调用者拿到的就是本次调用的结果。

当函数正常返回值是指针时,Linux 内核还经常使用错误指针:

1
2
3
4
struct object *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

1
2
3
4
5
6
flowchart LR
K[Linux 内核<br/>返回 -ENOENT] --> G[glibc 系统调用包装层]
G --> R[函数返回 -1]
G --> T[当前线程 TLS<br/>errno = ENOENT]
R --> A[应用程序检查返回值]
T --> A

因此,errno 是用户态接口适配后的结果,不是 Linux 内核内部传递错误的主要方式。

7.2 glibc:每线程一个 TLS 错误码对象

glibc 的公开头文件采用:

1
#define errno (*__errno_location())

内部则把对应对象声明为线程局部对象:

1
extern __thread int errno;

__errno_location() 返回当前线程对应实例的地址:

1
2
3
4
int *__errno_location(void)
{
return &errno;
}

这里的“线程局部”需要准确理解:

  • 不是线程函数栈上的自动局部变量;
  • 不是所有线程共享的普通全局变量;
  • 逻辑上属于当前线程的 TLS 区域;
  • 同一线程多次访问通常定位到同一个实例;
  • 不同线程访问时,通常得到不同地址和不同值;
  • 线程被调度到另一个 CPU 后,仍然访问该线程自己的 TLS,而不是某个 CPU 固定的变量。

所以可以把 glibc 的实现概括为:

每个线程拥有独立的 errno 存储实例,glibc 通过 TLS 定位机制让同一个宏名访问当前线程的实例。

7.3 RT-Thread:返回 rt_err_t,同时提供线程级 errno 兼容层

RT-Thread 的大量内核 API 使用 rt_err_t 直接返回状态。常见约定是:

1
2
3
4
RT_EOK:成功
-RT_ETIMEOUT:超时
-RT_EINVAL:参数无效
-RT_ENOMEM:内存不足

也就是说,RT-Thread 原生接口通常可以直接从函数返回值获得具体失败原因。

同时,RT-Thread 还提供类似 POSIX 的 errno 兼容机制:

1
#define errno (*_rt_errno())

当前实现中,_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
2
3
0:成功
-EBUSY:非阻塞获取时资源忙
-EAGAIN:等待超时

这与 Linux 内核内部的风格接近,错误原因直接包含在返回值中。

当启用 errno 支持时,Zephyr 又可以根据配置选择不同的保存方式:

  1. 由所选 C 库提供 errno
  2. 使用 Z_THREAD_LOCAL 声明 TLS 变量;
  3. 使用当前线程对象中的 errno_var
  4. 用户态线程使用其 userspace local data 中的 errno_var

因此,Zephyr 同时支持“直接返回负错误码”和“为 POSIX/C 库接口提供线程级 errno”,两者服务于不同 API 层。

7.5 FreeRTOS:核心 API 主要返回状态值,errno 属于组件或 C 库适配

FreeRTOS 内核本身没有把统一的 POSIX errno 作为所有 API 的主要错误通道。核心 API 常见的返回形式包括:

1
2
3
4
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

1
2
#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 库状态能够按任务隔离。

7.6 CMSIS-RTOS2:通过 osStatus_t 枚举直接返回

CMSIS-RTOS2 采用显式状态枚举:

1
2
3
4
5
6
7
8
9
10
typedef enum {
osOK = 0,
osError = -1,
osErrorTimeout = -2,
osErrorResource = -3,
osErrorParameter = -4,
osErrorNoMemory = -5,
osErrorISR = -6,
osErrorSafetyClass = -7
} osStatus_t;

调用者直接检查函数返回的 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 更倾向直接返回错误码

与隐藏的“最近错误”状态相比,直接返回错误码通常更适合嵌入式和实时系统:

  1. 错误与本次调用一一对应,不容易被后续调用覆盖;
  2. 不依赖 TLS,适合资源受限系统;
  3. 在中断上下文中更容易定义行为;
  4. 静态分析和单元测试更直接;
  5. 调用链可以使用 return ret; 原样传播错误;
  6. 不需要为每个线程额外维护 C 库兼容状态。

但 POSIX 文件、网络和 C 库接口已经广泛采用 errno,所以支持 POSIX 的 RTOS 往往同时存在两层接口:

1
2
RTOS 原生层:直接返回状态码
POSIX/C 库层:失败返回值 + 每线程 errno

这两种模型没有绝对优劣,关键是不能混用判断方式。必须根据具体 API 文档确定:错误是在返回值中、在 errno 中,还是通过输出参数返回。


8. 为什么必须尽快执行 errsv = errno

下面的代码存在隐患:

1
2
3
4
if (open(path, O_RDONLY) == -1) {
log_something();
fprintf(stderr, "open failed: %s\n", strerror(errno));
}

log_something() 内部可能调用其他系统调用或库函数,从而修改 errno。此时打印出的错误不一定仍然属于 open()

更稳妥的写法是:

1
2
3
4
5
6
if (open(path, O_RDONLY) == -1) {
int errsv = errno;

log_something();
fprintf(stderr, "open failed: %s\n", strerror(errsv));
}

Linux errno(3) 手册专门给出了这种保存方式。

8.1 信号处理函数中的额外注意事项

信号处理函数也可能影响 errno。常见防护形式是:

1
2
3
4
5
6
7
8
static void signal_handler(int signo)
{
int saved_errno = errno;

/* 仅执行异步信号安全操作。 */

errno = saved_errno;
}

这保证信号处理函数不会破坏被中断代码原本准备读取的错误码。


9. pthread 接口为什么是例外

很多 pthread API 不采用“返回 -1 并设置 errno”的模型,而是:

  • 成功返回 0
  • 失败直接返回正的错误码。

例如:

1
2
3
4
5
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,而是把错误号作为函数返回值。

因此下面的写法不可靠:

1
2
3
if (pthread_mutexattr_init(&attr) != 0) {
int errsv = errno; /* 可能读到旧值。 */
}

正确做法是直接保存返回值:

1
2
3
4
int errsv = pthread_mutexattr_init(&attr);
if (errsv != 0) {
/* errsv 本身就是错误码。 */
}

9.1 两种错误模型对比

API 类型 成功 失败返回值 失败原因位置
open()read() 等传统接口 非负值或指定成功值 通常为 -1 errno
多数 pthread 接口 0 正错误码 返回值本身

不要根据函数名称猜测错误模型,必须查阅该函数文档。


10. Lely mtx_init() 为什么最后执行 errno = errsv

考虑下面这种包装逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 接口返回:

1
2
3
4
5
0
EINVAL
ENOMEM
EAGAIN
...

而包装后的 C11 风格线程接口只想向上层返回:

1
2
thrd_success
thrd_error

如果只返回 thrd_error,具体失败原因就会丢失。因此包装层执行:

1
2
errno = errsv;
return thrd_error;

把 pthread 返回的具体错误码转存到当前线程的 errno 中。

完整转换关系是:

1
2
3
4
5
6
flowchart TD
A[pthread_mutex_init] -->|返回 0| B[mtx_init 返回 thrd_success]
A -->|返回 ENOMEM/EINVAL 等| C[errsv 保存错误码]
C --> D[errno = errsv]
D --> E[mtx_init 返回 thrd_error]
E --> F[调用者通过 errno 获取具体原因]

所以:

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdio.h>
#include <string.h>

static void *worker(void *arg)
{
int value = *(int *)arg;

errno = value;
printf("thread=%lu errno_addr=%p errno=%d (%s)\n",
(unsigned long)pthread_self(),
(void *)__errno_location(),
errno,
strerror(errno));

return NULL;
}

int main(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));
return 1;
}

ret = pthread_create(&thread2, NULL, worker, &err2);
if (ret != 0) {
fprintf(stderr, "pthread_create: %s\n", strerror(ret));
return 1;
}

pthread_join(thread1, NULL);
pthread_join(thread2, NULL);
return 0;
}

编译:

1
cc -Wall -Wextra -O2 -pthread errno_tls_demo.c -o errno_tls_demo

典型输出会呈现以下特征:

1
2
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 实现。


13. 常见错误用法

13.1 不检查返回值,直接判断 errno

错误:

1
2
3
4
some_function();
if (errno != 0) {
/* 不能据此确认本次调用失败。 */
}

正确:

1
2
3
4
if (some_function() == -1) {
int errsv = errno;
/* 处理 errsv。 */
}

13.2 在读取 errno 前调用其他函数

错误:

1
2
3
4
if (open(path, O_RDONLY) == -1) {
printf("open failed\n");
handle_error(errno);
}

正确:

1
2
3
4
5
6
if (open(path, O_RDONLY) == -1) {
int errsv = errno;

printf("open failed\n");
handle_error(errsv);
}

13.3 对 pthread 返回值读取 errno

错误:

1
2
3
if (pthread_mutexattr_init(&attr) != 0) {
fprintf(stderr, "%s\n", strerror(errno));
}

正确:

1
2
3
4
int ret = pthread_mutexattr_init(&attr);
if (ret != 0) {
fprintf(stderr, "%s\n", strerror(ret));
}

13.4 依赖错误码的具体数字

不推荐:

1
2
3
if (errno == 2) {
/* ... */
}

推荐:

1
2
3
if (errno == ENOENT) {
/* ... */
}

错误码数值可能因系统或架构而异,应使用符号常量。

13.5 手工声明 errno

不要写:

1
extern int errno;

应写:

1
#include <errno.h>

14. 最终心智模型

可以把 Linux、glibc 与 RTOS 的错误处理记成下面八句话:

  1. errno 是 C/POSIX 接口的一种错误报告机制,不是所有内核统一维护的变量;
  2. errno 是一个可修改的 int 左值,但在 glibc 中通常由宏实现;
  3. glibc 通过 __errno_location() 访问当前线程的 TLS 错误码对象;
  4. Linux 内核内部通常直接返回 -Exxx,指针接口还可能使用 ERR_PTR()
  5. glibc 系统调用包装层把内核负错误码转换成用户态的 -1 + errno
  6. pthread API 通常直接返回正错误码,不依赖 errno
  7. RT-Thread、Zephyr 等 RTOS 原生 API 通常直接返回状态码,同时可以为 POSIX/C 库接口提供线程级 errno
  8. FreeRTOS、CMSIS-RTOS2 等系统更强调 API 专用状态或状态枚举,是否存在任务级 errno 取决于所集成的 C 运行库。

用一段伪代码总结:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 传统系统调用包装模型。 */
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;
}

所以,对下面这行代码可以得到一个非常精确的回答:

1
errsv = errno;

不是“现在去内核取错误码”,而是:

调用 __errno_location() 定位当前线程的 errno 存储单元,并把其中已经保存的错误码复制到局部变量 errsv


参考资料

以下资料用于核对本文中的接口语义和实现细节,核心结论优先依据官方手册和 glibc 源码。

  1. GNU C Library Manual, Checking for Errors
    https://www.gnu.org/software/libc/manual/html_node/Checking-for-Errors.html
  2. Linux man-pages, errno(3)
    https://man7.org/linux/man-pages/man3/errno.3.html
  3. Linux man-pages, syscall(2)
    https://man7.org/linux/man-pages/man2/syscall.2.html
  4. glibc public header, stdlib/errno.h
    https://codebrowser.dev/glibc/glibc/stdlib/errno.h.html
  5. glibc internal header, include/errno.h
    https://codebrowser.dev/glibc/glibc/include/errno.h.html
  6. glibc source, csu/errno-loc.c
    https://codebrowser.dev/glibc/glibc/csu/errno-loc.c.html
  7. 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
  8. glibc declarations, misc/sys/cdefs.h
    https://codebrowser.dev/glibc/glibc/misc/sys/cdefs.h.html
  9. Linux man-pages, pthread_mutexattr_init(3)
    https://man7.org/linux/man-pages/man3/pthread_mutexattr_init.3.html
  10. Linux man-pages, pthread_mutex_init(3)
    https://man7.org/linux/man-pages/man3/pthread_mutex_init.3.html
  11. Linux Kernel Documentation, Error Pointers
    https://docs.kernel.org/core-api/kernel-api.html#error-pointers
  12. RT-Thread source, include/klibc/kerrno.h
    https://github.com/RT-Thread/rt-thread/blob/master/include/klibc/kerrno.h
  13. RT-Thread source, src/klibc/kerrno.c
    https://github.com/RT-Thread/rt-thread/blob/master/src/klibc/kerrno.c
  14. Zephyr source, lib/libc/common/source/errno/errno.c
    https://github.com/zephyrproject-rtos/zephyr/blob/main/lib/libc/common/source/errno/errno.c
  15. Zephyr source, kernel/mutex.c
    https://github.com/zephyrproject-rtos/zephyr/blob/main/kernel/mutex.c
  16. FreeRTOS Kernel source, include/projdefs.h
    https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/include/projdefs.h
  17. FreeRTOS Kernel source, include/newlib-freertos.h
    https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/include/newlib-freertos.h
  18. Arm CMSIS-RTOS2 source, cmsis_os2.h
    https://github.com/ARM-software/CMSIS_6/blob/main/CMSIS/RTOS2/Include/cmsis_os2.h