前言
C 语言没有字符串类型,只有「以'\0'结尾的char数组」这一个约定。<string.h>里的那一组函数全部建立在这个约定之上,它们有一个共同特点:除了少数几个带n的函数,其余都不做任何边界检查。函数不知道你的缓冲区有多大,越界了也不会报错,只会安静地踩坏旁边的内存——这是 C 程序里缓冲区溢出(buffer overflow)最主要的来源。
最常见的一个误解是把strncpy当成「安全的strcpy」。它不是:strncpy(dst, src, n)在src长度达到或超过n时不会写结束符'\0',于是dst变成了一个没有结尾的字节数组,之后任何按字符串处理的函数都会一路读过头。还有一个高频误解是认为字符串操作函数会告诉你缓冲区够不够——只有snprintf会(通过返回值),其他的都不会。
本文按「模型 → 拷贝与连接 → 比较与查找 → 内存与格式化」的顺序,把 ISO C 标准里的字符串函数过一遍,重点是每个函数的边界行为和结束符行为。文中函数原型均取自 ISO C 标准(<string.h>/<stdio.h>),凡是非标准扩展都会明确标注来源。示例用 C99 编译:gcc -std=c99 -Wall -Wextra。
一、C 字符串的模型:以 '\0' 结尾的字符数组
长度 N 的字符串占用 N+1 个字节,最后一个是值为 0 的字符'\0'。所有<string.h>里的函数都靠这个终止符判断结束,没有任何一个函数会去读缓冲区的大小。
#include <stdio.h> #include <string.h> int main(void) { char arr[] = "hello"; /* 数组:sizeof 为 6,含结尾的 '\0' */ const char *ptr = "hello"; /* 指针:sizeof 为指针本身的大小(64 位上是 8)*/ const char *lit = "hello"; /* 字符串字面量 */ printf("%zu %zu %zu\n", sizeof(arr), /* 6 */ sizeof(ptr), /* 8(64 位平台)*/ strlen(arr)); /* 5:不含 '\0' */ /* 字符串字面量的静态存储期,同一个字面量在多处出现可能被合并成一份, 所以下面这两个指针的比较结果由实现决定,不要依赖 */ printf("%d\n", ptr == lit ? 1 : 0); return 0; }两点必须记住:
sizeof(数组)是缓冲区总大小,strlen(指针)是字符串长度,两者永远不是一回事。一旦数组名传给函数、退化成指针,sizeof就再也拿不到缓冲区大小了——这就是「把缓冲区大小单独作为参数传进去」这一惯例的由来。- 字符串字面量的内容不可修改。在 C 里它的类型是
char[N](这一点和 C++ 不同,C++ 里是const char[N]),编译器不会因为你写lit[0] = 'x'而报错,但这是未定义行为:字面量通常放在只读段,写它多半直接崩溃,而标准并不保证崩溃——它什么都不保证。
二、长度、拷贝与连接
size_t strlen(const char *s); char *strcpy(char *dest, const char *src); char *strncpy(char *dest, const char *src, size_t n); char *strcat(char *dest, const char *src); char *strncat(char *dest, const char *src, size_t n);各函数的边界行为差异,是这一节的全部重点:
| 函数 | 拷贝/追加多少 | 是否保证写入'\0' | dest需要多大 |
|---|---|---|---|
strcpy | src全部 | 是 | strlen(src) + 1 |
strncpy | 至多n个 | 不保证(strlen(src) >= n时不写) | n字节,且需要手动补'\0' |
strcat | src全部 | 是 | strlen(dest) + strlen(src) + 1 |
strncat | 至多n个 | 是(总是补) | strlen(dest) + n + 1 |
strncpy还有一个人人都会踩的细节:当src比n短时,它会把剩下的字节全部用'\0'填满。也就是说strncpy(dst, "a", 1000)会老老实实写 1000 字节。这个行为和「按需拷贝」的直觉相反,也是strncpy在性能敏感的代码里被诟病的原因。
下面是一段可以直接编译的对照示例:
/* strings_copy.c 编译:gcc -std=c99 -Wall -Wextra strings_copy.c */ #include <stdio.h> #include <string.h> int main(void) { const char *src = "hello, world"; char dst[8]; /* ❌ 危险:src 有 12 个字符,dst 只有 8 字节,strcpy 会越界写,行为未定义 */ /* strcpy(dst, src); */ /* ✅ 截断后必须自己补 '\0',否则 dst 不是一个合法的字符串 */ strncpy(dst, src, sizeof(dst) - 1); dst[sizeof(dst) - 1] = '\0'; printf("[%s]\n", dst); /* [hello, ] */ /* ✅ 更省心的做法:snprintf 自动截断,并保证以 '\0' 结尾 */ char dst2[8]; int n = snprintf(dst2, sizeof(dst2), "%s", src); printf("[%s] 完整长度=%d\n", dst2, n); /* [hello, ] 完整长度=12 */ /* ✅ 拼接:先确认空间再拼 */ char path[32] = "/usr"; if (strlen(path) + strlen("/local") + 1 <= sizeof(path)) { strcat(path, "/local"); } printf("%s\n", path); /* /usr/local */ return 0; }注意strncpy的第二个参数写法sizeof(dst) - 1:留出最后一格手工放'\0'。这是strncpy的标准用法模板,记不住它就不要用strncpy,直接用snprintf。
三、比较、查找与切分
int strcmp(const char *s1, const char *s2); int strncmp(const char *s1, const char *s2, size_t n); char *strchr(const char *s, int c); char *strrchr(const char *s, int c); char *strstr(const char *haystack, const char *needle); size_t strspn(const char *s, const char *accept); size_t strcspn(const char *s, const char *reject); char *strpbrk(const char *s, const char *accept); char *strtok(char *str, const char *delim);strcmp的返回值只保证符号:小于 0、等于 0、大于 0。不要写成if (strcmp(a, b) == -1),也不要直接当布尔用——if (strcmp(a, b))表达的是「不相等」,这是最常见的反向逻辑 bug。
查找函数的返回值是指针,不是下标。strchr的第二个参数类型是int,传入的char会被提升;另外strchr(s, '\0')是合法的,返回指向结尾终止符的指针。
strtok是这一组里最需要小心的。
/* strtok_demo.c 编译:gcc -std=c99 -Wall -Wextra strtok_demo.c */ #include <stdio.h> #include <string.h> int main(void) { /* 必须是可写的数组:strtok 会改写原串,把分隔符换成 '\0' */ char text[] = "a,,b"; char *save = NULL; /* strtok_r 是 POSIX 函数,不属于 ISO C。 在严格 -std=c99 下可能需要 #define _POSIX_C_SOURCE 200809L */ for (char *tok = strtok_r(text, ",", &save); tok != NULL; tok = strtok_r(NULL, ",", &save)) { printf("[%s]\n", tok); } return 0; }输出是:
[a] [b]注意中间那个空字段被跳过了——strtok会把连续的分隔符合并处理,因此它无法解析出空字段,这是它的硬限制,不是配置问题。此外:
strtok内部用静态变量保存位置,不是线程安全的,也不能嵌套使用。标准 C 库里没有可重入版本;POSIX 提供strtok_r(char *str, const char *delim, char **saveptr),MSVC 提供的strtok_s与之同形(三个参数)。需要留意的是 C11 附录 K 里定义的同名函数strtok_s是四个参数的版本,附录 K 又是可选的、很多实现根本不提供——跨平台时以目标平台的文档为准,不要凭名字猜签名。strtok会修改原串,所以不能对字符串字面量使用,那是对只读存储的写入,属于未定义行为。
四、内存操作与格式化
void *memcpy(void *dest, const void *src, size_t n); void *memmove(void *dest, const void *src, size_t n); void *memset(void *s, int c, size_t n); int memcmp(const void *s1, const void *s2, size_t n); int snprintf(char *str, size_t size, const char *format, ...); int sprintf(char *str, const char *format, ...);最需要强调的是memcpy与memmove的分工:源和目标内存区域重叠时用memcpy是未定义行为,标准不保证任何结果(某些实现从低地址往高地址复制,另一些可能用向量指令按块复制,行为完全不可预测)。有重叠就用memmove。
/* mem_demo.c 编译:gcc -std=c99 -Wall -Wextra mem_demo.c */ #include <stdio.h> #include <string.h> int main(void) { char buf[16] = "abcdef"; /* 把索引 1 起的 3 个字节前移到索引 0,源和目标重叠 */ memmove(buf, buf + 1, 3); /* ✅ 重叠必须用 memmove */ printf("%s\n", buf); /* bcddef */ char dst[16]; memcpy(dst, "0123456789", 11); /* 不重叠时用 memcpy,语义更直接 */ printf("%s\n", dst); /* 0123456789 */ char small[8]; int n = snprintf(small, sizeof(small), "%s-%d", "abcdefgh", 42); if (n < 0) { printf("编码错误\n"); } else if ((size_t)n >= sizeof(small)) { /* 返回值是"假如空间足够会写多少个字符(不含 '\0')" */ printf("被截断,完整长度=%d,实际存入=[%s]\n", n, small); /* 输出:被截断,完整长度=11,实际存入=[abcdefg] */ } return 0; }snprintf的返回值规则值得单独记住:它返回「假如缓冲区足够大,本应写入的字符数(不含结尾'\0')」,而不是实际写入的字符数;返回负值表示发生了编码错误。因此「是否被截断」的判断方法就是n >= size,这也是它相比sprintf唯一的安全优势——sprintf完全不检查边界,缓冲区不够就是越界写,属未定义行为。
memset(s, c, n)的第二个参数虽然是int,但只有低 8 位会被使用——想用memset把int数组置为 1 是无效的,它会把每个字节都设成 1,得到0x01010101这样的值,而不是 1。
数字与字符串互转不属于<string.h>:atoi/atol/atof在<stdlib.h>,它们无法报告错误(输入非法时返回 0,和输入真的是 "0" 无法区分)。需要区分这两种情况就用strtol/strtod,它们提供endptr指向第一个未解析的字符,并把溢出情况写入errno(ERANGE)。
常见坑点
| 场景 | ❌ 错误写法 | ✅ 正确写法 |
|---|---|---|
| 修改字符串字面量 | char *p = "abc"; p[0] = 'x';(UB,标准不保证任何行为) | char p[] = "abc"; p[0] = 'x'; |
用sizeof求字符串长度 | size_t len = sizeof(p);(p是指针,得到 8) | size_t len = strlen(p); |
| 无边界拷贝 | strcpy(dst, src);,dst可能不够大 | 用snprintf(dst, sizeof(dst), "%s", src); |
strncpy后忘记补结束符 | strncpy(dst, src, n); printf("%s", dst); | strncpy(dst, src, n); dst[n] = '\0'; |
strncat空间算错 | 按n + 1分配,忽略了dest已有的内容 | 预留strlen(dest) + n + 1 |
strcmp当布尔用 | if (strcmp(a, b)) { /* 以为是相等 */ } | if (strcmp(a, b) == 0) { /* 相等 */ } |
对字面量用strtok | strtok("a,b", ",")(UB:写只读存储) | 先复制到数组:char t[] = "a,b"; strtok(t, ","); |
重叠内存用memcpy | memcpy(p, p + 1, n);(UB) | memmove(p, p + 1, n); |
有两个坑需要额外提一句。一是漏包含<string.h>:在 64 位平台上这尤其危险,因为 C89 会把未声明的函数隐式假定为返回int,而strlen返回的size_t是 64 位无符号数,被当成int截断后可能得到错误的长度(现代编译器已把隐式函数声明从警告升级为错误,但老代码里仍会见到)。二是多线程里用strtok:它把位置保存在内部静态变量里,两个线程同时切分不同字符串会互相踩踏,必须换成 POSIX 的strtok_r。
另外两个跨平台注意事项:strdup(复制字符串并malloc)是POSIX 函数,不属于 ISO C 标准,在严格模式下需要_POSIX_C_SOURCE;strlcpy/strlcat来自 BSD,glibc 长期不提供(较新的版本才补上),跨平台代码不要依赖它们。
总结
| 函数 | 是否检查目标缓冲区大小 | 结束符'\0'行为 | 典型用途 |
|---|---|---|---|
strlen | —— | 不计入长度 | 求长度(每次调用都遍历,别放进循环条件) |
strcpy | ❌ 不检查 | 总是写 | 确认目标够大时的整体拷贝 |
strncpy | 部分(限源长度) | 源过长时不写 | 定长字段拷贝,须手工补'\0' |
strcat | ❌ 不检查 | 总是写 | 确认空间足够时的拼接 |
strncat | 部分(限追加长度) | 总是写 | 限制追加长度,注意dest已有内容也要算空间 |
strtok | —— | 把分隔符改写成'\0' | 简单切分;不可重入、会改原串 |
memcpy | ❌ 不检查 | 不涉及 | 不重叠的字节块拷贝 |
memmove | ❌ 不检查 | 不涉及 | 允许重叠的字节块拷贝 |
snprintf | ✅ 通过size参数 | 总是写(size > 0时) | 格式化输出与安全拼接 |
用这三条规则可以避开绝大多数字符串 bug:第一,任何写入目标缓冲区的调用,先问「目标多大、源多长」;第二,凡是带n的函数,先确认它对结束符的处理(strncpy不补、strncat补);第三,重叠内存一律memmove。至于日常拼接,最省心的组合是snprintf加检查返回值,它在安全性和可读性上都比手工算strncpy/strncat的长度更可靠。