程序地址空间
C/C++的内存分布验证
在学习C/C++语言时,我们了解到内存布局大抵如下图所示

用以下代码来验证上图分布
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_unval;
int g_val = 100;
int main(int argc, char *argv[], char *env[])
{
const char *str = "helloworld";
printf("code addr: %p\n", main);
printf("init global addr: %p\n", &g_val);
printf("uninit global addr: %p\n", &g_unval);
int test = 10;
char *heap_mem = (char*)malloc(10);
char *heap_mem1 = (char*)malloc(10);
char *heap_mem2 = (char*)malloc(10);
char *heap_mem3 = (char*)malloc(10);
printf("heap addr: %p\n", heap_mem); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem1); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem2); //heap_mem(0), &heap_mem(1)
printf("heap addr: %p\n", heap_mem3); //heap_mem(0), &heap_mem(1)
printf("test addr: %p\n", &test); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem1); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem2); //heap_mem(0), &heap_mem(1)
printf("stack addr: %p\n", &heap_mem3); //heap_mem(0), &heap_mem(1)
return 0;
}

根据运行结果可以验证出内存分布图是正确的,如果将局部变量用static修饰,那么作用域不变,生命周期与全局变量相同。我们将代码部分转变为static int test = 10;,观察运行结果
可以发现test变量并不在栈区了,而是在全局区,那么就可以得知static做到生命周期与全局变量相同的原理是该变量的存储位置在全局区。
虚拟地址
cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
{
pid_t id = fork();
if(id == 0)//子进程
{
while(1)
{
printf("我是子进程,我的pid为%d,我的ppid为%d,g_val为%d,g_val的地址为%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
else
{
while(1)
{
printf("我是父进程,我的pid为%d,我的ppid为%d,g_val为%d,g_val的地址为%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
通过运行上述代码得到下述结果,我们可以发现对于同一个变量,父子进程的值与地址都是相同的。

cpp
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int g_val = 0;
{
pid_t id = fork();
if(id == 0)//子进程
{
while(1)
{
g_val++;
printf("我是子进程,我的pid为%d,我的ppid为%d,g_val为%d,g_val的地址为%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
else
{
while(1)
{
printf("我是父进程,我的pid为%d,我的ppid为%d,g_val为%d,g_val的地址为%p\n",getpid(),getppid(),g_val,&g_val);
sleep(1);
}
}
return 0;
}
但是如果我们让子进程对变量++,可以发现以下结果。

我们可以发现虽然子进程的变量值改变了,父进程的变量值没有改变,因为进程之间是具有独立性的,子进程是不能改变父进程的变量的,但是g_val的地址都是相同的,那么怎么可能同一个地址所对应的变量的值不一样呢?那么根据以上结果我们一定可以推出我们所打印出来的地址一定不会是真实的物理地址!因为如果是真实的物理地址怎么可能会有不一样的值呢!所以之前所学到的所有地址,打印出来的所有地址都不是真实的物理地址,那么所打印出来的是什么呢,就是虚拟地址。
虚拟地址空间

进程在进行访问内存时,会先进行虚拟地址与物理地址的映射找到物理内存,才可以访问数据,所以一定会有一个页表来进行映射虚拟内存与物理内存。
在最开始创建子进程时,子进程会继承父进程的代码和数据,所以最开始的映射是一样的,但是当子进程开始修改数据时,OS会重新开辟空间拷贝内容,进行更改映射关系,在写入数据时进行拷贝与更改映射关系就是写时拷贝。
而虚拟地址空间到底是什么呢?
类似与老板给各员工画的一张饼,这张饼是虚假的,但是员工会认为是真实的,同样的这张饼也需要被管理起来,同样是先描述再组织,所以虚拟地址空间本质就是OS对进程画的饼,一定是一个数据结构!通过查找内核代码可以找到一个mm_struct的结构体,这个就是虚拟地址空间,其中有下图所示的变量来划分区域。
mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。

命令行参数与环境变量之所以能被子进程看到,是因为命令行参数和环境变量是属于父进程地址空间内的数据资源和代码数据区一样,子进程会继承父进程的地址空间!
页表中除了映射关系还有权限,比如代码是只读的,字符串常量和代码编译在一起的也是只读的,由权限所约束。
虚拟地址空间存在的原因
1.虚拟地址空间可以变相保护物理内存的安全,保证进程的独立性。最简单的一个例子,如果进程A抛出了一个指针指向了进程B所使用的地址,如果是物理地址,就会乱套,没办法保证进程的独立性,也无法保证物理内存的安全。虚拟地址空间使得每次要访问内存都需要进行一次转换,在转换的过程中会进行安全审核,在计算机中,任何问题都可以通过添加一层软件层来解决。在比如,父母不会将压岁钱直接交给孩子使用,而是让孩子需要使用时去询问父母,这样父母就可以得知孩子使用的钱的用途是否安全正确,而孩子也会知道自己是有多少压岁钱在父母那里。
-
虚拟地址空间可以让无序的内存变成有序。可执行程序在理论上是可以加载到内存的任何一个位置的,是不会像虚拟地址分布图那样将相同元素分布在一块区域,而通过虚拟地址空间,进程看到自己的代码和数据全都是有序看待。
-
虚拟地址空间让进程管理与内存管理进行解耦合。创建一个进程时,是先有内核数据结构,然后再加载代码和数据,内存是边加载边执行,也就是惰性加载,上述提到的写时拷贝也是一种惰性加载,加载到内存就必须要进行内存申请,申请内存是先申请的虚拟地址空间,从而进行映射到物理内存地址,这样将进程管理与内存管理进行分开。
vm_area_struct
对于堆空间,每次进行申请时都是一小块的,但是内存分布图中其是一整个区域,那么是如何做到仅仅给出一小块区域的呢

用的是vm_area_struct,上图mm_struct表示汇总了虚拟地址的整体情况,而用vm_area_struct进行区域划分,那么在堆区中再次划分成小区域也是用的vm_area_struct,只要在堆中继续有vm_area_struct就行了。而vm_area_struct也是结构体,也是需要组织起来,一般使用双链表组织,一旦vm_area_struct数量多了就会使用红黑树。