☰
一次把 strstr、strtok、strerror、perror 用到骨子里:从面试题到真实工程现场
2026/9/30 2:25:26 网站建设 项目流程

一、先别背函数原型:你真正要解决的是什么问题

假设你现在要写一个日志解析器,输入长这样:

text

2026-09-29 10:23:45 [ERROR] user=zhangsan@163.com msg=connection timeout

你要做四件事:

  1. 从这一行里找到"ERROR"子串,判断是不是错误日志。

  2. 把zhangsan@163.com按@和.切分成zhangsan、163、com。

  3. 如果读取日志文件失败,把错误原因打印出来。

  4. 程序里到处都要检查错误,得有一套统一的错误处理方式。

这四个需求,恰好对应strstr、strtok、strerror、perror。今天我们就从这四个需求出发,把每个函数吃透。教育学上这叫“情境认知”——知识不是孤立的,它必须挂在一个真实任务上,你才能记得住、用得出。


二、strstr:指针算术不是装饰,是本质

先看你修正后的代码:

c

char* my_strstr(const char* str1, const char* str2) { const char* cur = str1; const char* s1 = NULL; const char* s2 = NULL; assert(str1 && str2); if (*str2 == '\0') { return (char*)str1; } while (*cur) { s1 = cur; s2 = str2; while (*s1 && *s2 && *s1 == *s2) { s1++; s2++; } if (*s2 == '\0') { return (char*)cur; } cur++; } return NULL; }

这段代码里有三个“指针算术”的关键点,很多人写对了但说不清。

第一点:cur和s1为什么是两个指针?

cur是“本轮尝试的起点”,s1是“本轮正在比较的位置”。s1从cur开始,往前走;cur始终不动,直到本轮失败,才cur++。

如果把s1和cur合成一个,你就没法在失败后回到起点重新来。这是暴力匹配必须付出的代价。

第二点:if (*str2 == '\0') return (char*)str1;为什么放在最前面?

这是处理空模式串。C 标准规定:空字符串是任何字符串的子串,且返回原串。很多初学者会漏掉这个边界情况,面试官一测就露馅。

第三点:while (*s1 && *s2 && *s1 == *s2)的短路特性。

&&是从左到右短路的。所以:

  • 如果*s1 == '\0',后面*s2和*s1 == *s2都不执行,直接退出循环。

  • 如果*s2 == '\0',*s1 == *s2也不执行,直接退出。

这个顺序保证了永远不会越界读。如果把*s1 == *s2放前面,就可能在'\0'处继续比较,导致越界。

面试官会怎么追问?

追问一:你这个 strstr 最坏复杂度是多少?
O(n×m)。主串每个起点都要跟模式串比一遍。

追问二:能不能优化到 O(n+m)?
可以,用 KMP。核心是预处理模式串,算出 next 数组,失配时主串不回退。

追问三:工程里真的用 KMP 吗?
不一定。glibc 的 strstr 在长模式下用 Two-Way 算法,常数比 KMP 小;短模式下用暴力优化版。Rust 的memchr用 SIMD 加速单字节查找,比 KMP 快很多。

追问四:如果模式串很长,主串很短呢?
先比较长度,strlen(str1) < strlen(str2)直接返回 NULL,省掉无意义的循环。


三、strtok:C 标准库中最“有状态”的函数

你的代码:

c

char arr[] = "zhangsan@163.com"; char arr3[] = "@."; char* p = NULL; for (p = strtok(arr, arr3); p != NULL; p = strtok(NULL, arr3)) { printf("%s\n", p); }

strtok的设计非常特殊。它不是“纯函数”——同样的输入,不同次调用,返回不同结果。因为它内部维护了一个静态指针。

strtok 的执行过程

用"zhangsan@163.com"和分隔符"@."来走一遍:

第一次调用strtok(arr, "@."):

  1. 从arr[0]开始扫描,跳过开头的分隔符(这里没有)。

  2. 记下起点start = &arr[0]。

  3. 继续扫描,直到遇到@(arr[8])。

  4. 把@改成'\0'。

  5. 静态指针saved指向@后面的位置(&arr[9])。

  6. 返回start,即"zhangsan"。

第二次调用strtok(NULL, "@."):

  1. 因为str == NULL,从静态指针saved继续。

  2. saved指向'1',不是分隔符。

  3. 记下起点start = &arr[9]。

  4. 继续扫描,直到遇到.(arr[12])。

  5. 把.改成'\0'。

  6. saved指向&arr[13]。

  7. 返回"163"。

