ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

C语言字符串函数与内存边界:从strcpy到memmove的安全实践

C语言字符串函数与内存边界:从strcpy到memmove的安全实践

先抛一个问题:你在C语言里处理字符串的时候,第一反应是去翻函数名,还是先想清楚这块内存长什么样?我见过不少写了好几年C的人,用strcat拼字符串拼得很顺手,直到某一天程序突然崩溃,调试器指着一处缓冲越界,才意识到自己从头到尾都没搞懂'\0'到底意味着什么。这篇就把字符串函数和字符函数一起拆开讲,但我不打算按教科书挨个列一遍,而是从“C语言里的字符串到底是怎么存在的”这个底层逻辑说起,把那些真正容易踩的坑铺开。

这篇文章适合正在学C语言进阶、准备校招笔试、或者在工作中经常和字符串打交道的人。你能得到的不仅是每个函数的用法,更是一套“看到字符串函数时应该如何推理内存行为”的思维方式。手写实现和优化思路我也会给,但重点是解释为什么这些函数的实现长这样,以及边界条件在哪里。

1. 字符串的“类型”是个误会:C语言里只有字符数组和内存约定

1.1 字符串不是类型,而是对一块内存“以\0结尾”的约定

C语言的标准库里根本没有string类型,这是很多人学C的第一个认知偏差。你写的"hello",本质是一段连续内存里的字符序列,标准里对字符串的定义是“以空字符'\0'结尾的字符数组”。所以字符串不是被“存储”出来的,而是被“约定”出来的:一段内存,只要结束符是'\0',程序员就可以把它当作字符串处理。

这个约定的代价是:所有字符串函数都必须依赖'\0'来感知边界。如果某一块内存中间没有'\0',或者'\0'的位置比分配空间晚,那么从函数视角看,它依然是一个合法字符串,只是比你期望的更长。这也是后面所有越界问题的根源——函数不会替你检查缓冲区大小,它只认内存里的结束标记。

再往下一层,字符串字面量也有讲究。char *p = "hello"和char arr[] = "hello"看似差不多,实际天差地别。前者是把一个指向只读存储区的指针赋给p,后者是在栈上把"hello"连同'\0'一起复制了一份。标准不保证修改字符串字面量安全,很多嵌入式环境的ROM区甚至真的不允许写,所以p[0] = 'H'是未定义行为,崩溃只是最常见的结果之一。

1.2 字符数组和字符指针:sizeof、可写性、指向位置的差别

我把这两个声明放在一起对比,基本就能看明白问题:

char *p = "hello"; // p是变量,指向只读静态区 char arr[] = "hello"; // arr是数组,栈上存了6个字节

差别最直接的体现是sizeof。64位机器上,sizeof(p)通常是8,它只存指针本身;sizeof(arr)是6,包含'h' 'e' 'l' 'l' 'o' '\0'。strlen算出来则都是5,因为strlen不会把结束符算进去。这一点常被当作面试题,但它背后真正的考点是:你到底在操作引用还是操作实体。

还有一个很容易被忽略的点:数组名是“指向首元素的常量地址”,不能整体赋值,不能自增自减。指针却可以随时指向别处。所以当你写arr = something的时候编译过不了,这不是语法严苛,而是数组和指针在类型系统里本来就分属两类东西。真正严格的定义是:数组退化为指针,指针不一定会退化为数组。

2. strlen、strcmp这些“只读”函数,读的是内存而不是字符串

2.1 手写strlen:找的是空字符,不是数个数

先看最基础的strlen,很多人会以为它返回的是“字符串一共有多少字节”,其实它的工作只有一个:从头开始找到第一个'\0',返回中间隔了几个字符。

size_t my_strlen(const char *s) { size_t n = 0; while (s[n] != '\0') n++; return n; }

这个实现看着简单,但已经暴露了两个关键点。第一,如果s指向的内存不是以'\0'结尾,这个循环会一直走下去,读到不属于这块缓冲区的地址,结果是未定义行为。第二,返回值是size_t,是无符号类型,所以你绝对不能把strlen的返回值和一个负数直接比较,或者用它做减法而不做类型保护,否则会出现“len - 1 变成超大数”的经典事故。

还有个常见误区:空字符串的strlen是0,但strlen(NULL)是未定义行为,不要指望返回0。判断一个输入字符串是否为空,应该先判断指针是否为NULL,再调用strlen。

2.2 一次读多个字节的优化思想

