MIT 6.828 JOS学习笔记5. Exercise 1.3

Exercise 1.3

 设置一个断点在地址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:

1 .globl start
2 start:
3   .code16                # Assemble for 16-bit mode
4   cli                    # Disable interrupts

  这几条指令就是boot.S最开始的几句,其中cli是boot.S,也是boot loader的第一条指令。这条指令用于把所有的中断都关闭。因为在BIOS运行期间有可能打开了中断。此时CPU工作在实模式下。

5  cld                         # String operations increment

  这条指令用于指定之后发生的串处理操作的指针移动方向。在这里现在对它大致了解就够了。

6  # Set up the important data segment registers (DS, ES, SS).
7  xorw    %ax,%ax             # Segment number zero
8  movw    %ax,%ds             # -> Data Segment
9  movw    %ax,%es             # -> Extra Segment
10 movw    %ax,%ss             # -> Stack Segment

  这几条命令主要是在把三个段寄存器,ds,es,ss全部清零,因为经历了BIOS,操作系统不能保证这三个寄存器中存放的是什么数。所以这也是为后面进入保护模式做准备。

11  # Enable A20:
12  #   For backwards compatibility with the earliest PCs, physical
13  #   address line 20 is tied low, so that addresses higher than
14  #   1MB wrap around to zero by default.  This code undoes this.
15 seta20.1:
16  inb     $0x64,%al               # Wait for not busy
17  testb   $0x2,%al
18  jnz     seta20.1

19  movb    $0xd1,%al               # 0xd1 -> port 0x64
20  outb    %al,$0x64

21 seta20.2:
22  inb     $0x64,%al               # Wait for not busy
23  testb   $0x2,%al
24  jnz     seta20.2

25  movb    $0xdf,%al               # 0xdf -> port 0x60
26  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线,代表可以进入保护模式了。

27   # Switch from real to protected mode, using a bootstrap GDT
28   # and segment translation that makes virtual addresses
29   # identical to their physical addresses, so that the
30   # effective memory map does not change during the switch.
31   lgdt    gdtdesc
32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

  首先31号指令 lgdt gdtdesc,是把gdtdesc这个标识符的值送入全局映射描述符表寄存器GDTR中。这个GDT表是处理器工作于保护模式下一个非常重要的表。具体可以参照我们的Appendix 1关于实模式和保护模式的介绍。至于这条指令的功能就是把关于GDT表的一些重要信息存放到CPU的GDTR寄存器中,其中包括GDT表的内存起始地址,以及GDT表的长度。这个寄存器由48位组成,其中低16位表示该表长度,高32位表该表在内存中的起始地址。所以gdtdesc是一个标识符,标识着一个内存地址。从这个内存地址开始之后的6个字节中存放着GDT表的长度和起始地址。我们可以在这个文件的末尾看到gdtdesc,如下:

 1 # Bootstrap GDT
 2 .p2align 2                               # force 4 byte alignment
 3 gdt:
 4   SEG_NULL                               # null seg
 5   SEG(STA_X|STA_R, 0x0, 0xffffffff)      # code seg
 6   SEG(STA_W, 0x0, 0xffffffff)            # data seg
 7
 8 gdtdesc:
 9   .word   0x17                           # sizeof(gdt) - 1
10   .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位,依次类推,所以我们就可以理解SEG函数为什么要那么写(其实还是有很多不理解的。。)。

   然后在gdtdesc处就要存放这个GDT表的信息了,其中0x17是这个表的大小-1 = 0x17 = 23,至于为什么不直接存表的大小24,根据查询是官方规定的。紧接着就是这个表的起始地址gdt。

27   # Switch from real to protected mode, using a bootstrap GDT
28   # and segment translation that makes virtual addresses
29   # identical to their physical addresses, so that the
30   # effective memory map does not change during the switch.
31   lgdt    gdtdesc
32   movl    %cr0, %eax
33   orl     $CR0_PE_ON, %eax
34   movl    %eax, %cr0

  再回到刚才那里,当加载完GDT表的信息到GDTR寄存器之后。紧跟着3个操作,32~34指令。 这几步操作明显是在修改CR0寄存器的内容。CR0寄存器还有CR1~CR3寄存器都是80x86的控制寄存器。其中$CR0_PE的值定义于"mmu.h"文件中,为0x00000001。可见上面的操作是把CR0寄存器的bit0置1,CR0寄存器的bit0是保护模式启动位,把这一位值1代表保护模式启动。

35  ljmp    $PROT_MODE_CSEG, $protcseg

  这只是一个简单的跳转指令,这条指令的目的在于把当前的运行模式切换成32位地址模式

protcseg:
  # Set up the protected-mode data segment registers
36  movw    $PROT_MODE_DSEG, %ax    # Our data segment selector
37  movw    %ax, %ds                # -> DS: Data Segment
38  movw    %ax, %es                # -> ES: Extra Segment
39  movw    %ax, %fs                # -> FS
40  movw    %ax, %gs                # -> GS
41  movw    %ax, %ss                # -> SS: Stack Segment

    修改这些寄存器的值。这些寄存器都是段寄存器。大家可以戳这个链接看一下具体介绍 http://www.eecg.toronto.edu/~amza/www.mindsec.com/files/x86regs.html

   这里的23~29步之所以这么做是按照规定来的,https://en.wikibooks.org/wiki/X86_Assembly/Global_Descriptor_Table链接中指出,如果刚刚加载完GDTR寄存器我们必须要重新加载所有的段寄存器的值,而其中CS段寄存器必须通过长跳转指令,即23号指令来进行加载。所以这些步骤是在第19步完成后必须要做的。这样才能是GDTR的值生效。

# Set up the stack pointer and call into C.
42  movl    $start, %esp
43  call bootmain

  接下来的指令就是要设置当前的esp寄存器的值,然后准备正式跳转到main.c文件中的bootmain函数处。我们接下来分析一下这个函数的每一条指令:

// read 1st page off disk
1 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头部格式。



 

2 if (ELFHDR->e_magic != ELF_MAGIC)
3        goto bad;

  elf头部信息的magic字段是整个头部信息的开端。并且如果这个文件是格式是ELF格式的话,文件的elf->magic域应该是=ELF_MAGIC的,所以这条语句就是判断这个输入文件是否是合法的elf可执行文件。

4 ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);

  我们知道头部中一定包含Program Header Table。这个表格存放着程序中所有段的信息。通过这个表我们才能找到要执行的代码段,数据段等等。所以我们要先获得这个表。

  这条指令就可以完成这一点,首先elf是表头起址,而phoff字段代表Program Header Table距离表头的偏移量。所以ph可以被指定为Program Header Table表头。

 

5 eph = ph + ELFHDR->e_phnum;

   由于phnum中存放的是Program Header Table表中表项的个数,即段的个数。所以这步操作是吧eph指向该表末尾。

6 for (; ph < eph; ph++)
    // p_pa is the load address of this segment (as well
    // as the physical address)
7    readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

   这个for循环就是在加载所有的段到内存中。ph->paddr根据参考文献中的说法指的是这个段在内存中的物理地址。ph->off字段指的是这一段的开头相对于这个elf文件的开头的偏移量。ph->filesz字段指的是这个段在elf文件中的大小。ph->memsz则指的是这个段被实际装入内存后的大小。通常来说memsz一定大于等于filesz,因为段在文件中时许多未定义的变量并没有分配空间给它们。

   所以这个循环就是在把操作系统内核的各个段从外存读入内存中。

8 ((void (*)(void)) (ELFHDR->e_entry))();

  e_entry字段指向的是这个文件的执行入口地址。所以这里相当于开始运行这个文件。也就是内核文件。 自此就把控制权从boot loader转交给了操作系统的内核。

以上就是我们对这两个文件的分析。下面我们来完成Exercise要求我们做的事情:

   

首先完成第一部分,即对boot.S程序的跟踪,我们还是要采用gdb。首先打开一个terminal,并且cd到xv6的根目录下,输入make qemu-gdb。然后再打开一个terminal也来到xv6的根目录下,输入gdb,我们就可以开始调试了。

首先要设置一个断点,设置到boot.S程序开始运行的地方,因为我们之前已经介绍过,BIOS会把boot sector复制到0x7c00地址处,所以boot.S的起始运行地址就是0x7c00.

所以我们在gdb窗口中输入 b *0x7c00,然后再输入c,表示继续运行到断点处,在这里我们输入

   x/30i 0x7c00

  显示如下:

    

这条gdb指令是把存放在0x7c00以及之后30字节的内存里面的指令反汇编出来,我们可以拿它直接和boot.S以及在obj/boot/boot.asm进行比较,如下:

 首先是obj/boot/boot.asm

 

 然后是boot.S

 

 可见这三者在指令上没有区别,只不过在源代码中,我们指定了很多标识符比如set20.1,.start,这些标识符在被汇编成机器代码后都会被转换成真实物理地址。比如set20.1就被转换为0x7c0a,那么在obj/boot/boot.asm中还把这种对应关系列出来了,但是在真实执行时,即第一种情况中,就看不到set20.1标识符了,完全是真实物理地址。

紧接着完成Exercise的第二部分:

 我们可以对bootmain函数中的语句逐一分析:

 首先在boot.S中的最后一句是

        0. call bootmain

call指令将会把返回地址压入栈中,然后把bootmain的起始地址赋给%eip寄存器的值,所以在这句指令执行后%eip 的值变为 0x7d0b

 因为我们从obj/boot/boot.asm 文件中,正好可以看到bootmain函数的汇编形式:

 

可见bootmain翻译成汇编程序的第一条指令是push %ebp,地址为0x7d0b。所以正好和%eip的值对上。另外%esp寄存器中的值由0x7c00,变为0x7bfc。其中0x7bfd~0x7c00中存放的值是boot.S运行时的返回地址。

然后我们开始正式分析bootmain函数,上图中的四句汇编代码是进行过程调用时,被调过程必须要事先执行的一些通用的任务。

关于汇编语言过程调用的知识,可以阅读Apendix 2

        1 0x7d0b  push  %esp

        2 0x7d0c  mov  %esp, %ebp

这两句就是在修改栈帧界限的信息。阅读Appdenix 2,里面具体解释了这两句的含义。

        3. 0x7d0e  push  %esi

        4  0x7d0f  push  %ebx

这两句操作是在备份%esi,%ebx的值,因为这两个寄存器叫做被调用者保存寄存器,即如果要在子过程中使用了它们,那么在子过程开头处必须先备份这些寄存器的值。

那么此时%esp的值为0x7bf0,而%ebp的值设置为0x7bf8。可见进入bootmain函数后,把0x7c00之前的低地址空间拿来作为栈帧使用。

以上这些都属于过程调用的常见指令,下面进入bootmain的c语言程序部分。

首先看第一条C语言指令

// read 1st page off disk
1 readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

下面看下这条指令汇编的结果:

首先7d10~7d17三条指令的目的是为了把三个输入参数压入栈帧之中,以供readseg子过程访问,%esp寄存器的值也随之改变为0x7be4。此时7d1c执行,调用readseg子过程,该子过程的第一条指令地址为0x7cd2。由于题目让我们深入到reagsec里面看看,但是reagsec只在readseg函数里面被调用了,所以必须深入到readseg。调用call时,会把bootmain下一个要执行的指令地址0x7d21压入堆栈0x7be0,所以%esp的值会变成0x7be0.

