什么是进程地址空间
我们学习C语言时,应该见过这样的空间布局图:
操作系统会给每个进程创建一个这样的地址空间,即进程地址空间。我们回顾一下各个区域的作用:
代码段: 存放机器指令,在程序执行期间,代码段是只读的,以防止程序意外地修改其指令。
数据段:存储程序中已初始化的全局变量、静态变量和常量。与代码段不同,数据段在程序运行期间是可以读写的,以便程序能够访问和修改其中存储的数据。
栈:用于存储局部变量、函数参数、以及函数调用时的上下文信息(如返回地址和寄存器状态)。栈内存由编译器自动分配和释放,采用后进先出的数据结构。
堆:用于动态内存分配,是由程序员手动分配和释放的,用于动态地存储程序在运行期间创建的对象。
虚拟地址
我们做个简单的实验:
#include<stdio.h>
int main()
{
int a=0;
int id=fork();
if(id>0)
{
a=1;
printf("我是父进程,a=%d ,地址:%p\n",a , &a);
}
else if(id==0)
{
a=2;
printf("我是父进程,a=%d ,地址:%p\n",a , &a);
}
else
{
printf("创建失败");
}
return 0;
}
运行结果:
我们看到了一个违反常识的现象,子进程和父进程中的a地址相同,但a的值不同。看起来就像是同一个·地址储存了两个值。为什么会出现这种情况呢?因为这里打印出的地址是虚拟地址。
mm_sruct
进程PCB中包含了一个重要结构mm_struct。mm_struct是Linux内核中用于描述一个进程的虚拟地址空间的主要数据结构。
struct mm_struct {
......
unsigned 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_stack堆的起始位置
//brk堆的当前的最后地址
//用户堆栈的起始地址
unsigned long arg_start, arg_end, env_start, env_end;
//arg_start 命令行参数的起始地址
//arg_end命令行参数的起始地址
//env_start环境变量的起始地址
//env_end环境变量的最后地址
......
};
在上面的代码中我们可以看到,所谓堆,栈,代码段,数据段的起始和结束地址就是一个整型数字。这些地址实际上是虚拟地址,它们并不直接对应物理内存中的实际位置。相反,它们是由内核的内存管理子系统映射到物理内存或其他类型的内存上的。
页表
操作系统会将虚拟地址与内存中的物理地址相映射, 这些映射关系就被保存到页表。当程序使用虚拟内存地址进行访问时,操作系统会根据页表将这些虚拟地址转换为实际的物理内存地址。页表的主要目的是实现内存的有效管理和保护,以及提供内存抽象,使得程序可以像访问连续的内存一样访问非连续的物理内存。
现在我们已经可以理解上面的现象了,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址!
写时拷贝
写时拷贝是一种计算机程序中的内存管理策略。其核心思想是,如果有多个进程或线程需要读取同一份数据,那么只保留一份数据,只有当其中一个进程或线程需要修改这份数据时,才会真正地复制这份数据,并为修改的进程或线程分配独立的内存空间。
当使用fork()系统调用创建一个新进程时,子进程会继承父进程的地址空间、数据、环境变量等资源。但为了提高效率和节省内存,这些资源并不是立即复制给子进程的,而是让父子进程共享这些资源。只有当其中一个进程试图修改这些共享资源时,操作系统才会进行实际的复制操作,即写时拷贝。
进程地址空间的意义
数据安全性:进程地址空间为每一个进程都创建了一个独立的地址空间,确保每个进程在访问物理内存时都经过进程地址空间获取虚拟地址,再通过页表映射到物理地址。这种机制有效地防止了进程对物理内存的非法访问,大大增强了数据的安全性。
进程独立性:进程地址空间的存在使得每个进程都有自己独立的地址空间,确保了进程间的独立性。这样,即使两个进程执行相同的代码,它们的数据也不会互相干扰。这种独立性是并发和多任务处理得以实现的基础。
内存管理灵活性:进程地址空间为操作系统提供了一种灵活的内存管理机制。通过虚拟地址和页表的结合,操作系统可以更容易地管理、分配和回收内存,而无需关心物理内存的具体布局。这种灵活性使得操作系统能更好地适应不同的应用场景和硬件环境。