MIT6.828 Lab1 The Boot Loader

环境

ubuntu 20.04 64位系统

part2的内容是从./lab/obj/kernel.img中引导我们的系统JOS,本文很大一部分内容都是翻译Lab1
课程的原地址:MIT-6.828

预先需要的知识:

虽然mit这课提供了相关知识,但是我个人觉得,对保护模式有过实操以后,看相关的保护模式代码确实很容易懂很多。

  1. 汇编,要知道基本的语句是干嘛的,比如说jne,jl等等,推荐王爽《汇编原理》
  2. 了解x86保护模式的基本工作,这里推荐《x86-实模式到保护模式》
  3. 基本的C语言操作,了解编译连接等基础知识

本文涉及的Exercise:

  • exercise 3:这个是和调试相关的,做了
  • exercise 4: 这个是和指针(pointer)相关的学习,没做
  • exercise 5:这个是和了解链接地址相关的,做了
  • exercise 6:查看加载内核前后内存的区别,做了

正文

软盘或者硬盘的存储单元都是512字节的,每512一个字节称为一个扇区。512字节是disk最小的传输粒度(transfer granularity),也就是说每次往硬盘写入的或者读出的数据都是512字节的整数倍的,比如说1024字节(2个扇区)。如果一个硬盘是可引导的(bootable),那么他的第一个扇区叫做引导扇区(MBR就是这个东西),在这个扇区内部存放着boot loader。当BIOS发现一个软盘(floppy)或者一个硬盘是可引导的时候,BIOS会将引导扇区 (boot sector)内的bootloeader复制到内存的0x7c00处(cs:ip=0x0000:0x7c00),然后BIOS会执行一个jmp 0x0000:0x7c00,接着跳转到了引导扇区的代码去执行。

PS:上面这段基本是翻译lab1 part2的说明,实际情况应该和这里说的有点点不一样。因为Boot sector只有很小的一点,就512字节。可能是因为这个lab1的代码也很少,所以直接在boot sector里面引导内核。科学的做法应该是boot sector里面的代码引导boot loader,boot loader去将内核引导到内存当中

接上面正文,用CD-ROMs内的引导扇区能做工作更多,因为CD-ROMs一个扇区有2048字节,但是再MIT6.828这门课里面,我们就是使用的传统的512字节位扇区单位的硬盘。在我们的代码中boot loader 由两个文件组合而成,分别是./boot/boot.S./boot/main.c,boot loader主要完成下面两个工作:

  1. Boot loader将处理器从实模式切换到保护模式,因为在实模式之下处理器只能访问0-1MB的内存,切换到保护模式才使得处理可以访问更大的内存。这里不详细说明保护模式和实模式的区别,保护模式的编程可参考《X86-从实模式到保护模式》这本书
  2. 接下来boot loader会通过汇编指令从硬盘当中将kernel读入到内存当中。具体的和交互的工作比较无聊,等做完所有的系列看看有时间再来补充吧。

接下来是本节课的重点,就是几个exercise的实验

Exercise 3

使用GDB调试下boot loader,设置breakpoint在0x7c00处,逐步调试boot loead的代码。并且对照着反编译的obj/boot/boot.asm来查看当前正在运行的到那一步了。接着观察在./boot/main.c的当中代码的运行,包括函数readsect(),追踪readsect(),看看从硬盘读取kernel的代码。

并且回答下列问题:

  • 在哪里代码开始运行32位的代码? 是什么让处理器从16位切换到32位运行?
  • 哪一条语句是boot loader最后一条执行的?哪一条语句是kernel最先被加载的?
  • 哪一条语句是kernel中最先执行的语句?
  • boot loader如何知道内核有多少个扇区大小?它是怎么知道的?

接下来一个一个实验解决这些问题吧。

在哪里代码开始运行32位的代码? 是什么让处理器从16位切换到32位运行?
首先,先使用b *0x7c00设置一个断点,然后按下键盘上的c,GDB就会之习惯到0x7c00处并且停在那里,这是GDB调试设置断点的方式,前面我们说到0x7c00这个地址就会开始执行boot loader的代码。结果如下:

boot loader的最开始的代码

GDB截图:
GDB调试的结果

通过截图可以看到,此时GDB执行的语句正是我们boot loader的第一条语句--cli (我不再赘述具体汇编指令的作用)
接下来在GDB输入si,他就会执行接下去的命令。
接下来先对boot.S这个文件做一些介绍注释

#include 

# .set是AT&T的语法,和x86的equ是一个意思,就是生命一些常量,在变异的时候会被直接替换为对应的值
.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                         # 清除中断标志
  cld                         # 字符串复制的方向,递增复制

  # 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.
#下面是处理键盘的一些工作以及开启A20地址线
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

  # 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.

  # 加载GDT到gdtr寄存器,并且将cr0寄存器的最低位设置为1
# 这样就开启了保护模式
  lgdt    gdtdesc
  movl    %cr0, %eax
  orl     $CR0_PE_ON, %eax
  movl    %eax, %cr0
  
 #跳转到保护模式下的代码去执行
  ljmp    $PROT_MODE_CSEG, $protcseg

上面的那几行是和键盘处理相关的,具体的意思看这里键盘处理。上面这几行代码就完成了保护模式的开启。关键是以下几条代码:

  lgdt    gdtdesc
  movl    %cr0, %eax
  orl     $CR0_PE_ON, %eax
  movl    %eax, %cr0

回到我们最初的问题,是在哪里完成了实模式到保护模式的切换呢? 答案就是下面一行代码完成了实模式到保护模式的跳转

 ljmp    $PROT_MODE_CSEG, $protcseg

boot.S当中,其他的代码,稍微解释下

#下面几个SET都调用了宏函数,具体的实现在./lab/inc/mmu.h当中
gdt:
  SEG_NULL              # null seg
  SEG(STA_X|STA_R, 0x0, 0xffffffff) # code seg
  SEG(STA_W, 0x0, 0xffffffff)           # data seg

上面是初始化了几个需要的描述符,在这里代码段描述符和数据段描述符都是平坦(flat)的,也就是说他们的地址范围都是从0~4GB。因此eip寄存器当中的地址实际上就是真正的物理地址。还需要注意一点就是,cs:ip组成的地址在保护模式下叫做线性地址(linear address),在引入页式内存管理的时候,那个地址叫做虚拟地址virtual address。在那种情况下,真正的物理地址需要经过如下的转换: virtual address > linear address > physical address。
好,回到正题,我们现在已经知道了,通过对cr0寄存器的设置,在使用jmp跳转,就进入了保护模式,现在我们来观察下实验结果:

实模式到保护模式的跳转

在ljmp这条指令前面的地址是[0:7c2d],我们可以到此时的寻址方式还是最简单的8086的cs:ip模式,在Jmp之后,下一条指令mov,前面的地址已经是0x7c32,成功了,我们已经完成了从实模式到保护模式的跳转,第一个问题解决完毕。

boot loader最后一条执行的命令是什么? kernel第一条执行的命令是什么?
按照boot.S的代码,我们来看下最后两条非常关键的代码

  movl    $start, %esp
  call bootmain

call bootmain,这个就会去执行main.C当中的代码。好了接下来我们还一个参照物,对照文件./lab/obj/boot/boot.asm来查看我们代码的运行。
首先运行的是main.c中的bootmain()这个函数,在bootmain()函数中去调用了readseg函数

readseg((uint32_t) ELFHDR, SECTSIZE*8, 0)

它有三个参数,所以将三个参数都压入到栈当中,ELFHDR=0x10000,SECTSIZE*8=4096,也就是对应的
十六进制0x1000,第三个参数是0。根据C calling convention,右边的参数先压栈,所以在汇编代码中
我们可以看到第一个压栈的是0,第二个0x01000,第三个0x10000。

    readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);
    7d2e:   52                      push   %edx
    7d2f:   6a 00                   push   $0x0
    7d31:   68 00 10 00 00          push   $0x1000
    7d36:   68 00 00 01 00          push   $0x10000
    7d3b:   e8 a2 ff ff ff          call   7ce2 

