1. 项目介绍
室内空气中的颗粒物、二氧化碳和挥发性有机物是影响健康的主要因素,一台能同时测量、显示并在超标时主动提醒的设备有实际使用价值。本项目以 NXP FRDM-MCXN236 开发板为核心,接 Sensirion SEN66 多合一环境传感器,通过并口触摸屏实时显示 9 项环境数据(PM1.0、PM2.5、PM4.0、PM10、相对湿度、温度、VOC 指数、NOx 指数、CO2 浓度)。9 项数据的报警上限均可在触摸屏上调节并写入片内 Flash 掉电保存;任一数值超限时板载红灯高频闪烁,DFR0760 语音模块播报具体的传感器名称、当前值和阈值;数据无效时也会提示检查传感器连接。
系统运行 Zephyr RTOS 4.4,界面基于 LVGL 9。除基础的数据采集、串口输出、LED 指示外,项目还实现了多界面触摸导航、阈值设置界面、系统信息界面、TTS 语音播报和背光自动管理,采集周期 1 秒。
2. 硬件介绍
硬件 | 说明 |
FRDM-MCXN236 | NXP MCXN236 SoC,Cortex-M33 主频 150 MHz,1 MB 片内 Flash,192 KB SRAM0 + 96 KB SRAM1,板载蓝/红双色 LED 与 LinkServer 调试电路 |
LCD 触摸屏 shield | 480×320 彩屏,8080 并口驱动(lcd_par_s035_8080 shield),电容触摸输入 |
SEN66 | Sensirion 九通道环境传感器,I2C 地址 0x6B,接 Arduino 排针 J2.18/J2.20;PM 输出分辨率 0.1 µg/m³,CO2 量程 40000 ppm |
DFR0760 | DFRobot 语音合成模块 V2,I2C 地址 0x40,接 mikroBUS 排针 J5.5/J5.6,支持中文文本直接合成 |
板载 LED | 蓝灯指示系统正常(1 Hz),红灯指示报警/数据无效(5 Hz),均为低电平有效 |
3. 方案框图与设计思路
3.1 硬件框图

3.2 软件总体结构
系统由 main 组装根、四个常驻线程和一个背光工作项组成。main 完成初始化序列后即退出,业务全部运行在模块自有的执行体中;线程之间只共享三样东西:互斥锁保护的传感器数据快照、语音文本消息队列、settings 阈值树。两条外部 I2C 总线各有一把互斥锁(K_MUTEX_DEFINE 静态初始化,随镜像加载即生效),任何多事务序列(SEN66 读数、TTS 探测与播报)都持锁进行,避免事务交错。
执行体 | 类型 | 周期 | 职责 | 对外接口 |
main | 主线程(初始化后返回) | 一次 | 组装:加载阈值、初始化背光、启动 UI 线程 | 定义两把 I2C 总线锁 |
sen66_task | 线程,自启动 | 1 s | 读 SEN66、判有效性、发布快照 | 快照 g_sen66_data + sen66_data_lock |
sen66_state | 线程,自启动 | 100 ms | 阈值比较、LED 状态、排队语音 | 读快照;调 threshold_store / tts_worker_speak |
tts_worker | 线程,自启动 | 事件驱动 | DFR0760 探测与播报 | k_msgq 文本队列;mikroBUS 总线锁 |
ui_entry | 线程,main 启动 | 持续 | 唯一允许调用 LVGL 的线程 | 读快照与阈值绑定到控件 |
backlight_ctrl | 延时工作项 + input 回调 | 1 s tick | 背光超时熄屏、触摸唤醒 | input 子系统触摸事件;背光 GPIO |

3.3 各执行体说明
(1) main:
main 不承载业务,只按依赖顺序完成初始化后返回:

(2) sen66_task 采集线程(1 s 周期)
上电后启动 SEN66 连续测量,随后每秒查询一次数据就绪标志并读取 9 通道原始值;读失败也会发布”无效快照”,让下游(LED、UI)明确地摘除数据而不是沿用旧值。

/*初始化:连续测量模式,全程持总线锁*/
k_mutex_lock(&i2c_bus_lock_arduino, K_FOREVER);
sensirion_i2c_hal_init();
sen66_init(SEN66_I2C_ADDR_6B);
sen66_stop_measurement();
sensirion_hal_sleep_us(50000);
sen66_start_continuous_measurement();
k_mutex_unlock(&i2c_bus_lock_arduino);
while (1) {
/*串行化Arduino总线上的SEN66事务*/
k_mutex_lock(&i2c_bus_lock_arduino, K_FOREVER);
error = sen66_get_data_ready(&padding, &data_ready);
if (error != NO_ERROR) {
k_mutex_unlock(&i2c_bus_lock_arduino);
sen66_publish_invalid();
k_sleep(K_SECONDS(SEN66_POLL_INTERVAL_S));
continue;
}
if (data_ready) {
error = sen66_read_measured_values_as_integers(
&mass_concentration_pm1p0, &mass_concentration_pm2p5,
&mass_concentration_pm4p0, &mass_concentration_pm10p0,
&ambient_humidity, &ambient_temperature,
&voc_index, &nox_index, &co2);
k_mutex_unlock(&i2c_bus_lock_arduino);
if (error != NO_ERROR) {
sen66_publish_invalid();
k_sleep(K_SECONDS(SEN66_POLL_INTERVAL_S));
continue;
}
/* SEN66用0xFFFF/0x7FFF标记不可用读数,任一通道无效即整帧无效*/
bool valid = !(mass_concentration_pm1p0 >= 65535 ||
mass_concentration_pm2p5 >= 65535 ||
/* ... PM4.0/PM10同上... */
co2 >= 65535 ||
ambient_humidity == 0x7FFF ||
ambient_temperature == 0x7FFF ||
voc_index == 0x7FFF || nox_index == 0x7FFF);
/*持锁发布快照,供状态线程与UI绑定读取*/
k_mutex_lock(&sen66_data_lock, K_FOREVER);
g_sen66_data.data_ready = true;
g_sen66_data.valid = valid;
g_sen66_data.timestamp_ms = k_uptime_get_32();
g_sen66_data.pm1p0 = mass_concentration_pm1p0;
/* ...其余7个通道同样写入... */
k_mutex_unlock(&sen66_data_lock);
}
k_sleep(K_SECONDS(SEN66_POLL_INTERVAL_S));
}
(3) sen66_state 状态线程(100 ms tick)
唯一的状态决策点:取快照、比阈值、控 LED、排语音。语音只入队不等待,多路超限按 30 s 间隔轮换播报,避免队列被同一通道灌满。

阈值比较与 LED 控制的核心代码:
/*阈值比较:存储的是显示单位,快照是原始编码,比较前把上限放大divisor倍*/
if (m != NULL && thr > 0 && scaled[i] > thr * m->divisor) {
out[n].id = (enum threshold_sensor)i;
out[n].value = scaled[i];
out[n].threshold = thr;
n++;
}
if (!snap.data_ready || !fresh) {
gpio_pin_set_dt(&led_blue, 0); /*无数据/陈旧:双灯灭*/
gpio_pin_set_dt(&led_red, 0);
} else if (n_violated > 0) {
gpio_pin_set_dt(&led_blue, 0);
if ((tick % 2) == 0) {
gpio_pin_toggle_dt(&led_red); /*报警:红5 Hz */
}
} else if (snap.valid) {
gpio_pin_set_dt(&led_red, 0);
if ((tick % 10) == 0) {
gpio_pin_toggle_dt(&led_blue); /*正常:蓝1 Hz */
}
} else if (now32 >= SEN66_INIT_GRACE_MS) {
gpio_pin_set_dt(&led_blue, 0); /*预热期后仍无效:红5 Hz */
if ((tick % 2) == 0) {
gpio_pin_toggle_dt(&led_red);
}
}
(4) tts_worker 语音线程
独占 DFR0760:探测、音量、播报都锁在 mikroBUS 总线锁下完成;请求方(状态线程)只往队列塞文本,最长的合成耗时被隔离在本线程内。

