Mit 6.828 Lab1 第二部分
Part2 The Boot Loader
个人电脑的软盘和硬盘被划分为 512 字节的区域,称为扇区。扇区是磁盘的最小传输粒度:每次读取或写入操作必须有一个或多个扇区大小,并在扇区边界对齐。如果磁盘是可启动的,第一个扇区称为启动扇区,因为这是启动加载程序代码所在的位置。当 BIOS 发现可引导软盘或硬盘时,它会将 512 字节的引导扇区加载到物理地址 0x7c00 至 0x7dff 的内存中,然后使用 jmp 指令将 CS:IP 设置为 0000:7c00,将控制权传递给引导加载器。与 BIOS 加载地址一样,这些地址也是相当随意的,但对于 PC 来说,它们是固定和标准化的。
从 CD-ROM 启动的功能在个人电脑的发展过程中出现得更晚,因此个人电脑架构师利用这个机会对启动过程进行了一些重新思考。因此,现代 BIOS 从 CD-ROM 启动的方式更加复杂(也更加强大)。CD-ROM 使用的扇区大小是 2048 字节,而不是 512 字节,BIOS 可以将更大的启动映像从磁盘加载到内存中(而不仅仅是一个扇区),然后再将控制权转移到它。更多信息,请参阅 "El Torito "可引导光盘格式规范。
但对于 6.828,我们将使用传统的硬盘启动机制,这意味着我们的启动加载器必须容纳在 512 字节内。引导加载器由一个汇编语言源文件 boot/boot.S 和一个 C 语言源文件 boot/main.c 组成。引导加载程序必须执行两个主要功能:
1、首先,引导加载程序会将处理器从实际模式切换到 32 位保护模式,因为只有在该模式下,软件才能访问处理器物理地址空间中超过 1MB 的所有内存。保护模式在《PC 汇编语言》第 1.2.7 和 1.2.8 节中有简要介绍,在英特尔体系结构手册中有详细说明。目前,您只需了解在保护模式下,分段地址(段:偏移量对)到物理地址的转换方式不同,转换后偏移量是 32 位,而不是 16 位。
2、其次,引导加载程序通过 x86 的特殊 I/O 指令直接访问 IDE 磁盘设备寄存器,从硬盘读取内核。如果你想更好地理解这里的特殊 I/O 指令的含义,请查看 6.828 参考页面上的 "IDE 硬盘驱动器控制器 "部分。在这门课中,你不需要学习太多关于特定设备编程的知识:编写设备驱动程序实际上是操作系统开发的一个非常重要的部分,但从概念或架构的角度来看,它也是最不有趣的部分之一。
了解引导加载器源代码后,请查看 obj/boot/boot.asm 文件。该文件是 GNUmakefile 在编译 Boot Loader 后创建的 Boot Loader 的反汇编文件。通过这个反汇编文件,我们可以很容易地看到引导加载器的所有代码在物理内存中的确切位置,也更容易在 GDB 中跟踪引导加载器的运行情况。同样,obj/kern/kernel.asm 包含了 JOS 内核的反汇编,这对调试非常有用。
你可以使用 b 命令在 GDB 中设置地址断点。例如,b *0x7c00在地址 0x7C00 处设置断点。到达断点后,你可以使用 c 和 si 命令继续执行:c 命令会让 QEMU 继续执行,直到下一个断点(或按下 GDB 中的 Ctrl-C),而 si N 命令则会一次执行 N 条指令。
要检查内存中的指令(除了 GDB 自动打印的下一条要执行的指令),可以使用 x/i 命令。该命令的语法是 x/Ni ADDR,其中 N 是要反汇编的连续指令数,ADDR 是开始反汇编的内存地址。
练习 3.看看实验工具指南,尤其是有关 GDB 命令的部分。即使你熟悉 GDB,其中也包括一些对操作系统工作有用的深奥 GDB 命令。
在地址 0x7c00 处设置断点,这是引导扇区的加载位置。继续执行直到该断点。使用源代码和反汇编文件 obj/boot/boot.asm 跟踪 boot/boot.S 中的代码。同时使用 GDB 中的 x/i 命令反汇编 Boot Loader 中的指令序列,并将原始 Boot Loader 源代码与 obj/boot/boot.asm 和 GDB 中的反汇编进行比较。
跟踪 boot/main.c 中的 bootmain(),然后跟踪 readsect()。找出与 readsect() 中每条语句相对应的准确汇编指令。跟踪 readsect() 的其余部分并返回 bootmain(),确定从磁盘读取内核剩余扇区的 for 循环的开始和结束。找出循环结束时将运行的代码,在那里设置断点,并继续运行到该断点。然后逐步完成引导加载程序的剩余部分。
能够回答下列问题:
1、处理器从何时开始执行 32 位代码?从 16 位模式切换到 32 位模式的具体原因是什么?
在加载完全局描述符表寄存器,并且将CR0的PE位置置1后,开始执行32位代码,第一条语句为:
movw $PROT_MODE_DSEG, %ax # Our data segment selector
7c32: 66 b8 10 00 mov $0x10,%ax
原因:对A20地址线的控制,开启A20线,则会切换到32位保护模式,关闭则切换至16位的实模式。
2、引导加载器执行的最后一条指令是什么,刚刚加载的内核的第一条指令是什么?
最后一条指令:7d61: ff 15 18 00 01 00 call *0x10018
第一条指令:0x10000c: movw $0x1234,0x472
3、内核的第一条指令在哪里?
内核的第一条指令在0x10000c
4、Boot Loader 如何决定必须读取多少个扇区才能从磁盘获取整个内核?它从哪里找到这些信息?
bootLoader从第二个扇区开始,读取8个扇区的大小,也就是4096个字节,读取elf文件,其从elf文件中获取代码段的地址,并读取。
5、Bootloader由一个汇编语言源文件boot/boot.s和一个C语言源文件boot/main.c组成,以下为源码
//file: boot/boot.S
/* 1、禁止中断,并初始化实模式下的段寄存器ds、es、ss
2、使能A20地址线,开启保护模式
3、加载全局描述符表寄存器,并将CR0的PE位置1
4、进入32位保护模式,初始化保护模式下的段寄存器
5、调用main.c
#include <inc/mmu.h>
# Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00.
.set PROT_MODE_CSEG, 0x8 # kernel code segment selector
.set PROT_MODE_DSEG, 0x10 # kernel data segment selector
.set CR0_PE_ON, 0x1 # protected mode enable flag
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
cld # String operations increment
# Set up the important data segment registers (DS, ES, SS).
xorw %ax,%ax # Segment number zero
movw %ax,%ds # -> Data Segment
movw %ax,%es # -> Extra Segment
movw %ax,%ss # -> Stack Segment
# Enable A20:
# For backwards compatibility with the earliest PCs, physical
# address line 20 is tied low, so that addresses higher than
# 1MB wrap around to zero by default. This code undoes this.
seta20.1:
inb $0x64,%al # Wait for not busy 读取AT键盘控制器状态寄存器
testb $0x2,%al # 判断输入缓冲器是否满的
jnz seta20.1 # 如果满 则陷入死循环
movb $0xd1,%al # 0xd1 -> port 0x64 关闭A20地址线(猜测)
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地址线
# Switch from real to protected mode, using a bootstrap GDT
# and segment translation that makes virtual addresses
# identical to their physical addresses, so that the
# effective memory map does not change during the switch.
lgdt gdtdesc # 加载全局描述符表寄存器
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0 # 将CR0的PE位置1,表示已经开启保护模式
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
# 0x8,0x7c32
.code32 # Assemble for 32-bit mode
protcseg:
# Set up the protected-mode data segment registers
movw $PROT_MODE_DSEG, %ax # Our data segment selector
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
# If bootmain returns (it shouldn't), loop.
spin:
jmp spin
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
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
涉及到控制寄存器CR0,0位是保护允许位PE(Protedted Enable),用于启动保护模式,如果PE位置1,则保护模式启动,如果PE=0,则在实模式下运行。
1 位是监控协处理位MP(Moniter coprocessor),它与第3位一起决定:当TS=1时操作码WAIT是否产生一个“协处理器不能使用”的出错信号。第3位是任务转换位(Task Switch),当一个任务转换完成之后,自动将它置1。随着TS=1,就不能使用协处理器。
CR0的第2位是模拟协处理器位 EM (Emulate coprocessor),如果EM=1,则不能使用协处理器,如果EM=0,则允许使用协处理器。
第4位是微处理器的扩展类型位 ET(Processor Extension Type),其内保存着处理器扩展类型的信息,如果ET=0,则标识系统使用的是287协处理器,如果 ET=1,则表示系统使用的是387浮点协处理器。
CR0的第31位是分页允许位(Paging Enable),它表示芯片上的分页部件是否允许工作。
CR0的第16位是写保护未即WP位(486系列之后),只要将这一位置0就可以禁用写保护,置1则可将其恢复。
//file: boot/main.c
#include <inc/x86.h>
#include <inc/elf.h>
/**********************************************************************
* This a dirt simple boot loader, whose sole job is to boot
* an ELF kernel image from the first IDE hard disk.
*
* DISK LAYOUT
* * This program(boot.S and main.c) is the bootloader. It should
* be stored in the first sector of the disk.
*
* * The 2nd sector onward holds the kernel image.
*
* * The kernel image must be in ELF format.
*
* BOOT UP STEPS
* * when the CPU boots it loads the BIOS into memory and executes it
*
* * the BIOS intializes devices, sets of the interrupt routines, and
* reads the first sector of the boot device(e.g., hard-drive)
* into memory and jumps to it.
*
* * Assuming this boot loader is stored in the first sector of the
* hard-drive, this code takes over...
*
* * control starts in boot.S -- which sets up protected mode,
* and a stack so C code then run, then calls bootmain()
*
* * bootmain() in this file takes over, reads in the kernel and jumps to it.
**********************************************************************/
#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
void readsect(void*, uint32_t);
void readseg(uint32_t, uint32_t, uint32_t);
void
bootmain(void)
{
struct Proghdr *ph, *eph;
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
// load each program segment (ignores ph flags)
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
for (; ph < eph; ph++)
// p_pa is the load address of this segment (as well
// as the physical address)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);
// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();
bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}
// Read 'count' bytes at 'offset' from kernel into physical address 'pa'.
// Might copy more than asked
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;
end_pa = pa + count;
// round down to sector boundary
pa &= ~(SECTSIZE - 1); //~511=(111111111)=0
// translate from bytes to sectors, and kernel starts at sector 1
offset = (offset / SECTSIZE) + 1;
// If this is too slow, we could read lots of sectors at a time.
// We'd write more to memory than asked, but it doesn't matter --
// we load in increasing order.
while (pa < end_pa) {
// Since we haven't enabled paging yet and we're using
// an identity segment mapping (see boot.S), we can
// use physical addresses directly. This won't be the
// case once JOS enables the MMU.
readsect((uint8_t*) pa, offset);
pa += SECTSIZE;
offset++;
}
}
void
waitdisk(void)
{
// wait for disk reaady
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}
void
readsect(void *dst, uint32_t offset)
{
// wait for disk to be ready
waitdisk();
outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors
// wait for disk to be ready
waitdisk();
// read a sector
insl(0x1F0, dst, SECTSIZE/4);
}
第一个扇区为引导扇区,因此bootmain.c从第二个扇区开始,在磁盘上找到内核可执行的副本。内核是一个ELF格式的二进制,为了访问ELF报头,bootmain加载了ELF二进制文件的前4096个字节。它将内存拷贝放置在地址0x10000处
练习 4.C 语言的最佳参考书是 Brian Kernighan 和 Dennis Ritchie(简称 "K&R")合著的《C 编程语言》。我们建议学生购买这本书(这里有亚马逊链接),或者从麻省理工学院的 7 本书中找到一本。
阅读 K&R 中的 5.1(指针和地址)至 5.5(字符指针和函数)。然后下载 pointers.c 的代码,运行它,并确保了解所有打印值的来源。特别是,请确保您了解打印的第 1 行和第 6 行中的指针地址从何而来,打印的第 2 行至第 4 行中的所有值是如何到达的,以及为什么第 5 行中打印的值看似已损坏。
关于 C 语言中的指针,还有其他参考文献(例如 Ted Jensen 编写的教程,其中大量引用了 K&R 的内容),但并不强烈推荐。
警告:除非你已经精通 C 语言,否则不要跳过甚至略过这个阅读练习。如果你没有真正理解 C 语言中的指针,那么你将在后续的实验中遭受难以言表的痛苦和折磨,并最终以艰难的方式理解指针。相信我们,你不会想知道什么是 "艰难的方法"。
要理解 boot/main.c,你需要知道什么是 ELF 二进制文件。当你编译和链接一个 C 程序(如 JOS 内核)时,编译器会将每个 C 源文件('.c')转换成一个对象文件('.o'),其中包含以硬件期望的二进制格式编码的汇编语言指令。然后,链接器会将所有编译好的对象文件合并成一个二进制映像,如 obj/kern/kernel,在这种情况下,它就是 ELF 格式的二进制文件,即 "可执行和可链接格式"。
ELF二进制文件以固定长度的 ELF 头文件开始,然后是可变长度的程序头文件,列出要加载的每个程序段。这些 ELF 头文件的 C 语言定义在 inc/elf.h 中,我们感兴趣的部分有:
- .text:程序的可执行指令
- .rodata:只读数据,如 C 编译器生成的 ASCII 字符串常量
- .data:数据部分保存程序的初始化数据,例如使用初始化器声明的全局变量,如 int x = 5;
链接器在计算程序的内存布局时,会为未初始化的全局变量(如 int x;)在内存中紧跟 .data 之后的 .bss 部分预留空间。C 语言要求 "未初始化 "的全局变量以零值开始。因此,ELF 二进制文件中不需要存储 .bss 的内容;相反,链接器只记录 .bss 部分的地址和大小。加载程序或程序本身必须将 .bss 部分置零。
可以通过objdump指令来查看二进制文件,objdump常用的选项有:
-h
--section-headers
--headers
显示目标文件各个section的头部摘要信息
--all-headers
-x
显示所可用的头信息,包括符号表、重定位入口。-x 等价于-a -f -h -r -t 同时指定
-f
--file-headers
显示objfile中每个文件的整体头部摘要信息。
查看内核ELF文件中各个section的头部摘要信息:
除了上面列出的部分,你还会看到更多其他部分,但其他部分对我们的目的来说并不重要。大多数其他部分用于保存调试信息,这些信息通常包含在程序的可执行文件中,但不会被程序加载器加载到内存中。
请特别注意 .text 部分的 "VMA"(链接地址)和 "LMA"(加载地址)。段的加载地址是该段应加载到内存中的内存地址。
章节的链接地址是该章节执行时的内存地址。链接器会以各种方式在二进制文件中对链接地址进行编码,例如当代码需要全局变量的地址时。(可以生成不包含任何此类绝对地址的位置无关代码。现代共享库广泛使用了这种方法,但它需要付出性能和复杂性的代价,因此我们不会在 6.828 中使用它)。
查看boot文件生成的ELF文件的section:
引导加载器使用 ELF 程序头来决定如何加载这些部分。程序头指定了要加载到内存中的 ELF 对象的哪些部分,以及每个部分应该占用的目标地址。您可以键入以下命令查看程序头:
ELF 对象中需要加载到内存中的区域就是那些标记为 "LOAD "的区域。每个程序头的其他信息也会给出,如虚拟地址("vaddr")、物理地址("paddr")和加载区域的大小("memsz "和 "filesz")。
回到 boot/main.c,每个程序头的 ph->p_pa 字段包含段的目标物理地址(在这种情况下,它确实是一个物理地址,尽管 ELF 规范对该字段的实际含义含糊不清)。
BIOS 从地址 0x7c00 开始将引导扇区加载到内存中,因此这是引导扇区的加载地址。这也是引导扇区的执行地址,所以也是它的链接地址。我们通过在 boot/Makefrag 中向链接器传递 -Ttext 0x7C00 来设置链接地址,这样链接器就会在生成的代码中产生正确的内存地址。
练习 6.我们可以使用 GDB 的 x 命令检查内存。GDB 手册中有详细说明,但现在只需知道 x/Nx ADDR 命令将打印 ADDR 处的 N 个内存字即可。(注意,命令中的两个 "x "都是小写):字的大小并非通用标准。在 GNU 汇编中,一个字是两个字节(xorw 中的 "w "代表字,表示 2 个字节)。
重置机器(退出 QEMU/GDB,然后重新启动)。在 BIOS 进入引导装载程序时检查 0x00100000 处的 8 个内存字,然后在引导装载程序进入内核时再检查一次。它们为什么不同?第二个断点有什么?(回答这个问题并不需要使用 QEMU。)
首先要记录当BIOS进入Boot loader的时刻,内存0x00100000处的值,因此设置断点在0x7c00,b *0x7c00,在输入x/8x 0x00100000:
再要记录bootloader进入内核的时刻:
分析结果:
输入objdump -h obj/kern/kernel命令,查看section header的信息,可知道.text section需要加载在0x100000,因此0x100000位置的值的改变是由于.text的加载。