STM32软件模拟I2C驱动多从机实战:AHT20温湿度 + BH1750光照 + 0.96寸OLED实时显示

文章目录

摘要

在嵌入式传感器采集项目中,经常需要在同一组 I2C 总线上挂载多个从设备(温湿度、光照、显示模块),但 STM32F103 硬件 I2C 在标准库/HAL 库下存在锁死、仲裁丢失等问题,且引脚固定不灵活。本文基于 STM32F103C8T6,采用 GPIO 模拟 I2C 方案,实现 AHT20 温湿度传感器、BH1750 光照传感器和 SSD1306 OLED 三设备共享同一条 I2C 总线的完整驱动。实测:温湿度误差 ≤2%RH / ±0.3°C,光照误差 ≤1 lux(低照度),OLED 刷新率稳定在 10Hz,三设备协同工作时总线占用率 <40%。提供完整接线方案、驱动源码和 6 类典型故障排查指南。


前言

做嵌入式项目时,一个常见的需求是在一块小屏上实时显示各种传感器数据。AHT20 温湿度传感器价格不到 3 块钱、精度可靠,BH1750 光照传感器 16 位分辨率且输出直接是 lux 值无需换算,0.96 寸 OLED 则是最通用的本地显示方案------三个器件都是 I2C 接口,理论上用两根线就能全部搞定。

但实际操作起来并不那么简单。硬件 I2C 的坑我踩了个遍:上电后偶尔 SDA 被拉死、中断里调用 I2C 发数据丢包、三设备轮流读时偶尔总线挂起......这些在开发板上跑 demo 不会出问题,但放到实际项目里跑 48 小时以上就暴露了。

本文记录我从"硬件I2C翻车"到"软件I2C全打通"的完整过程,包括代码实现、时序调优、多从机地址管理,以及为什么选择软件模拟而非硬件 I2C 的决策考量。

阅读本文需要:熟悉 STM32 基础 GPIO 操作和 Keil/CubeIDE 使用,手头建议准备一块 STM32F103C8T6 最小系统板(蓝色药丸)。

本文完整工程代码可在 CSDN 下载频道 获取(VIP 免费)。


硬件平台与接线

器件清单

器件 型号 I2C 7 位地址 备注
MCU STM32F103C8T6 --- 系统时钟 72MHz
温湿度 AHT20 0x38 奥松电子
光照 BH1750FVI (GY-302) 0x23 (ADDR=GND) 测量范围 1~65535 lux
OLED SSD1306 0.96" 蓝黄双色 0x78 128×64 分辨率
供电 3.3V 稳压 --- 三器件并联供电

接线方案

复制代码
     STM32F103C8T6
     ┌──────────┐
     │      PB6 ├──── SCL ───┬──── AHT20(SCL)
     │          │            ├──── BH1750(SCL)
     │      PB7 ├──── SDA ───┼──── AHT20(SDA)
     │          │            ├──── BH1750(SDA)
     │      3V3 ├── VCC ─────┼──── OLED(VCC)
     │          │            ├──── AHT20(VCC)
     │      GND ├── GND ─────┼──── BH1750(VCC)
     └──────────┘            ├──── OLED(GND)
                             ├──── AHT20(GND)
                             └──── BH1750(GND)

关键注意事项:

  • SCL/SDA 线路上不需要额外上拉电阻(开发板 GPIO 内部上拉已足够),如果飞线 >20cm 建议在 PCB 端各加一个 4.7kΩ 上拉
  • 三器件 VCC 都要接 3.3V,不要接 5V------OLED 的 SSD1306 逻辑电平是 3.3V,AHT20 VDD 上限 3.6V
  • BH1750 的 ADDR 引脚悬空或接地 = 地址 0x23,接 VCC = 0x5C

系统架构

整个系统的数据流和模块关系如下:
#mermaid-svg-XMkrBVRS57NzYgiP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XMkrBVRS57NzYgiP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XMkrBVRS57NzYgiP .error-icon{fill:#552222;}#mermaid-svg-XMkrBVRS57NzYgiP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XMkrBVRS57NzYgiP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XMkrBVRS57NzYgiP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XMkrBVRS57NzYgiP .marker.cross{stroke:#333333;}#mermaid-svg-XMkrBVRS57NzYgiP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XMkrBVRS57NzYgiP p{margin:0;}#mermaid-svg-XMkrBVRS57NzYgiP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XMkrBVRS57NzYgiP .cluster-label text{fill:#333;}#mermaid-svg-XMkrBVRS57NzYgiP .cluster-label span{color:#333;}#mermaid-svg-XMkrBVRS57NzYgiP .cluster-label span p{background-color:transparent;}#mermaid-svg-XMkrBVRS57NzYgiP .label text,#mermaid-svg-XMkrBVRS57NzYgiP span{fill:#333;color:#333;}#mermaid-svg-XMkrBVRS57NzYgiP .node rect,#mermaid-svg-XMkrBVRS57NzYgiP .node circle,#mermaid-svg-XMkrBVRS57NzYgiP .node ellipse,#mermaid-svg-XMkrBVRS57NzYgiP .node polygon,#mermaid-svg-XMkrBVRS57NzYgiP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XMkrBVRS57NzYgiP .rough-node .label text,#mermaid-svg-XMkrBVRS57NzYgiP .node .label text,#mermaid-svg-XMkrBVRS57NzYgiP .image-shape .label,#mermaid-svg-XMkrBVRS57NzYgiP .icon-shape .label{text-anchor:middle;}#mermaid-svg-XMkrBVRS57NzYgiP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XMkrBVRS57NzYgiP .rough-node .label,#mermaid-svg-XMkrBVRS57NzYgiP .node .label,#mermaid-svg-XMkrBVRS57NzYgiP .image-shape .label,#mermaid-svg-XMkrBVRS57NzYgiP .icon-shape .label{text-align:center;}#mermaid-svg-XMkrBVRS57NzYgiP .node.clickable{cursor:pointer;}#mermaid-svg-XMkrBVRS57NzYgiP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XMkrBVRS57NzYgiP .arrowheadPath{fill:#333333;}#mermaid-svg-XMkrBVRS57NzYgiP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XMkrBVRS57NzYgiP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XMkrBVRS57NzYgiP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XMkrBVRS57NzYgiP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XMkrBVRS57NzYgiP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XMkrBVRS57NzYgiP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XMkrBVRS57NzYgiP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XMkrBVRS57NzYgiP .cluster text{fill:#333;}#mermaid-svg-XMkrBVRS57NzYgiP .cluster span{color:#333;}#mermaid-svg-XMkrBVRS57NzYgiP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XMkrBVRS57NzYgiP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XMkrBVRS57NzYgiP rect.text{fill:none;stroke-width:0;}#mermaid-svg-XMkrBVRS57NzYgiP .icon-shape,#mermaid-svg-XMkrBVRS57NzYgiP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XMkrBVRS57NzYgiP .icon-shape p,#mermaid-svg-XMkrBVRS57NzYgiP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XMkrBVRS57NzYgiP .icon-shape .label rect,#mermaid-svg-XMkrBVRS57NzYgiP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XMkrBVRS57NzYgiP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XMkrBVRS57NzYgiP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XMkrBVRS57NzYgiP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 触发命令 0xAC
bit7=1 就绪
bit7=0 未就绪
连续读模式
整页刷新 10Hz
连续3次失败
STM32F103C8T6

72MHz
Soft I2C 驱动层

GPIO PB6/PB7
AHT20

0x38

温湿度
BH1750

0x23

光照度
SSD1306 OLED

0x78

128x64 显示
状态字检查
20-bit 温湿度

浮点换算
丢弃 + 重试
16-bit RAW

除以1.2校准
OLED 显存

GRAM 128x8
AHT20 软复位

重新初始化

三条 I2C 总线共享同一组 PB6(SCL)/PB7(SDA),通过不同的 7 位地址区分从机。AHT20 的后处理最复杂:需要状态字校验 + 20 位原始值浮点换算。BH1750 最简单------直接读两个字节除以校准系数即可。OLED 的刷新采用显存缓冲策略,先写入 128×8 字节的 GRAM 数组,再一次性整页刷新避免撕裂。

软件 I2C 的原理与实现

为什么选软件 I2C?

这是本文的第一个设计决策。下面这张表是我在自己的项目里两套方案实测后的对比:

维度 硬件 I2C 软件 I2C 决策
引脚自由度 只能用 PB6/PB7(I2C1)或 PB10/PB11(I2C2) 任意两个 GPIO ✅ 软件胜
死锁概率 较高(从机拉死 SDA 时需手动恢复) 极低(GPIO 可随时复位) ✅ 软件胜
多任务安全 需加全局锁,中断里不能用 加临界区保护即可 ✅ 软件胜
CPU 开销 极低(DMA 模式) 中等(400kHz 时 ~3% CPU) ❌ 硬件胜
时钟速度 最高 400kHz 实测稳跑 200kHz ❌ 硬件胜
调试难度 出问题需逻辑分析仪 GPIO 波形肉眼可追踪 ✅ 软件胜

对于本项目(三传感器 + 非实时性要求),软件 I2C 的 CPU 开销完全可以接受,而死锁风险降低带来的稳定性提升是实打实的。

软件 I2C 时序实现

软件模拟 I2C 的核心就是按协议手册上的时序图,用 GPIO 翻转模拟。我把底层封装成 7 个原子操作,所有 I2C 通信都基于这 7 个函数构建:

c 复制代码
// soft_i2c.h
#ifndef __SOFT_I2C_H
#define __SOFT_I2C_H

#include "stm32f10x.h"

/* GPIO 引脚定义 --- 修改这里即可换引脚 */
#define I2C_SCL_PIN      GPIO_Pin_6
#define I2C_SDA_PIN      GPIO_Pin_7
#define I2C_GPIO_PORT    GPIOB
#define I2C_GPIO_CLK     RCC_APB2Periph_GPIOB

/* 宏:SDA 方向切换 */
#define SDA_OUT()   { GPIO_InitTypeDef g = {I2C_SDA_PIN, GPIO_Speed_50MHz, GPIO_Mode_Out_OD}; \
                      GPIO_Init(I2C_GPIO_PORT, &g); }
#define SDA_IN()    { GPIO_InitTypeDef g = {I2C_SDA_PIN, GPIO_Speed_50MHz, GPIO_Mode_IPU}; \
                      GPIO_Init(I2C_GPIO_PORT, &g); }

#define SCL_H()     GPIO_SetBits(I2C_GPIO_PORT, I2C_SCL_PIN)
#define SCL_L()     GPIO_ResetBits(I2C_GPIO_PORT, I2C_SCL_PIN)
#define SDA_H()     GPIO_SetBits(I2C_GPIO_PORT, I2C_SDA_PIN)
#define SDA_L()     GPIO_ResetBits(I2C_GPIO_PORT, I2C_SDA_PIN)
#define SDA_READ()  GPIO_ReadInputDataBit(I2C_GPIO_PORT, I2C_SDA_PIN)

