Chapter 12 · FreeRTOS 实时操作系统

实战:多任务传感器网关

把前 11 章的任务、队列、信号量、事件组、软件定时器、中断延迟处理与低功耗全部串起来,搭建一个采集→处理→上报的三级流水线网关,给出完整架构、代码骨架与上线 checklist。

课程进度100%

项目目标与设计原则

系统目标
一个 STM32/ESP32 传感器网关:周期采集多路传感器(I2C/SPI/ADC),对原始数据做滤波与换算,再通过网络/串口批量上报云端;同时处理按键与告警事件,空闲时进入低功耗。要求:采集不丢点、上报失败可重试、各模块解耦、可调试、续航良好。
职责分离原则
每个任务只干一件事:采集任务只管把数据取上来,处理任务只管算,上报任务只管发。任务之间不共享全局变量、不互相调用函数,而是通过队列传数据、事件组传状态解耦。这样任一模块可独立修改、独立测试,也天然规避了大部分竞态。
生产者—消费者 + 流水线
采集是生产者,处理是消费者兼生产者,上报是最终消费者,用两级队列串成流水线。队列天然做缓冲:某一级偶尔慢一点,数据先在队列里排队,不会丢也不会阻塞上游太久(配合合理的队列长度与超时)。
中断延迟处理落地
传感器"数据就绪"用 DMA + 中断触发(第 9 章):ISR 只清标志、vTaskNotifyGiveFromISR 唤醒采集任务,重活在任务上下文做。按键用 EXTI 中断 + 事件组置位。既保证实时响应,又不在中断里干重活。
共享外设的互斥
上报任务与调试打印可能都要用同一条 UART,采集任务的多个传感器共用一条 I2C 总线——这些共享外设一律用互斥量(第 5 章,带优先级继承)保护,谁用谁加锁,杜绝总线数据交错。
系统就绪同步与告警
事件组(第 6 章)表达全局状态:WiFi 连上、时间同步、配置加载三位齐了上报任务才开工(AND 等待);按键、告警、看门狗超时等异步事件用 OR 等待由一个管理任务统一处理。
看门狗与健康监控
软件定时器(第 7 章)做链路看门狗:每成功上报一次就 Reset,长时间没上报则触发重连;再用一个周期定时器定期打印运行时统计与栈高水位(第 11 章),持续监控系统健康。
低功耗收尾
所有任务都尽量阻塞在队列/通知/延时上,让空闲任务有机会进入 Tickless(第 10 章);采集周期用 vTaskDelayUntil 保证严格节拍,唤醒时刻尽量对齐以减少唤醒次数,延长深睡时间。

整体架构

多任务传感器网关 · 数据流与内核对象 ═══════════════════════════════════════════════════════════ 传感器 ──DMA中断──► ISR ──NotifyGive──► 采集任务 SensorTask(prio 4) (I2C/SPI/ADC) │ 读原始数据 │ 加锁 I2C 互斥量 ▼ xQueueSend [原始数据队列 rawQ] │ ▼ xQueueReceive 处理任务 ProcTask(prio 3) │ 滤波/换算/打包 ▼ xQueueSend [上报数据队列 txQ] │ 事件组 sysEG ──AND(WIFI|TIME|CFG)──► ▼ xQueueReceive 上报任务 CommTask(prio 2) │ 加锁 UART 互斥量 │ 发送 + 失败重试 ▼ 成功→ xTimerReset(watchdog) 按键/告警 ─EXTI中断→ SetBitsFromISR → 事件组 ─OR→ MgrTask(prio 5) 软件定时器:linkWatchdog(单次,喂狗) + healthTimer(周期,打印统计) 空闲时 → Tickless Idle → STOP 低功耗

内核对象与任务规划

对象/任务类型优先级/参数作用
MgrTask任务prio 5(最高)处理按键/告警等异步事件
SensorTask任务prio 4被中断唤醒,采原始数据入 rawQ
ProcTask任务prio 3从 rawQ 取,滤波换算入 txQ
CommTask任务prio 2从 txQ 取,上报云端+重试
rawQ / txQ队列len 8~16两级流水线缓冲
i2cMutex / uartMutex互斥量保护共享总线
sysEG事件组24 位就绪状态 + 异步事件
linkWatchdog软件定时器单次 30s上报超时→重连
healthTimer软件定时器周期 60s打印统计/栈水位

初始化:创建所有内核对象

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"
#include "semphr.h"
#include "event_groups.h"
#include "timers.h"

