前言
今晚在實驗室摸魚做6.S081的Lab3 Allocator,並立下flag,改掉一個bug就拍死一隻在身邊飛的蚊子。在擊殺8只蚊子拿到Legendary後仍然沒能通過usertest,人已原地裂解開來。遂早退實驗室滾回宿舍,撿起自己已經兩年沒寫的blog,碼點自己用vscode除錯xv6的心得和小tips,如果對同樣在碼xv6但無法忍受gdb除錯介面的小夥伴們有幫助那就太好了,積點功德,但願明天能通過test,少打幾隻蚊子(
還是從直接用gdb除錯說起
剛開始碼lab時,我想很多人第一反應和我是一樣的:我的程式是在程式上跑的,那我該如何除錯我的程式?
google之可以找到答案:https://stackoverflow.com/questions/10534798/debugging-user-code-on-xv6-with-gdb
但實際執行過程有點不同,拿我個人寫的sleep.c來說吧,程式碼如下:
#include "kernel/types.h" #include "user.h" int parse_int(const char* arg) { const char* p = arg; for ( ; *p ; p++ ) { if ( *p < '0' || *p > '9' ) { return -1; } } return atoi(arg); } int main(int argc,char** argv) { int time; if (argc != 2) { printf("you must input one argument only\n"); exit(0); } time = parse_int(argv[1]); if (time < 0) { printf("error argument : %s\n",argv[1]); exit(0); } sleep(time); exit(0); }
函式parse_int的作用是檢查我們輸入的引數(睡眠的時間)是否包括除了數字以外的東西。編寫好之後,在makefile中把我們寫好的sleep.c加進去:
UPROGS=\
$U/_cat\
$U/_echo\
$U/_forktest\
........
$U/_kalloctest\
$U/_bcachetest\
$U/_alloctest\
$U/_bigfile\
$U/_sleep\
執行 make fs.img,sleep.c就會被編譯成elf檔案_sleep,並儲存在xv6的檔案系統中。
接下來我們開啟一個視窗,輸入 make qemu-gdb,qemu會卡住,等待gdb與他連線。
注意,MIT 6.S081 2019提供的xv6採用的指令集是riscv,因此我們虛擬機器上針對x86指令集的gdb可能無法較好的除錯。我們需要用交叉編譯工具來編譯xv6,並用交叉編譯工具提供的gdb來除錯。交叉編譯工具在課程主頁上有提供(但我找不到連結到哪兒去了)。我的虛擬機器已經下載了完整的交叉編譯鏈,並且環境變數也已經設定完畢。因此我只需要在makefile中新增下面一行:
gdb: riscv64-unknown-elf-gdb kernel/kernel
在另一個視窗執行make gdb,即可呼叫專用於riscv的gdb(riscv64-unknown-elf-gdb),除錯核心檔案kernel/kernel。
接下來的操作其實與stackoverflow上面的高贊回答幾乎一致了:
ms@ubuntu:~/public/MIT 6.S081/Lab4/xv6-riscv-fall19$ make gdb riscv64-unknown-elf-gdb kernel/kernel GNU gdb (GDB) 9.1 Copyright (C) 2020 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 "--host=x86_64-pc-linux-gnu --target=riscv64-unknown-elf". Type "show configuration" for configuration details. For bug reporting instructions, please see: <http://www.gnu.org/software/gdb/bugs/>. Find the GDB manual and other documentation resources online at: <http://www.gnu.org/software/gdb/documentation/>. For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from kernel/kernel... The target architecture is assumed to be riscv:rv64 0x0000000000001000 in ?? () (gdb) file user/_sleep Reading symbols from user/_sleep... (gdb) b parse_int Breakpoint 1 at 0x0: file user/sleep.c, line 6. (gdb) c
我們已經在sleep.c上打了斷點。按c執行到斷點處:
(gdb) file user/_sleep Reading symbols from user/_sleep... (gdb) b parse_int Breakpoint 1 at 0x0: file user/sleep.c, line 6. (gdb) c Continuing. Breakpoint 1, parse_int ( arg=0x505050505050505 <error: Cannot access memory at address 0x505050505050505>) at user/sleep.c:6 6 for ( ; *p ; p++ ) { (gdb)
這程式輸出?wtf ?我們xv6的介面還沒有提示shell啟動,為什麼就跳轉到了這個函式上了?
不急,我們先看看pc指標的值:
Breakpoint 1, parse_int ( arg=0x505050505050505 <error: Cannot access memory at address 0x505050505050505>) at user/sleep.c:6 6 for ( ; *p ; p++ ) { (gdb) info reg pc pc 0x0 0x0 <parse_int> (gdb)
pc指向0x0,也就是NULL,這個地址很明顯是一個虛地址。而我們在parse_int上打下的斷點,地址也是在0x0處。其實看到這裡應該你應該已經猜到,gdb很可能就是在監視pc值,當pc值等於斷點值時斷點就會被觸發。其實這個斷點觸發是因為核心載入完成後啟動的第一個使用者程式,具體程式碼在kernel/proc.c中的userinit.c中:
// Set up first user process. void userinit(void) { struct proc *p; p = allocproc(); // xv6的第一個程式,其pid = 1 initproc = p; uvminit(p->pagetable, initcode, sizeof(initcode)); // 第一個程式的程式碼段就是proc.c下的initcode,將這段程式碼的虛實對映關係新增到使用者程式頁表中 p->sz = PGSIZE; p->tf->epc = 0; // 設定使用者程式的pc指標初始值為0,這就是sleep.c中斷點被觸發的原因 p->tf->sp = PGSIZE; safestrcpy(p->name, "initcode", sizeof(p->name)); p->cwd = namei("/"); p->state = RUNNABLE; // 該程式等待排程 release(&p->lock); }
你只需要知道,xv6會在核心載入完畢後建立第一個程式,第一個程式的程式碼段是proc.c下的initcode陣列,程式入口地址為0x0。當這個程式被排程時,pc指標被設為0,觸發了我們打在sleep.c中的斷點。這個時候斷點雖然被觸發,但程式並沒有執行到我們想要的地方,僅僅是pc值正好與斷點值相同而已。
進一步討論
下面我們提出一個問題:
1) 從上面的討論來看,gdb只是在監測pc指標。以及一些其他暫存器(例如說堆疊指標sp、其他的使用者可訪問暫存器)。那麼為什麼我們設斷點b parse_int, gdb就可以知道斷點打在0x0處?為什麼gdb可以告訴我們我們的變數值?
為了搞懂這個問題,我們需要對elf檔案有一個簡單的瞭解。我們知道,程式碼的虛擬地址是在編譯(連結)期生成的,而程式碼編譯後的結果一般是一個ELF(Executable Linkable Format)檔案。ELF檔案記錄了我們程式碼中每個函式的虛擬地址,此外還會有一些其他有助於我們的資訊。我們可以使用指令檢視一下user/_sleep這個ELF檔案的格式。新開一個終端,輸入命令readelf -a user/_sleep
1 ms@ubuntu:~/public/MIT 6.S081/Lab4/xv6-riscv-fall19$ readelf -a user/_sleep 2 ELF 頭: 3 Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 4 類別: ELF64 5 資料: 2 補碼,小端序 (little endian) 6 版本: 1 (current) 7 OS/ABI: UNIX - System V 8 ABI 版本: 0 9 型別: EXEC (可執行檔案) 10 系統架構: RISC-V 11 版本: 0x1 12 入口點地址: 0x3a 13 程式頭起點: 64 (bytes into file) 14 Start of section headers: 22520 (bytes into file) 15 標誌: 0x5, RVC, double-float ABI 16 本頭的大小: 64 (位元組) 17 程式頭大小: 56 (位元組) 18 Number of program headers: 1 19 節頭大小: 64 (位元組) 20 節頭數量: 18 21 字串表索引節頭: 17 22 23 節頭: 24 [號] 名稱 型別 地址 偏移量 25 大小 全體大小 旗標 連結 資訊 對齊 26 [ 0] NULL 0000000000000000 00000000 27 0000000000000000 0000000000000000 0 0 0 28 [ 1] .text PROGBITS 0000000000000000 00000078 29 0000000000000834 0000000000000000 WAX 0 0 2 30 [ 2] .rodata PROGBITS 0000000000000838 000008b0 31 0000000000000059 0000000000000000 A 0 0 8 32 [ 3] .sbss NOBITS 0000000000000898 00000909 33 0000000000000008 0000000000000000 WA 0 0 8 34 [ 4] .bss NOBITS 00000000000008a0 00000909 35 0000000000000010 0000000000000000 WA 0 0 8 36 [ 5] .comment PROGBITS 0000000000000000 00000909 37 0000000000000012 0000000000000001 MS 0 0 1 38 [ 6] .riscv.attributes LOPROC+0x3 0000000000000000 0000091b 39 0000000000000035 0000000000000000 0 0 1 40 [ 7] .debug_aranges PROGBITS 0000000000000000 00000950 41 00000000000000f0 0000000000000000 0 0 16 42 [ 8] .debug_info PROGBITS 0000000000000000 00000a40 43 0000000000000ea7 0000000000000000 0 0 1 44 [ 9] .debug_abbrev PROGBITS 0000000000000000 000018e7 45 00000000000005ab 0000000000000000 0 0 1 46 [10] .debug_line PROGBITS 0000000000000000 00001e92 47 000000000000133c 0000000000000000 0 0 1 48 [11] .debug_frame PROGBITS 0000000000000000 000031d0 49 0000000000000488 0000000000000000 0 0 8 50 [12] .debug_str PROGBITS 0000000000000000 00003658 51 00000000000002d0 0000000000000001 MS 0 0 1 52 [13] .debug_loc PROGBITS 0000000000000000 00003928 53 0000000000001578 0000000000000000 0 0 1 54 [14] .debug_ranges PROGBITS 0000000000000000 00004ea0 55 0000000000000080 0000000000000000 0 0 1 56 [15] .symtab SYMTAB 0000000000000000 00004f20 57 00000000000006a8 0000000000000018 16 24 8 58 [16] .strtab STRTAB 0000000000000000 000055c8 59 000000000000017b 0000000000000000 0 0 1 60 [17] .shstrtab STRTAB 0000000000000000 00005743 61 00000000000000b5 0000000000000000 0 0 1 62 Key to Flags: 63 W (write), A (alloc), X (execute), M (merge), S (strings), I (info), 64 L (link order), O (extra OS processing required), G (group), T (TLS), 65 C (compressed), x (unknown), o (OS specific), E (exclude), 66 p (processor specific) 67 68 There are no section groups in this file. 69 70 程式頭: 71 Type Offset VirtAddr PhysAddr 72 FileSiz MemSiz Flags Align 73 LOAD 0x0000000000000078 0x0000000000000000 0x0000000000000000 74 0x0000000000000891 0x00000000000008b0 RWE 0x8 75 76 Section to Segment mapping: 77 段節... 78 00 .text .rodata .sbss .bss 79 80 There is no dynamic section in this file. 81 82 該檔案中沒有重定位資訊。 83 84 The decoding of unwind sections for machine type RISC-V is not currently supported. 85 86 Symbol table '.symtab' contains 71 entries: 87 Num: Value Size Type Bind Vis Ndx Name 88 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 89 1: 0000000000000000 0 SECTION LOCAL DEFAULT 1 90 2: 0000000000000838 0 SECTION LOCAL DEFAULT 2 91 3: 0000000000000898 0 SECTION LOCAL DEFAULT 3 92 4: 00000000000008a0 0 SECTION LOCAL DEFAULT 4 93 5: 0000000000000000 0 SECTION LOCAL DEFAULT 5 94 6: 0000000000000000 0 SECTION LOCAL DEFAULT 6 95 7: 0000000000000000 0 SECTION LOCAL DEFAULT 7 96 8: 0000000000000000 0 SECTION LOCAL DEFAULT 8 97 9: 0000000000000000 0 SECTION LOCAL DEFAULT 9 98 10: 0000000000000000 0 SECTION LOCAL DEFAULT 10 99 11: 0000000000000000 0 SECTION LOCAL DEFAULT 11 100 12: 0000000000000000 0 SECTION LOCAL DEFAULT 12 101 13: 0000000000000000 0 SECTION LOCAL DEFAULT 13 102 14: 0000000000000000 0 SECTION LOCAL DEFAULT 14 103 15: 0000000000000000 0 FILE LOCAL DEFAULT ABS sleep.c 104 16: 0000000000000000 0 FILE LOCAL DEFAULT ABS ulib.c 105 17: 0000000000000000 0 FILE LOCAL DEFAULT ABS printf.c 106 18: 00000000000003b8 34 FUNC LOCAL DEFAULT 1 putc 107 19: 00000000000003da 170 FUNC LOCAL DEFAULT 1 printint 108 20: 0000000000000880 17 OBJECT LOCAL DEFAULT 2 digits 109 21: 0000000000000000 0 FILE LOCAL DEFAULT ABS umalloc.c 110 22: 0000000000000898 8 OBJECT LOCAL DEFAULT 3 freep 111 23: 00000000000008a0 16 OBJECT LOCAL DEFAULT 4 base 112 24: 00000000000000a2 28 FUNC GLOBAL DEFAULT 1 strcpy 113 25: 0000000000000690 54 FUNC GLOBAL DEFAULT 1 printf 114 26: 0000000000001091 0 NOTYPE GLOBAL DEFAULT ABS __global_pointer$ 115 27: 000000000000025e 88 FUNC GLOBAL DEFAULT 1 memmove 116 28: 0000000000000358 0 NOTYPE GLOBAL DEFAULT 1 mknod 117 29: 000000000000015a 116 FUNC GLOBAL DEFAULT 1 gets 118 30: 0000000000000891 0 NOTYPE GLOBAL DEFAULT 2 __SDATA_BEGIN__ 119 31: 0000000000000390 0 NOTYPE GLOBAL DEFAULT 1 getpid 120 32: 00000000000002f0 24 FUNC GLOBAL DEFAULT 1 memcpy 121 33: 000000000000074e 230 FUNC GLOBAL DEFAULT 1 malloc 122 34: 00000000000003a0 0 NOTYPE GLOBAL DEFAULT 1 sleep 123 35: 0000000000000320 0 NOTYPE GLOBAL DEFAULT 1 pipe 124 36: 0000000000000330 0 NOTYPE GLOBAL DEFAULT 1 write 125 37: 0000000000000368 0 NOTYPE GLOBAL DEFAULT 1 fstat 126 38: 0000000000000662 46 FUNC GLOBAL DEFAULT 1 fprintf 127 39: 0000000000000340 0 NOTYPE GLOBAL DEFAULT 1 kill 128 40: 0000000000000484 478 FUNC GLOBAL DEFAULT 1 vprintf 129 41: 0000000000000380 0 NOTYPE GLOBAL DEFAULT 1 chdir 130 42: 0000000000000348 0 NOTYPE GLOBAL DEFAULT 1 exec 131 43: 0000000000000318 0 NOTYPE GLOBAL DEFAULT 1 wait 132 44: 0000000000000000 58 FUNC GLOBAL DEFAULT 1 parse_int 133 45: 0000000000000328 0 NOTYPE GLOBAL DEFAULT 1 read 134 46: 0000000000000360 0 NOTYPE GLOBAL DEFAULT 1 unlink 135 47: 00000000000002b6 58 FUNC GLOBAL DEFAULT 1 memcmp 136 48: 0000000000000308 0 NOTYPE GLOBAL DEFAULT 1 fork 137 49: 00000000000008b0 0 NOTYPE GLOBAL DEFAULT 4 __BSS_END__ 138 50: 0000000000000398 0 NOTYPE GLOBAL DEFAULT 1 sbrk 139 51: 00000000000003a8 0 NOTYPE GLOBAL DEFAULT 1 uptime 140 52: 0000000000000891 0 NOTYPE GLOBAL DEFAULT 3 __bss_start 141 53: 0000000000000114 34 FUNC GLOBAL DEFAULT 1 memset 142 54: 000000000000003a 104 FUNC GLOBAL DEFAULT 1 main 143 55: 00000000000003b0 0 NOTYPE GLOBAL DEFAULT 1 ntas 144 56: 00000000000000be 44 FUNC GLOBAL DEFAULT 1 strcmp 145 57: 0000000000000388 0 NOTYPE GLOBAL DEFAULT 1 dup 146 58: 0000000000000891 0 NOTYPE GLOBAL DEFAULT 2 __DATA_BEGIN__ 147 59: 00000000000001ce 70 FUNC GLOBAL DEFAULT 1 stat 148 60: 0000000000000891 0 NOTYPE GLOBAL DEFAULT 2 _edata 149 61: 00000000000008b0 0 NOTYPE GLOBAL DEFAULT 4 _end 150 62: 0000000000000370 0 NOTYPE GLOBAL DEFAULT 1 link 151 63: 0000000000000310 0 NOTYPE GLOBAL DEFAULT 1 exit 152 64: 0000000000000214 74 FUNC GLOBAL DEFAULT 1 atoi 153 65: 00000000000000ea 42 FUNC GLOBAL DEFAULT 1 strlen 154 66: 0000000000000350 0 NOTYPE GLOBAL DEFAULT 1 open 155 67: 0000000000000136 36 FUNC GLOBAL DEFAULT 1 strchr 156 68: 0000000000000378 0 NOTYPE GLOBAL DEFAULT 1 mkdir 157 69: 0000000000000338 0 NOTYPE GLOBAL DEFAULT 1 close 158 70: 00000000000006c6 136 FUNC GLOBAL DEFAULT 1 free 159 160 No version information found in this file.
可以看到編譯後的結果中有不少.debug段。這些程式段為我們debug提供輔助。在編譯時如果提供了除錯選項 -g,那麼編譯後就會給我們提供這些輔助資訊。這些輔助資訊是我們程式中的符號。gdb可以監控pc、sp、各類暫存器的值,配合這些符號,就可以將這些資訊“翻譯”為我們想要看的變數。
舉個不恰當的例子。某個函式f(int a,int b)那麼函式呼叫時,將會執行兩次 sp -= sizeof(int)的操作,將兩個int壓到棧上。當我們用gdb除錯時,gdb根據sp、pc值,結合符號表可知此時有兩個int型別變數a和b正在被呼叫,於是將sp + sizeof(int)處的地址解釋為int b,將sp + 2 * sizeof(int)解釋為int a,並展示在gdb前端介面上。執行bt檢視堆疊時,gdb也是根據sp,通過查閱符號表,將堆疊中的函式地址解釋為我們的函式名,並展示在gdb前端上。
我們曾經輸入過命令 file user/_sleep,其目的就是告訴gdb,載入_sleep的符號表,用它的符號表去解釋你看到的東西!
你可以嘗試一下在其他地方打下斷點:
(gdb) b sleep Breakpoint 2 at 0x3a0: file user/usys.S, line 100. (gdb) b sys_close Function "sys_close" not defined.
在_sleep的符號表中可以看到sleep的段,即ELF檔案_sleep包含了sleep函式的符號資訊,因此這個斷點可以被準確打下。
sys_close的斷點是無法打下來的,有時它還會提示你“Cannot access address at XXXX”。原因也很明顯,_sleep的符號表中沒有sys_close函式的記錄。實際上這個函式的符號存放在kernel/kernel的符號表中。除非讓gdb載入kernel/kernel的符號表,否則gdb就根本不知道這個函式到底在哪裡。
這個時候你也可以理解,為什麼parse_int的函式引數這麼奇怪了。因為這個時候執行的根本不是_sleep,拿_sleep的符號表去解釋這些資訊,肯定是錯誤的。
(gdb) c Continuing. Breakpoint 1, parse_int ( arg=0x1 <parse_int+1> "\021\006\354\"\350&\344", <incomplete sequence \340>) at user/sleep.c:6 6 for ( ; *p ; p++ ) { (gdb) c Continuing. Breakpoint 1, parse_int (arg=0x1460 "") at user/sleep.c:6 6 for ( ; *p ; p++ ) { (gdb) Continuing.
按了幾次c後,終於出現了我們的shell介面。
隨後,在xv6的shell輸入命令 sleep 10。可能還需要按幾次c,才能到達真正的parse_int函式的斷點。
這個時候我們已經可以除錯parse_int了,enjoy it!
除錯xv6的第一個程式
雖然我們已經很好的解釋了為什麼parse_int的斷點被觸發了,但上述內容並不是我們的重點,下面來我們的重頭戲之一:讓我們看看initcode那堆東西到底做了什麼,即xv6的第一個使用者程式到底做了什麼!
我們直接將斷點打在0x0上,檢視彙編程式碼,si除錯:
...... For help, type "help". Type "apropos word" to search for commands related to "word"... Reading symbols from kernel/kernel... The target architecture is assumed to be riscv:rv64 0x0000000000001000 in ?? () (gdb) b *0x0 Breakpoint 1 at 0x0 (gdb) c Continuing. Breakpoint 1, 0x0000000000000000 in ?? () => 0x0000000000000000: 17 05 00 00 auipc a0,0x0 (gdb) si 0x0000000000000004 in ?? () => 0x0000000000000004: 13 05 05 02 addi a0,a0,32 (gdb) 0x0000000000000008 in ?? () => 0x0000000000000008: 97 05 00 00 auipc a1,0x0 (gdb) 0x000000000000000c in ?? () => 0x000000000000000c: 93 85 05 02 addi a1,a1,32 (gdb) 0x0000000000000010 in ?? () => 0x0000000000000010: 9d 48 li a7,7 (gdb) 0x0000000000000012 in ?? () => 0x0000000000000012: 73 00 00 00 ecall (gdb)
系統呼叫detected,編號為7,檢視kerne/syscall.h可知,編號為7的系統呼叫是SYS_EXEC。我們先把斷點1刪掉避免gdb因為斷點崩潰掉,然後再exec上打斷點:
0x0000000000000010 in ?? () => 0x0000000000000010: 9d 48 li a7,7 (gdb) 0x0000000000000012 in ?? () => 0x0000000000000012: 73 00 00 00 ecall (gdb) delete 1 (gdb) b exec Cannot access memory at address 0x80004da8 (gdb)
嗯,失敗了...不過可以理解,因為這個時候程式在執行使用者程式,而exec的程式碼在核心區,使用者區自然不能去訪問核心區的程式碼了。我們老老實實si單步除錯過ecall,直到CPU進入核心態後再看看能不能打下這個斷點:
=> 0x0000000000000010: 9d 48 li a7,7 (gdb) 0x0000000000000012 in ?? () => 0x0000000000000012: 73 00 00 00 ecall (gdb) delete 1 (gdb) b exec Cannot access memory at address 0x80004da8 (gdb) si 0x0000003ffffff004 in ?? () => 0x0000003ffffff004: 23 34 15 02 sd ra,40(a0) (gdb) 0x0000003ffffff008 in ?? () => 0x0000003ffffff008: 23 38 25 02 sd sp,48(a0) (gdb) ..... 0x0000003ffffff07e in ?? () => 0x0000003ffffff07e: 83 32 05 01 ld t0,16(a0) (gdb) 0x0000003ffffff082 in ?? () => 0x0000003ffffff082: 03 33 05 00 ld t1,0(a0) (gdb) 0x0000003ffffff086 in ?? () => 0x0000003ffffff086: 73 10 03 18 csrw satp,t1 (gdb) b exec Cannot access memory at address 0x80004da8 (gdb) si 0x0000003ffffff08a in ?? () => 0x0000003ffffff08a: 73 00 00 12 sfence.vma (gdb) b exec Breakpoint 2 at 0x80004da8: file kernel/exec.c, line 14. (gdb) c
在執行完csrw satp, t1後,我們終於能在exec上打下斷點了!不過不要打下這個斷點,我們繼續一步一步除錯,程式碼會進入到kernel/trap.c中:
0x0000003ffffff086 in ?? () => 0x0000003ffffff086: 73 10 03 18 csrw satp,t1 (gdb) 0x0000003ffffff08a in ?? () => 0x0000003ffffff08a: 73 00 00 12 sfence.vma (gdb) 0x0000003ffffff08e in ?? () => 0x0000003ffffff08e: 82 82 jr t0 (gdb) usertrap () at kernel/trap.c:41 41 { (gdb) n 44 if((r_sstatus() & SSTATUS_SPP) != 0) (gdb) 54 return x; (gdb)
繼續除錯,終於我們看到了系統呼叫總入口,按下s進入系統呼叫總入口syscall,然後進入我們想要看的系統呼叫sys_exec中。
(gdb) 56 if(r_scause() == 8){ (gdb) 224 return x; (gdb) 59 if(p->killed) (gdb) 64 p->tf->epc += 4; (gdb) 68 intr_on(); (gdb) 70 syscall(); (gdb) s syscall () at kernel/syscall.c:138 138 struct proc *p = myproc(); (gdb) n 140 num = p->tf->a7; (gdb) 141 if(num > 0 && num < NELEM(syscalls) && syscalls[num]) { (gdb) 142 p->tf->a0 = syscalls[num](); (gdb) s sys_exec () at kernel/sysfile.c:419 419 if(argstr(0, path, MAXPATH) < 0 || argaddr(1, &uargv) < 0){ (gdb) n 422 memset(argv, 0, sizeof(argv)); (gdb) 424 if(i >= NELEM(argv)){ (gdb) n 427 if(fetchaddr(uargv+sizeof(uint64)*i, (uint64*)&uarg) < 0){ (gdb) 430 if(uarg == 0){ (gdb) 434 argv[i] = kalloc(); (gdb) 435 if(argv[i] == 0) (gdb) 437 if(fetchstr(uarg, argv[i], PGSIZE) < 0){ (gdb) 424 if(i >= NELEM(argv)){ (gdb) 427 if(fetchaddr(uargv+sizeof(uint64)*i, (uint64*)&uarg) < 0){ (gdb) 430 if(uarg == 0){ (gdb) 431 argv[i] = 0; (gdb) 442 int ret = exec(path, argv); (gdb) p path $1 = "/init\000\000\000 \337\377\377?\000\000\000\340\061\001\200\000\000\000\000 \337\377\377?\000\000\000@\337\377\377?\000\000\000\246\n\000\200\000\000\000\000\330\061\001\200\000\000\000\000\310\061\001\200\000\000\000\000`\337\377\377?\000\000\000\034\061\000\200\000\000\000\000\310\061\001\200", '\000' <repeats 12 times>, "\220\337\377\377?\000\000\000\256?\000\200\000\000\000\000\220\337\377\377?\0
OK,exec的東西我們已經可以知道了,它要將init這個程式“裝入”到核心中。這個程式對應的C程式碼在user/init.c下,對應的ELF檔案為user/_init。我們不再仔細的看exec了,後面我可能會單獨寫一篇blog細講ELF檔案和exec(不過大概率無限咕咕咕),直接單行跳過,從sys_exec中跳出,回到了trap.c的usertrap()函式中,下一步就會從使用者trap裡返回使用者態:
442 int ret = exec(path, argv); (gdb) p path $1 = "/init\000\000\000 \337\377\377?\000\000\000\340\061\001\200\000\000\000\000 \337\377\377?\000\000\000@\337\377\377?\000\000\000\246\n\000\200\000\000\000\000\330\061\001\200\000\000\000\000\310\061\001\200\000\000\000\000`\337\377\377?\000\000\000\034\061\000\200\000\000\000\000\310\061\001\200", '\000' <repeats 12 times>, "\220\337\377\377?\000\000\000\256?\000\200\000\000\000\000\220\337\377\377?\000\000\000\220\337\377\377?\000\000" (gdb) n 444 for(i = 0; i < NELEM(argv) && argv[i] != 0; i++) (gdb) n 445 kfree(argv[i]); (gdb) 444 for(i = 0; i < NELEM(argv) && argv[i] != 0; i++) (gdb) 447 return ret; (gdb) n usertrap () at kernel/trap.c:79 79 if(p->killed) (gdb) n 86 usertrapret();
usertrap () at kernel/trap.c:79 79 if(p->killed) (gdb) 86 usertrapret(); (gdb) s usertrapret () at kernel/trap.c:95 95 struct proc *p = myproc(); (gdb) n 99 intr_off(); (gdb) 166 asm volatile("csrw stvec, %0" : : "r" (x)); (gdb) 106 p->tf->kernel_satp = r_satp(); // kernel page table (gdb) 202 return x; (gdb) 107 p->tf->kernel_sp = p->kstack + PGSIZE; // process's kernel stack (gdb) 108 p->tf->kernel_trap = (uint64)usertrap; (gdb) 109 p->tf->kernel_hartid = r_tp(); // hartid for cpuid() (gdb) 297 return x; (gdb) 115 unsigned long x = r_sstatus(); (gdb) 116 x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode (gdb) 60 asm volatile("csrw sstatus, %0" : : "r" (x)); (gdb) 120 asm volatile("csrw sepc, %0" : : "r" (x)); (gdb) 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
這個詭異的函式指標和函式呼叫,我們不能用n,因為很可能找不到對應的C程式碼,我們用si苟過去:
130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) si 0x0000000080002814 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002816 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x000000008000281a 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x000000008000281e 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002820 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002822 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002824 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002826 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002828 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x000000008000282c 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x000000008000282e 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000000080002830 130 ((void (*)(uint64,uint64))fn)(TRAPFRAME, satp); (gdb) 0x0000003ffffff090 in ?? () => 0x0000003ffffff090: 73 90 05 18 csrw satp,a1 (gdb) 0x0000003ffffff094 in ?? () => 0x0000003ffffff094: 73 00 00 12 sfence.vma (gdb) 0x0000003ffffff098 in ?? () => 0x0000003ffffff098: 83 32 05 07 ld t0,112(a0) (gdb) 0x0000003ffffff09c in ?? () => 0x0000003ffffff09c: 73 90 02 14 csrw sscratch,t0
後面的彙編程式碼其實就是trampoline.S下的userret函式。它完成從核心態到使用者態的返回。至此係統呼叫sys_exec的其實在系統呼叫之前執行的外殼函式(就是ecall那一塊的程式碼),就是其下的uservec函式。userret函式完成從核心態到使用者態的返回。至此係統呼叫sys_exec的流程已經結束。如果你希望看到這段程式碼回到使用者態,還需要重新載入使用者態相應的符號表。但使用者態程式碼是initcode,所以你無法觀看。不過沒關係,掌握了核心符號表與使用者程式符號表的切換,你可以隨心所欲的除錯系統呼叫,套路都是一樣的。
最後我們來看一下init.c的程式碼:
// init: The initial user-level program #include "kernel/types.h" #include "kernel/stat.h" #include "user/user.h" #include "kernel/fcntl.h" char *argv[] = { "sh", 0 }; int main(void) { int pid, wpid; if(open("console", O_RDWR) < 0){ mknod("console", 1, 1); open("console", O_RDWR); } dup(0); // stdout dup(0); // stderr for(;;){ printf("init: starting sh\n"); pid = fork(); if(pid < 0){ printf("init: fork failed\n"); exit(1); } if(pid == 0){ exec("sh", argv); printf("init: exec sh failed\n"); exit(1); } while((wpid=wait(0)) >= 0 && wpid != pid){ //printf("zombie!\n"); } } }
大致意思是開啟標準輸入(0)、標準輸出(1)、標準錯誤輸出(2)對應的終端。由於所有的程式的祖先程式都是這個pid = 1的程式,因此它們都會繼承標準輸入和標準輸出。隨後初代程式(怎麼這麼中二?)fork,自己迴圈呼叫wait回收殭屍程式,子程式(即pid = 2的程式)執行sh,即載入我們的shell,這樣我們就可以利用shell操作我們的xv6了。
最後我們總結一下xv6的第一個使用者程式總流程:
1) xv6成功boot,啟動第一個使用者程式,初始化程式碼為initcode,這段initcode寫死在了kernel/proc.c中
2) 第一個使用者程式(即initcode程式碼)開始執行,初始指標為0x0。initcode程式碼僅僅是一行 exec("init"),即將init"裝入"到當前程式中。
3) init程式裝入後執行fork,父程式pid=1,無限迴圈呼叫wait回收殭屍程式,子程式pid=2,呼叫exec("sh"),即啟動shell,開啟互動介面
OK,如果你能把這節內容掌握,你就可以自由的在xv6中往返於核心和使用者空間了。
用vscode除錯xv6
下面進入本blog的第二個重頭戲:告別gdb的介面,使用vscode來除錯核心!
其實vscode本身僅僅是個編輯器,並不具有除錯能力,它所做的不過是和gdb互動,將gdb輸出的除錯資訊重新渲染到介面上而已。
除錯xv6,需要用到gdb的remote debug模式,由qemu提供一個GDBstub,gdb需要連線到這個GDBstub上,建議閱讀以下文件:http://davis.lbl.gov/Manuals/GDB/gdb_17.html
我們需要給在vscode中為xv6配置相應的launch.json檔案:
{
"version": "0.2.0",
"configurations": [
{
"name": "debug xv6",
"type": "cppdbg",
"request": "launch",
"program": "${workspaceFolder}/kernel/kernel",
"args": [],
"stopAtEntry": true,
"cwd": "${workspaceFolder}",
"miDebuggerServerAddress": "localhost:26000",
"miDebuggerPath": "/usr/local/bin/riscv64-unknown-elf-gdb",
"environment": [],
"externalConsole": false,
"MIMode": "gdb",
"setupCommands": [
{
"description": "pretty printing",
"text": "-enable-pretty-printing",
"ignoreFailures": true
}
],
"logging": {
// "engineLogging": true,
// "programOutput": true,
}
}
]
}
program就是在kernel/下的kernel
miDebuggerServerAddress設定為gdbstub的地址(我的機器上一般是localhost:26000,可以檢視makefile的輸出確定)
miDebuggerPath是我們除錯riscv所用的gdb地址
stopAtEntry設定為true時,程式將在入口處觸發一次斷點,方便我們打新的斷點
logging選項控制vscode端除錯過程的輸出,engineLogging和programOutput是兩個比較重要的除錯日誌,如果除錯出現錯誤,可以將這兩個選項設為true,檢視日誌輸出確認問題所在。
配置好上述檔案好,先不要啟動除錯,先開啟一個終端,輸入make qemu-gdb。在專案根目錄下會有一個.gdbinit檔案,開啟檔案可以看到下面的內容:
set confirm off set architecture riscv:rv64 target remote 127.0.0.1:26000 symbol-file kernel/kernel set disassemble-next-line auto
.gdbinit檔案gdb初始化時的配置檔案。當啟動gdb時,gdb會自動在根目錄下搜尋.gdbinit檔案,如果有則一定會執行一次其中的配置。這個.gdbinit告訴我們,qemu提供了一個GDBstub(127.0.0.1:26000)。另一臺機器啟動時可以連線到這個GDBstub上,即可遠端除錯。
由於我們在vscode中已經設定了target-remote模式,因此在執行vscode中的debug時,(127.0.0.1:26000)的連線會被建立兩次,一次由vscode觸發,另一次由.gdbinit觸發,第二次連線會強行中斷第一次連線。因此執行make qemu-gdb後,要將target remote 127.0.0.1:26000這行刪去,否則會爆GDBstub錯誤。
set confirm off set architecture riscv:rv64 symbol-file kernel/kernel set disassemble-next-line auto
在vscode中點選除錯按鈕,程式即可到達核心main的入口:
那麼在vscode中怎麼切換符號表檔案呢?底側欄有一個“除錯控制檯”,在其中可以直接輸入gdb命令。我們只需要輸入 -exec file /user/_sleep,即可切換到_sleep的符號表,現在我們的user/sleep.c下已經可以打斷點了!但是如果打斷點,一定要在程式碼側欄打,不要再除錯控制檯中用 -exec b func來打,否則vscode會出現異常。
注意我們的斷點是紅的,說明斷點有效。
如果vscode除錯提示GDBstub出現問題,基本可以確定時因為gdb的設定出現了問題,可以將launch.json中logging的幾個選項置true,然後在底端的“輸出”欄看輸出日誌,定位問題在哪裡。
ok,和gdb說f**k off吧!
小Tips
1、後面的Lab會對核心進行魔改,經常會出現page fault。如果沒有實現lazy allocator,基本可以確定是程式碼越界問題。但如果打斷點在panic上,只能看到核心態的堆疊,而看不到使用者態的堆疊,不利於定位使用者程式碼的問題。這種情況下可以看日誌stepc的輸出,並在用這個地址打下斷點。根據gdb的除錯資訊,可以確認問題所在的檔案和行數。僅當epc真正指向一個有效的函式塊內時可以使用。如果出現instruction page fault,那麼epc指向的就是一個非法地址,在這個地址上是無法打斷點的。
2、使用*(array)@10,可以將指標array解釋為陣列,並列印後面的10個元素。具體可見這篇blog:https://github.com/Microsoft/vscode-cpptools/issues/172#issuecomment-460063503
後記
熬夜寫完後突然想起來,今天晚上離開實驗是的時候忘記打卡了.....一個晚上白乾.....
後面會慢慢開始更新blog,主要更新自己上的一些公開課(已經完成了的6.824,正在肝的6.828和15-445)的一些筆記。後面會有開題和小論文,下半年留著刷leetcode和背面試八股,能發育的事件已經所剩不多了,加油吧。
個人是半途轉行的非科班生,對於技術的見解也會有很多錯誤,如果有dalao發現錯誤,還請從評論區指出,萬分感謝。