OS开发(2) - 构建一个极简OS(Multiboot)

启动OS

OS本质上也是一个软件,储存在硬盘上。当电脑刚开机的时候内存里全是清空状态,需要一个程序加载OS到内存中开始运行,这个程序就是引导加载程序(bootloader)。电脑硬件中有ROM(只读内存)晶片,其中存有BIOS,ROM中的内容会永久保存。电脑开机后,会首先运行BIOS,进行加点自检(POST),然后加载主引导扇区(MBR)到内存中来运行其中的bootloader。Bootloader启动后用户可以进行简单的操作,选择想要加载的操作系统并将其加载入内存。但其实使用传统BIOS的电脑越来越少,现在大多数都已经使用UEFI模式。UEFI中文是统一可扩展固件接口,用于定义操作系统与系统固件之间的软件界面,作为传统BIOS的替代方案。其实总的来说,传统BIOS和UEFI引导启动OS的方式没有什么太大区别,都是这几个步骤:开机运行ROM中固定的程序,读取硬盘中一个固定的分区/位置来加载运行bootloader,初始化部分硬件,用户交互bootloader选择OS,检查OS后将其载入内存开始运行。UEFI能逐渐取代传统BIOS的理由有以下几点:

  1. UEFI启动的bootloader是CPU的保护模式,而传统BIOS是实模式
  2. UEFI提供了多种设备的支持,并为其提供了C语言风格接口(软件抽象),避免开发者去写汇编语言去和硬件互动
  3. UEFI支持GPT分区表,有了比MBR(传统BIOS所用)更大的储存空间

我们先从传统BIOS开始,使用最简单的汇编搭建一个可以运行的最简OS。Bootloader上选择GRUB(由GUN提供),之后可以考虑构建一个自己的bootloader。当bootloader准备加载OS的时候,我们还没有堆栈和虚拟内存的概念,硬件也还未完全初始化,在传统BISO下,运行操作系统的第一步只能是汇编语言,告诉cpu该如何执行由C/C++语言编写成的kernel。

Multiboot Standard是bootloader和kernel之间一个简单的interface,GRUB通过它来识别kernel。原理是设置一些特殊的全局变量作为multiboot header,如果bootloader找到这些特殊值就可以成功识别我们的OS kernel。

OS引导程序

创建boot.S来作为引导,这里分析一个 osdev 的例子:

/* Declare constants for the multiboot header. */
.set ALIGN,    1<<0             /* align loaded modules on page boundaries */
.set MEMINFO,  1<<1             /* provide memory map */
.set FLAGS,    ALIGN | MEMINFO  /* this is the Multiboot 'flag' field */
.set MAGIC,    0x1BADB002       /* 'magic number' lets bootloader find the header */
.set CHECKSUM, -(MAGIC + FLAGS) /* checksum of above, to prove we are multiboot */
 
/* 
声明multiboot header来让bootloader可以检测出自己是kernel。bootloader会检查kernel file的前
8KB内容,保证4-byte对齐。单独开启一个程序段(section)来保证能在kernel file的最开头出现
*/
.section .multiboot
.align 4
.long MAGIC
.long FLAGS
.long CHECKSUM
 
/*
Multiboot standard没有定义stack pointer,所以需要kernel自己来提供,这里我们分配16KB作为
kernel的stack。由于x86的stack是从顶部向下增长,但真实的stack是从底部向上增长,所以先定义
stack_bottom符号,跳过16KB,再定义stack_top符号,来保证stack符合x86的从"top"向"bottom"增长。
根据System V ABI standard,x86的stack必须16-byte对齐。因为这个stack是等程序装载的时候
才需要分配空间且初始化,所以我们可以将其定义在bss section来减小kernel file的大小
*/
.section .bss
.align 16
stack_bottom:
.skip 16384 	# 16 KB
stack_top:
 
/*
链接脚本会指定_start为kernel入口,bootloader装载完程序后会跳到这里开始运行。由于kernel
需要一直运行,所以此处永不return
*/
.section .text
.global _start
.type _start, @function		# 指定_start的类型是函数
_start:
	/*
	此时bootloader会开启x86的32位保护模式(32位保护模式是通过segment来寻址,并加
	入了不同segment有不同的cpu等级权限来进行保护),关闭中断和分页,将CPU设置为
	multiboot规范所要求的状态。此时没有printf函数,没有任何限制和安全级别限制,也
	没有debug机制,只有内核自己定义的一切。此时内核拥有对机器的完全控制权。
	*/
 
	/* 将stack pointer放入寄存器esp中来初始化stack */
	mov $stack_top, %esp
 
	/*
	在进入更上层的kernel前,可以在此处初始化一些关键cpu状态。尽可能减少当关键功能脱机时
	的早期环境。此时处理器还没有完全初始化:浮点运算指令和扩展指令集都还未初始化。此处应
	该装载GDT(32位保护模式寻址所需的东西),开启分页功能。如果有用到C++的全局构造器
	(构造全局类和静态类)和exception的话,也需要提前准备好来保证运行顺利
	*/
 
	/*
	进入kernel上层(C/C++代码)。System V ABI要求执行call的时候stack必须16-byte
	对齐,我们首先对齐了16 byte,分配了16倍数bytes的空间,所以至此保证stack是
	16-byte对齐的
	*/
	call kmain
 
	/*
	如果系统已经没事做了,就进入无限循环:
	1. 用cli指令禁止中断。bootloader已经禁止过了。不过可能还会在kmain里打开中断并返
	   回这里,所以还是要cli一次。虽然从kmain返回这件事本身不太可能。
	2. 用hlt指令锁住电脑,直到下一次中断来临。
	3. 由于已经关掉了中断,所以只有不可屏蔽中断和x86的系统管理模式会唤醒电脑。当这种情
	   况发生时,再跳回hlt指令来无限循环
	*/
	cli
