2、内存布局与指针安全:进程内存布局(栈、堆、BSS、数据段、代码段),指针的初始化与野指针,指针运算的风险

大家好,我是你们的老朋友。今天我们来聊聊C语言里最让人又爱又恨的东西——指针。

说实话,我见过太多程序员栽在指针上。我自己刚入行那会儿,也因为这个吃过不少苦头。有一次线上服务半夜挂了,查了一整晚,最后发现是一个野指针在作祟。从那以后,我对内存布局和指针安全就格外上心。

这一章,咱们就把这些东西彻底讲透。

2.1 进程内存布局:你的程序在内存里长什么样?

你写了一个C程序,编译完跑起来。它在内存里到底是怎么待着的?

其实,一个进程的内存布局,从高地址到低地址,大致分成这么几块:栈、堆、BSS段、数据段、代码段。我画了一张图,你一看就明白。

进程虚拟内存布局(x86-64 Linux 典型布局) 高地址(0x7fffffffffff) 栈(Stack) 局部变量、函数参数、返回地址。向下增长。 堆(Heap) 动态分配的内存(malloc/calloc/realloc)。向上增长。 BSS段(未初始化全局/静态变量) 数据段(已初始化全局/静态变量) 代码段(.text,只读,存放指令) 低地址(0x00000000)

嗯,这张图很重要。你把它记在脑子里,以后分析指针问题会轻松很多。

2.1.1 栈(Stack)

栈是用来存放局部变量和函数调用信息的。每次你调用一个函数,系统就会在栈上给它分配一块空间,叫栈帧。函数返回时,这块空间自动释放。

栈的特点是:自动分配、自动释放、速度极快。但它的空间有限,一般只有几MB。你如果在函数里定义一个很大的数组,比如 int arr[1000000],很容易就把栈撑爆了,造成栈溢出。

我个人习惯是:大块数据用堆,小块临时数据用栈。

2.1.2 堆(Heap)

堆是动态内存分配的区域。你用 malloccallocrealloc 申请的内存,都来自堆。

堆的特点是:手动分配、手动释放、空间大。但速度比栈慢,而且容易产生内存泄漏——你申请了不释放,程序跑久了内存就吃光了。

我记得有一次,一个同事写的服务跑了三天后突然变慢,最后发现是循环里不断 malloc 却忘了 free。嗯,这种坑我踩过不止一次。

2.1.3 BSS段和数据段

这两个段存放的都是全局变量和静态变量。区别在于:

存放内容 特点
BSS段 未初始化的全局/静态变量 程序加载时自动清零
数据段 已初始化的全局/静态变量 初始值保存在可执行文件中

说白了,你写 int g_val; 没给初值,它就待在BSS段。你写 int g_val = 42;,它就待在数据段。

2.1.4 代码段(.text)

代码段存放的是你的程序指令。它是只读的,防止程序意外修改自己的代码。你想想看,如果代码能被随便改,那程序还怎么跑?

核心要点: 理解内存布局,是理解指针安全的基础。你知道了变量在哪儿,才能知道指针指向哪儿是合法的。

2.2 指针的初始化与野指针

野指针是什么?说白了,就是一个指针变量里存了一个无效的地址。你用它去访问内存,轻则段错误,重则数据被破坏、程序崩溃。

我曾经在一个嵌入式项目里,因为一个野指针,把整个系统的配置表给写乱了。那叫一个惨,排查了整整两天。

2.2.1 指针必须初始化

指针变量在定义时,如果不初始化,它的值是随机的(栈上的垃圾值)。这个随机值指向哪里?鬼知道。

正确的做法:

int *p = NULL;  // 明确初始化为空指针
int a = 10;
p = &a;          // 指向一个合法的变量

或者:

int *p = &a;    // 定义时直接初始化
警告: 永远不要使用未初始化的指针!这是C语言中最常见的bug来源之一。

2.2.2 释放后置空

当你用 free 释放了一块堆内存,指针变量里存的地址并没有变。它指向的是一块已经归还给系统的内存。这时候再访问它,就是典型的悬空指针。

