【实战】智慧农场项目
本文档面向已经学过 STM32 外设基础、了解 FreeRTOS 基本概念,但从未做过完整 RTOS 实战工程的学习者。
下载例程代码: 下载代码
一、项目整体介绍
1.1 这个项目是做什么的
智慧农场项目是一个完整的嵌入式监测与控制系统,运行在 STM32F103 单片机上,使用 FreeRTOS 实时操作系统进行任务调度。
系统能够:
- 实时监测: 温度、湿度、土壤湿度、光照强度、降雨量等环境参数
- 自动控制: 根据土壤湿度自动启停水泵
- 报警提示: 环境参数超出安全范围时触发蜂鸣器和蓝牙报警
- 人机交互: 通过 OLED 显示屏、按键和旋钮进行参数设置和状态查看
1.2 为什么选择"智慧农场"作为实战项目
在真实的嵌入式项目中,单一功能的 demo和完整的产品级系统之间存在巨大鸿沟。智慧农场这个场景恰好能填平这个鸿沟,因为它具备真实产品的典型特征:
✅ 多传感器融合: 涉及 I2C(AHT20)、ADC(土壤湿度、光照、降雨量)等多种外设 ✅ 实时性要求不同: 传感器采集需要周期执行,用户输入需要快速响应,报警需要及时处理 ✅ 任务间协作: 传感器数据要传递给显示任务,报警消息要传递给蓝牙任务 ✅ 资源竞争: OLED 和 AHT20 共用 I2C1 总线,需要互斥访问 ✅ 用户交互: 有完整的菜单系统、参数编辑逻辑
这正是真实嵌入式项目的典型特征。
1.3 学完这个项目你能掌握什么
不只是 API,而是工程思维:
- 如何将一个复杂系统拆分成多个任务
- 如何设计任务的优先级和执行周期
- 任务之间如何安全地交换数据
- 如何避免资源竞争导致的死锁和数据错乱
- 如何处理周期性任务和事件驱动任务
二、系统整体架构设计
2.1 系统由哪些模块组成
从硬件模块看,系统包含:
| 模块 | 功能 | 通信接口 |
|---|---|---|
| AHT20 | 温湿度传感器 | I2C1 |
| OLED | 显示屏 | I2C1 |
| 土壤湿度传感器 | 检测土壤湿度 | ADC1 |
| 光照传感器 | 检测光照强度 | ADC2 |
| 降雨量传感器 | 检测降雨量 | ADC |
| 水泵 | 自动灌溉 | GPIO |
| 蜂鸣器 | 报警提示 | GPIO + 定时器 |
| 按键 | 用户输入 | GPIO |
| 旋钮 | 参数调节 | GPIO |
| 蓝牙模块 | 无线通信 | UART3 + DMA |
硬件连接说明
本项目所有外设模块均通过杜邦线连接到STM32学习板的相应接口。

图 2.1: 智慧农场项目硬件连接示意图
上图展示了所有模块与STM32学习板的连接方式。如果你手头有实物,可以按照上图进行接线。
关键连接说明:
-
I2C1 总线连接(共享总线,需要特别关注):
- OLED 显示屏: SCL → PB6, SDA → PB7
- AHT20 温湿度传感器: SCL → PB6, SDA → PB7
- ⚠️ 注意: 两个设备共用 I2C1 总线,这就是为什么代码中需要使用互斥锁
i2c1Mutex来保护总线访问
-
ADC 传感器连接:
- 光照传感器: AO → PB1(ADC1_IN9)
- 土壤湿度传感器: AO → PA0(ADC2_IN0)
- 降雨量传感器: ADC 输入引脚请参考具体硬件配置
-
GPIO 输入设备:
- KEY1 按键: PB12(页面切换)
- KEY3 按键: PB15(编辑模式切换)
- 旋钮(编码器): CH1 → PA8, CH2 → PA9(使用 TIM1 编码器模式)
-
输出设备:
- 水泵继电器: PA12(低电平触发)
- 蜂鸣器: PB9(TIM4_CH4, PWM 控制)
-
UART3 蓝牙模块:
- TX: PB10
- RX: PB11
- 使用 DMA 方式传输,提高效率
电源连接:
- 所有模块 VCC 接 3.3V 或 5V(根据模块规格)
- 所有模块 GND 接 STM32 GND
- ⚠️ 注意: 务必保证共地,否则通信会失败
从软件任务看,系统划分为 4 个任务 + 1 个定时器:
┌─────────────────────────────────────────────────┐
│ FreeRTOS 调度器 │
├─────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ SensorTask │ │ InputTask │ │
│ │ 优先级:普通 │ │ 优先级:高 │ │
│ │ 周期:1000ms │ │ 周期:10ms │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ ScreenTask │ │ BLETask │ │
│ │ 优先级:低 │ │ 优先级:低 │ │
│ │ 周期:10ms │ │ 事件驱动 │ │
│ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────────────────┐ │
│ │ BeepTimer (软件定时器) │ │
│ │ 周期:500ms │ │
│ └──────────────────────────┘ │
└─────────────────────────────────────────────────┘
2.2 这是一个"任务驱动"的系统
为什么不用 while(1) 全部写在 main 里?
在裸机编程中,你可能习惯这样写:
int main() {
// 初始化所有外设
AHT20_Init();
OLED_Init();
// ...
while(1) {
// 读取传感器
AHT20_Read(&temp, &humi);
soilMoisture = SoilMoisture_Get();
// ...
// 检查报警
if (temp > MAX_TEMP) {
Beep_on();
}
// ...
// 刷新显示
OLED_ShowTemp(temp);
OLED_ShowHumi(humi);
// ...
// 检测按键
if (KEY1_Pressed()) {
// 切换页面
}
// ...
delay_ms(10);
}
}
这种写法在实际项目中会遇到什么问题?
❌ 问题 1: 代码耦合严重
- 传感器读取、显示刷新、按键检测全混在一起
- 修改显示逻辑可能会影响传感器采集
❌ 问题 2: 实时性无法保证
- OLED 刷新需要时间(通过 I2C 发送整屏数据)
- 在刷新 OLED 期间,按键检测被阻塞,用户会感觉"按键不灵敏"
- 传感器采集也被延迟,数据时效性变差
❌ 问题 3: 难以扩展
- 要添加蓝牙功能,需要在主循环里加一堆代码
- 主循环变得越来越长,越来越难维护
❌ 问题 4: 资源竞争无保护
- AHT20 和 OLED 共用 I2C1,交替访问时可能出现数据错乱
- 需要手动管理互斥访问,容易出错
FreeRTOS 如何解决这些问题?
FreeRTOS 通过任务调度让每个功能模块独立运行:
// 传感器任务:每秒执行一次
void SensorTask() {
while(1) {
// 采集数据
AHT20_Read(&temp, &humi);
// 检查报警
osDelay(1000);
}
}
// 显示任务:每10ms执行一次
void ScreenTask() {
while(1) {
// 刷新显示
OLED_Refresh();
osDelay(10);
}
}
// 输入任务:每10ms执行一次
void InputTask() {
while(1) {
// 检测按键
if (KEY1_Pressed()) {
// 切换页面
}
osDelay(10);
}
}
每个任务都是独立的执行流,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)
❌ 报警检测逻辑写死在任务里,导致代 码难以扩展
正确做法:
void StartSensorTask(void *argument) {
// 初始化
osMutexAcquire(i2c1MutexHandle, osWaitForever);
AHT20_Init();
osMutexRelease(i2c1MutexHandle);
for(;;) {
// 读取数据(使用互斥锁保护I2C)
osMutexAcquire(i2c1MutexHandle, osWaitForever);
AHT20_Read(&farmState.temperature, &farmState.humidity);
osMutexRelease(i2c1MutexHandle);
// 检查报警,发送消息
if (farmState.temperature > farmSafeRange.maxTemperature) {
SendWarningFloat("temperature_high", farmState.temperature);
}
// 延时1秒
osDelay(1000); // ✅ 使用osDelay,任务会进入阻塞态,让出CPU
}
}
ScreenTask(显示任务)
职责:
- 初始化 OLED 显示屏
- 根据当前页面渲染界面(首页/阈值设置页)
- 定期刷新显示内容
为什么这样设计?
- 周期性执行: 10ms 刷新一次,保证显示流畅(100Hz)
- 优先级 BelowNormal5: 最低优先级,不会影响实时性要求高的任务
- 使用互斥锁: OLED 和 AHT20 共用 I2C1,刷新前必须获取
i2c1Mutex
新手容易犯的错误: ❌ 每次刷新都重新初始化 OLED(非常慢) ❌ 刷新频率太低(比如100ms),导致界面卡顿 ❌ 忘记使用互斥锁,导致 I2C 数据错乱
正确做法:
void StartScreenTask(void *argument) {
OLED_Init(); // 只初始化一次
for(;;) {
// 双缓冲:先在内存中绘制,再一次性发送
OLED_NewFrame();
// 根据当前页面渲染
switch (pageIndex) {
case PAGE_HOME:
renderHomePage();
break;
case PAGE_RANGE:
renderRangePage();
break;
}
// 使用互斥锁保护I2C
osMutexAcquire(i2c1MutexHandle, osWaitForever);
OLED_ShowFrame();
osMutexRelease(i2c1MutexHandle);
osDelay(10);
}
}
InputTask(输入任务)
职责:
- 检测按键输入(KEY1、KEY3)
- 检测旋钮旋转方向
- 根据输入控制页面切换和阈值编辑
为什么这样设计?
- 周期性执行: 10ms 检测一次,保证响应及时
- 优先级 High: 高优先级,确保用户输入快速响应
- 事件驱动: 按键按下时才执行操作,平时只检测不操作
新手容易犯的错误: ❌ 按键检测用延时消抖,阻塞任务执行 ❌ 旋钮检测逻辑复杂,导致任务执行时间过长 ❌ 忘记处理"长按"、"连发"等边界情况
正确做法:
void StartInputTask(void *argument) {
Knob_Init(); // 只初始化一次
for(;;) {
// 检测按键(消抖逻辑在 BSP 层处理)
if (isKey1Clicked()) {
ScreenPage_NextPage();
}
// 在阈值设置页时处理阈值编辑
if (pageIndex == PAGE_RANGE) {
if (isKey3Clicked()) {
RangeEditState_Toggle();
}
KnobDirection dir = Knob_IsRotating();
if (dir == KNOB_DIR_LEFT) {
// 左旋逻辑
} else if (dir == KNOB_DIR_RIGHT) {
// 右旋逻辑
}
}
osDelay(10);
}
}
BLETask(蓝牙任务)
职责:
- 从 BLE 队列接收报警消息
- 通过 UART3(DMA 方式)发送消息到蓝牙模块
- 发送完成后释放消息内存
为什么这样设计?
- 事件驱动: 使用
osMessageQueueGet(..., osWaitForever)阻塞等待,队列为空时任务挂起,不占用 CPU - 优先级 Low: 低优先级,不影响实时性要求高的任务
- 内存管理: 消息由 SensorTask 分配,由 BLETask 释放,避免内存泄漏
新手容易犯的错误: ❌ 用轮询方式检查队列,浪费 CPU ❌ 发送完消息后忘记释放内存,导致内存泄漏 ❌ 用阻塞方式发送 UART,导致任务长时间占用 CPU
正确做法:
void StartBLETask(void *argument) {
for(;;) {
char *msg;
// 阻塞等待队列消息(队列为空时任务挂起,不占用CPU)
osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);
if (msg != NULL) {
// 使用DMA发送(异步,不阻塞CPU)
HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));
// 等待发送完成
while (HAL_UART_GetState(&huart3) != HAL_UART_STATE_READY) {
osDelay(1);
}
// 释放内存
vPortFree(msg);
}
}
}
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 为什么任务不能"直接互相调用"
在裸机编程中,你可能习惯这样调用函数:
int main() {
while(1) {
temp = readTemperature();
if (temp > MAX_TEMP) {
sendBluetoothAlert(temp);
}
delay_ms(1000);
}
}
在 FreeRTOS 中,为什 么不能直接调用其他任务的函数?
❌ 问题 1: 任务是独立的执行流
- 每个任务都有自己的栈空间和程序计数器
- 直接调用只是"函数调用",不是"任务切换"
❌ 问题 2: 无法保证实时性
- 如果
sendBluetoothAlert()耗时较长(比如发送 UART),会阻塞当前任务 - SensorTask 被阻塞,传感器采集延迟,报警不及时
❌ 问题 3: 无法解耦
- SensorTask 直接调用 BLETask 的函数,耦合严重
- 如果 BLETask 的实现改变,SensorTask 也要修改
正确的做法是什么?
✅ 使用队列进行任务间通信:
// SensorTask:检测到报警时,发送消息到队列
char *msg = pvPortMalloc(100);
snprintf(msg, 100, "{\"type\":\"warning\", \"reason\":\"temperature_high\", \"value\":%d.%d}", minInt, minDec);
osMessageQueuePut(BLEQueueHandle, &msg, NULL, 0);
// BLETask:从队列接收消息,异步处理
char *msg;
osMessageQueueGet(BLEQueueHandle, &msg, NULL, osWaitForever);
HAL_UART_Transmit_DMA(&huart3, (uint8_t *)msg, strlen(msg));
vPortFree(msg);