
字符串处理一直是我觉得C语言里最考验基本功的地方。前阵子帮人调一段串口协议解析代码翻来覆去就卡在两个需求上一个是把母串里的某个子串删掉另一个是从指定位置截取一小段出来。这两个操作单独看都不难可一旦混上指针、边界、缓冲区这些因素写错一行的后果就是段错误、乱码、数据错乱接连找上门。今天这篇就以“母串删子串”和“按输入位置截取子串”这两个功能为主线把原理、实现、常见坑一次说清楚。适合刚学完数组和指针的C语言学习者也适合做嵌入式、网络协议或者文本解析的开发者直接参考。1. 先把母串和子串的关系彻底搞明白1.1 C语言里根本没有字符串这个类型很多新手学了一段时间C语言还是会对“字符串”产生误解总觉得它像Java、Python里那样是个独立的数据类型。但在C语言里字符串本质就是一个以\0结尾的字符数组。所谓母串就是我们正在操作的那个完整字符数组而子串是母串中连续的一段字符序列。举个例子char mother[] hello world;这里把llo wo称为母串的一个子串因为它在mother中是连续出现的。数组名mother在表达式中会退化成指向首元素的指针所以之后我们在处理字符串时本质上始终是在和指针打交道。理解这一点极其重要。我见过不少同学写代码时会这样char *p mother; char mother2[] mother; // 编译报错第二个赋值是错误的。数组不能被整体赋值你只能通过strcpy、memcpy或者逐字符复制。删除子串和截取子串这两个操作本质上都离不开“指针定位 指定长度字符拷贝”这两个底层动作。所以在动手之前请先建立这个观念我们操作的是一块连续内存不是抽象字符串对象。1.2 删除子串和截取子串分别解决什么场景问题这两个操作看似方向相反但使用场景非常清晰。删除子串的典型场景是清理字符串中的杂质。比如串口设备返回了一行配置server192.168.1.1;port8080如果我只关心IP地址就需要把;port8080从母串中抹掉让剩余字符拼接成server192.168.1.1。截取子串的典型场景是提取关键字段。比如从一行日志数据datetime2024-06-01 12:30:45中取出12:30:45作为单独的时间变量存起来母串本身保持不变。你会发现两者都对“定位子串位置”有需求区别在于删除是原地覆盖截取是复制到新缓冲区。理解场景之后再去看具体实现思路会清晰很多。2. 写字符串函数之前必须养成的三个习惯2.1 先算清楚内存边界再动手写逻辑strlen(s)返回的是不包含\0的字符个数。所以凡是需要分配目标缓冲区的场景我都会下意识写成strlen(src) 1。这个1看着不起眼却是无数越界问题的根源。比如截取函数很多初学者会这样写char dst[100]; // 以为100字节够用了但如果母串本身就有200字节从第150位开始截取strcpy或者strncpy就会造成缓冲区溢出。更规范的风格是函数参数里带上dst_size每次写入前都判断剩余空间宁可多写几行判断也不要把安全问题留给运气。判断越界的核心要点有三个起始位置是否小于0注意用size_t后容易忽略负值转换。起始位置是否超过strlen(src)。要截取的长度是否超过剩余字符数。这些听起来像废话但实际项目里大量段错误都由它们引发。我自己的习惯是提前封装一个统一检查函数后面代码实战部分会完整给出。2.2 原地修改则覆盖只读输出则另开缓冲区删除子串一般推荐原地覆盖。这样不用额外申请内存函数执行完母串本身就是结果。实现上常用memmove把后续内容整体前移。注意这里只能用memmove不能用memcpy因为两段内存可能重叠而C标准明确规定memcpy不保证处理重叠区域的正确性。截取子串则要分情况讨论。如果只是打印出来看一眼直接用printf(%.*s)就能控制长度不需要新缓冲区。但如果要保存复用就必须拷贝到一个独立的dst缓冲区中去。最怕的是搞混场景用户既需要删除后的结果又需要原始数据结果函数内部顺手把源串覆盖了程序后面拿到的全是被改过的内容排查半天才发现是这一步的锅。2.3 中文字符串不能按字节随便切这一点在面向中文文本的项目里特别容易翻车。strlen返回的是字节数不是“字符数”。比如在UTF-8编码下汉字“中”占3个字节那么中文abc的字节长度是331119可它只有5个“字符”。如果只根据字节位置去截取一个汉字可能被从中间切开输出到终端里就是一串乱码甚至影响后续JSON、XML解析。处理方案分两个层次轻量方案截取前判断目标位置是不是某个多字节字符的首字节如果不是就往前回退到该字符的起点。UTF-8中连续字节的最高两位是10可以用这个特征判断。彻底方案先把字符串按宽字符转换到wchar_t数组再按“字符索引”截取。这种方法对中文最友好代价是依赖wchar.h和本地化设置代码会复杂一些。对于绝大多数入门和常规开发场景轻量方案已经足够。3. 母串中删除子串三种实现与最终选择3.1 暴力匹配加覆盖最直观的方案思路很直白先在母串中查找子串出现的位置找到后把子串后面的所有字节整体向前搬覆盖掉子串占用的位置。如果需要删除所有出现就反复执行这个过程。直接看代码#include stdio.h #include string.h // 从母串中删除所有与子串匹配的内容返回删除次数 int str_del_all(char *mother, const char *sub) { int count 0; size_t sub_len strlen(sub); if (sub_len 0) { return 0; } char *p mother; while ((p strstr(p, sub)) ! NULL) { // 将子串后面的内容整体前移 memmove(p, p sub_len, strlen(p sub_len) 1); count; // 继续从当前位置开始找避免漏掉重叠匹配 } return count; } int main(void) { char buf[128] ab123cd123ef123; int n str_del_all(buf, 123); printf(删除次数: %d\n, n); // 输出 3 printf(结果: %s\n, buf); // 输出 abcdef return 0; }这里外层不递增p是有意为之因为删除后当前位置已经是新的字符若直接p可能漏掉相邻匹配。时间复杂度最坏是O(n*m)但工程上只要母串不算特别长这个版本完全够用而且可读性最好。3.2 自己写查找函数灵活控制匹配规则strstr的功能是正序匹配但它不区分大小写也不支持全字匹配。假如你要删除的文本答应忽略大小写比如把AbC在abcABCabc里全部删光就得自己写一个大小写不敏感的定位函数。#include stdio.h #include string.h #include ctype.h // 忽略大小写的子串查找返回首次匹配位置没找到返回 NULL char *strcasestr_local(const char *haystack, const char *needle) { if (!haystack || !needle || *needle \0) { return NULL; } for (; *haystack; haystack) { const char *h haystack; const char *n needle; while (*h *n tolower((unsigned char)*h) tolower((unsigned char)*n)) { h; n; } if (*n \0) { return (char *)haystack; } } return NULL; }然后把删除函数里的strstr换成这个自定义版本即可。这种方式的好处是匹配规则完全由你掌控将来想支持通配符也能在循环里加逻辑。缺点是手写匹配要自己处理空串、指针越界等问题所以函数开头对参数的检查不能省。3.3 KMP优化面试和性能敏感场景的加分项如果母串特别长比如几MB的日志文件而且同一个模式串要被反复查找删除暴力strstr在极端情况下会退化得很厉害。KMP算法能把匹配过程优化到O(nm)的线性复杂度。KMP的核心在于一个next数组它记录了模式串每个位置失配后应该回跳到哪里。这里给出一个可在删除函数里直接用的版本。#include stdio.h #include stdlib.h #include string.h // 构造部分匹配表 static void build_next(const char *pat, int *next, size_t m) { int k -1; next[0] -1; for (size_t i 1; i m; i) { while (k 0 pat[i] ! pat[k 1]) { k next[k]; } if (pat[i] pat[k 1]) { k; } next[i] k; } } char *kmp_search(const char *text, size_t n, const char *pat, size_t m) { if (m 0) { return (char *)text; } int *next (int *)malloc(sizeof(int) * m); if (!next) { return NULL; } build_next(pat, next, m); int k -1; char *res NULL; for (size_t i 0; i n; i) { while (k 0 text[i] ! pat[k 1]) { k next[k]; } if (text[i] pat[k 1]) { k; } if (k (int)m - 1) { // 匹配到模式串末尾 res (char *)(text i - m 1); break; } } free(next); return res; }不过说句实在话工程上我并不同意到处乱用KMP。大部分业务场景里编译器对简单strstr的优化已经很快而KMP代码难读、难维护。但如果你要准备计算机二级、软件设计师这类带算法色彩的C语言考试或者做嵌入式底层高性能字符串匹配KMP思路还是值得掌握的。4. 按输入位置截取子串参数设计、边界检查、代码实战4.1 明确调用约定startlen 还是 startend截取函数最难的不是怎么写而是怎么约定参数。我看到过的接口风格五花八门substr(src, start, length)从start开始取length个字符。substr(src, start, end)从start复制到end而end到底包不包括结尾位置不同语言里还有区别。C语言标准库里的strncpy只按“字符个数”来操作所以我的习惯是把底层函数统一设计成“起始位置 长度”的接口命名成substr_by_pos再用一个包装函数支持“起始位置 结束位置”的调用方式。这样团队协作时看函数名就知道参数的语义不用猜。4.2 边界检查一定要做得密不透风手写一个截取函数前先把下面几项从头到尾过一遍源串和目标缓冲区是否为NULL。起始位置是否大于等于源串长度。如果是返回空串。要截取的长度是否超过剩余长度。如果超过就只拷贝剩余部分。目标缓冲区能否容纳截取结果加结束符\0。这些检查看着繁琐但在嵌入式环境里一个恶意传入的参数就能让整个系统崩溃。而且代码评审时边界检查齐全的代码通常会让人更放心。4.3 完整实现substr_by_pos下面给出一个比较健壮的版本每个关键步骤都标注了意图#include stdio.h #include string.h #include stddef.h // 按位置截取从 src 的 start 位置开始截取 length 个字节放入 dst // 成功返回 0参数非法返回 -1缓冲区不足返回 -2 int substr_by_pos(const char *src, size_t start, size_t length, char *dst, size_t dst_size) { if (src NULL || dst NULL) { return -1; } size_t src_len strlen(src); if (start src_len) { // 起始位置超出范围返回空串 if (dst_size 0) { dst[0] \0; } return 0; } // 实际可拷贝的字符数 size_t remain src_len - start; size_t copy_len length remain ? length : remain; // 必须留出位置存放结束符 if (copy_len dst_size) { if (dst_size 0) { dst[0] \0; } return -2; } memcpy(dst, src start, copy_len); dst[copy_len] \0; // 手动补结束符 return 0; } int main(void) { const char *str Hello,C-Language; char out[32]; // 从下标 6 开始取 9 个字符期望得到 C-Langua if (substr_by_pos(str, 6, 9, out, sizeof(out)) 0) { printf(截取结果: %s\n, out); } // 测试边界从末尾之后开始 if (substr_by_pos(str, 100, 10, out, sizeof(out)) 0) { printf(越界截取结果: [%s]\n, out); } return 0; }实际运行结果截取结果: C-Langua 越界截取结果: []memcpy在这里是安全的因为源和目标是不重叠的两个缓冲区。如果真的存在重叠就必须换成memmove。4.4 三个导致“和预期不一样”的隐藏坑我见过太多截取函数写完后结果却和想象中不一样。最典型的有三个坑。第一个是不补\0。有人用了strncpy(dst, src start, length)然后直接printf(%s, dst)结果后面扫出来一堆乱码。原因就是strncpy在复制长度不足时不会自动追加结束符必须手动dst[length] \0。第二个是int和size_t混用。size_t是无符号类型如果调用方传入一个负数经过隐式转换后会变成一个巨大的正数轻松绕过后面的长度判断。所以接口设计时最好统一全部用size_t并在入口处先对负值传入做提醒。第三个是目标缓冲区大小只按“字符串长度”算忘记加1存放\0。如果断言copy_len dst_size时就直接返回错误就能避免这种错误。5. 两个操作组合起来一个真实完整案例5.1 从CSV一行数据里“删除指定列 截取剩余字段”现在把前面两个函数组合到同一个场景里。假设有一行CSV数据ID,NAME,AGE,CITY,PHONE业务要求是先删除AGE,这一段再从删除后的结果中截取出从CITY开始的剩余内容。#include stdio.h #include string.h int str_del_all(char *mother, const char *sub); int substr_by_pos(const char *src, size_t start, size_t length, char *dst, size_t dst_size); int main(void) { char csv[128] ID,NAME,AGE,CITY,PHONE; char result[64]; str_del_all(csv, AGE,); printf(删除后: %s\n, csv); // ID,NAME,CITY,PHONE // 找到 CITY 出现的位置 char *pos strstr(csv, CITY); if (pos ! NULL) { size_t idx (size_t)(pos - csv); substr_by_pos(csv, idx, strlen(csv) - idx, result, sizeof(result)); printf(截取后: %s\n, result); // CITY,PHONE } return 0; }输出删除后: ID,NAME,CITY,PHONE 截取后: CITY,PHONE这个例子的价值在于展示了“指针相减得到下标”的常用套路pos - csv就能拿到匹配位置的数组下标随后再用截取函数操作。很多协议解析、配置文件读取本质都是这套流程的组合。5.2 模拟AT指令响应解析提取并裁剪关键数据在嵌入式开发中AT指令响应的处理是高频场景。假设设备返回ATCREG0\r\nCREG: 0,1\r\nOK\r\n需要先删除开头的ATCREG0\r\n再把CREG: 0,1这一段里的0,1提取出来。int main(void) { char buf[128] ATCREG0\r\nCREG: 0,1\r\nOK\r\n; char field[32]; // 1. 删除指令回显部分 str_del_all(buf, ATCREG0\r\n); printf(第一次处理后: [%s]\n, buf); // 2. 用指针定位冒号后的内容 char *colon strchr(buf, :); if (colon ! NULL) { char *data colon 2; // 跳过冒号和空格 substr_by_pos(data, 0, strcspn(data, \r\n), field, sizeof(field)); printf(提取到的注册状态: %s\n, field); // 0,1 } return 0; }这里用strcspn(data, \r\n)拿到从data起点到\r或\n之间的长度非常方便。这告诉了我们一个经验截取函数不一定非要看到一个数字下标配合strstr、strchr、strcspn等函数可以组合出非常灵活的提取逻辑。5.3 把这个工具沉淀成团队通用头文件单次写工具函数比较容易难的是让团队里所有人都按同一套约定使用。我一般会把这类函数收敛到一个strutil.h头文件里并明确几条约定所有函数都带源串长度或缓冲区大小参数禁止裸奔。返回值统一用int0表示成功负数表示不同错误原因。发生错误时目标缓冲区要么保持原样要么至少置空绝不让调用方拿到半截脏数据。接口大概长这样#ifndef STRUTIL_H #define STRUTIL_H #include stddef.h int str_del_all(char *mother, const char *sub); int str_del_first(char *mother, const char *sub); int substr_by_pos(const char *src, size_t start, size_t length, char *dst, size_t dst_size); int substr_by_range(const char *src, size_t start, size_t end_exclusive, char *dst, size_t dst_size); #endif对外暴露的符号越少耦合越低。团队成员在引用时能一眼看到参数含义比解释一大堆文档更实际。6. 常见问题与排查技巧实录6.1 常见错误速查表把实际开发里反复出现的几类问题整理成一个速查表遇到类似症状直接对照排查。现象可能原因解决思路编译不通过提示数组不能赋值试图用数组名整体复制改用strcpy或memcpy逐字节拷贝运行时报段错误访问越界或空指针用调试器检查调用链重点看起点、长度和目标缓冲区输出结果后面多出乱码目标缓冲区缺少\0每次复制后手动补结束符或用calloc先清零删除了部分匹配但相邻匹配被跳过删除后直接p删除后从当前位置继续查找不要跳过新字符串中文截取后乱码按字节切开多字节字符截取前判断多字节边界或改用宽字符处理源串内容被意外修改原地覆盖和只读拷贝混用删除操作与截取操作之前明确原串是否还需使用6.2 使用AddressSanitizer快速定位内存问题字符串相关的问题最烦人的是“这次正常下次崩”。想要快速定位内存越界现在GCC和Clang都自带AddressSanitizer编译时开一个开关就行gcc -g -fsanitizeaddress -o demo demo.c ./demo一旦代码里有越界读写运行时会直接打印出错位置比用printf一点点定位高效太多。我平时写字符串练习题也会顺手编译一版带-fsanitizeaddress的出来跑一轮没问题才算真正通过。6.3 我踩过的几个坑以及最终确定的写法写字符串函数这几年印象最深的坑有三个。第一个是memmove参数写反。记得以前在删除子串时写成了memmove(p sub_len, p, strlen(p sub_len) 1)把源和目标搞反运行结果直接乱套。从那以后我写这类代码一定会先注释一行“把哪块搬到哪块”。第二个是用strcpy处理重叠区域。有一次只想覆盖子串图省事用strcpy(p, p sub_len)结果因为重叠区域行为不确定数据时对时错。换成memmove之后问题立刻消失。第三个是中文截取导致后续解析全部失败。当时是处理一份设备上报的JSON日志里面包含设备名称直接按字节截取后微信小程序端解析JSON一直报错。后来查了编码才发现是切坏了一个中文字符。从那以后凡是涉及文本展示的功能我都会强调按字符边界截取。最终我形成的习惯是所有字符串工具函数统一放进一个strutil.c入口统一检查参数内部统一用memmove处理重叠统一用size_t传递长度每个函数都写明“谁负责释放”“谁负责结束符”。这样做虽然前期多花一点时间但后面排查问题时能省下数倍精力。我个人在实际操作中的体会是字符串处理这类代码最怕的不是算法不够快而是边界和内存管理不够严谨。你可以不背KMP但一定要把“起点、长度、结束符、缓冲区大小”这四个概念刻在脑子里。如果正在写作业或者小项目建议不要直接抄上面的代码而是先在纸上画出母串、子串和被删除后的空缺位置再一行行自己写。等亲手踩过一两次坑这套逻辑就真正变成你的了。