这篇想聊的是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为止,中间啃了多少个字符,完全取决于内存里实际存了什么,而不是调用方说了算。
这就带来两个经典问题:
- 如果源字符串没在末尾放
\0,strcpy会一直拷贝下去,直到在内存深处偶然撞到一个'\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]就是原来的\0,src的内容从这开始覆盖,确实也能拼出来。然而,这个写法相当于调用了两次strlen(还需要统计src长度),还要额外维护下标,既啰嗦又容易因为dest的缓冲区实际容量小于len_dest+len_src+1而越界写。标准的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个\0),src长度明显超过剩下的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'的98),"abc" < "abcd"(因为比较到'c'和'c'相等之后,一方结束,终止符ASCII码0小于'd'的100)。这些结论都源于strcmp的实现逻辑:先比较可打印字符,等一方结束后,终止符参与比较且排在前面。
如果只记住结论,考试能过;把实现逻辑吃透,你才能预判一个诡异的BUG:比如从文件里读进来的两行字符串,一行末尾有\n,一行没有,strcmp调用半天比较出的结果和你预期不符。知道原理之后就明白了——\n字符参与了比较,它不是看不见就不存在。
6. 进阶组合:用模拟函数拼出 strchr 和 strstr
模拟实现单个函数是热身,把它们组合起来才是真正爽的地方。我这次顺带实现了strchr(找字符在字符串中首次出现的位置)和strstr(找子串),前者为后者打基础,后者应用了前面strcmp的指针思路。
6.1 模拟实现 strchr
char *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 关于模拟实现的边界测试用例设计
写完函数不测试等于白写。我一般会准备一张测试用例表:
| 测试场景 | 输入示例 | 预期结果 |
|---|---|---|
| 普通字符串拷贝 | src="hello", dest[10] | dest="hello" |
| 空字符串拷贝 | src="" | dest="" |
| 只拷贝一个字符 | src="A" | dest="A", 长度1 |
| 目标缓冲区紧张 | dest[6]进阶拷贝"hello" | 正常,因为6够放"hello"+\0 |
| 源和目标重叠 | src=dest+1 | 未定义行为,测试中观察但不依赖 |
| 拼接空字符串 | src="" | dest不变 |
| 拼接后恰好填满缓冲区 | 精确计算长度 | dest末尾有\0 |
| 比较大小写 | "abc"vs"abd" | 返回负值 |
之所以强调边界测试,是因为手写字符串函数的bug大多藏在边界处。src为空、目标只有1字节容量、字符串恰好填满整个数组——这些边界情况不测一遍,代码大概率有隐患。
8. 实测复盘:跑完所有测试后我收获了什么
这一节是我这次模拟实现过程中最有实感的部分,不是理论灌输,而是动手后的复盘。
8.1 标准库实现的精妙之处
模拟完之后再回头看标准库代码,很多设计意图就清晰了。比如strcpy的返回值设计成目标地址,是为了链式写;strcmp使用无符号字符比较,是为了在不同平台上语义一致;strchr把第二个参数声明为int,是为了兼容EOF(EOF不是字符值,而是一个负数标记)。
这些设计不是拍脑袋定的,每一个都在解决一个真实问题。当你自己动手写,就能体会到什么叫"接口设计就是成本控制",一个好的返回值约定能让调用代码简洁不少,同时少出bug。
8.2 自己模拟实现的价值到底在哪
很多人觉得,标准库已经提供了完善实现,自己模拟纯属浪费时间。我的观点完全相反。
第一,模拟实现是理解C语言底层细节的捷径。指针运算、数组下标、类型转换、const语义、未定义行为,这些知识看书一百遍不如手写一遍来得扎实。
第二,嵌入式或库函数受限场景下,你真的可能需要自写实现。有些平台的C库不完整,有些场景需要特殊处理(比如不使用动态内存,或要求极低内存占用),这时候能快速手写正确版本就是核心竞争力。
第三,通过自己实现,你会更敬畏库函数背后的边界处理。以后调用strcpy时,你会下意识思考目标缓冲区大小够不够,源字符串是否规范,这本身就是安全编码素养的提升。
8.3 给正在学习的人几条实在建议
- 先画内存图,再写代码。哪怕是在纸上画,也要把指针指向哪里、交换之后字符在什么位置标清楚。
- 给自己的函数命名加上前缀,比如
my_strcpy,避免和标准库函数同名导致链接或语义冲突。 - 每写完一个函数,立刻写最小测试代码调用它,不要攒到最后一起调试。一次性显示十几个错误会让你焦头烂额。
- 用编译器的告警选项,比如
gcc -Wall -Wextra -Wpedantic,它会帮你发现很多粗心问题。 - 字符串两个字永远不要忘记
\0,不管是初始化、拷贝、拼接还是比较,\0都要纳入计算。
动手写一遍,胜过我在这里唠叨一百句。你踩到指针误用的坑,才会懂为什么代码规范里强调用const修饰只读的参数;你被缓冲区溢出折磨一晚上,才会在项目里强制规定使用带长度限制的字符串函数。这正是"不受限制"这几个字教给我最重要的东西:自由是有代价的,而C语言把控制权全交到你手里,责任也在你自己身上。