使用gdb调试xv6内核

从最近两个 Lab 开始,代码逻辑的复杂度明显上升,对内核进行调试可能是帮助理解操作系统机制的绝佳方法。因此在开始本 Lab 之前,我们先来配置一下针对 xv6 内核的 gdb 调试器。

  1. 安装 gdb-multiarch.

利用包管理工具进行安装,我使用的是 Ubuntu 系统,执行以下命令:

1
sudo apt install gdb-multiarch
  1. 在 xv6 项目根目录下可以看到 .gdbinit 文件,其中已经写好了一些 gdb 的初始化选项,使用文本编辑器或 cat 命令查看:
1
2
3
4
5
6
set confirm off                                                         
set architecture riscv:rv64
target remote 127.0.0.1:26000
symbol-file kernel/kernel
set disassemble-next-line auto
set riscv use-compressed-breakpoints yes
  1. ~/.config/gdb/ 目录下的文件 gdbinit 中(没有则新建)添加安全加载路径,否则可能无法加载 .gdbinit 的配置。
1
add-auto-load-safe-path <xv6项目的根目录>/.gdbinit
  1. 打开两个终端窗口(可以使用 tmux 进行分屏),都需要进入 xv6 根目录,第一个窗口输入 make-qemu 等待调试器连接,第二个窗口输入 gdb-multiarch 打开 gdb,如果前面配置正确,那么 gdb 并自动加载 .gdbinit 配置,与 qemu 连接,之后便可以开始正常调试了。

RISC-V assembly

一些有关 RISC-V 汇编的问题,最好先通过网上博客或手册简单了解一下 RISC-V 的基本指令。

Q1:

Which registers contain arguments to functions? For example, which register holds 13 in main’s call to printf?

A1:

可以参考 RISC-V 的 calling conventiona0 - a7: 这些寄存器用于传递函数的前八个整数或指针类型的参数,如果超出这些寄存器的数量,超出的部分会存放在栈上。观察指令 li a2,13 可知,13 作为 printf 的第二个参数,存放在寄存器 a2 中。

Q2:

Where is the call to function f in the assembly code for main? Where is the call to g? (Hint: the compiler may inline functions.)

A2:

调用函数 f 和函数 g 的代码被编译器优化,直接计算出了结果 12,作为 printf 的参数存入寄存器 a1 中:

1
26:   45b1                    li  a1,12

Q3:

At what address is the function printf located?

A3:

位于 0x638 地址处。

Q4:

What value is in the register ra just after the jalr to printf in main?

A4: 参考 riscv-callingra 用来存储函数调用的返回地址,因此 ra 的值为 jalr 1544(ra) 的后一条指令地址,即 0x38.

Q5:

Run the following code.

1
2
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, &i);

What is the output? Here’s an ASCII table that maps bytes to characters.

The output depends on that fact that the RISC-V is little-endian. If the RISC-V were instead big-endian what would you set i to in order to yield the same output? Would you need to change 57616 to a different value?

Here’s a description of little- and big-endian and a more whimsical description.

A5:

  • %x 用于输出一个无符号十六进制整数。
  • %s 用于输出一个字符指针所指向的字符串,直到遇到空字符\0为止。

小端模式下,57616 的 十六进制表示为 e110,&i 首地址开始的字节分别为 0x72, 0x6c, 0x64, 0x0,对应 ASCII 表中的字符为 r, l, d,因此最终输出结果为 He110 World.

若采取大端模式,i 的值应当替换为 0x726c6400,57616 的值无需改变,因为十六进制的书写规则并没有改变(高位在左,低位在右)。

Q6:

In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?

1
printf("x=%d y=%d", 3);

A6:

关于可变参数的内容查看 《C Programming Language 2nd Edition》(K\&R)的 7.3 节 Variable-length Argument Lists.

简而言之,这样的操作将引发未定义行为,此时 ap 指向了一个未知的内存区域,并将该区域的数据以整型的形式输出。

Backtrace

思路

思路其实很简单:对照 lecture notes 给出的栈的结构,从当前栈帧的起始地址 fp 开始,fp - 8 的位置存放着当前函数调用的返回地址(上一次函数调用处的下一条指令地址),即我们 需要打印 的地址,fp - 16 的位置存放着上一次函数调用所在栈帧的起始地址,将该地址作为新的 fp 重复上述步骤即可。

