第1章 引导和内核初始化

1.1. 概要

本章概述了引导和系统初始化过程,从 BIOS(固件)POST 开始,到第一个用户进程创建。由于系统启动的初始步骤非常依赖于体系结构,因此以 IA-32 体系结构为例。但是 AMD64 和 ARM64 体系结构更为重要和引人注目,根据本文档的主题,应在不久的将来对其进行解释。

FreeBSD 引导过程可能出乎意料地复杂。在 BIOS 将控制权传递给内核之后,必须完成大量底层配置,然后才能加载并执行内核。此设置必须以简单且灵活的方式完成,从而允许用户获得大量的自定义可能性。

1.2. 概述

引导过程是一个极其依赖于机器的活动。不仅必须为每种计算机体系结构编写代码,而且在同一体系结构上也可能存在多种类型的引导。例如,stand 的目录列表显示了大量与体系结构相关的代码。每个受支持的体系结构都有一个目录。FreeBSD 支持 CSM 引导标准(兼容性支持模块)。因此,CSM 受支持(同时支持 GPT 和 MBR 分区),并且 UEFI 引导(完全支持 GPT,大部分支持 MBR)。它还支持从 ext2fs、MSDOS、UFS 和 ZFS 加载文件。FreeBSD 还支持 ZFS 的引导环境功能,该功能允许 HOST 操作系统传达有关引导内容的详细信息,这些详细信息超出了过去可能的简单分区。但是,如今 UEFI 比 CSM 更相关。以下示例显示了从 MBR 分区硬盘驱动器引导 x86 计算机,其中 FreeBSD boot0 多引导加载程序存储在第一个扇区中。该引导代码启动 FreeBSD 三阶段引导过程。

理解此过程的关键在于它是一系列复杂程度不断提高的阶段。这些阶段是 boot1boot2loader(有关更多详细信息,请参阅 boot(8))。引导系统按顺序执行每个阶段。最后一个阶段 loader 负责加载 FreeBSD 内核。以下各节将检查每个阶段。

以下是在不同引导阶段生成的输出示例。实际输出可能因机器而异

FreeBSD 组件

输出(可能有所不同)

boot0

F1    FreeBSD
F2    BSD
F5    Disk 2

boot2 [1]

>>FreeBSD/x86 BOOT
Default: 0:ad(0p4)/boot/loader
boot:

loader

BTX loader 1.00 BTX version is 1.02
Consoles: internal video/keyboard
BIOS drive C: is disk0
BIOS 639kB/2096064kB available memory

FreeBSD/x86 bootstrap loader, Revision 1.1
Console internal video/keyboard
([email protected], Fri Apr  9 04:04:45 UTC 2021)
Loading /boot/defaults/loader.conf
/boot/kernel/kernel text=0xed9008 data=0x117d28+0x176650 syms=[0x8+0x137988+0x8+0x1515f8]

内核

Copyright (c) 1992-2021 The FreeBSD Project.
Copyright (c) 1979, 1980, 1983, 1986, 1988, 1989, 1991, 1992, 1993, 1994
        The Regents of the University of California. All rights reserved.
FreeBSD is a registered trademark of The FreeBSD Foundation.
FreeBSD 13.0-RELEASE 0 releng/13.0-n244733-ea31abc261f: Fri Apr  9 04:04:45 UTC 2021
    [email protected]:/usr/obj/usr/src/i386.i386/sys/GENERIC i386
FreeBSD clang version 11.0.1 ([email protected]:llvm/llvm-project.git llvmorg-11.0.1-0-g43ff75f2c3fe)

1.3. BIOS

当计算机开机时,处理器的寄存器将设置为一些预定义的值。其中一个寄存器是指令指针寄存器,其在开机后的值是明确定义的:它是 32 位值 0xfffffff0。指令指针寄存器(也称为程序计数器)指向处理器要执行的代码。另一个重要的寄存器是 32 位控制寄存器 cr0,其在重新引导后的值是 0cr0 的一个位,即 PE(保护启用)位,指示处理器是在 32 位保护模式下运行还是在 16 位实模式下运行。由于此位在引导时被清除,因此处理器在 16 位实模式下引导。实模式意味着,除其他事项外,线性地址和物理地址相同。处理器不在 32 位保护模式下立即启动的原因是向后兼容。特别是,引导过程依赖于 BIOS 提供的服务,而 BIOS 本身则使用旧版 16 位代码工作。

0xfffffff0 的值略小于 4GB,因此除非机器具有 4GB 的物理内存,否则它无法指向有效的内存地址。计算机的硬件会转换此地址,使其指向 BIOS 内存块。

BIOS(基本输入输出系统)是主板上的一个芯片,具有相对较少的只读内存 (ROM)。此内存包含各种特定于主板提供的硬件的底层例程。处理器将首先跳转到地址 0xfffffff0,该地址实际上位于 BIOS 的内存中。通常,此地址包含一个跳转指令,指向 BIOS 的 POST 例程。

POST(开机自检)是一组例程,包括内存检查、系统总线检查和其他底层初始化,以便 CPU 可以正确设置计算机。此阶段的重要步骤是确定引导设备。现代 BIOS 实现允许选择引导设备,从而允许从软盘、CD-ROM、硬盘或其他设备引导。

POST 中的最后一件事是 INT 0x19 指令。INT 0x19 处理程序从引导设备的第一个扇区读取 512 字节到地址 0x7c00 的内存中。术语“第一个扇区”源自硬盘驱动器体系结构,其中磁盘被划分为多个圆柱形磁道。磁道编号,每个磁道被划分为多个(通常为 64 个)扇区。磁道编号从 0 开始,但扇区编号从 1 开始。磁道 0 是磁盘上最外层的磁道,扇区 1(第一个扇区)具有特殊用途。它也称为 MBR 或主引导记录。第一个磁道上的其余扇区从不使用。

此扇区是我们的引导顺序起点。正如我们将看到的,此扇区包含我们的 boot0 程序的副本。BIOS 将跳转到地址 0x7c00,以便开始执行。

1.4. 主引导记录(boot0

在从 BIOS 接收控制权(位于内存地址0x7c00)后,boot0 开始执行。它是 FreeBSD 控制下的第一段代码。boot0 的任务非常简单:扫描分区表,并允许用户选择要从中启动的分区。分区表是一种特殊且标准的数据结构,嵌入在 MBR 中(因此也嵌入在 boot0 中),描述了四个标准的 PC “分区”。boot0 驻留在文件系统中的 /boot/boot0。它是一个大小为 512 字节的小文件,如果您在安装时选择了“bootmanager”选项,则 FreeBSD 的安装程序会将其写入硬盘的 MBR。实际上,boot0 **就是** MBR。

如前所述,我们正在调用 BIOS 的INT 0x19将 MBR(boot0)加载到地址0x7c00处的内存中。boot0 的源文件可以在 stand/i386/boot0/boot0.S 中找到,这是一段由 Robert Nordier 编写的很棒的代码。

MBR 中从偏移量0x1be开始的一个特殊结构称为分区表。它包含四个 16 字节的记录,称为分区记录,它们表示硬盘是如何分区的,或者用 FreeBSD 的术语来说,是如何被切片的。这 16 个字节中的一个字节表示分区(切片)是否可引导。必须且只能有一个记录设置此标志,否则 boot0 的代码将拒绝继续执行。

分区记录具有以下字段:

  • 1 字节的文件系统类型

  • 1 字节的可引导标志

  • 6 字节的 CHS 格式描述符

  • 8 字节的 LBA 格式描述符

分区记录描述符包含有关分区在驱动器上确切位置的信息。LBA 和 CHS 这两个描述符都描述了相同的信息,但方式不同:LBA(逻辑块寻址)具有分区的起始扇区和分区长度,而 CHS(柱面磁头扇区)具有分区的第一个和最后一个扇区的坐标。分区表以特殊签名0xaa55结尾。

MBR 必须适合 512 字节,即一个磁盘扇区。此程序使用低级“技巧”,例如利用某些指令的副作用以及重用先前操作的寄存器值,以最大限度地利用尽可能少的指令。在处理嵌入在 MBR 本身中的分区表时,也必须小心。由于这些原因,修改 boot0.S 时要非常小心。

请注意,boot0.S 源文件按“原样”汇编:指令被逐一转换为二进制,没有任何附加信息(例如,没有 ELF 文件格式)。这种低级控制是在链接时通过传递给链接器的特殊控制标志实现的。例如,程序的文本段被设置为位于地址0x600。在实践中,这意味着必须将 boot0 加载到内存地址0x600才能正常工作。

值得查看 boot0Makefilestand/i386/boot0/Makefile),因为它定义了 boot0 的一些运行时行为。例如,如果使用连接到串行端口(COM1)的终端进行 I/O,则必须定义宏SIO-DSIO)。-DPXE 通过按 F6 启用通过 PXE 启动。此外,程序定义了一组标志,允许进一步修改其行为。所有这些都在 Makefile 中进行了说明。例如,查看链接器指令,这些指令命令链接器将文本段的起始地址设置为0x600,并按“原样”构建输出文件(去除任何文件格式)。

      BOOT_BOOT0_ORG?=0x600
      ORG=${BOOT_BOOT0_ORG}
