☰
STM32F1+SHT30+OLED:软件模拟I2C驱动与温湿度显示实战
2026/10/15 15:19:28 网站建设 项目流程

简介:基于正点原子STM32F1 mini板的SHT30温湿度传感器与0.96寸OLED显示源码包,面向嵌入式初学者及需要快速搭建环境监测项目的开发者。工程完整实现了I2C总线配置、SHT30数据读取与校验、SSD1306驱动显示,以及定时刷新、界面切换逻辑,可直接编译烧录后在OLED屏上查看实时温湿度。资源共200个文件,压缩包约4.89MB,核心内容以C源码与H头文件为主,同时包含uvprojx等Keil工程配置、hex烧录文件、map/lst编译输出等辅助材料,目录结构规整,便于按模块逐项学习。已有2444人学习下载,适合通过实际代码理解传感器驱动、I2C通信与嵌入式GUI设计的完整流程,也可作为正点原子开发板进阶练习的参考样例。

1. STM32F1 + SHT30 + OLED:这个组合为什么值得你花一个周末

STM32F1、SHT30、0.96寸OLED,这三个名词放在一起,是很多开发者入坑嵌入式传感采集的经典组合。SHT30是 Sensirion 新一代数字温湿度传感器,I2C 接口,精度比老款 SHT20 更高、功耗更低;OLED 屏幕最常用的是 SSD1306 驱动芯片、128x64 分辨率的 0.96 寸屏;STM32F103 则是永不过时的入门级 MCU。三样东西都不算贵,接线只要四根(电源、地、SCL、SDA),一个周末就能跑通一个实时显示温湿度的独立小系统。

但这篇笔记想解决的不是「怎么点亮」,而是「怎么让它一次跑通、源码可复现」。我见过太多人卡在同一个坑里:SHT30 的 CRC 校验不过、OLED 的 I2C 地址搞反、F1 硬件 I2C 死等事件标志导致卡死——每一个都足以让你怀疑人生。这篇文章会直接给你一套能用的驱动代码和完整工程思路,从 I2C 时序讲到显存模型,再讲到排错手段。适合正在做课程设计、想给毕设加一个显示模块、或者准备把传感器接入自己小项目的开发者。你不需要先精通 HAL 库,只需要有 STM32 最基本的开发环境。

2. SHT30 驱动:I2C 时序、寄存器命令与 CRC 校验

2.1 先搞清楚 SHT30 的 I2C 地址和测量模式

SHT30 的 I2C 从机地址是 0x44(7 位地址),换算成 8 位写地址是 0x88,读地址是 0x89。很多新手在这里第一次翻车——OLED 的地址是 0x3C(8 位写地址 0x78),两个设备地址形式不一样,容易混淆。特别注意:SHT30 还有一个可选地址引脚 ADDR,接地是 0x44,接高电平是 0x45,默认不焊电阻就是 0x44。

SHT30 支持单次测量和周期测量两种模式。单次测量模式适合电池供电、需要按需采样的小系统:发一条命令,等待测量完成,读回 6 个字节(温度 2 字节 + CRC 1 字节 + 湿度 2 字节 + CRC 1 字节)。周期测量模式适合持续监测,传感器会按 0.5、1、2、4、10 Hz 的节奏自动刷新数据,MCU 只需要定期去读。对于 OLED 实时显示这种场景,单次测量就够了——屏幕刷新率也就几赫兹,没必要让传感器持续工作浪费电流。

单次测量最常用的命令是 0x2C 0x06,含义是「开启单次测量,时钟延长开启,中等重复性」。这里有一个很多教程不讲的细节:0x2C 命令带时钟延长(clock stretching),意思是传感器在测量过程中会把 SCL 拉低,告诉主机「我还没测完,别急着读数据」。但如果你用的是软件模拟 I2C,一定要在发送完命令后主动延时一段时间(我一般延时 20ms 以上,中等重复性典型测量时间是 4.5ms,留足余量),然后再去读数据。

2.2 从零写一个软件模拟 I2C:为什么不用 F1 的硬件 I2C

