波特律动
课程中心/STM32基础教程/【实战】智慧农场项目

【实战】智慧农场项目

本文档面向已经学过 STM32 外设基础、了解 FreeRTOS 基本概念,但从未做过完整 RTOS 实战工程的学习者。

下载例程代码: 

注意

CubeIDE:请按照 例程使用方法🔗 导入例程,否则下载的可能不是例程而是其他工程。

Keil:请使用 ArmCC V6 编译,否则可能会出现编译错误。点击此处查看切换编译器方法🔗

一、项目整体介绍

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学习套件

关键连接说明:

  1. I2C1 总线连接(共享总线,需要特别关注):
    • OLED 显示屏: SCL → PB6, SDA → PB7
    • AHT20 温湿度传感器: SCL → PB6, SDA → PB7
    • ⚠️ 注意: 两个设备共用 I2C1 总线,这就是为什么代码中需要使用互斥锁 i2c1Mutex 来保护总线访问
  2. 光照传感器连接(独立软件 I2C):
    • 光照传感器: SDA → PA10, SCL → PA11
    • ⚠️ 注意: 光照模块不走硬件 I2C1,而是使用 GPIO 模拟 I2C,因此它与 OLED/AHT20 不共享 i2c1Mutex
  3. ADC 传感器连接:
    • 降雨量传感器: AO → PB1(ADC1_IN9)
    • 土壤湿度传感器: AO → PA0(ADC2_IN0)
  4. GPIO 输入设备:
    • KEY1 按键: PB12(页面切换)
    • KEY3 按键: PB15(编辑模式切换)
    • 旋钮(编码器): CH1 → PA8, CH2 → PA9(使用 TIM1 编码器模式)
  5. 输出设备:
    • 水泵继电器: PA12(Pump_On() 输出高电平,Pump_Off() 输出低电平)
    • 蜂鸣器: PB9(TIM4_CH4, PWM 控制)
  6. 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 里?

在裸机编程中,你可能习惯这样写:

C语言
1int main() {
2 // 初始化所有外设
3 AHT20_Init();
4 OLED_Init();
5 // ...
6
7 while(1) {
8 // 读取传感器
9 AHT20_Read(&temp, &humi);
10 soilMoisture = SoilMoisture_Get();
11 // ...
12
13 // 检查报警
14 if (temp > MAX_TEMP) {
15 Beep_on();
16 }
17 // ...
18
19 // 刷新显示
20 OLED_ShowTemp(temp);
21 OLED_ShowHumi(humi);
22 // ...
23
24 // 检测按键
25 if (KEY1_Pressed()) {
26 // 切换页面
27 }
28 // ...
29
30 delay_ms(10);
31 }
32}

这种写法在实际项目中会遇到什么问题?

❌ 问题 1: 代码耦合严重

  • 传感器读取、显示刷新、按键检测全混在一起
  • 修改显示逻辑可能会影响传感器采集

❌ 问题 2: 实时性无法保证

  • OLED 刷新需要时间(通过 I2C 发送整屏数据)
  • 在刷新 OLED 期间,按键检测被阻塞,用户会感觉"按键不灵敏"
  • 传感器采集也被延迟,数据时效性变差

❌ 问题 3: 难以扩展

  • 要添加蓝牙功能,需要在主循环里加一堆代码
  • 主循环变得越来越长,越来越难维护

❌ 问题 4: 资源竞争无保护

  • AHT20 和 OLED 共用 I2C1,交替访问时可能出现数据错乱
  • 需要手动管理互斥访问,容易出错

FreeRTOS 如何解决这些问题?

FreeRTOS 通过任务调度让每个功能模块独立运行:

C语言
1// 传感器任务:每秒执行一次
2void SensorTask() {
3 while(1) {
4 // 采集数据
5 AHT20_Read(&temp, &humi);
6 // 检查报警
7 osDelay(1000);
8 }
9}
10
11// 显示任务:每10ms执行一次
12void ScreenTask() {
13 while(1) {
14 // 刷新显示
15 OLED_Refresh();
16 osDelay(10);
17 }
18}
19
20// 输入任务:每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(传感器任务)

职责:

  1. 初始化所有传感器
  2. 定期读取传感器数据
  3. 更新全局状态变量 farmState
  4. 检查环境参数是否超出安全范围
  5. 超出范围时发送报警消息到队列

为什么这样设计?

  • 周期性执行: 环境参数变化缓慢,1秒采集一次足够
  • 优先级 Normal: 不需要快速响应,但不能过低(否则被显示任务抢占太久)
  • 使用互斥锁: AHT20 和 OLED 共用 I2C1,读取前必须获取 i2c1Mutex

新手容易犯的错误:
❌ 在任务里用 HAL_Delay() 延时,会阻塞整个任务
❌ 忘记释放互斥锁,导致死锁(OLED 永远无法访问 I2C1)
❌ 报警检测逻辑写死在任务里,导致代码难以扩展

正确做法:

C语言
1void StartSensorTask(void *argument) {
2 // 初始化
3 osMutexAcquire(i2c1MutexHandle, osWaitForever);
4 AHT20_Init();
5 osMutexRelease(i2c1MutexHandle);
6
7 for(;;) {
8 // 读取数据(使用互斥锁保护I2C)
9 osMutexAcquire(i2c1MutexHandle, osWaitForever);
10 AHT20_Read(&farmState.temperature, &farmState.humidity);
11 osMutexRelease(i2c1MutexHandle);
12
13 // 检查报警,发送消息
14 if (farmState.temperature > farmSafeRange.maxTemperature) {
15 SendWarningFloat("temperature_high", farmState.temperature);
16 }
17
18 // 延时1秒
19 osDelay(1000); // ✅ 使用osDelay,任务会进入阻塞态,让出CPU
20 }
21}

ScreenTask(显示任务)

职责:

  1. 初始化 OLED 显示屏
  2. 根据当前页面渲染界面(首页/阈值设置页)
  3. 定期刷新显示内容

为什么这样设计?

  • 周期性执行: 10ms 刷新一次,保证显示流畅(100Hz)
  • 优先级 BelowNormal5: 最低优先级,不会影响实时性要求高的任务
  • 使用互斥锁: OLED 和 AHT20 共用 I2C1,刷新前必须获取 i2c1Mutex

新手容易犯的错误:
❌ 每次刷新都重新初始化 OLED(非常慢)
❌ 刷新频率太低(比如100ms),导致界面卡顿
❌ 忘记使用互斥锁,导致 I2C 数据错乱

正确做法:

C语言
1void StartScreenTask(void *argument) {
2 OLED_Init(); // 只初始化一次
3
4 for(;;) {
5 // 双缓冲:先在内存中绘制,再一次性发送
6 OLED_NewFrame();
7
8 // 根据当前页面渲染
9 switch (pageIndex) {
10 case PAGE_HOME:
11 renderHomePage();
12 break;
13 case PAGE_RANGE:
14 renderRangePage();
15 break;
16 }
17
18 // 使用互斥锁保护I2C
19 osMutexAcquire(i2c1MutexHandle, osWaitForever);
20 OLED_ShowFrame();
21 osMutexRelease(i2c1MutexHandle);
22
23 osDelay(10);
24 }
25}

InputTask(输入任务)

职责:

  1. 检测按键输入(KEY1、KEY3)
  2. 检测旋钮旋转方向
  3. 根据输入控制页面切换和阈值编辑

为什么这样设计?

  • 周期性执行: 10ms 检测一次,保证响应及时
  • 优先级 High: 高优先级,确保用户输入快速响应
  • 事件驱动: 按键按下时才执行操作,平时只检测不操作

新手容易犯的错误:
❌ 按键检测用延时消抖,阻塞任务执行
❌ 旋钮检测逻辑复杂,导致任务执行时间过长
❌ 忘记处理"长按"、"连发"等边界情况

正确做法:

C语言
1void StartInputTask(void *argument) {
2 Knob_Init(); // 只初始化一次
3
4 for(;;) {
5 // 检测按键(消抖逻辑在 BSP 层处理)
6 if (isKey1Clicked()) {
7 ScreenPage_NextPage();
8 }
9
10 // 在阈值设置页时处理阈值编辑
11 if (pageIndex == PAGE_RANGE) {
12 if (isKey3Clicked()) {
13 RangeEditState_Toggle();
14 }
15
16 KnobDirection dir = Knob_IsRotating();
17 if (dir == KNOB_DIR_LEFT) {
18 // 左旋逻辑
19 } else if (dir == KNOB_DIR_RIGHT) {
20 // 右旋逻辑
21 }
22 }
23
24 osDelay(10);
25 }
26}

BLETask(蓝牙任务)

职责:

  1. 从 BLE 队列接收报警消息
  2. 通过 UART3(DMA 方式)发送消息到蓝牙模块
  3. 发送完成后释放消息内存

为什么这样设计?

  • 事件驱动: 使用 osMessageQueueGet(..., osWaitForever) 阻塞等待,队列为空时任务挂起,不占用 CPU
  • 优先级 Low: 低优先级,不影响实时性要求高的任务
  • DMA 发送: 串口数据由 DMA 搬运,当前实现会在发送后轮询状态,并通过 osDelay(1) 让出 CPU
  • 内存管理: 消息由 SensorTask 分配,由 BLETask 释放,避免内存泄漏

新手容易犯的错误:
❌ 用轮询方式检查队列,浪费 CPU
❌ 发送完消息后忘记释放内存,导致内存泄漏
❌ 用阻塞方式发送 UART,导致任务长时间占用 CPU

正确做法:

C语言
1void StartBLETask(void *argument) {
2 for(;;) {
3 char *msg;
4
5 // 阻塞等待队列消息(队列为空时任务挂起,不占用CPU)
6 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);
7
8 if (msg != NULL) {
9 // 使用DMA发送,数据搬运交给DMA完成
10 HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));
11
12 // 等待发送完成(等待期间让出CPU)
13 while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {
14 osDelay(1);
15 }
16
17 // 释放内存
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 为什么任务不能"直接互相调用"

在裸机编程中,你可能习惯这样调用函数:

C语言
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 也要修改

正确的做法是什么?

✅ 使用队列进行任务间通信:

C语言
1// SensorTask:检测到报警时,发送消息到队列
2char *msg = pvPortMalloc(100);
3snprintf(msg, 100, "{\"type\":\"warning\", \"reason\":\"temperature_high\", \"value\":%d.%d}", minInt, minDec);
4osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);
5
6// 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 BLETask
2 │ │
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 + 内存屏障

C语言
1// SensorTask:更新全局变量
2farmState.temperature = 25.5;
3farmState.humidity = 60.2;
4
5// 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

C语言
1// InputTask:检测到按键,修改全局变量
2if (isKey1Clicked()) {
3 ScreenPage_NextPage(); // 修改pageIndex
4}
5
6// 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

C语言
1// SensorTask:读取 AHT20 前获取互斥锁
2osMutexAcquire(i2c1MutexHandle, osWaitForever);
3AHT20_Read(&farmState.temperature, &farmState.humidity);
4osMutexRelease(i2c1MutexHandle);
5
6// 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(),其他任务长时间等待
  • 互斥锁应该"快进快出",只保护临界区

✅ 正确做法:

C语言
1// 错误示例
2osMutexAcquire(i2c1MutexHandle, osWaitForever);
3AHT20_Read(&temp, &humi);
4osDelay(100); // ❌ 错误:延时期间占用互斥锁,其他任务无法访问I2C
5osMutexRelease(i2c1MutexHandle);
6
7// 正确示例
8osMutexAcquire(i2c1MutexHandle, osWaitForever);
9AHT20_Read(&temp, &humi);
10osMutexRelease(i2c1MutexHandle);
11osDelay(100); // ✅ 正确:释放互斥锁后再延时

五、典型工作流程的完整拆解

5.1 场景:温湿度采集 → 数据处理 → 显示更新 → 报警执行

让我们完整追踪一次"温度超限报警"的数据流动:

步骤 1: SensorTask 采集温湿度数据(每秒执行)

C语言
1// Core/App/Tasks/SensorTask.c:179
2osMutexAcquire(i2c1MutexHandle, osWaitForever); // 获取I2C互斥锁
3AHT20_Read(&farmState.temperature, &farmState.humidity); // 读取温湿度
4osMutexRelease(i2c1MutexHandle); // 释放I2C互斥锁

发生了什么?

  1. SensorTask 尝试获取 i2c1Mutex
  2. 如果 ScreenTask 正在刷新 OLED,SensorTask 会阻塞等待
  3. 获取互斥锁成功后,通过 I2C1 读取 AHT20 的温湿度数据
  4. 将数据写入全局变量 farmState.temperature 和 farmState.humidity
  5. 释放互斥锁

步骤 2: SensorTask 检查温度是否超限

C语言
1// Core/App/Tasks/SensorTask.c:202-203
2warning += CheckRangeFloat(farmState.temperature,
3 farmSafeRange.minTemperature,
4 farmSafeRange.maxTemperature,
5 "temperature_low", "temperature_high");

发生了什么?

  1. 读取 farmState.temperature 的当前值(比如 35.5℃)
  2. 读取 farmSafeRange.maxTemperature 的阈值(比如 30.0℃)
  3. 比较发现 35.5 > 30.0,温度超限!
  4. 调用 SendWarningFloat("temperature_high", 35.5)

步骤 3: SensorTask 发送报警消息到队列

C语言
1// Core/App/Tasks/SensorTask.c:41-56
2static 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}

发生了什么?

  1. 在 FreeRTOS 堆中分配 100 字节内存
  2. 格式化 JSON 字符串: {"type":"warning", "reason":"temperature_high", "value":35.5}
  3. 将消息指针(不是消息内容!)放入 BLEQueue
  4. 队列由空变为非空,BLETask 被唤醒

步骤 4: SensorTask 启动蜂鸣器

C语言
1// Core/App/Tasks/SensorTask.c:219-223
2if (warning > 0) {
3 Beep_on(); // 启动蜂鸣器(软件定时器)
4} else {
5 Beep_off(); // 关闭蜂鸣器
6}

发生了什么?

  1. 检测到 warning > 0,有报警
  2. 调用 Beep_on() 启动软件定时器 BeepTimer
  3. 定时器每 500ms 触发一次回调,翻转蜂鸣器 GPIO,实现"滴-滴-滴"报警音

步骤 5: SensorTask 延时 1 秒,继续下一次循环

C语言
1// Core/App/Tasks/SensorTask.c:226
2osDelay(1000);

发生了什么?

  1. SensorTask 进入阻塞态,让出 CPU
  2. 调度器切换到其他就绪任务(比如 ScreenTask 或 BLETask)
  3. 1 秒后,SensorTask 被唤醒,继续下一次采集

步骤 6: BLETask 被队列唤醒,发送蓝牙报警

C语言
1// Core/App/Tasks/BleTask.c:54
2osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever); // 阻塞等待,直到有消息

发生了什么?

  1. BLETask 之前在 osMessageQueueGet() 处阻塞,等待队列消息
  2. SensorTask 放入消息后,队列非空,BLETask 被唤醒
  3. 从队列取出消息指针 msg

步骤 7: BLETask 通过 UART3 发送报警消息

C语言
1// Core/App/Tasks/BleTask.c:58
2HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg)); // 使用DMA发送

发生了什么?

  1. 通过 UART3 以 DMA 方式发送 JSON 字符串到蓝牙模块
  2. 数据搬运由 DMA 完成,不会像阻塞式串口发送那样一直占着 CPU
  3. 蓝牙模块将消息无线传输到手机或网关

步骤 8: BLETask 等待发送完成,释放内存

C语言
1// Core/App/Tasks/BleTask.c:61-69
2while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {
3 osDelay(1); // 等待DMA发送完成
4}
5vPortFree(msg); // 释放消息内存

发生了什么?

  1. 轮询 UART 状态,等待 DMA 发送完成
  2. 发送完成后,释放之前分配的内存(避免内存泄漏)
  3. 继续等待下一条消息

步骤 9: ScreenTask 显示最新的温度数据(每 10ms 执行)

C语言
1// Core/App/Tasks/ScreenTask.c:68-74
2OLED_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);

