IMX6ULL 平台 I2C 总线:从硬件原理到裸机驱动

在嵌入式开发中,I2C 是最常用的低速串行外设总线,凭借两根线即可实现多设备挂载,广泛用于 EEPROM、温度传感器、RTC、OLED 等外设的驱动开发。本文基于 IMX6ULL 平台,从硬件底层原理、总线协议规范,到裸机驱动实现、外设驱动练习的全流程讲解,所有代码均来自实际工程,兼顾通用性与可移植性。

开始之前:首先要知道I2C的类型:同步串行半双工

一、I2C 硬件核心:开漏输出与线与特性

I2C 总线的所有特性,都建立在开漏输出 + 线与特性的基础上。

1.1 线与特性:多设备挂载的基础

I2C 总线支持多设备挂载的核心,就是它的线与特性:当总线上多个设备同时输出电平时,只要有一个设备拉低总线,总线电平就为低;只有所有设备都输出高电平,总线才会呈现高电平

表格

chipA 输出 chipB 输出 总线实际电平
1 0 0
0 1 0
1 1 1
0 0 0

这个特性决定了 I2C 总线不会出现多个设备同时输出高低电平导致的短路问题,也是多主机、多从机通信的硬件基础。

1.2 推挽输出 和 开漏输出

线与特性无法通过常用的推挽输出实现,必须使用开漏输出,我们先看两种输出模式的本质区别:

推挽输出(1.和2两种情况)

推挽输出由两个 MOS 管交替导通实现:

  • 上管 mos1 导通、下管 mos2 截止:输出强高电平
  • 上管 mos1 截止、下管 mos2 导通:输出强低电平

它的优势是高低电平驱动能力都很强,但致命问题是无法实现线与:如果两个推挽输出引脚接在一起,一个输出高、一个输出低,会直接形成 VCC 到 GND 的直流通路,烧毁芯片。因此推挽输出绝对不能用于 I2C 总线。

开漏输出(1和3两种情况)

开漏输出仅保留了推挽结构的下管 mos2,上管完全断开,高电平必须依靠外部上拉电阻实现:

  • mos2 导通:引脚拉到 GND,输出低电平
  • mos2 截止:引脚进入高阻态,电平由外部上拉电阻拉到 VCC

这种模式完美适配线与特性:所有设备都只能拉低总线,高电平靠公共上拉电阻实现,不会出现短路问题,这也是 I2C 总线必须使用开漏输出 + 外部上拉电阻的核心原因。

1.3 硬件设计关键要点

  1. 上拉电阻选型:标准 100kHz 速率推荐 4.7kΩ,范围一般在 4.7k~10kΩ 之间。阻值过小会导致总线电流过大、功耗升高;阻值过大会导致信号上升沿过缓,影响时序稳定性且驱动能力太弱了。
  2. 电平兼容:开漏输出天然支持跨电平域通信,比如 IMX6ULL 的 3.3V 引脚和 51 单片机的 5V 引脚,只要把上拉电阻接到目标电平域,即可实现安全的电平匹配,无需额外的电平转换芯片,因为高电平由上拉电阻确定,与芯片工作电压无关,所以适配能力强。

二、I2C 总线协议规范:驱动编写的时序基础

I2C 是同步、串行、半双工总线,仅靠 SCL(串行时钟线)和 SDA(串行数据线)两根线完成通信,所有传输都严格遵循时序规范。

2.1 核心时序信号

完整的 I2C 通信由起始信号、数据传输、应答、停止信号四个核心部分组成,时序如下:

1. 起始信号(Start)

总线空闲时(SCL、SDA 均为高电平),主机在 SCL 为高电平期间,将 SDA 从高电平拉低,产生起始信号。起始信号由主机发起,起始信号产生后,总线进入忙状态,其他设备不能再发起通信。

2. 停止信号(Stop)

主机在 SCL 为高电平期间,将 SDA 从低电平拉高,产生停止信号。停止信号由主机发起,停止信号产生后,总线回到空闲状态,释放总线控制权。

3. 数据传输规则
  • 数据位宽:每次传输 1 个字节(8bit),遵循MSB 优先(高位在前)的传输顺序。
  • 时序约束:SCL 低电平时,发送方才可以改变 SDA 的电平;SCL 高电平时,SDA 必须保持稳定,接收方在此期间采样 SDA 电平。这是 I2C 通信的核心规则,时序搞反必然导致数据采样错误。
4. 应答信号(ACK/NACK)

每传输完 8bit 数据,会占用第 9 个时钟周期传输应答信号,确保接收方成功收到数据:

  • ACK(应答):接收方将 SDA 拉低,表示数据接收成功。
  • NACK(非应答):接收方保持 SDA 为高电平,表示数据接收失败,或主机通知从机结束读传输。

