1. 程式人生 > >MIT-6.828 Lab1實驗報告

MIT-6.828 Lab1實驗報告

imp 彈出 遞歸調用 switch語句 kde 信息 編譯器 化工 x86匯編

Lab1:Booting a PC

概述

本文主要介紹lab1,從內容上分為三部分,part1簡單介紹了匯編語言,物理內存地址空間,BIOS。part2介紹了BIOS從磁盤0號扇區讀取boot loader到0000:7c00處,並將cs:ip設置成0000:7c00。boot loader主要做兩件事:

  1. 創建兩個全局描述符表項(代碼段和數據段),然後進入保護模式
  2. 從磁盤加載kernel到內存

part3主要介紹進入內核後的一些操作,首先會開啟分頁模式。還介紹了格式化輸出,函數調用過程。
對應的lab主頁為:lab1

Part 1: PC Bootstrap

本課程使用的匯編使用AT&T語法,Brennan‘s Guide to Inline Assembly給出Intel語法和AT&T語法之間的一些對應關系。
物理地址內存空間可用下圖來描述:

+------------------+  <- 0xFFFFFFFF (4GB)
|      32-bit      |
|  memory mapped   |
|     devices      |
|                  |
/\/\/\/\/\/\/\/\/\/
/\/\/\/\/\/\/\/\/\/|                  |
|      Unused      |
|                  |
+------------------+  <- depends on amount of RAM
|                  |
|                  |
| Extended Memory  |
|                  |
|                  |
+------------------+  <- 0x00100000 (1MB)
|     BIOS ROM     |
+------------------+  <- 0x000F0000 (960KB)
|  16-bit devices, |
|  expansion ROMs  |
+------------------+  <- 0x000C0000 (768KB)
|   VGA Display    |
+------------------+  <- 0x000A0000 (640KB)
|                  |
|    Low Memory    |
|                  |
+------------------+  <- 0x00000000

最早期的16-bit Intel 8088處理器僅支持1MB(0x00000000~0x000FFFFF)的物理尋址能力。到了80286和80386處理器,分別支持16MB和4GB的物理尋址能力。為了做到向後兼容,保留了低1MB的內存布局。
PC通電後會設置CS為0xf000,IP為0xfff0,也就是說第一條指令會在物理內存0xffff0處,該地址位於BIOS區域的尾部。
QEMU提供了調試功能,打開兩個終端,一個在lab目錄下執行make qemu-gdb,QEMU會在執行第一條指令前暫停,等待GDB的連接。另一個終端執行make gdb執行完後會出現如下輸出

GNU gdb (GDB) 6.8-debian
Copyright (C) 2008 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i486-linux-gnu".
+ target remote localhost:26000
The target architecture is assumed to be i8086
[f000:fff0] 0xffff0:    ljmp   $0xf000,$0xe05b
0x0000fff0 in ?? ()
+ symbol-file obj/kern/kernel
(gdb) 

可以看到第一條指令確實在0xf000:0xfff0處,該條指令為ljmp $0xf000,$0xe05b跳轉到BIOS的前半部分。然後做一些初始化工作,最後從磁盤起始扇區加載512字節到物理地址0x7c00處,並用jmp指令將CS:IP設置為0x0000:0x7c00,從而進入boot loader的控制。

Part 2: The Boot Loader

boot laoder代碼在boot/boot.S和boot/main.c中,主要做了兩件事:

  1. 從實模式進入保護模式
  2. 從磁盤加載kernel

先看boot/boot.S,

  cli                         # Disable interrupts
  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

cli這條指令應該是被加載到0x7c00處的指令,也就是進入boot loader後執行的第一條指令。後面幾行主要就是設置段寄存器ds, es, ss為0。

  # 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

這幾行主要是為了開啟A20,也就是處理器的第21根地址線。在早期8086處理器上每次到物理地址達到最高端的0xFFFFF時,再加1,就又會繞回到最低地址0x00000,當時很多程序員會利用這個特性編寫代碼,但是到了80286時代,處理器有了24根地址線,為了保證之前編寫的程序還能運行在80286機子上。設計人員默認關閉了A20,需要我們自己打開,這樣就解決了兼容性問題。接著往下看:

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

lgdt這條指令的格式是lgdt m48操作數是一個48位的內存區域,該指令將這6字節加載到全局描述表寄存器(GDTR)中,低16位是全局描述符表(GDT)的界限值,高32位是GDT的基地址。”gdtdesc“被定義在第82行:

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

