練習1:理解通過make生成執行文件的過程。(要求在報告中寫出對下述問題的回答)
實驗過程
靜態分析代碼。
實驗的目錄結構如下:
.
├── boot
├── kern
│ ├── debug
│ ├── driver
│ ├── init
│ ├── libs
│ ├── mm
│ └── trap
├── libs
└── tools
其中./boot里面是bootloader的相關代碼;
./kern里面是操作系統的相關代碼;
./toos/sign.c描述了怎樣把bootloader變成一個規范的主引導扇區。
問題解答
問題一
操作系統鏡像文件ucore.img是如何一步一步生成的?(需要比較詳細地解釋Makefile中每一條相關命令和命令參數的含義,以及說明命令導致的結果)
輸入 make V=@echo 命令,make工具便把目錄下的文件進行了編譯。通過設置V=@echo 參數,把編譯過程打印了下來。大致如下:
- 先使用
gcc命令,把./kern目錄下的代碼都編譯成obj/kern/*/*.o文件; - 用
ld命令通過/tools/kern.ls文件配置,把obj/kern/*/*.o文件連接成bin/kern; - 用
gcc命令,把boot目錄下的文件編譯成obj/boot/*.o文件; - 用
gcc把tools/sign.c編譯成obj/sign/tools/sign.o; - 用
ld把obj/boot/*.o連接成obj/bootblock.o; - 使用第4步生成的obj/sign/tools/sign.o,將
obj/bootblock.o文件規范化為,符合規范的硬盤住引導扇區的文件bin/bootblock - 用
dd命令創建了一個bin/ucore.img文件; - 用
dd命令把bin/bootblock寫入bin/ucore.img文件; - 用
dd命令創bin/kernel寫入bin/ucore.img文件。
命令及參數解釋:
gcc: Linux下的C語言編譯器。
ld:把一定量的目標文件跟檔案文件連接起來,並重定位它們的數據,連接符號引用。一般,在編譯一個程序時,最后一步就是運行'ld'。
用法:
ld [option] [objs...]
參數:
-o:指定輸出文件名;
-e:指定程序的入口符號。
-m: 指定連接器
-N: 指定 可讀寫 的 正文 和 數據 節(section). 如果 輸出格式 支持 Unix 風格的 幻數(magic number), 則 輸出文件 標記為 OMAGIC.當 使用 `-N' 選項 時, linker 不做數據段 的 頁對齊(page-align).
-e: 設置程序開端
-T: 等同於 -c 告訴 ld 從指定文件中讀取連接命令.
dd:用指定大小的塊拷貝一個文件,並在拷貝的同時進行指定的轉換。
參數注釋:
- if=文件名:輸入文件名,缺省為標准輸入。即指定源文件。< if=input file >
- of=文件名:輸出文件名,缺省為標准輸出。即指定目的文件。< of=output file >
- ibs=bytes:一次讀入bytes個字節,即指定一個塊大小為bytes個字節。
- obs=bytes:一次輸出bytes個字節,即指定一個塊大小為bytes個字節。
- bs=bytes:同時設置讀入/輸出的塊大小為bytes個字節。
- cbs=bytes:一次轉換bytes個字節,即指定轉換緩沖區大小。
- skip=blocks:從輸入文件開頭跳過blocks個塊后再開始復制。
- seek=blocks:從輸出文件開頭跳過blocks個塊后再開始復制。
- 注意:通常只用當輸出文件是磁盤或磁帶時才有效,即備份到磁盤或磁帶時才有效。
- count=blocks:僅拷貝blocks個塊,塊大小等於ibs指定的字節數。
- conv=conversion:用指定的參數轉換文件。
- ascii:轉換ebcdic為ascii
- ebcdic:轉換ascii為ebcdic
- ibm:轉換ascii為alternate ebcdic
- block:把每一行轉換為長度為cbs,不足部分用空格填充
- unblock:使每一行的長度都為cbs,不足部分用空格填充
- lcase:把大寫字符轉換為小寫字符
- ucase:把小寫字符轉換為大寫字符
- swab:交換輸入的每對字節
- noerror:出錯時不停止
- notrunc:不截短輸出文件
- sync:將每個輸入塊填充到ibs個字節,不足部分用空(NUL)字符補齊。
/dev/zero: 是一個輸入設備,你可你用它來初始化文件。該設備無窮盡地提供0(是ASCII 0 就是NULL),
問題二
一個被系統認為是符合規范的硬盤主引導扇區的特征是什么?
問題一種提到,bootloader.o文件經過sign.o的操作后,變成符合規范的引導文件。所以,我們先來看看tools/sign.c:
#include <stdio.h>
#include <errno.h>
#include <string.h>
#include <sys/stat.h>
int
main(int argc, char *argv[]) {
struct stat st;
// 檢查輸入參數
if (argc != 3) {
fprintf(stderr, "Usage: <input filename> <output filename>\n");
return -1;
}
// 讀取文件
if (stat(argv[1], &st) != 0) {
fprintf(stderr, "Error opening file '%s': %s\n", argv[1], strerror(errno));
return -1;
}
// 輸出文件名和文件大小
printf("'%s' size: %lld bytes\n", argv[1], (long long)st.st_size);
// 如果文件長度大於510,則報錯退出
if (st.st_size > 510) {
fprintf(stderr, "%lld >> 510!!\n", (long long)st.st_size);
return -1;
}
// 申請一個512長度的buf數組,並初始化為0
char buf[512];
memset(buf, 0, sizeof(buf));
FILE *ifp = fopen(argv[1], "rb");
int size = fread(buf, 1, st.st_size, ifp);
// 校驗文件長度
if (size != st.st_size) {
fprintf(stderr, "read '%s' error, size is %d.\n", argv[1], size);
return -1;
}
fclose(ifp);
// 把buf數組的最后兩位置為 0x55, 0xAA
buf[510] = 0x55;
buf[511] = 0xAA;
FILE *ofp = fopen(argv[2], "wb+");
size = fwrite(buf, 1, 512, ofp);
if (size != 512) {
fprintf(stderr, "write '%s' error, size is %d.\n", argv[2], size);
return -1;
}
fclose(ofp);
printf("build 512 bytes boot sector: '%s' success!\n", argv[2]);
return 0;
}
上面這段代碼做的事情除了參數校驗以外,就是把源文件讀到長度512字節的buf數組里,然后給最后兩字節賦值為了0x55和0xAA。
所以,我們可以猜測主引導扇區的規則如下:
- 大小為512字節
- 多余的空間填0
- 最后16位為0x55AA
網上搜了下資料,說
結束標志(占2個字節)其值為AA55,存儲時低位在前,高位在后,即看上去是55AA(十六進制)。
練習2 使用qemu執行並調試lab1中的軟件。
問題一
從CPU加電后執行的第一條指令開始,單步跟蹤BIOS的執行。
(2020-03-18 修改)
gdb 調試 BIOS 的方法可以看這里。
因為運行在雲主機上,沒有 GUI。所以直接跑 make debug 會報找不到 gnome-terminal 的錯。打印了下 debug 的執行過程如下:
qemu-system-i386 -S -s -parallel stdio -hda bin/ucore.img -serial null &
sleep 2
gnome-terminal -e gdb -q -tui -x tools/gdbinit
大概是這么個意思:
- 用
qemu加載鏡像並停在最開始,然后放到后台運行。其中-S參數是把 cpu 停在最開始,-s參數是在tcp::1234等gdb連接,和-gdb tcp::1234作用一樣。 - 等兩秒,應該是在等
qemu完全啟動好。 - 開一個新的
gnome-terminal窗口,並在里面執行命令gdb -q -tui -x tools/gdbinit。因為我沒有gnome-terminal所以就在一個新shell直接跑了里面的命令。
此時 tools/gdbinit 的內容為:
set architecture i8086
target remote :1234
在 gdb 窗口中輸入 i r 命令可以查看寄存器的狀態:
eax 0x0 0
ecx 0x0 0
edx 0x663 1635
ebx 0x0 0
esp 0x0 0x0
ebp 0x0 0x0
esi 0x0 0
edi 0x0 0
eip 0xfff0 0xfff0
eflags 0x2 [ IOPL=0 ]
cs 0xf000 61440
ss 0x0 0
ds 0x0 0
es 0x0 0
fs 0x0 0
gs 0x0 0
fs_base 0x0 0
gs_base 0x0 0
...
此時 cs=0xf000, eip=0xfff0,所以 cpu 下一條指令在cs:eip = 0xffff0,輸入 x /2i 0xffff0 查看接下來執行的代碼。
(gdb) x /2i 0xffff0
0xffff0: ljmp $0x3630,$0xf000e05b
0xffff7: das
在 qemu 命令中加上 -d in_asm -D bin/q.log 參數,可以把執行的匯編指令保存到日志文件 q.log 里。完整命令如下:
qemu -S -s -parallel stdio -hda bin/ucore.img -serial null \
-d in_asm -D bin/q.log
修改 tools/gdbinit,在 0x7c00 處設置斷點,並 continue 然后依次關掉 qemu 和 gdb 直接在日志文件中查看從 0xffff0 到 0x7c00 直接運行的代碼。
此時 tools/q.log 的內容為:
set architecture i8086
target remote :1234
break *0x7c00
continue
日志文件內容為:
----------------
IN:
0xfffffff0: ea 5b e0 00 f0 ljmpw $0xf000:$0xe05b
----------------
IN:
0x000fe05b: 2e 66 83 3e b8 60 00 cmpl $0, %cs:0x60b8
0x000fe062: 0f 85 b9 f0 jne 0xd11f
----------------
... (省略20710行)
----------------
IN:
0x000edefa: c6 05 ee bd 0e 00 01 movb $1, 0xebdee
0x000edf01: 58 popl %eax
0x000edf02: 5b popl %ebx
0x000edf03: c3 retl
----------------
IN:
0x000ef79c: b9 ad 80 0f 00 movl $0xf80ad, %ecx
0x000ef7a1: 31 d2 xorl %edx, %edx
0x000ef7a3: 8d 44 24 0e leal 0xe(%esp), %eax
0x000ef7a7: e8 06 e4 ff ff calll 0xedbb2
----------------
IN:
0x00007c00: fa cli
(更新結束)
問題二
在初始化位置0x7c00設置實地址斷點,測試斷點正常。
在gdb中執行以下命令
b *0x7c00
continue
發現程序執行到0x7c00處確實停下來了,說明斷點正常。
問題三
從0x7c00開始跟蹤代碼運行,將單步跟蹤反匯編得到的代碼與bootasm.S和 bootblock.asm進行比較。
執行make debug命令,啟動qemu和gdb開始debug。
然后在gdb中輸入b *0x7c00,在內存0x7c00處設置斷點。
continue讓程序繼續執行,程序會在前面設置的0x7c00的斷點處停下來。
輸入x /10i $pc查看接下來的10條指令,得到如下輸出:
=> 0x7c00: cli
0x7c01: cld
0x7c02: xor %eax,%eax
0x7c04: mov %eax,%ds
0x7c06: mov %eax,%es
0x7c08: mov %eax,%ss
0x7c0a: in $0x64,%al
0x7c0c: test $0x2,%al
0x7c0e: jne 0x7c0a
0x7c10: mov $0xd1,%al
可以發現,這和boot/bootasm.S文件中的內容一致。通過單步跟蹤,發現執行指令確實是bootasm.S中的指令,大致過程如下:
- 禁用中斷 (
cli) - 復位操作方向標志(
cld) - 初始化ds, es, ss寄存器為0
- 激活A20地址線
- 加載全局描述符表 (gdt)
- 打開cr0 ( 開啟保護模式)
- 切換到32位模式
- 設置ds, es, fs, gs, ss為0x10
- 設置棧頂指針、棧底指針
- 調用bootmain
上面最后一步跳轉到bootmain中執行,接下來我們來看下bootmain中的執行過程:
- 從硬盤起始處讀取4k內容到內存0x10000處
- 加載各程序段
- 調用
ELFHDR->e_entry的入口函數
可以看出上面最后調用調用ELFHDR->e_entry的入口函數,即切換到kernel處了。
bootblock.asm把bootasm.S和bootmain.c都內容都整合到一起了。
並且bootblock.asm中每行代碼下面都帶有地址信息,和用gdb單步調試的時候基本一致。
問題四
自己找一個bootloader或內核中的代碼位置,設置斷點並進行測試。
break kern_init
練習3 分析bootloader進入保護模式的過程。
分析過程詳見練習2問題一,進入保護模式的過程如下:
- 激活A20地址線
- 加載全局描述符表 (gdt)
- 打開cr0 ( 開啟保護模式)
為何開啟A20,以及如何開啟A20
為何開啟A20:若不開啟A20,cpu在訪問地址空間時第20位始終會是0,這時只能訪問奇數段不能訪問偶數段;開啟A20后,cpu可訪問連續地址空間。
如何開啟A20:
- 等待8042 Input buffer為空;
- 發送Write 8042 Output Port (P2)命令到8042 Input buffer;
- 等待8042 Input buffer為空;
- 將8042 Output Port(P2)得到字節的第2位置1,然后寫入8042 Input buffer;
如何初始化GDT表
.p2align 2 # force 4 byte alignment
gdt:
SEG_NULLASM # null seg
SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel
SEG_ASM(STA_W, 0x0, 0xffffffff) # data seg for bootloader and kernel
gdtdesc:
.word 0x17 # sizeof(gdt) - 1
.long gdt # address gdt
如何使能和進入保護模式
將cr0寄存器置1
練習4 分析bootloader加載ELF格式的OS的過程
問題一
bootloader如何讀取硬盤扇區的?
讀硬盤扇區的代碼如下:
// bootmain.c
/* 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);
}
從outb()可以看出這里是用LBA模式的PIO(Program 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");
}
問題二
bootloader是如何加載ELF格式的OS?
- 從硬盤讀了8個扇區數據到內存
0x10000處,並把這里強制轉換成elfhdr使用; - 校驗
e_magic字段; - 根據偏移量分別把程序段的數據讀取到內存中。
練習5 實現函數調用堆棧跟蹤函數
我們需要在lab1中完成kdebug.c中函數print_stackframe的實現,可以通過函數print_stackframe來跟蹤函數調用堆棧中記錄的返回地址。
首先,可以通過read_ebp()和read_eip()函數來獲取當前ebp寄存器和eip 寄存器的信息。
因為程序在執行一個一個函數前,會依次把 參數、返回地址、當前epb入棧。如下圖所示
+| 棧底方向 | 高位地址
| ... |
| ... |
| 參數3 |
| 參數2 |
| 參數1 |
| 返回地址 |
| 上一層[ebp] | <-------- [ebp]
| 局部變量 | 低位地址
所以,當我們拿到 ebp 時,就可以知道上層函數的所有信息,即ebp到*ebp的內容。所以ebp+2開始就是上一層的(可能的)參數,ebp+1即是當前層的返回地址(可以當作上一層的eip)。
實現過程代碼如下:
void
print_stackframe(void) {
/* LAB1 YOUR CODE : STEP 1 */
/* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);
* (2) call read_eip() to get the value of eip. the type is (uint32_t);
* (3) from 0 .. STACKFRAME_DEPTH
* (3.1) printf value of ebp, eip
* (3.2) (uint32_t)calling arguments [0..4] = the contents in address (unit32_t)ebp +2 [0..4]
* (3.3) cprintf("\n");
* (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.
* (3.5) popup a calling stackframe
* NOTICE: the calling funciton's return addr eip = ss:[ebp+4]
* the calling funciton's ebp = ss:[ebp]
*/
uint32_t ebp = read_ebp(), eip = read_eip();
for (int i = 0; i < STACKFRAME_DEPTH && ebp != 0; i++) {
cprintf("ebp: 0x%08x eip: 0x%08x args:", ebp, eip);
for (int ij= 0; j < 4; j++) {
cprintf(" 0x%08x", ((uint32_t*)(ebp + 2))[j]);
}
cprintf("\n");
print_debuginfo(eip - 1);
eip = *((uint32_t*) ebp + 1);
ebp = *((uint32_t*) ebp);
}
}
執行 make qemu得到如下結果:
(THU.CST) os is loading ...
Special kernel symbols:
entry 0x00100000 (phys)
etext 0x0010325f (phys)
edata 0x0010ea16 (phys)
end 0x0010fd20 (phys)
Kernel executable memory footprint: 64KB
ebp: 0x00007b38 eip: 0x00100a27 args: 0x0d210000 0x00940010 0x00940001 0x7b680001
kern/debug/kdebug.c:305: print_stackframe+21
ebp: 0x00007b48 eip: 0x00100d21 args: 0x007f0000 0x00000010 0x00000000 0x00000000
kern/debug/kmonitor.c:125: mon_backtrace+10
ebp: 0x00007b68 eip: 0x0010007f args: 0x00a10000 0x00000010 0x7b900000 0x00000000
kern/init/init.c:48: grade_backtrace2+19
ebp: 0x00007b88 eip: 0x001000a1 args: 0x00be0000 0x00000010 0x00000000 0x7bb4ffff
kern/init/init.c:53: grade_backtrace1+27
ebp: 0x00007ba8 eip: 0x001000be args: 0x00df0000 0x00000010 0x00000000 0x00000010
kern/init/init.c:58: grade_backtrace0+19
ebp: 0x00007bc8 eip: 0x001000df args: 0x00500000 0x00000010 0x00000000 0x00000000
kern/init/init.c:63: grade_backtrace+26
ebp: 0x00007be8 eip: 0x00100050 args: 0x7d6e0000 0x00000000 0x00000000 0x00000000
kern/init/init.c:28: kern_init+79
ebp: 0x00007bf8 eip: 0x00007d6e args: 0x7c4f0000 0xfcfa0000 0xd88ec031 0xd08ec08e
<unknow>: -- 0x00007d6d --
其中,最深一層對應着第一個使用堆棧的函數,即boot/bootmain.c中的bootmain。在boot/bootasm.S中的第 68 行可以看到,bootloader 設置的堆棧從 0x7c00 開始,然后 call bootmain。所以 bootmain 的 ebp 為 0x7bf8
練習6 完善中斷初始化和處理
請完成編碼工作和回答如下問題:
- 中斷描述符表(也可簡稱為保護模式下的中斷向量表)中一個表項占多少字節?其中哪幾位代表中斷處理代碼的入口?
中斷描述符表的一個表項占8字節。根據中斷類型的不同,其中每個字節代表的意義也不同。
一個表項的結構如下:

可以看到,其中第16到31位為中斷例程的段選擇子,第0到15位 和 第48到63位分別為偏移量的地位和高位。這幾個數據一起決定了中斷處理代碼的入口地址。
- 請編程完善kern/trap/trap.c中對中斷向量表進行初始化的函數idt_init。在idt_init函數中,依次對所有中斷入口進行初始化。使用mmu.h中的SETGATE宏,填充idt數組內容。每個中斷的入口由tools/vectors.c生成,使用trap.c中聲明的vectors數組即可。
/* idt_init - initialize IDT to each of the entry points in kern/trap/vectors.S */
void
idt_init(void) {
// (1) 拿到外部變量 __vector
extern uintptr_t __vectors[];
// (2) 使用SETGATE宏,對中斷描述符表中的每一個表項進行設置
for (int i = 0; i < 256; i++) {
uint16_t istrap = 0, off = 0, dpl = 3;
SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);
}
// set for switch from user to kernel
SETGATE(idt[T_SWITCH_TOU], 0, GD_KTEXT, __vectors[T_SWITCH_TOU], DPL_USER);
SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);
// (3) 調用lidt函數,設置中斷描述符表
lidt(&idt_pd);
}
- 請編程完善trap.c中的中斷處理函數trap,在對時鍾中斷進行處理的部分填寫trap函數中處理時鍾中斷的部分,使操作系統每遇到100次時鍾中斷后,調用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
在函數體頭部聲明一個靜態變量用於計數
static int32_t tick_count = 0;
然后,在時間中斷 IRQ_OFFSET + IRQ_TIMER的case中添加判斷打印的條件:
tick_count++;
if (0 == (tick_count % TICK_NUM)) {
print_ticks();
}