/* 事件位定义 */
#define EV_WIFI  (1UL<<0)
#define EV_TIME  (1UL<<1)
#define EV_CFG   (1UL<<2)
#define EV_READY (EV_WIFI|EV_TIME|EV_CFG)
#define EV_BTN   (1UL<<8)
#define EV_ALARM (1UL<<9)

typedef struct { uint32_t ts; int16_t raw[4]; } SensorMsg;
typedef struct { uint32_t ts; float val[4]; }   Packet;

QueueHandle_t      g_rawQ, g_txQ;
SemaphoreHandle_t  g_i2cMutex, g_uartMutex;
EventGroupHandle_t g_sysEG;
TimerHandle_t      g_watchdog, g_health;
TaskHandle_t       g_sensorTask;

void app_init(void) {
  g_rawQ     = xQueueCreate(16, sizeof(SensorMsg));
  g_txQ      = xQueueCreate(8,  sizeof(Packet));
  g_i2cMutex = xSemaphoreCreateMutex();
  g_uartMutex= xSemaphoreCreateMutex();
  g_sysEG    = xEventGroupCreate();
  configASSERT(g_rawQ && g_txQ && g_i2cMutex && g_uartMutex && g_sysEG);

  g_watchdog = xTimerCreate("wd", pdMS_TO_TICKS(30000),
                            pdFALSE, NULL, LinkTimeoutCb);
  g_health   = xTimerCreate("hp", pdMS_TO_TICKS(60000),
                            pdTRUE,  NULL, HealthCb);

  xTaskCreate(MgrTask,    "MGR",  256, NULL, 5, NULL);
  xTaskCreate(SensorTask, "SEN",  384, NULL, 4, &g_sensorTask);
  xTaskCreate(ProcTask,   "PRC",  384, NULL, 3, NULL);
  xTaskCreate(CommTask,   "COM",  512, NULL, 2, NULL);

  xTimerStart(g_health, 0);
  vTaskStartScheduler();   /* 启动调度,不返回 */
}

采集任务:中断唤醒 + 互斥总线

/* DMA 完成中断:只清标志 + 唤醒采集任务 */
void DMA1_Stream0_IRQHandler(void) {
  BaseType_t woken = pdFALSE;
  __HAL_DMA_CLEAR_FLAG(&hdma_i2c, DMA_FLAG_TCIF0);
  vTaskNotifyGiveFromISR(g_sensorTask, &woken);
  portYIELD_FROM_ISR(woken);
}

void SensorTask(void *arg) {
  TickType_t last = xTaskGetTickCount();
  for (;;) {
    /* 严格 100ms 节拍触发一次采集 */
    vTaskDelayUntil(&last, pdMS_TO_TICKS(100));

    start_i2c_dma_read();                 /* 启动 DMA */
    ulTaskNotifyTake(pdTRUE,             /* 等 DMA 完成中断 */
                     pdMS_TO_TICKS(50));

    SensorMsg m; m.ts = xTaskGetTickCount();
    if (xSemaphoreTake(g_i2cMutex, pdMS_TO_TICKS(20))) {
      read_raw(m.raw);                    /* 临界区:独占 I2C */
      xSemaphoreGive(g_i2cMutex);
      /* 入队;满则丢最旧一帧策略视需求而定 */
      xQueueSend(g_rawQ, &m, 0);
    }
  }
}

处理任务与上报任务

void ProcTask(void *arg) {
  SensorMsg m; Packet p;
  for (;;) {
    /* 阻塞等原始数据;有数据才醒,利于低功耗 */
    if (xQueueReceive(g_rawQ, &m, portMAX_DELAY) == pdTRUE) {
      p.ts = m.ts;
      for (int i = 0; i < 4; i++)
        p.val[i] = filter_and_scale(m.raw[i]);
      xQueueSend(g_txQ, &p, pdMS_TO_TICKS(10));
    }
  }
}

void CommTask(void *arg) {
  Packet p;
  /* 等三大前置条件全就绪(AND)才开始上报 */
  xEventGroupWaitBits(g_sysEG, EV_READY,
                      pdFALSE, pdTRUE, portMAX_DELAY);
  xTimerStart(g_watchdog, 0);

  for (;;) {
    if (xQueueReceive(g_txQ, &p, portMAX_DELAY) == pdTRUE) {
      if (xSemaphoreTake(g_uartMutex, pdMS_TO_TICKS(100))) {
        bool ok = upload_with_retry(&p, 3);  /* 失败重试 3 次 */
        xSemaphoreGive(g_uartMutex);
        if (ok) xTimerReset(g_watchdog, 0);   /* 喂狗 */
      }
    }
  }
}

管理任务:OR 等待多路异步事件

