简介:这是一份基于 STM32F4 平台的 I2C 通信例程,面向嵌入式开发初学者与进阶人员,演示通过硬件 I2C 接口读写 EEPROM 存储器(典型型号 24LC02),并将读回的数据通过串口发送到电脑,以验证读写流程的正确性,适合用来学习 I2C 时序、外设寄存器配置以及基础驱动程序编写。压缩包内共有 1028 个文件,其中以 86 个 C 语言源文件、107 个头文件和 94 个汇编文件为主要工程代码,同时包含编译后生成的 axf 与 hex 固件、map 映射文件,以及 250 个 HTML 帮助文档、201 个 JS 脚本和 PDF 说明,整体压缩后约 6.92MB,目录结构完整,覆盖源代码、工程配置、编译产物与资料文档。目前已有 2463 人学习下载,读者可以拿到完整可编译的 STM32F4 工程:核心函数 I2C_Test 会先构造 256 字节的数据缓冲区,将其写入 EEPROM 的起始地址,再按地址读回,最后通过 RS232 发送到电脑端进行比对;函数内部把初始化、写数据、读数据、串口输出串联起来,注释清晰,便于移植到实际项目或进一步学习 I2C 驱动框架。
1. 这年头还有必要自己写 I2C 例程吗?
答案是:非常有必要,但不是让你把时序从头撸一遍,而是让你明白厂商给了例程之后,为什么你复制粘贴到自己板子上还是不通。STM32F4 的 I2C 外设是出了名的“配置多、状态机绕、报错隐晦”,网上能找到的例程又普遍停留在“用库函数发一个字节”的阶段,一旦遇到从机不应答、总线锁死、时钟延展这类问题,大多数人就开始玄学改参数。这篇笔记想做的事情很直接:给你一套在 F4 上能跑通的主机收发、从机中断响应的最小例程,把每个配置项为什么这么写讲透,再把最常见的几个翻车点按“现象→原因→解决”摆出来。适合刚拿到开发板想调通 I2C 传感器的人,也适合被项目里的 EEPROM 或触摸芯片折腾到怀疑人生的开发者,哪怕是熟手也能在 DMA 收尾和总线错误排查这两节找回一点效率。
2. 先看协议再动手:I2C 总线机制和主机配置的五个关键选择
刚开始接触 I2C 的人容易犯一个错误:上来就对着参考手册配寄存器,结果配出来一个“看起来对但怎么都不出数据”的总线。这是因为 I2C 和 SPI、UART 不一样,它是一根半双工、带应答机制、支持多主机的同步串行总线,配置外设之前,你得先把总线上的角色和时序规则想清楚。这一章不展开讲电平标准,只挑五个真正影响你能不能跑通的点来讲:寻址、速率、引脚、库的选择、时序参数。
2.1 7 位寻址和 10 位寻址:从机地址该往左挪一位还是原样写
I2C 寻址最常见的坑就是地址位宽。很多从机芯片的数据手册里会写“设备地址为 0xA0”或者“Slave Address = 0x50”,这里的地址到底是不是你在代码里直接往 DR 寄存器里丢的那个数,取决于总线上传地址字节时是 7 位模式还是 10 位模式。
F4 的 I2C 外设默认支持 7 位寻址,此时第一个字节的高 7 位是从机地址,最低位是读写标志。也就是说,如果芯片手册写的是“7 位地址 0x50”,那么你在代码里发送地址字节时,要发送的是(0x50 << 1) | 读写位,也就是0xA0或0xA1。而很多从机手册写的 0xA0 其实是“包含读写位的首字节”,这时候你就不能再左移了,直接0xA0 | (1<<0)即可。
我一般会建议在代码里把基地址和读写位分开写,避免每次都在移位上烧脑:
#define EEPROM_ADDR_BASE 0xA0 /* 器件手册给出的首字节基地址,不含读写位 */ #define I2C_WRITE_ADDR (EEPROM_ADDR_BASE & 0xFE) #define I2C_READ_ADDR (EEPROM_ADDR_BASE | 0x01)这段代码的逻辑是:把手册给的基地址先明确成“不含读写位”的 7 位左移后的值,然后读地址就是把最低位置 1,写地址就是清最低位。用按位与和按位或,比<< 1直观得多,也避免了在多个文档之间来回换算。
还有一个需要注意的点:如果从机芯片支持 10 位寻址,你在初始化I2C_InitStructure.I2C_AddressingMode里要填I2C_AddressingMode_10bit,而且在发送地址阶段要连续发两个字节,第一个字节的高 5 位是11110xx的固定前缀。市面上绝大多数传感器和 EEPROM 都是 7 位,直接用 7 位模式即可,但如果你做的是多主机的复杂系统,10 位模式能让总线上挂更多设备,代价是驱动代码要多处理一段状态。
2.2 标准模式还是快速模式:100kHz 和 400kHz 的真实选择
F4 的 I2C 外设支持标准模式(100kHz)、快速模式(400kHz)和快速模式 Plus(1MHz)。基准频率来自 PCLK1,也就是 APB1 总线的时钟。很多人在这一步只是随便填了一个数字,结果示波器上看出来的 SCL 频率完全不是那么回事。
正确的配置流程是:确认 APB1 实际频率,然后计算CCR寄存器里的分频值。在快速模式下,CCR 的低 12 位决定 SCL 高电平和低电平的时间,而占空比可以由I2C_InitStructure.I2C_DutyCycle选择I2C_DutyCycle_2(2:1)或I2C_DutyCycle_16_9(16:9)。如果是标准模式,占空比固定为 1:1,计算就简单得多。
以 APB1 = 42MHz 为例,标准模式 100kHz 的目标是 SCL 周期 10μs,一个周期需要 420 个时钟,高低电平各 210 个,所以 CCR 设为 210。快速模式 400kHz 如果占空比选 2:1,则高电平 1/3、低电平 2/3,计算需要的时间片再换算成寄存器值,你可以直接这么写:
I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed = 400000; /* 目标总线速率 400kHz */ I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; /* 快速模式占空比 2:1 */ I2C_InitStructure.I2C_OwnAddress = 0x00; /* 本机作为主机时不参与寻址 */ I2C_Init(I2C1, &I2C_InitStructure);这里关键参数是I2C_ClockSpeed和I2C_DutyCycle。库函数会根据I2C_ClockSpeed反推出 CCR 的值,但你要注意,如果 APB1 频率比预期高很多,CCR 可能被截断到 12 位上限,输出频率反而偏离目标。
2.3 引脚复用和外部上拉:为什么你的 SCL 波形是圆的
F4 的 I2C 引脚是复用功能,不是单纯的 GPIO 输出。很多人配置完 AF 之后发现波形不对,怀疑是例程问题,其实问题出在引脚速度、上下拉和外部硬件上。
首先,I2C 引脚必须是开漏输出。内部上下拉电阻在 F4 上是可选的,但有一个 30kΩ 到 50kΩ 左右的弱上拉,这个阻值对 100kHz 总线勉强能用,对 400kHz 来说上升沿会变得很慢,因为 RC 充电时间太长。所以板上必须得有外部上拉电阻,常见取值是 2.2kΩ 到 4.7kΩ,上拉到 VDD。
其次,引脚复用映射要对。比如 I2C1 的 SCL 可以映射到 PB6 或 PB8,SDA 可以映射到 PB7 或 PB9,你在GPIO_PinAFConfig里填错 AF 编号,引脚就完全不出波形。推荐这么初始化:
GPIO_InitTypeDef GPIO_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); GPIO_PinAFConfig(GPIOB, GPIO_PinSource6, GPIO_AF_I2C1); /* SCL */ GPIO_PinAFConfig(GPIOB, GPIO_PinSource7, GPIO_AF_I2C1); /* SDA */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_OD; /* 开漏输出 */ GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; /* 内部弱上拉只能做兜底 */ GPIO_Init(GPIOB, &GPIO_InitStructure);注意GPIO_OType_OD是开漏,不能写推挽;GPIO_Speed不用太高,50MHz 足够,太高反而加重电磁干扰。内部上拉只做兜底,别指望它承担 400kHz 的上升沿。
再提一个很容易被忽略的细节:如果你用的是开发板,板上通常已经有 I2C 上拉电阻,但如果你自己飞线接传感器模块,模块上也有上拉,那就变成了“双重上拉”,等效阻值减半,虽然不至于出错,但会额外增加功耗,某些低功耗场景会产生莫名其妙的问题。
2.4 标准外设库和 HAL 库:例程抄起来该用哪种
这个问题是 F4 开发者的老纠结。标准外设库现在官方不更新了,但它在网上存量最大,几乎所有的老例程、博客、参考代码都是基于它写的;HAL 库是官方现在的亲儿子,带着 CubeMX 一起用,图形化配置很香。
我的建议很简单:如果你是为了快速复现这篇例程,就用标准外设库,因为它函数少、状态机暴露得直接,出问题你能看到是卡在哪个事件上;如果你要做的产品以后需要频繁换芯片型号,或者团队里有人习惯用 CubeMX,那就用 HAL 库,但要做好 HAL 的 I2C 接口经常因为超时机制误报的心理准备。
下面几章的代码我按标准外设库来写,因为它的 API 更靠近寄存器,适合讲原理。HAL 库的移植不难,核心就是HAL_I2C_Master_Transmit和HAL_I2C_Master_Receive两个函数,但 HAL 对错误标志的处理是聚合式的,调试时你很难定位具体是 NACK 还是仲裁丢失。
2.5 时钟延展和总线时序:从机把 SCL 拉低时主机该怎么办
时钟延展(Clock Stretching)是 I2C 协议里一个容易让人困惑的机制。从机如果一时处理不过来,可以在 SCL 为低电平期间继续拉低它,让主机暂停时钟,等从机准备好再释放。这对硬件主机而言意味着:你发完一个数据字节后,不能假设 SCL 马上就能继续翻转,得等从机准备好。
F4 的 I2C 硬件对时钟延展有处理,但处理得比较“硬”——如果你的软件没有在合理时间内收手,它可能直接卡死在某个事件等待上。所以软件设计上要避免“死等标志位”的写法,要给每个等待事件加超时。
这一节的结论是:先确认总线上有没有从机支持时钟延展,如果有,主机侧要增加超时判断,不要用while空转等标志。后面第四章会专门给出超时处理的建议。
3. 从零到能跑:主机发送、接收和从机响应的最小例程
这一章直接上代码。选一个最常见的场景:主机是 STM32F4,从机是一颗 I2C 接口的 EEPROM(比如 AT24C128 系列),演示写一个字节和读一个字节的完整流程。代码基于标准外设库,注意每个 API 调用背后的状态机。
3.1 主机写入一个字节:起始信号、地址、数据、停止信号的顺序
写一个字节的标准流程是:主机产生起始条件→发送从机地址+写位→等待从机 ACK→发送内部寄存器地址→等待 ACK→发送数据→等待 ACK→产生停止条件。
关键点是:每一步之间必须等待对应的 EV 事件,不能连发。总线上的变化是有节奏的,硬件状态机一步一步往下走,你跳过等待直接写下一个数据,很可能把数据写到错误的位置,甚至引发总线错误。代码如下:
uint8_t I2C_WriteByte(uint8_t devAddr, uint8_t regAddr, uint8_t data) { /* 等待总线空闲 */ while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); /* 产生起始条件,对应 EV5 事件 */ I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); /* 发送从机地址 + 写位 */ I2C_Send7bitAddress(I2C1, devAddr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); /* 发送寄存器地址 */ I2C_SendData(I2C1, regAddr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); /* 发送数据 */ I2C_SendData(I2C1, data); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); /* 产生停止条件 */ I2C_GenerateSTOP(I2C1, ENABLE); return 0; }这里有几个事件要分清:I2C_EVENT_MASTER_MODE_SELECT是 EV5,表示起始条件发出去了;I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED是 EV6,表示从机地址加写位发出去且收到 ACK;I2C_EVENT_MASTER_BYTE_TRANSMITTING是 EV8,表示数据寄存器已空、移位寄存器还在往外发;I2C_EVENT_MASTER_BYTE_TRANSMITTED是 EV8_2,表示最后一个字节已经发送完成。
这段代码如果第一次跑通不了,九成是卡在while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY))这个等待上。总线忙标志在刚上电时如果不为 0,多半是 SCL 或 SDA 被外部拉低了,或者是之前一次通信没有正常结束。
3.2 主机读取一个字节:伪起始条件与从机地址的读写位切换
读操作比写操作多一个步骤:你要先写寄存器地址,然后重新产生起始条件,再发送“从机地址+读位”,最后读一个字节。注意第二次起始条件在 I2C 术语里叫“重复起始条件”,代码上表现为不先发停止条件,直接再调用一次I2C_GenerateSTART。
uint8_t I2C_ReadByte(uint8_t devAddr, uint8_t regAddr) { uint8_t data = 0; while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY)); /* 第一次起始:写寄存器地址 */ I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, devAddr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, regAddr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); /* 重复起始:切换为读模式 */ I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, devAddr, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); /* 关闭 ACK,读取最后一个字节 */ I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); data = I2C_ReceiveData(I2C1); /* 恢复 ACK,避免影响后续通信 */ I2C_AcknowledgeConfig(I2C1, ENABLE); return data; }这里最重要的一步是:在读最后一个字节之前就要把 ACK 关掉并生成停止条件。原因在于,主机在接收模式下,每收到一个字节都会回 ACK,如果它不回 ACK,从机就知道“这是最后一个字节了”。所以顺序必须是:先关 ACK、再发 STOP、最后取数据。如果你先读了数据再发 STOP,从机会认为你还要继续读,一直把 SCL 拉低等下一个字节,总线就卡死了。
3.3 从机中断收发:不做主机的角色该怎么响应
有时候 F4 要作为从机,比如接一个外部主机(树莓派或者其他 MCU)。这种情况下不能再用查询方式,因为主机不知道什么时候会发起通信,你必须用中断或 DMA。
用标准外设库配从机模式,核心是设置I2C_OwnAddress,然后使能I2C_IT_EVT和I2C_IT_BUF两个中断通道,分别处理事件和缓冲区。这里给一段最小从机发送响应代码:
void I2C_EV_IRQHandler(void) { if (I2C_GetITStatus(I2C1, I2C_IT_ADDR)) { /* 清 ADDR 标志 */ I2C_ClearFlag(I2C1, I2C_FLAG_ADDR); } else if (I2C_GetITStatus(I2C1, I2C_IT_TXE)) { /* 主机请求数据,发送一个字节 */ I2C_SendData(I2C1, slaveTxBuffer[slaveTxIndex++]); } else if (I2C_GetITStatus(I2C1, I2C_IT_RXNE)) { /* 主机发来数据,读取 */ slaveRxBuffer[slaveRxIndex++] = I2C_ReceiveData(I2C1); } }这段代码的逻辑是:ADDR中断表示主机已经发送了从机地址,此时你要在中断里清除标志,否则后续的事件不会触发;TXE表示发送数据寄存器空了,主机正在读取你的数据;RXNE表示收到了一字节数据,你要及时取走。
需要说明的是,从机模式下总线地址匹配后,硬件会自动切换到收发模式,软件只需要在对应标志置位时做响应。如果从机响应太慢,主机那边会超时,所以对时间敏感的应用建议把缓冲区数据准备好,中断里只做拷贝,不要在中断里做浮点运算或者长循环。
3.4 初始化顺序:时钟、引脚、I2C 外设的先后关系
很多例程翻车不是翻在通信函数,而是初始化顺序就错了。F4 的外设时钟是分总线使能的,GPIO 走 AHB1,I2C 走 APB1,如果你只开了 APB1 的时钟而忘了开 AHB1 的 GPIO 时钟,引脚配置写进去全部无效。
推荐的顺序是:先开 GPIO 时钟,再开 I2C 时钟,然后配置引脚复用,再初始化 I2C,最后使能 I2C。反过来容易出现引脚配置失败但程序不报错的尴尬局面。完整初始化可以这样:
void I2C_Init_Default(void) { GPIO_InitTypeDef GPIO_InitStructure; I2C_InitTypeDef I2C_InitStructure; /* 1. 使能时钟 */ RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); /* 2. 引脚复用 */ GPIO_PinAFConfig(GPIOB, GPIO_PinSource6, GPIO_AF_I2C1); GPIO_PinAFConfig(GPIOB, GPIO_PinSource7, GPIO_AF_I2C1); /* 3. 引脚模式 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_OD; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_UP; GPIO_Init(GPIOB, &GPIO_InitStructure); /* 4. I2C 外设参数 */ I2C_InitStructure.I2C_Mode = I2C_Mode_I2C; I2C_InitStructure.I2C_Ack = I2C_Ack_Enable; I2C_InitStructure.I2C_AcknowledgedAddress = I2C_AcknowledgedAddress_7bit; I2C_InitStructure.I2C_ClockSpeed = 400000; I2C_InitStructure.I2C_DutyCycle = I2C_DutyCycle_2; I2C_InitStructure.I2C_OwnAddress = 0x00; I2C_Init(I2C1, &I2C_InitStructure); /* 5. 使能外设 */ I2C_Cmd(I2C1, ENABLE); }注意第 4 步里I2C_OwnAddress = 0x00,这是主机模式,不需要给自己设地址,设了也没关系,但别设成和从机一样的值,否则在多主机环境里会产生地址冲突。
4. 避坑:I2C 例程里最常见的五个翻车点
这一章是我在这些年调试 I2C 过程中真正踩过、或者看别人踩过的坑的汇总。每条都按“现象→原因→解决”来写,方便你照着排查。
4.1 卡死在while (I2C_GetFlagStatus(I2C1, I2C_FLAG_BUSY))这一行
现象:程序一跑到等待总线空闲就出不来,用调试器看全速运行,代码就一直停在这一句。
原因:总线忙标志由硬件管理,只要检测到 SCL 或 SDA 为低电平,就认为总线忙。最常见的导火索是:上一次通信没有产生有效的停止条件,总线被从机拉死;或者 SDA 线上被某个外部设备拉低,主机上电时检测到低电平就认为忙。
解决:先把硬件上所有挂在 I2C 总线上的设备断电,用万用表量 SCL 和 SDA 对地电压,正常空闲状态应该是高电平(因为有上拉)。如果 SDA 为低,多半是某个从机处于异常状态,需要给整个总线复位上电。软件上的补救是在初始化前先对引脚做一次复位操作,但这不是治本,只是给你调试留一个入口。
4.2 发送地址后收不到 ADDR 事件,从机就是不答
现象:主机发送从机地址后,等待I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED超时,从机没有拉低 SDA 回 ACK。
原因:多半是从机地址不对,或者从机不在总线上。地址不对又分两种情况:一种是真的把地址写错了;另一种是芯片手册里写的地址本来就是 7 位地址,但你没有左移一位。另外,如果从机是刚上电还没完成内部初始化,它也不会应答,需要给它一个延时。
解决:拿逻辑分析仪抓 SCL 和 SDA,看主机发出的地址字节到底是什么。如果是 0xA0,而手册写的是 0x50,那说明你直接填了 7 位地址而没左移。正确的第一个地址字节应该是(0x50 << 1) | 0即0xA0。如果你抓到的波形正确但仍然没有 ACK,那就用 I2C 扫描程序逐个地址试,看从机到底响应哪个地址。
4.3 读操作最后一个字节一直等不到I2C_EVENT_MASTER_BYTE_RECEIVED
现象:读 EEPROM 的时候,前面都正常,就在最后一步等待接收完成事件时卡住。
原因:你还没有关闭 ACK 就发起了停止条件,或者你先读了数据又发停止。主机在接收模式下,如果不提前关闭 ACK,会一直等从机发送下一字节;从机则因为没有收到 NACK 而继续准备数据,两边互相等待,总线锁死。
解决:严格按“最后一个字节前关 ACK→发 STOP→等事件→读数据”的顺序来写。不少人在I2C_AcknowledgeConfig(I2C1, DISABLE)和I2C_GenerateSTOP(I2C1, ENABLE)的顺序上搞反了,先发 STOP 再关 ACK,这时候 ACK 还没关,从机还在发送,总线照样卡。这个顺序没有道理可讲,就是硬件状态机的约定,记下来就好。
4.4 用 HAL 库跑例程,同样的从机但经常报I2C_ERROR_AF(ACK Failure)
现象:标准外设库的代码跑得好好的,换成 HAL 库之后同样的地址、同样的速率,偶尔报错I2C_ERROR_AF,重试一次又好了。
原因:HAL 库的 I2C 实现带超时机制,底层通过I2C_WaitOnFlagUntilTimeout判断事件是否发生。如果总线上有干扰、或者主机主频偶尔被中断抢占,导致响应变慢,超时机制就会误判为 ACK 失败。另外 HAL 库对从机 NACK 的处理是把它当作错误返回的,而标准外设库只是置个标志位,不会中断流程,所以底层的容错行为不同。
解决:如果确定要留在 HAL 库,一是增大超时时间,二是把HAL_I2C_Master_Transmit的Timeout参数从默认值调大,比如调到 1000ms 甚至更大;三是检查总线上拉电阻阻值,必要时换成 2.2kΩ,把抗干扰能力提上去。
4.5 中断里收发数据偶尔丢字节,调试器一看是BTR溢出
现象:主机用中断方式连续读一个大块数据,比如 256 字节,跑到一半发现中间有丢失,寄存器里I2C_ISR的 OVR(Overrun/Underrun)位被置位。
原因:接收模式下,主机在 RXNE 置位后必须在下一个 SCL 脉冲之前把数据读走,否则新数据会覆盖旧数据,造成 Overrun。中断响应如果被更高优先级的事情卡住,比如串口中断,就来不及取数。F4 的 I2C 接收缓冲区很浅,只有一格,不像串口有 FIFO。
解决:如果数据量超过 8 字节、长度又固定,直接用 DMA 通道而不是中断。DMA 把数据搬到内存,不占用 CPU,Overrun 基本不会再出现。后面第四章会给出 DMA 收发的配置,这一节先记住结论:中断只适合小数据量,大块传输必须换 DMA。
5. 结束前的最后一课:用逻辑分析仪验证时序,顺手把 DMA 收发改了
这一章不是总结,而是给你两个能立刻改善调试效率的技巧:怎么用逻辑分析仪验证 I2C 时序,以及怎么把前面查询式代码改成 DMA 收发,把 CPU 从长数据块传输里解放出来。
5.1 逻辑分析仪抓波形:别靠猜,看电平
没有逻辑分析仪,调试 I2C 就是盲人摸象。你不需要买几万块钱的高端设备,一台百来元的 8 通道逻辑分析仪配合开源软件就能解决问题。关键是你得知道看什么:一看起始条件有没有;二看地址字节的值对不对;三看 ACK 位有没有;四看数据字节有没有多传或少传。
抓波形的正确姿势是:把 SDA 接通道 1,SCL 接通道 2,地线接板子的 GND,采样率设到 1MHz 以上,触发方式设为下降沿触发,然后用解码器选 I2C 协议,软件会直接把地址字节、读写位、ACK 状态标出来。如果解码器显示的地址是 0xA0 而你预期的是 0x50,那不是硬件问题,是你软件里地址换算错了。
还有一个技巧:对比同一段代码在“刚上电第一次通信”和“长时间运行后再通信”的波形差异。波形不变但功能失效,一般是电源纹波或者干扰;波形变化大概率是某个从机内部的看门狗在起作用,导致它没有正常回到空闲态。这些用逻辑分析仪一抓一个准,比盲目调上拉电阻有效率得多。
5.2 DMA 收发改造:把 CPU 还给你的业务逻辑
DMA 收发的核心是:地址和命令阶段仍然用查询模式发送,等进入连续数据阶段后,把数据寄存器的地址交给 DMA,由 DMA 自动完成数据搬运。对 I2C 主机读 256 字节这种场景,改造是值得的,因为查询模式会持续占用 CPU,期间无法响应其他中断。
给出一个读多字节的 DMA 初始化片段,以 I2C1、DMA1 通道 7 的接收为例:
void I2C_DMA_ReadBuf(uint8_t devAddr, uint8_t regAddr, uint8_t *buf, uint16_t len) { /* 发送设备地址和寄存器地址,流程同前的查询写法 */ I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, devAddr, I2C_Direction_Transmitter); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, regAddr); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTING)); /* 配置 DMA 接收 */ DMA_InitTypeDef DMA_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_DMA1, ENABLE); DMA_DeInit(DMA1_Stream0); DMA_InitStructure.DMA_Channel = DMA_Channel_1; DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&I2C1->DR; DMA_InitStructure.DMA_Memory0BaseAddr = (uint32_t)buf; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralToMemory; DMA_InitStructure.DMA_BufferSize = len; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Normal; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_FIFOMode = DMA_FIFOMode_Disable; DMA_Init(DMA1_Stream0, &DMA_InitStructure); /* 第二次起始,切读模式 */ I2C_GenerateSTART(I2C1, ENABLE); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, devAddr, I2C_Direction_Receiver); while (!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); /* 使能 DMA,硬件自动接收 len 个字节 */ I2C_DMACmd(I2C1, ENABLE); DMA_Cmd(DMA1_Stream0, ENABLE); /* 等待 DMA 传输完成 */ while (!DMA_GetFlagStatus(DMA1_Stream0, DMA_FLAG_TCIF0)); /* 关闭 DMA 和 I2C,发送停止条件 */ DMA_Cmd(DMA1_Stream0, DISABLE); I2C_DMACmd(I2C1, DISABLE); I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); }这段代码有几个需要留意的参数:DMA_Channel_1是 DMA1 流 0 的 I2C1 接收请求映射,不同的 F4 型号映射可能不同,务必查参考手册的 DMA 请求映射表,别抄错通道;DMA_PeripheralDataSize必须是 Byte,因为 I2C 数据寄存器是 8 位;DMA_FIFOMode_Disable直接使用直接模式,省去 FIFO 阈值配置的麻烦。
改造完 DMA 之后,CPU 在传输期间就能腾出来做别的事,但别忘了:DMA 传输期间不能去读写 I2C 的数据寄存器,否则会干扰 DMA 搬运,这一点经常有人忽略,在中断里顺手读了一下 DR,结果整个 DMA 数据就乱了。
最后分享一个我自己的教训:I2C 调试最忌讳“调一下试一下”的碰运气做法,尤其不要反复改上拉电阻值去碰波形。上拉电阻的大小选择很简单,100kHz 用 4.7kΩ,400kHz 用 2.2kΩ,总线负载重就再往下降一档,这不是需要反复试玄学参数的地方。真正的核心是把逻辑分析仪抓到的波形和协议规范一行行比对,地址对不对、ACK 有没有、停止条件是不是完整。做习惯了,你会发现自己解决问题的速度比同事快很多。希望这篇笔记能帮你少走几步弯路,从一开始就把 I2C 例程跑得稳稳当当。
本文还有配套的精品资源,点击获取