吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 4317|回复: 15
收起左侧

[系统底层] 从0到-1写一个操作系统-0x06-实现内存分页

  [复制链接]
peiwithhao 发表于 2023-1-7 15:20
本帖最后由 peiwithhao 于 2023-3-2 14:06 编辑

这里写个往期推荐,这样可以来回跳跃(狗头
0x00-环境准备
0x01-BIOS以及MBR
0x02-MBR支持显卡
0x03-MBR操作硬盘以及Loader
0x04-进入保护模式
0x05-内存容量检测
0x06-实现内存分页

0x00 基础知识

今天我们来讲解分页的知识,这段知识在学习操作系统和计算机组成原理的时候都是个重要考点哩,但是其中也只讲了些计算方法,今天咱们就来把他实现。

1.虚拟地址

在之前我们检测了咱们的内存地址,发现只有512MB,但是我们的操作系统是基于32位的,所以按道理来说最大寻址空间应该是4GB,难道说我们程序若是放到高于512MB就无法运行了吗,事实上咱们这个4GB寻址范围和内存地址512MB并不是得一一对应,这里直接给大家讲结论,若是想要知道细节的话只需要去翻翻计算机组成原理这本书就行了。也就是说我们的程序最开始是希望加载在一个4GB的广阔大地中的,而加载到哪儿是我们链接器来决定的,实际上也就是程序员自行抉择。但是真正物理机上并不存4GB,所以我们将整片程序中的一部分称为页,然后我们按照自己的需要映射到真实的物理内存当中,此时我们并不需要一次性全部放到物理内存当中。

此时形成的效果就是,咱们在咱们自以为的空间里面是连续的,,而映射到物理内存中是由操作系统决定的,此时就并不一定连续,但我们程序进行的一系列操作都是基于我们自认为的虚拟空间的,操作系统只需要负责映射就行。下面为大家解释一级u页表。

2.一级页表

在我们没有使用分页机制的时候,我们采用的仍然是系统自带的分段方式,也就是依靠段地址:段内偏移来进行地址选择,且该地址仍然是物理地址,寻址过程如图:

而我们开启分页机制之后,我们程序员所使用的地址就变为了虚拟地址,然后我们的寻址过程就变成如下图:

我们使用4GB虚拟内存,首先会将其分为大小一致的一堆页,而这个页面大小一般定为4KB,也就是说在32位地址中,高20位为页地址,而低12位为页内地址.在我们本来的程序中是进行了分段的操作,但是载入物理内存的过程中就会进行分页而打乱顺序,此时就需要用到页表,也表中保存的也就是一个个映射,保证你按顺序访问虚拟地址,他会给出想对应的物理地址。

所以我们就需要一个页表来建立这层映射关系,也表中每个页表项就保存着一个真实物理地址。但是光有页表还不行,我们还需要找得到他,所以我们还需要一个额外的寄存器来保存这个页表在物理地址中的位置。这个寄存器就是控制寄存器CR3.
这里为了防止大家看蒙,我来梳理一下寻址过程:

  1. 首先我们拥有想要访问的虚拟地址
  2. 此时我们取虚拟地址的高20位,这就是页表相对偏移
  3. 我们找到cr3寄存器中的页表首地址,然后加上我们刚刚取到的偏移再乘上4,(这是因为一个页表项占4字节),我们访问该物理地址就会得到另一个物理地址
  4. 刚刚从页表当中得到的物理地址是我们真正想要访问的页地址,此时我们再加上虚拟地址的低12位,也就是页内地址,这样我们就得到了我们真正想访问的地址了。

3.二级页表

二级页表同一级页表类似,就是中间又加了一层而已,这里提出二级页表的原因是由于最高级页表必须在内存,但是我们若只采用一级的话,常驻内存的页表会十分巨大,所以我们需要再加上一级页表(这里应该被叫做页目录)用来减少内存消耗,我们在一级页表是采用了高20位来表示页表项的便宜,这里我们二级页表将其对半分开,高10位用作页目录偏移,剩下的10位用作页表偏移。分配情况如下图所示:

其中页目录项之于页目录,页表项之于页表,就如同段描述符之于全局描述表一样,下面给出这俩的具体结构:

这里我们可以看到并不是说表项全是地址,他还有很多别的标志位,其中表项保存地址只用了20位,但为什么不是32位呢,因为咱们只需要高20位,也就是页的首地址,而页都是以0x1000为单位的,所以低12位肯定为0,就不需要保存啦,接下来介绍每个标志位的含义:

  • P位,Present,类似段描述符,表示是否存在,为1表示存在于物理内存
  • RW, Read/Write,读写位,为1则表示可读写
  • US, User/Supervisor,普通/超级用户位,若为1则表示处于用户级,任意级别(0,1,2,3)都可以使用此页,当为0的时候表示超级用户位,特权级别3不可访问,而(0,1,2)可以访问此页
  • PWT,  Page-Level Write-Through,意为页级通写位,若为1表示采用通写方式,表示该页不仅在内存,还存在在高速缓存。我们在这里默认置0
  • PCD, Page-Level Cache-disable,意为页级高速缓存禁用位,1表示该页启用高速缓存,0为禁用,我们这里默认置0
  • A, Access,意为访问位,若为1则表示该页已经被CPU访问过了,这里是由CPU赋值的
  • D, Dirty,脏位,表示该页已经被修改。此项仅对于页表项有效,对目录项不发生改变
  • PAT, Page Attribute Table, 意为页属性表位,这位比较复杂,我们不涉及,直接置0
  • G, Global,全局位,用来指定该位是否为全局页,为1表示是,为0表示不是。若为全局页,则该页会在TLB中一直保存(快表)。顺便这里加个知识点:清空TLB有两种方式,一种是invlpg指令针对单独虚拟地址条目进行清理,还有一种是修改CR3寄存器,这将直接清空TLB
  • AVL, Available,可用位,这里咱们不需要管

若要启用分页机制,我们需要执行以下步骤:

  1. 准备好页目录表和页表
  2. 将页表地址写入CR3控制寄存器
  3. 将CR0的PG位置1

其中第二步我们需要了解一个CR3寄存器的结构,大家不要急:

CR3寄存器被用来存放页表的首地址,所以他还有个更响亮的名字:页目录基址寄存器(Page Directory Base Register,PDBR)


执行开启页表的最后一步也就是将CR0的PG位置1,这里为1就表示真正意义上采用了内存分页,而之前没设置的时候都是采用分段机制

0x01 启用分页机制实战

这里我们首先在boot.inc文件中添加咱们的页表相关属性:

PAGE_DIR_TABLE_POS equ 0x100000     ;页目录存放首址
;------------ 页表以及相关属性 -------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_U equ 000b
PG_US_S equ 100b

这里的标识同之前咱们定义段描述符类似,就不过多赘述了。
然后我们到loader这儿构造页目录以及页表,代码如下:

;-------------  创建页目录以及页表 ------------
setup_page:
;先把页目录占用的空间逐字清0
  mov ecx, 4096     ;表示4K
  mov esi, 0
.clear_page_dir:
  mov byte [PAGE_DIR_TABLE_POS + esi], 0
  inc esi
  loop .clear_page_dir

;开始创建页目录项(PDE)
.create_pde:    ;创建Page Directory Entry
  mov eax, PAGE_DIR_TABLE_POS
  add eax, 0x1000   ;此时eax为第一个页表的位置以及属性
  mov ebx, eax      ;此处为ebx赋值, 是为.create_pte做准备, ebx为基址
;下面将页目录项0和0xc00都存为第一个页表的地址,每个页表表示4MB内存
;这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表
;这是为将地址映射为内核地址做准备
  or eax, PG_US_U | PG_RW_W | PG_P
  ;页目录项的属性RW和P位为1,US为1表示用户属性,所有特权级都可以访问
  mov [PAGE_DIR_TABLE_POS + 0x0], eax       ;第一个目录项
    ;在页目录表中的地一个目录项写入第一个页表的位置(0x101000)及属性
  mov [PAGE_DIR_TABLE_POS + 0xc00], eax     ;一个页表项占用4字节
  ;0xc00表示第768个页表占用的页表项,0xc00以上的目录项用于内核空间,768用16进制表示为0x300,这个值再加就是刚好属于内核进程了
  ;也就是页表的0xc0000000~0xffffffff供给1G属于内核,0x0~0xbfffffff共计3G属于用户进程
  sub eax, 0x1000
  mov [PAGE_DIR_TABLE_POS + 4092], eax      ;使得最后一个目录项地址指向页目录表自己的地址

;开始创建页表项(PTE)
  mov ecx, 256                  ;1M低端内存/每页大小4K = 256
  mov esi, 0                    ;该页表用来分配0x0~0x3fffff的物理页,也就是虚拟地址0x0~0x3fffff和虚拟地址0xc0000000~0xc03fffff对应的物理页,我们现在只用了低1MB,所以此时虚拟地址是等于物理地址的
  mov edx, PG_US_U | PG_RW_W | PG_P     ;同上面类似
.create_pte:    ;创建Page Table Entry
  mov [ebx + esi*4], edx    ;此时ebx为第一个页表的首地址,这在上面咱们已经赋值了
  add edx, 4096
  inc esi
  loop .create_pte

;创建内核其他页面的PDE
  mov eax, PAGE_DIR_TABLE_POS
  add eax, 0x2000       ;此时eax为第二个页表的位置
  or eax, PG_US_U | PG_RW_W | PG_P  ;同上
  mov ebx, PAGE_DIR_TABLE_POS
  mov ecx, 254          ;范围为第769~1022的所有页目录项数量
  mov esi, 769
.create_kernel_pde:
  mov [ebx+esi*4], eax
  inc esi
  add eax, 0x1000
  loop .create_kernel_pde
  ret

在上面的注释解释的已经十分清楚了,上述代码做的事我来简述一下,大家就可以更加顺畅的理解。首先咱们是在0x100000的物理地址构建页目录,该页目录后面咱们就紧挨着存放页表,下面给出图片示意

而由于距离页目录偏移0xc00的地方之后,就属于了高1G,这里可以通过简单的计算得出来,0 + 0xc00/4 * 2^22 = 0xc0000000,这里刚好可以得出咱们内核存放的最低地址。
所以说我们首先将页目录偏移0,以及偏移0xc00的地方填入我们的地一个页表地址,由于咱们页目录和第i一个页表挨着存放,所以第0个页表地址应该为0x101000,如下图:

然后我们就构建页表项,此时我们虽然有一整页,但是我们只需要分配低1MB内存即可,因为咱们的内核就只需要用到这1MB而已,他并不是很大,此时我们沿着物理地址从0开始填入页表项,这里注意由于咱们的目录项第0位和第0xc00偏移的目录项都指向了这第0个页表,所以他俩这里的地址映射到的是同一快内存。
再之后我们就可以创建内核的其他目录项,这里的意义我么留做以后讲解,现在我们就是挨个填上页表地址而已。


以上就是页目录以及页表的初始构建,接下来我们使用来看看:
还记得我们上次loader运行到了jmp $么,我们接着往下来编写

;创建页目录及页表并初始化页内存位图
  call setup_page

;要将描述符表地址及偏移量写入内存gdt_ptr,一会儿用新地址重新加载
  sgdt [gdt_ptr]      ;存储到原来gdt所有的位置

;将gdt描述符中视频段描述符中的段基址+0xc0000000
  mov ebx, [gdt_ptr + 2]        ;加上2是因为gdt_ptr的低2字节是偏移量,高四字节才是GDT地址
  or dword [ebx + 0x18 + 4], 0xc0000000   ;视频段是第3个段描述符,每个描述符是8字节,故为0x18,这里再加上4是因为咱们要的是高4字节,这里或的含义就类似与加,因为目前最高位肯定为0
;段描述符高四字节的最高位是段基址的第31~24位

;将gdt的基址加上0xc0000000使其成为内核所在的高地址
  add dword [gdt_ptr + 2], 0xc0000000

  add esp, 0xc0000000   ;将栈指针同样映射到内核地址

  ;把也目录地址附给cr3
  mov eax, PAGE_DIR_TABLE_POS
  mov cr3, eax

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

  ;在开启分页后,用gdt新的地址重新加载
  lgdt [gdt_ptr]    ;重新加载

  mov byte [gs:160], 'V'    ;视频段段基址已经被更新,用字符V表示vitual addr

  jmp $

上面的注释十分详细,这里我们再来看看结果,

可以看到确实打印的是V了,这证明我们在修改视频段描述符后他正确通过虚拟地址转换为了物理地址然后实现了打印,这里我们可以再来看看gdt的内容是否变化:

这里清楚的看到咱们的gdt初始地址已经存在与内核范围内了,VIDEO 描述符同样。

0x02 使用虚拟地址访问页表

大伙不要掉以轻心,今天的任务并没有完成,由于在我们程序运行的过程中,免不了会进行内存申请,或者说因管理内存而选择释放块,所以我们的页表应该是一个动态的概念,我们的页表应该随着我们的要求来增加或者说删减,要实现这个功能我们首先就得使用虚拟地址访问到页表,在前面实现内存分页的代码中,我们将页目录的最后一项保存为页目录的首地址:

mov [PAGE_DIR_TABLE_POS + 4092], eax      ;使得最后一个目录项地址指向页目录表自己的地址

这里我们来先看看目前咱们程序的虚拟地址以及物理地址的映射关系:

这里有五对映射,是不是有点奇怪,难道说刚刚我们的代码建立了五对映射吗,我没注意到啊根本,实际上不是说我们自主构建的,而是由于访问机制的问题,让程序以为咱们构造了五对映射,这里我们依次讲解:

1.0x00000000~0x000fffff

这段虚拟内存映射到了咱们物理地址的首1MB位置,这与我们上面代码构造的一致

2.0xc0000000~0xc00fffff

同上,这是我们代码自己映射的,真真正在是咱们创造的

3.0xffc00000~0xffc00fff

这里就有点噶住了,为什么这里也会有个映射呢,不要急,我们取0xffc00000的高10位来查看发现为全1,这就说明我们访问此虚拟地址的时候访问的是最后一个页目录项,而最后一个页目录项咱们保存的并不是页表地址,而是咱们页目录的地址,这样我们机器就会将页目录看成一个页表来进行理解,而此时我们页目录最后一项保存的是0x101000,所以我们这段映射也会映射到0x101000~0x101fff

4.0xfff00000~0xfff00fff

这里的地址也比较特殊,取首地址0xfff00000来进行分析,首先高10位全1表示该虚拟地址在页目录中应对应的是最后一个页目录项,目前为止同上面是一致的,然后我们取中间10位发现其为0x300,这里是不是很眼熟,没错,在页目录项中第0x300表项也就是偏移为0xc00的地方,这里我们之前将其的地址也改为了第0个页表地址,所以此时这段映射会映射到0x101000~0x101fff

5.0xfffff000~0xffffffff

还是拿首地址来分析,0xfffff000这里我们可以观察到,高10位和中间10位都全为1,所以我们首先会查看页目录最后一项,然后将页目录表当作页表看待,然后我们再次访问页目录最后一项,我们以为他是页表最后一项,这里保存的仍然是0x100000,所以我们这里的映射是0x100000~0x100fff

这里我们可以得出结论,当我们若是虚拟地址高20位全为1时(也就是说上面的第五类映射)我们就能够访问到我们的页目录的物理地址。因此最后总结一下:

  1. 获取页目录地址:让虚拟地址高20位为0xFFFFF,低12位为0x000,也即0xfffff000,这也是页目录表中第0个页目录项自身的物理地址
  2. 访问页目录中的页目录项,也就是获取页表物理地址:要使虚拟地址为0xfffffxxx,其中xxx为偏移。
  3. 访问页表中的页表项:使得虚拟地址高10位为0x3ff,目的是获取页目录表的物理地址。中间10位为页表索引。低12位为页表内的偏移。

0x03 了解内核

终于到了这一步了吗,要来了吗。走到这里我们历经千辛万苦(有点夸张,但是能坚持到这儿已经正在往成功迈进了),接下来便是真正意义上的操作系统了--内核。
想当初我就是PWN学到kernel PWN的部分的时候觉得内核特别有趣,但是有的时候解题也是一知半解,就比如说一些特权级啥的都不是蛮理解,所以我就想着要不自己跟着实现一个操作系统算了,这下总不会啥也不懂了吧。
回到正题,实际上内核也是一段程序,他与咱们日常生活中的程序其实差别并没有很大,只不过说是功能差异而已,这里有人问了,既然都一样,那我写个hello world也可以?没错,还真可以
直接给出当前内核代码(开始用C了),这里希望大家同我保持一致,在当前目录创建一个kernel的文件夹,然后创建main.c文件。

int main(void){
  while(1);
  return 0;
}

然后我们使用gcc进行编译

gcc -c -o kernel/main.o kernel/main.c

这里我们选择先生成目标文件而不是可执行文件,针对于这两者之间的差距我这里推荐一本好书,那就是《程序员的自我修养——链接,装载与库》,不得不说这本书写的是真好,之前学PWN也是看着这个书才摸清了点门路,作为程序员我觉得这本书里面的知识是必须得知晓的。
我们可以使用file命令来查看文件的属性,这里也可以看到是relocatable,表示可重定位文件

而可重定位文件中的符号还没“定位”,我们可以通过nm命令来查看文件中的符号以及地址情况

可以看到该文件确实只包含一个符号,那就是main,且地址为0
这里我们不直接生成可执行文件是因为我们目前还需要自己来为其设置虚拟初始地址,这里我们使用linux自带的链接器ld进行链接

ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin

这里给大家解释一下各参数的含义:

  • -Ttext:指起始虚拟地址为0xc0001500
  • -o:指定输出文件
  • -e:指定程序的起始地址

这里的e参数需要特别解释一下,我们先去掉这个参数来看效果

这里报出一个错误就是找不到入口符号_start
一个程序总该有个入口地址,这个地址表示该程序从哪里开始执行,所以这个-e参数就是指定程序从哪儿开始执行,在这里由于我们的程序过于简单,没有_start符号,而链接器一般入口地址是给的_start所以这里会报错,因此我们在这儿将入口地址设置为main符号就可以了。(或者说你把main函数名换成_start也行,这样程序中就只有一个_start符号了)
我们用file命令来查看生成的kernel.bin文件发现成功生成了可执行文件(excutable)

今天内核就到这儿,下一篇讲解简单内核的编写以及elf文件结构

0x04 总结

今天的页目录和页表搞懂之后十分的清爽,这里当然是也结合了之前听课的知识了,但是之前都是计算十分无趣,今天算是又重新弄懂了一遍。
本次我的所有源码已在github上成功上传,分支名定为Page,欢迎各位指教

传送门

免费评分

参与人数 11吾爱币 +17 热心值 +9 收起 理由
kanglehao + 1 + 1 谢谢@Thanks!
huRD + 1 + 1 用心讨论,共获提升!
willJ + 7 + 1 欢迎分析讨论交流,吾爱破解论坛有你更精彩!
yanecc + 1 + 1 热心回复!
wuboxun + 1 + 1 谢谢@Thanks!
Fan2115 + 1 + 1 鼓励转贴优秀软件安全工具和文档!
没有鸡哪来的蛋 + 1 用心讨论,共获提升!
accor + 1 + 1 用心讨论,共获提升!
wonly211 + 1 + 1 非常不错,请继续分享,谢谢!
熊猫拍板砖 + 1 + 1 用心讨论,共获提升!
lordship + 1 谢谢@Thanks!

查看全部评分

本帖被以下淘专辑推荐:

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

pj_soup 发表于 2023-2-28 09:24
今日完成分页,感觉分页这块和全局描述符还很重要不然后面特权级比较难理解。在这里卡了很久,才能弄他们的关系和原理比较麻烦的是地址映射的计算,对于我这种数字不敏感的人,总是结算错
 楼主| peiwithhao 发表于 2023-1-7 15:49
yiwozhutou 发表于 2023-1-7 15:46
那个图像那么牛逼 怎么跟自己画的一样啊

书上截的图,自己画的属实难看
Clown4730 发表于 2023-1-7 15:25
yiwozhutou 发表于 2023-1-7 15:46
那个图像那么牛逼 怎么跟自己画的一样啊
urdarling 发表于 2023-1-7 15:55
这么复杂啊 那看来鸿蒙系统果然不是一般小公司能做出来的,没有几千亿费用根本搞不出来
lordship 发表于 2023-1-7 18:18
开始学习
zhs321921 发表于 2023-1-7 19:43
有用,就是太难了
wonly211 发表于 2023-1-7 23:30
非常不错,请继续分享,谢谢!
xmp788 发表于 2023-1-8 14:58
我要能有这本事,微软那帮人都会来中国争着给我提鞋
a725 发表于 2023-1-9 08:50
感谢分享!
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - LCG - LSG ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2024-12-21 23:54

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表