第5章 保护模式进阶,向内核迈进

获取物理内存容量

利用 BIOS 中断 0x15 子功能 0xe820 获取内存

BIOS 中断 0x15 的子功能 0xE820 能够获取系统的内存布局,由于系统内存各部分的类型属性不同,BIOS 就按照类型属性来划分这片系统内存,所以这种查询呈迭代式,每次 BIOS 只返回一种类型的内存信息,直到将所有内存类型返回完毕。子功能 0xE820 的强大之处是返回的内存信息较丰富,包括多个属性字段,所以需要一种格式结构来组织这些数据。内存信息的内容是用地址范围描述符来描述的,用于存储这种描述符的结构称之为地址范围描述符( Address Range Descriptor Structure, ARDS )
image.png
image.png
image.png

image.png

利用 BIOS 中断 0x15 子功能 0xe801 获取内存

此方法虽然简单,但功能也不强大,最大只能识别 4GB 内存
image.png

利用 BIOS 中断 0x15 子功能 0x88 获取内存

该方法使用最简单,但功能也最简单,简单到只能识别最大 64GB的内存
image.png

实战内存容量检测

   %include "boot.inc"
   section loader vstart=LOADER_BASE_ADDR
   LOADER_STACK_TOP equ LOADER_BASE_ADDR
   
;构建gdt及其内部的描述符
   GDT_BASE:   dd    0x00000000 
	       dd    0x00000000

   CODE_DESC:  dd    0x0000FFFF 
	       dd    DESC_CODE_HIGH4

   DATA_STACK_DESC:  dd    0x0000FFFF
		     dd    DESC_DATA_HIGH4

   VIDEO_DESC: dd    0x80000007	       ; limit=(0xbffff-0xb8000)/4k=0x7
	       dd    DESC_VIDEO_HIGH4  ; 此时dpl为0

   GDT_SIZE   equ   $ - GDT_BASE
   GDT_LIMIT   equ   GDT_SIZE -	1 
   times 60 dq 0					 ; 此处预留60个描述符的空位(slot)
   SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0         ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
   SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0	 ; 同上
   SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0	 ; 同上 

   ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
   ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
   ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
   total_mem_bytes dd 0					 
   ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

   ;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
   gdt_ptr  dw  GDT_LIMIT 
	    dd  GDT_BASE

   ;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,256字节
   ards_buf times 244 db 0
   ards_nr dw 0		      ;用于记录ards结构体数量

   loader_start:
   
;-------  int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局  -------

   xor ebx, ebx		      ;第一次调用时,ebx值要为0
   mov edx, 0x534d4150	      ;edx只赋值一次,循环体中不会改变
   mov di, ards_buf	      ;ards结构缓冲区
.e820_mem_get_loop:	      ;循环获取每个ARDS内存范围描述结构
   mov eax, 0x0000e820	      ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
   mov ecx, 20		      ;ARDS地址范围描述符结构大小是20字节
   int 0x15
   jc .e820_failed_so_try_e801   ;若cf位为1则有错误发生,尝试0xe801子功能
   add di, cx		      ;使di增加20字节指向缓冲区中新的ARDS结构位置
   inc word [ards_nr]	      ;记录ARDS数量
   cmp ebx, 0		      ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
   jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
   mov cx, [ards_nr]	      ;遍历每一个ARDS结构体,循环次数是ARDS的数量
   mov ebx, ards_buf 
   xor edx, edx		      ;edx为最大的内存容量,在此先清0
.find_max_mem_area:	      ;无须判断type是否为1,最大的内存块一定是可被使用
   mov eax, [ebx]	      ;base_add_low
   add eax, [ebx+8]	      ;length_low
   add ebx, 20		      ;指向缓冲区中下一个ARDS结构
   cmp edx, eax		      ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
   jge .next_ards
   mov edx, eax		      ;edx为总内存大小
.next_ards:
   loop .find_max_mem_area
   jmp .mem_get_ok

;------  int 15h ax = E801h 获取内存大小,最大支持4G  ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
   mov ax,0xe801
   int 0x15
   jc .e801_failed_so_try88   ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
   mov cx,0x400	     ;cx和ax值一样,cx用做乘数
   mul cx 
   shl edx,16
   and eax,0x0000FFFF
   or edx,eax
   add edx, 0x100000 ;ax只是15MB,故要加1MB
   mov esi,edx	     ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
   xor eax,eax
   mov ax,bx		
   mov ecx, 0x10000	;0x10000十进制为64KB
   mul ecx		;32位乘法,默认的被乘数是eax,积为64,32位存入edx,32位存入eax.
   add esi,eax		;由于此方法只能测出4G以内的内存,32位eax足够了,edx肯定为0,只加eax便可
   mov edx,esi		;edx为总内存大小
   jmp .mem_get_ok

;-----------------  int 15h ah = 0x88 获取内存大小,只能获取64M之内  ----------
.e801_failed_so_try88: 
   ;int 15后,ax存入的是以kb为单位的内存容量
   mov  ah, 0x88
   int  0x15
   jc .error_hlt
   and eax,0x0000FFFF
      
   ;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
   mov cx, 0x400     ;0x400等于1024,将ax中的内存容量换为以byte为单位
   mul cx
   shl edx, 16	     ;把dx移到高16or edx, eax	     ;把积的低16位组合到edx,32位的积
   add edx,0x100000  ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
   mov [total_mem_bytes], edx	 ;将内存换为byte单位后存入total_mem_bytes处。


;-----------------   准备进入保护模式   -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

   ;-----------------  打开A20  ----------------
   in al,0x92
   or al,0000_0010B
   out 0x92,al

   ;-----------------  加载GDT  ----------------
   lgdt [gdt_ptr]

   ;-----------------  cr0第0位置1  ----------------
   mov eax, cr0
   or eax, 0x00000001
   mov cr0, eax

   jmp dword SELECTOR_CODE:p_mode_start	     ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
					     ; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt:		      ;出错则挂起
   hlt

