SGP30气体传感器在天空星GD32F407开发板上的I2C驱动与甲醛检测实战

最近在做一个室内空气质量监测的小项目,需要检测甲醛(TVOC)和二氧化碳浓度,正好用上了SGP30这款传感器。很多刚开始接触嵌入式开发的朋友可能会觉得驱动这类I2C传感器有点复杂,其实只要把通信时序和数据格式搞清楚了,上手并不难。今天我就以天空星的GD32F407开发板为例,带大家一步步实现SGP30的驱动,并成功读取到气体浓度数据。

这篇文章适合正在学习GD32或STM32,想通过实际项目掌握I2C通信和传感器驱动的朋友。我会从最基础的硬件连接讲起,到I2C时序的模拟、传感器的初始化,最后教大家如何解析并显示甲醛和二氧化碳的浓度值。跟着做下来,你就能在自己的项目里用上这个传感器了。

1. 认识SGP30传感器与I2C通信

1.1 SGP30传感器是什么?

SGP30是一款数字输出的气体传感器,它最大的特点是把多个气体传感元件都集成在了一颗芯片里。简单来说,它内部有4个“小鼻子”(金属氧化物传感元件),可以“闻”出空气中的不同气体成分。

它主要检测两类东西:

  • TVOC(总挥发性有机物):这个指标就包含了我们常说的甲醛、苯等有害气体。它的测量范围是0到60000 ppb(十亿分之一)。
  • CO2(二氧化碳):测量范围是400到60000 ppm(百万分之一)。

传感器的工作原理,你可以把它想象成一块特殊的“海绵”(金属氧化物材料)。当目标气体(比如甲醛)接触到这块“海绵”时,会和“海绵”表面的氧气发生反应,导致“海绵”的电阻发生变化。传感器内部的电路(ASIC)就是通过检测这个电阻的变化,再经过一系列计算和校准,最终得出我们需要的浓度数值。

1.2 关键参数与硬件连接

在动手接线之前,咱们先看看SGP30模块的几个关键参数:

参数规格
工作电压3.3V
工作电流约40mA
通信接口I2C
引脚数量4 Pin (VCC, GND, SDA, SCL)

接线非常简单,把天空星开发板和SGP30模块用杜邦线连接起来就行:

  1. VCC -> 开发板的 3.3V 电源引脚。
  2. GND -> 开发板的 GND 引脚。
  3. SDA -> 开发板的 PB8 引脚(这是我们代码里定义的I2C数据线)。
  4. SCL -> 开发板的 PB9 引脚(这是我们代码里定义的I2C时钟线)。

注意:一定要接3.3V,接5V可能会烧坏传感器!另外,I2C通信线(SDA和SCL)通常需要接上拉电阻(一般是4.7kΩ或10kΩ)到3.3V。很多开发板已经在电路板上集成了这些电阻,如果你的模块上没有,可能需要自己外接。

1.3 I2C通信基础与SGP30地址

I2C是一种非常常用的两线制串行通信协议,一根是时钟线(SCL),一根是数据线(SDA)。通信由主机(我们的GD32)发起和控制。

对于SGP30,它的I2C从机地址是0x58(7位地址)。在实际通信时,我们需要根据读/写操作,组合出完整的8位地址:

  • 写操作地址(0x58 << 1) = 0xB0。向传感器发送命令时用这个地址。
  • 读操作地址(0x58 << 1) | 0x01 = 0xB1。从传感器读取数据时用这个地址。

SGP30的命令都是双字节(16位)的,发送时先发高8位,再发低8位。我们最常用的两个命令是:

  • 初始化命令0x2003
  • 读取空气质量命令0x2008

2. 软件驱动编写:手把手实现I2C模拟时序

天空星GD32F407开发板有硬件I2C外设,但为了让大家更清楚地理解I2C的通信过程,我们这里采用GPIO模拟I2C时序的方式。这种方式更灵活,也更容易调试。

2.1 引脚与宏定义

首先,我们在头文件 bsp_sgp30.h 里定义好要使用的引脚和操作宏。

#ifndef _BSP_SGP30_H_
#define _BSP_SGP30_H_

#include "gd32f4xx.h"

// 端口定义:使用PB8作为SDA,PB9作为SCL
#define RCU_SDA    RCU_GPIOB
#define PORT_SDA   GPIOB
#define GPIO_SDA   GPIO_PIN_8

#define RCU_SCL    RCU_GPIOB
#define PORT_SCL   GPIOB
#define GPIO_SCL   GPIO_PIN_9

// 设置SDA为输出模式(主机要发送数据时)
#define SDA_OUT()  gpio_mode_set(PORT_SDA, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SDA)
// 设置SDA为输入模式(主机要接收数据时)
#define SDA_IN()   gpio_mode_set(PORT_SDA, GPIO_MODE_INPUT, GPIO_PUPD_PULLUP, GPIO_SDA)
// 读取SDA引脚的电平
#define SDA_GET()  gpio_input_bit_get(PORT_SDA, GPIO_SDA)
// 控制SDA和SCL引脚输出高或低电平
#define SDA(x)     gpio_bit_write(PORT_SDA, GPIO_SDA, (x ? SET : RESET))
#define SCL(x)     gpio_bit_write(PORT_SCL, GPIO_SCL, (x ? SET : RESET))

// 函数声明
void SGP30_Init(void);
uint32_t SGP30_Read(void);
void SGP30_Write_cmd(uint8_t a, uint8_t b);

#endif

2.2 GPIO初始化与基础I2C时序函数

接下来,在 bsp_sgp30.c 文件中,我们先初始化GPIO,然后实现最核心的几个I2C时序函数:起始、停止、发送字节、接收字节、应答等。

#include "bsp_sgp30.h"
#include "stdio.h"
#include "board.h"

// 必要的微秒和毫秒延时函数,需要根据你的系统时钟实现
extern void delay_us(uint32_t us);
extern void delay_ms(uint32_t ms);

/**
  * @brief  SGP30引脚初始化(GPIO配置)
  * @note   这只是配置GPIO属性,真正的传感器初始化在后面
  */
void SGP30_GPIO_Init(void)
{
    // 使能GPIOB的时钟
    rcu_periph_clock_enable(RCU_SCL);
    rcu_periph_clock_enable(RCU_SDA);

    // 配置SCL为开漏输出模式,上拉,速度50MHz
    gpio_mode_set(PORT_SCL, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SCL);
    gpio_output_options_set(PORT_SCL, GPIO_OTYPE_OD, GPIO_OSPEED_50MHZ, GPIO_SCL);

    // 配置SDA为开漏输出模式,上拉,速度50MHz
    gpio_mode_set(PORT_SDA, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SDA);
    gpio_output_options_set(PORT_SDA, GPIO_OTYPE_OD, GPIO_OSPEED_50MHZ, GPIO_SDA);
}

/**
  * @brief  I2C起始信号
  */
void IIC_Start(void)
{
    SDA_OUT();         // 确保SDA为输出模式
    SCL(1);
    SDA(1);
    delay_us(5);
    SDA(0);            // 在SCL高电平时,SDA产生一个下降沿,即起始信号
    delay_us(5);
    SCL(0);            // 钳住总线,准备发送数据
    delay_us(5);
}

/**
  * @brief  I2C停止信号
  */
void IIC_Stop(void)
{
    SDA_OUT();
    SCL(0);
    SDA(0);
    delay_us(5);
    SCL(1);
    delay_us(5);
    SDA(1);            // 在SCL高电平时,SDA产生一个上升沿,即停止信号
    delay_us(5);
}

/**
  * @brief  主机发送应答(ACK)或非应答(NACK)信号
  * @param  ack: 0-发送应答(ACK), 1-发送非应答(NACK)
  */
void IIC_Send_Ack(unsigned char ack)
{
    SDA_OUT();
    SCL(0);
    if(!ack) SDA(0);   // 发送ACK,拉低SDA
    else      SDA(1);   // 发送NACK,拉高SDA
    delay_us(5);
    SCL(1);             // 产生一个时钟脉冲
    delay_us(5);
    SCL(0);
    SDA(1);             // 释放SDA线
}

/**
  * @brief  主机等待从机应答
  * @retval 0: 收到应答, 1: 超时无应答
  */
