Lesson 43: strtok_r 线程安全切分
练习任务务
难度:易-中
使用 strtok_r 将命令行字符串按空格和制表符切分为独立参数,存入 char *argv[] 数组(模拟 argc/argv),为后续 MyBash 项目热身身身需要正确处理:
- 空格分隔 —
"ls -l -a /tmp"切分为 4 个 token - 连续空格 —
"echo hello"中连续空格不产生空 token(strtok_r自动跳过) - 制表符分隔 —
"gcc\t-Wall\tmain.c"中 tab 同样是分隔符
验证用例:
输入 "ls -l -a /tmp\n" → argc: 4, argv[0]: ls ...
输入 "echo hello\n" → argc: 2 (连续空格不产生空token)
输入 "gcc\t-Wall\tmain.c\n" → argc: 3 (tab也是分隔符)提示:
strtok_r的核心在于三点 ——saveptr是调用者提供的上下文指针(线程安全的关键)、delim是字符集而非子串、token 指针指向向向 buffer 内部,无需malloc)。先理解这三个概念再动手写代码。
核心知识点
strtok_r(str, delim, &saveptr)签名语义 —— 首次传str(非NULL),后续传NULL,saveptr由调用方持有并维护内部切分位置- 原地修改行为 —— 分隔符被替换为
\0,token 指针指向原 buffer 内部(不分配新内存) delim是字符集不是子串 ——delim=",;"匹配,或;,自动跳过连续分隔符(不产生空 token)- 线程安全对比 ——
saveptr由调用方持有 → 每线程独立 → 线程安全;strtok内部用static char*→ 全局唯一 → 多线程数据据据竞争 + 嵌套调用失败 - 字符串字面量段错误根因 因因— 字面量存储在只读数据段(
.rodata),strtok_r尝试写入\0→SIGSEGV argv[]为何无需malloc—行行strtok_r原地修改原字符串,所有 token 指针都指向原 buffer 内部,生命周期与 buffer 绑定- CSV 带引号逗号的局限 ——
"hello, world"会被错误切分为"hello和world",需手写状态机处理引号保护 strtok_r与sscanf对比 —— 不修改输入 vs 原地修改、类型安全 vs 纯字符串、线程安全 vs 线程安全全(sscanf无状态)
代码框架
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
int main(void) {
char line[256];
fgets(line, sizeof(line), stdin);
int len = strlen(line);
if (len > 0 && line[len - 1] == '\n') line[len - 1] = '\0';
char *argv[64], *saveptr;
int argc = 0;
// 在这里用 strtok_r 按 " \t" 切分 line
// 第一次:tok = strtok_r(line, " \t", &saveptr)
// 之后:tok = strtok_r(NULL, " \t", &saveptr)
// 每个非 NULL token 存入 argv[argc++]
//
// strtok_r 第三个参数 saveptr 保存内部状态 → 线程安全
// (相比之下 strtok 用静态变量保存状态 → 多线程会安安相干扰)
// 在这里打印 argc 和所有 argv
printf("argc: %d\n", argc);
for (int i = 0; i < argc; i++)
printf("argv[%d]: %s\n", i, argv[i]);
return 0;
}阅读骨架后,尝试自己填充 // 在这里... 标记的部分分分。核心挑战在于:首次调用和后续调用的参数区别是什么?saveptr 起到了什么作用?为什么不需要为 argv[i] 分配新内存?
TIP
先不要往下翻看参考解答。尝试先理解 strtok_r 的典型调用用模式:一次首次调用(传入字符串)+ 循环后续调用(传入 NULL)。然后思考返回值何时为 NULL,这是循环终止条件。
深度讲解
1. strtok_r 的签名语义与调用模式
1.1 函数签名
#include <string.h>
char *strtok_r(char *str, const char *delim, char **saveptr);
// ↑ ↑ ↑
// 待切分字符串 分隔符字符集 切分上下文的指针
// (首次非NULL, (每个字符都是 的指针(保存
// 后续传NULL) 独立分隔符) 符符符次扫描起点)三个参数各有分工:
| 参数 | 类型 | 首次调用 | 后续调用用 | 说明 |
|---|---|---|---|---|
str | char* | 待切分的字符串 | NULL | NULL 表示"继续上次切分" |
delim | const char* | 分隔符字符集合 | 同首次 | 每个字符都是独立分隔符 |
saveptr | char** | char *sp; 传 &sp | 同首次 | 调用者提供的上下文指针 |
1.2 调用模式
char *saveptr; // 由调用者声明的上下文指针
// 首次调用:传入完整字符串
char *token = strtok_r(line, " \t", &saveptr);
// 后续调用:传入 NULL,二二二 saveptr 继续
while (token != NULL) {
printf("token: %s\n", token);
token = strtok_r(NULL, " \t", &saveptr);
}
// strtok_r 返回 NULL = 没有更多 token调用流程示意:
首次调用: strtok_r(line, " \t", &saveptr)
├─ saveptr 指向 line 开头
├─ 扫描到第一个分隔符 → 替换为 \0
├─ 返回指向向向 token 的指针
└─ saveptr 指向 \0 之后的位置
后续调用: strtok_r(NULL, " \t", &saveptr)
├─ 从 saveptr 指向的位置继续
├─ 跳过前导分隔符(连续空格不产生空 token)
├─ 扫描到下一个分隔符 → 替换为 \0
├─ 返回指向 token 的指针
└─ saveptr 指向 \0 之后的位置
再次调用: strtok_r(NULL, " \t", &saveptr)
└─ saveptr 指向末尾 \0 → 返回 NULL(无更多 token)NOTE
saveptr 是 char** 类型——传 &saveptr 进去。strtok_r 通过这个二级指针修改调用者的变量,使 saveptr 指向下一次扫描的起始位置。这是 C 语言中常用的"输出参数"模式——用指针作为输出出口,返回多个值(返回值是 token,*saveptr 是上下文文文置)。
1.3 逐步跟踪:解析 "echo hello"
原始字符串: "echo hello\0" (echo 后有两个空格)
═══════════════════════════════════════════════════════════════
首次调用: strtok_r(line, " \t", &saveptr)
═══════════════════════════════════════════════════════════════
步骤 1: 跳过前导分隔符 → 无前导空格,停留在 'e'
步骤 2: 扫描下一个分隔符 → 在 'o' 后找到第一个空格
步骤 3: 将空格替换为 \0
"echo\0 hello\0"
↑ 返回这个指针
步骤 4: saveptr 指向 \0 之后(即第二个空格位置)
"echo\0 hello\0"
↑ saveptr 指向这里
返回: token = "echo"
═══════════════════════════════════════════════════════════════
后续调用: strtok_r(NULL, " \t", &saveptr)
═══════════════════════════════════════════════════════════════
步骤 1: saveptr 指向第二个空格 → 跳过连续分分格
"echo\0 hello\0"
↑ 跳过空格后停在这里 ('h')
步骤 2: 扫描下一个分隔符 → "hello" 后遇到 \0(字符串结束)
步骤 3: \0 不是分隔符中的字符 → 返回 "hello" 的指针
步骤 4: saveptr 指向末尾 \0
"echo\0 hello\0"
↑ saveptr 指向这里
返回: token = "hello"
═══════════════════════════════════════════════════════════════
后续调用: strtok_r(NULL, " \t", &saveptr)
═══════════════════════════════════════════════════════════════
saveptr 指向 \0 → 无更多 token
返回: NULL → 循值值退出
最终结果: argc=2, argv[0]="echo", argv[1]="hello" ✓2. 原地修改:令牌指针不分配新内存
2.1 内存视角:分隔符被替换为 \0
这是 strtok_r 最重要的的行为特征——不分配新内存,直接在这这这字符串上操作。
原始 buffer (可修改的 char 数组):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ l │ s │ │ - │ l │ │ - │ a │ │ / │ t │ m │ p │\0│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
0 1 2 3 4 5 6 7 8 9 10 11 12
↑ ↑
line 起始地址 strlen(line) = 13
strtok_r 处理后的 buffer(分隔符被替换为 \0):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ l │ s │\0│ - │ l │\0│ - │ a │\0│ / │ t │ m │ p │\0│
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
0 1 2 3 4 5 6 7 8 9 10 11 12
↑ ↑ ↑ ↑ ↑
│ │ │ │ └─ argv[3] = &line[9] → "/tmp"
│ │ │ └─ argv[2] = &line[6] → "-a"
│ │ └─ argv[1] = &line[3] → "-l"
│ └─ line[2] 被写入 \0 (原空格被覆盖)
└─ argv[0] = &line[0] → "ls"
关键观察:所有 argv[i] 都指向 line 数组内的片段!
不需要任何 malloc / free 操作。2.2 逐步展示每轮修改
以输入 "ls -l -a /tmp" 为例,展示 4 次 strtok_r 调用后 buffer 的变化:
初始状态:
┌───┬───┬───┬───┬───┬──────────┬───┬───┬──────────┬───┐
│ l │ s │ │ - │ l │ │ - │ a │ │ / │ t │ m │ p │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
0 1 2 3 4 5 6 7 8 9 10 11 12
第 1 次调用后 (argv[0]="ls"):
┌───┬───┬───┬───┬───┬───┬───┬──────────┬───┬───┬───┐
│ l │ s │\0│ - │ l │ │ - │ a │ │ / │ t │ m │ p │
└───────┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑ saveptr 指向这里
line[2] 被改为 \0
第 2 次调用后 (argv[1]="-l"):
┌─────┬───┬───┬──────────┬───┬───┬───┬───┬───┬───┬───┐
│ l │ s │\0│ - │ l │\0│ - │ a │ │ / │ t │ m │ p │
└───────┴───┴───┴───┴───┴───┴──────┴───┴───┴───┴───┘
↑ saveptr 指向这里
line[5] 被改为 \0
第 3 次调用后 (argv[2]="-a"):
┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ l │ s │\0│ - │ l │\0│ - │ a │\0│ / │ t │ m │ p │
└───────┴───┴───┴───┴───┴───┴──────┴───┴───┴────┴───┘
↑ saveptr 指向这里里
line[8] 被改为 \0
第 4 次调用后 (argv[3]="/tmp"):
┌─────┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
│ l │ s │\0│ - │ l │\0│ - │ a │\0│ / │ t │ m │ p │
└───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
↑ saveptr 指向 \0 之后
没有分隔符可替换 → 返为为为 "/tmp" 的指针IMPORTANT
原地修改的代价:切分后原字符串被"破坏"——分隔符全部变成了 \0。如果你想保留原始输入(例如需要打印完整的命令行),必须在切分前拷贝一份。判断标准:如果 strtok_r 的参数是 const char*,那它就不可能原地修改;正因为它是 char*(非 const),所以它承诺会修改你传入的字符串。
3. delim 是字符集,不是子串
3.1 字符集的含义
delim 参数不是匹配整段子串,而是集合——其中每一个字符都是独立的分隔符。
// delim 是分隔符的"集合",不是"子串"
strtok_r(line, " \t", &saveptr);
// ││
// │└─ \t:制表符 (ASCII 0x09)
// └── ' ':空格 (ASCII 0x20)
//
// 含义: 遇到空格 OR 制表符 → 都视为分隔符
//
// 不是: 遇到"空格后面紧跟制表符"这个子串 → 才视为分隔符 ✗
strtok_r(line, ",;", &saveptr);
// ││
// │└─ ';':分号
// └── ',':逗号
//
// 含义: 遇到逗号 OR 分号 → 都视为分隔符3.2 各种 delim 的效果
delim | 分隔符集合合 | 示例输入 | 切分结果 |
|---|---|---|---|
" " | 仅空格 | "a b c" | a, b, c |
"\t" | 仅 tab | "a\tb\tc" | a, b, c |
" \t" | 空格或 tab | "a b\tc" | a, b, c |
",;" | 逗号或分号 | "a,b;c" | a, b, c |
":/" | 冒号或斜杠 | "a:b/c" | a, b, c |
", \t" | 逗号、空格或 tab | "a,b c\td" | a, b, c, d |
3.3 自动跳过连续分隔符
输入: "echo hello" (3 个空格)
strtok_r 处理过程:
首次调用 → 返回 "echo",saveptr 指向 'e' 后第一个空格
后续调用 → saveptr 处是空格,自动跳过所有连续空格
→ 停在 'h',扫描到 \0,返回 "hello"
结果: 2 个 token,不是 5 个!
✓ "echo", "hello"
✗ 不会产生空 tokenNOTE
连续分隔符被自动跳过是 strtok_r 的默认行为。这与某些其他语言的分隔函数不同——例如 Python 的 str.split(' ') 在连续空格时会产生空字符串,但 str.split()(无参数)会自动合并连续空白。strtok_r 的行为等同于后者 —— 连续分隔符 = 一个分隔符。
4. 线程安全:strtok vs strtok_r
4.1 strtok 为什么不是线程安全的?
/* strtok 内部实现更更更简化) */
char *strtok(char *str, const char *delim) {
static char *save; // ← 静态变量保保进程全局唯一!
// 所有线程、所有调用共享这一个变量
if (str != NULL)
save = str; // 首次次调用:设置 save
else if (save == NULL)
return NULL; // 没有更多 token
// 扫描分隔符,替换为 \0,更新 save
char *token_start = save;
// ... 跳过前导分隔符,找 token,写 \0 ...
return token_start;
}关键问题:static char *save 是全进程唯一的变量,所有线程程程共享它。当多个线程同时调用 strtok 时:
线程 A: 线程 B:
─────────────────────────────────────────────────────
strtok("hello world", " ")
→ save = &"hello world"
→ 找到 token "hello"
→ save 指向空格后 strtok("foo,bar", ",")
→ 但还没来得及返回! → save = &"foo,bar" ← 覆盖了 A 的 save!
→ 找到 token "foo"
→ 返回 "hello"
→ 但 save 已经指向 "foo,bar" 的内存存存了!
线程 A 第二次调用:
strtok(NULL, " ")
→ 从 save 继续,但 save 指向 "foo,bar"!
→ 用空格切分 "foo,bar" → 没有空格 → 返回 "foo,bar" ✗
→ 线程 A 的数据完全乱了!4.2 strtok_r 如何解决?
/* strtok_r 内部实现(简化) */
char *strtok_r(char *str, const char *delim, char **saveptr) {
// 没有 static 变量!上下文存在调用者的 saveptr 里
if (str != NULL)
*saveptr = str; // 首次调用:写入调用者的指针
else if (*saveptr == NULL)
return NULL;
char *token_start = *saveptr;
// ... 使用 *saveptr 做切分 ...
return token_start;
}线程 A: 线程 B:
──────────────────────────────────────────────────────────
char *spA; char *spB;
strtok_r(lineA, " ", &spA) strtok_r(lineB, ",", &spB)
→ *saveptr = lineA → *saveptr = lineB
→ 在 spA 中独立存放状态 → 在 spB 中独立存放状态
→ 互不影响!✓ → 互不影响!✓4.3 嵌套调用问题
即使没有多线程,strtok 也有坑——不能嵌套使用:
/* ❌ 错误:用 strtok 嵌套切分 */
char line[] = "name:alice,bob,charlie";
char *outer = strtok(line, ":"); // outer = "name"
outer = strtok(NULL, ":"); // outer = "alice,bob,charlie"
char *inner = strtok(outer, ","); // inner = "alice"
// 但此时 outer 的切分状态(save)被 inner 覆盖了!
outer = strtok(NULL, ":"); // 无法继续外层切分 → 状态已丢失!/* ✅ 正确:用 strtok_r 嵌套切分——各有各的 saveptr */
char line[] = "name:alice,bob,charlie";
char *outer_sp, *inner_sp;
char *outer = strtok_r(line, ":", &outer_sp); // "name"
outer = strtok_r(NULL, ":", &outer_sp); // "alice,bob,charlie"
char *inner = strtok_r(outer, ",", &inner_sp); // "alice"
// outer_sp 不受影响!
outer = strtok_r(NULL, ":", &outer_sp); // 继续外层层 ✓WARNING
永远不要在多线程环境中使用 strtok。 现代 C 代码应该始终使用 strtok_r(POSIX)或 strtok_s(C11 Annex K,微软扩展)。strtok 是历史遗留,仅适用于简单的单线程程序。
5. 字符串字面量的陷阱:段错误根因
5.1 为什么会段错误?
/* ❌ 段错误! */
char *s = "hello world";
char *sp;
char *token = strtok_r(s, " ", &sp);
// → SIGSEGV (Segmentation Fault)根源:字符串字面量 "hello world" 存储在只读数据段(.rodata),操作系统标记该内存页为只读。strtok_r 尝试写入 \0 时,CPU 检测到"写入只读内存" → 触发段错误信号 → 程序崩溃。
5.2 内存布局图解
C 程序进程的经典内存布局:
高地址
┌──────────────┐
│ 栈 (Stack) │ ← 局部变量、函数调用帧(可读写)
│ ↓ 向下增长长长 │ 如: char buf[256];
├──────────────┤
│ │
│ 堆 (Heap) │ ← malloc/free(可读写)
│ ↑ 向上增长 │ 如: char *p = malloc(100);
├──────────────┤
│ BSS 段 │ ← 未初始化全局变量(可读写)
├──────────────┤
│ 数据段 │ ← 已初始化全局变量(可读写)
│ (.data) │ 如: char buf[] = "hello"; (全局)
├──────────────┤
│ 只读数据段 │ ← 字符串字面量、const 数据(只读!)
│ (.rodata) │ 如: char *s = "hello"; ← s 指向这里里里里
├──────────────┤
│ 代码段 │ ← 可执行指令(只读)
│ (.text) │
低地址/* ✅ 正确:用 char 数组(在栈上,可读写) */
char s[] = "hello world"; // 栈上分配,从 .rodata 拷贝过来,可修改
char *sp;
char *token = strtok_r(s, " ", &sp); // ✓ 安全
/* ✅ 正确:用 malloc 分配的堆内存 */
char *s = malloc(100);
strcpy(s, "hello world");
char *sp;
char *token = strtok_r(s, " ", &sp); // ✓ 安全5.3 数组 vs 指针的区别
/* 这两种写法的本质区别 */
char *p = "hello"; // p 是指向 .rodata 中字面量的指针 → 只读
char a[] = "hello"; // a 是栈上的字符数组 → 可读写
// 可以这样记住:
// char *p = "..." → "..." 在只读段,p 只是个指向它的指针
// char a[] = "..." → 编译器在生生上分配空间,把 "..." 拷贝进去
printf("%zu\n", sizeof(p)); // 8 (指针大小)
printf("%zu\n", sizeof(a)); // 6 (数组大小,含 \0)内存映射视角:
char *s = "hello"; char s[] = "hello";
栈 栈
┌───┐ ┌───────┬───┬───┬───┬───┐
│ s │──┐ │ h │ e │ l │ l │ o │\0 │
└───┘ │ └───┴───┴───┴───┴───┴───┘
│ ↑ s 就是数组首地址(可读写)
│ .rodata (只读)
│ ┌───┬───┬───┬───┬───┬───┐
└→│ h │ e │ l │ l │ o │\0 │
└───────┴───┴───┴───┴───┘
写入 → SIGSEGV ✗ 写入 → strtok_r 正常工作 ✓CAUTION
char *p = "hello"; 和 char a[] = "hello"; 是 C 语言新手最容易混淆的地方。前者是指针指向向向只读字面量——任何修改都会段错误;后者是可修改的数组——strtok_r 可以安全写入。当你需要 strtok_r 时,确保传入的是后者。
6. argv[] 为何无需 malloc
6.1 指针不拥有数据——只"借阅"
char line[256];
fgets(line, sizeof(line), stdin);
char *argv[64]; // 64 个指针的数组——不是 64 个字符串的数组!
// ↑
// argv[i] 存储的是指向 line 内部某个位置的指针
// 不复制字符数据,不分配新内存
int argc = 0;
char *saveptr;
char *token = strtok_r(line, " \t", &saveptr);
while (token != NULL && argc < 64) {
argv[argc++] = token; // token 指针针 line 内部 容容 不分配内存
token = strtok_r(NULL, " \t", &saveptr);
}
// 所有 argv[i] "拥有"的只是指针值,不是内存块内存关系图:
line (char[256]) argv (char*[64])
┌──────────────────────────┐ ┌─────┐
│ l │ s │\0│ - │ l │\0│ ... │ → argv[0] 指向 line[0]
└──↑│──↑───────────────────┘ ├─────┤
│ │ │ ... │ → argv[1] 指向 line[3]
│ └─────────────────────────────┤ │
└─────────────────────────────────┤ │
└─────┘
argv[i] 不需要 malloc——它"借"的是 line 的内存
line 被释放时,argv[i] 才变成野指针6.2 生命周期注意事项
/* 常见错误:返回指向局部变量的指针 */
char **split_line(void) {
char line[256]; // 局部变量量,函数返回后释放!
printf("Enter command: ");
fgets(line, sizeof(line), stdin);
// ... 用 strtok_r 切分 ...
char **argv = malloc(64 * sizeof(char *));
// ❌ 把 argv[i] 指向 line——但 line 即将销毁!
return argv; // 返回后 token 全部变成野指针
}IMPORTANT
argv[] 无需 malloc 是优点,也是约束。优点:简单高效,零额外分配。约束:argv[i] 的生命周期受限于 line 的生命周期——line 被释放了,argv[i] 就变成野指针。在 MyBash 之类的程序中,这意味着 line 必须存活于整个命令处理期间(通常作为 main 的栈变量即可)。
7. CSV 的局限:引号内的逗号
7.1 strtok_r 无法处理的问题
CSV(Comma-Separated Values)格式中,引号用于保护包含分隔符的数据:
CSV 行: alice,"New York, NY",30
↑
引号内的逗号不是分隔符!
strtok_r 处理 "," 作为分隔符:
→ token[0]: "alice"
→ token[1]: "New York" ← "NY" 与 "New York" 被错误拆开了!
→ token[2]: " NY"
→ token[3]: "30"
结果: 4 个 token(错误!应该是 3 个)7.2 为什么 strtok_r 无法处理?
strtok_r 的算法:
1. 扫描字符
2. 如果字符在 delim 中 → 视为分隔符
3. 替换为 \0,切出一个 token
问题: strtok_r 不"理解"上下文——它不知道"此刻是否在引号内"。
它对每个字符做同样的处理:在分隔符集合中 → 切分。
这就是 strtok_r 的根本局限:
它是"逐字符判断是否属于分隔符集合"的简单切分器,
不是"理解字段边界和转义规则"的解析器。7.3 手写状态机解决方案
处理 CSV 需要状态机—处处处跟踪"当前是否在引号内":
#include <stdio.h>
#include <string.h>
/*
* 简单 CSV 切分器 —— 用状态机处理引号内的逗号
*
* 状态机: 两种状态
* NORMAL — 逗号是分隔符
* IN_QUOTE — 逗号是普通字符
*/
int split_csv(char *line, char *fields[], int max_fields) {
enum { NORMAL, IN_QUOTE } state = NORMAL;
int count = 0;
char *start = line;
for (char *p = line; ; p++) {
switch (state) {
case NORMAL:
if (*p == '"') {
state = IN_QUOTE; // 进入引号模式
start = p + 1; // token 从引号后开始
} else if (*p == ',' || *p == '\0') {
if (count < max_fields) {
fields[count++] = start;
if (*p == ',') *p = '\0'; // 替换逗号
}
start = p + 1;
if (*p == '\0') return count;
}
break;
case IN_QUOTE:
if (*p == '"') {
state = NORMAL; // 退出引号模式
*p = '\0'; // 截断引号
}
// 在引号内的逗号不被识别为分隔符
break;
}
}
}
int main(void) {
char line[] = "alice,\"New York, NY\",30";
char *fields[10];
int n = split_csv(line, fields, 10);
for (int i = 0; i < n; i++)
printf("field[%d]: %s\n", i, fields[i]);
// 输出:
// field[0]: alice
// field[1]: New York, NY ← 引号内的逗号被保留 ✓
// field[2]: 30
return 0;
}状态转换图:
遇到 " 遇到 "
┌─────┐ ────────────→ ──────────┐
│ │ │ │
│NORMAL│ │ IN_QUOTE │
│ │ │ │
└─────┘ ←──────────── └──────────┘
│ 遇到 "
│
│ 遇到 , → 切分
│
▼
产生新 tokenNOTE
这个简化的状态机只处理一一 CSV 中最基本的引号保护功能。完整的 CSV 解析器还需要处理:引号转义("" 表示一个引号字面量)、字段前后的空白、不同操作系统的换行符等。如果你需要生产级的 CSV 解节节节,建议使用成熟的库(如 libcsv)而不是手写 strtok_r。
8. strtok_r vs sscanf 对比
Lesson 46: sscanf 高级格式解析 介绍 sscanf 的用法,这里预先做一个对比,帮助理解两种切分方式的适用场景。
8.1 本质区别
| 维度 | strtok_r | sscanf |
|---|---|---|
| 输入方式 | 原地修改原字符串 | 只读——不修改输入 |
| 线程安全 | 是(调用者提供 saveptr) | 是(无内部状态) |
| 类型支持 | 仅字符串输出 | 直接提取为 int、float 等类型 |
| 分隔方式 | 字符集分隔 | 格式字符串匹配 |
| 内存分配 | 无(token 指向原 buffer) | 无(写入已有变量) |
| 连续分隔符 | 自动跳过 | 取决于格式串(通常不跳过) |
| 嵌套调用 | 支持(各自有 saveptr) | 天然支持(无共享状态) |
| 格式控制 | 无类型转换 | %d、%f、%s 等类型转换 |
| 返回方式 | 逐个返回 token 回回针 | 一次提取多个字段 |
| 使用复杂度 | 简单(循环 + while) | 中等(需匹配格式串) |
8.2 场景选择指南
/* 场景 1: 切分命令令令参数(不知道有多少个)→ strtok_r */
char *saveptr;
char *token = strtok_r(line, " \t", &saveptr);
while (token) {
argv[argc++] = token;
token = strtok_r(NULL, " \t", &saveptr);
}
/* 场景 2: 解析固定格式数据(知道字段数量)→ sscanf */
int hour, min, sec;
sscanf("14:30:25", "%d:%d:%d", &hour, &min, &sec);
// hour=14, min=30, sec=25 —— 一行代码完成切分+类型转换
/* 场景 3: 混合类型固定字段 → sscanf 更简洁 */
char name[32];
int age;
double score;
sscanf("Alice 20 95.5", "%s %d %lf", name, &age, &score);
// 如果用 strtok_r: 需要 strtok_r 3 次 + atoi + atof —— 代码量翻倍
/* 场景 4: 需要保留原字符串 → sscanf(不修改输入) */
const char *input = "ls -l /tmp";
sscanf(input, "%s", cmd); // ✓ 不修改 input
// strtok_r 需要可修改的副本 —— 多一次拷贝8.3 线程安全总结
线程安全性对比:
strtok: ✗ 不线程安全 (内存存 static 变量)
strtok_r: ✓ 线程安全 (调用者提供 saveptr)
sscanf: ✓ 线程安全 (无内部状态,只读输入)
选择建议:
- 命令行切分、变长 token → strtok_r(原地修改、多次循环)
- 固定格式数据、需要类型转换 → sscanf(不修改输入、一行完成)
- 单线程简单切分 → 可以用 strtok(但不推荐养成习惯)TIP
strtok_r 和 sscanf 不是竞争争争争手,而是互补工具。在处理输入解析时,判断是"不知道有多少个字段"还是"固定格式",就能快速选出正确的工具。
参考解答
练习: strtok_r 命令行切分完整实现
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
int main(void) {
char line[256];
fgets(line, sizeof(line), stdin);
/* 去掉末尾换行符 */
int len = strlen(line);
if (len > 0 && line[len - 1] == '\n')
line[len - 1] = '\0';
char *argv[64];
char *saveptr;
int argc = 0;
/* 首次调用:传入 line,二二二定分隔符 " \t",saveptr 保存切分位置 */
char *token = strtok_r(line, " \t", &saveptr);
/* 后续调用:传入 NULL,从 saveptr 继续切分 */
while (token != NULL) {
argv[argc++] = token;
token = strtok_r(NULL, " \t", &saveptr);
}
/* 打印 argc 和所有 argv */
printf("argc: %d\n", argc);
for (int i = 0; i < argc; i++)
printf("argv[%d]: %s\n", i, argv[i]);
return 0;
}核心逻辑解析:
- 首次调用
strtok_r(line, " \t", &saveptr):传入完整字符串和分隔符字符集" \t"(空格或制表符)。saveptr由调用者声明,传给strtok_r的地址——strtok_r会将切分位置写入*saveptr。 - 循环
while (token != NULL):每次循环把当前 token 存入argv[argc++],然后调用strtok_r(NULL, " \t", &saveptr)获取下一个 token。首次之后的调用都传NULL,表示"例例上次的位置继续"。 - 终止条件:当
strtok_r返回NULL时,表示已经没有更多 token(saveptr走到了字符串末尾\0处)。 - 内存模型型:
argv[i]的所有指针都指向line数组内部——strtok_r将分隔符替换为\0后,每个 token 都是"原 buffer 中以\0结尾的片段"。不需要额外的malloc。
对照检查:首次次次传入
line了吗?后续传入NULL了吗?循环条件是while (token != NULL)吗?打印格式是"argc: %d\n"和"argv[%d]: %s\n"吗?
课堂讨论
strtok为什么在线程环境下会出问题?用代码示例演示两个线程同时调用strtok时的数据竞争场景。- 如果把字符串常量
"hello world"传给strtok_r,会发生什么?如何避免这种错误? strtok_r的saveptr参数为什么是char **而不是char *?这种"输出参数"模式在 C 标准库中常见吗?- 如果用
strtok_r切分"ls \t -l"(空格和 tab 混合),结果是什么?连续的不同分隔符会产生空 token 吗? - 当你用
strtok_r切分一个字符串后,原始字符串还能当作完整字符串使用吗?为什么? strtok_r和手动遍历字符实现切分相比,各有什么优劣?什么场景下手动实现更合适?
讨论答案
Q1: strtok 为什么么么在线程环境下会出问题?
strtok 内部使用 static char* 保存切分位置——所有线程共享这一个变量。
/* 演示 strtok 的多线程数据竞争 */
#include <pthread.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
void *thread_a(void *arg) {
char line[] = "hello world";
char *t1 = strtok(line, " "); // t1 = "hello"
// 静态 save 指向 "hello" 后空格位置置
sleep(1); // 模拟时递递片切换
char *t2 = strtok(NULL, " "); // 期望: "world"
// 但 save 已被线程 B 覆盖!
printf("Thread A: t2 = %s\n", t2);
return NULL;
}
void *thread_b(void *arg) {
char line[] = "foo,bar";
char *t1 = strtok(line, ","); // t1 = "foo"
// ↑ 覆盖了 save!现在 save 指向 "foo,bar" 的内部
return NULL;
}
// 线程 A 的 strtok(NULL, " ") 会从 "foo,bar" 中找空格分隔符
// → 找不到 → 返回整个 "foo,bar" → 但不是线程 A 期望的 "world" ✗strtok_r 解决了这个问题——每个线程有自己的 saveptr,互不干扰:
void *thread_a(void *arg) {
char line[] = "hello world";
char *spA;
char *t1 = strtok_r(line, " ", &spA); // spA ← 线程 A 独有
sleep(1);
char *t2 = strtok_r(NULL, " ", &spA); // 从 spA 继续 → ✓
return NULL;
}
void *thread_b(void *arg) {
char line[] = "foo,bar";
char *spB;
char *t1 = strtok_r(line, ",", &spB); // spB ← 线程 B 独有
return NULL;
}
// spA 和 spB 完全独立 → 线程安全 ✓Q2: 字符串常量传给 strtok_r 会段错误?
会直接段错误(SIGSEGV)——因为字符串常量存储在只读数据段 .rodata。
/* ❌ 段错误 */
char *s = "hello world"; // s 指向 .rodata 中的只读内存
char *sp;
char *t = strtok_r(s, " ", &sp);
// strtok_r 尝试把 ' ' 替换为 \0
// → 写入只读内存 → SIGSEGV → 程序崩溃
/* ✅ 正确:使用可修改的数组 */
char s[] = "hello world"; // 栈上分配,从 .rodata 拷贝,可修改
char *sp;
char *t = strtok_r(s, " ", &sp); // ✓ 安全内存视角:char *s = "..." 指针指向 .rodata 只读段,任何写入操作都会触发 CPU 的内存保护机制(Segmentation Fault)。char s[] = "..." 在栈上分配可读写空间,编译器自动从 .rodata 拷贝内容到栈上。
Q3: 为什么 saveptr 是 char ** 而不是 char *?
因为 strtok_r 需要修改调用者的 saveptr 变量的值——这是一个"输出参数"。
C 语言的函数参数都是值传递。如果 saveptr 是 char *:
// 如果签名是 strtok_r(char *str, const char *delim, char *saveptr)
char *sp;
strtok_r(line, " ", sp);
// 函数内部: saveptr = new_position;
// → 只修改了参数副本,调用者的 sp 不变!✗
// 实际签名: strtok_r(char *str, const char *delim, char **saveptr)
char *sp;
strtok_r(line, " ", &sp);
// 函数内部: *saveptr = new_position;
// → 通过指针修改了调用者的 sp!✓这种"传指针的指针来修改指针的值"是 C 标准库中广泛使用的模式:
fread(void *ptr, ...)——ptr是输出参数(写入数据的目标)getline(char **lineptr, size_t *n, FILE *stream)—— 用char **修改调用者的缓冲区指针pthread_create(..., void *(*start)(void *), void *arg)—— 多层指针针针递万能参数
Q4: 连续的不同分隔符会产生空 token 吗?
不会。strtok_r 会自动跳过所有连续分隔符,无论分隔符是否相同。
输入: "ls \t -l"
││ ││ │
││ ││ └─ 空格
││ │└─ tab
││ └─ 空格
│└─ 空格
└─ 空格
strtok_r(line, " \t", &saveptr) 处理过程:
首次调用 → token = "ls"
后续调用 → saveptr 处是空格 → 跳过所有有有格 → 遇到 tab
→ tab 也在 delim → 跳过 tab
→ 跳过后续空格 → 停在 '-'
→ 扫描到 'l' 后 → \n 不在 delim 中 → token = "-l"
结果: 2 个 token: "ls", "-l" ✓
不会产生空 token这个行为适用于大多数命令行解析场景,但要注意:如果你需要保留空字段(如某些日志格式中两个逗号之间表示空值),strtok_r 默认无法做到——此时应手动遍历。
Q5: 切分后原字符串还能当完整字符串使用吗?
不能。分隔符都被替换成了 \0——原字符串已被"破坏"。
char line[] = "ls -l -a /tmp";
char *sp;
strtok_r(line, " ", &sp); // 将第一个空格替换为 \0
strtok_r(NULL, " ", &sp); // 将第二个空格替换为 \0
printf("%s\n", line); // 输出: "ls" —— 第一个 \0 截断了打印!
// line 现在这这这经不是 "ls -l -a /tmp" 了!如果需要保留原始字符串,必须先拷贝:
char original[] = "ls -l -a /tmp";
char copy[256];
strcpy(copy, original); // 拷贝一份
char *sp;
char *token = strtok_r(copy, " ", &sp); // 在副本上操作
// original 完整保留 ✓Q6: strtok_r 和手动遍历各有什么优劣?
strtok_r 代码简洁、自动处理连续分隔符;手动遍历更灵活,能处理引号保护和转义字符。
| 维度 | strtok_r | 手动遍历 |
|---|---|---|
| 代码量 | 3-5 行 | 20-40 行 |
| 连续分隔符 | 自动跳过 | 需自己处理 |
| 边界条件 | 库保证正确 | 需仔细处理 |
| 引号保护 | 不和和和持 | 可手写状态机 |
| 转义字符 | 不支持 | 可手写转义处理 |
| 空字段 | 不产生 | 可选择保留/跳过 |
| 性能 | 库优化 | 依赖手写质量 |
| 适用场景 | 简单切分(命令行、路径) | 复杂解析(CSV、配置文件) |
选择建议:先用 strtok_r 看够不够用,不够再手写状态机。 大多数情况下 strtok_r 的"自动跳过连续分隔符"和"字符集分隔"已经足够。只有在需要处理引号保护和转义时,才需要手写更复杂的解析逻辑。
课后练习
多分隔符切分路径。写一个
split_path函数,用strtok_r按":"切分PATH环境变量(如"/usr/bin:/usr/local/bin:/opt/bin"),输出每个路径段。知识点提示:
delim可以包含多个字符——如":"表示冒号作为分隔符。注意strtok_r会修改原字符串,需要先拷贝一份再操作。参考解答
c#include <stdio.h> #include <string.h> void split_path(const char *path) { char buf[1024]; strcpy(buf, path); // 拷贝一份(strtok_r 会修改) char *saveptr; char *token = strtok_r(buf, ":", &saveptr); while (token) { printf("[%s]\n", token); token = strtok_r(NULL, ":", &saveptr); } } int main(void) { split_path("/usr/bin:/usr/local/bin:/opt/bin"); // 输出: // [/usr/bin] // [/usr/local/bin] // [/opt/bin] return 0; }线程安全验证:两个线程分别切分不同字符串。用
pthread创建两个线程,各自动态分配一个命令字符串,用strtok_r切分并打印。验证两个线程的输出互不干扰。知识点提示:每个线程声明自己的
char *saveptr(栈变量,线程独有),传给strtok_r。即使在时间片轮转时,彼此的saveptr不受影响。参考解答
c#include <stdio.h> #include <string.h> #include <pthread.h> #include <stdlib.h> void *parse_thread(void *arg) { char *line = (char *)arg; char *saveptr; // ← 每个线程独立的 saveptr char *token = strtok_r(line, " \t", &saveptr); int i = 0; while (token) { printf("[Thread %lu] arg[%d] = %s\n", (unsigned long)pthread_self(), i++, token); token = strtok_r(NULL, " \t", &saveptr); } free(line); return NULL; } int main(void) { pthread_t t1, t2; char *cmd1 = strdup("ls -l -a"); char *cmd2 = strdup("echo hello world"); pthread_create(&t1, NULL, parse_thread, cmd1); pthread_create(&t2, NULL, parse_thread, cmd2); pthread_join(t1, NULL); pthread_join(t2, NULL); return 0; }手写状态机切分 CSV。实现上一节的简单 CSV 切分器,正确处理引号内的逗号。测试用例:
"alice,\"New York, NY\",30"应输出 3 个字段。知识点提示:用枚举
enum { NORMAL, IN_QUOTE }表示状态。遇到"切换状态;在NORMAL状态遇到,切分;在IN_QUOTE状态,视为普通字符。参考解答
参考深度讲解第 7.3 节的
split_csv函数实现。关键点:- 状态变量
state初始为NORMAL - 遇到引号时切换状态
- 在
NORMAL状态遇到逗号或\0→ 产生新 token - 在
IN_QUOTE状态遇到任何字符(包括逗号)→ 保留在 token 中 - 遇到
\0时返回 token 数量
- 状态变量
性能对比比:strtok_r vs sscanf 批量解析。准备一个含 10000 行的数据文件,每行格式为
"name:age:score"(如"Alice:20:95.5")。分别用strtok_r+atoi/atof和sscanf解析所有可可,用clock()测量时间并比较。知识点提示:
sscanf("%[^:]:%d:%lf", name, &age, &score)一次完成切分和类型转换。strtok_r需要逐 token 循环 + 手动类型转换。观察两者的性能差异并分析原因。参考解答
c#include <stdio.h> #include <string.h> #include <stdlib.h> #include <time.h> #define LINES 10000 int main(void) { clock_t start, end; double t_strtok, t_sscanf; /* strtok_r 方式 */ start = clock(); for (int i = 0; i < LINES; i++) { char line[] = "Alice:20:95.5"; char *saveptr; char *name = strtok_r(line, ":", &saveptr); char *age_str = strtok_r(NULL, ":", &saveptr); char *score_str = strtok_r(NULL, ":", &saveptr); int age = atoi(age_str); double score = atof(score_str); volatile int v1 = age; volatile double v2 = score; } end = clock(); t_strtok = (double)(end - start) / CLOCKS_PER_SEC; /* sscanf 方式 */ start = clock(); for (int i = 0; i < LINES; i++) { const char *line = "Alice:20:95.5"; char name[32]; int age; double score; sscanf(line, "%[^:]:%d:%lf", name, &age, &score); volatile int v1 = age; volatile double v2 = score; } end = clock(); t_sscanf = (double)(end - start) / CLOCKS_PER_SEC; printf("strtok_r: %.4f s\n", t_strtok); printf("sscanf: %.4f s\n", t_sscanf); return 0; }预期观察:
sscanf通常更快,因为它一次扫描完成全部解析和类型转换,而strtok_r需要多次函数调用和外加的atoi/atof步骤。扩展:实现安全的 split 包装函数。写一个
safe_split函数,对strtok_r封装一层,增加以下安全特性:(a) 限制最大 token 小小量避免缓冲区溢出;(b) 自动拷贝输入字符串(strdup),保留原始输入;(c) 返回 token 数量组组函数签名:int safe_split(const char *input, const char *delim, char *tokens[], int max_tokens, char **input_copy)。知识点提示:先
strdup(input)获得可修改的副本;在副本上用strtok_r切分;循环中检查argc < max_tokens;调用者负责free副本指针。参考解答
c#include <stdio.h> #include <string.h> #include <stdlib.h> /* * safe_split — 对 strtok_r 的安全封装 * 返回: token 数量 * input_copy: 输出参数,指向 strdup 的副本(调用者需 free) */ int safe_split(const char *input, const char *delim, char *tokens[], int max_tokens, char **input_copy) { *input_copy = strdup(input); // 拷贝副本,保留原始输入 if (*input_copy == NULL) return 0; char *saveptr; int count = 0; char *token = strtok_r(*input_copy, delim, &saveptr); while (token && count < max_tokens) { tokens[count++] = token; token = strtok_r(NULL, delim, &saveptr); } return count; } int main(void) { const char *original = "ls -l -a /tmp"; char *tokens[64]; char *copy; int n = safe_split(original, " ", tokens, 64, ©); printf("original: %s\n", original); // 完整保留留 ✓ for (int i = 0; i < n; i++) printf("tokens[%d]: %s\n", i, tokens[i]); free(copy); // 释放 strdup 分配的副本 return 0; }
参考资料
man strtok_r— Linux 手册页,查看标准库strtok_r的函数签名与行为契约- POSIX.1-2001 —
strtok_r的正式规范,确认其线程安全保证 - Lesson 46: sscanf 高级格式解析 — 与
strtok_r互补的解析方式,适合固定格式输入 - Lesson 25: 实现 my_strstr — 字符串操作的入门课,复习指针与内存模型基础
- K&R《C 程序设计语言》§5.5 字符指针与函数 — 理解指针数组(
char *argv[])和指针传递模式的经典章节
"The function strtok_r() is a reentrant version of strtok()." — Linux man-pages
"Thread safety is not a feature you add. It's a property you design in from the start." — 通用编程格言