21.5 易于移植的扩展函数
在日常系统级 C 语言开发中,像 strdup、strlcpy、memmem 与 strsep 这类扩展函数十分常见。它们往往比基础的标准库接口更契合具体业务场景,实现体量也多在几十行之内。然而在长期的标准演进中,它们并不在当时的 ISO C 核心规范内(部分接口直至 C23 或 POSIX.1-2024 才正式收录),不同操作系统与编译环境对这些符号的暴露条件、命名规范甚至边界语义存在显著差异。
在第 16 章的 wordfreq 案例中曾出现过典型的一幕:代码在 Windows 平台顺利编译,搬到 Linux 上启用 -std=c17 却直接报出隐式声明错误。根源便在于严格标准模式下 glibc 会主动隐藏非标准拓展接口。
本节我们将这批常用扩展函数抽象为统一的 mdr_ 前缀版本,分别纳入头文件 mdr_ext.h 与实现文件 src/mdr_ext.c。整个实现过程的核心考量在于构建确定性的跨平台契约:厘清原生接口在不同工具链中的可用边界,在标准 C 层面提供无外部依赖的回退实现,并确保自建逻辑与业界主流规范严密咬合。
1. 扩展函数的移植边界
这批常用的非标准辅助函数主要派生自三大历史源流:
- POSIX 规范族:例如动态复制字符串的
strdup、strndup,返回末尾指针的stpcpy,以及可重入分词函数strtok_r; - BSD 家族(尤其是 OpenBSD):例如主打定长缓冲区安全截断的
strlcpy、strlcat,以及保留空字段的分词接口strsep; - GNU/Linux 及其他运行时扩展:例如在任意内存块中检索子序列的
memmem。
这些函数被纳入正式标准的节奏极为迟缓:stpcpy 早在 POSIX.1-2008 便已确立;strdup 与 strndup 直到 C23 才终于补录进 ISO C 规范;而 strlcpy、strlcat 与 memmem 则直到 POSIX.1-2024(Issue 8)才被正式收录。
规范的收录进度与现实中“当下能否开箱即用”完全是两个维度的概念。下表整理了在四种典型编译配置下,通过启用 -Werror=implicit-function-declaration 进行符号可编译性探测的实际表现(只要头文件未主动暴露原型,编译即被判定为失败):
| 函数 | 来源 | 现状 | glibc 2.41 -std=c17 | glibc 2.41 -std=gnu17 | MinGW-w64 UCRT 13.2 | MSVC 14.51 |
|---|---|---|---|---|---|---|
strdup | POSIX | C23 起进标准 | 不声明 | 声明 | 声明 | 声明(另有 _strdup) |
strndup | POSIX | C23 起进标准 | 不声明 | 声明 | 不声明 | 不声明 |
stpcpy | POSIX 2008 | 非 ISO C | 不声明 | 声明 | 不声明 | 不声明 |
strlcpy | OpenBSD | POSIX Issue 8 | 不声明 | 声明(2.38 起) | 不声明 | 不声明 |
strlcat | OpenBSD | POSIX Issue 8 | 不声明 | 声明(2.38 起) | 不声明 | 不声明 |
memmem | GNU/BSD | POSIX Issue 8 | 不声明 | 声明 | 不声明 | 不声明 |
strsep | BSD | 非标准 | 不声明 | 声明 | 不声明 | 不声明 |
strtok_r | POSIX | 非 ISO C | 不声明 | 声明 | 声明 | 不声明 |
strcasecmp | POSIX/BSD | 非 ISO C | 不声明 | 声明 | 声明 | 不声明(用 _stricmp) |
在同一套 glibc 运行时下,仅仅从 -std=gnu17 切换为 -std=c17,整张表格的符号声明状态便发生剧烈反转。其深层机制在于:严格标准模式会在预处理期定义 __STRICT_ANSI__,促使系统头文件 <string.h> 屏蔽掉所有非 ISO C 既定接口。此时若想重新引入这些符号,必须显式定义 _POSIX_C_SOURCE 或 _DEFAULT_SOURCE 等功能特性测试宏(详细机制可参阅功能特性测试宏)。
扩展函数的可用性由三项条件共同决定
判断一个扩展函数在目标平台是否可以直接调用,必须同时满足三个层面的约束:
- 库运行时与头文件版本:底层 C 运行时库是否已经随附了该符号及其原型定义;
- 标准模式与宏开关:当前的语言标准方言是否允许暴露该接口,或是否显式开启了匹配的功能特性测试宏;
- 平台命名与符号映射:目标工具链是否采用了下划线私有前缀命名(例如 MSVC CRT 体系下的
_strdup、_stricmp、_strnicmp)。
只有三项条件同时成立,接口才处于安全可用状态;脱离编译器方言与命名约定而单看系统库版本,极易引发难以排查的跨平台编译中断。
观察表格中第五列(glibc 扩展模式)与第七列(MSVC)的对比,更能直观体会跨平台工程的割裂感:-std=gnu17 只是 GCC/Clang 阵营特有的默认语法方言,而在 MSVC 生态中既不存在 GNU 方言,也不支持 _DEFAULT_SOURCE 开关。这就要求工程库必须在自身架构层设计稳健的中立适配策略。
2. strdup 与 strndup
在动态字符串处理场景中,strdup 的职责极其直观:动态分配一块恰好足以容纳源字符串(连同结尾的终止空字符)的堆内存,并将内容完整复制进去:
char *mdr_strdup(const char *s) {
size_t len = mdr_strlen(s);
char *copy = (char *)malloc(len + 1);
if (copy == NULL) {
return NULL;
}
mdr_memcpy(copy, s, len + 1);
return copy;
}2
3
4
5
6
7
8
9
10
末尾增加的 1 个字节至关重要,专门用于存放空字符 \0。在第 13 章中,未经验收的 dup_string 曾因漏算这 1 字节导致 AddressSanitizer 精确报告堆缓冲区溢出(heap-buffer-overflow)。在动态复制逻辑中,内存分配大小必须严格与「内容长度 + 终止符」对齐。
strndup 则增加了一个上限形参 n,表示最多从源序列中截取 n 个有效字节并追加终止空字符。需要特别注意:strndup 对源数据对象的契约比常规字符串函数更为宽松。POSIX Issue 8 明确规定,调用 strndup 时,实参对象只需保证在 n 且完全不包含 \0 的原始字符数组,操作也完全合法且安全:
char *mdr_strndup(const char *s, size_t n) {
size_t len = mdr_strnlen(s, n);
char *copy = (char *)malloc(len + 1);
if (copy == NULL) {
return NULL;
}
mdr_memcpy(copy, s, len);
copy[len] = '\0';
return copy;
}2
3
4
5
6
7
8
9
10
11
正因如此,获取有效长度时必须调用受限的 mdr_strnlen。若不慎误用无界的 mdr_strlen,算法将一路越过数组边界扫描未初始化的内存,直至碰巧踩到某个零字节引发段错误或脏数据泄漏。在工程测试用例中,我们专门构造了一个未封口的定长数组来验证这一契约:
const char raw[5] = {'a', 'b', 'c', 'd', 'e'}; /* 没有终止空字符 */
char *p = mdr_strndup(raw, sizeof raw);
printf("\"%s\" length %zu\n", p, strlen(p));
free(p);2
3
4
5
在 Linux 环境下开启 ASan 编译执行:
$ gcc -std=c17 -Wall -Wextra -g -fsanitize=address,undefined \
-Iinclude asan_dup.c src/mdr_ext.c src/mdr_string.c src/mdr_memory.c -o asan_dup
$ ./asan_dup
"abcde" length 5
"Hello"2
3
4
5
raw 数组虽然仅包含 5 个有效字节且末尾未封口,mdr_strndup 严格在 5 字节边界内完成采样,随后在堆上分配合理的 6 字节存储并填入终止符。与标准 strdup 相同,返回的堆内存生命周期由调用方负责,需显式调用 free 释放。
3. stpcpy
在常规的字符串复制接口中,strcpy 始终返回目标缓冲区的起始地址。当我们需要执行链式拼接时,这种设计就会带来显著的性能损耗:由于不知道前一次写入停留在何处,后续每一次拼接都必须调用 strcat 从头重新线性扫描 \0。
stpcpy 针对这一痛点调整了返回契约:它直接返回目标缓冲区中新写入的终止空字符所在地址。有了这个尾随指针,多段文本拼接便可以直接首尾相接,免去了一切冗余的末尾探查:
strcat(buf, "log/");
strcat(buf, "2026/");
strcat(buf, "app.log");2
3
char *end = mdr_stpcpy(buf, "log/");
end = mdr_stpcpy(end, "2026/");
end = mdr_stpcpy(end, "app.log");2
3
在上面的三段路径拼接中,strcat 在后两次调用时必须分别重新遍历 "log/" 与 "log/2026/" 的全部字符;而 stpcpy 始终手握写入前哨指针,每一次写入都是常数开销。当拼接段数较多或前缀文本较长时,strcat 链的总时间复杂度会呈二次方 stpcpy 则将整体耗时稳定压制在线性
$ gcc -std=c17 -Wall -Wextra -Iinclude cat_chain.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o cat_chain
$ ./cat_chain
strcat chain: "log/2026/app.log"
stpcpy chain: "log/2026/app.log" (16 chars)2
3
4
5
end - buf 的差值恰好等于累计写入的字符个数(不计终止空字符),该指针位置也正处于下一次追加的最佳锚点。其基础实现逻辑十分精炼:
char *mdr_stpcpy(char *restrict dest, const char *restrict src) {
while ((*dest = *src) != '\0') {
dest++;
src++;
}
return dest;
}2
3
4
5
6
7
循环条件在逐字节搬运字符的同时完成终止符判定,跳出循环时 dest 指针恰好停留在新写入的 \0 处。与标准 strcpy 一致,这里借助 restrict 限定源与目标内存区间绝不能发生物理重叠。
4. strlcpy 与 strlcat
在 C 语言的历史实践中,针对定长字符数组的溢出防护一直饱受争议。标准库中的 strncpy 因不会自动保证以空字符结尾且在空间宽裕时存在冗余清零开销,常常成为隐患温床。OpenBSD 团队为此设计了 strlcpy 与 strlcat,其核心理念在于面向目标缓冲区容量进行绝对防御。
strlcpy 确立了三条核心契约:
- 严格限制写入上限:至多向目标缓冲区拷贝
dsize - 1个有效字符; - 强制保证以空字符结尾:只要
dsize > 0,写入的末尾字节永远为\0; - 确定性返回预期全长:返回值始终恒等于源串的真实长度
strlen(src)。
第三条返回值契约构成了截断检测的基石:由于返回值反映的是“若想完整容纳源串所需占用的字符空间”,因此只要发现 mdr_strlcpy(...) >= dsize,调用方即可明确判定目标文本发生了截断。
size_t mdr_strlcpy(char *restrict dst, const char *restrict src, size_t dsize) {
size_t len = mdr_strlen(src);
if (dsize > 0) {
size_t copy = len < dsize - 1 ? len : dsize - 1;
mdr_memcpy(dst, src, copy);
dst[copy] = '\0';
}
return len;
}2
3
4
5
6
7
8
9
10
11
当形参 dsize 为 0 时,函数必须保持目标内存处于未触碰状态,但依旧需要返回源串长度,以便调用方据此重新计算动态分配规模。此处的条件分支不可或缺:若缺乏非零保护,无符号算术表达式 dsize - 1 将立即下溢回绕为 SIZE_MAX,从而导致后续内存拷贝陷入巨量越界写入。
strlcat 则负责在现有字符串末尾安全追加内容,确保整个缓冲区的总占用绝不超过 dsize 字节。其返回值定义为拼接前的目标初始有效长度与源串有效长度之和(即完整拼接所需的总字符数)。如果目标缓冲区本身已被填满且未找到任何终止符,函数将直接返回 dsize + strlen(src),该值必然大于或等于 dsize,从而向外部明确传递截断状态:
size_t mdr_strlcat(char *restrict dst, const char *restrict src, size_t dsize) {
size_t dlen = mdr_strnlen(dst, dsize);
size_t slen = mdr_strlen(src);
size_t copy;
if (dlen == dsize) {
return dsize + slen;
}
copy = dsize - dlen - 1;
if (copy > slen) {
copy = slen;
}
mdr_memcpy(dst + dlen, src, copy);
dst[dlen + copy] = '\0';
return dlen + slen;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
在工程中的典型调用范式如下:
char path[16];
size_t need;
/* strlcpy 检测截断:返回值不小于缓冲区大小 */
need = mdr_strlcpy(path, "/usr/local/share/mdr", sizeof path);
printf("strlcpy -> \"%s\", need %zu, truncated %s\n", path, need,
need >= sizeof path ? "yes" : "no");
need = mdr_strlcpy(path, "/etc/mdr", sizeof path);
printf("strlcpy -> \"%s\", need %zu, truncated %s\n", path, need,
need >= sizeof path ? "yes" : "no");
/* strlcat 追加并检测截断 */
mdr_strlcpy(path, "/etc", sizeof path);
need = mdr_strlcat(path, "/mdr.conf", sizeof path);
printf("strlcat -> \"%s\", need %zu, truncated %s\n", path, need,
need >= sizeof path ? "yes" : "no");2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$ gcc -std=c17 -Wall -Wextra -Iinclude ext_demo.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o ext_demo
$ ./ext_demo
strlcpy -> "/usr/local/shar", need 20, truncated yes
strlcpy -> "/etc/mdr", need 8, truncated no
strlcat -> "/etc/mdr.conf", need 13, truncated no
stpcpy -> "log/2026/app.log" (length 16)2
3
4
5
6
7
第一行示例中,16 字节的缓冲区面对 20 字节的源串,写入了前 15 个字符并封以空字符,返回值为 20。调用方只需比对返回值与容量,便能立刻获悉截断事实以及完整保存所需的真实尺寸。第二行中 8 字节有效数据恰好容纳;第三行中追加路径后的总长度为 13 字符,同样安全保留在 16 字节的界限内。
对比传统 strncpy,这种返回值设计的工程收益十分直观:strncpy 在截断时可能遗漏末尾的 \0,且其返回值仅仅是目标首地址,调用方若想排查截断必须自行二次遍历。两者的使用模式对比十分悬殊:
char buf[16];
strncpy(buf, src, sizeof buf); /* 可能不带终止空字符 */
/* 不知道有没有截断,也不知道完整长度 */2
3
char buf[16];
if (strlcpy(buf, src, sizeof buf) >= sizeof buf) {
/* 发生了截断,缓冲区里是前 15 个字符 */
fprintf(stderr, "path too long, need %zu bytes\n", strlen(src) + 1);
}2
3
4
5
左栏代码一旦源数据长度触及或超过缓冲区容量,数组末尾将缺少终止符,传给后续字符串处理例程会直接引发未定义行为。而右栏的 strlcpy 无论是否发生截断,目标对象在内存中始终保持合法的以空字符结尾的 C 字符串形态,且为调用方留出了清晰的错误处理分界线。
在需要引入格式化参数(如整数拼接、字段对齐)的复杂场景下,标准接口 snprintf 同样采用“返回假设空间充裕时应写入的总长度”这一契约模型,同样通过比对返回值与缓冲区容量判断截断。在工程选型中,若纯粹处理定长缓冲区的文本搬运与追加,strlcpy 与 strlcat 的语义更轻量且无格式化解析开销;而在长文本组合或动态规模场景下,优先计算长度并进行堆分配更为合适。需要指出的是,strlcpy 也有其历史争议点:由于契约强制计算源串全长,即便目标仅能接收几十个字节,底层仍需完整遍历超长源文本,在长文本场景下未能规避遍历开销。
5. memmem
标准库的 strstr 仅适用于以空字符结尾的普通文本;一旦检索目标转移到二进制文件流、网络数据包或任意包含 \0 字节的内存块,以零作为结束符的假设便不复成立。memmem 接受两组显式的「起始指针 + 字节长度」,能够无视内容边界在任意二进制缓冲区中检索目标子序列。以解析文件流中嵌入的 PNG 魔数特征为例:
void *mdr_memmem(const void *haystack, size_t haystacklen, const void *needle,
size_t needlelen) {
const unsigned char *h = (const unsigned char *)haystack;
const unsigned char *n = (const unsigned char *)needle;
size_t remaining = haystacklen;
if (needlelen == 0) {
return (void *)haystack;
}
while (remaining >= needlelen) {
const unsigned char *p = (const unsigned char *)mdr_memchr(
h, n[0], remaining - needlelen + 1);
size_t skipped;
if (p == NULL) {
break;
}
if (mdr_memcmp(p, n, needlelen) == 0) {
return (void *)p;
}
skipped = (size_t)(p - h) + 1;
h += skipped;
remaining -= skipped;
}
return NULL;
}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
此处采用了一种在小规模数据下十分高效实用的两阶段扫描法:首先利用 memchr 高效锁定子模式的首字节候选位置;一旦首字节匹配,再通过 memcmp 验证后续完整内容。首字节的探查范围被严格限制在 remaining - needlelen + 1 内,因为超出此限后的剩余空间已经不足以容纳整个子模式。当模式串长度 needlelen 为 0 时,函数约定直接返回源起始地址 haystack,与标准 strstr 对空模式串的处理逻辑完全对齐。这种边界规格必须显式落实,避免调用方在传入零长匹配时遭遇未定义行为。
在 PNG 文件头的 16 个字节中查找几段关键特征标记:
$ gcc -std=c17 -Wall -Wextra -Iinclude memmem_demo.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o memmem_demo
$ ./memmem_demo
signature at offset 4
IHDR at offset 12
length field at offset 8
IEND at offset -12
3
4
5
6
7
PNG 规范中魔数签名的字节序列为 89 50 4E 47 0D 0A 1A 0A,其中连续包含了换行符与空控制字符。若用 strstr 检索,遇到空字符就会提前截断误判。而在 memmem 的守护下,二进制跨度被精确限定。示例最后一行检索 IEND 块返回 -1(表示未找到),准确反映了文件头部仅包含元数据而未见文件尾的客观事实。
6. strtok_r 与 strsep
在 21.2 讨论标准库 strtok 时,我们指出了其严重的架构缺陷:该函数将上下文扫描游标保存在内部的静态对象中,直接导致该接口既不支持多线程并发,也无法在嵌套循环中安全复用。一旦在解析外层记录的同时启动内层字段分词,内层的迭代操作就会立即覆写静态游标,导致外层循环的状态被彻底破坏。
解决这一缺陷的经典工业方案是 POSIX 定义的 strtok_r(后缀 _r 代表 reentrant,即可重入)。它将扫描上下文的持有权转移给调用方,通过传入一个外部对象指针显式维持各层分词的独立进度。重构前文按行与按列双层切分的场景:
const char text[] = "red,green,blue\ncyan,magenta,yellow";
char copy[sizeof text];
char *save_line = NULL;
char *line;
unsigned long lines = 0;
unsigned long fields = 0;
memcpy(copy, text, sizeof text);
printf("按行切分,再对每一行按逗号切分:\n");
for (line = mdr_strtok_r(copy, "\n", &save_line); line != NULL;
line = mdr_strtok_r(NULL, "\n", &save_line)) {
char *save_field = NULL;
char *field;
lines++;
printf(" 行 %lu: %s\n", lines, line);
for (field = mdr_strtok_r(line, ",", &save_field); field != NULL;
field = mdr_strtok_r(NULL, ",", &save_field)) {
fields++;
printf(" 字段: %s\n", field);
}
}
printf("共 %lu 行、%lu 个字段(期望 2 行、6 个字段)\n", lines, fields);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ gcc -std=c17 -Wall -Wextra -Iinclude tok_r_fixed.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o tok_r_fixed
$ ./tok_r_fixed
按行切分,再对每一行按逗号切分:
行 1: red,green,blue
字段: red
字段: green
字段: blue
行 2: cyan,magenta,yellow
字段: cyan
字段: magenta
字段: yellow
共 2 行、6 个字段(期望 2 行、6 个字段)2
3
4
5
6
7
8
9
10
11
12
13
运行结果清晰输出了两行、六个独立字段,完全符合预期。外层遍历与内层遍历分别依托各自的游标对象 save_line 与 save_field 维持状态,二者完全解耦,互不干扰。
通过一个更紧凑的键值列表切分用例,可以更直观地观察静态游标失效的过程:
char text[] = "alpha:1,2;beta:3,4";
char *outer = mdr_strtok(text, ";");
while (outer != NULL) {
printf("outer %s ->", outer);
char *inner = mdr_strtok(outer, ",:");
while (inner != NULL) {
printf(" [%s]", inner);
inner = mdr_strtok(NULL, ",:");
}
printf("\n");
outer = mdr_strtok(NULL, ";");
}2
3
4
5
6
7
8
9
10
11
12
13
$ gcc -std=c17 -Wall -Wextra -Iinclude nested_tok_bad.c src/mdr_string.c src/mdr_memory.c -o nested_tok_bad
$ ./nested_tok_bad
outer alpha:1,2 -> [alpha] [1] [2]
$ gcc -std=c17 -Wall -Wextra -Iinclude nested_tok.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o nested_tok
$ ./nested_tok
outer alpha -> [alpha] [1] [2]
outer beta -> [beta] [3] [4]2
3
4
5
6
7
8
在错误版本中,控制台仅输出了第一组 alpha 便戛然而止:当内层循环把 alpha:1,2 遍历完毕后,底层全局静态游标已被推进到字符串尽头;外层下一次尝试取下一个分号时直接拿到了空指针,导致后续的 beta 记录被无声吞没。两套程序的根本差异仅在于:mdr_strtok_r 要求每个循环作用域提供独立的上下文追踪指针对象。
mdr_strtok_r 将解析进度交由外部指针对象 saveptr 托管,使得嵌套或多线程并发安全成为可能:
char *mdr_strtok_r(char *restrict str, const char *restrict delim,
char **restrict saveptr) {
char *start;
if (str != NULL) {
*saveptr = str;
}
if (*saveptr == NULL) {
return NULL;
}
start = *saveptr + mdr_strspn(*saveptr, delim);
if (*start == '\0') {
*saveptr = NULL;
return NULL;
}
*saveptr = start + mdr_strcspn(start, delim);
if (**saveptr != '\0') {
**saveptr = '\0';
(*saveptr)++;
} else {
*saveptr = NULL;
}
return start;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
与聚焦可重入性的 strtok_r 不同,来自 BSD 谱系的 strsep 致力于修正分词本身的业务逻辑缺陷。strtok 家族有一条默认行为:它会把任意连续出现的分隔符视作单个分隔标记予以合并跳过。例如面对 "a,,b" 时,strtok 只能切出 a 与 b 两个片段;而在严格的位置对齐协议(如 CSV 格式)中,两个连续逗号明确表达着“中间存在一个空字段”。
strsep 严格践行“见界即分”的原则,遇到分隔符便切分一刀,完整保留空字段内容。以解析包含缺失项的键值配置行为例:
$ gcc -std=c17 -Wall -Wextra -Iinclude sep_demo.c src/mdr_ext.c src/mdr_string.c \
src/mdr_memory.c -o sep_demo
$ ./sep_demo
strtok: "name=wf;;level=;mode=fast" -> [name=wf] [level=] [mode=fast]
strsep: "name=wf;;level=;mode=fast" -> [name=wf] [] [level=] [mode=fast] (4 fields)2
3
4
5
针对同一行配置输入,strtok 仅提取出 3 个有效字段,而 strsep 准确分离出 4 个字段。在 strsep 的解析结果中,name=wf 之后紧跟的空字符串清晰记录了原始文本中由 ;; 引起的空缺项;而 strtok 则将这层结构信息直接抹除。在数据协议中,若字段位置具备强位置语义(如 CSV 列映射),采用 strsep 才能忠实还原数据全貌;若语法本身视连续空白符为普通分隔边界,strtok 的合并特性则更契合直觉。
在实现机制上,strsep 的逻辑非常克制:它仅操作调用方传入的二级指针对象 stringp,每次从 *stringp 起始位置推进至首个分隔符处将其置空,并将 *stringp 重置到下一项的起始位置;当所有内容扫描完毕后,将 *stringp 置为空指针。
在 Windows MSVC 环境下,与之对标的跨平台安全接口被命名为 strtok_s。MSVC CRT 的 strtok_s 形参签名与返回值契约同 POSIX strtok_r 基本完全对应(待切分串、分隔符集合、外部上下文指针)。需要注意的是,C11 规范在其可选的附录 K(Bounds-checking interfaces)中同样定义了一个名为 strtok_s 的函数,但其签名中额外插入了目标缓冲区最大长度参数。在跨平台工程中遇到该符号时,必须核实具体头文件所属的规范谱系,防止形参错位。
7. 只按 ASCII 不区分大小写的比较
在文本比较中,直接调用 POSIX 的 strcasecmp 往往潜藏着隐蔽的本地化陷阱:该接口的大小写折叠逻辑高度依赖当前运行时的区域设置(Locale)。例如在土耳其语环境(tr_TR)下,无点小写字母 ı 与带点大写字母 İ 才是配对字母,普通的小写 'i' 折叠后的表现与标准 ASCII 预期大相径庭。
对于 HTTP 协议头部(如 Content-Type)、命令行参数或配置文件关键字这类工业协议解析任务,我们需要的并不是随用户操作系统语言变动的本地化折叠,而是永远锁死在标准 7 位 ASCII 范围内的确定性规则:
static unsigned char ascii_lower(unsigned char c) {
if (c >= 'A' && c <= 'Z') {
return (unsigned char)(c - 'A' + 'a');
}
return c;
}
int mdr_ascii_casecmp(const char *s1, const char *s2) {
const unsigned char *p1 = (const unsigned char *)s1;
const unsigned char *p2 = (const unsigned char *)s2;
while (*p1 != '\0' && ascii_lower(*p1) == ascii_lower(*p2)) {
p1++;
p2++;
}
return (int)ascii_lower(*p1) - (int)ascii_lower(*p2);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
此处不直接调用 <ctype.h> 中的 tolower,主要出于两重稳健性考虑:
- 规避本地化污染:标准库
tolower同样受全局区域设置牵制,无法保证全局一致的纯 ASCII 行为; - 防范类型提升陷阱:标准库字符判断宏与函数的形参契约强制要求实参必须是
unsigned char可表示的数值或常量EOF。若在char默认为有符号类型的平台上直接传入带负值的字符,会直接引发数组越界或未定义行为。
自主编写转换函数仅需两次简单的区间比较,便可将映射逻辑完全收敛至标准 ASCII。在命名上冠以 ascii_casecmp,也能在团队协作中清晰传达“不随本地语言环境波动”的工程语义。
验证该映射是否严密的最直接手段,是对所有可能的 256×256 个单字节组合进行全覆盖矩阵测试,确保除了标准的英文字母区间外,没有任何标点符号或控制字符发生异常关联:
$ gcc -std=c17 -Wall -Wextra -O2 casecmp256.c -o casecmp256
$ ./casecmp256
checked 65025 byte pairs, mismatches 0
('A','a')=0 ('a','A')=0 ('Z','[')=31 ('@','`')=-322
3
4
此处 65025 项对应排除终止空字符后的 [、\、] 等)以及边界字符 @、` 均保持着严格的序关系,未被错误折叠。
衍生接口 mdr_ascii_ncasecmp 进一步引入了长度上限 n,当 n 为 0 时立即返回 0。POSIX 中的 strcasecmp/strncasecmp 与 MSVC CRT 中的 _stricmp/_strnicmp 承担着相近的角色,但后者往往受制于 CRT 的特定代码页与区域设置策略。
8. 在项目中提供这些函数
在完成这组扩展函数的封装后,在具体工程落地时通常有两种集成策略:
第一种策略是无条件静态绑定自研版本。即工程始终编译并链接自身的 mdr_ 系列函数。鉴于这批辅助函数实现极为精巧,占用的二进制体积通常只有数十到数百字节,却能彻底抹平跨平台的符号缺失与语义异构风险,调用方无需在业务逻辑中编写任何平台分支宏。对于 strlcpy、strsep 这类平台分歧极大的函数,这种策略最为稳妥省心。
第二种策略是构建期动态特性探测。当系统底层实现具备压倒性性能优势时(例如 glibc 中的 memmem 采用了深度的 Two-Way 算法优化,在超长文本匹配中远胜双重循环),可以通过构建系统(如 CMake 的 check_symbol_exists)在配置阶段探测环境是否提供该符号。若存在则通过配置宏重定向至系统实现,若缺失则平滑回退至自建版本。
回退实现切忌直接占用系统原生函数名
在提供补充实现时,函数必须使用 mdr_strlcpy 这种具备项目命名空间的前缀,决不可直接命名为 strlcpy。一旦目标系统的新版本(如 glibc 2.38+)开始在标准头文件中暴露同名原型,无前缀的自建函数就会与系统声明发生重定义冲突;若效仿部分开源项目使用宏替换原生名称(如 #define strlcpy mdr_strlcpy),系统头文件在包含时其函数原型便会被宏强行改写,进而直接引发编译器语法报错。此外,现代编译器常将标准名视作内建优化入口(Built-in)。保持自研前缀是保障长期可移植性的坚实底线。
无论采取哪种集成路线,双轨一致性测试都不可或缺。系统自带的 strlcpy 与自研的 mdr_strlcpy 在发生截断、边界对齐等边缘场景下的行为必须百分之百等价。在后续 21.7 中,我们将建立差分测试框架,将完全相同的输入数据集分别注入系统原生实现与回退实现,校验其返回值与输出内存镜像是否字符级一致。
构建期探测本身同样需要警惕经验主义陷阱:例如不可在 CMake 中武断编写“若操作系统为 Windows 则必定没有 strlcpy”之类的粗糙判定。不同发行版本的 MinGW 或 MSVC 都在持续跟进系统接口,最可靠的判定标准永远是针对具体头文件能否被包含、特定符号是否能被解析进行原子化编译探测。
关于 strlcpy 与 strlcat 的返回值,下列说法正确的是哪个?
下面关于 strndup 的说法,哪些是正确的?
9. 习题
9.1 巩固
正文那张可用性表是用「只引用函数名」的办法测出来的。写一个脚本,对 strdup、strndup、stpcpy、strlcpy、strlcat、memmem、strsep、strtok_r、strcasecmp 九个名字各生成一个只包含 int main(void) { return (int)NAME; } 的源文件,加上 -Werror=implicit-function-declaration 编译,打印「声明」或「缺失」。strcasecmp 要额外包含 <strings.h>,在脚本注释里写明。
(1) 在 -std=c17 和 -std=gnu17 下各跑一遍,与正文的表对照。先注明你用的是什么平台:在 MinGW-w64 上两种模式的结论可能完全一样,在 glibc 上 strdup、strndup、stpcpy 这些会整片翻转。把这一差别记下来,并在报告里说明原因。
(2) 在 MinGW-w64 上 strdup 在两种模式下都声明,在 glibc 上 -std=c17 却不声明。用正文的三条标准解释:这是其中哪一条在起作用。
(3) 去掉 -Werror=implicit-function-declaration 再编译缺失声明的调用,编译器会怎么处理返回值?以 64 位平台上的 size_t n = strlcpy(buf, src, size); 为例说明为什么这种「能编过」的代码更危险:返回类型被当成 int,size_t 拿到的值与真实长度无关。
先按规格算出下面程序的每一行输出,再编译运行核对:
#include "mdr_ext.h"
#include <stdio.h>
int main(void) {
char buf[24] = "abcdefghij";
size_t n;
n = mdr_strlcpy(buf, "mdr-library", 12);
printf("1: %d \"%s\"\n", (int)n, buf);
n = mdr_strlcpy(buf, "mdr", 0);
printf("2: %d \"%s\"\n", (int)n, buf);
n = mdr_strlcat(buf, "defghijklmn", 12);
printf("3: %d \"%s\"\n", (int)n, buf);
n = mdr_strlcpy(buf, "", sizeof buf);
printf("4: %d \"%s\"\n", (int)n, buf);
n = mdr_strlcat(buf, "xy", sizeof buf);
printf("5: %d \"%s\"\n", (int)n, buf);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
(1) 第 2 行 dsize 为 0,缓冲区里为什么仍是上一次的内容,返回值 3 又有什么用。
(2) 第 3 行一个字符也没有追加,返回值却是 22。算出 dlen、copy 各是多少,说明这条结果怎么用一句话读出来(提示:返回值不小于 dsize 表示目标已满)。
(3) 第 5 行如果换成一个内容为空串、长度只有 2 的数组,mdr_strlcat(tiny, "xy", sizeof tiny) 返回什么?数组里留下几个字符?有没有越界写?用这一点说明 strlcat 与 strcat 在对容量的处理上差在哪里。
下面这个 strlcat 的写法通过了一半的测试,缺陷只在目标缓冲区快满时出现:
static size_t buggy_strlcat(char *dst, const char *src, size_t dsize) {
size_t dlen = mdr_strnlen(dst, dsize);
size_t slen = mdr_strlen(src);
size_t copy = dsize - dlen;
if (copy > slen) {
copy = slen;
}
mdr_memcpy(dst + dlen, src, copy);
dst[dlen + copy] = '\0';
return dlen + slen;
}2
3
4
5
6
7
8
9
10
11
12
(1) 用 char buf[10] = "abc"; 调用 buggy_strlcat(buf, "defghi", 9),写出缓冲区内容和返回值。哪一条语句违反了「最多写满 dsize 个字节」?
(2) 返回值是否也错了?说明它和写入位置为什么无关。
(3) 用 diff_guard 把数据区按 dsize 精确分配,运行后应看到 GUARD strlcat: data[9] is 00, expected A5。把数据区改成 dsize + 1 字节再试一次,缺陷还在,检查却通过了;说明这与 21.7 第 4.1 节「分配大小要与逻辑长度严格对应」是同一件事。
(4) 给出修正后的那一行,并说明为什么 mdr_strlcpy 没有这个问题。
下面这段缓冲区含 0x00,用 strstr 找不到全部内容。先写出七次 mdr_memmem 调用各自的返回偏移(未找到写 -1),再运行核对:
const unsigned char bin[12] = {0x00, 0x01, 0x00, 0x01, 0x02, 0x00,
0x01, 0x02, 0x03, 0xFF, 0x00, 0x01};
a = mdr_memmem(bin, 12, "\x00\x01", 2);
b = mdr_memmem(bin, 12, "\x01\x02\x03", 3);
c = mdr_memmem(bin, 12, "\x03\xFF", 2);
d = mdr_memmem(bin, 12, "", 0);
e = mdr_memmem(bin, 5, "\x02\x00", 2);
f = mdr_memmem(bin, 5, "\x01\x02", 2);
g = mdr_memmem(bin, 1, "\x00\x01", 2);2
3
4
5
6
7
8
9
10
(1) e 和 f 的 haystack 长度都是 5,为什么一个不命中、一个命中在偏移 3?
(2) g 的 haystack 只有 1 字节,needlelen 为 2,实现里的 remaining >= needlelen 起了什么作用?
(3) 把 b 的 haystack 长度从 12 改成 8,结果变成多少?这说明长度参数比缓冲区实际大小更严格时会发生什么。
9.2 编程
一条 14 字节的定长记录保存日期时间,字段之间没有分隔符,整条记录也不以空字符结尾,后面紧跟一段别的数据:
static const char block[21] = {'2', '0', '2', '6', '1', '0', '2', '2',
'0', '9', '4', '5', '0', '0',
'T', 'A', 'I', 'L', '!', '?', '\0'};2
3
用 mdr_strndup 写一个 char *field(const char *record, size_t offset, size_t width),把年、月、日、时、分、秒各取成独立字符串,再取一个宽度为 0 的字段,输出:
2026-10-22 09:45:00 empty=""要求:
- 每个字段的存储都由调用方
free。 - 再把某一处
mdr_strndup(record + offset, width)换成mdr_strdup(record + offset),运行并记录输出,说明strndup的「最多读 n 个字节」在这里解决了什么问题。 - 在 Linux 上用 ASan 构建跑一次,确认正确版本没有越界读。
验收:正确版本输出与上面逐字相同,-Wall -Wextra 无警告。
mdr_memmem 只返回第一处命中。在它上面写 size_t find_all(const unsigned char *hay, size_t hlen, const unsigned char *needle, size_t nlen, size_t *offs, size_t cap),找出全部出现位置,允许重叠:命中后从命中的下一个字节继续,而不是跳过整个 needle。
用下面的数据验证,输出偏移与个数:
aaaa / aa -> 3 处,偏移 0 1 2
abcabcabc / abc -> 3 处,偏移 0 3 6
abc / 空 needle -> 0 处
abc / abcd -> 0 处
12 字节二进制 / 00 01 -> 4 处,偏移 0 2 5 102
3
4
5
其中二进制 haystack 取 00 01 00 01 02 00 01 02 03 FF 00 01。
要求:
cap是offs能容纳的个数,命中数超过cap时只写前cap个,返回值仍是真实命中数。needle为空、needle比剩余部分长时都返回 0,且不写offs。- 用第一个例子说明为什么不能写成
left -= nlen这种「跳过整个 needle」的推进方式。
写一个程序,对同一行文本分别用 mdr_strsep 和 mdr_strtok_r 切分,统计字段个数,输出下面这张表:
a,b,c strsep=3 strtok=3 same=yes
,a strsep=2 strtok=1 same=no
a,,b strsep=3 strtok=2 same=no
a, strsep=2 strtok=1 same=no
Ada,36,,Beijing, strsep=5 strtok=3 same=no
Ada,36,,Beijing strsep=4 strtok=3 same=no2
3
4
5
6
要求:
- 每次切分前把原文本复制到两份独立数组里,两个函数各用一份。
- 最后一行
Ada,36,,Beijing没有结尾逗号,strsep仍比strtok_r多一个字段,指出多出来的是哪一段、它来自哪里。 - 一句话说明「字段与位置一一对应」的 CSV 该用哪一个,「允许重复分隔符」的格式该用哪一个。
验收:输出逐列与上表相同,-Wall -Wextra 无警告。
9.3 综合与挑战
写 size_t join_path(char *dst, size_t size, const char *const *parts, size_t count),把若干段用 / 连成一条路径,返回值是「装下完整结果需要的字节数(含终止空字符)」,与 strlcpy 的约定一致。要求同时用上 mdr_stpcpy 与 mdr_strlcpy、mdr_strlcat:
- 先扫一遍算出
need。 need < size时走快路径:用mdr_stpcpy链首尾相接,一次也不重新找末尾。need >= size时退回mdr_strlcat,保证截断后仍是合法字符串。size为 0 时一个字节都不写,只返回need。
用三段 {"log", "2026", "app.log"}、缓冲区预填 #,对 size 取 32、16、8、4、1、0 运行,期望:
size 32: "log/2026/app.log", need 16, truncated no
size 16: "log/2026/app.lo", need 16, truncated yes
size 8: "log/202", need 16, truncated yes
size 4: "log", need 16, truncated yes
size 1: "", need 16, truncated yes
size 0: need 16, untouched yes2
3
4
5
6
追问:(1) size 为 1 时结果为空串,哪一步写入了那个终止空字符?(2) 快路径里 need 恰好等于 size 时若误走快路径,会在哪个下标写出界?用防护字节验证一次。
写一个配置行解析器,输入是一行 键=值;键=值 形式的文本(值里可以有逗号分隔的列表):
Host=db;port=5432;Tags=fast,stable,new;broken;empty=;path=/usr/local/share/mdr-library-2026要求:
- 外层用
mdr_strtok_r按;切,每段再用strchr找=;找不到=的段落输出bad line: broken并继续。 - 键名用
mdr_ascii_casecmp与"tags"比较,所以Tags也要被识别成列表。 - 列表值用另一个
save指针再切一层,说明为什么两个save必须分开,换成mdr_strtok会发生什么。 - 值统一用
mdr_strlcpy抄进 16 字节的栈上缓冲区;被截断时输出[truncated, need N],其中 N 是strlcpy返回值。 - 空值输出
(empty),不能与前一条混淆。
验收输出:
Host -> db
port -> 5432
Tags -> fast, stable, new
bad line: broken
empty -> (empty)
path -> /usr/local/shar [truncated, need 33]2
3
4
5
6
正文只做了 mdr_ascii_casecmp,没有做不区分大小写的子串查找。实现
const char *mdr_ascii_strcasestr(const char *haystack, const char *needle);语义与 strstr 相同,但字母比较只折叠 A 到 Z;needle 为空时返回 haystack,找不到返回 NULL。
要求:
- 写一份「显然正确」的慢版本作参照:逐位置逐字符比较,折叠规则写在同一处或各自的辅助函数里,两处必须一致。
- 用差分测试验证:haystack 长度不超过 4、needle 长度不超过 2、字母表
{'a','b','A','B'}穷举;再加 256 个单字节 haystack,其中0x80以上的字节不能被当成任何字母。 - 用下面几组输入验证
"Content-Type: text/HTML; charset=utf-8":
text/html -> 偏移 14
TEXT/HTML -> 偏移 14
空 needle -> 偏移 0
text/xml -> -12
3
4
- 说明这个函数与
strcasestr的区别:后者受区域设置影响,前者不受,函数名里的ascii就是为了让调用方一眼看出这一点。
没有 GNU 扩展环境的读者可以只做前两问;有 strcasestr 的读者可以顺带跑一遍差分,把在 en_US.UTF-8 下两者不一致的输入记下来。
10. 参考资料
- POSIX Issue 8,
strlcpy:https://pubs.opengroup.org/onlinepubs/9799919799/functions/strlcpy.html - POSIX Issue 8,
memmem:https://pubs.opengroup.org/onlinepubs/9799919799/functions/memmem.html - POSIX Issue 8,
strdup:https://pubs.opengroup.org/onlinepubs/9799919799/functions/strdup.html - POSIX Issue 8,
stpcpy:https://pubs.opengroup.org/onlinepubs/9799919799/functions/stpcpy.html - POSIX Issue 8,
strtok(含strtok_r):https://pubs.opengroup.org/onlinepubs/9799919799/functions/strtok.html - Todd C. Miller、Theo de Raadt,strlcpy and strlcat: Consistent, Safe, String Copy and Concatenation,USENIX 1999:https://www.usenix.org/legacy/event/usenix99/full_papers/millert/millert.pdf
- 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
- Linux man-pages,
strsep(3):https://man7.org/linux/man-pages/man3/strsep.3.html - Microsoft Learn,
_strdup、_wcsdup、_mbsdup:https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/strdup-wcsdup-mbsdup - Microsoft Learn,
strtok_s:https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/strtok-s-strtok-s-l-wcstok-s-wcstok-s-l-mbstok-s-mbstok-s-l - cppreference,
strdup:https://en.cppreference.com/w/c/string/byte/strdup