Lab1:Booting a PC
概述
本文主要介紹lab1,從內容上分為三部分,part1簡單介紹了匯編語言,物理內存地址空間,BIOS。part2介紹了BIOS從磁盤0號扇區讀取boot loader到0000:7c00處,並將cs:ip設置成0000:7c00。boot loader主要做兩件事:
- 創建兩個全局描述符表項(代碼段和數據段),然后進入保護模式
- 從磁盤加載kernel到內存
part3主要介紹進入內核后的一些操作:
- 首先會開啟分頁模式。
- 格式化輸出字符串的原理。本質還是往物理內存0xB8000起始的顯存寫數據。
- 函數調用過程。
對應的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中,主要做了兩件事:
- 從實模式進入保護模式,加載全局描述符表(boot/boot.S)
- 從磁盤加載kernel到內存(boot/main.c)
先看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,內存,磁盤可以抽象出下面的圖:
可能有人會有疑問,如何保證boot/boot.S和boot/main.c編譯鏈接后剛好512字節(一個扇區)?而且作為主引導扇區,最后兩個字節必須是0x55AA,boot/boot.S和boot/main.c都沒有相應的措施來保證。
剛開始我也很疑惑,后面發現boot目錄下有一個sign.pl文件:
open(BB, $ARGV[0]) || die "open $ARGV[0]: $!";
binmode BB;
my $buf;
read(BB, $buf, 1000);
$n = length($buf);
if($n > 510){
print STDERR "boot block too large: $n bytes (max 510)\n";
exit 1;
}
print STDERR "boot block is $n bytes (max 510)\n";
$buf .= "\0" x (510-$n);
$buf .= "\x55\xAA";
open(BB, ">$ARGV[0]") || die "open >$ARGV[0]: $!";
binmode BB;
print BB $buf;
close BB;
這段腳本將輸入文件,填充為512字節並且最后以0x55AA結尾。編譯過程中,makefile會將鏈接后的文件做這么一個處理。
Part 3: The Kernel
該部分將進入內核執行,主要講三件事:
- 開啟分頁模式,將虛擬地址[0, 4MB)映射到物理地址[0, 4MB),[0xF0000000, 0xF0000000+4MB)映射到[0, 4MB)(/kern/entry.S)
- 在控制台輸出字符串(/kern/init.c)
- 測試函數的調用過程 (/kern/init.c)
開啟分頁模式
操作系統經常被加載到高虛擬地址處,比如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(),cputchar()打印一個字符到屏幕。cputchar()會調到kern/console.c中的cga_putc(),該函數將int c打印到控制台,可以看到該函數處理會打印正常的字符外,還能處理回車換行等控制字符,甚至還能處理滾屏。cga_putc()會將字符對應的ascii碼存儲到crt_buf[crt_pos]處,實際上crt_buf在初始化的時候被初始為
KERNBASE(0xF00B8000) + CGA_BUF(0xB8000),也就是虛擬地址0xF00B8000處,這里正是顯存的起始地址(根據目前的頁表虛擬地址0xF00B8000將被映射到物理地址0xB8000處)。
所以往控制台寫字符串,本質還是往物理地址0xB8000開始的顯存寫數據。
根據函數調用圖,可以發現真正實現字符串輸出的是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。所以反匯編一個函數會發現開頭兩個指令都是
push %ebp, mov %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了。同時我們也發現esp寄存器被設置為了$(bootstacktop),bootstacktop被定義在kern/entry.S中,也就是說我們在內核編譯鏈接成的ELF文件中保留了KSTKSIZE字節的空間,作為棧使用。
bootstack:
.space KSTKSIZE //申請KSTKSIZE字節的空間作為棧
.globl bootstacktop //.globl表示導出bootstacktop
bootstacktop:
現在就簡單了,開始動手實現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
如有錯誤,歡迎指正:
15313676365
參考資料
《x86匯編語言-從實模式到保護模式》
《程序員的自我修養》