Sensirion SCD4x · NDIR CO2 传感器 · I2C

SCD41 蓝板 · SCD40 紫板
CO2 传感器模块接线与代码

蓝板是 SCD41(SKU 0456),紫板是 SCD40(SKU 0455),尺寸都是 21.6 × 13.4 mm, 四线 I2C 接口,能同时输出 CO2 浓度、温度和湿度。两块板引脚、板载元件和代码完全通用,可直接互换, 区别只在芯片量程和是否支持单次测量。 供电范围 2.4 – 5.5 V,3.3V 和 5V 都能直接用(Sensirion 数据手册)。 这一页整理引脚定义、Arduino UNO / ESP32 / STM32 / 树莓派四种接线图、可下载源码, 以及205 mA 峰值电流和VCC 该接 3.3V 还是 5V这两个最容易踩的坑。

更新于 2026-08-01

🔥 打开 ESP Launchpad 一键烧录(乐鑫官方) 查看其它烧录方式
1

蓝板 SCD41 还是紫板 SCD40

最简单:看板子颜色和丝印

本店这两块板从外观就能分清,不用烧程序:

板子颜色芯片SKU板上丝印CO2 规格量程
蓝板 SCD41 0456 SCD41 CO2 IIC 400 – 5000 ppm
紫板 SCD40 0455 SCD40 CO2 IIC 400 – 2000 ppm

丝印在板子背面,字是反着看的。两块板的尺寸、引脚、板载元件和代码完全一样, 可以直接互换,只是量程规格和单次测量能力不同——怎么选见第 2 节。

SCD41 蓝板实物图:反面丝印 SCD41 CO2 IIC,正面引脚 GND VCC SCL SDA,尺寸 21.6mm×13.4mm
蓝板 · SCD41 SKU 0456 · 背面丝印 SCD41 CO2 IIC
SCD40 紫板实物图:反面丝印 SCD40 CO2 IIC,正面引脚 GND VCC SCL SDA,尺寸 21.6mm×13.4mm
紫板 · SCD40 SKU 0455 · 背面丝印 SCD40 CO2 IIC
看图注意

实物图左上是背面(丝印 SCD41/SCD40 CO2 IIC,引脚字是反的), 右下是正面(引脚从左到右 GND / VCC / SCL / SDA)。 排针是单独附带的,需要自己焊。两块板都是 21.6 × 13.4 mm、2 克。

✓ 已实测:蓝板 SCD41(0456)真实哈气响应

对着蓝板模块哈了一口气,实测 CO2 读数从 1114 ppm 一路涨到 1931 ppm (涨幅约 820 ppm),确认这颗传感器不是返回固定值,是在真实检测气体浓度变化。 测试时读到的芯片序列号:921215073B57。

约 23 秒无声预览:先看 1114 ppm,再哈气,读数跳到 1553 ppm;完整教程里还有接线、型号判断和代码讲解。

这次测试用的是自制 UI 固件(另有专门项目,不是下方第 6 节的下载代码), 上面短视频只做快速确认现象;完整教程发布后再补 B 站链接。第 6 节的官方例程和精简版代码本身尚未单独跑过, 请见各自的验证说明。

方法 2:看芯片壳上的激光刻字

新批号的传感器,型号直接激光刻在顶部金属壳上,旁边还有一个二维码(批次信息)。 拿到手先翻过来看一眼,比烧程序去试快得多。

SCD41 与 SCD40 芯片金属外壳激光刻字对比示意图,型号刻在壳体上,旁边有二维码
新批号在金属壳上刻有 SCD41 或 SCD40 字样,右下角是二维码。图为示意,实物刻字位置以手上模块为准。
看不到刻字怎么办

老批号可能没有刻字,或者刻字被模块的排针、外壳挡住看不清。 这种情况用下面两种软件方法识别,不用拆板子。

方法 3 / 4:用程序读出来

刻字看不清时,用 I2C 命令识别。建议两个都跑一遍:

方法命令判据可靠性
3. 单次测量命令
最关键的软件判据
0x219D
measure_single_shot
命令被接受并能读出有效数据 → SCD41
命令无应答或读不出数据 → SCD40
高。SCD40 本身不支持这条命令
4. 变体寄存器 0x202F
get_sensor_variant
返回值高 4 位:0 → SCD40,1 → SCD41 仅较新固件支持,老固件读不到(待确认)
现成的自测程序

下面的代码包里有一份 SCD4x_识别是40还是41.ino, 烧进 Arduino 或 ESP32、打开串口监视器(115200)就会自动跑完上面两个方法并直接给出结论。 见 第 6 节代码下载。

2

SCD40 与 SCD41 到底差在哪

两颗都是 Sensirion SCD4x 系列的光声式 NDIR CO2 传感器,同一个封装、同一套基础命令。真正的差别集中在量程规格和单次测量能力两点上。本店紫板(0455)用 SCD40,蓝板(0456)用 SCD41。

项目SCD40SCD41
CO2 规格量程400 – 2000 ppm400 – 5000 ppm
单次测量 0x219D不支持支持
低功耗间歇测量受限可用单次测量做低功耗
I2C 地址0x620x62
引脚 / 封装完全相同,可直接互换
周期测量周期5 秒一组
基础命令集相同(启动 / 读数 / 停止 / 序列号 / 校准)
怎么选

室内空气质量、新风联动、CO2 报警这类应用,日常浓度在 400–2000 ppm 之间, SCD40 完全够用。
密闭空间、车内、发酵、温室、人多的会议室可能冲到 2000 ppm 以上, 或者需要低功耗电池供电,那就要 SCD41。

精度数值请以数据手册为准

上表的量程按 Sensirion 官方规格整理。具体精度指标(如 ±(40 ppm + 5%)) 随批次和固件版本可能不同,需要写进方案文档时请查对应型号的最新数据手册, 不要直接引用本页数字。本页没有列出的参数一律待确认,不做推测。

3

引脚定义

两块板引脚完全相同,正面丝印从左到右依次是:

  • GND地。所有主控接线都必须共地,这是最常见的疏漏。
  • VCC电源正。芯片供电范围 2.4 – 5.5 V,3.3V 和 5V 都行;接和主控逻辑相同的那一路(UNO 接 5V,ESP32/STM32/树莓派接 3.3V)。详见第 4 节。
  • SCLI2C 时钟线。
  • SDAI2C 数据线。
  • NC空脚,不接。
背面丝印是反的,别看错

板子背面丝印顺序是 SDA / SCL / VCC / GND,和正面相反——那是从背面看的顺序。 背面同时印着芯片型号(SCD41 CO2 IIC 或 SCD40 CO2 IIC),可以用来区分两块板。 接线以正面丝印为准,或者直接对着焊盘旁边的字接。

板载元件:上拉和去耦都已经做好了

两块板的板载元件相同,都带了:

  • 10K 上拉电阻 × 2 —— 接在 SDA 和 SCL 上,上拉到 VCC。 所以不用你另外加上拉电阻,直接四根线接上去就行。
  • 100nF 去耦电容 × 1 —— 给传感器供电滤波用。

板上没有 LDO 稳压和电平转换芯片,但并不需要:SCD4x 本身 2.4 – 5.5 V 都能供电, 而上拉电阻是接到 VCC 的——这正是「VCC 要接和主控逻辑相同那一路」的原因,详见第 4 节。

4

供电与电平:最容易踩的两个坑

坑 1 · 峰值电流约 205 mA

SCD4x 是光声式传感器,每次测量要驱动光源,瞬间峰值电流可达约 205 mA。 这个数字比大多数 I2C 传感器高一个数量级,是很多「读数乱跳」「模块一会儿认得到一会儿认不到」的真正原因。

这也意味着:不是随便找个脚给电就行,要看那个脚能出多少电流。

坑 2 · VCC 接 3.3V 还是 5V —— 都可以

按 Sensirion 数据手册,SCD4x 的供电范围是 2.4 – 5.5 V, 3.3V 和 5V 都在范围内,接哪个都能工作。

SCL / SDA 是开漏输出,I2C 的电平由上拉电阻接到哪一路电源决定。 所以有一条很省事的原则:

模块 VCC 接到和主控逻辑相同的那一路电源,I2C 电平自然就一致,不需要电平转换。

主控VCC 接为什么
Arduino UNO / Nano
5V 逻辑
5V 电平和 UNO 一致,直接接 A4 / A5 即可;
而且 5V 脚电流够,见下方说明
ESP32 / STM32 / 树莓派
3.3V 逻辑
3.3V 同理,电平一致,直接接

ESP32 板上的输出本来就是 3.3V,没有 5V 可选,直接接 3V3。

坑 3 · UNO 上别接 3.3V 那个脚

Arduino UNO 上 5V 和 3.3V 两个输出脚都有,但给 SCD4x 供电要用 5V 那个:

UNO 的脚电流能力结论
3.3V 约 50 mA 不要用。205 mA 峰值一来就掉压,表现为扫不到地址、读数乱跳
5V USB 供电时整板约 500 mA 用这个。电流够,电平也和 UNO 一致

3.3V 系统(ESP32 / STM32 / 树莓派)同样要注意 3.3V 那一路能不能扛住 205 mA 峰值。 如果同时还带屏幕、WiFi、电机,建议给 3.3V 加大电容,或者用外部电源单独供并共地。

5

四种主控接线图

下面四张图的连线一一对应、不交叉,照着接即可。以正面丝印为准,蓝板紫板接法完全一样。

SCD41 接 Arduino UNO 的接线图:GND 对 GND,VCC 按模块类型接 5V 或外部 3.3V,SCL 对 A5,SDA 对 A4
Arduino UNO / Nano。VCC 接哪里取决于模块带不带稳压:带稳压接 5V,裸板要外部 3.3V。UNO 自己的 3.3V 脚只有约 50 mA,带不动。
SCD41 接 ESP32 的接线图:GND 对 GND,VCC 对 3V3,SCL 对 GPIO22,SDA 对 GPIO21
ESP32。GPIO21 / GPIO22 是默认 I2C 引脚,程序里可以改成其它脚。
SCD41 接 STM32F103C8T6 的接线图:GND 对 GND,VCC 对 3.3V,SCL 对 PB6,SDA 对 PB7
STM32F103C8T6。CubeMX 里把 I2C1 速度设成 100 kHz。
SCD41 接树莓派的接线图:GND 对 6 号脚,VCC 对 1 号脚 3.3V,SCL 对 5 号脚,SDA 对 3 号脚
树莓派。VCC 接 1 号脚的 3.3V,不要接 2 号脚的 5V。
6

