STM32CubeMX+HAL库驱动SHT40:硬件I2C完整实现与踩坑指南
2026/9/24 12:19:16 网站建设 项目流程

最近做室内环境监测节点,MCU 选了老面孔 STM32F103C8T6,温湿度传感器没有继续用烂大街的 DHT11/DHT22,而是换了 Sensirion 的 SHT40。选它的原因很直接:I2C 接口、精度高、响应快、自带 CRC 校验,DFN-4 封装小到几乎不占板面积。真正动手才发现,网上一搜基本都是软件模拟 I2C 在驱动这颗传感器,用 STM32CubeMX + HAL 库的硬件 I2C 方案反而没什么人系统写过。这篇文章把从 CubeMX 配置、HAL 库驱动编写、CRC 校验,到踩坑调试、稳定运行一个月的完整过程全部记下来,给准备上 SHT40 又不想用软 I2C 的朋友一条可以直接照抄的路。

1. 为什么放弃软 I2C:硬件 I2C 的选型逻辑

1.1 项目需求对传感器提出的要求

这个节点要放在一个没有空调的机房角落,供电是一节 18650 电池加 LDO,主控平时休眠,每 30 秒醒来采集一次温湿度,再通过 LoRa 把数据发到网关。整个系统的功耗预算卡得很死,所以传感器选型有几个硬指标:待机电流要低、测量电流不能离谱、唤醒后要能快速出数、接口最好是标准总线方便以后扩展。

DHT11 首先被排除,精度太差、响应太慢,温度误差 ±2°C 在机房场景里根本不够看。DHT22 精度稍好一点,但单总线时序对延时极其敏感,在 FreeRTOS 环境下容易被高优先级任务打断导致读时序错乱。SHT40 的优势很明显:I2C 接口天然抗任务调度干扰,高重复性测量典型耗时只有 8.2ms,温度精度 ±0.2°C,湿度精度 ±1.8%RH,待机电流 0.08µA,平均测量电流在 1Hz 采样率下只有 0.4µA 左右。这个功耗特性对电池供电非常友好。

但 SHT40 的数据手册明确要求主机必须支持时钟拉伸(Clock Stretching)或者等待足够长的测量时间再发起读取。这一点在后来的调试中确实踩了坑,后面专门讲。

1.2 软 I2C 与硬件 I2C 对比:F1 系列的历史包袱与现实

STM32F1 系列的硬件 I2C 在社区里名声不太好,早年标准外设库时代确实存在一些勘误表上的问题,比如总线错误后无法自动恢复、EV5/EV6 事件标志位判断繁琐、多主机模式下容易卡死。这些历史问题导致大量工程师养成了"F1 上不用硬 I2C,一律软 I2C"的习惯。但我个人认为,这个结论在 HAL 库时代需要被重新审视——STM32CubeF1 固件包在 1.8.x 版本之后对 I2C 外设驱动做了大量重写,事件标志位管理、错误恢复、超时机制都完善了很多。

软 I2C 的本质是用 GPIO 翻转模拟时序,优点是不依赖芯片外设、寄存器配置简单、代码跨平台移植方便,Sensirion 官方也提供了基于软 I2C 的驱动样例。但缺点同样明显:占用 CPU 期间不能做别的事,时序受中断影响,如果任务优先级设计不当,高位接低位的翻转延迟会造成通信失败。硬件 I2C 把时序交给外设控制,配合 DMA 或中断,CPU 可以在传输过程中去处理别的任务,这在 RTOS 环境里是很重要的优势。

1.3 上拉电阻与电气连接的注意事项

硬件 I2C 和传感器之间还有一个容易被忽视的关键点:上拉电阻选型。SHT40 数据手册给出的 I2C 最大速率是 1MHz,STM32F103 的 I2C 外设支持标准模式 100kHz 和快速模式 400kHz。我最初在面包板上用 4.7kΩ 上拉电阻跑 400kHz,波形上升沿明显变缓,后来换成 2.2kΩ 才恢复正常。基本原则是:总线速率越高,需要的上拉电阻越小,但也不能太小,否则灌电流过大会损伤引脚,2.2kΩ 在 400kHz 下是比较均衡的选择。

另外 SHT40 的 VDD 引脚对电源纹波比较敏感,数据手册要求在 VDD 和 GND 之间放置 100nF 旁路电容,并且要尽量靠近传感器引脚。我第一版 PCB 布线图没有严格遵守这个要求,电容放得比较远,结果读到的湿度值在相邻两次采样之间偶尔会跳 1%RH 左右,改成靠近放置后问题消失。

2. SHT40 芯片特性与协议要点拆解

2.1 引脚定义与封装细节

SHT40 采用 4 脚 DFN 封装,尺寸只有 1.5mm x 1.5mm,引脚排列为 VDD、SDA、GND、SCL。这里有个容易踩的坑:DFN 封装的引脚编号是逆时针的,画封装时如果不核对数据手册的顶视图,很容易把 SDA 和 SCL 搞反。我第一次手工焊接样板时就因为封装视图理解错误,飞线才解决。

在实际接线中,SHT40 的 SDA 和 SCL 需要分别连接到 STM32 的 I2C 引脚。以 STM32F103C8T6 为例,I2C1 的默认引脚是 PB6(SCL)和 PB7(SDA)。SHT40 的 ADDR 引脚如果悬空或接地,7 位 I2C 地址是 0x44;如果接 VDD,地址变为 0x45,这个引脚可以用来在同一总线上挂载两颗传感器。

SHT40 引脚功能连接目标
1 (VDD)电源输入 1.08V~3.6V3.3V,并接 100nF 旁路电容
2 (SDA)I2C 数据线PB7,接 2.2kΩ 上拉到 3.3V
3 (GND)GND
4 (SCL)I2C 时钟线PB6,接 2.2kΩ 上拉到 3.3V

2.2 命令字与测量模式

SHT40 的 I2C 命令设计非常简洁,所有命令都是单字节。测量命令有三个:0xFD 代表高重复性测量,0xF6 代表中重复性,0xE0 代表低重复性。重复性越高,测量时间越长、噪声越低。高重复性典型测量时间 8.2ms,中重复性 4.5ms,低重复性 1.7ms。实测下来,在普通室内环境中,中重复性和高重复性读出的温湿度差异极小,大约 0.1°C 和 0.3%RH 以内,但测量时间差了近一倍。

此外还有软复位命令 0xFE,用于在不掉电的情况下复位传感器内部状态;以及一组加热器命令,可以短时间开启内部加热器来去除凝露。加热器命令的配置组合比较多,本文暂不展开,只在后面扩展部分简单提一下。

这里要注意一个细节:SHT40 每次上电后不需要额外的初始化序列,直接发测量命令即可。但数据手册建议在首次测量前执行一次软复位,确保传感器状态干净。我在驱动初始化函数里固定发一次 0xFE,然后延时 10ms 再开始正常测量。

2.3 测量事务时序:从 START 到 6 字节数据

一次完整的温湿度测量事务分为两个阶段。第一阶段,主机发送 START 条件,然后发送 7 位地址 0x44 加写位 0,再发送测量命令字节,最后发送 STOP 条件。第二阶段,主机等待测量完成,然后发送 START、7 位地址 0x44 加读位 1,从机连续返回 6 个字节:温度高字节、温度低字节、温度 CRC、湿度高字节、湿度低字节、湿度 CRC。

两个阶段之间需要等待的时长,取决于测量模式和总线是否支持时钟拉伸。SHT40 的时钟拉伸机制是:从机在测量期间会把 SCL 拉低,直到测量完成才释放。理论上支持时钟拉伸的主机可以直接发起读操作,从机会一直拉着 SCL 直到数据准备好。但 STM32F1 的老版本 HAL 库对时钟拉伸的处理并不完美,稳妥做法是在发完测量命令后,用 HAL_Delay 延时至少比标称测量时间长 2ms 再发起读取。高重复性模式下,我延时 10ms,实测可靠。

3. CubeMX 配置硬件 I2C 的完整流程

