深入理解 I2C 总线:STM32 外设通信的关键纽带

发布于:2025-05-27 ⋅ 阅读:(14) ⋅ 点赞:(0)

引言

在嵌入式系统的广袤天地中,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 与各类外设之间稳定、高效地通信。同时,理解仲裁机制有助于在多主机环境下保障数据传输的可靠性。

最后

作为技术分享者,我一直致力于用清晰易懂的语言和详细的代码示例,帮助大家深入理解技术知识。但由于技术的复杂性和个人知识的局限性,文中可能存在不足或疏漏之处。非常期待大家在评论区提出宝贵意见和建议,无论是对内容的疑问,还是对代码优化的想法,都欢迎分享。让我们携手在技术学习的道路上不断探索、共同进步!


网站公告

今日签到

点亮在社区的每一天
去签到