关键问题是 什么时候停止 ?可以看到上述 backtrace 的过程就好像是在遍历一个链表,当链表的 next 域为空指针时链表到达末尾,那 traceback 完成后fp 的值应该是什么?为了寻找这个问题的答案,我选择先不设置终止条件,让它一直向上搜索,最后发现,返回地址最终为一个很小的值,这个地址显然不是我们想要的,在此之前应该退出,即本次 traceback 的尽头是 0x80001c92.

但打印出来的函数调用的返回地址似乎并没有什么规律,因此我又尝试将遍历过程中的栈帧起始地址 fp 打印出来,得到以下结果:

结合提示:

Xv6 allocates one page for each stack in the xv6 kernel at PAGE-aligned address.

原因就很明显了,在打印第三个返回地址时,此时栈帧起始地址为 0x3fffffa000,注意该地址后 12 二进制数为 0,且页面大小为 4KB,因此该地址位于一个页面的起始地址。又因为 xv6 内核只为每个 内核栈 分配一个页面的存储空间,该页面的起始地址按页面大小对齐,所以此时已经到达一个内核栈的顶端,无需继续遍历。

弄清楚了这些,代码的编写就很简单了:

1
2
3
4
5
6
7
8
9
10
void backtrace(void) {
printf("backtrace:\n");
uint64 fp = r_fp();
uint64 top = PGROUNDUP(fp);

do {
printf("%p\n", *(uint64 *)(fp - 8));
fp = *(uint64 *)(fp - 16);
} while (fp < top); // reach the top of kernel stack
}

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
--- a/kernel/defs.h
+++ b/kernel/defs.h
@@ -80,6 +80,7 @@ int pipewrite(struct pipe*, uint64, int);
void printf(char*, ...);
void panic(char*) __attribute__((noreturn));
void printfinit(void);
+void backtrace(void); // here

// proc.c
int cpuid(void);
diff --git a/kernel/printf.c b/kernel/printf.c
index e1347de..a068cbd 100644
--- a/kernel/printf.c
+++ b/kernel/printf.c
@@ -114,6 +114,23 @@ printf(char *fmt, ...)
release(&pr.lock);
}

+// here
+void backtrace(void) {
+ printf("backtrace:\n");
+ uint64 fp = r_fp();
+ uint64 top = PGROUNDUP(fp);
+
+ do {
+ printf("%p\n", *(uint64 *)(fp - 8));
+ fp = *(uint64 *)(fp - 16);
+ } while (lower < top);
+}
+
void
panic(char *s)
{
diff --git a/kernel/riscv.h b/kernel/riscv.h
index 1691faf..fae7bf3 100644
--- a/kernel/riscv.h
+++ b/kernel/riscv.h
@@ -331,6 +331,15 @@ sfence_vma()
asm volatile("sfence.vma zero, zero");
}

+// here
+static inline uint64
+r_fp()
+{
+ uint64 x;
+ asm volatile("mv %0, s0" : "=r" (x) );
+ return x;
+}
+

#define PGSIZE 4096 // bytes per page
#define PGSHIFT 12 // bits of offset within a page
diff --git a/kernel/sysproc.c b/kernel/sysproc.c
index e8bcda9..f27c007 100644
--- a/kernel/sysproc.c
+++ b/kernel/sysproc.c
@@ -70,6 +70,7 @@ sys_sleep(void)
sleep(&ticks, &tickslock);
}
release(&tickslock);
+ backtrace(); // here
return 0;
}

Alarm

思路

目前为止感觉最复杂的一题,需要对 trap 机制有一个比较深入的理解,建议在上手之前先仔细阅读与 trap 有关的代码:kernel/trampoline.Skernel/trap.c,这里也推荐一位博主写的两篇有关 xv6 的 trap 机制的博客:

6.S081——陷阱部分(一文读懂xv6系统调用)——xv6源码完全解析系列(5)

6.S081——补充材料——RISC-V架构中的异常与中断详解

test0: invoke handler

我们不妨按照提示的顺序来进行,不关注 sys_sigreturn,先把 sys_sigalarm 的功能实现。

