字符函数和字符串函数这个系列,走到第四篇了。前面几篇把 <ctype.h> 里的字符分类、大小写转换,还有 <string.h> 里的 strlen、strcpy、strcat、strcmp 这些最常用的字符串函数都过了一遍。按很多教材的进度,到这里大部分人就开始写项目了,但工作中真正让我吃过亏的,反而是这篇要讲的四个函数:memcpy、memmove、memcmp、memset。它们虽然也放在 string.h 里,操作的对象却常常不是字符串,而是一块内存。不夸张地说,字符串函数如果你只掌握前三种,常规业务功能够用;但一旦开始碰协议解析、缓冲区管理、结构体序列化,这一篇才是真正的分水岭。这篇适合已经掌握 strlen/strcpy 那些基本函数、想进一步把内存操作理清楚的读者,也适合准备面试、想看手写实现和避坑细节的朋友。
1. 从str系列到mem系列:字符串函数体系里的“分水岭”
1.1 整个系列讲到这里,还缺哪一块
回顾一下 string.h 的函数版图,会发现它其实分成两拨:
一拨是 str* 开头的,比如 strlen、strcpy、strcat、strcmp、strchr、strstr。它们的共同特征是:一切以 \0 为边界。strcpy 从源字符串开头一直拷到 \0 为止,strcmp 比较到某个字符串出现 \0 就停止,strlen 更是靠数 \0 前面的字符数量来返回长度。这套规则在纯文本处理里很自然,也足够好用。
另一拨就是 mem* 开头的内存操作函数,也就是 memcpy、memmove、memcmp、memset。它们不关心 \0,只关心一个明确指定的字节数 n。对它们来说,内存就是内存,里面放的是字符串、整数数组、结构体还是网络报文,完全无所谓,反正都是一串字节。
这个区分就是本篇的核心。C语言里字符串本质上是一个以 \0 结尾的 char 数组,所以 str* 家族处理的是“有语义的文本”,而 mem* 家族处理的是“没有语义的裸字节”。二进制数据里随时可能出现 0x00,如果一个协议头里恰好有值为 0 的字段,strcpy 和 strcmp 会直接罢工,这时候只能靠 mem* 函数。
1.2 mem系列为什么容易让新手栽跟头
有位读者之前问过我一个问题:为什么我在项目里明明用对了 strcpy,换成 memcpy 之后程序却随机崩溃?我让他把代码发过来,发现他把 memcpy(dest, src, strlen(src)) 里的长度少写了 1,漏掉了 \0。strcpy 是“自己判断边界”,memcpy 是“长度全部由你负责”,这个思维切换不过来,就会踩坑。
举个例子,你有一个 char buf[16] = "hello world";,想把其中 world 的前两个字符 wo 单独拷出来。如果用 strncpy,它会在源字符串不足时自动补 \0,反而会多做很多无意义的事;更直接的做法其实是 memcpy(dest, buf + 6, 2),不关心边界、不关心 \0,拷完就是两个字节。
这种“裸操作”的定位,让 mem* 系列成为 C 语言里同时兼具“高效”和“危险”两种属性的函数。它们处理的数据可以很大,比如拷一个 10MB 的缓冲区,一条 memcpy 就完成;也可以很底层,比如清零一个结构体。但也正因为没有边界检查,没有长度推断,所有防守都要调用者自己负责。读这一篇的时候,请把“我在亲手搬运字节”这个意识带上,后面所有坑都从这里来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. memcpy:最常用的拷贝函数,也是越界事故高发区
2.1 原型与三个需要刻进DNA的细节
memcpy 的原型非常短:
c复制void *memcpy(void *dest, const void *src, size_t n);
它的语义是:从 src 指向的地址开始,拷贝 n 个字节到 dest 指向的地址,返回 dest。就这么多。但越是短的东西越藏坑,至少有三个细节需要刻进 DNA。
第一,参数顺序是目标在前、源在后,和 strcpy 一致。我见过不止一个同事把 memcpy(src, dest, n) 写反——因为 void* 对类型检查宽松,编译器根本不报错,结果就是源数据被覆盖或者目标越界。写反之后的症状非常诡异,尤其是两个缓冲区大小差不多时,可能运行很久才炸,排查起来特别痛苦。
第二,dest 和 src 不能重叠。C 标准明确说,memcpy 遇到重叠区域属于未定义行为,不要指望它帮你兜底。有些平台内部正好能应付重叠,有些平台用了 SIMD 指令后重叠会产生奇怪的结果,所以“碰巧能用”绝不能作为依赖。
第三,n 是字节数,不是元素个数,更不是字符串长度。这一点新手最容易出错。
字节数到底怎么算,我整理了一个速查:
| 拷贝对象 | 正确的 n 写法 |
|---|---|
字符串(带 \0) |
strlen(src) + 1 |
| 数组(完全拷贝) | sizeof(arr) |
| 结构体(完全拷贝) | sizeof(StructType) |
| 指针指向的内存 | 必须自己维护长度,没有捷径 |
2.2 用一段代码演示最常见的三个事故现场
事故一:长度漏掉 \0。
c复制char src[] = "hello";
char dst[16];
memcpy(dst, src, strlen(src)); // 漏了 +1,dst 末尾没有 '\0'
printf("%s\n", dst); // 大概率打印出 hello + 垃圾字符,或者直接越界
这段代码只拷贝了 5 个字节,而字符串实际占 6 个字节。dst[5] 是栈上残留的随机数据,后续一旦调用 strlen(dst) 就可能读到很远。很多“程序偶尔打印乱码”的问题,根源就在这里。
事故二:对指针用 sizeof 求内存大小。
c复制char *src = malloc(64);
strcpy(src, "hello");
char dst[64];
memcpy(dst, src, sizeof(src)); // sizeof(src) 是 8,不是 64,也不是 6
这里 sizeof(src) 返回的是指针本身的大小,64 位平台上是 8 字节,最后只拷贝了 8 个字节,字符串内容根本没拷全。而且更坑的是,因为 dst 容量大,这段代码不会崩溃,只会悄悄产生错误结果。你单步调试可能都看不出问题,一直到下游逻辑发现数据不对才回头查。
事故三:dest 空间不足。
c复制char dst[8];
memcpy(dst, "this is a long string", 21); // 直接写穿栈
memcpy 不检查目标容量,它只管拷贝 n 字节。目标空间不够就写穿,轻则破坏相邻变量,重则栈溢出被利用。工程上如果长度不确定,必须先做容量检查再拷贝,或者使用带长度上限的安全封装。
这里补一个面试高频题:手写 memcpy。基础版本其实很简单:
c复制void *my_memcpy(void *dest, const void *src, size_t n) {
char *d = (char *)dest;
const char *s = (const char *)src;
for (size_t i = 0; i < n; i++) {
d[i] = s[i];
}
return dest;
}
关键点有三个:第一,void* 不能直接解引用,必须强转成 char*,因为 char 占 1 字节,指针加减和赋值都以字节为单位;第二,src 是 const,函数不能修改源数据;第三,返回值必须是 dest,这样能保持和标准库一致的接口语义。至于性能优化——比如按 4 字节、8 字节一次拷贝——那是后话,先把逐字节的正确版本写出来,再谈优化。
3. memmove:重叠拷贝救星,和memcpy差的那一行判断
3.1 什么场景才会触发重叠,用实际例子推演
你是否遇到过这种情况:想把数组里的元素整体右移一位,或者在一个缓冲区里把数据往后挪一点,好腾出前面的空间。这种场景下,源内存和目标内存会有一部分重叠,问题就来了。
看这个最经典的例子:
c复制int arr[5] = {1, 2, 3, 4, 5};
memcpy(arr + 1, arr, 4 * sizeof(int));
// 期望结果:{1, 1, 2, 3, 4}
期望是把 arr[0] 到 arr[3] 这四个数拷到 arr[1] 到 arr[4],但 memcpy 的结果不可预期。我们手动推演一下正向拷贝的过程就明白了:
初始数组是 {1, 2, 3, 4, 5}。
第一步,arr[1] = arr[0],数组变成 {1, 1, 3, 4, 5},此时 arr[1] 已经不是原始数据 2 了。
第二步,arr[2] = arr[1],注意这里的 arr[1] 已经是上一步被改写过的 1,所以数组变成 {1, 1, 1, 4, 5}。原始数据 3 还没被拷贝就已经被覆盖了。
第三步,arr[3] = arr[2] = 1,数组变成 {1, 1, 1, 1, 5}。
最终结果完全错误。
如果把源和目标的位置反过来,memcpy(arr, arr + 1, 4 * sizeof(int)),期望得到 {2, 3, 4, 5, 5},正向拷贝反而是正确的。这说明重叠拷贝是否出错,取决于目标在源的前面还是后面。
3.2 手写实现与边界判断
memmove 的存在就是专门解决重叠问题的。它的原型和 memcpy 一样,但允许源和目标区域重叠。正确的实现思路是:
- 如果目标在源的前面(
dest < src),从前往后拷贝。因为目标位置不会覆盖尚未被读取的源数据。 - 如果目标在源的后面(
dest > src),从后往前拷贝。先把后面的源数据拷到后面的目标位置,再从后往前一点点挪。 - 如果地址相同,直接返回。
这个逻辑可以打个比方:一整排书要往右挪一个位置,如果从左边第一本开始拿,你每次放下的书都会盖住右手边还没拿的下一本;必须从最右边开始,一本一本往右放,才能保证每本书都完好。往左挪则相反,从左边开始才是安全的。
手写版本:
c复制void *my_memmove(void *dest, const void *src, size_t n) {
char *d = (char *)dest;
const char *s = (const char *)src;
if (d < s) {
while (n--) {
*d++ = *s++;
}
} else if (d > s) {
d += n;
s += n;
while (n--) {
*--d = *--s;
}
}
return dest;
}
这里有两个细节值得说。
第一,d 和 s 必须先转成 char*,才能用 <、> 做指针比较。void* 不能做关系运算。而且这种比较只有在一个对象内部才有意义,重叠场景恰好满足这个前提。
第二,反向拷贝时,我先把指针移动到末尾再在循环里“先减后拷”。注意 while (n--) 的边界:最后一次循环进入时,n 的值已经是 0,但循环体已经执行了一次。这种写法简洁,但对初学者不太友好,如果怕出错,可以改用 for 循环,从 n 减到 0,每次手动计算 d[i] 和 s[i] 的位置,效果一样。
3.3 memcpy和memmove到底怎么选
很多人在工程里纠结:到底用 memcpy 还是 memmove?
我的建议非常简单:如果无法立刻确定源和目标是否有重叠,一律用 memmove。因为不重叠时,memmove 和 memcpy 的结果完全一样;重叠时,只有 memmove 是对的。付出的代价只是极微弱的性能差异,换来的是正确性保障,这笔账很划算。
如果确实能证明不重叠,而且这段代码是性能热点,比如在音视频解码、网络协议栈里高频拷贝大块数据,那就用 memcpy。标准库在明确不重叠的前提下可以做更激进的优化,比如按字复制、用 SIMD 指令,比手写逐字节版本快得多。
还有一个反直觉的知识点:很多平台上的 memcpy 内部会在检测到重叠时自动退化成 memmove,但这属于实现细节,不是标准承诺。你不能依赖“反正我用的这个平台没问题”。
4. memcmp和memset:二进制比较与清零的边界细节
4.1 memcmp:要不要停下来?
先看原型:
c复制int memcmp(const void *ptr1, const void *ptr2, size_t n);
它逐字节比较 ptr1 和 ptr2 指向的前 n 个字节,返回值为负数、0、正数,分别代表小于、等于、大于。关键区别在于:strcmp 遇到 \0 就停,memcmp 只认长度。
举个例子:
c复制char a[] = "abc\0def";
char b[] = "abc\0ghi";
strcmp(a, b); // 返回 0,因为第 4 个字节 \0 时 strcmp 就认为字符串结束了
memcmp(a, b, 8); // 返回负值,因为第 5 个字节 'd' 和 'g' 不同
strcmp 比较到 \0 就停止,后面的 def 和 ghi 根本不会参与比较,所以它在二进制数据面前是不可靠的。memcmp 则老老实实比较满 8 个字节,哪怕中间有 \0,也要继续往后比。这就是很多文章里说的“二进制安全”。
实际业务里,memcmp 最常见的三个使用场景:
- 比较两个固定长度的二进制包或协议头部。
- 比较哈希值、摘要结果。哈希值是二进制数据,随时可能包含
0x00,用strcmp比较会得出错误结论。 - 比较结构体内存是否一致,但这一步有陷阱。
结构体比较的陷阱值得单独说。假设有这样一个结构体:
c复制struct Point {
int x;
int y;
};
两个变量分别赋值 x = 1; y = 2;,然后 memcmp(&a, &b, sizeof(struct Point)),结果不一定为 0。原因是结构体成员之间可能存在补齐字节(padding),这些字节没有被赋值,内容是栈上的随机残留。解决办法是:先 memset(&a, 0, sizeof(a)),再给成员赋值。这样补齐字节也被清成 0,memcmp 才可靠。
另外注意返回值,标准只规定负、零、正三种关系,具体数值取决于实现,glibc 通常返回第一个不同字节的差值,但别把代码写成 if (memcmp(...) == 1) 这种依赖具体数值的形式。
4.2 memset:你以为的“赋值”其实是“逐字节填充”
c复制void *memset(void *ptr, int value, size_t n);
memset 把 ptr 开始的 n 个字节,每个字节都填充为 value 的低 8 位。注意这里的措辞:每个字节。value 虽然是 int 类型,但实际参与填充的只有低 8 位,其余部分会被丢弃。
这个特性导致三个高频坑。
坑一:想给 int 数组填充非零值。
c复制int arr[4];
memset(arr, 1, sizeof(arr));
// 你以为 arr[0] == 1,实际是 0x01010101,也就是 16843009
因为 memset 不知道你操作的是 int 数组,它只负责把每个字节填成 0x01,4 个字节拼在一起就成了一个很大的整数。给整数数组填充 0 或 -1 没问题,因为 0x00 和 0xFF 的字节组合恰好等于 0 和 -1;填充其他值基本都会出问题。
坑二:对函数参数里的“数组”用 sizeof。
c复制void clear_arr(int arr[]) {
memset(arr, 0, sizeof(arr)); // sizeof(arr) == 8,因为参数退化成指针
}
这是最隐蔽的坑。函数参数里的 int arr[] 本质是 int *arr,sizeof(arr) 只返回指针大小,64 位平台上是 8。结果只清零了前 8 个字节,后面的数据全是垃圾。解决方式是,要么在调用方算好 sizeof 传进来,要么显式传长度参数。
坑三:给动态内存清零时写超边界。
c复制char *p = malloc(16);
strcpy(p, "hello");
memset(p, 0, 32); // 越界写,破坏堆内存结构
清零动态内存时,长度必须和分配长度一致。如果你分配内存时就希望整块都是 0,直接用 calloc 更省事,它分配出来的内存初始就是全零,还省了一次 memset 调用。malloc + memset 的组合不是不能用,但你要对长度足够敏感。
memset 清结构体倒是非常顺手:
c复制Frame f;
memset(&f, 0, sizeof(f));
// 等价于 f.type = 0; f.len = 0; ... 逐个成员赋值
不过要补充一个严谨性问题:memset 用全零填充结构体,对整数类型来说 0 的表示就是全零,没问题;但对浮点数和指针,标准不保证全零一定是 0.0 或 NULL。在主流平台上(x86、ARM 上的常规实现)这点基本没问题,大多数代码也这么写;如果要做严格的跨平台开发,再仔细查目标平台的 ABI 文档。日常业务里,memset(&f, 0, sizeof(f)) 够用了。
5. 工程实战:手写实现、组合用法与避坑清单
5.1 一个完整的组合案例:二进制帧的拷贝与清零
把这些函数组合在一起,就是真实项目中解析二进制帧的典型代码。假设有一个网络帧格式:
c复制typedef struct {
uint8_t type;
uint16_t len;
uint8_t payload[256];
} Frame;
从接收缓冲区 raw 中解析出 Frame,新手最容易写错的方式是:
c复制memcpy(&f, raw, sizeof(Frame));
这个写法有两个问题。第一,结构体存在对齐填充:type 占 1 字节,len 是 uint16_t,为了对齐,len 大概率从偏移 2 开始,中间空出 1 字节 padding。但线上数据格式通常没有这个 padding,type 后紧跟 len,直接整块拷贝会对不齐。第二,网络字节序可能与主机字节序不同,len 字段需要转换。
更稳的做法是逐字段拷贝:
c复制Frame f;
memset(&f, 0, sizeof(f)); // 先清零,把 padding 也清掉
memcpy(&f.type, raw, sizeof(f.type)); // 拷贝 type
memcpy(&f.len, raw + 1, sizeof(f.len)); // 拷贝 len,跳过 type 占用的 1 字节
// 如果协议规定 len 是大端序,需要转换
f.len = (uint16_t)((f.len >> 8) | (f.len << 8));
memcpy(f.payload, raw + 3, sizeof(f.payload)); // 拷贝 payload
这里每个 memcpy 都指定了准确的字节数,不依赖结构体布局,也不依赖字节序假设,每一段数据都清清楚楚。
再举一个缓冲区追加字符串的场景:
c复制char buf[64];
size_t used = 0;
strcpy(buf, "hello");
used = 5;
// 追加 " world",包括 \0
memcpy(buf + used, " world", 7);
used += 6; // 这里 used 变成 11,不算 \0
如果这里用 strcat(buf, " world"),函数还得自己找 buf 里 \0 的位置,多扫一遍;用 memcpy 时因为已经知道 used,直接从目标偏移处写,逻辑更直白。前提是你要保证 buf 剩余空间足够,这个责任依然在调用者身上。
5.2 标准库vs手写:性能认知与面试考点
手写 memcpy、memmove 几乎是 C 语言面试必考题,但我要强调一句:那是面试考基本功,实际业务里直接用标准库。
标准库的 memcpy 经过了几十年的优化,不同平台会针对指令集做调优。有些实现按 8 字节、16 字节复制,有些用 SSE2、AVX 指令一次处理 32 字节甚至更多。你自己写的逐字节循环,在拷贝大数据块时可能比标准库慢几倍到十几倍。而且现代编译器通常把 memcpy 识别为内建函数,长度是编译期常量时,会直接优化成几条加载存储指令,根本不会产生函数调用。
面试时手写实现要展示的是这几层理解:
- 为什么要转成
char*?因为字节是内存操作的最小单位。 - 为什么
src要声明成const?因为拷贝操作不应当修改源数据。 memmove为什么要判断方向?因为正向覆盖源的后半段、反向覆盖源的前半段,都会造成数据丢失,只有按正确的方向拷贝才能避开被覆盖的区域。- 返回值为什么设计成
void*而不是void?为了支持链式调用,保持和标准库一致的接口。
写完手写版本后,一定要跑重叠方向的测试:
c复制int a[] = {1, 2, 3, 4, 5};
my_memmove(a + 1, a, 4 * sizeof(int)); // 目标在源后面,期望 {1,1,2,3,4}
int b[] = {1, 2, 3, 4, 5};
my_memmove(b, b + 1, 4 * sizeof(int)); // 目标在源前面,期望 {2,3,4,5,5}
一个向左一个向右,方向逻辑都覆盖到,基本可以确认实现正确。很多人写 my_memmove 只测了一种方向,结果没发现反向拷贝的 bug,这是个常见的测试盲区。
5.3 一页纸的选型清单
最后,把字符串函数和内存函数的选择逻辑整理成一张清单,可以直接贴在工位上:
| 需求 | 推荐函数 | 关键注意点 |
|---|---|---|
拷贝完整字符串(含 \0) |
strcpy / memcpy |
memcpy 长度用 strlen(src) + 1 |
| 拷贝字符串前 n 个字符 | strncpy |
源不足时会自动补 \0,注意效率 |
| 拷贝固定长度二进制块 | memcpy |
源和目标必须不重叠 |
| 拷贝可能重叠的内存块 | memmove |
无法确定时优先用它 |
| 比较两个字符串 | strcmp / strncmp |
遇到 \0 停止 |
| 比较固定长度二进制块 | memcmp |
不关心 \0,按字节比较 |
| 清零结构体/数组 | memset |
长度用 sizeof(实际对象),不是指针 |
| 动态分配并清零 | calloc |
避免 malloc + memset 的长度失误 |
每一条都是我实际踩过的坑或者帮别人排查过的线上问题。尤其是 memcpy 长度漏 +1、memset 对指针用 sizeof 这两个,出现频率高得离谱。
我在实际开发中,看到过好几次内存越界、数据被改得稀奇古怪的问题,最后定位到都不是什么复杂算法,就是 memcpy 和 memset 的参数写错了:要么 n 不对,要么 dest/src 顺序反了,要么在重叠场景用了 memcpy。所以我后来把调用的习惯固定成这样:统一先写目标再写源;长度尽量用“目标容量”或“源长度”这种单变量表达式,不临时拼;遇到重叠场景直接 memmove;写完任何涉及内存拷贝的代码,先跑一遍带重叠和边界情况的测试。这几个习惯不复杂,但能把很大一部分内存问题挡在发布之前。
