21.3 内存函数
在日常处理连续数据搬移时,如果需要将一个数组中的五个元素整体向后平移一位,直觉上最直接的写法往往是 mdr_memcpy(a + 1, a, 5 * sizeof a[0])。然而在未开启编译器优化的调试模式下运行,控制台打印出的结果却可能变成灾难性的 1 1 1 1 1 1——数组中的每一个元素都被首个元素的值无情覆盖。发生这种覆灭的原因在于源区域与目标区域在物理内存上紧密重叠在一起;代码在从低地址向高地址逐字节拷贝的过程中,前一步刚刚写入目标的新值,在紧接着的下一步中又被当作源数据重新读取。在 C 标准中,memcpy 的前置条件明确排除了任何内存区域的交叠,上述代码直接构成了严重的前置条件违约。
本节我们将围绕无类型原生内存,系统实现五个核心内存操作函数:mdr_memmove、mdr_memcmp、mdr_memchr、mdr_memccpy 与 C23 引入的安全擦除接口 mdr_memset_explicit。与上一节的字符串函数相比,内存函数的核心差异在于其操作完全独立于终止空字符 \0:其处理的数据边界由调用方显式传入的字节计数值决定,因而能够原生支持任意二进制格式、结构体对象表示以及缺乏零结尾标记的硬件数据流。在完成接口实现之后,我们还将深入汇编层面,探究现代编译器两项极富启示性的底层优化行为:其一是自动将手写的逐字节循环模式识别并替换为库函数调用;其二是死代码消除优化如何导致清空敏感密钥缓冲区的操作被编译器直接整段删除。
1. 按字节访问对象
标准库内存函数的形参列表通常采用两个 void * 指针和一个表示尺寸的 size_t 计数。无类型指针 void * 能够平滑接收指向任何数据类型的对象指针,而在函数实现内部,我们统一将其强制转换为 unsigned char * 以便展开逐字节的读取与写入。
选择以 unsigned char 作为基本访问单元有着深厚的标准依据:一方面,C 标准允许平台将 char 视作有符号整型,若直接使用可能在位运算、字符下标索引或大小比较时引入意料之外的负数符号扩展;另一方面,C 语言对象与内存模型明确规定,任何类型的对象均可以通过指向 unsigned char 的指针合法访问其底层的物理对象表示(object representation),且这种访问绝不会违反严格别名分析(strict aliasing)规则,具体机理在对象表示中有过系统推导。
此前在基础练习中实现过的两个核心函数已内置在该模块中:mdr_memset 负责将传入的整型字符截断为单字节无符号数后重复填充到目标区域,mdr_memcpy 则负责在假定互不重叠的前提下将源区域逐字节复制到目标区域。本节新增的五个函数将与它们紧密并列在同一个源文件 src/mdr_memory.c 中:
void *mdr_memset(void *dest, int ch, size_t count) {
unsigned char *p = (unsigned char *)dest;
unsigned char byte = (unsigned char)ch;
MDR_ASSERT(dest != NULL || count == 0);
for (size_t i = 0; i < count; i++) {
p[i] = byte;
}
return dest;
}2
3
4
5
6
7
8
9
10
形参 ch 的类型声明为 int,这同样延续了标准库的历史设计惯例:在函数体内部将其强制收窄转换为 unsigned char,确保仅保留低 8 位有效数据。例如传入 0xA5 这样的值时能精准写入对应字节,而传入溢出单字节的 0x1FF 时,写入目标的依然是合法截断后的 0xFF。在单元测试中,我们专门设计了针对该类型截断特性的断言用例。
2. 重叠与 memmove
重新回到开篇那个数组元素后移的故障现场。源数据块与目标数据块全部坐落在同一个数组 a 内部,且目标区域的起始地址比源区域高出一个元素,两者形成了局部重叠。若继续采用自前向后(低地址到高地址)的逐字节拷贝策略,每向前推进一步,读取到的源数据字节很可能早已被前一步的写入动作破坏覆写。
从空间几何的角度来看,设源内存区间为
- 当
时:目标起始地址处于源区间的低地址一侧。此时应当自前向后进行复制,因为每次写入的物理位置始终落在尚未被扫描读取的源字节之前。 - 当
时:目标起始地址处于源区间的高地址一侧。此时必须改为自后向前(从末尾倒数)逆序复制,这样每次写入的高地址位置同样不会影响前面尚未读取的低地址源数据。 - 当
时:两块内存区域完全重合,函数无需执行任何内存写入操作,直接返回dest即可。
在 mdr_memmove 的具体实现中,我们正是依据上述地址相对高低来决定双向循环分支:
void *mdr_memmove(void *dest, const void *src, size_t count) {
unsigned char *d = (unsigned char *)dest;
const unsigned char *s = (const unsigned char *)src;
MDR_ASSERT(dest != NULL || count == 0);
MDR_ASSERT(src != NULL || count == 0);
if ((uintptr_t)d < (uintptr_t)s) {
for (size_t i = 0; i < count; i++) {
d[i] = s[i];
}
} else if ((uintptr_t)d > (uintptr_t)s) {
for (size_t i = count; i > 0; i--) {
d[i - 1] = s[i - 1];
}
}
return dest;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
在比对两个指针的相对大小时,代码显式将其转换为了标准整数类型 uintptr_t。这是因为根据 C 语言标准,仅当两个指针指向同一个数组对象(或其紧邻的尾后位置)时,直接使用 < 或 > 关系运算符比较才是良定义的;若两个指针来源于互不相关的独立对象,直接进行指针比对属于未定义行为。将其规整为无符号整数后,比较动作被明确落实为物理虚拟地址数值的高低判定,这种设计在工业级跨平台 C 运行库中被广泛采纳。
用 mdr_memcpy 和 mdr_memmove 做同一件事,结果一眼能分开:
#include "mdr_memory.h"
#include <stdio.h>
#include <string.h>
static void show(const char *label, const char *buf, size_t n) {
printf("%-32s%.*s\n", label, (int)n, buf);
}
int main(void) {
char buf[16];
memcpy(buf, "abcdef", 7);
mdr_memcpy(buf + 1, buf, 5);
show("mdr_memcpy(buf + 1, buf, 5):", buf, 6);
memcpy(buf, "abcdef", 7);
mdr_memmove(buf + 1, buf, 5);
show("mdr_memmove(buf + 1, buf, 5):", buf, 6);
memcpy(buf, "abcdef", 7);
memmove(buf + 1, buf, 5);
show("memmove(buf + 1, buf, 5):", buf, 6);
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
$ gcc -std=c17 -Wall -Wextra -O0 -Iinclude -o ov ov.c src/mdr_memory.c
$ ./ov
mdr_memcpy(buf + 1, buf, 5): aaaaaa
mdr_memmove(buf + 1, buf, 5): aabcde
memmove(buf + 1, buf, 5): aabcde2
3
4
5
在上述终端打印中,mdr_memcpy 试图把 "abcdef" 的前 5 个字节搬移到偏移量为 1 的位置。在低地址的第一个字节 'a' 覆写到索引 1 之后,循环向后推进一步读取源串的第 2 个字节时,读到的物理内容恰恰已经是刚刚覆写进去的 'a';这种前向污染像多米诺骨牌一样向后传导,导致后续每一步搬运的值都是 'a',最终整段内存坍塌成了 aaaaaa。相比之下,mdr_memmove 准确识别出目标地址高于源地址,果断启动逆序从后向前拷贝,从而交出了完整无损的正确结果 aabcde。
而开头那个整数数组平移的案例,更包含着一层极其深刻的工程警示。如果我们针对同一份调用 mdr_memcpy 的重叠代码,分别在不同的编译器优化级别下生成程序,会目睹截然相反的运行行为:
$ gcc -std=c17 -Wall -Wextra -O0 -Iinclude -o shift0 shift.c src/mdr_memory.c
$ ./shift0
1 1 1 1 1 1
$ gcc -std=c17 -Wall -Wextra -O2 -Iinclude -o shift2 shift.c src/mdr_memory.c
$ ./shift2
1 1 2 3 4 52
3
4
5
6
在开启 -O2 优化之后,程序表面上打印出了符合预期的 1 1 2 3 4 5。然而,这种所谓的「正确」纯粹是由编译器的隐式行为偶发带来的假象,源码本身依然处于严重违背契约的未定义行为状态:在 -O2 下,GCC 的模式识别引擎自动把手写的逐字节循环识别成了对系统内建 memcpy 的调用,而当前测试主机上的 glibc/UCRT 运行时库内部碰巧采用了倒序向量寄存器搬运策略,从而侥幸躲过了数据踩踏。一旦我们显式传入 -fno-builtin 禁用这层优化拦截,运行结果立刻打回原形退化为全 1:
$ gcc -std=c17 -Wall -Wextra -O2 -fno-builtin -Iinclude -o shiftf shift.c src/mdr_memory.c
$ ./shiftf
1 1 1 1 1 12
3
标准库从未承诺过 memcpy 必须按何种时序或方向搬运内存。换一台处理器架构、换一个版本的 C 运行时库、乃至仅仅改变一个编译器的微小标志,这种偶发的「正常」就会瞬间土崩瓦解。违反接口前置条件的程序产生任何行为都在未定义语义的默许范围之内,而其中杀伤力最大的恰恰就是这种「在当前开发机上碰巧正常运行」的隐蔽状态,它会严重麻痹开发者,使致命缺陷潜伏到生产环境才骤然爆发。
int a[6] = {1, 2, 3, 4, 5, 0};
/* 前置条件要求两个区域不重叠 */
mdr_memcpy(a + 1, a, 5 * sizeof a[0]);2
3
4
int a[6] = {1, 2, 3, 4, 5, 0};
/* 允许重叠,内部按方向选择复制顺序 */
mdr_memmove(a + 1, a, 5 * sizeof a[0]);2
3
4
memcpy 与 memmove 的唯一区别是能否重叠
memcpy 要求源和目标区域不重叠,违反时行为未定义;memmove 允许重叠,当目标位于源之后时从后向前复制,其余情况从前向后复制。两者的参数、返回值和复制结果在区域不重叠时完全相同。memmove 通常稍慢,因为它要先比较两个地址。
验证这种「所有偏移组合都对」的函数,逐条写测试不如穷举。16 字节的缓冲区里,源偏移、目标偏移、长度三个参数各取 0 到 15,只保留两个区域都落在缓冲区里的组合,一共 1751 种,全部跑一遍用不了几毫秒:
#include "mdr_memory.h"
#include <stdio.h>
#include <string.h>
int main(void) {
unsigned char a[16];
unsigned char b[16];
unsigned long cases = 0;
unsigned long bad = 0;
for (size_t src_off = 0; src_off < 16; src_off++) {
for (size_t dst_off = 0; dst_off < 16; dst_off++) {
for (size_t len = 0; len < 16 && len + src_off <= 16 && len + dst_off <= 16;
len++) {
for (size_t i = 0; i < 16; i++) {
a[i] = (unsigned char)(i * 7u + 3u);
b[i] = a[i];
}
mdr_memmove(a + dst_off, a + src_off, len);
memmove(b + dst_off, b + src_off, len);
cases++;
if (memcmp(a, b, 16) != 0) {
bad++;
}
}
}
}
printf("cases = %lu, mismatches = %lu\n", cases, bad);
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
28
29
30
$ gcc -std=c17 -Wall -Wextra -O2 -Iinclude -o exh exh.c src/mdr_memory.c
$ ./exh
cases = 1751, mismatches = 02
3
1751 种组合,与标准库的 memmove 逐字节相同。这个程序也在 Linux 上用 -fsanitize=address,undefined 编译运行过,结果一样,没有越界报告。穷举的思路和防护缓冲区的做法在 21.7 展开。
3. 比较与查找
内存比对函数 mdr_memcmp 与上一节的字符串比对函数 mdr_strcmp 相比,最根本的分水岭在于循环终止时机:字符串版本一旦遇到零字节 \0 就会立即判定到达串尾并终止扫描,而内存版本则完全无视数据内部是否夹杂着零字节,其扫描跨度完全由显式传入的字节数 count 决定:
int mdr_memcmp(const void *lhs, const void *rhs, size_t count) {
const unsigned char *p1 = (const unsigned char *)lhs;
const unsigned char *p2 = (const unsigned char *)rhs;
MDR_ASSERT(lhs != NULL || count == 0);
MDR_ASSERT(rhs != NULL || count == 0);
for (size_t i = 0; i < count; i++) {
if (p1[i] != p2[i]) {
return (int)p1[i] - (int)p2[i];
}
}
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
正因如此,mdr_memcmp 能够安全用于比对结构体的内存表示、加密哈希摘要、以及各类带有内嵌零字节的序列化二进制数据。在返回值约定上,它与 mdr_strcmp 同样遵循「仅保证正负符号、不保证具体数值」的 ISO C 规范,在单元测试中必须写成 < 0 或 > 0 进行断言。
单字节内存检索接口 mdr_memchr 也遵循同样的独立性原则:
void *mdr_memchr(const void *ptr, int ch, size_t count) {
const unsigned char *p = (const unsigned char *)ptr;
unsigned char c = (unsigned char)ch;
MDR_ASSERT(ptr != NULL || count == 0);
for (size_t i = 0; i < count; i++) {
if (p[i] == c) {
return (void *)(p + i);
}
}
return NULL;
}2
3
4
5
6
7
8
9
10
11
12
它在连续的 count 个字节范围内顺序查找目标单字节 (unsigned char)ch,中途若遭遇零字节绝不提前终止。这正是它与 mdr_strchr 之间的清晰界限:后者把遇到的第一个零字节当成字符串的终点并终止查找,而前者把零字节一视同仁地视为普通的数据单元。
#include "mdr_memory.h"
#include <stdio.h>
#include <string.h>
int main(void) {
const unsigned char data[] = {'a', 'b', 0, 'c', 'd'};
const unsigned char *m = mdr_memchr(data, 'c', sizeof data);
const char *s = strchr((const char *)data, 'c');
printf("区域: a b NUL c d\n");
printf("mdr_memchr 找到 'c' 的偏移: %d\n", m != NULL ? (int)(m - data) : -1);
printf("strchr 找到 'c' 的偏移: %d\n",
s != NULL ? (int)(s - (const char *)data) : -1);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ gcc -std=c17 -Wall -Wextra -Iinclude -o mc mc.c src/mdr_memory.c
$ ./mc
区域: a b NUL c d
mdr_memchr 找到 'c' 的偏移: 3
strchr 找到 'c' 的偏移: -12
3
4
5
两个函数都在 'c' 上找,前者找到了偏移 3 的那个字节,后者在中途的零字节处就停下了,报告「没找到」。解析二进制格式、在网络缓冲区里找标记字节,都该用 memchr。
正确答案是 B。strchr 的查找范围到终止空字符为止,memchr 的范围由 count 决定,中间的零字节只是普通数据。C 错在 memchr 接受任意 int,按 unsigned char 比较;D 把「提前停下」当成优点,对二进制数据来说那恰恰是漏找。
4. memccpy
mdr_memccpy 复制到遇见指定字节或复制满 count 个字节为止:
void *mdr_memccpy(void *restrict dest, const void *restrict src, int c,
size_t count) {
unsigned char *d = (unsigned char *)dest;
const unsigned char *s = (const unsigned char *)src;
unsigned char byte = (unsigned char)c;
MDR_ASSERT(dest != NULL || count == 0);
MDR_ASSERT(src != NULL || count == 0);
for (size_t i = 0; i < count; i++) {
d[i] = s[i];
if (d[i] == byte) {
return d + i + 1;
}
}
return NULL;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
当在目标数据流中成功撞见目标字节 byte 时,函数返回的是目标缓冲区中紧随其后的下一个字节物理地址;若在扫描满整整 count 个字节后仍未找到匹配项,则返回 NULL。这个返回值的指针位置可以直接作为下一次追加写入的起始坐标:例如在提取一段以 ':' 结尾的配置字段时,将返回的指针减去 1,便能精准定位到该分隔符本身并将其覆写为字符串终止符。
在标准化演进历史上,memccpy 长期属于 POSIX 体系下的扩展函数,直到 C23 标准才被正式纳入 ISO C 官方标准库。如果在较老的环境下使用 -std=c17 配合 Linux glibc 进行编译,系统头文件在默认严格模式下会选择对该声明隐身,编译器随即报出 implicit declaration of function 'memccpy' 警告甚至错误;必须额外添加 -std=gnu17、显式定义功能测试宏 _DEFAULT_SOURCE、或者切到 C23 模式方可正常识别。而在 Windows 平台的 UCRT 中,不论设置何种标准选项均无差别导出该符号。这种因平台演进与标准模式切换引发的符号缺失,正是我们将在 21.5 节集中攻克的技术痛点,同时也是我们为这套工具库编写独立 mdr_ 实现的现实工程动力。
它能不重复扫描地拼接多个字符串。下面这个函数用空格连接一个单词数组,总时间与总长度成正比:
#include "mdr_memory.h"
#include <stdio.h>
/* 用空格连接 n 个单词,返回写入的字符数;放不下时返回 -1。 */
static int join_words(char *dst, size_t size, const char *const words[],
size_t n) {
char *p = dst;
if (size == 0) {
return -1;
}
*p = '\0';
for (size_t i = 0; i < n; i++) {
char *after;
if (i > 0) {
after = mdr_memccpy(p, " ", '\0', size - (size_t)(p - dst));
if (after == NULL) {
dst[size - 1] = '\0';
return -1;
}
p = after - 1;
}
after = mdr_memccpy(p, words[i], '\0', size - (size_t)(p - dst));
if (after == NULL) {
dst[size - 1] = '\0';
return -1;
}
p = after - 1;
}
return (int)(p - dst);
}
int main(void) {
const char *words[] = {"alpha", "beta", "gamma", "delta"};
char buf[64];
int r;
r = join_words(buf, sizeof buf, words, 4);
printf("size=64: r=%d, \"%s\"\n", r, buf);
r = join_words(buf, 12, words, 4);
printf("size=12: r=%d, \"%s\"\n", r, buf);
r = join_words(buf, 6, words, 4);
printf("size=6: r=%d, \"%s\"\n", r, buf);
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
这里用 '\0' 当 mdr_memccpy 的分隔字节:每个单词在终止空字符处停下,函数返回它后面的位置,减一就是新的末尾。剩余空间用 size - (p - dst) 算出来,复制永远不会越过 dst 的末尾。
$ gcc -std=c17 -Wall -Wextra -O2 -Iinclude -o join join.c src/mdr_memory.c
$ ./join
size=64: r=22, "alpha beta gamma delta"
size=12: r=-1, "alpha beta "
size=6: r=-1, "alpha"2
3
4
5
第三次调用只剩 6 个字节,alpha 加终止符刚好用掉 6 个,再多一个字符都放不下,返回 -1。缓冲区里保留的是截断处之前的内容,并且一定有终止符。
5. 清除敏感数据
在涉及密码认证、数据解密或私钥计算的工程场景中,开发者通常会在函数返回前将暂存敏感数据的局部栈缓冲区清零,以防止机密残留被后续同栈帧函数刺探或随内存转储泄露。例如下面这段常规实现:
size_t copy_and_wipe(const char *input) {
char buf[32];
size_t n = (size_t)snprintf(buf, sizeof buf, "%s", input);
memset(buf, 0, sizeof buf);
return n;
}2
3
4
5
6
7
然而,一旦开启 -O2 级别的高级优化,查看编译器生成的机器汇编,会赫然发现整条 memset 语句彻底蒸发了:
$ gcc -std=c17 -Wall -Wextra -O2 -fno-asynchronous-unwind-tables -S wipe_plain.c
$ sed -n '/copy_and_wipe:/,/ret/p' wipe_plain.s
copy_and_wipe:
subq $40, %rsp
movq %rdi, %rcx
leaq .LC0(%rip), %rdx
xorl %eax, %eax
movq %rsp, %rdi
movl $32, %esi
call snprintf@PLT
addq $40, %rsp
cltq
ret2
3
4
5
6
7
8
9
10
11
12
13
这里加了 -fno-asynchronous-unwind-tables,只是把与本次讨论无关的 .cfi_* 行去掉,生成的指令不受影响。这段汇编来自 Debian 13 上的 GCC 14.2,寄存器名和调用约定是 x86-64 Linux 的;Windows 上的 MinGW-w64 GCC 13.2 用 rcx、rdx、r8 传递前三个参数,生成的代码思路相同。在上述汇编代码中,snprintf 调用完成之后,栈指针直接恢复并紧接着执行 ret 函数返回,其间没有任何针对栈内存写入零的指令。之所以发生这种触目惊心的优化消除,根源在于现代编译器的死存储消除(Dead Store Elimination, DSE)优化技术:由于局部缓冲区 buf 在随后的控制流中生命周期结束,且没有任何后续代码会再次读取这块内存,编译器推断向其写入数据对于程序的最终「可观察行为」毫无影响,因此判定这次擦除是纯粹的多余开销,将其彻底剔除。
为了在优化环境下坚守机密擦除的安全底线,mdr_memset_explicit 引入了 volatile 限定符来强制维系物理写入:
void *mdr_memset_explicit(void *dest, int ch, size_t count) {
volatile unsigned char *p = (volatile unsigned char *)dest;
unsigned char byte = (unsigned char)ch;
MDR_ASSERT(dest != NULL || count == 0);
for (size_t i = 0; i < count; i++) {
p[i] = byte;
}
return dest;
}2
3
4
5
6
7
8
9
10
通过将指向目标缓冲区的指针显式声明为 volatile unsigned char *,我们向编译器施加了不可撼动的语义约束:对该指针解引用发生的每一次写入,均被标准严格界定为独立且不可篡改的「可观察副作用」。优化器在进行死代码分析时,绝无权限消除任何一步 volatile 写入,从而稳稳保全了清零逻辑。对比左右两栏汇编代码可以清晰看到,引入 mdr_memset_explicit 之后,编译器在退出函数前规规矩矩地发射了函数调用与擦除指令:
copy_and_wipe:
subq $40, %rsp
movq %rdi, %rcx
leaq .LC0(%rip), %rdx
xorl %eax, %eax
movq %rsp, %rdi
movl $32, %esi
call snprintf@PLT
addq $40, %rsp
cltq
ret2
3
4
5
6
7
8
9
10
11
copy_and_wipe:
pushq %rbp
movq %rdi, %rcx
leaq .LC0(%rip), %rdx
movl $32, %esi
pushq %rbx
xorl %eax, %eax
subq $40, %rsp
movq %rsp, %rdi
call snprintf@PLT
movq %rsp, %rdi
movl $32, %edx
xorl %esi, %esi
movl %eax, %ebx
call mdr_memset_explicit@PLT
addq $40, %rsp
movslq %ebx, %rax
popq %rbx
popq %rbp
ret2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
两份汇编都用 GCC 14.2 在 Debian 13 上以 -O2 -fno-asynchronous-unwind-tables 生成。右栏的 call mdr_memset_explicit 后面紧跟着返回,说明这次调用被完整保留。
volatile 访问是可观察行为
volatile 告诉编译器:该对象的每一次读或写都必须按源码指定的次数与顺序如实执行,不可省略、合并或重排。清除敏感数据正是需要这种保证的场合:写入之后没有任何代码再读这块内存,对普通对象来说这次写入没有可观察效果,编译器有权删除;换成 volatile 访问之后,删除它就改变了程序的可观察行为。
标准库的对应函数叫 memset_explicit,C23 才有;此前各家提供了自己的版本,glibc 和 BSD 的 explicit_bzero、Windows 的 SecureZeroMemory,语义相同。glibc 2.41 的头文件里既没有 memset_explicit 的声明,libc 里也没有这个符号;MinGW-w64 的 UCRT 同样没有。本章提供的 mdr_ 版本则是这两个平台上可以直接调用的兜底实现。
6. 编译器眼中的内存函数
在现代编译器的语义分析眼中,标准库的内存操作并非神秘的黑盒,而是一类具有特殊内建地位的模式构件。最耐人寻味的现象之一是:即便我们在源码中手写了朴素的逐字节拷贝循环,只要开启了 -O2 优化,编译器往往也会通过循环模式识别(Loop Distribution / Idiom Recognition)将其自动逆向替换为对底层 memcpy 的库调用。查看它编出来的汇编(用 -DNDEBUG 关掉断言以简化输出,MinGW-w64 GCC 13.2):
mdr_memcpy 在 -O2 下的汇编
mdr_memcpy:
subq $40, %rsp
.seh_stackalloc 40
.seh_endprologue
testq %r8, %r8
je .L8
call memcpy
movq %rax, %rcx
.L8:
movq %rcx, %rax
addq $40, %rsp
ret
.seh_endproc2
3
4
5
6
7
8
9
10
11
12
13
函数体里自己写的逐字节循环不见了,取而代之的是一条 call memcpy。GCC 有一个优化遍叫 loop distribution,专门把逐字节的复制、填充循环识别成库调用。
用一个独立的函数观察这件事,看得更清楚:
#include <stddef.h>
void *byte_copy(void *restrict dest, const void *restrict src, size_t count) {
unsigned char *d = dest;
const unsigned char *s = src;
for (size_t i = 0; i < count; i++) {
d[i] = s[i];
}
return dest;
}2
3
4
5
6
7
8
9
10
11
需要指出的是,原型声明中的 restrict 限定符在这里起到了决定性的使能作用:它向分析器宣称源区间与目标区间绝无重叠可能,编译器据此才能放心地将逐字节循环整体替换为高性能的 memcpy 符号调用;一旦移除 restrict,由于编译器必须防范地址重叠风险,原有的逐字节加载指令便会被保守地保留下来。无论是 while (count-- > 0) { *d++ = *s++; } 还是标准的数组下标循环,编译器均能精准捕获这一模式。关闭该优化遍后的汇编对比:
$ gcc -std=c17 -Wall -Wextra -O2 -S bytecopy.c
$ grep -E 'call|movb' bytecopy.s
call memcpy
$ gcc -std=c17 -Wall -Wextra -O2 -fno-tree-loop-distribute-patterns -S bytecopy.c
$ grep -E 'call|movb' bytecopy.s
movb %r10b, (%rax,%r9)2
3
4
5
6
输出来自 Windows 上的 MinGW-w64 GCC 13.2;Linux 上的 GCC 14.2 两行分别是 call memcpy@PLT 与 movb %r8b, (%rax,%rcx),结论相同,寄存器名随调用约定变化。第一行里循环被换成了 call memcpy,第二行加了 -fno-tree-loop-distribute-patterns 之后,逐字节的 movb 回到循环里。这正是 mdr_ 前缀的价值之一:函数名不叫 memcpy,编译器换成的库调用指向标准库,mdr_memcpy 自己不会卷进去。
然而,如果开发者误将自定义的逐字节复制函数直接命名为 memcpy,灾难便会立刻降临:优化器在分析 memcpy 函数体内部的手写循环时,同样会将其识别为「标准拷贝模式」,进而发射一条针对 memcpy 的 call 指令——这直接导致函数体在内部无限自我调用,形成无法终止的死循环并迅速打爆调用栈:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* 与标准库同名;长度来自命令行,编译期无法确定 */
void *memcpy(void *restrict dest, const void *restrict src, size_t count) {
unsigned char *d = dest;
const unsigned char *s = src;
while (count-- > 0) {
*d++ = *s++;
}
return dest;
}
int main(int argc, char *argv[]) {
char buf[64];
size_t n = argc > 1 ? (size_t)atoi(argv[1]) : 6;
memcpy(buf, "hello", n);
printf("%s\n", buf);
return 0;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
复制长度从命令行读进来,编译器没法把它优化成常数,函数体里的循环照常参与识别。编译出来是这样的(Windows,GCC 13.2,-O2):
memcpy:
subq $40, %rsp
.seh_stackalloc 40
.seh_endprologue
testq %r8, %r8
je .L2
call memcpy
movq %rax, %rcx
.L2:
movq %rcx, %rax
addq $40, %rsp
ret
.seh_endproc2
3
4
5
6
7
8
9
10
11
12
13
函数体里唯一的一条 call 就指向 memcpy,也就是它自己。程序一运行就会无限递归,直到栈溢出。在 Windows 上用 GDB 看到的调用栈:
$ gdb -q -batch -ex run -ex bt --args ./recrun.exe 6
[New Thread 16668.0x57f4]
Thread 1 received signal SIGSEGV, Segmentation fault.
0x00007ff72dbb16d9 in memcpy ()
#0 0x00007ff72dbb16d9 in memcpy ()
#1 0x00007ff72dbb16de in memcpy ()
#2 0x00007ff72dbb16de in memcpy ()
#3 0x00007ff72dbb16de in memcpy ()
#4 0x00007ff72dbb16de in memcpy ()
#5 0x00007ff72dbb16de in memcpy ()
#6 0x00007ff72dbb16de in memcpy ()
#7 0x00007ff72dbb16de in memcpy ()
#8 0x00007ff72dbb16de in memcpy ()
#9 0x00007ff72dbb16de in memcpy ()2
3
4
5
6
7
8
9
10
11
12
13
14
15
一层接一层的 memcpy 帧,就是递归的证据。Linux 上同样崩溃,timeout 报告 Segmentation fault、退出状态 139。
这段程序在 Windows 上另有一个陷阱。把复制长度改成常数、把 printf 也删掉,只留下一个 return 0 的 main,程序照样崩溃,并且发生在进入 main 之前:
$ gdb -q -batch -ex "break main" -ex run -ex bt --args ./constrec5.exe
Breakpoint 1 at 0x140003239
[New Thread 21204.0x505c]
Thread 1 received signal SIGSEGV, Segmentation fault.
0x00007ff76de416d9 in memcpy ()
#0 0x00007ff76de416d9 in memcpy ()
#1 0x00007ff76de416de in memcpy ()
#2 0x00007ff76de416de in memcpy ()
#3 0x00007ff76de416de in memcpy ()2
3
4
5
6
7
8
9
10
断点设在 main 上,程序没走到就崩了:Windows 的 C 运行库在启动阶段自己会调用 memcpy,链接器把这个符号解析到了这份有缺陷的实现上,于是启动流程陷入无限递归。同一份程序在 glibc 上退出状态是 0,因为 glibc 内部的调用不经过这个名字。用标准库的名字定义函数,影响范围不止自己那几个调用点。
修法有两个:-fno-builtin 关掉全部内建函数识别,-ffreestanding 声明这是独立实现环境,编译器不再假定标准库存在。C 库和嵌入式固件用这两个选项编译自己的字符串、内存函数,原因就在这里。本章的 mdr_ 版本不需要它们,因为名字避开了标准库。
21.1 里 strlen("abc") 在编译期被折叠成 3,是同一类现象的另一个方向:编译器按内建语义理解标准库名字,把调用替换成自己生成的代码。内存函数这边更隐蔽,替换发生在函数体内部,写循环的人看不出问题,直到运行起来栈溢出。
7. 标准库为什么更快
在相同的测试硬件上,glibc 或 UCRT 中的 memcpy 在搬移大块数据时,其吞吐性能往往比我们手写的逐字节循环高出数倍甚至十余倍。其性能优势的背后,主要依赖以下工程优化支柱:
- 按机器字批量搬移:在支持 64 位寻址的架构上,单次迭代直接读写 8 个字节的字长对象,只有首尾不足机器字对齐的零散部分才采用逐字节兜底。
- 精细处理指针对齐:当源指针或目标指针未对齐到机器字边界时,工业实现会先通过极少量的单字节复制使指针跨越到对齐边界,在主体传输中充分享受硬件对齐访存的高速通道。
- 矢量化与 SIMD 指令:充分利用 x86 的 AVX-2/AVX-512 或 ARM 的 NEON 向量指令集,单条指令即可并发搬运 16、32 乃至 64 字节数据。
- 专有的类型别名扩展:标准库实现内部通常会借助 GCC/Clang 的
__may_alias__类型属性来构造特殊的无符号整型指针,显式豁免严格别名分析,避免优化器退化。由于这一属性属于编译器私有扩展,不具备跨平台的标准 C 表达力。
而在本章的教学与基础实现中,mdr_ 内存函数全数严格坚持基于 unsigned char 的逐字节访问规范。这是因为若要在纯标准 C 中强行通过指针转换进行按字读取,极易触碰「有效类型」(Effective Type)与严格别名分析的红线,导致编译器假设失真并生成错误的机器码,具体规则在对象表示中有过深入剖析。通过 unsigned char 访问对象的原生字节表示,是 ISO C 唯一无条件承诺始终安全合规的通用通道。
如果业务场景确实需要从任意对齐的内存缓冲区中快速读取一个机器字(例如读取一个 32 位整数),标准 C 体系内提供了一条兼顾绝对安全性与峰值性能的最佳实践:直接借助 memcpy 将数据拷贝到局部的强类型整型对象中:
#include <stdint.h>
#include <string.h>
uint32_t load_word(const void *p) {
uint32_t w;
memcpy(&w, p, sizeof w);
return w;
}2
3
4
5
6
7
8
9
由于现代优化编译器具备对标准库 memcpy 语义的完备内建感知,面对此类短小且尺寸固定的内存拷贝,编译器绝不会真的去发射一条跨函数的 call 调用指令,而是会将其在前端直接折叠编译为单条单周期的普通加载指令:
$ gcc -std=c17 -O2 -S wordload.c
$ sed -n '/load_word:/,/ret/p' wordload.s
load_word:
.LFB0:
.cfi_startproc
movl (%rdi), %eax
ret2
3
4
5
6
7
这是 Debian 13 上 GCC 14.2 的输出;Windows 上的 GCC 13.2 生成 movl (%rcx), %eax,差别只在参数寄存器的名字。没有函数调用,没有逐字节循环,就是一条 32 位载入。想从缓冲区里读一个整数,这样写既完全避开了未对齐指针解引用引发的总线错误与未定义行为,又在最终代码质量上与手写硬件加载指令达到了完全的等效。
8. 习题
8.1 巩固
同一个 16 字节缓冲区,每次先写入 "abcdef",再做一次搬移,然后打印字符串(每次打印前不要重置缓冲区,四次之间只靠 memcpy 那一行重置):
char b[16];
memcpy(b, "abcdef", 7);
mdr_memmove(b + 2, b, 4);
printf("[%s]\n", b);
memcpy(b, "abcdef", 7);
mdr_memmove(b, b + 2, 4);
printf("[%s]\n", b);
memcpy(b, "abcdef", 7);
mdr_memmove(b + 3, b + 3, 4);
printf("[%s]\n", b);
memcpy(b, "abcdef", 7);
mdr_memmove(b + 4, b + 2, 0);
printf("[%s]\n", b);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(1) 写出四次打印的结果。
(2) 对前两次,指出 mdr_memmove 走的是哪个分支(从前向后还是从后向前),并说明走另一个分支会得到什么。
(3) 第三、四次调用里,函数体里的两个循环各执行了几次?
不运行程序,写出输出:
unsigned char b[4];
memset(b, 0x1FF, sizeof b);
printf("%02X %02X %02X %02X\n", b[0], b[1], b[2], b[3]);
memset(b, -1, sizeof b);
printf("%02X\n", b[0]);
unsigned char data[] = {'a', 'b', 0, 'c', 'd'};
printf("%d\n", (int)((unsigned char *)mdr_memchr(data, 'c', sizeof data) - data));
printf("%d\n", (int)((unsigned char *)mdr_memchr(data, 0, sizeof data) - data));
const char *s = (const char *)data;
printf("%d\n", (int)(strchr(s + 3, 'd') - s));2
3
4
5
6
7
8
9
10
11
12
13
14
要求对每一行指出起作用的规则:memset 取低 8 位、memchr 不把零字节当结束、strchr 的范围由终止符决定。
8.2 编程
在一个整数数组的第 pos 个位置插入一个元素,需要把 pos 之后的元素整体后移一格。给出两个版本:
/* 逐字节从前向后复制 */
static void *copy_forward(void *dest, const void *src, size_t count) {
unsigned char *d = dest;
const unsigned char *s = src;
for (size_t i = 0; i < count; i++) {
d[i] = s[i];
}
return dest;
}
static void insert_forward(int *a, size_t *n, size_t pos, int value) {
copy_forward(a + pos + 1, a + pos, (*n - pos) * sizeof a[0]);
a[pos] = value;
(*n)++;
}
/* 把 copy_forward 换成 mdr_memmove 的版本 */
static void insert_memmove(int *a, size_t *n, size_t pos, int value);2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
对一个初始为 {1, 2, 3, 4, 5, 0}、n 为 5 的数组,在位置 1 插入 9:
(1) 两个版本分别打印出什么?insert_forward 的结果从第几个元素开始出错,为什么。
(2) 把 insert_forward 里的 copy_forward 换成标准库的 memcpy,在 -O0、-O2、-O2 -fno-builtin 三种构建下各跑一次。三种构建在 MinGW-w64 GCC 13.2 上都打印出正确结果,-O2 还会给出 -Wrestrict 警告。说明这种「正确」为什么不能作为程序没有问题的证据,以及那条警告指的是什么。
(3) 写一句规格注释,说明 insert_memmove 对 pos 的要求,以及为什么这里不能用 mdr_memcpy。
用 mdr_memccpy 写一个按行切分的函数:
/* 从 src 的前 size 个字节里取一行写入 dst(最多 cap - 1 个字节,总是补终止符)。
* 行尾的 '\n' 不计入结果;\r\n 两个字节都去掉。
* 返回这一行在 src 中消耗的字节数,size 为 0 时返回 0。 */
size_t mdr_read_line(const char *src, size_t size, char *dst, size_t cap);2
3
4
验收,用循环把整个输入切完:
输入 "ab\ncd\n\nxy"(9 个字节) 得到 4 行:"ab"、"cd"、""、"xy",共消耗 9 个字节
输入 "one\r\ntwo" 得到 2 行:"one"、"two"2
再回答两个问题:
- 调用
mdr_memccpy(dst, src, '\n', n)时,返回值与dst的差是什么含义?没有遇到'\n'时返回值是什么? - 如果一行比
cap - 1还长,函数应该怎样处理才既不越界、又让下一次调用从正确的位置继续?给出你的做法,并说明调用方怎么判断发生了截断。
把一个口令先复制进栈上的缓冲区再清零,两个版本只差清零函数:一个用标准库的 memset,另一个用本库的 mdr_memset_explicit。
size_t wipe_plain(const char *input) {
char buf[32];
size_t n = (size_t)snprintf(buf, sizeof buf, "%s", input);
memset(buf, 0, sizeof buf);
return n;
}
size_t wipe_explicit(const char *input) {
char buf[32];
size_t n = (size_t)snprintf(buf, sizeof buf, "%s", input);
mdr_memset_explicit(buf, 0, sizeof buf);
return n;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
在 MinGW-w64 GCC 13.2 上用下面的命令生成汇编(Linux 上换掉 .seh_* 相关选项):
gcc -std=c17 -O2 -fno-asynchronous-unwind-tables -S wipe.c(1) 贴出两个函数各自的指令,指出哪一个在 snprintf 之后没有任何清零指令,并说明编译器凭什么删掉它。
(2) mdr_memset_explicit 里把指针声明成 volatile unsigned char *。如果只把 ch 或 count 加 volatile,效果一样吗?
(3) 用 -O0 重新生成汇编,两个版本会有什么不同?由此说明这类缺陷为什么在调试构建里看不见。
从缓冲区里读一个 32 位整数,不依赖主机对齐,也不违反有效类型规则:
uint32_t load_u32(const void *p);
void store_u32(void *p, uint32_t value);
uint64_t load_le64(const void *p); /* 按小端组装 8 个字节 */2
3
(1) 用 memcpy 实现三个函数。要求 -Wall -Wextra 无警告,并能在任意字节偏移上调用(load_u32(buf + 1) 是合法的)。
(2) 用 gcc -std=c17 -O2 -S 查看 load_u32 的汇编,要求只有一条 32 位载入指令、没有函数调用。把 memcpy 换成 return *(const uint32_t *)p; 再生成一次,两份汇编在这台机器上完全相同。说明为什么这条更短的写法仍然不能用:从哪两条规则看它是错的,以及哪一类平台上它会直接崩溃。
(3) 对字节序列 08 07 06 05 04 03 02 01 调用 load_le64,期望结果写成十六进制。再对同一个缓冲区先 store_u32(p, 0x12345678u) 再逐字节打印,并让 load_u32 读回。分别说明:哪一步的结果在大小端机器上相同,哪一步不同。store_u32 与 mdr_store_le32 的差别就在这里。
8.3 综合与挑战
正文第 6 节用一个与标准库同名的 memcpy 演示了编译器把函数体里的循环换成对自身的调用。复现实验:
void *memcpy(void *restrict dest, const void *restrict src, size_t count) {
unsigned char *d = dest;
const unsigned char *s = src;
while (count-- > 0) {
*d++ = *s++;
}
return dest;
}2
3
4
5
6
7
8
9
main 从命令行读一个长度,调用 memcpy(buf, "hello", n) 之后打印 buf。
(1) 用 -O0、-O2、-O2 -fno-builtin 三种方式构建运行,记录结果和退出状态。哪一种构建下 main 根本没跑起来?
(2) 在出问题的构建上用 gdb -batch -ex run -ex bt 取调用栈,贴出前若干帧,解释为什么每一帧都是 memcpy。
(3) 这一节说「用标准库的名字定义函数,影响范围不止自己那几个调用点」。用 -O2 的汇编或 gdb 的断点位置说明:崩溃发生在 main 之前,是运行库启动阶段哪一类调用被链接到了这份实现上。
(4) 把函数改名为 mdr_memcpy_copy,同样的 -O2 构建还崩溃吗?由此总结 mdr_ 前缀在这个工程里的实际作用。
下面的实现把 mdr_memmove 的两个分支写反了:目标地址更高时反而从前向后复制。
void *mdr_memmove_bug(void *dest, const void *src, size_t count) {
unsigned char *d = (unsigned char *)dest;
const unsigned char *s = (const unsigned char *)src;
if ((uintptr_t)d < (uintptr_t)s) {
for (size_t i = count; i > 0; i--) {
d[i - 1] = s[i - 1];
}
} else if ((uintptr_t)d > (uintptr_t)s) {
for (size_t i = 0; i < count; i++) {
d[i] = s[i];
}
}
return dest;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
(1) 用正文第 2 节的穷举程序把被测函数换成 mdr_memmove_bug,缓冲区大小、三层循环、初始数据都照抄。报告 cases 与 mismatches 两个数,并打印第一组出错的 src_off、dst_off、len(在循环里第一次发现不一致时记下这三个值)。
(2) 手工验证第一组出错参数,写出错误结果与正确结果。
(3) 修好之后重跑,要求 mismatches 为 0;再用 -fsanitize=address,undefined 构建跑一遍(MinGW 没有运行库时说明原因并跳过)。
(4) 为什么这个穷举只覆盖 1751 种组合就能说明问题?如果把缓冲区从 16 字节改成 32 字节,组合数会变成多少(写出算式即可,不必真跑)。
逐字节的 mdr_memmove 比标准库慢,原因之一是每次只搬一个字节。按机器字搬的版本可以这样写,既保持标准允许的访问方式,又利用 memcpy 生成单条载入指令:
/* 每次把一个 size_t 大小的块经 memcpy 读出再写入,尾部不足一块的逐字节处理。 */
void *mdr_memmove_words(void *dest, const void *src, size_t count);2
要求:
- 保持
mdr_memmove的方向判断:目标地址更低时从前向后,更高时从后向前,相等时直接返回。前后两个方向的块内复制也必须遵守这个方向,不能只对整块调memcpy。 - 用第 2 节的 1751 组穷举与标准库
memmove对照,mismatches必须为 0。 - 测三种实现的吞吐:逐字节版、按字版、库函数
memmove。缓冲区取 1 MiB,重复搬移 200 次,用clock()计时,各跑 5 次取中位数,报告以库函数为 1 的相对速度。同一台机器上重复测量,结果允许有百分之几的波动。 - 解释按字版在源和目标都没有对齐时为什么不会变慢得离谱,以及为什么块内复制必须用
memcpy而不是*(size_t *)d = *(size_t *)s。
验收:穷举 0 处不一致;三种实现的相对速度有数字;第 4 问的答案里提到有效类型与对齐两条理由。
9. 参考资料
- cppreference,
memmove:https://en.cppreference.com/w/c/string/byte/memmove - cppreference,
memcmp:https://en.cppreference.com/w/c/string/byte/memcmp - cppreference,
memchr:https://en.cppreference.com/w/c/string/byte/memchr - cppreference,
memccpy:https://en.cppreference.com/w/c/string/byte/memccpy - cppreference,
memset(含memset_explicit):https://en.cppreference.com/w/c/string/byte/memset - musl libc,
memmove.c:https://git.musl-libc.org/cgit/musl/tree/src/string/memmove.c - Yang 等,Dead Store Elimination (Still) Considered Harmful,USENIX Security 2017:https://www.usenix.org/conference/usenixsecurity17/technical-sessions/presentation/yang
- GCC Optimize Options(
-ftree-loop-distribute-patterns):https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html - GCC C Dialect Options(
-ffreestanding、-fno-builtin):https://gcc.gnu.org/onlinedocs/gcc/C-Dialect-Options.html explicit_bzero(3):https://man7.org/linux/man-pages/man3/explicit_bzero.3.html- Microsoft Learn,
SecureZeroMemory:https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/aa366877(v=vs.85) - ISO/IEC 9899:2024 工作草案 N3220,7.26 节
<string.h>:https://www.open-std.org/jtc1/sc22/wg14/docs/n3220.pdf