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发现错误,还请从评论区指出,万分感谢。

 

posted @ 2020-09-25 14:43  KatyuMarisa  阅读(16511)  评论(4编辑  收藏  举报