1. 为什么这两个函数总被人放在一起比较
1.1 一个从面试到Code Review都绕不开的话题
有一次我给团队做Code Review,看到一位同事直接用strcpy拷贝一段从网络协议包里解析出来的字段,我当时就拦了下来。他有点不服气,说"都是拷贝,为什么不能用?"这个问题其实特别典型——strcpy和memcpy大概是C语言里被问得最多、也用得最混乱的两个函数。网上关于两者区别的帖子满天飞,但大部分回答都停在"一个是拷贝字符串,一个是拷贝内存"这种表面层次,真到写代码的时候该怎么选、为什么不安全、边界条件是什么,很多人其实没有吃透。
这篇文章想把这两个函数彻底讲明白。适合谁看?准备校招社招面试的C/C++方向同学、刚入门系统编程的新人、以及写了不少年代码但对底层行为不太较真的开发者。看完之后你会知道:面试官问这个问题时真正想考什么,以及实际工程中碰到字符串拷贝、内存拷贝该怎么决策。
1.2 名字和声明越像,越容易混淆语义
先看这两个函数在标准库里的声明:
c复制char *strcpy(char *dest, const char *src);
void *memcpy(void *dest, const void *src, size_t n);
从表面看,它们的模式几乎一样:都是一个目标地址、一个源地址,一个隐含长度、一个显式长度。这是它们被放在一起比较的第一个原因——"长得很像"。但strcpy处理的对象是"字符串",它工作的前提是src必须以'\0'结尾;而memcpy处理的对象是"一段内存",它根本不在乎里面有没有'\0',只关心你要拷贝多少个字节。
这个差异往深了说,是C语言里"字符串"和"内存块"两种抽象的区别。字符串是一种以'\0'为终止标记的字符序列,它的长度是运行时才能确定的隐式信息;而内存块是一段指定长度的连续字节,它的边界由使用者显式给出。strcpy服务于前者,memcpy服务于后者。理解了这一层,你就能明白为什么这两个函数的使用场景几乎完全不能互相替换。
1.3 C23废弃strcpy:行业在用脚投票
聊到这里有必要提一个背景:最新的C23标准已经将strcpy标记为废弃(deprecated)。这是官方在明确表态——在安全敏感的系统级编程当中,strcpy这种无法告知目标缓冲区大小的函数已经不符合现代工程的要求。
为什么还会在面试题里反复出现?因为存量代码太多、教学体系惯性太大,而且"理解strcpy为什么危险"本身就是理解C语言内存模型的一把钥匙。面试官问这个问题,很多时候不是真的指望你用strcpy写生产代码,而是看你对"字符串终止""缓冲区边界""未定义行为"这些底层概念有没有真正建立直觉。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 底层工作机制:一个在找路牌,一个在搬货
2.1 strcpy的本质:对'\0'的逐字节扫描
strcpy(dest, src)的行为可以用一句话概括:从src地址开始,一个字节一个字节地往dest拷贝,每次拷贝完检查当前字符是不是'\0',如果是就停止。这个过程的循环次数不受调用者控制,完全取决于src字符串的长度。
用伪代码表示大概是这样的:
c复制char *strcpy(char *dest, const char *src) {
char *p = dest;
while ((*p++ = *src++))
;
return dest;
}
注意这个while条件:先赋值,再判断赋进去的值。也就是说,'\0'也会被拷贝进dest,然后循环才停下来。这就是很多人容易忽略的一点——strcpy会把终止符一起拷贝走,目标缓冲区如果只预留了字符串内容的长度,就会溢出1个字节。别小看这1个字节,在堆内存布局里它可能恰好覆盖相邻对象的元数据,是大量堆溢出漏洞的源头。
从工作方式上看,strcpy像一个沿着路找路牌的人,路牌就是'\0',什么时候看到路牌什么时候停。它没办法提前知道要走多远,只能一步步走。
2.2 memcpy的本质:闭着眼睛按长度搬运
memcpy(dest, src, n)的行为就简单直接得多:从src拷贝n个字节到dest,循环执行n次,不管里面有没有'\0'。
c复制void *memcpy(void *dest, const void *src, size_t n) {
unsigned char *d = dest;
const unsigned char *s = src;
while (n--) {
*d++ = *s++;
}
return dest;
}
当然,这是示意实现。现代glibc里,memcpy会把n大于一定阈值的情况从逐字节循环切换成机器字长拷贝,甚至走SIMD指令,一次搬16字节、32字节、64字节。因为它长度已知、方向确定、目标地址对齐已知,这些信息都给了编译器充分的优化空间。
memcpy更像一个按订单件数搬货的物流工,手里拿着"件数n"这个单据,按数量搬完就收工,不需要检查货箱里装的是什么。
2.3 为什么strcpy必然存在缓冲区溢出隐患
用strcpy的安全隐患不在于函数本身"写错了",而在于它缺少一个能力边界参数。函数只知道dest的起始地址,不知道dest指向的内存到底有多大。只要src里的内容一直有非'\0'的字节,它就会一直往下写,写到哪算哪。被覆盖的内存可能是另一个变量、堆栈上的返回地址、或者相邻堆对象的头部。
更麻烦的是,C语言没有在运行时检查数组越界的能力,数组名在表达式里会退化成指针。所以char buf[16]; strcpy(buf, user_input);这段代码里,编译器完全无法知道buf只有16字节,user_input却有200字节。于是栈被击穿,程序崩溃都算轻的,被精心构造的输入覆盖返回地址,就会变成经典的栈溢出攻击。
很多人在这一步会产生一个疑问:那不用strcpy,用strncpy是不是就安全了?很遗憾,strncpy也有自己的坑,后面专门讲。
3. 关键差异对比:返回值、空指针、重叠内存与性能
3.1 返回值的设计:链式调用的代价
strcpy和memcpy都返回dest指针。这个设计初衷是为了支持链式调用,比如:
c复制printf("%s\n", strcpy(buf, "hello"));
性能上没损失(复制dest地址),但对可读性没什么帮助。真正需要注意的是,返回值是dest而不是src,也就是说你不能从返回值里拿到"拷贝了多少字节"或"源数据有没有被截断"这类信息。需要更精细控制时,必须自己先算长度,或者走snprintf这类有返回值的函数。
在工程上我建议不要过度依赖这个返回值。它最大的价值其实是在调试的时候,可以在调用处直接拿到目标地址打印验证,比如assert(strcpy(buf, src) == buf);,仅此而已。
3.2 空指针与n=0的边界行为
先说结论:src或dest为NULL时,strcpy和memcpy的行为都是未定义的(undefined behavior),不是你传入NULL它就一定会崩溃,而是可能崩溃、可能正常工作、可能随机出错。
拿memcpy来说,有些实现在n=0时会对指针做解引用检查(比如判断地址对齐),然后才进入循环,这时候传入NULL也可能直接段错误;有些实现则检查完n后直接返回,NULL反而没事。但"没事"只是碰巧,你的代码依赖这种碰巧,就是在未定义行为上跳舞。
我自己的习惯是:调用前显式做防御性检查。
c复制if (src == NULL || dest == NULL || n == 0) return;
虽然标准没要求,但系统编程里多这一层检查往往能避免线上事故。你可能会说"这么慢",但实际上NULL对比和n==0对比是几个周期的事,对性能几乎无感,换来的是崩溃可控。
3.3 重叠内存:memcpy和strcpy的禁区,memmove的正道
这是面试里最容易被追问的细节之一。先记住一个核心结论:不管是strcpy还是memcpy,源区间和目标区间发生重叠,都属于未定义行为。很多人以为memcpy内部是逐字节拷贝,所以小心翼翼地从前往后拷就没事,这是错觉。
举一个典型的向右移动例子:
c复制char buf[] = "hello world";
memcpy(buf + 1, buf, 6);
如果逐字节从前向后拷贝,buf[0]的'h'先被搬到buf[1],此时buf[1]原来的'e'已经被覆盖,下一步再把buf[1](现在是'h')搬到buf[2]……最终结果是"hhello ",原来想保留的"hello "完全变形。这只是恰好逐字节向前拷贝的简化分析,实际优化后的memcpy可能用向量寄存器一次性读入多字节,结果又是另一个样子,但一样是错的,而且错得不可预测。
正确做法是用memmove。memmove保证在源和目标重叠时,结果等同于先把src完整拷贝到一个临时缓冲,再从临时缓冲拷贝到dest。它牺牲一定的性能换来了语义上的确定性。所以工程里的选择标准很简单:
- 确定不重叠:用
memcpy,快。 - 不确定或可能重叠:用
memmove,稳。
strcpy在重叠场景下同样没有保证。有一种常见的需求是在同一个字符串内部做"前移"或"后移",比如去掉前两个字符写成strcpy(buf, buf + 2)。这种代码在特定编译器上可能碰巧能用,但只要换一个平台或优化级别就会翻车。我非常不建议在项目里这么写,正确的做法是memmove(buf, buf + 2, strlen(buf + 2) + 1);。
3.4 性能差异:变长扫描与定长拷批
性能差异也很好理解。strcpy的循环次数等于strlen(src)+1,这个值要逐字节扫描才能知道,而且现代CPU的分支预测在这种"随时随地可能结束"的循环上很难生效。glibc对strcpy也做了优化,会按机器字长检查'\0',但本质上它还是在"找终止符",效率天花板清晰可见。
memcpy则是另一回事:长度n是显式已知的,编译器可以安排循环展开、向量化、按对齐宽度搬运。在大块数据拷贝场景下,memcpy的性能通常远超strcpy,这也是一个经验事实。
这里可以做一个简单的对比表:
| 对比维度 | strcpy | memcpy |
|---|---|---|
| 操作对象 | 以'\0'结尾的字符串 |
任意字节内存块 |
| 终止条件 | 遇到'\0' |
由n决定 |
是否拷贝'\0' |
是,终止符会一起拷走 | 无关,按长度拷贝 |
| 长度是否需显式传入 | 不需要 | 需要 |
| 对二进制数据的安全程度 | 低,遇到0就截断 | 高,完全按字节处理 |
| 重叠内存支持 | 不支持,未定义行为 | 不支持,推荐memmove |
| 可优化空间 | 受制于变长扫描 | 定长拷贝,便于向量化 |
| 安全性 | 容易溢出 | 更可控,但传入长度错误也同样危险 |
| 空指针行为 | 未定义,通常直接崩溃 | 未定义,n=0时依实现而定 |
4. 最容易翻车的三个误用场景复盘
4.1 用strcpy拷贝二进制数据,结果被0x00拦腰截断
这是我见过发生频率最高的事故。某项目里需要把一段协议字段复制到另一个结构体,字段里包含版本号、消息类型、标志位等二进制内容。写代码的人图省事,直接调strcpy。
问题在于,二进制数据里出现0x00太正常了。比如:
c复制unsigned char packet[] = {0x01, 0x02, 0x00, 0x03, 0xFF};
char buf[16];
strcpy(buf, (char *)packet); // 危险!
这条strcpy执行完,buf里只会得到0x01 0x02 0x00,后面0x03 0xFF直接丢了。更糟糕的是strcpy会在0x00处补一个终止符,也就是说目标缓冲区里凭空多了一个0x00,后续解析逻辑如果按照长度读取,会把这一个字节也当成有效数据,整个数据流全乱。
这类Bug的排查难度很高,因为崩溃点往往距离出错点十万八千里。我的经验是,只要拷贝对象是协议字段、序列化缓冲区、图像数据、音视频编码数据这类非文本字节流,一律用memcpy或memmove,并显式传入长度。这条规则不需要思考,直接执行。
4.2 用memcpy处理字符串,长度多算或少算一个字节
memcpy反过来也有坑——用它处理字符串,长度计算是重灾区。最常见的两个错误:
第一,忘了加1。memcpy(dst, src, strlen(src));这样只拷贝了字符串内容,没有拷贝末尾的'\0'。结果是dst的尾部残留了上一次数据的内容,printf("%s")打出来后面多一截"尾巴",有时候是乱码,有时候是内存里的敏感信息。这种错误很隐蔽,因为字符串内容本身没问题,只有到了缓冲区末尾才会暴露。
第二,把指针大小当缓冲区大小。
c复制void func(const char *src) {
char buf[64];
memcpy(buf, src, sizeof(src)); // 错误!src是指针,sizeof是8
}
sizeof(src)在64位平台上是8,不是64,也不等于strlen(src)+1。这条代码的实际效果是只拷贝了个开头,后面的内容全部丢失。正确写法取决于src传入前的长度约定,通常需要额外传一个size参数,或者显式strlen并加校验。
这里提供一个我常用的校验模板:
c复制if (strlen(src) + 1 > sizeof(dst)) {
// 记录错误日志,返回错误码,绝对不硬拷
return -1;
}
memcpy(dst, src, strlen(src) + 1);
很多人觉得strlen会扫描两遍性能不好,但实际上正确性永远是第一位,性能优化应该建立在对长度语义的确切理解之上,而不是靠省掉校验来换取。
4.3 同一块内存上的"截断"操作
第三种翻车场景来自想当然的重叠操作。比如有人想把字符串开头的几个字符去掉,写了strcpy(buf, buf + 2),或者想把字符串向后移动,写了strcpy(buf + 1, buf)。这类代码的共性是:没有意识到strcpy(和memcpy)对重叠区域没有任何保证。
举一个具体例子。
c复制char s[] = "hello";
strcpy(s, s + 1); // 想把s变成"ello"
直观的想法是从'e'开始往s开头拷贝,好像没问题。但strcpy的实现是从s+1读一个字节写一个字节,它不知道源和目标有重叠。当它往s[0]写入'e'的时候,s[1]还是'e'(因为还没被覆盖),所以这一步没问题;但继续往s[1]写的时候,它读的是s[2]……看起来似乎能"凑巧"完成。问题在于这种凑巧依赖具体实现和编译优化。某些高度优化的strcpy实现会预先按机器字长读取一大块数据,在读到s[1]的内容时,它可能已经包含了你刚写入的'e'而不是原来的'l'。结果就会变成垃圾数据。
处理重叠的唯一正解是memmove。任何在同一个数组内部移动字符的需求,我都会先问自己一句话:源区间和目标区间有没有可能重叠?只要答案是"有",就立刻换成memmove,不需要任何犹豫。
5. 工程中的选择标准与安全替代方案
5.1 决策模型:按数据类型与长度语义选函数
光记住差异表格还不够,关键是在真实代码里快速决策。我给自己总结了一个简单的决策模型,两步:
第一步,看拷贝对象。是不是"字符串"? 这里说的字符串指的是"必须以'\0'结尾、依赖终止符才能表达的字符序列"。如果是,进入第二步;如果不是(二进制、协议、结构体、序列化字节流),直接用memcpy/memmove,长度用变量显式传入。
第二步,看能否确认长度。能不能在一次strlen或传入参数中得到长度,并校验目标空间足够? 如果答案是可以,那么有两种合理做法:一是memcpy(dest, src, len_with_null),二是snprintf(dest, size, "%s", src)。如果答案是不可以(比如函数只接收const char*且没有长度参数),那就想办法改接口签名,不要硬着头皮调strcpy。
这个模型的核心逻辑是:字符串的正确操作不能建立在"假设src合法且目标缓冲区足够大"之上,必须用显式长度或固定边界把它框死。
5.2 strncpy是另一个坑
很多人一听说strcpy不安全,就想到用strncpy替代,这其实踩进了另一个更隐蔽的坑。strncpy(dest, src, n)的语义是:
- 如果
strlen(src) < n,拷贝完src后会用0x00填充剩余空间,直到写满n个字节; - 如果
strlen(src) >= n,只拷贝前n个字节,不会自动补'\0'。
这两个行为都反直觉。第一种情况下,当你处理大量大小固定的字符串时,每一次拷贝都会带来额外的清零开销,这是毫无必要的性能浪费;第二种情况下,你拷完发现dest根本没有终止符,后面调strlen、printf都会越界读。
使用strncpy的正确姿势是双保险:
c复制strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0';
每次都要手动加最后一行,漏掉就是事故。既然这么费劲,我现在的习惯是干脆不用strncpy,字符串固定缓冲区拷贝直接snprintf,既安全又自带终止符保证。只有极少数处理固定宽度char数组的传统代码里,strncpy才有它的位置。
5.3 安全写法的具体替代清单
在实际工程里,我推荐这样一套替代方案:
| 需求场景 | 不推荐 | 推荐 |
|---|---|---|
| C风格字符串拷贝到固定数组 | strcpy(dst, src) |
snprintf(dst, sizeof(dst), "%s", src) |
| 明确知道长度的字符串拷贝 | strcpy |
memcpy(dst, src, len + 1) 并校验长度 |
| 可能重叠的字符/字节拷贝 | strcpy/memcpy |
memmove(dst, src, len) |
| 二进制数据/协议字段 | strcpy |
memcpy 或 memmove(按长度) |
| C++里拷贝已有字符串 | strcpy(dst, s.c_str()) |
s.copy(dst, buf_size) 或直接std::string赋值 |
特别提一下snprintf这个函数。它把字符串拷贝和安全边界整合在了一起,不光是格式化用的,做"安全版strcpy"也很好用:
c复制snprintf(dest, sizeof(dest), "%s", src);
行为上等价于:尽量拷贝src的内容,但最多写sizeof(dest)-1个字符,然后强制补'\0'。它的返回值表示如果空间足够,字符串会有多长。所以你可以这样玩:
c复制int needed = snprintf(NULL, 0, "%s", src); // 0表示不写,仅计算长度
if (needed < 0 || (size_t)needed >= sizeof(dest)) {
// 空间不够,走扩容逻辑
} else {
snprintf(dest, sizeof(dest), "%s", src);
}
第一次调用传NULL和0的写法也是标准支持的,用来计算所需长度。这个技巧在不知道字符串长度、又要保证安全拷贝的场景很好用。
5.4 C++工程里我很少直接用这些C函数
如果项目是C++,能用的手段其实更多,我的个人偏好是按优先级排序:
- 如果已经拿到
std::string,直接赋值,或者assign、append、substr,不要让c_str()在外面满天飞。 - 如果必须填充一个
char*缓冲区,优先std::string::copy(dst, count, pos),它会返回实际拷贝的字符数,不会自动加'\0',所以拷完要手动补。 - C++17以后可以用
std::string_view来接收只读字符串参数,避免因为c_str()临时对象生命周期问题导致的悬挂空指针。
只有真正在写C接口回调、嵌入系统、或者需要精确控制内存布局的地方,memcpy/memmove/snprintf才是我会直接使用的C标准库函数。这不算什么高深技巧,纯粹是"能用类型系统解决的事,就不该让程序员拿命去记长度"。
6. 面试回答模板与我的一些经验
6.1 一套稳妥的回答思路
面试如果被问到"strcpy和memcpy的区别",我比较推荐的表述框架是:从语义差异入手,用边界和底层机制支撑,最后落到工程选择。你可以这样说:
"这两个函数最根本的区别是操作对象不同。strcpy处理的是以'\0'结尾的字符串,它通过扫描终止符来确定拷贝边界,拷贝过程中会把终止符一并复制;memcpy处理的是任意内存块,完全由第三个参数n决定拷贝字节数,不在乎内容里是否有'\0'。从安全性角度,strcpy不接收目标缓冲区大小,无法防止溢出,工程中应该避免直接使用;memcpy虽然更可控,但对重叠内存是不安全的,需要时用memmove替代。另外它们的返回值都指向dest,主要是为了支持链式调用。"
这段话信息密度够、层次分明,而且能引导面试官顺着你的思路继续追问。如果他追问,大概率会落在memmove和strncpy这两个点上,也就是我前面讲过的内容。
6.2 面试官后续最爱的三个追问
追问一:memcpy和memmove的区别?
memcpy不保证重叠内存的正确性,memmove保证。底层实现上,memmove会额外判断源和目标的前后位置关系,再决定是从前往后拷贝还是从后往前拷贝,甚至可能先拷贝到临时缓冲。代价是分支判断带来的少量性能损耗。所以明确不重叠时用memcpy,无法确定时用memmove。
追问二:什么时候用strcpy其实没问题?
在没有外部输入的字符串常量场景,且你能确定目标缓冲区足够大,strcpy确实"不会出问题"。比如char buf[32]; strcpy(buf, "hello");这种代码跑一百年也不会崩。但这种代码仍不该出现在新工程里,因为当别人以后往这个调用点补数据的时候,很可能忘了这里用的是strcpy,风险就会在某个深夜爆发出来。代码是给人看的,写安全的代码是对后面维护者的基本尊重。
追问三:C23为什么废弃strcpy?
因为它的接口设计无法提供"目标缓冲区大小"的信息,C标准委员会和主流安全机构都认为它导致了大量内存破坏类漏洞。C23把它标记为deprecated,是明确引导开发者使用strncpy(带长度参数)或者自己实现安全包装。但这个"废弃"不是删除,存量代码仍然编译,只是新代码不该再用。
6.3 我现在的编码习惯
写了这些年C/C++,我给自己的代码定了几条死规矩,分享出来供你参考。
第一,新代码里出现strcpy直接过不了我的Code Review,除非在非常特殊的兼容场景下,并且旁边要注释原因。第二,凡是从外部输入流里拿数据做拷贝的,一律先用长度判断再决定走memcpy还是snprintf,不接受任何"输入长度肯定没问题"的说法。第三,凡是源和目标可能是同一缓冲区、或者指向同一块内存的偏移区域,直接写memmove,多花两个字节打这几个字,省掉一次深夜排查。第四,写完一段涉及拷贝的代码,我会习惯性地问自己:这段缓冲区多大?内容从哪来?如果内容长度超过缓冲区会怎样?这三个问题都答得出来,代码才算合格。
这些习惯不是凭空来的,都是踩过坑之后的总结。比如我用memcpy算错长度、也用strcpy处理过二进制数据、也在同块内存上干过"移花接木"的蠢事。老实说,C语言的内存安全本来就全靠程序员自觉,工具函数用对了不代表一定安全,但用错了大概率出事。希望这篇梳理能帮你少走几步弯路,至少在面试问到这个经典问题的时候,你能说出点比别人更深的东西。
