简介:面向嵌入式开发者的CCS811空气检测传感器示例工程,用于快速搭建基于STM32的室内CO₂与VOC浓度采集系统,适合物联网、智能家居及环境监测场景,也适合刚接触气体传感器和串口通信的开发者参考。资源共174个文件,以h/c源码、o/d中间编译文件为主,附带uvprojx/uvoptx工程配置、sct链接脚本、hex烧录文件及map映射文件,整体约4.36MB,Keil工程结构清晰,可直接打开参考,已有776人学习下载。示例重点演示双串口通信方案:串口一接收传感器输出的浓度数据,串口二发送控制与校准指令,同时涵盖CCS811初始化、工作模式配置、数据解析与异常处理,便于在读取数据的同时动态调参。通过阅读源码和烧录hex,可快速掌握CCS811寄存器操作、STM32串口中断或轮询收发逻辑,并据此扩展自己的空气质量监测功能;工程中的中间文件也可帮助了解整个编译链接流程。
1. 用 CCS811 示例程序做空气质量检测,第一步不是接传感器
拿到一颗 CCS811 模块,把配套示例程序烧进去就能在串口看到 eCO₂ 和 TVOC 两路数据,这是它比多数传感器都更直接的地方。但不少人在第一版程序里看到的数值先猛冲到几千再回落到几百,这个阶段很容易误判为接线断了或者芯片是坏的。CCS811 的特殊性在于,它把浓度换算算法固化在芯片内部,所以示例程序看起来只是按 I2C 时序读寄存器,实际还涉及内部热稳定、基线初始化和算法收敛。如果手头正好有一份 CCS811 示例程序,或者正准备把官方 demo 移植到自己的 MCU 上,下面按寄存器初始化、测量模式选定、数据读取和基线保存这几条主线展开,覆盖从接线到最终数据可用的完整路径。
2. 把 CCS811 示例程序拆开看:I2C 地址、启动序列和 MEAS_MODE
2.1 I2C 地址由 ADDR 引脚决定,别只认 0x5A
CCS811 的 7bit I2C 地址由外部 ADDR 引脚的电平决定,拉高是 0x5A,拉低是 0x5B。绝大多数 demo 例程默认地址是 0x5A,但不同模块的硬件设计差别很大,有的把 ADDR 悬空,有的直接接地,导致总线上扫描不到设备。第一次接线时先用 I2C 扫描工具扫一遍地址,再用扫描结果去改示例程序顶部的地址宏,是最省时间的方法。
CCS811 的 I2C 时钟最高支持到 400kHz,寄存器地址自动递增,所以可以在一次事务里从首地址连续读多个字节。示例程序读取数据时通常会做主从机之间的连续读,而不是逐字节读。常见做法是依靠一个平台无关的 I2C 读写函数把整个过程包起来:
/* 平台 I2C 读写封装,i2c_master_write_read 是一次组合事务 */ static int16_t ccs811_i2c_read_reg(uint8_t reg, uint8_t *buf, uint16_t len) { uint8_t addr = (CCS811_DEV_ADDR << 1); /* 7 位地址左移成字节地址 */ int16_t ret = i2c_master_write_read(addr, ®, 1, buf, len); if (ret < 0) { return CCS811_I2C_ERR; } return CCS811_OK; }读函数先向从机写寄存器地址,随后 SDA 方向翻转,从机按地址递增规则回传数据。CCS811 支持连续读的关键是地址自动递增,从 0x02 到 0x07 六个字节能一次拿回 CO₂、TVOC、状态和错误码,没必要拆成多次读事务。示例程序里如果发现每次读到的 CO₂ 和 TVOC 相同,就先检查这个封装函数是否真的执行了连续读,而不只是读了第一个字节。
2.2 初始化前四步:软复位、HW_ID、APP_START、测量模式
CCS811 出厂后芯片里有两段固件:bootloader 和应用固件。上电后默认在 bootloader 状态,需要向 0xF2 寄存器写启动命令,应用固件才会运行。示例程序里通常先用 STATUS 寄存器确认 APP_VALID 位,再发启动命令。我一般直接按固定的四步走,避免寄存器状态不确定带来的干扰:
/* 初始化 CCS811 的完整前四步 */ static int ccs811_init(void) { uint8_t id = 0; uint8_t reset_cmd[4] = {0x11, 0xE5, 0x72, 0x8A}; /* 第 1 步:软复位,让芯片回到已知状态 */ if (ccs811_i2c_write_reg(0xF4, reset_cmd, 4) != CCS811_OK) { return CCS811_ERR_RESET; } delay_ms(100); /* 第 2 步:读硬件 ID,CCS811 固定为 0x81 */ if (ccs811_i2c_read_reg(0xFE, &id, 1) != CCS811_OK || id != 0x81) { return CCS811_ERR_DEVICE; } /* 第 3 步:启动应用固件 */ if (ccs811_i2c_write_reg(0xF2, NULL, 0) != CCS811_OK) { return CCS811_ERR_APPSTART; } delay_ms(100); /* 第 4 步:设置测量模式为 1 秒连续 */ uint8_t mode = CCS811_MODE_1S; ccs811_i2c_write_reg(0x01, &mode, 1); delay_ms(1000); /* 等算法完成首轮采集 */ return CCS811_OK; }跳过软复位直接启动会遇到两类问题:一是芯片可能在 bootloader 的深度休眠态,写 0xF2 后无响应;二是测量模式还保留着上次配置,后续读回来的数据周期不对。读 0xFE 等于 0x81 是确认设备存在的最快方法,有些例程把这一步放在软复位之前,顺序影响不大,重点是软复位后必须留出芯片内部重启的时间,100ms 是保守值。
2.3 MEAS_MODE 寄存器决定数据多久更新一次
MEAS_MODE 寄存器地址是 0x01,高四位选择驱动模式,低四位是中断行为位。示例程序里通常把测量模式和中断一并配置进去,最常用的配置是纯模式值,以及模式值加上 DRDY_INT 使能两种。
| 模式值 | 工作方式 | 数据更新周期 | 适用场景 |
|---|---|---|---|
| 0x00 | 空闲,不采集 | — | 低功耗挂起 |
| 0x10 | 恒功率连续 | 1 s | 默认室内监测 |
| 0x20 | 脉冲加热 | 10 s | 电池供电、便携设备 |
| 0x30 | 脉冲加热 | 60 s | 极低功耗节点 |
| 0x40 | 恒功率连续 | 250 ms | 算法调试、快速响应测试 |
| 0x18 | 恒功率连续 + 数据就绪中断 | 1 s | 用 nINT 唤醒 MCU |
0x40 档 250ms 更新一次,通常只在做传感器响应对比时用,长期运行没必要,因为持续加热会让芯片自身温度偏高,间接影响基线。10s 和 60s 档功耗低,但数据曲线会明显变成阶梯状,如果上游应用要绘制实时趋势图,我一般选 1s 档。中断位那两项,0x18 适合用 GPIO 唤醒低功耗 MCU,不然轮询 STATUS 寄存器就够了。
3. 示例程序的核心代码实现:怎么初始化、怎么把 eCO₂/TVOC 读出来
3.1 示例程序的代码层次:I2C 平台层、芯片驱动层、业务层
典型的 CCS811 示例程序并不是只有一个 main.c,而是按“平台 I2C 驱动 + 芯片驱动 + 业务逻辑”三层组织。官方库和成熟第三方库基本都是这个结构:ccs811.c 只关心寄存器地址和位定义,不关心跑在 STM32 还是 ESP32 上;platform_i2c.c 由使用者填上具体 MCU 的 I2C 读写接口;main.c 只做策略性工作,比如多久读一次、数据格式化输出成什么字符串。芯片驱动层对外暴露的 API 通常只有四个:
- ccs811_init(bus, mode)
- ccs811_read_algorithm_results(bus, result)
- ccs811_set_environmental_data(bus, temp, humidity)
- ccs811_get_baseline / ccs811_set_baseline
这个精简接口设计让示例主循环非常短。如果拿到一份和这里结构差别很大的例程,先不要急着看寄存器操作,定位到 I2C 读写两个底层函数,确认它们和你的平台一致,后续移植能省下大量时间。很多移植问题不是出在 CCS811 驱动逻辑上,而是底层 I2C 读写函数的参数顺序对不上。
3.2 读取算法结果:一次连续读,拆出两个浓度值
ALG_RESULT_DATA 寄存器组从 0x02 开始,连续 6 字节包含 eCO₂(2 字节)、TVOC(2 字节)、STATUS(1 字节)、ERROR_ID(1 字节)。示例程序通常定义一个结构体把字段打包,然后一次 I2C 连续读完成填充。这里是一个可以抄作业的实现:
typedef struct { uint16_t eco2; /* 等效 CO2 浓度,单位 ppm */ uint16_t tvoc; /* 总挥发性有机物,单位 ppb */ uint8_t status; /* 算法状态寄存器 */ uint8_t error_id; /* 错误码 */ } ccs811_result_t; int ccs811_read_algorithm_results(ccs811_bus_t *bus, ccs811_result_t *res) { uint8_t buf[6]; int ret; ret = bus->read_reg(bus->addr, 0x02, buf, sizeof(buf)); if (ret != CCS811_OK) { return ret; } res->eco2 = ((uint16_t)buf[0] << 8) | buf[1]; res->tvoc = ((uint16_t)buf[2] << 8) | buf[3]; res->status = buf[4]; res->error_id = buf[5]; return CCS811_OK; }数据是大端序,高字节在前,低字节在后,和芯片数据手册里的寄存器表格一致。eCO₂ 的单位是 ppm,TVOC 的单位是 ppb,直接相减或比较时要注意量纲,TVOC 换算成 ppm 要除以 1000。读取函数本身没有对 DATA_READY 做判断,调用方要在主循环里先看状态位再调用,避免重复读同一份旧数据。很多示例例程把状态检查和数据读取拆成两个函数,就是为了应对电动率传感器在高速模式下反复读同一份数据的情况。
3.3 状态位与错误码:什么时候数据可以信
STATUS 寄存器位定义在工作中很关键:bit 7 表示固件模式(0 为 bootloader,1 为应用固件);bit 3 是 APP_VALID;bit 2 是 DATA_READY;bit 0 是 ERROR。读 ALG_RESULT_DATA 返回的第 5 个字节才是当前算法状态,和寄存器 0x00 的状态不完全一样,示例程序里做了两层状态判断属于正常设计。
| 状态位 | 含义 | 处理方式 |
|---|---|---|
| bit 2 DATA_READY | 新数据已就绪 | 置 1 后才调用读取函数 |
| bit 0 ERROR | 有错误需要处理 | 读取 ERROR_ID 寄存器定位 |
| bit 3 APP_VALID | 应用固件可用 | 初始化时检查 |
错误码 ERROR_ID 寄存器地址是 0xE0,常见值对应 WRITE_REG_INVALID、MEASMODE_INVALID、HEATER_FAULT。出现 HEATER_FAULT 说明内部加热器开路,多半是模块焊接或芯片本身有问题,不用再调软件。WRITE_REG_INVALID 则要检查写入寄存器的地址是否越界,尤其是向只读寄存器写数据时最容易触发。
4. 在目标板上跑通整个示例程序:接线、参数调整和串口日志分析
4.1 最小硬件连接,引脚逐个确认
接线是整个示例程序跑不通时最容易出问题的地方。CCS811 的供电电压是 3.3V,不能直连 5V,否则芯片会立刻进入过压状态。SCL 和 SDA 需要上拉电阻,如果模块板上没有,就要在 MCU 侧外部加上 4.7k 到 10k 的上拉。下面是一份我常用的最小连接表:
| 模块引脚 | 接 MCU | 说明 |
|---|---|---|
| VDD | 3.3V | 不能接 5V |
| GND | GND | 共地 |
| SCL | I2C SCL | 带上拉 |
| SDA | I2C SDA | 带上拉 |
| nWAKE | GND | 固定接地,常开 |
| nINT | GPIO 输入 | 可选,数据就绪中断 |
| nRESET | GPIO 输出 | 可选,复位控制 |
nWAKE 是 CCS811 的一个特殊引脚,低电平有效。如果示例程序一直读不到 ACK,先确认模块上有没有用 10k 上拉把 nWAKE 拉高。有的模块原理图把 nWAKE 接到 3.3V,那就必须在每次 I2C 操作前把 nWAKE 拉低并等待至少 1μs,否则芯片不响应总线命令。固定接地是减少时序干扰的最简单做法,大部分成品模块也是这么设计的。
4.2 主循环和串口输出:轮询状态再读数据
示例程序的 main 循环一般长这样:先初始化 I2C,再初始化 CCS811,然后进入 while(1),每次检查 STATUS 寄存器的 DATA_READY 位,为真才读数据并打印。下面这段是典型写法:
void main_loop(ccs811_bus_t *bus) { ccs811_result_t res; uint8_t status; while (1) { /* 每次读取前先看状态寄存器,避免重复消费旧数据 */ if (bus->read_reg(bus->addr, 0x00, &status, 1) != CCS811_OK) { continue; } if (status & 0x08) { /* DATA_READY 置位 */ if (ccs811_read_algorithm_results(bus, &res) == CCS811_OK) { printf("eCO2=%u ppm, TVOC=%u ppb\r\n", res.eco2, res.tvoc); } } delay_ms(1000); } }主循环把读取频率和测量模式解耦。即使 MEAS_MODE 配置成 10s 更新一次,主循环也可以每 500ms 轮询一次,只是 DATA_READY 置位的频率会变低。这个解耦设计让示例程序在调试时不用反复改测量模式,直接改主循环的 delay 就能观察不同轮询周期下的行为。串口打印建议加上时间戳,方便对齐外部温湿度变化和气敏数据跳变。
4.3 串口日志数值速查
串口打开之后,前几轮的数值会很不稳定,这是正常现象。先让程序跑一段时间,观察数据趋势而不是单点数值。以下是一个粗略的参考区间,不是标定值,仅用于判断数据是否合理:
| 数据 | 参考值 | 判断 |
|---|---|---|
| eCO₂ | < 600 ppm | 室内通风良好 |
| eCO₂ | 600–1000 ppm | 人员密度高,需要关注 |
| eCO₂ | > 1000 ppm | 建议通风后再看趋势 |
| TVOC | < 50 ppb | 环境较干净 |
| TVOC | 50–500 ppb | 中等污染,可能来自装修材料 |
如果打印出来的 eCO₂ 长时间卡在 400ppm 不变化,或者 TVOC 稳定在 0,多半不是代码问题,而是芯片内部算法认为当前环境“干净到无法分辨”。这时拿一个已知的气味源,比如酒精棉球放在传感器附近,观察数值是否在几十秒内上升,就能确认数据链路是否真在工作。
5. 多学一点:基线的读取、写入,以及 24 小时老化期的数据处理
5.1 基线存取是示例程序里最容易漏掉的功能
CCS811 内部有基线寄存器,地址 0x11,读取时返回 2 字节,写入时需要按固定打包规则发 4 字节。示例程序一般都包含基线读写接口,但很多移植者不知道什么时候该用。正确的使用方式是在传感器持续运行 12 到 24 小时后,把读到的基线值存入 Flash 或 EEPROM,设备下次断电重启后直接写回,从而跳过重新收敛的时间。代码写法如下:
/* 读取当前基线,2 字节 */ uint16_t ccs811_get_baseline(ccs811_bus_t *bus) { uint8_t buf[2] = {0}; if (bus->read_reg(bus->addr, 0x11, buf, 2) == CCS811_OK) { return ((uint16_t)buf[0] << 8) | buf[1]; } return 0; } /* 写回基线,需要 4 字节:0x00 + 高字节 + 低字节 + 0x00 */ void ccs811_set_baseline(ccs811_bus_t *bus, uint16_t baseline) { uint8_t buf[4]; buf[0] = 0x00; buf[1] = (baseline >> 8) & 0xFF; buf[2] = baseline & 0xFF; buf[3] = 0x00; bus->write_reg(bus->addr, 0x11, buf, 4); }写基线时如果不带头尾两个 0x00,写入会被芯片直接拒绝,这是一个非常容易踩的坑。另外,不要在运行时间不足 6 小时时保存基线,否则会把未收敛的初值当成稳定值存下来,导致后续所有数据都带着固定偏移。
5.2 老化期的数据判断:什么情况真的需要换模块
CCS811 在出厂后有一段老化时间,通常需要 24 到 48 小时连续运行才能让内部算法进入稳定状态。新模块头几个小时的 eCO₂ 数据会有明显的漂移,数值整体偏高几百 ppm 都算常见。如果程序在这个阶段直接做阈值报警,很容易误报。比较合理的做法是先收集一段日志,观察 24 小时的峰值和谷值范围,再设定报警阈值。
老化期结束后,如果数据仍然异常,可以用环境数据补偿寄存器做修整。0x05 寄存器可以写入外部温湿度值,CCS811 会用这些信息修正计算基准,很多示例程序没有实现这段,导致传感器在极干燥或高温环境下输出偏差很大。把从 SHT30 这类温湿度传感器读到的值换算成 0.01℃ 和 0.004% 相对湿度的格式写入,eCO₂ 输出的稳定性会有可见改善。写入格式是 4 字节,依次为湿度高字节、湿度低字节、温度高字节、温度低字节。把这组命令接到主循环的初始化末尾,对比写入前后的串口日志,就能判断当前模块对温湿度的敏感程度。
本文还有配套的精品资源,点击获取