第1章:字符数组与字符串——从底层理解C语言的灵魂
大家好,我是你们的老朋友。今天咱们来聊聊C语言里一个特别容易踩坑、但又特别基础的话题——字符数组与字符串。
说实话,我刚开始学C语言那会儿,觉得字符串不就是用双引号括起来的一串字符吗?有什么好讲的?直到我在一个嵌入式项目里,因为字符串拷贝没处理好,导致整个系统跑飞了……嗯,从那以后,我再也不敢小看这玩意儿了。
1.1 字符数组的初始化:你以为你懂了,其实未必
字符数组,说白了就是存放字符的数组。但它的初始化方式,比普通数组要灵活得多。
// 方式一:逐个字符初始化
char str1[6] = {'H', 'e', 'l', 'l', 'o', '\0'};
// 方式二:字符串常量初始化
char str2[6] = "Hello";
// 方式三:省略数组大小
char str3[] = "Hello"; // 编译器自动计算为6个元素
我个人习惯用方式二或方式三。为什么?因为省事,而且可读性强。但要注意一个细节:字符串常量末尾自动带一个'\0'(空字符),所以数组长度至少要比字符个数多1。
char str[5] = "Hello";,编译器不会报错,但数组只有5个元素,放不下结尾的'\0'。这就埋下了隐患——后面用字符串函数时,它会一直往后读内存,直到碰上一个'\0'为止。你想想看,这多危险?
我在项目中遇到过类似的问题。有一次调试一个串口通信程序,发现接收到的数据总是多出几个乱码。查了半天,原来就是字符数组没留够'\0'的位置。从那以后,我每次定义字符数组都会多算一个位置。
1.2 字符串的存储方式:内存里到底长什么样?
咱们来看看字符串在内存中是怎么存的。我用一个简单的例子来说明:
char str[] = "C语言";
printf("数组大小: %lu\n", sizeof(str)); // 输出 7(每个汉字占2字节,加上结尾'\0')
printf("字符串长度: %lu\n", strlen(str)); // 输出 6
这里有个关键区别:sizeof 计算的是数组占用的总字节数,包括结尾的'\0';而 strlen 只计算有效字符个数,不包含'\0'。
为什么会这样?因为C语言中字符串的结束标志就是'\0'。所有字符串处理函数,比如 strcpy、strcat、strlen,都是靠检测'\0'来判断字符串在哪结束的。
核心要点: 没有'\0',就不是一个合法的C字符串。它只是一个普通的字符数组。
我画了一张图,帮你理解字符串在内存中的布局:
1.3 字符串输入输出的陷阱:那些年我们踩过的坑
字符串的输入输出,看起来简单,实际上处处是坑。我挑几个最常见的说说。
陷阱一:scanf 读取字符串时的缓冲区溢出
char name[10];
scanf("%s", name); // 如果用户输入超过9个字符,直接溢出!
我曾经在一个学生项目中看到这样的代码。用户输入了一个很长的名字,程序直接崩溃了。为什么?因为 scanf("%s") 不会检查缓冲区大小,它会一直读,直到遇到空白字符为止。超出数组的部分,就会覆盖相邻的内存区域。
💡 我的建议: 使用 scanf("%9s", name) 来限制读取长度,或者干脆用 fgets() 代替。
陷阱二:gets 函数——C语言史上最危险的函数之一
char buf[100];
gets(buf); // 危险!没有长度限制
这个函数在C11标准中已经被正式移除了。但很多老代码里还能看到。它没有任何缓冲区保护,输入多长它就读多长。黑客经常利用这个漏洞进行缓冲区溢出攻击。
嗯,这里要注意:永远不要在生产代码中使用 gets()。用 fgets() 替代它。
陷阱三:printf 输出时的格式匹配
char str[] = "Hello";
printf("%s\n", str); // 正确
printf("%s\n", &str); // 错误!但编译器可能不报错
这里 str 本身就是数组首地址,所以 %s 直接传 str 就行。传 &str 取的是整个数组的地址,类型不匹配,但有些编译器只会给个警告。
1.4 字符串安全函数:别再拿生产环境开玩笑
传统的字符串函数,比如 strcpy、strcat、sprintf,都有一个共同的问题:不检查目标缓冲区的大小。这在嵌入式系统里尤其致命——一个缓冲区溢出就可能让整个系统崩溃。
好在C11标准引入了一批安全函数,它们以 _s 结尾,比如:
| 传统函数 | 安全函数 | 说明 |
|---|---|---|
| strcpy | strcpy_s | 需要指定目标缓冲区大小 |
| strcat | strcat_s | 拼接时检查剩余空间 |
| sprintf | snprintf | 限制输出长度(非_s系列,但更常用) |
| gets | gets_s | 限制读取长度 |
来看看 strncpy 的正确用法:
char dest[10];
const char* src = "Hello, World!";
// 错误用法:strcpy 会溢出
// strcpy(dest, src);
// 正确用法:strncpy 限制拷贝长度
strncpy(dest, src, sizeof(dest) - 1);
dest[sizeof(dest) - 1] = '\0'; // 手动添加结束符
printf("dest = %s\n", dest); // 输出 "Hello, Wo"
⚠️ 注意: strncpy 有一个坑——如果源字符串长度大于等于指定的拷贝长度,它不会自动添加 '\0'。所以一定要手动在末尾加 '\0',就像上面代码做的那样。
我个人更推荐使用 snprintf,它用起来更直观:
char dest[10];
const char* src = "Hello, World!";
snprintf(dest, sizeof(dest), "%s", src);
// snprintf 会自动在末尾加 '\0',并且不会溢出
我曾经在一个物联网项目里,用 snprintf 替代了所有的 sprintf。从那以后,再也没有出现过因为字符串格式化导致的崩溃问题。你想想看,一个简单的函数替换,就能避免那么多潜在风险,何乐而不为呢?
小结
这一章我们聊了字符数组的初始化、字符串的存储方式、输入输出的陷阱,以及安全函数的使用。说白了,核心就一句话:永远不要假设用户输入是安全的,永远给缓冲区留够空间,永远记得加 '\0'。
这些看似基础的知识,恰恰是很多严重bug的根源。我在职业生涯中见过太多因为字符串处理不当导致的问题——从程序崩溃到安全漏洞,再到数据损坏。希望你能从这一章开始,养成好的编程习惯。
📌 本章核心要点:
- 字符数组初始化时,记得留一个位置给 '\0'
- sizeof 和 strlen 的区别:sizeof 算 '\0',strlen 不算
- 永远不要用 gets(),少用 scanf("%s")
- 优先使用 strncpy、snprintf 等安全函数
- 使用 strncpy 后,手动添加 '\0'