stand/i386/boot0/Makefile

现在让我们开始研究 MBR 或 boot0,从执行开始的地方开始。

为了更好地说明,对某些指令进行了一些修改。例如,展开了一些宏,并在测试结果已知时省略了一些宏测试。这适用于所有显示的代码示例。

start:
      cld			# String ops inc
      xorw %ax,%ax		# Zero
      movw %ax,%es		# Address
      movw %ax,%ds		#  data
      movw %ax,%ss		# Set up
      movw $LOAD,%sp		#  stack
stand/i386/boot0/boot0.S

这段代码块是程序的入口点。它是 BIOS 传递控制权的地方。首先,它确保字符串操作自动递增其指针操作数(cld 指令)[2]。然后,因为它对段寄存器状态没有假设,所以它初始化了它们。最后,它将堆栈指针寄存器(%sp)设置为($LOAD = 地址0x7c00),因此我们拥有了一个可用的堆栈。

下一个代码块负责代码的重定位和随后跳转到重定位后的代码。

      movw %sp,%si     # Source
      movw $start,%di		# Destination
      movw $0x100,%cx		# Word count
      rep			# Relocate
      movsw			#  code
      movw %di,%bp		# Address variables
      movb $0x8,%cl		# Words to clear
      rep			# Zero
      stosw			#  them
      incb -0xe(%di)		# Set the S field to 1
      jmp main-LOAD+ORIGIN	# Jump to relocated code
stand/i386/boot0/boot0.S

由于 BIOS 将 boot0 加载到地址0x7C00,因此它将自身复制到地址0x600,然后将控制权转移到那里(回想一下,它被链接到地址0x600执行)。源地址0x7c00被复制到寄存器%si。目标地址0x600被复制到寄存器%di。要复制的字数为256(程序大小 = 512 字节),被复制到寄存器%cx。接下来,rep 指令重复其后面的指令,即movsw,重复次数由%cx寄存器决定。movsw 指令将%si指向的字复制到%di指向的地址。这重复了另外 255 次。在每次重复中,源寄存器和目标寄存器%si%di都会递增 1。因此,在完成 256 字(512 字节)的复制后,%di的值为0x600`512`= `0x800`,%si的值为`0x7c00`512= 0x7e00;我们因此完成了代码的重定位。自从上次更新此文档以来,代码中的复制指令已发生更改,因此已引入 movsw 和 stosw 而不是 movsb 和 stosb,它们每次迭代复制 2 字节(1 个字)。

接下来,目标寄存器%di被复制到%bp%bp获得值0x800。值8被复制到%cl,以准备进行新的字符串操作(如我们之前的movsw)。现在,stosw执行 8 次。此指令将0值复制到目标寄存器(%di,其值为0x800)指向的地址,并递增它。这重复了另外 7 次,因此%di最终值为0x810。实际上,这清除了地址范围0x800-0x80f。此范围用作写入 MBR 回磁盘的(伪造)分区表。最后,此伪造分区的 CHS 寻址的扇区字段被赋予值 1,并从重定位后的代码跳转到主函数。请注意,在跳转到重定位后的代码之前,避免了对绝对地址的任何引用。

以下代码块测试 BIOS 提供的驱动器号是否应使用,或使用存储在 boot0 中的驱动器号。

main:
      testb $SETDRV,_FLAGS(%bp)	# Set drive number?
#ifndef CHECK_DRIVE	/* disable drive checks */
      jz save_curdrive		# no, use the default
#else
      jnz disable_update	# Yes
      testb %dl,%dl		# Drive number valid?
      js save_curdrive		# Possibly (0x80 set)
#endif
stand/i386/boot0/boot0.S

此代码测试flags变量中SETDRV位(0x20)。回想一下,寄存器%bp指向地址位置0x800,因此测试是在地址0x800-69= 0x7bb处的flags变量上进行的。这是可以对 boot0 进行的修改类型的示例。SETDRV标志默认情况下未设置,但可以在 Makefile 中设置。设置后,将使用存储在 MBR 中的驱动器号,而不是 BIOS 提供的驱动器号。我们假设默认值,并且 BIOS 提供了一个有效的驱动器号,因此我们跳转到save_curdrive

下一个代码块保存 BIOS 提供的驱动器号,并调用putn在屏幕上打印新行。

save_curdrive:
      movb %dl, (%bp)		# Save drive number
      pushw %dx			# Also in the stack
#ifdef	TEST	/* test code, print internal bios drive */
      rolb $1, %dl
      movw $drive, %si
      call putkey
#endif
      callw putn		# Print a newline
stand/i386/boot0/boot0.S

请注意,我们假设未定义TEST,因此其中的条件代码不会被汇编,并且不会出现在我们的可执行文件 boot0 中。

我们的下一个代码块实现了分区表的实际扫描。它在屏幕上打印分区表中四个条目中的每个条目的分区类型。它将每个类型与一组众所周知的操作系统文件系统进行比较。已识别分区类型的示例包括 NTFS(Windows®,ID 0x7)、ext2fs(Linux®,ID 0x83)以及当然还有ffs/ufs2(FreeBSD,ID 0xa5)。实现相当简单。

      movw $(partbl+0x4),%bx	# Partition table (+4)
      xorw %dx,%dx		# Item number

read_entry:
      movb %ch,-0x4(%bx)	# Zero active flag (ch == 0)
      btw %dx,_FLAGS(%bp)	# Entry enabled?
      jnc next_entry		# No
      movb (%bx),%al		# Load type
      test %al, %al		# skip empty partition
      jz next_entry
      movw $bootable_ids,%di	# Lookup tables
      movb $(TLEN+1),%cl	# Number of entries
      repne			# Locate
      scasb			#  type
      addw $(TLEN-1), %di	# Adjust
      movb (%di),%cl		# Partition
      addw %cx,%di		#  description
      callw putx		# Display it

next_entry:
      incw %dx			# Next item
      addb $0x10,%bl		# Next entry
      jnc read_entry		# Till done
stand/i386/boot0/boot0.S

需要注意的是,每个条目的活动标志都被清除了,因此在扫描后,在我们的 boot0 内存副本中,**没有**分区条目处于活动状态。稍后,将为选定的分区设置活动标志。这确保了如果用户选择将更改写回磁盘,则只存在一个活动分区。

下一个代码块测试其他驱动器。在启动时,BIOS 将计算机中存在的驱动器数量写入地址0x475。如果存在任何其他驱动器,boot0 会将当前驱动器打印到屏幕上。用户稍后可以命令 boot0 扫描另一个驱动器上的分区。

      popw %ax			# Drive number
      subb $0x80-0x1,%al		# Does next
      cmpb NHRDRV,%al		#  drive exist? (from BIOS?)
      jb print_drive		# Yes
      decw %ax			# Already drive 0?
      jz print_prompt		# Yes
stand/i386/boot0/boot0.S

我们假设只有一个驱动器存在,因此不会执行跳转到print_drive。我们还假设没有发生任何奇怪的事情,因此我们跳转到print_prompt

下一个代码块只是打印一个提示符,后跟默认选项。

print_prompt:
      movw $prompt,%si		# Display
      callw putstr		#  prompt
      movb _OPT(%bp),%dl	# Display
      decw %si			#  default
      callw putkey		#  key
      jmp start_input		# Skip beep
stand/i386/boot0/boot0.S

最后,执行跳转到start_input,在那里使用 BIOS 服务来启动计时器并读取来自键盘的用户输入;如果计时器超时,将选择默认选项。

start_input:
      xorb %ah,%ah		# BIOS: Get
      int $0x1a			#  system time
      movw %dx,%di		# Ticks when
      addw _TICKS(%bp),%di	#  timeout
read_key:
      movb $0x1,%ah		# BIOS: Check
      int $0x16			#  for keypress
      jnz got_key		# Have input
      xorb %ah,%ah		# BIOS: int 0x1a, 00
      int $0x1a			#  get system time
      cmpw %di,%dx		# Timeout?
      jb read_key		# No
stand/i386/boot0/boot0.S

通过寄存器%ah0x1a号中断请求和参数0来请求中断。BIOS有一套预定义的服务,应用程序通过int指令以软件生成中断的方式请求这些服务,并在寄存器中传递参数(在本例中为%ah)。在这里,我们特别请求自午夜以来的时钟滴答数;此值由BIOS通过RTC(实时时钟)计算。该时钟可以被编程为以2 Hz到8192 Hz的频率工作。BIOS在启动时将其设置为18.2 Hz。当请求得到满足时,BIOS将在寄存器%cx%dx中返回一个32位结果(低字节在%dx中)。此结果(%dx部分)被复制到寄存器%di中,并且TICKS变量的值被添加到%di中。此变量位于boot0中,相对于寄存器%bp(回想一下,它指向0x800)的偏移量为_TICKS(负值)。此变量的默认值为0xb6(十进制为182)。现在,想法是boot0不断地向BIOS请求时间,当寄存器%dx中返回的值大于存储在%di中的值时,时间到期,将进行默认选择。由于RTC每秒滴答18.2次,因此此条件将在10秒后满足(此默认行为可以在Makefile中更改)。在此时间过去之前,boot0会持续询问BIOS是否有任何用户输入;这是通过int 0x16%ah中的参数1来完成的。