可以看到GDT有3項,第一項時空項,第二第三項分別是代碼段,數據段,它們的起始地址都是0x0,段界限都是0xffffffff。lgdt指令後面的三行是將CR0寄存器第一位置為1,其他位保持不變,這將導致處理器的運行變成保護模式。支持處理器已經進入保護模式。保護模式有疑問的同學可以參考《x86匯編語言-從實模式到保護模式》的第10,11章。

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

接下來的分別設置esp,然後調用bootmain函數,該函數定義在/boot/main.c中。接著bootmain函數:

    struct Proghdr *ph, *eph;

    // read 1st page off disk
    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)
    ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
    eph = ph + ELFHDR->e_phnum;
    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);

    // call the entry point from the ELF header
    // note: does not return!
    ((void (*)(void)) (ELFHDR->e_entry))();

void readseg(uint32_t pa, uint32_t count, uint32_t offset)函數從磁盤offset字節(offset相對於第一個扇區第一個字節開始算)對應的扇區開始讀取count字節到物理內存pa處。首先讀取第一個扇區的SECTSIZE*8(一頁)字節的內核文件(ELF格式)到物理內存ELFHDR(0x10000)處。接下來檢查ELF文件的魔數。如果對ELF文件格式不熟悉可以看我之前的文章ELF格式。接下來從ELF文件頭讀取ELF Header的e_phoff和e_phnum字段,分別表示Segment結構在ELF文件中的偏移,和項數。然後將每一個Segment從ph->p_offset對應的扇區讀到物理內存ph->p_pa處。
將內核ELF文件中的Segment從磁盤全部讀取到內存後,跳轉到ELFHDR->e_entry指向的指令處。正式進入內核代碼中。
這一步執行完後CPU,內存,磁盤可以抽象出下面的圖:
技術分享圖片

Part 3: The Kernel

該部分將進入內核執行,主要講三件事:

  1. 開啟分頁模式,將虛擬地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)
  2. 提供輸出格式化字符串到控制臺的能力
  3. 函數的調用過程

開啟分頁模式

操作系統經常被加載到高虛擬地址處,比如0xf0100000,但是並不是所有機器都有這麽大的物理內存。可以使用內存管理硬件做到將高地址虛擬地址映射到低地址物理內存。虛擬地址轉換為物理地址的過程可用下面的圖描述:技術分享圖片

虛擬地址的高10位(0000000010B)作為頁目錄的下標,從頁目錄中獲取頁表的物理地址0x08001000,虛擬地址的第11~20位(0000000001B)作為頁表的下標,得到該頁對應的物理地址0x0000c000,最後將虛擬地址的低12位(000001010000B或者0x50)和得到的頁的物理地址(0x0000c000)加得到0x00000c050就是虛擬地址0x00801050轉換後的物理地址。
來看/kern/entry.S:

    movl    $(RELOC(entry_pgdir)), %eax
    movl    %eax, %cr3          //cr3 寄存器保存頁目錄表的物理基地址
    # Turn on paging.
    movl    %cr0, %eax
    orl $(CR0_PE|CR0_PG|CR0_WP), %eax
    movl    %eax, %cr0          //cr0 的最高位PG位設置為1後,正式打開分頁功能

第1行將$(RELOC(entry_pgdir))的值賦給eax寄存器,entry_pgdir定義在/kern/entrypgdir.c中,是頁目錄的數據結構,將虛擬地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)

__attribute__((__aligned__(PGSIZE)))        //強制編譯器分配給entry_pgdir的空間地址是4096(一頁大小)對齊的
pde_t entry_pgdir[NPDENTRIES] = {           //頁目錄表。這是uint32_t類型長度為1024的數組
    // Map VA‘s [0, 4MB) to PA‘s [0, 4MB)
    [0]
        = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P,    //設置頁目錄表的第0項
    // Map VA‘s [KERNBASE, KERNBASE+4MB) to PA‘s [0, 4MB)
    [KERNBASE>>PDXSHIFT]
        = ((uintptr_t)entry_pgtable - KERNBASE) + PTE_P + PTE_W //設置頁目錄表的第KERNBASE>>PDXSHIFT(0xF0000000>>22)項
};

