在嵌入式开发中,软件模拟 IIC(两线串行通信)是一项常见需求,比如读取温度传感器、OLED 屏幕等外设。但很多开发者会遇到一个经典问题:前 8 位数据传输正常,第 9 位应答(ACK)却异常,数据线卡在中间电平,导致通信失败。本文将结合实际案例,深入分析这个问题的根源,并提供完整的代码解决方案。
一、问题现象:前 8 位正常,第 9 位应答异常
我们先看一个典型的模拟 IIC 波形:
- SCL 时钟信号前 8 个周期正常
- SDA 数据线在第 9 个周期(应答位)卡在 1.6V 左右的中间电平
- 单片机无法采样到有效低电平,判断为 NACK(无应答)
从波形可以看出,前 8 位数据传输正常,但第 9 位应答位出现了电平异常,这是很多模拟 IIC 项目中常见的 "坑"。
二、问题根源:GPIO 模式配置错误
2.1 错误的配置:推挽输出
很多开发者在配置模拟 IIC 时,会将 SDA 引脚配置为推挽输出模式。推挽输出的特点是:
- 输出高电平时,由单片机内部上管主动推高到 VDD
- 输出低电平时,由单片机内部下管主动拉低到 GND
在模拟 IIC 的应答阶段,程序会执行SDA = 1;,意图是 "释放"SDA 线,让从设备(如温度传感器)可以拉低 SDA 来发送 ACK 信号。但在推挽输出模式下,SDA = 1;并不是释放总线,而是单片机主动将 SDA 推高到 VDD。
2.2 冲突的产生:推挽输出 vs 开漏输出
此时,从设备(温度传感器)的 SDA 引脚通常配置为开漏输出模式,在应答时会主动拉低 SDA 线。这就导致了冲突:
- 单片机推挽输出:主动推高 SDA 到 VDD
- 从设备开漏输出:主动拉低 SDA 到 GND
两个设备 "硬碰硬",SDA 线的电平被拉到中间值(如 1.6V),既不是有效的高电平也不是有效的低电平,导致单片机无法采样到从设备的应答信号。
三、正确的解决方案:开漏输出 + 上拉电阻
3.1 开漏输出的原理
开漏输出(Open-Drain)的特点是:
- 输出高电平时,内部上管关闭,引脚处于高阻态,由外部上拉电阻将电平拉到 VDD
- 输出低电平时,内部下管开启,将引脚拉到 GND
在模拟 IIC 中,开漏输出是必须的,因为:
3.2 完整的代码实现
以下是基于 STM32 LL 库的模拟 IIC 完整代码示例,包含正确的 GPIO 配置和通信函数:
#include "stm32f1xx_ll_bus.h"
#include "stm32f1xx_ll_gpio.h"
#include "stm32f1xx_ll_rcc.h"
// 定义IIC引脚(根据实际硬件修改)
#define IIC_SCL_PORT GPIOB
#define IIC_SCL_PIN LL_GPIO_PIN_6
#define IIC_SDA_PORT GPIOB
#define IIC_SDA_PIN LL_GPIO_PIN_7
// 延时函数(根据系统时钟调整)
#define IIC_DELAY_US(us) LL_mDelay(us)
/**
* @brief 初始化模拟IIC的GPIO引脚
* @note 必须配置为开漏输出模式,外部添加4.7kΩ上拉电阻
*/
void IIC_GPIO_Init(void)
{
// 使能GPIOB时钟
LL_APB2_GRP1_EnableClock(LL_APB2_GRP1_PERIPH_GPIOB);
// 配置SCL引脚为开漏输出
LL_GPIO_SetPinMode(IIC_SCL_PORT, IIC_SCL_PIN, LL_GPIO_MODE_OUTPUT);
LL_GPIO_SetPinOutputType(IIC_SCL_PORT, IIC_SCL_PIN, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinSpeed(IIC_SCL_PORT, IIC_SCL_PIN, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinPull(IIC_SCL_PORT, IIC_SCL_PIN, LL_GPIO_PULL_NO); // 依赖外部上拉
// 配置SDA引脚为开漏输出
LL_GPIO_SetPinMode(IIC_SDA_PORT, IIC_SDA_PIN, LL_GPIO_MODE_OUTPUT);
LL_GPIO_SetPinOutputType(IIC_SDA_PORT, IIC_SDA_PIN, LL_GPIO_OUTPUT_OPENDRAIN);
LL_GPIO_SetPinSpeed(IIC_SDA_PORT, IIC_SDA_PIN, LL_GPIO_SPEED_FREQ_HIGH);
LL_GPIO_SetPinPull(IIC_SDA_PORT, IIC_SDA_PIN, LL_GPIO_PULL_NO); // 依赖外部上拉
// 初始状态:SCL和SDA都为高电平(释放总线)
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
}
/**
* @brief 产生IIC起始信号
*/
void IIC_Start(void)
{
// SDA拉低前,SCL必须为高
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// SDA拉低,产生起始信号
LL_GPIO_ResetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(5);
// SCL拉低,准备发送数据
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
}
/**
* @brief 产生IIC停止信号
*/
void IIC_Stop(void)
{
// SDA拉低前,SCL必须为高
LL_GPIO_ResetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(5);
// SCL拉高
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// SDA拉高,产生停止信号
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(5);
}
/**
* @brief 发送一个字节数据
* @param data 要发送的数据
* @return 0: 收到ACK; 1: 收到NACK
*/
uint8_t IIC_SendByte(uint8_t data)
{
uint8_t i, ack;
// 从高位到低位发送8位数据
for(i = 0; i < 8; i++)
{
// 拉低SCL,准备设置数据
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(3);
// 设置SDA电平
if(data & 0x80)
{
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
}
else
{
LL_GPIO_ResetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
}
data <<= 1;
IIC_DELAY_US(2);
// 拉高SCL,让从设备采样数据
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
}
// 接收从设备应答
// 释放SDA,让从设备可以拉低
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
// 拉高SCL,采样应答信号
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 读取SDA电平:0=ACK, 1=NACK
ack = LL_GPIO_IsInputPinSet(IIC_SDA_PORT, IIC_SDA_PIN);
// 拉低SCL,准备下一次传输
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
return ack;
}
/**
* @brief 读取一个字节数据
* @param ack 0: 发送ACK; 1: 发送NACK
* @return 读取到的数据
*/
uint8_t IIC_ReadByte(uint8_t ack)
{
uint8_t i, data = 0;
// 释放SDA,让从设备可以发送数据
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
// 从高位到低位读取8位数据
for(i = 0; i < 8; i++)
{
// 拉低SCL,准备读取数据
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(3);
// 拉高SCL,采样数据
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 读取SDA电平
data <<= 1;
if(LL_GPIO_IsInputPinSet(IIC_SDA_PORT, IIC_SDA_PIN))
{
data |= 0x01;
}
IIC_DELAY_US(2);
}
// 发送应答信号
if(ack == 0)
{
// 发送ACK:拉低SDA
LL_GPIO_ResetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
}
else
{
// 发送NACK:释放SDA
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
}
IIC_DELAY_US(2);
// 拉高SCL,让从设备采样应答
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 拉低SCL,准备下一次传输
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 释放SDA
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
return data;
}
/**
* @brief 等待从设备应答
* @return 0: 收到ACK; 1: 超时未收到ACK
*/
uint8_t IIC_WaitAck(void)
{
uint8_t timeout = 255;
// 释放SDA
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
// 拉高SCL
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 等待SDA被拉低
while(LL_GPIO_IsInputPinSet(IIC_SDA_PORT, IIC_SDA_PIN))
{
timeout–;
if(timeout == 0)
{
IIC_Stop();
return 1; // 超时
}
}
// 拉低SCL
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
return 0; // 收到ACK
}
/**
* @brief 发送ACK信号
*/
void IIC_SendAck(void)
{
// 拉低SDA
LL_GPIO_ResetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
// 拉高SCL
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 拉低SCL
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 释放SDA
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
}
/**
* @brief 发送NACK信号
*/
void IIC_SendNack(void)
{
// 释放SDA
LL_GPIO_SetOutputPin(IIC_SDA_PORT, IIC_SDA_PIN);
IIC_DELAY_US(2);
// 拉高SCL
LL_GPIO_SetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
// 拉低SCL
LL_GPIO_ResetOutputPin(IIC_SCL_PORT, IIC_SCL_PIN);
IIC_DELAY_US(5);
}
四、其他可能的 NACK 原因排查
虽然 GPIO 模式配置错误是最常见的原因,但模拟 IIC 通信失败还可能有其他原因:
五、总结
模拟 IIC 通信中,GPIO 模式配置是关键中的关键。必须将 SDA 和 SCL 引脚配置为开漏输出模式,并添加外部上拉电阻,这样才能正确实现总线的 "释放" 和 "拉低" 操作,避免总线冲突导致的应答异常。
希望本文的分析和代码示例能帮助你解决模拟 IIC 通信中的常见问题,让你的嵌入式项目更加稳定可靠。
如果你也遇到过类似的 IIC 通信问题,欢迎在评论区留言讨论!
网硕互联帮助中心




评论前必须登录!
注册