无论按键按下还是时间到期,后续代码都会验证选择。根据选择,寄存器%si被设置为指向分区表中相应的分区条目。此新选择将覆盖先前的默认选择。实际上,它成为新的默认值。最后,将所选分区的ACTIVE标志设置为已启用。如果在编译时启用了它,则具有这些修改值的在内存版本boot0将被写回磁盘上的MBR。我们把这个实现的细节留给读者。

我们现在以boot0程序的最后一个代码块结束我们的研究。

      movw $LOAD,%bx		# Address for read
      movb $0x2,%ah		# Read sector
      callw intx13		#  from disk
      jc beep			# If error
      cmpw $MAGIC,0x1fe(%bx)	# Bootable?
      jne beep			# No
      pushw %si			# Save ptr to selected part.
      callw putn		# Leave some space
      popw %si			# Restore, next stage uses it
      jmp *%bx			# Invoke bootstrap
stand/i386/boot0/boot0.S

回想一下,%si指向所选分区条目。此条目告诉我们分区在磁盘上的起始位置。当然,我们假设所选分区实际上是FreeBSD切片。

从现在开始,我们将倾向于使用技术上更准确的术语“切片”而不是“分区”。

传输缓冲区设置为0x7c00(寄存器%bx),并通过调用intx13请求读取FreeBSD切片的第一个扇区。我们假设一切正常,因此不执行跳转到beep。特别是,新读取的扇区必须以魔术序列0xaa55结尾。最后,%si(指向所选分区表的指针)中的值被保留以供下一阶段使用,并且执行跳转到地址0x7c00,在那里开始执行我们的下一阶段(刚刚读取的块)。

1.5. boot1阶段

到目前为止,我们已经经历了以下序列

  • BIOS进行了一些早期的硬件初始化,包括POST。MBR(boot0)从绝对磁盘扇区1加载到地址0x7c00。执行控制权被传递到该位置。

  • boot0将其自身重新定位到链接执行的位置(0x600),然后跳转以在适当的位置继续执行。最后,boot0将FreeBSD切片的第一个磁盘扇区加载到地址0x7c00。执行控制权被传递到该位置。

boot1是引导加载序列中的下一步。它是三个引导阶段中的第一个。请注意,我们一直在专门处理磁盘扇区。实际上,BIOS加载绝对第一个扇区,而boot0加载FreeBSD切片的第一个扇区。这两个加载都指向地址0x7c00。我们可以从概念上将这些磁盘扇区视为分别包含文件boot0boot1,但实际上对于boot1来说并非完全如此。严格来说,与boot0不同,boot1不是引导块的一部分[3]。相反,一个单独的完整文件boot/boot/boot)最终被写入磁盘。此文件是boot1boot2Boot Extender(或BTX)的组合。此单个文件的大小大于单个扇区(大于512字节)。幸运的是,boot1恰好占用此单个文件的前512字节,因此当boot0加载FreeBSD切片的第一个扇区(512字节)时,它实际上是在加载boot1并将控制权转移到它。

boot1的主要任务是加载下一个引导阶段。这个下一个阶段有点复杂。它由一个名为“Boot Extender”或BTX的服务器和一个名为boot2的客户端组成。正如我们将看到的,最后一个引导阶段loader也是BTX服务器的客户端。

现在让我们详细了解boot1到底做了什么,从它的入口点开始,就像我们对boot0所做的那样。

start:
	jmp main
stand/i386/boot2/boot1.S

start处的入口点只是跳过一个特殊的数据区域到标签main,它依次如下所示

main:
      cld			# String ops inc
      xor %cx,%cx		# Zero
      mov %cx,%es		# Address
      mov %cx,%ds		#  data
      mov %cx,%ss		# Set up
      mov $start,%sp		#  stack
      mov %sp,%si		# Source
      mov $MEM_REL,%di		# Destination
      incb %ch			# Word count
      rep			# Copy
      movsw			#  code
stand/i386/boot2/boot1.S

就像boot0一样,此代码将boot1重新定位到内存地址0x700。但是,与boot0不同,它不会跳转到那里。boot1链接到地址0x7c00执行,实际上就是它最初加载到的位置。重新定位的原因将在稍后讨论。

接下来是一个查找FreeBSD切片的循环。尽管boot0从FreeBSD切片加载了boot1,但没有将有关此切片的信息传递给它[4],因此boot1必须重新扫描分区表以查找FreeBSD切片的起始位置。因此它重新读取MBR

      mov $part4,%si		# Partition
      cmpb $0x80,%dl		# Hard drive?
      jb main.4			# No
      movb $0x1,%dh		# Block count
      callw nread		# Read MBR
stand/i386/boot2/boot1.S

在上面的代码中,寄存器%dl维护有关引导设备的信息。这是由BIOS传递并由MBR保留的。数字0x80及以上告诉我们我们正在处理硬盘,因此调用nread,其中读取MBR。nread的参数通过%si%dh传递。标签part4处的内存地址被复制到%si。此内存地址保存一个“伪分区”,供nread使用。以下是伪分区中的数据

      part4:
	.byte 0x80, 0x00, 0x01, 0x00
	.byte 0xa5, 0xfe, 0xff, 0xff
	.byte 0x00, 0x00, 0x00, 0x00
	.byte 0x50, 0xc3, 0x00, 0x00
stand/i386/boot2/boot1.S

特别是,此伪分区的LBA被硬编码为零。这被用作BIOS读取硬盘上的绝对扇区1的参数。或者,可以使用CHS寻址。在这种情况下,伪分区包含柱面0、磁头0和扇区1,这等效于绝对扇区1。

现在让我们继续查看nread

nread:
      mov $MEM_BUF,%bx		# Transfer buffer
      mov 0x8(%si),%ax		# Get
      mov 0xa(%si),%cx		#  LBA
      push %cs			# Read from
      callw xread.1		#  disk
      jnc return		# If success, return
stand/i386/boot2/boot1.S

回想一下,%si指向伪分区。偏移量0x8处的字[5]被复制到寄存器%ax,偏移量0xa处的字被复制到%cx。它们被BIOS解释为表示要读取的LBA的低4字节值(假设高4字节为零)。寄存器%bx保存MBR将加载到的内存地址。将%cs压入堆栈的指令非常有趣。在这种情况下,它什么也没做。但是,正如我们将很快看到的,boot2与BTX服务器结合使用xread.1。此机制将在下一节中讨论。

xread.1处的代码进一步调用read函数,该函数实际上调用BIOS请求磁盘扇区

xread.1:
	pushl $0x0		#  absolute
	push %cx		#  block
	push %ax		#  number
	push %es		# Address of
	push %bx		#  transfer buffer
	xor %ax,%ax		# Number of
	movb %dh,%al		#  blocks to
	push %ax		#  transfer
	push $0x10		# Size of packet
	mov %sp,%bp		# Packet pointer
	callw read		# Read from disk
	lea 0x10(%bp),%sp	# Clear stack
	lret			# To far caller
stand/i386/boot2/boot1.S

请注意此块末尾的长返回指令。此指令弹出nread压入的%cs寄存器,并返回。最后,nread也返回。

将MBR加载到内存后,开始搜索FreeBSD切片的实际循环

	mov $0x1,%cx		 # Two passes
main.1:
	mov $MEM_BUF+PRT_OFF,%si # Partition table
	movb $0x1,%dh		 # Partition
main.2:
	cmpb $PRT_BSD,0x4(%si)	 # Our partition type?
	jne main.3		 # No
	jcxz main.5		 # If second pass
	testb $0x80,(%si)	 # Active?
	jnz main.5		 # Yes
main.3:
	add $0x10,%si		 # Next entry
	incb %dh		 # Partition
	cmpb $0x1+PRT_NUM,%dh		 # In table?
	jb main.2		 # Yes
	dec %cx			 # Do two
	jcxz main.1		 #  passes
stand/i386/boot2/boot1.S

如果识别出FreeBSD切片,则执行将在main.5处继续。请注意,当找到FreeBSD切片时,%si指向分区表中的相应条目,而%dh保存分区号。我们假设找到了FreeBSD切片,因此我们在main.5处继续执行

main.5:
	mov %dx,MEM_ARG			   # Save args
	movb $NSECT,%dh			   # Sector count
	callw nread			   # Read disk
	mov $MEM_BTX,%bx			   # BTX
	mov 0xa(%bx),%si		   # Get BTX length and set
	add %bx,%si			   #  %si to start of boot2.bin
	mov $MEM_USR+SIZ_PAG*2,%di			   # Client page 2
	mov $MEM_BTX+(NSECT-1)*SIZ_SEC,%cx			   # Byte
	sub %si,%cx			   #  count
	rep				   # Relocate
	movsb				   #  client
stand/i386/boot2/boot1.S

回想一下,此时,寄存器%si指向MBR分区表中的FreeBSD切片条目,因此对nread的调用将有效地读取此分区开头的扇区。传递给寄存器%dh的参数告诉nread读取16个磁盘扇区。回想一下,FreeBSD切片的前512字节或第一个扇区与boot1程序一致。还要回想一下,写入FreeBSD切片开头的文件不是/boot/boot1,而是/boot/boot。让我们看看这些文件在文件系统中的大小

-r--r--r--  1 root  wheel   512B Jan  8 00:15 /boot/boot0
-r--r--r--  1 root  wheel   512B Jan  8 00:15 /boot/boot1
-r--r--r--  1 root  wheel   7.5K Jan  8 00:15 /boot/boot2
-r--r--r--  1 root  wheel   8.0K Jan  8 00:15 /boot/boot

boot0boot1 都是 512 字节,因此它们正好可以放入一个磁盘扇区。 boot2 大得多,包含 BTX 服务器和 boot2 客户端。最后,一个名为 boot 的文件比 boot2 大 512 字节。此文件是 boot1boot2 的连接。如前所述,boot0 是写入绝对第一个磁盘扇区(MBR)的文件,而 boot 是写入 FreeBSD 分区第一个扇区的文件;boot1boot2 **不会**写入磁盘。用于将 boot1boot2 连接成单个 boot 的命令仅仅是 cat boot1 boot2 > boot

因此 boot1 占据 boot 的前 512 字节,并且由于 boot 被写入 FreeBSD 分区的第一个扇区,因此 boot1 正好适合此第一个扇区。当 nread 读取 FreeBSD 分区的前 16 个扇区时,它实际上读取了整个 boot 文件[6]。我们将在下一节中详细了解 boot 如何由 boot1boot2 组成。

回想一下,nread 使用内存地址 0x8c00 作为传输缓冲区来保存读取的扇区。此地址的选择非常方便。实际上,由于 boot1 属于前 512 字节,因此它最终位于地址范围 0x8c00-0x8dff 内。接下来的 512 字节(范围 0x8e00-0x8fff)用于存储bsdlabel[7]

从地址 0x9000 开始是 BTX 服务器的开头,紧随其后的是 boot2 客户端。BTX 服务器充当内核,并在最高特权级别以保护模式执行。相反,BTX 客户端(例如 boot2)以用户模式执行。我们将在下一节中了解如何实现这一点。nread 调用后的代码在内存缓冲区中找到 boot2 的开头,并将其复制到内存地址 0xc000。这是因为 BTX 服务器安排 boot2 在从 0xa000 开始的段中执行。我们在下一节中详细探讨这一点。

boot1 的最后一个代码块启用对 1MB 以上内存的访问[8],并以跳转到 BTX 服务器的起始点结束。

seta20:
	cli			# Disable interrupts
seta20.1:
	dec %cx			# Timeout?
	jz seta20.3		# Yes

	inb $0x64,%al		# Get status
	testb $0x2,%al		# Busy?
	jnz seta20.1		# Yes
	movb $0xd1,%al		# Command: Write
	outb %al,$0x64		#  output port
seta20.2:
	inb $0x64,%al		# Get status
	testb $0x2,%al		# Busy?
	jnz seta20.2		# Yes
	movb $0xdf,%al		# Enable
	outb %al,$0x60		#  A20
seta20.3:
	sti			# Enable interrupts
	jmp 0x9010		# Start BTX
stand/i386/boot2/boot1.S

请注意,在跳转之前,已启用中断。

1.6. BTX 服务器

接下来,我们的引导序列是 BTX 服务器。让我们快速回顾一下我们是如何走到这一步的。

  • BIOS 将绝对扇区一(MBR 或 boot0)加载到地址 0x7c00 并跳转到那里。

  • boot0 将自身重新定位到 0x600(其链接到的执行地址),并跳转到那里。然后它将 FreeBSD 分区的第一个扇区(包含 boot1)读入地址 0x7c00 并跳转到那里。

  • boot1 将 FreeBSD 分区的前 16 个扇区加载到地址 0x8c00。这 16 个扇区或 8192 字节是整个文件 boot。该文件是 boot1boot2 的连接。 boot2 又包含 BTX 服务器和 boot2 客户端。最后,跳转到地址 0x9010,即 BTX 服务器的入口点。

在详细研究 BTX 服务器之前,让我们进一步回顾一下如何创建单个、一体化的 boot 文件。 boot 的构建方式在其 Makefilestand/i386/boot2/Makefile)中定义。让我们看看创建 boot 文件的规则。

      boot: boot1 boot2
	cat boot1 boot2 > boot
stand/i386/boot2/Makefile

这告诉我们,需要 boot1boot2,并且该规则只是将它们连接起来以生成一个名为 boot 的单个文件。创建 boot1 的规则也很简单。

      boot1: boot1.out
	${OBJCOPY} -S -O binary boot1.out ${.TARGET}

      boot1.out: boot1.o
	${LD} ${LD_FLAGS} -e start --defsym ORG=${ORG1} -T ${LDSCRIPT} -o ${.TARGET} boot1.o
stand/i386/boot2/Makefile

要应用创建 boot1 的规则,必须解析 boot1.out。这又取决于 boot1.o 的存在。此最后一个文件只是我们熟悉的 boot1.S 汇编的结果,无需链接。现在,应用创建 boot1.out 的规则。这告诉我们,应将 boot1.ostart 作为其入口点链接,并从地址 0x7c00 开始。最后,通过应用相应的规则,从 boot1.out 创建 boot1。此规则是应用于 boot1.outobjcopy 命令。请注意传递给 objcopy 的标志:-S 告诉它剥离所有重定位和符号信息;-O binary 指示输出格式,即简单的、未格式化的二进制文件。

有了 boot1,让我们看看 boot2 是如何构建的。

      boot2: boot2.ld
	@set -- `ls -l ${.ALLSRC}`; x=$$((${BOOT2SIZE}-$$5)); \
	    echo "$$x bytes available"; test $$x -ge 0
	${DD} if=${.ALLSRC} of=${.TARGET} bs=${BOOT2SIZE} conv=sync

      boot2.ld: boot2.ldr boot2.bin ${BTXKERN}
	btxld -v -E ${ORG2} -f bin -b ${BTXKERN} -l boot2.ldr \
	    -o ${.TARGET} -P 1 boot2.bin

      boot2.ldr:
	${DD} if=/dev/zero of=${.TARGET} bs=512 count=1

      boot2.bin: boot2.out
	${OBJCOPY} -S -O binary boot2.out ${.TARGET}

      boot2.out: ${BTXCRT} boot2.o sio.o ashldi3.o
	${LD} ${LD_FLAGS} --defsym ORG=${ORG2} -T ${LDSCRIPT} -o ${.TARGET} ${.ALLSRC}

      boot2.h: boot1.out
	${NM} -t d ${.ALLSRC} | awk '/([0-9])+ T xread/ \
	    { x = $$1 - ORG1; \
	    printf("#define XREADORG %#x\n", REL1 + x) }' \
	    ORG1=`printf "%d" ${ORG1}` \
	    REL1=`printf "%d" ${REL1}` > ${.TARGET}
stand/i386/boot2/Makefile

构建 boot2 的机制要复杂得多。让我们指出最相关的事实。依赖项列表如下所示。

      boot2: boot2.ld
      boot2.ld: boot2.ldr boot2.bin ${BTXDIR}
      boot2.bin: boot2.out
      boot2.out: ${BTXDIR} boot2.o sio.o ashldi3.o
      boot2.h: boot1.out
stand/i386/boot2/Makefile

请注意,最初没有头文件 boot2.h,但其创建取决于 boot1.out,我们已经有了它。其创建规则有点简洁,但重要的是输出 boot2.h 类似于以下内容。

#define XREADORG 0x725
stand/i386/boot2/boot2.h

回想一下,boot1 已重新定位(即从 0x7c00 复制到 0x700)。此重新定位现在将变得有意义,因为正如我们将看到的,BTX 服务器会回收一些内存,包括 boot1 最初加载的空间。但是,BTX 服务器需要访问 boot1xread 函数;根据 boot2.h 的输出,此函数位于 0x725 位置。实际上,BTX 服务器使用 boot1 重新定位代码中的 xread 函数。现在可以从 boot2 客户端访问此函数。

下一条规则指示链接器链接各种文件(ashldi3.oboot2.osio.o)。请注意,输出文件 boot2.out 链接到地址 0x2000 (${ORG2}) 以执行。回想一下,boot2 将在用户模式下执行,在 BTX 服务器设置的特殊用户段内。此段从 0xa000 开始。此外,请记住,bootboot2 部分已复制到地址 0xc000,即用户段开始处的偏移量 0x2000,因此当我们向其传递控制权时,boot2 将正常工作。接下来,通过剥离其符号和格式信息,从 boot2.out 创建 boot2.bin;boot2.bin 是一个**原始**二进制文件。现在,请注意,创建了一个文件 boot2.ldr,它是一个 512 字节的文件,内容全部为零。此空间保留用于 bsdlabel。

现在我们有了 boot1boot2.binboot2.ldr 文件,在创建一体化的 boot 文件之前,只剩下 BTX 服务器了。BTX 服务器位于 stand/i386/btx/btx 中;它有自己的 Makefile 及其自己的构建规则集。需要注意的重要一点是,它也编译为**原始**二进制文件,并且链接到地址 0x9000 以执行。详细信息可以在 stand/i386/btx/btx/Makefile 中找到。

有了构成 boot 程序的文件,最后一步是将它们**合并**。这是通过一个名为 btxld 的特殊程序完成的(源代码位于 /usr/src/usr.sbin/btxld 中)。此程序的一些参数包括输出文件名(boot)、其入口点(0x2000)及其文件格式(原始二进制文件)。最后,此实用程序将各个文件合并到 boot 文件中,该文件由 boot1boot2bsdlabel 和 BTX 服务器组成。此文件正好占用 16 个扇区或 8192 字节,在安装过程中实际上写入 FreeBSD 分区的开头。现在让我们继续研究 BTX 服务器程序。

BTX 服务器准备一个简单的环境,并在将控制权传递给客户端之前,从 16 位实模式切换到 32 位保护模式。这包括初始化和更新以下数据结构。

  • 修改中断向量表 (IVT)。IVT 为实模式代码提供异常和中断处理程序。

  • 创建中断描述符表 (IDT)。为处理器异常、硬件中断、两个系统调用和 V86 接口提供条目。IDT 为保护模式代码提供异常和中断处理程序。

  • 创建任务状态段 (TSS)。这是必要的,因为处理器在执行客户端(boot2)时以**最低**特权级别工作,但在执行 BTX 服务器时以**最高**特权级别工作。

  • 设置 GDT(全局描述符表)。为监管程序代码和数据、用户代码和数据以及实模式代码和数据提供条目(描述符)。[9]

现在让我们开始研究实际的实现。回想一下,boot1 跳转到地址 0x9010,即 BTX 服务器的入口点。在研究程序在那里执行之前,请注意 BTX 服务器在地址范围 0x9000-0x900f 处有一个特殊的头部,就在其入口点之前。此头部定义如下。

start:						# Start of code
/*
 * BTX header.
 */
btx_hdr:	.byte 0xeb			# Machine ID
		.byte 0xe			# Header size
		.ascii "BTX"			# Magic
		.byte 0x1			# Major version
		.byte 0x2			# Minor version
		.byte BTX_FLAGS			# Flags
		.word PAG_CNT-MEM_ORG>>0xc	# Paging control
		.word break-start		# Text size
		.long 0x0			# Entry address
stand/i386/btx/btx/btx.S

请注意,前两个字节是 0xeb0xe。在 IA-32 架构中,这两个字节被解释为相对于头部的跳转到入口点,因此理论上,boot1 可以跳转到这里(地址 0x9000)而不是地址 0x9010。请注意,BTX 头部的最后一个字段是指向客户端(boot2)入口点 b2 的指针。此字段在链接时修补。

紧随头部之后是 BTX 服务器的入口点。

/*
 * Initialization routine.
 */
init:		cli				# Disable interrupts
		xor %ax,%ax			# Zero/segment
		mov %ax,%ss			# Set up
		mov $MEM_ESP0,%sp		#  stack
		mov %ax,%es			# Address
		mov %ax,%ds			#  data
		pushl $0x2			# Clear
		popfl				#  flags
stand/i386/btx/btx/btx.S

这段代码禁用中断,设置一个工作栈(起始地址为0x1800)并清除EFLAGS寄存器中的标志位。注意,popfl指令从栈中弹出双字(4字节)并将其放入EFLAGS寄存器。由于实际弹出的值为2,因此EFLAGS寄存器实际上被清零了(IA-32要求EFLAGS寄存器的第2位始终为1)。

我们的下一段代码块将内存范围0x5e00-0x8fff清零。此范围是各种数据结构将被创建的地方。

/*
 * Initialize memory.
 */
		mov $MEM_IDT,%di		# Memory to initialize
		mov $(MEM_ORG-MEM_IDT)/2,%cx	# Words to zero
		rep				# Zero-fill
		stosw				#  memory
stand/i386/btx/btx/btx.S

回想一下,boot1最初加载到地址0x7c00,因此,通过此内存初始化,该副本实际上消失了。但是,也请回想一下,boot1已重定位到0x700,因此副本仍然保留在内存中,BTX服务器将使用它。

接下来,更新实模式IVT(中断向量表)。IVT是异常和中断处理程序的段/偏移对数组。BIOS通常将硬件中断映射到中断向量0x80xf0x700x77,但如将看到的,8259A可编程中断控制器(控制硬件中断到中断向量的实际映射的芯片)被编程为将这些中断向量从0x8-0xf重新映射到0x20-0x27,以及从0x70-0x77重新映射到0x28-0x2f。因此,为中断向量0x20-0x2f提供了中断处理程序。BIOS提供的处理程序未直接使用的原因是它们在16位实模式下工作,而不是在32位保护模式下工作。处理器模式将很快切换到32位保护模式。但是,BTX服务器设置了一种机制来有效地使用BIOS提供的处理程序。

/*
 * Update real mode IDT for reflecting hardware interrupts.
 */
		mov $intr20,%bx			# Address first handler
		mov $0x10,%cx			# Number of handlers
		mov $0x20*4,%di			# First real mode IDT entry
init.0:		mov %bx,(%di)			# Store IP
		inc %di				# Address next
		inc %di				#  entry
		stosw				# Store CS
		add $4,%bx			# Next handler
		loop init.0			# Next IRQ
stand/i386/btx/btx/btx.S

下一个代码块创建IDT(中断描述符表)。在保护模式下,IDT类似于实模式下的IVT。也就是说,IDT描述了处理器在保护模式下执行时使用的各种异常和中断处理程序。从本质上讲,它也包含一个段/偏移对数组,尽管结构稍微复杂一些,因为保护模式下的段与实模式下的段不同,并且应用了各种保护机制。

/*
 * Create IDT.
 */
		mov $MEM_IDT,%di		# IDT's address
		mov $idtctl,%si			# Control string
init.1:		lodsb				# Get entry
		cbw				#  count
		xchg %ax,%cx			#  as word
		jcxz init.4			# If done
		lodsb				# Get segment
		xchg %ax,%dx			#  P:DPL:type
		lodsw				# Get control
		xchg %ax,%bx			#  set
		lodsw				# Get handler offset
		mov $SEL_SCODE,%dh		# Segment selector
init.2:		shr %bx				# Handle this int?
		jnc init.3			# No
		mov %ax,(%di)			# Set handler offset
		mov %dh,0x2(%di)		#  and selector
		mov %dl,0x5(%di)		# Set P:DPL:type
		add $0x4,%ax			# Next handler
init.3:		lea 0x8(%di),%di		# Next entry
		loop init.2			# Till set done
		jmp init.1			# Continue
stand/i386/btx/btx/btx.S

IDT中的每个条目长8字节。除了段/偏移信息外,它们还描述了段类型、特权级别以及段是否驻留在内存中。构造方式使得中断向量0到0xf(异常)由函数intx00处理;向量0x10(也是异常)由intx10处理;硬件中断(稍后配置为从中断向量0x20一直到中断向量0x2f)由函数intx20处理。最后,用于系统调用的中断向量0x30由intx30处理,向量0x31和0x32由intx31处理。必须注意,只有中断向量0x30、0x31和0x32的描述符被赋予了特权级别3,与boot2客户端相同,这意味着客户端可以通过int指令执行软件生成的中断到这些向量而不会失败(这是boot2使用BTX服务器提供的服务的方式)。另外,请注意,只有软件生成的中断受到较低特权级别代码执行的保护。硬件生成的中断和处理器生成的异常始终得到充分处理,无论涉及的实际特权如何。

下一步是初始化TSS(任务状态段)。TSS是硬件功能,可帮助操作系统或执行软件通过进程抽象实现多任务功能。IA-32架构要求至少创建一个TSS并使用它,如果使用多任务功能或定义了不同的特权级别。由于boot2客户端在特权级别3下执行,而BTX服务器在特权级别0下运行,因此必须定义TSS。

/*
 * Initialize TSS.
 */
init.4:		movb $_ESP0H,TSS_ESP0+1(%di)	# Set ESP0
		movb $SEL_SDATA,TSS_SS0(%di)	# Set SS0
		movb $_TSSIO,TSS_MAP(%di)	# Set I/O bit map base
stand/i386/btx/btx/btx.S

请注意,在TSS中为特权级别0的栈指针和栈段提供了值。这是必要的,因为如果在特权级别3下执行boot2时接收到中断或异常,处理器会自动更改为特权级别0,因此需要一个新的工作栈。最后,TSS的I/O映射基地址字段被赋予了一个值,它是从TSS开头到I/O权限位图和中断重定向位图的16位偏移量。

创建IDT和TSS后,处理器就可以切换到保护模式了。这在下一段代码块中完成。

/*
 * Bring up the system.
 */
		mov $0x2820,%bx			# Set protected mode
		callw setpic			#  IRQ offsets
		lidt idtdesc			# Set IDT
		lgdt gdtdesc			# Set GDT
		mov %cr0,%eax			# Switch to protected
		inc %ax				#  mode
		mov %eax,%cr0			#
		ljmp $SEL_SCODE,$init.8		# To 32-bit code
		.code32
init.8:		xorl %ecx,%ecx			# Zero
		movb $SEL_SDATA,%cl		# To 32-bit
		movw %cx,%ss			#  stack
stand/i386/btx/btx/btx.S

首先,调用setpic来编程8259A PIC(可编程中断控制器)。该芯片连接到多个硬件中断源。从设备接收中断后,它会向处理器发出适当的中断向量信号。这可以自定义,以便将特定中断与特定中断向量关联,如前所述。接下来,使用指令lidtlgdt分别加载IDTR(中断描述符表寄存器)和GDTR(全局描述符表寄存器)。这些寄存器加载IDT和GDT的基地址和限制地址。以下三条指令设置%cr0寄存器的保护使能(PE)位。这有效地将处理器切换到32位保护模式。接下来,使用段选择符SEL_SCODE长跳转到init.8,该选择符选择超级用户代码段。跳转后,处理器有效地在CPL 0(最高特权级别)下执行。最后,通过将段选择符SEL_SDATA分配给%ss寄存器来为栈选择超级用户数据段。此数据段的特权级别也为0

我们的最后一个代码块负责将TR(任务寄存器)加载到我们之前创建的TSS的段选择符,并在将执行控制权传递给boot2客户端之前设置用户模式环境。

/*
 * Launch user task.
 */
		movb $SEL_TSS,%cl		# Set task
		ltr %cx				#  register
		movl $MEM_USR,%edx		# User base address
		movzwl %ss:BDA_MEM,%eax		# Get free memory
		shll $0xa,%eax			# To bytes
		subl $ARGSPACE,%eax		# Less arg space
		subl %edx,%eax			# Less base
		movb $SEL_UDATA,%cl		# User data selector
		pushl %ecx			# Set SS
		pushl %eax			# Set ESP
		push $0x202			# Set flags (IF set)
		push $SEL_UCODE			# Set CS
		pushl btx_hdr+0xc		# Set EIP
		pushl %ecx			# Set GS
		pushl %ecx			# Set FS
		pushl %ecx			# Set DS
		pushl %ecx			# Set ES
		pushl %edx			# Set EAX
		movb $0x7,%cl			# Set remaining
init.9:		push $0x0			#  general
		loop init.9			#  registers
#ifdef BTX_SERIAL
		call sio_init			# setup the serial console
#endif
		popa				#  and initialize
		popl %es			# Initialize
		popl %ds			#  user
		popl %fs			#  segment
		popl %gs			#  registers
		iret				# To user mode
stand/i386/btx/btx/btx.S

请注意,客户端的环境包括栈段选择符和栈指针(寄存器%ss%esp)。实际上,一旦TR加载了适当的栈段选择符(指令ltr),就会计算栈指针并将其与栈的段选择符一起压入栈中。接下来,将值0x202压入栈中;这是控制权传递给客户端时EFLAGS将获得的值。此外,还压入了用户模式代码段选择符和客户端的入口点。回想一下,此入口点在链接时在BTX头文件中进行了修补。最后,段选择符(存储在寄存器%ecx中)用于段寄存器%gs, %fs, %ds和%es,以及%edx中的值(0xa000)被压入栈中。请记住压入栈中的各种值(它们很快就会被弹出)。接下来,其余通用寄存器的值也被压入栈中(注意将值0压入7次的loop)。现在,将开始从栈中弹出值。首先,popa指令从栈中弹出最后压入的7个值。它们按顺序存储在通用寄存器中%edi, %esi, %ebp, %ebx, %edx, %ecx, %eax。然后,将压入的各种段选择符弹出到各个段寄存器中。栈中仍然有5个值。在执行iret指令时,它们会被弹出。此指令首先弹出从BTX头文件中压入的值。此值是指向boot2入口点的指针。它被放置在指令指针寄存器%eip中。接下来,弹出用户代码段的段选择符并将其复制到寄存器%cs中。请记住,此段的特权级别为3,即最低特权级别。这意味着我们必须为该特权级别的栈提供值。这就是为什么处理器除了进一步弹出EFLAGS寄存器的值外,还会再从栈中弹出两个值。这些值进入栈指针(%esp)和栈段(%ss)。现在,执行从boot0的入口点继续。

重要的是要注意用户代码段是如何定义的。此段的基地址设置为0xa000。这意味着代码内存地址是相对于地址0xa000的;如果正在执行的代码是从地址0x2000获取的,则实际寻址的内存为0xa000+0x2000=0xc000

1.7. boot2 阶段

boot2定义了一个重要的结构struct bootinfo。此结构由boot2初始化并传递给加载程序,然后进一步传递给内核。此结构的一些节点由boot2设置,其余节点由加载程序设置。此结构除了其他信息外,还包含内核文件名、BIOS硬盘几何形状、引导设备的BIOS驱动器号、可用物理内存、envp指针等。其定义为

/usr/include/machine/bootinfo.h:
struct bootinfo {
	u_int32_t	bi_version;
	u_int32_t	bi_kernelname;		/* represents a char * */
	u_int32_t	bi_nfs_diskless;	/* struct nfs_diskless * */
				/* End of fields that are always present. */
#define	bi_endcommon	bi_n_bios_used
	u_int32_t	bi_n_bios_used;
	u_int32_t	bi_bios_geom[N_BIOS_GEOM];
	u_int32_t	bi_size;
	u_int8_t	bi_memsizes_valid;
	u_int8_t	bi_bios_dev;		/* bootdev BIOS unit number */
	u_int8_t	bi_pad[2];
	u_int32_t	bi_basemem;
	u_int32_t	bi_extmem;
	u_int32_t	bi_symtab;		/* struct symtab * */
	u_int32_t	bi_esymtab;		/* struct symtab * */
				/* Items below only from advanced bootloader */
	u_int32_t	bi_kernend;		/* end of kernel space */
	u_int32_t	bi_envp;		/* environment */
	u_int32_t	bi_modulep;		/* preloaded modules */
};

boot2进入无限循环等待用户输入,然后调用load()。如果用户没有按下任何键,则循环会因超时而中断,因此load()将加载默认文件(/boot/loader)。函数ino_t lookup(char *filename)int xfsread(ino_t inode, void *buf, size_t nbyte)用于将文件内容读取到内存中。/boot/loader是ELF二进制文件,但在ELF头文件之前添加了a.outstruct exec结构。load()扫描加载程序的ELF头文件,将/boot/loader的内容加载到内存中,并将执行权传递给加载程序的入口点。

stand/i386/boot2/boot2.c:
    __exec((caddr_t)addr, RB_BOOTINFO | (opts & RBX_MASK),
	   MAKEBOOTDEV(dev_maj[dsk.type], dsk.slice, dsk.unit, dsk.part),
	   0, 0, 0, VTOP(&bootinfo));

1.8. 加载程序阶段

加载程序也是BTX客户端。我不会在这里详细描述它,Mike Smith撰写了一份全面的手册页,loader(8)。上面讨论了底层机制和BTX。

加载程序的主要任务是引导内核。当内核加载到内存中时,加载程序会调用它。

stand/common/boot.c:
    /* Call the exec handler from the loader matching the kernel */
    file_formats[fp->f_loader]->l_exec(fp);

1.9. 内核初始化

让我们看一下链接内核的命令。这将有助于确定加载程序将执行权传递给内核的确切位置。此位置是内核的实际入口点。此命令现在已从sys/conf/Makefile.i386中排除。我们感兴趣的内容可以在/usr/obj/usr/src/i386.i386/sys/GENERIC/中找到。

/usr/obj/usr/src/i386.i386/sys/GENERIC/kernel.meta:
ld -m elf_i386_fbsd -Bdynamic -T /usr/src/sys/conf/ldscript.i386 --build-id=sha1 --no-warn-mismatch \
--warn-common --export-dynamic  --dynamic-linker /red/herring -X -o kernel locore.o
<lots of kernel .o files>

