ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言字符串函数深度解析:strcat/strstr/strchr的原理与工程实践

C语言字符串函数深度解析:strcat/strstr/strchr的原理与工程实践 做嵌入式开发和后端服务这几年我几乎每天都在跟字符串打交道。日志要拼接、协议要解析、配置要查找翻来覆去就是strcat、strstr、strchr这三个函数。但说实话很多人对它们的认识停留在“会用原型”的层面strcat拼接过一次strstr用过一次找子串strchr偶尔用来找冒号真到了项目里遇到段错误、乱码、溢出问题就开始抓瞎。这一篇我不想照着教科书念定义。我想从一个实际写代码的人的角度把这三个函数的底层行为、内存操作、典型工程场景、以及我踩过的坑全部摊开讲。内容围绕三件事展开这三个函数本质上是怎样操作内存的在日志系统、报文解析、配置读取这些真实场景中怎么用得漂亮以及哪些情况下你必须放弃它们换更安全或更高效的手段。适合刚学完C语言基础、想进阶的读者也适合在工作中频繁处理字符串、但没时间深挖底层细节的朋友。1. 先从底层认清三个函数的本质1.1 strcat看似简单实际上是一场指针移动的接力赛先看原型char *strcat(char *dest, const char *src);作用是把src指向的字符串追加到dest末尾返回dest的起始地址。这个函数表面上是“拼接”底层做的事情是三步找到dest的结束符\0在这个位置开始把src的字符一个个拷过去最后补上一个\0。很多人以为自己会写但真让你手动实现一版能一次写对的人其实不多。我给出一个教学版的实现char *my_strcat(char *dest, const char *src) { char *p dest; while (*p ! \0) p; while ((*p *src) ! \0) ; return dest; }这里有两个细节值得注意。第一第一个while循环结束后p指向的是dest的结束符\0不是最后一个有效字符。很多初学者会写成while (*p) p; p--;这就多退了一步导致新字符串从上一个字符的位置开始覆盖结果输出会缺一个字符。第二第二个循环里的赋值语句*p *src是整个函数的灵魂。它先执行赋值再判断赋值结果是否为\0。这意味着src末尾的\0也会被拷贝进去同时p和src两个指针都会自增。当src的\0被拷贝时循环条件为假循环结束dest已经是完整正确的字符串了。这种“先赋值后判断”的写法在 C 语言里非常经典strcpy的标准实现也是这个套路。但经典不代表安全。strcat的最大问题是它完全不关心dest的容量。你告诉它 “把src接到末尾”它就傻乎乎地一直往后面写写到dest的缓冲区边界之外也不会停下。在工程上这几乎就是缓冲区溢出漏洞的同义词。再说一个效率层面的隐藏成本。如果拿strcat连续拼接多个字符串比如strcat(buf, A); strcat(buf, B); strcat(buf, C);每次调用strcat都必须从头开始扫描buf找到当前的\0位置。第一次拼接扫描了 N 个字符第二次又扫描了 N1 个第三次 N2 个。如果拼接次数多、字符串长这个重复扫描会形成典型的 O(n^2) 复杂度。我后面会在日志系统的章节专门讲怎么绕开这个坑。还有一点strcat的返回值其实就是入参dest设计者这样设计是为了支持链式调用比如strcat(strcat(buf, A), B)。但链式调用意味着第二次strcat又从头扫描性能更差实际工程中我并不推荐这么写。1.2 strstr朴素的子串匹配和容易被忽略的边界原型如下char *strstr(const char *haystack, const char *needle);haystack是被搜索的主串needle是要找的子串。返回needle第一次在haystack中出现的位置指针如果没找到返回NULL。标准库的实现通常是朴素匹配外层循环枚举haystack中每个可能的起始位置内层循环逐字符和needle比较。最坏情况下主串长度 n、子串长度 m复杂度是 O(n*m)。比如主串是aaaaaaaaaaaaaaaaaaaaaaaaaaaaab子串是aaaaab每次都匹配到最后一个字符才失败然后回溯到下一个位置重新匹配。不过实际工程中大多数搜索场景的文本和关键字都不算长字符串内容也不是刻意构造的最坏情况strstr的朴素匹配效率完全够用。使用strstr时边界情况比想象中更容易翻车我有两个印象特别深一个是needle为空字符串的情况。标准规定如果needle为空字符串strstr直接返回haystack。这个行为很多文档写的含糊你在做字符串截取时如果不判空拿到一个起始指针再用这个指针去算长度可能得到的是整个字符串的长度和预期完全不符。另一个是返回值可能指向主串内部。strstr返回的是haystack末尾之后某个位置而不是新分配的内存。它不会修改原字符串但你可以通过返回的指针去修改原字符串的内容。比如char text[] hello world; char *p strstr(text, world); if (p ! NULL) { p[0] W; // 直接修改主串 }这没问题。但如果你写成char *text hello world; char *p strstr(text, world); p[0] W; // 崩溃那就完了。因为text指向的是字符串字面量位于只读存储区任何修改都会触发未定义行为。这两者的区别我在后面的 const 陷阱部分还会再展开。如果你需要不区分大小写的搜索可以关注strcasestr但它在 POSIX 标准里不是 C 标准函数跨平台时要自己处理。比如 Windows 上就需要定义_GNU_SOURCE或者自己手写一个转换小写再比较的版本。1.3 strchr单字符搜索里最被轻视的函数原型char *strchr(const char *s, int c);strchr在字符串s中查找字符c找到返回第一次出现位置的指针没找到返回NULL。这里c的类型是int但函数内部会把它转成char来比较。两个容易被忽略的细节第一strchr可以查找\0。当你调用strchr(s, \0)时它必然返回指向s末尾\0的指针。这个特性可以用来快速定位字符串末尾虽然实际用strlen更直白但某些场景下确实能写出更简洁的代码。第二strchr只找第一次出现的位置。你需要最后一次出现的位置时得用strrchr。比如解析文件路径想取出文件名char path[] /home/user/project/main.c; char *slash strrchr(path, /); char *filename slash ? slash 1 : path;这一段就是strrchr的经典用法。strchr和strrchr配合使用在路径处理、文件扩展名提取这些场景里非常顺手。另外strchr的实现也很简单本质上就是一个逐字符遍历while (*s *s ! c) s; return (*s c) ? s : NULL;注意这里用到了*s ! c作为循环条件之一所以当s已经走到\0时循环退出再判断*s c是否为真。如果查找的字符是\0这个逻辑是能正确返回末尾指针的。这也是为什么strchr查找\0会成功的原因——标准库把字符比较和结束判断分开了。strchr还有一个容易被忽略的用途在协议解析中快速定位分隔符。HTTP 头里每行是键: 值的结构冒号分隔你一次strchr(line, :)就能拿到键和值的分界点。后面我会用完整示例演示。2. 工程场景实战三个函数的黄金组合2.1 日志系统拼接告别 strcat 的 O(n^2)先看一个非常典型的“反面教材”。很多人在嵌入式设备和后台服务里喜欢用strcat去拼一个日志行char log_buf[256] {0}; strcat(log_buf, [2025-06-01 10:30:00] ); strcat(log_buf, [INFO] ); strcat(log_buf, [network] ); strcat(log_buf, connect timeout);这个代码功能上没错但性能很差。每次strcat都要从头扫描一遍log_buf找到末尾。日志系统通常处于高频调用路径上这种写法会白费大量 CPU 周期。我在实际项目中更推荐“指针移动法”char log_buf[256]; char *p log_buf; int n; n sprintf(p, [%s] , timestamp); p n; n sprintf(p, [%s] , level); p n; n sprintf(p, [%s] , module); p n; sprintf(p, %s, message);重点是每次sprintf之后立刻把p移动到本次写入的末尾。这样每次追加都不需要重新扫描整个缓冲区整体复杂度从 O(n^2) 降到了 O(n)在日志频繁输出的系统里效果很明显。这里必须配合缓冲区的剩余空间检查。上面的示例为了简洁省略了工程上应该这样int remain sizeof(log_buf) - (p - log_buf); n snprintf(p, remain, [%s] , level); p n;snprintf会限制最多写入remain-1个字符再加上一个\0可以从源头防止缓冲区溢出。如果remain小于等于 0就不要再写了。你可能会问为什么不用strncat答案是strncat也有它的毛病每次调用时它内部仍然需要从dest开头扫描一次找到\0的位置所以连续多次strncat同样存在 O(n^2) 的问题并没有比strcat好多少。用sprintf/snprintf的“指针移动法”是更清爽的做法。2.2 协议解析strstr 定位特征段strchr 切分键值服务端开发中解析自定义协议特别频繁。假设一条报文格式如下BEGIN:1 TYPE:heartbeat DATA:online END:1我需要做三件事先找到报文内容的起点和终点再按行拆分最后每行再按冒号拆分键值。第一步定位数据起点char *start strstr(buffer, BEGIN:); if (start NULL) { // 报文不完整或格式错误 return -1; } start strlen(BEGIN:);第二步按行扫描每行用strchr找冒号char *line start; char *end strstr(line, END:); if (end NULL) return -1; while (line end) { char *newline strchr(line, \n); if (newline NULL) newline end; char *colon strchr(line, :); if (colon ! NULL colon newline) { size_t key_len colon - line; size_t value_len newline - colon - 1; // key 就是 line 到 colon 的部分 // value 就是 colon1 到 newline 的部分 } line newline 1; }这个示例里strstr负责“找区间”strchr负责“找点”两者配合非常自然。工程上解析 HTTP 头也是一样的套路先用strstr(headers, \r\n\r\n)找到头结束的位置再对每个头字段行做strchr(line, :)把键和值分开。这里我踩过一个坑原始报文里可能包含\r\n也可能只有\n。如果只找\n不处理\r解析出来的 value 末尾会残留一个\r比较时永远不相等。解决办法很简单在拿到 value 的长度后判断一下末尾是否\r是就长度减一。实际协议解析中换行符差异引发的 bug排错往往要花半天。2.3 配置文件读取迷你 INI 解析器很多嵌入式项目没有现成的配置库需要自己读 INI 文件。一个典型的配置行长这样[network] ip192.168.1.100 port8080 [debug] level3解析流程可以这样设计逐行读取遇到[section]时用strchr提取小括号中间的部分遇到keyvalue时用strchr(line, )定位等号再用strstr(line, ip)等方式匹配我知道的 key。只匹配 key 有两种写法我建议别用strstr(line, ip)。因为strstr是子串匹配ip也会匹配到pip或者options这类包含ip的单词。更稳妥的做法是先strchr(line, )取出 key 的长度再精确比较 key 部分。char *equal strchr(line, ); if (equal NULL) continue; size_t key_len equal - line; char *spaces line key_len; while (spaces line (*(spaces - 1) || *(spaces - 1) \t)) spaces--; key_len spaces - line; if (key_len 2 strncmp(line, ip, 2) 0) { // 解析 ip 的值 }这个代码同时处理了key value这种带空格的情况。你可能会问为什么不用sscanf(line, %[^]%s, key, value)sscanf对简单的keyvalue场景是够的但它处理不了[section]这类行而且对格式错误容忍度很低一旦值里包含空格或者注释行为就不符合预期。手写strchr 长度计算的方案看起来代码多但每一步都可控反而更好维护。2.4 路径与文件名校验strrchr 的巧用虽然不是标题里的核心函数但strrchr和strchr经常一起出现。比如我要从路径中提取扩展名char path[] /tmp/download/file.tar.gz; char *dot strrchr(path, .); if (dot ! NULL) { // dot 指向 .gz扩展名是 dot 1 }这里刻意用strrchr而不是strchr因为路径里可能有多级目录目录名里也可能带点我们只需要最后一个点后面的部分。同样的思路拿文件名用strrchr(path, /)拿端口号前的主机名用strchr(host, :)。这三个函数在实际工程里就是一组基本工具组合使用的时候代码的语义会非常清晰。3. 安全性与性能什么时候必须放弃 strcat3.1 缓冲区溢出strcat 的不安全根源strcat不安全的根源在于它不知道dest缓冲区的容量。它只负责把src的字符一个个往dest里写直到把src写空。如果src比剩余空间长它会毫不犹豫地突破边界。举个实际例子char buf[8] hello; strcat(buf, world, this is too long);buf只有 8 字节初始占用 5 个数据字符加一个结尾\0剩余可用空间是 2 个字节。strcat会把后面那一长串全都写进内存直接写穿栈上相邻变量。轻则变量被覆盖重则函数返回地址被篡改程序行为完全失控。在真实项目里我记得有一次排查一个神秘的变量被篡改问题最后发现就是某个模块里一个strcat越界写坏了相邻的全局数组。要在工程上规避这个问题通常有几种做法一是改用strncat限制最大追加长度strncat(buf, src, sizeof(buf) - strlen(buf) - 1);但要注意strncat的第三个参数是“最多追加的字符数”它会在追加结束后再补一个\0。如果你把第三个参数直接写成sizeof(buf)而buf已经快满了它依然会写sizeof(buf)个字符再加\0同样溢出。所以稳妥写法是上面那种“剩余空间减一”的公式。二是用snprintfsnprintf(buf, sizeof(buf), %s%s, buf, src);这种做法能由snprintf内部保证不会超过sizeof(buf)而且支持一次拼接多个来源。缺点是每次调用也要重新扫描buf在频繁拼接下有性能问题。要解决性能问题还是回到我前面说的“指针移动法”用snprintf往指针位置追加同时维护剩余长度。三是如果你明确知道数据长度和缓冲区容量并且在性能敏感的热点路径上运行可以考虑memcpy手动拼接并维护长度。这种写法最灵活也要求程序员对内存有足够的掌控力适合对性能有极致追求的场景。3.2 strstr 的性能陷阱与替代方案strstr的朴素匹配在最坏情况下是 O(n*m)。如果主串很长、模式串也长而且文本内容高度重复实测会明显卡顿。比如在 10 MB 的日志里反复匹配一个 100 字节的模式串最坏情况下的回退开销会吃掉大量 CPU。遇到这种场景你可以在工程上分三步考虑先评估最坏情况是否真的会出现。如果文本是人写的配置、协议报文、命令输入通常不会触碰最坏复杂度strstr足够用。如果文本是机器生成的重复模式比如传感器数据流、大规模日志那就要谨慎了。再考虑是否引入标准库提供的替代函数。某些平台提供了更长名称的优化函数比如strstr在 glibc 里其实已经是二路算法最坏情况不是朴素的 O(n*m)而是更优。Windows 上的StrStrA之类的 API 也有厂商优化。多数情况下直接用标准库不会太差。最后如果实在要自己实现可以在 BM、KMP、Sunday 算法里选一个。我个人的建议是普通项目别自己写算法优先用标准库真正要达到极致性能时再针对数据特征选择算法并配套完整的性能测试。以strstr和strchr为主打函数的代码在协议解析、日志提取等场景中定位精准往往比提前优化更值得。其实strstr还有一个常见的工程误用用strstr判断一个字符串里是否包含某个关键字然后根据结果做分支。例如if (strstr(user_input, system) ! NULL) { // 危险操作 }这种判断很容易被绕过因为strstr做的是子串匹配不是单词匹配。用户输入subsystems也会通过。在命令解析、权限校验等安全敏感场景中必须做词汇边界判断。比如判断一个字符串是不是独立的system通常要检查关键字两侧是否都是非字母数字字符。3.3 返回值与 const 的工程陷阱看一段代码const char *msg hello world; char *p strchr(msg, o); p[0] X;strchr的原型接收的是const char *返回的却是char *。这块设计一直有争议因为传入const字符串时理论上不应该能通过返回指针去修改。但标准库为了兼容老代码把返回值设计成了char *。这导致了一个很尴尬的情况你拿到一个非 const 的指针但背后指向的内存可能是只读的。这个陷阱最常见的触发场景是字符串字面量。C 标准规定修改字符串字面量是未定义行为。很多编译器会把字面量放到只读段一旦写操作立刻段错误。你以为strchr(text, o)给了你可修改的指针实际上一写就崩。工程上的规避策略是如果源字符串是const char *使用strchr或strstr的返回值时统一按只读处理绝不通过它去修改内容。如果你确定需要修改请先复制到可写的字符数组char buf[64]; snprintf(buf, sizeof(buf), %s, msg); char *p strchr(buf, o); if (p ! NULL) *p X;另一个相关问题是strstr和strchr返回的指针指向原字符串内部函数本身不会新分配内存。这意味着你不应该把返回值直接交给某个“自动释放”的封装也不要用完就free否则必然崩溃。很多从其他语言转过来的开发者在这里容易踩坑以为查找到的字符串是新分配的内存。4. 常见问题与排查技巧实录4.1 段错误的四种典型原因我在调试很多 C 程序时遇到段错误的第一反应就是检查字符串操作相关的代码。用strcat、strstr、strchr导致段错误原因通常集中在以下四种。第一种dest指向只读内存。比如char *p test; strcat(p, 123);p指向字符串字面量strcat往里写数据段错误几乎是必然。实际调试时我们经常在gdb里发现崩溃地址落在只读段定位过来就是这类问题。第二种dest未初始化或为空指针。调用strcat时函数会先扫描dest找\0如果是个野指针它会去扫描一块非法内存。排查时可以检查dest是否经过malloc或数组定义是否忘记初始化第一个字节。第三种缓冲区过小。strcat或者strncat的长度参数计算错误导致写越界。这种问题有时候不立即崩溃而是过一段时间才表现出来排错极为隐蔽。建议对每一个缓冲区容量有全局限定尽量用一个专门宏或常量来定义不要到处写死。第四种dest和src内存重叠。strcat、strcpy这类函数不保证支持重叠区域。如果你用同一个缓冲区做原地操作可能造成死循环或者内容错乱。比如strcat(buf, buf 5);这种代码行为未定义最好用memmove配合长度计算来完成。调试时工欲善其事必先利其器。我一般会在gdb里用p buf、x/20bx buf这类命令直接查看内存内容先看\0的位置再看越界的字符从哪里开始。如果一个段错误十分钟内定位不了多半是字符串的长度或结束符出了问题先把所有strlen的调用打出来寻找不合预期的地方。4.2 中文编码下的查找字节与字符的认知博弈strchr、strstr处理的单位是字节不是字符。对于纯英文 ASCII 内容这没有问题。但工程上免不了和中文打交道比如配置文件的注释、协议里的name字段、日志里的中文描述。以 UTF-8 编码为例一个汉字通常占 3 个字节。此时用strchr(line, 中)是编译不过的因为中是一个多字节字符常量在 C 语言里无法直接用单引号表示。就算你尝试把中文字符串直接传给strstr比如char *p strstr(utf8_text, 你好);它能正确工作吗能。因为strstr做的是字节串的匹配它会逐字节找你好对应的 UTF-8 字节序列。但前提是你传入的关键字必须是完整的 UTF-8 编码而且原始文本和关键字的编码必须一致。如果配置文件是 GBK源码是 UTF-8strstr会找出一堆完全不匹配的乱码序列。如果你需要按“字符”而不是“字节”来处理中文strchr这类函数就不够用了。一般要借助 ICU、libiconv 或者特定的宽字符函数wcschr。但在嵌入式项目中我建议尽量在协议和配置层面约定使用纯 ASCII 的键名和英文值。中文只允许出现在自由文本描述里不做字段定位。这样既避免乱码也避免跨编码环境带来的排错地狱。如果确实要在中文文本里查找某个中文字符串特别注意一点strstr的返回值是字节偏移而不是字符偏移。你用返回值减去haystack得到的是字节数直接用于字符截取会切出半个汉字。实际项目里我见过不止一次因为这种偏移计算导致打印出来乱码的问题。4.3 三个函数的版本选择速查表我整理了一张表平时写代码犹豫时就看它需求推荐函数不推荐的原因拼接字符串且确定容量够strcat不检查容量生产代码尽量少用拼接字符串担心溢出snprintf或指针移动法strncat会重复扫描参数易写错查找子串是否存在strstr注意返回值指向原串只读处理忽略大小写查找子串strcasestrPOSIX跨平台需自己实现查找字符位置strchr只能按字节处理找到最后一个字符位置strrchr注意路径分隔符等场景按长度查找防越界strnstr非标准需自己实现加上限判断精确比较键值strcmp/strncmpstrstr是子串匹配不精确strnstr不是 C 标准函数很多嵌入式平台也没有。项目里如果非要用直接写一个简单的带长度限制查找函数是个不错的选择。4.4 调试技巧与指针排查心得字符串函数相关的 bug最有效的排查工具依然是gdb。我分享两个非常具体的操作。第一用p打印字符串内容时如果显示的是地址而不是内容可以用p (char*)ptr强制转换。尤其在调试strchr和strstr的返回值时返回值本身就是一个char *如果因为类型问题变成int打印出来就是地址看不出内容。第二用x/20bx buf查看内存字节。有时候字符串内容看起来正常但末尾缺少\0用printf打印会持续输出直到碰到下一个\0。这时用x/bx查看内存就能立刻确认\0是否存在、位置在哪里。这个技巧在排查strcat拼接后的字符串被截断或越界时非常有效。再分享一个测试思路。写单元测试时针对strcat、strstr、strchr这几个函数建议把边界用例一次列全空字符串与空关键字关键字比主串还长关键字正好在主串开头和结尾主串全等关键字目标字符是结束符\0使用中文和多字节编码的用例这些用例看似简单但能拦住绝大多数回归问题。我见过很多项目把字符串函数视为“每台机器上都能跑”的公共功能不做测试结果底层一个长度计算错误导致上层功能反复出 bug。另外自己做字符串封装时我习惯把函数设计成“输入长度参数 返回实际写入长度”的形式。这样所有调用方都知道缓冲区是否用尽、内容是否被截断。这种模式比裸用strcat和strstr安全得多也给后续代码审计留下清晰的边界。5. 写在最后的实操心得这几年代码写下来我从这三个简单的字符串函数里得到的最大教训是底层函数越简单越要带着敬畏心去用。strcat看起来一行就能拼接字符串但它对内存的信任程度在当前这种安全要求越来越高的环境下已经过时了。我在新项目里基本不用裸的strcat而是用snprintf的指针移动法或者自己封装一个带长度上限的追加函数。strstr和strchr倒是仍然高频使用但每次拿到返回指针时我都会多问一句这个指针指向的内存是只读吗原字符串还会被修改吗返回值到底指向哪里最后再分享一个小技巧如果你写的代码需要在一个字符串里连续做多次查找每次都从strstr或strchr的返回值开始继续搜索一定要记得把上次找到的位置保存下来而不是每次都从字符串开头重新找。我在做协议解析时就经常用这样的写法char *p buffer; while ((p strstr(p, BEGIN:)) ! NULL) { // 处理这段内容 p strlen(BEGIN:); }这样一轮循环就可以把字符串中所有满足条件的片段都找出来避免漏掉重叠内容也让代码意图一目了然。字符串处理没有银弹但只要理解了底层的内存操作逻辑再把安全和边界问题放在第一位你就能在 C 语言里应对大多数真实场景。
返回列表