int *p = (int*)malloc(sizeof(int));
*p = 42;
free(p);
// p 现在是悬空指针!
// *p = 100;  // 危险!不要这样做!
p = NULL;      // 置空,避免误用

我个人的习惯是:free 之后立刻把指针置为 NULL。这样即使后面不小心用了,也能通过 if (p != NULL) 检查出来。

2.2.3 作用域问题

还有一种情况:返回局部变量的地址。

int* get_val() {
    int x = 10;
    return &x;  // 危险!x 是局部变量,函数返回后栈空间被回收
}

int *p = get_val();
// p 现在指向一块无效的栈内存

嗯,这个坑我见过太多次了。记住:永远不要返回局部变量的地址。如果你需要返回指针,要么用 malloc 分配堆内存,要么用静态变量或全局变量。

2.3 指针运算的风险

指针运算,说白了就是给指针做加减法。C语言允许你对指针进行 +-++-- 等操作。但这里面的门道很多,稍不注意就出问题。

2.3.1 指针加减法的单位

指针加减法的单位是 指向类型的大小,不是字节。

int arr[5] = {10, 20, 30, 40, 50};
int *p = arr;       // p 指向 arr[0]
p++;                // p 指向 arr[1],地址增加了 sizeof(int) 个字节
printf("%d\n", *p); // 输出 20

这个很好理解。但如果你用 void* 指针做运算,就不行了——因为 void 类型的大小是未知的。所以 void* 指针不能直接做加减法。

2.3.2 越界访问

指针运算最危险的地方,就是越界。

int arr[3] = {1, 2, 3};
int *p = arr;
*(p + 5) = 100;  // 越界!arr 只有3个元素,p+5 指向了 arr 之外的内存

这段代码编译时不会报错,运行时也不会立刻崩溃。但它悄悄地把某个未知内存地址的值改成了100。这个bug可能几小时后才暴露出来,到时候你根本不知道是哪里改的。

我曾经在一个网络协议栈里,就是因为一个数组越界,导致相邻的变量被覆盖,整个协议解析全乱了。查了三天才定位到问题。

避坑指南: 做指针运算时,一定要确保结果指针仍然在合法范围内。我一般会在循环里加边界检查,或者用 assert 来断言。

2.3.3 指针相减

两个指向同一数组的指针可以相减,结果是一个 ptrdiff_t 类型的整数,表示两个指针之间相差的元素个数。

int arr[5] = {1, 2, 3, 4, 5};
int *p1 = &arr[1];
int *p2 = &arr[4];
ptrdiff_t diff = p2 - p1;  // diff = 3
printf("%td\n", diff);     // 输出 3

但如果两个指针指向不同的数组,相减的结果是未定义的。不要这么做。

2.3.4 指针比较

指向同一数组的指针可以用 <><=>= 比较大小。但指向不同对象的指针比较,行为是未定义的。

int arr1[3] = {1, 2, 3};
int arr2[3] = {4, 5, 6};
int *p1 = &arr1[0];
int *p2 = &arr2[0];
// if (p1 < p2) { ... }  // 未定义行为!不要这样写

你想想看,两个不同对象的地址,谁大谁小跟编译器、操作系统、内存布局都有关系,没有意义。

2.4 总结

这一章我们聊了进程内存布局、指针初始化、野指针、指针运算的风险。说白了,核心就一句话:指针必须指向合法的内存,操作不能越界

我做了这么多年C语言开发,最大的体会就是:指针用好了,它是利器;用不好,它是凶器。每次写指针操作,我都会多问自己一句:这个指针指向哪里?它指向的内存还活着吗?我有没有越界?

养成这个习惯,你的代码会安全很多。

本章核心要点:
  • 进程内存布局:栈(局部变量)、堆(动态分配)、BSS(未初始化全局/静态)、数据段(已初始化全局/静态)、代码段(只读指令)
  • 指针必须初始化,释放后置空
  • 不要返回局部变量的地址
  • 指针运算的单位是类型大小,注意越界
  • 不同对象的指针不要比较或相减
公众号:蓝海资料掘金营,微信 deep3321