1:	hlt
	jmp 1b
 
/*
将_start的size设为当前位置'.'减去它的开始位置,用于debug
*/
.size _start, . - _start

值得一提的是,汇编有两种语法:Intel和AT&T。两种语法有一些差别,如Intel中第一个操作数作为目的操作数,第二个操作数作为源操作数(例:mov dest src)。而在AT&T中,第一个操作数是源操作数,第二个是目的操作数(例:mov src dest)。AT&T也要求在寄存器名字前加%来表示这是一个寄存器。GCC默认使用AT&T语法,所以我们以上写的汇编也是基于AT&T的。之后可能会有一些C语言中的内联汇编(__asm__("...")),也会基于AT&T语法。编译引导程序boot.S等待linking:

i686-elf-as boot.s -o boot.o

编写内核

在平常的C/C++环境下,开发者在用户空间内有很多标准库可以使用,这些标准库的实现方式是由系统来定。但在开发OS的时候,我们不能使用这些标准库,因为我们处于一个独立的环境,使用其他系统提供的标准库并不能在我们想要的机器上正常运行。然而,有一些头文件并不是标准库的内容,而是由编译器提供的。即使在独立的C语言开发环境中,也可以使用这些头文件。如:使用<stdbool.h>获取bool类型,<stddef.h>获取size_tNULL<stdint.h>获取intx_tuintx_t(x是多少位)等。此外还有<float.h><iso646.h><limits.h><stdarg.h>等。之后就可以用C语言来写kernel.c了,注意入口函数是kmain(在boot.s中定义的)。 osdev 上有一个很简单的kernel,实现了用VGA文本模式在终端输出字符。写完后编译:

i686-elf-gcc -c kernel.c -o kernel.o -std=gnu99 -ffreestanding -O2 -Wall -Wextra

-c代表只编译和汇编但不链接,-ffreestanding代表独立开发环境,-Wall -Wextra打开警告。值得一提的是,VGA文本模式和BIOS逐渐被弃用,逐渐转向UEFI,需要用像素画出每一个字符。

链接内核

在一般开发中,GCC自带链接脚本来链接多个object文件,但在系统开发中我们需要自己链接来达到想要的目的。这里依旧是分析 osdev 上的一个简单链接脚本(保存在linker.ld中):

/* bootloader会从_start处进入内核 */
ENTRY(_start)
 
/* 指定内核每个程序段的链接地址、加载地址等 */
SECTIONS
{
	/* 从1MB这个地址开始,因为bootloader一般都是把内核加载到这里 */
	. = 1M;
 
	/* 首先放入multiboot header来让bootloader识别,其首次是真正的text段 */
	.text BLOCK(4K) : ALIGN(4K)
	{
		*(.multiboot)
		*(.text)
	}
 
	/* 只读数据 */
	.rodata BLOCK(4K) : ALIGN(4K)
	{
		*(.rodata)
	}
 
	/* 初始化的读写数据 */
	.data BLOCK(4K) : ALIGN(4K)
	{
		*(.data)
	}
 
	/* 未初始化的读写数据(包括stack) */
	.bss BLOCK(4K) : ALIGN(4K)
	{
		*(COMMON)
		*(.bss)
	}
 
	/* 编译器可能会生成别的section,默认会将他们放入同名segment中。如果需要
	   的话在此处继续添加内容即可 */
}

最后可以用编译器链接所有object文件来生成最终的kernel:

i686-elf-gcc -T linker.ld -o myOS.bin -ffreestanding -O2 -nostdlib boot.o kernel.o -lgcc

此时myOS.bin就是我们的kernel。注意这里我们也链接了libgcc,交叉编译器会依赖与它,所以最好是打包在一起。

验证Multiboot

需要安装GRUB,使用以下命令可以检测内核二进制文件是不是符合multboot的标准:

grub-file --is-x86-multiboot myos.bin

如果return code是0则没有问题,否则是1(linux可以用echo $?查看最近一次运行程序的return code)

运行内核

除了GRUB以外还需要安装xorriso(Ubuntu可能需要安装mtools)。首先创建grub.cfg并将以下代码保存在其中:

menuentry "myOS" {
	multiboot /boot/myOS.bin
}

然后通过以下命令将myOS.bin打包成GRUB能直接使用的CD-ROM镜像文件myOS.iso:

mkdir -p isodir/boot/grub
cp myOS.bin isodir/boot/myOS.bin
cp grub.cfg isodir/boot/grub/grub.cfg
grub-mkrescue -o myOS.iso isodir

使用Ubuntu的话可能会出现grub-mkrescue: error: 'mformat' invocation failed错误(我自己两台机器都这样),这是缺了mtools了,用以下命令安装:

sudo apt install mtools

用虚拟机测试OS

虚拟机可以帮我们测试我们的操作系统,这里使用QEMU,以下命令可以通过iso启动OS:

qemu-system-i386 -cdrom myos.iso

如果所在的开发系统是由EFI程序(即使用UEFI)引导启动,则GRUB可能不是PC-BIOS版本的,从而生成的iso需要EFI去引导,qemu-system-i386无法使用UEFI引导启动OS。此时需要安装PC-BIOS版本的GRUB,在Ubuntu上安装:

apt install grub-pc-bin

此外,QEMU也支持没有可启动介质的情况下直接启动内核二进制文件:

qemu-system-i386 -kernel myos.bin

请我喝杯咖啡吧 ヾ(^▽^*)))
hyt658 - 支付宝 支付宝
hyt658 - 微信 微信
  • 文章标题: OS开发(2) - 构建一个极简OS(Multiboot)
  • 本文作者: hyt658
  • 本文链接: /2.os-barebone.html
  • 版权声明: 本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!