一、项目介绍和创意介绍
1.1 项目背景
2026贸泽电子M-Design创意设计大赛(第二季),我实现的是任务一智能家居,使用 FRDM_RW612 开发板、SEN66多合一传感器以及OLED显示屏制作了多合一远程环境监测项目。
室内环境质量(PM2.5、CO₂、温湿度等)直接影响人体健康,目前市面多数方案只测一两个参数,数据显示也只停留在设备本地,缺少远程查看和历史追溯的能力。本项目的目标是做一套低成本、高集成度的环境监测系统,支持本地 OLED 和手机小程序双向展示。
1.2 核心创意
一颗芯片测9种参数。 选 SEN66 这颗多合一传感器,同时输出 PM1.0/2.5/4.0/10、温度、湿度、CO₂、VOC、NOx,省掉多个分立传感器带来的布线复杂度和成本。
本地显示 + 远程查看。 本地用 OLED 翻页显示9种数据,远程通过 BLE 5.0 推送到微信小程序,小程序里还能看历史曲线。
上位机用微信小程序。 不要装 App,微信扫码直接用。Android / iOS 都支持,微信本身就带 BLE API,存数据用 wx.setStorageSync 就行。
1.3 系统组成
部分 | 方案 | 职责 |
|---|---|---|
下位机 | FRDM-RW612 + Zephyr RTOS | 采集传感器、驱动 OLED、走 BLE |
传感器 | SEN66 | 9种环境参数,I2C 接口 |
显示屏 | SSD1306 OLED 128×64 | 本地展示数据 |
通信 | BLE 5.0 GATT Notify | 下位机 → 手机 |
上位机 | 微信小程序 | 实时显示 + 历史曲线 |
二、硬件介绍
2.1 FRDM-RW612 开发板
NXP 官方的低成本评估板,主控是 RW612 双核(Cortex-M33 @ 260MHz + DSP),板载 2MB Flash、768KB SRAM,集成 2.4GHz BLE 5.0 射频,自带 J-Link 调试器。
2.2 SEN66 多合一传感器
Sensirion 出品,一颗芯片集成四种传感技术:
- 激光散射 → PM1.0 / PM2.5 / PM4.0 / PM10
- 电容式 → 温湿度
- NDIR 非色散红外 → CO₂
- MOx 金属氧化物 → VOC / NOx 指数
I²C 接口,地址 0x6b。
2.3 SSD1306 OLED 显示屏
0.96 寸,128×64 像素,驱动芯片 SSD1306,I²C 接口,地址 0x3c。
2.4 硬件连接
FRDM-RW612 的 flexcomm2 配成 I²C 主模式(100kHz),挂两个从设备:

图2-1: 硬件连接示意图
两路外设的 SCL 和 SDA 引脚并联,靠地址区分。按钮 SW0 和 RGB LED 走 GPIO,跟 I²C 没关系。
三、方案框图和项目设计思路
3.1 系统架构
分四层:传感器采集 → 主控处理 → BLE 通信 → 应用展示。

3.2 数据流
本地路径:SEN66 → I²C → sen66_thread 采集 → sensor_data 共享内存 → user_ui_thread → OLED
远程路径:SEN66 → I²C → sen66_thread → sensor_data → ble_thread → BLE Notify → 微信小程序
两个路径共用同一份数据源,通过 sensor_data 模块的 k_mutex 保证线程安全。
3.3 多线程架构
线程 | 优先级 | 栈大小 | 干什么 |
|---|---|---|---|
sen66_thread | 7 | 4096 | 每秒读一次 SEN66 |
ble_thread | 6 | 4096 | BLE 广播、连手机、每5秒推数据 |
user_ui_thread | 8 | 2048 | OLED 显示,2秒刷新一次 |
hmi_thread | 8 | 1024 | 按钮中断 + LED 控制 |
线程间不走消息队列,直接用 k_mutex 保护共享内存,避免拷贝开销。
3.4 BLE 数据包协议
22 字节定长包,小端序,一次 Notify 发完:
偏移 | 字节数 | 字段 | 类型 | 物理值换算 |
|---|---|---|---|---|
0 | 2 | PM1.0 | uint16 | ÷10 = μg/m³ |
2 | 2 | PM2.5 | uint16 | ÷10 |
4 | 2 | PM4.0 | uint16 | ÷10 |
6 | 2 | PM10 | uint16 | ÷10 |
8 | 2 | 湿度 | int16 | ÷100 = %RH |
10 | 2 | 温度 | int16 | ÷200 = °C |
12 | 2 | VOC | int16 | ÷10 |
14 | 2 | NOx | int16 | ÷10 |
16 | 2 | CO₂ | uint16 | ppm |
18 | 4 | 时间戳 | uint32 | 系统启动秒数 |
用整型传原始值,接收端再除,避免传浮点数。
3.5 历史存储和 OLED 优化
- 下位机环形缓冲区 1440 条(约24小时,每分钟记1条)
- 上位机 wx.setStorageSync 存 10000 条
- OLED 帧缓冲 + 变化检测:新旧 buffer 对比,只刷变化页,I²C 通信省 75%+
四、原理图介绍
没有自制 PCB,跑在 FRDM-RW612 官方开发板上。原理图层面就只有 flexcomm2 的 I²C 接口接两个外设。


flexcomm2 配成 I²C 主模式,频率 100kHz。SEN66 和 OLED 的 SCL/SDA 并一起,从机地址分别是 0x6b 和 0x3c。板上自带 I²C 上拉电阻。
五、软件流程图和关键代码介绍
5.1 整体软件架构
下位机跑 Zephyr RTOS v4.3.0。main() 只打了个编译时间就进空循环睡大觉,真正干活的是四个独立线程:
// main.c — 就这12行
int main(void)
{
printk("Zephyr Application Build Time: %s %s\r\n", __DATE__, __TIME__);
while (1) {
k_msleep(1000);
}
return 0;
}
四个线程通过 K_THREAD_DEFINE 宏定义,编译时自动创建。
线程调度流程

图5-1: 四大线程的软件流程图
5.2 SEN66 采集线程
线程入口在 sen66_thread.c,做了这几件事:
- 初始化 I²C HAL
- 初始化 SEN66 驱动
- 复位芯片(等 1.2s)
- 读序列号(打印到日志)
- 启动连续测量模式
- 每秒循环:读9种数据 → 写共享内存 → 每60次写一条历史
以下是完整的主循环代码(直接从源文件复制):
while (1) {
/* 6. 读取测量值 */
error = sen66_read_measured_values_as_integers(&pm1p0, &pm2p5, &pm4p0, &pm10p0, &humidity, &temperature,
&voc_index, &nox_index, &co2);
if (error != NO_ERROR) {
LOG_WRN("Faild to read measurements: %i", error);
continue;
}
/* 更新共享数据 */
m_sensor_data.pm1_0 = pm1p0;
m_sensor_data.pm2_5 = pm2p5;
m_sensor_data.pm4_0 = pm4p0;
m_sensor_data.pm10 = pm10p0;
m_sensor_data.humidity = humidity;
m_sensor_data.temperature = temperature;
m_sensor_data.voc_index = voc_index;
m_sensor_data.nox_index = nox_index;
m_sensor_data.co2 = co2;
m_sensor_data.timestamp = k_uptime_get() / 1000;
m_sensor_data.valid = true;
sensor_data_update(&m_sensor_data);
/* 每分钟添加一次历史记录 */
history_counter++;
if (history_counter >= 60) {
sensor_data_add_history(&m_sensor_data);
history_counter = 0;
}
row_count++;
k_msleep(1000);
}
说明:sen66_read_measured_values_as_integers 是 Sensirion 官方驱动库的接口,一次 I²C 读操作取回9个值。取回来先填到栈上的 m_sensor_data,再调 sensor_data_update 写进全局共享内存(内部有 k_mutex 保护)。历史记录是每60次采(约1分钟)记一条,环形缓冲区 1440 条。
5.3 传感器数据共享模块
这是线程间通信的核心,代码在 sensor_data.c。
写端(SEN66 线程调):
void sensor_data_update(const struct sensor_data *data)
{
k_mutex_lock(&data_mutex, K_FOREVER);
memcpy(¤t_data, data, sizeof(*data));
current_data.valid = true;
k_mutex_unlock(&data_mutex);
}
读端(BLE 线程和 UI 线程调):
int sensor_data_get(struct sensor_data *data)
{
if (!data) {
return -EINVAL;
}
k_mutex_lock(&data_mutex, K_FOREVER);
if (!current_data.valid) {
k_mutex_unlock(&data_mutex);
return -ENODATA;
}
memcpy(data, ¤t_data, sizeof(*data));
k_mutex_unlock(&data_mutex);
return 0;
}
说明:这两个接口都很短。写的时候 lock→memcpy→unlock,读的时候 lock→copy out→unlock。valid 标志用来区分"还没采过数据"和"数据已更新",SEN66 线程第一次写时才设为 true,读端发现 false 就直接返回 -ENODATA。
历史记录的环形缓冲区逻辑:
int sensor_data_add_history(const struct sensor_data *data)
{
k_mutex_lock(&history_mutex, K_FOREVER);
struct history_record *record = &history_buffer[history_write_index];
record->timestamp = data->timestamp;
record->temperature = data->temperature;
/* ... 其他字段赋值 ... */
history_write_index = (history_write_index + 1) % MAX_HISTORY_RECORDS;
if (history_count < MAX_HISTORY_RECORDS) {
history_count++;
}
k_mutex_unlock(&history_mutex);
return 0;
}
history_write_index 从0走到1439再绕回0,满了就覆盖老数据。
5.4 BLE GATT 服务和通信线程
GATT 服务用自定义 128-bit UUID,Service 是 12345678-1234-5678-1234-56789abcdef0,Characteristic 是 ...f1,支持 Read 和 Notify。
服务定义(ble_ehs_service.c 第57-72行):
static struct bt_gatt_attr ehs_attrs[] = {
/* [0] 主服务 */
BT_GATT_PRIMARY_SERVICE(&ehs_service_uuid),
/* [1] 特征声明 */
BT_GATT_CHARACTERISTIC(&sensor_data_uuid.uuid,
BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY,
BT_GATT_PERM_READ,
read_sensor_data, NULL, NULL),
/* [2] CCC */
BT_GATT_CCC(ccc_cfg_changed,
BT_GATT_PERM_READ | BT_GATT_PERM_WRITE),
};
说明:标准的 3-attribute 结构。BT_GATT_CHRC_READ | BT_GATT_CHRC_NOTIFY 表示这个特征值可以被读,也支持推送;CCC 配成可读可写,手机端通过写 CCC 来启用/禁用 Notify。
BLE 线程的主逻辑在 ble_thread.c,以下是关键的数据发送段(第92-198行):
/* 定期发送数据 */
if (is_connected &&
(current_time - connect_time >= CONNECT_DELAY) &&
(current_time - last_send_time >= DATA_SEND_INTERVAL))
{
err = sensor_data_get(&data);
if (err == 0) {
/* 检查传感器数据是否有效(上电初期数据不稳定) */
bool data_valid = true;
if (data.pm1_0 >= 65535 || data.pm2_5 >= 65535 ||
data.pm4_0 >= 65535 || data.pm10 >= 65535) {
data_valid = false;
}
if (data.co2 >= 65535) {
data_valid = false;
}
if (data.nox_index >= 32767) {
data_valid = false;
}
if (!data_valid) {
LOG_WRN("Invalid sensor data, skipping");
last_send_time = current_time;
retry_count = 0;
continue;
}
/* 构造数据包 */
memset(&packet, 0, sizeof(packet));
packet.pm1_0 = data.pm1_0;
/* ... 其余字段赋值 ... */
packet.timestamp = data.timestamp;
err = ble_ehs_send_sensor_data(&packet);
if (err == 0) {
retry_count = 0;
} else if (err == -ENOMEM) {
retry_count++;
if (retry_count <= MAX_NOTIFY_RETRY) {
/* 不更新 last_send_time,下次循环立即重试 */
} else {
last_send_time = current_time;
retry_count = 0;
}
}
/* ... 其他错误处理 ... */
}
}
k_sleep(K_SECONDS(1));
说明:这里有几个设计点。CONNECT_DELAY = 5 秒是等手机那边配好 CCC 和 MTU;数据有效性校验过滤上电初期 SEN66 的异常值;-ENOMEM 重试是因为 ATT 层还没准备好,最多试3次,3次都失败就跳过等下一个周期。
断线重连在 ble_ehs_service.c 的第92-110行:
static void disconnected(struct bt_conn *conn, uint8_t reason)
{
char addr[BT_ADDR_LE_STR_LEN];
bt_addr_le_to_str(bt_conn_get_dst(conn), addr, sizeof(addr));
LOG_INF("<<< Disconnected: %s (0x%02x)", addr, reason);
if (current_conn) {
bt_conn_unref(current_conn);
current_conn = NULL;
}
ccc_value = 0;
bt_le_adv_stop();
k_work_reschedule(&adv_restart_work, K_MSEC(500));
}
disconnected 回调里把 current_conn 引用释放掉、ccc_value 清零,然后停广播等 500ms 再重启。延迟重启是因为 BLE 栈内部需要时间清理旧的资源。
5.5 UI 显示线程
OLED 显示逻辑在 user_ui.c,分三页翻:
static void ui_show_page1(const struct sensor_data *data)
{
char buf[32];
snprintf(buf, sizeof(buf), "TEMP : %5.1F ^C", (double)data->temperature / 200.0);
oled_draw_string(0, 0, buf);
snprintf(buf, sizeof(buf), "HUMI : %5.1F %%", (double)data->humidity / 100.0);
oled_draw_string(0, 16, buf);
snprintf(buf, sizeof(buf), "CO2 : %5u PPM", data->co2);
oled_draw_string(0, 32, buf);
}
// page2(PM1.0/2.5/4.0)和 page3(PM10/VOC/NOx)结构一样
static void ui_update_display(const struct sensor_data *data)
{
static uint8_t page_idx = 0;
static int64_t last_page_switch = 0;
int64_t now = k_uptime_get();
char buf[16];
if (now - last_page_switch >= 5000) {
page_idx = (page_idx % 3) + 1;
last_page_switch = now;
}
oled_clear();
if (page_idx == 1) ui_show_page1(data);
else if (page_idx == 2) ui_show_page2(data);
else if (page_idx == 3) ui_show_page3(data);
snprintf(buf, sizeof(buf), "%d/3 PAGE", page_idx);
oled_draw_string(64, 48, buf);
oled_refresh();
}
说明:page_idx 从1到3循环,每5秒切一次。每页固定三个数据行(y=0,16,32),右下角显示页码。
欢迎界面的实现:
static void ui_show_welcome(void)
{
#if defined(CONFIG_BOARD_FRDM_RW612)
oled_clear();
oled_draw_centered(0, "EETREE & MOUSER");
oled_draw_centered(16, "RW612 BLE HT");
oled_draw_centered(32, "OLED && BLE");
oled_draw_centered(48, "WECHAT MINIPROGRAM");
oled_refresh();
#elif defined(CONFIG_BOARD_FRDM_MCXW71)
/* MCXW71 版显示不同的品牌 */
oled_draw_centered(0, "EEPW & ELE14");
oled_draw_centered(16, "MCXW71 BLE HT");
/* ... */
#endif
}
预编译宏区分不同开发板,换板子就显示不同的品牌信息。
5.6 微信小程序
小程序连接 BLE 设备的流程,核心代码在 realtime.js。
搜索设备(精确匹配名称):
_startDiscovery() {
// 先清理旧监听器
if (this._deviceFoundHandler) {
wx.offBluetoothDeviceFound(this._deviceFoundHandler);
}
this._deviceFoundHandler = (res) => {
if (!this._isConnecting) return;
res.devices.forEach(device => {
const name = device.name || device.localName || '';
// 精确匹配设备名称 "BLE HT Meter"
if (name === TARGET_DEVICE_NAME) {
wx.stopBluetoothDevicesDiscovery();
wx.offBluetoothDeviceFound(this._deviceFoundHandler);
this._connectToDevice(device.deviceId);
}
});
};
wx.onBluetoothDeviceFound(this._deviceFoundHandler);
wx.startBluetoothDevicesDiscovery({ ... });
}
说明:TARGET_DEVICE_NAME 是 'BLE HT Meter',用 === 全等匹配而不是 includes() 模糊匹配,这是为了避免连到周围的"BYD BLE3"、"海豹06EV"之类无关设备。
连接后的服务发现和特征值查找:
_discoverServices() {
wx.getBLEDeviceServices({
deviceId: this._deviceId,
success: (res) => {
// 优先找自定义 UUID
const targetUUID = app.globalData.serviceUUID.toUpperCase();
let targetSvc = res.services.find(
s => s.uuid.toUpperCase() === targetUUID
);
// 没找到的话,排除标准服务后取第一个
if (!targetSvc) {
const standardPrefixes = ['00001800', '00001801', '0000180a', '0000181a'];
targetSvc = res.services.find(s => {
const uuid = s.uuid.toUpperCase();
return !standardPrefixes.some(p => uuid.startsWith(p));
});
}
if (targetSvc) {
this._serviceId = targetSvc.uuid;
this._discoverCharacteristics();
} else {
this._retryDiscoverServices(); // 重试
}
},
fail: (err) => { this._retryDiscoverServices(); }
});
}
说明:先按配置的 UUID 精准查找,找不到时把标准蓝牙服务(GAP/GATT/设备信息等)排除,取第一个非标准服务作为备选。失败时最多重试3次,间隔1.5秒。
收到数据后的解析:
_handleSensorData(buffer) {
const dataView = new DataView(buffer);
const off = 0;
const pm1_0 = dataView.getUint16(off + 0, true);
const pm2_5 = dataView.getUint16(off + 2, true);
const pm4_0 = dataView.getUint16(off + 4, true);
const pm10 = dataView.getUint16(off + 6, true);
const rawHumi = dataView.getInt16(off + 8, true);
const rawTemp = dataView.getInt16(off + 10, true);
const voc_index = dataView.getInt16(off + 12, true);
const nox_index = dataView.getInt16(off + 14, true);
const co2 = dataView.getUint16(off + 16, true);
const timestamp = dataView.getUint32(off + 18, true);
const data = {
pm1_0: (pm1_0 / 10.0).toFixed(1),
pm2_5: (pm2_5 / 10.0).toFixed(1),
pm4_0: (pm4_0 / 10.0).toFixed(1),
pm10: (pm10 / 10.0).toFixed(1),
humidity: Math.max(0, Math.min(100, rawHumi / 100.0)).toFixed(1),
temperature: Math.max(-10, Math.min(80, rawTemp / 200.0)).toFixed(1),
voc_index: (voc_index / 10.0).toFixed(1),
nox_index: (nox_index / 10.0).toFixed(1),
co2: Math.min(40000, co2),
updateTime: this._formatTime(new Date())
};
app.globalData.currentData = data;
app.addHistoryRecord({ timestamp: Date.now(), ...data });
this._updateDisplayData(data);
}
说明:getUint16(off, true) 的第二个参数 true 表示小端序,跟下位机的协议对应。温湿度做了边界限制(湿度0~100%,温度-10~80°C),CO₂ 限 40000,防止 SEN66 的异常值污染显示。
六、功能展示图及说明
6.1 硬件连接
照片里能看到:
- FRDM-RW612 开发板,USB 线供电
- SEN66 传感器模块用杜邦线连到开发板 flexcomm2 接口
- SSD1306 OLED 并联在同一个 SCL/SDA 上
- 板载按钮 SW0 和 RGB LED 也可见

6.2 OLED 显示
上电先出欢迎画面,5秒后进数据轮播。
欢迎画面:
EETREE & MOUSER
RW612 BLE HT
OLED && BLE
WECHAT MINIPROGRAM
三页轮播(每5秒):
- Page 1:温度 (°C)、湿度 (%RH)、CO₂ (ppm)
- Page 2:PM1.0 / PM2.5 / PM4.0 (μg/m³)
- Page 3:PM10 (μg/m³)、VOC 指数、NOx 指数
每页右下角显示 "x/3 PAGE"。




6.3 微信小程序界面
实时数据页: 顶部显示连接状态 + 扫描按钮。中间是9张卡片,每张一个传感器数据带彩色图标。底部显示最后更新时间、数据条数、操作引导。
历史曲线页: 顶部时间筛选(1h/6h/24h/3d)。中间 Canvas 画的曲线图,可切换传感器显示。下方显示最大/最小/平均值。底部 9 宫格显示各传感器最新值,点击切换曲线。
6.4 操作步骤
- 开发板 USB 上电,OLED 出欢迎画面
- 手机微信打开小程序
- 点"扫描并连接",自动搜名为 "BLE HT Meter" 的设备
- 连上后自动显示9种数据
- 切"历史曲线"看趋势
6.5 微信界面演示





七、设计中遇到的难题和解决方法
7.1 BLE Notify 报 -ENOMEM
连接建立后调用 bt_gatt_notify() 频繁返回 -ENOMEM。
原因:刚连上时 ATT 通道还没完全建好,TX 缓冲区没分配好就发 Notify,BLE 栈返回 -ENOMEM。
解决:
- 连上后等 5 秒再发(
CONNECT_DELAY) - -ENOMEM 时最多重试 3 次
- -EACCES(CCC没开)和 -ENOTCONN(断连)不重试,直接跳过
7.2 断连后搜不到设备
手机断开后广播停了,或者 BLE 栈内部资源没释放干净,导致重启广播失败。
解决:disconnected 回调里一次性做完三件事:bt_conn_unref 释放引用 → bt_le_adv_stop 停旧广播 → k_work_reschedule 延迟500ms重启。丢到 workqueue 里避免在中断上下文干活。
7.3 小米/红米手机连不上
wx.openBluetoothAdapter() 返回 10001 或 10004。
原因:MIUI 的 BLE 扫描需要开位置权限,只在 Manifest 里配蓝牙权限不够。
解决:Android 端先调 wx.authorize({ scope: 'scope.userLocation' }) 要位置权限,要不到也继续走,不卡死。
7.4 SEN66 上电数据异常
刚上电时 PM 读到 65535、CO₂ 读到 65535。
原因:传感器内部的激光器、NDIR 灯丝、MOx 加热板都需要预热才能稳定。
解决:BLE 线程在发数据前做三级校验 data.pmX >= 65535、data.co2 >= 65535、data.nox_index >= 32767,有一个超阈值就跳过本次发送,等下一周期再试。
7.5 OLED 刷屏慢
全屏刷要传 1024 字节(128×8 pages),100kHz I²C 下要 80ms+。
解决:维护新旧两个帧 buffer,刷屏前逐 page 对比,只刷变化的 page。实测平时只变 1-2 个 page,I²C 通信量减了 75%+。
7.6 小程序连不上设备
断断续续出现 getBLEDeviceServices:fail:no service,还会连到汽车的"BYD BLE3"。
排查过程:翻控制台日志发现同时连了多个设备,服务发现失败率高,而且旧监听器没注销。
问题 | 根因 | 修法 |
|---|---|---|
匹配到车载蓝牙 | | 改为 |
同时连多个设备 | | 加 |
服务发现失败 | 连接完立刻查服务,栈还没稳定 | 失败后等1.5秒重试,最多3次 |
监听器泄漏 | 旧监听器残留在全局 | 断开时 |
断开后重连慢 | | 改轻量清理,只清内部状态 |
八、心得体会
8.1 技术上的收获
Zephyr RTOS 多线程:之前写单片机都是裸机轮询,这次用 4 个独立线程加互斥锁,代码结构比裸机清晰很多。
BLE 协议栈:从配广播数据、注册 GATT 服务、处理 CCC 配置到处理 ENOMEM 重试,踩了不少坑才跑通。特别是 ATT 层时序问题,文档几乎没讲,全靠读源码试出来的。
微信小程序 BLE API:流程比想象的长——开适配器、扫设备、连、MTU协商、发现服务、发现特征值、开 Notify,七步走完才能收数据。中间任何一步失败都要处理,对异步编程的容错要求比较高。
日志驱动 debug:小程序连不上的问题花了最多时间。最后是靠逐行分析控制台日志,发现同时连了"BYD BLE3"和"BLE HT Meter"两台设备,才定位到原因。修完这个之后又修了监听器泄漏和服务发现重试,连接成功率才上去。
8.2 Zephyr 开发体验
设备树 + 代码分离做得不错,换开发板只要改 .overlay,不用改 C 代码。Kconfig 配起来也方便。
不足是文档不够全面,BLE 这块的时序细节、ATT 层状态机基本没有说明,遇到问题只能去翻 Zephyr 源码或者社区。
8.3 还能改进的地方
- 低功耗:现在是一直采一直发,可以加休眠模式
- 上云:数据存到云端,多设备查看
- 告警:PM2.5/CO₂ 超限了弹通知
- 导出:历史数据能导成 CSV
- 多设备:一个手机盯多个传感器节点