记录笔者在完成PA3.1必答题的个人理解.
本文章为一生一芯教学项目的个人理解,笔者并不能保证其正确,请注意学术诚信
笔者并非计算机科班,内容难免存在错误,如您发现,欢迎与我联系.
从yield test调用yield()开始, 到从yield()返回的期间, 这一趟旅程具体经历了什么? 软(AM, yield test)硬(NEMU)件是如何相互协助来完成这趟旅程的?
1.CTE初始化
设置异常入口与用户异常回调函数:
1 2 3 4 5 6 7 8 9
| bool cte_init(Context*(*handler)(Event, Context*)) { asm volatile("csrw mtvec, %0" : : "r"(__am_asm_trap));
user_handler = handler;
return true; }
|
在yield test中调用cte_init
1
| CASE('i', hello_intr, IOE, CTE(simple_trap));
|
2.进行自陷操作
在yield test中调用AM的yield()
,在AM中调用ecall,在NEMU中:
- 将当前PC值保存到mepc寄存器
- 在mcause寄存器中设置异常号
- 从mtvec寄存器中取出异常入口地址
- 跳转到异常入口地址(在AM中是
trap.S
中的__am_asm_trap
函数)
3.保存上下文
现在程序跳转到了异常入口trap.S
中的__am_asm_trap
函数中,先进行保存上下文的操作.
1 2 3 4 5 6 7 8 9 10 11 12 13
| addi sp, sp, -CONTEXT_SIZE
MAP(REGS, PUSH)
csrr t0, mcause csrr t1, mstatus csrr t2, mepc
STORE t0, OFFSET_CAUSE(sp) STORE t1, OFFSET_STATUS(sp) STORE t2, OFFSET_EPC(sp)
mv a0, sp
|
展开为汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50
| # 创建栈帧(栈向下生长) 80001528: f7410113 addi sp,sp,-140 # 将除$0,sp以外的寄存器入栈 8000152c: 00112223 sw ra,4(sp) 80001530: 00312623 sw gp,12(sp) 80001534: 00412823 sw tp,16(sp) 80001538: 00512a23 sw t0,20(sp) 8000153c: 00612c23 sw t1,24(sp) 80001540: 00712e23 sw t2,28(sp) 80001544: 02812023 sw s0,32(sp) 80001548: 02912223 sw s1,36(sp) 8000154c: 02a12423 sw a0,40(sp) 80001550: 02b12623 sw a1,44(sp) 80001554: 02c12823 sw a2,48(sp) 80001558: 02d12a23 sw a3,52(sp) 8000155c: 02e12c23 sw a4,56(sp) 80001560: 02f12e23 sw a5,60(sp) 80001564: 05012023 sw a6,64(sp) 80001568: 05112223 sw a7,68(sp) 8000156c: 05212423 sw s2,72(sp) 80001570: 05312623 sw s3,76(sp) 80001574: 05412823 sw s4,80(sp) 80001578: 05512a23 sw s5,84(sp) 8000157c: 05612c23 sw s6,88(sp) 80001580: 05712e23 sw s7,92(sp) 80001584: 07812023 sw s8,96(sp) 80001588: 07912223 sw s9,100(sp) 8000158c: 07a12423 sw s10,104(sp) 80001590: 07b12623 sw s11,108(sp) 80001594: 07c12823 sw t3,112(sp) 80001598: 07d12a23 sw t4,116(sp) 8000159c: 07e12c23 sw t5,120(sp) 800015a0: 07f12e23 sw t6,124(sp) # 读CSR寄存器到t0,t1,t2寄存器(CSR寄存器不能直接入栈,先将其读到寄存器上) 800015a4: 342022f3 csrr t0,mcause 800015a8: 30002373 csrr t1,mstatus 800015ac: 341023f3 csrr t2,mepc # 将t0,t1,t2寄存器入栈 800015b0: 08512023 sw t0,128(sp) 800015b4: 08612223 sw t1,132(sp) 800015b8: 08712423 sw t2,136(sp)
800015bc: 00020537 lui a0,0x20 800015c0: 00a36333 or t1,t1,a0 800015c4: 30031073 csrw mstatus,t1 # 将sp寄存器复制到a0中 # 根据RISC-V调用约定,a0被认为是向函数传递的第一个参数 # 下文通过以上机制来向函数传参 800015c8: 00010513 mv a0,sp
|
4.调用AM的异常处理句柄
4.1 调用句柄与参数传递
在__am_asm_trap
的汇编代码中调用AM的异常处理句柄cte.c
中的__am_irq_handle
1
| 800015cc: e75ff0ef jal ra,80001440 <__am_irq_handle>
|
之前我们提到__am_asm_trap
通过RISC-V的调用约定向异常处理句柄Context* __am_irq_handle(Context *c)
传递输入的参数,关于RISC-V的调用约定:
- 参数传递规则:
整数/指针参数:按顺序使用a0到a7,超出的部分通过栈传递(从右向左压栈)
大结构体:通过指针传递,或拆分到多个寄存器/栈空间
- 返回值:
整数/指针:通过a0和a1返回(64位值可能占用两个寄存器)
大型数据:通过内存(调用者分配空间,传递指针)
根据RISC-V的调用约定,调用者将c
(上下文结构体的地址,也就是这里的sp)放在a0寄存器中,完成向__am_irq_handle
参数的传递.
4.2 事件分发
__am_irq_handle
通过区别上下文结构体中mcause寄存器的值来分发事件.
注意在RISC-V中mepc是通过软件来指示+4操作的.
1 2 3 4 5 6 7 8 9 10
| Event ev = {0}; switch (c->mcause) { case 0xb : if(c->GPR1 == -1){ ev.event = EVENT_YIELD; }else{ ev.event = EVENT_SYSCALL; } c->mepc += 0x4; break; default: ev.event = EVENT_ERROR; break; }
|
4.3 调用用户的异常回调函数
__am_irq_handle
的c = user_handler(ev, c);
调用用户句柄.user_handler
在第一步初始化CTE时定义.
在yield test中的回调:
1 2 3 4 5 6 7 8 9
| Context *simple_trap(Event ev, Context *ctx) { switch(ev.event) { case EVENT_YIELD: putch('y'); break; default: panic("Unhandled event"); break; } return ctx; }
|
识别EVENT_YIELD事件(在事件分发时确定),并做出响应.
4.4 上下文恢复
用户句柄返回一个新的上下文结构体,__am_irq_handle
通过调用约定将这个新的结构体返回给__am_asm_trap
.
程序通过mret
指令返回到mepc的位置中(在__am_irq_handle
中完成+4),完成异常操作.
1 2 3 4 5 6 7 8 9 10
| mv sp, a0 LOAD t1, OFFSET_STATUS(sp) LOAD t2, OFFSET_EPC(sp) csrw mstatus, t1 csrw mepc, t2
MAP(REGS, POP)
addi sp, sp, CONTEXT_SIZE mret
|
展开为汇编代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| # 将a0中的新的上下文结构体移动到sp,切换到新的上下文 # 在yield test中这个还是原来的结构体保持不变 800015d0: 00050113 mv sp,a0 # 将上下文结构体CSR出栈,加载进硬件中 800015d4: 08412303 lw t1,132(sp) 800015d8: 08812383 lw t2,136(sp) 800015dc: 30031073 csrw mstatus,t1 800015e0: 34139073 csrw mepc,t2 # 将上下文结构体寄存器出栈,加载进硬件中 800015e4: 00412083 lw ra,4(sp) 800015e8: 00c12183 lw gp,12(sp) 800015ec: 01012203 lw tp,16(sp) 800015f0: 01412283 lw t0,20(sp) 800015f4: 01812303 lw t1,24(sp) 800015f8: 01c12383 lw t2,28(sp) 800015fc: 02012403 lw s0,32(sp) 80001600: 02412483 lw s1,36(sp) 80001604: 02812503 lw a0,40(sp) 80001608: 02c12583 lw a1,44(sp) 8000160c: 03012603 lw a2,48(sp) 80001610: 03412683 lw a3,52(sp) 80001614: 03812703 lw a4,56(sp) 80001618: 03c12783 lw a5,60(sp) 8000161c: 04012803 lw a6,64(sp) 80001620: 04412883 lw a7,68(sp) 80001624: 04812903 lw s2,72(sp) 80001628: 04c12983 lw s3,76(sp) 8000162c: 05012a03 lw s4,80(sp) 80001630: 05412a83 lw s5,84(sp) 80001634: 05812b03 lw s6,88(sp) 80001638: 05c12b83 lw s7,92(sp) 8000163c: 06012c03 lw s8,96(sp) 80001640: 06412c83 lw s9,100(sp) 80001644: 06812d03 lw s10,104(sp) 80001648: 06c12d83 lw s11,108(sp) 8000164c: 07012e03 lw t3,112(sp) 80001650: 07412e83 lw t4,116(sp) 80001654: 07812f03 lw t5,120(sp) 80001658: 07c12f83 lw t6,124(sp) # 销毁栈帧 8000165c: 08c10113 addi sp,sp,140 # 从异常入口返回到mepc的位置 80001660: 30200073 mret
|
代码最后会返回到yield test触发自陷的代码位置, 然后继续执行. 在它看来, 这次时空之旅就好像没有发生过一样.
4.5 上下文切换(PA4.1)
在4.4中,yield test用户回调句柄返回的还是原来的上下文结构体,如果返回的是另一个上下文结构体呢?
事实上, 有了CTE, 我们就有一种很巧妙的方式来实现上下文切换了. 具体地, 假设进程A运行的过程中触发了系统调用, 通过自陷指令陷入到内核. 根据__am_asm_trap()
的代码, A的上下文结构(Context)将会被保存到A的栈上. 在PA3中, 系统调用处理完毕之后, __am_asm_trap()
会根据栈上保存的上下文结构来恢复A的上下文. 神奇的地方来了, 如果我们先不着急恢复A的上下文, 而是先将栈顶指针切换到另一个进程B的栈上, 那会发生什么呢? 由于B的栈上存放了之前B保存的上下文结构, 接下来的操作就会根据这一结构来恢复B的上下文. 从__am_asm_trap()
返回之后, 我们已经在运行进程B了! -PA4.1
这样的想法就被称为上下文切换,在yield-os.c
中:
4.5.1 进程控制块(PCB)
因为每一个上下文结构体的位置都不固定,所以我们使用cp
(Context pointer)记录上下文结构体的位置,当想要找到其它进程的上下文结构的时候, 只要寻找这个进程相关的cp指针即可.操作系统为每一个进程维护一个进程控制块(PCB),其管理进程相关的信息.
1 2 3 4 5
| typedef union { uint8_t stack[STACK_SIZE]; struct { Context *cp; }; } PCB; static PCB pcb[2], pcb_boot, *current = &pcb_boot;
|
这里cp的内存位置在栈顶. 在进行上下文切换的时候, 只需要把PCB中的cp指针返回给CTE的__am_irq_handle()
函数即可, 剩余部分的代码会根据上下文结构恢复上下文.
4.5.2 内核线程
对于刚创建的进程,我们需要在栈底创建一个初始的上下文结构体,从这个上下文结构体开始正确执行此线程.同时我们需求这个内核线程可以携带一个参数,用于该线程的函数入口来调用.在恢复上下文时我们说过执行mret
指令时程序会跳转到mepc寄存器的值,因此我们想到我们需要修改内核线程的上下文结构体的mepc为函数入口地址:c->mepc = (uintptr_t)entry;
.同时还需要传递参数,我们通过调用约定在a0寄存器放置参数内容.
同时kcontext函数会返回此上下文结构体的指针,我们直接将其赋值给对应pcb的cp指针.
1 2
| pcb[0].cp = kcontext((Area) { pcb[0].stack, &pcb[0] + 1 }, f, (void *)1L); pcb[1].cp = kcontext((Area) { pcb[1].stack, &pcb[1] + 1 }, f, (void *)2L);
|
4.5.3 用户句柄的上下文切换
在配置好不同线程的内核线程之后,我们直接进行自陷操作,一路跳转到用户异常句柄中.
线程/进程调度
进行第一次自陷时:
1 2 3 4 5
| static Context *schedule(Event ev, Context *prev) { current->cp = prev; current = (current == &pcb[0] ? &pcb[1] : &pcb[0]); return current->cp; }
|
在这里通过识别current
PCB是PCB[0]还是PCB[1],函数返回另一个上下文结构体.这里与yield test不同(另一个结构体),第一次自陷时,根据上文描述,程序跳转到PCB[0]的线程中.
线程入口
因为新的上下文结构体mepc被配置为f(void *arg)
这个线程入口的函数地址,程序跳转到这个函数中,根据调用约定传递参数.
1 2 3 4 5 6 7
| static void f(void *arg) { while (1) { putch("?AB"[(uintptr_t)arg > 2 ? 0 : (uintptr_t)arg]); for (int volatile i = 0; i < 10000; i++) ; yield(); } }
|
函数通过判断当前线程携带的参数arg
来识别是PCB[0]还是PCB[1],以此判断输出是A还是B.
要注意,kcontext()
要求内核线程不能从entry返回, 否则其行为是未定义的.因此我们在线程入口调用yield进行自陷操作,继续进行上下文切换,到另一个PCB.
上下文切换的示意
第一次自陷->用户句柄 切换到PCB[0]->f() 自陷->用户句柄 切换到PCB[1]->f() 自陷->用户句柄 切换到PCB[0]->f() 自陷-> …