4.内核层

       本章内容仍然涉及处理器体系结构,硬件外设及编译链接的相关知识,因此可结合6,7,8章内容并行阅读。同时本章将改用AT&T格式的GAS汇编语言来编写操作系统,此前的Intel汇编语言格式将在BootLoader引导启动程序向物理平台迁移的过程中继续使用。

4.1.内核执行头程序

        处理器把控制器移交给Kernel内核程序后,Kernel内核程序最先执行的是内核执行头程序。内核执行头程序是一段精心设计的汇编代码,且需借助特殊的编译链接方法才能得到最先执行。

4.1.1.什么是内核执行头程序

        其实就是内核程序中的一小段汇编代码。当Loader引导加载程序移交控制权后,处理器便会执行Kernel内核程序的这段代码。

        内核执行头程序负责为操作系统创建段结构和页表结构,设置某些结构的默认处理函数,配置关键寄存器等工作。

        完成上述工作后,依然要借助远跳转指令才能进入系统内核主程序。BootLoader引导启动程序占用了0~1MB的物理地址空间,而内核程序将使用1MB以上的物理地址空间。

        如何把内核执行头程序编译生成到整个内核程序文件的起始处。

        为达到这一目的,我们必须手动编写内核程序的链接脚本。在内核程序的链接过程中,链接器会按链接脚本描述的地址空间布局,把编译好的各个程序片段填充到内核程序文件中。

        本系统内核程序的链接脚本名为kernel.lds。此时只需记住内核层的起始线性地址0xffff 8000 0000 0000对应着物理地址0处,内核程序的起始线性地址位于0xffff 8000 0000 0000 + 0x10 0000处即可。

4.1.2.写一个内核执行头程序

        程序将全局描述符表GDT结构,中断描述符表IDT结构,任务状态段TSS结构放在内核程序的数据段内,其中的汇编伪指令.section定义段名为.data,且手动配置全局描述符表GDT内的各个段描述符。

        还通过伪指令.globl来修饰标识符GDT_Table,IDT_Table,TSS64_Table,以使这三个标识符可被外部程序引用或访问。伪指令相当于c语言的extern,可保证在本程序正常配置描述符表项的同时,内核程序的其他部分也能操作这些描述符表项。

        比较典型场景有,向IDT表项设置中断/异常处理函数,保存/还原各个进行的任务状态段信息,创建LDT描述符表等。

        各描述符表结构准备完毕后,还需为操作系统创建并初始化页表及页表项。

        在64位的IA-32e模式下,页表最高可分为4个等级,每个页表项由原来的4B扩展至8B,且分页机制除了提供4KB大小的物理页外,还提供2MB和1GB大小的物理页。        

        对拥有大量物理内存的操作系统来说,使用4KB物理页可能会导致页颗粒过于零碎,从而造成频繁的物理页维护工作,而采用2MB物理页也许比4KB物理页更合理。

        本段程序借助伪指令.org来固定各个页表的起始地址,并使用伪指令.align将对齐标志设置为8B。以页目录(顶层页表)为例,使用代码.org 0x1000定位页目录后,此页表便位于内核执行头程序起始地址0x1000偏移处,然后链接器再根据链接脚本的描述,将内核执行头程序的起始线性地址设置在0xffff 8000 0000 0000 + 0x10 0000地址处,因此推算出页目录的起始线性地址位于0xffff 8000 0010 0000 + 0x1000 = 0xffff 8000 0010 1000处。

        此页表将线性地址0和0xffff 8000 0000 0000映射为同一物理页以方便页表切换,即程序在配置页表前运行于线性地址0x10 0000附近,经过跳转后运行于线性地址0x ffff 8000 0000 0000附近。

        代码将前10MB物理内存分别映射到线性地址0处和0xffff 8000 0000 0000处,接着把物理地址0xe000 0000开始的16MB内存映射到线性地址0xa0 0000处和0x ffff 8000 00a0 0000处,最后使用伪指令.fill将数值0填充到页表的剩余499个页表项里。

        系统数据结构准备就绪后,处理器将执行下述程序,再次进行IA-32e模式的初始化。此次使用的绝大部分系统数据结构将始终伴随着操作系统的运行。

        在GAS编译器中,使用标识符_start作为程序的默认起始位置,同时还要使用伪指令.globl对_start标识符加以修饰。如不使用.globl修饰_start标识符的话,链接过程会出现警告

ld: warning: cannot find entry symbol _start; defaulting to ffff 8000 0100 0000

        这段程序中的汇编代码lgdt GDT_POINTER(%rip)采用的是RIP-Relative寻址模式。这是为IA-32e模式新引入的寻址方法。

Intel汇编语言格式AT&T汇编语言格式
RIP-Relative寻址[rip+displacement]displacement(%rip)

         表中的displacement是一个有符号的32位整数值,且目标地址又依赖于当前的RIP寄存器(指令指针寄存器),则displacement将提供RIP +/- 2GB的寻址范围,代码lidt IDT_POINTER(%rip)同理。

        值得注意的是,NASM编译器不支持[rip+displacement]格式,解决办法是使用关键字rel修饰。

mov rax, [rel table] 

        本段程序层多次使用lretq代码进行段间切换,却未曾使用代码ljmp或lcall,这是因为GAS编译器暂不支持直接远跳转JMP/调用CALL指令。

        一些指令在64位环境下是不可用的,典型的指令有PUSH CS/DS/ES/SS指令和POP DS/ES/SS指令。

lcall 0x0018:0x0010 0000

ljmp 0x0018:0x0010 0000 

        因为上述原因,这段程序只能借助汇编代码lretq进行段间跳转,此处先模仿远调用汇编代码lcall的执行过程,伪造了程序的执行现场,并结合RIP-Relative寻址模式将段选择子和段内地址偏移保存到栈中,然后执行代码lretq恢复调用现场,即返回到目标代码段的程序地址中。

        此处借助汇编代码lretq跳转到模块entry64的起始地址处,从而完成了从线性地址0x10 0000向地址0xffff 8000 0010 0000切换的工作。

        通过这种方法,内核执行头程序最终跳转至内核主程序Start_Kernel函数中。在内核编译脚本Makefile中,以下指令负责编译head.s文件。

head.o : head.s

        gcc -E head.S > head.s

        as --64 -o head.o head.s 

        注意,head.S文件的后缀名是大写字母S,千万不要写成小写字母s。

        经过这段命令编译后,生成的是编译文件,而非可执行程序,还需经过链接才能生成可以执行的程序。但目前仍缺少内核主程序Start_Kernel函数,使得内核执行头程序无法完成最后的跳转。

4.2.内核主程序

        内核主程序,或称内核主函数,相当于应用程序的主函数。

        内核主程序正常下不会返回。因为内核执行头程序没有给内核主程序提供返回地址,且关机,重启等功能也并非是在内核主程序返回的过程里实现的,所以没必要让内核主程序返回。

        内核主程序负责调用各个系统模块的初始化函数,在这些模块初始化结束后,它会创建出系统的第一个进程init,并将控制权交给init进程。

        此刻的内核主程序并不具备任何功能,只是为了让内核执行头程序拥有目标跳转地址而已。

        目前,这个内核主程序只是个空函数,没有返回地址,一旦进入将保持死循环状态。

        在编译脚本Makefile中,使用下述所示指令可编译main.c文件,生成内核程序。

main.o : main.c

        gcc -mcmodel=large -fno-builtin -m64 -c main.c 

        这段Makefile负责编译main.c文件,将源代码文件main,c编译成程序片段main.o,随后再用代码清单将其编译成可执行程序。

system : head.o main.o

        ld -b elf64-x86-64 -o system head.o main.o -T Kernel.lds 

        这个脚本命令负责将编译生成的main.o文件与head.o文件链接成可执行文件,并取名为system。在整个链接过程中会使用到链接脚本文件Kernel.lds。        

        经过编译后生成的文件system依然不是最终的内核程序,还必须再使用代码清单的命令将system文件中的二进制程序提取出来。

all : system

        objcopy -I elf64-x86-64 -S -R ".eh_frame" -R ".comment" -O binary system kernel.bin 

        此段Makefile脚本命令的作用是剔除system程序里多余的段信息,并提取出二进制程序段数据(包括text段,data段,bss段等)。

        使用复制命令把生成的内核程序kernel.bin复制到boot.img虚拟软盘镜像文件内,便可启动Bochs虚拟机观看运行效果。

        由于目前还未实现屏幕显示功能,以至于虚拟机屏幕仍然是黑色的,那么查看RIP寄存器是否在执行Start_Kernel函数中的死循环,将是个不错的验证方法。

        首先,使用objdump命令反汇编可执行程序system,以取得代码while(1);的线性地址,详细反汇编命令如下:

objdump -D system 

        此处需特别注意,反汇编的程序文件是system,非kernel.bin。

        因为只有system文件记录着内核程序的各个段信息,它能显示出程序的地址及其他相关信息;而kernel.bin文件只保存着程序的机器码,并不含有任何段描述信息,以至于无法通过该文件查询出程序的指令地址。

        在这段反汇编信息中,汇编代码jmp ffff 8000 0010 4004一行便是while死循环语句。随后运行Bochs虚拟机,再向终端命令行输入r命令查看通用寄存器内的数据。

        这段查询信息中的rip: ffff 8000_0010 4004一行记录着RIP寄存器的值,该值与上文反编译出的代码jmp ffff 8000 0010 4004描述的地址相一致,从而说明处理器正在不停执行此条JMP指令。

        同时,在按下Ctrl +C进入DBG调试命令行时,日志信息

[0x0000 0010 4004] 0008: ffff 8000 0010 4004 (unk. ctxt): jmp .-2(0xffff 8000 0010 4004) ; ebfe

        也标明正在执行指令的物理地址([0x0000 0010 4004]),线性地址(0008:ffff 8000 0010 4004),反汇编地址(jmp .-2(0xffff 8000 0010 4004))和机器码(ebfe)等信息。

        既然内核执行头程序已经跳转至内核主程序,则此后的开发便可使用汇编语言和C语言。

4.3.屏幕显示

        为在屏幕上显示颜色,需通过帧缓冲存储器来完成。帧缓冲存储器,是屏幕显示画面的一个内存映像,帧缓存的每个存储单元对应屏幕上的一个像素,整个帧缓存对应一幅帧图像。

        帧缓存的特点是可对每个像素点进行操作,不仅可借助它在屏幕上画出色彩,还可在屏幕上用像素点描绘文字及图片。

        此前的Loader引导加载程序曾经设置过显示芯片的显示模式(模式号:0x180,分辨率:1440*900,颜色深度:32bit),且内核执行头程序(head.S)还将帧缓存的物理基地址(0xe000 0000)映射到线性地址0xffff 8000 00a0 0000和0xa0 0000处。

4.3.1.在屏幕上显示色彩

        在向帧缓存写入数据前,必须了解帧缓存的格式,即一个像素点能显示的颜色值位宽。

        Loader引导加载程序设置的显示模式可支持32位颜色深度的像素点,其中0~7位代表蓝颜色,8~15位代表绿颜色,16~23位代表红颜色,24~31位是保留的。

        这32bit值可组成16M种不同的颜色,可以表现出真实的色彩。

        如果想设置屏幕上某个像素点的颜色,则必须知道这个点在屏幕上的位置,并计算出该点距离屏幕原点的偏移值,随后才可在偏移处设置此像素点的颜色值。

        屏幕的坐标原点位于屏幕的左上角,以本系统目前配置的显示模式为例。

        让屏幕显示几条色带。

        首先必须确定帧缓存区被映射的线性地址,此处是0xffff 8000 00a0 0000,由于页表映射的关系(模式切换时的同一性地址映射),帧缓存区地址空间也被映射到线性地址0xa0 0000处。

        然后,通过几组循环语句向每1440*20个像素点依次写入红色值(0x00ff 0000),绿色值(0x0000 ff00),蓝色值(0x0000 00ff),白色值(0x00ff ffff)。

        特殊说明在设置显示模式的过程中,有个寄存器位可在设置显示模式后清除屏幕上的数据,Loader引导加载程序已将该寄存器位置位,所以早前在屏幕上显示的信息皆已被清除。

        此种像素点填充颜色值的方法,比使用BIOS的INT 10h中断服务程序更方便,更直接有效。

4.3.2.在屏幕上显示log

        仅需在一个固定像素方块内用像素点画出字符,即可实现屏幕上的字符显示功能。

        本节将基于ASCII字符集制作出一个ASCII字符的子集,其中包含大小写字母,数组,及一些常用符号,并实现一个简单的格式输出函数color_printk,通过此函数可在屏幕上打印出格式化的彩色字符串。

        1.ASCII字符串

        ASCII字符集共有256个字符,其中包括字母,数字,符号和一些非显示信息。目前我们只实现一些常用的显示字符,供color_printk函数在屏幕上打印即可。

        上图是数字0和一个8*16的像素点矩阵,像素点矩阵中的黑色像素点在屏幕上映射出(组成)了数字0,它们是数字0的字体颜色。

        只要根据像素点矩阵的映射原理, 计算出每行的十六进制数值(像素点矩阵图形信息),再将这16行数值组合起来就构成了字符像素位图。

        这段程序中的每对大括号保存着一个字符的字符像素位图。在字符的显示过程中,只需把位图中为1的位写入字体颜色值,将位图中为0的位写入字体背景颜色值,便可将该字符显示在屏幕上。

        2.显示彩色字符函数color_printk的实现

        在实现color_printk函数前,需先准备一个用于屏幕信息的结构体struct position。该结构体记录着当前屏幕分辨率,字符光标所在位置,字符像素矩阵尺寸,帧缓存起始地址和帧缓存区容量大小。

        这个结构体定义于头文件printk.h内,将这个头文件和其他相关头文件包含到printk.c文件中。

        此处特别注意头文件stdarg.h,它是GNU C编译环境自带的头文件。因为color_printk函数支持可变参数,只有添加引用这个头文件后,才可使用可变参数的相关功能。

        头文件lib.h和linkage.h分别是为本系统编写的内核通用库函数,宏定义及一些常用的函数修饰符。现在轮到本节的主要color_printk。

        这段程序中,函数参数中的省略号,关键字va_list,关键字va_start及关键字va_end均属于可变参数的内容,而函数vsprintf则用于解析color_printk函数提供的格式化字符串及其参数,vsprintf函数会将格式化后的字符串结果保存到一个4096B的缓冲区buf,并返回字符串长度。

        随后,color_printk函数检索buf缓冲区内的格式化字符串,从中找出\n,\b,\t等转义符,并在屏幕打印格式化字符串的过程中解析这些转义符。这个检索打印过程会首先检测\n转义符。

        通过for循环语句检测格式化后的字符串(逐个字符检测),如果发现某个待显示字符是\n转义符,则将光标行数加1,列数设置为0。否则,判断待显示字符是否为\b转义符。

        如确定待显示字符是\b转义符,则调整列位置并调用putchar函数打印空格符来覆盖之前的字符。如待显示字符既不是\n转义符,又不是\b转义符,则继续判断其是否为\t转义符。

        如确认待显示字符为\t转义符,则计算当前光标距下一个制表位需填充的空格符数量,将计算结果保存到局部变量line中。再结合for循环语句和if条件判断语句,把显示位置调整到下一个制表位,并使用空格符填补调整过程中占用的字符显示空间。

        代码

((Pos.XPosition + 8) & ~(8 - 1)) - Pos.XPosition;

        中的数值8,表示一个制表位占用8个显示字符。

        排除待显示字符是\n,\b,\t转义符后,那么它就是一个普通的字符。

        这里使用putchar函数将字符打印在屏幕上。这里需给putchar函数传递帧缓存线性地址,行分辨率,屏幕列像素点位置,屏幕行像素点位置,字体颜色,字体背景色和字符位图等参数。

        字符显示结束后,还要为下次字符显示做准备,即更新当前字符的显示位置(此处的字符显示位置可理解为光标位置)。

        这是for循环语句的结尾,这段程序负责调整光标的列位置和行位置。在for循环语句内曾多次调用函数putchar在屏幕上打印字符,该函数会使用到此前设计的ASCII字符库。

        在这段程序使用到了帧缓存区首地址,将该地址加上字符首像素位置(首像素是指字符像素矩阵左上角第一个像素点)的偏移(Xsize * (y + i) + x),可得到待显示字符矩阵的起始线性地址。代码中的for循环体从字符首像素地址开始,将字体颜色和背景色的数值按字符位图的描绘,填充到相应的线性地址空间中。

        接下来,将把vsprintf函数分为几个程序片段,逐个讲解格式化字符串的解析过程。

        函数vsprintf依然借助for循环语句完成格式化字符串的解析工作。该循环体会逐个解析字符串,如字符不为‘%’就认为它是个可显示字符,直接将之存入缓冲区buf中,否则进一步解析其后的字符串格式。

        按字符串格式规定,符号'%'后面可接'-','+',' ','#','0'等格式符,如下一个字符是上述格式符,则设置标志变量flags的标志位(标志位定义在printk.h头文件内)。

        这部分程序可提取出后续字符串中的数字,并将其转化为数值以表示数据区域的宽度。如下一个字符不是数字而是字符'*',则数据区域的宽度将由可变参数提供,根据可变参数值亦可判断数据区域的对齐显示方式(左、右对齐)。获取数据区域宽度后,下一步还要提取出显示数据的精度。

        如果数据区域的宽度后面跟有字符'.',说明其后的数值是显示数据的精度。代码采用于计算数据区区域宽度相同的方法计算出显示数据的精度。随后还要获取显示数据的规格。

        代码用于检测显示数据的规格,如%ld格式化字符串中的字母'l',就表示显示数据的规格是长整型数(long型)。经过逐个格式符的解析,数据区域的宽度和精度等信息皆已获取,现在将遵照这些信息把可变参数格式化成字符串,并存入buf缓冲区内。从下述代码开始将进入可变参数的字符串转化过程,目前支持的格式符有c,s,o,p,x,X,d,i,u,n,%等。

        如果匹配出格式符c,那么程序将可变参数转换为一个字符,并根据数据区域的宽度和对齐方式填充空格符,这就是%c格式符的功能。有了字符显示功能,则字符串显示功能将很快能实现。

        这段程序实现字符串显示功能,即%s格式符的功能。整个显示过程会把字符串的长度与显示精度进行比对,根据数据区域的宽度和精度等信息截取待显示字符串的长度并补齐空格符。此处涉及内核通用库函数strlen,本节稍后部分将会对这个函数的程序实现予以介绍。

        这部分程序是八进程,十进制,十六进制及地址值的格式化显示功能,它借助函数number实现可变参数的数字格式化功能,并根据各个格式符的功能置位相应标志位供number函数使用。

        函数vsprintf的最后一部分代码负责格式化字符串的扫尾工作。

        在代码清单中,格式符%n的功能是,把目前已格式化的字符串长度返回给函数的调用者。如格式化字符串中出现字符%%,则把第一个格式符'%'视为转义符,经过格式化解析后,最终只显示一个字符%。如在格式化解析过程中,出现任何不支持的格式符,则不做任何处理。直接将其作为字符串输出到buf缓冲区中。

        函数vsprintf是color_printk的主体功能函数。其中的skip_atoi函数负责将数值字符转换成整数值,number函数则用于将长整型变量值转换成指定进制规格(由参数base指定进制数)的字符串,并由precision参数提供显示精度值。 

        函数skip_atoi只能将数值字母转换为整数值,因此这个函数会先判断当前字符是否为数值字母。如果是数值字母,则将当前字符转换成数值(*((*s)++) - '0'),并拼入已转换的整数值(i*10 + *((*s)++) - '0')。代码中的is_digit是一个宏(#define is_digit(c)  ((c) >= '0' && (c) <= '9')),它用来确认当前字符是数值字母。

        与函数skip_atoi相比,number函数的逻辑相对复杂,它可将整数值按指定进制规格转换成字符串。

        不管number函数将整数值转换成大写字母还是小写字母,它最高支持36进制的数值转换。此函数会根据参数base确定转换的进制规格,而代码tmp[i++]=digits[do_div(num, base)];负责将整数值转换成字符串(按数值倒序排列),然后再将tmp数组中的字符倒序插入到显示缓冲区。

        do_div宏是一条内嵌汇编语句,它借助DIV汇编指令将整数值num除以进制规格base(在DIV汇编指令中,被除数由RDX:RAX寄存器组成,由于num变量是个8B的长整型变量,因此RDX寄存器被赋值为0),计算结果的余数部分即是digits数组的下标索引值。

        特别注意,如将这行汇编改为__asm__("divq %4" : "=a" (n), "=d" (__res):"0" (n), "1" (0), "r" (base));在理论上是可行的,但编译过程中会提示错误Error: Incorrect register '%ecx' used with 'q' suffix,可见编译器为寄存器约束符选择32位寄存器而非64位。

        在vsprintf函数里还涉及一个strlen,同样用内嵌汇编语言编写。

        函数strlen先将AL寄存器赋值为0,随后借助SCASB汇编逐字节扫描String字符串,每次扫描都会与AL寄存器对比,并根据对比结果置位相应标示位。

4.4.系统异常

4.4.1.异常的分类

        1.错误

        可被修正的异常。修正后重新执行异常指令

        2.陷阱

        从异常后指令继续执行

        3.终止

        不允许程序或任务继续执行

        

        表按异常/中断的向量号升序排列,某些异常发生时,会根据当时处理器的运行状态生成错误码,错误码的各状态位代表着引起异常的原因。

        如操作系统能捕获处理器异常,将给今后的调试带来极大方便。

4.4.2.系统异常处理(一)

        处理器采用类似汇编CALL的方法来执行异常/中断处理程序。


版权声明:本文为x13262608581原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。