实际上,sys_sigalarm 函数的功能很简单,只是简单地将用户态下传递的参数 tickshandler 存入进程的 struct proc 结构体中。实现调用 handler 的操作需要在内核态下的 usertrap 中完成,具体来说,针对时钟中断导致的 trap 将在 if(which_dev == 2) 后的语句中被处理。有两个目标需要完成: 定时函数调用

定时的逻辑比较清楚,在 struct proc 中添加变量 ticksum,代表从上次 handler 处理完成开始进程累计的时钟中断次数,该变量在进程初始化时设置为 0,随后每次遇到时钟中断,都自增 1,如果自增后的值达到了设定的间隔 ticks,则将其复位为 0,调用 handler 函数。

函数调用是一个需要考虑的问题,这里不能直接利用函数指针 handler 进行函数调用,因为 handler 指向的函数位于用户空间下,而 usertrap 位于内核态下,页表的地址映射不同,无法直接根据用户空间下的虚拟地址进行寻址(直接调用引发的错误如下图所示),需要在本次中断结束返回到用户态之后执行。因此正确的做法应该是设置进程 struct procepc 寄存器为函数指针 handler,这样在中断处理完成,进程回到用户态并被 CPU 调度执行后,寄存器 pc 将被设置预先保存的 epc 的值,这样函数 handler 就被成功调度执行了。至此,test0 应该成功通过。

在进入到 test1\&2 之前,有必要说一说我的一些思考:在上面的讨论中,我们知道内核无法直接根据函数指针 handler 的值进行用户空间函数的调用,那能否在内核态下根据进程的用户态页表和给定的虚拟地址,利用软件地址转换机制(vm.c 中的 walkaddr 函数)来将用户空间的虚拟地址转换为物理地址进行寻址呢(这也是我最开始的想法)?答案是不行,因为即便是在内核态下,程序中的地址仍然是虚拟地址,也就是说即便知道用户态函数实际存储的物理地址,我们也只有在 给出一个虚拟地址,该虚拟地址经过内核页表地址转换之后,刚好得到了正确的物理地址, 才可能成功。而实际上,尽管内核 KERNBASEPHYSTOP 地址都是直接映射,但内核页表中可能并没有所需要的页表项,因此,这并不会成功。

test1/test2(): resume interrupted code

test1 的目标是,存储和恢复中断处理前后的寄存器状态。那么问题就来了:为什么需要存储这些寄存器?需要存储哪些寄存器?

其实最开始,我是有些纠结寄存器状态的存储目的是什么,认为可能是与内核态和用户态切换有关,但仔细想想,这部分的工作应该是由 trampoline.Susertrapret 来完成的,那么为什么还需要存储和恢复寄存器?

事实上,在系统未关闭中断的情况下,时钟中断可能在程序执行的任何时刻发生,且在返回到原程序位置继续执行之前还需要执行预先设定好的 handler 函数,那么寄存器状态的保存将是必要的。一方面在执行 handler 函数期间,如果 handler 函数包含一些对局部变量的处理,那么通用寄存器的值将会发生改变,从而使得中断返回时程序的执行结果与预期不符;另一方面,由于 epc 的值被手动改变,如果执行完 handler 之后不恢复中断发生时的保存的 pc 值,那么 pc 将会指向 handler 函数末尾的下一条指令,中断因此无法正常返回。 简单来说,这部分的操作相当于手动模拟了 线程 的切换。

另一个问题是:需要存储哪些寄存器?好吧,在解决这个 Lab 时我其实偷了点懒,没有去仔细琢磨,只是简单地将整个 trapframe 中所有的寄存器都保存下来。但根据上面的讨论,再结合 RISC-V 的 calling convention,应该不难得出答案。

最后的 test2 就比较简单了,目标是:

Prevent re-entrant calls to the handler——if a handler hasn’t returned yet, the kernel shouldn’t call it again.

解决的办法有很多,可以额外在 strcut proc 添加一个变量,用来表示进程当前是否正处在处理 handler 的过程中,如果是,则不进行 ticksum 的自增操作。这里我采用了一点 小技巧 :不添加额外的变量,而是在处理 handler 前将 ticksum 置为负数,并在自增前判断 ticksum 是否非负,在 sys_sigreturn 时再将它置为 0,本质上与添加变量的操作大差不差。

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
diff --git a/Makefile b/Makefile
index 7a7e380..bc4d47a 100644
--- a/Makefile
+++ b/Makefile
@@ -188,6 +188,7 @@ UPROGS=\
$U/_grind\
$U/_wc\
$U/_zombie\
+ $U/_alarmtest\