发生了什么?

  1. ScreenTask 读取全局变量 farmState.temperature(35.5℃)
  2. 将浮点数转换为字符串 "35.5℃"
  3. 在 OLED 的帧缓冲区中绘制温度文字
  4. 使用互斥锁保护 I2C,将帧缓冲区发送到 OLED 显示

数据流动总结:

纯文本
1AHT20 (硬件)
2
3SensorTask (I2C读取)
4
5farmState.temperature (全局变量)
6
7CheckRangeFloat() (检查超限)
8
9SendWarningFloat() (创建消息)
10
11BLEQueue (消息队列)
12
13BLETask (UART发送)
14
15蓝牙模块 → 手机用户
16
17同时:
18farmState.temperature (全局变量)
19
20ScreenTask (OLED显示)
21
22OLED屏幕显示 "35.5℃"

5.2 FreeRTOS 在其中起到了什么作用

如果没有 FreeRTOS,这些功能怎么实现?

❌ 裸机实现:

C语言
1int main() {
2 while(1) {
3 // 读取传感器
4 AHT20_Read(&temp, &humi);
5
6 // 检查报警
7 if (temp > MAX_TEMP) {
8 Beep_on();
9 // 发送蓝牙(阻塞式,会延迟整个系统)
10 HAL_UART_Transmit(&huart3, msg, len, 1000);
11 Beep_off();
12 }
13
14 // 刷新显示(慢速I2C操作)
15 OLED_Refresh();
16
17 // 检测按键(可能在刷新OLED期间漏按)
18 if (KEY_Pressed()) {
19 // 切换页面
20 }
21
22 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: 任务划分不合理

❌ 错误示例: 把所有功能都写在一个任务里

C语言
1void StartTask(void *argument) {
2 while(1) {
3 // 读取传感器
4 AHT20_Read(&temp, &humi);
5
6 // 刷新显示
7 OLED_Refresh();
8
9 // 检测按键
10 if (KEY_Pressed()) {
11 // 切换页面
12 }
13
14 // 发送蓝牙
15 if (needAlert) {
16 HAL_UART_Transmit(&huart3, msg, len, 1000);
17 }
18
19 osDelay(10);
20 }
21}

问题: 又回到裸机编程模式了,FreeRTOS 的优势完全没用上

✅ 正确做法: 按功能模块划分任务

C语言
1// 传感器任务
2void SensorTask() {
3 while(1) {
4 AHT20_Read(&temp, &humi);
5 osDelay(1000);
6 }
7}
8
9// 显示任务
10void ScreenTask() {
11 while(1) {
12 OLED_Refresh();
13 osDelay(10);
14 }
15}
16
17// 输入任务
18void InputTask() {
19 while(1) {
20 if (KEY_Pressed()) {
21 // 切换页面
22 }
23 osDelay(10);
24 }
25}

错误 2: 优先级设计不当

❌ 错误示例: 所有任务优先级相同

C语言
1const osThreadAttr_t SensorTask_attributes = {
2 .priority = osPriorityNormal, // 普通优先级
3};
4
5const osThreadAttr_t InputTask_attributes = {
6 .priority = osPriorityNormal, // 也是普通优先级
7};
8
9问题: 按键响应不及时,用户感觉"按键不灵敏"
10✅ 正确做法: 根据实时性要求设计优先级
11const osThreadAttr_t InputTask_attributes = {
12 .priority = osPriorityHigh, // 高优先级,按键响应快
13};
14
15const osThreadAttr_t SensorTask_attributes = {
16 .priority = osPriorityNormal, // 普通优先级
17};

错误 3: 忘记使用互斥锁保护共享资源

❌ 错误示例: 直接访问共享的 I2C 总线

C语言
1// SensorTask
2void SensorTask() {
3 while(1) {
4 AHT20_Read(&temp, &humi); // 没有互斥锁保护
5 osDelay(1000);
6 }
7}
8
9// ScreenTask
10void ScreenTask() {
11 while(1) {
12 OLED_Refresh(); // 没有互斥锁保护
13 osDelay(10);
14 }
15}

问题: I2C 数据错乱,OLED 显示花屏

✅ 正确做法: 使用互斥锁保护共享资源

C语言
1// SensorTask
2void SensorTask() {
3 while(1) {
4 osMutexAcquire(i2c1MutexHandle, osWaitForever);
5 AHT20_Read(&temp, &humi);
6 osMutexRelease(i2c1MutexHandle);
7 osDelay(1000);
8 }
9}
10
11// ScreenTask
12void ScreenTask() {
13 while(1) {
14 osMutexAcquire(i2c1MutexHandle, osWaitForever);
15 OLED_Refresh();
16 osMutexRelease(i2c1MutexHandle);
17 osDelay(10);
18 }
19}

错误 4: 阻塞函数导致任务长时间占用 CPU

❌ 错误示例: 在任务里用 HAL_Delay() 或阻塞式 UART

C语言
1void BLETask() {
2 while(1) {
3 char *msg;
4 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);
5
6 // 阻塞式发送,占用CPU 1秒
7 HAL_UART_Transmit(&huart3, (uint8_t *)msg, strlen(msg), 1000);
8
9 vPortFree(msg);
10 }
11}

问题: BLETask 占用 CPU 期间,其他任务无法执行,系统卡顿

✅ 正确做法: 使用 DMA 发送,并在等待完成时让出 CPU

C语言
1void BLETask() {
2 while(1) {
3 char *msg;
4 osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);
5
6 // DMA发送,数据搬运交给DMA完成
7 HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));
8
9 // 等待发送完成(期间可以执行其他任务)
10 while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {
11 osDelay(1);
12 }
13
14 vPortFree(msg);
15 }
16}