然后就开始进入readseg函数,进入readseg函数的头几个操作仍旧是跟子过程调用有关的,包括保存调用过程bootmain的栈帧信息,保存一个被调用者保存寄存器,%edi,的值。

下一条汇编语句 mov 0xc(%ebp), %edi  是要取出第二个输入参数即0x1000,4096到%edi寄存器中。

因为此时的%ebp的值为0x7bdc,在这里面存放的是bootmain过程的%ebp值,0x04(%ebp)即0x7be0存放的是bootmain的返回地址,0x08(%ebp)存放的是第1个输入参数0x10000,0xc(%ebp)存放的是第2个参数0x1000,0x10(%ebp)中存放的是第3个参数0x00。(具体原理你看完Appendix 2就会明白。)

接下来的语句如下:

可见是把第3个参数0x0存入%esi,第1个参数0x10000存入%ebx。由于这两个寄存器也是被调用者保护寄存器,所以在改变他们的值之前都会把他们之前的值压入堆栈。

下一条要执行的汇编语句为 shr $0x9 %esi

由于shr是汇编逻辑右移指令,而%esi中存放的是第3个参数值,offset,将这个offset逻辑右移9位正好等于把offset的除以512即SECTSIZE,所以就是在完成

 offset = (offset / SECTSIZE) + 1指令的其中一部分。这条c语言指令的功能是计算要读取这段区域的第一个扇区的扇区号。

下一条汇编语句:  add  %ebx, %edi

   由于%ebx中存放的是第1个参数pa,%edi中存放的是第2个参数0x1000(即4096),那么这条指令完成的就是C语言语句:

   end_pa = pa + count;

这句指令是让end_pa指向要被读取到内存的这块数据所存放的最后一个位置的地址。

下一条汇编语句:  inc %esi

  这条语句很好理解就是在完成C语言语句 offset = (offset / SECTSIZE) + 1  中的加1的部分。

下一条汇编语句: and $0xfffffe00, %ebx

  这条语句完成的是C语言语句: pa &= ~(SECTSIZE-1)   

  功能就是把pa重新定向到offset存储单元所在的扇区的起始地址。

下一条汇编语句: jmp 7cff <readseg+0x2d>

   此时开始进入while循环,即开始把外存中的数据传输到内核。为了实现while循环,汇编通常采取的方式是就是这种先jmp,然后再判断循环条件的这种方法来实现。所以这条指令首先是要进行jmp,jump到判断循环条件的语句处,0x7cff.

下两条汇编语句: cmp  %edi, %ebx

         jb 7cef <readseg+0x1d>

这条语句就是在判断当前%ebx的值和%edi的值的大小,其中%ebx存放的就是指针pa,而%edi中存放的是指针end_pa,所以它执行的C语言指令就是  while(pa < end_pa) ,  那么当%edi即end_pa仍旧大于%ebx即pa时,则jb指令(大于时跳转)会跳转回while循环的第二个语句0x7cef。

  

下三条汇编语句: push %esi 

          inc %esi

push %ebx

  这三条语句在为调用readsec函数而做准备,把输入参数先压入到堆栈中。

下面进入到readsec函数中: call 7x81<readsec>

              push %ebp

             mov %esp, %ebp

             push %edi

  首先还是过程调用时的通用操作,修改栈帧等操作。             

下一条指令: call 7x6c<waitdisk>

  这一条操作其实是调用了一个子函数,waitdisk(),这个函数用于查询当前磁盘的状态是否已经准备好进行操作。如果没有准备好,那么程序就会一直停在这里,直到磁盘准备好。