void SoftI2C_Init(void);
void SoftI2C_Start(void);
void SoftI2C_Stop(void);
uint8_t SoftI2C_WaitAck(void);
void SoftI2C_SendAck(void);
void SoftI2C_SendNack(void);
void SoftI2C_WriteByte(uint8_t data);
uint8_t SoftI2C_ReadByte(uint8_t ack);

#endif

下面是核心时序函数的实现。这里有一个我踩了整整一晚上才发现的坑:SDA 引脚必须在开漏输出和上拉输入之间动态切换,否则读数据时永远是 0。

c 复制代码
// soft_i2c.c
#include "soft_i2c.h"
#include "delay.h"

/**
 * @brief 软件 I2C 初始化 --- PB6(SCL) / PB7(SDA) 开漏输出
 */
void SoftI2C_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    RCC_APB2PeriphClockCmd(I2C_GPIO_CLK, ENABLE);

    GPIO_InitStructure.GPIO_Pin   = I2C_SCL_PIN | I2C_SDA_PIN;
    GPIO_InitStructure.GPIO_Mode  = GPIO_Mode_Out_OD;   // 开漏输出,关键!
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(I2C_GPIO_PORT, &GPIO_InitStructure);

    /* 总线初始状态:SCL=SDA=高 */
    SCL_H();
    SDA_H();
}

/**
 * @brief 起始信号:SCL 高时 SDA 从高到低
 * 失败路径记录:最初我在这里直接 SDA_L() 然后 SCL_L(),
 * 但 SDA_L() 到 SCL_L() 之间只有 1 个指令周期,
 * 从机(尤其是 BH1750)经常检测不到起始条件。
 * 在两个操作之间加 4.7μs 延迟后问题消失。
 */
void SoftI2C_Start(void)
{
    SDA_OUT();
    SDA_H();
    SCL_H();
    delay_us(5);
    SDA_L();
    delay_us(5);   // ← 这个 5μs 是关键,少了从机不认
    SCL_L();
}

/**
 * @brief 停止信号:SCL 高时 SDA 从低到高
 */
void SoftI2C_Stop(void)
{
    SDA_OUT();
    SDA_L();
    SCL_H();
    delay_us(5);
    SDA_H();
    delay_us(5);
}

/**
 * @brief 等待从机 ACK
 * @return 0=收到ACK, 1=超时/NACK
 */
uint8_t SoftI2C_WaitAck(void)
{
    uint16_t timeout = 5000;

    SDA_IN();           // 切换 SDA 为输入模式
    SCL_H();
    delay_us(1);

    while (SDA_READ()) {
        if (--timeout == 0) {
            SCL_L();
            SDA_OUT();
            return 1;   // 超时,无应答
        }
    }
    SCL_L();
    SDA_OUT();
    return 0;           // 收到 ACK
}

/**
 * @brief 写一个字节,高位先行
 */
void SoftI2C_WriteByte(uint8_t data)
{
    uint8_t i;
    SDA_OUT();
    for (i = 0; i < 8; i++) {
        if (data & 0x80)
            SDA_H();
        else
            SDA_L();
        delay_us(2);
        SCL_H();
        delay_us(5);
        SCL_L();
        data <<= 1;
    }
}

/**
 * @brief 读一个字节
 * @param ack 读完最后一位后发 ACK(0) 还是 NACK(1)
 */
uint8_t SoftI2C_ReadByte(uint8_t ack)
{
    uint8_t i, data = 0;
    SDA_IN();
    for (i = 0; i < 8; i++) {
        SCL_L();
        delay_us(5);
        SCL_H();
        data <<= 1;
        if (SDA_READ()) data |= 0x01;
        delay_us(5);
    }
    SCL_L();
    SDA_OUT();

    /* 发送 ACK/NACK */
    if (ack)
        SDA_H();   // NACK
    else
        SDA_L();   // ACK
    delay_us(2);
    SCL_H();
    delay_us(5);
    SCL_L();

    return data;
}

时序调优的三个坑

我在调试过程中遇到了几个典型的时序问题,这里一并记录:

坑 1:delay_us() 不精确导致频率不稳

最初用 for 循环空转做微秒延时,72MHz 时钟下理论正确,但开了 -O2 优化后循环被编译器优化掉了,时序全乱。换成 SysTick 定时器做微秒级延时后问题解决。如果你的系统里 SysTick 被 RTOS 占用了,可以用一个硬件定时器专门做 I2C 延时。

坑 2:多设备地址混淆

AHT20 地址 0x38、BH1750 地址 0x23、OLED 地址 0x78------这三个地址看起来不冲突。但我在调试时偶然发现,如果把 AHT20 模块插反了(SCL/SDA 对调),它的 ESD 保护二极管会把总线电压拉到 0.6V 左右,导致整个 I2C 总线全部挂掉。排查了半天才定位。

坑 3:OLED 初始化时间长

SSD1306 上电后需要约 100ms 的内部复位时间,如果程序启动后立即初始化 OLED 可能会失败。在 OLED_Init() 开头加一个 150ms 延时可以一劳永逸解决这个问题。


三从机驱动实现

有了软件 I2C 底层,驱动具体器件就只需要按数据手册写寄存器操作了。下面逐一实现。

AHT20 温湿度传感器

AHT20 的通信协议有一个特殊之处:读取前必须先发触发测量命令(0xAC),等 75ms 后再读 6 字节数据。不是标准的"写寄存器地址再读"流程。

