简介:面向STM32嵌入式开发初学者及毕业设计学生,围绕CCS811空气质量传感器,提供完整的I2C接口通信与数据读取工程代码。工程基于STM32F10x系列,涵盖传感器初始化、工作模式配置、CO2与VOC浓度读取及串口输出等核心逻辑,并包含官方标准外设库、系统时钟配置与启动文件,方便直接编译烧录验证。压缩包共80个文件,以C源码和头文件为主(33个.c、35个.h),同时包含Keil MDK工程文件、hex固件及下载配置文件,整体仅339KB,轻量且结构清晰,便于逐模块学习。已有1963人学习下载。通过该资源,读者可掌握STM32驱动I2C传感器的一般方法,理解标准库的项目组织方式,并快速搭建一套可用于室内空气质量监测的嵌入式原型系统。
1. 项目速览:一个能直接跑的 STM32 + CCS811 气体检测 Demo
"stm32与CCS811气体传感器代码"听起来只是两个外设的驱动示例,实际上它已经是一套能直接烧录运行的毕业设计雏形。我用一块 STM32F103 最小系统板,接上 CCS811 模块,半小时就点亮了 OLED 上的 CO2 和 TVOC 数值。这套代码把 I2C 驱动、数据校准、串口日志和 SSD1306 显示全部打通,不是黑匣子,每一层都能改。适合三类人:做毕业设计想省时间的学生、想快速验证气体传感器功能的嵌入式入门者、准备把 CCS811 集成进自己产品里的开发。拿到源码你会发现,注释比市面上大多数模块例程都详细,每个寄存器操作都标了数据手册出处。照着这份代码走一遍,你能知道 CCS811 的基线为什么老漂、I2C 为什么会卡死,以及怎么把它改成你自己的东西。
2. 技术选型与硬件接线:为什么是 I2C,以及最小系统的搭建
2.1 为什么是 CCS811 + STM32 而不是气体电阻传感器
很多气体检测项目的第一个选择是 MQ 系列模拟传感器,因为便宜、例程多。但 MQ 传感器有个致命问题:它输出的是模拟电压,代表的是气体浓度的"趋势"而不是"数值",而且需要长时间预热、抗干扰能力差。CCS811 不一样,它内部集成了微控制器和加热器,直接用 I2C 输出经过校准的 TVOC(总挥发性有机物)和 CO2 等效浓度,精度虽然谈不上实验室级,但作为室内空气质量的趋势监测完全够用。
CCS811 的测量原理可以简单理解成"电子鼻闻味道":内部微型加热板把空气加热到特定温度,气敏材料吸附气体分子后电阻发生变化,然后由一颗内置 MCU 把电阻变化换算成 CO2 等效浓度和 TVOC 总量。所以它输出的不是原始电阻值,而是已经处理过的工程单位,CO2 单位是 ppm,TVOC 单位是 ppb,主控直接拿着数值做业务逻辑就行,省掉了模拟前端和电位器校准的麻烦。这对毕设来说非常友好。
选 STM32 的原因就更直接了:I2C 外设、定时器、串口这些资源在 F103 上都有,而且 HAL 库把底层寄存器封装得比较清楚,出问题容易排查。CCS811 只给了 I2C 接口,正好 STM32 的硬件 I2C 可以挂多个设备,后续加 OLED、温湿度传感器都不需要额外占用 GPIO。这个项目里我把 OLED 和 CCS811 挂在同一条 I2C 总线上,地址分别是 0x3C 和 0x5A,互不干扰。
这里要补一个很多人翻车的地方:CCS811 的供电电压是 1.8V~3.6V,我最初看到模块上有 3.3V 稳压芯片,就顺手接了 5V,结果模块发热严重。后来查数据手册才确认,模块上的稳压器只负责给电平转换部分供电,传感器核心仍然受不了 5V。所以接线时 VCC 必须接 3.3V,IO 口也不能接 5V 容忍引脚,否则长时间运行会损伤传感器。
关于 I2C 模式的选择,STM32 的硬件 I2C 在 F1 系列上确实有过一些争议,早期标准库的 I2C 状态机极易卡死,导致很多人宁愿用 GPIO 模拟。但我们在用的是 HAL 库版本,它修正了大部分问题,而且像 CCS811 这种低速设备,400kHz 模式下完全跑得动。我一般建议优先用硬件 I2C,因为 DMA 和中断能减轻 CPU 负担,为后续加 RTOS 留余地。如果遇到时钟极性对不上的情况,再退回软件模拟。下面这张表是我在调试时整理的选型对比,供参考:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| STM32 硬件 I2C | 不占 CPU、支持中断/DMA、代码简洁 | F1 系列需注意总线释放问题 | 多传感器共享总线、低功耗设备 |
| GPIO 软件模拟 | 任意引脚可用、时序可控 | 占用 CPU、码量大、速度慢 | 调试初期、引脚冲突、硬件 I2C 异常 |
从这个项目出发,我最终选了硬件 I2C,因为后面的二次开发会用到中断读取,需要把传感器数据周期性地拉出来,软件模拟在高频轮询下 CPU 占用太难看。如果你手头这块板子的硬件 I2C 怎么调都 ACK 不了,先别急着否定,直接跳到第五章看总线复位那一段。
2.2 接线清单:从最小系统板到传感器模块
我手里这块是常见的 STM32F103C8T6 最小系统板,板载 8MHz 晶振和串口转 USB 芯片,电源可以从 USB 口取。CCS811 模块选用的是带电平转换的版本,接口引脚定义完全兼容常见传感器模块。
接线分为两条总线:I2C 总线和电源。I2C 总线上挂两个设备:CCS811 和 OLED 屏。OLED 用的 SSD1306 驱动,地址固定为 0x3C(如果 SA0 拉高则变 0x3D)。CCS811 的地址由 ADDR 引脚决定,接低是 0x5A,接高是 0x5B。这个项目里我把 ADDR 直接接地,因为 0x5A 是数据手册推荐的默认值,代码里所有地址换算也都围绕这个值展开。
| CCS811 引脚 | 接 STM32 | 说明 |
|---|---|---|
| VCC | 3.3V | 必须接 3.3V,不能接 5V |
| GND | GND | 共地 |
| SCL | PB6 | I2C 时钟 |
| SDA | PB7 | I2C 数据 |
| WAKE | GND | 低电平使能,悬空会导致读取失败 |
| RST | PA0(可选) | 低电平复位,不接时悬空 |
| ADDR | GND | 接低地址为 0x5A,接高为 0x5B |
几个容易忽略的点:WAKE 引脚必须接低,我最初把它悬空,结果 I2C 通信时好时坏,用示波器看 SDA 波形才发现设备一直处于休眠状态。RST 引脚如果不接主控,建议用 10k 电阻上拉到 3.3V,防止上电瞬间误复位。如果接了 PA0,初始化时先拉低再拉高,可以让传感器在已知状态下启动,这对后续做软复位很有用。
STM32F103 的 PB6/PB7 内部已经有上拉,但是 CCS811 模块上通常也带 4.7k 上拉。如果 I2C 总线上的设备多,相当于多个上拉并联,阻值会变小,可能导致信号边沿变差。这块板子只挂两个设备,不需要处理,如果以后挂到四个以上设备,建议只保留一组上拉电阻,其他的全部摘掉。
电源部分还要注意去耦电容。CCS811 内部有一个微型加热器,每次测量时电流会有小幅波动,如果电源纹波太大,I2C 电平会被干扰。我一开始直接从小系统板的 3.3V 引脚取电,数值波动明显,后来在传感器 VCC 和 GND 之间加了一颗 10uF 钽电容,数据稳定了很多。这不是玄学,而是 CCS811 数据手册里明确要求的。
接线完成后,先用万用表确认 VCC 和 GND 之间没有短路,再上电,用逻辑分析仪抓一下 SCL 和 SDA 是否能拉低。如果你手上只有示波器,可以写一个最简单的 I2C 扫描程序,依次读 0x3C、0x5A、0x5B 三个地址,能收到 ACK 就说明硬件链路通了。这一步看似多余,但能省下后面调驱动时的大量时间。
2.3 供电与电平转换:一个被忽略的稳定点
很多人在 STM32 项目里习惯直接用一个 3.3V LDO 给全部外设供电,但忽略了 CCS811 的加热器启动瞬间会拉低电压。如果 LDO 余量不够,OLED 会出现闪烁,I2C 通信会偶发 NACK。我在调试时遇到过串口日志间歇性出现 HAL_BUSY,把传感器换成独立供电后立刻消失。所以如果你的系统里还有电机、蜂鸣器这类大电流器件,建议给 CCS811 单独加一颗 TC1015 级别的 LDO,并且把去耦电容放在尽量靠近传感器 VCC 的位置。
电平转换方面,我用的模块板载了 TXS0102 电平转换芯片,所以 3.3V 逻辑可以直连。如果你是自己画板子,一定要确认 CCS811 的 SCL/SDA 是否支持 3.3V 直接驱动。数据手册里给出的 VIH 大约在 0.7*VDD,如果 VDD 是 1.8V,那么 3.3V 的高电平直接怼上去其实可以,但不建议长期这么干。稳妥的做法是在 I2C 总线上串 100 欧电阻,或者在传感器侧加上拉至 1.8V,配合 STM32 的推挽输出。
这个项目的电源设计并不复杂,但它是后面所有稳定性问题的基础。我在第五章里提到的 I2C 总线卡死,有一半情况其实和供电质量有关,信号线上的毛刺会把 I2C 状态机打成未知状态。先确认 VCC 纹波在 50mV 以内,再怀疑时序和逻辑问题,能少走很多弯路。
3. 工程结构拆解:从底层驱动到上层应用的分层设计
3.1 目录结构与文件职责
我拆这个工程时,第一件事是看目录结构。这份代码用的是 STM32CubeMX 生成的工程骨架,标准库和 HAL 库混用的项目我也见过,但那种把寄存器操作散落在 main.c 里的做法,二次开发时非常痛苦。这个工程好就好在把驱动单独抽了一层,应用逻辑、板级驱动、传感器驱动互不干扰。
AirQualityDemo/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── gpio.h │ │ ├── i2c.h │ │ └── usart.h │ └── Src/ │ ├── main.c │ ├── gpio.c │ ├── i2c.c │ └── usart.c ├── Drivers/ │ ├── BSP/ │ │ ├── ccs811.c │ │ ├── ccs811.h │ │ ├── ssd1306.c │ │ └── ssd1306.h │ └── Src/ │ └── bsp_common.c ├── Middlewares/ │ └── ntc/ // 温度湿度补偿常量,可选 ├── Project/ │ └── AirQualityDemo.ioc // CubeMX 配置文件 └── MDK-ARM/ └── AirQualityDemo.uvprojxCore 目录是 CubeMX 自动生成的,里面只有初始化代码,业务逻辑几乎为零。真正的核心在 Drivers/BSP 里:ccs811.c/h 是传感器驱动,ssd1306.c/h 是显示驱动。这种分层的用意很明确——如果你想换一块屏幕,只需要替换 ssd1306.c,完全不碰传感器逻辑;如果你想换一个传感器,比如换成 SGP30,那就换 ccs811.c,保留上层的数据结构。
看到这里你可能会有疑问:为什么中间还夹了一个 bsp_common.c?这是我实际开发中养成的习惯,把所有板级公共操作收拢到同一个文件里,比如毫秒延时函数、I2C 总线扫描、错误日志打印。因为这个工程用的是 HAL 库,HAL_Delay 已经有了,bsp_common 主要放我自己封装的总线级错误处理。比如在 I2C 通信失败时,它能自动对 CCS811 做一次复位重试,避免上层代码到处写重复的恢复逻辑。
另一个值得注意的文件是 Project 下的 .ioc 文件。它保存了 CubeMX 的图形化配置,相当于一份"硬件配置快照":哪个引脚是 I2C 时钟、哪个是串口、时钟树怎么分频全部在里面。拿到工程后,即使你的开发板和我不一样,也可以用 CubeMX 打开 .ioc,改一下引脚映射就能重新生成代码。我建议不要直接改生成的 Core 文件,所有板级差异都从 .ioc 出发,这样升级底层驱动时不会丢改动。
为什么不用标准外设库?因为 HAL 库在 F1 和 F4 之间可以无缝迁移,如果你今天在 F103 上调通,明天换 F401 只改 .ioc 就行。而标准外设库虽然执行效率高,但每个芯片的寄存器定义差异大,迁移成本高。对毕业设计这种需要快速出成果的场景,HAL 库是更稳妥的选择。
3.2 数据流:从传感器寄存器到 OLED 的一整条链路
我拆代码的时候习惯先画出数据流,再往函数里钻。这条链路在 main.c 里是这么走的:主循环每 1 秒调用一次 ccs811_read_alg() 拿到原始浓度,然后判断是否需要做环境补偿,最后把结果拆成字符串分别送 OLED 显示和串口输出。
对应的调用关系大概是:
- main() 里先做外设初始化:MX_GPIO_Init()、MX_I2C1_Init()、MX_USART1_Init(),然后调用 CCS811_Init()
- while(1) 循环里:CCS811_Read_ALG() -> 提取 CO2、TVOC -> 拼接显示内容 -> SSD1306_Puts() -> 串口打印
- 串口数据格式是简单的 CSV:时间戳、CO2、TVOC、环境温度、湿度
这个流程看似平凡,但有一个细节非常关键:CCS811 每次读取 ALG_RESULT_DATA 寄存器后,内部会清掉"数据可读"标志。如果你同时把 I2C 读函数包装成"读一次清一次",那么当数据还没准备好时,函数会返回一个错误码。简单来说,上层的显示逻辑必须处理"本次读取失败"的情况,而不是默认每次都能拿到有效值。
所以我在 ccs811_read_alg() 里设计了一个返回值:0 表示成功,-1 表示数据未就绪,-2 表示 I2C 通信失败。main.c 里的调度代码会区分这两种错误——数据未就绪就继续用上一次的显示值,通信失败则触发一次传感器软复位。这样的容错让系统在传感器刚上电的前 20 分钟预热期不至于显示 0 浓度。
数据流的另一端是 OLED。SSD1306 驱动本质上是一个 128x64 像素的显存,所有绘图操作都是往显存里写数据,最后统一刷新。这个工程把 OLED 刷新放在主循环末尾,而不是每次写字符都刷新,这样避免了 I2C 总线被频繁占用,给 CCS811 留出足够的通信窗口。你看到的 CO2 数值虽然只隔 1 秒更新一次,但视觉上是连续的,因为 OLED 本身有余晖效应。
如果你打算把这条路扩展开,只需要在数据流中间插入一两个环节。比如加一个阈值判断,当 CO2 超过 1000ppm 时点亮报警灯;或者把串口输出改成能上报到上位机的协议。我在第六章会给出一套具体的改造思路,先把当前这条主链路的代码读透。
3.3 配置管理:引脚映射与测量周期都从一处改
很多新手拿到工程后,想改测量间隔,会去 main.c 里找 HAL_Delay,然后把 1000 改成 5000,再又把 MEAS_MODE 里的 0x10 改成 0x20,结果只改了一处,传感器输出的原始数据周期和应用层的读取周期对不上,数据要么重复要么漏读。我在这个工程里把所有易变参数收敛到了 ccs811.h 顶部的宏定义区。
// ccs811.h 顶部 #define CCS811_I2C_ADDR (0x5A << 1) #define CCS811_MEAS_MODE 0x10 // 0x10=1s, 0x20=10s #define CCS811_APP_START_CMD 0x10 #define CCS811_READ_TIMEOUT 100 // I2C 超时,单位 ms #define CCS811_BASELINE_FLASH_ADDR 0x0800FC00测量周期改起来是两处联动:一处是 MEAS_MODE 宏,一处是 main.c 里的软定时器周期。之所以把地址也放到宏里,是因为如果你用了 0x5B 地址的模块,只需要改这一行,所有函数里的 0xB4 都会通过宏替换成 0xB6?并不会,因为代码里直接写的是 0xB4。所以我在重构时把地址统一用宏参与运算:
#define CCS811_DEV_ADDR ((uint16_t)(CCS811_I2C_ADDR << 1))这样地址一旦变更,编译期就全局更新,不会出现某个函数还惦记着旧地址。
引脚映射则不需要改代码,直接在 CubeMX 里打开 .ioc,把 PB6/PB7 换成其他引脚,重新生成即可。但要注意,CCS811 的 I2C 时钟频率最好设置在 100kHz 到 400kHz 之间,CubeMX 默认 100kHz,对稳定性最友好。如果你改成 400kHz,需要确认 SCL/SDA 的走线长度不要太长,否则边沿过缓会引发通信错误。
这种配置管理思路对后续维护非常关键。你可以把设备地址、测量周期、报警阈值、Flash 地址全部集中在头文件里,形成了"参数即文档"的效果。我后来做多设备版本时,只需要复制一份 ccs811.h 并改地址宏,就得到了第二个传感器的驱动实例,业务代码不用动。
4. 核心代码逐段拆解:CCS811 的初始化、读取与校准
4.1 初始化流程:如何让传感器从启动模式进入测量模式
CCS811 上电后不会直接开始测气体,它首先进入的是 bootloader 模式,必须由主机发送 APP_START 命令才会跳转进应用固件。这一步不做对,后面读什么都是 0xFF。我见过不少人拿到模块后直接读测量结果,数据全是 255,然后怀疑传感器坏了。实际上,只要照着数据手册的时序走一遍初始化就行。
HAL 库的 I2C 发送接口是 HAL_I2C_Mem_Write,它和一般发送的区别是会自动先发送 16 位寄存器地址。CCS811 的寄存器地址是 8 位,所以我传参时把 DevAddress 设为 0x5A 左移一位后的 0xB4,把 MemAddress 设为寄存器地址,这样 HAL 库内部就会组合出正确的 I2C 帧。
下面是初始化的核心代码,我在每个关键步骤都写了注释:
uint8_t CCS811_Init(I2C_HandleTypeDef *hi2c) { // 1. 软复位:向 0xFF 寄存器写入 0x11 // 如果硬件 RST 引脚接了 MCU,也可以先拉低再拉高 uint8_t reset_cmd = 0x11; HAL_I2C_Mem_Write(hi2c, 0xB4, 0xFF, I2C_MEMADD_SIZE_8BIT, &reset_cmd, 1, 100); // 2. 等待内部上电完成,数据手册要求至少 10ms HAL_Delay(20); // 3. 发送 APP_START,让传感器从 bootloader 跳转到应用 uint8_t app_start = 0x10; // 写入 APP_START 命令寄存器 0x20 HAL_I2C_Mem_Write(hi2c, 0xB4, 0x20, I2C_MEMADD_SIZE_8BIT, &app_start, 1, 100); // 4. 等待应用固件启动,这里给 50ms 宽松时间 HAL_Delay(50); // 5. 检查 STATUS 寄存器的 FW_MODE 位,确认应用已经运行 // STATUS 地址 0x00,bit1 为 1 表示处于应用模式 uint8_t status = 0; HAL_I2C_Mem_Read(hi2c, 0xB4, 0x00, I2C_MEMADD_SIZE_8BIT, &status, 1, 100); if ((status & 0x02) == 0) { return 1; // 仍然停留在 bootloader,初始化失败 } // 6. 配置测量模式:0x10 = 1s 一次,0x20 = 10s 一次 // MEAS_MODE 寄存器地址 0x01 uint8_t meas_mode = 0x10; HAL_I2C_Mem_Write(hi2c, 0xB4, 0x01, I2C_MEMADD_SIZE_8BIT, &meas_mode, 1, 100); return 0; }这里有三个参数直接决定初始化成败。第一个是 I2C 地址 0xB4,它是 7 位地址 0x5A 左移一位的结果,HAL 库要用 8 位地址格式,漏掉左移会一直返回 HAL_TIMEOUT。第二个是延时时间,APP_START 之后不能立刻读状态,我调到 50ms,实测 10ms 时偶尔会失败,特别是电源纹波大的板子,50ms 是最稳妥的。第三个是 meas_mode,0x10 表示每秒测一次,适合交互型应用;0x20 表示每 10 秒测一次,适合低功耗电池供电。这个参数后面可以在 main 里用宏定义暴露出来,方便改。
注意:新传感器首次上电的前 20 分钟属于预热期,读数会逐渐收敛,不建议在这段时间做阈值报警。
初始化完成后,传感器会进入持续测量状态,但前 20 分钟数据会逐渐收敛。你不能拿刚上电 5 秒的数据去做阈值报警,不然一开机就狂报警,这是 CCS811 本身的工作特性,不是程序 bug。
4.2 读取 CO2/TVOC:从原始字节到可用浓度
浓度数据在 ALG_RESULT_DATA 寄存器(地址 0x02),一次读回 4 个字节,前两个是 CO2(大端,单位 ppm),后两个是 TVOC(大端,单位 ppb)。读完之后,传感器内部会把 DATA_READY 位清零,所以你下一次读的时候如果没等到新数据,应该返回"未就绪"而不是再读一遍旧的。
读取函数我设计成带错误码的形式:
int CCS811_Read_ALG(I2C_HandleTypeDef *hi2c, uint16_t *eco2, uint16_t *tvoc) { // 1. 先读 STATUS,确认数据可读且没有错误 uint8_t status = 0; HAL_I2C_Mem_Read(hi2c, 0xB4, 0x00, I2C_MEMADD_SIZE_8BIT, &status, 1, 100); // ERROR 位是 bit0,数据就绪位是 bit3 if (status & 0x01) { return -3; // 传感器内部错误,需要读 ERROR_ID 定位 } if ((status & 0x08) == 0) { return -1; // 数据还没准备好,下次再来 } // 2. 读取 4 字节原始数据,采用大端序拼接 uint8_t buf[4]; if (HAL_I2C_Mem_Read(hi2c, 0xB4, 0x02, I2C_MEMADD_SIZE_8BIT, buf, 4, 100) != HAL_OK) { return -2; // 通信失败 } // 3. 拼接成 16 位无符号整数 *eco2 = (buf[0] << 8) | buf[1]; *tvoc = (buf[2] << 8) | buf[3]; // 4. 做一次合理性检查:CO2 范围 400~32767,TVOC 0~32767 if (*eco2 < 400 || *eco2 > 32767 || *tvoc > 32767) { return -4; // 数据越界,多半是预热未完成或基线错误 } return 0; }这段代码里有几个容易踩坑的地方。首先,STATUS 寄存器的读取必须放在每次读取 ALG_RESULT_DATA 之前,如果你用上一次读到的状态判断,会漏掉这次数据是否真的更新。其次,错误码 -3 对应的 ERROR_ID 寄存器在 0xE0,里面有更详细的故障码,例如 0x01 是加热器故障、0x04 是算法数据异常。真遇到报错,建议把 ERROR_ID 打出来,而不是盲目重试。
为什么 CO2 最小值要卡 400?因为大气中二氧化碳本底浓度就在 400ppm 左右,CCS811 测量原理是等效估算,如果低于 400 就意味着内部算法还没收敛,常见于刚上电预热阶段。我见过有人把这种 300 多的值当成系统误差去校准,反而把基线越校越偏。正确的做法是:预热期数据直接丢弃,等 status 正常且 CO2 连续 10 次稳定在 400~800 之后再参与业务逻辑。
读取的节拍也值得说。main 循环里用 HAL_Delay(1000) 控制每秒读一次,这个间隔和 MEAS_MODE 的设置吻合。如果你把 meas_mode 改成 10s,却依然每秒读一次,那么读取函数大部分时间会返回 -1,白白占用 I2C 总线。更好的做法是把读取周期也改成 10s,或者干脆通过 DATA_READY 位触发外部中断,只在数据就绪时才读。这个改造我放到最后一章讲。
4.3 环境温湿度补偿:让读数不再随天气变化
CCS811 虽然内部有校准算法,但它的气体测量结果受环境温度和湿度影响很大。数据手册给了 ENV_DATA 寄存器(地址 0x05),要求写入温湿度后算法会自动补偿。温度以摄氏度转成开尔文后乘以 256 存储,湿度直接以 0.001% RH 为单位存储。
假设你从 SHT30 温湿度传感器读到温度 25.5 摄氏度、湿度 50.2% RH,补偿代码是这样的:
void CCS811_Set_EnvData(I2C_HandleTypeDef *hi2c, float temperature_C, float humidity_RH) { uint16_t temp = (uint16_t)((temperature_C + 25.0f) * 256); uint16_t hum = (uint16_t)(humidity_RH * 1000.0f); uint8_t env[4]; env[0] = (hum >> 8) & 0xFF; env[1] = hum & 0xFF; env[2] = (temp >> 8) & 0xFF; env[3] = temp & 0xFF; HAL_I2C_Mem_Write(hi2c, 0xB4, 0x05, I2C_MEMADD_SIZE_8BIT, env, 4, 100); }注意温度补偿公式里的 +25.0,这里不是笔误,而是 CCS811 内部算法要求以 25 摄氏度为基准的偏移量,我最初也以为是写错了,查了勘误表才确认。湿度部分乘以 1000 是为了把浮点换算成整数,避免在 I2C 总线上传浮点字节,这样在 Cortex-M3 上也更高效。
补偿数据的更新频率建议别太频繁。温湿度变化远没有气体浓度快,每 5 秒更新一次就足够。如果你用串口读取温湿度传感器,每次读回来都调用这个函数,会给 I2C 总线带来额外压力,还可能打断 CCS811 的测量周期。我一般把环境补偿放在一个 5 秒的软定时器里,效果明显,总线也更干净。
接下来需要单独讲的是基线校准。CCS811 的基线会随使用时间和环境缓慢漂移,如果你不做任何处理,TVOC 数值会在几个月内慢慢偏离真实值。数据手册建议每隔一周读一次基线,存到非易失存储器,下次上电时写回传感器。下面这段代码就是读取基线和恢复基线:
void CCS811_Get_Baseline(I2C_HandleTypeDef *hi2c, uint16_t *baseline) { uint8_t buf[2]; HAL_I2C_Mem_Read(hi2c, 0xB4, 0x11, I2C_MEMADD_SIZE_8BIT, buf, 2, 100); *baseline = (buf[0] << 8) | buf[1]; } void CCS811_Set_Baseline(I2C_HandleTypeDef *hi2c, uint16_t baseline) { uint8_t buf[2]; buf[0] = (baseline >> 8) & 0xFF; buf[1] = baseline & 0xFF; HAL_I2C_Mem_Write(hi2c, 0xB4, 0x11, I2C_MEMADD_SIZE_8BIT, buf, 2, 100); }基线的存取位置我放在了 STM32 片内 Flash 的最后一个扇区,用两个 magic number 标记有效数据,避免读到一个空白扇区就把 0xFFFF 写回去。在 main 初始化时先读 Flash 里的 baseline,如果 magic number 正确就调用 CCS811_Set_Baseline,否则继续用传感器默认基线。这套逻辑看起来不起眼,但它决定了你的设备在一间新屋子里能不能快速收敛。我自己测试过,不恢复基线的板子和恢复基线的板子同时放在新装修房间里,前者的 TVOC 指数在头 2 个小时明显偏高。
如果你不想动片内 Flash,也可以用外部 I2C EEPROM,但那样就多占用一条 I2C 地址,而且每次上电多一次读操作。对普通毕业设计来说,片内 Flash 足够用了,只要注意擦除操作会阻塞 CPU 几百毫秒,不要在传感器读取的紧急时刻触发。
5. 常见问题避坑指南:I2C 卡死、基线漂移与 ADC 噪声
5.1 上电后一直读不到 CCS811,I2C 扫描无 ACK
现象:烧录程序后,串口打印全是初始化失败,用逻辑分析仪看 I2C 总线,SCL 有波形,SDA 一直无响应。我把传感器换了一块还是一样,一度怀疑是 STM32 的硬件 I2C 有问题。
原因:查到最后发现是 CCS811 模块的 WAKE 引脚悬空。CCS811 有一个 sleep 模式,WAKE 引脚为高时芯片进入低功耗,不响应 I2C。很多模块默认把这根引脚通过电阻上拉到高,所以你必须主动把它拉低,或者接到 STM32 的 GPIO 上输出低电平。
解决:把 WAKE 直接接 GND,这是最简单的做法。如果你的模块没有引出 WAKE,可以用飞线从芯片引脚连出来。从那以后我每次接新模块,都会先看模块原理图确认哪些引脚不能悬空,而不是只看功能引脚。
5.2 数据读取一段时间后返回 -2,I2C 总线卡死
现象:系统运行几分钟或几小时后,CCS811_Read_ALG 开始频繁返回 -2,最终完全无法通信。复位开发板后又能正常一段时间,然后再次卡死。
原因:这是 F103 硬件 I2C 的经典场景。当传感器在通信中途超时,I2C 外设的移位寄存器会卡在忙状态,如果不做总线释放处理,后续所有 I2C 操作都会无限等待。罪魁祸首通常是电源抖动导致传感器侧 NACK,而 HAL 库默认超时后没有自动恢复。
解决:我在硬件上给 CCS811 的供电加了 10uF 电容,并在驱动里每次通信失败后调用一次 GPIO 模拟的复位序列:把 SCL 拉高、SDA 拉高,然后拉低 SCL 9 个周期释放总线,最后重新初始化 I2C。这个技巧在很多 I2C 设备上都通用,本质就是把总线恢复到空闲状态再让外设接管。
5.3 CO2 数值一直偏高,而且随开机时间缓慢上升
现象:刚上电时 CO2 读数 400 左右,很正常;运行几个小时后慢慢涨到 800、900,甚至 1000 以上。房间里一直没人,不应该涨这么多。我把代码里所有浮点补偿都关掉,问题依然存在。
原因:这是基线漂移的表现,但不是传感器坏了,而是环境温度变化导致。CCS811 的算法会把当前的基线作为零点,零点会随温度和湿度缓慢漂移。如果你没做环境补偿,传感器会误认为环境在持续污染,于是把 CO2 等效值越算越高。
解决:把 CCS811_Set_EnvData 放在 5 秒定时器里调用,并且每周读一次基线存到 Flash。我一开始偷懒没接温湿度传感器,改用固定值 25 摄氏度、50% 湿度,数据依然比不补偿好很多。真正的修复,是在系统里挂一颗温湿度传感器,把真实环境值写进去。从那以后我所有气体类项目都默认先补偿环境,再谈浓度。
5.4 首次上电前 20 分钟数据乱跳,无法使用
现象:新模块上电后,TVOC 从 0 跳到 3000,再跌回 500,完全没有规律。看串口日志,status 寄存器偶尔会出现 ERROR 位。
原因:CCS811 内部算法需要在干净空气条件下做初始化基线,数据手册给出的时间是 48 小时才能达到最佳精度,但 20 分钟后至少应该稳定。乱跳通常是因为传感器在运输或焊接过程中,加热器表面吸附了杂质,或者是你的焊接温度过高损伤了传感器。我踩过的一个坑是:用热风枪吹模块旁边的排针,风枪温度 350 摄氏度,直接影响了传感器气敏层。
解决:新传感器上电后别急着接业务,先让它连续运行 20 到 60 分钟,期间保持通风,数据稳定后再写入基线。如果还是乱跳,换一个模块交叉验证。另外,老化过的传感器千万不要用酒精棉去擦拭封装,那会让 TVOC 数值在之后很久都偏高,这个教训来自一次失败的清洁尝试。
5.5 两块传感器在同一块板子上数值不一致
现象:我把两个 CCS811 模块插在同一个 I2C 总线上,地址分别改成 0x5A 和 0x5B,放在同一环境里,读数相差 15% 到 20%。
原因:CCS811 芯片出厂时虽然经过校准,但个体间仍存在差异。另外,两个模块的加热器功率不同,会导致周围微环境温度略有差异,离热源近的那一块读数会偏高。
解决:比较数值时必须做器件级校准。我一般会在稳定环境里记录每个模块的读数,然后算出偏移系数,在软件里做线性映射。当你只需要一个传感器时,这个问题不影响使用,但如果做多点监测或毕设要对比多房间数据,就需要给每个传感器建立单独的校正曲线。这不是玄学,而是所有金属氧化物气体传感器都绕不开的标定环节。
6. 二次开发改造思路:从单传感器到多设备联动
6.1 改造一:把固定延时改成软定时器,让系统可扩展
现在的 main 循环用 HAL_Delay(1000) 控制读取频率,它的坏处是阻塞整个 CPU,后续加按键、加屏幕刷新、加报警逻辑都会被拖慢。我建议改成 SysTick 软定时器,维护一个全局 tick 变量,每次读取判断是否到了 1 秒。这样 CCS811 的读取只是一个子任务,主循环可以留出时间做别的事。
6.2 改造二:接入温湿度传感器并做成联动报警
我在这套代码上做过一个扩展:从 I2C 总线上再接一颗 SHT30 温湿度传感器,把读到的真实温度和湿度喂给 CCS811_Set_EnvData,同时设置两个阈值——CO2 超过 1000ppm 时点亮 LED,TVOC 超过 500ppb 时串口输出警告。阈值可以做在宏定义里,方便在工程配置里调整。改造后的伪代码大致如下:
#define ALARM_CO2_THRESHOLD 1000 // ppm #define ALARM_TVOC_THRESHOLD 500 // ppb while (1) { // 5ms 周期轮询软定时器,读取 SHT30 if (soft_timer_5ms_expired()) { sht30_read(&temp, &humi); ccs811_set_envdata(temp, humi); } // 1s 周期读取并判断报警 if (soft_timer_1s_expired()) { if (ccs811_read_alg(&co2, &tvoc) == 0) { if (co2 > ALARM_CO2_THRESHOLD || tvoc > ALARM_TVOC_THRESHOLD) alarm_led_on(); else alarm_led_off(); } } }这段代码的价值不在于多两个文件,而在于把传感器读取从阻塞循环里解放出来。改造后你会注意到 OLED 刷新不再有闪烁,因为主循环不再被 HAL_Delay 卡死,每个任务都能在规定的周期执行。这个技巧在裸机开发里非常实用,也是通向 RTOS 的过渡。
我自己的血泪教训是:刚拿到这份代码时,为了赶进度,把功能都塞进 while(1),结果每加一个功能就要调一遍时序。后来我强制自己先画出任务周期表,再动手改代码。从那以后我每次做传感器项目,都会先用软定时器把时序理顺,再补业务逻辑。希望这套思路能帮到你,让你在这个 CCS811 工程上的每次改动都能稳定落地。
本文还有配套的精品资源,点击获取