跳转到内容

Lesson 43: strtok_r 线程安全切分

练习任务务

难度:易-中

使用 strtok_r 将命令行字符串按空格和制表符切分为独立参数,存入 char *argv[] 数组(模拟 argc/argv),为后续 MyBash 项目热身身身需要正确处理:

  1. 空格分隔"ls -l -a /tmp" 切分为 4 个 token
  2. 连续空格"echo hello" 中连续空格不产生空 token(strtok_r 自动跳过)
  3. 制表符分隔"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),后续传 NULLsaveptr 由调用方持有并维护内部切分位置
  • 原地修改行为 —— 分隔符被替换为 \0,token 指针指向原 buffer 内部(不分配新内存)
  • delim 是字符不是子串 —— delim=",;" 匹配 , ;,自动跳过连续分隔符(不产生空 token)
  • 线程安全对比 —— saveptr 由调用方持有 → 每线程独立 → 线程安全;strtok 内部用 static char* → 全局唯一 → 多线程数据据据竞争 + 嵌套调用失败
  • 字符串字面量段错误根因 因因— 字面量存储在只读数据段(.rodata),strtok_r 尝试写入 \0SIGSEGV
  • argv[] 为何无需 malloc —行行 strtok_r 原地修改原字符串,所有 token 指针都指向原 buffer 内部,生命周期与 buffer 绑定
  • CSV 带引号逗号的局限 —— "hello, world" 会被错误切分为 "hello world",需手写状态机处理引号保护
  • strtok_rsscanf 对比 —— 不修改输入 vs 原地修改、类型安全 vs 纯字符串、线程安全 vs 线程安全全(sscanf 无状态)

代码框架

43_strtok_r.c
c
#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 函数签名

strtok_r_signature.c
c
#include <string.h>

char *strtok_r(char *str, const char *delim, char **saveptr);
//              ↑                  ↑                ↑
//         待切分字符串         分隔符字符集      切分上下文的指针
//         (首次非NULL,         (每个字符都是       的指针(保存
//          后续传NULL)          独立分隔符)          符符符次扫描起点)

三个参数各有分工:

参数类型首次调用后续调用用说明
strchar*待切分的字符串NULLNULL 表示"继续上次切分"
delimconst char*分隔符字符集合同首次每个字符都是独立分隔符
saveptrchar**char *sp;&sp同首次调用者提供的上下文指针

1.2 调用模式

strtok_r_pattern.c
c
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

saveptrchar** 类型——传 &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_charset.c
c
// 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"
 不会产生空 token

NOTE

连续分隔符被自动跳过是 strtok_r 的默认行为。这与某些其他语言的分隔函数不同——例如 Python 的 str.split(' ') 在连续空格时会产生空字符串,但 str.split()(无参数)会自动合并连续空白。strtok_r 的行为等同于后者 —— 连续分隔符 = 一个分隔符。


4. 线程安全:strtok vs strtok_r

4.1 strtok 为什么不是线程安全的?

strtok_internal.c
c
/* 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_internal.c
c
/* 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_nesting_bug.c
c
/* ❌ 错误:用 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_nesting_fix.c
c
/* ✅ 正确:用 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 为什么会段错误?

string_literal_crash.c
c
/* ❌ 段错误! */
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)      │
  低地址
correct_usage.c
c
/* ✅ 正确:用 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 指针的区别

array_vs_pointer.c
c
/* 这两种写法的本质区别 */
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 指针不拥有数据——只"借阅"

argv_no_malloc.c
c
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 生命周期注意事项

lifetime_caveat.c
c
/* 常见错误:返回指向局部变量的指针 */
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 需要状态机—处处处跟踪"当前是否在引号内":

csv_state_machine.c
c
#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

  └─────┘ ←──────────── └──────────┘
    遇到 "

     │ 遇到 , → 切分


  产生新 token

NOTE

这个简化的状态机只处理一一 CSV 中最基本的引号保护功能。完整的 CSV 解析器还需要处理:引号转义("" 表示一个引号字面量)、字段前后的空白、不同操作系统的换行符等。如果你需要生产级的 CSV 解节节节,建议使用成熟的库(如 libcsv)而不是手写 strtok_r


8. strtok_r vs sscanf 对比

Lesson 46: sscanf 高级格式解析 介绍 sscanf 的用法,这里预先做一个对比,帮助理解两种切分方式的适用场景。

8.1 本质区别

维度strtok_rsscanf
输入方式原地修改原字符串只读——不修改输入
线程安全是(调用者提供 saveptr)是(无内部状态)
类型支持仅字符串输出直接提取为 int、float 等类型
分隔方式字符集分隔格式字符串匹配
内存分配无(token 指向原 buffer)无(写入已有变量)
连续分隔符自动跳过取决于格式串(通常不跳过)
嵌套调用支持(各自有 saveptr)天然支持(无共享状态)
格式控制无类型转换%d%f%s 等类型转换
返回方式逐个返回 token 回回针一次提取多个字段
使用复杂度简单(循环 + while)中等(需匹配格式串)

