字符串处理模块:字符串拼接、分割、查找、替换的封装、安全字符串操作

字符串处理,说白了就是嵌入式开发里的家常便饭。我做了十几年嵌入式,几乎每个项目都离不开字符串操作。但说实话,这也是最容易出bug的地方——缓冲区溢出、内存泄漏、野指针,哪个不是让人头疼的问题?

今天咱们就来聊聊,怎么把字符串处理封装成一个靠谱的模块。我会把我在项目中踩过的坑、总结的经验,一股脑儿倒出来。

为什么需要封装字符串模块?

你想想看,一个嵌入式项目里,字符串拼接、分割、查找、替换这些操作,少说也得出现几十次。如果每次都是临时写一段代码,那后果是什么?

  • 代码重复,维护成本高
  • 容易忘记检查缓冲区大小
  • 不同人写的风格不统一
  • 出bug了要到处改

我早期的一个项目,就因为字符串拼接没检查长度,导致堆栈被破坏,设备在客户现场死机。那次教训太深刻了。从那以后,我就养成了封装字符串模块的习惯。

安全字符串操作的核心原则

嵌入式环境资源有限,我们不能像PC那样随便用malloc。安全字符串操作,说白了就三条原则:

  1. 明确目标缓冲区大小——每次操作都要知道能写多少字节
  2. 始终保留结尾'\0'——字符串必须以空字符结尾
  3. 返回实际写入长度——让调用者知道操作结果

核心思想:宁可截断,绝不溢出。这是嵌入式字符串处理的铁律。

字符串拼接的安全封装

标准库的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}替换成实际用户名。

模块的整体设计

下面这张图展示了字符串处理模块的整体架构:

字符串处理模块架构 公共接口层 (string_util.h) 安全拼接 安全分割 查找替换 格式化 核心实现层 (string_util.c) 安全机制 缓冲区边界检查 | 参数有效性验证 | 返回值状态码 底层依赖:标准C库 (string.h, stdio.h)

从图上可以看出,整个模块分为四层:

  • 接口层:定义公共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