ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RISC-V自研核移植RT-Thread完整指南:从BSP到调度器

RISC-V自研核移植RT-Thread完整指南:从BSP到调度器 在 ysyx一生一芯学习进入 SoC 阶段之后最大的瓶颈往往不是把核设计出来而是怎么证明你的核真的能“运行软件”。跑一个 hello world 只是热身真正有价值的是让一个像样的操作系统在上面转起来。这时候 rt-thread 就成了很合适的选择它比 xv6 完整比 Linux 轻量而且最妙的是它的 BSP板级支持包结构非常适合学习者根据自己写的 RISC-V 核去移植。这篇文章就把我在 ysyx 学习过程中把 rt-thread 移植到自研 RISC-V 核上的完整过程写出来从决策思路到实际代码从踩坑到验证都摊开讲。先说明我的目标平台ysyx 阶段设计的 SoCCPU 是一个具备 M 态和 S 态的 RISC-V 核外设包括 UART、定时器、SPI Flash 和简单的 GPIO内存就是一个 SDRAM物理地址从 0x80000000 开始。这门功课做到后面你会发现移植 rt-thread 根本不是“把代码拷过来改几个宏”的事而是把你的硬件抽象成操作系统能听懂的语言。整个过程踩过的坑不少但跑通那一刻的成就感值得。1. 移植前的关键决策1.1 先认清自己的硬件能力边界在动手之前我花了一晚上把 SoC 的地址映射整理成一张表这一步千万别跳过。rt-thread 的移植不要求你理解所有外设但你必须知道 CPU 启动后从哪里取第一条指令、外设的 MMIO 地址在哪、中断控制器怎么触发、内存有多大。ysyx 里常见的映射大概是这样的外设/内存基地址示例说明SPI Flash0x20000000启动时从 Flash 加载代码或直接映射运行SDRAM0x80000000主内存rt-thread 的代码、栈、堆全在这里UART0x40000000 附近串口控制台移植期最重要的调试工具Timer0x40001000 附近提供 mtime 和 mtimecmp给系统节拍用我当时用了一个很土但有效的方法先写一个裸机程序分别往这些地址写测试值再用 UART 打印出来确保地址没有偏差。寄存器地址要是错了后面所有调试都会变成灾难。另外还要确认你的核是否支持 RISC-V 标准中断控制器PLIC/CLINT还是自己实现了简单的中断逻辑。rt-thread 在 RISC-V 上主要依赖机器定时器中断和外部中断不同实现方式会直接影响 libcpu 里中断入口的写法。1.2 选对起始 BSP比从零开始快十倍rt-thread 的源码结构是 kernel / libcpu / bsp 三层。libcpu 是 CPU 架构相关的部分bsp 是具体板子的部分。我不建议一上来就想着“我自己写一个全新 BSP”更稳的做法是找一个结构接近的官方 BSP 改。我最终选了bsp/qemu-riscv-virt作为参考核心原因是它和我的目标环境最像RISC-V、MMIO 外设、直接跑在物理内存上没有复杂的设备树解析。树莓派 3B 的 BSP 也可以参考但它有 GPU 参与启动容易带偏思维。hifive1 的 BSP 也不错但它是公司设计的成熟 SoC中断和时钟的初始化和我们自研核差异较大。参考 BSP 主要拿三样东西链接脚本的大致骨架、context_gcc.S的上下文切换汇编、board.c里初始化的调用流程。我一开始犯过一个错误想全部自己手写结果连.bss段清零这种基础操作都能写错。后来认怂先复制参考 BSP再一行一行改成自己的 SoC 版本效率高得多。1.3 理解 rt-thread 的启动与调度模型移植前你得先把 rt-thread 的启动流程在脑子里走一遍。它不是一个独立于硬件的黑盒它的第一步就是执行你提供的启动代码_start汇编 - 设置栈指针 - 清零 .bss - 调用 rt_hw_board_init - 创建 main 线程 - 调用 rtthread_startup - 启动调度器这里面有两条线一条是 C 语言世界的初始化一条是汇编层面的线程栈切换。两者交汇点就在于rt_hw_board_init这个函数通常做时钟、串口和内存堆的初始化是 BSP 里最核心的 C 入口。调度的原理其实就是一个“换栈游戏”每个线程有自己的栈栈里保存着该线程的寄存器现场调度器做的事情就是保存当前线程的现场、恢复下一个线程的现场、然后ret回到它被打断的位置继续执行。理解了这个模型后面读context_gcc.S心里就有底了。2. 移植 rt-thread 的核心流程拆解2.1 搭建工程骨架复制再修改我的工程目录是在 ysyx 项目的software目录下新建的直接放一份 rt-thread 源码然后在 bsp 下建自己的板级目录。建议不要用 rt-thread studio 自动生成工程那个工具在 Windows 下很方便但 ysyx 的调试环境通常在 Linux 命令行下工作Makefile GCC toolchain 更加透明可控。关键的 Makefile 变量有三块C_SRCS包含所有需要编译的.c文件ASM_SRCS包含汇编文件CFLAGS里需要指定-marchrv64gc -mcmodelmedany。medany这个选项很重要它让代码不要假设自己一定运行在链接地址附近因为你还可能从 Flash 搬到 SDRAM 去跑地址的 pc 相对浮动问题就靠它兜底。我用的工具链是 ysyx 环境自带的 riscv64 交叉编译工具链。链接脚本的入口设置为_start并把_start放在.text段开头。2.2 链接脚本与内存布局搞错必崩链接脚本是移植早期最容易埋雷的地方。我的经验是把内存划分成三块段位置说明.text0x80000000 起代码段包括启动汇编和设备初始化.rodata / .data紧接 .text只读数据 已初始化数据.bss数据段之后未初始化数据启动时清零heap.bss 之后到栈底rt-thread 的动态内存池这里要特别留意__rt_assert之类的调试宏会使用.rodata.str1.1存放字符串如果你的链接脚本没有把只读数据段放进去程序一旦调用 assert 就会跳到空地址。另一个坑是栈的方向RISC-V 栈是向下增长的所以栈顶地址应该是最大地址但栈顶符号名一般写为__stack_top。我第一次写的时候把栈底和栈顶搞反了结果一进中断就疯狂覆盖全局变量。还有一个细节是_heap_start和_heap_end这两个符号rt-thread 的堆管理器要靠它们划定动态内存范围。我直接用链接脚本里__bss_end__作为堆起点至于堆的上限由 SDRAM 大小减去栈大小决定拿计算器按一下就算出来了。2.3 libcpu 移植上下文切换与中断入口libcpu 是整个移植的硬骨头核心文件是libcpu/riscv/context_gcc.S。考虑到我是在 rv64 平台上移植直接参考 rt-thread 自带的 rv64 汇编实现。上下文切换分为两种场景。第一种是rt_hw_context_switch_to首次切换到新线程时使用此时没有“上一个线程”要保存只需要从目标线程的栈上恢复寄存器。第二种是rt_hw_context_switch用于两个线程间的切换需要先将当前线程的所有 callee-saved 寄存器压栈然后切换sp再从目标线程栈顶恢复。寄存器保存范围是有讲究的。RISC-V 调用规范规定callee-saved 寄存器是 s0-s11 和 sp但线程切换相当于每个线程都是一个独立的“调用者”需要保存和恢复全部寄存器。ra是必须保存的因为线程恢复后要通过ret跳回原来的执行位置实际上 rt-thread 在创建线程栈时会把ra指向线程的入口函数。我补一段大家容易忽略的代码理解rt-thread 创建一个线程时构造出的初始栈里 ra 被设为一个叫做rt_thread_entry的入口函数指针。也就是说第一次调度到该线程时汇编代码执行ret后会直接跳进那个线程的入口而不会经过常规函数调用。理解这一点看汇编时就不会被绕晕。中断入口也要改。RISC-V 里中断入口需要从mtvec寄存器指向的地址开始汇编要做的是先关中断、保存上下文、将栈指针切换到被中断线程的内核栈或当前栈上然后调用rt_interrupt_enter、执行中断服务程序、最后rt_interrupt_leave并恢复上下文。我一开始忘了保存t0-t6这套临时寄存器结果中断返回后主程序的数据被改得面目全非调试了两天才反应过来。2.4 rtconfig.h 裁剪别让调度器比你外设还复杂rtconfig.h 是 rt-thread 的“总开关”。对于早期移植我的建议是能关的功能全关掉。组件层只开内核必需项依赖裁剪到极致#define RT_THREAD_PRIORITY_MAX 8 #define RT_TICK_PER_SECOND 1000 #define RT_USING_HEAP #define RT_USING_CONSOLE #define RT_CONSOLE_DEVICE_NAME uart0 #define RT_USING_SMP 0RT_TICK_PER_SECOND我设为 1000也就是 1ms 一个系统节拍。这个值要结合你的 UART 波特率和中断处理时间一起考虑太密了 CPU 全在调时钟中断太疏了时间敏感任务又不准。在 ysyx 的核上 1000Hz 是很均衡的选择。堆大小这块我直接在board.c里用rt_system_heap_init(heap_start, heap_end)初始化动态内存。ysyx 阶段的 SDRAM 一般有 16MB 到 256MB我划了 4MB 给 rt-thread 堆足够跑多个线程和后续的组件测试。3. 板级驱动的实现要点3.1 串口控制台先让“嘴”张开移植期最不能缺的驱动是 UART。rt-thread 的控制台通过三个接口和底层交互rt_hw_console_init、rt_hw_console_putchar、rt_hw_console_getchar。在早期阶段我强烈建议先把 putchar 和 getchar 做成纯轮询模式不要一上来就搞中断收数据。void rt_hw_console_putchar(char c) { while ((uart_read(reg_state) UART_TX_FULL)); uart_write(reg_data, c); } char rt_hw_console_getchar(void) { if (uart_read(reg_state) UART_RX_EMPTY) return -1; return uart_read(reg_data) 0xff; }轮询模式的好处是逻辑简单第一版先跑通。但这里有个用户必须知道的前提rt-thread 的内核日志打印频繁轮询发送时容易卡住因为它要等 FIFO 全空才写入下一个字符。如果你的 UART 波特率从 115200 起步没问题但如果某个外设驱动打印量大轮询模式的瓶颈会很明显。这时候就得上 FIFO 半空判断而不是全空判断或者直接切中断模式。3.2 系统时钟与节拍调度的心脏RISC-V 的机器定时器一般由 CLINT 提供核心是mtime实时计数器和mtimecmp比较器中断条件就是 mtime 到达 mtimecmp 设定的数值。移植时的关键参数是时钟频率不同 SoC 的 mtime 频率差异很大常见的有 10MHz、50MHz甚至和 CPU 主频一致。节拍的实现逻辑是void rt_hw_timer_init(void) { mtime 0; timer_compare mtime CLOCK_RATE / RT_TICK_PER_SECOND; set_mtvec_handler(timer_isr); enable_mtimer_interrupt(); }每次进入定时器中断在中断服务函数里更新 mtimecmp再调用rt_tick_increase()。这里最容易出现的 bug 是 mtimecmp 更新不及时导致中断风暴——CPU 忙到只处理中断主代码完全执行不了。我的应对方式是在中断服务函数最开始就清掉中断条件并预置下一个节拍点然后在中断返回前才调用rt_tick_increase。3.3 串口数据溢出到底是谁的锅网上搜“rt-thread 串口 溢出”能翻出一堆求助帖子。我的亲身经历是这样的菊花串接收字符串时如果 CPU 正在跑一个耗时较长的临界区操作UART 的 FIFO很多 SoC 只有 16 字节被填满后续数据直接被硬件丢弃。轮询模式下这个问题无解因为你根本没有“及时读取”的时机。解决思路是中断 环形缓冲。串口接收中断触发后先把数据从硬件 FIFO 读出写入软件 ringbuffer等到应用层调read时再从 ringbuffer 里取。rt-thread 有现成的rt_ringbuffer组件直接把串口接收移植成中断回调即可。移植早期我不建议引入这个复杂度但你要知道有这么一步串口从“能说话”到“能稳定接收大量数据”中间差的是一个不可丢数据的 FIFO。我自己踩过的具体场景是在跑文件系统测试时某些目录遍历操作会卡住几十毫秒此时串口打印和输入都挤在一起丢失的字符直接导致 shell 命令解析失败。后来我把 console 的 getchar 改成了中断 ringbuffer 实现问题才彻底解决。3.4 中断使能别让全局开关毒死你的调度器RISC-V 里有一个 mstatus 的全局中断开关位。它是一把双刃剑你可以在临界区里用disable_irq来阻止中断插入但如果 rt-thread 的调度器运行中不小心关闭了全局中断核就会变成“聋子”调度节拍停掉所有线程死锁。我建议对中断的标志位操作做一个统一封装并且只在极短临界区使用。rt-thread 内部已经把关键临界区包在rt_hw_interrupt_disable/enable里了你的 BSP 只需要正确实现这两个函数。错误示范是把定时器初始化代码里的disable_irq一直开到线程切换恢复现场为止那样后果就是整个系统一按下启动键就趴在第一个 tick 上。4. 调试过程与常见问题实录4.1 启动即崩溃先查链接脚本移植初期最常见的现象是代码一启动就跑到 0x0或者直接跳飞。这时候 90% 是链接脚本的问题。我排查顺序是看_start是否真的被放在了内存首地址用riscv64-unknown-elf-readelf -h和-l检查入口地址。确认 LMA加载地址和 VMA运行地址。你的代码可能从 Flash 加载但运行在 SDRAM如果启动代码没有做搬移那就只会看到乱流。检查.bss段长度是否为 0如果长度异常__bss_start和__bss_end符号可能指向了错误位置。我自己的核启动时固化在 SDRAM 里所以不需要搬移。但我也在 Flash 启动的变体上栽过链接脚本 VMA 写成了 SDRAMLMA 却留在 Flash结果加载器把代码写到 SDRAM跳转时又跑去 Flash 执行Nat 复现过程非常曲折。对所有从 Flash 启动的工程建议在启动汇编里先完成 Flash 到 SDRAM 的搬运再开 C 世界。4.2 能打印但调度就跑飞多半是栈问题“能打 hello但一创建线程就死”是我见过最高频的反馈。这时候大概率是线程栈初始化出了问题。rt-thread 要求线程栈按 16 字节对齐RISC-V 调用规范要求 sp 指针在函数入口处保持 16 字节对齐我们构造初始栈时就得保证这一点。另一个隐蔽的坑是栈空间太小。我用了一个默认 2048 字节的线程栈然后在里面调用rt_kprintf和snprintf结果直接压爆了栈。排查方法是用list_thread命令查看线程栈使用峰值把栈大小调到 4096 甚至 8192。别心疼内存在 SDRAM 充足的情况下栈给大一点能省下大量调试时间。4.3 中断正常但线程切换死循环可能是 mcause 没清你在中断服务函数里读mcause判断中断来源但忘了在退出中断前清中断条件。对于 CLINT 定时器清中断的方式就是更新 mtimecmp对于外部 UART 中断有些 SoC 需要读 FIFO 来清状态。如果中断标志没有清除处理器退出中断后立刻再次触发同一个中断表现就是系统“卡死”或者某线程一直被打断别的线程永远得不到运行。这类问题用 GDB 单步跟很容易看出来每次重复进入同一个mtvec入口mcause值不变。我习惯在中断入口汇编里第一件事就把mcause存到内存变量中方便 GDB 检查。4.4 用波形和 GDB 双双定位往往比瞎猜快ysyx 里有两大调试神器仿真波形和 GDB。跑 Verilator 仿真时把mtime、mtvec、UART 的收发状态拉到波形里能直观看到中断风暴。上 FPGA 时OpenOCD GDB 是最舒服的组合可以直接打断点看pc和栈回溯。我的实际经验是先用 GDB 确认“到底死在哪条指令”再用波形确认“为什么那条指令能被执行到这里”。两层合作稳定高效。比如地址映射错了GDB 会告诉你 PC 落到了 0x00000000而中断风暴波形里 mtimecmp 更新的时间线清清楚楚。4.5 调试真随机把 UART 波特率算对有次移植后终端输出是乱码我的第一反应是波特率配置错了。检查 UART 时钟是否和外设频率一致即可。ysyx 的 SoC 中UART 的时钟有时是固定值有时是 CPU 主频分频而来如果你的 CPU 频率提高了一倍但 UART 分频配置没变输出当然是雪花。这一类问题用示波器看 TX 引脚电平宽度可以获得直接证据但开发板上没有示波器时用逻辑分析仪也能解决。5. 运行验证与功能扩展5.1 最小验证两个线程的乒乓测试移植完成后的第一件事不是跑满整个测试套件而是写一个最简单但能证明调度器工作的程序创建两个线程一个负责打印 “A”一个负责打印 “B”通过信号量交替执行。每个线程打印后延时几个 tick。#include rtthread.h static rt_sem_t sem RT_NULL; static void thread_a_entry(void *parameter) { while (1) { rt_sem_take(sem, RT_WAITING_FOREVER); rt_kprintf(thread A running\n); rt_thread_mdelay(200); rt_sem_release(sem); } } static void thread_b_entry(void *parameter) { while (1) { rt_sem_take(sem, RT_WAITING_FOREVER); rt_kprintf(thread B running\n); rt_thread_mdelay(300); rt_sem_release(sem); } } int main(void) { sem rt_sem_create(sem, 1, RT_IPC_FLAG_PRIO); rt_thread_create(a, thread_a_entry, RT_NULL, 2048, 20, 10); rt_thread_create(b, thread_b_entry, RT_NULL, 2048, 21, 10); return 0; }如果 A 和 B 能交替打印说明调度器、时钟节拍、线程栈和信号量都基本工作了。这一步过了移植的主体就拿到了一颗“定心丸”。5.2 串口 shell 跑起来才能进一步调试rt-thread 的 FinSH 组件是很宝贵的调试资源跑通之后可以直接在终端里敲命令查看线程栈、内存和 CPU 占用。接入 FinSH 只需要把串口接收接到 shell 线程上并配置RT_USING_FINSH。配好之后我最常用的命令是list_thread、list_memheap、ps。尤其是ps直接列出每个线程的栈用量和状态对排查栈溢出极其有效。还有一个命令是free能实时查看堆碎片状态堆溢出问题一眼定位。5.3 从移植到扩展外设文件系统与 GUI当内核跑稳后我开始把 SPI Flash 接入 rt-thread 的 DFS设备文件系统框架挂上了 littlefs。这个过程比之前的所有移植都顺滑因为 rt-thread 的设备驱动框架很成熟你只需要实现一个read/write/ioctl接口并注册进去即可。至于 GUI 方向不少同学搜“Freertos 移植 LVGL”或“LVGL 移植 stm32”到了 rt-thread 这边可以用官方lvgl软件包前提是你的 SoC 有一个帧缓冲设备以及足够的堆内存。如果你的 ysyx SoC 没有显示控制器LVGL 可以先在模拟器里跑或者外接一块 SPI 屏通过软件像素填充先看效果。这类扩展的意义在于它会倒逼你把驱动抽象层做得更规范。5.4 继续深入的几条路径移植完成后还可以沿这几条路径往下走给 rt-thread 添加网络协议栈用 lwIP 驱动以太网控制器。把自研核的 S 态能力用起来跑 rt-thread 的 SMP 模式或多核调度。与通用的 Linux 内核启动对比看看自研核在运行 rt-thread 时还有哪些硬件缺失。我个人觉得移植 rt-thread 最大的价值不是最终结果而是过程中你真正理解了一个操作系统和硬件的相互关系。写完这篇文章时我已经能在自研核上跑起多线程任务并稳定打印数据看到串口里一条条调度日志滚动出来你会觉得之前所有的深夜调试都值了。最后再分享一个我自己的体会别在第一次移植的时候就追求“完整”先把能证明调度工作的最小系统跑起来再去点亮外设这个节奏是最不容易劝退的。每一步验证都靠“多线程交替打印 串口输出”来做比任何复杂测试都可靠。
返回列表