C++语法|进程虚拟地址空间和函数调用栈

本文来自施磊老师的课程,老师讲的非常不错,我的笔记也是囫囵吞枣全部记下,但是我在这里推荐一本书,真的真的建议初学C++或者想要进阶C++的同学们看看:《CPU眼里的C/C++》

进程的虚拟地址空间和布局

任何的编程语言,无非产生两种东西:指令和数据。
在任何操作系统,程序编译链接完成后,会生成可执行文件,并且该文件会存储在磁盘当中,在我们执行该文件,它就会加载到内存中。那么我们就有一个疑问:内存到底有没有区域的划分,划分之后又是什么样子呢?

不过就算我们加载到内存,也不可能加载到物理内存!!!

加载到内存中,首先linux系统(x86 32位)会给当前进程分配一个 2 3 2 2^32 232(4G)大小到一块空间。

需要注意的是,比较常见的画法是,低地址在下,高地址在上

这个空间叫做进程的虚拟地址空间,其实虚拟地址空间的本质不过是内核创建的一系列数据结构。

NOTE:
它存在,你能看见,它是物理的
它存在,你不能看见,它是透明的
它不存在,你能看见,它是虚拟的
它不存在,你也看不见,它被删除了

进程虚拟地址空间

该空间被默认分为两部分,一部分从0x00000000~0xC0000000一共3G到校被称为user space用户空间,剩下的空间为kernal space为1G。

每一个进程都有这么一个虚拟地址空间,在用户空间的划分情况又是什么样的呢?

1 不可访问区域

它并没有从零地址开始存储,而是从`0x08048000`开始存储,所以最顶部的空间是不能够访问的。有些情况下如果我们访问控制真: ```cpp char *p = nullptr; strlen(p); char *src = nullptr; strcpy(dest, src); ``` 这些都是零地址,其实就是在我们的`0x00000000`~`0x08048000`这部分地址不允许访问,不能读也不能写。如果访问的话程序会崩溃,系统要报异常(通过信号)。

2 .text代码段和.rodata只读数据段

0x08048000开始,首先是.text如果有人问指令在运行的时候放在哪块区域,我们不要说全局变量区或者静态区,直接说代码段或者.text段即可****。这一部分通常还有一块区域叫做.rodata叫做只读数据段放的是什么呢?

//在函数中定义一个局部变量指针
char *p = "hello world"; //会报warning
//只能写成
const chart *p = "hello world";

对于本例来说,p就在栈上,p指向的那个常量字符串就在.rodata段,那么如果我们想修改这个指针*p = 'a',这样编译是没有问题的,但是如果运行这个程序会直接挂掉,因为.rodata.text段落只能读不能写。其实在C++较新的编译器中,是不能使用普通指针指向常量字符串的(会报warning)。如果我们使用const修饰,所以就不会发生*p='a'这样不可预期的错误了。

3 .data数据段和.bss数据段

这两个段落都叫数据段,那么这两个有什么不同呢?

.data只存放初始化过的并且初始化数据不为0的

.bss存放未初始化的以及初始化为0的,程序运行的时候会把该段数据全部初始化为0。

那么,当我们全局作用域中,写一个全局变量但是没有初始化,当我们去打印它的值会发现它是一个0,程序运行的时候我们内核给当前进程分配地址空间,我们程序未初始化的数据放在.bss,我们的内核也就是操作系统会自己负责把.bss段的数据全部置为0,这就是为什么未初始化的全局变量是0。

#include <iostream>
using namespace std;
int gdata;
int main() {   
    cout << gdata << endl; //被内核初始化为0
    return 0;
}

4 .heap段

.bss段落再往下,暂时还没有,但是我们先把它画出来,这块空间就叫做堆heap!.heap只有在我们调用了newmallocalloc才被分配空间。

5 *.dll *so库

堆内存再往下就是我们当前程序在运行过程中会加载一些共享库,也就是我们的动态链接库,windows下是*.dll,linxu下是*.so库。再一个需要注意的是,这里也是我们堆栈共享区,栈会向低地址生长,堆则会向高地址生长。
比较常见的画法应该是下面是低地址,上面是高地址

6 stack段

现在就到我们的栈空间!程序运行每一个线程都独有的stack栈空间!栈空间跟其他地方不一样的是,栈空间是从下往上进行增长,堆被分配时是从低地址到高地址的增长。

7 命令行参数和环境变量

在这里存储命令行参数和环境变量的路径


8 内核空间

此处为内核空间,内核空间是进程共享的!!!!
以上就是我们用户空间内存划分的布局,在内核空间主要分为了ZONE_DMA和ZONE_NORMAL还有ZONE_HIGHMEM这三块地区。大概分别为16M、800M、剩下的就是我们的ZONE_HIGHMEM
在ZONE_NORMAL一般是放PCB块,以及内核空间的线程和内核空间运行的函数所在的栈空间都在这一部分。
最后ZONE_HIGHMEM是高端内存,它是映射我们高地址的物理内存的时候做地址映射用的。

代码分析

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

static int gdata1 = 10;
static int gdata1 = 0;
static int gdata3;

int main() {    
    int a = 12;
    int b = 0;
    int c;

    static int e = 13;
    static int f = 0;
    static int g;
    return 0;
}

gdata1gdata4被初始化并且初始值不为零,被放在.data段。

gdata2gdata3gdata5gdata6未初始化或初始值为0,被放在.bss段。

至于abc他们并不产生符号,而是产生指令,比如说int a = 12;在x86指令集中为mov dword ptr[a], 0Ch。所以他们三个局部变量最终产生的是指令,被放在.text段。

然后关于efg这三个为静态局部变量,也是放在数据段(.data或.bss)的,但程序运行的时候是不会初始化的,只有第一次运行到他们才会进行初始化,分别放在数据段的.data.bss.bss

如果我们有如下操作:

//打印c
cout << c << endl;
//打印g
cout << g << endl;

打印c肯定不为0!因为它是栈上的无效值,但是如果打印g,肯定是0!因为他在.bss段。


综上所述,我们的红色部分都存储在.text部分,因为他们都会产生指令

但是我们一定要问自己一个问题,我们a、b、c已知那些数据都是放在栈上面的,有为什么说他们产生了指令呢

重点问题

为什么局部变量一会儿说在栈上,一会儿又是在 .text段

a, b, c编译后产生的指令是要放到.text段的,但是这个函数运行的时候,系统会在栈上面给该函数开辟一个栈帧,指令mov dword ptr[a], 0Ch就是把12放在a这块内存的4字节内存中,所以指令运行的时候会在栈空间上划分一块4字节的空间来存放12。也就是说a这个语句生成的时机是在函数运行时的,我们执行可执行文件后,先加载它的指令放在.text段,然后等到这条指令运行时,才会在栈空间开辟一个4字节的空间。

每一个进程的用户空间是私有的,但是内核空间是共享的!!!

如果我创建多个进程,QQ、酷狗音乐、VS。各自都有各自的用户空间,但是内核空间是共享的。

进程跟进程之间通信比较难的原因就是因为他们的用户空间是隔离的,谁也访问不到谁,但是内核空间是共享的。所以说进程之间的通信方式有哪些??

这样我们很容易理解了,进程间通信其实就是在内核空间划分了一块儿内存,这样一来进程1往内核共享的这块内存中写数据,进程2、3就都能看的见。
匿名管道通信

本模块推荐书籍:
《深入理解计算机系统》 尤其第七章 链接
《程序员的自我修养》尤其是

函数调用栈

给定一下代码:

int sum (int a, int b) {
    int tmp = 0;
    tmp = a + b;
    return tmp;
}
int main() {
    int a = 10;
    int b = 20;
    
    int ret = sum(a, b);
    cout << "ret: " << ret << endl;    
    return 0;
}

问两个问题:

  • 问题一:main函数调用sum,sum执行完之后,怎么知道回到哪个函数中
  • 问题二:sum函数执行完,回到main以后,怎么知道从哪一行指令继续运行

接下来我们会以此为例,讲解函数调用栈的使用过程

代码运行过程

函数运行时,要在栈帧上开辟空间。描述一个栈结构有栈顶和栈底就可以了,所以在这里我们给这个main函数的栈帧表示出来。

(1)ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶。
(2)EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部。

在这里,esp存储的是main函数栈帧栈顶的地址,所以说esp是可变的,随着栈的生产而逐渐变小。
ebp存放的是min函数栈帧栈底的地址,假如说栈底地址是0x0018ff40,ebp。
栈底是高地址,栈顶是低地址。

int a = 10;

汇编指令:mov dword ptr[a], 0Ah(真正的汇编指令是mov dword ptr[ebp-4], 0Ah,这里为了方便理解直接用了a)

我们来看,这个main函数的第一行代码是int a = 10;,执行的时候大家都知道a不产生符号,如果是汇编指令的话就是mov dword ptr[ebp-4], 0Ah,a是我们函数的第一个局部变量,所以他就出现在栈底,那为什么要用ebp减4呢,因为ebp是高地址,往上是低地址。
操作系统访问局部变量就是用栈底指针的偏移来访问

int b = 20

汇编指令:mov dword ptr[b], 14h (ptr[ebp-8])
图上画出来如图:

int ret = sum(a, b)

关于本条指令,ret是借助sum的返回值才完成初始化,所以我们先放到这里。

现在我们要开始调用函数了:一个函数的调用要先从右向左压参数,压栈往哪里压呢?往栈顶压!

  • 先压b,这块内存就是sum函数形参变量b的内存。所以形参内存开辟是由调用方函数来完成的。
  • 由于压栈操作push指令,所以esp也指向了栈顶。
  • 以上两个操作的汇编指令有两个,并且a也同理,所以一共四个汇编指令。完成a,b的压栈和相关指令如下:
mov eax, dword ptr[ebp-8]
push eax
mov eax, dword ptr[ebp-4]
push eax
  • 两个变量全部压完栈后,接下来就是函数调用指令call sum
    这个call指令会做两件事情,我们先展示call后面的汇编:
add esp, 8
move dword ptr[ebp-0Ch], eax

假设第一行指令的地址为08124458,call会把这个指令的地址入栈,因为我们后续等sum函数运行完,必须知道再继续运行哪一块代码。在这里我们就回答了上述的第二个问题
此时我们的内存情况如图:

  • 接下来我们要进入sum函数了
    其实我们需要首先执行我们的左括号,它对应三条指令,
push ebp
mov ebp, esp
sub esp, 4Ch

我们的push ebp会把ebp的地址压栈,还记得ebp是啥吗?没错,就是我们用来表示main函数的“栈帧”基地值,至此,main函数“栈帧”保护工作完成!这里也就回答了我们提出的第一个问题

紧接着mov ebp, esp,更新“栈帧”基准线,让他与栈顶平齐!现在他俩的地址相等了

再然后sub esp, 4Ch,也就是说我们的esp要往上走4Ch的空间,也就是给我们的sum函数开辟栈帧空间,主要是为了给我们的临时变量分配“栈”内存。

  • 接下来轮到我们sum函数中间的代码了,首先是int temp = 0;汇编指令如下:
mov dword ptr[ebp-4], 0

(这里的栈帧初始化只有windows的编译器才会做)

  • 接下来是temp = a + b,我们应该怎么取a和b呢?
    还记得之前我们的形参变量存到哪了吗?
    10=>int a 20=>int b这个位置。这里需要我们借助ebp来进行间接寻址。
mov eax, dword ptr[ebp+0Ch]
add ecx, dword ptr[ebp+8]		//这里计算a+b
mov dword ptr[ebp-4], eax		//把a+b的结果放到局部变量temp
  • 然后是return temp,注意temp是函数的局部变量,它是出不去的,temp是4个字节,返回他的时候不产生临时变量,而是直接通过eax寄存器带出去,所以汇编如下:
mov eax, dword ptr[ebp-4]
  • 最后到右括号了,我们先看汇编
mov esp, ebp
pop ebp
ret 0

第一行指令把ebp的值赋给esp,所以esp直接从上面跑到了sum函数栈帧的栈底,这里就是我们的回退栈空间

现在再看这段代码还安全吗?

int* func() {
	int data = 10;
	return &data;
}

我们的esp回退后,栈空间已经交还给系统了,这个地址返回之后还能用吗?肯定是不能,我们已经失去了对它的控制,成为了野指针。

第二行指令pop ebp,出栈,并把出栈元素的值赋给ebp,现在我们的栈顶放的是0x0018ff40,把它给ebp!我们的ebp又回到main函数栈帧的栈底了!

并且随着出栈,esp也往下走了,所以指向`0x08124458`

第三行指令ret ,也就是出栈操作,把出栈的内容放入CPU的PC寄存器(该寄存器存放下一行要执行的指令)中,我们现在出栈的是0x08124458,这个地址是什么还记得吗,就是我们的main函数中,call sum指令后面的add esp, 8这个指令的地址!

现在正式回到main函数调用完sum之后的指令位置了!

回到main函数

call sum
add esp, 8      //0x08124458
mov dword ptr[ebp-0Ch], eax

最后这两个指令就是完成ret的赋值操作。结束!

请回答本节开头的两个问题

看文本节后,能回答出这两个问题吗?

int sum (int a, int b) {
    int tmp = 0;
    tmp = a + b;
    return tmp;
}
int main() {
    int a = 10;
    int b = 20;
    
    int ret = sum(a, b);
    cout << "ret: " << ret << endl;    
    return 0;
}

回答两个问题:

  • 问题一:main函数调用sum,sum执行完之后,怎么知道回到哪个函数中
  • 问题二:sum函数执行完,回到main以后,怎么知道从哪一行指令继续运行

相关推荐

最近更新

  1. docker php8.1+nginx base 镜像 dockerfile 配置

    2024-05-10 14:52:06       98 阅读
  2. Could not load dynamic library ‘cudart64_100.dll‘

    2024-05-10 14:52:06       106 阅读
  3. 在Django里面运行非项目文件

    2024-05-10 14:52:06       87 阅读
  4. Python语言-面向对象

    2024-05-10 14:52:06       96 阅读

热门阅读

  1. Windows下通过MySQL Installer安装MySQL服务

    2024-05-10 14:52:06       36 阅读
  2. Redisson

    2024-05-10 14:52:06       30 阅读
  3. Redis7降级到Redis6如何AOF备份恢复(错的)

    2024-05-10 14:52:06       31 阅读
  4. Vue 问题集

    2024-05-10 14:52:06       36 阅读
  5. 2024年了还只会CURD

    2024-05-10 14:52:06       30 阅读