ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

一次把 strstr、strtok、strerror、perror 用到骨子里:从面试题到真实工程现场

一次把 strstr、strtok、strerror、perror 用到骨子里:从面试题到真实工程现场 一、先别背函数原型你真正要解决的是什么问题假设你现在要写一个日志解析器输入长这样text2026-09-29 10:23:45 [ERROR] userzhangsan163.com msgconnection timeout你要做四件事从这一行里找到ERROR子串判断是不是错误日志。把zhangsan163.com按和.切分成zhangsan、163、com。如果读取日志文件失败把错误原因打印出来。程序里到处都要检查错误得有一套统一的错误处理方式。这四个需求恰好对应strstr、strtok、strerror、perror。今天我们就从这四个需求出发把每个函数吃透。教育学上这叫“情境认知”——知识不是孤立的它必须挂在一个真实任务上你才能记得住、用得出。二、strstr指针算术不是装饰是本质先看你修正后的代码cchar* 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(nm)可以用 KMP。核心是预处理模式串算出 next 数组失配时主串不回退。追问三工程里真的用 KMP 吗不一定。glibc 的 strstr 在长模式下用 Two-Way 算法常数比 KMP 小短模式下用暴力优化版。Rust 的memchr用 SIMD 加速单字节查找比 KMP 快很多。追问四如果模式串很长主串很短呢先比较长度strlen(str1) strlen(str2)直接返回 NULL省掉无意义的循环。三、strtokC 标准库中最“有状态”的函数你的代码cchar arr[] zhangsan163.com; char arr3[] .; char* p NULL; for (p strtok(arr, arr3); p ! NULL; p strtok(NULL, arr3)) { printf(%s\n, p); }strtok的设计非常特殊。它不是“纯函数”——同样的输入不同次调用返回不同结果。因为它内部维护了一个静态指针。strtok 的执行过程用zhangsan163.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开始扫描到末尾。没有遇到分隔符返回comsaved置为NULL。第四次调用saved NULL直接返回NULL。为什么 strtok 被工程界“嫌弃”三个原因原因一不是线程安全的。saved是静态变量所有线程共享。两个线程同时解析字符串会互相踩。解决方案是strtok_rcchar* 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不满足需求就自己写一个。四、errnoC 语言错误处理的“暗号”errno是什么表面上是一个int变量实际上是 C 标准库的“错误暗号”。标准库函数出错时会做两件事返回一个表示失败的值比如NULL、-1、EOF。把errno设成具体的错误码。比如fopen失败返回NULL同时errno被设成ENOENT文件不存在或EACCES权限不够。为什么不能只看 errno因为成功调用不保证清零 errno。cerrno 0; FILE* f fopen(exists.txt, r); // 假设这个文件存在 // f ! NULL但 errno 可能还是上次的旧值所以正确的判断方式是cerrno 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 files question is); }strerror(errno)返回错误码对应的字符串比如No such file or directory。perror(前缀)等价于cfprintf(stderr, %s: %s\n, 前缀, strerror(errno));区别特性strerrorperror返回值字符串指针无输出位置由调用者决定stderr是否自动读 errno否要手动传是格式无前缀前缀: 错误信息\n线程安全可能不安全取决于 strerror什么时候用哪个需要把错误信息拼进自定义格式用strerror。出错直接打印一行日志用perror。多线程环境用strerror_r 自定义输出。那个循环打印 200 个错误码的代码cfor (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可重入分割fopenerrnoperror错误处理strcspn找换行符位置这才是真实的工程代码。每个函数都有它存在的理由每个边界都要处理。七、面试官的追问路径你能撑到第几层面试官问 strstr不会只问“怎么写”。他会一层一层往下追第一层写一个 strstr。第二层你的循环条件为什么是*s1 *s2 *s1 *s2答防止越界读。如果s1或s2到了\0立即停止。第三层空模式串怎么处理答返回原串。C 标准规定空字符串是任何字符串的子串。第四层最坏复杂度是多少什么时候退化答O(n×m)。主串和模式串都是aaaa...a这种重复字符时退化。第五层怎么优化答KMPO(nm)。或者 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线性时间正则引擎底层用自动机。HyperscanIntel 的 high-performance 多模式匹配库用 SIMD 加速。但你要注意这些高级工具都建立在基础算法之上。你不懂 KMP 的“利用已匹配信息”思想就理解不了 AC 自动机你不懂暴力匹配的边界处理就写不出正确的优化版。九、教育学视角从“知道”到“会用”的三层递进第一层陈述性知识。知道strstr是找子串strtok是分割字符串。这是“知道是什么”。第二层程序性知识。能写出正确的my_strstr能处理空模式串、越界、返回值。这是“知道怎么做”。第三层条件性知识。知道什么时候用strtok什么时候用strtok_r什么时候自己写分割器知道strerror和perror各自的适用场景。这是“知道什么时候用”。大部分人停在第二层。面试官要的是第三层。怎么练到第三层在真实项目中用。写一个日志解析器写一个 CSV 解析器写一个 HTTP 请求行解析器。每写一个你就多一层理解。十、练习题从入门到劝退练习 1入门修复 strstr 的越界问题cchar* 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[] zhangsan163.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劝退实现一个“查找所有匹配位置”的 strstrc#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(nm)。strtok切分字符串。有状态、非线程安全、修改原串。工程里用strtok_r或自己写。errno错误暗号。线程局部存储成功不清零判断前要手动清零。strerror / perror翻译错误码。perror更省事strerror更灵活。如果面试官问你“strstr 的循环条件为什么要判*s1 *s2”你就答防止越界读。\0和\0相等如果不判指针会越过字符串末尾。如果问“strtok 为什么不是线程安全的”你就答它用静态变量保存上次位置多线程会互相覆盖要用strtok_r。如果问“perror 和 strerror 有什么区别”你就答perror自动读errno、输出到stderr、自带前缀strerror只返回字符串。把这三点吃透这四个函数就不再是“背原型”而是你面试和工程里的基本盘。
返回列表