第8章:字符串内存操作:memset、memcpy、memmove、memcmp 的区别与实现

大家好,我是老刘。今天咱们聊聊C语言里那四个让人又爱又恨的内存操作函数——memset、memcpy、memmove、memcmp。说实话,我见过太多人在这些函数上栽跟头了。尤其是刚入行的朋友,总觉得它们差不多,用起来随便挑一个就行。嗯,这种想法很危险。

你想想看,如果只是简单的字符串拷贝,用strcpy就够了。但当你面对的是二进制数据、结构体、或者需要精确控制内存布局的场景时,这四个函数就是你的看家本领。今天我就把它们的区别、实现、以及我踩过的坑,一次性给你讲透。

8.1 为什么需要内存操作函数?

字符串函数(比如strcpy、strcat)遇到'\0'就停。但内存操作函数不一样——它们只认长度,不认结束符。这意味着你可以操作任意类型的数据:整数数组、结构体、甚至是一段音频流。

我在做嵌入式项目时,经常需要把传感器数据打包成字节流发送出去。这时候strcpy根本没用,因为数据里可能包含0x00。你只能用memcpy。说白了,字符串函数是给“人读的文本”用的,内存函数是给“机器读的二进制”用的。

8.2 memset:把内存块填成统一值

memset的原型很简单:

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

它的作用是把s指向的内存区域的前n个字节,全部设置成c(实际只取c的低8位)。

常见用法:

  • 初始化数组为0
  • 清空结构体
  • 重置缓冲区

举个例子:

#include <stdio.h>
#include <string.h>

typedef struct {
    int id;
    char name[32];
    float score;
} Student;

int main() {
    Student stu;
    // 清空结构体,确保所有字段初始为0
    memset(&stu, 0, sizeof(Student));
    printf("id = %d, name = '%s', score = %.2f\n", stu.id, stu.name, stu.score);
    return 0;
}

重要提醒:memset的第二个参数是int,但实际只取低8位。所以memset(buf, 0x1234, 100)等价于memset(buf, 0x34, 100)。我曾经见过有人想初始化int数组为1,写了memset(arr, 1, sizeof(arr)),结果每个字节都是0x01,int值变成了0x01010101——完全不是他想要的效果。

我的习惯:初始化结构体时,我永远用memset(&obj, 0, sizeof(obj)),而不是逐个字段赋值。这样既安全又高效,还能避免漏掉某个字段。

8.3 memcpy:高效但危险的内存拷贝

memcpy的原型:

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

它从src拷贝n个字节到dest。注意:src和dest不能重叠。如果重叠,行为是未定义的。

为什么不能重叠?因为memcpy的实现通常是从前往后拷贝。如果dest在src后面且两者重叠,拷贝过程中会覆盖src还没读到的数据,导致结果错误。

看一个典型实现:

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

这个实现很简单,但性能一般。很多库会用字(4字节或8字节)对齐拷贝来加速。不过那是优化层面的事,咱们先理解原理。

我曾经踩过的坑:有一次我需要把数组元素后移一位,写了memcpy(arr+1, arr, (n-1)*sizeof(int))。结果arr[0]被拷贝到arr[1]后,arr[1]又被拷贝到arr[2]……最后整个数组变成了arr[0]的重复。这就是典型的重叠问题。从那以后,只要不确定是否重叠,我就用memmove。

8.4 memmove:安全的拷贝,哪怕重叠

memmove的原型和memcpy一模一样:

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

区别在于:memmove保证即使dest和src重叠,结果也是正确的。它通过判断地址大小,决定从前往后还是从后往前拷贝。

实现思路:

  • 如果dest < src,从前往后拷贝(和memcpy一样)
  • 如果dest > src,从后往前拷贝,避免覆盖

看代码:

void *my_memmove(void *dest, const void *src, size_t n) {
    if (dest == NULL || src == NULL) return NULL;
    
    char *d = (char *)dest;
    const char *s = (const char *)src;
    
    if (d < s) {
        // 从前往后
        for (size_t i = 0; i < n; i++) {
            d[i] = s[i];
        }
    } else {
        // 从后往前
        for (size_t i = n; i > 0; i--) {
            d[i-1] = s[i-1];
        }
    }
    
    return dest;
}

