【xv6操作系統】系統調用與traps機制解析及實驗設計
- 系統調用
- 相關理論
- 系統調用追溯
- 系統調用實驗設計
- Sysinfo
- 🚩系統調用總結(結合trap機制)
- trap
- trap機制
- trap代碼流程
- Backtrace實驗
- alarm實驗
系統調用
相關理論
??隔離性(isolation): 類似的,操作系統某種程度上為所有的應用程序服務。當你的應用程序出現問題時,你會希望操作系統不會因此而崩潰。比如說你向操作系統傳遞了一些奇怪的參數,你會希望操作系統仍然能夠很好的處理它們(能較好的處理異常情況)。所以,你也需要在應用程序和操作系統之間有強隔離性。
使用操作系統的一個原因,甚至可以說是主要原因就是為了實現multiplexing(CPU在多進程同分時復用)和內存隔離。如果你不使用操作系統,并且應用程序直接與硬件交互,就很難實現這兩點。
- 應用程序不能直接與CPU交互,只能與進程交互
- 操作系統內核會完成不同進程在CPU上的切換
- 操作系統不是直接將CPU提供給應用程序,而是向應用程序提供“進程”,進程抽象了CPU,這樣操作系統才能在多個應用程序之間復用一個或者多個CPU。
硬件對于強隔離的支持
??當用戶程序執行系統調用,會通過ECALL(詳情結合下章trap)觸發一個軟中斷(software interrupt),軟中斷會查詢操作系統預先設定的中斷向量表,并執行中斷向量表中包含的中斷處理程序。中斷處理程序在內核中,這樣就完成了user mode到kernel mode的切換,并執行用戶程序想要執行的特殊權限指令。
??每一個進程都會有自己獨立的page table,這樣的話,每一個進程只能訪問出現在自己page table中的物理內存。操作系統會設置page table,使得每一個進程都有不重合的物理內存,這樣一個進程就不能訪問其他進程的物理內存,因為其他進程的物理內存都不在它的page table中。
編譯運行kernel:
??Makefile會為所有內核文件做相同的操作,比如說pipe.c,會按照同樣的套路,先經過gcc編譯成pipe.s,再通過匯編解釋器生成pipe.o。之后,系統加載器(Loader)會收集所有的.o文件,將它們鏈接在一起,并生成內核文件。

User/Kernel mode切換:
-
用戶的應用程序執行系統調用的唯一方法就是通過這里的ECALL指令。
-
調用ECALL指令,并將fork對應的數字作為參數傳給ECALL
-
這里的數字參數代表了應用程序想要調用的System Call。
-
可以通過系統調用或者說ECALL指令,將控制權從應用程序轉到操作系統中
進程虛擬地址空間
-
使用符號
p->xxx
來指代proc
結構中的元素;struct proc
在 kernel/proc.h 文件第 86 行定義。 -
每個進程有兩個棧:一個用戶棧
user stack
和一個內核棧kernel stack
( p->kstack )enum procstate { UNUSED, USED, SLEEPING, RUNNABLE, RUNNING, ZOMBIE }; // Per-process state struct proc {struct spinlock lock; // 鎖// p->lock must be held when using these:enum procstate state; // Process statevoid *chan; // If non-zero, sleeping on chanint killed; // If non-zero, have been killedint xstate; // Exit status to be returned to parent's waitint pid; // Process ID// wait_lock must be held when using this:struct proc *parent; // Parent process// these are private to the process, so p->lock need not be held.uint64 kstack; // Virtual address of kernel stackuint64 sz; // Size of process memory (bytes)pagetable_t pagetable; // User page tablestruct trapframe *trapframe; // data page for trampoline.Sstruct context context; // swtch() here to run processstruct file *ofile[NOFILE]; // Open filesstruct inode *cwd; // Current directorychar name[16]; // Process name (debugging) };
在內核態中如何獲取用戶參數,如sleep(10),涉及一個拷貝
系統調用追溯
1. 實驗要求: 添加一個trace system call調用,可以實現跟蹤system call。此函數入參為一個數字,可以控制跟蹤哪些system call。
如:
- trace(1<<SYS_fork),trace(10b),trace(2)表示跟蹤fork調用;
- trace(1<<SYS_read),trace(10 0000b),trace(32),表示跟蹤read調用;
2. 一些理論基礎
?
?
-
initcode.S
將 exec 的參數放置在寄存器 a0 和 a1,將系統調用編號放在a7
中.# exec(init, argv) .globl start start:la a0, initla a1, argvli a7, SYS_exececall
-
sys_call返回值: 當
sys_exec
返回時,系統調用會將其返回值記錄在p->trapframe->a0
中。如果系統調用編號無效,系統調用將打印錯誤并返回 ?1
system call調用鏈路
1)在user/user.h做函數聲明
2)Makefile調用usys.pl(perl腳本)生成usys.S,里面寫了具體實現,通過ecall進入kernel,通過設置寄存器a7的值,表明調用哪個system call
3)ecall表示一種特殊的trap,轉到kernel/syscall.c:syscall執行
4)syscall.c中有個函數指針數組,即一個數組中存放了所有指向system call實現函數的指針,通過寄存器a7的值定位到某個函數指針,通過函數指針調用函數
系統調用實驗設計
核心1:內核函數調用過程
entry進入內核,調用syscall函數,通過a7獲取函數指針數組的數值(即sys_call的入口地址)

?
核心2:內核獲取用戶參數
調用 argint
,argaddr
函數,本質是從進程中獲取

?
實驗設計:
(1)鏈路配置:
-
user.h聲明 trace函數
int trace(int);
-
user.pl加入跳板函數
entry("trace");
-
在 syscalls 函數指針數組添加
sys_trace
數組,并在syscall.h中宏定義SYS_trace
// syscall.c [SYS_trace] sys_trace,// syscall.h #define SYS_trace 22
-
在syscall中外部函數聲明,并在sys_call在sysproc.c中定義函數原型
sys_trace
//sys_call.c extern uint64 sys_trace(void);//sysproc.c //Add a sys_trace() function uint64 sys_trace(void)
(2)函數實現: 根據實驗要求設計
- 在 sys_trace 中獲取用戶系統調用參數
mask
uint64 sys_trace(void) {int mask;if(argint(0, &mask) < 0)return -1; }
- 通過在 proc 結構的新變量中記住其參數來實現新的系統調用,即
proc
結構體(proc.h)中添加新的變量mask
,以便子進程繼承//Add a sys_trace() function uint64 sys_trace(void) {int mask;if(argint(0, &mask) < 0)return -1;//獲取當前進程struct proc *p = myproc();//需要在proc.h結構體中添加新的成員變量 maskp->trace_mask = mask; return 0; }
- 修改 fork() (kernel/proc.c) 以將跟蹤掩碼從父進程復制到子進程
//copy the trace mask from the parent to the child process. np->trace_mask = p->trace_mask;
- 修改 kernel/syscall.c 中的 syscall()函數,以打印跟蹤輸出

Sysinfo
- 個人認為本題重點在于如何實現用戶態與內核態的信息交互
(調用、參數、返回值等)
添加一個sysinfo system call調用,可以實現打印可用空間(字節)、可用進程數
首先閱讀測試案例:user/sysinfotest.c:main,需結合程序
testcall
:測試 sysinfo調用失敗,用戶傳遞非法內存地址,因為Risc-v只支持39位 在下一章頁表中涉及testmem
:使用sbrk分配物理內存頁,測試剩余分配量,釋放測試testproc
:測試有多少進程未使用,測試fork,使用 + 1,未使用 -1
實驗設計: 構建 sysinfo 系統調用鏈路,略,參考System call tracing 中的實驗設計的第一步:鏈路配置
程序設計:
1. 獲取用戶sysinfo參數
uint64 addr; // user pointer to struct stat
// step1: copy a struct sysinfo back to user space
if(argaddr(0, &addr) < 0)return -1;
2. 獲取未使用的空間和進程數存入 struct sysinfo
結構體
struct sysinfo info; // sysinfo struct// step2 get freemen
info.freemem = acquire_freemen(); //kalloc.c
// step3 get unused number of processes
info.nproc = acquire_npro(); //proc.c
3. 功能函數實現
(1) 獲取未使用的空間,參考 kalloc.c: kalloc,遍歷空頁表,計算頁數,返回 頁數 * 每一頁的字節數,這里涉及到一點下一章的頁表知識
// 參考 kalloc
uint64 acquire_freemen(void)
{struct run *r; //listuint64 cnt = 0;acquire(&kmem.lock); r = kmem.freelist;//遍歷鏈表求長度 頁表while(r){r = r->next;cnt++;}release(&kmem.lock);return cnt * PGSIZE; // 頁表頁數 * 每一頁的字節數
}
(2) 獲取未使用的進程數,遍歷進程,判斷進程狀態
uint64 acquire_npro(void)
{struct proc *p; // 進程指針int cnt = 0;// 遍歷所有進程for(p = proc; p < &proc[NPROC]; p++) {acquire(&p->lock); // get lock//進程狀態為使用if(p->state == UNUSED) {cnt++;} release(&p->lock); // release lcok }return cnt;
}
4. 內核數據拷貝給用戶,利用 copyout
函數將內核地址的數據拷貝給用戶態
函數定義:
int copyout(pagetable_t pagetable, uint64 dstva, char *src, uint64 len)
數據拷貝實現
struct proc *p = myproc(); // process p
copyout(p->pagetable, addr, (char *)&info, sizeof(info));
5. 完整 sys_sysinfo
函數實現:
//Add a sys_sysinfo() function
uint64
sys_sysinfo(void)
{struct sysinfo info; // sysinfo structuint64 addr; // user pointer to struct statstruct proc *p = myproc(); // process p// step2 get parainfo.freemem = acquire_freemen();// step3 get number of processesinfo.nproc = acquire_npro();// step1: copy a struct sysinfo back to user spaceif(argaddr(0, &addr) < 0)return -1;if(copyout(p->pagetable, addr, (char *)&info, sizeof(info)) < 0)return -1;//printf("call sys_sysinfo Hi\n");return 0;
}
🚩系統調用總結(結合trap機制)
- 這里在學習頁表、trap后總結

?
trap
??大佬認為: 本部分主要內容其實都在lecture里(lecture 5、lecture 6),實驗不是非常復雜但是以理解概念為重,trap機制、trampoline作用、函數calling convention、調用棧、特權模式、riscv匯編,這些即使都不知道可能依然能完成 lab。但是不代表這些不重要,相反這些才是主要內容,
參考大佬的博文:[mit6.s081] 筆記 Lab4: Traps | 中斷陷阱
trap機制
參考博文: Mit6.S081學習筆記
- 程序執行系統調用
- 程序出現了類似page fault、運算時除以0的錯誤
- 一個設備觸發了中斷使得當前程序運行需要響應內核設備驅動
都會發生用戶空間和內核空間的切換,這種切換通常被稱為trap

