程序内存布局中,Text、Data、BSS、Heap与Stack如何构成一串的?
摘要:本文围绕程序运行时的内存布局进行了介绍,对典型 C 程序在进程虚拟地址空间中的结构进行了梳理。文章首先说明了程序运行时内存划分的基本形式,指出一个典型程序通常由代码段(Text Segment)、数据段(Data Segment)、BSS
一、程序运行时的内存模型
在现代计算机系统中,程序运行时并不是直接操作物理内存,而是运行在进程虚拟地址空间中。操作系统为每个进程提供一块独立的地址空间,并按照程序运行的需求将其划分为多个逻辑区域,用于存储不同类型的数据。这种划分并不是物理内存结构,而是一种按照数据用途与生命周期进行的逻辑划分。在典型的C/C++程序中,进程地址空间通常包含以下几个区域:
代码段(Text Segment)
数据段(Data Segment)
BSS段
堆(Heap)
栈(Stack)
二、程序地址空间整体结构
典型的程序虚拟地址空间结构如下:
各个区域在运行过程中具有不同的增长方向与管理方式:
Stack:从高地址向低地址增长
Heap:从低地址向高地址增长
Text/Data/BSS:固定大小
这里需要注意一个点,就是堆和栈在运行过程中可能相向增长。
三、代码段(Text Segment)
代码段用于存放程序编译之后生成的机器指令。编译器在对源代码进行编译时,会将每一个函数转换为对应的机器指令序列,这些指令最终都会被放入代码段中,当程序被加载到内存并开始运行时,CPU从代码段中读取指令并按照顺序执行,从而完成程序逻辑。因此,从程序执行的角度来看,代码段实际上就是程序真正被处理器执行的部分。
以C语言为例,当定义一个普通函数时:
void func(void)
{
printf("Hello world!\n");
}
在编译阶段,编译器会将该函数转换为一系列具体的机器指令,例如函数入口、参数准备、函数调用以及函数返回等指令。这些指令在链接阶段会被统一放入程序的代码段中。当程序运行时,CPU通过程序计数器(Program Counter)逐条读取这些指令并执行,因此函数的执行本质上就是处理对代码段中机器指令的顺序解释和执行过程。
代码段通常被设置为只读且可执行的内存区域,之所以设置为只读,一方面是为了防止程序在运行过程中意外修改自己的指令,从而导致不可预测的行为;另一方面也是处于系统安全性的考虑,如果代码段可以随意写入,就可能被恶意代码篡改,从而改变程序的执行逻辑。现代操作系统通常会对代码段设置“只读 + 可执行”的访问权限,即允许处理器读取并执行其中的指令,但不允许对其进行写操作。
从生命周期上看,代码段在程序加载时就已经被映射到进程的地址空间中,并且在整个程序运行期间保持不变。程序结束后,操作系统会回收这部分内存。因此与堆或栈这种运行时会动态变化的区域不同,代码段的大小和内容在程序编译完成后基本就已经确定。
在一些支持虚拟内存管理的操作系统中,如果多个进程运行的是同一个程序,它们的代码段还可以被操作系统共享。例如多个进程同时运行同一个可执行文件时,系统通常只需要在物理内存中保存一份代码段,而在每个进程的虚拟地址空间中映射到这同一份物理内存。这样既可以节省内存,也不会影响程序的独立运行,因为代码段本身只是只读的,各个进程不会相互修改其中的内容。
四、数据段(Data Segment)
数据段用于存放已经初始化的全局变量和静态变量。这类变量在程序开始运行之前就已经具有确定的初始值,因此在程序加载阶段,操作系统或运行时环境会将这些初始值一并加载到内存中。换句话说,就是当程序真正开始执行时,这些变量就已经处于初始化完成的状态,可以被程序直接访问和修改
比如在C语言中:
int global_var = 10;
static int counter = 5;
global_var 和 counter 都属于已初始化的全局或静态变量,因此它们会被放入数据段中。编译器在生成可执行文件时,会将这些变量的初始值一起写入可执行文件的对应区域。当程序被加载到内存时,这些初值会被拷贝到进程地址空间中的数据段区域,从而保证程序运行时变量已经处于正确的初始状态。
数据段中的变量具有比较明确的生命周期:它们在程序启动时被创建,在整个程序运行过程中始终存在,并且只有在程序结束时才会被系统回收。因此,与栈中的局部变量不同,数据段中的变量不会随着函数调用结束而销毁,而是贯穿程序运行的整个周期。
需要注意的是,数据段主要针对的是已初始化的全局变量和静态变量。如果全局变量或静态变量在定义时并没有给出初始值,则通常不会被放入数据段,而是会进入另一块专门用于存储未初始化变量的区域,即 BSS段。这种划分的主要目的在于减少可执行文件的体积,因为未初始化变量在文件中只需记录其大小,而不需要存储具体的数据内容。
五、BSS段
BSS段用于存放未初始化的全局变量和静态变量。例如:
int global_count;
static int buffer[1024];
在这类变量的定义中,并没有显式给出初始值。按照C语言的规则,未初始化的全局变量和静态变量在程序启动时会被自动初始化为0。因此,在程序加载阶段,操作系统或运行时环境会为这些变量分配对应的内存空间,并将其内容清零。
BSS段与数据段在程序运行时的表现非常类似,二者中的变量都具有相同的生命周期:从程序启动开始一直存在到程序结束。但在可执行文件中,它们的处理方式有所不同。对于数据段中的变量,编译器需要在可执行文件中保存其初始值;而对于 BSS段 中的变量,只需要记录变量所占用的空间大小即可。程序加载时,系统根据这些信息在内存中分配相应空间并进行清零操作。因此,BSS段本身通常不会占用可执行文件的实际存储空间。
六、堆(Heap)
堆是一块用于动态内存分配的内存区域。与数据段和BSS段不同,堆中的内存并不是在程序启动时一次性确定的,而是在程序运行过程中根据需要动态申请和释放。在C语言中,常见的堆内存分配函数包括 malloc、calloc、realloc和 free 。例如:
int *p = malloc(sizeof(int));
执行这条语句时,库会在堆区域申请一块足以存储一个 int 类型数据的内存空间,并将这块内存的地址返回给指针 p。之后程序可以通过这个指针对该内存进行读写操作。当这块内存不再被需要时,可以通过 free 函数将其释放,以便内存管理器将其重新纳入可分配空间。
由于堆内存的申请和释放完全由程序控制,因此其生命周期具有很大的灵活性。堆内存可以在函数内部申请,但在函数返回之后仍然保持有效,只要程序没有显式释放它,就可以在其他地方继续使用。这一特性使得堆非常适合用于存储生命周期不固定的数据结构,例如链表、树或大型缓冲区。不过,正因为堆的管理依赖程序员控制,如果释放逻辑处理不当,就可能产生内存泄露或悬空指针等问题。
七、栈(Stack)
栈主要用于保存函数调用过程中的上下文信息。当程序调用一个函数时,系统会在栈上创建一个新的栈帧(Stack Frame),用于保存当前函数执行所需的各种数据。典型的栈帧内容包括函数参数、局部变量、返回地址以及部分寄存器的保存区。例如:
void func(void)
{
int a = 10;
}
当 func 被调用时,局部变量 a 会被分配在当前函数对应的栈帧中。当函数执行结束并返回时,这个栈帧会被自动销毁,相应的栈空间也随之被释放。因此,栈中的数据具有明显的作用域特性,其生命周期通常仅限于函数执行期间。
与堆相比,栈的内存分配和释放由编译器和处理器自动完成,不需要程序员手动干预,因此开销非常小,效率也更高。不过,栈空间通常是有限的,如果在栈上分配过大的局部数组或出现无限递归调用,就可能导致栈空间耗尽,从而引发栈溢出(Stack Overflow)。
八、程序示例解析
从整体结构上来看,程序运行时的内存布局通常可以理解为:代码段存放可执行指令,数据段和BSS段存放全局数据,堆用于动态分配的对象,而栈则负责维护函数调用过程中的临时数据。不同区域之间在管理方式和生命周期各不相同,共同构成了程序运行时的基本内存结构。
下面将通过一个简单的C程序示例说明在实际程序中这些区域分别存放哪些数据:
#include <stdio.h>
#include <stdlib.h>
int global_var = 10;//Data Segment
int global_uninit;//BSS Segment
int main(void)
{
int local_var = 5;//Stack
int *p = malloc(sizeof(int));//指针变量 p 本身位于Stack,malloc申请的内存位于Heap
//Data Segment 变量地址
printf("global_var:%p\n", &global_var);
//BSS Segment 变量地址
printf("global_uninit:%p\n", &global_uninit);
//Stack 变量地址
printf("local_var:%p\n", &local_var);
//Heap 内存地址
printf("heap:%p\n", p);
return 0;
}
运行后可以得到结果:
global_var:0x404038
global_uninit:0x404040
local_var:0x7ffd84791424
heap:0x7ed260
