☰
内存函数深度解析:memmove、memcmp、memchr 的用法与避坑指南
2026/10/21 16:55:40 网站建设 项目流程

我们上一篇把memcpy、memset这两个最常用的内存函数讲透了,从源码实现到对齐陷阱都有涉及。评论区有不少同学催更,说还想看memmove和memcmp这种进阶一点的内存函数怎么用、怎么避坑,也有同学问能不能把memchr这类查找函数也一并讲了。

那这篇就顺着上一篇继续往后写,重点解决几个上一篇遗留但没展开的问题:memcpy和memmove到底差在哪、为什么memcmp的返回值不能直接拿来当布尔值用、memchr在解析二进制协议时能玩出哪些花活。这篇文章依旧是建立在 C11 标准的基础上,代码实测环境是 x86-64 架构的 Linux,编译器用的 GCC 11.4。

如果你还没看过上一篇,建议先回去把memcpy的对齐问题搞清楚,因为这一篇里很多优化技巧和陷阱都跟内存对齐逃不开关系。准备好了的话,我们直接进入正题。

1.memmove不是memcpy的简单复制品

很多初学者第一次接触memmove时,听到最多的解释就是"memcpy不处理内存重叠,memmove处理内存重叠",这话没错,但如果只是这么理解,你会漏掉大量实现细节和性能差异。这俩函数的核心区别在于重叠区域的复制方向和调度策略,这直接决定了它们在不同场景下的性能表现和安全性。

1.1 重叠复制的方向判断:正向还是反向

先问你一个问题:如果源地址在目标地址前面,也就是src < dest,且两者的范围有重叠,你应该从前往后复制还是从后往前复制?

答案是:必须从后往前。

原因很简单。假设源区域是[src, src+n),目标区域是[dest, dest+n),且src < dest。如果你从前往后复制,先把src[0]复制到dest[0],此时如果dest[0]恰好落在源区域内,你实际上已经覆盖了还没被读取的源数据。比如src=0x100,dest=0x108,长度为 8 字节,从前往后复制时,第一步把0x100的内容写到0x108,但0x108本身是原本0x108处的数据,它还没被复制走,这样一来0x108的原值就丢了,后面的复制全是错的数据。

从后往前复制则完全规避这个问题:先从src[7]开始,写到dest[7],每一步写的位置都在已读位置的后方,永远也不会踩到还没读的源数据。

反过来,如果dest < src,也就是目标地址在前面,你就得从前往后复制。这是一套非常朴素的"谁先被覆盖谁先被搬走"的逻辑。

1.2 一个亲手验证过的实现

我建议你至少在调试器里跑一遍下面这段逻辑,不要只停留在理论上:

void *my_memmove(void *dest, const void *src, size_t n) { if (dest == NULL || src == NULL || n == 0) { return dest; } 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; }

注意for (size_t i = n; i > 0; i--)这个写法,不能写成for (size_t i = n; i >= 0; i--),因为size_t是无符号整数,i >= 0永远为真,你会得到无限循环。这种坑我已经看无数人踩过了,而且是在写 memmove 这种系统级函数时踩的,调试起来特别隐蔽。

1.3 编译器真正做了什么:库函数的实现远超你想象

自己手写版本只是为了理解原理,真实场景下你应该永远调用标准库的memmove。拿主流的 glibc 来说,它的memmove实现做了大量优化:

  1. 按字长复制:当内存对齐到sizeof(unsigned long)时,会改用 4 字节、8 字节甚至 SIMD 指令(比如 AVX2 的 256 位寄存器)批量搬运,性能比逐字节复制高出几个数量级。
  2. 分块调度:glibc 的memmove根据源、目标地址的偏移量以及长度,选择不同的搬运策略,比如先用单字节把地址对齐到字长边界,再用字长批量复制,最后处理尾部的剩余字节。
  3. 处理页边界:当长度大到跨越页边界(4KB)时,还会考虑 TLB 的影响,调整复制顺序来减少缺页异常。

这也是为什么我反复强调:别自己写 memmove,直接用标准库。你写的版本不一定错,但性能一定赶不上经过几十年调优的库函数。

1.4 什么时候选 memcpy,什么时候选 memmove