下一条指令:

  根据boot.c文件中,我们可以看到在readsect子程序中,waitdisk()操作之后需要调用一系列的outb子函数,这个子函数其实就是汇编语言中的outb汇编指令,它属于IO端口命令,IO端口指令用于向外部设备的端口输出指令,或从外部设备的端口读入数据,之前我们也介绍过。那么outb函数有两个参数,第1个参数是端口号,第2个参数是输入的值。整个函数的功能就是想该端口输出一个字节的数据

  那么具体的一个outb函数的实现如下,比如我们现在就考察第一个outb指令:outb(0x1F2, 1);  它所对应的汇编语句如下:

  

  可见应该把端口号送入%edx,把输出数据送入%al中,然后调用out汇编命令即可。

  那么后边的命令0x7c95~0x7cb9就是完成后续的所有outb操作,只不过端口号和数据有所差别。通过这些指令可以看出,系统是先想0x1F2端口送入一个值1,代表取出一个扇区,然后向0x1F3~0x1F6中送入你要读取的扇区编号的32bit表示形式。最后向0x1F7端口输出0x20指令表示要读取这个扇区。

下一条:

  那么输入完上述地址,指令到相应的端口后,就可以让磁盘自己去工作,此时系统只需调用waitdisk过程来等待磁盘完成读取。waitdisk退出后,代表数据已经被读取。然后就可以执行下一个指令了。

  下一个指令又是一个IO端口指令,insl,这个函数包含3个输入参数,port代表端口号,addr代表这个扇区存放在主存中的起始地址,cnt则代表读取的次数。

  

  图中是insl的汇编实现。

  首先0x7cbf指令会把readsect函数的第1个参数送入%edi,第1个参数是dst,即这个扇区数据存放的目的起始地址,其中readseg是把pa送给readsect作为第一个参数的。所以当前%edi中存放的是pa。 0x7cc2指令会把%ecx赋值为0x80, 0x7cc7会把%edx赋值为0x1f0。

  0x7ccc执行指令cld,用于清除方向标识,这个在前面讨论过,主要是为了能够实现串操作,串操作的含义就是连续的一串相同的操作,通常作用在连续的内存上,比如把一串字符串常量送入到某个连续地址处,此时如果采用串操作的话,每传一个字节的数据,串操作可以自动的把源操作数和目的操作数的地址加或减1。那么下一个操作就直接作用在下一个空间了。cld清除标志位,表明一个串操作完成后源操作数和目的操作数的地址加1。

  而在0x7ccd处的指令:

     repnz  insl  (%dx), %es:(%edi)

  其中首先关注一下repnz指令:

  repnz指令又叫做重复串操作指令,它是一个前缀,位于一条指令之前,这条指令将会一直被重复执行,并且直到计数寄存器的值满足某个条件。repnz指令是当计数器%ecx的值不为零是就一直重复后面的串操作指令。那么被重复调用的指令就是insl指令。

    insl (%dx), %es:(%edi)

  这个指令中,%dx中存放着要访问的端口号,0x1f0。该指令的目的就是把端口0x1f0中的数据传输给后面所指向的地址。而后面的地址采用%es:(%edi)格式,其中%edi中存放的就是要被存放的内存空间的起始地址。由于当前计数寄存器%ecx中存放的数值为128,代表我们进行128次存取操作就能读取512byte的扇区。所以每次存取4个字节。 我们可以具体调试验证下:

  刚刚执行完cld指令后,查询寄存器信息。dx中存放的0x1f0,端口号,edi中存放的是pa,0x10000,起始地址。存放在0x10000~0x10005中是:

  

   执行了一次insl操作后,发现dx中存放端口号没有改变,但是edi发生改变,值变成0x10004, 而存放在0x10000~0x10004地址处内容如下:

  

  可见0x10000到0x10004地址处发生了改变。一次操作会读取4个字节。所以我们只需要调用128次函数,就能完成512的字节的存储。

下一条:

  当完成了128次读取操作后,循环退出,执行下面三句

  

  这三句用于返回调用函数。这就是读取一个扇区readsect子函数的整个运行过程。

下一条:

  然后我们回到readseg函数,继续执行下一条命令:

  

  紧接着是两个出栈操作,当前的栈顶元素是上一轮while(pa < end_pa)循环中传递给readsect的两个输入参数,由于在下一轮调用时,这两个参数会改变,所以先把这两个值pop出来。然后进行下一句。

  

  首先把%edi %ebx中的内容进行比较,其中%edi存放的是end_pa值,而%ebx存放的是当前pa值。如果end_pa仍旧大于pa,说明该段包含多个扇区,这些扇区还没有全部读取完成。所以跳回 0x7cef。当读取到end_pa之后,准备退出readseg,最后执行readseg退出之前的操作:

  

  其中第一句是把-0xc(%ebp)这个有效地址值发送给%esp,在执行这条指令之前,通过info registers发现%esp中为0x7bd0,%ebp中为0x7bdc。可见这个操作并不会改变什么。然后就是恢复我们之前保存的 被调用者保存寄存器的值 %ebx,%esi,%edi。 最后恢复bootmain的栈帧,回到bootmain。

  回到bootmain后,由于我们这个操作读取出来的数据块是内核的第一个块,里面存放的是内核文件的elf文件头。所以我们要下一步要做的事情就是验证读出的这个文件头是不是代表一个有效的elf文件?其中一个文件如果是有效的ELF文件,那么这个文件的ELF文件头部的头四个字节EI_MAG0~EI_MAG3分别是0x7f,‘E‘,‘L‘,‘F‘。下面是验证头四个字节的代码:

  

  首先把esp的内容加上了一个0x0c,执行完成后%esp的值由0x7be4变为0x7bf0。在bootmain开始时,曾经把%esp的值减去0xc,相当于为这个过程分配一些额外的内存空间,现在这个操作把这些空间收回了。

  然后0x7d24指令把0x10000地址处的内容和$0x464c457f进行比较,其实0x464c457f正好是0x7f,‘E‘,‘L‘,‘F‘。而0x10000正好是EI_MAG0,所以这一步操作就是在比较EI_MAG0~EI_MAG30x7f,‘E‘,‘L‘,‘F‘是否相等。如果不相等,则0x7d2e则会跳转到0x7d69处,这里面存放着这种异常情况发生的话如何操作,但是一般不会走到这里。如果相等则代表这是一个有效的elf文件,所以继续往下执行。

  

  紧接着执行两条指令。他们分别对应着两个C语言语句:

  

  0x7d30指令是完成它上面的那条C语言的操作的一部分。先把ELF文件头部中e_phoff字段的值存入%ebx。这个字段中存放的是Program Header Table(段头部表)的起始地址在文件中的偏移。而这个e_phoff字段是elf头部字段中的第28个字节到第31个字节。所以汇编语句中这个字段的起始地址为0x1001C。所以%ebx中存放着Program Header Table的起始地址的偏移。

  0x7d36指定就是把e_phnum字段的值,读入到%eax中,该字段代表Program Header Table中表项的个数。

  在这里我们就知道了操作系统内核一共有多少个段,以及段表起始地址。通过info registers指令查看,%eax的值为3,%ebx的值为0x34,即52。可见操作系统内核文件一共3个段,段表起始地址在相对于内核文件起始地址的0x34单元处。而ELF文件头大小就是52,所以段表紧挨着ELF文件头。

接下来:

  

  接下来0x7d3d指令把%ebx的内容和整个内核文件的起始地址0x10000相加,得到的就是Program Header Table的起始物理地址。0x7d43操作,是把%eax的值左移5位,相当于乘以32倍。原因就是Program Header Table表中的每个表项大小为32字节,所以这步操作求出Program Header Table表一共占多少字节。

  0x7d46指令完成的操作就是,把%ebx+%eax*1的值送入到%esi,可见%esi中存放的是Program Header Table的最后一个存储单元的地址。

  

接下来:

  

  接下来就进入到for循环了,这个循环的操作是要把内核的每一段从外存取到内存。由于是循环,汇编要想实现循环,一般先进行一步jmp操作,跳转到判断终止条件处。

  

  这里就是判断循环条件的地方,其中%esi存放的是Program Header Table表尾地址,%ebx存放的是当前访问到Program Header Table中的位置。如果%esi > %ebx,代表还没有取出所有段,自然要跳回循环开始处0x7d4b。

  0x7d4b ~ 0x7d5c就是在完成main.c中for循环里面的唯一操作:

     readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

  这一个步操作的意思:

  ph当前存放的是一个Program Header Table中一个表项的起始地址。ph->p_pa字段就是p_paddr字段,代表这个段的将要被存放在这个系统的内存中的起始物理地址。ph->p_memsz字段,代表这个段被实际的装入内存后,它所占用的内存大小。ph->p_offset字段,代表这个段的起始地址距离整个内核文件起始地址的偏移。所以这个C语言语句的含义就是把这个表项所代表的段存放到ph->p_pa字段的所指定的内存地址处。

  至于汇编程序也和前面在bootmain刚开始的时候调用readseg函数的方式类似。0x7d4b~0x7d51是在把readseg的输入参数压入栈中。0x7d54是修改ph指针,让它指向下一个Program Header Table中的表项。0x7d57调用readseg函数。0x7d5c指令是修改%esp,因为之前在输入参数时一共输入3个长度为4字节的参数,所以%esp减少了0x0c,这里让%esp的值恢复。0x7d5f~0x7d61 判断循环条件。

  

  然后就是循环执行完成的操作了,循环执行完成后,操作系统内核中所有的指令,数据都已经转移到内存中。下面就要执行最后一步的操作:

    ((void (*)(void)) (ELFHDR->e_entry))();

  其中ELF文件头的e_entry字段的含义是这个可执行文件的第一条指令的虚拟地址。所以这句话的含义就是把控制权转移给操作系统内核。

  

  以上就是对整个Exercise1.3的完整解答~

  如果有疑问或者建议欢迎骚扰

    [email protected]

时间: 2024-10-15 04:09:44

MIT 6.828 JOS学习笔记5. Exercise 1.3的相关文章

MIT 6.828 JOS学习笔记9. Exercise 1.5

Lab 1 Exercise 5 再一次追踪一下boot loader的一开始的几句指令,找到第一条满足如下条件的指令处: 当我修改了boot loader的链接地址,这个指令就会出现错误. 找到这样的指令后,把boot loader的链接地址修改一下,我们要在boot/Makefrag文件中修改它的链接地址,修改完成后运行  make clean, 然后通过make指令重新编译内核,再找到那条指令看看会发生什么. 最后别忘了改回来. 答: 这道题希望我们能够去修改boot loader的链接地

MIT 6.828 JOS学习笔记8. Exercise 1.4

Lab 1 Exercise 4 阅读关于C语言的指针部分的知识.最好的参考书自然是"The C Programming Language". 阅读5.1到5.5节.然后下载pointers.c的代码,并且编译运行它,确保你理解在屏幕上打印出来的所有的值是怎么来的.尤其要重点理解第1行,第6行的指针地址是如何得到的,以及在第2行到第4行的值是如何得到的,还有为什么在第5行打印出来的值看起来像程序崩溃了. 答: 首先编译运行文件pointer.c,得到如下结果: 首先程序声明了3个重要的

MIT 6.828 JOS学习笔记2. Lab 1 Part 1.2: The kernel