3.1 引脚初始化与时钟树配置

打开 STM32CubeMX,选择 STM32F103C8T6 芯片后,首先配置 RCC 的 HSE 为晶振模式,然后进入 Clock Configuration 页面,把系统时钟设置为 72MHz。这里有个容易忽略的地方:I2C1 外设的时钟源是 APB1,APB1 最大 36MHz,而 APB1 的分频系数默认是 2,也就是 PCLK1 是 36MHz。这个值会影响 I2C 时序参数的自动计算,CubeMX 会根据它自动算出 CCR 寄存器的值,所以时钟树要先配置好再配置 I2C。

接着配置 SYS 的 Debug 为 Serial Wire,否则板载 ST-Link 在程序跑起来后可能连不上芯片。然后进入 Pinout & Configuration 页面,找到 I2C1,在 Mode 里选择 I2C 模式。此时 CubeMX 会自动把 PB6 和 PB7 分配给 I2C1,不需要手动指定引脚。

顺便提一句,如果使用 I2C2,默认引脚是 PB10(SCL)和 PB11(SDA)。选 I2C1 还是 I2C2,主要看板上其他外设占用了哪些引脚。我这次为了给 LCD 屏幕留出更多 IO,选了 I2C1。

3.2 I2C 外设参数设置与生成代码检查

在 I2C1 的 Configuration 面板里,Parameter Settings 中有三个关键参数:时钟速度、时钟拉伸、地址位宽。时钟速度设 400000 Hz,这是 F1 硬件 I2C 在快速模式下能稳定跑的上限,如果再往上超频,对总线电容和上拉电阻的要求会非常苛刻。时钟拉伸保持默认的 ByPass 模式,地址位宽选 7-bit。

CubeMX 默认调用 HAL_I2C_Init 时,会读取 I2C_InitTypeDef 中的 AddressingMode、ClockSpeed 等字段。生成代码后,建议检查一下 stm32f1xx_hal_msp.c 文件里的 HAL_I2C_MspInit 函数,确认 GPIO 初始化是否正确设置了开漏输出模式和上拉。这里有个典型遗漏:HAL 库的 I2C GPIO 配置默认可能只设置开漏输出,却没有使能内部上拉。虽然外部已经有 2.2kΩ 上拉电阻,不使能内部上拉也不影响通信,但如果用的是无外部上拉的开发板,就必须手动加上 GPIO_PULLUP。

生成代码后还要检查主程序里是否调用了 MX_I2C1_Init(),以及是否正确初始化了 HAL 库本身。CubeMX 生成的 main.c 里默认会调用 HAL_Init()、SystemClock_Config()、MX_GPIO_Init() 和 MX_I2C1_Init(),如果没有其他特殊需求,不需要改动这些初始化顺序。

3.3 生成代码后的必要检查清单

代码生成完之后,不要急着写业务逻辑,先做三个检查。

第一,确认 I2C1 句柄变量名前缀。CubeMX 默认生成的句柄是 hi2c1,在 main.c 中定义,其他源文件如果要调用,必须用 extern 声明。第二,确认 stm32f1xx_hal_conf.h 中 HAL_I2C_MODULE_ENABLED 宏没有被注释掉,否则编译会报 HAL_I2C_Init 未定义。第三,确认系统主频和 APB1 分频是否符合预期,这直接影响到 I2C 时序计算的准确性。

做完这三项检查后,可以先用一个最简单的程序测试总线是否通:调用 HAL_I2C_IsDeviceReady(&hi2c1, 0x44 << 1, 3, 100),如果返回 HAL_OK,说明 SHT40 已经正确响应了地址。如果返回 HAL_TIMEOUT 或者 HAL_ERROR,先检查接线、上拉电阻和地址左移问题,这三项占了 90% 的通信失败原因。

4. HAL 库驱动代码实现与 CRC 校验

4.1 数据结构与命令宏定义

驱动代码我单独建了一个 sht40.c 和 sht40.h,头文件里定义地址、命令宏、返回码以及一个温湿度数据结构:

#define SHT40_I2C_ADDR (0x44 << 1) // 7位地址0x44左移1位,适配HAL库8位地址 #define SHT40_CMD_MEAS_HIGH 0xFD #define SHT40_CMD_MEAS_MED 0xF6 #define SHT40_CMD_MEAS_LOW 0xE0 #define SHT40_CMD_SOFT_RESET 0xFE #define SHT40_CRC8_POLY 0x31 #define SHT40_CRC8_INIT 0xFF typedef struct { float temperature; float humidity; uint8_t error; } SHT40_Data_t; uint8_t SHT40_Init(void); uint8_t SHT40_ReadData(SHT40_Data_t *data); uint8_t SHT40_CalcCRC8(uint8_t *src, uint8_t len);

这个头文件里最关键的一行是地址定义。HAL 库的 I2C 函数要求传入的是 8 位地址(7 位地址左移 1 位),而 SHT40 数据手册标注的是 7 位地址 0x44。网上很多示例代码直接写 0x44 传给 HAL_I2C_Master_Transmit,结果从机不应答,就是没搞懂这个左移关系。

4.2 单次测量函数实现

读取函数的核心逻辑是:发送测量命令、等待测量完成、读取 6 字节数据、逐段校验 CRC、换算成温湿度。我采用阻塞式调用,超时时间设 100ms,因为整个测量过程最多 10ms 左右,加上 I2C 传输时间,100ms 的余量足够,又不会在总线异常时卡死系统。

