ARTICLE DETAIL

资讯详情

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

模拟实现C语言字符串函数:strcpy、strcat、strcmp底层原理与安全实战

模拟实现C语言字符串函数:strcpy、strcat、strcmp底层原理与安全实战 这篇想聊的是C语言里那组让我又爱又恨的不受限制字符串函数。爱的是它们简单直接恨的是它们带来的坑一个比一个隐蔽。如果你正在学C语言或者已经在单片机、嵌入式、Linux C编程里摸爬滚打这篇文章应该能帮你看清楚这些老朋友的真面目。所谓不受限制指的是那些以空字符\0作为终止条件、调用方无法显式控制拷贝或比较长度的函数典型代表就是strcpy、strcat、strcmp这几个。与之相对的strncpy、strncat、strncmp则会给操作加上一个计数上限属于受限版本。我这次做的是手动模拟实现前一类不调库函数全部用指针纯手写顺便把边界条件、安全隐患和底层设计思路一起盘明白。适合谁看刚学完指针和数组、想实战一下的同学准备计算机二级或者嵌入式笔试的人还有那些用单片机做字符串处理的工程师——你大概率也在维护自己的私有字符串库这篇文章的思路可以直接搬过去用。1. 不受限制这四个字才是理解整组函数的关键很多人以为strcpy和strncpy的区别只是多了一个数字参数其实没那么简单。两个函数的内在逻辑完全不同如果只记住有没有n写出来的代码迟早出问题。1.1 什么叫做不受限制先看一个基本事实C语言里的字符串没有长度字段它的结束全靠\0来识别。比如定义char str[] hello内存里实际存的是h e l l o \0最后那个看不见的\0才是字符串真正意义上的句号。不受限制的函数在读取字符串时唯一的停止条件就是遇到\0。也就是说它从起始地址开始一个字节一个字节地啃啃到\0为止中间啃了多少个字符完全取决于内存里实际存了什么而不是调用方说了算。这就带来两个经典问题如果源字符串没在末尾放\0strcpy会一直拷贝下去直到在内存深处偶然撞到一个\0导致缓冲区越界写。如果目标缓冲区不够大strcpy照样不管不顾地往后写stack smashing就是这么来的。所以模拟实现不受限制函数本质上是在模拟一种信任机制调用方必须保证源字符串规范、目标缓冲区足够大函数自己不做安全检查。安全设计缺失的锅C语言背了很多年但作为使用者我们得明白锅在哪。1.2 内存视角下的\0与字符串边界模拟实现之前建议先画一画内存图。很多同学写strcpy卡住不是因为不会写循环而是没想清楚\0也算一个字节它也要被搬运。比如拼一个设计目标dest指向的目标区要能容纳源字符串所有字符再加一个\0。你现在可以回想一下自己定义的字符数组大小有没有为这个\0留出位置这也是为什么我建议在函数实现里做防御式检查如果源指针和目标指针为NULL直接报错返回别让空指针崩溃发生在几行之后莫名其妙的地方。标准库函数不保证空指针检查但我们的模拟版本可以做得更严谨这是自写函数比库函数更可控的地方之一。2. 热身用三种思路实现strlen先把手感练出来不要小看strlen它虽然只有一句话的逻辑但能牵扯出const修饰、返回类型、空指针防御甚至性能优化。我写的时候喜欢先写最笨的版本再逐步演进。2.1 最基础的计数器版本size_t my_strlen(const char *s) { size_t count 0; while (s[count] ! \0) { count; } return count; }这个版本用数组下标访问初学者一眼就能看懂。但注意参数类型是const char *意味着函数承诺不修改字符串内容返回类型是size_t这是一个无符号类型在stddef.h或stdio.h里定义本质上是unsigned long的别名。这里有一个隐蔽的坑size_t是无符号数在条件判断里如果写成while (count strlen(s))这类代码当strlen返回0时会出现-1被转换成超大无符号数的麻烦。我们在模拟实现时也容易踩类似的坑后面讲strcmp的时候会再提到。2.2 指针移动版理解地址跳跃size_t my_strlen(const char *s) { const char *p s; while (*p ! \0) { p; } return (size_t)(p - s); }这个版本的核心是p - s两个指针相减得到的是它们之间相差的元素个数。因为p是指向char的指针所以相减结果就是字符串长度。这里有一个重要的隐含条件只有当两个指针指向同一个数组或同一块连续内存时减法才有意义。我们这里s是起始地址p在同一个字符串内移动所以安全。再看一层const char *p s;这里的const放在char *之前意思是p指向的字符不能通过p修改。但p本身可以移动所以p没问题。这个语法细节面试经常考你写模拟实现的时候也顺便练了。2.3 提升读写效率的优化思路有一种经典的strlen优化不是逐个字节数而是按4字节或8字节分组检查提前算出哪些字节是\0。原理是每次读一个机器字比如32位整数然后用一个位运算技巧判断其中是否包含零字节int has_null_byte(unsigned int x) { return ((x - 0x01010101) ~x 0x80808080) ! 0; }这个公式背后的原理是如果某个字节是0那么减去0x01010101之后那个字节的最高位会发生借位变化配合按位取反和掩码就能检测出来。不过这个方法依赖内存对齐、字节序等条件自己实现容易出错。我的看法是学习阶段用前两种版本就足够重点是把指针运算和const语义吃透。真要优化性能追求的是减少对内存总线的占用量而不只是少写几行代码。单片机上体会更明显一次读多个字节确实能节省时间但如果你的编译器和硬件环境没对齐支持反而会引发总线错误。3. 上手strcpy从玩具级实现到能上线的防御式写法strcpy是字符串函数里的重头戏。我先从能跑逐步改到合理中间会提到三个关键设计选择为什么返回值要返回目标地址、为什么用assert做输入检查、以及指针移动版本和数组下标版本的真实差异。3.1 第一版教科书式极简实现char *my_strcpy(char *dest, const char *src) { char *ret dest; while ((*dest *src) ! \0) { ; } return ret; }这一版非常经典循环体是空的赋值和判断糅合在一个表达式里。*dest *src的执行顺序是先取出*src让src自增再把取出的字符赋给*dest让dest自增最后判断这个赋进去的字符是不是\0。如果是循环结束如果不是继续。注意\0也被拷贝了这是strcpy和memcpy最关键的区别之一。memcpy负责搬运指定字节数不关心内容strcpy负责搬运整个字符串连同终止符一起。能跑通但有两个问题。第一如果传入的src或dest是空指针程序直接段错误崩溃没有任何提示。第二如果src和dest内存重叠行为是未定义的。这两点在标准库中也是未定义行为但我们自己写完全可以做得更稳。3.2 第二版加断言加空指针保护#include assert.h char *my_strcpy(char *dest, const char *src) { assert(dest ! NULL); assert(src ! NULL); assert(dest ! src); char *ret dest; while ((*dest *src) ! \0) { ; } return ret; }assert是C标准库提供的断言宏条件为假时程序中止并打印文件和行号。调试阶段强烈建议保留发布阶段可以通过定义NDEBUG宏来关闭断言。为什么要把dest ! src也加进去因为如果源和目标指向同一个地址自己拷自己没有意义而且循环中两个指针同时自增虽然结果可能碰巧相同但设计上属于重叠区间未定义行为直接拒绝更干净。这里我要吐槽一个常见误区很多人觉得assert是给用户看的报错机制实际它是给开发者看的调试工具。用户输入错误导致的问题应该靠if返回失败码或提前处理不能指望断言之类的手段在发布版本中兜底。3.3 第三版记录目标起始地址支持链式调用char *my_strcpy(char *dest, const char *src) { assert(dest ! NULL src ! NULL); char *ret dest; while ((*dest *src) ! \0) { ; } return ret; }这个版本本质上和第一版一样但你有没有想过一个问题既然内部已经知道dest最后移动到哪了为什么还要开一个ret变量记首地址返回目标地址核心价值是支持链式调用。比如char buffer[64]; strcpy(strcpy(buffer, hello), world);内层的strcpy返回buffer首地址外层的strcpy从首地址开始覆盖写于是最终得到hello world。这种写法简洁、高效但要求内层返回值确实指向拷贝后的起始位置。如果你在实现里return dest而不是return ret链式调用就会从\0的位置继续写结果惨不忍睹。在嵌入式场景中我一般不会让代码变得太花哨链式调用的可读性并不高但作为库函数设计返回首地址是约定俗成的接口规范。掌握它你就理解了为什么strcpy的返回值不是字符串长度也不是拷贝字符个数而是目标地址。3.4 和 strlen 嵌套实现另一种思路char *my_strcpy_ver2(char *dest, const char *src) { assert(dest ! NULL src ! NULL); size_t len my_strlen(src); for (size_t i 0; i len; i) { dest[i] src[i]; } return dest; }这是先取长度、再按长度逐字符搬的思路。优势是逻辑清晰劣势是my_strlen已经遍历了一遍源字符串这里又遍历第二遍效率减半。对于字符串函数这种高频调用场景性能浪费不划算。所以正规实现不这么做直接靠\0判断走完一遍就结束。你会看到很多开源项目的字符串库采用类似dest[i] src[i]的写法但加上了len参数比如sprintf返回值的处理本质上是受控版本。对于不受限制的版本来讲一遍循环搞定是底线。4. 重头戏strcat循环找尾指针才是正确姿势strcat的全称是string concatenate也就是字符串拼接。它的作用是把src追加到dest字符串末尾覆盖掉dest原来的\0然后在新内容的末尾再补一个\0。4.1 最直白的实现分两步走char *my_strcat(char *dest, const char *src) { assert(dest ! NULL src ! NULL); char *ret dest; // 第一步找到 dest 的终止符 \0 while (*dest ! \0) { dest; } // 第二步把 src 的内容连同上终止符拷贝进来 while ((*dest *src) ! \0) { ; } return ret; }逻辑不复杂但有两处细节值得掰开揉碎。第一第一步用的是while (*dest ! \0) dest;循环结束之后dest正好停在原来的\0位置上不能多走一步dest。多走一步会跳过\0位置开始覆盖后面无关的内存大概率丢内容或触发越界。第二第二步的循环和strcpy内部一模一样就是边赋值边判断是否为\0。因为\0本身也要被覆盖它是拼接点所以这里不是追加到dest字符串最后一个有效字符之后1的位置而是从\0位置开始覆盖写入。这其实是整个strcat最容易迷惑的地方它不是方案A保留原有字符串在后面增加内容而是方案B从终止符开始重写原有字符串保持不变是因为它的有效部分在终止符之前。从内存模型上B是直接自然的。4.2 为什么不能用先strlen再copy的加法有人会写char *my_strcat_bad(char *dest, const char *src) { size_t len_dest my_strlen(dest); size_t len_src my_strlen(src); for (size_t i 0; i len_src; i) { dest[len_dest i] src[i]; } return dest; }看着没毛病先算出dest的有效长度然后从dest[len_dest]开始写。但问题出在这里的dest[len_dest]就是原来的\0src的内容从这开始覆盖确实也能拼出来。然而这个写法相当于调用了两次strlen还需要统计src长度还要额外维护下标既啰嗦又容易因为dest的缓冲区实际容量小于len_destlen_src1而越界写。标准的strcat编译后往往比手动展开循环的版本更高效因为库函数在编译器优化下经常被内联为strcpy加指针移动模式。所以我们直接沿用它最核心的指针移动思路而不是另起炉灶。4.3 strcat 的安全隐患为什么拼接类函数最要命单个strcpy如果只是源缓冲区比目标大越界写一个字符串还比较容易被发现但strcat是两段内容的叠加你很难预估最终长度。比如char buf[16] prefix; strcat(buf, hello world, this is a long suffix);buf初始只占用了7个字节prefix占6个字符1个\0src长度明显超过剩下的9个字节。strcat会从头到尾写完src完全无视buf边界直接栈溢出。攻击者可以精心构造这个src的内容覆盖返回地址或者相邻变量这就是经典的栈溢出漏洞利用方式之一。模拟实现的时候我格外推荐在函数入口处加一条显眼注释// 警告此函数不会检查目标缓冲区大小调用前请确保空间足够。 char *my_strcat(char *dest, const char *src)这类注释不是写给自己看的是写给未来三个月后忘了上下文的自己看的也是写给接手代码的同事看的。4.4 安全替代方案的启发为什么库提供了 strncat受限版本strncat(dest, src, n)最多从src拷贝n个字符并保证末尾补\0。它并不能完全替代strcat因为它只限制最多拷贝的源字符数不限制目标缓冲区的绝对容量但已经能防止最恶劣的无限越界写。它的实现思路里有一个细节从dest找\0之后逐字符拷贝同时计数当拷贝了n个字符后强制在dest n处写入\0。这个拷满就封口的思想模拟时完全可以借鉴。如果我们自己实现一个带上限的拼接函数可以用类似的模式。5. strcmp不是返回 1 和 -1是返回差值strcmp是一个让很多人头疼的函数因为标准库只规定返回值是大于0、小于0或等于0没有规定必须是1和-1。所以严格按标准返回*p1 - *p2是允许且推荐的。5.1 教科书写法与注意点int my_strcmp(const char *s1, const char *s2) { assert(s1 ! NULL s2 ! NULL); while (*s1 ! \0 *s2 ! \0 *s1 *s2) { s1; s2; } return (int)(unsigned char)(*s1) - (int)(unsigned char)(*s2); }循环条件有三个两个字符串都没走到终止符且当前字符相等。一旦不满足就跳出循环此时*s1和*s2中至少有一项是终止符或者两个字符不相同了。返回两者之差。这里有个经典坑如果用signed char做减法在高位为1的字符比如扩展ASCII码大于127上符号扩展可能导致结果的正负含义被扭曲。所以我的写法里先把字符强转成unsigned char再做差。这是C标准库实现惯用的技法保证比较的是字节的无符号数值。5.2 性能优化不要用一次比较一个字符的老套思路为了直观体验很多人会写int my_strcmp_slow(const char *s1, const char *s2) { int i 0; while (s1[i] s2[i]) { if (s1[i] \0) return 0; i; } return s1[i] - s2[i]; }这个写法确实容易懂但每次循环都要做两次索引寻址。现代CPU分支预测和内存访问模式下指针版本通常更快。更极端的优化是每次读4字节或8字节一次比较一组字节是否相同找出不匹配位置再精确到字节。这个思路和前面优化strlen是一个路数只不过难度更高、更依赖字面常量掩码。我个人的建议是嵌入式工程师可以研究一下批量比较的思路平时做项目够用就好应试和初学者了解即可把重点放在正确性上。5.3 结合ASCII排序逻辑的扩展应用理解了strcmp是按照字典序比较的你就能解释很多现象apple banana因为a的ASCII码97小于b的98abc abcd因为比较到c和c相等之后一方结束终止符ASCII码0小于d的100。这些结论都源于strcmp的实现逻辑先比较可打印字符等一方结束后终止符参与比较且排在前面。如果只记住结论考试能过把实现逻辑吃透你才能预判一个诡异的BUG比如从文件里读进来的两行字符串一行末尾有\n一行没有strcmp调用半天比较出的结果和你预期不符。知道原理之后就明白了——\n字符参与了比较它不是看不见就不存在。6. 进阶组合用模拟函数拼出 strchr 和 strstr模拟实现单个函数是热身把它们组合起来才是真正爽的地方。我这次顺带实现了strchr找字符在字符串中首次出现的位置和strstr找子串前者为后者打基础后者应用了前面strcmp的指针思路。6.1 模拟实现 strchrchar *my_strchr(const char *s, int c) { assert(s ! NULL); while (*s ! \0) { if (*s (char)c) { return (char *)s; } s; } if ((char)c \0) { return (char *)s; } return NULL; }两个细节要说明。第一c的类型是int而不是char这是标准库的规定调用时传入字符常量比如a会隐式转换成int但这不等于我们可以直接用int和char直接比较而不做转换——在表达式中字符会隐式提升为int所以实际比较没问题。但在模拟实现时为了和标准库语义一致需要小心c是负值或大于CHAR_MAX的情况。第二查找\0是允许的返回指向字符串终止符的位置。很多人会漏了这个情况因为查找结束符听起来不常用但标准库允许实现时也要覆盖。6.2 模拟实现 strstr朴素匹配char *my_strstr(const char *haystack, const char *needle) { assert(haystack ! NULL needle ! NULL); if (*needle \0) { return (char *)haystack; } const char *cur haystack; while (*cur ! \0) { const char *h cur; const char *n needle; while (*h ! \0 *n ! \0 *h *n) { h; n; } if (*n \0) { return (char *)cur; } cur; } return NULL; }朴素匹配的思路是从haystack的每一个位置开始尝试把needle从头到尾比对一遍。外层循环的cur记录当前尝试的起点内层循环用两个临时指针h和n同步向后走一旦发现不相等就退出内层。内层退出后检查*n是不是\0如果是说明整个needle都匹配上了返回当前起点。这个实现可以直接复用前面my_strlen的思路但没必要先量长度再来回对齐因为朴素匹配本身就是O(m*n)复杂度多一次遍历只会更慢。如果想优化KMP算法会快很多但它在单片机等资源受限场景中预处理和状态维护的开销也不小贪便宜贪快最后反而复杂。真实项目里用指针判断就够如果性能不够再从算法层面换。6.3 字符串按空格切分的应用示例在相关搜索词里看到c语言将一个字符串按照里面的空格分开这里补一个组合例子用while按空格拆字符串并把拆出的单词用my_strlen统计长度。#include stdio.h void split_by_space(const char *str) { while (*str ! \0) { while (*str ) { str; } if (*str \0) { break; } const char *word_start str; while (*str ! *str ! \0) { str; } size_t word_len (size_t)(str - word_start); printf(%.*s (len%zu)\n, (int)word_len, word_start, word_len); } }这个例子说明了一个道理手写字符串函数拼在一起做分词器非常顺手你根本不需要引入重量级库。利用前面实现的my_strlen和指针差异运算拆分逻辑导出的长度计算变得很自然。如果你已经在用单片机且不方便调标准库的strsep这种手写拆分方式会非常香。7. 内存重叠、断言与调试用最严苛的心态面对手写字符串函数模拟实现的乐趣一半在于编码一半在于被自己写的代码坑到怀疑人生。这一节专门聊聊那些只有动手写过才会碰到的实际问题。7.1 内存重叠的后果给自己造一个灾难现场内存重叠指的是src和dest指向的缓冲区有交集。典型场景是用strcpy做字符串内平移比如把hello world从第三个字符开始整体前移。如果dest和src存在重叠且dest位于src后面的位置方向不当就会覆盖掉尚未读取的数据。标准库对这类场景一律定义为未定义行为不允许依赖任何结果。实测一个例子char buf[] hello;然后执行my_strcpy(buf 1, buf)。我们的实现从头开始逐个赋值顺序是buf[2] buf[1]、buf[3] buf[2]……问题出现在buf[2]原本是字符l当src移动到buf 2时那里已经被覆盖成了buf[1]的e所以最终结果会诡异。整体变成乱码而不是从第二个字符开始重复hello。这就是为什么memmove存在它允许重叠内存区安全拷贝内部自行判断拷贝方向。我们在模拟双向循环的时候也应该考虑到方向问题。7.2 调试技巧用 assert 和边界打印验证每一步调试手写字符串函数最忌凭感觉看结果。我常用的三件套在进入函数前打印src的原始地址和内容。在关键操作后检查目标缓冲区首字节和终止符位置是否符合预期。用printf(copy result: %s\n, dest)确认逻辑正确但要注意如果dest本身不是以\0开头越界问题可能让%s打印出可怕的长串。还有一个我自己踩过很多次的坑手写strcpy的时候把终止符判断写成了while (*dest *src)但是忘了给循环体加分号或空语句结果循环体空转但逻辑顺序错了。这种低级的语法错误在经验丰富的工程师身上也偶发最好的应对就是写完立刻跑一个10字节长度的最小用例用肉眼跟踪每个循环的指针位置。7.3 关于模拟实现的边界测试用例设计写完函数不测试等于白写。我一般会准备一张测试用例表测试场景输入示例预期结果普通字符串拷贝srchello, dest[10]desthello空字符串拷贝srcdest只拷贝一个字符srcAdestA, 长度1目标缓冲区紧张dest[6]进阶拷贝hello正常因为6够放hello\0源和目标重叠srcdest1未定义行为测试中观察但不依赖拼接空字符串srcdest不变拼接后恰好填满缓冲区精确计算长度dest末尾有\0比较大小写abcvsabd返回负值之所以强调边界测试是因为手写字符串函数的bug大多藏在边界处。src为空、目标只有1字节容量、字符串恰好填满整个数组——这些边界情况不测一遍代码大概率有隐患。8. 实测复盘跑完所有测试后我收获了什么这一节是我这次模拟实现过程中最有实感的部分不是理论灌输而是动手后的复盘。8.1 标准库实现的精妙之处模拟完之后再回头看标准库代码很多设计意图就清晰了。比如strcpy的返回值设计成目标地址是为了链式写strcmp使用无符号字符比较是为了在不同平台上语义一致strchr把第二个参数声明为int是为了兼容EOFEOF不是字符值而是一个负数标记。这些设计不是拍脑袋定的每一个都在解决一个真实问题。当你自己动手写就能体会到什么叫接口设计就是成本控制一个好的返回值约定能让调用代码简洁不少同时少出bug。8.2 自己模拟实现的价值到底在哪很多人觉得标准库已经提供了完善实现自己模拟纯属浪费时间。我的观点完全相反。第一模拟实现是理解C语言底层细节的捷径。指针运算、数组下标、类型转换、const语义、未定义行为这些知识看书一百遍不如手写一遍来得扎实。第二嵌入式或库函数受限场景下你真的可能需要自写实现。有些平台的C库不完整有些场景需要特殊处理比如不使用动态内存或要求极低内存占用这时候能快速手写正确版本就是核心竞争力。第三通过自己实现你会更敬畏库函数背后的边界处理。以后调用strcpy时你会下意识思考目标缓冲区大小够不够源字符串是否规范这本身就是安全编码素养的提升。8.3 给正在学习的人几条实在建议先画内存图再写代码。哪怕是在纸上画也要把指针指向哪里、交换之后字符在什么位置标清楚。给自己的函数命名加上前缀比如my_strcpy避免和标准库函数同名导致链接或语义冲突。每写完一个函数立刻写最小测试代码调用它不要攒到最后一起调试。一次性显示十几个错误会让你焦头烂额。用编译器的告警选项比如gcc -Wall -Wextra -Wpedantic它会帮你发现很多粗心问题。字符串两个字永远不要忘记\0不管是初始化、拷贝、拼接还是比较\0都要纳入计算。动手写一遍胜过我在这里唠叨一百句。你踩到指针误用的坑才会懂为什么代码规范里强调用const修饰只读的参数你被缓冲区溢出折磨一晚上才会在项目里强制规定使用带长度限制的字符串函数。这正是不受限制这几个字教给我最重要的东西自由是有代价的而C语言把控制权全交到你手里责任也在你自己身上。
返回列表