在C/C++程序员眼中,对内存有着明确的分区,例如堆区、栈区等等,
那么这些谈论的东西,跟我们在系统中的内存是一个东西吗?
今天我们来探讨这一知识。
文章目录
- 1.对内存分区的认识
- 1). 栈区的使用特点
- 2). 内存区域的划分
- 3). 环境变量和命令行参数的存储
- 2. 进程地址空间
- 1). 解决遗留问题
- 2). 什么是地址空间?
- a. 什么是地址空间?
- b. 地址空间结构体的进一步理解
- 3). 页表
- a. 访问权限
- b. 地址对应内容有无
- a. 进程切换与页表
- 3. 为什么要有地址空间
- 4. 再次理解写时拷贝
1.对内存分区的认识
在C/C++程序员眼中,我们的内存应该是这个样子:
1). 栈区的使用特点
在关于堆区和栈区的使用应该是,堆区向上增长,栈区向下增长。
栈区向下增长:
#include <stdio.h>
int main()
{
int a = 0;
int b = 0;
int c = 0;
int d = 0;
int e = 0;
printf("%p\n", &a);
printf("%p\n", &b);
printf("%p\n", &c);
printf("%p\n", &d);
printf("%p\n", &e);
return 0;
}
我们看到先创建的变量的地址是要比后创建的变量的地址是大的,也说明了栈空间的使用是向下的,但是我们在面对一个数组的时候,数组的第一个元素和最后一个元素谁的地址大呢?在日常经验的使用中,明显最后一个元素的地址是大于首元素地址的,这又与栈区向下增长的说法不符了,其实栈区的使用特点是:向下增长,向上使用,当在创建一个变量的时候,进程会在栈区中开辟所需要的空间,然后再向上使用。例如一个结构体:
#include <stdio.h>
struct stu{
int a;
int b;
int c;
};
int main()
{
struct stu s;
printf("%p\n", &s.a);
printf("%p\n", &s.b);
printf("%p\n", &s.c);
}
可以看到结构体中的成员也遵循栈区的向下增长,向上使用。
而C语言访问变量的方式的本质也就是变量起始地址+偏移量的访问方法
所以堆区和栈区是堆栈相对而生。
2). 内存区域的划分
我们上面说了内存是那样划分的,所以现在我们用代码证明一下,上面划分的区域对不对:
#include <stdio.h>
#include <stdlib.h>
int b = 0;
int main()
{
int* a = (int*)malloc(sizeof(int) * 10);
const char* str = "hello world";
printf("栈区:%p\n", &a);
printf("堆区:%p\n", a);
printf("全局变量区:%p\n", &b);
printf("字符常量区:%p\n", str);
printf("代码区:%p\n", main);
return 0;
}
可以看到,确实如我们所说,大概是这么一个分配的情况。
3). 环境变量和命令行参数的存储
环境变量和命令行参数,存储在栈区之上:
2. 进程地址空间
1). 解决遗留问题
接下来我们会用我在一篇博客中遗留的问题:Linux系统调用函数fork展开讨论,用它引入并讲述,我们所理解的内存分区和内存是一个东西吗?以及什么是进程地址空间。
我们在使用fork的过程中,我们发现创建子进程之后,一个函数返回了两个值,我们知道这是fork的效果,在return的时候子进程就已经被创建好,开始运行了。但是返回之后,我们发现一个变量它居然有两个值,并且我们在父子进程中分别打印这个变量地址,发现它们的地址竟然一样,这个我们无法理解。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main()
{
printf("我是一个进程,我的id是:%d,我的父进程的id是:%d\n", getpid(), getppid());
pid_t id = fork();
if(id < 0)
{
return 1;
}
else if(id > 0)
{
printf("我是一个父进程,我的id是:%d,我的父进程的id是:%d,%d:%p\n", getpid(), getppid(), id, &id);
}
else
{
printf("我是一个子进程,我的id是:%d,我的父进程的id是:%d,%d:%p\n", getpid(), getppid(), id, &id);
}
return 0;
}
同一个地址,会存储两个变量吗?这显然是不可能的,硬件上就不支持这么做,所以我们得出一个结论:我们谈论的堆区、栈区它不是电脑当中的内存,那它是什么呢?我们在这里给出答案,它叫进程地址空间也叫线性空间,里面的地址都是虚拟地址。
那进程地址空间和内存有什么关系呢?这里一张图简略说明一下上面疑问的答案:
首先,父进程开始运行之后,调用函数fork形成子进程,然后在return的时候子进程就已经存在了,至于父子进程谁先运行,这是调度的问题受多方面影响。每个进程都有自己的进程地址空间,而进程地址空间由进程pcb中的一个指针指向。
我们假设是父进程先运行,当程序执行到创建变量id并给id初始化的时候,id的虚拟地址就被创建好,并且内存中的地址对应的内存也已经开辟好,然后虚拟内存的地址和物理内存的地址就像上图一样被放在一个表里面,这个表叫做页表,虚拟地址至此和物理地址形成一种映射关系,然后fork会给父进程返回子进程的id,那么此时物理内存中存储的应当是子进程id。
然后就是子进程执行到创建变量id的这一步,子进程在被创建好的时候,它也形成了自己的pcb,以及进程地址空间,子进程会继承父进程的一些东西,这其中就有父进程的页表。所以子进程此时也有着和父进程相同的id的虚拟地址以及存储id变量物理内存的地址,但是现在fork又返回了一个值,子进程要将写如变量id中,但是同时有两个进程指向者着id的物理内存,说明不能执行修改,那就会发生写时拷贝,操作系统会再开辟一块内存把子进程fork的返回值放在这个内存中,并且把这块物理内存的地址放在紫禁城的页表中覆盖原来页表中的地址。
在这个过程中,我们发现两个进程的页表中id变量的虚拟地址并没有发生改变,改变的是物理内存的地址,所以当我们打印变量id的地址的时候,打印的地址为什么会一样了。
2). 什么是地址空间?
a. 什么是地址空间?
讲过上面的的讲述,肯定是不能讲清楚地址空间是什么的,所以我现在谈论进程地址空间是什么。
我们的电脑假设有16G的内存,而我们使用电脑的时候,肯定是有许多进程,我们也说过每个进程都有自己的进程地址空间,进程需要被管理来,那么每个进程的进程地址空间也需要被管理器来,如何管理?先描述在组织
描述:我们看到进程地址空间它的主要特点就是里面划分着许多区域,那这些区域是怎么被划分的呢?就像我们小时候两个人做同桌的时候发生矛盾就会在桌子上画线以表明自己的区域一样,进程地址空间区域的划分也是如此,它也会用若干变量来划分区域,比如stack_strat,stack_end,heap_start,heap_end这样的变量来将进程地址空间划分开来,那么管理进程地址空间,就只需要管理由这几个变量组成的结构体就可以来管理进程地址空间了,而在Linux中这个结构体叫mm_struct。
而组织这些结构体就是用进程pcb中的一个指针来指向它。
所以进程地址空间也只是一个结构体而已,在32位系统下,结构体变量的范围是从00000000到ffffffff。进程中的使用内存的行为最终都会靠页表映射到物理内存中。
,当我们在进程中创建一个变量的时候都会先有一个虚拟地址映射到物理地址这个映射关系由页表来存储,物理地址存储着变量的内容。
b. 地址空间结构体的进一步理解
我们在进行开发的过程中,肯定会遇到需要内存中一块特殊的区域来做一些特殊的任务,这就需要更加细致的对地址空间的划分,而这个划分在mm_struct中也有:
这个变量可以对地址空间有着更细致的划分
3). 页表
每个进程又会有自己的页表,页表中存储着虚拟地址与物理地址的映射关系,除此之外,每个页表的映射关系之后还有两个存储着其他的东西。
a. 访问权限
我们知道我们代码和字符常量区的内容是不可被修改的,原因就是因为页表中的访问权限决定的。
它可能会用10的方式来判断你能否对这个内存中的内容进行修改。
b. 地址对应内容有无
现在的有的游戏它的内存占用很大,有的甚至比我们的电脑内存还要大,那电脑怎么能够运行起来这个程序呢?它其实是将先将程序的一部分运行起来,然后等到需要下一部分内容的时候在释放这一部分,加载下一部分内容到内存,以实现运行整个游戏,那操作系统怎么知道,我需要的内容不在内存中呢?这就是利用了页表拥有的第四个数据,判断当前物理内存是否被分配和有无内容:
它大概使用01的这种办法来表示当前物理内存有无数据,如果没有操作系统就需要把下一步部分从磁盘加载到内存中,而加载的这个行为叫做缺页中断。很明显这已经是内存管理了。
a. 进程切换与页表
我们暂且理解为进程pcb中也会有一个指针来指向自己的页表,然后当自己的进程被调度的时候,上一个进程被切走,我们把硬件上下文在放到寄存器中,而这个过程中有一个寄存器CR3这个寄存器中就会存储页表的地址,这个地址自然也是物理地址。
3. 为什么要有地址空间
1.让进程以统一的视角看待内存,任意一个进程可以通过地址空间+页表的方式
访问内存将乱序的内存数据变成有序
2.使用进程地址空间可以有效防止进程访问内存的安全检查
通过页表的内存访问权限可以达到目的
3.通过页表让进程映射到物理内存中,可以保证进程的独立性以及进程管理和内
存管理之间互不相干
4. 再次理解写时拷贝
我们知道使用fork之后,父子进程共享代码数据,原本进程运行在栈区堆区等的数据是可以修改的,页表的权限是可读可写的,而在使用fork创建子进程的时候,父进程的页表中的数据权限都被改成了只读,子进程会拷贝父进程的页表,当然子进程的页表也会是只读的,很明显我们是不知道此时,父子进程的页表都变成了只读的,所以当我们要对数据进行修改时,会发生报错,而这个错误会由操作系统来判别这到底是怎么回事,而此时操作系统会有两种判断一个是真的出错了,一个就是不是出错,而是触发了我们进行重新申请内存拷贝内容的机制,而这才是操作系统内存的写时拷贝。之所以为什么申请新空间后是先拷贝原数据再修改,而不是直接申请新空间之后直接填写新的数据,是因为,有的数据可能具有整体性,它不是单一的。