2.2 完整通信帧结构

I2C 的标准读写传输都遵循固定的帧格式,所有操作都包裹在起始和停止信号之间:

复制代码
起始信号 + 7位从机地址 + 1位读写位 + 应答 + [寄存器地址 + 应答] + 数据 + 应答 + ... + 停止信号
  • 7 位从机地址:总线上每个从机都有唯一的 7 位地址,主机通过地址匹配目标从机。
  • 1 位读写位:0 表示主机向从机写数据,1 表示主机从从机读数据。
  • 寄存器地址:外设内部的寄存器地址,长度可以是 1 字节、2 字节等,由外设决定。

三、IMX6ULL I2C 裸机驱动

基于上述协议,我们实现了一套通用的 IMX6ULL I2C 裸机驱动,兼容多字节寄存器地址,支持标准 100kHz 速率,可直接适配绝大多数 I2C 外设。

3.1 核心寄存器说明

IMX6ULL 的 I2C 外设操作完全基于寄存器,核心寄存器如下:

表格

寄存器 全称 核心功能
I2CR I2C 控制寄存器 使能外设、主从模式切换、发送 / 接收模式切换、应答配置、起始 / 停止信号生成
I2SR I2C 状态寄存器 传输完成标志、应答状态、总线忙状态、仲裁丢失状态查询
IFDR I2C 频率分频寄存器 配置 SCL 时钟分频系数,设置总线速率
I2DR I2C 数据寄存器 写入数据即发起发送,读取即获取总线上收到的数据

3.2 驱动代码逐行分析

驱动分为初始化、等待函数、写操作、读操作、通用传输接口五个部分,完整代码如下:

1. 总线初始化函数

c

运行

复制代码
void i2c_init(I2C_Type *base)
{
    //1. 先失能I2C外设,配置必须在失能状态下执行
    base->I2CR &= ~I2CR_IEN;
    //2. 配置分频系数,设置SCL速率为100kHz
    base->IFDR = 0x15;
    //3. 使能I2C外设
    base->I2CR |= I2CR_IEN;
}
  • 初始化必须先关闭外设,否则寄存器配置不生效;
  • 0x15 对应 66MHz IPG 时钟分频后得到 100kHz 标准速率;
  • 引脚复用与电气配置需要在外设初始化前完成,后续外设驱动会详细说明。
2. 传输完成等待与状态检查函数

这是所有传输的基础,负责等待单字节传输完成,并检查从机应答状态:

c

运行

复制代码
static int wait_i2c_iif(I2C_Type *base)
{
    // 轮询等待传输完成标志IIF置1
    while((base->I2SR & I2SR_IIF) == 0){
        // 工程化优化:此处必须添加超时机制,避免总线异常死循环
    }
    // 软件清除IIF标志位,必须手动清0
    base->I2SR &= ~I2SR_IIF;
    
    // 检查从机应答:RXAK为0表示收到ACK,为1表示无应答
    if((base->I2SR & I2SR_RXAK) == 0){
        return 0;
    } else {
        return -1;
    }
}
  • 每发送 / 接收一个字节,IIF 位都会置 1,必须软件清除;
  • 无应答时返回 - 1,上层可直接终止传输,避免无效操作;
  • 工程化开发必须添加超时机制,比如软件计数器或定时器,防止总线异常时卡死。
3. 主机写操作实现

主机向从机指定寄存器地址写入数据,兼容多字节寄存器地址:

c

运行

复制代码
static void i2c_write(I2C_Type *base, unsigned char dev_addr, 
                      unsigned short reg_addr, int reg_len, 
                      unsigned char *data, int len)
{
    int stat = 0;
    // 0. 清除之前的仲裁丢失、传输完成标志
    base->I2SR &= ~(I2SR_IAL | I2SR_IIF);
    // 1. 设置为发送模式:接下来要发地址、寄存器、数据,都是主机发送
    base->I2CR |= I2CR_MTX;
    // 2. 置位MSTA,产生起始信号,切换为主机模式
    base->I2CR |= I2CR_MSTA;
    
    // 3. 发送7位从机地址 + 写标志位(0)
    base->I2DR = (dev_addr << 1) | 0;
    stat = wait_i2c_iif(base);
    if (stat != 0) goto stop; // 无应答直接终止
    
    // 4. 发送寄存器地址,兼容多字节地址(1/2/4字节)
    int j = reg_len - 1;
    for (; j >= 0; j--){
        // 从高字节到低字节依次发送
        base->I2DR = (reg_addr >> (j * 8));
        stat = wait_i2c_iif(base);
        if (stat != 0) goto stop;
    }
    
    // 5. 循环发送用户数据
    int i = 0;
    for (; i < len; i++){
        base->I2DR = data[i];
        stat = wait_i2c_iif(base);
        if (stat != 0) goto stop;
    }

stop:
    // 6. 清0 MSTA,产生停止信号,释放总线
    base->I2CR &= ~I2CR_MSTA;
    // 等待总线空闲,确保停止信号发送完成
    while((base->I2SR & I2SR_IBB) != 0){
        // 同样建议添加超时机制
    }
}
  • 核心优化:通过循环实现多字节寄存器地址兼容,适配 16 位地址的大容量 EEPROM、传感器等外设;
  • 每一步传输都检查应答,异常直接跳转到停止信号,避免总线卡死;
  • 停止信号发送后必须等待 IBB 位清 0,确保总线完全释放。
4. 主机读操作实现

主机从从机指定寄存器地址读取数据,是驱动中稍稍有点难以理解的部分,尤其是下面第十部中的两次切换:

c

运行

复制代码
static void i2c_read(I2C_Type *base, unsigned char dev_addr,  
                     unsigned short reg_addr, int reg_len, 
                     unsigned char *data, int len)
{
    int stat = 0;
    // 0. 清除状态标志
    base->I2SR &= ~(I2SR_IAL | I2SR_IIF);
    // 1. 初始化为发送模式:先写寄存器地址
    base->I2CR |= I2CR_MTX;
    // 2. 产生起始信号
    base->I2CR |= I2CR_MSTA;
    
    // 3. 发送从机地址 + 写标志位:告诉从机要写寄存器地址
    base->I2DR = (dev_addr << 1) | 0;
    stat = wait_i2c_iif(base);
    if (stat != 0) goto stop;
    
    // 4. 发送寄存器地址,兼容多字节
    int j = reg_len - 1;
    for (; j >= 0; j--){
        base->I2DR = (reg_addr >> (j * 8));
        stat = wait_i2c_iif(base);
        if (stat != 0) goto stop;
    }
    
    // 5. 产生重复起始信号RSTA:切换传输方向,不释放总线
    base->I2CR |= I2CR_RSTA;
    // 6. 发送从机地址 + 读标志位(1):告诉从机接下来主机要读数据
    base->I2DR = (dev_addr << 1) | 1;
    stat = wait_i2c_iif(base);
    if (stat != 0) goto stop;
    
    // 7. 切换为接收模式:主机准备接收从机的数据
    base->I2CR &= ~I2CR_MTX;
    // 8. 配置应答模式:多字节读取时,除最后一个字节外都回复ACK
    if (len > 1){
        base->I2CR &= ~I2CR_TXAK; // 收到数据后回复ACK
    } else {
        base->I2CR |= I2CR_TXAK;  // 仅1字节时,收到后回复NACK
    }
    
    // 9. 伪读:IMX6ULL I2C外设特性,切换接收模式后首次读触发接收
    data[0] = base->I2DR;
    
    // 10. 循环读取数据
    int i = 0;
    for (; i < len; i++){
        // 等待单字节接收完成
        stat = wait_i2c_iif(base);
        
        // 倒数第二个字节:提前设置TXAK,最后一个字节回复NACK
        if (i == (len - 2)){
            base->I2CR |= I2CR_TXAK;
        }
        // 最后一个字节:提前切换为发送模式,如果不切换的话stop信号无法正确产生,会卡死总线
        if (i == (len - 1)){
            base->I2CR |= I2CR_MTX;
        }
        
        // 读取收到的数据
        data[i] = base->I2DR;
        if (stat != 0) goto stop;
    }

stop:
    // 11. 发送停止信号,释放总线
    base->I2CR &= ~I2CR_MSTA;
    while((base->I2SR & I2SR_IBB) != 0){
        // 超时机制
    }
}

核心说明

  1. 读操作必须先执行一次写操作,告诉从机要读取的寄存器地址,再通过重复起始信号切换读方向;
  2. 伪读是 IMX6ULL I2C 外设的强制要求,切换接收模式后必须先读一次 I2DR,才能触发第一个字节的接收,否则读不到正确数据;
  3. 必须在倒数第二个字节接收完成前,设置 TXAK 位,确保最后一个字节收到后回复 NACK,通知从机结束传输(为什么要这样呢,因为如果放在最后一个字节前的话,在我切换之前,最后一个字节数据已经接到数据寄存器中了,并且已经回复了ACK应答了,他只是还没有读取,但实际已经接到,所以需要提前);
  4. 为什么要切换发送模式:因为如果不切换的话,当我执行完data[i] = base->I2DR,即取得最后一个字节数据时,按理说应该发送stop信号,然而由于此时处于接受状态,你一读取base->I2DR,硬件就会自动触发去接取下一次数据,然而这已经是最后一个了,他会多产生一个8+1时钟周期,导致stop信号无法正常发送,从而卡死总线
5. 通用传输接口封装

为了提升代码复用性我们将读写操作封装为统一的传输接口,通过结构体管理传输参数,上层应用无需关心底层时序:

首先定义传输消息结构体:

c

运行

复制代码
// 传输方向枚举
enum I2C_Dir {
    I2C_Write = 0,
    I2C_Read = 1
};

// I2C传输消息结构体
struct I2C_Msg {
    unsigned char dev_addr;  // 7位从机地址
    unsigned short reg_addr; // 从机寄存器地址
    int reg_len;             // 寄存器地址字节数
    unsigned char *data;     // 数据缓冲区
    int len;                 // 数据长度
    enum I2C_Dir dir;        // 传输方向
};

通用传输接口:

c

运行

复制代码
void transfer(I2C_Type *base, struct I2C_Msg *msg)
{
    // 入口参数校验
    if (base == NULL || msg == NULL)
    {
        return;
    }
    // 根据传输方向调用读写函数
    if (msg->dir == I2C_Write){
        i2c_write(base, msg->dev_addr, msg->reg_addr, msg->reg_len, msg->data, msg->len);
    } else {
        i2c_read(base, msg->dev_addr, msg->reg_addr, msg->reg_len, msg->data, msg->len);
    }
}

四、裸机驱动练习:

基于上述通用驱动,我们实现两个常用的 I2C 外设:AT24C02 EEPROM 和 LM75A 温度传感器,验证驱动的可用性。

4.1 AT24C02 EEPROM 驱动

AT24C02 是 2Kbit(256 字节)的 I2C 接口 EEPROM,掉电数据不丢失,常用于存储设备配置参数。

  • 7 位从机地址:0x50(A0/A1/A2 引脚接地)
  • 寄存器地址:1 字节(0x00~0xFF)
  • 页写入时间:最大 5ms,写入后必须延时等待

完整驱动代码:

c

运行

复制代码
#include "at24c02.h"
#include "fsl_iomuxc.h"
#include "i2c.h"
#include "gpt.h"

// AT24C02初始化:引脚复用+I2C外设初始化
void at24c02_init(void)
{
    // 1. 引脚复用:UART4_RX/TX复用为I2C1_SDA/SCL
    IOMUXC_SetPinMux(IOMUXC_UART4_RX_DATA_I2C1_SDA, 1);
    IOMUXC_SetPinMux(IOMUXC_UART4_TX_DATA_I2C1_SCL, 1);
    // 2. 引脚电气配置:开漏模式、上拉、100MHz速率
    IOMUXC_SetPinConfig(IOMUXC_UART4_RX_DATA_I2C1_SDA, 0x10B0);
    IOMUXC_SetPinConfig(IOMUXC_UART4_TX_DATA_I2C1_SCL, 0x10B0);
    // 3. I2C1外设初始化
    i2c_init(I2C1);
}

// 从指定地址读取len个字节
void at24c02_read(unsigned char reg_addr, unsigned char *data, int len)
{
    struct I2C_Msg msg = {
        .dev_addr = 0x50,
        .reg_addr = reg_addr,
        .reg_len = 1,
        .data = data,
        .len = len,
        .dir = I2C_Read
    };
    transfer(I2C1, &msg);
}

// 向指定地址写入len个字节
void at24c02_write(unsigned char reg_addr, unsigned char *data, int len)
{
    struct I2C_Msg msg = {
        .dev_addr = 0x50,
        .reg_addr = reg_addr,
        .reg_len = 1,
        .data = data,
        .len = len,
        .dir =  I2C_Write
    };
    transfer(I2C1, &msg);
    // 必须延时5ms,等待EEPROM完成内部写入
    delay_ms(5);
}

4.2 LM75A 温度传感器驱动

LM75A 是数字温度传感器,测量范围 - 55℃~+125℃,广泛用于工业测温场景。

  • 7 位从机地址:0x48
  • 温度寄存器地址:0x00,16 位(2 字节)
  • 数据格式:11 位二进制补码,右移 7 位后乘以 0.5 即为实际温度
  • 浮点运算依赖 FPU,必须提前使能 Cortex-A7 的 FPU

完整驱动代码:

c

运行

复制代码
#include "lm75a.h"
#include "fsl_iomuxc.h"
#include "i2c.h"
#include "gpt.h"

#define LM75A_ADDR 0x48

// LM75A初始化,同AT24C02,同一总线只需初始化一次
int lm75a_init(void)
{
    IOMUXC_SetPinMux(IOMUXC_UART4_RX_DATA_I2C1_SDA, 1);
    IOMUXC_SetPinMux(IOMUXC_UART4_TX_DATA_I2C1_SCL, 1);
    IOMUXC_SetPinConfig(IOMUXC_UART4_RX_DATA_I2C1_SDA, 0x10B0);
    IOMUXC_SetPinConfig(IOMUXC_UART4_TX_DATA_I2C1_SCL, 0x10B0);
    i2c_init(I2C1);
    return 0;
}

// 读取当前温度,单位:℃
float lm75a_read_temperature( void)
{
    unsigned char buf[2] = {0};
    short temp = 0;
    // 填充传输消息,读取2字节温度数据
    struct I2C_Msg msg = {
        .dev_addr = LM75A_ADDR,
        .reg_addr = 0x00,
        .reg_len = 1,
        .data = buf,
        .len = 2,
        .dir = I2C_Read
    };
    transfer(I2C1, &msg);
    
    // 拼接16位数据,高字节在前,低字节在后
    temp = (buf[0] << 8) | buf[1];
    // 11位有效数据,右移7位
    temp = temp >> 7;
    // 转换为实际温度,分辨率0.5℃
    return temp * 0.5;
}

FPU 使能汇编代码(必须在启动时调用,否则浮点运算会触发硬件异常):

asm

复制代码
enable_fpu:
    // 1. 设置CPACR寄存器,使能CP10和CP11协处理器(FPU)完全访问权限
    mrc     p15, 0, r0, c1, c0, 2
    orr     r0, r0, #(0xF << 20)
    mcr     p15, 0, r0, c1, c0, 2
    
    // 2. 使能FPU,设置FPEXC的EN位
    mov     r0, #0x40000000
    vmsr    fpexc, r0
    
    // 3. 清除FPSCR所有标志位,初始化浮点状态
    mov     r0, #0x00000000
    vmsr    fpscr, r0
    
    bx      lr

五、常见问题排查

5.1常见问题与排查思路

  1. 从机无应答,通信完全失败

    • 优先检查硬件:上拉电阻是否焊接、引脚复用是否正确、从机地址是否匹配、SDA/SCL 是否接反;
    • 用示波器查看 SCL/SDA 波形,确认是否有起始信号和地址输出,判断是主控没发出来,还是从机没应答。
  2. 读操作数据全 0 或全乱码

    • 检查读函数的伪读是否添加、NACK 是否提前设置、接收模式切换是否正确;
    • 确认寄存器地址长度是否匹配外设,多字节地址是否按正确顺序发送。
  3. AT24C02 连续写入失败

    • 检查写入后是否添加了 5ms 延时,EEPROM 内部写入需要时间,未延时会导致后续写入失败;
    • 确认写入长度不超过页大小(AT24C02 页大小 8 字节),跨页写入需要分多次操作。
  4. LM75A 温度计算时系统死机

    • 确认启动时是否调用了 FPU 使能函数,Cortex-A7 内核默认关闭 FPU,未使能时使用浮点运算会触发异常。

六、总结

本文实现的 IMX6ULL I2C 裸机驱动,兼顾了通用性和可移植性,兼容多字节寄存器地址,通过统一的传输接口,可快速适配 OLED、RTC、加速度传感器等绝大多数 I2C 外设。

相关推荐
kelleyv2 小时前
C语言过时了?C3和Zig谁能拯救它
c语言·zig·c3·系统级开发·语言革新
沉鱼.442 小时前
第十三届题目
c语言·c++·算法
mftang4 小时前
ARM架构和主要内核介绍-D
arm开发·cortex-r·cortex-m·cortex-a
算法鑫探5 小时前
10个数下标排序:最大值、最小值与平均值(下)
c语言·数据结构·算法·排序算法·新人首发
少司府5 小时前
C++基础入门:类和对象(中)
c语言·开发语言·c++·类和对象·运算符重载·默认成员函数
大神的风范6 小时前
QT部署YOLO11实时检测
驱动开发·深度学习·qt·目标检测·计算机视觉
爱编码的小八嘎6 小时前
C语言完美演绎7-5
c语言
信工 18026 小时前
rk3568-Linux应用程序和驱动程序接口
linux·驱动开发·rk3568
REDcker6 小时前
OpenSSL:C 语言 TLS 客户端完整示例
c语言·网络·数据库