Lesson 27: 实现 my_memmove — 内存重叠拷贝
练习任务
难度:中 【重点】【面试高频】
实现自己的 memmove 函数——my_memmove(dest, src, n),将 n 字节从 src 拷贝到 dest,返回 dest。函数原型与标准库一致:
void *my_memmove(void *dest, const void *src, size_t n);与 Lesson 26 的 memcpy 的关键区别:memmove 必须正确处理 src 和 dest 内存区域重叠的情况。本题验证用例演示 dest > src 的经典场景——将字符串整体右移一位:
输入 "abcd\n" → 输出 "aabc\n"
输入 "hello\n" → 输出 "hhell\n"
输入 "xy\n" → 输出 "xx\n"程序逻辑:读取一行字符串,去掉末尾的 '\n',然后将 buf[0..len-2] 拷贝到 buf[1..len-1]——相当于把字符串前 len-1 个字符整体右移一位,第一个字符保持不变。
提示:
memcpy在面对重叠区域时行为未定义——如果你用 Lesson 26 学到的正向逐字节拷贝来处理dest > src,会发现输出不是 "aabc" 而是 "aaaa"。思考为什么?本课的核心答案是:拷贝方向由地址关系决定——当dest > src时,必须从后向前拷贝,因为在写入目标之前,源数据尚未被覆盖。
核心知识点
- 内存重叠三种情形全景 — 不重叠 /
dest < src/dest > src,每种对正向拷贝的正确性不同 - 正向拷贝的级联污染 —
dest > src时逐字节跟踪,揭示写入覆盖未读数据的破坏链 - 反向拷贝的正确性 —
dest > src时从末尾开始拷贝,每个字节在被覆盖前已完成迁移 - 方向判断三分支决策 —
dest < src→ 正向 /dest > src→ 反向 /dest == src→ 空操作 memcpyvsmemmove完整对比 — UB 处理、restrict限定符、性能差异、超集/子集关系- 真实工程场景 — 数组插入(
dest > src→ 反向)和数组删除(dest < src→ 正向) - 跨对象指针比较是 UB — C 标准 §6.5.8 规定只有指向同一分配对象内的指针比较才合法
- 反向循环边界易错点 —
for (i=n; i>0; i--) d[i-1]=s[i-1]而非for (i=n; i>=0; i--) d[i]=s[i]
代码框架
#include <stdio.h>
#include <string.h>
void *my_memmove(void *dest, const void *src, size_t n)
{
// 将 dest 和 src 转为 char* 指针,方便逐字节操作
// char *d = (char *)dest;
// const char *s = (const char *)src;
// 在这里转换指针类型
// 如果 dest == src 或 n == 0,直接返回 dest(无需拷贝)
// 在这里处理边界情况
// 情况 1:dest < src(目标在源左边,无重叠写覆盖读的风险)
// 正向拷贝——从前往后
// 在这里写正向拷贝:for (size_t i = 0; i < n; i++) d[i] = s[i];
// 情况 2:dest > src(目标在源右边,有重叠风险!)
// 必须反向拷贝——从后往前
// 想一想:如果正向会发生什么?
// 在这里写反向拷贝:for (size_t i = n; i > 0; i--) d[i-1] = s[i-1];
// 返回 dest
}
int main(void)
{
char buf[256];
// 用 fgets 读一行到 buf,去掉末尾 '\n'
// 在这里读取输入
// 计算字符串长度 len = strlen(buf)
// 在这里计算长度
// 核心测试:将 buf[0..len-2] 拷贝到 buf[1..len-1]
// 即相当于把整个字符串往右挪一位:
// "abcd" -> "aabc"(第 2 个字符起是原来的前 3 个字符)
// 这是 dest > src 的典型场景——必须用反向拷贝
// my_memmove(buf + 1, buf, len - 1);
printf("%s\n", buf);
return 0;
}框架中的核心思考:
- 为什么
dest > src时正向拷贝会出错?画内存图来追踪。 - 方向判断有两个分支还是三个分支?
dest == src需要单独处理吗? - 反向循环的边界条件怎么写才不会越界?
TIP
先不要往下翻看参考解答。回顾 Lesson 26 中 memcpy 的逐字节写法,然后思考:如果 src 和 dest 指向同一块内存且 dest > src,逐字节正向拷贝会发生什么?在纸上画出 buf = "abcd\0",执行 my_memmove(buf+1, buf, 3),逐步跟踪数组内容的变化。
深度讲解
1. 三种内存关系的全景分析
回顾 Lesson 26 学的 memcpy:它假设 src 和 dest 不重叠。一旦重叠,memcpy 的行为就是未定义行为(UB)。而现实世界中,我们经常需要在同一块缓冲区中移动数据——删除数组元素(前移)、插入数组元素(后移)——这些操作天然涉及重叠。这就是 memmove 诞生的原因。
memmove 的挑战在于:src 和 dest 的相对位置决定了拷贝方向。让我们逐一分析三种情形。
1.1 情况 A:完全不重叠
src dest
↓ ↓
mem: ... [a][b][c][d][\0] ... [ ][ ][ ][ ] ...
└──┬──┘ └──┬──┘
源区域(已分配) 目标区域(已分配)
不能碰头 → 安全 怎么拷贝都对 ✓
memcpy ✅ memmove ✅两块内存完全分离,互不干扰。正向拷贝、反向拷贝结果完全相同。此时 memmove 等价于 memcpy——无论走哪个分支。
1.2 情况 B:dest < src(目标在源前面)
dest src
↓ ↓
mem: ... [ ][ ][ ][ ][a][b][c][d][\0] ...
└────┬────┘
重叠区间 └────┬────┘
源区间
正向拷贝: 写入 dest 从左开始 → 离 src 尚有一段距离 → 不会覆盖未读数据 ✓
反向拷贝: 也安全 ✓(但正向更符合 CPU 缓存预取方向)
memcpy: ✅ 正向安全(标准说不保证,但大部分实现此场景正向可以)
memmove: ✅ 走正向分支这是最常见的场景——删除数组元素(后续元素前移):
int arr[] = {1, 2, 3, 4, 5};
// 删除 arr[1]=2,将 arr[2..4] 前移:
memmove(arr + 1, arr + 2, 3 * sizeof(int));
// dest=arr+1, src=arr+2 → dest < src → 正向拷贝 ✓
// arr = {1, 3, 4, 5, 5}1.3 情况 C:dest > src(目标在源后面)← 本题测试场景!
src dest
↓ ↓
mem: ... [a][b][c][d][ ][ ][ ][ ][\0] ...
└────┬────┘
源区间 └────┬────┘
重叠区间 → 写入位置与未读数据冲突!
正向拷贝: 写入 dest 从左开始 → 立即覆盖了 src 的后半部分!✗
反向拷贝: 从右端末尾开始 → 先读最后数据,再往前走 → 写入时该字节已被读完 ✓
memcpy: ❌(数据级联污染)
memmove: ✅ 走反向分支这是**数组插入元素(后续元素后移)**的场景:
int arr[10] = {1, 2, 3, 4, 5};
// 在位置 1 插入 99,将 arr[1..4] 后移:
memmove(arr + 2, arr + 1, 4 * sizeof(int));
// dest=arr+2, src=arr+1 → dest > src → 必须反向!memcpy 会出错
arr[1] = 99;
// arr = {1, 99, 2, 3, 4, 5}CAUTION
dest > src 是本课的核心难题,也是面试中考官最常追问的场景。如果候选人在白板上不假思索地写出正向循环却画不出这个场景的破坏过程,面试分扣一半。
2. 正向拷贝的灾难——逐字节跟踪
这是本课最重要的部分。用逐字节跟踪的方式展示——为什么 dest > src 时正向拷贝会导致数据连锁污染。
2.1 初始状态
假设 buf = "abcd\0",执行 my_memmove(buf+1, buf, 3)——将位置 0~2 的 3 个字节拷贝到位置 1~3。正确结果应为 "aabc"(第一个 'a' 不变,后面三个被 'a','b','c' 覆盖)。
初始状态(地址视角):
地址: 0 1 2 3 4
┌────┬────┬────┬────┬────┐
buf: │ a │ b │ c │ d │ \0 │
└────┴────┴────┴────┴────┘
↑ ↑
src dest
(0) (1)
n = 3(拷贝 3 字节)2.2 正向拷贝破坏过程(错误做法)
═══════════════════════════════════════════════════════════════
正向拷贝 —— 每步展示 buf 数组的变化
═══════════════════════════════════════════════════════════════
i=0: dest[0] = src[0]
→ 把 buf[1] 赋值为 buf[0] 的值 'a'
┌────┬────┬────┬────┬────┐
│ a │ a │ c │ d │ \0 │ ← 'b' 被 'a' 覆盖了!
└────┴────┴────┴────┴────┘
↑ ↑
src dest buf[0]=src[0] 还是 'a',src[0] 没事 ✓
(0) (1) buf[1] 本来存 'b',被覆盖成了 'a'
i=1: dest[1] = src[1]
→ 本应取 buf[1] 的原始值 'b'
→ 但 buf[1] 在 i=0 时已被覆盖为 'a'!
→ 所以 src[1] 读到的不是 'b',而是 'a'(错误!)
┌────┬────┬────┬────┬────┐
│ a │ a │ a │ d │ \0 │ ← 'c' 被 'a' 覆盖
└────┴────┴────┴────┴────┘
↑ ↑
src dest buf[2] 被污染成了 'a'
(0) (2) 本该写入 'b',但现在写入了 'a'
i=2: dest[2] = src[2]
→ buf[2] 现在的值是 'a'(被上一轮污染的)
→ 所以写入 buf[3] 的是 'a',不是 'c'
┌────┬────┬────┬────┬────┐
│ a │ a │ a │ a │ \0 │ ← 'd' 被 'a' 覆盖
└────┴────┴────┴────┴────┘
结果: buf = "aaaa" → 完全错误!应该是 "aabc" ❌根因分析:正向拷贝的顺序是"从前往后"——i=0, 1, 2, ...。当 dest > src 时,dest[0] 的位置就是 src[1] 的位置(或更后面)。写入 dest[0] 的同时覆盖了尚未被读取的 src[1](或更后面的源数据)。这个"读写-读写"的交替产生了级联污染——一处污染产生连锁反应。
级联污染的因果链:
i=0: 写 dest[0]=buf[1] → 覆盖了 src[1] = 原始的 'b'
i=1: 读 src[1] → 读到被污染的值 'a'(而非 'b')
写 dest[1]=buf[2] → 用错误的值覆盖了 src[2]
i=2: 读 src[2] → 读到被污染的值 'a'(而非 'c')
写 dest[2]=buf[3] → 用错误的值覆盖了 src[3]
→ 一次污染 → 永久错误 → 连锁蔓延 → 结果全错3. 反向拷贝的正确做法——逐字节跟踪
3.1 反向拷贝过程
初始状态(同 2.1):
┌────┬────┬────┬────┬────┐
buf: │ a │ b │ c │ d │ \0 │
└────┴────┴────┴────┴────┘
↑ ↑
src dest
(0) (1), n = 3
═══════════════════════════════════════════════════════════════
反向拷贝——从 i=n-1=2 开始,递减到 0
═══════════════════════════════════════════════════════════════
i=2: dest[2] = src[2]
→ 把 buf[3] 赋值为 buf[2] 的值 'c'
┌────┬────┬────┬────┬────┐
│ a │ b │ c │ c │ \0 │ ← 'd' 被覆盖
└────┴────┴────┴────┴────┘
buf[3] 原来的 'd' 不在拷贝范围内(dest 只到 buf[3])
且 buf[2] 的值 'c' 已经安全迁移到 buf[3] ✓
i=1: dest[1] = src[1]
→ 把 buf[2] 赋值为 buf[1] 的值 'b'
┌────┬────┬────┬────┬────┐
│ a │ b │ b │ c │ \0 │ ← 'c' 被覆盖
└────┴────┴────┴────┴────┘
buf[2] 原来的 'c' 已在 i=2 时迁移到 buf[3] ✓
i=0: dest[0] = src[0]
→ 把 buf[1] 赋值为 buf[0] 的值 'a'
┌────┬────┬────┬────┬────┐
│ a │ a │ b │ c │ \0 │ ← 'b' 被覆盖
└────┴────┴────┴────┴────┘
buf[1] 原来的 'b' 已在 i=1 时迁移到 buf[2] ✓
结果: buf = "aabc" → 完全正确!✓根因分析:反向拷贝从末尾(i=n-1)开始,先读取最后需要的数据,将其拷贝到安全位置(目标区域的末尾);然后往前推,处理更靠前的数据。当后面的数据被覆盖时,它已经完成了向安全位置的迁移。这就是 memmove 的精髓——拷贝方向由地址关系决定,而非一成不变。
3.2 反向循环的两种写法
// 方式 1: for 循环——最明确
for (size_t i = n; i > 0; i--)
d[i - 1] = s[i - 1];
// 方式 2: while 递减——最简洁
while (n-- > 0)
d[n] = s[n];
// ⚠️ 为什么不用这种?
// for (size_t i = n; i >= 0; i--) d[i] = s[i];
// ↑ 当 i=0 执行完后 i-- → i 在无符号下会变成 SIZE_MAX → 无限循环!
// ↓ 且第一轮 i=n 时访问了 d[n] = s[n] → 越界!// while (n--) d[n] = s[n] 的展开——以 n=3 为例
size_t n = 3;
// 第 1 轮: n-- → n=2(先取值 3 判断 >0,再减 1)
d[2] = s[2]; // buf[3] = buf[2] = 'c' ✓
// 第 2 轮: n-- → n=1
d[1] = s[1]; // buf[2] = buf[1] = 'b' ✓
// 第 3 轮: n-- → n=0
d[0] = s[0]; // buf[1] = buf[0] = 'a' ✓
// 第 4 轮: n=0 → while (0) → 退出NOTE
while (n--) 和 for (i=n; i>0; i--) d[i-1]=s[i-1] 完全等价。前者更简洁(利用 n 自然递减),后者更明确(索引和范围一目了然)。
4. 方向判断三分支算法
void *my_memmove(void *dest, const void *src, size_t n)
{
char *d = (char *)dest;
const char *s = (const char *)src;
/* 分支 1: 同一地址或无数据——无需拷贝 */
if (d == s || n == 0)
return dest;
/* 分支 2: dest < src —— 正向拷贝 */
if (d < s) {
for (size_t i = 0; i < n; i++)
d[i] = s[i];
}
/* 分支 3: dest > src —— 反向拷贝 */
else {
for (size_t i = n; i > 0; i--)
d[i - 1] = s[i - 1];
}
return dest;
}三分支决策图:
┌─ d == s 或 n == 0 ─→ 无需拷贝,直接返回 dest
│
指针比较 ─┼─ d < s ──→ 正向拷贝(和 memcpy 一样)
│ for (i=0; i<n; i++) d[i] = s[i]
│
└─ d > s ──→ 反向拷贝
for (i=n; i>0; i--) d[i-1] = s[i-1]WARNING
指针比较 d < s 只在指向同一数组(或同一块 malloc 分配的内存)内时才有定义。如果 d 和 s 指向两个完全不相关的分配块,d < s 的比较结果是未定义的。在实际使用中,memmove 的调用者始终保证 src 和 dest 在同一缓冲区或其副本内,所以这个比较是安全且合法的。
5. memcpy vs memmove —— 完整对比
| 维度 | memcpy | memmove |
|---|---|---|
| 重叠处理 | 不保证(未定义行为) | 保证正确处理重叠 |
| 方向判断 | 无 | 有(if (d < s) 三分支) |
restrict 限定符 | C99 起声明为 restrict | 无 restrict |
| 拷贝方向 | 总是正向 | d < s:正向;d > s:反向 |
| 性能 | 略快(无条件分支) | 多一次指针比较和条件跳转 |
| 能否替代对方 | 不能替代 memmove | 可以替代 memcpy(多一次判断,开销通常可忽略) |
| 使用场景 | 拷贝到全新分配的 buffer | 数组内移动元素、不确定是否重叠 |
| 面试考点 | void* → char* 转换 | 内存重叠 + 方向判断 |
| 标准年份 | C89 | C89 |
IMPORTANT
性能差异在实际中几乎为零。现代编译器(gcc/clang -O2)会将 memmove 的指针比较视为常量推断——如果编译器能静态证明不重叠,分支可被消除。因此在 99% 的场景下,直接用 memmove 代替 memcpy 不会有可测量的性能损失。
6. 真实工程场景
6.1 数组删除元素——前移(dest < src,正向安全)
int arr[] = {10, 20, 30, 40, 50};
int len = 5;
// 删除 arr[1] = 20:将 arr[2..4] 前移一位
memmove(arr + 1, arr + 2, (len - 2) * sizeof(int));
// dest=arr+1, src=arr+2 → dest < src → 正向拷贝 ✓
len--;
// arr = {10, 30, 40, 50, 50}6.2 数组插入元素——后移(dest > src,必须反向)
int arr[10] = {10, 20, 30, 40, 50};
int len = 5;
// 在位置 1 插入 99:先将 arr[1..4] 后移一位
memmove(arr + 2, arr + 1, 4 * sizeof(int));
// dest=arr+2, src=arr+1 → dest > src → 必须反向拷贝!
arr[1] = 99;
len++;
// arr = {10, 99, 20, 30, 40, 50}6.3 字符串去除前缀
char str[] = " hello";
// 将 "hello" 前移覆盖前导空格
size_t spaces = 0;
while (str[spaces] == ' ') spaces++;
memmove(str, str + spaces, strlen(str + spaces) + 1);
// dest=str, src=str+3 → dest < src → 正向拷贝 ✓
// str = "hello"7. 跨对象指针比较——C 标准的边界
7.1 什么情况下指针比较合法?
C 标准 §6.5.8(关系运算符)对指针比较的规定:
当两个指针指向同一个数组对象的元素、或指向该数组最后一个元素之后的一个位置时,比较有定义。对于其他情况,行为未定义。
int arr[10];
int *p = arr;
int *q = arr + 5;
// ✅ 合法:p 和 q 指向同一数组内的元素
if (p < q) { /* ... */ }
int x, y;
int *px = &x;
int *py = &y;
// ❌ 未定义:px 和 py 指向不同的独立变量
// if (px < py) { /* UB!*/ }
// 但两个指针相等性比较 (== / !=) 在任意对象之间合法
if (px == py) { /* 合法:是一个确定的 false */ }7.2 为什么 memmove 中使用指针比较是安全的?
在 memmove 的实际使用场景中,调用者始终让 src 和 dest 指向同一块分配内存(同一数组、同一 malloc 块的子区域)。这保证了 d < s 的比较始终合法。
char buf[512];
char *mid = buf + 256;
// ✅ 合法:buf 和 mid 指向同一数组
memmove(mid, buf, 256); // d > s → 反向 ✓
// ❌ 不合法:两个独立的 malloc 块
char *a = malloc(100);
char *b = malloc(100);
// memmove(a, b, 50); // d 和 s 来自不同分配 → 指针比较 UB
// 而且这种用法本身就荒谬——为什么不直接用 memcpy?参考解答
练习: my_memmove 完整实现
#include <stdio.h>
#include <string.h>
void *my_memmove(void *dest, const void *src, size_t n)
{
char *d = (char *)dest;
const char *s = (const char *)src;
/* dest 和 src 指向同一地址,或者没有数据要拷贝 → 直接返回 */
if (d == s || n == 0)
return dest;
if (d < s) {
/* 情况 1: dest < src — 目标在源左边,正向拷贝安全 */
for (size_t i = 0; i < n; i++)
d[i] = s[i];
} else {
/* 情况 2: dest > src — 目标在源右边,必须反向拷贝
* 若正向会覆盖未读取的源数据(级联污染) */
for (size_t i = n; i > 0; i--)
d[i - 1] = s[i - 1];
}
return dest;
}
int main(void)
{
char buf[256];
fgets(buf, sizeof(buf), stdin);
/* 去掉末尾的 '\n' */
int i = 0;
while (buf[i] && buf[i] != '\n')
i++;
buf[i] = '\0';
/* 计算去掉换行后的字符串长度 */
size_t len = strlen(buf);
/* 核心测试: dest > src 的重叠场景
* 将 buf[0..len-2] 拷贝到 buf[1..len-1] — 整体右移一位
* 示例: "abcd" → "aabc"(第一个字符保留,后续字符右移覆盖)
*
* my_memmove(buf+1, buf, len-1):
* dest = buf+1, src = buf → dest > src → 走反向拷贝分支
*/
if (len > 0)
my_memmove(buf + 1, buf, len - 1);
printf("%s\n", buf);
return 0;
}核心决策说明:
- 三分支先判等:
d == s || n == 0提前返回避免了无意义的循环和 UB 风险。 d < s走正向:dest 在 src 左边时,正向拷贝是安全的——写入位置在源数据之前(更小的地址),不会覆盖尚未读取的字节。else走反向:此时d > s(因为d == s已被第一分支排除),必须从末尾开始拷贝。这种顺序保证了每一个"被写入的位置"的内存,在写入之前其对应的源数据已被读取并迁移。- 反向循环
i=n; i>0; i--:用d[i-1]=s[i-1]而非d[i]=s[i]避免越界。n=3时访问范围是 d[2]d[1]d[0],恰好覆盖 n 个字节。
对照检查:
void*转为char*了吗?三分支(==/</>)完整了吗?反向循环用的是for(i=n; i>0; i--)吗?返回的是dest(原始指针)吗?
课堂讨论
- 如果
dest和src完全不重叠,正向拷贝和反向拷贝结果一样吗?哪个方向对 CPU 缓存更友好? - 为什么 C 标准不直接把
memcpy做成memmove——让memcpy也处理重叠? - 能否用
memcpy实现memmove?如果能,哪些场景可以、哪些场景不可以? - 指针比较
d < s在所有 C 实现中都合法吗?如果不合法,什么情况下会出问题? - 为什么本课的测试用例选择
dest > src而不是dest < src?
讨论答案
Q1: 不重叠时正向和反向拷贝结果一样吗?
结果完全一样,但正向拷贝对 CPU 缓存更友好。
// src 和 dest 位于完全不重叠的两块内存
char src[] = "hello";
char dest[10];
memmove(dest, src, 6);
// 正向: i=0→5,读 src[0]→src[5]
// 反向: i=5→0,读 src[5]→src[0]
// 结果: dest = "hello" ✓(完全一致)
// 为什么正向更好?
// CPU 的缓存预取(prefetch)按正向递增地址工作
// 正向拷贝触发连续地址的 cache line 填充,反向拷贝则触发逆向地址访问
// 对于大块数据,正向的优势更明显在 memmove 的实现中,不重叠且 d < s 走正向分支——这恰好是对缓存最友好的方向。d > s 走反向是无奈之举,但对缓存的影响在实际使用中通常可忽略(数据已经在 L1 cache 中)。
Q2: 为什么 C 标准不直接把 memcpy 做成 memmove?
两个原因:历史和性能。
历史原因:memcpy 出现在最早的 C 标准(C89/ANSI C),当时假定程序员会自行保证参数不重叠。memmove 稍后加入,专门处理重叠场景——这是一个增量改进而非替代。
性能原因:memcpy 假设不重叠,编译器可以做更激进的优化:
- 硬件 DMA 传输假设源和目标不重叠
- SIMD 指令块拷贝可无序加载存储(不重叠保证无依赖)
- C99 起
memcpy正式声明为restrict,编译器据此生成更优代码
长远趋势:随着编译器优化能力增强,两者性能差异越来越小。LLVM/Clang 甚至会因具体上下文用 memmove 替换 memcpy(当重叠加安全更重要时)。
Q3: 能否用 memcpy 实现 memmove?
可以部分实现,但不能完全替代。
// 场景 1: dest < src — 可以用 memcpy(正向拷贝安全)
// src = &arr[2], dest = &arr[0]
memcpy(dest, src, n); // ✓
// 场景 2: dest > src — 不能用 memcpy(正向会污染数据)
// src = &arr[0], dest = &arr[1]
memcpy(dest, src, n); // ❌ 结果错误!
// 必须反向拷贝,而 memcpy 永远只做正向关键区别:
dest < src(前移)→memcpy正向可行,因为写入不会覆盖未读数据dest > src(后移)→memcpy正向不可行,因为写入会覆盖未读数据——必须反向memcpy无法做到反向拷贝——这是它的根本限制
因此:不能用 memcpy 实现通用的 memmove。 反之可以——memmove 是 memcpy 的安全超集。
Q4: 指针比较 d < s 在所有 C 实现中合法吗?
条件合法:只在指向同一数组(或同一分配块)内时合法。
C 标准 §6.5.8 规定:指正关系比较(<, >, <=, >=)仅在两正指针指向**同一数组的元素(或数组末尾之后一个位置)**时有定义。
// ✅ 合法:同一数组
int arr[10];
int *a = arr, *b = arr + 5;
if (a < b) { /* OK */ }
// ❌ 未定义行为:不同对象
int x, y;
int *px = &x, *py = &y;
if (px < py) { /* UB */ }
// ✅ 合法:同一 malloc 块
int *block = malloc(100 * sizeof(int));
int *mid = block + 50;
if (block < mid) { /* OK */ }在 memmove 的实际调用场景中,dest 和 src 始终指向同一个缓冲区或它的副本——调用 memmove 跨两个不相关的内存区域本身就没有意义。因此 memmove 中使用 d < s 的比较是安全且实用的。
Q5: 为什么只测 dest > src 而不测 dest < src?
因为 dest < src 场景正向拷贝就能正确处理——它不能区分 memcpy 和 memmove。
测试设计的基本原则:测试用例必须能区分正确实现和错误实现。如果只测 dest < src,即使你用 memcpy 的实现(不加方向判断)也能通过——测试失去了区别度。
// 场景 A: dest > src(本课测试用例)
my_memmove(buf+1, buf, len-1);
// 如果用 memcpy(不加方向判断): 输出 "aaaa" ❌
// 如果用 memmove(加方向判断): 输出 "aabc" ✓
// → 能区分正反与错误实现 ✓
// 场景 B: dest < src
my_memmove(buf, buf+2, 3);
// 如果用 memcpy(不加方向判断): 输出 "cde" ✓
// 如果用 memmove(加方向判断): 输出 "cde" ✓
// → 不能区分正确与错误实现 ✗这是面试中的经典考点——如果候选人写出了正向拷贝的代码,追问"如果 dest > src 会怎样"时,很多人画不出破坏过程。
课后练习
扩展:使用范围判断替代指针比较。不用指针比较
d < s,而是检反dest是否落在[src, src+n)的范围来判断是否需要反向拷贝。写出这种实现方式并分析其优点。知识点提示:
if (d > s && d < s + n)判断"dest 在源区间内"——这是 C 标准推荐的方式,不依赖跨对象指针比较的合法性。参考解答
cvoid *my_memmove_v2(void *dest, const void *src, size_t n) { unsigned char *d = (unsigned char *)dest; const unsigned char *s = (const unsigned char *)src; if (n == 0 || d == s) return dest; /* 检查是否有重叠: dest 是否落在 [src, src+n) 之间 */ if (d > s && d < s + n) { /* 危险重叠:反向拷贝 */ for (size_t i = n; i > 0; i--) d[i - 1] = s[i - 1]; } else { /* 安全:正向拷贝 */ for (size_t i = 0; i < n; i++) d[i] = s[i]; } return dest; }优点:不依赖指针比较的合法性——用
d < s + n表达的"dest 在 src 范围内"逻辑更清晰,在 C 标准层面上更安全。编写自测程序,验证三种重叠场景。写一个包含三个子测试的
selftest()函数:完全不重叠的拷贝、dest < src的前移拷贝、dest > src的后移拷贝。每种场景用独立缓冲区,用assert验证。知识点提示:不重叠场景——两个独立数组,拷贝后逐字节比较。前移场景——同一数组内模拟删除。后移场景——同一数组内模拟插入。
参考解答
c#include <stdio.h> #include <string.h> #include <assert.h> void selftest(void) { /* 测试 1: 完全不重叠 */ { char src[] = "hello world"; char dest[32] = {0}; my_memmove(dest, src, strlen(src) + 1); assert(strcmp(dest, "hello world") == 0); printf("Test 1 PASS: non-overlapping\n"); } /* 测试 2: dest < src — 前移(模拟删除) */ { char buf[] = "abcdef"; my_memmove(buf, buf + 2, 3); buf[3] = '\0'; assert(strcmp(buf, "cde") == 0); printf("Test 2 PASS: dest < src (forward)\n"); } /* 测试 3: dest > src — 后移(模拟插入) */ { char buf[] = "abcd"; my_memmove(buf + 1, buf, 3); assert(strcmp(buf, "aabc") == 0); printf("Test 3 PASS: dest > src (reverse)\n"); } printf("All tests passed!\n"); }性能对比:对 1 KB / 1 MB / 10 MB 的缓冲※各执行 1000 次
memcpy和memmove(都传不重叠的独立 buffer),测量各自的平均耗时,验证两者是否有可测量的性能差异。知识点提示:用
clock()测量时间。预期结果:现代编译器优化后两者几乎无差异。参考解答
c#include <stdio.h> #include <string.h> #include <stdlib.h> #include <time.h> #define ROUNDS 1000 double bench(void *(*fn)(void *, const void *, size_t), size_t sz) { char *src = malloc(sz), *dst = malloc(sz); memset(src, 0x42, sz); clock_t t0 = clock(); for (int i = 0; i < ROUNDS; i++) fn(dst, src, sz); clock_t t1 = clock(); free(src); free(dst); return (double)(t1 - t0) / CLOCKS_PER_SEC / ROUNDS * 1e9; } int main(void) { size_t sizes[] = {1024, 1024*1024, 10*1024*1024}; printf("%-10s %12s %12s\n", "Size", "memcpy(ns)", "memmove(ns)"); for (int i = 0; i < 3; i++) { double tm = bench(memcpy, sizes[i]); double tv = bench(memmove, sizes[i]); printf("%-10s %12.1f %12.1f\n", i==0?"1KB":i==1?"1MB":"10MB", tm, tv); } return 0; }预期:
memcpy和memmove的单次调用耗时几乎相同(差异在纳秒级,属测量误差范围)。阅读 material:Linux 内核的 memmove 实现。在 Linux 内核源码中搜索
memmove的汇编实现(arch/x86/lib/memmove_64.S),理解?在 x86-64 平台上如何使用std(设置方向标志)和rep movsb(重复字符串操作)来处理正向/反向拷贝。知识点提示:x86 的
rep movsb指令由DF(Direction Flag)控制方向:cld(clear DF)= 正向,std(set DF)= 反向。内核在汇编层面检查地址关系,设置 DF 后执行rep movsb,最后恢复 DF。这是 C 语言"指针比较+循环"语义下的硬件加速版本。
参考资料
man memmove— Linux 手册页,查看标准库memmove的函数签名与行为契约- C99 标准 §7.21.2.2 —
memmove函数的正式规范 - C99 标准 §6.5.8 — 关系运算符与指针比较规则
- K&R《C 程序设计语言》§5.4 指针与地址算术 — 指针比较的经典讲解
- Linux 内核 memmove 源码 — 工业级汇编实现
"The difference between a good programmer and a great one is the understanding of what happens at the boundaries." — Anonymous