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

简介:面向STM32F1嵌入式开发学习者,这套基于正点原子mini板的SHT30温湿度传感器与OLED显示源码包,提供了可直接编译运行的完整工程,适用于课程设计、毕业设计或快速原型验证。源码涵盖I2C外设初始化、SHT30读写时序与数据校验、SSD1306 OLED驱动显示,以及定时刷新和用户界面切换等关键模块;在正点原子mini板上硬件连接已经完成,开发者只需关注软件驱动编写,烧录后即可在0.96英寸屏幕上实时显示温度和湿度数值。资源共有200个文件,以34个C源文件、38个头文件为主,另含工程配置、链接脚本与编译输出文件,压缩包体积仅4.89MB,目录结构清晰,便于按驱动层、应用层和配置文件分类检索。目前已有2444人学习浏览,非常适合学习I2C总线协议、传感器数据采集、OLED图形驱动以及STM32工程管理,是嵌入式入门和进阶的实用参考资料。

1. 为什么是SHT30+OLED:一条I2C总线上挂两颗从机的低成本环境监测方案

在STM32F1上同时驱动SHT30温湿度传感器和0.96寸OLED显示屏,本质上就是在一根I2C总线上挂两颗从机:一颗是传感器,地址通常为0x44;一颗是屏,地址通常为0x3C。很多照着例程抄代码的开发者在这条总线上栽跟头,因为F1的硬件I2C外设出了名地挑剔,而市面上一半以上的例程宁可多写几十行GPIO代码做软件模拟I2C,也不碰硬件I2C。这篇笔记围绕“源码可用”这个承诺,把从引脚接线、传感器驱动、OLED显示到调试排错的完整链路讲清楚,给新手一条能一次点亮的路,也把参数边界和常见翻车点交代给熟手。

2. 硬件连接与工程搭建:引脚分配、上拉电阻和标准外设库模板

2.1 器件选型与引脚分配:SCL/SDA挂设备之前先看这三点

SHT30是数字温湿度传感器,I2C接口,测量范围温度-40~125℃、湿度0~100%RH,典型精度能在±0.3℃和±2%RH这个量级,比DHT11可靠得多。OLED这里用最常见的0.96寸128x64,驱动芯片是SSD1306,同样走I2C。两块模块都是3.3V供电,不需要额外电平转换,MCU也用3.3V供电即可。

引脚分配上,我的习惯是选PB6做SCL、PB7做SDA,因为这两个引脚刚好是STM32F1的I2C1外设引脚,即使后面想改成硬件I2C也不用重新连线。但要注意,软件I2C其实不受引脚限制,只要是有开漏输出能力的GPIO都能用。推荐分配如下:

功能SHT30引脚OLED引脚接MCU引脚
电源VINVCC3.3V
地GNDGNDGND
时钟SCLSCLPB6
数据SDASDAPB7

上拉电阻是这里最容易忽略的坑。市面上大多数SHT30模块和OLED模块都自带了4.7k欧上拉电阻,直接接就能用。但如果你买的是裸传感器或者从别的板子上拆下来的,没有上拉,I2C空闲时总线会处于不确定状态,通信时好时坏。外部补两个4.7k欧电阻到3.3V,这是标准做法。低于1k会让总线负载过重,高于10k会拖慢上升沿,4.7k在400kHz时钟下是安全的。

另外要注意,SHT30的ADDR引脚决定I2C地址:接GND是0x44,接VDD是0x45。OLED的SA0引脚(有些模块标注为DC)决定地址是0x3C还是0x3D。做硬件前先确认模块背面的电阻位置或丝印,后面软件里的地址才能对得上。

2.2 工程模板选型:标准外设库加软件I2C,少走弯路

做STM32F1的工程,现在网上能看到两类模板:标准外设库(SPL)和HAL库。我的建议是用标准外设库加自己写的软件I2C。原因是F1的HAL库I2C代码量大、依赖层多,出问题时不好定位;而标准外设库只需要寄存器操作GPIO,代码完全可控。F1这颗芯片的硬件I2C本身有EV事件处理繁琐、总线锁死恢复麻烦的毛病,软件I2C反而能规避掉大部分无关变量。

工程目录按功能拆开,方便后面换平台或者加传感器:

Project/ ├── User/ │ ├── main.c │ └── stm32f10x_it.c ├── HARDWARE/ │ ├── i2c_soft/ │ │ ├── i2c_soft.c │ │ └── i2c_soft.h │ ├── sht30/ │ │ ├── sht30.c │ │ └── sht30.h │ └── oled/ │ ├── oled.c │ └── oled.h ├── SYSTEM/ │ └── delay/ │ ├── delay.c │ └── delay.h └── Libraries/ └── CMSIS + 标准外设库

这个结构里,HARDWARE下的三个模块只依赖底层的I2C接口和延时接口,不直接操作寄存器。以后换个平台,只要把i2c_soft重写一遍,sht30和oled的代码可以原样搬走。这也是我一直强调的模块化习惯,尤其是做源码可用的例程时,耦合度越低,别人抄起来越省事。

软件I2C的核心代码就这么点东西。SCL和SDA都用开漏输出模式,配合外部上拉电阻,读SDA时再把引脚切到输入模式。开漏输出是关键,用推挽输出会在高电平时和从机拉低总线打架,导致数据错乱。

#define I2C_SCL_H() GPIOB->BSRR = GPIO_Pin_6 #define I2C_SCL_L() GPIOB->BRR = GPIO_Pin_6 #define I2C_SDA_H() GPIOB->BSRR = GPIO_Pin_7 #define I2C_SDA_L() GPIOB->BRR = GPIO_Pin_7 // PB7 输入模式(上拉输入) #define I2C_SDA_IN() GPIOB->CRL &= ~(0xF << 28); \ GPIOB->CRL |= (0x8 << 28) // PB7 开漏输出模式,50MHz #define I2C_SDA_OUT() GPIOB->CRL &= ~(0xF << 28); \ GPIOB->CRL |= (0x7 << 28) void i2c_start(void) { I2C_SDA_OUT(); I2C_SDA_H(); I2C_SCL_H(); delay_us(5); I2C_SDA_L(); // SDA 先拉低,SCL 保持高,形成起始条件 delay_us(5); I2C_SCL_L(); } uint8_t i2c_write_byte(uint8_t dat) { uint8_t i; uint8_t ack; I2C_SDA_OUT(); for (i = 0; i < 8; i++) { if (dat & 0x80) I2C_SDA_H(); else I2C_SDA_L(); dat <<= 1; I2C_SCL_H(); delay_us(2); I2C_SCL_L(); delay_us(2); } I2C_SDA_IN(); // 释放 SDA,读取从机应答位 I2C_SCL_H(); delay_us(2); ack = GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7); I2C_SCL_L(); I2C_SDA_OUT(); return ack; // 0 表示从机 ACK,1 表示 NACK } void i2c_stop(void) { I2C_SDA_OUT(); I2C_SDA_L(); I2C_SCL_H(); delay_us(5); I2C_SDA_H(); // SCL 高电平期间 SDA 拉高,形成停止条件 delay_us(5); }

逻辑说明:start条件是SDA先拉低、SCL保持高;stop条件反过来,SCL保持高时SDA拉高。写字节时,数据在SCL高电平期间必须稳定,所以先设置SDA再拉高SCL,这样从机在SCL上升沿采样是安全的。ACK读取必须把SDA切成输入模式,因为应答位是由从机拉低的。如果你发现读回来的ack一直是1,大概率就是SDA没切成输入,或者外部上拉缺失导致从机拉不动总线。

2.3 时钟与延时基础:SysTick的ms级和us级延时实现

SHT30单次测量需要等待十几毫秒,OLED上电后要等复位完成,I2C的时钟翻转需要微秒级延时,这些都要靠一个可靠的延时函数。F1的板子晶振通常有两种:8MHz和16MHz。标准外设库的SystemInit默认把时钟配置到72MHz,前提是外部晶振为8MHz且倍频数为9。如果你的板子用了16MHz晶振,SystemClock会是144MHz,超频了,延时全乱。所以第一步先确认SystemCoreClock变量打印出来是72000000。

SysTick做ms延时最简单的做法是每次调用时重新装载,不用中断服务函数,避免干扰主流程:

void delay_us(uint32_t us) { uint32_t i; SysTick->CTRL &= ~(SysTick_CTRL_ENABLE_Msk); // 先关闭 SysTick->LOAD = SystemCoreClock / 1000000 * us - 1; SysTick->VAL = 0; SysTick->CTRL |= SysTick_CTRL_ENABLE_Msk; // 使能 while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk)); SysTick->CTRL &= ~(SysTick_CTRL_ENABLE_Msk); } void delay_ms(uint16_t ms) { while (ms--) { delay_us(1000); } }

参数说明:LOAD寄存器的值是72 * us - 1,因为72MHz主频下1us等于72个时钟周期。注意LOAD是24位寄存器,最大值16777215,所以delay_us最大只能约23万微秒,超过就溢出了。实际场景里用到毫秒级就够,不会有问题。us延时的精度在软件I2C里不需要太严格,I2C协议只要求SCL高电平最小4.7us,我们给2us反而能让时序更稳。如果你发现某些模块通信不稳定,可以把2us改成4us,低速但可靠。

3. 让SHT30交出真实数据:单次测量、CRC8校验与数据转换

3.1 先搞懂SHT30的命令体系:单次测量与周期测量怎么选

SHT30的数据手册里命令很多,但例程里真正常用的就两类:单次测量和周期测量。单次测量是每次你发一个测量命令,等一段时间,再读数据;周期测量是传感器自己按设定频率连续测量,MCU随时去读最新结果。对于电池供电的采集设备,单次测量更省电,因为测量完可以进睡眠;对于长期在线刷新显示的场景,周期模式省掉频繁发命令的开销。

常用命令对照如下:

命令码含义适用场景
0x2C 0x06单次测量,高重复性默认推荐,精度最好
0x2C 0x0D单次测量,中重复性对功耗敏感的场景
0x2C 0x10单次测量,低重复性只需要趋势判断
0x21 0x30周期测量,1Hz,高重复性长时间在线监测
0x30 0xA2软复位总线异常时恢复
0xE0 0x00读状态寄存器检查传感器状态

高重复性测量的等待时间约为15ms,中重复性约6ms,低重复性约4.5ms。驱动代码里我统一延时20ms,确保任何模式下都读得到完整数据。周期模式有个坑:它需要持续发送读命令才能拿到最新值,而且如果MCU端配合不好,传感器会一直占用总线,所以我一般在例程里只用单次模式,逻辑最简单也最稳。

3.2 SHT30驱动代码:带CRC8校验的稳定读取实现

SHT30返回的原始数据是6个字节:温度高字节、温度低字节、温度CRC、湿度高字节、湿度低字节、湿度CRC。CRC8校验多项式是0x31,初值0xFF。很多例程偷懒不校验CRC,直接读前4字节就能用,但如果总线上有干扰或者线缆过长,CRC能帮你识别出错误数据。建议保留校验逻辑,代价只是几十行代码。

// 计算 CRC8,多项式 0x31,初值 0xFF uint8_t sht30_crc8(uint8_t *data, uint8_t len) { uint8_t crc = 0xFF; uint8_t i; while (len--) { crc ^= *data++; for (i = 0; i < 8; i++) { crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1); } } return crc; } // 返回值:0成功,1无应答,2温度CRC错误,3湿度CRC错误 uint8_t sht30_read_temp_humi(float *temp, float *humi) { uint8_t buf[6]; uint8_t ret = 0; // 发送单次高重复性测量命令 0x2C06 i2c_start(); ret |= i2c_write_byte(0x44 << 1); // 0x44 左移一位变成写地址 0x88 ret |= i2c_write_byte(0x2C); ret |= i2c_write_byte(0x06); i2c_stop(); if (ret) return 1; // 传感器没应答,直接退出 delay_ms(20); // 等测量完成 // 读取6字节数据 i2c_start(); ret |= i2c_write_byte((0x44 << 1) | 0x01); // 读地址 0x89 if (ret) { i2c_stop(); return 1; } buf[0] = i2c_read_byte(0); // ACK buf[1] = i2c_read_byte(0); buf[2] = i2c_read_byte(0); buf[3] = i2c_read_byte(0); buf[4] = i2c_read_byte(0); buf[5] = i2c_read_byte(1); // 最后一字节发 NACK i2c_stop(); if (sht30_crc8(&buf[0], 2) != buf[2]) return 2; if (sht30_crc8(&buf[3], 2) != buf[5]) return 3; // 温度:-45 + 175 * raw / 65535 *temp = ((uint16_t)(buf[0] << 8 | buf[1])) * 175.0f / 65535.0f - 45.0f; // 湿度:100 * raw / 65535 *humi = ((uint16_t)(buf[3] << 8 | buf[4])) * 100.0f / 65535.0f; return 0; }

逻辑说明:发送测量命令后必须stop,让传感器开始工作。延时20ms后再发起读操作。读6个字节时,前5个字节回ACK表示“我还要继续读”,最后1个字节回NACK表示“读完了”。i2c_read_byte(ack)的参数就是控制这个行为的,实现里读到数据后根据参数决定拉高还是拉低SDA。

数据转换公式是手册里的标准公式,温度可以这样记:原始值0对应-45℃,65535对应130℃;湿度原始值0对应0%RH,65535对应100%RH。算出来以后,再对照一个常识判断:温度在-40~125℃范围外、湿度在0~100%范围外,那一定是解析出错了,优先查CRC和I2C时序。

这里还要提一个写地址的细节。I2C通信时7位地址要左移一位变成8位,最低位是读写标志。0x44写就是0x88,读就是0x89。有些人直接把0x44当作8位地址发出去,那相当于在找0x22这个地址的设备,必然无应答。这是新手最容易犯的错。

3.3 读数异常的排查点:ACK失败、测量延时和地址混淆

当你第一次把代码烧进去,大概率遇到的情况是温度显示85℃、湿度显示0.0,或者干脆是乱码。这时先别急着改代码,按顺序排查三个点。

第一,检查ACK。在return 1的分支里,说明i2c_write_byte返回了1,也就是从机没有拉低SDA回应。这时候用万用表量一下SCL和SDA的空闲电平,正常都应该是3.3V。如果SDA是0V,说明总线被某个设备拉住了,可能是之前通信中途断电,从机还在等后续数据。解决办法是把两个引脚都重新初始化为开漏输出,然后手动切换SCL九个时钟脉冲,强制从机释放总线。

第二,检查测量延时。SHT30高重复性测量要15ms,如果你只延时了2ms就去读,读回来的要么是上次的数据,要么是0xFF。把延时改到20ms或更长,问题通常立刻消失。

第三,检查地址。模块上如果ADDR引脚接了VDD,地址就不是0x44而是0x45了。OLED同理,0x3C和0x3D的区别就在SA0引脚的电平。买个逻辑分析仪,二十几块钱的就行,挂在SDA和SCL上看波形,起始条件、地址字节、ACK位一目了然,比拿万用表猜快得多。

4. OLED显示温湿度:SSD1306初始化、页寻址与字符串渲染

4.1 SSD1306初始化序列:每条命令的字节含义

SSD1306的初始化序列在网上能找到很多版本,但命令都大同小异。核心是:打开内部电荷泵、设定扫描方向、设置对比度、清屏、点亮。如果你用了某个初始化序列后发现屏幕镜像或者上下颠倒,就是0xA0/0xA1和0xC0/0xC8这两组命令选反了。0xA1是段重映射,0xA0是正常方向;0xC8是COM扫描方向从COM0到COM63,0xC0反过来。这两个命令决定屏幕正常显示还是镜像翻转。

void oled_init(void) { delay_ms(100); // 等待 OLED 模块上电稳定 oled_write_cmd(0xAE); // 关闭显示 oled_write_cmd(0x20); oled_write_cmd(0x00); // 设置内存寻址模式为页寻址 oled_write_cmd(0x81); oled_write_cmd(0xCF); // 设置对比度 0xCF oled_write_cmd(0xA8); oled_write_cmd(0x3F); // 设置 multiplex ratio 为 1/64 oled_write_cmd(0xD3); oled_write_cmd(0x00); // 显示偏移为 0 oled_write_cmd(0x40); // 起始行 0 oled_write_cmd(0xA1); // 段重映射,方向正常 oled_write_cmd(0xC8); // COM 扫描方向,正常 oled_write_cmd(0xDA); oled_write_cmd(0x12); // COM 引脚配置,0.96寸常见配置 oled_write_cmd(0xD5); oled_write_cmd(0x80); // 时钟分频,默认值 oled_write_cmd(0x8D); oled_write_cmd(0x14); // 启用内部电荷泵 oled_write_cmd(0xD9); oled_write_cmd(0xF1); // 预充电周期 oled_write_cmd(0xDB); oled_write_cmd(0x40); // VCOMH 电压 oled_write_cmd(0xA4); // 使用显存内容显示 oled_write_cmd(0xA6); // 正常显示模式,不是反色 oled_write_cmd(0xAF); // 打开显示 oled_clear(); }

参数说明:对比度0xCF偏亮,如果你觉得刺眼可以降到0x7F。预充电周期0xF1决定像素的充电时间,太短会偏暗,太长有残影。正常情况下用这套配置就能点亮,如果屏幕发暗或者闪烁,优先调这两个参数。还有一点,0x8D 0x14这个电荷泵命令必须放在打开显示之前,否则屏幕会白屏。

oled_write_cmd的实现要特别注意控制字节。SSD1306在I2C模式下,写入的每个字节前面都要跟一个控制字节:0x00表示命令,0x40表示数据。网上有些例程把命令写成了0x80,那是把数据/命令位标志理解成了最高位,也能工作,因为0x80和0x00在SSD1306的命令解析里被当作D/C位处理。但我建议统一用0x00和0x40,跟手册一致,以后排查问题不容易混乱。

4.2 显存写入与页地址模式:把二进制点阵画上屏幕

SSD1306的显存是128x64,按页分成8个水平条带,每页8像素高。页地址模式下,你设置一个起始页地址和列地址,然后连续写数据,内部的指针会自动递增。所以写一屏数据前,要先把列地址低字节和高字节按顺序发一遍,再发页地址,然后才是数据。顺序错一点,字符就会显示在错误的位置。

void oled_write_cmd(uint8_t cmd) { i2c_start(); i2c_write_byte(0x3C << 1); // OLED 地址 0x3C i2c_write_byte(0x00); // 控制字节:命令 i2c_write_byte(cmd); i2c_stop(); } void oled_write_data(uint8_t dat) { i2c_start(); i2c_write_byte(0x3C << 1); i2c_write_byte(0x40); // 控制字节:数据 i2c_write_byte(dat); i2c_stop(); } void oled_clear(void) { uint8_t page, col; for (page = 0; page < 8; page++) { oled_write_cmd(0xB0 + page); // 页地址 0~7 oled_write_cmd(0x00); // 列地址低字节 oled_write_cmd(0x10); // 列地址高字节 for (col = 0; col < 128; col++) { oled_write_data(0x00); } } }

清屏的循环里,每写完一页128个字节,内部列指针已经到127了,但为了保险,每次进入新页还是重新设置列地址。这个习惯能避免某些屏的控制器在跨页后指针行为不一致的问题。实际我也碰到过某些OLED模块在连续写多页后指针不会自动回卷,所以每次设置页地址后都重新设置列地址是最稳妥的写法。

显示字符需要字模。常见的8x16字体每个字符占16字节:上半8字节显示在页X,下半8字节显示在页X+1。所以一行字符的高度是16像素,128x64的屏幕最多显示4行。例程里我常用两个函数:显示字符串和显示单个字符,字符串函数逐个取字模表中对应字符的偏移量。

4.3 温湿度数据上屏:格式化输出与刷新节奏

把SHT30读到的温度湿度打印到OLED上,核心是处理浮点数转字符串。这里有两条路:一条是用sprintf直接格式化,代码简单;另一条是把浮点数拆成整数部分和小数部分分别显示。sprintf的坑是:在标准库环境下,默认不启用浮点格式化支持,你需要开启MicroLIB或者勾选支持浮点打印,否则输出会变成空字符串或者乱码。为了稳妥,我通常把温度乘10转成整数,然后自己拼字符串,避开浮点格式化的兼容性问题。

uint8_t sht30_read_temp_humi(&temp, &humi); // 转成整数,避免 sprintf 浮点格式化问题 int16_t temp_x10 = (int16_t)(temp * 10); int16_t humi_x10 = (int16_t)(humi * 10); char str[16]; // 温度:显示为整型加一位小数,负号要处理 sprintf(str, "T:%d.%d C", temp_x10 / 10, temp_x10 % 10); oled_show_string(0, 0, str); // 湿度 sprintf(str, "H:%d.%d %%RH", humi_x10 / 10, humi_x10 % 10); oled_show_string(0, 2, str);

注意湿度字符串里的百分号必须写两个%才能转义出来。负数温度的话,temp_x10/10会得到负整数,但temp_x10%10在C语言里对负数会得到负值,所以显示-3.2时会变成“-3.-2”,这是新手很容易踩的坑。处理办法是:先判断temp_x10小于0,把绝对值算出来,再拼接字符串。

刷新节奏方面,SHT30的测量周期和OLED的刷新频率要匹配。我一般每500ms读一次传感器,读出来有效就刷新屏幕,读失败就保留旧值不刷,同时用程序计数器统计失败次数。这样做有两个好处:一是传感器数据稳定,不会被频繁测量带来的热量干扰;二是OLED刷新速度不需要太快,50ms一刷和500ms一刷人眼看着没区别,但功耗差一个量级。

5. 常见问题与排查避坑:五条实测翻车记录

5.1 温度一直显示85℃或0.0——传感器根本没被应答

现象:程序跑起来,OLED亮了,但温度和湿度显示固定值,比如温度85.0℃、湿度0.0%RH,或者全是0.0。不管怎么重启都是这样。

原因:85℃这个数字看起来很眼熟,其实是我在初始化代码里给的临时初值,说明sht30_read_temp_humi返回了错误码,主循环没有正确处理。最根本的原因是I2C通信没建立起来,常见有三种:地址左移搞错,把0x44直接当成写地址发出去了;SHT30的ADDR引脚电平导致实际地址是0x45,但代码里写死了0x44;传感器根本没上电,VIN和GND接反了。

解决:在main函数最开始先跑一遍I2C总线扫描,遍历地址0x30到0x50,把能响应ACK的设备打印出来。这样屏幕上或串口里能看到“found 0x44”和“found 0x3C”,一眼就能确认哪些设备在线、地址对不对。这个方法比盯着数据发愣高效得多。

5.2 I2C总线卡死:OLED花屏后一切命令失效

现象:刚开始烧录时OLED能显示几行字,但显示内容错乱,像花屏。过几秒或者重新初始化后,整个OLED和SHT30都不响应了,重新插电又正常,运行一会儿又卡死。

原因:这是软件I2C最常见的翻车点,SDA或SCL被配置成了推挽输出。推挽输出在高电平时会强驱动总线,而I2C从机应答时会把SDA拉低。两者同时作用,要么电流过大,要么电平状态被锁死。另一个原因是I2C通信过程中被中断打断,比如中断服务函数里也调用了I2C读写,两个流程抢总线,从机收到一半数据就开始等后续字节,总线被从机钳住。

解决:GPIO必须配置为开漏模式,哪怕是软件I2C也不能偷懒。代码里SDA_OUT那个宏,之前版本我写过推挽输出配置0x3,后来全改成开漏输出0x7。另外,确认整个工程里只有主循环一条路径在调用I2C函数,中断里不要碰I2C。如果真的卡死了,手动恢复总线的方法是:SCL输出连续9个高低脉冲,同时保持SDA为高,从机内部状态机会被重置,总线释放。

5.3 编译通过但下载后无反应——启动文件与Flash容量不匹配

现象:编译零错误零警告,代码下载下去了,但单片机就是没反应,连main函数里的第一行LED翻转都没执行。用调试器看,程序跑飞或者卡在HardFault_Handler。

原因:绝大多数情况是startup启动文件选错了。STM32F103C8T6只有64KB Flash,对应startup_stm32f10x_md.s;F103ZET6有512KB Flash,对应startup_stm32f10x_hd.s。如果把md工程用了hd的启动文件,链接时向量表偏移变了,中断都进不去,程序自然跑不起来。还有一种是外部晶振型号不对,8MHz晶振配倍频9倍是72MHz,如果板子上是16MHz晶振,SystemInit配出来的主频翻倍到144MHz,Flash访问时序跟不上,程序直接跑飞。

解决:先检查启动文件是不是和芯片型号匹配,再在main第一行打一个GPIO翻转断点,用调试器确认能进main。串口打印SystemCoreClock的值,确认是72000000。如果主频不对,去system_stm32f10x.c里把PLL倍频参数改过来。

5.4 地址写死导致的兼容性问题——换一批器件就不亮

现象:自己手上的SHT30和OLED跑得好好的,把同样的代码烧到一个新买的板子上,屏幕或传感器没反应。同一个项目,两个硬件,一个能跑一个不能跑。

原因:OLED模块的地址选择脚SA0在实际模块上经常被电阻硬连接,不同批次可能不同。SHT30的ADDR引脚也是,有些模块把它接到了VDD,导致从机地址变成0x45。这些外部因素只有用硬件实测才能确认,代码写死地址就是埋雷。

解决:不要假设地址,写一个自动探测函数。驱动初始化前,扫描总线上所有有效地址,把SHT30和OLED的地址分别识别出来,存入全局变量,后续所有I2C操作都使用这个变量。扫描函数本身很轻量,几十行代码,但对可移植性帮助极大。这也是“源码可用”四个字里最容易被人忽略的含义——代码可用的前提是硬件能对上。

5.5 电源抖动带来的数据跳变与亮度不均

现象:传感器数据和OLED显示都正常,但湿度数值会突然跳变个3%~5%,屏幕亮度也有轻微不均匀。用示波器看3.3V电源,纹波很大,尤其在OLED刷新瞬间。

原因:SHT30和OLED同时挂在同一条电源线上,OLED刷新时内部电荷泵会有一个脉冲电流,瞬间拉低VDD。SHT30的温湿度测量结果对电源稳定度有一定要求,电源跳动直接反映在ADC参考上。另一个常见因素是面包板接线太长,寄生电感大,电流突变时压降明显。

解决:OLED的VCC和SHT30的VIN之间用一个10欧电阻隔离,再各自并联一个100nF去耦电容,这是低成本且有效的方案。如果还有问题,就在总电源处加一个47uF电解电容。实际调试里,把SHT30放到离OLED远一点的位置也有帮助,因为OLED背面的驱动芯片本身会发热,影响温度读数。湿度跳变还有一个隐蔽原因:测量间隔太短,传感器自身工作发热影响了微环境的湿度,把采样周期放到1秒以上基本能消除。

6. 让源码真正可复用:驱动分层、printf调试和逻辑分析仪验证

驱动分层这件事,值得单独说一下。我在2.2里把工程拆成了i2c_soft、sht30、oled三个模块,目的就是让上层代码和下层层代码解耦。sht30.c里不应该出现任何GPIO操作,它只认识i2c_soft提供的start、stop、write_byte、read_byte四个函数。oled同理。这样你换一块MCU,比如从STM32F1换成F4或者G0,只需要重写i2c_soft,上层的传感器驱动和显示驱动一行都不用改。我自己的血泪经验是:第一次写驱动时把GPIO操作用宏拷得到处都是,结果换板子时花了一个晚上改代码,从那以后就坚持这个分层习惯。

printf调试在这个工程里比OLED显示更早发挥作用。重定向fputc到串口USART1,在main里把原始6字节、CRC结果、转换后的浮点数全部打出来,然后对比逻辑分析仪抓到的波形。这样定位问题非常快。

int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_SR_TC)); USART1->DR = ch; return ch; }

逻辑分析仪的使用有个技巧:采样率不需要很高,2MHz就够抓400kHz的I2C。关键是要把SDA和SCL两个通道同时接好,触发方式设为SDA下降沿。抓下来的波形里,首先看起始条件:SDA先拉低、SCL保持高。然后看地址字节,第一个字节应该是0x88或0x89,对应0x44的写和读。如果地址对但后面数据全0xFF,说明从机没准备好,延时不够。这套流程走一遍,I2C的问题基本都能定位。

还有一个我常用的验证技巧:给传感器发软复位命令0x30 0xA2,等10ms,再重新初始化。如果复位后读数恢复正常,说明传感器本身没坏,只是状态机被总线干扰搞乱了。这个技巧在写进正式版本前先把软复位做进初始化流程,能少跑很多次冤枉路。

最后的习惯养成:每次接线后,上电先量I2C两条线的空闲电平,必须都是高电平。SDA如果是低,说明某个从机正在等待后续数据,绝对不能直接跑程序。这一个动作值五块钱万用表,却能省下一晚上排查时间。希望帮到你。

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

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

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

立即咨询