错误 5: 内存泄漏

❌ 错误示例: 分配内存后忘记释放

C语言
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}
10
11void 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 堆内存耗尽,系统崩溃

✅ 正确做法: 谁分配谁释放,或者明确释放责任

C语言
1// SensorTask:分配内存
2char *msg = pvPortMalloc(100);
3snprintf(msg, 100, "alert: temp=%f", temperature);
4osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);
5// 注释说明:BLETask负责释放
6
7// 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 学完这个项目后,下一步可以挑战什么

初级挑战:

  1. 添加新的传感器
    • 添加 CO2 传感器(UART 通信)
    • 添加气压传感器(I2C 通信)
    • 思考:新传感器如何集成到现有任务架构?
  2. 优化显示效果
    • 添加更多页面(历史数据曲线图)
    • 添加开机动画和图标
    • 思考:如何避免页面切换时闪烁?

中级挑战:

  1. 添加数据存储功能
    • 使用 Flash 或 EEPROM 存储历史数据
    • 定期保存传感器数据(每分钟)
    • 思考:如何避免频繁写操作损坏 Flash?
  2. 添加网络通信
    • 集成 WiFi 模块(ESP8266)
    • 实现 MQTT 协议上传数据到云平台
    • 思考:网络通信优先级如何设计?网络断开如何处理?
  3. 优化电源管理
    • 添加低功耗模式(Stop Mode)
    • 定时唤醒采集数据
    • 思考:如何平衡功耗和实时性?

高级挑战:

  1. 实现 OTA 升级
    • 通过蓝牙或 WiFi 升级固件
    • 实现 Bootloader 和 APP 双分区
    • 思考:如何保证升级过程中断电恢复?
  2. 实现多语言支持
    • 支持中英文切换
    • 使用文件系统存储字库
    • 思考:如何存储大量多语言文本?

七、总结:从"看懂"到"会设计"

7.1 这个项目的核心设计思想

任务驱动的本质: 将复杂系统拆分为多个独立执行的任务,通过调度器自动管理任务切换,实现"关注点分离"。

任务划分的核心原则:

  1. 功能独立: 每个任务只负责一个功能模块
  2. 实时性匹配: 任务优先级与其实时性要求匹配
  3. 资源保护: 共享资源必须用互斥锁保护
  4. 通信解耦: 任务间通过队列或全局变量通信,不直接调用

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 理解事件驱动任务和队列通信

记住:看懂代码只是第一步,自己动手设计一个新项目才是真正掌握!

祝你学习顺利!