简介:面向 STM32F407 嵌入式开发者的硬件 I2C 通信参考例程,基于 MDK 与 HAL 库编写,适合正在学习 I2C 协议或需要移植外设驱动的入门及中级开发者。代码以一个精简的 C 源文件呈现,集中展示 I2C 外设初始化配置,包括时钟速率、GPIO 复用为开漏输出、主从设备地址设定等关键环节;同时梳理主模式发送与接收流程,借助 HAL_I2C_Master_Transmit、HAL_I2C_Master_Receive 可理解起始条件、从机地址、应答位与停止条件构成的完整数据帧。针对仲裁丢失、总线错误、应答失败等调试点,例程提供错误标志检查与中断处理思路,便于在传感器或显示模块通信中快速定位故障。压缩包共 1 个文件,类型为 C 源码,大小约 1KB,结构紧凑,便于直接对照修改;目前已有 226 人浏览学习,可作为快速上手 STM32F407 硬件 I2C 的参考起点。
1. STM32F407 的硬件 I2C,为什么值得放弃“经典”
“STM32F407_I2c.rar”这类命名的资源包,在技术交流群里一年四季都会出现:解压出来往往是标准外设库时代的模板工程,里面写着“硬件 I2C 不稳定,请用模拟时序”的注释。但实际结论正相反——STM32F407 的硬件 I2C 外设并没有“天生缺陷”,大量失败案例出在时钟配置、开漏上拉和中断标志位处理三处。本文从硬件 I2C 与模拟 I2C 的选型讲起,覆盖初始化、读写状态机、DMA 收尾和逻辑分析仪验证,给出一套能在 STM32F407 上直接复现的方案。适合驱动 OLED、EEPROM、温湿度传感器时不想再逐位翻转 GPIO 的读者,也适合想彻底搞清 NACK、BUSY、超时这些词到底在说什么的人。这里先把话说死:只要配置顺序正确,F407 硬件 I2C 的稳定性不输任何模拟实现。
2. STM32F407 硬件 I2C 的选型对比与初始化配置
2.1 STM32F407 硬件 I2C 为什么比模拟 I2C 更值得选
模拟 I2C(也叫 GPIO 模拟 I2C)的常见理由是“代码可控、出问题好查”。在 F407 这种主频 168MHz、GPIO 翻转速度足够快的芯片上,模拟方案也确实能跑出 400kHz 的时序。但代价是:字节级延迟函数依赖编译器优化等级,换个 -O0 调试就可能超时;中断里一旦插进别的处理,SCL 的高电平宽度会漂移。硬件 I2C 外设全部由内部状态机驱动,时序不占用 CPU,这是它的本质优势。
还有一种折中做法是用“半硬件”方式:开漏输出加外部上拉,手工控制 SCL/SDA 翻转。这在老工程师手里很常见,但代码里会堆满GPIO_WriteBit和空循环延时,可维护性极差。STM32F407 的 I2C 外设支持 100kHz 标准模式和 400kHz 快速模式,地址支持 7 位和 10 位,还带多主机仲裁与超时检测。把这些能力放着不用,去逐位模拟,等于用 C 语言写汇编该干的事。
另一个常被忽略的点是功耗与中断负载。硬件 I2C 的事件中断只在需要软件参与的节点触发(地址匹配、数据寄存器空、传输完成),而模拟方案在每个 bit 翻转时都要忙等。对 F407 这种性能过剩的芯片来说,省下的是时间片,不是电。对实时性要求不高的场景二者差异不大,但一旦系统里同时跑着 DMA、定时器或网络协议栈,硬件 I2C 的确定性就体现出来了。
2.2 APB1 时钟与引脚复用:配置硬件 I2C 的前置条件
STM32F407 的 I2C1 挂在 APB1 总线上,APB1 的时钟上限是 42MHz。I2C 外设的输入时钟I2CCLK由 APB1 提供,SCL 频率由该时钟分频而来。标准模式 100kHz 下,分频系数要保证 SCL 高/低电平时间满足 I2C 总线规范;快速模式 400kHz 下,对上升时间的要求更紧,需要正确配置上升时间寄存器,否则 SCL 波形边缘过缓,从机可能采样失败。
引脚复用方面,I2C1 默认映射到 PB6(SCL) / PB7(SDA),I2C2 映射到 PB10(SCL) / PB11(SDA),I2C3 映射到 PA8(SCL) / PC9(SDA)。这里最容易踩的坑是:GPIO 配置成复用开漏模式后忘记使能引脚时钟,或者把 SCL 配成了推挽输出。I2C 总线是开漏结构,任何一方拉低总线时,对方必须能读到低电平;推挽模式会让两个设备对拉,直接损坏引脚或造成总线锁死。外部上拉电阻通常取 4.7kΩ(100kHz)或 2.2kΩ(400kHz),板子上的走线电容也会影响上升沿。
下面是 I2C1 在标准外设库下的引脚与时钟配置示例。虽然新项目多使用 HAL 库,但标准外设库在存量工程中仍大量存在,而且能清晰展示寄存器级操作流程:
void I2C_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOB, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_I2C1, ENABLE); 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_NOPULL; GPIO_Init(GPIOB, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOB, GPIO_PinSource6, GPIO_AF_I2C1); GPIO_PinAFConfig(GPIOB, GPIO_PinSource7, GPIO_AF_I2C1); }这段代码的关键点在最后两行:GPIO_PinAFConfig把 PB6/PB7 的复用功能切到 I2C1,而不是默认的 GPIO 功能。GPIO_OType_OD指定开漏输出,GPIO_PuPd_NOPULL表示内部不上拉——上拉电阻在板级电路上。GPIO_Speed_50MHz对 I2C 来说是足够的,因为 SCL 最高才 400kHz,真正的限制在于开漏模式下输出级的翻转速度。
2.3 STM32F407 硬件 I2C 的最小初始化代码与参数说明
初始化 I2C 外设本身包含四个必设参数:时钟频率、上升时间、应答模式、从机地址。在 HAL 库中,这些参数集中在I2C_InitTypeDef结构体里,初始化流程如下:
I2C_HandleTypeDef hi2c1; void MX_I2C1_Init(void) { hi2c1.Instance = I2C1; hi2c1.Init.ClockSpeed = 100000; /* 100kHz 标准模式 */ hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2; /* 快速模式下的占空比,标准模式无用 */ hi2c1.Init.OwnAddress1 = 0x30; /* 本机地址,做主设备时随意设置 */ hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT; hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE; hi2c1.Init.OwnAddress2 = 0x00; hi2c1.Init.GeneralCallMode = I2D_GENERALCALL_DISABLE; hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE; if (HAL_I2C_Init(&hi2c1) != HAL_OK) { Error_Handler(); } }DutyCycle参数在 100kHz 下不生效,但 HAL 库要求必须赋值。NoStretchMode指的是从机时钟拉伸——F407 做主设备时,如果从机需要更多时间准备数据,会拉低 SCL 让主机等待;保持禁用状态即可,否则主设备会忽略从机的拉伸请求,造成数据错位。
| 参数 | 常用取值 | 作用 | 注意点 |
|---|---|---|---|
| ClockSpeed | 100000 / 400000 | 决定 SCL 频率 | 400kHz 时需检查板级上拉电阻是否过弱 |
| AddressingMode | 7BIT / 10BIT | 匹配从机地址格式 | 大多数传感器和 EEPROM 是 7 位地址 |
| OwnAddress1 | 任意 7 位地址 | 本机被寻址时使用 | 做主设备时不影响发送 |
| GeneralCallMode | ENABLE / DISABLE | 是否响应广播地址 0x00 | 多主机总线中通常禁用 |
提示:标准外设库中 EV5、EV6、EV8 这类事件标志在 HAL 库中被抽象成了
HAL_I2C_MASTER_SEND内部的状态机。如果调试老例程时看到 EV 事件相关代码,不要直接照搬寄存器操作,先把库版本对应的参考手册找出来。
3. STM32F407 硬件 I2C 的读写状态机、错误恢复与现场检查
3.1 STM32F407 硬件 I2C 的编程模型:从 Start 到 Stop 的状态流转
I2C 通信的发起者是主设备,总线上的每一次事务都以 Start 条件开始、以 Stop 条件结束。F407 硬件外设把这个过程分解成几个离散事件:Start 发送完成、从机地址发送完成、数据寄存器空、数据寄存器非空、Stop 发送完成。每个事件对应一个状态位,软件需要通过这些状态位驱动事务前进。
以主设备向从机写一个字节为例,标准流程是:主机发送 Start 条件 → 发送从机地址 + 写标志位(最低位为 0)→ 等待 ACK → 发送数据字节 → 等待 ACK → 发送 Stop 条件。硬件外设不会自动推进这些步骤,每一步都需要软件确认前一事件完成。常见的“硬件 I2C 卡死”现象,大多是某个事件标志没有被及时清除,或者软件轮询时检查了错误的中断标志。
HAL 库的HAL_I2C_Master_Transmit已经把上述过程封装成了轮询/中断/DMA 三种模式。用轮询模式时,内部是一个带超时的状态机循环,任何一步失败都会返回错误码。看这个函数的实现源码会对状态流转有更深理解:它检查I2C_FLAG_BUSY、发送地址、等待TXIS、写数据、最终发 Stop。整个过程由I2C_WaitOnFlagUntilTimeout控制超时,超时时间由调用方传入。
3.2 带超时和错误码的读写函数:直接可抄
下面这个函数用 HAL 库实现向指定寄存器写入数据的操作,常用于初始化传感器寄存器:
#include "i2c.h" #define I2C_TIMEOUT 100 /* 单位毫秒 */ int8_t I2C_WriteReg(I2C_HandleTypeDef *hi2c, uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) { uint8_t buffer[256]; if (len > 250) return -1; buffer[0] = reg_addr; memcpy(&buffer[1], data, len); HAL_StatusTypeDef status = HAL_I2C_Master_Transmit( hi2c, /* 使用的 I2C 外设句柄 */ (uint16_t)(dev_addr << 1), /* 7 位地址左移 1 位,bit0 为 0 表示写 */ buffer, /* 发送缓冲区:寄存器地址 + 数据 */ (uint16_t)(len + 1), /* 总字节数 */ I2C_TIMEOUT); /* 超时时间,单位毫秒 */ if (status != HAL_OK) { /* 返回 HAL 库的错误码,便于上层区分超时/应答/总线错误 */ return (int8_t)status; } return 0; }dev_addr << 1这一步是关键。I2C 总线上的 7 位从机地址在传输时要左移一位,最低位表示读(1)或写(0)。很多初次调试的人直接把 0xA0 当作设备地址传入,其实 0xA0 已经是左移后的结果。函数内部用memcpy把寄存器地址和待写数据拼接成连续缓冲区,这样一次事务就把“先写寄存器地址、再写数据”的流程走完。
读操作略微复杂,因为需要先发送寄存器地址,然后重新发送 Start 条件(或使用 Repeated Start),再切到读模式:
int8_t I2C_ReadReg(I2C_HandleTypeDef *hi2c, uint8_t dev_addr, uint8_t reg_addr, uint8_t *data, uint16_t len) { HAL_StatusTypeDef status; status = HAL_I2C_Master_Transmit( hi2c, (uint16_t)(dev_addr << 1), ®_addr, 1, I2C_TIMEOUT); if (status != HAL_OK) return (int8_t)status; status = HAL_I2C_Master_Receive( hi2c, (uint16_t)(dev_addr << 1) | 0x01, data, len, I2C_TIMEOUT); return (int8_t)status; }读操作的第一次发送不带 Stop 条件,HAL 库会自动使用 Repeated Start 衔接第二次读操作。这里不需要手动操作 GPIO,硬件外设处理了时序细节。如果从机要求“先写寄存器地址后读数据”、中间不能有 Stop 条件,这个函数天然满足。
3.3 错误标志位对照表与定位方法
HAL 库把 I2C 错误收敛为几种固定类型,看HAL_I2C_GetError的返回值就能缩小排查范围。实际调试中,最常见的错误码和对应现场如下:
| 错误码 | 含义 | 典型现场 | 检查方向 |
|---|---|---|---|
| HAL_I2C_ERROR_AF | 应答失败(NACK) | 地址不对、设备未上电、写入了 EEPROM 正在擦写 | 用逻辑分析仪抓地址字节,确认 7 位地址是否匹配 |
| HAL_I2C_ERROR_BERR | 总线错误 | SDA 上出现不属于任何事务的跳变 | 检查总线冲突、外部干扰、上拉电阻是否开路 |
| HAL_I2C_ERROR_OVR | 过载/欠载 | DMA 模式下手速没跟上,数据寄存器被覆盖 | 降低 I2C 频率或检查 DMA 配置 |
| HAL_I2C_ERROR_TIMEOUT | 超时 | 总线被拉低(从机死机)或上拉缺失 | 量 SCL/SDA 静态电平,若非高电平就是总线锁死 |
提示:NACK 不全是坏事。主机发送地址后收到 NACK,至少说明总线上有设备在响应;如果连 NACK 都没有,大概率是总线物理层问题,不是地址问题。
3.4 读写失败后的现场检查顺序
代码写得没问题但 I2C 就是不通时,按以下顺序查,能省掉一半排错时间。第一步,万用表量 SCL 和 SDA 对地电压,空闲状态应接近上拉电压(通常 3.3V),如果其中一个被拉低到 0V,就是有设备锁死了总线。第二步,示波器或逻辑分析仪看 Start 条件是否存在,不存在则是软件没跑进发送流程、或引脚复用配置错误。第三步,看地址字节后的 ACK,ACK 不存在则查地址左移、写标志位、设备地址是否匹配。第四步,数据阶段出错时,检查寄存器地址是否在设备有效范围内,很多传感器对非法寄存器地址直接回 NACK。最后,查中断优先级——如果 I2C 中断被其他高优先级中断长期抢占,HAL 库的轮询模式会超时,中断模式会丢事件。
4. STM32F407 硬件 I2C 加 DMA:模式、配置与高频坑位
4.1 为什么大块数据传输要开 DMA
I2C 的轮询模式在每次收发一个字节时都会占用 CPU 等待。对于 24C02 这种一页 8 字节的 EEPROM 来说差异不明显,但碰到需要连续读取数百字节的传感器(比如某些温湿度传感器的一次性读全部寄存器)时,轮询模式会让 CPU 空转几百微秒。DMA 模式让外设在数据寄存器准备好时直接搬运内存,字节间的小间隔不再打断 CPU。
F407 的 I2C1 有独立的 DMA 请求,发送走 DMA1 通道 6,接收走 DMA1 通道 7。其他 I2C 外设对应的通道号不同,具体查参考手册的 DMA 请求映射表。配置 DMA 时需要注意方向:发送时外设是目标(存储器到外设),接收时外设是源(外设到存储器),方向设反会导致数据完全错乱。
4.2 STM32F407 硬件 I2C DMA 模式配置代码
用 HAL 库启用 DMA 发送前,需要先把 DMA 句柄与 I2C 句柄绑定。下面是一份最小配置:
DMA_HandleTypeDef hdma_i2c1_tx; void MX_DMA_Init(void) { __HAL_RCC_DMA1_CLK_ENABLE(); hdma_i2c1_tx.Instance = DMA1_Stream6; hdma_i2c1_tx.Init.Channel = DMA_CHANNEL_1; /* I2C1_TX 对应通道 1 */ hdma_i2c1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH; /* 内存 → 外设 */ hdma_i2c1_tx.Init.PeriphInc = DMA_PINC_DISABLE; /* 外设地址固定 */ hdma_i2c1_tx.Init.MemInc = DMA_MINC_ENABLE; /* 内存地址递增 */ hdma_i2c1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE; hdma_i2c1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE; hdma_i2c1_tx.Init.Mode = DMA_NORMAL; /* 不做循环模式 */ hdma_i2c1_tx.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Init(&hdma_i2c1_tx); /* 关键:把 DMA 句柄关联到 I2C 句柄 */ __HAL_LINKDMA(&hi2c1, hdmatx, hdma_i2c1_tx); }配置完 DMA 后,发送函数换成HAL_I2C_Master_Transmit_DMA,调用方式和轮询版相同,但函数立即返回,传输在后台进行。传输结束时需在 I2C 事件中断里调用HAL_I2C_MasterTxCpltCallback,在这个回调里做后续处理。
DMA_NORMAL模式是必须的:如果设成DMA_CIRCULAR,DMA 会一遍遍重传同一块数据,而 I2C 外设并不会自动开启下一笔事务。MemDataAlignment和PeriphDataAlignment都设成字节对齐,因为 I2C 的地址发送阶段也是字节操作,任何宽度不匹配都会让 DMA 搬运出错。
4.3 高频坑位之一:总线锁死的现场处理
总线锁死指 SDA 被某个设备拉低,SCL 还在正常翻转。触发场景常见于“主机发送了半截数据后突然复位”或“从机正在处理数据时主机又发起新事务”。复位主机 MCU 往往无效,因为锁死的是从机侧状态机。
标准做法是让主机手动产生 9 个 SCL 时钟脉冲,把从机内部状态推进到空闲。在 F407 上可以临时把 SCL 引脚配成普通 GPIO 推挽输出,手动翻转:
void I2C_Bus_Clear(void) { GPIO_InitTypeDef gpio; /* 先把 PB6/SCL 和 PB7/SDA 切换为普通开漏输出 */ GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_OUT; GPIO_InitStructure.GPIO_OType = GPIO_OType_OD; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOB, &GPIO_InitStructure); /* 单独拉低 SCL,再放开发送 9 个脉冲 */ for (int i = 0; i < 9; i++) { GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); } /* 发出 Stop 条件:SDA 在 SCL 高电平期间拉高 */ GPIO_ResetBits(GPIOB, GPIO_Pin_7); delay_us(1); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(1); GPIO_SetBits(GPIOB, GPIO_Pin_7); /* 恢复 I2C 复用功能 */ GPIO_PinAFConfig(GPIOB, GPIO_PinSource6, GPIO_AF_I2C1); GPIO_PinAFConfig(GPIOB, GPIO_PinSource7, GPIO_AF_I2C1); }delay_us的延时参数决定了脉冲频率,5 微秒对应约 100kHz。这个总线释放序列不保证 100% 成功——某些从机需要更长的时间才能解除锁死状态,但值得在复位前先试。若连续几次后 SDA 仍然为低,再从硬件上排查从机电源和焊接问题。
4.4 高频坑位之二:时钟频率与上拉匹配
F407 的硬件 I2C 对 SCL 频率的实际输出受 APB1 时钟分频影响。标准库下,配置 400kHz 需要正确设置 CCR 寄存器;HAL 库由时序计算自动完成。但外部上拉电阻如果偏大(比如 10kΩ),上升沿会变得平缓,400kHz 工作时 SCL 高电平宽度可能达不到从机要求的最小值。现象是:同一块板子 100kHz 完全正常,改成 400kHz 后随机丢失 ACK。处理方式优先换 2.2kΩ 上拉,其次降低 I2C 频率到 200kHz 左右折中。不要指望软件上增加延时能解决这种问题,波形走样后任何软件修补都是添乱。
5. 用逻辑分析仪验证 STM32F407 硬件 I2C 时序:测什么与怎么改
5.1 抓取条件和连接方式
在没有示波器的情况下,逻辑分析仪是验证 I2C 时序最直接的设备。连接方式很简单:逻辑分析仪的 CH0 接 SCL、CH1 接 SDA,地线与被测板子共地。采样率至少设为 4MHz——400kHz 的 SCL 一个周期大约 2.5 微秒,4MHz 采样率每个周期能采到 10 个点,足够看清 ACK 窗口和边沿位置。
触发条件设为 SDA 下降沿,因为 I2C 事务以 SDA 拉低(Start 条件)开始。采样深度放到 100ms 级别,能覆盖一次完整的寄存器写入加读取过程。市面上常见的分析仪软件都内置 I2C 协议解析器,但不要直接看解析结果,先看原始波形,确认 Start、地址、ACK、数据、Stop 各段边界是否清晰。
5.2 三段式核对:从波形反推寄存器状态
拿到波形后按三段核对。第一段是地址字节:波形上的前 8 个 bit 应该等于“设备地址左移 1 位后的值”。比如地址 0x50 左移后是 0xA0(二进制 1010000 0),最后一个 bit 为写标志 0。如果波形显示 0x50 原值(0101000 0),说明软件里已经做过左移、而调用处又左移了一次,地址前 7 位被截断。
第二段是 ACK 窗口:第 9 个时钟脉冲的高电平期间,SDA 应该被从机拉低。如果 SDA 保持高电平,说明从机没有识别出地址。此时看从机有没有上电、地址线有没有接对。
第三段是数据区域和 Stop:数据字节写入后,下一次 Start 与 Stop 之间的地址字节应为“左移地址 + 1”(读标志)。很多“读出来全 0xFF”的问题,这一段的地址字节就是错的——主机还在用写标志发送读命令,从机当然不响应。
5.3 从波形回到代码定位的一个方法
如果波形显示从机在 ACK 后 SDA 一直被拉低,直到第 9 个时钟结束后才释放,这是“时钟拉伸”。从机正在处理内部事务(比如 EEPROM 页写入)。此时主机不应发送 Stop,而是应该等待并重试。对应到代码,就是在HAL_I2C_Master_Transmit返回 NACK 后加一个 5 毫秒延时再重试,而不是立刻以失败收场。抓一段波形对比成功和失败两次传输,能直观看到从机释放 SDA 的延迟时间,从而确定重试间隔是否合理。
提示:把逻辑分析仪挂在 SCL 上,触发设为 SDA 下降沿,单次采集 250ms,就能把整个上电到首笔 I2C 事务的过程完整记录下来。改完代码后重抓一次,对比两次波形在地址阶段和数据阶段的差异,就能确认是电气问题还是软件问题。
本文还有配套的精品资源,点击获取