
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- [为什么要学 I2C:先看一个痛点](#为什么要学 I2C:先看一个痛点)
- [I2C 是什么](#I2C 是什么)
- [I2C 的特点](#I2C 的特点)
- [用 I2C 的常见设备](#用 I2C 的常见设备)
- [STM32 里的 I2C:四方关系](#STM32 里的 I2C:四方关系)
- [I2C 硬件原理(灵魂部分)](#I2C 硬件原理(灵魂部分))
- [I2C 通信协议(数据帧格式)](#I2C 通信协议(数据帧格式))
- 主设备与从设备
- [I2C 内部结构框图](#I2C 内部结构框图)
- [Cortex-M3 如何访问 I2C 寄存器](#Cortex-M3 如何访问 I2C 寄存器)
- 全文总结
一、为什么要学 I2C:先看一个痛点
1.1 回忆一下 USART 的问题
我们之前学过 USART (串口),它做的是 一对一(1 对 1) 的串行通信------一根线只能连接两个设备。
假设现在板子上有 3 个带 USART 接口的传感器,我们想用单片机和它们分别通信,怎么办?
只能加接口:给 3 个传感器各配一个独立的 UART 接口,那就是 3 个接口。
问题来了:
- 如果接 8 个、16 个,甚至更多设备呢?
- 每个接口都要占用引脚、要配置中断、要写驱动代码......
- 单片机上 UART 接口的数量是有限的!根本不够用!
1.2 I2C 就是为解决"设备太多、接口太少"而生的
I2C 用两根线 就可以把很多很多设备串在一根总线上,MCU 想和哪个设备说话,就叫它的"名字"(地址),其他设备乖乖听着、不吭声。
一句话总结痛点:
用最少的物理连线,实现一主多从(甚至多主多从)的板级通信。
二、I2C 是什么?
2.1 名字拆解
I2C 全称 Inter-Integrated Circuit:
| 单词 | 含义 |
|---|---|
| Inter | 内部的、之间的 |
| Integrated | 集成的 |
| Circuit | 电路 |
所以中文一般叫它 集成电路总线。注意写法是 I2C(I 的平方 C),因为"I"和"C"中间夹着一个"C"(IC 之间)。
2.2 一句话定义
I2C 是一种串行、同步、半双工 的通信协议,只用 两根线 (SCL 时钟线 + SDA 数据线)就能在集成电路之间进行数据的双向传输,典型用于单片机与传感器、存储器等低速外围设备的连接。
几个关键词先有个印象:
- 串行:数据一位一位地传,不像并行口那样 8 位一起传。
- 同步:有专门的时钟线(SCL)来"打节拍",收、发双方跟着节拍走,所以不需要像串口那样约定波特率。
- 半双工:同一时刻只能一个方向传数据------要么主发从收,要么从发主收,不能同时双向。
2.3 谁发明的?
由 Philips(飞利浦) 公司在 1980 年代初开发,现在归 NXP(恩智浦) 所有。
2.4 一个典型场景
一根 I2C 总线上挂着 3 个甚至 16 个设备,都并联在 同一对 SCL / SDA 线 上,只需要 MCU 的一个 I2C 接口即可。这就是总线型 连接方式,所以 I2C 也叫 I2C Bus(I2C 总线)。
✅ 知识总结
Q:请讲讲 I2C 是什么?为什么会有 I2C 这个东西?
I2C 全称 Inter-Integrated Circuit,中文叫集成电路总线,是由飞利浦(现恩智浦 NXP)公司于 1980 年代初开发的串行、同步、半双工通信协议。它只需要 SCL(串行时钟线)和 SDA(串行数据线)两根线,就能在一根总线上挂载多个低速设备(如传感器、EEPROM、RTC 等)进行通信。之所以诞生 I2C,是因为像 USART 这样的接口只能一对一通信,当外设数量增多时接口不够用;而 I2C 用极少的物理连线实现一对多、多对多的板级通信,读写多种传感器和模块的数据。它的特点包括两线制、多主机多从机架构、每个从机有唯一地址、数据由 SCL 时钟同步、半双工传输,以及有标准(100kbps)、快速(400kbps)、快速+(1Mbps)等速度模式。
三、I2C 的特点

下面把 I2C 的六大特点详细展开:
3.1 两线制
这是 I2C 最显著的招牌,只需要两根信号线:
- SCL (Serial Clock Line,串行时钟线):由主设备产生,负责给整个通信"打拍子",同步所有数据的传输。
- SDA (Serial Data Line,串行数据线):双向数据线,用来传地址和数据位。
3.2 多主机 / 多从机
一条总线上可以挂多个主设备(Master)和多个从设备(Slave):
- Master(主设备):主动发起通信的一方。
- Slave(从设备):被动响应通信的一方。
现实中绝大多数应用都只有 1 个主机(比如就是我们的单片机),从机可以有很多个。
3.3 设备地址
每个从设备都有一个唯一的地址,主设备靠这个地址来点名:
- 7 位地址(常见)或 10 位地址(罕见)。
- 正因为要地址,所以 I2C 从设备出厂时就会被厂商通过"固定 / 引脚可选 / 寄存器烧写"等方式预设好地址。
注意细节:只有从机需要地址。因为从机数量多,主机要"点名";而主机只有一个(或通过仲裁选出来),不需要被点名。
3.4 同步串行通信
所有数据的传输都由 SCL 时钟信号 同步。数据位在 SCL 的高电平期间被采样,因此收发双方不需要约定波特率,硬件节奏天然一致。
3.5 半双工
同一时刻,数据只能在一个方向上传:
- 要么主发从收(写操作)
- 要么从发主收(读操作)
3.6 速度模式
| 模式 | 速率 |
|---|---|
| 标准模式(Standard Mode) | 100 kbps |
| 快速模式(Fast Mode) | 400 kbps |
| 快速模式+(Fast Mode Plus) | 1 Mbps |
注意:STM32F103ZET6 不支持"快速模式+",最高用到 400 kbps。
✅ 知识总结
Q:请说说 I2C 有哪些主要特点?
I2C 总线有六大特点:一是两线制,只用 SCL 时钟线和 SDA 数据线两根线,SCL 由主设备产生用于同步,SDA 双向用于传数据;二是支持多主机多从机架构,主设备发起通信、从设备响应,但实际应用中大多只有一个主机;三是每个从设备都有唯一地址,通常是 7 位,也有罕见的 10 位地址,主设备靠地址选择通信对象;四是同步串行通信,数据在 SCL 高电平期间被采样;五是半双工,同一时刻数据只能单向传输;六是定义了多种速度模式,标准模式 100kbps、快速模式 400kbps、快速模式+ 1Mbps。
四、用 I2C 的常见设备
几乎所有的低速传感器都支持 I2C,常见的有:
| 设备类型 | 说明 |
|---|---|
| 温湿度 + 气压传感器 | 环境监测常用 |
| 3 轴加速度计 + 3 轴陀螺仪 | 手机自动旋转屏幕、AR 导航、防抖摄影、体感游戏都靠它 |
| 环境光传感器 | 测量光照强度 |
| 摄像头模组 | 摄像头配置接口通常就是 I2C |
| 心率、血氧传感器 | 手表、手环测心率、血氧浓度 |
| 小尺寸显示屏 | 屏幕控制接口用 I2C |
| ToF 传感器 | 基于光"飞行时间"计算距离 |
| RTC 模块 | 很多不带 RTC 外设的主控会外接 RTC 芯片 |
为什么这些东西都要用 I2C?
- I2C 支持 1 对多通信,一根总线能接很多传感器。
- 用最少的物理连接(2 根线)实现芯片间简单、高效的板级通信。
- 这些传感器大多是低速设备,I2C 的速度(几百 kbps)完全够用。
✅ 知识总结
Q:I2C 一般用在什么场景?为什么这些场景选 I2C?
I2C 应用非常广泛,几乎所有低速传感器和模块都支持 I2C,例如温湿度气压传感器、六轴惯性传感器(加速度计+陀螺仪)、环境光传感器、摄像头配置接口、心率血氧传感器、小尺寸显示屏、ToF 距离传感器、RTC 时钟模块等。选择 I2C 的原因主要有两点:一是它支持一对多通信,一根总线可以挂很多从设备,读写多种传感器的数据非常方便;二是它只需要两根线就能完成板级通信,物理连接最少、成本低,适合低速外设场景。
五、STM32 里的 I2C:四方关系
5.1 先分清"协议"和"外设"
之前讲的都是 I2C 的协议/标准,属于"纸上谈兵"。
而现实中,芯片厂商已经把符合 I2C 规范的硬件电路 做成了一个模块,直接集成到 MCU 内部,这叫 I2C 片上外设。
以后我们说"STM32 的 I2C",指的就是这个片上外设,而不是单纯的协议。
我的 STM32F103ZET6:
- 内部有 2 个 I2C 模块(I2C1、I2C2)。
- 它们挂在 APB1 总线上。
5.2 四方关系:ARM-CPU、MCU、I2C、GPIO
无论是之前的 USART,还是现在的 I2C,无论是输出时钟还是数据,本质都是要通过 GPIO 的物理引脚输出到外部。
所以四者的关系是:

┌─────────────────────────────────────────────┐
│ ARM-CPU(Cortex-M3 内核) │
│ 负责执行程序、读写寄存器、下发指令 │
└──────────────────┬──────────────────────────┘
│ 内部总线(统一编址)
┌──────────────────▼──────────────────────────┐
│ STM32F103ZET6(MCU 芯片) │
│ 内部集成各种片上外设,其中就有 I2C 外设 │
└──────────────────┬──────────────────────────┘
│ 外设信号线
┌──────────────────▼──────────────────────────┐
│ I2C 片上外设 │
│ 负责按 I2C 协议收发数据、产生时序 │
└──────────────────┬──────────────────────────┘
│ SCL / SDA 信号
┌──────────────────▼──────────────────────────┐
│ GPIO(复用功能) │
│ 物理针脚真正输出高低电平到芯片外部 │
└─────────────────────────────────────────────┘
一句话总结:
CPU 通过读写 I2C 外设的寄存器来控制它;I2C 外设通过 GPIO 的复用引脚把 SCL、SDA 信号送出去。
5.3 两个重要细节
- I2C 需要 两个 Pin,分别对应 SCL 和 SDA。具体是哪个引脚,要看《STM32F103XX 数据手册》里的引脚定义表。
- I2C 使用的 GPIO 引脚一般要配置为复用模式。不过在 STM32CubeMX 里这些都是自动配置的,我们只需要注意即可。
小技巧:查数据手册时遇到陌生字段(脚位、管脚名称、类型、IO 电平、主功能、默认复用功能、重定义功能、FT 等),可以查文档,也可以借助 AI 解释。但查引脚对应关系这类事情,建议自己查文档,因为熟悉文档是嵌入式工程师的基本功。
5.4 GPIO 的"通用性"体现
GPIO 叫 General Purpose Input Output(通用输入输出)。这里的"通用"就体现在:它既可以被 USART 复用,也可以被 I2C 复用,还可以被 SPI 复用......为 MCU 收发各类外设信号,正是 GPIO 的职责所在。
✅ 知识总结
Q:请说说 STM32F103ZET6 中 MCU、ARM-CPU、I2C、GPIO 的四方关系?
STM32F103ZET6 内部集成有 I2C 片上外设,共两个(I2C1、I2C2),挂在 APB1 总线上。四方关系是:ARM-CPU(Cortex-M3 内核)通过内部总线、以统一编址的方式读写 I2C 外设的寄存器,实现对它的配置和控制;I2C 外设负责按照协议产生时序、收发数据;而无论时钟还是数据信号,最终都要通过 GPIO 的物理引脚(复用功能)输出到芯片外部。所以 CPU 控制 I2C 外设,I2C 外设借助 GPIO 复用引脚对外收发 SCL、SDA 信号,这就是四者的分工。
六、I2C 硬件原理(灵魂部分)
这一章是理解 I2C 的重中之重,请务必认真看完。搞懂了这一章,I2C 协议和代码就都不难了。
6.1 I2C 设备间是怎么接线的
典型场景:1 个主设备(MCU)+ N 个从设备,都挂在同一对 SCL、SDA 线上。
几个关键点:
- 不只是 MCU 内置了 I2C 接口,其他支持 I2C 的设备内部也内置了 I2C 接口或等效电路,所以才能互相通信。
- 总线上还需要 上拉电源(VDD)和上拉电阻。它们严格来说不属于 I2C 本身,而是因为 I2C 工作原理的需要,由硬件工程师在板子上布置的。
接线示意图:
VDD ──┬── R1 ──┬─────────┬─────────┬─────────┐ (上拉电阻)
│ │ │ │ │
SCL线──────┤ │ │ │
SDA线──────┴─────────┴─────────┴─────────┘
│ │ │ │
MCU 从设备1 从设备2 从设备3
(Master) (Slave) (Slave) (Slave)
所有设备的 SCL、SDA 引脚都要并联到这两条线上,然后两条线各通过一个上拉电阻接到 VDD。
6.2 开漏输出(Open-Drain)------ I2C 的地基
为什么必须是开漏?
I2C 规范(UM10204 文档 3.1.1 节)明确要求:
The output stages of devices connected to the bus must have an open-drain or open-collector to perform the wired-AND function.
(连接到总线上的设备的输出级必须采用 开漏(Open-Drain) 或 开集(Open-Collector) 结构,以实现 线与(Wired-AND) 功能。)
也就是说,所有挂在 SCL、SDA 上的设备,输出级必须用开漏结构。
开漏结构的本质
回顾 STM32 的 GPIO 端口位结构(把推挽的 P-MOS 去掉就是开漏):
-
开漏输出只有两种状态:
- 输出低电平(0):内部 N-MOS 导通,引脚接地。
- 高阻抗(Hi-Z,相当于悬空):内部 N-MOS 断开,引脚既不接高也不接地。
-
开漏输出无法主动输出高电平(1)!高电平全靠外部的上拉电阻把引脚拉到 VDD。
开漏输出的两种状态:
① N-MOS 导通(写 0):
引脚 ── N-MOS 导通 ── GND → 输出低电平(0)② N-MOS 断开(写 1):
引脚 ── N-MOS 断开 ── (悬空)
↑ 上拉电阻把引脚拉到 VDD → 输出高电平(1)
用代码理解"写 1 断开、写 0 导通"
回忆 GPIO 章节:在开漏模式下,向端口位数据寄存器写不同的值,就能控制 N-MOS:
c
// 假设 PA4 作为 I2C 的 SDA 引脚,配置为开漏输出
GPIOA->ODR &= ~(1 << 4); // 写 0:闭合 N-MOS,SDA 线被接地 → 输出低电平(0)
GPIOA->ODR |= (1 << 4); // 写 1:断开 N-MOS,SDA 线悬空,被上拉电阻拉到 VDD → 输出高电平(1)
默认状态:都是断开的
开漏输出默认是断开 的。所以只要没人驱动总线,SCL 和 SDA 线就会被上拉电阻拉成高电平,处于空闲状态。
有趣细节:STM32CubeMX 配置 I2C 配套 GPIO 时,甚至都不让你设置初始电平------因为开漏模式下默认就是断开的,后续用软件方式打开即可。
✅ 知识总结
Q:I2C 为什么必须使用开漏输出?
因为 I2C 协议要求连接在 SCL、SDA 总线上的所有设备都必须使用开漏(或开集)输出结构。开漏输出只有两种状态:N-MOS 导通时输出低电平,N-MOS 断开时输出高阻抗,无法主动输出高电平,高电平要由外部上拉电阻提供。这样做的根本目的是实现"线与"功能:任何设备只要导通自己的 N-MOS 就能把总线拉低,只有所有设备都处于高阻态时总线才被上拉为高电平。这与 I2C 需要的多设备共享总线、时钟同步、总线仲裁等机制是天然匹配的。
6.3 SDA 是怎么输出 0 和 1 的
以 MCU 的 I2C 作为 Master 视角,看看 SDA 线如何输出高低电平。
输出 1(高电平):
- 只要让 N-MOS 保持断开 ,SDA 线就在上拉 VDD 的拉动下自动保持高电平。
输出 0(低电平):
- 只要让 N-MOS 闭合 ,SDA 线就接地,输出低电平。
所以精髓就是:上拉电阻 + 开漏模式。
I2C 输出 1 → N-MOS 断开 → SDA 线被上拉拉高 → 高电平
I2C 输出 0 → N-MOS 闭合 → SDA 线接地 → 低电平
用代码直观表示(逻辑层):
c
// 模拟:开漏模式下输出一个数据位
void sda_output_bit(uint8_t bit)
{
if (bit == 1)
{
GPIOA->ODR |= (1 << 4); // 写 1:断开 N-MOS,让上拉电阻把线拉高
}
else
{
GPIOA->ODR &= ~(1 << 4); // 写 0:闭合 N-MOS,把线拉低
}
}
结论:I2C 如果有节奏地持续输出 1/0,SDA 线上就会持续呈现出高低电平。而其他从设备也挂在 SDA 线上,自然能读到这些电平变化------这就是"发送"的本质。
6.4 SDA 是怎么获取数据的
再看输入方向,以从设备(Slave)接收为例:
- 主设备已经把高低电平写到了 SDA 线上。
- 从设备的 SDA 引脚工作在复用输入 模式,从数据线接入到自身 I2C 内部,在理解上等价于一根导线直接接入。
- I2C 会对 SDA 信号直接采样(采样前可能经过一级施密特触发器对波形整形,让信号更干净)。
两个要点:
- I2C 接收方能直接感知 SDA 线上的高低电平。
- I2C 切换为输入模式时,需要把 N-MOS 断开 ,也就是"释放总线"------否则你一直拉着地,别人怎么发数据给你?
c
// 模拟:读取 SDA 线上的电平
void sda_set_input_mode(void)
{
// 关键!进入输入前先"释放总线":断开 N-MOS
GPIOA->ODR |= (1 << 4); // 写 1,断开 N-MOS
// 配置为输入模式(具体要看 GPIO 模式配置寄存器)
// 之后读取数据寄存器即可获得线上的电平
uint8_t level = (GPIOA->IDR >> 4) & 0x01; // 1 表示高电平,0 表示低电平
}
6.5 SCL 时钟的输出与输入
时钟的本质,也是高低电平。
- SCL 的输出/输入原理和 SDA 完全一样:开漏 + 上拉,写 1 断开 N-MOS 输出高,写 0 闭合 N-MOS 输出低。
- 所以只要搞懂了 SDA 的输出输入,SCL 自然就懂了。
一个重要规定:
在 I2C 通信中,时钟信息一般由主设备主动产生,而且只能由主设备主动产生。从设备只是在主设备给的时钟节拍下工作。
6.6 时钟与数据的关系(极其重要)
I2C 规定了两条铁律:
- SCL 为高电平时,SDA 上的数据必须稳定不变。
- SCL 为低电平时,SDA 上的数据才允许变化。
为什么?
-
接收方是在 SCL 高电平期间采样 SDA 的。
-
如果在高电平期间数据乱跳,接收方根本不知道采到的是 0 还是 1。
-
所以数据必须在 SCL 低电平期间准备好、变化完,然后 SCL 拉高时保持稳定,让接收方"安安稳稳"地采样。
时序示意(文字版):
SCL: 低 高 低 高 低 高
|--准备--|--采样--|--准备--|--采样--|--准备--|
SDA: 数据1 (稳定) 数据2 (稳定)
允许变化 允许变化
为了保证这一点,硬件设计上要求 SCL 的高电平时间要比 SDA 的数据保持时间"窄",也就是 SDA 的保持时间必须覆盖住 SCL 高电平的采样时刻,并且留有余量。
补充细节:
- 发送方角度:只有自己产生的 SCL 是低电平时,自己才能去改 SDA 的数据。
- 接收方采集:只要 SDA 稳定,原则上都可以采集。常见两种方式:上升沿采集、高电平采集。
- 通过控制 SCL、SDA 高低电平的保持时长,就能形成不同样式的时序结构(这是后面协议的基础)。
一句话总结这节:
SDA 上的电信号能不能被当成有效数据,是由 SCL 的高电平决定的。
✅ 知识总结
Q:I2C 是如何通过 SDA 和 SCL 收发数据的?时钟和数据是什么关系?
I2C 的 SDA、SCL 引脚都采用开漏输出加外部上拉电阻的结构。输出 1 时断开 N-MOS,让上拉电阻把线拉高;输出 0 时闭合 N-MOS,让线接地,从而在 SDA 线上产生高低电平。接收方通过 SDA 引脚的输入通道直接感知线上电平,切换为输入时需先断开 N-MOS 释放总线。SCL 时钟的产生原理与 SDA 完全相同,由主设备负责产生。时钟与数据的关系是:SCL 为高电平时 SDA 上的数据必须稳定不变,SCL 为低电平时 SDA 才允许变化,接收方在 SCL 高电平期间对 SDA 采样,从而得到 0 或 1。
6.7 "线与"(Wired-AND)------ I2C 最妙的设计
现在换一个角度看 I2C:在一个总线拓扑里,看所有设备合起来对总线的控制。
线与规则(一票否决制):
- 只要有任何一个 设备闭合自己的 N-MOS 接地,整条 SCL/SDA 线就被强制拉低。
- 只有所有设备都处于高阻态(N-MOS 全部断开),总线才能被上拉电阻拉高。
用 C 语言类比:
c
// 模拟"线与"逻辑:总线的最终电平 = 所有设备输出的逻辑与
uint8_t device_a = 1; // 设备 A 输出 1(N-MOS 断开,靠上拉为高)
uint8_t device_b = 1; // 设备 B 输出 1
uint8_t device_c = 0; // 设备 C 输出 0(N-MOS 闭合,拉低)
uint8_t device_d = 1; // 设备 D 输出 1
// 总线最终电平:只要有至少一个 0,结果就是 0;只有全是 1,结果才是 1
uint8_t bus_level = device_a & device_b & device_c & device_d;
// bus_level == 0 (因为 device_c 拉低了整条总线)
结论:
- 对 SDA 来说,多个设备无论谁输出高电平,只要有一个导通 N-MOS 输出低电平,整条 SDA 线就是低电平,所有设备感知到的数据都是 0。
- SCL 同理。
- I2C 用开漏模式在硬件上实现了"若干个引脚输出的逻辑与" ,这个特性就叫线与(Wired-AND)。
6.8 为什么要"线与"?------ 时钟同步与总线仲裁
① 时钟同步(Clock Synchronization)与时钟拉伸(Clock Stretching)
场景:主设备给从设备发数据,SCL 高电平时从设备采集数据。但从设备太忙了,处理不过来,怎么办?
- 从设备把 SCL 线拉低。
- 因为"线与"特性,SCL 线必然变低------就算主设备想拉高也不行!
- 主设备检测到 SCL 为低,就知道"对方还没准备好",于是暂停产生时钟,乖乖等待。
- 从设备处理完,释放 SCL,上拉电阻把 SCL 拉高,主设备才恢复通信。
这个"从设备强行让主设备等待"的机制,就叫 I2C 时钟拉伸(Clock Stretching)。
放心,这个过程是通信双方硬件自动完成的,不需要我们写代码操心。
为什么需要它?因为这是 I2C 同步的体现:从设备跟不上时,通过拉低 SCL 让主设备放慢节奏,保证数据不丢。
时钟拉伸示意:
主设备想产生时钟: 高 低 高 (被卡住)
SCL线上实际: 高 低 低 ← 从设备拉低,主设备拉不高
从设备忙完了: 释放 → 上拉拉高,主设备继续产生时钟
② 总线仲裁(Arbitration,先了解即可)
场景:两个主设备同时想占用总线发数据(通常是发地址),冲突了怎么办?
- 两个主都在发数据。A 发的是 1(释放总线),B 发的是 0(拉低总线)。
- 因为"线与",A 发现自己发的是 1,但总线上实际是 0(被 B 拉低了)。
- A 就知道自己输掉了仲裁,立即停止发送,转为从设备角色。
- 发 0 的 B 赢得仲裁,继续通信。
关键点:
- 谁先拉 SDA 线,谁仲裁胜利,失败方自动退出。
- 仲裁过程不会破坏数据------因为被破坏的那一方发的是 1,而总线上被拉成 0,就等于它没发过东西。
✅ 知识总结
Q:什么是 I2C 的"线与"特性?它有什么用?
线与(Wired-AND)是 I2C 基于开漏输出和上拉电阻实现的特性:总线上只要任何一个设备闭合 N-MOS 把线拉低,整条总线就是低电平;只有所有设备都处于高阻态,总线才能被上拉为高。从逻辑上讲,总线电平等于所有设备输出电平的逻辑与。它有两个重要作用:一是实现时钟同步与时钟拉伸,当从设备处理不过来时,主动拉低 SCL,主设备检测到后暂停产生时钟,等从设备处理完释放 SCL 再继续,从而保证通信可靠同步;二是实现总线仲裁,当多个主设备同时竞争总线时,谁先输出低电平谁赢得仲裁,输出高电平的一方检测到总线被拉低就知道自己失败,立即退出并转为从设备,仲裁过程不会破坏数据。
6.9 为什么必须用开漏,而不是推挽?
前面说了 I2C 规定要用开漏,那为什么不能用推挽?这背后的原因是硬伤,我们分析一下。
如果强行用推挽会怎样?
推挽输出自己就能输出高电平和低电平(内部有 P-MOS 和 N-MOS,一个推上去一个拉下来),理论上可以不依赖上拉电阻。
假设一个场景:MCU 通过 SCL 给从设备发时钟,但从设备想利用"线与"把 SCL 拉低,告诉主设备"我还没准备好,稍等"。
推挽模式的灾难:
- 主设备输出高电平 → 内部 P-MOS 导通,SCL 接到 VDD。
- 从设备输出低电平 → 内部 N-MOS 导通,SCL 接到 GND。
- 结果:VDD 直接对 GND 短路!
根据欧姆定律 I = U / R,导线的电阻趋近于 0,短路电流会极大,可能烧毁 MCU 和从设备内部的 MOS 管。
所以推挽模式既无法实现"线与"逻辑,又存在短路烧毁的风险,不能在 I2C 中使用。
为什么开漏就行?
开漏模式下,设备内部无法直接输出高电平 ,只能输出低电平和高阻抗;高电平由外部上拉电阻提供。
这意味着:
- 主设备想输出高 → N-MOS 断开(高阻)→ 不拉,外部上拉决定电平。
- 从设备想拉低 → N-MOS 闭合 → 接地。
- 谁拉低谁说了算,永远不会出现 VDD-GND 短路(上拉电阻有限流作用,且每端都只是开关、不主动输出高)。
更重要的一个对比点:
-
推挽输出用的是 MCU 内部的 VDD,设计者没法在芯片内部塞电阻来限流------只有芯片厂商(意法)才能改。
-
开漏输出把"拉高"这个动作交给了外部的上拉电阻,板子设计者可以自由选择上拉电阻的阻值,从而避免短路问题。
推挽的致命问题:
主设备 P-MOS 导通(输出高) + 从设备 N-MOS 导通(输出低)
→ VDD 和 GND 之间只隔着导通的 MOS 管和导线 → 近似短路 → 烧毁开漏的安全方案:
主设备 N-MOS 断开(高阻) + 从设备 N-MOS 导通(拉低)
→ 只有从设备在拉地,上拉电阻在限流 → 安全,且实现"线与"
✅ 知识总结
Q:为什么 I2C 要用开漏输出而不是推挽输出?
I2C 必须使用开漏输出,原因有两点。第一,推挽输出会导致短路烧毁:推挽模式内部同时存在 P-MOS 和 N-MOS,当主设备输出高电平(P-MOS 导通接 VDD)而从设备同时拉低电平(N-MOS 导通接地)时,VDD 和 GND 之间形成近似短路,电流极大,会烧毁芯片。第二,推挽无法实现"线与"逻辑,也就无法支撑 I2C 的时钟拉伸和总线仲裁机制。而开漏输出只能输出低电平和高阻抗,高电平由外部上拉电阻提供,设备间不会出现主动推高和主动拉低的对抗,从根本上避免了短路;同时由于上拉电阻在芯片外部,硬件工程师可以自由选择阻值来规避风险。开漏结构配合上拉电阻天然实现了"线与",这正是 I2C 选择开漏的根本原因。
七、I2C 通信协议(数据帧格式)
好,现在硬件层已经通了:我们能在 SDA 线上传 0 和 1,接收方能在 SCL 高电平采样得到数据。
但问题来了------光传 0 和 1 是不够的。就像学会了字母 a、b、c、d,还得把字母组成单词、单词组成句子,才能和老外交流。
I2C 的"句子"就是数据帧(协议)。学习协议前,先列出三个问题:
- 通信之前,总线默认处于什么状态?
- 接收方怎么知道"对方开始发了"?
- 接收方怎么知道"对方发完了"?
7.1 空闲状态(Idle)
所有 I2C 设备默认工作在开漏模式且 N-MOS 默认断开(所以 CubeMX 都不让你设 GPIO 初始电平)。
- 当没有设备驱动总线 时,SCL 和 SDA 都在上拉电阻作用下保持高电平。
- 这个状态就叫空闲状态。
任何通信开始前和结束后,总线都必须处于空闲状态。 这是 I2C 通信的大前提。
空闲状态:
SCL ──────高──────
SDA ──────高──────
7.2 起始信号(START)和停止信号(STOP)
问题:SCL、SDA 默认都是高电平。接收方怎么知道你要开始发数据?
我们知道 SDA 只有两种电平(高/低),分别用来表示数据 1 和 0。如果直接用"高电平"表示开始,接收方就会懵:到底这是数据 1,还是开始信号?
所以 I2C 设计了特殊情况 ,用跳变来表示开始和结束------这正好利用了前面那条铁律的"反面":
正常传输时,SCL 高电平期间 SDA 必须稳定不变 ;
而开始/停止,恰恰就是在 SCL 高电平期间让 SDA 发生跳变!
起始信号(START) :在 SCL 为高电平 时,把 SDA 从高电平拉低到低电平(高→低跳变)。
- 作用:通知总线上所有设备,传输即将开始。
停止信号(STOP) :在 SCL 为高电平 时,把 SDA 从低电平拉高到高电平(低→高跳变)。
- 作用:标识传输结束,总线释放,回到空闲状态。

用代码表示起始/停止的要点:
c
// 产生起始信号:SCL 高电平期间,SDA 高 → 低
void i2c_start(void)
{
i2c_sda_high(); // 1. SDA 先拉高(此时 SCL 也是高,处于空闲)
i2c_scl_high();
delay_short(); // 保持足够时间
i2c_sda_low(); // 2. 在 SCL 为高时,把 SDA 拉低 → 产生 START
delay_short();
i2c_scl_low(); // 3. SCL 拉低,准备传数据(数据只能在 SCL 低时变化)
}
// 产生停止信号:SCL 高电平期间,SDA 低 → 高
void i2c_stop(void)
{
i2c_sda_low(); // 1. SDA 先拉低
i2c_scl_high(); // 2. SCL 拉高
delay_short();
i2c_sda_high(); // 3. 在 SCL 为高时,把 SDA 拉高 → 产生 STOP
delay_short();
}
回答前面的问题:
- 接收方怎么知道对方开始发了?→ SCL 高电平期间,SDA 出现高→低跳变。
- 接收方怎么知道对方发完了?→ SCL 高电平期间,SDA 出现低→高跳变。
接收方在 SCL 高电平期间对 SDA 采样,会收到四种情况:
| 采到的情况 | 含义 |
|---|---|
| 高电平 | 数据 1 |
| 低电平 | 数据 0 |
| 高→低跳变 | 起始信号 |
| 低→高跳变 | 停止信号 |
✅ 知识总结
Q:请讲讲 I2C 的空闲状态、起始信号和停止信号。
I2C 总线空闲时,所有设备都处于开漏断开状态,SCL 和 SDA 两条线都被上拉电阻拉为高电平,这个状态叫空闲状态,任何通信的开始和结束都必须在空闲状态基础上进行。起始信号由主设备产生:在 SCL 为高电平期间,把 SDA 从高电平拉低到低电平,也就是产生一个高→低跳变,用于通知总线上所有设备传输即将开始。停止信号同样由主设备产生:在 SCL 为高电平期间,把 SDA 从低电平拉高到高电平,即低→高跳变,用于标识传输结束并释放总线,使总线回到空闲状态。之所以用跳变来表示开始和停止,是因为正常数据传输时 SCL 高电平期间 SDA 必须保持稳定,用跳变可以和数据 0、1 有效区分。
7.3 从设备地址:主机怎么找到目标?
总线上挂了这么多从设备,主机怎么确定要跟谁通信?答案:地址。
每个从设备都有唯一的地址,一般由厂商预设。例如板上常见外设的 I2C 地址:
| 设备 | 地址 |
|---|---|
| EEPROM(AT24C02) | 0x50 |
| 温湿度传感器 | 0x38 |
| 音频编码芯片 | 0x19 |
| 音频解码芯片 | 0x18 |

地址帧的结构
主设备发出起始信号后,接着发送从设备地址帧,格式为:
起始信号 + [7位从机地址 | 1位读写标志] + ACK应答位
└──────────── 第 1 字节 ────────────┘
- 1:7 位:从设备地址(高位在前 MSB)。
- 8 位(R/W) :读写标志。
0= 写操作(Master → Slave)1= 读操作(Slave → Master)
协议规定:
I2C 的数据和地址都按字节(8 位)为单位传输 ,高位在前(MSB 方式)。
地址也是数据------就像"人名也是汉字"一样,地址帧本质就是发一个字节。
谁收到这个地址?
- 主设备把地址写到 SDA 线上,所有从设备都能读到这个字节。
- 每个从设备把自己的地址和收到的一对比:匹配的就知道"主人在叫我",不匹配的继续沉默。
- 之后读写操作就只和这一个被点名的从设备进行。
从设备怎么应答?------ 引出 ACK
主设备发完"7 位地址 + 1 位读写标志"后,需要确认"对方收到并同意通信"。于是:
在第 9 个时钟的高电平期间,地址匹配的从设备利用"线与"特性,主动拉低 SDA 线 ,这就是 ACK 应答,意思是:"我收到地址了,我同意通信。"

回答前面的问题:
- 怎么确定跟哪个从设备通信?→ 通过唯一地址点名。
- 怎么确定是读还是写?→ 发送的第一个 8 比特数据的 LSB(最低位)表示读写。
- 怎么知道从设备同意被选中?→ 从设备在第 9 个时钟高电平拉低一次 SDA,发出 ACK。
c
// 代码视角:发送地址帧(7位地址 + 读写位)
void i2c_send_addr_with_rw(uint8_t slave_addr, uint8_t is_read)
{
// 地址左移 1 位,腾出最低位给读写标志
// 写操作 R/W=0,读操作 R/W=1
uint8_t addr_frame = (slave_addr << 1) | is_read;
// 按 MSB 高位在前,一位一位发出去
for (int i = 7; i >= 0; i--)
{
i2c_sda_bit((addr_frame >> i) & 0x01); // 输出当前位
i2c_scl_pulse(); // 打一个时钟脉冲(SCL 低→高→低)
}
// 第 9 个时钟:释放 SDA,读取从设备的应答
// 如果 SDA 被从设备拉低 → ACK;如果还是高 → NACK
}
7.4 应答 ACK / 无应答 NACK
每个字节(无论是地址还是数据)传输完成后,接收方都要向发送方反馈一个应答位,告诉对方"我收到了/我没收到"。
- ACK(Acknowledge,应答) :表示数据成功接收 。实现方式:接收方在第 9 个时钟拉低 SDA。
- NACK(Non-acknowledge,无应答) :表示接收方未准备好、地址不匹配、接收失败,或表示"读取结束"。实现方式:接收方不拉低 SDA(保持高电平)。


关键细节:
- I2C 是同步可靠性通信,需要应答来保证可靠性。(对比:串口没有应答,可靠性要靠软件自己设计。)
- I2C 传输以字节(8 位)为单位,所以应答位固定出现在第 9 位(第 9 个时钟)。
- 发送方在第 9 个 SCL 高电平期间采样 SDA:被拉低 → ACK,保持高 → NACK。
c
// 代码视角:等待并判断从设备是否应答(假设已经发完一字节)
uint8_t i2c_wait_ack(void)
{
i2c_sda_release(); // 释放 SDA(断开 N-MOS),等待从设备来拉低
i2c_scl_high(); // 第 9 个时钟高电平
delay_short();
uint8_t ack = i2c_read_sda(); // 采样 SDA
i2c_scl_low(); // 时钟拉低,完成第 9 个脉冲
if (ack == 0)
{
return 1; // SDA 被拉低 → 收到 ACK,从设备接收成功
}
else
{
return 0; // SDA 仍为高 → NACK,从设备未接收
}
}
✅ 知识总结
Q:I2C 是怎么寻址的?ACK 和 NACK 分别是什么意思?
I2C 通过从设备地址寻址,每个从设备都有唯一的 7 位(或 10 位)地址。主设备发出起始信号后,发送一个字节的地址帧:高 7 位是从机地址、最低位是读写标志,0 表示写、1 表示读,按 MSB 高位在前发送。总线上所有从设备都能收到该字节并做地址比对,匹配的从设备在第 9 个时钟高电平期间拉低 SDA 发出 ACK,表示收到地址并同意通信。ACK 和 NACK 是每传输完一个字节后,由接收方向发送方反馈的应答位:接收方在第 9 个 SCL 高电平期间把 SDA 拉低表示 ACK(成功接收),保持 SDA 为高表示 NACK(未接收、未准备好、地址不匹配或表示读取结束)。发送方在第 9 个时钟高电平采样 SDA 来判断 ACK 还是 NACK。这是 I2C 可靠性的重要保证。
7.5 主设备写数据(Master Write)
既然能发"7 位地址 + 1 位读写标志 + ACK",那发送普通数据就完全同理了------只是没有地址、读写位,一字节就一字节。
写数据完整流程如下:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 第 1 步 | 发送起始信号 | SCL 高电平期间,SDA 高→低跳变 |
| 第 2 步 | 发送 7 位地址 + 读写标志(0 = 写) | MSB 高位在前 |
| 第 3 步 | 接收从设备的 ACK | 从设备拉低 SDA 表示同意 |
| 第 4 步 | 发送 数据 0(1 字节) | 主机向从机写数据 |
| 第 5 步 | 接收从设备的 ACK | 从设备确认收到数据 0 |
| ... | ... | ... |
| 第 m 步 | 发送 数据 n(1 字节) | 可连续发送多个字节 |
| 第 m+1 步 | 接收从设备的 ACK | 从设备确认收到数据 n |
| 第 m+2 步 | 发送停止信号 | SCL 高电平期间,SDA 低→高跳变 |
要点:
- 一个起始和停止周期内,可以发送多个字节的有效数据。
- 每个字节后从设备都要回 ACK,这就是 I2C 的可靠性。
- 若从设备内部过忙、处理不过来,它可以发 NACK 告知主机,或者通过 SCL 时钟拉伸让主机放慢。
代码视角(写 1 字节):
c
// 完整过程:主机向从机写 1 字节
void i2c_master_write_byte(uint8_t slave_addr, uint8_t data)
{
i2c_start(); // 1. 起始信号:SCL 高,SDA 高→低
uint8_t addr_frame = (slave_addr << 1) | 0; // 2. 7 位地址 + 写标志(0)
i2c_send_byte(addr_frame); // 发送地址帧
i2c_wait_ack(); // 3. 等待从机 ACK(第 9 个时钟拉低 SDA)
i2c_send_byte(data); // 4. 发送 1 字节数据
i2c_wait_ack(); // 5. 等待从机 ACK
i2c_stop(); // 6. 停止信号:SCL 高,SDA 低→高
}
对 EEPROM 这种设备,"写"往往还要先告诉它写入的寄存器地址(内部地址),所以实际写流程是:起始 → 设备地址(写) → 内部地址 → 数据 → ... → 停止。这正是应用层协议需要约定的内容,后面实战章节会讲。
7.6 主设备读数据(Master Read)
读的过程和写类似,但发送方和应答方的角色调换了,而且有几个关键细节。
读数据完整流程:
| 步骤 | 动作 | 说明 |
|---|---|---|
| 第 1 步 | 发送起始信号 | SCL 高电平期间,SDA 高→低跳变 |
| 第 2 步 | 发送 7 位地址 + 读写标志(1 = 读) | MSB 高位在前 |
| 第 3 步 | 接收从设备的 ACK | 从设备拉低 SDA 表示同意 |
| 第 4 步 | 接收从设备发送的 数据 0 | 数据由从机发,主机收 |
| 第 5 步 | 主机发送 ACK | 主机表示"收到了,请继续发" |
| ... | ... | ... |
| 第 m 步 | 接收从设备发送的 数据 n | 1 字节 |
| 第 m+1 步 | 主机发送 NACK! | 关键区别在这里 |
| 第 m+2 步 | 发送停止信号 | SCL 高电平期间,SDA 低→高跳变 |
为什么最后一次应答不是 ACK,而是 NACK?
先看写操作:Master 写、Slave 读时,SCL 和 SDA 都由 Master 控制,Slave 只负责拉低 SDA 应答,不会和 Master 抢线。
再看读操作:Master 读、Slave 写时,SCL 由 Master 控制,但 SDA 主要由 Slave 控制。这时问题来了------
如果主机收到最后一个字节后,照例回了 ACK:
- Master 视角:"我收到 ACK 了,我要发停止信号(要用 SDA 线)。"
- Slave 视角:"主机给我 ACK 了,说明它收到了,我应该继续发下一个数据(也要用 SDA 线)。"
两台设备同时要用 SDA 线 → 竞争 → 互相干扰 → 通信失败!
所以协议约定:主机收到自己想要的最后一个字节后,不发 ACK,而是发 NACK,意思是:
"我要的数据已经收完了,你不要再发数据了,也不要再占用 SDA 线了。"
这样从机乖乖停下,把 SDA 让出来,主机才能安全地发送停止信号。
总结读操作中 ACK/NACK 的约定(读场景下的特殊含义):
- ACK:主机收到数据了,请从机继续发。
- NACK:主机收够了,请从机停止发送、让出 SDA 线。
这个不对称设计是 I2C 协议的精髓之一:Master 读、Slave 写的通信协议是不对称的------读写过程并不完全相同。
代码视角(读 1 字节):
c
// 完整过程:主机从从机读 1 字节
void i2c_master_read_byte(uint8_t slave_addr, uint8_t *buffer)
{
i2c_start(); // 1. 起始信号
uint8_t addr_frame = (slave_addr << 1) | 1; // 2. 7 位地址 + 读标志(1)
i2c_send_byte(addr_frame); // 发送地址帧
i2c_wait_ack(); // 3. 等待从机 ACK(从机同意通信)
*buffer = i2c_read_byte(); // 4. 读 1 字节(数据由从机发出)
i2c_send_nack(); // 5. 主机发 NACK:告诉从机"收完了,别再发"
i2c_stop(); // 6. 停止信号,释放总线
}
补充细节:
- Master 接收数据一般不会来不及:因为 SCL 时钟节奏由 Master 掌控,从机只能在 Master 的节奏下发数据。
- 从机怎么知道主机要读什么数据? 很多时候主机会在发完从设备地址后,再发一个"操作码 / 内部地址" ,先指定要读哪个寄存器、从哪个地址开始读,然后才进入读流程。这些属于应用层协议的约定。
生活化理解(打电话例子):
你妈打电话(起始信号)→ 接通 → 你妈问:"是张三吗?"(发从设备地址)→ 张三:"是的"(ACK)→ 你妈:"剩饭在厨房柜子第三层,记得饿了热一下吃"(发数据)→ 张三:"好的"(ACK)→ 你妈:"挂了哦"(停止信号)→ 张三:"好的"(ACK)。
7.7 小结:一次完整通信长什么样
写数据流程(硬件 + 协议完整版):
起始 → 从机地址(7位) + 写标志(0) → 从机ACK → 数据字节1 → ACK → ... → 数据字节n → ACK → 停止
读数据流程(硬件 + 协议完整版):
起始 → 从机地址(7位) + 读标志(1) → 从机ACK → 从机发数据1 → 主机ACK → ... → 从机发数据n → 主机NACK → 停止
✅ 知识总结
Q:请分别讲讲 I2C 主设备写数据和读数据的完整流程,以及它们的区别。
I2C 写数据流程:主设备发起始信号,然后发送从机地址帧(7 位地址 + 1 位读写标志,写为 0),从机地址匹配后在第 9 个时钟拉低 SDA 发 ACK,主设备随后每发一个字节数据,从机就回一个 ACK,可以连续发送多个字节,最后主设备发停止信号结束。I2C 读数据流程:主设备发起始信号,发送地址帧(读写标志为 1),从机回 ACK 后开始发送数据,主设备每收到一个字节回一个 ACK 表示"请继续发",但当主设备收到最后一个想要的字节后,改发 NACK 表示"数据够了,不要再发",然后再发停止信号。两者的关键区别在于:写操作时 SCL 和 SDA 都由主设备控制,不会发生总线竞争;而读操作时 SDA 由从设备主导,如果主设备最后也回 ACK,从设备会误以为还要继续发数据,双方会争抢 SDA 线导致通信失败,所以协议约定最后一次应答必须是 NACK 来让从设备让出总线。这就是 I2C 收发协议不对称的原因。
八、主设备与从设备(Master / Slave)
8.1 什么决定了主从?
之前我们一直默认"MCU 是主、其他设备是从",但这只是为了方便讲解。实际上:
任何支持 I2C 的设备,无论它是什么,都可以作为主设备,也可以作为从设备。 这是可以配置的。
8.2 两者的职责对比
主设备(Master):
- 发起通信,产生起始信号和停止信号。
- 生成时钟信号(SCL)。
- 对从设备进行寻址。
- 控制数据传输方向(读/写)。
- 一条总线上可以有多个主设备(靠仲裁协调),但绝大多数应用只有一个主机。
- 典型代表:MCU、CPU、FPGA 等。
从设备(Slave):
- 被主设备寻址。
- 响应主设备的命令。
- 被动接收或发送数据。
- 每个从设备在总线上有唯一地址。
- 不能主动发起通信。
- 典型代表:传感器、EEPROM、RTC、ADC/DAC、IO 扩展芯片等。
8.3 澄清一个常见误区
从设备也可以读写数据! "能读写数据"和"主导通信过程"是两码事。
谁是 Master 看的是谁在主导通信(发起、定时序、寻址、结束),而不是谁在发数据。
生活化理解(老师 vs 学生):
- 老师(Master):"张三,你站起来,作业多久没交了,上课还走神!"(Master 选择 Slave 并写数据)
- 老师(Master):"你有什么想说的?说两句。"(Master 要求读取)
- 学生(Slave):"我错了,我肯定改......"(Slave 在 Master 的要求下发数据)
整个过程老师主导,但学生也在"读和写"(听和说)。主导通信的是老师 = Master。
身份可以互换(毕业后的反转):
- 曾经的学生毕业当了驾校教练(现在是 Master)。
- 曾经的老师没驾照,变成了学员(现在是 Slave)。
- 教练问:"转向灯怎么打?人行道能开车吗?"(新 Master 写数据)
- 教练说:"你解释一下。"(新 Master 读数据)
- 学员:"我错了,我改......"(新 Slave 发数据)
所以:同一个 I2C 设备,做主做从完全取决于如何配置它。 不过在我们的应用里,MCU 基本都充当 Master 去控制板级外设,从设备都是传感器、存储芯片等。
✅ 知识总结
Q:I2C 的主设备和从设备分别是什么?有什么区别?
I2C 支持多主多从架构。主设备(Master)负责发起通信、产生起始和停止信号、生成 SCL 时钟、对从设备寻址、控制数据传输方向,是通信的主导者;从设备(Slave)被主设备寻址,响应主设备的命令,被动接收或发送数据,每个从设备有唯一地址,不能主动发起通信。典型的主设备是 MCU、CPU、FPGA,典型的从设备是传感器、EEPROM、RTC、ADC/DAC、IO 扩展芯片等。需要特别注意的是,从设备同样可以读写数据,能读写和主导通信是两码事------判断谁是主设备的标准是谁在主导通信过程。而且一个 I2C 设备充当主设备还是从设备是可以配置的。
九、I2C 内部结构框图
前面我们把视角放在了 GPIO 引脚、开漏模式、外部接线和协议上,现在把视角钻进 I2C 外设内部,看看它自己是怎么工作的。
9.1 核心组成(简化版)
I2C 接口内部的核心硬件(简化理解):
- SCL 时序控制逻辑:负责产生时钟节拍。
- SDA 数据通道:负责收发数据。
- 数据寄存器(DR):用户可见,负责"接货"和"发货"。
- 移位寄存器(Shift Register):用户不可见,真正一位一位往外"抖"数据的地方。
- 自身地址寄存器(OAR):保存本设备的地址(从机模式用来被识别)。
- 比较器:把收到的地址和自己的地址做比较,判断是否被点名。
- 以及控制、状态、时钟配置等一大堆寄存器(下一章详聊)。

9.2 收发数据是怎么流转的?
接收方向(收一个字节):
- I2C 接口通过 SDA 输入通道逐位采集电平(在 SCL 时钟节拍下)。
- 每个时钟采到一个 bit,就移入移位寄存器。
- 移满 8 个 bit 后,I2C 硬件自动把移位寄存器的内容平移 到数据寄存器 DR。
- 我们(CPU / 软件)只要读 DR 寄存器,就能拿到这 1 字节数据。
发送方向(发一个字节):
- 我们(CPU / 软件)把 1 字节数据写入数据寄存器 DR。
- I2C 硬件自动把 DR 的内容拷贝到移位寄存器。
- 移位寄存器按 MSB(高位在前) 一位一位对外输出 0/1 高低电平。
- 发完 8 个 bit,即完成 1 字节的发送。
和 USART 一样:移位寄存器属于 I2C 内部寄存器,对用户(Cortex-M3)不可见,我们只能通过 DR 收发数据。
9.3 关于"地址"在内部怎么处理的
- 站在 Master 视角:发送"地址数据 + 读写位",本质就是发送 1 字节数据,跟发普通数据没区别。
- 站在 Slave 视角 :接收"地址数据 + 读写位",本质就是接收 1 字节数据。收到后按协议解析:
- 知道这是"地址 + 读写位",而且是 MSB 在前。
- 提取出地址,通过比较器和自己 OAR(自身地址寄存器)里的地址比对。
- 匹配 → 主机叫的是我;再根据读写位确认自己接下来是收数据还是发数据。
9.4 引出大量"待办问题"
要真正用起来,还需要回答很多问题,比如:
- 发送方怎么写入 DR?接收方怎么读取 DR?
- 接收方怎么设置自己的 OAR 寄存器?
- 发送方怎么发送起始位?怎么知道对方确认了 ACK?
- 接收方怎么知道对方是要读还是写?
- 发送方怎么知道 DR 空了、可以写下一个数据?
- 接收方怎么知道 DR 满了、可以取数据了?
- I2C 接口可以被单独使能吗?
- 怎么感知总线被占用(busy)?
- 怎么发停止位?
这些问题靠什么解决?------ 寄存器!
I2C 接口是一个独立的硬件单元,为了支持地址设置、数据写入获取、控制配置、状态查询,内部必然有大量的寄存器和寄存器位。这点和 GPIO、USART 完全一样(异曲同工)。
✅ 知识总结
Q:请讲讲 I2C 外设内部的基本结构和数据收发流程。
I2C 外设内部的核心结构包括:数据寄存器 DR、移位寄存器、自身地址寄存器 OAR、地址比较器、SCL 时序控制逻辑,以及各种控制和状态寄存器。发送数据时,CPU 把数据写入 DR,硬件自动将 DR 内容拷贝到移位寄存器,移位寄存器按 MSB 高位在前的方式一位一位输出到 SDA;接收数据时,I2C 在每个时钟沿把 SDA 上的电平移入移位寄存器,收满 8 位后自动平移到 DR,CPU 读 DR 即可得到一字节数据。移位寄存器对用户不可见。对于地址处理:主机发送地址帧就是发一字节数据;从机收到后按协议解析出地址,通过比较器和自身地址寄存器 OAR 比对,匹配则说明被点名,再根据读写位确定收发角色。要把这些流程跑起来,本质就是配置和控制 I2C 内部的大量寄存器。
十、Cortex-M3 如何访问 I2C 寄存器
10.1 先看宏观:I2C 挂在哪条总线上
在 STM32F103ZET6 中,I2C1 和 I2C2 都挂在 APB1 总线上。这是硬件上的归属,决定了它们使用什么时钟源。
10.2 两个核心问题
要"访问 I2C 接口",本质就两个问题:
- Cortex-CPU 能不能访问 I2C 内部的任意一个寄存器? ------ 能!
- I2C 内部寄存器常见的比特位都是干什么的? ------ 查文档,用到再查。
10.3 统一编址:寄存器 = 内存地址
STM32 的外设寄存器直接映射到 CPU 的地址空间,统一编址。所以访问寄存器就跟访问内存一样简单------用一个 32 位地址去读写即可。
I2C1 的地址空间:
- 基地址范围:
0x4000 5400 ~ 0x4000 57FF - 大小计算:
0x4000 57FF - 0x4000 5400 + 1 = 0x400 = 1024 字节 = 1KB
注意:虽然 I2C1 被分配了 1KB 地址空间,但实际使用的只有开头的一小部分,后面的地址是保留的,访问它们可能产生不可预知的行为。
10.4 九个寄存器的地址清单
I2C 内部一共 9 个寄存器 ,每个都是 32 位 。访问方式统一为:基地址 + 寄存器偏移量。
| 寄存器名称 | 偏移量 | 地址计算 | 最终地址 |
|---|---|---|---|
| I2C_CR1(控制寄存器 1) | 0x00 | 0x40005400 + 0x00 | 0x40005400 |
| I2C_CR2(控制寄存器 2) | 0x04 | 0x40005400 + 0x04 | 0x40005404 |
| I2C_OAR1(自身地址寄存器 1) | 0x08 | 0x40005400 + 0x08 | 0x40005408 |
| I2C_OAR2(自身地址寄存器 2) | 0x0C | 0x40005400 + 0x0C | 0x4000540C |
| I2C_DR(数据寄存器) | 0x10 | 0x40005400 + 0x10 | 0x40005410 |
| I2C_SR1(状态寄存器 1) | 0x14 | 0x40005400 + 0x14 | 0x40005414 |
| I2C_SR2(状态寄存器 2) | 0x18 | 0x40005400 + 0x18 | 0x40005418 |
| I2C_CCR(时钟控制寄存器) | 0x1C | 0x40005400 + 0x1C | 0x4000541C |
| I2C_TRISE(上升时间寄存器) | 0x20 | 0x40005400 + 0x20 | 0x40005420 |
这 9 个寄存器按功能可以分为几类:控制类 (名字带 CR)、地址类 (名字带 OAR)、数据类 (名字带 DR)、状态类 (名字带 SR)、其他。目前知道分类就够了,细节需要时查参考手册。
I2C2 也是一套完全一样的寄存器组,只是基地址不同,计算方法完全相同。
10.5 用代码直接访问寄存器
既然寄存器统一编址,我们就可以用"地址"直接读写:
c
// 定义 I2C1 基地址和常用寄存器(直接用地址访问)
#define I2C1_BASE (0x40005400u) // I2C1 外设基地址
// 用指针操作寄存器:volatile 防止编译器优化掉我们的读写
#define I2C1_CR1 (*(volatile uint32_t *)(I2C1_BASE + 0x00u)) // 控制寄存器1
#define I2C1_DR (*(volatile uint32_t *)(I2C1_BASE + 0x10u)) // 数据寄存器
#define I2C1_SR1 (*(volatile uint32_t *)(I2C1_BASE + 0x14u)) // 状态寄存器1
#define I2C1_SR2 (*(volatile uint32_t *)(I2C1_BASE + 0x18u)) // 状态寄存器2
// 示例:通过寄存器直接收发一字节数据(示意,真实工程用 HAL 库)
void i2c_reg_send_byte(uint8_t data)
{
// 等发送条件满足后,把数据写入 DR 寄存器,
// I2C 硬件会自动把 DR 内容搬到移位寄存器,按 MSB 发出去
I2C1_DR = data;
}
uint8_t i2c_reg_recv_byte(void)
{
// 等接收完成标志置位后,从 DR 寄存器读出接收到的数据
return (uint8_t)I2C1_DR;
}
10.6 核心结论
- 通过地址就能访问 I2C 内部寄存器,访问寄存器 ≈ 读写内存------这正是外设和内存统一编址的好处。
- 能访问寄存器,就能用位操作访问每个寄存器的每一个比特位。
- 配置、控制 I2C,本质就是读写寄存器。
我们目前还不必背每个寄存器的细节(过早陷入细节会迷失方向),知道"9 个寄存器、按 CR/OAR/DR/SR 分类、用基地址+偏移量访问"就足够了。后面实战章节会结合 HAL 源码逐个看关键寄存器位。
✅ 知识总结
Q:Cortex-M3 是怎么访问和控制 STM32 的 I2C 外设的?
STM32 采用了外设和内存统一编址的方式,I2C 外设的寄存器被直接映射到 CPU 的地址空间。STM32F103ZET6 有 I2C1 和 I2C2 两个 I2C 外设,都挂在 APB1 总线上。以 I2C1 为例,其地址范围是 0x40005400~0x400057FF,共 1KB,内部有 9 个 32 位寄存器,包括控制类(CR1、CR2)、地址类(OAR1、OAR2)、数据类(DR)、状态类(SR1、SR2)以及时钟控制(CCR)和上升时间(TRISE)寄存器。访问任一寄存器的地址就是基地址加上寄存器偏移量,例如数据寄存器 DR 的地址是 0x40005410。由于统一编址,访问寄存器就像读写内存一样,用指针或位操作即可完成,所以配置和控制 I2C 的本质就是读写寄存器。
十一、全文总结
11.1 一张图回顾核心链路
为什么要 I2C? → 一对一通信接口不够用,需要一主多从的总线方案
I2C 是什么? → 串行、同步、半双工、两线制(SCL+SDA)的集成电路总线
硬件靠什么工作? → 开漏输出 + 外部上拉电阻 + 线与特性
怎么传 0 和 1? → 输出:开漏写0闭合N-MOS接地、写1断开靠上拉;输入:采样SDA电平
怎么采数据? → SCL 高电平时 SDA 必须稳定,接收方高电平采样
怎么组织通信? → 空闲 → 起始 → 地址+读写位 → ACK → 数据(+ACK)... → NACK/停止 → 空闲
怎么找对象? → 从机唯一地址 + 地址帧 + ACK应答
怎么分主从? → 谁主导通信谁就是 Master,从机也能读写数据
STM32 怎么用? → I2C1/I2C2 挂 APB1,统一编址,基地址+偏移量访问 9 个寄存器
11.2 我们已经掌握的
- I2C 是什么、为什么需要 I2C。
- STM32F103ZET6 中 MCU、ARM-CPU、I2C、GPIO 的四方关系。
- I2C 硬件原理:开漏接法、线与特性、SDA/SCL 收发 0/1 的本质。
- I2C 通信协议:空闲、起始、停止、地址、读写位、ACK/NACK、读写流程。
- 主从模式的概念与区别。
- I2C 内部结构框图的基本组成。
- Cortex-M3 通过统一编址访问 I2C 寄存器的细节。
