【Linux】程序地址空间
【linxu】程序地址空间
- 程序地址空间回顾
- 进程地址空间
- 引出地址空间
- 地址空间的区域划分
- 页表
- 为什么要有地址空间和页表
- malloc和new申请内存的原理
- 页表的更多信息
- 操作系统判断写实拷贝
程序地址空间回顾
我们在学习C语言的时候曾经画过内存的空间分布图
我们可以写一些代码来进行测试
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
printf("code addr: %p\n", main);
printf("init data addr: %p\n", &g_val);
printf("uninit data addr: %p\n", &g_unval);
char *heap = (char*)malloc(20);
char *heap1 = (char*)malloc(20);
char *heap2 = (char*)malloc(20);
char *heap3 = (char*)malloc(20);
static int c;
printf("heap addr: %p\n", heap);
printf("heap1 addr: %p\n", heap1);
printf("heap2 addr: %p\n", heap2);
printf("heap3 addr: %p\n", heap3);
printf("stack addr: %p\n", &heap);
printf("stack addr: %p\n", &heap1);
printf("stack addr: %p\n", &heap2);
printf("stack addr: %p\n", &heap3);
printf("c addr: %p, c: %d\n", &c, c);
for(int i = 0; argv[i]; 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;
}
这里需要注意的是,如果这个实验是在vs下进行的话,可能得不出这样的结果,因为vs可能并不遵循这个规则。
上面的内存分布图是内存吗?
我们先来看一个奇怪的现象。
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork");
return 0;
}
else if(id == 0)
{
//child,子进程肯定先跑完,也就是子进程先修改,完成之后,父进程再读
int cnt = 0;
while (1)
{
sleep(1);
printf("child[%d]: %d : %p\n", getpid(), g_val, &g_val);
if (cnt++ == 3)
{
g_val = 200;
printf("child change g_val:100->200\n");
}
}
}
else
{
//parent
while (1)
{
sleep(1);
printf("parent[%d]: %d : %p\n", getpid(), g_val, &g_val);
}
}
return 0;
}
从上面我们知道父进程和子进程是共享数据的,而一旦子进程对数据或代码进行修改的话会发生写实拷贝,所以当子进程修改数据后父进程和子进程的g_val是不一样的着我们可以理解。但是最让我们不理解的是,为什么他们的地址会是一样的?同一块地址怎么可能拿到不同的数据?所以这里我们一定可以断定,这里的地址一定不是物理地址。这个地址这我们直接说明:这个地址叫做虚拟地址/线性地址。
所以我们语言上所用到的所有地址都不是物理地址,而是虚拟地址。所以上面我们那张空间地址分布不是物理内存,这里我们叫做进程地址空间
进程地址空间
引出地址空间
之前我们说过当创建一个进程,每个进程都会有一个进程PCB(task_struct),同时每个进程都会有一个进程地址空间。而每个变量的地址其实都是在地址空间里面的,而地址空间并不是真正的物理内存,但是变量的创建是在物理内存中的,那么必定有一种映射关系,将地址空间的地址映射到这正的物理内存中去。
所以当创建子进程的时候,子进程会继承父进程的代码和数据,其中就包括这张映射表,而这行映射表中的数据也是被继承过来的,而我们打印出来的地址就是这张映射表中的地址,也就是从父进程中继承过来的。所以打印出来的地址就会一样,但是他们所映射到的物理地址却是不同的。
所以这里同样也就是了我们之前创建进程的时候pid_t id = fork()时,一个id即等于0,又大于0。因为父进程和子进程执行不同的代码块时,id所映射到的是不同的物理内存变量,所以大家的虚拟地址是一样的,但父和子的映射关系不同,映射到的地方不同,所以读到的内容也是不一样的。
地址空间的区域划分
- 每一个进程,都会有一个进程地址空间,在32位下地址空间的大小死[0, 4GB];
- 地中空间多了也需要管理:先描述,再组织,所以对进程地址空间的管理就变成了对数据结构的管理。
如果我们要对一块空间进行划分,而空间也时需要进行管理的,所以这块空间就是一个结构体。
struct area
{
//各种属性
//……
}
而空间是需要被使用的,也就说明了空间区域划分的作用就是不让使用空间的人越界。所以我们就需要定义出一个用户使用空间的范围。
struct destop_area
{
struct area xxx_start;
struct area xxx_end;
}
所以对于地址空间,一开始的对空间的划分就是使用类似于上面的划分分方式。所以对于进程来讲也是如此的,进程PCB中会有指向地址空间的地址,而地址空间中存在着区域划分。
struct mm_struct
{
struct vm_area_struct* mmap;
struct rb_root mm_rb;
struct vm_area_struct* mmap_cache;
//....
unsingned long start_code, end_code, start_data, end_data;
//代码段的开始start_code ,结束end_code,数据段的开始start_data,结束end_data
unsigned long start_brk, brk, start_stack;
//start_brk和brk记录有关堆的信息,
//start_brk是用户虚拟地址空间初始化,
//brk是当前堆的结束地址,
//start_stack是栈的起始地址
unsigned long arg_start, arg_end, env_start, env_end;
//参数段的开始arg_start,结束arg_end,
//环境段的开始env_start,结束env_end
}
页表
首先我们的地址空间是不具备对代码和数据的保存能力的,数据和代码都是保存在物理内存中的。所以操作系统必然要有一种机制将地址空间(虚拟/线性)转化到物理内存当中。
所以操作系统提供了一张映射表来做到这个工作,这个映射表叫做——页表
那么这个转化工作是由谁来做的呢?
- 粗浅的说是CPU,在转化的过程中,CPU中的CR3寄存器会记录页表的地址(注意:CR3中存储的地址一定是真实的物理地址,如果是虚拟地址,那CPU还不知道页表在哪,那怎么通过映射关系找到CR3中虚拟地址映射到实际的物理地址呢),当CPU开始执行正文代码时,假设遇到了a++这样的指令,那么CPU就会根据CR3寄存器中页表的地址进行查表,从而就得到了物理内存地址,也就找到了a的值。
- 准确的说,这个转化工作是由CPU中的硬件单元MMU(内存管理单元)完成的
为什么要有地址空间和页表
- 因为有了页表映射关系,所以进程管理和物理内存管理进行解耦合。站在内存的角度,程序加载到内存的什么地方是随机的,但是因为有了页表,所以进程不需要关心程序被加载到了内存中的那一部分。
- 而对于进程地址空间来讲,现在进程地址空间只关心页表,而物理内存中的程序的代码和数据可能是不连续的,但是只要映射到页表中的话,就相当于有序了,所以页表可以保证将无序变有序,就可以让进程以同一的视角看内存。
- 地址空间+页表的设计是保护内存安全的重要手段!
malloc和new申请内存的原理
在我们没有了解到进程地址空间之前,我们默认是我们使用malloc和new申请空间时,OS直接会给我们开辟内存供我们使用,但是今天我们要修正一下。
我们在是使用malloc/new的时候,肯能我们申请的空间不会立即被使用,而是过一段时间才会被使用,那么这个时候OS是不会在物理内存中为我们开辟空间的,因为OS要确保效率和资源利用率。但我们使用malloc/new申请空间的时候,首先是在地址空间中返回给用户虚拟地址,但是这个时候页表并没有创建映射关系,只有当用户尝试用这个虚拟地址进行写入的时候,会发生缺页中断,OS才会在物理内存开辟空间,并在页表中建立映射关系。
这样做的好处是:
- 可以充分保证内存的使用率
- new/malloc的速度会更快(在用户层面只需要返回虚拟地址即可,剩下的部分都由操作系统完成)
页表的更多信息
页表其实不光存放虚拟地址和物理内存地址,还有其他的属性,比如会存放权限属性
我们平时写代码时常量不可修改究竟是谁决定的?
- 其实就是操作系统在页表中该数据的权限属性上放置的是’r’,当你要对该数据进行修改时(写入)时,首先需要进行虚拟地址与物理地址的转化,转化的过程中操作系统发现权限为只读,所以才不可修改不可写入。
那const修饰的数据是不是也是由页表决定的呢?
不是!
const与系统没有任何关系,const是编译器检查前后语法的问题。const的意义是将可能在未来运行时出现的错误提前在编译阶段发现并报错。所以我们说const能加则加,是一种好的编程习惯,防御性编程。
操作系统判断写实拷贝
在父进程创建子进程时,按之前所学子进程会继承父进程的进程地址空间和页表。
并且操作系统还会将父子进程的页表中数据对应的权限属性修改为只读!
当父或子进程修改(写入)该数据时,会发生缺页中断,但其实缺页中断做的工作不仅会在物理内存上开辟空间建立映射
关系,还会对我们的访问操作做判断:
- 操作系统会判断,页表权限为只读,但数据所在的进程地址空间属于可读可写的数据区,操作系统明白了,
这是要写时拷贝啊!所以这就是操作系统判断什么时候进行写时拷贝的原理,根据这个方法,操作系统就能实现按需拷
贝!谁要使用(写入)给谁开辟新的物理空间,否则就不拷贝,共用物理内存空间。