7 Day:向内核迈进--虚拟内存

前言:

今天我们要探索一个非常非常非常重要的副本:虚拟内存。虚拟内存是一个很神奇的东西,即使你的物理内存只有几百MB,但是你的进程自己的内存空间却可以有几GB,这么神奇的技术相信你肯定迫不及待想要了解了,废话不多说我们开始吧

今日开发参考资料:

《操作系统真象还原》


一,分段内存的弊端

我们在讨论分页内存,首先先要讨论分段内存的弊端,用饭圈语言来说就是踩一捧一,那分段内存有什么弊端呢,我们看下面的一个例子:

从图中我们可以了解到,当B进程结束时,D进程要运行,结果发现:咦?内存不够了,瞧这弊端不就来了。

👿 这时候有人可能就会说,博主你个小黑子,在尬黑,这明明有解决方法的啊,怎么能说是分段内存的弊端呢,明明是你不会解决(急)!!

 🆗,那我们一起来看看有什么解决方法,我也来列举一下:

①等待A进程或者C进程结束,然后就有空间给D进程

②先把A进程或者C进程置换到内存中,让20M和15M这些碎片内存先合并,在给D分配内存

首先第①个方法我就不说了,看起来是最简单,实际上又是最低效率的,你等一个进程自己死亡要等到猴年马月去啊,我们直接看第②个方法。

方法②分析:

 题外话:其实写了这么久的操作系统,我也陷入了一个鸡和鸡蛋的问题,也就是到底是操作系统依赖硬件还是硬件依赖操作系统。结果到后来我悟了,其实硬件和操作系统之间是相辅相成的,就像人类一样,你的思维,想法,大脑是你的软件。心,肝,脾,肺是你的硬件。就像跑步这一动作,大脑负责下达指令,心,肺,等等身体器官开始被调度,完成跑步这一动作。在下面我们也可以看到我所说的这些迹象。

 我们在学保护模式的时候了解到了段描述符有两个字段:P(是否存在内存中)和 A(调用计数)。

  • 外存移到内存

如果P为1,则直接去内存中访问该段,如果P为0,CPU则抛出异常,操作系统处理异常将段从外村存入到内存中,再将P置为1,再将A置为1。

  • 内存移到外存

这个就有一点LRU的感觉,还记得我们有个A吗,A置为1是由CPU设置的,但是设置为0是由操作系统设置的,操作系统每当发现当前段描述符的A为1时,就会将其置为0,然后统计一周期内,每个段描述符置为1的次数,并挑出最少的一段放入外存中,并将其P置为0。

这样往复操作,就可以用内存转外存,外存转内存的方法来管理内存。但是我们仔细想一想这方法难道真的没缺陷吗?

  • 如果内存段过大,置换时IO的压力是很大的
  • 如果你的内存只有4KB,你要跑1MB的进程这该咋办呢

二,页表

我们仔细想想,实际的原因就是因为20MB和15MB两段是不连续的,而这种不连续无法被使用是因为,在编码时,代码段地址时连续的,所以线性地址是连续的,就默认为物理地址也是连续的。那如此一来我们让连续的线性地址对应不连续的物理地址,该问题不就迎刃而解了吗。

上述我们有说道,让一个地址对应另一个地址,深入学过一些语言同学肯定听说过一个大名鼎鼎的数据结构:HASH_MAP。对某一个地址做相关hash函数映射来对应某个内容。而我们的页表就是这个思想,(注意页表是CPU的一个机制,不是操作系统编写程序,毕竟代码再快也快不过硬件)


分页与分段

分页是建立在分段的基础上的

物理地址与虚拟地址

线性地址:段基址+段内偏移地址

物理地址:当CPU不打开分页机制时,此时线性地址被直接送往CPU总线,这个地址就是物理地址

虚拟地址:当CPU打开分页机制时,线性地址不在被直接送往CPU总线,而是先查询页表根据页表再去访问内存,这个就是虚拟地址

🌟分页机制的思想🌟:通过映射,可以使连续的线性地址与任意物理内存地址相关联,逻辑 上连续的线性地址其对应的物理地址可以不连续。


分页机制的作用:

  • 线性地址代替物理地址
  • 用大小相等的页代替大小不等的段

分页所采用的数据结构就是页表

分页机制本质上是将大小不同的大内存段拆分成大小相等的小内存块

页表:页表就是个N行1列的表格,页表中的每一行(只有一个单元格)称为页表项,页表项大小为4Byte,页表项的作用是存储内存物理地址

现在我们来构想一下页表项数量与页表项大小

如果说一个进程拥有4GB虚拟内存,那么页表就要能表达4GB的地址,如果我们逐字节对应的页表中的一个页表项,一个页表项中存储32位地址,那么4GB*4=16GB,一个页表就占了4GB,这属实是太离谱了


