F1 FreeBSD
F2 BSD
F5 Disk 2
第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 三阶段引导过程。
理解此过程的关键在于它是一系列复杂程度不断提高的阶段。这些阶段是 boot1、boot2 和 loader(有关更多详细信息,请参阅 boot(8))。引导系统按顺序执行每个阶段。最后一个阶段 loader 负责加载 FreeBSD 内核。以下各节将检查每个阶段。
以下是在不同引导阶段生成的输出示例。实际输出可能因机器而异
FreeBSD 组件 | 输出(可能有所不同) |
| |
|
|
loader |
|
内核 |
|
1.3. BIOS
当计算机开机时,处理器的寄存器将设置为一些预定义的值。其中一个寄存器是指令指针寄存器,其在开机后的值是明确定义的:它是 32 位值 0xfffffff0
。指令指针寄存器(也称为程序计数器)指向处理器要执行的代码。另一个重要的寄存器是 32 位控制寄存器 cr0
,其在重新引导后的值是 0
。cr0
的一个位,即 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
才能正常工作。
值得查看 boot0 的 Makefile(stand/i386/boot0/Makefile),因为它定义了 boot0 的一些运行时行为。例如,如果使用连接到串行端口(COM1)的终端进行 I/O,则必须定义宏SIO
(-DSIO
)。-DPXE
通过按 F6 启用通过 PXE 启动。此外,程序定义了一组标志,允许进一步修改其行为。所有这些都在 Makefile 中进行了说明。例如,查看链接器指令,这些指令命令链接器将文本段的起始地址设置为0x600
,并按“原样”构建输出文件(去除任何文件格式)。
BOOT_BOOT0_ORG?=0x600 ORG=${BOOT_BOOT0_ORG}
为了更好地说明,对某些指令进行了一些修改。例如,展开了一些宏,并在测试结果已知时省略了一些宏测试。这适用于所有显示的代码示例。 |
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
这段代码块是程序的入口点。它是 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
由于 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
此代码测试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
我们的下一个代码块实现了分区表的实际扫描。它在屏幕上打印分区表中四个条目中的每个条目的分区类型。它将每个类型与一组众所周知的操作系统文件系统进行比较。已识别分区类型的示例包括 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
需要注意的是,每个条目的活动标志都被清除了,因此在扫描后,在我们的 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
下一个代码块只是打印一个提示符,后跟默认选项。
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
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
通过寄存器%ah
中0x1a
号中断请求和参数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
从现在开始,我们将倾向于使用技术上更准确的术语“切片”而不是“分区”。 |
传输缓冲区设置为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
。我们可以从概念上将这些磁盘扇区视为分别包含文件boot0和boot1,但实际上对于boot1来说并非完全如此。严格来说,与boot0不同,boot1不是引导块的一部分[3]。相反,一个单独的完整文件boot(/boot/boot)最终被写入磁盘。此文件是boot1、boot2和Boot Extender
(或BTX)的组合。此单个文件的大小大于单个扇区(大于512字节)。幸运的是,boot1恰好占用此单个文件的前512字节,因此当boot0加载FreeBSD切片的第一个扇区(512字节)时,它实际上是在加载boot1并将控制权转移到它。
boot1的主要任务是加载下一个引导阶段。这个下一个阶段有点复杂。它由一个名为“Boot Extender”或BTX的服务器和一个名为boot2的客户端组成。正如我们将看到的,最后一个引导阶段loader也是BTX服务器的客户端。
现在让我们详细了解boot1到底做了什么,从它的入口点开始,就像我们对boot0所做的那样。
start: jmp 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
就像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
在上面的代码中,寄存器%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
特别是,此伪分区的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
回想一下,%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
将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
如果识别出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
回想一下,此时,寄存器%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
boot0 和 boot1 都是 512 字节,因此它们正好可以放入一个磁盘扇区。 boot2 大得多,包含 BTX 服务器和 boot2 客户端。最后,一个名为 boot 的文件比 boot2 大 512 字节。此文件是 boot1 和 boot2 的连接。如前所述,boot0 是写入绝对第一个磁盘扇区(MBR)的文件,而 boot 是写入 FreeBSD 分区第一个扇区的文件;boot1 和 boot2 **不会**写入磁盘。用于将 boot1 和 boot2 连接成单个 boot 的命令仅仅是 cat boot1 boot2 > boot
。
因此 boot1 占据 boot 的前 512 字节,并且由于 boot 被写入 FreeBSD 分区的第一个扇区,因此 boot1 正好适合此第一个扇区。当 nread
读取 FreeBSD 分区的前 16 个扇区时,它实际上读取了整个 boot 文件[6]。我们将在下一节中详细了解 boot 如何由 boot1 和 boot2 组成。
回想一下,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
1.6. BTX 服务器
接下来,我们的引导序列是 BTX 服务器。让我们快速回顾一下我们是如何走到这一步的。
BIOS 将绝对扇区一(MBR 或 boot0)加载到地址
0x7c00
并跳转到那里。boot0 将自身重新定位到
0x600
(其链接到的执行地址),并跳转到那里。然后它将 FreeBSD 分区的第一个扇区(包含 boot1)读入地址0x7c00
并跳转到那里。boot1 将 FreeBSD 分区的前 16 个扇区加载到地址
0x8c00
。这 16 个扇区或 8192 字节是整个文件 boot。该文件是 boot1 和 boot2 的连接。 boot2 又包含 BTX 服务器和 boot2 客户端。最后,跳转到地址0x9010
,即 BTX 服务器的入口点。
在详细研究 BTX 服务器之前,让我们进一步回顾一下如何创建单个、一体化的 boot 文件。 boot 的构建方式在其 Makefile(stand/i386/boot2/Makefile)中定义。让我们看看创建 boot 文件的规则。
boot: boot1 boot2 cat boot1 boot2 > boot
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
要应用创建 boot1 的规则,必须解析 boot1.out。这又取决于 boot1.o 的存在。此最后一个文件只是我们熟悉的 boot1.S 汇编的结果,无需链接。现在,应用创建 boot1.out 的规则。这告诉我们,应将 boot1.o 与 start
作为其入口点链接,并从地址 0x7c00
开始。最后,通过应用相应的规则,从 boot1.out 创建 boot1。此规则是应用于 boot1.out 的 objcopy 命令。请注意传递给 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}
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
请注意,最初没有头文件 boot2.h,但其创建取决于 boot1.out,我们已经有了它。其创建规则有点简洁,但重要的是输出 boot2.h 类似于以下内容。
#define XREADORG 0x725
回想一下,boot1 已重新定位(即从 0x7c00
复制到 0x700
)。此重新定位现在将变得有意义,因为正如我们将看到的,BTX 服务器会回收一些内存,包括 boot1 最初加载的空间。但是,BTX 服务器需要访问 boot1 的 xread
函数;根据 boot2.h 的输出,此函数位于 0x725
位置。实际上,BTX 服务器使用 boot1 重新定位代码中的 xread
函数。现在可以从 boot2 客户端访问此函数。
下一条规则指示链接器链接各种文件(ashldi3.o、boot2.o 和 sio.o)。请注意,输出文件 boot2.out 链接到地址 0x2000
(${ORG2}) 以执行。回想一下,boot2 将在用户模式下执行,在 BTX 服务器设置的特殊用户段内。此段从 0xa000
开始。此外,请记住,boot 的 boot2 部分已复制到地址 0xc000
,即用户段开始处的偏移量 0x2000
,因此当我们向其传递控制权时,boot2 将正常工作。接下来,通过剥离其符号和格式信息,从 boot2.out 创建 boot2.bin;boot2.bin 是一个**原始**二进制文件。现在,请注意,创建了一个文件 boot2.ldr,它是一个 512 字节的文件,内容全部为零。此空间保留用于 bsdlabel。
现在我们有了 boot1、boot2.bin 和 boot2.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 文件中,该文件由 boot1、boot2、bsdlabel
和 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
请注意,前两个字节是 0xeb
和 0xe
。在 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
这段代码禁用中断,设置一个工作栈(起始地址为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
回想一下,boot1最初加载到地址0x7c00
,因此,通过此内存初始化,该副本实际上消失了。但是,也请回想一下,boot1已重定位到0x700
,因此该副本仍然保留在内存中,BTX服务器将使用它。
接下来,更新实模式IVT(中断向量表)。IVT是异常和中断处理程序的段/偏移对数组。BIOS通常将硬件中断映射到中断向量0x8
到0xf
和0x70
到0x77
,但如将看到的,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
下一个代码块创建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
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
请注意,在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
首先,调用setpic
来编程8259A PIC(可编程中断控制器)。该芯片连接到多个硬件中断源。从设备接收中断后,它会向处理器发出适当的中断向量信号。这可以自定义,以便将特定中断与特定中断向量关联,如前所述。接下来,使用指令lidt
和lgdt
分别加载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
请注意,客户端的环境包括栈段选择符和栈指针(寄存器%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.out的struct 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中定义。以下是它们的功能描述。
| 此例程解析从引导程序传递给内核的参数。内核可以通过三种方式引导:通过上面描述的加载程序、通过旧的磁盘引导块或通过旧的无盘引导过程。此函数确定引导方法,并将 |
| 此函数尝试找出它正在运行的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
上次修改时间:2024年3月9日,作者 Danilo G. Baio