说实话,在ysyx学到CPU能跑通乘法器和简单的裸机程序之后,下一件最“提神”的事,就是给它移植一个真正的RTOS。我最后选的是RT-Thread,不只是因为中文资料相对友好,更因为它内核体积小、代码路径足够清晰,自己写的RISC-V核能扛得住,也最方便反查核本身的bug。这篇文章不打算把官方文档重新抄一遍,而是想认真聊聊我在自研RV64核上移植RT-Thread时做的关键取舍、改过的核心代码,以及几次差点让人心态崩掉的排查过程。
如果你现在正卡在ysyx的某个阶段,被中断异常、多线程调度、外设交互这些词搞得一头雾水,这篇内容应该正好对胃口。我会从“为什么选RT-Thread”讲到“中断上下文切换怎么写”,再落到“串口乱码怎么查”,全程按实际踩坑顺序来,尽量把每一步的取舍逻辑都说明白。
1. 为什么偏偏是RT-Thread:移植前的思路拆解
1.1 Linux太重,RT-Thread刚好是一把顺手的手术刀
在ysyx里完成CPU设计后,很多人第一反应是“我要在上面跑Linux”。先别急,Linux对核的要求比看上去高得多:MMU、S模式、完整的中断虚拟化、设备树、文件系统、页表管理。这些对刚验证完正确性的自研核来说,每一步都是一座大山,而且一旦出错,你根本分不清是CPU的bug还是OS初始化的问题。
RT-Thread的优势在于它要求的硬件条件极其朴素:一个能跳转的异常入口、一块可读写的内存、一个可编程定时器、一个字符输出通道。这些正好是ysyx学生核最容易被验证清楚的部分。内核裁剪后只有几十KB,调度器代码就那两三千行,追踪起来心里有底。更重要的是,它在异常处理路径上非常依赖中断现场的正确保存和恢复,这就逼着你把mtvec、mcause、mepc这一整套RISC-V异常机制真正吃透,而不是像跑裸机三循环那样随便对付。
我在自己核上跑RT-Thread时还有一个很深的体会:这个“移植”过程其实是一面镜子。如果你的核在中断嵌套、CSR读写、内存访问返回地址这些地方有任何隐藏问题,RTOS会在五分钟内把它暴露出来。所以每次调试,我不仅是调OS移植,更是在给核的异常模型做一次全面体检。
1.2 目标硬件需要满足的最小条件
我不建议一上来就追求功能齐全的SoC。拿我做移植的这台“实验机”举例,它其实非常简陋:
- CPU核:RV64IMAC单核,只实现了机器模式(M Mode),没有S模式和MMU。
- 内存:简单的一块SRAM模型,大小设成了64MB,虽然实际只用了前几MB。
- 外设:UART串口一个,用于日志输出;CLINT定时器一个,用于产生系统tick中断。
- 中断控制器:没有接PLIC,直接把定时器和串口的中断线合到一起,接到核的irq引脚上。
这个配置跑Linux是完全不够看的,但跑RT-Thread绰绰有余。为什么强调“只需机器模式”?因为RT-Thread的riscv移植通过汇编上下文切换机制,默认在机器模式下也能完成线程调度。少了地址翻译这一层,你排查起来会轻松很多。不过我建议你在设计SoC时还是顺手留出S模式支持,毕竟后续如果要跑Linux或者做虚拟化实验,回头再补CSR会很痛苦。
1.3 移植窗口到底在哪:不是改内核,而是适配板级
很多人一听到“移植RT-Thread”,第一反应是要动整个内核代码,这个理解是错的。RT-Thread在RISC-V架构上已经有比较成熟的移植基础,官方bsp目录里有riscv64-virt、nuclei系列等工程。我们真正要做的是“在bsp下新建一个自己的板级目录”,让RT-Thread知道自己跑在什么内存地址、时钟频率多少、串口寄存器在哪儿。
核心要动的只有这几个文件:
rtconfig.h:决定裁剪哪些组件,比如是否开启内核调度器、信号量、设备驱动框架。board.c/board.h:板级初始化,包括时钟、内存、栈顶地址、控制台串口。context_gcc.S:上下文切换和中断入口的汇编代码,如果官方实现和你的核行为不一致,需要在这里改。link.lds/.lds.S:链接脚本,定义代码段、数据段、栈、堆的布局。
理解这点很重要:你的99%精力应该放在“怎么让内核的通用代码正确跑在你的硬件上”,而不是“把内核重写一遍”。我也是在吃了好几次亏之后才明白,先建一个最简BSP跑通hello world,比什么都强。
2. 先把最简BSP搭起来:目录、配置和链接脚本
2.1 一个干净的自建BSP目录长什么样
我在RT-Thread源码的bsp/下新建了一个riscv64-personal目录,一开始里面只有六个文件,多的都不要。目录大概长这样:
bsp/riscv64-personal/ ├── SConstruct ├── board.c ├── board.h ├── Kconfig ├── link.lds.S └── rtconfig.h很多人喜欢直接复制官方的riscv64-virt工程,结果带进来一堆用不到的驱动和配置项,编译警告几百条,看都看不过来。我建议是从零手写,这样每个符号从哪来、每个地址是多少,心里都有数。等跑通了,再根据需要往回填组件。
SConstruct这步没什么好说的,指向RT-Thread根目录的构建脚本就行。重点是board.c和link.lds.S,它们决定了内核能不能正常跳转到你的硬件世界。
2.2 链接脚本里必须看得懂的三个符号
在RISC-V移植中,链接脚本不只是“把代码放在哪”,它定义了内存视图。我在link.lds.S里最关注三个符号:
_ram_start:内存起点,RT-Thread用它计算堆的范围。_stack_top:启动时的初始栈顶,C代码调用之前的临时栈就长在这里。_end:镜像结束地址,也是堆区的起始位置。
以64MB内存为例,我的布局大致是:
.ram_text : ALIGN(4) { _ram_start = .; *(.text) *(.text*) } > RAM .ram_data : ALIGN(4) { *(.rodata) *(.rodata*) *(.sdata) *(.sdata*) *(.data) *(.data*) } > RAM .bss : ALIGN(4) { __bss_start = .; *(.bss) *(.bss*) *(COMMON) __bss_end = .; } > RAM _end = .; _stack_top = ORIGIN(RAM) + LENGTH(RAM);为什么_stack_top要放在内存末尾?因为RISC-V栈是向下生长的,把栈顶设到最高地址,可以有效防止栈区与静态数据区互相覆盖。调试时你还能利用一个现象:栈增长越界后,最先被改写的是堆区顶部的内存,通过观察_end附近的数据是否被破坏,能快速判断是不是栈溢出了。
还有一点是关于16字节对齐。RISC-V的函数调用规范要求在调用点栈指针保持16字节对齐,所以我在汇编里做SP切换时都会先andi sp, sp, ~15,防止后续浮现奇怪的浮点或原子操作对齐错误。
2.3 rtconfig.h裁剪的原则:关掉一切用不上的
rtconfig.h是RT-Thread的“开关总闸”。我踩过一个大坑:直接从官方配置文件复制,结果默认开启了大量设备驱动框架和组件,编译产物直接超了SRAM镜像大小。更麻烦的是,这些组件在初始化时会对莫名其妙的地址做读写,一旦碰到未实现的外设,CPU直接掉进异常。
我最后留下的核心配置项非常少:
#define RT_THREAD_PRIORITY_MAX 32 // 最大优先级数 #define RT_TICK_PER_SECOND 1000 // 每秒tick数 #define RT_ALIGN_SIZE 8 #define RT_NAME_MAX 8 #define RT_USING_TIMER_SOFT // 如果暂时用不到,可以关掉 #define RT_USING_MUTEX #define RT_USING_SEMAPHORE #define RT_USING_CONSOLE #define RT_CONSOLEBUF_SIZE 128每次新加功能时,我都先回到这个文件问自己一句:这个宏不开,我下一步功能还能不能跑通?比如RT_USING_COMPONENTS_INIT我一开始保持开启,因为RT-Thread的板级初始化会通过自动初始化调用board_init和rt_hw_serial_init,不开这个宏,你的串口注册可能不生效。但像RT_USING_CPLUSPLUS这种,在裸RISC-V核上就纯属给自己找事。
3. 上下文切换与中断入口:移植的“心脏”代码
3.1 从mtvec到trap入口,中断必须在开头“安家”
RT-Thread启动后,第一件事就是要把异常向量表地址写到CSR寄存器mtvec里。这个动作通常会放在rt_hw_board_init之前的汇编启动代码中,或者直接在trap_init里执行。代码很简单:
void trap_init(void) { /* 把汇编trap入口地址写入mtvec */ asm volatile("csrw mtvec, %0" : : "r"((rt_uint64_t)trap_entry)); }这里有个细节:trap_entry必须是一个汇编全局符号,如果直接用C函数地址,编译器可能插入额外的prologue/epilogue代码,导致中断现场保存时栈指针已经不是你期望的样子了。我见过有人把trap_entry定义成C函数,结果每次中断返回后,线程的返回地址都被栈上的垃圾数据覆盖。
mtvec有两种模式:直接跳转模式和向量模式。RT-Thread的riscv移植默认使用直接跳转模式,即mtvec直接指向.global trap_entry,所有异常统一走一个入口。这种做法对自研核是最友好的,因为你不必为每个中断源准备跳转表,也方便在入口处统一安排现场保护动作。
3.2 三个调度函数:一个都不能少
RT-Thread的上下文切换在libcpu/riscv目录下,核心是三个汇编函数:
rt_hw_context_switch_to:第一次切换到目标线程,此时没有来源线程,不需要保存现场。rt_hw_context_switch:在线程主动让出CPU时调用,保存当前线程上下文,恢复目标线程上下文。rt_hw_context_switch_interrupt:在中断服务程序结束前调用,保存当前“被打断”的线程,切换目标线程。
我第一次看到这三个函数的时候也很懵,怎么一个调度器要三个切换函数?后来才理解,它们分别对应“初始化启动线程”、“线程主动让权”、“中断抢占后调度的终点”三种场景。
以rt_hw_context_switch为例,核心汇编逻辑大致是这样:
.globl rt_hw_context_switch rt_hw_context_switch: /* 保存当前线程上下文到其栈中 */ addi sp, sp, -32 sd ra, 0(sp) sd sp, 8(sp) sd gp, 16(sp) sd tp, 24(sp) sd s0, 32(sp) sd s1, 40(sp) sd s2, 48(sp) sd s3, 56(sp) sd s4, 64(sp) sd s5, 72(sp) sd s6, 80(sp) sd s7, 88(sp) sd s8, 96(sp) sd s9, 104(sp) sd s10,112(sp) sd s11,120(sp) /* 将当前栈指针保存到线程结构体 */ ld t0, 0(a0) /* 第一个参数是from线程的栈指针存储地址 */ sd sp, 0(t0) /* 加载目标线程的栈指针 */ ld t1, 0(a1) ld sp, 0(t1) /* 恢复目标线程上下文 */ ld ra, 0(sp) ld gp, 16(sp) ld tp, 24(sp) ld s0, 32(sp) ld s1, 40(sp) ld s2, 48(sp) ld s3, 56(sp) ld s4, 64(sp) ld s5, 72(sp) ld s6, 80(sp) ld s7, 88(sp) ld s8, 96(sp) ld s9, 104(sp) ld s10,112(sp) ld s11,120(sp) addi sp, sp, 144 ret这里最容易犯错的地方是:寄存器保存数量要和RISC-V ABI一致。s0~s11是Callee-saved,必须保存;t0~t6、a0~a7这些是Caller-saved,在线程主动切换时,编译器已经保证调用方在调用前会保存它们,所以不需要在这里再次保存。如果你把Caller-saved寄存器也一股脑保存进上下文,栈就白白多了好几十字节,而且不同编译器优化级别下行为不一致,坑得很。
还有一个细节是sp本身。你注意到我在保存列表里写了sd sp, 8(sp),其实这是在刚压栈后把SP的旧值存到了当前新栈帧的某个偏移处。但这个保存通常不会被恢复,因为新线程加载的是它自己的SP。这里真正起作用的是“回写from线程SP到它的线程栈指针存储区”这一步。对,a0参数指向的并不是线程栈的物理起始地址,而是线程TCB里存放“该线程下一次运行时应使用的SP”的字段。理解这一点,你读RT-Thread源码时就不会绕晕。
3.3 中断嵌套到底开不开
在M模式下,我最初按最简单方式处理:中断入口统一关全局中断,处理完再开。但RT-Thread本身是支持中断嵌套的,它通过rt_interrupt_enter和rt_interrupt_leave来维护一个中断嵌套深度计数器。如果你在中断处理过程中不重新开全局中断,那么嵌套计数始终为1,问题不大;但如果某个驱动需要在中断里等待数据,你又不重开中断,就可能死锁。
我建议初期先把硬件中断嵌套关掉,把IRQ处理写得尽量短,不够效率但非常稳定。等系统整体跑顺了,再尝试在trap_entry里恢复mstatus的MPIE位来开启嵌套。这里的核心逻辑是:RISC-V在进入中断时硬件会自动把mstatus.MIE清零并保存到MPIE,所以你只要在中断处理中再次csrw mstatus把MIE置1,就能手动打开嵌套。
但注意,要在嵌套打开前保存好当前现场。嵌套中断会再次压栈,如果现场保存区是静态数组而不是基于栈的,就会溢出。这也是为什么我一直在强调“用栈保存现场”,别自己开一个全局保存区。
4. 时钟、串口与自测用例:让系统真正“活”起来
4.1 时钟tick:调度器的时间尺子
RT-Thread的调度器依赖一个周期性中断来驱动时间片轮转。没有这个tick,你最多只能手动让线程主动让出CPU,但高优先级线程永远没法抢占低优先级线程。我用的CLINT定时器,它的工作流程其实和STM32的SysTick很相似:往定时器比较寄存器写入一个目标计数,当计数器达到该值时产生中断,然后在中断里重新装载下一次的目标值。
初始化代码大致是:
#define CLINT_BASE 0x2000000UL #define CLINT_MTIMECMP 0x2004000UL #define CLINT_MTIME 0x200BFF8UL #define TICK_INTERVAL (CPU_FREQ / RT_TICK_PER_SECOND) void board_timer_init(void) { uint64_t tick = *(volatile uint64_t *)(CLINT_BASE + CLINT_MTIME); tick += TICK_INTERVAL; *(volatile uint64_t *)(CLINT_BASE + CLINT_MTIMECMP) = tick; /* 开启定时器中断,int如果被封装到plic则额外配置 */ csr_set(mie, 0x80); // machine timer interrupt位 csr_set(mstatus, 0x8); // MIE全局中断开关 }中断处理里每次都要重新写下一次的比较值,否则定时器中断只会触发一次。我最初漏掉了这一步,现象是系统启动后正常打印了一会儿,随后整个调度器“冻结”——其实不是死了,而是再也没有新的tick来触发线程切换。
OS tick的中断处理函数应该是这样的模式:
void timer_irq_handler(void) { /* 清除当前timer中断pending */ *(volatile uint64_t *)(CLINT_BASE + CLINT_MTIMECMP) += TICK_INTERVAL; rt_tick_increase(); }rt_tick_increase是关键,它会让调度器检查当前线程时间片是否用完,并决定是否切换。你在中断里还必须注意:RT-Thread要求在进入中断后先调用rt_interrupt_enter,退出前调用rt_interrupt_leave。这两个函数用来标记当前是否处于中断状态,从而决定rt_hw_context_switch_interrupt是否需要真的切换线程。
4.2 串口控制台:调试的基本盘
没有串口输出,你几乎无法判断移植进度。所以我把串口驱动放在最早完成。UART实现并不复杂,核心是发送和接收:
void rt_hw_console_output(const char *str) { while (*str) { if (*str == '\n') { uart_putc('\r'); } uart_putc(*str++); } } void uart_putc(char ch) { while ((uart_read_reg(LSR) & 0x20) == 0); uart_write_reg(THR, ch); }这里有一个经典坑:串口发送需要等待发送保持寄存器为空,也就是LSR的bit5置1。如果不判断状态位直接写THR,CPU会“静默丢字”。在低波特率或者外设模型模拟速度慢的时候,现象就是第一行正常、随后疯狂乱码。
映射到RT-Thread的控制台框架后,有个小细节:rt_hw_console_output是RT-Thread的底层输出钩子,它一般不会启用终端的完整设备驱动框架;而如果你想用rt_device_find("uart0")来做标准输入输出,就要在board.c里注册rt_hw_uart_init。我选择先用前一种,等内核启动完全正常,再补设备驱动框架,这样可以把变量的传播范围控制到最小。
4.3 三个自测用例,验证移植是否真的“活”了
我建议不要一上来就跑跑demo程序,那只能证明你会打印。按下面三步来验证,才能确认调度器真的在工作:
第一步:裸线程主动让权测试
#include <rtthread.h> static rt_thread_t tid1 = RT_NULL; static rt_thread_t tid2 = RT_NULL; static void thread_entry(void *param) { rt_uint32_t count = 0; while (count++ < 5) { rt_kprintf("thread %d running\n", (rt_uint32_t)param); rt_thread_delay(100); // 主动让出CPU并等待100个tick } } int test_scheduler(void) { tid1 = rt_thread_create("t1", thread_entry, (void *)1, 1024, 5, 10); tid2 = rt_thread_create("t2", thread_entry, (void *)2, 1024, 5, 10); rt_thread_startup(tid1); rt_thread_startup(tid2); return 0; }如果在串口上每隔约1000毫秒轮换打印两个线程的编号,说明计时器tick和线程延时通道是通的。
第二步:不同优先级的抢占
把上面两个线程的优先级改成5和6,高优先级线程需要每延时就把CPU让给低优先级吗?其实不会,因为rt_thread_delay会让当前线程进入挂起状态,低优先级线程自然会被调度执行,但这不是“抢占”。真正的抢占要做一个死循环的高优先级线程:
static void high_prio(void *param) { while (1) { rt_kprintf("H"); } } static void low_prio(void *param) { while (1) { rt_kprintf("L"); } }如果高优先级线程一直打印H,低优先级几乎不会输出,说明调度器严格按照优先级抢占。这是最简单的公平性测试,但能直接暴露优先级比较函数是否写错。
第三步:中断与线程交互
写一个简单的中断回调,在定时中断里累加一个全局变量,然后线程读取。如果读取到的计数与理论值一致,说明中断处理函数和线程环境之间没有发生上下文破坏。
volatile rt_uint32_t tick_count = 0; void timer_irq_handler(void) { tick_count++; rt_tick_increase(); } static void reader(void *param) { rt_uint32_t last = 0; while (1) { if (tick_count != last) { rt_kprintf("tick=%d\n", tick_count - last); last = tick_count; } rt_thread_delay(100); } }这三步做完,RT-Thread的内核移植就算基本“活”了。之后你想加shell、加文件系统、加网络协议栈,都是在这个骨架上的增量工作。
5. 冻结、乱码、跑飞:常见问题排查实录
5.1 我遇到的四类“怪病”
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 启动串口无任何输出 | mtvec未设置、串口地址映射错、link脚本栈顶没初始化 | 先检查board.c里串口基地址是否和SoC一致,再看汇编启动段是否在C调用前设置了临时栈 |
| 定时中断只触发一次 | 中断处理里没有重新装载比较值,或没清pending位 | 在handler开头打印一条汇编寄存器的值,确认interrupt脚是否确实拉高 |
| 线程切换瞬间跑飞 | 上下文保存寄存器数量不对,或SP没对齐16字节 | 在rt_hw_context_switch前后打印from/to线程的SP,看是否指向合法内存 |
| 串口第一行正常,后面乱码 | 发送缓冲状态位判断错误、时钟频率配错 | 用逻辑分析仪抓UART波形,核对波特率生成器的分频系数 |
5.2 三个亲测有效的“土办法”
先说第一个:直接在trap_entry开头打印mcause和mepc的值。这两个CSR一个是异常原因,一个是发生异常时的指令地址。RISC-V异常处理的最大好处就是信息都摆在明面上,把这两个值打出来,基本能定位是哪种异常:非法指令、加载访问错、还是断点。
void dump_trap(uint64_t mcause, uint64_t mepc) { rt_kprintf("mcause=%08lx mepc=%08lx\n", mcause, mepc); if (mcause & 0x8000000000000000ULL) { rt_kprintf("interrupt, cause=%d\n", (uint32_t)(mcause & 0xfff)); } else { rt_kprintf("exception, cause=%d\n", (uint32_t)mcause); } }第二个办法是在关键汇编函数里手动码一个死循环加读PC指令。如果你怀疑CPU跑飞了,在rt_hw_context_switch的ret前加一个“记录返回地址”的汇编宏,每次切换线程时把即将跳转的ra打印到串口。这种方法慢是慢了点,但配合-O0编译,能把跑飞的位置压缩到几十行汇编以内。
第三个办法是减少硬件复杂度做二分。把定时器中断频率从1000Hz降到10Hz,把线程栈从4096字节调到8192字节,把优先级从32个压缩到8个。每次只改动一个变量,看现象是否变化。我用这个办法排掉了三个潜在问题:栈溢出、优先级数组越界、定时器溢出导致tick间隔异常。
5.3 一份快速自检清单
移植不顺利的时候,对照这个清单过一遍,很多“面色苍白”的bug都能救回来:
- [ ]
mtvec是否正确指向汇编入口,且入口是汇编符号而不是C函数。 - [ ]
mstatus.MIE和mstatus.MPIE初始值是否符合预期。 - [ ] 中断入口保存现场的栈是用当前线程栈,而不是某个全局数组。
- [ ] 上下文切换处SP是否保持16字节对齐。
- [ ] 线程TCB中“线程栈栈顶”是用
scheduler的栈指针保存位置,而不是线程栈的静态地址。 - [ ] 定时器中断频率是否匹配
RT_TICK_PER_SECOND配置。 - [ ]
rt_interrupt_enter和rt_interrupt_leave是否成对出现。 - [ ] 链接脚本中的
_stack_top是否落在实际可读写的内存区间。
每次从ysyx的FPGA板子上重新烧录,我都会先跑一遍这个清单再开始调新功能,基本能节省两三个小时的无效调试时间。
我自己的最终感受是:移植RT-Thread看起来是“给内核做适配”,实际上整个调试链条逼你把RISC-V特权架构、汇编调用约定、内存布局、中断模型这四件事串成一条线。在ysyx的阶段里,这种问题远比多跑几个benchmark更值钱。如果让我重来一遍,我依然会先做最小的串口打印和tick中断,再碰调度器,这是最稳妥的路径。现在RT-Thread移植已经是我验收新板卡的“第一瓶试剂”了——往里面扔点自测线程,核好不好,十分钟便见分晓。