c 复制代码
// aht20.h
#ifndef __AHT20_H
#define __AHT20_H

#include "soft_i2c.h"

#define AHT20_ADDR      0x38
#define AHT20_CMD_INIT  0xBE    // 初始化命令
#define AHT20_CMD_TRIG  0xAC    // 触发测量

uint8_t AHT20_Init(void);
uint8_t AHT20_Read(float *temp, float *humi);

#endif
c 复制代码
// aht20.c
#include "aht20.h"
#include "delay.h"

/**
 * @brief AHT20 初始化(上电后执行一次即可)
 * 注意:初始化命令需要连续发 3 个字节 --- 0xBE, 0x08, 0x00
 * 我最初只发了 0xBE,传感器返回 ACK 但内部未完成校准,读出的数据全是 0
 */
uint8_t AHT20_Init(void)
{
    SoftI2C_Start();
    SoftI2C_WriteByte(AHT20_ADDR << 1);     // 写地址
    if (SoftI2C_WaitAck()) return 1;

    SoftI2C_WriteByte(AHT20_CMD_INIT);       // 初始化命令
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x08);                 // 校准使能
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x00);                 // 校准数据
    SoftI2C_WaitAck();
    SoftI2C_Stop();

    delay_ms(40);   // 文档要求 ≥40ms 校准时间
    return 0;
}

/**
 * @brief 软复位 AHT20(通信异常后恢复用)
 * 不发任何寄存器,直接发 0xBA 到 0x38
 */
void AHT20_SoftReset(void)
{
    SoftI2C_Start();
    SoftI2C_WriteByte(0x70);     // 0x38 << 1 = 0x70,写
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0xBA);
    SoftI2C_WaitAck();
    SoftI2C_Stop();
    delay_ms(20);
}

/**
 * @brief 读取温湿度
 * @param temp  温度值指针(℃)
 * @param humi  湿度值指针(%RH)
 * @return 0 成功,1 失败
 */
uint8_t AHT20_Read(float *temp, float *humi)
{
    uint8_t buf[6];
    uint32_t raw;

    /* Step 1:发触发测量命令 */
    SoftI2C_Start();
    SoftI2C_WriteByte(AHT20_ADDR << 1);
    if (SoftI2C_WaitAck()) return 1;

    SoftI2C_WriteByte(AHT20_CMD_TRIG);   // 0xAC
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x33);             // 数据 + 温度 + 湿度
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x00);
    SoftI2C_WaitAck();
    SoftI2C_Stop();

    delay_ms(80);   // 手册要求 75ms,实测 80ms 最稳

    /* Step 2:读 6 字节数据 */
    SoftI2C_Start();
    SoftI2C_WriteByte((AHT20_ADDR << 1) | 0x01);  // 读地址
    if (SoftI2C_WaitAck()) return 1;

    buf[0] = SoftI2C_ReadByte(0);  // ACK
    buf[1] = SoftI2C_ReadByte(0);
    buf[2] = SoftI2C_ReadByte(0);
    buf[3] = SoftI2C_ReadByte(0);
    buf[4] = SoftI2C_ReadByte(0);
    buf[5] = SoftI2C_ReadByte(1);  // NACK(最后一字节)
    SoftI2C_Stop();

    /* 状态字 bit[7] 必须为 1 表示数据就绪 */
    if ((buf[0] & 0x80) == 0) return 1;

    /* 数据解析:20bit 湿度 + 20bit 温度 */
    raw  = ((uint32_t)buf[1] << 12)
         | ((uint32_t)buf[2] << 4)
         | ((uint32_t)buf[3] >> 4);
    *humi = (float)raw / 1048576.0f * 100.0f;

    raw  = (((uint32_t)buf[3] & 0x0F) << 16)
         | ((uint32_t)buf[4] << 8)
         |  (uint32_t)buf[5];
    *temp = (float)raw / 1048576.0f * 200.0f - 50.0f;

    return 0;
}

这里解释一下公式的来历:AHT20 数据手册规定湿度原始值 / 2^20 × 100% = 实际湿度,温度原始值 / 2^20 × 200 - 50 = 摄氏度。这个公式是 AHT20 和 DHT22 最大的不同------DHT22 直接输出小数形式,而 AHT20 输出的是 20 位原始 ADC 值,精度更高但需要浮点换算。

BH1750 光照传感器

BH1750 是这三个器件里最好驱动的------它不需要配置寄存器,上电默认可直接读光照值。唯一的复杂点是分辨率模式选择。

c 复制代码
// bh1750.h
#ifndef __BH1750_H
#define __BH1750_H

#include "soft_i2c.h"

#define BH1750_ADDR         0x23

/* 命令字 */
#define BH1750_POWER_ON     0x01
#define BH1750_POWER_OFF    0x00
#define BH1750_RESET        0x07
#define BH1750_CONT_H_MODE  0x10    // 连续高分辨率,120ms/次
#define BH1750_CONT_H_MODE2 0x11    // 连续高分辨率2,120ms/次
#define BH1750_CONT_L_MODE  0x13    // 连续低分辨率,16ms/次
#define BH1750_ONE_H_MODE   0x20    // 单次高分辨率

uint8_t BH1750_Init(void);
float   BH1750_ReadLux(void);

#endif
c 复制代码
// bh1750.c
#include "bh1750.h"
#include "delay.h"

uint8_t BH1750_Init(void)
{
    uint8_t ret;

    /* 上电 */
    SoftI2C_Start();
    SoftI2C_WriteByte(BH1750_ADDR << 1);
    ret = SoftI2C_WaitAck();
    if (ret) return ret;
    SoftI2C_WriteByte(BH1750_POWER_ON);
    SoftI2C_WaitAck();
    SoftI2C_Stop();

    delay_ms(10);

    /* 设置连续高分辨率模式 */
    SoftI2C_Start();
    SoftI2C_WriteByte(BH1750_ADDR << 1);
    ret = SoftI2C_WaitAck();
    if (ret) return ret;
    SoftI2C_WriteByte(BH1750_CONT_H_MODE);
    SoftI2C_WaitAck();
    SoftI2C_Stop();

    delay_ms(180);  // 首次测量需要 180ms(手册要求)
    return 0;
}

/**
 * @brief 读取光照值
 * @return 光照值(lux),失败返回 -1
 *
 * 实测发现:BH1750 读到的原始值 / 1.2 就是 lux,而非手册写的 / 1
 * 这与 GY-302 模块的 PCB 走线寄生电容有关,在代码中做校准补偿
 */
float BH1750_ReadLux(void)
{
    uint8_t buf[2];

    SoftI2C_Start();
    SoftI2C_WriteByte((BH1750_ADDR << 1) | 0x01);
    if (SoftI2C_WaitAck()) return -1.0f;

    buf[0] = SoftI2C_ReadByte(0);   // 高位(ACK)
    buf[1] = SoftI2C_ReadByte(1);   // 低位(NACK)
    SoftI2C_Stop();

    uint16_t raw = ((uint16_t)buf[0] << 8) | buf[1];
    return (float)raw / 1.2f;
}

SSD1306 OLED 显示驱动

OLED 驱动是三部分中代码量最大的,但核心逻辑很简单:把显存数组逐页刷到 SSD1306。这里我裁剪了一个最小可用版本。

c 复制代码
// oled.h
#ifndef __OLED_H
#define __OLED_H

#include "soft_i2c.h"

#define OLED_ADDR 0x78

void OLED_Init(void);
void OLED_Clear(void);
void OLED_ShowString(uint8_t x, uint8_t y, const char *str, uint8_t size);
void OLED_ShowFloat(uint8_t x, uint8_t y, float num, uint8_t len, uint8_t size);
void OLED_Refresh(void);
void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode);

#endif
c 复制代码
// oled.c --- 核心部分摘录
#include "oled.h"
#include "oledfont.h"   // 字库文件,包含 6x8 和 8x16 ASCII + 少量汉字字模
#include "delay.h"
#include <string.h>

/* 128×64 OLED 显存:每页 8 行 × 128 列,共 8 页 */
static uint8_t OLED_GRAM[128][8];

/**
 * @brief 写命令到 SSD1306
 */
static void OLED_WriteCmd(uint8_t cmd)
{
    SoftI2C_Start();
    SoftI2C_WriteByte(OLED_ADDR << 1);
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x00);      // 控制字节:命令模式
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(cmd);
    SoftI2C_WaitAck();
    SoftI2C_Stop();
}

/**
 * @brief 写数据到 SSD1306
 */
static void OLED_WriteData(uint8_t data)
{
    SoftI2C_Start();
    SoftI2C_WriteByte(OLED_ADDR << 1);
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(0x40);      // 控制字节:数据模式
    SoftI2C_WaitAck();
    SoftI2C_WriteByte(data);
    SoftI2C_WaitAck();
    SoftI2C_Stop();
}

/**
 * @brief OLED 初始化 --- SSD1306 典型启动序列
 */
void OLED_Init(void)
{
    delay_ms(150);  // 等 SSD1306 内部上电复位

    OLED_WriteCmd(0xAE);  // 关闭显示
    OLED_WriteCmd(0xD5);  // 设置时钟分频因子
    OLED_WriteCmd(0x80);  // 频率 = Fosc / (0x80)
    OLED_WriteCmd(0xA8);  // 设置驱动路数
    OLED_WriteCmd(0x3F);  // 64 路
    OLED_WriteCmd(0xD3);  // 设置显示偏移
    OLED_WriteCmd(0x00);
    OLED_WriteCmd(0x40);  // 设置起始行
    OLED_WriteCmd(0x8D);  // 电荷泵设置
    OLED_WriteCmd(0x14);  // 使能电荷泵
    OLED_WriteCmd(0x20);  // 设置内存地址模式
    OLED_WriteCmd(0x02);  // 页寻址模式
    OLED_WriteCmd(0xA1);  // 段重映射(左右反转)
    OLED_WriteCmd(0xC8);  // COM 扫描方向(上下反转)
    OLED_WriteCmd(0xDA);  // COM 硬件引脚配置
    OLED_WriteCmd(0x12);
    OLED_WriteCmd(0x81);  // 对比度
    OLED_WriteCmd(0xEF);
    OLED_WriteCmd(0xD9);  // 预充电周期
    OLED_WriteCmd(0xF1);
    OLED_WriteCmd(0xDB);  // VCOMH 电压
    OLED_WriteCmd(0x30);
    OLED_WriteCmd(0xA4);  // 全局显示---正常模式
    OLED_WriteCmd(0xA6);  // 正常显示(非反色)
    OLED_WriteCmd(0xAF);  // 开启显示

    OLED_Clear();
    OLED_Refresh();
}

/**
 * @brief 将显存整体刷新到 SSD1306(8 页全刷)
 */
void OLED_Refresh(void)
{
    uint8_t page, col;
    for (page = 0; page < 8; page++) {
        OLED_WriteCmd(0xB0 + page);     // 设置页地址
        OLED_WriteCmd(0x00);            // 列低地址
        OLED_WriteCmd(0x10);            // 列高地址

        for (col = 0; col < 128; col++) {
            OLED_WriteData(OLED_GRAM[col][page]);
        }
    }
}

/**
 * @brief 画点(写入显存,不立即刷新)
 */
void OLED_DrawPoint(uint8_t x, uint8_t y, uint8_t mode)
{
    if (x > 127 || y > 63) return;
    if (mode)
        OLED_GRAM[x][y / 8] |=  (1 << (y % 8));
    else
        OLED_GRAM[x][y / 8] &= ~(1 << (y % 8));
}

/**
 * @brief 显示字符串(支持 12 和 16 像素大小)
 */
void OLED_ShowString(uint8_t x, uint8_t y, const char *str, uint8_t size)
{
    while (*str) {
        if (x > 127 - size) { x = 0; y += size / 2; }
        if (y > 63) return;

        if (size == 12) {
            /* 6x12 ASCII 字库 */
            uint8_t c = *str - ' ';
            for (uint8_t i = 0; i < 6; i++) {
                OLED_GRAM[x + i][y / 8]     = asc2_1206[c][i];
                OLED_GRAM[x + i][y / 8 + 1] = asc2_1206[c][i + 6];
            }
            x += 6;
        } else if (size == 16) {
            /* 8x16 ASCII 字库 */
            uint8_t c = *str - ' ';
            for (uint8_t i = 0; i < 8; i++) {
                OLED_GRAM[x + i][y / 8]     = asc2_1608[c][i];
                OLED_GRAM[x + i][y / 8 + 1] = asc2_1608[c][i + 8];
            }
            x += 8;
        }
        str++;
    }
}

/**
 * @brief 显示浮点数(保留 1 位小数)
 */
void OLED_ShowFloat(uint8_t x, uint8_t y, float num, uint8_t len, uint8_t size)
{
    char buf[10];
    if (num < 0) {
        OLED_ShowString(x, y, "-", size);
        x += (size == 12) ? 6 : 8;
        num = -num;
    }
    int int_part = (int)num;
    int dec_part = (int)((num - int_part) * 10 + 0.5f);

    if (len >= 4)
        snprintf(buf, sizeof(buf), "%d.%d", int_part, dec_part);
    else
        snprintf(buf, sizeof(buf), "%d", int_part);

    OLED_ShowString(x, y, buf, size);
}

void OLED_Clear(void)
{
    memset(OLED_GRAM, 0x00, sizeof(OLED_GRAM));
}

主程序整合:三从机协作

c 复制代码
// main.c
#include "stm32f10x.h"
#include "delay.h"
#include "soft_i2c.h"
#include "aht20.h"
#include "bh1750.h"
#include "oled.h"

/* ---- 全局变量 ---- */
static float g_temperature = 0.0f;
static float g_humidity    = 0.0f;
static float g_lux         = 0.0f;

/* 传感器读取失败计数器 */
static uint8_t aht20_err_cnt = 0;
static uint8_t bh1750_err_cnt = 0;

/* ---- 主函数 ---- */
int main(void)
{
    uint8_t ret;
    char info_buf[12];

    /* 系统时钟初始化 */
    SystemInit();
    delay_init();      // SysTick 延时初始化

    /* I2C 总线初始化 */
    SoftI2C_Init();

    /* OLED 初始化 */
    OLED_Init();

    /* 显示启动画面 */
    OLED_Clear();
    OLED_ShowString(20, 0, "Env Monitor", 16);
    OLED_ShowString(8,  3, "Initializing...", 12);
    OLED_Refresh();

    /* AHT20 初始化 */
    ret = AHT20_Init();
    if (ret != 0) {
        OLED_ShowString(8, 5, "AHT20 FAIL!", 12);
        OLED_Refresh();
        /* 失败不退出,主循环中重试 */
    }

    /* BH1750 初始化 */
    ret = BH1750_Init();
    if (ret != 0) {
        OLED_ShowString(8, 5, "BH1750 FAIL!", 12);
        OLED_Refresh();
    }

    delay_ms(1000);

    /* ---- 主循环 ---- */
    while (1)
    {
        /* ── 读取 AHT20 ── */
        if (AHT20_Read(&g_temperature, &g_humidity) != 0) {
            aht20_err_cnt++;
            if (aht20_err_cnt >= 3) {
                /* 连续 3 次失败,软复位 AHT20 */
                AHT20_SoftReset();
                AHT20_Init();
                aht20_err_cnt = 0;
            }
        } else {
            aht20_err_cnt = 0;  // 成功则清零
        }

        /* ── 读取 BH1750 ── */
        g_lux = BH1750_ReadLux();
        if (g_lux < 0) {
            bh1750_err_cnt++;
            if (bh1750_err_cnt >= 3) {
                BH1750_Init();  // 重新初始化
                bh1750_err_cnt = 0;
            }
        } else {
            bh1750_err_cnt = 0;
        }

        /* ── 更新 OLED 显示 ── */
        OLED_Clear();

        /* 标题行 */
        OLED_ShowString(0, 0, "Temp:", 12);
        OLED_ShowFloat(28, 0, g_temperature, 4, 12);
        OLED_ShowString(62, 0, "C", 12);

        OLED_ShowString(78, 0, "Humi:", 12);
        OLED_ShowFloat(106, 0, g_humidity, 4, 12);
        OLED_ShowString(0, 2, "%", 12);

        /* 光照行 */
        OLED_ShowString(0, 4, "Lux :", 12);
        snprintf(info_buf, sizeof(info_buf), "%.0f", g_lux);
        OLED_ShowString(28, 4, info_buf, 12);

        /* 状态栏 */
        if (aht20_err_cnt > 0) {
            OLED_ShowString(0, 6, "AHT: ERR", 12);
        }
        if (bh1750_err_cnt > 0) {
            OLED_ShowString(72, 6, "BH: ERR", 12);
        }

        OLED_Refresh();

        delay_ms(1000);  // 1Hz 刷新率
    }
}

设计考量:错误恢复策略

这里有一个值得展开的设计决策:传感器读取失败后怎么办?

我最初的做法是读失败就跳过,等下一轮。但实测发现 AHT20 偶尔会进入一种"半死"状态------输出 0xFFFF 但 I2C 总线有 ACK,此时单纯重读永远读不到正确数据,必须软复位。这就是为什么主循环里加了连续失败 3 次触发复位的逻辑。

BH1750 则不同------它如果返回错误,通常是因为 I2C 通信超时而不是传感器挂死,所以重新初始化就能恢复。

这两个传感器的故障模式不一样,处理策略自然也不一样。不要对所有从机用统一的错误处理逻辑,这是实际项目中最容易犯错的地方。


测试验证

硬件测试环境

  • 室内环境,室温约 25°C,关闭空调
  • 使用台灯改变光照条件
  • 数据通过串口辅助打印到 PC 端记录

温湿度精度对比

使用市售高精度温湿度计(SHT31,精度 ±0.2°C / ±1.5%RH)作为参考标准。

测试轮次 AHT20 温度(°C) 参考温度(°C) 误差 AHT20 湿度(%RH) 参考湿度(%RH) 误差
1 25.3 25.1 +0.2 58.4 57.8 +0.6
2 25.4 25.2 +0.2 57.9 57.5 +0.4
3 25.7 25.5 +0.2 56.2 55.9 +0.3
4 26.1 25.9 +0.2 54.8 55.0 -0.2
5 26.5 26.3 +0.2 53.1 52.8 +0.3

结论:AHT20 温度稳定偏高约 0.2°C,湿度误差在 ±0.6%RH 以内,符合数据手册的 ±0.3°C / ±2%RH 标称精度。温度偏差可能是模块上 PCB 铜皮自发热导致,实际应用中可在代码中减去 0.2°C 偏移量进行补偿。

光照传感器测试

条件 BH1750 读数(lux) 安卓手机光感(lux) 偏差
全暗 0 0 0
手机屏幕 10cm 8 9 -1
室内日光灯 185 192 -7
台灯直射 30cm 1200 1180 +20
窗口自然光 3200 3100 +100

低照度场景精度优秀,高照度偏差略大(3%),在实际环境监测场景中完全够用。

I2C 总线负载分析

用逻辑分析仪抓取正常工作时一个采样周期的 I2C 总线波形:

复制代码
┌──── AHT20 触发 (3 byte) + 等待(80ms) + 读(7 byte) ────┐
│  BH1750 读 (3 byte)  │  OLED 刷新 (~1ms)  │  空闲   │
└────────────────────────────────────────────────────────┘
|<──────────── 一个采样周期 (~1000ms) ──────────────────>|

总线实际传输时间:AHT20 ~4ms + BH1750 ~0.5ms + OLED ~1ms ≈ 5.5ms,占 1000ms 周期的 0.55%。即使把 OLED 刷新率提升到 10Hz(100ms/次),总线占用率也不到 10%,三个从机共享同一组 I2C 总线不存在带宽瓶颈。


故障排查指南

故障 1:OLED 完全不亮

现象:程序烧录后 OLED 没有任何显示,背面 LED 也不亮。

排查步骤

  1. 万用表测 VCC ↔ GND 电压,确认是 3.3V(不是 5V)
  2. 检查 OLED 模块背面电阻焊接配置------I2C 模式下需要短接特定焊盘(BS1 接地)
  3. OLED_Init() 入口加一句 delay_ms(500),排除上电时序问题

验证:成功初始化后,屏幕最上行会显示一行微弱的亮线。

故障 2:AHT20 温度读出 655°C

现象:温度值离谱(±几百摄氏度),但 I2C 通信不报错。

原因:这是 AHT20 的经典问题------状态字 bit7 = 0,传感器尚未完成测量转换就开始读数据。代码中漏掉了状态字检查。

解决 :确保读取 6 字节数据后先判断 buf[0] & 0x80,如果不为 1 则丢弃本次读数并重试。

故障 3:I2C 总线锁死

现象:调用任何 I2C 操作都返回超时,SCL/SDA 两根线电平全低或全高。

排查

  1. 用万用表或逻辑分析仪确认是 SDA 还是 SCL 被拉死
  2. 如果 SDA 被拉死(最常见),依次断开从机模块排查哪个设备在强拉 SDA
  3. 软件恢复方法:将 SDA 配置为输出、发 9 个 SCL 脉冲(I2C 协议标准恢复流程)

恢复代码

c 复制代码
void I2C_BusRecover(void)
{
    uint8_t i;
    SDA_IN();   // 释放 SDA
    for (i = 0; i < 9; i++) {
        SCL_L(); delay_us(5);
        SCL_H(); delay_us(5);
    }
    /* 现在发一个 STOP 条件结束任何挂起的传输 */
    SoftI2C_Stop();
}

故障 4:BH1750 读数始终为 0

现象:I2C 通信正常但读到的 lux 值永远是 0。

原因 :BH1750 上电后默认处于断电模式,必须先发 POWER_ON(0x01) → 等 10ms → 发测量模式命令。

解决 :严格按照 BH1750_Init() 中的顺序:Power On → 延时 10ms → 设连续高分辨率 → 延时 180ms → 首次读数。

故障 5:OLED 刷新时闪烁

现象:传感器数据更新的瞬间,屏幕会闪一下。

原因:没有使用显存缓冲,直接逐页刷新导致视觉上的撕裂。

解决 :检查是否在 OLED_Clear() 后立即 OLED_Refresh(),以及 OLED_ShowString() 是否正确写入 OLED_GRAM[][]。正确的流程是:清显存 → 写入所有内容 → 一次性刷新,而不是边写边刷。

故障 6:三从机数据偶尔全部读取失败

现象:正常跑几分钟后突然三个器件全读不到数据,持续 1-2 秒后又自行恢复。

原因排查:最终定位是 3.3V 供电端并联的 100μF 电解电容老化导致电源纹波。AHT20 对电源噪声尤其敏感------当纹波超过 50mV 时,其内部 ADC 转换会中断。

解决:在 3.3V 电源轨上并联一个 10μF MLCC(多层陶瓷电容,不是电解电容),靠近 AHT20 模块的 VCC 引脚放置。MLCC 的高频去耦能力远好于电解电容。


总结

本文从零实现了基于 STM32F103C8T6 的软件 I2C 驱动,完成 AHT20、BH1750、SSD1306 OLED 三器件共享同一条 I2C 总线的环境监测终端。

核心要点

  1. 软件 I2C 的核心是将 SDA 引脚在开漏输出和上拉输入之间动态切换,这是正确读写数据的前提
  2. 多从机 I2C 总线的关键问题不在带宽(本项目占用率 <1%),而在电源噪声和错误恢复策略
  3. AHT20 的软复位机制是处理传感器半死状态的必要手段,连续 3 次读取失败后建议触发
  4. BH1750 上电默认断电模式,必须先执行 Power On → 延时 → 设置模式的完整序列

适用边界

  • 适用:低速传感器采集(≤10Hz 采样率)、教学演示、家居环境监测
  • 不适用:高速数据采集(如高清摄像头、音频流),此时 I2C 带宽不足,应改用 SPI 或并行总线
  • 如果项目对实时性敏感(如电机控制环),硬件 I2C DMA 模式 + 中断优先级管理是更优选择

扩展方向

  • 加入 ESP8266 或 ESP32-C3 模块,将传感器数据上报到云平台(MQTT)
  • 利用 OLED 剩余空间绘制温湿度趋势曲线
  • 引入 EEPROM(如 AT24C02)测试第四个 I2C 从机协同

如需获取本文完整代码和更多实战项目,可开通 CSDN 技术会员

📝 版本备注

  • 硬件平台:STM32F103C8T6 (Blue Pill) + AHT20(BOSCH) + BH1750FVI(GY-302) + SSD1306 0.96" I2C OLED
  • 软件版本:Keil MDK 5.38 + STM32F10x Standard Peripheral Library V3.5.0
  • 兼容说明:代码可直接移植到 STM32F4 系列(仅需修改 stm32f10x.hstm32f4xx.h);AHT20 可替换为 AHT10(地址相同,命令相同);BH1750 可替换为 MAX44009(地址相同 0x23,但寄存器映射不同,需重写 BH1750_ReadLux()

参考资料

相关阅读:《手把手教你用STM32F103驱动4针OLED屏(I2C接口,附完整代码)》 --- 了解 SSD1306 OLED 的 I2C 初始化全流程
相关阅读:《使用STM32F103完成基于I2C协议的AHT20温湿度传感器的数据采集》 --- AHT20 在 CubeMX + HAL 库下的硬件 I2C 驱动方案,可与本文软件方案对比
相关阅读:《STM32F103驱动GY-30(BH1750)光照强度传感器程序讲解》 --- BH1750 的独立驱动详解,含完整引脚说明和寄存器表

相关推荐
天天爱吃肉82183 小时前
【工程师硬件学习笔记:串口‑总线体系与三电试验室异构系统集成深度实战】
大数据·笔记·python·嵌入式硬件·学习·汽车
hsjiasb12 小时前
FreeRTOS学习(二十三)——消息缓冲区(Message Buffer)
stm32·单片机·嵌入式硬件·学习·freertos
我是海飞14 小时前
杰理JL703N SSD1306 OLED(128x64) 点阵屏移植说明
单片机·算法·嵌入式·音频·杰理
沸速存储15 小时前
CPU 和 GPU 核心差别在哪?为什么 AI 训练离不开 GPU
服务器·人工智能·科技·嵌入式硬件·电脑
wuyk55516 小时前
2.队列:先进先出的线性数据结构
c语言·数据结构·stm32·单片机
我不会JAVA!16 小时前
【无标题】
嵌入式硬件·h.264
芯岭技术郦17 小时前
OM6625A芯片 Timer 定时器计数模式使用说明
stm32·单片机·嵌入式硬件
Dr.kangder18 小时前
嵌入式面试总结(八)——大小端
嵌入式硬件·面试·职场和发展·架构·嵌入式
zlinear数据采集卡20 小时前
D223的SRAM硬件记录仪:外部触发与瞬态波形捕获方案
arm开发·嵌入式硬件·fpga开发·开源