38、内存操作:memcpy/memmove/memset/memcmp 的实现与区别、内存重叠问题

内存操作,说白了就是 C 语言里最底层的“搬砖”活。你写再花哨的算法,最后数据还是在内存里挪来挪去。我早年刚入行时,觉得这些函数太简单了,不就是复制、填充、比较嘛?直到有一次线上服务莫名其妙崩溃,查了两天,最后定位到是 memcpy 踩了内存重叠的坑……嗯,从那以后我再也不敢小看这几个函数了。

今天咱们就把 memcpymemmovememsetmemcmp 这四兄弟彻底聊透。不光讲用法,还要手撕源码,把内存重叠这个“隐形杀手”揪出来。

内存操作四兄弟 · 核心关系图 memcpy 复制(不处理重叠) memmove 复制(安全处理重叠) memset 填充(按字节) memcmp 比较(按字节) 升级 内存重叠场景(核心区别) 源地址 < 目标地址 且 重叠 源数据区 目标数据区 ← 重叠区域 memcpy:从前往后复制 → 可能覆盖未读数据 memmove:判断方向,从后往前复制 → 安全

1. memset:内存“刷墙”工

memset 的作用很简单:把一段内存的每个字节都设成同一个值。我习惯用它来清零结构体或数组,尤其是定义局部变量时,不清零的话里面可能是随机值,容易出 bug。

void *memset(void *s, int c, size_t n);

注意第二个参数是 int,但实际只取低 8 位(一个字节)。所以 memset(buf, 0x1234, 100) 等价于 memset(buf, 0x34, 100)

典型用法: 清零结构体、初始化数组、清空缓冲区。

我自己实现过一个简化版,核心就是逐字节赋值:

void *my_memset(void *s, int c, size_t n) {
    unsigned char *p = (unsigned char *)s;
    unsigned char ch = (unsigned char)c;
    for (size_t i = 0; i < n; i++) {
        p[i] = ch;
    }
    return s;
}

当然,标准库会用更高效的算法(比如按 4 字节或 8 字节对齐写入),但原理一样。

小技巧: 如果你要初始化一个 int 数组为 0,用 memset 没问题。但想初始化成全 1?memset(arr, 1, sizeof(arr)) 会把每个字节设成 1,结果每个 int 是 0x01010101,不是 1。这个坑我踩过。

2. memcpy 与 memmove:复制双胞胎,性格大不同

这两个函数都用于内存复制,原型几乎一样:

void *memcpy(void *dest, const void *src, size_t n);
void *memmove(void *dest, const void *src, size_t n);

区别就一句话:memcpy 不保证处理内存重叠,memmove 保证

什么叫内存重叠?就是源地址和目标地址指向的内存区域有交集。比如你想把数组 [1,2,3,4,5] 从位置 0 复制到位置 2,结果可能是 [1,2,1,2,1] 而不是 [1,2,1,2,3]——因为复制过程中把还没读的源数据覆盖了。

我曾经…… 在一个网络协议栈里用 memcpy 移动数据包缓冲区,结果包内容总是错位。查了半天才发现源和目的地址重叠了。换成 memmove 后问题立刻消失。所以,只要你不确定是否重叠,就用 memmove。性能损失微乎其微,安全第一。

手撕 memcpy 实现

void *my_memcpy(void *dest, const void *src, size_t n) {
    unsigned char *d = (unsigned char *)dest;
    const unsigned char *s = (const unsigned char *)src;
    for (size_t i = 0; i < n; i++) {
        d[i] = s[i];
    }
    return dest;
}

这个版本从前往后复制。如果 dest 在 src 后面且重叠,就会出问题。

手撕 memmove 实现

