阶段项目:多传感器采集与 OLED 仪表盘

2026-09-10 00:00    #ESP32-S3   #ESP-IDF   #外设   #阶段3  

本课将前面的总线、传感器、FreeRTOS 队列和显示知识组合起来。配套完整工程:examples/28-sensor-dashboard。

环境与验收边界: DNESP32S3 V1.2,ESP-IDF v5.5.5;本机 macOS 构建通过。Ubuntu 26、外设接线、屏幕效果和实际测量仍待实板验收。下文输出均为预期格式,不能当作已采集的硬件日志。

本课完成后能做什么

硬件连接与资源

信号或资源对应关系
传感器总线I²C0 GPIO41/42,AP3216C 0x1e、QMA6100P 0x12
显示总线I²C1 GPIO5/4,SSD1306 0x3c
OLED 复位XL9555 IO0_5;四针模式可跳过
温度来源芯片内部传感器,显示 CHIP C,不标室温

以下为正点原子 DNESP32S3 V1.2 原理图的相关页,保留原厂标识;表格用于快速定位,遇到板卡版本差异以实际原理图为准。点击图片可查看引脚与网络名。

DNESP32S3 原理图第 1 页

DNESP32S3 原理图第 2 页

本课信号与数据处理流程

综合项目先分清谁负责什么

任务周期负责的状态
光照采样200 msAP3216C 初始化与读数
加速度采样100 msQMA6100P 初始化与读数
芯片温度采样1000 ms温度读取
应用/显示约 200 ms 刷新最近读数、错误和时间戳

采集任务只发送事件,不操作 OLED。应用任务是显示模型的唯一写入者。屏幕显示 ALS 原始值、X 轴 g 和芯片温度,串口每两秒输出状态和数据年龄。这是阶段演示界面,显示内容不代表环境温度或完整运动姿态。

事件必须携带来源、结果和时间

sample_event_t 包含传感器编号、错误码、采样时间和三个浮点字段。FreeRTOS 队列按值复制结构体,因此任务下一轮复用局部变量不会破坏已入队的数据。不要把临时局部变量的指针发给消费者。

成功、失败、还没收到数据、数据过期是四种不同状态。显示中对应数值、ERR、WAIT、STALE;最近一次采样失败时,不会继续用前一次的成功值掩盖错误。3 秒未更新才标记过期,单调时间不受系统校时影响。

如何让一个设备异常不阻止其他设备

总线和设备句柄在启动阶段建立,实际器件初始化移到对应任务。读失败后降为每秒重试;每笔 I²C 事务有 50 ms 上限。显示失败也只进入屏幕自己的限频重试,不终止采样任务。

软件隔离仍受电气连接限制:AP3216C 和 QMA6100P 共用同一物理 I²C 总线,某个器件把 SDA 永久拉低时,两者都可能失败。OLED 用另一条总线,芯片温度不依赖 I²C,仍可继续。验证时分别测试缺少单个地址和整条线被拉低,理解两种故障范围。

队列容量不是越大越好

正常每秒约 16 个事件(5 次光照、10 次加速度、1 次温度),32 项队列为短暂显示操作提供余量。采集任务发送事件时最多等待 10 ms。队列满时,程序会记录警告并丢弃这条新样本,避免阻塞整个采样链。

应用每次消费一个事件,最多等待 20 ms,然后检查是否到了刷新时间。绘屏仍是同步传输,100 kHz 下需要约百毫秒,这段时间采集可以继续入队。程序不保证严格实时刷新;若后续加入更多设备,应测量队列压力并采用快照、合并更新或更快总线。

vTaskDelayUntil() 按上次唤醒点规划下一次执行,减少周期累积漂移;它不会让一次超过周期的 I²C 操作自动变快。

工程如何组织

驱动放在 components/BSP/,应用放在 main/main.c。本工程包含 BUS、EXIO、OLED、AP3216C、QMA6100P、TEMP 模块。ESP-IDF 会自动发现项目根目录下的 components/BSP。main 通过 PRIV_REQUIRES BSP 声明组件依赖,INCLUDE_DIRS 则导出编译时使用的头文件搜索路径,不涉及运行时文件查找。

固定引脚通过 CONFIG_LESSON_* 常量定义在本工程的 Kconfig.projbuild 中,构建后可在 sdkconfig 中核对。这些默认值对应本板连线,仅修改配置数字并不能改变硬件连接。

