ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

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

基于STM32的FreeRTOS智能手表实战:任务调度与移植详解 做嵌入式这几年我见过太多人学FreeRTOS卡在同一个地方任务创建会了、信号量会用了但一放到真实项目里就不知道该怎么搭框架。恰好前阵子收了一块吃灰的STM32F103C8T6和一块小屏就想着干脆做个智能手表练练手——这玩意儿功能看着简单真做起来要碰调度的实时性、显示刷新、按键响应、低功耗一堆事FreeRTOS那套机制几乎全都能用上。这篇文章是系列的第一篇先把项目整体思路、硬件选型、FreeRTOS移植链路和第一个多任务Demo讲透后续再逐个模块拆解。如果你正准备用FreeRTOS做点完整的东西或者已经把板子买回来但不知道从哪儿下手这篇应该能帮你省不少弯路。我尽量把每一步为什么要这么做讲清楚而不是只丢给你一段能编译通过的代码。1. 为什么拿智能手表练手FreeRTOS一个不鸡肋的项目选题1.1 手表这个场景天然适合演示RTOS的价值很多人学RTOS最大的困惑是我写的裸机程序也跑得好好的为什么要引入操作系统这个问题的答案在做手表项目时会非常直观。一块智能手表背后要同时处理的事情至少包括屏幕刷新、触摸或按键检测、时间计数、传感器数据采集、蓝牙通信、低功耗管理。如果用裸机状态机去写任何一个外设的耗时操作都可能阻塞其他模块代码会越改越绕。FreeRTOS解决的正是这个问题把每件事拆成独立任务让它们按优先级和调度策略轮流占用CPU。比如按键扫描这种突发任务不需要频繁执行传感器采集可以固定周期跑UI刷新优先级低一点、不抢系统资源这些在裸机里要花大力气才能理清的协作关系在RTOS里就是几个任务配置的事。1.2 功能拆解这块表最小可用版要做什么既然是系列项目我不会一上来就画一个大而全的饼。第一版智能手表我只规划了四个核心功能时间显示使用RTC或软件计数器维护时间在TFT屏幕上显示时、分、秒。按键交互两颗物理按键用于切换页面或触发计时功能。数据采集读取板载温度传感器或光敏电阻在屏幕上显示实时环境数据。状态反馈LED灯或蜂鸣器在特定事件下给出提示。这些功能单拎出来都不难但组合在一起就必须考虑优先级、资源分配和通信机制了。比如按键是突发事件应该用中断唤醒温度采集不要求实时可以低频轮询屏幕刷新比较慢不能让它在关键路径上卡住其他任务。1.3 这一系列文章的学习路线后续我打算按这个顺序推进本篇文章先把FreeRTOS移植和任务框架跑通第二篇做显示驱动和简单UI第三篇做传感器数据采集与任务间通信第四篇再考虑低功耗和外部中断优化。每一篇都会把你实际会遇到的问题——而不是教程里那种刚刚好的问题——摊开来讲。2. 硬件选型与工程搭建F103C8T6这套配置的取舍2.1 为什么选STM32F103C8T6作为主控我手头这块F103C8T6可以说是学习用街板了20KB RAM、64KB Flash主频72MHz跑FreeRTOS加一个轻量级UI库完全够用。选择它还有一个好处是生态成熟CubeMX直接支持Keil和IAR都能用遇到问题搜解决方案一抓一大把。如果你手头有其他板子比如F103RCT6、F407或者H743思路完全一样。F407资源更充裕跑LVGL会更流畅但作为FreeRTOS入门项目C8T6反而更能逼你学会精打细算——栈多给一点都不行真真切切体会到内存管理的紧张感。2.2 屏幕与外设的具体选型屏幕我用的是1.44寸ST7735 SPI接口TFT屏128x128分辨率理由是驱动简单、资料多而且SPI接口只占用几根GPIO不跟I2C传感器抢引脚。你如果用0.96寸OLED或者ST7789的屏幕也完全没问题驱动文件和显示驱动接口稍微改一下就行。传感器方面第一版我选了I2C接口的温湿度传感器SHTC3以及一个ADC接口的光敏电阻。I2C天生适合低速、周期性的数据采集用FreeRTOS的消息队列把它采集到的数据发给UI任务再合适不过了。2.3 CubeMX初始化的关键步骤工程搭建我用STM32CubeMX生成版本建议1.9以上生成的HAL库代码比较稳定。关键配置如下RCC时钟设置HSE选择Crystal/Ceramic ResonatorHCLK设为72MHzAPB1分频236MHzAPB2不分频72MHz注意APB1上的定时器时钟是72MHz因为定时器时钟是APB1的两倍这在后面配置HAL时基定时器时会用到。GPIO分配SPI1_SCK: PA5SPI1_MOSI: PA7屏幕CS: PA4DC: PA6RST: PA3BL(背光): PA2按键KEY1: PB0外部中断模式KEY2: PB1外部中断模式LED: PC13推挽输出I2C与ADCI2C1: PB6SCL、PB7SDA速率400kHzADC1_IN0: PA0用于读取光敏电阻电压2.4 生成代码前的两个细节很多人在CubeMX里勾了一堆外设就直接Generate结果编译通过但实际跑起来各种诡异问题。这里有两个细节一定要注意第一在Project Manager - Project标签页里把Toolchain选成MDK-ARM V5或V6这会直接生成一个可以直接打开的Keil工程。如果你习惯用IAR就选EWARM生成的工程结构略有差异但核心代码完全一致。第二在Project Manager - Code Generator标签页里勾选Generate peripheral initialization as a pair of .c/.h files per peripheral。这样每个外设的初始化代码独立成文件不像默认那样全部堆在main.c里后续维护会舒服得多。3. FreeRTOS移植细节时基冲突、堆区配置与第一处深坑3.1 在CubeMX里添加FreeRTOS的完整流程CubeMX里的Middleware and Software Packs - FREERTOS勾选Interface为CMSIS_V1还是CMSIS_V2我建议直接用CMSIS_V2它对FreeRTOS 10.x的支持更完整API也更接近原生FreeRTOS。CMSIS_V1虽然也能用但新项目没必要选择旧接口。勾选之后CubeMX会自动生成FreeRTOS的初始化代码包括内存分配器、任务列表、默认的start任务。但你需要在Configuration里手动添加任务或者生成代码后自己写任务创建函数我建议用后者——完全靠CubeMX的可视化配置反而限制太死手写任务函数更灵活。3.2 时基冲突一个必踩且必须理解的坑如果你什么都不改直接用HAL_Init初始化再开FreeRTOS板子大概率会死在第一个vTaskDelay上。原因很经典HAL库默认用SysTick做时间基准FreeRTOS也需要SysTick做时间片轮转和系统节拍两边抢一个定时器谁也跑不顺。解决办法也简单把HAL时基从SysTick换成一个基本定时器。我在CubeMX的SYS选项卡里把Timebase Source改成TIM6。这样SysTick完全交给FreeRTOS管理HAL库的延时和超时机制走TIM6两者互不干扰。提示改完时基之后HAL_Delay依然可以用但它此时依赖TIM6。在FreeRTOS任务里我还是建议用vTaskDelay替代HAL_Delay做非阻塞延时否则一个任务会卡住整个调度器。3.3 参数配置堆、栈与时钟频率FreeRTOS的参数配置中最重要的是总堆大小configTOTAL_HEAP_SIZE。F103C8T6只有20KB RAM默认配置给的堆大小是12KB左右可以先用这个值跑通后续如果创建的任务多了按需调整。有一个原则是总堆大小必须可以容纳所有任务栈、消息队列和信号量宁可放大不要抠太小但也不能超过芯片RAM上限。任务栈大小方面建议每个任务先给256 words即1KB跑起来后用uxTaskGetStackHighWaterMark检查剩余空间再逐步调整。系统节拍configTICK_RATE_HZ我设成1000Hz也就是1ms一个tick做计时任务时精度比较够用。3.4 第一处编译错误obj目录创建失败按正常顺序生成工程、编译你可能遇到这个经典的报错.\obj\freertos.hex: error: Q0147E: failed to create directory .\obj\freertos这个错误的原因很直接Keil的输出目录配置成了.\obj\freertos.hex这种路径但obj目录下没有先创建freertos子目录。老版本Keil会自动帮你建新版本反而不会。解决办法有两个要么在工程文件夹下手动创建一个obj目录并在其中新建freertos子目录要么在Options for Target - Output标签页里把Select Folder for Objects改成.\obj\不带子目录名。这类跟代码无关的环境问题卡住一次基本上就记住了后面很难再犯。但我想说的是别烦它编译环境和工具链的问题本来就是嵌入式开发学习曲线的一部分踩过一次会发现它其实特别简单。3.5 手动编写第一个任务创建函数CubeMX生成代码后在main.c的MX_FREERTOS_Init函数里可以看到系统默认创建了一个defaultTask。我习惯删掉它改成自己需要的任务void MX_FREERTOS_Init(void) { xTaskCreate(AppTaskCreate, AppTaskCreate, 256, NULL, 1, AppTaskCreate_Handler); osKernelStart(); } static void AppTaskCreate(void *argument) { xTaskCreate(Task_UI, Task_UI, 256, NULL, 2, Task_UI_Handler); xTaskCreate(Task_Sensor, Task_Sensor, 256, NULL, 1, Task_Sensor_Handler); vTaskDelete(NULL); } static void Task_UI(void *argument) { while(1) { // 更新屏幕显示 vTaskDelay(pdMS_TO_TICKS(50)); } }这段代码的逻辑是先创建一个任务创建者任务在里面再创建真正的业务任务创建完之后把自己删除。这样做的好处是所有任务的创建集中在入口函数里后续新增任务一目了然。3.6 Keil和IAR的移植差异如果你用的是IAR而不是Keil移植思路完全一致CubeMX生成的EWARM工程可以直接用。差别主要是编译器的优化策略和栈检查方式但对FreeRTOS本身没有任何影响。不过有一个习惯要养成无论用哪个IDE编译器的优化级别不要开最高尤其Debug阶段用-O0或-O1即可。之前遇到过一个奇怪的现象优化开到-O3之后任务调度时序有轻微变化排查起来非常痛苦最后发现是编译器把一些变量优化掉了。4. 任务划分与数据流这块表里每个线程在忙什么4.1 任务的职责边界与优先级设计一个简单的智能手表任务清单可以拆成下面几类任务名功能触发方式优先级典型周期/事件Task_KeyScan扫描物理按键外部中断通知3最高事件触发Task_Sensor读取温度/光照周期轮询2500msTask_UI刷新屏幕显示周期轮询250msTask_Log串口日志输出队列消息1最低消息触发优先级的划分原则是突发性要求高的事件给高优先级对实时性要求不高的刷新和日志给低优先级。按键扫描最特殊它应该用外部中断消息队列的方式中断里只发信号真正的扫描和处理放在任务里做这样既保证响应速度又不阻塞系统。这就是FreeRTOS二值信号量最典型的应用场景。4.2 用队列做任务间通信传感器任务采集到数据之后要发给UI任务显示按键任务处理完按键事件后也要通知UI切换页面。这里我选择了FreeRTOS消息队列QueueHandle_t xSensorDataQueue; QueueHandle_t xKeyEventQueue; typedef struct { uint16_t temperature; // 温度值缩小10倍 uint16_t light; // 光照ADC值 } SensorData_t; static void Task_Sensor(void *argument) { SensorData_t data; while(1) { data.temperature SHTC3_ReadTemp(); data.light ADC_GetValue(); xQueueSend(xSensorDataQueue, data, 0); vTaskDelay(pdMS_TO_TICKS(500)); } }队列为什么比共享变量好用因为队列天然具备阻塞消费者、保护临界区的作用。UI任务在xQueueReceive时如果没有数据就会把自己挂起让出CPU给其他任务执行这比轮询共享变量要节省资源得多。4.3 栈大小的估算与堆栈溢出检测任务栈给多少是新手最容易摸不着头脑的地方。我的经验是先给一个保守估计值跑起来后用高水位线函数去核验。假设一个函数嵌套调用的局部变量加起来最多占用200字节50 words加上任务上下文的保存空间256 words的栈初始值比较合适。运行一段时间后在代码里加一句检查UBaseType_t highWaterMark uxTaskGetStackHighWaterMark(Task_UI_Handler);这个值代表这个任务从启动到现在栈剩余空间最少是多少。如果返回值非常小说明栈快溢出了要调大如果一直很大说明栈分配过于宽松可以在RAM紧张时适当缩小。另外你可以在FreeRTOSConfig.h里设置#define configCHECK_FOR_STACK_OVERFLOW 2这个是FreeRTOS内置的栈溢出检查机制会在任务切换时通过硬件检查栈指针是否越界。开了之后如果发生溢出会调用vApplicationStackOverflowHook你可以在这个钩子里点亮LED或者断点告警。4.4 Cortex-M3内核切换流程知其所以然用FreeRTOS这么久我还是建议你花点时间理解一下Cortex-M3的上下文切换流程。简单来说FreeRTOS依赖两个特殊的机制实现任务切换SysTick异常周期性产生每个系统节拍到达时内核会决定是否切换到更高优先级的就绪任务。PendSV异常所有其他更高优先级中断处理完毕后才执行被用作延迟的上下文切换。这个过程大致是SysTick中断触发 - 内核保存当前任务的寄存器到其任务栈 - 找到下一个最高优先级就绪任务 - 恢复它的寄存器 - CPU开始执行新任务。PendSV的存在保证了在真正的硬件中断处理过程中不会被任务切换打断从而减少了竞态安全问题的发生。了解这个机制的价值在于当你遇到任务乱跳、数据被覆盖这类问题能快速想到是不是临界区没有保护、优先级设计有没有问题而不是无头苍蝇一样到处调试。4.5 二值信号量与按键消抖的配合按键扫描任务接到的其实是按键中断发来的信号量。外部中断里我做两件事清除标志位然后xSemaphoreGiveFromISR释放一个信号量。void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { BaseType_t xHigherPriorityTaskWoken pdFALSE; if (GPIO_Pin KEY1_Pin) { xSemaphoreGiveFromISR(xKeySemaphore, xHigherPriorityTaskWoken); } portYIELD_FROM_ISR(xHigherPriorityTaskWoken); }任务收到信号量后延迟20ms再读取一次电平这样就把机械抖动过滤掉了。二值信号量在这里的作用是事件标志而非计数按键每按一次唤醒一次任务。注意在中断服务函数里不能调用xSemaphoreGive这类不带FromISR后缀的API否则会因中断上下文切换引发不可预期的行为。这是FreeRTOS初学者的高频错误。5. 点亮屏幕与显示刷新LVGL在FreeRTOS里的集成路线5.1 为什么要在这个阶段引入LVGL屏幕显示如果直接操作底层像素写文字、画图形要花大量时间做字体和布局。LVGL是一个开源的图形库提供了控件、字体、事件系统等一套完整的UI方案和FreeRTOS搭配是嵌入式圈子里最常见的组合之一。但我要提醒你不要在项目初期就急着把LVGL堆上去。我见过太多人一上来就移植LVGL结果界面还没跑起来就已经被各种配置绕晕了。建议先用裸驱动的形式在屏幕上画几个色块和字符确认屏幕时序没问题再引入LVGL。这样出了问题可以快速判断是显示驱动问题还是LVGL移植问题。5.2 LVGL的完整移植路径LVGL移植到FreeRTOS STM32平台上需要做四件事第一准备LVGL源码。从GitHub下载lvgl源码推荐使用8.x版本9.x改动较大、文档还不够丰富新手从8.x开始更稳妥。把源码里的lvgl目录整个复制到工程的Middlewares文件夹下。第二配置lv_conf.h。从lv_conf_template.h复制一份改名为lv_conf.h打开它把#if 0改成#if 1开启配置。关键配置项包括颜色深度我用的屏幕是RGB565所以LV_COLOR_DEPTH设为16、内存大小(LV_MEM_SIZE设为4KB即可F103资源紧张不能给太大)。第三实现显示驱动接口。LVGL通过lv_disp_drv_t调用你的屏幕填充接口。核心是flush_cb回调函数把LVGL内部缓冲区的内容填充到屏幕上static void my_disp_flush(lv_disp_drv_t *disp_drv, const lv_area_t *area, lv_color_t *color_p) { ST7735_DrawArea(area-x1, area-y1, area-x2, area-y2, (uint16_t*)color_p-full); lv_disp_flush_ready(disp_drv); }第四提供心跳节拍。LVGL需要一个毫秒级的时间基准来驱动动画和任务调度。有两种方式一种是利用FreeRTOS的vTaskDelay定时调用lv_tick_inc(1)另一种是配置一个定时器中断调用。我了推荐前一种在UI任务里每1ms调用一次。5.3 刷新策略全量刷新还是局部刷新LVGL默认会把整个脏区域修改过的区域打包发送到屏幕而ST7735这类SPI屏如果每次全量刷新128x128像素每帧都需要传输大量数据CPU占用非常高。一个很实用的做法是开一个大一点的缓冲区。LVGL8.x里可以配置LV_MEM_SIZE和LV_USE_PERF_MONITOR通过lv_disp_drv_t的buffer成员设置static lv_disp_draw_buf_t draw_buf; static lv_color_t buf[LV_HOR_RES_MAX * 40]; lv_disp_draw_buf_init(draw_buf, buf, NULL, LV_HOR_RES_MAX * 40);这里给的是128 * 40个像素即10行像素的缓冲区。LVGL会把这个缓冲区切分为两个部分支持分块渲染比全量刷新要快得多。实测下来在72MHz的F103上渲染简单页面基本能做到不卡顿。5.4 显示任务与UI事件的关系LVGL在FreeRTOS里通常单独占用一个任务这个任务做的事情很简单调用lv_timer_handler()处理UI事件和刷新然后vTaskDelay(5)让出CPU。注意不要在多个任务里同时调用lv_timer_handler()LVGL不是线程安全的如果多个地方同时操作UI控件会出现渲染异常。UI事件和FreeRTOS任务之间怎么关联举个例子按键任务通过消息队列把按键事件发给UI任务UI任务根据键值调用lv_btn_set_state或者lv_label_set_text更新界面。这种模式下LVGL在一个任务里被安全驱动外部数据通过队列传递既保证了实时性又避免了资源竞争。6. 当前进度与下一步规划做到这里这套FreeRTOS智能手表的骨架已经完整跑通了系统节拍1ms三个业务任务UI、传感器、按键并行调度消息队列和信号量通信正常ST7735屏幕可以显示LVGL跑起来的基础页面。如果你跟我一样从零开始走了一遍恭喜你你已经跨过了FreeRTOS学习最关键的方法论门槛——你知道一个项目该怎么拆任务、怎么定优先级、怎么处理任务间通信了。接下来的系列内容里我会继续把温度传感器数据接到实际UI页面上做一两个真正能交互的界面比如秒表和计步显示同时把按键的中断触发、低功耗模式这些优化项逐个填上。最后分享两个我实际开发中总结的小技巧。第一个是养成看任务状态的习惯调试阶段在周期任务里打印uxTaskGetSystemState的返回值能直观看到每个任务处于运行、就绪还是阻塞态。第二个是保存好能跑的版本每完成一个里程碑功能编译出来的hex文件单独存一份后面改坏了随时能回滚这个习惯帮我省了很多返工时间。下一篇文章我们直接进显示驱动部分把LVGL的界面真正做起来。到时候见。
返回列表