进程地址空间简介
一、进程地址空间布局
下图是 Linux 下一个进程里典型的内存地址空间布局:


text 段:
- 代码段通常是指用来存放程序执行代码的一块内存区域。
- 这部分区域的大小在程序运行前就已经确定,并且内存区域通常属于只读(某些架构也允许代码段为可写,即允许修改程序)。
- 在代码段中,也有可能包含一些只读的常数变量,例如字符串常量等。
data 段:
- data 段通常是指用来存放程序中已初始化的全局变量的一块内存区域。
- 数据段属于静态内存分配。
bss 段:
- bss 段通常是指用来存放程序中未初始化的全局变量的一块内存区域。
- bss 是英文 Block Started by Symbol 的简称。
- bss 段属于静态内存分配。
- bss 段在分配完毕之后会被清 0。
heap:
- 堆是用于存放进程运行中被动态分配的内存段,它的大小并不固定,可动态扩张或缩减。
- 当进程调用 malloc 等函数分配内存时,新分配的内存就被动态添加到堆上(堆被扩张);
- 当利用 free 等函数释放内存时,被释放的内存从堆中被剔除(堆被缩减)。
stack:
- 栈又称堆栈,是用户存放程序临时创建的局部变量,
- 也就是说我们函数括弧 “{}” 中定义的变量(但不包括 static 声明的变量,static 意味着在数据段中存放变量)。
- 除此以外,在函数被调用时,其参数也会被压入发起调用的进程栈中,并且待到调用结束后,函数的返回值也会被存放回栈中。
- 由于栈的先进先出(FIFO)特点,所以栈特别方便用来保存/恢复调用现场。
- 从这个意义上讲,我们可以把堆栈看成一个寄存、交换临时数据的内存区。
二、栈的生长方向
- 栈整体在内存中是向下生长的(从高地址向低地址);
- 但同一个栈帧中的局部变量(例如数组)通常是从低地址向高地址增长的。
第二条是显然符合逻辑的,你也不想数组是从高地址向低地址增长吧,这就很奇怪!
三、如何判断栈的增长方向?
对于一个用惯了 i386 系列机器的人来说,这似乎是一个无聊的问题,因为栈就是从高地址向低地址增长。不过,显然这不是这个问题的目的,既然把这个问题拿出来,问的就不只是 i386 系列的机器,跨硬件平台是这个问题的首先要考虑到的因素。
在一个物质极大丰富的年代,除非无路可退,否则我们坚决不会使用汇编去解决问题,而对于这种有系统编程味道的问题,C 是一个不错的选择。那接下来的问题就是如何用 C 去解决这个问题。
C 在哪里会用到栈呢?稍微了解一点 C 的人都会立刻给出答案,没错,函数。我们知道,局部变量都存在于栈之中。似乎这个问题立刻就得到了解答,用一个函数声明两个局部变量,然后比较两个变量的地址,这样就可以得到答案。
等一下,怎么比较两个变量的地址呢?
先声明的先入栈,所以,它的第一个变量的地址如果是高的,那就是从上向下增长。“先声明的先入栈”?这个结论从何而来?一般编译器都会这么处理。要是不一般呢?这种看似正确的方法实际上是依赖于编译器的,所以,可移植性受到了挑战。
那就函数加个参数,比较参数和局部变量的位置,参数肯定先入栈。那为什么不能局部变量先入栈?第一反应是怎么可能,但仔细想来又没有什么不可以。所以,这种方法也依赖于编译器的实现。
那到底什么才不依赖于编译器呢?
不妨回想一下,函数如何调用。执行一个函数时,这个函数的相关信息都会出现栈之中,比如参数、返回地址和局部变量。当它调用另一个函数时,在它栈信息保持不变的情况下,会把它调用那个函数的信息放到栈中。
似乎发现了什么,没错,两个函数的相关信息位置是固定的,肯定是先调用的函数其信息先入栈,后调用的函数其信息后入栈。那接下来,问题的答案就浮出了水面。
比如,设计两个函数,一个作为调用方,另一个作为被调用方。被调用方以一个地址(也就是指针)作为自己的入口参数,调用方传入的地址是自己的一个局部变量的地址,然后,被调用方比较这个地址和自己的一个局部变量地址,由此确定栈的增长方向。
给出了一个解决方案之后,我们再回过头来看看为什么之前的做法问题出在哪。为什么一个函数解决不了这个问题。前面这个大概解释了函数调用的过程,我们提到,函数的相关信息会一起送入栈,这些信息就包括了参数、返回地址和局部变量等等,在计算机的术语里,有个说法叫栈帧,指的就是这些与一次函数调用相关的东西,而 在一个栈帧内的这些东西其相对顺序是由编译器决定的,所以,仅仅在一个栈帧内做比较,都会有对编译器的依赖。就这个问题而言,参数和局部变量,甚至包括返回地址,都是相同的,因为它们在同一个栈帧内,它们之间的比较是不能解决这个问题的,而它们就是一个函数的所有相关信息,所以,一个函数很难解决这个问题。
好了,既然有了这个了解,显然可以扩展一下前面的解决方案,可以两个栈帧内任意的东西进行比较,比如,各自的入口参数,都可以确定栈的增长方向。
狂想一下,会不会有编译器每次专门留下些什么,等下一个函数的栈帧入栈之后,在把这个留下的东西入栈呢?这倒是个破坏的好方法。如果哪位知道有这么神奇的编译器,不妨告诉我。我们可以把它的作者拉过来打一顿,想折磨死谁啊!
#include<stdio.h>
void func1();
void func2(int *a);
void func1()
{
int a = 0;
func2(&a);
}
void func2(int *a)
{
int b = 0;
printf("%x\n%x\n", a, &b);
}
int main()
{
func1();
}
结果:
3f48939c
3f48937c
可以看到,a>b。说明生长方向向下。电脑中栈的增长方向是由高地址向低地址方向增长的。
四、为什么栈向下增长
“这个问题与虚拟地址空间的分配规则有关,每一个可执行 C 程序,从低地址到高地址依次是:.text,.data,.bss,堆,栈,环境参数变量;其中堆和栈之间有很大的地址空间空闲着,在需要分配空间的时候,堆向上涨,栈往下涨。”
这样设计可以使得堆和栈能够充分利用空闲的地址空间。如果栈向上涨的话,我们就必须得指定栈和堆的一个严格分界线,但这个分界线怎么确定呢?平均分?但是有的程序使用的堆空间比较多,而有的程序使用的栈空间比较多。所以就可能出现这种情况:一个程序因为栈溢出而崩溃的时候,其实它还有大量闲置的堆空间呢,但是我们却无法使用这些闲置的堆空间。所以呢,最好的办法就是让堆和栈一个向上涨,一个向下涨,这样它们就可以最大程度地共用这块剩余的地址空间,达到利用率的最大化!!
呵呵,其实当你明白这个原理的时候,你也会不由地惊叹当时设计计算机的那些科学家惊人的聪明和智慧!!
堆栈方向相反极小情况会发生的问题,堆栈重叠。
五、为什么要把堆和栈分开
为什么要把堆和栈区分出来呢?栈中不是也可以存储数据吗?
第一,从软件设计的角度来看,栈代表了程序的处理逻辑,而堆代表了实际的数据。栈主要用于记录函数调用的先后顺序和临时上下文,体现 了“时间” 上的执行流程;而堆则用于存储生命周期较长、结构灵活的数据,体现了 “空间” 上的存储结构。
这种逻辑与数据的分离,体现了“分而治之”的设计思想,使程序的结构更清晰、职责更明确。这种隔离与模块化的理念,在软件系统的各个层面都广泛应用,例如前后端分离、控制逻辑与数据模型分离等。
第二,堆与栈的分离,使得堆中的数据可以被多个执行上下文(如多个线程或函数调用栈)共享。这为程序提供了一种高效的数据交互方式,例如通过共享内存或共享对象实现线程之间的通信。同时,堆中还可以存放共享常量、缓存等全局资源,避免重复占用栈空间,从而节省内存,提高性能。
相比之下,栈是线程私有的,无法直接被其他线程访问。因此,把可共享的数据放入堆中,是现代程序设计中实现模块间通信、资源复用的重要手段。
第三,栈帧通常在创建时由操作系统分配 固定大小 的内存。而堆则不同,堆的内存可以在运行时按需动态分配,具有更大的灵活性。为了支持这种动态内存管理,程序将栈和堆分开管理,栈中只需保存指向堆对象的指针即可。
第四,面向对象就是堆和栈的完美结合。虽然面向对象程序在底层的执行方式与传统的结构化程序几乎没有区别,依然使用栈维护调用过程、在堆中分配动态内存,但它改变了我们思考和组织程序的方式,使程序更接近人类自然的认知习惯。
当我们将一个对象拆解,会发现:对象的属性(数据成员)存放在堆中,而对象的方法(行为)在调用时通过栈执行。也就是说,面向对象既封装了数据结构,又封装了对这些数据的操作逻辑,达成了“数据与行为的统一”。
面向对象的设计理念,把抽象建模和执行机制结合得非常优雅,体现了程序设计中结构与行为的平衡之美。
六、栈溢出原因
栈溢出(Stack Overflow) 是一种常见的程序运行错误,通常发生在程序使用的栈空间超过系统为它分配的最大栈容量时。栈溢出一般是以下两种原因:
-
递归层次超过系统限制
-
人为
#include <stdio.h> int main ( ) { char name[8]; printf("Please type your name: "); gets(name); printf("Hello, %s!", name); return 0; }
七、如何利用堆栈溢出
对于上一节认为制造栈溢出的例子,加入栈帧的结构大致如下图所示:

现在我们再执行一次,输入 ipxodiAAAAAAAAAAAAAAA,执行完 gets(name) 之后,由于我们输入的 name 字符串太长,name 数组容纳不下,只好向栈帧顶部继续写 A,其结果就是这些 A 覆盖了堆栈的老的元素。 我们可以发现,%EBP,返回地址 ret 都已经被 A 覆盖了。在 main 返回的时候,就会把 AAAA 的 ASCII 码:0x41414141 作为返回地址,CPU 会试图执行 0x41414141 处的指令,结果出现错误。这就是一次堆栈溢出。
我们已经制造了一次堆栈溢出。其原理可以概括为:由于字符串处理函数(gets,strcpy 等等)没有对数组越界加以监视和限制,我们利用字符数组写越界,覆盖堆栈中的老元素的值,就可以修改返回地址。

浙公网安备 33010602011771号