但是為什麽要RELOC(entry_pgdir)呢?RELOC這個宏的定義如下:#define RELOC(x) ((x) - KERNBASE) KERNBASE又被定義在/inc/memlayout.h中#define KERNBASE 0xF0000000。那為什麽要減0xF0000000呢?因為現在還沒開啟分頁模式,entry_pgdir這個符號代表的地址又是以0xF0000000為基址的(為什麽?沒有為什麽,這個是在鏈接時,鏈接器根據/kern/kernel.ld中的. = 0xF0100000;來指定的。可以參考《程序員的自我修養》p127-使用ld鏈接腳本)。總結來說就是etnry_pgdir結構所在的物理內存在RELOC(entry_pgdir)處。接下來將頁目錄的物理地址復制到cr3寄存器,並且將cr0 的最高位PG位設置為1後,正式打開分頁功能。

格式化輸出到控制的臺

這一小結提供了一些函數,用於將字符串輸出到控制臺。我們需要了解這些函數的原理,並且正式開始動手寫代碼。這些函數分布在kern/printf.c, lib/printfmt.c, kern/console.c中。閱讀總結出如下的調用關系:技術分享圖片

void
cputchar(int c)
{
    cons_putc(c);
}
static void
cons_putc(int c)
{
    serial_putc(c);
    lpt_putc(c);
    cga_putc(c);
}
static void
cga_putc(int c)
{
    // if no attribute given, then use black on white
    if (!(c & ~0xFF))
        c |= 0x0700;

    switch (c & 0xff) {
    case ‘\b‘:
        if (crt_pos > 0) {
            crt_pos--;
            crt_buf[crt_pos] = (c & ~0xff) | ‘ ‘;
        }
        break;
    case ‘\n‘:                  //如果遇到的是換行符,將光標位置下移一行,也就是加上80(每一行占80個光標位置)
        crt_pos += CRT_COLS;
        /* fallthru */
    case ‘\r‘:                  //如果遇到的是回車符,將光標移到當前行的開頭,也就是crt_post-crt_post%80
        crt_pos -= (crt_pos % CRT_COLS);
        break;
    case ‘\t‘:                  //制表符很顯然
        cons_putc(‘ ‘);
        cons_putc(‘ ‘);
        cons_putc(‘ ‘);
        cons_putc(‘ ‘);
        cons_putc(‘ ‘);
        break;
    default:                    //普通字符的情況,直接將ascii碼填到顯存中
        crt_buf[crt_pos++] = c;     /* write the character */
        break;
    }

    // What is the purpose of this?
    if (crt_pos >= CRT_SIZE) {      //判斷是否需要滾屏。文本模式下一頁屏幕最多顯示25*80個字符,
        int i;                      //超出時,需要將2~25行往上提一行,最後一行用黑底白字的空白塊填充

        memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
        for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
            crt_buf[i] = 0x0700 | ‘ ‘;
        crt_pos -= CRT_COLS;
    }

    /* move that little blinky thing */     //移動光標
    outb(addr_6845, 14);
    outb(addr_6845 + 1, crt_pos >> 8);
    outb(addr_6845, 15);
    outb(addr_6845 + 1, crt_pos);
}

cputchar()最終會調到cga_putc(),該函數將int c打印到控制臺,可以看到該函數處理會打印正常的字符外,還能處理回車換行等控制字符,甚至還能處理滾屏,具體看註釋。
根據函數調用圖,可以發現真正實現字符串輸出的是vprintfmt()函數,其他函數都是對它的包裝。vprintfmt()函數很長,大的框架是一個while循環,while循環中首先會處理常規字符:

        while ((ch = *(unsigned char *) fmt++) != ‘%‘) {        //先將非格式化字符輸出到控制臺。
            if (ch == ‘\0‘)                                     //如果沒有格式化字符直接返回
                return;
            putch(ch, putdat);
        }

對於格式化的處理使用switch語句。不難理解。
看下Exercise 8,要求添加一些代碼,使能支持"%o"輸出八進制。那就很簡單了,在vprintfmt()中找到case ‘o‘的地方:
補充如下代碼:

            // 從ap指向的可變字符串中獲取輸出的值
            num = getuint(&ap, lflag);
            //設置基數為8
            base = 8;
            goto number;

非常容易理解,getuint函數從ap指向的可變字符串中獲取要輸出的值,將基數設置為8就行了。保存後,重新make,然後執行./grade-lab1查看當前實驗是否通過。在我的機子上顯示如下:技術分享圖片

可以看到printf後顯示ok,說明我們通過了該實驗。

