《操作系统真象还原》第五章 保护模式进阶 向内核迈进

配合视频学习效果更佳!:
https://www.bilibili.com/video/BV1Fm4y1b7wK/?vd_source=701807c4f8684b13e922d0a8b116af31
https://www.bilibili.com/video/BV1sL411z7LS/?vd_source=701807c4f8684b13e922d0a8b116af31
https://www.bilibili.com/video/BV1DP41197hh/?vd_source=701807c4f8684b13e922d0a8b116af31

代码仓库地址:https://github.com/xukanshan/the_truth_of_operationg_system

操作系统需要管理硬件,自然就需要知道自己有哪些硬件可用以及这些硬件的信息。内存容量是一个非常重要的信息,操作系统获得内存容量的方法通常在实模式工作时就调用BIOS中断来获取,然后存储在内存中,进入保护模式后再使用这些信息(因为保护模式不能使用BIOS中断)。

p181剖析loader.S代码:

1、代码功能(核心)

获取内存总容量

2、实现原理(核心)

在实模式下调用BIOS中断获得内存容量,然后将信息存储在内存中

3、代码逻辑(核心)

A、调用BIOS中断0x15子功能0xe820获取内存总容量,若不成功则跳至B

B、调用BIOS中断0x15子功能0xe801获取内存总容量,若不成功则跳至C

C、调用BIOS中断0x15子功能0x88获取内存总容量

4、怎么写代码?(核心)

注:在第4章最后一版loader.S基础之上(进入保护模式前)加获取内存容量的代码

A、按照BIOS中断返回类型的数据结构来开辟内存空间来存储中断获取到的信息

B、查询中断使用用法(书p178),调用BIOS中断0x15子功能0xe820获得内存的ARDS结构体,不调用0xe801与0x88中断(懒)

C、遍历获得的ARDS结构体,将所有结构体中的BaseAddrLow与LengthLow相加,冒泡选择值最大的就是内存的大小

5、代码实现如下:(myos/boot/loader.S)

loader.S中删除了之前调用BIOS中断打印字符串“2 loader in real”相关代码

loader.S中新加入代码

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

ards_buf times 244 db 0                                 ;人工对齐total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,256字节
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
    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

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

loader.S中增加了可以调用hlt(让处理器暂停执行的指令)的标号.error_hlt

.error_hlt:		                                        ;出错则挂起
    hlt

6、其他代码详解查看书p183

修改myos/boot/mbr.S 的jmp指令,跳转位置 + 0x300,使之能跳转至loader.S中的loader_start

   jmp LOADER_BASE_ADDR + 0x300

编译,写入,运行。

获取到的值与我们设定的bochs虚拟机配置文件中的32MB是一致的,只是显示单位是B。

分段模型最大的缺点就是每个段大小不一,带来内存管理的困难(例子查看书P186)。所以需要通过固定大小且比一般段更小的块管理来代替原有的段管理,这就引出了分页机制。而且正是由于分页机制的引入,原有CPU设计者所设想的那种分段机制就基本被弃用,如现代操作系统都用平坦模型,抛弃了原来的那种在内存中分段管理的思路。

分页机制的核心就是,将程序所要用的虚拟内存空间(也就是32位编程时给程序分配的空间)与实际用到的内存空间分离。将程序所要用到的虚拟内存空间,拆分成一个个固定大小的小块(虚拟的,4K),与物理内存上的固定大小的小块建立映射关系。就是这样的一种分离,带来了内存管理的粒度更小(原有的段机制下段可能远比4K大得多)、粒度更标准(段大小不一,现在统一成了4K大小)、更灵活(将程序暂时用不到的小块不放入内存中,等用到了再从磁盘中调入)等优点。

在这里插入图片描述

为了向前兼容,之前段机制无法绕过(硬件决定好了要更大的寻址空间就必须要开启保护模式(段机制)),所以这分页机制只能在原有的段机制下开启。原有段机制得到的地址被重新命名为线性地址,需要经过页机制的处理得到物理地址,具体做法就是用段机制下得到的线性地址查询页表找到内存上的页。

在这里插入图片描述

为了平衡管理整个进程(页表是以进程为单位分配)的页表的表项数量与整个页表大小的关系,最终选择了一个页表1M项,整个页表4MB(一个表项4B)。由于页表管理的是整个进程的虚拟地址空间,所以4GB内存大小(32位程序编程时,程序员都是按照可以完全使用4GB内存来编写,只不过这种完全式的使用根本不会真的给它,所以这个4GB内存空间实际上是虚拟的)对应1M表项的话,那么一个表项就管理4KB大小的虚拟地址空间。

为什么每个进程都需要独立的页表?其根本原因是32位程序编程时,程序员都是按照可以独占使用4GB内存编写,所以每个进程都想完全使用4GB内存。如果要用一个页表统一管理所有进程,那么就必须要有可以管理N(程序数量)*4GB内存空间的页表,但这页表对应的内存不可能有这么大。且这么大的内存出现后,之后的程序编程就会按照可以这么大的内存做基础,这又需要管理N*大于4GB的页表,然后循环…所以只能采取每个进程使用独立的页表,让他们假装可以使用4GB内存,然后再用某种机制只分配给他们“够用”的内存,而非4GB完全内存。这样的话,所有进程的页表所占总空间就是N(程序数量) *4MB,这又是一个不小的开销。所以采用二级页表的做法,让一级页表只有1024个页表项(每个页表项管理4MB空间,一级页表开销4KB),一级页表1024个页表项对应1024个二级页表,每个二级页表管理着4MB空间,然后每个二级页表都有1024个表项,每个二级页表表项管理着4KB的空间。当程序用到某4MB空间时候,才会为它建立对应的二级页表。这就达到了节省页表开销空间的目的。

分段机制下产生的线性地址通过分页机制转换到真实物理地址查看书p194。

页目录项与页表项结构如下图:(各字段含义查看书P196)

在这里插入图片描述

现在我们编写代码,进行一些页表的初始化工作:

P199剖析loader.S代码:

1、代码功能

一些页表的初始化工作

2、实现原理

硬件层面本就提供了分页机制,我们只需要进行页表的一些初始化工作,然后开启分页机制(硬件层面)即可

3、代码逻辑(核心)

A、将页目录表位置预留出来(创建0)

B、初始化一个页目录表(一级页表)

C、初始化第一个页表(二级页表)

4、怎么写代码?

A、先在boot.inc中定义页目录表的起始位置(放在内存1MB开始的位置),与定义模块化的页目录项与页表项字段;在上一版所写的loader.S中加入我们的创建页表函数。

B、在页目录表的位置初始化0。

C、页目录表0号项与768号项均指向第一个页表——0号项指向第一个页表(loader这个程序会运行在0-4M空间内,且跨越了段机制与页机制,顺序映射(第一个页表映射0开始4MB,第二个页表映射紧挨着下一个4MB空间)可以保证之前段机制下的线性地址和分页后的虚拟地址对应的物理地址一致,在这4M空间内,分段下的线性地址=分页后的虚拟地址=物理地址),768号项指向第一个页表是为了让分页机制下3GB开始的4MB虚拟地址空间(虚拟地址0-3G空间用户用,3-4G空间操作系统用)对应到了0-4M实际物理空间,因为这里放着我们的操作系统。这是为了所有进程共享操作系统做准备。页目录表最后一项1023号项指向自己——为的是将来动态操作页表做准备(后面章节会详细讲到)。

D、初始化第一个页表(非页目录表)——因为这个页表管理着0—4M的物理地址空间,我们的操作系统就在这个空间内。

E、初始化页目录表769号-1022号项,769号项指向第二个页表(此页表紧挨着上面的第一个页表),770号指向第三个,以此类推,——为将来用户进程做准备,使所有用户进程共享内核空间(从768号项—1022号项的页目录表项会被拷贝到所有用户进程的页目录表项中);

5、代码实现如下:

boot.inc中加入如下代码 (myos/include/boot.inc)

PAGE_DIR_TABLE_POS equ 0x100000                     ;页目录表在内存中的起始位置——从1M开始的位置

                                                    ;---------模块化的页目录表字段,PWT PCD A D G AVL 暂时不用设置   ----------
PG_P  equ   1b
PG_RW_R	 equ  00b 
PG_RW_W	 equ  10b 
PG_US_S	 equ  000b 
PG_US_U	 equ  100b  

looder.S中加入如下代码

setup_page:                                             ;------------------------------------------   创建页目录及页表  -------------------------------------
                                                        ;----------------以下6行是将1M开始的4KB置为0,将页目录表初始化
    mov ecx, 4096                                       ;创建4096个byte 0,循环4096次
    mov esi, 0                                          ;用esi来作为偏移量寻址
.clear_page_dir:
    mov byte [PAGE_DIR_TABLE_POS + esi], 0
    inc esi
    loop .clear_page_dir

                                                        ; ----------------初始化页目录表,让0号项与768号指向同一个页表,该页表管理从0开始4M的空间
.create_pde:				                            ;一个页目录表项可表示4MB内存,这样0xc03fffff以下的地址和0x003fffff以下的地址都指向相同的页表,这是为将地址映射为内核地址做准备
    mov eax, PAGE_DIR_TABLE_POS                         ; eax中存着页目录表的位置
    add eax, 0x1000 			                        ; 在页目录表位置的基础上+4K(页目录表的大小),现在eax中第一个页表的起始位置
    mov ebx, eax				                        ; 此处为ebx赋值,现在ebx存着第一个页表的起始位置
    or eax, PG_US_U | PG_RW_W | PG_P	                ; 页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问.
                                                        ; 现在eax中的值符合一个页目录项的要求了,高20位是一个指向第一个页表的4K整数倍地址,低12位是相关属性设置
    mov [PAGE_DIR_TABLE_POS + 0x0], eax                 ; 页目录表0号项写入第一个页表的位置(0x101000)及属性(7)
    mov [PAGE_DIR_TABLE_POS + 0xc00], eax               ; 页目录表768号项写入第一个页表的位置(0x101000)及属性(7)
					                                    
    sub eax, 0x1000                                     ;----------------- 使最后一个目录项指向页目录表自己的地址,为的是将来动态操作页表做准备
    mov [PAGE_DIR_TABLE_POS + 4092], eax	            ;属性包含PG_US_U是为了将来init进程(运行在用户空间)访问这个页目录表项
                                                        
    mov ecx, 256				                        ; -----------------初始化第一个页表,因为我们的操作系统不会超过1M,所以只用初始化256项
    mov esi, 0                                          ; esi来做寻址页表项的偏移量
    xoe edx, edx                                        ;将edx置为0,现在edx指向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                                        ; edx指向下一个4kb空间,且已经设定好了属性,故edx中是一个完整指向下一个4kb物理空间的页表表项
    inc esi                                             ; 寻址页表项的偏移量+1
    loop .create_pte                                    ;循环设定第一个页表的256; -------------------初始化页目录表769-1022号项,769号项指向第二个页表的地址(此页表紧挨着上面的第一个页表),770号指向第三个,以此类推
    mov eax, PAGE_DIR_TABLE_POS                         ; eax存页目录表的起始位置
    add eax, 0x2000 		                            ; 此时eax为第二个页表的位置
    or eax, PG_US_U | PG_RW_W | PG_P                    ; 设置页目录表项相关属性,US,RW和P位都为1,现在eax中的值是一个完整的指向第二个页表的页目录表项
    mov ebx, PAGE_DIR_TABLE_POS                         ; ebx现在存着页目录表的起始位置
    mov ecx, 254			                            ; 要设置254个表项
    mov esi, 769                                        ; 要设置的页目录表项的偏移起始
.create_kernel_pde:
    mov [ebx+esi*4], eax                                ; 设置页目录表项
    inc esi                                             ; 增加要设置的页目录表项的偏移
    add eax, 0x1000                                     ; eax指向下一个页表的位置,由于之前设定了属性,所以eax是一个完整的指向下一个页表的页目录表项
    loop .create_kernel_pde                             ; 循环设定254个页目录表项
    ret

6、其他代码详解查看书p200

进行完初始化部分页目录表与页表工作之后,我们现在就要正式开启页机制了。我们之前已经将我们操作系统实际所在的物理地址空间0——4M与虚拟地址空间3G开始4M建立了映射关系,所以我们访问虚拟地址空间3G开始的4M空间,实际上访问的是物理地址空间0——4M,也就是我们的操作系统。

在分页机制下,我们现在要让访问物理0——4M空间的用户进程代码去访问3G开始的4M虚拟地址空间,这是为了将虚拟地址空间0——3G给应用进程使用(我们上面代码中页目录表的768-1022号项会拷贝到所有进程的页目录表中,不会拷贝0号项)。我们之前在分段机制下是通过选择子的方式查找GDT表中的段描述符来访问物理地址,所以我们需要通过修改GDT表中的段描述符实现上述重定位访问。同时由于访问GDT表不是通过GDT表中的段描述符,而是通过GDTR中的值,所以我们也需要修改GDTR中的值,让进程通过GDTR中的值找GDT的时候不是去物理内存0——4M空间找,而是去虚拟地址3G开始的4M找(不修改的话,要想让GDT表正常工作,需要将用户进程自己的页目录表第0项与物理地址0——4M建立映射关系,此时GDTR中存储的GDT线性地址才能转换成正确的GDT物理地址)

P202剖析loader.S代码:

1、代码功能

开启页表机制,在页机制下显示一个字符

2、实现原理

操作系统是一个高度和硬件相关的程序,在硬件层面IA32体系就提供了页机制的支撑

3、代码逻辑

A、创建页目录表与第一个页表

B、GDT中的视频段描述符、GDT表基地址、栈指针地址升高

**C、**开启页机制

**D、**重载GDT表

E、操作显存段显示字符

4、怎么写代码?

**A、**调用之前写好的setup_page完成页目录表与页表的创建

B、GDT中的视频段描述符表地址+3G 、GDT表基地址+3G、栈指针地址+3G

C、开启页机制二步骤

  1. 将页目录表地址写入控制寄存器CR3
  2. 将CR0寄存器的PG位置为1

D、重新加载GDT地址

E、操作显存段显示字符

5、代码实现如下:

loader.S中加入如下代码: (myos/boot/loader.S)

    call setup_page                                     ;创建页目录表的函数,我们的页目录表必须放在1M开始的位置,所以必须在开启保护模式后运行

                                                        ;以下两句是将gdt描述符中视频段描述符中的段基址+0xc0000000
    mov ebx, [gdt_ptr + 2]                              ;ebx中存着GDT_BASE
    or dword [ebx + 0x18 + 4], 0xc0000000               ;视频段是第3个段描述符,每个描述符是8字节,0x18 = 24,然后+4,是取出了视频段段描述符的高4字节。然后or操作,段基址最高位+c
                                           
    add dword [gdt_ptr + 2], 0xc0000000                 ;将gdt的基址加上0xc0000000使其成为内核所在的高地址

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

    mov eax, PAGE_DIR_TABLE_POS                         ; 把页目录地址赋给cr3
    mov cr3, eax
                                                        
    mov eax, cr0                                        ; 打开cr0的pg位(31)
    or eax, 0x80000000  
    mov cr0, eax
                                                      
    lgdt [gdt_ptr]                                      ;在开启分页后,用gdt新的地址重新加载

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

6、其他代码详解查看书p202

通过info指令查看GDT表信息,GDTR中GDT起始地址修改成功

在这里插入图片描述

我们做一个总结,32位可以寻址4GB空间,这个空间可以有很多种表达方式,如:2的32次方个字节的空间;2的20次方个4K空间;2的10次方个4M空间。就是因为这样,我们才可以将整个4G按照我们的需求拆分成不同数量的固定块大小(思想见书P190)来管理。一个32位的地址(假设是4M的整数倍),高10位就代表了这是4G空间中某个4M空间。中间10位,就代表了是某个4M空间中某个4K空间。最后12位,代表了某个4M空间中某个4K空间中的某个字节空间。

这就是为什么,线性地址最终可以转换成实际地址。只不过这转换(映射)的过程是通过页表来完成的。页目录表管理整个4G空间,共1024项,每个项管理4M空间。而32位地址(假设是4M的整数倍)高10位本就是确定了4G空间中的某个4M空间,所以才能用这个高10位去页目录表中做偏移找到页目录表中的某个表项,也就是确定了4G空间中某个具体的4M空间。只不过这个4M空间又用了一张二级页表来管理。32位地址的中间10位,代表了某个具体4M空间中某个具体的4K空间,而这个4K空间又完全地被一个二级页表表项所管理,所以我们才能用中间10位做二级页表的偏移,找到那个具体的4K空间。而最后12位,就是在这个4K空间中,找到那个具体的字节块。

现在我们来完成loader最后一个使命,那就是将操作系统的二进制文件加载到内存中,根据elf文件头信息为其创建内核映像。

程序中最重要的部分就是段和节。program header用于描述每个段的信息,section header用于描述每个节的信息。由于程序中段与节的大小和数量是不固定的,所以program header与section header的数量大小也是不固定的,因此需要为它们专门找个数据结构来描述它们,这个描述结构就是program header table与section header table。但是,多一个段,就多一个program header,program header table就会变大,所以program header table与section header table的大小也是不固定的,就又需要一个elf header来描述program header table与section header table。

下图是编译好的操作系统的elf文件头格式,我们要从elf header中取得program header table的信息,又从program header table中得到关于对每个段program header的信息,根据这个信息去确定每个段的大小,起始地址,在内存中的目的地址(都是虚拟地址)。

在这里插入图片描述

elf header结构如下,各字段大小与含义见书p215
在这里插入图片描述

program header table中每个program header的结构如下,大小与含义见书p217

在这里插入图片描述

p223,p224,p228剖析loader.S代码:

1、代码功能

loader从磁盘中加载编译好的elf格式的kernel二进制文件,按照elf文件头的信息创建内核映像,然后执行内核代码。

2、实现原理

由于这个二进制文件是被编译好了的,是按照elf文件布局分布的,加载到内存中不能直接运行(有文件头)。我们要根据elf文件头的信息,将编译好了的文件中的各种段复制到内存中运行。具体来说,就是通过elf header得到program header table信息,然后根据program header table信息找到program header信息,该结构描述了各个段的起始偏移,大小,目的虚拟地址,根据这些信息将各个段复制到指定位置。最后跳入内核代码执行。

3、代码逻辑

A、把编译好了的kernel.bin从磁盘复制到内存指定位置

B、分析kernel.bin的elf文件头,找到各个段,然后将各个段复制到内存指定位置,这个位置是由于我们指定了内核可执行代码的入口0xc0001500后编译器根据实际代码情况自己指定的

4、怎么写代码?

A、在boot.inc中定义内核文件加载到内存中的位置:0x70000;定义内核文件在磁盘中的起始盘区:0x9;定义内核可执行代码的入口地址0xc0001500;把栈顶位置的宏定义从loader.S中移到boot.inc中。

B、写一个32位环境下读取磁盘的函数(这个函数我们直接去修改mbr.S中实现的16位读取磁盘的函数),然后传参调用此函数完成从磁盘中加载elf格式的内核文件到内存指定地址。

**C、**实现拷贝函数,用于将信息在内存中进行移动。

D、在boot.inc中定义program header type字段为空的宏,用此宏来判断program header描述的段是否需要被加载。

E、写一个解析elf文件的函数,该函数的功能:从指定位置的elf header中取出:1、program header table的偏移位置(加上内核文件在内存的起始位置就是物理地址),这为的是找到第一个program header;2、program header table中的program header(对于每个段的描述符)数量;3、program header table每个program header的大小。这三个信息为的是遍历每一个program header,如果该program header描述的段不是空段就从program header取出每个段的:1、段的大小;2、本段的偏移(加上内核文件在内存的起始位置就是物理地址);3、段在内存中的虚拟地址(编译内核时我们会指定一个程序入口虚拟地址,编译器会根据指定的程序入口虚拟地址,计算出每个段虚拟地址。由于我们指定的虚拟地址就是真实加载的物理地址,所以取出的虚拟地址就是的段目的物理地址)。然后传参调用mem_cpy函数将段内容复制到指定位置。

F、调用E写的函数,完成内核映像的创建。然后跳入内核执行,删除显示字符’v’的代码与jmp $

5、代码实现如下:

首先,我们先修改**(myos/boot/include/boot.inc)**,增加一些宏定义

LOADER_STACK_TOP equ LOADER_BASE_ADDR               ;这一条之前是在loader.S中定义,现在搬过来了

KERNEL_BIN_BASE_ADDR equ 0x70000                    ;定义内核在内存中的缓冲区,也就是将编译好的内核文件暂时存储在内存中的位置
KERNEL_START_SECTOR equ 0x9                         ;定义内核在磁盘的起始扇区
KERNEL_ENTRY_POINT equ 0xc0001500                   ;定义内核可执行代码的入口地址

由于我们现在是在32位环境下工作(开启了段页机制),因此之前我们写的mbr加载loader函数rd_disk_m_16不能直接用,得增加rd_disk_m_32myos/boot/loader.S

                                                        ;-------------------------------------------------------------------------------
                                                        ;功能:读取硬盘n个扇区
rd_disk_m_32:	   
                                                        ;-------------------------------------------------------------------------------
				                                        ; eax=LBA扇区号
				                                        ; ebx=将数据写入的内存地址
				                                        ; ecx=读入的扇区数
    mov esi,eax	                                        ;备份eax
    mov di,cx		                                    ;备份cx
                                                        ;读写硬盘:
                                                        ;第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~27位
    or al,0xe0	                                        ; 设置7~4位为1110,表示lba模式
    mov dx,0x1f6
    out dx,al

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

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

                                                        ;第5步:从0x1f0端口读数据
    mov ax, di                                          ;di当中存储的是要读取的扇区数
    mov dx, 256                                         ;每个扇区512字节,一次读取两个字节,所以一个扇区就要读取256次,与扇区数相乘,就等得到总读取次数
    mul dx                                              ;8位乘法与16位乘法知识查看书p133,注意:16位乘法会改变dx的值!!!!
    mov cx, ax	                                        ; 得到了要读取的总次数,然后将这个数字放入cx中
    mov dx, 0x1f0
.go_on_read:
    in ax,dx
    mov [ebx],ax                                        ;与rd_disk_m_16相比,就是把这两句的bx改成了ebx
    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

然后调用我们之前写好的加载函数,去加载磁盘中编译好的内核文件到指定内存位置(myos/boot/loader.S

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

接下来就是要去解析elf内核文件,根据program header信息加载段,在此之前我们还需要实现一个拷贝函数,用于将数据从内存中一个位置拷贝置另一个位置(myos/boo/loader.S

                                                        ;----------  逐字节拷贝 mem_cpy(dst,src,size) ------------
                                                        ;输入:栈中三个参数(dst,src,size)
                                                        ;输出:无
                                                        ;---------------------------------------------------------
mem_cpy:		      
    cld                                                 ;将FLAG的方向标志位DF清零,rep在执行循环时候si,di就会加1
    push ebp                                            ;这两句指令是在进行栈框架构建
    mov ebp, esp
    push ecx		                                    ; rep指令用到了ecx,但ecx对于外层段的循环还有用,故先入栈备份
    mov edi, [ebp + 8]	                                ; dst,edi与esi作为偏移,没有指定段寄存器的话,默认是ss寄存器进行配合
    mov esi, [ebp + 12]	                                ; src
    mov ecx, [ebp + 16]	                                ; size
    rep movsb		                                    ; 逐字节拷贝

                                                        ;恢复环境
    pop ecx		
    pop ebp
    ret

还需在(myos/boot/include/boot.inc)中定义程序的段类型,也就是program header中的type字段,用于判断该段是否应该被加载

                                                    ;-------------  程序段的 type 定义   --------------
PT_NULL equ 0

写一个函数kernel_init解析内存中的编译好的elf内核文件,创建内核映像**(myos/boot/loader.S)**

kernel_init:
    xor eax, eax                                        ;清空eax
    xor ebx, ebx		                                ;清空ebx, ebx记录程序头表地址
    xor ecx, ecx		                                ;清空ecx, cx记录程序头表中的program header数量
    xor edx, edx		                                ;清空edx, dx 记录program header尺寸

    mov dx, [KERNEL_BIN_BASE_ADDR + 42]	                ; 偏移文件42字节处的属性是e_phentsize,表示program header table中每个program header大小
    mov ebx, [KERNEL_BIN_BASE_ADDR + 28]                ; 偏移文件开始部分28字节的地方是e_phoff,表示program header table的偏移,ebx中是第1 个program header在文件中的偏移量
					                                    ; 其实该值是0x34,不过还是谨慎一点,这里来读取实际值
    add ebx, KERNEL_BIN_BASE_ADDR                       ; 现在ebx中存着第一个program header的内存地址
    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,该值是本program header 所表示的段相对于文件的偏移
    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

loader使命正式结束,接下来就是跳转进入内核开始执行(myos/boot/loader.S

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

enter_kernel:    
    call kernel_init
    mov esp, 0xc009f000
    jmp KERNEL_ENTRY_POINT                              ; 用地址0x1500访问测试,结果ok

删除(myos/boot/loader.S)以下代码(原lgdt [gdt_ptr]下面代码)

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

    jmp $

测试的kernel.c代码: (myos/kernel/kernel.c)

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

6、其他代码详解查看书p223,p225,p228

由于我们自带的gcc版本是9.4.0,而书上是gcc4.6.1,现在需要将我们的gcc版本降低,否则后面会出现非常多难以解决的问题.

ubuntu 16.04 gcc高低版本切换_总会习惯的博客-CSDN博客

gcc-4.4 /home/rlk/Desktop/the_truth_of_operationg_system/chapter_5/c/kernel/main.c -o /home/rlk/Desktop/kernel -c -m32

-m 因为我们的虚拟机是32位的,所以必须将我们的内核文件编译成32位的;-c 是生成当前文件的二进制文件,不要进行链接)

ld /home/rlk/Desktop/kernel -Ttext 0xc0001500 -e main -o /home/rlk/Desktop/kernel.bin -m elf_i386

-Ttext 指定编译起始虚拟位置,-e 指定函数入口,-m 指定模拟的链接环境

dd if=/home/rlk/Desktop/kernel.bin of=/home/rlk/Desktop/bochs/hd60M.img bs=512 count=200 conv=notrunc seek=9

我们的内核文件就是一个死循环,在虚拟内存的0xc0001500开始!

CPL、DPL、RPL总结

CPU总是在执行代码,代码总是处于某个段中,CS有自己的值,CS中的RPL位就是CPU当前的CPL。当这一段代码想要跳转到另一段代码中执行时,就需要加载另一段代码的选择子去GDT中查找对应的段描述符,段描述符中有被访问代码的DPL值,CPU就根据当前的CPL,与目的代码段的DPL做比较,来进行访问限制实现安全检查。理论上CPL<=DPL(数值)才能访问某个段,但实际上只允许平级访问。因为特权级低代码能做的事情,高特权级代码也能做。所以根本不会出现由特权级高代码切换到特权级低代码这种情况(除了中断返回)。而且特权级低代码肯定不能直接切换到特权级高的代码,自然就只能平级访问。但是仅仅依靠CPL与DPL是不够的,典型场景就是一个应用通过系统调用去操作操作系统的数据,在这种情况下是绝对不允许的。因此又引入了RPL,RPL是这个应用要加载的段描述符选择子的RPL位的值,比如刚刚那个场景,应用为了操作操作系统的数据,那么加载到ds中的段描述符选择子的RPL是0,但是应用此时的CPL是3,因此CPU就能通过CPL与RPL的差异来判断应用想要搞鬼,所以就会强行将加载到ds中的段描述符的选择子的RPL改为应用的CPL。参见这篇文章:https://zhuanlan.zhihu.com/p/410107674

CPU的初始CPL是在保护模式开启时(只有保护模式开启时,才有特权级这一概念)初始化的。因为当我们BIOS加载MBR,MBR加载loader并执行时,需要用到一句jmp 0:loader_start,后面保护模式开启后,现在的CS值是全0(虽然这是个非法段描述符选择子),我们需要用jmp SLE_CODE:p_mode_start,来刷新流水线,CPU会将当前的CS(值为全0)当做一个段描述符选择子,其中的RPL是0,也就是CPU的CPL是0,然后将SLE_CODE作为选择子查段描述符,对应的段描述符(我们设置的)中的DPL为0,cpu允许跳转。然后跳转之后的段的CS值的RPL,又成为当前CPU的CPL,这样CPL就被初始化为0了。

一致性代码段能够实现低特权级访问高特权级,但不会改变CPU的CPL,cpu的CPL仍然以访问前的低特权级代码的RPL。所以一致性代码段的DPL实际上是访问特权级的上限。也就是DPL=1,那么CPL=1,2,3都可以访问(=0应该也可以访问,只是一般没必要)。

同样的逻辑可以用于eflags的IOPL位,与IO位图来实现应用对端口(硬件)操作的安全性检查。

s中的段描述符的选择子的RPL改为应用的CPL。参见这篇文章:https://zhuanlan.zhihu.com/p/410107674

CPU的初始CPL是在保护模式开启时(只有保护模式开启时,才有特权级这一概念)初始化的。因为当我们BIOS加载MBR,MBR加载loader并执行时,需要用到一句jmp 0:loader_start,后面保护模式开启后,现在的CS值是全0(虽然这是个非法段描述符选择子),我们需要用jmp SLE_CODE:p_mode_start,来刷新流水线,CPU会将当前的CS(值为全0)当做一个段描述符选择子,其中的RPL是0,也就是CPU的CPL是0,然后将SLE_CODE作为选择子查段描述符,对应的段描述符(我们设置的)中的DPL为0,cpu允许跳转。然后跳转之后的段的CS值的RPL,又成为当前CPU的CPL,这样CPL就被初始化为0了。

一致性代码段能够实现低特权级访问高特权级,但不会改变CPU的CPL,cpu的CPL仍然以访问前的低特权级代码的RPL。所以一致性代码段的DPL实际上是访问特权级的上限。也就是DPL=1,那么CPL=1,2,3都可以访问(=0应该也可以访问,只是一般没必要)。

同样的逻辑可以用于eflags的IOPL位,与IO位图来实现应用对端口(硬件)操作的安全性检查。

猜你喜欢

转载自blog.csdn.net/kanshanxd/article/details/130863777