STM32F103 的硬件 I2C 模块是出了名的难用。它的事件标志位逻辑复杂,用标准外设库写起来要处理 EV5、EV6、EV7 一大堆事件,还容易在 BUSY 标志上卡死。业内有个共识:F1 系列的硬件 I2C 能用,但坑太多,很多一线工程师在低速率、短距离的场景下宁愿用 GPIO 模拟 I2C,稳定性和可调试性都好得多。这不是玄学,是踩过坑之后的血泪选择。

软件模拟 I2C 的核心就是两根线的时序控制:SCL 产生时钟,SDA 在时钟高电平期间保持稳定、在低电平期间切换。下面是完整代码,先看头文件和宏定义:

// soft_i2c.h #ifndef __SOFT_I2C_H #define __SOFT_I2C_H #include "stm32f1xx.h" #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_GPIO_PORT GPIOB #define SCL_H() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define SCL_L() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define SDA_H() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define SDA_L() HAL_GPIO_WritePin(I2C_GPIO_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define SDA_READ() HAL_GPIO_ReadPin(I2C_GPIO_PORT, I2C_SDA_PIN) void I2C_Init(void); void I2C_Start(void); void I2C_Stop(void); void I2C_Ack(void); void I2C_NoAck(void); uint8_t I2C_WaitAck(void); void I2C_SendByte(uint8_t data); uint8_t I2C_RecvByte(void); #endif

SCL_H 和 SDA_H 这些宏把 GPIO 操作封装成 IO 操作,改引脚时只需要改上面的 PIN 定义。这个设计的用意是:当你发现 PB6、PB7 被其他功能占用时,换引脚只需要改宏,不用动函数内部逻辑。下面是实现文件:

// soft_i2c.c #include "soft_i2c.h" void I2C_Init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); gpio.Pin = I2C_SCL_PIN | I2C_SDA_PIN; gpio.Mode = GPIO_MODE_OUTPUT_OD; // 开漏输出 gpio.Pull = GPIO_PULLUP; // 内部上拉 gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(I2C_GPIO_PORT, &gpio); SCL_H(); SDA_H(); } void I2C_Start(void) { SDA_H(); SCL_H(); delay_us(5); SDA_L(); // SCL 高电平期间 SDA 拉低,产生起始条件 delay_us(5); SCL_L(); } void I2C_Stop(void) { SCL_L(); SDA_L(); delay_us(5); SCL_H(); SDA_H(); // SCL 高电平期间 SDA 拉高,产生停止条件 delay_us(5); } void I2C_Ack(void) { SCL_L(); SDA_L(); // 主机发送应答:拉低 SDA delay_us(5); SCL_H(); delay_us(5); SCL_L(); } void I2C_NoAck(void) { SCL_L(); SDA_H(); // 主机发送非应答:释放 SDA delay_us(5); SCL_H(); delay_us(5); SCL_L(); } uint8_t I2C_WaitAck(void) { SCL_L(); SDA_H(); // 释放 SDA,让从机控制 delay_us(5); SCL_H(); delay_us(5); if (SDA_READ() == GPIO_PIN_SET) { SCL_L(); return 1; // 返回 1 表示无应答 } SCL_L(); return 0; // 返回 0 表示收到应答 } void I2C_SendByte(uint8_t data) { for (int i = 0; i < 8; i++) { SCL_L(); if (data & 0x80) SDA_H(); else SDA_L(); data <<= 1; delay_us(3); SCL_H(); delay_us(3); } SCL_L(); } uint8_t I2C_RecvByte(void) { uint8_t data = 0; SDA_H(); // 释放 SDA,准备接收 for (int i = 0; i < 8; i++) { data <<= 1; SCL_H(); delay_us(3); if (SDA_READ() == GPIO_PIN_SET) data |= 0x01; SCL_L(); delay_us(3); } return data; }

关于这段代码有四个参数值得注意。第一,GPIO 模式用开漏输出而不是推挽输出——I2C 协议要求 SDA 和 SCL 都是线与结构,开漏输出配合上拉电阻才能让设备正确拉低总线。如果这里用了推挽输出,当从机想拉低 SDA 而主机输出高电平时,会直接短路。第二,内部上拉电阻对 400kHz 以下的 I2C 够用,但如果你外接的 OLED 模块上已经带了 4.7k 上拉电阻,内部上拉可以不用管,双上拉也不会出问题。第三,delay_us(3) 在 72MHz 主频下大约产生 1.5us 的时钟低电平,去程频率约 100-200kHz,这是兼容性最好的速度区间,几乎所有从机都能响应。第四,I2C_RecvByte 里在读取前先 SDA_H() 释放总线,这个操作必不可少——如果不释放,SDA 输出寄存器还保持上次的状态,会干扰从机发送数据。

2.3 单次测量命令与温湿度计算公式

有了 I2C 基础,SHT30 的驱动就很简单了。先看完整的测量与数据读取函数:

// sht30.c #include "sht30.h" #include "soft_i2c.h" #define SHT30_ADDR_W 0x88 // 0x44 << 1 #define SHT30_ADDR_R 0x89 // CRC-8:多项式 x^8 + x^5 + x^4 + 1,初始值 0xFF static uint8_t SHT30_CalcCRC(uint8_t *data, uint8_t len) { uint8_t crc = 0xFF; for (int i = 0; i < len; i++) { crc ^= data[i]; for (int j = 0; j < 8; j++) { if (crc & 0x80) { crc = (crc << 1) ^ 0x31; } else { crc <<= 1; } } } return crc; } uint8_t SHT30_ReadTempHum(float *temp, float *hum) { uint8_t buf[6]; uint16_t raw_temp, raw_hum; // 发送单次测量命令:0x2C 0x06(中等重复性,时钟延长) I2C_Start(); I2C_SendByte(SHT30_ADDR_W); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(0x2C); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(0x06); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); delay_ms(20); // 等待测量完成,中等重复性典型 4.5ms,留足余量 // 读 6 字节数据 I2C_Start(); I2C_SendByte(SHT30_ADDR_R); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (int i = 0; i < 6; i++) { buf[i] = I2C_RecvByte(); if (i < 5) I2C_Ack(); else I2C_NoAck(); } I2C_Stop(); // CRC 校验 if (SHT30_CalcCRC(&buf[0], 2) != buf[2]) { return 2; // 温度 CRC 失败 } if (SHT30_CalcCRC(&buf[3], 2) != buf[5]) { return 3; // 湿度 CRC 失败 } raw_temp = (buf[0] << 8) | buf[1]; raw_hum = (buf[3] << 8) | buf[4]; *temp = -45.0f + 175.0f * (float)raw_temp / 65535.0f; *hum = 100.0f * (float)raw_hum / 65535.0f; return 0; }

这段代码的返回值设计成 0、1、2、3 四档,排查问题的时候非常有用。返回 1 说明从机没有响应,先用逻辑分析仪或者示波器看 SCL 和 SDA 上有没有波形,检查接线和地址是否写错;返回 2 说明温度数据在传输过程中被干扰或者从机异常;返回 3 则是湿度数据的 CRC 没过。我调试过的案例里,CRC 失败最常见的原因是 I2C 速率过高或者电源纹波太大。

温湿度公式来自 SHT30 数据手册:温度实际值 = -45 + 175 * (原始值 / 65535),湿度实际值 = 100 * (原始值 / 65535)。两个公式都是线性的,看到原始值先不要急着转浮点打印,可以用串口把原始值传出来,先确认数值范围是否合理——温度原始值应该在 0 到 65535 之间,对应 -45 到 130 摄氏度,如果原始值恒为 0 或者恒为 65535,说明通信大概率没通,但驱动也没报错(因为 0x0000 的 CRC 恰好等于 0xBE,是合法的)。

2.4 连续读取时的一个隐藏坑:测量间隔不足会读到旧数据

如果你的代码是循环调用 SHT30_ReadTempHum,每轮循环间隔小于传感器的测量时间(比如 1ms),传感器还在忙,此时读取会返回上一次的测量结果或者全 0 数据。我见过有人在主循环里没有加延时,结果读到的数据每隔几百毫秒才跳变一次,以为是传感器坏了。正确的做法是在两次测量之间保证至少 20ms 的间隔,或者用周期测量模式。周期测量模式下传感器自动刷新,你随时读都能拿到最近一次的结果,不存在「还没测完」的问题。

周期测量模式的配置命令是 0x21 0x30(1Hz、中等重复性),配置成功后传感器会持续工作,电流消耗从单次测量的微安级升到百微安级,但使用起来确实省心。对于用 USB 供电、不关心功耗的 OLED 显示项目,我推荐直接上周期模式,少一个时序问题。

3. OLED 显示:SSD1306 的页寻址、显存模型与字模渲染

3.1 SSD1306 显存模型:理解页和列的关系

0.96 寸 OLED 的分辨率是 128x64,SSD1306 内置 1KB 显存,恰好对应 128 * 64 / 8 = 1024 字节。显存的组织方式是按页划分:64 行分成 8 页(Page0 到 Page7),每页 8 行像素,每行 128 列。写入显存时,一个字节的每一位对应同一列上的 8 个像素点,bit0 是页内最上面那行,bit7 是最下面一行。

这个模型意味着你写 OLED 驱动时,数据是按列方向组织的,不是按行。比如你要在屏幕左上角显示一个 8x8 的方块,需要写 8 个字节,每个字节都是 0xFF,分别写入 Column0 到 Column7。很多新手习惯按行去想,结果写出来的图像是竖着的或者错位的。

页寻址模式下,SSD1306 的操作序列是:先发 0xB0 到 0xB7 设置页地址(0xB0 对应 Page0),再发 0x00~0x0F 和 0x10~0x1F 设置列地址低四位和高四位,然后连续写入数据字节,列地址自动递增。下面这段初始化代码把所有关键配置写清楚了:

// oled.c #define OLED_ADDR_W 0x78 // SSD1306 的 8 位写地址(7 位地址 0x3C) static void OLED_WriteCmd(uint8_t cmd) { I2C_Start(); I2C_SendByte(OLED_ADDR_W); I2C_WaitAck(); I2C_SendByte(0x00); // 控制字节:0x00 表示后面是命令 I2C_WaitAck(); I2C_SendByte(cmd); I2C_WaitAck(); I2C_Stop(); } static void OLED_WriteData(uint8_t data) { I2C_Start(); I2C_SendByte(OLED_ADDR_W); I2C_WaitAck(); I2C_SendByte(0x40); // 控制字节:0x40 表示后面是显存数据 I2C_WaitAck(); I2C_SendByte(data); I2C_WaitAck(); I2C_Stop(); } void OLED_Init(void) { delay_ms(100); // 上电等待,SSD1306 需要一段时间完成复位 OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); // 设置时钟分频 OLED_WriteCmd(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(0xCF); OLED_WriteCmd(0xD9); // 预充电周期 OLED_WriteCmd(0xF1); OLED_WriteCmd(0xDB); // VCOMH 电压 OLED_WriteCmd(0x40); OLED_WriteCmd(0xA4); // 显示全部点亮 OLED_WriteCmd(0xA6); // 正常显示(非反色) OLED_WriteCmd(0xAF); // 打开显示 OLED_Clear(); }

关键参数只有三个:0x8D 0x14 是电荷泵开关,这个忘了写屏幕就永远黑着——很多人以为代码没问题,实际上就是少了这一句;0x20 0x02 选页寻址模式,SPI 版本常用水平寻址,I2C 版本我一般用页寻址,操作简单直接;0xA1 和 0xC8 决定屏幕画面的左右和上下方向,如果显示出的字是镜像的,把 0xA1 改成 0xA0、0xC8 改成 0xC0 就好。对比度 0x81 0xCF 这个值在 3.3V 供电下比较合适,如果屏幕偏暗可以调大到 0xFF,偏亮就调小。

3.2 在显存上写字符:从字模到显示函数的完整链路

显示一个字符需要两张表:一张 ASCII 字模表(每个字符 16 字节,6x8 点阵),一张位置计算逻辑。6x8 点阵的意思是每个字符占 6 列像素、8 行像素,正好是页寻址模式下 8 行像素 = 1 页,所以一个字符就写在 6 个字节里。字模表的生成方法是用取模软件,选择「列行式」格式,每行 8 位点阵,一个字符 6 个字节。下面是显示函数的核心逻辑:

// oled.c 续 static uint8_t OLED_GRAM[128][8]; // 显存缓冲:x 列 0~127,y 页 0~7 void OLED_SetPos(uint8_t x, uint8_t page) { OLED_WriteCmd(0xB0 + page); // 设置页地址(Page0~7) OLED_WriteCmd(x & 0x0F); // 设置列地址低 4 位 OLED_WriteCmd(0x10 | (x >> 4)); // 设置列地址高 4 位 } void OLED_ShowChar(uint8_t x, uint8_t page, char ch) { uint8_t i; const uint8_t *font = &ASCII_Font[(ch - 32) * 6]; // 字模表 OLED_SetPos(x, page); for (i = 0; i < 6; i++) { OLED_WriteData(font[i]); } // 字符间留一个空列 OLED_SetPos(x + 6, page); OLED_WriteData(0x00); } void OLED_ShowString(uint8_t x, uint8_t page, char *str) { while (*str) { OLED_ShowChar(x, page, *str++); x += 7; // 6 列字符 + 1 列空 if (x > 120) { // 超过屏幕右边界就换行 x = 0; page++; if (page > 7) page = 0; } } }

这里我故意在驱动里加了一个显存缓冲数组 OLED_GRAM,但上面的 OLED_ShowChar 没有用到它,而是直接写屏幕。这样设计的用意是让你看到两条路线:直接写屏适合简单显示,代码量小,但每次刷新都有 I2C 通信开销;显存缓冲适合复杂界面,先在内存里改好,再一次性刷到屏幕上,避免频繁 I2C 通信造成画面闪烁。实际项目里,一个 ATMEGA 级别的单片机的 I2C 速率写满整个 OLED 显存约需 10ms,如果每秒钟刷新 10 次,I2C 总线占用率也就 10%,不用太担心性能。

页寻址模式下,x 坐标的单位是像素列(0~127),y 坐标的单位不是像素而是页(0~7)。你要在屏幕第 3 行显示文字,实际设置的 page 是 3,对应的像素行范围是 24~31。很多人把 y 当成像素坐标用,结果文字错位了。我在 OLED_ShowString 的参数里直接用 page 而不是 y,就是为了从接口层面避免这个混淆。

3.3 用 snprintf 拼接温湿度字符串

显示温湿度数据时,标准的做法是先用 snprintf 把浮点数格式化成字符串,再调用 OLED_ShowString。这里有一个嵌入式开发里常见的坑:默认的 C 库 printf 系列函数对浮点支持可能未启用,或者启用后会显著增加固件体积。在新手工程里,我建议直接用整数加小数点的拆分方式,绕开浮点格式化:

// 显示示例:温度 25.3 摄氏度,湿度 48.7% void Display_Update(float temp, float hum) { char buf[24]; int t_int, t_dec, h_int, h_dec; t_int = (int)temp; t_dec = (int)((temp - t_int) * 10); if (t_dec < 0) t_dec = -t_dec; h_int = (int)hum; h_dec = (int)((hum - h_int) * 10); if (h_dec < 0) h_dec = -h_dec; snprintf(buf, sizeof(buf), "Temp:%d.%d C", t_int, t_dec); OLED_ShowString(0, 2, buf); snprintf(buf, sizeof(buf), "Humi:%d.%d %%", h_int, h_dec); OLED_ShowString(0, 4, buf); }

整数和小数分开处理的好处是避免了对浮点格式化链路的依赖,代码在任何编译优化等级下都能稳定工作。注意这里对负温度的处理:t_dec 取了绝对值,所以 -5.3 会被打印成 -5.3,而不是 -5.-3。这个细节虽然简单,但确实是很多人调试负数显示时发现输出奇怪的直接原因。

OLED_ShowString 的两个显示位置我故意选在了 page 2 和 page 4,中间隔了一行。这是显示布局的经验:8 页显存、每页 8 像素行,6x8 字体显示两行文字时,中间留一页空白会让可读性大幅提升。如果你想让数据更醒目,也可以把这两行文字放在 page 0 和 page 3,间距更大。

4. 组装最小系统:引脚分配、工程配置与代码框架

4.1 接线方案与外设冲突检查

把 SHT30 和 OLED 都挂在同一条 I2C 总线上是最简洁的方案——两个设备地址不冲突(0x44 和 0x3C),可以共用 SCL 和 SDA。我常用的引脚分配是 PB6 做 SCL、PB7 做 SDA。选这两个引脚的理由是:STM32F103 的 PB6、PB7 是 I2C1 的硬件引脚,如果将来你想切到硬件 I2C,不用改板子;同时它们没有复用冲突,不占 USART 和 SPI。

接线表如下:

模块引脚STM32F103 引脚说明
SHT30VIN3.3V传感器供电,不要在 5V 下使用
SHT30GNDGND公共地
SHT30SCLPB6I2C 时钟
SHT30SDAPB7I2C 数据
OLEDVCC3.3V模块供电
OLEDGNDGND公共地
OLEDSCLPB6与 SHT30 并联
OLEDSDAPB7与 SHT30 并联

注意 SHT30 的供电电压上限是 3.6V,如果你用 5V 给传感器供电,轻则读数偏高等精度劣化,重则直接损坏。OLED 模块大多带稳压电路,但最好也确认一下板上 LDO 的输入范围。如果两个模块都没问题,整个系统的供电电流大约在 10mA 量级,用 USB 转 TTL 的 3.3V 输出足够。

4.2 标准外设库工程的配置要点

工程配置里最容易出错的是时钟和延时函数。SHT30 和 SSD1306 都是 I2C 器件,对时序的绝对时间不算敏感,但 delay_us 和 delay_ms 的实现必须有准确的时基。如果你是裸机开发,直接用 Systick 做 1ms 和 1us 延时;如果你用 HAL 库,HAL_Delay 提供毫秒级,微秒级可以用 DWT 实现。

用标准外设库(StdPeriph Library)创建工程时,我说一下我一般会做的勾选和修改:

  • 器件型号选 STM32F103C8T6 或同系列,宏定义 STM32F10X_MD
  • 启动文件用 startup_stm32f10x_md.s
  • 时钟配置为 72MHz:外部 8MHz 晶振,PLL 9 倍频
  • STM32F10x_StdPeriph_Driver 里只用 GPIO 和 RCC 模块,I2C 模块不用
  • 在 main.c 里加一个 DWT 延时函数的实现,用于微秒级延时

下面是 DWT 延时的标准写法:

// delay.c #include "delay.h" void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 72; // 72MHz 主频下,1us = 72 个时钟周期 while ((DWT->CYCCNT - start) < ticks); } void delay_ms(uint32_t ms) { for (uint32_t i = 0; i < ms; i++) { delay_us(1000); } }

DWT 是 Cortex-M3 内核自带的调试单元,CYCCNT 寄存器每时钟周期加一。用它做微秒延时不用占用定时器,也不用关中断,相比 TIM 定时器方案干净利落。实际使用中它的精度足够稳定,误差在几个时钟周期以内,对软件 I2C 这种微秒级时序完全够用。SysTick 被 HAL_Delay 占用了也没关系,两者互不冲突。

4.3 main 函数的主循环框架

主循环设计成「读传感器 → 显示 → 延时」的经典模式。框架如下:

// main.c #include "main.h" #include "soft_i2c.h" #include "sht30.h" #include "oled.h" #include "delay.h" int main(void) { HAL_Init(); DWT_Delay_Init(); I2C_Init(); OLED_Init(); float temp = 0.0f, hum = 0.0f; uint8_t err; while (1) { err = SHT30_ReadTempHum(&temp, &hum); if (err == 0) { Display_Update(temp, hum); } else { // 错误处理:1 无应答,2/3 CRC 失败 OLED_ShowString(0, 0, "SHT30 Error!"); } delay_ms(500); // 0.5 秒刷新一次 } }

这里有一个值得说明的设计决策:错误分支只显示错误信息,不让系统死循环。这样做的原因是,如果传感器松动了,你插回去之后程序能自动恢复显示,不需要复位。如果你写成 while(1) 死循环等待传感器恢复,调试的时候遇到接线松动就得反复复位。另外,刷新周期设成 500ms 是平衡功耗和实时性的值——温湿度本身变化慢,500ms 足够,OLED 也不会因为频繁 I2C 写入而闪烁。

我在调试阶段习惯在错误分支把错误码显示出来,而不是只用一句话带过。做法很简单:定义一个全局变量 error_code,在 SHT30_ReadTempHum 返回后赋值,显示函数里把 error_code 的数字也打印出来。这样传感器无应答(1)和 CRC 失败(2/3)在屏上一眼就能区分,不需要串口线也能定位问题。

5. 从现象反推原因:SHT30 + OLED 最常见的 5 个坑

5.1 OLED 屏幕不亮,代码检查了十遍也没问题

现象:程序编译下载都正常,OLED 屏幕完全没有反应,连背光都没有。

原因:最常见的是电荷泵没开。SSD1306 内部有一个电荷泵电路,用来产生 OLED 面板需要的约 8V 驱动电压。初始化序列里 0x8D 0x14 这两条命令必须成对出现,只发了 0x8D 没发 0x14 等效于没开。另一个常见原因是 I2C 线序接错——OLED 模块上 SCL 和 SDA 丝印容易看不清,有的人把两根线接反了。

解决:先用逻辑分析仪看 SCL 和 SDA 上有没有波形。如果完全没有波形,查 GPIO 配置是否正确、初始化函数有没有被调用;如果有波形但屏幕不亮,把初始化序列里 0x8D 和 0x14 单独拎出来在 main 里重发一遍。如果还是不行,尝试降低 I2C 速度——把 delay_us 从 3 改成 10,看屏幕是否有反应。某些 OLED 模块的时序容限比较紧,高速模式下拉不起来。

5.2 SHT30 读回全 0,而且 CRC 没有报错

现象:温度和湿度都是 0.00,用串口打印看返回值是 0(成功),但数据明显不对。

原因:全 0 的温度原始值是 0x0000,这是合法数值,只是对应 -45 摄氏度的物理下限。但如果你在室内 25 度的环境里读到这个值,说明传感器没有真正完成测量。常见原因是测量命令发完后没有等待足够时间就立即读取——传感器的测量时间典型值 4.5ms,最大值 7.5ms,主循环跑得足够快时,你发完命令下一条读指令可能在 1ms 内就发出了,此时传感器还没把数据锁存到输出寄存器。

解决:在发送完 0x2C 0x06 之后、读取数据之前,加一个至少 20ms 的延时。我建议把延时放在 SHT30_ReadTempHum 函数内部,而不是依赖调用方在外部延时——这样函数的使用者不需要知道传感器的内部时序细节。另外,如果用的是周期测量模式,第一次配置后也需要等一个完整周期(1Hz 模式下 1 秒)再读数据,否则读到的是传感器上电默认值。

5.3 OLED 文字错位、变成镜像

现象:第一个字符显示正常,后面的字符位置乱跳,或者整个画面左右颠倒。

原因:页寻址模式下列地址自动递增,但 SSD1306 的列地址计数器在到达 127 后不会自动回绕到 0,而是停在 127。如果你的代码里 x 坐标递增到 120 以上,继续写字符就会溢出,显示位置直接错乱。镜像问题通常是段重映射方向配置不对——0xA1 是正常方向,0xA0 是镜像,有些屏幕出厂时行列定义和标准不同,用 0xA1 反而会镜像。

解决:OLED_ShowString 函数里加 x > 120 的换行检测,这是最直接的防溢出手段。镜像问题的处理手段是:先写上 0xA1,看显示是否正常,如果不正常改成 0xA0 再试。COM 扫描方向同理,0xC8 和 0xC0 二选一。不要把这两个参数写死在初始化里不验证,不同批次的 OLED 模块有可能存在差异。

5.4 I2C 总线上同时挂两个设备,其中一个就没反应

现象:单独接 SHT30 一切正常,单独接 OLED 也一切正常,两个一起接上就有一个设备通信失败。

原因:地址冲突是首选排查方向——SHT30 的 7 位地址 0x44,OLED 是 0x3C,本来不冲突。但如果 SHT30 的 ADDR 引脚被接高电平,地址就变成 0x45,那就和某些 OLED 模块的变体地址冲突了。另一个原因是总线上缺少合适的上拉电阻。STM32F103 的内部上拉电阻大约 30-50kΩ,对于一根线挂两个 I2C 从机,这个阻值偏大,信号边沿会变缓,导致通信不稳定。

解决:先检查 SHT30 ADDR 引脚的连接,确认地址是 0x44 还是 0x45。然后在 SCL 和 SDA 上各加一颗 4.7kΩ 外部上拉电阻到 3.3V。如果你用的是带 OLED 的转接板,板上通常已经有上拉电阻。注意不要用 10kΩ 或更大的电阻,I2C 总线上挂的设备越多,需要的上拉就越强,4.7k 是 IO 速度和数据稳定性的折中选择。

5.5 数据刷新时 OLED 出现闪烁或撕裂

现象:屏幕每刷新一次就闪一下,或者数字在变化时出现上半部分和下半部分不同步。

原因:直接写屏模式下,每写一个字节就发一次 I2C 通信。如果主循环里频繁调用 OLED_ShowString,屏幕会不停被局部更新。SSD1306 的显存是静态的,理论上不会闪——闪的原因是主机写入数据的速度跟不上人眼的感知,写出几个字节后屏幕局部变化,过几毫秒另一个局部再变化,视觉上就是闪烁。

解决:引入全屏缓冲机制。主循环里的显示函数只改内存里的 OLED_GRAM 数组,然后在显示周期结束时一次性调用 OLED_Refresh 把整个显存推送到屏幕。这个函数的本质是循环 8 页、每页 128 字节连续写入。全屏刷新一次大约需要 1024 字节的 I2C 传输,在 400kHz 速率下耗时约 20ms。刷新频率可以降到 10Hz 左右,人眼完全不会察觉,而且彻底消除了局部更新造成的闪烁。

6. 代码移植和进阶:从单次测量到周期模式,以及显示美化技巧

如果你已经跑通了上面的代码,接下来值得花时间做三件事。

第一件是把驱动从单次测量切成周期测量模式。这能让你彻底摆脱时序问题——配置命令 0x21 0x30 写成一条,之后不再需要等待和命令,读数据即可。SHT30 在周期模式下每秒钟自动刷新 1 次,读到的永远是新鲜数据。切过去之后你会发现代码少了 20ms 延时和状态判断,简洁不少。代价是功耗从微安级升到约 200μA(1Hz 重复性),如果你的项目用纽扣电池供电,建议还是在单次模式上优化。

第二件是给显示界面加一点「人味」。我用 6x8 字体在 128x64 的屏幕上做了一套两行数据 + 两行状态的布局:第 0 行显示标题「环境监测」,第 2 行和第 4 行显示温度和湿度,第 6 行显示采集错误码或运行时长。这样屏幕看起来不像调试界面,更接近一个小产品。如果你想让数据更醒目,可以把 6x8 字体换成 8x16 字体(一个字符占 16 字节,高 16 像素),这样每个字符占满两页,数字的辨识度明显提升。

第三件是处理温湿度数据的小范围抖动。SHT30 的读数本身有轻微噪声,特别是湿度,相邻两次采样的偏差可能在 0.2% 左右。对于显示类应用,我习惯加一个简单的滑动平均滤波:维护一个长度为 4 的环形缓冲,每次采到新值就丢掉最旧的值,取平均再显示。这样屏幕上的数字稳定,不会跳来跳去。注意滤波会增加显示滞后——如果你需要快速响应环境变化,滤波长度不要超过 4。

我早期第一次调 SHT30 的时候,在 CRC 校验上卡了两天。当时用的软件模拟 I2C,时序延时调得太短,SCL 高电平时间不够,传感器返回的数据偶尔有一位翻转,CRC 校验时好时坏,那种「看起来通了但数据偶尔飞掉」的玄学问题,最后是靠把延时从 1us 加到 3us 解决的。现在我每写完一个 I2C 从机驱动,第一件事就是检查时序余量,其次才是功能逻辑。嵌入式开发就是这样,很多问题不是逻辑不对,而是细节没到位。SHT30 加 OLED 这套组合,你会踩的坑我基本都写在上面了,照着做一遍,剩下的大概率是你自己的工程配置问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询