想吐槽一件事。
这些年我在不少项目的笔试和面试环节里,见过太多次“手写strcpy”这道题。它看起来是C语言教材里最入门的一小节,可实际能写对的人,比例远比想象中低。很多候选人三秒钟写完一行代码,结果我问句“为什么返回值必须是char*”,人却愣住了。这个标题提示已经很直白:不调用C/C++的字符串库函数,自己实现strcpy。也就是说,不能用strcpy、strlen、strncpy、memcpy这些现成函数,得靠指针操作把一个字符串从源地址复制到目标地址。
这篇文章就是围绕这道题展开的。我会把标准实现的每一行拆开讲,把返回值、const、内存重叠、边界条件、缓冲区溢出这些容易被忽视的点全部摊开,再给你一套可以写进简历和代码里的完整实现思路。无论你是准备校招笔试的应届生,还是在嵌入式、底层领域做C语言开发的工程师,这篇文章都能帮你把这个“老古董”函数吃透。
1. 为什么一道字符串拷贝题,能拦住一半候选人
1.1 这不是背题,是内存模型的“压力测试”
很多人一开始想不通:strcpy不是一行就写完的事情吗?笔试五分钟,代码三行,有什么可考的?
问题恰恰出在“三行”上。字符串在C语言里不是一等公民,它本质上是一段连续内存加上一个“以'\0'结尾”的约定。字符串拷贝这件事,从内存模型看,就是一场“源地址的逐个字节搬到目标地址,直到遇到0”的过程。如果你只是背了一个循环模板,一旦遇到指针移动、结束条件、内存覆盖、返回地址这些引申问题,立刻就会露馅。
这题真正考的是三件事:
- 是否理解指针自增、解引用、赋值表达式返回值这些底层语义;
- 是否有边界意识,比如空指针、目标空间不足、源和目标重叠;
- 是否能用工程化的眼光看待一个看似简单的函数,比如const限定、返回值设计、安全替代方案。
我见过很多候选人能写出完全可运行的代码,但回答不了“为什么返回char*”。这说明他是背的。从面试官视角看,这种答案只能给及格分,达不到区分度。
1.2 在什么场景下,你才会真正需要手写它
面试笔试当然是高频场景,但手写strcpy绝不只是考试题。
在嵌入式裸机环境里,很多情况下标准库并不完备,比如某些芯片的启动代码阶段,C运行时库还没初始化,你不能随便调用标准函数。内核早期启动、引导程序、自研协议栈这类场景,经常需要自己造一套字符串处理工具,strcpy就是第一块基石。
还有一类场景是“你所在团队规定某些模块不允许链接标准库”。这种约束听起来很极端,但我在实际项目中确实遇到过。为了控制二进制体积、规避某些库函数在特定架构下的性能陷阱,团队会自己维护一套基础库。
所以,这道题不是学院派的死记硬背,它是一个真正可能出现在生产代码里的基础构件。你把这一题吃透,等于顺手把memcpy、memmove、strncpy这些亲戚函数也理解了一半。
2. 动手之前,先把 strcpy 的标准签名拆明白
2.1 标准原型里每个符号都是考点
先看完整的标准声明:
char *strcpy(char *dest, const char *src);四个词,四个考点。
第一个考点:返回值为什么是char *,而不是void?
因为strcpy执行完,调用方往往紧接着要用目标字符串。标准库把目标指针的起始地址作为返回值返回,这样可以支持链式表达式。比如:
char buffer[32]; printf("%s\n", strcpy(buffer, "hello"));更重要的是,strcpy这个函数族(还有strcat、strchr等)在C库里都保留了相似的惯例,返回目标指针已经成为一种设计风格。如果返回void,调用方想拿到拷贝后的字符串首地址,还得自己在外面再存一份dest的副本,很不方便。
第二个考点:src为什么要加const?
因为它只读。函数拷贝源字符串时,不应该去修改它的内容。const限定的作用是把“只读契约”写进原型里,让编译器和调用者都知道这个函数不会动src。这在C++里尤其重要,因为字符串字面量的类型是const char[],如果你的函数原型写成char *src,调用strcpy(dest, "abc")时会遇到类型不匹配的编译问题。
第三个考点:dest为什么是char *而不是数组?
因为数组作为函数形参会“退化”为指针。你在函数签名里写成char dest[],编译器也会把它解释成char *dest。这个退化意味着函数内部无法通过dest得到数组长度,所以strcpy这类函数必须依赖'\0'来判断结尾,而不能自己算缓冲区大小。
第四个考点:这个函数返回前,dest指针已经移动到了末尾,怎么还能返回起始地址?
这就牵出保存原始指针的必要性。很多错误实现都死在这里。
2.2 一行经典实现的逐字节拆解
标准实现最经典也最简练的版本是:
char *strcpy(char *dest, const char *src) { char *ret = dest; while ((*dest++ = *src++) != '\0') ; return ret; }这一小段代码信息密度非常高,逐行拆解一下。
char *ret = dest;这行先把目标起始地址保存下来。因为下面的循环会把dest这个指针一直往后移,如果不保存,最后return dest返回的就是目标字符串的尾部,而不是起始地址。
while ((*dest++ = *src++) != '\0') ;这一行是核心。先看*src++,后置++优先级高于解引用*,所以它等价于*(src++):先取出当前src指向的字符,再把src指针后移一位。*dest++同理。然后这个赋值表达式的整体含义是:把src当前位置的字符复制到dest当前位置,随后两个指针都自增。
这里最关键的是赋值表达式本身也有值。整个表达式的值就是“被赋进去的那个字符”。如果这个字符是'\0',表达式的值为0,循环立即停止。注意,'\0'此时已经被写进目标内存了,所以不需要在循环结束后补一条*dest = '\0'。
有些资料会把这个循环写作:
while (*dest++ = *src++) ;两者本质等价,因为赋值表达式的结果本身就是被复制的字符,非0继续循环,为0退出。不过我建议保留!= '\0',可读性更好,考官一眼就能看出你知道自己在判断什么。
2.3 三个翻车版本,看看你更接近哪一种
我面试时最喜欢干的一件事,就是让候选人现场手写strcpy,然后看他写出的第一个错误版本属于哪一类。
第一类错误:返回类型写成void。
void strcpy(char *dest, const char *src) { while ((*dest++ = *src++) != '\0') ; }功能上能把字符串复制过去,但接口不符合标准。调用方无法把它作为表达式使用,也无法拿到目标地址。虽然编译器不会报错,但后续别人调用你的函数时一定会踩坑。
第二类错误:没有保存原始地址,返回了移动到末尾的指针。
char *strcpy(char *dest, const char *src) { while ((*dest++ = *src++) != '\0') ; return dest; }这段代码看起来很完整,实际上返回的是目标字符串末尾的'\0'位置。做个实验,假设初始dest指向数组首地址,循环结束后dest已经指向了末尾。调用方拿这个返回值去做printf("%s", ...),打印出来永远是空字符串,因为从这个地址开始读,第一个字节就是'\0'。
第三类错误:忘了复制结尾的'\0'。
char *strcpy(char *dest, const char *src) { char *ret = dest; while (*src != '\0') { *dest = *src; dest++; src++; } return ret; }循环结束后,目标缓冲区里的确有源字符串的所有字符,但末尾没有'\0'。目标缓冲区只是“一段数据”,不是“一个字符串”。后面用strlen、printf("%s")、strcmp去操作它,都会越界读取,跑到未知内存里,结果不可预测。
这三个版本代表三个能力缺失点:接口设计意识、指针移动的追踪能力、对字符串结束符的敬畏。面试官往往只需要看到其中一点,就能判断候选人有没有真正理解内存模型。
3. 从“能跑”到“稳”:完整实现的完整推演
3.1 版本一:空指针与基本保护
标准库里的strcpy在传入空指针时是什么行为?C标准没有定义,属于未定义行为。库函数为了追求极致性能,一般不做防御性检查,把检查责任交给调用方。
但我们在笔试、面试或者自研基础库时,建议加上保护。因为空指针在真实程序里太常见了,一个指针在某个分支里被赋成NULL,传进来直接崩溃,排查起来费时费力。
char *strcpy(char *dest, const char *src) { if (dest == NULL || src == NULL) { return NULL; } char *ret = dest; while ((*dest++ = *src++) != '\0') ; return ret; }这里把返回NULL作为失败信号。当然,这种设计也有争议:如果调用方想链式使用返回值,比如printf("%s", strcpy(a, b)),一旦返回NULL就危险了。所以,防御性版本更适合放在自己的工具库里,而不是作为标准接口的替代品。
笔试时怎么写更稳妥?先写标准版本,然后主动提一句“标准strcpy不检查空指针,但工程上我会在入口加保护”。这句话本身就是加分项。
3.2 版本二:内存重叠到底怎么处理
这是整个strcpy话题里含金量最高的部分。
先定义什么是重叠。当dest和src指向同一块内存区域的不同位置时,两个缓冲区可能发生部分重叠。典型例子:
char buf[32]; strcpy(buf, "hello"); strcpy(buf + 1, buf); // 目标从 index 1 开始,源从 index 0 开始这种情况下,目标地址比源地址靠后,两个区间叠在一起。前面的字节被覆盖后,会影响后面还没被读取的源字节。
具体推演一下,假设buf初始是"hello\0"。dest = buf+1,src = buf。如果从前向后逐字节拷贝:
- 读取
buf[0]为'h',写入buf[1],此时buf[1]从'e'变成了'h'; - 源指针移动到
buf[1],读取到的已经是被污染过的'h',而不是原来的'e'; - 这个被污染的字符继续向后传播,最终整个字符串错乱。
这就是为什么标准strcpy禁止内存重叠。你想,如果库里允许重叠,就必须做“判断指针先后、决定向前还是向后复制”的逻辑,这个判断本身有开销,还会影响循环的简洁性。标准委员会干脆把重叠场景定义为未定义行为,让库实现走最快路径,把责任全部留给调用方。
处理重叠的正确思路是:如果dest在src左侧(即目标地址低于源地址),从前往后复制没问题;如果dest在src右侧,则应该从后往前复制,让高地址位的源字节先被读走。
题目禁止调用字符串库函数,所以我在下面的实现里自己写了一个简单的长度计算循环,不会调用strlen:
char *strcpy_safe(char *dest, const char *src) { if (dest == NULL || src == NULL) { return NULL; } // 同一缓冲区场景下,比较地址方向 if ((char *)dest <= (char *)src) { // dest 在 src 左侧,从前往后复制 char *ret = dest; while ((*dest++ = *src++) != '\0') ; return ret; } // dest 在 src 右侧,先求出 src 长度(不含 '\0') size_t len = 0; while (src[len] != '\0') { len++; } // 从后往前复制,包括 '\0' while (len > 0) { dest[len] = src[len]; len--; } dest[0] = src[0]; return dest; }细看这段代码的收尾逻辑:我们先在循环里把dest[len]依次赋为src[len],由于len从大往小走,所以先复制高地址位,再复制低地址位。dest[0] = src[0]处理最后一个字符。这样就能避免前面污染源数据。
不过这里我要说一个很专业的坑:严格来讲,C标准规定,当两个指针指向不同的数组对象时,用<=或>比较它们的大小属于未定义行为。所以上面的dest <= src比较,只有在确实知道两者位于同一块内存对象时才是安全的。工程上如果一定要跨区域比较,可以先把指针转成uintptr_t再比较,或者干脆不手写,直接调用memmove——这是C标准里为重叠场景准备的函数。
绝大多数面试场景,你只需要讲清楚“为什么重叠会有问题”“标准strcpy为什么不处理”“如果让我处理会怎么做”,就已经超出平均水平了。
3.3 版本三:要不要做字拷贝优化
性能优化是面试中喜欢问的进阶问题。strcpy的朴素实现每次复制一个字节,这对长字符串确实浪费。因为现代CPU的读取和写入宽度远超8位,一次按机器字(4字节或8字节)复制效率更高。
思路是:判断当前src地址是否按机器字对齐,没对齐就逐字节复制直到对齐;对齐后每次读取一个unsigned long,整块写入目标,同时检测这个块里有没有'\0',有就收尾;没有就继续下一块。
判断一个机器字里是否存在值为0的字节,有个经典技巧:
static int has_zero_byte(unsigned long x) { unsigned long mask = 0x0101010101010101UL; unsigned long high = 0x8080808080808080UL; return (x - mask) & ~x & high; }这个宏用在一个每字节高位为1、低位为1的被减数上,原理不展开,属于Hacker‘s Delight里的经典位运算技巧。它把“一个字节是否为0”的判断压缩成两次位运算,非常高效。
我见过一些手写优化版本,看起来漂亮,实际却存在隐患。最典型的是“整块写入”的越界风险:假设源字符串末尾的'\0'刚好在一个机器字的第5个字节,前面4个字节是有效字符,整块读入8字节没问题,但把8字节全部写入目标时,目标缓冲区边界可能不足8字节,瞬间越界写。
专业做法是:先读、检测、再决定写,但这样逻辑就复杂了。我的建议是,面试时你可以把优化思路讲清楚,但不要真的在生产代码里手写这种循环。现代编译器在-O2级别下,往往会把简单的字节复制循环自动优化成宽拷贝,很多平台的C库函数还用了SIMD指令。你自己写的版本很难跑过库函数,还容易引入缺陷。
这个版本我给出核心示意,但明确告诉你:它主要是用来展示思路的,工程上不要直接搬。
char *strcpy_fast(char *dest, const char *src) { char *ret = dest; while (((uintptr_t)src & (sizeof(unsigned long) - 1)) != 0) { *dest++ = *src++; if (*(dest - 1) == '\0') { return ret; } } unsigned long *dest_word = (unsigned long *)dest; const unsigned long *src_word = (const unsigned long *)src; while (1) { unsigned long chunk = *src_word++; if (has_zero_byte(chunk)) { const char *src_tail = (const char *)(src_word - 1); char *dest_tail = (char *)dest_word; while ((*dest_tail++ = *src_tail++) != '\0') ; return ret; } *dest_word++ = chunk; } }注意这里顺序:先检测到'\0',再复制块,还是先复制块再检测,各有取舍。上面的写法在最后一块会先把整个字写入,存在越界可能性。要绝对安全,就得放弃“整块写入”的流畅性,逐字节收尾。这些权衡都是真实工程里会讨论的东西,能讲出来就很加分。
3.4 安全拷贝函数:工程上尽量避免 strcpy
进入工程场景,我的态度很明确:除非你非常确定目标缓冲区够大,否则不要直接调用strcpy。缓冲区溢出是最经典的C语言安全问题之一。
假设你只给目标分配了8个字节,源字符串却有16个字节,strcpy会不管不顾地把16个字节连同'\0'全部写进去,多出的部分直接踩在相邻内存上。轻则修改变量的值,重则让程序崩溃或者出现更难查的内存破坏。
工程化的替代方案是带缓冲区大小的拷贝函数。虽然题目禁止调用字符串库函数,但你可以自己实现一个安全拷贝:
size_t safe_strcpy(char *dest, size_t dest_size, const char *src) { if (dest == NULL || src == NULL || dest_size == 0) { return 0; } size_t i = 0; while (i + 1 < dest_size && src[i] != '\0') { dest[i] = src[i]; i++; } dest[i] = '\0'; return i; }这个函数的要点是:最多复制dest_size - 1个字符,最后一个位置永远留给'\0'。返回值是实际复制的字符长度,调用方可以判断源字符串是否被截断(如果返回长度等于dest_size - 1,源字符串大概率没有复制完)。
为什么不直接用现成的strncpy?因为strncpy有自己的坑,这一点下面专门讲。
4. 面试官追着问:扩展函数与边界辨析
4.1 手写 strncpy 时最容易掉进哪个坑
strncpy的声明是:
char *strncpy(char *dest, const char *src, size_t n);意思是“最多复制n个字符”。听起来比strcpy安全,但有两个非常反直觉的行为。
第一个坑是:如果src的长度小于n,strncpy会用'\0'把剩余空间全部填充。假设n是1024,源字符串只有10个字符,那函数会额外写1014个'\0'。看起来没什么危害,但如果你在循环里频繁用n很大的strncpy,性能会很难看。
第二个坑更严重:如果src的长度不小于n,strncpy不会自动在目标末尾补'\0'。也就是说,目标缓冲区在复制完n个字符后,仍然是一段没有结束符的“裸数据”。后续用strlen去数,或者用printf("%s")去打印,都会一路读到缓冲区外面。
这也是为什么很多项目里流行snprintf(dest, size, "%s", src)来代替strncpy,因为snprintf保证以'\0'结尾。在C11标准提供strcpy_s之后,部分平台也开始推荐类似的安全接口。
手写一个安全strncpy的正确姿势是这样的:
char *strncpy_safe(char *dest, size_t dest_size, const char *src, size_t n) { if (dest == NULL || src == NULL || dest_size == 0) { return NULL; } size_t copy_len = n < dest_size - 1 ? n : dest_size - 1; size_t i = 0; for (; i < copy_len && src[i] != '\0'; i++) { dest[i] = src[i]; } // 保证目标字符串一定以 '\0' 结尾 if (i < dest_size) { dest[i] = '\0'; } return dest; }答案里能主动提到“strncpy不保证末尾补'\0'”这个坑,会让你在面试中的表现明显区别于只会背原型的人。
4.2 strcpy / strncpy / memcpy / memmove 一张表看清
很多初学者把这几个函数混为一谈,面试官也特别爱让候选人比较它们。我整理了一张对比表,方便记忆和理解。
| 函数 | 原型 | 复制单位 | 遇到'\0'会停吗 | 允许重叠吗 | 典型场景 |
|---|---|---|---|---|---|
| strcpy | strcpy(dest, src) | 字符 | 会,复制到'\0'为止 | 不允许 | 字符串拷贝,必须保证目标足够大 |
| strncpy | strncpy(dest, src, n) | 字符 | 会,但源不足n时补'\0' | 不允许 | 固定宽字段拷贝,要小心结束符 |
| memcpy | memcpy(dest, src, n) | 字节 | 不会 | 不允许 | 二进制数据、结构体快照 |
| memmove | memmove(dest, src, n) | 字节 | 不会 | 允许 | 内存区块移动、重叠区安全复制 |
单独说说memcpy和memmove:两者都只复制n个字节,不关心字节内容。区别只在于memmove明确保证“源和目的重叠时也能正确复制”,代价是函数内部会做方向判断。memcpy则言明不允许重叠,所以更快。你可以在数组移动、缓冲区整理这类场景直接选用memmove,把安全性交给标准库。
4.3 选错拷贝函数的三个真实事故
第一个事故,我见过有人把strcpy用在二进制协议解析上。协议头里有一段长度字段,后面跟着不定长负载。它直接strcpy把负载复制到协议结构体里,结果负载里有0x00字节,代表字符串终止,实际数据被截断。从那以后我就记住:任何非文本数据一律用memcpy或memmove,别用strcpy。
第二个事故,是有人用strncpy之后没检查末尾,目标字符串没有'\0'。后续代码用strlen统计长度,读到了缓冲区外的数据,表现就是“这个字符串长度怎么忽长忽短”。最后定位到问题,代码被喷得很难看。
第三个事故,是把memcpy用在重叠区块上。比如把一个数组从后往前移动,本应该用memmove,结果图省事用了memcpy。数据变成一段重复一段缺失,排查了很久才发现是重叠问题。
这些事都说明一个道理:strcpy本身没有错,错的是使用场景和调用方对函数语义的误解。
5. 高频追问清单与自查清单
5.1 面试官从这个问题延伸出的 8 个追问
手写完strcpy只是开胃菜,真正的重头戏是追问。我把常见的追问和期望回答整理成一个清单。
| 追问 | 期望回答 |
|---|---|
| 返回值为什么是char*而不是void | 支持链式调用,保持C字符串函数族的一致性 |
| src为什么要const修饰 | 函数承诺不修改源字符串,编译期契约 |
| 如果不保存dest原始地址会怎样 | 返回的指针指向末尾,调用方无法获得起始地址 |
| 标准strcpy为什么不处理重叠 | 标准将重叠定义为未定义行为,留给实现做最快路径 |
| 如果源和目的重叠,应该如何复制 | 比较地址方向,dest在src右侧时从后往前复制 |
| strncpy存在什么问题 | 源不足n时用'\0'填充有性能开销,源长度≥n时不保证末尾'\0' |
| 如何实现带缓冲区长度的安全拷贝 | 自己写循环,限制最多复制dest_size-1字节,末尾置0 |
| 优化strcpy有哪些思路 | 按机器字复制、检测零字节、对齐处理、交给编译器SIMD优化 |
这8个问题你心里都过一遍,基本就能从“会写”升级到“理解”。
5.2 现场调试中常见的五类异常
自己做这个函数时,可能遇到这些现象,我直接列出来给你排查用。
第一类,程序崩溃在strcpy调用处,常见原因是dest或src为NULL,没有做空指针保护。
第二类,编译能过,但printf("%s", dest)打印出的内容不对。先检查目标缓冲区末尾有没有'\0'。很多手写版本在循环结束后忘记补0,造成这种诡异现象。
第三类,目标字符串内容正确,但源字符串内容莫名被改了。基本可以判定内存重叠,而且复制方向错了。
第四类,程序不崩也不报错,但某个相邻变量的值在函数调用后被悄悄改变。这种大多是缓冲区溢出写穿了边界,把旁边的数据踩了。
第五类,在嵌入式环境下出现硬件异常。我遇到过dest指向只读存储区比如flash,strcpy尝试写入只读区,立刻触发总线错误。这类问题靠调试器一眼就能看到,但容易被初学者忽略。
5.3 我自己复盘这套题时的检查顺序
最后分享一下我拿到这道题时的检查顺序,你可以直接抄下来作为自测清单。
先看签名对不对,char *strcpy(char *dest, const char *src)一个词都不能少。再看有没有保存dest起始地址。然后看循环条件里有没有把'\0'复制进去。接着检查返回的是不是起始地址。最后主动补一句:“标准strcpy不检查重叠和空指针,工程上我会做防护”。这套组合下来,面试官基本没有追问空间。
如果要冲刺高分,再补充一点:讲一下C和C++环境差异。C++里通常直接用std::string或std::copy,很少裸写strcpy。能在C++语境下说出“我更倾向用std::string管理字符串生命周期,避免手工复制”,说明你不是只会背C语法,而是有现代的工程视角。
最后再分享一点个人体会。我在面试别人时,其实最想看到的不是一条炫技版的一行循环,而是候选人写完代码后,自己说出那句“这个函数有踩内存的风险”。能说出这句话的人,多半都有过线上排查的教训,或者有很好的安全意识。strcpy这道题,说到底不是让你秀手速,而是让你秀出对内存边界的敬畏。这一次踩过的坑,也都是让你离真正合格的C开发者更近一步。