字符串处理模块:字符串拼接、分割、查找、替换的封装、安全字符串操作
字符串处理,说白了就是嵌入式开发里的家常便饭。我做了十几年嵌入式,几乎每个项目都离不开字符串操作。但说实话,这也是最容易出bug的地方——缓冲区溢出、内存泄漏、野指针,哪个不是让人头疼的问题?
今天咱们就来聊聊,怎么把字符串处理封装成一个靠谱的模块。我会把我在项目中踩过的坑、总结的经验,一股脑儿倒出来。
为什么需要封装字符串模块?
你想想看,一个嵌入式项目里,字符串拼接、分割、查找、替换这些操作,少说也得出现几十次。如果每次都是临时写一段代码,那后果是什么?
- 代码重复,维护成本高
- 容易忘记检查缓冲区大小
- 不同人写的风格不统一
- 出bug了要到处改
我早期的一个项目,就因为字符串拼接没检查长度,导致堆栈被破坏,设备在客户现场死机。那次教训太深刻了。从那以后,我就养成了封装字符串模块的习惯。
安全字符串操作的核心原则
嵌入式环境资源有限,我们不能像PC那样随便用malloc。安全字符串操作,说白了就三条原则:
- 明确目标缓冲区大小——每次操作都要知道能写多少字节
- 始终保留结尾'\0'——字符串必须以空字符结尾
- 返回实际写入长度——让调用者知道操作结果
核心思想:宁可截断,绝不溢出。这是嵌入式字符串处理的铁律。
字符串拼接的安全封装
标准库的strcat不安全,因为它不检查目标缓冲区大小。我一般这样封装:
/**
* 安全字符串拼接
* @param dest 目标缓冲区
* @param dest_size 目标缓冲区总大小
* @param src 源字符串
* @return 拼接后的字符串长度(不含'\0')
*/
size_t safe_strcat(char *dest, size_t dest_size, const char *src)
{
size_t dest_len = 0;
size_t src_len = 0;
size_t copy_len = 0;
if (dest == NULL || src == NULL || dest_size == 0) {
return 0; // 参数错误,直接返回
}
// 计算目标字符串当前长度
while (dest_len < dest_size && dest[dest_len] != '\0') {
dest_len++;
}
// 如果目标已满,直接返回
if (dest_len >= dest_size - 1) {
return dest_len;
}
// 计算源字符串长度
src_len = strlen(src);
// 计算实际可拷贝长度(留一个字节给'\0')
copy_len = (src_len < dest_size - dest_len - 1) ?
src_len : (dest_size - dest_len - 1);
// 拷贝字符串
memcpy(dest + dest_len, src, copy_len);
dest[dest_len + copy_len] = '\0';
return dest_len + copy_len;
}
这段代码我用了好多年,基本没出过问题。注意看几个关键点:
- 先检查参数有效性
- 计算目标字符串当前长度
- 计算可拷贝长度时,留一个字节给结尾'\0'
- 返回实际拼接后的长度
小技巧:我习惯把dest_size参数放在dest后面,这样和memcpy、strncpy的参数顺序一致,不容易搞混。
字符串分割的封装
字符串分割在解析协议、配置文件时特别常用。标准库的strtok有个问题——它会修改原字符串,而且不是线程安全的。
我一般这样封装:
/**
* 安全字符串分割(不修改原字符串)
* @param input 输入字符串
* @param delimiter 分隔符
* @param tokens 存放分割结果的指针数组
* @param max_tokens 最大分割数
* @return 实际分割出的token数量
*/
int safe_split(const char *input, char delimiter,
char *tokens[], int max_tokens)
{
int count = 0;
const char *start = input;
const char *end = NULL;
if (input == NULL || tokens == NULL || max_tokens <= 0) {
return 0;
}
while (*start != '\0' && count < max_tokens) {
// 跳过连续的分隔符
while (*start == delimiter) {
start++;
}
if (*start == '\0') break;
// 找到当前token的结束位置
end = start;
while (*end != '\0' && *end != delimiter) {
end++;
}
// 这里假设调用者已经为每个token分配了足够的内存
// 实际项目中,我会配合safe_strcpy使用
size_t len = end - start;
memcpy(tokens[count], start, len);
tokens[count][len] = '\0';
count++;
start = end;
}
return count;
}
这个实现有几个好处:
- 不修改原字符串,可以重复使用
- 支持连续分隔符(比如"a,,b"也能正确分割)
- 调用者控制内存分配,避免隐藏的malloc
注意:这个函数假设调用者已经为tokens数组中的每个指针分配了足够的内存。我见过有人忘了分配内存,结果memcpy写到了非法地址。嗯,这个坑我也踩过。
字符串查找与替换
查找和替换是字符串处理的高频操作。标准库的strstr可以查找,但替换需要自己实现。
我封装了一个安全的替换函数:
/**
* 安全字符串替换(只替换第一个匹配项)
* @param dest 输出缓冲区
* @param dest_size 输出缓冲区大小
* @param src 源字符串
* @param old_str 要替换的子串
* @param new_str 替换成的新子串
* @return 替换后的字符串长度,-1表示失败
*/
int safe_replace(char *dest, size_t dest_size,
const char *src, const char *old_str,
const char *new_str)
{
const char *pos = NULL;
size_t src_len, old_len, new_len;
size_t prefix_len, total_len;
if (dest == NULL || src == NULL ||
old_str == NULL || new_str == NULL) {
return -1;
}
// 查找第一个匹配位置
pos = strstr(src, old_str);
if (pos == NULL) {
// 没有找到,直接拷贝原字符串
return safe_strcpy(dest, dest_size, src);
}
src_len = strlen(src);
old_len = strlen(old_str);
new_len = strlen(new_str);
// 计算前缀长度(匹配位置之前的字符数)
prefix_len = pos - src;
// 计算替换后的总长度
total_len = prefix_len + new_len + (src_len - prefix_len - old_len);
// 检查缓冲区是否够用
if (total_len >= dest_size) {
return -1; // 缓冲区太小
}
// 拷贝前缀
memcpy(dest, src, prefix_len);
// 拷贝新字符串
memcpy(dest + prefix_len, new_str, new_len);
// 拷贝后缀
memcpy(dest + prefix_len + new_len,
pos + old_len,
src_len - prefix_len - old_len);
// 添加结尾'\0'
dest[total_len] = '\0';
return (int)total_len;
}
这个函数只替换第一个匹配项。如果需要替换所有匹配项,可以循环调用。我在项目中就遇到过需要替换所有占位符的场景,比如把"Hello {name}"中的{name}替换成实际用户名。
模块的整体设计
下面这张图展示了字符串处理模块的整体架构:
从图上可以看出,整个模块分为四层:
- 接口层:定义公共API,隐藏实现细节
- 功能模块:按功能划分,每个模块独立实现
- 核心实现:具体的算法和逻辑
- 安全机制:贯穿所有操作的边界检查
避坑指南
我在项目中积累了一些经验,分享给大家:
我曾经犯过的错:
- 用sizeof(指针)代替sizeof(数组)——指针的大小是4或8字节,不是缓冲区大小
- 忘记给结尾'\0'留位置——导致字符串没有正确终止
- 在中断服务函数里用strtok——破坏了全局状态,导致主循环解析出错
这些坑说起来简单,但调试起来真要命。尤其是缓冲区溢出,有时候不会立即崩溃,而是在某个角落悄悄破坏数据,等到你发现时,已经很难定位了。
测试策略
字符串模块的测试,我一般覆盖这些场景:
| 测试场景 | 测试用例 | 预期结果 |
|---|---|---|
| 正常拼接 | "Hello" + " World" | "Hello World" |
| 缓冲区刚好够 | 目标大小=12,"Hello" + " World" | "Hello World" (11字符+'\0') |
| 缓冲区不够 | 目标大小=8,"Hello" + " World" | "Hello W" (截断,保留'\0') |
| 空字符串 | "" + "test" | "test" |
| NULL指针 | NULL + "test" | 返回0,不崩溃 |
这些测试用例,说白了就是覆盖正常、边界、异常三种情况。我每次修改模块代码后,都会跑一遍这些测试,确保没有引入新bug。
总结
字符串处理模块,说难不难,说简单也不简单。关键在于:
- 把安全放在第一位,缓冲区检查不能省
- 接口设计要清晰,让调用者不容易用错
- 测试要全面,边界条件尤其重要
我见过太多项目,字符串操作写得乱七八糟,最后维护成本高得吓人。花点时间封装一个靠谱的模块,绝对是值得的。毕竟,代码写出来是给人看的,顺便让机器执行一下。
公众号:蓝海资料掘金营,微信deep3321