
字符串函数是每个写C语言的人都绕不过去的一关。不管你是刚学指针的初学者还是已经在写业务代码的老手只要代码里出现过文本处理就必然跟string.h里的这几个函数打过照面strlen、strcpy、strcmp、strcat、strchr。它们看起来简单但实际使用中翻车率极高缓冲区溢出、返回值比较反直觉、局部越界查半天查不出来……这些问题我踩过不少身边同事也经常被坑。这篇东西就是把它们一个个拆开把声明、行为、边界条件、常见误用、手写实现逻辑都讲透适合正在学C语言的朋友也适合准备面试、或者想把手头代码里的隐患清一遍的人。1. 这批函数到底在解决什么问题1.1 C语言字符串的真实样子没有“字符串”这个类型首先要明白一个前提C语言里根本没有字符串类型。所谓字符串本质上是一段连续的内存结尾有一个值为0的字节也就是\0空字符。所有str开头的库函数都是在跟这种“以空字符结尾的字符数组”打交道。这个设计有它的历史原因。几十年前内存极其珍贵不可能为字符串单独设计一套运行时结构于是干脆用“约定”来代替“类型”你告诉我起始地址我一路往下读直到碰到0为止。这个约定非常简单但也带来两个直接后果没有一个地方记录字符串“有多长”想知道长度只能从头数到尾这是strlen为什么是 O(n) 的原因。没有一个地方记录缓冲区“有多大”函数只能假设你给的目标空间足够大不够就继续写写到别人的地盘上去。理解这一点你会突然对很多“莫名其妙”的 bug 豁然开朗。比如strcpy把源字符串复制到目标它根本不知道目标数组只有 16 字节它只知道一路复制到\0为止。源字符串 40 字节它就写 40 字节。溢出或者说缓冲区溢出就是这么来的。1.2 为什么这些库函数不能丢有人可能会说现在写代码不都用 C 的std::string吗C 里也有snprintf、memcpy为什么还要单独学这几个“老古董”我的看法是第一嵌入式、系统底层、协议解析、操作系统内核以及大量已有的 C 项目仍然以它们为基础你看得懂、改得动这些代码前提就是熟练这些函数。第二std::string的底层实现也绕不开内存拷贝和长度计算理解 C 字符串模型之后你才能理解它内部为什么需要\0结尾、为什么返回的引用会失效、为什么会涉及深拷贝。第三面试几乎必考。手写strcpy、strcmp是经典中的经典它考的可不只是背代码而是对指针、边界、类型转换、内存模型的综合理解。还有一点值得说这些函数的设计确实粗糙但标准库后来给出的“安全版本”比如strncpy、strncat也并不是完美答案它们有自己的坑。我会在后面专门讲这一段。你只有把老版本的行为吃透才有可能真正理解新版本为什么安全、安全在哪里、缺陷又在哪里。这是没法跳过的基本功。2. 逐个拆解定义、行为与边界2.1 strlen别被它的简单迷惑strlen的原型是size_t strlen(const char *s);它做一件事从s指向的地址开始逐字节向后扫描直到遇到\0然后返回“空字符之前的字符个数”。这个返回值不包含\0本身。看起来再简单不过但至少有三个细节值得你注意。第一返回值是size_t是无符号类型。这会导致一个很经典的坑if (strlen(s) - 1 0) { // 永远成立 // 这里的代码永远会执行 }strlen(s)如果返回00 - 1在无符号运算下变成一个极大的数当然大于等于0。正确写法应该是if (strlen(s) 1) { ... }或者先把返回值转成有符号类型再做比较。我当时第一次看到strlen(s) - 1这个写法时以为是自己 C 语言没学好后来才发现是类型转换的问题。这个细节在代码评审里出现过不止一次值得记住。第二它指望字符串必须以\0结尾。如果某个字符数组没有空字符strlen就会越界扫描直到某块内存里碰巧有个0才停下来返回一个根本不可信的大数字。扫描越界本身就是未定义行为程序可能表现正常也可能立刻崩溃。所以规则很简单对谁调用strlen谁就必须是一个“真正的 C 字符串”。第三不要重复调用strlen。在循环里写for (i 0; i strlen(s); i)每一次循环都从头数一遍字符串长度复杂度直接翻成 O(n²)。字符串短还好长字符串会有肉眼可见的性能浪费。正确做法是先把长度存到变量里size_t len strlen(s); for (size_t i 0; i len; i) { ... }2.2 strcpy 与 strcat方便却危险的两兄弟这两个函数经常被一起讨论因为它们的危险模式一模一样。char *strcpy(char *dest, const char *src); char *strcat(char *dest, const char *src);strcpy把src指向的字符串包括结尾的\0复制到dest指向的地址。strcat则先找到dest里\0的位置再从那里开始追加src的内容。它们的共同问题是都不检查目标缓冲区到底有多大。strcpy不检查strcat同样不检查。只要src的实际长度把目标挤爆超出部分就会写到相邻内存里。这种溢出在 C 语言里属于未定义行为直接表现通常是目标数组紧邻的其他变量被篡改程序运行到某个地方莫名其妙崩溃函数返回地址被覆盖程序执行流程被劫持缓冲区溢出攻击就是利用这点开启调试器的优化模式后现象更诡异。strcat还要格外注意一个叠加问题如果dest和src是同一块内存或者位置重叠行为是未定义的。因为strcat会先把dest的结尾找到然后边复制边把源数据读走而源可能已经被自己覆盖了。我自己测试过strcat(buf, buf)在很多编译器下会陷入一种死循环式的增长最终把整个内存写满。使用这两兄弟的安全守则只有一条你必须用某种方式提前保证目标缓冲区有足够空间再进行调用。比如char dest[32]; if (strlen(src) sizeof(dest)) { strcpy(dest, src); } else { // 处理源字符串过长的情况 }2.3 strcmp比较的真实规则strcmp的原型int strcmp(const char *s1, const char *s2);它的行为是按字节逐个比较两个字符串中对应位置的字符直到遇到第一个不相等的字符或者某一方遇到\0。比较的基础是字符的编码值通常就是 ASCII 码。返回值的规则是如果s1大于s2返回正数如果s1小于s2返回负数如果完全相等返回 0。很多人误以为返回值一定是1或-1其实标准只规定了“正数”和“负数”并没有规定具体数值。所以在判断时应该用 0、 0、 0而不是去比较返回值是否等于某个特定值。还要注意一个点strcmp的比较是按字典序的不是按长度。比如abc和abcd前三个字符相同然后abc遇到\0比较结果为负这说明短的字符串小于长的前缀相同的字符串。如果你想按长度再按内容去比较就得自己写逻辑。标准规定strcmp的比较应该像是把字符看作unsigned char来进行这在char类型默认是有符号的平台上很关键。如果你手写实现时直接return *s1 - *s2;在中文环境或者使用扩展编码时可能得到错误的正负判断。正确做法是比较前把char转成unsigned char再相减。int my_strcmp(const char *a, const char *b) { while (*a (*a *b)) { a; b; } return (unsigned char)(*a) - (unsigned char)(*b); }2.4 strchr 与它的兄弟们查找字符的那点事strchr是在字符串中查找一个字符首次出现的位置返回指向该字符的指针char *strchr(const char *s, int c);这里有个不太直观的细节参数c的类型是int但函数内部会把c转换成char来比较。标准里写明这个转换和查找行为应该是“把字符视为 unsigned char”来进行的。如果找不到对应字符返回NULL。strchr最常见的用途是拆字符串。比如把keyvalue从处拆开char *p strchr(str, ); if (p) { *p \0; // 在 处截断 char *key str; char *value p 1; }这里有个非常容易被忽视的点strchr在查找\0时如果传入的c是0它会返回指向字符串末尾空字符的指针而不是返回NULL。这是标准行为很多人在做“查找空字符”时会踩一脚以为找不到。理解之后其实可以利用这个特性来判断一个字符是否在字符串中出现过判断c是否是\0时strchr的返回语义和普通字符不一样。与strchr配套的还有几个函数函数作用注意点strrchr从右往左查找最后一次出现的字符返回最后一个匹配位置strstr查找子串第一次出现的位置需要匹配多个字符strpbrk在字符串中查找任意一组字符中的第一个匹配接受“字符集合”而不是单个字符memchr在指定的内存块中查找字符需要显式传入长度不依赖\0strstr是找子串底层实现通常有优化算法双指针滑动匹配复杂度优于暴力逐字符比较。如果你只需要找单个字符用strchr就够了需要找集合里的任意一个用strpbrk需要在一个非字符串的内存区域里找则必须用memchr。选错函数要么效率降低要么语义不对。2.5 strncpy 与 strncat带 n 的安全版本并不完美标准库后来加了一组带长度限制的函数看起来安全实际上坑也不少。char *strncpy(char *dest, const char *src, size_t n); char *strncat(char *dest, const char *src, size_t n);strncpy最多从src复制n个字符到dest。这里面的坑是如果src的长度小于等于n函数会把剩余位置全部用\0填充如果src的长度大于等于n则复制了n个字符后不会自动补\0。也就是说strncpy的结果不一定是一个“正常的 C 字符串”。最常见的建议是char dest[32]; strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] \0;先把最后一个位置置空避免dest不以\0结尾。这样写确实避免了缓冲区越界但也放弃了strncpy的“零填充”特性浪费了一点性能。strncat的行为和strncpy不同它最多追加n个字符并且总是在结果末尾补一个\0。所以调用它时需要保证目标缓冲区除了原有内容之外还能容纳n 1个字符。很多人把n直接传成sizeof(dest)然后发现追加后没有位置放\0就出问题了。正确理解是n控制的是“最多追加多少个字符”而不是“目标缓冲区总大小”。实际上我对项目里老代码的建议是如果你只是想安全地复制一个以空字符结尾的字符串别用strncpy直接用snprintf更直观也更安全snprintf(dest, sizeof(dest), %s, src);这条语句在目标空间不足时会自动截断并且保证结果以\0结尾。代价是效率比strcpy低一些但换来的是安全性和清晰度值得。3. 读懂背后的实现自己动手写一遍3.1 几个教科书级别的实现自己在纸上写一遍这些函数对理解它们的行为帮助极大。我先给一版最基础、最直白的实现不考虑优化重点是把逻辑讲清楚。// strlen数到空字符为止 size_t my_strlen(const char *s) { const char *p s; while (*p) { p; } return (size_t)(p - s); } // strcpy先把空字符也复制过去 char *my_strcpy(char *dest, const char *src) { char *d dest; while ((*d *src)) ; return dest; } // strcat先找到 dest 的结尾再复制 src char *my_strcat(char *dest, const char *src) { char *d dest; while (*d) d; while ((*d *src)) ; return dest; } // strcmp逐字符比较遇到不同或空字符停止 int my_strcmp(const char *a, const char *b) { while (*a (*a *b)) { a; b; } return (unsigned char)(*a) - (unsigned char)(*b); } // strchr返回第一个匹配字符的指针 char *my_strchr(const char *s, int c) { char ch (char)c; for (;; s) { if (*s ch) { return (char *)s; } if (*s \0) { return NULL; } } }注意my_strchr里返回(char *)s的转换。传入参数是const char *但按标准要求返回的是char *所以必须做一个去掉const的强制转换。这个转换在语义上有点别扭但标准就是这么规定的。my_strcpy里的while ((*d *src));是非常经典的一行。它做了三件事把*src赋值给*d然后两个指针都自增最后判断赋值语句的结果也就是被赋的字符是否为 0。等于说连空字符一起复制然后在空字符处停止循环。my_strcmp的返回值用了(unsigned char)(*a) - (unsigned char)(*b)。如果不转成unsigned char在char是有符号的平台上遇到扩展字符比如大于127的字节可能出现负数当成正数的反直觉结果。这版实现虽然能用但性能上还有优化空间。比如strlen、strcpy在编译器里通常会被替换成更高效的实现一次读取多个字节检查每个字节是否为0利用位运算一次判断4个或8个字符然后用编译器提供的向量化指令。日常使用不用你手动写这些但理解这一点面试时提到“编译器会做优化、我们会选择面向宽度的实现”会是一个不错的加分项。3.2 内存重叠为什么必须用 memmove这几个字符串函数有个共同的隐含前提源和目标不能重叠。一旦重叠行为就是未定义的。典型场景是你想把一个字符串往后移动几个位置或者往前移动几个位置比如给某个片段腾出空间。如果用strcpy或memcpy处理重叠区域就可能出现严重的复制错乱。举个例子char buf[] abcdef; // 想把 cdef 移动到 buf2 的位置实际就是原 buf 里的 cdef strcpy(buf 2, buf 2);这种复制在memcpy和strcpy下结果不可预知需要处理源和目标重叠的拷贝时标准做法是使用memmovechar buf[] abcdef; memmove(buf 2, buf, 4); // 把 abcd 移到 cdef... 处memmove的实现会先判断源地址和目标地址的前后关系决定是从前往后拷贝还是从后往前拷贝确保重叠时也能得到正确结果。这个知识点在项目里非常实用。比如解析网络数据包时你经常要把一段数据往前移动几个字节去掉头部或者调整对齐这时候用memmove比memcpy稳妥得多。同样strcpy和memcpy都做不到的事情也别指望strncpy能处理它们都属于“不允许重叠”的范畴。3.3 手写版本在面试中的要义很多人以为面试手写strcpy就是背一段代码其实考官真正想看的是你考虑边界条件的能力。我见过不少候选人能写出循环体但忽略了扣分点。下面这几个点是我觉得面试时最有区分度的地方strcpy需要返回dest指针而不是返回void。虽然有返回值和没有返回值功能上都能完成复制但标准库的签名是char *这样调用方可以链式调用比如strlen(strcpy(dst, src))。要保留最初的dest指针。因为循环体里dest指针一直在移动直接return dest;返回的就是指向末尾的指针完全错误。strcmp要谈字符类型的转换。这里体现你对“char 是否有符号”这类实现差异的理解。strchr的const语义转换要能解释清楚。如果能进一步说出strcpy、strcat的安全隐患以及替代方案说明你不只是背了 API而是真的理解内存模型。4. 实操实录一段代码看清这些函数的正确用法4.1 完整的可运行 Demo把理论落到代码里。下面这个 Demo 基本上把这几个函数的主要用法都覆盖了注释写清楚每一步在做什么#include stdio.h #include string.h int main(void) { const char *name data; char path[64]; char log_msg[64]; // 1. strlen获取长度 size_t len strlen(name); printf(name length: %zu\n, len); // 2. strcpy安全的复制方式先手动判断空间 if (len sizeof(path)) { strcpy(path, name); printf(path: %s\n, path); } else { fprintf(stderr, buffer too small\n); return 1; } // 3. strcat拼接先计算剩余可用空间 const char *suffix .log; size_t suffix_len strlen(suffix); size_t used strlen(path); if (used suffix_len 1 sizeof(log_msg)) { strcpy(log_msg, path); strcat(log_msg, suffix); printf(log_msg: %s\n, log_msg); } else { fprintf(stderr, no space for log suffix\n); return 1; } // 4. strcmp比较 if (strcmp(name, data) 0) { printf(name equals data\n); } // 5. strchr定位字符 char *dot strchr(log_msg, .); if (dot) { printf(extension: %s\n, dot 1); } else { printf(no dot found\n); } // 6. memmove 处理重叠区间 char buffer[32] hello world; memmove(buffer 6, buffer, 5); buffer[11] \0; printf(memmove result: %s\n, buffer); return 0; }这段代码里我最想强调的其实是第 3 步先计算当前已用长度、剩余空间再进行strcat。很多人在实际项目里会偷懒直接写strcat(log_msg, suffix);。当时没问题后来某一天suffix变成了用户输入的长字符串缓冲区立刻溢出整个模块的日志输出错乱排查了很久才发现是拼接处出了问题。4.2 缓冲区大小怎么算才不出错跟字符串函数打交道最核心的技能其实是“算缓冲区”。我总结了一个习惯每次要往某个缓冲区写东西之前先问三个问题缓冲区总共多大已经用了多少还剩多少可用空间用一个简单公式记忆总大小 已用长度 待追加长度 1。那个1是留给\0的最容易丢的就是它。举例说明char dest[64]; const char *prefix user:; const char *name alice; size_t total sizeof(dest); // 64 size_t used strlen(prefix); // 5 size_t need strlen(name) 1; // 6 if (used need total) { strcpy(dest, prefix); strcat(dest, name); } else { // 处理空间不足 }注意strlen(prefix)代表的是逻辑长度sizeof(dest)代表的是数组的“容量”。很多人犯的错误是把这两个概念混在一起拿sizeof(dest)去跟strlen(src)比较以为空间够了结果忽略了\0还要占一个字节。这个问题在字符数组作为函数参数传入时尤其容易踩数组一旦退化成指针sizeof就再也不能得到缓冲区容量了。void append_suffix(char *dest, size_t dest_size, const char *suffix) { size_t used strlen(dest); if (used strlen(suffix) 1 dest_size) { return; // 空间不足直接拒绝操作 } strcat(dest, suffix); }在函数里接收char *dest的同事必须同时把dest_size传进来否则根本无法判断安全边界。如果你在项目里看到有人写了void f(char *dst)然后在内部调用strcpy(dst, src)我没有办法不担心。4.3 替代方案怎么选既然strcpy、strcat这么危险实际项目里是不是就不该用我不主张一刀切。当你明确知道目标缓冲区足够大、数据源可信、性能敏感并且代码中已经用断言或显式检查覆盖了前提条件时直接用strcpy完全没问题。但如果这些前提不那么确定更稳的方案是snprintf(dst, size, %s, src)适用于“复制一个字符串到已知大小的缓冲区”自动截断并保证空字符结尾。strncat(dst, src, n)适用于“追加不超过 n 个字符”但你必须搞清楚n的含义。memmove适用于源和目标可能重叠的情况。memcpy适用于没有重叠、且已知精确长度的情况效率比字符串函数高。对截断的处理要额外小心。snprintf截断后返回的值是“如果空间足够应该写入的字符数”而不是实际写入的字符数。如果你想判断是否发生截断要用返回值去和缓冲区大小比较而不是靠strlen(dst)。5. 常见问题与排查技巧5.1 高频 bug 速查我把这些年实际工作里看到的高频 bug 整理成了一个表基本覆盖了八成以上的现场问题现象常见原因解决办法程序崩溃在某个strcpy/strcat附近目标缓冲区太小先检查空间或改用snprintf局部变量被悄悄篡改某个strcpy写越界覆盖了相邻变量用调试器看数组周边内存检查所有复制点strlen返回巨大数字传入的数组没有\0检查数组初始化使用memcpy显式长度strcmp比较结果不符合预期手写实现没有考虑 unsigned char或者拿返回值去和 1 比较改用strcmp比较时只用 0/正/负strncat追加后字符串不完整n传的是sizeof(dest)而不是“最多追加几个字符”传入剩余空间 - 1strncpy后输出乱码src长度恰好大于等于n结果没有\0手动补\0在最后修改字符串后其他指针内容也变了多个指针指向同一块内存或复制的只是指针确认是否需要深拷贝字符串拼接后长度不是期望值多次strcat但没有同步更新缓冲区余量每步都重新计算used need最让我印象深刻的一个案例是这样某同学写了一段解析配置文件的代码把一个keyvalue字符串拆开然后用strcpy把value复制到另一个缓冲区。看起来天衣无缝但运行到后期程序遇到大文件就崩。调试后发现文件里有一行的value后面没有换行符strcpy一直把后面的内存数据也复制过去缓冲区写爆了之后堆结构被破坏直到很久之后系统才崩溃。这个问题如果早点对源字符串做长度校验完全能避免。5.2 排查工具与手段遇到字符串相关的崩溃我推荐按下面的顺序排查开启编译器的地址消毒器AddressSanitizer。编译时加-fsanitizeaddress -g运行时会直接报出哪个函数哪一行发生了越界写入以及越界了多少字节。这个工具只要用过一次你就会后悔以前怎么依赖人肉 Debug。用调试器在可疑函数上设置条件断点。比如我想知道哪一次调用strcpy的源字符串特别长可以在断点里写strlen(src) 100命中后再查看调用栈和变量。打印缓冲区容量和已用长度。写一个小的诊断函数把每个缓冲区的sizeof、strlen、剩余空间都打出来崩溃前后对比。关注编译警告。-Wall -Wextra -Wformat2能抓出不少printf格式串与参数类型不匹配的问题。strcpy本身不会给出警告但某些静态分析器会提示你“使用不安全函数”。还有一个习惯我认为极其有用字符串相关操作全部封装成工具函数比如int safe_strcpy(char *dst, size_t dst_size, const char *src); int safe_strcat(char *dst, size_t dst_size, const char *src);这两个函数内部做长度检查空间不足时返回错误码。项目里所有地方都用它们以后想在上面加日志、加统计、加全局开关只需改一个文件。这个思路比“每次都记得手动判断空间”可靠得多因为人的记性是有极限的。5.3 面试与工作中的一个核心心态最后说点我的体会。字符串函数这批接口看起来是你编程早期的启蒙知识但它真正考验的是你对“内存边界”的敏感度。写业务代码时任何一个函数调用都可能是越界的起点而“越界”往往不立刻报错而是隔了很久、在完全不相干的地方爆发。我个人的建议是不要只满足于“会用”要去把每个函数的行为边界摸一遍。你对strlen的尽头、strcpy的越界、strcmp的类型转换理解得越清楚写出来的代码就越经得起推敲。面试考这东西本质上是在看你这个人有没有“把细节搞清楚之后再动手”的素养。工作多年之后回头看这几个老函数教给我的东西比很多花哨的框架都实在。如果你正在学 C或者正准备面试不妨把这篇里提到的例子全部手动编译运行一遍改一改参数看看崩溃现象是什么样的。踩过几次坑你就再也不会忘了。