在嵌入式开发中,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 硬件设计关键要点
- 上拉电阻选型:标准 100kHz 速率推荐 4.7kΩ,范围一般在 4.7k~10kΩ 之间。阻值过小会导致总线电流过大、功耗升高;阻值过大会导致信号上升沿过缓,影响时序稳定性且驱动能力太弱了。
- 电平兼容:开漏输出天然支持跨电平域通信,比如 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){
// 超时机制
}
}
核心说明:
- 读操作必须先执行一次写操作,告诉从机要读取的寄存器地址,再通过重复起始信号切换读方向;
- 伪读是 IMX6ULL I2C 外设的强制要求,切换接收模式后必须先读一次 I2DR,才能触发第一个字节的接收,否则读不到正确数据;
- 必须在倒数第二个字节接收完成前,设置 TXAK 位,确保最后一个字节收到后回复 NACK,通知从机结束传输(为什么要这样呢,因为如果放在最后一个字节前的话,在我切换之前,最后一个字节数据已经接到数据寄存器中了,并且已经回复了ACK应答了,他只是还没有读取,但实际已经接到,所以需要提前);
- 为什么要切换发送模式:因为如果不切换的话,当我执行完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常见问题与排查思路
-
从机无应答,通信完全失败
- 优先检查硬件:上拉电阻是否焊接、引脚复用是否正确、从机地址是否匹配、SDA/SCL 是否接反;
- 用示波器查看 SCL/SDA 波形,确认是否有起始信号和地址输出,判断是主控没发出来,还是从机没应答。
-
读操作数据全 0 或全乱码
- 检查读函数的伪读是否添加、NACK 是否提前设置、接收模式切换是否正确;
- 确认寄存器地址长度是否匹配外设,多字节地址是否按正确顺序发送。
-
AT24C02 连续写入失败
- 检查写入后是否添加了 5ms 延时,EEPROM 内部写入需要时间,未延时会导致后续写入失败;
- 确认写入长度不超过页大小(AT24C02 页大小 8 字节),跨页写入需要分多次操作。
-
LM75A 温度计算时系统死机
- 确认启动时是否调用了 FPU 使能函数,Cortex-A7 内核默认关闭 FPU,未使能时使用浮点运算会触发异常。
六、总结
本文实现的 IMX6ULL I2C 裸机驱动,兼顾了通用性和可移植性,兼容多字节寄存器地址,通过统一的传输接口,可快速适配 OLED、RTC、加速度传感器等绝大多数 I2C 外设。