嵌入式实时操作系统入门:APM32F103C8T6移植RT-Thread nano实践

嵌入式实时操作系统入门:APM32F103C8T6移植RT-Thread nano实践 这次我们来看一个很适合入门嵌入式实时操作系统的组合APM32F103C8T6 标准库开发 RT-Thread nano。先直接说重点。APM32F103C8T6 是极海半导体推出的一颗 Cortex-M3 内核微控制器大量开发板资料都按 STM32F103C8T6 的替代型号来宣传很多开发者关心的第一件事就是“能不能直接沿用原来的标准库思路”。方向可以继承但工程文件、外设库、启动文件要按 APM32 官方 SDK 来。RT-Thread nano 是 RT-Thread 官方提供的轻量版 RTOS体积比完整版小很多适合 Flash 和 RAM 都不太宽裕的 M3 MCU正好可以用来学习多线程、信号量、优先级调度这些实时系统基础概念。这篇文章会沿着一条可执行路径来做三件事搭建 APM32F103C8T6 的 MDK 标准库工程点亮一颗 LED在同一个工程里移植 RT-Thread nano最后用两个线程加信号量验证调度与同步。硬件方面需要一块 APM32F103C8T6 最小系统板、一个支持 SWD 的调试器、一台 Windows 电脑和 Keil MDK。文章会尽量把“哪些操作能直接照做、哪些地方必须按自己拿到的 SDK 版本调整”写清楚避免照抄之后还要返工。1. APM32F103C8T6 与 RT-Thread nano 核心信息速览在动手之前先把这套组合的关键信息列出来后面对照着看会比较清楚项目说明主控型号APM32F103C8T6极海半导体 APM32F103 系列内核ARM Cortex-M3型号定位按常见命名规则C8 指向 64KB Flash 档位的中容量型号T6 指向 LQFP48 封装与常见工业级温度范围Flash / SRAM具体容量以官方数据手册为准学习项目通常按几十 KB Flash、十几 KB SRAM 级别规划资源开发方式标准外设库Standard Peripheral Library简称“标准库”也可直接用寄存器开发官方 SDKAPM32F10x_SDK包含固件库、例程、启动文件、链接脚本开发 IDEKeil MDK 5 为主也支持 IAR、Eclipse 类环境烧录调试通过 SWD 接口常见调试器是 ST-Link、J-Link、CMSIS-DAPRTOSRT-Thread nano支持线程、信号量、互斥量、事件、邮箱、消息队列等基础内核对象裁剪能力通过 rtconfig.h 裁剪内核特性组件可关闭适合资源受限 MCU主要学习内容标准库 GPIO 操作、时钟配置、RTOS 移植、多线程调度、信号量同步这套组合为什么有价值因为 APM32F103C8T6 的资料组织方式和 STM32F103 很像网上教程数量多遇到问题容易搜到答案RT-Thread nano 又是国产 RTOS 里文档完善、社区活跃的方案两个放一起比较适合作为“从裸机写法过渡到 RTOS 写法”的第一站。2. APM32F103C8T6 资源认识与资料获取方式不要急着打开 Keil 就开始写代码。拿到一块 APM32F103C8T6 开发板后建议先做两件事确认芯片型号印刷找到对应的官方资料压缩包。APM32F103 属于 M3 内核芯片指令集是 ARMv7-M没有 MMU也没有硬件浮点单元这和 STM32F103 的常见使用方式一致。对嵌入式入门来说这类芯片的外设操作方式比较直接控制 GPIO 就去看 GPIO 寄存器控制串口就去看 USART 寄存器内部框图和外设寄存器分布都写在用户手册里。命名中有一个容易混淆的点APM32F103C8T6 和 STM32F103C8T6 外观、引脚定义、调试接口经常是兼容的很多最小系统板可以直接拿来练习。但“引脚兼容”不等于“软件工程文件完全通用”。APM32 官方提供了独立的 SDK 和例程我们做标准库开发时应当以 APM32F10x_SDK 里的库文件为基准而不是直接把 STM32 标准库工程中的所有文件复制过去。需要准备的资料主要有三类APM32F10x 数据手册Datasheet看电气参数、引脚定义、绝对最大额定值。APM32F10x 用户手册User Manual看外设寄存器、时钟树、复用功能映射。APM32F10x_SDK包含标准外设库源码、启动文件、官方例程和 Keil 工程模板。官方 SDK 解压后的目录结构通常会按库文件、启动文件、例程分类。每个官方例程里往往已经有可编译的 Keil 工程对刚接触这款芯片的人来说最稳的第一步不是自己新建空工程而是先打开 SDK 中 GPIO 或 LED 相关的例程确认编译、下载、运行整条链路能通。如果厂商例程能在开发板上跑起来后面做自己的工程时就有了“最低可用参照物”。标准库其实不神秘本质上就是把寄存器操作封装成函数缺点是每个外设初始化代码偏长优点是代码可读性和可维护性比堆寄存器版本高。3. 开发环境准备与前置检查开发这套组合环境准备比写业务代码更容易出问题。下面按硬件、软件、驱动三个方向梳理。3.1 硬件检查准备 APM32F103C8T6 开发板时重点检查三点板载 LED 对应的 GPIO 引脚、串口芯片型号、调试器接口电平。LED 引脚非常关键。不同厂家做的 APM32F103C8T6 最小系统板LED 可能接在 PC13、PB1、PB2也可能带三极管驱动且低电平点亮。拿到开发板后先看原理图记住 LED 引脚号和高低电平有效方向。后面所有 LED 闪烁验证都依赖这个信息。调试器连接方面APM32F103C8T6 常用 SWD 四线SWDIO、SWCLK、GND、3V3部分调试器还需要复位引脚。连接时注意共地目标板电压 3.3V 时调试器电源选择要对准 3.3V 档位避免烧坏引脚。3.2 软件工具开发这套组合的软件工具链一般包括工具用途Keil MDK 5编辑、编译、下载工程APM32F10x_SDK固件库、例程、启动文件APM32 芯片 Pack 或 SDK 自带工程模板让 Keil 识别芯片型号和 Flash 算法ST-Link / J-Link / CMSIS-DAP 驱动调试器通信串口工具查看 RT-Thread 线程输出如 MobaXterm、Xshell、串口助手启动 Keil 前先确认编译器版本是 ARMCC 还是 AC6。很多老教程默认使用 ARMCCAC5而新版 Keil 默认可能切到 AC6。标准库工程在 AC6 下往往会有语法差异所以最省事的办法是找到官方 SDK 例程自带的工程选项先按照官方默认配置编译一次再决定是否切换编译器版本。3.3 首次编译前的三个检查点芯片型号是否能在 Keil 器件列表里找到找不到就先安装对应 Pack。SDK 路径中不要出现中文和空格避免某些老版本 ArmCC 头文件包含路径出错。确认调试器选择正确然后设置 SWD 模式、合适的速度不能选成 JTAG 模式导致下载失败。4. 使用标准库建立 APM32F103C8T6 基础工程从零搭建标准库工程听起来步骤不少但只要把工程文件拆成几个固定部分理解后面扩展会很顺手。4.1 最小标准库工程的组成一个可编译、可下载的最小 APM32F103C8T6 工程通常包含这些内容project/ ├── Libraries/ │ ├── APM32F10x_StdPeriphDriver/ │ │ ├── inc/ │ │ └── src/ │ ├── CMSIS/ │ │ ├── core_cm3.c │ │ └── system_apm32f10x.c │ └── startup/ │ └── startup_apm32f10x_md.s ├── User/ │ ├── main.c │ ├── apm32f10x_it.c │ └── apm32f10x_conf.h ├── Project/ │ └── xxx.uvprojx └── Debug/ └── xxx.hex工程里最关键的是启动文件。Cortex-M3 的启动文件负责初始化栈指针、设置向量表、执行 SystemInit。APM32F103C8T6 属于中等容量型号启动文件通常对应名字中包含_md或_md.s的那个文件。到底选哪个以 SDK 例程为准不要把 STM32 的启动文件直接拿过来。4.2 标准库点亮 LED 的四个步骤不管使用哪家芯片的标准外设库GPIO 点亮 LED 都绕不开四个步骤打开对应 GPIO 端口的时钟。把 LED 引脚配置为推挽输出模式。根据开发板原理图设置引脚初始电平。在循环中翻转引脚电平实现闪烁。下面的例子用寄存器方式写是为了把底层动作展示清楚。实际工程中使用 APM32F10x_SDK 的 GPIO 库函数封装更好但具体函数名需要以官方 SDK 头文件为准不同 SDK 版本有一定差异。#include apm32f10x.h void Delay(void) { volatile uint32_t i; for (i 0; i 500000; i); } int main(void) { // 使能 GPIOB 时钟示例使用 PB1 引脚 RCC-APB2ENR | RCC_APB2ENR_IOPBEN; // 把 PB1 配置为推挽输出速度 50MHz GPIOB-CRL ~(0x0F (4 * 1)); GPIOB-CRL | (0x03 (4 * 1)); // 默认输出高电平具体点亮电平取决于板载 LED 电路 GPIOB-ODR | (1 1); while (1) { GPIOB-ODR ^ (1 1); Delay(); } }使用 APM32 官方标准外设库时初始化逻辑类似只是把寄存器赋值替换成库函数// 伪代码实际函数名请参考 APM32F10x_SDK 的 gpio.h RCC_EnableAPB2PeriphClk(RCC_APB2_PERIPH_GPIOB, ENABLE); GPIO_ConfigPin(GPIOB, GPIO_PIN_1, GPIO_MODE_OUT_PP); GPIO_ClearBit(GPIOB, GPIO_PIN_1);这里必须强调APM32 官方库为了兼容原 STM32 用户习惯函数风格接近但头文件、结构体名、枚举名并不保证一致。建议把apm32f10x_gpio.h打开确认当前 SDK 里的 API 写作形式再迁移自己的代码。4.3 时钟配置的注意事项标准库工程的时钟配置通常由system_apm32f10x.c和启动文件完成。Cortex-M3 启动代码会调用 SystemInit设置 Flash 等待周期、PLL 来源与倍频系数。对 APM32F103C8T6 这类芯片外部晶振是否焊接直接影响时钟配置。很多最小系统板为了省成本只保留内部 HSI 晶振或者虽然预留了 8MHz 晶振位置但没焊。如果官方例程默认按外部晶振配置而你的板子上没有外部晶振现象往往是程序看起来下载成功串口波特率偏差很大SysTick 延时明显不准LED 闪烁节奏异常。遇到这种情况不要急着怀疑芯片坏了先把时钟源改成内部 HSI或者找 SDK 里对应的 HSI 配置例程对比。正确判断当前板子有没有焊外部晶振也是嵌入式开发的一项基本功。4.4 标准库 API 与教程不一致时怎么处理这是使用国产替代 MCU 时最常见的问题。网上搜到的 STM32 F1 例程不能直接照搬进 APM32 工程常见差异包括可能出现差异的对象STM32 习惯写法APM32 可能写法头文件stm32f10x.hapm32f10x.hGPIO 初始化函数GPIO_Init以 SDK gpio.h 为准时钟使能函数RCC_APB2PeriphClockCmd以 SDK rcc.h 为准结构体类型GPIO_InitTypeDef以 SDK 头文件为准处理方法不是死背 API而是先确认工程里包含的头文件是apm32f10x.h然后打开 SDK 例程用“Matching”思路搜索自己需要的功能。如果配置功能在库函数里找不到可以退回寄存器操作这是最后兜底的办法因为 ARM Cortex-M3 外设寄存器地址是公开固定的。5. RT-Thread nano 能解决什么问题裸机开发常用一个while(1)超级循环加中断处理。当业务比较多比如同时跑按键扫描、LED 闪烁、串口解析、传感器采集时超级循环的实时性会变差。一个耗时操作可能阻塞其他任务几毫秒甚至几十毫秒。RT-Thread nano 提供的是最基本的 RTOS 能力线程管理、线程调度、线程间同步与通信。程序逻辑从“一个大循环”拆成“多个独立线程”每个线程只做自己那一部分由内核决定什么时候切换。RT-Thread nano 特别适合 APM32F103C8T6 这类中低端 MCU原因在于裁剪成本低。它不像完整版 RT-Thread 那样把所有组件全部纳入nano 默认只保留内核相关文件像设备框架、文件系统、网络协议栈这些组件可以根据需要选择是否加入。对学内核原理来说nano 的文件量少读得过来资源占用也比完整版小很多一般不会因为 “Flash 64KB 不够用” 而推不下去。RT-Thread nano 的主要功能范围包括内核能力说明线程管理创建线程、启动线程、线程挂起/恢复优先级抢占高优先级线程就绪后可以抢占低优先级线程时间片轮转同优先级线程之间按时间片调度信号量用于线程同步和互斥控制互斥量解决优先级反转问题事件集一对多、多对一的事件通知邮箱 / 消息队列线程间传递数据在选择 RTOS 时需要注意RTOS 不是万能的。如果开发场景只有一两个循环任务那裸机可能更简单不要为了“上 RTOS”而上 RTOS。引入 RTOS 后你会遇到优先级配置、栈空间不足、共享资源保护、临界区等新问题。学习阶段先跑通两个线程再去理解这些概念会顺畅很多。6. 在 APM32F103C8T6 上移植 RT-Thread nano移植 RT-Thread nano 通常有两条路线。第一条是用官方在 Keil 中提供的 RT-Thread Nano 软件包从 RTE 环境添加操作比较快第二条是手动添加源码能更清楚地看到内核有哪些文件、哪些部分和芯片相关。对于学习来说建议先走第一条把系统跑起来再看第二条的源码。6.1 移植前需要了解的内核相关文件RT-Thread nano 有几个文件需要在移植时重点关注文件作用rtconfig.h内核裁剪配置定义最大优先级、时钟节拍、内存管理方式board.c板级初始化包含系统时钟、SysTick、控制台串口实现context_rvds.sKeil 环境下的上下文切换汇编代码rt_hw_board_init()板级初始化入口SysTick_Handler时钟节拍中断处理需要调用 rt_tick_increase()RT-Thread nano 的移植核心是和硬件相关的board.c。它在rt_hw_board_init()中完成系统时钟初始化、SysTick 配置、堆内存初始化并提供控制台串口输出所需的rt_hw_console_output()函数。6.2 在 Keil RTE 中添加 RT-Thread nano如果使用 Keil MDK 的 RTE 方式大概操作如下在 Keil 中打开我们已经编译通过的 APM32F103C8T6 标准库工程。点击 “Manage Run-Time Environment” 按钮进入 RTE 管理界面。在软件包列表中找到 RT-Thread 相关组件勾选 RT-Thread Nano Kernel。让 Keil 自动复制必要的 RT-Thread nano 文件到工程中。添加完成后工程里会出现 RTOS 相关目录和文件。这里建议先编译一次看看缺少哪些文件或提示哪几个中断宏重复定义。RTE 方式不是“点一下就直接能用”它只负责把源码放进来真正适配 APM32F103C8T6 的时钟和串口代码仍然需要自己确认。6.3 手动移植的源码组织方式手动方式适合用来理解运行原理。把 RT-Thread nano 源码包中的 src、libcpu/arm/cortex-m3、include 目录加入工程然后自己写一个board.c。关键代码如下所示#include board.h #include apm32f10x.h void rt_hw_board_init(void) { // 1. 配置系统时钟 // 2. 配置 SysTick产生 RT_TICK_PER_SECOND 频率节拍中断 // 3. 为动态内存堆分配一段空间 // 4. 初始化用于 rt_kprintf 输出的串口 } void SysTick_Handler(void) { rt_tick_increase(); }需要注意如果 APM32F103C8T6 启动文件中已经定义了SysTick_Handler、PendSV_Handler、SVC_Handler等空函数并且没有使用弱符号链接时就会出现重复定义错误。错误提示通常类似symbol PendSV_Handler multiply defined symbol SysTick_Handler multiply defined解决方法是把启动文件中与 RTOS 冲突的这几个中断处理函数注释掉。SysTick_Handler最后使用 RT-Thread 内核中的实现PendSV_Handler使用上下文切换汇编文件中的实现。这也是新手移植 RTOS 最容易卡住的地方之一。6.4 rtconfig.h 中的关键配置项RT-Thread nano 的行为大多由 rtconfig.h 驱动。常见的配置项包括// 系统节拍频率常见配置为 1000即 1ms 一个 tick #define RT_TICK_PER_SECOND 1000 // 最大优先级数量 #define RT_THREAD_PRIORITY_MAX 32 // 内核对象名称长度 #define RT_NAME_MAX 8 // 开启线程栈检查便于调试栈溢出 #define RT_USING_OVERFLOW_CHECK // 使用动态内存堆 #define RT_USING_HEAPRT_TICK_PER_SECOND会影响所有延时和超时时间。如果配置成 1000则rt_thread_mdelay(1000)表示延时 1 秒如果配置成 100同一个函数延时就变成 10 秒。修改节拍频率后要同时确认 SysTick 的重装载值是否配套否则时间概念会乱。RT_THREAD_PRIORITY_MAX决定系统有多少个优先级。数值越高优先级队列空间越大。对 APM32F103C8T6 这种资源有限的芯片先用 32 个优先级足够。6.5 串口输出与控制台对接RT-Thread 的rt_kprintf默认输出到控制台设备。在移植 nano 时需要自己提供rt_hw_console_output()函数否则打印函数没有实际输出通道。一个常见的函数实现思路是void rt_hw_console_output(const char *str) { while (*str ! \0) { // 等待串口发送寄存器为空然后发送一个字符 // 伪代码uart_send_byte(*str); str; } }实际串口发送函数仍然是 APM32 标准库负责实现的部分可以参考 SDK 中 USART 例程。控制台串口最常用的是 USART1PA9 为 TXPA10 为 RX。做验证时波特率可以选择 115200 或 9600要确保系统时钟频率正确。6.6 第一个 RT-Thread nano 线程完成移植后写一个最直接的验证程序创建一个线程让它在终端打印系统 tick或者让 LED 周期性翻转。核心代码如下#include rtthread.h #include board.h static void led_thread_entry(void *parameter) { while (1) { // 这里调用上一节实现的 LED 电平翻转函数 user_led_toggle(); rt_thread_mdelay(500); } } int main(void) { rt_thread_t thread; // 创建线程名字 led_thread栈 1024 字节优先级 20 thread rt_thread_create(led, led_thread_entry, RT_NULL, 1024, 20, 10); if (thread ! RT_NULL) { rt_thread_startup(thread); } return 0; }这里把user_led_toggle()当作已经实现的 LED 翻转函数。实际项目中可以使用 APM32 标准库里的 GPIO 写电平操作也直接使用第 4 节寄存器操作的核心代码。7. 多线程调度与信号量验证只创建一个线程还不能直观感受 RTOS 的价值。接下来创建两个不同优先级的线程用信号量做一次同步能明显看到调度的变化。7.1 两个线程的设计线程 A 优先级较高每 1000ms 释放一个信号量线程 B 优先级较低等待这个信号量拿到后打印一次。由于信号量初始值为 0B 在没有信号量时会阻塞等 A 唤醒它。这样一来输出的顺序完全由内核同步机制控制而不是盲目延时。7.2 信号量实例代码下面的代码使用 RT-Thread 标准 API。串口输出函数仍然依赖前文的rt_hw_console_output()。#include rtthread.h #include board.h static struct rt_semaphore sem_demo; static void thread_high(void *param) { while (1) { rt_kprintf(high thread: release semaphore\n); rt_sem_release(sem_demo); rt_thread_mdelay(1000); } } static void thread_low(void *param) { while (1) { rt_sem_take(sem_demo, RT_WAITING_FOREVER); rt_kprintf(low thread: got semaphore\n); } } int main(void) { // 初始化信号量初始值 0 rt_sem_init(sem_demo, sem_demo, 0, RT_IPC_FLAG_PRIO); // 创建线程并启动 rt_thread_create(high, thread_high, RT_NULL, 1024, 10, 10); rt_thread_create(low, thread_low, RT_NULL, 1024, 20, 10); return 0; }这段程序运行后串口输出会重复出现 high 打印和 low 打印。观察输出的稳定性可以判断时钟节拍配置是否正确、线程栈大小是否余量充足。如果线程跑飞优先检查线程栈大小和任务函数里的局部变量占用。也可以尝试把两个线程优先级反过来观察