Lab1:練習四——分析bootloader載入ELF格式的OS的過程

練習四:分析bootloader載入ELF格式的OS的過程。

1.題目要求

通過閱讀bootmain.c,了解bootloader如何載入ELF文件。通過分析源程式碼和通過qemu來運行並調試bootloader&OS,

  • bootloader如何讀取硬碟扇區的?
  • bootloader是如何載入ELF格式的OS?

提示:可閱讀「硬碟訪問概述」,「ELF執行文件格式概述」這兩小節。

2.整個流程

假定進入了保護模式之後,bootloader需要能夠載入ELF文件。因為kenerl(就是ucore os)是以ELF的形式存在硬碟上的。

bootloader如何讀取硬碟扇區的?就是說boot loader能夠訪問硬碟,bootloader把硬碟數據讀取出來之後,要把其中ELF格式文件給分析出來。從而知道ucore它的程式碼段應該放在什麼地方,應該有多大一塊空間放這個程式碼段數據。哪一段空間是放數據段的數據,然後把它載入到記憶體中去,同時還知道跳轉到ucore哪個位置去執行。

讀取扇區是readsect函數,用到了in b,out b這種機器指令。in b,out b的實現都是內聯彙編來實現的,它採取了一種IO空間的地址定址方式,能夠把外設的數據給讀到記憶體中來,這也是x86裡面的定址方式。除了正常的memory方式之外,還有IO這一種定址方式。

readsect函數這一塊其實不用仔細去看,只需要知道bootloader從哪開始把相應的扇區給讀進來,記憶它讀多大,讀完之後它就需要去進一步的分析。這個分析呢,需要去了解相應的ELF格式。

在bootmain函數中,有對ELF的格式判斷,它怎麼知道都進來這個扇區的數據是一個ELF格式的文件呢?它其實是讀取了ELF的header,然後判斷它的一個特殊的成員變數e_magic,看它是否等於一個特定的值,就認為確實是一個合法的ELF格式的文件。

在bootmain.c中有更詳細的把ELF文件讀取進來的一段判斷。它怎麼能夠根據ELFheader和proghdr程式頭來讀出相應的程式碼段和數據段,然後加到相應的地方去。

最後一句image-20220503112407992

就是決定了bootloader把這個載入完之後,到底跳轉到什麼地方去,把控制權交給ucore去執行

3.預備知識

3.1ELF文件格式

ELF(Executable and linking format)文件格式是Linux系統下的一種常用目標文件(object file)格式,有三種主要類型:

  • 用於執行的可執行文件(executable file),用於提供程式的進程映像,載入到記憶體執行。 這也是本實驗的OS文件類型。
  • 用於連接的可重定位文件(relocatable file),可與其它目標文件一起創建可執行文件和共享目標文件。
  • 共享目標文件(shared object file),連接器可將它與其它可重定位文件和共享目標文件連接成其它的目標文件,動態連接器又可將它與可執行文件和其它共享目標文件結合起來創建一個進程映像。

ELF文件結構:

image-20220504175154533

首先,ELF文件格式提供了兩種視圖,分別是鏈接視圖和執行視圖。
鏈接視圖是以節(section)為單位,執行視圖是以段(segment)為單位。鏈接視圖就是在鏈接時用到的視圖,而執行視圖則是在執行時用到的視圖。上圖左側的視角是從鏈接來看的,右側的視角是執行來看的。可以看出,一個segment可以包含數個section。
本文關注執行,結構體Proghdr是用於描述段 (segment) 的 program header,可有多個。

ELF header在文件開始處描述了整個文件的組織。ELF的文件頭包含整個執行文件的控制結構。

兩個結構體都定義在elf.h中:

struct elfhdr {		 //ELF文件頭
  uint magic;  		// must equal ELF_MAGIC
  uchar elf[12];
  ushort type;
  ushort machine;
  uint version;
  uint entry;  		// 程式入口的虛擬地址
  uint phoff;  		// program header起始位置
  uint shoff;		//section header起始位置
  uint flags;
  ushort ehsize;	// ELF文件頭本身大小
  ushort phentsize;
  ushort phnum;  	// program header個數
  ushort shentsize;
  ushort shnum;
  ushort shstrndx;
};
struct proghdr {//程式表頭
  uint type;   	// 段類型
  uint offset;  // 段相對於ELF文件開頭的偏移
  uint va;     	// 段的第一個位元組將被放到記憶體中的虛擬地址
  uint pa;// 物理地址
  uint filesz;
  uint memsz;  	// 段在記憶體映像中佔用的位元組數,就是在記憶體中的大小
  uint flags;	// 讀,寫,執行許可權
  uint align;
};

