目录
一、补充内容
在文章开始之前,先补充两个在 Linux 下编写代码的常用操作
1.Makefile 的编写方式
在以前我们 Makefile 是这样编写的:
但是如果我们有许多的文件
所以,针对以上情况,有一种简便的写法:
其中 $@ 相当于 中@ 就相当于当前依赖方法所对应的依赖关系中的目标文件
即 $@ 就相当于上面的 hello
而 $^ 中 ^ 相当于 hello: 右侧的所有文件
因为上面我们右侧只有一个hello.c 所以 ^ 在此就表示hello.c
2.vim 下批量化注释
进入v-block模式 (Ctrl+B),然后使用 hjkl 控制上下左右 再输入大写 I (i) 输入 // 再按ESC
如果想取消注释,选中注释区域,然后输入d。
二、程序地址空间回顾
2.1 验证地址空间
接下来我们来学习进程地址空间。在C语言中,我们都见过如下的空间布局图。
现在我们写一段代码,来验证地址空间。
我们来看看运行结果
- 其中正文代码部分即代码的地址,所以我们可以直接输出 main 函数的地址,就是当前代码所处的位置。
- 通常来说 环境变量的地址位于命令行参数之上。
我们之前就知道,字符常量是不能修改,但是为什么呢?其实的字符常量和我们的代码一样,都位于正文代码区域,是只读的。
关于栈上开辟空间和使用 static 变量
所以static 的本质就是将局部变量 转化为全局变量,将该变量开辟在全局区域。
2.2 奇怪的代码
但是我们对程序地址空间其实并不了解,我们再先写一段代码来感受一下:
代码功能如下:
- 定义一个全局变量g_val,然后使用 fork 创建一个子进程
- 父子进程循环打印 g_val 的值。
- 执行5秒后,子进程将 g_val 的值进行改动。
代码:
结果:
我们发现,父子进程,输出的地址是一致的,但是变量内容不一样。
得出以下结论:
- 变量内容不一样,所以父子进程输出的变量绝对不是同一个变量
- 但是地址值是一样的,说明该地址绝对不是物理地址!
- 在 Linux 下,这种地址叫做虚拟地址
- 我们在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址由OS统一管理。
- OS负责将虚拟地址转化成物理地址。
三、进程地址空间
所以之前说,"程序地址空间"是不准确的,准确的应该说成进程地址空间,那么如何理解呢?
我们来看下图:
- 上面的图就足矣说明问题,同一个变量,地址相同,其实是虚拟地址相同,内容不同其实是被映射到了不同的物理地址。
其中 mm_struct 就是(虚拟)进程地址空间,其由task_struct(PCB)维护。
所以,为什么进程具有独立性
因为地址空间和页表(用户级)是每一个进程都私有一份。
只要保证,每一个进程的页表,映射的是物理内存的不同区域,就能做到,进程之间不会互相干扰以来保证进程的独立性。
3.1 什么是虚拟地址空间
先说结论:虚拟地址空间是一种内核数据结构,其中有各个区域的划分。
接下来我们来看 Linux 的内核源代码:
结论:
- 堆向上增长以及栈向下增长实际就是改变mm_struct当中堆和栈的边界刻度。
- 我们生成的可执行程序实际上也被分为了各个区域,例如初始化区、未初始化区等。当该可执行程序运行起来时,操作系统则将对应的数据加载到对应内存当中即可,大大提高了操作系统的工作效率。
3.2 为什么一个变量有两个值
回答我们以前的问题,为什么 fork 之后,一个值具有两个值。
当 id=fork() 的时候,谁先返回,谁就要发生写时拷贝,所以,同一个变量,会有不同的内容值,本质是因为其虚拟内存是相同的,但是其对应的物理内存不相同。
3.3 为什么要有地址空间
大家观察以下程序:
在我们以前的认识里,以上代码绝对是会报错的,因为我们知道,字符常量是不能被修改的。但是为什么字符常量区不能被修改,只能被读取呢?
代码当然可以写,只不过操作系统设置了 读写权限。即页表在映射时设置了读/写权限。所以,如果我们对只读权限区的数据进行修改,操作系统立马就能检测这个进行非法访问的操作,退出该进程。
延迟分配的策略
在C/C++中,我们 malloc/new 空间,如果不立马使用,必然会造成空间资源的浪费。
所以,在操作系统中,因为有地址空间的存在,上层申请空间,其实是在地址空间上申请的,物理内存空间可能一个字节都未分配。
而当你真正进行物理地址空间访问的时候,才执行内存的相关管理算法,帮你申请内存,构建页表映射关系,然后你才能进行内存的访问。这即是延迟分配的策略,提高整机的效率。
1、有了进程地址空间后,就不会有任何系统级别的越界问题存在了。例如进程1不会错误的访问到进程2的物理地址空间,因为你对某一地址空间进行操作之前需要先通过页表映射到物理内存,而页表只会映射属于你的物理内存。总的来说,虚拟地址和页表的配合使用,本质功能就是包含内存。
2、有了进程地址空间后,每个进程都认为看得到都是相同的空间范围,并且认为其独占空间。包括进程地址空间的构成和内部区域的划分顺序等都是相同的,这样一来我们在编写程序的时候就只需关注虚拟地址,而无需关注数据在物理内存当中实际的存储位置。
3、有了进程地址空间后,每个进程都认为自己在独占内存,这样能更好的完成进程的独立性以及合理使用内存空间(当实际需要使用内存空间的时候再在内存进行开辟),并能将进程管理与内存管理进行解耦或分离。
简述:
- 保护内存,防止越界等操作。
- 使操作系统可以统一地看待所有进程。
- 让各进程认为自身独占空间,通过页表映射实现各进程的独立性。
- 使内存管理和进程管理两套管理策略进行了解耦合的作用。
- 延迟分配策略提高整机效率。
3.4 重新理解挂起
首先我们要知道,进程的新建态。
一个进程被创建的时候,操作系统可能只分配了其内核结构,即 task_struct、进程地址空间(mm_struct)以及页表 。这种最初的状态,就是所谓的进程新建态。
比如当我们运行 50G大小 的游戏,我们内存只有 8/16G 的电脑是如果运行起那么庞大的游戏的呢?
是因为操作系统采用了分批加载、换出的操作。
比如我们进入游戏,游戏启动进程则被执行,而当我们进入对局,游戏启动进程则被挂起。
一个可执行程序有代码和数据。当我们短期不在执行该进程时,比如等待输入、等待网络进入阻塞队列。
进程的数据和代码则会被换出到磁盘当中,此时该进程就是被挂起了。
页表映射的时候,不仅仅可以映射内存,还可以映射到磁盘中。
四、写时拷贝
每个进程被创建时,其对应的进程控制块(task_struct)和进程地址空间(mm_struct)也会随之被创建。而操作系统可以通过进程的task_struct找到其mm_struct,因为task_struct当中有一个结构体指针存储的是mm_struct的地址。
例如,父进程有自己的task_struct和mm_struct,该父进程创建的子进程也有属于其自己的task_struct和mm_struct,父子进程的进程地址空间当中的各个虚拟地址分别通过页表映射到物理内存的某个位置,如下图:
当子进程刚刚被创建时,父进程和子进程的数据和代码是共享的,即父子进程的代码和数据通过映射到物理内存的同一块空间。只有当父进程或子进程需要修改数据时,才将父进程的数据在内存当中拷贝一份,然后进行修改。
例如,子进程需要将全局变量 g_val 改为200,那么此时就在内存的某处存储 g_val 的新值,并且改变子进程中 g_val 的虚拟地址和通过页表映射后得到的物理地址即可。
总结上图:
这种在需要进行数据修改时再进行拷贝的技术,称之为写时拷贝技术。
1.为什么数据要进行写时拷贝
进程具有独立性。多进程运行,需要独享各种资源,多进程运行期间互不干扰,不能让子进程的修改影响到父进程。
2.为什么不在创建子进程的时候就进行数据的拷贝?
子进程不一定使用父进程的所有数据,并且在子进程不对数据进行写入的情况下,没有必要对数据进行拷贝。操作系统采用了按需分配的原则,在需要修改数据时在分配(延时分配),这样额可以高效使用内存空间、提高运行效率。
3.代码会不会进行写时拷贝
90%的情况下是不会的,但这并不代表代码不能进行写时拷贝,例如在进程替换的时候,则需要进行代码的写时拷贝。
4.程序内部有地址吗?
在我们程序编译时,没有被加载到内存中时,编译器就在我们程序的内部根据进程地址空间的地址设置了虚拟地址。
地址空间不仅仅是 OS 内部要遵守的,其实编译器同样也遵守。在编译器编译代码的时候,就已经给我们形成了各个区域,例:代码区、数据区。并且采用了和 Linux 内核中一样的编制方式,给每一个变量,每一行代码都进行了编址。故,程序在编译的时候,每一个字段早已经具有了一个虚拟地址。
着重强调,程序内部的地址,依旧使用的编译器编译好的虚拟地址。