我们再来仔细思考一下,我们的线性地址是32位地址对吧,那我们把32位地址给分成两部分:页表项数量部分,页大小部分。于是我们便有了下面这张图

 这样子,页表项数量是 2^20次方,一个页的大小是2^12次方(4KB),正好就是4GB

注意:页和页表项不是一个东西,页是地址空间的计量单位,并不是专属物理地址或线性地址,你可以理解页即为内存块,分页即把一个内存分为n个大小为m的内存块

理解小卫士:

举一个做核酸的例子,一个学校有1000个人要做核酸,如果一个人一个试管,那就需要1000个试官,这就很浪费资源对不对,于是我们分成50个试管,一个试管20个人,这样就大大节约了资源(不要纠结为什么不全都放在一个试管里面,那做了和没做一样)。


1个试管20个人 == 页的大小为4KB

试管+编号 == 页表项+物理地址

试管盒 == 页表

1000个人 == 内存大小


不知道这样是不是稍微好理解一点了,如果还不理解我们接着往下看你就肯定理解了。

于是我们便有了页表的一个大概模型,页表有2^20个页表项,页表项存访32位的物理地址,每个页表项存储的物理地址相隔4KB,整个页是4MB


一级页表

由上图可知, 32位线性地址组装后,前20位用来索引页表中的页表项由于页表项地址间隔为4,所以要乘以4然后+物理页地址(cr3寄存器中读取)寻找到对应的页表项取出物理地址+后12位偏移地址

整个寻址是由页部件转换处理自动完成的


二级页表

刚刚讲的是一级页表,现在主流计算机多级页表了,为了循序渐进,我们还是来讲讲二级页表。

我是饭圈博主,所以讲二级页表前我们先来踩一捧一。

① 一级页表全部填满需要4MB大小

② 在启用一级页表时要提前填满,因为操作系统要用高1GB,用户用低3GB

③ 进程多了的话,页表多了,内存占用就大了

二级页表则把1M的页表项分成1K个页表,1个页表里面1K个页表项,1K个页表有专门的页表目录存储。

 寻址:高10位页目录索引(PDE),中10位页表索引(PTE),低12位偏移地址


页表项和页目录项

  •  P:该页是否存在物理内存中
  • RW:1为可读可写,0为可读不可泄
  • US:1为普通用户(0,1,2,3)特权的程序都可以访问,0为超级用户(只允许0,1,2)特权的程序访问
  • PWT:页级通写位,1表示该页采用通写的方式代表他既是普通内存还是高速缓存,可以改善页的访问效率,我们置为0即可
  • PCD:业绩高速缓存禁止位,1代表启用,0代表禁止
  • A:和段描述符的一样,是计数的。
  • Dirty:脏页位,如果此页面执行写操作时,就要置为1,只对页表项有用
  • PAT:页属性位,置为0
  • G:全局位,由于内存地址转换十分耗时,为了提高速度,可以将虚拟地址和物理地址转换结果存放到TLB(缓存地址转换结果的高速缓存)中 
  • AVL:和段描述符一样

用户进程与操作系统

我们之前有说过,没有操作系统的进程不是一个好进程,进程整个功能的完整运行需要依赖操作系统,我们要访问操作系统的功能就需要访问操作系统的内存。

因此我们要将操作系统的内存写入进程的虚拟页表中,0~3GB为用户进程,3GB~4GB是操作系统


 三,开始冻手写代码

打开分页的三个步骤

① 准备好页目录表和页表

② 将页表地址填入控制寄存器cr3

③ 寄存器cr0的PG位置1

首先我们页目录和页表的布局如下:

 Loader.S

[bits 32]
p_mode_start:
   mov ax, SELECTOR_DATA
   mov ds, ax
   mov es, ax
   mov ss, ax
   mov esp,LOADER_STACK_TOP
   mov ax, SELECTOR_VIDEO
   mov gs, ax

   call setup_page

   sgdt [gdt_ptr] ;由于启用分页,所以将gdt的地址和偏移地址暂且存放在内存gdt_ptr位置上
   mov ebx,[gdt_ptr+2]; gdt_ptr前2字节为偏移地址,后4字节为基址,得到gdt基址
   
   or dword [ebx+0x18+4],0xc0000000 ;由于显存将放到内核态所以其基址将改变要给其或上内核虚拟基址0xc0000000,显存是第三个段描述符,一个段描述符8字节,所以是0x18,4代表是段基址的最高位
   add dword [gdt_ptr +2],0xc0000000

   add esp,0xc0000000

   ;把页目录地址赋给cr3
   mov eax,PAGE_DIR_TABLE_POS
   mov cr3,eax

   ;打开cr0的pg位(31w位)
   mov eax,cr0
   or dword eax,0x80000000
   mov cr0,eax

   lgdt [gdt_ptr]

   mov byte [gs:0x140], 'P'
   mov byte [gs:0x141], 0xA4

   jmp $

