ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

手写RISC-V SBI与实验内核:从M模式到S模式的启动实践

手写RISC-V SBI与实验内核:从M模式到S模式的启动实践 简介面向RISC-V架构学习者的MySBI与BenOS实验代码源自《RISC-V体系结构编程与实践》第二章适合需要深入理解系统级编程、操作系统内核设计或希望在模拟器上实践RISC-V启动流程的开发者与研究人员。压缩包共17个文件以C源文件、头文件、汇编文件为主搭配链接脚本、Makefile、sbi_payload及riscv64-benos_defconfig配置文件能够支撑BenOS与MySBI的定制构建和编译验证整体仅7KB轻量精简便于快速部署。目前已有304人学习使用。通过这份代码读者可以系统梳理MySBI的启动加载、系统调用与中断处理逻辑深入分析BenOS的任务调度、内存管理和设备驱动模型结合源码阅读和实际编译调试在RISC-V模拟器或硬件平台上运行从而串联起从架构原理到内核实现的完整知识链路对嵌入式系统和实时操作系统方向的工程师具有很高的实践参考价值。 我最近把一年前写的MySBI与BenOS实验代码重新整理了一遍。这套东西没什么产品逻辑就是一套跑在RISC-V上的最小SBI实现外加一个跑在S模式下的实验内核BenOS。SBI全称Supervisor Binary Interface解决的是S模式内核和M模式底层固件之间的接口问题而BenOS正是通过MySBI提供的接口完成控制台输出、定时器设置这些基础操作。如果你好奇一个操作系统从CPU上电到打印出第一行字符的全部过程或者想亲手写一个能跑在真实硬件/QEMU上的最小内核这套代码是很合适的参考样本。很多人一上来就追复杂内核、追Linux内核源码反倒把最基础的启动链路跳过去了。我当初做MySBI的目的很简单不用OpenSBI自己用汇编和C写一个够用的SBI实现再把BenOS拉起来跑完整走一遍M模式到S模式的启动链路。这篇文章会把整个项目的设计思路、核心模块、环境搭建、调试过程完整拆开讲代码里一些关键步骤我会直接贴出来方便对照复现。1. 项目整体设计与思路拆解1.1 MySBI与BenOS的定位RISC-V在标准设计里有三个特权级U模式用户态、S模式内核态、M模式机器态。M模式权限最高能访问所有CSR和物理内存S模式则是普通操作系统内核所在的位置。问题是绝大部分硬件细节比如定时器、中断控制器、电源管理都挂在M模式控制的内存映射寄存器上S模式不能直接碰。这个时候就需要SBI在中间做一层翻译把S模式的请求通过ecall指令送到M模式处理。MySBI就是这层翻译的实现。它负责的事情很集中提供SBI调用入口ecall处理逻辑实现控制台输出、字符输入提供定时器设置供S模式调度使用完成M模式到S模式的启动跳转BenOS则是用SBI服务的“客户”。它不直接操作串口寄存器也不直接配置定时器只通过标准SBI调用号请求MySBI帮忙。两者放一起看就是一条完整的RISC-V启动与运行链路。1.2 为什么不直接用OpenSBIOpenSBI是RISC-V社区的标准SBI实现功能完善支持各种平台。但它的代码量相当大涉及平台初始化、多核启动、IPI、PMU等一堆机制初学者很容易被细节淹没。我当时的诉求是“最小可用”保证实验内核能启动、能输出、能设置定时器就够了。自己写SBI的最大收益是彻底理解ecall的完整路径S模式执行ecall后CPU如何跳转到M模式预设的stvec/mtvec入口如何根据a7寄存器分派调用如何通过mret回到S模式继续执行。这个过程在OpenSBI里是被封装好的直接看源码反而容易断片。另外实验代码可以任意精简。MySBI在去掉所有平台无关的扩展功能后核心代码量也就几百行。对于只想理解机制的人几百行比几千行友好得多。如果以后要跑Linux等真实系统再替换成OpenSBI完全无缝因为接口是标准化的BenOS和Linux调SBI的方式是一致的。2. 实验环境搭建与启动流程2.1 QEMU与交叉编译工具链准备这次实验我选择了QEMU的virt平台而不是真实开发板。原因很简单方便调试、可重复、不烧硬件。QEMU模拟的RISC-V virt机器提供了一块标准16550兼容UART内存默认128MB上电时CPU从0x80000000开始取指。这些信息在写链接脚本和SBI初始化代码时都用得上。交叉编译工具链直接用发行版自带的riscv64-linux-gnu-系列sudo apt install gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu gdb-multiarch如果用的是Fedora系包名会有些不同但组件是一样的。QEMU需要带RISC-V支持sudo apt install qemu-system-misc验证环境是否就绪riscv64-linux-gnu-gcc --version qemu-system-riscv64 --version2.2 目录结构与构建流程项目代码我按模块拆成两个目录mysbi/和benos/。mysbi负责M模式启动、SBI实现benos负责S模式实验内核。两个部分各有独立的Makefile最终由根目录Makefile串联。以MySBI为例构建命令很简单cd mysbi make生成的目标文件是mysbi.elf。内核入口地址设置在0x80000000QEMU启动时直接通过-kernel参数加载这个ELF。启动命令qemu-system-riscv64 \ -machine virt \ -m 128M \ -nographic \ -bios none \ -kernel mysbi.elf-bios none很关键意思是不要加载任何固件直接把mysbi.elf作为第一个执行程序。这样CPU从0x80000000取指时执行的就是MySBI的入口——_start。3. 核心模块实现解析3.1 从汇编入口到M模式初始化MySBI的入口用汇编编写因为C语言运行环境还没有建立栈指针sp、全局指针gp都还是未初始化状态。入口做三件事设置栈指针清零BSS段跳转到C代码入口代码大致长这样.section .text.init .globl _start _start: csrr a0, mhartid la sp, boot_stack_top la a0, trap_vector csrw mtvec, a0 call sbi_main这里有一个初学者容易忽略的点mtvec寄存器必须尽早设置。只要开了中断任何异常都会跳到mtvec指向的地址如果这时候该地址是空的CPU直接跑飞。链接脚本里把入口地址固定到0x80000000OUTPUT_ARCH(riscv) ENTRY(_start) SECTIONS { . 0x80000000; .text : { *(.text.init) *(.text) } .rodata : { *(.rodata) } .data : { *(.data) } .bss : { *(.bss) } }注意QEMU的virt平台还有一些MMIO区域比如UART地址在0x10000000CLINT定时器和软件中断控制器在0x02000000。这些地址是平台相关的写SBI时要保持和平台一致换硬件就需要改。3.2 进入S模式mret跳转与特权级切换MySBI初始化完成后要做的核心操作就是降级到S模式并跳转BenOS。如果CPU一直在M模式BenOS就没法真正运行在S模式后续SBI调用链路就验证不了。从M模式进入S模式的标准流程是设置几个CSR然后执行mret#define MSTATUS_MPP (1L 11) // 将mstatus.MPP设为01表示返回S模式 static void enter_s_mode(void (*fn)(void)) { unsigned long mstatus; asm volatile(csrr %0, mstatus : r(mstatus)); mstatus | MSTATUS_MPP; asm volatile(csrw mstatus, %0 ::r(mstatus)); asm volatile(csrw mepc, %0 ::r(fn)); asm volatile(mret); }mepc存放下一条要执行的指令地址mret会把当前特权级切到mstatus.MPP指定的级别然后跳到mepc。这个机制可以比喻成“M模式把接力棒交给S模式”交接点是mepc交接后的状态由mstatus控制。如果跳转后发现BenOS完全没有执行先检查MPP位是否设置正确。MPP是两位字段位置在mstatus[12:11]值01对应S模式。把它漏掉CPU会默认回到U模式然后访问S模式资源直接报异常。3.3 ecall入口与SBI调用约定BenOS运行在S模式后它请求服务的唯一入口就是ecall指令。SBI规范定义了调用约定a7存放SBI调用号a6通常存放扩展ID早期legacy调用没有用a0-a5存放参数返回时a0存放错误码或返回值MySBI的异常处理入口统一放在trap_vector。mtvec有两种模式Direct和Vectored。最简单的是Direct模式所有异常都跳到同一个地址再由软件判断原因。我们在入口处保存现场然后判断是中断还是ecallvoid trap_handler(void) { unsigned long mcause csr_read(mcause); unsigned long mepc csr_read(mepc); if ((mcause 0x8000000000000000UL) 0) { // 异常而不是中断 unsigned long code mcause 0xFFF; if (code 9) { // ECALL from S-mode handle_sbi_call(); csr_write(mepc, mepc 4); return; } } // 其他异常先打印信息再死循环 while (1); }注意mepc指向的是ecall指令本身如果不加4直接mret就会无限循环执行同一条ecall。这个细节可以说是新手最容易踩的坑之一。3.4 SBI功能实现控制台输出与定时器控制台输出是最简单也最直接的SBI功能。virt平台用的是16550 UART位于0x10000000只需要往发送保持寄存器THR偏移0写一个字节即可void uart_putchar(char c) { volatile unsigned char *uart (volatile unsigned char *)0x10000000; *uart c; }S模式内核调用方式long sbi_console_putchar(char ch) { return sbi_call(1, ch, 0, 0); }这里的调用号1是SBI规范里legacy console putchar的标准编号。配套的getchar调用号是2从UART接收寄存器RBR偏移0读入字节。定时器部分稍微复杂。RISC-V的定时器是CLINT提供的mtime和mtimecmp它们都是MMIO寄存器mtime实时计数器以固定的硬件频率递增mtimecmp比较寄存器当mtime mtimecmp时触发机器定时器中断MySBI实现set_timer本质就是往mtimecmp写目标值void sbi_set_timer(unsigned long stime_value) { volatile unsigned long *mtimecmp (volatile unsigned long *)0x2004000; *mtimecmp stime_value; }BenOS通过ecall调用后MySBI在M模式设置好比较值下次mtime递增到这个值时CPU会产生定时器中断。这套机制是后来所有调度器的基础。4. 调试方法与踩坑记录4.1 用QEMUGDB做最小内核调试内核开发没有调试器基本寸步难行。QEMU提供了内置的GDB Server支持启动时加两个参数qemu-system-riscv64 \ -machine virt -m 128M -nographic \ -bios none -kernel mysbi.elf \ -s -S-s让QEMU在1234端口开GDB调试服务-S表示启动后立刻暂停CPU等待调试器连接。另开终端gdb-multiarch mysbi.elf (gdb) target remote :1234 (gdb) break _start (gdb) continue我调试时最喜欢用的命令是info registers和x/x(gdb) info registers mstatus (gdb) x/10i $pc前者看CSR状态后者看当前地址的汇编指令。配合layout asm还能实时观察指令流非常直观。4.2 常见问题速查表我把这次实验过程中实际遇到的典型问题整理成一个速查表每个问题都附了排查思路和解决方式现象可能原因排查与解决启动后没有任何输出UART地址不对或-bios none缺失确认virt平台UART地址是0x10000000检查启动命令是否把BIOS加载覆盖掉了BenOS完全没有运行没有正确设置mstatus.MPP单步检查mret前的mstatus值确认MPP位为01ecall后重复进异常mepc没有加4mepc指向当前ecallmret后会再次执行该指令异常处理里必须mepc4打印第一行字符后卡死串口忙等待逻辑不对检查16550的LSR寄存器偏移5bit5确认发送FIFO可写再写THR定时器中断不触发mtimecmp地址写错virt平台CLINT在0x02000000mtimecmp偏移是0x4000检查两个地址是否匹配调试时csrr命令报错GDB不认识RISC-V的CSR名字确认使用的是gdb-multiarch旧版本用p/x $mstatus替代还有一个容易忽略的问题BSS段没清零。如果uart_putchar里用到缓冲区而BSS区是随机值输出内容会不稳定。MySBI在汇编入口阶段就执行了BSS清零la t0, __bss_start la t1, __bss_end 1: bgeu t0, t1, 2f sd zero, 0(t0) addi t0, t0, 8 j 1b 2:链接脚本里需要导出__bss_start和__bss_end.bss : { __bss_start .; *(.bss) . ALIGN(16); __bss_end .; }这些坑单看文档很难发现只有真正动手做过一遍才会印象深刻。我当时调试BenOS时钟中断时就因为在mtimecmp地址上少写了一位导致中断一直不触发整整排查了一个晚上。后来用GDB读mtime和mtimecmp的原始值对比才发现目标值根本没写进去。最后再分享一个小技巧如果想让实验代码更接近真实场景可以在MySBI里加入少量扩展SBI调用比如休眠延时、获取启动参数等。这些扩展不影响标准调用但能帮你理解SBI规范里“legacy调用”和“扩展调用”并存的设计逻辑。整套实验代码的价值不在于多复杂而在于把一条完整的启动链路走通之后再看OpenSBI或者别的SBI实现心里就有一张完整的图了。本文还有配套的精品资源点击获取
返回列表