Lab 1 Part 1: PC bootstrap 我们继续~ PC机的物理地址空间 这一节我们将深入的探究到底PC是如何启动的.首先我们看一下通常一个PC的物理地址空间是如何布局的:                           这张图仅仅展示了内存空间的一部分. 第一代PC处理器是16位字长的Intel 8088处理器,这类处理器只能访问1MB的地址空间,即0x00000000~0x000FFFFF.但是这1MB也不是用户都能利用到的,只有低640KB(0x00000000~0x00

MIT 6.828 JOS学习笔记7. Lab 1 Part 2.2: The Boot Loader

Lab 1 Part 2 The Boot Loader Loading the Kernel 我们现在可以进一步的讨论一下boot loader中的C语言的部分,即boot/main.c.但是在我们分析之前,我们应该先回顾一些关于C语言的基础知识. Exercise 4: 阅读关于C语言的指针部分的知识.最好的参考书自然是"The C Programming Language". 阅读5.1到5.5节.然后下载pointers.c的代码,并且编译运行它,确保你理解在屏幕上打印出来的所

MIT 6.828 JOS学习笔记17. Lab 3.1 Part A User Environments

Introduction 在这个实验中,我们将实现操作系统的一些基本功能,来实现用户环境下的进程的正常运行.你将会加强JOS内核的功能,为它增添一些重要的数据结构,用来记录用户进程环境的一些信息:创建一个单一的用户环境,并且加载一个程序运行它.你也可以让JOS内核能够完成用户环境所作出的任何系统调用,以及处理用户环境产生的各种异常. Part A: User Environments and Exception Handling 新包含的文件inc/env.h里面包含了JOS内核的有关用户环境(

MIT 6.828 JOS学习笔记16. Lab 2.2

Part 3 Kernel Address Space JOS把32位线性地址虚拟空间划分成两个部分.其中用户环境(进程运行环境)通常占据低地址的那部分,叫用户地址空间.而操作系统内核总是占据高地址的部分,叫内核地址空间.这两个部分的分界线是定义在memlayout.h文件中的一个宏 ULIM.JOS为内核保留了接近256MB的虚拟地址空间.这就可以理解了,为什么在实验1中要给操作系统设计一个高地址的地址空间.如果不这样做,用户环境的地址空间就不够了. Permission and Fault

MIT 6.828 JOS学习笔记4. Lab 1 Part 2.1: The Boot Loader

Part 2: The Boot Loader 对于PC来说,软盘,硬盘都可以被划分为一个个大小为512字节的区域,叫做扇区.一个扇区是一次磁盘操作的最小粒度.每一次读取或者写入操作都必须是一个或多个扇区.如果一个磁盘是可以被用来启动操作系统的,就把这个磁盘的第一个扇区叫做启动扇区.这一部分介绍的boot loader程序就位于这个启动扇区之中.当BIOS找到一个可以启动的软盘或硬盘后,它就会把这512字节的启动扇区加载到内存地址0x7c00~0x7dff这个区域内. 对于6.828,我们将采用

MIT 6.828 JOS学习笔记10. Lab 1 Part 3: The kernel

Lab 1 Part 3: The kernel 现在我们将开始具体讨论一下JOS内核了.就像boot loader一样,内核开始的时候也是一些汇编语句,用于设置一些东西,来保证C语言的程序能够正确的执行. 使用虚拟内存 在运行boot loader时,boot loader中的链接地址(虚拟地址)和加载地址(物理地址)是一样的.但是当进入到内核程序后,这两种地址就不再相同了. 操作系统内核程序在虚拟地址空间通常会被链接到一个非常高的虚拟地址空间处,比如0xf0100000,目的就是能够让处理器

MIT 6.828 JOS学习笔记6. Appendix 1: 实模式(real mode)与保护模式(protected mode)

在我们阅读boot loader代码时,遇到了两个非常重要的概念,实模式(real mode)和保护模式(protected mode). 首先我们要知道这两种模式都是CPU的工作模式,实模式是早期CPU运行的工作模式,而保护模式则是现代CPU运行的模式. 但是为什么现代CPU在运行boot loader时仍旧要先进入实模式呢?就是为了实现软件的向后兼容性不得已才这样的. 下面我们分别看下这两种工作模式的基本原理. 实模式(real mode) 实模式出现于早期8088CPU时期.当时由于CPU