基于8051内核的GPIO软件模拟IIC读取SHT30温湿度传感器实战

📅 发布时间:2026/9/24 1:36:26
基于8051内核的GPIO软件模拟IIC读取SHT30温湿度传感器实战
上个月调一块基于 NSA2302 的方案板IIC 总线上挂了一个 SHT30 温湿度传感器读出来的数据要么全是 0xFF要么偶发飞掉一个字节。当时我怀疑是芯片硬件问题用逻辑分析仪抓完波形才发现问题根本不在传感器而在自己写的 C 代码里ACK 判断写反了而且读取最后一个字节时回错了应答。这篇文章就把我用 NSA2302 通过 IIC 读取传感器数据的完整过程整理出来包含可直接抄作业的 C 代码、IIC 时序拆解以及几个折腾了我一晚上的坑。不管你是刚接触 8051 系列的初学者还是准备把传感器数据接进小家电方案的嵌入式工程师只要总线上用的是 IIC这套思路基本都能直接复用到你的板子上。先交代一下背景NSA2302 是一颗 8 位 8051 内核 MCU主频不高、RAM 也不大片内虽然带了硬件 IIC 外设但寄存器交互偏绕。我的做法是用 GPIO 软件模拟 IIC时序完全可控出问题也好排查代码还能跨平台移植。下面从硬件连接开始一步步把整个链路讲透。1. NSA2302 的引脚分配、硬件连接与上拉电阻选择实验1.1 先确认引脚复用别一上来就接杜邦线NSA2302 这类国产 8051 内核 MCU最常用的就是 P1 口。我的习惯是把 P1.0 分配为 SCLP1.1 分配为 SDA原因很简单这两个引脚默认就是普通 GPIO不需要额外关闭第二功能。有些型号的 P1 口可能复用了 ADC、PWM 或比较器拿到板子第一步应该去翻数据手册里的引脚功能表确认没有复用冲突。如果复用功能默认开启IIC 波形会被内部外设拉乱表现出来的症状就是通信时好时坏。上电初始化时SCL 必须配置为输出模式SDA 要配置为既能输出又能输入。8051 的准双向口本身支持“写 1 后读外部电平”的操作只要把 SDA 置 1就能从同一根引脚读回从机拉低或释放的电平状态。如果 NSA2302 支持开漏输出模式建议把 SDA 设为开漏如果不支持准双向口也够用。下面是初始化函数的示例寄存器名以你手头 NSA2302 的数据手册为准不同批次或型号可能命名不同void IIC_GPIO_Init(void) { // P1.0 配置为推挽输出P1.1 配置为准双向/开漏 // 如果 NSA2302 的默认上电状态就是准双向口可跳过寄存器配置 P1M1 ~0x03; // 示例P1.0/P1.1 模式位清零 P1M0 | 0x01; // 示例P1.0 推挽输出P1.1 开漏 IIC_SCL 1; IIC_SDA 1; }提示很多初学者接好线后忘了把 SDA 拉高。IIC 总线空闲时 SDA 和 SCL 都应该为高电平如果上电后 SDA 是低电平从机可能认为总线上一直有起始信号直接不响应。1.2 上拉电阻取多大从 4.7k 到 1k我踩过的三种情况IIC 总线是开漏结构设备只能把 SDA/SCL 拉低拉高必须靠外部上拉电阻完成。这个电阻如果选得不对后果非常隐蔽上拉电阻太大比如 10kΩ 以上总线上升沿变缓方波变成三角波主控在 SCL 高电平期间采样 SDA 时可能采到不确定电平。尤其在 400kHz 快速模式下这种情况几乎必现。上拉电阻太小比如 100Ω灌入电流过大从机输出低电平时的功耗和误码风险都会增加极端情况下可能把芯片引脚拉坏。我这边 NSA2302 和 SHT30 都工作在 3.3V板内走线距离约 5cm实际测试结果如下总线速率3.3V 推荐阻值5V 推荐阻值实测表现100kHz 标准模式4.7kΩ10kΩ波形规整连续读取 1 小时无错码400kHz 快速模式2.2kΩ4.7kΩ波形良好但延时参数需同步调优快速模式 长线 20cm1kΩ2.2kΩ上升沿明显改善但功耗略增如果想自己估算可以用上升时间公式 τ R_pullup × C_bus 做参考。IIC 规范要求 400kHz 模式下上升时间不超过 300ns假设总线电容 100pF4.7kΩ 的 τ 约为 470ns略超换成 2.2kΩ 后 τ 约为 220ns余量充足。100kHz 标准模式下这个要求放宽到 1000ns4.7kΩ 就很安全。2. IIC 时序拆解起始、停止、ACK 三件事别想当然2.1 五段式时序背下来不如用代码画出来IIC 通信的核心可以拆成三个动作起始、数据位、停止。起始条件是 SCL 为高电平时 SDA 产生一个下降沿停止条件是 SCL 为高电平时 SDA 产生一个上升沿。数据位则规定SCL 低电平期间允许 SDA 变化SCL 高电平期间 SDA 必须保持稳定。用一个对讲机的类比来说SCL 是拍手的节拍器SDA 是说话的人。只有在节拍器落下SCL 低时说话人才能换词节拍器抬起SCL 高期间必须闭嘴保持住当前词。如果说话人在节拍器抬起时改词听的人就会把这次变化误解为起始或停止信号。写代码时最直观的方式就是把每个动作拆成函数void IIC_Start(void) { IIC_SDA 1; IIC_SCL 1; IIC_Delay(); IIC_SDA 0; // SCL 高期间 SDA 拉低 起始 IIC_Delay(); IIC_SCL 0; } void IIC_Stop(void) { IIC_SDA 0; IIC_SCL 1; IIC_Delay(); IIC_SDA 1; // SCL 高期间 SDA 拉高 停止 IIC_Delay(); }2.2 为什么 SDA 的输入输出方向要来回切很多人在写软件模拟 IIC 时卡在 ACK 上就是因为没理解 SDA 是双向线。主控发完一个字节后必须立刻释放 SDA写成 1也就是高阻态把总线交给从机。从机如果正常收到数据会在第 9 个时钟周期把 SDA 拉低这就是 ACK如果从机没响应SDA 保持高电平就是 NACK。8051 的准双向口读引脚前必须写 1否则读出来永远是引脚寄存器的输出值而不是外部真实电平。所以IIC_WriteByte里发完 8 个数据位后一定要先执行IIC_SDA 1;再拉高 SCL、读取 SDA 电平。漏掉这一行ACK 检测永远失败。发一个字节并检查 ACK 的完整代码bit IIC_WriteByte(unsigned char dat) { unsigned char i; bit ack; for (i 0; i 8; i) { IIC_SDA (dat 0x80) ? 1 : 0; IIC_Delay(); IIC_SCL 1; IIC_Delay(); IIC_SCL 0; dat 1; } IIC_SDA 1; // 释放总线等从机应答 IIC_Delay(); IIC_SCL 1; IIC_Delay(); ack IIC_SDA ? 0 : 1; // SDA 为低 ACK IIC_SCL 0; return ack; }2.3 时钟频率和延时怎么配软件模拟 IIC 没有硬件分频器SCL 的频率完全由IIC_Delay()决定。标准模式 100kHz、快速模式 400kHz单个时钟周期分别为 10µs 和 2.5µs。实际做的时候建议先用标准模式把链路跑通再逐步加快。NSA2302 主频一般在 12MHz 到 24MHz 之间简单的空延时可以用_nop_()嵌套实现但要注意编译器优化等级可能会把空循环优化掉。稳妥做法是写一个带 volatile 变量的延时函数static void IIC_Delay(void) { volatile unsigned char i; for (i 0; i 30; i) { // 空循环耗时约 5us 12MHz } }提示换编译器或优化等级后延时时间会变。最靠谱的调整方式是接逻辑分析仪看实际波形把 SCL 高电平时间调到目标值附近。别凭感觉加大延时延时会拖慢整个通信但并不是越大越稳。3. 手写一套 IIC 驱动从 GPIO 到寄存器读写的完整 C 代码3.1 底层原语Start、Stop、WriteByte、ReadByte这一节直接给完整可用的底层驱动。以下代码基于 Keil C51 环境如果你的编译器不支持bit和sbit把bit换成unsigned charsbit换成对应引脚的置位/清零宏即可。#include reg52.h #include intrins.h // 引脚定义按自己板子实际接线修改 sbit IIC_SCL P1^0; sbit IIC_SDA P1^1; static void IIC_Delay(void) { volatile unsigned char i; for (i 0; i 30; i) { // 空循环调整次数可改变 IIC 速率 } } void IIC_Start(void) { IIC_SDA 1; IIC_SCL 1; IIC_Delay(); IIC_SDA 0; IIC_Delay(); IIC_SCL 0; } void IIC_Stop(void) { IIC_SDA 0; IIC_SCL 1; IIC_Delay(); IIC_SDA 1; IIC_Delay(); } bit IIC_WriteByte(unsigned char dat) { unsigned char i; bit ack; for (i 0; i 8; i) { IIC_SDA (dat 0x80) ? 1 : 0; IIC_Delay(); IIC_SCL 1; IIC_Delay(); IIC_SCL 0; dat 1; } IIC_SDA 1; IIC_Delay(); IIC_SCL 1; IIC_Delay(); ack IIC_SDA ? 0 : 1; IIC_SCL 0; return ack; } unsigned char IIC_ReadByte(void) { unsigned char i, dat 0; IIC_SDA 1; for (i 0; i 8; i) { IIC_SCL 1; IIC_Delay(); dat 1; if (IIC_SDA) { dat | 0x01; } IIC_SCL 0; IIC_Delay(); } return dat; } void IIC_SendAck(bit ack) { // ack 1 表示发送 ACKack 0 表示发送 NACK IIC_SDA ack ? 0 : 1; IIC_Delay(); IIC_SCL 1; IIC_Delay(); IIC_SCL 0; IIC_SDA 1; IIC_Delay(); }这里有个容易被忽略的细节IIC_ReadByte读第一个 bit 之前SDA 必须已经释放为输入状态所以函数开头先执行IIC_SDA 1;。IIC_SendAck里发完 ACK 后也要把 SDA 恢复为 1避免影响下一个字节的读取。3.2 应答逻辑的统一封装读最后一个字节必须回 NACK主控作为接收方时每读完一个字节都要回复应答还需要继续读就回 ACK告诉从机“继续发”读到最后一个字节时回 NACK告诉从机“我不要再收了”然后主机再发停止信号。如果顺序搞反从机会以为主机还想继续收数据总线状态就会错乱。这个逻辑封装到多字节读取函数里最清晰unsigned char IIC_ReadRegs(unsigned char dev7_addr, unsigned char reg_addr, unsigned char *buf, unsigned char len) { unsigned char i; IIC_Start(); if (!IIC_WriteByte((dev7_addr 1) | 0)) { IIC_Stop(); return 0; } if (!IIC_WriteByte(reg_addr)) { IIC_Stop(); return 0; } IIC_Start(); // 重复起始 if (!IIC_WriteByte((dev7_addr 1) | 1)) { IIC_Stop(); return 0; } for (i 0; i len; i) { buf[i] IIC_ReadByte(); IIC_SendAck(i (len - 1)); // 最后一个字节发 NACK } IIC_Stop(); return 1; }参数dev7_addr是传感器的 7 位地址。以 SHT30 为例默认地址是 0x44函数内部左移一位后变成 0x88 作为写地址0x89 作为读地址。这是 IIC 地址规则里的常见坑数据手册里写的是 7 位地址而总线上实际发送的是 8 位7 位地址 1 位读写标志。3.3 面向传感器的寄存器读写封装有上面的底层函数写针对具体传感器的驱动就很方便了。以 BMP280 这类需要配置寄存器的传感器为例写单字节寄存器可以直接这样做unsigned char IIC_WriteReg(unsigned char dev7_addr, unsigned char reg_addr, unsigned char dat) { IIC_Start(); if (!IIC_WriteByte((dev7_addr 1) | 0)) { IIC_Stop(); return 0; } if (!IIC_WriteByte(reg_addr)) { IIC_Stop(); return 0; } if (!IIC_WriteByte(dat)) { IIC_Stop(); return 0; } IIC_Stop(); return 1; }这套驱动的设计思路是底层只管位时序中间层管字节和 ACK应用层只管传感器业务逻辑。后续换传感器、换主控底层基本不动改应用层就行。我在多个项目里复用这套代码移植成本很低。4. 实例用 NSA2302 读 SHT30 温湿度传感器4.1 SHT30 的命令与 IIC 地址SHT30 是 Sensirion 出品的数字温湿度传感器IIC 接口默认 7 位地址 0x44。它的读取流程和普通寄存器型传感器不太一样先发一条测量命令等测量完成后再直接发读地址去读 6 个字节数据中间没有寄存器地址这一步。SHT30 支持多种测量模式最常用的是单次测量高可重复性命令命令MSBLSB说明单次测量高可重复性0x2C0x06推荐功耗稍高但数据最稳单次测量中可重复性0x2C0x0D均衡单次测量低可重复性0x2C0x10最快但噪声略大命令发送完成后需要等待芯片完成测量。数据手册标的是 2ms 到 20ms实际测试建议至少留 20ms尤其是 3.3V 供电或芯片老化后测量时间会变长。等待时间不够会读到上一帧或全零数据。4.2 读 6 字节并解析温湿度读数据时主机流程是起始 - 发送读地址 0x89 - 从机回 ACK - 主机连续读 6 个字节 - 最后回 NACK - 停止。6 字节数据分别是温度高字节、温度低字节、温度 CRC、湿度高字节、湿度低字节、湿度 CRC。开发阶段建议把 CRC 校验加上量产时如果总线环境干净也可以跳过以节约代码空间。下面给出不含 CRC 校验的简洁版本#define SHT30_ADDR 0x44 #define SHT30_CMD_MSB 0x2C #define SHT30_CMD_LSB 0x06 unsigned char SHT30_ReadData(unsigned char *buf) { // buf 长度至少 6 字节 IIC_Start(); if (!IIC_WriteByte((SHT30_ADDR 1) | 0)) { IIC_Stop(); return 0; } if (!IIC_WriteByte(SHT30_CMD_MSB)) { IIC_Stop(); return 0; } if (!IIC_WriteByte(SHT30_CMD_LSB)) { IIC_Stop(); return 0; } IIC_Stop(); Delay20ms(); // 等待测量完成建议 20ms IIC_Start(); if (!IIC_WriteByte((SHT30_ADDR 1) | 1)) { IIC_Stop(); return 0; } buf[0] IIC_ReadByte(); IIC_SendAck(1); buf[1] IIC_ReadByte(); IIC_SendAck(1); buf[2] IIC_ReadByte(); IIC_SendAck(1); buf[3] IIC_ReadByte(); IIC_SendAck(1); buf[4] IIC_ReadByte(); IIC_SendAck(1); buf[5] IIC_ReadByte(); IIC_SendAck(0); // 最后一字节 NACK IIC_Stop(); return 1; }8051 上做浮点运算比较吃力所以解析温湿度时我用定点运算代替浮点。SHT30 的原始 ADC 值是 16 位温度和湿度的转换公式为温度 -45 175 × raw / 65535单位 °C相对湿度 100 × raw / 65535单位 %RH放大 100 倍以保留两位小数void SHT30_Parse(unsigned char *buf, long *temp100, unsigned int *humi100) { unsigned int temp_raw, humi_raw; temp_raw ((unsigned int)buf[0] 8) | buf[1]; humi_raw ((unsigned int)buf[3] 8) | buf[4]; *temp100 -4500L (17500L * temp_raw) / 65535L; // 0.01 ℃ *humi100 (unsigned int)((10000UL * humi_raw) / 65535UL); // 0.01 %RH }使用temp100时整数部分是temp100 / 100小数部分是temp100 % 100。注意当温度为负值时%的结果也是负的打印前需要做符号处理或者直接用temp100做比较判断避免格式化输出踩坑。4.3 把数据打印到串口验证代码跑通后串口是最直观的验证工具。NSA2302 的 UART 初始化不展开只给一个简单的打印调用示例unsigned char sht30_buf[6]; long temp100; unsigned int humi100; while (1) { if (SHT30_ReadData(sht30_buf)) { SHT30_Parse(sht30_buf, temp100, humi100); // 串口输出例如T25.31 C H48.52 % printf(T%ld.%02ld C H%u.%02u %%\r\n, temp100 / 100, temp100 % 100, humi100 / 100, humi100 % 100); } DelayMs(1000); }如果串口输出稳定、数值在合理范围说明整个 IIC 链路已经打通。这时候再去做功耗优化、速度提升、异常处理才有意义。5. 实测中踩过的坑与排查方法5.1 读回全 0xFF先从硬件查起全 0xFF 这个现象很有迷惑性第一反应往往是“代码是不是写错了”。但根据我的排查经验这个症状一般先指向硬件问题用万用表量 SDA 空闲电平。正常应该有 3.3V或对应 VDD高电平如果是 0V说明上拉电阻没接或者传感器供电没通。检查 SCL/SDA 是否接反。不少 PCB 的丝印会坑人最好用蜂鸣档顺着线确认到传感器的实际引脚。看传感器 VDD 是否正常。SHT30 供电低于 2.4V 时可能完全不上电总线自然无应答。确认引脚模式。8051 准双向口读之前必须写 1否则即使外部是低电平读回来也可能一直是高。如果以上都正常再抓波形确认是否有起始信号和地址字节。5.2 NACK 反复出现时钟延时要背锅地址字节发出去之后从机不回 ACK也就是 SDA 在第 9 个时钟高电平期间仍然保持高电平。这个时候优先检查两件事第一是 SCL 频率太高。软件模拟 IIC 的延时太短SCL 高电平宽度不足从机来不及处理或采样就会漏掉 ACK。解决办法是把延时加大把速率降到 100kHz 再试。第二是设备地址移位错误。SHT30 的 7 位地址是 0x44左移一位后的写地址才是 0x88。如果代码里直接把 0x44 放进IIC_WriteByte实际上发送的是地址 0x44 加写标志 0从机地址对不上肯定不回 ACK。这类问题在逻辑分析仪上一眼就能看出来波形里地址字节显示的不是 0x88。5.3 多字节读取时最后一个字节前就发 NACK这个问题藏得比较深。症状是读取多字节数据时第一个字节正常后面的字节全乱或者整个读取流程卡死。原因是主机每读完一个字节都回 ACK从机收到 ACK 后认为主机还要数据于是继续把下一个字节放上总线。当主机希望结束并发送停止信号时从机还在输出数据总线电平被从机拉着停止条件根本形成不了。正确逻辑是前 N-1 个字节回 ACK最后一个字节回 NACK然后停止。这个逻辑在IIC_ReadRegs和 SHT30 的读取函数里都已经体现但在自己写其它传感器驱动时特别容易漏。我建议把所有回 ACK 的调用都集中到函数末尾处理不要写一个字节就立刻决定减少手误的概率。5.4 逻辑分析仪是排查 IIC 的照妖镜调试 IIC 一定要备一台逻辑分析仪哪怕是几十块钱的 24MHz 采样率也够用。把通道 0 接 SCL、通道 1 接 SDA、地线共地然后就能看到完整的时序波形。抓完波形后重点看三个位置起始信号是否干净SDA 下降沿必须发生在 SCL 高电平期间。地址字节后面有没有 ACKACK 位是低电平如果一直为高说明从机没有被正确寻址。读数据阶段的最后一个字节主机回 NACK 后 SDA 释放为高随后出现停止条件。逻辑分析仪还能直观显示实际的 IIC 时钟频率。我在调 400kHz 模式时就是靠它发现自己的空循环延时让 SCL 跑到了 800kHz传感器完全跟不上。把延时代码调成 volatile 空循环后波形才正常。最后再说一个我个人习惯上的小技巧每次写完 IIC 驱动不要急着接真实传感器先用一根杜邦线把 SCL 和 SDA 短接跑一个回环测试——主控自己发一个字节再读回来确认 GPIO 收发方向切换没问题。这个办法帮我省了大量 debug 时间。另外如果项目里计划挂多个 IIC 设备选型时一定要把地址排开避免两个设备用同一个 7 位地址导致总线冲突。NSA2302 的硬件 IIC 外设留着以后慢慢研究先把 GPIO 软件模拟这套吃透后面看寄存器手册也会顺很多。