公司动态

手写strlen、strcpy、strcat、strcmp、strncpy:深入C字符串内存本质

📅 2026/8/27 23:46:40
手写strlen、strcpy、strcat、strcmp、strncpy:深入C字符串内存本质
1. 为什么我们要亲手写一遍 strlen、strcpy、strcat、strcmp——不是为了造轮子而是为了“看见”内存你有没有试过在调试一个字符串处理逻辑时明明代码看起来没问题但程序却在某个 strcpy 后突然崩溃或者 strcat 拼接后发现前一个字符串的末尾多了一堆乱码又或者 strcmp 返回值和你预想的完全相反查了半小时才发现自己把返回值当成了布尔真假用这些不是玄学是 C 语言字符串操作里最真实、最普遍的“踩坑现场”。而所有这些问题的根源都藏在那五个看似简单的函数背后strlen、strcpy、strcat、strcmp还有常被忽略但极其关键的 strncpy我们把它作为第5个来深入拆解。它们不是黑箱而是我们每天都在调用的“内存搬运工”——没有图形界面没有网络协议栈只有指针在内存里一格一格地挪动、比较、复制。我带过十几届嵌入式和底层开发新人发现一个惊人规律凡是能手写这五个函数且讲清楚每一步内存动作的人调试能力、边界意识和代码鲁棒性平均比只背函数原型的人高出一个数量级。这不是炫技而是建立一种“内存直觉”——你知道字符怎么存、空字符怎么标记、数组越界发生在哪一行、为什么 strcpy 不检查目标空间大小、为什么 strcmp 的返回值要减不要比。这篇内容就是带你从零开始一行一行写出这五个函数不依赖任何头文件不调用任何库函数只用最基本的指针、循环和条件判断。你会看到strlen 怎么靠一个 while 循环就数清了长度strcpy 怎么在复制完所有字符后还必须手动补上 \0strcat 怎么先找到第一个字符串的尾巴再把第二个字符串“接”上去strcmp 怎么逐字比较一发现不同就立刻返回差值而 strncpy 又是怎么用一个计数器强行给“不安全”的复制套上安全阀。所有代码都附带详细注释每一步都解释“为什么这么写”而不是“怎么写”。适合刚学完指针的 C 新手也适合写了三年业务代码、但对底层细节越来越模糊的开发者。如果你的目标是写出稳定、可维护、能经得住压力测试的 C 代码那么请从亲手实现这五个函数开始。2. 整体设计思路为什么不用标准库为什么必须手动管理 \0为什么参数顺序不能错2.1 核心设计哲学回归本质拒绝魔法很多人一看到“模拟实现”第一反应是“这有什么用直接 include string.h 不香吗” 这个问题问得非常好它恰恰点中了我们整个设计的出发点。标准库函数是高度优化、经过严格测试的工业级产品它的存在意义是提升开发效率而不是教学。而我们的目标完全不同让抽象的“字符串”概念具象成内存里连续排列的字节让“复制”这个动作变成指针在地址空间里的一次次递增让“比较”这个逻辑变成两个地址上字节值的逐位相减。所以我们刻意绕开所有标准库连 printf 都只用最基础的 putchar 来做简单验证后面会演示目的就是切断一切外部依赖逼你直面最原始的内存操作。这不是复古而是“降维打击”——当你能徒手在裸机上完成字符串操作再回头看任何高级框架里的字符串类都会有一种“不过如此”的通透感。2.2 关键前提C 语言字符串的唯一真相——\0 是命门所有设计决策都源于一个铁律C 语言中字符串不是一种独立的数据类型它就是一个以 \0ASCII 值为 0 的字符结尾的 char 类型数组。这个 \0 不是可有可无的装饰它是整个字符串机制的基石。想象一下strlen 函数如果不知道哪里是结尾它就会一直往后读直到撞上内存里某个碰巧为 0 的字节——这可能在几 KB 之外也可能在下一个变量的起始位置结果就是未定义行为Undefined Behavior轻则返回错误长度重则导致程序崩溃。所以我们在所有实现中都把 \0 的处理放在最核心的位置strcpy 必须在复制完源字符串后主动在目标地址的下一个位置写入 \0strcat 必须先找到目标字符串的 \0再从那里开始粘贴strcmp 在比较到任一字符串的 \0 时必须立即结束并根据另一个字符的值决定返回正负。这个规则不是约定俗成而是 C 语言内存模型的硬性要求。我曾经在一个车载仪表盘项目里因为一个 strncpy 没有手动补 \0导致菜单文字显示错乱排查了整整两天最后发现是后续的 sprintf 因为找不到字符串结尾而写坏了相邻的浮点数变量。教训很痛但从此以后只要看到字符串操作我的第一反应永远是“那个 \0它真的在该在的地方吗”2.3 参数设计与陷阱为什么 strcpy(dst, src) 而不是 (src, dst)函数参数的顺序绝不是随意定的。我们严格按照标准库的签名来设计char *strcpy(char *dest, const char *src)。这里有两个关键点第一目标dest在前源src在后。这是为了语义清晰——“把 src 复制到 dest 里”主语是 dest动作是复制。第二const char *src明确告诉编译器和调用者这个函数不会修改源字符串。这是一个重要的契约它让你在调用时可以放心传入字符串字面量如hello而不用担心被意外改写。反过来如果写成strcpy(src, dest)不仅语义混乱“把 dest 复制到 src 里”而且一旦 src 是只读内存比如代码段里的字符串常量程序在运行时就会因尝试写入只读区域而直接崩溃Segmentation Fault。我在教学生时总会让他们故意把参数顺序写反然后当场运行亲眼看到程序 segfault——这种“痛感教学”比一百遍口头强调都管用。另外所有函数都返回char *类型且返回的是 dest 指针对于 strcpy、strcat或一个整数对于 strcmp。这个返回值不是摆设它支持链式调用比如printf(%s, strcpy(buf, test));但更重要的是它让你能立刻拿到操作后的目标地址方便后续处理。2.4 安全边界为什么 strncpy 是第5个且必须重点讲前四个函数strlen、strcpy、strcat、strcmp都是“无长度限制”的经典版本它们完全信任调用者提供的内存空间足够大。这在早期 C 程序中很常见但也是无数缓冲区溢出漏洞的根源。所以我们把strncpy作为第5个函数引入不是为了凑数而是为了引入一个至关重要的概念显式的长度控制。strncpy(char *dest, const char *src, size_t n)的第三个参数n就是一道安全闸门。它规定了最多复制n个字符。但这里有个极易被忽视的陷阱如果src的长度小于nstrncpy 会用 \0 填充剩余空间但如果src的长度大于或等于n它不会在dest末尾自动添加 \0这意味着你得到的dest可能不是一个合法的 C 字符串。我见过太多人以为strncpy(dest, src, sizeof(dest)-1)就万无一失结果因为忘了在复制后手动加 \0导致后续所有字符串函数都失效。所以在我们的模拟实现里我们会明确写出这个逻辑分支并配上醒目的注释。这不是过度设计而是把生产环境里最常踩的坑提前暴露在学习阶段。3. 核心函数逐行解析与实操实现从原理到代码每一步都有据可依3.1 strlen一个 while 循环如何精准数出字符串长度strlen的任务看似最简单返回字符串的长度。但“长度”在这里有明确定义——从首地址开始到第一个 \0 字符之前的字符个数不包含 \0 本身。所以它的核心逻辑就是从指针指向的地址开始一个字节一个字节地读直到读到值为 0 的字节为止期间用一个计数器累加。关键在于它不能依赖任何其他函数也不能用数组下标虽然语法上等价但我们要强化指针思维。// 模拟实现 strlen size_t my_strlen(const char *str) { size_t len 0; // 逐字节检查直到遇到 \0 while (*str ! \0) { len; str; // 指针后移一位指向下一个字符 } return len; }这段代码只有 5 行但每一行都值得深究。size_t是无符号整数类型专门用于表示对象大小比 int 更安全避免负数长度。const char *str表示我们只读取不修改原字符串。while (*str ! \0)是核心判断*str是解引用操作获取当前指针地址上的字节值\0在内存里就是 0所以这行代码等价于while (*str ! 0)。len和str是同步进行的每确认一个非 \0 字符长度加一指针前进一格。为什么不用 for 循环因为 while 更直观地表达了“条件满足就继续”的意图而 for 循环的三段式初始化、条件、更新在这里反而增加了认知负担。实测时你可以用char test[] abc;来验证my_strlen(test)应该返回 3。注意test数组实际占用 4 字节a,b,c,\0但strlen只关心有效字符数。一个常见的新手错误是把*str ! \0写成str ! \0后者是在比较指针地址和数字 0完全错误。我建议初学者在纸上画出内存布局图test的地址假设是 0x1000那么test[0]在 0x1000test[1]在 0x1001test[2]在 0x1002test[3]即 \0在 0x1003。str初始指向 0x1000每次str后它依次指向 0x1001、0x1002、0x1003当*str读到 0x1003 地址上的值0时循环结束。3.2 strcpy复制不只是搬数据更是重建字符串结构strcpy的任务是将源字符串src完全复制到目标缓冲区dest中并确保dest也是一个以 \0 结尾的合法字符串。这里的关键陷阱是标准 strcpy 不检查 dest 的空间大小它假定你已经确保 dest 足够容纳 src 的全部内容包括结尾的 \0。所以我们的实现必须严格遵循这一契约同时清晰地展示其内部动作。// 模拟实现 strcpy char *my_strcpy(char *dest, const char *src) { char *original_dest dest; // 保存原始 dest 地址用于返回 // 逐字节复制包括 \0 while (*src ! \0) { *dest *src; // 将 src 当前字符赋值给 dest 当前位置 dest; src; } *dest \0; // 关键手动添加结尾 \0 return original_dest; }这段代码的核心在于“复制到 \0 并额外写 \0”。循环条件*src ! \0确保了src中的所有有效字符都被复制。循环体内*dest *src是一次内存写入操作dest和src让两个指针同步前进。最关键的一步在循环之后*dest \0;。为什么需要这一步因为当*src等于 \0 时循环结束此时src指向了源字符串的 \0但dest指向的是目标缓冲区中对应这个 \0 的位置——而这个位置我们还没有写入任何东西如果不手动写入 \0dest就只是一个字符数组不是一个字符串。我曾经在调试一个网络协议解析模块时发现接收缓冲区里的字符串总是多出奇怪的字符最后定位到是某处 strcpy 后忘了补 \0导致后续的 strtok 把缓冲区后面随机的内存数据也当成了字符串的一部分。所以在你的实现里*dest \0;这行代码绝对不能省略也不能写在循环内部那样会覆盖掉最后一个有效字符。另外返回original_dest是为了支持链式调用比如puts(my_strcpy(buf, hello));。3.3 strcat拼接的本质是“找尾巴”再“接上去”strcat的任务是将src字符串“追加”到dest字符串的末尾。这里的关键词是“追加”意味着dest必须是一个已存在的、以 \0 结尾的字符串而src的内容要被接到这个 \0 的位置之后。所以strcat的第一步永远是“寻找dest的尾巴”。// 模拟实现 strcat char *my_strcat(char *dest, const char *src) { char *original_dest dest; // 第一步找到 dest 的结尾即第一个 \0 while (*dest ! \0) { dest; } // 第二步从这个 \0 位置开始复制 src包括其 \0 while (*src ! \0) { *dest *src; dest; src; } *dest \0; // 再次手动添加结尾 \0 return original_dest; }这个实现清晰地分成了两个阶段。第一阶段找尾巴和strlen的逻辑几乎一样只是我们不计数而是让dest指针一路走到 \0 的位置。此时dest指向的就是dest字符串的“空位”。第二阶段复制和strcpy的循环部分完全一致把src的所有字符包括它的 \0复制过去。最后的*dest \0;看似多余但其实至关重要因为第二阶段的循环在*src \0时结束此时src指向了src的 \0dest指向了dest新结尾的位置而*dest *src这句并没有执行循环条件不满足所以我们必须手动写入这个 \0。一个经典的错误用法是strcat(dest, hello);其中dest是一个未初始化的局部数组比如char dest[10];。由于dest里全是随机垃圾值while (*dest ! \0)可能要遍历几十甚至上百个字节才能找到一个 0结果就是越界读取引发崩溃。这就是为什么strcat的前置条件如此重要dest必须是合法的、已初始化的字符串。在实际项目中我习惯在调用strcat前先用memset(dest, 0, sizeof(dest));初始化整个缓冲区一劳永逸地避免这个问题。3.4 strcmp比较不是“相等与否”而是“谁大谁小”的精确度量strcmp的任务是比较两个字符串的字典序lexicographical order并返回一个整数指示关系。它的返回值有严格定义如果str1小于str2返回负数如果str1大于str2返回正数如果相等返回 0。注意它返回的不是 -1、0、1 这样的固定值而是两个字符的 ASCII 码之差。这个设计非常精妙它提供了丰富的信息量差值的大小反映了“差异程度”符号则指示了大小关系。// 模拟实现 strcmp int my_strcmp(const char *str1, const char *str2) { // 逐字节比较直到出现不同或任一字符串结束 while (*str1 *str2) { if (*str1 \0) { // 如果当前字符都是 \0说明两个字符串完全相同 return 0; } str1; str2; } // 此时 *str1 ! *str2返回它们的差值 return (unsigned char)*str1 - (unsigned char)*str2; }这段代码的精妙之处在于循环条件*str1 *str2。它隐含了一个前提只要相等就继续。循环内部的if (*str1 \0)是终止条件当两个指针都指向 \0 时意味着我们已经比较完了所有字符且全部相等所以返回 0。如果循环因为*str1 ! *str2而退出说明在某个位置两个字符串的字符不同。此时return (unsigned char)*str1 - (unsigned char)*str2;这行代码就决定了最终结果。为什么要强制转换为unsigned char因为char在某些平台是有符号的如果*str1是\xFF255而char是 signed它会被解释为 -1减法结果就会出错。强制转为unsigned char确保了我们得到的是正确的 8 位无符号值。举个例子比较abc和abd。前两个字符 aa, bb指针都前进。第三个字符 c (99) vs d (100)差值是 -1所以返回 -1表示abc小于abd。再比如ab和abc前两个字符相等str1指向 \0str2指向 c此时*str1 \0为真但*str2不为 \0所以循环退出返回0 - c即 -99。这符合定义较短的字符串在字典序上小于较长的、以它为前缀的字符串。3.5 strncpy安全的代价是程序员必须承担的“补 \0”责任strncpy是前四个函数中唯一一个带长度参数的它的设计初衷就是为了防止缓冲区溢出。但它的行为也最为“反直觉”因此必须重点剖析。// 模拟实现 strncpy char *my_strncpy(char *dest, const char *src, size_t n) { char *original_dest dest; size_t i; // 情况1src 长度小于 n复制全部 src并用 \0 填充剩余空间 for (i 0; i n src[i] ! \0; i) { dest[i] src[i]; } // 情况2src 长度 n只复制前 n 个字符不自动加 \0 // 所以我们需要手动填充剩余空间为 \0 for (; i n; i) { dest[i] \0; } return original_dest; }这个实现清晰地展现了strncpy的双重逻辑。外层for循环的条件i n src[i] ! \0是关键它同时受长度n和源字符串实际长度的约束。只要i没到n且src[i]不是 \0就继续复制。循环结束后i的值就是实际复制的字符数。然后第二个for循环for (; i n; i)从i开始一直填充到n-1位置全部设为 \0。这就是strncpy的核心安全机制它保证 dest 的前 n 个字节要么是 src 的内容要么是 \0永远不会越界。但这也带来了代价如果src的长度大于或等于n那么dest的第n个字节即dest[n]不会被设置为 \0。这意味着dest在这种情况下不是一个以 \0 结尾的字符串例如char buf[5]; my_strncpy(buf, hello, 4);buf会变成h,e,l,l而buf[4]即第五个字节是未定义的垃圾值。后续如果用printf(%s, buf);就会一直打印直到遇到内存中的某个 \0结果不可预测。所以使用strncpy的黄金法则就是在调用后必须手动确保 dest 的最后一个字节是 \0。最稳妥的做法是my_strncpy(buf, src, sizeof(buf)-1); buf[sizeof(buf)-1] \0;。我在一个金融交易系统里曾因为一个strncpy后没补 \0导致日志记录模块把交易ID后面一大片内存都当成了字符串输出泄露了敏感的内存地址信息。那次事故后团队立下规矩所有strncpy调用后面必须紧跟一行dest[n-1] \0;并作为代码审查的必检项。4. 实操过程与完整验证从编译、测试到避坑指南4.1 完整可运行的测试代码框架光看函数实现还不够我们必须把它放到真实的环境中跑起来。下面是一个完整的、可直接编译运行的测试程序它包含了所有五个函数的定义、以及详尽的测试用例。我特意避开了#include stdio.h的复杂格式化输出只用最基础的putchar和putsputs是为了方便它内部也依赖 \0正好验证我们的实现是否正确。#include stdio.h // 仅用于 puts 和 printf 做简单输出核心逻辑不依赖它 // 所有函数的声明放在这里避免重复 size_t my_strlen(const char *str); char *my_strcpy(char *dest, const char *src); char *my_strcat(char *dest, const char *src); int my_strcmp(const char *str1, const char *str2); char *my_strncpy(char *dest, const char *src, size_t n); int main() { // 测试用例1strlen char test1[] Hello; printf(Test 1 - strlen: \%s\ - %zu\n, test1, my_strlen(test1)); // 测试用例2strcpy char dest1[10]; my_strcpy(dest1, World); printf(Test 2 - strcpy: \%s\\n, dest1); // 测试用例3strcat char dest2[20] Hello ; // 注意这里初始化了确保有 \0 my_strcat(dest2, C Language); printf(Test 3 - strcat: \%s\\n, dest2); // 测试用例4strcmp char s1[] apple; char s2[] application; printf(Test 4 - strcmp(\%s\, \%s\) - %d\n, s1, s2, my_strcmp(s1, s2)); printf(Test 4 - strcmp(\%s\, \%s\) - %d\n, s1, s1, my_strcmp(s1, s1)); // 测试用例5strncpy (安全场景) char dest3[10] {0}; // 初始化为全0确保安全 my_strncpy(dest3, short, 9); printf(Test 5a - strncpy (safe): \%s\\n, dest3); // 测试用例5strncpy (临界场景) char dest4[5] {0}; my_strncpy(dest4, hello, 4); // 只复制4个hell dest4[4] \0; // 手动补 \0 printf(Test 5b - strncpy (critical): \%s\\n, dest4); return 0; } // 这里放置上面所有的函数定义... // 为节省篇幅此处省略实际使用时请将前面的函数定义粘贴在此处这个测试框架的设计有讲究。首先所有测试字符串都使用数组定义char test1[] Hello;这样编译器会自动在末尾添加 \0确保输入是合法的。其次目标缓冲区dest1、dest2、dest3、dest4都明确指定了大小并在必要时进行了初始化{0}或Hello 这模拟了真实开发中最常见的使用场景。特别是dest4的测试我们特意展示了“临界场景”目标缓冲区大小为 5我们用strncpy复制 4 个字符然后手动补 \0。运行这个程序你应该看到类似这样的输出Test 1 - strlen: Hello - 5 Test 2 - strcpy: World Test 3 - strcat: Hello C Language Test 4 - strcmp(apple, application) - -112 Test 4 - strcmp(apple, apple) - 0 Test 5a - strncpy (safe): short Test 5b - strncpy (critical): hell如果某个测试失败比如Test 2输出了乱码那基本可以断定是strcpy里漏掉了*dest \0;这一行。4.2 编译与调试技巧如何用 gdb 看清内存里的每一个字节写完代码编译通过只是第一步。真正的功夫在调试。我强烈推荐使用gdbGNU Debugger来单步跟踪亲眼看到指针是如何移动、内存是如何被修改的。以下是具体步骤编译时加入调试信息gcc -g -o string_test string_test.c-g参数是关键它告诉编译器生成调试符号让 gdb 能够关联源代码和机器指令。启动 gdbgdb ./string_test设置断点在关键函数入口处打断点比如break my_strcpy然后run运行程序。单步执行与内存查看当程序停在my_strcpy的第一行时使用nextn命令单步执行。每执行一步用print /x $rax查看寄存器或更实用的x/10xb dest查看dest指针开始的 10 个字节的十六进制值来观察内存变化。例如在*dest *src;执行前x/5xb dest可能看到dest初始是0x00 0x00 0x00 0x00 0x00全零执行后第一个字节变成了W的 ASCII 值0x57。检查 \0在strcpy函数即将返回前务必执行x/10xb dest确认dest的末尾确实有一个0x00。这是验证你是否正确实现了字符串终结的最直接证据。我刚开始学的时候总以为“代码跑通了就行”直到有一次一个strcat在嵌入式设备上偶尔出错用 gdb 跟踪才发现是因为目标缓冲区在某些条件下没有被完全初始化导致while (*dest ! \0)循环走了很远。那次经历让我养成了一个习惯任何涉及字符串操作的函数在提交代码前必须用 gdb 单步跟踪至少一次亲眼确认 \0 的位置。这个习惯帮我避开了后面十年里 90% 的字符串相关 bug。4.3 常见问题速查表与独家避坑心得在带新人和 Code Review 的过程中我整理了一份高频问题清单。这些问题90% 都源于对 C 字符串底层机制的理解偏差而非语法错误。问题现象根本原因解决方案我的独家心得strcpy后printf输出乱码或崩溃dest缓冲区太小或strcpy后未确保dest有 \01. 确保dest大小 strlen(src) 12. 检查strcpy实现中是否有*dest \0;心得永远用sizeof(dest)而不是strlen(dest)来计算空间。strlen(dest)在dest未初始化时是无效的而sizeof是编译期常量绝对可靠。strcat拼接后dest里出现大量未知字符dest未初始化while (*dest ! \0)在垃圾内存中乱跑在定义dest时用char dest[SIZE] {0};或memset(dest, 0, sizeof(dest));初始化心得把 {0}当作声明字符串缓冲区的标配语法。它成本极低却能一劳永逸地避免“找尾巴”失败。strcmp返回值忽大忽小无法用 0判断相等错误地认为strcmp只返回 -1/0/1忽略了它返回的是 ASCII 差值严格按规范使用if (my_strcmp(a,b) 0)判断相等if (my_strcmp(a,b) 0)判断小于心得把strcmp的返回值想象成“温度计读数”。正数表示“a 更热”负数表示“b 更热”零表示“温度相同”。永远不要对返回值做 -1这样的判断。strncpy后puts输出超长乱码strncpy复制了n个字符但src长度 n导致dest末尾没有 \0在strncpy后必须手动设置dest[n-1] \0;心得创建一个宏来固化这个操作。#define SAFE_STRNCPY(d,s,n) do { my_strncpy(d,s,n-1); d[n-1]\0; } while(0)。用宏封装既安全又不易遗漏。函数返回的指针在printf中显示为地址而非字符串忘记在printf的格式化字符串中使用%s而用了%p或%d仔细检查printf的格式化字符串%s对应字符串指针%p对应地址%d对应整数心得养成“所见即所得”的调试习惯。如果printf输出的是0x7fff...这样的十六进制那一定是用了%p如果是-123这样的数字那一定是用了%d。除了表格里的问题还有一个隐藏极深的坑字符串字面量的存储位置。像hello这样的字符串是存储在程序的只读代码段.rodata里的。如果你试图用strcpy把东西复制到一个指向字面量的指针上比如char *p hello; strcpy(p, world);程序会在运行时崩溃。正确的做法是目标必须是一个可写的数组比如char buf[10]; char *p buf; strcpy(p, world);。我在一次面试中故意把这个陷阱写进题目结果 80% 的候选人当场栽倒。所以请牢牢记住char *p xxx;创建的是一个指向只读内存的指针char p[] xxx;创建的是一个可写的、位于栈上的数组。5. 从模拟实现到工程实践这些函数在真实项目里是如何被使用的5.1 嵌入式系统资源受限下的字符串操作守则在 MCU微控制器开发中RAM 动辄只有几 KBmalloc是奢侈品string.h里的函数虽然可用但其内部实现可能过于“厚重”。这时我们手写的精简版函数就有了巨大价值。例如在一个基于 STM32 的温湿度传感器节点里我们需要将传感器读数格式化为TEMP:25.3,HUMI:65这样的字符串然后通过 UART 发送出去。整个过程必须在 256 字节的栈缓冲区内完成。标准sprintf可能会引入大量浮点数支持代码体积庞大。而我们自己写的my_strcpy和my_strcat加上一个简单的my_itoa整数转字符串就能完美胜任且代码体积不到 100 字节。更重要的是我们完全掌控了内存的每一个字节知道dest缓冲区的每个位置何时被写入这对于需要严格时序控制的通信协议至关重要。在这种环境下“安全”不是靠strncpy而是靠静态分析人工校验在写代码前就用笔算好所有字符串的最大长度确保