MIT 6.S081 xv6調試不完全指北


前言

今晚在實驗室摸魚做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.
readelf

可以看到編譯后的結果中有不少.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吧!

雖然標題是“調試xv6的第一個進程”,但實際上我們省略了這個進程從userinit結束后到initcode被加載前的這一段過程。這段過程需要充分閱讀proc.c的源碼和xv6 book的相應章節后才能理解。后面我會在講進程和進程調度時仔細討論這段過程。

小Tips

1、如果沒有實現lazy allocation,那么發生page fault原因多半是訪問越界。例如用戶程序如果退出時使用了return 0而非exit(0),那么pc在執行完main函數后就越界了。

遇到這種情況,可以記錄下sepc和stval的值。然后再gdb中 -exec b *sepc。例如我在做alloctest時報錯:
$ alloctest
filetest: start
filetest: OK
memtest: start
scause 0x000000000000000f (store/AMO page fault)
sepc=0x0000000080000cb8 stval=0x0000000000000000
在vscode的調試控制台輸入 -exec b *0x0000000080000cb8,獲得輸出信息:
Breakpoint 6 at 0x80000cb8: file kernel/string.c, line 9.
這樣trap源就可以找到了。
這種方法不適合尋找instruction page fault的源,因為這個時候epc的值本身就是非法的。

2、使用*(array)@10,可以將指針array解釋為數組,並打印后面的10個元素。

具體可見這篇blog:https://github.com/Microsoft/vscode-cpptools/issues/172#issuecomment-460063503

3、如果希望完整的調試內核中的流程,盡可能采用n(單行調試),不要使用c(快進到下一斷點)。因為內核中不止有一個進程存在進程間可能在你按c的時候發生切換。比如你在調試file.c中的相關代碼,兩個斷點間經常會有begin_op或者end_op。begin_op和end_op是操縱設備的底層代碼,如果設備此時忙,則會讓當前進程休眠等待。如果你在begin_op前按了c而此時設備是忙的,那么會切換到另一個進程,你此時可能就到達不了begin_op后的那個斷點,而是觸發了另一個進程中的某個斷點(因為進程切換后pc值也切換了,然后執行到了該斷點)。

后記

熬夜寫完后突然想起來,今天晚上離開實驗室時候忘記打卡了.....一個晚上白干.....

后面會慢慢開始更新blog,主要更新自己上的一些公開課(已經完成了的6.824,正在肝的6.828和15-445)的一些筆記。后面會有開題和小論文,下學期留着刷leetcode和背面試八股,能發育的時間已經所剩不多了,加油吧。

個人是半途轉行的非科班生,對於技術的見解也會有很多錯誤,如果有dalao發現錯誤,還請從評論區指出,萬分感謝。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM