零知派——STM32+SCD41+旋转编码器:室内CO₂智能监测系统,三环可视化仪表盘 + 分级蜂鸣告警

目录

一、系统接线部分

1.1 硬件清单

1.2 接线方案表

1.3 具体接线图

1.4 连接实物图

二、安装与使用部分

三、代码讲解部分

3.1 Softwire 正确初始化序列

3.2旋转编码器四态查表消抖算法

3.3圆环仪表盘绘制与局部刷新策略

3.4音量控制及多频率告警

3.5 SCD4x库API使用

① CRC-8 校验原理

②数据就绪轮询机制

四、项目结果演示

4.1操作流程

4.2 视频演示

五、工作原理讲解

5.1 SCD41 通信时序

5.2 自动自校准机制

5.3旋转编码器原理

六、常见问题解答(FAQ)

Q1:码器旋转方向反了,或者非常灵敏转一格跳很多页?

Q2:蜂鸣器一直响或完全不响?

Q3:为什么CO₂数据长时间显示"--"?

项目概述

本项目基于零知派标准板(主控 STM32F103RBT6)驱动 Sensirion SCD41 NDIR CO₂传感器,配合 ST7789 240×240 TFT 显示屏、旋转编码器和无源蜂鸣器,实现了一套完整的室内空气质量实时监测系统。系统采用浅色简约主题,以圆环仪表盘的形式同屏展示 CO₂浓度、温度、湿度三路数据,并通过旋转编码器在四个功能页面间自由切换,电位器实时调节告警音量,SW 按键一键切换静音

项目难点

问题描述:SoftWire 时序在多外设共存场景下崩溃,导致 SCD41 无法初始化

解决方案:启动画面不调用 beep()、 TFT init 后加 100ms 稳定延时、使用全局 Wire 对象

一、系统接线部分

1.1 硬件清单

序号 模块 规格 数量
1 零知派标准板 STM32F103RBT6,Arduino 兼容 1
2 CO₂传感器 Sensirion SCD41,I2C,3.3V 1
3 TFT 显示屏 ST7789,240×240,SPI 1
4 旋转编码器 EC11,带按键 SW 1
5 无源蜂鸣器 3.3V,支持 PWM 驱动 1
6 滑动变阻器 10kΩ,线性 1
7 杜邦线 母对母 若干

1.2 接线方案表

严格按照代码中的宏定义进行接线,不得随意更改,否则编码器中断和 ADC 会失效

①SCD41 CO₂传感器

SCD41 引脚 零知派标准板 代码定义 说明
SDA A4 SoftWire SDA 软件 I2C 数据
SCL A5 SoftWire SCL 软件 I2C 时钟
VDD 5V — 供电,注意噪声
GND GND — 接地

旋转编码器 EC11

编码器引脚 零知派标准板 代码定义 说明
CLK(A相) D6 #define ENC_CLK 6 接外部中断
DT(B相) D12 #define ENC_DT 12 接外部中断
SW(按键) D14 #define ENC_SW 14 INPUT_PULLUP
VCC 5V — —
GND GND — —

蜂鸣器 & 电位器

器件 零知派标准板 代码定义 说明
蜂鸣器 + D3 #define BUZZER_PIN 3 数字输出,PWM
蜂鸣器 − GND — —
电位器中间脚 A0 #define VOLUME_PIN A0 模拟输入,12位ADC
电位器两端 3.3V / GND — 左侧接3.3V / 右侧接GND

请注意:ST7789显示屏直插零知派标准板TFT引脚,无需单独接线

1.3 具体接线图

wKgZO2oY6m-Aa_PUABDjR7ksHbo355.png

编码器 CLK和 DT接到支持attachInterrupt外部中断的引脚D6和D12

1.4 连接实物图

二、安装与使用部分

2.1 开源平台-输入"SCD41"并搜索-代码下载自动打开

2.2 连接-验证-上传

2.3 调试-串口监视器

wKgZPGoY6sKAN6B9AAuytJ6rdvY769.png

三、代码讲解部分

本项目代码基于SparkFun_SCD4x_Arduino_Library(底层使用SoftWire)和Adafruit_ST7789驱动

3.1 Softwire 正确初始化序列

// setup() 中的传感器初始化序列

// Step 1:TFT 初始化(SPI外设配置)
tft.init(240, 240);
tft.setRotation(3);
tft.fillScreen(COL_BG);
showSplash();  // ← 纯显示,绝对不调用 beep()

// Step 2:等待 SPI 外设稳定,给 I2C 上拉恢复时间
delay(100);   // ← 这 100ms 是解决 err=268 的关键

// Step 3:启动 SoftWire 总线
Wire.begin(); // 使用 SoftWire.cpp 末尾定义的全局 Wire 对象
delay(50);    // I2C 总线电平建立稳定时间

// Step 4:SparkFun 库初始化
// begin(wirePort, measBegin, autoCalibrate, skipStop, pollDevType)
bool ok = mySensor.begin(Wire, true, true, false, true);

参数说明:

参数 值 含义
wirePort Wire 使用全局 SoftWire 对象
measBegin true 初始化后立即启动周期测量
autoCalibrate true 启用 SCD41 自动校准(ASC)
skipStop false 先执行 stopPeriodicMeasurement 确保干净状态
pollDevType true 读取 feature set 版本,自动识别 SCD40/41

SoftWire 的 NOP-loop 时序在 STM32 多外设环境下不够稳定,导致该命令的 CRC 校验失败返回 268。SparkFun 的 begin() 通过 getSerialNumber() 的 CRC 校验能验证通信正常

3.2旋转编码器四态查表消抖算法

本项目使用格雷码查表法,配合累积步数阈值,实现准确的方向判断

// 16个状态转移,每个值代表方向变化量(+1/-1/0)
const int8_t encoderTable[] = {
   0,-1, 1, 0,
   1, 0, 0,-1,
  -1, 0, 0, 1,
   0, 1,-1, 0
};

void updateEncoder() {
    uint8_t clk     = digitalRead(ENC_CLK);   // A相
    uint8_t dt      = digitalRead(ENC_DT);    // B相
    uint8_t encoded = (clk < < 1) | dt;        // 拼成2位格雷码
    
    // 用上一状态+当前状态组成4位索引,查表得方向
    int8_t dir = encoderTable[(lastEncoded < < 2) | encoded];
    
    if (dir != 0) {
        accSteps += dir;
        // 累积4步才触发翻页,过滤抖动产生的虚假脉冲
        if (accSteps >= 4) {
            accSteps = 0;
            if (millis() - lastEncTrigTime > ENC_COOLDOWN) {
                pageCW = true;              // 顺时针:下一页
                lastEncTrigTime = millis();
            }
        } else if (accSteps <= -4) {
            accSteps = 0;
            if (millis() - lastEncTrigTime > ENC_COOLDOWN) {
                pageCCW = true;             // 逆时针:上一页
                lastEncTrigTime = millis();
            }
        }
    }
    lastEncoded = encoded;
}

// 编码器通过 attachInterrupt 绑定到两个引脚
attachInterrupt(digitalPinToInterrupt(ENC_CLK), updateEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC_DT),  updateEncoder, CHANGE);

// 在 loop() 主循环中,中断设置的标志位被消费
if (pageCW)  
{ 
    pageCW = false;  
    currentPage = (currentPage+1)%PAGE_COUNT; 
    lastPage = -2; 
}
if (pageCCW) 
{ 
    pageCCW = false; 
    currentPage = (currentPage-1+PAGE_COUNT)%PAGE_COUNT; 
    lastPage = -2; 
}

lastPage=-2 是强制全刷新标志,翻页时重绘整个屏幕避免残影

3.3圆环仪表盘绘制与局部刷新策略

圆环绘制是本项目视觉效果的核心,也是性能优化的重点

// 基础弧段绘制:从 startDeg 到 endDeg(以正上方为0°,顺时针)
void drawArcSection(int16_t cx, int16_t cy, int16_t r,
                    int16_t startDeg, int16_t endDeg, uint16_t color) {
    for (int16_t deg = startDeg; deg <= endDeg; deg++) {
        // 坐标系旋转:-90° 使 0° 指向正上方
        float rad = (deg - 90) * PI / 180.0f;
        tft.drawPixel(cx + (int16_t)(r * cosf(rad)),
                      cy + (int16_t)(r * sinf(rad)), color);
    }
}

// 圆环(多层弧段叠加形成宽度)
void drawRingArc(int16_t cx, int16_t cy,
                 int16_t innerR, int16_t outerR,
                 int16_t startDeg, int16_t endDeg, uint16_t color) {
    for (int16_t r = innerR; r <= outerR; r++)
        drawArcSection(cx, cy, r, startDeg, endDeg, color);
}

圆环绘制

// Step1:擦除整个圆环区域(用背景色填充外圆)
tft.fillCircle(cx, cy, outerR+2, COL_BG);

// Step2:绘制底层灰色轨道(300°满量程)
drawRingArc(cx, cy, innerR, outerR, 0, 300, COL_RING_BG);

// Step3:绘制有色填充弧(根据数据值映射到0~300°)
int16_t arc = (int16_t)map(constrain(co2, 400, 2500), 400, 2500, 0, 300);
if (arc > 0) drawRingArc(cx, cy, innerR, outerR, 0, arc, co2Color(co2));

// Step4:填充圆心区域(遮住innerR以内的像素,恢复白底)
tft.fillCircle(cx, cy, innerR-1, COL_BG);

局部刷新策略

// 只有首次进入页面或强制刷新时(full=true)才重绘静态背景
void drawPageCO2(bool full) {
    if (full) {
        tft.fillScreen(COL_BG);    // 仅此处全屏清空
        drawTopBar("CO2 Detail");
        drawBottomBar("TURN to switch");
    }
    // 以下每次循环都执行:只刷新圆环和数值区域
    // 数值文字前先精确擦除其占用矩形
    tft.fillRect(cx-48, labelY-12, 88, 16, COL_BG);  // 擦除等级标签
    // 再重绘
    tft.print(co2Label(co2));
}

bool full = (currentPage != lastPage);

lastPage = currentPage;

full 为 true 只在翻页瞬间,之后每帧都是 false,只刷新有数据变化的区域,大幅减少 SPI 传输量

3.4音量控制及多频率告警

蜂鸣器通过软件 PWM(手动控制高低电平时间)实现不同频率,由电位器 ADC 值控制占空比从而调节响度

void updateVolume() {
    int adc = analogRead(VOLUME_PIN);  // STM32 12位ADC:0~4095
    // 映射到 10%~90% 占空比,防止0%(无声)和100%(直流,损坏蜂鸣器)
    volumePercent = (uint8_t)map(adc, 0, 4095, 10, 90);
}

void beep(uint16_t freq, uint16_t dur) {
    if (freq == 0 || muteEnabled) return;
    uint32_t period   = 1000000UL / freq;          // 周期(微秒)
    uint32_t highTime = period * volumePercent / 100; // 高电平时间
    uint32_t lowTime  = period - highTime;            // 低电平时间
    uint32_t cycles   = (uint32_t)dur * 1000UL / period; // 总循环次数
    
    for (uint32_t i = 0; i < cycles; i++) {
        digitalWrite(BUZZER_PIN, HIGH);
        delayMicroseconds(highTime);
        digitalWrite(BUZZER_PIN, LOW);
        delayMicroseconds(lowTime);
    }
}

三档告警频率对应表:

CO₂ 范围 等级 圆环颜色 告警行为
0~400 ppm WAIT 灰色(数据未就绪) 无
400~800 ppm GOOD 绿色 0x07C0 无告警
800~1000 ppm FAIR 青绿 0x0454 单次 440Hz 短鸣
1000~1500 ppm POOR 琥珀黄 0xFEA0 双次 440Hz 鸣叫
1500~2000 ppm BAD 橙色 0xFC40 三次 280Hz 告警
>2000 ppm CRIT 红色 0xF800 三次 280Hz 急促告警

void handleBuzzer() {
    if (!dataValid) return;
    // 节流:最快每 4 秒告警一次,避免持续噪音
    if (millis() - lastBeepTime < BEEP_INTERVAL) return;
    
    if      (co2 > CO2_BAD)  { beepPattern(BEEP_CRIT, 3, 200, 150); lastBeepTime=millis(); }
    else if (co2 > CO2_POOR) { beepPattern(BEEP_WARN, 2, 150, 120); lastBeepTime=millis(); }
    else if (co2 > CO2_FAIR) { beepPattern(BEEP_WARN, 1, 100,   0); lastBeepTime=millis(); }
}

系统流程图

wKgZPGoY6vuAX7VWAATe74KmrIg816.png

3.5 SCD4x库API使用

API 调用时机 内部原理
mySensor.begin(Wire, true, true, false, true) setup() 一次 stopPeriodic→CRC序列号验证→ASC设置→startPeriodic
mySensor.readMeasurement() loop() 轮询 内部先调 getDataReadyStatus(),bit[10:0]≠0才读;一次读取 9 字节含3组 CRC
mySensor.getCO2() 数据就绪后 返回 uint16_t,单位 ppm,范围 400~5000
mySensor.getTemperature() 数据就绪后 返回 float,公式:-45 + 175×rawT/65535
mySensor.getHumidity() 数据就绪后 返回 float,公式:100×rawH/65535

① CRC-8 校验原理

//x^8+x^5+x^4+1 = 0x31
uint8_t SCD4x::computeCRC8(uint8_t data[], uint8_t len)
{
  uint8_t crc = 0xFF; //Init with 0xFF

  for (uint8_t x = 0; x < len; x++)
  {
    crc ^= data[x]; // XOR-in the next input byte

    for (uint8_t i = 0; i < 8; i++)
    {
      if ((crc & 0x80) != 0)
        crc = (uint8_t)((crc < < 1) ^ 0x31);
      else
        crc < <= 1;
    }
  }

  return crc; //No output reflection
}

SCD41 每两字节数据后附一字节 CRC,多项式为 x⁸+x⁵+x⁴+1 = 0x31,初始值 0xFF。SparkFun 库在每次 I2C 读取后自动验证,校验失败则 readMeasurement() 返回 false

②数据就绪轮询机制

bool SCD4x::readMeasurement(void)
{
  //Verify we have data from the sensor
  if (getDataReadyStatus() == false)
    return (false);

  scd4x_unsigned16Bytes_t tempCO2;
  tempCO2.unsigned16 = 0;
  scd4x_unsigned16Bytes_t  tempHumidity;
  tempHumidity.unsigned16 = 0;
  scd4x_unsigned16Bytes_t  tempTemperature;
  tempTemperature.unsigned16 = 0;

  _i2cPort->beginTransmission(SCD4x_ADDRESS);
  _i2cPort->write(SCD4x_COMMAND_READ_MEASUREMENT >> 8);   //MSB
  _i2cPort->write(SCD4x_COMMAND_READ_MEASUREMENT & 0xFF); //LSB
  if (_i2cPort->endTransmission() != 0)
    return (false); //Sensor did not ACK

  delay(1); //Datasheet specifies this

  #if SCD4x_ENABLE_DEBUGLOG
  uint8_t receivedBytes = (uint8_t)
  #endif // if SCD4x_ENABLE_DEBUGLOG
  _i2cPort->requestFrom((uint8_t)SCD4x_ADDRESS, (uint8_t)9);
  bool error = false;
  if (_i2cPort->available())
  {
    byte bytesToCrc[2];
    for (byte x = 0; x < 9; x++)
    {
      byte incoming = _i2cPort- >read();

      switch (x)
      {
      case 0:
      case 1:
        tempCO2.bytes[x == 0 ? 1 : 0] = incoming; // Store the two CO2 bytes in little-endian format
        bytesToCrc[x] = incoming; // Calculate the CRC on the two CO2 bytes in the order they arrive
        break;
      case 3:
      case 4:
        tempTemperature.bytes[x == 3 ? 1 : 0] = incoming; // Store the two T bytes in little-endian format
        bytesToCrc[x % 3] = incoming; // Calculate the CRC on the two T bytes in the order they arrive
        break;
      case 6:
      case 7:
        tempHumidity.bytes[x == 6 ? 1 : 0] = incoming; // Store the two RH bytes in little-endian format
        bytesToCrc[x % 3] = incoming; // Calculate the CRC on the two RH bytes in the order they arrive
        break;
      default: // x == 2, 5, 8
        //Validate CRC
        uint8_t foundCrc = computeCRC8(bytesToCrc, 2); // Calculate what the CRC should be for these two bytes
        if (foundCrc != incoming) // Does this match the CRC byte from the sensor?
        {
          #if SCD4x_ENABLE_DEBUGLOG
          if (_printDebug == true)
          {
            _debugPort->print(F("SCD4x::readMeasurement: found CRC in byte "));
            _debugPort->print(x);
            _debugPort->print(F(", expected 0x"));
            _debugPort->print(foundCrc, HEX);
            _debugPort->print(F(", got 0x"));
            _debugPort->println(incoming, HEX);
          }
          #endif // if SCD4x_ENABLE_DEBUGLOG
          error = true;
        }
        break;
      }
    }
  }
  else
  {
    #if SCD4x_ENABLE_DEBUGLOG
    if (_printDebug == true)
    {
      _debugPort->print(F("SCD4x::readMeasurement: no SCD4x data found from I2C, I2C claims we should receive "));
      _debugPort->print(receivedBytes);
      _debugPort->println(F(" bytes"));
    }
    #endif // if SCD4x_ENABLE_DEBUGLOG
    return (false);
  }

  if (error)
  {
    #if SCD4x_ENABLE_DEBUGLOG
    if (_printDebug == true)
      _debugPort->println(F("SCD4x::readMeasurement: encountered error reading SCD4x data."));
    #endif // if SCD4x_ENABLE_DEBUGLOG
    return (false);
  }
  //Now copy the int16s into their associated floats
  co2 = (float)tempCO2.unsigned16;
  temperature = -45 + (((float)tempTemperature.unsigned16) * 175 / 65536);
  humidity = ((float)tempHumidity.unsigned16) * 100 / 65536;

  //Mark our global variables as fresh
  co2HasBeenReported = false;
  humidityHasBeenReported = false;
  temperatureHasBeenReported = false;

  return (true); //Success! New data available in globals.
}

寄存器 bit[10:0] 全为 0 表示数据未就绪,任意一位为 1 表示可读

四、项目结果演示

4.1操作流程

初始化上电

wKgZPGoY7AiAJnNfAB6DmLcWIU8108.png

TFT 显示启动画面(CO₂ MONITOR + Lingzhi Lab 2026),约 1 秒后发出两声短鸣提示初始化成功

显示 "SCD41 Running..." 动态圆点,约 5 秒后第一帧数据到达,主界面自动切换为实时数据显示

主界面(Page 0)

上方 CO₂ 大圆环 + 下方温度/湿度小圆环同时展示,圆环颜色随数值实时变化,右侧等级标签跟随更新

wKgZO2oY7A2ABlQdACDMF4kPIsU801.png

旋转编码器右转:翻到下一页(CO₂详情 → 温湿度详情 → 系统信息 → 循环回主界面)

旋转编码器左转:翻到上一页

SW 按键按下

底栏 LIVE 切换为 MUTE(橙色),蜂鸣器静音;再次按下恢复 LIVE(绿色)

wKgZPGoY7AaANbMJAAWfWYN0uPg608.png

旋转电位器

可以调节蜂鸣器告警音量(最小 10% 占空比细声,最大 90% 占空比响亮)

wKgZPGoY7A6AMxeuAB2EWruGOxw639.png

CO₂溶度超过 1000ppm蜂鸣器单次告警;超过 1500ppm 双次;超过 2000ppm 三次急促告警,圆环变红

4.2 视频演示

https://live.csdn.net/v/528726?spm=1001.2014.3001.5501

本视频展示基于零知派标准板驱动 Sensirion SCD41 CO₂传感器的完整室内空气质量监测系统。演示内容包括:系统上电初始化流程、ST7789 TFT 浅色主题三环仪表盘实时刷新效果、旋转编码器左右翻页切换四个显示界面(主界面/CO₂详情/温湿度露点/系统信息)、电位器实时调节蜂鸣器音量、SW 按键静音切换、以及模拟高CO₂环境时的分级告警蜂鸣效果

五、工作原理讲解

SCD41 采用光声光谱(PAS)技术,是一种基于 NDIR 的 CO₂测量方案。其工作核心是CO₂分子对波长约 4.26 μm 的红外光有强烈的选择性吸收

wKgZPGoY7BGAOdJBAAVj_eV4rRI427.png

传感器内部:

红外光源周期性发射宽谱红外光

光穿过含有待测气体的腔体

特定波长的光被 CO₂分子吸收,剩余光被探测器接收

通过 Beer-Lambert 定律计算 CO₂浓度

A = ε × c × L

A: 吸光度 ε: 摩尔消光系数(CO₂固有属性) c: 浓度(即我们要测的值) L: 光程长度(腔体固定)

5.1 SCD41 通信时序

SCD41 的 I2C 地址固定为 0x62,不可更改。通信采用标准 I2C 协议,命令格式为 16 位命令字(MSB 先发)

①写命令时序

wKgZPGoY7RKAfiYDAABDjMLWTlE693.png

②基础命令

wKgZO2oY7S6ADuLzAACdn0U_1bY735.png

start_periodic_measurement(0x21B1)发出后必须等待 5 秒才能读取,发送其他命令(如 stop_periodic_measurement 0x3F86)后需等待 500ms 才能继续操作

③读数据时序

wKgZO2oY7w-AeCZ5AABObsq_NPU491.png

④一次完整测量读取的数据帧格式

总计 9 字节,每两字节数据附一字节 CRC-8

wKgZPGoY7yCANX9XAALw6jkkgFQ237.png

温度换算

wKgZPGoY7y6ALUo-AAASs_PePnE533.png

湿度换算

wKgZO2oY7zSAfKiPAAASQDm82IE602.png

5.2 自动自校准机制

SCD41 内置 ASC 算法,假设传感器在使用期间至少每周会暴露一次室外新鲜空气(约 400ppm)。ASC 会统计历史测量中的最低 CO₂值,并以此为基准自动漂移校准

wKgZPGoY7zyAYJcXAADHute8pKU933.png

5.3旋转编码器原理

EC11 是一种机械式增量旋转编码器,内部有两组相位差 90° 的触点(A相/CLK 和 B相/DT)。旋转时两相产生交替的高低电平变化,通过判断两相的相位超前/滞后关系确定旋转方向

①光电编码原理

wKgZO2oY70qAQvrhAAAndoD-H60889.png

编码器内部有一个带有栅格的光码盘,红外发射管和接收管分别位于码盘两侧、旋转时,栅格交替遮挡光线,产生脉冲信号

②A/B相信号产生

正交编码:两个光电传感器安装位置相差1/4个栅格间距,产生相位差90°的A相和B相信号

wKgZomUnpXiAPhdvAAX24toIEmk322.gif

正转:A 相脉冲的上升沿 / 下降沿超前B 相 90°, A 相先变化,B 相后变化

反转:B 相脉冲的上升沿 / 下降沿超前A 相 90°, B 相先变化,A 相后变化

③格雷码编码

将 A、B 两相的当前值拼成 2 位二进制(encoded = CLK<<1 | DT),与上一次状态一起组成 4 位索引((last<<2)|current),查 16 格表直接得到方向值 {+1/-1/0}

顺时针旋转时,A 引脚先于 B 引脚接地,格雷码按00→01→11→10→00的顺序循环

wKgZO2oY73eAAXILAABOIIG6hCA641.png

逆时针旋转时,B 引脚先于 A 引脚接地,格雷码按00→10→11→01→00的顺序循环

wKgZO2oY74GAFtMVAABPA5ezPAY554.png

每旋转一格产生 4 个有效边沿,累积满 ±4 才确认一次有效旋转

六、常见问题解答(FAQ)

Q1:码器旋转方向反了,或者非常灵敏转一格跳很多页?

A:方向反:交换 CLK 和 DT 的接线,或将 +1/-1 查表结果对换。跳页过多:检查 accSteps 阈值是否为 4,以及 ENC_COOLDOWN 是否设为 400ms

Q2:蜂鸣器一直响或完全不响?

A:确认使用的是无源蜂鸣器,有源蜂鸣器只能响单一固定频率,无法响应 PWM。完全不响检查 muteEnabled 是否为 true(底栏显示 MUTE),以及 D3 引脚接线

Q3:为什么CO₂数据长时间显示"--"?

A:SCD41 startPeriodicMeasurement 后首帧数据需要 5 秒。若超过 15 秒仍无数据,检查 A4/A5 接线和 供电。可临时开启 SparkFun 库的 Debug 输出:mySensor.enableDebugging(Serial)

项目资源整合

SCD41 数据手册: SCD4x Data Sheet

SCD4x库文件: sparkfun/SparkFun_SCD4x_Arduino_Library

审核编辑 黄宇

为您推荐

当前非电脑浏览器正常宽度,请使用移动设备访问本站!