摘要

1 引言

2 IIC 总线原理

2.1 IIC 总线结构

3 IIC 基本通信时序

3.1 IIC 总线基本时序

1 起始条件(START)

2 停止条件(STOP)

3 数据传输

4 应答信号

4 Hardware_IIC 说明文档

4.1 文件说明

4.2 当前工程协议约定

4.2.1 从机地址

4.2.2 从机寄存器空间

4.2.3 主机演示访问范围

4.3 主机侧工作原理

4.3.1 主机初始化做了什么

4.3.2 主机写寄存器流程

4.3.3 主机读寄存器流程

4.3.4 主机为什么要做超时和 ABRT 检查

4.4 从机侧工作原理

4.4.1 从机初始化做了什么

4.4.2 从机如何识别“寄存器地址字节”

4.4.3 从机写事务如何处理

4.4.4 从机读事务如何处理

4.4.5 为什么正常读完也可能出现 TX_ABRT

4.5 主从交互完整流程

4.5.1 写事务流程

4.5.2 读事务流程

4.6 调试时建议关注的现象

4.6.1 串口输出

4.6.2 如果主机打印 write err / read err

4.6.3 如果从机打印 slave err flags

5. 总结

摘要

IIC(Inter-Integrated Circuit)总线是一种广泛应用于嵌入式系统中的同步串行通信协议,具有布线简单、支持多设备通信以及硬件资源消耗低等特点。本文基于 MCU 硬件 IIC 控制器,设计并实现了一种典型的寄存器映射型 IIC 主从通信系统。系统采用主机—从机结构,从机内部维护寄存器表,通过标准寄存器读写协议完成数据交互。文章首先分析 IIC 总线工作原理与通信时序,然后结合实际工程代码,对主机寄存器访问流程、从机中断驱动机制以及异常处理策略进行了系统分析。进一步通过通信状态机和代码流程图解释系统软件结构。实验结果表明,该系统能够稳定完成连续寄存器读写,并具有良好的可扩展性和工程应用价值。


1 引言

在现代嵌入式系统中,大量外围器件需要与主控制器进行通信,例如温度传感器、EEPROM 存储器、实时时钟以及各种数据采集芯片。为了降低系统复杂度,工业界广泛采用串行通信总线。IIC 总线因其结构简单、可靠性高而成为最常用的通信接口之一。

IIC 总线最初由 Philips 公司提出,其设计目标是实现芯片之间的短距离通信。与 SPI 等总线相比,IIC 仅需要两条信号线即可实现多设备通信,因此特别适合资源受限的嵌入式系统。

在实际工程中,大多数 IIC 设备都采用 寄存器映射通信模型。主机首先发送寄存器地址,然后进行数据读写操作。典型应用包括:

  • EEPROM 存储器

  • 温度传感器

  • RTC 实时时钟

  • ADC/DAC 芯片

本文通过一个 MCU 硬件 IIC 控制器实现完整的主从通信系统,其中:

  • 主机负责寄存器访问

  • 从机维护寄存器表

  • 通信遵循标准寄存器访问协议

该系统能够很好地模拟真实 IIC 外设的通信方式。


2 IIC 总线原理

2.1 IIC 总线结构

IIC 总线由两条信号线构成:

信号线 功能
SCL 时钟线
SDA 数据线

IIC 总线采用 开漏(Open-Drain)输出结构,所有设备共享总线,并通过上拉电阻维持高电平。

这种设计具有两个重要特点:

  1. 支持多设备并联

  2. 避免总线冲突

在代码实现中,GPIO 被配置为:

  • 复用模式

  • 开漏输出

  • 上拉电阻


3 IIC 基本通信时序

3.1 IIC 总线基本时序

Understanding I2C Errors  Dev Center

IIC 通信由以下几个基本元素构成:

1 起始条件(START)

当 SCL 为高电平时 SDA 从高变低,表示通信开始。

2 停止条件(STOP)

当 SCL 为高电平时 SDA 从低变高,表示通信结束。

3 数据传输

每个数据字节包含 8 位数据。

4 应答信号

第 9 个时钟周期用于 ACK。

  • SDA = 0 → ACK

  • SDA = 1 → NACK


4 Hardware_IIC 说明文档

4.1 文件说明

本目录现在包含两份 IIC 示例源码:

  • Master_IIC.c
  • Slaver_IIC.c

两者配套使用,实现的是一个最小可用的“寄存器表型”I2C 主从通信模型。

此工程中的主机和从机的源代码从百度网盘中获取:

链接: https://pan.baidu.com/s/1q-m_srDCGYbLuIwx9PgKyg?pwd=2p45 提取码: 2p45 

这个模型的特点是:

  • 主机先写 1 个寄存器地址。
  • 如果后面继续写数据,则从该地址开始顺序写入从机寄存器表。
  • 如果后面改为读操作,则从该地址开始顺序读取从机寄存器表。
  • 从机内部维护一个 RegisterMap[] 数组,主机看到的“寄存器”本质上就是这个数组。

4.2 当前工程协议约定

4.2.1 从机地址

  • 当前从机地址:0x28
  • 地址类型:7 位地址

这个地址已经避开了 I2C 保留地址区,适合工程使用。

4.2.2 从机寄存器空间

从机内部定义了:

  • RegisterMap[32]

也就是提供了 32 个逻辑寄存器,地址范围为:

  • 0x00 ~ 0x1F

4.2.3 主机演示访问范围

当前主机示例每次都访问:

  • 起始寄存器:0x00
  • 连续长度:8 字节

因此主机每轮的动作是:

  1. 向从机 0x280x00 开始位置写入 8 字节。
  2. 再从从机 0x280x00 开始位置读回 8 字节。
  3. 通过串口打印读回结果。

4.3 主机侧工作原理

4.3.1 主机初始化做了什么

Master_IIC.c 中主机初始化主要做了以下几件事:

  1. 打开 GPIOA/GPIOB/USART0/I2C0 时钟。
  2. PA9/PA10 配置为 I2C 复用功能。
  3. 把 I2C 引脚输出类型改为开漏 GPIO_OType_OD
  4. 初始化 USART0,作为调试串口。
  5. 初始化 I2C0 为主机模式。
  6. 设置 I2C 速率为 100kHz
  7. 显式打开 RESTEN,允许主机发送 repeated START。

这里最关键的一点是 repeated START,因为“写寄存器地址后立即读数据”正是大多数寄存器型器件的标准访问方式。

4.3.2 主机写寄存器流程

主机函数:

  • I2C_MasterWriteRegisters()

对应的总线时序是:

START
SlaveAddr + W
RegisterAddr
Data0
Data1
...
DataN
STOP

函数内部逻辑如下:

  1. 先等待 I2C 总线空闲。
  2. 把目标从机地址写入 TAR
  3. DATACMD 先写 1 个寄存器地址字节。
  4. 再顺序写入数据字节。
  5. 最后 1 个字节附加 STOP 位。
  6. 等待硬件真正发送完成并返回空闲。

4.3.3 主机读寄存器流程

主机函数:

  • I2C_MasterReadRegisters()

对应总线时序是:

START
SlaveAddr + W
RegisterAddr
RESTART
SlaveAddr + R
Read0
Read1
...
ReadN
NACK
STOP

要注意,这个芯片的 I2C 读不是“直接读寄存器就能拿到总线数据”,而是:

  1. 先向 DATACMD 写“读命令”。
  2. 硬件收到该命令后才去总线上发起读。
  3. 数据回来后放进 RX FIFO。
  4. CPU 再从 RX FIFO 里把数据取出来。