uint8_t SHT40_ReadData(SHT40_Data_t *data) { uint8_t cmd = SHT40_CMD_MEAS_HIGH; uint8_t rxBuf[6]; uint16_t rawTemp, rawHum; if (HAL_I2C_Master_Transmit(&hi2c1, SHT40_I2C_ADDR, &cmd, 1, 100) != HAL_OK) return 1; HAL_Delay(10); if (HAL_I2C_Master_Receive(&hi2c1, SHT40_I2C_ADDR, rxBuf, 6, 100) != HAL_OK) return 2; if (SHT40_CalcCRC8(&rxBuf[0], 2) != rxBuf[2]) return 3; if (SHT40_CalcCRC8(&rxBuf[3], 2) != rxBuf[5]) return 4; rawTemp = (rxBuf[0] << 8) | rxBuf[1]; rawHum = (rxBuf[3] << 8) | rxBuf[4]; >uint8_t SHT40_CalcCRC8(uint8_t *src, uint8_t len) { uint8_t crc = SHT40_CRC8_INIT; for (uint8_t i = 0; i < len; i++) { crc ^= src[i]; for (uint8_t bit = 0; bit < 8; bit++) { if (crc & 0x80) crc = (crc << 1) ^ SHT40_CRC8_POLY; else crc <<= 1; } } return crc; }

这个实现的逻辑是逐字节逐位处理:每读入一个 bit,先判断最高位是否为 1,如果是就移位后异或多项式,否则直接移位。整个过程模拟了多项式除法。注意这里不需要对最终结果再做一次异或,SHT40 的 CRC 规范里没有最后异或这一步,有别于某些 CRC-8 变体。校验时,温度的 2 字节对应第 3 字节,湿度的 2 字节对应第 6 字节,分别校验。

4.4 温湿度换算公式

SHT40 返回的原始值是 16 位无符号整数,范围 0~65535,需要线性映射到实际的温湿度范围。温度公式是 -45 + 175 * raw / 65535,湿度公式是 -6 + 125 * raw / 65535。

我见过有同学把这两条公式记错成 SHT30 的版本。SHT30 和 SHT40 的换算公式确实是一样的,但 SHT35 的高温端上限是 125°C,别混。换算公式看起来简单,但有一个隐含细节:float 运算在 F103 这种没有 FPU 的 Cortex-M3 上会比较慢,单次换算大约消耗几十微秒,对于 30 秒才测量一次的应用完全无所谓。如果你在做一个需要每秒测量上千次的场景,建议把除法换成查表或者定点数运算。

另外,SHT40 在测量条件超限或者内部故障时,NDP(No Data Protect)功能会把原始值设置成特定模式让主机发现数据无效。但我实测发现,单纯依赖 CRC 校验已经能拦下绝大部分异常数据,NDP 更多的意义是在极端环境下保证安全性。

5. 实测数据与三个反复出现的坑

5.1 正常读数下的数据表现

硬件连接完成、驱动调通之后,我用一个室内场景做了 48 小时连续测试,每 10 秒读一次,总共记录了 17280 组数据。室温从晚上的 24.6°C 变化到白天的 27.2°C,湿度在 52%RH 到 68%RH 之间波动。SHT40 的表现很稳定,相邻两次采样之间几乎没有跳变,温度分辨率实测可以达到 0.01°C 级别。

这个稳定性比之前用 DHT22 时好了不少。DHT22 虽然标称精度也不错,但相邻两次采样经常出现 0.3°C 左右的跳变,放 10 秒滑动平均也压不干净。SHT40 在 400kHz I2C 下的读数一致性很好,我怀疑是内部 ADC 的噪声抑制做得更到位,加上数字滤波算法更成熟。

5.2 坑一:地址左移导致 NACK

这个坑几乎是每个从 Arduino 生态转 STM32 HAL 库的人都会踩。Arduino 的 Wire 库在传输时会自动把 7 位地址左移,所以写 0x44 是对的。但 HAL 库不做这个转换,它要求你传入完整的 8 位地址,也就是 0x88。我第一次测试时直接写 0x44,结果 HAL_I2C_Master_Transmit 返回 HAL_ERROR,用逻辑分析仪看波形发现总线上的地址是 0x22(0x44 右移一位),SHT40 根本不响应这个地址。

排查链路是这样的:先用 HAL_I2C_IsDeviceReady 做地址扫描,发现 0x44、0x88 都不对;再把 SCL/SDA 用逻辑分析仪抓下来,对照 I2C 协议逐个 bit 数,最终确认是地址移位问题。写代码前把 HAL 库函数原型仔细看一遍,不要在地址上想当然。

5.3 坑二:测量延时不足导致偶发失败

第二个坑出现在连续测量模式下。最初读完 6 字节后立即开始下一次测量,代码里发完测量命令只延时了 2ms 就去读。高重复性模式标称测量时间是 8.2ms,偶尔传感器会因为内部状态切换多花几百微秒,这时主机的读操作正好撞上从机还在忙,I2C 总线上就会出现异常。

现象非常隐蔽:大部分时间读数正常,但每隔几十次就会出现一次 HAL_I2C_Master_Receive 返回 HAL_TIMEOUT。我一度以为是 FreeRTOS 任务切换导致的问题,后来把测量间隔拉长、延时加大到 10ms,问题再也没有出现过。对于不带时钟拉伸处理的阻塞式调用,延时一定不要贴着标称值走,留出 20%~30% 的余量最稳妥。

5.4 坑三:CRC 连续报错的背后是信号完整性问题

第三个坑是排查时间最长的一次。有一版 PCB 打样回来之后,SHT40 能正常出数,但 CRC 校验失败的次数明显增加,大约每 100 次读操作会有 3~4 次报错。一开始怀疑是传感器个体问题,换了一片故障依旧;又怀疑是代码 CRC 实现有误,用软件模拟 I2C 去读,CRC 又全部正确。

后来用示波器量 SCL 波形,发现上升沿非常缓慢,从低到高花了接近 300ns。问题根源是 PCB 上 SDA 和 SCL 走线从 SHT40 到 STM32 绕了相当长一段,线间电容增大,而我又沿用了之前面包板上的 4.7kΩ 上拉电阻。换成 2.2kΩ 上拉电阻之后,CRC 报错率直接降为 0。

这个经验后来沿用到了所有 I2C 传感器上:如果 CRC 或者通信错误偶发出现,先不要怀疑代码逻辑,用示波器看边沿,用上拉电阻和走线长度做调整,通常比改代码更有效。

6. 工程优化与后续扩展思路

6.1 低功耗轮询策略与测量间隔设计

这个项目的最终形态是电池供电,所以低功耗策略很关键。SHT40 的待机电流只有 0.08µA,而一次高重复性测量的耗电大约是 0.4µA 左右(按 1Hz 采样率折算),这意味着真正费电的不是传感器本身,而是 MCU 的 I2C 外设和唤醒逻辑。

我的做法是:MCU 进入 STOP 模式前,先把 I2C 外设 Deinit 掉,把 PB6/PB7 重新配成模拟输入,彻底断掉内部上拉和时钟;唤醒后先重新初始化 I2C,再做一次测量,完成后再次 Deinit。这样可以把传感器和 I2C 外设在休眠期间的漏电压到最低。实测整机待机电流从没优化前的 3.2mA 降到了 22µA,其中大部分是 LDO 自身的静态电流。

测量间隔上,30 秒一次对于机房环境温湿度监测完全够用。如果你在做一个响应更快的手持设备,可以把 SHT40 的重复性降到中档甚至低档,1.7ms 的测量时间可以支撑较高的采样率。

6.2 多传感器挂载与地址扩展

SHT40 的 ADDR 引脚可以切换 I2C 地址为 0x45,因此一条 I2C 总线上最多可以挂两颗 SHT40。如果你需要采集两个不同位置的温湿度,这是很简单的扩展方案。

多传感器引入的新问题是总线电容增加,上拉电阻可能要按实际情况调整。两颗 SHT40 加上短线缆,总线上拉电阻用 2.2kΩ 依然没问题;如果挂到四颗以上,建议把 I2C 速率降到 100kHz,并且考虑使用 TCA9548A 这类 I2C 多路复用器来隔离分支电容。

6.3 从阻塞到中断 / DMA 的升级路径

目前 SHT40_ReadData 用的是阻塞式 HAL_I2C_Master_Transmit 和 HAL_I2C_Master_Receive。在裸机环境下,这个方案简单可靠。但如果你把驱动移植到 FreeRTOS 任务里,有一个隐患:阻塞等待 I2C 完成会占用 CPU 时间,虽然一次传输只有几百微秒,但在高优先级任务频繁抢占的场景下,还是可能造成低优先级任务的饥饿。

更优雅的升级方案是使用 HAL_I2C_Master_Transmit_IT / HAL_I2C_Master_Receive_IT 中断模式,或者干脆上 DMA。在 CubeMX 里把 I2C 的 DMA 请求打开,然后在代码中监听 HAL_I2C_MasterTxCpltCallback 和 HAL_I2C_MasterRxCpltCallback,通过信号量通知任务数据已经就绪。这样 CPU 在 I2C 传输期间可以去执行其他任务。代价是代码复杂度上了一个台阶,对于这个 30 秒采样一次的项目其实属于过度设计,但如果你的系统吞吐量很大,升级方向是明确的。

另外提醒一点,SHT40 的软复位命令 0xFE 在调试时很好用。如果你的传感器因为总线异常进入了一个奇怪的状态,执行一次软复位往往就能恢复,而不需要断电重启。我把这个命令留了调试接口,在串口助手里可以手动触发,排查硬件问题的时候省了不少事。

我在实际使用中还发现,SHT40 的加热器功能在高湿环境下有奇效。机房某个角落如果发生轻微凝露,温度传感器读数会异常偏高,湿度也会卡在 98%RH 附近下不来。此时可以给传感器内部加热器发一个 200mW 持续 1 秒的加热命令,把凝结的水汽蒸发掉,等传感器冷却 1 分钟后再读,数据就恢复正常了。这个功能在数据手册里介绍得很低调,但在实际工程里确实能救命。

SHT40 这颗传感器整体用下来,我认为它的定位就是"省心、精确、适合工程化"。硬件 I2C 驱动方式只要把时序留足余量、把 CRC 校验加上、把电气连接处理干净,稳定性完全可以信任。不要再被"F1 硬件 I2C 不能用"这种老观念束缚,配合 CubeMX 生成的 HAL 代码,大部分时候比软件模拟 I2C 更省事也更可靠。

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

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

立即咨询