[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

   mov byte [gs:160], 'P'

   jmp $

启用内存分页机制,畅游虚拟内存

内存为什么要分页?

防止内存碎片:
image.png

内存段是怎样被换出的?

在保护模式下,段描述符是内存段的身份证。 CPU 在引用一个段时,都要先查看段描述符。很多时候,段描述符存在于描述符表中( GDT 或 LDT),但与此对应的段并不在内存中,也就是说, CPU 允许在描述符表中已注册的段不在内存中存在,这就是它提供给软件使用的策略,我们利用它实现段式内存管理。如果该描述符中的 P 位为 1 ,表示该段在内存中存在。访问过该段后, CPU 将段描述符中的 A 位置 1,表示近来刚访问过该段。相反,如果 P 位为 0,说明内存中并不存在该段,这时候 CPU 将会抛出个 NP (段不存在)异常,转而去执行中断描述符表中 NP 异常对应的中断处理程序,此中断处理程序是操作系统负责提供的,该程序的工作是将相应的段从外存(比如硬盘)中载入到内存,并将段描述符的 P 位置 1 ,中断处理函数结束后返回, CPU 重复执行这个检查,继续查看该段描述符的 P 位,此时已经为 1 了,在检查通过后,将段描述符的 A 位置 1 。
以上是 CPU 加载内存段的过程,内存段是何时移出到外存上的呢?
段描述符的 A 位由 CPU 置 1,但清 0 工作可是由操作系统来完成的。 此位干吗用的呢?如果仅仅用来表示该段被访问过,这也意义不大啊。其实这正是软件和硬件相互配合的体现,操作系统每发现该位为 1 后就将该位清 0,这样一来,在一个周期内统计该位为 1 的次数就知道该段的使用频率了,从而可以找出使用频率最低的段。当物理内存不足时,可以将使用频率最低的段换出到硬盘,以腾出内存空间给新的进程。当段被换出到硬盘后,操作系统将该段描述符的 P 位置 0。当下次这个进程上 CPU 运行后,如果访问了这个段,这样程序流就回到了刚开始 CPU 检查出 P 位为 0、紧接着抛出异常、执行操作系统中断处理程序、换入内存段的循环。

一级页表

分页机制其实是建立在分段机制之上的。
image.png
分页机制的作用有两方面 。
• 将线性地址转换成物理地址。
• 用大小相等的页代替大小不等的段。
image.png
在此之前,大家要知道两件事。
(1)分页机制打开前要将页表地址加载到控制寄存器 cr3 中,这是启用分页机制的先决条件之一,在介绍二级页表时会细说。所以,在打开分页机制前加载到寄存器 cr3 中的是页表的物理地址,页表中页表项的地址自然也是物理地址了。
(2)虽然内存分页机制的作用是将虚拟地址转换成物理地址,但其转换过程相当于在关闭分页机制下进行,过程中所涉及到的页表及页表项的寻址,它们的地址都被 CPU 当作最终的物理地址(本来也是物理地址)直接送上地址总线,不会被分页机制再次转换(否则会递归转换下去)。
地址转换过程原理如下:
一个页表项对应一个页,所以,用线性地址的高 20 位作为页表项的索引,每个页表项要占用 4 字节大小,所以这高 20 位的索引乘以 4 后才是该页表项相对于页表物理地址的字节偏移量。用 cr3 寄存器中的页表物理地址加上此偏移量便是该页表项的物理地址,从该页表项中得到映射的物理页地址,然后用线性地址的低 12 位与该物理页地址相加,所得的地址之和便是最终要访问的物理地址。
image.png

二级页表

既然有了一级页表,为什么还要搞个二级页表呢?
(1 )一级页表中最多可容纳 1M ( 1048576 )个页表项,每个页表项是 4 字节,如果页表项全满的话,便是 4MB大小。
(2 )一级页表中所有页表项必须要提前建好,原因是操作系统要占用 4GB 虚拟地址空间的高 1GB,用户进程要占用低 3GB 。
(3 )每个进程都有自己的页表,进程一多,光是页表占用的空间就很可观了。
image.png
转换过程背后的具体步骤如下。
(1 )用虚拟地址的高 10 位乘以 4 ,作为页目录表内的偏移地址,加上页目录表的物理地址,所得的和,便是页目录项的物理地址。读取该页目录项,从中获取到页表的物理地址。
(2 )用虚拟地址的中间 10 位乘以 4 ,作为页表内的偏移地址,加上在第 1 步中得到的页表物理地址,所得的和,便是页表项的物理地址。读取该页表项,从中获取到分配的物理页地址。
(3 )虚拟地址的高 10 位和中间 10 位分别是 PDE 和 PIE 的索引值,所以它们需要乘以 4 。但低 12 位就不是索引值啦,其表示的范围是 0~0xfff,作为页内偏移最合适,所以虚拟地址的低 12 位加上第 2 步中得到的物理页地址,所得的和便是最终转换的物理地址。
image.png
image.png
P, Present,意为存在位 。 若为 1 表示该页存在于物理内存中,若为 0 表示该表不在物理内存中 。 操作系统的页式虚拟内存管理便是通过 P 位和相应的 pagefault 异常来实现的 。
RW, Read/Write,意为读写位 。 若为 1 表示可读可写,若为 0 表示可读不可写 。
US, User/Supervisor,意为普通用户/超级用户位。若为 1 时,表示处于 User 级,任意级别( 0 、 l 、 2 、3 )特权的程序都可以访问该页 。 若为 0,表示处于 Supervisor 级,特权级别为 3 的程序不允许访问该页,该页只允许特权级别为 0、 l 、 2 的程序可以访问。
PWT, Page-level Write-ηrrough,意为页级通写位,也称页级写透位 。 若为 1 表示此项采用通写方式,表示该页不仅是普通内存,还是高速缓存。此项和高速缓存有关,“通写”是高速缓存的一种工作方式,本位用来间接决定是否用此方式改善该页的访问效率 。 这里咱们直接置为 0 就可-ti.啦 。
PCD, Page-level Cache Disable,意为页级高速缓存禁止位 。 若为 1 表示该页启用高速缓存,为 0 表示禁止将该页缓存 。 这里咱们将其置为 0 。
A, Accessed,意为访问位。 若为 1 表示该页被 CPU 访问过啦,所以该位是由 CPU 设置的 。 还记得段描述符中的 A 位和 P 位吗?这两位在一起可以实现段式虚拟内存管理。 和它们一样,这里页目录项和页表项中的 A 位也可以用来记录某一内存页的使用频率(操作系统定期将该位清 0,统计一段时间内变成 1 的次数),从而当内存不足时,可以将使用频率较低的页面换出到外存(如硬盘),同时将页目录项或页表项的 P位置 0,下次访问该页引起 pagefault 异常时,中断处理程序将硬盘上的页再次换入,同时将 P 位置 l.
D, D让ty,意为脏页位。当 CPU 对一个页面执行写操作时,就会设置对应页表项的 D 位为 l 。 此项仅针对页表项有效,并不会修改页目录项中的 D 位。
PAT, Page Attribute Table,意为页属性表位,能够在页面一级的粒度上设置内存属性 。 比较复杂,将此位置。即可 。
G;Global ,意为全局位 。 由于内存地址转换也是颇费周折,先得拆分虚拟地址,然后又要查页目录,又要查页表的,所以为了提高获取物理地址的速度,将虚拟地址与物理地址转换结果存储在 TLB (TrnnslationLookaside Buffer)中, TLB 以后咱们会细说。 在此先知道 TLB 是用来缓存地址转换结果的高速缓存就 ok 啦 。此 G 位用来指定该页是否为全局页,为 1 表示是全局页,为 0 表示不是全局页 。 若为全局页,该页将在高速缓存 TLB 中一直保存,给出虚拟地址直接就出物理地址啦,无需那三步骤转换。 由于 TLB 容量比较小 (一般速度较快的存储设备容量都比较小),所以这里面就存放使用频率较高的页面。 顺便说一句, 清空 TLB 有两种方式, 一是用 invlpg 指令针对单独虚拟地址条目清理,或者是重新加载 cr3 寄存器,这将直接清空 TLB 。
AVL,意为 Available 位,表示可用,谁可以用?当然是软件,操作系统可用该位, CPU 不理会该位的值,那咱们也不理会吧 。

启用分页机制,我们要按顺序做好三件事 。
(1 )准备好页目录表及页表。
(2 )将页表地址写入控制寄存器 cr3 。
(3 )寄存器 cr0 的 PG 位置 1 。

控制寄存器 cr3 用于存储页表物理地址,所以 cr3 寄存器又称为页目录基址寄存器( Page Directory Base Register, PDBR ) 。
image.png

规划页表之操作系统与用户进程的关系

image.png
把 4GB 虚拟地址空间分成两部分,一部分专门划给操作系统,另一部分就归用户进程使用。比如我们之前都听说过,操作系统在 4GB 内存的高地址,用户进程在 4GB内存的低地址。比如 Linux,它就运行在虚拟地址的 3GB 以上,其他用户进程都运行在 3GB 以下。

启用分页机制

image.png

   %include "boot.inc"
   section loader vstart=LOADER_BASE_ADDR
   LOADER_STACK_TOP equ LOADER_BASE_ADDR
   
;构建gdt及其内部的描述符
   GDT_BASE:   dd    0x00000000 
	       dd    0x00000000

   CODE_DESC:  dd    0x0000FFFF 
	       dd    DESC_CODE_HIGH4

   DATA_STACK_DESC:  dd    0x0000FFFF
		     dd    DESC_DATA_HIGH4

   VIDEO_DESC: dd    0x80000007	       ; limit=(0xbffff-0xb8000)/4k=0x7
	       dd    DESC_VIDEO_HIGH4  ; 此时dpl为0

   GDT_SIZE   equ   $ - GDT_BASE
   GDT_LIMIT   equ   GDT_SIZE -	1 
   times 60 dq 0					 ; 此处预留60个描述符的空位(slot)
   SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0         ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
   SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0	 ; 同上
   SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0	 ; 同上 

   ; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
   ; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
   ; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
   total_mem_bytes dd 0					 
   ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

   ;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
   gdt_ptr  dw  GDT_LIMIT 
	    dd  GDT_BASE

   ;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,256字节
   ards_buf times 244 db 0
   ards_nr dw 0		      ;用于记录ards结构体数量

   loader_start:
   
;-------  int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局  -------

   xor ebx, ebx		      ;第一次调用时,ebx值要为0
   mov edx, 0x534d4150	      ;edx只赋值一次,循环体中不会改变
   mov di, ards_buf	      ;ards结构缓冲区
.e820_mem_get_loop:	      ;循环获取每个ARDS内存范围描述结构
   mov eax, 0x0000e820	      ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
   mov ecx, 20		      ;ARDS地址范围描述符结构大小是20字节
   int 0x15
   jc .e820_failed_so_try_e801   ;若cf位为1则有错误发生,尝试0xe801子功能
   add di, cx		      ;使di增加20字节指向缓冲区中新的ARDS结构位置
   inc word [ards_nr]	      ;记录ARDS数量
   cmp ebx, 0		      ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
   jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
   mov cx, [ards_nr]	      ;遍历每一个ARDS结构体,循环次数是ARDS的数量
   mov ebx, ards_buf 
   xor edx, edx		      ;edx为最大的内存容量,在此先清0
.find_max_mem_area:	      ;无须判断type是否为1,最大的内存块一定是可被使用
   mov eax, [ebx]	      ;base_add_low
   add eax, [ebx+8]	      ;length_low
   add ebx, 20		      ;指向缓冲区中下一个ARDS结构
   cmp edx, eax		      ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
   jge .next_ards
   mov edx, eax		      ;edx为总内存大小
.next_ards:
   loop .find_max_mem_area
   jmp .mem_get_ok

;------  int 15h ax = E801h 获取内存大小,最大支持4G  ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
   mov ax,0xe801
   int 0x15
   jc .e801_failed_so_try88   ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
   mov cx,0x400	     ;cx和ax值一样,cx用做乘数
   mul cx 
   shl edx,16
   and eax,0x0000FFFF
   or edx,eax
   add edx, 0x100000 ;ax只是15MB,故要加1MB
   mov esi,edx	     ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
   xor eax,eax
   mov ax,bx		
   mov ecx, 0x10000	;0x10000十进制为64KB
   mul ecx		;32位乘法,默认的被乘数是eax,积为64,32位存入edx,32位存入eax.
   add esi,eax		;由于此方法只能测出4G以内的内存,32位eax足够了,edx肯定为0,只加eax便可
   mov edx,esi		;edx为总内存大小
   jmp .mem_get_ok

;-----------------  int 15h ah = 0x88 获取内存大小,只能获取64M之内  ----------
.e801_failed_so_try88: 
   ;int 15后,ax存入的是以kb为单位的内存容量
   mov  ah, 0x88
   int  0x15
   jc .error_hlt
   and eax,0x0000FFFF
      
   ;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
   mov cx, 0x400     ;0x400等于1024,将ax中的内存容量换为以byte为单位
   mul cx
   shl edx, 16	     ;把dx移到高16or edx, eax	     ;把积的低16位组合到edx,32位的积
   add edx,0x100000  ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
   mov [total_mem_bytes], edx	 ;将内存换为byte单位后存入total_mem_bytes处。


;-----------------   准备进入保护模式   -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

   ;-----------------  打开A20  ----------------
   in al,0x92
   or al,0000_0010B
   out 0x92,al

   ;-----------------  加载GDT  ----------------
   lgdt [gdt_ptr]

   ;-----------------  cr0第0位置1  ----------------
   mov eax, cr0
   or eax, 0x00000001
   mov cr0, eax

   jmp dword SELECTOR_CODE:p_mode_start	     ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
					     ; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt:		      ;出错则挂起
   hlt

[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

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

   ;将gdt描述符中视频段描述符中的段基址+0xc0000000
   mov ebx, [gdt_ptr + 2]  
   or dword [ebx + 0x18 + 4], 0xc0000000      ;视频段是第3个段描述符,每个描述符是8字节,0x18。
					      ;段描述符的高4字节的最高位是段基址的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表示virtual addr

   jmp $

;-------------   创建页目录及页表   ---------------
setup_page:
;先把页目录占用的空间逐字节清0
   mov ecx, 4096
   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为基址。

;   下面将页目录项00xc00都存为第一个页表的地址,
;   一个页表可表示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       ;1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(7)
   mov [PAGE_DIR_TABLE_POS + 0xc00], eax     ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
					     ; 也就是页表的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
   mov edx, PG_US_U | PG_RW_W | PG_P	     ; 属性为7,US=1,RW=1,P=1
.create_pte:				     ; 创建Page Table Entry
   mov [ebx+esi*4],edx			     ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 
   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  ; 页目录项的属性US,RW和P位都为1
   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


为什么要在两处指向同一个页表?原因是我们在加载内核之前,程序中运行的一直都是 loader ,它本身的代码都是在1MB之内,必须保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致。第0个页目录项代表的页表,其表示的空间是 0~0x3fffff,包括了1MB(0 ~0xfffff)所以用了第0项来保证 loader 在分页机制下依然运行正确。那为什么也要把该地址放置到第 768 项呢?前面说过啦,我们将来会把操作系统内核放在低端 1M 物理内存空间,但操作系统的虚拟地址是 0xc0000000 以上,该虚拟地址对应的页目录项是第 768 个。这个算起来容易,0xc0000000 的高 10 位是 0x300 ,即十进制的 768 。这样虚拟地址 0x0000000 ~0xc03fffff之间的内存都指向的是低端 4MB 之内的物理地址,这自然包括操作系统所占的低端 1MB 物理内存。从而实现了操作系统高 3GB 以上的虚拟地址对应到了低端 1MB,也就是如前所说我们内核所占的就是低端 IMB

用虚拟地址访问页表

页表是一种动态的数据结构,有时候 要给页表“增肥”,比如申请一块内存时,需要往里面添加页表项或者页目录项。有时候也需要为页表“减肥”,比如在释放 块内存时,页表中相应的页表项或页目录项都要清零。这正是 级页表灵活的地方,根据 要动态增减。

总结一下用虚拟地址获取页表中各数据类型的方法 :
• 获取页目录表物理地址:让虚拟地址的高 20 位为 Oxfffff,低 12 位为 0x000 ,即 0xfffff000,这也
是页目录表中第 0 个页目录项自身的物理地址。
• 访问页目录中的页目录项,即获取页表物理地址:要使虚拟地址为 Oxffiffxxx,其中 xxx 是页目录项的索引乘以 4 的积 。
访问页表中的页表项 : 要使虚拟地址高 10 位为 Ox3ff,目的是获取页目录表物理地址。中间 IO 位为页表的索引,因为是 10 位的索引值,所以这里不用乘以 4。 低 12 位为页表内的偏移地址,用来定位页表项,它必须是己经乘以 4 后的值。
公式为 0x3ff<<22+中间10位<<12+低12位。

快表TLB(Translation Lookaside Buffer)简介

image.png
尽管 TLB 对开发人员不可见,但依然有两种方法可以间接更新 TLB ,一个是针对 TLB 中所有条目的方法一一重新加载 CR3 ,比如将 CR3 寄存器的数据读出来后再写入 CR3 ,这会使整个 TLB 失效。另一个方法是针对 TLB 中某个条目的更新。处理器提供了指令 invlpg (invalidate page ),它用于在 TLB 中刷新, 某个虚拟地址对应的条目,处理器是用虚拟地址来检索 TLB 的,因此很自然地,指令 invlpg 的操作数也是虚拟地址,其指令格式为 invlpg m。注意,其中 m 表示操作数为虚拟内存地址,并不是立即数,比如要更新虚拟地址 0x1234 对应的条目,指令为 invlpg [0x1234],并不是 invlpg 0x1234。将来咱们在内存管理系统中会涉及到 TLB 的更新操作,这一点应注意。

加载内核

二进制程序的运行方法

由于每个程序是单独存在的,所以程序的入口地址信息需要与程序绑定,最简单的办法就是在程序文件中专门腾出个空间来写入这些程序的入口地址,主调程序在该程序文件的相应空间中将该程序的入口信息读出来,将其加载到相应的入口地址,跳转过去就行了。当然不仅仅只写入程序入口地址,能写的东西很多,比如为了给程序分配内存,至少还得需要知道程序的尺寸大小。但在哪里写入程序的入口地址呢?这便是文件头的由来,在程序文件的开头部分记载这类信息,而程序文件中除文件头外其余的部分则是之前的程序体。这样一来,原先的纯二进制可执行文件加上新的文件头,就形成了一种文件格式 。
image.png

elf格式的二进制文件

ELF 指的是 Executable and Linkable Format,可执行链接格式。
image.png
程序中段的大小和数量是不固定的,节的大小和数量也不固定,因此需要为它们专门找个数据结构来描述它们,这个描述结构就是程序头表( program header table)和节头表( section header table )。既然程序头表和节头表都称为表,这说明里面存储的是多个程序头 program header和多个节头 section header 的信息,故这两个表相当于数组,数组元素分别是程序头 program header 和节头 section header。 再次强调,这两个表是用来将汇总程序头和节头的表,表中元素是头信息。也就是说程序头表( program header table )中的元素全是程序头( program header),而节头表( section header table)中的元素全是节头( section header ) 。虽然上面是将两个表一块说明的,但表中的元素全是单一的,不会在程序头表中存在节头信息 。
在表中,每个成员(数组元素)都统称为条目,即entry,一个条目代表一个段或一个节的头描述信息。对于程序头表,它本质上就是用来描述段(segment)的,所以您也可以称它为段头表。从名字上就能够看出,段等同于程序,所以将描述段信息的表说成 program header table,可见“段”才是程序本身的组成部分。
由于程序中段和节的数量不固定,程序头表和节头表的大小自然也就不固定了,而且各表在程序文件中的存储顺序自然也要有个先后,故这两个表在文件中的位置也不会固定。因此,必须要在一个固定的位置,用 一个固定大小的数据结构来描述程序头表和节头表的大小及位置信息,这个数据结构便是 ELFheader,它位于文件最开始的部分,并具有固定大小,一会儿咱们看 elf header 的数据结构就知道了。
ELF header 是个用来描述各种“头”的“头飞程序头表和节头表中的元素也是程序头和节头,可见,elf 文件格式的核心思想就是头中嵌头,是种层次化结构的格式。
image.png
image.png
e_ident[16]是 16 字节大小的数组,用来表示 elf 字符等信息
image.png
e_type 占用 2 字节,是用来指定 elf 目标文件的类型
image.png
image.png
e_machine 占用 2 字节,用来描述 elf 目标文件的体系结构类型,也就是说该文件要在哪种硬件平台(哪种机器)上才能运行。
image.png
e_version 占用 4 字节,用来表示版本信息。
e_entry 占用 4 字节,用来指明操作系统运行该程序时,将控制权转交到的虚拟地址。
e phoff 占用 4 字节,用来指明程序头表( program header table )在文件内的字节偏移量。如果没有程序头表,该值为 0 。
e_shoff 占用 4 字节,用来指明节头表( section header table )在文件内的字节偏移量。若没有节头表,该值为 0 。
e_flags 占用 4 字节,用来指明与处理器相关的标志,本书用不到那么多的内容,具体取值范围,有兴趣的同学还是要参考/usr/include/elf.h,
e_ehsize 占用 2 字节,用来指明 elf header 的宇节大小。
e_phentsize 占用 2 字节,用来指明程序头表( program header table )中每个条目( entry )的字节大小,即每个用来描述段信息的数据结构的字节大小,该结构是后面要介绍的 struct Elf32 Phdr。
e_phnum 占用 2 字节,用来指明程序头表中条目的数量。实际上就是段的个数。
e_shentsize 占用 2 宇节,用来指明节头表( section header table )中每个条目( en町)的字节大小,即每个用来描述节信息的数据结构的字节大小。
e shnum 占用 2 字节,用来指明节头表中条目的数量。实际上就是节的个数。
e_shstrndx 占用 2 宇节,用来指明 string name table 在节头表中的索引 index 。

程序头表中的条目的数据结构,这是用来描述各个段的信息用的,其结构名为 struct Elf32_Phdr:
image.png
p_type 占用 4 字节,用来指明程序中该段的类型。
image.png
p_offset 占用 4 字节,用来指明本段在文件内的起始偏移字节。
p_vaddr 占用 4 字节,用来指明本段在内存中的起始虚拟地址 。
p_paddr 占用 4 字节,仅用于与物理地址相关的系统中,因为 System V 忽略用户程序中所有的物理地
址,所以此项暂且保留,未设定。
p_filesz 占用 4 字节,用来指明本段在文件中的大小。
p_memsz 占用 4 字节,用来指明本段在内存中的大小。
p_flags 占用 4 字节,用来指明与本段相关的标志,此标志取值范围见表 5-13 。
image.png
p_align占用 4 字节,用来指明本段在文件和内存中的对齐方式。如果值为 0 或 1 ,则表示不对齐。否则 p_align 应该是 2 的幂次数。

将内核载入内存

由于内核是用c语言来实现的,可以在链接的时候指定程序的入口的起始地址,如:
Id kernel/main.o -Ttext Oxe0001500 -e main -o kernel/kernel.bin
loader.S 需要修改两个地方。
• 加载内核:需要把内核文件加载到内存缓冲区。
• 初始化内核:需要在分页后,将加载进来的 elf 内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此 loader 的工作结束。

内核被加载到内存后, loader 还要通过分析其 elf 结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是 elf格式的原文件 kernel.bin,另一份是 loader 解析 elf格式的 kernel.bin 后在内存中生成的内核映像(也就是将程序中的各种段 segment 复制到内存后的程序体),这个映像才是真正运行的内核。
将来内核肯定是越来越大,为了多预留出生长空间,咱们要将内核文件 kernel.bin 加载到地址较高的空间,而内核映像要放置到较低的地址。内核文件经过 loader 解析后就没用啦,这样内核映像将来往高地址处扩展时,也可以覆盖原来的内核文件 kernel.bin。

%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
;构建gdt及其内部的描述符
GDT_BASE:   dd    0x00000000 
dd    0x00000000

CODE_DESC:  dd    0x0000FFFF 
dd    DESC_CODE_HIGH4

DATA_STACK_DESC:  dd    0x0000FFFF
dd    DESC_DATA_HIGH4

VIDEO_DESC: dd    0x80000007	       ; limit=(0xbffff-0xb8000)/4k=0x7
dd    DESC_VIDEO_HIGH4  ; 此时dpl为0

GDT_SIZE   equ   $ - GDT_BASE
GDT_LIMIT   equ   GDT_SIZE -	1 
times 60 dq 0					 ; 此处预留60个描述符的空位(slot)
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0         ; 相当于(CODE_DESC - GDT_BASE)/8 + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0	 ; 同上
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0	 ; 同上 

; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0					 
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

;以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr  dw  GDT_LIMIT 
dd  GDT_BASE

;人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,256字节
ards_buf times 244 db 0
ards_nr dw 0		      ;用于记录ards结构体数量

loader_start:

;-------  int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局  -------

xor ebx, ebx		      ;第一次调用时,ebx值要为0
mov edx, 0x534d4150	      ;edx只赋值一次,循环体中不会改变
mov di, ards_buf	      ;ards结构缓冲区
.e820_mem_get_loop:	      ;循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820	      ;执行int 0x15后,eax值变为0x534d4150,所以每次执行int前都要更新为子功能号。
mov ecx, 20		      ;ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801   ;若cf位为1则有错误发生,尝试0xe801子功能
add di, cx		      ;使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr]	      ;记录ARDS数量
cmp ebx, 0		      ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop

;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr]	      ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf 
xor edx, edx		      ;edx为最大的内存容量,在此先清0
.find_max_mem_area:	      ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx]	      ;base_add_low
add eax, [ebx+8]	      ;length_low
add ebx, 20		      ;指向缓冲区中下一个ARDS结构
cmp edx, eax		      ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax		      ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok

;------  int 15h ax = E801h 获取内存大小,最大支持4G  ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G。
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88   ;若当前e801方法失败,就尝试0x88方法

;1 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400	     ;cx和ax值一样,cx用做乘数
mul cx 
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx	     ;先把低15MB的内存容量存入esi寄存器备份

;2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax,eax
mov ax,bx		
mov ecx, 0x10000	;0x10000十进制为64KB
mul ecx		;32位乘法,默认的被乘数是eax,积为64,32位存入edx,32位存入eax.
add esi,eax		;由于此方法只能测出4G以内的内存,32位eax足够了,edx肯定为0,只加eax便可
mov edx,esi		;edx为总内存大小
jmp .mem_get_ok

;-----------------  int 15h ah = 0x88 获取内存大小,只能获取64M之内  ----------
.e801_failed_so_try88: 
;int 15后,ax存入的是以kb为单位的内存容量
mov  ah, 0x88
int  0x15
jc .error_hlt
and eax,0x0000FFFF

;16位乘法,被乘数是ax,积为32位.积的高16位在dx中,积的低16位在ax中
mov cx, 0x400     ;0x400等于1024,将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16	     ;把dx移到高16or edx, eax	     ;把积的低16位组合到edx,32位的积
add edx,0x100000  ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB

.mem_get_ok:
mov [total_mem_bytes], edx	 ;将内存换为byte单位后存入total_mem_bytes处。


