手撕 memcpy 和 memmove:从字节级拷贝到内存重叠的终极较量

一、先想清楚:为什么需要"内存函数"

字符串函数只能处理以 '\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;
}

逐行拆:

  1. void* ret = dest; :先保存目标地址,最后返回。支持链式调用,跟 strcpy 返回原始地址一个道理。

  2. assert(dest && src); :断言非空。但注意,Release 下 NDEBUG 一开,assert 就没了。

  3. while (num--) :num 是 size_t,无符号。num-- 先用后减,当 num 为 0 时,条件为假,循环结束。但 num-- 会把 num 变成 (size_t)-1,即最大值。好在循环条件只判断一次,后面不再用 num,所以没事。

  4. *((char*)dest) = *((char*)src); :把 void* 转成 char*,然后逐字节赋值。为什么用 char*?因为 char 是 1 字节,能精确控制字节级拷贝。

  5. dest = (char*)dest + 1; :指针往后挪 1 字节。注意不能写 dest++,因为 void* 不能做算术。

  6. 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 不是简单的逐字节循环。它用了这些优化:

  1. 对齐处理:先处理开头不对齐的字节,让后面的地址对齐到 8 字节或 16 字节。

  2. 字长拷贝 :用 unsigned long 或 __m128i 一次拷 8 字节或 16 字节。

  3. SIMD 指令:用 SSE、AVX、AVX-512 一次拷 32 字节甚至 64 字节。

  4. 尾部处理:处理最后剩余的字节。

  5. 运行时调度:根据 CPU 支持的指令集,选择最优实现。

memmove 在此基础上还要判断方向:

  • 如果 dest < src,正向拷贝。

  • 如果 dest > src,反向拷贝。

  • 如果重叠且方向不对,反向拷贝。

现代 glibc 的 memmove 也用了 SIMD,性能非常接近 memcpy。


七、前沿视角:内存操作为什么还是安全重灾区

memcpy 是 C 语言里最危险的函数之一。为什么?

  1. 不检查目标大小 :memcpy(dest, src, num) 里的 num 由你决定,写大了就溢出。

  2. 不检查源大小 :如果 src 不够 num 字节,越界读。

  3. 不处理重叠:重叠时行为未定义。

  4. 类型不安全 :void* 不携带类型信息。

历史上大量漏洞都跟 memcpy 有关:

  • 缓冲区溢出

  • 堆溢出

  • 栈溢出

  • 信息泄露(越界读)

现代工具怎么防:

  • AddressSanitizer:运行时检测越界

  • FORTIFY_SOURCE :编译期检查已知大小的 memcpy

  • Valgrind:内存检测

  • Fuzzing:模糊测试

  • Rust :编译期所有权检查,copy_from_slice 带长度检查

  • C++ :std::copy、std::span

  • CHERI:硬件层面加边界检查

但工具再强,也得你懂原理。AI 能帮你写 memcpy,但它不一定知道 num 会不会超。


八、面试高频清单

  1. memcpy 和 memmove 的区别:重叠处理。

  2. memcpy 重叠是未定义行为。

  3. memmove 通过判断 dest 和 src 的相对位置决定拷贝方向。

  4. void* 不能解引用,不能做算术,必须转成 char*。

  5. 用 char* 是因为它 1 字节,能精确控制字节。

  6. 用 unsigned char* 更规范。

  7. memcpy 返回 void*,支持链式调用。

  8. memcpy 不检查目标大小,是安全重灾区。

  9. glibc 用 SIMD 优化 memcpy。

  10. 比较跨对象指针用 uintptr_t 更安全。

  11. memmove 在 dest == src 或 num == 0 时可以直接返回。

  12. 现代编译器能把 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 语言最核心的几个概念:

  1. void* 是通用指针 ,但不能直接操作,必须转 char*。

  2. memcpy 快但不安全,重叠是未定义行为。

  3. memmove 稳但稍慢,通过判断方向处理重叠。

  4. unsigned char* 比 char* 更规范。

  5. memcpy 是安全重灾区,不检查目标大小,工程里要自己封装。

如果面试官问你:"memcpy 和 memmove 有什么区别?"

你就答:memcpy 不处理重叠,重叠是未定义行为;memmove 通过判断方向决定拷贝顺序,能安全处理重叠。

如果问:"为什么 memcpy 不处理重叠?"

你就答:为了性能。不判断方向,代码更简单,编译器更容易优化成 SIMD。

如果问:"void* 能不能做算术?"

你就答:不能。void* 大小未定义,必须转成 char* 或具体类型才能做指针算术。

把这三点吃透,内存函数就不再是"背原型",而是你理解 C 内存模型的入口。

相关推荐
程序员_小兵1 小时前
PCIe Switch Fabric 技术解析
服务器·c语言·嵌入式硬件·php·fabric
白杨尚青1 小时前
C++入门篇(十一):string(中)——容量与增删改查:一篇吃透所有常用接口(万字详解)
java·开发语言·c++
老歌老听老掉牙1 小时前
旅行商问题:从数学模型到 Python 实战
开发语言·python
CC数分1 小时前
数据可视化入门:用Python把一堆数字变成一张图
开发语言·python·信息可视化·数据分析
java_nnnn1 小时前
JavaEE进阶-JavaScript初识
开发语言·前端·javascript·java-ee·ecmascript
朔北之忘 Clancy2 小时前
2024 CSP-J 第二轮真题解析
c++·算法·青少年编程·noip·csp·信奥赛·noi
YYYing.2 小时前
【设计模式系列 (五) 】原型模式
开发语言·后端·设计模式·原型模式·c/c++
重生之小比特2 小时前
【C++进阶】哈希表实现
开发语言·c++·哈希