代码下载

优先用官方例程

Sensirion 官方给四个平台都提供了现成的驱动和示例,由厂家维护、跟着固件更新, 比任何第三方教程都可靠,建议优先用这些。下面先列官方仓库,再放本站整理的精简版。

一、官方驱动与示例(推荐)

平台官方仓库 / 包怎么用
Arduino / ESP32 Sensirion/arduino-i2c-scd4x IDE 库管理器搜 Sensirion I2C SCD4x 安装,示例在
文件 → 示例 → Sensirion I2C SCD4x → exampleUsage
树莓派(C) Sensirion/raspberry-pi-i2c-scd4x 下载解压后:
cd example-usage && make && ./scd4x_i2c_example_usage
树莓派(Python) Sensirion/python-i2c-scd4x
pip install sensirion-i2c-scd4x
默认走 /dev/i2c-1,仓库 examples/ 里有完整示例
STM32 / 其它单片机 Sensirion/embedded-i2c-scd4x 平台无关的 C 驱动,只要实现 sensirion_i2c_hal 里几个函数
(用 HAL_I2C_Master_Transmit / Receive 对接即可)
库版本坑:头文件名改过

当前官方库的头文件是 SensirionI2cScd4x.h(小写的 c), 类名是 SensirionI2cScd4x。 网上不少老教程写的是 SensirionI2CScd4x.h(大写 C)和旧的三段式序列号接口, 那是旧版本的写法,照抄会编译不过。以库自带示例为准。

二、本站整理的打包下载

说明

Arduino 那一份是官方示例原样收录(保留 Sensirion BSD-3 版权声明), 只在开头加了中文接线和供电注释。 其余几份是本站写的精简版:不依赖第三方库、直接发 I2C 命令, 方便你一步步看哪里不通——这几份尚未实机验证,请以实测为准, 正式项目建议用上面的官方驱动。

Arduino / ESP32 官方例程

Sensirion 官方 exampleUsage 原版,加中文接线与供电说明。

下载

识别 40 还是 41

看不到芯片刻字时用,串口直接给结论。精简版,未实机验证。

下载

ESP32 精简版

不依赖库,直接发 I2C 命令,可自定义引脚。未实机验证。

下载

STM32 HAL 精简版

scd41.c / scd41.h,含 CubeMX 配置说明。未实机验证。
正式项目建议用官方 embedded-i2c-scd4x。

下载

树莓派 smbus2 精简版

单文件 Python,含 raspi-config 与 i2cdetect 步骤。未实机验证。

下载

全部合集

上面所有文件打包,含说明文档。

下载

三、本期视频演示用的完整UI固件源码

触摸中文UI,共579行

上面几份是精简的I2C命令示例,这一份是本站B站视频里实际演示的完整固件—— ESP32-2432S028R(2.8寸黄屏CYD)接SCD41,四页触摸中文界面(实时读数/CO2趋势图/传感器信息/ 温度偏移说明),含型号判定(单次测量命令)、CRC-8校验、原始值换算这几段视频里讲过的代码。 直接在下面框里滚动查看,不用下载;需要烧到板子上验证的话,用乐鑫官方 ESP Launchpad 一键烧录;本站也留了个自制版当备选,功能没有官方全。

/*
  ESP32-2432S028R(2.8寸黄屏CYD) + SCD41 CO2传感器 触摸UI演示(中文版)

  接线:模块 VCC->3.3V,GND->GND,SDA->IO27,SCL->IO22
       (跟 0911 ATECC608A / BMM150 / DPS310 项目同一路 CN1 I2C排针)
       ⚠ 别照抄资料网页上那份通用ESP32例程的 SDA=GPIO21——CYD板上21是屏幕背光
         (TFT_BL=21),接SDA会跟背光打架。
  屏幕/触摸引脚沿用 02_中文触摸效率时钟、0911 ATECC608A 已验证过的配置。
  中文字体用 U8g2_for_TFT_eSPI + U8g2自带的文泉驿点阵字体(wqy16/wqy12),
  不用额外生成字库文件,字库数据直接编译进固件。

  交互:
    - 单击:翻页(实时读数 -> CO2趋势 -> 芯片信息 -> 温度说明 -> 回实时读数)
    - 长按2秒:重新扫描传感器(软复位)
      (这里用2秒不用0911那边的5秒:0911的5秒是为了防止手滑触发Lock这种不可逆操作,
       SCD41这个UI里没有任何不可逆动作,用2秒手感更顺。)

  ⚠ 本演示刻意不包含以下两类命令,要加得先单独评估:
     · persist_settings (0x3615) —— 把温度偏移/海拔/ASC开关写进芯片EEPROM。
       官方datasheet只保证EEPROM约2000次擦写寿命,演示程序反复调用会折寿。
       (这些设置默认只存在RAM,掉电回默认值,不发这条不会写EEPROM。)
     · perform_forced_recalibration (0x362F) —— 强制重校准,改写校准基准,
       影响之后所有读数,且要在已知CO2浓度的环境里做才有意义。
*/