所以代码里的本质动作是:

  1. 先发 1 个写命令,把寄存器地址送给从机。
  2. 再发若干个 CMD=1 的读命令。
  3. 第一条读命令带 RESTART
  4. 最后一条读命令带 STOP
  5. 再从 RX FIFO 逐字节取走读回数据。

4.3.4 主机为什么要做超时和 ABRT 检查

示例版最容易出问题的地方,是所有等待都可能无限死循环。

工程版里加入了:

  • I2C_TIMEOUT_COUNT
  • I2C_MasterWaitStatus()
  • I2C_MasterCheckAbort()
  • I2C_MasterAbortTransfer()

这样做的意义是:

  • 如果从机没应答,不会永远卡死。
  • 如果地址或数据被 NACK,可以通过 TX_ABRT_SOURCE 看到具体原因。
  • 如果总线状态异常,可以主动请求 ABORT,减少死锁概率。

4.4 从机侧工作原理

4.4.1 从机初始化做了什么

Slaver_IIC.c 中从机初始化做了以下配置:

  1. 初始化 I2C0 为纯从机模式。
  2. 设置从机地址为 0x28
  3. 使用 7 位地址格式。
  4. 打开 RX_FULL / RD_REQ / RX_DONE / STOP_DET / TX_ABRT / RX_OVER 中断。
  5. 设置 FIFO 阈值为 0。
  6. 允许 RX FIFO 满时保持总线。

这些配置使得从机更像真实外设:

  • 写操作能及时取数。
  • 读操作能及时准备回送数据。
  • 异常路径有状态可查。

4.4.2 从机如何识别“寄存器地址字节”

从机使用了手册里的 FDB 机制。

手册定义:

  • 在从机接收模式下,地址后的第 1 个数据字节,DATACMD.FDB = 1

工程版代码把这个字节解释为:

  • 当前寄存器地址

也就是:

主机写:
START + 0x28(W) + 0x05 + 0xAA + 0xBB + STOP

从机理解为:
0x05 是寄存器地址
0xAA 写入寄存器 0x05
0xBB 写入寄存器 0x06

这就是寄存器型设备的典型行为。

4.4.3 从机写事务如何处理

从机接收写事务时,ISR 中首先不断把 RX FIFO 清空:

while (RFNF)
    读 1 个字节
    交给 Slave_ProcessReceivedByte()

Slave_ProcessReceivedByte() 内部规则如下:

  1. 如果 FDB=1,把这个字节当成寄存器地址。
  2. 如果 FDB=0,把这个字节写入当前寄存器。
  3. 每写 1 个数据字节,当前寄存器地址自动递增。
  4. 同时把前 8 个数据保存到 LastWriteShadow[],便于主循环打印。

当总线收到 STOP 时:

  1. ISR 进入 STOP_DET 分支。
  2. 把这笔写事务长度保存到 LastWriteLength
  3. WriteFrameReady 置 1。
  4. 主循环看到这个标志后,再统一打印。

所以对从机来说,真正的“一帧写事务结束”判据是:

  • STOP_DET

而不是某个固定长度,也不是“读到了某个特殊字节值”。

4.4.4 从机读事务如何处理

当主机改为读从机时,硬件会产生:

  • RD_REQ

这表示:

  • 主机正在向从机请求数据;
  • 如果从机 TX FIFO 里没有数据,硬件会保持 SCL 为低,等待软件装数。

工程版中,ISR 在 RD_REQ 分支里会做:

  1. 如果是本轮读事务的第一次请求,记录 TxStartRegister = CurrentRegister
  2. 调用 Slave_FillTxFifo()
  3. 把当前寄存器开始的内容顺序装入 TX FIFO。
  4. 清除 RD_REQ,让硬件释放时钟继续发送。

也就是说,从机发送的数据来源于:

  • RegisterMap[CurrentRegister]
  • RegisterMap[CurrentRegister + 1]
  • RegisterMap[CurrentRegister + 2]
  • ...

