从零手写操作系统之RVOS环境搭建-01
- 背景介绍
- 操作系统的定义
- 操作系统的分类
- 典型的 RTOS 介绍
- 课程系统RVOS简介
- Hello World
- QEMU介绍
- QEMU-virt 地址映射
- 系统引导
- 引导程序要做哪些事情
- 如何判断当前hart是不是第一个hart?
- 如何初始化栈?
- 如何在屏幕输出Hello World
- 通过串口输出
- UART特点
- UART的物理接口
- UART的通信协议
- NS16550a 编程接口介绍
- NS16550a 的初始化
- NS16550a 的数据读写
本系列参考: 学习开发一个RISC-V上的操作系统 - 汪辰 - 2021春 整理而来,主要作为xv6操作系统学习的一个前置基础。
RVOS是本课程基于RISC-V搭建的简易操作系统名称。
课程代码和环境搭建教程参考github仓库: https://github.com/plctlab/riscv-operating-system-mooc/blob/main/howto-run-with-ubuntu1804_zh.md
背景介绍
操作系统的定义
操作系统的分类
典型的 RTOS 介绍
课程系统RVOS简介
Hello World
QEMU介绍
片上系统(System-on-Chip,SoC)是指将多个组件和功能集成到单个集成电路(IC)上的完整电子系统或微芯片。它将处理器核心、存储器、输入/输出接口、外设和其他系统组件等多个硬件组件集成在一颗芯片上。
- SoC旨在为特定应用提供全面的解决方案,例如移动设备、嵌入式系统或物联网(IoT)设备。通过在单个芯片上集成多个组件,SoC具有功耗降低、尺寸更小、性能提升和成本效益等优势。
- SoC广泛应用于各种电子设备,包括智能手机、平板电脑、智能电视、游戏机、汽车系统和许多其他消费电子产品和工业应用。它们在实现现代电子设备的功能和性能方面发挥着至关重要的作用。
QEMU(Quick EMUlator)是一个开源的虚拟化软件,用于模拟多种硬件平台和体系结构。“QEMU virt” 是 QEMU 中的一个虚拟平台,用于模拟基于 ARM 架构的虚拟机。它是 QEMU 支持的多个虚拟平台之一,常用于开发和测试 ARM 架构相关的软件和系统。
- QEMU virt 平台支持运行多种操作系统,包括 Linux、Android 等。它提供了一组虚拟设备和功能,包括 CPU、内存、磁盘、网络等,可以模拟一个完整的虚拟环境,使开发人员能够在此环境中进行应用程序的调试、性能优化、驱动程序的开发等。
- 通过使用 QEMU virt 平台,开发人员可以在不需要实际硬件的情况下进行 ARM 架构相关软件的开发和测试工作,极大地提高了开发效率和灵活性。同时,QEMU 还提供了丰富的命令行和配置选项,使用户可以根据自己的需求对虚拟机进行定制和扩展。
QEMU-virt 地址映射
QEMU 是一个虚拟化平台,它通过模拟不同的硬件设备和处理器架构,提供了统一的编址和访问方式。在 QEMU 中,所有的设备都被虚拟化为统一的地址空间,并通过内存映射来访问这些设备。这样,操作系统和应用程序可以使用统一的编程接口和地址空间访问不同的设备,而不需要关注实际的物理硬件细节。
系统引导
QEMU跑起来之后,BootLoader会跳转到0X8000-0000处继续执行。
QEMU的运行命令参数会携带-kernel参数,该参数指明加载我们的os.elf内核文件到内存。并且os.elf文件在链接时也指明了text代码段被加载到内存中的0x8000 0000位置处。
引导程序要做哪些事情
QEMU默认提供8个模拟的hart,这8个hart一上电都会去运行我们的kernel程序,课程为了简单起见,默认只会使用一个hart,其余hart让其进行空转:
因此,我们需要在kernel启动程序中编写程序完成上面的需求。
如何判断当前hart是不是第一个hart?
- 除了x0 - x31 这32个默认提供的通用寄存器外,RSIC-V对于每个特权模式都有自己的一条寄存器,也就是控制状态寄存器CSR
- RSIC-V定义了特殊的指令来访问这些CSR寄存器,因为机器一上电默认运行在Machine模式下,所以我们先关注如何读写Machine模式下的CSR寄存器
我们关注的是最上面的Machine Information Registers这组寄存器,这组寄存器中存放了当前机器的相关状态信息,比如: 当前hart的id.
为了读写这组状态寄存器,我们需要使用专门的CSR指令:
CSRRW指令(原子读写CSR寄存器): 一般可用于实现两个寄存器值的交换,并且这个过程是原子性的,不可打断
如果RD位为x0,则相当于将rs赋值给csr寄存器,因为向x0寄存器写入数据是没有意义的。
CSRRS(原子读并设置CSR中某一位的值):
如果RS位为x0,则只是单独对CSR寄存器进行读取。
经过上面的分析可知,如果要实现我们的需求,则需要读取mhartid寄存器:
核心汇编代码如下:
_start:
# park harts with id != 0
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 halt0,we park the hart
...
park:
wfi
j park
Wait for Interrupt instruction (WFI)
是 RISC-V架构定义的一条休眠指令。当处理器执行到 WFI 指令之后,将会停止执行当前的指令流,进入一种空闲状态。这种空闲状态可以被称为“休眠;"状态,直到处理器接收到中断,
如何初始化栈?
这里给出start.S汇编代码:
#include "platform.h"
# 每个硬件线程的栈大小为1024字节
.equ STACK_SIZE, 1024
# 声明符号 _start 为全局符号。它是程序的入口点。
.global _start
# 指定以下代码属于 .text 段,其中包含可执行指令。它标志着 _start 代码的开始。
.text
_start:
# park harts with id != 0
csrr t0, mhartid # 读取当前硬件线程的ID
mv tp, t0 # 将CPU的硬件线程ID保存在tp寄存器中以备后用
bnez t0, park # 如果不是硬件线程0,则进入休眠状态
slli t0, t0, 10 # 将硬件线程ID左移10位(相当于乘以1024)
la sp, stacks + STACK_SIZE # 将初始栈指针设置为第一个栈空间的末尾
add sp, sp, t0 # 将当前硬件线程的栈指针移动到栈空间中的相应位置
j start_kernel # hart 0 jump to c
park:
wfi
j park
stacks:
.skip STACK_SIZE * MAXNUM_CPU # 为所有硬件线程分配栈空间
.end # 文件结束
这里我们主要关注,如何做到为每个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 # 将当前硬件线程 ID 左移 10 位(相当于乘以 1024)
la sp, stacks + STACK_SIZE # 将栈指针设置为第一个栈空间的末尾
add sp, sp, t0 # 将当前硬件线程的栈指针移动到其在栈空间中的位置
这段代码通过使用每个硬件线程的唯一硬件线程ID来隔离每个hart的栈空间。每个硬件线程的ID不同,因此通过将硬件线程ID左移10位(相当于乘以1024),可以为每个硬件线程分配独立的栈空间。
- 首先,通过读取当前硬件线程的ID并将其存储在寄存器
t0
中,使用csrr
指令。 - 然后,使用
la
指令将栈指针sp
设置为stacks
标签加上STACK_SIZE
,即第一个栈空间的末尾地址。这样可以将栈指针设置为栈空间的最末尾。 - 最后,使用
add
指令将栈指针sp
与硬件线程ID左移10位的结果相加,以将当前硬件线程的栈指针移动到其在栈空间中的位置。由于每个硬件线程的ID不同,因此每个硬件线程的栈指针会被正确地定位到其分配的独立栈空间的位置。 - 这样,每个硬件线程就具有了自己独立的栈空间,实现了栈空间的隔离。
例如:
-
当有多个 hart(核心)运行时,每个 hart 都需要有自己独立的栈空间,以避免互相干扰和冲突。
-
假设有两个 harts,hart0 和 hart1,每个 hart 的栈空间大小为 1024 字节(STACK_SIZE)。
-
首先,我们将每个 hart 的 ID 左移 10 位(slli t0, t0, 10),相当于将 hart0 的 ID 乘以 1024。然后,我们将 stacks 的起始地址(栈空间的起始地址)与这个偏移量相加,即 la sp, stacks + STACK_SIZE。
-
假设 stacks 的起始地址为 0x1000。对于 hart0,它的 ID 是 0,因此 t0 的值为 0,偏移量为 0。所以 la sp, stacks + STACK_SIZE 将 sp 设置为 0x1000 + 1024 = 0x1400, add sp, sp, t0 —> 0x1400 + 0,即 hart0 的栈空间的起始地址。
-
对于 hart1,它的 ID 是 1,因此 t0 的值为 1,偏移量为 1024。所以 la sp, stacks + STACK_SIZE 将 sp 设置为 0x1000 + 1024 = 0x1400, add sp, sp, t0 --> 0x1400 + 1024 = 0x1800,即 hart1 的栈空间的起始地址。
-
通过这样的操作,我们将不同的 harts 的栈空间隔离开来,每个 hart 都有自己独立的栈空间,互不干扰。当各个 harts 运行时,它们可以在各自的栈空间上进行栈操作,而不会相互冲突。
如何在屏幕输出Hello World
通过串口输出
UART代表通用异步收发器(Universal Asynchronous Receiver-Transmitter)
。它是一种常用的串行通信协议,用于两个设备之间的通信。UART协议允许一次只传输和接收一位数据,通过单个数据线进行通信。
UART被广泛应用于各种应用中,包括嵌入式系统、微控制器以及计算机、调制解调器和传感器等不同设备之间的通信接口。它提供了一种简单高效的方法,用于设备之间的数据传输和接收。
UART通信包括起始位,随后是数据位(通常为8位),用于错误检测的可选奇偶校验位,以及停止位或多个停止位。起始位表示数据帧的开始,而停止位表示帧的结束。数据以异步方式传输,意味着设备之间没有共享时钟信号。
UART在点对点配置中运行,其中两个设备直接连接使用两条数据线:一条用于发送数据(TX),一条用于接收数据(RX)。一个设备的TX线连接到另一个设备的RX线,反之亦然。这允许设备之间的双向通信。
UART特点
UART的物理接口
UART物理接口通常由以下几个引脚组成:
-
TX (Transmit): 该引脚用于发送数据。数据从UART发送器输出到这个引脚,经过串行传输发送到接收设备。
-
RX (Receive): 该引脚用于接收数据。接收设备通过该引脚接收从发送设备发送的数据。
-
GND (Ground): 地线引脚,用于提供电路的地连接。
除了上述必要的引脚外,UART接口可能还包括其他可选引脚,如:
-
RTS (Request to Send): 请求发送引脚,用于控制数据发送。发送设备通过该引脚向接收设备发出发送请求。
-
CTS (Clear to Send): 清除发送引脚,用于确认接收设备准备好接收数据。接收设备通过该引脚向发送设备发送准备好接收的信号。
-
DTR (Data Terminal Ready): 数据终端就绪引脚,用于指示设备准备好进行数据通信。
-
DSR (Data Set Ready): 数据集就绪引脚,用于指示接收设备准备好接收数据。
-
RI (Ring Indicator): 响铃指示引脚,用于指示电话线路上是否有电话呼叫信号。
这些引脚的具体命名和功能可能在不同的设备和应用中有所不同,但上述列举的是UART接口常见的引脚。
UART的通信协议
NS16550a 编程接口介绍
QUME是一个知名的串口模拟器和仿真工具,它可以模拟各种串口设备,包括NS16550A芯片所提供的功能。因此,通过QUME,可以模拟NS16550A串口芯片的行为和接口。
使用QUME,可以创建虚拟串口设备,并通过配置参数来模拟NS16550A芯片的寄存器、数据传输、中断和状态等功能。因此我们能够进行串口通信的仿真和测试,而无需实际的硬件设备。
QUME提供了丰富的功能,包括模拟不同的串口参数、配置波特率、数据位数、校验位、停止位等,并且可以模拟接收和发送数据,监测串口状态和中断等。这样可以在虚拟环境中进行串口编程和调试,以确保代码在实际环境中正常工作。
需要注意的是,QUME是一个软件工具,它提供了对串口功能的模拟和仿真,但并不直接与硬件设备通信。因此,在实际使用中,QUME可以作为开发、测试和调试串口通信应用程序的有用工具,但在实际的硬件系统中,需要使用NS16550A芯片或其他串口硬件来实现真正的串口通信。
NS16550A是一种常用的串口通信芯片,它提供了一个编程接口,用于配置和控制串口通信功能。以下是NS16550A芯片的编程接口的基本介绍:
-
数据寄存器 (Data Register):用于读取和写入串口数据。通过读取数据寄存器,可以获取接收到的数据;通过写入数据寄存器,可以发送数据。
-
状态寄存器 (Status Register):用于获取串口的状态信息。可以通过读取状态寄存器来了解串口的接收和发送状态,包括是否有接收到数据、是否可以发送数据等。
-
控制寄存器 (Control Register):用于配置和控制串口的各种参数和功能。通过写入控制寄存器,可以设置波特率、数据位数、校验位、停止位等串口参数,以及启用或禁用接收和发送功能。
-
波特率发生器 (Baud Rate Generator):用于设置串口的波特率。通过设置波特率发生器的值,可以定义串口通信的传输速率。
-
中断控制寄存器 (Interrupt Control Register):用于配置和控制串口的中断功能。通过写入中断控制寄存器,可以启用或禁用不同类型的中断,如接收中断、发送中断等。
通过访问这些寄存器,可以对NS16550A芯片进行编程控制,实现对串口通信的配置、数据传输和状态监测等操作。具体的编程接口使用方式和寄存器地址等信息可以参考NS16550A芯片的数据手册或相关文档。
NS16550a 的初始化
- 关闭中断
- 设置波特率
- 设置异步数据通信格式
在这里,"关闭中断"指的是禁用串口(UART)的中断功能,即禁止串口触发和处理中断事件。
串口通信中的中断通常用于以下目的:
- 接收中断:当串口接收到数据时,会触发接收中断,通知处理器有新的数据可供处理。
- 发送中断:当串口发送缓冲区为空时,会触发发送中断,通知处理器可以继续发送新的数据。
通过禁用中断,就是告诉串口不要触发和处理这些中断事件。这样可以避免在初始化期间由于中断的发生而引起的干扰和错误。
禁用中断不会影响串口的数据传输功能,它仅仅是关闭了中断的触发和处理机制。一旦初始化完成,并且需要启用中断来处理接收和发送数据的中断事件时,可以通过适当的设置和配置重新启用中断。
完整代码注释如下: (可参考NS16550a相关文档进行学习)
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));
}
相关宏定义:
//读写uart寄存器的相关宏定义
#define uart_read_reg(reg) (*(UART_REG(reg)))
#define uart_write_reg(reg, v) (*(UART_REG(reg)) = (v))
/*
* The UART control registers are memory-mapped at address UART0.
* This macro returns the address of one of the registers.
*/
#define UART_REG(reg) ((volatile uint8_t *)(UART0 + reg))
/* This machine puts UART registers here in physical memory. */
#define UART0 0x10000000L
NS16550a 的数据读写
- TSR寄存器(Transmit Status Register)是一个用于发送状态信息的寄存器,通常在串口通信芯片中存在。
- LSR寄存器(Line Status Register)是一个用于线路状态信息的寄存器,通常在串口通信芯片中存在。
这里采用轮询的方式实现数据的发送—>putc:
int uart_putc(char ch){
//不断读取LSR寄存器,获取其第五位,判断是否为0,为0表示空闲
while ((uart_read_reg(LSR) & LSR_TX_IDLE) == 0);
//如果空闲就向THR寄存器中写入数据
return uart_write_reg(THR, ch);
}
启动函数:
extern void uart_init(void);
extern void uart_puts(char *s);
void start_kernel(void){
//初始化串口设备
uart_init();
//输出字符
uart_puts("Hello, RVOS!\n");
while (1) {}; // stop here!
}
编译运行:
注意如何退出qemu: