零知派——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

审核编辑 黄宇

为您推荐

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