目录
一、系统接线部分
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 具体接线图

编码器 CLK和 DT接到支持attachInterrupt外部中断的引脚D6和D12
1.4 连接实物图
二、安装与使用部分
2.1 开源平台-输入"SCD41"并搜索-代码下载自动打开
2.2 连接-验证-上传
2.3 调试-串口监视器

三、代码讲解部分
本项目代码基于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(); }
}
系统流程图

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操作流程
初始化上电

TFT 显示启动画面(CO₂ MONITOR + Lingzhi Lab 2026),约 1 秒后发出两声短鸣提示初始化成功
显示 "SCD41 Running..." 动态圆点,约 5 秒后第一帧数据到达,主界面自动切换为实时数据显示
主界面(Page 0)
上方 CO₂ 大圆环 + 下方温度/湿度小圆环同时展示,圆环颜色随数值实时变化,右侧等级标签跟随更新

旋转编码器右转:翻到下一页(CO₂详情 → 温湿度详情 → 系统信息 → 循环回主界面)
旋转编码器左转:翻到上一页
SW 按键按下
底栏 LIVE 切换为 MUTE(橙色),蜂鸣器静音;再次按下恢复 LIVE(绿色)

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

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 的红外光有强烈的选择性吸收

传感器内部:
红外光源周期性发射宽谱红外光
光穿过含有待测气体的腔体
特定波长的光被 CO₂分子吸收,剩余光被探测器接收
通过 Beer-Lambert 定律计算 CO₂浓度
A = ε × c × L
A: 吸光度 ε: 摩尔消光系数(CO₂固有属性) c: 浓度(即我们要测的值) L: 光程长度(腔体固定)
5.1 SCD41 通信时序
SCD41 的 I2C 地址固定为 0x62,不可更改。通信采用标准 I2C 协议,命令格式为 16 位命令字(MSB 先发)
①写命令时序

②基础命令

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

④一次完整测量读取的数据帧格式
总计 9 字节,每两字节数据附一字节 CRC-8

温度换算

湿度换算

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

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

编码器内部有一个带有栅格的光码盘,红外发射管和接收管分别位于码盘两侧、旋转时,栅格交替遮挡光线,产生脉冲信号
②A/B相信号产生
正交编码:两个光电传感器安装位置相差1/4个栅格间距,产生相位差90°的A相和B相信号

正转: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的顺序循环

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

每旋转一格产生 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
审核编辑 黄宇