1. 项目缘起:为什么我们要亲手“造轮子”?
刚学C语言那会儿,我总觉得strcpy、strcat这些函数用起来理所当然,不就是库函数嘛,调用一下就行了。直到后来自己写项目,遇到了缓冲区溢出导致程序崩溃,或者处理特殊字符时结果总是不对,才回过头来琢磨:这些黑盒子里面到底发生了什么?它们有没有什么我没注意到的“坑”?从那时起,我就养成了一个习惯:对于核心的、常用的库函数,一定要自己动手模拟实现一遍。这绝不是浪费时间,而是一种“知其然,更知其所以然”的深度修炼。
今天,我们就来彻底拆解C语言中最常用的几个字符串处理函数。我们的目标不是简单地复述手册,而是像设计者一样思考,从零开始,用最纯粹的C语言,一步步构建出它们的“平替”版本。你会看到,一个看似简单的strlen,在追求极致效率时可以有多种写法;一个常用的strcpy,如果不注意边界检查,可能就是安全漏洞的温床。通过这个过程,你不仅能牢牢掌握这些函数的内部机理,更能深刻理解指针操作、内存管理和编码安全的精髓,这对于你未来无论是做嵌入式开发、系统编程,还是应对各种笔试面试,都将是极为扎实的功底。
2. 模拟实现前的核心思想与约束
在动手写代码之前,我们必须先统一思想,明确游戏规则。模拟实现不是天马行空的创造,而是在给定约束下的精确还原。
2.1 函数原型与行为的一致性
这是最高原则。我们模拟的函数,其名称、参数类型、返回类型以及最重要的——外部行为,必须与标准库函数完全一致。这意味着:
- 参数不变:标准库的
strcpy声明是char *strcpy(char *dest, const char *src);,我们的模拟函数也必须是这个签名。 - 返回值一致:标准库的
strcpy返回目标字符串的起始地址(即dest),我们的实现也必须返回dest。这个返回值常被用于链式调用,比如strcat(strcpy(dest, src1), src2)。 - 副作用相同:函数对内存的修改、对输入参数的依赖关系必须一致。例如,
strcpy会修改dest指向的内存,并且依赖于src指向的以\0结尾的字符串。
2.2 关于“安全性”的边界讨论
这是一个关键且容易引起困惑的点。C标准库中原始的字符串函数(如strcpy,strcat)本身不检查目标缓冲区的大小,这是它们被认为“不安全”的根源。它们的设计哲学是“程序员负责一切”,追求极致的速度和简洁。
注意:在我们的模拟实现中,我们首先严格遵循原始函数的不安全行为进行实现。这是为了理解其最本质的工作原理。之后,我们会在分析章节专门探讨如何在此基础上增加安全检查,演进出
strncpy、strlcpy(非标准但流行)等安全版本。请务必分清“模拟原始行为”和“编写安全代码”是两个阶段的任务。
2.3 我们的“武器库”:允许使用的底层操作
既然要模拟,我们就不能直接调用其他的字符串库函数(否则就成了套娃)。我们被允许使用的“原子操作”非常有限:
- 指针的算术运算与解引用:这是核心中的核心。
*p,*(p+1),p++等。 - 基本赋值与比较:
=,==,!=,>,<等。 - 循环与条件判断:
while,for,if。 - 内存访问:通过指针读写内存。
我们的所有实现,都将建立在这些基础操作之上。好了,思想准备就绪,让我们开始第一个,也是最基础的一个函数。
3. 求字符串长度:strlen 的多种实现与效率权衡
strlen的功能是计算一个以空字符\0结尾的字符串的长度(不包括\0本身)。听起来很简单,但实现方式却能体现出不同的编程思维和优化技巧。
3.1 最直观的版本:计数器法
这是初学者最容易想到的方法。用一个临时指针遍历字符串,同时用一个整型变量count来记录步数。
// 版本1:计数器法 size_t my_strlen_counter(const char *str) { size_t count = 0; // 用于计数的变量 if (str == NULL) { // 良好的习惯:检查输入指针是否有效 return 0; // 处理空指针,实际标准库行为未定义,这里我们做防御性处理 } while (*str != '\0') { // 遍历直到遇到字符串结束符 count++; str++; } return count; }实现解析:count初始为0。while循环检查当前str指向的字符是否为\0,如果不是,count加1,指针str向后移动一个字符(指向下一个字符)。循环结束时,count的值就是字符串的长度。这个版本逻辑清晰,易于理解,但每次循环要执行两次加法(count++和str++)和一次比较。
3.2 更C语言风格的版本:指针差值法
C语言中,指针减指针可以得到它们之间相差的元素个数。我们可以利用这一点,省去单独的计数器。
// 版本2:指针差值法 size_t my_strlen_ptr_diff(const char *str) { const char *start = str; // 记录起始位置 if (str == NULL) { return 0; } while (*str != '\0') { str++; } return (size_t)(str - start); // 结束位置减去起始位置,即为长度 }实现解析:在开始遍历前,用start指针记录字符串的起始地址。然后让str指针一路走到\0的位置。最后,str - start得到的就是从头到尾走过的字符数,也就是字符串长度。这个方法比计数器法更“地道”,它直接利用了指针运算的特性,通常效率也略高一点。
3.3 追求极致的版本:无分支优化与字长读取
在一些高性能库(如Glibc)的实现中,strlen会采用更复杂、更极致的优化。其核心思想是:
- 对齐内存访问:现代CPU对对齐的内存访问(如4字节或8字节对齐)速度更快。实现会先处理开头几个不对齐的字节,直到指针指向一个对齐的地址。
- 按机器字长(Word)读取:一次不是读取一个字符(1字节),而是读取一个机器字(比如4或8字节)。这相当于同时检查多个字符是否为
\0。 - 位运算技巧快速检测0字节:在一个字(比如
0x44332201)中快速判断是否有任何一个字节为0x00。这通常通过神奇的位运算公式实现,例如((x - 0x01010101) & ~x & 0x80808080)对于32位系统。 - 无分支循环:减少或消除循环内的条件判断,利用位运算结果来定位
\0。
这种实现非常复杂,涉及大量底层知识,其目的是为了在处理长字符串时获得数倍甚至数十倍的性能提升。对于我们理解基本原理而言,前两种实现已经足够。但你需要知道,一个简单的strlen,在工业级代码中可能藏着如此深的优化心思。
4. 字符串复制:strcpy 与 strncpy 的陷阱与实现
复制函数是字符串操作中最常用,也最容易出问题的地方。
4.1 标准 strcpy 的模拟实现
char *strcpy(char *dest, const char *src);将src指向的字符串(包括结尾的\0)复制到dest指向的内存空间。
// 模拟实现标准 strcpy char *my_strcpy(char *dest, const char *src) { // 参数检查(标准库不检查,但好的模拟实现可以加入) // assert(dest != NULL && src != NULL); char *ret = dest; // 保存目标字符串起始地址,用于返回 while ((*dest++ = *src++) != '\0') { // 循环体为空,所有操作都在条件判断中完成 } return ret; }实现解析:这行while ((*dest++ = *src++) != '\0')是C语言中一个经典且紧凑的写法。它的执行顺序是:
- 将
src指向的字符赋值给dest指向的位置(*dest = *src)。 - 判断这个被赋值的字符是否等于
\0。 - 无论是否等于
\0,dest和src指针都自增1,指向下一个位置。 - 如果步骤2中判断字符不是
\0,则继续循环;如果是\0,则循环结束,并且\0也已经被复制过去了。
这个实现完美复刻了标准库的行为:它假设dest指向的内存空间足够大,能容纳src的所有字符(包括\0)。如果dest空间不足,就会发生缓冲区溢出(Buffer Overflow),覆盖后面的内存数据,导致程序崩溃或产生安全漏洞。这是原始strcpy最大的“罪”。
4.2 有限长度的复制:strncpy 的模拟与误区
为了缓解溢出问题,C库提供了char *strncpy(char *dest, const char *src, size_t n);。它的逻辑是:最多复制n个字符从src到dest。
// 模拟实现标准 strncpy char *my_strncpy(char *dest, const char *src, size_t n) { char *ret = dest; size_t i; for (i = 0; i < n && src[i] != '\0'; i++) { dest[i] = src[i]; } // 关键且反直觉的部分:如果 i < n,说明 src 提前结束了,需要用 \0 填充剩余空间 for ( ; i < n; i++) { dest[i] = '\0'; } return ret; }实现解析与重大陷阱:
- 复制阶段:
for循环在两种情况下停止:复制够了n个字符,或者遇到了src的结束符\0。 - 填充阶段:这是
strncpy最特殊也最容易被误用的一点!如果src的长度小于n,strncpy会用\0填充dest中剩余的所有字节,直到写满n个字符。 - 结尾符不确定性:
strncpy不保证目标字符串以\0结尾!只有在两种情况下dest会以\0结尾:src的长度(包括\0)小于n,此时dest的最后一个有效字符是填充的\0。src的长度大于等于n,此时dest恰好被src的前n个字符填满,最后一个字符不是\0。
踩坑实录:很多人以为
strncpy是安全的strcpy,直接strncpy(dest, src, sizeof(dest))。这错了!如果src很长,dest会被填满且没有\0结尾。后续使用dest作为字符串的函数(如printf(“%s”, dest))会一直读取内存直到遇到一个随机的\0,导致溢出或乱码。正确的做法是手动确保结尾:strncpy(dest, src, sizeof(dest)-1); dest[sizeof(dest)-1] = '\0';。
4.3 更优的选择:模拟 strlcpy 的思路
正因为strncpy的怪异行为,很多系统(如BSD)引入了strlcpy。它的原型是size_t strlcpy(char *dest, const char *src, size_t size);,其设计目标是安全且易于正确使用。
// 模拟 strlcpy 的行为(非标准,但更安全) size_t my_strlcpy(char *dest, const char *src, size_t size) { size_t src_len = my_strlen(src); // 需要先知道源串长度 size_t n; if (size == 0) { return src_len; // 如果目标空间为0,直接返回源串长度(需要复制的长度) } // 计算实际能复制的字符数,留一个位置给 \0 n = (src_len >= size) ? (size - 1) : src_len; // 复制最多 n 个字符 for (size_t i = 0; i < n; i++) { dest[i] = src[i]; } dest[n] = '\0'; // 无论何种情况,都确保目标字符串以 \0 结尾 return src_len; // 返回源串长度,方便调用者判断是否被截断 }实现解析:strlcpy的理念是“安全第一,结果可预测”。
- 它总是保证
dest以\0结尾(只要size > 0)。 - 它的第三个参数
size指的是dest缓冲区的总大小(包括\0的位置),而不是最大复制字符数。这更符合直觉。 - 返回值是
src的长度。这样,调用者可以轻松判断复制是否被截断:if (retval >= size) { /* 发生了截断 */ }。
虽然strlcpy不是C标准库函数,但其清晰的安全语义使得它在很多项目中成为首选。自己实现一个类似逻辑的函数,是工程中的常见做法。
5. 字符串连接:strcat 与 strncat 的细节
连接函数用于将一个字符串追加到另一个字符串的末尾。
5.1 标准 strcat 的模拟实现
char *strcat(char *dest, const char *src);将src字符串追加到dest字符串的末尾(覆盖dest原有的结束符\0,并在连接后的新字符串末尾添加\0)。
// 模拟实现标准 strcat char *my_strcat(char *dest, const char *src) { char *ret = dest; // 第一步:找到 dest 字符串的结尾(即 \0 的位置) while (*dest != '\0') { dest++; } // 第二步:从 dest 的结尾开始,执行 strcpy 操作 while ((*dest++ = *src++) != '\0') { ; } return ret; }实现解析:这个实现可以看作strlen(dest)+strcpy(dest_end, src)两个操作的组合。第一个while循环定位到dest字符串的末尾(\0处)。第二个while循环就是我们的my_strcpy逻辑,从dest的末尾开始复制src。同样,它不检查dest剩余空间是否足够,存在缓冲区溢出风险。
5.2 有限长度的连接:strncat 的模拟实现
char *strncat(char *dest, const char *src, size_t n);从src追加最多n个字符到dest末尾,并总是添加一个终止空字符\0。
// 模拟实现标准 strncat char *my_strncat(char *dest, const char *src, size_t n) { char *ret = dest; size_t dest_len = my_strlen(dest); dest += dest_len; // 移动到 dest 的末尾 size_t i; // 复制 src 中的字符,最多 n 个,或者遇到 \0 停止 for (i = 0; i < n && src[i] != '\0'; i++) { dest[i] = src[i]; } // 关键:无论复制了多少个字符,总是在末尾添加 \0 dest[i] = '\0'; return ret; }实现解析:strncat比strncpy的行为要友好得多,也安全得多。
- 它先找到
dest的末尾。 - 然后从
src复制最多n个非\0字符过去。 - 最重要的一点:复制完成后,它总是在目标字符串的末尾添加一个
\0。这意味着dest永远是一个有效的C字符串。 - 它不会像
strncpy那样用\0填充剩余空间。
因此,strncat是相对更安全的连接函数。但调用者仍需确保dest有足够的空间容纳dest原有的字符 +min(n, strlen(src))个新字符 + 1个\0。
6. 字符串比较:strcmp 与 strncmp 的逐字节逻辑
比较函数用于按字典序(ASCII码顺序)比较两个字符串。
6.1 标准 strcmp 的模拟实现
int strcmp(const char *str1, const char *str2);比较两个字符串。返回值为:
< 0:str1小于str2(第一个不匹配字符的ASCII值在str1中小于在str2中,或str1是str2的前缀)。= 0:str1等于str2。> 0:str1大于str2。
// 模拟实现标准 strcmp int my_strcmp(const char *str1, const char *str2) { // 循环比较,直到遇到不相等的字符或遇到 \0 while (*str1 != '\0' && *str1 == *str2) { str1++; str2++; } // 返回两个当前字符的ASCII差值 return *(unsigned char *)str1 - *(unsigned char *)str2; }实现解析:
while循环的条件是:两个指针都没走到结尾(*str1 != ‘\0’),并且当前字符相等(*str1 == *str2)。只要条件满足,就继续比较下一个字符。- 循环退出时,有两种可能:
- 遇到了不相等的字符。
str1走到了结尾(此时*str2可能是\0也可能不是)。
- 返回值计算:将两个当前字符**转换为
unsigned char**后相减。这是为了正确处理负值的char(在一些系统上char默认为signed)。例如,比较”\xFF”和”\x00”,如果按signed char解释,-1 - 0 = -1;如果按unsigned char解释,255 - 0 = 255。标准要求按unsigned char比较,以确保结果一致。 - 如果
str1先结束,且str2在相同位置也是\0,则循环因*str1 == ‘\0’而*str1 == *str2不成立(因为\0 == \0为真,但*str1 != ‘\0’为假),退出循环。此时*str1和*str2都是\0,相减结果为0,表示字符串相等。
6.2 有限长度的比较:strncmp 的模拟实现
int strncmp(const char *str1, const char *str2, size_t n);比较两个字符串的前n个字符。
// 模拟实现标准 strncmp int my_strncmp(const char *str1, const char *str2, size_t n) { if (n == 0) { return 0; // 比较0个字符,认为相等 } while (--n > 0 && *str1 != '\0' && *str1 == *str2) { str1++; str2++; } return *(unsigned char *)str1 - *(unsigned char *)str2; }实现解析:逻辑与strcmp类似,但增加了比较次数n的限制。
- 如果
n为0,直接返回0(相等)。 while循环条件:--n > 0确保最多比较n-1次(因为先减减)。同时,也要满足两个字符串都没结束且当前字符相等。- 循环退出条件可能是:比较次数用尽(
n变为0)、遇到不相等的字符、或某个字符串结束。 - 返回值的计算方式与
strcmp完全相同,比较的是退出循环时的当前字符。
strncmp常用于比较字符串的前缀,或者当你知道只需要比较固定长度时,它更安全,因为它不会因为字符串缺少\0而一直读取越界(尽管标准字符串应该以\0结尾,但破损的数据中可能没有)。
7. 内存操作函数的跨界模拟:memcpy 与 memmove
严格来说,memcpy和memmove不是字符串函数(它们处理的是内存块,不关心\0),但它们与字符串操作息息相关,且实现思想非常经典。
7.1 内存复制:memcpy 的模拟实现与限制
void *memcpy(void *dest, const void *src, size_t n);从src指向的位置开始复制n个字节到dest指向的位置。
// 模拟实现标准 memcpy (基础版本) void *my_memcpy(void *dest, const void *src, size_t n) { char *d = (char *)dest; const char *s = (const char *)src; // 通常的简单实现:按字节从前向后复制 for (size_t i = 0; i < n; i++) { d[i] = s[i]; } return dest; }实现解析与重大限制:这个简单的逐字节复制实现,在大多数情况下工作良好。但是,标准库的memcpy有一个非常重要的限制:它要求源内存区域和目标内存区域不能重叠。如果重叠,其行为是未定义的(Undefined Behavior)。
为什么?考虑src和dest重叠的情况,例如dest在src后面一点。当我们从前向后复制时,src中尚未被复制的数据可能会先被dest覆盖掉,导致复制结果错误。例如,想把”hello”从地址0复制到地址1,期望得到”hhello”,但从前向后复制会得到”hhhhh”。
7.2 可处理重叠的内存复制:memmove 的模拟实现
void *memmove(void *dest, const void *src, size_t n);功能与memcpy类似,但允许源和目标内存区域重叠。它是更安全的选择。
// 模拟实现标准 memmove void *my_memmove(void *dest, const void *src, size_t n) { char *d = (char *)dest; const char *s = (const char *)src; if (d == s || n == 0) { return dest; // 源和目标相同或复制长度为0,直接返回 } // 判断内存区域是否重叠,以及重叠的类型 if (d < s) { // 情况1:目标地址在源地址之前,从前向后复制是安全的 for (size_t i = 0; i < n; i++) { d[i] = s[i]; } } else { // 情况2:目标地址在源地址之后(或相等,但前面已排除相等),从后向前复制以避免覆盖 for (size_t i = n; i > 0; i--) { d[i - 1] = s[i - 1]; } } return dest; }实现解析:memmove的智慧在于复制方向的判断。
- 无重叠或目标在前:如果
dest的地址小于src的地址,或者两者不重叠,从前向后复制是安全的。因为即使重叠,dest在前面,它覆盖的是src已经“用过”的旧数据区域,不会影响后面待复制的数据。 - 目标在后(重叠):如果
dest的地址大于src的地址,并且两者重叠(dest < src + n),此时从前向后复制会破坏源数据。正确的做法是从后向前复制。从最后一个字节开始,依次向前复制,这样源区域中尚未被读取的数据就不会先被目标区域覆盖。
经验之谈:在实际编程中,如果你不确定两块内存是否重叠,永远优先使用
memmove。虽然它的名字暗示着“移动”,但它完全能胜任memcpy的工作,并且在重叠时行为是确定的。现代编译器的优化非常智能,在检测到内存不重叠时,对memmove的调用很可能被优化成与memcpy同样高效的指令。用memmove代替memcpy是一个低成本的高安全习惯。
8. 字符串查找:strchr 与 strstr 的算法思路
查找函数用于在字符串中定位字符或子串。
8.1 查找字符:strchr 的模拟实现
char *strchr(const char *str, int c);在字符串str中查找第一次出现字符c(转换为char)的位置,并返回指向该位置的指针。如果未找到,返回NULL。
// 模拟实现标准 strchr char *my_strchr(const char *str, int c) { if (str == NULL) { return NULL; } char ch = (char)c; // 将 int 转换为 char while (*str != '\0') { if (*str == ch) { return (char *)str; // 找到,返回指针。需要去除 const 限定 } str++; } // 循环结束也没找到,检查是否在找 \0 if (ch == '\0') { return (char *)str; // 标准规定,查找 \0 应返回指向字符串结尾的指针 } return NULL; // 未找到 }实现解析:逻辑很直接,遍历字符串,逐个字符比较。有两个细节需要注意:
- 参数
c是int类型:这是历史原因,为了兼容EOF(通常是-1)。在函数内部,需要将其转换为char类型进行比较。 - 查找空字符
\0:根据C标准,strchr也可以用来查找字符串的结束符\0,此时应返回指向\0的指针。我们的实现通过循环后的一个特殊判断来处理这种情况。
8.2 查找子串:strstr 的朴素算法实现
char *strstr(const char *haystack, const char *needle);在haystack(干草堆)字符串中查找第一次出现needle(针)子串的位置。
// 模拟实现标准 strstr (朴素匹配算法,Brute-Force) char *my_strstr(const char *haystack, const char *needle) { if (haystack == NULL || needle == NULL) { return NULL; } if (*needle == '\0') { return (char *)haystack; // 空子串是任何字符串的子串,返回原串起始位置 } const char *h; const char *n; const char *start = haystack; while (*start != '\0') { h = start; n = needle; // 内层循环,比较从 start 开始的子串是否与 needle 匹配 while (*h != '\0' && *n != '\0' && *h == *n) { h++; n++; } // 判断匹配是否成功 if (*n == '\0') { // needle 全部比较完毕,说明找到了 return (char *)start; } if (*h == '\0') { // haystack 剩余部分长度已经小于 needle,不可能再找到 break; } // 本次匹配失败,start 向后移动一位,继续尝试 start++; } return NULL; // 未找到 }实现解析(朴素算法):
- 外层循环:指针
start从haystack的第一个字符开始,每次尝试作为一个可能的匹配起点。 - 内层循环:用指针
h和n分别从当前的start和needle开头开始,逐个字符比较。 - 匹配成功条件:内层循环一直进行到
*n == ‘\0’,这意味着needle的所有字符都匹配上了,函数返回当前的start指针。 - 匹配失败:如果内层循环因为
*h != *n而退出,说明当前start位置不匹配。start向后移动一位,继续尝试。 - 提前终止优化:如果内层循环因为
*h == ‘\0’而退出(即haystack先到头了),而*n还不是\0,说明剩下的haystack长度已经比needle短,不可能再匹配成功,直接跳出外层循环返回NULL。
这个算法被称为“朴素匹配”或“暴力匹配”,其时间复杂度在最坏情况下是O(m*n),其中m和n分别是两个字符串的长度。对于短字符串,这完全够用。标准库的实现(如Glibc)在检测到needle较长时,可能会使用更高效的算法,如KMP算法或Boyer-Moore算法,这些算法通过预处理needle,可以在某些情况下达到O(m+n)的线性时间复杂度。理解朴素算法是学习这些高级算法的基础。
9. 综合测试与边界条件思考
纸上得来终觉浅,绝知此事要躬行。写完了所有模拟函数,我们必须进行全面的测试,尤其是各种边界情况和异常输入。
9.1 构建一个简单的测试框架
我们可以编写一个简单的main函数来测试我们的实现。为了严谨,应该将我们的模拟函数与标准库函数在相同输入下的输出进行对比。
#include <stdio.h> #include <string.h> #include <assert.h> // 这里插入我们上面实现的所有 my_xxx 函数... int main() { // 1. 测试 strlen printf("Testing my_strlen...\n"); assert(my_strlen("") == strlen("")); assert(my_strlen("hello") == strlen("hello")); assert(my_strlen("a\nb\tc") == strlen("a\nb\tc")); // 2. 测试 strcpy printf("Testing my_strcpy...\n"); char dest1[20]; char src1[] = "Copy this!"; assert(strcmp(my_strcpy(dest1, src1), strcpy(dest1, src1)) == 0); // 测试自我复制 (标准库行为是未定义,但我们实现可能工作) char self[] = "self"; my_strcpy(self, self); // 需要我们的实现能处理这种情况(指针相同) // 3. 测试 strncpy printf("Testing my_strncpy...\n"); char dest2[10]; char src2[] = "HelloWorld"; my_strncpy(dest2, src2, 5); dest2[5] = '\0'; // 手动添加结束符,因为 src2 长度 > 5 assert(strcmp(dest2, "Hello") == 0); char dest3[10] = "XXXXXX"; my_strncpy(dest3, "AB", 5); // dest3 现在应该是: 'A', 'B', '\0', '\0', '\0', 'X', '\0'... assert(dest3[0] == 'A'); assert(dest3[1] == 'B'); assert(dest3[2] == '\0'); // 被填充的 \0 assert(dest3[3] == '\0'); // 被填充的 \0 assert(dest3[4] == '\0'); // 被填充的 \0 // dest3[5] 仍然是原来的 'X',未被修改 // 4. 测试 strcat 和 strncat printf("Testing my_strcat & my_strncat...\n"); char buf1[20] = "Hello"; my_strcat(buf1, " World"); assert(strcmp(buf1, "Hello World") == 0); char buf2[10] = "Hi"; my_strncat(buf2, " there!", 4); // 追加 " the" assert(strcmp(buf2, "Hi the") == 0); // 注意:strncat 会自动加 \0 // 5. 测试 strcmp 和 strncmp printf("Testing my_strcmp & my_strncmp...\n"); assert(my_strcmp("apple", "banana") < 0); assert(my_strcmp("banana", "apple") > 0); assert(my_strcmp("same", "same") == 0); assert(my_strcmp("short", "shorter") < 0); // ‘\0’ 的 ASCII (0) 小于 ‘e’ (101) assert(my_strncmp("abcde", "abcxx", 3) == 0); // 前3个字符相同 assert(my_strncmp("abcde", "abcxx", 5) < 0); // 第4个字符 ‘d’ < ‘x’ // 6. 测试 memcpy 和 memmove printf("Testing my_memcpy & my_memmove...\n"); char arr1[10] = {0,1,2,3,4,5,6,7,8,9}; char arr2[10]; my_memcpy(arr2, arr1, 10); assert(memcmp(arr2, arr1, 10) == 0); // 重叠测试:将 arr1 中 [0,1,2,3,4] 复制到 [2,3,4,5,6] 的位置 // 期望结果: arr1 变成 [0, 1, 0, 1, 2, 3, 4, 7, 8, 9] char arr3[10] = {0,1,2,3,4,5,6,7,8,9}; my_memmove(arr3+2, arr3, 5); assert(arr3[0]==0 && arr3[1]==1 && arr3[2]==0 && arr3[3]==1 && arr3[4]==2 && arr3[5]==3 && arr3[6]==4); // 7. 测试 strchr 和 strstr printf("Testing my_strchr & my_strstr...\n"); char *test_str = "Find the needle in the haystack."; assert(my_strchr(test_str, 'n') == strchr(test_str, 'n')); assert(my_strchr(test_str, 'z') == NULL); assert(my_strchr(test_str, '\0') == test_str + strlen(test_str)); assert(my_strstr(test_str, "needle") == strstr(test_str, "needle")); assert(my_strstr(test_str, "needle") != NULL); assert(my_strstr(test_str, "pin") == NULL); assert(my_strstr("", "") == ""); // 空串是空串的子串 assert(my_strstr("abc", "") == "abc"); // 空串是任何串的子串 printf("All tests passed!\n"); return 0; }9.2 必须考虑的边界与异常情况
通过测试,我们需要特别关注以下几类情况,它们往往是Bug的藏身之处:
- 空指针(NULL):标准库函数在接收空指针时行为是“未定义的”(通常导致程序崩溃)。我们的模拟实现可以选择加入防御性检查(如返回0或NULL),但要知道这偏离了标准行为。在面试或严格模拟时,通常不考虑空指针,因为调用者有责任确保参数有效。
- 空字符串(“”):这是一个有效的字符串(只包含一个
\0)。我们的函数必须能正确处理它,例如strlen(“”)应返回0,strstr(haystack, “”)应返回haystack。 - 零长度操作:
strncpy(dest, src, 0)、memcpy(dest, src, 0)等。这些操作不应该修改任何内存,我们的实现需要正确处理(例如,直接返回dest)。 - 重叠内存:这是
memcpy和memmove的关键区别,也是strcpy在自复制时可能遇到的问题。我们的memmove实现必须正确处理。 - 目标缓冲区大小:这是所有“不安全”函数(
strcpy,strcat,sprintf等)的根源问题。在模拟实现中我们遵循原样,但在实际项目中,必须使用带长度限制的版本(strncpy、strncat、snprintf)或更安全的替代品,并仔细计算缓冲区大小。 - 字符符号性:在
strcmp系列函数中,比较时必须将char转换为unsigned char,以确保比较结果与机器符号表示无关。 - 查找函数的返回值:
strchr查找\0应返回指向结尾的指针,strstr查找空子串应返回原串指针。这些边缘情况容易被忽略。
10. 从模拟实现到工程实践:经验、教训与安全编码
走完这一遍模拟实现,我们收获的远不止几行代码。更重要的是,我们洞悉了这些基础工具的内部逻辑、潜在陷阱和设计哲学。下面是我在实际项目中总结出的几点核心经验:
1. 理解“未定义行为”的代价标准库函数在很多边界条件下(如空指针、缓冲区溢出)的行为是“未定义的”。这意味着编译器可以生成任何代码,程序可能崩溃、产生错误结果、或者看似正常地运行直到某个关键时刻出错。我们的模拟练习让我们亲身体验了这些边界,从而在以后编码时,会对这些地方产生本能的警惕。例如,在调用strcpy前,你脑子里会立刻响起警报:“dest的空间够吗?”
2. 优先使用“n”版本函数,但务必理解其语义strncpy、strncat、snprintf是你的朋友,但它们是“带刺的朋友”。你必须清楚:
strncpy不会自动添加\0,可能产生非终止字符串。strncat和snprintf会保证添加\0(只要目标空间大小参数size> 0)。- 永远记得
strncpy的n是“最大复制字符数”,而strncat和snprintf的n(或size)是“目标缓冲区总大小”。混淆它们会导致灾难。
一个安全的strncpy使用模式几乎总是成对出现:
char buf[64]; strncpy(buf, src, sizeof(buf) - 1); buf[sizeof(buf) - 1] = '\0'; // 手动确保终止3. 考虑使用更现代的替代方案如果你的项目环境允许,可以考虑以下更安全的方案:
strlcpy/strlcat:来自BSD,语义清晰(参数是目标缓冲区总大小,总是保证\0结尾,返回源长度),但非C标准。- C11 Annex K Bounds-checking interfaces:如
strcpy_s,strcat_s。它们有更严格的运行时检查,但普及度不高,且使用稍显繁琐。 - 使用高级语言或库:在C++中,优先使用
std::string。在C中,可以依赖一些经过严格测试的第三方安全字符串库。
4. 内存重叠是隐形的杀手除非你百分之百确定两块内存不重叠,否则对于内存复制操作,无条件使用memmove。memcpy的速度优势在当今编译器优化面前已不明显,而memmove带来的安全性提升是实实在在的。这个习惯能帮你避免许多难以调试的诡异Bug。
5. 手动实现是理解的终极检验当你对某个库函数的行为有疑惑,或者面试中被问到其实现时,没有比在脑子里或纸上模拟一遍其运行过程更好的方法了。这个过程强迫你考虑每一个字节、每一个指针的变化。例如,你能清晰地说出while (*d++ = *s++);这个循环的结束条件吗?它为什么能正确复制\0?
最后,记住C语言字符串的核心是“以\0结尾的字符数组”。这个简单的约定带来了巨大的灵活性和同样巨大的责任。我们的模拟实现之旅,本质上是一次对这份责任的深度体验。把这些函数的内部逻辑刻在脑子里,你就能在纷繁复杂的代码中,对字符串操作保持一份清醒和掌控力。