内存函数深度解析:memmove、memcmp、memchr 的用法与避坑指南

发布时间:2026/10/21 16:56:04
内存函数深度解析:memmove、memcmp、memchr 的用法与避坑指南 我们上一篇把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, srcn)目标区域是[dest, destn)且src dest。如果你从前往后复制先把src[0]复制到dest[0]此时如果dest[0]恰好落在源区域内你实际上已经覆盖了还没被读取的源数据。比如src0x100dest0x108长度为 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实现做了大量优化按字长复制当内存对齐到sizeof(unsigned long)时会改用 4 字节、8 字节甚至 SIMD 指令比如 AVX2 的 256 位寄存器批量搬运性能比逐字节复制高出几个数量级。分块调度glibc 的memmove根据源、目标地址的偏移量以及长度选择不同的搬运策略比如先用单字节把地址对齐到字长边界再用字长批量复制最后处理尾部的剩余字节。处理页边界当长度大到跨越页边界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 大于 b2.3 不要拿它当哈希用同一内容也可能不相等这是我在实际工作中踩过一个很有意思的坑。当时要判断两个二进制结构体是否逻辑等价结构体里有填充字段padding bytes。在 C 语言里结构体的填充字节内容是不确定的——它可能残留栈上的旧数据。哪怕两个结构体的有效字段完全相同如果你直接用memcmp比较整个sizeof(struct)遇到填充字节位置时完全可能因为随机残留数据导致比较失败。我当时的解决方法是把结构体先memset清零或者在比较时手动排除填充字节typedef struct { uint8_t type; // 1 字节 uint8_t flags; // 1 字节 uint16_t seq; // 2 字节 // 没有填充字节因为 112 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; // 找到帧头 } // 否则从 p1 继续找 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这会产生很多额外的写入操作性能远不如memcpytypedef struct { char name[32]; int age; } Person; Person p; // 如果 name 真正有效的只有 Alicestrncpy 会把后 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.c5.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做全量比对。这套模式基本覆盖了底层网络模块、序列化层、嵌入式通信协议栈里最常见的操作组合。你照着这个思路去改造成自己项目里需要的样子比从零开始想快得多。写到这里内存函数这条线基本就完整了。上一篇是地基这篇是支柱后面的项目实践就是往上盖楼。如果你在看这篇的过程中有哪个函数的行为跟你的理解对不上建议写一段小的测试代码让编译器替你验证。有些结论光看文档是记不牢的亲手跑一遍才有体感。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询