《ucore lab1 练习4》实验报告

时光总嘲笑我的痴心妄想 提交于 2019-12-02 14:44:49

[练习4]分析bootloader加载ELF格式的OS的过程

通过阅读bootmain.c,了解bootloader如何加载ELF文件。通过分析源代码和通过qemu来运行并调试bootloader&OS,理解:

  • 1.bootloader如何读取硬盘扇区的?
  • 2.bootloader是如何加载ELF格式的OS?

问题1:bootloader如何读取硬盘扇区

分析原理

阅读材料其实已经给出了读一个扇区的大致流程:

  • 1.等待磁盘准备好
  • 2.发出读取扇区的命令
  • 3.等待磁盘准备好
  • 4.把磁盘扇区数据读到指定内存

实际操作中,需要知道怎样与硬盘交互。阅读材料中同样给出了答案:所有的IO操作是通过CPU访问硬盘的IO地址寄存器完成。硬盘共有8个IO地址寄存器,其中第1个存储数据,第8个存储状态和命令,第3个存储要读写的扇区数,第4~7个存储要读写的起始扇区的编号(共28位)。了解这些信息,就不难编程实现啦。

分析代码

bootloader读取扇区的功能是在boot/bootmain.c的readsect函数中实现的,先贴代码:

static void
waitdisk(void) { //如果0x1F7的最高2位是01,跳出循环
    while ((inb(0x1F7) & 0xC0) != 0x40)
        /* do nothing */;
}
/* readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {
    // wait for disk to be ready
    waitdisk();

    outb(0x1F2, 1);        //读取一个扇区
    outb(0x1F3, secno & 0xFF);  //要读取的扇区编号
    outb(0x1F4, (secno >> 8)&0xFF);//用来存放读写柱面的低8位字节 
    outb(0x1F5, (secno >> 16)&0xFF);//用来存放读写柱面的高2位字节
          // 用来存放要读/写的磁盘号及磁头号
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);       // cmd 0x20 - read sectors

    // wait for disk to be ready
    waitdisk();

    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4); //获取数据
}

一般主板有2个IDE通道,每个通道可以接2个IDE硬盘。访问第一个硬盘的扇区可设置IO地址寄存器0x1f0-0x1f7实现的,具体参数见下表。一般第一个IDE通道通过访问IO地址0x1f0-0x1f7来实现,第二个IDE通道通过访问0x170-0x17f实现。每个通道的主从盘的选择通过第6个IO偏移地址寄存器来设置。从outb()可以看出这里是用LBA模式的PIO(Program IO)方式来访问硬盘的。从磁盘IO地址和对应功能表可以看出,该函数一次只读取一个扇区。
readseg简单包装了readsect,可以从设备读取任意长度的内容。

static void
    readseg(uintptr_t va, uint32_t count, uint32_t offset) {
        uintptr_t end_va = va + count;

        va -= offset % SECTSIZE;

        uint32_t secno = (offset / SECTSIZE) + 1; 
        // 加1因为0扇区被引导占用
        // ELF文件从1扇区开始

        for (; va < end_va; va += SECTSIZE, secno ++) {
            readsect((void *)va, secno);
        }
    }

根据代码可以得出读取硬盘扇区的步骤:

  • 等待硬盘空闲。waitdisk的函数实现只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不断查询读0x1F7寄存器的最高两位,直到最高位为0、次高位为1(这个状态应该意味着磁盘空闲)才返回。

  • 硬盘空闲后,发出读取扇区的命令。对应的命令字为0x20,放在0x1F7寄存器中;读取的扇区数为1,放在0x1F2寄存器中;读取的扇区起始编号共28位,分成4部分依次放在0x1F3~0x1F6寄存器中。

  • 发出命令后,再次等待硬盘空闲。

  • 硬盘再次空闲后,开始从0x1F0寄存器中读数据。注意insl的作用是"That function will read cnt dwords from the input port specified by port into the supplied output array addr.",是以dword即4字节为单位的,因此这里SECTIZE需要除以4.

问题2: bootloader如何加载ELF格式的OS

分析原理

首先从原理上分析加载流程。

  • bootloader要加载的是bin/kernel文件,这是一个ELF文件。其开头是ELF header,ELF Header里面含有phoff字段,用于记录program header表在文件中的偏移,由该字段可以找到程序头表的起始地址。程序头表是一个结构体数组,其元素数目记录在ELF Header的phnum字段中。

  • 程序头表的每个成员分别记录一个Segment的信息,包括以下加载需要用到的信息:

    • uint offset; // 段相对文件头的偏移值,由此可知怎么从文件中找到该Segment
    • uint va; // 段的第一个字节将被放到内存中的虚拟地址,由此可知要将该 Segment加载到内存中哪个位置
    • uint memsz; // 段在内存映像中占用的字节数,由此可知要加载多少内容
  • 根据ELF Header和Program Header表的信息,我们便可以将ELF文件中的所有Segment逐个加载到内存中

分析代码

ELF定义:

/* file header */
struct elfhdr {
    uint32_t e_magic;     // must equal ELF_MAGIC
    uint8_t e_elf[12];
    uint16_t e_type;      // 1=relocatable, 2=executable, 3=shared object, 4=core image
    uint16_t e_machine;   // 3=x86, 4=68K, etc.
    uint32_t e_version;   // file version, always 1
    uint32_t e_entry;     // entry point if executable
    uint32_t e_phoff;     // file position of program header or 0
    uint32_t e_shoff;     // file position of section header or 0
    uint32_t e_flags;     // architecture-specific flags, usually 0
    uint16_t e_ehsize;    // size of this elf header
    uint16_t e_phentsize; // size of an entry in program header
    uint16_t e_phnum;     // number of entries in program header or 0
    uint16_t e_shentsize; // size of an entry in section header
    uint16_t e_shnum;     // number of entries in section header or 0
    uint16_t e_shstrndx;  // section number that contains section name strings
};

