这篇博客主要复习 lecture05:GDB calling conentions 和 lecture06:System call entry/exit 的内容,外加 Lab4:traps 的心得
这里的前置知识是指 lecture05:GDB calling conentions 的内容,是由 TA 来上的,是作为 lecture06 的前置知识,主要讲解了以下三点内容:
我们常说的 x86、ARM、乃至 RISC-V 都是 ISA(Instruction Sets Architecture 指令集架构),即处理器能够理解的指令集,所以当我们说到一个RISC-V处理器时,意味着这个处理器能够理解RISC-V的指令集。
指令集就是为每一条指令对应一个二进制编码或者叫 Opcode。当处理器在运行时,如果遇到这些编码,就执行相应的操作。
要让 C 语言能够运行在你的处理器之上。我们首先要写出C程序,之后编译器将C程序编译成汇编语言(这个过程中有一些链接和其他的步骤),汇编器再将汇编语言翻译成二进制文件也就是.obj或者.o文件,汇编器会将每个指令翻译成对应的机器码
由于有不同的指令集架构,所以汇编语言也有不同的种类,如 arm 汇编、x86 汇编、RISC-V 汇编等等
所以可以这么理解:一种架构就是一种指令集,一种指令集就对应一种汇编
RISC-V中的 RISC 是精简指令集(Reduced Instruction Set Computer)的意思,而x86通常被称为 CISC,复杂指令集(Complex Instruction Set Computer)。这两者之间有一些关键的区别:
日常生活中接触到的指令集:
Android 手机使用的高通 Snapdragon 处理器基于 ARM 指令集 ,ARM也是一个精简指令集,
RISC-V 的另一个特殊之处在于模块化的思想:它区分了Base Integer Instruction Set(基本指令集)和Standard Extension Instruction Set(扩展指令集)。处理器可以选择性的支持扩展指令集,这种模式使得RISC-V更容易支持向后兼容。 每一个RISC-V处理器可以声明支持了哪些扩展指令集,然后编译器可以根据支持的指令集来编译代码。
寄存器位于 CPU 中,用来存储数据。详见我的博客:[从逻辑门到 CPU](从逻辑门到 CPU - byFMH - 博客园 (cnblogs.com)),寄存器之所以重要是因为汇编代码并不是在内存上执行,而是在CPU 和寄存器上执行: 做add,sub时,运算是 CPU 负责,各种中间结果以及最终结果都会对寄存器进行操作。
RISCV中的寄存器见下表,一共有 64 个寄存器:
通常我们在谈到寄存器的时候,我们会用它们的ABI(application binary interface)名字。不仅是因为这样描述更清晰和标准,同时也因为在写汇编代码的时候使用的也是ABI(应用程序二进制接口)名字。
这些寄存器的作用下面会一一介绍,现在只需要了解 a0 到 a7 寄存器是用来作为函数的参数。如果一个函数有超过 8 个参数,我们就需要用内存了。
当可以使用寄存器的时候,我们不会使用内存,我们只在不得不使用内存的场景才使用它。
这是很容易混淆的概念,表单中的第4列,Saver列,有两个可能的值Caller,Callee。区别是:
Stack 属于内存的一部分,对于Stack来说,是从高地址开始向低地址使用。所以栈总是向下增长。
每次调用一个函数,函数都会为自己创建一个 Stack Frame,并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。由于是向下增长,所以“移动”是指对当前的 Stack Pointer做减法。
Stack Frame 中包含以下数据:
CSAPP 中描述了 x86 架构下栈帧的结构:
可以看到并没有 To prev Frame 的设计,而是依靠将返回地址放到栈帧的末尾,来实现被调函数返回后自动找到返回地址,x86的 call 和 return 是入栈出栈,感觉是一种更巧妙的设计
前面我们已经讲过,操作系统的一个重要特点是隔离性,所以有了用户空间和内核空间的设计,但是进程如何由用户空间切换到内核空间,涉及到了很多细节。这是本节的讨论重点。
所谓 trap,就是一个进程从用户空间到内核空间的切换,这里要强调一句,trap 不是一个瞬间完成的,而是一个过程,这个过程主要包括以下内容:
只有完成了这一系列步骤,才能说进程由用户空间切换到了内核空间。
在前置知识中,已经知道 RISC-V 有 64 个寄存器,其中有 32 个通用寄存器,之所以叫通用,是因为这些寄存器用户程序也可以使用。
trap 的大致步骤如下:
SEPC 寄存器:
SEPC 寄存器是 RISC-V 架构中的一个特殊寄存器,全称为 Supervisor Exception Program Counter。当发生异常时,处理器会将异常的下一条指令的地址存储到 SEPC 寄存器中,然后根据异常类型跳转到相应的异常处理程序。需要注意的是,SEPC 寄存器不仅在异常处理时使用,还可以用于一些特权指令(如 SRET 和 MRET)的返回地址保存。这些指令用于从异常或中断处理程序返回到正常的程序执行流程中。
STVEC 寄存器:
STVEC 寄存器是 RISC-V 架构中的一个特殊寄存器,全称为 Supervisor Trap Vector Base Address Register。这个寄存器里存的是一段异常处理程序的地址,需要处理异常时,只需要从这个寄存器中读取出程序地址,加载到 pc 寄存器即可
在 lecture01 的学习中我们已经了解到 ecall 是RISC-V架构中的一条特权指令,系统调用时我们就是使用这条指令将进程从用户态切换到内核态,但是并没有详细介绍 ecall 指令的细节,其实这也是 trap 的关键。
ecall 指令是 RISC-V 中的指令,所以这三件事都是由 RISC-V 的 harware 完成的,无需 OS 介入
打印出 STVEC 寄存器的值,见下图,发现是 0x3ffffff000
,这个地址是 RISC-V 的 sv39 模式下的最高页的首地址(1<<39 - PAGESIZE
)
虽然现在 mode flags 已经改为 kernel mode,但是页表还没有切换,所以依旧查询 user mode 的虚拟地址空间,可以看出0x3ffffff000
正是 Tampoline page:
而程序总是遵循 pc 寄存器的指示,所以 ecall 指令之后,就会执行 trampoline page 的指令。
trampoline page 中的代码如下,位于文件 trampoline.S 中,是用汇编语言写的,代码如下:
uservec:
#
# trap.c sets stvec to point here, so
# traps from user space start here,
# in supervisor mode, but with a
# user page table.
#
# save user a0 in sscratch so
# a0 can be used to get at TRAPFRAME.
csrw sscratch, a0
# each process has a separate p->trapframe memory area,
# but it's mapped to the same virtual address
# (TRAPFRAME) in every process's user page table.
li a0, TRAPFRAME
# save the user registers in TRAPFRAME
sd ra, 40(a0)
sd sp, 48(a0)
sd gp, 56(a0)
sd tp, 64(a0)
sd t0, 72(a0)
sd t1, 80(a0)
sd t2, 88(a0)
sd s0, 96(a0)
sd s1, 104(a0)
sd a1, 120(a0)
sd a2, 128(a0)
sd a3, 136(a0)
sd a4, 144(a0)
sd a5, 152(a0)
sd a6, 160(a0)
sd a7, 168(a0)
sd s2, 176(a0)
sd s3, 184(a0)
sd s4, 192(a0)
sd s5, 200(a0)
sd s6, 208(a0)
sd s7, 216(a0)
sd s8, 224(a0)
sd s9, 232(a0)
sd s10, 240(a0)
sd s11, 248(a0)
sd t3, 256(a0)
sd t4, 264(a0)
sd t5, 272(a0)
sd t6, 280(a0)
# save the user a0 in p->trapframe->a0
csrr t0, sscratch
sd t0, 112(a0)
# initialize kernel stack pointer, from p->trapframe->kernel_sp
ld sp, 8(a0)
# make tp hold the current hartid, from p->trapframe->kernel_hartid
ld tp, 32(a0)
# load the address of usertrap(), from p->trapframe->kernel_trap
ld t0, 16(a0)
# fetch the kernel page table address, from p->trapframe->kernel_satp.
ld t1, 0(a0)
# wait for any previous memory operations to complete, so that
# they use the user page table.
sfence.vma zero, zero
# install the kernel page table.
csrw satp, t1
# flush now-stale user entries from the TLB.
sfence.vma zero, zero
# jump to usertrap(), which does not return
jr t0
这里的代码也很好懂,我就开门见山,直接总结 trampoline page 中的代码做了些什么事:
csrw sscratch, a0
,这个指令交换了 a0 和 sscratch 两个寄存器的内容,交换之后,a0 的值是0x3fffffe000,这是 trapframe page 的虚拟地址。它之前保存在SSCRATCH寄存器中;SSCRATCH寄存器的内容是 2,这是 a0 寄存器之前的值。a0 寄存器保存的是系统调用的第一个参数;所以通过这次交换,即保存了 a0 寄存器的旧值,又有了指向 trapframe page 的指针。
将 32 个用户寄存器的值保存到 trapframe page 中(应该是 31 个 ,zero 寄存器的值不用保存,但这里为了方便记忆),XV6 在每个 user page table 中都映射了 trapframe page(就在 trampoline page 的下面),这样每个进程都有自己的 trapframe page。(特别注意,这两个 page 对应的PTE并没有设置PTE_u标志位,所以用户程序不可以写这两个 page,trap 依旧是安全的)
从 trapframe page 中将以下数据读取出来:(因为这 4 个寄存器总是一起行动,所以我给他们起了个中二的名字: 4 虎将)
0x800027a0
,属于虚拟地址空间中的 kernel text 区域)切换 page table,将 kernel page table 的指针加载到 satp 寄存器中
通过 jump 指令跳转到函数:usertrap(),这是内核的C代码
总结一下,Trampoline page 中的代码主要完成了以下三件事:
一个有趣的思考就是,为什么切换页表后程序没有崩溃?
答案:因为目前程序还在 trampoline page 中执行,这个 page 在两个地址空间中的 va 相同,在两个页表中的映射相同(这两个page table 中其他所有的映射都是不同的,只有trampoline page的映射是一样的),所以最后得到的物理地址也相同,所以不管使用那个 page table,都在相同的物理地址上,做到了无缝切换
之所以叫trampoline page,是因为某种程度在它上面“弹跳”了一下,然后从用户空间走到了内核空间。
usertrap 函数属于内核代码了,内存中位于 kernel 虚拟地址空间的 kernel text 段,这里的代码细节很多,我就不一一讲解了,细节在图中都说明了,我只从宏观上讲,usertrap 负责判断触发 trap 的原因,并执行响应的处理:
提一个特别的细节:uservec 中切换了页表,在切换之前使用的是 user page table,所以 trapframe page 在 user page table 中映射到了物理内存的某个 page 中;切换页表后,到了 usrtrap() 函数中,使用到了 trapframe page,那么在 kernel page table 中,也可以使用 trapframe page 吗?
答案:kalloc() 函数分配的 trapframe page 在 kernel space 中的 va 和 pa 是一样的,在每个用户进程创建(我习惯叫出厂)时,已经为这个进程设置好了页表,这个页表只配置了 trampoline page 和 trapframe page,而 trapframe page 的虚拟地址是次高页,物理地址是 kalloc() 函数分配的,而内核维护了所有进程的信息,每个进程的 trapframe page 在 kernel space 中的 va/pa 都保存在 p->trapframe 字段中,所以 kernel 可以访问任意进程的 trapframe page
这个函数也是细节颇多,我都写在图中了,总结如下:
这里的代码和 uservec 一样,也位于 Tampoline page ,回忆 uservec 中的代码主要完成了以下三件事:
对应的,userret 的代码主要完成 3 件事:
上一张全家福,从这个图中也可以知道 trap 的流程 细节多到爆炸:(大图看不清没关系,每个部分都已经都在前面有详细描述,这里只是给个 trap 整体概念)
所有代码见:我的GitHub实现(记得切换到相应分支)
这个任务主要是读 RISC-V 的汇编代码,回答一些问题。
首先看 C 源码:
#include "kernel/param.h"
#include "kernel/types.h"
#include "kernel/stat.h"
#include "user/user.h"
int g(int x) {
return x+3;
}
int f(int x) {
return g(x);
}
void main(void) {
printf("%d %d\n", f(8)+1, 13);
exit(0);
}
使用命令 make fs.img
编译 user/call.c
,生成一个可读的汇编代码文件 user/call.asm
printf
?哪个寄存器存储了函数的参数?比如 main 函数中调用了 printf 函数,那个寄存器存储了 printf 函数的参数 13?
回答:这个汇编代码的可读性非常好,有汇编和 C 的对比,从汇编代码中可以找到以下语句:
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
26: 45b1 li a1,12
可以清楚地看到执行 printf("%d %d\n", f(8)+1, 13);
,先将 13 和 12 分别加载到寄存器 a2 和 a3 中,而第一个参数"%d %d\n"则会加载到 a1 寄存器中,所以寄存器参数就是 a0~a7,超过 8 个参数会借用栈空间
f
in the assembly code for main? Where is the call to g
? (Hint: the compiler may inline functions.main 函数的汇编代码中,在哪里调用 f?在哪里调用 g?
回答:在 C 代码中,printf("%d %d\n", f(8)+1, 13);
显然一次性调用了 f 和 g,但是对应的汇编只有:
26: 45b1 li a1,12
说明编译器进行了优化,直接将 f(8)+1
替换为 12
,所以main
的汇编代码没有调用 f
和 g
函数,而是进行了内联优化。
printf
located?函数 printf 的地址在哪里?
回答:文件中全局搜索一下即可:课件地址在 000000000000065a
000000000000065a <printf>:
void
printf(const char *fmt, ...)
{
...
}
ra
just after the jalr
to printf
in main
?jalr
命令后,ra
寄存器的值是多少?
回答:仔细阅读 [reference](RISC-V assembly - build a OS --- RISC-V组装-构建操作系统 (gitbook.io)),有如下总结描述:
When your program is calling a function, it prepares the return address first before jump to the function. So that when the function about to return, it knows where it should return to.
jalr
saves return address for current PC+4, jump to the calling function.ra
contains the return address.
从中我们可以知道在函数调用时,跳转之前会先准备返回地址,以便在返回时继续,jalr
会保存返回地址为 PC+4
, 从下面可知跳转之前的地址是 0x34
,所以返回地址就是0x38
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13
26: 45b1 li a1,12
28: 00000517 auipc a0,0x0
2c: 7d850513 addi a0,a0,2008 # 800 <malloc+0xe8>
30: 00000097 auipc ra,0x0
34: 62a080e7 jalr 1578(ra) # 65a <printf>
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.
回答:输出结果为:He110 World
。
解释:(参考博客:xv6-labs-2022 Lab4 traps 答案与解析 (chens.life))
57616
转换为 16 进制为 e110
,格式化描述符 %x
打印出了它的 16 进制值。所以是He110
在小端(little-endian)处理器中,数据0x00646c72
的高字节存储在内存的高位,那么从内存低位,也就是低字节开始读取,对应的 ASCII 字符为 rld
;在 大端(big-endian)处理器中,数据 0x00646c72
的高字节存储在内存的低位,那么从内存低位,也就是高字节开始读取其 ASCII 码为 dlr
。
所以如果要求大端序和小端序输出相同的内容 ,那么在其为大端序的时候,i
的值应该为 0x726c64
,这样才能保证从内存低位读取时的输出为 rld
。
无论 57616
在大端序还是小端序,它的二进制值都为 e110
。大端序和小端序只是改变了多字节数据在内存中的存放方式,并不改变其真正的值的大小,所以 57616
始终打印为二进制 e110
'y='
? (note: the answer is not a specific value.) Why does this happen?printf("x=%d y=%d", 3);
回答:这里明显看到少传了一个 y 的参数,但是系统调用依旧会去 a3 寄存器中找参数,所以会打印出一个垃圾值
这个任务比较简单,要求打印出函数的调用栈,代码见:我的GitHub实现,这里只说几点注意:
要想完成这个任务,需要明白打印函数的调用栈其实就是遍历进程的栈帧,这需要了解栈帧的结构:
起始地址,也就是距离栈顶的栈帧地址保存在 fp 寄存器中,所以返回地址在栈帧指针的 -8 偏移量处;前一个帧指针位于当前栈帧指针 -16 偏移量处
在 xv6 中,所有的进程的内核栈空间只有 1 个 page,该进程的所有的栈帧都在同一个页上面,所以遍历栈帧的停止条件就是使用PGROUNDUP(fp)
来定位帧指针所在的页面的最高地址,当 fp 一直向上,和 PGROUNDUP(fp) 一致时,就说明到栈底了,也即遍历完毕。
这个任务比较有趣,给 OS 添加一个新特性,使得在一个进程运行时,可以周期性执行其他进程(即便只有一个 CPU 核)
具体实现就是添加一个新的系统调用 sigalarm(interval, handler)
。如果一个应用调用了 sigalarm(n, fn)
那么这个进程每消耗 n
个 ticks,内核就要调用函数 fn
。
这里的核心解决方案就是:正常用户程序A trap 到内核空间执行完任务后,会回到用户空间,而 p->trapframe->epc 则保存了用户空间的返回地址,所以我们只需在这里“做一点手脚”,我们把 p->trapframe->epc 修改为另一个用户程序 B 的地址,这样就会从内核空间直接返回到用户空间 B,从而完成调用 B 的实现。
至于何如做到“周期性”,这利用了 XV6 已实现的特性:每一次 tick,硬件时钟都会强制执行一个中断,无需我们介入,该中断在 kernel/trap.c 中处理:
if(which_dev == 2)
{
// printf("p->ticks: %d, and check p->alarm_interval: %d\n", p->pticks,p->alarm_interval);
if(p->alarm_interval == 0) { //sigalarm(0,0)
//ok 直接 usertrapret();
} else if(ticks - p->last_tick >= p->alarm_interval) {//到达指定时间间隔
// printf("time hit!: %d\n", p->alarm_interval);
p->last_tick = ticks;
if(p->alarm_trapframe == 0){//没有handler在运行
// printf("change epc, run halder!\n");
p->alarm_trapframe = (struct trapframe *)kalloc();//分配一个新page
memmove(p->alarm_trapframe, p->trapframe, PGSIZE);//用来保存原trapframe
p->trapframe->epc = p->alarm_handler;//将epc字段改为handler的地址,这样经过(p->trapframe->epc)-> SEPC -> PC, trap返回用户空间后就直接进入了handler函数
} else {
// printf("please wait,there is a handler is running......\n");
}
}else{
yield();
}
这个方案要小心翼翼地实现,因为害怕破坏用户空间 A 的任何状态,从而不能正确返回,所以上面的代码会有一个 p->alarm_trapframe 用来存储 A 程序的trapframe page,然后在 sigreturn 中恢复,所以这种机制的调用过程如下:
说实话,这种调用方式还是比较丑陋的,尤其是 funcB 中对于 sigreturn 的调用。
OK,以上就是 lecture05:GDB calling conentions 和 lecture06:System call entry/exit 的内容,外加 Lab4:traps 的心得 的所有内容了,这一节总结来看就是细节多到爆炸,但是仔细理清的话也不是不可接受,尤其是 trampoline page 的设计非常巧妙,在这里实现了页表的切换。
所有代码见:我的GitHub实现(记得切换到相应分支)
手机扫一扫
移动阅读更方便
你可能感兴趣的文章