项目标题是“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 库的延时调用走 TIM7,FreeRTOS 的系统节拍走 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) | 职责 | 阻塞点 |
|---|---|---|---|---|
| SensorTask | 3 | 256 | 周期读取心率/血氧数据 | 延迟/等待采集完成 |
| KeyTask | 2 | 128 | 扫描按键状态、防抖、触发事件 | 等待按键事件队列 |
| DisplayTask | 1 | 512 | 根据界面状态刷新屏幕 | 等待 UI 消息队列 |
| TimeTask | 0 | 128 | 维护时间/日期、计步累计 | 延迟 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 读数据总是超时,强烈建议直接用软件模拟 I2C,GPIO 手动翻转 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 里按住 Ctrl+F10 查看调用栈。如果发现当前执行的点在某个任务内部,基本是任务写坏了内存,比如数组越界、野指针、或者向一个已删除的队列发送消息。
第二步,查看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 菜单状态机和事件分发机制的详细实现,到时候见。