第6章:字符串分割——strtok 的用法、线程安全问题与手写分割函数

字符串分割,说白了就是把一个长串按某个分隔符拆成若干小段。比如把 "hello,world,foo" 按逗号拆成 "hello""world""foo"。这需求太常见了——解析配置文件、处理 CSV 数据、拆分命令行参数……几乎每个 C 程序员都会遇到。

标准库给了我们一个现成的函数:strtok。但你真的会用吗?它有什么坑?线程安全怎么解决?如果让你自己实现一个分割函数,你会怎么写?

这一章,我就把这些事掰开了讲清楚。

6.1 strtok 的基本用法

strtok 的原型长这样:

char *strtok(char *str, const char *delim);

第一次调用时,传入待分割的字符串和分隔符集合。之后每次调用传入 NULL,它会返回下一个子串。直到返回 NULL,表示分割完毕。

看个例子:

#include <stdio.h>
#include <string.h>

int main() {
    char str[] = "apple,banana,cherry,date";
    char *token = strtok(str, ",");
    
    while (token != NULL) {
        printf("%s\n", token);
        token = strtok(NULL, ",");
    }
    return 0;
}

输出:

apple
banana
cherry
date

很简单对吧?但这里有个关键点:strtok 会修改原字符串。它把找到的分隔符替换成 '\0',然后返回当前子串的指针。所以如果你需要保留原字符串,记得先拷贝一份。

⚠️ 重要提醒: 传入的字符串必须是可修改的字符数组,不能是字符串常量。比如 char *str = "hello,world"; 这种写法会导致段错误。我当年刚学 C 时就被这个坑过,调试了半天才发现问题。

6.2 strtok 的线程安全问题

为什么说 strtok 不是线程安全的?因为它内部维护了一个静态指针,用来记录当前分割的位置。当多个线程同时调用 strtok 时,这个静态指针会被乱改,结果就是数据错乱。

举个例子:线程 A 正在分割 "a,b,c",线程 B 突然开始分割 "x,y,z"。线程 A 的静态指针被线程 B 覆盖了,接下来线程 A 拿到的子串就全乱了。

标准库提供了线程安全版本:strtok_r(POSIX 标准)和 strtok_s(C11 标准)。它们多了一个 saveptr 参数,用来保存上下文状态。

strtok_r 的原型:

char *strtok_r(char *str, const char *delim, char **saveptr);

用法:

#include <stdio.h>
#include <string.h>

int main() {
    char str[] = "apple,banana,cherry";
    char *saveptr;
    char *token = strtok_r(str, ",", &saveptr);
    
    while (token != NULL) {
        printf("%s\n", token);
        token = strtok_r(NULL, ",", &saveptr);
    }
    return 0;
}

每个线程传入自己的 saveptr,互不干扰。这样就能安全地在多线程环境中使用了。

💡 我的建议: 即使你只在单线程中写代码,也尽量养成用 strtok_r 的习惯。因为代码以后可能会被复用,提前考虑线程安全总没错。

6.3 手写一个字符串分割函数

有时候标准库的函数不够灵活。比如你想支持多个连续分隔符(像 "a,,b,c" 中的空字段),或者你想把分割结果存到数组中。这时候自己写一个更靠谱。

下面是我自己常用的一个版本:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>