void *my_memmove(void *dest, const void *src, size_t n) {
    unsigned char *d = (unsigned char *)dest;
    const unsigned char *s = (const unsigned char *)src;

    if (d < s) {
        // 目标在前,从前往后复制(安全)
        for (size_t i = 0; i < n; i++) {
            d[i] = s[i];
        }
    } else if (d > s) {
        // 目标在后,从后往前复制(避免覆盖未读数据)
        for (size_t i = n; i > 0; i--) {
            d[i-1] = s[i-1];
        }
    }
    // 如果 d == s,什么都不用做
    return dest;
}

核心逻辑就是判断地址高低,决定复制方向。你想想看,如果目标地址比源地址大,从前往后复制就会把源数据尾部覆盖掉;反过来从后往前复制,先复制尾部,再复制头部,就安全了。

面试高频题: 请实现一个 memmove,并说明为什么 memcpy 不处理重叠。答案就是上面这段代码 + 方向判断。

3. memcmp:内存“照妖镜”

memcmp 用来比较两块内存是否相等,按字节比较:

int memcmp(const void *s1, const void *s2, size_t n);

返回值:相等返回 0;s1 < s2 返回负数;s1 > s2 返回正数。注意,它比较的是无符号字节,所以 0x800x7F 大。

我在项目中常用它来校验两个结构体是否相同,或者检查加密后的数据是否被篡改。但要注意:如果结构体里有指针成员,memcmp 比较的是指针值,不是指针指向的内容。这个区别很重要。

// 简单实现
int my_memcmp(const void *s1, const void *s2, size_t n) {
    const unsigned char *p1 = (const unsigned char *)s1;
    const unsigned char *p2 = (const unsigned char *)s2;
    for (size_t i = 0; i < n; i++) {
        if (p1[i] != p2[i]) {
            return p1[i] - p2[i];
        }
    }
    return 0;
}

4. 内存重叠问题深度剖析

咱们再深入聊聊内存重叠。很多初学者觉得“重叠就重叠呗,反正数据一样”。其实不然。看这个例子:

char buf[10] = "HelloWorld";
memcpy(buf + 2, buf, 6);  // 把 "Hello" 复制到 "lloWo" 的位置
// 期望结果:HeHello
// 实际结果:HeHeHeWo  (因为复制过程中源数据被覆盖)

为什么会这样?因为 buf 的地址是 0x100,buf+2 是 0x102。复制时,先复制 'H' 到 0x102,再复制 'e' 到 0x103,然后复制 'l' 到 0x104……但注意,当复制到第 3 个字节时,源地址 0x102 已经被改成了 'H',所以复制的是新值,不是原来的 'l'。这就是重叠导致的“脏读”。

memmove 会检测到目标地址大于源地址,于是从后往前复制:先复制 'o'(源地址 0x104)到 0x106,再复制 'l'(0x103)到 0x105……这样源数据始终是原始值,结果正确。

避坑指南: 永远不要假设你的数据不会重叠。尤其是在实现环形缓冲区、滑动窗口、数据包重组时,重叠是常态。我习惯在代码里统一用 memmove,除非能 100% 确定不重叠且对性能有极致要求(比如音视频编解码),才用 memcpy。

5. 总结对比表

函数 功能 处理重叠 性能 典型场景
memset 按字节填充 不涉及 清零、初始化
memcpy 内存复制 不保证 最高 无重叠复制
memmove 内存复制 安全处理 略低(方向判断) 不确定是否重叠
memcmp 按字节比较 不涉及 校验、判等

好了,这四兄弟的底裤基本被扒干净了。我个人建议:日常开发中,memset 放心用,memmove 优先用,memcpy 谨慎用,memcmp 注意字节序。记住这些,面试官再问内存操作,你就能从原理到实现,再到避坑,一条龙给他讲明白。

最后一个小提醒: 这些函数的参数 n 都是字节数,不是元素个数。很多人写 memcpy(arr1, arr2, sizeof(arr1)) 时,如果 arr1 是指针,sizeof 就是指针大小,不是数组大小。这个错误我见过不下十次。

公众号:蓝海资料掘金营,微信 deep3321