diff --git a/kernel/proc.c b/kernel/proc.c
index 22e7ce4..80096f7 100644
--- a/kernel/proc.c
+++ b/kernel/proc.c
@@ -119,6 +119,7 @@ allocproc(void)
found:
p->pid = allocpid();
p->state = USED;
+ p->ticksum = 0; // here

// Allocate a trapframe page.
if((p->trapframe = (struct trapframe *)kalloc()) == 0){
diff --git a/kernel/proc.h b/kernel/proc.h
index f6ca8b7..c1d5a23 100644
--- a/kernel/proc.h
+++ b/kernel/proc.h
@@ -105,4 +105,10 @@ struct proc {
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
+
+ int ticks; // here
+ void (*handler)();
+ int ticksum;
+
+ struct trapframe strapframe;
};
diff --git a/kernel/syscall.c b/kernel/syscall.c
index c1b3670..d4e5585 100644
--- a/kernel/syscall.c
+++ b/kernel/syscall.c
@@ -104,6 +104,8 @@ extern uint64 sys_unlink(void);
extern uint64 sys_wait(void);
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);
+extern uint64 sys_sigalarm(void); // here
+extern uint64 sys_sigreturn(void);

static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
@@ -127,6 +129,8 @@ static uint64 (*syscalls[])(void) = {
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
+[SYS_sigalarm] sys_sigalarm, // here
+[SYS_sigreturn] sys_sigreturn,
};

void
diff --git a/kernel/syscall.h b/kernel/syscall.h
index bc5f356..a040610 100644
--- a/kernel/syscall.h
+++ b/kernel/syscall.h
@@ -20,3 +20,5 @@
#define SYS_link 19
#define SYS_mkdir 20
#define SYS_close 21
+#define SYS_sigalarm 22 // here
+#define SYS_sigreturn 23
diff --git a/kernel/sysproc.c b/kernel/sysproc.c
index f27c007..ee859ed 100644
--- a/kernel/sysproc.c
+++ b/kernel/sysproc.c
@@ -96,3 +96,28 @@ sys_uptime(void)
release(&tickslock);
return xticks;
}
+
+// here
+uint64 sys_sigalarm(void) {
+ struct proc *p = myproc();
+
+ if (argint(0, &(p->ticks)) < 0) {
+ return -1;
+ }
+
+ if (argaddr(1, (uint64 *)&(p->handler)) < 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+uint64 sys_sigreturn(void) {
+ struct proc *p = myproc();
+
+ // restore registers
+ memmove(p->trapframe, &(p->strapframe), sizeof(p->strapframe));
+
+ p->ticksum = 0;
+ return 0;
+}
diff --git a/kernel/trap.c b/kernel/trap.c
index a63249e..447e6d8 100644
--- a/kernel/trap.c
+++ b/kernel/trap.c

@@ -77,8 +77,17 @@ usertrap(void)
exit(-1);

// give up the CPU if this is a timer interrupt.
- if(which_dev == 2)
+ if(which_dev == 2) {
+ // here
+ if (p->ticks > 0 && p->ticksum >= 0 && ++(p->ticksum) >= p->ticks) {
+ // save registers
+ memmove(&(p->strapframe), p->trapframe, sizeof(p->strapframe));
+
+ p->ticksum = -1; // prevent re-entrant calls to the handler
+ p->trapframe->epc = (uint64)p->handler;
+ }
yield();
+ }

usertrapret();
}
diff --git a/user/user.h b/user/user.h
index b71ecda..422a4c1 100644
--- a/user/user.h
+++ b/user/user.h
@@ -23,6 +23,8 @@ int getpid(void);
char* sbrk(int);
int sleep(int);
int uptime(void);
+int sigalarm(int ticks, void (*handler)()); // here
+int sigreturn(void);

// ulib.c
int stat(const char*, struct stat*);
diff --git a/user/usys.pl b/user/usys.pl
index 01e426e..84c6784 100755
--- a/user/usys.pl
+++ b/user/usys.pl
@@ -36,3 +36,5 @@ entry("getpid");
entry("sbrk");
entry("sleep");
entry("uptime");
+entry("sigalarm"); # here
+entry("sigreturn");