3. ARM处理器I2C控制器硬件原理

I2C总线,说白了就是两根线(SCL时钟、SDA数据)搞定一堆设备通信。很多刚入行的兄弟觉得它简单,不就是发个地址、读个数据嘛。但真到了调驱动的时候,时序不对、仲裁失败、从机不响应……各种坑等着你。我最早在某个项目里就被I2C的ACK/NACK坑过一整天,后来才发现是地址匹配逻辑没搞透。

这一节,咱们就把I2C控制器的内部架构、时序细节、寄存器操作彻底讲明白。你把这些吃透了,写驱动就是水到渠成的事。

3.1 I2C控制器内部架构

一个完整的ARM I2C控制器,内部大致包含这几个核心模块:

  • 总线仲裁单元——解决多主机冲突
  • 地址匹配单元——判断是不是在叫自己
  • 数据缓冲单元——收发数据的临时仓库
  • 时序控制单元——生成SCL时钟、控制SDA跳变
  • 中断/状态管理单元——通知CPU干活

我个人习惯把I2C控制器想象成一个“智能收发室”:CPU把要发的数据丢进缓冲,控制器自己按I2C协议把数据搬出去;收到数据后,再通知CPU来取。你想想看,这样CPU就不用盯着每一条SCL时钟了,效率高很多。

核心要点:I2C控制器是硬件状态机,不是CPU模拟的GPIO翻转。它内部有专门的移位寄存器、比较器和时钟分频器,能自动完成起始条件、地址发送、数据收发、应答检测等操作。

3.1.1 总线仲裁

I2C允许多个主机挂在同一条总线上。如果两个主机同时拉低SDA,谁说了算?

仲裁机制很简单:谁先输出低电平,谁就赢得总线。因为I2C的SDA是开漏输出,低电平会覆盖高电平。仲裁失败的控制器会自动释放总线,转为从机模式。

我曾经在一个多主机的项目中遇到过仲裁失败导致数据错乱的问题。排查了半天,发现是一个从机的地址和另一个主机的数据发生了冲突。嗯,这里要注意:仲裁只发生在SDA上,SCL始终由主机控制。

场景 主机A输出 主机B输出 SDA实际电平 结果
仲裁获胜 低 高 低 主机A继续,主机B退出
仲裁失败 高 低 低 主机B继续,主机A退出
数据一致 低 低 低 两者都继续

3.1.2 地址匹配

每个I2C从机都有一个7位或10位地址。主机发送地址后,控制器内部的地址比较器会把收到的地址和自身配置的地址寄存器做对比。匹配上了,就拉低SDA发ACK;没匹配上,就保持高电平发NACK。

我记得有一次调试一个温湿度传感器,怎么都读不到数据。用逻辑分析仪一看,主机发的地址是0x5A,但从机地址其实是0x5B。差了一个bit,从机根本不响应。所以写驱动时,地址一定要左移一位,因为I2C协议里地址字节的最后一位是读写标志位。

避坑指南:我曾经在配置从机地址时,直接把数据手册上的7位地址写进了地址寄存器,结果死活收不到ACK。后来才发现,控制器硬件会自动把地址左移一位,再把读写位拼到LSB。所以寄存器里填的应该是7位地址本身,不需要手动移位。

3.1.3 数据缓冲

数据缓冲通常是FIFO(先进先出)结构,深度从8字节到64字节不等。发送时,CPU把数据写入FIFO,控制器自动按位送出;接收时,控制器把收到的字节存入FIFO,CPU再读走。

缓冲的好处很明显:

  • 减少CPU中断频率——一次中断可以处理多个字节
  • 提高传输效率——CPU不用等每个bit
  • 支持DMA传输——数据直接在FIFO和内存之间搬

但要注意,FIFO有溢出风险。我曾经在一个项目中,接收中断处理得太慢,FIFO满了之后新数据直接把旧数据覆盖了。后来把FIFO阈值调低,配合DMA才解决。

3.2 I2C时序详解

I2C的时序其实不复杂,但每个细节都关系到通信成败。咱们一个一个说。

3.2.1 起始条件(START)

SCL为高电平时,SDA从高电平跳变到低电平。这个下降沿告诉所有从机:总线要开始传输了。

控制器内部是怎么实现的?其实就是软件或硬件控制GPIO输出:先拉高SCL,再拉低SDA,然后拉低SCL准备传数据。时序图上看就是SDA先于SCL变化。

3.2.2 停止条件(STOP)

SCL为高电平时,SDA从低电平跳变到高电平。这个上升沿告诉所有从机:传输结束,总线释放。

我个人习惯在每次传输结束后,检查一下总线上有没有残留的时钟脉冲。有些从机在收到STOP后还需要一点时间才能释放总线,如果紧接着发下一个START,可能会造成总线冲突。

3.2.3 ACK/NACK