#include <SPI.h>
#include <TFT_eSPI.h>
#include <U8g2_for_TFT_eSPI.h>
#include <Wire.h>

#define I2C_SDA 27
#define I2C_SCL 22

#define TOUCH_CS  33
#define TOUCH_IRQ 36

#define HOLD_MS 2000UL

#define SCREEN_W 320
#define SCREEN_H 240

#define COL_BG      TFT_BLACK
#define COL_TEXT    TFT_WHITE
#define COL_DIM     TFT_DARKGREY
#define COL_OK      TFT_GREEN
#define COL_WARN    TFT_RED
#define COL_ACCENT  TFT_YELLOW
#define COL_ORANGE  0xFD20
#define COL_CYAN    TFT_CYAN
#define COL_BAR_BG  0x2104   // 深灰

TFT_eSPI tft = TFT_eSPI();
U8g2_for_TFT_eSPI u8f;

// ---------------- SCD4x 命令与底层读写 ----------------
static const uint8_t SCD4X_ADDR = 0x62;

static const uint16_t CMD_STOP_PERIODIC  = 0x3F86;
static const uint16_t CMD_GET_SERIAL     = 0x3682;
static const uint16_t CMD_GET_VARIANT    = 0x202F;
static const uint16_t CMD_SINGLE_SHOT    = 0x219D;   // 仅SCD41支持
static const uint16_t CMD_START_PERIODIC = 0x21B1;
static const uint16_t CMD_READ_MEASURE   = 0xEC05;
static const uint16_t CMD_DATA_READY     = 0xE4B8;

/* Sensirion CRC-8:多项式0x31,初始值0xFF。每2字节数据跟1字节CRC。 */
static uint8_t sensirionCrc(const uint8_t *data, uint8_t len) {
  uint8_t crc = 0xFF;
  for (uint8_t i = 0; i < len; i++) {
    crc ^= data[i];
    for (uint8_t b = 0; b < 8; b++) {
      crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
    }
  }
  return crc;
}

static uint8_t sendCmd(uint16_t cmd) {
  Wire.beginTransmission(SCD4X_ADDR);
  Wire.write((uint8_t)(cmd >> 8));
  Wire.write((uint8_t)(cmd & 0xFF));
  return Wire.endTransmission();
}

static bool readWords(uint16_t *out, uint8_t count) {
  uint8_t need = count * 3;
  if (Wire.requestFrom((int)SCD4X_ADDR, (int)need) != need) return false;
  for (uint8_t i = 0; i < count; i++) {
    uint8_t b[2];
    b[0] = Wire.read();
    b[1] = Wire.read();
    uint8_t c = Wire.read();
    if (sensirionCrc(b, 2) != c) return false;
    out[i] = ((uint16_t)b[0] << 8) | b[1];
  }
  return true;
}

// ---------------- 应用状态 ----------------
enum AppState {
  STATE_DETECT,
  STATE_ERROR,
  STATE_LIVE,     // 实时读数(主页)
  STATE_TREND,    // CO2趋势条形图
  STATE_INFO,     // 芯片信息
  STATE_ABOUT,    // 温度偏移/自发热说明
};

static AppState state = STATE_DETECT;

static String serialNumberStr = "-";
static String variantStr      = "SCD4x";
static bool   singleShotOk    = false;   // measure_single_shot是否被接受(SCD41判据)

static uint16_t co2Ppm   = 0;
static float    tempC    = 0.0f;
static float    humidity = 0.0f;
static bool     haveData = false;

// CO2历史,趋势页用。5秒一个点,60个点约5分钟。
#define HIST_N 60
static uint16_t hist[HIST_N];
static uint8_t  histCount = 0;
static uint8_t  histHead  = 0;

static void histPush(uint16_t v) {
  hist[histHead] = v;
  histHead = (histHead + 1) % HIST_N;
  if (histCount < HIST_N) histCount++;
}

/* 取第i个历史点,i=0是最旧的 */
static uint16_t histAt(uint8_t i) {
  uint8_t start = (uint8_t)((histHead + HIST_N - histCount) % HIST_N);
  return hist[(start + i) % HIST_N];
}

// ---------------- 空气质量分级 ----------------
// 室内CO2常用参考档位:户外空气约400ppm,室内长期高于1000ppm就该通风了。
static const char *qualityText(uint16_t ppm) {
  if (ppm < 600)  return "优 · 空气清新";
  if (ppm < 800)  return "良 · 正常室内";
  if (ppm < 1000) return "一般 · 建议通风";
  if (ppm < 1500) return "偏差 · 该开窗了";
  if (ppm < 2000) return "差 · 明显闷";
  return "很差 · 尽快通风";
}

static uint16_t qualityColor(uint16_t ppm) {
  if (ppm < 800)  return COL_OK;
  if (ppm < 1000) return COL_ACCENT;
  if (ppm < 1500) return COL_ORANGE;
  return COL_WARN;
}

