内核雏形
本章主要是研究一下内核的可执行文件格式ELF并实现一个内核的雏形,值得庆祝的是,这一章终于可以使用C语言进行编程了。
首先,提出一个必须明确的问题——Loader应该走多远?
- 完成了实模式到保护模式的跳转后。其他的一些工作,比如GDT、IDT、8259A的初始化等是交给内核做还是也由Loader完成?
- 实际上,从逻辑上将,Loader不是OS的一部分,所以不该越俎代庖。
- 因此,还是尽量让Loader简单,其余工作交给内核。
另外,写内核用什么编程语言?
- 答案是主要用C语言,但部分还是得用汇编。这就意味着,我们将会迎来一个让人兴奋的事:多语言混合编译成可执行文件,两种语言可以编译到一起还能相互调用,这是我之前未曾体验的…(还挺期待的^_^哈哈!)
- 使用汇编的部分主要有比如:时钟中断处理、异常处理,甚至于进程调度的一部分代码,因为这些要直接操作寄存器的操作,用C语言没法直接进行。
- 说到这里,我又产生了一个想法:单片机编程都可以用语言操作寄存器呀…em想想也容易想通,单片机操作寄存器都要调用官方提供的接口呀,那些接口程序应该最终也是调用的汇编程序吧。。。
Linux下汇编的Hello World
这一步主要内容:生成一个ELF格式可执行文件(可以被linux识别和执行)、导出汇编程序入口点给链接程序识别、调用Linux系统的系统调用
代码比较短,直接贴出:
; 编译链接方法
; (ld 的‘-s’选项意为“strip all”)
;
; $ nasm -f elf hello.asm -o hello.o
; $ ld -s hello.o -o hello
; $ ./hello
; Hello, world!
; $
[section .data] ; 数据在此
strHello db "Hello, world!", 0Ah
STRLEN equ $ - strHello
[section .text] ; 代码在此
global _start ; 我们必须导出 _start 这个入口,以便让链接器识别
_start:
mov edx, STRLEN
mov ecx, strHello
mov ebx, 1
mov eax, 4 ; sys_write
int 0x80 ; 系统调用
mov ebx, 0
mov eax, 1 ; sys_exit
int 0x80 ; 系统调用
- strip all,这里的意思是“剥除”掉ELF文件中的符号表等内容(符号表,主要用来调试用的吧)
- 入口点默认是"_start"这个标号所在的位置,我们不但要定义它,还要用global关键字将其导出,这样链接程序才能找到它。得到ELF文件的文件头里有一项就是程序的入口地址
- 汇编调用Linux系统调用(中断号就是0x80):也是用int指令,因为我们要写自己的OS所以自然用不到Linux的系统调用,而且要自己提供系统调用。Linux的所有系统调用都通过这个0x80中断
- 这里的链接会产生“ld: i386 架构于输入文件 hello.o 与 i386:x86-64 输出不兼容”错误,由于我们的linux是64位的默认ld以64位链接,而hello.o是32位格式的。解决方法
下面就是令人激动的时刻:汇编和C语言共存
这里只是先举个具体的例子来学习怎么实现汇编和C语言程序链接成可执行程序。为后面的具体使用打基础,正所谓先有感性认识来产生兴趣,这样才能在后面理性而相对枯燥的使用坚持下去。
汇编代码和C语言代码的相互调用
sequenceDiagram
foo.asm->>bar.c: foo.asm中的_start部分代码调用bar.c的choose()
bar.c->>foo.asm: choose()里调用foo.asm里的myprint
汇编代码foo.asm
; 编译链接方法
; $ nasm -f elf foo.asm -o foo.o
; $ gcc -c bar.c -o bar.o
; $ ld -s hello.o bar.o -o foobar
; $ ./foobar
; 用extern关键字来声明本文件之外的函数名
extern choose ; int choose(int a, int b);
[section .data] ; 数据在此, choose()函数的两个参数
num1st dd 3
num2nd dd 4
[section .text] ; 代码在此
global _start ; 我们必须导出 _start 这个入口,以便让链接器识别。
global myprint ; 导出这个函数为了让 bar.c 使用
_start:
; 汇编的参数通过栈传递(调用C程序就必须通过栈传递),而且有趣的是这里并没有自己定义栈段
; 遵循C Calling Convention,后面参数先入栈,并由调用者Caller清栈
push dword [num2nd] ; `.
push dword [num1st] ; |
call choose ; | choose(num1st, num2nd);
add esp, 8 ; / 调用者清栈
mov ebx, 0
mov eax, 1 ; sys_exit
int 0x80 ; 系统调用
; void myprint(char* msg, int len)
myprint:
mov edx, [esp + 8] ; len
mov ecx, [esp + 4] ; msg
mov ebx, 1
mov eax, 4 ; sys_write
int 0x80 ; 系统调用
ret
C语言代码bar.c
void myprint(char* msg, int len); // 同样,调用的本文件外的函数,要前置声明
int choose(int a, int b)
{
if(a >= b){
myprint("the 1st one\n", 13);
}
else{
myprint("the 2nd one\n", 13);
}
return 0;
}
一些注意点已在代码注释中标出,就不另外解释了。
ELF(可执行可链接格式)
ELF由4部分组成(不一定都有),除了ELF header外其余非固定,但ELF header中有其余3部分的信息
- ELF header:包含文件格式标示字符串(ELF)、运行所需体系结构(如i386)、程序入口地址以及其他3部分的信息等
- Program header table:Program header描述的是系统准备程序运行所需的一个段或者其他信息,如段类型、段长、vstart、对齐情况等。一个段,一个Program header
- Sections:
- Section header table:
从Loader到内核
用Loader加载ELF
与之前不同的是,这时,由于内核文件格式的变化,我们需要根据Program header table中的值把内核相应的段放到正确的位置。
具体做法:
- 像引导扇区处理Loader那样把内核文件读入内核(就是先不管内核文件的具体结构
- 读入内存后就好处理了,可以在保护模式下挪动内核在内存中的位置。
另外的几点小细节:
- 把内核读入内存部分,和读Loader除了文件名和目标地址外都一样,为什么不写成函数来调用?
- 函数调用时堆栈会栈很大空间,开销大,而引导扇区,每一字节都很珍贵。(考虑得真周到)
- 一些常量可以共享,比如FAT12的BPB头和一些equ. 写成一个fat12hdr.inc头文件来包含
没有kernel.bin,所以先写一个最基本的试试效果,同样也是显示一个提示字符串"Ready."后面的.表示读取的扇区数
跳入保护模式
跳入保护模式之前,要做好相应准备
首先就是
设置GDT
三个描述符
- 0~4GB 的可执行段
- 0~4GB 的可读写段
- 指向显存开始的段
初始化描述符,填入相应段基址
- 因为原来程序是由BIOS或者DOS加载的,所以原来的代码段描述符的段基址等内容是程序运行时计算(通过cs得到)后填入相应位置的。
- 而现在Loader和内核读入内存的位置由我们自己决定,所以位置也不用计算了,可以直接将物理地址作为常量写入 BaseOfLoaderPhyAddr,另外值得一提的是,由于我们前面说过保护模式下通常用平坦模型,重定位是个难题,而这个BaseOfLoaderPhyAddr就能够作为标号重定位的基地址,方便我们进行重定位。
- 更进一步,我们把BaseOfLoaderPhyAddr、BaseOfLoader、OffsetOfLoader这些几个文件里都用到的常量放进load.inc
后续步骤
- 进入保护模式,打开地址线A20等已经很熟悉了
- 得到并显示内存可用情况。这部分是在开头实模式的时候通过调用int 15h中断获取的内存信息,这里主要就是显示一下,并供分页机制使用
- 主要的内容是一个地址范围描述符结构(ARDS),每一项描述一段地址,包括BaseAddr,Length和Type。
- 其中Type描述是这个地址范围的地址类型,包括:1 AddressRangeMemory(表示可以被OS使用),2 AddressRangeReserved(表示这段地址正被使用或者被保留不能给OS用)
- 分页前的准备:根据内存信息计算内核任务所需PDE和页表数,然后初始化页目录和页表。仅仅内核的页映射是由Loader做的,用户程序都是内核做的。
- 正式开启分页:为了简化,线性地址都对应相等的物理地址且不考虑内存空洞等
运行效果如下:
重新放置内核
之前是直接将内核文件读到一个内存位置,而我们知道,ELF文件的Program header table里存了各个段的信息,比如段在内存中的虚拟地址,段在文件和内存中的对齐情况等。我们前面是将整个kernel.bin作为一个整体读入内存,接下来就需要参照ELF文件头以及Program header里的信息重新调整各个段在内存中的位置。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qbSjvXDn-1575170906505)(http://yeholdon.top/wp-content/uploads/2019/11/15745696086372832247485802021040.jpg)]
总结一下,也就是说,因为一个任务(进程)有自己独立的4GB虚拟地址空间,所以对于每一个可执行程序,运行后被加载进内存成为任务,而被加载的位置(虚拟地址)通常是由编译器来决定的,因为ELF文件中的Program header里的p_vaddr项是由编译器生成的。
但是,内核显然不能让编译器来决定其被加载的位置,内核是要常驻内存的,况且要和用户程序的内核空间对应起来。后面用户程序的线性地址和物理地址的映射也都是由内核决定的,内核自己在内存中的位置首先地明确,这样内核在进行页映射的时候才方便。
解决方法有两个:
- 修改页表让编译器生成的那个随机的地址映射到自己想要的物理地址
- 修改ld的选项使它生成的可执行文件中p_vaddr的值变小
显然,第二种方式更简单易行,毕竟编译器每次默认生成的p_addr都可能不一样,映射关系修改起来会很麻烦
为了能够让内核ELF文件里的头部信息受控制,可以使用编译器的编译选项来设置。
具体选项为:-Ttext 程序的入口地址(假设为0x30400)
ELF header等编译器添加的信息会位于0x30400之前
正式开始转移内核
总结起来就是:将kernel.bin根据ELF文件信息转移到正确的位置——找出每个Program header,根据其信息进行内存复制(这部分代码是一个函数InitKernel)
为什么是转移到0x30400而不是其他?
这个问题确实值得思考,我开始也有这个疑问,好在作者说明了这个问题:其实就是根据当前,也就是加载完内核后,内存的使用情况来决定的。具体见上图。
最后是运行结果,可以看到我们已经完成了:引导扇区 加载loader Loader加载内核 Loader进入保护模式 内核转移调整 转移到内核执行的所有步骤
扩充内核
搞了那么长时间,其实现在才进入真正意义上的操作系统。而且只是操作系统目前最核心(也最底层)的部分——内核。这一小节的目的是扩充内核。
后面的内容主要参考了Minix源码
前面才刚刚进入保护模式,然后跳入了内核执行,但是GDT和堆栈等还是用的在loader里的,因此下一步要做的就是切换堆栈和GDT。
- 对于堆栈,很简单,就是在内核中开辟堆栈空间并更新esp。
- 对于GDT,则是将原有的loader中的GDT复制到内核中,并重新lgdt,更新gdt_ptr。
注意点
-
PUBLIC和PRIVATE这两个宏是C语言本身没有的,这里使用并没有实际意义,只是为了增加代码可读性,显示变量的作用,表示是全局符号还是局部符号,所以PUBLIC实际宏定义为空,而PRIVATE实际宏定义为static,这样这两种类型符合在可执行文件中存储的位置就不同,存在不同的段中和汇编的global关键字相配合,实现两种语言的相互调用。
-
memcpy(&gdt, (void *)(*((u32 *)(&gdt_ptr[2]))), *((u16*)(&gdt_ptr[0])) + 1 );
这里的第二个参数:我们目的是传入一个地址,地址在C语言中用指针变量表示。但是这里,我们的地址是以字节为单位存在gdt_ptr[0]开始的4个字节里,所以要把数组中的连续4个元素转成一个32位的指针变量传入。因此,也才有了这个复杂的转换。 -
sudo mount-o loop a.img /mnt/floppy/的时候会出现错误mount point /mnt/floppy does not exist,这个要先mkdir /mnt/floppy/再执行,如果还不行,就得先sudo losetup /dev/loop0 a.img,创建loop设备,然后操作loop设备,就是对a.img数据的操作了,
loop设备挂载到/mnt/floppy上,sudo mount /dev/loop0 /mnt/floppy/
整理文件夹
从某种意义上来说,从这里开始,我们才真正开始操作系统的开发,但是前面的工作却都是必不可少的,汇编、保护模式、引导器、ELF文件格式、汇编C语言共存等等,有了前面打下的基础,接下来的工作就会轻松顺利很多。
到目前为止,我们的文件已经很多且复杂了,每次修改还要自己一个个使用单独的命令进行编译链接等整个过程实在太麻烦,所以是时候整理归类我们的文件,并使用自动化编译链接工具make了。
.
├── a.img 相当于我们装操作系统的磁盘
├── bochsrc bochs启动配置文件
├── boot 引导扇区和loader
│ ├── boot.asm
│ ├── include
│ │ ├── fat12hdr.inc fat12文件系统的头部
│ │ ├── load.inc 加载loader和内核时各个位置的定义
│ │ └── pm.inc 保护模式描述符类型值说明,和描述符结构宏定义
│ │ └── lib.inc 显示int,str,回车等内容的函数
│ └── loader.asm
├── boot.bin
├── include
│ ├── const.h 常量和宏等定义
│ ├── protest.h 段描述符结构体
│ └── type.h 各种C语言变量类型的typedef
├── kernel
│ ├── kernel.asm
│ └── start.c 和kernel.asm配合,结合汇编实现内核功能要对寄存器操作的用汇编,其余大多都可以用C语言
├── kernel.bin
├── kernel.o
├── kliba.o
├── lib 汇编用到的函数库
│ ├── kliba.asm 暂时只有一个显示字符串的函数
│ └── string.asm 字符串操作的库,目前只含memcpy
├── loader.bin
├── protest.c
├── start.o
└── string.o
Makefile
make及其参照的makefile文件简单来说就是将之前对各个文件逐个进行编译链接的过程用makefile文件以一定的格式表示出来,然后执行make这个自动化编译的程序的时候,它就能够根据makefile里规定的步骤自动执行整个编译链接过程,另外,比较重要的一定是,make会自动比较文件是否修改,从而避免未修改的文件重复编译。
语法细节就不说明了。有几个核心概念介绍一下:
- 用=定义变量$(变量值)来引用变量
target:prerequisities
command
- target:是生成的目标名称
- prerequisities:是产生生成的目标需要的文件,前置依赖,只有这里的文件内容有更新时,才会执行command来生成新的target
- command:就是利用prerequisite来生成target要执行的命令。 @ 代 表 t a r g e t , @代表target, @代表target,<代表prerequisite的第一个名字。
- 另外target也可以是动作,此时prerequisite就是这个动作的子动作
- make + 动作名可以执行相应动作,动作名省略的话就是默认为第一个 动作名
总结一句话就是:由果寻因,先看要生成什么,再找生成它要什么条件。
- nasm -I + pathname 用来指定编译中用到的include文件所在目录
- disasm反汇编bin文件回.asm文件,-u指定处理器模式是32位 -o用来指定.文件加载进内存的物理地址 -e表示跳过.bin头部的字节数,通常设为程序入口点距离程序开头的偏移值
遇到一个问题(坑):
- 在start.c里第二次调用disp_str函数的时候会没有任何输出 。测试确定作者的代码也有同样的问题。说明是编译器的更新导致C和汇编背后在操作寄存器的的方式和作者写代码时用的版本的编译器不同了,因此在编译调用汇编disp_str函数的C语言代码的时候,背后使用了某些disp_str函数里用到的寄存器,而disp_str里又忘了保护而从未造成了第二次调用的时候要用到那个寄存器,然而它的值却已经在第一次调用的时候被修改了。
- 顺着这个方向,查看了disp_str的汇编代码,果然发现了代码中使用了ebx寄存器,但是没有使用push来保护后再使用(当然,另外还有几个寄存器比如esi/edi也没有保护,但是显然这些寄存器被C编译器用到的可能远没有ebx高,因此先测试是否是ebx造成的问题。
- 开始测试的时候,我在开头push ebp后push ebx,但是结果仍然错误,原因是后面的获取栈中输入参数的操作mov esi[ebp + 8] ;这里的8没有随着增加到12导致参数获取错误。修改过后问题解决。
添加中断处理
这里先设置中断主要是为了后面写进程和进程切换做准备。
因为,从操作系统的角度来说,进程必须是OS可控制的,需要处理器的执行需要在OS和进程之间切换,而这个切换实现的机制,就是要依靠中断。
添加中断的主要工作:
- 设置8259A
- 建立IDT
先设置(初始化)8259A
-
对于设置8259A,主要工作其实就是向各个控制寄存器写控制字,对于这个过程,可以这样实现:
用out指令来写寄存器的部分用一个汇编函数out_byte来实现,而主题函数则是一个init_8259A()的c函数,里面调用out_byte函数来完成各个控制字的写入。相应的端口则定义成宏,添加在const.h中 -
其他比较杂的函数声明统一放到一个头文件proto.h中。
-
另外,memcpy函数单独放到头文件string.h中,这个头文件是仿照C语言标准库中的string.h,存放字符串操作相关的函数声明,后面还会加入更多的函数。
-
最后,要记得在.c文件里包含它们
-
还有就是更新Makefile
- makefile中,会遇到一个问题,就是当生成某个文件要依赖的文件过多时,可以用gcc的-M参数来自动生成依赖关系。
建立并设置IDT
-
IDT的处理完全可以参考GDT
-
这里将原来放在start.c开头的很多全局变量都放到global.h中,包括gdt[]/gdt_ptr[]等还有这里的idt
-
global.h的开头有这样的预编译指令,其中EXTERN通常都是extern的宏定义,但是只有在global.c里是空。这么做主要是为了保证各个源文件在共享这些全局变量的同时,只有包含在global.c的那一份为定义,其它地方的都是声明,即保证只有一份定义。
/* EXTERN is defined as extern except in global.c */
#ifdef GLOBAL_VARIABLES_HERE
#undef EXTERN
#define EXTERN
#endif
-
在kernel.asm里加载gdt_ptr里的内容进idtr
-
接下来就是在IDT里添加表项
- 处理器可以处理的中断和异常列表里有大概20个,这里逐一添加进kernel.asm,并用global导出
- 中断/异常处理的过程如下:中断/异常发生时,eflags\cs\sip已经被压栈,如果有错误码的话,也会被压栈。因此,如果有错误码的中断/异常就直接把中断向量号压栈,然后执行一个函数exception_handler。当然,这个函数使用C语言编写会比较方便不过在里面要调用汇编函数来显示字符提示。而如果没有错误码就先压栈一个0xFFFFFFFF占位再执行上面的两步操作。
-
exceptionhandler函数:目前主要就是清空屏幕,然后打印传入的堆栈信息。函数体存在protect.c里,里面用到的将32位2进制整数以16进制显示的部分使用了C语言实现,而没有再用lib.inc里的汇编实现
-
设置IDT:init_proc()函数里调用init_idt_desc函数来设置IDT的每个表项,这里先全部都初始化成中断门。
-
init_idt_desc(vector, desc_type, handler, privilege),vector是向量号,也就是当前要修改的描述符在IDT中的索引,desc_type顾名思义,handler是函数指针,主要是获取中断处理函数的地址,privilege也很显然,用来填描述符的属性域的。
-
出现了
klib.c:(.text+0xfe):对‘__stack_chk_fail_local’未定义的引用
错- solution:在Makefile的CFLAGS里添加-fno-stack-protector标志并make clean后重新make
-
还有一个问题:作者的代码里没有在调用了disp_int()函数的protect.c文件里添加disp_int()的声明,应该在proto.h里添加上这个声明,否则就会出现warning: implicit declaration of function ‘disp_int’这实际上是个错误但是C编译器把它写成了警告。不知道作者的代码是怎么运行完整的???另外,原来定义在klib.asm里的disp_pos现在统一由C语言定义在global.h里了,所以要记得改,否则显示位置会有问题。
-
还有就是,添加了global.h文件后,makefile中start.o:后面的prerequisite里并没有添加global.h也可以正确编译。还有就是,除了开始的start.o和init8259.o其他几个目标文件的prerequisite都只有它们相应的源文件,并没有其他已经include的头文件,可见,这个prerequisite中.h文件并不是必须的,添加了.h文件应该只是用来表示当prerequisite中至少有一个文件比target文件新时,才执行后面的command。
到此,中断/异常处理机制已经建立。不过,我们虽然初始化(设置)完了8259A但是还没真正用过它,前面都是用的软中断/异常来测试。
下面就来测试并使用一下8259A的外部硬件中断
- 两片级联的8259A可以挂接15个不同的外部设备,所以我们也应当有15个中断处理程序,这里为了简单起见,用了两个(主从各一个)带参数的宏来作为中断处理程序(其实就是宏里调用同一个中断处理程序,但是参数传入的是中断请求号irq,所以中断处理程序显示的irq也会根据中断源的不同而不同)
- 实际的中断处理程序用c写,但真正的中断处理程序还是在汇编也就是kernel.asm中,因为写入IDT里的是汇编里的函数的地址。只不过我们这里在kernel.asm里的中断处理程序的作用就是调用C里面spurious_irq()函数,在里面来真正实现中断处理程序的功能。
- 另外,要记得添加中断向量号宏和在IDT里添加相应的中断描述符。
- 到此为止还不能出现效果,因为1.我们没有设置IF位,而且在初始化8259A的时候屏蔽了所有中断2.我们没有让硬件产生外部中断
- 所以首先,用out_byte写控制字解决2,打开键盘中断,并使用sti指令打开IF。
最后看一下结果运行结果: