在C语言的学习中,字符串的定义一直是一个经典的坑,下面两种定义方法看起来很相似:
char *p = "Hello";
char p[] = "Hello";
一眼看去,这两种定义都可以打印出字符串,但在实际操作时差异巨大:一种允许修改,另一种尝试修改会导致程序直接崩溃;一种可以安全返回指针,另一种返回后会导致未定义行为。
如果想弄懂他们到底有什么区别,仅仅停留在语法层面显然是不够的,我们需要深入到内存布局、编译过程、运行时行为,甚至硬件保护机制(如MMU)中去理解原理。下面通过代码演示、内存分析和原理扩展,一步步讲透。
1. 现象演示:为什么修改字符串会崩溃?
下面先来看一段经典的代码:
#include <stdio.h>
int main() {
const char *str1 = "Hello";//指针方式
char str2[] = "Hello";//数组方式
//修改数组定义的字符串
str2[0] = 'h';
printf("str2: %s\n", str2);//输出 hello,正常运行
//修改指针定义的字符串
str1[0] = 'h';//程序崩溃
printf("str1: %s\n", str1);
return 0;
}
结果:str2 正常修改并打印 "hello",而尝试修改 str1 时程序直接崩溃。 程序运行结果和我们的预期是一样的。为什么看似相同的操作,一个正常,另一个却崩溃了?
这涉及到C程序的进程虚拟内存布局,要理解这个问题,我们下面来看一下进程的内存分布。程序在运行时,内存被划分成了不同的区域。
2. 两种定义的内存原理区别
2.1对于char *p = "Hello" :
编译时: 字符串 "Hello" 被视为字符串字面量,直接硬编码放在了 .rodata(只读数据区)。程序中所有相同的字符串字面量(如多个 "Hello")通常会被编译器合并,只保留一份,节省空间。
运行时: 指针变量 p 分配在栈上,它的值是 "Hello" 在常量区的地址(例:0x4000)。
为什么崩溃:当执行 p[0] = 'h' 时,CPU 试图去写 0x4000 这个地址,也就是写入 .rodata 区域。MMU(内存管理单元) 发现这是只读区域,立即触发页面错误(Page Fault),产生 Segmentation Fault(段错误),程序崩溃。
2.2对于char p[] = "Hello":
编译时:"Hello" 仍放入 .rodata 作为初始化模板。编译器知道数组长度是 6,在函数的栈帧里预留了 6 个字节的空间。
运行时:程序执行时,会自动把 .rodata 里的 "Hello" 拷贝一份到栈上的数组里。
为什么没有崩溃:程序中修改的是栈上的副本。栈内存是可读可写的(RAM),所以修改完全没问题。
3. 另一个经典坑:函数返回字符串指针
还有一个比较经典的场景,原理类似:生命周期不同。
#include <stdio.h>
//错误
char* wrong() {
char p[] = "Hello";
return p; //悬空指针
}
//正确
char* right() {
char *p = "Hello";
return p;//安全
}
int main()
{
char *str1 = wrong();
printf("Wrong:%s\n",str1);
char *str2 = right();
printf("Right:%s\n",str2);
return 0;
}
wrong():返回栈上副本地址,函数返回后栈帧销毁,p变成悬空指针,访问它会造成未定义行为。 right():返回 .rodata 地址,整个程序生命周期有效。
3.1当写下这行代码char *p = "Hello";
编译时(与上面相同的步骤),编译器把字符串 "Hello"(这 6 个字节)直接硬编码写进了 .rodata,假设 "Hello" 在内存中的地址是 0x4000。运行时,函数开始执行,在栈上分配了一个 8 字节(对于64位系统,通常为8字节)的空间,这就是指针变量 p。然后CPU执行一条指令:把 0x4000 这个数字填到 p 里。
此时的状态: p (在栈上):存着 0x4000。 0x4000 (在常量区):存着 'H', 'e', 'l', 'l', 'o', '\0'。
函数返回后,栈帧销毁,但返回的指针仍指向 .rodata 中的字符串,完全有效,整个程序生命周期内都可安全读取。
3.2当写下这行代码时char p[] = "Hello";
编译时,编译器看到 "Hello",依然把它放在 .rodata(常量区),这里你可以把它理解为一个模板,同时,编译器看到 p[],算了一下长度是 6,它会在函数的 栈 (Stack) 帧里预留出 6 个字节的空位。当函数开始执行,CPU 执行了一串类似memcpy的指令:把 .rodata 里的 "Hello" 拷贝到栈上预留的那 6 个字节里。
此时的状态: .rodata 里:还是有一个 "Hello"(这是模版)。 栈上:也有了一个独立的"Hello"(这是 p 数组,可以理解为复印件)。
函数返回后,栈帧被销毁,p 所在的那 6 字节内存失效。返回的指针成为悬空指针,指向已释放的栈内存。后面使用printf触发未定义行为:可能侥幸打印正确(栈内存尚未被覆盖),可能打印乱码,也可能直接崩溃。
4. 核心原理总结
char *p,指针指向静态只读区(.rodata),不可改、生命周期长。
char p[ ],栈上创建可写副本,可改、生命周期短(函数作用域)。
程序崩溃源于硬件保护(对只读页进行写入操作),悬空源于栈自动回收。
------END------
掌握这两个坑,你就已经跨过了C语言字符串处理的一大门槛!如果有任何疑问,欢迎私信或者评论区讨论~