我在实际项目里养成了一个习惯:如果目标内存是刚分配的,或者我能从逻辑上保证没有重叠,就用memcpy。因为memcpy少了重叠检测这一层,理论上可以有更激进的优化路径,实测在极端场景下比memmove快那么一点点(但在大多数通用 CPU 上差距可以忽略不计)。

但一旦你操作的内存来自缓冲区复用、内存池、或者结构体内字段搬移,直接用memmove,不要犹豫。不要觉得"我确认了没重叠所以用 memcpy 更高效",这种自信往往是 bug 的温床。我之前在某个跨平台通信模块里,就因为一个memcpy导致收包缓存里的数据被覆盖,排查了两天才发现是缓冲区复用后源地址与目标地址存在 16 字节的重叠。

一句话:逻辑上没重叠且追求极致性能,用 memcpy;任何存在重叠争议嫌疑的场景,无脑 memmove。你的时间比那几纳秒值钱。

2.memcmp的返回值设计:比你想的更有深意

memcmp是另一个被人用错的函数。先看声明:

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

很多人把它当成strcmp的"字节版",觉得返回值就是负数、零、正数三种情况。这个理解方向是对的,但三个细节很多人不知道。

2.1 比较逻辑:从低地址到高地址,遇差即停

memcmp从s1与s2的首字节开始,按无符号unsigned char逐字节比较,遇到第一个不相等的字节就返回它们的差值。也就是说它不一定完整遍历 n 个字节。

这跟哈希校验完全不同:哈希校验要看完所有字节才能给出结果,而memcmp是短路逻辑。这在设计上有一个很大的好处——对不同类型的数据结构进行快速比较时,通常不相等的位置出现在头部,那memcmp就非常快。

举个例子,你想比较两个 4KB 的结构体是否一致,如果它们在第一个字段就不同,memcmp只比较几个字节就返回了,而不是傻乎乎地把 4KB 全部比完。

2.2 返回值不是 -1/0/1,而是 "差值"

你绝对不要假设返回值一定是 -1 或 1。标准规定:返回值是第一个不同字节处(unsigned char)s1[i] - (unsigned char)s2[i]的差值,这个差值可能是 -255 到 255 之间的任意整数。

看个实际案例:

char buf1[4] = {0x01, 0x02, 0x03, 0x04}; char buf2[4] = {0x01, 0x02, 0x03, 0x06}; int result = memcmp(buf1, buf2, 4); // result = 0x04 - 0x06 = -2

没错,返回的是-2而不是-1。如果你的代码写成if (memcmp(a, b, n) == -1)或者if (memcmp(a, b, n) == 1),那在某些数据下就会得到错误的结果。

正确的姿势是只判断三种关系:

if (memcmp(a, b, n) < 0) // a 小于 b if (memcmp(a, b, n) == 0) // a 等于 b if (memcmp(a, b, n) > 0) // a 大于 b

2.3 不要拿它当哈希用:同一内容也可能不相等

这是我在实际工作中踩过一个很有意思的坑。当时要判断两个二进制结构体是否"逻辑等价",结构体里有填充字段(padding bytes)。在 C 语言里,结构体的填充字节内容是不确定的——它可能残留栈上的旧数据。哪怕两个结构体的有效字段完全相同,如果你直接用memcmp比较整个sizeof(struct),遇到填充字节位置时,完全可能因为随机残留数据导致比较失败。

我当时的解决方法是把结构体先memset清零,或者在比较时手动排除填充字节:

typedef struct { uint8_t type; // 1 字节 uint8_t flags; // 1 字节 uint16_t seq; // 2 字节 // 没有填充字节,因为 1+1+2 = 4 正好对齐 } __attribute__((packed)) PacketHeader;

如果不方便改结构体定义,就用手动逐字段比较的方式:

int headers_equal(const PacketHeader *a, const PacketHeader *b) { return a->type == b->type && a->flags == b->flags && a->seq == b->seq; }

注意:memcmp比较的是内存中的原始字节序列,不是逻辑业务上的"相等"。这是它和strcmp(针对字符串语义)之间最根本的思维差异。

3.memchr在二进制数据里"找人"的艺术

memchr的函数原型:

void *memchr(const void *s, int c, size_t n);

作用:在s指向的内存块前n个字节内,查找第一次出现的字节c(以unsigned char解释),找到就返回指向该位置的指针,找不到返回NULL。

它和strchr最大的区别在于:strchr遇到\0会停,memchr不会。因此memchr是唯一能在二进制数据(可能包含\0)里查找字节的标准库函数。这决定了它在协议解析、序列化数据扫描、文件格式解析中无可替代的地位。

3.1 实际应用:从一个数据帧里切出关键字段

我给你模拟一个非常典型的场景:解析自定义二进制协议帧,帧结构是[帧头 0xAA 0xBB][类型 1字节][长度 2字节][payload...][校验 1字节]。现在要从接收缓冲区里找到某个0xAA 0xBB帧头的位置。

#include <stdio.h> #include <string.h> #define FRAME_SIGNATURE_0 0xAA #define FRAME_SIGNATURE_1 0xBB char *find_frame_header(const char *buf, size_t buf_len) { if (buf == NULL || buf_len < 2) { return NULL; } // 先找第一个 0xAA char *p = (char *)memchr(buf, FRAME_SIGNATURE_0, buf_len); while (p != NULL) { // 检查 p 后面是否还有至少 1 字节 if ((size_t)(p - buf) < buf_len - 1 && (unsigned char)p[1] == FRAME_SIGNATURE_1) { return p; // 找到帧头 } // 否则从 p+1 继续找 size_t remaining = buf_len - (size_t)(p - buf) - 1; if (remaining == 0) { break; } p = (char *)memchr(p + 1, FRAME_SIGNATURE_0, remaining); } return NULL; } int main(void) { char buffer[] = {0x01, 0x02, 0xAA, 0xBB, 0x10, 0x00, 0x05, 0x41}; char *header = find_frame_header(buffer, sizeof(buffer)); if (header != NULL) { printf("找到帧头,偏移量 = %ld\n", header - buffer); } else { printf("未找到帧头\n"); } return 0; }

这段代码的重点是第二步memchr(p + 1, ...)——第一次没匹配到完整帧头时,不能直接放弃,要把搜索窗口向右挪一个字节继续找。这其实是一个朴素字符串匹配问题,你的第一个模式是\xAA\xBB,就按 KMP 或暴力匹配的思路去写。

3.2 硬件加速下的 memchr:比你想象的快

在 glibc 的实现里,memchr同样用了 SIMD 优化。x86-64 上,它会一次性读取 16 字节(SSE)甚至 32 字节(AVX2),然后用掩码判断每个字节是否等于目标值,而不是逐字节一个cmp。所以在大块二进制数据里反复memchr是非常高效的。

但这里有个性能误区:很多人觉得"循环调用 memchr 找每一个分隔符"比"自己写一个循环逐字节扫描"更慢,理由是函数调用开销。实测下来恰恰相反,因为memchr的 SIMD 加速完全碾压任何手写逐字节循环。在我某次解析 10MB 日志文件时,从逐字节扫变成memchr找换行符,时间从 120ms 降到了 30ms 左右。

所以一个非常实用的建议是:能用标准库函数解决的事,永远优先用标准库,不要自己造轮子。这个原则放在内存函数上尤其成立,因为 glibc 的实现比你想象中疯狂得多。

3.3 用 memchr 模拟"字节串查找":手写 memmem

标准库里有strstr但它是字符串专用(遇\0停止),C 标准并没有提供memmem这个函数(虽然在 GNU 扩展里有)。那要在二进制数据里找一段字节序列,该怎么办?

简单做法是用两层循环,外层遍历每个起始位置,内层用memcmp比较是否匹配。结合memchr可以做一个优化:先找模式串第一个字节的候选位置,再用memcmp比对剩余部分。

void *search_bytes(const void *haystack, size_t haystack_len, const void *needle, size_t needle_len) { if (needle_len == 0 || haystack_len < needle_len) { return NULL; } const unsigned char *h = (const unsigned char *)haystack; const unsigned char *n = (const unsigned char *)needle; for (size_t i = 0; i <= haystack_len - needle_len; ) { const unsigned char *p = (const unsigned char *) memchr(h + i, n[0], haystack_len - needle_len - i + 1); if (p == NULL) { break; } size_t offset = (size_t)(p - h); if (memcmp(p, n, needle_len) == 0) { return (void *)p; } i = offset + 1; // 移动一个字节,继续找 } return NULL; }

这个实现比纯暴力循环快,因为它先用 SIMD 加速的memchr缩小了memcmp的调用次数。你的模式串第一位越"稀有",剪枝效果就越明显。

4. 内存函数族 vs 字符串函数族:一张表看懂差异

很多同学问过我一个问题:"memcmp和strcmp到底有什么区别?memcpy和strcpy呢?"其实它们本质上是两套完全不同的东西。字符串函数族以\0作为边界判定,它们操作的是"具有语义的字符序列";内存函数族以长度n作为边界,操作的是"纯字节序列"。

推荐你用下面这张对照表快速判断该用哪个:

函数对字符串版本内存版本核心区别
复制strcpy/strncpymemcpy/memmove字符串版遇\0停止;内存版必须指定长度
比较strcmp/strncmpmemcmp字符串版遇\0停止,且差异定位到\0;内存版比较的是完整长度
查找strchr/strstrmemchr/memmem(GNU)字符串版遇\0停止;内存版适合二进制数据
填充strnset(非标准)memset内存版可以直接覆盖所有字节

4.1 注意strncpy与memcpy的一个大坑

strncpy虽然接收n参数,但它和memcpy的行为完全不同:strncpy遇到\0后,会在剩余位置**继续填充\0**直到填满n个字节——这就是著名的"strncpy用\0填充尾部"行为。

很多人在复制结构体里的固定大小字符数组时,习惯用strncpy(dest, src, sizeof(dest)),觉得这样安全,但实际上如果你确认源字符串长度小于等于目标缓冲区大小,strncpy会把剩余字节全部填充为0,这会产生很多额外的写入操作,性能远不如memcpy:

typedef struct { char name[32]; int age; } Person; Person p; // 如果 name 真正有效的只有 "Alice",strncpy 会把后 27 个字节全填 0 strncpy(p.name, "Alice", sizeof(p.name)); // 如果确定源长度一定小于等于目标,用以下方式更高效: // memset(p.name, 0, sizeof(p.name)); // memcpy(p.name, "Alice", 6); // 注意长度要自己算

这里有另一个经验之谈:sizeof(dest)提供的是编译期固定大小,但它默认就是 32 字节;memcpy需要你传入实际要用的大小。你如果贪图省事直接传sizeof,一旦源字符串长度超过了目标缓冲区,memcpy就会越界写坏栈上数据。所以选用哪个函数永远取决于你是否真的知道数据长度。

5. 性能陷阱:为什么你的内存函数调用会意外的慢

很多人以为调用memcpy就是最优解,其实不是。下面几个场景我全部亲手踩过,每个都能让你的程序在数据量上来之后突然卡顿。

5.1 冷热路径:小长度复制也别忽视

有时候一个函数被调用几百万次,每次只复制 10 字节,看起来又小又热,但就是因为调用了memcpy进入了一个短循环调用,导致分支预测频繁失败,性能反而比手写 2 条赋值语句慢。

GCC 的-finline-stringops优化选项恰好解决这个问题。它在-O2以上默认开启,让编译器把较小的内存操作内联成普通的加载与存储指令,而不是调用函数。如果你在嵌入式环境里手动关掉了优化,可以考虑打开:

gcc -O2 -finline-stringops main.c

5.2 内存不对齐:SIMD 优化的隐形杀手

上一篇我详细说过memcpy的对齐,这里再补充一个与memmove密切相关的情况。当你把源地址和目标地址都正确对齐到unsigned long大小后,memmove会走快速的字长复制路径;但如果你手动构造了一个偏移 2 字节的缓冲,库函数就只能退回到逐字节复制或者做一些额外的对齐调整。性能差距可能在 4 到 10 倍以上。

所以在你设计协议格式、缓存分配、内存池时,尽量让缓冲区起始地址天然对齐到 8 字节甚至 16 字节。用带对齐属性的结构体、用posix_memalign分配对齐内存,都是值得的。

5.3 缓存一致性:跨核心复制时的隐藏问题

这是更底层的坑。当两个线程通过共享内存进行通信,一个线程写入共享缓冲区,另一个线程用memcpy读取时,看似只是普通复制,但实际上缓存一致性协议(如 MESI)会让这次复制产生大量的缓存同步开销。这不是memcpy本身慢,而是硬件层面为了保证数据一致性付出的代价。

在这个场景下,你需要考虑采用无锁队列或内存屏障 + 发布订阅的模式,而不是反复memcpy同一个热点区域。有些同学一开始不理解为什么共享了内存还要复制一份数据,一般到了做多线程性能优化时就会明白——避免缓存抖动比省一次memcpy重要得多。

6. 一次性串联:用内存函数族手写一个小型字节缓冲区

理论讲了不少,最后用一个小项目把所有函数串起来。假设你在写一个底层收发模块,需要一个不依赖realloc的字节缓冲区,并且支持追加、查找、读取等操作。

下面的代码是一个完全可编译可运行的最小实现:

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> typedef struct { uint8_t *data; size_t capacity; size_t length; } ByteBuffer; int bb_init(ByteBuffer *bb, size_t capacity) { bb->data = (uint8_t *)malloc(capacity); if (bb->data == NULL) { return -1; } bb->capacity = capacity; bb->length = 0; return 0; } void bb_destroy(ByteBuffer *bb) { free(bb->data); bb->data = NULL; bb->capacity = bb->length = 0; } // 追加一段数据,必要时扩容 int bb_append(ByteBuffer *bb, const void *src, size_t len) { if (bb->length + len > bb->capacity) { size_t new_capacity = bb->capacity * 2; while (new_capacity < bb->length + len) { new_capacity *= 2; } uint8_t *new_data = (uint8_t *)malloc(new_capacity); if (new_data == NULL) { return -1; } // 用 memmove 是因为旧缓冲区地址可能与新缓冲区地址有重叠, // 用 memcpy 在这种扩容场景下理论上也可能没问题, // 但保险起见我会用 memmove memmove(new_data, bb->data, bb->length); free(bb->data); bb->data = new_data; bb->capacity = new_capacity; } memcpy(bb->data + bb->length, src, len); bb->length += len; return 0; } // 从缓冲区头部消费 len 字节,把剩余数据前移 int bb_consume(ByteBuffer *bb, size_t len) { if (len > bb->length) { return -1; } size_t remaining = bb->length - len; // 前移剩余数据,这是典型的缓冲区重叠场景 memmove(bb->data, bb->data + len, remaining); bb->length = remaining; return 0; } // 在缓冲区里查找指定字节序列 void *bb_find(const ByteBuffer *bb, const void *pattern, size_t pattern_len) { if (pattern_len == 0 || pattern_len > bb->length) { return NULL; } const uint8_t *p = (const uint8_t *)pattern; size_t i = 0; while (i <= bb->length - pattern_len) { const uint8_t *candidate = (const uint8_t *) memchr(bb->data + i, p[0], bb->length - i); if (candidate == NULL) { break; } size_t offset = (size_t)(candidate - bb->data); if (memcmp(candidate, p, pattern_len) == 0) { return (void *)candidate; } i = offset + 1; } return NULL; } int main(void) { ByteBuffer bb; if (bb_init(&bb, 16) != 0) { return 1; } const char *hello = "Hello, ByteBuffer!"; bb_append(&bb, hello, strlen(hello) + 1); // 加上 '\0' const char *needle = "Byte"; void *found = bb_find(&bb, needle, 4); if (found != NULL) { printf("在偏移 %ld 处找到模式\n", (const char *)found - (const char *)bb.data); } // 消费前 7 个字节 "Hello, " bb_consume(&bb, 7); printf("消费后缓冲区内容: %s\n", bb.data); bb_destroy(&bb); return 0; }

这个字节缓冲区的妙处在于,它把memcpy、memmove、memchr、memcmp全部串到了一个连贯的业务逻辑里:

  • bb_append用memcpy追加数据;
  • 扩容时用memmove搬移旧数据,防止新旧地址重叠导致出错;
  • bb_consume用memmove做内存前移,这是最经典的重叠场景;
  • bb_find用memchr加速定位首字节,再用memcmp做全量比对。

这套模式基本覆盖了底层网络模块、序列化层、嵌入式通信协议栈里最常见的操作组合。你照着这个思路去改造成自己项目里需要的样子,比从零开始想快得多。

写到这里,内存函数这条线基本就完整了。上一篇是地基,这篇是支柱,后面的项目实践就是往上盖楼。如果你在看这篇的过程中有哪个函数的行为跟你的理解对不上,建议写一段小的测试代码,让编译器替你验证。有些结论,光看文档是记不牢的,亲手跑一遍才有体感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询