gcc函數調用過程可以用如下圖解釋:
技術分享圖片
技術分享圖片

  1. 執行call指令前,函數調用者將參數入棧,按照函數列表從右到左的順序入棧
  2. call指令會自動將當前eip入棧,ret指令將自動從棧中彈出該值到eip
  3. 被調用函數負責:將ebp入棧,esp的值賦給ebp

直接看Exercise 11,讓我們補全mon_backtrace()函數,該函數打印函數調用棧打印格式如下:

Stack backtrace:
  ebp f0109e58  eip f0100a62  args 00000001 f0109e80 f0109e98 f0100ed2 00000031
  ebp f0109ed8  eip f01000d6  args 00000000 00000000 f0100058 f0109f28 00000061
  ...

mon_backtrace()定義在/kern/monitor.c中,在/kern/init.c中被test_backtrace()調用,進入內核後會調用test_backtrace()

test_backtrace(int x)
{
    cprintf("entering test_backtrace %d\n", x);
    if (x > 0)
        test_backtrace(x-1);
    else
        mon_backtrace(0, 0, 0);
    cprintf("leaving test_backtrace %d\n", x);
}

test_backtrace(5);調用後會進行遞歸,最終調用mon_backtrace,mon_backtrace的任務就是將遞歸調用過程中的棧信息打印出來。結合之前的知識,我們可以畫出函數調用過程中ebp的值存儲圖:
技術分享圖片

至於為什麽一開始ebp的值是0?看kern/entry.S中如下代碼:

    # Clear the frame pointer register (EBP)
    # so that once we get into debugging C code,
    # stack backtraces will be terminated properly.
    movl    $0x0,%ebp           # nuke frame pointer

    # Set the stack pointer
    movl    $(bootstacktop),%esp
    # now to C code
    call    i386_init

在跳轉到i386_init函數前,已經將ebp寄存器設置為0了。現在就簡單了,開始動手實現mon_backtrace函數。
實驗提供了read_ebp()函數,可以讓我們方便獲取寄存器ebp的值。我們如下實現mon_backtrace函數。

int 
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
    // Your code here.
    uint32_t *ebp = (uint32_t *)read_ebp(); //獲取ebp的值
    while (ebp != 0) {                      //終止條件是ebp為0
        //打印ebp, eip, 最近的五個參數
        uint32_t eip = *(ebp + 1);
        cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, eip, *(ebp + 2), *(ebp + 3), *(ebp + 4), *(ebp + 5), *(ebp + 6));
        //更新ebp
        ebp = (uint32_t *)(*ebp);
    }
    return 0;
}

接著看Exercise 12,該實驗要求我們在實驗11的基礎上還要輸出當前eip(也就是當前正在執行的指令)對應的文件名,所在行號,對應函數,以及在函數內的偏移。
實驗提供了int debuginfo_eip(uintptr_t addr, struct Eipdebuginfo *info)函數(在/kern/kdebug.c中),該函數輸入eip,和一個Eipdebuginfo結構指針,執行完畢後,會將eip對應的信息填充到該結構中。接著完善mon_backtrace函數:

int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
    // Your code here.
    uint32_t *ebp = (uint32_t *)read_ebp();
    struct Eipdebuginfo eipdebuginfo;
    while (ebp != 0) {
        //打印ebp, eip, 最近的五個參數
        uint32_t eip = *(ebp + 1);
        cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n", ebp, eip, *(ebp + 2), *(ebp + 3), *(ebp + 4), *(ebp + 5), *(ebp + 6));
        //打印文件名等信息
        debuginfo_eip((uintptr_t)eip, &eipdebuginfo);
        cprintf("%s:%d", eipdebuginfo.eip_file, eipdebuginfo.eip_line);
        cprintf(": %.*s+%d\n", eipdebuginfo.eip_fn_namelen, eipdebuginfo.eip_fn_name, eipdebuginfo.eip_fn_addr);
        //更新ebp
        ebp = (uint32_t *)(*ebp);
    }
    return 0;
}

在lab目錄下執行make, ./grade-lab1,如果一切順利將看到如下輸出:
技術分享圖片

就說明我們通過了lab1的所有實驗。

本人的實驗代碼已經上傳github,歡迎關註https://github.com/gatsbyd/mit_6.828_jos

參考資料

《x86匯編語言-從實模式到保護模式》
《程序員的自我修養》

MIT-6.828 Lab1實驗報告