1. 项目缘起:为什么选择RT-Thread Nano与FinSH
最近在做一个基于STM32F072RBT的小型数据采集设备,项目需求不算复杂,但需要一定的实时性和一个方便调试的交互接口。一开始考虑过裸机轮询,但随着外设(ADC、I2C、UART)增多,状态机写得越来越复杂,维护起来很头疼。FreeRTOS当然是个好选择,但感觉对于我这个资源有限的Cortex-M0内核芯片(STM32F072只有128KB Flash和16KB RAM)来说,它的内存占用和启动速度还是让我有点犹豫。
这时,RT-Thread Nano进入了我的视线。它是一个极简版的RT-Thread实时操作系统,专门为资源受限的MCU设计,内核非常小巧,最小配置下RAM能控制在1KB以内,Flash占用也很少,而且提供了任务调度、信号量、邮箱等核心机制,完全能满足我的需求。更重要的是,RT-Thread生态里有FinSH组件,这是一个可以通过串口进行交互的命令行外壳(shell),能动态查看线程状态、修改变量值、执行函数,对于现场调试和参数配置来说,简直是“神器”。于是,我决定在STM32F072RBT上尝试移植RT-Thread Nano,并把FinSH组件也加进去,把整个过程记录下来,给有类似需求的朋友一个参考。
2. 环境准备与工程骨架搭建
移植的第一步是准备好“战场”。我的开发环境是Keil MDK-ARM V5,芯片包已经安装好了STM32F0系列。RT-Thread Nano的源码可以从RT-Thread官网的GitHub仓库下载,我选择的是3.1.5这个长期支持版本,比较稳定。
2.1 获取RT-Thread Nano源码包
RT-Thread Nano的源码包通常是一个独立的压缩文件,里面包含了内核核心文件、移植相关的板级支持包(BSP)模板以及一些示例。解压后,我们主要关注以下几个目录和文件:
rt-thread/:这是核心,里面的src目录包含了内核的所有源代码,如clock.c(时钟管理)、thread.c(线程管理)、ipc.c(进程间通信,如信号量、互斥锁)等。bsp/:这里存放了针对不同芯片和开发板的移植模板。对于STM32,我们通常参考stm32目录下的模板。components/:这里存放了可选组件,其中就有我们需要的finsh/。libcpu/:这里是与CPU架构相关的移植代码,比如上下文切换的汇编实现。对于Cortex-M0,我们需要arm/cortex-m0下的文件。
我的做法是,不在原工程里直接引用这些分散的文件,而是将必要的文件复制到我自己的项目目录中,这样工程结构更清晰,也便于版本管理。我在项目根目录下新建了一个rt-thread文件夹,然后把src核心文件、libcpu/arm/cortex-m0下的文件、以及components/finsh下的文件都复制了过来。bsp下的文件我们只参考,不直接复制,因为我们需要针对STM32F072做定制。
2.2 创建基础Keil工程并集成内核
首先,我用STM32CubeMX生成了一个基于STM32F072RBT的基础工程,配置好系统时钟(使用内部HSI 8MHz,通过PLL倍频到48MHz)、SysTick定时器(作为RT-Thread的时钟节拍来源)、以及一个用于FinSH的串口(USART1,波特率115200)。生成代码时,我选择只生成必要的HAL库代码,避免工程过于臃肿。
在Keil中新建工程后,我开始组织文件结构。我在项目管理器里创建了几个组(Group):
Application: 存放我的应用代码,如main.c。RT-Thread/kernel: 存放从rt-thread/src复制来的所有.c文件。RT-Thread/port: 存放移植层文件。这里需要重点关注几个文件:context_iar.s或context_gcc.S:这是上下文切换的汇编代码。由于我用Keil(ARMCC编译器),我需要一个对应ARM Compiler 5(armcc)或6(armclang)的版本。RT-Thread Nano包中可能没有直接提供,但我们可以从其他BSP示例中找一个context_rvds.s来修改,或者根据Cortex-M0的汇编指令自己写一个。核心是实现rt_hw_context_switch_to和rt_hw_context_switch这两个函数,用于保存和恢复线程的寄存器现场(包括PSP、R4-R11等)。cpuport.c: 这个文件实现了一些CPU架构相关的函数,最重要的是rt_hw_stack_init,它用于初始化一个新线程的栈空间,并把栈顶指针指向线程入口函数和参数。对于Cortex-M0,栈帧的结构(初始PSR、PC、LR等寄存器的压栈顺序)必须符合ARM架构过程调用标准(AAPCS)。board.c: 这是板级初始化文件,我们需要在这里实现系统时钟初始化(rt_hw_board_init)和时钟节拍配置(SysTick_Handler中断服务函数中调用rt_tick_increase())。
把上述文件添加到对应的Keil组里,然后开始配置头文件路径。必须包含的路径有:rt-thread/include(内核头文件)、rt-thread(我们自己复制文件的根目录,方便找到port下的头文件)、以及libcpu/arm/cortex-m0。接下来就是最关键的步骤:修改配置文件。
3. 内核裁剪与关键配置详解
RT-Thread Nano的精髓在于可裁剪性。所有的配置都在一个名为rtconfig.h的头文件中完成。我们可以手动创建这个文件,也可以从示例中复制一个过来修改。这个文件里的宏定义决定了内核包含哪些功能。
3.1 基础内核功能使能
首先,必须开启内核支持:
#define RT_USING_NANO // 明确使用Nano版本 #define RT_USING_OVERFLOW_CHECK // 开启栈溢出检查,调试必备 #define RT_THREAD_PRIORITY_MAX 8 // 设置最大优先级为8,对于小应用足够了 #define RT_TICK_PER_SECOND 100 // 系统时钟节拍频率,100Hz即10ms一个tick #define RT_ALIGN_SIZE 4 // 内存对齐字节数,ARM Cortex-M通常是4 #define RT_NAME_MAX 8 // 线程名称最大长度 #define RT_USING_HEAP // 启用动态内存堆,FinSH和部分API需要这里RT_TICK_PER_SECOND设置为100,意味着系统每10ms会产生一次时钟中断。这个值需要权衡:太低了调度不灵敏,太高了会增加中断开销。对于我的数据采集设备,10ms的粒度完全足够。
3.2 组件与设备配置
接下来,我们需要开启FinSH组件和它依赖的串口设备:
#define RT_USING_FINSH // 启用FinSH组件 #define FINSH_USING_MSH // 启用模块化shell,这是主流用法 #define FINSH_THREAD_STACK_SIZE 512 // FinSH线程的栈大小,根据命令复杂度调整 #define FINSH_THREAD_PRIORITY 20 // FinSH线程优先级,设得比应用线程低一些,避免阻塞应用 #define FINSH_USING_HISTORY // 允许使用上下箭头查看历史命令 #define FINSH_USING_SYMTAB // 使用符号表,这是FinSH能找到我们自定义命令的关键 #define FINSH_CMD_SIZE 80 // 命令行缓冲区大小 #define RT_USING_DEVICE // 启用设备框架 #define RT_USING_SERIAL // 启用串口设备驱动 #define RT_SERIAL_USING_DMA // 如果使用DMA收发串口数据可以开启,我这里先不用注意,FINSH_THREAD_PRIORITY我设置为20(数字越大优先级越低,RT-Thread默认优先级数越小优先级越高)。我的数据采集线程优先级设为10,这样FinSH就不会抢占采集任务,保证实时性。
3.3 内存堆配置
动态内存堆是FinSH和内核对象动态创建的基础。RT-Thread Nano支持多种内存管理算法,对于STM32F072,我选择使用小内存管理算法(SLAB)的变种,或者简单的连续内存管理。我们需要在board.c的rt_hw_board_init()函数中初始化堆空间:
// 在board.c中定义一个数组作为堆空间 #define HEAP_BEGIN (&Image$$RW_IRAM1$$ZI$$Limit) // 使用链接脚本定义的未初始化数据段结束地址作为堆起始 #define HEAP_END (0x20004000) // STM32F072RB的RAM结束地址是0x20004000(16KB) void rt_hw_board_init() { // ... 初始化HAL库、系统时钟等 ... SystemCoreClockUpdate(); // 初始化系统时钟节拍(SysTick) SysTick_Config(SystemCoreClock / RT_TICK_PER_SECOND); // 初始化动态内存堆 rt_system_heap_init((void*)HEAP_BEGIN, (void*)HEAP_END); // 初始化板级串口设备(后文详述) rt_hw_usart_init(); // 打印RT-Thread版本信息(可选,依赖串口) rt_kprintf("\nRT-Thread Nano v%d.%d.%d\n", RT_VERSION, RT_SUBVERSION, RT_REVISION); }这里Image$$RW_IRAM1$$ZI$$Limit是一个由链接器生成的符号,代表已初始化数据和未初始化数据(BSS段)结束后的地址,也就是空闲RAM的起始地址。这样能确保堆空间不会和全局变量冲突。我们需要在Keil的链接器配置里确保这个符号可用(通常默认就是可用的)。
4. 串口驱动与FinSH的“搭桥”工作
内核跑起来了,堆也有了,现在需要让FinSH能通过串口和我们对话。这需要两个步骤:实现一个串口设备驱动,并将这个设备注册为FinSH的控制台。
4.1 实现RT-Thread设备驱动框架下的串口驱动
RT-Thread的设备驱动框架定义了一套标准的操作接口(struct rt_device_ops)。我们需要为USART1实现这些接口。我选择在board.c旁边新建一个drv_usart.c文件。
首先,定义一个继承自rt_device的结构体,包含我们需要的硬件句柄(HAL的UART_HandleTypeDef)和收发缓冲区:
#include <rtdevice.h> #include "stm32f0xx_hal.h" struct stm32_uart { rt_uint32_t uart_periph; // 外设基地址,如USART1 UART_HandleTypeDef huart; // HAL句柄 rt_uint16_t irqno; // 中断号 };然后,实现最关键的几个操作函数:
rt_err_t uart_configure(struct rt_device *dev, struct rt_device_configuration *cfg): 配置串口波特率、数据位等。这里可以直接调用HAL_UART_Init。rt_size_t uart_write(struct rt_device *dev, rt_off_t pos, const void *buffer, rt_size_t size): 发送数据。我们可以使用阻塞式的HAL_UART_Transmit,或者为了效率,使用中断或DMA。为了简单起见,初次调试我用了阻塞发送。rt_size_t uart_read(struct rt_device *dev, rt_off_t pos, void *buffer, rt_size_t size): 读取数据。同样,可以使用HAL_UART_Receive或中断方式。这里有个关键点:FinSH是通过轮询(poll)方式读取控制台输入的,所以我们的read函数不能一直阻塞等待。一个常见的做法是,在串口接收中断服务函数(USART1_IRQHandler)中将收到的字符存入一个环形缓冲区(ring buffer),然后在uart_read函数中从这个环形缓冲区里取数据。如果缓冲区为空,则返回0,表示当前无数据。这样FinSH线程就能非阻塞地检查是否有输入。
最后,实现一个初始化函数rt_hw_usart_init(),在这个函数里:
- 初始化HAL的UART句柄(配置引脚、波特率等)。
- 调用
rt_device_register函数,将我们实现的这个stm32_uart设备注册到RT-Thread的设备框架中,设备类型为RT_Device_Class_Char(字符设备),并给它起个名字,比如"uart1"。 - 开启串口接收中断。
4.2 将串口设备绑定为FinSH控制台
设备注册好后,我们需要告诉FinSH使用哪个设备作为输入输出。在main.c或board.c的初始化后期(在rt_hw_board_init()之后),调用以下函数:
#include <finsh.h> void rt_console_set_device(const char* device_name);例如:rt_console_set_device("uart1");。
这样,rt_kprintf函数(内核打印函数)和FinSH的输入输出就都会重定向到uart1这个设备上了。此时,编译下载程序,如果串口线连接正确,上电后应该能看到通过rt_kprintf打印的RT-Thread版本信息。
4.3 创建FinSH线程并导出符号表
仅仅设置控制台设备还不够,FinSH本身需要一个独立的线程来运行。通常,我们会在main.c中创建初始线程,然后在这个初始线程里调用finsh_system_init()来启动FinSH线程:
// 定义初始线程的栈和线程句柄 ALIGN(RT_ALIGN_SIZE) static rt_uint8_t main_stack[512]; static struct rt_thread main_thread; // 初始线程入口函数 void main_thread_entry(void* parameter) { // 用户应用初始化... // 例如:初始化ADC、启动数据采集线程等 // 初始化并启动FinSH finsh_system_init(); // 此时,FinSH线程已经创建并启动,在后台等待命令 while (1) { // 主线程可以执行其他任务,或者挂起 rt_thread_mdelay(1000); } } int main(void) { // 硬件初始化(HAL_Init等)已在启动文件完成,或需手动调用 // RT-Thread内核初始化 rt_thread_init(&main_thread, "main", main_thread_entry, RT_NULL, &main_stack[0], sizeof(main_stack), 10, // 优先级 20); // 时间片 rt_thread_startup(&main_thread); // 启动初始线程 // 启动RT-Thread调度器,永不返回 rt_system_scheduler_start(); return 0; }finsh_system_init()函数内部会创建一个名为“tshell”的线程,其栈大小和优先级就是我们之前在rtconfig.h里配置的FINSH_THREAD_STACK_SIZE和FINSH_THREAD_PRIORITY。
要让FinSH能识别并执行我们自定义的函数(比如一个读取ADC值的函数),我们需要导出符号表。有两种方式:
- 自动导出(推荐):在要导出的函数定义前,加上RT-Thread提供的宏。例如,我有一个函数
int read_adc_value(void),想通过FinSH调用,只需在函数定义处添加:
这样,FinSH启动时就会自动将这个函数名#include <finsh.h> FINSH_FUNCTION_EXPORT(read_adc_value, read adc value);read_adc_value和帮助信息"read adc value"加入到命令表中。在串口终端输入read_adc_value回车,就能执行这个函数并看到返回值。 - 手动导出:在
finsh组件初始化前,调用finsh_syscall_append函数手动添加。这种方式更灵活,但代码稍显繁琐。
5. 编译、调试与排坑实录
配置和代码都写好了,接下来就是激动人心的编译和调试环节。这个过程几乎不可能一帆风顺,我遇到了几个典型问题,这里分享出来,希望能帮你节省时间。
5.1 链接错误:未定义的汇编符号
第一次编译,链接器报错:Error: L6218E: Undefined symbol rt_hw_context_switch_to。这是因为我们缺少了上下文切换的汇编文件,或者文件没添加到工程,或者文件里的函数名与内核引用的不对应。
解决方案:确保context_rvds.s(或你使用的汇编文件)已添加到Keil的RT-Thread/port组中。打开这个文件,检查里面定义的函数名是否与rtdef.h中声明的一致。通常需要以下函数:
rt_hw_context_switch_to:用于启动第一个线程。rt_hw_context_switch:用于在线程间切换。rt_hw_context_switch_interrupt:用于在中断服务程序中进行线程切换(可选,但建议实现以支持rt_interrupt_enter/leave)。 对于Cortex-M0,这些函数需要用汇编实现PSP(进程栈指针)的保存与恢复,以及 PendSV 异常触发机制。
5.2 FinSH无响应或打印乱码
程序下载后,串口有输出(版本信息),但按回车没反应,或者输入字符后显示乱码。
可能原因及排查:
- 串口配置问题:首先用示波器或逻辑分析仪检查USART1的TX引脚,看是否有数据发出。如果有,但PC端乱码,检查波特率、数据位、停止位、校验位是否与终端软件(如Putty、SecureCRT)设置一致。特别注意:RT-Thread的
rt_kprintf和FinSH默认使用\n作为换行,而有些终端软件需要\r\n。可以在rtconfig.h中定义RT_USING_TERMINAL并实现相关钩子函数,或者直接修改finsh组件中关于行结束符的处理。 - 控制台设备未正确设置:确认
rt_console_set_device("uart1")中的"uart1"与你注册设备时使用的名字完全一致(大小写敏感)。可以在调用此函数后加一句rt_kprintf("console set to uart1\n")来验证。 - FinSH线程未启动或栈溢出:在
rtconfig.h中打开RT_USING_OVERFLOW_CHECK和线程栈调试信息。然后在线程初始化后,在main线程里调用list_thread命令(如果FinSH还没起来,可以先用rt_kprintf打印线程列表)。查看tshell线程的状态是否为running,以及栈使用率是否接近100%。如果栈溢出,增大FINSH_THREAD_STACK_SIZE。 - 串口接收中断未正确触发:FinSH需要接收字符。确保你的
drv_usart.c中正确配置并开启了串口接收中断(HAL_UART_Receive_IT),并且中断服务函数USART1_IRQHandler被正确实现,将接收到的数据存入环形缓冲区。可以在中断服务函数里加一个翻转LED的代码,测试是否有字符输入中断。
5.3 自定义命令无法识别
输入read_adc_value,FinSH返回msh: command not found.。
排查步骤:
- 检查宏定义:确认
rtconfig.h中FINSH_USING_SYMTAB已定义。 - 检查导出宏:确认自定义函数前使用了
FINSH_FUNCTION_EXPORT宏,并且函数名和帮助字符串没有语法错误。一个常见的坑:这个宏会在编译时在特定的段(section)放置一个数据结构。你需要检查Keil的链接器配置,确保包含了这个段。通常,RT-Thread的移植模板会提供一个分散加载文件(.sct)或链接脚本,其中定义了FSymTab和VSymTab段的存放位置。如果没有,你可能需要手动在链接器选项中添加--keep=FSymTab和--keep=VSymTab(对于ARMCC),或者确保这些段不会被优化掉。 - 查看符号表:在FinSH中,有一个内置命令
list_symbol()可以列出所有已导出的命令。输入这个命令,看看你的read_adc_value是否在列表中。如果不在,说明导出失败。
5.4 系统运行不稳定或HardFault
加入FinSH后,系统偶尔跑飞,触发HardFault。
可能原因:
- 栈空间不足:这是最常见的原因。FinSH线程栈、你的应用线程栈、以及中断嵌套都可能消耗栈空间。使用
list_thread命令查看各线程栈使用情况,适当增加栈大小。特别是中断服务函数中不要使用大的局部变量。 - 堆空间不足:FinSH内部可能会动态分配内存(如历史命令记录)。如果堆空间太小,
rt_malloc失败可能导致异常。检查HEAP_END - HEAP_BEGIN的大小,确保有足够的空闲RAM(对于STM32F072,留出2-4KB给堆是比较安全的)。 - 中断优先级冲突:SysTick中断和PendSV中断的优先级需要正确设置。对于Cortex-M,SysTick和PendSV通常设置为最低优先级,以确保它们不会打断高优先级的中断(如你的串口接收中断)。在
board.c的rt_hw_board_init()中,调用HAL_NVIC_SetPriority(SysTick_IRQn, 0xF, 0)来设置SysTick优先级(具体值根据NVIC优先级分组而定)。PendSV的优先级在上下文切换汇编代码中设置。 - 设备驱动操作非线程安全:如果你的串口
write或read函数被多个线程调用(比如rt_kprintf可能在中断中调用),而没有加锁保护,可能会造成数据竞争。考虑在设备驱动结构体中增加一个互斥锁(rt_mutex_t),在操作前后进行加锁解锁。
6. 优化与实践心得
系统稳定运行后,还可以做一些优化,让开发体验更好。
6.1 优化FinSH输入体验
默认的FinSH输入可能不支持退格键(Backspace)删除、方向键移动光标。这些功能依赖于终端类型(VT100)的支持。你可以在终端软件中设置为VT100或ANSI模式。同时,确保你的串口驱动在收到特殊字符(如0x7F代表退格,0x1B代表ESC)时能正确处理。RT-Thread的FinSH组件通常已经处理了这些,但需要终端模拟器配合。
6.2 使用MSH(模块化Shell)命令
除了导出C函数,RT-Thread的MSH还支持用更自然的方式定义命令,类似于Linux shell中的命令风格。你可以使用MSH_CMD_EXPORT宏来定义命令和处理函数。这种方式定义的命令可以带参数,解析起来更方便。例如:
static void my_cmd(int argc, char** argv) { if (argc < 2) { rt_kprintf("Usage: my_cmd <value>\n"); return; } int val = atoi(argv[1]); rt_kprintf("You input: %d\n", val); } MSH_CMD_EXPORT(my_cmd, this is my command);在FinSH中输入my_cmd 123,就会调用这个函数并输出You input: 123。
6.3 将FinSH作为强大的调试工具
FinSH不仅仅是一个命令执行器,结合RT-Thread的内置命令,它变成了一个强大的实时调试工具:
list_thread:查看所有线程的状态、优先级、栈大小和使用率、错误代码。这是分析系统负载和排查线程阻塞的利器。list_timer:查看系统定时器列表。list_sem/list_mutex/list_mailbox/list_mempool:查看各种内核对象的状态,对于分析同步问题非常有帮助。free:查看当前堆内存的使用情况。- 你甚至可以自定义命令来读取或修改某个全局变量,或者触发一个特定的测试流程,极大地方便了现场调试和参数整定。
6.4 关于资源占用的实测数据
最后,分享一下在STM32F072RBT上最终的资源占用情况(通过Keil的Map文件查看):
- Flash占用:内核 + FinSH组件(不含浮点数打印等高级功能)总计约12KB。
- RAM占用:内核数据区约1KB,加上为FinSH线程栈(512字节)、堆空间(2KB)以及全局变量,总RAM使用约6KB,剩余10KB左右供应用使用。
这个占用比预想的还要小,完全在STM32F072的承受范围内。整个移植过程虽然遇到一些坑,但一旦跑通,带来的开发效率提升是巨大的。特别是对于需要现场调试和参数调整的设备,不用再反复烧录程序,通过串口命令行就能完成大部分工作,这种感觉非常棒。