自制x86 Bootloader开发笔记(2)——— Bootloader设计与启动区代码实现
计算机启动流程简介
要知道如何设计bootloader,需要先了解一下计算机启动的流程。具体可见引用1,这里只需要关注以下这一点即可:
- 系统启动后会自动将硬盘的第一个扇区(主引导记录,MBR)加载至内存0x7c00处,并检查MBR的第511和第512个字节是否为0x55和0xaa,如果是,则跳转至0x7c00出开始执行对应的代码。
只要知道了这一点,我们的开发之路就可以正式启动了,因为从代码跳转至0x7c00处开始,控制权就转交到了我们所编写的代码之中。
总体设计
bootloader的作用就和它的名字一样,一个作用是boot,另一个作用是loader,它负责电脑上电启动后的一些系统准备工作,完成后加载内核并且跳转到内核的入口,将控制权转交给内核。这次要实现的bootloader比较简单,主要完成以下几个功能
编写Makefile
我们使用Makefile来控制项目的构建,这里分成了三个Makefile文件。第一个最外层的Makefile负责生成硬盘镜像、格式化文件系统、往硬盘写入booloader以及内核等环境初始化工作。其他两个Makefile则是子Makefile,负责bootloader的构建和内核的构建,项目结构如下:
|--最外层Makefile
|--bootloader
| |-- bootloader的Makefile
| |-- bootloader的源代码
|--kernel
| |-- kernel的Makefile
| |-- kernel的源代码
最外层Makefile
CORES = $(shell grep -c ^processor /proc/cpuinfo 2>/dev/null || sysctl -n hw.ncpu)
DISK = kernel.img
FSC_OBJ = ./bootloader/boot.o
LOADER_OBJ = ./bootloader/loader.o
FORMATOR = ../tools/mkmyfs/mkmyfs
KERNEL = ./kernel/arcus_kernel
all: build qemu
build: clean build_bootloader build_kernel
ifneq ($(FORMATOR), $(wildcard $(FORMATOR)))
$(MAKE) all -j$(CORES) -C ../tools/mkmyfs
endif
ifneq ($(DISK), $(wildcard $(DISK)))
dd if=/dev/zero of=$(DISK) bs=1024 count=524288
sleep 5
endif
$(FORMATOR) $(DISK) -f
dd if=$(FSC_OBJ) of=$(DISK) conv=notrunc
dd if=$(LOADER_OBJ) of=$(DISK) conv=notrunc seek=3
$(FORMATOR) $(DISK) -w $(KERNEL)
sleep 1
.PHONY: build_bootloader
build_bootloader:
$(MAKE) all -j$(CORES) -C bootloader
.PHONY: build_kernel
build_kernel:
$(MAKE) all -j$(CORES) -C kernel
.PHONY: qemu
qemu:
qemu-system-x86_64 -smp 8 -m 8g -hda $(DISK) -monitor stdio -no-reboot
.PHONY: clean
clean:
$(MAKE) clean -C bootloader
$(MAKE) clean -C kernel
这里介绍最关键的几个部分:
- 硬盘镜像生成:使用
dd
命令生成空的文件,dd if=/dev/zero of=$(DISK) bs=1024 count=524288
,命令表示生成1024*524288字节的文件,内部全部填充空数据0x00。 - Makefile递归执行:使用Makefile -C参数切换makefile工作目录
- 硬盘格式化:自己实现了一个建议文件系统和格式化工具,代码可见项目的tool目录,这里不进行赘述
- 启动区代码写入:同样使用
dd
命令写入,dd if=$(FSC_OBJ) of=$(DISK) conv=notrunc
,conv=notrunc
表示不进行截断,硬盘镜像文件显然比bootloader大很多,写入bootloder后其他剩余的扇区当然要保留下来 - elf loader写入:
dd if=$(LOADER_OBJ) of=$(DISK) conv=notrunc seek=3
,依旧是dd命令,启动区我们使用汇编实现,而elf loader因此逻辑更复杂,采用了C语言,因此编译结果是一个独立的二进制文件,需要额外写入,seek=3
表示跳过前三个扇区,即跳过之前写入的bootloader
bootloader的Makefile
ASM = nasm
CC = x86_64-elf-gcc
OBJCOPY = x86_64-elf-objcopy
LD = x86_64-elf-ld
FST_PART_SRC = boot.asm
LOADER_C_SRC = $(shell find . -name "*.c")
LOADER_C_OBJ = $(patsubst %.c,%.o,$(LOADER_C_SRC))
LOADER_ASM_SRC = $(shell find . -name "*.asm" ! -path "./boot.asm")
LOADER_ASM_OBJ = $(patsubst %.asm,%.o,$(LOADER_ASM_SRC))
TARGET_FST_PART = boot.o
TARGET_LOADER_TMP = loader_tmp.o
TARGET_LOADER = loader.o
all: clean first_part kernel_loader
.PHONY: first_part
first_part: $(FST_PART_SRC)
$(ASM) $(FST_PART_SRC) -f bin -g -o $(TARGET_FST_PART)
.PHONY: kernel_loader
kernel_loader: link
$(OBJCOPY) -O binary $(TARGET_LOADER_TMP) $(TARGET_LOADER)
.PHONY: link
link: $(LOADER_C_OBJ) $(LOADER_ASM_OBJ)
$(LD) -nostdlib -Ttext 0x8200 $(LOADER_C_OBJ) $(LOADER_ASM_OBJ) -T scripts/loader.ld -o $(TARGET_LOADER_TMP)
%.o:%.c
$(CC) -c -w -ffreestanding -I ./include -o $@ $<
%.o:%.asm
$(ASM) -f elf64 -o $@ $<
.PHONY: clean
clean:
-rm $(TARGET_FST_PART) $(TARGET_LOADER_TMP) $(TARGET_LOADER) $(LOADER_C_OBJ) $(LOADER_ASM_OBJ)
同样介绍最关键的几个部分:
- 启动区前三个扇区使用汇编实现,完成读取硬盘内容,进入长模式等工作。elf loader因逻辑更复杂,使用C语言实现
- elf loader(即kernel_loader这个任务)编译出来的产物不能直接使用,因为gcc编译出来的64位可执行文件不是纯二进制的,它有着自己的ABI,就是ELF格式,我们还指望elf loader来实现加载elf文件的功能呢,当前并没有运行elf文件的能力,只能接受纯二进制的代码。因此这里需要一些trick,只把代码里的可执行代码和数据拿出来,这样就得到了我们编写的代码的纯二进制格式,使用objcopy命令即可完成这个工作
$(OBJCOPY) -O binary $(TARGET_LOADER_TMP) $(TARGET_LOADER)
- 在最外层的Makefile我们知道elf loader的写入位置是第四个扇区,即0x7c00 + (512 * 3) = 0x8200,因此需要指定elf loader的程序入口点是0x8200, 使用
-Ttext 0x8200
,并配合链接器脚本,即可让链接器将程序的入口点函数锚定到0x8200的位置,链接器脚本如下:
ENTRY(main)
SECTIONS
{
.text :
{
*(.text.main);
*(.text*);
}
}
*(.text.main)
指定了.text.main段在text段中排在最前面的位置,配合C语言void loader_main() __attribute__ ((section (".text.main")));
,将loader_main函数放置到.text.main中,即可让代码跳转到0x8200就开始执行loader_main函数。由此就完成了汇编语言和C语言纯二进制产物的融合。
编写启动区代码
Makefile编写完成之后,就终于可以开始bootloader的代码编写了,首先开发第一个扇区,就是启动区的代码:
[BITS 16]
org 7c00h
mov si, 0
mov ax, cs
mov ds, ax
mov es, ax
mov esp, 7c00h
jmp load_stage2
disk_rw_struct:
db 16 ; size of disk_rw_struct, 10h
db 0 ; reversed, must be 0
dw 0 ; number of sectors
dd 0 ; target address
dq 0 ; start LBA number
read_disk_by_int13h:
mov eax, dword [esp + 8]
mov dword [disk_rw_struct + 4], eax
mov ax, [esp + 6]
mov word [disk_rw_struct + 2], ax
mov eax,dword [esp + 2]
mov dword [disk_rw_struct + 8], eax
mov ax, 4200h
mov dx, 0080h
mov si, disk_rw_struct
int 13h
ret
load_stage2:
push dword 0x7e00 ; target address
push word 50 ; number of blocks
push dword 1 ; start LBA number
call read_disk_by_int13h
add esp, 10
jmp enter_long_mode
times 510-($-$$) db 0
dw 0xaa55
这段代码编译之后正好是512个字节,是一个扇区的大小,正好填满启动区。代码中times 510-($-$$) db 0
这行表示重复填0直到填满510个字节位置,而dw 0xaa55
则使得这个扇区的最后两个字节满足启动区签名的条件,使其能够被识别为一个启动区。
第一个扇区的功能很简单,就是调用BIOS中断中的扩展INT 13h中断,读取之后的几个扇区,并且跳转到第二个扇区。下面这张表摘自引用2,描述了INT 13h中断需要使用的参数:
Registers | Description | ||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
AH | 42h = 扩展读功能函数编号 | ||||||||||||||||||
DL | 驱动器编号 (e.g. 1st HDD = 80h) | ||||||||||||||||||
DS:SI | segment:offset pointer to the DAP
|
我们将之后几个扇区加载到启动区之后的扇区,也就是0x8000的位置,让后跳转至第二个扇区,启动区的工作就结束了。
之后进入长模式和文件系统以及ELF Loader的内容相对独立,放在之后的章节描述。
项目地址:https://github.com/basic60/ARCUS