Lab1:Part 2 The Boot Loader
PC的软盘和硬盘分为多个512个字节大小的的区域,称为扇区。 扇区是磁盘的最小传输单位:每个读或写操作必须是一个或多个扇区,并且必须在扇区边界上开始。 如果磁盘是可引导的,则第一个扇区称为引导扇区,因为这是引导加载程序代码所在的位置。 当BIOS找到可引导的软盘或硬盘时,它将512字节的引导扇区加载到物理地址0x7c00
至0x7dff
的内存中,然后使用jmp
指令将CS:IP
设置为0000:7c00
,将控制权传递给引导程序装载机。
对于6.828,我们将使用常规的硬盘启动机制。 引导加载程序由一个汇编语言源文件boot/boot.S
和一个C源文件boot/main.c
组成。浏览源文件,了解其具体做了什么。 引导加载程序必须执行两个主要功能:
- 首先,引导加载程序将处理器从实模式切换到32位保护模式,因为只有在这种模式下,软件才能访问
1MB
以上的所有内存。 - 其次,引导加载程序通过 x86 的特殊 I/O 指令直接访问 IDE 磁盘设备寄存器,从而从硬盘读取内核。
了解了引导加载程序的源代码之后,请查看文件 obj/boot/boot.asm
,这是引导加载程序的反汇编版本,该文件可以轻松地准确查看所有引导加载程序代码在物理内存中的位置,并且可以更轻松地跟踪在 GDB 中逐步引导加载程序时发生的情况,对于调试很有帮助。
GBD 指令
-
b --- 在指定地址设置断点
,如b *0x7c00
-
c --- 执行到下一个断点或直到按 Ctel C 为止
-
si N --- 执行到后面的第 N 条
-
x/i --- 检查内存中的指令(除即将执行的下一条指令外)
,如x/Ni addr
现实从 addr 开始的 N 条指令
boot 文件下 boot.S 与 mian.c 的源码阅读
boot.s
cli # Disable interrupts
cli
是 boot.S 的第一条指令,关全局中断。
cld # String operations increment
cld
用于将 DF 位(Direction Flag) 置零,DF 用于串操作指令中决定内存地址的变化方向,DF 置零使得串操作朝地址增加方向。
# 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
通过 xorw
指令使得 ax 寄存器内容清零,在通过 movw
指令给 ds, es, ss 寄存器置零。因为经历了 BIOS 后,这三个寄存器存放的内容不确定,需要重置,为进入保护模式做准备。
# 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:
# 从 0x64 端口读取一个字节,存于 ax 寄存器的低八位
inb $0x64,%al # Wait for not busy
# 测试 al 中的第二位(与操作),若结果为 0 则 ZF = 1
testb $0x2,%al
# ZF 为 0 就跳转循环 seta20.1
jnz seta20.1
# 0x64 端口空闲,将 0xdl 送往该端口
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
# 将 0xdf 送往 0x64 端口
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
在 http://bochs.sourceforge.net/techspec/PORTS.LST 中找到了 0x64 端口及其各位的作用:
该端口属于键盘控制器 804x,名称是控制器读取状态寄存器。
testb 0x02, %al
测试 0x64
端口的 bit 1, 若其为 1(该指令会将 ZF 标志置 1),则说明输入缓冲满,不能马上往该端口写入数据,需要重新测试端口(jnz seta20.1
指令),直到端口空闲。
上述代码分别给0x64 和 0x60
端口写入两条指令0xd1,0xdf
,查到这两条指令的含义如下
0xd1
指令将下一条写到0x60
端口的指令写到键盘控制器 804x 的输出端口,即0xdf
被写到键盘控制器 804x 的输出端口。
0xdf
指令使能 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
# 修改寄存器 cr0 的 bit0,使其为 1
movl %cr0, %eax
orl $CR0_PE_ON, %eax # CR0_PE_ON 定义于 mmu.h 文件,内容为 0x00000001
movl %eax, %cr0
lgdt
指令用于加载全局描述符,用于将gdtdesc
这个标识符的值送入全局映射描述符表寄存器 GDTR 中。gdtdesc
是一个标识符,标识着一个内存地址。从这个内存地址开始之后的6个字节中存放着 GDT 表的长度和起始地址,GDT 表是处理器在保护模式下一个很重要的表。(我没弄清楚,参考 这儿。
加载 LGDT 结束后,将 CR0 寄存器的 bit1 置 1。
# Bootstrap GDT
.p2align 2 # force 4 byte alignment
# gdt 表,共三个表项
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
由于 xv6 其实并没有使用分段机制,也就是说数据段和代码段的位置没有区分,所以数据段和代码段的起始地址都是0x0
,大小都是0xffffffff
。
# Jump to next instruction, but in 32-bit code segment.
# Switches processor into 32-bit mode.
ljmp $PROT_MODE_CSEG, $protcseg
该指令跳转至 protcseg
,并将运行模式切换至 32 位地址模式
.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
在加载完 GDTR 后必须重新加载上述所有寄存器的值,这是必须要求的:https://en.wikibooks.org/wiki/X86_Assembly/Global_Descriptor_Table 。
# Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
设置当前 esp 寄存器的值,然后跳转至 main.c 中的 bootmain 函数。
main.c
#define ELFHDR ((struct Elf *) 0x10000) // scratch space
指向 elf 类的指针,该指针起始地址为 0x100000
。
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
该函数定义在 bootmain
的后面
// 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);
// 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);
}
readseg(uint32_t pa, uint32_t count, uint32_t offset)
函数用于从距离 kernel 物理地址为 offset
处, 长度为count
(byte 为单位)的内存中读取数据,并送到以 pa
为起始地址的物理内存处。
故 readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
是将内核的第一页 (512 * 8 B) 的内容读取到内存地址为0x10000
(ELFHDR) 处,即把操作系统的映像文件的 elf 头部读取到内存中。
elf 文件,参考 https://www.cnblogs.com/fatsheep9146/p/5115086.html :
elf 文件:elf 是一种文件格式,主要被用来把程序存放到磁盘上。是在程序被编译和链接后被创建出来的。一个 elf 文件包括多个段。对于一个可执行程序,通常包含存放代码的文本段 (text section),存放全局变量的 data 段,存放字符串常量的 rodata 段。
elf 文件的头部就是用来描述这个 elf 文件如何在存储器中存储。
需要注意的是,你的文件是可链接文件还是可执行文件,会有不同的elf头部格式。
elf header 定义
// inc/elf.h
struct Elf {
uint32_t e_magic; // must equal ELF_MAGIC
uint8_t e_elf[12];
uint16_t e_type;
uint16_t e_machine;
uint32_t e_version;
uint32_t e_entry; //This member gives the virtual address to which the system first transfers control, thus starting the process.
uint32_t e_phoff; //This member holds the program header table's file offset in bytes.
uint32_t e_shoff;
uint32_t e_flags;
uint16_t e_ehsize;
uint16_t e_phentsize;
uint16_t e_phnum; //This member holds the number of entries in the program header table.
uint16_t e_shentsize;
uint16_t e_shnum;
uint16_t e_shstrndx;
};
参考:ELF文件解析(二):ELF header详解,这篇博客有关于 elf header 的详细解读。
// is this a valid ELF?
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
elf 文件头部的 magic 字段是头部信息的开端,若读入的是 elf 格式,ELFHDR->e_magic != ELF_MAGIC
应为假。
// load each program segment (ignores ph flags)
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;
e_phoff
代表 Program Header Table 在 elf 文件头部中的偏移量(byte 为单位),ph
指向 Program Header Table 的表头。
e_phnum
存放 Program Header Table 中的表项数,故 eph
指向 Program Header Table 尾
来源: ELF Header
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);
struct Proghdr {
uint32_t p_type;
uint32_t p_offset;
uint32_t p_va;
uint32_t p_pa;
uint32_t p_filesz;
uint32_t p_memsz;
uint32_t p_flags;
uint32_t p_align;
};
以下来自:MIT 6.828 JOS学习笔记5. Exercise 1.3
这个for循环就是在加载所有的段到内存中。ph->paddr 根据参考文献中的说法指的是这个段在内存中的物理地址。ph->off 字段指的是这一段的开头相对于这个elf文件的开头的偏移量。ph->filesz 字段指的是这个段在 elf 文件中的大小。ph->memsz 则指的是这个段被实际装入内存后的大小。通常来说 memsz 一定大于等于 filesz,因为段在文件中时许多未定义的变量并没有分配空间给它们。
所以这个循环就是在把操作系统内核的各个段从外存读入内存中。
我没找到 Proghdr
各项的具体定义,改天找到了再回来补充吧。
// call the entry point from the ELF header
// note: does not return!
((void (*)(void)) (ELFHDR->e_entry))();
ELFHDR->e_entry
是执行点入口,这就开始运行这个文件,控制权就从 boot loader 转给了操作系统的内核。