;-----------------   准备进入保护模式   -------------------
;1 打开A20
;2 加载gdt
;3 将cr0的pe位置1

;-----------------  打开A20  ----------------
in al,0x92
or al,0000_0010B
out 0x92,al

;-----------------  加载GDT  ----------------
lgdt [gdt_ptr]

;-----------------  cr0第0位置1  ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start	     ; 刷新流水线,避免分支预测的影响,这种cpu优化策略,最怕jmp跳转,
; 这将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt:		      ;出错则挂起
hlt

[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

; -------------------------   加载kernel  ----------------------
mov eax, KERNEL_START_SECTOR        ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR       ; 从磁盘读出后,写入到ebx指定的地址
mov ecx, 200			       ; 读入的扇区数

call rd_disk_m_32

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

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

;将gdt描述符中视频段描述符中的段基址+0xc0000000
mov ebx, [gdt_ptr + 2]  
or dword [ebx + 0x18 + 4], 0xc0000000      ;视频段是第3个段描述符,每个描述符是8字节,0x18。
;段描述符的高4字节的最高位是段基址的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]             ; 重新加载

;;;;;;;;;;;;;;;;;;;;;;;;;;;;  此时不刷新流水线也没问题  ;;;;;;;;;;;;;;;;;;;;;;;;
;由于一直处在32位下,原则上不需要强制刷新,经过实际测试没有以下这两句也没问题.
;但以防万一,还是加上啦,免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel	  ;强制刷新流水线,更新gdt
enter_kernel:    
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT                 ; 用地址0x1500访问测试,结果ok


