我们在之前的语言的学习过程中有提到过程序的地址空间,它到底是什么样的呢?
我们来看下面这张图片:
我们通过一段代码来验证一下是不是这样的:
#include
#include
int num1 = 10;
int num2;
int main()
{
printf("已初始化: %p\n", &num1);
printf("未初始化: %p\n", &num2);
const char *s = "12345";
printf("常量区, %p\n", s);
int *p = new int(10);
printf("堆区: %p\n", p);
printf("栈区: %p\n", &p);
printf("栈区: %p\n", &s);
return 0;
}
虽然代码很粗糙,但是不难看出和这张图片基本是相符的。
我们来看另一段代码:
#include
#include
#include
int s_val = 100;
int main()
{
pid_t id = fork();
if (id > 0)
{
int count = 0;
while (1)
{
printf("我是父进程, PID->%d, PPID->%d, val->%d, &val->%p\n", getpid(), getppid(), s_val, &s_val);
if(count == 2) s_val = 10;
count++;
sleep(2);
}
}
else if (id == 0)
{
while (1)
{
printf("我是子进程, PID->%d, PPID->%d, val->%d, &val->%p\n", getpid(), getppid(), s_val, &s_val);
sleep(2);
}
}
else
{
perror("fork fail");
}
return 0;
}
我们用fork
创建了一个子进程,在几秒后让子进程修改s_val的值,我们看看父子进程会有什么变化呢?
我们发现在子进程修改了s_val
的值后,父进程还是输出s_val
原来的值,甚至他们输出的地址还是一样的。这是为什么呢?
按照之前学的来看,同一个物理地址输出的应该是同一个值,但这里又不是相同的值。
其实这就要提出一个虚拟地址的概念了,我们在语言层面上操作的地址都不是物理地址,而是操作系统根据物理地址和页表转化过来的。我们之前也提了,操作系统不会放心给用户这么大的权限的,所以说如果你用语言写一个内存泄露,操作系统真的就会内存泄露吗?这显然是否定的。
看上去输出的同一个地址,本质其实不是相同的。
所以说我们称呼它为程序的地址空间是不够准确的,我们应该称呼它为进程的地址空间。
进程地址空间本质上是一种内核数据,在Linux系统中由结构体mm_struct
实现。
类比之前的那张图片,看看内核中的mm_struct
是怎么实现的:
struct mm_struct
{
unsigned int code_start;
unsigned int code_end;
unsigned int init_start;
unsigned int init_end;
unsigned int uninit_start;
unsigned int uninit_end;
unsigned int heap_start;
unsigned int heap_end;
unsigned int stack_start;
unsigned int stack_end;
……
}
操作系统创建进程时创建一个PCB(其中有一个结构体指针指向mm_struct)和mm_struct。
一开始子进程被创建的时候和父进程共享同一块空间,但是当子进程需要修改数据时,将父进程的数据进行拷贝,再将子进程的数据进行修改,分别通过页表映射到不同的物理地址。
其中的拷贝技术就是写时拷贝。
为什么会发生写时拷贝呢?
原因是进程间有独立性,每个进程都共享相同的资源,为了达到进程间互不干扰的目的,不让子进程影响父进程,就有了写时拷贝。
一般来说,子进程不会用到父进程所有的数据,且子进程不对数据进行修改等操作,就没有必要对数据进行写时拷贝,但这不代表进程不能进行写时拷贝。况且在必要时才对数据进行写时拷贝也能提高空间的利用率。
为什么有进程地址空间?
创建一个进程实际上就是创建了PCB(task_struct)、mm_struct和页表。