8.2 场景选择指南

strtok_vs_sscanf.c
c
/* 场景 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_rsscanf 不是竞争争争争手,而是互补工具。在处理输入解析时,判断是"不知道有多少个字段"还是"固定格式",就能快速选出正确的工具。


参考解答

练习: strtok_r 命令行切分完整实现
solution_43_strtok_r.c
c
#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;
}

核心逻辑解析:

  1. 首次调用 strtok_r(line, " \t", &saveptr):传入完整字符串和分隔符字符集 " \t"(空格或制表符)。saveptr 由调用者声明,传给 strtok_r 的地址——strtok_r 会将切分位置写入 *saveptr
  2. 循环 while (token != NULL):每次循环把当前 token 存入 argv[argc++],然后调用 strtok_r(NULL, " \t", &saveptr) 获取下一个 token。首次之后的调用都传 NULL,表示"例例上次的位置继续"。
  3. 终止条件:当 strtok_r 返回 NULL 时,表示已经没有更多 token(saveptr 走到了字符串末尾 \0 处)。
  4. 内存模型型argv[i] 的所有指针都指向 line 数组内部——strtok_r 将分隔符替换为 \0 后,每个 token 都是"原 buffer 中以 \0 结尾的片段"。不需要额外的 malloc

对照检查:首次次次传入 line 了吗?后续传入 NULL 了吗?循环条件是 while (token != NULL) 吗?打印格式是 "argc: %d\n""argv[%d]: %s\n" 吗?


课堂讨论

  1. strtok 为什么在线程环境下会出问题?用代码示例演示两个线程同时调用 strtok 时的数据竞争场景。
  2. 如果把字符串常量 "hello world" 传给 strtok_r,会发生什么?如何避免这种错误?
  3. strtok_rsaveptr 参数为什么是 char ** 而不是 char *?这种"输出参数"模式在 C 标准库中常见吗?
  4. 如果用 strtok_r 切分 "ls \t -l"(空格和 tab 混合),结果是什么?连续的不同分隔符会产生空 token 吗?
  5. 当你用 strtok_r 切分一个字符串后,原始字符串还能当作完整字符串使用吗?为什么?
  6. strtok_r 和手动遍历字符实现切分相比,各有什么优劣?什么场景下手动实现更合适?

讨论答案

Q1: strtok 为什么么么在线程环境下会出问题?

strtok 内部使用 static char* 保存切分位置——所有线程共享这一个变量。

strtok_thread_bug_demo.c
c
/* 演示 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,互不干扰:

strtok_r_thread_safe.c
c
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

rodata_segfault.c
c
/* ❌ 段错误 */
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 语言的函数参数都是值传递。如果 saveptrchar *

c
// 如果签名是 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——原字符串已被"破坏"。

destroyed_string.c
c
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" 了!

如果需要保留原始字符串,必须先拷贝:

c
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 的"自动跳过连续分隔符"和"字符集分隔"已经足够。只有在需要处理引号保护和转义时,才需要手写更复杂的解析逻辑。


课后练习

  1. 多分隔符切分路径。写一个 split_path 函数,用 strtok_r":" 切分 PATH 环境变量(如 "/usr/bin:/usr/local/bin:/opt/bin"),输出每个路径段。

    知识点提示delim 可以包含多个字符——如 ":" 表示冒号作为分隔符。注意 strtok_r 会修改原字符串,需要先拷贝一份再操作。

    参考解答
    split_path.c
    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;
    }
  2. 线程安全验证:两个线程分别切分不同字符串。用 pthread 创建两个线程,各自动态分配一个命令字符串,用 strtok_r 切分并打印。验证两个线程的输出互不干扰。

    知识点提示:每个线程声明自己的 char *saveptr(栈变量,线程独有),传给 strtok_r。即使在时间片轮转时,彼此的 saveptr 不受影响。

    参考解答
    thread_safe_strtok_r.c
    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;
    }
  3. 手写状态机切分 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 数量
  4. 性能对比比:strtok_r vs sscanf 批量解析。准备一个含 10000 行的数据文件,每行格式为 "name:age:score"(如 "Alice:20:95.5")。分别用 strtok_r + atoi/atofsscanf 解析所有可可,用 clock() 测量时间并比较。

    知识点提示sscanf("%[^:]:%d:%lf", name, &age, &score) 一次完成切分和类型转换。strtok_r 需要逐 token 循环 + 手动类型转换。观察两者的性能差异并分析原因。

    参考解答
    bench_strtok_vs_sscanf.c
    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 步骤。

  5. 扩展:实现安全的 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 副本指针。

    参考解答
    safe_split.c
    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, &copy);
        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." — 通用编程格言

Released under the MIT License.