PS:本C++进阶讲解基于x86 32位linux环境

进程的虚拟地址空间内存划分和布局

我们知道,任何的编程语言,在编译文件后都会产生的两种东西是指令和数据,并以可执行文件.exe的形式存储在磁盘里,而每次运行可执行文件时,都需要将磁盘上的.exe加载到内存当中,由CPU读取指令并对数据进行操作。

那么磁盘上的文件加载到内存(虚拟内存,而非直接加载到物理内存)后,如何为数据分配内存空间,内存空间的结构划分是什么样的,就需要我们详细了解。

首先,linux系统给每一个进程会分配一个= 4G大小的一块虚拟内存空间(地址范围0x00000000~~0xFFFFFFFF)。这块空间大体分为两部分:用户空间(0x00000000~~0xC0000000,占3G)和内核空间(0xC0000001~~0xFFFFFFF,占1G)。

内存空间按地址从低到高,更具体的划分如下:

  1. 整个内存空间的前一小块(0x00000000—0x08048000,低地址空间)是不可访问的,这片区域在现代linux操作系统中是故意留空用于保护的,防止空指针访问导致程序崩溃。如果程序试图访问虚拟地址0x0,cpu的内存管理单元会查询页表中的虚拟到物理地址映射,发现这片低地址并没有有效映射(无对应物理地址),就会发生异常中断程序进程。

    e.g.

    1
    2
    3
    4
    char *p = nullptr;
    strlen(p); //strlen()函数试图访问指针p指向的内存空间存放的数据并求长度,但p是指向nullptr也即零地址0x0的空指针,程序运行时会报错。
    char *src = nullptr;
    strcpy(p,src); //同理,strcpy()函数试图访问空指针src指向的内存,将其存放的数据拷贝给p,将会报错。注意p和src都是指向零地址的,但是实际内存中的低地址空间并不会真的分配给这些指针用于存放数据,只是cpu在按照nullptr查询这片低地址空间的映射并取数据或写数据时会发现查询没有结果从而报出异常。
  2. 用户空间从0x08048000开始的第一块有效区域是.text和.rodata段。.text段也叫代码段,用于存放实际代码内容编译后对应的汇编指令。.rodata段即只读数据段,用于存放字符串字面量,其中的数据都是只能读取不能修改的。

    e.g.

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    //c风格操作,使用字符类型指针直接指向一个字符串字面量
    char *p = "abcd"; //"abcd"这个字符串字面量存储在.rodata段。
    *p = "a"; //修改指针指向的内存空间里的数据,就是直接修改.rodata段里的"abcd",这样做是不允许的,会在运行阶段报错。
    //PS:目前许多高级语言已经规定普通指针不能直接指向字符串字面量,只有声明为const类型的指针才可以。这样就在编译阶段避免了修改.rodata段的数据这种错误操作。

    //C++风格操作,创建string类对象并将其赋值为一个字符串字面量
    using namespace std;
    string s = "abcd";
    //这一行代码做了三件事情,一个是在内存的栈stack上(下面有解释)创建string类对象s(这个对象内部通常包含了一些控制信息,比如一个指向字符数据的指针、字符串的当前长度和已分配内存的容量等),因为s是一个局部变量(PS:这行代码编译时会转化为汇编指令,储存在.text段)。
    //第二个是在.rodata段创建"abcd"这个字符串字面量。
    //最后一件事情是将"abcd"赋值给s,这步操作由string类的赋值构造函数完成,它首先会在内存的堆heap上动态分类一块大小足以容下"abcd"的内存,然后将.rodata段中的"abcd"拷贝到堆上分配的新内存里,再将对象s的内部指针指向这块堆上的新内存。
    s = "a";
    //跟c风格直接更改指针指向的.rodata段数据不同,这里的操作首先在.rodata段重新创建一块内存存放字符串"a",然后触发string类的赋值操作(operator=),释放对象s原本分配在堆上存放"abcd"的动态内存(并不会对.rodata段里的"abcd"产生实际影响),然后重新在堆上分配一块内存,将.rodata段新创建的"a"拷贝过来,并将s内部指针指向这块内存,同时更新s长度状态。
  3. 再往上走,就是静态区(低到高依次为.data段和.bss段):静态区用于存放可读可写的全局/静态变量,只要程序编译好,就会在.data段存放,一直到程序结束内存清空。.data和.bss的区别在于.data存放已经初始化且不为0的数据,而.bss段存放初始化为0或者未初始化的数据(程序编译时操作系统将其自动置为0)

    PS:为什么需要 .bss 段? 这是一种优化。对于一个很大的、未初始化的全局数组 int big_array[10000];,如果把它存放在 .data 段,那么在可执行文件(如 ELF 文件)中就需要实实在在地占用 10000 * 4 字节的空间来存储这些零。而如果放在 .bss 段,可执行文件中只需要记录“需要分配 40000 字节,并全部置为零”这个信息,而不需要存储那些零本身。这大大减小了可执行文件的大小。

  4. 堆(heap),用于存放动态分配的内存。

  5. 加载共享库,用于存放一些动态链接库的映射(windows下为*.dll库,linux下为*so库)。

  6. 栈(stack),一个程序进程运行时可能会产生多个不同的线程(如一道程序由多个函数构成,每个函数就是一个小线程),那么每个函数在内存上占有的私有空间就是栈。也就是说,函数内部的非静态局部变量都在栈上存储。

    局部变量与栈 (Stack)

    • 生命周期:局部变量的生命周期与它所在的函数或代码块绑定。函数开始执行时,它被创建;函数执行结束时,它必须被销毁。
    • 栈的特性:栈是一种后进先出 (LIFO) 的数据结构,这与函数的调用/返回机制完美契合。
      • 函数调用:当一个函数被调用时,系统会在栈顶“压入”一个新的栈帧 (Stack Frame)。这个栈帧里包含了函数的参数、返回地址以及所有局部变量
      • 函数返回:当函数执行完毕返回时,它的整个栈帧会被“弹出”,相当于瞬间释放了所有局部变量占用的空间。
      • 对于虚拟内存的栈,地址是从高到低分配的。我们设栈顶指针esp,栈底指针ebp。当数据被压栈的时候,栈顶指针esp会向下移动一个数据大小的位置,而栈底指针不动,每次压栈都是在栈顶处(低地址)分配空间,并让栈顶指针下移。
    • 为什么用栈?
      • 高效:栈的分配和释放只是移动一下栈顶指针 (esp),这是一个极快的操作,远比在堆上动态分配内存要快。
      • 自动管理:程序员无需关心局部变量的内存释放,它随着函数返回自动完成,避免了内存泄漏。
  7. 命令行参数和环境变量(环境变量即程序调用外部库的指定路径)

  8. 内存最高的1G大小的空间就是内核空间了,主要分为ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM三块区域,其中ZONE_NORMAL用于存放进程的PCB(进程控制块,存储进程相关信息)以及内部线程私有的栈空间信息等,而ZONE_MEM为高端内存,用于映射高地址物理内存(此处不详细展开)。

    需要注意的是,内存上的内核空间是进程间共享的(进程间通过匿名管道通信),用户空间才是各个进程私有的,相互无法访问。

image-20250814174159721

以上就是一个进程的虚拟内存空间划分情况,接下来我们以一个简单的代码例子具体说明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include<iostream>
using namespace std;

int gdata1 = 10;
int gdata2 = 0;
int gdata3;

static int gdata4 = 11;
static int gdata5 = 0;
static int gdata6;
//以上六个变量都是全局变量,编译后会在可执行文件里的符号表里产符号映射,将变量名映射到运行时分配的内存地址。这些变量就称为数据,存放在虚拟内存的数据段里,具体的:gdata1和gdata4初始化不为零,存放在.data段;其余的初始化为零或未初始化,放在.bss段

int main()
{
int a = 12;
//在函数里声明的局部变量则不会在符号表里产生符号,这行代码编译后对应的是一个汇编指令:mov dword ptr[a],0Ch
//表示将12这个值放到a这个变量在栈上的内存中(运行时指令本身放在.text段,12这个值被存放在栈上开辟好的4字节内存里)
//PS:a这个字母并不会被CPU理解,实际的汇编指令里a应该是直接指向内存地址的一个指针,如ebp-4(ebp栈底是高地址),a只是编译器产生的方便程序员阅读的形式。因此只要程序运行到函数结束,栈上分配的线程对应的内存自动弹出销毁,进程就识别不到int a = 12这个信息了。
int b = 0;
int c;
//b,c与a同理,但是注意变量c没有初始化,所以是栈上的无效值,打印时会出现乱码。

static int e = 13;
static int f = 0;
static int g;
//以上局部静态变量生命周期为整个程序运行过程,因次存放在静态数据段.data和.bss,但注意作用域还是局限于main函数

cout << c << "+" << g << endl;//打印结果是乱码+0,因为g会被加载器自动置零
return 0
}

汇编角度详解函数调用堆栈过程

观察以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include"pch.cpp"
#include<iostream>
using namespace std;

int sum(int a,int b)
{
int temp = 0;
temp = a + b;
return temp;
}

int main()
{

}