?
對比 (二)ARM寄存器組織與異常處理 中的異常處理學習,這是ARM寄存器組織異常處理過程:
- 拷貝之前的模式及狀態
- 切換模式 用戶模式 -> 異常模式
- 禁止相應的中斷
- 修改模式位
- 修改狀態位
- 保存返回地址
- 設置
PC
為相應的中斷異常向量表的值
?
先認識一些內核模式下的寄存器,留個印象:
- scause: trap類型(sys_call, page fault)
- sststus: 保存狀態模式(User / supervisor mode)
- scratch: trapframe地址
- sepc: 保存當前PC的值
- stvec: trap handler地址
- csr: 控制狀態寄存器,csrr,csrw,csrrw指令
用戶下的內存塊:
- trampoline:存放切換代碼的地方 (內核下也有)
- trapframe:類似于上下文保存的結構 (內核下沒有)
??需要清楚如何讓程序的運行,從只擁有user權限并且位于用戶空間的程序,切換到擁有supervisor權限的內核。在這個過程中,硬件的狀態將會非常重要,因為很多的工作都是將硬件從適合運行用戶應用程序的狀態,改變到適合運行內核代碼的狀態。
用戶應用程序可以使用全部的32個寄存器,很多寄存器都有特殊的作用。其中:
- 程序計數寄存器PC(Program Counter Register)
- 表明當前mode的標志位,這個標志位表明了當前是
supervisor mode
還是user mode
- 還有一堆控制CPU工作方式的寄存器,比如
SATP
(Supervisor Address Translation andProtection)寄存器,包含了指向page table的物理內存地址 STVEC
(Supervisor Trap Vector Base Address Register)寄存器,指向了內核中處理trap的指令的起始地址SEPC
(Supervisor Exception Program Counter)寄存器,在trap的過程中保存程序計數器的值- SSCRATCH(Supervisor Scratch Register)寄存器,這也是個非常重要的寄存器
這些寄存器表明了執行系統調用時計算機的狀態。
trap代碼流程
用戶程序執行系統調用函數(實際上通過執行ECALL指令來執行系統調用)
??用戶程序→ ECALL→uservec(在trampoline中)→usertrap(在trap.c中)→ syscall → sys xxx(對應的系統調用)一執行結果返回給syscall→usertrapret(在trap.c中)→userret(在trampoline中)→系統調用完成,返回到用戶空間,恢復ECALL之后的用戶程序的執行

?
一、當一個trap來臨((ecall指令)RISC-V硬件做了什么
- 如果是設備中斷,并且狀態SIE位(sststus中的標志位)被清空,不執行以下操作
- 清除SIE以禁用中斷(disable interrupts by clearing SlE)(ECALL禁用,防止切換到其他進程,并重新trap,覆蓋sepc)
- 保存
pc
的值到sepc
(epc = PC) - 保存當前所處模式到SPP位(sststus中的標志位) (user mode -> supervisor mode
- 設置
scause
表明 trap類型(Set scause to reflect the trap’s cause) - 切換到
supervisor
模式(Set the mode to supervisor) - 將
stvec
寄存器里面的值復制到pc
(Copy the stvec to the pc) - 從
pc
上取值執行(Start executing at the new pc)
所以現在,ecall在硬件上幫我們做了一點點工作,但是實際上我們離執行內核中的C代碼還差的很遠。接下來:
- 我們需要保存32個用戶寄存器的內容,這樣當我們想要恢復用戶代碼執行時,我們才能恢復這些寄存器的內容。(保存現場)
- 因為現在我們還在user page table,我們需要切換到kernel page table
- 我們需要創建或者找到一個kernel stack,并將Stack Pointer寄存器的內容指向那個kernel stack。這樣才能給C代碼提供棧
- 我們還需要跳轉到內核中C代碼的某些合理的位置。
ecall并不會為我們做這里的任何一件事。
GDB過程:




二、uservec函數
- 保存現場(32個通用寄存器)
- 把內核的page table,內核的stack、當前執行該進程的CPU號裝載到寄存器里
- 跳轉到usertrap繼續執行

三、usertrap函數
-
分情況,執行系統調用/中斷/異常的處理邏輯
scause == 8
-> syscall ->num = p->trapframe->a7
-> syscalls表驅動 -> sys_write(16)
-
修改了stvec的值,還可能會修改sepc的值
void usertrap(void) {int which_dev = 0;if((r_sstatus() & SSTATUS_SPP) != 0)panic("usertrap: not from user mode");// send interrupts and exceptions to kerneltrap(),// since we're now in the kernel.w_stvec((uint64)kernelvec);// save user program counter.p->trapframe->epc = r_sepc();if(r_scause() == 8) {// system call// sepc points to the ecall instruction,// but we want to return to the next instruction.p->trapframe->epc += 4;// an interrupt will change sstatus &c registers,// so don't enable until done with those registers.intr_on();syscall();} // give up the CPU if this is a timer interrupt.if(which_dev == 2) {yield();}usertrapret(); }
四、usertrapret函數
- 填入了trapframe的內容,這樣下一次從用戶空間轉換到內核空間時可以用到這些數據
- 存儲
kernel page table
的指針 - 存儲當前用戶進程的
kernel stack
- 存儲
usertrap
函數的指針,這樣trampoline代碼才能跳轉到這個函數 - 從tp寄存器中讀取當前的
CPU核編號
,并存儲在trapframe中 - 恢復stvec、sepc的值(supervisor mode register)
- 存儲
五、userret函數
-
恢復現場
-
把用戶空間的
page table
、用戶空間的stack
裝載到寄存器里 -
執行sret指令(相對于ecall指令)
六、sret指令
- 程序會切換回user mode
- SEPC寄存器的數值會被拷貝到PC寄存器(程序計數器)
- 重新打開中斷

Backtrace實驗
添加 backtrace 功能,打印出調用棧,用于調試
實驗步驟:
-
在 defs.h 中添加聲明
backtrace
,并在 print 中定義
?
-
在 riscv.h 中添加獲取當前 fp(frame pointer)寄存器的方法:
static inline uint64 r_fp() {uint64 x;asm volatile("mv %0, s0" : "=r" (x) );return x; }
-
獲取當前棧幀指針
void backtrace(void) {//并在 Backtrace 中調用r_fp以讀取當前幀指針//此函數使用內聯匯編來讀取 s0。uint64 fp = r_fp(); // 當前棧幀指針
-
lecture notes have a picture of the layout of stack frames. 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. -
Xv6 allocates one page for each stack in the xv6 kernel at PAGE-aligned address. You can compute the top and bottom address of the stack page by using
PGROUNDDOWN(fp)
andPGROUNDUP(fp)
-
程序設計
void backtrace(void) {//并在 Backtrace 中調用r_fp以讀取當前幀指針//此函數使用內聯匯編來讀取 s0。uint64 fp = r_fp(); // 當前棧幀指針uint64 return_address; // 函數返回地址 printf("backtrace:\n");// 判斷是否已經到達棧底while (fp != PGROUNDUP(fp)){// 獲取每個棧的返回地址return_address = *(uint64*)(fp - 8);// 更新 fp 獲取上個棧的棧幀地址fp = *(uint64*)(fp - 16);printf("%p:\n",return_address);} }
函數調用棧(Stack)
- 棧由高地址往低地址增長
- 在xv6里,有一頁大小(4KB)
- 棧指針(stack pointer)保存在
sp
寄存器里

棧幀(Stack Frame)
-
當前棧幀的地址保存在
s0/fp
寄存器里 -
當前棧幀的地址也叫棧幀的指針(frame pointer, fp),指向該棧幀的最高處
-
棧幀指針往下偏移8個字節是函數返回地址
return address
-
往下偏移16個字節是上一個棧幀的棧幀指針
previous frame pointer

- fp 指向當前棧幀的開始地址,sp 指向當前棧幀的結束地址。
- 棧從高地址往低地址生長,所以 fp 雖然是幀開始地址,但是地址比 sp 高
- 棧幀中從高到低第一個 8 字節
fp-8
是 return address,也就是當前調用層應該返回到的地址。 - 棧幀中從高到低第二個 8 字節
fp-16
是 previous address,指向上一層棧幀的 fp 開始地址。 - 剩下的為保存的寄存器、局部變量等。一個棧幀的大小不固定,但是至少 16 字節。
- 在 xv6 中,使用一個頁來存儲棧,如果 fp 已經到達棧頁的上界,則說明已經到達棧底。
查看 call.asm,可以看到,一個函數的函數體最開始首先會擴充一個棧幀給該層調用使用,在函數執行完畢后再回收,例子:
int g(int x) {0: 1141 addi sp,sp,-16 // 擴張調用棧,得到一個 16 字節的棧幀2: e422 sd s0,8(sp) // 將返回地址存到棧幀的第一個 8 字節中4: 0800 addi s0,sp,16return x+3;
}6: 250d addiw a0,a0,38: 6422 ld s0,8(sp) // 從棧幀讀出返回地址a: 0141 addi sp,sp,16 // 回收棧幀c: 8082 ret // 返回
注意棧的生長方向是從高地址到低地址,所以擴張是 -16,而回收是 +16。
alarm實驗
??該實驗需要實現 sigalarm和 sigreturn 兩個系統調用,為用戶進程添加定期通知功能,使得進程在一段時間內使用 CPU 后,會被定期“提醒”,類似于一種用戶態的中斷處理,用來模擬用戶級的異常處理。
作用:
- 對于希望限制其占用CPU時間的計算密集型進程,或者希望進行計算但也希望采取一些定期行動的進程可能很有用。
- 更一般來說,你將實現一種原始的用戶級中斷/故障處理程序;例如,你可以使用類似的機制來處理應用程序中的頁面錯誤
實驗要求與實現:
0. 核心參數設置:
struct proc{...// alarm test0int ticks; // 報警間隔 interval for the alarmuint64 handler; // call functionint ticks_count; // how many ticks right now//test1 test2 struct trapframe *save_trap_frame; // 保存現場int is_handling; // 是否正在中斷 防止二次打斷...
}
1. 添加新的系統調用:
- user.h 聲明系統調用
- usys.pl添加入口
- syscall.h函數編號
- syscall.c更加系統調用,做函數表映射
2. 保存 sigalarm
的報警間隔與 handler
指針保存在 struct proc
中新的字段,并在proc.c中allocproc初始化字段
-
核心代碼:
sys_sigalarm: 獲取用戶參數 argint(0, &ticks); argaddr(1, &handler); // 保存參數 p->ticks = ticks; p->handler = handler; p->ticks_count = 0;sys_sigreturn: p->is_handling = 0; 清空中斷標記位 memmove(p->trapframe, p->save_trap_frame, PGSIZE); 中斷返回 保存現場的地址
-
完整代碼
-
系統調用函數實現:
(1)sys_sigalarm
獲取用戶數據(間隔時間與處理函數handler)并保存到進程中uint64 sys_sigalarm(void) {int ticks;uint64 handler;// 進入中斷// 獲取進程struct proc *p = myproc();// 獲取用戶參數argint(0, &ticks);argaddr(1, &handler);// 保存參數p->ticks = ticks;p->handler = handler;p->ticks_count = 0;return 0; }
(2)
sys_sigreturn
中斷返回保存現場,清空中斷標志位uint64 sys_sigreturn(void){ //恢復現場// 獲取進程struct proc *p = myproc();// 清空異常標志p->is_handling = 0; // 中斷返回 保存現場的地址memmove(p->trapframe, p->save_trap_frame, PGSIZE); return 0;}
-
proc/allocproc函數初始化成員變量
// init p->ticks = 0;p->ticks_count = 0;p->handler = 0;
3. 在kernel/trap.c中的實現該時鐘中斷的代碼usertrap
-
核心代碼:
保存中斷現場 memmove(p->save_trap_frame, p->trapframe, PGSIZE); handler存入epc寄存器,破壞了現場,因此這兩行代碼不可先后更換 p->trapframe->epc = p->handler;
-
完整代碼:
// give up the CPU if this is a timer interrupt.if(which_dev == 2){if(p->ticks > 0){p->ticks_count++;//時間到 并且無其他中斷if(p->ticks_count > p->ticks && p->is_handling == 0){p->ticks_count = 0;//保存中斷現場 memmove(p->save_trap_frame, p->trapframe, PGSIZE);//執行函數的地址入口p->trapframe->epc = p->handler; // handler存入epc寄存器p->is_handling = 1; // 標記正在中斷}}yield();}