一、先别背函数原型:你真正要解决的是什么问题
假设你现在要写一个日志解析器,输入长这样:
text
2026-09-29 10:23:45 [ERROR] user=zhangsan@163.com msg=connection timeout
你要做四件事:
从这一行里找到
"ERROR"子串,判断是不是错误日志。把
zhangsan@163.com按@和.切分成zhangsan、163、com。如果读取日志文件失败,把错误原因打印出来。
程序里到处都要检查错误,得有一套统一的错误处理方式。
这四个需求,恰好对应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, "@."):
从
arr[0]开始扫描,跳过开头的分隔符(这里没有)。记下起点
start = &arr[0]。继续扫描,直到遇到
@(arr[8])。把
@改成'\0'。静态指针
saved指向@后面的位置(&arr[9])。返回
start,即"zhangsan"。
第二次调用strtok(NULL, "@."):
因为
str == NULL,从静态指针saved继续。saved指向'1',不是分隔符。记下起点
start = &arr[9]。继续扫描,直到遇到
.(arr[12])。把
.改成'\0'。saved指向&arr[13]。返回
"163"。
第三次调用:
从
saved开始,扫描到末尾。没有遇到分隔符,返回
"com",saved置为NULL。
第四次调用:
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 标准库的“错误暗号”。
标准库函数出错时,会做两件事:
返回一个表示失败的值(比如
NULL、-1、EOF)。把
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));
区别:
| 特性 | strerror | perror |
|---|---|---|
| 返回值 | 字符串指针 | 无 |
| 输出位置 | 由调用者决定 | stderr |
| 是否自动读 errno | 否,要手动传 | 是 |
| 格式 | 无前缀 | 前缀: 错误信息\n |
| 线程安全 | 可能不安全 | 取决于 strerror |
什么时候用哪个?
需要把错误信息拼进自定义格式:用
strerror。出错直接打印一行日志:用
perror。多线程环境:用
strerror_r+ 自定义输出。
那个循环打印 200 个错误码的代码
c
for (i = 0;i < 200;i++) { printf("%d: %s\n",i,strerror(i)); }这段代码在学习阶段非常有价值。它让你看到系统定义了哪些错误码。但工程里不要这么写,因为:
错误码不保证从 0 到 199 连续。
有些错误码对应
"Unknown error"。strerror可能不是线程安全的。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语句中安全展开。面试常考。
十一、收尾
今天我们从“日志解析器”这个真实需求出发,把四个函数串成了一条链:
strstr:找子串。核心是指针算术和边界处理。最坏 O(n×m),KMP 可以优化到 O(n+m)。
strtok:切分字符串。有状态、非线程安全、修改原串。工程里用
strtok_r或自己写。errno:错误暗号。线程局部存储,成功不清零,判断前要手动清零。
strerror / perror:翻译错误码。
perror更省事,strerror更灵活。
如果面试官问你:“strstr 的循环条件为什么要判*s1 && *s2?”
你就答:防止越界读。'\0'和'\0'相等,如果不判,指针会越过字符串末尾。
如果问:“strtok 为什么不是线程安全的?”
你就答:它用静态变量保存上次位置,多线程会互相覆盖,要用strtok_r。
如果问:“perror 和 strerror 有什么区别?”
你就答:perror自动读errno、输出到stderr、自带前缀;strerror只返回字符串。
把这三点吃透,这四个函数就不再是“背原型”,而是你面试和工程里的基本盘。