21.1 范围、命名与契约
如果在项目中尝试自行实现一个 strlen 函数(让他固定返回 42),可能会遇到一种颇具迷惑性的现象:代码编译时没有产生任何警告,但运行结果却呈现出奇特的分裂——传入字面量 strlen("abc") 时计算出的结果是 3,而传入动态参数 strlen(argv[0]) 时得到的返回值却是自定义实现中写死的 42。在源码层面上,这两次调用形式完全相同,但执行路径却大相径庭,因为函数标识符命名触发了编译器对标准库内置函数的特殊识别,导致了未定义行为。
这些底层细节超出了本节的范围。因此本节系统梳理后续各个练习模块的边界,深入剖析为什么在自定义实现中绝不能随意沿用标准库的函数命名,并展示如何将自然语言形式的标准规格拆解为清晰的前置条件与后置条件,以便指导具体的代码实现与单元测试编写。最后,我们将从零开始搭建起贯穿后续六个小节的完整 CMake 工程骨架。
1. 为什么自己实现
标准库的工业级实现往往凝结了长期的体系结构优化经验。例如,GNU C Library(glibc)中的 strlen 通常采用按机器字甚至向量寄存器批量扫描的策略;musl libc 的 memcpy 会依据指针对齐情况与拷贝长度分别跳转到多条精细优化的汇编分支;而在面对长模式串检索时,glibc 的 strstr 还会采用 Two-Way 字符串匹配算法。这些经过极致优化的实现非常值得深入研读,但在直接翻看汇编级优化之前,更重要的基础是先把接口本身的契约语义与边界行为理清楚。
亲手从零实现一遍这些基础接口,核心目的在于磨练两项至关重要的底层编程能力:基于裸字节的指针算术推理,以及严密解读标准化文档的能力。例如,在实现 mdr_strnlen 时,需要准确厘清「至多能向后安全读取多少个字节」以及「在指定检索窗口内未发现空字符时究竟应当返回什么」;在编写 mdr_memmove 时,则必须根据源内存块与目标内存块的相对地址,严密推导复制的遍历次序。每一个逻辑分歧点都能在权威的技术规范中找到确切裁定,而严谨的单元测试则会在出错时第一时间给出反馈。这种严苛的边界推敲训练,能直接迁移到未来阅读任何第三方库或操作系统底层接口文档的实践中。
在商业生产代码中,工程原则通常截然相反:凡是标准库已经提供的功能,都应优先调用标准库,具体原委在标准库的使用契约中有过详尽讨论。成熟的标准库实现历经全行业海量硬件平台与极端输入的长期考验,编译器也能结合目标指令集生成特化的内联机器码。手写版本很难在通用场景下的正确性与峰值吞吐上全面超越成熟的运行时实现。
在实际开发中,主要有两种典型场景促使我们自行实现这套基础垫片。其一是填补特定目标平台上缺失的高频扩展函数:例如 Windows 平台的 MSVC C 运行时长期未内置 strlcpy、strsep 或 memmem 等在类 Unix 平台广泛使用的工具接口,为了保证跨平台代码的整洁与统一,项目通常会自带一套稳妥的兜底实现,21.5 节将专门聚焦这一需求。其二是面向没有操作系统支撑的独立实现(freestanding implementation)环境:在资源极其受限的微控制器或固件裸机开发中,系统并不提供完整的标准 C 运行时支持,基础的字符串处理与内存搬运函数往往需要固件工程自己封装并提供。
本章构建的 mdr 库并非用于向外部发布的生产级软件,其核心价值在于带领读者将接口规格解析、C 语言逻辑实现、单元测试覆盖与自动化构建流程完整跑通一遍。相关工程源码存放在 code/开发与工程基础/21_库接口实现练习/ 目录中。
2. 本章的范围
纳入本章进行实战练习的接口,需要同时满足以下三条筛选原则:
- 完全能够使用标准 C 语言的核心语法表达,不强依赖专有的编译器扩展或内建指令。
- 不需要宿主操作系统提供特权服务,例如向内核动态申请堆内存、执行磁盘文件读写、或者向系统查询复杂的区域设置(locale)。
- 不依赖平台特定的底层 ABI 约定,也不依赖缺乏普通 C 回退机制的编译器专用底层特性,比如以变长参数列表遍历、处理器寄存器状态暂存与恢复为代表的机制。
上述第三条规则在日常开发中最容易被忽视。例如标准库中的 setjmp 与 longjmp 表面上看起来仅是普通的函数调用签名,但在实际执行时,它们必须把调用点的通用寄存器、程序计数器和调用栈帧严密保存并在后续跳转中恢复,纯粹依靠标准 C 的语言结构根本无法表达这种底层硬件控制行为。
| 接口组 | 小节 | 代表函数 |
|---|---|---|
<string.h> 中与区域设置无关的字符串函数 | 21.2 | strcpy、strcat、strstr、strtok |
| 内存函数 | 21.3 | memmove、memcmp、memchr、memccpy |
C23 <stdbit.h> 对应的位操作 | 21.4 | clz、ctz、bit_ceil、bswap |
| 各平台命名不统一的扩展函数 | 21.5 | strlcpy、strlcat、strsep、memmem |
| 有普通 C 回退的内建功能 | 21.6 | __builtin_popcount、__builtin_expect |
排除在外的接口和原因:
| 接口 | 不纳入的原因 |
|---|---|
malloc、free | 存储要从操作系统取得,纯 C 无法凭空造出内存 |
printf 家族 | 依赖可变参数机制与区域设置,规模也远超其他函数 |
setjmp、longjmp | 保存和恢复寄存器与栈帧,依赖 ABI |
va_arg | 编译器内建,无法用普通 C 遍历参数 |
| 原子操作 | 需要硬件指令或编译器支持,C11 起由 <stdatomic.h> 提供 |
strcoll、toupper、isalpha | 结果取决于当前区域设置 |
sin、sqrt、pow | 数值精度是另一门学问 |
下列哪个接口不符合本章的三条标准?
3. 命名
3.1 标准库的名字是保留的
在宿主实现(hosted implementation)中,标准库所提供的外部标识符均属于保留名称,这一规则并不取决于用户源码中具体包含了哪些头文件。如果我们在自己的工程里重新定义一个同名的外部函数,根据 C 语言标准,这种做法直接构成了未定义行为(undefined behavior),相关的详细条款可以参考保留标识符。
更为隐蔽的是,编译器在遇到这种情况时往往不会直接报错或抛出警告。以下面这段看似无害的小程序为例,它在编译期完全静默,但在运行期却展现出截然矛盾的行为:
#include <stdio.h>
#include <string.h>
/* 自己定义的 strlen:总是返回 42,以便看出调用的是哪一份代码 */
size_t strlen(const char *s) {
(void)s;
return 42;
}
int main(int argc, char *argv[]) {
(void)argc;
printf("strlen(\"abc\") = %d\n", (int)strlen("abc"));
printf("strlen(argv[0]) = %d\n", (int)strlen(argv[0]));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
在 Linux 上用 GCC 14.2 分别以 -O0、-O2 和 -O2 -fno-builtin 构建时,可能的输出:
$ gcc -std=c17 -Wall -Wextra -O0 -o mystrlen mystrlen.c
$ ./mystrlen
strlen("abc") = 3
strlen(argv[0]) = 42
$ gcc -std=c17 -Wall -Wextra -O2 -o mystrlen mystrlen.c
$ ./mystrlen
strlen("abc") = 3
strlen(argv[0]) = 42
$ gcc -std=c17 -Wall -Wextra -O2 -fno-builtin -o mystrlen mystrlen.c
$ ./mystrlen
strlen("abc") = 42
strlen(argv[0]) = 422
3
4
5
6
7
8
9
10
11
12
3.2 用 -fno-builtin 关掉内建识别
-fno-builtin 关掉编译器对所有标准函数的内建识别。上面最后一组构建加了它,两次调用都返回 42,说明编译器不再按内建 strlen 的语义处理 strlen("abc"),调用老老实实落在了自己写的函数上。只想关掉一个函数的识别,写 -fno-builtin-strlen,效果相同,波及的范围小一些。
编译器之所以敢这样处理,是因为 C 标准把这些名字保留给了标准库,理由见 3.1 节。名字既然只能是标准库那个函数,编译器就可以放心按标准语义去优化它。
标准库的名字不能用来自定义函数
标准库提供的外部标识符在宿主实现中是保留的,重新定义同名函数属于未定义行为。编译器还常把这些名字当作内建函数处理,使调用在编译期就被替换成内建语义。给练习实现加 mdr_ 前缀,可以同时避开保留名和内建处理,读代码的人也能一眼看出调用的是哪一份实现。
3.3 前缀留给标准库
C 语言标准在「未来的库发展方向」(Future library directions)条款中规定:以 str、mem 或 wcs 紧跟一个小写字母开头的函数名,全部预留给 <string.h> 及相关标准头文件用于未来扩充。这类外部名字无论当前源文件是否包含了对应头文件,在全局命名空间中都属于保留标识符。在 C23 标准中,这类名字被进一步细化定义为可能保留(potentially reserved):只要具体的宿主环境提供了该接口,它就立即生效为保留名;即使当前平台尚未提供,用户在代码中声明该名字虽然暂时不会引发未定义行为,但一旦项目迁移到实现了该接口的新环境,就会瞬间爆发命名冲突。
这在真实软件工程演进中屡见不鲜。例如在 GNU C Library 2.38 版本中,开发者将源自 OpenBSD 的实用接口 strlcpy 与 strlcat 正式吸纳进了官方的 string.h 头文件。当时诸如开源项目 direwolf 等一批历史工程由于长期跨平台维护的需要,在自身代码库中也内建了同名函数实现。在升级到新版 glibc 之后,这些项目纷纷在编译阶段直接崩溃。故障的直接诱因在于:该项目为了在调试期间追踪调用栈,使用宏将 strlcpy 重定向到了自定义的调试版本,导致宏展开污染了标准头文件内部的声明:
/* 仿照 direwolf 1.6 的 direwolf.h:把 strlcpy 重命名到自己的实现 */
#define strlcpy(dst, src, siz) strlcpy_debug(dst, src, siz, __FILE__, __LINE__)
#include <stddef.h>
#include <stdio.h>
#include <string.h>2
3
4
5
6
7
glibc 的 string.h 声明了 strlcpy,宏把这条声明也改了名,于是 char * 后面跟上了 __FILE__ 这样的字符串常量。在这台 Linux 机器上(glibc 2.41,-std=gnu17):
$ gcc -std=gnu17 -Wall -Wextra -o names names.c
names.c:3:61: error: expected declaration specifiers or ‘...’ before string constant
3 | #define strlcpy(dst, src, siz) strlcpy_debug(dst, src, siz, __FILE__, __LINE__)
| ^~~~~~~~
names.c:3:71: error: expected declaration specifiers or ‘...’ before numeric constant2
3
4
5
同一个文件在 MinGW-w64 GCC 13.2 上正常编译运行,因为 Windows 的 C 运行库没有声明 strlcpy。在 Linux 上改用 -std=c17 也能编译,严格模式不声明 strlcpy,宏没有东西可改。同一份源码忽而通过忽而报错,取决于库的声明和功能测试宏,这类问题在第 16 章展开。
按 C17 的规则,direwolf 自带 strlcpy 这个外部函数名本来就是保留的,冲突只是把一处隐藏的未定义行为变成了编译错误。加前缀的练习实现没有这个问题。
3.4 本章的命名约定
为了消除符号污染与内建识别干扰,本章所有的接口设计与文件组织严格遵循以下命名契约:
- 所有导出的标准对应函数统一采用
mdr_前缀加标准函数名的形式,例如mdr_strlen、mdr_memcpy、mdr_strtok。 - 位操作与整数运算相关函数显式附加操作数位宽后缀,与第一部分中建立的规范保持一致,例如
mdr_popcount_u32、mdr_clz_u64、mdr_bswap32。 - 头文件命名同样使用模块前缀,依次为
mdr_string.h、mdr_memory.h、mdr_bits.h、mdr_ext.h以及mdr_compiler.h,绝不直接采用string.h或memory.h。 - 仅在具体模块内部使用、不对外导出的辅助计算函数,一律声明为文件作用域的
static静态函数,不进入公共头文件。
在项目实践中,头文件名称的隔离与函数符号同等关键。如果开发者将自定义头文件随意命名为 string.h,并通过编译器的 -I 参数将其所在目录置于头文件检索路径中,那么后续源码中所有标准的 #include <string.h> 都会被编译器优先拦截并重定向到项目本地的这套文件上,导致标准库的原型声明与类型定义悉数失效。下面是在检索路径中错误放置自制 string.h 后暴露出的典型编译器级联错误(基于 Windows 环境 MinGW-w64 GCC 13.2):
$ gcc -std=c17 -Wall -Wextra -I. shadow.c -o shadow
In file included from shadow.c:1:
./string.h:3:1: error: unknown type name 'size_t'
3 | size_t my_strlen(const char *s);
| ^~~~~~
shadow.c: In function 'main':
shadow.c:5:5: warning: implicit declaration of function 'strcpy' [-Wimplicit-function-declaration]2
3
4
5
6
7
把文件改名为 mdr_string.h 后,同一份 shadow.c 编译通过。
4. 读懂一份规格
标题中所提到的「契约」,借鉴了软件工程中经典的契约式设计(Design by Contract)思想:前置条件(precondition)规定了调用方进入函数前必须履行的义务,而后置条件(postcondition)则是函数在正常执行结束后向调用方给出的状态保证。在后文的分析中,我们将统一采用「规格」(specification)、「前置条件」与「后置条件」这几个概念来梳理逻辑。
一份足以指导编码与测试的规格,通常由五个明确的维度组成:形参语义、前置条件、操作副作用(效果)、返回值含义以及后置条件。以最为基础的内存拷贝函数 memcpy 为例:
void *memcpy(void *restrict dest, const void *restrict src, size_t count);- 参数:
dest指向接收数据的目标缓冲区起始地址,src指向被拷贝的源数据块起始地址,count则指定需要复制的连续字节数。 - 前置条件:目标区域与源区域各自在物理内存中必须至少具备
count个可合法访问的连续有效字节;同时,这两个内存区域绝不能存在任何物理地址上的重叠。原型中的restrict类型限定符在类型系统层面强化了「互不重叠」的约束,一旦调用方传入了存在交叉重叠的地址区间,执行后果即属于未定义行为。 - 效果:将
src起始的count个连续字节逐一搬运并覆盖到dest开头的内存中。 - 返回值:原样返回目标缓冲区的起始指针
dest。 - 后置条件:操作结束后,
dest前count个字节的内容与执行前src所对应字节的内容完全一致。
相比之下,日常开发中极易被误读的是带有长度限制的字符串拷贝函数 strncpy。如果我们同样按照上述结构将其逐项拆解,其行为差异就会一目了然:
char *strncpy(char *restrict dest, const char *restrict src, size_t count);- 参数:接收目标指针
dest、源字符指针src以及最大写入长度限制count。 - 前置条件:
dest必须拥有至少容纳count个字节的可写内存空间;src必须在以其为起点的地址范围内至少保证min(strlen(src) + 1, count)个字节可安全读取;两块内存区域同样严禁重叠。 - 效果:至多向目标缓冲区复制
count个字节。当src作为以空字符结尾的字符串其有效长度小于count时,函数在拷贝完全部有效字符和结尾的空字符之后,会继续向目标缓冲区追加空字符,直至累计写满整整count个字节。 - 返回值:返回目标缓冲区的起始指针
dest。 - 后置条件:当
src的字符串长度小于count时,dest构成一个以空字符结尾的标准字符串,且后续空余空间均被填充为'\0';而当src的长度大于或等于count时,dest的前count个字节仅忠实记录源串对应内容,末尾绝不会自动追加终止空字符。
| 项目 | memcpy | strncpy |
|---|---|---|
| 前置条件 | 两个区域各至少 count 字节且不重叠 | dest 至少 count 字节,源可读范围足够,不重叠 |
| 效果 | 复制 count 个字节 | 最多复制 count 个字节,源较短时补空字符到 count 个 |
| 返回值 | dest | dest |
| 后置条件 | 前 count 个字节与源相同 | 源较短时得到字符串,否则前 count 个字节与源相同且无终止空字符 |
这里能看出前置条件和后置条件的分工。strncpy 的后置条件在两种情况下差别很大,而它到底落在哪种情况,取决于调用方传进来的 src 有多长。把三种长度各跑一遍,目标缓冲区先填满 #(0x23),再按字节打印:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void dump(const char *label, const unsigned char *p, size_t n) {
printf("%-24s", label);
for (size_t i = 0; i < n; i++) {
printf(" %02X", p[i]);
}
printf("\n");
}
int main(int argc, char *argv[]) {
unsigned char buf[8];
const char *src = argc > 1 ? argv[1] : "abcdefghij";
size_t n = (size_t)(argc > 2 ? atoi(argv[2]) : 8);
size_t len = 0;
memset(buf, '#', sizeof buf);
strncpy((char *)buf, src, n);
dump("result:", buf, sizeof buf);
while (len < sizeof buf && buf[len] != 0) {
len++;
}
printf("terminating null in buffer: %s\n", len < sizeof buf ? "yes" : "no");
return 0;
}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
$ gcc -std=c17 -Wall -Wextra -o sc sc.c
$ ./sc ab 8
result: 61 62 00 00 00 00 00 00
terminating null in buffer: yes
$ ./sc abcdefgh 8
result: 61 62 63 64 65 66 67 68
terminating null in buffer: no
$ ./sc abcdefghij 8
result: 61 62 63 64 65 66 67 68
terminating null in buffer: no2
3
4
5
6
7
8
9
10
第二组与第三组测试的结果非常典型:由于拷贝源串长度达到了限制上限,目标缓冲区被刚好写满了 8 个字节,末尾完全没有出现用于截断字符串的终止空字符 \0。如果下游代码误将这块缓冲区当作常规 C 字符串传入其他文本处理函数,程序将毫无防备地越界向后遍历,直到在宿主栈上碰巧撞见某个偶发的零字节为止。Windows 上 MinGW-w64 GCC 13.2 的输出逐行相同。
在这两份规格中,「两块内存区域不得重叠」都被明确归入调用方的义务范畴。标准库内部出于性能考量,并不会主动在运行时对比两组指针的地址范围。调用方一旦违反前置条件,程序究竟会呈现出何种状态,完全交由编译器优化器与底层硬件决定。下面这份程序故意违反 memcpy 的前置条件:
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[16] = "0123456789abcdef";
/* 源区域与目标区域重叠:调用 memcpy 违反前置条件 */
memcpy(buf + 2, buf, 10);
printf("%.16s\n", buf);
return 0;
}2
3
4
5
6
7
8
9
10
11
在 Linux 上用 GCC 14.2 构建,两个优化级别给出不同的字符串:
$ gcc -std=c17 -Wall -Wextra -O0 -o ov ov.c
$ ./ov
010123456789cdef
$ gcc -std=c17 -Wall -Wextra -O2 -o ov ov.c
ov.c: In function 'main':
ov.c:8:5: warning: 'memcpy' accessing 10 bytes at offsets 2 and 0 overlaps 8 bytes at offset 2 [-Wrestrict]
$ ./ov
010123456767cdef2
3
4
5
6
7
8
在 -O0 未开启优化的调试级别下,编译器发射了一条针对外部 memcpy 符号的调用指令,实际执行表现碰巧等同于自低地址向高地址依次逐字节覆写。而在开启 -O2 优化之后,编译器内建分析器敏锐地捕捉到了这两个操作数在同一个数组内的重叠偏移,直接给出了 -Wrestrict 编译警告;并且由于小尺寸拷贝被彻底内联优化成了寄存器指令组合,运行时的覆写时序与原有的逐字节假设完全不同。这两种运行表现都在未定义行为的许可范围之内,任何稳健的代码都绝不能对这类偶发现象产生依赖。当业务场景确实存在内存区间重叠的可能性时,必须使用专门为此设计的 memmove 函数,它能确保即使区间交叉也能得到正确无损的搬运结果;在 21.3 节中,我们将亲手实现这一机制。Windows 上 MinGW-w64 GCC 13.2 的结果与上面逐行相同,-O2 的警告也相同。
前置条件由调用方保证,函数不检查
前置条件写在规格里,是调用方的义务;标准库函数不检查它,违反后行为未定义。省略数组大小检查、传入重叠区域、传空指针,都属于这一类错误。函数的保证只覆盖前置条件成立的情形,所以阅读规格时要先确认调用点满足前置条件,再看返回值和后置条件。
下列哪些属于 memcpy 的前置条件,即调用方的义务?
5. 为 mdr_ 函数写规格注释
头文件中的接口注释应当遵循统一且严谨的结构展开:首先用一句话提炼核心功能,随后明确声明「调用方保证」的前置条件,最后列出「返回」所代表的返回值含义与后置条件保证。本章采用如下标准注释模板:
/*
* 一句话说明功能。
* 调用方保证:前置条件。
* 返回:返回值与后置条件;与标准函数有差别时写在这里。
*/2
3
4
5
以第一部分实现过的 mdr_strnlen 为例,完整的契约式注释写作:
/*
* 返回 s 开头连续非空字符的个数,最多检查 maxlen 个字节。
* 调用方保证:s 指向的区域至少有 min(strlen(s) + 1, maxlen) 个可读字节。
* 返回:范围内遇到空字符时为它之前的字节数,否则为 maxlen;
* 不读取第 maxlen 个字节之后的内容。
*/
size_t mdr_strnlen(const char *s, size_t maxlen);2
3
4
5
6
7
注释末尾补充的「不读取第 maxlen 个字节之后的内容」,描述的是函数的显式访问边界约束。调用方正是依赖这条明确的副作用边界,才能放心地在仅持有定长缓冲区的场景下调用该函数,而不必担忧函数内部产生无意的内存越界探测。
再看 mdr_strchr 的规格注释:
/*
* 在 s 中查找 (char)ch 首次出现的位置,终止空字符也算字符串的一部分。
* 调用方保证:s 指向以空字符结尾的字符串。
* 返回:指向该字符的指针;没有找到时返回 NULL。
*/
char *mdr_strchr(const char *s, int ch);2
3
4
5
6
「终止空字符也算字符串的一部分」这一句是标准规定的关键语义,它直接决定了 mdr_strchr(s, '\0') 必须返回指向字符串末尾 '\0' 的有效指针,若漏掉这层说明,单元测试设计时就极易遗漏针对零字符检索的边界用例。
/* 在 s 中查找 c。 */
char *mdr_strchr(const char *s, int ch);2
/*
* 在 s 中查找 (char)ch 首次出现的位置,
* 终止空字符也算字符串的一部分。
* 调用方保证:s 指向以空字符结尾的字符串。
* 返回:指向该字符的指针;没有找到时返回 NULL。
*/
char *mdr_strchr(const char *s, int ch);2
3
4
5
6
7
对比左右两栏可以发现,左栏模糊的注释并未交代三件极其关键的事项:检索未命中时返回何种哨兵值、目标包含空字符时的判定逻辑、以及传入指针必须满足怎样的合法性假设。面对此类注释,使用者只能被迫阅读底层实现;而当该模块处于需求定义或团队协作阶段时,实现可能尚未开始编写。
需要澄清的是,在注释中写明「调用方保证」,并不代表函数在所有环境下都完全被动。在日常的调试构建中,完全可以针对开销极低的关键前置条件插入断言进行防御性捕获;而在正式的发布构建中,这些断言会被预处理器移除,契约责任依然由调用方承担。正因如此,规格注释不能写成「函数会校验 s 是否为空指针」,这种表述混淆了防御性检查与接口契约的权责边界。
6. 在调试构建中检查前置条件
在 mdr_compiler.h 中,我们定义了轻量级的 MDR_ASSERT 宏。它在底层直接封装了标准库的 assert,当宏 NDEBUG 被定义时会自动展开为空操作:
#define MDR_ASSERT(cond) assert(cond)关于标准断言的详细机制可以参考断言。将断言放置在函数体入口处,一旦调用方传递了非法参数违反前置条件,程序能够在第一时间内在现场抛出异常并终止,避免非法指针继续在调用链深处游荡、引发难以追踪的偶发性崩溃:
#include "mdr_compiler.h"
#include <stdio.h>
#include <stdlib.h>
static size_t count_bytes(const char *s) {
size_t n = 0;
MDR_ASSERT(s != NULL);
while (s[n] != '\0') {
n++;
}
return n;
}
int main(void) {
const char *arg = getenv("DEMO_ARG");
printf("%lu\n", (unsigned long)count_bytes(arg));
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
在 Linux 上,带断言和不带断言的两个构建分别运行:
$ gcc -std=c17 -Wall -Wextra -g -o demo demo.c
$ DEMO_ARG=hello ./demo
5
$ ./demo
demo: demo.c:8: count_bytes: Assertion `s != ((void *)0)' failed.
Aborted
$ echo $?
134
$ gcc -std=c17 -Wall -Wextra -g -DNDEBUG -o demo_nd demo.c
$ ./demo_nd
Segmentation fault
$ echo $?
1392
3
4
5
6
7
8
9
10
11
12
13
断言失败时程序以 134 退出,-DNDEBUG 的构建直接触发段错误,退出状态 139。Windows 上 MinGW-w64 GCC 13.2 的断言信息格式不同,内容是 Assertion failed: s != ((void *)0), file demo.c, line 8。
断言机制仅存在于调试构建中,因此在接口实现中设置断言需要遵循审慎的选择原则:
- 优先检查计算成本廉价且极易误犯的低级错误,例如指针判空、以及
maxlen等尺寸参数与底层缓冲区之间的明显逻辑矛盾。 - 坚决不检查必须通过耗时遍历才能确认的隐式条件,比如「目标缓冲区必须拥有足够容纳源串的字节数」。这类检查即便在调试期也会造成严重的计算放大,依然必须全权交由调用方的逻辑来保证。
- 断言内部严禁包含任何具有副作用的表达式,断言的存在与否不得以任何形式改变正常前置条件满足时的语义逻辑与输出表现。
void *mdr_memcpy(void *dest, const void *src, size_t count) {
if (overlap(dest, src, count)) {
return mdr_memmove(dest, src, count);
}
/* 逐字节复制 */
}2
3
4
5
6
void *mdr_memcpy(void *dest, const void *src, size_t count) {
MDR_ASSERT(!ranges_overlap(dest, src, count));
/* 逐字节复制,与标准库一致 */
}2
3
4
左栏的防御性设计表面上看起来宽容且健壮,但实际上却掩盖了调用方的逻辑缺陷:调用方编写了违反 memcpy 契约的代码,而函数内部却悄无声息地降级为 memmove 来掩盖错误,导致这类违规在测试阶段完全无法被暴露。更严重的是,这破坏了自定义接口与标准 memcpy 之间的语义一致性。更为合理的工程实践是:在调试构建中通过断言强硬地拦截重叠错误,而在发布构建中保持与工业标准完全一致的执行路径。
7. 工程骨架
本章所有的接口实现与测试套件均整合在同一个模块工程中,目录层级组织如下:
21_库接口实现练习/
├── CMakeLists.txt
├── include/
│ ├── mdr_compiler.h # MDR_ASSERT,21.6 加内建检测宏
│ ├── mdr_string.h # 21.2
│ ├── mdr_memory.h # 21.3
│ ├── mdr_bits.h # 21.4
│ └── mdr_ext.h # 21.5
├── src/
│ ├── mdr_string.c
│ ├── mdr_memory.c
│ ├── mdr_bits.c
│ └── mdr_ext.c # 21.5 之前只有头文件里的声明
├── tests/
│ ├── check.h # 第 11 章的测试框架
│ ├── check.c
│ ├── test_string.c
│ ├── test_memory.c
│ ├── test_bits.c
│ └── diff_util.c 等 # 21.7 加入
└── exercises/ # 本页习题的起始文件在测试基础设施方面,tests/check.h 延续了我们在自动化测试中建立的极简断言体系:利用 CHECK 进行布尔逻辑判定,利用 CHECK_EQ_SIZE 精确比对两个 size_t 类型的计数值,利用 CHECK_STR_EQ 对比以空字符结尾的字符串内容,最后通过 check_report 输出「套件名: 总检查数 checks, 失败数 failed」并依据测试成败返回对应的进程退出码。本章各个功能模块的单元测试均使用这套轻量框架,这充分说明了良好的测试设施完全可以做到自洽与解耦,不与任何业务代码产生绑定。
在构建系统 CMakeLists.txt 中,核心的源文件扫描与目标定义逻辑如下(其余部分主要用于设置标准级别、通用告警与 Sanitizer 选项):
file(GLOB MDR_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/src/*.c")
add_library(mdr STATIC ${MDR_SOURCES})
target_include_directories(mdr PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/include")
file(GLOB MDR_UNIT_TESTS CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_*.c")
foreach(test_src IN LISTS MDR_UNIT_TESTS)
get_filename_component(test_name "${test_src}" NAME_WE)
add_executable(${test_name} "${test_src}" tests/check.c)
target_include_directories(${test_name} PRIVATE tests)
target_link_libraries(${test_name} PRIVATE mdr)
add_test(NAME ${test_name} COMMAND ${test_name})
endforeach()2
3
4
5
6
7
8
9
10
11
12
13
配置中引入的 CONFIGURE_DEPENDS 参数极其关键:它指示 CMake 在每次执行编译动作前主动检查源码目录。后续小节中当我们向目录中加入新的扩展实现(如 tests/test_ext.c)或差分测试(如 tests/diff_string.c)时,构建系统能自动感知新增文件并动态更新编译依赖图,完全免去了反复手动运行 CMake 配置命令的琐碎负担。在目标依赖结构上,mdr 被编译为一个独立的静态库(static library),各个单元测试可执行文件直接链接该库;差分测试则会按 tests/diff_*.c 规则被收集,并在链接时附带 tests/diff_util.c 工具支持。更多 CMake 的工程化组织技巧可参阅 CMake 与 XMake。
此前在第一部分练习中完成的七个基础函数,已按职责归类迁移进了对应的源文件中:mdr_strlen、mdr_strcmp 与 mdr_strnlen 放置在 src/mdr_string.c;mdr_memset 与 mdr_memcpy 放置在 src/mdr_memory.c;而 mdr_popcount_u32 与 mdr_bswap32 则放置在 src/mdr_bits.c。测试代码同样按模块分文件编写,并针对每个接口设计了至少覆盖三组典型边界条件的测试用例:
static void test_strnlen(void) {
const char non_terminated[4] = {'a', 'b', 'c', 'd'};
CHECK_EQ_SIZE(mdr_strnlen("", 4u), 0u);
CHECK_EQ_SIZE(mdr_strnlen("hello", 0u), 0u);
CHECK_EQ_SIZE(mdr_strnlen("hello", 3u), 3u);
CHECK_EQ_SIZE(mdr_strnlen("hello", 10u), 5u);
CHECK_EQ_SIZE(mdr_strnlen(non_terminated, 4u), 4u);
}2
3
4
5
6
7
8
9
在 Linux 上配置、构建并运行测试(这台机器上的 CMake 在 tools/cmake/bin 下,没有加入 PATH,下面为便于阅读写成 cmake):
$ cmake -S . -B build
$ cmake --build build -j2
$ ctest --test-dir build --output-on-failure
Test project /home/minsecrus/tutorial/work/p064/mdr/build
Start 1: test_bits
1/3 Test #1: test_bits ........................ Passed 0.00 sec
Start 2: test_memory
2/3 Test #2: test_memory ...................... Passed 0.00 sec
Start 3: test_string
3/3 Test #3: test_string ...................... Passed 0.00 sec
100% tests passed, 0 tests failed out of 3
Total Test time (real) = 0.01 sec2
3
4
5
6
7
8
9
10
11
12
13
14
直接运行测试程序,能看到每个套件的检查数:
$ ./build/test_string
test_string: 15 checks, 0 failed
$ ./build/test_memory
test_memory: 28 checks, 0 failed
$ ./build/test_bits
test_bits: 9 checks, 0 failed2
3
4
5
6
在 Windows 平台上使用 MinGW 构建时,CMake 需要显式指定生成器类型,生成的目标可执行文件后缀为 .exe。需要说明的是,某些特定版本的 CMake(例如 3.29)在配合 MinGW Makefiles 生成器时,如果源码所在路径包含中文字符,在执行自动扫描时可能会出现找不到 VerifyGlobs.cmake 的偶发报错;此时只要改用 Ninja 生成器(即附加 -G Ninja 参数),即可完美规避该路径解析问题,产出的测试结果与 Linux 平台完全一致。
当单元测试中出现断言未通过时,框架内部的 check_fail 会立即输出「文件名:行号: 详细诊断」,随后由 check_report 输出最终的统计信息并以非零状态退出。例如,如果我们故意把 test_string.c 中的某条断言期望值篡改,重新执行 ctest 便会观察到如下明确的失败提示:
Start 3: test_string
3/3 Test #3: test_string ......................***Failed 0.00 sec
/home/minsecrus/tutorial/work/p064/mdr/tests/test_string.c:28: mdr_strnlen("hello", 3u) is 3, expected 5
test_string: 15 checks, 1 failed
67% tests passed, 1 tests failed out of 32
3
4
5
6
7
可以看到,控制台输出清晰指明了出错的物理源文件、具体行号、计算得出的实际值、预期的正确值以及原始表达式文本,开发者无需立即启动庞大的调试器,单凭这行日志就能迅速逆向锁定问题原因。
工程配置中还提供了两个功能开关:通过 -DMDR_SANITIZE=ON 可以在编译期开启 AddressSanitizer 与 UndefinedBehaviorSanitizer,建议将带检测的产物放在独立的目录(如 build-asan/)中管理,具体使用要点可回顾编译器诊断与程序检查。在具备完整 Sanitizer 运行时支持的环境下,所有测试用例同样能够完全绿灯通过。而另一个开关 -DMDR_NO_BUILTINS=ON 则专门用于 21.6 节对比内建优化与纯 C 回退路径。
MinGW 上打不开 MDR_SANITIZE
本机的 MinGW-w64 GCC 13.2 没有 ASan 和 UBSan 的运行库,链接时报 cannot find -lasan。Windows 上可以改用 MSVC 自带的 clang,或者在 Linux、macOS 上运行这个构建。普通构建不受影响。
8. 习题
8.1 巩固
按本章的三条标准判断下列接口是否适合放进练习库,并说明理由:strlen、memmove、strtok、qsort、malloc、printf、setjmp、toupper、strtol、memset_explicit。
不纳入的接口要指出它卡在哪一条标准上。strtok 和 toupper 的理由不同,分别写清楚。
8.2 编程
复现正文里的 strlen 冲突。写一个程序,里面自己定义 size_t strlen(const char *s),让它返回一个与真实长度不同的值,然后:
- 用
-O0、-O2、-O2 -fno-builtin三种方式构建,记录strlen("abc")和strlen(argv[0])各自的结果。 - 把
strlen的地址存进函数指针,通过指针调用strlen("abc"),在同样的三种方式下记录结果。 - 用一句话解释每个结果,说明编译器在什么时候按内建语义处理了调用。
在有第二种编译器或第二台机器的条件下,把结果对照一遍。
给 mdr_strnlen 和 mdr_memset 加上前置条件断言:前者检查 s 非空,后者检查 dest 非空或 count 为 0。
写一个程序,分别用合法实参和空指针调用这两个函数,在带断言和不带断言(-DNDEBUG)的构建里各运行一次,记录输出和退出状态。要求合法调用在两种构建里的输出完全一致,并说明为什么断言不能改变合法调用的行为。
按本章的目录树建立 mdr 库:五个头文件、四个源文件,tests/check.h、tests/check.c 从第 11 章照搬。把第一部分实现过的七个函数按模块放进库:
mdr_string.c:mdr_strlen、mdr_strcmp、mdr_strnlenmdr_memory.c:mdr_memset、mdr_memcpymdr_bits.c:mdr_popcount_u32、mdr_bswap32
每个函数至少三个检查,断言要覆盖空串、长度为 0 与上限附近的调用,以及至少一个非 ASCII 字节。用 file(GLOB ... CONFIGURE_DEPENDS) 收集源文件与测试,注册到 ctest,然后在 build-asan/ 里用 -DMDR_SANITIZE=ON 再跑一遍。环境里没有 ASan 运行库时,说明原因并跳过这一步。
验收:ctest --test-dir build 全部通过,普通构建在 -Wall -Wextra 下无警告。
8.3 综合与挑战
code/开发与工程基础/21_库接口实现练习/exercises/22116_names.c 是一份从旧项目抄来的代码:在 MinGW 上编译运行正常,拿到 glibc 上用 -std=gnu17 就报错。读这份代码,找出三处命名问题:
- 两个文件作用域对象的名字。
- 函数名
strlcpy_debug。 strlcpy宏与标准头文件的相对位置。
修好之后,要求同一份源码在 MinGW 和 glibc 上都能构建,输出 5 1 1 hello。对每一处问题写出:按哪条规则它不合法或不可移植,为什么在一个平台上没有暴露。
没有 Linux 环境的读者可以读 glibc 的 string.h 源码确认 strlcpy 的声明,并解释为什么用 -std=c17 构建时同一个文件能通过。
9. 参考资料
- cppreference,Reserved identifiers:https://en.cppreference.com/w/c/language/identifier
- cppreference,
memcpy:https://en.cppreference.com/w/c/string/byte/memcpy - cppreference,
strncpy:https://en.cppreference.com/w/c/string/byte/strncpy - cppreference,
memchr:https://en.cppreference.com/w/c/string/byte/memchr - cppreference,
strrchr:https://en.cppreference.com/w/c/string/byte/strrchr - ISO/IEC 9899:2024 工作草案 N3220,7.33 节 Future library directions:https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf
- ISO/IEC 9899:2011 草案 N1570,7.31 节 Future library directions:https://www.open-std.org/jtc1/sc22/wg14/www/docs/n1570.pdf
- Bertrand Meyer,Applying "Design by Contract",IEEE Computer 25(10),1992:https://doi.org/10.1109/2.161279
- David R. Hanson,C Interfaces and Implementations: Techniques for Creating Reusable Software,Addison-Wesley,ISBN 978-0-201-49841-7。
- GNU C Library 2.38 发布说明(新增
strlcpy、strlcat):https://lists.gnu.org/archive/html/info-gnu/2023-07/msg00010.html - direwolf issue #489,glibc 2.38 下的构建失败:https://github.com/wb2osz/direwolf/issues/489
- GCC 手册,Options Controlling C Dialect(
-fno-builtin):https://gcc.gnu.org/onlinedocs/gcc/C-Dialect-Options.html - GCC 手册,Builtins for C Library Functions:https://gcc.gnu.org/onlinedocs/gcc/Library-Builtins.html