应用代码:入口与处理流程

下面代码直接取自配套工程的 main/main.c,头文件和 BSP 实现在仓库中一并提供。初学时先看 app_main(),再追踪它调用的模块。

  1#include "sdkconfig.h"
  2#include <stdio.h>
  3#include <stdint.h>
  4#include <stdbool.h>
  5#include <string.h>
  6#include <stdlib.h>
  7#include <assert.h>
  8#include "esp_err.h"
  9#include "esp_log.h"
 10#include "esp_timer.h"
 11#include "freertos/FreeRTOS.h"
 12#include "freertos/task.h"
 13#include "ap3216c.h"
 14#include "qma6100p.h"
 15#include "temp.h"
 16#include "oled.h"
 17#include "exio.h"
 18#include "freertos/queue.h"
 19typedef enum { LIGHT, MOTION, CHIP, SENSOR_COUNT } sensor_id_t;
 20typedef struct {
 21    sensor_id_t id;
 22    esp_err_t error;
 23    int64_t sampled_us;
 24    float x,y,z;
 25} sample_event_t;
 26static QueueHandle_t s_samples;
 27static void sample_task(void *arg)
 28{
 29    sensor_id_t id=(sensor_id_t)(intptr_t)arg;
 30    const int periods[]={200,100,1000};
 31    bool ready=id==CHIP;
 32    TickType_t wake=xTaskGetTickCount();
 33    while(1) {
 34        sample_event_t e={.id=id,.sampled_us=esp_timer_get_time()};
 35        if(!ready) {
 36            e.error=id==LIGHT ? light_reset() : motion_reset();
 37            ready=e.error==ESP_OK;
 38        }
 39        if(ready) {
 40            if(id==LIGHT) {
 41                light_sample_t v;
 42                e.error=light_read(&v);
 43                if(e.error==ESP_OK) {
 44                    e.x=v.als; e.y=v.ps; e.z=v.ps_valid;
 45                    if(!v.ps_valid) e.error=ESP_ERR_INVALID_RESPONSE;
 46                }
 47            } else if(id==MOTION) {
 48                acceleration_t v;
 49                e.error=motion_read(&v);
 50                if(e.error==ESP_OK) {e.x=v.x; e.y=v.y; e.z=v.z;}
 51            } else e.error=chip_temp_read(&e.x);
 52            if(e.error!=ESP_OK && id!=CHIP) ready=false;
 53        }
 54        // 队列按值复制;局部变量离开本轮后不会留下悬空指针。
 55        if(xQueueSend(s_samples,&e,pdMS_TO_TICKS(10))!=pdTRUE)
 56            ESP_LOGW("sample","queue full, id=%d",id);
 57        int delay=e.error==ESP_OK ? periods[id] : 1000;
 58        vTaskDelayUntil(&wake,pdMS_TO_TICKS(delay));
 59    }
 60}
 61void app_main(void)
 62{
 63    i2c_master_bus_handle_t sensors,display;
 64    ESP_ERROR_CHECK(bus_init(0,CONFIG_LESSON_I2C_SDA,CONFIG_LESSON_I2C_SCL,&sensors));
 65    ESP_ERROR_CHECK(bus_init(1,CONFIG_LESSON_OLED_SDA,CONFIG_LESSON_OLED_SCL,&display));
 66    // 注册句柄不会要求器件在线;实际通信在各自任务中完成,便于断线后恢复。
 67    ESP_ERROR_CHECK(light_open(sensors));
 68    ESP_ERROR_CHECK(motion_open(sensors));
 69    ESP_ERROR_CHECK(exio_init(sensors));
 70    ESP_ERROR_CHECK(oled_open(display));
 71    ESP_ERROR_CHECK(chip_temp_init());
 72    s_samples=xQueueCreate(32,sizeof(sample_event_t)); assert(s_samples);
 73    for(int id=0;id<SENSOR_COUNT;id++)
 74        ESP_ERROR_CHECK(xTaskCreate(sample_task,"sample",4096,(void *)(intptr_t)id,4,NULL)
 75                        ==pdPASS ? ESP_OK : ESP_ERR_NO_MEM);
 76    sample_event_t latest[SENSOR_COUNT]={0};
 77    bool seen[SENSOR_COUNT]={0},screen_ready=false;
 78    int64_t next_refresh=0,next_retry=0,next_log=0;
 79    while(1) {
 80        sample_event_t e;
 81        if(xQueueReceive(s_samples,&e,pdMS_TO_TICKS(20))==pdTRUE) {
 82            latest[e.id]=e; seen[e.id]=true;
 83        }
 84        int64_t now=esp_timer_get_time();
 85        // 即使屏幕失联,仍持续消费事件;重试限频为 1 秒一次。
 86        if(!screen_ready && now>=next_retry) {
 87            screen_ready=oled_reset()==ESP_OK;
 88            next_retry=now+1000000;
 89        }
 90        if(now>=next_log) {
 91            for(int i=0;i<SENSOR_COUNT;i++)
 92                ESP_LOGI("dashboard","id=%d seen=%d status=%s x=%.2f age=%lld ms",i,seen[i],
 93                    esp_err_to_name(latest[i].error),latest[i].x,(now-latest[i].sampled_us)/1000);
 94            next_log=now+2000000;
 95        }
 96        if(!screen_ready || now<next_refresh) continue;
 97        next_refresh=now+200000;
 98        oled_clear(); oled_text(0,0,"SENSOR DASHBOARD");
 99        const char *labels[]={"ALS","X G","CHIP C"};
100        for(int i=0;i<SENSOR_COUNT;i++) {
101            char text[32];
102            if(!seen[i]) snprintf(text,sizeof(text),"%s WAIT",labels[i]);
103            else if(latest[i].error!=ESP_OK) snprintf(text,sizeof(text),"%s ERR",labels[i]);
104            else if(now-latest[i].sampled_us>3000000) snprintf(text,sizeof(text),"%s STALE",labels[i]);
105            else snprintf(text,sizeof(text),"%s %.2f",labels[i],latest[i].x);
106            oled_text(0,2+i*2,text);
107        }
108        if(oled_refresh()!=ESP_OK) { screen_ready=false; next_retry=now+1000000; }
109    }
110}

CMake 与中文注释

1# 私有依赖只供本组件源码使用。
2idf_component_register(SRCS "main.c" INCLUDE_DIRS "."
3    PRIV_REQUIRES esp_timer BSP )

每个工程有自己的 project() 名称和 sdkconfig.defaults。VS Code 已关闭普通 CMake 自动配置,C/C++ 语言服务从 build/compile_commands.json 读取编译信息。首次构建前,这个文件还不存在,需要先激活 IDF 并完成构建,无需手工拼接头文件路径。

编译、烧录与观察

 1# 激活 EIM 安装的 IDF;每个新终端都需要激活。
 2source ~/.espressif/tools/activate_idf_v5.5.5.sh
 3# 进入单个示例根目录,不是在 examples 总目录运行。
 4cd ~/mycode/esp32-blog/examples/28-sensor-dashboard
 5# 首次配置目标;已有正确配置时不用每次重复 set-target。
 6idf.py set-target esp32s3
 7# 编译会生成 build/compile_commands.json,供 VS Code 使用。
 8idf.py build
 9# 把这个值换成实际连接开发板的端口;macOS 通常为 /dev/cu.*。
10PORT=/dev/ttyUSB0
11idf.py -p "$PORT" flash monitor
12# 串口监视退出:Ctrl+]。

预期输出或屏幕内容(示意;数值、日志前缀和启动时间依现场而变):

1SENSOR DASHBOARD
2ALS <raw>
3X G <g>
4CHIP C <摄氏度>
5# 设备失败时对应位置显示 ERR。

故障定位与逐项验收

先确认目标为 esp32s3,再检查实际端口、供电,并按本文引脚表核对跳线。编译成功只能说明源码与工具链兼容,模块型号、物理线路和时序仍需实板确认。

遇到通信错误,先检查错误码和对应模块,不要用“错误时返回 0”掩盖异常。文中的预期日志只供阅读程序时参考;实板验收时,应记录实际现象,保存日志和接线照片。

与教材和官方实现对照

这是课程组合项目,无对应官方单一工程。复用 16、20、23、27 课 BSP,通过任务和队列组合;不复用 GPIO0 单总线传感器,以免把微秒忙等与本阶段多任务初次集成混在一起。

本地验证记录与参考

返回课程目录