/* 按键中断:给事件组置位 */
void EXTI0_IRQHandler(void) {
  BaseType_t woken = pdFALSE;
  __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
  xEventGroupSetBitsFromISR(g_sysEG, EV_BTN, &woken);
  portYIELD_FROM_ISR(woken);
}

void MgrTask(void *arg) {
  for (;;) {
    EventBits_t b = xEventGroupWaitBits(
        g_sysEG, EV_BTN | EV_ALARM,
        pdTRUE,          /* 处理后清位 */
        pdFALSE,         /* OR:任一事件即处理 */
        portMAX_DELAY);
    if (b & EV_BTN)   handle_button();
    if (b & EV_ALARM) handle_alarm();
  }
}

/* 看门狗回调:长时间没成功上报→触发重连(简短,重活转任务) */
void LinkTimeoutCb(TimerHandle_t t) {
  (void) t;
  xEventGroupClearBits(g_sysEG, EV_WIFI);   /* 标记掉线 */
  request_reconnect();
}

/* 健康监控回调:定期打印统计与栈水位 */
void HealthCb(TimerHandle_t t) {
  (void) t;
  dump_cpu_usage();
  printf("heap free=%u\r\n",
         (unsigned)xPortGetFreeHeapSize());
}
优先级排布的用意

MgrTask(5) 最高——用户按键与告警要最快响应;SensorTask(4) 次之——采集有硬实时节拍不能被拖;ProcTask(3) 和 CommTask(2) 较低——它们阻塞在队列上,有数据才跑,慢一点由队列缓冲兜底。这种"越靠近硬件/用户、优先级越高,越靠后台、优先级越低"的排布,是数据流水线的通用范式。

队列满了怎么办:明确背压策略

本例采集入队用超时 0(满则丢弃当前帧),适合"实时性优先、可容忍偶尔丢点"的监测场景。若要求不丢数据,应给上游设合理阻塞超时并加大队列,或让上游降速;若要求只保留最新,可用队列覆写模式。务必显式设计满队列时的背压策略,不要留默认——这是流水线系统最容易埋雷的地方。

上线部署 Checklist

类别检查项
中断要调内核 API 的中断优先级数值 ≥ configMAX_SYSCALL_INTERRUPT_PRIORITY;ISR 末尾都有 portYIELD_FROM_ISR
每个任务用 uxTaskGetStackHighWaterMark 实测,留 20~30% 余量;开 configCHECK_FOR_STACK_OVERFLOW=2
用 heap_4;xPortGetMinimumEverFreeHeapSize 有足够余量;开 malloc 失败钩子
互斥共享外设(I2C/UART)全部互斥量保护;Take 都设超时不用永久等待
队列长度按最坏突发校核;满队列背压策略明确(丢弃/阻塞/覆写)
断言开发/联调期 configASSERT 开启并停机;无断言触发
低功耗任务尽量阻塞;开 Tickless;唤醒源配全;实测睡眠电流达标
健壮性上报失败可重试;看门狗(软件+硬件 IWDG)覆盖;断网可恢复
监控定期打印运行时统计与栈水位;IDLE 占比留有余量(不长期跑满)
别忘了硬件看门狗兜底

软件定时器看门狗只能发现"上报超时"这类逻辑层面的卡死,无法拯救"整个系统跑飞/死锁"的情况。生产设备必须再加一层硬件独立看门狗(如 STM32 的 IWDG):在最低优先级任务或空闲钩子里定期喂它,一旦系统整体卡死喂狗停止,硬件会强制复位。两层看门狗结合,才是无人值守嵌入式设备的可靠保障。

本章小结 · 全课总结

本章把全课知识串成一个真实网关:任务按"越靠近硬件/用户优先级越高"排布(Mgr 5 > Sensor 4 > Proc 3 > Comm 2);队列把采集→处理→上报连成两级流水线并做缓冲;互斥量保护共享的 I2C/UART 总线;事件组 AND 等待三大前置就绪、OR 等待按键/告警等异步事件;中断延迟处理让 ISR 只清标志+唤醒任务(NotifyGive/SetBitsFromISR + portYIELD_FROM_ISR);软件定时器做链路看门狗(喂狗式 Reset)与周期健康监控;vTaskDelayUntil 保证采集硬节拍;任务尽量阻塞以便进入 Tickless 低功耗。上线前对照 checklist 核查中断优先级、栈/堆余量、互斥覆盖、队列背压、断言、低功耗、健壮性与监控,并务必加一层硬件看门狗兜底。至此你已掌握 FreeRTOS 从内核原理到工程落地的完整链路——愿你的每一个任务都各司其职、准时醒来、从容睡去。