简介:这是一份基于STM32F1系列微控制器的温湿度监测工程源码,使用SHT30传感器采集环境数据,并通过0.96寸OLED屏实时显示。工程面向正点原子mini板开发环境,也适合需要学习I2C外设与传感器驱动移植的嵌入式开发者;代码已整理成可直接编译的Keil工程,包含I2C底层驱动、SHT30读写与校验、SSD1306显示驱动以及轮流显示温度湿度的用户界面。资源包共200个文件,以h头文件、c源文件为主,辅以uvprojx工程配置、hex烧录文件与编译生成的map、lst文件,压缩包大小约4.89MB,解压后即可打开使用。已有2441人学习查看,适合需要快速参考驱动实现或在此基础上扩展应用的用户。通过这份源码,可以系统理解STM32的I2C通信时序、温湿度数据转换和OLED点阵显示原理,对提升嵌入式驱动开发能力有实际帮助;代码在正点原子mini板上验证可用,注释清晰,便于学习与二次开发。 最早接触温湿度测控这块时,我也是从DHT11走过来的,便宜、教程多、网上随便一搜就是大把例程。但用过一段时间后你会明显感到它那几个老毛病有多烦人:精度勉强、时序敏感、温漂明显,而且每篇例程写出来的时序节奏都不太一样,换个引脚换个芯片就得重新调。后来项目里换成了SHT30,配合STM32F103和一块OLED屏做本地显示,整个体验完全不一样——I2C接口挂上去就通,数据稳定,还有CRC校验,调试起来省心太多了。
这篇文章就是把这一套“STM32F1 + SHT30 + OLED”方案完整拆开讲。从硬件选型、I2C时序、数据读取、OLED驱动到源码工程组织,最后再把我在实测中踩过的坑一并列出来。适合已经会点STM32基础、想跳过DHT11直接上数字温湿度传感器的读者,也适合手里正好有一块F103核心板、想快速跑通一个带显示的温湿度计项目的朋友。文末的源码逻辑可以直接抄进自己的工程里,先跑通,再按需改。
1. 为什么是SHT30而不是DHT11:选型对比与接线
先说选型这件事。很多新手朋友以为DHT11和SHT30只是精度不同,其实它们的接口协议和开发体验完全不在一个层级上。
DHT11走的是单总线时序,MCU需要把引脚配置成开漏输出,然后手动拉低拉高,一个bit一个bit地抠时序,温湿度数据全靠GPIO电平翻转来模拟,对定时器延时精度要求很高。如果用的是主频不准的内部RC振荡器,或者编译器优化等级一变,读取结果就可能变成乱码。而SHT30走的是标准I2C协议,你只需要把从机地址和寄存器/命令字发过去,数据就规规矩矩地回来,开发门槛低很多。
从性能参数上对比就更直观:
| 对比项 | DHT11 | SHT30 |
|---|---|---|
| 温度精度 | ±2℃ | ±0.3℃(典型值) |
| 湿度精度 | ±5%RH | ±2%RH |
| 接口 | 单总线(GPIO模拟时序) | I2C(最大1MHz) |
| 数据校验 | 简单8位校验 | CRC-8,可靠性高 |
| 典型价格 | 1~3元 | 6~12元 |
| 测量分辨率 | 8bit温度 / 8bit湿度 | 16bit / 16bit |
多花几块钱换来的不仅仅是精度提升,更重要的是省掉了那些让人头大的时序调优工作。I2C协议有标准可循,出错也好排查,用逻辑分析仪一目了然。
接线方面,我这套方案用的是STM32F103C8T6,SHT30和OLED都挂在同一个I2C总线上。SHT30的VDD接3.3V,GND接地,SDA和SCL分别接PB7和PB6。OLED的SDA/SCL同样接在PB7/PB6上,VCC接3.3V(如果模块丝印标注支持5V供电,你也可以接5V,但建议统一用3.3V,尤其SHT30必须3.3V,它的绝对最大供电电压是3.6V,直接上5V会烧芯片)。
注意:SHT30的ADDR引脚是地址选择脚。ADDR接地时I2C从机地址是0x44,ADDR接VDD时地址是0x45。PCB设计上通常默认拉低,所以市面上多数模块都是0x44。如果你的模块读不到数据,第一件事就是量一下ADDR引脚的电位。
I2C总线还需要上拉电阻。STM32核心板或者传感器模块内部一般已经带了4.7k或10k上拉,直接能用。如果是自己做的板子,记得在SDA和SCL上分别焊一个4.7k左右的电阻到3.3V,否则总线信号上不去,通信会时好时坏。
2. 读SHT30数据的完整I2C链路:命令、时序与CRC
SHT30的I2C通信不像普通EEPROM那样先发寄存器地址再读写,它用的是“命令字”体系。核心是单片机先往传感器写两个字节的命令,传感器执行完测量之后,再把测量结果读回来。以单次测量、高重复性模式为例,最常用的命令是0x2C 0x06。
这里稍微解释一下命令字含义:0x2C代表单次测量模式且不使用时钟延展(clock stretching),0x06代表高重复性(另外还有0x0D中重复性和0x10低重复性)。重复性越高,内部测量时间越长,噪声越低,对应的转换时间大约在12.5ms到15ms之间。
单片机端的主流程并不复杂:
uint8_t sht30_write_cmd(uint16_t cmd) { uint8_t buf[2]; buf[0] = cmd >> 8; buf[1] = cmd & 0xFF; return i2c_write_bytes(SHT30_ADDR, buf, 2); } uint8_t sht30_read_data(float *temp, float *humi) { uint8_t buf[6]; uint16_t rawTemp, rawHumi; if (sht30_write_cmd(0x2C06) != 0) { return 1; } delay_ms(20); // 等待转换完成 if (i2c_read_bytes(SHT30_ADDR, buf, 6) != 0) { return 2; } // 检查CRC if (crc8(buf, 2) != buf[2] || crc8(buf + 3, 2) != buf[5]) { return 3; } rawTemp = (buf[0] << 8) | buf[1]; rawHumi = (buf[3] << 8) | buf[4]; *temp = -45.0f + 175.0f * rawTemp / 65535.0f; *humi = 100.0f * rawHumi / 65535.0f; return 0; }SHT30返回的6个字节结构很固定:第1~2字节是温度原始值(16bit),第3字节是温度CRC校验值,第4~5字节是湿度原始值,第6字节是湿度CRC校验值。转换公式用了datasheet里给的线性映射:温度转换成实际摄氏度是-45 + 175 * rawTemp / 65535,湿度转换是100 * rawHumi / 65535。
很多网上拿来的例程是不做CRC校验的,直接读6个字节就拿来算。传感器本身在正常环境下几乎不会出错,但只要总线受干扰,或者接线长了以后信号边沿变差,偶尔就会冒出个异常大值,显示“-40℃”或“0%RH”。加了CRC校验之后,读到的每一帧数据都是经过完整性验证的,可靠性完全不一样。
CRC8的实现也不复杂,多项式是0x31,初值0xFF:
uint8_t crc8(const uint8_t *data, uint8_t len) { uint8_t crc = 0xFF; for (uint8_t i = 0; i < len; i++) { crc ^= data[i]; for (uint8_t j = 0; j < 8; j++) { if (crc & 0x80) { crc = (crc << 1) ^ 0x31; } else { crc <<= 1; } } } return crc; }如果你用的是STM32的硬件I2C外设,读回来的流程是相同的,只是把i2c_write_bytes和i2c_read_bytes换成HAL库的HAL_I2C_Master_Transmit和HAL_I2C_Master_Receive,也就是在发送时带上从机地址和数据长度,在接收时从总线上直接拿6字节数据。区别只是底层驱动,上层这套命令和CRC逻辑可以完全复用。
3. OLED显示模块:SSD1306初始化与字符串输出
OLED部分用的是最常见的SSD1306驱动、128x64分辨率的I2C接口屏。地址大多数是0x3C,也有少数模块是0x3D,可以在模块背面丝印或者淘宝详情页里确认一下。如果两者都试了依然没有反应,就用I2C扫描程序扫一遍总线,看看实际枚举出来的从机地址是什么。
SSD1306上电后并不会自动显示内容,必须先发送一串初始化命令。初始化序列其实有固定套路,我在工程里用的是这么一组:
static void oled_init_cmd(void) { uint8_t cmds[] = { 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0x20, 0x00, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF }; for (uint8_t i = 0; i < sizeof(cmds); i++) { oled_write_cmd(cmds[i]); } }这里面最容易漏掉的是0x8D 0x14。0x8D是电荷泵开关命令,0x14表示开启内部DC-DC升压。如果少了这两条,OLED内部升压电路不工作,屏幕有背光亮,但怎么刷都刷不出像素。我遇到过太多次初学者问“为什么我的OLED上电亮了但什么都显示不出来”,十有八九就是这里的问题。
然后是字符显示。我不太建议在128x64的小屏上用全屏帧缓冲方式去刷新,太浪费内存,而且刷新速度还慢。更实用的做法是“页地址模式”,把屏幕分成8个page,每个page是8像素高的横条,定位到某个page和column之后,直接把字模数据写进去即可。ASCII字库用6x8或8x16都行,我工程里用的是8x16,显示更清晰一些,温度湿度信息量不大,一屏完全放得下。
温度湿度数据的显示需要做一次浮点到字符串的转换。嵌入式开发里我不太喜欢在STM32F1这种小芯片上频繁用浮点格式化,所以更推荐把整数部分和小数部分拆开拼字符串:
float temp, humi; uint8_t temp_int, temp_dec, humi_int, humi_dec; char line[16]; sht30_read_data(&temp, &humi); temp_int = (uint8_t)temp; temp_dec = (uint8_t)((temp - temp_int) * 10); humi_int = (uint8_t)humi; humi_dec = (uint8_t)((humi - humi_int) * 10); sprintf(line, "T:%d.%d C", temp_int, temp_dec); oled_show_string(0, 0, line); sprintf(line, "H:%d.%d %%", humi_int, humi_dec); oled_show_string(0, 2, line);OLED屏上显示速度很快,而且SHT30数据半小时不刷也能保持显示,不需要像数码管那样动态刷新,所以这个方案做静态显示非常合适。如果你有显示中文的需求,要先把汉字取模生成字库数组,再写一个按二维坐标绘制字模的函数。这部分代码量会大些,但原理就是把8x16的英文字模扩展成16x16的中文字模。
4. 让源码一次跑通的工程组织:初始化顺序与主循环设计
很多人拿到例程第一反应是“编译完直接烧”,结果发现什么都显示不出来,然后就卡住了。其实多数问题不是代码本身有问题,而是工程文件的组织方式不匹配,或者芯片型号、启动文件选错了。这里我把这套源码的工程结构和我推荐的初始化顺序完整说一下。
工程上我用的是标准外设库3.5 + Keil MDK5,芯片选择STM32F103C8,启动文件是startup_stm32f10x_md.s。目录里核心文件就这些:
main.c:系统时钟配置、引脚初始化、主循环逻辑sht30.c/sht30.h:SHT30命令、CRC、数据读取封装oled.c/oled.h:SSD1306驱动、字库、字符串绘制myi2c.c/myi2c.h:模拟I2C底层时序(也可替换成硬件I2C/HAL)
如果你用的是HAL库,文件结构几乎一样,只需要把myi2c.c替换成对应的HAL I2C调用即可。之前提到的HAL_I2C_Mem_Write和HAL_I2C_Mem_Read在这里就能派上用场。在CubeMX里把I2C1的SCL/SDA配置成PB6/PB7,速率设为100kHz,然后直接在sht30.c里调用HAL函数,剩下的业务逻辑一行不用改。
初始化顺序是这套东西能不能一次跑起来的关键,我的经验是严格按下面的顺序来:
int main(void) { Delay_Init(); // 1. 延时函数必须先就绪 OLED_Init(); // 2. 初始化OLED OLED_Clear(); // 3. 清屏 SHT30_Init(); // 4. 初始化SHT30(这里实际上只是确认通信) delay_ms(20); // 5. 给传感器上电稳定时间 while (1) { float temp = 0, humi = 0; uint8_t err = SHT30_ReadData(&temp, &humi); if (err == 0) { OLED_ShowTempHumi(temp, humi); } else { OLED_ShowError(); } delay_ms(2000); // 2秒刷新一次 } }主循环里2秒刷一次是我实际调出来的平衡值。SHT30单次测量模式下,每次读取必然触发一次测量,间隔太短传感器转换时间不够,容易读到旧数据;间隔太长又没必要。2秒钟人眼感觉不到卡顿,功耗也低,如果以后做锂电池版本,这个刷新率对续航很友好。
还有个小细节:SHT30_Init()里我并没有做什么神秘的初始化命令,只是发一条软复位或者直接读一次数据做握手,确认传感器在线。真正重要的是主循环里封装错误处理——读不到数据时不要让程序卡死,而是显示一个ERR提示,这样调试的时候至少能知道是通信断了还是传感器没上电。
5. 实测翻车记录:NACK、花屏、跳数的排查思路
这套方案虽然简单,但我在实际调试中还是踩了几个坑,每一个都很有代表性,写出来给大家避一避。
第一个坑:SHT30读回来全是NACK。
查这个问题的链路非常固定:先用万用表量SDA/SCL的对地电压,正常空闲状态应该都是3.3V左右,如果其中一根是0V,那基本就是上拉电阻缺失或者引脚配置成了推挽输出而不是开漏。再看ADDR引脚,如果模块上默认接了上拉电阻,你的代码却按0x44去访问,从机根本不会应答。最后用逻辑分析仪抓波形,看主机发送地址之后有没有ACK位。这三个方向能覆盖95%以上的NACK问题。
第二个坑:OLED屏幕亮着但没有任何字形。
出现这种问题先不要急着怀疑字库代码,八九成是初始化命令里少了电荷泵那两条。前面提到的0x8D 0x14是开启DC-DC升压的开关,很多淘宝卖家给的例程里偏偏漏了这两条,或者顺序不对。另外,如果OLED模块上有RES引脚,初始化之前要先做一次硬件复位:RES拉低50ms,再拉高50ms,然后再发初始化命令。模块上电瞬间若没有外部复位时序,SSD1306内部状态可能不稳定,也会导致后续命令无效。
第三个坑:温度偶尔跳成-40℃或者湿度显示0%。
这个现象基本就是CRC没做导致的。数据在I2C总线上传输时,如果线长超过20cm,或者旁边有大电流电机、继电器干扰,偶发错一个bit是很正常的事。做上CRC校验后,错误帧直接丢弃并在下一轮重新读取,显示就不会再乱跳。我强烈不建议省略这一步。
第四个坑:显示乱码或者汉字是反的。
这种情况通常不是通信问题,而是字模取模方式选错了。取模软件里常见的设置有“逐行式”“逐列式”“低位在前/高位在前”等选项,SSD1306的水平寻址模式下,字模数据是按列从上到下、从左到右扫的,所以取模时一般要选“列行式”或“纵向取模,高位在前”。我把显示ASCII字母和汉字的函数分开了,ASCII用oled_show_string,汉字用oled_show_chinese,分别对应不同的字模格式,避免混用出错。
第五个坑:I2C总线卡死,程序跑飞。
模拟I2C最大的问题就是如果主从设备时序不合,很容易卡在某个while循环里等不到应答。我的解决方法是给等待ACK的循环加超时计数,比如while (... ) timeout++,如果超时就直接返回错误,而不是死等。硬件I2C也有类似问题,标准库的I2C事件等待也会卡死,HAL库版本相对好一些,因为有超时机制。
6. 进阶玩法:中断刷新、周期测量模式与后续扩展
基础版本跑通之后,你会开始想它还能做什么。这里分享几个我已经验证过的扩展方向。
第一个是中断刷新。不要在定时器中断函数里直接做I2C读取和OLED刷新,因为I2C通信耗时不稳定,OLED刷新更是要几百微秒到几毫秒,放在中断里会严重影响系统实时性。正确做法是定时器中断只置一个flag = 1,主循环检测到标志位后再去做读取和刷新。这样哪怕I2C通信偶尔出点幺蛾子,也只是主循环慢一点,中断响应时间完全不受影响。
第二个是SHT30的周期测量模式。SHT30支持自动周期测量(比如每秒测量10次),MCU可以只发一条配置命令,然后每隔一段时间直接读最新结果,不用再每次触发单次测量。这种模式适合需要更高数据更新率、又不希望频繁操作I2C的场景。代价是传感器内部一直在测量,休眠电流比单次测量模式高。如果做电池供电的低功耗设备,我更推荐单次测量模式,MCU休眠前发一条命令唤醒测量,醒来后读结果,平均电流能压得比较低。
第三个是把数据送出去。OLED只承担本地显示功能,如果项目需要远程监控,可以用串口把温度和湿度以JSON或CSV格式发出去,接到ESP8266/ESP32或者USB转串口模块上,这样就能把SHT30变成一个小型环境数据采集节点。注意SHT30的I2C地址冲突问题,如果同一总线上要挂多个SHT30,就得把ADDR引脚分别接GND和VDD,一个用0x44,一个用0x45。
最后再分享一个我个人的调试习惯:每次拿到一个新的传感器模块,我都会先用一个I2C总线扫描程序扫一遍,看看实际识别到的从机地址和手册里写的一不一样,然后再上正式例程。这一步真的能省掉大量“为什么读不到数据”的排查时间。这套STM32F1 + SHT30 + OLED的方案,我在三个不同型号的F103板子上跑过,除了引脚映射需要微调之外,核心代码一次都没改过,稳定性是经过验证的。你可以直接把这份源码烧进板子里,先跑通,再根据你的需求去改刷新率、通讯方式或者显示布局。
本文还有配套的精品资源,点击获取