4.4.5 为什么正常读完也可能出现 TX_ABRT

这一点最容易被误解。

在这个 I2C 控制器里,从机发数据时,如果:

  1. 预先装入了多于主机真正需要的字节;
  2. 主机在读完最后 1 个字节后返回 NACK;
  3. 硬件会把 TX FIFO 中剩余未发字节 flush 掉;
  4. 同时置位 TX_ABRT

所以:

  • TX_ABRT 不一定意味着“真正错误”
  • 很可能只是“主机正常读完,本次剩余待发数据被控制器清空”

工程版因此专门做了 Slave_OnTxAbort()

  1. 读取 TX_ABRT_SOURCE
  2. 读取 TX_FLUSH_CNT
  3. 计算主机真正读走了多少字节
  4. CurrentRegister 推进到下次应该继续读的位置
  5. 对“正常 flush 型结束”不重复当成严重错误上报

这一步非常关键,否则从机寄存器读指针会乱。


4.5 主从交互完整流程

4.5.1 写事务流程

以主机写 8 字节到从机 0x00 开始为例:

主机:
START
0x28 + W
0x00
0x10
0x11
0x12
0x13
0x14
0x15
0x16
0x17
STOP

从机内部流程:

  1. 收到地址匹配后进入从机接收状态。
  2. 收到首字节 0x00,且 FDB=1
    • 解释成寄存器地址;
    • CurrentRegister = 0x00
  3. 收到后续 8 个普通字节:
    • 0x10 -> RegisterMap[0x00]
    • 0x11 -> RegisterMap[0x01]
    • ...
    • 0x17 -> RegisterMap[0x07]
  4. 收到 STOP_DET
    • 保存本次写入长度;
    • 通知主循环打印。

主循环最后会打印类似:

write reg 0x00 len 0x08 data 10 11 12 13 14 15 16 17

4.5.2 读事务流程

以主机从从机 0x00 开始读 8 字节为例:

主机:
START
0x28 + W
0x00
RESTART
0x28 + R
读 8 字节
NACK
STOP

从机内部流程:

  1. 主机先用写方向发 0x00
    • 从机收到首字节 0x00
    • CurrentRegister = 0x00
  2. 主机发 repeated START,再切换为读方向。
  3. 从机收到 RD_REQ
    • CurrentRegister 为起点;
    • 往 TX FIFO 填 RegisterMap[0x00] 开始的数据。
  4. 主机连续读走 8 字节。
  5. 最后一个字节后主机返回 NACK,并发 STOP。
  6. 如果 TX FIFO 还有剩余预装数据,控制器 flush 它们并触发 TX_ABRT
  7. 从机在 Slave_OnTxAbort() 中根据 flush 数量推算:
    • 主机真正拿走了多少字节;
    • 下次寄存器读指针应推进到哪里。

4.6 调试时建议关注的现象

4.6.1 串口输出

主机串口应周期性看到:

read: xx xx xx xx xx xx xx xx

从机串口应周期性看到:

write reg 0x00 len 0x08 data xx xx xx xx xx xx xx xx

4.6.2 如果主机打印 write err / read err

优先检查:

  1. 主从地址是否一致;
  2. 上拉是否存在;
  3. SCL/SDA 是否交叉或接反;
  4. 两边 I2C 速率是否合理;
  5. Last_AbortSource 中是否出现 7ADDR_NOACK / TXDATA_NOACK 类来源。

4.6.3 如果从机打印 slave err flags

优先检查:

  1. 是否发生 RX_OVER
  2. 主机是否发得太快,但从机 ISR 处理不过来;
  3. 是否存在异常时钟、线缆太长或波形质量差的问题。

5. 总结

当前这套代码的核心思想可以概括为两点:

  • 主机按“寄存器地址 + 连续数据”的方式访问从机。
  • 从机把所有读写都映射到内部 RegisterMap[],并用中断驱动收发。


 

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