文章目录
-
- 摘要
- 前言
- 硬件平台与接线
- 系统架构
- [软件 I2C 的原理与实现](#软件 I2C 的原理与实现)
-
- [为什么选软件 I2C?](#为什么选软件 I2C?)
- [软件 I2C 时序实现](#软件 I2C 时序实现)
- 时序调优的三个坑
- 三从机驱动实现
-
- [AHT20 温湿度传感器](#AHT20 温湿度传感器)
- [BH1750 光照传感器](#BH1750 光照传感器)
- [SSD1306 OLED 显示驱动](#SSD1306 OLED 显示驱动)
- 主程序整合:三从机协作
- 测试验证
- 故障排查指南
-
- [故障 1:OLED 完全不亮](#故障 1:OLED 完全不亮)
- [故障 2:AHT20 温度读出 655°C](#故障 2:AHT20 温度读出 655°C)
- [故障 3:I2C 总线锁死](#故障 3:I2C 总线锁死)
- [故障 4:BH1750 读数始终为 0](#故障 4:BH1750 读数始终为 0)
- [故障 5:OLED 刷新时闪烁](#故障 5:OLED 刷新时闪烁)
- [故障 6:三从机数据偶尔全部读取失败](#故障 6:三从机数据偶尔全部读取失败)
- 总结
- 参考资料
摘要
在嵌入式传感器采集项目中,经常需要在同一组 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 也不亮。
排查步骤:
- 万用表测 VCC ↔ GND 电压,确认是 3.3V(不是 5V)
- 检查 OLED 模块背面电阻焊接配置------I2C 模式下需要短接特定焊盘(BS1 接地)
- 在
OLED_Init()入口加一句delay_ms(500),排除上电时序问题
验证:成功初始化后,屏幕最上行会显示一行微弱的亮线。
故障 2:AHT20 温度读出 655°C
现象:温度值离谱(±几百摄氏度),但 I2C 通信不报错。
原因:这是 AHT20 的经典问题------状态字 bit7 = 0,传感器尚未完成测量转换就开始读数据。代码中漏掉了状态字检查。
解决 :确保读取 6 字节数据后先判断 buf[0] & 0x80,如果不为 1 则丢弃本次读数并重试。
故障 3:I2C 总线锁死
现象:调用任何 I2C 操作都返回超时,SCL/SDA 两根线电平全低或全高。
排查:
- 用万用表或逻辑分析仪确认是 SDA 还是 SCL 被拉死
- 如果 SDA 被拉死(最常见),依次断开从机模块排查哪个设备在强拉 SDA
- 软件恢复方法:将 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 总线的环境监测终端。
核心要点
- 软件 I2C 的核心是将 SDA 引脚在开漏输出和上拉输入之间动态切换,这是正确读写数据的前提
- 多从机 I2C 总线的关键问题不在带宽(本项目占用率 <1%),而在电源噪声和错误恢复策略
- AHT20 的软复位机制是处理传感器半死状态的必要手段,连续 3 次读取失败后建议触发
- 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.h→stm32f4xx.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 的独立驱动详解,含完整引脚说明和寄存器表