性能对比:memmove比memcpy多了一次判断和可能的分支,但现代CPU的分支预测很准,性能差异微乎其微。我个人建议:除非你100%确定不重叠,否则一律用memmove。安全第一,别为了那点性能埋雷。

8.5 memcmp:比较两块内存是否相等

memcmp的原型:

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

它逐字节比较s1和s2的前n个字节。返回值:

  • 0:相等
  • <0:s1小于s2
  • >0:s1大于s2

注意:比较的是无符号字节。所以即使有符号char是负数,也会被当作正数比较。

实现:

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;
}

使用场景:比较两个结构体是否相等、校验数据完整性、判断密码哈希值。但注意:结构体可能有padding字节(对齐填充),这些字节的值是未定义的。直接memcmp两个结构体可能得到错误结果。我一般会先memset清零再赋值,或者逐个字段比较。

8.6 四者对比一览表

函数 功能 重叠安全 典型用途 注意事项
memset 填充内存块 不涉及 初始化、清空 只取低8位,不能初始化非0值
memcpy 拷贝内存 不安全 不重叠的拷贝 性能略高,但风险大
memmove 拷贝内存 安全 任何拷贝场景 推荐优先使用
memcmp 比较内存 不涉及 数据校验、相等判断 注意padding和字节序

8.7 知识体系图

下面这张图帮你理清这四个函数的关系和选择逻辑:

字符串内存操作函数知识体系 内存操作函数 memset memcpy memmove memcmp 填充内存块 初始化/清空 注意:只取低8位 拷贝内存 不重叠场景 ⚠ 重叠不安全 安全拷贝 任何场景 ✅ 重叠安全 比较内存 数据校验 注意padding 选择建议 不确定是否重叠?→ 用 memmove 需要初始化?→ 用 memset 需要比较?→ 用 memcmp(注意结构体padding)

8.8 避坑指南与实战经验

我在项目里见过太多因为内存操作函数用错导致的bug。下面这几个坑,你一定要注意:

  1. memset初始化非0值:想初始化int数组为1?别用memset。它按字节填充,结果会是0x01010101。正确做法是用循环赋值。
  2. memcpy重叠拷贝:只要src和dest指向的内存区域有重叠,就别用memcpy。哪怕你觉得“应该没问题”,也请用memmove。我吃过这个亏,不想你再吃一次。
  3. memcmp比较结构体:结构体有padding字节,值不确定。直接memcmp可能误判。要么memset清零后再用,要么逐个字段比较。
  4. 长度参数错误:sizeof(指针)和sizeof(数组)不一样。传参时数组退化为指针,sizeof得到的是指针大小(4或8字节),不是数组大小。我建议用sizeof(数组名)或明确计算长度。
  5. 忽略返回值:memset、memcpy、memmove都返回目标地址。虽然不检查也能用,但链式调用时很有用。比如:char *p = (char *)memcpy(buf, src, n) + n;

我曾经犯过的错:有一次我写网络协议栈,需要把接收缓冲区里的数据往前挪。我用了memcpy(buf, buf+offset, len),结果数据全乱了。调试了一下午才发现是重叠问题。从那以后,我写了个宏:#define SAFE_COPY(d,s,n) memmove(d,s,n),强制自己用memmove。虽然有点极端,但再也没出过这类bug。

8.9 总结

好了,今天的内容就到这里。memset、memcpy、memmove、memcmp这四个函数,说白了就是C语言里操作内存的四大金刚。记住:

  • memset用来填,memcpy/memmove用来搬,memcmp用来比
  • 不确定是否重叠?用memmove
  • 初始化结构体?先memset清零
  • 比较结构体?小心padding

这些函数看着简单,但用错了后果很严重。希望今天的讲解能帮你避开我当年踩过的坑。下次见!


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