使用Bootloader加载操作系统内核2:Bootloader启动流程

搞清楚了i386分段机制以后,我们来看bootloader是怎么启动的。下图就是32位CPU的物理地址空间范围,前面的1M是之前的8086的地址空间,从0到640K就是之前的8086可以使用的物理内存,从640到768是VGG显存空间,往这个区域写入字符就可以在显示器里显示,768-960是用来映射到外设,960-1M就是映射到BIOS ROM,BIOS程序是放在ROM里面,ROM跟RAM一样通过地址就可以访问,1M以上就是32位CPU的物理内存,最上面的一点是映射到32位CPU的外设。

CPU上电以后是运行在实模式的,它会把CS的值设置成0xf000,然后IP设置为0xfff0,那么根据实模式的寻址方式,它获取到的物理地址就是CS左移四位加上这个IP的值就是0xFFFF0,这个地址是落在BIOS的地址空间范围的,对应的就是BIOS程序的入口地址,所以CPU上电以后就会跳转到BIOS程序去执行。

BIOS首先会进行自检,就是对计算机的硬件进行检查,看硬件有没有故障,然后对这些硬件进行初始化,初始化完成以后BIOS会选择一个启动设备,可以是硬盘光盘软盘,我们这里以硬盘为例,它会把硬盘的第一个扇区也就是主引导扇区,把里面的内容加载到物理地址7C00处,第一个扇区里面放的就是bootloader程序,它会设置CS=0然后IP=7C00,

这时候CPU就会执行bootloader程序,主引导扇区里面内容是以55AA这两个字节结束的,用来作为一种标记表明这个设备它可以用于启动,如果一个设备的主引导扇区不是以这两个字节结尾,那么就说明它不能用于启动,BIOS就会选择其它的设备来启动,bootloader启动完成后,它会设置GDTR寄存器,段描述符,然后将CPU从实模式切换到保护模式去执行。

我们先看一下bootloader的代码,然后再看它怎么编译链接执行的,

boot.S

* Macros to build GDT entries in assembly.
*/
#define SEG_NULL \
.word 0, 0; \
.byte 0, 0, 0, 0
#define SEG(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
// Application segment type bits
#define STA_X 0x8 // Executable segment
#define STA_E 0x4 // Expand down (non-executable segments)
#define STA_C 0x4 // Conforming code segment (executable only)
#define STA_W 0x2 // Writeable (non-executable segments)
#define STA_R 0x2 // Readable (executable segments)
#define STA_A 0x1 // Accessed
# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.

这里是设置段选择子,

.set PROT_MODE_CSEG, 0x8 # code segment selector
.set PROT_MODE_DSEG, 0x10 # data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag

start就是代码段的入口地址,然后关闭中段,把段寄存器的值设置成0,

.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment

这里是在实模式下面去打印hello word,就是把hello word这个字符串写入到VGA显存,

它是通过rep mov指令来实现的,它会把一个字符串从原地址写入到目的地址,目的地址是通过ES DI来指定的,ES就是基地址是0xb800,Di是偏移地址0xbe2,

所以它的寻址方式就是把b800转移4位,再加上这个be2就是b8be2,它对应的就是VGA显存的地址,然后原地址就来自于si msg这个标号儿,msg1里面存放的就是in real mode,

然后str里面就是hello word,cx里面放的是要移动的字节的大小,msg1是24个字节,这里str里的hello word是26个字节,那么它就会把这两个字符串,移动到VGA显存里面显示出来,

# print "hello world" in real mode
movw $0xb800,%ax
movw %ax,%es
movw $msg1,%si
movw $0xbe2,%di
movw $24,%cx
rep movsb
movw $str,%si
movw $0xc04,%di
movw $26,%cx
rep movsb

下面是打开A20地址线,也是为了跟之前的处理器进行兼容,如果没有打开A20地址线,那么它就只能寻址1M的地址空间,

打开以后它就可以寻址4G的地址空间,这里我们就大概知道它的意思就可以了,

# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x6,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60

下面是加载描述符表设置GDTR寄存器的内容,把CR0寄存器的第0位把它设置成1,这样就可以打开保护模式,

# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0

下面是一个长跳转,它会把CS寄存器设置成标号PROT_MODE_CSEG的值,就是代码段的段选择子8,

然后把IP寄存器设置成标号protcseg的值,对应的就是下面32位指令的入口地址,然后就会跳转到相应的地址去执行,

CPU在寻址的时候,就首先根据CS里面存放的段选择子,去描述符表中找到对应的段基址,

再用段基址加上IP里面的偏移地址,就可以获取到实际的物理地址,然后跳转到实际的物理地址去执行,就下面protcseg:位置,这时候就开始进入32位模式,

# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_SEG, $protcseg
.code32 # Assemble for 32-bit mode
protcseg:

它首先把数据段的段选择子,设置到DS寄存器里面就是10,然后设置其它的段比如SS堆栈段,在获取堆栈段里面内容的时候,就是取SS里面的内容作为段选择子,然后从描述符表中获取到段基址,

再加上偏移地址获取到实际的物理地址,因为后面这些内容它也是在数据段的,所以它跟DS设成一样的值,

# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment

下面是在保护模式下去打印hello world,我们可以看到它这里的寻址方式跟实模式就不一样,它是根据ES里面的内容作为段选择子,获取到对应的段基址,然后段基址再加上di里面的偏移地址,作为实际的物理地址,就是VGG缓存所对应的地址,然后把hello word写入到VGA缓存里面

# print "hello world" in protect mode
movl $msg2,%esi
movl $0xb8d22,%edi
movl $60,%ecx
rep movsb

下面是就是执行main.c里面定义的这个bootmain函数,在执行c函数之前首先要设置堆栈,那么就把栈顶设置成start这个地址,start就是代码段的起始地址,从start往上就是代码段,因为栈它是从高地址往低地址方向去增长的,所以从start往下就是栈的空间,跟代码段就不会冲突,然后bootman这个函数里面就一直进行循环,

# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
# Bootstrap GDT
.p2align 2 # force 4 byte alignment

下面是段描述符我们前面说过它使用的是一一映射,把虚拟地址跟线性地址进行一一映射,这样它就起不到保护的作用,因为我们当前只有bootloader一个程序,所以暂时用不到保护的机制,我们后面学习操作系统的时候会再去打开分页的机制,

gdt:
SEG_NULL # null seg
SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
SEG(STA_W, 0x0, 0xffffffff) # data seg
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
msg1:
.byt 'i',0x7,'n',0x7,' ',0x7,'r',0x7,'e',0x7,'a',0x7,'l',0x7,' ',0x7,'m',0x7,'o',0x7,'d',0x7,'e',0x7
msg2:
.byte 'i',0x7,'n',0x7,' ',0x7,'p',0x7,'r',0x7,'o',0x7,'t',0x7, 'e',0x7,'c',0x7,'t',0x7, 'e',0x7,'d',0x7,' ',0x7,'m',0x7,'o',0x7,'d',0x7, 'e',0x7
str:
.byte ':',0xc,' ',0xc,'h',0xc,'e',0xc,'l',0xc,'l',0xc,'o',0xc,' ',0xc,'w',0xc,'o',0xc,'r',0xc,'l',0xc,'d',0xc

main.c

void bootmain(void)
{
while (1)
/* do nothing */;
}

然后我们再看一下bootloader程序是怎么编译的,我们看一下对应的Makefile,

Makefile

OBJDIR := obj
OBJDIRS :=
TOOLPREFIX :=

首先是设置使用的工具命令,以及编译链接的参数,主要就是设置成编译成32位的,然后不要使用内置的标准库,因为我们编写的bootloader跟操作系统,它是没有标准库可以使用的,我们会自己去写标准库,

CC = $(TOOLPREFIX)gcc
AS = $(TOOLPREFIX)gas
LD = $(TOOLPREFIX)ld
OBJCOPY = $(TOOLPREFIX)objcopy
OBJDUMP = $(TOOLPREFIX)objdump
NM := $(TOOLPREFIX)nm
CFLAGS = -fno-pc -static -fno-builin -ggdb -gstabs -Wall -m32 -Werror -nostdinc -fno-stack-protector -MD
LDFLAGS = -m elf_i386 -nostdlib
# Make sure that 'all' is the first target
all:
clean:
rm -rf $(OBJDIR)

然后我们需要生成boot这个目标,boot目标就依赖于dir/bin/boot这个文件,我们编译后的文件是放在obj这个文件夹,然后boot这个文件它就就依赖于boot.o跟main.o,在boot这个文件下所有的.o文件,都是通过它所对应的.c文件跟.s文件来生成,就让gcc来编译,

######## for bootloader build
OBJDIRS += boot
BOOT_OBJS := $(OBJDIR)/boot/boot.o $(OBJDIR)/boot/main.o
$(OBJDIR)/boot/%.o: boot/%.c
@echo + cc -O0 $<
@mkdir -p $(@D)
$(CC) $(CFLAGS) -O0 -c -o $@ $<
$(OBJDIR)/boot/%.o: boot/%.S
@echo + as $<
@mkdir -p $(@D)
$(C) $(CFLAGS) -c -o $@ $<

boot.o跟main.o生成以后我们就生成boot文件,就使用链接器把它们链接成可执行文件,这里就是调用LD来进行链接,-e是指定程序的入口地址start,就是boot.s里的start,-Ttext是设置代码段的地址设成7C00,

因为我们的bootloader会加载到7C00去执行,所以这里需要设置成7C00,你这里设置成7C00,链接器就会认为你的程序,最终会在物理地址7C00的地方去执行,

那么它在链接的时候,就会根据这个地址去生成一些绝对地址,所以如果链接地址写的不是7C00,然后运行的时候又在7C00去运行,那么再去访问一下绝对地址的时候它就会报错,所以这里必须写成7C00,

$(OBJDIR)bin/boot: $(BOOT_OBJS)
@echo + ld bin/boot
@mkdir -p $(@D)
$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out

下面OBJCOPY是把编译生成的文件中的代码段提取出来,放在boot这个文件里面,因为我们编译生成的boot.out它是ELF格式的,但是BIOS它不会去解析这个ELF格式,它只是把这个文件内容放到7C00然后就跳转过去执行,所以我们这里需要把ELF文件里面代码段提取出来生成boot文件,

$(OBJCOPY) -S -O binary -j .text $@.out $@
perl boot/sign.pl $@
boot: $(OBJDIR)/bin/boot

然后我们编译执行boot这个目标来看一下,那么就是执行make boot,它就根据boot.s生成boot.o,main.c生成mian.o,然后使用链接器,把boot.o跟main.o链接成可执行文件boot.out,再把boot.out里面的代码段提取出来,放在boot文件里面,我们通过file命令看一下,boot.out它就是一个ELF的文件,然后这个boot它就是一个二进制的文件,它里面只有代码,我们通过hexdump来看一下,可以看到它里面就是二进制的代码,

我们通过objdump来看一下boot.out里面的代码段如下图,可以看到代码段的内容就是fa fc 31 c0,就上图boot里的fa fc 31 c0,所以把boot这个文件内容加载到7C00的地方,CPU就可以直接执行,从下图我们也可以看出代码段它的地址是7C00,

编译以后我们来看怎么去运行这个bootloader,正常的运行应该是把bootloader放在磁盘第一个扇区,然后BIOS从第一个扇区去加载执行bootloader,但是如果使用实际的设备的话就比较麻烦,所以我们会使用qemu来模拟,qemu是一个功能强大的模拟器,它可以模拟一台完整的计算机,然后计算机里面的CPU内存键盘什么设备都有,然后还有内置的BIOS程序,

你可以认为qemu跟一个正式的计算机是一模一样的,qemu里面可以运行Windows Linux Unix,如果你写的bootloader和OS可以在qemu里面运行,那么理论上你的代码不用做任何修改,就可以在一个正式的机器上去运行,qemu的运行也比较简单,就执行qemu-system-i386这个命令,然后设置一个虚拟的硬盘,硬盘里面存放的就是你的代码bootloader程序,

所以我们这里会先设置一个虚拟硬盘,把boot这个文件内容去放置到硬盘的第一个扇区,前面我们说过硬盘第一个扇区就是主引导扇区,它的格式是以55跟AA结尾的,

所以我们需要对这个boot文件做一下处理,调用这个脚本boot/sign.pl去执行,它是把boot这个文件扩展成512个字节,后面扩展的都是0,然后把最后两个字节设成55跟AA,

从下面hexdump -C boot我们也可以看出boot后面扩展的是0,最后面两个字节是55AA,所以它就符合主引导扇区的格式,

看下面的Makefile,根据boot文件来制作一个虚拟的硬盘就是boot.img,调用dd这个命令来执行,if是入口,of是出口,它入口开始是/dev/zero把boot.img里面的内容全部设成0,大小是10000,单位是扇区,就是有1万个扇区,512*10000就是5.1M,文件的大小就是5.1M,

然后第二个dd是把boot作为入口,然后boot.img作为出口,就是把boot的内容写入到虚拟键盘的第一个扇区,然后生成boot.img文件,然后这个boot.img它是作为all这个目标的依赖,all就是默认的目标,我们直接敲make命令它就执行的是all这个目标,

# How to build the boot disk image
$(OBJDIR)/bin/boot.img: $(OBJDIR)/bin/boot
@echo + mk $@
dd if=/dev/zero of=$(OBJDIR)/bin/boot.img count=10000 2>/dev/null
dd if=$(OBJDIR)/bin/boot of=$(OBJDIR)/bin/boot.img conv=nrunc 2>/dev/null
mv $(OBJDIR)/bin/boot.img~ $(OBJDIR)/bin/boot.img
all: $(OBJDIR)/bin/boot.img

然后我们执行make就会生成boot.img文件

然后我们就使用qemu来加载执行,就调用make qemu,qemu这个目标就依赖于这个boot.img,它会执行下面的qemu命令,去加载这个虚拟硬盘执行,

QEMU := qemu-system-i386
IMAGES = $(OBJDIR)/bin/boot.img
QEMUOPTS = drive file=$(OBJDIR)/bin/boot.img,index=0,media=disk -serial mon:stdio -m 512
qemu: $(IMAGES)
$(QEMU) $(QEMUOPTS)

我们来运行一下,qemu运行以后它就会执行它内置的BIOS程序,然后BIOS就会从虚拟硬盘的第一个扇区,去加载bootloader程序执行,bootloader首先会在实模式下面去打印hello word,然后切换到保护模式,在保护模式下面再去打印hello word,这样我们的bootloader程序就启动成功了.

觉得不错点赞关注一下,跟我一起学习计算机科学!