1. 为什么要在 F429 上折腾 RT-Thread nano 加 FinSH
拿到一块 STM32F429 的板子,第一反应多半是直接上 CubeMX 生成 HAL 库工程,点个灯、跑个串口,这没什么门槛。但真要把这东西用到实际项目里,裸机那套前后台架构很快就会撑不住——任务一多,while(1)里塞满了各种标志位判断和延时,改一处崩三处。这时候引入一个轻量级 RTOS 几乎是必然选择。
RT-Thread nano 3.1.3 这个版本在圈子里口碑一直不错,它把完整版 RT-Thread 裁剪到只保留内核最核心的部分:线程调度、信号量、互斥量、邮箱、消息队列、内存管理,整个内核编译出来也就几 KB 到十几 KB,对 F429 这种 192KB SRAM、2MB Flash 的芯片来说毫无压力。更关键的是它带了一个 FinSH 控制台组件,能让你通过串口直接敲命令查看线程状态、读写变量、调用函数,调试效率比单纯打 log 高出一个量级。
这套组合解决的核心问题是:在资源受限的 MCU 上,用最小的移植成本获得一个可交互的实时内核。适合谁看?如果你已经会用 CubeMX 配置外设、能看懂 HAL 库的基本调用,但还没在 STM32 上跑过 RTOS,或者跑过但移植过程稀里糊涂,那这篇内容就是给你准备的。我会把从建工程到 FinSH 敲出第一条命令的完整链路拆开讲,包括那些官方文档里一笔带过、但实际会卡住你半天的细节。
需要提前说明的是,RT-Thread nano 和完整版 RT-Thread 在移植方式上差别很大。nano 版本没有设备驱动框架、没有组件自动初始化机制,很多东西要手动接。这既是它的轻量之处,也是移植时最容易踩坑的地方。下面我按实际操作的顺序,把每个环节的考量和手法都摊开说。
2. 工程创建与基础环境搭建
2.1 CubeMX 里的关键配置项
打开 CubeMX 新建工程,选好 F429 对应的具体型号(比如 F429ZIT6 这类,按你手上板子的实际型号来)。第一步先把时钟树配好,F429 主频能跑到 180MHz,HAL 库延时和 RTOS 的 tick 都依赖这个。外部晶振频率按板子实际情况填,一般是 8MHz 或 25MHz,然后 PLL 倍频到 180MHz。这里有个细节:SysTick 的时钟源要确认清楚,CubeMX 默认用 HCLK/8 给 HAL 做时基,但 RT-Thread 要用 SysTick 做系统 tick,两者会打架,后面会专门处理。
调试接口建议保留 SWD,方便下载和在线调试。串口至少开一个,用来接 FinSH,比如 USART1,波特率 115200,8 数据位、1 停止位、无校验。中断优先级这块要注意,HAL 库默认给 SysTick 和 PendSV 的优先级配置和 RTOS 有冲突,CubeMX 里 NVIC 那页先别急着改,等生成代码后在stm32f4xx_it.c和启动文件里统一处理。
工程管理页面,工具链选 MDK-ARM 或 STM32CubeIDE 都行,我这边以 MDK 为例。代码生成选项里务必勾选"Generate peripheral initialization as a pair of .c/.h files per peripheral",这样每个外设的初始化代码独立成文件,后面移植时结构清晰,不会全堆在 main.c 里。另外把"Copy only necessary library files"选上,避免工程目录臃肿。
生成代码后先编译一遍,确认裸机工程能跑通,串口能正常收发。这一步别跳过,不然后面出问题你分不清是 RTOS 移植的锅还是基础配置的锅。
2.2 获取 RT-Thread nano 3.1.3 源码包
nano 版本的源码获取方式和完整版不同,它是以 pack 形式分发的。在 MDK 里可以通过 Pack Installer 直接安装 RT-Thread nano 的软件包,安装后在工程里勾选组件即可。但这种方式对版本控制不友好,我习惯手动把源码目录拷进工程,方便后续改配置和排查。
源码包的核心目录结构是这样的:rt-thread下分src(内核源码)、include(头文件)、bsp(板级支持,nano 里主要是board.c和rtconfig.h)、components/finsh(FinSH 组件)。3.1.3 这个版本的内核源码文件包括clock.c、device.c、idle.c、ipc.c、irq.c、kservice.c、mem.c、mempool.c、object.c、scheduler.c、thread.c、timer.c这些,移植时按需加入工程。
把rt-thread整个文件夹复制到你的工程目录下,比如放在Middlewares/RT-Thread里。然后在 MDK 工程里新建分组,把src下的.c文件加进去,include和bsp路径加到头文件搜索路径。FinSH 组件的源码在components/finsh下,有shell.c、msh.c、finsh_compiler.c、finsh_error.c、finsh_heap.c、finsh_node.c、finsh_ops.c、finsh_parser.c、finsh_var.c、finsh_vm.c、finsh_token.c等,如果只用 msh 模式(不启用 C 表达式解释器),可以只加shell.c和msh.c,能省不少 Flash。
2.3 rtconfig.h 的裁剪与配置
rtconfig.h是整个 RT-Thread 的配置中枢,nano 版本的这个文件在bsp目录下。打开它你会看到一堆#define,每一项都对应一个功能开关。移植时最需要关注的有这么几个:
RT_THREAD_PRIORITY_MAX定义最大优先级数,默认 32,够用。RT_TICK_PER_SECOND是系统 tick 频率,默认 1000,也就是 1ms 一个 tick。这个值影响延时精度和调度开销,1000 对大多数场景合适,如果你对功耗敏感可以降到 100,但延时精度会变粗。
RT_ALIGN_SIZE是字节对齐,默认 4,F429 是 32 位内核,保持 4 就行。RT_NAME_MAX是对象名字最大长度,默认 8,如果你习惯给线程起长名字可以调到 16,但每个对象会多占几个字节 RAM。
RT_USING_HEAP这个必须开,FinSH 和一些内核对象动态创建都依赖堆。开了之后还要在board.c里实现堆的初始化,把一段 RAM 区域交给rt_system_heap_init。RT_USING_CONSOLE也要开,不然 FinSH 没法输出。RT_CONSOLEBUF_SIZE是控制台缓冲区大小,默认 128,如果打印长字符串会截断,可以调到 256。
RT_USING_FINSH是 FinSH 的总开关,开了之后下面还有FINSH_USING_MSH、FINSH_USING_MSH_ONLY等子选项。建议先只开 msh 模式,也就是把FINSH_USING_MSH_ONLY打开,这样 FinSH 只解析命令名和参数,不解析 C 表达式,代码体积小很多,调试够用了。等你熟悉了再考虑开完整模式。
3. 内核移植的核心环节拆解
3.1 SysTick 与 HAL 时基的冲突处理
这是移植路上第一个大坑,也是最多人卡住的地方。HAL 库默认用 SysTick 中断来做HAL_Delay的时基,中断里调用HAL_IncTick。而 RT-Thread 也要用 SysTick 做系统 tick,中断里调用rt_tick_increase。两个都想独占 SysTick,直接冲突。
标准解法是:把 HAL 的时基从 SysTick 挪到另一个定时器上。CubeMX 里在 SYS 配置页把 Timebase Source 从 SysTick 改成 TIM1 或其他空闲定时器。这样 SysTick 就空出来给 RT-Thread 用。改完之后 CubeMX 会自动生成stm32f4xx_hal_timebase_tim.c,里面用 TIM1 中断调HAL_IncTick。
然后在board.c里实现 RT-Thread 的 tick 配置。nano 版本的board.c里有个rt_hw_board_init函数,里面要调用SysTick_Config把 SysTick 配成 1ms 中断一次,中断服务函数里调rt_tick_increase。具体来说,SysTick_Config(SystemCoreClock / RT_TICK_PER_SECOND)这一句就搞定配置,然后在SysTick_Handler里写rt_tick_increase()。
这里有个细节:SysTick_Handler这个名字在stm32f4xx_it.c里已经有一个弱定义了,你需要在board.c里重新实现它,或者把stm32f4xx_it.c里的那个删掉。我习惯在board.c里直接写,然后确保stm32f4xx_it.c里不再重复定义,否则链接会报重定义错误。
3.2 堆初始化与内存布局
RT_USING_HEAP开了之后,必须给内核指定一块堆内存。在board.c的rt_hw_board_init里,用rt_system_heap_init来初始化。典型写法是传两个地址:堆起始地址和结束地址。
堆从哪来?两种方式。一种是直接用数组,在board.c里定义一个static rt_uint8_t heap[HEAP_SIZE],然后把数组首尾地址传进去。这种方式简单,但数组占的是静态 RAM,编译时就要确定大小。另一种是利用链接器符号,把未被使用的 RAM 区域划给堆。F429 的 RAM 分布比较复杂,有 112KB 的 SRAM1、16KB 的 SRAM2、64KB 的 CCM,还有备份 SRAM。一般把堆放在主 SRAM 里,大小给个 16KB 到 32KB 就够 nano 用了。
我实测下来,如果只跑几个线程加 FinSH,堆给 8KB 也能转,但留点余量稳妥。用数组方式的话,HEAP_SIZE定义成 16*1024,放在board.c顶部。注意这个数组别被编译器优化掉,加个static就行,链接器会保留。
注意:堆的起始地址要做 4 字节对齐,
rt_system_heap_init内部会检查,不对齐会断言失败。数组天然对齐,用链接器符号的话要手动RT_ALIGN一下。
3.3 中断优先级与 PendSV 配置
Cortex-M4 的中断优先级寄存器是 8 位的,但 STM32 只用了高 4 位,所以实际可配的优先级是 0 到 15,数值越小优先级越高。RT-Thread 需要用到 PendSV 异常来做上下文切换,它的优先级必须设成最低,也就是 15,这样它不会打断其他中断。
在board.c里,rt_hw_board_init中要配置 NVIC 的优先级分组。HAL 库默认用NVIC_PRIORITYGROUP_4,也就是 4 位全给抢占优先级,没有子优先级。这个分组下,PendSV 和 SysTick 的优先级都设成 15 就行。具体代码是HAL_NVIC_SetPriority(PendSV_IRQn, 15, 0)和HAL_NVIC_SetPriority(SysTick_IRQn, 15, 0)。
为什么 SysTick 也设 15?因为 SysTick 中断里只做 tick 累加,不涉及调度决策,设成最低优先级可以保证它不打断其他外设中断。真正触发调度的是 PendSV,它在所有中断处理完之后才执行,这是 Cortex-M 的标准玩法。
还有个容易忽略的点:在中断服务函数里调用 RT-Thread 的 API 时,必须用rt_interrupt_enter和rt_interrupt_leave包起来,否则内核无法正确判断当前是否在中断上下文,可能导致调度异常。HAL 库的中断处理函数里如果调用了 RTOS API,记得加上这两个调用。
4. FinSH 控制台的接入与调试
4.1 串口设备对接
FinSH 要工作,底层得有一个能收发的串口。nano 版本没有设备驱动框架,所以不能像完整版那样用rt_device接口。你需要自己实现两个函数:一个用于输出字符,一个用于获取输入字符,然后在rtconfig.h里通过宏告诉 FinSH 用哪个函数。
具体来说,在board.c里实现rt_hw_console_output,里面调用 HAL 的HAL_UART_Transmit把字符串发出去。这个函数会被rt_kprintf调用,所以 FinSH 的所有输出都走这里。注意这个函数可能在中断上下文被调用,所以别在里面做阻塞操作,用带超时的发送或者直接查寄存器发送都行。
输入这块,FinSH 的 msh 模式需要一个字符输入接口。在shell.c里有个rt_hw_console_getchar的弱定义,你需要在board.c里重新实现它,用HAL_UART_Receive读一个字符返回。如果没收到数据就返回 -1。这个函数会被 FinSH 的线程轮询调用,所以不能阻塞。
提示:
rt_hw_console_getchar里如果用HAL_UART_Receive带超时,超时设 0 就是非阻塞查询。但 HAL 库的接收函数在超时 0 的情况下行为有点微妙,更稳妥的做法是直接读 USART 的 DR 寄存器判断 RXNE 标志位,这样最直接。
4.2 FinSH 线程的启动
FinSH 在 nano 里是以一个独立线程的形式运行的。在shell.c里有个finsh_system_init函数,它会创建 FinSH 线程。这个函数需要通过INIT_APP_EXPORT或者手动调用来执行。nano 版本没有自动初始化机制,所以你得在main函数里手动调用finsh_system_init(),或者在rt_hw_board_init之后调用。
调用时机很关键:必须在rt_system_scheduler_start之前,也就是调度器启动之前完成 FinSH 线程的创建。因为调度器一旦启动,main 函数就变成了一个普通线程,那时候再创建 FinSH 线程虽然也行,但初始化顺序会乱。我习惯在main函数里,rt_hw_board_init之后、rt_system_scheduler_start之前,把 FinSH 初始化掉。
FinSH 线程的优先级和栈大小在rtconfig.h里配。FINSH_THREAD_PRIORITY默认是 20,FINSH_THREAD_STACK_SIZE默认 1024。如果 msh 命令里要调用的函数栈开销大,栈要加大,比如调到 2048。优先级别设太高,20 左右合适,保证它不抢占关键任务。
4.3 msh 命令的导出与自定义
FinSH 最实用的功能就是能自定义命令。在 nano 的 msh 模式下,用MSH_CMD_EXPORT宏就能把一个函数导出成命令。比如你写一个void led_on(void),然后MSH_CMD_EXPORT(led_on, turn on led),编译下载后,串口里敲led_on就能执行。
这个宏的原理是把函数指针和命令名放到一个特定的链接段里,FinSH 启动时遍历这个段把所有命令注册进来。所以命令的注册是自动的,不需要手动维护列表。但要注意,导出的函数签名必须是void func(void)或者void func(int argc, char **argv),前者用于无参命令,后者用于带参命令。
带参命令里,argc是参数个数(包含命令名本身),argv是参数字符串数组。比如你导出void test(int argc, char **argv),敲test 123 abc,那么argc是 3,argv[0]是 "test",argv[1]是 "123",argv[2]是 "abc"。参数解析要自己写,用atoi转数字,用strcmp比较字符串。
我一般会导出几个调试命令:一个查看线程状态(调rt_thread_dump或者自己遍历线程链表),一个查看堆使用情况(调rt_memory_info),一个控制 LED 或继电器。这几个命令在调试时非常顺手,比反复改代码重新下载快得多。
5. 常见问题排查与避坑经验
5.1 编译链接阶段的典型报错
移植过程中编译报错是家常便饭,我整理了几个高频的:
| 报错信息 | 原因 | 解决方式 |
|---|---|---|
undefined symbol rt_interrupt_enter | 没把irq.c加入工程 | 把src/irq.c加到工程分组 |
undefined symbol rt_hw_console_output | 没实现控制台输出函数 | 在board.c里实现该函数 |
multiply defined SysTick_Handler | board.c和stm32f4xx_it.c都定义了 | 删掉stm32f4xx_it.c里的定义 |
undefined symbol _rt_thread_stack | 启动文件没适配 | 检查启动文件是否用了 RT-Thread 提供的版本 |
region RAM overflowed | 堆数组太大 | 减小HEAP_SIZE或优化其他 RAM 占用 |
其中SysTick_Handler重定义这个坑最典型。CubeMX 生成的stm32f4xx_it.c里默认有SysTick_Handler,里面调HAL_IncTick。你把 HAL 时基改成 TIM1 之后,这个函数其实没用了,但 CubeMX 不会自动删。你得手动把它注释掉或者删掉,然后在board.c里写自己的SysTick_Handler。
5.2 运行时卡死与调度异常
编译过了不代表能跑。最常见的运行时问题是:程序下载后卡在某个地方不动,串口没输出。排查思路是这样的:
先确认rt_hw_board_init有没有被调用。如果main函数里第一句就是rt_hw_board_init,那在它之前加个点灯或者串口打印,确认程序确实进了 main。如果连 main 都没进,那就是启动文件或者时钟配置的问题。
如果进了rt_hw_board_init但卡在里面,多半是堆初始化或者 SysTick 配置的问题。rt_system_heap_init如果传了非法地址会断言失败,断言里会调rt_kprintf,如果控制台还没初始化,就死循环了。所以堆初始化要放在控制台初始化之前,这样断言信息能打出来。
如果调度器启动后卡死,检查线程栈大小。nano 默认的 idle 线程栈是 128 字节,main 线程栈是 512 字节。如果 main 线程里调用了 FinSH 初始化,栈可能不够,会栈溢出。栈溢出在 Cortex-M 上表现为 HardFault,或者直接跑飞。把RT_MAIN_THREAD_STACK_SIZE调到 1024 或 2048 试试。
5.3 FinSH 无响应或乱码
FinSH 敲命令没反应,或者输出乱码,通常是这几个原因:
串口波特率不匹配。CubeMX 里配的波特率和串口终端软件里设的要一致,115200 是默认值。如果晶振频率填错了,实际波特率会偏,导致乱码。用示波器量一下 TX 引脚或者换个波特率试试。
rt_hw_console_getchar实现有问题。如果这个函数一直返回 -1,FinSH 就收不到输入。检查 USART 的 RX 引脚配置、中断使能、以及函数里读寄存器的逻辑。我遇到过因为没开 USART 的接收使能(USART_CR1_RE位),导致 RXNE 永远不置位的情况。
FinSH 线程没启动。确认finsh_system_init被调用了,而且是在调度器启动之前。如果调度器已经启动,main 变成了线程,这时候再调finsh_system_init也能创建线程,但可能因为优先级问题一直得不到调度。检查 FinSH 线程的优先级是不是被其他高优先级线程压住了。
实操心得:调试 FinSH 的时候,先在
rt_hw_console_output里直接发一个固定字符串,确认串口输出通路是通的。然后再调rt_kprintf打印版本号,确认内核的打印接口正常。最后才测 FinSH 的输入。分层排查,别一上来就怀疑 FinSH 本身。
5.4 内存与栈的监控技巧
RTOS 跑起来之后,内存和栈的使用情况要心里有数。nano 提供了rt_memory_info接口,能查堆的总大小、已用大小、最大可用块。在 FinSH 里导出一个命令调它,随时能看。
栈的监控稍微麻烦点。nano 没有完整版的栈检查功能,但可以在创建线程时把栈填一个特定值(比如 0xCC),运行一段时间后看栈底有多少字节被覆盖了,估算栈的实际使用量。或者用rt_thread_stack_check如果版本支持的话。我一般是在调试阶段把栈开大一点,稳定之后再逐步缩小,找到一个安全余量。
还有个细节:F429 有 CCM RAM,这块内存不能通过 DMA 访问,但 CPU 访问速度最快。如果把线程栈放在 CCM 里,能提升一点性能。但要注意堆和栈别混用 CCM 和普通 SRAM,否则 DMA 传输会出问题。简单起见,堆和栈都放普通 SRAM,CCM 留给对性能敏感的数据。
6. 源码结构与后续扩展方向
6.1 工程目录组织建议
移植完成之后,工程目录最好整理一下,方便后续维护。我的习惯是这样分的:
Project/ ├── Core/ │ ├── Inc/ │ └── Src/ │ ├── main.c │ ├── stm32f4xx_it.c │ └── ... ├── Drivers/ │ ├── CMSIS/ │ └── STM32F4xx_HAL_Driver/ ├── Middlewares/ │ └── RT-Thread/ │ ├── src/ │ ├── include/ │ ├── bsp/ │ └── components/ │ └── finsh/ └── User/ ├── app_task.c └── app_task.hUser目录放自己的应用代码,和内核、HAL 库分开。这样升级 RT-Thread 版本或者换 HAL 库版本时,自己的代码不受影响。board.c和rtconfig.h放在bsp下,这两个文件是移植的核心,改动最多,单独放一处方便查找。
6.2 从 nano 到完整版的迁移路径
nano 用熟了之后,如果项目需要文件系统、网络协议栈、设备驱动框架这些,就得考虑迁移到完整版 RT-Thread。迁移不是推倒重来,nano 的内核 API 和完整版是兼容的,线程、信号量、消息队列这些用法完全一样。
主要差别在于:完整版有自动初始化机制(INIT_BOARD_EXPORT、INIT_DEVICE_EXPORT等),设备驱动用rt_device统一接口,组件通过 menuconfig 配置。迁移时,board.c里的硬件初始化要改成用INIT_BOARD_EXPORT导出,串口要注册成rt_device而不是直接调 HAL。FinSH 的接入方式也变成通过设备框架,不用自己实现rt_hw_console_getchar了。
但如果你只是想要一个轻量内核加命令行调试,nano 完全够用,没必要为了完整版而完整版。nano 的代码量小,出问题容易定位,对 Flash 和 RAM 的占用也少,在很多中小型项目里反而是更务实的选择。
6.3 几个值得尝试的扩展
这套基础工程搭好之后,可以往上加东西。比如把 FinSH 的命令和实际硬件绑定,导出pwm_set、adc_read这类命令,调试硬件时不用改代码。再比如用 RT-Thread 的软件定时器做一个周期任务,配合 FinSH 命令动态调整周期,测试不同负载下的系统表现。
还可以把rt_kprintf重定向到 LCD 或者 RTT(Real-Time Transfer,一种调试输出方式),这样不占串口也能看日志。RTT 的移植在 nano 上也不复杂,实现一个rt_hw_console_output的变体就行。
如果项目对实时性要求高,可以研究一下 nano 的调度器配置,比如把RT_THREAD_PRIORITY_MAX调大,用更细的优先级划分。或者开RT_USING_TIMER_SOFT,把软件定时器放到独立线程里跑,避免在中断上下文做耗时操作。
我个人在实际操作中的体会是,nano 的移植难点不在内核本身,而在板级适配的那些琐碎细节——时钟、中断优先级、堆布局、控制台对接。这些环节任何一个出问题,现象都是"跑不起来",但原因各不相同。所以移植时一定要分层验证,每接一个环节就测一下,别等全部接完再调。串口输出是最可靠的调试手段,rt_kprintf能打出来,就说明内核基本活了,剩下的都是外围问题。最后再分享一个小技巧:把rtconfig.h里的RT_DEBUG打开,内核会在关键路径做断言检查,虽然增加一点开销,但能在早期发现很多配置错误,等稳定后再关掉。