标准库地图、错误约定与程序控制
本节目标
查询 C23 标准库头文件、错误约定、errno、断言与程序终止接口。
C 标准库不是一组具有统一失败规则的函数。先按任务定位头文件,再阅读具体接口的参数契约和返回值;只有接口明确把错误报告到 errno 或其他状态时,才检查相应状态。本章以 ISO C23 为基线,建立这条查询路径,并区分正常、快速、立即和异常终止。动态内存、数值、流、字符和并发接口的详细用法留在各自专题。
标准库头文件与任务地图
按“现在要完成什么任务”选择头文件;不要因为某个声明在本机被间接带入,就省略定义该接口的标准头。
| 任务分区 | 标准头文件 | 主要接口或名字 | 使用判断 |
|---|---|---|---|
| 诊断与错误 | <assert.h>、<errno.h> | assert、errno、EDOM、ERANGE、EILSEQ | 区分内部不变量与接口规定的错误编号 |
| 类型、范围与布局 | <float.h>、<limits.h>、<stddef.h>、<stdint.h>、<inttypes.h> | 类型范围、size_t、定宽整数、格式宏 | 查询实现提供的范围和标准类型,不猜位宽 |
| 语言与实参辅助 | <stdarg.h>、<stdalign.h>、<stdbool.h>、<stdnoreturn.h>、<iso646.h> | 可变实参及兼容性名字 | 先确认名字在 C23 中的角色,再决定新代码是否需要 |
| 算术与数值环境 | <math.h>、<tgmath.h>、<complex.h>、<fenv.h> | 数学、复数、泛型数学、浮点环境 | 使用 <complex.h> 前确认条件特性支持;同时检查规定的错误方式和浮点环境 |
| 字符与区域设置 | <ctype.h>、<locale.h>、<uchar.h>、<wchar.h>、<wctype.h> | 字符分类、locale、编码字符、宽字符 | 输入值域、转换状态和 locale 都是契约的一部分 |
| 输入输出与对象操作 | <stdio.h>、<string.h> | 流、文件、字符串、内存块 | 容量、终止空字符、重叠和流状态必须逐接口判断 |
| 程序支持与控制 | <stdlib.h>、<setjmp.h>、<signal.h> | 转换、分配、环境、终止、非局部跳转、信号 | 本章详述错误读取与程序终止的共同边界 |
| 时间与并发 | <time.h>、<stdatomic.h>、<threads.h> | 时间、原子接口、线程接口 | 时间接口与两个条件特性头分开判断;不把缺少并发设施推及 <time.h> |
| C23 位运算与受检整数 | <stdbit.h>、<stdckdint.h> | 位查询、受检整数算术 | 用标准接口表达任务,不手写依赖表示的技巧 |
<stdlib.h> 覆盖的任务很多,仍不能把它理解成“通用头”:例如流接口属于 <stdio.h>,字符串和内存块接口属于 <string.h>。<uchar.h> 处理编码字符类型及转换边界,<wchar.h> 提供宽字符输入输出、宽字符串和多字节转换接口,<wctype.h> 负责宽字符分类与映射。
<complex.h>、<stdatomic.h> 和 <threads.h> 都是 C23 的条件特性头文件;实现可以不提供这些头文件及相应设施。探测和构建配置应分别处理复数、原子和线程能力,不能因为 <time.h> 与并发头列在同一任务分区,就把时间接口也误判为条件特性。
头文件也划定名字的使用边界。__ 开头或 _ 后接大写字母的标识符保留用于任何用途;任何 _ 开头的标识符在文件作用域的普通标识符和标签名字空间中保留。标准库条款列出的外部链接标识符以及 errno,始终保留作外部链接标识符,不因未包含关联头文件而开放。包含关联头文件后,该头的子条款列出的宏名按规定用途保留;列出的文件作用域标识符同时保留为宏名和同一名字空间的文件作用域标识符。未来库方向列出的匹配名字仍按潜在保留规则判断。
未来库方向还列出潜在保留模式:ckd_ 开头的类型名和函数名、str/mem/wcs 后接小写字母的函数名,以及 atomic_ 后接小写字母的函数名都是潜在保留标识符。潜在保留标识符若定义为宏就成为保留标识符;否则,若其定义见于标准第 1 至第 6 章,也成为保留标识符;其他情形仍是潜在保留标识符。项目代码应避开这些模式,以免与不同实现或后续标准发生冲突。
最小选择过程是:写下任务 → 在地图定位头文件 → 查接口声明及逐项契约 → 设计成功和失败分支 → 最后再调用。相关的头文件自包含原则见声明、定义、头文件与预处理器。
接口契约、前置条件与失败约定
调用标准库接口前,先验证该接口要求的值域、指针、对象大小、生存期和状态;越过前置条件不一定会得到一个可检查的错误返回。
| 契约维度 | 调用前问题 | 典型处理 |
|---|---|---|
| 值域 | 整数、字符值或枚举值是否在接口允许范围内 | 转换或拒绝无效输入 |
| 指针与生存期 | 是否允许空指针;所指对象是否存在且可访问 | 在调用前检查并保持对象存活 |
| 容量与重叠 | 目标区域是否足够;源和目标能否重叠 | 传入真实容量,按契约选择接口 |
| 库状态 | 是否需要预先清除 errno、流标志或转换状态 | 只清除接口文档要求区分的状态 |
| 结果 | 成功值、失败信号和附加状态分别是什么 | 先判返回值,再读规定的附加状态 |
标准库接口的约束不是运行期验证 API。若接口要求有效非空指针、足够容量或合法值域,调用者违反要求可能直接进入未定义行为,而不是稳定返回错误码。只有接口明确给出失败结果时,程序才拥有可处理的失败路径。行为分类的判断方法见限定符、对象访问与程序行为边界。
static int write_label(char *destination, size_t capacity) {
const char label[] = "ready";
if (destination == NULL || capacity < sizeof label) {
return 0;
}
memcpy(destination, label, sizeof label);
return 1;
}
这里由项目接口先把空指针和容量不足变成可处理的 0;满足检查后才把合法参数交给 memcpy。常见错误是先调用再期待库函数替调用者补做前置条件检查。
返回值、空指针与哨兵值
返回空指针、负值、EOF、零或其他哨兵值的含义由具体接口定义,不能建立全库通用的单一失败值。
| 接口 | 成功结果 | 失败或特殊结果 | 下一步 |
|---|---|---|---|
getenv | 指向环境字符串的指针 | 找不到名字时为空指针 | 不修改返回字符串 |
atexit | 返回零 | 注册失败时非零 | 不假设失败会设置 errno |
fgetc | 转换为 unsigned char 的字符值 | EOF | 再用流状态区分结束与错误 |
strtol | 转换结果,并通过 endptr 报告停止位置 | 返回值可能与有效结果重合 | 结合停止位置和规定的 errno 错误判断 |
system | 由接口规则解释的状态 | 空命令指针用于查询命令处理器 | 状态含义依具体调用及宿主环境解释 |
先保存接口返回值并立即按该接口的规则判断,避免随后调用覆盖附加状态。
const char *setting = getenv("APP_MODE");
if (setting == NULL) {
/* 名字未匹配;选择项目定义的默认模式。 */
}
空指针在 getenv 中表示没有匹配项;同一个空指针传给要求有效字符串的接口却可能违反前置条件。类似地,EOF 既不是任意函数的统一错误码,也不能存入 char 后再可靠判断。常见错误是写一个“非零总是失败”的通用包装器,抹掉每个接口自己的结果语义。
errno 与错误宏
只有当接口通过自身返回值或其他规定方式报告失败,并且标准规定该失败会设置 errno 时,errno 才可用于解释该失败。
<errno.h> 提供可修改左值形式的 errno,以及正整数常量宏 EDOM、ERANGE、EILSEQ。三者分别供接口报告定义域错误、范围错误和非法多字节序列;是否还有其他错误宏由实现决定。errno 的值本身不是跨实现稳定的数字,不应把数字常量写进协议或固定输出。
若需要区分“本次调用没有设置错误”与某个错误值,调用前把 errno 设为 0;成功调用不保证把旧值清零。正确顺序是:清零(仅在接口需要时)→ 调用 → 检查接口自己的失败或歧义结果 → 只在标准规定的分支解释 errno。
errno = 0;
char *end = NULL;
long value = strtol(text, &end, 10);
if (end == text) {
/* 没有完成转换。 */
} else if (errno == ERANGE) {
/* 数值超出 long 可表示范围。 */
} else if (*end != '\0') {
/* 只有前缀构成整数。 */
} else {
/* value 可用。 */
}
不要写成“调用后只要 errno != 0 就失败”:那可能读到陈旧值。也不要把 perror、strerror 生成的实现相关、locale 相关文案当作精确测试输出;测试稳定分支时应断言接口结果和标准错误宏。
断言与运行期诊断
assert 用于程序内部不变量,不能代替对外部输入、资源失败或正常错误路径的处理。
| 名字 | 头文件 | 适用对象 | 失败效果 |
|---|---|---|---|
assert(expression) | <assert.h> | 开发期间应始终成立的内部条件 | 条件为假时输出诊断并调用 abort |
NDEBUG | 在包含 <assert.h> 前控制 | 构建配置是否移除断言检查 | 定义时断言不求值 |
断言可能因 NDEBUG 完全不求值,所以表达式不能承担赋值、读取输入、释放资源等必要副作用。外部输入无效、文件打开失败、内存分配失败和网络数据不合约都属于普通错误路径,应检查返回值、清理资源并返回项目定义的状态。
static int element_at(const int values[], size_t count, size_t index, int *out) {
if (values == NULL || out == NULL || index >= count) {
return 0;
}
assert(count > 0);
*out = values[index];
return 1;
}
这里公开边界由 if 检查;断言只记录通过这些检查后必然成立的内部推论。断言失败的文本和宿主呈现方式不适合作为固定输出合同。
命令行参数与环境查询
命令行参数由 main 的参数提供,环境字符串用 <stdlib.h> 的 getenv 查询;两者都要在使用前验证,而不能假设宿主一定提供特定内容。
| 输入 | 接口或形式 | 可依赖内容 | 必须检查 |
|---|---|---|---|
| 参数数量 | int main(int argc, char *argv[]) | argc 非负;参数数组按标准规则提供 | 使用 argv[index] 前验证 index < argc |
| 参数文本 | argv[index] | 指向由宿主提供的字符串 | 语法、范围及项目约束 |
| 环境字符串 | getenv(name) | 匹配时返回指向字符串的指针 | 空指针、生命周期和内容格式 |
| 命令处理器 | system(NULL) | 用于查询命令处理器是否可用 | 返回值仅按 system 契约解释 |
| 执行命令 | system(command) | 把字符串交给宿主环境 | 注入、转义、可移植性和返回状态 |
getenv 返回的字符串由实现管理,调用者不得修改,后续环境操作可能使指针失效。需要跨越后续环境查询或修改长期保存内容时,应先复制到调用者管理、容量明确的存储中。
system 只负责把字符串交给宿主环境处理;C 不替程序解析参数边界、转义不可信输入,也不保证不同宿主使用同一种命令语言。因此不得把用户文本直接拼成命令,返回值也不能简化为全平台统一的进程退出码。
int main(int argc, char *argv[]) {
if (argc != 2) {
fputs("usage: program MODE\n", stderr);
return EXIT_FAILURE;
}
return argv[1][0] == '\0' ? EXIT_FAILURE : EXIT_SUCCESS;
}
这段程序只验证参数数量和空字符串;若参数还代表数字、路径或枚举值,必须继续验证相应语义。
正常终止与退出处理函数
需要按标准完成正常清理时,从 main 返回或调用 exit;用 atexit 注册无需参数的正常退出处理函数,并检查注册结果。
| 接口 | 头文件 | 结果或效果 | 清理边界 |
|---|---|---|---|
return status(初始 main 调用) | 语言语法 | 结束 main | 与以同一状态调用 exit 对应 |
exit(status) | <stdlib.h> | 正常终止,不返回 | 调用正常退出处理函数,再处理流和临时文件 |
atexit(function) | <stdlib.h> | 成功返回零,失败返回非零 | 注册无参数、无返回值的正常退出处理函数 |
EXIT_SUCCESS、EXIT_FAILURE | <stdlib.h> | 表示成功或不成功终止 | 宿主接收的具体状态形式由实现定义 |
从 main 的初始调用返回与调用 exit 具有标准规定的对应关系;到达 main 末尾等价于返回 0。exit 调用正常退出处理函数并处理流,quick_exit 只调用快速退出处理函数,_Exit 不调用这两类处理函数。
正常退出处理函数按与注册相反的顺序调用;同一函数注册多次就按注册关系调用多次。在退出处理期间新增的注册也受标准的先后关系约束,不能依赖某个实现偶然采用的静态数组遍历细节。处理函数应短小、避免依赖已清理资源,并且不再次发起互相冲突的终止流程。
static void release_report(void) {
puts("report=released");
}
int main(void) {
if (atexit(release_report) != 0) {
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
若清理动作自身可能失败且调用方必须获知结果,不要把它只放进 atexit 处理函数;在主控制流中显式清理并检查结果更合适。
快速与立即终止
只需运行专门的快速退出处理函数时用 quick_exit;必须立即把控制交回宿主且不调用两类退出处理函数时用 _Exit,二者都不是普通错误处理的默认选择。
| 接口 | 注册或调用 | 会调用的处理函数 | 不会调用的处理函数 |
|---|---|---|---|
at_quick_exit(function) | 注册,成功零、失败非零 | 供 quick_exit 使用 | 不加入 atexit 序列 |
quick_exit(status) | 正常终止,不返回 | at_quick_exit 注册的快速处理函数 | atexit 注册的正常处理函数 |
_Exit(status) | 正常终止,不返回 | 无 | atexit 与 at_quick_exit 注册的处理函数 |
atexit 与 at_quick_exit 的注册序列相互独立。快速退出处理函数也按与注册相反的顺序调用,之后终止流程进入 _Exit 的效果;它不执行 exit 那套标准流处理序列。对于 _Exit,未写出的缓冲数据是否刷新、打开流是否关闭以及临时文件是否移除是实现定义的,不能把其中任何一种结果写成可移植保证。
以下片段只用于说明,不执行,也没有固定输出:
static void emergency_marker(void) {
/* 只能依赖快速终止路径仍然有效的状态。 */
}
static void choose_immediate_termination(int use_handler) {
if (use_handler != 0) {
if (at_quick_exit(emergency_marker) != 0) {
_Exit(EXIT_FAILURE);
}
quick_exit(EXIT_FAILURE);
}
_Exit(EXIT_FAILURE);
}
不要在必须持久化的数据仍停留在用户态缓冲区时盲目选择这些路径,也不要假设正常退出处理函数会补做清理。
异常终止与 abort
abort 用于程序无法按普通合同继续时的异常终止;可恢复的输入错误或资源失败应返回错误,而不是主动触发异常终止。
| 接口 | 头文件 | 终止类别 | 可移植边界 |
|---|---|---|---|
abort() | <stdlib.h> | 异常终止 | 不返回,不执行正常退出处理函数 |
SIGABRT | <signal.h> | abort 使用的信号宏 | 不承诺信号编号或宿主显示文本 |
raise(signal) | <signal.h> | 向程序发送指定信号 | 返回值和处理效果按信号接口判断 |
signal(signal, handler) | <signal.h> | 建立标准信号处理方式 | 可调用接口、对象访问和重入限制很窄 |
abort 表示异常终止,不执行正常退出处理函数;正文不承诺宿主状态码或信号呈现方式。未写出流是否刷新、打开流是否关闭、临时文件是否移除由实现定义,因此不能依赖 atexit 或普通缓冲输出完成事故路径报告。
异常终止与非局部跳转不是同一机制。setjmp 与 longjmp 属于非局部跳转,用 <setjmp.h> 在仍然有效的调用环境间转移控制;它们不终止程序,也不是绕过资源清理和对象状态规则的通用异常系统。自动对象值、跳转目标生存期以及调用环境都有专门约束。
static void require_internal_state(int ready) {
if (ready == 0) {
abort();
}
}
这类代码不在代表程序中执行。若 ready == 0 可能来自合法外部输入,应把函数改成返回可检查的失败状态,而不是调用 abort。
错误处理与程序控制陷阱
排查库调用时沿“前置条件 → 接口返回值 → 专属附加状态 → 已获取资源 → 终止方式”的顺序走;不要从 errno 或某段宿主文本反推所有步骤。
| 症状 | 根因 | 正确选择 |
|---|---|---|
| 成功调用后仍报告错误 | 直接读取陈旧 errno | 先按接口返回值判断;需要消歧时调用前清零 |
| 空指针一律被当成库错误 | 不同接口的空指针语义不同 | 查当前接口的参数与返回合同 |
| 发布构建漏掉必要工作 | 把副作用写进 assert | 先执行必要操作,再断言内部结果 |
修改 getenv 返回内容 | 返回字符串由实现管理 | 只读使用,长期保存则复制 |
| 快速退出时清理未执行 | 注册到了错误的处理函数序列 | 分别设计 atexit 与 at_quick_exit 路径 |
| 异常路径丢失日志 | 依赖未保证刷新的缓冲流 | 在选择终止方式前完成必要的显式持久化 |
| 处理函数递归终止 | 重复或混合调用终止函数 | 让处理函数单向、短小且不重入终止流程 |
| 把宿主命令当 C 接口 | 假设 system 负责解析和安全转义 | 隔离宿主命令层,不拼接不可信文本 |
终止函数和处理函数的调用顺序、可重入限制及重复调用边界必须按 C23 原文核对,不凭常见实现推断。程序多次调用 exit,多次调用 quick_exit,或混合调用二者,会越过标准规定的行为边界;退出处理函数也不能用 longjmp 逃离当前调用。设计清理链时,让每个资源拥有明确的单次释放位置,不依赖重复终止来“再试一次”。
下面的代表程序注册正常退出处理函数,构造一个必然超出 long 正范围的十进制文本,再按 strtol 的合同判断停止位置和 ERANGE。它不执行 quick_exit、_Exit、abort、信号处理或非局部跳转,也不输出实现提供的错误文案:
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
static void report_cleanup(void) {
puts("cleanup=done");
}
int main(void) {
if (atexit(report_cleanup) != 0) {
fputs("atexit-error\n", stderr);
return EXIT_FAILURE;
}
char text[sizeof(long) * CHAR_BIT + 2];
int written = snprintf(text, sizeof text, "%ld0", LONG_MAX);
if (written < 0 || (size_t)written >= sizeof text) {
fputs("format-error\n", stderr);
return EXIT_FAILURE;
}
errno = 0;
char *end = NULL;
long value = strtol(text, &end, 10);
if (end == text || *end != '\0' || errno != ERANGE) {
fputs("range-check-error\n", stderr);
return EXIT_FAILURE;
}
(void)value;
puts("range-error=ERANGE");
puts("fallback=12");
puts("main-status=success");
return EXIT_SUCCESS;
}
输出:
range-error=ERANGE
fallback=12
main-status=success
cleanup=done
最后按接口反查头文件:
| 要找的接口或信息 | 头文件 | 本章检查重点 |
|---|---|---|
assert、NDEBUG | <assert.h> | 仅检查内部不变量;表达式无必要副作用 |
errno、EDOM、ERANGE、EILSEQ | <errno.h> | 先确认接口失败且规定设置,再解释错误宏 |
setjmp、longjmp、jmp_buf | <setjmp.h> | 非局部跳转的环境、生存期和对象值约束 |
signal、raise、SIGABRT | <signal.h> | 信号处理函数的严格操作边界 |
FILE、EOF、输入输出接口 | <stdio.h> | 返回值之外还要检查流结束和错误状态 |
strtol、getenv、system、终止接口 | <stdlib.h> | 每个接口各自的返回、环境和清理合同 |
| 字符串和内存块接口 | <string.h> | 容量、空终止、对象范围与重叠 |
| 编码字符类型与转换 | <uchar.h> | 代码单元、转换状态和编码边界 |
| 宽字符、宽字符串、多字节转换 | <wchar.h> | locale、状态对象、返回哨兵和容量 |
| 宽字符分类与映射 | <wctype.h> | 合法实参、分类描述符和映射结果 |
这张表是入口,不替代具体接口条款。遇到失败时先回到接口声明和契约,再决定检查返回值、errno、流状态、转换状态或项目自己的错误对象。