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" "mv a0, %2\n\t" "mv a1, %3\n\t" "mv a2, %4\n\t" "ecall\n\t" "mv %0, a0" : "=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 #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 #include "os.h" size_t strlen (const char *str) { char *ptr = (char *)str; while (*ptr != EOS) { ptr++; } return ptr - str; } void * memcpy (void *dest, const void *src, size_t count) { char *ptr = dest; while (count--) { *ptr++ = *((char *)(src++)); } return dest; }
结果
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
函数和一个普通的函数之间的核心差别仅仅是它会 换栈 。
以上参考:任务切换 - 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 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
函数的整体流程:
Trap
控制流在调用 __switch
之前就需要明确知道即将切换到哪一条目前正处于暂停状态的 Trap
控制流,因此 __switch
有两个参数,
第一个参数代表它自己
第二个参数则代表即将切换到的那条 Trap
控制流。
这里我们用上面提到过的 current_task_cx_ptr
和 next_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 #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_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 &= (0U << 8 ); w_sstatus(sstatus); cx_ptr->sepc = (reg_t )task_entry; cx_ptr->sstatus = sstatus; cx_ptr->sp = user_sp; tasks[_top].task_context = tcx_init((reg_t )cx_ptr); 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
报错:
原因:
OS/kerneltrap.S
中的mv sp, a0
要删掉,
正常从__alltraps
走下来的trap_handler
流程。如果是这种情况,trap_handler
会在a0
里返回之前通过mv a0, sp
传进去的&mut TrapContext
,所以这里sp
和a0
相同没有必要再mv sp, a0
重新设置一遍。
app第一次被__switch
的时候通过__restore
开始运行。这时候a0
是个无关的数据(指向上一个TaskContext
的指针),这里再mv sp a0
就不对了,而__restore
要的TrapContext
已经在__switch
的恢复过程中被放在sp
上了。(这个sp
就是初始化时写完TrapContext
后的内核栈顶)
所以如果存在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
重新执行