
简介这是一份针对STM32L431RCT6芯片的uCOS-III操作系统移植工程基于Keil MDK开发适合嵌入式初学者、单片机开发者以及希望从裸机编程过渡到RTOS的人群。工程使用外部12MHz晶振将系统时钟配置为80MHz完整移植uCOS-III后实现了两个典型功能任务PC1、PC2、PC3三路LED交替闪烁PA9、PA10串口以115200波特率循环打印“stm32l431 with ucosiii”。下载包共168个文件以84个.h头文件、72个.c源文件为主另有5个.asm汇编文件、4个.s启动文件和Keil工程配置总大小987KB头文件与源文件覆盖HAL驱动、内核源码和任务调度实现汇编文件用于启动初始化目录划分直观。整体结构紧凑既展示了uCOS-III在Cortex-M4内核上的移植要点也给出了LED与串口这类基础外设的任务化驱动方法。目前已有759人学习下载工程可直接打开编译也可参考任务创建与临界区处理方式修改引脚或时钟后用于自己的硬件验证。1. 拿到 STM32L431RC 先别急着点灯uC/OS-III 基础工程能省下你一整周的移植时间很多人在拿到一块 STM32L431RC 板子时第一反应是先去点灯等串口、按键、传感器全挤进一个 while(1) 大循环以后才发现代码已经拆不动了。uC/OS-III 在很多人眼里是个黑匣子其实一份能直接编译下载的移植工程比看十篇移植笔记都管用。这份基于 stm32l431rc ucosiii 的工程实现已经帮你把系统时钟、PendSV/SysTick 中断、两个示例任务全部搭好编译烧录后 LED 按固定节拍翻转串口按另一套周期输出计数你只需要关注怎么往上加自己的任务。它适合刚开始接触 RTOS 的嵌入式开发者、需要演示多任务调度行为的课程设计以及想在低功耗 M4 上评估 RTOS 可行性的从业者。2. 选型与文件边界为什么 L431 上跑 uC/OS-III拿到压缩包后先看哪几个文件2.1 硬件底子Flash、RAM 和低功耗特性决定了它能跑多“重”的系统STM32L431RC 的定位是低功耗 MCU但它的计算资源并不弱Cortex-M4F 内核最高 80MHz256KB Flash64KB SRAM还带 FPU。这个配置跑 uC/OS-III 完全不吃力真正吃资源的不是内核而是你规划的任务栈。我见过不少人在选型时担心 RTOS 占用太大实际上 uC/OS-III 内核自身只占用几 KB RAM剩下的大头全在任务栈上。假设你规划 5 个任务每个任务栈开 1KB加上消息队列、信号量这些内核对象总共也就 10KB 左右。对于 64KB SRAM 的 L431RC 来说余量很充足。这颗芯片还带多种低功耗模式Stop 模式下电流能降到微安级别。但要注意低功耗和 RTOS 的“节拍”天然是矛盾的SysTick 在整个芯片停机时会停摆唤醒后时间基准会错乱。这个问题在第五章里会专门讲属于移植后最容易翻车的点不是芯片本身的问题。另外Cortex-M4F 的 FPU 寄存器在任务切换时需要保存。常见的 uC/OS-III 移植会通过 os_cpu_a.asm 里的 PendSV 处理函数把带 V 后缀的寄存器一并压栈如果移植包省了这一步任务一旦用到浮点运算栈就会被写穿。这份资源里的 os_cpu_a.asm 是匹配 L4 的完整实现不建议自己改动。2.2 三种移植方式的取舍直接复制源码、用图形化工具重新生成、还是拿现成工程改自己从零移植 uC/OS-III 到 L431RC常见路径无非三种。第一种是下载 uC/OS-III 官方源码包手动拷贝 os_core.c、os_task.c、os_time.c 等内核文件再补上 os_cpu_a.asm 和 os_cpu_c.c。这种方式能看清每一个编译细节但工作量集中在启动文件、中断向量和时钟配置三块出错率非常高。第二种是用芯片厂商的图形化初始化工具生成基础工程再把 uC/OS-III 源码挂进去。这种方式优点是外设初始化规范缺点是工具生成了自己的 SysTick_Handler而 uC/OS-III 也要用 SysTick 做系统节拍两边会在中断函数上打架需要手动缝合。第三种就是直接拿一份验证过的工程包来改。这份“stm32l431rc ucosiii 工程实现”就属于第三种。它已经把 os_cpu 相关文件、HAL 库、启动文件和 BSP 驱动按启动链路放好我拿到手以后只需要改引脚定义和任务逻辑省掉的不只是写代码时间还有排查中断的调试时间。我一般建议如果是第一次跑 RTOS先拿现成工程把 LED 和串口跑通再去研究 os_cpu_c.c 里每个函数的作用。否则一边学理论一边处理汇编上下文切换很容易卡在一个 HardFault 上一整天。2.3 打开压缩包后先核对的文件清单启动文件、os_cpu 和 BSP拿到工程包别急着点编译先按表格里的启动链路核对一遍文件确认它和你的硬件环境对得上。文件作用你需要做什么startup_stm32l431xx.s定义中断向量表和 Reset_Handler确认芯片型号是 L431xx不要删掉 PendsV_Handlerstm32l4xx_hal_conf.hHAL 模块开关确认 UART 和 GPIO 对应的宏已打开os_cpu_a.asmPendSV 上下文切换汇编不要改动除非要加 FPU 功能os_cpu_c.cSysTick 钩子函数和 CPU 定时测量相关的 C 实现检查 OS_CPU_SysTickHandler 是否被调用os_cfg_app.h内核配置包括 OS_CFG_TICK_RATE_HZ默认 1000 时 OSTimeDly(500) 是 500msbsp.c/bsp.h板级初始化LED、UART 也在这里核对引脚定义常规改动最多的地方我在拿到这类资源时习惯先看 bsp.c 里的 LED 引脚是不是复用成了其它外设再看 os_cfg_app.h 里的节拍频率是否和 SysTick 初始化一致。血泪经验是很多工程编译能过、灯也能亮但串口乱码或者任务时间不对根源就在这两处没对齐。3. 把系统跑起来的三个关键配置时钟、PendSV 优先级与第一个任务的创建3.1 时钟树HSI PLL 到 80MHz 的参数怎么设L431RC 上电后默认使用内部 HSI 16MHz直接点灯没问题但跑 RTOS 时大家通常会把系统时钟提到 80MHz。用内部 HSI 做 PLL 源的好处是可以不依赖外部晶振很多核心板只焊了晶振座没焊晶振这时如果配置里选了 HSE工程跑起来会卡在时钟超时里。常见做法是把 HSI 16MHz 作为 PLL 输入经过二分频、二十倍频、四分频后得到 80MHz。具体参数在工程里通常是下面这样static void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct {0}; __HAL_RCC_PWR_CLK_ENABLE(); __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1); RCC_OscInitStruct.OscillatorType RCC_OSCILLATORTYPE_HSI; RCC_OscInitStruct.HSIState RCC_HSI_ON; RCC_OscInitStruct.HSICalibrationValue RCC_HSICALIBRATION_DEFAULT; RCC_OscInitStruct.PLL.PLLState RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource RCC_PLLSOURCE_HSI; RCC_OscInitStruct.PLL.PLLM 1u; RCC_OscInitStruct.PLL.PLLN 20u; RCC_OscInitStruct.PLL.PLLP RCC_PLLP_DIV2; RCC_OscInitStruct.PLL.PLLQ RCC_PLLQ_DIV4; RCC_OscInitStruct.PLL.PLLR RCC_PLLR_DIV4; if (HAL_RCC_OscConfig(RCC_OscInitStruct) ! HAL_OK) { Error_Handler(); } RCC_ClkInitStruct.ClockType RCC_CLOCKTYPE_SYSCLK | RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2; RCC_ClkInitStruct.SYSCLKSource RCC_SYSCLKSOURCE_PLLCLK; RCC_ClkInitStruct.AHBCLKDivider RCC_SYSCLK_DIV1; RCC_ClkInitStruct.APB1CLKDivider RCC_HCLK_DIV1; RCC_ClkInitStruct.APB2CLKDivider RCC_HCLK_DIV1; if (HAL_RCC_ClockConfig(RCC_ClkInitStruct, FLASH_LATENCY_4) ! HAL_OK) { Error_Handler(); } }这段代码的核心是 PLL 链路HSI 16MHz 经过 PLLM 分频变成 16MHz再经过 PLLN 放大 20 倍得到 320MHz VCO最后经过 PLLR 四分频得到 80MHz SYSCLK。这里最容易被忽略的是 FLASH_LATENCY_480MHz 下 Flash 等待周期必须设为 4否则程序在敏感时刻会出现随机性 HardFault这类问题很难复现属于典型的玄学故障。PLLP 和 PLLQ 在这个配置里分别被设置为 2 和 4PLLP 通常给 USB 或 SAI 用PLLQ 给 ADC 用。如果这个工程没有用到这些外设保持它们有合法分频系数即可不能直接写成 0。3.2 OS 初始化和起始任务OSInit、OSStart 与 OSTaskCreate 的执行顺序跑 uC/OS-III 的 main 函数并不复杂关键是执行顺序不能乱先初始化 HAL 和板级外设再调 OSInit然后在 OSStart 之前创建起始任务或应用任务。如果把 OSTaskCreate 放在 OSInit 之前内核对象还没有初始化创建任务会直接返回错误。下面是一段工程里最常见的初始化结构int main(void) { OS_ERR err; HAL_Init(); SystemClock_Config(); BSP_Init(); /* 初始化 LED、UART、按键等板级外设 */ OSInit(err); /* 初始化 uC/OS-III 内核对象 */ OSTaskCreate((OS_TCB *)StartTaskTCB, (CPU_CHAR *)start, (OS_TASK_PTR )StartTask, (void *)0u, (OS_PRIO )2u, (CPU_STK *)StartTaskStk[0], (CPU_STK_SIZE )StartTaskStkSize / 10u, (CPU_STK_SIZE )StartTaskStkSize, (OS_MSG_QTY )0u, (OS_TICK )0u, (void *)0u, (OS_OPT )OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR, (OS_ERR *)err); OSStart(err); /* 启动调度器函数不返回 */ }OSStart 会把 CPU 控制权交给优先级最高的就绪任务所以 OSStart 之后的代码通常不会再执行。起始任务的优先级设置成 2是因为 uC/OS-III 里数字越小优先级越高而空闲任务优先级是默认的 OS_CFG_PRIO_MAIN_APP 或 os_cfg_app.h 里定义的最大值应用任务优先级通常从 3 到 20 这个范围选。OSTaskCreate 的参数里第二个栈参数是“栈增长之后的剩余警戒位置”一般填栈大小的十分之一。最后一个参数 OS_OPT_TASK_STK_CHK | OS_OPT_TASK_STK_CLR 建议保留它允许你在调试时通过 OSTaskStkChk 检查栈使用水位这在第六章会用到。3.3 中断优先级分组为什么 PendSV 和 SysTick 必须垫底uC/OS-III 上下文切换靠的是 PendSV 异常系统节拍靠的是 SysTick 异常。这两个中断的优先级必须被设置为最低否则它们会打断其它外设中断导致临界区保护失效甚至出现任务切换时被打断的恶性竞争。在 L4 系列上HAL_Init 会把 NVIC 优先级分组配置为 Group4也就是抢占优先级用高四位从 0 到 15。uC/OS-III 对 PendSV 和 SysTick 的要求是优先级数值最大也就是最低。我在这份工程里通常会在启动文件或 os_cpu_c.c 里补上这两行NVIC_SetPriority(PendSV_IRQn, 0x0F); NVIC_SetPriority(SysTick_IRQn, 0x0F);PendSV 和 SysTick 都设为 15表示任何外设中断都可以抢占它们但它们不能抢占别的外设中断。这样做能避免 uC/OS-III 的任务切换机制与串口、ADC 等中断互相干扰。SysTick_Handler 里要做的事也会多一项。芯片厂商的 HAL 库默认在 SysTick_Handler 里只调用 HAL_IncTick 给 HAL_Delay 计时但 uC/OS-III 还需要在这里调用 OS_CPU_SysTickHandler让它把内核时间片往前推。两者可以同时保留不会互相影响但是 HAL_Delay 本身不能再作为任务内的延时手段因为它在延时期间会把 CPU 让给其他中断而不会触发操作系统的任务调度。4. 把 LED 和串口变成两个独立任务任务划分、栈尺寸与 printf 重定向4.1 先动脑再写代码哪些功能拆成任务哪些不拆很多人把裸机工程里的所有 while 循环都直接搬进 RTOS结果任务之间互相抢 CPU比裸机还乱。拆任务的基本原则是只有那些需要独立节奏、独立阻塞点、独立资源等待的逻辑才适合拆。LED 闪烁是一个周期行为适合拆成独立任务串口输出是一个阻塞型外设操作也适合拆开否则在同一个任务里打印时LED 点灯会被串口阻塞拖慢。反过来需要共享同一份全局数据的逻辑不适合用两个任务直接读写而应该通过消息队列或信号量传递。这份工程里的 LED 任务和串口任务没有任何共享数据所以只用到了 OSTimeDly是最干净的多任务演示。任务优先级需要有个规划LED 闪烁对实时性要求不高可以把优先级设为 5串口输出比 LED 慢得多阻塞时间长优先级设为 6 更合理。数字越小优先级越高如果把串口任务设得比 LED 高串口阻塞传输时 LED 的翻转就被频繁打断观察起来像卡顿。4.2 LED 闪烁任务用 OSTimeDly 替代裸机 HAL_Delay在裸机工程里大家习惯用 HAL_Delay 实现延时但进入 RTOS 后任务栈内不应该再调用它会阻塞 CPU 的 HAL_Delay。正确做法是让任务进入等待状态把 CPU 让给其它就绪任务。下面是工程里 LED 任务从起始任务中创建后的完整结构#define AppTaskLedStkSize 512u static CPU_STK AppTaskLedStk[AppTaskLedStkSize]; static OS_TCB AppTaskLedTCB; static void AppTaskLed(void *p_arg) { OS_ERR err; (void)p_arg; while (DEF_TRUE) { BSP_LedToggle(LED_GREEN); OSTimeDly(500u, OS_OPT_TIME_PERIODIC, err); } }在起始任务里创建这个任务时任务栈基地址传 AppTaskLedStk栈大小传 512。这里 512 的单位不是字节而是 CPU_STK 数量。L431 是 32 位内核CPU_STK 本质是 4 字节无符号整型所以 512 的栈实际占 2KB。任务里用了 OS_OPT_TIME_PERIODIC表示这是一个周期延时时系统节拍从延时开始时计时到点后任务恢复运行。另一个选项 OS_OPT_TIME_DLY 是从当前时刻开始延时被打断后实际延时时间可能比参数略长。LED 闪烁这类周期任务建议始终用 OS_OPT_TIME_PERIODIC。LED 翻转一下等 500 个 tick如果 OS_CFG_TICK_RATE_HZ 是 1000那么实际延时就是 500ms如果配置成 100就是 5 秒。这就是为什么我前面说 os_cfg_app.h 里的节拍频率和代码里的延时参数必须对着看否则会出现一种很诡异的“灯好像没反应”的现象。4.3 串口输出重定向 fputc 到 USART2并注意微库选项串口输出这件事裸机里通常只调 printf。但在 RTOS 工程里printf 背后依赖的 fputc 是由你提供的如果没重定向输出会落到默认调试器通道或干脆没有输出。我在这个工程里把串口任务和 printf 绑定在 USART2 上代码结构如下#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart2, (uint8_t *)ch, 1u, HAL_MAX_DELAY); return ch; }同时串口任务本身负责定时打印#define AppTaskUartStkSize 1024u static CPU_STK AppTaskUartStk[AppTaskUartStkSize]; static OS_TCB AppTaskUartTCB; static void AppTaskUart(void *p_arg) { uint32_t cnt 0u; OS_ERR err; (void)p_arg; while (DEF_TRUE) { cnt; printf(task uart running, cnt%lu\r\n, cnt); OSTimeDly(1000u, OS_OPT_TIME_PERIODIC, err); } }串口任务栈设成 1024 个 CPU_STK也就是 4KB不是随便拍的。printf 在格式化输出时会在栈上构建临时字符串整型转字符、补位、换行这些操作都可能消耗几百字节栈空间。如果栈只给 256第一次 printf 可能不崩第二次格式化长字符串时就容易把栈冲到未知内存区域。调试时你会发现栈水位飙升这个问题在第六章会验证。这个示例使用阻塞式 HAL_UART_Transmit 发送好处是逻辑简单坏处是串口在慢波特率下会长时间占用当前任务。在正式项目里更合理的方案是把串口发送丢进 DMA或者只用消息队列把打印内容交给专门的低优先级任务。这属于已知取舍不是工程缺陷。另外重定向 fputc 后必须确认编译器的库是“微库”模式不同 IDE 对这个设置叫法不同。如果没有开微库编译器可能会把 printf 解析成另一种分支导致你的 fputc 根本不会被调用。排查串口输出问题时先查这个设置比查代码更有效。5. 移植后最容易翻车的四个坑从 HardFault 到乱码的排查顺序5.1 现象程序卡死在 HardFault_Handler第一个任务都没起来刚烧录这个工程如果代码没进入你的应用任务而是直接在 HardFault_Handler 里死循环我第一个会怀疑 PendSV 中断没有正确“接进”中断向量表。原因大概率是启动文件里没有把 PendSV_Handler 和 SysTick_Handler 符号导出或者 os_cpu_a.asm 里的 PendSV_Handler 函数名和启动文件不一致。还有一个常见原因是任务创建时优先级数组越界uC/OS-III 需要为每个优先级准备一个空链表节点如果 OS_CFG_PRIO_MAX 设置得比实际使用的任务优先级小创建任务时会断言失败最终导致初始化异常。解决方法是先用调试器复位到 HardFault 现场查看 LR 寄存器指向的函数位于哪个源文件。如果是 os_cpu_a.asm多半是上下文切换符号没对接上如果是 bsp.c 或 printf优先检查栈地址是否越界。我的经验是先不要急着改业务代码把启动文件里的 PendSV_Handler 和中断向量表里声明的符号逐字对比一遍这类问题基本能解决。5.2 现象串口输出乱码LED 倒是正常串口发出的内容肉眼可读但全是乱码LED 也在跑说明代码执行没问题问题出在时钟频率和波特率分频的不一致上。最常见原因有两个。一个是工程配置里系统时钟实际跑在 16MHz但代码或调试配置里默认以为跑在 80MHz于是串口分频后实际波特率和串口助手设定值差了一大截。另一个是 PCLK1 被分频过而 USART2 挂在 APB1 总线上HAL 库在初始化 UART 时会根据当前 PCLK1 计算波特率。如果你在时钟配置里把 APB1 分频因子改了UART 的分频结果会跟着变但串口助手不会跟着变。解决方法是先别去改波特率先在调试器里读出 HAL_GetPCLK1 的真实值再读 USART 的 BRR 寄存器反推当前波特率。让串口助手设置成与 BRR 匹配的波特率乱码大概率消失。如果还是不对再检查 TX/RX 引脚是不是没有复用成 USART 功能GPIO 复用配置错误也会让信号波形发生偏移。5.3 现象任务有输出了但 LED 周期和设置的延时明显对不上任务已经创建成功串口也在打印但 LED 的翻转周期不是预期的 500ms而是看起来几倍或几分之一。这个现象比 HardFault 更容易被忽略因为它不属于“跑不起来”的问题。原因首先去找系统节拍频率。OS_CFG_TICK_RATE_HZ 的值决定了每个 tick 的时间长度如果这个宏定义成 100而你在任务里写的是 OSTimeDly(500)实际等待的是 5 秒而不是 500ms。LED 闪得“很慢”基本就是这个原因。第二个原因是 SysTick_Handler 里没有调用 OS_CPU_SysTickHandler。如果你用芯片厂商的初始化工具生成了工程SysTick_Handler 里可能只有 HAL_IncTickuC/OS-III 的延时功能完全依赖 OSTimeTick 推进任务等待时间没有它任务会永久停在自己的延时状态。解决方法是把中断函数的实现改成同时调用 HAL_IncTick 和 OS_CPU_SysTickHandler。第三个容易踩的点是我自己遇到过多次的调试主机在单步执行时SysTick 也是跟着暂停的任务看起来“乱套”其实是正常的。避免用单步长时间观察时间相关行为应该直接全速运行看现象。5.4 现象一进 STOP 低功耗模式唤醒后所有任务全乱套L431 是低功耗芯片很多人跑通 RTOS 之后第一件想做的是进 STOP 模式省电结果发现任务调度彻底乱掉唤醒后延时时间完全不准。原因是 STOP 模式下 SysTick 时钟会被关闭节拍计数从硬件层面停止了而 uC/OS-III 并不知道这段时间发生了什么。唤醒后它只知道自己调用了多少次 OSTimeDly但实际已经过去了更长时间于是一堆挂在延时队列里的任务瞬间全部变为就绪状态看起来像是任务同时抢跑。解决这个问题不能只靠 RTOS 本身常见做法有两条。第一在进入 STOP 前记录当前 tick唤醒后做补偿把丢失的节拍补回去但补偿逻辑对不同的睡眠时长要小心。第二更推荐使用 LPTIM 作为低功耗时基把系统节拍切换到低功耗定时器上但这一步要改动 os_cpu_c.c 的时钟源。这份工程资源里没有集成低功耗相关内容如果你需要跑低功耗建议先保持 SysTick 工作把整机功耗问题单独解决后再接入 RTOS。强行把 HAL_PWR_EnterSTOPMode 丢进任务里调用大概率是踩到上面这个坑。6. 验证移植是否合格栈水位、GPIO 翻转测切换耗时与收尾习惯6.1 用 OSTaskStkChk 查看每个任务的真实栈水位任务跑起来后不要凭感觉猜栈够不够uC/OS-III 提供了任务栈检查接口。前提是创建任务时启用了 OS_OPT_TASK_STK_CHK否则返回的数据没有参考意义。我通常会在串口任务里定期打印一段栈水位信息CPU_STK stk_free 0u; CPU_STK stk_used 0u; OS_ERR err; OSTaskStkChk(AppTaskLedTCB, stk_free, stk_used, err); printf(led task: used%lu free%lu\n, stk_used, stk_free);如果 used 已经接近 512说明任务栈设得太紧要往上调。如果 free 常年大于 300说明可以适当缩小省下内存给消息队列用。这个检查应该在任务运行一段时间后看最大值而不是刚启动时看。6.2 用 GPIO 翻转测任务切换耗时不被“看起来正常”骗过要验证上下文切换是否真的高效可以在两个任务切换时翻转一个空闲 GPIO用示波器看脉冲宽度。例如在 LED 任务延时前翻转一次在串口任务开始处再翻转一次示波器捕获到的电平持续时间内就包含了任务切换、调度器查找和栈恢复的全部耗时。如果这个脉冲宽度达到几十微秒以上可能说明系统节拍中断或 PendSV 实现有问题可以回查 os_cpu 相关的配置。从那以后我每次拿到新板子都会强制把栈水位检查和 GPIO 翻转耗时测试跑一遍确认调度行为可靠之后才继续往上加业务代码。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取