标准库厂商不会用上面那个朴素版本,因为逐字节读在长字符串上太慢。业界常用的优化思路叫“word-at-a-time”,就是每次从内存里读4字节或8字节,一次性判断这几字节中有没有'\0'。思路是:把读取的整块数据和一个特殊掩码做位运算,如果结果不是0,就说明当前4字节里有一个字节是0,再逐字节定位。

这个思路本身不难,难在安全性。如果你从一个未对齐的地址一次性读4字节,在某些架构上会出问题;如果指针靠近页边界,一次宽读可能越到未映射的下一页,直接段错误。所以glibc这类实现会在开头阶段先逐字节读到对齐边界,再用宽读处理中间部分。这提醒我们:优化不能脱离内存模型瞎弄,不是所有算法都可以直接加上宽读。

2.3 strcmp比较的是字节序而不是字典序

strcmp初一看起来是“比较两个字符串谁大”,实际做的是逐字节比较,直到遇到第一个不同的字节,然后返回(unsigned char)a[i] - (unsigned char)b[i]。我经常看到有人误以为它返回0、1、-1,其实标准只说“小于0、等于0、大于0”,具体数值由实现决定。所以不要在代码里写if (strcmp(a, b) == 1)这种判断,应该写== 0或者> 0。

另外一个坑是:strcmp不会忽略大小写,也不按字典序处理数字。比如比较版本号,strcmp("2.10", "2.9")会把第二个字符'1'和'9'做比较,'1'的ASCII码小于'9',所以结果判断为“2.10小于2.9”。这种语义不符合版本号的比较规则,碰到这类需求,老老实实把版本号拆成数字逐段比,别指望字符串比较函数帮你搞定。

3. 拷贝与拼接:strcpy、strcat、strncpy是怎么把缓冲区搞炸的

3.1 手写strcpy:返回dest的目的

字符串拷贝函数的经典实现是这样:

char *my_strcpy(char *dest, const char *src) { char *p = dest; while ((*p++ = *src++) != '\0') ; return dest; }

这段代码把赋值和判断写在一起,循环体是空的。它在不断把src的字节拷到dest,直到拷完'\0'为止。为什么返回值是dest而不是void?因为这样支持链式调用,比如strlen(strcpy(buf, tmp)),另外也能让调用方保留一个指向目标起点的指针,方便后续处理。

但这个实现的致命问题也很直白:它根本不知道dest有多大。如果src长度加上'\0'超过了dest缓冲区,它就会往前一路写下去,覆盖dest后面的数据。所谓缓冲区溢出,就是这么一点一点写出来的。

3.2 strcpy不做边界检查:一次溢出演示

看这段代码:

char buf[8]; strcpy(buf, "0123456789");

字符串本身11字节(10个数字加'\0'),buf只有8字节,strcpy会老实写到buf[0]到buf[10],buf[8]以后的内容全是别人的地盘。运气好的时候只是栈变量被覆盖,程序还能继续跑;运气差的时候,返回值地址被改掉,函数返回时直接跳到非法地址。用ASan这类内存检测工具,一跑就能看到stack-buffer-overflow的红色报错,具体到是哪个函数写越界。

所以我在代码评审里看到裸strcpy,一般会直接标问题。不是说绝对不能用,而是调用之前你必须自己证明dest容量足够。这个“证明”在大型项目里几乎做不到,代码一演化,长度分析就失效。

3.3 strncpy的“三宗罪”

很多人听说strcpy不安全,就改用strncpy,以为带上n就万事大吉,结果踩进另一个坑。strncpy的完整语义是:如果src长度小于n,剩余位置全填'\0';如果src长度大于等于n,则只拷贝n字节,而且不保证dest会有'\0'结尾。

这个行为带来三个问题。第一,不自动补结束符,dest可能只是一个没有结尾标记的字符数组,后面接任何字符串函数都会越界读。第二,如果源字符串远短于n,它会用空字符填充完整个剩余区域,等于做了一次没必要的memset,性能白白损耗。第三,n参数通常写的是缓冲区大小,但语义上它同时是“最多复制几个字符”和“源太短时补几个空字符”,这两个概念混在一起,非常容易误用。

正确用法是:strncpy之后手动设置dest[n - 1] = '\0',或者干脆把它当成“复制固定宽度字段”的工具,而不是通用安全拷贝。

3.4 strcat的二次扫描和O(n²)陷阱

