为什么需要多线程
嵌入式管道监测系统需要同时做三件事:周期性采集传感器、刷新 LCD 显示、把数据落盘或上云。如果用单线程顺序执行,任何一个环节阻塞(比如 Modbus 超时或 Flash 写入)都会拖慢整个系统,导致 LCD 卡顿或采样丢失。
RT-Thread 提供了轻量级多线程调度,让我们可以把不同职责拆到独立线程里,通过 IPC 机制传递数据和通知。
线程全景
系统启动后共有 6 个业务线程:
| 线程 | 栈大小 | 优先级 | 职责 |
|---|---|---|---|
app_init | 2048B | 8 | 一次性初始化,拉起其它线程后退出 |
app_meas | 3072B | 10 | 周期采集所有传感器,发布测量快照 |
app_disp | 3072B | 15 | LCD 渲染,响应按键和定时维护 |
app_store | 4096B | 18 | CSV 批量写入 LittleFS |
app_led | 1024B | 20 | 心跳灯翻转,观察系统存活 |
uplink | 4096B | 12 | JSON 上行 + 下行命令解析 |
优先级数值越小越高。采集线程(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 */
采集线程完成一轮采样后,只做两件事:
- 用互斥锁更新共享快照
g_latest_measurement - 发送
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 线程按依赖关系依次拉起:
- 外设初始化(LCD、按键)
- 显示线程、心跳线程、存储线程(不依赖总线)
- Modbus 服务初始化
- 流量计、PT100 设备配置
- 等待 1s 让现场设备稳定
- 采集线程启动
- 上行服务启动
- 看门狗启动
这种顺序保证了:在采集线程开始轮询之前,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 来说开销可以忽略。