第三次调用:

  1. 从saved开始,扫描到末尾。

  2. 没有遇到分隔符,返回"com",saved置为NULL。

第四次调用:

  1. saved == NULL,直接返回NULL。

为什么 strtok 被工程界“嫌弃”

三个原因:

原因一:不是线程安全的。saved是静态变量,所有线程共享。两个线程同时解析字符串,会互相踩。解决方案是strtok_r:

c

char* saveptr; char* token = strtok_r(arr, "@.", &saveptr); while (token != NULL) { printf("%s\n", token); token = strtok_r(NULL, "@.", &saveptr); }

原因二:会修改原字符串。它把分隔符替换成'\0'。如果你后面还要用原串,得先拷贝。

原因三:跳过连续分隔符。"a,,b"按,分割,只返回"a"和"b",中间的空串被丢掉。有些场景需要保留空字段,比如 CSV 解析,strtok就不合适。

手撕一个“保留空字段”的分割器

c

#include <stdio.h> #include <string.h> void split_preserve(const char* str, char delim) { const char* start = str; const char* p = str; while (1) { if (*p == delim || *p == '\0') { // 打印 [start, p) 区间 printf("[%.*s]\n", (int)(p - start), start); if (*p == '\0') break; start = p + 1; } p++; } } int main() { split_preserve("a,,b,c", ','); return 0; }

输出:

text

[a] [] [b] [c]

这个版本不修改原串,保留空字段,线程安全。工程里如果strtok不满足需求,就自己写一个。


四、errno:C 语言错误处理的“暗号”

errno是什么?表面上是一个int变量,实际上是 C 标准库的“错误暗号”。

标准库函数出错时,会做两件事:

  1. 返回一个表示失败的值(比如NULL、-1、EOF)。

  2. 把errno设成具体的错误码。

比如fopen失败返回NULL,同时errno被设成ENOENT(文件不存在)或EACCES(权限不够)。

为什么不能只看 errno

因为成功调用不保证清零 errno。

c

errno = 0; FILE* f = fopen("exists.txt", "r"); // 假设这个文件存在 // f != NULL,但 errno 可能还是上次的旧值

所以正确的判断方式是:

c