;-----------------   将kernel.bin中的segment拷贝到编译的地址   -----------
kernel_init:
xor eax, eax
xor ebx, ebx		;ebx记录程序头表地址
xor ecx, ecx		;cx记录程序头表中的program header数量
xor edx, edx		;dx 记录program header尺寸,即e_phentsize

mov dx, [KERNEL_BIN_BASE_ADDR + 42]	  ; 偏移文件42字节处的属性是e_phentsize,表示program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28]   ; 偏移文件开始部分28字节的地方是e_phoff,表示第1program header在文件中的偏移量
; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44]    ; 偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL		  ; 若p_type等于 PT_NULL,说明此program header未使用。
je .PTNULL

;为函数memcpy压入参数,参数是从右往左依然压入.函数原型类似于 memcpy(dst,src,size)
push dword [ebx + 16]		  ; program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数:size
mov eax, [ebx + 4]			  ; 距程序头偏移量为4字节的位置是p_offset
add eax, KERNEL_BIN_BASE_ADDR	  ; 加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax				  ; 压入函数memcpy的第二个参数:源地址
push dword [ebx + 8]			  ; 压入函数memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr,这就是目的地址
call mem_cpy				  ; 调用mem_cpy完成段复制
add esp,12				  ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx				  ; edx为program header大小,即e_phentsize,在此ebx指向下一个program header 
loop .each_segment
ret

