两根线怎么挂住一堆设备:I2C 协议原理

经典三问

Q: I2C 是什么?

A: 两根线(SCL 时钟、SDA 数据)上的同步串行协议,靠地址在一根总线上挑出要通信的那台设备。

Q: 为什么 I2C 能一根总线挂很多设备?

A: 所有设备共用这两根线,靠地址区分目标;串口则更像一对一的通道;空闲时线是高电平,谁说话由主机安排。

Q: I2C 常见使用场景?

A: 同一块板子上的屏幕、陀螺仪、EEPROM、各种传感器------引脚紧张、设备多、速率要求不高时最合适。


你早就在用它了。

从 OLED 上出现第一行字那天起,每一帧画面都是这条总线送过去的:清屏、写一个像素、显示一个数字,背后都是 PB8 和 PB9 上的一串电平。那时候我们只需要知道"接对四根线,调驱动就能显示",不需要知道线上到底发生了什么。

下面回头把那两根线讲透:为什么两根线能挂住一堆设备、为什么它们必须"松手"、为什么数据只能在时钟低电平的时候改、地址和应答在线上长什么样。讲完给你一份最小的软件 I2C------每一拍都由程序亲手拨出来,再用一次地址扫描把"地址"从概念变成屏幕上的一个数字。

串口是一对一,I2C 是一对多

串口那几篇里,PA9 和 PA10 连的是电脑,一条线两头各一个设备。I2C 的目标不一样:一根总线上挂好几台设备,还要让它们互不打架。

串口(UART) I2C
线 TX、RX,各走一个方向 SCL、SDA,共用
双工 全双工(能同时收发) 半双工(同一时刻只有一个方向)
时钟 异步:两边事先约好波特率 同步:有一根专门的时钟线
设备数 点对点,两个 一条总线多台,靠地址区分
谁定节奏 两边各自按波特率计时 主机产生时钟,从机跟着走

最后一行是理解 I2C 的钥匙:产生时钟的那个人是主机。 从机不能自己开口说"我要发数据",它只能等主机点名。所以"多主多从"这种说法里,主机的身份是动态的------谁先占用总线、谁在产生时钟,谁就是这一刻的主机。

时钟线带来的好处很直接:不需要预先约定速度,慢一点的从机可以随时拉住时钟让主机等(这叫时钟延展),主机收到的是一个准确的"还没准备好",而不是一串错位的数据。

两根线凭什么挂住一堆设备:开漏加上拉

先看空闲时的状态:没人说话的时候,SCL 和 SDA 都是高电平。

这个高电平不是哪个设备"推"上去的,而是上拉电阻把线拉上去的。每个设备的这两个引脚都配成开漏输出 :它只能主动把线拉到低电平,想要高电平就只能松手,让上拉电阻把线拉回去。

写成代码就是一句容易误会的话:

c 复制代码
GPIO_SetBits(GPIOB, GPIO_Pin_9);      /* SDA 写 1:不是推高,而是松手 */

松手之后线是高是低,由别人决定。

为什么非得这样? 因为一根线上有七八个设备,如果每个都能把线推高、也能拉低,那么一个推高、一个拉低的时候,电流直接从电源穿过两个输出级------轻则波形出错,重则烧管子。开漏把"推高"这个动作取消了,所有设备只能拉低,于是:

  • 任何一个设备拉低,线就是低(这叫线与);
  • 所有设备都松手时,线才回到高。

这条规则马上带来一个必须的后果:接收方才有资格在第 9 拍把数据线拉低,表示"我收到了"。 如果主机是推挽输出、把线牢牢顶在高电平,从机根本拉不动它,应答就没法实现。开漏不只是为了安全,它是应答机制能存在的前提。

上拉电阻的取值要挑一下:

  • 太大(比如 100kΩ):松手以后线回升得慢,上升沿变缓,速率一高就来不及拉到高电平,通信时好时坏;
  • 太小(比如 1kΩ):线被拉低时,灌进驱动级的电流变大,可能超过器件能承受的范围;
  • 常见取值在 4.7kΩ 左右,这也是各种模块和自己搭电路时最常写的一个数。