// 分割字符串,返回子串数组,count 保存子串个数
// 调用者需要 free 返回的数组和每个子串
char **split(const char *str, const char *delim, int *count) {
    if (str == NULL || delim == NULL || count == NULL) {
        return NULL;
    }

    // 先拷贝一份,因为我们要修改它
    char *copy = strdup(str);
    if (copy == NULL) {
        return NULL;
    }

    // 第一遍遍历,统计子串个数
    int tokens = 0;
    char *saveptr;
    char *token = strtok_r(copy, delim, &saveptr);
    while (token != NULL) {
        tokens++;
        token = strtok_r(NULL, delim, &saveptr);
    }

    // 分配指针数组
    char **result = (char **)malloc((tokens + 1) * sizeof(char *));
    if (result == NULL) {
        free(copy);
        return NULL;
    }

    // 第二遍遍历,复制每个子串
    int index = 0;
    // 注意:strtok_r 会修改 copy,所以需要重新拷贝
    free(copy);
    copy = strdup(str);
    if (copy == NULL) {
        free(result);
        return NULL;
    }

    token = strtok_r(copy, delim, &saveptr);
    while (token != NULL) {
        result[index] = strdup(token);
        if (result[index] == NULL) {
            // 释放已分配的内存
            for (int i = 0; i < index; i++) {
                free(result[i]);
            }
            free(result);
            free(copy);
            return NULL;
        }
        index++;
        token = strtok_r(NULL, delim, &saveptr);
    }

    result[index] = NULL;  // 以 NULL 结尾
    *count = tokens;
    free(copy);
    return result;
}

// 释放分割结果
void free_split(char **arr, int count) {
    if (arr == NULL) return;
    for (int i = 0; i < count; i++) {
        free(arr[i]);
    }
    free(arr);
}

int main() {
    char str[] = "apple,banana,,cherry,date";
    int count;
    char **tokens = split(str, ",", &count);
    
    if (tokens != NULL) {
        for (int i = 0; i < count; i++) {
            printf("token[%d] = '%s'\n", i, tokens[i]);
        }
        free_split(tokens, count);
    }
    return 0;
}

这个函数有几个特点:

  • 支持连续分隔符(空字段会被当作一个空字符串)
  • 返回动态分配的数组,调用者负责释放
  • 内部使用 strtok_r,线程安全
  • 做了完善的内存错误处理
🔑 关键设计思路: 先遍历一次统计个数,再遍历一次填充数据。这样只需要一次内存分配,效率更高。如果你对性能有极致要求,可以改成动态扩容的方式。

6.4 避坑指南

我在项目中用字符串分割踩过不少坑,这里分享几个最常见的:

  1. 原字符串被修改strtok 会修改原字符串,如果你需要保留原始数据,一定要先 strdup 拷贝一份。
  2. 空字符串处理strtok 会跳过连续的分隔符,不会返回空字符串。如果你需要保留空字段,得自己实现。
  3. 内存泄漏:手写分割函数时,记得在出错或结束时释放所有分配的内存。我建议写一个配套的 free_split 函数。
  4. 分隔符集合strtok 的第二个参数是一个字符集合,不是字符串。比如 ",;" 表示逗号或分号都可以作为分隔符。
  5. 线程安全:多线程环境下务必使用 strtok_rstrtok_s
⚠️ 我曾经踩过的坑: 有一次在嵌入式项目中用 strtok 解析 GPS 数据,因为中断服务函数和主循环同时调用了 strtok,导致数据解析完全错乱。后来换成 strtok_r 才解决。从那以后,我写任何代码都默认用线程安全版本。

6.5 知识体系图

下面这张图总结了本章的核心知识点和它们之间的关系:

字符串分割知识体系 字符串分割 标准库 strtok 线程安全问题 手写分割函数 修改原字符串 跳过连续分隔符 静态指针冲突 strtok_r / strtok_s 支持空字段 内存管理 选择建议 简单场景 单线程、不关心空字段 → 用 strtok 多线程场景 多线程、需要线程安全 → 用 strtok_r 复杂场景 需要空字段、自定义逻辑 → 手写分割函数

6.6 总结

字符串分割看似简单,但细节不少。我个人的经验是:

  • 如果只是临时用一下,strtok 够用,但记得用 strtok_r 替代。
  • 如果需要保留空字段或自定义行为,自己写一个也不难。
  • 内存管理一定要小心,谁分配谁释放,这是 C 语言的基本修养。

嗯,这一章就到这里。字符串分割是基础中的基础,但基础打牢了,后面遇到复杂解析任务才不会慌。


公众号:蓝海资料掘金营,微信 deep3321