// ---------------- 触摸:只用IRQ判断"有没有摁下",不做坐标映射 ----------------
// XPT2046在CS拉低期间,只要屏幕被摁着PENIRQ就一直是低电平——够用来做
// "单击/长按"这种不需要具体坐标的交互,省掉坐标标定(跟0911项目同一思路)。
static void touchInit() {
  pinMode(TOUCH_CS, OUTPUT);
  digitalWrite(TOUCH_CS, LOW);
  pinMode(TOUCH_IRQ, INPUT);
}

static bool isTouchedNow() {
  return digitalRead(TOUCH_IRQ) == LOW;
}

// ---------------- 绘制小工具 ----------------
static int cursorY;
static void resetCursor(int y) { cursorY = y; }
static void setPrimaryFont()   { u8f.setFont(u8g2_font_wqy16_t_gb2312); }   // 16px
static void setSecondaryFont() { u8f.setFont(u8g2_font_wqy12_t_gb2312); }   // 12px

static void drawHeader(const char *title, uint16_t color) {
  tft.fillScreen(COL_BG);
  u8f.setForegroundColor(color);
  setPrimaryFont();
  u8f.drawUTF8(12, 26, title);
  tft.drawFastHLine(0, 34, SCREEN_W, COL_DIM);
  resetCursor(56);
}

// tier: 2=正文(16px),1=小字提示(12px)
static void printLine(const String &text, uint16_t color, uint8_t tier = 2) {
  u8f.setForegroundColor(color);
  if (tier >= 2) setPrimaryFont(); else setSecondaryFont();
  u8f.drawUTF8(12, cursorY, text.c_str());
  cursorY += (tier >= 2) ? 24 : 18;
}

static void printGap(int px) { cursorY += px; }

static void drawFooterHint(const char *text) {
  u8f.setForegroundColor(COL_DIM);
  setSecondaryFont();
  u8f.drawUTF8(12, SCREEN_H - 8, text);
}

static void drawProgressBar(uint32_t elapsedMs) {
  const int x = 12, y = SCREEN_H - 30, w = SCREEN_W - 24, h = 10;
  uint32_t clamped = elapsedMs > HOLD_MS ? HOLD_MS : elapsedMs;
  int fillW = (int)((uint32_t)w * clamped / HOLD_MS);
  tft.fillRect(x, y, w, h, COL_BAR_BG);
  if (fillW > 0) tft.fillRect(x, y, fillW, h, COL_ACCENT);
  tft.drawRect(x, y, w, h, COL_DIM);
}

// ---------------- 各页面 ----------------
static void renderDetectPage() {
  drawHeader("检测中...", COL_TEXT);
  printLine("正在查找 SCD41", COL_TEXT);
}

static void renderErrorPage() {
  drawHeader("未找到传感器", COL_WARN);
  printLine("请检查接线:", COL_TEXT);
  printLine("SDA=IO27  SCL=IO22", COL_TEXT);
  printLine("VCC=3.3V  地址=0x62", COL_TEXT);
  printGap(6);
  printLine("VCC不要接5V,会打坏IO", COL_ACCENT, 1);
  drawFooterHint("长按2秒重新扫描");
}

/* 实时读数页:只重画数值区,避免整屏刷新闪烁 */
static void renderLiveStatic() {
  drawHeader("实时读数", COL_CYAN);
  drawFooterHint("轻触:翻页    长按2秒:重新扫描");
}

static void renderLiveValues() {
  // 清掉数值区(标题线以下、底部提示以上)
  tft.fillRect(0, 38, SCREEN_W, SCREEN_H - 38 - 22, COL_BG);

  if (!haveData) {
    u8f.setForegroundColor(COL_DIM);
    setPrimaryFont();
    u8f.drawUTF8(12, 80, "等待第一组数据...");
    u8f.setForegroundColor(COL_DIM);
    setSecondaryFont();
    u8f.drawUTF8(12, 104, "周期测量每5秒出一次");
    return;
  }

  uint16_t qc = qualityColor(co2Ppm);

  // 大字CO2数值:用TFT_eSPI内置7段字体(font 7),比点阵字体大得多,隔着镜头也看得清
  tft.setTextColor(qc, COL_BG);
  tft.setTextDatum(TL_DATUM);
  tft.drawNumber(co2Ppm, 14, 46, 7);

  // ppm单位 + CO2标签
  u8f.setForegroundColor(COL_TEXT);
  setPrimaryFont();
  u8f.drawUTF8(200, 88, "ppm");
  u8f.setForegroundColor(COL_DIM);
  setSecondaryFont();
  u8f.drawUTF8(200, 60, "CO2 二氧化碳");

  // 空气质量条
  const int barX = 12, barY = 104, barW = SCREEN_W - 24, barH = 12;
  // 400ppm(户外)~2000ppm 映射成整条
  int v = (int)co2Ppm - 400;
  if (v < 0) v = 0;
  int fill = (int)((long)barW * v / 1600);
  if (fill > barW) fill = barW;
  tft.fillRect(barX, barY, barW, barH, COL_BAR_BG);
  if (fill > 0) tft.fillRect(barX, barY, fill, barH, qc);
  tft.drawRect(barX, barY, barW, barH, COL_DIM);

  u8f.setForegroundColor(qc);
  setPrimaryFont();
  u8f.drawUTF8(12, 142, qualityText(co2Ppm));

  // 温湿度
  char buf[48];
  u8f.setForegroundColor(COL_TEXT);
  setPrimaryFont();
  snprintf(buf, sizeof(buf), "温度 %.1f C", tempC);
  u8f.drawUTF8(12, 172, buf);
  snprintf(buf, sizeof(buf), "湿度 %.1f %%", humidity);
  u8f.drawUTF8(170, 172, buf);

  u8f.setForegroundColor(COL_DIM);
  setSecondaryFont();
  u8f.drawUTF8(12, 192, "温度含自发热,偏高属正常,见第4页");
}

static void renderTrendPage() {
  drawHeader("CO2 趋势 (近5分钟)", COL_CYAN);

  const int gx = 12, gy = 46, gw = SCREEN_W - 24, gh = 130;
  tft.drawRect(gx, gy, gw, gh, COL_DIM);

  if (histCount < 2) {
    u8f.setForegroundColor(COL_DIM);
    setPrimaryFont();
    u8f.drawUTF8(gx + 10, gy + 60, "数据还不够,等几个周期");
    drawFooterHint("轻触:翻页    长按2秒:重新扫描");
    return;
  }

  // 纵轴范围:跟着数据自适应,但至少留400~1000这个基本区间
  uint16_t lo = 65535, hi = 0;
  for (uint8_t i = 0; i < histCount; i++) {
    uint16_t v = histAt(i);
    if (v < lo) lo = v;
    if (v > hi) hi = v;
  }
  if (lo > 400) lo = 400;
  if (hi < 1000) hi = 1000;
  uint16_t span = (uint16_t)(hi - lo);
  if (span < 100) span = 100;

  // 1000ppm参考线(室内建议通风的常用门槛)
  int refY = gy + gh - (int)((long)gh * (1000 - lo) / span);
  if (refY > gy && refY < gy + gh) {
    for (int x = gx + 2; x < gx + gw - 2; x += 6) {
      tft.drawPixel(x, refY, COL_ACCENT);
    }
    u8f.setForegroundColor(COL_ACCENT);
    setSecondaryFont();
    u8f.drawUTF8(gx + gw - 46, refY - 3, "1000");
  }

  // 条形图:每个历史点一根竖条
  int barW = gw / HIST_N;
  if (barW < 1) barW = 1;
  for (uint8_t i = 0; i < histCount; i++) {
    uint16_t v = histAt(i);
    int h = (int)((long)(gh - 4) * (v - lo) / span);
    if (h < 1) h = 1;
    if (h > gh - 4) h = gh - 4;
    int x = gx + 2 + i * barW;
    tft.fillRect(x, gy + gh - 2 - h, barW > 1 ? barW - 1 : 1, h, qualityColor(v));
  }

  char buf[48];
  u8f.setForegroundColor(COL_DIM);
  setSecondaryFont();
  snprintf(buf, sizeof(buf), "最低 %u    最高 %u    共 %u 个点", lo, hi, histCount);
  u8f.drawUTF8(12, gy + gh + 16, buf);

  drawFooterHint("轻触:翻页    长按2秒:重新扫描");
}

static void renderInfoPage() {
  drawHeader("传感器信息", COL_OK);
  printLine("型号:" + variantStr, COL_TEXT);
  printLine("序列号:", COL_DIM, 1);
  printLine(serialNumberStr, COL_TEXT);
  printGap(4);
  printLine("地址 0x62   I2C 100kHz", COL_TEXT, 1);
  printGap(4);
  if (singleShotOk) {
    printLine("单次测量命令:支持", COL_OK, 1);
    printLine("-> 判定为 SCD41", COL_OK, 1);
  } else {
    printLine("单次测量命令:不支持", COL_WARN, 1);
    printLine("-> 判定为 SCD40", COL_WARN, 1);
  }
  drawFooterHint("轻触:翻页    长按2秒:重新扫描");
}

static void renderAboutPage() {
  drawHeader("温度为什么偏高?", COL_ACCENT);
  printLine("芯片自己会发热,测到的", COL_TEXT, 1);
  printLine("温度比环境高,官方默认", COL_TEXT, 1);
  printLine("扣掉4度作为温度偏移。", COL_TEXT, 1);
  printGap(6);
  printLine("装进外壳后热量更散不掉,", COL_TEXT, 1);
  printLine("偏移值要按实际情况重调。", COL_TEXT, 1);
  printGap(6);
  printLine("本演示不写EEPROM:", COL_ACCENT, 1);
  printLine("温度偏移/海拔/自校准开关", COL_DIM, 1);
  printLine("默认只存RAM,掉电就回默认,", COL_DIM, 1);
  printLine("不发persist_settings不烧写。", COL_DIM, 1);
  drawFooterHint("轻触:回实时读数    长按2秒:重新扫描");
}

static void renderCurrentState() {
  switch (state) {
    case STATE_DETECT: renderDetectPage(); break;
    case STATE_ERROR:  renderErrorPage(); break;
    case STATE_LIVE:   renderLiveStatic(); renderLiveValues(); break;
    case STATE_TREND:  renderTrendPage(); break;
    case STATE_INFO:   renderInfoPage(); break;
    case STATE_ABOUT:  renderAboutPage(); break;
  }
}

// ---------------- 触摸事件 -> 状态机 ----------------
static void onShortTap() {
  switch (state) {
    case STATE_LIVE:  state = STATE_TREND; renderCurrentState(); break;
    case STATE_TREND: state = STATE_INFO;  renderCurrentState(); break;
    case STATE_INFO:  state = STATE_ABOUT; renderCurrentState(); break;
    case STATE_ABOUT: state = STATE_LIVE;  renderCurrentState(); break;
    default: break;   // 错误页只能长按重试
  }
}

static void onLongPressComplete() {
  ESP.restart();
}

// ---------------- 传感器初始化 ----------------
static bool sensorInit() {
  Wire.beginTransmission(SCD4X_ADDR);
  if (Wire.endTransmission() != 0) return false;

  // 上电后可能还在跑周期测量,不停下来后面的命令会被忽略
  sendCmd(CMD_STOP_PERIODIC);
  delay(500);

  uint16_t sn[3];
  if (sendCmd(CMD_GET_SERIAL) == 0) {
    delay(5);
    if (readWords(sn, 3)) {
      char buf[16];
      snprintf(buf, sizeof(buf), "%04X%04X%04X", sn[0], sn[1], sn[2]);
      serialNumberStr = String(buf);
    }
  }

  // 方法A:变体寄存器(新固件才有)
  if (sendCmd(CMD_GET_VARIANT) == 0) {
    delay(5);
    uint16_t v;
    if (readWords(&v, 1)) {
      uint8_t variant = (uint8_t)((v >> 12) & 0x0F);
      if (variant == 0)      variantStr = "SCD40";
      else if (variant == 1) variantStr = "SCD41";
    }
  }

  // 方法B:单次测量命令,只有SCD41支持——关键判据
  if (sendCmd(CMD_SINGLE_SHOT) == 0) {
    delay(5000);
    uint16_t raw[3];
    if (sendCmd(CMD_READ_MEASURE) == 0) {
      delay(2);
      if (readWords(raw, 3) && raw[0] != 0) {
        singleShotOk = true;
        variantStr = "SCD41";
      }
    }
  }

  if (sendCmd(CMD_START_PERIODIC) != 0) return false;
  return true;
}

/* 有新数据就取一组,没有就直接返回。5秒出一次。 */
static bool sensorPoll() {
  uint16_t ready = 0;
  if (sendCmd(CMD_DATA_READY) != 0) return false;
  delay(2);
  if (!readWords(&ready, 1)) return false;
  if ((ready & 0x07FF) == 0) return false;   // 低11位为0=还没准备好

  if (sendCmd(CMD_READ_MEASURE) != 0) return false;
  delay(2);
  uint16_t raw[3];
  if (!readWords(raw, 3)) return false;
  if (raw[0] == 0) return false;

  co2Ppm   = raw[0];
  tempC    = -45.0f + 175.0f * (float)raw[1] / 65535.0f;
  humidity = 100.0f * (float)raw[2] / 65535.0f;
  haveData = true;
  histPush(co2Ppm);

  Serial.printf("CO2: %u ppm\t温度: %.1f C\t湿度: %.1f %%RH\n",
                co2Ppm, tempC, humidity);
  return true;
}

void setup() {
  Serial.begin(115200);
  delay(300);

  tft.init();
  tft.setRotation(1);
  tft.fillScreen(COL_BG);

  u8f.begin(tft);
  u8f.setFontMode(1);       // 透明背景
  u8f.setFontDirection(0);

  touchInit();

  Wire.begin(I2C_SDA, I2C_SCL);
  Wire.setClock(100000);    // SCD4x建议100kHz,别拉到400kHz

  renderDetectPage();

  if (!sensorInit()) {
    Serial.println("SCD41 not found");
    state = STATE_ERROR;
    renderCurrentState();
    return;
  }

  Serial.print("S/N: ");     Serial.println(serialNumberStr);
  Serial.print("Variant: "); Serial.println(variantStr);

  state = STATE_LIVE;
  renderCurrentState();
}

void loop() {
  static uint32_t lastPoll = 0;
  static uint32_t lastSample = 0;
  static bool wasDown = false;
  static uint32_t pressStartMs = 0;
  static bool longFired = false;
  static uint32_t lastActionMs = 0;
  static uint8_t pollFailCount = 0;   // 连续几次sensorPoll()失败,用来判定"传感器被拔了"

  uint32_t now = millis();

  // 每5秒取一次传感器数据(周期测量的出数节奏)
  if (state != STATE_ERROR && state != STATE_DETECT && now - lastSample >= 5000) {
    lastSample = now;
    if (sensorPoll()) {
      pollFailCount = 0;
      // 只有当前停在会变的页面才重画,其它页面等翻回去时自然是新数据
      if (state == STATE_LIVE)  renderLiveValues();
      if (state == STATE_TREND) renderTrendPage();
    } else {
      // sensorPoll()失败原因有两种:①传感器拔掉了(I2C直接无应答);②数据还没准备好
      // (正常现象,比如刚开机头几秒)。用连续失败次数区分,别偶尔一次没读到就误判:
      // 连续3次(约15秒,覆盖3个测量周期)都拿不到数据才真的当"传感器不在了"处理,
      // 不然刚好卡在数据未就绪的那一瞬间会被错误地判成"拔了"。
      // 之前这里什么都不做,画面就一直停在拔传感器前最后一次的旧读数上,看起来
      // 像"没传感器也在正常出数"——这是真实bug,不是设计如此。
      pollFailCount++;
      if (pollFailCount >= 3 && (state == STATE_LIVE || state == STATE_TREND ||
                                  state == STATE_INFO || state == STATE_ABOUT)) {
        haveData = false;
        state = STATE_ERROR;
        renderCurrentState();
      }
    }
  }

  if (now - lastPoll < 20) return;
  lastPoll = now;

  bool down = isTouchedNow();

  if (down && !wasDown) {
    pressStartMs = now;
    longFired = false;
  }

  if (down) {
    uint32_t held = now - pressStartMs;
    if (held > 300) drawProgressBar(held);   // 摁过300ms才显示进度条,避免单击闪一下
    if (!longFired && held >= HOLD_MS) {
      longFired = true;
      lastActionMs = now;
      onLongPressComplete();
    }
  }

  if (!down && wasDown) {
    if (!longFired && (now - lastActionMs > 250)) {
      lastActionMs = now;
      onShortTap();
    } else if (!longFired) {
      renderCurrentState();   // 抹掉没走完的进度条
    }
  }

  wasDown = down;
}

原始文件:下载 main.ino (依赖 TFT_eSPI、U8g2_for_TFT_eSPI 两个库,PlatformIO/Arduino IDE库管理器搜名字装)

7

使用要点

  • 周期测量每 5 秒出一组数据。启动后第一组数据要等约 5 秒,不是立刻就有。
  • 发配置类命令前必须先停:发 stop_periodic_measurement(0x3F86)并等 500 ms,否则命令会被忽略。这是新手最常见的错误。
  • 读数为 0 表示该帧无效,程序里应当丢弃,不要显示成"CO2 浓度为 0"。
  • 刚上电前几分钟读数会漂,属于正常现象,需要预热。
  • 每个数据字后面跟 1 字节 CRC(多项式 0x31,初始值 0xFF)。CRC 校验不过通常是杜邦线松动、线太长或缺上拉电阻。
  • I2C 速度用 100 kHz,不要拉到 400 kHz。
命令值说明
start_periodic_measurement0x21B1启动周期测量
read_measurement0xEC05读 CO2 / 温度 / 湿度
stop_periodic_measurement0x3F86停止,之后等 500 ms
get_serial_number0x3682读序列号,用来确认通信正常
get_data_ready_status0xE4B8低 11 位非 0 表示数据就绪
measure_single_shot0x219D单次测量,仅 SCD41 支持

换算公式(来自数据手册):

物理量换算
CO2原始值直接就是 ppm
温度 (°C)-45 + 175 × raw / 65535
湿度 (%RH)100 × raw / 65535
8

排查流程

  1. 先跑 I2C 扫描,确认总线上能看到 0x62。树莓派用 i2cdetect -y 1,Arduino / ESP32 用任意 I2C Scanner。
  2. 扫不到 0x62:检查 SDA / SCL 有没有接反、VCC 是否接对(UNO 接 5V,ESP32 / STM32 / 树莓派接 3.3V)、有没有共地。UNO 用户重点查供电是否够(见第 4 节)。
  3. 扫得到但读数失败:多半是 CRC 校验不过。板上已有 10K 上拉,所以重点查杜邦线是否松动、线是否过长、有没有干扰源。
  4. 能读但数值是 0:那一帧无效,正常现象,程序里丢弃即可。
  5. 数值一直不变或明显不合理:确认是否忘了先发停止命令就直接发配置命令;再确认预热时间是否足够。
  6. 怀疑型号不符:跑第 1 节的识别程序确认是 SCD40 还是 SCD41。

Support

需要技术支持时,把这几项一起发来

复制下面这段填一下发给客服,我们一次就能定位,不用来回问。

【SCD41 蓝板 · SCD40 紫板CO2 传感器模块接线与代码 技术支持】
主控型号:            (如 Arduino UNO / ESP32 / STM32)
供电接的是:          □ 3.3V   □ 5V
I2C Scanner 扫到的地址:
串口显示的内容:
用的是哪份代码:      □ 本页示例   □ 其他(请说明)
接线照片:            (请附一张能看清接线的照片)
10

官方资料

本页信息来源

两块板的引脚顺序、尺寸、SKU 和板载元件(2 × 10K 上拉、100nF 去耦)来自实物与本店确认; 供电范围(2.4 – 5.5 V)、命令字、CRC 和换算公式来自 Sensirion 官方数据手册; Arduino 例程为官方原版,其余精简版尚未实机验证。

← 返回传感器分类 返回首页 进入淘宝店铺 →