BIOS 启动计算机,检查和准备硬件后,接下来会将控制权传递给 Boot Loader。Boot Loader 负责将操作系统从磁盘加载到内存中。接下来讲解 Boot Loader 的实现细节。
在 JOS 中 Boot Loader 分为了汇编和 C 语言两部分来实现的,这章节讲解汇编部分 boot/boot.S
,下一章讲解 C 语言部分boot/main.c
。
为什么拆分为两部分?
简单来说无法全部用 C 语言来实现,例如将处理器从实模式切换到保护模式。这个过程需要直接操作处理器的控制寄存器,这些操作在高级语言中通常是不支持的,因此需要使用汇编语言来完成。
此外在系统启动时,硬件处于一个未知的状态,需要进行初始化。这部分工作通常包括设置中断,初始化内存,检测硬件设备等,这些工作需要直接操作硬件,而汇编语言可以直接操作硬件,因此通常使用汇编语言来完成。
当处理器切换到保护模式后,引导加载器需要加载内核到内存,并跳转到内核的入口点开始执行。这部分工作可以使用高级语言(如 C 语言)来完成,因为高级语言更易于编写和维护,同时也可以利用高级语言的各种特性,如函数、结构体等。
总的来说,使用汇编和 C 语言两部分来实现引导加载器,既可以利用汇编语言直接操作硬件的优势,又可以利用高级语言易于编写和维护的优势。
主引导记录(MBR)
主引导记录(Master Boot Record,MBR)是位于硬盘驱动器的第一个扇区(即硬盘的第一个物理扇区,通常是第 0 柱面,第 0 磁头,第 1 扇区)的一段引导代码。它的大小为 512 字节,主要包含以下两部分内容:
-
引导加载器(Boot Loader):这是一段小程序,它的任务是加载并执行操作系统的引导程序。引导加载器的大小通常非常小,因为它需要适应 MBR 的大小限制。在引导加载器中,通常会包含一些基本的硬件初始化代码,以及加载操作系统引导程序的代码。
-
分区表(Partition Table):分区表是硬盘分区信息的列表。它描述了硬盘上的各个分区的位置和大小。分区表通常位于 MBR 的最后,占用 64 字节,可以描述最多 4 个分区。
当计算机启动时,BIOS 会首先读取硬盘的 MBR,并执行其中的引导加载器代码。引导加载器会根据分区表的信息,找到操作系统的引导程序所在的位置,然后加载并执行它,从而启动操作系统。
需要注意的是,由于 MBR 的大小限制,引导加载器通常只能完成最基本的加载任务。对于一些复杂的操作系统,可能需要使用更复杂的引导加载器。在这种情况下,MBR 中的引导加载器的任务就是加载这个更复杂的引导加载器,这个更复杂的引导加载器通常被存储在硬盘的其他位置。
此外,MBR 也有一些局限性。例如,它只能支持最大 2TB 的硬盘,只能创建最多 4 个主分区等。为了解决这些问题,现代的计算机系统通常使用 GUID 分区表(GPT)来代替 MBR。
JOS 中 BIOS 最后会将 512 字节的引导扇区加载到物理地址 0x7c00
到 0x7dff
的内存中,其中包含了 Boot Loader 程序。随后使用指令 jmp 将 CS:IP
设置为 0000:7c00
并将控制权传递给 Boot Loader,最后 Boot Loader 负责将 OS 从磁盘加载到内存中。
为什么是 512 字节?
这个大小是由历史原因决定的,因为在早期的硬盘中,512 字节是一个很合适的大小,既可以满足存储引导加载器的需要,又不会浪费太多的空间。
PC 是以扇区位单位进行读取的数据的,一个扇区是 512 字节。为什么 PC 是以扇区为单位从磁盘中读取数据,主要有两个原因:
-
兼容性:由于历史原因,很多操作系统和硬件设备都假设硬盘的扇区大小为 512 字节。如果改变这个大小,可能会导致一些软件和硬件设备无法正常工作。
-
效率:硬盘的读写操作有一定的开销,如果每次只读写很小的数据,这个开销就会变得很大。因此,硬盘通常会一次读写一个扇区的数据,这样可以提高数据的读写效率。
在现代的硬盘中,扇区的大小可能会大于 512 字节,例如 4096 字节。但是为了兼容老的软件和硬件设备,这些硬盘通常会提供一个 512 字节扇区的模拟模式。
启用 A20
Boot Loader 拿到控制权后会先执行启用 A20 线,使得可以访问超过 1MB 的内存地址。
下面这段代码是用于启用 A20 线的。A20 线是计算机内存的一个地址线,它可以访问的内存地址超过 1MB。在早期的 PC 机中,为了保持向后兼容性,物理地址线 20 默认被拉低,这意味着所有超过 1MB 的内存地址都会回绕到零,也就是说,它们会被映射到内存的开始位置。
"拉低"在硬件设计中通常指的是将某个电平设置为低电平(通常是 0V),这在数字逻辑中通常表示逻辑"0"。"A20 线被拉低"意味着 A20 地址线被禁用,这限制了 CPU 访问的物理内存地址范围。
在早期的 PC 机中,为了保持与 IBM PC 和 IBM PC/XT 的向后兼容性,A20 线在启动时默认被禁用(或者说被"拉低")。这意味着,尽管 CPU 的地址总线有 21 根线(A0-A20),可以寻址 2^21(即 2MB)的内存空间,但由于 A20 线被禁用,实际上只能访问到 1MB 的内存空间。
启用 A20 线(或者说"拉高"A20 线)可以让 CPU 访问超过 1MB 的内存空间。这在运行需要大量内存的现代操作系统时是必要的。
然而,随着计算机硬件的发展,内存容量已经远远超过了 1MB,因此需要解除这一限制,以便能够访问更多的内存。这就是为什么需要启用 A20 线。
seta20.1:
inb $0x64,%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
这段代码的目的就是解除这一限制,启用 A20 线,使得可以访问超过 1MB 的内存地址。具体来说,代码首先等待键盘控制器(端口 0x64)不忙,然后向键盘控制器发送命令 0xd1,这个命令的作用是告诉键盘控制器接下来要写入的数据是一个命令字节。然后代码再次等待键盘控制器不忙,最后向键盘控制器的数据端口(端口 0x60)写入命令 0xdf,这个命令的作用是启用 A20 线。
在早期的 IBM PC 兼容机中,键盘控制器(通常是 Intel 8042 芯片)不仅负责处理键盘输入,还负责处理一些系统级别的功能,其中之一就是控制 A20 线的状态。这是因为在设计这些系统时,人们需要找到一种方法来禁用 A20 线以保持与旧的 8086 处理器的兼容性,而键盘控制器恰好有一些未使用的输出线,所以人们选择了使用键盘控制器来控制 A20 线。
在这段代码中,首先通过读取端口 0x64 的状态,等待键盘控制器不忙。然后向端口 0x64 写入 0xd1,这是一个特殊的命令,告诉键盘控制器接下来要写入的数据是一个命令字节,而不是普通的数据。然后再次等待键盘控制器不忙,最后向数据端口 0x60 写入 0xdf,这个命令的作用是启用 A20 线。
这样做的原因是,键盘控制器的某个输出线被连接到了 A20 线的门控,当这个输出线被设置为高电平时,A20 线就会被启用,允许 CPU 访问超过 1MB 的内存地址。而 0xdf 这个命令就是用来设置这个输出线为高电平的。
16 位实模式切换到 32 位保护模式
A20 线开启后接下来将 16 位实模式切换到 32 位保护模式,在 JOS 操作系统中,处理器从 16 位模式切换到 32 位模式的过程发生在引导加载器(boot loader)的早期阶段。这个过程通常在引导加载器的汇编语言部分中完成。
切换到 32 位模式的关键步骤是设置和启用保护模式。这是通过设置控制寄存器 CR0 的 PE(保护使能)位来实现的。当 PE 位被设置为 1 时,处理器就会进入保护模式,此时可以执行 32 位代码。
以上就是处理器从 16 位模式切换到 32 位模式的过程。下面是具体的切换代码。
# 通过使用引导GDT(全局描述符表)和段翻译,
# 从实模式切换到保护模式,使虚拟地址与物理地址相同,
# 以确保在切换期间内存映射保持不变。
lgdt gdtdesc
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
# 跳转到下一条指令,但在32位代码段中执行。
# 这将使处理器切换到32位模式。
ljmp $PROT_MODE_CSEG, $protcseg
这段代码描述的是从 16 位实模式切换到 32 位保护模式的过程。在 x86 架构的早期,处理器在启动时会处于 16 位的实模式,这种模式下,处理器可以直接访问物理内存,但是只能访问到 1MB 的内存空间。为了能够访问更多的内存和提供更好的内存保护机制,处理器需要切换到 32 位的保护模式。
切换到保护模式的过程如下:
-
lgdt gdtdesc
:这条指令用于加载全局描述符表(GDT)。GDT 是一种数据结构,它定义了各种不同的内存段,包括它们的基地址、限制和访问权限等信息。在切换到保护模式之前,需要先设置好 GDT。 -
movl %cr0, %eax
和orl $CR0_PE_ON, %eax
:这两条指令用于修改控制寄存器 CR0 的 PE(保护使能)位,将其设置为 1。CR0 是一个 32 位的寄存器,它的第 0 位是 PE 位,当 PE 位被设置为 1 时,处理器会切换到保护模式。 -
movl %eax, %cr0
:这条指令将修改后的 CR0 值写回 CR0 寄存器,完成模式切换。 -
ljmp $PROT_MODE_CSEG, $protcseg
:这条指令执行一个长跳转,跳转到标签protcseg
指向的地址,并且在跳转后,处理器会开始在 32 位代码段中执行代码。这条指令的执行会导致处理器更新代码段寄存器 CS,从而使处理器开始执行 32 位代码。
这样,处理器就从 16 位实模式切换到了 32 位保护模式。
设置堆栈指针并调用 C 语言函数。
下面这段代码是在设置保护模式下的数据段寄存器,并设置堆栈指针,然后调用 C 语言函数bootmain
。
protcseg:
# 设置保护模式下的数据段寄存器
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
# 设置堆栈指针并调用C语言函数。
movl $start, %esp
call bootmain
# 如果 bootmain 返回(它不应该返回),则进入循环。
spin:
jmp spin
首先,movw $PROT_MODE_DSEG, %ax
将PROT_MODE_DSEG
(保护模式下的数据段选择器)的值移动到寄存器%ax
中。然后,movw %ax, %ds
等指令将%ax
中的值复制到各个段寄存器(DS、ES、FS、GS 和 SS)中。这样做是为了在保护模式下设置正确的数据和堆栈段。
在 x86 架构中,DS、ES、FS、GS、SS 是段寄存器,它们在保护模式下用于存储段选择器,用于指定内存访问的段。
- DS(Data Segment):数据段寄存器,通常用于存储操作数和结果的内存段的段选择器。
- ES(Extra Segment):附加段寄存器,通常用于字符串和其他数据块操作的目标地址的段选择器。
- FS、GS:这是在 80386 中新增的两个段寄存器,主要用于操作系统,可以用于存储任何段选择器。
- SS(Stack Segment):堆栈段寄存器,用于存储堆栈的段选择器。
在这段代码中,所有这些段寄存器都被设置为同一个值(PROT_MODE_DSEG
),这是因为在这个特定的上下文中,所有的内存访问都应该在同一个内存段中进行。这样做可以简化内存管理,因为处理器不需要在不同的内存段之间切换。
接着,movl $start, %esp
将堆栈指针%esp
设置为start
的地址。这是为了在调用bootmain
函数之前设置正确的堆栈。
在 x86 架构中,%esp
寄存器是堆栈指针寄存器(Stack Pointer Register)。它的主要作用是指向当前的栈顶。当我们在程序中调用函数、保存临时变量或者保存 CPU 的状态时,这些信息通常会被压入栈中,而%esp
寄存器就是用来追踪当前栈顶位置的。
例如,当我们调用一个函数时,返回地址通常会被压入栈中,然后%esp
寄存器的值会减小(在 x86 架构中,栈是向下增长的),以指向新的栈顶。当函数返回时,返回地址会从栈中弹出,%esp
寄存器的值会增大,以指向新的栈顶。
因此,%esp
寄存器在函数调用、异常处理以及任务切换等操作中都起着非常重要的作用。
然后,call bootmain
调用bootmain
函数。这个函数应该包含了引导加载器的主要逻辑,例如加载内核到内存,然后跳转到内核的入口点。
最后,如果bootmain
函数返回(实际上它不应该返回),代码会进入一个无限循环spin
。这是一个安全措施,防止执行未定义的指令。如果bootmain
函数意外返回,CPU 将会在这个无限循环中停止,而不是继续执行可能存在的随机指令。
全局描述表 GDT
下面这段代码定义了一个全局描述符表(Global Descriptor Table,简称 GDT)。GDT 是 x86 架构中用于实现内存保护和分段内存管理的重要数据结构。每个段描述符定义了一个段的属性,如基地址、限制和访问权限等。
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
在这段代码中,定义了一个包含三个段描述符的 GDT:
-
第一个描述符是一个空描述符(
SEG_NULL
),在 x86 架构中,第一个描述符必须是空描述符。 -
第二个描述符定义了一个代码段(
SEG(STA_X|STA_R, 0x0, 0xffffffff)
)。STA_X
和STA_R
表示这个段是可读的并且可执行的。0x0
和0xffffffff
分别是这个段的基地址和限制,表示这个段覆盖了整个 4GB 的地址空间。 -
第三个描述符定义了一个数据段(
SEG(STA_W, 0x0, 0xffffffff)
)。STA_W
表示这个段是可写的。同样,这个段也覆盖了整个 4GB 的地址空间。
gdtdesc
定义了一个 GDT 描述符,它包含了 GDT 的大小和地址。在切换到保护模式之前,处理器需要知道 GDT 的位置和大小,这就是通过加载这个 GDT 描述符来实现的。因为在保护模式下,处理器通过段选择器和偏移量来访问内存,其中段选择器是一个 16 位的值,它的高 13 位是索引,用于在 GDT 中查找对应的段描述符。在这里,0x17
是 GDT 的大小减 1(因为 GDT 的大小是以字节为单位,所以需要减 1),gdt
是 GDT 的地址。
+------------------------+
| GDT Header |
+------------------------+
| Segment Descriptor 1 | +----------------------+
+------------------------+ | Segment Descriptor 2 |
| Segment Descriptor 2 | +----------------------+
+------------------------+ | Segment Descriptor 3 |
| Segment Descriptor 3 | +----------------------+
| ... | | ... |
+------------------------+ +----------------------+
| Segment Descriptor n | | Segment Descriptor n |
+------------------------+ +----------------------+
上面的文本图形化展示了全局描述符表(GDT)的基本结构。GDT 是用于在 x86 架构中进行内存分段和保护的重要数据结构。以下是各个部分的解释:
-
GDT Header: GDT 的开头包含一个简单的头部,其中包括 GDT 的大小等信息。
-
Segment Descriptor 1, 2, 3, ..., n: GDT 包含一系列段描述符,每个描述符对应一个内存段。每个段描述符包含了关于该段的信息,如基地址、段限制、访问权限等。这些描述符以数组的形式存在,可以根据需要添加更多的描述符。
每个段描述符的结构大致如下:
+------------------------+------------------------+
| Base Address | Segment Limit | <-- 64 bits
+------------------------+------------------------+
| G | D/B | 0 | AVL | P | Limit | AVL | S | Type | <-- 32 bits
+------------------------+--------+-------+-------+
-
Base Address: 段的基地址,指示段在内存中的起始位置。
-
Segment Limit: 段的限制,指示段的大小。
-
G (Granularity): 指示段限制的单位,如果为 1,表示以 4KB 为单位;如果为 0,表示以字节为单位。
-
D/B (Default/Big): 当为 1 时表示 32 位操作模式,当为 0 时表示 16 位操作模式。
-
AVL (Available): 可由系统或程序自由使用的位。
-
P (Present): 表示段是否在内存中存在。
-
S (System/Segment): 如果为 0,表示是系统段;如果为 1,表示是代码或数据段。
-
Type: 指示段的类型,如代码段、数据段等。
以上图示仅为简化的表示,实际 GDT 可能会更复杂,包括特权级、段的类型、权限等更多信息。这里的图示主要用于概述 GDT 的基本结构。
保护模式下的寻址方式
在 x86 架构中,当处理器运行在保护模式下时,内存访问不再是直接通过物理地址,而是通过一个叫做"段选择器"的值加上一个偏移量来完成的。这种方式提供了更好的内存保护和更大的内存访问范围。
段选择器是一个 16 位的值,它的高 13 位是索引,用于在全局描述符表(GDT)中查找对应的段描述符,剩余的 3 位就被用来表示请求者的特权级别和选择使用的表,为访问控制和隔离提供了更多的灵活性。段描述符包含了段的基地址、限制和访问权限等信息。
假设我们有一个段选择器,它的值为0x1234
,那么它的二进制表示为0001 0010 0011 0100
。其中,高 13 位(0001 0010 0011 0
)是索引,用于在 GDT 中查找对应的段描述符。
在 GDT 中,每个段描述符占用 8 个字节,所以我们可以通过索引乘以 8 来计算段描述符在 GDT 中的偏移量。在这个例子中,索引的值为0x123
(十进制的 291),所以段描述符在 GDT 中的偏移量为0x123 * 8 = 0x918
。
然后,处理器会将这个偏移量加上 GDT 的基地址,得到段描述符在内存中的物理地址。处理器会从这个地址处读取 8 个字节的数据,得到段描述符的内容。
最后,处理器会根据段描述符的内容和偏移量来访问内存。例如,如果偏移量为0x5678
,那么处理器会访问的物理地址就是段的基地址加上0x5678
。
总结
整体来说,Boot Loader 是在启动过程中的关键步骤,它设置了 CPU 的运行环境,从实模式切换到保护模式,并准备好跳转到高级语言编写的引导程序。