这里可以看到一些有趣的事情。首先,内核是ELF动态链接二进制文件,但内核的动态链接器为/red/herring,这绝对是一个伪造的文件。其次,查看文件sys/conf/ldscript.i386可以了解编译内核时使用了哪些ld选项。阅读前几行,字符串

sys/conf/ldscript.i386:
ENTRY(btext)

表示内核的入口点是符号btext。此符号在locore.s中定义。

sys/i386/i386/locore.s:
	.text
/**********************************************************************
 *
 * This is where the bootblocks start us, set the ball rolling...
 *
 */
NON_GPROF_ENTRY(btext)

首先,将EFLAGS寄存器设置为预定义值0x00000002。然后初始化所有段寄存器。

sys/i386/i386/locore.s:
/* Don't trust what the BIOS gives for eflags. */
	pushl	$PSL_KERNEL
	popfl

/*
 * Don't trust what the BIOS gives for %fs and %gs.  Trust the bootstrap
 * to set %cs, %ds, %es and %ss.
 */
	mov	%ds, %ax
	mov	%ax, %fs
	mov	%ax, %gs

btext调用例程recover_bootinfo()identify_cpu(),它们也在locore.s中定义。以下是它们的功能描述。

recover_bootinfo

此例程解析从引导程序传递给内核的参数。内核可以通过三种方式引导:通过上面描述的加载程序、通过旧的磁盘引导块或通过旧的无盘引导过程。此函数确定引导方法,并将struct bootinfo结构存储到内核内存中。

identify_cpu

此函数尝试找出它正在运行的CPU是什么,并将找到的值存储在变量_cpu中。

接下来的步骤是启用VME(如果CPU支持)。

sys/i386/i386/mpboot.s:
	testl	$CPUID_VME,%edx
	jz	3f
	orl	$CR4_VME,%eax
3:	movl	%eax,%cr4

然后,启用分页。

sys/i386/i386/mpboot.s:
/* Now enable paging */
	movl	IdlePTD_nopae, %eax
	movl	%eax,%cr3			/* load ptd addr into mmu */
	movl	%cr0,%eax			/* get control word */
	orl	$CR0_PE|CR0_PG,%eax		/* enable paging */
	movl	%eax,%cr0			/* and let's page NOW! */

接下来的三行代码是因为设置了分页,所以需要跳转才能在虚拟地址空间中继续执行。

sys/i386/i386/mpboot.s:
	pushl	$mp_begin				/* jump to high mem */
	ret

/* now running relocated at KERNBASE where the system is linked to run */
mp_begin:	/* now running relocated at KERNBASE */

函数init386()被调用,传入第一个空闲物理页面的指针,之后调用mi_startup()init386是一个与架构相关的初始化函数,而mi_startup()则是一个与架构无关的函数('mi_'前缀表示机器无关)。内核永远不会从mi_startup()返回,通过调用它,内核完成了引导过程。

sys/i386/i386/locore.s:
	pushl	physfree			/* value of first for init386(first) */
	call	init386				/* wire 386 chip for unix operation */
	addl	$4,%esp
	movl	%eax,%esp			/* Switch to true top of stack. */
	call	mi_startup			/* autoconfiguration, mountroot etc */
	/* NOTREACHED */

1.9.1. init386()

init386()定义在sys/i386/i386/machdep.c中,执行特定于i386芯片的底层初始化。加载程序执行了切换到保护模式的操作。加载程序创建了第一个任务,内核将继续在该任务中运行。在查看代码之前,请考虑处理器必须完成哪些任务才能初始化保护模式执行。

  • 初始化从引导程序传递的内核可调参数。

  • 准备GDT。

  • 准备IDT。

  • 初始化系统控制台。

  • 初始化DDB,如果它被编译到内核中。

  • 初始化TSS。

  • 准备LDT。

  • 设置线程0的pcb。

init386()通过设置环境指针(envp)并调用init_param1()来初始化从引导程序传递的可调参数。envp指针已从加载程序的bootinfo结构中传递过来。

sys/i386/i386/machdep.c:
	/* Init basic tunables, hz etc */
	init_param1();

init_param1()定义在sys/kern/subr_param.c中。该文件包含一些sysctl,以及两个函数init_param1()init_param2(),它们由init386()调用。

sys/kern/subr_param.c:
	hz = -1;
	TUNABLE_INT_FETCH("kern.hz", &hz);
	if (hz == -1)
		hz = vm_guest > VM_GUEST_NO ? HZ_VM : HZ;

TUNABLE_<typename>_FETCH用于从环境中获取值。

/usr/src/sys/sys/kernel.h:
#define	TUNABLE_INT_FETCH(path, var)	getenv_int((path), (var))

Sysctl kern.hz是系统时钟滴答。此外,这些sysctl由init_param1()设置:kern.maxswzone, kern.maxbcache, kern.maxtsiz, kern.dfldsiz, kern.maxdsiz, kern.dflssiz, kern.maxssiz, kern.sgrowsiz

然后init386()准备全局描述符表(GDT)。x86上的每个任务都在其自己的虚拟地址空间中运行,并且该空间由段:偏移量对寻址。例如,处理器要执行的当前指令位于CS:EIP处,则该指令的线性虚拟地址将为“代码段CS的虚拟地址”+ EIP。为方便起见,段从虚拟地址0开始,到4GB边界结束。因此,此示例中指令的线性虚拟地址仅为EIP的值。段寄存器(如CS、DS等)是选择器,即GDT中的索引(更准确地说,索引本身不是选择器,而是选择器的INDEX字段)。FreeBSD的GDT为每个CPU保存15个选择器的描述符。

sys/i386/i386/machdep.c:
union descriptor gdt0[NGDT];	/* initial global descriptor table */
union descriptor *gdt = gdt0;	/* global descriptor table */

sys/x86/include/segments.h:
/*
 * Entries in the Global Descriptor Table (GDT)
 */
#define	GNULL_SEL	0	/* Null Descriptor */
#define	GPRIV_SEL	1	/* SMP Per-Processor Private Data */
#define	GUFS_SEL	2	/* User %fs Descriptor (order critical: 1) */
#define	GUGS_SEL	3	/* User %gs Descriptor (order critical: 2) */
#define	GCODE_SEL	4	/* Kernel Code Descriptor (order critical: 1) */
#define	GDATA_SEL	5	/* Kernel Data Descriptor (order critical: 2) */
#define	GUCODE_SEL	6	/* User Code Descriptor (order critical: 3) */
#define	GUDATA_SEL	7	/* User Data Descriptor (order critical: 4) */
#define	GBIOSLOWMEM_SEL	8	/* BIOS low memory access (must be entry 8) */
#define	GPROC0_SEL	9	/* Task state process slot zero and up */
#define	GLDT_SEL	10	/* Default User LDT */
#define	GUSERLDT_SEL	11	/* User LDT */
#define	GPANIC_SEL	12	/* Task state to consider panic from */
#define	GBIOSCODE32_SEL	13	/* BIOS interface (32bit Code) */
#define	GBIOSCODE16_SEL	14	/* BIOS interface (16bit Code) */
#define	GBIOSDATA_SEL	15	/* BIOS interface (Data) */
#define	GBIOSUTIL_SEL	16	/* BIOS interface (Utility) */
#define	GBIOSARGS_SEL	17	/* BIOS interface (Arguments) */
#define	GNDIS_SEL	18	/* For the NDIS layer */
#define	NGDT		19

请注意,这些#define本身不是选择器,而只是选择器的INDEX字段,因此它们正是GDT的索引。例如,内核代码的实际选择器(GCODE_SEL)的值为0x20。

下一步是初始化中断描述符表(IDT)。当发生软件或硬件中断时,处理器会引用此表。例如,要进行系统调用,用户应用程序会发出INT 0x80指令。这是一个软件中断,因此处理器的硬件会在IDT中查找索引为0x80的记录。此记录指向处理此中断的例程,在本例中,将是内核的系统调用门。IDT最多可以有256(0x100)个记录。内核为IDT分配NIDT个记录,其中NIDT为最大值(256)。

sys/i386/i386/machdep.c:
static struct gate_descriptor idt0[NIDT];
struct gate_descriptor *idt = &idt0[0];	/* interrupt descriptor table */

为每个中断设置相应的处理程序。还设置了INT 0x80的系统调用门。

sys/i386/i386/machdep.c:
	setidt(IDT_SYSCALL, &IDTVEC(int0x80_syscall),
			SDT_SYS386IGT, SEL_UPL, GSEL(GCODE_SEL, SEL_KPL));

因此,当用户空间应用程序发出INT 0x80指令时,控制权将转移到函数_Xint0x80_syscall,该函数位于内核代码段中,并将以超级用户权限执行。

然后初始化控制台和DDB。

sys/i386/i386/machdep.c:
	cninit();
/* skipped */
  kdb_init();
#ifdef KDB
	if (boothowto & RB_KDB)
		kdb_enter(KDB_WHY_BOOTFLAGS, "Boot flags requested debugger");
