自制x86 Bootloader开发笔记(2)——— Bootloader设计与启动区代码实现

发布时间 2023-11-05 01:45:13作者: basic60

计算机启动流程简介

要知道如何设计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

这里介绍最关键的几个部分:

  1. 硬盘镜像生成:使用dd命令生成空的文件,dd if=/dev/zero of=$(DISK) bs=1024 count=524288,命令表示生成1024*524288字节的文件,内部全部填充空数据0x00。
  2. Makefile递归执行:使用Makefile -C参数切换makefile工作目录
  3. 硬盘格式化:自己实现了一个建议文件系统和格式化工具,代码可见项目的tool目录,这里不进行赘述
  4. 启动区代码写入:同样使用dd命令写入,dd if=$(FSC_OBJ) of=$(DISK) conv=notruncconv=notrunc表示不进行截断,硬盘镜像文件显然比bootloader大很多,写入bootloder后其他剩余的扇区当然要保留下来
  5. 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)

同样介绍最关键的几个部分:

  1. 启动区前三个扇区使用汇编实现,完成读取硬盘内容,进入长模式等工作。elf loader因逻辑更复杂,使用C语言实现
  2. elf loader(即kernel_loader这个任务)编译出来的产物不能直接使用,因为gcc编译出来的64位可执行文件不是纯二进制的,它有着自己的ABI,就是ELF格式,我们还指望elf loader来实现加载elf文件的功能呢,当前并没有运行elf文件的能力,只能接受纯二进制的代码。因此这里需要一些trick,只把代码里的可执行代码和数据拿出来,这样就得到了我们编写的代码的纯二进制格式,使用objcopy命令即可完成这个工作$(OBJCOPY) -O binary $(TARGET_LOADER_TMP) $(TARGET_LOADER)
  3. 在最外层的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中断需要使用的参数:

INT13H 中断使用参数
Registers Description
AH 42h = 扩展读功能函数编号
DL 驱动器编号 (e.g. 1st HDD = 80h)
DS:SI segment:offset pointer to the DAP
DAP : Disk Address Packet
offset range size description
00h 1 byte size of DAP (set this to 10h)
01h 1 byte unused, should be zero
02h..03h 2 bytes 需要读取的扇区数
04h..07h 4 bytes 读取的数据最终的传输地址
08h..0Fh 8 bytes 开启读取的扇区LBA (LBA从0开始)LBA详解见 logical block addressing.

我们将之后几个扇区加载到启动区之后的扇区,也就是0x8000的位置,让后跳转至第二个扇区,启动区的工作就结束了。

之后进入长模式和文件系统以及ELF Loader的内容相对独立,放在之后的章节描述。


项目地址:https://github.com/basic60/ARCUS

引用

  1. http://www.ruanyifeng.com/blog/2013/02/booting.html ,计算机是如何启动的?
  2. https://en.wikipedia.org/wiki/INT_13H#INT_13h_AH=42h:_Extended_Read_Sectors_From_Drive , 使用扩展INT 13H中断读取硬盘