操作系统实战 -1-<实现一个简单的内核>
转载请说明出处:——Welkin Chan
HelloOS为极客时间《操作系统实战45讲》中引入的示例OS,很多博客已经写了相关的实验过程(这里简要带过),而没有写与代码相关的内容,本文主要介绍这个代码的思路。
本文使用的实验环境为VMWare16.0 PRO以及Ubuntu16.04
一、理论基础
1.1 HelloOS的启动流程
自你按下计算机的开机键那一刻,计算机就被赋予了电能。然而,CPU被设计成只能读取内存,而内存主要由RAM组成,RAM在一开始的断电情况下是不存东西的,那么操作系统是怎么启动的呢?
这就要说到被固定在ROM只读存储器上的BIOS系统了,BIOS内含启动操作系统的一系列程序,具体的启动细节见我即将发布的另一篇博文《从按下电源按钮到操作系统的载入》,Ubuntu系统的引导程序是GRUB,该课程在一开始时考虑到大家的基础就不从引导程序开始写了,借GRUB程序引产我们的HelloOS。
对于HelloOS,具体的引导流程如上,PC机加电启动和PC机BIOS固件这是不变的,GRUB是Ubuntu的引导程序,通过GRUB引导被我们写入硬盘分区的HelloOS文件,然后HelloOS会被运行。
1.2 HelloOS的架构
从上到下介绍HelloOS的代码架构。首先构建makefile以脚本的形式将多个相关文件编译并链接起来形成我们最终的HelloOS。代码一共有三份:
(1)entry.asm,这个asm汇编代码调用GRUB初始化实地址模式下的计算机内存,并通过关中断、初始化寄存器、设立GDT以及IDT等操作开启保护模式,最后调用操作系统HelloOS。
(2)main.c,操作系统的main函数,这与我们练习编程的main函数有着一些差别,这是操作系统的主main函数,关于这个我将在以后的博文介绍相关操作系统内核的工作流程。
(3)vgastr.c,这是在显卡的字符模式基础上编程。因为HelloOS中并不考虑写入libc.so等动态或者静态的函数库,因此,printf函数的实现就是个问题,该课程使用显卡的字符模式,通过将字符按照一个个字节写入显存的方式输出到屏幕上,后面会说到。
(4)我们知道,HelloOS没有编译器,所以我们需要把这些代码在linux中编译好成机器代码,才能让它运行。如图,gcc管c语言代码,nasm管asm代码。asm是C++内嵌的汇编代码。
(5)hello.Ids,lds又名链接脚本,链接脚本主要用于规定如何把输入文件内的section放入输出文件内, 并控制输出文件内各部分在程序地址空间内的布局,具体内容后面代码部分介绍。
(6)HelloOS.elf是linux的可执行文件,运行后直接:,初步估计某个地方溢出。
(7)HelloOS.bin则是本文的内核机器代码,大家可以去linux的/boot/文件夹下看一看,这里存放的是与启动相关的文件,这里有很多bin都是启动文件。
二、编写HelloOS
2.1 main.c
我们首先从最简单的函数开始写起,也就是main函数,它是被GRUB引导后调用的第一个函数。
#include "vgastr.h"
void main()
{
printf("Hello This maybe your first OS!");
return;
}
该函数很简单明了,通过格式化字符函数输出一个字符串到屏幕上。
2.2 vgastr.h与vgastr.c
main.c中的vgastr.h库是控制计算机屏幕VGABIOS固件程序显示特定字符,我们要在屏幕上显示字符,就要编程操作显卡。无论我们 PC 上是什么显卡,它们都支持一种叫 VESA 的标准,这种标准下有两种工作模式:字符模式和图形模式。显卡们为了兼容这种标准,提供一种叫 VGABIOS 的固件程序。
字符模式的工作细节:
在8086结构中,显存位于内存地址的0xB8000——0xBFFFF处,为了在屏幕上显示出文字,我们需要把必要的信息加载到内存以0xB8000起始的位置。显卡初始化时,会形成一个25x80的文本模式(25行、80列),这部分内容的信息来自内存0xB8000——0xB8FA0,由于屏幕上的每一个字符需要2个连续字节表示,一共25x80x2 = 4000B的空间。
两个连续字节包含的信息如下:
低字节:字符的ASCII码,由于ASCII码只有7bit,这一字节的最高位置为0;其中有一部分表示信息的代码是无法显示的。
高字节:字符的属性,表示为K R G B I R G B。低4位定义的是前景色,高4位定义的是背景色,K为闪烁位,I为亮度位。
在初始化的情况下,显存里存储的是黑底白字的空白字符。
本文内容知道这里就差不多了,具体的显卡文本模式内容见:显卡文本模式 - Max.C的博客 (437436999.github.io)
首先在vgastr.h中定义函数:
void _strwrite(char* string);
void printf(char* fmt, ...);
接着在vgastr.c中实现函数:
1 void _strwrite(char* string)
2 {
3 char* p_strdst = (char*)(0xb8000);
4 while (*string)
5 {
6
7 *p_strdst = *string++;
8 p_strdst += 2;
9 }
10 return;
11 }
12
13 void printf(char* fmt, ...)
14 {
15 _strwrite(fmt);
16 return;
17 }
调用_strwrite函数,从第三行的初始化指针指向上文所述的显卡内存起始地址,对输入的字符串指针string进行2个字节的逐位增加并赋值到指向显卡内存的指针所对应的内存中(第7-8行),整个过程使用while循环知道字符串全部读取完毕。
2.3 entry.asm
原课程中说到,要“借鸡生蛋”,借用GRUB程序实现一个引导程序。一旦选择了启动选项,GRUB把选择的内核加载内存并把控制交给内核,也就是说,这个asm文件就是GRUB所交由的内核引导文件,它初始化内存、调用函数等功能。
头部
1 MBT_HDR_FLAGS EQU 0x00010003
2 MBT_HDR_MAGIC EQU 0x1BADB002 ;多引导协议头魔数
3 MBT_HDR2_MAGIC EQU 0xe85250d6 ;第二版多引导协议头魔数
4 global _start ;导出_start符号
5 extern main ;导入外部的main函数符号
6
7 [section .start.text] ;定义.start.text代码节
8 [bits 32] ;汇编成32位代码
魔术头是GRUB引导一个操作系统的一个规范标准,具体内容见用 GRUB 引导自己的操作系统_Ivan 的专栏-CSDN博客,其中写道:
能够被 GRUB 引导的内核有两个条件:
(1) 需要有一个 Multiboot Header ,这个 Multiboot Header 必须在内核镜像的前 8192 个字节内,并且是首地址是 4 字节对其的。
(2) 内核的加载地址在 1MB 以上的内存中,这个要求是 GRUB 附加的,并非多重引导规范的规定。
Multiboot Header
Multiboot Header的分布必须如下所示:
偏移量 | 类型 | 域名 | 备注 |
0 | u32 | magic | 必需 |
4 | u32 | flags | 必需 |
8 | u32 | checksum | 必需 |
12 | u32 | header_addr | 如果flags[16]被置位 |
16 | u32 | load_addr | 如果flags[16]被置位 |
20 | u32 | load_end_addr | 如果flags[16]被置位 |
24 | u32 | bss_end_addr | 如果flags[16]被置位 |
28 | u32 | entry_addr | 如果flags[16]被置位 |
32 | u32 | mode_type | 如果flags[16]被置位 |
36 | u32 | width | 如果flags[16]被置位 |
40 | u32 | height | 如果flags[16]被置位 |
44 | u32 | depth | 如果flags[16]被置位 |
magic
域是标志头的魔数,它必须等于十六进制值 0x1BADB002。
flags
flags域指出OS映像需要引导程序提供或支持的特性。0-15 位指出需求:如果引导程序发现某些值被设置但出于某种原因不理解或不能不能满足相应的需求,它必须告知用户并宣告引导失败。16-31位指出可选的特性:如果引导程序不能支持某些位,它可以简单的忽略它们并正常引导。自然,所有 flags 字中尚未定义的位必须被置为 0。这样,flags 域既可以用于版本控制也可以用于简单的特性选择。
如果设置了 flags 字中的 0 位,所有的引导模块将按页(4KB)边界对齐。有些操作系统能够在启动时将包含引导模块的页直接映射到一个分页的地址空间,因此需要引导模块是页对齐的。
如果设置了 flags 字中的 1 位,则必须通过 Multiboot 信息结构(参见引导信息格式)的 mem_* 域包括可用内存的信息。如果引导程序能够传递内存分布(mmap_*域)并且它确实存在,则也包括它。
如果设置了 flags 字中的 2 位,有关视频模式表(参见引导信息格式)的信息必须对内核有效。
如果设置了 flags 字中的 16 位,则 Multiboot 头中偏移量 8-24 的域有效,引导程序应该使用它们而不是实际可执行头中的域来计算将 OS 映象载入到那里。如果内核映象为 ELF 格式则不必提供这样的信息,但是如果映象是 a.out 格式或者其他什么格式的话就必须提供这些信息。
checksum
域 checksum 是一个 32 位的无符号值,当与其他的 magic 域(也就是 magic 和 flags)相加时,结果必须是 32 位的无符号值 0(即magic + flags + checksum = 0)
header_addr
这里往后的 32 个字节不是必须的,并且对于内核为 ELF 格式时是不需要的。
所以,按照上文介绍的Header结构,GRUB以及GRUB2的结构代码就按照上文写成如下的代码
1 _start:
2 jmp _entry
3 ALIGN 8
4 mbt_hdr:
5 dd MBT_HDR_MAGIC
6 dd MBT_HDR_FLAGS
7 dd -(MBT_HDR_MAGIC+MBT_HDR_FLAGS)
8 dd mbt_hdr
9 dd _start
10 dd 0
11 dd 0
12 dd _entry
13
14 ;以上是GRUB所需要的头
15 ALIGN 8
16 mbt2_hdr:
17 DD MBT_HDR2_MAGIC
18 DD 0
19 DD mbt2_hdr_end - mbt2_hdr
20 DD -(MBT_HDR2_MAGIC + 0 + (mbt2_hdr_end - mbt2_hdr))
21 DW 2, 0
22 DD 24
23 DD mbt2_hdr
24 DD _start
25 DD 0
26 DD 0
27 DW 3, 0
28 DD 12
29 DD _entry
30 DD 0
31 DW 0, 0
32 DD 8
33 mbt2_hdr_end:
34 ;以上是GRUB2所需要的头
35 ;包含两个头是为了同时兼容GRUB、GRUB2
接下来就是关中断,这是因为,实地址模式情况下,如果计算机突然有请求,比如你按键盘输入,产生中断,让CPU处理你的请求,这会导致操作系统引导停止,即使不停止,也会造成其他程序的中断,从而导致操作系统加载失败,那这就麻烦了,因此需要关闭中断功能,才能避免意外。
1 ALIGN 8
2
3 _entry:
4 ;关中断
5 cli
6 ;关不可屏蔽中断
7 in al, 0x70
8 or al, 0x80
9 out 0x70,al
接着,加载GDT(全局描述表)以及初始化寄存器与栈空间,这将会在我的后面一篇博文《从按下电源按钮到操作系统的载入》详细介绍过程。
1 ;重新加载GDT
2 lgdt [GDT_PTR]
3 jmp dword 0x8 :_32bits_mode
4
5 _32bits_mode:
6 ;下面初始化C语言可能会用到的寄存器
7 mov ax, 0x10
8 mov ds, ax
9 mov ss, ax
10 mov es, ax
11 mov fs, ax
12 mov gs, ax
13 xor eax,eax
14 xor ebx,ebx
15 xor ecx,ecx
16 xor edx,edx
17 xor edi,edi
18 xor esi,esi
19 xor ebp,ebp
20 xor esp,esp
21 ;初始化栈,C语言需要栈才能工作
22 mov esp,0x9000
23 ;调用C语言函数main
24 call main
25 ;让CPU停止执行指令
26 halt_step:
27 halt
28 jmp halt_step
29
30
31 GDT_START:
32 knull_dsc: dq 0
33 kcode_dsc: dq 0x00cf9e000000ffff
34 kdata_dsc: dq 0x00cf92000000ffff
35 k16cd_dsc: dq 0x00009e000000ffff
36 k16da_dsc: dq 0x000092000000ffff
37 GDT_END:
38
39 GDT_PTR:
40 GDTLEN dw GDT_END-GDT_START-1
41 GDTBASE dd GDT_START
2.4 hello.lds
lds上文说过是连接脚本,说白了就是指定这个程序运行在内存中的各个段的分布。具体内容见[转]Linux下的lds链接脚本详解 - only_eVonne - 博客园 (cnblogs.com)。先看代码,将定位器符号设置为0x200000,将所有(*符号代表任意输入文件)输入文件的.text section合并成一个.text section, 该section的地址由定位器符号的值指定, 即0×200000。也就是我加粗的那一段代码看成一块。下面的begin_xxx,若不指定, 则该符号的初始值为0。
1 ENTRY(_start)
2 OUTPUT_ARCH(i386)
3 OUTPUT_FORMAT(elf32-i386)
4 SECTIONS
5 {
6 . = 0x200000;
7 __begin_start_text = .;
8 .start.text : ALIGN(4) { *(.start.text) }
9 __end_start_text = .;
10
11 __begin_text = .;
12 .text : ALIGN(4) { *(.text) }
13 __end_text = .;
14
15 __begin_data = .;
16 .data : ALIGN(4) { *(.data) }
17 __end_data = .;
18
19 __begin_rodata = .;
20 .rodata : ALIGN(4) { *(.rodata) *(.rodata.*) }
21 __end_rodata = .;
22
23 __begin_kstrtab = .;
24 .kstrtab : ALIGN(4) { *(.kstrtab) }
25 __end_kstrtab = .;
26
27 __begin_bss = .;
28 .bss : ALIGN(4) { *(.bss) }
29 __end_bss = .;
30 }
三、实验
流程主要参考手写简单操作系统内核_南大小王-CSDN博客_写操作系统内核
1、make编译
2、把bin文件移动到/boot文件夹下面,或者不移动,只需要cfg中的path设置为bin文件的绝对地址就行了
3、修改/etc/default/grub
1 # If you change this file, run 'update-grub' afterwards to update
2 # /boot/grub/grub.cfg.
3 # For full documentation of the options in this file, see:
4 # info -f grub -n 'Simple configuration'
5
6 GRUB_DEFAULT=0
7 #GRUB_HIDDEN_TIMEOUT=0
8 #GRUB_HIDDEN_TIMEOUT_QUIET=true
9 GRUB_TIMEOUT=30
10 GRUB_DISTRIBUTOR=`lsb_release -i -s 2> /dev/null || echo Debian`
11 GRUB_CMDLINE_LINUX_DEFAULT="text"
12 GRUB_CMDLINE_LINUX=""
13
14 # Uncomment to enable BadRAM filtering, modify to suit your needs
15 # This works with Linux (no patch required) and with any kernel that obtains
16 # the memory map information from GRUB (GNU Mach, kernel of FreeBSD ...)
17 #GRUB_BADRAM="0x01234567,0xfefefefe,0x89abcdef,0xefefefef"
18
19 # Uncomment to disable graphical terminal (grub-pc only)
20 #GRUB_TERMINAL=console
21
22 # The resolution used on graphical terminal
23 # note that you can use only modes which your graphic card supports via VBE
24 # you can see them in real GRUB with the command `vbeinfo'
25 #GRUB_GFXMODE=640x480
26
27 # Uncomment if you don't want GRUB to pass "root=UUID=xxx" parameter to Linux
28 #GRUB_DISABLE_LINUX_UUID=true
29
30 # Uncomment to disable generation of recovery mode menu entries
31 #GRUB_DISABLE_RECOVERY="true"
32
33 # Uncomment to get a beep at grub start
34 #GRUB_INIT_TUNE="480 440 1"
注意,第11-12行一定要按着那么写,尤其是12行必须为空,不然GRUB没法显示HelloOS的界面
4、修改/boot/grub/grub.cfg,增加HelloOS启动项:
menuentry 'HelloOS' {
insmod part_msdos #GRUB加载分区模块识别分区
insmod ext2 #GRUB加载ext文件系统模块识别ext文件系统
set root='hd0,msdos1' #注意boot目录挂载的分区,这是我机器上的情况
multiboot2 /boot/HelloOS.bin #GRUB以multiboot2协议加载HelloOS.bin
boot #GRUB启动HelloOS.bin
}
set root那一行需要自己查看/boot文件夹挂载的位置,按照df -h命令查看,或者,重启电脑,按esc,再按c进入字符界面,通过ls查找HelloOS.bin文件,哪个盘显示就在哪个盘。
5、重启进入GRUB选择HelloOS
emmm,虚拟机强制重启(关电重启)吧,这个os毕竟连关闭功能都没有。