STM32F4 I2C例程详解:从协议配置到总线调试与DMA改造
简介这是一份基于 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 | (10)即可。我一般会建议在代码里把基地址和读写位分开写避免每次都在移位上烧脑#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和快速模式 Plus1MHz。基准频率来自 PCLK1也就是 APB1 总线的时钟。很多人在这一步只是随便填了一个数字结果示波器上看出来的 SCL 频率完全不是那么回事。正确的配置流程是确认 APB1 实际频率然后计算CCR寄存器里的分频值。在快速模式下CCR 的低 12 位决定 SCL 高电平和低电平的时间而占空比可以由I2C_InitStructure.I2C_DutyCycle选择I2C_DutyCycle_22:1或I2C_DutyCycle_16_916: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 或 PB8SDA 可以映射到 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表示从机地址加写位发出去且收到 ACKI2C_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 走 AHB1I2C 走 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_AFACK 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的 OVROverrun/Underrun位被置位。原因接收模式下主机在 RXNE 置位后必须在下一个 SCL 脉冲之前把数据读走否则新数据会覆盖旧数据造成 Overrun。中断响应如果被更高优先级的事情卡住比如串口中断就来不及取数。F4 的 I2C 接收缓冲区很浅只有一格不像串口有 FIFO。解决如果数据量超过 8 字节、长度又固定直接用 DMA 通道而不是中断。DMA 把数据搬到内存不占用 CPUOverrun 基本不会再出现。后面第四章会给出 DMA 收发的配置这一节先记住结论中断只适合小数据量大块传输必须换 DMA。5. 结束前的最后一课用逻辑分析仪验证时序顺手把 DMA 收发改了这一章不是总结而是给你两个能立刻改善调试效率的技巧怎么用逻辑分析仪验证 I2C 时序以及怎么把前面查询式代码改成 DMA 收发把 CPU 从长数据块传输里解放出来。5.1 逻辑分析仪抓波形别靠猜看电平没有逻辑分析仪调试 I2C 就是盲人摸象。你不需要买几万块钱的高端设备一台百来元的 8 通道逻辑分析仪配合开源软件就能解决问题。关键是你得知道看什么一看起始条件有没有二看地址字节的值对不对三看 ACK 位有没有四看数据字节有没有多传或少传。抓波形的正确姿势是把 SDA 接通道 1SCL 接通道 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 例程跑得稳稳当当。本文还有配套的精品资源点击获取