;----------  逐字节拷贝 mem_cpy(dst,src,size) ------------
;输入:栈中三个参数(dst,src,size)
;输出:;---------------------------------------------------------
mem_cpy:		      
cld
push ebp
mov ebp, esp
push ecx		   ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
mov edi, [ebp + 8]	   ; dst
mov esi, [ebp + 12]	   ; src
mov ecx, [ebp + 16]	   ; size
rep movsb		   ; 逐字节拷贝

;恢复环境
pop ecx		
pop ebp
ret


;-------------   创建页目录及页表   ---------------
setup_page:
;先把页目录占用的空间逐字节清0
mov ecx, 4096
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为基址。

;   下面将页目录项00xc00都存为第一个页表的地址,
;   一个页表可表示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       ;1个目录项,在页目录表中的第1个目录项写入第一个页表的位置(0x101000)及属性(3)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax     ; 一个页表项占用4字节,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,
; 也就是页表的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
mov edx, PG_US_U | PG_RW_W | PG_P	     ; 属性为7,US=1,RW=1,P=1
.create_pte:				     ; 创建Page Table Entry
mov [ebx+esi*4],edx			     ; 此时的ebx已经在上面通过eax赋值为0x101000,也就是第一个页表的地址 
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  ; 页目录项的属性RW和P位为1,US为0
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


;-------------------------------------------------------------------------------
;功能:读取硬盘n个扇区
rd_disk_m_32:	   
;-------------------------------------------------------------------------------
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
mov esi,eax	   ; 备份eax
mov di,cx		   ; 备份扇区数到di
;读写硬盘:
;1步:设置要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al            ;读取的扇区数

mov eax,esi	   ;恢复ax

;2步:将LBA地址存入0x1f3 ~ 0x1f6

;LBA地址7~0位写入端口0x1f3
mov dx,0x1f3                       
out dx,al                          

;LBA地址15~8位写入端口0x1f4
mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al

;LBA地址23~16位写入端口0x1f5
shr eax,cl
mov dx,0x1f5
out dx,al

shr eax,cl
and al,0x0f	   ;lba第24~27or al,0xe0	   ; 设置74位为1110,表示lba模式
mov dx,0x1f6
out dx,al

;3步:向0x1f7端口写入读命令,0x20 
mov dx,0x1f7
mov al,0x20                        
out dx,al

;;;;;;; 至此,硬盘控制器便从指定的lba地址(eax),读出连续的cx个扇区,下面检查硬盘状态,不忙就能把这cx个扇区的数据读出来

;4步:检测硬盘状态
.not_ready:		   ;测试0x1f7端口(status寄存器)的的BSY位
;同一端口,写时表示写入命令字,读时表示读入硬盘状态
nop
in al,dx
and al,0x88	   ;4位为1表示硬盘控制器已准备好数据传输,7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready	   ;若未准备好,继续等。

;5步:从0x1f0端口读数据
mov ax, di	   ;以下从硬盘端口读数据用insw指令更快捷,不过尽可能多的演示命令使用,
;在此先用这种方法,在后面内容会用到insw和outsw等

mov dx, 256	   ;di为要读取的扇区数,一个扇区有512字节,每次读入一个字,共需di*512/2,所以di*256
mul dx
mov cx, ax	   
mov dx, 0x1f0
.go_on_read:
in ax,dx		
mov [ebx], ax
add ebx, 2
; 由于在实模式下偏移地址为16,所以用bx只会访问到0~FFFFh的偏移。
; loader的栈指针为0x900,bx为指向的数据输出缓冲区,且为16位,
; 超过0xffff后,bx部分会从0开始,所以当要读取的扇区数过大,待写入的地址超过bx的范围时,
; 从硬盘上读出的数据会把0x0000~0xffff的覆盖,
; 造成栈被破坏,所以ret返回时,返回地址被破坏了,已经不是之前正确的地址,
; 故程序出会错,不知道会跑到哪里去。
; 所以改为ebx代替bx指向缓冲区,这样生成的机器码前面会有0x66和0x67来反转。
; 0X66用于反转默认的操作数大小! 0X67用于反转默认的寻址方式.
; cpu处于16位模式时,会理所当然的认为操作数和寻址都是16,处于32位模式时,
; 也会认为要执行的指令是32位.
; 当我们在其中任意模式下用了另外模式的寻址方式或操作数大小(姑且认为16位模式用16位字节操作数,
; 32位模式下用32字节的操作数),编译器会在指令前帮我们加上0x66或0x67,
; 临时改变当前cpu模式到另外的模式下.
; 假设当前运行在16位模式,遇到0X66时,操作数大小变为32位.
; 假设当前运行在32位模式,遇到0X66时,操作数大小变为16位.
; 假设当前运行在16位模式,遇到0X67时,寻址方式变为32位寻址
; 假设当前运行在32位模式,遇到0X67时,寻址方式变为16位寻址.

