ARTICLE DETAIL

资讯详情

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

C语言字符串函数详解:从strcpy陷阱到安全编码实践

C语言字符串函数详解:从strcpy陷阱到安全编码实践 有次排查一段串口数据解析模块的崩溃问题GDB 指向的是一行strcpy。目标缓冲区我开得足够大源字符串也是合法指针理论上怎么想都不该越界。查到最后发现源字符串是设备返回的二进制拼出来的末尾少了\0——strcpy不认字节数只认结束符于是一路向前扫到下一个零字节才停下。这类问题几乎每个写过 C 语言的人都遇见过。字符函数和字符串函数是 C 标准库里最常用也最容易被低估的一批工具表面看就是几个单词实际用起来边界条件多到让人怀疑人生。这篇文章我打算把 C 语言里字符分类、字符串操作、内存操作这几类函数从头到尾过一遍函数行为、返回值陷阱、常见误用以及对应的安全写法最后再对照一下 VB.NET 里的字符串函数设计看看别的语言是怎么处理同一类问题的。适合刚学完语法准备写真实项目的人也适合被线上 bug 折磨过想系统补课的老手。1. 先说清楚C 语言里字符串到底是个什么东西1.1 字符串是一块内存的视图不是独立类型C 语言没有字符串类型。所谓的字符串本质上是char数组或者更严谨地说是一段以\0结尾的连续内存。当你写hello的时候编译器实际上为你分配了 6 个字节h、e、l、l、o、\0最后一个位置就是终止符。所有字符串函数都依赖这个约定来工作strlen从起始地址开始计数遇到\0停止strcpy不断复制直到遇到\0才停。这意味着字符串函数的输入必须是以\0结尾的合法内存区域否则函数不知道在哪里停下来就会越界访问。我记得刚学 C 的时候特别不适应这一点总觉得字符串应该是一个东西能直接赋值、能比较、能拼接。但 C 不是这么设计的它只给你一块内存的起始地址剩下的约定比如结束符需要人和函数共同遵守。1.2 这个设计带来的连锁后果你不管理长度函数内部也不会替你管理越界是常态而不是异常。缓冲区溢出之所以在 C 程序里高频出现核心原因就是strcpy、sprintf这类函数不检查目标缓冲区够不够大。\0本身是数据的一部分但如果你要处理的二进制数据中间恰好有零字节字符串函数就会在这里截断。所以标准库才需要memcpy、memmove这类不关心\0、只认长度的内存函数作为补充。理解了这个底层的裸奔设计你才能解释很多经验之谈为什么人人都说strcpy危险为什么查字符串要用strstr而查字节要用memchr为什么strncpy有时候并不会给你拷贝一个完整的字符串所有答案都指向同一个根因——C 的字符串只是一个约定不是一个类型。1.3 字符数组、字符串字面量、字符指针容易搞混的三种写法很多刚入门的人分不清下面这些写法我先用一张表把它们的区别讲透写法实际内存布局能不能修改内容char s[] abc;栈上分配 4 字节a,b,c,\0可以修改s[0] x;char *p abc;字符串字面量通常存在只读区p指向首字符不能通过p修改内容否则是未定义行为char s[4]; strcpy(s, abc);预分配数组再把内容拷贝进去可以修改我自己就踩过第二行的坑。当年写一段小工具想统计某个字符串里字符出现的次数图省事直接对char *p some text做修改操作结果在某些平台上直接段错误换个平台又悄无声息地运行成功。这类问题是未定义行为表现可以完全不同排查起来特别耗时间。所以原则很简单要修改字符串内容就用char s[]这种数组形式只想读取才用指针指向字符串字面量。2. 字符分类与转换ctype.h 这套体检工具你用对了吗2.1 字符分类函数全景ctype.h里的函数承担一个很基础也很重要的任务判断一个字符属于哪一类。它们看起来简单但属于高频使用的工具比如写词法分析、过滤输入、校验数据格式等场景都离不开。函数判断内容典型例子isalnum字母或数字isalnum(a)为真isalnum(!)为假isalpha英文字母isalpha(Z)为真isblank空格或水平制表符isblank( )为真iscntrl控制字符iscntrl(\n)为真isdigit十进制数字isdigit(7)为真isgraph除空格外的可打印字符isgraph()为真islower小写字母islower(a)为真isprint可打印字符含空格isprint( )为真ispunct标点符号ispunct(!)为真isspace空白字符isspace(\t)为真isupper大写字母isupper(A)为真isxdigit十六进制数字isxdigit(f)为真这些函数解决的问题很直接你不用自己写出(c a c z)这种既容易错、又不具备可移植性的判断。依赖 ASCII 码表做范围判断的代码换一个不是 ASCII 的环境就可能出问题而ctype.h的函数按当前 locale 来定义字符分类行为更规范。2.2 返回值和参数里最容易被忽略的坑这几个函数有一个共同点参数要求是unsigned char类型的值或者是EOF。如果你传入的是一个普通的char而你的平台默认char是有符号的那么当一个字符的 ASCII 码大于 127 时它会被当作负数传进去此时行为是未定义的。我之前写过一个数据清洗的程序从 GBK 编码的文件里读取字节直接把这些字节传给isalpha做过滤结果在编译和运行中偶尔出现完全不可预期的结果。后来查标准才明白必须先把字节转成unsigned char再调用。// 错误示范c 是 char 类型可能是有符号的传入 isalpha 是未定义行为 char c some_byte; if (isalpha(c)) { ... } // 正确做法显式转成 unsigned char if (isalpha((unsigned char)c)) { ... }返回值也不是简单返回 0 或 1而是非零表示成立零表示不成立。所以不要直接拿函数返回值跟 1 比比如if (isalpha(c) 1)就是错误写法应该只判断真假if (isalpha(c))。2.3 实战写一个清洗函数把一个字符串里的非字母数字字符去掉并把大写字母转成小写这是很常见的输入预处理逻辑。用上面的函数可以很简洁地实现同时避开前面提到的坑#include ctype.h #include stdio.h void sanitize(const char *src, char *dst, size_t dst_size) { if (dst_size 0) { return; } size_t j 0; for (size_t i 0; src[i] ! \0; i) { unsigned char c (unsigned char)src[i]; if (isalnum(c)) { if (j 1 dst_size) { break; } dst[j] (char)tolower(c); } } dst[j] \0; } int main(void) { char out[64]; sanitize(Hello, World! 123, out, sizeof(out)); printf(%s\n, out); // 输出 helloworld123 return 0; }这里有几个细节值得展开。第一每次用tolower之前注意它的参数类型和返回值类型它接收int实际要求同样必须是unsigned char或EOF所以我先把字符转成了unsigned char。第二我在把字符写入目标缓冲区之前检查了剩余空间避免越界。第三循环结束后手动补了一个\0这提醒我们任何用循环拼接字符串的代码最终都要保证终止符存在。如果你只是简单地在命令行里过滤一段文本上面的代码已经够用。但如果在解析协议、处理文件名、拼接路径等更高风险的场景我会建议把清理输入做成通用工具函数放进公共模块把所有涉及ctype.h的调用都集中起来方便后续统一审计。3. 字符串基础操作strlen、strcpy、strcat、strcmp 的用法与隐患3.1 strlen 的无符号类型陷阱strlen返回size_t也就是无符号整数。这意味着一个小到不能再小的问题不要拿strlen的结果和带符号的负数比较。if (strlen(s) -1) { // 永远为假-1 会被转成巨大的无符号数 }这个比较看起来离谱但真实代码里会出现类似的场景比如计算剩余空间时remaining total - strlen(s)如果strlen(s)大于total因为size_t是无符号的结果会变成一个非常大的正数后续判断就全部失灵。我的习惯是凡涉及长度计算提前把size_t转换成ptrdiff_t或int并做好上限检查。还有一个常见性能问题在循环里不断调用strlen。比如for (size_t i 0; i strlen(s); i) { ... }每次迭代都要把整个字符串从头扫一遍字符串越长越慢。正确做法是提前保存长度size_t len strlen(s); for (size_t i 0; i len; i) { ... }3.2 strcpy 和 strcat危险三兄弟中的两位strcpy(dst, src)的行为很简单从src逐个复制字节到dst直到遇到\0并把它也复制过去。问题在于它完全不知道dst有多大。一旦src的实际长度加上终止符超出dst空间越界写入就发生了。这不是可能出错而是在大多数情况下都会破坏相邻内存的数据轻则变量被篡改重则程序崩溃。strcat(dst, src)就更隐蔽它先找到dst末尾的\0然后从那里开始追加src的内容。所以目标缓冲区必须能容纳原字符串长度 追加字符串长度 1。strcpy和strcat还有一个共同限制源和目的不能重叠。比如strcpy(p, p 1)这类操作标准明确说是未定义行为。如果要处理重叠得用后面要讲的memmove。3.3 strcmp不要以为返回值只有 -1、0、1strcmp(s1, s2)按字典序比较两个字符串返回值小于 0 表示s1小于s2等于 0 表示相等大于 0 表示s1大于s2。注意它返回的可能是-3、5这些任意数值不是只可能返回-1或1。如果你写if (strcmp(a, b) -1)来判定a 小于 b逻辑上就错了应当写if (strcmp(a, b) 0)。判断相等的时候有人喜欢if (!strcmp(a, b))这能跑但不能说清晰我推荐写成if (strcmp(a, b) 0)让阅读代码的人一眼就能读懂意图。3.4 三个 n 版本函数行为差异大到离谱函数行为典型坑strncpy(dst, src, n)最多复制 n 个字符如果src长度小于 n剩余位置填\0如果src长度大于等于 n不写终止符目标可能不是 C 字符串必须手动补\0strncat(dst, src, n)最多追加 n 个字符并且总会追加一个\0目标缓冲区至少需要strlen(dst) n 1字节strncmp(s1, s2, n)最多比较 n 个字符遇到\0会提前停止一般用于判断前缀注意它是按字节比较不是按字符为什么strncpy会设计成满了就不补终止符因为它的起源是在早期的 UNIX 文件系统里处理固定长度的目录项字段那时候字段存储在定长数组中不要求以\0结尾。到了通用字符串拷贝的场合这个历史包袱就成了经典的坑。我见到最多的错误是char buf[8]; strncpy(buf, a very long string, sizeof(buf)); // buf 没有以 \0 结尾后续 strlen(buf) 可能会越界如果要用strncpy务必自己补终止符而且要提前留一个字节的位置char buf[8]; strncpy(buf, a very long string, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0;在 C 语言最新标准和很多实际工程环境里我更推荐直接用snprintf(dst, sizeof(dst), %s, src)来做拷贝用snprintf(dst offset, sizeof(dst) - offset, %s, src)来做追加。它总是保证输出以\0结尾并且知道目标缓冲区大小比strncpy直观得多。4. 子串查找与内容解析strchr、strstr、strtok 的高频场景4.1 strchr 和 strrchr定位单个字符strchr(s, c)返回s中第一个出现字符c的指针找不到返回NULL。strrchr则是从右往左找返回最后一个出现的位置。这两个函数最常见的用法是在一个字符串里快速找分隔符。很多人不知道的是如果传入的c是\0strchr会返回字符串末尾终止符的指针这是合法且有用的可以快速拿到字符串结尾的位置char *end strchr(s, \0); // end 现在指向 s 末尾的 \0替换字符串里的某个字符是个经典应用。下面这段代码把s里所有空格替换成下划线注意循环的推进方式#include stdio.h #include string.h int main(void) { char s[] hello world foo; char *p s; while ((p strchr(p, )) ! NULL) { *p _; p; } puts(s); return 0; }关键点在于替换后p让下一轮strchr从替换后的下一个位置开始搜索这样连续空格也能正确处理不会死循环。4.2 strstr查找子串strstr(haystack, needle)在haystack中查找子串needle第一次出现的位置返回指向这个位置的指针找不到返回NULL。它的复杂度在一般场景下足够好不需要自己手写双重循环。一个要注意的边界是needle为空字符串此时标准规定返回haystack本身。这意味着拿到返回结果后除了判断非空还要留意是否需要区分找到了空子串和目标子串真的在开头两种情况具体看你的业务逻辑。strstr不会修改任何内容也不会像strtok那样破坏原始字符串所以它的使用压力小很多。4.3 strtok分割字符串的两个反直觉点strtok(s, delim)用来按分隔符分割字符串。第一次调用传入待解析的字符串之后传NULL告诉函数继续从上次的位置往后解析#include stdio.h #include string.h int main(void) { char line[] Alice,30,Engineer; char *token strtok(line, ,); while (token ! NULL) { printf(%s\n, token); token strtok(NULL, ,); } return 0; }输出Alice 30 Engineer这个函数有两个非常反直觉的特点第一它会修改原字符串。strtok找到分隔符后会把它替换成\0所以传入的字符串必须可写如果你传char *s Alice,30;这种指向字符串字面量的指针就会触发未定义行为。我看到不少人在这上面栽跟头最典型的是用一个const char *指向常量字符串然后调用strtok。第二函数内部有静态状态不是线程安全的也不能嵌套使用。如果你在解析外层时又在一个子函数里用strtok解析另一个字符串外层的上下文会被内层的调用覆盖。标准 C 里推荐用strtok_rPOSIX或strtok_sC11 可选它们多了一个调用者提供的指针参数来保存位置#include stdio.h #include string.h int main(void) { char line[] Alice,30,Engineer; char *saveptr NULL; char *field strtok_r(line, ,, saveptr); while (field ! NULL) { printf(field: %s\n, field); field strtok_r(NULL, ,, saveptr); } return 0; }4.4 实战用 strtok_r 解析 URL 查询参数URL 查询参数通常长这样nameTomage20cityNYC。解析思路很清晰先按分割出每一对键值再按分割键和值#include stdio.h #include string.h void parse_query(char *query) { char *save1 NULL; char *pair strtok_r(query, , save1); while (pair ! NULL) { char *save2 NULL; char *key strtok_r(pair, , save2); char *value strtok_r(NULL, , save2); if (key value) { printf(key%s, value%s\n, key, value); } pair strtok_r(NULL, , save1); } } int main(void) { char query[] nameTomage20cityNYC; parse_query(query); return 0; }这个例子里外层用save1保存分割的位置内层用save2保存分割的位置互不干扰这才体现了strtok_r相比strtok的实质价值。注意我在parse_query里接收的参数是可修改的char *query绝对不能传字符串字面量因为strtok系列要改写原字符串。5. 内存级操作memcpy、memmove、memset、memcmp 是另一套逻辑5.1 memcpy 和 memmove重叠内存必须用后者memcpy(dst, src, n)从src复制n个字节到dstmemmove(dst, src, n)也做同样的事区别在于如果源和目的内存区域有重叠memcpy的行为是未定义的而memmove保证能正确处理。为什么memcpy不能处理重叠因为它可能用逐字节向前复制的优化实现如果目标地址在源地址之后且两者重叠就可能在读取某个字节之前把它覆盖掉。而memmove会先判断方向必要时从末尾往前复制或者使用临时缓冲保证复制结果正确。char buf[] hello world; // 把 world 移动到 buf 开头得到 world memmove(buf, buf 6, 6);在这里如果用memcpy行为就不可预测。工程上我的建议是能确定不重叠就用memcpy它少做判断通常更快一旦有任何不确定性直接memmove性能差距在实际项目中通常可以忽略。5.2 memset、memcmp、memchr内存操作的另三员memset(ptr, value, n)把从ptr开始的n个字节设置为value。最常见的用途是清零结构体或数组struct config { int timeout; char name[32]; }; struct config cfg; memset(cfg, 0, sizeof(cfg));这里要提醒一句如果你在写 Cmemset只能用于没有虚函数、没有自定义析构函数等非平凡类型否则会破坏对象内部结构。纯 C 的场景则比较安全但也要注意sizeof(cfg)别写错成指针大小。memcmp(a, b, n)比较两块内存的前n个字节返回值和strcmp类似。区别在于它完全不理会\0一直比较满n个字节所以适合比较包含二进制数据的缓冲区。memchr(ptr, c, n)在ptr开始的n个字节中查找字符c返回第一个匹配位置的指针找不到返回NULL。它的价值在于即使内存块中间有\0它也不会提前停止因此可以用来在二进制数据里找某个字节。5.3 字符串函数 vs 内存函数到底用哪个需求该用哪个原因计算文本长度strlen结束位置由\0决定复制已知长度文本memcpy/memmove已经知道长度没必要依赖字符串匹配复制可能包含\0的数据memcpy/memmove字符串函数会在\0处截断比较两个文本是否相等strcmp/strncmp按字符串语义比较遇到\0停止比较任意内存块memcmp按指定长度比较完整的内存在字符串里找字符/子串strchr/strstr按字符串语义查找在内存块里找字节memchr不因\0提前停止理解这张表背后的逻辑其实就一句话字符串函数关心遇到\0就停内存函数关心明确指定多少字节。你处理的是文本就优先用字符串函数处理的是二进制协议、结构体、网络数据包就优先用内存函数。6. 横向对照VB.NET 里的字符串函数如何设计6.1 .NET 的字符串不可变、自带长度、运行时代管聊完 C 的字符串函数我想拿 VB.NET 做一次对照因为它的设计思路和 C 完全两个极端。在 .NET 里String是一个真正的类型底层保存着字符数组和长度信息运行时知道它有多长不需要\0做终止符。这带来两个直接结果第一所有字符串操作都不存在越界写的问题因为字符串是不可变的任何修改操作都会创建新的字符串对象原字符串保持不变。你不需要像 C 里那样反复计算目标缓冲区够不够大这个负担被运行时接管了。第二因为是引用类型且不可变把字符串传给函数不用担心被修改对照 C 里strtok会把原字符串改写的行为这个体验完全是另一个层次。6.2 VB.NET 里那些读起来像英语的函数VB.NET 从早期 BASIC 传统中继承了一批以动词命名的字符串函数和 C 的函数名风格差异很大VB.NET 函数作用对应的 C 思路Len(s)字符串长度strlenLeft(s, n)/Right(s, n)取左边/右边 n 个字符strncpy 手工偏移Mid(s, start, length)从中间截取一段strncpy 手工偏移InStr(s, sub)找子串位置strstr后减指针Replace(s, old, new)替换所有匹配的子串需要自己循环strstr处理Split(s, delimiter)按分隔符拆成数组strtokJoin(arr, delimiter)把数组拼成字符串需要自己拼接Trim(s)去掉首尾空格手写或调用辅助函数UCase(s)/LCase(s)转大小写toupper/tolower循环一个典型的 VB.NET 解析字符串的写法Dim text As String Hello,World Dim parts As String() text.Split(,c) Dim upper As String text.ToUpper()不需要手动malloc不需要担心\0不需要考虑缓冲区大小每一步都是高层抽象。Split之后返回的是一个真正的字符串数组而 C 里用strtok则是一段需要小心维护状态的循环。6.3 换一种语言能带走什么从 C 到 VB.NET 或反过来并不是简单替换函数名而是换一套思维模型。C 给的自由和责任很明确你需要自己管理内存、自己关心终止符、自己保证缓冲区。VB.NET以及大多数现代语言则把字符串封装成自带元数据、运行时会管理生命周期的对象。你不能说哪个一定更好只能说不同场景需要不同取舍。嵌入式、操作系统内核、高性能协议解析这些场景C 的字符串处理虽然原始但足够透明、没有隐藏分配性能和可控性都极其重要。而在业务应用开发中让运行时去处理字符串的分配与复制能省下大量心智负担。站在学习者的角度我反而觉得先啃 C 的字符串函数再学 VB.NET 或 Python 的字符串 API会比反过来的顺序更扎实。因为你看过strtok怎么改原字符串才更能理解高级语言为什么要返回一个新数组你看过strncpy不写终止符的坑才更能体会运行时保证字符串完整性这句话的分量。7. 实战排坑字符串处理中我踩过的典型问题7.1 strncpy 不保证\0结尾前面已经反复提到这个问题但值得用一个完整案例来加深印象。假设你要拷贝一个用户输入的字符串到一个固定大小的结构体字段里struct user { char name[16]; int age; }; struct user u; strncpy(u.name, input, sizeof(u.name));如果input的长度大于等于 16u.name就不会以\0结尾。接下来如果代码里调用printf(%s, u.name)printf会继续读u.name后面的内存也就是u.age甚至更后面的栈内容轻则打印乱码重则被安全扫描工具标记为信息泄露漏洞。我现在的习惯是任何strncpy之后紧跟一行u.name[sizeof(u.name) - 1] \0;或者干脆用snprintf替代snprintf(u.name, sizeof(u.name), %s, input);这样即使输入超长u.name也一定以\0结尾。7.2 malloc(strlen(s)) 忘了 1这是另一个高发错误。假设要拷贝一段字符串char *p malloc(strlen(s)); strcpy(p, s);strlen(s)只统计了不包含终止符的字符个数但strcpy会把\0也写进去所以至少要分配strlen(s) 1个字节。少一个字节就会越界写而且因为只越界一个字节往往不会立刻崩溃而是潜伏下来在某个不确定的时刻破坏堆结构导致程序行为诡异。正确写法char *p malloc(strlen(s) 1); if (p ! NULL) { strcpy(p, s); }检查malloc返回的NULL也不能省特别是在嵌入式环境或长时间运行的服务里分配失败是真实会发生的情况。7.3 strtok 的隐式状态导致嵌套解析失效有一次我写一个配置文件解析器外层用strtok按行分割内层再用strtok分割键值结果发现内层调用之后外层解析循环直接中断或跳到错误位置。原因就是strtok用静态变量保存当前位置内层调用把它覆盖了。解决办法就是换成strtok_r每个解析层次维护各自的saveptr。前面 URL 解析代码已经展示了正确姿势。记住一句话在多线程或嵌套解析的场景里永远不要使用strtok一律用strtok_r。如果你的目标平台没有strtok_r自己封装一个基于状态结构体的解析函数也不是难事。7.4 用 ASan 快速定位字符串越界字符串越界问题最难的地方在于它常常不在出错现场暴露。一个函数里越界写破坏的是另一个函数里的数据。我现在的第一反应不是瞪着代码猜而是直接开 AddressSanitizer 跑一遍gcc -fsanitizeaddress,undefined -g -o test test.c ./test如果程序有越界写、越界读、缓冲区溢出等问题ASan 会精确报告出问题的那一行代码以及相关的调用栈。我处理字符串崩溃的基本流程是先开 ASan 复现问题看到报告后顺着调用栈检查涉及字符串操作的参数尤其是目标缓冲区大小和源字符串是否以\0结尾。这个方法帮我解决过至少十几个看起来完全无规律的内存问题。配合 GDB 的使用场景通常是在 ASan 报告不够详细时编译时加上-g在可疑函数设置断点打印关键变量的地址和长度确认哪一侧出了问题。7.5 一个减少低级错误的封装习惯最后分享一个我日常写 C 代码时的习惯统一封装简单安全的拷贝、追加接口。不需要多复杂重点是把所有边界处理集中在同一个地方void safe_copy(char *dst, size_t dst_size, const char *src) { if (dst_size 0) { return; } snprintf(dst, dst_size, %s, src); }snprintf的好处在于它始终在目标缓冲区末尾写\0返回值是如果空间充足会生成的字符数不含\0可以用它判断是否发生了截断。追加类似只要注意从目标字符串当前末尾开始并计算剩余空间即可。在项目里统一使用这类封装之后我遇到的字符串相关崩溃明显减少。表面上看是少写了几行代码本质上是把所有危险的边界判断收拢到一处便于代码审查和测试覆盖。如果你也在维护一个有一定历史的 C 代码库我强烈建议把散落的strcpy、strcat逐步替换成这类安全封装或snprintf方案。老实说C 的字符串函数并不友好大多数坑来自语言没有原生字符串类型这个底层设计。但反过来想正因为如此用 C 处理字符串的过程逼着你把内存布局、指针语义、边界条件都搞清楚。把这一套真正弄明白之后再去接触任何其他语言的字符串接口你都会觉得轻松很多。
返回列表