还有两件常被忽略的事:

模块自带的上拉会并联。 你手上那些 OLED、传感器模块,很多已经在板上焊了上拉电阻。挂两块,等效上拉就变小一半;挂四块,拉低时的电流翻两番------挂得多的时候要按并联后的阻值算一次灌电流。

上拉接到哪一路电压,总线的高电平就是多少。 上拉接 3.3V,总线高电平就按 3.3V 算;这条总线上如果有 5V 设备,就得先看清楚它的引脚能不能接受 3.3V 的高电平,以及它内部有没有自己的上拉(那会变成额外的一路)。

最后是线长和电容。线越长,越容易拾取干扰,同时线本身的电容也越大------设备挂得多、杜邦线拉得长,上升沿都会变缓。所以 I2C 天生是板内协议:几十厘米以内很舒服,拉出一米就开始看运气。真要跑得远,要么降速,要么换差分或无线那一类方案。

时序的四个基本单元

I2C 的全部规则可以压缩成一句:时钟线为高电平的时候,数据线必须保持稳定。

实际规则是:数据只允许在 SCL 为低电平时改变。两个例外是起始和停止,它们故意违反这条规则,用来标记"开始"和"结束"。记住这一句,时序图就不用背了。

四个基本单元,逐个看:

单元 线怎么动 谁在动
起始 START SCL 为高时,SDA 由高变低 主机
停止 STOP SCL 为高时,SDA 由低变高 主机
发一个字节 SCL 低时放好数据位 → SCL 拉高(对方在这期间读)→ 再拉低,重复 8 次 发送方
应答 第 9 拍:接收方拉低表示收到(ACK),松手表示没收到(NACK) 接收方

起始与停止的动作都在时钟高电平期间完成,所以它们和普通的数据位不会混淆。总线空闲时两根线都是高。

发送一个字节 是高位先行:先把最高位放到 SDA,再把 SCL 拉高,让对方在这个高电平窗口里读走,然后拉低时钟准备下一位。八次之后,一个字节就出去了。

接收一个字节 反过来:接收方要先松手(把 SDA 释放成高),由对方逐位放数据,自己在 SCL 高电平期间读。

第 9 拍是应答,这一拍最容易被漏掉,也最有用:

  • 发送方发完 8 位之后,第 9 拍要松手,把线让给接收方;
  • 接收方如果收到了,就把 SDA 拉低,这一拍读出来是 0,叫 ACK ;没收到或者不想再收,就松手,读出来是 1,叫 NACK;
  • 主机发完一个字节,要收 对方的应答;主机收完一个字节,要发应答;
  • 主机读最后一个字节时,必须发 NACK,意思是"够了,别再发"。我第一次写 I2C 读时序时就漏过这一步,结果从机继续往外吐数据。

地址:一根线上怎么点名

起始条件之后,主机送出的第一个字节不是数据,而是地址:

text 复制代码
1 个字节:  D7 D6 D5 D4 D3 D2 D1 D0
            └──── 7 位地址 ────┘ └ 方向:0 写,1 读

所以 7 位地址在线上是两个不同的数。常见的 OLED 地址 0x3C 是 7 位地址:

  • 7 位地址:0x3C = 011 1100;
  • 写地址字节:0x3C << 1 | 0 = 0x78;
  • 读地址字节:0x3C << 1 | 1 = 0x79。

0x3C 和 0x78 经常出现在同一个工程的两处注释里,说的就是这两个层次。判断的办法很简单:看它有没有把读写位算进去。

地址还有几个要知道的点:

  • 10 位地址 是另一套格式,用两个字节发送,前 5 位固定成 11110。手上大部分小模块用不到,遇到了按手册写就行。
  • 地址 0x00 是广播地址,所有支持广播的设备都会应答,写进去的数据会同时到达所有设备。有些设备靠它做统一复位。
  • 同一条总线上不能有两个同地址的设备。 两个都焊上去以后,它们会同时应答、同时听数据:读的时候数据会"打架",写的时候两台设备都被改掉。地址冲突是那种"现象随机、查起来很累"的故障------设备上的地址引脚(比如 MPU6050 的 AD0)就是为了让你换地址的。

还有一个概念要在这里立起来:从机内部通常有自己的"地址指针"。 主机除了要知道"跟哪台设备说话",还要告诉它"读/写它内部的哪个寄存器"。这个内部地址不在这 7 位里,而是跟在设备地址后面,作为普通数据发过去的------下一节就能看到它。

一次完整的读写:三种最常用的时序

把上面的单元拼起来,就得到实际使用中反复出现的三条时序。

指定地址写(往某个设备的某个寄存器写一个字节):

text 复制代码
起始 → 设备地址+写 → ACK → 寄存器地址 → ACK → 数据 → ACK → 停止

当前地址读 (从设备内部指针当前指的寄存器读):

text 复制代码
起始 → 设备地址+读 → ACK → 数据 → NACK → 停止

它读的是"上次停在哪儿",所以单独用它的场合不多。

指定地址读(读某个指定寄存器,最常用):

text 复制代码
起始 → 设备地址+写 → ACK → 寄存器地址 → ACK → 重复起始 → 设备地址+读 → ACK → 数据 → NACK → 停止

注意中间那个重复起始 :它是"再发一次起始条件",但不是先停止再起始。为什么要这么绕?因为从机不知道自己该读哪个寄存器------得先用一次"写"把它的内部指针挪到目标位置,然后马上转成读。中间如果插一个停止条件,总线会被释放,别的设备可能抢进来,内部指针的状态也不再由我们掌控。重复起始就是为这件事准备的。

拿 OLED 对照一下:我们平时那一串 0x00、0x40、0xB0 | page,就发生在"数据"那一段里。这些字节怎么解释,是 SSD1306 自己的规定,不在 I2C 协议里------协议只管把字节安全地送到,送到以后是什么意思,归设备自己说。

自己拨一遍:最小软件 I2C

到这里,协议已经完整了。剩下的问题是:这些电平谁来拨?

我们前面用的 OLED 驱动就是这么干的:代码直接操作 PB8 和 PB9,一位一位把波形拨出来。这种实现叫软件 I2C。它的好处是每一拍都看得见------时序图上的每个箭头都能在代码里找到对应的那一行。

引脚沿用前面的分配:PB8 是 SCL,PB9 是 SDA。

先把最底下那三个小函数写出来:写 SDA、写 SCL、读 SDA。整个软件 I2C 的执行机构就是它们。

c 复制代码
static void SoftI2C_SDA(uint8_t high)
{
    if (high != 0U)
    {
        GPIO_SetBits(GPIOB, GPIO_Pin_9);
    }
    else
    {
        GPIO_ResetBits(GPIOB, GPIO_Pin_9);
    }
}

static void SoftI2C_SCL(uint8_t high)
{
    if (high != 0U)
    {
        GPIO_SetBits(GPIOB, GPIO_Pin_8);
    }
    else
    {
        GPIO_ResetBits(GPIOB, GPIO_Pin_8);
    }
}

static uint8_t SoftI2C_ReadSda(void)
{
    return (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_9) == Bit_SET) ? 1U : 0U;
}

写的那两个函数里,GPIO_SetBits() 就是"松手":引脚配成开漏之后,写 1 不会把线推高,只把它交还给上拉电阻。

读的那个函数里,GPIO_ReadInputDataBit() 拿到的是引脚上真实 的电平(Bit_SET 就是库里表示"高"的那个值)。开漏输出模式下,引脚的实际电平仍然可以被读回来,这正是 I2C 能在同一根线上"我松手、你说话"的原因------同一个引脚,写的时候是输出,读的时候是输入。

再初始化:两个引脚配成开漏输出,然后都松手,让总线回到空闲状态。

c 复制代码
static void SoftI2C_Init(void)
{
    GPIO_InitTypeDef gpio;

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);

    gpio.GPIO_Pin = GPIO_Pin_8 | GPIO_Pin_9;
    gpio.GPIO_Mode = GPIO_Mode_Out_OD;        /* 开漏:只能拉低,写 1 是松手 */
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &gpio);

    SoftI2C_SDA(1U);                          /* 空闲:两根线都松手 */
    SoftI2C_SCL(1U);
}

速率由一个空转函数控制:

c 复制代码
static void SoftI2C_Delay(void)
{
    volatile uint32_t i;

    for (i = 0U; i < 20U; i++)
    {
    }
}

它没有任何"技术含量",但它是软件 I2C 里唯一决定速率的东西:每处电平变化之间插一次,循环次数越大,波形越慢。(循环变量写成 volatile 是为了让编译器别把这段空循环优化掉。)常见写法里也有人干脆不写延时,靠函数自身的开销把速度压下来;显式写出来,是为了让"快慢握在自己手里"这件事看得见。别把它当成精确的微秒延时------它的作用是拉长每一拍,不是定时。

为什么不用前面那套用 SysTick 做的精确延时? 因为它要解决的问题不是"等一个准确的时间"。

从机是靠时钟沿同步的,所以这里需要的是"够慢",不是"准"。换成 Delay_us(1) 反而带来两个麻烦:

  • 那个函数每次调用都要读 SysTick->VAL、做一圈跨零判断,开销和你要等的时间是同一个量级,甚至更长。你叫它等 1µs,实际花掉多久并不由那个 1 决定,而由函数自身决定;想靠调整这个数字来控制 I2C 速率,等于把速率交给了一个与总线无关的机制;
  • 它依赖 SysTick 一直在跑。软件 I2C 经常被用在中断里(比如传感器拉了一下中断脚,程序就在那个中断里读它的寄存器),再者,Delay_us() 内部会把 TICKINT 临时关掉再恢复,嵌套调用还会把等待拉长。

但"够慢"这件事有下限,也有上限。 协议里规定的是各种电平的最小宽度,那是下限;上限来自另外两处:

  • 从机手册里的最大时钟频率。 SSD1306 这一档标的是 400kHz,再快的从机也总有一个上限,超过了它就不认账;
  • 总线上高电平的爬升时间。 上拉电阻和线电容决定了线从低爬到高要多久。时钟周期短到高电平还没爬到位就被拉低,从机读到的就不是"高",波形直接废掉。

这两条一叠加,就成了软件 I2C 移植时最容易踩的坑:它的速率由代码本身决定。 主频、编译优化等级、每次调用函数的那点开销,全都算进每一拍的长短里。于是同一份代码搬到一个主频更高的芯片上,每一拍会自动变快------原本在 72MHz 上刚好合适的空循环,到一百多兆的芯片上可能已经超过从机能接受的频率,甚至短到高电平还没爬上去就被拉低。

现象就是:代码一个字没改,换块板子就黑屏,而且没有任何报错。 没有报错是因为 I2C 从设计上就不打算吼你------它只会安静地不应答。

排查办法很短,要背下来:先把 SoftI2C_Delay() 的循环次数加大十倍。 如果这样就能亮,那问题就是速率,不是接线、不是地址、不是驱动逻辑;然后再逐步往回减,减到刚好稳定,最后留在那个值的两倍上,给换排线、换板子、多加一块设备留出余量。比黑屏更早拿到证据的还有地址扫描:扫到 0x3C,说明此刻这个速率从机还跟得上;一个 ACK 都扫不到,就先别怀疑显示代码。

还有一个和速率无关、症状却一模一样的坑,移植时同样常见:把这两个引脚配成了推挽输出。 那样主机把线牢牢顶在高电平,从机根本拉不低它,第 9 拍永远收不到 ACK,屏幕照样是黑的,但原因完全不同------一个是太快,一个是根本没给从机留说话的余地。开漏输出不是风格问题,是应答能不能存在的前提。

需要精确时间的地方,精确延时是必需的:传感器上电后要等的稳定时间、复位脉冲的宽度、超声波回波超时,那些场合差一点就是错的。而 I2C 的位延时是另一类需求------把节奏放慢到从机跟得上(顺带一提,兼容 SMBus 的那类器件还规定了最长超时,慢到几十毫秒一级就要小心,普通 I2C 器件没有这条)。真嫌它不够快、不够准,答案不是换一个延时函数,而是换一种实现:把产生时钟这件事整个交给硬件。

那次黑屏的经过,和更标准的查法

这类坑我自己踩过一次。把一份 OLED 代码移到另一块芯片上,接线上电,屏幕全黑。接线、地址、供电、初始化顺序都对着查过一遍,代码是验证过能跑的那一份,一个字没改;换一块同样的屏,还是黑。

最后是灵光一闪:把 SoftI2C_Delay() 里的循环次数加大,屏幕亮了。问题确实是速度------新的芯片更快,每一拍短到超过了从机能接受的范围。但我一直记得当时那种别扭:改对了,却不知道自己改对了什么。 那个循环次数是试出来的,不是算出来的;换一块更快的芯片,同样的黑屏会再来一次。

灵感能救急。想要下次不再靠灵感,遇到这类"神秘 bug"更标准的做法是这四步:

第一步,把看不见的东西变成看得见的。 首选逻辑分析仪或示波器:接上 SCL,量一个完整周期有多长(高电平段加低电平段),再和从机手册里的最大频率对一下。没有仪器也有替代办法------地址扫描(扫到 0x3C,说明此刻的速率从机还跟得上),或者用周期计数把每一拍的时间直接"读"出来。

第二步,一次只变一个量,做对照。 把延时翻十倍再跑一遍:亮了,问题就在速率和时序上,接线、地址、驱动逻辑可以先放下;还是黑,那就不是速度,回去查开漏还是推挽、上拉和地址。一次改动排除一半可能性,这就是对照实验的全部价值。

第三步,找临界值,然后退到它的一半。 逐步减小延时,直到刚好出错,记下这个临界点,再退回去一倍------让程序工作在临界速率的一半。留这份余量,是因为换一根排线、换一批模块、温度变一点,临界点都会移动。正好踩在临界点上的代码,是"今天能用"的代码。

第四步,把结论写下来,别让它再变回灵感。 那个循环次数旁边要有一行注释:主频多少、从机手册的上限多少、实际工作频率是多少、为什么留了一倍余量。一个没有来历的数字,就是下一次"神秘"的来源------讲丢字节那一回,屏幕上那两个数字就是为了把"偶尔抽风"变成"有个数字在涨",这里是同一件事。

四个基本单元,逐个落成函数。起始与停止:

c 复制代码
static void SoftI2C_Start(void)
{
    SoftI2C_SDA(1U);
    SoftI2C_SCL(1U);
    SoftI2C_Delay();

    SoftI2C_SDA(0U);        /* SCL 高电平期间,SDA 由高变低 = 起始 */
    SoftI2C_Delay();

    SoftI2C_SCL(0U);
    SoftI2C_Delay();
}

static void SoftI2C_Stop(void)
{
    SoftI2C_SDA(0U);
    SoftI2C_SCL(1U);
    SoftI2C_Delay();

    SoftI2C_SDA(1U);        /* SCL 高电平期间,SDA 由低变高 = 停止 */
    SoftI2C_Delay();
}

发一个字节,顺便把第 9 拍的应答读回来:

c 复制代码
static uint8_t SoftI2C_SendByte(uint8_t data)
{
    uint8_t bit;
    uint8_t acknowledged;

    for (bit = 0U; bit < 8U; bit++)
    {
        SoftI2C_SDA((uint8_t)((data & 0x80U) != 0U));   /* 高位先行 */
        SoftI2C_Delay();

        SoftI2C_SCL(1U);                               /* 对方在这一拍读走 */
        SoftI2C_Delay();

        SoftI2C_SCL(0U);
        SoftI2C_Delay();

        data = (uint8_t)(data << 1);
    }

    SoftI2C_SDA(1U);                    /* 第 9 拍:松手,把线让给接收方 */
    SoftI2C_Delay();
    SoftI2C_SCL(1U);
    SoftI2C_Delay();

    acknowledged = (SoftI2C_ReadSda() == 0U) ? 1U : 0U;  /* 被拉低 = ACK */

    SoftI2C_SCL(0U);
    SoftI2C_Delay();

    return acknowledged;
}

收一个字节,应答由调用者决定(读最后一个字节要传 NACK):

c 复制代码
static uint8_t SoftI2C_ReadByte(uint8_t send_ack)
{
    uint8_t bit;
    uint8_t data = 0U;

    SoftI2C_SDA(1U);                    /* 先松手,把数据线让给从机 */

    for (bit = 0U; bit < 8U; bit++)
    {
        SoftI2C_SCL(1U);
        SoftI2C_Delay();

        if (SoftI2C_ReadSda() != 0U)
        {
            data |= (uint8_t)(0x80U >> bit);
        }

        SoftI2C_SCL(0U);
        SoftI2C_Delay();
    }

    SoftI2C_SDA((send_ack == 0U) ? 1U : 0U);   /* 0 = 发 NACK,1 = 发 ACK */
    SoftI2C_Delay();
    SoftI2C_SCL(1U);
    SoftI2C_Delay();
    SoftI2C_SCL(0U);
    SoftI2C_Delay();

    SoftI2C_SDA(1U);                    /* 松手,别一直占着数据线 */

    return data;
}

对着前面的时序表数一遍:SCL 为高的时候,SDA 从来没有变过------除了起始与停止那两处故意的地方。这就是全部的秘密。

一处观察:地址扫描

现在把"地址"这件事从概念变成一个数字。

做法很直接:取每一个可能的 7 位地址,发一次"起始 + 地址 + 写",然后看第 9 拍有没有 ACK。 有 ACK 就说明这个地址上真有设备在听。

打印地址要用到一个小函数,把 0~15 变成一个十六进制字符:

c 复制代码
static uint8_t Uart_HexChar(uint8_t value)
{
    if (value < 10U)
    {
        return (uint8_t)('0' + value);
    }

    return (uint8_t)('A' + (value - 10U));
}

然后就是扫描:

c 复制代码
static void SoftI2C_Scan(void)
{
    uint8_t address;

    Uart_SendString("I2C scan start\r\n");

    for (address = 0x08U; address <= 0x77U; address++)
    {
        SoftI2C_Start();

        if (SoftI2C_SendByte((uint8_t)(address << 1)) != 0U)
        {
            Uart_SendString("ACK at 0x");
            Uart_SendByte(Uart_HexChar((uint8_t)(address >> 4)));
            Uart_SendByte(Uart_HexChar((uint8_t)(address & 0x0FU)));
            Uart_SendString("\r\n");
        }

        SoftI2C_Stop();
    }

    Uart_SendString("I2C scan end\r\n");
}

Uart_SendString() 和 Uart_SendByte() 是串口那几篇里的两个发送函数:一个发字符串,一个发单个字节。扫描里为什么每次都要配一对 SoftI2C_Start() 和 SoftI2C_Stop()?因为没被应答的那一次传输也把总线占着了,必须用停止条件把它放回空闲,下一个地址才能重新发起始。

扫描范围从 0x08 到 0x77,是因为 0x000x07 和 0x78 0x7F 在协议里有特殊用途,正经设备不会把地址放在那里。

接上 OLED 跑一次,串口里应该出现:

text 复制代码
I2C scan start
ACK at 0x3C
I2C scan end

这一个数字有三层意思,比现象本身重要得多:

  1. 地址不是别处抄来的一个写法,它真的是线上的一个字节。 0x3C 左移一位,就是刚才发出去的那个 0x78;
  2. ACK 是设备真的在场。 它把 SDA 拉低了,这个动作只有接在总线上的芯片做得到;
  3. 没有 ACK 的时候,问题多半在物理层、地址或者速率上------接线、上拉、供电、地址算错、每一拍太快,五个里面总有一个。程序逻辑还没轮到怀疑。

按现象查的几个坑

一个 ACK 都没有。 先量电压:SDA、SCL 在空闲时是不是高电平?不是高,说明没有上拉或者线被拉死;是高电平但扫不到任何地址,先检查 SDA/SCL 有没有接反、设备有没有供电。

扫描结果时有时无。 这是上升沿太慢的典型表现:上拉太大、线太长、挂的设备太多。把线缩短、把速率降下来(加大 SoftI2C_Delay() 的循环次数)再试。

换了一块芯片就全黑,代码一个字没改。 先加延时,把 SoftI2C_Delay() 的循环次数翻十倍到几十倍再试一次。软件 I2C 的速率跟着主频和编译优化走,芯片更快就是每一拍更短;太快会同时踩到从机的最大频率和总线的爬升时间这两条线。这也是移植时第一个该试的动作------它花不到一分钟,却能立刻把"速率问题"和"接线问题"分开。

总线挂死:某一次通信中途停了,之后所有设备都不应答。 原因通常是主机在从机"正在往外吐字节"的时候复位了------从机还在等时钟,就会一直把 SDA 拉低不放,总线永远是忙的。恢复办法是让它把话说完:手动产生 9 个时钟脉冲 (把 SCL 拉高拉低 9 次,期间让 SDA 松手),然后补一个停止条件。这也是很多驱动里带一段"总线恢复"代码的原因。

两个设备同地址。 表现是扫描时出现不该出现的应答,写数据时两台一起被改。查地址引脚,或者换掉其中一个设备的地址。

从机拉住时钟不放。 这不是故障,是时钟延展:它在说"等一下"。软件 I2C 天然容忍它;换成硬件外设时,也要确认它开着对应的容忍机制。

上拉接错电压。 上拉接在 5V 上,这条总线的"高电平"就按 5V 算,接 3.3V 设备之前要确认它能不能接受。反过来,上拉接 3.3V 而设备期望更高的高电平,它可能认不出高电平,也就扫不到。

最后橘猫说

回头看前面建立的几件事:

  • 两根线,靠地址分设备。 主机产生时钟,从机只能被点名------这是它和串口最根本的区别;
  • 开漏加上拉。 所有人只能拉低,写 1 是松手;上拉是这根线的"默认高",也是上升沿速度的来源;
  • SCL 高时数据不许变。 唯一的例外是起始和停止;
  • 第 9 拍看应答。 它是"设备真的在"的唯一证据,也是排查时第一个该看的地方。

还有一件事留在这里:这套时序目前是 CPU 一位一位拨出来的,所以叫软件 I2C。那么,它一定要由 CPU 来拨吗? STM32 里有专门干这件事的外设:它自己产生时钟、自己发起始和停止、自己收发应答;代价是它把每一拍藏了起来,看得见的东西少了一半,速率和总线的脾气也换成另一套。这里先讲到软件这一份,那个外设留到以后再说。

先把软件这一份看熟。等你清楚每一拍该长什么样,再去看那个外设替掉了哪几行代码,才知道自己换来了什么、让出了什么。

相关推荐
欧西17 分钟前
零成本做一颗芯片:我用 OpenLane 把 SHA-256 从 Verilog 做到了 GDS
开源·嵌入式
武子康2 小时前
h3.c 的两个权重槽,怎样把内存压力换成 SSD 等待
c语言·人工智能·agent
倔强的胡萝卜2 小时前
基于STM32F103C8T6的温度报警器设计
stm32·单片机·嵌入式硬件
缘来是你远去是我3 小时前
ZYNQ 系列芯片中断机制详解:类型、处理过程与中断处理函数
单片机·嵌入式硬件
LCG米5 小时前
STM32 定时器主从模式输出精确脉冲数实测:TRGO 触发链拆解、多发一脉冲根因与干净停止时序落地
stm32·单片机·嵌入式硬件·嵌入式
Dev_XH5 小时前
【成品设计】基于物联网的配电箱智能防护系统设计
stm32·单片机·嵌入式硬件·物联网
Dev_XH7 小时前
【成品设计】基于单片机的工厂设备能耗监控与优化系统
stm32·单片机·嵌入式硬件·物联网
anguoyang21 小时前
RK3588 上模型「能跑但很慢」:逐算子查 CPU 回退的完整方法与实测
嵌入式
§ꦿCFོ༉1 天前
嵌入式八股—C语言入门学习(一)
c语言·编程语言·嵌入式八股文