4、Python模拟I2C:使用RPi.GPIO库模拟I2C时序

说实话,很多刚接触嵌入式开发的朋友,一听到「模拟I2C」就觉得头大。觉得这东西太底层,太麻烦。但我个人的经验是——搞懂模拟I2C,你才算真正理解了I2C协议。硬件I2C虽然方便,但一旦出了问题,你连调试的抓手都没有。

这一章,我们就用RPi.GPIO库,在树莓派上纯软件模拟I2C时序。说白了,就是用GPIO引脚,一根一根地拉高拉低,把I2C的每一个细节都做出来。

4.1 为什么需要模拟I2C?

你可能会问:树莓派不是自带硬件I2C吗?为什么还要自己模拟?

嗯,我在项目里遇到过好几次这样的情况:硬件I2C总线被占用了,或者某个引脚功能冲突了。更常见的是——你要驱动的OLED模块,它的I2C地址和另一个设备冲突了。这时候,用两个普通GPIO引脚模拟I2C,就能完美绕开冲突。

另外,模拟I2C还有一个巨大的好处:调试方便。你可以精确控制每一根线的时序,甚至可以在关键位置插入打印信息,看看波形到底对不对。

核心思路: 用任意两个GPIO引脚,分别模拟SCL(时钟线)和SDA(数据线)。通过软件控制高低电平,实现I2C协议的完整时序。

4.2 准备工作:引脚与库

我们先确定一下用到的引脚。我个人习惯把SCL和SDA放在相邻的引脚上,这样接线方便。

信号 树莓派物理引脚 BCM编号 说明
SCL Pin 11 GPIO 17 时钟线,由主机控制
SDA Pin 12 GPIO 18 数据线,双向传输

库方面,我们只用RPi.GPIO。这个库是树莓派的标准GPIO控制库,几乎每个树莓派上都预装了。如果没有,跑一句pip install RPi.GPIO就行。

小提示: 模拟I2C对时序精度要求比较高。建议使用BCM编号而不是物理引脚编号,因为BCM编号更直接对应芯片内部寄存器,延迟更小。

4.3 核心函数:GPIO控制基础

在开始写I2C时序之前,我们先封装几个最基础的GPIO操作函数。这些函数虽然简单,但它们是整个模拟I2C的基石。

import RPi.GPIO as GPIO
import time

# 引脚定义
SCL_PIN = 17
SDA_PIN = 18

# 初始化GPIO
def i2c_gpio_init():
    GPIO.setmode(GPIO.BCM)
    GPIO.setup(SCL_PIN, GPIO.OUT)
    GPIO.setup(SDA_PIN, GPIO.OUT)
    # 初始状态:SCL和SDA都拉高(空闲状态)
    GPIO.output(SCL_PIN, GPIO.HIGH)
    GPIO.output(SDA_PIN, GPIO.HIGH)

# SCL控制
def scl_high():
    GPIO.output(SCL_PIN, GPIO.HIGH)

def scl_low():
    GPIO.output(SCL_PIN, GPIO.LOW)

# SDA控制
def sda_high():
    GPIO.output(SDA_PIN, GPIO.HIGH)

def sda_low():
    GPIO.output(SDA_PIN, GPIO.LOW)

# 延时函数(调整这个值可以改变I2C速度)
def i2c_delay():
    time.sleep(0.000005)  # 5微秒,约100kHz

这里有个细节要注意:延时函数。我刚开始做的时候,直接用time.sleep(0.001),结果OLED死活不响应。后来用示波器一看,时钟频率才1kHz,太慢了。OLED模块一般支持100kHz到400kHz,所以5微秒的延时差不多能跑到100kHz左右。

4.4 起始信号与停止信号

I2C协议里,起始信号和停止信号是最基本的「交通规则」。说白了,就是告诉从机:我要开始通信了,或者通信结束了。

起始信号(Start Condition): SCL为高电平时,SDA从高电平切换到低电平。

停止信号(Stop Condition): SCL为高电平时,SDA从低电平切换到高电平。

def i2c_start():
    """产生I2C起始信号"""
    sda_high()
    scl_high()
    i2c_delay()
    # SCL高电平时,SDA从高变低
    sda_low()
    i2c_delay()
    scl_low()
    i2c_delay()

def i2c_stop():
    """产生I2C停止信号"""
    sda_low()
    scl_high()
    i2c_delay()
    # SCL高电平时,SDA从低变高
    sda_high()
    i2c_delay()
注意: 起始信号和停止信号的时序非常严格。SCL为高时,SDA必须保持稳定;只有在SCL为低时,SDA才能变化。我曾经因为顺序写反,导致OLED一直无法识别起始信号,排查了整整一个下午。

4.5 数据发送与ACK信号

发送数据是I2C的核心操作。每个字节8位,从最高位(MSB)开始发送。每发送完一个字节,主机要释放SDA线,等待从机返回ACK(应答)信号。

ACK信号说白了就是:从机把SDA拉低,告诉主机「我收到了,继续发」。如果从机没拉低,那就是NACK,表示出问题了。

def i2c_send_byte(data):
    """发送一个字节数据,返回ACK状态"""
    # 发送8位数据,从高位开始
    for i in range(8):
        # 在SCL低电平时设置SDA
        if data & 0x80:
            sda_high()
        else:
            sda_low()
        data <<= 1
        i2c_delay()
        
        # 产生时钟脉冲
        scl_high()
        i2c_delay()
        scl_low()
        i2c_delay()
    
    # 释放SDA线,准备接收ACK
    GPIO.setup(SDA_PIN, GPIO.IN)
    i2c_delay()
    
    # 产生第9个时钟脉冲,读取ACK
    scl_high()
    i2c_delay()
    
    # 读取SDA状态
    ack = GPIO.input(SDA_PIN)
    
    scl_low()
    i2c_delay()
    
    # 重新将SDA设为输出
    GPIO.setup(SDA_PIN, GPIO.OUT)
    
    # 返回ACK状态(低电平表示ACK)
    return ack == 0

这里有一个关键点:发送完数据后,要把SDA引脚从输出模式切换为输入模式。为什么?因为ACK信号是从机拉低的,如果主机还占着SDA线,从机根本拉不动。我见过不少新手在这里翻车,SDA一直保持高电平,从机永远无法应答。

4.6 完整的数据写入流程

有了起始信号、停止信号和字节发送函数,我们就可以组合出一个完整的I2C写入流程了。以向OLED写一个命令为例:

def i2c_write_byte(dev_addr, data):
    """向指定设备地址写入一个字节"""
    # 1. 起始信号
    i2c_start()
    
    # 2. 发送设备地址 + 写标志(最低位为0)
    #    例如:0x3C << 1 | 0 = 0x78
    if not i2c_send_byte(dev_addr << 1):
        print("设备无应答!")
        i2c_stop()
        return False
    
    # 3. 发送数据
    if not i2c_send_byte(data):
        print("数据发送失败!")
        i2c_stop()
        return False
    
    # 4. 停止信号
    i2c_stop()
    return True

# 使用示例:向OLED发送命令
# OLED地址通常为0x3C
i2c_write_byte(0x3C, 0xAF)  # 0xAF是OLED显示开启命令
经验之谈: 设备地址左移一位,再加上读写位,这是I2C协议的标准做法。OLED的7位地址是0x3C,左移后变成0x78,再加上写标志0,就是0x78。很多初学者直接传0x3C,结果设备永远不应答。

4.7 知识结构图:模拟I2C核心流程

下面这张图,把整个模拟I2C的流程串起来了。你可以把它当作一个「速查地图」。

模拟I2C核心流程 模拟I2C核心流程 GPIO初始化 起始信号 (Start) 发送设备地址 + 写位 收到ACK? 停止信号 + 报错 发送数据字节 停止信号 (Stop) SCL高时SDA变化 起始:高→低 停止:低→高

4.8 常见问题与避坑指南

模拟I2C看起来简单,但实际调试时坑不少。我把自己踩过的坑整理了一下:

  • SDA方向切换忘记做:发送完数据后,一定要把SDA从输出切为输入,否则永远收不到ACK。我曾经因为这个,对着逻辑分析仪看了半小时,才发现SDA一直是被主机拉高的。
  • 延时太短或太长:延时太短,从机跟不上;延时太长,通信速度太慢。5微秒是个不错的起点,如果不行再调整。
  • 起始信号顺序搞反:先拉高SCL,再拉高SDA,然后拉低SDA,最后拉低SCL。这个顺序不能错,错了从机就不认。
  • 停止信号后忘记释放总线:停止信号后,SCL和SDA都应该保持高电平,表示总线空闲。如果SDA被拉低,下一个起始信号可能无法被识别。
重要提醒: 模拟I2C的时序完全依赖软件延时,会受到系统负载的影响。如果树莓派同时运行多个高负载任务,I2C时序可能会抖动。这时候,要么给Python进程提高优先级,要么换用硬件I2C。

4.9 完整代码示例

最后,我把上面所有的函数整合成一个完整的模拟I2C类。这样用起来更方便:

import RPi.GPIO as GPIO
import time

class SoftI2C:
    def __init__(self, scl_pin, sda_pin, delay_us=5):
        self.scl = scl_pin
        self.sda = sda_pin
        self.delay = delay_us / 1_000_000
        
        GPIO.setmode(GPIO.BCM)
        GPIO.setup(self.scl, GPIO.OUT)
        GPIO.setup(self.sda, GPIO.OUT)
        GPIO.output(self.scl, GPIO.HIGH)
        GPIO.output(self.sda, GPIO.HIGH)
    
    def _delay(self):
        time.sleep(self.delay)
    
    def start(self):
        GPIO.output(self.sda, GPIO.HIGH)
        GPIO.output(self.scl, GPIO.HIGH)
        self._delay()
        GPIO.output(self.sda, GPIO.LOW)
        self._delay()
        GPIO.output(self.scl, GPIO.LOW)
        self._delay()
    
    def stop(self):
        GPIO.output(self.sda, GPIO.LOW)
        GPIO.output(self.scl, GPIO.HIGH)
        self._delay()
        GPIO.output(self.sda, GPIO.HIGH)
        self._delay()
    
    def send_byte(self, data):
        for i in range(8):
            if data & 0x80:
                GPIO.output(self.sda, GPIO.HIGH)
            else:
                GPIO.output(self.sda, GPIO.LOW)
            data <<= 1
            self._delay()
            GPIO.output(self.scl, GPIO.HIGH)
            self._delay()
            GPIO.output(self.scl, GPIO.LOW)
            self._delay()
        
        GPIO.setup(self.sda, GPIO.IN)
        self._delay()
        GPIO.output(self.scl, GPIO.HIGH)
        self._delay()
        ack = GPIO.input(self.sda)
        GPIO.output(self.scl, GPIO.LOW)
        self._delay()
        GPIO.setup(self.sda, GPIO.OUT)
        return ack == 0
    
    def write(self, dev_addr, data):
        self.start()
        if not self.send_byte(dev_addr << 1):
            self.stop()
            return False
        if not self.send_byte(data):
            self.stop()
            return False
        self.stop()
        return True

# 使用示例
i2c = SoftI2C(17, 18)
i2c.write(0x3C, 0xAF)  # 开启OLED显示

嗯,到这里,模拟I2C的核心内容就讲完了。你可能会觉得代码量有点大,但每一行都有它的意义。说白了,模拟I2C就是让你亲手触摸到协议的本质。下次遇到I2C通信问题,你就能从底层去分析,而不是对着数据手册干瞪眼。