本来想打算调试这一串的所有汇编语句的,后来一想工作量太大了,要逐个去看寄存器的内容来判断参数的变化。我打算对main.c当中的语句做一个详细的注解。

//扇区的大小
#define SECTSIZE    512

//ELF header在内存当中临时存放的地址
#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;
        //将系统kernel文件中偏移量的4096字节数据读到0x10000处,
        //这是因为readelf -l kernel显示第一个需要被加载的段的地址在偏移两0x1000处,说明前面全部都是
        //ELF header的内容,所以我们先将ELF header的内容读到内存当中
    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)
        // e_phoff得到的是0x34,ELFHDR + ELFHDR->e_phoff = 0x10034,这个地址就是第一个program header的地址
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff); 

    //eph是program table entry的个数, ELFHDR->e_phnum
        //这里决定了要循环多少次
    eph = ph + ELFHDR->e_phnum;
    for (; ph < eph; ph++)
        // p_pa is the load address of this segment (as well
        // as the physical address)
                //根据readelf -l kernel的输出结果,第一个段的offset = 0x1000,p_pa = 0x00100000,size = 0x07dac
                // 所以我们要做的就是,系统镜像偏移0x1000处读取0x07dac个字节的数据到0x00100000
                // 在readseg函数中,我们将offset转为真正的扇区号,因为镜像是存放在第1扇区开始的,所以
                //这个是可以计算的
        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 */;
}

readelf -l kernel 结果,结果显示3 program headers,第一个program header的offset是在0x1000,说明0x1000前面都是elf header的内容:

Elf file type is EXEC (Executable file)
Entry point 0x10000c
There are 3 program headers, starting at offset 52

Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0xf0100000 0x00100000 0x07dac 0x07dac R E 0x1000
  LOAD           0x009000 0xf0108000 0x00108000 0x0b6a8 0x0b6a8 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10

 Section to Segment mapping:
  Segment Sections...
   00     .text .rodata .stab .stabstr 
   01     .data .got .got.plt .data.rel.local .data.rel.ro.local .bss 
   02    

在解释一下另外两个函数

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不一定都是512字节对齐的,我们将pa做一个512字节对齐
    //下面进行一个举例,例如pa=700,~(512-1) = 0x1110_0000_0000
    //700 & 0x1110_0000_0000 = 0x200 = 512,这样我们就做到了对齐
    pa &= ~(SECTSIZE - 1);
        
        //使用偏移量来计算所要读取的扇区是哪个
//,比如说上面的offset = 0x1000,0x1000/512+1 = 9,就是读取9号扇区。
//扇区号+1这个应该是这样理解的,
//硬盘扇区默认就是从1扇区开始计算的。
//如果我们要读取511字节的内容,511/512=0,肯定是不对的。所以要+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);

        //物理地址+512字节
        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();

    //使用LBA模式的逻辑扇区方式来寻找扇区,这里和硬件相关
      //就暂且不要管好了,涉及到的硬件细节太多了,确实很麻烦
    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
    //一次读取的单位是四个字节,所以循环sectorsize/4 = 128次
    insl(0x1F0, dst, SECTSIZE/4);
}

好了,终于要回归问题了,首先回答boot loader最后执行的一条指令是什么? 上面已经说了 ((void (*)(void)) (ELFHDR->e_entry))();这条语句会跳转到内核当中去执行,那么这条语句就是boot loader最后一条语句了。我们来验证一下。

跳转到内核

如图call 0x10018这里存放的就是entry,0x10018这个地址是这样得到的,在ELF header 当中偏移0x18存放的是entry,前面我们将ELF header放到了0x10000处,所以自然entry的地址就是0x10018了。在0x10018中存放的是0x10000c,call命令执行完后,我们就到了0x10000c执行了。我们使用readelf -a kernel命令查看以下:
entry

用mit网站上哪个objdump -x也一样,我们可以看到,我们的镜像的entry确实是0x10000c。让我们乘胜追击,解决下一个问题,kernel当中第一句执行的命令是什么?
用一下上面的图,我们可以看到内核当中第一句执行的命令是movw 0x1234 0x472,如下图所示:

