引言
在嵌入式系统的广袤天地中,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 与各类外设之间稳定、高效地通信。同时,理解仲裁机制有助于在多主机环境下保障数据传输的可靠性。
最后
作为技术分享者,我一直致力于用清晰易懂的语言和详细的代码示例,帮助大家深入理解技术知识。但由于技术的复杂性和个人知识的局限性,文中可能存在不足或疏漏之处。非常期待大家在评论区提出宝贵意见和建议,无论是对内容的疑问,还是对代码优化的想法,都欢迎分享。让我们携手在技术学习的道路上不断探索、共同进步!