2. 内存布局与缓冲区:进程内存布局与栈上分配机制

大家好,我是你们的老朋友。今天我们来聊聊一个非常核心的话题——进程的内存布局。说白了,就是你的程序跑起来之后,那些变量、代码、数据到底是怎么在内存里安家的。

我刚开始学C语言的时候,总觉得这些概念离我很远。直到有一次,我在一个嵌入式项目里遇到了一个诡异的崩溃问题——函数返回后,局部变量居然被莫名其妙地改写了。查了两天才发现,是栈溢出把返回地址给踩了。嗯,从那以后,我再也不敢轻视内存布局了。

2.1 进程的经典内存布局

一个正在运行的进程,它的虚拟地址空间通常被划分为几个区域。你想想看,就像一栋楼,不同楼层住着不同的人,干不同的事。进程的内存也是这样分层的。

核心区域一览:

  • 代码段(Text Segment):存放你的机器指令。只读的,谁也别想改它。
  • 数据段(Data Segment):又分两块——已初始化数据区(存全局变量、静态变量)和未初始化数据区(BSS段,存那些默认值为0的全局变量)。
  • 堆(Heap):动态分配的地盘。你用 malloccalloc 搞出来的内存都在这儿。
  • 栈(Stack):函数调用的核心舞台。局部变量、函数参数、返回地址,全在栈上。

我个人习惯把栈想象成一个叠盘子架——后进先出。函数调用就是往上叠一个盘子,函数返回就是拿走一个盘子。简单直观。

下面这张图,是我手绘的一个进程内存布局示意图,帮你把抽象的东西具象化:

进程虚拟地址空间(从高地址到低地址) 栈(Stack) 局部变量、函数参数、返回地址 高地址 ↓ 向下增长 堆(Heap) 动态分配的内存(malloc / free) ↑ 向上增长 低地址 BSS段(未初始化全局/静态变量) 数据段(已初始化全局/静态变量) 代码段(只读指令)

💡 小提示:栈和堆的增长方向是相反的。栈从高地址往低地址走,堆从低地址往高地址走。这样设计,是为了让它们能灵活地共享中间的空白区域,避免浪费。

2.2 缓冲区在栈上的分配机制

好了,现在我们聚焦到栈上。缓冲区,说白了就是一块连续的内存区域,用来临时存放数据。在C语言里,最常见的缓冲区就是局部数组。

你写一个 char buf[64],编译器就会在栈上给你划出64个字节的空间。怎么划的?我来给你拆解一下。

2.2.1 栈帧的结构

每次调用一个函数,系统都会在栈上创建一个栈帧(Stack Frame)。这个栈帧里装了什么?

  • 返回地址:函数执行完后,该回到哪里去。
  • 调用者的栈基址(EBP/RBP):用来恢复调用者的栈帧。
  • 局部变量:你的那些 int achar buf[64] 全在这儿。
  • 函数参数:有些参数通过寄存器传,有些直接压栈。

举个例子,你看下面这段代码:

void vulnerable() {
    char buffer[8];   // 栈上分配8字节
    int flag = 0;     // 栈上分配4字节
    // 注意:buffer 和 flag 在栈上是相邻的!
}

这段代码编译后,栈上的布局大概长这样(假设栈向下增长):

地址(从高到低) 内容 大小
高地址 返回地址 4或8字节
调用者EBP 4或8字节
flag(0x00000000) 4字节
低地址 buffer[0..7] 8字节

⚠️ 警告:注意看!buffer 在低地址,flag 在高地址。如果你往 buffer 里写超过8个字节的数据,就会覆盖掉 flag 的值。这就是缓冲区溢出的基本原理。

我曾经在一个网络协议栈的代码里见过类似的漏洞。开发者定义了一个 char buf[16],然后直接 recv(sock, buf, 64)。你想想看,64个字节往16字节的缓冲区里塞,后果是什么?返回地址被覆盖,程序直接跑飞了。嗯,这就是经典的栈溢出攻击入口。

2.3 局部变量与数组的内存排列

局部变量在栈上的排列顺序,并不是你写代码的顺序。编译器会根据自己的优化策略重新排布。但有一个规律是确定的——数组总是连续存放的

来看一个更复杂的例子:

void example() {
    int a = 1;
    char buf[12];
    int b = 2;
    short c = 3;
}

编译器可能会这样排(具体取决于编译器和优化选项):

  • 先分配 buf[12](12字节,对齐到16字节边界)
  • 再分配 ab(各4字节)
  • 最后分配 c(2字节,但可能被填充到4字节)

为什么这么排?因为编译器要满足对齐要求。比如 int 类型通常要求4字节对齐,short 要求2字节对齐。数组本身没有特殊对齐要求,但它的起始地址要满足内部元素的对齐。

关键点:

  • 数组在栈上是连续分配的,没有空隙。
  • 局部变量的分配顺序不保证与声明顺序一致。
  • 编译器可能会在变量之间插入填充字节以满足对齐。
  • 栈上的缓冲区紧邻其他局部变量和返回地址,这是安全问题的根源。

我个人习惯在写代码时,把缓冲区放在函数的最后声明。虽然编译器不一定按我的顺序来,但至少从代码可读性上,我能提醒自己:这里有个缓冲区,别搞溢出了。

2.4 一个典型的栈溢出示例

光说不练假把式。我们来看一个真实的、能跑的例子:

#include <stdio.h>
#include <string.h>

void check_password() {
    char password[8];
    int auth_flag = 0;

    printf("请输入密码: ");
    gets(password);  // 危险!没有长度检查

    if (strcmp(password, "secret") == 0) {
        auth_flag = 1;
    }

    if (auth_flag) {
        printf("验证通过!\n");
    } else {
        printf("密码错误!\n");
    }
}

int main() {
    check_password();
    return 0;
}

这段代码有什么问题?问题大了去了。gets() 不会检查输入长度。如果你输入超过8个字符,比如 "AAAAAAAAAA",就会覆盖掉 auth_flag 的值。更可怕的是,如果你输入精心构造的数据,还能覆盖返回地址,实现任意代码执行。

💡 避坑指南:我曾经在一个遗留系统里看到过类似的代码。当时我建议团队把 gets() 全部替换成 fgets(),并明确指定缓冲区大小。就这么一个改动,消除了十几个潜在的安全漏洞。记住:永远不要用 gets(),永远不要相信输入长度。

2.5 总结一下

这一节我们聊了进程的内存布局,重点看了栈和缓冲区。说白了,栈就是函数调用的舞台,缓冲区就是舞台上的道具。道具放错了位置,或者道具太大放不下,就会出乱子。

理解这些底层机制,不是为了让你去写漏洞,而是为了让你在写代码时,心里有数。知道哪里可能出问题,才能提前防范。

好了,今天就到这儿。记住:缓冲区溢出不是 bug,是灾难。写代码时多留个心眼,比事后补漏洞强一万倍。


公众号:蓝海资料掘金营,微信 deep3321