深入内存布局:char *p 与 char p[ ] 的底层原理与崩溃真相

70 阅读5分钟

在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;
}

屏幕截图 2025-12-11 122713.png

结果:str2 正常修改并打印 "hello",而尝试修改 str1 时程序直接崩溃。 程序运行结果和我们的预期是一样的。为什么看似相同的操作,一个正常,另一个却崩溃了?

这涉及到C程序的进程虚拟内存布局,要理解这个问题,我们下面来看一下进程的内存分布。程序在运行时,内存被划分成了不同的区域。

image.png

image.png

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;
}

屏幕截图 2025-12-11 125706.png

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语言字符串处理的一大门槛!如果有任何疑问,欢迎私信或者评论区讨论~