一、先想清楚:为什么需要"内存函数"
字符串函数只能处理以 '\0' 结尾的字符数组。但现实中,你要拷贝的东西可能是:
-
一个
int数组 -
一个结构体
-
一段二进制数据
-
一段堆上分配的内存
这些数据里可能包含 '\0',也可能根本没有"结束符"这个概念。所以 C 标准库提供了 memcpy、memmove、memset、memcmp 这些按字节操作的内存函数。
它们的参数不是 char*,而是 void*。void* 是"通用指针",可以接收任意类型的地址,但不能直接解引用,也不能直接做算术 。用之前必须转成具体类型,比如 char*。
这就是你代码里为什么反复出现 (char*)dest 和 (char*)src。
二、my_memcpy:逐字节正向拷贝
先看你的:
c
void* my_memcpy(void* dest, const void* src, size_t num)
{
void* ret = dest;
assert(dest && src);
while (num--)
{
*((char*)dest) = *((char*)src);
dest = (char*)dest + 1;
src = (char*)src + 1;
}
return ret;
}
逐行拆:
-
void* ret = dest;:先保存目标地址,最后返回。支持链式调用,跟strcpy返回原始地址一个道理。 -
assert(dest && src);:断言非空。但注意,Release 下NDEBUG一开,assert就没了。 -
while (num--):num是size_t,无符号。num--先用后减,当num为 0 时,条件为假,循环结束。但num--会把num变成(size_t)-1,即最大值。好在循环条件只判断一次,后面不再用num,所以没事。 -
*((char*)dest) = *((char*)src);:把void*转成char*,然后逐字节赋值。为什么用char*?因为char是 1 字节,能精确控制字节级拷贝。 -
dest = (char*)dest + 1;:指针往后挪 1 字节。注意不能写dest++,因为void*不能做算术。 -
return ret;:返回目标原始地址。
这段代码逻辑是对的。my_memcpy(arr2, arr1+1, sizeof(int)*5) 会把 arr1[1] 到 arr1[5] 这 5 个 int 拷到 arr2。输出:
text
2 3 4 5 6 0 0 0 0 0
但这里有个关键问题:memcpy 不能处理内存重叠。
标准原文说:如果 dest 和 src 指向的内存区域有重叠,memcpy 是未定义行为 。你的实现是正向拷贝,如果 dest > src 且区域重叠,就会把源数据覆盖掉,拷出来的结果不对。
比如:
c
int arr[] = {1, 2, 3, 4, 5};
my_memcpy(arr + 1, arr, sizeof(int) * 3);
// 期望: {1, 1, 2, 3, 5}
// 实际: {1, 1, 1, 1, 5} ← 覆盖了
因为先拷 arr[0] 到 arr[1],此时 arr[1] 变成 1;再拷 arr[1] 到 arr[2],但 arr[1] 已经被覆盖成 1 了,所以 arr[2] 也变成 1。
这就是 memmove 存在的理由。
三、my_memmove:判断方向,安全处理重叠
看你的:
c
void* my_memmove(void* dest, const void* src, size_t num)
{
void* ret = dest;
assert(dest && src);
if (dest < src)
{
// 前->后的拷贝
while (num--)
{
*(char*)dest = *(char*)src;
dest = (char*)dest + 1;
src = (char*)src + 1;
}
}
else
{
// 后->前的拷贝方式
while (num--)
{
*((char*)dest + num) = *((char*)src + num);
}
}
return ret;
}
思路是对的:
-
dest < src:目标在源前面,正向拷贝不会覆盖还没读的数据,安全。 -
dest > src:目标在源后面,正向拷贝会覆盖,得从后往前拷。
但这里有两个面试级细节:
细节一:dest < src 比较指针,跨对象是未定义行为。
C 标准说,用关系运算符比较两个不指向同一数组的指针,是未定义行为。虽然实践中大多数平台能跑,但严格来说应该转成 uintptr_t 比较:
c
if ((uintptr_t)dest < (uintptr_t)src)
或者更标准的做法:判断区域是否重叠,再决定方向。
细节二:while (num--) 在反向拷贝里的问题。
c
while (num--)
{
*((char*)dest + num) = *((char*)src + num);
}
当 num 为 5 时,num-- 返回 5,循环体里 num 已经变成 4,所以先拷下标 4,再拷 3、2、1、0。正确。
但当 num 为 0 时,num-- 返回 0,条件为假,循环结束,num 变成 (size_t)-1。循环体没执行,所以没事。
更清晰的 memmove 实现
c
void* my_memmove(void* dest, const void* src, size_t num)
{
void* ret = dest;
assert(dest && src);
if (dest == src || num == 0)
{
return ret;
}
unsigned char* d = (unsigned char*)dest;
const unsigned char* s = (const unsigned char*)src;
if (d < s)
{
// 正向拷贝
for (size_t i = 0; i < num; i++)
{
d[i] = s[i];
}
}
else
{
// 反向拷贝
for (size_t i = num; i > 0; i--)
{
d[i - 1] = s[i - 1];
}
}
return ret;
}
用 unsigned char* 更规范,因为 char 可能是有符号的,而内存操作应该按无符号字节处理。
四、memcpy 和 memmove 的本质区别
| 特性 | memcpy | memmove |
|---|---|---|
| 能否处理重叠 | 不能,未定义行为 | 能,安全 |
| 实现复杂度 | 简单,正向拷贝 | 需要判断方向 |
| 性能 | 通常更快 | 略慢,因为要判断 |
| 适用场景 | 确定不重叠 | 可能重叠 |
| 标准要求 | 重叠是 UB | 必须正确 |
面试常问:
memcpy和memmove有什么区别?
memcpy不处理重叠,重叠是未定义行为;memmove处理重叠,通过判断方向决定拷贝顺序。
为什么memcpy不处理重叠?为了性能。不判断方向,代码更简单,编译器更容易优化成 SIMD 指令。
如果确定不重叠,用哪个?用
memcpy,它更快。
memmove一定比memcpy慢吗?不一定。现代编译器可能把
memmove优化成memcpy,如果它能证明不重叠。但通常memmove多一次判断,略慢。
五、为什么用 void* 和 char*
void* 的作用:
-
接收任意类型的指针,不用强制转换。
-
表示"我只是一个地址,不知道指向什么类型"。
-
不能解引用,不能做算术,必须转成具体类型。
char* 的作用:
-
char是 1 字节,char* + 1正好跳 1 字节。 -
内存操作本质上是字节操作,用
char*最合适。 -
标准规定
char的宽度是 1 字节,sizeof(char) == 1。
为什么不用 int*?
因为 int* + 1 跳 4 字节(或 8 字节,取决于平台),不能精确控制字节。
为什么用 unsigned char* 更好?
因为 char 可能是有符号的,负值在某些操作里会出问题。内存操作应该按无符号字节处理。
六、glibc 怎么实现 memcpy 和 memmove
glibc 的 memcpy 不是简单的逐字节循环。它用了这些优化:
-
对齐处理:先处理开头不对齐的字节,让后面的地址对齐到 8 字节或 16 字节。
-
字长拷贝 :用
unsigned long或__m128i一次拷 8 字节或 16 字节。 -
SIMD 指令:用 SSE、AVX、AVX-512 一次拷 32 字节甚至 64 字节。
-
尾部处理:处理最后剩余的字节。
-
运行时调度:根据 CPU 支持的指令集,选择最优实现。
memmove 在此基础上还要判断方向:
-
如果
dest < src,正向拷贝。 -
如果
dest > src,反向拷贝。 -
如果重叠且方向不对,反向拷贝。
现代 glibc 的 memmove 也用了 SIMD,性能非常接近 memcpy。
七、前沿视角:内存操作为什么还是安全重灾区
memcpy 是 C 语言里最危险的函数之一。为什么?
-
不检查目标大小 :
memcpy(dest, src, num)里的num由你决定,写大了就溢出。 -
不检查源大小 :如果
src不够num字节,越界读。 -
不处理重叠:重叠时行为未定义。
-
类型不安全 :
void*不携带类型信息。
历史上大量漏洞都跟 memcpy 有关:
-
缓冲区溢出
-
堆溢出
-
栈溢出
-
信息泄露(越界读)
现代工具怎么防:
-
AddressSanitizer:运行时检测越界
-
FORTIFY_SOURCE :编译期检查已知大小的
memcpy -
Valgrind:内存检测
-
Fuzzing:模糊测试
-
Rust :编译期所有权检查,
copy_from_slice带长度检查 -
C++ :
std::copy、std::span -
CHERI:硬件层面加边界检查
但工具再强,也得你懂原理。AI 能帮你写 memcpy,但它不一定知道 num 会不会超。
八、面试高频清单
-
memcpy和memmove的区别:重叠处理。 -
memcpy重叠是未定义行为。 -
memmove通过判断dest和src的相对位置决定拷贝方向。 -
void*不能解引用,不能做算术,必须转成char*。 -
用
char*是因为它 1 字节,能精确控制字节。 -
用
unsigned char*更规范。 -
memcpy返回void*,支持链式调用。 -
memcpy不检查目标大小,是安全重灾区。 -
glibc 用 SIMD 优化
memcpy。 -
比较跨对象指针用
uintptr_t更安全。 -
memmove在dest == src或num == 0时可以直接返回。 -
现代编译器能把
memmove优化成memcpy,如果证明不重叠。
九、练习题与答案
练习 1:实现 my_memcpy,用 unsigned char*
c
void* my_memcpy(void* dest, const void* src, size_t num)
{
void* ret = dest;
unsigned char* d = (unsigned char*)dest;
const unsigned char* s = (const unsigned char*)src;
for (size_t i = 0; i < num; i++)
{
d[i] = s[i];
}
return ret;
}
练习 2:实现 my_memmove,处理重叠
c
void* my_memmove(void* dest, const void* src, size_t num)
{
void* ret = dest;
if (dest == src || num == 0) return ret;
unsigned char* d = (unsigned char*)dest;
const unsigned char* s = (const unsigned char*)src;
if (d < s)
{
for (size_t i = 0; i < num; i++)
d[i] = s[i];
}
else
{
for (size_t i = num; i > 0; i--)
d[i - 1] = s[i - 1];
}
return ret;
}
练习 3:实现 my_memset
c
void* my_memset(void* dest, int value, size_t num)
{
void* ret = dest;
unsigned char* d = (unsigned char*)dest;
for (size_t i = 0; i < num; i++)
{
d[i] = (unsigned char)value;
}
return ret;
}
练习 4:实现 my_memcmp
c
int my_memcmp(const void* s1, const void* s2, size_t num)
{
const unsigned char* p1 = (const unsigned char*)s1;
const unsigned char* p2 = (const unsigned char*)s2;
for (size_t i = 0; i < num; i++)
{
if (p1[i] != p2[i])
return p1[i] - p2[i];
}
return 0;
}
练习 5:下面代码输出什么?
c
int arr[] = {1, 2, 3, 4, 5};
memcpy(arr + 1, arr, sizeof(int) * 3);
for (int i = 0; i < 5; i++) printf("%d ", arr[i]);
答案:未定义行为。常见输出 1 1 1 1 5,但标准不保证。
练习 6:下面代码输出什么?
c
int arr[] = {1, 2, 3, 4, 5};
memmove(arr + 1, arr, sizeof(int) * 3);
for (int i = 0; i < 5; i++) printf("%d ", arr[i]);
答案:1 1 2 3 5。
练习 7:为什么 memcpy 比 memmove 快?
答案:memcpy 不需要判断方向,代码更简单,编译器更容易优化成 SIMD 指令。memmove 多一次判断,且反向拷贝可能影响 CPU 预取。
练习 8:写一个 safe_memcpy,检查目标缓冲区大小
c
#include <string.h>
#include <assert.h>
int safe_memcpy(void* dest, size_t dest_size, const void* src, size_t num)
{
assert(dest && src);
if (num > dest_size) return -1;
memcpy(dest, src, num);
return 0;
}
十、收尾
今天这两个函数,看起来只是"拷内存",其实藏着 C 语言最核心的几个概念:
-
void*是通用指针 ,但不能直接操作,必须转char*。 -
memcpy快但不安全,重叠是未定义行为。 -
memmove稳但稍慢,通过判断方向处理重叠。 -
unsigned char*比char*更规范。 -
memcpy是安全重灾区,不检查目标大小,工程里要自己封装。
如果面试官问你:"memcpy 和 memmove 有什么区别?"
你就答:memcpy 不处理重叠,重叠是未定义行为;memmove 通过判断方向决定拷贝顺序,能安全处理重叠。
如果问:"为什么 memcpy 不处理重叠?"
你就答:为了性能。不判断方向,代码更简单,编译器更容易优化成 SIMD。
如果问:"void* 能不能做算术?"
你就答:不能。void* 大小未定义,必须转成 char* 或具体类型才能做指针算术。
把这三点吃透,内存函数就不再是"背原型",而是你理解 C 内存模型的入口。