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.
可以看到编译后的结果中有不少.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函数后就越界了。
$ alloctest filetest: start filetest: OK memtest: start scause 0x000000000000000f (store/AMO page fault) sepc=0x0000000080000cb8 stval=0x0000000000000000
Breakpoint 6 at 0x80000cb8: file kernel/string.c, line 9.
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发现错误,还请从评论区指出,万分感谢。