引言
在嵌入式系统的广袤天地中,STM32 单片机凭借其卓越的性能与丰富的外设资源,成为众多开发者的得力工具。而 I2C(Inter - Integrated Circuit)总线协议,作为 STM32 与各类外部设备进行数据交互的重要桥梁,在现代电子设备中扮演着举足轻重的角色。
想象一下,在智能手表中,STM32 需要与心率传感器、加速度计等协同工作,精准采集人体运动和生理数据;在智能家居网关里,它又要与温湿度传感器、智能门锁等设备通信,营造舒适安全的居住环境。这些场景中,I2C 总线就像一条无形的纽带,让 STM32 与各种外设之间实现高效、稳定的 "对话"。
I2C 协议由飞利浦公司首创,因其简洁的硬件连接、灵活的通信方式和广泛的适用性,在消费电子、工业控制、医疗设备等诸多领域得到了极为广泛的应用。无论是小巧便携的移动设备,还是复杂精密的工业控制系统,都能发现 I2C 的身影。
通过深入学习 I2C 通信,我们能够透彻理解 STM32 与外部设备的数据交互机制,提升嵌入式系统开发的专业能力,为实现更加复杂、智能的应用奠定坚实基础。接下来,让我们一同深入探索 I2C 通信的奥秘,揭开其工作原理、通信过程以及在 STM32 开发中具体应用的神秘面纱。
I2C 总线定义
两线式串行总线
-
两线式:处理器与外设之间仅需两根信号线,即 SCL(时钟控制信号线)和 SDA(数据线)。SCL 由 CPU 掌控,用于实现数据同步,遵循 "低放高取" 原则,即 SCL 为低电平时将数据放置在 SDA 上,SCL 为高电平时从 SDA 获取数据;SDA 用于传输数据,通信双方均可控制,发送数据时由发送方掌控。需特别注意,SCL 和 SDA 必须分别连接上拉电阻,使其默认电平为高电平。
-
串行:因仅有一根 SDA 数据线,数据传输为串行方式,且在 SCL 时钟信号控制下,一个时钟周期传输一个 bit 位。I2C 数据传输从高位开始,每次传输一个字节,若传输多个字节需逐个进行。
-
总线:SCL 和 SDA 上可连接多个外设(理论上也可连接多个 CPU,但实际场景中较少见,常见为一个处理器连接多个外设)。

I2C 功能框图

I2C 总线协议相关概念
信号与位定义
-
START 信号:起始信号,仅由 CPU 发起,标志着 CPU 开始访问外设。其时序为 SCL 处于高电平时,SDA 由高电平向低电平跳变。
-
STOP 信号:结束信号,同样仅由 CPU 发起,表示 CPU 结束对总线的访问。其时序是 SCL 为高电平时,SDA 由低电平向高电平跳变。

-
R/W 读写位:用于指示 CPU 是向外设写入数据(R/W = 0)还是从外设读取数据(R/W = 1),有效位数为 1 个 bit 位。
-
设备地址:用于标识外设在总线上的唯一性,同一 I2C 总线上不同外设具有唯一设备地址。设备地址有效位数通常为 7 位(10 位极为少见),且不包含读写位。设备地址由原理图和芯片手册共同确定,例如 AT24C02。读设备地址 = 设备地址 << 1 | R/W = 1;写设备地址 = 设备地址 << 1 | R/W = 0 。这样设计是因为 I2C 总线协议规定数据传输一次一个字节(8 位),设备地址本身 7 位不足一字节,与 1 位的 R/W 位组合凑够一字节,方便 CPU 寻址外设并告知读写操作。

片内寄存器
任何 I2C 外设芯片内部都集成有一系列寄存器,即片内寄存器。这些寄存器地址从 0x00 开始编号,CPU 不能直接以指针形式访问,必须严格按照读写时序进行操作。实际上,CPU 访问 I2C 外设本质就是访问其内部的寄存器,因此关注 I2C 外设需重点留意其片内寄存器的特性、基地址以及读写时序。
I2C 总线数据传输的流程 (协议)
以 AT24C02 存储器为例
- 写一个字节:遵循特定时序,先发送 START 信号,接着发送写设备地址并等待 ACK,再发送要写入的寄存器地址并等待 ACK,随后发送要写入的数据并等待 ACK,最后发送 STOP 信号。

- 连续写多个字节:与写一个字节流程类似,但在发送要写入的数据时,可连续发送多个字节,期间同样需等待 ACK,当遇到跨页情况时需特殊处理。

- 读取一个字节:先发送 START 信号,然后发送写设备地址并等待 ACK,发送要读取的寄存器地址并等待 ACK,再次发送 START 信号,接着发送读设备地址并等待 ACK,之后读取 1 字节数据并回复 NACK 信号,最后发送 STOP 信号。

- 连续读取多个字节:发送 START 信号后,依次发送写设备地址、要读取的寄存器地址并等待 ACK,再次发送 START 信号和读设备地址并等待 ACK,然后循环读取多字节数据,根据剩余字节数回复 ACK 或 NACK,最后发送 STOP 信号。

代码演示
基于 STM32F103X 模拟 I2C 读写 AT24C02
因 I2C 硬件存在一些问题,本次采用 GPIO 模拟 I2C 方式。

