目录
开始之前先来看看这样一段代码。请问在运行时这段代码会不会报错呢?
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *str = "hello world";
*str = 'H';
return 0;
}
答案是会出问题。为什么呢?因为 str 这个指针保存的是字符串的起始地址,并非字符串。*str 要将 h 改为 H ,但是字符串在字符常量区,具有可读属性,所以就会报错。这个问题相信在学习C/C++的时候都已经接触过。下面我们通过这篇文章进一步了解
进程地址空间
在学习C++的时候,你大概率学过这张内存分布图,C/C++程序是按照下面的方式存储的。
从低地址到高地址,内存分布为:
- 代码段:存储
可执行代码
和只读常量
- 数据段:存储
全局变量
和静态数据
- 堆区:用于
动态内存管理
,堆区内存往高处增长 - 栈区:大部分
局部变量
,栈区内存往低处增长 - 内核空间:
命令行参数argv
和环境变量env
等
我们可以用下面的代码来验证一下,注意该代码是在Linux环境下运行的,你可能在别的操作系统下跑出来的结果有差异
#include <stdio.h>
#include <stdlib.h>
int g_val_1;
int g_val_2 = 10;
int main(int argc, char* argv[], char* env[])
{
printf("代码段地址: %p\n", main);
const char* str = "hello world";
printf("字符常量区地址: %p\n", str);
printf("已初始化全局变量: %p\n", &g_val_2);
printf("未初始化全局变量: %p\n", &g_val_1);
char* mem1 = (char*)malloc(100);
char* mem2 = (char*)malloc(100);
char* mem3 = (char*)malloc(100);
printf("堆区地址1: %p\n", mem1);
printf("堆区地址2: %p\n", mem2);
printf("堆区地址3: %p\n", mem3);
printf("栈区地址1: %p\n", &mem1);
printf("栈区地址2: %p\n", &mem2);
printf("栈区地址3: %p\n", &mem3);
static int a = 0;
int b,c;
printf("栈区地址4--a: %p\n", &a);
printf("栈区地址5: %p\n", &b);
printf("栈区地址6: %p\n", &c);
printf("命令行参数地址: %p\n", &argv[0]);
printf("环境变量地址: %p\n", &env[0]);
return 0;
}
运行结果如下
上述的结果验证了那张内存分布图,但是有几点需要注意:
- 函数存储在代码段。注意:函数名的本质就是地址,所以 main 和 &main 是一样的。
- 堆区的地址向高出增长
- 栈区的地址向低处增长
- static修饰的局部变量在编译的时候编译到全局变量区。上述a变量的作用域是在main函数里面,但生命周期是全局的
上述的东西叫做进程地址空间,那么它是内存吗?
为了回答这个问题,我们先来看看虚拟地址
虚拟地址
先看下面的代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
pid_t id = fork();
if(id == 0)
{
int cnt = 3;
//子进程
while(1)
{
printf("i am child, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n ", getpid(), getppid(), g_val, &g_val);
sleep(1);
if(cnt) cnt--;
else
{
g_val = 200;
printf("子进程改变g_val的值: 100->200\n");
cnt--;
}
}
}
else
{
//父进程
while(1)
{
printf("i am father, pid: %d, ppid: %d, g_val: %d, &g_val: %p\n ", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
return 0;
}
运行结构
上述结果中,父进程的g_val一直为100,因为进程具有独立性,父子进程的数据互不影响。子进程刚被创建时,和父进程共用数据和代码,因此父子进程第一次输出g_val的值的时候,不论值和地址都是一样的。
但是在子进程修改了val的值之后,为什么父子进程的val明明不同了,&val还是一样的?
一块内存同时只能存储一个值。那此处父子进程的val值不同,只有一个可能:这个地址不是真正的物理地址
其实在语言层面接触到的地址(无论是C/C++还是其他语言),所使用的都不是物理地址,而是虚拟地址(线性地址)
初步理解进程地址空间
下面我们来初步的理解上面的现象
之前我们说到,当你创建一个进程的时候,操作系统一定会为该进程创建一个PCB,还会把对应的可执行程序(代码和数据)加载到内存中,而PCB需要通过一定的方式来找到代码和数据。实际上事情并没有这么简单
实际中,当我们PCB一旦创建出来,紧接着我们的系统还会创建一个进程地址空间。我们平时看到的地址是进程地址空间的地址。而PCB中有对应的指针指向该进程地址空间的。进程地址空间会通过页表来映射物理内存。页表,会维护虚拟地址与物理地址之间的映射关系,当进程通过虚拟地址在进程地址空间中查找数据,其实本质上是拿着虚拟地址到页表中查找映射关系,进而找到真实的物理地址,再对数据进行访问。
当父进程创建子进程的时候,子进程会以父进程为模板,初始化自己的值,当然也有属于自己的值。由于每一个进程都要有一份进程地址空间,所以子进程也会拷贝父进程的进程地址空间和页表。页表是直接拷贝的,也就是说包括虚拟地址和物理地址之间的映射关系。这就能解释为什么父子进程的g_val的值是一样的了。
当子进程对g_val的值进行修改的时候会发生写时拷贝。子进程会把与父进程共用的g_val拷贝一份到别的地方,然后修改。这个过程中,对于子进程,g_val的物理地址改变了,于是对页表的映射关系进行修改,g_val的虚拟地址不变,但是虚拟地址和物理地址的映射改变了。这个过程中只修改了物理地址,虚拟地址没有改变。
通过以上解释,你就能明白为什么在子进程中修改了g_val的值,父子进程中g_val的地址是一样的,但是结果不同了。就是因为父子进程的页表不同,映射关系不同,访问到的物理地址不同。因此我们输出的时候看到了一个地址两个值的情况。
如何理解地址空间
什么叫做地址空间
你的进程在CPU中运行,才有访问内存的需求。当进程访问内存时一定是要确定要访问哪一个地址。CPU通过地址总线来访问物理内存,在32位计算机中,有32位的地址和数据总线,所以每一根地址总线只有0、1概念,那么32根总线就有种组合,那么内存空间就是
*1byte即4GB的大小。那么地址空间就是地址总线的排列组合形成的范围,范围大小为
。
如何理解地址空间上的区域划分
为了方便理解,我们举一个例子,现在有一个100cm的桌子,小胖占50cm,小花占另外50cm。这个例子本质就是区域划分。那么我们怎么用计算机语言来表示呢?答案是用结构体,区域划分就是在结构体里定义区域的起始和结束。
struct area{
int start; //开始位置
int end; //结束位置
};
struct destop_area //约定最大范围是100cm
{
struct area xiaopang; //小胖的区域
struct area xiaohua; //小花的区域
};
struct destop_area line_area = {{0,49},{50,99}};
所谓的空间区域调整(变大,缩小)如何理解?
line_area.xiaopan.end -= 10;
line_area.xiaohua.start -= 10;
我们给小胖分配了{0,49}大小的地址空间,那么在这个连续的空间范围内,每一个最小的单位都可以有地址,这个地址可以被小胖直接使用!
所以进程地址空间本质就是一个描述进程可视范围的大小。地址空间内一定要存在各种区域划分,对线性地址进行start和end即可。
mm_struct
每一个进程都有进程地址空间,所以操作系统会创建进程地址空间的结构体,对其进行管理。地址空间是内核中的一个结构体对象,类似于PCB,也是要被操作系统管理的。这个结构体叫做mm_struct,默认划分的区域大小是4GB
以下是Linux 2.6内核中mm_struct的部分源码
unsigned long total_vm, locked_vm, shared_vm, exec_vm;
unsigned long stackvm, reserved_vm, def_flags, nr_ptes;
unsigned long start_code, end_code, start_data, end_data;
unsigned long start_brk, brk, start_stack;
unsigned long arg_start, arg_end, env_start, env_end;
大致结构图如下
进程地址空间的意义
1、让进程以统一的视角看待内存
在为进程分配内存的时候,分配的内存是比较散乱的,此时就会导致地址非常杂乱无章可循。当通过页表映射,把指向相同功能的内存地址放到一起,此时我们就有的栈区,堆区,静态区等等区域,更好地统一管理地址了。
2、将进程管理和内存管理解耦
由于页表的存在,此时进程管理和内存管理就是互不影响的。进程只需要去读取内存,申请内存等,无需考虑硬件层面的内存是如何管理的。对于磁盘,只需要做好加载数据到内存的工作,加载完数据后,无需考虑进程是如何读取地址,如何获取数据的。
3、保护了内存安全
当用于向内存发出非法访问时,进程地址空间就可以检测出来,比如访问越界的内存等等。此时内存中的数据不会受到任何影响,因为该错误已经被进程地址空间检测并处理了。
比如我们通过指针向非法的内存进行写入,那么进程地址空间就可以检测出来该地址是超出了某个范围的,在操作系统层面就直接报错,而不会真的等到对内存写入了数据之后,才发现该访问非法。
4、确保了进程的独立性
进程 = 内核数据结构 + 进程自己的代码和数据。通过进程地址空间的映射,每个进程都有自己的内核数据结构,自己的代码,自己的数据,相互之间完全独立互不影响
初谈页表
每一个正在执行的进程,它的页表在CPU中的CR3寄存器中,该寄存器中会保留当前正在运行的进程的页表地址(这个地址是物理地址)。如果当前进程被切换走了,担不担心找不到当前进程的页表呢?不需要担心。因为CR3寄存器中的内容属于进程的硬件上下文。
我们先以下面的图片简单理解一下页表
虚拟地址和物理地址就不说了,这里说一说标志位,通过它可以知道当前访问的物理地址是否是可读可写的。(可执行在页表中并没有特别的体现,CPU中有一个eip寄存器,eip指向谁谁就是可执行的)。
现在我们就可以理解最开始的代码了
#include <stdio.h>
#include <stdlib.h>
int main()
{
char *str = "hello world";
*str = 'H';
return 0;
}
"hello world"是在字符常量区的,它在页表中的标志位是只能被读取,不能被修改的。所以当你修改时就会报错。
缺页中断
我们先建立一个共识:现代操作系统几乎不做如何浪费空间和时间的事情
你可能会发现,一个游戏可能有18个G,但是内存只要8个G,游戏为什么能执行呢?因为操作系统可以实现对大文件的分批加载。如果我加载的内存这个时间片呢没有跑完,那这就是一种变相的浪费时间和空间的事情。所以我们的操作系统加载程序的方式是一种惰性加载的方式。就是说我虚拟地址可能给你很多,但是物理地址几乎是用多少加载多少。这种情况就是在页表的虚拟地址一侧我给你填完,但是对应的物理地址一侧我可能不填完。所以页表中还有一个标记位来表示对应的代码和数据是否已经加载到内存中。当操作系统访问数据时,没有识别到数据没有在物理内存里(对应的标志位为0),那么操作系统就会在内存中找到物理地址,然后将可执行程序的需要的代码和数据加载到内存里,然后将这段代码的地址填入到页表的对应位置,这个过程是自动完成的,这个过程叫做缺页中断。
所以只从技术情况下讨论(实际上不会),在极端情况下,操作系统将进程地址空间和页表加载好,但是一行代码和数据都不给你加载。所以进程在被创建的时候,先要创建内核数据结构,再加载对应的可执行程序。
动态内存管理底层机制
当用户向内存申请空间,但是用户很有可能还没有这么快就使用这块内存,那么如果操作系统直接把这一块内存分配给该进程,就会导致内存的浪费。
操作系统要为效率和资源利用率负责,因此当用户进行内存申请的时候,操作系统不会直接分配内存。操作系统会先给用户一个虚拟地址,比如malloc和new都会返回指针,这个指针就是虚拟地址。但是虽然有了虚拟地址,但是页表中没有该虚拟地址的映射关系。
直到用户尝试对这个内存进行访问,只要访问合法,就会去页表中查找该虚拟地址的物理地址。当发现该虚拟地址不存在页表中,此时就会向操作系统报错,发生缺页中断。
一旦发生缺页中断,操作系统就会进行分析,发现是用户想要访问之前动态开辟的内存,于是操作系统此时才真正开辟内存,并且在页表中建立映射关系。
因此:动态内存管理的本质,是在虚拟地址中申请内存。
这么做有两个好处:
1、保证了内存的使用率,直到用户对内存写入,才真正开辟内存
2、提升了malloc,new等动态内存管理的速度,因为没有真的申请内存