1. 引入
在C/C++中,多次画过这幅程序地址空间布局图 ——
" title="">
那么这是内存吗?事实上它根本就不是内存!!!是不是颠覆了世界观?!那它是什么呢?
我们先来看一段程序。定义了一个全局变量,在3s时,父或子进程更改数据——
" title="">
我们惊奇的发现,同一个地址,居然打出了不同的变量 ——
" title="">
众所周知,在fork创建子进程时,父子默认情况共享数据,修改时,为了维护进程独立性,发生写时拷贝,这是能够理解的。但地址怎么能没有变化呢?
如果C/C++中打印出来的是物理内存的地址,这种现象绝对不可能存在!这说明,我们在语言中所使用的地址,绝对不是物理地址,而是虚拟地址。
2. 进程地址空间
所以之前所说的“程序地址空间”是不准确的,准确的说是“进程地址空间”,那么什么是进程地址空间呢?
2.1 what?
每个进程都有一个地址空间,操作系统为每一个进程画了一个大饼,它们都认为自己在独占物理内存。 (至于为什么画大饼,暂时理解为便于统一规划,后文详谈)
系统中存在大量进程,需要管理地址空间,那么就需要先描述、再组织。
地址空间本质上在内核中是一个数据类型 ,可以定义具体的进程地址空间变量——
struct mm_struct
{
//进程地址空间
}
" title="">
那么我们是如何用struct结构体进行区域划分的?各个区域又是如何与物理内存建立关联的?
2.2 how?
⛄️ 虚拟地址空间 & 分页
我们将实体物理内存抽象出一把尺子,上面的刻度相当于虚拟地址(地址空间进行区域划分时,对应的线性位置虚拟地址)
" title="">
每个进程都认为自己拥有4GB,都认为空间的划分是按照4GB来划分的。虽然这里只有start和end,但这是一个区间概念,每个进程都认为mm_struct
代表的是从0x00000000到0xffffffff整个内存。
:heart: 那么如何将虚拟地址和物理地址建立映射关系呢?通过查页表(页表+MMU硬件设备)
" title="">
2.3 why?
:heart: 1. 通过添加一层软件层,完成有效的对进程操作内存的风险管理(权限管理),本质是为了保护物理内存各个进程的数据安全
类似于过年的压岁钱妈妈帮你收着,等你要用的时候,再来问我要,防止你乱花钱。对应到这里,中间层是有利于操作系统管理的,不是不给你,而是管控你的做法是否合适;如果没有中间层(OS),能直接访问物理地址,可能发生非法越界访问。
在语言层面上,我们知道,字符串存在字符常量区不能修改 ——
char* str = "more than words";
*str = 'b'; //不能修改
本质上是因为,这里str指针就是虚拟地址,*解引用进行写入时,访问虚拟地址,要进行虚拟地址和物理地址的转化,然而OS只给你读r权限,直接把你的进程崩溃掉。
" title="">
:heart: 2. 将内存申请和内存使用在时间上解耦。通过虚拟地址空间,来屏蔽底层申请内存的过程,达到进程读写内存操作和OS进行内存管理进行软件层面上的分离。
比如我们在堆上申请一大块空间,但是我们可能暂时不会全部使用甚至暂时不用(有了空间,从来没有读写),在OS角度,这部分空间本来是可以给别人立马用的,却被闲置着。于是,OS在当你真的要使用时,再把空间开辟出来,建立映射关系,这叫做基于缺页中断进行物理内存申请。
再比如假如物理内存已经100%占满了,而你还要,那么OS执行内存管理算法,把某些进程闲置的空间置换到磁盘上,这样进程照样可以申请到内存。而这些都是我们用户在应用层根本感受不到,换句话说OS做的内存操作是透明的。
:heart: 3. 站在CPU和应用层的角度,进程统一使用4GB的空间,且每个空间区域的相对位置是比较确定的。
比如CPU寻找不同进程代码的第一行,如果直接访问物理内存,CPU会比较凌乱。有了虚拟地址空间,CPU能以统一的视角看待物理内存,不同的进程再通过的各自的页表,映射到不同的物理内存。同时,程序的代码和数据可以加载到内存的任意位置,大大减少内存管理的负担。
进一步谈进程和程序有什么区别?进程要包括描述进程的PCB、进程虚拟地址空间、页表、代码和数据。
3. 再次理解
3.1 又回到最初的起点
:yellow_heart: 我们再次回到文章开头的问题,为什么相同的地址会打印出不同的值?
众所周知,子进程的创建是以父进程为模板的 ——
为了维护进程的独立性,子进程在更改时发生写时拷贝,即为子进程重新开辟一段物理空间,把值拷贝过来,再重新建立虚拟地址到物理地址的映射关系 ——
所以打印的是一样的虚拟地址,而不同的值,是因为在物理内存上本来就是不同的变量。
之前说的,父子进程的代码一般是共享的,也就是通过映射到同一段物理空间实现的。
之前说的,所有的只读数据一般可以只有一份,本质不是在语言上,而是在系统上,这样操作系统的维护成本是最低的,不同的虚拟地址映射到相同的物理地址上 ——
// 打印str和p的地址相同
char* str = "more than words";
char* p = "more than words";
3.2 验证进程地址空间
#include<stdio.h>
#include<stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc,char* argv[],char* env[])
{
const char* str = "more than words";
printf("code addr:%p\n",main);
printf("string rdonly addr:%p\n",str);
printf("init addr:%p\n",&g_val);
printf("uninit addr:%p\n",&g_unval);
char* heap1 = (char*)malloc(10);
char* heap2 = (char*)malloc(10);
char* heap3 = (char*)malloc(10);
char* heap4 = (char*)malloc(10);
printf("heap addr:%p\n",heap1);
printf("heap addr:%p\n",heap2);
printf("heap addr:%p\n",heap3);
printf("heap addr:%p\n",heap4);
int a = 10;
int b = 20;
printf("stack addr:%p\n",&a);
printf("stack addr:%p\n",&b);
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;
}
可以看到,地址空间变化完全吻合。其中堆向上生长,栈向下生长,堆栈相对而生,且空间很大 ——
" title="">
相同的程序,每次运行,main函数的地址都一样 ——
" title="">
系统传的参数,在栈的上面,再完善一下 ——
" title="">
持续更新@边通书