unsigned char I2C_WaitAck(void)
{
    unsigned char ack_flag = 10; // 超时计数
    SCL(0);
    SDA(1);             // 主机释放SDA线
    SDA_IN();           // 设置SDA为输入,准备读取从机应答
    delay_us(5);
    SCL(1);             // 产生时钟脉冲
    delay_us(5);

    // 等待SDA被从机拉低(ACK信号)
    while( (SDA_GET() == 1) && (ack_flag) )
    {
        ack_flag--;
        delay_us(5);
    }

    if( ack_flag <= 0 ) // 超时,未收到应答
    {
        IIC_Stop();
        return 1;
    }
    else
    {
        SCL(0);
        SDA_OUT();      // 重新设置SDA为输出,为后续发送做准备
    }
    return 0;
}

/**
  * @brief  I2C发送一个字节
  * @param  dat: 要发送的数据
  */
void Send_Byte(uint8_t dat)
{
    int i = 0;
    SDA_OUT();
    SCL(0); // 拉低时钟线,开始数据位传输

    for( i = 0; i < 8; i++ )
    {
        // 从最高位(MSB)开始发送
        SDA( (dat & 0x80) >> 7 );
        delay_us(1);
        SCL(1); // 拉高时钟,从机在此时读取数据位
        delay_us(5);
        SCL(0); // 拉低时钟,准备下一个数据位
        delay_us(5);
        dat <<= 1; // 左移,准备发送下一位
    }
}

/**
  * @brief  I2C读取一个字节
  * @retval 读取到的数据
  */
unsigned char Read_Byte(void)
{
    unsigned char i, receive = 0;
    SDA_IN(); // 设置SDA为输入,准备读取数据

    for(i = 0; i < 8; i++ )
    {
        SCL(0);
        delay_us(5);
        SCL(1); // 拉高时钟,主机在此时读取数据位
        delay_us(5);
        receive <<= 1; // 左移,为接收新数据位腾出空间
        if( SDA_GET() )
        {
            receive |= 1; // 如果SDA为高,则该数据位为1
        }
        delay_us(5);
    }
    SCL(0);
    return receive;
}

2.3 SGP30专用读写函数

有了基础的I2C时序函数,我们就可以封装针对SGP30的读写命令了。

/**
  * @brief  向SGP30发送命令
  * @param  regaddr_H: 命令高8位
  * @param  regaddr_L: 命令低8位
  */
void SGP30_Write_cmd(uint8_t regaddr_H, uint8_t regaddr_L)
{
    IIC_Start();
    Send_Byte(0xB0); // 发送器件写地址 (0x58 << 1)
    I2C_WaitAck();
    Send_Byte(regaddr_H); // 发送命令高字节
    I2C_WaitAck();
    Send_Byte(regaddr_L); // 发送命令低字节
    I2C_WaitAck();
    IIC_Stop();
    delay_ms(100); // 命令执行需要一定时间
}

/**
  * @brief  从SGP30读取数据
  * @retval 一个32位数据,高16位是CO2值,低16位是TVOC值
  * @note   数据格式:2字节CO2 + 1字节CO2_CRC + 2字节TVOC + 1字节TVOC_CRC
  *         本函数简化处理,未进行CRC校验,直接拼接了CO2和TVOC的原始数据。
  *         实际产品中建议加入CRC校验以提高可靠性。
  */
uint32_t SGP30_Read(void)
{
    uint32_t dat = 0;
    uint8_t crc;

    IIC_Start();
    Send_Byte(0xB1); // 发送器件读地址 ((0x58 << 1) | 0x01)
    I2C_WaitAck();

    // 读取CO2数据高8位
    dat = Read_Byte();
    IIC_Send_Ack(0); // 发送ACK,继续读取
    dat <<= 8;

    // 读取CO2数据低8位
    dat += Read_Byte();
    IIC_Send_Ack(0); // 发送ACK,继续读取

    // 读取CO2的CRC校验字节(此处读出但未使用)
    crc = Read_Byte();
    IIC_Send_Ack(0); // 发送ACK,继续读取

    // 读取TVOC数据高8位
    dat <<= 8;
    dat += Read_Byte();
    IIC_Send_Ack(0); // 发送ACK,继续读取

    // 读取TVOC数据低8位
    dat <<= 8;
    dat += Read_Byte();
    IIC_Send_Ack(1); // 发送NACK,读取结束

    IIC_Stop();
    return dat;
}