loop .go_on_read
ret

对于可执行程序,我们只对其中的段( segment )感兴趣,它们才是程序运行的实质指令和数据的所在地,所以我们要找出程序中所有的段。
函数 kemel_init 的作用是将 kernel.bin 中的段( segment)拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是平时所说的内存中的程序映像。 kernel init 的原理是分析程序中的每个段(segment ),如果段类型不是 PT_Nl且L C 空程序类型),就将该段拷贝到编译的地址中。
在进入内核之后,栈也要重新规划了,栈起始地址不能再用之前的 Oxc0000900 啦。为了方便编写程序,我们在进入内核前将战指针改成我们期待的值,在第 189 行,我们将 esp 改成了 Oxc009f00 。此地址的选择也是根据图 5-43 。也许有同学会说,为什么不把 esp 选为 Ox9fc00,这才是最合理的。没错,您说得对,我们都是会过日子的人, Ox9fc00 确实是最省空间的选择,这样做,以后的程序也不会出错 。但这牵扯到以后要说的 pcb,即程序控制块(咱们在以后线程相关章节会细说 pcb,这里仅要求大家对此有个浅表的了解即可),每个 pcb 都是自然页,也就是要求 4.KB 对齐,即 4.KB 的范围是 OxOOO~Oxfff,而不是类似 Ox333~Oxl332 这样的范围 。 我们打算将在 4阻内的最高地址作为战底,如果以 Ox9fc00 作为战底,虽然不出会什么问题,但它显得太个性了,比其他 pcb 少了 Ox400 字节 。 所以,为了统一 pcb 大小,我们这里选择枝底的要求是:它接近最大可用地址 Ox9tb筐,并且以 4阳对齐,所以 Ox9tUOO 是最合适的 。
image.png
image.png

特权级深入浅出

特权级那点事

image.png

TSS简介

TSS ,即 Task State Segment,意为任务状态段,它是处理器在硬件上原生支持多任务的一种实现方式,也就是说处理器原本是想让操作系统开发厂商利用此结构实现多任务的,人家处理器厂商已经提供了多任务管理的解决方案,尽管后来操作系统井不买账,这是后话,以后再议 。 TSS 是一种数据结构,它用于存储任务的环境。
image.png
TSS 是每个任务都有的结构 , 它用于一个任务的标识,相当于任务的身份证,程序拥有此结构才能运行,这是处理器硬件上用于任务管理的系统结构,处理器能够识别其中每一个字段。该结构看上去也有点复杂,里面众多寄存器都囊括到这 104 字节中啦,其实这 104 字节只是 TSS 的最小尺寸,根据需要,还可以再接上个 IO 位图,这些内容将在后面章节用到时补充。
任务是由处理器执行的,任务在特权级变换时,本质上是处理器的当前特权级在变换,由一个特权级变成了另外一个特权级 。 这就开始涉及栓的问题了,处理器固定,处理器在不同特权级下,应该用不同特权级的战,原因是如果在同一个战中容纳所有特权级的数据时,这种交叉引用会使战变得非常温乱,并且,用一个椅容纳多个特权级下的数据,战容量有限 , 这很容易溢出。举个例子,处理器位于 0 特权级时要用0特权级的楼, 3 特权级下也只能用 3 特权级的栈。
特权级转移分为两类, 一类是由中断门、调用门等手段实现低特权级转向高特权级,另一类则相反,是由调用返回指令从高特权级返回到低特权级,这是唯一一种能让处理器降低特权级的情况。
由于特权级向低转移后,处理器特权级有了变化,同样也需要将当前战更新为低特权级的枝,它如何找到对应的低特权级检呢?正常情况下,特权级由低向高转移在先,由高向低返回在后,即只有先向更高特权级转移,才能谈得上再从高特权级回到低特权级,否则没有“去”就谈不上“回”(宁可被骂眼嗦,我也要说清楚〉 。 当处理器由低向高特权级转移时,它自动地把当时低特权级的战地址( SS 和 ESP )压入了转移后的高特权级所在的战中(随着以后深入学习大家会明白这一点),所以,当用返回指令如 retf 或iret 从高特权级向低特权级返回时,处理器可以从当前使用的高特权级的战中获取低特权级的钱段选择子及偏移量。 由高特权级返回低特权级的过程称为“向外层转移”。
TSS 是硬件支持的系统数据结构,它和 GDT 等一样,由软件填写其内容,由硬件使用。 GDT 也要加载到寄存器 GDTR 中才能被处理器找到, TSS 也是一样,它是由 TR ( Task Register )寄存器加载的,每次处理器执行不同任务时,将 TR 寄存器加载不同任务的 TSS 就成了。

CPL和RPL入门

当前特权级CPL保存在CS选择子中的RPL部分
一致性代码段也称为依从代码段, Confonning,用来实现从低特权级的代码向高特权级的代码转移。一致性代码段是指如果自己是转移后的目标段,自己的特权级( DPL) 一定要大于等于转移前的 CPL,即数值上 CPL主DPL,也就是一致性代码段的 DPL 是权限的上限,任何在此权限之下的特权级都可以转到此代码段上执行 。
代码段可以有一致性和非一致性之分,但所有的数据段总是非一致的,即数据段不允许被比本数据段特权级更低的代码段访问 。

门、调用门和RPL序

门结构是什么呢?就是记录一段程序起始地址的描述符。
描述符有多种,刚才所说的一致性代码段,虽然它里面全是代码,但它本身是内存段,并不是指具体的一段例程,所以可以用“段描述符”来“描述”。还有一种称为“门描述符”的结构,用来描述一段程序。进入这种神奇的“门”,处理器便能转移到更高的特权级上。
image.png
任务门描述符可以放在 GDT、 LDT 和 IDT (中断描述符表,后面章节在介绍中断时大伙儿就清楚了)中,调用门可以位于 GDT 、 LDT 中,中断门和陷阱门仅位于 IDT 中。
任务门、调用门都可以用 call 和 jmp 指令直接调用,原因是这两个门描述符都位于描述符表中,要么是 GDT , 要么是 LDT,访问它们同普通的段描述符是一样的,也必须要通过选择子,因此只要在 call 或jmp 指令后接任务门或调用门的选择子便可调用它们了。陷阱门和中断门只存在于 IDT 中,因此不能主动调用,只能由中断信号来触发调用。
任务门有点特殊,它用任务 TSS 的描述符选择子来描述一个任务,有关 TSS 的内容会在用户进程部分介绍 。 除任务门之外,另外的三个门描述符都是用代码段选择子及偏移地址来描述一段程序例程的。但是,无论是哪种门描述符,它们中所记录的信息都已经可以确定所描述的对象(例程或任务)了,所以在被调用时, CPU 都会忽略调用指令中的偏移量。例如:假设某调用门描述符位于 GDT 中第 1 个位置,这样的指令“ call 0x0008: 0x1234 ” 在调用此调用门时,偏移量 0x1234 会被 CPU 忽略 。

  1. 调用门
    call 和 jmp 指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用 。 call 指令使用调用门可以实现向高特权代码转移, jmp 指令使用调用门只能实现向平级代码转移。
  2. 中断门
    以 int 指令主动发中断的形式实现从低特权向高特权转移, Linux 系统调用便用此中断门实现,以后咱们在实现中断时会展开细说。
  3. 陷阱门
    以 int3 指令主动发中断的形式实现从低特权向高特权转移,这一般是编译器在调试时用,本书中咱们不用过多关注 。
  4. 任务门
    任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务门的选择子或任务 TSS 的选择子。
    image.png
    不知道大伙儿有没有想过,为什么可以使用门结构进入高特权级呢?
    这肯定是 CPU 硬件电路中写好的规则

陷入内核后,由于特权级变化,导致使用的栈也要跟着变化,不能再使用用户程序下的 3 特权级战了,处理器要用。特权级下的拢,所以,处于 0 特权级下的内核程序需要在其对应的 0 级战中获取参数。大家要清楚,用户传入参数是在 3 特权级下做的,参数在 3 特权级战中,内核服务程序是在 0 特权级下,它需要在 0 特权级战中获取参数, 3 特权级的用户程序怎样能越权将参数压入 o 特权级下的战呢?这种访问相当于 CPL 为 3 的进程访问 DPL 为 0 的数据段,数值上 CPL>DPL,处理器会引发异常的。有没有读者这样想,让操作系统再提供一个调用门专门负责传入参数到 0 级战……先不说这样是否可行,不能否认这的确有点荒唐,我们的初衷是想通过调用门执行内核程序,现在却为了使用“调用 n ”而去调用另外一个调用门,这显然恨不科学。处理器的设计者也看到了这一点,为了方便软件开发人员,处理器在固件上实现参数的自动复制,即,将用户进程压在 3 特权级战中的参数自动复制到 0 特权级战中 。

RPL的前世今生

RPL,Request Privilege Level
个人理解,如果按照原先的调用方式,用户的代码经过一个门后特权级就提高了,这也太危险了,需要一个RPL记录下用户真正的特权级,虽然特权级cpl是变化了,但是他真实的特权级RPL是访问不了内存的,只有系统调用,也就是真正的内核暴露给用户的接口,才可以访问内核的空间。
比如,当前运行的是用户进程,也就是处理器的 CPL CCS.RPL )为 3 ,用户进程自己的数据段 DPL为 3,此时进程想往自己的数据段中写入数据,进程就要提供自己数据段的选择子到段寄存器 DS (选择子通常是由操作系统提供的),由于此时的写入只是同级操作,用户自己便能够完成,不需要系统功能调用,所以操作系统自然也不知道此事。 CPL=阳L=3, DPL=3 ,故数值上满足( RPL~三DPL && CPL~DPL),写入没问题。
还是这个用户进程,现在它想往内核数据段搞点破坏,想写入数据,所以它就要提供内核数据段的选择子。按理说内核数据段选择子是不会暴露给用户的,但用户能猜出来,所以可以伪造一个内核数据段的选择子。因为 GDT 大小是有限的,除第 0 个描述符不能用以外,其他的都可以挨个试,哈哈,也许有更好的方法。用户进程在 3 级环境下想直接写入内核数据段,它伪造的选择子的 RPL 为 0,故 CPL=3, RPL=O,内核数据段 DPL=O,故数值上不满足 RPL~三DPL&&CPL 运DPL,从而写入失败,处理器抛出 GP 异常,即一般保护性错误。其实只要 CPL 为 3 ,伪造的 RPL 是多少都不行, CPL 才是短板 。

IO特权级

一方面将指令分级的原因是有些指令的执行对计算机有着严重的影响,它们只有在 0 特权级下被执行,因此被称为特权指令( Privilege Instruction)。比如 hlt 指令,它可以让计算机停机,处理器只信任操作系统,所以它不得不放在 0 特权级下。同类的指令还有 lgdt、 lidt、 l位、 popf等,这些对计算机的正常运行起着非同小可的影响,操作系统只有亲自执行它们才放心。
另一方面体现在 1/0 读写控制上。 IO 读写特权是由标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位图决定的,它们用来指定执行 IO 操作的最小特权级。 IO 相关的指令只有在当前特权级大于等于 IOPL 时才能执行,所以它们称为 IO 敏感指令( 1/0 Sensitive Instruction),如果当前特权级小于 IOPL 时执行这些指令会引发处理器异常。这类指令有 M、 out、 cli 、 sti。所以你懂的,不只是操作系统可以进行 IO 端口访问,用户进程也是可以的,只是操作系统不允许用户进程这么做。
image.png
在 eflags 寄存器中第 12~ 13 位便是 IOPL CI/O Privilege Level),即 IO 特权级,它除了限制当前任务进行 IO 敏感指令的最低特权级外,还用来决定任务是否允许操作所有的 IO 端口,对,没错,是全部 IO端口,** IOPL 位是打开所有 IO 端口的开关(用来单独设置端口访问的方式是 IO 位图,一会儿介绍)。每个任务(内核进程或用户进程)都有自己的 eflags 寄存器,所以每个任务都有自己的 IOPL. 它表示当前任务要想执行全部 IO 指令的最低特权级,也就是处理器最低的 CPL,**只有任务的当前特权级大于等于 IOPL才允许执行全部 IO 指令,即数值上 CPL运IOPL 。
数值上 CPL运IOPL,程序既可以执行IO特权指令,又可以操作所有的 IO端口。倘若数值上 CPL> IOPL,程序也不是完全无法进行任何 IO 操作
之前说过, IOPL 是所有 IO 端口的开关,不过,这个开关还留有余地,如果将开关打开,便可以访问全部 65536 个端口,如果开关被关上,即数值上 CPL> IOPL,则可以通过 IO 位图来设置部分端口的访问权限 。 也就是说,先在整体上关闭,再从局部上打开。这有点像设置防火墙的规则,先默认为全部禁止访问,想放行哪些端口再单独打开。
image.png

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值