start.s文件(.s文件是汇编语言源代码文件的扩展,包含了汇编语言代码,直接对应目标处理器的ISA)

一个典型的.s文件文件包含了以下几个部分:

数据段:存储程序使用的常量、字符串、数组等数据。

代码段:存储实际程序指令。

全局符号和标签:定义程序中的使用的标识符和函数名,供汇编器和链接器使用

编写一个简单RISC-V的系统

需要以下几个文件部分:

platform.h(硬件信息)

start.s(

初始化堆栈:为每个 hart 分配堆栈空间,并初始化栈指针 sp

挂起非 hart 0 的核心:通过 wfi 指令将非 hart 0 的核心挂起,直到 hart 0 启动内核。

设置堆栈对齐:确保堆栈空间的对齐,以便符合 RISC-V 的 16 字节对齐规则。

多核心初始化:通过 hart id 为每个核心分配独立的堆栈空间。)

uart.c(交互显示)

宏定义uart所需要的寄存器。

uart初始化(打开或者关闭中断,设置串口的数据位、停止位、校验位、禁止波特锁存器)

定义uart输出函数输入函数

Makefile(编译的构建规则)

types.h(记录数据类型)

kernel.c(内核执行文件等同于int main)

最小RVOS

platform.h

1
2
3
4
5
6
#ifndef __PLATFORM_H__
#define __PLATFORM_H__

#define MAXNUM_CPU 8
#define UART0 0x10000000L
#endif

start.S

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
#include "platform.h"
.equ STACK_SIZE ,1024
.global _start
.text
_start:
csrr t0, mhartid # read current hart id
mv tp, t0 # keep CPU's hartid in its tp for later usage.
bnez t0, park # if we're not on the hart 0
# we park the hart
# Setup stacks, the stack grows from bottom to top, so we put the
# stack pointer to the very end of the stack range.
slli t0, t0, 10 # shift left the hart id by 1024
la sp, stacks + STACK_SIZE # set the initial stack pointer
# to the end of the first stack space
add sp, sp, t0 # move the current hart stack pointer
# to its place in the stack space

j start_kernel # hart 0 jump to c

park:
wfi
j park

# In the standard RISC-V calling convention, the stack pointer sp
# is always 16-byte aligned.
.balign 16
stacks:
.skip STACK_SIZE * MAXNUM_CPU # allocate space for all the harts stacks

.end # End of file