strcat的实现也很简单:先从头遍历dest找到'\0',然后从那里开始把src整个拷过来。问题就出在“先从头找'\0'”这一步。

如果在一个循环里反复用strcat追加短字符串,比如:

char buf[1024] = ""; for (i = 0; i < 1000; i++) { strcat(buf, token); }

每次strcat都要从buf的开头扫描到当前结尾,然后再追加,总的扫描次数是1 + 2 + 3 + ... + n,复杂度是O(n²)。实测数据一大,性能立刻崩掉。

更好的做法是维护一个当前写位置的指针,每次追加之后把指针移动到新的'\0'位置,或者直接用snprintf累计输出。总之不要让字符串函数反复扫描你已经知道长度的数据。

3.5 推荐的拼接方式:snprintf和手动追加

我自己在实际工程里,拼接字符串首选snprintf,理由很简单:它在格式化的同时明确写入目标缓冲区大小,并且在写不下时不会完全截断,而是返回本应写入的长度,方便做溢出判断。

char buf[128]; int n = snprintf(buf, sizeof(buf), "%s:%s:%d", name, ip, port); if (n < 0 || (size_t)n >= sizeof(buf)) { // 缓冲区不够,或者格式化出错 }

如果是在一个不断增长的消息缓冲里追加数据,我会选择完全不依赖字符串函数,直接维护一个len和一个capacity结构体,每次用memcpy按长度追加,最后记得在末尾写一个'\0'。这样性能和安全性都可控。

4. 查找与切分:strchr、strstr、strtok的边界行为

4.1 strchr与strrchr:空字符也算“找到”

strchr在字符串里查找一个字符,返回指向第一次出现位置的指针。最容易忽略的是:查找'\0'是合法的,而且一定会返回指向字符串结束符的指针。因为'\0'本身就是字符串的一部分,标准对strchr的定义允许搜索它。这个特性可以用来获取指向字符串末尾的指针,但要小心别误判成“没找到”。

strrchr是从尾部开始查找,它也会先找到末尾的'\0',然后向前找目标字符。如果查找普通字符,返回值指向找到的位置;如果查找'\0',它直接返回末尾位置。所以判断是否找到的标准永远是“返回值是否为NULL”,不要用“返回位置是否等于字符串开头”这种猜想。

4.2 strstr朴素实现的性能隐患

strstr返回子串首次出现的位置。朴素的实现是双层循环:外层遍历主串,内层比较子串,复杂度O(n*m)。如果主串有几百万字节、子串比较长,这个性能会非常难看。

char *naive_strstr(const char *hay, const char *needle) { if (*needle == '\0') return (char *)hay; for (; *hay; hay++) { const char *h = hay; const char *n = needle; while (*h && *n && *h == *n) { h++; n++; } if (*n == '\0') return (char *)hay; } return NULL; }

标准并没有规定strstr必须用某种算法,不同平台可能差异很大。如果子串不长、主串也不大,朴素实现性能差别不大;但如果你在做大文本扫描,建议自己实现KMP或者Boyer-Moore,或者根据实际数据做启发式选择。不能用“系统函数肯定快”这种理由放弃对算法的思考。

4.3 strtok会修改原字符串,还不可重入

strtok可能是整个字符串库里最“反直觉”的函数。它用分隔符把字符串切开,第一次调用传入要处理的字符串,后面传NULL继续分。问题是它内部保存了一个静态指针,记录上次处理的位置,这个状态是全局的,任何线程或者嵌套调用都会互相干扰。

更危险的是,strtok会把分隔符所在的位置改成'\0',直接在原字符串上动手。如果你传入的是一个字符串字面量,比如strtok("a,b,c", ","),程序会尝试修改只读内存,直接崩。所以使用前一定要确认传入的是可写缓冲区,比如字符数组或者malloc出来的内存。

还有一点:连续两个分隔符会被当作一个分隔符处理,空字段会被跳过。如果你要解析CSV并且希望保留空字段,strtok达不到要求。多线程环境下用strtok更是大忌,应该用可重入版本strtok_r,它把状态存到调用方提供的指针里。

4.4 不改原缓冲区的切分方案

处理协议消息、命令行参数这类场景,我不建议用strtok去切内存。一个更稳妥的思路是:只用strchr定位分隔符,记录每个字段的起止指针,然后用长度比较去判断内容,或者按需拷贝到独立缓冲区。这样做的好处是原始数据不会被修改,字符串字面量也能解析,而且可以天然保留空字段。

如果临时需要提取某个子串,也可以直接构造一个只读的“切片视图”:记录起始指针和长度,需要比较的时候用memcmp或手工限定长度比较。这种方法写起来多几行代码,但能避免大量strtok引入的坑,尤其适合网络协议解析这种对稳定性要求高的场景。

5. 字符分类函数:isalpha、to*系列背后的查表实现

5.1 为什么必须用unsigned char或EOF

ctype.h里那些函数,比如isalpha、isdigit、isalnum、isspace,签名都是int isalpha(int c)。这里有一个重要约定:参数必须能表示为unsigned char,或者是EOF。也就是说,如果你传了一个char类型的变量,而这个char在平台上恰好是有符号类型且值为负数,比如0x80到0xFF之间的某个字节,那么它被当作int传入后是负数,不在合法范围内,结果是未定义行为。

正确写法是显式转换:

char ch = text[i]; if (isalpha((unsigned char)ch)) { // ... }

很多人忘记了这一步,导致在读取非ASCII文本时,字符分类函数偶尔会给出看似随机的错误结果。尤其当数据来自网络、文件或键盘输入时,字节值超过0x7F太常见了。

5.2 查表实现的原理

标准库实现字符分类,常见做法是一张256项的表,每个字符对应一个位掩码,表示它属于哪几个类别。比如数字字符同时设置DIGIT位,小写字母同时设置LOWER和ALPHA位。函数实现大体是这样:

static const unsigned short ctype_table[256] = { /* 'A' */ CT_UPPER | CT_ALPHA, /* '0' */ CT_DIGIT, /* ... */ }; int isalpha(int c) { return (c == EOF) ? 0 : (ctype_table[(unsigned char)c] & CT_ALPHA) != 0; }

查表的好处是:判断一个字符属于哪个类别,只需要一次内存访问加一次位与运算,比逐条if判断快得多,而且语义清晰。理解这个机制之后,你就明白为什么“参数必须是unsigned char”这么重要了:因为表的下标只有0到255,如果传进来一个负数,数组访问就越界了。

5.3 多字节字符与locale:不要把UTF-8当单字节处理

isalpha和tolower这一族函数只处理单字节字符,一次只作用于一个字节。而UTF-8编码下,中文、日文、韩文等字符由多个字节组成,每个字节的取值范围经常大于0x7F。如果你用isalpha去判断一个UTF-8中文字符的首字节,几乎一定会得到“不是字母”的结论,因为首字节的二进制特征不在ASCII字母的范围内。

更麻烦的是locale(区域设置)会影响某些分类函数的结果。在特定locale下,某些非ASCII字节可能被判定为字母。所以如果你要处理的是国际化文本,应该考虑使用宽字符版本函数,比如iswalpha、towlower,先把多字节字符串转换成wchar_t数组,再逐字符分类。这里又一个容易踩的细节:宽字符处理的开销和编码转换逻辑都比单字节复杂,不是简单替换函数名就行。

6. memcpy与memmove:二进制安全才是内存函数的灵魂

6.1 memcpy的复制方向和重叠风险

memcpy是内存拷贝函数,按字节数精确复制,不会因为遇到'\0'就停止。这个特性让它能拷贝任何内容,包括文本、结构体、音频数据。但它有一个硬性要求:源和目的内存区域不能重叠。如果重叠,标准直接定义为未定义行为。

为什么会崩?以memcpy(s + 1, s, 5)为例,目标区域是s[1]到s[5],源区域是s[0]到s[4]。如果实现从前往后逐字节复制,先把s[0]拷到s[1],这一步没事;下一步要拷贝的源s[1]已经被刚才那个操作改掉了,于是拷到s[2]的数据是错的。复制越长,错误扩散越多,最后整个缓冲区变成一团乱码,可能连正常运行都做不到。

6.2 memmove如何保证重叠安全

memmove是专门为重叠场景设计的内存移动函数。它可能使用的策略是:如果目标地址小于源地址,从前往后复制;如果目标地址大于源地址,从后往前复制。这样无论怎么重叠,需要读取的数据总是先被保存下来,不会被覆盖。

void *my_memmove(void *dst, const void *src, size_t n) { unsigned char *d = dst; const unsigned char *s = src; if (d < s) { while (n--) *d++ = *s++; } else { d += n; s += n; while (n--) *--d = *--s; } return dst; }

这段代码只是原理示意,真实库实现还会考虑宽拷贝和对齐,但核心思想就是这样:通过调整方向,让拷贝不覆盖尚未读取的源数据。所以工程上有个很实用的习惯:凡是怀疑源和目的可能重叠的,一律用memmove,损失的那一点性能换来的是正确性。

6.3 字符串函数和内存函数怎么选

做文本处理的时候,字符串函数和内存函数经常可以互相替代,但选错会出问题。我习惯用这张表来帮助决策:

函数族终止条件二进制安全典型场景
strcpy/strcat/strlen/strcmp'\0'否纯文本
strncpy/strncat/strncmp以n为主、部分受'\0'影响否受限文本操作
memcpy/memmove/memset/memcmp显式n是二进制、结构体、含'\0'数据

关键判断点是:你的数据里是否可能包含'\0'?比如网络包、文件内容、加密数据,里面任意字节都可能等于0。如果你用strlen去量长度,遇到第一个0就停了,后面全被丢。这种场景必须用内存函数,精确控制字节数。反过来,如果数据是给人看的多字节文本,字符串函数更顺手,但前提是你已经保证了'\0'结尾。

7. 实战复盘:从一次字符串拼接事故看函数选型

7.1 一段典型的“不安全拼接”代码长什么样

我之前接手过一个网络协议模块的维护任务,消息构造函数长这样:

char req[256]; strcpy(req, "GET "); strcat(req, path); strcat(req, " HTTP/1.1\r\n"); strcat(req, "Host: "); strcat(req, host); strcat(req, "\r\n\r\n");

一段代码里三个strcat加一个strcpy,所有缓冲区大小全靠一个固定数组256扛着。只要path或host稍长一点,或者用户输入偏长,req就爆了。最头疼的是它不一定立刻崩,可能只是把栈上后面的变量改坏,出现匪夷所思的逻辑错误。

这就是我在代码评审里最常看到的“局部正确、整体脆弱”的典型代表。每一行单独看都成立,组合在一起就是一个没有边界保护的通道。

7.2 排查链路:从越界崩溃到ASan定位

当时的问题现象是:某次请求后程序偶发崩溃。gdb的backtrace指向一个跟字符串没任何关系的函数,看起来完全不符合直觉。最初怀疑是那边指针被写坏,后来用ASan重新编译跑同样的用例,杀出的是stack-buffer-overflow,调用栈从strcat到req的写操作一眼就看明白。

排查过程里特别值得注意的一点:越界写不一定马上把Crash炸出来,它可能把栈上某个指针的低字节改掉,造成后续内存访问异常。这种“故障发生地和根因相隔十万八千里”的现象,是手写字符串拼接的经典副作用。遇到这类问题,不要相信gdb栈顶就是根因,先考虑有没有隐藏的越界写。

7.3 重构后的处理方式

重构后的代码完全放弃了strcat,改用snprintf一把梭:

char *build_request(char *dst, size_t dstsz, const char *path, const char *host) { int n = snprintf(dst, dstsz, "GET %s HTTP/1.1\r\n" "Host: %s\r\n" "\r\n", path, host); if (n < 0 || (size_t)n >= dstsz) return NULL; return dst; }

snprintf每次调用都会把目标缓冲区大小传进去,写不下就返回需要的长度,调用方借此判断。相比手写一串strcat,可读性、安全性、性能三方面都更好。如果消息还需要继续追加,我会改成维护一个动态缓冲结构体,用memcpy按长度写入,并单独维护一个len变量,最后的'\0'由构造器统一补上。

7.4 我总结的几条铁律

经历过几次字符串事故之后,我给自己定了几条规矩,做代码评审时也会照着检查:

  • 看到strcpy、strcat、sprintf,要么改成带缓冲区大小的snprintf,要么先证明长度不可能超。
  • 凡是动态拼接,维护显式的长度和容量,不做二次扫描。
  • 切分字符串不修改原始缓冲区,用指针加长度做视图。
  • 字符分类函数接收参数之前,全部强制转为unsigned char。
  • 不确定源和目标是否重叠时,无脑用memmove而不是memcpy。

这几条不能解决所有问题,但能把绝大多数字符串相关崩溃挡在测试阶段。

最后分享一个小技巧:写任何字符串处理代码之前,先在注释里写清楚“这块内存是谁分配的、可写多大、数据是否一定以'\0'结尾”。这三个问题想清楚了,函数选型基本不用纠结。多花一分钟画清楚边界,省下来的,是几个小时的调试时间。

RELATED READING

延伸阅读

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