ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C语言字符串函数进阶:strtok分割、strstr查找与缓冲区溢出避坑

C语言字符串函数进阶:strtok分割、strstr查找与缓冲区溢出避坑 做C语言开发这些年几乎天天跟字符和字符串打交道。字符函数和字符串函数这套API看着简单真正用起来却藏着不少门道。上一篇我们聊了基础用法和常用场景这篇集中讲进阶内容strtok()到底怎么安全分割字符串strstr()在长文本里查找子串有哪些边界陷阱怎样判断一个字符串是不是“纯数字”顺带分享一些我在项目里踩过的缓冲区溢出的坑。无论你是刚接触C语言的新手还是已经写了两年代码但对字符串处理还不太放心的朋友这篇的内容都可以直接拿到项目里去验证。字符串这东西在C语言里没有原生类型一切都要靠字符数组、字符指针和标准库函数来模拟。很多人写业务代码时能跑通但一旦遇到中文输入、连续分隔符、或者内存越界就开始怀疑人生。这篇文章我想做一个真正的“从入门到防坑”的进阶总结把底层逻辑、核心函数、内存安全、数字判断以及几个真实场景里的高频问题都串起来讲一遍。1. 字符函数与字符串函数的分工与底层逻辑1.1 谁处理字符谁处理字符串C语言标准库里字符处理和字符串处理是两套独立体系。字符函数住在ctype.h里处理的是单个字符isalpha、isdigit、isalnum、isspace、toupper、tolower……它们的参数类型是int返回值也是int而不是你以为的char。为什么非得用int因为isalpha这一类的函数在设计时要兼容EOF。EOF的值是-1如果你传char类型进去在char默认有符号的平台上还好说在char无符号的平台很多ARM平台就是如此上char的0xFF会变成255而EOF是-1函数内部往往用int类型去匹配255和-1并不会被当成同一个东西结果就是逻辑错乱但编译不出任何警告。字符串函数在string.h里全部围绕\0做文章。strlen靠数\0之前的字符个数来算长度strcpy靠遇到\0来停止复制。这也意味着你的字符数组如果忘了写\0strlen会冲出数组边界一路读到内存里碰巧是0的位置。这种“未定义行为”在线上最难排查因为你不知道它哪一次会崩。所以我的习惯是定义字符数组时显式初始化成0或者用sizeof配合memset清零。凡是外部传入的字符串入口处第一时间判断指针是不是NULL再判断第一个字符是不是\0很多时候可以拦截掉一半的线上问题。1.2 字符数组与字符指针能改和不能改的差别新手最容易忽略的是这两行的天壤之别char s1[] hello; char *s2 hello;s1是一个在栈上分配的6字节数组内容是h、e、l、l、o、\0每个字节都可以改写。s2是一个指针它指向编译器放在只读数据段里的字符串常量。你可以让s2重新指向别的地方但你绝不能通过s2去修改那5个字母和那个\0。谁改了谁就是段错误。这个区别直接影响一个很著名的函数——strtok()。因为strtok会修改传入字符串里的分隔符为\0如果你把字符串常量直接传给strtok程序运行到第一次分割时就会崩溃。正确做法是把字符串先拷到可写缓冲区里再做分割。顺带提醒一句函数形参写成char s[]和char *s没有本质区别都是指针。所以不要在函数内部用sizeof(s)去算数组大小那是指针大小几乎一定算错。2. 核心函数深度拆解strtok()、strstr()与拼接函数2.1 strtok()分割字符串的完整机制字符串分割是日常需求里高频中的高频。配置文件解析、CSV读取、命令行参数拆分都躲不开。strtok()的原型是char *strtok(char *str, const char *delim);它内部维护一个静态指针用来记录“上次分割到哪里了”。第一次调用时你传字符串本身它找到第一个分隔符把分隔符置为\0返回字符串开头的指针。第二次开始你传NULL它从静态指针记录的位置继续找下一个分隔符。以此类推直到全部处理完返回NULL。这个设计带来的三个坑我必须挨个说清楚。第一个坑是修改原字符串。它把分隔符替换成\0原来的字符串被“切割”了如果你后面还想用原始完整内容必须提前做副本。副本可以用strdup()函数内部会动态分配内存记得free。第二个坑是线程不安全。因为那个静态指针是全局共享的两个线程同时分割两个不同字符串状态会互相污染。多线程环境务必用strtok_r()char *strtok_r(char *str, const char *delim, char **saveptr);saveptr由调用者自己提供一个char*变量函数把当前分割位置写进去。这样每个调用序列都有独立状态互不干扰。第三个坑是连续分隔符。strtok会把连续出现的分隔符当成一个整体跳过。比如按逗号分割a,,b,c你会得到a、b、c中间那个空字符串丢失了。如果业务上需要保留空字段比如解析CSV时空字段是有含义的strtok就没法满足得自己写一个单字符分隔的循环。下面给一个完整的分割示例把字符串按逗号分割char buffer[128]; char *input apple,banana,,orange; char *save NULL; char *token; strncpy(buffer, input, sizeof(buffer) - 1); buffer[sizeof(buffer) - 1] \0; token strtok_r(buffer, ,, save); while (token ! NULL) { printf(token: %s\n, token); token strtok_r(NULL, ,, save); }注意这里我先用strncpy把input拷到buffer里而不是直接把input传给strtok这正是为了避免修改原字符串。如果你用的是传统的strtok就把save那个参数去掉但单线程里没问题多线程一定要用strtok_r。关于strtok的返回值要再强调一次除了第一次调用外strtok返回的都是子串的起始地址指向的位置是原buffer内部。也就是说这些子串并没有独立分配内存它们和buffer是共生的。buffer改动了、生命周期结束了token也就失效了。所以不要让函数返回一个strtok的token供外部长期持有。2.2 strstr()与strchr()查找子串的边界问题strstr()干的事情是在一个字符串里找另一个字符串第一次出现的位置。它返回一个指针指向找到的子串开头。找不到返回NULL。很多人对它的理解停留在“找到了”却忽略了返回值还能用来做子串提取。比如解析HTTP头时你想取Content-Length后面的值可以先用strstr找到这行的起点再用strchr找到冒号再把指针挪到数字开头用strtol转换。整个过程都是指针挪动不需要临时拷贝效率高且干净。但边界问题也在这里strstr返回的指针指向原字符串内部如果你要提取子串需要根据起始位置和长度自己malloc一块内存再memcpy。这里最常见的错误是忘了给\0留位置malloc(strlen(sub))就复制导致最后落地的字符串没有终结符printf打印时越界。另一个容易错的地方是strchr和strrchr的区别。strchr返回从开头数第一次出现的字符strrchr返回最后一次出现的字符。比如解析路径时想取文件名应该用strrchr(path, /)而不是strchr。用错了在路径只含一个斜杠时看不出问题遇到多级目录就会拿到错误的那一段。要注意别对NULL指针调用strchr或者直接解引用strstr的返回值。实际写代码时我习惯先把结果存下来判断非NULL再做后续操作避免一长串表达式里解引用NULL导致崩了还不好定位。2.3 拼接操作strcat/strncat的容量隐患字符串拼接strcat大家都会写但它是缓冲区溢出的重灾区。strcat(dest, src)完全不检查dest到底还剩多少空间它只负责一直复制到src的\0为止。如果dest的数组小了写穿栈是分分钟的事。很多人换成strncat就想当然地认为安全了。strncat(dest, src, n)其实有个隐蔽行为它最多从src复制n个字符然后在dest末尾追加一个\0。注意这个n是“最多复制的源字符数”不是“dest的总容量”。换句话说你如果在一个能装20个字符的dest里已经放了10个字符再向后面拼接src你最多允许复制10个字符预留1个给\0——这也需要你自己算好。对比一下strncpy和strncat的行为能更清楚地看到两个函数的设计哲学差异strncpy(dest, src, n)复制最多n个字符如果src长度小于n补\0如果src长度大于等于n它就只复制n个字符不会自动补\0。strncat(dest, src, n)总是追加\0但n表示最多复制的源字符数。所以用strncpy之后必须手动补\0这是我见过新手最容易踩的沉默杀手。你在目标数组末尾改成buf[n-1] \0一行代码能救回无数个线上崩溃。更优雅的做法是用snprintf代替这两类拼接。snprintf(dest, sizeof(dest), %s%s, dest, src)看起来绕但它既安全又能一次解决格式化问题。后面第3节我会专门展开。3. 内存安全与缓冲区溢出字符串操作的生死线3.1 复制字符串的安全之道先看这段经典风险代码char src[64]; char dest[8]; strcpy(dest, src);如果src实际长度超过7strcpy硬生生把dest后面的内存写穿。栈上的返回地址一旦被覆盖程序就可能被劫持——这已经不仅是“崩溃”的问题了。我自己的原则是目标缓冲区长度已知时优先用snprintf或memcpy手动补\0。不需要指定长度时用strdup直接复制并申请恰好大小的内存用完free。尽量少用strcpy和strcat这两兄弟在代码评审里应该被视为红色警报。strcpy向固定大小的缓冲区复制内容就像不管货柜的体积硬要把整集装箱的货都塞进去塞不下的部分只能往外溢溢出的货会掉进旁边的区域。旁边的区域可能是另一个变量、一段返回地址甚至是一小块堆内存的管理信息。这里要讲讲strncpy的细节。strncpy(dest, src, n)的n指的是目标数组能容纳的最大字符数。如果src长度小于n函数会用\0把剩余的空间全部填充性能不够好但结果安全。如果src长度大于等于n那dest里没有任何\0后续strlen越界几乎是必然的。提示strncpy之后无论如何都要在目标数组的最后一格手动补\0这是很多线上bug的共同源头。3.2 snprintf一个函数搞定格式化与截断snprintf(dest, size, format, ...)是目前我认为最值得推荐的字符串安全输出函数。它的第二个参数始终传目标缓冲区大小函数内部保证最多写size-1个字符然后写入\0。即使内容被截断dest也始终是合法字符串。它有另一个隐蔽优势返回值是“如果空间足够时应当写入的字符数”注意不包括\0。所以你可以先用snprintf(NULL, 0, %s, src)来探测需要的长度再动态分配。这在构造动态字符串时非常有用也不需要反复猜测缓冲区大小。举例动态地拼接一个用户名和域名int need snprintf(NULL, 0, %s%s, user, domain); char *email malloc(need 1); if (email ! NULL) { snprintf(email, need 1, %s%s, user, domain); }我记得第一次用这个技巧是在写一个国际化的邮件格式化模块时以前固定数组常被中文或者超长用户名撑爆改用snprintf探测长度后这个模块再没出过问题。snprintf还能用来做安全的数字转换。比如把int转字符串snprintf(buf, sizeof(buf), %d, value)比itoa更安全因为itoa是平台相关函数标准库里没有。在调试网络协议时经常要把端口号、长度字段转成字符串拼接snprintf一把梭。4. 数字字符串判断从纯手写到标准库再到SQL4.1 C语言判断数字字符串的几种方案“判断数字字符串”是非常实际的需求。比如你从配置文件读到的value是字符串但它到底是123这样的数字、还是12.5这样的浮点、还是abc这样的垃圾决定了后续能不能安全地atoi或者atof。atoi一族函数有迷惑性atoi(12abc)返回12atoi(abc)返回0。你是不是觉得它是不是合法的判断不出来这就很危险你无法区分“字符串是0”和“字符串不是数字”。所以atoi、atof不适合做校验。方案一纯手工遍历。对每个字符做检查逻辑最可控int is_integer_str(const char *s) { if (s NULL || *s \0) { return 0; } if (*s || *s -) { s; if (*s \0) { return 0; } } while (*s ! \0) { if (!isdigit((unsigned char)*s)) { return 0; } s; } return 1; }这里有一个细节isdigit的参数应该强转为unsigned char否则遇到扩展ASCII字符在高位为1时可能会触发未定义行为前面讲字符函数时也提过。方案二用strtol做完整判断。strtol在转换结束后会通过endptr告诉你消费到了哪个位置。如果endptr没有移动过说明开头就非法如果endptr没到字符串末尾说明后面还有残留字符。两者都能判断清楚int is_integer_str2(const char *s) { if (s NULL || *s \0) { return 0; } char *end NULL; errno 0; long v strtol(s, end, 10); (void)v; if (errno ERANGE) { return 0; } if (end s || *end ! \0) { return 0; } return 1; }这里还要考虑溢出问题如果数字超出long范围strtol把errno置为ERANGE。如果不检查errno超长数字会被认为合法这在对接外部输入时是隐患。另外strtol会跳过前导空白符。如果你不希望接受 123这种输入可以再加一个判断首字符必须是数字、正负号之一。方案三正则表达式。C标准库没有正则但如果你引入POSIX的regex.h或者项目里用了PCRE、oniguruma这类库那直接用^[0-9]$之类匹配就行。不过为了一个数字判断引入整个正则库性价比不高我更倾向前面两种。4.2 在DB2 SQL里怎么判断数字字符串这个需求同样出现在数据库侧。数据库里有一列varchar存着用户填的表单号码你要把那些“长得像数字”的行筛出来以便做聚合。不同数据库的写法差异很大这里以DB2为例讲两个思路。思路一利用TRANSLATE把非数字字符替换掉。DB2的TRANSLATE可以做字符映射TRANSLATE(column, , abcdefghijklmnopqrstuvwxyz)你把字母全部映射成空字符串如果结果非空说明除了字母以外还有别的非数字存在如果结果和原字符串一样长说明原字符串根本没有字母。这个思路再加一层CASE WHEN判断就能处理纯数字校验。思路二尝试CAST。DB2允许在WHERE里直接写CASE WHEN column TRIM(CAST(CAST(column AS DECIMAL) AS CHAR(30))) THEN Y ELSE N END当字符串不是合法数字时CAST会报错所以一般要配合异常处理语句或函数不同DB2版本写法略有差异。比较稳妥的是在上游先排除字母字符再对剩余内容做数值转换。在数据库里判断数字字符串本质原理和C语言是一样的要么逐个字符验证字符集要么让解析器去解析并捕获异常。只是SQL的抽象层级高会更依赖数据库的函数特性。你在做数据清洗时尽量在业务代码入口就把字段类型校验好别把脏数据带到库里再挣扎这样效率通常更高。5. 常见问题排查与避坑实录5.1 高频问题速查表做字符串处理我整理了一个速查表基本覆盖了绝大多数C语言字符串的崩溃现场现象根因处理建议程序运行到strtok时崩溃传入的是字符串常量或不可写内存先用strncpy/strdup拷贝到可写缓冲区strlen返回异常大的值字符数组没有初始化或者没有\0显式初始化或分配时清零strncpy后printf乱码strncpy在源长n时不补\0必须手动buf[n-1]\0函数里sizeof(s)算错形参里的char s[]实际是指针用strlen或者额外传数组长度参数多线程同时用strtokstrtok内部静态指针冲突换成strtok_risalpha/isdigit不工作传了char负值或扩展ASCII参数强转unsigned charatoi(12abc)得到12没做整体格式校验用strtolendptr或手写遍历strstr返回值不能安全使用可能在解引用NULL时崩了先用if判断返回值再使用中文串截断后乱码按字节截断切碎了多字节字符按字符边界处理或用宽字符函数DB2里CAST数字字符串报错脏数据混入非数字字符先用TRANSLATE排字符再做CAST这张表里的每一条都是我或者同事在真实项目里踩过的。遇到字符串相关bug时先对着这个表过一遍往往比看半天堆栈更快。5.2 一些让代码更健壮的习惯经验到了最后往往是几个简单习惯的堆叠。第一字符串函数入口统一做空指针和空串检查。任何函数如果接收外部传进来的字符串指针前两行一定是if (str NULL || *str \0) { return DEFAULT_VALUE; }这样写基本不会影响性能但能把崩溃率降下一个量级。第二凡是向缓冲区内写入都带一个“容量”参数。不管是用snprintf还是strncpy或者memcpy都要明确写入上限。项目里可以约法三章不允许直接使用strcpy、sprintf、strcat新代码一律使用snprintf系列和strn系列。第三谨慎对待“在同一个缓冲区上反复拼接”。每次拼接都重新计算剩余空间而不是预计一次就够。曾经有位同事在循环里往固定数组拼接100条日志消息第60条开始就越界但数组后面刚好是另一个无关变量导致问题直到数据量增大才暴露排查了两天。事后我们约定所有循环拼接一段一律用动态缓冲加snprintf。第四中文和多字节字符集的场景strlen返回的是字节数而不是字符数。比如你好在UTF-8下是6个字节如果你的截断逻辑按字节数去截很可能截出半个中文字符。这种情况要么按字符边界处理要么改用宽字符函数wcslen、wprintf或者直接在上层用处理Unicode的库。最后再分享一点字符串处理没有银弹每个函数都是在确定性、安全性和效率之间做权衡。以我个人的经验与其追求某个“万能函数”不如在团队里统一一套字符串处理规范把这些坑写进代码评审checklist里。我自己的工具箱里snprintf、strtok_r、strncpy加手动补结束符这三样用得最多也最放心。遇到字符串相关的问题先从边界、可写性、线程安全这三个角度排查绝大多数坑都能迅速定位。希望这篇进阶总结能给你省下一些查bug的时间。
返回列表