uart.c

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
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
#include "types.h"
#include "platform.h"
#define BUFFER_SIZE 100
#define UART_REG(reg) ((volatile uint8_t *)(UART0 + reg))
/*
* UART control registers map. see [1] "PROGRAMMING TABLE"
* note some are reused by multiple functions
* 0 (write mode): THR/DLL
* 1 (write mode): IER/DLM
*/
#define RHR 0 // Receive Holding Register (read mode)
#define THR 0 // Transmit Holding Register (write mode)
#define DLL 0 // LSB of Divisor Latch (write mode)
#define IER 1 // Interrupt Enable Register (write mode)
#define DLM 1 // MSB of Divisor Latch (write mode)
#define FCR 2 // FIFO Control Register (write mode)
#define ISR 2 // Interrupt Status Register (read mode)
#define LCR 3 // Line Control Register
#define MCR 4 // Modem Control Register
#define LSR 5 // Line Status Register
#define MSR 6 // Modem Status Register
#define SPR 7 // ScratchPad Register
/*
* POWER UP DEFAULTS
* IER = 0: TX/RX holding register interrupts are both disabled
* ISR = 1: no interrupt penting
* LCR = 0
* MCR = 0
* LSR = 60 HEX
* MSR = BITS 0-3 = 0, BITS 4-7 = inputs
* FCR = 0
* TX = High
* OP1 = High
* OP2 = High
* RTS = High
* DTR = High
* RXRDY = High
* TXRDY = Low
* INT = Low
*/
/*
* LINE STATUS REGISTER (LSR)
* LSR BIT 0:
* 0 = no data in receive holding register or FIFO.
* 1 = data has been receive and saved in the receive holding register or FIFO.
* ......
* LSR BIT 5:
* 0 = transmit holding register is full. 16550 will not accept any data for transmission.
* 1 = transmitter hold register (or FIFO) is empty. CPU can load the next character.
* ......
*/
#define LSR_RX_READY (1<<0)
#define LSR_TX_IDLE (1<<5)
void uart_init()
{
/* disable interrupts. */
uart_write_reg(IER, 0x00);

/*
* Setting baud rate. Just a demo here if we care about the divisor,
* but for our purpose [QEMU-virt], this doesn't really do anything.
*
* Notice that the divisor register DLL (divisor latch least) and DLM (divisor
* latch most) have the same base address as the receiver/transmitter and the
* interrupt enable register. To change what the base address points to, we
* open the "divisor latch" by writing 1 into the Divisor Latch Access Bit
* (DLAB), which is bit index 7 of the Line Control Register (LCR).
*
* Regarding the baud rate value, see [1] "BAUD RATE GENERATOR PROGRAMMING TABLE".
* We use 38.4K when 1.8432 MHZ crystal, so the corresponding value is 3.
* And due to the divisor register is two bytes (16 bits), so we need to
* split the value of 3(0x0003) into two bytes, DLL stores the low byte,
* DLM stores the high byte.
*/
uint8_t lcr = uart_read_reg(LCR);
uart_write_reg(LCR, lcr | (1 << 7));
uart_write_reg(DLL, 0x03);
uart_write_reg(DLM, 0x00);

/*
* Continue setting the asynchronous data communication format.
* - number of the word length: 8 bits
* - number of stop bits:1 bit when word length is 8 bits
* - no parity
* - no break control
* - disabled baud latch
*/
lcr = 0;
uart_write_reg(LCR, lcr | (3 << 0));
}

int uart_putc(char ch)
{
while ((uart_read_reg(LSR) & LSR_TX_IDLE) == 0);
return uart_write_reg(THR, ch);
}

void uart_puts(char *s)
{
while (*s) {
uart_putc(*s++);
}
}
int uart_getc()
{
while((uart_read_reg(LSR) & LSR_RX_READY) ==0);
return (char)uart_read_reg(RHR);
}
void uart_gets(char *buffer) {
int i = 0;
char ch = 0; // 初始化变量
while (i < BUFFER_SIZE - 1) { // 防止缓冲区溢出
ch = uart_getc(); // 获取一个字符
if (ch == '\n' || ch == '\r') { // 如果是换行或回车
break; // 结束输入
}
buffer[i++] = ch; // 将字符存入缓冲区
}
buffer[i] = '\0'; // 确保字符串以 '\0' 结尾
}

types.h

1
2
3
4
5
6
7
8
#ifndef __TYPES_H__
#define __TYPES_H__
typedef unsigned char uint8_t;
typedef unsigned short uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long uint64_t;

#endif

kernel.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
extern void uart_init(void);
extern void uart_puts(char *s);
extern void uart_gets(char *buffer);


void start_kernel(void)
{
char chr[100];
uart_init();
uart_puts("Hello, RVOS!\n");
uart_gets(chr);
uart_puts("Echo:");
uart_puts(chr);
while (1) {} // stop here!
}

内存管理

内存管理分类:

自动管理内存-Stack

静态内存-全局变量/静态变量

动态管理内存-堆(heap)

总体流程如下:

  1. 内存初始化:启动时划分内存区域,生成管理结构(链表或位图)。

  2. 内存分配:根据算法分配内存块,并在需要时拆分大块。

  3. 内存释放:释放后合并相邻块以减少碎片。

  4. 内存碎片整理:定期合并空闲块,防止碎片化。

  5. 内存保护:使用 MMU/MPU 实现任务间内存隔离。

  6. 监控与调试:提供工具查看内存使用状况,调试内存管理问题

    更多资料:从零手写操作系统之RVOS内存管理模块简单实现-02-腾讯云开发者社区-腾讯云

中断与异常

异常是同步的,由所执行指令触发的,中断是异步的,是由外部设备的事件触发的。中断与被中断的指令及进程无直接关联,异常关联被中断的指令与进程,异常的处理可能会阻塞杀死本进程.

中断分类:

  1. 软中断
  2. 定时中断
  3. 外部中断

中断采用的寄存器(RISCV):

中断

更多中断详细资料可看:从零手写操作系统之RVOS外设中断实现-04-腾讯云开发者社区-腾讯云

中断信号转换和汇聚由PILC(中断平台控制器,Platform-Level Interrupt Controller)完成。

PILC是RISCV架构中特有的存在。

在其他架构上,中断控制器的实现有所不同。例如:

  • ARM架构:使用 GIC(Generic Interrupt Controller,通用中断控制器)。
  • x86架构:使用 APIC(Advanced Programmable Interrupt Controller,高级可编程中断控制器)或传统的 PIC(Programmable Interrupt Controller)。
  • MIPS架构:有自己的中断控制机制。

每个 PLIC 包含 2 个 32 位的 Pending 寄存器,每一个 bit 对应一个中断源,如果为 1 表示该中断源上发生了中断(进入Pending 状态),有待 hart 处理,否则表示该中断源上当前无中断发生。
Pending 寄存器中断的 Pending 状态可以通过 claim 方式清除。
第一个 Pending 寄存器的第 0 位对应不存在的 0 号中断源,其值永远为 0。

uart中断

Linux系统上,UART的中断机制通常支持接收中断、数据寄存器空、发送完成中断

  1. 接收完成中断(Receive Complete Interrupt)
  • 作用:当 UART 接收到一个字节的数据并存储在接收数据寄存器中时,会触发接收完成中断。操作系统可以通过这个中断来读取数据。
  • 硬件寄存器:接收完成中断通常基于 UART 接收数据寄存器(如 RXD)中的数据填充,硬件会设置一个标志位(如 RXC)表示接收到新数据。
  • Linux 驱动:在 Linux 中,串口驱动通常会监听这个中断,当接收到数据时,通过 tty 驱动将数据从接收寄存器读取到内核缓冲区。
  • 会在每接收到一个字节后触发一次,直到所有字节都读完,关于多字节的处理,可以采用DMA或者中断合并机制来减少中断次数,可中断一次处理多个字节数据。或者某平台提供接收FIFO缓冲区,这种情况下接收中断会在FIFO缓冲区满时候触发中断。
  1. 数据寄存器空中断(Data Register Empty Interrupt)
  • 作用:当 UART 的发送数据寄存器空闲时,会触发数据寄存器空中断。通常情况下,硬件会根据 TXD 寄存器的状态设置一个标志(如 TXE)来表示发送缓冲区为空。
  • 硬件寄存器:当发送数据寄存器为空时,硬件会通过设置 TXE(Transmit Empty)标志来触发中断。驱动程序可以通过这个中断写入新的数据到发送寄存器。
  • Linux 驱动:Linux 串口驱动通过 uart 驱动程序监听此中断,一旦发送寄存器空,驱动会将缓冲区中的数据写入发送寄存器,继续数据发送。
  1. 发送完成中断(Transmit Complete Interrupt)
  • 作用:当 UART 完成数据的发送(即所有数据已从发送数据寄存器传输到硬件并已送出)时,会触发发送完成中断。通常,这是一个可选的中断,取决于硬件设计。
  • 硬件寄存器:发送完成中断可能通过 TXC(Transmit Complete)标志来触发,表示所有数据已经发送完毕。
  • Linux 驱动:这个中断常用于确认所有数据已经成功发送并清空相关状态,驱动程序通常会通过中断来完成后续任务,如通知应用程序或管理发送队列。

多任务切换

参考资料:

从零手写操作系统之RVOS协作式多任务切换实现-03-腾讯云开发者社区-腾讯云

从零手写操作系统之RVOS抢占式多任务实现-06-腾讯云开发者社区-腾讯云

多任务切换机制分为以下几种:

1.协作式多任务切换(Cooperative Multitasking)

在协作式多任务切换中,任务需要主动让出 CPU,即任务自身在某个执行点上调用“让出 CPU”的指令(例如 yield()),切换到其他任务。任务切换通常发生在任务完成一个工作单元或者进入等待状态的时候。

  • 实现方式:任务执行到安全点时调用让出函数,触发上下文切换。
  • 优点:实现简单,适合嵌入式系统,任务可以控制自己的切换点。
  • 缺点:如果某个任务没有主动让出 CPU,其他任务会处于“饥饿”状态。
  1. 抢占式多任务切换(Preemptive Multitasking)

在抢占式多任务切换中,系统通过定时器中断等硬件机制强制让任务让出 CPU,确保各任务可以在固定的时间片内执行,保证调度公平性。

  • 实现方式:通过定时器中断周期性触发任务切换,当前任务的状态被保存,切换到下一个任务。
  • 优点:公平性强,任务不需要主动让出 CPU,因此不会出现某个任务长期占用 CPU 的情况。
  • 缺点:实现较为复杂,通常需要硬件支持。
  1. 时间片轮转调度(Round-Robin Scheduling)

时间片轮转调度是一种基于时间片的抢占式调度。每个任务按照顺序在 CPU 上执行一个固定的时间片,到期后切换到下一个任务。

  • 实现方式:为每个任务分配一个时间片,到期时通过定时器中断切换到下一个任务。
  • 优点:简单公平,所有任务轮流执行,避免任务饥饿。
  • 缺点:对于短任务,可能会出现较高的切换开销。
  1. 优先级调度(Priority-Based Scheduling)

在优先级调度中,系统根据任务的优先级来选择执行的任务。通常由抢占式机制实现,高优先级任务可以中断低优先级任务来获得 CPU 资源。(可以是抢占的或者非抢占的)

  • 实现方式:任务被分配优先级,当新的高优先级任务需要执行时,中断当前任务。
  • 优点:高优先级任务能及时得到执行,适合实时系统。
  • 缺点:可能导致“优先级反转”或“低优先级任务饥饿”。
  1. 实时调度(Real-Time Scheduling)

实时调度是一种基于任务的时间约束的调度方法。分为硬实时软实时两类:

  • 硬实时:必须在严格的时间内完成任务,否则可能会导致系统失败。
  • 软实时:尽量在一定时间内完成任务,但允许一定的延迟。
  • 实现方式:基于优先级或时间片调度,结合任务的时间约束来决定切换。
  1. 分时调度(Time-Sharing Scheduling)

分时调度让多个用户或任务共享 CPU 时间。每个任务得到的时间片较长,适合交互式操作,让用户感到系统始终在响应。

  • 实现方式:为每个任务分配较长的时间片,在时间片到期后切换任务。
  • 优点:用户感到操作流畅,适合交互式系统。
  • 缺点:不能保证实时性,不适合对响应时间要求严格的应用。
  1. 双核或多核系统的任务切换(Multicore Scheduling)

在双核或多核系统中,任务切换不仅发生在单个 CPU 内核上,还可以跨多个内核调度。调度器需决定任务在各个内核之间的分配和切换,通常实现更复杂的并行调度机制。

  • 实现方式:分布式调度,多个内核各自执行任务切换,或者统一调度器分配任务到各个内核。
  • 优点:高效利用多核资源,提升系统并发能力。
  • 缺点:任务在多个内核间切换有开销,需考虑负载均衡和内核间通信。
