Lab: traps
在這一個lab當中6.1810 / Fall 2025它要求我們理解xv6當中函數調用時的堆棧情況以及如何操控內存尋找多級函數調用的起始,更重要的是它帶我們直觀地感受到了中斷的全過程。
在此之前,官網給出了一些提示:
- 在開始編程之前, 請閲讀xv6教程的第4章,以及相關的源碼文件
kernel/trampoline.S。 kernel/trap.c當中是處理所有中斷的代碼。
RISC-V assembly (簡單)
在這個lab當中,要求我們閲讀一些彙編代碼,並且瞭解c語言的某些語句對應的彙編是怎樣的,同時瞭解不同寄存器的不同職責(例如ra寄存器是存放返回地址的寄存器)。然後帶我們瞭解了一下編譯器在編譯代碼時,如何優化/簡化我們的代碼。最後帶我們直觀地理解了一下大端模式和小端模式的區別以及兩者在面對多字節存儲和單個數值存儲所造成的不同的影響。
如何閲讀彙編代碼
以下是截取了call.asm當中的一部分代碼,這類代碼是反彙編得來的結果。接下來將開始解析這段程序。
##這是c語言函數f的反彙編代碼(通過截取編譯器輸出得到,不是手寫的彙編代碼,手寫的彙編只有助記符,沒有地址碼和機器碼)。
int f(int x) {
##對於這一行,e是十六進制的內存地址/偏移,1141是十六進制機器碼。
##再往後的addi sp,sp,-16是將棧指針sp減16。(一般只有壓棧的情況下才會修改棧指針)
e: 1141 addi sp,sp,-16
##對於這一行,10是十六進制的內存地址/偏移,e422是十六進制機器碼。
##再往後的sd s0,8(sp)是將寄存器s0的內容保存到棧的8偏移處(sp+8)。
10: e422 sd s0,8(sp)
##對於這一行,12是十六進制的內存地址/偏移,0800是十六進制機器碼。
##再往後的addi sp,sp,16是將棧指針sp加16。(一般只有出棧的情況下才會修改棧指針)
12: 0800 addi s0,sp,16
##調用函數g
return g(x);
}
問題解答:
一、Which registers contain arguments to functions? For example, which register holds 13 in main's call to printf?(哪些寄存器包含函數的參數?例如,在main調用printf時,哪個寄存器存放着13?)
答:從main函數開始,有如下寄存器:
- sp棧指針寄存器,用於存儲當前棧頂地址。(入棧先減地址再入,出棧先出再增)。
- ra返回地址寄存器,專門用於保存函數調用返回地址的寄存器。
- s0保存寄存器,用於保存函數執行過程中需要持續使用的中間值、幀指針等。
- a0~a7是函數的參數寄存器,傳遞函數參數時會用到。
在main調用printf時,寄存器a2存放着13。
二、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.)(在main函數的彙編代碼中,對函數f的調用在哪裏?對g的調用又在哪裏?(提示:編譯器可能會內聯函數。))
答:在main函數當中,對f的調用被簡化為了一條指令:li a1,12,因為編譯器在編譯代碼時,對於非常簡單的函數數會進行內聯優化(算出其結果,然後直接寫入對於寄存器中,無需生成jal/jalr調用指令)。對於g的調用會在f當中,但是由於函數g過於簡單,所以對函數f進行了指令融合,即將函數g當中的指令邏輯融合到f當中。在本例子當中,g會做加3操作然後返回,然後我們可以在f當中直接進行加三操作,無需調用g。
三、At what address is the function printf located?(函數printf位於哪個地址?)
答:在call.asm當中,有以下一行代碼:
30: 6c4000ef jal ra,6f4 <printf>
其中jal是跳轉指令,指令格式為:jal ra 目標地址,再結合後面的<printf>我們可以得知0x6f4是printf的地址,對應printf的入口。
四、What value is in the register ra just after the jalr to printf in main?(在main函數中執行jalr到printf之後,寄存器ra中的值是什麼?)
答:因為ra是返回地址寄存器,也就是説它裏面保存的是執行完函數調用後應該返回的地址,所以此時在執行到printf後,ra內的值應該是指令jal ra,6f4 <printf>的下一條指令的地址,也就是0x34。
五、Run the following code.(運行接下來的代碼)
unsigned int i = 0x00646c72;
printf("H%x Wo%s", 57616, (char *) &i);
What is the output? Here's an ASCII table that maps bytes to characters.(輸出是什麼?這是一個將字節映射到字符的ASCII表。)
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?(輸出取決於 RISC-V 是小端字節序這一事實。如果 RISC-V 是大端字節序,那麼為了得到相同的輸出,你會將 i 設為多少?你需要將 57616 改成其他值嗎?)
答:輸出內容如下(xv6默認小端模式):
He110,World
- 小端模式:低地址存放低位,高地址存放高位。
- 大端模式:低地址存放高位,高地址存放低位。
依照大端模式,我們需要將i進行修改,大小端模式隻影響多字節的存儲,而57616只是單個數值,不涉及多字節存儲。為符合大端要求,我們需要將i修改為:0x726c6400即可。
六、In the following code, what is going to be printed after 'y='? (note: the answer is not a specific value.) Why does this happen?(在下面的代碼中,'y='後面將會打印出什麼?(注意:答案不是一個具體的值。)為什麼會出現這種情況?)
printf("x=%d y=%d", 3);
答:因為變量y沒有對應的賦值,所以會輸出一個未初始化的隨機的值(類似:0,1385,-2294)。在彙編時,printf函數會用到兩個寄存器,其中 a1負責存放3,a2沒有指定要存放誰,所以裏面的值是未知的。
Backtrace(中等)
當一個函數調用另一個函數時,CPU 會將調用點的返回地址保存到棧上,這樣被調用函數執行完後才能回到原來的位置繼續執行。每次調用都會創建新的棧幀,並在棧幀中保存返回地址和上一層的棧幀指針。因此,如果我們知道當前函數的棧幀位置,就可以順着棧幀鏈“回溯”,找到上一層函數的返回地址,再繼續向上,直到遍歷完整個調用鏈。我們把這個過程形象地稱為“順騰摸瓜”,意思是沿着棧幀鏈,一層層找到調用關係。
本 Lab 要求實現一個 backtrace() 函數,它從當前棧幀出發,沿着棧幀鏈打印每一層函數的返回地址。輸出順序應與調用鏈一致(從當前函數向上直到最初調用的內核入口)。
棧幀:指函數棧幀,是函數在運行時在棧上分配的一段內存,用來保存函數調用需要的信息(例如:返回地址,上一層函數的棧幀指針,局部變量,保存的寄存器)。
官網提示和個人解析
1、在kernel/defs.h在聲明 backtrace() 函數原型,這樣其他文件可以調用,並且在kernel/printf.c當中實現該函數。
2、由於我們需要獲取當前的棧幀地址,所以官網給我們提供了一個r_fp函數,用於返回當前的棧幀地址。我們需要將這個函數複製到kernel/riscv.h當中的#ifndef __ASSEMBLER__ ... #endif 定義當中。
3、在實現該功能時,我們需要獲取當前棧幀的地址,好在官網提供了r_fp函數,它返回一個uint64類型的數據(這是棧幀指針,指向的位置存放着真正的棧幀地址),對其解引用會得到當前的棧幀地址。
4、接下來我們開始“向上”尋找調用鏈上的函數,根據官網的提示,在“棧幀地址 - 8”的位置上存放的是上一層函數的返回地址,也是我們要打印的地址。在“棧幀地址 - 16”的位置上存放的是上一層函數棧幀的地址,在打印完畢後我們切換到上一層函數的棧幀,然後繼續打印返回地址,然後再次向上尋找棧幀,直至到達頂端。
以下是xv6內核的相關約定(幾乎每個函數調用的時候都會伴隨以下彙編代碼):
addi sp, sp, -X # 分配棧幀
sd ra, 8(sp) # 保存返回地址
sd s0, 0(sp) # 保存舊的 frame pointer(棧幀指針,指向存放棧幀的內存)
mv s0, sp # 更新 frame pointer(棧幀指針)
更直觀點:
s0 → 指向自己棧幀的 s0 存放位置 + 8
s0-8 → ra
s0-16 → 上一級 s0
5、在向上尋找時也要注意越界的問題,在xv6當中,整個棧都在同一個頁面當中,因此所有的棧幀都是在一個頁面中(這也解釋了為什麼遞歸的層級多了會爆棧的原因,因為調用新的函數會創建新的棧幀,佔用同一個棧的內存),所以我們需要保證我們在獲取到新的棧幀的同時,要保證與剛才才處理過的棧幀處於同一頁面,官網當中給出了PGROUNDDOWN(fp)宏來幫助我們判斷當前fp的頁面。同時也有一個忽略的點就是要保證地址是遞減的,這樣總會遞減到當前頁的邊界,使得循環終止,如果忽略該條件,可能會導致地址加加減減跳不出本頁,進而無限循環。
6、通過當前棧幀獲取上一層函數的返回地址,並且打印。
7、通過當前棧幀獲取上一層函數的棧幀,然後繼續尋找。
相關代碼
在kernel/printf.c當中。
void backtrace(){
// 當前的棧幀
uint64 s0 = r_fp();
// 臨時變量,最新的函數棧幀
uint64 temp = s0;
// 臨時變量,用於接下來的比較
uint64 log = s0;
printf("backtrace:\n");
// 確保找到的棧幀和最新的棧幀是同一頁,並且棧幀只能單調遞減,不能出現環路,防止死循環。
while((PGROUNDDOWN(s0) == PGROUNDDOWN(temp)) && (s0 >= log )){
// 棧幀-8是返回地址,取出返回地址當中的值,以地址形式打印
uint64 ra = *(uint64 *)(s0 - 8);
printf("%p\n", (void *)ra);
// 臨時變量保存當前棧幀,在保證棧幀單調遞減的判斷中使用
log = s0;
// 棧幀-16是上一層函數的棧幀
s0 = *(uint64*)(s0 - 16);
}
}
之後根據官網的提示進行實驗結果的驗證即可。
Alarm(困難)
在這一lab當中,要求我們實現用户態的定時“中斷”功能。用户進程可以向操作系統註冊一個函數(handler),並且要求CPU在每隔n個tick後執行該函數一次,在handler在被執行時,用户程序需要“被暫停”,直到handler執行完畢後再返回用户程序,這個過程類似於硬硬件中斷機制,只不過採用了軟件的方法實現。
官網提示和個人解析:
1、首先要求我們新添加兩個系統調用,分別是:sigalarm(interval, handler)和sigreturn(void),具體的添加方式詳見:Lab2-system calls && MIT6.1810操作系統工程【持續更新】 - 小白同學_C - 博客園。與此同時,sigalarm(interval, handler)函數的第一個參數interval代表每隔多少和tick執行handler,第二個參數handler就代表CPU每個n個tick要執行函數的地址了。這就需要我們在進程的proc當中添加新的成員用於記錄當前tick和當前已經過去了多少tick以及註冊的函數指針。
2、如果應用程序調用 sigalarm (0, 0),內核應停止生成周期性的警報調用。
3、handler執行時,需要我們暫停用户程序,也就是先將用户程序的代碼保護起來,替換為handler的代碼。待handler執行完畢後再將用户程序的代碼恢復,這就要求我們在進程的proc當中添加相應的trapframe幀用於保存用户程序狀態(和中斷的思想一樣,打斷當前執行的程序→保護斷點和現場→獲得中斷向量→執行中斷處理程序→恢復斷點和現場→被打斷的程序繼續執行)。
4、把user/alarmtest.c 添加到 Makefile 中。
5、記得把sigalarm(interval, handler)和sigreturn(void)添加到user/user.h當中。,格式如下:
int sigalarm(int ticks, void (*handler)());
int sigreturn(void);
6、每過一個tick,硬件時鐘就會觸發一次中斷,該中斷在 kernel/trap.c 的 usertrap () 中處理,所以我們需要在這裏進行修改。官網説了,只要發生定時器中斷時,我們才需要修改/對比進程的時鐘tick數。在如下判斷體內實現“判斷handler是否執行的相關邏輯”,並且保存用户程序的斷點和將用户程序代碼替換為handler就在此執行。
if(which_dev == 2) ...
7、 當handler執行完畢後,也是調用了sigreturn函數時,要求我們返回當前進程的a0寄存器。
相關代碼
有關添加系統調用的代碼在這裏就先跳過了,可以翻翻博主之前的文章Lab2-system calls && MIT6.1810操作系統工程【持續更新】 - 小白同學_C - 博客園。
一、kernel/proc.h
// 在進程的proc當中新添加如下內容:
int alarmticks; // 警報之間的滴答聲
int alarmtickscount; // 上次警報後的滴答聲次數
uint64 alarmhandler; // 鬧鐘滴答聲過去時調用的處理程序
int inhandler; // 是否正在處理alarm
struct trapframe alarm_tf; // 用於保存被 alarm 打斷時的 trapframe
二、kernel/sysproc.c
uint64
sys_sigalarm(void)
{
int ticks;
uint64 handler;
// 讀取用户傳入參數
argint(0, &ticks);
argaddr(1, &handler);
struct proc *p = myproc();
// 設置 alarmticks 和 alarmhandler
p->alarmticks = ticks;
p->alarmtickscount = 0;
// 如果 ticks 和 handler 都為 0,表示取消 alarm,所以將 alarmhandler 設置為 -1,表示不調用 handler
if(ticks == 0 && handler == 0) {
p->alarmhandler = -1;
}else{
p->alarmhandler = handler;
}
return 0;
}
uint64
sys_sigreturn(void)
{
struct proc *p = myproc();
// 恢復被 alarm 打斷時的 trapframe,以便在 handler 處理完後繼續被打斷的程序
memmove(p->trapframe, &p->alarm_tf, sizeof(struct trapframe));
// 處理完 alarm 後,重置 inhandler 和 alarmtickscount
p->inhandler = 0;
p->alarmtickscount = 0;
return p->trapframe->a0; // 返回用户程序中 a0 的值
}
三、kernel/trap.c
// 在usertrap函數的 if(which_dev == 2)... 當中添加如下內容:
// 在時鐘中斷時,檢查是否需要處理 alarm
if(which_dev == 2){
// 獲取當前進程的指針
struct proc *p = myproc();
// 如果當前進程設置了 alarmhandler,並且不在處理 alarm 的過程中,就檢查是否需要調用 alarmhandler
// 注意,alarmhandler 的值為 -1 表示沒有設置 handler(不調用),值為 0 表示正在處理 alarm,
// 所以只有當 alarmhandler 大於 0 時才表示設置了 handler 並且不在處理 alarm 的過程中
if(p->alarmhandler == 0 || p->alarmhandler != -1){
// 增加滴答聲計數,並檢查是否達到了 alarmticks,如果達到了,並且 alarmticks 大於 0,就調用 handler
p->alarmtickscount++;
// 如果達到了設定的滴答數,且設置了 alarmticks(大於 0),就調用 handler
if(p->alarmtickscount >= p->alarmticks && p->alarmticks > 0 ){
p->alarmtickscount = 0;
// 調用用户設置的 alarm 處理程序
if(!p->inhandler){
// 設置當前正在處理 alarm,防止在處理 alarm 的過程中被再次打斷調用 handler
p->inhandler = 1;
// 保存被打斷時的 trapframe 到 alarm_tf 中,以便在 handler 處理完後恢復
memmove(&p->alarm_tf, p->trapframe, sizeof(struct trapframe));
// 設置 trapframe 的 epc 為 handler 的地址,這樣在 usertrapret() 時就會跳轉到 handler 處執行
p->trapframe->epc = (uint64)p->alarmhandler;
}
}
}
yield();
}
驗收成果
xiaobai@***:~/xv6-labs-2025$ ./grade-lab-traps alarm
make: 'kernel/kernel' is up to date.
== Test running alarmtest == (4.1s)
== Test alarmtest: test0 ==
alarmtest: test0: OK
== Test alarmtest: test1 ==
alarmtest: test1: OK
== Test alarmtest: test2 ==
alarmtest: test2: OK
== Test alarmtest: test3 ==
alarmtest: test3: OK
xiaobai@***:~/xv6-labs-2025$
寫在最後
這一lab可以説很直觀地讓我們感受到中斷的過程是怎樣的,特別是涉及“保護斷點/現場”,“獲取中斷服務程序入口地址”,“恢復斷點/現場”等內容。
快過年了,爭取大年三十前儘快趕出下一個lab。