操作系统——加载内核(十)
操作系统——加载内核(十)
2020-09-23 18:16:58 hawk
概述
这一篇博客就是简单的介绍一下将要实现的内核在内存中的布局,然后写一个简单的内核(没有实现任何功能,仅仅悬挂程序表示成功加载),并将其完成加载。需要注意的是,之后我们的实验,也就是内核功能的编写,基本就是用c语言进行编写,辅助一些汇编代码,这里特别说明一下。也就是我们需要将通过gcc编写的elf文件加载如内存中并进行运行,这就是我们这篇博客的主要任务。
磁盘位置
我们的操作系统,也就是内核数据,一定是从磁盘上加载到内存中的。那么具体的内核的位置放置在磁盘的哪里呢?实际上,和前面是一样的,只要mbr和loader是自己实现的,自己约定一个位置就行,只要不覆盖掉其他会用到的数据即可;如果mbr和loader并不是自己写的,就按照别人约定好的地址进行放置即可。
前面由于bios程序我们使用的是别人实现好的bochs软件自带的bios程序,其要求mbr位于第0个扇区(LBA方式),并且第0个扇区的最后两个字节只能是0x55、0xaa。而由于mbr是我们自己实现的,因此loader程序在磁盘上的位置可以有我们自己指定——而考虑到mbr程序只能是1个扇区的大小(其结尾的两个字节已经定死了),所以我们就直接把loader程序放置到了第1个扇区(LBA方式)。而由于loader程序同样是我们自己实现的,因此loader加载的内核数据在磁盘中的位置仍然可以自己指定。当前我们实现的loader程序的大小没有超过5个扇区,但是说不准我们后面可能就会拓展loader的功能,从而使其大小增大,因此为了尽量一劳永逸的解决这个问题,我们直接将其放在第10个扇区即可(LBA方式)。
内存位置
当我们已经解决了操作系统在磁盘上的位置后,我们就可以直接将其读入内存中,相关的代码和mbr加载loader程序是十分相似的,就是与IO接口的一系列通信而已,这里不再赘述。这里需要说明一下,虽然前面我们已经开启了分页机制,但是为了操作方便,我们将内核加载入内存的过程放置在开启保护模式之后、开启分页模式之前进行,这样子就完全不需要考虑内存映射的问题(实际上最后执行的之后还是会需要考虑的)。
这里由于我们后面实现的内核实际上并不会很大,也就70KB左右,因此我们就直接将其放置在低端的1MB内存中,方便直接进行管理。这里我们再稍微给出现在的低端1MB(物理地址)的内存布局,看一下哪里可以用来加载内核,如下表所示
起始地址 | 结束地址 | 大小 | 用途 |
0x9fc00 | 0x9ffff | 1KB |
EBDA (Extended BIOS Data Area)拓展bios数据区 |
0x7e00 | 0x9fbff | 622080B | 可用区域 |
0x7c00 | 0x7dff | 512B | MBR被BIOS加载到此处,共512字节 |
0x---- | 0x7bff | - | 可用区域 |
0x700 | 0x--- | Loader被MBR加载到此处(这里是我个人实现的操作系统的地址,可以任意指定其他的地址,纯属个人喜好) | |
0x500 | 0x6ff | 512B | 可用区域 |
0x400 | 0x4ff | 256B | BIOS Data Area(BIOS数据区) |
0x000 | 0x3ff | 1K | Interrupt Vector Table(中断向量表) |
实际上,0x9ffff-0xfffff主要用于存储BIOS程序和显存进行使用,因此我们不能将内核加载到哪些位置上。而根据上表的描述,实际上,0x7c00-0x9fbff这段地址十分的适合。这段地址没有被其他程序使用(MBR程序已经结束了自己的生命了,所以其上的内容相当于没有被使用),并且剩余空间足够大,远远超过可能的内核大小(100KB)。因此,如果我们将内核数据加载到这个位置上,内核数据会基本不受影响的进行运行,下面就是考量一下具体将内核程序加载到的位置了。
这里大致说明一下内核的运行——前面我们已经说过了,实际上我们会使用高级语言——c语言进行编写内核程序,然后通过gcc工具生成elf文件,这个文件就是内核的相关数据,我们会直接将其加载如内存中。但是根据前面博客所分析过的,实际上elf文件并不是直接给cpu进行运行的,其前面还包含了诸多数据(和运行代码无直接关系)。因此我们需要loader程序解析加载入内存的elf文件,将其各个段(segment,包含程序运行的代码和数据等关键资源信息)重新映射到内存中,作为前面elf文件的映像,这个映像才是真正运行的内核。因此,我们在加载内核数据,将其尽量放置在高地址空间处;而将内核映像加载到低地址空间处。这样子哪怕后面内核功能丰富了,大小变大了,内核映像仍然有较大的生长空间(通过直接覆盖掉内核)。因此这里,将内核数据就加载到0x60000地址处(也是个人喜好,只要确保剩余的大小足以让内核数据复制进来即可)
内核映像位置
前面在介绍内核的过程中也提到了,实际上真正运行的是内核映像文件,因此这里我们需要讨论一下内核映像文件在内存中的位置。实际上还是接着前面分析过的,我们要选择位置仍然处于低端1MB内存处(物理地址),仍然在上面介绍的可用空间范围内。不过前面说过了,尽量将内核数据放置在高地址处,而降内核映像文件放置在低地址处,因此我们就放置在0x500 - 0x60000范围中。而考虑到实际上0x700开始包含有被MBR加载的loader程序以及GDT数据,位于0x700(自己选择的地址),不过其数据大小最多也就9个扇区的大小(loader程序在磁盘上的位置最多就是第1扇区(LBA方式)到第9扇区(LBA方式)),因此我们稍微抬高点内存映像的起始地址即可。不妨设置为0x2000。(仍然是自己选择的,个人喜好问题而已),在虚拟地址中也就是0xc0002000。
构建内核映像
虽然前面一直在说真正运行的内核是内核映像,也简单提到了,从内核数据中提取所有的段,从而完成内核映像的构建,这里在稍微具体的说明一下。首先,实际上我们会在内存分页机制启动后在完成内核映像的构建——因为我们会根据elf头文件的相关信息,直接把对应的段拷贝到虚拟地址所在处即可。(实际上物理地址还是对应的低端1MB内存中的0x2000,但是由于开启了分页机制,所以只能使用虚拟地址进行访问)。
而分析的方式也很简单。根据前面博客分析过的,找到elf文件偏移28字节处,获取程序头表在elf文件的偏移;然后找到elf文件偏移44字节处,从而获取程序头表包含的条目个数。之后分别进行遍历这些段即可,找到对应的虚拟地址和大小,从而将对应的非PT_NULL的段直接复制到相应的虚拟地址中,从而完成内核映像的构建。
实验
链接仓库点击即可。其实这次的实验也很简单,我们构建最简单的kernel文件(仅仅悬停),并让loader将其加载到对应的内核空间,并将程序的执行流转移即可。首先我们给出include/boot.inc头文件中修改的部分,这里主要包含了宏定义,增强程序可读性,源代码如下所示
KERNEL_START_SECTOR equ 10 KERNEL_BASE_ADDR equ 0x60000 ;内核数据加载入内存的偏移 KERNEL_ENTRY_ADDR equ 0xc0002000 ;内核映像文件位置 PT_NULL equ 0 ;elf文件中程序头表的segment的类型,表示可以忽略
下面则是真正修改的loader程序,源代码如下所示
; 这里实现简单的loader,其讲系统由实模式进入保护模式 ; 最后loader仍然会输出相关的字符串,然后进行悬停,方便进行观察 ;------------------------------------------------------------------------ %include "boot.inc" ; 类似于C语言的宏定义 ;-------------------------------------------------------------------------------- ; 这个文件的主要定义如下所示 ; LOADER_BASE_ADDR equ 0x700 ; LOADER_START_SECTOR equ 0x1 ; GDT和GDT的选择子的宏定义 SECTION LOADER vstart=LOADER_BASE_ADDR ;这个地址表示将起始地址设置为LOADER_BASE_ADDR——因为MBR会将loader程序加载到LOADER_BASE_ADDR处 LOADER_STACK_TOP equ LOADER_BASE_ADDR ;这里提前说明一下,实际上loader的起始栈顶是LOADER_BASE_ADDR jmp LOADER_START ;16位实模式相对近转移 ;-----------------这部分内容用来构建GDT结构,由于栈向下生长,因此不会破坏GDT-------------------------------- ; db: data byte, 1字节 ; dw: data word, 2字节 ; dd: data double-word, 4字节 ; dq: data quarter-word, 8字节 GDT_BASE: dd 0x00000000, 0x00000000 ;前面分析过了,GDT的第0个段描述符无法使用,因此直接置为0即可 GDT_CODE: dd 0x0000ffff, GDT_DES_CODE_HIGH_4B ;这是GDT的第1个段描述符,代码段。由于采用了平坦模式,因此段基址设置为0, 段界限设置为0xfffff GDT_DATA_STACK: dd 0x0000ffff, GDT_DES_DATA_HIGH_4B ;这是GDT的第2个段描述符,数据(栈)段。由于采用了平坦模式,因此断机制设置为0,段界限设置为0xfffff ;这里需要说明一下,这里纯数据和栈公用一个段,且该段向上扩展。但是栈的方向和段的拓展方向并没有关系。 ;段的拓展方向仅仅是用来约束段偏移的,即向上拓展的话,则[base_add, base_add + offset]是对应的段 ;向下拓展的话,则[base_add, base_add + offset]非该段,其余都是段描述符对应的段 GDT_VIDEO: dd 0x8000_0007, GDT_DES_VIDEO_HIGH_4B ;这是GDT的第3个段描述符,显存数据段。未采用平坦模式,段基址为0xb8000,段大小为32KB,即段界限为0x7 ;-----------------下面定义一下GDT的选择子的-------------------------------- GDT_SECT_CODE equ ((0x0001 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0) ;描述符索引值为0x1;在GDT中索引;请求权限为0特权级 GDT_SECT_DATASTACK equ ((0x0002 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0) ;描述符索引值为0x2;在GDT中索引;请求权限为0特权级 GDT_SECT_VIDEO equ ((0x0003 << 3) | GDT_SECT_TI_GDT | GDT_SECT_RPL_0) ;描述符索引值为0x3;在GDT中索引;请求权限为0特权级 ;-----------------这里则是GDTR相关的信息,用来构造GDTR的值-------------------------------- GDT_SIZE equ ($ - GDT_BASE) ;当前内存中GDT的大小 GDT_LIMIT equ (GDT_SIZE - 1) ;GDTR中的值,由于其从0开始,需要减一,类似于段界限 times 60 dq 0 ;在预留60个段描述符的空间 GDT_PTR: dw GDT_LIMIT dd GDT_BASE ;低2个字节是GDT的界限,高4个字节是GDT内存起始地址 LOADER_START: ; 向1MB内存中的文本模式的显示适配器区域写入数据 ;------------------------------------------------------------------------ ; 每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性 ; 由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b ;------------------------------------------------------------------------ mov cx, 0x0 mov byte al, [format] ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中 LOOP_LOADER: mov di, cx mov byte dl, [di + loaderMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符 sub dl, 0 jz LOOP_LOADER_END ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置 add di, di add di, 160 ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第2行,则需要从80 * 2 = 160的偏移开始 mov byte [gs:di], dl ;这里通过变址寻址访问内存 add di, 1 mov byte [gs:di], al ;这里通过变址寻址访问内存 add cx, 1 jmp near LOOP_LOADER ;无条件相对近跳转,会重新跳转到LOOP处执行循环 LOOP_LOADER_END: ;------------------------------------------------------------------------ ; 我们将上面的指令分析一下 ; 可以看到,对于内存寻址来说,这里通过直接寻址进行寻址 ; 我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性 ; 高字节是字符对应的ascii码,从而完成了内存的写入。 ;------------------------准备进入保护模式------------------------------------------------------------------------------ ; 1. 将GDT装载入GDTR中 lgdt [GDT_PTR] ; 2. 打开A20Gate mov dx, 0x92 in al, dx or al, 0000_0010b out dx, al ; 3. 修改CR0寄存器 mov eax, cr0 or eax, 0x00000001 mov cr0, eax ;----------------------------------这里需要通过无条件跳转来刷新流水线,否则会出错,需要特别注意一下---------------- jmp GDT_SECT_CODE:PROTECTION_MODE_START ;绝对地址远调用 ;-------------------------------------下面是观察用的保护模式下的代码,用来确认成功进入保护模式--------------- [bits 32] PROTECTION_MODE_START: mov ax, GDT_SECT_DATASTACK mov ds, ax mov es, ax mov ss, ax ;初始化各个段寄存器,将其都指向GDT_SECT_DATASTACK段描述符对应的段 mov esp, LOADER_STACK_TOP mov ax, GDT_SECT_VIDEO mov gs, ax ;这里将gs设置为GDT_VIDEO段描述符对应的段,即显存段,那里没有使用平坦模式,访问显存仍然类似于实模式 ; 在开启保护模式的基础上向文本模式的显示适配器区域写入数据 ;------------------------------------------------------------------------ ; 每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性 ; 由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b ;------------------------------------------------------------------------ mov cx, 0x0 mov byte al, [format] ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中 LOOP_PROTECT: mov di, cx mov byte dl, [di + protectionMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符 sub dl, 0 jz LOOP_PROTECT_END ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置 add di, di add di, 320 ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第3行,则需要从80 * 2 * 2 = 320的偏移开始 mov byte [gs:di], dl ;这里通过变址寻址访问内存 add di, 1 mov byte [gs:di], al ;这里通过变址寻址访问内存 add cx, 1 jmp near LOOP_PROTECT ;无条件相对近跳转,会重新跳转到LOOP处执行循环 LOOP_PROTECT_END: ;------------------------------------------将内核数据读入内存中------------------------------------------------------------------- ; 调用rd_disk_n读入数据,由于内核文件还是比较大的,但并没有超过100KB,这里我们就读取100K / 512B = 200,我们就读取200个扇区 ;--------------------------------------------------------------------------------------------------------------------------------- mov eax, KERNEL_START_SECTOR mov ebx, KERNEL_BASE_ADDR mov ecx, 200 call rd_disk_n ;---------------------------------------准备开启分页机制--------------------------------------------------------------------------- ; 1. 构建页目录表和页表 call SETUP_PAGE ;--------------------------------------------------移动GDT-------------------------------------------------------------------------- ; 由于我们都将内核运行在3GB以上,因此我们同样需要重启加载GDT,从而确保GDTR是从内核空间进行访问的 ; 其中需要注意的是,其余代码段、数据-栈段都是平坦模式,但是显存段并没有使用平摊模式,因此我们需要修改其基址到内核空间中 ; 还有esp,也要修改其值到内核空间中 ; 这些步骤很简单,就是加上内核空间的基址即可 sgdt [GDT_PTR] ;将GDTR内容进行保存 mov dword ebx, [GDT_PTR + 2] ;获取GDT的基址 or dword [ebx + 0x18 + 4], 0xc0000000 ;将显存段移动到0xc0000000, 也就是段描述符最高8位修改即可 or dword [GDT_PTR + 2], 0xc0000000 ;将GDT也移动到0xc0000000 add esp, 0xc0000000 ;同样将栈空间进行映射 ; 2. 将页目录表地址写入控制寄存器cr3 mov eax, PAGE_DIR_TABLE_POS mov cr3, eax ; 3. 修改CR0寄存器 mov eax, cr0 or eax, 0x80000000 mov cr0, eax ;--------------------------------------------------重载GDT------------------------------------------------------------------------- lgdt [GDT_PTR] jmp GDT_SECT_CODE:PAGE_END ;无条件跳转,强制刷新流水线,并更新gdt PAGE_END: ; 在开启分页机制的基础上向文本模式的显示适配器区域写入数据 ;------------------------------------------------------------------------ ; 每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性 ; 由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b ;------------------------------------------------------------------------ mov cx, 0x0 mov byte al, [format] ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中 LOOP_PAGE: mov di, cx mov byte dl, [di + pageMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符 sub dl, 0 jz LOOP_PAGE_END ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置 add di, di add di, 480 ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第4行,则需要从80 * 2 * 3 = 480的偏移开始 mov byte [gs:di], dl ;这里通过变址寻址访问内存 add di, 1 mov byte [gs:di], al ;这里通过变址寻址访问内存 add cx, 1 jmp near LOOP_PAGE ;无条件相对近跳转,会重新跳转到LOOP处执行循环 LOOP_PAGE_END: ;------------------------------------------------------------------------ ; 我们将上面的指令分析一下 ; 可以看到,对于内存寻址来说,这里通过直接寻址进行寻址 ; 我们每一次输入两个字节信息,其中低字节是上面分析的字符的属性 ; 高字节是字符对应的ascii码,从而完成了内存的写入。 ; 完成内核映像的构建,并且将程序流转移给内核映像 ;-------------------------------------------------------------------------- call KERNEL_INIT ; 成功开启分页后,完成输出提示信息 ;------------------------------------------------------------------------ ; 每个字符2字节,其低字节为字符对应的ASCII码,高字节为字符的属性 ; 由于其为背景蓝色,前景色浅品红色,不闪烁,其高字节值为 00011101b ;------------------------------------------------------------------------ mov cx, 0x0 mov byte al, [format] ;初始化计数器cx,。由于前面已经设置了ds段寄存器为0,该指令相当于将字符属性字节读入ax寄存器中 LOOP_KERNEL: mov di, cx mov byte dl, [di + kernelMsg];这里通过变址寻址访问内存,由于前面设置了ds段寄存器为0,这里直接获取字符串中的对应字符 sub dl, 0 jz LOOP_KERNEL_END ;判断字符串是否结束。有条件跳转,因此仅仅修改段偏移地址,由于cs始终为0,自然跳转到LOOPEND对应的位置 add di, di add di, 640 ;由于VGA模式为80 * 25,即一行80个字符,每一个字符2字节,如果输出在终端的第5行,则需要从80 * 2 * 4 = 640的偏移开始 mov byte [gs:di], dl ;这里通过变址寻址访问内存 add di, 1 mov byte [gs:di], al ;这里通过变址寻址访问内存 add cx, 1 jmp near LOOP_KERNEL ;无条件相对近跳转,会重新跳转到LOOP处执行循环 LOOP_KERNEL_END: mov esp, 0xc009f000 ;将栈也转移到对应的位置 ; 下面跳转至内核映像处,并进行执行 ;------------------------------------------------------------------------ jmp GDT_SECT_CODE:KERNEL_ENTRY_ADDR ;无条件相对近跳转 ;-----------------------------------下面开始创建页目录以及页表---------------------------------------------- SETUP_PAGE: mov ecx, 4096 ;先把页目录占用的空间逐字节清零 mov esi, 0 .CLEAR_PAGE_DIRECTORY: mov byte [ds:PAGE_DIR_TABLE_POS + esi], 0x0 ;平坦模式,且保护模式下内存访址很灵活 inc esi loop .CLEAR_PAGE_DIRECTORY ;保护模式下,ecx作为循环计数器 ; 创建页目录项PDE ;---------------------------------------------设计说明------------------------------------------------------ ; 对于页目录表和页表,实际上上有一些额外的注意点 ; 1. 在加载内核前,程序中运行的一直都是loader程序,其本身的代码都在低端1MB内存中,所以需要确保其在内存分页机制下和虚拟地址和段机制下的线性地址这两者对应的物理地址是一致的。而段机制下线性地址和物理地址一一对应。也就是虚拟地址0-0xfffff必须一一映射到物理地址的0-0xfffff,也就是页目录表第0项对应的页表0-0xff页表项值已经确定 ; 2. 前面已经分析过了,操作系统会被加载到低1MB内存空间中,而同时我们需要将操作系统物理地址映射到高0xc0000000处,其对应的页目录表索引是0x300。那么也就是页目录表第0x300项对应的页表0-0xff页表项值已经确定 ; 这里不妨直接将页目录表第0项页目录项和第0x300页目录项指向同一个页表 ; 3. 我们有可能还想在虚拟地址中动态操作页表,因此将第1023页表项指向自己(开启分页机制后,虽然cr3寄存器也可以直接访问到页表的物理地址,但是即使我们获取了cr3寄存器值,也仅仅是物理地址,我们进行访存的时候,仍然会当做虚拟地址,在进行转换),这里要说明的就是,开启分页机制后,只有页部件可以以物理地址访问,其余所有的地址都会首先经过页部件进行转换。 ;--------------------------------------------------------------------------------------------------------------------- ; 首先构造页目录表的第0页目录项和第0x300的页目录项,分别指向对应的页表(页表和页表目录表紧挨着) mov eax, PAGE_DIR_TABLE_POS add eax, 0x1000 ;即这是第1个页表地址 mov ebx, eax ;保存第1个页表的地址 or eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1 ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘 ; 将构造好的页表地址分别放入第0个页目录项和第0x300(偏移为0xc00) mov [ds:PAGE_DIR_TABLE_POS], eax mov [ds:PAGE_DIR_TABLE_POS + 0xc00], eax ; 将页目录项地址放入第1023(0x3ff)项(偏移为0xffc, 4092) sub eax, 0x1000 mov [ds:PAGE_DIR_TABLE_POS + 4092], eax ;--------------------------------下面构建第一个页表的页表项(PTE)------------------------------------------------------ mov ecx, 0x100 ;前面分析需要映射到低端1MB内存中,所以页表需要完成1MB / 4KB = 256个页表项 mov esi, 0 mov eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1 ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘 .LOOP_CREATE_PTE: mov [ds:ebx + 4 * esi], eax ;ebx在前面保存了第1个页表的地址 add eax, 0x1000 inc esi loop .LOOP_CREATE_PTE ;------------------------------------完善内核空间的其他页表--------------------------------------------------------------- ; 前面将第0x300页目录项,也就是对应虚拟地址 0xc0000000~(0xc0000000 + 4M)的空间完成了映射(实际上完成了1M的部分映射) ; 第1023页目录项,完成了映射(映射到了自己,即页目录表) ; 但是为了完全共享内核,需要将0x300~1023所有的页目录项全部初始化好 ; 否则如果仅仅初始化一部分,新建用户进程,为了共享会将内核页目录表复制到用户进程页目录表,但如果此时系内核申请内存,新使用了 ; 页目录项,而对应的用户进程则无法同步 ;------------------------------------------------------------------------------------------------------------------------ mov eax, PAGE_DIR_TABLE_POS add eax, 0x2000 ;此时指向第二个页表地址 or eax, PTDE_US_1 | PTDE_RW_1 | PTDE_P_1 ;User用户级(任何特权级都可访问);可读可写;内存存在硬盘 mov esi, 0x301 mov ecx, 254 ;即1022 - 0x301 + 1 mov ebx, PAGE_DIR_TABLE_POS .LOOP_CREATE_KERNEL_PDE: mov [ds:ebx + 4 * esi], eax add eax, 0x1000 inc esi loop .LOOP_CREATE_KERNEL_PDE ret ; 下面我们实现一个函数调用 ;-------------------------------------------------------------------------- ; 函数名称: rd_disk_n 功能描述:读取硬盘的n个扇区到指定地址 ;-------------------------------------------------------------------------- ; 输入: ; eax--LBA扇区号 ; ebx--讲数据写入的内存地址 ; cx--读入的扇区数 ; 输出: ;-------------------------------------------------------------------------- rd_disk_n: mov esi, eax; 备份eax寄存器的值 mov di, cx; 备份cx寄存器的值 ; 第1步,设置要读取的扇区数 mov dx, 0x1f2 mov al, cl out dx, al ;向0x172寄存器写入cx的值 mov eax, esi ;恢复eax的值 ; 第2步,将LBA地址存入LBA寄存器 ; 将LBA的低8位存入0x1f3 mov dx, 0x1f3 out dx, al ;向0x173寄存器写入LBA扇区号的低8位 ; 将LBA的8-15位存入0x1f4 mov dx, 0x1f4 shr eax, 8 out dx, al ;向0x174寄存器写入LBA扇区号的8-15位 ; 将LBA的16-23位存入0x1f5 mov dx, 0x1f5 shr eax, 8 out dx, al ;向0x1f5寄存器写入LBA扇区号的16-23位 ; 将LBA的24-27存入0x1f6,并且设置对应的第4位为1,从盘;第6位1,LBA;第5,7位1,MBS mov dx, 0x1f6 shr eax, 8 or al, 11110000b out dx, al ;向0x1f6寄存器写入 1111 | LBA扇区号的24-27位 ; 第3步,将0x1f7端口写入读命令,即read sector,0x20 mov dx, 0x1f7 mov al, 0x20 out dx, al ; 第4步,检测硬件状态 .not_ready: nop ;相当于什么都不做,防止打扰磁盘的工作 in al, dx ;读取硬盘Status寄存器 and al, 00001000b ;第3位表示硬盘数据已经准备好了 cmp al, 00001000b jnz .not_ready ;如果数据未准备好,则继续等待 ; 从0x1f0端口读取数据 mov ax, di ;将前面备份的cx赋值给ax,也就是读取的扇区数 mov dx, 256 ;data寄存器16位,一次读取2字节,共cx * 512 / 2次 mul dx ;这里是mul指令,格式为mul register,计算register * ax/al ;如果register是8位,则为register * al,结果放在ax寄存器中 ;如果register是16位,则为register * ax,结果存入eax中 ;这里是第2中情况,即将总字节数放置eax寄存器中,因为仅仅256就大于8位,因此这种是合理的 mov cx, ax ;这里设置循环次数,虽然上面说了乘积为16位,但是实际上我们的loader不会太大 ;因此其字节数不会超过16位,我们只需要低位即可 mov dx, 0x1f0 .go_on_read: in ax, dx ;读取硬盘Data寄存器 mov word [ebx], ax;变址寻址,将数据存储到目的地址中,前面ds段寄存器已经被置为0 add ebx, 2 loop .go_on_read ;重复循环cx次,这里需要说明,由于在实模式下,因此bx最多16位 ;即其遍历最大0x0-0xffff的值,也就是其最多可以读入的内存为64KB ret ;因为其为近跳转,没有段寄存器的变化,所以ret即可 ;-----------------------------------下面将从内核数据构建内核映像------------------------------------------------- ; 也就是简单分析内核elf文件,然后根据其elf文件头文件信息,将需要的段/segment复制到对应的虚拟地址中 ; 完成内核映像的构建 ;----------------------------------------------------------------------------------------------------------------- KERNEL_INIT: xor eax, eax xor ebx, ebx xor ecx, ecx xor edx, edx ;清零相关的寄存器 mov word dx, [KERNEL_BASE_ADDR + 42] ;获取2字节的程序头表中每一个条目的大小 mov dword ebx, [KERNEL_BASE_ADDR + 28] ;获取4字节的程序头表和文件起始的偏移值 add ebx, KERNEL_BASE_ADDR ;此时ebx寄存器中保存的即为程序头表的地址 mov word cx, [KERNEL_BASE_ADDR + 44] ;获取程序头表的条目个数 .KERNEL_INIT_LOOP: cmp dword [ebx + 0], PT_NULL je .KERNEL_INIT_PT_NULL ;如果为pt_NULL类型,则直接略过复制,进行下一个便利 ;下面开始调用mem_cpy函数进行复制 ;--------------------------------------------------------------------------------------------------------------- ; [ebp + 8] 目的地址 ; [ebp + 12] 源地址 ; [ebp + 16] size ;---------------------------------------------------------------------------------------------------------------- push dword [ebx + 16] ;将程序头表偏移16,也就是段的大小,入栈 mov eax, [ebx + 4] ;当前段相对文件的偏移 add eax, KERNEL_BASE_ADDR ;此时eax寄存器保存当前段的地址 push dword eax ;将当前段的起始地址,入栈 push dword [ebx + 8] ;见程序头表偏移8,也就是段的目的虚拟地址,入栈 call MEM_CPY ;进行数据复制 add esp, 12 ;恢复栈平衡 .KERNEL_INIT_PT_NULL: add ebx, edx ;获取程序头表中下一个条目的地址 loop .KERNEL_INIT_LOOP ;通过loop命令进行快速遍历 ret ;-----------------------------------下面完成数据复制功能------------------------------------------------ ; 这里主要通过调用rep指令完成数据的复制 ;----------------------------------------------------------------------------------------------------------------- MEM_CPY: cld ;清除方向标志,这里稍微简单介绍一下这个指令 ;首先需要简单介绍一下movs搬移指令族,其将ds:(e)si指向的地址处的数据搬移到es:(e)di指向的地址处 ;往往用来配合rep指令,进行重复操作,也就是重复ecx次,而这个方向是通过eflaes寄存器中的DF标志位——DF为0,表示从低向高;DF为1,表示从高相低 ;而cld(clearn direction)和std(set direction)指令可以通过修改DF位,控制其方向,这里需要地址不断增大,因此需要DF为0,也就是cld指令即可 ;下面是正常函数调用的其实操作,压入ebp,然后通过[ebp + offset]访问参数 push ebp mov ebp, esp ;此时第n个参数地址为[ebp + 8 + 4 * n] push ecx ;保存一下ecx,因为caller函数中同样需要ecx mov edi, [ebp + 8] ;目的地址 mov esi, [ebp + 12] ;源地址 mov ecx, [ebp + 16] ;size参数 rep movsb ;逐字节copy pop ecx ;恢复ecx的值 pop ebp ret ; 下面进行常量设置 ;------------------------------------------------------------------------ loaderMsg db "Hawk's LOADER", 0 ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束 protectionMsg db "Now in PROTECTION mode", 0 ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束 pageMsg db "Now enable Paging", 0 ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束 kernelMsg db "load Kernel", 0 ;即伪操作指令,表示每一个元素大小为1字节, 并且在结尾为\x00表明字符串结束 format db 00011101b ;这里是显存中的字符属性,表明其为背景蓝色,前景色浅品红色,不闪烁 times (2560 - ($ - $$)) db 0 ;使用0填充至5个扇区
这里实际上大体上和前面的loader程序太大区别。这里简单介绍一下修改的部分
1. 将gs段寄存器作为显存段寄存器段基址——因为前面一直在使用es段寄存器当做段基址,但是这里使用到了movs指令族,其会使用到es段寄存器和ds段寄存器,所以需要进行适当地修改
2. 在开启保护模式后和开启分页机制前,中间插入读取磁盘的函数来读取内核数据——这里读取磁盘的函数和前面MBR程序中读取磁盘的函数没有什么太大的区别,也不进行赘述了。
3. 在开启分页机制后,将读取的内核数据根据ELF头文件,映射到目的虚拟地址处,并将程序流转移。这里稍微需要说明的一下是,根据头文件进行分析并没有什么困难,就是根据前面博客中讲到的,根据elf头文件的对应字段,进行循环即可。这里面的数据复制使用的是movs指令族配合rep指令,从而方便的进行数据的复制。
最后就是内核数据,我们前面已经说过了,这里的内核不会实现任何功能,仅仅悬停用来观察内核被成功进行加载,其源代码如下所示
int main(void) { while(1){;} //让程序进行悬停 return 0; }
这样子,我们就基本完成了此次试验。下面开始进行测试,首先编译内核数据,命令如下所示
gcc kernel/main.c -m32 -c -o kernel/main.o ld -melf_i386 kernel/main.o -Ttext 0xc0002000 -e main -o kernel/kernel.bin dd if=kernel/kernel.bin of=../hawk.img bs=512 seek=10 count=200 conv=notrunc
结果如图所示
然后和以前差不多,仍然是完成mbr程序、loader程序的编译和写入,命令如下所示
nasm -I include/ -o mbr.bin mbr.S nasm -I include/ -o loader.bin loader.S dd if=mbr.bin of=../hawk.img bs=512 count=1 conv=notrunc dd if=loader.bin of=../hawk.img bs=512 seek=1 count=5 conv=notrunc
结果如图所示
最后我们进行虚拟机上的运行,如图所示
可以看到,确实成功在内核空间中执行了内核代码。