2026 ADI CodeFusion 竞赛--基于Max32690EVKIT和Arduino UNO Q 的多传感器环境监测站
1. 所选任务
所选任务:赛道二:智能感知
官方任务描述(入门级题目:多传感器环境监测站):
采集温度、湿度、气压、光照等环境数据,采样周期1秒至1分钟可配置,在OLED屏实时显示(刷新率5Hz以上),并通过BLE发送至手机或上位机。当监测数据超过设定阈值时,1秒内触发声光报警提示。
本项目实际实现(与官方要求逐项对照):
官方要求 | 本项目实现 |
|---|---|
通过 I2C 读取 SEN66 的 9 种数据 | 已实现:I2C0 @0x6B 读取 PM1.0/PM2.5/PM4.0/PM10、温度、湿度、CO2、VOC、NOx,含无效数据判定 |
采样周期 1 秒至 1 分钟可配置 | 已实现:1s~60s 屏上连续可调,Zephyr Settings/NVS 持久化,断电保持,修改后最迟 100ms 生效 |
LED 显示屏实时显示(刷新率 ≥5Hz) | 已实现:ST7735R 128x128 TFT 彩屏 + LVGL,主屏九宫格 10Hz 刷新,另含设置页与系统信息页 |
通过 BLE 发送至手机或上位机 | 改用 CAN:Zephyr 软件栈暂无 MAX32690 BLE 驱动(详见第 8 节难点 1),数据改经 CAN 总线 500kbps 发送至 Arduino Uno Q;Arduino Uno Q 双核同时运行,实现两个功能,功能1:在 Led Matrix 滚动展示环境数据,功能2:网页显示实时传感器数据,并显示历史曲线 |
数据超阈值时 1 秒内声光报警 | 已实现:LED 红 5Hz + DFR0760 V2 中文语音播报,报警线程 200ms 轮询保证 1 秒内触发,带 5% 滞后防抖与限次策略 |
2. 项目描述
本项目基于 MAX32690EVKIT 开发板(Zephyr RTOS v4.3.0 + LVGL 9.x),构建了一套多参数环境监测站:
- 采集:SEN66 传感器通过 I2C 提供 PM1.0/PM2.5/PM4.0/PM10(颗粒物)、温度、湿度、CO2、VOC、NOx 共 9 通道数据,采样周期 1s~60s 连续可配置并持久化到 flash;
- 显示:128x128 TFT 彩屏以 10Hz 刷新九宫格实时数据,并提供设置页(采样周期 / 阈值 / TTS 设置)与系统信息页;
- 报警:任一通道超过阈值时 1 秒内触发光(红灯 5Hz)+ 声(中文语音播报)报警,带 5% 滞后防抖、60s 滑动窗口限 3 次等防骚扰策略,全部阈值支持屏上编辑并持久化;
- 对外:CAN 总线(500kbps)将传感器数据发送至 Arduino Uno Q;Arduino Uno Q 双核同时运行,实现两个功能,功能1:在 Led Matrix 滚动展示环境数据,功能2:网页显示实时传感器数据,并显示历史曲线;
- 语音:DFR0760 V2 语音合成模块负责开机提示、异常提示与报警播报,音量/语速/合成场景可在屏上设置。
固件最终资源占用:FLASH 12.46%(3MB)/ RAM 16.50%(768KB)。
3. 硬件介绍
硬件 | 接口/引脚 | 作用 |
|---|---|---|
MAX32690EVKIT(MAX32690) | — | 主控:120MHz Cortex-M4F + RV32 协核,3MB flash + 1MB SRAM(本项目 CM4 分配 768KB) |
SEN66 环境传感器 | I2C0 @0x6B(P2.7/P2.8) | 9 通道数据:PM1.0/2.5/4.0/10、温度、湿度、CO2、VOC、NOx |
DFR0760 V2 语音合成模块 | I2C2 @0x40(P1.7/P1.8,JP9/JP10 断开脱离 Codec) | 中文语音播报(报警/提示),音量语速场景可调 |
ST7735R 128x128 TFT(1.45" 彩屏) | SPI | LVGL UI 显示 |
CAN0 | P2.23/P2.22,500kbps | 向 Arduino Uno Q 发送传感器数据 |
M5 双按键 | P0.7 蓝 / P0.8 红(上拉低有效) | LVGL keypad:蓝=下一项/加,红=上一项/减 |
板载按键 sw0(P4.0) | gpio-keys | LVGL keypad:ENTER 确认 |
LED0 红(P0.14)/ LED1 绿(P2.12) | GPIO 低有效 | 报警状态灯:正常绿 1Hz、报警红 5Hz |
UART2 | P1.9/P1.10,921600 8N1 | Zephyr 日志后端(调试) |
开发板与外设模块间均为杜邦线连接,无自绘 PCB。
4. 方案框图 + 设计思路

方案框图解读:
- 系统核心:MAX32690EVKIT 主控板,运行 Zephyr RTOS v4.3.0 + LVGL 9.x;图中大方框内的采集线程、共享数据、报警引擎、TTS 服务、UI、CAN 通信均为板内并行线程/模块。
- 输入链路(2 路):
- 传感器输入:SEN66 环境传感器经 I2C0(地址 0x6B)接入采集线程
sen66_thread,按 1~60s 可配置节拍读取 9 通道数据(PM1.0/PM2.5/PM4.0/PM10、温度、湿度、CO2、VOC、NOx),每周期写入共享数据区sensor_data(互斥锁保护); - 按键输入:3 个物理按键(蓝键/红键/sw0)经 GPIO 映射为 LVGL keypad,向 UI 提供焦点移动与 ENTER 确认。
- 数据中枢:
sensor_data是全系统唯一数据源,显示、报警、CAN 三路消费者均从中读取最近一次有效采样,彼此解耦。 - 输出链路(3 路并行):
- 本地显示:UI(LVGL,6 个屏幕)以 10Hz 读取数据,经 SPI 驱动 ST7735R 128x128 TFT 彩屏,主屏以九宫格实时刷新 9 通道数据;
- 声光报警:报警引擎
alarm以 200ms 轮询共享数据(保证 1 秒内反应),超阈值时驱动板载 LED(报警红 5Hz / 正常绿 1Hz),同时将播报文本入队 TTS 服务线程,经 I2C2(地址 0x40)发往 DFR0760 V2 完成中文语音合成; - 对外通信:CAN 通信
can_comm将周期数据帧经 CAN0 总线(500kbps)发往 Arduino Uno Q;Uno Q 双核并行运行——功能 1 在 LED Matrix 上滚动展示环境数据,功能 2 通过网页显示实时传感器数据与历史曲线。
- 配置持久化:UI 中的采样周期、报警阈值、TTS(音量/语速/场景)设置统一经 Zephyr Settings 存入 NVS flash,断电保持、重启生效。
- 调试通道:UART2(921600 8N1)独立于业务链路,将 Zephyr 分级日志输出到 PC 调试终端。
设计思路:
- 单数据源:
sensor_data模块以互斥锁保护最近一次有效采样,采集/显示/报警/CAN 各线程全部读它,天然解耦; - 节拍式采样:采样周期(1~60s)修改后以 100ms 分片休眠,最迟 100ms 生效;历史记录按"每满 60 秒一条"统计,与周期解耦;
- 报警引擎独立线程:200ms 轮询共享数据保证 1 秒反应;每通道独立状态机(触发
>阈值、解除<阈值-5%滞后防抖);类型合并播报(单参数报数值、多参数合并"建议通风");重复限次用 3 槽环形时间戳实现 60s 滑动窗口最多 3 次、间隔 ≥20s; - 服务化外设:TTS 是"消息队列 + 专用线程"的播报服务,调用方非阻塞入队(满则丢弃),播放耗时互不影响;LED 状态灯由 100ms tick 驱动,
led_alarm_set()原子切换; - 配置全持久化:采样周期(
sampling_cfg)、6 项阈值(alarm_cfg,树thr/*)、TTS 设置(tts_cfg,树tts/*)统一走 Zephyr Settings over NVS,断电保持; - UI 统一框架:
ui_manager管理屏幕注册/切换/100ms 刷新分发,6 个屏幕(splash/main/settings×3 子页/info)各自实现 build/on_enter/refresh 三接口;物理 3 键映射为 LVGL keypad,焦点顺序与视觉一致(自上而下、从左到右)。
5. 原理图、PCB 设计
本项目全部基于官方评估板与成品模块搭建,模块间以杜邦线互连,无自绘原理图与 PCB。主控占用的硬件资源(引脚 / 总线 / 跳线 / 存储分区)罗列如下。
5.1 引脚与总线资源分配
资源 | 主控引脚 | 连接对象 | 配置要点 |
|---|---|---|---|
I2C0 | P2.7 = SDA,P2.8 = SCL | SEN66 环境传感器 | 7 位地址 0x6B,标准模式上拉 |
I2C2 | P1.7 = SDA,P1.8 = SCL | DFR0760 V2 语音合成模块 | 7 位地址 0x40;需断开 JP9 / JP10 使 P1.7/P1.8 脱离板载 Codec 并从 JH2 引出,总线上自加上/下拉电阻 |
CAN0 | P2.23 = TX,P2.22 = RX | Arduino Uno Q(经 CAN 收发器) | 波特率 500kbps;两引脚电源域均设 |
UART2 | P1.9 = RX,P1.10 = TX | PC 调试终端(USB 转串口) | 921600 8N1,Zephyr 日志后端 |
SPI(板载) | 评估板 LCD 排线座 | ST7735R 1.45" TFT 彩屏 | 128x128 可视 / 130x132 GRAM,板载直连无需外接 |
GPIO 输入 | P0.7 | M5 蓝键 | 内部上拉、低有效( |
GPIO 输入 | P0.8 | M5 红键 | 同上,gpio-keys → LVGL PREV/减 |
GPIO 输入 | P4.0(板载 SW2 = sw0) | 板载按键 | gpio-keys → LVGL ENTER 确认 |
GPIO 输出 | P0.14 | LED0 红 | 低有效,驱动强度 2,报警时 5Hz 闪烁 |
GPIO 输出 | P2.12 | LED1 绿 | 低有效,驱动强度 2,正常时 1Hz 闪烁 |
供电 | 3.3V / GND(JH 排针) | SEN66、DFR0760 V2、M5 按键 | 全部外设 3.3V 供电,与主控共地 |
5.2 跳线与硬件配置要点
- JP9 / JP10:必须拨到非 Codec 位置,否则 P1.7/P1.8 被板载音频编解码器占用,I2C2 上的 DFR0760 V2 无法通信;
- CAN 管脚电源域:P2.22/P2.23 在设备树中显式配置
power-source = <MAX32_VSEL_VDDIO>,与 CAN 收发器 IO 电平一致; - 外接按键:蓝/红双键为 M5 成品模块,信号脚接 P0.7/P0.8,依赖主控内部上拉(低电平有效),软件侧 gpio-keys 驱动自带 30ms 消抖;
- TFT 显示屏:为评估板板载器件(Crystalfontz CFAF128128B1-0145T,ST7735R 或兼容控制器),经板上排线连接,framebuffer 保持 130x132 覆盖整个 GRAM,UI 内容限定在 128x128 可视区内;
- 调试接口:板载 10-pin SWD 用于烧录与断点调试,UART2 单独承担运行日志输出。
5.3 片内存储资源划分
MAX32690 片内 3MB flash 与 CM4 可用 SRAM 的划分如下(设备树 overlay 配置):
资源 | 分区 / 范围 | 大小 | 用途 |
|---|---|---|---|
Flash | | 2944 KB | 固件代码分区(zephyr,code-partition) |
Flash | | 64 KB | 死机 coredump 转储 |
Flash | | 64 KB | Zephyr Settings / NVS:采样周期、阈值、TTS 设置持久化 |
SRAM | 0x20000000 – 0x200BFFFF(sram0~sram5) | 768 KB | CM4 链接可用 RAM(SDK 默认仅声明 128 KB,overlay 显式扩到 768 KB) |
6. 软件流程图 + 调试软件说明 + 关键代码说明
此项目由两个主板构成,MAX32690EVKIT + Arduino Uno Q。
1. 主要工作在 MAX32690EVKIT,传感器数据采集、警报判断、声光报警都在这个主控;
2. Arduino UNO Q 通过CAN接收 MAX32690EVKIT 发送的9项传感器数据,解析,在 Led Matrix 滚动展示,并建立网页,在网页端显示实时数据和历史曲线;【本文不展开讲解】
6.1 系统总览流程图

总览说明: 系统基于 Zephyr RTOS,采用多线程并行架构。上电后经 SYS_INIT 阶段完成驱动与子系统初始化,main 线程仅负责一次性 GUI 初始化,随后进入空循环。六大业务线程(UI/LVGL、sen66 采集、alarm 报警、tts 播报、led 状态灯、can_tx 通信)由 K_THREAD_DEFINE 在内核启动时自启动,通过共享数据层 sensor_data 与消息队列解耦。sensor_data 是全系统唯一数据源,sen66 线程写入,UI、alarm、CAN 三路消费者从中读取。
6.2 各线程详细流程图
6.2.1 main 线程

流程解说: main 线程仅承担一次性 GUI 初始化职责。Zephyr LVGL 模块在 APPLICATION SYS_INIT 阶段(先于 main)已完成 LVGL 与 keypad indev 初始化,因此 main 中直接调用 lvgl_gui_init() 构建 6 个屏幕并加载 splash、解除黑屏。完成后进入 5ms 空循环,不承载任何业务逻辑。UI 渲染与刷新完全由 LVGL workqueue(优先级 0)和 lv_timer 驱动。
线程主体代码(CM4/src/main.c):
int main(void)
{
/* LVGL (and its button-input indev) are initialized by the Zephyr LVGL
* module at the APPLICATION SYS_INIT level, which runs before main(),
* so it is safe to build the GUI here. */
lvgl_gui_init();
while (1) {
k_sleep(K_MSEC(5));
}
return 0;
}
6.2.2 LVGL 界面(9 个屏幕)

界面总览: UI 基于 LVGL 9.x,通过 ui_manager 统一管理 9 个屏幕的注册、切换与刷新。每个屏幕实现 struct ui_screen_api { build / on_enter / refresh } 三接口,100ms 定时器统一分发刷新事件。物理 3 键(蓝=NEXT、红=PREV、sw0=ENTER)映射为 LVGL keypad,焦点顺序自上而下、从左到右。
9 个屏幕一览:
序号 | 屏幕 ID | 作用 | 关键特性 |
|---|---|---|---|
① | SPLASH(欢迎页) | 开机画面,显示 LOGO 与项目名 | 3s 后自动进入 MAIN 并销毁自身,释放内存 |
② | MAIN(主屏) | 九宫格实时显示 9 通道传感器数据 | 3×3 布局:PM1.0/PM2.5/PM4.0/PM10/温度/湿度/CO2/VOC/NOx;10Hz 刷新;无效数据显示 |
③ | SETTINGS(设置菜单) | 设置入口页,三个子项 | 采样周期设置 / 报警阈值设置 / TTS 语音设置 |
④ | PERIOD(采样周期) | 调整采样周期 | 1~60s 连续可调;ENTER 保存 → NVS 持久化;Saved 提示 |
⑤ | THR_MENU(阈值菜单) | 6 个报警阈值入口 | PM / 温度 / 湿度 / CO2 / VOC / NOx,选择进入单项编辑 |
⑥ | THR_EDIT(阈值编辑) | 单项阈值数值编辑 | 加减键调整,ENTER 保存 → NVS 持久化,返回菜单 |
⑦ | TTS_MENU(TTS 菜单) | TTS 三项设置入口 | 音量 / 语速 / 合成场景 |
⑧ | TTS_EDIT(TTS 编辑) | TTS 单项参数编辑 | 保存 → NVS + 置 |
⑨ | INFO(系统信息) | 运行状态自检 | CPU 占用率、LVGL 堆内存、Flash 占用、外设清单 |
关键代码(CM4/src/ui/ui_manager.h — 屏幕 ID 定义):
typedef enum {
UI_SCREEN_SPLASH = 0, /* 欢迎页,3s 自动进入 MAIN 并销毁 */
UI_SCREEN_MAIN, /* 传感器数据主屏 */
UI_SCREEN_SETTINGS, /* 设置菜单 */
UI_SCREEN_PERIOD, /* 采样周期编辑 */
UI_SCREEN_THR_MENU, /* 阈值菜单 */
UI_SCREEN_THR_EDIT, /* 阈值编辑 */
UI_SCREEN_TTS_MENU, /* TTS 设置菜单(音量/语速/合成场景) */
UI_SCREEN_TTS_EDIT, /* TTS 单项编辑 */
UI_SCREEN_INFO, /* 系统信息页 */
UI_SCREEN_COUNT,
} ui_screen_id_t;
6.2.3 sen66 采集线程

流程解说: sen66 线程是系统数据源头。初始化阶段依次完成 I2C HAL 初始化、共享数据/采样配置/报警配置从 NVS 恢复、SEN66 复位与连续测量启动、CAN 控制器初始化并释放 can_ready_sem。主循环以 100ms 分片休眠实现 1~60s 可配置节拍,查询 data-ready 标志后读取 9 通道数据,经有效性检查(0xFFFF/0x7FFF 为预热无效)后写入共享数据区,同时非阻塞投递 CAN 发送队列。每满 60 秒追加一条历史记录(与采样周期解耦)。I2C 错误累计 ≥5 次才上报通信异常,避免瞬时抖动误报。
线程主体代码(CM4/src/sen66_thread.c — sen66_thread_entry 完整主循环):
void sen66_thread_entry(void *p1, void *p2, void *p3)
{
int16_t error = NO_ERROR;
uint8_t padding = 0;
bool data_ready = false;
uint16_t comm_fail_count = 0;
sensirion_i2c_hal_init();
sen66_init(SEN66_I2C_ADDR_6B);
/* sensor_data 模块必须最先初始化:初始化失败分支会提前 return,
* 而报警线程/UI 仍在调用 sensor_data_get()(依赖互斥锁) */
sensor_data_init();
/* 从 flash 加载上次保存的采样周期(首次启动用默认 1s)与报警阈值 */
sampling_cfg_init();
alarm_cfg_init();
error = sen66_device_reset();
if (error != NO_ERROR) {
LOG_ERR("sen66 device reset error: %i", error);
(void)tts_announce("传感器初始化失败,请检查设备。");
return;
}
/* Initialize the CAN bus for data transmission */
if (can_comm_init() < 0) {
LOG_ERR("CAN communication init failed, continuing without CAN");
}
sensirion_hal_sleep_us(1200000);
/* ... 读序列号 ... */
error = sen66_stop_measurement();
/* ... 失败处理 ... */
sensirion_hal_sleep_us(50000);
error = sen66_start_continuous_measurement();
/* ... 失败处理 ... */
sensirion_hal_sleep_us(1000000);
uint16_t pm1p0 = 0, pm2p5 = 0, pm4p0 = 0, pm10p0 = 0;
int16_t humidity = 0, temperature = 0, voc_index = 0, nox_index = 0;
uint16_t co2 = 0;
uint16_t row_count = 0;
int64_t last_history_tick = k_uptime_get();
while (1) {
int64_t cycle_start = k_uptime_get();
/* 1. 查询 data-ready */
data_ready = false;
error = sen66_get_data_ready(&padding, &data_ready);
if (error != NO_ERROR) {
if (++comm_fail_count >= 5) {
alarm_sensor_comm_ok(false); /* 连续5次 → 通信异常 */
}
k_msleep(100);
continue;
}
if (!data_ready) {
k_msleep(100);
continue;
}
/* 2. 读取 9 通道整数值 */
error = sen66_read_measured_values_as_integers(
&pm1p0, &pm2p5, &pm4p0, &pm10p0,
&humidity, &temperature, &voc_index, &nox_index, &co2);
if (error != NO_ERROR) {
if (++comm_fail_count >= 5) alarm_sensor_comm_ok(false);
continue;
}
/* 3. 有效性检查:0xFFFF/0x7FFF 为预热无效,不计通信失败 */
if ((pm1p0 >= INVALID_UINT16_THRESHOLD) || ...) {
k_msleep(1000);
continue;
}
/* 4. 采样有效:清零失败计数 + 更新共享数据 */
comm_fail_count = 0;
alarm_sensor_comm_ok(true);
m_sensor_data.pm1_0 = pm1p0;
m_sensor_data.pm2_5 = pm2p5;
/* ... 其余字段 ... */
m_sensor_data.valid = true;
sensor_data_update(&m_sensor_data);
/* 5. 非阻塞投递 CAN(3 帧:0x100 PM / 0x101 环境 / 0x102 CO2) */
can_comm_send_sen66_data(pm1p0, pm2p5, pm4p0, pm10p0,
humidity, temperature, voc_index, nox_index, co2);
/* 6. 每满 60 秒 → 历史记录(时间基准,与采样周期无关) */
if (k_uptime_get() - last_history_tick >= 60000) {
sensor_data_add_history(&m_sensor_data);
last_history_tick = k_uptime_get();
}
/* 7. 串口打印表格(每 10 行打表头) */
/* ... LOG_INF 打印 ... */
row_count++;
/* 8. 100ms 分片休眠补满配置周期,修改后最迟 100ms 生效 */
while (k_uptime_get() - cycle_start <
sampling_cfg_get_period_ms()) {
k_msleep(100);
}
}
}
补充关键代码 — 100ms 分片休眠逻辑:
/* 按配置的采样周期补满本拍剩余时间(100ms 分片休眠,
* 周期被修改后最迟 100ms 内生效)。 */
while (k_uptime_get() - cycle_start <
sampling_cfg_get_period_ms()) {
k_msleep(100);
}
6.2.4 alarm 报警线程

流程解说: alarm 线程以 200ms 固定节拍轮询共享数据,保证从异常数据出现到报警触发的链路延迟远小于 1 秒。每通道独立滞后状态机(触发 >阈值、解除 <阈值×95%)防止数值在阈值附近抖动反复触发。光报警通过 led_alarm_set() 原子切换 LED 状态,仅在边沿变化时调用。语音报警采用 60s 滑动窗口限 3 次、最小间隔 20s 的防骚扰策略,单参数报具体数值、多参数合并播报"建议通风"。传感器通信异常使用独立限次器,文案为"传感器通信异常,请检查设备。"
线程主体代码(CM4/src/alarm.c — alarm_thread_entry 完整轮询主循环):
static void alarm_thread_entry(void *p1, void *p2, void *p3)
{
struct limiter val_lim; /* 数值报警限次器 */
struct limiter comm_lim; /* 通信异常限次器 */
uint8_t last_set = 0; /* 上一轮报警类型集合 */
bool led_alarm = false; /* LED 状态缓存(边沿检测) */
bool comm_bad_announced = false;
ARG_UNUSED(p1);
ARG_UNUSED(p2);
ARG_UNUSED(p3);
LOG_INF("Alarm engine started (poll %u ms)", (unsigned int)ALARM_POLL_MS);
limiter_reset(&val_lim);
limiter_reset(&comm_lim);
while (1) {
int64_t now = k_uptime_get();
struct sensor_data data;
uint8_t cur = 0;
bool comm_bad = atomic_get(&s_comm_ok) == 0;
/* ① 逐通道滞后状态机(有有效采样时) */
if (sensor_data_get(&data) == 0) {
for (int ch = 0; ch < CH_COUNT; ch++) {
int32_t v = ch_value(ch, &data);
int32_t thr =
(int32_t)alarm_cfg_get(ch_cfg[ch].thr_id) *
ch_cfg[ch].scale;
int32_t clear = thr * CLEAR_MARGIN_NUM /
CLEAR_MARGIN_DEN;
if (!ch_alarming[ch]) {
if (v > thr) ch_alarming[ch] = true; /* 触发 */
} else if (v < clear) {
ch_alarming[ch] = false; /* 解除 */
}
if (ch_alarming[ch]) {
cur |= BIT(ch_type[ch]); /* 聚合为语音类型 */
}
}
}
/* ② 光报警:数值报警 或 通信异常 → 红灯;仅边沿变化时切换 */
bool want_alarm = (cur != 0U) || comm_bad;
if (want_alarm != led_alarm) {
led_alarm_set(want_alarm);
led_alarm = want_alarm;
}
/* ③ 语音报警(数值):新集合立即播报,同集合 20s 重复,60s 限 3 次 */
if (cur != 0U) {
if (cur != last_set) {
limiter_reset(&val_lim);
if (limiter_allow(&val_lim, now)) announce_types(cur);
last_set = cur;
} else if (now - val_lim.last >= REPEAT_INTERVAL_MS) {
if (limiter_allow(&val_lim, now)) announce_types(cur);
}
} else {
last_set = 0;
limiter_reset(&val_lim);
}
/* ④ 通信异常语音:独立限次器,同一策略 */
if (comm_bad) {
if ((!comm_bad_announced ||
now - comm_lim.last >= REPEAT_INTERVAL_MS) &&
limiter_allow(&comm_lim, now)) {
(void)tts_announce("传感器通信异常,请检查设备。");
}
comm_bad_announced = true;
} else {
comm_bad_announced = false;
limiter_reset(&comm_lim);
}
k_msleep(ALARM_POLL_MS); /* 200ms */
}
}
补充关键代码 — 60s 滑动窗口限次器:
struct limiter {
int64_t ticks[REPEAT_MAX_PER_WINDOW]; /* 最近 3 次触发时刻 */
uint8_t idx;
int64_t last;
};
static bool limiter_allow(struct limiter *l, int64_t now)
{
int recent = 0;
for (size_t i = 0; i < ARRAY_SIZE(l->ticks); i++) {
if (l->ticks[i] != 0 && now - l->ticks[i] < REPEAT_WINDOW_MS) {
recent++;
}
}
if (recent >= REPEAT_MAX_PER_WINDOW) {
return false; /* 60s 窗口已满 3 次,拒绝 */
}
limiter_push(l, now); /* 记录本次时刻 */
return true; /* 允许播报 */
}
6.2.5 tts 播报线程

流程解说: TTS 被实现为"消息队列 + 专用线程"的播报服务,调用方(alarm、sen66)只需一次非阻塞入队 tts_announce(),语音播放(秒级耗时)完全在独立线程完成,不影响采样节拍与 1 秒报警响应。初始化阶段从 NVS 加载配置、通过真实读写探测 DFR0760 V2 模块(严禁 0 字节扫描,避免 MAX32 I2C 驱动挂死),发送音量/语速/场景标记与读法策略后入队开机播报。服务循环以 250ms 超时等待消息,超时时检查 cfg_dirty 脏标志——UI 保存 TTS 设置后置位,TTS 线程在空闲时重发配置标记,避免挤占报警播报窗口。队列深度 3,满则丢弃并告警。
线程主体代码(CM4/src/tts.c — tts_entry 完整服务循环):
void tts_entry(void *p1, void *p2, void *p3)
{
const struct device *i2c_dev = DEVICE_DT_GET(I2C2_NODE);
struct tts speech;
int ret;
ARG_UNUSED(p1);
ARG_UNUSED(p2);
ARG_UNUSED(p3);
LOG_INF("TTS service thread started");
/* ① 加载 NVS 持久化的音量/语速/场景配置 */
tts_cfg_init();
if (!device_is_ready(i2c_dev)) {
LOG_ERR("I2C2 is not ready");
return;
}
/* ② 通过真实读写探测 DFR0760(严禁 0 字节扫描,MAX32 I2C 驱动会挂死) */
LOG_INF("Initializing TTS device at address 0x%02x",
(unsigned int)TTS_DEFAULT_I2C_ADDRESS);
ret = tts_init(&speech, i2c_dev, TTS_DEFAULT_I2C_ADDRESS);
if (ret != 0) {
LOG_ERR("TTS initialization failed: %d", ret);
return;
}
/* ③ 应用配置 + 固定读法策略(标点/语言/单词/数字) */
apply_cfg(&speech);
apply_policies(&speech);
/* ④ 入队开机播报 */
(void)tts_announce("系统初始化完毕,开启环境检测。");
while (1) {
struct tts_msg msg;
/* 250ms 超时等待:空闲时可处理 cfg_dirty,不挤占报警播报 */
ret = k_msgq_get(&tts_msgq, &msg, K_MSEC(250));
/* 脏标志检查:UI 保存 TTS 设置后置位,空闲时重发配置 */
if (atomic_cas(&s_cfg_dirty, 1, 0)) {
apply_cfg(&speech);
}
if (ret != 0) {
continue; /* 超时,继续等待 */
}
/* 取到文本 → 经 I2C2 发往 DFR0760 V2(秒级耗时,独立线程完成) */
LOG_INF("Speaking: %s", msg.text);
ret = tts_speak(&speech, msg.text);
if (ret != 0) {
LOG_ERR("TTS speech operation failed: %d", ret);
}
}
}
补充关键代码 — 非阻塞播报接口(调用方使用):
int tts_announce(const char *text)
{
struct tts_msg msg;
size_t len;
int ret;
if (text == NULL) return -EINVAL;
len = strlen(text);
if (len >= TTS_ANNOUNCE_MAX) {
LOG_WRN("announcement too long (%zu bytes), dropped", len);
return -EINVAL;
}
memcpy(msg.text, text, len + 1U);
ret = k_msgq_put(&tts_msgq, &msg, K_NO_WAIT);
if (ret != 0) {
LOG_WRN("TTS queue full, dropped: %s", text);
return -ENOMEM;
}
return 0;
}
6.2.6 led 线程

流程解说: led_task 是系统中最简单的线程,只做一件事——以 100ms 固定节拍读取原子标志 s_alarm,根据报警状态输出不同灯效。报警态下绿灯熄灭、红灯按 tick 奇偶翻转实现 5Hz 闪烁;正常态下红灯熄灭、绿灯按 tick/5 奇偶翻转实现 1Hz 闪烁。LED 状态的切换完全由 alarm 线程通过 atomic_t 单向置位/清除,无锁、无阻塞、无竞争。优先级设为 7(低于 LVGL),100ms 级别的调度抖动对闪烁效果不可见。
线程主体代码(CM4/src/led.c — led_task_entry 完整 tick 循环):
static atomic_t s_alarm; /* alarm 线程置位/清除 */
static void led_task_entry(void *p1, void *p2, void *p3)
{
uint32_t tick;
if (led_hw_init() < 0) {
LOG_ERR("Status LED service disabled");
return;
}
tick = 0;
while (1) {
if (atomic_get(&s_alarm) != 0) {
/* 报警态:绿灯灭,红灯 5Hz(每 tick 翻转) */
gpio_pin_set_dt(&leds[LED_GREEN], 0);
gpio_pin_set_dt(&leds[LED_RED], (tick & 1U) ? 1 : 0);
} else {
/* 正常态:红灯灭,绿灯 1Hz(每 5 tick 翻转) */
gpio_pin_set_dt(&leds[LED_RED], 0);
gpio_pin_set_dt(&leds[LED_GREEN],
((tick / GREEN_TOGGLE_TICKS) & 1U) ? 1 : 0);
}
tick++;
k_msleep(LED_TICK_MS); /* 100ms */
}
}
补充关键代码 — 报警状态设置接口(alarm 线程调用):
void led_alarm_set(bool active)
{
atomic_set(&s_alarm, active ? 1 : 0); /* 原子操作,无锁无阻塞 */
}
6.2.7 can_tx 通信线程

流程解说: CAN 通信采用"生产者非阻塞投递 + 专用 TX 线程发送"的两段式结构。生产者(sen66 线程)将 3 帧采样数据(0x100 PM 数据 / 0x101 温湿度 VOC NOx / 0x102 CO2)大端打包后以 K_NO_WAIT 投递 can_tx_msgq(深度 4,满则清空重试一次),立即返回绝不阻塞采集。消费者(can_tx 线程)自启动后先等待 can_ready_sem(sen66 线程内 can_comm_init() 完成后释放),防止控制器未就绪时访问驱动触发 MPU fault。主循环带心跳超时取消息,超时则发送心跳帧(含 uptime、序号、控制器状态)供上位机验活;取到数据帧后先检查总线状态,必要时执行 bus-off 恢复(can_stop → can_start),然后逐帧 can_send(K_NO_WAIT) 发送到 CAN0 总线(500kbps)。总线错误、无 ACK、bus-off 恢复都被隔离在 TX 线程内,采集与报警链路完全不受影响。
线程主体代码(CM4/src/can_comm.c — can_tx_thread 消费者主循环):
static void can_tx_thread(void *arg1, void *arg2, void *arg3)
{
struct can_tx_cycle cycle;
int ret;
/* K_THREAD_DEFINE 自启动可能早于 can_comm_init(),
* 先等信号量防止 can_dev=NULL 触发 MPU fault */
if (k_sem_take(&can_ready_sem, K_SECONDS(5)) != 0) {
LOG_ERR("CAN TX thread: can_comm_init() not called within 5 s "
"-- check main(). TX thread parked, no CAN traffic.");
while (1) k_msleep(1000); /* 永久挂起 */
}
LOG_INF("CAN TX thread started");
while (1) {
/* 带心跳超时取消息:无数据时用心跳帧保活 */
#if (CAN_HEARTBEAT_PERIOD_MS > 0)
ret = k_msgq_get(&can_tx_msgq, &cycle, K_MSEC(CAN_HEARTBEAT_PERIOD_MS));
if (ret == -EAGAIN) {
can_tx_attempts++;
if (can_tx_bus_ready()) can_tx_send_heartbeat();
continue;
}
#else
ret = k_msgq_get(&can_tx_msgq, &cycle, K_FOREVER);
#endif
if (ret < 0) continue;
can_tx_attempts++;
/* 总线状态检查 + bus-off 恢复,失败则丢弃本轮 */
if (!can_tx_bus_ready()) continue;
/* 逐帧发送 cycle 中的所有 CAN 帧 */
for (uint8_t i = 0; i < cycle.frame_count; i++) {
can_tx_one_frame(&cycle.frames[i]);
}
}
}
补充关键代码 — 生产者非阻塞投递(sen66 线程调用):
int can_comm_send_sen66_data(uint16_t pm1p0, uint16_t pm2p5, uint16_t pm4p0,
uint16_t pm10p0, int16_t humidity,
int16_t temperature, int16_t voc_index,
int16_t nox_index, uint16_t co2)
{
struct can_tx_cycle cycle = { 0 }; /* 零初始化,未填字节保证为 0 */
int ret;
if (can_dev == NULL) return -ENODEV;
cycle.frame_count = CAN_TX_FRAMES_PER_CYCLE;
/* Frame 1: PM values (CAN ID 0x100, DLC=8) */
cycle.frames[0].id = CAN_ID_SEN66_PM;
cycle.frames[0].dlc = 8;
pack_u16_be(&cycle.frames[0].data[0], pm1p0);
pack_u16_be(&cycle.frames[0].data[2], pm2p5);
pack_u16_be(&cycle.frames[0].data[4], pm4p0);
pack_u16_be(&cycle.frames[0].data[6], pm10p0);
/* Frame 2: Environmental values (CAN ID 0x101, DLC=8) */
cycle.frames[1].id = CAN_ID_SEN66_ENV;
cycle.frames[1].dlc = 8;
pack_i16_be(&cycle.frames[1].data[0], humidity);
pack_i16_be(&cycle.frames[1].data[2], temperature);
pack_i16_be(&cycle.frames[1].data[4], voc_index);
pack_i16_be(&cycle.frames[1].data[6], nox_index);
/* Frame 3: CO2 value (CAN ID 0x102, DLC=8) */
cycle.frames[2].id = CAN_ID_SEN66_CO2;
cycle.frames[2].dlc = 8;
pack_u16_be(&cycle.frames[2].data[0], co2);
/* 非阻塞投递到 CAN TX 线程;满则清空队列重试一次 */
ret = k_msgq_put(&can_tx_msgq, &cycle, K_NO_WAIT);
if (ret < 0) {
k_msgq_purge(&can_tx_msgq);
ret = k_msgq_put(&can_tx_msgq, &cycle, K_NO_WAIT);
}
return ret;
}
6.3 调试软件说明
- ADI CodeFusion Studio™ 2.2.1:项目管理、图形化外设/内存配置(生成设备树 overlay)、基于 CMake+Ninja 的构建、烧录与调试;
- 串口日志:UART2 @921600,Zephyr logging 后端,所有模块(tts/alarm/can…)分级日志(INF/WRN/ERR)用于问题定位;
- SWD 调试:板载 10-pin SWD,配合 arm-zephyr-eabi-gdb / OpenOCD 断点调试;
- Info 屏自检:固件内置 CPU 占用率、LVGL 堆内存、Flash 占用实时显示与外设清单,无需外接工具即可确认系统状态。
6.4 通用设计模式总结
设计模式 | 应用场景 | 核心机制 |
|---|---|---|
单数据源 | sensor_data 共享数据层 | 互斥锁保护最近一次有效采样,多消费者只读 |
消息队列 + 服务线程 | TTS 播报、CAN 发送 | 生产者非阻塞入队,消费者专用线程处理耗时操作 |
原子标志单向通信 | alarm → LED 状态切换 | |
脏标志握手 | UI → TTS 配置更新 | |
滞后状态机 | 报警触发/解除 | 5% 滞环,防止阈值附近抖动 |
滑动窗口限次 | 重复语音报警防骚扰 | 3 槽环形时间戳 + 60s 窗口 + 20s 最小间隔 |
分片休眠 | 采样周期可配置 | 100ms 切片循环,修改后最迟 100ms 生效 |
两段式通信 | CAN 总线发送 | 生产者投递 + 专用 TX 线程,错误隔离 |
7. 实物演示及说明
CodeFusion Studio™ 软件截图:
- CodeFusion SDK 安装到 C 盘,路径是
C:\analog\cfs\2.2.1 - VS Code 中 CodeFusion Studio 插件版本 v2.2.1 注意版本必须匹配,否则插件无法正确工作。
7.1 创建工程
第一步选择 SoC ,这里以 MAX32690 为例。


第二步选择开发板和封装,这里以 MAX32690EVKIT 为例。

第三步配置工程选项。这里选择手工配置工程。

第四步选择工程 core 配置,可以选择 Cortex-M4F 或者 RISC-V,或者两个都选择。

最后一步选择工程路径,这里就不演示了。
7.3 演示 led 配置
板载两个LED,绿色LED闪烁表示系统正常,红色LED闪烁表示系统检测到异常。管脚配置如下:
- P2.2 -- led1_green
- P0.14 -- led0_red
在 Peripheral 界面中找到 GPIO0,给 P0.14 分配都 CM4 内核上,然后使能管脚。

点击 P0.14 右侧配置选项,跳转到 Pin Config 界面,可以配置GPIO对应的功能,设备树节点信息,如下图所示。

7.4 演示 button 配置
除了板载的 sw0,另外添加了一个 M5 Dual button 模块,这三个按键管脚配置如下:
- P4.0 -- sw0
- P0.8 -- btn_red
- P0.7 -- btn_blue
同理,配置 P0.8 作为 btn_red,管脚复用配置如下:

点击 Configuration 之后,继续这是 P0.8 PIN CONFIG,如下所示:

7.5 演示 I2C0 配置
SEN66 通过 I2C0 连接,对应管脚配置如下:
- P2.7 -- SDA
- P2.8 -- SCL
注意,I2C 配置和GPIO不同,除了外设使能、PIN Config 之外,还需要使能时钟。
使能I2C0,分配到 Cortex-M4F 内核,选择管脚,配置速率为100KHz

注意 I2C0 两个管脚电源配置为 VDDIOH

I2C0 时钟使能,首先点开Clock Config 界面,展开 Peripheral 找到 I2C0,点进去然后使能 I2C0。


7.6 其他外设
DFR0760 TTS 语音合成模块,通过这个 I2C 与主控通信,对应的管脚配置如下:
- P1.7 -- SDA
- P1.8 -- SCL
配置步骤同上,这里就不一一截图演示了。
特别注意:外设、时钟、管脚配置完毕,要点击最下方的生成代码,确保生成新的 .overlay 文件和 .conf 文件。这样 ADI CodeFusion Studio 的 Zephyr 编译环境就能加载新的设备树。
开发板实物图:

演示流程与预期现象:
步骤 | 操作 | 预期现象 |
|---|---|---|
1 | 上电 | TTS 播报"系统初始化完毕,开启环境检测。";LED 绿灯 1Hz 闪烁 |
2 | 开机 3s 后自动进入主屏 | 九宫格实时显示 9 通道数据(预热期无效字段显示 |
3 | sw0/PREV/NEXT 切换至 Settings | 可编辑采样周期(1~60s,保存显示 Saved)、6 项阈值、TTS 设置(音量/语速/场景),断电重启保持 |
4 | 将某项阈值改为低于当前值 | 1 秒内:绿灯灭、红灯 5Hz、语音播报该参数超标;同一报警 1 分钟内最多重复 3 次 |
5 | 将阈值改回 | 数值回落至阈值-5% 后解除报警,绿灯恢复 1Hz |
6 | 切换到 Info 页 | 显示 CPU/LVGL 内存/Flash 占用与外设清单 |
7 | Uno Q 侧接收 | CAN 总线 500kbps 周期收到 SEN66 数据帧 |
7.7 演示画面
画面1 -- 欢迎界面
开机后 LCD 显示如下欢迎界面,语音播报系统初始化完成。
EETREE & ADI
CodeFusion Studio
Env Monitor V0.1
Batman9527
2026/9/19

画面2-- Env Monitor
欢迎界面显示3秒钟之后自动跳转到第二个界面--Env Monitor。 此界面9宫格的形式展示SEN66 采集的9项传感器数据。
每个传感器包含名字与数值。

画面3--设置主界面
导航到设置主界面,有如下3个设置选项
- Period 1S 【设置SEN66采样周期,当前设置采样周期1秒钟,可调范围 1~60 秒钟】
- Thresholds 【多个传感器设置阈值】
- TTS 【语音合成器参数设置】

画面4--设置阈值
此页面可以设置 SEN66 传感器阈值,如果实际数值超过阈值则产生警报。

画面5--Info
显示系统信息,包括 CPU 占用率, LVGL 内存利用率,Flash 占用率,以及外设占用的硬件资源。

画面6 -- 3个按键在LVGL导航
此系统把三个按键接入 Zephyr 系统,并映射到 LVGL UI系统中,其中:
- 板载按键 PB0 作为 Enter 键
- 外接的 红色按键作为 Left/Prev 导航键
- 外界的 蓝色按键作为 Right/Next 导航键

画面7--设置温度阈值
- 界面上左边“-”号,按一下板载的 PB0 即点击一次,数值减1;
- 界面上右边"+"号,按一下板载的 PB0 即点击一次,数值加1;
- 最后导航到中间的数值,按一下板载的 PB0 表示确认修改;

画面8--设置Period周期
此界面设置SEN66传感器采样间隔时间。可选值范围 1~60 秒钟。

画面9--TTS语音合成器设置
此界面设置语音合成器的三种参数:
- Volume 设置音量,可选值范围 1~10
- Speed 设置语速,可选值范围 1~10
- Scene 设置音效,可选值范围 0~5

画面10--Arduino Uno Q Led Matrix 显示
- Led Matrix 滚动展示传感器数据;
- 亮度有三个挡位,可以在 web ui 设置
- 速度有三个挡位,可以在 web ui 设置

画面11--Uno Q 建立的 WebUI
Arduino Uno Q 建立的 web ui,有两个主要界面:
- 实时数据,以九宫格的方式展示9项传感器数据
- 历史曲线,主窗口一个曲线,点击上方的传感器选择按钮,可以选择一个传感器类型
界面最下方有 Led Matrix 设置按钮,可以设置亮度等级、速度等级。

画面12--Uno Q Led Matrix 亮度最高档位
下图是 led matrix 以最高亮度显示。

画面13--Uno Q WebUI 实时数据展示
这是 web ui 的实时数据界面,展示9项传感器实时数据。
九宫格形式,每一个包含图标、名字、数值和单位。

8. 遇到的难点及解决方法
- Zephyr 下 BLE 不可用(任务 4):MAX32690 芯片带 BLE 5.2 radio,但 Zephyr 软件栈目前没有 MAX32 的 BLE controller/HCI 驱动,设备树也不支持(CodeFusion Studio 生成 overlay 时明确提示)。解决:对外数据通路改用任务 5 的 CAN 接口(500kbps 发往 Uno Q);BLE 待 Zephyr 上游支持后可补。
- SRAM 128KB → 768KB 之谜:CodeFusion Studio 图形界面给 CM4 分配了 768KB,但 Zephyr 链接器仍按 128KB 报 RAM 98.96%——CFS 的内存分配不会传导到 Zephyr 设备树(SDK 板级 dts 固定 128KB)。解决:在 overlay 中显式声明 SRAM 大小;且改完 overlay 后
build/zephyr/.config可能不重新生成,需手动删除强制重配置。最终 RAM 占用降至 16.50%。 - MAX32 I2C 驱动 0 长度写永久挂起:旧测试代码用
i2c_write(NULL, 0, addr)做总线扫描探测 TTS 模块,但 MAX32 驱动对"无 RESTART 的 0 长度写"不把地址字节送入 FIFO,内核信号量死等,TTS 线程卡死、开机无声。解决:删除所有总线扫描,芯片存在性由tts_init()的真实读写 + 重试检测;并把该缺陷记入项目文档(本项目 I2C 探测禁用 0 字节写)。 - 开机误报"传感器通信异常":SEN66 预热期返回无效测量(0xFFFF 字段),初版把无效帧计入连续失败,每次开机都误播报一次。解决:区分"I2C 读写错误"与"数据无效"——只有真实 I/O 错误计入连续失败(≥5 次报警),预热期无效帧不计数。
- TFT 屏可视区外的"青色垃圾线":ST7735 控制器显存是 130x132,可视区仅 128x128;把 framebuffer 缩到 128x128 后边缘露出未刷新的 GRAM 垃圾。解决:保持 130x132 framebuffer 覆盖全 GRAM,UI 内容严格布置在 0..127 内。
- CodeFusion Studio 重新生成 overlay 抹掉手动修改:图形化配置保存会整体重写 overlay 文件,手工添加的存储分区/LVGL 按键映射等全部丢失,构建报错。解决:在 overlay 文件头维护"手动编辑清单"注释,每次重新生成后逐项核对恢复。
- RAM 资源紧张:任务 6 初版链接时 RAM 溢出 2600 字节。解决:压缩 TTS 线程栈(4096→2560)、播报队列(4×192B→3×128B)、系统堆(8192→6144),在 768KB 口径下占用仅 16.50%。
9. 心得体会
- 图形化配置不等于全部:CodeFusion Studio 的图形化外设/内存配置确实高效,但生成产物(设备树 overlay)与构建系统之间的"传导断层"(SRAM 分配不进 devicetree、重新生成覆盖手动编辑)必须吃透。把"手动编辑清单"写进文件头注释并形成核对习惯,是从"能跑"走向"可维护"的关键。
- 服务化 + 队列是嵌入式播报的正解:把 TTS 从"测试程序"重构为"消息队列 + 专用线程"的播报服务后,报警链路完全不受语音播放耗时影响;脏标志握手让设置修改即时生效又不污染关键路径。
- 防抖与限次是产品化的必修课:5% 滞后、60s 窗口限 3 次、类型合并播报,这些"论文里不写"的细节直接决定报警功能是"可用"还是"烦人"。
- 对活动的建议:① 希望官方提供更多 Zephyr 生态的板级外设支持(如 MAX32690 的 BLE 上游驱动),任务 4 的遗憾本质是生态成熟度;② 建议任务书提前标注各任务的软件栈依赖关系(例如 BLE 依赖厂商驱动是否就绪),便于选手取舍与规划;③ 若能提供 Uno Q 与 EVKit 的官方联动例程,任务 5 的对接会更顺畅。