STM32F030跑FreeRTOS:多串口并发与内存优化实战指南

📅 发布时间:2026/9/10 12:09:30
STM32F030跑FreeRTOS:多串口并发与内存优化实战指南
简介基于STM32F030C8T6微控制器移植FreeRTOS并实现多串口通信的完整工程示例面向嵌入式开发者和物联网工程师解决在有限资源芯片上运行实时操作系统并同时管理多路串口的问题。资源共包含286个文件以七十六个C语言头文件、五十六个C源码文件为主体辅以编译器工程配置、链接脚本、中间产物以及说明文档并附带hex烧录文件与axf映像文件压缩包体量约4.26MB目录结构清晰便于检索。项目已经过编译验证目前已有五百三十二人学习下载。通过研读源码可深入掌握FreeRTOS的任务创建与优先级调度、信号量与互斥量同步机制以及串口中断和DMA收发流程并能直接以这套工程为基础扩展连接温湿度传感器、GPS模块或无线通信模块等典型物联网设备适合作为课程设计、毕业设计或产品原型开发的起点。1. STM32F030 跑 FreeRTOS把 8KB SRAM 算到极限再动手STM32F030C8T6 经常被拿来和 F103 比比完得出的结论往往是“资源太少跑什么 RTOS”。拆完 H02BOX 这个工程后我的看法正好相反正因为它只有 8KB SRAM任务栈和缓冲区才必须在写代码前定死工程反而比大 RAM 平台上那些随意 new 出来的任务干净得多。这个项目把三路 USART 全部接进 FreeRTOS一路做调试口一路接采集传感器一路预留 Modbus在 4KB 堆空间里同时跑起三个业务任务、一个统计任务和空闲任务还给堆栈溢出检测留了位置。对做嵌入式采集盒、物联网网关或配电终端的人来说值得抄的不是“移植 FreeRTOS”这个动作而是它拆任务、分内存、挂串口中断的那套顺序。2. FreeRTOS 移植到 STM32F030C8T6从外设预算到中断优先级2.1 F030C8T6 的外设账三个 USART、八个定时器和一路 RTCF030C8T6 属于 F0 系列里“外设不少、引脚有限”的档位64KB Flash、8KB SRAM内部 48MHz 时钟三个 USART、两个 SPI、两个 I2C、八个定时器还带一路独立 RTC。很多人觉得 M0 内核外设少实际上真正的限制在引脚重映射。USART2 的 TX/RX 和 I2C1、SPI1 共用同一组 AF 引脚板子布局阶段就要决定串口和总线谁优先否则后面只能飞线。H02BOX 的源码清单里出现 stm32f0xx_tim.c 和 stm32f0xx_rtc.c说明定时器和 RTC 确实在业务里干活定时器用来做帧超时和看门狗喂狗节奏RTC 负责记录盒子掉电后的时间戳。这也是 F0 上做“数据采集盒”的典型组合串口收数据、定时器判超时、RTC 打时间标签。外设分工大致如下外设数量在 H02BOX 里的角色容易踩的坑USART3 路调试、采集、预留 ModbusAF 复用和 I2C/SPI 冲突TIM8 个超时计数、PWM、延时48MHz 时钟源要先核对RTC1 路掉电时间戳依赖 LSI精度有限DMA5 通道USART 接收搬运多个外设抢同一通道还有个细节值得留意工程文件里带了一批 STM32F042 的备份文件说明这个项目很可能是从 F042 工程迁移过来的。F042 的 SRAM 是 6KB、Flash 48KB迁到 F030C8T6 后 Flash 多出 16KB多出来的空间正好用来放日志和调试断言。迁移时最常漏的不是启动文件而是链接脚本。原 F042 工程的 RAM 长度按 0x1800 写换到 F030C8T6 必须改成 0x2000否则任务栈一深就硬 fault。2.2 configTOTAL_HEAP_SIZE 怎么定heap_4 与任务栈切割FreeRTOS 在 STM32 上最常见的堆实现是 heap_4.c原因很简单它支持空闲块合并和释放。heap_1.c 只分配不释放适合任务数量永远不变的老式固件heap_2.c 能释放但不能合并碎片。而调试阶段经常要临时创建任务验证某个外设用完再删heap_4 是容错率最高的选择。代价是它比 heap_2 多占一点 ROM对 64KB Flash 来说完全无所谓。H02BOX 里三个串口任务加一个统计任务基本固定但为了调试方便我给 RTOS 堆留 4KB剩下 4KB 给中断嵌套栈、libc 全局缓冲和业务数组。FreeRTOSConfig.h 里对应配置是这样#define configTOTAL_HEAP_SIZE ( ( size_t ) ( 4 * 1024 ) ) /* 给 FreeRTOS 4KB */ #define configMINIMAL_STACK_SIZE 128 /* 空闲任务栈单位 4 字节 */ #define configMAX_PRIORITIES 5 #define configUSE_MALLOC_FAILED_HOOK 1 #define configUSE_STACK_OVERFLOW_HOOK 1configTOTAL_HEAP_SIZE 的单位是字节4KB 的意思是所有任务的栈、TCB、队列、信号量都从这 4KB 里出。configMINIMAL_STACK_SIZE 的单位是字F0 是 32 位内核一个字对应 4 字节128 字等于 512 字节这是空闲任务的下限不能再压缩。任务栈我按“每路串口一个 256 字”来切#define TASK_SERIAL1_STACK_SIZE 192 /* 调试协议解析约 768 字节 */ #define TASK_SERIAL2_STACK_SIZE 192 /* 传感器数据接收约 768 字节 */ #define TASK_SERIAL3_STACK_SIZE 256 /* Modbus 主从栈约 1KB */ #define TASK_STAT_STACK_SIZE 64 /* 统计任务256 字节 */三个串口任务的总栈约 2.5KB加空闲任务的 512B、四个任务的 TCB 和键队列4KB 堆余量在 10% 左右。这个余量不是浪费而是给 printf 家族函数准备的。工程里如果把 printf 的格式化缓冲区放在任务栈上一个 %d 加字符串拼接就能吃掉 200 字节栈切得太死会第一个被它击穿。2.3 Cortex-M0 的 SysTick、PendSV、SVC 优先级设置F0 的 NVIC 只有 2 位优先级满打满算四个等级数值 0 最高、3 最低。FreeRTOS 要求 PendSV 和 SVC 必须是最低优先级也就是数值最大的一档。如果照搬 F103 工程的写法把 PendSV 配成 15F0 上会直接写穿优先级寄存器15 的二进制是 1111截断成低两位后变成 3看起来没问题但更高位的写入行为不可控临界区保护就失效了。#define configLIBRARY_KERNEL_INTERRUPT_PRIORITY 3 /* 内核最稳定数值最大 */ #define configKERNEL_INTERRUPT_PRIORITY 3 /* 对应 PendSV 和 SVC */ #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 1 #define configMAX_SYSCALL_INTERRUPT_PRIORITY 1configMAX_SYSCALL_INTERRUPT_PRIORITY 设为 1意思是优先级 0 的中断可以打断临界区优先级 1 及以上的中断才允许调用带 FromISR 后缀的 API。串口中断在 F0 上一般设到优先级 1 或 2 设 1 可以保证入队实时性设 2 则给别的紧急中断留更高位置。SysTick 和 PendSV 都在 3保证任务切换本身不会被更高优先级的外设频繁打断。检查这套配置是否生效可以在临界区里翻转一个 GPIO用示波器看中断响应时 GPIO 翻转是否有毛刺如果临界区外设中断被无端延迟优先级一定配错了。3. 多串口并发实现队列、中断和 DMA 的分工3.1 收数据用队列不在中断里做解析裸机多串口最常见的写法是在主循环里轮询每个串口的接收标志位数据少时没问题一旦三路串口同时来数据主循环里就会出现“查完 A 串口再查 B 串口”的时间差高波特率下丢包几乎是必然的。FreeRTOS 的做法是把每路串口的中断服务函数做成“只搬运、不解析”的短函数把字节塞进队列就走解析逻辑全部放到对应任务里。以 USART2 的接收为例中断服务函数这样写static QueueHandle_t xUart2RxQueue; void USART2_IRQHandler(void) { BaseType_t xHigherPriorityTaskWoken pdFALSE; uint8_t ucByte; if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { ucByte (uint8_t)(USART_ReceiveData(USART2) 0xFF); xQueueSendFromISR(xUart2RxQueue, ucByte, xHigherPriorityTaskWoken); } if (xHigherPriorityTaskWoken pdTRUE) portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }注意这里必须用 xQueueSendFromISR而不是普通版 xQueueSend。FromISR 版本永远不会阻塞队列满时直接返回 errQUEUE_FULL调用者决定丢弃新字节还是丢弃旧字节。如果有三个任务等着接收数据同一个串口每次只喂一个字节就属于典型的“一个生产者、一个消费者”队列长度读到 128配合 115200 波特率足够扛住一帧 64 字节的连续数据。后台任务侧用 xQueueReceive 带超时接收超时时间设成 50ms既能及时处理帧又能顺便做超时判帧。3.2 不定长帧接收IDLE 中断配合 DMA 环形缓冲区逐字节入队对 CPU 的消耗是可以算出来的115200 波特率下每字节间隔约 87 微秒三路串口同时满载时中断频率超过 34kHz在 48MHz 的 M0 上已经占用了不少主频。项目里 USART3 走 ModbusModbus 帧长度不定、协议要求帧间间隔 3.5 个字符以上这正好用“IDLE 中断 DMA”来收DMA 按环形缓冲区连续搬运总线空闲时触发 IDLE 中断中断里读出 DMA 剩余计数字就知道这一帧落在哪个区间。void USART3_IRQHandler(void) { uint16_t usLength 0; BaseType_t xHigherPriorityTaskWoken pdFALSE; if (USART_GetITStatus(USART3, USART_IT_IDLE) ! RESET) { USART_ClearITPendingBit(USART3, USART_IT_IDLE); DMA_Cmd(DMA1_Channel3, DISABLE); /* 记录这一帧长度总容量减掉 DMA 当前剩余计数 */ usLength RX_BUF_SIZE - DMA_GetCurrDataCounter(DMA1_Channel3); DMA_SetCurrDataCounter(DMA1_Channel3, RX_BUF_SIZE); DMA_Cmd(DMA1_Channel3, ENABLE); xQueueSendFromISR(xUart3FrameQueue, usLength, xHigherPriorityTaskWoken); } if (xHigherPriorityTaskWoken pdTRUE) portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }DMA_GetCurrDataCounter 返回的是剩余未搬运的字节数所以本帧实际长度要用总容量减掉它。这里有个很容易犯的错DMA 通道在 F0 上和串口的对应关系要查参考手册的 DMA request mapping 表USART3_RX 和 USART2_RX 可能抢同一个通道两个串口同时开 DMA 前必须确认通道没有重复占用。收到帧长度后也不要在中断里立即处理把长度塞进 xUart3FrameQueue让 Modbus 任务从队列里取长度再按 DMA 缓冲区的连续区间解析。如果要支持大帧跨缓冲区回绕可以维护一个首尾索引解析时做两次 memcpy 拼接这在 Modbus RTU 最大 256 字节帧的场景下已经是万元一失的做法。3.3 发送互斥多任务同时写串口的前提很多人以为多串口就是“每个串口独立硬件、互不干扰”但在 H02BOX 这种工程里三个任务都可能往调试串口打印日志也可能有任务同时想往采集串口写响应。同一路 USART 的发送寄存器只有一个两个任务同时调用发送函数字节会交错成乱码。发送保护策略按串口分开每个串口一个互斥量串口职责接收机制发送保护任务优先级USART1调试日志RXNE 中断逐字节入队独立互斥量2低USART2传感采集RXNE 中断逐字节入队独立互斥量3中USART3Modbus 预留DMA IDLE 中断独立互斥量4高按 USART3 的优先级最高、USART1 调试口最低来分配是因为 Modbus 对时延最敏感调试日志延迟几十毫秒完全无所谓。发送函数的常见写法是拿互斥量后阻塞发送static SemaphoreHandle_t xUart1Mutex; void vUart1SendString(uint8_t *pucData, uint16_t usLen) { xSemaphoreTake(xUart1Mutex, portMAX_DELAY); while (usLen--) { while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET) ; USART_SendData(USART1, *pucData); } xSemaphoreGive(xUart1Mutex); }这里用 portMAX_DELAY 等互斥量意味着拿不到锁就一直挂起。低优先级任务在打印长日志时高优先级任务会一直阻塞直到日志发完。如果不想让高优先级任务等太久可以把阻塞时间改成 pdMS_TO_TICKS(50)超时后返回错误码由调用方决定重发还是丢弃。另一个方案是把发送做成“待发队列”发送任务从队列里取数据再统一走 DMA 发送把发送和业务彻底解耦但要加一块内存做缓冲在 8KB SRAM 下不一定划算。小项目里每个串口独立互斥量是最省内存也最容易验证的方案。4. 串口任务常见事故堆栈溢出检测与优先级反转4.1 printf 重定向引起的“假死机”在任务里直接调用 printf是 H02BOX 这类多串口工程最容易出现“跑几天后假死”的根源。printf 重定向到 USART1 发送函数而该函数内部又去拿 xUart1Mutex表面看只是慢一点实际会触发死锁任务 A 正在发一长串日志持有互斥量任务 B 优先级更高因为某个串口事件被调度B 里也调用 printf阻塞等待同一个互斥量如果 A 在打印过程中被 SysTick 切换出去B 一直占着 CPU 等锁A 永远得不到执行这把锁就永远放不开系统表现就是所有任务都不再跑只有看门狗在复位。所以这里的规则很死板中断服务函数里不允许调用 printf临界区里不允许调用 printf带 FromISR 后缀的 API 函数也不应该出现在打印逻辑里。更稳妥的工程做法是单独开一个日志任务其他任务把要打印的内容拼成字符串放入队列日志任务排队发送。代价是多占一个队列和一块缓冲区换来的是“任何优先级组合下都不会因为打印死锁”。如果 SRAM 实在不够至少保证 printf 只在一个最低优先级任务里调用避免高优先级任务抢锁。4.2 堆栈溢出检测钩子怎么接、Cortex-M0 上的坑M0 没有 MPU任务栈溢出发生后硬件不会主动报警只会悄悄改写相邻内存。FreeRTOS 提供了两级软件检测通过 configCHECK_FOR_STACK_OVERFLOW 控制值为 1 时每次任务切换检查栈指针是否落在任务栈范围内值为 2 时在任务栈底部放一个哨兵字切换时检查哨兵是否被改写。建议直接用 2检出率更高代价是每次切换多几次内存比较在 M0 上依然可以接受。void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { taskDISABLE_INTERRUPTS(); for (;;) { __NOP(); } }钩子函数里先关中断再死循环是为了让现场停下来方便调试器挂起后查看当前任务栈指针和 pcTaskName。我一般会在这里加一个全局变量记录溢出的任务名比如strcpy(ucOverflowTaskName, pcTaskName)然后把 ucOverflowTaskName 放进调试器的 Watch 窗口才能在复位后知道到底是哪个任务出了问题。注意 F0 的栈是向下增长的溢出通常发生在任务栈的低地址端也就是数组的起始处如果你在任务里声明了一个 300 字节的局部数组这个数组可能正好跨过栈底哨兵字被改写但任务栈指针还停在合法范围钩子不一定立刻触发这就是为什么检测到溢出后第一件事是加大对应任务栈而不是改业务逻辑。4.3 互斥量的优先级继承与反转现场前面提到 printf 死锁属于互斥量优先级组合问题。更完整的称呼是优先级反转低优先级任务持有锁中优先级任务抢占 CPU导致高优先级任务拿不到锁被无限拖延。FreeRTOS 的 xSemaphoreCreateMutex 自带优先级继承机制当高优先级任务等待一把被低优先级任务持有的互斥量时内核会临时把低优先级任务的优先级提升到高优先级任务的级别让它尽快跑完释放锁。这里最容易混淆的是 xSemaphoreCreateBinary 和 xSemaphoreCreateMutex 的差别。二值信号量也常用来做串口发送互斥但它不做优先级继承。在只有两三个任务的工程里差别不明显一旦任务数超过四个、优先级层数拉满用二值信号量做互斥就会出现“中优先级任务把低优先级任务踩死”的经典反转。所以只要语义是“保护共享资源”一律用互斥量二值信号量只用于事件通知。调优先级时还要注意 F0 上 configMAX_PRIORITIES 设为 5意味着优先级 0 到 4。不要让任何任务跑到优先级 4这一档留给中断触发的临时提升否则调度器的 Tail Chain 优化空间会被压缩系统响应反而变慢。互斥量加优先级继承后M0 上每次任务切换会多一点时间戳和优先级比较的开销但对 48MHz 主频来说完全在承受范围内。5. 内存水位检查与一个排障技巧5.1 用 uxTaskGetStackHighWaterMark 看每个任务的真实余量多串口项目跑一段时间后最该验证的不是功能而是每个任务栈到底还剩多少。FreeRTOS 提供 uxTaskGetStackHighWaterMark返回任务从启动到现在出现过的“最小剩余栈量”单位是字。数值越小说明越接近溢出如果返回值长期是 1 或 2这个任务随时可能触发栈溢出钩子。在 H02BOX 里可以把统计任务做成每 5 秒轮询一次全部任务extern TaskHandle_t xSerial1TaskHandle; extern TaskHandle_t xSerial2TaskHandle; extern TaskHandle_t xSerial3TaskHandle; void vStatTask(void *pvParameters) { TickType_t xLastWakeTime xTaskGetTickCount(); for (;;) { UBaseType_t ulMinStack1 uxTaskGetStackHighWaterMark(xSerial1TaskHandle); UBaseType_t ulMinStack2 uxTaskGetStackHighWaterMark(xSerial2TaskHandle); UBaseType_t ulMinStack3 uxTaskGetStackHighWaterMark(xSerial3TaskHandle); vUart1Printf(h01%u h02%u h03%u\r\n, (unsigned int)ulMinStack1, (unsigned int)ulMinStack2, (unsigned int)ulMinStack3); vTaskDelayUntil(xLastWakeTime, pdMS_TO_TICKS(5000)); } }vTaskDelayUntil 和 vTaskDelay 的区别是前者按绝对时间触发不会因为任务中途被抢占而累积漂移。HighWaterMark 的数值是历史最小值不是当前剩余值所以只要任务运行期间出现过一次深递归或大数组这个值就会永久反映出来。调试时把 vUart1Printf 的格式化参数换成%u对照任务实际接收的帧率基本能判断是栈紧张还是队列积压。5.2 malloc 失败钩子与迁移残留排查configUSE_MALLOC_FAILED_HOOK 打开后当 heap_4 堆空间不足系统会调用 vApplicationMallocFailedHook。这个钩子的触发时机比栈溢出更容易定位因为它一定发生在某个创建任务或创建队列的调用处。把重点放在 vTaskCreate 的返回值检查上任何 pdPASS 以外的结果都要打印出来不要忽略。如果 xQueueCreate 返回值是 NULL优先查 configTOTAL_HEAP_SIZE而不是换一个更小的队列长度来“凑合”。最后给一个排障技巧从 F042 工程迁移到 F030C8T6 后先别急着跑功能把工程设置里的 RAM 起始地址和大小对着链接脚本核对一遍。F042 的 SRAM 从 0x20000000 开始共 6KBF030C8T6 是 8KB起始地址不变大小从 0x1800 改成 0x2000再用上面的 HighWaterMark 输出一组数值保存下来作为后续每次改动的回归基线。这样每次更新代码只需要对比这组数字哪个任务栈余量明显下降了就说明改动带进了新的深栈函数比等溢出钩子触发后再抓现场要快得多。本文还有配套的精品资源点击获取