文章目录
- 一、发展历史:从飞利浦实验室到你口袋里的手机
-
- [二、I2C 协议详解(技术规范)](#二、I2C 协议详解(技术规范))
-
-
- [a. 硬件连接:两根线串起所有设备](#a. 硬件连接:两根线串起所有设备)
- [b. 那我设备设计的时候都设计成默认高电平不就好了?](#b. 那我设备设计的时候都设计成默认高电平不就好了?)
- [c.那为什么不把 `AD0` 引脚默认设计成内部上拉到 `VCC`,省得外部再接线?](#c.那为什么不把
AD0引脚默认设计成内部上拉到VCC,省得外部再接线?)
- [(2) 空闲状态:总线什么时候算"闲着"](#(2) 空闲状态:总线什么时候算"闲着")
-
- [a. **为什么是"放手"而不是"主动输出高电平"?这和场效应管的特性直接相关。**](#a. 为什么是"放手"而不是"主动输出高电平"?这和场效应管的特性直接相关。)
- b.**所以"空闲状态是高电平"到底是什么意思?**
- c.一张表彻底讲清楚
- [d. 一句话刻进脑子里](#d. 一句话刻进脑子里)
- [(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. 问的核心:"
AD0接P20,完整流程是什么?") - [c. 所以,`#define MPU6050_ADDR 0x68` 到底有没有用?](#define MPU6050_ADDR 0x68` 到底有没有用?)
- [d. 完整调用示例(结合你之前的软件 I2C)](#d. 完整调用示例(结合你之前的软件 I2C))
- [e. 总结:你要的"完整流程"](#e. 总结:你要的"完整流程")
- [(9) 那代码里怎么写?](#(9) 那代码里怎么写?)
- [(10)**为什么厂商留一个 `AD0` 引脚?**](#(10)为什么厂商留一个
AD0引脚?) - [(11) 为什么要左移一位](#(11) 为什么要左移一位)
-
- [第一步:先搞清楚 `0x68` 到底是什么](#第一步:先搞清楚
0x68到底是什么) - 第二步:那为什么非要左移一位?
- [第三步:以 `0x68` 为例,亲眼看着它怎么变](#第三步:以
0x68为例,亲眼看着它怎么变) - 第四步:用图直观感受
- [第五步:那为什么有些库直接写 `0x68` 就行?](#第五步:那为什么有些库直接写
0x68就行?) - 一句话刻在脑子里
- [第一步:先搞清楚 `0x68` 到底是什么](#第一步:先搞清楚
- [(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Ω左右- 总线默认被上拉电阻拉到
高电平,任何设备只能拉低,不能强行拉高
为什么必须是开漏输出?
因为如果两个设备一个输出
高、一个输出低,推挽模式会直接短路烧毁。开漏输出保证任何设备只能"拉低"或"放手",这就是线与逻辑 ,谁拉低总线就变低,安全又巧妙。
以常见的
EEPROMAT24C02为例,它与MCU连接如下:
SCL(时钟线)→MCU的I2C时钟引脚(如PB6)SDA(数据线)→MCU的I2C数据引脚(如PB7)- 两个引脚各通过
4.7kΩ电阻上拉到VCC- 芯片的
A0/A1/A2地址引脚接地或接VCC决定其设备地址
b. 那我设备设计的时候都设计成默认高电平不就好了?
你还真是个天才,一开始我也这么想------所有设备地址全部设计成一样的,不就不用纠结了?
但现实啪啪打脸:如果同一条 I2C 总线上挂两个相同型号的传感器,两个相同的地址会直接在总线上"打架",谁也分不清主机在叫谁,整个通信崩溃。
所以厂商才留了一个 AD0 引脚,让你能在硬件上区分两个相同芯片 :一个接地(0x68),一个接 VCC(0x69)各叫各的,互不干扰。
c.那为什么不把 AD0 引脚默认设计成内部上拉到 VCC,省得外部再接线?
这就涉及到芯片内部场效应管(MOSFET)的一个关键特性了:场效应管的栅极(Gate)输入阻抗极高,几乎是绝缘的 。这意味着:

-
如果
AD0引脚内部上拉到VCC:引脚悬空时,栅极积累的电荷没有泄放通路,电平会不确定 ------可能是高,可能是低,甚至悬在半空中来回跳。这会导致芯片上电时地址随机变,同一个板子这次认0x68,下次认0x69,你还怎么调试? -
所以芯片设计时,地址引脚默认是"必须由外部明确给定电平" 。你接
GND就是0,接VCC就是1,没有"悬空"这个选项。这是为了保证上电瞬间地址就确定,绝不模棱两可。
一句话总结:
场效应管输入阻抗太高,悬空时电平不确定,地址会乱跳。所以
AD0必须由你外部明确接线,不能靠内部默认上拉来偷懒。
(2) 空闲状态:总线什么时候算"闲着"
当 SDA(数据线) 和 SCL(时钟线) 两条线同时为 高电平 时,总线处于空闲状态。

a. 为什么是"放手"而不是"主动输出高电平"?这和场效应管的特性直接相关。
I2C 总线上所有设备的 SCL 和 SDA 引脚,内部都用的是开漏输出 (Open-Drain),驱动元件是 N 沟道场效应管(MOSFET):
- 栅极(
Gate)= 高电平时 :场效应管导通,SDA/SCL被拉到GND,输出低电平→ 这是"我要发0"时的动作- 栅极(
Gate)= 低电平时 :场效应管截止 ,引脚对外呈高阻态 ,相当于"这根线我不管了" → 这是"我要发1"或者"我闭嘴"时的动作,此时总线电平由上拉电阻决定,自然为高电平
看到没?场效应管只能主动拉低,不能主动拉高。
你可能会困惑:"高电平"和"场效应管导通输出低电平"怎么同时存在?其实它们是结果 和手段的关系,反着来的:
|---------------|---------------------|------------|---------------|
| 你想让总线输出什么 | 场效应管在干什么 | 总线实际电平 | 通俗理解 |
| 低电平 | 导通 (把线拉到 GND) | 低 | 我主动把线踩到地上 |
| 高电平 | 截止(高阻态,啥也不干) | 高 | 我放手,上拉电阻把线拽上去 |
关键就在这个高阻态:当所有设备都"放手"(场效应管全部截止),总线上的电平由谁决定?
答案是外部上拉电阻 ,它把总线拉到
VCC,所以空闲时总线是高电平。
b.所以"空闲状态是高电平"到底是什么意思?
空闲状态 = 所有设备都放手 (场效应管全部截止)→ 没人拉低总线 → 上拉电阻把线拉到
VCC→ 总线呈现高电平。
"高电平"不是谁主动输出的,而是"大家都不拉低"的结果。
这正是 I2C 总线实现线与逻辑的物理基础------只要有一个设备拉低,整根线就变低;所有设备都放手,线才恢复高。
c.一张表彻底讲清楚
|----------|------------|----------|------------|
| 场景 | 场效应管状态 | 总线电平 | 谁在决定电平 |
| 设备输出 0 | 导通 | 低 | 设备主动拉低 |
| 设备输出 1 | 截止(高阻态) | 高 | 上拉电阻被动拉高 |
| 总线空闲 | 所有设备都截止 | 高 | 上拉电阻被动拉高 |
d. 一句话刻进脑子里

场效应管是个**"只能拉低不能拉高"的开关。高电平从来不是它输出的,而是它 "不拉低"时,上拉电阻把线抬上去的。空闲状态就是所有人都不拉低,总线自然被拉高。所以,这也是为什么 I2C 总线上必须加上拉电阻的原因: 正因为场效应管只能拉低不能拉高,如果总线上没有上拉电阻,当所有设备都"放手"时,SDA 和 SCL 就变成悬空状态**------电平不确定,可能是高、可能是低、可能来回跳。整条总线的通信直接废掉。
加上上拉电阻之后,一切就通了:
- 空闲状态 :所有人放手 → 上拉电阻把线拉到
高电平,总线明确处于"空闲"- 起始信号 :主机把
SDA拉低 → 总线从"闲"变"忙",通信开始- 发送数据
0:设备导通场效应管,把线拉到低电平- 发送数据
1:设备截止场效应管,放手 → 上拉电阻把线拉回高电平- 接收数据 :一方放手让出总线,另一方驱动;读取时线被上拉电阻撑着,读到明确的
高或低- 应答
ACK:从机导通拉低 →0(收到)- 非应答
NACK:从机截止放手 → 上拉电阻拉高 →1(没收到)- 停止信号 :主机先拉低
SDA再释放SCL,最后释放SDA→ 上拉电阻把两线都拉回高电平,总线回到空闲- 时钟延伸 :从机想拖时间,直接把
SCL拉低不放,主机乖乖等着;从机放手后,上拉电阻把SCL拉高,通信继续
一句话总结:
场效应管负责"拉低",上拉电阻负责"拉高"。两个配合,才有了
I2C总线上的0和1、START和STOP、ACK和NACK、空闲和忙碌。没有上拉电阻,I2C连一个稳定的高电平都生不出来。
(3) 起始与停止:通信的开关
I2C 总线上一切通信都由主机发起和结束,有两个特殊信号:
起始条件(START) :SCL(时钟线) 为 高电平 期间,SDA(数据线) 从 高电平 切换到 低电平。
这个下降沿就像喊了一声 注意,要开始说话了!总线从空闲进入忙碌。

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

(4) 数据的有效性:什么时候能变,什么时候不能变
这是 I2C 最关键的一条规矩:
SCL(时钟线)低电平期间 :发送方可以改变SDA(数据线)上的数据,把下一位准备好SCL(时钟线)高电平期间 :SDA(数据线)必须保持稳定,接收方在这个期间采样读取数据
就像跳华尔兹------节拍间隙(SCL(时钟线)=0)换姿势,节拍正点(SCL(时钟线)=1)必须定住。数据在 SCL(时钟线) 上升沿之前准备好,下降沿之后才能改变,先传最高位(MSB),一位一位来。
(5) 发送与接收一个字节
发送一个字节 (主机→从机):
SCL(时钟线)低电平 期间,主机把数据位放到SDA(数据线)上(高位先行)- 释放
SCL(时钟线),从机在SCL(时钟线)高电平期间读取SDA(数据线)上的数据位 - 拉低
SCL(时钟线),准备下一位 - 重复
8次,一个字节发送完成

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

(6) 应答/非应答(ACK/NACK)
每传输完一个字节(8 位数据),接收方必须反馈一位"收到没":
接收应答(主机发送完一字节后,等待从机反馈):
- 主机释放
SDA(数据线)(切为输入) - 第
9个时钟,从机把SDA(数据线)拉低 =ACK(收到),保持高=NACK(没收到或不想收了) - 主机读取
SDA(数据线)电平,判断应答状态

发送应答(主机接收完一字节后,给从机反馈):
- 主机在第
9个时钟把SDA(数据线)拉低 =ACK(继续收),拉高 =NACK(不收了) - 如果主机是接收方,收完最后一个字节后必须发
NACK,然后发停止条件

(7) 从机地址:叫谁谁答应
每个 I2C 从机都有一个唯一的 7 位地址。
主机在起始条件后,第一个字节就是7 位地址 + 1 位读写方向(0=写,1=读)。 所有从机都会听到这个地址,只有地址匹配的那个才会在第 9 个时钟拉低 SDA(应答),其余设备继续沉默。
例如 MPU6050 六轴传感器的 I2C 地址:
AD0引脚接地(GND):1101000(0x68)AD0引脚接电源(VCC):1101001(0x69)

MPU6050 六轴传感器的 I2C 地址这个地址值怎么来的?
一句话:前 6 位是芯片出厂时写死的,最后 1 位由 AD0 引脚的电平决定。 ;打开 MPU6050 的数据手册,官方直接给出了地址格式:

c
1 1 0 1 0 0 AD0
↑ ↑
前6位出厂固化 最后1位由你接线决定
AD0接地(GND)→ 最后一位 =0→ 完整7位地址 =1101000=0x68AD0接电源(VCC)→ 最后一位 =1→ 完整7位地址 =1101001=0x69
(8) 假设我的AD0是引脚P20那个流程应该怎么写
#define MPU6050_ADDR 0x68 看起来确实"一步到位 "了,但它其实跳过了硬件工程师才需要关心的接线环节。那我把这个过程彻底拆开给你看。
a. 先吃透那 7 位地址的"拼图规则"
MPU6050 的 7 位 I2C 地址结构:
|--------|-------|-------|-------|-------|-------|-------|------------------|
| 位序 | 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=0x68AD0 = 1→ 完整 7 位地址 =0b1101001=0x69
b. 问的核心:"AD0 接 P20,完整流程是什么?"
假设你用的 MCU 是 STM32(或任何一款单片机),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. 总结:你要的"完整流程"
- 硬件上:MPU6050 的 AD0 引脚接到 P20。
- 初始化时 :调用
mpu6050_addr_pin_init(0),让 P20 输出低电平------AD0=0。 - 代码中 :
#define MPU6050_ADDR 0x68配合硬件 AD0=0。 - 通信时 :
i2c1_send_byte((MPU6050_ADDR << 1) | 0)自动拼出写地址0xD0。 - 读 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=读)
大多数库(Arduino、STM32 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 里明明有个 6 和 8,怎么变成 1101000 的?完全对不上啊!
别急,我们一步步推,从根上把它讲透。
第一步:先搞清楚 0x68 到底是什么
0x 开头表示这是十六进制,不是十进制! 你心里想的"六十八"是十进制 68,但计算机眼里 0x68 是一个十六进制数,它俩完全不是一回事:
| 十六进制 | 十进制 | 二进制 | |
|---|---|---|---|
| MPU6050 地址 | 0x68 |
104 |
1101000 |
| 你想的数字 | × | 68 |
1000100 |
十六进制里,每个数字对应 4 位二进制:
6=01108=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 = 1101000(7 位二进制)。
左移前(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=D,0000=0 → 0xD0,一气呵成。
第五步:那为什么有些库直接写 0x68 就行?
因为 Arduino 的 Wire 库、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就有I2C1和I2C2两个外设,支持:- 多主机模型
7位/10位地址模式- 标准速度(
100 kHz)和快速(400 kHz) DMA传输- 兼容
SMBus协议

(14) 软件设计流程
如果选择软件模拟方式,我们需要实现以下基础函数,然后像搭积木一样组合成对具体器件的读写操作:
- 初始化
GPIO(开漏输出,释放总线) - 等待总线空闲
- 产生起始信号
- 产生停止信号
- 发送应答/非应答
- 等待从机应答
- 发送一个字节
- 接收一个字节
下面一章我们就按这个流程,一步步手搓这些函数,并用它们来读写 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 为例(使用标准外设库),但思路同样适用于 Arduino、ESP32 等平台。
(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)