;清空0x1000 页目录项的空间
setup_page:
	mov ecx,4096
	mov esi,0
.clear_page_dir:
	mov byte [PAGE_DIR_TABLE_POS+esi],0
	inc esi
	loop .clear_page_dir

;创建页目录项
.create_pde:
	mov eax,PAGE_DIR_TABLE_POS
	add eax,0x1000        ;4kb
	mov ebx,eax			  ;内核的物理地址0~0xffff	
	or eax,PG_US_U | PG_RW_W | PG_P
	mov [PAGE_DIR_TABLE_POS+0x0],eax ;创建为第0个页目录项,指向0x101000虚拟地址,因为还没开始分页时,loader的线性地址要和虚拟地址一致
	mov [PAGE_DIR_TABLE_POS+0xc00],eax ;创建第768(c00/4)个页目录项,这个是因为我们规定了虚拟地址高1GB的地方是存储内核的,0xc0000000的高10位是0xc00
	sub eax,0x1000
	mov [PAGE_DIR_TABLE_POS+4092],eax ;指向页目录自己


;初始化第0页目录的页表,由于我们的内核就1MB不到,所以页表项就放256(256*4=1024)个
	mov ecx,256
	mov esi,0
	mov edx,PG_US_U | PG_RW_W | PG_P
.create_pte:
	mov [ebx+esi*4],edx
	add edx,4096 ;4KB
	inc esi
	loop .create_pte


;实现内核共享,将高1G的页目录项全部初始化,为的就是以后内核共享时无需临时加载,全部一起加载进来
	mov eax,PAGE_DIR_TABLE_POS
	add eax,0x2000
	or eax,PG_US_U | PG_RW_W |PG_P;
	mov eax,PAGE_DIR_TABLE_POS
	mov ecx,254
	mov esi,769


.create_kernel_pde:
	mov [ebx+esi*4],eax
	inc esi
	add eax,0x1000
	loop .create_kernel_pde
	ret

代码有点复杂,如果有不懂的可以留言问我

最后我们的图像也是顺利展现出来

 记不记得我们在代码里面说过我们把显存的段描述符放到了内核中,我们可以先ctrl+c暂停然后使用如下命令查看

info gdt

 可以看到显存段描述符基址确实被加载到0xc00b8000虚拟地址了,我们的分页生效了


四,虚拟地址访问页表

今天的学习量真是大呢,如果你实在累了可以先休息休息,享受一下运行成功后的成果,如果你还有精力,我们再一起把后面的咬咬牙给啃完。

有人可能会问,页表是动态生成动态减少的,申请内存需要增加,释放内存需要减少,那我虚拟地址要经过页表的转换,那我怎么通过虚拟地址访问到页表的物理地址并进行修改呢

对于这个问题,我们代码已经做了一个解答,还记不记得我们有一行代码,是让最后一个页目录项指向自己,这行奇怪的代码就是关键!

mov [PAGE_DIR_TABLE_POS+4092],eax ;指向页目录自己

首先我们现在bochs里面输入 info tab 即可看到页表地址映射

 前两个没什么问题,分别是 第0项和第768项,这也说明我们的代码没问题,但是后面这三项是什么东东?其实正是因为我们有了上面那行代码才有了这三项结果,我们一个个分析。

0x00000000ffc00000-0x00000000ffc00fff -> 0x000000101000-0x000000101fff

这一行奇怪的代码,首先从前面的虚拟地址我们可以分析出一下数据

① 其高10位是1023 说明是页目录最后一项(获得地址就是页目录表的地址,跳转到页目录)

② 中间10位是0x00 说明是(注意上述操作已跳转)页目录的第一个页目录项 (也就是我们一开始的第0页目录项)

③ 后面12位就是页表的偏移地址

不知道你开始察觉没有,高10位直接定位到页目录本身的地址,而中间10位则是页目录中页表的物理地址,后12位则是页表内的物理地址,如果你还有疑虑可以试着推推最后三项。

所以用虚拟地址访问页表的三种方法总结下来就是

  • 页目录访问:高20位如果为0xfffff 低12位为0x000,这就是页目录本身地址
  • 页访问:高20位为0xfffff,低12为xxx,这就可以访问页地址
  • 页项访问:高10为0x3ff,中10位为页目录项索引,低12位为页项索引

五,快表TLB

其实说白了TLB你可以理解就是我们平常写web应用中,前端和数据库之间的缓存,用于存储物理地址和虚拟地址的映射。

TLB是在处理器的高速缓存中,俗称快表

但是由于内存地址的特殊性,以及缓存协同问题,TLB往往需要开发人员自己去更新,更新方法有两种。

① cr3寄存器写入数据时,更新整个TLB

② 使用invlpg指令更新TLB的某个条目

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值