【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();}