在这里我们只需要关注其中的几个参数,e_magic,是用来判断读出来的ELF格式的文件是否为正确的格式;e_phoff,是program header表的位置偏移;e_phnum,是program header表中的入口数目;e_entry,是程序入口所对应的虚拟地址。

宏定义:

#define ELFHDR          ((struct elfhdr *)0x10000) 
#define SECTSIZE        512

bootloader加载os的功能是在bootmain函数中实现的,先贴代码:

    void
    bootmain(void) {
        // 首先读取ELF的头部
        readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

        // 通过储存在头部的幻数判断是否是合法的ELF文件
        if (ELFHDR->e_magic != ELF_MAGIC) {
            goto bad;
        }

        struct proghdr *ph, *eph;

        // ELF头部有描述ELF文件应加载到内存什么位置的描述表,
        // 先将描述表的头地址存在ph
        ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
        eph = ph + ELFHDR->e_phnum;

        // 按照描述表将ELF文件中数据载入内存
        for (; ph < eph; ph ++) {
            readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
        }
        // ELF文件0x1000位置后面的0xd1ec比特被载入内存0x00100000
        // ELF文件0xf000位置后面的0x1d20比特被载入内存0x0010e000

        // 根据ELF头部储存的入口信息,找到内核的入口
        ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();

    bad:
        outw(0x8A00, 0x8A00);
        outw(0x8A00, 0x8E00);
        while (1);
    }
  • 首先从硬盘中将bin/kernel文件的第一页内容加载到内存地址为0x10000的位置,目的是读取kernel文件的ELF Header信息。

  • 校验ELF Header的e_magic字段,以确保这是一个ELF文件

  • 读取ELF Header的e_phoff字段,得到Program Header表的起始地址;读取ELF Header的e_phnum字段,得到Program Header表的元素数目。

  • 遍历Program Header表中的每个元素,得到每个Segment在文件中的偏移、要加载到内存中的位置(虚拟地址)及Segment的长度等信息,并通过磁盘I/O进行加载

  • 加载完毕,通过ELF Header的e_entry得到内核的入口地址,并跳转到该地址开始执行内核代码

调试代码

  1. 输入make debug启动gdb,并在bootmain函数入口处即0x7d0d设置断点,输入c跳到该入口

  2. 单步执行几次,运行到call readseg处,由于该函数会反复读取硬盘,为节省时间,可在下一条语句设置断点,避免进入到readseg函数内部反复执行循环语句。(或者直接输入n即可,不用这么麻烦)

  3. 执行完readseg后,可以通过x/xw 0x10000查询ELF Header的e_magic的值,查询结果如下,确实与0x464c457f相等,所以校验成功。注意,我们的硬件是小端字节序(这从asm文件的汇编语句和二进制代码的对比中不难发现),因此0x464c45实际上对应字符串"elf",最低位的0x7f字符对应DEL。

(gdb) x/xw 0x10000
0x10000:        0x464c457f
  1. 继续单步执行,由0x7d2f mov 0x1001c,%eax可知ELF Header的e_phoff字段将加载到eax寄存器,0x1001c相对0x10000的偏移为0x1c,即相差28个字节,这与ELF Header的定义相吻合。执行完0x7d2f处的指令后,可以看到eax的值变为0x34,说明program Header表在文件中的偏移为0x34,则它在内存中的位置为0x10000 + 0x34 = 0x10034.查询0x10034往后8个字节的内容如下所示:
(gdb) x/8xw 0x10034
0x10034:        0x00000001      0x00001000      0x00100000      0x00100000
0x10044:        0x0000dac4      0x0000dac4      0x00000005      0x00001000
  • 可以结合代码中定义的Program Header结构来理解这8个字节的含义。
struct proghdr {
    uint32_t p_type;   // loadable code or data, dynamic linking info,etc.
    uint32_t p_offset; // file offset of segment
    uint32_t p_va;     // virtual address to map segment
    uint32_t p_pa;     // physical address, not used
    uint32_t p_filesz; // size of segment in file
    uint32_t p_memsz;  // size of segment in memory (bigger if contains bss)
    uint32_t p_flags;  // read/write/execute bits
    uint32_t p_align;  // required alignment, invariably hardware page size
};
  • 还可以使用readelf -l bin/kernel来查询kernel文件各个Segment的基本信息,以作对比。查询结果如下所示,可见与gdb调试结果是一致的。
Program Headers:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  LOAD           0x001000 0x00100000 0x00100000 0x0dac4 0x0dac4 R E 0x1000
  LOAD           0x00f000 0x0010e000 0x0010e000 0x00aac 0x01dc0 RW  0x1000
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
  1. 继续单步执行,由0x7d34 movzwl 0x1002c,%esi可知ELF Header的e_phnum字段将加载到esi寄存器,执行完x07d34处的指令后,可以看到esi的值变为3,这说明一共有3个segment。

  2. 后面是通过磁盘I/O完成三个Segment的加载,不再赘述。

总结一下就是:

  • 从硬盘读了8个扇区数据到内存0x10000处,并把这里强制转换成elfhdr使用;
  • 校验e_magic字段;
  • 根据偏移量分别把程序段的数据读取到内存中。
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!