#72_聊聊I2C以及他们的变体

文章目录

  • 一、发展历史:从飞利浦实验室到你口袋里的手机
    • [二、I2C 协议详解(技术规范)](#二、I2C 协议详解(技术规范))
        • [a. 硬件连接:两根线串起所有设备](#a. 硬件连接:两根线串起所有设备)
        • [b. 那我设备设计的时候都设计成默认高电平不就好了?](#b. 那我设备设计的时候都设计成默认高电平不就好了?)
        • [c.那为什么不把 `AD0` 引脚默认设计成内部上拉到 `VCC`,省得外部再接线?](#c.那为什么不把 AD0 引脚默认设计成内部上拉到 VCC,省得外部再接线?)
      • [(2) 空闲状态:总线什么时候算"闲着"](#(2) 空闲状态:总线什么时候算"闲着")
      • [(3) 起始与停止:通信的开关](#(3) 起始与停止:通信的开关)
      • [(4) 数据的有效性:什么时候能变,什么时候不能变](#(4) 数据的有效性:什么时候能变,什么时候不能变)
      • [(5) 发送与接收一个字节](#(5) 发送与接收一个字节)
      • [(6) 应答/非应答(`ACK`/`NACK`)](#(6) 应答/非应答(ACK/NACK))
      • [(7) 从机地址:叫谁谁答应](#(7) 从机地址:叫谁谁答应)
      • [(8) 假设我的`AD0`是引脚`P20`那个流程应该怎么写](#(8) 假设我的AD0是引脚P20那个流程应该怎么写)
        • [a. 先吃透那 7 位地址的"拼图规则"](#a. 先吃透那 7 位地址的“拼图规则”)
        • [b. 问的核心:"`AD0` 接 `P20`,完整流程是什么?"](#b. 问的核心:"AD0P20,完整流程是什么?")
        • [c. 所以,`#define MPU6050_ADDR 0x68` 到底有没有用?](#define MPU6050_ADDR 0x68` 到底有没有用?)
        • [d. 完整调用示例(结合你之前的软件 I2C)](#d. 完整调用示例(结合你之前的软件 I2C))
        • [e. 总结:你要的"完整流程"](#e. 总结:你要的"完整流程")
      • [(9) 那代码里怎么写?](#(9) 那代码里怎么写?)
      • [(10)**为什么厂商留一个 `AD0` 引脚?**](#(10)为什么厂商留一个 AD0 引脚?)
      • [(11) 为什么要左移一位](#(11) 为什么要左移一位)
      • [(12) 典型通信时序](#(12) 典型通信时序)
      • [(13) 实现通信的两种手段](#(13) 实现通信的两种手段)
      • [(14) 软件设计流程](#(14) 软件设计流程)
  • 三、核心概念通俗讲解(开漏、线与、华尔兹)
      • [(1) 主与从:一个邮递员 + N 个家庭](#(1) 主与从:一个邮递员 + N 个家庭)
      • [(2) 为什么用开漏,不用推挽?](#(2) 为什么用开漏,不用推挽?)
      • [(3) SDA(数据线) 与 SCL(时钟线) 的华尔兹](#(3) SDA(数据线) 与 SCL(时钟线) 的华尔兹)
  • [四、实践指南:手搓一个简易 I2C(软件模拟)](#四、实践指南:手搓一个简易 I2C(软件模拟))
      • [(1) 准备工作](#(1) 准备工作)
      • [(2) 初始化](#(2) 初始化)
      • [(3) 等待总线空闲](#(3) 等待总线空闲)
      • [(4) 起始条件](#(4) 起始条件)
      • [(5) 停止条件](#(5) 停止条件)
      • [(6) 等待应答](#(6) 等待应答)
      • [(7) 主机发送应答](#(7) 主机发送应答)
      • [(8) 发送字节](#(8) 发送字节)
      • [(9) 接收字节](#(9) 接收字节)
      • [(10) 整合:扫描 I2C 总线设备](#(10) 整合:扫描 I2C 总线设备)
      • [(11) 实例:AT24C02 的页写与随机读](#(11) 实例:AT24C02 的页写与随机读)
        • [a. 页写(注意页内回卷)](#a. 页写(注意页内回卷))
        • [b. 随机读](#b. 随机读)
        • [c. 简单测试程序](#c. 简单测试程序)
  • 五、现代的变体:它们还叫"I2"吗?
      • [(1) I2S:不是控制总线,是音频的"高速公路"](#(1) I2S:不是控制总线,是音频的"高速公路")
      • [(2) SMBus 和 PMBus:工业电源的"听话版本"](#(2) SMBus 和 PMBus:工业电源的"听话版本")
      • [(3) I3C:I2C 的真正接班人](#(3) I3C:I2C 的真正接班人)
  • 六、结语:手搓一次,理解十年

从电视里的一根总线,到你手上的一行代码,我们今天把 I2C 的故事彻底聊透。

你有没有好奇过:为什么一块小小的电路板上,所有传感器都愿意用那两根线说话?

为什么无论 Arduino 还是树莓派,教程里几乎都有 i2c.scan() 的身影?

这就是 I2C,它老派,却不落伍,简单,却满身智慧。

今天这篇博客,咱们就一起坐上时光机,从它的出生年代出发,一路拆到现代变体。还会带你手搓一个软件 I2C,不依赖任何硬件外设,就用 GPIO 一"位"一"位"地捏出来。

一、发展历史:从飞利浦实验室到你口袋里的手机

I2C 的全称是 Inter-Integrated Circuit ,直译就是"芯片之间的对话"。 它最初叫 I²C (I 平方 C),因为总线上挂着多个 IC。后来商标注册问题,大家都习惯写 I2C。

时间回到 1982 年 ,荷兰的飞利浦半导体(后来的 NXP)正面临一个头痛的问题: 电视机的电路板越来越复杂,各种功能芯片------调谐器、视频解码、音频处理------需要大量的并行总线连接,引脚数爆炸,PCB 布线像蜘蛛网。 他们想:能不能用两根线 ,把所有这些芯片串起来,统一通信?

于是 I2C 诞生了。核心计划很简单:

  • 一条 数据线 SDA(数据线)(Serial Data)
  • 一条 时钟线 SCL(Serial Clock)
  • 主设备控制时钟,所有从设备靠唯一的 7 位地址区分。1982 年公布后,I2C 迅速从电视扩展到所有需要板级通信的地方。 速度不断升级:
  • 标准模式 (Standard-mode) --- 100 kbit/s
  • 快速模式 (Fast-mode) --- 400 kbit/s
  • 快速模式 Plus (Fast-mode Plus) --- 1 Mbit/s
  • 高速模式 (High-speed mode) --- 3.4 Mbit/s
  • 超快速模式 (Ultra Fast-mode) --- 5 Mbit/s(单向,只写)

2010 年代后,飞利浦的专利陆续到期,I2C 彻底成为公共领域协议。 今天,你随手拿一个温湿度传感器、一块 OLED 屏幕、一个 EEPROM(电可擦除可编程只读存储器),几乎清一色用的都是 I2C。它就像嵌入式的"普通话",懂的设备最多,活得也最久。

二、I2C 协议详解(技术规范)

这一节我们把 I2C 协议从头到尾捋一遍,从硬件连接到通信流程,再到软件设计思路一条线讲透。

a. 硬件连接:两根线串起所有设备

I2C 总线的硬件连接极其简单,记住四句话就够了:

  • 所有设备的 SCL(时钟线) 连在一起,所有设备的 SDA(数据线) 连在一起
  • 每个设备的 SCL(时钟线)SDA(数据线) 都要配置成开漏输出模式
  • SCL(时钟线)SDA(数据线) 各加一个上拉电阻,阻值一般 4.7kΩ 左右
  • 总线默认被上拉电阻拉到 高电平,任何设备只能拉低,不能强行拉高

为什么必须是开漏输出?

因为如果两个设备一个输出 、一个输出 ,推挽模式会直接短路烧毁。开漏输出保证任何设备只能"拉低"或"放手",这就是线与逻辑 ,谁拉低总线就变 ,安全又巧妙。

以常见的 EEPROM AT24C02 为例,它与 MCU 连接如下:

  • SCL(时钟线)MCUI2C 时钟引脚(如 PB6
  • SDA(数据线)MCUI2C 数据引脚(如 PB7
  • 两个引脚各通过 4.7kΩ 电阻上拉到 VCC
  • 芯片的 A0/A1/A2 地址引脚接地或接 VCC 决定其设备地址
b. 那我设备设计的时候都设计成默认高电平不就好了?

你还真是个天才,一开始我也这么想------所有设备地址全部设计成一样的,不就不用纠结了?

但现实啪啪打脸:如果同一条 I2C 总线上挂两个相同型号的传感器,两个相同的地址会直接在总线上"打架",谁也分不清主机在叫谁,整个通信崩溃。

所以厂商才留了一个 AD0 引脚,让你能在硬件上区分两个相同芯片 :一个接地(0x68),一个接 VCC0x69)各叫各的,互不干扰。

c.那为什么不把 AD0 引脚默认设计成内部上拉到 VCC,省得外部再接线?

这就涉及到芯片内部场效应管(MOSFET的一个关键特性了:场效应管的栅极(Gate)输入阻抗极高,几乎是绝缘的 。这意味着:

  • 如果 AD0 引脚内部上拉到 VCC :引脚悬空时,栅极积累的电荷没有泄放通路,电平会不确定 ------可能是高,可能是低,甚至悬在半空中来回跳。这会导致芯片上电时地址随机变,同一个板子这次认 0x68,下次认 0x69,你还怎么调试?

  • 所以芯片设计时,地址引脚默认是"必须由外部明确给定电平" 。你接 GND 就是 0,接 VCC 就是 1,没有"悬空"这个选项。这是为了保证上电瞬间地址就确定,绝不模棱两可。

一句话总结:

场效应管输入阻抗太高,悬空时电平不确定,地址会乱跳。所以 AD0 必须由你外部明确接线,不能靠内部默认上拉来偷懒。

(2) 空闲状态:总线什么时候算"闲着"

SDA(数据线) 和 SCL(时钟线) 两条线同时为 高电平 时,总线处于空闲状态。

a. 为什么是"放手"而不是"主动输出高电平"?这和场效应管的特性直接相关。

I2C 总线上所有设备的 SCLSDA 引脚,内部都用的是开漏输出Open-Drain),驱动元件是 N 沟道场效应管(MOSFET):

  • 栅极(Gate)= 高电平时 :场效应管导通,SDA/SCL 被拉到 GND,输出 低电平这是"我要发 0"时的动作
  • 栅极(Gate)= 低电平时 :场效应管截止 ,引脚对外呈高阻态 ,相当于"这根线我不管了" → 这是"我要发 1"或者"我闭嘴"时的动作,此时总线电平由上拉电阻决定,自然为 高电平

看到没?场效应管只能主动拉低,不能主动拉高。

你可能会困惑:"高电平"和"场效应管导通输出低电平"怎么同时存在?其实它们是结果手段的关系,反着来的:

|---------------|---------------------|------------|---------------|
| 你想让总线输出什么 | 场效应管在干什么 | 总线实际电平 | 通俗理解 |
| 低电平 | 导通 (把线拉到 GND) | 低 | 我主动把线踩到地上 |
| 高电平 | 截止(高阻态,啥也不干) | 高 | 我放手,上拉电阻把线拽上去 |

关键就在这个高阻态:当所有设备都"放手"(场效应管全部截止),总线上的电平由谁决定?

答案是外部上拉电阻 ,它把总线拉到 VCC,所以空闲时总线是 高电平

b.所以"空闲状态是高电平"到底是什么意思?

空闲状态 = 所有设备都放手 (场效应管全部截止)→ 没人拉低总线 → 上拉电阻把线拉到 VCC → 总线呈现 高电平
"高电平"不是谁主动输出的,而是"大家都不拉低"的结果。

这正是 I2C 总线实现线与逻辑的物理基础------只要有一个设备拉低,整根线就变低;所有设备都放手,线才恢复高。

c.一张表彻底讲清楚

|----------|------------|----------|------------|
| 场景 | 场效应管状态 | 总线电平 | 谁在决定电平 |
| 设备输出 0 | 导通 | 低 | 设备主动拉低 |
| 设备输出 1 | 截止(高阻态) | 高 | 上拉电阻被动拉高 |
| 总线空闲 | 所有设备都截止 | 高 | 上拉电阻被动拉高 |

d. 一句话刻进脑子里

场效应管是个**"只能拉低不能拉高"的开关。高电平从来不是它输出的,而是它 "不拉低"时,上拉电阻把线抬上去的。空闲状态就是所有人都不拉低,总线自然被拉高。所以,这也是为什么 I2C 总线上必须加上拉电阻的原因: 正因为场效应管只能拉低不能拉高,如果总线上没有上拉电阻,当所有设备都"放手"时,SDASCL 就变成悬空状态**------电平不确定,可能是高、可能是低、可能来回跳。整条总线的通信直接废掉。

加上上拉电阻之后,一切就通了:

  • 空闲状态 :所有人放手 → 上拉电阻把线拉到 高电平,总线明确处于"空闲"
  • 起始信号 :主机把 SDA 拉低 → 总线从"闲"变"忙",通信开始
  • 发送数据 0 :设备导通场效应管,把线拉到 低电平
  • 发送数据 1 :设备截止场效应管,放手 → 上拉电阻把线拉回 高电平
  • 接收数据 :一方放手让出总线,另一方驱动;读取时线被上拉电阻撑着,读到明确的
  • 应答 ACK :从机导通拉低 → 0(收到)
  • 非应答 NACK :从机截止放手 → 上拉电阻拉高 → 1(没收到)
  • 停止信号 :主机先拉低 SDA 再释放 SCL,最后释放 SDA → 上拉电阻把两线都拉回 高电平,总线回到空闲
  • 时钟延伸 :从机想拖时间,直接把 SCL 拉低不放,主机乖乖等着;从机放手后,上拉电阻把 SCL 拉高,通信继续

一句话总结:

场效应管负责"拉低",上拉电阻负责"拉高"。两个配合,才有了 I2C 总线上的 01STARTSTOPACKNACK、空闲和忙碌。没有上拉电阻,I2C 连一个稳定的 高电平 都生不出来。

(3) 起始与停止:通信的开关

I2C 总线上一切通信都由主机发起和结束,有两个特殊信号:

起始条件(STARTSCL(时钟线)高电平 期间,SDA(数据线)高电平 切换到 低电平

这个下降沿就像喊了一声 注意,要开始说话了!总线从空闲进入忙碌。

停止条件(STOPSCL(时钟线)高电平 期间,SDA(数据线)低电平 切换到 高电平

这个上升沿表示"我说完了,总线还给大家",总线回到空闲。

(4) 数据的有效性:什么时候能变,什么时候不能变

这是 I2C 最关键的一条规矩:

  • SCL(时钟线) 低电平 期间 :发送方可以改变 SDA(数据线) 上的数据,把下一位准备好
  • SCL(时钟线) 高电平 期间SDA(数据线) 必须保持稳定,接收方在这个期间采样读取数据

就像跳华尔兹------节拍间隙(SCL(时钟线)=0)换姿势,节拍正点(SCL(时钟线)=1)必须定住。数据在 SCL(时钟线) 上升沿之前准备好,下降沿之后才能改变,先传最高位(MSB),一位一位来。

(5) 发送与接收一个字节

发送一个字节主机从机):

  1. SCL(时钟线) 低电平 期间,主机把数据位放到 SDA(数据线) 上(高位先行
  2. 释放 SCL(时钟线),从机在 SCL(时钟线) 高电平 期间读取 SDA(数据线) 上的数据位
  3. 拉低 SCL(时钟线),准备下一位
  4. 重复 8 次,一个字节发送完成

接收一个字节(从机→主机):

  1. 主机先释放 SDA(数据线)(切为输入模式),让从机驱动
  2. SCL(时钟线) 低电平 期间,从机把数据位放到 SDA(数据线) 上(高位先行
  3. 释放 SCL(时钟线),主机在 SCL(时钟线) 高电平 期间读取 SDA(数据线) 上的数据位
  4. 拉低 SCL(时钟线),准备下一位
  5. 重复 8 次,一个字节接收完成

(6) 应答/非应答(ACK/NACK

每传输完一个字节(8 位数据),接收方必须反馈一位"收到没":

接收应答(主机发送完一字节后,等待从机反馈):

  1. 主机释放 SDA(数据线)(切为输入)
  2. 9 个时钟,从机把 SDA(数据线) 拉低 = ACK(收到),保持 = NACK(没收到或不想收了)
  3. 主机读取 SDA(数据线) 电平,判断应答状态

发送应答(主机接收完一字节后,给从机反馈):

  1. 主机在第 9 个时钟把 SDA(数据线) 拉低 = ACK(继续收),拉高 = NACK(不收了)
  2. 如果主机是接收方,收完最后一个字节后必须发 NACK,然后发停止条件

(7) 从机地址:叫谁谁答应

每个 I2C 从机都有一个唯一的 7 位地址。

主机在起始条件后,第一个字节就是7 位地址 + 1 位读写方向(0=写,1=读)。 所有从机都会听到这个地址,只有地址匹配的那个才会在第 9 个时钟拉低 SDA(应答),其余设备继续沉默。

例如 MPU6050 六轴传感器的 I2C 地址:

  • AD0 引脚接地(GND):11010000x68
  • AD0 引脚接电源(VCC):11010010x69

MPU6050 六轴传感器的 I2C 地址这个地址值怎么来的?

一句话:6 位是芯片出厂时写死的,最后 1 位由 AD0 引脚的电平决定。 ;打开 MPU6050 的数据手册,官方直接给出了地址格式:

c 复制代码
 1  1  0  1  0  0  AD0
↑                   ↑
 前6位出厂固化       最后1位由你接线决定
  • AD0 接地(GND)→ 最后一位 = 0 → 完整 7 位地址 = 1101000 = 0x68
  • AD0 接电源(VCC)→ 最后一位 = 1 → 完整 7 位地址 = 1101001 = 0x69

(8) 假设我的AD0是引脚P20那个流程应该怎么写

#define MPU6050_ADDR 0x68 看起来确实"一步到位 "了,但它其实跳过了硬件工程师才需要关心的接线环节。那我把这个过程彻底拆开给你看。

a. 先吃透那 7 位地址的"拼图规则"

MPU60507I2C 地址结构:

|--------|-------|-------|-------|-------|-------|-------|------------------|
| 位序 | Bit 6 | Bit 5 | Bit 4 | Bit 3 | Bit 2 | Bit 1 | Bit 0 |
| 数值 | 1 | 1 | 0 | 1 | 0 | 0 | AD0 |
| 来源 | 出厂固化(不可修改) |||||| 由 AD0 引脚电平决定 |

  • 6 110100:芯片出厂时就刻死了,你改不了。
  • 最低 1 AD0:芯片留了一个引脚出来,让你自己决定接高还是接低。
    • AD0 = 0 → 完整 7 位地址 = 0b1101000 = 0x68
    • AD0 = 1 → 完整 7 位地址 = 0b1101001 = 0x69
b. 问的核心:"AD0P20,完整流程是什么?"

假设你用的 MCUSTM32(或任何一款单片机),AD0 引脚接到了 P20

完整流程分两步:

第 1 步:硬件初始化(配置 P20 为输出,并输出低电平或高电平)

c 复制代码
/**
 * Function:    Mpu6050_Addr_Pin_Init
 * Description: CN:配置MPU6050的AD0引脚电平,决定I2C设备地址--EN:Configure MPU6050 AD0 pin level to determine I2C device address
 * Parameters:  level - CN:0=接地,地址0x68; 1=接VCC,地址0x69--EN:0=GND, address 0x68; 1=VCC, address 0x69
 * Return value:无
 */
void Mpu6050_Addr_Pin_Init(uint8_t level)
{
    GPIO_InitTypeDef GPIO_InitStruct;                           /*CN:定义GPIO初始化结构体--EN:Define GPIO init structure*/

    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOx, ENABLE);      /*CN:使能P20所在GPIO时钟(这里假设连在GPIOx)--EN:Enable GPIO clock for P20 (assumed GPIOx here)*/

    /*CN:配置P20为推挽输出--EN:Configure P20 as push-pull output*/
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_PP;               /*CN:推挽输出模式--EN:Push-pull output mode*/
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;                      /*CN:引脚0(P20的第0位)--EN:Pin 0 (bit 0 of P20)*/
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;              /*CN:设置GPIO速度--EN:Set GPIO speed*/
    GPIO_Init(GPIOx, &GPIO_InitStruct);                         /*CN:初始化GPIOx--EN:Initialize GPIOx*/

    /*CN:输出指定电平到AD0引脚--EN:Output specified level to AD0 pin*/
    if (level == 0)
    {
        GPIO_ResetBits(GPIOx, GPIO_Pin_0);                      /*CN:AD0接地→地址0x68--EN:AD0 to GND → address 0x68*/
    }
    else
    {
        GPIO_SetBits(GPIOx, GPIO_Pin_0);                        /*CN:AD0接VCC→地址0x69--EN:AD0 to VCC → address 0x69*/
    }
}

第 2 步:根据 AD0 的实际接法,在代码中定义正确的地址

这里有两种写法,你一看就明白 #define 的意义了。

写法 A:根据接线情况,手动选择(目前你的情景)

c 复制代码
#define MPU6050_AD0_PIN_LEVEL 0              /*CN:AD0引脚接GND--EN:AD0 pin connected to GND*/

#if MPU6050_AD0_PIN_LEVEL == 0
    #define MPU6050_ADDR 0x68                /*CN:AD0=0时设备地址为0x68--EN:Device address 0x68 when AD0=0*/
#else
    #define MPU6050_ADDR 0x69                /*CN:AD0=1时设备地址为0x69--EN:Device address 0x69 when AD0=1*/
#endif

写法 B:运行时动态读取 AD0 引脚电平,自动决定地址(更灵活)

c 复制代码
```c
/**
 * Function:    mpu6050_get_addr
 * Description: CN:读取AD0引脚电平,动态返回I2C设备地址--EN:Read AD0 pin level and dynamically return I2C device address
 * Parameters:  无
 * Return value:CN:0x68或0x69--EN:0x68 or 0x69
 */
uint8_t MOU6050_Get_Addr(void)
{
    /*CN:读取AD0引脚电平,返回对应I2C地址--EN:Read AD0 pin level and return corresponding I2C address*/
    if (GPIO_ReadInputDataBit(GPIOx, GPIO_Pin_0) == 0)          /*CN:STM32读取引脚状态函数--EN:STM32 read pin state function*/
    {
        return 0x68;                                             /*CN:AD0=0,返回0x68--EN:AD0=0, return 0x68*/
    }
    else
    {
        return 0x69;                                             /*CN:AD0=1,返回0x69--EN:AD0=1, return 0x69*/
    }
}
c. 所以,#define MPU6050_ADDR 0x68 到底有没有用?

当然有用,而且非常有用。

你之所以觉得它"起不到任何作用",是因为它已经帮你省略了上面的全部推导过程。它的作用就是把 "AD0=0 时地址是 0x68" 这个物理事实,用一行宏直接写死在代码里了------前提是你必须保证硬件上的 AD0 真的接了 GND

如果你把 AD0 接到了 VCC,还坚持用 #define MPU6050_ADDR 0x68,那芯片会直接不应答 ------因为它在总线上的名字已经是 0x69 了,你喊 0x68 它当然不理你。

d. 完整调用示例(结合你之前的软件 I2C)
c 复制代码
/**
 * Function:    mpu6050_read_whoami
 * Description: CN:读取MPU6050的WHO_AM_I寄存器以验证通信正常--EN:Read MPU6050 WHO_AM_I register to verify communication
 * Parameters:  无
 * Return value:CN:WHO_AM_I寄存器的值(正常为0x68)--EN:WHO_AM_I register value (normally 0x68)
 */
uint8_t mpu6050_read_whoami(void)
{
    uint8_t whoami = 0;

    i2c1_start();                                                  /*CN:发送起始条件--EN:Send start condition*/
    i2c1_send_byte((MPU6050_ADDR << 1) | 0);                      /*CN:发送写地址--EN:Send write address*/
    if (i2c1_wait_ack()) goto exit;                                /*CN:无应答则退出--EN:Exit if no ACK*/
    i2c1_send_byte(0x75);                                          /*CN:发送WHO_AM_I寄存器地址0x75--EN:Send WHO_AM_I register address 0x75*/
    if (i2c1_wait_ack()) goto exit;                                /*CN:无应答则退出--EN:Exit if no ACK*/

    i2c1_start();                                                  /*CN:重复起始条件--EN:Repeated start condition*/
    i2c1_send_byte((MPU6050_ADDR << 1) | 1);                      /*CN:发送读地址--EN:Send read address*/
    if (i2c1_wait_ack()) goto exit;                                /*CN:无应答则退出--EN:Exit if no ACK*/
    whoami = i2c1_recv_byte();                                     /*CN:接收WHO_AM_I值--EN:Receive WHO_AM_I value*/
    i2c1_ack_nack(I2C_NACK);                                       /*CN:主机发送NACK结束接收--EN:Master sends NACK to end reception*/

exit:
    i2c1_stop();                                                   /*CN:发送停止条件--EN:Send stop condition*/
    return whoami;                                                 /*CN:返回读取值--EN:Return read value*/
}
e. 总结:你要的"完整流程"
  1. 硬件上:MPU6050 的 AD0 引脚接到 P20。
  2. 初始化时 :调用 mpu6050_addr_pin_init(0),让 P20 输出低电平------AD0=0。
  3. 代码中#define MPU6050_ADDR 0x68 配合硬件 AD0=0。
  4. 通信时i2c1_send_byte((MPU6050_ADDR << 1) | 0) 自动拼出写地址 0xD0
  5. 读 WHO_AM_I :如果返回 0x68,恭喜,通信成功。

如果你把 P20 改接 VCC 并调用 mpu6050_addr_pin_init(1),对应的 #define 就应该改成 0x69------写地址就会自动变成 (0x69 << 1) | 0 = 0xD2

(9) 那代码里怎么写?

注意:7 位地址在 SDA 线上实际传输时,会左移 1 位,最低位补读写方向 ,变成 8 位:

AD0 7 位地址 写方向(8 位) 读方向(8 位)
GND 0x68 0xD0 0xD1
VCC 0x69 0xD2 0xD3

0xD0 = 0x68 << 1 | 0(左移一位,末位补 0=写)
0xD1 = 0x68 << 1 | 1(左移一位,末位补 1=读)

大多数库(ArduinoSTM32 HAL 等)内部会自动左移,你只需传入 7 位地址 0x68,不必手动算 0xD0

c 复制代码
#define MPU6050_ADDR 0x68                   /*CN:MPU6050 7位I2C设备地址--EN:MPU6050 7-bit I2C device address*/

/*CN:写操作:地址左移1位,最低位补0(写方向)--EN:Write operation: shift address left by 1, LSB = 0 (write)*/
i2c1_start();                              /*CN:发送起始条件--EN:Send start condition*/
i2c1_send_byte((MPU6050_ADDR << 1) | 0);   /*CN:发送写地址 0xD0--EN:Send write address 0xD0*/
i2c1_wait_ack();                           /*CN:等待从机应答--EN:Wait for slave ACK*/

/*CN:读操作:地址左移1位,最低位补1(读方向)--EN:Read operation: shift address left by 1, LSB = 1 (read)*/
i2c1_start();                              /*CN:发送起始条件--EN:Send start condition*/
i2c1_send_byte((MPU6050_ADDR << 1) | 1);   /*CN:发送读地址 0xD1--EN:Send read address 0xD1*/
i2c1_wait_ack();                           /*CN:等待从机应答--EN:Wait for slave ACK*/

(10)为什么厂商留一个 AD0 引脚?

同一个 I2C 总线上如果挂两个相同型号的传感器(比如无人机冗余 IMU),两个 0x68 会打架。AD0 引脚让你能在硬件上把其中一个地址改成 0x69,两条 IMU 就能挂在同一根总线上工作,不必换芯片型号。

(11) 为什么要左移一位

很多初学者在这里会懵:明明数据手册写着地址是 0x68,为什么代码里有时写 0x68,有时写 0xD0?多出来的那个 0 是哪来的?而且 0x68 里明明有个 68,怎么变成 1101000 的?完全对不上啊!

别急,我们一步步推,从根上把它讲透。

第一步:先搞清楚 0x68 到底是什么

0x 开头表示这是十六进制,不是十进制! 你心里想的"六十八"是十进制 68,但计算机眼里 0x68 是一个十六进制数,它俩完全不是一回事:

十六进制 十进制 二进制
MPU6050 地址 0x68 104 1101000
你想的数字 × 68 1000100

十六进制里,每个数字对应 4 位二进制:

  • 6 = 0110
  • 8 = 1000

所以 0x68 = 0110 1000 = 01101000(8 位二进制)。砍掉最高位的 0,就是 1101000(7 位) ,和 MPU6050 数据手册第 9 节写的 b110100X 完美吻合。

第二步:那为什么非要左移一位?

I2C 协议规定,起始条件之后主机发送的第一个字节 必须是 8 位,

格式如下:

|----|----|----|----|----|----|----|-------------|
| 位7 | 位6 | 位5 | 位4 | 位3 | 位2 | 位1 | 位0 |
| A6 | A5 | A4 | A3 | A2 | A1 | A0 | R/W |
| 7 位从机地址 ||||||| 读写位 |
| ||||||| 0 = 写 1 = 读 |

看到了吗?8 个位置里,左边 7 个放地址,最右边那个被读写方向 占用了。7 位地址被"挤"在左边,所以必须整体往左挪一位 ,把最低位腾出来给 R/W

第三步:以 0x68 为例,亲眼看着它怎么变

MPU6050 地址 = 0x68 = 11010007 位二进制)。

左移前(7 位地址):

|----|----|----|----|----|----|----|
| 位6 | 位5 | 位4 | 位3 | 位2 | 位1 | 位0 |
| 1 | 1 | 0 | 1 | 0 | 0 | 0 |
| 7 位地址 = 0x68 |||||||

左移一位后(8 位,最低位空出来给读写方向):

|----|----|----|----|----|----|----|----|
| 位7 | 位6 | 位5 | 位4 | 位3 | 位2 | 位1 | 位0 |
| 1 | 1 | 0 | 1 | 0 | 0 | 0 | ? |
| 空位留给 R/W(读写方向) ||||||||

补上读写位之后:

|----|----|----|----|----|----|----|----|----------|-----|
| 位7 | 位6 | 位5 | 位4 | 位3 | 位2 | 位1 | 位0 | 十六进制 | 含义 |
| 1 | 1 | 0 | 1 | 0 | 0 | 0 | 0 | 0xD0 | 写操作 |
| 1 | 1 | 0 | 1 | 0 | 0 | 0 | 1 | 0xD1 | 读操作 |

一目了然:7 位地址 1101000 稳稳待在左边,最低位 位0 就是读写方向的坑位。左移之后,写 = 0xD0,读 = 0xD1

第四步:用图直观感受

把整个过程用表格展示,一步步看着 0x68 怎么变成 0xD0

|--------|--------------|---------------------|----------|
| 步骤 | 内容 | 二进制 | 十六进制 |
| ① | 数据手册写的 7 位地址 | 1 1 0 1 0 0 0 | 0x68 |
| ② | 左移 1 位,空出最低位 | 1 1 0 1 0 0 0 0 | 0xD0 |
| ③ | 最低位补 0(写操作) | 1 1 0 1 0 0 0 0 | 0xD0 |
| ④ | 最低位补 1(读操作) | 1 1 0 1 0 0 0 1 | 0xD1 |

多出来的那个 0,就是左移之后空出来的最低位,补上读写方向。1101=D0000=00xD0,一气呵成。

第五步:那为什么有些库直接写 0x68 就行?

因为 ArduinoWire 库、STM32 的部分 HAL 封装,在函数内部偷偷帮你做了这个左移操作

c 复制代码
Wire.beginTransmission(0x68);  // 你写 0x68
// 库内部自动: (0x68 << 1) | 0 = 0xD0 → 发到 SDA 线上

但如果手搓软件 I2C,没有库帮你,就得自己算:

c 复制代码
i2c_send_byte((0x68 << 1) | 0);  // 手动左移+补写位 → 0xD0
i2c_send_byte((0x68 << 1) | 1);  // 手动左移+补读位 → 0xD1
一句话刻在脑子里
你嘴上叫的名字 实际在 SDA 线上跑的数据
0x68(7 位地址) 0xD0(写)/ 0xD1(读)
0x69(7 位地址) 0xD2(写)/ 0xD3(读)

0x68 是身份证号(7 位),左移一位是给读写方向腾位置,变成 0xD0 / 0xD1 才是实际发出去的 8 位数据。库帮你算好了,你就写 0x68;手搓裸机,就得自己左移。

(12) 典型通信时序

了解了以上所有要素,我们来看完整的读写流程:

指定地址写(往某个寄存器的某个地址写数据):

复制代码
START | 从机地址+W | ACK | 寄存器地址 | ACK | 数据 | ACK | STOP

主机先叫从机名字(写模式),告诉它要写的寄存器地址,然后发数据。

当前地址读(读从机当前地址指针指向的数据):

复制代码
START | 从机地址+R | ACK | 数据 | NACK | STOP

主机直接叫从机名字(读模式),从机返回当前地址的数据,主机收完后发 NACK 结束。

指定地址读(复合格式,先写地址再读):

复制代码
START | 从机地址+W | ACK | 寄存器地址 | ACK | REPEAT START | 从机地址+R | ACK | 数据 | NACK | STOP

先写模式告诉从机"我要读这个寄存器的地址",然后不 STOP,直接再来一个 START,切换成读模式取数据。

(13) 实现通信的两种手段

了解了协议之后,实现 I2C 通信有两条路:

  • 软件模拟 :纯用 GPIO 手动翻转 SCL(时钟线)SDA(数据线),按照时序要求一"位"一"位"地控制。优点是灵活,任何 MCU 都能用,不占硬件外设。缺点是容易受中断干扰,时序精度依赖代码,CPU 也被占用。硬件 I2C 不够时,这就是救命稻草。

  • 硬件 I2C :使用 MCU 内部集成的 I2C 外设,由硬件状态机自动完成时钟生成、起始终止条件、应答收发、数据收发等功能,减轻 CPU 负担。

  • 例如 STM32F103C8T6 就有 I2C1I2C2 两个外设,支持:

    • 多主机模型
    • 7 位/10 位地址模式
    • 标准速度(100 kHz)和快速(400 kHz
    • DMA 传输
    • 兼容 SMBus 协议

(14) 软件设计流程

如果选择软件模拟方式,我们需要实现以下基础函数,然后像搭积木一样组合成对具体器件的读写操作:

  1. 初始化 GPIO(开漏输出,释放总线)
  2. 等待总线空闲
  3. 产生起始信号
  4. 产生停止信号
  5. 发送应答/非应答
  6. 等待从机应答
  7. 发送一个字节
  8. 接收一个字节

下面一章我们就按这个流程,一步步手搓这些函数,并用它们来读写 AT24C02

三、核心概念通俗讲解(开漏、线与、华尔兹)

在动手之前,我们把协议背后最精妙的几个设计思想单独拎出来,用大白话讲透,这样代码就不是死记硬背了。

(1) 主与从:一个邮递员 + N 个家庭

  • 主设备:唯一有权发时钟的"邮递员",决定什么时候开始、结束谈话。
  • 从设备:老实等着的"住户",从不主动产生时钟,只听地址被叫到才应答。

总线上可以有多个主设备(多主模式),但同一时刻只能有一个主设备掌控时钟。我们初学者先从单主多从入手。

(2) 为什么用开漏,不用推挽?

如果大家都用强驱动(推挽),一个设备输出高、另一个输出低,那就直接短路,冒烟收场。

开漏输出 + 上拉电阻的妙处在于:

  • 任何设备只能把总线拉低,或者"放手"(高阻态)。
  • 总线由上拉电阻默认拉高,但只要有一个设备拉低,总线就变低。

这就是线与逻辑 :全家任何一个人把 SDA(数据线) 拉到地,整根线都变成低电平。这个特性让时钟延伸、应答、多主仲裁变得异常简单安全。

(3) SDA(数据线) 与 SCL(时钟线) 的华尔兹

数据变化只能在 SCL(时钟线) 低电平时进行,数据采样在 SCL(时钟线) 高电平时进行------就像双人舞:

  • 舞者只能在节拍空挡(SCL(时钟线)=0)换动作;
  • 节拍正点(SCL(时钟线)=1)时,姿势必须不动。

再配合起始(高电平时 SDA(数据线) 高→低)和停止(高电平时 SDA(数据线) 低→高)这两个特殊舞步,所有通信行为都被严格且优雅地定义了。

四、实践指南:手搓一个简易 I2C(软件模拟)

很多时候,一个廉价的 MCU 没有硬件 I2C或者硬件 I2C 出了名的难用又或者你只是想过一把裸编的瘾。 那么我们打开 GPIO用软件把 SCL(时钟线) 和 SDA(数据线) 的每一拍捏出来 ,手搓一个 I2C 主机。

下面以 STM32 为例(使用标准外设库),但思路同样适用于 ArduinoESP32 等平台。

(1) 准备工作

查看原理图确定引脚: 例如 PB6 - I2C1_SCL(时钟线)PB7 - I2C1_SDA(数据线)。 设置标准速度 100 kbps,半周期约 5 µs

定义一些宏,方便后续操作(开漏输出,高电平靠外部上拉):

c 复制代码
#define I2C1_SCL_GPIO   GPIOB                             /*CN:I2C1时钟线GPIO端口--EN:I2C1 clock line GPIO port*/
#define I2C1_SDA_GPIO   GPIOB                             /*CN:I2C1数据线GPIO端口--EN:I2C1 data line GPIO port*/
#define I2C1_SCL_PIN    GPIO_Pin_6                        /*CN:I2C1时钟线引脚--EN:I2C1 clock line pin*/
#define I2C1_SDA_PIN    GPIO_Pin_7                        /*CN:I2C1数据线引脚--EN:I2C1 data line pin*/

/*CN:开漏输出控制:拉低=输出0,释放=输出1(或切为输入)--EN:Open-drain control: pull low = output 0, release = output 1 (or switch to input)*/
#define I2C1_SCL_LOCK()    GPIO_ResetBits(I2C1_SCL_GPIO, I2C1_SCL_PIN) /*CN:拉低SCL--EN:Pull SCL low*/
#define I2C1_SCL_RELEASE() GPIO_SetBits(I2C1_SCL_GPIO, I2C1_SCL_PIN)   /*CN:释放SCL--EN:Release SCL*/
#define I2C1_SDA_LOCK()    GPIO_ResetBits(I2C1_SDA_GPIO, I2C1_SDA_PIN) /*CN:拉低SDA--EN:Pull SDA low*/
#define I2C1_SDA_RELEASE() GPIO_SetBits(I2C1_SDA_GPIO, I2C1_SDA_PIN)   /*CN:释放SDA--EN:Release SDA*/
#define I2C1_SDA_READ      GPIO_ReadInputDataBit(I2C1_SDA_GPIO, I2C1_SDA_PIN) /*CN:读取SDA电平--EN:Read SDA level*/

/*CN:方向切换(用于配合开漏输入读取)--EN:Direction switch (for open-drain input reading)*/
#define I2C1_SDA_OUT()     {GPIO_InitTypeDef gis; \
                            gis.GPIO_Mode = GPIO_Mode_Out_OD; \
                            gis.GPIO_Pin = I2C1_SDA_PIN; \
                            gis.GPIO_Speed = GPIO_Speed_50MHz; \
                            GPIO_Init(I2C1_SDA_GPIO, &gis);}    /*CN:将SDA设为开漏输出--EN:Set SDA as open-drain output*/
#define I2C1_SDA_IN()      {GPIO_InitTypeDef gis; \
                            gis.GPIO_Mode = GPIO_Mode_IN_FLOATING; \
                            gis.GPIO_Pin = I2C1_SDA_PIN; \
                            GPIO_Init(I2C1_SDA_GPIO, &gis);}    /*CN:将SDA设为浮空输入--EN:Set SDA as floating input*/

延时函数使用系统节拍定时或简单循环:

c 复制代码
void systick_delay_us(uint32_t us); /*CN:微秒延时函数(需自行实现)--EN:Microsecond delay function (to be implemented)*/

(2) 初始化

每个设备的 SCL(时钟线)SDA(数据线) 都要配置成开漏输出模式

c 复制代码
/**
 * Function:    I2c1_Init
 * Description: CN:初始化I2C1的GPIO为开漏输出,释放总线--EN:Initialize I2C1 GPIO as open-drain output, release bus
 * Parameters:  无
 * Return value:无
 */
void I2c1_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;                          /*CN:定义GPIO初始化结构体--EN:Define GPIO init structure*/
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);     /*CN:使能GPIOB时钟--EN:Enable GPIOB clock*/

    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_Out_OD;              /*CN:设置为开漏输出模式--EN:Set to open-drain output mode*/
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;       /*CN:配置引脚6和7--EN:Configure pin 6 and 7*/
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;             /*CN:设置GPIO速度--EN:Set GPIO speed*/
    GPIO_Init(GPIOB, &GPIO_InitStruct);                        /*CN:初始化GPIOB--EN:Initialize GPIOB*/

    /*CN:释放总线,进入空闲状态--EN:Release bus, enter idle state*/
    I2C1_SDA_RELEASE();                                        /*CN:释放SDA--EN:Release SDA*/
    I2C1_SCL_RELEASE();                                        /*CN:释放SCL--EN:Release SCL*/
}

(3) 等待总线空闲

协议规定

SDA(数据线)SCL(时钟线) 两条线同时为 高电平 时,总线处于空闲状态。此时所有设备都"放手"(输出级场效应管截止),由上拉电阻把电平拉到 高,谁都可以随时发起通信。

为什试图从场效应管开始解析

c 复制代码
/**
 * Function:    I2c1_Wait_Idle
 * Description: CN:等待I2C总线进入空闲状态(SCL与SDA同时为高)--EN:Wait for I2C bus to enter idle state (both SCL and SDA high)
 * Parameters:  无
 * Return value:CN:1表示空闲,0表示超时--EN:1 for idle, 0 for timeout
 */
uint8_t I2c1_Wait_Idle(void)
{
    uint32_t timeout = 0xFFFF;                                 /*CN:超时计数器--EN:Timeout counter*/
    while (--timeout && !(I2C1_SCL_READ & I2C1_SDA_READ));    /*CN:等待SCL和SDA都为高--EN:Wait for both SCL and SDA high*/
    return !!timeout;                                          /*CN:超时返回0,空闲返回1--EN:Return 0 for timeout, 1 for idle*/
}

(4) 起始条件

协议规定

起始条件(START):SCL(时钟线) 为 高电平 期间,SDA(数据线) 高电平 切换 到 低电平。这个下降沿就像喊了一声 注意,要开始说话了!总线从空闲进入忙碌。

c 复制代码
/**
 * Function:    I2c1_Start
 * Description: CN:产生I2C起始信号(SCL高时SDA高->低)--EN:Generate I2C start condition (SDA high->low while SCL high)
 * Parameters:  无
 * Return value:无
 */
void I2c1_Start(void)
{
    I2C1_SDA_OUT();          /*CN:确保SDA为输出--EN:Ensure SDA is output*/
    I2C1_SCL_RELEASE();      /*CN:释放SCL--EN:Release SCL*/
    I2C1_SDA_RELEASE();      /*CN:释放SDA--EN:Release SDA*/
    systick_delay_us(5);     /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SDA_LOCK();         /*CN:SDA下降沿,起始信号--EN:SDA falling edge, start signal*/
    systick_delay_us(5);     /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SCL_LOCK();         /*CN:钳住SCL,后续可放数据--EN:Clamp SCL low, ready for data*/
}

(5) 停止条件

协议规定

停止条件(STOP):SCL(时钟线) 为 高电平 期间,SDA(数据线)从 低电平 切换到 高电平。这个上升沿表示"我说完了,总线还给大家",总线回到空闲。

c 复制代码
/**
 * Function:    I2c1_Stop
 * Description: CN:产生I2C停止信号(SCL高时SDA低->高)--EN:Generate I2C stop condition (SDA low->high while SCL high)
 * Parameters:  无
 * Return value:无
 */
void I2c1_Stop(void)
{
    I2C1_SDA_OUT();          /*CN:确保SDA为输出--EN:Ensure SDA is output*/
    I2C1_SCL_LOCK();         /*CN:钳住SCL--EN:Clamp SCL low*/
    I2C1_SDA_LOCK();         /*CN:拉低SDA--EN:Pull SDA low*/
    I2C1_SCL_RELEASE();      /*CN:释放SCL--EN:Release SCL*/
    systick_delay_us(5);     /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SDA_RELEASE();      /*CN:SDA上升沿,停止信号--EN:SDA rising edge, stop signal*/
    systick_delay_us(5);     /*CN:延时5微秒--EN:Delay 5 microseconds*/
}

(6) 等待应答

协议规定

1、接收应答(主机发送完一字节后,等待从机反馈):

2、主机释放 SDA(数据线)(切为输入)

3、第 9 个时钟,从机把 SDA(数据线) 拉低 = ACK(收到),保持 高 = NACK(没收到或不想收了)

4、主机读取 SDA(数据线) 电平,判断应答状态

c 复制代码
/**
 * Function:    I2c1_Wait_Ack
 * Description: CN:主机等待从机应答(第9个时钟脉冲)--EN:Master waits for slave ACK (9th clock pulse)
 * Parameters:  无
 * Return value:CN:0=ACK, 1=NACK--EN:0=ACK, 1=NACK
 */
uint8_t I2c1_Wait_Ack(void)
{
    uint8_t ack;
    I2C1_SDA_IN();                     /*CN:主机释放SDA,切为输入--EN:Master releases SDA, switch to input*/
    I2C1_SCL_LOCK();                   /*CN:钳住SCL--EN:Clamp SCL low*/
    systick_delay_us(5);               /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SCL_RELEASE();                /*CN:第9个时钟高电平,从机拉低SDA表示ACK--EN:9th clock high, slave pulls SDA low for ACK*/
    ack = I2C1_SDA_READ;               /*CN:读取应答位--EN:Read ACK bit*/
    I2C1_SCL_LOCK();                   /*CN:钳住SCL--EN:Clamp SCL low*/
    systick_delay_us(5);               /*CN:延时5微秒--EN:Delay 5 microseconds*/
    return ack;                        /*CN:0=ACK, 1=NACK--EN:0=ACK, 1=NACK*/
}

(7) 主机发送应答

协议规定

1、发送应答(主机接收完一字节后,给从机反馈):

2、主机在第 9 个时钟把 SDA(数据线) 拉低 = ACK(继续收)拉高 = NACK(不收了)

3、如果主机是接收方,收完最后一个字节后必须发 NACK,然后发停止条件

c 复制代码
/**
 * Function:    I2c1_Ack_Nack
 * Description: CN:主机发送应答/非应答位--EN:Master sends ACK/NACK bit
 * Parameters:  ack_nack - CN:I2C_ACK(0)或I2C_NACK(1)--EN:I2C_ACK(0) or I2C_NACK(1)
 * Return value:无
 */
void I2c1_Ack_Nack(uint8_t ack_nack)
{
    I2C1_SDA_OUT();                    /*CN:确保SDA为输出--EN:Ensure SDA is output*/
    I2C1_SCL_LOCK();                   /*CN:钳住SCL--EN:Clamp SCL low*/
    if (ack_nack == I2C_ACK)
    {
        I2C1_SDA_LOCK();               /*CN:ACK:拉低--EN:ACK: pull low*/
    }
    else
    {
        I2C1_SDA_RELEASE();            /*CN:NACK:释放--EN:NACK: release*/
    }
    systick_delay_us(5);               /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SCL_RELEASE();                /*CN:释放SCL--EN:Release SCL*/
    systick_delay_us(5);               /*CN:延时5微秒--EN:Delay 5 microseconds*/
    I2C1_SCL_LOCK();                   /*CN:钳住SCL--EN:Clamp SCL low*/
    I2C1_SDA_RELEASE();                /*CN:释放SDA--EN:Release SDA*/
}

(8) 发送字节

协议规定

发送一个字节(主机→从机):

1、SCL(时钟线)低电平 期间,主机把数据位放到 SDA(数据线) 上(高位先行) 释放 SCL(时钟线),从机在 SCL(时钟线) 高电平

2、 期间读取 SDA(数据线) 上的数据位 拉低 SCL(时钟线),准备下一位 重复8次,一个字节发送完成

c 复制代码
/**
 * Function:    I2c1_Send_Byte
 * Description: CN:向I2C总线发送一个字节(MSB在前)--EN:Send one byte to I2C bus (MSB first)
 * Parameters:  data - CN:要发送的8位数据--EN:8-bit data to send
 * Return value:无
 */
void I2c1_Send_Byte(uint8_t data)
{
    int i;
    I2C1_SDA_OUT();                           /*CN:确保SDA为输出--EN:Ensure SDA is output*/
    for (i = 7; i >= 0; i--)
    {
        I2C1_SCL_LOCK();                      /*CN:低电平,可以改数据--EN:Low level, can change data*/
        if (data & (1 << i))
        {
            I2C1_SDA_RELEASE();               /*CN:数据位1,释放SDA--EN:Data bit 1, release SDA*/
        }
        else
        {
            I2C1_SDA_LOCK();                  /*CN:数据位0,拉低SDA--EN:Data bit 0, pull SDA low*/
        }
        systick_delay_us(5);                  /*CN:延时5微秒--EN:Delay 5 microseconds*/
        I2C1_SCL_RELEASE();                   /*CN:高电平,从机采样--EN:High level, slave samples*/
        systick_delay_us(5);                  /*CN:延时5微秒--EN:Delay 5 microseconds*/
    }
    I2C1_SCL_LOCK();                          /*CN:钳住SCL--EN:Clamp SCL low*/
    I2C1_SDA_RELEASE();                       /*CN:释放总线,等待从机ACK--EN:Release bus, wait for slave ACK*/
}

(9) 接收字节

协议规定

接收一个字节(从机→主机):

1、主机先释放 SDA(数据线)(切为输入模式),让从机驱动

2、SCL(时钟线) 低电平 期间,从机把数据位放到SDA(数据线)上(高位先行)

3、释放 SCL(时钟线),主机在 SCL(时钟线) 高电平 期间读取 SDA(数据线) 上的数据位

4、拉低 SCL(时钟线),准备下一位

5、重复 8 次,一个字节接收完成

c 复制代码
/**
 * Function:    I2c1_Recv_Byte
 * Description: CN:从I2C总线接收一个字节(MSB在前)--EN:Receive one byte from I2C bus (MSB first)
 * Parameters:  无
 * Return value:CN:接收到的8位数据--EN:8-bit received data
 */
uint8_t I2c1_Recv_Byte(void)
{
    int i;
    uint8_t data = 0;
    I2C1_SDA_IN();                             /*CN:主机释放SDA--EN:Master releases SDA*/
    for (i = 0; i < 8; i++)
    {
        I2C1_SCL_LOCK();                       /*CN:低电平,从机可以改数据--EN:Low level, slave can change data*/
        systick_delay_us(5);                   /*CN:延时5微秒--EN:Delay 5 microseconds*/
        I2C1_SCL_RELEASE();                    /*CN:高电平,主机采样--EN:High level, master samples*/
        data <<= 1;                            /*CN:数据左移一位--EN:Shift data left one bit*/
        if (I2C1_SDA_READ)
        {
            data |= 1;                         /*CN:读到高电平,置位最低位--EN:Read high level, set LSB*/
        }
        systick_delay_us(5);                   /*CN:延时5微秒--EN:Delay 5 microseconds*/
    }
    return data;                               /*CN:返回接收到的数据--EN:Return received data*/
}

(10) 整合:扫描 I2C 总线设备

用上面函数可以快速扫描总线上挂了哪些设备:

c 复制代码
/**
 * Function:    I2c1_Scan
 * Description: CN:扫描I2C总线上的所有设备地址--EN:Scan all device addresses on I2C bus
 * Parameters:  无
 * Return value:无
 */
void I2c1_Scan(void)
{
    printf("Scanning I2C devices...\n");               /*CN:打印扫描起始信息--EN:Print scan start message*/

    for (uint8_t addr = 1; addr < 127; addr++)
    {
        I2c1_Start();                                  /*CN:发送起始条件--EN:Send start condition*/
        I2c1_Send_Byte((addr << 1) | 0);               /*CN:发送7位地址+写位--EN:Send 7-bit address + write bit*/
        if (I2c1_Wait_Ack() == I2C_ACK)                /*CN:检查是否有设备应答--EN:Check if device acknowledges*/
        {
            printf("Device found at 0x%02X\n", addr);  /*CN:打印找到的设备地址--EN:Print found device address*/
        }
        I2c1_Stop();                                   /*CN:发送停止条件--EN:Send stop condition*/
    }
}

(11) 实例:AT24C02 的页写与随机读

有了上面的基础驱动,读写 AT24C02 就简单了。我们按照器件手册的时序来写。

a. 页写(注意页内回卷)

AT24C02 一页 8 字节,写入地址若跨页会自动回卷到页首,所以实际写入时要计算页内剩余空间。

c 复制代码
/**
 * Function:    at24c02_page_write
 * Description: CN:AT24C02页写入(注意8字节页边界回卷)--EN:AT24C02 page write (note 8-byte page boundary rollover)
 * Parameters:  buf  - CN:数据缓冲区--EN:Data buffer
 *              size - CN:要写入的字节数--EN:Number of bytes to write
 *              addr - CN:片内起始地址--EN:Internal start address
 * Return value:CN:成功写入的字节数,失败返回-1--EN:Number of bytes written, -1 on failure
 */
int at24c02_page_write(char *buf, int size, uint8_t addr)
{
    int ret = -1, i, remain;
    if (!buf || size <= 0)
    {
        return -1;                                     /*CN:参数检查失败--EN:Parameter check failed*/
    }

    remain = 8 - (addr % 8);                           /*CN:AT24C02页大小=8,计算当前页剩余空间--EN:AT24C02 page size=8, calculate remaining space on current page*/
    if (size > remain)
    {
        size = remain;                                 /*CN:限制写入量不超页边界--EN:Limit write size to page boundary*/
    }

    if (!i2c1_wait_idle())
    {
        return -1;                                     /*CN:等待总线空闲超时--EN:Bus idle wait timeout*/
    }
    i2c1_start();                                      /*CN:发送起始条件--EN:Send start condition*/
    i2c1_send_byte(0xA0);                              /*CN:设备地址+写位(具体看硬件连接)--EN:Device address + write bit (depending on hardware connection)*/
    if (i2c1_wait_ack())
    {
        goto exit;                                     /*CN:无应答,退出--EN:No ACK, exit*/
    }

    i2c1_send_byte(addr);                              /*CN:发送片内地址--EN:Send internal address*/
    if (i2c1_wait_ack())
    {
        goto exit;                                     /*CN:无应答,退出--EN:No ACK, exit*/
    }

    for (i = 0; i < size; i++)
    {
        i2c1_send_byte(buf[i]);                        /*CN:发送数据字节--EN:Send data byte*/
        if (i2c1_wait_ack())
        {
            break;                                     /*CN:无应答,停止发送--EN:No ACK, stop sending*/
        }
    }
    ret = i;                                           /*CN:记录成功写入的字节数--EN:Record number of bytes successfully written*/
exit:
    i2c1_stop();                                       /*CN:发送停止条件--EN:Send stop condition*/
    systick_delay_ms(5);                               /*CN:等待内部写周期(5毫秒)--EN:Wait for internal write cycle (5 ms)*/
    return ret;                                        /*CN:返回写入字节数--EN:Return bytes written*/
}
b. 随机读

采用复合格式:先写片内地址,再重新起始读。

c 复制代码
/**
 * Function:    at24c02_read
 * Description: CN:AT24C02随机读取--EN:AT24C02 random read
 * Parameters:  buf  - CN:数据缓冲区--EN:Data buffer
 *              size - CN:要读取的字节数--EN:Number of bytes to read
 *              addr - CN:片内起始地址--EN:Internal start address
 * Return value:CN:成功读取的字节数,失败返回-1--EN:Number of bytes read, -1 on failure
 */
int at24c02_read(char *buf, int size, uint8_t addr)
{
    int ret = -1, i;
    if (!buf || size <= 0)
    {
        return -1;                                     /*CN:参数检查失败--EN:Parameter check failed*/
    }

    if (!i2c1_wait_idle())
    {
        return -1;                                     /*CN:等待总线空闲超时--EN:Bus idle wait timeout*/
    }
    i2c1_start();                                      /*CN:发送起始条件--EN:Send start condition*/
    i2c1_send_byte(0xA0);                              /*CN:设备地址+写位--EN:Device address + write bit*/
    if (i2c1_wait_ack())
    {
        goto exit;                                     /*CN:无应答,退出--EN:No ACK, exit*/
    }
    i2c1_send_byte(addr);                              /*CN:发送片内地址--EN:Send internal address*/
    if (i2c1_wait_ack())
    {
        goto exit;                                     /*CN:无应答,退出--EN:No ACK, exit*/
    }

    i2c1_start();                                      /*CN:重复起始条件--EN:Repeated start condition*/
    i2c1_send_byte(0xA1);                              /*CN:设备地址+读位--EN:Device address + read bit*/
    if (i2c1_wait_ack())
    {
        goto exit;                                     /*CN:无应答,退出--EN:No ACK, exit*/
    }

    for (i = 0; i < size - 1; i++)
    {
        buf[i] = i2c1_recv_byte();                     /*CN:接收数据字节--EN:Receive data byte*/
        i2c1_ack_nack(I2C_ACK);                        /*CN:主机发送ACK--EN:Master sends ACK*/
    }
    buf[i] = i2c1_recv_byte();                         /*CN:接收最后一个字节--EN:Receive last byte*/
    i2c1_ack_nack(I2C_NACK);                           /*CN:主机发送NACK--EN:Master sends NACK*/
    ret = size;                                        /*CN:记录成功读取的字节数--EN:Record number of bytes read*/
exit:
    i2c1_stop();                                       /*CN:发送停止条件--EN:Send stop condition*/
    return ret;                                        /*CN:返回读取字节数--EN:Return bytes read*/
}
c. 简单测试程序
c 复制代码
/**
 * Function:    main
 * Description: CN:AT24C02读写测试主程序--EN:AT24C02 read/write test main program
 * Parameters:  无
 * Return value:CN:0--EN:0
 */
int main(void)
{
    /*CN:初始化I2C1--EN:Initialize I2C1*/
    i2c1_init();                                       /*CN:初始化I2C1--EN:Initialize I2C1*/
    printf("1 - 录入密码, 2 - 验证密码, 3 - 测试回滚\n"); /*CN:打印菜单--EN:Print menu*/
    int sel;
    scanf("%d", &sel);                                 /*CN:读取用户选择--EN:Read user selection*/
    if (sel == 1)
    {
        char pwd[10] = "";
        scanf("%s", pwd);                              /*CN:读取密码--EN:Read password*/
        int cnt = at24c02_page_write(pwd, strlen(pwd), 0); /*CN:写入密码--EN:Write password*/
        printf("写入 %d 字节\n", cnt);                  /*CN:打印写入字节数--EN:Print bytes written*/
    }
    else if (sel == 2)
    {
        char pwd[10] = "", val[10] = "";
        scanf("%s", pwd);                              /*CN:读取输入密码--EN:Read input password*/
        int cnt = at24c02_read(val, 6, 0);              /*CN:读取存储的密码--EN:Read stored password*/
        printf("读取 %d 字节: %s\n", cnt, val);         /*CN:打印读取结果--EN:Print read result*/
        printf(strcmp(pwd, val) == 0 ? "通过\n" : "失败\n"); /*CN:验证密码--EN:Verify password*/
    }
    else if (sel == 3)
    {
        char buf[20] = "";
        scanf("%s", buf);                              /*CN:读取测试数据--EN:Read test data*/
        int cnt = at24c02_page_write(buf, strlen(buf), 4); /*CN:从第1页后4字节写--EN:Write from 4th byte of page 1*/
        printf("写入 %d 字节\n", cnt);                  /*CN:打印写入字节数--EN:Print bytes written*/
        cnt = at24c02_read(buf, 8, 0);                  /*CN:整页读取--EN:Read entire page*/
        printf("整页读取: %s\n", buf);                  /*CN:打印整页内容--EN:Print entire page content*/
    }
    return 0;                                          /*CN:程序结束--EN:Program end*/
}

五、现代的变体:它们还叫"I2"吗?

I2C 这个老骨干,在几十年里开枝散叶,演进出了好几个专精领域和直接继承者。

(1) I2S:不是控制总线,是音频的"高速公路"

I2S 全称 Inter-IC Sound (集成芯片间音频总线),同样由飞利浦发明(1986年)。

常有人被名字迷惑,其实它与 I2C 用途完全不同

特性 I2C I2S
用途 控制、配置、数据交换 数字音频流传输
时钟 单一 SCL(时钟线) 位时钟 SCK + 字选择 WS
数据线 1 根 SDA(数据线),双向 1 根或多根 SD,单向
信号结构 起始+地址+数据+应答 连续音频帧,左右通道交替
速度 最高 5 Mbit/s 典型 几十 MHz

I2S 的 WS 标明左右通道,SCK 移入数据,SD 传输样本。在音频系统中,经常用 I2C 配置 Codec 寄存器,用 I2S 传输音频数据,两条总线完美配合。

(2) SMBus 和 PMBus:工业电源的"听话版本"

SMBus (System Management Bus)是 Intel 1995 年在 I2C 基础上定义的,用于 PC 主板管理电池、温度、风扇等,加入了超时、错误校验等严格规范。

PMBus (Power Management Bus)是 SMBus 的扩展,专用于电源管理,把电压、电流、温度都包装成标准命令。

它们就像是"穿了西装的 I2C",更严谨,更适合工业环境。

(3) I3C:I2C 的真正接班人

I3C(Improved Inter-Integrated Circuit)由 MIPI 联盟推出,向下兼容一部分 I2C 设备,但进行了脱胎换骨的升级:

  • 动态地址分配,彻底告别地址冲突
  • 带内中断,从设备可以主动通知主机(不用再轮询!)
  • 速度大幅提升:SDR 模式 12.5 Mbps,HDR 模式更高
  • 支持热插拔,面向传感器生态

现在很多手机的传感器中枢就用 I3C 降功耗、提响应,I2C → I3C,就像从乡间小路开上了八车道智慧高速。

六、结语:手搓一次,理解十年

写到这里,你可能会发现,I2C 的寿命之所以绵长,是因为它的设计足够"傻"又足够"巧":

傻到可以用几个 GPIO 硬生生模拟出来,巧到能支撑无数设备在同一总线上愉快相处。

手搓 I2C 的意义,不在于取代硬件,而在于拆掉总线协议的黑箱,建立起从"波形电平"到"读传感器数值"的完整认知链。

下回你再在示波器上看到 SDA(数据线) 上的那一串抖动,你就会会心一笑:"嗯,这是应答位,那个小脉宽,是时钟延伸。"

愿你在嵌入式的路上,永远保有一根地气、一颗匠心。

扩展阅读:

  • NXP UM10204: I2C-bus specification and user manual
  • I2S bus specification (Philips, 1986)
  • MIPI I3C Basic Specification
  • 《Mastering the I2C Bus》 (Vincent Himpe)
相关推荐
机器人图像处理1 小时前
6-自动白平衡(灰度世界算法)
opencv·算法·相机
Dr.Zeus1 小时前
从电芯到系统:BMS算法视角下的电池热管理深度解析作者署名
算法·能源
偷懒下载原神1 小时前
【网络编程】UDP协议
网络·网络协议·udp
yyuuuzz1 小时前
aws亚马逊云上部署常见问题梳理
运维·服务器·网络·数据库·云计算·aws
ulias2121 小时前
leetcode热题 - 6
linux·算法·leetcode
七颗糖很甜1 小时前
卫星通信遇到“太空天气”会怎样---电离层闪烁对卫星通信的影响
大数据·python·算法
CHANG_THE_WORLD1 小时前
从0到1 编写HexDump工具
单片机·嵌入式硬件
ghie90901 小时前
4轴运动控制源代码(STM32 + GRBL 1.1移植版)
stm32·单片机·嵌入式硬件
fpcc1 小时前
跟我学C++中级篇—Linux文件读写的分析
linux·c++