Lab1:Booting a PC 概述 本文主要介紹lab1,從內容上分為三部分,part1簡單介紹了彙編語言,物理記憶體地址空間,BIOS。part2介紹了BIOS從磁碟0號扇區讀取boot loader到0000:7c00處,並將cs:ip設置成0000:7c00。boot loader主要做兩 ...
Lab1:Booting a PC
概述
本文主要介紹lab1,從內容上分為三部分,part1簡單介紹了彙編語言,物理記憶體地址空間,BIOS。part2介紹了BIOS從磁碟0號扇區讀取boot loader到0000:7c00處,並將cs:ip設置成0000:7c00。boot loader主要做兩件事:
- 創建兩個全局描述符表項(代碼段和數據段),然後進入保護模式
- 從磁碟載入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中,主要做了兩件事:
- 從實模式進入保護模式
- 從磁碟載入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
該部分將進入內核執行,主要講三件事:
- 開啟分頁模式,將虛擬地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)
- 提供輸出格式化字元串到控制台的能力
- 函數的調用過程
開啟分頁模式
操作系統經常被載入到高虛擬地址處,比如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函數調用過程可以用如下圖解釋:
- 執行call指令前,函數調用者將參數入棧,按照函數列表從右到左的順序入棧
- call指令會自動將當前eip入棧,ret指令將自動從棧中彈出該值到eip
- 被調用函數負責:將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彙編語言-從實模式到保護模式》
《程式員的自我修養》