操作系统的设计与实现(一)BootLoader引导加载程序

《一个64位操作系统的设计与实现-田宇》学习笔记(一)BootLoader引导加载程序

第一篇笔记主要内容是BootLoader的原理与实现

代码放在:https://github.com/Sunxingzhezhexingsun/LzxOS

其它参考资料:

为什么主引导记录的内存地址是0x7C00?

计算机是如何启动的?

磁盘基本知识

BIOS中断向量表

汇编语言第14章 端口的读写

知乎-关于内存地址和显存地址

第一章 操作系统概述

操作系统的组成结构如下图,分为内核层与应用层。内核层主要负责控制硬件设备、分配系统资源、为应用层提供健全的接口支持、保证应用程序稳定运行、执行流调度等等。而应用层则主要负责人机交互。

  • 引导启动

    是指,计算机从BIOS上电自检后到跳转至内核程序执行前这一期间执行的一段或几段程序 这些程序主要用于检测计算机硬件,并配置内核运行时所需的参数 然后再把检测信息和参数提交给内核解析,内核会在解析数据的同时对自身进行配置。引导启动程序曾经分为两部分,Boot和Loader,现在通常合二为一,统称为BootLoader

  • 内存管理

    主要作用是有效管理物理内存

  • 异常/中断处理

  • 进程管理

  • 设备驱动

  • 文件系统

    文件系统用于把机械硬盘的部分或全部扇区组织成一个便于管理的结构化单元

编写一个操作系统需要的知识:

  • 硬件方面

    需要指导处理器和外围设备的电路组成,也就是处理器和外围设备是怎么连接的,进而可以知道处理器如何控制外围设备,以及如何与之通信。

    需要阅读硬件设备的芯片手册,芯片手册会详细描述芯片的硬件特性、通信方式、芯片内部的寄存器功能,以及控制寄存器的方法。但是和电子工程师关注的点不一样,作为操作系统开发人员更关注处理器如何与硬件设备通信、如何控制它们的寄存器状态。

    所以在硬件方面, 掌握硬件电路、处理器和外围设备的芯片手册即可。其中,处理器芯片手册会介绍如何初始化处理器 如何切换处理器工作模式等一系列操作处理器的信息与方法,这些知识为操作系统运行提供技术指导 硬件芯片手册会对设备上的所有寄存器功能进行描述,我们根据这些寄存器功能方可编写出驱动程序。

  • 软件方面

    汇编语言和C语言,还有数据结构。

第二章 虚拟机及开发系统平台介绍

开发和编译环境书里用的CentOS 6,我用的是MacOS 10.15。虚拟运行环境书里用的Bochs,我可能也用用QEMU。在mac上可以很方便的用brew安装bochs和qemu,不用折腾编译的问题

1
2
3
4
# 安装qemu
brew install qemu
# 安装 bochs
brew install bochs

汇编语言的书写格式大体分两种,一种是AT&T汇编语言格式,一种是Intel汇编语言格式。AT&T的格式相对复杂,Intel格式相对简洁。

第三章 BootLoader引导启动程序

3.1 Boot引导程序

3.1.1 Boot引导原理

BIOS自检结束后会根据启动选项设置去选择启动设备。比如如果是软盘启动的话,就去检测第0磁头第0磁道第1个扇区,是否以数值0x55 0xaa两字节作为结尾如果是,那么BIOS就认为这个扇区是 Boot Sector (引导扇区),进而把此扇区的数据复制到物理内存地址0x7c00处,随后将处理器的执行权移交给这段程序(跳转至0x7C00地址处执行

因为这个扇区只有512B,所以只能装的下一个boot程序,由boot程序将Loader程序装到内存中,再由Loader程序装载内核。这个过程也可以看作是硬件设备向软件设备移交控制权。

3.1.2 写一个Boot引导程序

通过BIOS的中断服务,实现了往屏幕显示字符串的功能:

需要注意开头的org 0x7c00起始地址,因为BIOS会将这段程序装载到物理内存的0x7c00地址,如果不指定这个起始地址的话可能会导致寻址错误。而末尾的最后两个字节0x55 0xAA则表明这是一个引导扇区

BIOS中断向量表:https://blog.csdn.net/piaopiaopiaopiaopiao/article/details/9735633

通过BIOS中断来实现各种功能

3.1.4 在Bochs(QEMU)上运行Boot

QEMU运行

原书用的bochs,一开始我在ubuntu18上装最新版的bochs有问题没解决,所以用了qemu

1
sudo apt-get install qemu

编译boot.asm

1
nasm ./boot.asm -o boot.bin

将二进制文件写入软盘映像

1
dd if=boot.bin of=./boot.img bs=512 count=1 conv=notrunc

qemu启动脚本:

1
2
3
4
5
# boot.sh
qemu-system-x86_64 \
-boot a \ #使用
-fda $1 \ #挂载$1到第一个软盘
-m 2048

Run:

1
./boot.sh ./boot.img

鼠标点进去QEMU窗口后会锁定出不来,我是mac上开的vmware ubuntu,直接Ctrl+空格的话是鼠标回到mac主机,要从QEMU回到ubuntu虚拟机的话,得按ctrl+option+空格

Bochs运行

运行bochs的配置文件bochsrc:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# configuration file generated by Bochs
plugin_ctrl: unmapped=1, biosdev=1, speaker=1, extfpuirq=1, parallel=1, serial=1, iodebug=1
config_interface: textconfig
# display_library: x
#memory: host=2048, guest=2048
romimage: file="/usr/local/share/bochs/BIOS-bochs-latest"
vgaromimage: file="/usr/local/share/bochs/VGABIOS-lgpl-latest"
boot: floppy
floppy_bootsig_check: disabled=0
floppya: type=1_44, 1_44="boot.img", status=inserted, write_protected=0
# no floppyb
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata0-master: type=none
ata0-slave: type=none
ata1: enabled=1, ioaddr1=0x170, ioaddr2=0x370, irq=15
ata1-master: type=none
ata1-slave: type=none
ata2: enabled=0
ata3: enabled=0
pci: enabled=1, chipset=i440fx
vga: extension=vbe, update_freq=5

# cpu: count=1:1:1, ips=4000000, quantum=16, model=corei7_haswell_4770, reset_on_triple_fault=1, cpuid_limit_winnt=0, ignore_bad_msrs=1, mwait_is_nop=0, msrs="msrs.def"

cpuid: x86_64=1,level=6, mmx=1, sep=1, simd=avx512, aes=1, movbe=1, xsave=1,apic=x2apic,sha=1,movbe=1,adx=1,xsaveopt=1,avx_f16c=1,avx_fma=1,bmi=bmi2,1g_pages=1,pcid=1,fsgsbase=1,smep=1,smap=1,mwait=1,vmx=1
cpuid: family=6, model=0x1a, stepping=5, vendor_string="GenuineIntel", brand_string="Intel(R) Core(TM) i7-4770 CPU (Haswell)"

print_timestamps: enabled=0
debugger_log: -
magic_break: enabled=0
port_e9_hack: enabled=0
private_colormap: enabled=0
clock: sync=none, time0=local, rtc_sync=0
# no cmosimage
# no loader
log: -
logprefix: %t%e%d
debug: action=ignore
info: action=report
error: action=report
panic: action=ask
keyboard: type=mf, serial_delay=250, paste_delay=100000, user_shortcut=none
mouse: type=ps2, enabled=0, toggle=ctrl+mbutton
speaker: enabled=1, mode=system
parport1: enabled=1, file=none
parport2: enabled=0
com1: enabled=1, mode=null
com2: enabled=0
com3: enabled=0
com4: enabled=0

megs: 2048

bochs运行bochs -f ./bochsrc

3.1.5 加载Loader到内存

FAT12文件系统

为方便后续加载loader和加载kernel,为软盘创建了FAT12文件系统。

这样引导扇区的结构也要改变,在boot代码之前要加入FAT12文件系统的控制信息:

对应的代码:

FAT12文件系统下的软盘可以分为这几个区:

其中FAT1和FAT2是两个一样的FAT表,相当于有一个是备份的作用。

FAT12文件系统以簇分配数据区的存储空间。一个文件存储的时候,占用的磁盘空间不一定是连续的,FAT表就是用于将这些不连续的文件片段按照簇号链接起来,就像单向链表一样。所以可以看到FAT表项中的值,其实就是下一个簇的簇号,这样只需要给出文件对应存储空间的第一个簇号,就可以根据FAT表一直检索出后续的簇了。FAT表项如下:

根目录区保存着目录项信息。用来描述文件和目录的,最重要的是起始簇号,根据起始簇号可以像上面说的那样,根据FAT表定位到文件的存储位置。目录项结构如下:

然后借助BIOS中断服务来写一个从磁盘读取扇区数据到内存的函数:Func_ReadSector()

用到的中断:

关于扇区、磁道、磁头等,参考磁盘基本知识

函数接收三个参数,起始扇区号、读入扇区数、目标缓冲区地址。其中,起始扇区号用的是LBA格式(Logic Block Address,逻辑块格式),而INT 13H,AH=02H中断服务需要的是CHS格式(Cylinder/Head/Sector,柱面/磁头/扇区格式)的磁盘扇区号,因此在函数中要进行转换:

Func_ReadSector()函数:

然后可以可以通过Func_ReadSector()方法,将根目录区中的扇区读入内存缓冲区,来寻找目录文件名称为”LOADER BIN”的目录项。这里需要说明的是,FAT12文件系统的目录项中目录文件名规定是11位,其中,8字节为文件名,3字节为扩展名,而且FAT12是不区分文件名大小写的。比如一个小写字母组成的文件“loader.bin”,会创建一个文件名为大写”LOADER BIN”的目录项,数据都是放在大写名称的目录项中的。而小写文件名的目录项尽作为其显示的名称来用,因此这里我们搜索的是大写名称的目录项。

搜索loader.bin文件目录项的代码如下,代码逻辑主要是:从根目录区读取扇区到内存,然后遍历扇区内的每一个目录项,将目录项内的文件名字符串和”LOADER BIN”比对,如果在这个扇区中找到了对应的目录项,则跳转至成功分支,否则,继续读取下一个扇区,直到搜索完根目录区的14个扇区为止。(根目录所容纳的目录项数=224, 目录项大小=32,224*32/512 上取整 = 14个扇区)

搜索失败的话在频幕上显示搜索失败的字符串:

这里还没有写loader.bin程序,所以当然会搜索失败了,在Bochs中运行:

找到对应的目录项后,就可以从里面拿出起始的簇号。

我们知道,要想从磁盘中读取整个文件,需要从起始簇号开始,通过FAT表不断的检索下一个簇号,因此可以先实现一个函数Func_ParseFATEntry(),其功能是用于解析FAT表项,输入一个簇号(FAT表项号),输出下一个簇号(FAT表项号)。代码如下,主要逻辑就是通过FAT表项号计算出目标FAT表项所在的扇区及扇区内偏移,通过Func_ReadSector()方法把FAT表项所在的扇区加载进内存,再根据扇区内偏移读取FAT表项中的内容,以得到下一个簇的簇号。这里需要注意的是,FAT12文件系统的一个FAT表项是12位即1.5个字节,3个字节存储2个FAT表项。所以需要判断表项的奇偶性,才能准确的读出表项内容,具体的操作看代码:

有了Func_ParseFATEntry()Func_ReadSector(),就可以根据目录项中的起始簇号,查询fat表,按顺序将loader.bin加载进内存了。代码如下,代码逻辑主要就是读取出对应目录项中的DIR_FstClus字段,得到loader.bin的起始簇号,然后根据FAT表,按顺序一个簇一个簇地将文件内容加载到内存中,直到FAT表项中的值为0xfff,表示这是文件的最后一个簇,然后就jmp到loader的地址去执行loader的代码。这里给loader安排的内存地址是0x10000,因为现在CPU是以实模式运行的,是通过段寄存器<<4 + 段内偏移得到物理地址的,所以这里将段寄存器内容设置为LoaderBase=0x1000,段内偏移设置为LoaderOffset=0

3.1.6 从Boot跳转到Loader程序

将loader加载到内存后,通过jmp LoaderBase:LoaderOffset跳转到loader地址处。这里使用的是段间的跳转,所以需要指明段基地址。这条代码执行后,CS寄存器的值就会设置为LoaderBase的值,即0x1000,在实模式下段寄存器的值需要左移四位,即得到Loader的段基地址0x10000。

写一个简单的Loader,在屏幕上显示开始执行loader的信息,这里我将loader填充为5个扇区大小,是为了检测上面boot中的按顺序读取扇区功能是否正常工作

编译boot.asm和loader.asm

1
2
nasm boot.asm -o boot.bin
nasm loader.asm -o loader.bin

用bximage创建一个1.44M大小的软盘镜像:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
liuzhongxin@MacBook-Pro boot % bximage 
========================================================================
bximage
Disk Image Creation / Conversion / Resize and Commit Tool for Bochs
$Id: bximage.cc 13481 2018-03-30 21:04:04Z vruppert $
========================================================================

1. Create new floppy or hard disk image
2. Convert hard disk image to other format (mode)
3. Resize hard disk image
4. Commit 'undoable' redolog to base image
5. Disk image info

0. Quit

Please choose one [0] 1

Create image

Do you want to create a floppy disk image or a hard disk image?
Please type hd or fd. [hd] fd

Choose the size of floppy disk image to create.
Please type 160k, 180k, 320k, 360k, 720k, 1.2M, 1.44M, 1.68M, 1.72M, or 2.88M.
[1.44M] 1.44M

What should be the name of the image?
[a.img] boot.img

Creating floppy image 'boot.img' with 2880 sectors

The following line should appear in your bochsrc:
floppya: image="boot.img", status=inserted

将boot.bin写入软盘:

1
dd if=boot.bin of=./boot.img bs=512 count=1 conv=notrunc

然后将loader.bin放进软盘中,这里因为在boot.bin中已经为软盘创建文件系统了,所以只需要挂载软盘镜像,然后用cp命令,就可以将loader.bin复制进去,cp命令是会自动识别文件系统并做出正确的操作的,而不需要我们自己去设置目录表项、FAT表项啥的。

在mac下,可以直接使用hdiutil mount img来装载软盘镜像到/Volumes/目录下,推出软盘用hdiutil eject /Volumes/bootloader。其实也可以直接双击它,mac就会将它装载了,就像拔插U盘一样方便

1
2
3
liuzhongxin@MacBook-Pro boot % hdiutil mount ./boot.img 
/dev/disk2 /Volumes/bootloader
liuzhongxin@MacBook-Pro boot %

然后将loader.bin直接copy进去就行了

1
cp ./loader.bin /Volumes/bootloader/

用bochs运行:

根据boot.asm中的代码,每读取loader.bin的一个扇区就输出一个".",这里输出了五个,正好符合Loader.bin的大小,说明boot.asm的代码没有问题~

Boot引导总结

总结一下boot引导程序的过程:

  • 创建引导扇区(第0磁头、第0磁道、第1扇区以0x55 0xAA结尾)
  • 引导程序以0x7c00作为起始地址
  • 在引导扇区中建立FAT12文件系统(FAT12文件系统的整个组成结构信息,包括FAT表份数、根目录区容纳的目录项数、总扇区数等)
  • 编写函数Func_ReadSector(),实现从磁盘读取扇区到内存的功能
  • 使用Func_ReadSector()函数,将根目录区中的扇区逐个读入内存,寻找loader.bin目录项
  • 编写函数Func_ParseFATEntry(),输入FAT表项号,输出下一个簇的簇号(FAT表项号)
  • 使用Func_ParseFATEntry()函数和Func_ReadSector()函数,将loader.bin从磁盘中加载进内存
  • jmp到Loader引导加载程序

3.2 Loader引导加载程序

3.2.1 Loader原理

Loader引导加载程序的任务主要有三个:检测硬件信息、处理器模式切换、向内核传递数据。这些工作能够让内核初始化之后正常运行。

  • 检测硬件信息

    Loader引导加载程序需要检测的硬件信息很多,主要是通过BIOS 中断服务程序来获取和检测硬件信息 由于BIOS在上电自检出的大部分信息只能在实模式下获取,而且内核运行于非实模式下,那么就必须在进入内核程序前将这些信息检测出来,再作为参数提供给内核程序使用在这些硬件信息中,最重要的莫过于物理地址空间信息,只有正确解析出物理地址 间信息,才能知道ROM RAM 设备寄存器 间和内存空洞等资源的物理地址范围,进而将其交给内存管理单元模块加以维护。

  • 处理器模式切换

    本书第六章就是处理器体系结构相关的基础内容

    操作系统的设计与实现(二)处理器体系结构

    从起初BIOS运行的实模式( real mode ),到32位操作系统使用的保护模式( protect mode ),再到64位操作系统使用的IA-32e模式( long mode ,长模式), Loader引导加载程序必须历经这三个模式,才能使处理器运行于64位的IA-32e模式。

  • 向内核传递数据

    这里的数据一部分是控制信息,这部分是纯软件的,比如启动模式之类的东西,另一部分是硬件数据信息,通常是指Loader引导加载程序检测出的硬件数据信息 Loader引导加载程序将这些数据信息多半都保存在固定的内存地址中,并将数据起始内存地址和数据长度作为参数传递给内核,以供内核程序在初始化时分析 配置和使用,典型的数据信息有内存信息、 VBE信息等

装载Kernel,突破1MB寻址限制

Loader最终要将Kernel装载到内存地址0x100000处,即1MB处,而实模式下,物理地址寻址位宽是20位,刚好上限就是1MB,因此,要想将Kernel装载到内存地址0x100000处,需要突破这个寻址限制。

首先,我们需要开启地址A20功能,这是一个历史遗留问题。最初的处理器只有20根地址线,只能寻址1MB以内的物理内存,为了保证硬件平台的向下兼容,便出现了一个控制开启或禁止1MB以上地址空间的开关。这个开关引脚称之为A20。如果A20引脚位低电平,那么只有低20位地址有效,其它位均为0。开启A20功能这里使用了操作I/O端口0x92的方法,其实也可以通过BIOS中断服务程序INT 15h来做。

开启了地址A20功能后,就要想办法突破保护模式的逻辑地址寻址位宽只有20位的限制了。这里,我们采用的方法是:先进入保护模式,在保护模式中直接给fs段寄存器载入段选择子(该段选择子指向的段描述符的段基址为0,段限长为4GB),然后处理器在装入段选择子的同时还会把段描述符给装入fs段寄存器(隐藏部分),然后我们再切回实模式,接下来再使用fs段寄存器访问内存时,处理器就会按照保护模式的逻辑地址寻址方式,即段基地址+32位段内偏移,这样就可以把寻址范围从1MB扩大到4GB了

开启地址A20功能和为fs段寄存器加载段选择子、段描述符的代码如下,这里进入保护模式是通过mov cr0, eax指令将CR0.PE标志位置1,回到实模式则反之。注意lgdt指令前的0x66,这是因为当前代码处于16位宽状态下(在代码开始使用了[SECTION .16]定义了一个新的segment,并使用[BITS 16]伪指令通知NASM编译器生成的代码将运行在16位宽的处理器上),使用32位宽数据指令时需要在指令前加入前缀0x66。clisti指令则分别用于关闭外部中断和开启外部中断。

代码中用到的段描述符和段选择子等相关数据结构如下:

通过boot装载loader的过程我们知道,将磁盘中的数据装进内存是借助BIOS中断服务程序完成的,而实模式下的BIOS中断服务程序只支持1MB以内的物理地址空间寻址。因此,我们可以取一个中转的地址0x7E00,先用BIOS中断服务程序将扇区中的数据读入这个1MB内的缓冲区,再使用movloop指令将这个扇区的内容复制到0x100000地址处即可。

这里可以直接使用boot.asm中寻找loader.bin的代码,将其装载入内存的代码也可以copy过来,稍作修改、增加内存复制的代码即可:

最后,完成装载后在屏幕第一行中间输出一个G字母,标识装载完成kernel,这里没有用INT 10h中断来完成输出,而是用了更符合操作显卡内存的方法。在最开始的1MB物理地址空间内,不仅有显示字符的内存空间,还有显示像素的内存空间以及其它用途的内存空间。这段代码只为展示操作显示内存的方法,毕竟不能长期依赖BIOS中断服务程序。知乎-关于内存地址和显存地址

编译后运行,这里我随便写了个kernel.bin,以验证装载是否正常运行:

编译运行:

利用bochs的内存查看功能,看一下0x10000处的内容是否正确:

正确!说明kernel.bin已经被正确的加载到了物理地址0x100000处

同时,通过sreg命令查看段寄存器,可以看到fs段寄存器与其它实模式下的段寄存器的不同(段基地址和段限长等):

获取物理地址空间信息

参考资料

Detecting Memory(x86) BIOS Function: INT 0X15, EAX=0XE820

从硬件获取内存布局–E820

INT 15h,AX = E820h-查询系统地址映射

在x86平台下,通过BIOS中断服务程序INT 15h, EAX=0xE820可以获取物理地址空间的信息,物理地址空间信息由一个结构体数组构成,它描述了计算机平台的地址空间划分情况,它记录的地址空间类型包括可用物理内存空间、设备寄存器地址空间、内存空洞等…

这个中断服务程序的具体用法可以参考Detecting Memory(x86) BIOS Function: INT 0X15, EAX=0XE820

By far the best way to detect the memory of a PC is by using the INT 0x15, EAX = 0xE820 command. This function is available on all PCs built since 2002, and on most existing PCs before then. It is the only BIOS function that can detect memory areas above 4G. It is meant to be the ultimate memory detection BIOS function.

基本用法:
对于第一次调用该函数,将ES:DI指向列表的目标缓冲区。清除EBX。将EDX设置为幻数0x534D4150。将EAX设置为0xE820(请注意,EAX的高16位应设置为0)。将ECX设置为24。执行INT 0x15。

如果第一次调用该函数成功,则EAX将设置为0x534D4150,并且进位标志将被清除。 EBX将被设置为某个非零值,在下次调用该函数时必须保留该值。 CL将包含实际存储在ES:DI的字节数(可能为20)。

对于随后的函数调用:将DI增加您的列表条目大小,将EAX重置为0xE820,将ECX重置为24。到达列表末尾时,EBX可能重置为0。如果再次使用EBX =调用该函数0,列表将重新开始。如果EBX未重置为0,则当您在最后一个有效条目之后尝试访问该条目时,该函数将返回进位设置。

这里我们通过”0xE820”将物理地址空间信息保存在0x7E00地址处的临时转存空间里(这块地址刚才用来作为临时的kernel缓冲区了,现在kernel已经装载完毕,所以可以将这块地址另作他用)操作系统会在初始化内存管理单元时解析该结构体数组。

在Ubuntu虚拟机中,通过dmesg | grep e820可以看到这个结构体数组的内容:

结构体数组的表项如下(取自Linux kernel):

1
2
3
4
5
struct e820entry {
__u64 addr; /* start of memory segment */
__u64 size; /* size of memory segment */
__u32 type; /* type of memory segment */
} __attribute__((packed));

每进行一次调用,就会往ES:DI写入一个这种结构体,然后需要我们手动增加DI的值,继续读取下一个结构体到内存,直到EBX返回0为止。

代码实现如下,这里面我把利用中断服务程序打印字符串的功能封装成了一个函数

编译运行,查看0x7e00处的内存,可以看到已经将内存信息结构体数组读到了物理地址0x7e00处

3.2.3 从实模式进入保护模式再到IA-32e模式

完成这部分功能之前,建议先学习x86处理器的几种模式,可以阅读本书第六章(学习笔记:https://www.sunxiaokong.xyz/2020-09-05/lzx-babyos-2/)

0%