【实战】智慧农场项目
本文档面向已经学过 STM32 外设基础、了解 FreeRTOS 基本概念,但从未做过完整 RTOS 实战工程的学习者。
下载例程代码:
一、项目整体介绍
1.1 这个项目是做什么的
智慧农场项目是一个完整的嵌入式监测与控制系统,运行在 STM32F103 单片机上,使用 FreeRTOS 实时操作系统进行任务调度。
系统能够:
- 实时监测: 温度、湿度、土壤湿度,光照强度、降雨量等环境参数
- 自动控制: 根据土壤湿度自动启停水泵
- 报警提示: 环境参数超出安全范围时触发蜂鸣器和蓝牙报警
- 人机交互: 通过 OLED 显示屏、按键和旋钮进行参数设置和状态查看
1.2 为什么选择"智慧农场"作为实战项目
在真实的嵌入式项目中,单一功能的 demo和完整的产品级系统之间存在巨大鸿沟。智慧农场这个场景恰好能填平这个鸿沟,因为它具备真实产品的典型特征:
✅ 多传感器融合: 涉及 I2C1(AHT20、OLED)、软件 I2C(光照)、 ADC(土壤湿度、降雨量)等多种外设
✅ 实时性要求不同: 传感器采集需要周期执行,用户输入需要快速响应,报警需要及时处理
✅ 任务间协作: 传感器数据要传递给显示任务,报警消息要传递给蓝牙任务
✅ 资源竞争: OLED 和 AHT20 共用 I2C1 总线,需要互斥访问
✅ 用户交互: 有完整的菜单系统、参数编辑逻辑
这正是真实嵌入式项目的典型特征。
1.3 学完这个项目你能掌握什么
不只是函数调用,而是工程思维:
- 如何将一个复杂系统拆分成多个任务
- 如何设计任务的优先级和执行周期
- 任务之间如何安全地交换数据
- 如何避免资源竞争导致的死锁和数据错乱
- 如何处理周期性任务和事件驱动任务
二、系统整体架构设计
2.1 系统由哪些模块组成
从硬件模块看,系统包含:
模块 | 功能 | 通信接口 |
|---|---|---|
AHT20 | 温湿度传感器 | I2C1 |
OLED | 显示屏 | I2C1 |
土壤湿度传感器 | 检测土壤湿度 | ADC2 |
光照传感器 | 检测光照强度 | 软件 I2C |
降雨量传感器 | 检测降雨量 | ADC1 |
水泵 | 自动灌溉 | GPIO |
蜂鸣器 | 报警提示 | GPIO + 定时器 |
按键 | 用户输入 | GPIO |
旋钮 | 参数调节 | GPIO |
蓝牙模块 | 无线通信 | UART3 + DMA |
硬件连接说明
本项目所有外设模块均通过杜邦线连接到STM32学习板的相应接口。
上图展示了所有模块与STM32学习板的连接方式。如果你手头有实物,可以按照上图进行接线。
已有学习套件 → 获取智慧农场DLC扩展包
需先学习STM32? → 获取STM32学习套件
关键连接说明:
- I2C1 总线连接(共享总线,需要特别关注):
- OLED 显示屏: SCL → PB6, SDA → PB7
- AHT20 温湿度传感器: SCL → PB6, SDA → PB7
- ⚠️ 注意: 两个设备共用 I2C1 总线,这就是为什么代码中需要使用互斥锁 i2c1Mutex 来保护总线访问
- 光照传感器连接(独立软件 I2C):
- 光照传感器: SDA → PA10, SCL → PA11
- ⚠️ 注意: 光照模块不走硬件 I2C1,而是使用 GPIO 模拟 I2C,因此它与 OLED/AHT20 不共享 i2c1Mutex
- ADC 传感器连接:
- 降雨量传感器: AO → PB1(ADC1_IN9)
- 土壤湿度传感器: AO → PA0(ADC2_IN0)
- GPIO 输入设备:
- KEY1 按键: PB12(页面切换)
- KEY3 按键: PB15(编辑模式切换)
- 旋钮(编码器): CH1 → PA8, CH2 → PA9(使用 TIM1 编码器模式)
- 输出设备:
- 水泵继电器: PA12(Pump_On() 输出高电平,Pump_Off() 输出低电平)
- 蜂鸣器: PB9(TIM4_CH4, PWM 控制)
- UART3 蓝牙模块:
- TX: PB10
- RX: PB11
- 使用 DMA 方式发送,发送期间任务通过 osDelay(1) 让出 CPU 等待完成
电源连接:
- 所有模块 VCC 接 3.3V 或 5V(根据模块规格)
- 所有模块 GND 接 STM32 GND
- ⚠️ 注意: 务必保证共地,否则通信会失败
从软件任务看,系统划分为 4 个任务 + 1 个定时器:
1┌─────────────────────────────────────────────────┐2│ FreeRTOS 调度器 │3├─────────────────────────────────────────────────┤4│ │5│ ┌──────────────┐ ┌──────────────┐ │6│ │ SensorTask │ │ InputTask │ │7│ │ 优先级:普通 │ │ 优先级:高 │ │8│ │ 周期:1000ms │ │ 周期:10ms │ │9│ └──────────────┘ └──────────────┘ │10│ │11│ ┌──────────────┐ ┌──────────────┐ │12│ │ ScreenTask │ │ BLETask │ │13│ │ 优先级:低 │ │ 优先级:低 │ │14│ │ 周期:10ms │ │ 事件驱动 │ │15│ └──────────────┘ └──────────────┘ │16│ │17│ ┌──────────────────────────┐ │18│ │ BeepTimer (软件定时器) │ │19│ │ 周期:500ms │ │20│ └──────────────────────────┘ │21└─────────────────────────────────────────────────┘
2.2 这是一个"任务驱动"的系统
为什么不用 while(1) 全部写在 main 里?
在裸机编程中,你可能习惯这样写:
1int main() {2 // 初始化所有外设3 AHT20_Init();4 OLED_Init();5 // ...67 while(1) {8 // 读取传感器9 AHT20_Read(&temp, &humi);10 soilMoisture = SoilMoisture_Get();11 // ...1213 // 检查报警14 if (temp > MAX_TEMP) {15 Beep_on();16 }17 // ...1819 // 刷新显示20 OLED_ShowTemp(temp);21 OLED_ShowHumi(humi);22 // ...2324 // 检测按键25 if (KEY1_Pressed()) {26 // 切换页面27 }28 // ...2930 delay_ms(10);31 }32}
这种写法在实际项目中会遇到什么问题?
❌ 问题 1: 代码耦合严重
- 传感器读取、显示刷新、按键检测全混在一起
- 修改显示逻辑可能会影响传感器采集
❌ 问题 2: 实时性无法保证
- OLED 刷新需要时间(通过 I2C 发送整屏数据)
- 在刷新 OLED 期间,按键检测被阻塞,用户会感觉"按键不灵敏"
- 传感器采集也被延迟,数据时效性变差
❌ 问题 3: 难以扩展
- 要添加蓝牙功能,需要在主循环里加一堆代码
- 主循环变得越来越长,越来越难维护
❌ 问题 4: 资源竞争无保护
- AHT20 和 OLED 共用 I2C1,交替访问时可能出现数据错乱
- 需要手动管理互斥访问,容易出错
FreeRTOS 如何解决这些问题?
FreeRTOS 通过任务调度让每个功能模块独立运行:
1// 传感器任务:每秒执行一次2void SensorTask() {3 while(1) {4 // 采集数据5 AHT20_Read(&temp, &humi);6 // 检查报警7 osDelay(1000);8 }9}1011// 显示任务:每10ms执行一次12void ScreenTask() {13 while(1) {14 // 刷新显示15 OLED_Refresh();16 osDelay(10);17 }18}1920// 输入任务:每10ms执行一次21void InputTask() {22 while(1) {23 // 检测按键24 if (KEY1_Pressed()) {25 // 切换页面26 }27 osDelay(10);28 }29}
每个任务都是独立的执行流,FreeRTOS 调度器会根据优先级自动切换任务执行:
- InputTask 优先级最高,按键检测不会被其他任务阻塞
- ScreenTask 优先级较低,不会影响传感器采集和报警检测
- SensorTask 每秒执行一次,保证数据及时更新
这就是任务驱动的核心思想:关注点分离 + 调度器管理。
三、FreeRTOS 任务划分思路
3.1 为什么要拆成多个任务
任务划分的本质是什么?
任务划分不是简单地把代码拆成多个函数,而是要回答:哪些操作可以独立执行,哪些操作有先后顺序?
在本项目中,我们通过分析实时性需求和功能独立性来划分任务:
功能模块 | 实时性需求 | 执行频率 | 是否需要独立任务 | 理由 |
|---|---|---|---|---|
传感器采集 | 低(1秒更新一次) | 1Hz | ✅ 是 | 周期性执行,耗时较长(I2C通信) |
显示刷新 | 低(人眼100Hz足够) | 100Hz | ✅ 是 | 周期性执行,耗时较长(发送整屏数据) |
按键检测 | 高(用户要求灵敏) | 100Hz | ✅ 是 | 需要快速响应,不能被显示刷新阻塞 |
蓝牙通信 | 低(可以缓冲) | 按需 | ✅ 是 | 事件驱动,有报警时才执行 |
蜂鸣器控制 | 高(报警及时性) | 2Hz | ❌ 否(用定时器) | 周期性翻转GPIO,用软件定时器更合适 |
3.2 每个任务负责什么
SensorTask(传感器任务)
职责:
- 初始化所有传感器
- 定期读取传感器数据
- 更新全局状态变量 farmState
- 检查环境参数是否超出安全范围
- 超出范围时发送报警消息到队列
为什么这样设计?
- 周期性执行: 环境参数变化缓慢,1秒采集一次足够
- 优先级 Normal: 不需要快速响应,但不能过低(否则被显示任务抢占太久)
- 使用互斥锁: AHT20 和 OLED 共用 I2C1,读取前必须获取 i2c1Mutex
新手容易犯的错误:
❌ 在任务里用 HAL_Delay() 延时,会阻塞整个任务
❌ 忘记释放互斥锁,导致死锁(OLED 永远无法访问 I2C1)
❌ 报警检测逻辑写死在任务里,导致代码难以扩展
正确做法:
1void StartSensorTask(void *argument) {2 // 初始化3 osMutexAcquire(i2c1MutexHandle, osWaitForever);4 AHT20_Init();5 osMutexRelease(i2c1MutexHandle);67 for(;;) {8 // 读取数据(使用互斥锁保护I2C)9 osMutexAcquire(i2c1MutexHandle, osWaitForever);10 AHT20_Read(&farmState.temperature, &farmState.humidity);11 osMutexRelease(i2c1MutexHandle);1213 // 检查报警,发送消息14 if (farmState.temperature > farmSafeRange.maxTemperature) {15 SendWarningFloat("temperature_high", farmState.temperature);16 }1718 // 延时1秒19 osDelay(1000); // ✅ 使用osDelay,任务会进入阻塞态,让出CPU20 }21}
ScreenTask(显示任务)
职责:
- 初始化 OLED 显示屏
- 根据当前页面渲染界面(首页/阈值设置页)
- 定期刷新显示内容
为什么这样设计?
- 周期性执行: 10ms 刷新一次,保证显示流畅(100Hz)
- 优先级 BelowNormal5: 最低优先级,不会影响实时性要求高的任务
- 使用互斥锁: OLED 和 AHT20 共用 I2C1,刷新前必须获取 i2c1Mutex
新手容易犯的错误:
❌ 每次刷新都重新初始化 OLED(非常慢)
❌ 刷新频率太低(比如100ms),导致界面卡顿
❌ 忘记使用互斥锁,导致 I2C 数据错乱
正确做法:
1void StartScreenTask(void *argument) {2 OLED_Init(); // 只初始化一次34 for(;;) {5 // 双缓冲:先在内存中绘制,再一次性发送6 OLED_NewFrame();78 // 根据当前页面渲染9 switch (pageIndex) {10 case PAGE_HOME:11 renderHomePage();12 break;13 case PAGE_RANGE:14 renderRangePage();15 break;16 }1718 // 使用互斥锁保护I2C19 osMutexAcquire(i2c1MutexHandle, osWaitForever);20 OLED_ShowFrame();21 osMutexRelease(i2c1MutexHandle);2223 osDelay(10);24 }25}
InputTask(输入任务)
职责:
- 检测按键输入(KEY1、KEY3)
- 检测旋钮旋转方向
- 根据输入控制页面切换和阈值编辑
为什么这样设计?
- 周期性执行: 10ms 检测一次,保证响应及时
- 优先级 High: 高优先级,确保用户输入快速响应
- 事件驱动: 按键按下时才执行操作,平时只检测不操作
新手容易犯的错误:
❌ 按键检测用延时消抖,阻塞任务执行
❌ 旋钮检测逻辑复杂,导致任务执行时间过长
❌ 忘记处理"长按"、"连发"等边界情况
正确做法:
1void StartInputTask(void *argument) {2 Knob_Init(); // 只初始化一次34 for(;;) {5 // 检测按键(消抖逻辑在 BSP 层处理)6 if (isKey1Clicked()) {7 ScreenPage_NextPage();8 }910 // 在阈值设置页时处理阈值编辑11 if (pageIndex == PAGE_RANGE) {12 if (isKey3Clicked()) {13 RangeEditState_Toggle();14 }1516 KnobDirection dir = Knob_IsRotating();17 if (dir == KNOB_DIR_LEFT) {18 // 左旋逻辑19 } else if (dir == KNOB_DIR_RIGHT) {20 // 右旋逻辑21 }22 }2324 osDelay(10);25 }26}
BLETask(蓝牙任务)
职责:
- 从 BLE 队列接收报警消息
- 通过 UART3(DMA 方式)发送消息到蓝牙模块
- 发送完成后释放消息内存
为什么这样设计?
- 事件驱动: 使用 osMessageQueueGet(..., osWaitForever) 阻塞等待,队列为空时任务挂起,不占用 CPU
- 优先级 Low: 低优先级,不影响实时性要求高的任务
- DMA 发送: 串口数据由 DMA 搬运,当前实现会在发送后轮询状态,并通过 osDelay(1) 让出 CPU
- 内存管理: 消息由 SensorTask 分配,由 BLETask 释放,避免内存泄漏
新手容易犯的错误:
❌ 用轮询方式检查队列,浪费 CPU
❌ 发送完消息后忘记释放内存,导致内存泄漏
❌ 用阻塞方式发送 UART,导致任务长时间占用 CPU
正确做法:
1void StartBLETask(void *argument) {2 for(;;) {3 char *msg;45 // 阻塞等待队列消息(队列为空时任务挂起,不占用CPU)6 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);78 if (msg != NULL) {9 // 使用DMA发送,数据搬运交给DMA完成10 HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));1112 // 等待发送完成(等待期间让出CPU)13 while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {14 osDelay(1);15 }1617 // 释放内存18 vPortFree(msg);19 }20 }21}
3.3 任务优先级是如何考虑的
优先级设计的核心原则: 实时性要求越高,优先级越高
本项目优先级排序:
InputTask (High) > SensorTask (Normal) > ScreenTask (BelowNormal5) ≈ BLETask (Low)
为什么要这样设计?
任务 | 优先级 | 理由 |
|---|---|---|
InputTask | High | 用户按键要求立即响应,否则体验差 |
SensorTask | Normal | 1秒采集一次,不需要快速响应,但不能过低 |
ScreenTask | BelowNormal5 | 10ms刷新一次,低优先级不影响实时性 |
BLETask | Low | 报警消息可以缓冲,低优先级不影响系统功能 |
如果优先级设计不当会怎样?
❌ ScreenTask 优先级过高: 显示刷新频繁,会导致传感器采集被延迟,报警不及时
❌ InputTask 优先级过低: 用户按键检测被显示任务阻塞,感觉"按键不灵敏"
✅ 当前设计合理: 按键响应最快,传感器采集不被阻塞,显示刷新优先级最低但不影响功能
3.4 哪些任务是周期性的,哪些是事件驱动的
周期性任务
特点: 固定时间间隔执行一次,不管有没有事件发生
任务 | 执行周期 | 典型代码 |
|---|---|---|
SensorTask | 1000ms | osDelay(1000); |
ScreenTask | 10ms | osDelay(10); |
InputTask | 10ms | osDelay(10); |
为什么这样设计?
- SensorTask: 环境参数变化缓慢,1秒采集一次足够,太频繁浪费资源
- ScreenTask: 10ms 刷新一次,保证显示流畅(100Hz),人眼无法感知更高刷新率
- InputTask: 10ms 检测一次,保证按键响应及时,不会漏按
事件驱动任务
特点: 只有事件发生时才执行,平时阻塞等待
任务 | 触发条件 | 典型代码 |
|---|---|---|
BLETask | 队列有消息 | osMessageQueueGet(..., osWaitForever); |
为什么这样设计?
- BLETask: 报警消息是偶发事件,用轮询浪费 CPU,用阻塞等待最合理
- 阻塞等待的优势: 队列为空时任务挂起,不占用 CPU,收到消息时立即唤醒
四、任务之间的通信与协作
4.1 为什么任务不能"直接互相调用"
在裸机编程中,你可能习惯这样调用函数:
1int main() {2 while(1) {3 temp = readTemperature();4 if (temp > MAX_TEMP) {5 sendBluetoothAlert(temp);6 }7 delay_ms(1000);8 }9}
在 FreeRTOS 中,为什么不能直接调用其他任务的函数?
❌ 问题 1: 任务是独立的执行流
- 每个任务都有自己的栈空间和程序计数器
- 直接调用只是"函数调用",不是"任务切换"
❌ 问题 2: 无法保证实时性
- 如果 sendBluetoothAlert() 耗时较长(比如发送 UART),会阻塞当前任务
- SensorTask 被阻塞,传感器采集延迟,报警不及时
❌ 问题 3: 无法解耦
- SensorTask 直接调用 BLETask 的函数,耦合严重
- 如果 BLETask 的实现改变,SensorTask 也要修改
正确的做法是什么?
✅ 使用队列进行任务间通信:
1// SensorTask:检测到报警时,发送消息到队列2char *msg = pvPortMalloc(100);3snprintf(msg, 100, "{\"type\":\"warning\", \"reason\":\"temperature_high\", \"value\":%d.%d}", minInt, minDec);4osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);56// BLETask:从队列接收消息,异步处理7char *msg;8osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);9HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));10vPortFree(msg);
4.2 本项目中哪些地方需要任务通信
场景 1: SensorTask → BLETask(报警消息传递)
问题: SensorTask 检测到温度超标,需要通知 BLETask 发送蓝牙报警
解决方案: 使用消息队列 BLEQueue
1SensorTask BLETask2 │ │3 │ 检测到温度超标 │4 ├──────────分配内存创建消息──────────>│5 │ │6 │ <────────────放入队列────────────┤7 │ │8 │ (队列非空,唤醒BLETask)9 │ │10 │ 从队列取出消息11 │ │12 │ 通过UART发送13 │ │14 │ 发送完成,释放内存
为什么使用队列而不是全局变量?
❌ 全局变量的问题:
- SensorTask 写入全局变量后,BLETask 如何知道"有新消息"?需要轮询或标志位
- 如果 BLETask 正在处理上一条消息,SensorTask 写入会覆盖数据
✅ 队列的优势:
- 自动阻塞/唤醒:队列为空时 BLETask 挂起,有消息时自动唤醒
- 缓冲机制:队列可以存储多条消息,不会丢失
- 解耦:SensorTask 只管发送,不管 BLETask 何时处理
场景 2: SensorTask → ScreenTask(数据共享)
问题: SensorTask 读取传感器数据,ScreenTask 需要显示这些数据
解决方案: 使用全局变量 farmState + 内存屏障
1// SensorTask:更新全局变量2farmState.temperature = 25.5;3farmState.humidity = 60.2;45// ScreenTask:读取全局变量6OLED_PrintTemperature(farmState.temperature);7OLED_PrintHumidity(farmState.humidity);
为什么不用队列?
- 数据更新频繁(1秒1次),队列开销太大
- 数据不需要缓冲,只关心最新值
- 读写都是简单变量,不存在数据竞争风险
需要注意的问题:
✅ 浮点数在 ARM Cortex-M3 上不是原子操作
- farmState.temperature = 25.5 实际上是多条汇编指令
- 如果 ScreenTask 在赋值过程中读取,可能读到"一半新值、一半旧值"
✅ 解决方案:使用互斥锁或内存屏障
- 本项目中,SensorTask 只写,ScreenTask 只读,不会同时访问同一变量
- 且 ARM Cortex-M3 的对齐读写保证原子性,所以没有加锁
- 如果有"多写多读"场景,必须加锁
场景 3: InputTask → ScreenTask(页面切换)
问题: 用户按下 KEY1,需要切换 OLED 显示页面
解决方案: 使用全局变量 pageIndex
1// InputTask:检测到按键,修改全局变量2if (isKey1Clicked()) {3 ScreenPage_NextPage(); // 修改pageIndex4}56// ScreenTask:根据全局变量渲染7switch (pageIndex) {8 case PAGE_HOME:9 renderHomePage();10 break;11 case PAGE_RANGE:12 renderRangePage();13 break;14}
为什么不用队列?
- 页面切换是"命令",不需要携带数据
- 全局变量简单高效,队列开销太大
需要注意的问题:
✅ 全局变量的读写顺序
- InputTask 修改 pageIndex 后,ScreenTask 可能正在执行 switch (pageIndex) 之前
- 由于 10ms 刷新周期,即使延迟一帧,用户也感知不到
4.3 互斥锁的使用动机
问题场景: I2C 总线竞争
AHT20 和 OLED 共用 I2C1 总线:
STM32F103
│
├─ I2C1 ──┬── AHT20 (温湿度传感器)
│ └── OLED (显示屏)
如果不使用互斥锁会发生什么?
时间线:
t0: SensorTask 开始读取 AHT20
t1: SensorTask 正在通过 I2C 发送"读取温度"命令
t2: 任务调度器切换到 ScreenTask
t3: ScreenTask 开始刷新 OLED,通过 I2C 发送数据
t4: I2C 总线混乱!AHT20 收到错误命令,OLED 显示乱码
解决方案:使用互斥锁 i2c1Mutex
1// SensorTask:读取 AHT20 前获取互斥锁2osMutexAcquire(i2c1MutexHandle, osWaitForever);3AHT20_Read(&farmState.temperature, &farmState.humidity);4osMutexRelease(i2c1MutexHandle);56// ScreenTask:刷新 OLED 前获取互斥锁7osMutexAcquire(i2c1MutexHandle, osWaitForever);8OLED_ShowFrame();9osMutexRelease(i2c1MutexHandle);
互斥锁的工作原理:
时间线:
t0: SensorTask 获取 i2c1Mutex 成功,开始读取 AHT20
t1: SensorTask 正在通过 I2C 发送命令
t2: 任务调度器切换到 ScreenTask
t3: ScreenTask 尝试获取 i2c1Mutex,失败(已被 SensorTask 持有)
t4: ScreenTask 进入阻塞态,等待互斥锁
t5: SensorTask 释放 i2c1Mutex
t6: ScreenTask 被唤醒,获取 i2c1Mutex 成功,开始刷新 OLED
新手容易犯的错误:
❌ 忘记释放互斥锁
- 导致死锁:其他任务永远无法获取互斥锁
- 系统挂起,看似"卡死"
❌ 获取互斥锁后调用可能阻塞的函数
- 比如 osDelay(),其他任务长时间等待
- 互斥锁应该"快进快出",只保护临界区
✅ 正确做法:
1// 错误示例2osMutexAcquire(i2c1MutexHandle, osWaitForever);3AHT20_Read(&temp, &humi);4osDelay(100); // ❌ 错误:延时期间占用互斥锁,其他任务无法访问I2C5osMutexRelease(i2c1MutexHandle);67// 正确示例8osMutexAcquire(i2c1MutexHandle, osWaitForever);9AHT20_Read(&temp, &humi);10osMutexRelease(i2c1MutexHandle);11osDelay(100); // ✅ 正确:释放互斥锁后再延时
五、典型工作流程的完整拆解
5.1 场景:温湿度采集 → 数据处理 → 显示更新 → 报警执行
让我们完整追踪一次"温度超限报警"的数据流动:
步骤 1: SensorTask 采集温湿度数据(每秒执行)
1// Core/App/Tasks/SensorTask.c:1792osMutexAcquire(i2c1MutexHandle, osWaitForever); // 获取I2C互斥锁3AHT20_Read(&farmState.temperature, &farmState.humidity); // 读取温湿度4osMutexRelease(i2c1MutexHandle); // 释放I2C互斥锁
发生了什么?
- SensorTask 尝试获取 i2c1Mutex
- 如果 ScreenTask 正在刷新 OLED,SensorTask 会阻塞等待
- 获取互斥锁成功后,通过 I2C1 读取 AHT20 的温湿度数据
- 将数据写入全局变量 farmState.temperature 和 farmState.humidity
- 释放互斥锁
步骤 2: SensorTask 检查温度是否超限
1// Core/App/Tasks/SensorTask.c:202-2032warning += CheckRangeFloat(farmState.temperature,3 farmSafeRange.minTemperature,4 farmSafeRange.maxTemperature,5 "temperature_low", "temperature_high");
发生了什么?
- 读取 farmState.temperature 的当前值(比如 35.5℃)
- 读取 farmSafeRange.maxTemperature 的阈值(比如 30.0℃)
- 比较发现 35.5 > 30.0,温度超限!
- 调用 SendWarningFloat("temperature_high", 35.5)
步骤 3: SensorTask 发送报警消息到队列
1// Core/App/Tasks/SensorTask.c:41-562static void SendWarningFloat(const char *reason, float value) {3 char *msg = pvPortMalloc(100); // 在FreeRTOS堆中分配内存4 snprintf(msg, 100, "{\"type\":\"warning\", \"reason\":\"%s\", \"value\":%d.%d}",5 reason, minInt, minDec);6 osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0); // 将消息指针放入队列7}
发生了什么?
- 在 FreeRTOS 堆中分配 100 字节内存
- 格式化 JSON 字符串: {"type":"warning", "reason":"temperature_high", "value":35.5}
- 将消息指针(不是消息内容!)放入 BLEQueue
- 队列由空变为非空,BLETask 被唤醒
步骤 4: SensorTask 启动蜂鸣器
1// Core/App/Tasks/SensorTask.c:219-2232if (warning > 0) {3 Beep_on(); // 启动蜂鸣器(软件定时器)4} else {5 Beep_off(); // 关闭蜂鸣器6}
发生了什么?
- 检测到 warning > 0,有报警
- 调用 Beep_on() 启动软件定时器 BeepTimer
- 定时器每 500ms 触发一次回调,翻转蜂鸣器 GPIO,实现"滴-滴-滴"报警音
步骤 5: SensorTask 延时 1 秒,继续下一次循环
1// Core/App/Tasks/SensorTask.c:2262osDelay(1000);
发生了什么?
- SensorTask 进入阻塞态,让出 CPU
- 调度器切换到其他就绪任务(比如 ScreenTask 或 BLETask)
- 1 秒后,SensorTask 被唤醒,继续下一次采集
步骤 6: BLETask 被队列唤醒,发送蓝牙报警
1// Core/App/Tasks/BleTask.c:542osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever); // 阻塞等待,直到有消息
发生了什么?
- BLETask 之前在 osMessageQueueGet() 处阻塞,等待队列消息
- SensorTask 放入消息后,队列非空,BLETask 被唤醒
- 从队列取出消息指针 msg
步骤 7: BLETask 通过 UART3 发送报警消息
1// Core/App/Tasks/BleTask.c:582HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg)); // 使用DMA发送
发生了什么?
- 通过 UART3 以 DMA 方式发送 JSON 字符串到蓝牙模块
- 数据搬运由 DMA 完成,不会像阻塞式串口发送那样一直占着 CPU
- 蓝牙模块将消息无线传输到手机或网关
步骤 8: BLETask 等待发送完成,释放内存
1// Core/App/Tasks/BleTask.c:61-692while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {3 osDelay(1); // 等待DMA发送完成4}5vPortFree(msg); // 释放消息内存
发生了什么?
- 轮询 UART 状态,等待 DMA 发送完成
- 发送完成后,释放之前分配的内存(避免内存泄漏)
- 继续等待下一条消息
步骤 9: ScreenTask 显示最新的温度数据(每 10ms 执行)
1// Core/App/Tasks/ScreenTask.c:68-742OLED_PrintString(9, 14, "温度", &font12x12, OLED_COLOR_NORMAL);3floatToIntDec(farmState.temperature, &minInt, &minDec);4sprintf(msg, "%d.%d℃", minInt, minDec);5OLED_PrintString(x, 26, msg, &font12x12, OLED_COLOR_NORMAL);
发生了什么?
- ScreenTask 读取全局变量 farmState.temperature(35.5℃)
- 将浮点数转换为字符串 "35.5℃"
- 在 OLED 的帧缓冲区中绘制温度文字
- 使用互斥锁保护 I2C,将帧缓冲区发送到 OLED 显示
数据流动总结:
1AHT20 (硬件)2 ↓3SensorTask (I2C读取)4 ↓5farmState.temperature (全局变量)6 ↓7CheckRangeFloat() (检查超限)8 ↓9SendWarningFloat() (创建消息)10 ↓11BLEQueue (消息队列)12 ↓13BLETask (UART发送)14 ↓15蓝牙模块 → 手机用户1617同时:18farmState.temperature (全局变量)19 ↓20ScreenTask (OLED显示)21 ↓22OLED屏幕显示 "35.5℃"
5.2 FreeRTOS 在其中起到了什么作用
如果没有 FreeRTOS,这些功能怎么实现?
❌ 裸机实现:
1int main() {2 while(1) {3 // 读取传感器4 AHT20_Read(&temp, &humi);56 // 检查报警7 if (temp > MAX_TEMP) {8 Beep_on();9 // 发送蓝牙(阻塞式,会延迟整个系统)10 HAL_UART_Transmit(&huart3, msg, len, 1000);11 Beep_off();12 }1314 // 刷新显示(慢速I2C操作)15 OLED_Refresh();1617 // 检测按键(可能在刷新OLED期间漏按)18 if (KEY_Pressed()) {19 // 切换页面20 }2122 delay_ms(10);23 }24}
问题:
- UART 发送阻塞 1 秒,期间按键检测失效,用户感觉"卡顿"
- OLED 刷新期间,传感器采集延迟,数据时效性差
- 所有功能耦合在一起,难以维护和扩展
有了 FreeRTOS 后:
✅ 任务独立执行:
- SensorTask 每秒采集一次,不会被其他任务阻塞
- BLETask 异步发送报警,不影响 SensorTask 和 ScreenTask
- InputTask 快速响应按键,不会被 OLED 刷新阻塞
✅ 调度器自动管理:
- 优先级高的任务(InputTask)优先执行
- 阻塞的任务自动让出 CPU,其他任务运行
- 事件驱动的任务(BLETask)只在有事件时执行,节省 CPU
✅ 资源同步保护:
- 互斥锁保护 I2C 总线,避免竞争
- 队列实现任务间解耦通信
- 全局变量实现高效数据共享
这就是 FreeRTOS 的核心价值: 让复杂系统变得简单、可维护、可扩展。
六、写给初学者的工程经验总结
6.1 初学者最容易犯的 5 个错误
错误 1: 任务划分不合理
❌ 错误示例: 把所有功能都写在一个任务里
1void StartTask(void *argument) {2 while(1) {3 // 读取传感器4 AHT20_Read(&temp, &humi);56 // 刷新显示7 OLED_Refresh();89 // 检测按键10 if (KEY_Pressed()) {11 // 切换页面12 }1314 // 发送蓝牙15 if (needAlert) {16 HAL_UART_Transmit(&huart3, msg, len, 1000);17 }1819 osDelay(10);20 }21}
问题: 又回到裸机编程模式了,FreeRTOS 的优势完全没用上
✅ 正确做法: 按功能模块划分任务
1// 传感器任务2void SensorTask() {3 while(1) {4 AHT20_Read(&temp, &humi);5 osDelay(1000);6 }7}89// 显示任务10void ScreenTask() {11 while(1) {12 OLED_Refresh();13 osDelay(10);14 }15}1617// 输入任务18void InputTask() {19 while(1) {20 if (KEY_Pressed()) {21 // 切换页面22 }23 osDelay(10);24 }25}
错误 2: 优先级设计不当
❌ 错误示例: 所有任务优先级相同
1const osThreadAttr_t SensorTask_attributes = {2 .priority = osPriorityNormal, // 普通优先级3};45const osThreadAttr_t InputTask_attributes = {6 .priority = osPriorityNormal, // 也是普通优先级7};89问题: 按键响应不及时,用户感觉"按键不灵敏"10✅ 正确做法: 根据实时性要求设计优先级11const osThreadAttr_t InputTask_attributes = {12 .priority = osPriorityHigh, // 高优先级,按键响应快13};1415const osThreadAttr_t SensorTask_attributes = {16 .priority = osPriorityNormal, // 普通优先级17};
错误 3: 忘记使用互斥锁保护共享资源
❌ 错误示例: 直接访问共享的 I2C 总线
1// SensorTask2void SensorTask() {3 while(1) {4 AHT20_Read(&temp, &humi); // 没有互斥锁保护5 osDelay(1000);6 }7}89// ScreenTask10void ScreenTask() {11 while(1) {12 OLED_Refresh(); // 没有互斥锁保护13 osDelay(10);14 }15}
问题: I2C 数据错乱,OLED 显示花屏
✅ 正确做法: 使用互斥锁保护共享资源
1// SensorTask2void SensorTask() {3 while(1) {4 osMutexAcquire(i2c1MutexHandle, osWaitForever);5 AHT20_Read(&temp, &humi);6 osMutexRelease(i2c1MutexHandle);7 osDelay(1000);8 }9}1011// ScreenTask12void ScreenTask() {13 while(1) {14 osMutexAcquire(i2c1MutexHandle, osWaitForever);15 OLED_Refresh();16 osMutexRelease(i2c1MutexHandle);17 osDelay(10);18 }19}
错误 4: 阻塞函数导致任务长时间占用 CPU
❌ 错误示例: 在任务里用 HAL_Delay() 或阻塞式 UART
1void BLETask() {2 while(1) {3 char *msg;4 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);56 // 阻塞式发送,占用CPU 1秒7 HAL_UART_Transmit(&huart3, (uint8_t *)msg, strlen(msg), 1000);89 vPortFree(msg);10 }11}
问题: BLETask 占用 CPU 期间,其他任务无法执行,系统卡顿
✅ 正确做法: 使用 DMA 发送,并在等待完成时让出 CPU
1void BLETask() {2 while(1) {3 char *msg;4 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);56 // DMA发送,数据搬运交给DMA完成7 HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));89 // 等待发送完成(期间可以执行其他任务)10 while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {11 osDelay(1);12 }1314 vPortFree(msg);15 }16}
错误 5: 内存泄漏
❌ 错误示例: 分配内存后忘记释放
1void SensorTask() {2 while(1) {3 char *msg = pvPortMalloc(100); // 分配内存4 snprintf(msg, 100, "alert: temp=%f", temperature);5 osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);6 // ❌ 忘记释放内存!7 osDelay(1000);8 }9}1011void BLETask() {12 while(1) {13 char *msg;14 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);15 HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));16 // ❌ 忘记释放内存!17 }18}
问题: 运行一段时间后,FreeRTOS 堆内存耗尽,系统崩溃
✅ 正确做法: 谁分配谁释放,或者明确释放责任
1// SensorTask:分配内存2char *msg = pvPortMalloc(100);3snprintf(msg, 100, "alert: temp=%f", temperature);4osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);5// 注释说明:BLETask负责释放67// BLETask:释放内存8char *msg;9osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);10HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));11while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {12 osDelay(1);13}14vPortFree(msg); // 释放内存
6.2 本项目如何刻意避免这些错误
错误类型 | 本项目的解决方案 | 代码位置 |
|---|---|---|
任务划分不合理 | 按功能模块划分为 4 个独立任务 + 1 个定时器 | freertos.c:250-274 |
优先级设计不当 | 根据实时性要求设计:Input(High) > Sensor(Normal) > Screen(BelowNormal) ≈ BLE(Low) | freertos.c:63-103 |
忘记使用互斥锁 | 创建 i2c1Mutex 保护 I2C1 总线,所有访问前必须获取 | freertos.c:199, SensorTask.c:168-170 |
阻塞函数占用 CPU | UART 使用 DMA 发送,并在等待完成时通过 osDelay(1) 让出 CPU | BleTask.c:58 |
内存泄漏 | 明确约定:SensorTask 分配,BLETask 释放 | SensorTask.c:43, BleTask.c:69 |
6.3 学完这个项目后,下一步可以挑战什么
初级挑战:
- 添加新的传感器
- 添加 CO2 传感器(UART 通信)
- 添加气压传感器(I2C 通信)
- 思考:新传感器如何集成到现有任务架构?
- 优化显示效果
- 添加更多页面(历史数据曲线图)
- 添加开机动画和图标
- 思考:如何避免页面切换时闪烁?
中级挑战:
- 添加数据存储功能
- 使用 Flash 或 EEPROM 存储历史数据
- 定期保存传感器数据(每分钟)
- 思考:如何避免频繁写操作损坏 Flash?
- 添加网络通信
- 集成 WiFi 模块(ESP8266)
- 实现 MQTT 协议上传数据到云平台
- 思考:网络通信优先级如何设计?网络断开如何处理?
- 优化电源管理
- 添加低功耗模式(Stop Mode)
- 定时唤醒采集数据
- 思考:如何平衡功耗和实时性?
高级挑战:
- 实现 OTA 升级
- 通过蓝牙或 WiFi 升级固件
- 实现 Bootloader 和 APP 双分区
- 思考:如何保证升级过程中断电恢复?
- 实现多语言支持
- 支持中英文切换
- 使用文件系统存储字库
- 思考:如何存储大量多语言文本?
七、总结:从"看懂"到"会设计"
7.1 这个项目的核心设计思想
任务驱动的本质: 将复杂系统拆分为多个独立执行的任务,通过调度器自动管理任务切换,实现"关注点分离"。
任务划分的核心原则:
- 功能独立: 每个任务只负责一个功能模块
- 实时性匹配: 任务优先级与其实时性要求匹配
- 资源保护: 共享资源必须用互斥锁保护
- 通信解耦: 任务间通过队列或全局变量通信,不直接调用
7.2 如何设计下一个 STM32 + FreeRTOS 项目
步骤 1: 分析系统需求
- 列出所有功能模块(传感器、执行器、通信、显示等)
- 分析每个模块的实时性要求(快速响应 / 周期执行 / 事件驱动)
步骤 2: 划分任务
- 根据功能模块划分任务
- 确定每个任务的执行频率(周期性 / 事件驱动)
- 设计任务优先级(实时性要求越高,优先级越高)
步骤 3: 设计任务间通信
- 数据共享:用全局变量(简单高效)
- 事件通知:用消息队列(解耦、缓冲)
- 资源保护:用互斥锁(避免竞争)
步骤 4: 编写和调试
- 从简单任务开始(先让单个任务跑起来)
- 逐步添加任务和通信机制
- 使用调试工具(串口打印、逻辑分析仪)验证时序
步骤 5: 优化和扩展
- 分析任务执行时间(是否合理?)
- 检查资源占用(堆栈、堆内存)
- 优化优先级和执行周期
八、附录:关键文件索引
文件 | 功能说明 | 关键代码 |
|---|---|---|
Core/Src/main.c | 主程序入口,初始化外设和 FreeRTOS | main() 函数 |
Core/Src/freertos.c | FreeRTOS 初始化,创建任务、队列、互斥锁、定时器 | MX_FREERTOS_Init() |
Core/App/Tasks/SensorTask.c | 传感器采集任务 | StartSensorTask() |
Core/App/Tasks/ScreenTask.c | 显示任务 | StartScreenTask() |
Core/App/Tasks/InputTask.c | 输入任务 | StartInputTask() |
Core/App/Tasks/BleTask.c | 蓝牙通信任务 | StartBLETask() |
Core/App/global/farmState.h | 农场环境状态数据结构 | FarmState, FarmSafeRange |
Core/App/global/screen.h | 屏幕页面和阈值编辑状态管理 | ScreenPage, RangeEditIndex |
希望这份文档能帮助你真正理解 STM32 + FreeRTOS 的工程实践!
如果你在阅读代码时有疑问,建议按以下顺序深入:
- 先看 freertos.c 了解任务创建和资源初始化
- 再看 SensorTask.c 理解周期性任务的设计
- 然后看 ScreenTask.c 理解双缓冲和互斥锁的使用
- 最后看 BleTask.c 理解事件驱动任务和队列通信
记住:看懂代码只是第一步,自己动手设计一个新项目才是真正掌握!
祝你学习顺利!