1.实现sys_write函数

本小节目标:实现通过sys_write函数在 用户态的程序调用串口输出打印

OS/app.c

删掉之前的batch.c,在app.c函数中实现用户态函数 sys_write

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "os.h"

size_t syscall(size_t id, reg_t arg1, reg_t arg2, reg_t arg3) {
long ret;
asm volatile (
"mv a7, %1\n\t" // Move syscall id to a7 register
"mv a0, %2\n\t" // Move args[0] to a1 register
"mv a1, %3\n\t" // Move args[1] to a2 register
"mv a2, %4\n\t" // Move args[2] to a3 register
"ecall\n\t" // Perform syscall
"mv %0, a0" // Move return value to 'ret' variable
: "=r" (ret)
: "r" (id), "r" (arg1), "r" (arg2), "r" (arg3)
: "a7", "a0", "a1", "a2", "memory"
);
return ret;
}

size_t sys_wirte(size_t fd, const char* buf, size_t len)
{
syscall(__NR_write,fd,buf, len);
}

OS/trap.c

trap_handler函数修改

对系统的调用号进行分发 8 —> 表示 U模式下的ecall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
trap_Context* trap_handler(trap_Context* cx)
{
reg_t scause = r_scause();
switch (scause)
{
case 8:
__SYSCALL(cx->a7,cx->a0,cx->a1,cx->a2);
break;
default:
printf("undfined scause:%d\n",scause);

break;
}

cx->sepc += 8;

return cx;
}

OS/syscall.c 新建这个文件

其定义了 __SYSCALL函数的实现 从而调用printk进行输出

syscall中对 call的id进行分发 如果是__NR_write 则代表 __sys_write

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
/* syscall.c */
#include "os.h"

void __SYSCALL(size_t syscall_id, reg_t arg1, reg_t arg2, reg_t arg3) {
switch (syscall_id)
{
case __NR_write:
__sys_write(arg1, arg2, arg3);
break;
default:
printk("Unsupported syscall id:%d\n",syscall_id);
break;
}
}

void __sys_write(size_t fd, const char* data, size_t len)
{
if(fd ==1)
{
printk(data);
}
else
{
panic("Unsupported fd in sys_write!");
}
}

OS/string.c

syscall(__NR_write, *fd*, *buf*, *len*); 由于最后一个len需要计算字符串的长度,所以还要实现strlen函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/* string.c */
#include "os.h"
//计算字符串的长度
size_t strlen(const char *str)
{
char *ptr = (char *)str;
while (*ptr != EOS)
{
ptr++;
}
return ptr - str;
}
// 从存储区 src 复制 n 个字节到存储区 dest。
void* memcpy(void *dest, const void *src, size_t count)
{
char *ptr = dest;
while (count--)
{
*ptr++ = *((char *)(src++));
}
return dest;
}

结果

image-20240615221400398

2.协作式多任务调度

2.1switch介绍

协作式多任务调度:就是如果一个用户程序在做一些等待的事情的时候,比如等待外设响应而不需要占用cpu计算资源的时候可以让出cpu的使用权让下一个用户程序执行,当外设响应完毕后重新拿到cpu使用权继续执行,这大大的提高了cpu的执行效率。

任务切换:从一个任务切换到另一个任务,

  • 同上章节一样,当从用户态切换到内核态,需要保存上下文
  • 从一个任务切换到另一个任务也需要保存上下文,知道CPU重新拿回使用权恢复执行

实现多任务调度的关键:任务主动放弃CPU的执行权 ,在本章节中通过 调用用户态程序sys_yield实现(来告诉操作系统我要放弃CPU 的使用权了),然后操作系统进行任务切换。任务的切换是发生在S态的,任务在操作系统切换完毕后,同样通过_restore回到用户态执行程序,不过此时已经进行了任务切换,所以_restore回到的是切换后的任务

所以,任务切换其实是来自两个不同应用在内核中的 Trap 控制流之间的切换。当一个应用 Trap 到 S 模式的操作系统内核中进行进一步处理的时候,我们可以设计一个函数来给Trap 控制流调用,从而进行任务切换。这个函数我们定义为__switch

具体来说,调用 __switch 之后直到它返回前的这段时间,原 Trap 控制流 A 会先被暂停并被切换出去, CPU 转而运行另一个应用在内核中的 Trap 控制流 B 。然后在某个合适的时机,原 Trap 控制流 A 才会从某一条 Trap 控制流 C (很有可能不是它之前切换到的 B )切换回来继续执行并最终返回。__switch 函数和一个普通的函数之间的核心差别仅仅是它会 换栈

image-20240617152636754

以上参考:任务切换 - rCore-Tutorial-Book-v3 3.6.0-alpha.1 文档 (rcore-os.cn)

每个任务都有自己的任务上下文,每个任务也有自己的内核栈和用户栈空间,所以我们首先需要为每个任务定义内核栈和用户栈。新建一个task.c文件:

OS/task.c

定义了内核栈空间 和 用户栈空间的大小

1
2
3
4
5
6
#include "os.h"
#define USER_STACK_SIZE (4096 * 2)
#define KERNEL_STACK_SIZE (4096 * 2)
#define MAX_TASKS 10 /* 操作系统支持的最大任务数量 */
uint8_t KernelStack[MAX_TASKS][KERNEL_STACK_SIZE]; /* 任务内核栈 */
uint8_t UserStack[MAX_TASKS][USER_STACK_SIZE]; /* 任务用户栈 */

OS/context.h

定义 任务上下文结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* s模式下的任务上下文*/
typedef struct task_Context
{
reg_t ra;
reg_t sp;
reg_t s0;
reg_t s1;
reg_t s2;
reg_t s3;
reg_t s4;
reg_t s5;
reg_t s6;
reg_t s7;
reg_t s8;
reg_t s9;
reg_t s10;
reg_t s11;
}task_Context;

ra寄存器,sp寄存器,s0~s11寄存器, riscv 的函数调用寄存器保存规范:

  • ra:记录了__switch返回后应该跳转到哪里运行
  • 对于一般的函数而言,Rust/C 编译器会在函数的起始位置自动生成代码来保存 s0~s11 这些被调用者保存的寄存器。但 __switch 是一个用汇编代码写的特殊函数,它不会被 Rust/C 编译器处理,所以我们需要在 __switch 中手动编写保存 s0~s11 的汇编代码。
  • 其它寄存器中,属于调用者保存的寄存器是由编译器在高级语言编写的调用函数中自动生成的代码来完成保存的;还有一些寄存器属于临时寄存器,不需要保存和恢复。
寄存器名称 寄存器别名 保存约定 描述
t0 - t6 x5 - x7, x28 - x31 调用者 临时寄存器
a0 - a7 x10 - x17 调用者 参数/返回值寄存器
ra x1 调用者 返回地址寄存器
s0 - s11 x8,x9,x18 - x27 被调用者 保存寄存器
sp x2 被调用者 栈指针寄存器

因此,在调用__switch函数进行任务切换时,我们需要将当前任务的这些寄存器保存,然后将下一个要切换的任务的寄存器从拿出来然后完成寄存器替换。

每个任务都有一个保存自己任务上下文的地方,这个地方我们定义为TaskControlBlock,在TaskControlBlock不止可以保存任务的上下文信息,还可以保存任务的运行状态

OS/task.h

定义了TaskControlBlock来保存任务信息,一个任务信息包括这个任务的状态何任务上下文信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#ifndef __TASK_H__
#define __TASK_H__

#include "os.h"
typedef enum TaskState
{
UnInit, // 未初始化
Ready, // 准备运行
Running, // 正在运行
Exited, // 已退出
}TaskState;

typedef struct TaskControlBlock
{
TaskState task_state;
task_Context task_context;
}TaskControlBlock;

#endif

OS/task.c

添加 一个TaskControlBlock类型的数组,保存每个任务的状态信息和上下文

1
2
3
4
5
6
7
8
9
#include "os.h"
#define USER_STACK_SIZE (4096 * 2)
#define KERNEL_STACK_SIZE (4096 * 2)
#define MAX_TASKS 10 /* 操作系统支持的最大任务数量 */
uint8_t KernelStack[MAX_TASKS][KERNEL_STACK_SIZE]; /* 任务内核栈 */
uint8_t UserStack[MAX_TASKS][USER_STACK_SIZE]; /* 任务用户栈 */

struct TaskControlBlock tasks[MAX_TASKS]; // 任务数组,保存上下文和状态

OS/task.c

对于当前正在执行的任务的 Trap 控制流,我们用一个名为 current_task_cx_ptr 的变量来保存放置当前任务上下文的地址;而用next_task_cx_ptr的变量来保存放置下一个要执行任务的上下文的地址。

1
2
TaskContext *current_task_cx_ptr = &tasks[current].task_context;
TaskContext *next_task_cx_ptr = &tasks[next].task_context;

从栈上内容的角度看待__switch函数的整体流程:

image-20240617152834451

Trap 控制流在调用 __switch 之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap 控制流,因此 __switch 有两个参数,

  • 第一个参数代表它自己
  • 第二个参数则代表即将切换到的那条 Trap 控制流。

这里我们用上面提到过的 current_task_cx_ptrnext_task_cx_ptr 作为代表。在上图中我们假设某次 __switch 调用要从 Trap 控制流 A 切换到 B,一共可以分为四个阶段,在每个阶段中我们都给出了 A 和 B 内核栈上的内容。

  • 阶段 [1]:在 Trap 控制流 A 调用 __switch 之前,A 的内核栈上只有 Trap 上下文和 Trap 处理函数的调用栈信息,而 B 是之前被切换出去的;
  • 阶段 [2]:A 在 A 任务上下文空间在里面保存 CPU 当前的寄存器快照;
  • 阶段 [3]:这一步极为关键,读取 next_task_cx_ptr 指向的 B 任务上下文,根据 B 任务上下文保存的内容来恢复 ra 寄存器、s0~s11 寄存器以及 sp 寄存器。只有这一步做完后, __switch 才能做到一个函数跨两条控制流执行,即 通过换栈也就实现了控制流的切换
  • 阶段 [4]:上一步寄存器恢复完成后,可以看到通过恢复 sp 寄存器换到了任务 B 的内核栈上,进而实现了控制流的切换。这就是为什么 __switch 能做到一个函数跨两条控制流执行。此后,当 CPU 执行 ret 汇编伪指令完成 __switch 函数返回后,任务 B 可以从调用 __switch 的位置继续向下执行。

OS/switch.S

switch代码实现

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
.altmacro
.macro SAVE_SN n
sd s\n, (\n+2)*8(a0)
.endm
.macro LOAD_SN n
ld s\n, (\n+2)*8(a1)
.endm
.section .text
.globl __switch
__switch:

# 阶段 [1]
# __switch(
# current_task_cx_ptr: *mut TaskContext,
# next_task_cx_ptr: *const TaskContext
# )

# 阶段 [2]
# save kernel stack of current task
sd sp, 8(a0)
# save ra & s0~s11 of current execution
sd ra, 0(a0)
.set n, 0
.rept 12
SAVE_SN %n
.set n, n + 1
.endr

# 阶段 [3]
# restore ra & s0~s11 of next execution
ld ra, 0(a1)
.set n, 0
.rept 12
LOAD_SN %n
.set n, n + 1
.endr
# restore kernel stack of next task
ld sp, 8(a1)

# 阶段 [4]
ret

2.2协作式任务调度实现

OS/app.c

封装切换函数 sys_yield()

1
2
3
4
size_t sys_yield()
{
syscall(__NR_sched_yield,0,0,0);
}

OS/syscall.c

修改一下分发函数 加上yeild

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
/* syscall.c */
#include "os.h"

void __SYSCALL(size_t syscall_id, reg_t arg1, reg_t arg2, reg_t arg3) {
switch (syscall_id)
{
case __NR_write:
__sys_write(arg1, arg2, arg3);
break;
case __NR_sched_yield:
__sys_yield();
break;
default:
printk("Unsupported syscall id:%d\n",syscall_id);
break;
}
}

void __sys_write(size_t fd, const char* data, size_t len)
{
if(fd ==1)
{
printk(data);
}
else
{
panic("Unsupported fd in sys_write!");
}
}

void __sys_yield() {
schedule();
}

OS/os.h

添加宏定义

1
#define __NR_sched_yield 124

task.c中定义了schedule

OS/task.c

  • 首先定义了两个变量,一个用来表示当前执行的任务号,一个用来表示用户常见的任务数量。
1
2
static int _current = 0;
static int _top = 0;
  • 然后定义了一个task_create(void (*task_entry)(void))函数来创建任务,此函数传入的参数为一个函数指针,即用户态的应用程序的地址。
    • 在创建任务时我们首先需要为每个任务先构造该任务的trap上下文,包括入口地址和用户栈指针,并将其压入到内核栈顶,然后设置sepc、sstatus、sp寄存器的值。
    • 下一步就是需要为每一个任务构造一个初始的内核任务上下文,这里会调用一个tcx_init的函数,在这个函数里面我们会初始化任务的任务上下文。
    • 再完成trap上下文和任务上下文的构造后会将_top的值加一代表多了一个任务
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
51
52

static int _current = 0;
static int _top = 0;

struct TaskControlBlock tasks[MAX_TASKS]; // 任务数组,保存上下文和状态

struct task_Context tcx_init(reg_t kstack_ptr) {
struct task_Context task_ctx;

task_ctx.ra = __restore;
task_ctx.sp = kstack_ptr;
task_ctx.s0 = 0;
task_ctx.s1 = 0;
task_ctx.s2 = 0;
task_ctx.s3 = 0;
task_ctx.s4 = 0;
task_ctx.s5 = 0;
task_ctx.s6 = 0;
task_ctx.s7 = 0;
task_ctx.s8 = 0;
task_ctx.s9 = 0;
task_ctx.s10 = 0;
task_ctx.s11 = 0;

return task_ctx;
}

void task_create(void (*task_entry)(void))
{
if(_top < MAX_TASKS)
{
/* 对于每个任务先构造该任务的trap上下文,包括入口地址和用户栈指针,并将其压入到内核栈顶*/
trap_Context* cx_ptr = &KernelStack[_top] + KERNEL_STACK_SIZE - sizeof(trap_Context);
reg_t user_sp = &UserStack[_top] + USER_STACK_SIZE;
reg_t sstatus = r_sstatus();
// 设置 sstatus 寄存器第8位即SPP位为0 表示为U模式
sstatus &= (0U << 8);
w_sstatus(sstatus);
/* 设置用户程序内核栈 ,填充用户栈指针*/
cx_ptr->sepc = (reg_t)task_entry;
cx_ptr->sstatus = sstatus;
cx_ptr->sp = user_sp;

/* 构造每个任务任务控制块中的任务上下文,设置 ra 寄存器为 __restore 的入口地址*/
tasks[_top].task_context = tcx_init((reg_t)cx_ptr);
// 初始化 TaskStatus 字段为 Ready
tasks[_top].task_state = Ready;

_top++;

}
}
  • 然后定义了一个tcx_init的函数用来初始化创建的任务的任务上下文信息,传入的参数为任务的内核栈地址,
    • 这里把任务的ra寄存器的值设置为__restore,那是因为在应用真正跑起来之前,需要 CPU 第一次从内核态进入用户态。我们在上一篇文章中也介绍过实现方法,只需在内核栈上压入构造好的 Trap 上下文,然后 __restore 即可。但是现在我们是通过__switch来进行任务切换的,__switch切换完成后会返回到ra的地方执行,我们这里第一次将ra设置为__restore,那么程序就可以从内核态切换回用户态执行了,为此我们需要定义一个run_first_task()的函数来完成第一次切换。
    • 可以看见在run_first_task()函数中,先构造了一个_unused的任务上下文,然后调用__switch函数让切换到tasks[0]进行执行,用于初始化时tasks[0]ra被设置成了__restore的地址,所以就能去返回用户态运行task0了。需要注意的是, __restore 的实现需要做出变化:它 不再需要 在开头 mv sp, a0 了。因为在 __switch 之后,sp 就已经正确指向了我们需要的 Trap 上下文地址。
