目录
1.直接写代码看现象
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdlib.h>
#include <unistd.h>
int g_val = 100;
int main()
{
printf("father is running, pid: %d, ppid: %d\n", getpid(), getppid());
pid_t id = fork();
if(id == 0)
{
//child
int cnt = 0;
while(1)
{
printf("I am child process, pid: %d, ppid: %d. g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
cnt++;
if(cnt == 5)
{
g_val = 300;
printf("I am child process, change %d -> %d\n", 100, 300);
}
}
}
else
{
//father
while(1)
{
printf("I am father process, pid: %d, ppid: %d. g_val: %d, &g_val: %p\n", getpid(), getppid(), g_val, &g_val);
sleep(1);
}
}
}
并且我们还能观察到,从一开始父子进程便是相同的
由此可推出,我们打印出来的地址绝对不是物理地址,而是虚拟地址,故我们在C/C++中看到的地址是虚拟地址,由操作系统负责转化为物理地址
关于地址,从低地址到高地址存储的依次是:代码段、初始化全局数据区、未初始化全局数据区、堆区、栈区、命令行参数与环境变量。其中,堆区的空间是从小到大增长的,而栈区的空间是从大到小增长的
其中在32位机器中,使用32位(比特位)来表示一个地址,这意味着它可以表示 2^32
个不同的地址,也就是4GB;在64位机器中,使用64位(比特位)来表示一个地址,它可以表示 2^64
个不同的地址,也就是16EB
需要注意的是,这里的EB指的是艾字节(Exabyte),1EB等于 1,0241,024 PB(拍字节),而1PB等于 1,0241,024 TB(太字节),1TB等于 1,0241,024 GB(千兆字节),1GB等于 1,0241,024 MB(兆字节),1MB等于 1,0241,024 KB(千字节),1KB等于 1,0241,024 字节。因此,16EB是一个非常巨大的数字,远远超过了当前大多数应用场景的需求。
2.引入最基本的理解
我们在回到刚刚代码示例那里,当子进程修改g_val的值时,为了保证进程之间的独立性(也即是说,子进程的数值修改不应该影响父进程),此时就会发生写时拷贝,会给子进程的g_val开辟独立的物理地址空间,而不是与父进程共享同一块空间,通过观察我们发现,代码依旧共享同一块空间,只是数据区不同了而已
3.细节问题-理解它
- 什么叫做地址空间?
在32位机器下,数据与地址总共32根线,每根数据与地址线可以产生充电和放电两种状态,即产生0或1。因此地址总线排列组合形成地址范围为[0, 2^32 ]
,这就是地址空间
- 如何理解地址空间上的区域划分
【示例】小学生划分38线
张三和李四是同桌,桌子共长200cm,他们约定每人100cm。即课桌划分为[0,100], [101,200]这两个区域划分,如果要记录区域的结果,我们就需要先描述再组织
cpp
struct desktop{
int zhangsan_start;
int zhangsan_end;
int lisi_start;
int lisi_end;
};
操作系统为每个进程创建了进程地址空间和mm_struct,用于记录每个进程的各个区域的起始位置和结束位置。在已经被分配给某进程的空间范围内,该进程可以随意使用和访问
- 为什么要有进程地址空间?
【示例】大富翁的3个私生子
远在几千万千里的大富翁有三个私生子,3个私生子互相不知道对方的存在。这个大富翁对每一个私生子说,我有100亿,等我某一天驾鹤西去,这100亿都是你的。当某个儿子有需求时,他像父亲申请10w,此时大富翁就给他10w。但如果申请100亿,可能无法成功(因为有一部分被大富翁其他私生子占用了),但他并不会觉得这100亿不是他的,而是觉得自己申请的太多了
而这里的大富翁等同于操作系统,而这三个私生子等同于系统上的进程。操作系统有4GB的内存空间,进程坚信自己拥有操作系统的全部空间,但通常情况下,进程并不会申请过大的空间。
因此:1️⃣将无序变成有序,让进程以统一视角看待物理内存以及自己运行的各个区域,
在操作系统中,虚拟内存是一种内存管理功能,它让每个进程都认为自己拥有连续的、独立的地址空间,即使物理内存可能是不连续的,并且被多个进程共享。这样,每个进程都感觉自己拥有整个内存,而实际上它们是在操作不同的物理内存区域。这种抽象提供了以下几个好处:
因此:1️⃣将无序变成有序,让进程以统一视角看待物理内存以及自己运行的各个区域,
在操作系统中,虚拟内存是一种内存管理功能,它让每个进程都认为自己拥有连续的、独立的地址空间,即使物理内存可能是不连续的,并且被多个进程共享。这样,每个进程都感觉自己拥有整个内存,而实际上它们是在操作不同的物理内存区域。这种抽象提供了以下几个好处:
❍ 进程隔离:每个进程都不能访问其他进程的内存,这增强了系统的稳定性和安全性。
❍ 内存保护:操作系统可以设置权限,防止进程访问它不应该访问的内存区域。
❍ 内存扩展:通过虚拟内存,系统可以使用硬盘空间作为临时的内存使用,即交换空间(swap space),从而扩展可用内存的大小。
【示例2】:压岁钱的管理
小明新年获得了200块压岁钱,母亲担心小明会乱花钱,于是和小明说"你的压岁钱由我来保管,你需要买什么就和我说,我在从这里给你钱"。于是有一天小明要买一款游戏机150元,找妈妈去要,结果妈妈说"游戏机,会害了你的学习,不给买"。再一次小明要去买学习资料,向妈妈申请50块钱,妈妈同意了。因此,新增一个人,作为中间层,可以对非法请求进行拦截
因此:在操作系统中,页表除了包含虚拟地址到物理地址的映射关系,还记录了该区域的读写权限。当用户对其已申请空间做了超出读写权限外的操作,则会被操作系统识别到,并终止该进程。
也就是2️⃣操作系统会拦截非法请求-->对物理内存进行保护
**注意:**物理地址本身没有读写权限,我们在语言中的const等限制某个地址空间的读写权限,本质是在页表中添加读写权限
如果直接使用物理地址而不是虚拟地址,当我们对也指针进行访问时,由于物理地址没有读写权限控制,导致我们修改了其他进程的数据,破坏了进程的独立性。因此,使用虚拟地址+页表的方式可以保证进程的独立性
在操作细则中,由于内存空间十分宝贵,进程中的代码和数据不一定会被全部加载到内存,因此页表中还有一个字段,表示虚拟地址指向的代码和数据是否在磁盘上。如果虚拟地址映射物理地址时,发现该数据或代码位于磁盘上,则会引发页中断(即当前页表无法映射),此时系统在将对应的代码和数据加载到内存中
★ 惰性加载是一种按需加载资源(如图片、视频、脚本或其他数据)的策略,仅在真正需要时才进行加载。这与预加载(Preloading)相反,预加载是在页面加载时提前加载所有可能需要的资源。
同时,如果因为内存资源紧张,可能会将某个进程挂起,即将它的代码和数据先保存到磁盘中,待内存资源不紧张时再重新加载,但重新加载后的物理地址可能与之前的物理地址不再相同。假设进程没有使用虚拟地址空间+页表映射的方式,则每次将进程代码和数据加载到内存就需要改动PCB到地址空间内容,而不是修改页表的内容。这样将使得进程管理与内存管理耦合度过高。同时,物理内存中几乎所有的数据和代码都是乱序的,由于页表的存在,它可以将物理地址和虚拟地址进行映射,在进程视角,可以将内存分布有序化
因此:3️⃣因为有地址空间和页表的存在,将进程管理模块和内存管理模块进行了解耦合
此时我们也可以知道C/C++申请的地址是虚拟地址。
此时我们要重新定义一下我们对进程的概念进程=内核数据结构(PCB+页表+进程地址空间)+代码和数据