2. 内存布局与缓冲区:进程内存布局与栈上分配机制
大家好,我是你们的老朋友。今天我们来聊聊一个非常核心的话题——进程的内存布局。说白了,就是你的程序跑起来之后,那些变量、代码、数据到底是怎么在内存里安家的。
我刚开始学C语言的时候,总觉得这些概念离我很远。直到有一次,我在一个嵌入式项目里遇到了一个诡异的崩溃问题——函数返回后,局部变量居然被莫名其妙地改写了。查了两天才发现,是栈溢出把返回地址给踩了。嗯,从那以后,我再也不敢轻视内存布局了。
2.1 进程的经典内存布局
一个正在运行的进程,它的虚拟地址空间通常被划分为几个区域。你想想看,就像一栋楼,不同楼层住着不同的人,干不同的事。进程的内存也是这样分层的。
核心区域一览:
- 代码段(Text Segment):存放你的机器指令。只读的,谁也别想改它。
- 数据段(Data Segment):又分两块——已初始化数据区(存全局变量、静态变量)和未初始化数据区(BSS段,存那些默认值为0的全局变量)。
- 堆(Heap):动态分配的地盘。你用
malloc、calloc搞出来的内存都在这儿。 - 栈(Stack):函数调用的核心舞台。局部变量、函数参数、返回地址,全在栈上。
我个人习惯把栈想象成一个叠盘子架——后进先出。函数调用就是往上叠一个盘子,函数返回就是拿走一个盘子。简单直观。
下面这张图,是我手绘的一个进程内存布局示意图,帮你把抽象的东西具象化:
💡 小提示:栈和堆的增长方向是相反的。栈从高地址往低地址走,堆从低地址往高地址走。这样设计,是为了让它们能灵活地共享中间的空白区域,避免浪费。
2.2 缓冲区在栈上的分配机制
好了,现在我们聚焦到栈上。缓冲区,说白了就是一块连续的内存区域,用来临时存放数据。在C语言里,最常见的缓冲区就是局部数组。
你写一个 char buf[64],编译器就会在栈上给你划出64个字节的空间。怎么划的?我来给你拆解一下。
2.2.1 栈帧的结构
每次调用一个函数,系统都会在栈上创建一个栈帧(Stack Frame)。这个栈帧里装了什么?
- 返回地址:函数执行完后,该回到哪里去。
- 调用者的栈基址(EBP/RBP):用来恢复调用者的栈帧。
- 局部变量:你的那些
int a、char 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字节边界) - 再分配
a和b(各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,是灾难。写代码时多留个心眼,比事后补漏洞强一万倍。