为什么需要多线程

嵌入式管道监测系统需要同时做三件事:周期性采集传感器、刷新 LCD 显示、把数据落盘或上云。如果用单线程顺序执行,任何一个环节阻塞(比如 Modbus 超时或 Flash 写入)都会拖慢整个系统,导致 LCD 卡顿或采样丢失。

RT-Thread 提供了轻量级多线程调度,让我们可以把不同职责拆到独立线程里,通过 IPC 机制传递数据和通知。

线程全景

系统启动后共有 6 个业务线程:

线程栈大小优先级职责
app_init2048B8一次性初始化,拉起其它线程后退出
app_meas3072B10周期采集所有传感器,发布测量快照
app_disp3072B15LCD 渲染,响应按键和定时维护
app_store4096B18CSV 批量写入 LittleFS
app_led1024B20心跳灯翻转,观察系统存活
uplink4096B12JSON 上行 + 下行命令解析

优先级数值越小越高。采集线程(10)高于显示线程(15),确保传感器轮询不会被 UI 刷新抢占。

事件驱动:解耦的关键

线程之间不直接调用函数,而是通过 事件位消息队列 通信:

/* 应用层事件位定义 */
#define APP_EVENT_MEAS_UPDATED       (1UL << 0)  /* 新采样快照就绪 */
#define APP_EVENT_ALARM_TRIGGERED    (1UL << 1)  /* 报警事件入队 */
#define APP_EVENT_LCD_TOGGLE         (1UL << 2)  /* 按键请求切换 LCD 开关 */
#define APP_EVENT_LCD_REDRAW         (1UL << 3)  /* 请求整屏强制重绘 */
#define APP_EVENT_LCD_REINIT         (1UL << 4)  /* 请求重新初始化 LCD 驱动 IC */

采集线程完成一轮采样后,只做两件事:

  1. 用互斥锁更新共享快照 g_latest_measurement
  2. 发送 APP_EVENT_MEAS_UPDATED 事件位
static void app_publish_measurement(const app_measurement_t *measurement)
{
    rt_mutex_take(&g_measurement_lock, RT_WAITING_FOREVER);
    memcpy(&g_latest_measurement, measurement, sizeof(g_latest_measurement));
    rt_mutex_release(&g_measurement_lock);

    rt_event_send(&g_app_event, APP_EVENT_MEAS_UPDATED);
    app_alarm_evaluate(measurement);
}

显示线程和上行线程各自订阅感兴趣的事件位,互不干扰。

消息队列:批量与异步

除了事件位,系统还用了两个消息队列:

  • 存储队列 g_storage_mq:深度 16,每条一条 app_measurement_t 快照。显示线程不等存储完成,采集线程也不管 Flash 写入耗时。
  • 报警队列 g_alarm_mq:深度 16,每条一条 app_alarm_event_t。上行线程按需消费。

队列满了就丢弃并计数,避免阻塞生产者:

static void app_enqueue_measurement_for_storage(const app_measurement_t *measurement)
{
    rt_err_t err = rt_mq_send(&g_storage_mq, measurement, sizeof(*measurement));
    if (err != RT_EOK)
    {
        g_storage_drop_count++;
    }
}

共享快照的锁策略

测量快照 g_latest_measurement 是唯一被多线程读写的数据。锁的粒度控制得很小——只保护 memcpy 这一瞬间:

void app_measurement_copy(app_measurement_t *out)
{
    rt_mutex_take(&g_measurement_lock, RT_WAITING_FOREVER);
    memcpy(out, &g_latest_measurement, sizeof(*out));
    rt_mutex_release(&g_measurement_lock);
}

阈值检查、LCD 渲染、JSON 编码都在锁外执行,不影响其它消费者。

线程启动顺序

app_init 线程按依赖关系依次拉起:

  1. 外设初始化(LCD、按键)
  2. 显示线程、心跳线程、存储线程(不依赖总线)
  3. Modbus 服务初始化
  4. 流量计、PT100 设备配置
  5. 等待 1s 让现场设备稳定
  6. 采集线程启动
  7. 上行服务启动
  8. 看门狗启动

这种顺序保证了:在采集线程开始轮询之前,LCD 和存储已经就绪,用户立刻能看到数据。

事件等待的超时兜底

显示线程同时监听数据更新和 LCD 维护事件,超时 200ms 作为兜底刷新:

(void)rt_event_recv(&g_app_event,
                    wait_mask,
                    RT_EVENT_FLAG_OR | RT_EVENT_FLAG_CLEAR,
                    rt_tick_from_millisecond(LCD_REFRESH_PERIOD_MS),
                    &received);

这样即使没有新数据,屏幕也会每 200ms 刷新一次,处理定时维护(整屏重绘、IC 重初始化)。

小结

多线程 + 事件驱动的好处:

  • 解耦:采集不需要知道数据去了哪里,显示不需要知道数据怎么来的
  • 隔离:Modbus 超时不会卡住 LCD,Flash 写入不会丢采样
  • 可扩展:新增上行线程只需订阅现有事件位,不改动采集和显示代码

代价是需要小心锁的粒度和事件的时序——好在 RT-Thread 的 IPC 原语足够轻量,对 Cortex-M33 来说开销可以忽略。

后续阅读