简介这套源码包基于STM32F103C8T6实现HTU31D温湿度传感器的驱动与读取面向嵌入式初学者、单片机爱好者及环境监测项目开发者解决传感器通信与数据解析的入门问题。工程内置完整I²C主机读写驱动覆盖起始信号、地址发送、数据读取与停止信号等时序并针对HTU31D的命令序列进行寄存器配置同时包含USART1串口输出模块可直接将解码后的温湿度值打印至串口终端。包内共215个文件以c源码与h头文件为主辅以uvprojx工程文件、hex烧录固件、axf调试映像、map映射表及配置文档压缩包大小5.96MB目录涵盖工程源码、库文件与硬件资料结构清晰便于定位。目前已有1164人学习浏览。通过分析该工程可掌握STM32F103的I²C与串口外设初始化流程、标准外设库调用方式并参考其温湿度数据帧的拼接、校验与浮点转换逻辑对开发类似环境监测节点具有直接借鉴价值。 这块板子是我给一个小型环境监测盒做的核心传感器方案主控用了ST的STM32F103C8T6传感器选了TE的HTU31D温湿度传感器。一开始项目用的是DHT11精度和长期稳定性实在顶不住温度偏差在夏天能到3度湿度更是飘得离谱。换到HTU31D之后I2C接口直接挂到F103的I2C1上驱动代码两天就调通了整套系统运行了半年多数据一致性比我预期好很多。这篇就把整个驱动从零实现的过程、关键命令和踩过的坑都摊开来讲适合正打算把DHT11升级成数字I2C温湿度传感器、或者第一次在STM32F103上挂HTU31D的朋友。这套方案能做的事情其实不少室内环境监测、恒温恒湿柜、农业大棚、冷链运输记录仪核心都是“F103 I2C温湿度传感器”这个组合。你不需要很复杂的硬件知识只要会点STM32基础照着这篇文章就能把数据稳定读出来。1. 选型背景HTU31D强在哪F103还够不够用1.1 HTU31D的参数解读HTU31D是TE Connectivity推出的数字温湿度传感器DFN-8封装尺寸非常小I2C接口7位从机地址默认0x40。拿到一颗这样的传感器先别急着写代码把手册里的关键参数圈出来湿度精度±2%RH典型值这个精度在国产数字湿度传感器里算是第一梯队。温度精度±0.2°C典型值比DHT11的±2°C整整高出一个数量级。分辨率14位湿度、温度都是14位也就是说湿度从0到100%RH被切成16383个台阶。温度量程-40~125°C湿度量程0~100%RH覆盖日常环境监测绰绰有余。供电范围1.8~3.6V工程上直接给3.3V最省事。DHT11和HTU31D放在一起差距非常直观参数DHT11HTU31D接口单总线时序敏感I2C标准协议湿度精度±5%RH±2%RH温度精度±2°C±0.2°C分辨率1%RH / 1°C14位采样建议1Hz可更高实际1Hz足够校准出厂粗校准出厂校准通常免标定单总线这种协议最大的问题是时序窗口太窄稍微有点中断延迟或者线长变化读出来的数据就变成无效位。而I2C是标准同步协议时钟由主机控制时序裕量大得多这也是我在项目里坚持换掉DHT11的根本原因。1.2 F103为什么还值得选很多新入行的朋友觉得STM32F103是“上古芯片”但在这个场景下它反而非常合适。芯片价格便宜、库存量大CubeMX图形化配置几分钟就能生成工程标准外设库、HAL库、LL库的资料一抓一大把。驱动一个I2C温湿度传感器F103的性能富余量非常大而且待机功耗能做到很低适合电池供电的物联网节点。选型的核心原则是够用、稳定、好买。项目不是越贵越好而是把每一分成本花在该花的地方。传感器决定数据质量MCU决定系统成本F103HTU31D这个组合在两者之间找到了一个很好的平衡点。如果你手头正好有F103最小系统板或者一块以F103为核心的开发板拿来做这个驱动验证非常合适。2. HTU31D的I2C通信机制地址、命令与数据换算2.1 从机地址与两条核心命令I2C设备连接后第一步是确认地址。HTU31D的7位地址默认是0x40。在STM32 HAL库中传参时要左移一位变成0x80写地址或0x81读地址因为HAL库的I2C地址参数是8位格式。如果你买的模块上有ADDR选择焊盘注意可能被配置成其他地址读不到的时候先量一下别急着怀疑接线。驱动HTU31D最核心的命令其实就两条0x00启动一次温湿度测量温度、湿度同时采集。0xFE软复位让传感器恢复到上电初始状态。手册里可能还有用户寄存器配置、加热器控制这类功能但日常环境监测用不到先把这两条命令用透后面按需扩充即可。量产的板子上我用0x00一直很稳定你手里的芯片批次如果不同以最新手册为准。2.2 原始数据与物理量的换算测量命令发出后等待转换完成然后从从机读出6字节数据。我板子上的实测顺序是湿度高字节、湿度低字节、湿度CRC、温度高字节、温度低字节、温度CRC。如果你手册上拿到的是温度在前、湿度在后把解析顺序对调一下就行这个务必以你自己手上的手册为准。14位有效数据高字节左移8位后或上低字节得到原始码值。换算公式温度T(°C) raw_T × 165.0 / 16383.0 - 40.0湿度RH(%RH) raw_RH × 100.0 / 16383.0这里16383就是2的14次方减1对应14位ADC的最大码值。温度范围跨度为165度从-40到125度所以先把原始码值按比例映射到0~165的区间再整体减去40。湿度更直接0到100%RH线性映射到0~16383按比例乘100即可。很多人在这一步容易翻车直接用raw_T除以65535去算因为习惯了16位ADC的满量程。HTU31D是14位分辨率高位字节里低2位是无效的必须用16383而不是65535否则温度会整体偏低湿度也不对。这个问题我在帮同事查代码时遇到过不止一次。2.3 CRC-8校验的重要性I2C传输在强电干扰环境里可能出错HTU31D为每个测量值附带1字节CRC-8校验。多项式是0x31初始值是0x00。刚开始做的时候我嫌麻烦觉得工地环境没那么恶劣结果在继电器吸合瞬间读出来的湿度偶尔会莫名跳一下加了三串CRC校验后才定位到是I2C数据被干扰。CRC校验代码很固定逐位计算的版本就够用uint8_t HTU31D_CRC8(uint8_t *data, uint8_t len) { uint8_t crc 0x00; for (uint8_t i 0; i len; i) { crc ^ data[i]; for (uint8_t bit 0; bit 8; bit) { if (crc 0x80) { crc (crc 1) ^ 0x31; } else { crc 1; } } } return crc; }把温度或者湿度的两个数据字节喂进去得到的结果和第三个字节比较一致就说明这次传输没问题。有了这层校验后续做工业现场的设备时心里踏实很多。3. 驱动代码从零实现HAL库实战3.1 驱动结构体设计驱动文件我习惯分成htu31d.h和htu31d.c两个文件。用结构体把I2C句柄、温湿度数据、CRC错误计数封装到一起调用起来非常顺手typedef struct { I2C_HandleTypeDef *hi2c; float temperature; float humidity; uint8_t crc_error_cnt; } HTU31D_HandleTypeDef;I2C句柄用指针而不是直接复制一份这样多个传感器共用同一个I2C总线时结构体里存的只是指针不浪费内存。F103的RAM本来就不大能省一点是一点。3.2 初始化与软复位初始化函数里先执软复位目的是把传感器恢复到确定状态。上电瞬间如果电源抖动厉害传感器可能处于异常状态软复位能把它拉回来。uint8_t HTU31D_Init(HTU31D_HandleTypeDef *dev, I2C_HandleTypeDef *hi2c) { dev-hi2c hi2c; dev-crc_error_cnt 0; if (HTU31D_Reset(dev) ! 0) { return 1; } HAL_Delay(2); // 软复位后至少要等1.5ms给2ms更保险 return 0; } uint8_t HTU31D_Reset(HTU31D_HandleTypeDef *dev) { uint8_t cmd 0xFE; return HAL_I2C_Master_Transmit(dev-hi2c, 0x80, cmd, 1, 100); }HAL_I2C_Master_Transmit的最后一个参数是超时时间单位是毫秒。100ms超时足够判断总线是否卡死如果连这个都超时说明接线或者地址有问题。3.3 一次完整测量流程核心函数是一次测量并读出温湿度我把读数据函数返回不同的错误码方便上层判断uint8_t HTU31D_ReadData(HTU31D_HandleTypeDef *dev) { uint8_t cmd 0x00; uint8_t buf[6]; uint16_t rh_code 0, t_code 0; uint8_t rh_raw[2], t_raw[2]; // 1. 发出测量命令 if (HAL_I2C_Master_Transmit(dev-hi2c, 0x80, cmd, 1, 100) ! HAL_OK) { return 1; } // 2. 等待转换完成第一版可以先给10ms再逐步往下压 HAL_Delay(10); // 3. 读取6字节结果 if (HAL_I2C_Master_Receive(dev-hi2c, 0x81, buf, 6, 100) ! HAL_OK) { return 2; } // 4. 按湿度在前、温度在后的顺序解析以手册为准 rh_raw[0] buf[0]; rh_raw[1] buf[1]; t_raw[0] buf[3]; t_raw[1] buf[4]; // 5. CRC校验 if (HTU31D_CRC8(rh_raw, 2) ! buf[2]) { dev-crc_error_cnt; return 3; } if (HTU31D_CRC8(t_raw, 2) ! buf[5]) { dev-crc_error_cnt; return 4; } // 6. 合成14位码值并换算 rh_code (buf[0] 8) | buf[1]; t_code (buf[3] 8) | buf[4]; dev-humidity (float)rh_code * 100.0f / 16383.0f; dev-temperature (float)t_code * 165.0f / 16383.0f - 40.0f; return 0; }关于延时这步多说一句HTU31D的转换时间在不同供电电压、不同温度下会有点差异第一版驱动别追求极限性能先来个10ms保证每次都能读到新数据。等系统跑稳了再根据实测压缩延时甚至可以用状态寄存器查询忙标志来替代固定延时。3.4 主函数调用示例在main函数里初始化I2C外设后调用HTU31D_Init然后主循环每秒钟读一次HTU31D_HandleTypeDef htu31d; if (HTU31D_Init(htu31d, hi2c1) ! 0) { printf(HTU31D init failed\r\n); } while (1) { if (HTU31D_ReadData(htu31d) 0) { printf(T%.2f C, RH%.2f %%\r\n, htu31d.temperature, htu31d.humidity); } else { printf(HTU31D read error\r\n); } HAL_Delay(1000); }printf重定向到串口用串口助手直接看数据。跑通这一步你的F103就已经成功驱动HTU31D了。如果你还在用标准外设库也别慌寄存器字段和HAL库完全一致只是把HAL_I2C_Master_Transmit换成标准库里I2C_GenerateSTART、I2C_SendData那一套流程而已。4. 硬件接线与F103实测中的坑4.1 接线和上拉电阻HTU31D接F103最基本的四根线HTU31D引脚STM32F103引脚说明VIN3.3V供电传感器典型工作电压GNDGND共地SCLPB6I2C1时钟线SDAPB7I2C1数据线I2C总线需要上拉电阻这是协议本身的要求。一般用4.7kΩ到3.3V如果走线短、速率低10kΩ也能用。如果你买的是现成的HTU31D模块模块上通常已经带上拉电阻和滤波电容直接用杜邦线连接即可。如果是自绘PCB裸焊芯片DFN-8封装建议用转接板烙铁温度不要超过350°C单个引脚焊接时间控制在3秒内不然容易把焊盘烫坏。4.2 F103硬件I2C的经典坑F103的硬件I2C在标准外设库时代有个著名的坑总线忙标志BUSY在通信异常之后会一直置1导致程序死等。HAL库把底层封装好了但偶发的NACK和超时仍然存在尤其是电源纹波大或者干扰强的时候。我的实战经验有三条所有HAL I2C调用都必须检查返回值不要裸奔返回值不是HAL_OK就按异常处理。通信异常时先尝试软复位传感器再重新初始化I2C外设很多问题到这里就能解决。如果还是失败手动释放总线把SCL翻转9次让从机释放SDA。这个操作相当于“教会”从机跳出异常状态。void I2C_Recovery(I2C_HandleTypeDef *hi2c) { // 先把I2C外设关掉让PB6/PB7变成普通GPIO HAL_I2C_DeInit(hi2c); GPIO_InitTypeDef gpio {0}; gpio.Pin GPIO_PIN_6 | GPIO_PIN_7; gpio.Mode GPIO_MODE_OUTPUT_OD; gpio.Pull GPIO_NOPULL; gpio.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOB, gpio); for (int i 0; i 9; i) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); delay_us(10); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); delay_us(10); } // 之后重新初始化I2C即可 }这段代码在真机上验证过能挽救大部分“莫名其妙I2C卡死”的场景。4.3 什么时候改用软件模拟I2C如果你的设备旁边有电机、继电器这类强干扰源且走线避不开硬件I2C被拉死的概率会明显上升。这种情况我建议直接上软件模拟I2C。用GPIO电平翻转实现I2C时序对时序容忍度高调试也直观出问题的时候用示波器看GPIO波形就能定位。模拟I2C的核心思路是SCL和SDA都配置成开漏输出配合外部上拉电阻读SDA时把引脚切到输入模式。起始条件、停止条件、ACK应答这些时序严格按I2C协议来。写一个delay_us的微秒延时函数剩下的事情就是按位收发。void I2C_Soft_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(5); SDA_LOW(); delay_us(5); SCL_LOW(); } uint8_t I2C_Soft_WriteByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x80) SDA_HIGH(); else SDA_LOW(); data 1; delay_us(2); SCL_HIGH(); delay_us(2); SCL_LOW(); delay_us(2); } // 释放SDA准备接收从机ACK SDA_HIGH(); SCL_HIGH(); delay_us(2); uint8_t nack READ_SDA(); SCL_LOW(); return nack; }软件模拟I2C的缺点是占用CPU但对每秒读一次温湿度的场景来说完全不是问题。5. 数据稳定性和校准经验5.1 刚上电的数据漂移与滤波HTU31D上电后前几十秒的数据可能会出现缓慢漂移这不是芯片坏了而是湿敏电容需要一点时间从运输存储状态恢复到正常状态。我习惯在固件里做“上电预热”逻辑开机后前30秒的数据只采集不上报等稳定后再正式使用。日常读取时连续采集5次取平均值或者取5次结果的中位数能有效滤掉偶发的尖峰毛刺。中位数滤波尤其好用因为平均值会被极端值拉偏中位数不会。5次采集间隔100ms整个滤波周期500ms对温湿度这种慢变量完全不影响实时性。5.2 CRC错误增多怎么排查如果CRC错误计数持续增长优先检查三件事检查I2C线长。传感器通过排线和主板连接且排线超过20cm时把I2C时钟降到100kHz标准模式不要跑400kHz快速模式。检查上拉电阻阻值。线缆电容变大的时候4.7kΩ可能太弱换成2.2kΩ会让信号边沿更陡但也不能太小否则功耗和灌电流会超标。检查供电。传感器供电不要和继电器、电机共用一个LDO最好单独用一路低噪声LDO并在VIN引脚附近放0.1uF和10uF两级滤波电容。有一次CRC错误飙升排查到最后是传感器旁边的一颗DC-DC电感辐射干扰把电感换成屏蔽电感后问题消失。这类问题用示波器看I2C波形上的毛刺会非常清晰。5.3 饱和盐溶液校准法HTU31D出厂校准做得不错但批量板子之间总有个体差异。如果项目对湿度精度要求高可以用饱和盐溶液做校准。这个方法的原理是特定盐的饱和溶液在密闭空间里能维持一个固定的相对湿度。以氯化钠食盐为例20°C时饱和氯化钠溶液上方的相对湿度约为75.3%RH。把传感器和一杯饱和氯化钠溶液放进密封盒等30分钟以上记录传感器的湿度读数和75.3%做差这个差值就是偏移量在固件里做补偿即可。温度校准可以用冰水混合物做0°C点用室温水银温度计做室温点做两点线性修正。校准的时候要注意密封盒体积不能太小溶液表面积要足够大否则湿度平衡会很慢。我一般用那种装零食的密封罐盐溶液用个小碟子盛着效果很好。6. 扩展低功耗与FreeRTOS场景下的驱动改造6.1 STOP停机模式下的传感器处理环境监测项目很多是电池供电F103进入STOP模式后整机待机电流可以做到很低但传感器本身还在耗电。要让功耗再降一个台阶最彻底的办法是在传感器供电回路里加一颗PMOS管MCU睡眠时把PMOS关断传感器完全断电。唤醒后需要重新初始化传感器并且要注意一点HTU31D重新上电后要等10ms左右再发命令否则第一个请求很可能被漏掉。我的做法是在HTU31D_Init函数开头固定HAL_Delay(10)反正就10ms用户感知不到。这种方式比频繁软复位靠谱因为冷启动状态比任何软件复位都干净。6.2 FreeRTOS环境下加I2C互斥锁如果系统里还有EEPROM、OLED之类的I2C设备HTU31D驱动就得考虑总线竞争问题。FreeRTOS下最简单的做法是给I2C总线加一把互斥锁p a hrefhttps://download.csdn.net/download/ywf147/16129271 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p