头文件 iic.h
// iic.h
#ifndef __IIC_H_
#define __IIC_H_
#include "stm32f10x.h"
#include "system.h" // 位带操作
// SCL PB6
#define IIC_SCL_PORT GPIOB
#define IIC_SCL_PIN GPIO_Pin_6
#define IIC_SCL PBout(6)
// SDA PB7
#define IIC_SDA_PORT GPIOB
#define IIC_SDA_PIN GPIO_Pin_7
#define IIC_SDA PBout(7)
#define READ_SDA PBin(7)
extern void IIC_Init(void);
extern void IIC_Start(void);
extern void IIC_Stop(void);
extern u8 IIC_Wait_Ack(void);
extern void IIC_Ack(void);
extern void IIC_NAck(void);
extern void IIC_Send_Byte(u8 TxData);
extern u8 IIC_Read_Byte(u8 ack);
#endif
源文件 iic.c
// iic.c
#include "iic.h"
#include "systick.h"
void IIC_Init(void){
// 1.打开SCL/SDA时钟 - GPIOB
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
// 2.配置SCL - 推挽输出, 50MHz
GPIO_InitTypeDef GPIO_Config;
GPIO_Config.GPIO_Pin = IIC_SCL_PIN;
GPIO_Config.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Config.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(IIC_SCL_PORT, &GPIO_Config);
// 3.配置SDA - 推挽输出, 50MHz
GPIO_Config.GPIO_Pin = IIC_SDA_PIN;
GPIO_Config.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Config.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(IIC_SDA_PORT, &GPIO_Config);
// 4.拉高SCL/SDA
IIC_SDA = 1;
IIC_SCL = 1;
}
// 配置SDA为推挽输出, 50MHz
static void SDA_OUT(void){
GPIO_InitTypeDef GPIO_Config;
GPIO_Config.GPIO_Pin = IIC_SDA_PIN;
GPIO_Config.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Config.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_Init(IIC_SDA_PORT, &GPIO_Config);
}
// 配置SDA为上拉输入
static void SDA_IN(void){
GPIO_InitTypeDef GPIO_Config;
GPIO_Config.GPIO_Pin = IIC_SDA_PIN;
GPIO_Config.GPIO_Mode = GPIO_Mode_IPU;
GPIO_Init(IIC_SDA_PORT, &GPIO_Config);
}
/*
1.配置SDA为输出模式
2.拉高SCL / 拉高SDA
3.保持至少4.7us
4.拉低SDA
5.保持至少4us
*/
void IIC_Start(void){
SDA_OUT();
IIC_SCL = 1;
IIC_SDA = 1;
delay_us(6); // 6 >= 4.7
IIC_SDA = 0;
delay_us(6);
// 发送了开始信号
IIC_SCL = 0; // 将SCL拉低, 便于下一次数据传输
}
/*
1.配置SDA为输出模式
2.拉低SDA / 拉高SCL
3.保持大于4us
4.拉高SDA
5.保持大于4.7us
*/
void IIC_Stop(void){
SDA_OUT();
IIC_SDA = 0;
IIC_SCL = 1;
delay_us(6);
IIC_SDA = 1;
delay_us(6);
}
/*
cpu读取ack信号 - CPU读取一个低电平
收到ack, 0; 没收到ack, 1;
低放 - 外设放数据
高取 - CPU取数据
*/
u8 IIC_Wait_Ack(void){
u32 tempTime = 0; // 等待的次数
IIC_SCL = 0;
delay_us(6);
SDA_IN(); // 配置输入模式
IIC_SCL = 1; // SCL拉高, 读取数据
delay_us(6);
// 如何收到了ack信号呢?
// 收到了ack, SDA=0; 没收到ack, SDA=1;
while (READ_SDA) {
tempTime++;
if (tempTime > 250){
IIC_Stop();
return 1; // 没收到ack
}
}
IIC_SCL = 0; // 准备下次数据传输
return 0; //收到了ack
}
// 发送ack信号 - 发送一个低电平给外设
// 低放 - CPU放数据
// 高取 - 外设取数据
void IIC_Ack(void){
IIC_SCL = 0;
SDA_OUT();
IIC_SDA = 0;
delay_us(6);
// -> 将数据放到了SDA上 (ack)
IIC_SCL = 1;
delay_us(6);
IIC_SCL = 0; // 准备下一次数据传输
}
// 发送nack信号 - 发送一个高电平给外设
void IIC_NAck(void){
IIC_SCL = 0;
SDA_OUT();
IIC_SDA = 1;
delay_us(6);
// -> 将数据放到了SDA上 (nack)
IIC_SCL = 1;
delay_us(6);
IIC_SCL = 0; // 准备下一次数据传输
}
// 发送单字节
// 参数:要发送的数据
// TxData = xxxxxxxx
// 10000000 &
// x0000000
void IIC_Send_Byte(u8 TxData){
u8 i;
SDA_OUT(); // 输出模式
IIC_SCL = 0; // 后续将数据放到SDA上
for(i = 0; i < 8; i++) {
if (TxData & 0x80) // 非0
IIC_SDA = 1;
else
IIC_SDA = 0;
TxData <<= 1;
delay_us(6); // 低电平的时钟周期
IIC_SCL = 1; // 拉高
delay_us(6); // 高电平的时钟周期
IIC_SCL = 0;
}
}
// 读取单字节
// 返回值 : 读取的数据
// 参数: 是否回复ack
// 1, 回复ack; 0, 回复nack;
u8 IIC_Read_Byte(u8 ack){
u8 i = 0, data = 0; // data保存读取到的数据
SDA_IN(); // 配置为输入模式
for(i = 0; i < 8; i++) {
IIC_SCL = 0; // 拉低
delay_us(6);
IIC_SCL = 1;
data |= READ_SDA << (7 - i);
delay_us(6);
}
// 回复ack/nack
if(!ack)
IIC_NAck();
else
IIC_Ack();
return data; //返回读取的数据
}
at24c02.h
// at24c02.h
#ifndef __AT24C02_H_
#define __AT24C02_H_
#include "stm32f10x.h"
#define AT24C02_ID (0x50)
extern void AT24C02_Init(void);
extern u8 AT24C02_ReadByte(u16 ReadAddr);
extern void AT24C02_WriteByte(u16 WriteAddr, u8 data);
extern void AT24C02_ReadBlockData(u16 ReadAddr, u8* pBuffer, u16 Len);
extern void AT24C02_WriteBlockData(u16 WriteAddr, u8* pBuffer, u16 Len);
// 4个测试函数
extern void AT24C02_ReadOne(void);
extern void AT24C02_WriteOne(void);
extern void AT24C02_ReadMul(void);
extern void AT24C02_WriteMul(void);
#endif
at24c02.c
// at24c02.c
#include "iic.h"
#include "at24c02.h"
#include "systick.h"
#include "stdio.h"
void AT24C02_Init(void){
IIC_Init();
}
// 参数:要读取的寄存器地址
// 返回值 : 读取的数据
u8 AT24C02_ReadByte(u16 ReadAddr){
// 1.发送开始信号
IIC_Start();
// 2.发送写设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 0);
// 3.等待ack
IIC_Wait_Ack();
// 4.发送要读取的寄存器地址
IIC_Send_Byte(ReadAddr);
// 5.等待ack
IIC_Wait_Ack();
// 6.发送开始信号
IIC_Start();
// 7.发送读设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 1);
// 8.等待ack
IIC_Wait_Ack();
// 9.读取1字节数据 + 回复nack信号
u8 temp = IIC_Read_Byte(0);
// 10.发送停止信号
IIC_Stop();
return temp;
}
// 单字节写入
// 参数1: 要写入的寄存器地址
// 参数2: 要写入的数据
void AT24C02_WriteByte(u16 WriteAddr, u8 data){
// 1.发送开始信号
IIC_Start();
// 2.发送写设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 0);
// 3.等待ack
IIC_Wait_Ack();
// 4.发送要写入的寄存器地址
IIC_Send_Byte(WriteAddr);
// 5.等待ack
IIC_Wait_Ack();
// 6.发送要写入的数据
IIC_Send_Byte(data);
// 7.等待ack
IIC_Wait_Ack();
// 8.发送结束信号
IIC_Stop();
}
// 多字节读
// arg1: 要读取的多个寄存器地址第一个寄存器地址 - 12
// arg2: 存储数据的首地址
// arg3: 要读取的数据个数 : 4
// 12 13 14 15
// char buf[128]; char* pBuffer = buf;
void AT24C02_ReadBlockData(u16 ReadAddr, u8* pBuffer, u16 Len){
// 1.发送开始信号
IIC_Start();
// 2.发送写设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 0);
// 3.等待ack
IIC_Wait_Ack();
// 4.发送要读取的寄存器地址
IIC_Send_Byte(ReadAddr);
// 5.等待ack
IIC_Wait_Ack();
// 6.发送开始信号
IIC_Start();
// 7.发送读设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 1);
// 8.等待ack
IIC_Wait_Ack();
// 9.循环读取多字节数据
// 4 3 2 1
while(Len) {
if(1 == Len)
*pBuffer = IIC_Read_Byte(0); // 读取1字节, 回复nack
else
*pBuffer = IIC_Read_Byte(1); // 读取1字节, 回复ack
pBuffer++;
Len--;
}
// 10.发送stop信号
IIC_Stop();
}
// 多字节写
// arg1: 要写入的多个寄存器地址第一个寄存器地址 - 12
// arg2: 存储数据的首地址
// arg3: 要写入的数据个数 : 4
// 12 13 14 15
// char buf[128]; char* pBuffer = buf;
void AT24C02_WriteBlockData(u16 WriteAddr, u8* pBuffer, u16 Len){
// 1.发送开始信号
IIC_Start();
// 2.发送写设备地址
IIC_Send_Byte(AT24C02_ID << 1 | 0);
// 3.等待ack
IIC_Wait_Ack();
// 4.发送要写入的寄存器地址
IIC_Send_Byte(WriteAddr);
// 5.等待ack
IIC_Wait_Ack();
// 6.循环写入多字节数据
while(Len--) {
// 第一个字节不跨页
IIC_Send_Byte(*pBuffer);
IIC_Wait_Ack();
pBuffer++;
WriteAddr++; // 寄存器地址自增
if (WriteAddr % 8 == 0) { // 跨页
IIC_Stop();
delay_ms(5); // 页写入时间
//----------> 本次传输结束
IIC_Start();
IIC_Send_Byte(AT24C02_ID << 1 | 0);
IIC_Wait_Ack();
IIC_Send_Byte(WriteAddr);
IIC_Wait_Ack();
}
}
// 7.发送停止信号
IIC_Stop();
delay_ms(5);
}
// 4个测试函数
void AT24C02_ReadOne(void){
printf("READ DATA: %#x\n", AT24C02_ReadByte(0X00));
}
void AT24C02_WriteOne(void){
AT24C02_WriteByte(0X00, 0XAA); // [0X00] = 0XAA
}
void AT24C02_ReadMul(void){
u8 data[5] = {0};
AT24C02_ReadBlockData(0x00, data, 5);
u8 i;
for(i = 0; i < 5; i++)
printf("ADDR[%d], DATA[%#x]\n", i, data[i]);
}
void AT24C02_WriteMul(void){
u8 data[5] = {1,2,3,4,5};
// 00 01 02 03 04(寄存器) -> 数据: 1 2 3 4 5
AT24C02_WriteBlockData(0x00, data, 5);
}
附加:i2c 的仲裁机制
今天无意间在刷博客时了解到 I2C 的仲裁机制。当多个主设备连接到一个或多个从机时,若两个主设备同时试图通过 SDA 线路进行数据的发送或接收,就会出现问题。
I2C 总线上的仲裁包含两个部分:SCL 线上的同步和 SDA 线上的仲裁。
-
SCL 线上的同步(时钟同步):I2C 总线具有线 "与" 的逻辑功能,SCL 线上只要有一个节点发送低电平,总线上就呈现低电平;只有当所有节点都发送高电平时,总线才为高电平。因此,时钟低电平时间由时钟电平期最长的器件决定,高电平时间由时钟高电平期最短的器件决定。当多个主机同时发送时钟信号时,总线上表现为统一的时钟信号。从机若希望主机降低传送速度,可将 SCL 主动拉低延长其低电平时间来通知主机,主机在准备下一次传送时若发现 SCL 被拉低则进行等待,直到从机完成操作并释放 SCL 线的控制权。
-
SDA 线上的仲裁:同样基于 I2C 总线的线 "与" 逻辑功能。主机发送数据后,通过比较总线上的数据来决定是否退出竞争。丢失仲裁的主机立即切换到未被寻址的从机状态,以确保能被仲裁胜利的主机寻址。仲裁失败的主机继续输出时钟脉冲(在 SCL 上),直到发送完当前的串行字节。这种机制保证了 I2C 总线在多个主机竞争控制总线时数据不会丢失。
总结
通过本文,我们全面了解了 I2C 总线的定义、协议相关概念、数据传输流程,并通过代码演示了如何使用 GPIO 模拟 I2C 来读写 AT24C02 存储器,还额外探讨了 I2C 的仲裁机制。I2C 作为 STM32 与外设通信的重要方式,其简洁的硬件连接和灵活的通信协议,为嵌入式系统开发带来了诸多便利。
在实际应用中,准确把握 I2C 的工作原理和时序要求,合理运用代码实现数据的读写操作,能确保 STM32 与各类外设之间稳定、高效地通信。同时,理解仲裁机制有助于在多主机环境下保障数据传输的可靠性。
最后
作为技术分享者,我一直致力于用清晰易懂的语言和详细的代码示例,帮助大家深入理解技术知识。但由于技术的复杂性和个人知识的局限性,文中可能存在不足或疏漏之处。非常期待大家在评论区提出宝贵意见和建议,无论是对内容的疑问,还是对代码优化的想法,都欢迎分享。让我们携手在技术学习的道路上不断探索、共同进步!