操作系统作为计算机软硬件资源管理者,当然也要管理各个进程的内存分配,因此要有描述各个进程的内存分配情况的数据结构,这个内核数据结构就是进程地址空间。在Linux操作系统中,该数据结构的变量名为mm_struct
。
为了更好的管理内存分配,mm_struct的实现采用了如下策略:
mm_struct的伪代码如下:
struct mm_struct
{
long code_begin; //代码区起始地址
long code_end; //代码区结束地址
//...
long brk_begin; //堆区起始地址
long brk_end; //堆区结束地址
long brk_begin; //栈区起始地址
long brk_end; //栈区结束地址
}
说明: 由于进程地址空间是通过连续的线性虚拟地址描述的内存地址,因此通过起始地址和结束地址就可以将内存中各个区域区分开来。如下图:
虽然进程地址空间描述的是虚拟地址,但是进程要找到自己的数据和代码还是要落实到内存中的实际地址中查找,因此操作系统采用了映射的方式,将虚拟地址转换成实际的内存地址,在映射时使用了叫做页表的工具,页表中记录了虚拟地址和内存实际地址的映射,如下:
首先编写如下代码来观察写时拷贝现象:
#include
#include
#include
int main()
{
int val = 100;
pid_t id = fork();
assert(id >= 0);
if (id == 0)
{
//子进程
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, val:%d, &val:%p\n", getpid(), getppid(), val, &val);
sleep(1);
val = 200;
}
}
else if (id > 0)
{
while(1)
{
printf("我是父进程,pid:%d, ppid:%d, val:%d, &val:%p\n", getpid(), getppid(), val, &val);
sleep(1);
}
}
return 0;
}
将如上代码编译运行查看现象:
以上这种父子进程在代码共享同一个变量,当任意一方试图写入,双方便各自使用一份副本,称为写时拷贝。
首先,由于进程地址空间的地址都是虚拟地址,因此出现了父子进程在同一个地址上读取的数据不同。在子进程创建时,使用的进程地址空间和页表都是复制的父进程的,如下:
在子进程修改val时,操作系统就会为了子进程重新开辟一段空间,来记录修改后的值,然后将页表中映射的实际地址修改来实现写时拷贝,如下:
为了理解为什么要有进程地址空间,首先要了解一下两点知识:
malloc的本质
由于内存资源是有限的,操作系统作为了减少内存空间的浪费,在malloc申请内存时,只会在页表的虚拟地址中填入地址,而页表中实际内存地址的位置是空出来的,等待进程要使用的时候,再分配一块空间,将实际地址填入页表中。这样做避免了进程申请空间但是没有使用空间时,造成的空间浪费。
可执行程序的地址空间
源代码被编译的时候,就是按照虚拟地址空间的方式进行了早已编好的对应编址的方式编译代码和数据。
在Linux系统下,可以使用objdumop -S 可执行程序名
查看程序的虚拟地址:
进程在开始执行后,首先CPU执行的是虚拟的入口地址,该地址内容中也包含地址,CPU会通过入口地址中记录的内容开始进行页表的跳转,然后正常的执行进程的代码和数据。
为什么要有进程地址空间
首先,我们要知道如果没有进程地址空间,操作系统的工作方式。在没有进程地址空间时,也就不会有页表,进程加载到内存后,CPU按照进程内部的代码顺序执行。如下:
总结: