PA3.1-理解穿越时空的旅程

记录笔者在完成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的调用约定:

  1. 参数传递规则:
    ​整数/指针参数:按顺序使用a0到a7,超出的部分通过栈传递(从右向左压栈)
    ​大结构体:通过指针传递,或拆分到多个寄存器/栈空间
  2. 返回值:
    ​整数/指针:通过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_handlec = 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; // new context. then __am_irq_handle use returned context.
}

在这里通过识别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() 自陷-> …


PA3.1-理解穿越时空的旅程
http://example.com/2025/03/28/PA3-1-理解穿越时空的旅程/
作者
lethe
发布于
2025年3月28日
许可协议