最近做室内环境监测节点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 最大速率是 1MHzSTM32F103 的 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 的默认引脚是 PB6SCL和 PB7SDA。SHT40 的 ADDR 引脚如果悬空或接地7 位 I2C 地址是 0x44如果接 VDD地址变为 0x45这个引脚可以用来在同一总线上挂载两颗传感器。SHT40 引脚功能连接目标1 (VDD)电源输入 1.08V~3.6V3.3V并接 100nF 旁路电容2 (SDA)I2C 数据线PB7接 2.2kΩ 上拉到 3.3V3 (GND)地GND4 (SCL)I2C 时钟线PB6接 2.2kΩ 上拉到 3.3V2.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 外设的时钟源是 APB1APB1 最大 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默认引脚是 PB10SCL和 PB11SDA。选 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 在测量条件超限或者内部故障时NDPNo 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用逻辑分析仪看波形发现总线上的地址是 0x220x44 右移一位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 更省事也更可靠。