第一条执行的命令

第三个问题有点多余,第二个搞定了第三个就懂了,下面我们回答第三个问题
boot loader是如何知道内核占据多少个扇区的?
其实答案就在上面的代码里面,在函数readseg()当中,我们使用条件pa < end_pa来判断是否还要继续读取数据,如果条件成立就读下一个扇区的内容。并且我们使用offset来控制需要读的扇区号。

Exercise 5

回答下列问题:

  • 修改boot/Makefrag中的link address,继续跟踪boot loader的一些代码,看看boot loader会做一些什么错误的事情呢?

一个程序由普通的文本文件.c到可执行文件,中间需要经过一个很重要的过程就是链接(link),说实话我对于链接需要做的工作为并不了解很多,我就指出一点比较重要的在汇编程序当中,标号的地址会因为连接地址的不同而发生变化,暂时不理解没关系,首先需要得到这样一个概念链接地址关乎着标号的地址,对于普通的汇编代码并没有直接的影响
开始实验:
首先先看下原来的boot/Makefrag文件中的内容(截取了关键的部分):

$(OBJDIR)/boot/boot: $(BOOT_OBJS)
    @echo + ld boot/boot
    $(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7c00 -o [email protected] $^
    $(V)$(OBJDUMP) -S [email protected] >[email protected]
    $(V)$(OBJCOPY) -S -O binary -j .text [email protected] $@
    $(V)perl boot/sign.pl $(OBJDIR)/boot/boot

上面的,-Ttext说明了链接的地址,即0x7c00,先用gdb调试下看下未修改的情况:

未修改前

lgdt命令应该是将gdtdesc这个地址加载道gdtr寄存器,由于我这里有点问题,我运行的看不到gdtdesc标号的地址,如下:
我的运行情况

但是这里的意思是如此的,我用其他相类似的地方来描述这个问题,我选了上面键盘处理的jz指令来说明:
键盘处理的代码

可以看到jz经过编译后,就变成了jne指令了,跳转的目的地址就是0x7c14。好我们现在将上面boot/Makefrag当中的0x7c00修改为0x7e00在来看看这里的变化,注意别忘了make clean,然后重新make,如下图所是,不知道是不是因为系统环境的关系,gdb调试中Jne跳转的标号没有发生变化,但是jmp到保护模式确实发生了异常,我没有研究原因。但是在./boot/boot.asm当中标号的地址已经发生了变化,下面可以看到已经由0x7c14变成了0x7e14:
修改为0x7e00的结果

另外,因为我们现在link address的不同,所以lgdt加载的标号是错误的地址,所以应该无法正确的进入保护模式,实验结果观察到也确实是如此,结果如下图:
跳入保护模式

可以看到,现在根本无法正常进入保护模式,回到Bios的代码去了。所以我们的实验成功了!

Exercise 6

使用GDB调试命令,x/Nx,N是要查看的字(word)的个数,warning:字(word)的长度没有一个统一的标准,在GNU汇编当中(意思就是是说在AT&T的语法下,其实x86也是一样的),一个字(word)的长度应该是两个字节。问题是:查看以下在进入boot loader的时候,0x100000地址出的内容,以及进入内核后0x100000处的内容。

实际上这个问题并不需要我们去认真的debug,只要认真思考下,在刚进入boot loader的时候,x100000这个地方应该是空空如也的,因为内核还没有被加载。根据readelf -l kernel的结果,我们知道内核被加载到的地方是0x100000。所以当刚进入到内核后,0x100000这个地方就是内核的代码了。

结束

好了,到这里为止,lab 1 part2所有的exercise都已经做完了,通篇下来,想必对加载elf文件的内核有了很大的了解。接下来要做的就是lab 1 part3 了!!我在这个实验当中也有一些不了解的地方,比如说上面的lgdt指令和预期的结果不同。希望知道如何解决的老铁门相互交流,另外如果发现有错误的地方,希望各位提醒我修改~

你可能感兴趣的:(MIT6.828 Lab1 The Boot Loader)