如果你在C语言项目里搜索“段错误”出现次数最多的函数strcat一定排得进前三。我见过不少人一边骂strcpy不安全一边却对strcat毫无防备没有检查剩余空间、没有确认源字符串以\0结尾、甚至让源字符串和目标字符串指向同一块内存。直到日志模块连续崩溃三天或者现场设备在运行半年后突然死机才回过头来拆这个老函数。这篇来聊strcat的完整陷阱谱系。标题带“一”是因为这个系列后面还会专门拆strncpy、sprintf这些同批次问题函数但strcat值得先单独拎出来。适合正在学C语言的初学者也适合在嵌入式、服务端写C的老手看——很多坑不是你不知道而是你在代码评审时根本没想起它。1. strcat到底做了什么先拆开C标准背后的两个隐藏要求1.1 一次追加背后的“两遍扫描”strcat的原型大家应该都背过char *strcat(char *dest, const char *src);它的官方语义是把src指向的字符串追加到dest指向的字符串末尾并在最终结果后面补一个\0然后返回dest的起始地址。标准库实现几乎都是这个思路char *strcat(char *dest, const char *src) { char *p dest; while (*p ! \0) p; /* 第一遍找到目标串末尾 */ while ((*p *src)) ; /* 第二遍边复制边找源串末尾 */ return dest; }注意这里有两个独立的扫描动作。第一遍是寻找dest的终止符第二遍是从src的第一个字符开始复制直到复制完src的\0为止。从这个简单实现可以看出strcat没有做任何“空间判断”。它不知道dest指向的缓冲区到底有多少容量只知道“当前字符串在哪里结束就从哪里开始写”。这意味着它对你的信任是双重的它信任dest指向的缓冲区里从首地址开始确实存在一个合法的\0结尾字符串它信任从dest当前字符串末尾开始往后有足够的可写内存放得下整个src外加那个终止符。这两条信任恰恰是绝大多数Bug的源头。1.2 一个很容易忽略的点终止符也要占一个位置很多初学者算缓冲区长度时会漏掉最后的\0。比如char dst[10] hello; strcat(dst, world);dst当前长度是5src长度是5。看起来5加5等于10好像正好放得下不对。因为strcat复制完world之后还会把world之后的\0写进去。实际需要的内存是strlen(dst) strlen(src) 1也就是55111字节。dst只有10字节这个调用已经越界写了一个字节。这个一个字节的越界往往不会立刻崩溃。你可能会看到一个正常工作的程序然后把问题归咎于“系统不稳定”。实际上它已经践踏了相邻栈变量的一角只是还没轮到触发异常而已。所以在代码评审时我要求所有人把strcat的安全条件写成一行/* 目标缓冲区容量 - 目标当前字符串长度 源字符串长度 1 */ cap(dst) - strlen(dst) strlen(src) 1这里的cap(dst)不是strlen(dst)而是你为dst分配的整个内存区域大小。对于数组来说就是sizeof(dst)对于指针来说这个值必须由你自己记录C语言没有内置办法从指针还原出容量。2. 最常见的故障现场目标缓冲区溢出为什么是“最温柔的bug”2.1 溢出发生时程序不一定立刻崩溃如果读者以前只听说过“缓冲区溢出”可能以为它像电影里那样“砰”地一下触发安全警报。实际开发中最难缠的恰恰是溢出发生后程序仍然正常运行很久。举个例子栈上布局往往是这样的struct { char info[16]; int flag; } record;如果你执行strcpy(record.info, prefix-); /* info 现在是 prefix- */ strcat(record.info, a very long string that exceeds...);那么flag会被覆盖。flag原本是0或某个关键状态值被写入了一堆ASCII字节后可能变成了0x6e6f7372这种奇怪数字。程序不会立刻报错但后续逻辑判断flag 0会失败或者某个分支走错。这种Bug在真实项目里极难定位因为你抓堆栈时看到的往往不是strcat而是某个完全无关的函数。如果溢出更长覆盖到栈上的返回地址那程序才可能在函数返回时跳到一个非法地址出现经典的“段错误”。但那是运气好——在崩溃之前数据早已被默默破坏了很多轮。2.2 用一个最小例子亲眼看到数据被踩我建议你自己跑一下这个测试#include stdio.h #include string.h struct demo { char info[16]; int flag; }; int main(void) { struct demo d; d.flag 0x12345678; strcpy(d.info, hi); strcat(d.info, ABCDEFGHIJKLMNOPQRSTUVWXYZ); printf(info %s\n, d.info); printf(flag 0x%08X\n, d.flag); return 0; }在x86的小端环境下flag这个4字节很可能被写成了0x54535251之类的ASCII码组合。运行它会发现flag已经面目全非。我当年第一次跑这个测试时还发现一个更大的问题strcat并不保证只写坏flag它可能继续踩掉后面更多变量直到堆栈保护机制把程序杀掉。用GCC编译加-fstack-protector-all运行时多半能捕获到stack smashing detected但那是建立在编译器帮你布置了金丝雀的前提下如果优化等级高或目标平台特殊不一定每次都拦得住。2.3 为什么“差不多够大”从来不够不少人在写strcat时会拍脑袋给个大数组char buf[128]; strcpy(buf, user); strcat(buf, username); strcat(buf, token); strcat(buf, token);问题在于“127字节应该够了吧”这种假设在两次需求迭代之后就失效了。哪天登录逻辑里加了session_id、device_id每个都可能长到几百字节这个buf瞬间变成定时炸弹。真正可靠的做法是每次拼接前做容量检查。可C标准库不给你safe_strcat所以要么自己封装要么用snprintf一次成型snprintf(buf, sizeof(buf), user%stoken%s, username, token);snprintf的好处是它明确接收缓冲区容量并保证写满cap-1字节后一定补\0。代价是它会重新解析格式串、处理每个%s参数性能不如直接拼接但在非性能热点上完全够用。3. 源字符串没有终止符strcat的“迷行”与难以复现的崩溃3.1 字符数组初始化中的“隐形炸弹”比目标缓冲区溢出更隐蔽的是源字符串本身不以\0结尾。因为strcat会在src区域一直往后读直到找到一个\0为止。这个\0可能在源数组之后的内存里也可能在很远的地方甚至压根不在当前进程的可读地址内——那就会触发段错误。看这个例子char src[4] { a, b, c, d }; /* 没有终止符 */ char dst[32] hello; strcat(dst, src);src几乎是故意填满了4个字符却没有在末尾留\0。strcat会从src[0]开始复制一直读到内存里某处出现一个0x00为止。它会把那之后的所有内容包括其它调色板、填充字节、函数局部变量甚至栈上残留数据统统当作src的一部分复制进dst。这种行为在调试器里可能表现为dst变成了一长串乱码然后程序在后续printf(%s, dst)的时候再次越界读。更糟的是如果你把未初始化栈上的垃圾字节也复制进去而这些垃圾里恰好包含某些控制字符后续解析dst的逻辑就会产生难以追踪的状态错乱。3.2 为什么编译器不会警告C编译器对strcat(dst, src)不会产生任何警告因为strcat的原型里src是const char *编译器只知道“你把一个char[4]传给了const char *”这是完全合法的。它无法知道这个数组是否以\0结尾因为函数指针参数天然丢失长度信息。在代码评审时我常用的提问是“你凭什么确认这个src一定以\0结尾”如果回答是“它是我从别的地方拷贝过来的”那还不够。真正可靠的依据只有一个你给这个缓冲区写完了内容后主动补过\0。写网络协议解析时这种情况特别常见。比如从TCP包里取一个固定长度的字段char field[32]; memcpy(field, packet offset, 16); /* 这里忘记写 field[16] \0; */ strcat(log_msg, field);field不是字符串只是一块数据。用它调用strcat本质上就是在让strcat猜“数据在哪里结束”。工程上对这种“可能不是字符串”的缓冲区应该先显式置终止符memcpy(field, packet offset, 16); field[16] \0;3.3 如何用电镀层验证有没有越界在开发环境我们经常用“内存毒药”来找这种越界读。做法很简单char src[4]; memset(src, A, sizeof(src)); /* 故意不写\0 */如果strlen(src)返回的值大于或等于sizeof(src)就说明这个缓冲区根本没有终止符。用strnlen会更安全if (strnlen(src, sizeof(src)) sizeof(src)) { /* 缓冲区不满没有终止符 */ }嵌入式设备上没法跑ASan时我喜欢在调试构建里把新分配的缓冲区用0xAA填满然后检查字符串尾部是否出现连续0xAA字节来判断strcat是否读穿了。4. 重叠与别名strcat标准里的未定义行为常被默认“能用”4.1 什么样的重叠会触发未定义行为C标准明确规定strcat的源字符串和目标字符串如果发生重叠结果是未定义行为。也就是说你不能依赖任何实现下的具体表现。最容易出问题的是这种写法char s[32] C; strcat(s, s 1);这里目标dst是s的头部源src是s内部的第二个字符开始的位置。目标是C源是空字符串理论上似乎什么也不发生。但在另一个变体里就危险了char s[32] hello; strcat(s, s 1);目标字符串是hello源字符串以ello开头。由于目标和源重叠strcat会把源区间的数据一边读一边写。第一次写h到原h后面紧接着源指针可能已经越过了被修改的区域实现不同最终结果完全不同。还有最经典的自我复制strcat(s, s);某些实现会把hello复制成hellohello某些实现会死循环因为复制过程中总是找不到新的\0——源串被自己覆盖了。我在自己的机器上测试过glibc下strcat(s, s)在一定长度内是能跑的输出类似于重复一遍。但换到另一个库、另一个架构可能就是段错误。代码评审时只要看到strcat的实参里出现了同一个变量名一律打回去重写。4.2 为什么“看起来能用”这么坑人我自己刚工作那两年在一套老代码里见过类似这种写法char path[256] /tmp/data/; strcat(path, path strlen(path) 1);当时那套代码把路径和文件名放在同一个缓冲区里后面跟着一个“文件名”子串。实测中它确实能工作因为src指向目标末尾附近strcat在复制时源内容恰好还没被覆盖到。可后来有人调整了缓冲区布局把文件名挪到了更靠前的位置整个功能就随机崩溃了。这类Bug最核心的问题是它在当前环境下“碰巧”正确一旦换了编译器版本、标准库实现、内存布局甚至优化级别结果就完全不可预测。所以别用“我测过没问题”来给自己壮胆C标准里明确写了未定义行为就不该在工程代码赌运气。如果需要在一个大缓冲区内移动子串正确做法是用memmove它允许重叠memmove(path len, src, srclen 1);memmove会先判断方向必要时从尾部往前拷贝保证源区域被覆盖之前先读到需要的数据。4.3 在代码里主动拦截重叠调用如果团队老代码里strcat用得太广短期内没法全部改掉可以在调试版本里加一个断言层#define strcat(dst, src) safe_strcat_check((dst), (src)) char *safe_strcat_check(char *dst, const char *src) { /* 用uintptr_t比较地址区间是否重叠 */ uintptr_t d (uintptr_t)dst; uintptr_t s (uintptr_t)src; size_t dlen strlen(dst); size_t slen strlen(src); if ((s d s d dlen) || (d s d s slen)) { abort(); /* 调试版本直接终止 */ } return strcat(dst, src); }注意这个宏定义会污染全局命名空间只建议放在某个内部调试头文件里发布版本不要启用。5. 别急着换strncat它自己的陷阱清单5.1 第三参数不是缓冲区大小是“最多追加的字符数”很多教程说strcat不安全建议换strncat。这句话只对了一半。strncat确实限制了从源字符串复制过来的最大字符数但它不检查目标缓冲区容量。strncat的原型是char *strncat(char *dest, const char *src, size_t n);它最多从src复制n个字符到dest末尾然后总是再追加一个\0。所以实际调用代价是dest 需要的剩余空间 n 1有人常犯这个错误char dst[16] abc; strncat(dst, src, sizeof(dst)); /* 危险 */这里sizeof(dst)等于16意味着它最多可能往dst末尾追加16个字符再加上一个\0。dst已经有3个字符所以总共最多需要316120字节而dst只有16字节。溢出照样发生。正确的写法应该是size_t dst_len strlen(dst); size_t remain sizeof(dst) - dst_len; /* 含\0的总余量 */ strncat(dst, src, remain - 1);每次都要先算一遍剩余量使用体验非常繁琐。所以我认为strncat只适合一种场景你只想限制源字符串的读取长度同时已经手工保证目标缓冲区足够大。5.2 strncat 的另一个性能坑反复拼接的O(n²)strncat和strcat一样每次都要从dest开头扫描到末尾找到\0才开始追加。如果你在循环里连续拼接几百个字段每次扫描长度都会增加总时间呈平方级增长。char msg[2048] ; for (int i 0; i 500; i) { strncat(msg, item[i], sizeof(msg) - strlen(msg) - 1); }这段代码不仅每次都调用strlen(msg)去重新计算长度标准库里还要再从头扫一遍找\0等于一次拼接做了两次O(n)遍历。性能热点上我自己会手动维护写入位置size_t pos 0; for (int i 0; i 500; i) { int n snprintf(msg pos, sizeof(msg) - pos, %s, item[i]); if (n 0 || (size_t)n sizeof(msg) - pos) { /* 空间不足处理错误 */ break; } pos n; }或者更底层的做法是用一个指针p始终指向当前终止符每次拼完立刻更新char *p msg; char *end msg sizeof(msg); for (int i 0; i 500; i) { size_t len strlen(item[i]); if (p len 1 end) break; /* 放不下 */ memcpy(p, item[i], len 1); p len; }这样每次拼接都是O(len)而不是把已经拼好的内容重扫一遍。5.3 多字节字符串的截断问题strncat的“按最多n个字符”中的“字符”其实指“字节”。如果你在截断一个UTF-8编码的中文字符串n恰好落在两个字节之间就会把整个多字节字符劈成残缺字节后续可能造成非法UTF-8序列。某些终端解析这种输出会出现乱码某些日志系统会直接丢弃整条日志。如果项目里字符串主要是UTF-8或GBK我更推荐用snprintf而不是strncat做限长拼接因为你可以先用%s限制最大宽度再自己算好剩余空间。至少它不会在目标尾部追加一个无效的半截“字符”——实际上它也会截断只是语义更清楚你需要自己处理边界。6. 真正可落地的安全拼接方案手工维护末尾与容量6.1 一个自己实现的 safe_append讨论完这些坑以后我在团队内部推广的是一个自写的小函数。它不做任何动态分配只负责往一个固定容量缓冲区里安全追加一个字符串#include stdbool.h #include string.h bool safe_append(char *dst, size_t cap, const char *src) { if (!dst || cap 0) { return false; } /* 用strnlen先确认目标串在cap范围内有终止符 */ size_t dlen strnlen(dst, cap); if (dlen cap) { return false; /* 目标缓冲区不完整或已满 */ } /* 只扫描剩余空间范围内是否有终止符 避免源串本身是一个超长/缺失\0的缓冲区 */ size_t remain cap - dlen; size_t slen strnlen(src, remain); if (slen remain) { return false; /* 加上终止符会溢出不做任何修改 */ } memcpy(dst dlen, src, slen); dst[dlen slen] \0; return true; }这个函数有几个设计要点目标容量cap必须由调用方传入因为函数内部不可能知道dst是数组还是指针strnlen(dst, cap)最多扫描cap个字节如果都找不到\0说明dst自身就不是合法字符串函数拒绝操作strnlen(src, remain)最多扫描剩余空间那么大防止src本身是一块未终止的数据时把函数拖进越界读空间不足时直接返回false并且保持dst原样不动不会出现“拼了一半”这种让调用方更难处理的状态用memcpy而不是strcpy因为我们已经精确知道需要复制的长度memcpy不会去读源字符串的终止符少一次潜在越界。调用方式char buf[64] user; if (!safe_append(buf, sizeof(buf), username)) { /* 必须处理缓冲区放不下 */ }6.2 为什么我不用 strlcat有的读者会说BSD系统提供了strlcat它也是这个思路。没错strlcat是一个设计良好的函数它把目标缓冲大小作为参数保证结果一定以\0结尾还会返回需要的总长度方便调用方判断是否被截断。但问题是strlcat不是C标准函数。Linux的glibc至今没有把它加进标准库你在Linux上写strlcat需要自己备一份实现或者依赖某些第三方兼容层。对于跨平台项目我宁可维护一个自写的safe_append也不引这个依赖。C11的Annex K标准里有strcat_s但它的实现质量和在主流平台上的支持情况相当分裂而且它的安全条件同样要求调用方传入正确容量。个人建议除非你所在项目已经全面采用安全函数扩展否则自己写一个小工具更可控。6.3 什么时候依然可以用原生strcat我不会极端到“禁止所有strcat”。如果一个函数内部满足以下全部条件用原生strcat也能接受目标是一个局部字符数组容量在同一个函数内可以一眼看到源是一个字符串字面量或者已经被确定以\0结尾并且长度已知的变量追加前已经用sizeof算过剩余空间代码评审能一眼看出安全条件。典型安全用法char path[128]; snprintf(path, sizeof(path), %s%s, base, name); /* 下面已知name_len很小且base_lenname_len已经校验过 */ size_t base_len strlen(path); if (base_len 1 sizeof(path)) { strcat(path, /); }但这种用法里strcat基本可以被snprintf替换掉。所以我的原则很简单新代码里如果看到原生strcat评审时先打问号自己写的时候默认用safe_append。7. 把陷阱拦在编译器与测试阶段我常用的检测组合7.1 开发机上必开的编译选项在Linux开发环境我所有C项目的CMake默认会开这些-Wall -Wextra -Wformat2 -Wshadow -Wconversion -D_FORTIFY_SOURCE2 -fstack-protector-all_FORTIFY_SOURCE2与glibc配合时很多strcat/strcpy调用会被编译器替换成带检查的版本。当目标缓冲区大小在编译期可见运行时就会在写入前做__builtin___strcat_chk检查溢出时直接abort而不是静默踩内存。但这只对“目标缓冲区是数组且编译器能推断出大小”的情况有效。一旦目标退化为指针防御力就大减。所以编译期检查只能作为辅助不能替代代码层的容量检查。7.2 ASan是抓strcat类Bug最有效的运行时工具地址消毒器AddressSanitizer能精确捕获strcat的越界写和越界读。编译时加gcc -fsanitizeaddress,undefined -g -O1跑测试时如果strcat碰了不该碰的内存它会直接报出哪一行、哪个地址范围、是读还是写。我印象最深的一次是ASan直接把一个隐藏了三个星期的堆溢出抓到strcat那一行那条日志当时谁都没想到是字符串拼接引起的。嵌入式交叉编译器不一定支持ASan这时候可以参考我前面说的“缓冲区毒药”方法初始化时把所有内存填成0xAA定期检查字符串后面的边界是否还是0xAA。7.3 代码评审三问无论工具多好最便宜的还是人在评审时多做一步。我每次在diff里看到strcat会固定问三句目标缓冲区容量是多少这个数字是从哪来的目标串当前长度是否已知如果已知剩余量算过没有源字符串是否确定以\0结尾它来自文件、网络、用户输入还是内部拼接任何一问答不出来这行代码就不该合入主线。最后再分享一个我一直用的调试技巧如果你已经在老代码里发现了可疑的strcat先在开发环境全局替换成safe_append跑一遍回归测试通常能立刻炸出一批历史Bug。替换过程中记住不要用动态分配去“帮忙”因为动态分配本身又会引入内存泄漏和碎片问题尤其在单片机场景里堆是个稀缺资源。老老实实把缓冲区容量算明白比依赖任何库函数都更稳。