ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

FreeRTOS实战:基于STM32的智能手表任务架构与移植详解

FreeRTOS实战:基于STM32的智能手表任务架构与移植详解 项目标题是“FreeRTOS项目智能手表1”这其实是一个系列文的第一篇。我直接说结论干嵌入式这么多年RTOS 这关早晚要过。裸机跑逻辑确实直观但产品功能一多状态机套状态机全局变量满天飞到了后期加需求就像往装满的行李箱里硬塞东西每一次都要重新翻箱倒柜。把 FreeRTOS 用起来之后整个代码的条理性、可扩展性完全不是一个量级。这一篇我就拿自己做的一个智能手表原型项目当例子全程走一遍从环境搭建到任务架构设计的过程核心是把移植和任务划分这两块讲透。这个项目基于一块 STM32F103C8T6 小板子配了一块 1.3 寸的 IPS 屏幕外接一个心率传感器再加几个物理按键和一个小电机震动马达你能想到的主流智能手表的骨架功能这里都有个雏形。这篇内容适合正准备学 RTOS、或者已经看完理论但卡在“不知道怎么真正跑起来一个项目”的开发者如果对 CubeMX 不太熟也没关系文中所有操作我都会拆到细节层面。有一点我先把丑话说在前面很多人喜欢一上来就搬官方 eval board 的移植包对着参考手册一遍遍地改 startup 文件这个对学习内核原理有好处但对“我要尽快出一个可跑的原型”来说性价比太低。我的建议是用 STM32CubeMX 生成基础工程把精力重点放到任务设计、资源分配、优先级规划这些真正决定项目成败的地方上去。1. 项目整体设计与硬件选型的核心思路1.1 为什么用 FreeRTOS 来做手表而不是裸机轮询手表的业务逻辑单看每一项都不复杂读取传感器、刷新显示、响应按键、维护时间。但这些事情凑在一起就出现了一个尖锐的矛盾——不同外设对延迟的容忍度完全不同。按键扫描要求毫秒级响应传感器读取允许偶尔卡顿屏幕刷新占用时间最长但如果长时间不响应按键又会让用户体验变得非常差劲。在裸机环境下最常见的做法是写一个超级循环把轮询各个外设的时间片切得足够细。这种模式的问题不在于“能不能跑”而在于“改起来难”。你是用一个隐形的时序来约束所有功能模块之间的耦合关系今天改一个传感器驱动明天可能把按键响应拖慢 200 毫秒排查这种问题非常痛苦。校准不到位的嵌入式工程师的日常有一半都是在跟这种隐性关联做斗争。FreeRTOS 的思路是把 CPU 的使用权切给各个独立的执行序列每个任务看起来都是独占 CPU 在跑自己的逻辑。按键任务阻塞等待 GPIO 中断传感器任务按周期休眠唤醒显示任务只在有内容需要渲染时才被放进就绪队列。这样代码的语义结构跟实际业务天然对上每段逻辑自己管自己后期维护成本呈指数下降。另一个关键点是堆内存分配。裸机开发时大数组、静态缓冲区满天飞这块给显示、那块给通信改来改去很容易发生冲突。FreeRTOS 的 heap 管理组件能在运行期动态分配内存任务栈需要的空间、队列缓冲区的空间都可以按需向系统申请用完释放内存利用率比静态分配要高出一个档次这对 RAM 只有 20KB 的 STM32F103C8T6 来说是实打实的刚需。1.2 主控与外设选型权衡C8T6 到底够不够用很多人听到“智能手表”就下意识觉得内存至少要 256KB 起步这是个先入为主的误区。低端手表功能其实非常有限——显示时间日期、显示步数或心率、几个界面切换、续航尽量长。这些事情在 64KB Flash、20KB RAM 的 STM32F103C8T6 上完全做得开只要你不去跑 LVGL 这种重型 GUI 框架。我做原型选了一块国产的 STM32F103C8T6 核心板Flash 64KB、RAM 20KB。FreeRTOS 内核本身占用约 8~10KB Flash占用 RAM 约 1~2KB看你配置多少任务和队列。然后把显示部分的 buffer、传感器驱动、以及几个任务栈全部算进去整体 RAM 还能留下接近 4~5KB 的余量。这个余量在原型阶段完全够用就算后面要加功能也能通过换 C6T6 之类的大容量芯片平替。屏幕这块我选的是基于 ST7735 驱动的 1.3 寸 IPS 屏分辨率 240x240。这种屏幕驱动方式简单成本十来块钱在低端智能手表里特别常见。我采用 SPI 总线驱动6 根线搞定刷新率即便用软件模拟 SPI 也能达到勉强够用的水平。传感器部分用一颗采集心率/血氧的模块通过 I2C 访问。按键用了三个物理轻触开关一个负责解锁/点亮屏幕两个负责界面切换。震动马达接 GPIO 驱动一个小三极管放大电流直接由定时器 PWM 控制震动强度。硬件选型这块我踩过一个大坑——一开始想用 SPI 驱动一块 2.4 寸 320x240 的屏幕分辨率倒是不高但彩色深度 16bit 的图像缓冲要 150KB这在 C8T6 上根本塞不下。所以在项目设计阶段就应该用这个公式反复算分辨率 x 色彩深度 / 8 一帧所需内存。240x240 的屏幕RGB565 色彩深度一帧需要 240x240x2 115200 字节也就是接近 112KB。20KB 的 RAM 显然放不下。那低端方案是怎么解决的呢答案是直接用“写一个字刷一个字”的方式不预留全帧缓冲数据直接发到屏幕内部显存。代价是不能做复杂的窗口裁剪和局部刷新技术但功能完全够用。1.3 开发环境与工具链的确定这套项目我用的是 Keil MDK 5 加 STM32CubeMX 生成的工程。CubeMX 负责底层初始化代码的自动生成比如 GPIO、SPI、I2C、以及最重要的 FreeRTOS 中间件配置。在 CubeMX 图形界面里勾选 FreeRTOS 之后它会自动生成一个带 CMSIS_RTOS V1 封装层的工程里面已经包含一个默认的defaultTask而且任务创建、信号量、队列这些 API 都被封装为 CMSIS 标准接口。这套流程比手动移植轻松太多关键是出错概率极低因为所有的启动汇编、堆栈初始化、中断向量绑定都是 CubeMX 干掉的。有人会问CubeMX 生成的 FreeRTOS 能深度修改吗当然能。CubeMX 只是生成一个初始骨架你以后在代码里手动创建任务、队列、信号量完全不受影响。遇到heap_4.c不够用的情况还可以替换内存管理算法或者直接调整 FreeRTOSConfig.h 里的配置这些都不再依赖 CubeMX。后面我会说哪些配置必须在 CubeMX 里摆好哪些可以直接改头文件掌握这个边界很重要。用 Keil 做编译下载和调试用 ST-Link V2 作为下载器。Keil 里的调试器配置可以开启 RTOS 窗口实时查看任务状态、堆栈使用率这在排查“哪个任务跑飞了”的时候像是一把手术刀。2. FreeRTOS 在 STM32F103 环境下的移植实操2.1 CubeMX 里的配置路径与参数选择打开 STM32CubeMX新建一个工程选择 STM32F103C8Tx然后在Pinout Configuration标签页配置外设。时钟树是第一步。板载晶振如果用的是 8MHz那么在RCC里选择Crystal/Ceramic Resonator系统时钟在 Clock Configuration 界面里配置为 72MHz。这一步必须确认主频正确因为 FreeRTOS 的系统节拍是基于 SysTick 的如果主频不对时间基准会全部偏移。比如你以为延时了 10ms实际可能跑了 100ms。CubeMX 生成代码后SystemClock_Config会自动写好不需要手动改。接着在Middleware分类下找到FreeRTOS启用它。界面弹出后关键参数有这几个CMSIS_RTOS API version选 V1 即可V2 主要配合 ARM Compiler 6 使用如果你还在用 AC5 编译器Keil 默认选 V1 兼容性更好。Minimum stack size默认 128 words这是任务栈的最小值别改小。Memory management scheme选Heap_4。堆内存管理算法的选择直接关系到内存碎片问题Heap_4 支持合并相邻空闲块适合频繁创建/删除任务的场景也适合不同大小的任务栈分配。TICK_RATE_HZ默认 1000表示系统节拍为 1ms。这个值越高系统调度的粒度越细但中断开销也越大。手表这种交互密集型产品1000Hz 合适。生成代码之前建议在Project Manager里把编译器选为 MDK-ARM V5把工具链版本和 Keil 匹配好。生成的代码可以直接打开.uvprojx文件。2.2 SysTick 与 HAL_Delay 的冲突规避说一个几乎人人都会踩的坑你如果直接在 CubeMX 里默认使能 FreeRTOS 1ms 系统节拍这时候 HAL 库的HAL_Delay()默认依赖 SysTick 来实现延时但 SysTick 已经被 FreeRTOS 抢占用作系统节拍两者就会打架。具体表现是HAL_Delay(100)的实际等待时间完全不可控偶尔正常偶尔溢出。解决方案是在 CubeMX 的 SYS 选项卡里设置Timebase Source为其他定时器比如 TIM7。这个操作做完之后HAL 库的延时调用走 TIM7FreeRTOS 的系统节拍走 SysTick各干各的互不干扰。这一步强烈建议在生成工程之前就搞定生成之后再改虽然也行但需要手动改启动文件和中断处理容易出错。还有一点注意在 FreeRTOS 环境下不要在任务里调用HAL_Delay()。正确做法是使用osDelay()或者vTaskDelay()因为这些函数会让出 CPU 给其他就绪任务让系统保持并发运行。如果某个任务用HAL_Delay(1000)这 1 秒内 CPU 就在这个任务里死等对 RTOS 的实时性是一个伤害。2.3 中断优先级与临界区的正确配置FreeRTOS 在 CM3 内核上有两个专门的中断PendSV 和 SysTick。PendSV 用来做任务切换SysTick 用来做时间片轮转。这里面有一个特别重要的配置逻辑——中断优先级的数值越大实际优先级越低。而 FreeRTOS 要求所有使用系统 API 的中断其优先级必须数值上大于即实际优先级低于configMAX_SYSCALL_INTERRUPT_PRIORITY。CM3 只用高 4 位表示优先级所以优先级取值为 0 到 15其中 0 级最高。CubeMX 默认把 PendSV 和 SysTick 设为最低优先级数值 15把HAL_Delay用到的 TIM7 设为某个中等优先级这样做是为了避免普通中断打断系统节拍。实际项目里如果你要用到 EXTI 外部中断来唤醒按键也应遵循这个规则。按键中断的优先级可以比 SysTick 高但不能高到抢了 SysTick 或者调用了会立即触发任务调度的 FreeRTOS 接口而不符合优先级约束否则系统可能在调用队列操作或信号量释放时被打断导致临界区受到破坏。如果你使用 IRQHandler 里直接调用osSemaphoreRelease这类 FreeRTOS API务必把该中断的 priority 设置到大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY的数值。CubeMX 生成的 FreeRTOSConfig.h 中这个值默认是 5意味着优先级数值 5 到 15 的中断才能安全调用 FreeRTOS API。超过这个限制的中断优先级比系统调用允许的上限更高不能调用这些 API否则可能触发断言。2.4 移植完成后第一个任务的运行验证写一个简单的 Blink 任务验证移植是否成功。在main.c里创建任务我在 CubeMX 生成的MX_FREERTOS_Init函数里用osThreadNew创建了一个叫BlinkTask的任务栈大小为 128 字节能跑。任务代码如下void BlinkTask(void *argument) { for (;;) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); osDelay(500); } }把程序烧进去之后如果 LED 以 1Hz 频率闪烁说明系统节拍、任务创建、上下文切换这些基本链路是通的。这一步看起来简单却是整个项目的地基。地基不打牢后面再调试心率传感器、显示驱动都是空中楼阁。有一个判断系统是否存活的辅助手段在调试器里打开 FreeRTOS 任务列表窗口Keil 的 RTOS 调试支持。能看到BlinkTask处于 Running/Ready 状态而且堆栈使用率没超过 100%说明这个任务一直在正常被调度。如果任务列表里根本看不到这个任务说明它可能在创建时就挂掉了或者调度器压根没启动。检查调度器是否启动就看vTaskStartScheduler()是否被调用CubeMX 生成的osKernelStart()里会调。3. 手表系统的任务架构与关键模块实现3.1 任务划分的几条硬性原则项目规模一旦超过三四个功能模块任务划分就变成一种权衡艺术。我给出的几条经验原则都是从实际项目里打磨出来的原则一实时性要求不同的功能不要放同一个任务。按键响应要毫秒级显示刷新可以容忍几十毫秒传感器数据可以接受几百毫秒延迟。它们如果裹在同一个循环里执行周期只能按最慢的那个来。原则二占用时间长的操作独立成任务避免阻塞别人的关键路径。SPI 刷屏一帧差不多要十几毫秒I2C 读传感器一次也要几百微秒把这两件事放一个任务里按键就会变得迟钝。原则三任务间通信必须通过队列或信号量用全局变量传状态只在极少数情况下可接受。这个说法用图表可能更直观但实际上就是一句话如果你有两个任务读写同一个变量它们之间没有同步机制系统跑起来就是一场竞态灾难。原则四每个任务都要想清楚它的阻塞点是什么。任务不可能全是纯计算逻辑一定要有等待事件、等待超时、等待队列消息这些阻塞点。阻塞时任务进入 Blocked 状态CPU 才能去做别的事。在这个项目里我把系统划分为四个任务显示任务、按键扫描任务、传感器读取任务、时间管理任务。任务名优先级栈大小words职责阻塞点SensorTask3256周期读取心率/血氧数据延迟/等待采集完成KeyTask2128扫描按键状态、防抖、触发事件等待按键事件队列DisplayTask1512根据界面状态刷新屏幕等待 UI 消息队列TimeTask0128维护时间/日期、计步累计延迟 1 秒3.2 任务间通信机制选型与代码实现这个系统里最重要的通信有两条链路按键扫描任务把按键事件投递给显示任务传感器任务把采集结果发送给显示任务用于数据刷新。按键事件使用二值信号量还是队列我直接用队列。因为按键可能有多个类型短按、长按、双击队列里可以带一个事件结构体把按键类型和发生时刻都放进去。这里我把事件定义成枚举类型放在头文件里统一管理typedef enum { EVENT_KEY_NONE 0, EVENT_KEY_SINGLE_CLICK, EVENT_KEY_DOUBLE_CLICK, EVENT_KEY_LONG_PRESS, EVENT_SENSOR_HR_UPDATE, EVENT_SENSOR_SPO2_UPDATE } SystemEvent_t;队列定义QueueHandle_t xEventQueue; xEventQueue xQueueCreate(10, sizeof(SystemEvent_t));这里注意xQueueCreate的第一个参数是队列深度第二个参数是每个元素占用的字节数。队列深度设 10 是因为可能短时间内有传感器数据到达需要缓冲设为 1 也可以工作但频繁地丢消息会影响体验。尺寸方面这个项目的消息很小队列占用的内存大约是 10 x 4 字节 40 字节很轻量。按键扫描任务的核心逻辑如下void KeyTask(void *argument) { SystemEvent_t evt EVENT_KEY_NONE; uint8_t key_state 0; uint8_t last_key_state 0; uint32_t press_time 0; for (;;) { key_state read_key_gpio(); if (key_state 1 last_key_state 0) { press_time xTaskGetTickCount(); } else if (key_state 0 last_key_state 1) { uint32_t duration xTaskGetTickCount() - press_time; if (duration 50) { if (duration 1000) evt EVENT_KEY_LONG_PRESS; else evt EVENT_KEY_SINGLE_CLICK; xQueueSend(xEventQueue, evt, 0); } } last_key_state key_state; osDelay(10); } }按键扫描任务里的防抖逻辑是我加的检测到按键从按下变成释放时计算按下的持续时间超过 50ms 认为是有效按键超过 1 秒认为是长按。这个防抖加长按判断的办法在裸机开发里要写一堆外部状态变量但在 RTOS 里天然就是一个阻塞循环更自然。3.3 传感器任务与数据处理传感器模块通过 I2C 读取心率传感器芯片。这里我设计了一个周期任务每 200ms 读一次原始数据然后过滤掉明显无效的毛刺值把平滑结果发送到显示队列。void SensorTask(void *argument) { uint16_t hr_raw 0; uint8_t hr_valid 0; uint16_t hr_filtered 0; SystemEvent_t evt EVENT_SENSOR_HR_UPDATE; for (;;) { hr_raw read_heart_rate(); hr_valid (hr_raw 40 hr_raw 220) ? 1 : 0; if (hr_valid) hr_filtered (hr_filtered * 3 hr_raw) / 4; if (xQueueSend(xEventQueue, evt, 0) ! pdTRUE) { // 队列满说明显示任务还没处理完上一轮数据 // 这里直接丢弃新数据保证传感器任务不被阻塞 } osDelay(200); } }这里有个值得展开的调参点为什么滤波公式用(旧值*3 新值) / 4这种简单的一阶低通因为心率波形本身就是周期信号采样周期 200ms对应的奈奎斯特频率只有 2.5Hz低频噪声不多一阶低通就足够抑制毛刺而且不需要维护数组内存开销为零代码就一行。read_heart_rate内部实现用的是 HAL 库的 I2C 接口。这块我有一个经验提醒STM32F103 的 I2C 硬件模块在低速传感器通信时确实容易卡死如果用的是国产传感器芯片兼容性更差。如果你在调试时发现 I2C 读数据总是超时强烈建议直接用软件模拟 I2CGPIO 手动翻转 SCL/SDA。模拟 I2C 的数据速率上限虽然只有 100kHz 左右但对心率传感器完全够用。3.4 显示任务的刷新策略显示任务是整个系统中最吃资源也最应该优化的一部分。手表的 UI 逻辑说起来就是一堆静态页面加上少量数值更新。我维护了一个current_page变量显示任务根据页面 ID 决定绘制内容void DisplayTask(void *argument) { SystemEvent_t evt; for (;;) { if (xQueueReceive(xEventQueue, evt, portMAX_DELAY)) { switch (current_page) { case PAGE_MAIN: draw_main_page(); break; case PAGE_HR: draw_hr_page(); break; case PAGE_INFO: draw_info_page(); break; default: break; } } } }draw_main_page()内部会先调用 ST7735 的清屏函数然后绘制时间、日期、电量图标和步数。清屏这一步其实是整个 UI 里最费时的操作一次FillScreen要往 SPI 发 115200 字节的数据。所以我做了一件事页面切换时才全屏刷新数值变化时只刷新局部区域。局部刷新指先画一个填充矩形覆盖掉旧数字的区域再在那个位置重新绘制新数字。这里还要提一个 FreeRTOS 相关的关键点SPI 外设是全局共享资源如果传感器任务里面也有 SPI 读写有些传感器用 SPI 接口显示任务也在用 SPI两个任务同时操作 SPI 外设就会造成总线冲突。解决方案有两种一种是你只有一个任务使用 SPI其他任务通过队列把数据交给它另一种是用互斥量锁住 SPI 总线哪个任务拿到锁才能操作外设。我的项目里传感器走 I2C、屏幕走 SPI两者不冲突所以我直接用队列通信没加互斥量。但如果你后续加了 SPI Flash 存储这块必须用xSemaphoreCreateMutex()做互斥保护。3.5 时间管理与低功耗思路智能手表免不了要求时间准。我维护了一个 RTC 模块一小时同步一次系统 TICK 计数。在 FreeRTOS 里低功耗模式是另一个大话题第一版原型我没做特别深的 tickless 模式但会在任务设计中留出vTaskDelayUntil这种绝对延时的接口方便后续把这些周期任务全部票选进低功耗序列。时间任务用osDelay(1000)做秒计数这个概念很简单但实际开发有一个细节osDelay(1000)并不保证精确地每隔 1000ms 执行一次。如果在任务执行过程中有更高优先级的任务抢占了 CPU实际唤醒间隔可能变成 1100ms 甚至更多。如果对时间敏感要用vTaskDelayUntil(xLastWakeTime, pdMS_TO_TICKS(1000))来保证周期稳定性。这在步数计步的时间戳记录上影响尤其明显积少成多会差出几分钟。void TimeTask(void *argument) { TickType_t xLastWakeTime xTaskGetTickCount(); for (;;) { update_rtc_from_tick(); xLastWakeTime xTaskGetTickCount(); vTaskDelayUntil(xLastWakeTime, pdMS_TO_TICKS(1000)); } }4. 调试排查手记堆栈溢出、HardFault 与优先级反转4.1 用任务列表定位“跑飞”的任务在这个项目的开发过程中最痛苦的一次调试经历是设备运行大约十分钟后显示画面开始出现撕裂然后死机。裸机调试遇到这种问题基本靠猜但 FreeRTOS 里有现成的排查工具。首先在FreeRTOSConfig.h里开启configUSE_TRACE_FACILITY为 1然后通过vTaskList把每个任务的状态、堆栈高水位线打印出来。我在调试串口里看到了关键信息Name State Priority Stack Num DisplayTask B 1 210 3 SensorTask R 3 250 5DisplayTask 的理论栈是 512 words当前高水位线只有 210意思是这中间 302 words 都浪费了。而 SensorTask 的栈是 256 words当前水位 250已经非常接近溢出。也就是说传感器任务的栈分配太紧了一个局部数组或者一层函数调用深度稍微一变化就会爆栈。我把 SensorTask 的栈从 256 扩大到 384故障立刻消失。这个排查思路值得多说一句不开vTaskList的话堆栈溢出表现为系统随机死机或 HardFault你几乎无从下手。开了任务列表后哪个任务栈告急一目了然。所以实际项目里不要省这一步配置。4.2 堆栈溢出钩子函数的使用除了主动查看任务列表FreeRTOS 还提供两个自动检测的手段。一个是configCHECK_FOR_STACK_OVERFLOW设为 1 或 2在任务上下文切换时系统会检查栈指针是否越界如果发现越界会调用用户定义的vApplicationStackOverflowHook。我的习惯是在钩子函数里点亮一个独立的错误 LED同时向调试串口输出任务名。void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { printf(STACK OVERFLOW: %s\r\n, pcTaskName); while (1); }注意钩子函数触发时系统已经处于异常状态不能在里面做太多复杂操作。点亮 LED 输出信息然后死循环卡住方便你定位是哪一任务出问题。我在项目中实际触发过这个钩子一次原因很有意思不是任务里写了大数组而是工程链接时勾选了 MicroLIB导致某些库函数的调用栈比预想的大。解决办法是给那个任务的栈加 64 words再也没炸过。4.3 开机进 HardFault 的排查顺序HardFault 是 STM32 开发者最常碰到的非法异常。FreeRTOS 项目里会看到硬件出错后系统停止调度调试器跳到 HardFault_Handler。我的排查顺序有固定的三步。第一步打开 Keil 的 Call Stack Locals 窗口在 HardFault_Handler 里按住 CtrlF10 查看调用栈。如果发现当前执行的点在某个任务内部基本是任务写坏了内存比如数组越界、野指针、或者向一个已删除的队列发送消息。第二步查看SCB-CFSR寄存器和SCB-MMFAR、SCB-BFAR。CFSR会告诉你是什么类型的错误总线错误Bus Fault、存储器管理错误MemManage Fault、还是用法错误Usage Fault。如果BFAR有值并且你在代码里用到了这个地址八成是非法访问。第三步检查栈内是否有残留的线程 ID。FreeRTOS 的任务创建时会在栈里写入一个特定的初始化模式0xA5A5A5A5看栈指针附近的数据如果大量残留这个值说明栈富余如果这个值已经被完全覆盖说明栈不够。4.4 优先级反转的实际应对RTOS 面试题里十有八九会问优先级反转但真正项目里遇到这个问题的场景是传感器任务优先级高显示任务优先级低某个时刻显示任务正在写 SPI 总线被一个中断剥夺了 CPU此时高优先级传感器任务因为要等待 I2C 总线释放而阻塞但 I2C 总线的释放又依赖那个被中断打断的低优先级显示任务继续往前跑。这种互相等待就形成了典型的优先级反转最终表现为传感器数据偶尔卡顿一下。FreeRTOS 的解决方式是互斥量自带优先级继承机制。在创建互斥量时用xSemaphoreCreateMutex()当一个高优先级任务等待一个低优先级任务持有的互斥量时系统会在等待期间暂时把低优先级任务的优先级提升到高优先级任务的水平让它尽快执行并释放锁。这段逻辑对用户是完全透明的你只需要在涉及共享总线时不要用二值信号量代替互斥量。实际项目里我也确实踩过二值信号量当互斥量用的坑。二值信号量不提供优先级继承而且释放信号量的任务不要求是同一个任务意味着你可以在 A 任务加锁在 B 任务解锁这种能力用来做同步没问题但用来保护共享资源就是灾难。正确做法是凡是要互斥访问的硬件资源I2C、SPI、ADC一律创建互斥量而二值信号量只用来做“事件发生提醒”。4.5 内存碎片与 heap_4 的选择逻辑对于这个手表项目任务创建后基本不删除队列是一开始创建好的也没动态创建销毁按理说内存碎片不会很严重。但为什么我还是选择 Heap_4 而不是更省内存的 Heap_1、Heap_2原因很简单因为系统里有一个场景会反复分配/释放内存——传感器数据可能需要拼接字符串再发给显示队列还有调试串口的事件日志。一旦代码里出现malloc/free或者pvPortMalloc/vPortFree的成对调用就必须用支持内存合并的 Heap_4否则长期运行后 20KB 内存会被拆得七零八落。从数据上看我的工程启动后xPortGetFreeHeapSize()大概是 7KB 左右运行十分钟后降到 5KB再过一段时间稳定在 4.5KB。这说明内存泄漏仍然存在但并不致命。排查泄漏的办法是在vPortFree前后打印堆大小看哪段逻辑在频繁掉内存。这个位置就是我传感器任务里的字符串拼接代码改用snprintf后内存稳定在了 5.5KB。如果你希望完全规避内存泄漏另一种思路是任务栈和队列缓冲区全静态分配然后用 Heap_1只分配不释放。但对于一个要长期跑的智能手表我还是倾向 Heap_4 谨慎的字符串处理。5. 项目迭代方向与我在实际中的体会这一篇是系列文章的开篇整个项目跑起来的基础流程到这里就算差不多了。后续我计划在第二篇里讲如何把界面做得更丰富、加入多级菜单第三篇会说电池管理、低功耗模式和更复杂的事件分发。但聊到这儿我先分享几个我在过程中踩出来的经验。第一FreeRTOS 的项目调试不要全靠串口打印优先把vTaskList、vTaskGetRunTimeStats用起来。任务的可视化状态表比任何日志都快。第二任务栈宁大勿小但也要定期回头看高水位线适时精调两者结合才是长期稳定的保障。第三配置 FreeRTOS 时一定要理解每一个宏的含义直接照搬网上的配置模板虽然能跑但遇到问题时你会不知道从哪里下手。第四也是最重要的一点——RTOS 不是银弹任务划分设计得不好照样是烂代码只是从“裸机烂”变成“带系统烂”。这个项目做到现在我对 FreeRTOS 的体会越来越深。它最大的价值不是“多任务”本身而是把一个复杂的嵌入式产品拆解成若干个独立的、可测试的小模块。每个任务都是一个独立团队队列是团队间的沟通渠道调度器是统一协调的管理者。有了这套机制后续加功能、换硬件、修 bug 的心理负担少了很多。各位手上如果有正在用 RTOS 做的项目或者正准备从裸机切到 FreeRTOS 的欢迎在评论区把任务架构图或者调试踩坑经历发出来大家互相照应踩坑。下一篇我们聊聊 UI 菜单状态机和事件分发机制的详细实现到时候见。
返回列表