资讯动态

C语言内存函数详解:memcpy、memmove、memset、memcmp避坑指南

发布时间:2026/10/29 16:53:05 来源:尧图企业网站定制
做嵌入式或者底层开发的朋友对string.h里那几个mem开头的函数肯定不陌生。我刚入行那会儿总觉得strcpy用得挺顺手直到有一次在跨平台移植代码时结构体里带了个柔性数组用字符串函数拷贝直接踩了内存越界的坑调试了大半天才定位到问题。从那以后我对memcpy、memmove、memset、memcmp这四个函数的态度就变得格外谨慎。它们看起来简单参数就那么几个但真正用对、用稳里面有不少门道。这篇内容我就围绕这四个内存函数把它们的底层逻辑、适用边界、性能差异和踩坑经验一次讲透适合刚接触 C 语言内存操作的新手也适合想回头梳理底层细节的老手。1. 为什么这四个函数值得单独拎出来讲1.1 内存操作函数和字符串函数的本质区别很多人初学 C 语言时最先接触的是strcpy、strcmp、strlen这一套字符串函数。它们用起来直观遇到\0就停符合人类阅读习惯。但问题在于字符串函数的前提是数据必须以\0结尾而且中间不能出现\0。一旦你要处理的是二进制数据、结构体、图像像素缓冲区这个前提就不成立了。内存函数的设计思路完全不同。它们不关心数据内容是什么只认字节数和起始地址。memcpy(dst, src, n)的意思很直白从src开始老老实实搬n个字节到dst中间遇到什么值都不管。这个特性决定了它们能处理任意类型的数据包括浮点数组、结构体、协议报文缓冲区。我见过不少新手用strcpy去拷贝一个包含0x00字节的二进制缓冲区结果只拷了一半就停了后面全是脏数据。这种 bug 不会报错运行时也不崩但数据就是错的排查起来非常折磨人。所以理解这两类函数的边界是写好底层代码的第一道门槛。1.2 四个函数各自的职责定位这四个函数虽然都带mem前缀但职责划分很清晰我习惯用一句话概括它们函数核心职责典型场景memcpy非重叠内存块的字节拷贝结构体赋值、缓冲区初始化拷贝memmove支持重叠内存块的字节拷贝数组内元素前移/后移、缓冲区滑动窗口memset按字节填充指定值缓冲区清零、标志位批量置位memcmp按字节比较两块内存二进制数据比对、结构体内容校验这张表看着简单但每一项背后都有容易忽略的细节。比如memset是按字节填充不是按元素填充给int数组赋非零值就会出问题memcmp比较的是字节序列遇到结构体里的填充字节padding可能得到意外结果。这些细节我在后面会逐一展开。1.3 选型背后的性能与安全考量为什么标准库要提供memcpy和memmove两个几乎一样的函数而不是只留一个这背后是性能和安全性的权衡。memcpy的实现可以假设源和目标不重叠因此编译器和大厂库实现可以做激进优化比如按机器字长批量搬运、使用 SIMD 指令。而memmove必须处理重叠情况需要判断方向从高地址往低地址拷还是反过来多了一层分支判断理论上略慢一点。实际项目中如果你能百分百确认两块内存不重叠用memcpy性能更好但凡有一丝重叠的可能就必须用memmove否则行为未定义。我个人的习惯是只要源和目标来自同一个缓冲区一律用memmove宁可损失那一点点性能也不给自己埋雷。这个取舍在后面第 3 节会结合具体代码再讲。2. 四个函数的核心细节与实操要点2.1 memcpy最常用也最容易用错的拷贝函数memcpy的函数原型是void *memcpy(void *dest, const void *src, size_t n)返回dest指针。它的语义是拷贝n个字节源和目标内存区域不得重叠。这里的关键词是“不得重叠”一旦重叠标准明确说是未定义行为不同平台、不同编译器版本可能表现不一致。我踩过的一个典型坑是这样的在一个环形缓冲区里做数据搬移想把后面的数据往前挪几个字节图省事直接写了memcpy(buf, buf offset, len)。在某个平台上测试正常换到另一个编译器优化等级后数据就乱了。原因就是源和目标重叠memcpy的内部实现按字长批量拷贝时前面的写入覆盖了后面还没读的源数据。正确的做法是用memmove。如果你确实想用memcpy处理重叠场景那得自己保证拷贝方向正确比如从高地址往低地址手动循环但这已经是在重新实现memmove了没必要。另一个常见误区是参数顺序。memcpy(dest, src, n)是目标在前、源在后和赋值语句dest src的方向一致。但很多人写的时候会下意识把源写在前面尤其是从英文语义“copy from src to dest”直译过来时容易搞反。这种错误编译器不会报错因为两个参数类型都是void *运行时才表现为数据写到了错误的地方甚至直接段错误。提示拷贝结构体时如果结构体内部含有指针成员memcpy做的是浅拷贝两个结构体将指向同一块堆内存。释放时重复 free 会出问题这一点务必注意。关于返回值memcpy返回dest这个设计是为了支持链式调用比如memcpy(a, b, n)的返回值可以直接作为另一个函数的参数。但实际业务代码里我很少用到返回值更多是把它当过程调用。2.2 memmove重叠场景下的安全选择memmove的原型和memcpy完全一致区别只在语义它允许源和目标重叠。实现上标准库会先判断dest和src的地址关系如果dest在src前面低地址就从前往后拷如果dest在src后面就从后往前拷从而避免覆盖未读取的源数据。这个判断逻辑听起来简单但它是memmove存在的全部意义。我做过一个粗略的对比测试在 x86-64 平台上拷贝 1MB 数据memcpy和memmove在不重叠场景下的耗时差异通常在 5% 以内现代编译器和 glibc 对memmove的优化已经相当到位。所以“memmove一定比memcpy慢很多”这个说法并不准确除非你在极端性能敏感的循环里否则优先选安全的那个。实际使用中memmove最典型的场景是数组元素搬移。比如实现一个简单的动态数组删除中间某个元素后需要把后面的元素整体前移一位void array_remove_at(int *arr, size_t *len, size_t index) { if (index *len) return; size_t tail_count *len - index - 1; if (tail_count 0) { memmove(arr[index], arr[index 1], tail_count * sizeof(int)); } (*len)--; }这里如果用memcpy当tail_count大于 1 时就会出问题因为源区间[index1, len)和目标区间[index, len-1)明显重叠。这种代码在单元测试里可能侥幸通过因为小数据量下某些实现恰好没触发覆盖但换个大数组或者开高优化等级就原形毕露。注意memmove虽然安全但它的参数n依然是字节数不是元素个数。传sizeof(int)还是sizeof(int) * count要算清楚这是新手高频错误。2.3 memset按字节填充的陷阱与正确姿势memset的原型是void *memset(void *s, int c, size_t n)把从s开始的n个字节全部设置为c的低 8 位。注意这里说的是“字节”不是“元素”。这个细节决定了它的正确用法和常见错误。最经典的错误是给int数组赋非零值int arr[10]; memset(arr, 1, sizeof(arr)); // 错误每个字节变成 0x01这段代码执行后arr里每个int的四个字节都是0x01实际值变成0x01010101也就是十进制的 16843009完全不是想要的 1。正确做法是用循环赋值或者用memset只做清零memset(arr, 0, sizeof(arr)); // 正确全零清零之所以正确是因为 0 的每个字节都是 0按字节填充和按元素填充结果一致。这也是memset最常见的用途初始化缓冲区、清空结构体。我个人的习惯是凡是memset只用来清零非零填充一律用循环或者专门的初始化函数避免脑子一热写出上面那种 bug。还有一个容易忽略的点memset的第二个参数是int类型但只有低 8 位有效。传0x100和传0x00效果一样因为高位被截断。这个特性在某些场景下可以利用比如想填充0xFF传-1或0xFF都行但传0x1FF实际填充的还是0xFF。对于结构体清零memset(obj, 0, sizeof(obj))是标准做法但要注意如果结构体里有指针成员清零后指针变成NULL后续使用前必须重新分配。另外C 标准并不保证全零位的浮点数就是0.0虽然在 IEEE 754 平台上确实如此但严格来说这是实现定义行为。跨平台代码里浮点成员最好单独初始化。2.4 memcmp字节级比较的返回值玄机memcmp的原型是int memcmp(const void *s1, const void *s2, size_t n)比较两块内存的前n个字节。返回值规则是相等返回 0s1小于s2返回负数大于返回正数。注意这里说的是“负数”和“正数”不是固定的 -1 和 1。很多文档写“返回 -1/0/1”这是不准确的标准只保证符号具体数值取决于实现。这个细节在实际使用中影响不大因为大家通常只判断 0或者 0、 0。但如果你把返回值直接当索引或者做算术运算就可能出问题。我见过有人写int idx memcmp(a, b, n) 1;期望得到 0/1/2结果在某些平台上拿到的是别的值。memcmp比较的是字节序列按无符号字节逐个比较遇到第一个不同的字节就返回结果。这意味着它比较的是内存的二进制表示而不是逻辑值。对于结构体如果存在填充字节padding这些字节的值是不确定的memcmp可能因为填充字节不同而判定两个逻辑上相等的结构体不相等。typedef struct { char a; int b; } Packed; // 实际大小可能是 8 字节a 后面有 3 字节填充 Packed x {A, 1}; Packed y {A, 1}; // memcmp(x, y, sizeof(Packed)) 可能不等于 0这个坑在跨平台数据校验、哈希计算场景里特别隐蔽。解决办法有两个一是用#pragma pack或者手动排列成员消除填充二是逐字段比较而不是整体memcmp。我一般推荐后者虽然代码多一点但逻辑清晰、可移植性好。另外memcmp和strcmp的行为差异也要注意。strcmp遇到\0停止memcmp不管内容严格比较n个字节。比较二进制协议报文时用memcmp比较 C 字符串时用strcmp别混用。3. 完整实操从零实现一个安全的缓冲区管理模块3.1 需求拆解与接口设计光讲函数本身不够我拿一个实际的小模块来串一遍。假设要实现一个固定容量的字节缓冲区支持写入、读取、滑动窗口搬移和内容比对。这个场景在串口通信、网络收包、日志缓冲里非常常见。接口设计如下typedef struct { unsigned char *data; size_t capacity; size_t read_pos; size_t write_pos; } ByteBuffer; int bb_init(ByteBuffer *bb, size_t capacity); void bb_destroy(ByteBuffer *bb); size_t bb_write(ByteBuffer *bb, const void *src, size_t len); size_t bb_read(ByteBuffer *bb, void *dst, size_t len); void bb_compact(ByteBuffer *bb); int bb_equal(const ByteBuffer *a, const ByteBuffer *b);这里bb_compact负责把未读数据搬到缓冲区头部释放尾部空间正是memmove的用武之地。bb_equal用memcmp比较两个缓冲区的内容。bb_init用memset清零。bb_write和bb_read用memcpy搬运数据。3.2 核心函数的实现与参数计算先看初始化和销毁int bb_init(ByteBuffer *bb, size_t capacity) { if (!bb || capacity 0) return -1; bb-data (unsigned char *)malloc(capacity); if (!bb-data) return -1; memset(bb-data, 0, capacity); bb-capacity capacity; bb-read_pos 0; bb-write_pos 0; return 0; } void bb_destroy(ByteBuffer *bb) { if (!bb) return; free(bb-data); bb-data NULL; bb-capacity 0; bb-read_pos 0; bb-write_pos 0; }memset在这里的作用是把新分配的缓冲区清零虽然逻辑上未写入的区域不会被读取但清零能避免调试时看到脏数据也方便后续用memcmp做整体比对。这个习惯我强烈建议保留多花的那点时间远小于排查脏数据 bug 的成本。写入函数需要处理剩余空间计算size_t bb_write(ByteBuffer *bb, const void *src, size_t len) { if (!bb || !src || len 0) return 0; size_t free_space bb-capacity - bb-write_pos; size_t to_write (len free_space) ? len : free_space; if (to_write 0) { memcpy(bb-data bb-write_pos, src, to_write); bb-write_pos to_write; } return to_write; }这里memcpy的源是外部传入的src目标是缓冲区内部地址两者不可能重叠所以用memcpy是安全的。to_write取len和free_space的较小值保证不越界。返回值告诉调用方实际写入了多少字节调用方可以根据这个值判断是否需要重试或丢弃。读取函数类似size_t bb_read(ByteBuffer *bb, void *dst, size_t len) { if (!bb || !dst || len 0) return 0; size_t available bb-write_pos - bb-read_pos; size_t to_read (len available) ? len : available; if (to_read 0) { memcpy(dst, bb-data bb-read_pos, to_read); bb-read_pos to_read; } return to_read; }关键在bb_compact这是memmove的主场void bb_compact(ByteBuffer *bb) { if (!bb) return; size_t unread bb-write_pos - bb-read_pos; if (unread 0) { bb-read_pos 0; bb-write_pos 0; return; } if (bb-read_pos 0) { memmove(bb-data, bb-data bb-read_pos, unread); bb-read_pos 0; bb-write_pos unread; } }memmove的源是bb-data bb-read_pos目标是bb-data两者在同一块缓冲区里明显重叠。如果这里误用memcpy当unread大于read_pos时前面的写入会覆盖后面还没搬的源数据导致数据错乱。这个 bug 在小数据量、低优化等级下可能不显现一旦数据量上来就必现。比对函数用memcmpint bb_equal(const ByteBuffer *a, const ByteBuffer *b) { if (!a || !b) return 0; size_t len_a a-write_pos - a-read_pos; size_t len_b b-write_pos - b-read_pos; if (len_a ! len_b) return 0; if (len_a 0) return 1; return memcmp(a-data a-read_pos, b-data b-read_pos, len_a) 0; }先比较有效数据长度长度不同直接返回不等避免无意义的memcmp调用。长度相同再比内容。这个顺序上的小优化在频繁比对的场景里能省不少时间。3.3 编译验证与边界测试记录代码写完后我习惯用一组边界用例跑一遍。测试点包括容量为 1 的最小缓冲区、写入超过容量、读取超过可读长度、read_pos等于write_pos时的 compact、两个空缓冲区比对。编译时我开了-Wall -Wextra -fsanitizeaddressAddressSanitizer 能捕获越界访问和重叠拷贝的未定义行为。实测下来把bb_compact里的memmove故意改成memcpy在写入 100 字节、读取 50 字节后再 compactASan 直接报memcpy-param-overlap定位非常快。这也是我推荐大家调试内存问题时优先开 ASan 的原因比肉眼盯代码高效得多。另一个测试点是memset清零后立即memcmp。两个刚初始化的缓冲区有效长度都是 0bb_equal应该返回 1。如果实现里漏了长度判断直接memcmp传 0 长度标准规定返回 0逻辑上也对但多一次无意义调用。这种细节不影响正确性但体现代码的严谨程度。4. 常见问题排查与避坑经验实录4.1 高频错误速查表我把这些年遇到的和这四个函数相关的问题整理成一张表方便快速对照现象可能原因排查方向数据只拷贝了一部分误用字符串函数处理二进制检查是否用了strcpy/strlen拷贝后数据错乱memcpy处理了重叠区域改用memmoveint数组值异常巨大memset按字节填充非零值改用循环赋值结构体比对结果不稳定填充字节内容不确定逐字段比较或消除 padding程序随机崩溃拷贝长度超出目标容量检查n的计算开 ASan返回值判断失效假设memcmp返回 -1/1只判断符号不判断具体值这张表里前四项是我实际踩过的后两项是 review 别人代码时发现的。尤其是第一项新手用strcpy拷贝含0x00的协议报文现象是“有时候对有时候错”因为报文里0x00出现的位置不固定排查时容易被误导。4.2 参数顺序与长度计算的经典坑memcpy(dest, src, n)的参数顺序错误我前面提过这里补充一个更隐蔽的变体在封装函数里把参数透传时搞反。比如自己写了个my_copy(void *a, void *b, size_t n)内部调用memcpy(a, b, n)但调用方以为a是源、b是目标。这种错误在跨模块协作时特别常见因为函数签名里两个void *看不出方向。我的应对办法是命名上做区分参数名用dst和src而不是a和b。另外在函数注释里明确写清楚方向虽然多写几个字但能省下大量沟通成本。长度计算方面最常见的错误是把元素个数当字节数传。memcpy(dst, src, count)里count如果是元素个数而元素类型不是char就会少拷贝。正确写法是count * sizeof(ElementType)。我习惯在代码里显式写出乘法哪怕sizeof(char)等于 1 也写上保持一致性review 时一眼就能看出长度单位对不对。还有一个和sizeof相关的坑数组作为函数参数传递后会退化为指针此时sizeof(arr)得到的是指针大小而不是数组大小。在函数内部用memset(arr, 0, sizeof(arr))只会清零前 8 个字节64 位平台而不是整个数组。解决办法是把数组长度作为参数传进来或者用宏在数组未退化时计算长度。4.3 性能敏感场景的取舍心得在性能敏感的代码里这四个函数的选型需要多想一想。memcpy在小块数据几十字节以内上的开销主要来自函数调用本身现代编译器在开优化时可能把它内联成几条指令这时候手写循环未必更慢。但在大块数据上库实现的memcpy通常会用到 SIMD 指令和预取比手写循环快一个数量级这种时候千万别自己造轮子。memset清零大缓冲区时如果缓冲区后续会被完整覆盖写那这次清零其实是多余的。我见过一些代码在malloc之后习惯性memset清零然后马上memcpy覆盖全部内容这次清零纯属浪费。判断标准很简单清零后到第一次完整写入之间有没有代码会读取这块内存。没有读取就可以省掉。memcmp的比较在遇到第一个不同字节时就返回所以它的耗时和“两块内存从开头起有多长的相同前缀”相关而不是和总长度线性相关。比较两个几乎相同的长缓冲区很快比较两个开头就不同的缓冲区更快。这个特性在哈希碰撞检测、数据去重场景里可以利用先比长度再比内容能过滤掉大部分不相等的情况。提示跨平台代码里不要假设memcpy对重叠区域的行为也不要假设memcmp的具体返回值。标准只规定了符号和未定义行为不同 libc 实现、不同编译器优化都可能改变表现。最后分享一个我个人的习惯凡是涉及内存操作的函数参数里带长度的我都在调用前用断言检查一遍边界比如assert(offset len capacity)。断言在 release 版本里会被去掉不影响性能但在开发和测试阶段能第一时间暴露越界问题。配合 ASan 一起用内存类 bug 基本无处遁形。这套组合拳我在多个项目里用过定位效率比单纯打日志高太多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