每传输完一个字节(8位),接收方需要在第9个SCL周期拉低SDA表示ACK,或者保持高电平表示NACK。

常见的NACK场景:

  • 地址不匹配——从机没收到自己的地址
  • 从机忙——正在处理上次的数据
  • 从机不支持该操作——比如只写不读
  • 主机主动NACK——读操作最后一个字节,主机发NACK告诉从机不用再发了

注意:如果主机在发送地址后收到NACK,必须发STOP释放总线,否则从机可能一直处于等待状态。我见过有人忘记处理NACK,结果总线被锁死,所有设备都动不了。

I2C完整传输时序(写操作示例) SCL SDA START 从机地址 (7位) W ACK 数据字节 (8位) ACK STOP 时序说明: 1. START: SCL高电平时,SDA从高→低 2. 地址+读写位: 主机发送7位从机地址 + 1位读写标志(0=写,1=读) 3. ACK: 从机在第9个SCL周期拉低SDA表示响应 4. 数据: 每字节后跟ACK,直到主机发STOP或从机发NACK 5. STOP: SCL高电平时,SDA从低→高

3.3 ARM I2C寄存器详解

不同ARM厂商的I2C寄存器略有差异,但核心寄存器基本一致。我以最常见的ARM I2C控制器为例,讲清楚每个寄存器的用途和配置要点。

3.3.1 控制寄存器(I2C_CON)

这个寄存器控制I2C控制器的工作模式。关键位如下:

位域 名称 功能描述
[0] I2C_EN 使能I2C控制器,0=禁用,1=使能
[1] MASTER 主机模式使能,0=从机,1=主机
[2] TX_RX 传输方向,0=接收,1=发送
[3] STOP 产生停止条件,写1后自动发STOP
[4] RESTART 产生重复起始条件,用于组合传输
[7:6] SPEED 速率选择:00=标准(100kHz),01=快速(400kHz),10=高速(3.4MHz)

个人经验:配置控制寄存器时,我习惯先禁掉I2C_EN,把所有参数设好,最后再使能。否则在配置过程中,控制器可能因为状态不确定而产生错误的时序。

3.3.2 地址寄存器(I2C_ADDR)

从机模式下,这个寄存器存放本设备的地址。主机模式下,用来配置目标从机地址。

注意:地址寄存器存放的是7位地址,不是8位。读写位由控制器自动拼接到LSB。比如从机地址是0x3C,寄存器里就写0x3C,不要写0x78或0x79。

3.3.3 数据寄存器(I2C_DATA)

发送时,CPU往这个寄存器写数据,控制器自动移位输出。接收时,CPU从这个寄存器读数据。

有些控制器支持FIFO模式,数据寄存器对应的是FIFO的入口。这种情况下,连续读写数据寄存器就是在操作FIFO。

3.3.4 状态寄存器(I2C_STAT)

这个寄存器是调试I2C问题的关键。我每次调I2C驱动,第一件事就是打印状态寄存器,看看控制器卡在哪一步。

位域 名称 含义
[0] BUSY 总线忙,正在传输
[1] ARB_LOST 仲裁丢失,多主机冲突
[2] ACK_RCVD 收到ACK(0=收到NACK,1=收到ACK)
[3] TX_EMPTY 发送缓冲空,可以写新数据
[4] RX_FULL 接收缓冲满,有数据可读
[5] STOP_DET 检测到停止条件
[7] ERROR 传输错误(超时、仲裁丢失等)

重要提醒:状态寄存器通常是写1清零的。读完后如果不手动清除,下次判断状态时可能会读到旧值。我见过有人读状态寄存器后忘了清零,结果一直以为总线忙,程序卡死在等待循环里。

3.4 典型初始化流程

最后,我给出一个典型的I2C控制器初始化流程,你写驱动时可以照着这个框架来:

// 1. 使能I2C时钟(时钟管理寄存器)
CLK_ENABLE(I2C_CLOCK);

// 2. 配置GPIO为I2C功能(SCL和SDA)
GPIO_SET_ALT_FUNC(SCL_PIN, I2C_FUNC);
GPIO_SET_ALT_FUNC(SDA_PIN, I2C_FUNC);
GPIO_SET_OPEN_DRAIN(SCL_PIN);
GPIO_SET_OPEN_DRAIN(SDA_PIN);

// 3. 复位I2C控制器
I2C_CON = 0;
I2C_RESET();

// 4. 配置时钟分频(目标频率 = 时钟源 / 分频系数)
I2C_CLKDIV = CLOCK_SRC / (4 * TARGET_FREQ);

// 5. 配置控制寄存器(先禁能)
I2C_CON = (MASTER_MODE | SPEED_400K);

// 6. 使能I2C控制器
I2C_CON |= I2C_EN;

// 7. 检查总线是否空闲
while (I2C_STAT & BUSY_BIT)
    ; // 等待总线释放