news 2026/10/5 1:02:35

C语言字符串实战:母串删子串与按位置截取的实现与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C语言字符串实战:母串删子串与按位置截取的实现与避坑指南

字符串处理一直是我觉得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 删除子串和截取子串分别解决什么场景问题

这两个操作看似方向相反,但使用场景非常清晰。

删除子串的典型场景是清理字符串中的杂质。比如串口设备返回了一行配置:

"server=192.168.1.1;port=8080"

如果我只关心IP地址,就需要把";port=8080"从母串中抹掉,让剩余字符拼接成"server=192.168.1.1"。

截取子串的典型场景是提取关键字段。比如从一行日志数据:

"datetime=2024-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"的字节长度是3+3+1+1+1=9,可它只有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(n+m)的线性复杂度。

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 明确调用约定:start+len 还是 start+end

截取函数最难的不是怎么写,而是怎么约定参数。我看到过的接口风格五花八门:

  • 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指令响应的处理是高频场景。假设设备返回:

"AT+CREG=0\r\n+CREG: 0,1\r\nOK\r\n"

需要先删除开头的"AT+CREG=0\r\n",再把"+CREG: 0,1"这一段里的"0,1"提取出来。

int main(void) { char buf[128] = "AT+CREG=0\r\n+CREG: 0,1\r\nOK\r\n"; char field[32]; // 1. 删除指令回显部分 str_del_all(buf, "AT+CREG=0\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头文件里,并明确几条约定:

  • 所有函数都带源串长度或缓冲区大小参数,禁止裸奔。
  • 返回值统一用int,0表示成功,负数表示不同错误原因。
  • 发生错误时目标缓冲区要么保持原样,要么至少置空,绝不让调用方拿到半截脏数据。

接口大概长这样:

#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 -fsanitize=address -o demo demo.c ./demo

一旦代码里有越界读写,运行时会直接打印出错位置,比用printf一点点定位高效太多。我平时写字符串练习题也会顺手编译一版带-fsanitize=address的出来,跑一轮没问题才算真正通过。

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,但一定要把“起点、长度、结束符、缓冲区大小”这四个概念刻在脑子里。如果正在写作业或者小项目,建议不要直接抄上面的代码,而是先在纸上画出母串、子串和被删除后的空缺位置,再一行行自己写。等亲手踩过一两次坑,这套逻辑就真正变成你的了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 1:02:34

用数据说话!2026年性价比拉满的专业AI论文写作软件

2026年AI论文写作工具已从“基础生成”升级为智能协同研究系统&#xff0c;核心评价维度涵盖文献真实性、格式合规性、长文本逻辑、查重降重、AIGC合规等关键指标。本次测评覆盖6款主流工具&#xff0c;测试场景包括中文与英文论文、全流程与专项功能、免费与付费版本&#xff…

作者头像 李华
网站建设 2026/10/5 0:57:18

Claude Code next-steps 本地工作流完整部署指南

1. 这不是“安装插件”&#xff0c;而是重建本地AI编码工作流的起点最近在几个技术群和开源协作项目里&#xff0c;反复看到开发者问&#xff1a;“Claude Code 的 next-steps 插件到底怎么装&#xff1f;为什么 VS Code 里搜不到&#xff1f;”——这问题背后藏着一个被普遍忽…

作者头像 李华
网站建设 2026/10/5 0:54:11

从零搭建AI工程:提示词、RAG、上下文管理与生产落地全指南

做AI工程这些年&#xff0c;有个感触越来越深&#xff1a;会调API不等于会做AI工程。市面上铺天盖地的教程都在教你怎么调用模型接口、怎么跑通一个demo&#xff0c;但真正从零把一个AI项目做成产品级的东西&#xff0c;中间隔着大量没人讲的硬功夫——数据怎么管、提示词怎么迭…

作者头像 李华
网站建设 2026/10/5 0:35:00

SpringBoot+Vue医院后台管理系统:从数据库设计到部署答辩全攻略

有人问我&#xff0c;SpringBootVue做医院后台管理系统&#xff0c;到底选哪套方案最省心。我的看法很直接&#xff1a;如果你只想要一套能跑通、能答辩、能写论文的Java Web毕设&#xff0c;那么"SpringBootVueMySQLMyBatis-Plus"这套组合&#xff0c;加上完整的源码…

作者头像 李华
网站建设 2026/10/5 0:34:58

多波束成像声呐原理与Matlab仿真:从波束形成到点云

做水声装备这些年&#xff0c;我接触最多的需求就是“怎么把水下看明白”。侧扫声呐只能给你一幅声影图&#xff0c;看不出精确深度&#xff1b;单波束测深仪又一针一针地打&#xff0c;效率太低。直到多波束成像声呐出现&#xff0c;这个问题才算真正解决——它用一排换能器同…

作者头像 李华
网站建设 2026/10/5 0:34:45

离散数学quiz高分策略:定义驱动与结构化解题法

我不能为您生成或提供任何课程 quiz 的答案、解题捷径、作弊资源&#xff0c;或任何形式的学术不诚信内容。这不仅严重违反 Coursera 平台的《学术诚信政策》与北京大学的教学规范&#xff0c;更违背教育本质——离散数学作为计算机科学、人工智能、密码学、算法设计等领域的基…

作者头像 李华