【操作系统】内核雏形

实验目的

如何生成一个内核,能引导该内核,并进行扩展

实验内容

  1. 汇编和C的互相调用方法

  1. ELF文件格式

  1. 使用Loader加载ELF文件

  1. 如何加载并扩展内核

  1. 学习使用makefile

实验过程

  1. 汇编和C的互相调用方法

以书本举例为例,源代码包含两个文件:foo.asm和bar.c。程序人口_start在foo.asm中,一开始程序将会调用bar.c中的函数choose(),choose()将会比较传入的两个参数,根据比较结果的不同打印出不同的字符串。打印字符串的工作是由foo.asm中的西数myprint()来完成的。整个过程如图所示

代码foo.asm

对以上代码的说明:

(1)由于在bar.c中用到函数myprint(),所以要用关键字g1oba1将其导出。

(2)由于用到本文件外定义的函数choose(),所以要用关键字extern声明。

(3)不管是myprint()还是choose(),遵循的都是C调用约定(C Calling Convention),后面的参数先入栈,并由调用者(Caller)清理堆栈。

文件bar.c的内容很简单,包含函数myprint()的声明和函数choose()的主体,如下

  1. ELF文件格式

-依照书上方法,分析你修改的这个可执行文件

格式如上图所示,其中我们来分析每一个部分的含义和数据结构。

ELF header:

可执行文件foobar的开头如图所示:

开头的4字节是固定不变的,第1个字节值为0x7F,紧跟着就是ELF三个字符,这4字节表明这个文件是个ELF文件。

e_type 它标识的是该文件的类型,文件foobar的e_type是2,表明它是一个可执行文件。

e_machine foobar中此项的值为3,表明运行该程序需要的体系结构为Intel 80386。

e_version 这个成员确定文件的版本。

e_entry 程序的入口地址。文件foobar的入口地址为0x80480A0

e_phoff Program head table在文件中的偏移量(以字节计数)。这里的值是0x34。

e_shoff Section head table在文件中的偏移量(以字节计数)。这里的值是0x1D4。

e_flags 对IA32而言,此项为0。

e_ehsize ELF header大小(以字节计数)。这里值为0x34。

e_phentsize Program header table中每一个条目(一个Program header)的大小。这里值为0x20。

e_phnum Program header table中有多少个条目,这里有3个。

e_shentsize Section header table中每一个条目(一个Section header)的大小,这里值为0x28。

e_shnum Section header table中有多少个条目,这里有7个。

e_shstrndx 包含节名称的字符串表是第几个节(从零开始数)。这里值为6,表示第6个节包含节名称。

Program Header:

Program header描述的是一个段在文件中的位置、大小以及它被放进内存后所在的位置和大

小。如果我们想把一个文件加载进内存的话,需要的正是这些信息。

程序头表中共有三项,偏移分别是0x34~0x53、0x54~0x73、0x74~0x93。

p_type 当前Program header所描述的段的类型,分别为0x1、0x1、0x6474E551。

p_offset 段的第一个字节在文件中的偏移,分别为0x0、0x164、0x0。

p_vaddr 段的第一个字节在内存中的虚拟地址,分别为0x8048000、0x8049164、0x0。

p_paddr 在物理地址定位相关的系统中,此项是物理地址保留,分别为0x8048000、0x8049164、0x0。

p_filesz 段在文件中的长度,分别为0x164、0x8、0x0。

p_memsz 段在内存中的长度,分别为0x164、0x8、0x0。

p_flags 与段相关的标志,分别为0x5、0x6、0x7。

p_align 根据此项值来确定段在文件中以及内存中如何对齐,分别为0x1000、0x1000、0x10。

  1. 使用Loader加载ELF文件

我们的期望是使用Loader加载ELF文件,在之后肯定是要加载内核文件的。加载一个文件无非就是寻找文件、定位文件、读入内存三个步骤,所以在这次的loader中也是遵循这个步骤。

和之前实验的思路是类似的,只不过我们这次寻找的是kernel.bin文件。

在这里我们先随便写一个简单的kernel.asm文件来测试一下:

其作用就是显示一个字母K。

运行这个Loader程序。

  1. 加载并扩展内核

在上一步骤中,我们已经把kernel加载进了内存,但要想使内核运行并移交控制权,我们还需要先进入保护模式。

(1)跳入保护模式

首先是GDT以及对应的选择子,我们只定义三个描述符,分别是一个0~4GB的可执行段,一个0~4GB的可读写段和一个指向显存开始地址的段。

Loader的GDT:

如今,Loader是我们自己加载的,段地址就是 BaseOfLoader ,因此 Loader 中出现的标号的物理地址可以表示为:标号的物理地址=BaseOfLoader * 10h + 标号的偏移。

将 BaseOfLoader 以及相关声明写在同一个文件中 load.inc 中:

在boot.asm和loader.asm 中分别以一句 %include “load.inc” 将 load.inc包含。

其中BaseOfLoaderPhyAddr 用来代替 BaseOfLoader * 10h。

定义Loader的32位代码段,打印一个字符 “P”:

Loader进入保护模式:

运行,结果如下:

看到“P”说明成功进入保护模式。

(2)启动分页机制

首先初始化各个寄存器的值:

其中,TopOfStack 定义如下,为1KB 的堆栈:

为了得到可用内存的信息,在Loader开头的32位代码段中添加以下内容:

添加打印内存信息的部分:

添加启动分页机制的部分:

定义页目录和页表:

现在,我们来调用它们以显示内存信息和启动分页:

运行,结果如下:

(3)重新放置内核

我们要做的工作是根据内核的Program header table的信息进行内存复制:

如果Program header有n个,复制就进行n次。每一个Program header都描述一个段,p_offset 为段在文件中的偏移,p_filesz 为段在文件中的长度,p_vaddr 为段在内存中的虚拟地址。

由 ld 生成的可执行文件中,p_vaddr 的值太大,比如 0x8048xxx,这显然已经超出了128MB的内存范围。所以我们需要修改 ld 的选项来让它生成的可执行代码中 p_vaddr 的值变小。因此,将编译链接时的命令改为:

程序的入口地址就变为 0x30400了, ELF header 等信息位于 0x30400 之前。

查看kernel.bin的ELF header:xxd -u -a -g 1 -c 16 -l 80 kernel.bin

查看kernel.bin的Program header table:xxd -u -a -g 1 -c 16 -s +0x34 -l 0x20 kernel.bin

所以,我们应该这样放置内核:memcpy(30000h, 90000h + 0, 40Dh);

也就是说,我们应该把文件开始的 40Dh 字节内容放到内存 30000h 处。又因为程序入口在 30400h ,所以代码只有0E( 0Dh + 1) 个字节。

查看kernel.bin的内容:xxd -u -a -g 1 -c 16 kernel.bin

从中可以看出,从 400h ~ 40Dh 是仅有的代码,0xEBFE即“jmp $”。

下面在lodaer.asm增加代码以将 kernel.bin 根据ELF文件信息转移到正确的位置,找到每个Program header,根据其信息进行内存复制。

在32位代码段增加:call InitKernel

在loader.asm增加InitKernel

(4)向内核交出控制权

向内核跳转:

运行,结果如下:

第二行中央出现字符“K”,说明内核在执行。

扩充内核

(5)切换堆栈和GDT

切换堆栈和GDT的汇编代码如下:

C语言函数代码如下:函数cstart()首先把Loader中的原GDT复制给新的GDT,然后把gdt_prt中内容换成新的GDT的基地址和界限。

其中用到新定义的类型、结构体和宏可在type.h、const.h以及protect.h中找到。

编译链接:

运行,结果无事发生。

添加打印字符的代码:kliba.asm

在start.c中调用:

重新编译:

运行,结果如下:

(5)Makefile

整理文件夹后,目录树如下所示:

写一个makefile来编译/boot文件夹内的boot.bin和loader.bin:

“#”开头的是注释;“=”用来定义变量,ASM, ASMFLAGS 就是变量,使用它们的时候需要 $(ASM), $(ASMFLAGS) 。

Makefile的最后两行:

  1. 得到loader.bin文件,需要执行“$(ASM) $(ASMFLAGS) -o $@ $<”命令。

  1. 而loader.bin则依赖 boot/loader.asm boot/include/load.inc boot/include/fat12hdr.inc boot/include/pm.inc文件,其中至少一个文件比loader.bin新时,command被执行。

这条命令中$@代表target,$<代表prerequisites的第一个名字。

故而这条命令实际上等价于”nasm –o loader.bin loader.asm”。

此外,在makefile中,除了boot.bin和loader.bin两个文件后面有冒号,everything、clean和all后面也有冒号,但这三个不是文件,而是动作名称,比如运行“make clean”,就会执行“rm –f $(TARGET)”,也就是“rm –f boot.bin loader.bin”。

运行相关操作:

扩展Makefile:

现在,我们来扩展makefile,使得它能够编译链接我们整个项目。

代码太长不具体列出,主要结构和上面基本一致,要是把文件都加上了路径boot/ 。

因为目录层次的原因,我们把GCC的选项也增加了对头文件目录的指定“-I include”。

输入make image来把引导扇区、loader.bin和kernel.bin写入虚拟软盘:

往start.c里面添加一行打印代码:

重新make一下,启动,结果如下:

(6)添加中断处理

初始化8259A:

宏定义:

const.h

protect.h

函数init_8259A只用到一个函数,就是用来写端口的out_byte,此外我们还添加了in_byte用于对端口进行读操作,由于端口操作需要时间,所以两个函数都加了nop指令来添加延迟:

将函数声明写入头文件include/proto.h中,同时我们还将disp_str的声明也移入了头文件中,将memcpy放入头文件string.h中。

修改makefile:可以使用gcc的-M参数来自动生成依赖关系,然后再把依赖复制到makefile中。

接下来,初始化IDT,修改start.c:

代码和之前初始化GDT部分基本一致,此外,我们将原来位于start.c开头的gdt[]等的声明转移到global.h文件中。不过其中的EXTERN关键词定义在const.h中,通常情况下它被定义成extern。但是在global.h中你会发现,如果宏GLOBAL_VARIABLES_HERE被定义的话,EXTERN将会被定义成空值。联系global.c就会发现,通过宏GLOBAL_VARIABLES_HERE的使用,在让所有变量只出现一次(在global.h中)的同时,预编译结束后, global.c和其他.c文件中的结果不同。在global.c中,变量前面没有extern关键字,而在其他文件中,变量前将会有extern关键字。最后我们添加两句代码导入idt_ptr这个符号并加载IDT就可以了。

定义中断和异常:

我们对异常处理的主体思想是,如果有错误码,则直接把向量号压栈,然后执行一个函数exception_handler,如果没有错误码,则先在栈中压入一个oxFFFFFFFF,再把向量号压栈并随后执行exception_handler。我们使用C语言调用函数时无需担心破坏堆栈中的eip,cs和eflags。代码示例如下:

函数exception_handler,即异常处理函数,其主要思路就是把屏幕前5行通过打印空格的方式清空,然后再把堆栈当中的参数打印出来。

为了区别异常处理打印的字符串和正常情况输出的字符串,异常处理使用disp_color_str()函数(lib/kliba.asm),其实本质和disp_str()一样,只不过它增加了一个设置颜色的参数,来改变输出字符串的颜色。此外,我们还添加了一下disp_int函数(lib/klib.c)来打印整数,并添加了itoa函数将整数转化为字符串,当然,它只是把一个32位的数值用十六进制的方式显示出来,并不支持其他情况。

设置IDT:

把相关代码放入init_prot当中,位于protect.c当中。protect.c几乎只调用init_idt_desc函数,用它来初始化一个门描述符:

在init_prot()中,所有的描述符都初始化为中断门。

调用init_prot():

ud2能够产生一个#UD异常,在kernel.asm里添加一个ud2指令来测试一下我们的程序:

make后运行:

使用8259A进行中断:

我们现在已经完成了8259A的设置,我们在kernel.asm中定义了所有的中断例程,所有的中断都会触发一个spurious_irq的函数:

这个函数其实就是把IRQ号打印出来。

接下来设置IDT(显示部分示例):

现在已经可以运行了,只不过不会有结果,因为我们没有通过任何方式来设置IF位,而且在init_8259A()里把中断都屏蔽了,现在修改i8259.c,打开键盘中断:

在这里,我们向主8259A相应端口写入了0xFD,即打开了键盘中断,其他中断仍然处于屏蔽状态。然后在kernel.asm中添加sti指令设置IF位:

make运行,并敲击键盘:

make,运行,开始没有什么特殊现象,但当我们敲击键盘任意键时,出现字符串“spurious_irq:0x1”,这表明当前的IRQ号为1,正是对应的键盘中断。

课堂题目

  1. 修改启动代码,在引导过程中在屏幕上画出一个你喜欢的ASCII图案,并将第三章的内存管理功能代码、你自己设计的中断代码集成到你的kernel文件目录管理中,并建立makefile文件,编译成内核,并引导。

(1)画出一个ASCII图案

在kernel/start.c中编写函数,显示两个颜文字

在kernel/kernel.asm中extern声明 printBootLogo函数,并调用

结果如下:

(2)中断函数集成

修改代码,使kernel进入死循环(防止hlt被唤醒后继续执行后面的指令,导致出现错误)

修改hwint01函数:

结果如下,每按一次键盘,发现颜色发生变化: