云计算百科
云计算领域专业知识百科平台

90.模拟 IIC 通信中 GPIO 模式配置的关键:为什么开漏输出是必须的?

在嵌入式开发中,软件模拟 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 中,开漏输出是必须的,因为:

  • 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 通信失败还可能有其他原因:

  • 地址错误:确保从设备地址正确,包括读写位的设置
  • 供电问题:检查从设备供电是否正常,电压是否在允许范围内
  • 上拉电阻问题:确保外部上拉电阻阻值合适(通常 4.7kΩ),不要过大或过小
  • 引脚复用问题:确保 IIC 引脚没有被其他外设复用
  • 时序问题:确保延时函数正确,通信速率在从设备允许范围内
  • 五、总结

    模拟 IIC 通信中,GPIO 模式配置是关键中的关键。必须将 SDA 和 SCL 引脚配置为开漏输出模式,并添加外部上拉电阻,这样才能正确实现总线的 "释放" 和 "拉低" 操作,避免总线冲突导致的应答异常。

    希望本文的分析和代码示例能帮助你解决模拟 IIC 通信中的常见问题,让你的嵌入式项目更加稳定可靠。

    如果你也遇到过类似的 IIC 通信问题,欢迎在评论区留言讨论!

     

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 90.模拟 IIC 通信中 GPIO 模式配置的关键:为什么开漏输出是必须的?
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!