简介:这是一份基于正点原子STM32F1 mini板的SHT30温湿度传感器与0.96寸OLED显示屏联合应用源码,面向嵌入式初学者与电子设计爱好者,可快速搭建环境温湿度实时监测装置,也可直接用于课程设计或个人DIY项目。压缩包共200个文件,以38个.h头文件、34个.c源文件为主,另有Keil工程配置(.uvprojx)、编译链接产物(.o/.axf/.hex)及备份文件,整体大小4.89MB,解压后即可用Keil打开编译并烧录验证。目前已有2444人浏览下载。源码完整可用,涵盖I2C总线初始化、SHT30驱动时序(含命令写入、数据读取与校准)、SSD1306控制器的OLED显示驱动(字符/图形绘制)以及定时刷新逻辑;工程内还包含烧录文件与编译输出,对照正点原子mini板实物可快速核对引脚连接,理解传感器数据采集、处理与显示的完整嵌入式开发流程,尤其适合作为STM32驱动外部I2C器件与屏幕联调的入门练手项目。
1. SHT30 + OLED 源码:拿到手先想清楚的三件事
如果你的手头有一块STM32F1系列的mini开发板,想接SHT30温湿度传感器,再把温湿度实时显示在0.96寸OLED上,这套源码能省掉你大半个晚上的调试时间。SHT30并不是SHT20的简单升级,命令表、时序和数据格式都有区别;而STM32F1的硬件I2C外设又天生带些小毛病,所以工程里最常见的做法是软件模拟I2C直接驱动。
这份源码把SHT30读取、温湿度换算、OLED显示三件事串成一条完整流程,按mini开发板的PB6/PB7引脚布局预先配好。拿到手先确认三处:传感器从机地址、I2C总线速率、OLED初始化序列。适合正在做环境监测、室内温湿度计、实验室记录仪这类小项目的开发者,新手能照着接线跑通,熟手可以直接把驱动模块抽出来复用到自己的板子上。
2. SHT30驱动移植:软件I2C时序与两个公式
2.1 为什么选用软件模拟I2C:不给硬件外设添乱
STM32F1的硬件I2C在标准外设库的轮询写法下,需要处理EV5、EV6、BTF这一堆事件标志,时序稍紧就会卡在某个标志位上,表现是第一次通信正常、第二次就在等待ACK的地方死循环。很多开发者后来都转向GPIO翻转模拟,至少出错时你能拿示波器指着某一条波形说“这里不对”,代码逻辑完全透明。对这个项目而言,每次读取SHT30不过是写命令、等测量、读6个字节,总线速率用到100kHz就足够,软件模拟完全不存在性能瓶颈。
另一个原因是接线方便。软件I2C可以选任意两个GPIO,不跟板上的I2C1、I2C2外设引脚冲突,mini开发板的排针引出PB6、PB7,直接作为SCL和SDA,OLED也能挂在同一条总线上,两个设备的地址不冲突。
提示:如果只是临时点个灯,硬件I2C可能也能跑通;但你要连续采集几分钟不出错,软件I2C的稳定性明显高一个级别。
2.2 引脚初始化与总线寻址:先把线接对
SHT30是I2C接口,默认从机地址0x44(8位写地址0x88、读地址0x89),由ADDR引脚电平决定。mini开发板上SHT30模块的ADDR一般接地,地址就是0x44。SCL接PB6,SDA接PB7,VCC接3.3V,GND接GND,个别模块在板上自带4.7k上拉电阻,不需要额外接。
void SHT30_IIC_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_OD; // 开漏输出 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_6 | GPIO_Pin_7); // 默认释放总线 }这段初始化把PB6和PB7配置成开漏输出,开漏的目的是允许多个从机同时挂在总线上,任意一方都能把SDA拉低而不产生短路。GPIO_Speed设成50MHz只是IO翻转速度上限,实际总线速率由后面延时函数控制,不是这里决定的。如果板子上没有外部上拉电阻,内部上拉模式勉强能用,但SDA上升沿会变缓,通信距离短时没问题,量产或走线较长时还是建议外接4.7k上拉。
2.3 底层时序函数:起始、停止、字节收发与应答
软件I2C的四个基础函数是Start、Stop、SendByte、RecvByte。起始条件是在SCL高电平期间SDA产生一个下降沿,停止条件是在SCL高电平期间SDA产生一个上升沿,字节传输则按“高位在前、SCL高时采样”的规则逐位翻转。
#define SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) void IIC_Start(void) { SDA_H(); SCL_H(); delay_us(5); SDA_L(); // SCL高电平期间,SDA拉低 → 起始条件 delay_us(5); SCL_L(); }起始条件之后SCL被拉低,之后SDA上的数据变化才允许发生在SCL低电平期间。delay_us的取值决定总线速率,5微秒对应大约100kbps,如果你需要400kbps,把延时压到1微秒级别,前提是IO翻转速度和上拉电阻都跟得上。
uint8_t IIC_SendByte(uint8_t dat) { uint8_t i; for (i = 0; i < 8; i++) { if (dat & 0x80) SDA_H(); else SDA_L(); dat <<= 1; SCL_H(); delay_us(2); SCL_L(); delay_us(2); } SDA_H(); // 释放SDA,等待从机应答 SCL_H(); delay_us(2); if (SDA_READ()) i = 1; // 从机拉低SDA表示ACK else i = 0; SCL_L(); return i; }这个函数发送8位数据并返回从机应答状态,返回0表示有ACK,返回1表示NACK。SHT30收到正确地址和命令后会正常应答;如果设备没接对或地址写错,这里会返回NACK,是排查总线问题第一道关卡。注意发送完字节后SDA必须释放,否则从机无法拉低SDA应答。
uint8_t IIC_RecvByte(uint8_t ack) { uint8_t i, dat = 0; SDA_H(); // 释放SDA,让从机驱动 for (i = 0; i < 8; i++) { dat <<= 1; SCL_H(); delay_us(2); if (SDA_READ()) dat |= 1; SCL_L(); delay_us(2); } if (ack) SDA_L(); // 主机发送ACK else SDA_H(); // 最后一个字节发送NACK SCL_H(); delay_us(2); SCL_L(); SDA_H(); return dat; }接收函数的ack参数是主机对被读字节的应答:中间字节传1让主机拉低SDA表示“继续发”,最后一个字节传0表示“不要再发”。SHT30读取测量结果时一共返回6个字节,前5个字节主机都要回ACK,第6个字节回NACK,从机才会释放总线,下一次Start才能正常发起。
2.4 单次测量命令与等待时间:命令也要算CRC
SHT30的单次测量命令是0x2C06,含义是“开启单次测量、高重复性”。要注意SHT30的命令由两个命令字节加一个CRC校验字节组成,芯片校验通过才执行命令。很多网上例程直接省掉这个CRC也能跑,但严格按手册是必须补的,否则在某些批次芯片上命令会被无视,表现为总线上有ACK但传感器始终不启动测量。
#define SHT30_ADDR_W 0x88 #define SHT30_ADDR_R 0x89 #define CMD_MEAS_HIGH 0x2C06 uint8_t SHT30_CalcCRC(uint8_t *data, uint8_t len) { uint8_t crc = 0xFF; uint8_t i; while (len--) { crc ^= *data++; for (i = 0; i < 8; i++) { if (crc & 0x80) crc = (crc << 1) ^ 0x31; else crc <<= 1; } } return crc; } uint8_t SHT30_SendCmd(uint16_t cmd) { uint8_t buf[2], crc; buf[0] = cmd >> 8; buf[1] = cmd & 0xFF; crc = SHT30_CalcCRC(buf, 2); IIC_Start(); if (IIC_SendByte(SHT30_ADDR_W)) return 1; // NACK直接退出 IIC_SendByte(buf[0]); IIC_SendByte(buf[1]); IIC_SendByte(crc); IIC_Stop(); return 0; }CRC多项式0x31是SHT3x系列统一使用的CRC8算法,初值0xFF,计算范围是两个命令字节。为什么有的例程不发也没事,因为部分芯片对命令CRC的校验没那么严格,或者例程里隐藏了第三个字节。你不需要记忆每条命令的CRC结果,用这个动态计算函数最省事,换命令时不用查表。
发完命令后需要等待测量完成。高重复性单次测量的典型时间是18ms,最坏47ms,代码里延时20ms偏激进,稳妥起见可以延到50ms。
void SHT30_ReadRaw(uint16_t *temp_raw, uint16_t *humi_raw) { uint8_t buf[6]; SHT30_SendCmd(CMD_MEAS_HIGH); delay_ms(50); IIC_Start(); if (IIC_SendByte(SHT30_ADDR_R)) { IIC_Stop(); return; } buf[0] = IIC_RecvByte(1); buf[1] = IIC_RecvByte(1); buf[2] = IIC_RecvByte(1); // 温度数据CRC buf[3] = IIC_RecvByte(1); buf[4] = IIC_RecvByte(1); buf[5] = IIC_RecvByte(0); // 最后一个字节NACK IIC_Stop(); if (SHT30_CalcCRC(buf, 2) != buf[2]) return; if (SHT30_CalcCRC(buf + 3, 2) != buf[5]) return; *temp_raw = (buf[0] << 8) | buf[1]; *humi_raw = (buf[3] << 8) | buf[4]; }这段代码把读6个字节和CRC校验合在一起。如果CRC不匹配,直接丢弃这一帧。SHT30返回的数据格式是温度高字节、低字节、CRC、湿度高字节、低字节、CRC,温度和湿度原始值都是16位无符号整数,范围0到65535。
2.5 温湿度换算公式:从原始值到物理量的两步
原始值需要经线性映射才能变成摄氏度和相对湿度。SHT30数据手册给出的换算公式非常简洁,如下表所示。
| 物理量 | 换算公式 | 边界说明 |
|---|---|---|
| 温度 | T = -45 + 175 * raw / 65535 | raw=0对应-45℃,raw=65535对应130℃ |
| 相对湿度 | RH = 100 * raw / 65535 | raw=0对应0%RH,raw=65535对应100%RH |
实际写代码时,我一般不建议直接上浮点,STM32F1开浮点printf会占掉不少Flash,显示驱动里做浮点格式化更容易把库函数拉进来。更干净的做法是把分子分母凑成整数,一次算出0.01℃精度的整形值,例如温度用((int32_t)raw * 17500 / 65535 - 4500)得到0.01℃单位,湿度用((int32_t)raw * 10000 / 65535)得到0.01%RH单位。这个整型值可以直接拆位送到OLED,后面第三章的显示代码就是这么处理的。
3. OLED显示接入:SSD1306初始化、页面寻址与免浮点显示
3.1 OLED在I2C总线上的地址:0x3C为什么不会和SHT30打架
mini开发板配套的0.96寸OLED模块用的是SSD1306驱动芯片,I2C版本默认从机地址0x3C(8位写地址0x78),由模块背面的SA0电阻决定。SHT30的从机地址是0x44,8位写地址0x88,两个地址完全不同,可以放心挂在同一条I2C总线上。
| 设备 | 7位从机地址 | 8位写地址 | 8位读地址 |
|---|---|---|---|
| SHT30 | 0x44 | 0x88 | 0x89 |
| SSD1306 | 0x3C | 0x78 | 0x79 |
总线是分时复用的,每次通信都是Start开始、Stop结束,不存在两个从机同时抢线的问题。反而要注意的是OLED模块的I2C版本和SPI版本引脚定义不一样:I2C版本只有GND、VCC、SCL、SDA四个引脚,直接接线;SPI版本有CS、DC、RES、D0、D1等,用I2C工程时别接错。
3.2 SSD1306初始化序列:这几条命令决定能不能亮
SSD1306上电后默认处于关闭状态,必须完成初始化序列才会显示。初始化序列由一组控制字节和命令参数组成,其中最关键的三条是关显示0xAE、打开电荷泵0x8D 0x14、开显示0xAF。电荷泵没打开时屏幕不会亮,但数据写入都正常,这是黑屏问题最常见的根因。
void OLED_Init(void) { delay_ms(50); OLED_WriteCmd(0xAE); // 关闭显示 OLED_WriteCmd(0xD5); OLED_WriteCmd(0x80); // 时钟分频 OLED_WriteCmd(0xA8); OLED_WriteCmd(0x3F); // 复用比64 OLED_WriteCmd(0xD3); OLED_WriteCmd(0x00); // 显示偏移0 OLED_WriteCmd(0x40); // 起始行0 OLED_WriteCmd(0x8D); OLED_WriteCmd(0x14); // 打开电荷泵 OLED_WriteCmd(0x20); OLED_WriteCmd(0x02); // 页面寻址模式 OLED_WriteCmd(0xA1); // 段重映射 OLED_WriteCmd(0xC8); // 扫描方向 OLED_WriteCmd(0xDA); OLED_WriteCmd(0x12); // 引脚配置 OLED_WriteCmd(0xAF); // 开启显示 OLED_Clear(); }OLED_WriteCmd的第一个参数是控制字节,I2C模式下要发送0x00表示后续字节是命令,发送0x40表示后续字节是显示数据。初始化序列里的0x20 0x02把芯片设成页面寻址模式,这是代码里刷新逻辑必须匹配的关键,页面模式下一页128个字节正好对应屏幕一行8像素。
3.3 从显存到屏幕:页面寻址与刷新函数
SSD1306的128x64屏幕在逻辑上分成8页,每页8像素高,128像素宽。显示数据按页写入,屏幕左上角第一页覆盖第0到第7行,第二页覆盖第8到第15行,以此类推。用户程序里维护一个128x8字节的缓冲区,显示内容先画进缓冲区,再一次性刷到屏幕上,避免逐字符刷新产生的闪烁。
uint8_t OLED_GRAM[128][8]; void OLED_SetPos(uint8_t x, uint8_t y) { OLED_WriteCmd(0xB0 + y); // 设置页地址 OLED_WriteCmd(x & 0x0F); // 低4位列地址 OLED_WriteCmd(0x10 | (x >> 4)); // 高4位列地址 } void OLED_Refresh(void) { uint8_t i, j; for (i = 0; i < 8; i++) { OLED_SetPos(0, i); for (j = 0; j < 128; j++) { OLED_WriteData(OLED_GRAM[j][i]); } } }OLED_Refresh逐页把缓冲区内容发到屏幕,每次刷新要写8次页地址、共1024个数据字节。在100kHz I2C下刷新一帧大约需要90ms,这个速度对温湿度显示够用。如果后续要做动画或波形,建议把总线提到400kHz,或者换SPI接口的OLED。
3.4 显示温度与湿度:不用浮点库的整型拆位法
显示函数只需要两个原语:定位到某个页的某个列、画字符。5x7点阵英文字符每个占1字节宽,直接查表。核心显示逻辑是把2.5节算出来的整型温湿度拆成整数位和小数位,一个一个数字写进缓冲区。
void OLED_ShowNum(uint8_t x, uint8_t y, uint32_t num, uint8_t len) { uint8_t i; for (i = 0; i < len; i++) { uint8_t digit = (num / pow10(len - i - 1)) % 10; OLED_ShowChar(x + i * 6, y, '0' + digit); } }pow10是预计算的10的幂次查找表,避免引入数学库。比如显示温度时,t100 = ((int32_t)temp_raw * 17500 / 65535 - 4500)得到0.01℃单位,整数部分是t100 / 100,小数部分是t100 % 100,先显示整数,再显示一个小数点字符,最后补两位小数。整个过程只有整数除法,不占浮点库。
void OLED_ShowTempHumi(int32_t t100, int32_t h100) { uint8_t x = 16, y = 2; OLED_ShowCN(x, y, 0); // 显示"温度" if (t100 < 0) { OLED_ShowChar(x + 16, y, '-'); t100 = -t100; } OLED_ShowNum(x + 22, y, t100 / 100, 2); OLED_ShowChar(x + 33, y, '.'); OLED_ShowNum(x + 39, y, t100 % 100, 2); OLED_ShowChar(x + 51, y, 'C'); // 湿度同理,放在第4页 }OLED_ShowCN是点阵字库函数,16x16中文字模从取模软件生成后放进const数组。这份源码里已经预置了“温度”“湿度”“摄氏”等常用字模,如果你要显示自定义中文,需要用取模软件重新生成,注意取模方式和代码里的位序保持一致:逐行扫描还是逐列扫描,方向设为横向还是纵向,这一项错了显示出来的就是镜像或乱码。
4. 避坑:SHT30与OLED共存的五个常见问题与排查步骤
4.1 五个高频问题:现象、原因与解决
问题一:读回来的温度湿度一直是0xFFFF或固定值。
现象是OLED上显示的温度是130.0℃、湿度100.0%RH,或者始终不动。原因几乎都是SHT30没有正常响应,命令没有发出去,或者总线根本没有ACK。解决方法是先在SendCmd内部检查IIC_SendByte的返回值,一旦NACK就点亮一个LED或者串口打印错误标志,不要继续往下读。如果NACK发生在发完地址之后,优先查硬件接线和地址;如果NACK发生在发命令字节之后,问题集中在SDA线的上拉电阻或总线被其他设备拉死。
问题二:OLED完全无显示,但SHT30读数正常。
现象是传感器数据串口打印正常,屏幕却一片黑。原因通常有三类:第一是OLED地址写错成0x7A,第二是初始化序列里电荷泵没打开,第三是OLED模块本身需要RES引脚复位,程序里没有给复位时序。解决方法是先看代码里OLED地址是0x78还是0x7A,对照模块背面SA0电阻;然后在OLED_Init开头加一个50ms延时,通过控制RES引脚或软件延时保证上电稳定。确认基本盘时,可以在Clear后往缓冲区写满0xFF再刷新,全屏应该变白,否则问题在通信或初始化。
问题三:显示一帧之后I2C总线卡死,SCL或SDA被拉低。
现象是程序跑到某次读取后卡在延时或循环里,用逻辑分析仪看到SDA一直为低。原因是主机或从机在某个字节的应答阶段没有正确释放总线,常见的是最后一个字节没有发NACK却发了ACK,导致从机还在等待下一字节。解决方法是逐个检查IIC_RecvByte的ack参数,SHT30读6个字节必须前5个传1、最后传0;OLED写数据时则所有字节都等待ACK。另外可以在IIC_Start前加一个总线恢复函数,连续发9个SCL脉冲让卡死的从机释放SDA。
void IIC_Bus_Recover(void) { uint8_t i; SDA_H(); for (i = 0; i < 9; i++) { SCL_L(); delay_us(5); SCL_H(); delay_us(5); } IIC_Start(); IIC_Stop(); }这个恢复函数对SSD1306这种可能会锁住SDA的从机很有效,它模拟9个时钟脉冲让从机的内部状态机复位。我一般把这段放在主循环外部,用一个按键触发,调试时总线一卡就手动恢复,不需要重新上电。
问题四:在mini开发板上正常,换到自己画的板子读数乱跳。
现象是同一套代码换板子后,温度在正常值附近抖动几度,或者偶发读到0。原因是供电和上拉电阻变了:SHT30的VDD最好就近放一个100nF去耦电容,总线SCL和SDA各接一个4.7k上拉到3.3V。mini开发板模块自带上拉所以没暴露问题,自绘板如果漏了这两个电阻,上升沿变缓,在快速翻转下会采到错误电平。解决方法是补上拉和去耦电容,再把delay微调到2~5微秒让时序更宽松。
问题五:温度数值整体偏高,或者开机后慢慢爬升。
现象是SHT30读数比环境温度高2~3℃。原因通常是芯片自热或加热器被意外打开。SHT30内部有一个加热器,状态寄存器里的加热器位默认关闭,但如果你调试时执行过其他命令把它打开了,读数会整体抬升。解决方法是软复位传感器并重新读取状态确认加热器处于关闭状态,同时确保传感器不要贴着主控芯片或电源芯片放置,外壳开孔保持空气流通。
4.2 排查工具与自检例程:先确认传感器在总线上
五类问题里有一半和总线通信相关,所以我强烈建议在工程里留一个自检例程:上电后读SHT30的状态寄存器,把读取结果通过串口或OLED打出来。状态寄存器的读取命令是0xF32D,返回3个字节,其中2个数据字节加1个CRC字节。
uint8_t SHT30_ReadStatus(void) { uint8_t buf[3]; IIC_Start(); if (IIC_SendByte(SHT30_ADDR_W)) { IIC_Stop(); return 0xFF; } IIC_SendByte(0xF3); IIC_SendByte(0x2D); IIC_Stop(); IIC_Start(); if (IIC_SendByte(SHT30_ADDR_R)) { IIC_Stop(); return 0xFF; } buf[0] = IIC_RecvByte(1); buf[1] = IIC_RecvByte(1); buf[2] = IIC_RecvByte(0); IIC_Stop(); if (SHT30_CalcCRC(buf, 2) != buf[2]) return 0xFE; return (buf[0] << 8) | buf[1]; }自检通过的条件是返回值不是0xFF也不是0xFE。0xFF说明从机地址NACK,总线上没找到设备;0xFE说明CRC校验失败,总线时序有问题。这个函数不仅能验证SHT30是否存在,还能在修改时序参数后快速验证效果,比直接测温湿度更直观:温度对不上可能是环境问题,CRC不过就是实打实的时序问题。
5. 进阶:让温湿度数据可信的三个手段:CRC校验、滤波与降功耗
把数据采出来只是第一步,数据可信才是这个项目真正能交付的关键。我在资源里已经内置了CRC校验,但实际做产品时还需要再做三层加固。
第一层是保留CRC校验并且不要把CRC错误静默吞掉。SHT30每个测量通道返回2字节数据加1字节CRC,这是一个免费的总线健康检测机制。如果你的程序连续采集一小时,CRC错误出现次数超过个位数,说明总线上拉到有问题或者IO配置不当,这时候应该在日志里累计错误帧数,而不是直接跳过去。静默丢弃会在数据上留下不易察觉的坑。
第二层是软件滤波。SHT30本身精度不错,但环境气流波动会让读数跳变。我一般用滑动中值滤波:连续采集5帧,取中间值作为当前显示值,既保留响应速度又滤掉单点毛刺。实现时不需要排序函数,对5个数做三层比较就能得到中值,代码只有十几行,但效果比简单平均好很多,尤其适合有人在房间里走动的场景。
第三层是功耗控制。如果你的项目要用电池供电,SHT30的单次测量模式本身就比较友好:测完一次后芯片会自动回到空闲状态,电流在微安级。不要开启周期测量模式还让主控一直刷OLED,那是功耗黑洞。比较合理的做法是每10秒醒一次,读数据、刷屏、然后让SHT30和主控同时进入低功耗。
我第一次接SHT30时,为了省事把CRC校验函数注释掉了,结果数据显示偶尔跳一下,当时以为是传感器坏了,折腾了一晚上才发现是总线上拉电阻接触不良。从那以后我每次接SHT30都会强制走一遍完整流程:先IIC扫描确认ACK,再读状态寄存器验CRC,最后才进主循环刷OLED。这套流程也写进了这份源码,希望帮到你。
本文还有配套的精品资源,点击获取