细!手把手教你如何制作一个微型内核
前言
在看《linux内核设计与实现》的过程中发现只看书对于学习如何设计一个真正的内核太勉强了,还是要实践下才能真正的了解一个内核是怎么设计的,因此在GitHub上找了两个极简的内核(与真正的内核相比这两个内核代码少的可怜,更像内核组件)为例说明如何设计简单的Linux内核。阅读本文需要有一定的汇编语言和C语言功底。
基础
首先我们来先了解下Linux内核是什么,有什么作用。我们知道操作系统是一个计算机中最重要的部分,用户需要操作系统来运行各种应用进程,如果说用户使用的应用程序是通过操作系统间接调用计算机的部分资源来运行的,那么操作系统就相当于计算机资源的管理器,操作系统可以直接调用计算机的各种资源。操作系统包括一些基本组件,如文本编辑器、编辑器、与用户交互的程序、内核等,而内核作为操作系统的基础核心,在操作系统中是最重要也是最基础的部分,它是操作系统中最常用的基本模块,直接与硬件交互,充当底层驱动程序,对系统中的各种设备和组件进行寻址,用于管理系统资源,比如对进程、文件系统、同步、内存、网络协议等的操作和权限控制,通过内核可以将计算机的共享资源(CPU,内存等)分配给各个系统进程;内核还提供了一组面向系统的命令,应用程序调用系统调用就像C语言中调用普通函数。用内核和应用程序做一个比较的话,从最外层到最里层就是:用户应用程序>UNIX命令和库>系统调用接口>内核>硬件。
再了解下Linux内核组成,下面是一张完整的LInux内核运行原理图,子系统中的所有函数有400多个,互相使用连线交互:
下图是Linux内核的体系结构:
Linux内核主要包含:系统调用接口SCI、进程管理、内存管理、虚拟文件系统、网络协议栈、设备驱动、硬件架构。
1、系统调用接口
SCI为用户提供了一组系统调用函数作为用户态与内核态之间的桥梁,是一个函数调用多路复用和多路分解服务,同时该接口还依赖于内核的体系结构。
2、进程管理
Linux中实际上并没有区分进程和线程这两个概念,Linux中把线程称为轻量级进程,没有独立的地址空间,一个进程下的所有线程共享地址空间、文件系统资源、文件描述符、信号处理程序等,每个线程都有私有数据、堆栈信息等,内核通过SCI提供的fork、exec API来创建进程,wait API来暂停进程,kill、exit API来结束进程,并通过signal或POSIX机制在进程间进行通信和同步。使用进程描述符用来记录进程的状态。最后通过调度系统完成进程的执行。
3、内存管理
内核通过内存管理控制多个进程来安全的共用共享内存。内存以内存页为基本单位进行管理,从虚拟内存角度来看,页是Linux中的内存最小单位,大多数32位系统支持4kb的页,64位系统支持8kb的页。Linux 提供了对 4KB 缓冲区的抽象,如slab分配器,使用这种内存管理模式时使用4KB缓冲区为基数,从中分配结构并跟踪内存页使用情况,知道了页的存储情况后根据系统需求动态调整内存使用。为了支持多个用户使用内存,有时会出现可用内存被消耗光的情况,这时页面可以移出内存并放入磁盘中,这个过程称为交换,因为页面会被从内存交换到硬盘上,Linux系统中被用于交换的分区叫swap分区,windows系统下叫做虚拟内存。
4、虚拟文件系统
VFS是Linux内核和I/O设备之间封装的的一层访问接口,通过该接口Linux内核可以使用同一方式访问各种I/O设备,应用程序可以使用同一接口完成不同介质上不同文件系统的数据读写操作。VFS之所以能够连接各种文件系统,是因为它定义了所有文件系统都支持的、基本的、概念上的接口和数据结构。VFS采用面向对象的设计思路,使用结构体方式实现既包含数据又包含操作数据的函数指针。VFS的4个主要对象类型为:超级块对象、索引节点对象、目录项对象、文件对象。因为虚拟文件系统本身就是Linux内核的一部分,属于软件,所以不需要硬件的支持。
5、网络协议栈
这部分保证了网络协议的实现,在设计上遵循模拟协议本身的分层体系结构。具体可以参考这里。
6、设备驱动
驱动程序一般指设备驱动程序(Device Driver),是一种可以使计算机和设备通信的特殊程序,相当于硬件的接口,操作系统通过这个接口可以控制硬件设备的工作。内核中的大量源代码都在驱动中实现,用来控制特定的硬件设备。Linux 源码树提供了一个驱动程序子目录,这个目录又进一步划分为各种支持设备,例如Bluetooth、I2C、serial等。
7、体系结构相关代码
Linux很大程度上独立于所运行的体系结构,但一小部分依赖体系结构正常操作并实现更高效率。linux的arch目录中定义了内核中依赖于体系结构的部分,其中包含各种特定于体系结构的子目录(共同组成了BSP)。每个体系结构子目录中又包含了很多其他子目录,每个子目录都关注内核中的一个特定方面,例如引导、内核、内存管理等。
Linux采用宏内核架构,将所有函数放到一个大的文件中,这样可以直接调用函数减少了内核间的通信开销,但也导致一个函数出错会影响后面的所有函数,并且会影响内核的可维护性,为了提高内核的扩展性和可维护性,Linux内核采用模块化设计,支持添加或删除软件组件,添加或删除的组件被称为可加载内核模块(Loadable Kernel Modules,LKM),可以被单独编译但不能脱离内核单独使用,使用时被链接到内核在内核空间中运行。可加载内核模块包括驱动设备、内核扩展模块,可以在引导时根据需要或在任何时候由用户插入,用来实现文件系统、添加设备、系统调用或其他内核上层的功能。
介绍到这里大家应该对Linux内核的组成有了一个大致了解,当然完整的内核还包括更多具体细节,比如系统调用、内核数据结构、调度算法,中断和同步等,但由于本文只是说明如何制作一个微型内核,所以知道了以上知识后已经足以下面的学习了。对完整的Linux内核具体实现细节有兴趣的同学可以阅读《Linux内核设计与实现》并下载Linux源码进行学习。
实现
该部分通过GitHub上的两个极简内核来学习编写简单的内核。
例一
本例来源
本例的内核实现在屏幕上打印“my first kernel”并挂起。需要一台装有NASM编译器的虚拟机(笔者使用Ubuntu20.04用于演示)。
演示前我们需要知道一台计算机从接入电源启动到用户应用程序的过程发生了什么。
第一步计算机通电后主板会得到启动电源的信号检查设备是否正常,并且使用cpu清除寄存器上的所有数据,并为寄存器设置预定义的值,重置向量(CPU重置后第一条执行指令的地址)被设置为0xfffffff0(80386及以后的CPU一般是该地址),0xfffffff0=0xffff0000(CS寄存器值,基地址)+0xfff0(EIP寄存器值,地址偏移),然后0xfffffff0会有类似jne跳转指令,通过跳转指令去BIOS。第二步BIOS初始化完成后去读取MBR扇区(启动盘上的第一个扇区)将该扇区的内容(包含主引导程序)复制到0x7c00地址物理内存中,然后通过主引导程序初始化硬件设备为调用内核做准备,同时主引导程序还负责加载执行GRUB(引导装载程序,寻找内核并将内核加载到内存中运行)。第三步GRUB解压缩内核完成并将解压完的内核加载进内存后,会执行调用start_kernel()函数启动一系列初始化函数并初始化各种设备,这时内核终于完成了加载。第四步内核加载后运行的第一个运行程序是/sbin/init,它去读取/etc/inittab文件,并根据该文件进行系统初始化,等系统初始化完成并装载完内核模块后用户这时才会进入登录页面。
下面我们通过第一个例子模仿一个内核是如何运行的。真正的内核在引导加载程序启动内核代码后,跳转到内核入口点时就初始化内核设置堆栈、bss段等,再去执行内核中的其他函数(Linux内核使用宏内核架构,里边包含了所有必需的内核函数),这是一个复杂且严谨的过程,但本文的例子中内核代码只有一个函数(这个例子只能演示内核是怎么启动的)。
首先因为高级语言如C语言无法直接与计算机交互(从切换到实模式到保护模式只能使用汇编完成),所以我们这里使用汇编代码编写一个用于启动内核代码的小程序:
可以看到在程序代码后面做了注释,上面的程序在设置好数据、变量等后去调用了kmain。然后再去看一下kmain:
这里的kmain就是我们的内核,kmain没有调用其他函数,它的作用也非常简单,通过vidptr指针指向0xb8000地址(受保护模式下显存开始地址),第一次while循环写入0x07属性(0x07属性将显示的字符串以浅灰色打印,属性可变)的空白字符清空屏幕,第二次while循环将具有0x07属性的“my first kernel”字符串写入显存在屏幕上打印。
除了上面的两个文件还需要一个链接程序脚本link.ld,将该脚本作为参数传递给我们的链接程序。link.ld链接程序脚本为:
上面的link.ld文件将输出可执行文件的输出格式设置为32位可执行文件,ENTRY(start)指定符号名称,将start作为可执行文件起点,位置计数器.设置为0x100000,内核代码从该处开始执行(只是演示的话可以修改)。.text:{*(.text)}、.data:{*(.data)}、.bss:{*(.bss)}使链接器将目标文件的所有text部分合并到可执行文件的text部分,data部分合并到可执行文件的data部分,bss部分合并到可执行文件的bss部分。等链接器放置文本输出部分后,位置计数器的值将变为0x1000000+文本输出部分的大小。
有了上面的3个文件还需要使用GRUB在符合Multiboot规范(多重引导规范)的情况下加载内核,Multiboot规范规定内核在前8kb中有一个头文件,所以下面的kernel.asm的section .text中就多了下面4行代码,align指定符号对齐不重要,magic字段设置为0x1BADB002用于识别标题,flag字段也不重要设置为0即可,最后的校验和字段添加到magic字段和flag字段时必须为0,最后kernel.asm变成了:
以上文件准备好后使用nasm组装kernel.asm到一个目标文件,再编译kernel.c为另一个目标文件,最后使用link.ld将编译好的两个目标文件链接到一起就完成了:
可以配置GRUB将虚拟机内核设置为上面的内核,为了方便我们这里只用qemu模拟器看看效果:
例二
本例来源
本例与例一相比进行了扩展,本例内核使用I/O端口(从内核角度看I/O端口就是I/O总线上的特定内存地址)与I/O设备(输入输出设备)进行通信,I/O端口通过配置控制寄存器(一个处理器寄存器,可以控制CPU和其他数字设备)和读取数据寄存器来操控外部设备接收a-z、0-9和部分符号并打印到屏幕上。环境与例一相同。
和例一相同,本例也需要一个kernel.asm启动内核,下面去掉第6到10行就是编译前的kernel.asm文件:
上面代码都做了注释,这里简单说一下和例一的kernel.asm不同的地方。keyboard.handler跳转keyboard_handler_main,负责处理与键盘相关的input_handler,包括键码转换和输出。read_port负责读取I/O端口,使用in指令将dx寄存器指定I/O端口号,将读取数据传入al寄存器中,write_port负责写入I/O端口,使用out指令将al寄存器中的数据写入dx寄存器指定的I/O端口中。load_idt处理与中断描述符表IDT有关的东西。
keyboard_map.h:
上面是键盘映射表,后面keyboard_handler_main()通过它把扫描代码转换为ASCII码。
kernel.c:
上面的IDT_entry,idt_init用于创建IDT让用户读取I/O端口。中断描述符表IDT是一个系统表,通过IDT可以在CPU中断后找到中断的进程继续去执行。这里说一下中断,早期的计算机如果想知道设备执行哪个事件需要对设备状态进行监测,早期用于监测的方法叫做轮询,但因为程序只能串行执行,计算机的资源利用率低,所以为了提高资源利用率,并且方便用户控制,于是引入了中断机制,中断保证了多程序并发执行。简单的讲,内核使用中断一方面可以让所有的进程都有一定的资源可以使用不至于进程“饿死”,另一方面用户还能通过触发中断使CPU可以随时停止当前运行的进程进入核心态,内核再对不同的中断号去做不同的处理,完成用户自己可以操控计算机资源的目的。这里我们需要通过中断监测键盘的输入并作出处理,当检测到键盘有输入时,键盘会通过IRQ(中断向量)发送信号给给PIC(可编程中断控制器,它能接收中断并根据中断对硬件作出处理),PIC在初始化期间就储存了一个偏移量,偏移量和输入线号相加就是中断号,然后处理器通过查询IDT找到中断号相对应的中断地址程序入口,运行该地址的代码对键盘输入进行处理。上面的两个函数IDT_entry是IDT的具体实现。idt_init会先填充键盘中断的IDT条目,然后设置两个PIC,PIC1使用0x20作为命令端口,0x21作为数据端口,PIC2使用0xA0作为命令端口,0xA1作为数据端口。再通过初始化命令ICW1传给PIC3个数据端口的初始化字,ICW2写入PIC的数据端口,设置PIC的偏移量,与输入线的数据相加获得中断号。ICW3告诉PIC如何做主/从设备,这里不需要设置PIC间互相输入输出,也就不需要级联,将所有位设置为0即可。ICW4给出环境附加信息,设置低位使PIC为8086模式。
然后我们将键盘的中断号映射到键盘处理函数的地址,这需要知道键盘处理函数的地址和IDT哪个中断号映射,上面的代码中PIC1的偏移量初始化为了0x20,加上1就是中断号0x21,键盘处理函数地址需要映射到0x21中断,映射需要填充IDT中的0x21中断,我们不光设置了类型捕捉中断,还给出了内核代码偏移量0x08,中断门0x8e,用0填充了GDT(GRUB建立的)条目的其他位完成了填充与键盘中断对应的IDT。我们会把中断映射到keyboard_handler函数中,并且上面的kernel.asm中写入了该函数。最后完成上面的任务后我们通过load_idt()传递指向描述IDT描述符结构的指针即idt_ptr给lidt指令告诉CPU IDT的位置,它还因为sti指令会启动中断。
还有一点前面没有说,PIC中有IMR(中断屏蔽寄存器),它在PIC的IRQ线上,通过设置IMR的值可以禁用或启动IRQ线,前面我们将IMR第n位的值设置为1禁用了第n跳IRQ线,现在IDT建立并加载完成后我们使用kb_init启用IRQ线。
前面我们通过IDT 0x21中断映射键盘中断到keyboard_handler函数中了,keyboard_handler()会调用keyboard_handler_main()处理键盘的输入。keyboard_handler_main()会发送EOI信号给PIC使其对中断作出处理,读取0x64端口确定缓冲区状态,确定缓冲区是否为空,读取0x60端口读取缓冲区内容,0x60端口会通过前面说过的keyboard_map_.h确定按键的键码判断字符,并将字符打印到屏幕上。
最后编译并链接kernel.c和kernel.asm两个可执行文件:
使用qemu模拟器看一下效果:
结语
由于笔者是第一次接触内核,所以本文写的很粗糙,如果有什么错误和不足的地方还请大佬斧正、见谅,同时也很希望有大佬能够作出指点,拜谢。
参考
《linux内核设计与实现》
https://github.com/arjun024/mkernel
https://github.com/arjun024/mkeykernel
https://tldp.org/LDP/lkmpg/2.6/html/lkmpg.html
https://zh.wikipedia.org/wiki/%E5%86%85%E6%A0%B8
https://blog.csdn.net/go_str/article/details/80089990?utm_medium=distribute.pc_relevant.none-task-blog-baidujs_baidulandingword-1&spm=1001.2101.3001.4242
https://0xax.gitbooks.io/linux-insides/content/Booting/linux-bootstrap-1.html
https://0xax.gitbooks.io/linux-insides/content/Booting/linux-bootstrap.html系列文章
https://0xax.gitbooks.io/linux-insides/content/Initialization/系列文章
最后:需要Linux网络安全资料的请加微信:gogquick 免费限时获取