/**
  * @brief  SGP30初始化
  */
void SGP30_Init(void)
{
    SGP30_GPIO_Init();       // 初始化GPIO
    SGP30_Write_cmd(0x20, 0x03); // 发送初始化命令 0x2003
}

3. 在主程序中读取并解析数据

驱动函数都准备好了,现在就可以在主函数里调用它们了。这里有个关键点:SGP30上电后需要大约15秒的初始化时间。在这段时间里,读出的CO2值会固定为400ppm,TVOC值为0ppb。只有当TVOC不为0且CO2不为400时,才说明传感器初始化完成,可以开始使用真实数据。

#include "board.h"
#include "bsp_sgp30.h"
#include <stdio.h>

int main(void)
{
    uint32_t CO2Data, TVOCData; // 定义CO2和TVOC浓度变量
    uint32_t sgp30_raw_data;    // 定义从传感器读取的原始数据

    // 开发板基础初始化(系统时钟、LED等)
    board_init();
    // 串口初始化,用于打印数据到电脑
    bsp_uart_init();

    // 初始化SGP30传感器
    SGP30_Init();
    delay_ms(100);

    printf("SGP30 Initializing...\r\n");

    // 等待传感器初始化完成
    while(1)
    {
        SGP30_Write_cmd(0x20, 0x08); // 发送读取空气质量命令 0x2008
        sgp30_raw_data = SGP30_Read();

        CO2Data = (sgp30_raw_data & 0xFFFF0000) >> 16; // 提取高16位,即CO2值
        TVOCData = sgp30_raw_data & 0x0000FFFF;        // 提取低16位,即TVOC值

        // 判断是否初始化完成
        if(CO2Data != 400 || TVOCData != 0)
        {
            printf("SGP30 Init OK!\r\n");
            break;
        }
        delay_ms(1000); // 每秒检测一次
    }

    while (1)
    {
        // 循环读取并打印数据
        SGP30_Write_cmd(0x20, 0x08);
        sgp30_raw_data = SGP30_Read();

        CO2Data = (sgp30_raw_data & 0xFFFF0000) >> 16;
        TVOCData = sgp30_raw_data & 0x0000FFFF;

        printf("CO2: %d ppm, TVOC: %d ppb\r\n", CO2Data, TVOCData);

        delay_ms(2000); // 每2秒读取一次
    }
}

将代码编译下载到天空星GD32F407开发板,并通过串口助手(如Putty、SecureCRT)查看打印信息。你会先看到“SGP30 Initializing...”的提示,大约15秒后,提示初始化完成,并开始持续输出CO2和TVOC的浓度值。

4. 实战经验与注意事项

  1. 数据波动与滤波:气体传感器非常容易受环境(温度、湿度、气流)影响,所以刚开始读数波动大是正常现象。运行几分钟后数据会逐渐稳定。在实际项目中,为了得到更平滑的数据,可以对连续读取的多个值进行软件滤波,比如取平均值、滑动平均或中值滤波。

  2. CRC校验:为了确保数据传输的准确性,SGP30在每2字节数据后都跟随了1字节的CRC校验码。上面的示例代码为了简化,跳过了CRC校验。在产品开发中,强烈建议实现CRC校验功能,具体算法可以参考SGP30的数据手册。

  3. 预热与稳定:即使初始化完成,传感器也需要一段“预热”时间才能达到最佳精度和稳定性。对于要求高的测量,建议上电后等待更长时间(例如1分钟)再取用数据。

  4. I2C上拉电阻:如果通信不稳定(读不到数据或数据错误),首先检查SDA和SCL线上是否接了正确的上拉电阻(通常4.7kΩ到10kΩ)。这是I2C通信中最常见的问题之一。

  5. 电源质量:确保给SGP30提供干净、稳定的3.3V电源。电源噪声也可能导致传感器工作异常或数据不准。

通过这个实战项目,你不仅学会了如何驱动SGP30传感器,更重要的是掌握了通过GPIO模拟I2C通信的完整流程。这个思路可以迁移到几乎所有I2C接口的传感器或器件上。下次遇到新的I2C设备,你就能自己分析数据手册,写出驱动代码了。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