1
2
3
4
5
6
7
8
9
void run_first_task()
{
tasks[0].task_state = Running;
struct task_Context *next_task_cx_ptr = &(tasks[0].task_context);
struct task_Context _unused ;

__switch(&_unused,next_task_cx_ptr);
panic("unreachable in run_first_task!");
}
  • 最后我们来看schedule()函数,首先判断一下创建的任务数量不为0,然后进行轮转调度,如果下一个任务的状态是ready,那么就切换到下一个任务执行,并且将当前任务的状态置为ready
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

void schedule()
{
if (_top <= 0) {
panic("Num of task should be greater than zero!\n");
return;
}

/* 轮转调度 */
int next = _current + 1;
next = next % _top;

if(tasks[next].task_state == Ready)
{
struct TaskContext *current_task_cx_ptr = &(tasks[_current].task_context);
struct TaskContext *next_task_cx_ptr = &(tasks[next].task_context);
tasks[next].task_state = Running;
tasks[_current].task_state = Ready;
_current = next;
__switch(current_task_cx_ptr,next_task_cx_ptr); // 切换
}

3.测试

OS/app.c

新建三个task任务测试程序

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
51
52
53
54
55
56
void task_delay(volatile int count)
{
count *= 50000;
while (count--);
}


void task1()
{
const char *message = "task1 is running!\n";
int len = strlen(message);
while (1)
{
sys_wirte(1,message, len);
task_delay(10000);
sys_yield();
}
}


void task2()
{
const char *message = "task2 is running!\n";
int len = strlen(message);
while (1)
{
sys_wirte(1,message, len);
task_delay(10000);
sys_yield();
}



}

void task3()
{
const char *message = "task3 is running!\n";

int len = strlen(message);
while (1)
{
sys_wirte(1,message, len);
task_delay(10000);
sys_yield();
}

}

// 初始化函数
void task_init(void)
{
task_create(task1);
task_create(task2);
task_create(task3);
}

OS/main.c

1
2
3
4
5
6
7
8
9
10
#include "os.h"

void os_main()
{
printk("hello!!!\n");
trap_init();
task_init();
run_first_tasks();

}

sudo ./build.sh

sudo ./run.sh

报错:

image-20240618203510960

原因:

OS/kerneltrap.S

中的mv sp, a0要删掉,

  1. 正常从__alltraps走下来的trap_handler流程。如果是这种情况,trap_handler会在a0里返回之前通过mv a0, sp传进去的&mut TrapContext,所以这里spa0相同没有必要再mv sp, a0重新设置一遍。
  2. app第一次被__switch的时候通过__restore开始运行。这时候a0是个无关的数据(指向上一个TaskContext的指针),这里再mv sp a0就不对了,而__restore要的TrapContext已经在__switch的恢复过程中被放在sp上了。(这个sp就是初始化时写完TrapContext后的内核栈顶)
  3. 所以如果存在mv sp, a0,则会导致 访问内存异常 因为 a0是个无关的数据
1
2
3
4
5
6
7
8
9
10
11
12
13
__restore:
# case1: start running app by __restore
# case2: back to U after handling trap
# mv sp, a0
# now sp->kernel stack(after allocated), sscratch->user stack
# restore sstatus/sepc
ld t0, 32*8(sp)
ld t1, 33*8(sp)
ld t2, 2*8(sp)
csrw sstatus, t0
csrw sepc, t1
csrw sscratch, t2
# restore general-purpuse reg

重新执行

image-20240618203435306