ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言字符串函数深度解析:从strlen到strcpy的陷阱与实现

C语言字符串函数深度解析:从strlen到strcpy的陷阱与实现

字符串函数是每个写C语言的人都绕不过去的一关。不管你是刚学指针的初学者,还是已经在写业务代码的老手,只要代码里出现过文本处理,就必然跟string.h里的这几个函数打过照面:strlen、strcpy、strcmp、strcat、strchr。它们看起来简单,但实际使用中翻车率极高,缓冲区溢出、返回值比较反直觉、局部越界查半天查不出来……这些问题我踩过不少,身边同事也经常被坑。这篇东西就是把它们一个个拆开,把声明、行为、边界条件、常见误用、手写实现逻辑都讲透,适合正在学C语言的朋友,也适合准备面试、或者想把手头代码里的隐患清一遍的人。

1. 这批函数到底在解决什么问题

1.1 C语言字符串的真实样子:没有“字符串”这个类型

首先要明白一个前提:C语言里根本没有字符串类型。所谓字符串,本质上是一段连续的内存,结尾有一个值为0的字节(也就是'\0',空字符)。所有str开头的库函数,都是在跟这种“以空字符结尾的字符数组”打交道。

这个设计有它的历史原因。几十年前内存极其珍贵,不可能为字符串单独设计一套运行时结构,于是干脆用“约定”来代替“类型”:你告诉我起始地址,我一路往下读,直到碰到0为止。这个约定非常简单,但也带来两个直接后果:

  • 没有一个地方记录字符串“有多长”,想知道长度只能从头数到尾,这是strlen为什么是 O(n) 的原因。
  • 没有一个地方记录缓冲区“有多大”,函数只能假设你给的目标空间足够大,不够就继续写,写到别人的地盘上去。

理解这一点,你会突然对很多“莫名其妙”的 bug 豁然开朗。比如strcpy把源字符串复制到目标,它根本不知道目标数组只有 16 字节,它只知道一路复制到'\0'为止。源字符串 40 字节,它就写 40 字节。溢出,或者说缓冲区溢出,就是这么来的。

1.2 为什么这些库函数不能丢

有人可能会说:现在写代码不都用 C++ 的std::string吗?C 里也有snprintf、memcpy,为什么还要单独学这几个“老古董”?

我的看法是:第一,嵌入式、系统底层、协议解析、操作系统内核,以及大量已有的 C 项目仍然以它们为基础,你看得懂、改得动这些代码,前提就是熟练这些函数。第二,std::string的底层实现也绕不开内存拷贝和长度计算,理解 C 字符串模型之后,你才能理解它内部为什么需要\0结尾、为什么返回的引用会失效、为什么会涉及深拷贝。第三,面试几乎必考。手写strcpy、strcmp是经典中的经典,它考的可不只是背代码,而是对指针、边界、类型转换、内存模型的综合理解。

还有一点值得说:这些函数的设计确实粗糙,但标准库后来给出的“安全版本”(比如strncpy、strncat)也并不是完美答案,它们有自己的坑。我会在后面专门讲这一段。你只有把老版本的行为吃透,才有可能真正理解新版本为什么安全、安全在哪里、缺陷又在哪里。这是没法跳过的基本功。

2. 逐个拆解:定义、行为与边界

2.1 strlen:别被它的简单迷惑

strlen的原型是:

size_t strlen(const char *s);

它做一件事:从s指向的地址开始,逐字节向后扫描,直到遇到'\0',然后返回“空字符之前的字符个数”。这个返回值不包含'\0'本身。

看起来再简单不过,但至少有三个细节值得你注意。

第一,返回值是size_t,是无符号类型。这会导致一个很经典的坑:

if (strlen(s) - 1 >= 0) { // 永远成立 // 这里的代码永远会执行 }

strlen(s)如果返回0,0 - 1在无符号运算下变成一个极大的数,当然大于等于0。正确写法应该是:

if (strlen(s) >= 1) { ... }

或者先把返回值转成有符号类型再做比较。我当时第一次看到strlen(s) - 1这个写法时以为是自己 C 语言没学好,后来才发现是类型转换的问题。这个细节在代码评审里出现过不止一次,值得记住。

第二,它指望字符串必须以'\0'结尾。如果某个字符数组没有空字符,strlen就会越界扫描,直到某块内存里碰巧有个0才停下来,返回一个根本不可信的大数字。扫描越界本身就是未定义行为,程序可能表现正常,也可能立刻崩溃。所以规则很简单:对谁调用strlen,谁就必须是一个“真正的 C 字符串”。

第三,不要重复调用strlen。在循环里写for (i = 0; i < strlen(s); i++),每一次循环都从头数一遍字符串长度,复杂度直接翻成 O(n²)。字符串短还好,长字符串会有肉眼可见的性能浪费。正确做法是先把长度存到变量里:

size_t len = strlen(s); for (size_t i = 0; i < len; i++) { ... }

2.2 strcpy 与 strcat:方便却危险的两兄弟

这两个函数经常被一起讨论,因为它们的危险模式一模一样。

char *strcpy(char *dest, const char *src); char *strcat(char *dest, const char *src);

strcpy把src指向的字符串(包括结尾的'\0')复制到dest指向的地址。strcat则先找到dest里'\0'的位置,再从那里开始追加src的内容。

它们的共同问题是:都不检查目标缓冲区到底有多大。strcpy不检查,strcat同样不检查。只要src的实际长度把目标挤爆,超出部分就会写到相邻内存里。这种溢出在 C 语言里属于未定义行为,直接表现通常是:

  • 目标数组紧邻的其他变量被篡改;
  • 程序运行到某个地方莫名其妙崩溃;
  • 函数返回地址被覆盖,程序执行流程被劫持(缓冲区溢出攻击就是利用这点);
  • 开启调试器的优化模式后,现象更诡异。

strcat还要格外注意一个叠加问题:如果dest和src是同一块内存,或者位置重叠,行为是未定义的。因为strcat会先把dest的结尾找到,然后边复制边把源数据读走,而源可能已经被自己覆盖了。我自己测试过,strcat(buf, buf)在很多编译器下会陷入一种死循环式的增长,最终把整个内存写满。

使用这两兄弟的安全守则只有一条:你必须用某种方式提前保证目标缓冲区有足够空间,再进行调用。比如:

char dest[32]; if (strlen(src) < sizeof(dest)) { strcpy(dest, src); } else { // 处理源字符串过长的情况 }

2.3 strcmp:比较的真实规则

strcmp的原型:

int strcmp(const char *s1, const char *s2);

它的行为是按字节逐个比较两个字符串中对应位置的字符,直到遇到第一个不相等的字符,或者某一方遇到'\0'。比较的基础是字符的编码值(通常就是 ASCII 码)。返回值的规则是:

  • 如果s1大于s2,返回正数;
  • 如果s1小于s2,返回负数;
  • 如果完全相等,返回 0。

很多人误以为返回值一定是1或-1,其实标准只规定了“正数”和“负数”,并没有规定具体数值。所以在判断时应该用== 0、> 0、< 0,而不是去比较返回值是否等于某个特定值。

还要注意一个点:strcmp的比较是按字典序的,不是按长度。比如"abc"和"abcd",前三个字符相同,然后"abc"遇到'\0',比较结果为负,这说明短的字符串小于长的前缀相同的字符串。如果你想按长度再按内容去比较,就得自己写逻辑。

标准规定strcmp的比较应该像是把字符看作unsigned char来进行,这在char类型默认是有符号的平台上很关键。如果你手写实现时直接return *s1 - *s2;,在中文环境或者使用扩展编码时可能得到错误的正负判断。正确做法是:比较前把char转成unsigned char再相减。

int my_strcmp(const char *a, const char *b) { while (*a && (*a == *b)) { a++; b++; } return (unsigned char)(*a) - (unsigned char)(*b); }

2.4 strchr 与它的兄弟们:查找字符的那点事

strchr是在字符串中查找一个字符首次出现的位置,返回指向该字符的指针:

char *strchr(const char *s, int c);

这里有个不太直观的细节:参数c的类型是int,但函数内部会把c转换成char来比较。标准里写明,这个转换和查找行为应该是“把字符视为 unsigned char”来进行的。如果找不到对应字符,返回NULL。

strchr最常见的用途是拆字符串。比如把"key=value"从'='处拆开:

char *p = strchr(str, '='); if (p) { *p = '\0'; // 在 '=' 处截断 char *key = str; char *value = p + 1; }

这里有个非常容易被忽视的点:strchr在查找'\0'时,如果传入的c是0,它会返回指向字符串末尾空字符的指针,而不是返回NULL。这是标准行为,很多人在做“查找空字符”时会踩一脚,以为找不到。理解之后,其实可以利用这个特性来判断一个字符是否在字符串中出现过:判断c是否是'\0'时,strchr的返回语义和普通字符不一样。

与strchr配套的还有几个函数:

函数作用注意点
strrchr从右往左查找最后一次出现的字符返回最后一个匹配位置
strstr查找子串第一次出现的位置需要匹配多个字符
strpbrk在字符串中查找任意一组字符中的第一个匹配接受“字符集合”而不是单个字符
memchr在指定的内存块中查找字符需要显式传入长度,不依赖'\0'

strstr是找子串,底层实现通常有优化算法(双指针滑动匹配),复杂度优于暴力逐字符比较。如果你只需要找单个字符,用strchr就够了;需要找集合里的任意一个,用strpbrk;需要在一个非字符串的内存区域里找,则必须用memchr。选错函数要么效率降低,要么语义不对。

2.5 strncpy 与 strncat:带 n 的安全版本并不完美

标准库后来加了一组带长度限制的函数,看起来安全,实际上坑也不少。

char *strncpy(char *dest, const char *src, size_t n); char *strncat(char *dest, const char *src, size_t n);

strncpy最多从src复制n个字符到dest。这里面的坑是:如果src的长度小于等于n,函数会把剩余位置全部用'\0'填充;如果src的长度大于等于n,则复制了n个字符后不会自动补'\0'。也就是说,strncpy的结果不一定是一个“正常的 C 字符串”。最常见的建议是:

char dest[32]; strncpy(dest, src, sizeof(dest) - 1); dest[sizeof(dest) - 1] = '\0';

先把最后一个位置置空,避免dest不以'\0'结尾。这样写确实避免了缓冲区越界,但也放弃了strncpy的“零填充”特性,浪费了一点性能。

strncat的行为和strncpy不同:它最多追加n个字符,并且总是在结果末尾补一个'\0'。所以调用它时,需要保证目标缓冲区除了原有内容之外,还能容纳n + 1个字符。很多人把n直接传成sizeof(dest),然后发现追加后没有位置放'\0',就出问题了。正确理解是:n控制的是“最多追加多少个字符”,而不是“目标缓冲区总大小”。

实际上我对项目里老代码的建议是:如果你只是想安全地复制一个以空字符结尾的字符串,别用strncpy,直接用snprintf更直观也更安全:

snprintf(dest, sizeof(dest), "%s", src);

这条语句在目标空间不足时会自动截断,并且保证结果以'\0'结尾。代价是效率比strcpy低一些,但换来的是安全性和清晰度,值得。

3. 读懂背后的实现:自己动手写一遍

3.1 几个教科书级别的实现

自己在纸上写一遍这些函数,对理解它们的行为帮助极大。我先给一版最基础、最直白的实现,不考虑优化,重点是把逻辑讲清楚。

// strlen:数到空字符为止 size_t my_strlen(const char *s) { const char *p = s; while (*p) { p++; } return (size_t)(p - s); } // strcpy:先把空字符也复制过去 char *my_strcpy(char *dest, const char *src) { char *d = dest; while ((*d++ = *src++)) ; return dest; } // strcat:先找到 dest 的结尾,再复制 src char *my_strcat(char *dest, const char *src) { char *d = dest; while (*d) d++; while ((*d++ = *src++)) ; return dest; } // strcmp:逐字符比较,遇到不同或空字符停止 int my_strcmp(const char *a, const char *b) { while (*a && (*a == *b)) { a++; b++; } return (unsigned char)(*a) - (unsigned char)(*b); } // strchr:返回第一个匹配字符的指针 char *my_strchr(const char *s, int c) { char ch = (char)c; for (;; s++) { if (*s == ch) { return (char *)s; } if (*s == '\0') { return NULL; } } }

注意my_strchr里返回(char *)s的转换。传入参数是const char *,但按标准要求返回的是char *,所以必须做一个去掉const的强制转换。这个转换在语义上有点别扭,但标准就是这么规定的。

my_strcpy里的while ((*d++ = *src++));是非常经典的一行。它做了三件事:把*src赋值给*d,然后两个指针都自增,最后判断赋值语句的结果(也就是被赋的字符)是否为 0。等于说连空字符一起复制,然后在空字符处停止循环。

my_strcmp的返回值用了(unsigned char)(*a) - (unsigned char)(*b)。如果不转成unsigned char,在char是有符号的平台上,遇到扩展字符(比如大于127的字节),可能出现负数当成正数的反直觉结果。

这版实现虽然能用,但性能上还有优化空间。比如strlen、strcpy在编译器里通常会被替换成更高效的实现:一次读取多个字节,检查每个字节是否为0,利用位运算一次判断4个或8个字符,然后用编译器提供的向量化指令。日常使用不用你手动写这些,但理解这一点,面试时提到“编译器会做优化、我们会选择面向宽度的实现”,会是一个不错的加分项。

3.2 内存重叠:为什么必须用 memmove

这几个字符串函数有个共同的隐含前提:源和目标不能重叠。一旦重叠,行为就是未定义的。典型场景是你想把一个字符串往后移动几个位置,或者往前移动几个位置,比如给某个片段腾出空间。如果用strcpy或memcpy处理重叠区域,就可能出现严重的复制错乱。

举个例子:

char buf[] = "abcdef"; // 想把 "cdef" 移动到 buf+2 的位置(实际就是原 buf 里的 cdef) strcpy(buf + 2, buf + 2);

这种复制在memcpy和strcpy下结果不可预知,需要处理源和目标重叠的拷贝时,标准做法是使用memmove:

char buf[] = "abcdef"; memmove(buf + 2, buf, 4); // 把 "abcd" 移到 "cdef..." 处

memmove的实现会先判断源地址和目标地址的前后关系,决定是从前往后拷贝还是从后往前拷贝,确保重叠时也能得到正确结果。

这个知识点在项目里非常实用。比如解析网络数据包时,你经常要把一段数据往前移动几个字节,去掉头部或者调整对齐,这时候用memmove比memcpy稳妥得多。同样,strcpy和memcpy都做不到的事情,也别指望strncpy能处理,它们都属于“不允许重叠”的范畴。

3.3 手写版本在面试中的要义

很多人以为面试手写strcpy就是背一段代码,其实考官真正想看的是你考虑边界条件的能力。我见过不少候选人能写出循环体,但忽略了扣分点。下面这几个点是我觉得面试时最有区分度的地方:

  • strcpy需要返回dest指针,而不是返回void。虽然有返回值和没有返回值功能上都能完成复制,但标准库的签名是char *,这样调用方可以链式调用,比如strlen(strcpy(dst, src))。
  • 要保留最初的dest指针。因为循环体里dest指针一直在移动,直接return dest;返回的就是指向末尾的指针,完全错误。
  • strcmp要谈字符类型的转换。这里体现你对“char 是否有符号”这类实现差异的理解。
  • strchr的const语义转换要能解释清楚。
  • 如果能进一步说出strcpy、strcat的安全隐患以及替代方案,说明你不只是背了 API,而是真的理解内存模型。

4. 实操实录:一段代码看清这些函数的正确用法

4.1 完整的可运行 Demo

把理论落到代码里。下面这个 Demo 基本上把这几个函数的主要用法都覆盖了,注释写清楚每一步在做什么:

#include <stdio.h> #include <string.h> int main(void) { const char *name = "data"; char path[64]; char log_msg[64]; // 1. strlen:获取长度 size_t len = strlen(name); printf("name length: %zu\n", len); // 2. strcpy:安全的复制方式,先手动判断空间 if (len < sizeof(path)) { strcpy(path, name); printf("path: %s\n", path); } else { fprintf(stderr, "buffer too small\n"); return 1; } // 3. strcat:拼接,先计算剩余可用空间 const char *suffix = ".log"; size_t suffix_len = strlen(suffix); size_t used = strlen(path); if (used + suffix_len + 1 <= sizeof(log_msg)) { strcpy(log_msg, path); strcat(log_msg, suffix); printf("log_msg: %s\n", log_msg); } else { fprintf(stderr, "no space for log suffix\n"); return 1; } // 4. strcmp:比较 if (strcmp(name, "data") == 0) { printf("name equals 'data'\n"); } // 5. strchr:定位字符 char *dot = strchr(log_msg, '.'); if (dot) { printf("extension: %s\n", dot + 1); } else { printf("no dot found\n"); } // 6. memmove 处理重叠区间 char buffer[32] = "hello world"; memmove(buffer + 6, buffer, 5); buffer[11] = '\0'; printf("memmove result: %s\n", buffer); return 0; }

这段代码里我最想强调的其实是第 3 步:先计算当前已用长度、剩余空间,再进行strcat。很多人在实际项目里会偷懒,直接写strcat(log_msg, suffix);。当时没问题,后来某一天suffix变成了用户输入的长字符串,缓冲区立刻溢出,整个模块的日志输出错乱,排查了很久才发现是拼接处出了问题。

4.2 缓冲区大小怎么算才不出错

跟字符串函数打交道,最核心的技能其实是“算缓冲区”。我总结了一个习惯:每次要往某个缓冲区写东西之前,先问三个问题:

  1. 缓冲区总共多大?
  2. 已经用了多少?
  3. 还剩多少可用空间?

用一个简单公式记忆:总大小 = 已用长度 + 待追加长度 + 1。那个+1是留给'\0'的,最容易丢的就是它。

举例说明:

char dest[64]; const char *prefix = "user:"; const char *name = "alice"; size_t total = sizeof(dest); // 64 size_t used = strlen(prefix); // 5 size_t need = strlen(name) + 1; // 6 if (used + need <= total) { strcpy(dest, prefix); strcat(dest, name); } else { // 处理空间不足 }

注意strlen(prefix)代表的是逻辑长度,sizeof(dest)代表的是数组的“容量”。很多人犯的错误是把这两个概念混在一起,拿sizeof(dest)去跟strlen(src)比较,以为空间够了,结果忽略了'\0'还要占一个字节。这个问题在字符数组作为函数参数传入时尤其容易踩:数组一旦退化成指针,sizeof就再也不能得到缓冲区容量了。

void append_suffix(char *dest, size_t dest_size, const char *suffix) { size_t used = strlen(dest); if (used + strlen(suffix) + 1 > dest_size) { return; // 空间不足,直接拒绝操作 } strcat(dest, suffix); }

在函数里接收char *dest的同事,必须同时把dest_size传进来,否则根本无法判断安全边界。如果你在项目里看到有人写了void f(char *dst)然后在内部调用strcpy(dst, src),我没有办法不担心。

4.3 替代方案怎么选

既然strcpy、strcat这么危险,实际项目里是不是就不该用?我不主张一刀切。当你明确知道目标缓冲区足够大、数据源可信、性能敏感,并且代码中已经用断言或显式检查覆盖了前提条件时,直接用strcpy完全没问题。但如果这些前提不那么确定,更稳的方案是:

  • snprintf(dst, size, "%s", src):适用于“复制一个字符串到已知大小的缓冲区”,自动截断并保证空字符结尾。
  • strncat(dst, src, n):适用于“追加不超过 n 个字符”,但你必须搞清楚n的含义。
  • memmove:适用于源和目标可能重叠的情况。
  • memcpy:适用于没有重叠、且已知精确长度的情况,效率比字符串函数高。

对截断的处理要额外小心。snprintf截断后返回的值是“如果空间足够应该写入的字符数”,而不是实际写入的字符数。如果你想判断是否发生截断,要用返回值去和缓冲区大小比较,而不是靠strlen(dst)。

5. 常见问题与排查技巧

5.1 高频 bug 速查

我把这些年实际工作里看到的高频 bug 整理成了一个表,基本覆盖了八成以上的现场问题:

现象常见原因解决办法
程序崩溃在某个strcpy/strcat附近目标缓冲区太小先检查空间,或改用snprintf
局部变量被悄悄篡改某个strcpy写越界,覆盖了相邻变量用调试器看数组周边内存;检查所有复制点
strlen返回巨大数字传入的数组没有'\0'检查数组初始化;使用memcpy+显式长度
strcmp比较结果不符合预期手写实现没有考虑 unsigned char;或者拿返回值去和 1 比较改用strcmp,比较时只用 0/正/负
strncat追加后字符串不完整n传的是sizeof(dest),而不是“最多追加几个字符”传入剩余空间 - 1
strncpy后输出乱码src长度恰好大于等于n,结果没有'\0'手动补'\0'在最后
修改字符串后其他指针内容也变了多个指针指向同一块内存,或复制的只是指针确认是否需要深拷贝
字符串拼接后长度不是期望值多次strcat,但没有同步更新缓冲区余量每步都重新计算used + need

最让我印象深刻的一个案例是这样:某同学写了一段解析配置文件的代码,把一个key=value字符串拆开,然后用strcpy把value复制到另一个缓冲区。看起来天衣无缝,但运行到后期程序遇到大文件就崩。调试后发现,文件里有一行的value后面没有换行符,strcpy一直把后面的内存数据也复制过去,缓冲区写爆了之后,堆结构被破坏,直到很久之后系统才崩溃。这个问题如果早点对源字符串做长度校验,完全能避免。

5.2 排查工具与手段

遇到字符串相关的崩溃,我推荐按下面的顺序排查:

  1. 开启编译器的地址消毒器(AddressSanitizer)。编译时加-fsanitize=address -g,运行时会直接报出哪个函数哪一行发生了越界写入,以及越界了多少字节。这个工具只要用过一次,你就会后悔以前怎么依赖人肉 Debug。
  2. 用调试器在可疑函数上设置条件断点。比如我想知道哪一次调用strcpy的源字符串特别长,可以在断点里写strlen(src) > 100,命中后再查看调用栈和变量。
  3. 打印缓冲区容量和已用长度。写一个小的诊断函数,把每个缓冲区的sizeof、strlen、剩余空间都打出来,崩溃前后对比。
  4. 关注编译警告。-Wall -Wextra -Wformat=2能抓出不少printf格式串与参数类型不匹配的问题。strcpy本身不会给出警告,但某些静态分析器会提示你“使用不安全函数”。

还有一个习惯我认为极其有用:字符串相关操作全部封装成工具函数,比如:

int safe_strcpy(char *dst, size_t dst_size, const char *src); int safe_strcat(char *dst, size_t dst_size, const char *src);

这两个函数内部做长度检查,空间不足时返回错误码。项目里所有地方都用它们,以后想在上面加日志、加统计、加全局开关,只需改一个文件。这个思路比“每次都记得手动判断空间”可靠得多,因为人的记性是有极限的。

5.3 面试与工作中的一个核心心态

最后说点我的体会。字符串函数这批接口,看起来是你编程早期的启蒙知识,但它真正考验的是你对“内存边界”的敏感度。写业务代码时,任何一个函数调用都可能是越界的起点,而“越界”往往不立刻报错,而是隔了很久、在完全不相干的地方爆发。

我个人的建议是:不要只满足于“会用”,要去把每个函数的行为边界摸一遍。你对strlen的尽头、strcpy的越界、strcmp的类型转换理解得越清楚,写出来的代码就越经得起推敲。面试考这东西,本质上是在看你这个人有没有“把细节搞清楚之后再动手”的素养。工作多年之后回头看,这几个老函数教给我的东西,比很多花哨的框架都实在。

如果你正在学 C,或者正准备面试,不妨把这篇里提到的例子全部手动编译运行一遍,改一改参数,看看崩溃现象是什么样的。踩过几次坑,你就再也不会忘了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进