MIT6.S081-lab4
注:本篇lab的前置知識在《MIT6.S081-lab3前置》
1. RISC-V assembly
第一個問題
Which registers contain arguments to functions? For example, which register holds 13 in main’s call to
printf
?
我們先來看看main干了什么:
void main(void) {1c: 1141 addi sp,sp,-161e: e406 sd ra,8(sp) 20: e022 sd s0,0(sp)22: 0800 addi s0,sp,16printf("%d %d\n", f(8)+1, 13); # 編譯器直接算出來了,無需調用f和g函數24: 4635 li a2,13 # printf參數存入寄存器a226: 45b1 li a1,12 28: 00001517 auipc a0,0x1 # 存入格式格式字符串的大致地址,printf的第一個參數2c: 84850513 addi a0,a0,-1976 # a0 = a0 - 1976,即精確地得到格式字符串地址 "%d %d\n"30: 68c000ef jal 6bc <printf>exit(0);34: 4501 li a0,0 36: 26e000ef jal 2a4 <exit>
綜上,a0,a1,a2存放了對應的調用函數所要用的參數。
第二個問題
is the call to function
f
in the assembly code for main? Where is the call tog
? (Hint: the compiler may inline functions.)
對f的調用我發現已經被編譯器所優化了,這里直接將一個立即數存入了a1中:
26: 45b1 li a1,12
第三個問題
At what address is the function
printf
located?
根據匯編代碼,我們可以知道,printf位于6bc
處,事實上,我們可以在call.asm里面搜索printf,我們可以找到,函數的入口確實是6bc
:
....
void
printf(const char *fmt, ...)
{6bc: 711d addi sp,sp,-966be: ec06 sd ra,24(sp)6c0: e822 sd s0,16(sp)6c2: 1000 addi s0,sp,32.....
第四個問題
What value is in the register
ra
just after thejalr
toprintf
inmain
?
在main里面,我們很容易發現,根本沒有用到ra寄存器,但是其實,ra存儲的一般是我們的函數返回的地址,所以在我們調用jal的時候, 會自動將下一條指令的地址存入ra寄存器中,即0x34
第五個問題
Run the following code. What is the output?
unsigned int i = 0x00646c72;printf("H%x Wo%s", 57616, (char *) &i);
輸出:He110 World
,大端模式則需要將i修改為0x72 6c 64 00
,我們可以發現就是反轉了一下,而另一個數字無需修改,因為這個打印的是16進制表示數字,與大端小端字節序無關。
第六個問題
In the following code, what is going to be printed after
'y='
? (note: the answer is not a specific value.) Why does this happen?printf("x=%d y=%d", 3);
未定義行為,這取決于對應寄存器的值。
2. Backtrace
常爆panic的同學應該會對這個backtrace非常熟悉,他會打印我們函數調用鏈路的函數返回的地方。這就是我們實驗需要實現的東西了,根據lab1,我們可以知道,函數調用的時候,都會把函數返回的地方的地址存儲起來,那么我們的目的,就是找到這個存儲地址的地方,并且將他打印出來。
難點就在于怎么去找到這個地址,光靠自己去推理,肯定是很困難的,這時候就需要看給我們的hint。
首先,我們向kernel/defs.h中添加static inline uint64 r_fp()
這個函數,用來在我們的當前需要編寫的backtrace中獲取當前的幀指針,以此為基礎,來獲取之前的函數返回地址。
隨后繼續往下看:
- These lecture notes have a picture of the layout of stack frames. Note that the return address lives at a fixed offset (-8) from the frame pointer of a stackframe, and that the saved frame pointer lives at fixed offset (-16) from the frame pointer.
- Your
backtrace()
will need a way to recognize that it has seen the last stack frame, and should stop. A useful fact is that the memory allocated for each kernel stack consists of a single page-aligned page, so that all the stack frames for a given stack are on the same page. You can usePGROUNDDOWN(fp)
(seekernel/riscv.h
) to identify the page that a frame pointer refers to.
我們可以通過這個hint知道,我們保存的地址的偏移量是-8,而想要得到上一個幀地址,就需要-16,然后繼續以此為-8為偏移量去得到我們的保存的return地址,并且在遇到頁的邊緣的時候,我們就會停止回溯。
于是,我們的backtrace代碼就可以寫出來了:
void backtrace(void) {printf("backtrace:\n");uint64 ra, fp = r_fp();// 獲取前一個幀指針的位置,位于當前幀指針 fp - 16 的位置// 按照調用約定,fp-8 是返回地址,fp-16 是上一個函數的幀指針uint64 pre_fp = *((uint64*)(fp - 16));// 當上一個幀指針和當前幀指針還在同一個物理頁中(即沒有越過頁邊界)時,繼續回溯while (PGROUNDDOWN(fp) == PGROUNDDOWN(pre_fp)) {ra = *(uint64 *)(fp - 8);printf("%p\n", (void*)ra);// 更新當前幀指針為上一個幀指針fp = pre_fp;// 繼續獲取上一個幀的幀指針pre_fp = *((uint64*)(fp - 16));}// 打印最后一個返回地址(最后一個棧幀)ra = *(uint64 *)(fp - 8);printf("%p\n", (void*)ra);
}
除此之外,記得在kernel/defs.h定義我們的backtrace函數,并且將這個函數添加到sys_sleep中。
這樣,backtrace就算完成了。
3. Alarm
實驗要求是注冊一個時間間隔和函數到當前的cpu,到點的時候就會調用這個函數,并且期間要求恢復我們的當前進程的上下文(寄存器)不受影響,簡單來講,就是一個非常tiny的trap。
首先我們閱讀hint,這個實驗不讀hint真的是沒法做。
You’ll need to modify the Makefile to cause
alarmtest.c
to be compiled as an xv6 user program.The right declarations to put in user/user.h are:
int sigalarm(int ticks, void (*handler)());int sigreturn(void);
Update user/usys.pl (which generates user/usys.S), kernel/syscall.h, and kernel/syscall.c to allow
alarmtest
to invoke the sigalarm and sigreturn system calls.For now, your
sys_sigreturn
should just return zero.Your
sys_sigalarm()
should store the alarm interval and the pointer to the handler function in new fields in theproc
structure (inkernel/proc.h
).You’ll need to keep track of how many ticks have passed since the last call (or are left until the next call) to a process’s alarm handler; you’ll need a new field in
struct proc
for this too. You can initializeproc
fields inallocproc()
inproc.c
.Every tick, the hardware clock forces an interrupt, which is handled in
usertrap()
inkernel/trap.c
.You only want to manipulate a process’s alarm ticks if there’s a timer interrupt; you want something like
if(which_dev == 2) ...
Only invoke the alarm function if the process has a timer outstanding. Note that the address of the user’s alarm function might be 0 (e.g., in user/alarmtest.asm,
periodic
is at address 0).You’ll need to modify
usertrap()
so that when a process’s alarm interval expires, the user process executes the handler function. When a trap on the RISC-V returns to user space, what determines the instruction address at which user-space code resumes execution?It will be easier to look at traps with gdb if you tell qemu to use only one CPU, which you can do by running
make CPUS=1 qemu-gdb
You’ve succeeded if alarmtest prints “alarm!”.
- Your solution will require you to save and restore registers—what registers do you need to save and restore to resume the interrupted code correctly? (Hint: it will be many).
- Have
usertrap
save enough state instruct proc
when the timer goes off thatsigreturn
can correctly return to the interrupted user code.- Prevent re-entrant calls to the handler----if a handler hasn’t returned yet, the kernel shouldn’t call it again.
test2
tests this.- Make sure to restore a0.
sigreturn
is a system call, and its return value is stored in a0.
這些hint可謂是信息量很大了,簡單梳理一下,我們先將需要的系統調用框架先搭好:
makefile
UPROGS=\$U/_cat\$U/_echo\$U/_forktest\$U/_grep\$U/_init\$U/_kill\$U/_ln\$U/_ls\$U/_mkdir\$U/_rm\$U/_sh\$U/_stressfs\$U/_usertests\$U/_grind\$U/_wc\$U/_zombie\// 添加這一行$U/_alarmtest\
user/usys.pl
entry("sigalarm");
entry("sigreturn");
user/user.h
// lab
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
kernel/syscall.h
#define SYS_sigalarm 22
#define SYS_sigreturn 23
kernel/syscall.c
[SYS_sigalarm] sys_sigalarm,
[SYS_sigreturn] sys_sigreturn
// 這部分加在數組里面,做過之前的lab懂得都懂
目前我們大體的框架是弄好了,隨后著手去看我們的hint,我們可以知道,如果發生了定時器中斷,我們的which_dev就是2,hint告訴了我們這一點,于是,我們可以在這一部分代碼塊寫下我們的中斷邏輯,但是這部分應該如何去寫呢?我們需要去執行我們之前注冊的函數,并且需要保存當前的trapframe,保證之后還能夠回到這里,并且還需要去判斷計時器的時間,并且做一些加減操作,所以,我們在此之前,還需要對我們的proc結構體進行一些修改:
kernel/proc.h
//為proc結構體添加以下字段uint64 interval; // 間隔void (*handler)(); // 定時處理的函數uint64 ticks; // 上一次調用函數距離的時間struct trapframe *alarm_trapframe; // 用于恢復 trapframeint alarm_goingoff; // 是否正在alarm,防止嵌套的中斷,導致trapframe丟失
我們既然多了這么多字段,那么必須要在allocproc里面,也為這些字段進行初始化
static struct proc*
allocproc(void)
{//...found://...if((p->alarm_trapframe = (struct trapframe *)kalloc()) == 0) {freeproc(p);release(&p->lock);return 0;}p->ticks = 0;p->handler = 0;p->interval = 0;p->alarm_goingoff = 0;//...return p;
}
同時,在釋放proc的時候,也需要執行對應的操作:
static void
freeproc(struct proc *p)
{//...// free alarm trapframeif(p->alarm_trapframe)kfree((void*)p->alarm_trapframe);p->alarm_trapframe = 0;//...p->ticks = 0;p->handler = 0;p->interval = 0;p->alarm_goingoff = 0;p->state = UNUSED;
}
隨后,我們需要去編寫我們的具體的系統調用的邏輯,sigalarm和sigreturn
uint64
sys_sigalarm(void) {int n;uint64 handler;// 獲取參數argint(0, &n);argaddr(1, &handler);// 調用下一層return sigalarm(n, (void(*)())(handler));
}uint64
sys_sigreturn(void) {return sigreturn();
}
我們的sigreturn和sigalarm定義在trap.c
int sigalarm(int ticks, void(*handler)()) {// 初始化alarmstruct proc *p = myproc();p->interval = ticks;p->handler = handler;p->ticks = 0;return 0;
}int sigreturn() {struct proc *p = myproc();// 恢復之前的trapframe,并清除alarm標志位*(p->trapframe) = *(p->alarm_trapframe);p->alarm_goingoff = 0;// 這里返回a0的原因是,當我們執行return的時候,返回值會被保存在a0中// 導致a0被覆蓋,所以此時直接返回a0即可,我們在最后會進行分析return p->trapframe->a0;
}
當然,這兩個函數還需要在kernel/defs.h中聲明,否則會報錯!
最后,回到我們的usertrap函數,我們會在這里完成最后的工作
void
usertrap(void)
{//...// give up the CPU if this is a timer interrupt.if(which_dev == 2) {if(p->interval != 0) { // 如果設定了時鐘事件if(p->ticks++ == p->interval) {if(!p->alarm_goingoff) { // 確保沒有時鐘正在運行p->ticks = 0;*(p->alarm_trapframe) = *(p->trapframe);p->trapframe->epc = (uint64)p->handler;p->alarm_goingoff = 1;}}}yield();}usertrapret();
}
我們在which_dev滿足等于2的條件的時候,會增加我們的時鐘計時,當達到我們的間隔時間,就會保存我們的trapframe,并且修改我們的epc,epc是什么?就是我們返回用戶態的時候,會執行的代碼的指針,我們將需要執行的函數的地址賦給epc,也就是說,我們接下來就會去執行它,當然,如果需要我們的之前執行的函數能夠恢復,也就意味著,我們需要在注冊的函數里面主動去調用sigreturn,然后才能恢復到我們原來的用戶態的中斷的地方,這樣,就完成了這個系統調用的閉環。
回到剛剛的問題,為什么要返回a0?
我們可以查看匯編代碼來解決這個問題
kernel/kernel.asm
return p->trapframe->a0;80001c44: 6d3c ld a5,88(a0) # 加載 p->trapframe 的地址到 a5,偏移 88 字節是 trapframe*
}80001c46: 5ba8 lw a0,112(a5) # 加載 trapframe->a0 的值到 a0,偏移 112 字節是 a0 寄存器的位置80001c48: 60a2 ld ra,8(sp) # 恢復調用者的返回地址(ra)80001c4a: 6402 ld s0,0(sp) # 恢復調用者的幀指針(s0)80001c4c: 0141 addi sp,sp,16 # 恢復棧指針(釋放本函數棧幀)80001c4e: 8082 ret # 返回到調用者,返回值已保存在 a0 中
我們可以看見,我們會將返回的代碼賦給a0,但是即便如此,我們的a5也會被覆蓋,所以最好的辦法還是自己用匯編來實現這些上下文的切換。
那么最后,我們的alarm實驗就完成了。
== Test backtrace test ==
$ make qemu-gdb
backtrace test: OK (2.6s)
== Test running alarmtest ==
$ make qemu-gdb
(4.8s)
== Test alarmtest: test0 == alarmtest: test0: OK
== Test alarmtest: test1 == alarmtest: test1: OK
== Test alarmtest: test2 == alarmtest: test2: OK
== Test alarmtest: test3 == alarmtest: test3: OK
== Test usertests ==
$ make qemu-gdb
usertests: OK (151.6s)
即便之前讀過了系統調用陷入的一系列代碼,通過寫這個lab4的實驗,也是比較困難的,但也能學到一些東西的,雖然中途確實看了別人的代碼,但是總歸是寫出來的,重要的不是看了別人的多少的代碼,我倒是覺得這并不可恥,在一些無聊的地方卡住好幾個小時沒有一點進展,而因為秉持著學術誠信最后卻因為一些bug而放棄,這反倒是我最不想看到的,最重要的是從這個實驗中學到了多少,所以,在這里,我將自己學到的分享出去,希望能夠幫助更多的人。
參考文獻:
miigon’blog