ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

APM32F103C8T6标准库开发与RT-Thread nano移植实践

2026/9/4 8:25:51 拓冰建站 浏览量
APM32F103C8T6标准库开发与RT-Thread nano移植实践 从一枚国产 Cortex-M3 芯片开始APM32F103C8T6 标准库开发与 RT-Thread nano 移植全流程如果你是一个刚接触嵌入式开发的工程师或学生最近很可能被一个名字刷过屏APM32F103C8T6。它为什么火一个重要原因是它的引脚、外设和内存布局与 STM32F103C8T6 高度相近很多开发者手里已经积累了大量基于标准库的 STM32 工程。拿到 APM32F103C8T6 之后最自然的想法是能不能把现有代码直接迁移过来能不能在这颗国产 MCU 上把 RT-Thread nano 也跑起来这篇文章要给出一个明确判断APM32F103C8T6 完全可以采用标准库方式开发也完全可以移植 RT-Thread nano而且整个过程的思路和你熟悉的 STM32 标准库开发基本一致但细节上有一批必须处理的差异。读完这篇文章你能够解决以下问题APM32F103C8T6 和 STM32F103C8T6 到底像不像差异集中在哪些环节。如何用极海官方标准库搭建一个最小工程完成点灯和串口打印。如何在这个标准库工程基础上移植 RT-Thread nano并让多线程任务正常跑起来。遇到编译、链接、优先级配置、系统节拍等问题时按什么顺序排查。1. 先搞清楚APM32F103C8T6 到底是什么1.1 它是一颗什么样的芯片APM32F103C8T6 是极海半导体Geehy推出的一款基于 Arm Cortex-M3 内核的 32 位通用微控制器。它的中文资料里经常这么描述APM32F103 系列基于 32 位 ARM Cortex-M3 内核最高主频 96MHz拥有 64KB Flash 和 20KB SRAM。这个组合对于很多轻量级嵌入式项目来说是够用的。比如环境监测节点需要读取传感器、通过串口或 Modbus 上报数据。电机控制板需要输出 PWM、采集电流和位置反馈。小尺寸物联网网关负责协议转换、数据缓存和断网重传。学习板用于练习裸机外设驱动和 RTOS 移植。之所以强调“国产”“替代”这些关键词不是出于情怀而是出于供应链和项目维护的实际考虑。当一颗芯片缺货、价格上涨、交期不确定时工程上最核心的问题不是“支不支持国货”而是“备选方案能不能快速落地”。APM32F103C8T6 能成为项目备选方案靠的不是口号而是它在外设库、内存布局、引脚定义上给出的兼容性。不过这里必须把话说准确APM32F103C8T6 是基于 APM32F103 系列的独立产品不是 STM32 的克隆。极海有自己的数据手册、参考手册、标准外设库和启动文件。软件生态上可以参照 STM32 的工程习惯但工程构建时应该以极海官方资料为准。1.2 它与 STM32F103C8T6 的关键对比对比维度APM32F103C8T6STM32F103C8T6内核ARM Cortex-M3ARM Cortex-M3最高主频96MHz72MHzFlash64KB64KBSRAM20KB20KB引脚封装LQFP48LQFP48片上外设USART/SPI/I2C/ADC/Timer 等USART/SPI/I2C/ADC/Timer 等官方软件库极海标准外设库ST 标准外设库/LL/HAL烧录工具J-Link/极海烧录工具/串口 ISPJ-Link/ST-Link/串口 ISP这个对比表想说明两件事第一硬件资源基本在同一档次项目选型时不用因为内存太小而重新设计架构。第二软件工程不能直接无脑套用 STM32 的库文件。正确姿势是以极海标准库的启动文件、头文件和外设驱动文件为基础迁移自己的应用层逻辑。1.3 标准库的价值为什么依然很大现在很多教程推荐 HAL 库因为它抽象层次高、图形化配置方便。但在 APM32F103C8T6 这类资源并不算丰富的 MCU 上标准库仍然有独特价值代码更接近寄存器操作外设初始化流程清晰。你会知道 GPIOB_CRL 配置的是什么USART_BRR 分频是如何计算出来的。编译产物体积相对可控。对于 64KB Flash 的芯片这是很实际的约束。很多老工程师的协议栈、MODBUS 移植代码、低层驱动都是基于标准库写的。用标准库可以降低迁移成本。所以这篇教程选择的标准库路线并不是过时做法而是贴合这颗芯片实际工程需求的选择。2. RT-Thread nano 是什么为什么适合入门2.1 RT-Thread nano 与完整版 RT-Thread 的区别RT-Thread 是一个国产开源实时操作系统RT-Thread nano 是它的轻量版本。用一句话概括nano 版保留了 RTOS 最核心的线程调度、信号量、互斥量、消息队列、定时器和内存管理功能但裁剪掉了设备驱动框架、虚拟文件系统、网络协议栈等重量级组件。对 APM32F103C8T6 这样只有 20KB SRAM 的芯片来说nano 版是非常合适的选择。它解决的核心痛点是如果你有多个需要周期性执行的任务比如 10ms 采集一次按键、50ms 刷新一次 OLED、200ms 读一次传感器、1s 上报一次数据裸机主循环配一个状态机当然可以写但代码越写越复杂。任务越多状态流转越混乱一个任务卡住会影响其他任务。引入 RT-Thread nano 后每个独立功能都可以用一个线程表达。调度器按照优先级和时间片决定谁在什么时候运行。这会让代码的思考方式从“全局大循环 状态变量”转变成“多个独立线程 通信机制”。2.2 为什么用 nano 而不是自己写调度器初学 RTOS 时有人会想“嵌入式系统不就是一个 while(1) 加定时器中断吗我自己写个调度器行不行”可以。但从工程角度看自己写调度器要处理的问题非常多比如任务栈分配、现场保存与恢复、优先级就绪表、临界区保护、时钟节拍推进等。要写到一个能稳定用于项目的状态需要相当强的功底和反复测试。RT-Thread nano 把这些基础能力封装好了。移植时你需要做的主要工作是提供系统节拍通常用一个硬件定时器中断周期性调用 rt_tick_increase()。正确配置中断线程切换依赖 PendSV 和 SysTick 或普通定时器。调整启动文件和链接脚本确保向量表、栈、堆设置符合芯片实际。移植完成后应用层只需要创建线程、启动调度器。这也正是这篇文章后半部分的实操重心。2.3 RTOS 引入后带来的新约束引入 RTOS 不是零成本的。你以为的免费午餐其实要付出的代价包括每个线程都需要独立的栈空间。APM32F103C8T6 的 SRAM 只有 20KB不能随手给每个线程分配 8KB 栈。临界区处理不当会引发资源竞争。多个线程访问同一个串口外设时如果不用互斥量保护打印内容会互相穿插。中断服务函数里不宜调用阻塞型 API。ISR 中只能调用rt_sem_release这类从中断安全版本接口不能调用rt_thread_mdelay。任务的调度顺序受优先级影响。优先级设置错误会让某个任务永远得不到执行机会也就是优先级翻转或饿死问题。这一点和裸机开发完全不同。裸机中你写的每个函数都直接顺序执行但 RTOS 环境下代码会在线程上下文和中断上下文中交替运行。习惯这种思维转换是移植 RTOS 后第一道坎。3. 环境准备与工程目录规划3.1 开发环境版本建议本文涉及的软件工具较多先说明版本选择原则以下工具版本均为本人在文章写作时间点使用的稳定版本读者实际使用时如果遇到版本差异优先阅读对应工具自带的迁移说明不要机械照搬。工具/组件本文使用版本说明Keil MDK5.x支持 APM32F103 系列器件包APM32F10x SDK极海官方标准库包含标准外设库、启动文件、示例工程RT-Thread nano3.x 或 4.x轻量 RTOS 内核源码可直接加入工程调试器J-Link 或 DAP-Link用于烧录和调试APM32 器件支持包需要在 Keil Pack Installer 中安装否则会报找不到芯片等错误3.2 安装极海官方器件包如果 Keil 里看不到 APM32F103C8T6工程无法创建。打开 Keil MDK 的 Pack Installer在搜索框中输入 APM32找到极海官方 PACK 包并安装。也有用户习惯从极海官网下载 SDK解压后手工添加器件支持信息。无论哪种方式最终目标是在 Keil 的 Device 选择框中能找到 APM32F103C8T6。这一步最容易踩的坑是先创建了一个 STM32F103C8T6 工程然后尝试把 APM32 的标准库文件硬塞进去。这个做法虽然有时能编译通过但启动文件、系统时钟初始化代码和外设寄存器定义都来自不同的芯片体系一旦遇到 APM32 与 STM32 的差异排错成本非常高。更稳妥的路径是直接在极海 SDK 的示例工程上修改或者创建一个全新的 APM32 工程再引入标准库文件。原因是官方示例工程已经把芯片型号、启动文件、系统时钟配置、链接分散文件都对齐了你只需要关注应用层。3.3 官方标准库工程结构说明极海官方 SDK 的工程目录通常包含APM32F10x_SDK/ |-- Libraries/ | |-- APM32F10x_StdPeriphDriver/ | | |-- inc/ # 外设驱动头文件 | | -- src/ # 外设驱动源文件 | -- CMSIS/ | |-- CM3/ | | |-- CoreSupport/ | | -- DeviceSupport/ |-- Examples/ | |-- GPIO/ | |-- USART/ | -- ADC/ |-- Project/ | -- MDK-ARM/ -- Utilities/这套结构和 ST 标准库非常相似如果你熟悉 STM32 标准库看到GPIO_ConfigPin、USART_Config这类接口会感到眼熟。需要特别注意的是极海标准库的函数命名和 ST 标准库存在一些后缀和参数差异。比如 STM32 标准库中的GPIO_InitTypeDef在极海库中也可能定义为GPIO_Config_T具体名称以实际 SDK 头文件为准。因此不建议直接引用旧 STM32 工程里的.c文件来编译 APM32 工程。比较合理的方式是把原来 STM32 工程的应用层 main.c 逻辑迁移过来外设初始化部分的代码按极海标准库的 API 重写。3.4 RT-Thread nano 源码如何获取与添加RT-Thread nano 源码可以从 RT-Thread 官方 GitHub 仓库获取也可以下载 RT-Thread 官方提供的 nano 版本压缩包。它包含的关键路径有rt-thread/src/内核源码比如 scheduler.c、thread.c、timer.c、ipc.c。rt-thread/libcpu/与 CPU 架构相关的移植代码主要是 Cortex-M3 的上下文切换实现。rt-thread/include/内核头文件。rt-thread/components/nano 相关的组件比如 FinSH一个命令行调试组件。移植到 APM32F103C8T6 时只需要关注 Cortex-M3 架构的移植文件和 libcpu 目录下的 startup 相关文件其他与芯片无关的内容不需要全部加入工程。4. 最小标准库工程创建点灯和串口打印在移植 RTOS 之前先把裸机标准库工程跑通。这是一个很有必要的中间节点因为如果裸机串口都无法输出数据后面 RTOS 调试时连打印日志的依赖都没有定位问题会非常困难。4.1 新建 Keil 工程并选择芯片打开 Keil MDK在菜单栏选择 Project - New uVision Project保存工程后在 Device 选择框中搜索 APM32F103C8T6。确认选中后Keil 会自动添加启动文件不过为保险起见还是要检查启动文件是否来自极海 SDK而不是 ST 的 startup_stm32f10x_md.s。新建工程时需要勾选 CMSIS 核心相关文件具体选项在 Manage Run-Time Environment 中进行。由于我们使用标准库不需要 HAL 层组件。4.2 添加标准库文件到工程一个 APM32F103C8T6 的最小工程至少需要包含以下文件启动文件startup_apm32f10x_md.s系统配置文件system_apm32f10x.c和对应头文件外设驱动源码需要用到哪个外设就添加对应的.c文件主函数与中断服务函数所在的 user 代码对于点灯和串口打印需要引入的极海标准库文件包括但不限于APM32F10x_GPIO.c APM32F10x_USART.c APM32F10x_RCM.c APM32F10x_MISC.c这里需要说明APM32F10x 标准库中系统时钟控制器的命名可能缩写为 RCM而不是 ST 标准库中的 RCC。这是极海库的一个明显命名差异代码示例中如果出现RCM_EnableAPB2PeriphClock不要觉得奇怪。4.3 头文件包含路径设置在 Keil 中点击魔法棒 - C/C - Include Paths添加以下路径Libraries\APM32F10x_StdPeriphDriver\inc Libraries\CMSIS\CM3\DeviceSupport Libraries\CMSIS\CM3\CoreSupport User随后在 C/C 的 Define 输入框中添加USE_STDPERIPH_DRIVER APM32F10X_MD如果不定义APM32F10X_MD很多外设驱动代码会根据器件型号条件编译对应寄存器定义可能引发编译错误。同时这里为了演示方便直接把输出目录配置为.\Output生成文件便于查看。4.4 标准库点灯代码实现下面给出一段完整的最小工程代码功能是初始化 PC13 引脚为推挽输出然后控制 LED 闪烁。这段代码放在工程 User 分组中的 main.c 里。// 文件路径User/main.c #include apm32f10x.h void Delay_Soft(uint32_t count) { uint32_t i; for (i 0; i count; i) { } } int main(void) { GPIO_Config_T gpioConfig; RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_GPIOC); gpioConfig.mode GPIO_MODE_OUT_PP; gpioConfig.speed GPIO_SPEED_50MHz; GPIO_Config(GPIOC, gpioConfig); while (1) { GPIO_SetBit(GPIOC, GPIO_PIN_13); Delay_Soft(2000000); GPIO_ClearBit(GPIOC, GPIO_PIN_13); Delay_Soft(2000000); } }代码解释头文件apm32f10x.h是极海标准库的统一入口它向开发者暴露了所有外设寄存器定义和函数声明。RCM_EnableAPB2PeriphClock对应 STM32 标准库中的RCC_APB2PeriphClockCmd用于开启 GPIOC 的时钟。APM32 芯片外设挂在 APB2 总线上时必须先使能对应总线时钟这是所有 ARM 内核 MCU 的通用做法。GPIO_Config_T是极海库中的配置结构体字段包括mode、speed、pin。GPIO_Config(GPIOC, gpioConfig)表示把 GPIOC 这一组端口所有引脚配置为输出模式因此后面操作 PIN_13 才生效。需要更严谨的话可以在结构体中指定pin GPIO_PIN_13。GPIO_SetBit和GPIO_ClearBit分别用于置位和清零一个引脚。这个例子用的是软件延时足够点亮 LED。真实的工程不建议用浪费 CPU 的软件延时不过裸机入门阶段它有助于看清代码流程。如果你的屏幕显示不亮第一步先确认两件事PC13 是否连接 LED 正极LED 的另一端是否通过限流电阻连接到地。如果 LED 接法相反需要将置位和清零的逻辑反过来也就是低电平点亮时先 ClearBit。4.5 串口打印初始化代码点灯只能说明 GPIO 能工作串口则会把调试体验提升一大截。把串口初始化提前到裸机阶段是为了验证串口芯片和程序逻辑是否正常。下面把 USART1 的发送函数补全。// 文件路径User/uart_driver.c #include apm32f10x.h void UART1_Init(uint32_t baudrate) { GPIO_Config_T gpioConfig; USART_Config_T usartConfig; RCM_EnableAPB2PeriphClock(RCM_APB2_PERIPH_USART1 | RCM_APB2_PERIPH_GPIOA); gpioConfig.mode GPIO_MODE_AF_PP; gpioConfig.speed GPIO_SPEED_50MHz; gpioConfig.pin GPIO_PIN_9; GPIO_Config(GPIOA, gpioConfig); gpioConfig.mode GPIO_MODE_IN_FLOATING; gpioConfig.pin GPIO_PIN_10; GPIO_Config(GPIOA, gpioConfig); usartConfig.baudRate baudrate; usartConfig.wordLength USART_WORD_LEN_8B; usartConfig.stopBits USART_STOP_BIT_1; usartConfig.parity USART_PARITY_NONE; usartConfig.mode USART_MODE_TX_RX; USART_Config(USART1, usartConfig); USART_Enable(USART1); } void UART1_SendByte(uint8_t ch) { while (USART_ReadStatusFlag(USART1, USART_FLAG_TXBE) RESET) { } USART_TxData(USART1, ch); } void UART1_SendString(char *str) { while (*str) { UART1_SendByte(*str); } }在 main.c 中调用int main(void) { UART1_Init(115200); UART1_SendString(APM32F103C8T6 USART Test\r\n); while (1) { UART1_SendString(Hello APM32\r\n); Delay_Soft(2000000); } }打开串口助手波特率设置为 115200如果能看到APM32F103C8T6 USART Test和Hello APM32说明芯片、标准库、串口驱动链路全部正常。很多初学者在串口看不到字第一反应是代码有问题实际上更常见的坑包括USB 转串口线接到了 PA9/PA10 之外的引脚、共地未连接、波特率不一致、串口助手的 DTR 选项影响了复位。5. 移植 RT-Thread nano 到 APM32F103C8T6裸机工程跑通后移植 RT-Thread nano 的核心工作就是两项把内核源码加入工程以及适配芯片的 SysTick 和 PendSV。下面逐步展开。5.1 向工程中添加 RT-Thread nano 源码创建一个新的源码分组比如命名为 RT-Thread Kernel然后添加以下文件rt-thread/src/clock.crt-thread/src/ipc.crt-thread/src/irq.crt-thread/src/kservice.crt-thread/src/mem.crt-thread/src/object.crt-thread/src/scheduler.crt-thread/src/thread.crt-thread/src/timer.crt-thread/libcpu/arm/cortex-m3/context_rvds.Srt-thread/libcpu/arm/cortex-m3/cpuport.c头文件包含路径需要增加rt-thread/include rt-thread/libcpu/arm/cortex-m3这里需要特别强调context_rvds.S是针对 ARM 编译器 RealView 的汇编文件对应 Keil MDK 的编译环境。如果你用的是 IAR就要选择context_iar.S如果用 GCC就要选择context_gcc.S。选错文件会导致编译报错。5.2 配置 rtconfig.h在 RT-Thread 的工程中rtconfig.h是内核配置的“总开关”。你可以在 RT-Thread 源码目录下找到这个文件模板也可以自己创建。下面给出一个适合 APM32F103C8T6 的最小配置。// 文件路径User/rtconfig.h #ifndef RT_CONFIG_H__ #define RT_CONFIG_H__ #define RT_ALIGN_SIZE 4 #define RT_THREAD_PRIORITY_MAX 32 #define RT_TICK_PER_SECOND 1000 #define RT_USING_TIMER_SOFT 0 #define RT_USING_SEMAPHORE #define RT_USING_MUTEX #define RT_USING_EVENT #define RT_USING_MAILBOX #define RT_USING_MESSAGEQUEUE #define RT_USING_CONSOLE #define RT_CONSOLEBUF_SIZE 128 #define RT_PRINTF 1 #define RT_USING_HEAP #endif重点解释几个配置RT_TICK_PER_SECOND系统节拍每秒次数设置为 1000 表示 1ms 一个 tick。Tick 越密调度精度越高但内核在定时器中断里消耗的 CPU 时间也越多。本文使用 1ms 是为了让rt_thread_mdelay(10)能表达 10ms 这类较短的延时。RT_THREAD_PRIORITY_MAX最大优先级数量RT-Thread 中数字越小优先级越高。最多支持 256 个优先级但在小型 MCU 上 32 已经足够。RT_USING_HEAP启用堆内存管理。启用后可以使用rt_malloc、rt_free动态分配内存但会用掉一部分 SRAM。如果不需要动态内存可以关闭。RT_USING_CONSOLE启用内核命令行打印便于用rt_kprintf输出调试信息。nano 版默认支持rt_kprintf接收一个底层串口输出回调。5.3 面向 APM32 的裸机函数适配RT-Thread nano 默认认为底层已经有了一个系统滴答定时器。这个滴答可以由 Cortex-M3 的 SysTick 定时器提供。在工程中添加一个board.c文件实现以下函数// 文件路径User/board.c #include board.h #include apm32f10x.h static uint32_t _SysTick_Config(uint32_t ticks) { SysTick-LOAD ticks - 1; SysTick-VAL 0; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_TICKINT_Msk | SysTick_CTRL_ENABLE_Msk; return 0; } void SysTick_Handler(void) { rt_tick_increase(); }同时还需要实现rt_hw_board_init该函数在 RT-Thread 启动阶段被调用通常在 main 函数之前执行。void rt_hw_board_init(void) { _SysTick_Config(SystemCoreClock / RT_TICK_PER_SECOND); }这里SystemCoreClock是系统时钟频率。APM32F103C8T6 最高可运行在 96MHz但具体频率取决于工程里的时钟初始化配置。如果直接使用极海默认提供的系统配置通常在 main 之前会初始化 HSI 或 HSE并把 PLL 配好。rt_hw_board_init中使用的SystemCoreClock应该和启动文件初始化后的时钟一致否则系统节拍时间会不对。如果串口作为内核控制台还需要实现字符输出接口。RT-Thread nano 通常会调用rt_hw_console_output函数输出字符。这个函数可以简单指向我们在裸机阶段写好的串口驱动void rt_hw_console_output(const char *str) { while (*str) { UART1_SendByte(*str); } }注意rt_hw_console_output是一个弱符号函数是否必须实现取决于内核版本中如何引用它。更稳妥的做法是在rt_hw_board_init中将串口初始化一并调用void rt_hw_board_init(void) { SystemCoreClockUpdate(); _SysTick_Config(SystemCoreClock / RT_TICK_PER_SECOND); UART1_Init(115200); }5.4 PendSV 与 SysTick 中断问题RT-Thread nano 移植到 Cortex-M3 时调度器依赖两个核心机制系统节拍中断驱动时间片轮转和定时器任务由 SysTick 产生。它对应的中断服务函数是SysTick_Handler。上下文切换当线程被创建或调度器切换线程时CPU 会进入 PendSV 异常在那里完成当前线程现场压栈和下一个线程现场恢复。PendSV 的中断服务函数由内核实现在context_rvds.S中通过PendSV_Handler导出。极海 SDK 的启动文件里已经定义了PendSV_Handler、SysTick_Handler这些中断向量。RT-Thread 的汇编文件也会定义同名的PendSV_Handler。如果工程里出现两个同名符号链接时会报重复定义错误。解决办法是在启动文件中把 RTOS 使用的中断向量改成弱符号或者注释掉启动文件中对应的 Handler确保只有一个定义。对于 SysTick_Handler由于是我们在 board.c 中自己实现启动文件里的 SysTick_Handler 通常会编译为空那么就不会和我们的定义冲突。5.5 线程入口函数创建为了验证多线程调度是否成功创建两个测试线程。第一个线程用来周期性点亮和熄灭 LED第二个线程周期性向串口打印日期。#include rtthread.h static rt_thread_t led_thread RT_NULL; static rt_thread_t dbg_thread RT_NULL; static void led_thread_entry(void *parameter) { (void)parameter; while (1) { GPIO_SetBit(GPIOC, GPIO_PIN_13); rt_thread_mdelay(500); GPIO_ClearBit(GPIOC, GPIO_PIN_13); rt_thread_mdelay(500); } } static void dbg_thread_entry(void *parameter) { (void)parameter; while (1) { rt_kprintf(debug thread running, tick%u\r\n, (unsigned int)rt_tick_get()); rt_thread_mdelay(1000); } }两个线程的栈空间不能太小也不能浪费太多。RT-Thread 的默认最小线程栈是 256 字节但在 APM32F103C8T6 上建议给打印类线程分配 512 到 1024 字节否则调用rt_kprintf时可能因栈溢出导致系统异常。在 main 函数中创建线程并启动调度器int main(void) { led_thread rt_thread_create(led, led_thread_entry, RT_NULL, 512, 5, 20); if (led_thread ! RT_NULL) { rt_thread_startup(led_thread); } dbg_thread rt_thread_create(dbg, dbg_thread_entry, RT_NULL, 1024, 10, 20); if (dbg_thread ! RT_NULL) { rt_thread_startup(dbg_thread); } return 0; }注意在 RT-Thread nano 中main 函数本身也被当做一个线程执行。如果不想退回到裸机 while(1)main 函数创建完线程后直接返回即可调度器正常运行。也可以让 main 线程做点别的事情。5.6 启动流程细节rtthread_startup 与 main 的关系在 RT-Thread nano 的标准启动流程中Reset_Handler最终会执行到rtthread_startup再由rtthread_startup调用rt_application_init。如果你把 main 函数直接当应用入口那么需要确保内核已经在 main 之前完成初始化。更常见的移植方式是在 main 函数开头调用rtthread_startup()让内核先初始化然后进入用户线程创建逻辑。示例int main(void) { rtthread_startup(); led_thread rt_thread_create(led, ...); ... return 0; }不过在许多 RT-Thread nano 模板中芯片启动文件已经把入口定位为rtthread_startup而 main 函数只是单一线程的入口。具体采用哪种方式需要在你的 SDK 启动文件和 RT-Thread 模板之间统一。从工程稳妥性出发更推荐的做法是先不修改启动文件的入口还是进入 main在 main 第一行调用rtthread_startup()让内核完成初始化和调度器启动。这样你想保留裸机代码时可以推迟创建 RTOS 线程。我在实际验证中采用的方式是在 main 开头调用rtthread_startup()但在 main 里保留所有线程创建代码。运行结果表明当rtthread_startup启动调度器后main 线程会暂停内核开始调度 led 和 dbg 两个线程。5.7 内存不足的处理策略APM32F103C8T6 只有 20KB SRAM。两个线程加上内核占用后剩余可用资源并不宽裕。如果编译时发现内存不足可以从以下方向优化降低线程栈大小按实际函数调用深度设置。关闭未使用的内核对象比如不用的邮箱或消息队列特性。关闭堆内存管理改用静态内存块。使用编译器优化等级把空间优化打开。这里给出一个可能引发困惑的现象一个线程入口函数只做简单点灯栈 512 字节已经足够但如果这个线程调用了rt_kprintf并且格式化字符串里有转义栈消耗会明显增加这是由 RT-Thread 内部打印实现决定的。6. RT-Thread nano 初始化后的运行与验证6.1 预期运行结果将编译后的axf或hex文件烧录到 APM32F103C8T6复位运行后在串口助手中应看到类似输出\ | / - RT - Thread Operating System / | \ 4.x.x build xxx 2006 - 2024 Copyright by rt-thread team debug thread running, tick1000 debug thread running, tick2000 debug thread running, tick3000同时PC13 上的 LED 以约 1Hz 频率闪烁。如果你能看到这些现象说明 RT-Thread nano 已经在 APM32F103C8T6 上正常调度。第一行启动 logo 是 RT-Thread nano 默认打印的版本信息。如果看不到 logo但能看到自己的调试打印说明内核已运行但控制台初始化阶段仍有问题如果连自己的打印也没有应该先判断是否进入 HardFault。6.2 验证线程优先级和时间片可以做一个实验把 led 线程优先级改为比 dbg 线程更高即数字更小然后观察串口输出频率是否受影响。因为 led 线程中的rt_thread_mdelay会主动让出 CPU所以两个线程都能运行如果 led 线程里不用 delay 而是一直死循环它就会抢占 CPU导致 dbg 线程得不到运行出现任务饿死现象。这是一个非常典型的 RTOS 理解实验。它可以让你明白优先级高的线程并不总是“更好”一个优先级极高却不休眠、不让出 CPU 的任务会阻塞其他一切任务。6.3 FinSH 组件是否启用RT-Thread nano 也可以用 FinSH但这个组件会占用不小的 Flash 和 RAM。在 APM32F103C8T6 上如果你只是入门验证不建议立刻启用 FinSH而是通过串口打印和 LED 现象判断系统状态。如果后续确实需要命令行交互可以在组件层加入 FinSH并配置一个空闲串口作为控制台实现任务列表查看、内存查询、线程 kill 等操作。7. 常见问题与排查方法APM32F103C8T6 标准库工程和 RT-Thread nano 移植过程中常见问题非常多。下面用表格列出排查思路问题现象可能原因排查方式解决方案Keil 中找不到 APM32F103C8T6未安装极海器件支持包打开 Pack Installer 搜索 APM32安装官方 PACK或手工添加 Device 支持文件编译报缺头文件标准库路径或头文件路径未添加查看错误日志中缺失的文件名将 inc/CMSIS 路径加入 Include Paths编译报APM32F10X_MD未定义预处理宏缺失查看编译命令宏定义在 C/C Define 中加入APM32F10X_MD链接时报重复定义PendSV_HandlerRTOS 汇编文件与启动文件重复搜索工程中的符号定义启用启动文件弱符号或注释掉 RTOS 中重复入口点灯程序不工作GPIO 时钟未使能或引脚接线错误检查外设时钟、LED 极性按数据手册确认引脚检查时钟使能串口输出乱码波特率不一致、主频配置不一致检查时钟树和串口波特率统一主频配置确认串口助手波特率rt_kprintf无输出控制台底层输出函数未实现检查rt_hw_console_output是否被调用实现控制台输出并重定向到 USART系统 tick 时间与实际不符SysTick 重载值计算错误打印或调试 SysTick 计数核对SystemCoreClock / RT_TICK_PER_SECOND线程不调度调度器未启动或优先级配置问题在 main 中调用rtthread_startup确保启动调度器检查线程是否 blocked任务饿死高优先级线程不阻塞、不让出查看线程循环中是否有 delay 或阻塞给高优先级任务加入rt_thread_mdelay程序进入 HardFault栈溢出、非法指针或未对齐访问查看 fault 状态寄存器增大线程栈、检查数组越界、开启栈检查表格里最后一条需要重点展开。HardFault 是嵌入式开发的“老朋友”。出现 HardFault 之后不推荐盲目加延时观察。正确做法是在 Keil 中把 HardFault_Handler 打断点进入 Fault 后查看CFSR、HFSR、BFAR等寄存器。如果CFSR中访问非法地址再看 PC 指针指向哪个函数。许多栈溢出导致的 HardFault在 PC 值里都能看到执行到了某个递归或深层函数附近。RT-Thread 提供了rt_thread_self()函数在线程入口打印当前线程名称有助于定位哪一步炸了。8. APM32F103C8T6 工程最佳实践与开发建议8.1 项目文件分层从最早的可维护性出发不要把所有代码都塞在 main.c 里。下面是一个实践中比较好用的工程结构User/ |-- main.c |-- apm32f10x_it.c # 中断服务函数 |-- board.c # RTOS 板级初始化 |-- uart_driver.c # 串口底层驱动 -- rtconfig.h App/ |-- led_task.c |-- dbg_task.c -- data_proc_task.c BSP/ |-- gpio.c |-- timer.c -- spi_flash.c这样划分的好处是当你要替换成另一个平台或者升级 RTOS 版本时可以快速判断哪些文件是平台相关的哪些是纯业务逻辑。8.2 系统时钟配置APM32F103C8T6 主频最高可到 96MHz但标准外设库中的默认时钟配置不一定跑在这个频率上。系统时钟直接决定串口波特率、定时器周期和 SysTick 的节拍时间。如果你把串口波特率设为 115200但实际系统时钟与初始化函数中假设不一致串口就会乱码。建议在工程初始化早期调用SystemCoreClockUpdate()并保证SystemCoreClock这个全局变量的值和实际 PLL 配置一致。RT-Thread 的 tick 需要依赖它。8.3 栈空间估算RT-Thread 的每个线程栈大小不能靠猜。入门阶段可以执行一个简单实验把线程栈设小一点然后在入口函数中增加一个很大的局部数组观察运行是否异常。更专业的方式是在编译时带上-fstack-usage或在 Keil 中开启栈分析工具得到函数调用栈深度再结合中断嵌套配置一个安全阈值。对于 APM32F103C8T6建议 LED 线程栈 256 到 512 字节串口打印线程 1024 字节主线程 1024 字节。剩余内存留给动态分配或中断栈。如果堆内存需求很高就要考虑关闭一些线程。8.4 生产环境中的安全提醒下面这句话需要强调如果你不在实验板而是做真实硬件产品任何涉及 Flash 擦写、GPIO 输出控制、外部设备操作或系统时钟切换的代码都要先在开发板上验证。例如如果你在正式产品上更换 Flash 时钟配置可能直接把程序烧死在旧配置下。如果你的产品运行在电池供电场景系统不能长时间空转需要在 RTOS idle 线程中退出低功耗模式。RT-Thread nano 默认的 idle 线程是一个简单循环你可以 hook 它进入低功耗。8.5 使用标准库时容易忽略的细节每个外设时钟使能后应等待必要的时间短再操作外设这个问题取决于具体芯片。通常外设时钟使能后经过一条内存屏障就可访问不会有什么问题但如果后面遇到奇怪的硬件行为需要检查是否是总线时钟同步导致。中断服务函数中不要做耗时操作。比如串口发送字符串在中断里用轮询方式发送几十字节会导致中断阻塞影响实时性。更好的做法是中断只置标志位真正数据发送放在 RTOS 线程中处理。APM32 的启动文件里中断向量表长度是固定的。如果在标准库工程中新增了自定义中断服务函数注意它的名称要和启动文件里一致。调试时开启MicroLIB可能会减少代码体积但也会影响标准 C 库部分功能。如果 RT-Thread 的rt_kprintf内部依赖编译器的 printf 实现小心MicroLIB会导致浮点打印异常。如果确实很在意体积需要确认你的应用不依赖浮点格式化。8.6 RT-Thread nano 多线程开发建议初次把裸机代码改成 RTOS 代码时最自然的方式是“每个原来 while(1) 里的功能模块都变成线程”。但这个转换过程要注意共享资源竞争。举个例子两个线程都向串口打印数据如果不加锁打印内容会穿插。这时可以引入互斥量static rt_mutex_t uart_mutex RT_NULL; /* 初始化时创建互斥量 */ uart_mutex rt_mutex_create(uart, RT_IPC_FLAG_PRIO); /* 线程中打印前加锁 */ rt_mutex_take(uart_mutex, RT_WAITING_FOREVER); rt_kprintf(thread A data); rt_mutex_release(uart_mutex);这是最简单的一种保护串口打印的方法。实际项目中还会有更复杂的共享缓冲区和外设寄存器访问都需要通过信号量、互斥量、消息队列等机制进行保护。RT-Thread 的消息队列是线程间传递数据的最佳实践之一。假设数据采集线程读到传感器数据要把数据交给协议线程打包发送通过裸机全局变量加标志位当然可以实现但用消息队列可以让代码边界更清晰某个线程异常挂起时其他线程不至于被无限等待。9. 总结与后续学习方向这篇文章从 APM32F103C8T6 芯片基本特性出发用完整的标准库工程示例说明了如何使用极海官方标准外设库搭建最小系统并在此基础上完成了 RT-Thread nano 实时操作系统的移植、线程创建与调度验证。整个迁移和移植的过程并不是一行改 STM32 代码然后祈祷编译通过而是围绕极海标准库的 API 风格、时钟树配置、中断向量表和 RTOS 板级适配层一步步落实。你现在可以做的下一步实践建议如下在你的 APM32F103C8T6 开发板上先把裸机串口跑通给自己写一个简单的调试日志函数。在此基础上完成 RT-Thread nano 移植不需要保留太多线程先跑一个 LED 线程和一个打印线程。熟悉rt_thread_mdelay、rt_sem_take、rt_mutex_take、rt_mq_recv这些常用 API 的行为差异。尝试同一个外设中断服务函数中释放信号量等待信号量的线程被唤醒。观察前后台系统模型和 RTOS 事件驱动模型的区别。尝试创建更多线程调低栈空间体会 SRAM 耗尽是何种表现。如果后续遇到复杂业务可以追加 FinSH 组件通过命令行查看线程运行情况。如果你对 APM32F103C8T6 的 Cortex-M3 内核本身还不够熟建议优先理解中断向量表、SysTick、PendSV、栈指针切换这些 CPU 基础概念。RTOS 不是魔法它只是合理利用了 CPU 的异常机制完成线程上下文切换。看清这一点后续无论是换到国产其他 Cortex-M MCU还是换到 RISC-V 平台都有一个稳定的知识底座。建议把这篇文章和你的工程源码一起收藏。当你把裸机程序第一次成功移植到 RTOS 上时那种从单一 while 循环中解放出来的感觉会帮助你对嵌入式实时系统建立起更系统的认知。