bootmain()函數的作用是載入 ELF格式的ucore作業系統,

3.2 bootmain()函數

#include <defs.h>
#include <x86.h>
#include <elf.h>

/* *********************************************************************
 * 這是一個非常簡單的引導載入程式,它的唯一工作就是引導
 * 來自第一個IDE硬碟的ELF內核映像
 *
 * 磁碟布局
 * 這個程式(bootasm).S和bootmain.c是引導載入程式。
 * 應該存儲在磁碟的第一個扇區。
 *
 *  *第二個扇區包含內核映像。
 *
 *  * 內核映像必須是ELF格式。
 *
 * 開機步驟
 *  * 當CPU啟動時,它將BIOS載入到記憶體中並執行它
 *
 *  * BIOS初始化設備,設置中斷常式,以及
 *    讀取啟動設備(硬碟)的第一個扇區
 *    進入記憶體並跳轉到它。
 *
 *  * Assuming this boot loader is stored in the first sector of the
 *    hard-drive, this code takes over...
 *
 *  * 控制啟動bootasm.S -- 設置保護模式,
 *    和一個堆棧,C程式碼然後運行,然後調用bootmain()
 *
 *  * bootmain()在這個文件中接管,讀取內核並跳轉到它
 * */
// 扇區(sector)大小512
unsigned int    SECTSIZE  =      512 ; 
// 將0x10000設為內核起始地址
struct elfhdr * ELFHDR    =      ((struct elfhdr *)0x10000) ;     // scratch space

/* waitdisk - wait for disk ready */
static void
waitdisk(void) {
    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);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    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);
}

/* *
 * readseg - read @count bytes at @offset from kernel into virtual address @va,
 * might copy more than asked.
 * */