#endif

任务状态段是另一个x86保护模式结构,TSS由硬件用于在发生任务切换时存储任务信息。

局部描述符表用于引用用户空间代码和数据。定义了几个选择器指向LDT,它们是系统调用门以及用户代码和数据选择器。

sys/x86/include/segments.h:
#define	LSYS5CALLS_SEL	0	/* forced by intel BCS */
#define	LSYS5SIGR_SEL	1
#define	LUCODE_SEL	3
#define	LUDATA_SEL	5
#define	NLDT		(LUDATA_SEL + 1)

接下来,初始化proc0的进程控制块(struct pcb)结构。proc0是一个struct proc结构,描述一个内核进程。它在内核运行期间始终存在,因此它与线程0链接。

sys/i386/i386/machdep.c:
register_t
init386(int first)
{
    /* ... skipped ... */

    proc_linkup0(&proc0, &thread0);
    /* ... skipped ... */
}

结构struct pcb是proc结构的一部分。它定义在/usr/include/machine/pcb.h中,包含进程特定于i386架构的信息,例如寄存器值。

1.9.2. mi_startup()

此函数对所有系统初始化对象进行冒泡排序,然后依次调用每个对象的入口点。

sys/kern/init_main.c:
	for (sipp = sysinit; sipp < sysinit_end; sipp++) {

		/* ... skipped ... */

		/* Call function */
		(*((*sipp)->func))((*sipp)->udata);
		/* ... skipped ... */
	}

尽管sysinit框架在开发者手册中进行了描述,但我将讨论其内部机制。

每个系统初始化对象(sysinit对象)都是通过调用SYSINIT()宏创建的。让我们以announce sysinit对象为例。此对象打印版权信息。

sys/kern/init_main.c:
static void
print_caddr_t(void *data __unused)
{
	printf("%s", (char *)data);
}
/* ... skipped ... */
SYSINIT(announce, SI_SUB_COPYRIGHT, SI_ORDER_FIRST, print_caddr_t, copyright);

此对象的子系统ID为SI_SUB_COPYRIGHT(0x0800001)。因此,版权信息将在控制台初始化后立即打印出来。

让我们看看宏SYSINIT()到底做了什么。它扩展为C_SYSINIT()宏。然后C_SYSINIT()宏扩展为一个静态的struct sysinit结构声明,并带有一个DATA_SET宏调用。

/usr/include/sys/kernel.h:
      #define C_SYSINIT(uniquifier, subsystem, order, func, ident) \
      static struct sysinit uniquifier ## _sys_init = { \ subsystem, \
      order, \ func, \ (ident) \ }; \ DATA_WSET(sysinit_set,uniquifier ##
      _sys_init);

#define	SYSINIT(uniquifier, subsystem, order, func, ident)	\
	C_SYSINIT(uniquifier, subsystem, order,			\
	(sysinit_cfunc_t)(sysinit_nfunc_t)func, (void *)(ident))

DATA_SET()宏扩展为_MAKE_SET(),并且该宏是所有sysinit魔术隐藏的地方。

/usr/include/linker_set.h:
#define TEXT_SET(set, sym) _MAKE_SET(set, sym)
#define DATA_SET(set, sym) _MAKE_SET(set, sym)

执行这些宏后,内核中创建了各个部分,包括`set.sysinit_set`。在内核二进制文件上运行objdump,您可能会注意到此类小部分的存在。

% llvm-objdump -h /kernel
Sections:
Idx Name                               Size     VMA      Type
 10 set_sysctl_set                     000021d4 01827078 DATA
 16 set_kbddriver_set                  00000010 0182a4d0 DATA
 20 set_scterm_set                     0000000c 0182c75c DATA
 21 set_cons_set                       00000014 0182c768 DATA
 33 set_scrndr_set                     00000024 0182c828 DATA
 41 set_sysinit_set                    000014d8 018fabb0 DATA

此屏幕截图显示set.sysinit_set部分的大小为0x14d8字节,因此0x14d8/sizeof(void *)个sysinit对象被编译到内核中。其他部分(如set.sysctl_set)表示其他链接器集。

通过定义类型为struct sysinit的变量,set.sysinit_set部分的内容将被“收集”到该变量中。

sys/kern/init_main.c:
  SET_DECLARE(sysinit_set, struct sysinit);

struct sysinit定义如下:

sys/sys/kernel.h:
  struct sysinit {
	enum sysinit_sub_id	subsystem;	/* subsystem identifier*/
	enum sysinit_elem_order	order;		/* init order within subsystem*/
	sysinit_cfunc_t func;			/* function		*/
	const void	*udata;			/* multiplexer/argument */
};

回到mi_startup()的讨论,现在必须清楚sysinit对象是如何组织的。mi_startup()函数对它们进行排序并调用每个对象。最后一个对象是系统调度程序。

/usr/include/sys/kernel.h:
enum sysinit_sub_id {
	SI_SUB_DUMMY		= 0x0000000,	/* not executed; for linker*/
	SI_SUB_DONE		= 0x0000001,	/* processed*/
	SI_SUB_TUNABLES		= 0x0700000,	/* establish tunable values */
	SI_SUB_COPYRIGHT	= 0x0800001,	/* first use of console*/
...
	SI_SUB_LAST		= 0xfffffff	/* final initialization */
};

系统调度程序sysinit对象定义在文件sys/vm/vm_glue.c中,该对象的入口点为scheduler()。该函数实际上是一个无限循环,它表示一个进程ID为0的进程,即交换器进程。前面提到的thread0结构用于描述它。

第一个用户进程称为init,由sysinit对象init创建。

sys/kern/init_main.c:
static void
create_init(const void *udata __unused)
{
	struct fork_req fr;
	struct ucred *newcred, *oldcred;
	struct thread *td;
	int error;

	bzero(&fr, sizeof(fr));
	fr.fr_flags = RFFDG | RFPROC | RFSTOPPED;
	fr.fr_procp = &initproc;
	error = fork1(&thread0, &fr);
	if (error)
		panic("cannot fork init: %d\n", error);
	KASSERT(initproc->p_pid == 1, ("create_init: initproc->p_pid != 1"));
	/* divorce init's credentials from the kernel's */
	newcred = crget();
	sx_xlock(&proctree_lock);
	PROC_LOCK(initproc);
	initproc->p_flag |= P_SYSTEM | P_INMEM;
	initproc->p_treeflag |= P_TREE_REAPER;
	oldcred = initproc->p_ucred;
	crcopy(newcred, oldcred);
#ifdef MAC
	mac_cred_create_init(newcred);
#endif
#ifdef AUDIT
	audit_cred_proc1(newcred);
#endif
	proc_set_cred(initproc, newcred);
	td = FIRST_THREAD_IN_PROC(initproc);
	crcowfree(td);
	td->td_realucred = crcowget(initproc->p_ucred);
	td->td_ucred = td->td_realucred;
	PROC_UNLOCK(initproc);
	sx_xunlock(&proctree_lock);
	crfree(oldcred);
	cpu_fork_kthread_handler(FIRST_THREAD_IN_PROC(initproc), start_init, NULL);
}
SYSINIT(init, SI_SUB_CREATE_INIT, SI_ORDER_FIRST, create_init, NULL);

函数create_init()通过调用fork1()分配一个新进程,但不会将其标记为可运行。当此新进程由调度程序调度执行时,将调用start_init()。该函数定义在init_main.c中。它尝试加载并执行init二进制文件,首先探测/sbin/init,然后探测/sbin/oinit/sbin/init.bak,最后探测/rescue/init

sys/kern/init_main.c:
static char init_path[MAXPATHLEN] =
#ifdef	INIT_PATH
    __XSTRING(INIT_PATH);
#else
    "/sbin/init:/sbin/oinit:/sbin/init.bak:/rescue/init";
#endif

1。如果用户在boot0阶段选择操作系统后立即按下某个键,则会出现此提示。
2。如有疑问,请参阅官方英特尔手册,其中描述了每条指令的确切语义。
3。有一个文件/boot/boot1,但它没有写入FreeBSD分区的开头。相反,它与boot2连接形成boot,后者被写入FreeBSD分区的开头并在启动时读取。
4。实际上,我们在寄存器%si中传递了指向分区入口的指针。但是,boot1不假定它是由boot0加载的(也许其他一些MBR加载了它,并且没有传递此信息),因此它不假定任何内容。
5。在16位实模式的上下文中,一个字是2个字节。
6。512*16=8192字节,正好是boot的大小。
7。历史上称为磁盘标签。如果您想知道FreeBSD将此信息存储在哪里,它位于此区域 - 请参阅bsdlabel(8)
8。出于遗留原因,这是必要的。感兴趣的读者应该参阅。
9。当从保护模式切换回实模式时,需要实模式代码和数据,如英特尔手册所建议。

上次修改时间:2024年3月9日,作者 Danilo G. Baio