特点 协作式任务切换中的 trap handler 抢占式任务切换中的 trap handler
触发方式 由任务主动调用(如 yield() 由定时器中断强制触发
trap handler 的使用频率 取决于任务调用频率,任务在特定点自愿放弃 CPU 频率固定,由定时器中断控制
上下文切换时机 当任务执行到 yield() 或自愿让出 CPU 时 时间片到期强制切换
任务切换的控制权 任务自身控制,让出 CPU 系统控制,定时器到期强制切换
可靠性和实时性 较低,可能出现长时间占用 CPU 的任务 较高,任务被固定时间片切换,保证实时性

在RISC-V架构中,Trap Handler的定位是通过一系列的硬件寄存器来实现的。当发生异常(exception)或中断(interrupt)时,CPU会自动跳转到一个预定义的地址,这个地址是通过机器模式的控制和状态寄存器(CSRs)中的MTVEC(Machine Trap Base Address)寄存器来配置的。当Trap发生时,CPU会读取MTVEC寄存器中的值作为中断服务例程(ISR)的入口点,并跳转到该处执行相应的中断处理逻辑。

此外,为了能够处理不同类型的中断和异常,RISC-V提供了MCause(Machine Cause)寄存器来指示触发Trap的具体原因,以及MEPC(Machine Exception Program Counter)寄存器来记录导致Trap的指令地址。这些机制确保了操作系统能够准确地响应和处理各种硬件和软件引起的中断事件。

Trap Handler的工作机制

当 RISC-V CPU 遇到异常或中断时,会自动跳转到 trap handler 执行。以下是详细的执行步骤:

1.检测和保存当前状态

  • 触发 trap:当硬件中断、软件中断、异常或系统调用发生时,CPU 会触发 trap,并自动切换到 Machine Mode(M 模式)。

  • 保存程序计数器:CPU 会将触发 trap 的指令地址保存到 mepc 寄存器中,以便在完成处理后能够恢复到正确的执行位置。

  • 记录 trap 原因

    :CPU 将 trap 的具体原因记录到

    1
    mcause

    寄存器中。该寄存器的值分为两部分:

    • 最高位:0 表示异常,1 表示中断。
    • 低位:表示异常或中断的具体类型,例如指令地址错误、非法指令或定时器中断等。
  • 存储 trap 值:在某些异常中,CPU 会将 trap 相关的附加信息存储到 mtval 寄存器中(如发生地址错误时,存储异常的具体地址)。

2.定位 trap handler 地址

  • 读取 mtvec 寄存器mtvec 寄存器存储了 trap handler 的入口地址。它有两种模式:直接模式和向量模式。
    • 直接模式(Direct Mode):所有 trap 都会跳转到 mtvec 指定的单一地址(通常是操作系统的通用 trap handler 地址)。
    • 向量模式(Vectored Mode):不同的中断类型跳转到不同的地址,即 mtvec 基地址加上中断类型编号偏移量,从而实现更高效的 trap 处理。
  • 跳转到 trap handler:根据 mtvec 中的地址,CPU 跳转到相应的 trap handler,开始执行具体的处理逻辑。
  • 3.执行 trap handler

在 trap handler 中,操作系统会根据 trap 的具体类型执行不同的操作:

  • 保存当前上下文:trap handler 会将当前任务的 CPU 状态(包括通用寄存器)保存到内核栈中。保存上下文的操作通常会通过汇编完成,以确保在被打断的任务恢复后可以继续执行。

  • 判断 trap 类型

    :操作系统读取

    1
    mcause

    寄存器来判断 trap 的类型,以执行适当的处理。

    • 时钟中断:用于周期性触发任务调度器,进行任务切换。
    • 外部中断(如 I/O 中断):用于处理来自外部设备的请求。
    • 系统调用:trap handler 识别出系统调用后,会根据调用编号来执行相应的操作(如文件读写、内存分配)。
    • 异常处理:如果是非法指令或地址错误等异常,操作系统可能会终止任务或记录错误信息。
  • 执行特定处理逻辑:根据 trap 类型,调用相应的处理函数。对于中断,可能是完成 I/O 操作;对于时钟中断,可能是触发任务调度;对于系统调用,执行用户进程请求的系统服务。

4.恢复上下文

在完成 trap 处理后,trap handler 会恢复被打断的任务上下文:

  • 恢复通用寄存器:trap handler 从内核栈中将保存的寄存器值恢复到通用寄存器。
  • 恢复程序计数器:将 mepc 中保存的地址恢复到 PC,确保任务能够从中断或异常发生的位置继续执行。

5.返回到用户模式(或被打断的权限模式)

  • 关闭 Machine Mode 并返回

    :在恢复完上下文后,trap handler 使用 mret 指令(Machine Mode Return)从 Machine Mode 返回到之前的执行模式(通常是用户模式)。

    • mret 指令会将 mepc 中的地址载入 PC,从而让被打断的程序继续执行。
    • 如果任务处于用户模式,则会回到用户模式继续执行。

关键寄存器在 trap handler 中的作用总结

寄存器 作用
mstatus 保存和恢复 CPU 状态、管理中断使能
mtvec 指定 trap handler 的入口地址
mepc 保存被打断指令的地址
mcause 记录 trap 的原因(中断或异常类型)
mtval 存储与异常相关的具体信息
mie/mip 管理中断的使能和挂起状态

软件定时器和硬件定时器

tick是计算机系统中的一个基本时间单位,尤其常见于操作系统的时间管理和任务调度。每一个tick代表一个固定的时间间隔。

硬件定时是指利用硬件定时器(硬件晶振来提供时钟周期)来精确地控制和测量时间。提供高精度的时间测量与控制。

CLINT(Core Local Interruptor,核心本地中断控制器)是RISCV架构中的另一个中断控制器负责处理核心本地中断,包括软件中断和定时器中断。

mtimecmp(Machine Timer Compare Register)

mtimecmp 是一个定时器比较寄存器,用于定时器中断。每个内核都有一个 mtimecmp 寄存器,通过设置它的值可以控制定时器中断的触发时刻。

  • 作用:当 mtime 的值大于或等于 mtimecmp 时,将触发定时器中断。要取消定时器中断,可以将 mtimecmp 设置为一个大于 mtime 的值。

mtime(Machine Time Register)

mtime 是机器时间寄存器,保存着系统自启动以来的时间计数值(通常以周期计时)。mtime 会不断递增,通常由一个外部时钟源驱动。

  • 作用:保存系统当前时间计数值,单位为时钟周期。mtime 是只读的。

硬件定时流程

硬件定时

软件中断

软件定时器的设计流程:

系统调用

RISCV支持三种模式:simple embedded systems,secure embedded systems ,systems running Unix-like operating systems。

系统模式:用户态和内核态,想让系统开始跑在用户态就要设置对应的寄存器。

首先在之前.s启动文件中,设置了mstatus的寄存器MPP位和MPIE为1

mstatus寄存器不进行设置就是0,所以进入用户态就是MPP位不设置。

RISC-V处于安全考虑,不允许用户态程序直接执行部分特权指令,因此只能采用间接的方式进行访问,也就是通过系统调用的方式进行特权资源访问。

所谓系统调用,就是通过一条特殊的ecall指令,帮助我们从用户态切换到内核态执行,然后通过一条eret指令,从内核态再切换回用户态执行:

系统调用执行流程

系统启动流程

加电自检

初始化BIOS

检查CPU

寄存器

检查BIOS代码的完整性

检查DMA、timer、interrupt controller

检查系统内存

检查总线和外部设备

RISCV开发板Linux启动流程

-板子上电后,CPU从固定地址运行ROM中的代码

-ROM包含简单的设备驱动,从flash或者SD卡加载bootloader

-再由bootloader加载内核、initramfs等到内存,跳转到Linux内核启动

药品养护流程图 (1)

RISC-V UEFI 架构支持详解,第 1 部分 - OpenSBI/U-Boot/UEFI 简介 - 泰晓科技