第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 避坑指南
我在项目中用字符串分割踩过不少坑,这里分享几个最常见的:
- 原字符串被修改:
strtok会修改原字符串,如果你需要保留原始数据,一定要先strdup拷贝一份。 - 空字符串处理:
strtok会跳过连续的分隔符,不会返回空字符串。如果你需要保留空字段,得自己实现。 - 内存泄漏:手写分割函数时,记得在出错或结束时释放所有分配的内存。我建议写一个配套的
free_split函数。 - 分隔符集合:
strtok的第二个参数是一个字符集合,不是字符串。比如",;"表示逗号或分号都可以作为分隔符。 - 线程安全:多线程环境下务必使用
strtok_r或strtok_s。
strtok 解析 GPS 数据,因为中断服务函数和主循环同时调用了 strtok,导致数据解析完全错乱。后来换成 strtok_r 才解决。从那以后,我写任何代码都默认用线程安全版本。
6.5 知识体系图
下面这张图总结了本章的核心知识点和它们之间的关系:
6.6 总结
字符串分割看似简单,但细节不少。我个人的经验是:
- 如果只是临时用一下,
strtok够用,但记得用strtok_r替代。 - 如果需要保留空字段或自定义行为,自己写一个也不难。
- 内存管理一定要小心,谁分配谁释放,这是 C 语言的基本修养。
嗯,这一章就到这里。字符串分割是基础中的基础,但基础打牢了,后面遇到复杂解析任务才不会慌。