对于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区。一个扇区是一次磁盘操作的最小粒度。每一次读取或者写入操作都必须是一个或多个扇区。如果一个磁盘是可以被用来启动操作系统的,就把这个磁盘的第一个扇区叫做启动扇区。当BIOS找到一个可以启动的软盘或硬盘后,它就会把这512字节的启动扇区加载到内存地址0x7c00~0x7dff这个区域内。
对于6.828,我们将采用传统的硬盘启动机制,这就意味着我们的boot loader程序的大小必须小于512字节。整个boot loader是由一个汇编文件,boot/boot.S,以及一个C语言文件,boot/main.c组成。Boot loader必须完成两个主要的功能。
首先,boot loader要把处理器从实模式转换为32bit的保护模式,因为只有在这种模式下软件可以访问超过1MB空间的内容。
然后,boot loader可以通过使用x86特定的IO指令,直接访问IDE磁盘设备寄存器,从磁盘中读取内核。
对于boot loader来说,有一个文件很重要,obj/boot/boot.asm。这个文件是我们真实运行的boot loader程序的反汇编版本。所以我们可以把它和它的源代码即boot.S以及main.c比较一下。
Exercise3:
在地址0x7c00处设置断点,这是boot sector被加载的位置。然后让程序继续运行直到这个断点。跟踪/boot/boot.S文件的每一条指令,同时使用boot.S文件和系统为你反汇编出来的文件obj/boot/boot.asm。你也可以使用GDB的x/i指令来获取去任意一个机器指令的反汇编指令,把源文件boot.S文件和boot.asm文件以及在GDB反汇编出来的指令进行比较。
追踪到bootmain函数中,而且还要具体追踪到readsect()子函数里面。找出和readsect()c语言程序的每一条语句所对应的汇编指令,回到bootmain(),然后找出把内核文件从磁盘读取到内存的那个for循环所对应的汇编语句。找出当循环结束后会执行哪条语句,在那里设置断点,继续运行到断点,然后运行完所有的剩下的语句。
回答:
下面分析一下这道练习中所涉及到的两个重要文件,它们一起组成了boot loader。分别是/boot/boot.S和/boot/main.c文件。其中前者是一个汇编文件,后者是一个C语言文件。当BIOS运行完成之后,CPU的控制权就会转移到boot.S文件上。所以我们首先看一下boot.S文件。
/boot/boot.S:
.globl start
start:
.code16 # Assemble for 16-bit mode
cli # Disable interrupts
这几条指令就是boot.S最开始的几句,其中cli是boot loader的第一条指令。这条指令用于把所有的中断都关闭。因为在BIOS运行期间有可能打开了中断。此时CPU工作在实模式下。
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
这几条命令主要是在把3个段寄存器,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:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.1
movb $0xd1,%al # 0xd1 -> port 0x64
outb %al,$0x64
seta20.2:
inb $0x64,%al # Wait for not busy
testb $0x2,%al
jnz seta20.2
movb $0xdf,%al # 0xdf -> port 0x60
outb %al,$0x60
这部分指令在准备把CPU的工作模式从实模式转换为保护模式。我们可以看到其中包括inb,outb这样的IO端口命令。所以这些指令都是在对外部设备进行操作。根据下面的链接:
http://bochs.sourceforge.net/techspec/PORTS.LST
我们可以查看到,0x64端口属于键盘控制器804x,名称是控制器读取状态寄存器。下面是它各个位的含义。
所以16~18号指令是在不断的检测bit1。bit1的值代表输入缓冲区是否满了,也就是说CPU传送给控制器的数据是否已经取走了,如果CPU想向控制器传送新数据的话,必须先保证这一位为0。所以这三条指令会一直等待这一位变为0,才能继续向后运行。
当0x64端口准备好读入数据后,现在就可以写入数据了,所以19~20这两条指令是把0xd1这条数据写入到0x64端口中。当向0x64端口写入数据时,则代表向键盘控制器804x发送指令。这个指令将会被送给0x60端口。
通过图中可见,D1指令代表下一次写入0x60端口的数据将被写入给804x控制器的输出端口。可以理解为下一个写入0x60端口的数据是一个控制指令。
然后21~24号指令又开始再次等待,等待刚刚写入的指令D1,是否已经被读取了。
如果指令被读取了,25~26号指令会向控制器输入新的指令0xdf。通过查询我们看到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
movl %cr0, %eax
orl $CR0_PE_ON, %eax
movl %eax, %cr0
首先31号指令 lgdt gdtdesc,是把gdtdesc这个标识符的值送入全局映射描述符表寄存器GDTR中。这个GDT表是处理器工作于保护模式下一个非常重要的表。这条指令的功能就是把关于GDT表的一些重要信息存放到CPU的GDTR寄存器中,其中包括GDT表的内存起始地址,以及GDT表的长度。这个寄存器由48位组成,其中低16位表示该表长度,高32位表该表在内存中的起始地址。所以gdtdesc是一个标识符,标识着一个内存地址。从这个内存地址开始之后的6个字节中存放着GDT表的长度和起始地址。我们可以在这个文件的末尾看到gdtdesc,如下:
# 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
其中第3行的gdt是一个标识符,标识从这里开始就是GDT表了。可见这个GDT表中包括三个表项(4,5,6行),分别代表三个段,null seg,code seg,data seg。由于xv6其实并没有使用分段机制,也就是说数据和代码都是写在一起的,所以数据段和代码段的起始地址都是0x0,大小都是0xffffffff=4GB。
在第4~6行是调用SEG()子程序来构造GDT表项的。这个子函数定义在mmu.h中,形式如下:
#define SEG(type,base,lim) \
.word (((lim) >> 12) & 0xffff), ((base) & 0xffff); \
.byte (((base) >> 16) & 0xff), (0x90 | (type)), \
(0xC0 | (((lim) >> 28) & 0xf)), (((base) >> 24) & 0xff)
可见函数需要3个参数,一是type即这个段的访问权限,二是base,这个段的起始地址,三是lim,即这个段的大小界限。gdt表中的每一个表项的结构如图所示:
每个表项一共8字节,其中limit_low就是limit的低16位。base_low就是base的低16位,依次类推。
然后在gdtdesc处就要存放这个GDT表的信息了,其中0x17是这个表的大小-1 = 0x17 = 23,紧接着就是这个表的起始地址gdt。
# 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
当加载完GDT表的信息到GDTR寄存器之后。紧跟着3个操作,32~34指令。 这几步操作是在修改CR0寄存器的内容。CR0寄存器还有CR1~CR3寄存器都是80x86的控制寄存器。其中$CR0_PE的值定义于”mmu.h”文件中,为0x00000001。可见上面的操作是把CR0寄存器的bit0置1,CR0寄存器的bit0是保护模式启动位,把这一位置1代表保护模式启动。
ljmp $PROT_MODE_CSEG, $protcseg
这只是一个简单的跳转指令,这条指令的目的在于把当前的运行模式切换成32位地址模式。
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
修改这些寄存器的值,这些寄存器都是段寄存器。这里的23~29步之所以这么做是按照规定来的,如果刚刚加载完GDTR寄存器我们必须要重新加载所有的段寄存器的值,而其中CS段寄存器必须通过长跳转指令,即23号指令来进行加载。所以这些步骤是在第19步完成后必须要做的。这样才能是GDTR的值生效。
#Set up the stack pointer and call into C.
movl $start, %esp
call bootmain
接下来的指令就是要设置当前的esp寄存器的值,然后准备正式跳转到main.c文件中的bootmain函数处。我们接下来分析一下这个函数的每一条指令:
// read 1st page off disk
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
这里面调用了一个函数readseg,这个函数在bootmain之后被定义了:
void readseg(uchar *pa, uint count, uint offset);
它的功能从注释上来理解是,把距离内核起始地址offset个偏移量存储单元作为起始,将它和它之后的count字节的数据读出送入以pa为起始地址的内存物理地址处。
所以这条指令是把内核的第一个页(4MB = 4096 = SECTSIZE*8 = 512*8)的内容读取的内存地址ELFHDR(0x10000)处。其实完成这些后相当于把操作系统映像文件的elf头部读取出来放入内存中。
读取完这个内核的elf头部信息后,需要对这个elf头部信息进行验证,并且也需要通过它获取一些重要信息。所以有必要了解下elf头部。
注: http://wiki.osdev.org/ELF
elf文件:elf是一种文件格式,主要被用来把程序存放到磁盘上。是在程序被编译和链接后被创建出来的。一个elf文件包括多个段。对于一个可执行程序,通常包含存放代码的文本段(text section),存放全局变量的data段,存放字符串常量的rodata段。elf文件的头部就是用来描述这个elf文件如何在存储器中存储。
需要注意的是,你的文件是可链接文件还是可执行文件,会有不同的elf头部格式。
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;
elf头部信息的magic字段是整个头部信息的开端。并且如果这个文件是格式是ELF格式的话,文件的elf->magic域应该是ELF_MAGIC的,所以这条语句就是判断这个输入文件是否是合法的elf可执行文件。
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
我们知道头部中一定包含Program Header Table。这个表格存放着程序中所有段的信息。通过这个表我们才能找到要执行的代码段,数据段等等。所以我们要先获得这个表。
这条指令就可以完成这一点,首先elf是表头起址,而phoff字段代表Program Header Table距离表头的偏移量。所以ph可以被指定为Program Header Table表头。
eph = ph + ELFHDR->e_phnum;
由于phnum中存放的是Program Header Table表中表项的个数,即段的个数。所以这步操作是吧eph指向该表末尾。
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);
这个for循环就是在加载所有段到内存中。ph->paddr根据参考文献中的说法指的是这个段在内存中的物理地址。ph->off字段指的是这段的开头相对于这个elf文件的开头的偏移量。ph->filesz字段指的是这个段在elf文件中的大小。ph->memsz则指的是这个段被实际装入内存后的大小。通常来说memsz一定大于等于filesz,因为段在文件中时许多未定义的变量并没有分配空间给它们。
所以这个循环就是在把操作系统内核的各个段从外存读入内存中。
((void (*)(void)) (ELFHDR->e_entry))();
下面回答一下文中提出的四个问题:
1. 在什么时候处理器开始运行于32bit模式?到底是什么把CPU从16位切换为32位工作模式?
答:在boot.S文件中,计算机首先工作于实模式,此时是16bit工作模式。当运行完 ” ljmp $PROT_MODE_CSEG, $protcseg ” 语句后,正式进入32位工作模式。根本原因是此时CPU工作在保护模式下。
2. boot loader中执行的最后一条语句是什么?内核被加载到内存后执行的第一条语句又是什么?
答:boot loader执行的最后一条语句是bootmain子程序中的最后一条语句 ” ((void (*)(void)) (ELFHDR->e_entry))(); “,即跳转到操作系统内核程序的起始指令处。
这个第一条指令位于/kern/entry.S文件中,第一句 movw $0x1234, 0x472
3. 内核的第一条指令在哪里?
答:第一条指令位于/kern/entry.S文件中。
4. boot loader是如何知道它要读取多少个扇区才能把整个内核都送入内存的呢?在哪里找到这些信息?
答:首先关于操作系统一共有多少个段,每个段又有多少个扇区的信息位于操作系统文件中的Program Header Table中。这个表中的每个表项分别对应操作系统的一个段。并且每个表项的内容包括这个段的大小,段起始地址偏移等等信息。所以如果我们能够找到这个表,那么就能够通过表项所提供的信息来确定内核占用多少个扇区。
那么关于这个表存放在哪里的信息,则是存放在操作系统内核映像文件的ELF头部信息中。