errno = 0; FILE* f = fopen("test.txt", "r"); if (f == NULL) { // errno 才是有意义的 }

errno 的线程局部存储

早期 C 库里,errno是全局int。多线程程序里,一个线程出错设了errno,另一个线程读到的就是错的。

C11 之后,errno是线程局部存储。每个线程有自己的errno。实现方式通常是:

c

// GCC/Clang 扩展 __thread int errno; // C11 标准 _Thread_local int errno;

但注意:errno本身线程安全了,strerror不一定。因为strerror可能返回指向静态缓冲区的指针。多线程调用strerror,结果可能互相覆盖。POSIX 提供strerror_r作为可重入版本。


五、strerror 和 perror:翻译错误码的两种方式

先看你的代码:

c

// 方式一:strerror if (pFile == NULL) { printf("%s\n", strerror(errno)); } // 方式二:perror if (pFile == NULL) { perror("The file's question is"); }

strerror(errno)返回错误码对应的字符串,比如"No such file or directory"。

perror("前缀")等价于:

c

fprintf(stderr, "%s: %s\n", "前缀", strerror(errno));

区别:

特性strerrorperror
返回值字符串指针无
输出位置由调用者决定stderr
是否自动读 errno否,要手动传是
格式无前缀前缀: 错误信息\n
线程安全可能不安全取决于 strerror

什么时候用哪个?

  • 需要把错误信息拼进自定义格式:用strerror。

  • 出错直接打印一行日志:用perror。

  • 多线程环境:用strerror_r+ 自定义输出。

那个循环打印 200 个错误码的代码

c

for (i = 0;i < 200;i++) { printf("%d: %s\n",i,strerror(i)); }

这段代码在学习阶段非常有价值。它让你看到系统定义了哪些错误码。但工程里不要这么写,因为:

  1. 错误码不保证从 0 到 199 连续。

  2. 有些错误码对应"Unknown error"。

  3. strerror可能不是线程安全的。

  4. strerror的参数超出范围是未定义行为。

正确的遍历方式是查系统头文件,或者用sys_nerr(如果有定义)。


六、把这四个函数串成一个完整的日志解析器

现在我们把今天学的四个函数组合起来,写一个真正能跑的日志解析器:

c

#include <stdio.h> #include <string.h> #include <errno.h> #include <assert.h> // 解析一行日志,提取用户名 int parse_log_line(const char* line, char* username, size_t size) { assert(line && username && size > 0); // 1. 用 strstr 判断是否包含 ERROR const char* err_pos = strstr(line, "ERROR"); if (err_pos == NULL) { return 0; // 不是错误日志 } // 2. 用 strstr 找到 user= const char* user_pos = strstr(line, "user="); if (user_pos == NULL) { return -1; } user_pos += 5; // 跳过 "user=" // 3. 找到 user= 后面的空格或行尾 const char* end = strchr(user_pos, ' '); if (end == NULL) { end = line + strlen(line); } // 4. 拷贝用户名 size_t len = end - user_pos; if (len >= size) { return -2; // 缓冲区不够 } strncpy(username, user_pos, len); username[len] = '\0'; // 5. 用 strtok 切分邮箱 char email[128]; strncpy(email, username, sizeof(email) - 1); email[sizeof(email) - 1] = '\0'; char* saveptr = NULL; char* part = strtok_r(email, "@.", &saveptr); while (part != NULL) { printf(" 邮箱部分: %s\n", part); part = strtok_r(NULL, "@.", &saveptr); } return 1; } int main() { // 打开日志文件 errno = 0; FILE* fp = fopen("server.log", "r"); if (fp == NULL) { perror("打开日志文件失败"); return 1; } // 逐行读取 char line[512]; char username[128]; while (fgets(line, sizeof(line), fp) != NULL) { // 去掉换行符 line[strcspn(line, "\n")] = '\0'; int ret = parse_log_line(line, username, sizeof(username)); if (ret == 1) { printf("发现错误日志,用户: %s\n", username); } else if (ret < 0) { fprintf(stderr, "解析失败,错误码: %d\n", ret); } } fclose(fp); return 0; }

这段代码用到了:

  • strstr:找子串

  • strchr:找单个字符

  • strncpy:安全拷贝

  • strtok_r:可重入分割

  • fopen+errno+perror:错误处理

  • strcspn:找换行符位置

这才是真实的工程代码。每个函数都有它存在的理由,每个边界都要处理。


七、面试官的追问路径:你能撑到第几层

面试官问 strstr,不会只问“怎么写”。他会一层一层往下追:

第一层:写一个 strstr。

第二层:你的循环条件为什么是*s1 && *s2 && *s1 == *s2?
答:防止越界读。如果s1或s2到了'\0',立即停止。

第三层:空模式串怎么处理?
答:返回原串。C 标准规定空字符串是任何字符串的子串。

第四层:最坏复杂度是多少?什么时候退化?
答:O(n×m)。主串和模式串都是"aaaa...a"这种重复字符时退化。

第五层:怎么优化?
答:KMP,O(n+m)。或者 BM、Two-Way。

第六层:工程里 glibc 怎么实现的?
答:短模式串用暴力优化版,长模式串用 Two-Way 算法。

第七层:如果主串是流式的(不能回退),怎么办?
答:用 KMP 或 AC 自动机,因为它们的主串指针不回退。

问到第七层,能撑住的人不多。但每一层都是你理解深度的体现。


八、前沿视角:字符串匹配在今天的真实战场

字符串匹配不是“老古董”。它在这些领域非常活跃:

生物信息学:DNA 序列匹配,主串几亿个碱基,模式串几十个。KMP、后缀数组、FM-index 都是核心算法。

网络安全:入侵检测系统(Snort、Suricata)要同时匹配几万条规则。用的是 AC 自动机、Wu-Manber,比 KMP 更适合多模式。

编译器:词法分析用正则表达式引擎,底层是 DFA/NFA,但简单的关键字匹配还是用字符串查找。

数据库:LIKE '%pattern%'的底层实现,可能用 KMP、BM 或后缀树。

现代工具:

  • Rust 的memchrcrate:用 SIMD 加速单字节查找,比 KMP 快 10 倍以上。

  • Google 的RE2:线性时间正则引擎,底层用自动机。

  • Hyperscan:Intel 的 high-performance 多模式匹配库,用 SIMD 加速。

但你要注意:这些高级工具都建立在基础算法之上。你不懂 KMP 的“利用已匹配信息”思想,就理解不了 AC 自动机;你不懂暴力匹配的边界处理,就写不出正确的优化版。


九、教育学视角:从“知道”到“会用”的三层递进

第一层:陈述性知识。知道strstr是找子串,strtok是分割字符串。这是“知道是什么”。

第二层:程序性知识。能写出正确的my_strstr,能处理空模式串、越界、返回值。这是“知道怎么做”。

第三层:条件性知识。知道什么时候用strtok,什么时候用strtok_r,什么时候自己写分割器;知道strerror和perror各自的适用场景。这是“知道什么时候用”。

大部分人停在第二层。面试官要的是第三层。

怎么练到第三层?在真实项目中用。写一个日志解析器,写一个 CSV 解析器,写一个 HTTP 请求行解析器。每写一个,你就多一层理解。


十、练习题:从入门到劝退

练习 1(入门):修复 strstr 的越界问题

c

char* my_strstr(const char* str1, const char* str2) { assert(str1 && str2); if (*str2 == '\0') return (char*)str1; const char* cur = str1; while (*cur) { const char* s1 = cur; const char* s2 = str2; while (*s1 && *s2 && *s1 == *s2) { s1++; s2++; } if (*s2 == '\0') return (char*)cur; cur++; } return NULL; }

练习 2(进阶):写一个不修改原串的 split 函数

c

#include <stdio.h> #include <string.h> void split_safe(const char* str, char delim) { const char* start = str; const char* p = str; while (1) { if (*p == delim || *p == '\0') { printf("[%.*s]\n", (int)(p - start), start); if (*p == '\0') break; start = p + 1; } p++; } } int main() { split_safe("a,,b,c", ','); return 0; }

练习 3(进阶):用strtok_r重写邮箱分割

c

#include <stdio.h> #include <string.h> int main() { char email[] = "zhangsan@163.com"; char* saveptr = NULL; char* part = strtok_r(email, "@.", &saveptr); while (part != NULL) { printf("%s\n", part); part = strtok_r(NULL, "@.", &saveptr); } return 0; }

练习 4(劝退):实现一个“查找所有匹配位置”的 strstr

c

#include <stdio.h> #include <string.h> #include <assert.h> void strstr_all(const char* str1, const char* str2) { assert(str1 && str2); if (*str2 == '\0') return; const char* cur = str1; while (*cur) { const char* s1 = cur; const char* s2 = str2; while (*s1 && *s2 && *s1 == *s2) { s1++; s2++; } if (*s2 == '\0') { printf("匹配位置: %ld\n", cur - str1); } cur++; } } int main() { strstr_all("aaaa", "aa"); // 输出: 0, 1, 2 return 0; }

练习 5(劝退):写一个完整的错误处理宏

c

#include <stdio.h> #include <errno.h> #include <string.h> #include <stdlib.h> #define CHECK_NULL(ptr, msg) \ do { \ if ((ptr) == NULL) { \ fprintf(stderr, "%s: %s\n", msg, strerror(errno)); \ exit(EXIT_FAILURE); \ } \ } while (0) int main() { errno = 0; FILE* f = fopen("nonexistent.txt", "r"); CHECK_NULL(f, "打开文件失败"); fclose(f); return 0; }

输出:

text

打开文件失败: No such file or directory

这个宏用到了do { ... } while (0)技巧,保证在if语句中安全展开。面试常考。


十一、收尾

今天我们从“日志解析器”这个真实需求出发,把四个函数串成了一条链:

  1. strstr:找子串。核心是指针算术和边界处理。最坏 O(n×m),KMP 可以优化到 O(n+m)。

  2. strtok:切分字符串。有状态、非线程安全、修改原串。工程里用strtok_r或自己写。

  3. errno:错误暗号。线程局部存储,成功不清零,判断前要手动清零。

  4. strerror / perror:翻译错误码。perror更省事,strerror更灵活。

如果面试官问你:“strstr 的循环条件为什么要判*s1 && *s2?”
你就答:防止越界读。'\0'和'\0'相等,如果不判,指针会越过字符串末尾。
如果问:“strtok 为什么不是线程安全的?”
你就答:它用静态变量保存上次位置,多线程会互相覆盖,要用strtok_r。
如果问:“perror 和 strerror 有什么区别?”
你就答:perror自动读errno、输出到stderr、自带前缀;strerror只返回字符串。

把这三点吃透,这四个函数就不再是“背原型”,而是你面试和工程里的基本盘。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询