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的理由有以下几点:
- UEFI启动的bootloader是CPU的保护模式,而传统BIOS是实模式
- UEFI提供了多种设备的支持,并为其提供了C语言风格接口(软件抽象),避免开发者去写汇编语言去和硬件互动
- 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_t
和NULL
,<stdint.h>
获取intx_t
和uintx_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