前面几篇文章,我们从指针一路聊到数组、二级指针、命令行参数,很多地方都出现了字符串的影子——printf 用 %s 输出,argv 是一堆 char*。但 C 语言里并没有一个叫 string 的类型。那字符串到底是个什么东西?答案其实很纯粹:字符串,就是以空字符 '\0' 结尾的字符数组。
今天我们就来彻底搞清楚 C 语言字符串的里里外外。你会学到字符串在内存里怎么存、char[] 和 char* 的真正区别、怎么安全地读写字符串,以及标准库提供的那些字符串处理函数。学完这篇,你就再也不会被 strcpy 和 strcmp 搞迷糊,也能写出不怕缓冲区溢出的健壮代码。
一、C 风格字符串的本质:'\0' 是灵魂
在 C 语言里,字符串就是一段连续内存中、以空字符 '\0'(ASCII 码 0)结尾的字符序列。
比如字符串 "hello",在内存里实际占用 6 个字节:
地址: 100 101 102 103 104 105
内容: 'h' 'e' 'l' 'l' 'o' '\0'
所有的字符串操作,不管是 printf、strlen、strcmp,都是从起始地址开始往后逐字节扫描,直到遇到 '\0' 为止。这个终止符就是字符串的灵魂——没有它,字符串函数就无法知道该在哪里停下,结果要么读到垃圾值,要么直接崩溃。
所以,一个字符数组如果没有 '\0',就只是一堆字符,不是合法的 C 字符串。
二、字符数组 vs 字符指针:再辨析
第十六篇我们已经初步对比过,这里再从字符串角度强化一下。
方式一:字符数组(可修改)
char str[] = "hello";
编译器在栈上分配一个 6 字节的数组,把 "hello" 的内容(包括 '\0')复制进去。这块内存是你的,随便改:
str[0] = 'H'; // 合法,变成 "Hello"
sizeof(str) 得到的是整个数组的大小(6 字节),因为 str 是数组名。
方式二:字符指针(指向只读字面量)
char *ptr = "hello";
这里 ptr 只是一个指针,指向存储在只读数据区的字符串字面量 "hello"。试图修改会导致未定义行为(通常崩溃):
ptr[0] = 'H'; // 危险!不要这样做
sizeof(ptr) 得到的是指针的大小(4 或 8 字节),而不是字符串长度。
方式三:const char *(最诚实)
如果你只是想“借来看一看”一个字符串而不修改它,用 const char * 最能表达意图,而且编译器会帮你拦下误改的代码:
const char *msg = "hello";
// msg[0] = 'H'; // 编译错误,完美
一张表总结
| 声明 | 内存位置 | 可否修改内容 | sizeof 结果 |
|---|---|---|---|
char str[] = "hi" | 栈 | 可以 | 数组大小(3) |
char *ptr = "hi" | 指针在栈,字面量在只读区 | 不可以 | 指针大小(4/8) |
const char *ptr = "hi" | 同上 | 不可修改,编译器检查 | 指针大小 |
三、字符串的输入输出
printf 与 %s
%s 从给定地址开始逐个输出字符,直到遇到 '\0'(不包括它)。
char str[] = "hello";
printf("%s\n", str); // 输出 hello
可以指定最小宽度和精度(.5s 表示最多输出前 5 个字符),这在我们讲第六篇时提过,复习一下。
scanf 与 %s:极度危险
char name[10];
scanf("%s", name);
%s会在遇到第一个空白字符(空格、换行、Tab)时停止读取。- 它不会做越界检查!如果用户输入 20 个字符,
scanf照样往name里写,直接覆盖后面的栈空间,这就是典型的缓冲区溢出。
解决方案:给 %s 加宽度限制,比如 %9s,表示最多读 9 个字符(留一个位置给 '\0'):
char name[10];
scanf("%9s", name); // 最多读 9 个字符,安全
但 %s 仍然不能读取带空格的字符串(比如 “John Doe”)。
fgets:更安全的输入
char buffer[100];
if (fgets(buffer, sizeof(buffer), stdin) != NULL) {
// 读到的内容可能带换行符
}
fgets 会一直读,直到遇到换行符、文件结束,或读满 sizeof(buffer) - 1 个字符,然后在末尾加 '\0'。换行符会被保留在字符串里(如果空间够),你不想要的话得手动去掉。
去换行的小技巧:
buffer[strcspn(buffer, "\n")] = '\0';
strcspn 返回第一个匹配字符的索引,把那个位置的字符替换成 '\0' 就行。
四、常用字符串处理函数
C 标准库 <string.h> 提供了一组操作字符串的函数。我们用例子一个一个说明。
1. strlen(s):求字符串长度(不包括 '\0')
#include <string.h>
#include <stdio.h>
int main(void) {
char str[] = "hello";
printf("长度: %zu\n", strlen(str)); // 5
return 0;
}
它的内部实现就像我们上回写的:
size_t strlen(const char *s) {
const char *p = s;
while (*p) p++;
return p - s;
}
2. strcpy(dest, src):拷贝字符串
把 src(包括 '\0')复制到 dest 指向的空间。
char src[] = "world";
char dest[20];
strcpy(dest, src); // dest 现在是 "world"
危险:dest 必须足够大,否则溢出。更安全的是用 strncpy。
3. strncpy(dest, src, n):限制长度的拷贝
char dest[5];
strncpy(dest, "hello, world", sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // 重要!手动加 '\0'
strncpy 最多拷贝 n 个字符,但如果 src 的长度 >= n,它不会自动在 dest 末尾加 '\0'。这是个大坑,你必须自己手动补上。
4. strcat(dest, src):拼接字符串
把 src 追加到 dest 末尾(从 dest 的 '\0' 处开始覆盖)。
char dest[20] = "hello ";
strcat(dest, "world"); // dest 变成 "hello world"
同样有溢出风险,推荐 strncat。
5. strncat(dest, src, n):限制长度的拼接
char dest[10] = "hello ";
strncat(dest, "world!!!", sizeof(dest) - strlen(dest) - 1);
strncat 最多追加 n 个字符,然后自动加上 '\0'。它比 strncpy 安全得多。
6. strcmp(s1, s2):比较两个字符串
按字典序比较,返回:
- 0:相等
- 负数:
s1小于s2 - 正数:
s1大于s2
if (strcmp("apple", "banana") < 0) {
printf("apple 在 banana 前面\n");
}
陷阱:不能用 == 比较两个字符串的内容!if (s1 == s2) 比较的是两个指针的地址,而不是字符串内容。
7. strncmp(s1, s2, n):前 n 个字符比较
if (strncmp("hello", "helicopter", 3) == 0) {
printf("前三个字符相同\n");
}
五、snprintf:格式化到字符串(更安全的利器)
很多时候你不需要手动拼接,snprintf 能把格式化的结果安全地写入缓冲区,并保证不溢出、有 '\0' 结尾(C99 起)。
char buffer[50];
int year = 2026;
snprintf(buffer, sizeof(buffer), "今年是 %d 年", year);
printf("%s\n", buffer);
它让格式化输出到字符串变得既安全又灵活,强烈推荐。
六、自己动手写:深入理解
实现自己的字符串函数是检验理解的绝佳方式。这里写两个:my_strcpy 和 my_strcmp。
#include <stddef.h>
char *my_strcpy(char *dest, const char *src) {
char *original = dest;
while (*src != '\0') {
*dest = *src;
dest++;
src++;
}
*dest = '\0'; // 记得加终止符
return original;
}
int my_strcmp(const char *s1, const char *s2) {
while (*s1 != '\0' && *s2 != '\0') {
if (*s1 != *s2) {
return (unsigned char)*s1 - (unsigned char)*s2;
}
s1++;
s2++;
}
// 谁先到 '\0' 谁更短
return (unsigned char)*s1 - (unsigned char)*s2;
}
观察循环里指针的移动,你会发现它们和数组遍历完全一致。自己写一遍,就再也不会忘记 '\0' 的意义。
七、常见错误与陷阱
1. 用 == 比较字符串内容
char s1[] = "abc";
char s2[] = "abc";
if (s1 == s2) // 永远为假!比较的是地址
应该用 strcmp。
2. 忘记留 '\0' 的位置
char buf[5];
strncpy(buf, "hello", 5); // buf 没有 '\0',printf 会越界
永远保证缓冲区比最大字符数多 1。
3. 使用 gets(已从 C11 标准移除,但老代码中可能有)
char buf[100];
gets(buf); // 无论输入多长都往里写,极度危险
用 fgets 替代。
4. 返回局部字符数组的地址
char *get_message(void) {
char msg[] = "hello";
return msg; // 返回栈上数组地址,无效
}
应返回字面量指针、静态数组地址,或动态分配的内存。
八、小结
今天我们真正理解了 C 语言字符串的本质:一个以 '\0' 结尾的字符数组。你学到了:
'\0'是所有字符串函数工作的基础。- 字符数组和字符指针的区别决定了你可不可以修改字符串。
scanf("%s")危险,fgets安全,宽度限制不能忘。strlen、strcpy、strcat、strcmp的用法,以及它们的安全版本strncpy、strncat、strncmp。snprintf是一个更安全的格式化工具。
字符串是 C 语言中最常用的“非标量”类型,后续我们写文件操作、网络通信、用户交互,几乎都离不开它。现在你已经有了安全使用它的基础。下一篇,我们要迈入另一个重量级话题:函数与指针的强强联合——如何让函数接收函数作为参数(回调函数),以及那个让很多人头疼的“函数指针”到底怎么用。
课后小练习
- 写一个函数
size_t my_strlen(const char *s),不使用任何库函数,实现strlen的功能。 - 写一个函数
void safe_concat(char *dest, const char *src, size_t dest_size),安全地把src拼接到dest末尾,保证不溢出并以'\0'结尾。 - 用
fgets读取用户输入的一行文字(可能含空格),去掉末尾换行符,然后统计其中的字母、数字和空格的个数。 - (陷阱题)下面的代码有问题吗?如果有,指出问题并修正:
char *p; strcpy(p, "hello"); - (实战)实现一个简单的
grep过滤器:从标准输入逐行读取字符串,如果该行包含用户指定的关键词(通过argv[1]传入),就打印这一行。提示:使用fgets和strstr(strstr(haystack, needle)返回指向 needle 首次出现位置的指针,找不到返回 NULL)。
我们下期见!

6880

被折叠的 条评论
为什么被折叠?