int tts_worker_speak(const char *text)
{
char msg[TTS_WORKER_TEXT_MAX];
if (!atomic_get(&tts_ready)) {
return -ENODEV; /*模块未探测到,语音禁用*/
}
strcpy(msg, text);
return k_msgq_put(&tts_msgq, msg, K_NO_WAIT) == 0 ? 0 : -EBUSY;
}
static void tts_worker_thread(void)
{
/*探测最长阻塞约2 s,全程持mikroBUS总线锁*/
k_mutex_lock(&i2c_bus_lock_mikrobus, K_FOREVER);
bool ready = tts_init(&tts_dev, DEVICE_DT_GET(TTS_WORKER_I2C_NODE),
TTS_DEFAULT_I2C_ADDRESS) == 0;
if (ready) {
tts_set_volume(&tts_dev, TTS_WORKER_VOLUME);
}
k_mutex_unlock(&i2c_bus_lock_mikrobus);
atomic_set(&tts_ready, ready ? 1 : 0);
while (1) {
k_msgq_get(&tts_msgq, msg, K_FOREVER); /*阻塞等播报请求*/
k_mutex_lock(&i2c_bus_lock_mikrobus, K_FOREVER);
if (tts_speak(&tts_dev, msg) != 0) {
LOG_WRN("TTS playback failed");
}
k_mutex_unlock(&i2c_bus_lock_mikrobus);
}
}
(5) ui_entry UI 线程
LVGL 不是线程安全的,全部调用收敛到一个线程;传感器数据经 custom 层的定时器从快照刷到控件,界面上不做任何 I2C 访问。

static void ui_thread(void)
{
setup_ui(&guider_ui); /*构建GUI Guider生成的界面*/
custom_init(&guider_ui); /*注册数据绑定定时器与导航按钮*/
while (1) {
uint32_t sleep_ms = lv_timer_handler(); /* LVGL唯一调用点*/
k_msleep(MIN(sleep_ms, INT32_MAX));
}
}
本系统共有四个界面,三屏(实时数据、阈值设置、系统信息)加上开机画面依次呈现;</> 两个导航按钮挂在 lv_layer_top() 上,开机画面期间隐藏,进入实时数据屏后才显示,三个主屏环形切换,150 ms 滑动动画。
界面 1,开机画面(splash,只显示一次):setup_ui 构建后直接 lv_screen_load 上屏;LV_EVENT_SCREEN_LOADED 回调里安排 3000 ms 延时,随后以 FADE_OUT 动画切到实时数据屏,auto_del 自动删除自身,之后不再出现。

界面 2,实时数据(screen_sensors):GUI Guider 生成,9 个通道容器分九宫格排布,每个容器含图标、名称和大号数值(Montserrat 24)。custom_init 注册 50 ms 数据绑定定时器,从 g_sen66_data 快照读取原始编码,按各通道 divisor 换算成显示单位刷到标签上;数据无效时全部显示 --。该屏在界面切换中会被删除重建,定时器回调先判 screen == NULL 防野指针,字体也在每次回调里重新设置。

界面 3,阈值设置(screen_threshold):手写界面。顶部 50 px 蓝色标题栏,下方可纵向滚动的列表共 9 行,每行 48 px:左侧传感器名称,中间 - 按钮、当前阈值(24 号字居中),右侧 + 按钮;阈值为 0 时显示 OFF,表示关闭该路报警。步进和上下限全部取自 metas[] 元数据表(见下一节)。

界面 4,系统信息(screen_sysinfo):手写界面,7 行只读信息。Flash/RAM 占用直接取链接符号 __rom_region_start/end 与 _image_ram_start/end 计算,总量来自 CONFIG_FLASH_SIZE/CONFIG_SRAM_SIZE;Uptime 由一个常驻的 1 s 定时器刷新,界面被删除期间回调空转,不会重复创建定时器。

(6) 阈值设置与保存:threshold_store
阈值功能横跨界面、存储、报警三方,核心思想是一张元数据表做唯一事实源。metas[] 给出每个通道的名称、原始编码 divisor、调节步进、上下限和出厂默认值;界面加减、settings 加载校验、状态线程超限比较三处都读它,不存在第二份:
static const struct threshold_sensor_meta
metas[THR_SENSOR_COUNT] = {
[THR_PM1_0] = { "PM1.0", 10, 10, 0, 1000, 50 },
[THR_PM2_5] = { "PM2.5", 10, 10, 0, 1000, 75 },
/* ... PM4.0/PM10/湿度/温度/VOC/NOx ... */
[THR_CO2] = { "CO2", 1, 100, 0, 40000, 1000 },
};
存储值就是显示单位的 int32(PM 为 µg/m³、湿度为 %、CO2 为 ppm),不做缩放。持久化走 Zephyr settings 子系统,ZMS 后端落在片内 Flash 的 storage 分区,每个通道一个叶子键 thr/0..thr/8。完整链路如下:

界面侧的事件处理把传感器 id 和方向打包进 user_data,一次回调服务全部 18 个按钮(screen_threshold.c):
/* user_data packs the sensor id (upper bits) and the direction (bit 0) */
static void step_cb(lv_event_t *e)
{
intptr_t ud = (intptr_t)lv_event_get_user_data(e);
int id = (int)(ud >> 1);
int dir = (ud & 1) ? 1 : -1;
const struct threshold_sensor_meta *m =
threshold_sensor_meta((enum threshold_sensor)id);
old = (int)threshold_store_get((enum threshold_sensor)id);
disp = old + dir * m->step;
if (disp < 0) {
disp = 0;
}
if (disp > m->max) {
disp = m->max;
}
(void)threshold_store_set((enum threshold_sensor)id, (int32_t)disp);
refresh_row(id);
}
写入路径先更新内存再写入 Flash,同一次按键屏幕立即变化、断电也不丢(threshold_store.c):
clamped = CLAMP(value, metas[id].min, metas[id].max);
k_mutex_lock(&thr_lock, K_FOREVER);
thresholds[id] = clamped;
k_mutex_unlock(&thr_lock);
if (!s_initialized) {
LOG_WRN("settings not initialized, threshold kept in RAM only");
return 0;
}
snprintf(key, sizeof(key), "%s/%d", SETTINGS_TREE, (int)id);
ret = settings_save_one(key, &clamped, sizeof(clamped));
加载路径在开机时由 settings 子系统逐键回调,读到的值同样过一遍 CLAMP,Flash 里即使落了越界数据也不会进入比较逻辑(加载回调 thr_settings_set 与 SETTINGS_STATIC_HANDLER_DEFINE 的完整代码见第 4 节)。状态线程比较时把显示单位的阈值乘回 divisor 再与快照的原始编码对比,这一条在 3.3 (3) 节的代码里。
(7) backlight_ctrl 背光模块(工作项,非线程)
没有独立线程:触摸事件经 input 子系统回调重置倒计时,1 s 一次的倒计时由延时工作项执行,到期熄屏。

/*任意触摸事件都算活动:点亮背光并重置倒计时*/
static void touch_event_cb(struct input_event *evt, void *user_data)
{
if (!backlight_is_on) {
backlight_set(true);
LOG_INF("Backlight: on (touch detected)");
}
backlight_countdown = CONFIG_SAMPLE_BACKLIGHT_TIMEOUT_SEC;
k_work_reschedule(&backlight_tick_work, K_SECONDS(1));
}
/* 1 s工作项:倒计时归零熄屏*/
static void backlight_tick_cb(struct k_work *work)
{
backlight_countdown--;
if (backlight_countdown > 0) {
k_work_reschedule(&backlight_tick_work, K_SECONDS(1));
} else {
backlight_set(false);
LOG_INF("Backlight: off (no touch for %d s)",
CONFIG_SAMPLE_BACKLIGHT_TIMEOUT_SEC);
}
}
3.4 设计取舍
· main 只做组装,不做具体业务,业务都在各自的 K_THREAD_DEFINE 自启动线程里,模块边界清晰。
· 采集线程只负责”读传感器、判有效性、发布快照”,状态线程负责”比阈值、控 LED、排语音”,播报再由独立线程执行。最耗时的 DFR0760 合成(约 2 秒)不会拖慢 1 秒采集节奏,也不会冻结 LVGL。
· 阈值走 Zephyr settings 子系统,界面修改后立即 settings_save_one 写 Flash,断电不丢;业务代码只面对 settings API,与底层存储解耦。
· SEN66 的 VOC/NOx 原始值按 Index×10 编码,快照中保存原始编码,UI 和语音统一除以 10 显示,避免多处各自换算造成不一致。
4. 调试软件介绍与关键机制代码
开发调试链路:Zephyr 官方 west 工具完成编译烧录(west build -b frdm_mcxn236/mcxn236 --sysbuild,west flash --linkserver),运行日志通过 UART 串口终端观察,是本项目中排错的主要手段;排查配置类问题时,以 build 目录里镜像实际的 zephyr/.config 为准核对(Kconfig 依赖缺失时选项会被静默丢弃,见第 6 节难题一)。UI 骨架用 NXP GUI Guider 生成,手写代码补齐业务界面。各线程的流程图与循环代码见 3.3 节,这里给出两个跨模块的关键机制。
阈值持久化采用标准 settings 模式,注册一个树处理器,加载和保存都走子系统(threshold_store.c):
static int thr_settings_set(const char *name, size_t len,
settings_read_cb read_cb, void *cb_arg)
{
int32_t value;
int id;
if (name[0] < '0' || name[0] > '9' || name[1] != '\0') {
return 0; /*只处理thr/0 .. thr/8 */
}
id = name[0] - '0';
if (id >= THR_SENSOR_COUNT || len != sizeof(value)) {
return 0;
}
if (read_cb(cb_arg, &value, sizeof(value)) < 0) {
return -EINVAL;
}
thresholds[id] = CLAMP(value, metas[id].min, metas[id].max);
return 0;
}
SETTINGS_STATIC_HANDLER_DEFINE(threshold_store, SETTINGS_TREE, NULL,
thr_settings_set, NULL, NULL);
/*界面+/-修改后立即存储到NVS*/
snprintf(key, sizeof(key), "%s/%d", SETTINGS_TREE, (int)id);
ret = settings_save_one(key, &clamped, sizeof(clamped));
界面切换由挂在 lv_layer_top() 的左右两个按钮驱动,三个主界面环形回绕(screen_mgr.c):
static void switch_to(int dir, bool forward)
{
if (cur_idx < 0 || switching) {
return; /* splash期间或动画进行中忽略*/
}
new_idx = (cur_idx + dir + SCR_COUNT) % SCR_COUNT;
switching = true;
/* 300 ms单次定时器作为防连点保护,动画结束后复位switching */
lv_timer_t *guard = lv_timer_create(switching_clear_cb,
SWITCH_GUARD_MS, NULL);
lv_timer_set_repeat_count(guard, 1);
load_screen(s_ui, screen_slot(s_ui, new_idx),
screen_setup(new_idx), forward);
cur_idx = new_idx;
}
5. 功能展示与说明
四个界面组成环形导航,左上 < 右上 > 按钮切换,边界自动回绕(数据 → 阈值 → 信息 → 数据)。
界面 2,实时数据:9 通道图标与数值两栏排布,每秒刷新,与串口输出一致。

界面 3,阈值设置:9 行传感器列表,-/+ 按钮按各自步进调节,数值即时写 Flash;重启后日志可见 Threshold PM1.0 loaded from flash: 60,设置得以恢复。

界面 4,系统信息:开发板型号、应用版本 V1.1.0、Zephyr 版本、Flash/RAM 占用(取自链接符号 _image_rom_size/_image_ram_size)、传感器型号与上电时间(每秒刷新)。

报警联动实测:向传感器附近呼气或放置污染物使 PM2.5 越过阈值后,红灯转 5 Hz 闪烁,扬声器播报”PM2.5当前值75.6,超过阈值75.0,请检查”,每 30 秒重复一次;恢复后蓝灯回到 1 Hz,报警停止。
6. 项目中遇到的难题及解决方法
难题一:settings 保存返回 -2,界面 +/− 不生效。 现象是开机加载不报错、保存却失败。逐层排查后发现配置里写了 CONFIG_SETTINGS_ZMS=y,但 build 目录的实际 .config 中后端 choice 落到了 SETTINGS_NONE。原因是 Kconfig 中各后端对底层存储只是 depends on 而非 select,工程没有显式打开 CONFIG_ZMS 与 CONFIG_FLASH_MAP,整个后端就被静默丢弃。教训是配置必须以镜像里的实际 .config 为准核对,而不是只看源码里写了什么。
难题二:NVS 挂载返回 -22,片内 Flash 写不进去。 这是耗时最长的一个问题。nvs_mount() 报 -EINVAL,但分区映射、扇区几何、设备状态全部正常。通过在诊断代码里对同一扇区做”读、擦、写、回读”逐步隔离,出现了关键现象:擦除成功,第一次写成功,同一页的第二次写必定失败。回头查 SoC 级 dtsi 找到答案——MCXN236 的 ROM 编程 API 只支持 128 字节整页编程,而 NVS 内部缓冲固定 32 字节,其挂载逻辑在写块尺寸大于 32 时直接拒绝。最终换用支持自定义块尺寸的 ZMS 后端(CONFIG_ZMS_CUSTOM_BLOCK_SIZE=128)解决,settings 应用层代码一行未改。
难题三:打开存储子系统后 SRAM 溢出。 NVS/Flash 子系统占掉 8 KB noinit 区,LVGL 96 KB 池放不下了。利用 MCXN236 独立的 96 KB SRAM1,通过 CONFIG_LV_Z_MEMORY_POOL_ZEPHYR_REGION=y 和 CONFIG_LV_Z_MEMORY_POOL_ZEPHYR_REGION_NAME="SRAM1" 两条配置把池整体搬过去,SRAM0 余量恢复,无需手写链接脚本。
难题四:快速连点切换按钮死机。 两次点击都进入屏幕装载动画,同一屏幕被重复删除重建,触发 use-after-free 硬错误。加了一个 300 ms 单次定时器作为切换保护窗,动画期间忽略新的切换请求。
其他小的坑:LVGL 的 lv_obj_set_pos 坐标是相对父容器内容区的,主题默认 8 px 内边距会让绝对布局的行底部被裁剪,列表容器需要 pad_all 0;sysbuild 构建进行中又触发烧录的增量构建,两个 ninja 打架导致链接时找不到库文件,重新构建即可,烧录时用 --skip-rebuild 可避免。
7. 心得体会
这个项目前后最大的收获是对”配置即代码”的理解。Zephyr 的 Kconfig 和 devicetree 把硬件描述、裁剪选项层层叠加,写对时一行顶百行,写错时静默失败、毫无征兆——后端被丢弃不报错、写块尺寸被覆写不报错,最后都以一个莫名的 -22 或 -ENOENT 呈现在运行期。 debug 的正确姿势不是反复试,而是设计能把变量隔离开的实验:先证明分区映射对,再证明擦除对,再证明单次编程对,最后”只有第二次写失败”这一条现象就把 128 字节页约束锁定了。回头看,SoC dtsi 里那行 MCXN23x ROM Flash API supports writing of 128B pages 的注释早就写在那里,读源码比猜问题便宜得多。
多线程拆分也验证了它该有的样子。语音合成动辄两秒,如果图省事放在采集或状态线程里,1 秒的采集节奏立刻被破坏;放在 LVGL 线程里则会掉帧。拆成三个线程后,它们之间只剩一把锁保护的数据快照和一个文本队列,联调时每个环节都能单独打日志验证。反过来,界面上那次 use-after-free 死机提醒我,UI 框架提供的”方便”背后往往有前提条件(动画期间屏幕指针会被置空),这类约束只能从框架源码里读出来,不能靠感觉。
整体上,从传感器 I2C 时序、RTOS 线程划分、存储选型到 UI 交互,一条链路自己走完一遍后,对”嵌入式系统是一个整体”这句话的理解具体了很多:任何一个看似局部的改动(多开一个子系统、换一种存储后端)都会沿着内存、Flash、线程栈一路传导下去,规划时的余量和排查时的证据链缺一不可。