
上个月做一个小项目需要在一个STM32F103C8T6上同时挂三颗AHT21B温湿度传感器做多点采集。板子上一路硬件I2C被另一个外设占了剩下两路传感器得另想办法。我本可以直接切换到复用引脚或者换一颗带更多I2C外设的芯片但当时手头器件就这么定死了于是干脆把软件模拟IIC的方案拉了出来。调完发现这玩意儿不仅解决了引脚冲突反而让我对IIC时序的理解比之前用硬件外设时清晰得多。这篇文章就把我软件模拟IIC驱动AHT21B的完整过程、代码逻辑和踩坑记录整理出来。先说清楚这篇内容适合谁看手里有STM32想用普通GPIO模拟IIC驱动AHT21B或其他IIC传感器的人被硬件I2C各种异常卡住、想换个思路的人以及刚接触IIC通信、想通过一个具体传感器把时序彻底搞明白的新手。AHT21B本身也是个很典型的传感器精度高、价格低、通信协议简单拿它练手再合适不过。1. 为什么放着硬件IIC不用非要软模拟很多人在这个问题上会先入为主STM32自带硬件I2C外设为什么要吃力不讨好地去用GPIO模拟这个疑问我完全理解毕竟硬件外设确实能省CPU但在实际项目中软件模拟IIC的存在感远比想象中强。1.1 硬件IIC在真实项目里的几块绊脚石STM32的硬件I2C外设被无数人吐槽过最典型的是F1系列在总线异常后容易锁死在busy状态必须整块外设复位甚至重新初始化才能恢复。我实际遇到过总线冲突后I2C外设的状态寄存器死活卡在busy查了各种资料最后只能用__HAL_I2C_DISABLE再重新使能才拉回来。这在产品现场是致命的一旦总线被拉死传感器数据就断了。另一块绊脚石是引脚复用冲突。STM32的I2C1固定挂PB6/PB7I2C2挂PB10/PB11I2C3挂PA8/PC9引脚选择非常有限。如果你的PCB布局或现有电路已经把某些引脚占了硬件I2C基本就废了。我之前那个三路AHT21B的项目就是这样I2C1已经被OLED占用I2C2的引脚被UART3复用走了I2C3在F103C8T6上压根没有引出。1.2 软件模拟IIC真正擅长的地方软件模拟IIC最大的优势是引脚自由。任意两个GPIO都可以变成一组IIC总线我甚至干过在同一个端口的不同位上同时模拟两组IIC这种操作。这样在PCB布线时可以绕开所有冲突想走哪儿走哪儿。第二个优势是时序完全可控。硬件I2C的时序由外设寄存器决定遇到时钟延展、总线冲突只能靠外设自己处理软件干预的空间很小。软模拟则是每一拍的高低电平都由代码延时控制逻辑分析仪上看到的波形和代码完全对应。上次调一个速率匹配问题我把SCL高电平时间从4微秒程控调到10微秒硬件I2C想做这种调整得改寄存器配置软模拟改一个延时宏就行了。第三个优势是代码可移植性极强。我从F103写到F407再到ESP32模拟IIC的代码几乎原封不动搬过去就能用只改GPIO操作宏就行。但硬件I2C驱动在不同系列芯片上HAL库的接口差异大迁移成本明显高。1.3 什么时候该回头用硬件IIC软模拟也不是万能的。如果总线上挂了很多设备需要IIC协议栈里的多主机仲裁、超时检测、10位地址这些高级功能软件模拟实现成本会非常可观。还有如果传输速率要求很高快速模式以上软模拟也很难稳定跑到400kHz以上。我自己一般的原则是超过两颗从机、总线上有不同速率的设备、或者对实时性要求高到主循环不能被打断的场合就老老实实用硬件外设。如果只是单颗或两三颗低速传感器软模拟的简洁性完全值得。说到底软模拟不是替代品更多是硬件外设不够用或不好用时的灵活补充两者掌握的边界清楚了选型自然就顺了。2. AHT21B的数据手册为什么我劝你别直接上手看AHT21B是奥松电子出品的数字温湿度传感器IIC接口测量精度湿度±2%RH、温度±0.5°C这个精度在消费级产品里已经相当能打。它的命令集和数据格式在数据手册里其实写得不算复杂但如果你第一次接触很容易在几个地方被绕进去。我就解构一下。2.1 AHT21B的命令与地址细节AHT21B的7位设备地址是0x38所以8位写地址是0x70读地址是0x71。它有三个关键命令触发测量0xAC后面跟两个参数0x33和0x00软复位0xBA读取状态0x71这个其实是读操作这里有个新手最容易犯的迷糊触发测量命令后面为什么要跟0x33、0x00我当时也纠结了半天查了几个版本的数据手册才确定这是AHT20/AHT21系列固定的测量触发序列不是任意数据。0x33和0x00是测量分辨率、功耗模式这类配置的默认值传感器会按照这个序列执行一次完整的温湿度采集。2.2 测量流程与忙检测机制AHT21B的测量流程是主机发送0xAC 0x33 0x00然后等待至少80毫秒这是芯片内部完成ADC转换的时间之后主机再发起读操作从机返回6字节数据。这6字节数据就是整个通信的关键字节序号含义说明Byte0状态字bit7为1表示芯片忙bit3为1表示已校准Byte1湿度数据高位湿度原始值的bit19-12Byte2湿度数据中位湿度原始值的bit11-4Byte3湿度低4位温度高4位高4位是湿度bit3-0低4位是温度bit19-16Byte4温度数据中位温度原始值的bit15-8Byte5温度数据低位温度原始值的bit7-0这里注意数据手册会告诉你发送测量命令后要等80ms但你实际调试时最好在读操作之前先读一次状态字检查bit7有没有归零。为什么因为80ms是典型值如果环境温度极低或传感器老化转换时间可能会略长。轮询busy位比死等延时更靠谱。我当时用的是先延时80ms再读状态字如果busy位还在置位继续等待直到清零或超时返回错误这样做能把偶发的读取失败率降下去。2.3 原始值到温湿度的换算这里有个常见的坑拿到6字节原始数据后先将Byte1、Byte2、Byte3的高4位拼接成20位湿度原始值将Byte3的低4位和Byte4、Byte5拼接成20位温度原始值。换算公式如下uint32_t hum_raw (uint32_t)data[1] 12 | (uint32_t)data[2] 4 | (uint32_t)(data[3] 4); uint32_t temp_raw (uint32_t)(data[3] 0x0F) 16 | (uint32_t)data[4] 8 | (uint32_t)data[5]; float humidity (float)hum_raw / 1048576.0f * 100.0f; // 2^20 1048576 float temperature (float)temp_raw / 1048576.0f * 200.0f - 50.0f;我见过有人写换算代码时直接把20位原始值当成16位来除结果湿度显示出来直接翻了好几倍。为什么20位数据要除以2的20次方因为传感器内部ADC是20位的满量程对应1048576个码值。你可以把它想成一个百分比的刻度尺湿度0%RH对应码值0100%RH对应码值1048575中间线性对应。温度是-50°C到150°C的量程共200°C所以先缩放200再减50偏移量。如果你发现读出来的温度和实际温差很大先检查拼接位有没有搞错尤其是Byte3那一个字节被拆成了两半一半给湿度一半给温度这个位运算最容易错。3. 手写软件模拟IIC协议栈从GPIO到完整时序软件模拟IIC的核心就是一句话用GPIO的电平翻转配合延时复刻IIC总线的物理时序。下面我按代码实现的顺序从底层GPIO配置到完整字节读写一层层拆开来讲。3.1 GPIO初始化与宏定义以STM32F103为例我选的是PB8作为SCLPB9作为SDA。初始化时有一个关键点SDA引脚必须配置为开漏输出同时外部接上拉电阻。SCL可以配开漏也可以配推挽但为了和IIC总线标准一致我也配成了开漏输出。#define IIC_SCL_PIN GPIO_PIN_8 #define IIC_SCL_PORT GPIOB #define IIC_SDA_PIN GPIO_PIN_9 #define IIC_SDA_PORT GPIOB #define IIC_SCL_HIGH() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_SET) #define IIC_SCL_LOW() HAL_GPIO_WritePin(IIC_SCL_PORT, IIC_SCL_PIN, GPIO_PIN_RESET) #define IIC_SDA_HIGH() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_SET) #define IIC_SDA_LOW() HAL_GPIO_WritePin(IIC_SDA_PORT, IIC_SDA_PIN, GPIO_PIN_RESET) void IIC_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin IIC_SCL_PIN | IIC_SDA_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; // 开漏输出 GPIO_InitStruct.Pull GPIO_NOPULL; // 外部已接上拉 GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); IIC_SCL_HIGH(); IIC_SDA_HIGH(); }这里最关键的细节是开漏输出。开漏模式下GPIO只能主动拉低想输出高电平必须靠外部上拉电阻。IIC总线的SDA线是双向的主机和设备都能拉低它所以必须用开漏结构才能避免两个设备同时输出高低电平导致短路。你可以把它想成多人共用一个按钮任何人都可以把按钮按下拉低但没人按的时候它自动弹起来上拉拉高。如果一人死死按住推挽输出高另一人再按下去就短路了。上拉电阻的值也是经验之谈。IIC标准规定了上拉电阻范围常见的是4.7kΩ。如果总线上挂的设备多、线缆长可以降到2.2kΩ如果走线短、设备少10kΩ也能跑。STM32内部虽然有上拉电阻但阻值一般在30kΩ~50kΩ之间对IIC这种需要较快翻转的总线来说偏弱所以我强烈建议外部加一个实际电阻否则通信速率稍微上来波形上升沿就会变得平缓采样就出错了。3.2 起始信号、停止信号与字节传输的实现IIC总线有四个基础时序起始信号、停止信号、发送字节、接收字节。所有的通信命令都是这几个基础时序的组合。我先把起始和停止信号的代码给出void IIC_Start(void) { IIC_SDA_HIGH(); IIC_SCL_HIGH(); delay_us(5); IIC_SDA_LOW(); // SCL高电平期间SDA产生下降沿 delay_us(5); IIC_SCL_LOW(); } void IIC_Stop(void) { IIC_SDA_LOW(); IIC_SCL_HIGH(); delay_us(5); IIC_SDA_HIGH(); // SCL高电平期间SDA产生上升沿 delay_us(5); }起始信号的定义是SCL保持高电平期间SDA从高跳变到低。停止信号反过来SCL保持高电平期间SDA从低跳变到高。我见过新手写代码时先拉低SDA再拉高SCL结果SCL拉高瞬间SDA已经是低电平了起始信号根本没被识别出来。写的时候要时刻记住判据是“SCL为高时SDA的边沿方向”。字节发送和接收的代码如下void IIC_SendByte(uint8_t data) { for (uint8_t i 0; i 8; i) { if (data 0x80) IIC_SDA_HIGH(); else IIC_SDA_LOW(); data 1; delay_us(2); IIC_SCL_HIGH(); delay_us(5); IIC_SCL_LOW(); delay_us(2); } } uint8_t IIC_ReadByte(void) { uint8_t data 0; for (uint8_t i 0; i 8; i) { IIC_SCL_HIGH(); delay_us(5); data 1; if (IIC_SDA_READ()) data | 0x01; IIC_SCL_LOW(); delay_us(5); } return data; }发送字节的逻辑是先在SCL低电平期间把SDA设置到位然后拉高SCL从机在SCL高电平期间采样SDA再拉低SCL准备下一bit。这个“数据在低电平期间准备好在高电平期间被采样”的节奏是整个IIC通信的精髓。你仔细体会一下从机不是无时无刻都在读SDA的它只在SCL上升沿后的高电平窗口中采样。所以你在SCL低电平期间怎么折腾SDA都不会被误读这给软件实现留了很大的容错空间。接收字节时要把SDA切换为输入模式。上面代码里的IIC_SDA_READ()需要你这么实现uint8_t IIC_SDA_READ(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; uint8_t val; GPIO_InitStruct.Pin IIC_SDA_PIN; GPIO_InitStruct.Mode GPIO_MODE_INPUT; GPIO_InitStruct.Pull GPIO_PULLUP; HAL_GPIO_Init(IIC_SDA_PORT, GPIO_InitStruct); val HAL_GPIO_ReadPin(IIC_SDA_PORT, IIC_SDA_PIN); GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(IIC_SDA_PORT, GPIO_InitStruct); return val; }这种实现每次读SDA都要重新配置GPIO效率不高但胜在逻辑清晰能跑。如果追求效率可以把SDA引脚配置成开漏模式读之前把输出寄存器拉高然后直接读IDR寄存器不需要切模式也能读对。不过那是优化阶段的事第一版先把功能跑通最重要。3.3 主机读写时序与ACK处理IIC总线上主机发送完8位数据后需要释放SDA从机在第9个时钟周期拉低SDA表示应答ACK。如果从机不应答SDA保持高电平NACK说明地址错误、设备离线或命令不支持。uint8_t IIC_WaitAck(void) { uint8_t ack; IIC_SDA_HIGH(); // 释放SDA让从机控制 delay_us(2); IIC_SCL_HIGH(); delay_us(5); ack IIC_SDA_READ(); // 读取SDA电平0为ACK1为NACK IIC_SCL_LOW(); delay_us(2); return ack; }注意这个函数的写法主机想读取从机的应答电平必须先把SDA置高否则如果主机在发送完最后一个bit后还强拉着SDA低电平从机想拉都拉不动ACK信号就永远读不到。这个细节我记得很清楚第一次写IIC_WaitAck时忘了释放SDA结果总线上永远是NACK查了半天才发现是主机自己把总线堵死了。主机读取完从机数据后对最后一字节要发出NACK拉高SDA表示读取结束从机收到NACK就停止发送释放总线。void IIC_SendNack(void) { IIC_SDA_HIGH(); delay_us(2); IIC_SCL_HIGH(); delay_us(5); IIC_SCL_LOW(); delay_us(2); }4. 基于模拟IIC的AHT21B驱动封装与实际效果基础时序写好之后AHT21B的驱动就是在这些时序上的具体业务逻辑了。这一层我会把代码组织思路讲清楚然后给出一个实测能用的驱动。4.1 驱动层的整体划分与接口设计我习惯把代码分成两层底层是上面写的IIC协议栈iic_soft.c/h只负责总线上传字节和收字节上层是传感器驱动aht21b.c/h负责按照AHT21B的命令序列组装和解析。下层不关心传感器是谁上层不管GPIO怎么操作。这样分层的好处是以后换一个IIC传感器只需要重写上层底层协议栈不用动。aht21b.h里暴露给用户的核心接口就三个uint8_t AHT21B_Init(void); uint8_t AHT21B_ReadData(float *humidity, float *temperature);Init里主要做软复位和检查校准标志位ReadData就是一次完整的测量触发数据读取换算。这种接口设计看着简单但对用户最友好管你内部怎么折腾我只关心拿到的温湿度对不对。4.2 一次完整的温湿度采集流程AHT21B的驱动源码长这样#define AHT21B_WR_ADDR 0x70 #define AHT21B_RD_ADDR 0x71 #define AHT21B_STATUS_BUSY 0x80 #define AHT21B_STATUS_OK 0x18 uint8_t AHT21B_ReadData(float *humidity, float *temperature) { uint8_t data[6] {0}; uint32_t hum_raw 0; uint32_t temp_raw 0; if (humidity NULL || temperature NULL) return 1; // 1. 触发测量 IIC_Start(); IIC_SendByte(AHT21B_WR_ADDR); if (IIC_WaitAck()) { IIC_Stop(); return 1; } IIC_SendByte(0xAC); IIC_WaitAck(); IIC_SendByte(0x33); IIC_WaitAck(); IIC_SendByte(0x00); IIC_WaitAck(); IIC_Stop(); // 2. 等待测量完成带超时保护 uint16_t timeout 1000; while (timeout--) { delay_ms(10); IIC_Start(); IIC_SendByte(AHT21B_WR_ADDR); if (IIC_WaitAck()) { IIC_Stop(); return 1; } IIC_SendByte(0x71); // 读取状态字 IIC_WaitAck(); IIC_Start(); IIC_SendByte(AHT21B_RD_ADDR); if (IIC_WaitAck()) { IIC_Stop(); return 1; } data[0] IIC_ReadByte(); IIC_SendNack(); IIC_Stop(); if ((data[0] AHT21B_STATUS_BUSY) 0) break; } if (timeout 0) return 1; // 3. 读取6字节完整数据 IIC_Start(); IIC_SendByte(AHT21B_RD_ADDR); if (IIC_WaitAck()) { IIC_Stop(); return 1; } for (uint8_t i 0; i 6; i) { data[i] IIC_ReadByte(); if (i 5) IIC_SendAck(); else IIC_SendNack(); } IIC_Stop(); // 4. 拼接原始值并换算 hum_raw ((uint32_t)data[1] 12) | ((uint32_t)data[2] 4) | (data[3] 4); temp_raw ((uint32_t)(data[3] 0x0F) 16) | ((uint32_t)data[4] 8) | data[5]; *humidity (float)hum_raw / 1048576.0f * 100.0f; *temperature (float)temp_raw / 1048576.0f * 200.0f - 50.0f; return 0; }有几个细节值得展开。第二步里我发完测量命令后先发送0x71命令读状态字这一步的目的是检查busy位是否清零。注意这个序列里用了一个组合技巧先发写地址0x71命令然后再发一个起始信号读地址来读数据这是IIC总线“重复起始”的操作。后面读取6字节数据时前5个字节要发ACK最后一个字节发NACK这个顺序不能乱如果全发ACK或者倒数第二个发NACK传感器会提前停止发送数据就可能错位。延时函数的分配也要说一嘴。delay_us(5)对应SCL高电平和低电平各5微秒一个完整SCL周期是10微秒频率约100kHz正好卡在IIC标准模式的规格内。delay_us(2)作为数据建立和保持时间留足了裕量。AHT21B的数据手册要求数据建立时间至少几百纳秒2微秒已经很富余了。4.3 实测数据与稳定性优化我拿了三颗AHT21B同时挂在三组模拟IIC总线上连续跑了一天一夜记录室温环境下的数据。典型输出是温度26.35°C、湿度58.7%RH左右波动范围温度±0.1°C湿度±0.3%RH和旁边一颗用硬件I2C驱动的同型号传感器数据几乎一致。这个精度对于环境监测、小型气象站、粮库仓储这类场景完全够用。稳定性上软件模拟IIC有个天然优势一旦总线异常复位代码比硬件外设简单得多。我加了一个看门狗式的机制每次读数据返回错误时先对GPIO做一次完整的释放复位SCL拉高、SDA拉高再重新初始化传感器基本都能恢复正常。用硬件I2C时处理类似总线锁死我往往要调半天寄存器才能恢复软模拟三行代码解决。5. 软件模拟IIC调AHT21B最容易栽的5个坑这部分是我最想和你们分享的都是现场踩出来的经验。5.1 坑一GPIO开漏模式却没接上拉电阻有个客户拿我的代码去跑说SDA波形上升沿非常平缓甚至读不到数据。我远程让他量波形一测SCL高电平只有1.2V。原因就是他PCB上没画上拉电阻想着STM32内部上拉能用。我上面说过内部上拉30kΩ~50kΩ对于IIC这种总线来说太弱了上升沿时间会拉长到微秒级通信速率一高就采样出错。解决办法外部加4.7kΩ上拉电阻到3.3V尽量靠近传感器端放置。如果板子已经做好了没办法改可以在初始化里把GPIO内部上拉打开配合降低通信速率来救急但稳定性和抗干扰能力会打折扣只能作为临时方案。5.2 坑二发完测量命令就急着读数据AHT21B不是FPGAADC转换需要时间。我第一次调的时候照着网上某个精简例程抄发送完0xAC之后delay了1毫秒就去读数据结果读回来全是0xFF。折腾了很久才发现是转换没完成。数据手册里写得很明确测量触发后至少等待80ms。我当时用示波器抓SDA上的应答波形从机始终不响应读请求因为busy位还在置位它在忙状态根本不会响应读操作。后来我把检查busy位的逻辑加上通过状态字判断而不是死等延时问题就消失了。所以我建议你也用轮询busy位的方案既保证转换完成又能应对芯片老化导致的转换时间延长。5.3 坑三读回前先拼数据还是先读状态别搞错了顺序有位朋友自己写驱动读数据之前没有先读状态字而是发了两次IIC_Start直接进连续读模式。他读到前两个字节后才意识到第一个字节可能是状态字硬是把状态字当成了湿度高位结果湿度一直显示成300%以上这类离谱值。解决方法没有捷径你得从数据格式上真正理解。一次完整的读序列会返回6字节开头永远是状态字。如果你只关心温湿度读6字节后从第1字节开始拼接千万别把第0字节算进去。5.4 坑四模拟IIC频率拉太高上拉电阻跟不上了软件模拟IIC的通信频率完全由你的延时决定延时越短频率越高。我优化代码时把delay_us从5微秒缩到1微秒想着SCL频率能跑到500kHz结果逻辑分析仪上看到波形完全变形SCL高电平期间SDA根本稳定不下来数据错误率惨不忍睹。原因有两层一是AHT21B支持的最大速率就是400kHz快速模式超过规格二是我的上拉电阻是4.7kΩ走线又有点长RC时间常数限制了下拉释放的速率导致上升沿变缓。后来我老老实实把延时调回5微秒SCL稳定在100kHz标准模式问题彻底消失。不要纠结于跑高速这个传感器测的是环境变化极慢的量100kHz的采样频率绰绰有余。5.5 坑五SDA输入输出模式切换不及时导致读回全0x00这是我移植到另一颗芯片时遇到的问题。我把HAL_GPIO的初始化代码复制过去但读SDA信号时没有把SDA配置成输入模式直接调用了HAL_GPIO_ReadPin读输出数据寄存器ODR读回来的当然永远是上次写入的值也就是全0或者全1。用HAL库时最容易犯这个错因为在F1系列上开漏输出模式下读IDR输入数据寄存器是能读到真实电平的但很多人读的是ODR。我后来统一的写法是读取之前确保SDA是输入模式并且把输出寄存器置1等于释放总线再读。如果对性能有要求可以建立一个读宏直接读IDR寄存器但前提是SDA已经配置成开漏模式且输出锁存器为1这样总线的实际电平才会反映在IDR上。#define IIC_SDA_READ() HAL_GPIO_ReadPin(IIC_SDA_PORT, IIC_SDA_PIN)使用这个宏之前请务必确认SDA在开漏模式下输出锁存器已经置1。6. 从AHT21B到其他传感器这套软模拟方案能走多远AHT21B只是一个起点软件模拟IIC这套底层代码几乎是通用的换成SHT30、BMP280、OLED屏、EEPROM等IIC设备只需要重写上层驱动底层协议栈完全不用动。我自己后来在一个项目里同时模拟了两组IIC一组挂温湿度传感器一组挂OLED屏幕刷新两组独立运行互不干扰这在硬件I2C数量有限的低端MCU上几乎是不可能的。如果你接下来想往深处走我建议你做三件事第一用逻辑分析仪抓一下软件模拟IIC的波形对照数据手册逐段比对起始、停止、ACK和数据bit这个动作会让你对IIC的理解远超背十遍概念第二把代码从HAL库移植到标准外设库或寄存器版本体会一下底层代码对硬件操作的直接性第三试着给这套模拟IIC加上超时保护和错误恢复机制让它在总线被从机异常拉低时还能自动恢复。最后再分享一个小技巧。调试软件模拟IIC时我经常在关键节点比如发送起始信号前、等待ACK后把GPIO翻转一次用示波器或者逻辑分析仪勾住这个调试引脚就能实时看到代码跑到哪一步了。比串口打印轻量得多也不影响时序。这个方法在裸机调试里救过我太多次推荐你下次也试试。