ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32软件模拟IIC读取AHT21B温湿度传感器完整实战

2026/10/4 5:32:50 拓冰建站 浏览量
STM32软件模拟IIC读取AHT21B温湿度传感器完整实战 手里正好躺着一颗AHT21B温湿度传感器抽空用STM32F103C8T6做了一个软件模拟IIC的采集方案。这个小项目看着简单真正动手却把IIC时序、寄存器格式、数据拼接、开漏输出这些底层细节全串起来了。项目实测下来读取非常稳定温度和湿度数值都贴合实际环境而且全部代码加起来也就两百来行。如果你正准备用STM32读取AHT21B的数据或者一直想搞明白软件模拟IIC到底怎么写、怎么调这篇内容应该能让你少走不少弯路。我会把代码怎么来的、每个函数为什么这么写、调试时踩过的坑都一并讲清楚。1. 先说说为什么我要用软件模拟IIC1.1 STM32硬件IIC的坑不是玄学在很多嵌入式论坛里关于STM32F103硬件I2C的讨论一直热度很高。我自己早年用标准外设库调F1的硬件I2C确实遇到过读取传感器时卡在事件等待里的情况排查半天发现是状态标志位的处理顺序不对加超时、重写状态机之后才勉强能用。后来换到HAL库到了忙标志BUSY没清干净的时候一样会超时。不是说STM32的I2C外设完全不能用而是它对于新人来说状态机复杂度明显偏高一旦出问题排查成本比软件模拟大得多。这里说的坑一方面是芯片本身的状态机确实复杂另一方面也和很多早期教程、早期固件库的代码质量有关。社区里长期流传着“F1硬件I2C慎用”的说法本质上不是因为硬件坏了而是用起来太容易在细节上翻车。对很多只需要周期性读取一次温湿度、对IIC速率没有要求的场景来说与其跟硬件I2C的状态机较劲不如用软件模拟来得踏实。1.2 软件模拟IIC更适合什么场景软件模拟IIC的核心优势就四个字逻辑透明。起始、停止、发送每一位、读取每一位全是我们自己写的代码来控制GPIO翻转整个时序一清二楚出了问题拿逻辑分析仪一看波形马上能定位是哪一步不对。调试的时候你可以在任意一个函数里打断点这是硬件I2C很难给的体验。还有一个实际好处是引脚自由。STM32硬件I2C1的SCL/SDA引脚是固定的分别是PB6和PB7I2C2则是PB10和PB11。如果这几个引脚被其他功能占用比如接了按键、接了LED硬件I2C就无能为力了。软件模拟只用两个任意GPIO板子上哪个引脚空着就用哪个硬件设计的时候灵活很多。当然软件模拟也有代价——它占用CPU时间IIC时序做得越快CPU空转时间越长但如果你的采集频率是1秒一次这点消耗完全可以忽略。AHT21B自身一次测量就要几十毫秒本来就属于低速传感器软件模拟跑个100kHz的标准模式绰绰有余。所以在这个项目里选软件模拟不是将就而是“匹配需求”的结果。2. AHT21B这颗传感器到底该怎么读2.1 引脚、地址和上电时序AHT21B是奥维Aosong出的一款数字温湿度传感器四脚封装分别是VDD、GND、SDA、SCL。供电范围是1.8V到3.6V我直接接3.3V和STM32同一个电源域这是最稳妥的接法。需要注意传感器不要接5V否则长期运行很容易损坏。它的IIC从机地址是0x38注意这是7位地址。在IIC总线上发送时需要左移一位变成8位写地址是0x70读地址是0x71。很多新手卡在地址这一步老想着IIC地址是不是0x38直接发出去结果从机根本不应答实际上要先左移再拼上读写位。上电时序有个容易被忽略的点芯片上电后需要至少100ms的稳定时间之后才能正常响应命令。如果你在系统启动后立刻去读传感器很可能会收到NACK或者读到全零数据。这个延时不能省我一般习惯上电延时100ms之后再发软复位命令0xBA再等20ms然后才开始正式测量。还有上拉电阻的问题。IIC总线要求SCL和SDA都必须有上拉电阻一般用4.7kΩ比较常见。STM32内部虽然有上拉但拉电流能力有限而且是弱上拉高速通信时波形会很难看所以外部最好还是各接一个4.7kΩ电阻到3.3V。面包板测试时如果实在没有贴片电阻飞两根杜邦线到开发板的3.3V也可以应急只要线径别太长。2.2 一次完整的测量命令流AHT21B的测量流程比DHT11那种单总线协议清晰得多。整条流程就是写测量命令等待测量完成读取数据。写测量命令的格式是固定的起始信号、器件写地址0x70、发送0xAC、发送0x33、发送0x00、停止信号。这三字节参数不是随便写的0xAC是测量命令0x33和0x00是命令附带的配置参数数据手册里明确要求一起发送少发或多发都可能测量异常。我在网上看到过有些精简例程只发一个0xAC就停了测出来的数据偶尔会飘多半就是参数没给全。发送完命令之后芯片开始内部测量。典型测量时间大约80ms我一般留出100ms以上的延时。等待时间不够直接去读的话芯片在DA引脚上只会回NACK这时你就得重试。更严谨的做法是读取状态寄存器里的busy位判断测量是否完成不过对这个项目来说延时100ms已经足够可靠代码也更简单。触发测量之后不要频繁反复触发建议每次读完之后等1秒左右再触发下一次这样传感器读数才能稳定反映环境变化。读取数据时主机先发起始信号再发器件读地址0x71然后连续读6个字节。前5个字节是有效数据第6个字节是CRC校验码。读前5个字节时主机要回ACK读第6个字节时主机回NACK然后发停止信号。这个ACK/NACK的顺序如果搞反了传感器下一轮通信很可能异常。2.3 6字节数据怎么变成温度和湿度读取回来的6个字节前5字节的排列规律是前三个字节是湿度原始20位数据后两个字节加第三个字节的低4位是温度原始20位数据。具体拆解如下。第一个字节的高4位里有bit7是busy状态位、bit6是CRC使能位低4位是湿度数据的高4位第二字节是湿度数据的中间8位第三字节是湿度数据的低8位。温度的20位也是类似第四字节的低4位是温度的高4位第五字节是中间8位等等。用代码表达就是uint32_t raw_h ((uint32_t)(buf[0] 0x0F) 16) | ((uint32_t)buf[1] 8) | (uint32_t)buf[2]; uint32_t raw_t ((uint32_t)(buf[3] 0x0F) 16) | ((uint32_t)buf[4] 8) | (uint32_t)buf[5];这里的细节buf[3]必须和0x0F做与运算因为它的高4位实际上是状态位和一些保留位不一定是0不屏蔽掉会污染温度数据。有些例程里直接用buf[3]整字节左移那是因为恰好高位全是0结果碰巧对了但不够严谨。两个20位的原始值换算出物理量的公式如下温度T raw_t ÷ 2^20 × 200 − 50单位℃ 湿度RH raw_h ÷ 2^20 × 100单位%RH比如读取到的温度原始值换算出来是25.3℃湿度原始值换算出来是56.8%RH这就算正常。如果数据明显不在量程内比如温度算出来是-100℃或者湿度超过100%基本可以断定是数据拼接出错或者IIC读取时序有问题。AHT21B和常见DHT11的差异很明显放个表感受一下| 对比项 | AHT21B | DHT11 | | 接口 | IIC | 单总线 | | 温度分辨率 | 0.01℃ | 1℃ | | 湿度精度 | ±2%RH | ±5%RH | | 温度量程 | -40℃~80℃ | 0℃~50℃ | | 测量时间 | 约80ms | 约250ms | | 供电范围 | 1.8V~3.6V | 3.3V~5V | | 数据可靠性 | 20位原码CRC可选 | 8位校验和 |从这个表能看出来AHT21B不仅精度和分辨率更高IIC接口在读取速度和多设备扩展上也比单总线舒服得多。这也是为什么很多新项目宁愿选AHT21B而不选老式的DHT11。3. 软件IIC底层GPIO配置与关键时序编写3.1 SDA为什么必须用开漏输出很多新手在配置GPIO时会习惯性配成推挽输出这在模拟SPI、模拟UART里问题不大但在模拟IIC里是个隐患。IIC是双向总线SDA既要从主机输出数据也要从机向主机回数据如果主机把SDA配成推挽输出并置高电平从机想拉低SDA时根本拉不动电气上就冲突了。正确做法是把SCL和SDA都配置成开漏输出并外部接上拉电阻。开漏输出的特点是输出0时引脚被强下拉输出1时引脚呈高阻态电平由外部上拉电阻决定。这样一来主机释放SDA就是往输出寄存器写1此时从机能自由地把SDA拉低主机检测引脚电平就能读到从机数据。这就是IIC能实现双向通信的物理基础。还有一个小技巧SDA配置成开漏输出后读取引脚电平时直接用GPIO_ReadInputDataBit读输入数据寄存器就行不用在读写之间来回切换输入输出模式。因为即使引脚配置成输出模式输入数据寄存器依然反映引脚的实时电平。这一点很多教程没讲透导致很多人把SDA切换成输入模式、读完了再切回输出模式代码臃肿不说切换瞬间还容易产生毛刺。实际接线时串一个330Ω左右的限流电阻再连传感器也可以但这个项目里直接连接并靠外部上拉就足够了。面包板飞线测试时线尽量短稳定性和抗干扰能力都会更好。3.2 起始、停止、应答函数的实现细节IIC协议里最重要的三个时序是起始信号、停止信号和字节发送。起始信号的定义是SCL为高电平期间SDA由高电平跳变到低电平。停止信号则是SCL为高电平期间SDA由低电平跳变到高电平。理解这个定义后代码就很简单。void IIC_Start(void) { SDA_H(); SCL_H(); IIC_Delay(); SDA_L(); // SCL高期间SDA拉低产生起始条件 IIC_Delay(); SCL_L(); } void IIC_Stop(void) { SDA_L(); SCL_H(); IIC_Delay(); SDA_H(); // SCL高期间SDA拉高产生停止条件 IIC_Delay(); }字节发送的要点是数据位必须在SCL低电平期间准备好然后在SCL高电平期间保持稳定从机在SCL高电平期间采样。IIC是MSB先行也就是先发最高位。发送完8个数据位之后的第9个时钟周期是应答位主机释放SDA然后拉高SCL读取SDA电平低电平代表ACK高电平代表NACK。void IIC_Send_Byte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); dat 1; IIC_Delay(); SCL_H(); IIC_Delay(); SCL_L(); } }接收字节的思路刚好反过来主机在每个SCL高电平期间读SDA引脚电平组装到一个临时变量里。读完8位后主机通过ACK或NACK来通知从机是否继续发送。这里最容易被忽略的是最后一字节必须回NACK让从机知道数据已经收完否则从机可能一直占用总线。3.3 微秒延时的选择与避坑软件模拟IIC的时钟速度完全靠延时函数控制。从原理上说SCL高电平持续时间至少要满足传感器最小要求一般IIC标准模式100kHz快速模式400kHz对AHT21B这种20位数据的低速传感器跑100kHz完全够用。我实际写代码时用的延时大约在1us到2us整个时钟周期大概4us到8us折合频率在100kHz以上这样读一次数据非常快。最省事的延时函数是for循环空转但不同主频下延时时长完全不同。72MHz主频和8MHz主频下的同一个空循环实际延时差好几倍。如果你用逻辑分析仪看波形发现SCL频率高到异常说明延时太短。更好的方案是用DWTData Watchpoint and Trace外设做微秒延时不依赖SysTick中断精度高也不容易卡死。网上很多delay_ms函数是用SysTick实现的如果工程里没有初始化SysTick或者没开SysTick中断delay_ms调用后就会一直卡住。我见过不少新手在这个问题上耗了很久现象就是程序运行到延时函数后直接跑飞。DWT方案不需要中断初始化几行代码就能用建议直接拷贝到工程里备用。这段实现网上资料很多但是核心思路就是通过DWT的CYCCNT寄存器做周期计数主频已知就能精确换算成微秒。4. 可直接抄的完整驱动代码4.1 底层IIC实现我把完整的底层代码放在这里用STM32标准外设库编写芯片型号是STM32F103C8T6引脚选择PB6作为SCL、PB7作为SDA。GPIO初始化采用了开漏输出模式SDA读取直接用IDR读写之间不需要切换方向。// iic_soft.h #ifndef __IIC_SOFT_H #define __IIC_SOFT_H #include stm32f10x.h #define IIC_SCL_GPIO_PORT GPIOB #define IIC_SCL_GPIO_PIN GPIO_Pin_6 #define IIC_SDA_GPIO_PORT GPIOB #define IIC_SDA_GPIO_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(IIC_SCL_GPIO_PORT, IIC_SCL_GPIO_PIN) #define SCL_L() GPIO_ResetBits(IIC_SCL_GPIO_PORT, IIC_SCL_GPIO_PIN) #define SDA_H() GPIO_SetBits(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN) #define SDA_L() GPIO_ResetBits(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN) #define SDA_READ() GPIO_ReadInputDataBit(IIC_SDA_GPIO_PORT, IIC_SDA_GPIO_PIN) void IIC_GPIO_Init(void); void IIC_Start(void); void IIC_Stop(void); void IIC_Send_Byte(uint8_t dat); uint8_t IIC_Recv_Byte(uint8_t ack); uint8_t IIC_Wait_Ack(void); #endif// iic_soft.c #include iic_soft.h #include delay.h void IIC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin IIC_SCL_GPIO_PIN | IIC_SDA_GPIO_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); SCL_H(); SDA_H(); } void IIC_Delay(void) { // 约1~2us 72MHz实际根据主频调整 uint8_t i 6; while (i--); } void IIC_Start(void) { SDA_H(); SCL_H(); IIC_Delay(); SDA_L(); IIC_Delay(); SCL_L(); } void IIC_Stop(void) { SDA_L(); SCL_H(); IIC_Delay(); SDA_H(); IIC_Delay(); } void IIC_Send_Byte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); dat 1; IIC_Delay(); SCL_H(); IIC_Delay(); SCL_L(); } } uint8_t IIC_Recv_Byte(uint8_t ack) { uint8_t i, dat 0; for (i 0; i 8; i) { dat 1; SCL_H(); IIC_Delay(); if (SDA_READ()) dat | 0x01; SCL_L(); IIC_Delay(); } if (ack) SDA_L(); else SDA_H(); IIC_Delay(); SCL_H(); IIC_Delay(); SCL_L(); SDA_H(); IIC_Delay(); return dat; } uint8_t IIC_Wait_Ack(void) { uint8_t timeout 255; SDA_H(); SCL_H(); IIC_Delay(); while (SDA_READ()) { if (--timeout 0) { SCL_L(); return 1; } } SCL_L(); return 0; }4.2 AHT21B驱动实现传感器驱动分为初始化、触发测量、读取数据三个部分。我在初始化里加入软复位保证传感器处于已知状态触发测量和读取数据放在一个函数里这样主函数调用起来最清爽。同时保留CRC校验函数作为可选功能方便想验证数据完整性的人直接抄。// aht21b.h #ifndef __AHT21B_H #define __AHT21B_H #include stm32f10x.h #define AHT21B_ADDRESS_W 0x70 #define AHT21B_ADDRESS_R 0x71 #define AHT21B_CMD_INIT 0xBE #define AHT21B_CMD_RST 0xBA #define AHT21B_CMD_MEASURE 0xAC #define AHT21B_MEASURE_WAIT_US 100000 void AHT21B_Init(void); uint8_t AHT21B_Read_Data(float *humidity, float *temperature); #endif// aht21b.c #include aht21b.h #include iic_soft.h #include delay.h static uint8_t AHT21B_CalCRC(const uint8_t *pData, uint8_t len) { uint8_t crc 0xFF; uint8_t i, j; for (i 0; i len; i) { crc ^ pData[i]; for (j 0; j 8; j) { if (crc 0x80) crc (uint8_t)((crc 1) ^ 0x31); else crc 1; } } return crc; } void AHT21B_Init(void) { IIC_GPIO_Init(); delay_ms(100); IIC_Start(); IIC_Send_Byte(AHT21B_ADDRESS_W); IIC_Wait_Ack(); IIC_Send_Byte(AHT21B_CMD_RST); IIC_Wait_Ack(); IIC_Stop(); delay_ms(20); } uint8_t AHT21B_Read_Data(float *humidity, float *temperature) { uint8_t buf[6]; uint8_t i; uint32_t raw_h, raw_t; uint8_t err 0; IIC_Start(); IIC_Send_Byte(AHT21B_ADDRESS_W); err | IIC_Wait_Ack(); IIC_Send_Byte(AHT21B_CMD_MEASURE); err | IIC_Wait_Ack(); IIC_Send_Byte(0x33); err | IIC_Wait_Ack(); IIC_Send_Byte(0x00); err | IIC_Wait_Ack(); IIC_Stop(); if (err) return 1; delay_ms(100); IIC_Start(); IIC_Send_Byte(AHT21B_ADDRESS_R); err | IIC_Wait_Ack(); if (err) return 1; for (i 0; i 6; i) { if (i 5) buf[i] IIC_Recv_Byte(0); else buf[i] IIC_Recv_Byte(1); } IIC_Stop(); // 可选的CRC校验屏蔽后可不检查第6字节 if (AHT21B_CalCRC(buf, 5) ! buf[5]) return 2; if (buf[0] 0x80) return 3; raw_h ((uint32_t)(buf[0] 0x0F) 16) | ((uint32_t)buf[1] 8) | (uint32_t)buf[2]; raw_t ((uint32_t)(buf[3] 0x0F) 16) | ((uint32_t)buf[4] 8) | (uint32_t)buf[5]; *humidity (float)raw_h / 1048576.0f * 100.0f; *temperature (float)raw_t / 1048576.0f * 200.0f - 50.0f; return 0; }这里几个返回值需要说明0表示成功1表示IIC通信时从机没有应答2表示CRC校验失败3表示读取时传感器仍在忙。主函数里拿到非零返回值时上一轮数据可以不更新继续等下一轮。有一段代码我想提醒一下AHT21B_Read_Data里的delay_ms(100)是简单粗暴的做法因为芯片测量需要时间。如果你的系统里对时序有更高要求可以在发完测量命令后反复读取busy位等busy位清零再去读数据这样测量周期能压缩到最短。但代价是要多写一个读状态寄存器的函数考虑到这个项目的应用场景是每秒读一次100ms延时根本不影响体验。4.3 主函数调用与串口输出主函数逻辑很简单就是初始化、循环读、串口打印。我用的串口是USART1波特率115200配合ST-Link自带的虚拟串口可以直接在调试器上看到输出。#include stm32f10x.h #include delay.h #include usart.h #include aht21b.h int main(void) { float hum 0.0f; float temp 0.0f; uint8_t ret 0; delay_init(); USART1_Init(115200); AHT21B_Init(); while (1) { ret AHT21B_Read_Data(hum, temp); if (ret 0) { printf(Temp: %.2f C, Hum: %.2f %%RH\r\n, temp, hum); } else { printf(AHT21B read error: %d\r\n, ret); } delay_ms(1000); } }注意printf需要重定向到串口也就是实现fputc函数把字符直接通过USART1发送这个操作很多串口例程里都有。如果不想用printf也可以直接把hum和temp拆成整型、小数字节用自写函数发字符串。串口输出只是验证手段实际项目里数据可以走显示屏、蓝牙、无线模块或者CAN总线逻辑都一样。5. 实测现象、数据校验与常见问题排查5.1 我的实测记录和校验方法代码烧录后我第一件事是看串口输出数据是否稳定。某次实测数据大致如下表室温环境下采集了十几组数据温度和湿度波动都很小。| 测试场景 | 温度 | 湿度 | | 桌面静置 | 25.3℃ | 58.2%RH | | 用手心握住传感器2分钟 | 32.1℃ | 66.7%RH | | 对传感器哈气 | 27.6℃ | 87.3%RH | | 静置恢复2分钟后 | 25.5℃ | 58.7%RH |判断传感器读数是否合理我有两个方法。第一个是变化趋势对照法用手心握住传感器温度应该明显上升对着传感器哈气湿度会大幅上升后缓慢回落。如果这些变化都符合预期说明数据链路是通的。第二个是和参考设备对比法把AHT21B和空调面板温度、或者和另一个已知准确的温湿度计放在一起数据偏差应该在传感器标称精度范围内。如果读数毫无变化甚至一直是同一个值优先怀疑数据拼接错误而不是传感器坏了。用调试器看6个原始字节用计算器按公式手动算一遍往往很快能找到问题点。5.2 常见故障现象排查速查表我把调试过程中遇到过的、以及帮别人排查过的问题整理成了一个表格按现象分门别类排查时直接对照就行。| 现象 | 可能原因 | 解决办法 | | 读不到设备全部NACK | SDA/SCL接线错误、上拉电阻缺失、供电异常 | 检查接线确认VDD为3.3V补4.7kΩ上拉电阻 | | 数据全是0xFF | SDA一直处于高电平主机没读到从机拉低的信号 | 查SDA是否接对查从机是否上电用逻辑分析仪看完整波形 | | 数据全是0x00或固定几个值 | 寄存器拼接错误、IIC多读或少读一个字节 | 确认buf数组长度是6确认第6字节NACK | | 温度偏高且波动大 | 传感器贴太近主板/发热元件 | 让传感器远离LDO、主控芯片必要时用软排线外接 | | 读取一次后第二次卡死 | IIC总线上一次没有正常停止 | 检查停止信号是否完整确认每字节后ACK/NACK顺序 | | 程序运行到delay_ms卡住 | SysTick未初始化或中断未开启 | 改用DWT延时或正确初始化SysTick中断 | | 湿度偶尔跳到100%RH | 传感器受到水汽直接接触或焊剂残留 | 清洗传感器避免近距离哈气测量前保证外壳干燥 |5.3 从踩坑里总结的几点体会这个项目做完我最大的感受是模拟IIC相比硬件I2C虽然要多写一些底层代码但调试体验真的好太多。数据不对的时候逻辑分析仪一挂上去就能看到是哪个时序出了问题不用像硬件I2C那样去猜状态寄存器的值。我建议每个做嵌入式的人手里都备一台便宜的逻辑分析机几十块钱关键时刻能省几个小时。还有一个体会是传感器采集这种任务数据校验比一开始想象的重要。很多情况下数据看起来是对的比如温度在25℃左右湿度在50%左右但如果CRC校验没过说明通信链路已经有偶发错误了。加了CRC之后至少能保证你看到的数据是完整的不会因为一次总线毛刺而把错误数据当真值用。AHT21B的CRC校验多项式是0x31初始值是0xFF代码我已经放在上面直接用就可以。最后给个扩展建议如果你现在用的是STM32AHT21B驱动代码换到别的芯片上也很方便只需要把IIC底层改成对应平台的GPIO操作接口上层读温度和湿度的逻辑完全不用动。如果数据要上报云端或者接入Home Assistant也可以把串口输出改成ESP8266、ESP32的串口透传这样就能实现远程温湿度监测了。