//讀取segment
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {
    uintptr_t end_va = va + count;

    // round down to sector boundary
    va -= offset % SECTSIZE;

    // translate from bytes to sectors; kernel starts at sector 1
    uint32_t secno = (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.
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // read the 1st page off disk
    // 從 0 開始讀取 8*512 = 4096 byte 的內容到 ELFHDR
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF?
    // 通過儲存在頭部的e_magic判斷是否是合法的ELF文件
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
     // 獲得程式頭表的起始位置 ph
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    // 獲取程式頭表結束的位置 eph
    eph = ph + ELFHDR->e_phnum;
    
    // 按照描述表將ELF文件中數據載入記憶體
    for (; ph < eph; ph ++) {
        // 根據每個 program header 讀取 segment
        // 從 p_offset 開始拷貝 p_memsz 個 byte 到 p_pa
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    // ELF文件0x1000位置後面的0xd1ec比特被載入記憶體0x00100000 
   // ELF文件0xf000位置後面的0x1d20比特被載入記憶體0x0010e000 
   // 根據ELF頭部儲存的入口資訊,找到內核的入口
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
	//跳到內核程式入口地址,將cpu控制權交給ucore內核程式碼
bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}

bootasm.S完成了bootloader的大部分功能,包括打開A20,初始化GDT,進入保護模式,更新段暫存器的值,建立堆棧

接下來bootmain完成bootloader剩餘的工作,就是把內核從硬碟載入到記憶體中來,並把控制權交給內核。

現在看不懂這個函數具體怎麼實現的沒關係,後面會有具體的解釋。只需要知道它的功能就行。

4. 問題解答

4.1問題一:bootloader如何讀取硬碟扇區的?

讀硬碟扇區的程式碼如下:

/* 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(使用內聯彙編實現),設置讀取扇區的數目為1
    outb(0x1F2, 1);                         // count = 1
    outb(0x1F3, secno & 0xFF);
    outb(0x1F4, (secno >> 8) & 0xFF);
    outb(0x1F5, (secno >> 16) & 0xFF);
    outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);
    outb(0x1F7, 0x20);                      // cmd 0x20 - read sectors
	// 上面四條指令聯合制定了扇區號  
	// 在這4個位元組聯合構成的32位參數中  
    // 29-31位強制設為1  
    // 28位(=0)表示訪問"Disk 0"  
    // 0-27位是28位的偏移量
    
    // wait for disk to be ready
    waitdisk();
	//將扇區內容載入到記憶體中虛擬地址dst
    // read a sector
    insl(0x1F0, dst, SECTSIZE / 4);//也用內聯彙編實現
}

就是把硬碟上的kernel,讀取到記憶體中

outb()可以看出這裡是用LBA模式的PIO(Program IO)方式來訪問硬碟的(即所有的IO操作是通過CPU訪問硬碟的IO地址暫存器完成)。從磁碟IO地址和對應功能表可以看出,該函數一次只讀取一個扇區。  

IO地址 功能
0x1f0 讀數據,當0x1f7不為忙狀態時,可以讀。
0x1f2 要讀寫的扇區數,每次讀寫前,你需要表明你要讀寫幾個扇區。最小是1個扇區
0x1f3 如果是LBA模式,就是LBA參數的0-7位
0x1f4 如果是LBA模式,就是LBA參數的8-15位
0x1f5 如果是LBA模式,就是LBA參數的16-23位
0x1f6 第0~3位:如果是LBA模式就是24-27位 第4位:為0主盤;為1從盤
0x1f7 狀態和命令暫存器。操作時先給命令,再讀取,如果不是忙狀態就從0x1f0埠讀數據

其中insl的實現如下:

// x86.h
static inline void
insl(uint32_t port, void *addr, int cnt) {
    asm volatile (
            "cld;"
            "repne; insl;"
            : "=D" (addr), "=c" (cnt)
            : "d" (port), "0" (addr), "1" (cnt)
            : "memory", "cc");
}

讀取硬碟扇區的步驟:

  1. 等待硬碟空閑。waitdisk的函數實現只有一行:while ((inb(0x1F7) & 0xC0) != 0x40),意思是不斷查詢讀0x1F7暫存器的最高兩位,直到最高位為0、次高位為1(這個狀態應該意味著磁碟空閑)才返回。
  2. 硬碟空閑後,發出讀取扇區的命令。對應的命令字為0x20,放在0x1F7暫存器中;讀取的扇區數為1,放在0x1F2暫存器中;讀取的扇區起始編號共28位,分成4部分依次放在0x1F3~0x1F6暫存器中。
  3. 發出命令後,再次等待硬碟空閑。
  4. 硬碟再次空閑後,開始從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.

4.2 問題二:bootloader如何載入ELF格式的OS

  1. 從硬碟讀了8個扇區數據到記憶體0x10000處,並把這裡強制轉換成elfhdr使用;
  2. 校驗e_magic欄位;
  3. 根據偏移量分別把程式段的數據讀取到記憶體中。

之前已經看了readsect函數, readsect從設備的第secno扇區讀取數據到dst位置

static void readsect(void *dst, uint32_t secno)

readseg簡單包裝了readsect,可以從設備讀取任意長度的內容。

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

    // round down to sector boundary
    va -= offset % SECTSIZE;

    // translate from bytes to sectors; kernel starts at sector 1
    uint32_t secno = (offset / SECTSIZE) + 1;
		// 加1因為0扇區被引導佔用
        // ELF文件從1扇區開始
  
    for (; va < end_va; va += SECTSIZE, secno ++) {
        readsect((void *)va, secno);
    }
}

最後是bootmain函數:

/* bootmain - the entry of bootloader */
void
bootmain(void) {
    // read the 1st page off disk
    // 從 0 開始讀取 8*512 = 4096 byte 的內容到 ELFHDR
    readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);

    // is this a valid ELF?
    // 通過儲存在頭部的e_magic判斷是否是合法的ELF文件
    if (ELFHDR->e_magic != ELF_MAGIC) {
        goto bad;
    }

    struct proghdr *ph, *eph;

    // load each program segment (ignores ph flags)
     // 獲得程式頭表的起始位置 ph
    ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);
    // 獲取程式頭表結束的位置 eph
    eph = ph + ELFHDR->e_phnum;
    
    // 按照描述表將ELF文件中數據載入記憶體
    for (; ph < eph; ph ++) {
        // 根據每個 program header 讀取 segment
        // 從 p_offset 開始拷貝 p_memsz 個 byte 到 p_pa
        readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);
    }

    // call the entry point from the ELF header
    // note: does not return
    // ELF文件0x1000位置後面的0xd1ec比特被載入記憶體0x00100000 
   // ELF文件0xf000位置後面的0x1d20比特被載入記憶體0x0010e000 
   // 根據ELF頭部儲存的入口資訊,找到內核的入口
    ((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();
	//跳到內核程式入口地址,將cpu控制權交給ucore內核程式碼
bad:
    outw(0x8A00, 0x8A00);
    outw(0x8A00, 0x8E00);

    /* do nothing */
    while (1);
}