背景
kernel 2.6.32
32位平台
空间布局图
如何理解地址划分
地址划分,本质是调整地址空间的定义start和end,内存中定义了管理每个区域范围的结构体,叫mm_struct,每个进程都有一个这个结构体指针变量
验证上面划分的结构,写一段各个属性变量的代码来验证地址
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
// int a = 10;
//字面常量
const char *str = "helloworld";
// 10;
// 'a';
//main函数地址
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
static int test = 10;
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test stack addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
printf("read only string addr: %p\n", str);
for(int i = 0 ;i < argc; i++)
{
printf("argv[%d]: %p\n", i, argv[i]);
}
for(int i = 0; env[i]; i++)
{
printf("env[%d]: %p\n", i, env[i]);
}
return 0;
}
首先是代码地址,在最小的位置。最往上是字符常量区,然后是数据区,static也在数据区,本质是将局部的变量开辟在全局区域。堆区后面是栈区,中间空了很大一部分的共享区。两个箭头表示,堆的变量是往上增长,地址越来越大,栈则是越来越小,栈上面有命令行参数,在之后是环境变量的地址,最后是内核空间1G
在32位系统下,一个进程的地址空间,取值范围是0x00000000 - 0xffffffff
用户空间【0,3GB】 ,内核空间【3,4GB】
地址空间是操作系统专门为进程设计的内核数据结构,包含了各个区域的划分,起始和结束,还有更多属性
所以创建一个进程,操作系统先创建PCB结构,再创建地址空间,用地址空间指针指向进程的地址空间对象,就可以找到地址空间和对应的页表
每一个进程都有一份地址空间
虚拟内存
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
int main()
{
pid_t id = fork();
if(id < 0){
perror("fork");
return 0;
}
else if(id == 0){ //child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读取
g_val=100;
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
}else{ //parent
sleep(3);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
sleep(1);
return 0;
}
//与环境相关,观察现象即可
child[3046]: 100 : 0x80497e8
pa
上面的代码运行后,子进程修改了全局变量的值,结果同一个变量产生了两个不同的值,且他们的地址是一样的,这是为什么
- 变量内容不一样,但地址一样,所以这个地址肯定不是真正的物理地址
需要说明一下地址空间的设计
由于直接访问物理内存是不安全的,如果进程都直接访问物理内存,那么很容易一个进程用指针修改另一个进程的值,不遵守进程的独立性。所以,引入了虚拟内存的概念,进程直接访问虚拟内存,虚拟内存就是上面的0x0-0xffffffff的内存布局,每个进程都划分了这么大的空间,实际上每个进程这些空间只是物理内存的一部分
那么一个虚拟内存地址如何转化为对应的物理内存地址,物理内存和虚拟内存中间有一个页表的结构,里面记录了地址的对应关系。地址空间和页表(用户级)每个进程都有一份,只要保证,每一个进程的页表,映射的物理内存的不同区域,这样进程之间就不会互相干扰,保证了独立性
分页和虚拟地址空间
这时就可以解释上面为什么同一个变量会有两个不同的值,fork产生子进程后,子进程的变量分页结构等很多都是基于父进程的拷贝,共享一段内存。当父子进程对某个值写入时,会为子进程单独拷贝这个变量的一个区域,映射和父进程不同的物理内存位置。所以虽然都是同一个变量的虚拟内存地址,但在物理内存处是两块不同的区域,这个过程就叫写时拷贝
fork为什么会有两个返回值
前面说过是因为return执行了两次,return的本质就是对id变量进行写入,发生了写时拷贝,父子进程在物理内存中都有属于自己的地址空间,只不过用同一个虚拟地址来标识了
虚拟内存的产生
在没有虚拟内存的时候,进程是直接加载到物理内存上,通过进程的起始和边界控制是不是自己的部分。这样可以通过指针访问到另一个进程的数据,如果一个进程有问题,越界也可能会导致另一个程序崩溃
所以引入了虚拟内存的概念,虚拟内存通过页表映射到物理内存中,虚拟内存作为中间者,如果转换后是非法的,要么转换后是非法的,要么越界,要么没有权限,就会禁止访问,解决了安全性的问题
当我们程序还没有运行,编译形成可执行程序的时候,就已经有了地址。地址空间不仅仅是OS内部遵守的,编译器也要遵守。即编译器编代码的时候就已经形成了各个代码区,数据区,并且,采用和linux内核中一样的编址方式,给每一个变量代码都编址,所以,程序编译的时候,每一个字段早已经有了一个虚拟地址,每一个变量,每一个函数,编译器给的都有地址
,同样也一定被加载到了物理内存
有两套地址,一套是程序内部使用的,一套是加载到内存分页的物理内存地址。用代码和函数的地址作为页表的左值,得到对应的物理地址右值
cpu读到的是物理地址还是虚拟地址?
cpu读到的也是虚拟地址,通过页表转换为物理地址操作
为什么要有地址空间
1.凡是非法的访问或者映射,操作系统都会识别到,并终止这个进程。这样,有效的保护了物理内存,地址空间和页表是OS创建的,凡是使用地址空间和页表进行映射,都在OS的监管之下,保护了所有的合法数据和进程有效数据
所有的进程崩溃,就是进程退出,系统杀掉了这个进程
2.因为有地址空间的存在,页表映射的存在,代码和数据就可以加载到任意位置。内存管理和进程管理完成了解耦合
new和malloc空间的时候,本质是在虚拟内存申请的,使用了延迟分配的策略,提高效率。
如果申请了物理空间如果不立马使用,造成了空间浪费。所以,申请一段空间的时候,物理内存可以一个字节都不给,只有在真正对这个空间访问的时候,才执行相关内存的管理算法,申请内存,构建映射关系,内存的访问由操作系统自动完成,用户和进程0感知。操作系统如何知道虚拟内存分配的空间需要被访问而未被加载物理内存,用了缺页中断技术。这样就不会存在申请了但不用的空间
3.物理内存理论上可以任意位置加载,会造成数据和代码乱序。但是,因为有页表的存在,可以将内存分布有序化。进程的代码和数据如果已经运行完了可以去除,映射关系也删掉,但不影响后面的代码运行。不同的进程映射到不同的物理内存,很容易做到,独立性的实现。
因为有地址空间的存在,每一个进程都认为自己拥有4GB空间(32),并且各个区域有序的,进而可以通过页表映射到不同的区域,来实现进程的独立性。每个进程不需要知道其他进程的存在
重新理解挂起
加载程序是创建进程,并不需要立马把所有程序的代码和数据加载到内存中,创建映射关系。极端情况下,只有内核数据结构被加载到内存中,当真正执行这段代码和数据的时候,才创建物理内存,这样就可以实现分批加载和分批换出。比如100G的游戏怎么运行,只加载需要运行的进程,分批换出。
挂起的时候,页表不仅仅只能映射物理内存,还可以映射磁盘,只需要填入映射到磁盘中代码数据的位置,将物理内存中的位置释放。就可以把程序挂起,不用交换到磁盘上