【stm32】I2C 阻塞通信实战与 HAL 源码

🔥铅笔小新z:个人主页

🎬博客专栏:stm32学习

💫滴水不绝,可穿石;步履不休,能至渊。


目录

  1. [什么是 EEPROM](#什么是 EEPROM)
  2. [AT24C02 生态介绍](#AT24C02 生态介绍)
  3. [AT24C02 读写介绍](#AT24C02 读写介绍)
  4. [AT24C02 与灵犀板(硬件连接)](#AT24C02 与灵犀板(硬件连接))
  5. 实战目标与需求分析
  6. [软件设计:CubeMX 图形化配置](#软件设计:CubeMX 图形化配置)
  7. 代码实现与解析(应用层)
  8. [I2C 核心 HAL 函数解析](#I2C 核心 HAL 函数解析)
  9. [HAL 底层源码深度解析](#HAL 底层源码深度解析)
  10. [附录:HAL 手册与自学方法](#附录:HAL 手册与自学方法)

1. 什么是 EEPROM

1.1 先记住一句人话

EEPROM (Electrically Erasable Programmable Read-Only Memory,电可擦除可编程只读存储器)

是一种用电信号就能擦除和重新编程断电不丢数据可以按字节修改非易失性存储器

我们逐词拆开理解:

  • 电可擦除(Electrically Erasable):擦除数据用的是"电信号",而不是紫外线照射等物理手段。也就是说,程序员写代码就能把它擦干净,不需要把芯片拆下来。
  • 可编程(Programmable):擦除之后还能再次写入新的数据,可以反复用。
  • 只读(Read-Only):这个名字有点"历史的误会",早期的存储器只能读不能写,现在的 EEPROM 既能读也能写。之所以还叫"只读",是因为它设计用途是保存那些"平时只读、偶尔才改"的数据。
  • 非易失性(Non-Volatile) :这是最关键的特性------断电之后数据依然保存,不会像 RAM 那样一断电就清零。

1.2 它到底有什么用?

EEPROM 非常适合存「需要频繁小量更新、但要长期保存 」的配置或数据,核心优势在于字节级修改的灵活性

打个比方:

  • Flash(闪存) 像"一页一页撕的笔记本",通常要按扇区/页擦除再写,大容量、低成本,用来装程序、装文件。
  • EEPROM 像"带橡皮擦的单行笔记本",我想改第 3 行第 2 个字,直接改那一个字就行,其他字不受影响。虽然容量小、贵一点,但按字节改这个能力是它不可替代的价值。

虽然 Flash(本质上是 EEPROM 技术的一种衍生)在大容量、低成本存储上占据主导,但传统 EEPROM 在需要精确字节级修改、中等容量、高可靠性的场景中依然不可替代。

典型应用场景:

  • 存储设备 MAC 地址(网卡出厂就烧录好,一辈子不改)
  • 传感器 校准参数(设备生产时校准一次,长期保存)
  • 家电的 用户设置(比如灯光颜色、定时时间)
  • 嵌入式设备运行日志、计数值、开机次数等

1.3 常见型号与命名规律

EEPROM 型号非常多,但几乎都以 24 开头,例如:

  • AT24Cxxx:Microchip / Atmel(微芯科技 / 爱特梅尔,2016 年 Atmel 被 Microchip 收购,现在是一家公司)
  • M24Cxxx:STMicroelectronics(意法半导体)

核心型号分布(容量从小到大):

型号 容量 特点与用途
AT24C02 / AT24C04 / AT24C08 2Kbit ~ 8Kbit 极小容量,常用于存 MAC 地址、传感器校准参数等微小数据,成本最低
AT24C32 / AT24C64 32Kbit ~ 64Kbit 市面最主流、使用最广泛,容量适中,足以存储大量配置项或几百条日志
AT24C256 / AT24C512 256Kbit ~ 512Kbit 较大容量,适合较长的固件日志、自定义字库等

说明:Kbit 是"千比特"(1Kbit = 1024 bit = 128 字节),所以 2Kbit = 2048 bit = 256 字节。

本节使用的正是 AT24C02,它是 I2C 接口 EEPROM 中最经典的"入门课代表",连接和使用都非常简单。


✨ 知识点总结:什么是 EEPROM?

提问:请说明 EEPROM 是什么,它和 Flash 有什么区别?

答:EEPROM 全称是"电可擦除可编程只读存储器",是一种可以通过电信号擦除和重新编程、断电后数据不丢失、并且支持按字节修改的非易失性存储器。它最大的特点是字节级修改能力------修改任意一个字节都不会影响其他字节,因此非常适合存储需要频繁小量更新又要长期保存的配置类数据,比如 MAC 地址、传感器校准参数、用户设置等。

它与 Flash 的区别主要在三点:第一,EEPROM 可以按字节任意修改,而 Flash 一般需要按扇区/页先擦除再写;第二,EEPROM 容量小、成本相对高,Flash 容量大、成本低;第三,在应用定位上,EEPROM 常用于存"少量但经常要改"的配置数据,Flash 常用于存程序代码和大块数据。虽然 Flash 本质上是 EEPROM 技术的衍生,但在精确字节级修改、中等容量、高可靠性的场景下,传统 EEPROM 仍有不可替代的优势。


2. AT24C02 生态介绍

2.1 芯片命名含义逐字母拆解

AT24C02 这个名字不是随便起的,每个部分都有含义:

部分 含义 说明
AT 厂商前缀 Atmel(爱特梅尔),美国半导体公司,发明了 AVR 微控制器(Arduino 的核心)和 EEPROM 存储技术,2016 年被 Microchip(微芯科技,总部美国亚利桑那州)收购。虽然现在很多厂商都在生产 24C02,但 "AT" 前缀已经成为这一系列的行业代名词
24 系列/协议标识 24 系列 采用 I2C 协议(SDA、SCL 两根线);25 系列 通常采用 SPI 协议(速度更快,引脚更多);93 系列采用 Microwire 协议(较老的一种三线接口)
C 工艺/电压标识 意味着低功耗,支持宽电压工作范围(通常 1.7V~5.5V),非常适合 3.3V 供电的 STM32
02 存储容量(Capacity) 表示存储容量为 2Kbit ,换算成字节是 256 字节:2Kbit / 8 = 2048 bit / 8 = 256 Bytes

购买渠道:可以在立创商城(https://www.szlcsc.com/ )搜索 "AT24C02",也能在上面免费下载芯片数据手册(规格书)。

2.2 关于"手册生态"的小知识

  • 一般简单的外设芯片 ,厂商只提供一份数据手册(Datasheet) 就够了,主要讲清楚硬件参数和电气特性。
  • 如果芯片比较复杂 (比如我们用的 STM32F103ZET6 本身也是芯片),厂商一般还会提供参考手册(Reference Manual),详细给程序员介绍外设的寄存器和使用方式。

2.3 关于"国产化生态"的小知识

国外芯片进入国内价格往往偏高,而国内需求旺盛,于是国内硬件企业就通过两条路径解决:山寨国产替代

  • 山寨:打别人的品牌,卖类似的产品(灰色地带)。
  • 国产替代:用自己的品牌,卖功能兼容的产品(正规路线)。

所以,虽然我们嘴上讲 "AT24C02",但我的开发板上实际用的是深圳市英锐芯电子科技有限公司(IDCHIP) 生产的 24C02。国产厂商做自主品牌时,芯片上一般会把 "AT" 前缀去掉,直接印 24C02

结论 :市面上能生产这类芯片的厂商太多了,但后续我们什么都不用操心------只要看到 24C02,就是我们要用的 EEPROM


3. AT24C02 读写介绍

3.1 读写特点:随机读写 + 顺序读写

AT24C02 支持两种读取方式:

  • 随机读取:可以指定任意地址,读取单个字节。
  • 顺序读取 :读取一个字节后,24C02 芯片的内部地址指针会自动递增,可以连续读取后续字节。

如何理解 AT24C02 的"地址"?

把 AT24C02 想象成一个数组:

c 复制代码
uint8_t array[256];        // 一维数组:地址就是数组下标
// 或者
uint8_t array[32][8];      // 二维数组:8 字节一页,共 32 页

所谓"地址",就是数组下标。你要读写哪个存储单元,就告诉芯片"下标是多少"。

3.2 写入规则:字节写 + 页写

写入规则:

  • 字节写入 :可以写入单个字节到任意地址
  • 页写入 :AT24C02 内部把 8 个字节叫做一页 ,所以一共有 256 / 8 = 32 页

页写入有一个非常关键的坑------地址回卷(Roll Over):

  • 24C02 芯片允许一次性最多写入一页(8 字节)
  • 写入的起始地址必须是页的起始地址 ,即地址的低 3 位必须为 0(是 8 的整数倍)。
  • 如果尝试写入超过一页的数据,地址会自动回卷到该页的开头覆盖之前写入的数据 ------注意,它不会向后写到下一页!这是新手最容易踩的坑。

小技巧:任意地址转页起始地址

c 复制代码
// 把低 3 位清零,就是该地址所在页的起始地址
page_addr &= (~0x7);
// 例如地址 0x05 -> 0x00,地址 0x08 -> 0x08,地址 0x0B -> 0x08,地址 0x18 -> 0x18

3.3 写保护:WP 引脚

芯片有一个 WP(Write Protect,写保护)引脚:

  • WP 接到 GND(低电平):允许正常的读、写操作。
  • WP 接到 VCC(高电平) :整个存储器被写保护,禁止写入,但可以读取。

3.4 自身设备地址:A2 A1 A0 引脚

AT24C02 有 3 个地址引脚 A2、A1、A0 ,可以接 VCC 或 GND,用来设置芯片在 I2C 总线上的低 3 位地址

完整的 7 位器件地址格式为:

复制代码
二进制: 1 0 1 0 A2 A1 A0
十六进制:0xA0 | (A2 A1 A0)

前 4 位 1010 是 24 系列 EEPROM 的固定标识,后 3 位由硬件引脚 A2/A1/A0 决定。这三个引脚主要是为了让硬件工程师定制地址、防止多颗芯片地址冲突,我们作为软件开发者一般只使用即可,不用修改。


✨ 知识点总结:AT24C02 的读写规则与地址

提问:请介绍 AT24C02 的读写方式、页写入规则以及器件地址是怎么确定的。

答:AT24C02 支持随机读取和顺序读取,内部可以看作一个 256 字节的数组,地址就是数组下标。写入支持字节写入(任意地址写 1 字节)和页写入(每 8 字节一页,共 32 页)。页写入时必须从页的起始地址(地址低 3 位为 0)开始,且一次最多写 8 字节,如果超出本页会地址回卷覆盖本页开头,而不会写到下一页,这是编写驱动时要注意的关键点。

AT24C02 还有一个 WP 写保护引脚,接高电平时禁止写入。它的 7 位器件地址由固定前缀 1010 加上硬件引脚 A2/A1/A0 组成,例如三个引脚都接地时地址为 1010000(0x50)。提供 A2/A1/A0 引脚的目的主要是让硬件工程师可以通过接线定制地址、避免同一条 I2C 总线上多颗设备地址冲突。


4. AT24C02 与开发板(硬件连接)

4.1 角色与位置

  • AT24C02 已经被设计并焊接到了开发板的 PCB 上。
  • 在板子上,MCU 和 AT24C02 是对等的两个角色:MCU 是主机(Master),AT24C02 是从机(Slave)。
  • 重要概念 :AT24C02 是 MCU 的外部设备 ,它不是片上外设。片上外设是 MCU 内部的设备(相对于 Cortex-M3 内核而言),比如我们后面要用的 I2C2 接口。AT24C02 是挂在 I2C2 总线上的外部芯片。
  • 让 MCU 和 AT24C02 通过 SCL、SDA、上拉电阻连接起来的这套硬件电路,是开发板厂商自己设计的,跟意法半导体(STMicroelectronics)无关。

4.2 走线说明(重点)

1. I2C 通信走线

  • 选择 MCU 的 I2C2 作为 Master ,AT24C02 作为 Slave
  • AT24C02 的三个地址引脚 A0、A1、A2 都接地 ,所以它的 7 位器件地址为:
    1010000(二进制)= 0x50(十六进制)。
  • I2C 的两根线 SCL(时钟)和 SDA(数据)由 MCU 的 PB10 / PB11 引脚复用提供。

2. 串口走线

  • 使用 USART1 作为通信串口,把主机(MCU)和从机(EEPROM)的通信过程信息打印出来给电脑看。
  • 现代笔记本很少有 USART 接口,一般需要 USB 转 TTL 模块;我的开发板把 USB 转 TTL 电路内置 到了板子上,只需一根 USB 线接入电脑即可与上位机串口助手( comtool )通信。

3. LED 指示走线

  • LED1 指示数据正常通信过程(绿灯/代表正常)。
  • LED2 指示数据异常通信过程(红灯/代表异常)。

一句话总结这套硬件连接:

MCU 的 I2C2(PB10=SCL,PB11=SDA)连到 AT24C02(地址 0x50),USART1 连电脑做调试打印,两颗 LED 做运行状态指示。


✨ 知识点总结:I2C 硬件连接与设备地址

提问:如何确定一个 I2C 从机的设备地址?请结合 AT24C02 说明。

答:I2C 从机的设备地址由两部分组成:芯片本身固定的标识位和可以由硬件引脚的接线状态决定的地址位。以 AT24C02 为例,它的 7 位器件地址格式是固定的 1010 A2 A1 A0,其中高 4 位 1010 是 24 系列 EEPROM 的固定前缀,低 3 位由外部引脚 A2/A1/A0 的接法决定:引脚接 GND 视为 0,接 VCC 视为 1。开发板上 A2/A1/A0 全部接地,因此地址为 1010000,即十六进制 0x50。硬件工程师可以通过改变这三个引脚的接线来定制地址,避免同一条 I2C 总线上多颗从机地址冲突;对软件开发者而言,只需要在代码里配置正确的地址即可。


5. 实战目标与需求分析

5.1 实战目标

  1. 掌握 I2C 主机阻塞模式的配置过程,包括 CubeMX 软件配置以及生成代码的理解。
  2. 掌握 I2C 主机阻塞模式数据发送、接收相关函数接口的使用方法。
  3. 掌握 AT24Cxx EEPROM 的使用方法。

5.2 需求(做一个"点灯配置保存"小项目)

设计一个小项目,把 I2C 通信用起来:

  • 通过 USART1,实现 comtool(上位机串口助手)和单片机之间的交互式数据通信。
  • 用户通过上位机输入的点灯数据包括:点亮哪一颗 LED什么时候点亮点亮几次
  • 数据到达 USART1 之后,通过 Cortex-M3 阻塞式写入到 EEPROM,保存点灯数据(断电不丢)。
  • 重启单片机后,读取 EEPROM,获取点灯信息。
  • 根据点灯信息,按照规则点亮指定的 LED 灯。
  • 闪灯的同时输出提示信息,样例:LEDx 被点亮
  • 灭灯的同时输出提示信息,样例:LEDx 被熄灭

通俗理解:用电脑通过串口告诉单片机"我要点灯,怎么点",单片机把这份"点灯说明书"存进 EEPROM,然后重启,单片机自己读说明书照做。 断电重启后数据依然在,这就是 EEPROM 的意义所在。


✨ 知识点总结:阻塞模式 vs 非阻塞模式

提问:I2C 的阻塞模式(Blocking)和非阻塞模式(Non-blocking / IT / DMA)有什么区别?

答:阻塞模式就是调用函数后,CPU 会一直等待通信完成(通常配合超时保护,防止卡死)才返回;函数返回时数据已经收发完成,逻辑简单直观,代码容易读懂,适合通信频率不高、对实时性要求不苛刻的场景,比如本次课程中读写 EEPROM。非阻塞模式则包括中断(IT)和 DMA 两种方式:中断模式是数据准备好后触发中断再处理;DMA 模式则是由 DMA 控制器自动搬运数据,几乎不占用 CPU。非阻塞模式的优点是 CPU 在通信期间可以去做别的事情,响应更实时、吞吐更高,但代码复杂度更高,逻辑也更难调。实际工程中,低速且偶发的通信常用阻塞模式,高速、大数据量或需要并行处理的场景常用中断或 DMA 模式。


6. 软件设计:CubeMX 图形化配置

6.1 配置仿真器接口(SWD)

开发板预留的仿真器调试接口是 SWD(Serial Wire Debug,串行线调试)。

在 CubeMX 的 SYS(System)配置里,将 Debug 选项选择为 "Serial Wire"

⚠️ 重要提醒 :如果错误配置或禁止 了 debug 接口(如 JTAG / SWD),烧录完程序后将无法通过调试器连接目标 STM32 设备,也就是"烧一次就变砖"的经典翻车现场。所以调试口一定要打开。

6.2 配置晶振和时钟(RCC)

  • 在 RCC 配置中,HSE(高速外部时钟)选择 "Crystal / Ceramic Resonator" (晶振/陶瓷谐振器),表示使用外部晶振。

  • 开发板上的外部晶振是 8MHz

  • 配置时钟树,将系统运行最大频率配置到 72MHz (STM32F103 的极限主频)。

关于内部晶振与外部晶振的经验:

  • 实际工作中,很少使用内部晶振,大部分情况下用的都是外部晶振
  • 内部晶振(HSI)容易受温度影响,会导致 温漂 问题。
  • 温漂:即温度变化引起频率漂移。MCU 内部的片上外设中,凡是依赖时钟频率工作的模块(比如定时器、串口波特率、ADC 采样率)都容易受影响------频率一偏,波特率就对不上了,通信自然出问题。

6.3 配置 I2C2 接口

在 Connectivity(连接性)中找到 I2C2,主要配置:

  • I2C 速度模式 :快速模式(Fast Mode),速率 400KHz
  • 地址模式:7-bit 地址模式(需要与从机保持一致,AT24Cxx 芯片是 7 位地址)。
  • 时钟占空比Tlow / Thigh = 2(低电平与高电平时长比 2:1)。
  • 其他配置默认即可。

使能 I2C2 后会自动生成相应的 GPIO 管脚配置,保持默认即可:

  • SCL → PB10SDA → PB11
  • 复用功能模式为 Alternate Function Open Drain(复用开漏输出)

两个概念先搞清楚:

  • Alternate Function(复用功能) :引脚不再作为普通 GPIO 数据输入输出,而是交给片上外设(如 I2C、SPI、USART)来控制。
  • Open Drain(开漏) :引脚内部的驱动结构是"开漏"------只能强力拉低到地(GND)不能主动输出高电平 ,高电平必须依靠外部上拉电阻实现。这是 I2C 协议的硬件基础(线与逻辑)。

由于 MCU 的 I2C2 充当 Master,不需要配置 Slave Features(从机功能),默认即可。

6.3.1 问题一:I2C 为什么配置成 400KHz?怎么选速度?

  • 首先,MCU 内部的 I2C 接口本身支持标准模式(100KHz)快速模式(400KHz) 两种速度。
  • 速度的选择主要取决于从机 AT24C02 的速度要求 。怎么知道从机支持多快?学习期间要培养查阅可靠信源的习惯------最好的办法是翻阅 AT24C02 芯片的数据手册(规格书)。

细节 :根据 AT24C02 数据手册,当工作电压(VCC)处于 1.8V、2.5V 或 2.7V 及以上时,该芯片最高支持 400 kHz 的快速模式(Fast-mode)

选择 I2C 通信速度的方法论:

  1. I2C 属于通信,在可靠 的前提下,越快越好
  2. 参与通信的设备,各自支持的速度频率不同,就要取所有设备都支持的速度中"最快的那个"
  3. 说白了:在保证可靠的前提下,取所有设备支持速度的交集(最小值),然后选其中最快的那个------没必要"没苦硬吃"。

6.3.2 问题二:I2C 配置里的"占空比"是什么?

占空比是什么(是什么) :在一个完整的 SCL 时钟周期内,SCL 信号低电平时间与高电平时间之间的分配比例

复制代码
占空比 = 低电平时长 / 高电平时长

注意:传统意义上,占空比的定义是"(有效电平时间 / 总周期时间)× 100%"。但在 I2C 里我们采用 "低电平时长 / 高电平时长" 的理解。原因一方面是为了适配 STM32CubeMX 的配置,另一方面如果要用"有效电平"就得先解释什么是"有效",太麻烦。学完之后你会发现这两种理解其实是一回事,此处不做纠结。

什么是一个周期(周期)一个低电平 + 一个高电平就是一个周期。

细节:

  • 只有 SCL 才讨论占空比 ,SDA 不讨论。因为占空比的前提是输出方有"稳定比率的输出方波",而 SDA 输出的数据是随机的------比如发送 0000 0000,连高电平都没有,自然谈不上占空比。
  • 整个 SCL 输出的方波图,是由无数个具有相同规律的"周期"组成的。

周期时长的计算:

速度模式 频率 1 秒的周期数 一个周期的时长
标准模式 Standard Mode 100KHz 100K 个 1s / 100000 = 10μs
快速模式 Fast Mode 400KHz 400K 个 1s / 400000 = 2.5μs

本质理解 :I2C 下的占空比,本质是高低电平"瓜分"周期时长的一种分配方案

为什么要有占空比(为什么)?

信源是 NXP(恩智浦半导体,总部荷兰,前身是飞利浦半导体)发布的《UM10204.pdf》(I²C-bus 规范与用户手册),它定义了 I²C 总线的电气特性、时序和协议标准,是开发 I2C 设备的官方"圣经"。I²C 协议最初由荷兰飞利浦半导体在 1982 年提出。

权威协议规定(为了满足 I2C 电气特性,高低电平有最短时长要求):

  • 标准模式 :低电平持续时间不少于 4.7μs ,高电平持续时间不少于 4.0μs
    • 标准模式一个周期 10μs,如果按 1:1 分配,高低电平各 5μs,恰好满足要求(5 ≥ 4.7,5 ≥ 4.0)。所以 CubeMX 在标准模式下干脆不需要你显示配置占空比。
  • 快速模式 :低电平持续时间不少于 1.3μs ,高电平持续时间不少于 0.6μs
    • 快速模式一个周期只有 2.5μs,如果还用 1:1,高低电平各 1.25μs,此时 1.25 < 1.3,低电平不满足要求,蛋糕得重新划分。
    • 比如占空比设为 2:1 :低电平 = (2.5μs / 3) × 2 = 1.67μs ,高电平 = (2.5μs / 3) × 1 = 0.83μs,都满足协议要求。

所以占空比的本质结论

占空比是高低电平瓜分周期时长的一种分配方案,设置成特定比率的目标,就是让高低电平都满足 I2C 的电气特性要求

常见的占空比:

  • I2C 常见占空比是 2:116:9
    • 16:9 时,低电平 1.6μs,高电平 0.9μs,也满足最低要求。
    • 工程实践中,2:1 较为常见

细节:为什么 I2C 特别在意低电平,低电平时间普遍比高电平时间长?

言简意赅:

  1. SCL 低电平期间才允许 SDA 电平变化,而变化需要更多时间保证;SCL 高电平时 SDA 不变,I2C 只需要采样数据,没有太多时间成本。
  2. I2C 总线上拉低快、拉高慢(开漏结构靠外部上拉电阻拉高,电流小所以慢),整体导致电平变化慢,就需要更长的 SCL 低电平时长。

6.4 配置串口 USART1

串口基本参数配置,核心配置为:

  • 模式:异步模式(Asynchronous)
  • 波特率:115200
  • 字长:9 位数据(8 位数据 + 1 位校验位)
  • 校验:奇校验(Odd Parity)
  • 停止位:1 位

⚠️ 这里有个坑 :上位机串口助手(comtool)的配置必须和单片机保持一致(同样的波特率、数据位、校验位、停止位),才能正确传输数据。

使能 USART1 会自动生成对应管脚的配置(TX/RX 引脚),保持默认即可。

6.5 配置 LED

选择 4 颗 LED 灯,对应关系如下:

针脚 LED 编号 板上的 LED 名称
PF8 SYS_LED1 LED4
PF9 SYS_LED2 LED5
PF10 SYS_LED3 LED7
PF11 SYS_LED4 LED8

结合原理图可知:这 4 个 LED 的 output 模式应为 通用输出开漏模式(GPIO_MODE_OUT_OD),因为外部已经提供了高电平(LED 阳极接高电平,由引脚开漏拉低点亮)。

6.6 生成项目

  • MDK-ARM 是 ARM 公司官方推出的集成开发环境(俗称 Keil )。CubeMX 配置完成后,在 Project Manager 中选择 MDK-ARM,点击 GENERATE CODE 生成工程。
  • 生成之后点击 Open Project ,即可用 Keil uVision 打开对应的 .uvprojx 工程文件。

✨ 知识点总结:I2C 通信速度与占空比

提问:如何选择 I2C 的通信速度?占空比是什么,为什么快速模式下需要配置占空比?

答:选择 I2C 通信速度的原则是"在可靠的前提下,取所有参与通信设备都支持速度的交集(即最小值)中选最快的那一个"。具体做法是先查阅从机(如 AT24C02)的数据手册,确认它最高支持什么速度,再结合主机能力确定。例如 AT24C02 在电压 2.7V 及以上时最高支持 400kHz 快速模式,所以本节配置为 400KHz。

占空比在 I2C 中定义为"一个 SCL 周期内低电平时长与高电平时长的比值"。它本质是高低电平瓜分周期时长的一种分配方案。之所以要关注占空比,是因为 I2C 协议对高低电平的最短持续时间有硬性要求:标准模式要求低电平不少于 4.7μs、高电平不少于 4.0μs;快速模式要求低电平不少于 1.3μs、高电平不少于 0.6μs。标准模式一个周期 10μs,1:1 分配各 5μs 就能满足要求;而快速模式一个周期只有 2.5μs,若仍用 1:1 则低电平只有 1.25μs,不满足 1.3μs 的要求,因此需要配置 2:1 占空比,使低电平达到 1.67μs、高电平 0.83μs。此外,由于 I2C 采用开漏结构、拉高依赖外部上拉电阻导致拉高慢,且 SCL 低电平期间才允许 SDA 变化,所以低电平普遍需要更长时间,这也是 I2C 特别在意低电平时长的原因。


7. 代码实现与解析(应用层)

生成工程后,我们把应用代码写进 CubeMX 预留的 USER CODE 区。整个代码分三层:

  • App 层:业务逻辑(用户交互、点灯配置)
  • BSP 层:板级驱动(LED、串口、AT24C02 驱动封装)
  • HAL 层:STM32 官方 HAL 库(CubeMX 自动生成)

7.1 App.h ------ 应用层头文件

c 复制代码
#ifndef __APP_H
#define __APP_H

#ifdef __cplusplus
extern "C"
{
#endif

#include <stdint.h>
#include "BSP_LED.h"
#include "BSP_Usart_Redir.h"
#include "BSP_AT24C02.h"

// 魔数:用于校验 EEPROM 里的配置是否有效(相当于"有效数据"的标记)
#define APP_MAGIC   0x35
// 版本号:配置结构的版本,未来结构体改了可以升级校验
#define APP_VERSION 0x01

// 8 字节的配置结构体类型
typedef struct 
{
    uint8_t magic;        // 魔数,用于判断 EEPROM 中数据是否有效
    uint8_t version;      // 版本,用于判断数据结构版本是否匹配
    uint8_t led_id;       // 点哪一颗 LED(1~4,5 表示全选)
    uint8_t repeat_cnt;   // 闪几次(1~255)
    uint16_t delay_ms;    // 时间间隔,单位 ms
    uint16_t start_time;  // 开始闪灯的时间,单位 ms
} config_t;

// EEPROM 中保存配置的起始地址(0x00 即第 0 个字节)
#define CONFIG_ADDRESS 0x0

// 简化读写接口:写配置 = 从 0 地址写入 conf_len 个字节
#define SAVE_CONFIG(conf_addr, conf_len) BSP_AT24C02_Write(CONFIG_ADDRESS, conf_addr, conf_len)
// 简化读写接口:读配置 = 从 0 地址读出 conf_len 个字节
#define LOAD_CONFIG(conf_addr, conf_len) BSP_AT24C02_Read(CONFIG_ADDRESS, conf_addr, conf_len)

void App_Init(void);   // 应用初始化:让用户选择重新配置还是使用已有配置
void App_Run(void);    // 应用运行:校验配置并执行点灯逻辑

#ifdef __cplusplus
}
#endif

#endif

7.2 App.c ------ 应用层实现

c 复制代码
#include "App.h"

// 全局配置变量
config_t gconfig;

// 打印当前配置清单,方便在串口助手上确认配置内容
static void Config_Display(void)
{
    printf(">>-----------配置清单---------------<<\n");
    printf("config.magic: 0x%x\n", gconfig.magic);
    printf("config.version: 0x%x\n", gconfig.version);
    printf("config.led_id: %d\n", gconfig.led_id);
    printf("config.repeat_cnt: %d\n", gconfig.repeat_cnt);
    printf("config.delay_ms: %d\n", gconfig.delay_ms);
    printf("config.start_time: %d\n", gconfig.start_time);
}

// 提示用户输入一个整数。
// 返回值:scanf 成功读到 1 个数则返回该数;否则打印提示并返回 EOF(读入失败)
static int Request_Input(const char *message)
{
    int value = 0;
    printf(">> %s\n", message);
    if (scanf("%d", &value) == 1)
    {
        return value;   // 输入合法,返回用户输入
    }
    printf("[!] 输入无效, 请重新尝试!\n");
    return EOF;         // 输入非法,返回 EOF
}

void App_Init(void)
{
    // 获取用户的选择:1 = 重新配置参数,2 = 使用现有的配置(读取 EEPROM)
    int choice = Request_Input("1. 重新配置参数 2. 使用现有的配置");
    if (choice == 1)
    {
        // ---------- 重新配置参数 ----------
        gconfig.magic = APP_MAGIC;                                    // 填上魔数,标记"配置有效"
        gconfig.version = APP_VERSION;                                // 填上版本号
        gconfig.led_id = (uint8_t)Request_Input("step1: 选择LED[1,4], 5表示全选 >> ");
        gconfig.repeat_cnt = (uint8_t)Request_Input("step2: 设置闪灯次数, [1,255]>> ");
        gconfig.delay_ms = (uint16_t)Request_Input("step3: 设置闪灯间隔,单位ms>> ");
        gconfig.start_time = (uint16_t)Request_Input("step4: 设置开始时间,单位ms>> ");

        Config_Display();   // 打印配置确认

        // 将配置结构体写入 EEPROM(地址 0 开始,长度 sizeof(config_t) 字节)
        SAVE_CONFIG(&gconfig, sizeof(gconfig)); // AT24C02写入
        printf(">> write config done <<\n");
    }
    else if (choice == 2)
    {
        // ---------- 读取已有配置 ----------
        LOAD_CONFIG(&gconfig, sizeof(gconfig)); // 从 EEPROM 读回配置
        printf(">> load config done <<\n");
    }
    else
    {
        printf("[!] 输入无效, 选项只有1 || 2!\n");
    }
}

void App_Run(void)
{
    // 校验:魔数对 + 版本对,才认为 EEPROM 里的配置有效
    if(gconfig.magic == APP_MAGIC && gconfig.version == APP_VERSION)
    {
        printf(">> %d ms之后, 开始闪灯\n", (int)(gconfig.start_time));
        HAL_Delay(gconfig.start_time);                       // 等待开始时间
        printf(">> 闪灯中...\n");
        // 按配置点亮对应 LED,闪 repeat_cnt 次,每次间隔 delay_ms
        LED_Blink((LED_t)(gconfig.led_id), gconfig.repeat_cnt, gconfig.delay_ms);
    }
}

7.3 BSP_AT24C02.h ------ AT24C02 驱动头文件

c 复制代码
#ifndef __BSP_AT24C02_H
#define __BSP_AT24C02_H

#ifdef __cplusplus
extern "C"
{
#endif

#include <stdbool.h>
#include "stm32f1xx_hal.h"

// 设备信息结构体:描述一个 EEPROM 从设备
typedef struct
{
    I2C_HandleTypeDef *hi2c;    // 指向所用 I2C 外设句柄(本工程为 hi2c2)
    uint16_t dev_address;       // 从设备地址(7 位地址左移 1 位后的 8 位形式)
    uint8_t page_size;          // 页大小(AT24C02 为 8)
    bool is_init;               // 是否初始化成功(设备是否在线)
}eeprom_t;

#define DEV_ADDRESS 0x50        // 器件 7 位地址 0x50
#define PAGE_SIZE 8             // 每页 8 字节
#define DEV_TOTAL_SIZE 256      // 总容量 256 字节

#define SCAN_RETYIES 2          // 初始化时探测设备的重试次数
#define SCAN_TIME_MS 5          // 初始化探测单次超时时间(ms)
#define POLL_RETYIES 10         // 写完成后轮询就绪的重试次数
#define POLL_TIME_MS 2          // 轮询单次超时时间(ms)
#define READ_TIMEOUT_MS 50      // 读操作总超时时间(ms)
#define WRITE_TIMEOUT_MS 50     // 写操作总超时时间(ms)

bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c);                     // 初始化 + 探测设备在线
bool BSP_AT24C02_Read(uint16_t start_address, void *data_out, uint8_t data_len);  // 任意地址读
bool BSP_AT24C02_Write(uint16_t start_address, void *data_in, uint8_t data_len);  // 任意地址写(自动跨页)

#ifdef __cplusplus
}
#endif

#endif

7.4 BSP_AT24C02.c ------ AT24C02 驱动实现(重点)

c 复制代码
#include "BSP_AT24C02.h"

// 全局设备对象
eeprom_t at24c02;

// 初始化:绑定 I2C 句柄、计算设备地址、探测设备是否在线
bool BSP_AT24C02_Init(I2C_HandleTypeDef *hi2c)
{
    if (!hi2c)
        return false;

    at24c02.hi2c = hi2c;
    at24c02.dev_address = DEV_ADDRESS << 1; // 左移 1 位:7 位地址变 8 位(最低位留作读写位)
    at24c02.page_size = PAGE_SIZE;
    at24c02.is_init = false;

    // 探测设备是否在线(HAL_I2C_IsDeviceReady 内部完成):
    // 1. 产生起始条件 START
    // 2. 发送从设备地址 + 写标志 W(0)
    // 3. 若收到 ACK -> 从设备活着呢!
    // 4. 发送停止条件 STOP
    if (HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address, SCAN_RETYIES, SCAN_TIME_MS))
    {
        at24c02.is_init = true;   // 收到应答,设备在线
    }

    return at24c02.is_init;
}

// 任意地址读取:支持读 1 字节 / 连续读多字节(内部自动"随机读+顺序读")
bool BSP_AT24C02_Read(uint16_t start_address, void *data_out, uint8_t data_len)
{
    // 参数合法性检查
    if (!at24c02.is_init || !data_out || data_len == 0)
        return false;

    // 越界检查:起始地址 + 长度不能超过总容量
    if (start_address + data_len > DEV_TOTAL_SIZE)
    {
        return false;
    }

    // HAL 库内存读接口:
    //   - 先发送"器件地址+写位",再发送"内存地址(字地址)",
    //   - 然后重新产生起始条件,再发送"器件地址+读位",最后连续接收数据。
    //   整个"先选地址、再读取"的过程由 HAL_I2C_Mem_Read 一步完成。
    HAL_StatusTypeDef state = HAL_I2C_Mem_Read(
        at24c02.hi2c, at24c02.dev_address,     // I2C 句柄 + 从机地址
        start_address,                          // 要读的内存起始地址
        I2C_MEMADD_SIZE_8BIT,                   // 字地址长度:8 位(AT24C02 内部 256 字节,8 位够用)
        data_out,                               // 读到的数据存到这里
        data_len,                               // 读多少字节
        READ_TIMEOUT_MS);                       // 总超时
    return state == HAL_OK;
}

// 检查设备是否就绪(写完成后用来"等待写入周期结束")
static bool BSP_AT24C02_Ready()
{
    if(!at24c02.is_init)
        return false;
    // HAL_I2C_IsDeviceReady 内部会多次尝试,只要一次收到 ACK 就返回 HAL_OK
    return HAL_OK == HAL_I2C_IsDeviceReady(at24c02.hi2c, at24c02.dev_address, POLL_RETYIES, POLL_TIME_MS);
}

// 单页写入:只写"当前页内"的数据(写入后必须等待 EEPROM 内部写周期完成)
static bool BSP_AT24C02_Write_Page(uint16_t start_address, void *data_in, uint8_t data_len)
{
    if(!at24c02.is_init || !data_in || data_len == 0)
        return false;

    // 页内偏移 = 起始地址对页大小取余,即"本页内开始写入的下标"
    uint16_t page_offset = start_address % at24c02.page_size;

    // 关键检查:页偏移 + 长度不能超过本页,否则会发生"地址回卷"覆盖本页开头
    if(page_offset + data_len > at24c02.page_size)
    {
        return false;   // 超过一页,拒绝写入
    }

    // HAL 库内存写接口:发送器件地址 + 内存地址 + 数据
    HAL_StatusTypeDef state = HAL_I2C_Mem_Write(
        at24c02.hi2c,
        at24c02.dev_address,      // 从机地址(左移后)
        start_address,            // 内存地址
        I2C_MEMADD_SIZE_8BIT,     // 字地址 8 位
        data_in,                  // 待写入数据
        data_len,                 // 写入字节数
        WRITE_TIMEOUT_MS);        // 超时
    if(state != HAL_OK)
        return false;

    // 关键:EEPROM 内部写入一个字节约需 5ms+ 的时间,
    // 写完必须等设备"忙完"才能进行下一次写,否则数据会丢。
    // 这里通过 HAL_I2C_IsDeviceReady 轮询探测:设备恢复应答就说明写完内部了。
    return BSP_AT24C02_Ready();
}

// 支持任意长度的写入,自动按页拆分、自动跨页
bool BSP_AT24C02_Write(uint16_t start_address, void *data_in, uint8_t data_len)
{
    if(!at24c02.is_init || !data_in || data_len == 0)
        return false;
    if(start_address + data_len > DEV_TOTAL_SIZE)
        return false;

    uint16_t bytes_written = 0;   // 已经成功写入了多少字节
    while(bytes_written < data_len)   // 还没写完,继续
    {
        // 还剩多少数据没写
        uint16_t data_left = data_len - bytes_written;

        // 下一次写入的起始地址
        uint16_t new_start_address = start_address + bytes_written;

        // 当前地址落在哪一页的哪个位置、这一页还剩多少空间
        uint16_t page_offset = new_start_address % at24c02.page_size;
        uint16_t page_space = at24c02.page_size - page_offset;

        // 计算本次实际写入的字节数:
        //   - 若剩余数据 <= 页内剩余空间,则全部写完;
        //   - 若剩余数据 > 页内剩余空间,则只写满本页,剩下交给下一轮 while 处理。
        uint16_t write_bytes = data_left;
        if(write_bytes > page_space)
        {
            write_bytes = page_space;
        }

        // 单页写入(数据指针要跟着偏移到未写的位置)
        if(!BSP_AT24C02_Write_Page(new_start_address, (uint8_t*)data_in+bytes_written, write_bytes))
        {
            return false;   // 任一次写失败即整体失败
        }

        bytes_written += write_bytes;   // 累计已写字节数
    }

    return true;
}

开发小经验:实际编写时优先先写"控制设备的代码",边演示边完善代码;前期可以局部性地阅读部分源代码,不必一次看懂全部。

7.5 main.c ------ 调用方(CubeMX 生成 + 用户代码)

c 复制代码
/* Includes ------------------------------------------------------------------*/
#include "main.h"

/* USER CODE BEGIN Includes */
#include "App.h"          // 引入应用层头文件
/* USER CODE END Includes */

/* Private variables ---------------------------------------------------------*/
I2C_HandleTypeDef hi2c2;    // I2C2 外设句柄
UART_HandleTypeDef huart1;  // USART1 外设句柄

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
static void MX_GPIO_Init(void);
static void MX_I2C2_Init(void);
static void MX_USART1_UART_Init(void);

/* The application entry point */
int main(void)
{
  /* MCU Configuration --------------------------------------------------------*/
  HAL_Init();                 // 复位所有外设、初始化 Flash 接口和 SysTick

  /* Configure the system clock */
  SystemClock_Config();       // 配置系统时钟(HSE 8MHz -> 72MHz)

  /* Initialize all configured peripherals */
  MX_GPIO_Init();             // 初始化 GPIO(LED 等)
  MX_I2C2_Init();             // 初始化 I2C2
  MX_USART1_UART_Init();      // 初始化 USART1

  /* USER CODE BEGIN 2 */
  BSP_AT24C02_Init(&hi2c2);   // 初始化 AT24C02(探测设备在线)
  BSP_Usart_Init(&huart1);    // 初始化串口打印重定向
  /* USER CODE END 2 */

  /* Infinite loop */
  while (1)
  {
    App_Init();               // 让用户选择:重新配置 or 读取已有配置
    App_Run();                // 校验配置并执行点灯
    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

Gitee代码链接,这是我的代码,可以参考。


✨ 知识点总结:AT24C02 驱动的分层设计与页写入实现

提问:写一个 AT24C02 的驱动,核心要注意什么?为什么写操作需要"等待"?

答:编写 AT24C02 驱动时,核心要点包括三个方面。第一,设备地址处理:芯片的 7 位地址是 0x50,传给 HAL 库时必须在调用前左移 1 位,变成 8 位(最低位留给读写位,0 表示写、1 表示读)。第二,读写都要走"内存型"接口(HAL_I2C_Mem_Read / HAL_I2C_Mem_Write),因为它们会在内部自动完成"先发器件地址、再发内存地址、再读/写数据"的完整时序;读操作还会自动产生重新起始条件。第三,页写入规则:AT24C02 每页 8 字节,一次写不能跨页,所以驱动要按"当前页剩余空间"拆分数据,循环逐页写入;写入前还要检查"页内偏移 + 长度 ≤ 8",防止地址回卷覆盖数据。

关于"等待":EEPROM 的写入是有物理时间的,向 AT24C02 写入一个字节需要约 5ms 以上的完整写周期,且写入期间芯片不会响应其他命令。因此写完一页后必须等待写入完成,通常通过 HAL_I2C_IsDeviceReady 轮询探测------芯片恢复应答(ACK)就表示内部写周期结束,可以继续下一次操作。忽略这一步会导致数据写入失败或丢失。


8. I2C 核心 HAL 函数解析

8.1 HAL_I2C_IsDeviceReady ------ 检测设备是否就绪

为什么需要它?

对 EEPROM 写入时,需要主动等待 5ms+ 的时间等写入完成。虽然直接用 HAL_Delay(5) 也能等,但这种方式非常粗糙 :一是死等浪费时间,二是无法真正知道芯片内部写完了没有。HAL 库 I2C 部分提供了更优雅的方案------HAL_I2C_IsDeviceReady,用它来做写入等待。

AT24Cxx 写操作需要等待完成,原因有 2 点:

  1. 写入需要物理时间 :向 AT24C02 写入数据时,一个完整的写入周期是 5ms +(来自数据手册)。
  2. 从机以 ACK 表明就绪:AT24C02 作为 I2C 从机,主机只能通过"从机是否有应答 ACK"来判断从设备是否就绪。数据发出去之后,要收到 ACK 才证明对方收到了,这个过程需要时间。

函数原型:

c 复制代码
HAL_StatusTypeDef HAL_I2C_IsDeviceReady(I2C_HandleTypeDef *hi2c,
                                        uint16_t DevAddress,
                                        uint32_t Trials,
                                        uint32_t Timeout)

参数说明:

参数 含义
hi2c 指向 I2C 句柄结构体的指针,包含指定 I2C 外设的配置信息
DevAddress 目标设备地址,7 位设备地址在调用前必须左移 1 位
Trials 尝试次数,检测失败时的重试次数,建议值 1~10
Timeout 单次检测操作的超时时间(毫秒),0 表示无超时限制

返回值:

返回值 含义
HAL_OK 设备就绪,检测到设备应答,可以进行通信
HAL_ERROR 设备未响应或检测失败,所有尝试均失败
HAL_BUSY I2C 总线忙或 I2C 句柄状态不正确
HAL_TIMEOUT 操作超时,可能是总线异常或设备无响应

功能概述 :该函数用于检测目标 I2C 设备是否准备好进行通信,主要用于设备的连接性检测设备扫描 。它通过发送设备地址并检测应答信号来判断设备是否在线,支持多次尝试,以提高检测可靠性。

8.2 HAL_I2C_Mem_Read ------ 从指定内存地址读取

为什么读 EEPROM 选它,而不选 HAL_I2C_Master_Receive?

很多同学会好奇:读取数据难道不应该用 HAL_I2C_Master_Receive 吗?确实有这个函数,但它不适合在这里使用(在绝大部分场景下它也不是主流)。

8.2.1 读取 AT24C02 需要什么样的时序?

核心点在于:使用 I2C 读取 AT24C02,需要先发送器件地址,然后发送字地址(即要访问器件的哪一个存储位置)。

详细过程(读 N 个字节):

  1. 主机(MCU)发送起始标志(START)。
  2. 主机发送从机地址 + 写标志位(注意没写错!需要先"写"给 AT24C02 未来要读取的地址信息,这个地址对 AT24C02 来说就是接下来要发送的数据)。
  3. 从机(EEPROM)发送 ACK 应答位。
  4. 主机发送要读取的内存起始地址(字地址)。
  5. 从机发送 ACK 应答位。
  6. 主机发送重新起始标志(Repeated Start)

什么是重新起始标志?

  • 没有发送停止标志的情况下再次发送起始标志。
  • 不释放总线控制权 ,是一个原子的、不可分割的总线事务。
  • 保证操作序列的连续性和独占性。
  • 说白了就是:已经告诉 AT24C02 我要访问哪一个地址了,下面我"重新开始"读取。
  1. 主机发送从机地址 + 读标志位
  2. 从机发送 ACK 应答位。
  3. 从机发送数据字节 0
  4. 主机发送 ACK 应答位。
  5. ......(循环)
  6. 从机发送数据字节 n
  7. 主机发送 NACK(非应答)位------通知从机"我不要再收了"。
  8. 主机发送停止标志(STOP)。

整个过程最后都是由 HAL_I2C_Mem_Read 独自完成的,所以我们选择它。

8.2.2 HAL_I2C_Master_Receive 为什么不适合?

它的时序是"普通主机接收":发送 START → 从机地址+读 → 收数据 → STOP。这个读取逻辑没有提前选中 AT24C02 的内部内存地址的过程,所以我们无法直接用它读取 EEPROM 的指定位置。

正面回答:为什么读选择 HAL_I2C_Mem_Read?

因为它内部帮我们设计了"先写入 AT24C02 要被读取的位置地址信息,然后重新开始读取"的完整过程。

函数原型:

c 复制代码
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c,
                                   uint16_t DevAddress,
                                   uint16_t MemAddress,
                                   uint16_t MemAddSize,
                                   uint8_t *pData,
                                   uint16_t Size,
                                   uint32_t Timeout)

参数说明:

参数 含义
hi2c 指向 I2C 句柄结构体的指针
DevAddress 目标设备地址,7 位地址调用前必须左移 1 位
MemAddress 内部存储器地址,即要读取数据的起始地址
MemAddSize 内部存储器地址的大小:I2C_MEMADD_SIZE_8BIT(8 位地址)或 I2C_MEMADD_SIZE_16BIT(16 位地址)
pData 指向数据缓冲区的指针,用于存储读到的数据
Size 要读取的数据字节数
Timeout 超时时间(毫秒)

返回值HAL_OK(成功)、HAL_ERROR(通信错误/超时)、HAL_BUSY(外设忙/总线被占用)。

关于"字地址长度"(MemAddSize)的通俗解释 :EEPROM 内部有很多存储格子(字节),你需要告诉它"我要读/写哪个格子","字地址"就是格子编号,而"这个编号占几个字节"就是字地址长度 。AT24C02 只有 256 个格子,编号 0~255,一个字节(8 位)就够,所以用 I2C_MEMADD_SIZE_8BIT

8.3 HAL_I2C_Mem_Write ------ 向指定内存地址写入

为什么写 EEPROM 选它,而不选 HAL_I2C_Master_Transmit?

8.3.1 写入 AT24C02 需要什么样的时序?

无论写入一个字节还是一页,使用 I2C 写入 AT24C02,都需要先发送器件地址,再发送字地址

如"字节写"时序所示:向 AT24Cxx 特定地址写入数据前需要先写入要操作的内存地址。如果使用 HAL_I2C_Master_Transmit 进行写操作 ,就需要在待发送的数据缓冲区前手动追加内存地址字节再发送------非常麻烦且容易出错。

STM32 的 HAL 库专门封装了一个满足该时序的函数,就是 HAL_I2C_Mem_Write。写入的整个过程最后由它独自完成,所以我们选择它。

8.3.2 HAL_I2C_Master_Transmit 为什么不适合?

它的时序是"普通主机发送":发送 START → 从机地址+写 → 发数据 → STOP。没有提前选中 AT24C02 内部内存地址 的过程,无法直接用它往指定地址写入。这和之前 HAL_I2C_Mem_Read 的理解过程是一样的。

函数原型:

c 复制代码
HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c,
                                    uint16_t DevAddress,
                                    uint16_t MemAddress,
                                    uint16_t MemAddSize,
                                    uint8_t *pData,
                                    uint16_t Size,
                                    uint32_t Timeout)

参数说明HAL_I2C_Mem_Read 几乎一致,只是 pData 指向要发送的数据缓冲区Size要发送的数据字节数

返回值HAL_OK(成功)、HAL_ERROR(通信错误、应答失败、超时)、HAL_BUSY(外设忙/总线被占用)。


✨ 知识点总结:为什么用 Mem_Read / Mem_Write 而不是 Master_Receive / Master_Transmit

提问:为什么读写 EEPROM 要用 HAL_I2C_Mem_Read / HAL_I2C_Mem_Write,而不是 HAL_I2C_Master_Receive / HAL_I2C_Master_Transmit?

答:原因是"时序不匹配"。EEPROM 这类带内部地址空间的存储设备,无论读写,主机都需要先告诉从机"我要访问内部哪个存储单元",也就是要发送一个"字地址"(内存地址)。普通的主机接收函数 HAL_I2C_Master_Receive 只完成"发地址→收数据→停"的流程,主机发送函数 HAL_I2C_Master_Transmit 只完成"发地址→发数据→停"的流程,它们都没有"发送字地址"这个环节,无法定位到 EEPROM 内部的具体地址。

而内存型接口 HAL_I2C_Mem_Read 和 HAL_I2C_Mem_Write 则内置了完整的"内存访问"时序:先发送器件地址和写位,再发送内存地址,然后重新产生起始条件(读时),再发送器件地址和读写位,最后完成数据收发并产生停止条件。以读为例,还必须先以"写"的方式把目标地址告诉从机,再用"重新起始 + 读"的方式取回数据。正因为 HAL 库内部帮我们自动完成了"先选地址、再读写"的复合时序,所以读取 EEPROM 必须使用 Mem_Read,写入必须使用 Mem_Write。


9. HAL 底层源码深度解析

前面我们已经掌握了 I2C 原理,并用 HAL 库完成了配置和通信代码,接下来把理论再往底层走一层------读 HAL 源码

读源码前的重要心态

  • HAL 库源码有相当一部分是"debug 出来的"(先有 bug,后面打补丁修好),所以有些代码看起来很奇怪。作为意法半导体之外的人,很多逻辑本身就很难理解,甚至意法的不同代程序员也不敢轻易改核心逻辑。所以对这类代码,我们抓大放小
  • 看源码,核心放在流程梳理寄存器设置上,然后结合参考手册一一对应。
  • 本节只看 I2C 相关代码,重点关注:I2C 初始化逻辑 + 读写 + 检测设备状态这三块。

9.1 初始化相关

9.1.1 MX_I2C1_Init ------ 参数从 CubeMX 到代码

CubeMX 生成的主函数里初始化 I2C 外设的代码如下(加注释):

c 复制代码
static void MX_I2C1_Init(void)
{
  // 选择 I2C 寄存器基地址:使用第 2 号 I2C 接口(工程里变量名是 hi2c1,实际是 I2C2)
  hi2c1.Instance = I2C2;

  // 设置 I2C 通讯速度:400000 Hz(400kHz),属于快速模式 (Fast Mode)
  hi2c1.Init.ClockSpeed = 400000;

  // 设置快速模式下的占空比:低电平与高电平时间比为 2:1
  hi2c1.Init.DutyCycle = I2C_DUTYCYCLE_2;

  // 设置设备自身的第一个 7 位地址(作为从机时才使用,我们是 Master,设为 0 即可)
  hi2c1.Init.OwnAddress1 = 0;

  // 设置地址模式:使用 7 位地址模式(最常用的模式,与 AT24C02 一致)
  hi2c1.Init.AddressingMode = I2C_ADDRESSINGMODE_7BIT;

  // 禁用双地址模式:不使用第二个从机地址
  hi2c1.Init.DualAddressMode = I2C_DUALADDRESS_DISABLE;

  // 设置设备自身的第二个地址(上面已禁用,此处设为 0 无效)
  hi2c1.Init.OwnAddress2 = 0;

  // 禁用广播呼叫模式:不响应地址为 0 的通用呼叫
  hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;

  // 禁用禁止时钟延长模式:允许从机通过拉低时钟线来暂停通信(标准行为)
  hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;

  // 调用 HAL 库函数执行初始化,把上面这些"软件配置"落到寄存器上
  if (HAL_I2C_Init(&hi2c1) != HAL_OK)
  {
    Error_Handler();
  }
}

可见,上述代码就是我们在 CubeMX 上配置的参数------CubeMX 把图形化的配置翻译成了结构体赋值语句

9.1.2 I2C_HandleTypeDef 与 I2C_TypeDef ------ 软件如何操作硬件寄存器

把视角放在这句代码:hi2c1.Instance = I2C2;

1. 先看 I2C2 是什么(寄存器基地址宏):

c 复制代码
#define I2C2                ((I2C_TypeDef *)I2C2_BASE)   // I2C2 被定义为一个指向寄存器结构体的指针
#define I2C2_BASE           (APB1PERIPH_BASE + 0x00005800UL)  // I2C2 挂在 APB1 总线,基地址偏移 0x5800
#define APB1PERIPH_BASE     PERIPH_BASE
#define PERIPH_BASE         0x40000000UL  // 外设基地址(内存映射的外设别名区起点)

结论:I2C2 其实就是地址 0x40005800 处的一个指针。从该地址开始,连续排列着 I2C 外设的所有寄存器。

2. 再看 I2C_TypeDef(I2C 内部 9 个寄存器)的结构体定义:

c 复制代码
typedef struct
{
  __IO uint32_t CR1;    // 控制寄存器 1(使能、起始、停止、应答、POS 等关键控制位)
  __IO uint32_t CR2;    // 控制寄存器 2(外设时钟频率 FREQ 等)
  __IO uint32_t OAR1;   // 自身地址寄存器 1
  __IO uint32_t OAR2;   // 自身地址寄存器 2
  __IO uint32_t DR;     // 数据寄存器(收发数据的"门口",写它就发,读它就收)
  __IO uint32_t SR1;    // 状态寄存器 1(SB、ADDR、TXE、BTF、RXNE 等标志位)
  __IO uint32_t SR2;    // 状态寄存器 2(BUSY、MSL、TRA 等标志位)
  __IO uint32_t CCR;    // 时钟控制寄存器(时钟频率/占空比配置)
  __IO uint32_t TRISE;  // 上升时间寄存器(满足电气特性的上升沿时间)
} I2C_TypeDef;          // 就是 I2C 内部的 9 个寄存器

3. 再看 I2C_HandleTypeDef(HAL 层的"句柄",在寄存器结构之上再加一层软件信息):

c 复制代码
typedef struct
{
  I2C_TypeDef                *Instance;       // 指向 I2C 寄存器结构体的指针(指向硬件)
  I2C_InitTypeDef            Init;            // 通信参数(速度、占空比、地址模式等)
  uint8_t                    *pBuffPtr;       // 指向数据收发缓冲区的指针
  uint16_t                   XferSize;        // 传输总大小(用于计数)
  __IO uint16_t              XferCount;       // 剩余传输计数
  __IO uint32_t              XferOptions;     // 传输选项
  __IO uint32_t              PreviousState;   // 上一次通信状态(内部使用)
  DMA_HandleTypeDef          *hdmatx;         // 发送 DMA 句柄
  DMA_HandleTypeDef          *hdmarx;         // 接收 DMA 句柄
  HAL_LockTypeDef            Lock;            // 软件互斥锁(防止并发访问)
  __IO HAL_I2C_StateTypeDef  State;           // I2C 通信状态
  __IO HAL_I2C_ModeTypeDef   Mode;            // I2C 通信模式
  __IO uint32_t              ErrorCode;       // 错误码
  __IO uint32_t              Devaddress;      // 目标设备地址
  __IO uint32_t              Memaddress;      // 目标内存地址
  __IO uint32_t              MemaddSize;      // 目标内存地址长度
  __IO uint32_t              EventCount;      // 事件计数
  ...
} I2C_HandleTypeDef;

结论:

  • hi2c1.Instance = I2C2:就是让 hi2c1.Instance 直接指向片上外设 I2C2,从而可以直接通过指针访问 I2C2 的寄存器。
  • hi2c2.Instance = I2C2 时,hi2c2.Instance 指向 0x40005800,从这里开始就是 I2C 连续的 9 个寄存器。因为每个寄存器都是 32 位(4 字节),且结构体成员顺序与寄存器物理地址顺序完全一致,所以自然形成了偏移量。
  • 这样,Cortex-M3 就可以像操作内存一样,用指针操作 I2C 的所有寄存器------这就是"软件操作硬件"的本质。
9.1.3 I2C 复用 GPIO ------ HAL_I2C_MspInit

I2C 复用了 GPIO 引脚,我们需要看和 I2C 强相关的 GPIO 设置。追踪 HAL_I2C_MspInit

c 复制代码
void HAL_I2C_MspInit(I2C_HandleTypeDef* hi2c)
{
  GPIO_InitTypeDef GPIO_InitStruct = {0};
  if(hi2c->Instance==I2C2)     // 只处理 I2C2
  {
    /* Enable GPIOB clock */
    __HAL_RCC_GPIOB_CLK_ENABLE();   // 先开 GPIOB 的时钟(引脚属于 B 组)

    /**I2C2 GPIO Configuration
    PB10     ------> I2C2_SCL
    PB11     ------> I2C2_SDA
    */
    GPIO_InitStruct.Pin = GPIO_PIN_10|GPIO_PIN_11;   // 引脚:PB10、PB11
    GPIO_InitStruct.Mode = GPIO_MODE_AF_OD;          // 复用开漏输出(I2C 标配)
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;    // 高速
    HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);          // 应用配置

    /* Peripheral clock enable */
    __HAL_RCC_I2C2_CLK_ENABLE();    // 开启 I2C2 外设时钟
  }
}

这里又和 CubeMX 默认生成的配置一致。

细节:什么是 MSP?

  • MSP = MCU Support Package(微控制器支持包)
  • MCU Support 指的是特定芯片硬件相关的支持配置。比如同样是 I2C 协议,在 STM32F1 和 STM32F4 上逻辑是一样的,但它们使用的引脚(GPIO)、时钟(RCC)和中断号(IRQ)可能完全不同。MSP 就是把这些"因芯片而异"的部分独立出来管理。
9.1.4 开启 I2C2 时钟 ------ __HAL_RCC_I2C2_CLK_ENABLE

HAL_I2C_MspInit 中使能 I2C 时钟,本质是让 I2C 接入时钟树,应对外部的配置操作。

c 复制代码
// 这里先 SET_BIT 再 READ_BIT,是为了保证"写后读"让设置真正生效(外设总线访问需要延时)
#define __HAL_RCC_I2C2_CLK_ENABLE()   do { \
                                        __IO uint32_t tmpreg; \
                                        SET_BIT(RCC->APB1ENR, RCC_APB1ENR_I2C2EN); \
                                        /* 使能后延时,等待时钟稳定 */ \
                                        tmpreg = READ_BIT(RCC->APB1ENR, RCC_APB1ENR_I2C2EN); \
                                        UNUSED(tmpreg); \
                                      } while(0U)

#define RCC_APB1ENR_I2C2EN_Pos               (22U)                 // 第 22 位
#define RCC_APB1ENR_I2C2EN_Msk               (0x1UL << RCC_APB1ENR_I2C2EN_Pos)  // 0x00400000
#define RCC_APB1ENR_I2C2EN                   RCC_APB1ENR_I2C2EN_Msk // I2C2 时钟使能位

其实就是设置 RCC 中的 APB1ENR 寄存器,因为 I2C2 挂在 APB1 总线上。打开参考手册搜 "RCC" 就能找到这个寄存器的第 22 位。

9.1.5 I2C 设备使能 ------ PE 位

在配置 I2C 寄存器参数前和之后,会看到这两个函数:

c 复制代码
__HAL_I2C_DISABLE(hi2c);   // 配置前先关闭设备(避免配置过程中工作状态混乱)
// ... 配置寄存器参数 ...
__HAL_I2C_ENABLE(hi2c);    // 配置后使能设备,开始正常工作

它们其实是宏:

c 复制代码
#define __HAL_I2C_ENABLE(__HANDLE__)  SET_BIT((__HANDLE__)->Instance->CR1, I2C_CR1_PE)   // 置位 PE
#define __HAL_I2C_DISABLE(__HANDLE__) CLEAR_BIT((__HANDLE__)->Instance->CR1, I2C_CR1_PE) // 清零 PE

#define SET_BIT(REG, BIT)     ((REG) |= (BIT))      // 置位:REG = REG | BIT
#define CLEAR_BIT(REG, BIT)   ((REG) &= ~(BIT))     // 清位:REG = REG & ~BIT

I2C_CR1_PE 定义在 CR1 寄存器的第 0 位:

c 复制代码
#define I2C_CR1_PE_Pos  (0U)
#define I2C_CR1_PE_Msk  (0x1UL << I2C_CR1_PE_Pos)   // 0x00000001
#define I2C_CR1_PE      I2C_CR1_PE_Msk              // Peripheral Enable(外设使能位)

所以 PE 位就是 I2C 接口设备"是否开始工作"的总开关。

✨ 开启时钟 vs 设备使能

  • 设备要正常工作,两个都必须开启
  • 开启时钟 → 设备被"接通电源",寄存器可以配置了;但设备本身没有使能,还不能正常工作。
  • 设备使能(置位 PE)→ 设备真正开始工作。
  • 打个比方(全自动洗衣机):开启时钟 = 接通电源配置 = 设定洗涤程序设备使能 = 按下启动键,之后洗衣机才开始正常工作。
  • 当然,不是所有片上外设都需要使能这一步,具体要看设备硬件的设定。
9.1.6 MODIFY_REG ------ "先清空、再设置"修改寄存器的关键宏

现在数据写在结构体变量(内存中),并且已经能找到 I2C 所有寄存器了,那么 HAL_I2C_Init 的后半部分就是把结构体变量中的数据按规则设置到对应寄存器里。这段代码的核心就是 MODIFY_REG 宏:

c 复制代码
#define MODIFY_REG(REG, CLEARMASK, SETMASK)  WRITE_REG((REG), (((READ_REG(REG)) & (~(CLEARMASK))) | (SETMASK)))

这个宏的核心作用 :在不改变寄存器其他位 的情况下,安全地修改特定几位的值。

宏的执行顺序(第①步到第⑤步):

  1. 第①步:从寄存器 REG 读取当前 32 位原始数值(把寄存器当右值)。
  2. 第②步:将 CLEARMASK(清除掩码)按位取反------"要保留的位置置 1,要清除的位置置 0"。
    例如 CLEARMASK = 0000 0011(要清低 2 位),则 ~CLEARMASK = 1111 1100
  3. 第③步:REG & (~CLEARMASK) ------ 把指定位置清零,同时保持其他位不变。
  4. 第④步:| SETMASK ------ 在清零的位置上,设置目标值。
  5. 第⑤步:把结果写回目标寄存器 REG。

一个通俗的 Demo(LED 模式寄存器):

假设有一个 8 位寄存器 REG,其中第 5:4 位(从右往左数,从 0 开始)表示 LED 模式:

  • 00 关闭、01 低速闪烁、10 高速闪烁、11 常亮。

假设当前 REG = 1011 0100,此时第 5:4 位是 11(常亮)。需求:在不改变其他位 的情况下,把模式改成 10(高速闪烁)。

准备两个掩码:

  • 清除掩码 CLEARMASK = 0011 0000(把第 5:4 位清空)
  • 设置掩码 SETMASK = 0010 0000(把第 5:4 位置成 10

运算过程:

复制代码
1011 0100         <- REG(当前值)
1100 1111 &       <- ~CLEARMASK(取反)
------------
1000 0100         <- 第 [5:4] 位已被清零,其他位不变

1000 0100
0010 0000 |       <- SETMASK
------------
1010 0100         <- 第 [5:4] 位变成 10,其他位依然不变

结果对比

  • 修改前:10**11**0100(模式位 = 11 常亮)
  • 修改后:10**10**0100(模式位 = 10 高速闪烁)
  • 其他位:开头两位 10 和末尾四位 0100 全程没有变化。

如果不执行"清空"会发生什么?

直接拿 1011 0100| SETMASK(0010 0000)

复制代码
1011 0100
0010 0000 |
------------
1011 0100   <- 结果没变(因为目标位本来就是 1,或运算不产生变化)

所以说,修改寄存器指定位置的正确姿势就是"先清空,再设置"

看一个真实例子(HAL_I2C_Init 中的 CR1 配置):

c 复制代码
MODIFY_REG(hi2c->Instance->CR1, (I2C_CR1_ENGC | I2C_CR1_NOSTRETCH), (hi2c->Init.GeneralCallMode | hi2c->Init.NoStretchMode));

#define I2C_CR1_ENGC_Pos    (6U)                                  // 第 6 位:广播呼叫使能
#define I2C_CR1_ENGC_Msk    (0x1UL << I2C_CR1_ENGC_Pos)           // 0x00000040
#define I2C_CR1_ENGC        I2C_CR1_ENGC_Msk

#define I2C_CR1_NOSTRETCH_Pos   (7U)                              // 第 7 位:时钟拉伸禁止
#define I2C_CR1_NOSTRETCH_Msk   (0x1UL << I2C_CR1_NOSTRETCH_Pos)  // 0x00000080
#define I2C_CR1_NOSTRETCH       I2C_CR1_NOSTRETCH_Msk

参考之前的配置:

c 复制代码
hi2c1.Init.GeneralCallMode = I2C_GENERALCALL_DISABLE;   // 关闭广播 -> 0
hi2c1.Init.NoStretchMode = I2C_NOSTRETCH_DISABLE;       // 不禁止时钟拉伸 -> 0

#define I2C_GENERALCALL_DISABLE   0x00000000U
#define I2C_NOSTRETCH_DISABLE     0x00000000U

所以 MODIFY_REG(hi2c->Instance->CR1, (ENGC | NOSTRETCH), (0 | 0)),本质就是把 CR1 的第 6、7 位清 0(用 CLEARMASK 指明要清的位置,SETMASK 传 0)。

如果将来要开启,只要传:

c 复制代码
#define I2C_GENERALCALL_ENABLE    I2C_CR1_ENGC
#define I2C_NOSTRETCH_ENABLE      I2C_CR1_NOSTRETCH

此时 CLEARMASK == SETMASK == (ENGC | NOSTRETCH) == (1<<6 | 1<<7),先清空再设置,优雅地完成修改。

结论:

  • I2C 或者其他任何片上外设,各个标志位在对应寄存器中的位置彼此独立、不会重复
  • 这意味着可以穷举所有寄存器的所有位的位置 ,充当 CLEARMASK 和 SETMASK(宏清单在 stm32f103xe.h 中)。
  • 使用 MODIFY_REG,就可以把我们之前在结构体中设置的选项由 HAL 库设置到寄存器中,从而从"软件"过渡到"硬件"

9.2 设备就绪相关

下面研究设备就绪检测。驱动里的封装:

c 复制代码
bool wait_write_complete(void)
{
    if(!at24c02.is_init) return false;
    return HAL_I2C_IsDeviceReady(at24c02.i2c, at24c02.dev_address, 100, 10) == HAL_OK ? true : false;
}

HAL_I2C_IsDeviceReady 的意思是:如果设备没有就绪,该函数会自动按 Trials(尝试次数)重试,只要有一次成功就返回 HAL_OK。

9.2.1 HAL_I2C_IsDeviceReady 源码宏观循环
c 复制代码
HAL_StatusTypeDef HAL_I2C_IsDeviceReady(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint32_t Trials, uint32_t Timeout)
{
  uint32_t I2C_Trials = 0U;
  if (hi2c->State == HAL_I2C_STATE_READY)   // 条件1:I2C 设备本身空闲(没人占用)
  {
    do
    {
      /* Generate Start */
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);   // 条件3 步骤1:产生起始条件

      /* Send slave address */
      hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);   // 条件3 步骤2:发送从机地址(写)

      /* Check if the ADDR flag has been set */
      if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR) == SET)   // 收到 ACK,地址被确认
      {
        return HAL_OK;   // 设备就绪,返回成功
      }
      else
      {
        /* Wait until BUSY flag is reset */
        if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
        {
          return HAL_ERROR;
        }
      }

      /* Increment Trials */
      I2C_Trials++;          // 尝试次数 +1
    }
    while (I2C_Trials < Trials);   // 循环直到达到最大尝试次数

    hi2c->State = HAL_I2C_STATE_READY;
    __HAL_UNLOCK(hi2c);
    return HAL_ERROR;   // 所有尝试都失败
  }
  else
  {
    return HAL_BUSY;   // 条件1 不满足:I2C 设备正被占用
  }
}

整体上,HAL_I2C_IsDeviceReady通过 Trials 尝试多次检测设备是否就绪,只要有一次就绪就返回 HAL_OK,表示设备正常工作。

I2C 设备就绪,背后其实是"三种就绪"必须同时满足:

  1. I2C 设备本身没人访问且正常工作hi2c->State == HAL_I2C_STATE_READY)。
    • 因为存在中断:中断处理函数也可能访问同一个 I2C 设备,导致重复访问。为避免冲突,HAL 在句柄中定义 State 变量表示状态。谁要访问 I2C,必须先检测 State 并设置它------先来的先设置,后来的被拦截。
  2. I2C 总线本身是就绪的,没有处于 busy 状态
    • 因为存在"发送时对方来不及接收,接收方通过线与机制拉低 SCL,不让发送方继续发"的情况,此时总线处于 busy 状态。
    • 所以要发数据,总线必须不是 busy 的。
  3. 目标设备在线并且地址匹配
    • 当 Master 发送信息时,必须保证 Slave 在线且能正常应答 ACK。

用生活类比(和女朋友看电影):

  1. 女朋友愿意去(她要是约好了闺蜜去图书馆,就处于"忙"状态)。
  2. 从出发地到电影院道路畅通(堵车了就去不了)。
  3. 电影院正常营业且你导航的地址正确(影院关门或导航错了也看不了)。

三个条件同时满足,电影才能看成。检测设备是否 ready,就围绕这三点展开。

hi2c->State 的常见取值(HAL_I2C_StateTypeDef 枚举):

c 复制代码
typedef enum
{
  HAL_I2C_STATE_RESET             = 0x00U,  // 外设尚未初始化
  HAL_I2C_STATE_READY             = 0x20U,  // 外设已初始化,可正常使用
  HAL_I2C_STATE_BUSY              = 0x24U,  // 有内部流程正在进行
  HAL_I2C_STATE_BUSY_TX           = 0x21U,  // 正在发送数据
  HAL_I2C_STATE_BUSY_RX           = 0x22U,  // 正在接收数据
  HAL_I2C_STATE_LISTEN            = 0x28U,  // 从机监听模式
  HAL_I2C_STATE_BUSY_TX_LISTEN    = 0x29U,  // 从机监听 + 发送
  HAL_I2C_STATE_BUSY_RX_LISTEN    = 0x2AU,  // 从机监听 + 接收
  HAL_I2C_STATE_ABORT             = 0x60U,  // 用户请求中止中
  HAL_I2C_STATE_TIMEOUT           = 0xA0U,  // 超时状态
  HAL_I2C_STATE_ERROR             = 0xE0U   // 错误状态
} HAL_I2C_StateTypeDef;

这些状态值根本不用记,源码直接能翻。

9.2.2 检测 I2C 设备本身是否被占用

如果 hi2c->State != HAL_I2C_STATE_READY,直接 return HAL_BUSY,表示 I2C 设备正在被使用------这是"条件 1"没有被满足。

9.2.3 检测 I2C 总线是否就绪 ------ I2C_WaitOnFlagUntilTimeout
c 复制代码
/* Wait until BUSY flag is reset */
if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
{
    return HAL_BUSY;
}

I2C 相关标志位定义(高 16 位选择寄存器,低 16 位定位寄存器内具体某位):

c 复制代码
#define I2C_FLAG_OVR     0x00010800U   // SR1:溢出
#define I2C_FLAG_AF      0x00010400U   // SR1:应答失败(Acknowledge Failure)
#define I2C_FLAG_ARLO    0x00010200U   // SR1:仲裁丢失
#define I2C_FLAG_BERR    0x00010100U   // SR1:总线错误
#define I2C_FLAG_TXE     0x00010080U   // SR1:数据寄存器空(可写入)
#define I2C_FLAG_RXNE    0x00010040U   // SR1:数据寄存器非空(已收到数据)
#define I2C_FLAG_STOPF   0x00010010U   // SR1:停止条件检测到
#define I2C_FLAG_ADD10   0x00010008U   // SR1:10 位地址模式下的地址已发送
#define I2C_FLAG_BTF     0x00010004U   // SR1:字节发送结束(DR 和移位寄存器都空/都有数据)
#define I2C_FLAG_ADDR    0x00010002U   // SR1:地址已被确认(收到 ACK)
#define I2C_FLAG_SB      0x00010001U   // SR1:起始条件已发送
#define I2C_FLAG_DUALF   0x00100080U   // SR2:双地址标志
#define I2C_FLAG_GENCALL 0x00100010U   // SR2:广播呼叫标志
#define I2C_FLAG_TRA     0x00100004U   // SR2:发送/接收方向
#define I2C_FLAG_BUSY    0x00100002U   // SR2:总线忙
#define I2C_FLAG_MSL     0x00100001U   // SR2:主机/从机模式

其中 I2C_TIMEOUT_BUSY_FLAG 定义为 25ms

I2C_WaitOnFlagUntilTimeout 的核心作用是:

不断检查 I2C_FLAG_BUSY 标志位是否在寄存器内被设置。被设置 → 总线忙;否则返回 HAL_OK,说明总线正常可访问。

c 复制代码
static HAL_StatusTypeDef I2C_WaitOnFlagUntilTimeout(I2C_HandleTypeDef *hi2c, uint32_t Flag,
    FlagStatus Status, uint32_t Timeout, uint32_t Tickstart)
{
  /* Wait until flag is set */
  while (__HAL_I2C_GET_FLAG(hi2c, Flag) == Status)   // 只要标志位还处于"不满足"状态就一直等
  {
    /* Check for the Timeout */
    if (Timeout != HAL_MAX_DELAY)
    {
      if (((HAL_GetTick() - Tickstart) > Timeout) || (Timeout == 0U))   // 超时判断
      {
        if ((__HAL_I2C_GET_FLAG(hi2c, Flag) == Status))
        {
          hi2c->PreviousState = I2C_STATE_NONE;
          hi2c->State         = HAL_I2C_STATE_READY;
          hi2c->Mode          = HAL_I2C_MODE_NONE;
          hi2c->ErrorCode     |= HAL_I2C_ERROR_TIMEOUT;   // 记录超时错误
          __HAL_UNLOCK(hi2c);
          return HAL_ERROR;   // 超时,返回失败
        }
      }
    }
  }
  return HAL_OK;
}

细节 :单片机开发中,阻塞式接口基本都会带超时时间,主要是为了防止"卡死"情况(硬件异常导致标志位永远不满足,如果没有超时,程序会永远死等下去)。

重点看 __HAL_I2C_GET_FLAG 宏 ------ "地址 + 偏移"封装思想:

c 复制代码
#define __HAL_I2C_GET_FLAG(__HANDLE__, __FLAG__) ((((uint8_t)((__FLAG__) >> 16U)) == 0x01U) ? \
                (((((__HANDLE__)->Instance->SR1) & ((__FLAG__) & I2C_FLAG_MASK)) == ((__FLAG__) & I2C_FLAG_MASK)) ? SET : RESET) : \
                (((((__HANDLE__)->Instance->SR2) & ((__FLAG__) & I2C_FLAG_MASK)) == ((__FLAG__) & I2C_FLAG_MASK)) ? SET : RESET))

#define I2C_FLAG_MASK  0x0000FFFFU   // 只保留低 16 位

这个宏的设计本质 :每个标志位宏是一个 32 位"地址 + 偏移"封装

  • 高 16 位代表"哪一个寄存器"(0x0001 → SR1,0x0010 → SR2)。
  • 低 16 位代表"在指定寄存器中的哪一位"。

I2C_FLAG_BUSY = 0x00100002U 为例,代入宏:

  1. (I2C_FLAG_BUSY >> 16U) = 0x0010(uint8_t) 强转后 = 0x10,不等于 0x01 ,所以三目运算符选中 SR2 寄存器分支。
  2. I2C_FLAG_BUSY & 0x0000FFFFU = 0x0002,即低 16 位是位 1(第 2 个比特位)。
  3. 展开后就是 ((SR2 & 0x2) == 0x2) ? SET : RESET------检测 SR2 寄存器的第 1 位是否为 1
  4. 翻参考手册:SR2 的第 1 位就是 BUSY(总线忙) 标志。

编码细节:为什么"自己和自己比较"(全比较)?

形如 (SR2 & 0x2) == 0x2,看起来像是自己和自己比。这样做是为了实现 "全比较":寄存器标志位可能是几位连起来的,需要同时确认这几位的值。

用取款类比(掩码 0x03 = 需要同时满足"输入密码 0x01"和"插入银行卡 0x02"两个条件):

  1. "不比自己" if (动作 & 规定) :你只输了密码(0x01),0x01 & 0x03 = 0x01(真)→ 判定通过,钱吐出来了!------这银行要倒闭了,没插卡也能取钱。
  2. "自己比自己" if ((动作 & 规定) == 规定) :你只输了密码,0x01 & 0x03 = 0x01,不等于规定的 0x03 → 判定失败。必须又输密码又插卡,结果才等于 0x03。

所以用"与掩码后再与掩码比较"的方式,能一次性确认多位标志全部满足,且新增规则时无需改代码逻辑。

9.2.4 目标设备在线并且地址匹配 ------ 完整 6 步详解

目前已经判断:I2C 设备本身没被占用(READY),总线也不是 busy,可访问。下一步确认"目标设备在线且地址匹配":构建一个 I2C 请求,只发送对方的地址,如果收到 ACK 就说明对方在线。

本质:在 I2C SDA 线上发送一个"地址探测数据",携带地址让对方应答。

第 1 步:Master 向 Slave 发送开始标志

c 复制代码
SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);

#define I2C_CR1_START_Pos   (8U)
#define I2C_CR1_START_Msk   (0x1UL << I2C_CR1_START_Pos)   // 0x00000100
#define I2C_CR1_START       I2C_CR1_START_Msk              // Start Generation(起始产生位)

把 I2C 模块当成一个黑盒产品,只要设置其控制寄存器 CR1 的 START 标志位为 1,I2C 硬件就会自动在 SCL 高电平期间,让 SDA 产生一个从高到低的跳变,用它表示"开始"。

第 2 步:Master 检测起始条件发送完成

c 复制代码
/* Wait until SB flag is set */
if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, tickstart) != HAL_OK)
{
   if (READ_BIT(hi2c->Instance->CR1, I2C_CR1_START) == I2C_CR1_START)
   {
       hi2c->ErrorCode = HAL_I2C_WRONG_START;
   }
   return HAL_TIMEOUT;
}

#define I2C_FLAG_SB  0x00010001U   // 高 16 位 = 0x0001 -> SR1;低 16 位 = 0001 -> 第 0 位

0x00010001U 高 16 位是 0x0001,说明该标志在 SR1 内部;低 16 位是 0001,说明检测的是 SR1 最低位(SB:起始条件已发送)。

第 3 步:Master 发送 slave 地址

c 复制代码
/* Send slave address */
hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);

只要把数据(包括地址数据)写入 DR 寄存器 ,I2C 接口就会自动发送。I2C 发送数据的最小单位是字节

c 复制代码
#define I2C_7BIT_ADD_WRITE(__ADDRESS__)  ((uint8_t)((__ADDRESS__) & (uint8_t)(~I2C_OAR1_ADD0)))

#define I2C_OAR1_ADD0_Pos   (0U)
#define I2C_OAR1_ADD0_Msk   (0x1UL << I2C_OAR1_ADD0_Pos)   // 0x00000001
#define I2C_OAR1_ADD0       I2C_OAR1_ADD0_Msk              // Bit 0(最低位)

I2C_OAR1_ADD0 就是 0000 0001,取反后 1111 1110,与地址相与 → 把 DevAddress 的最低位置 0

所以调用 HAL_I2C_IsDeviceReady 等函数时,设备地址要 <<1 :7 位地址左移一位后,最低位 LSB = 0,表示写操作I2C_7BIT_ADD_WRITE 再把最低位清零确认是写。
Tips :不同平台、不同工具生成的代码是否需要左移,确实和工具生成代码的逻辑有关。养成阅读源码的习惯,就能以不变应万变。

第 4 步:检测 ACK,确保地址发送可靠性

要确保地址发送结束,必须是"地址发送结束"且"没有出错":

c 复制代码
tmp1 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR); // 地址发送是否结束(收到 ACK)
tmp2 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_AF);   // 发送过程中有没有应答失败(NACK)

// 没有超时 && 发送没结束 && 没应答失败 -> 继续循环检测
while ((hi2c->State != HAL_I2C_STATE_TIMEOUT) && (tmp1 == RESET) && (tmp2 == RESET))
{
    if (((HAL_GetTick() - tickstart) > Timeout) || (Timeout == 0U))
    {
       hi2c->State = HAL_I2C_STATE_TIMEOUT;   // 超时则标记超时状态
    }
    tmp1 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR);
    tmp2 = __HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_AF);
}

#define I2C_FLAG_ADDR  0x00010002U   // SR1 第 1 位:地址已被确认
#define I2C_FLAG_AF    0x00010400U   // SR1 第 11 位:应答失败

两个标志位高 16 位都是 0001,选择 SR1 寄存器;一个在第 1 位(0x02 → 0000 0000 0000 0010),一个在第 11 位(0x0400 → 0000 0100 0000 0000)。

结束条件有三种情况:

  • 异常情况 1:超时。
  • 异常情况 2:应答失败(AF)。
  • 正常情况 :地址发送结束、发送成功。根据参考手册,Master 收到 ACK 之后,ADDR 标志位会被置 1

细节:ADDR 标志位只是"地址发送之后,对收到的 ACK 进行标识"。

第 5 步:判断返回情况

c 复制代码
/* Check if the ADDR flag has been set */
if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR) == SET)
{
    ...  // 地址被确认,设备在线
    return HAL_OK;   // 重点关注
}
else
{
    ...  // 否则走失败流程
}

因为正常情况只有一种,所以只需要看 ADDR 标志位是否被设置

第 6 步:发送停止位

确定设备在线且地址正确、应答收到后,完成 I2C 通信过程:

c 复制代码
if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_ADDR) == SET)
{
    /* Generate Stop */
    SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 置位 STOP 位,自动发送停止条件

    /* Clear ADDR Flag */
    __HAL_I2C_CLEAR_ADDRFLAG(hi2c);               // 清空 ADDR 标志位(细节可不深究)

    /* Wait until BUSY flag is reset */
    if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
    {
        return HAL_ERROR;
    }

    hi2c->State = HAL_I2C_STATE_READY;   // 恢复状态为 READY
    __HAL_UNLOCK(hi2c);                  // 解锁(释放占用)
    return HAL_OK;                       // 成功返回,设备 ready!
}

#define I2C_CR1_STOP_Pos   (9U)
#define I2C_CR1_STOP_Msk   (0x1UL << I2C_CR1_STOP_Pos)   // 0x00000200
#define I2C_CR1_STOP       I2C_CR1_STOP_Msk              // Stop Generation(停止产生位)

只要向 CR1 写入 STOP 标志位,Master I2C 就会自动在 SCL 高电平期间,让 SDA 产生一个从低到高的跳变,用它表示"停止"。

细节STOP 标志位没有"完成标志位",也不需要 Master 确认 Slave 收到------停止信号是 Master 单方面宣告总线释放。

至此,I2C 中"检测设备是否 ready"的逻辑就完全清楚了。

9.3 写相关:HAL_I2C_Mem_Write

有了对设备就绪的深刻认识,看读写就容易多了。发送类函数有很多但都大同小异,我们读之前用的 HAL_I2C_Mem_Write

9.3.1 HAL_I2C_Mem_Write 源码清单(加详细注释)
c 复制代码
HAL_StatusTypeDef HAL_I2C_Mem_Write(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
                                    uint16_t MemAddress, uint16_t MemAddSize,
                                    uint8_t *pData, uint16_t Size, uint32_t Timeout)
{
  uint32_t tickstart = HAL_GetTick();   // 记录起始时间,用于超时管理

  assert_param(IS_I2C_MEMADD_SIZE(MemAddSize));   // 参数检查:内存地址长度必须合法

  if (hi2c->State == HAL_I2C_STATE_READY)   // 条件1:I2C 设备空闲(没人占用)
  {
    /* Wait until BUSY flag is reset */
    // 条件2:等待 SCL/SDA 总线空闲(不忙)
    if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BUSY, SET, I2C_TIMEOUT_BUSY_FLAG, tickstart) != HAL_OK)
    {
      return HAL_BUSY;   // 超时仍不空闲,返回忙
    }

    __HAL_LOCK(hi2c);   // 加锁:标记本函数已占用 I2C,防止并发访问

    /* Check if the I2C is already enabled */
    if ((hi2c->Instance->CR1 & I2C_CR1_PE) != I2C_CR1_PE)   // 代码鲁棒性:确保设备已使能
    {
      __HAL_I2C_ENABLE(hi2c);   // 没使能就补上
    }

    CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_POS);   // 关闭 POS(写操作不关心 POS)
    hi2c->State     = HAL_I2C_STATE_BUSY_TX;       // 状态改为"正在发送"
    hi2c->Mode      = HAL_I2C_MODE_MEM;            // 模式改为"内存访问模式"(第一个数据带地址)
    hi2c->ErrorCode = HAL_I2C_ERROR_NONE;          // 清空错误码

    /* Prepare transfer parameters */
    hi2c->pBuffPtr  = pData;        // 保存待发送数据缓冲区起始地址
    hi2c->XferCount = Size;         // 保存待发送数据长度
    hi2c->XferSize  = hi2c->XferCount;   // 剩余计数,初始等于总长度
    hi2c->XferOptions = I2C_NO_OPTION_FRAME;   // 传输选项(历史遗留字段)

    /* Send Slave Address and Memory Address */
    // 核心:开启通信 -> 发起始条件 -> 发设备地址 -> 发内存地址(字地址)
    // 详见 I2C_RequestMemoryWrite 源码
    if (I2C_RequestMemoryWrite(hi2c, DevAddress, MemAddress, MemAddSize, Timeout, tickstart) != HAL_OK)
    {
      return HAL_ERROR;
    }

    // 到这里:起始条件、设备地址、内存地址都已发送完成,开始发真实数据
    while (hi2c->XferSize > 0U)   // 还有数据没发完
    {
      /* Wait until TXE flag is set */
      // 等待 TXE 标志:数据寄存器 DR 为空,可以写入下一字节
      if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
      {
        if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)   // 若是对应失败
        {
          SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 发停止位
        }
        return HAL_ERROR;
      }

      /* Write data to DR */
      hi2c->Instance->DR = *hi2c->pBuffPtr;   // 向 DR 写入一个字节
      hi2c->pBuffPtr++;                       // 缓冲区指针后移
      hi2c->XferSize--;                       // 更新计数
      hi2c->XferCount--;

      // 双寄存器缓冲优化:若 BTF 置位(DR 和移位寄存器都空了)
      // 且还有数据,就立即把下一个字节补进 DR,提高发送效率
      if ((__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == SET) && (hi2c->XferSize != 0U))
      {
        hi2c->Instance->DR = *hi2c->pBuffPtr;   // 立即补发下一字节
        hi2c->pBuffPtr++;
        hi2c->XferSize--;
        hi2c->XferCount--;
      }
    }

    /* Wait until BTF flag is set */
    // 跳出 while 说明"发给寄存器"的数据全部写完,但数据并不一定已经被对方接收!
    // CPU 写寄存器与 I2C 硬件实际发送是异步的。
    // 这里等 BTF:DR 和移位寄存器都空,且所有数据都被对方收到
    if (I2C_WaitOnBTFFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
    {
      if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
      {
        SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
      }
      return HAL_ERROR;
    }

    /* Generate Stop */
    SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 发送停止条件

    hi2c->State = HAL_I2C_STATE_READY;   // 恢复空闲状态
    hi2c->Mode  = HAL_I2C_MODE_NONE;     // 清空模式

    __HAL_UNLOCK(hi2c);   // 解锁
    return HAL_OK;        // 返回发送完成
  }
  else
  {
    return HAL_BUSY;   // 设备被占用,返回忙
  }
}
9.3.2 细节 1:关于发送的工作模式

在 STM32F103ZET6 中,没有直接的 Master 和 Slave 配置项 。Master 和 Slave 的差别主要由调用的函数类别 决定------可以简单理解为:谁发送 Start 标志位,谁就是 Master

c 复制代码
typedef enum
{
  HAL_I2C_MODE_NONE    = 0x00U,   // 外设空闲,没有任何通信进行
  HAL_I2C_MODE_MASTER  = 0x10U,   // 普通主机模式(字节流):执行 Master_Transmit/Receive,
                                  // 逻辑 = [设备地址] + [原始数据流],不关心数据含义
  HAL_I2C_MODE_SLAVE   = 0x20U,   // 从机模式(被动响应):等待主机呼叫自己的地址,
                                  // 硬件不主动产生 START/STOP
  HAL_I2C_MODE_MEM     = 0x40U    // 内存/寄存器模式:执行 Mem_Read/Write,
                                  // 逻辑 = [设备地址] + [寄存器/内存地址] + [数据内容],
                                  // 是对 MASTER 模式的"特化",内部自动处理"先写内存偏移地址,再读写数据"的复合时序
} HAL_I2C_StateTypeDef;
9.3.3 核心函数:I2C_RequestMemoryWrite
c 复制代码
static HAL_StatusTypeDef I2C_RequestMemoryWrite(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
                                                uint16_t MemAddress, uint16_t MemAddSize,
                                                uint32_t Timeout, uint32_t Tickstart)
{
  /* Generate Start */
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);   // 1. 发送起始条件

  /* Wait until SB flag is set */
  // 2. 等待起始条件发送完成
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK)
  {
    if (READ_BIT(hi2c->Instance->CR1, I2C_CR1_START) == I2C_CR1_START)
    {
      hi2c->ErrorCode = HAL_I2C_WRONG_START;
    }
    return HAL_TIMEOUT;
  }

  /* Send slave address */
  // 3. 发送 7 位设备地址 + 写位(0)------ 这就是地址要 <<1 的原因
  hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);

  /* Wait until ADDR flag is set */
  // 4. 等待地址发送完毕、收到 ACK
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
  {
    return HAL_ERROR;
  }

  /* Clear ADDR flag */
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);   // 5. 清 ADDR 标志(细节不深究)

  /* Wait until TXE flag is set */
  // 6. 等待数据寄存器空(准备发内存地址)
  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
  {
    if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
    }
    return HAL_ERROR;
  }

  /* If Memory address size is 8Bit */
  // 7. 根据字地址宽度决定发送方式:8 位就发一次
  if (MemAddSize == I2C_MEMADD_SIZE_8BIT)
  {
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);   // 发送字地址低字节
  }
  /* If Memory address size is 16Bit */
  else
  {
    // 16 位地址发两次:先高字节再低字节
    hi2c->Instance->DR = I2C_MEM_ADD_MSB(MemAddress);   // 高字节

    if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
    {
      if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
      {
        SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
      }
      return HAL_ERROR;
    }

    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);   // 低字节
  }

  // 返回成功。这里没有检测 DR 是否空,因为"是否真的发送完成"在后续主函数代码中检测
  return HAL_OK;
}

关于 MemAddSize 判断 :调用时必须告诉 HAL 目标设备的字地址宽度。I2C_MEMADD_SIZE_8BIT = 0x00000001UI2C_MEMADD_SIZE_16BIT = 0x00000010U。AT24C02 只有 256 字节,用 8 位地址即可。

9.3.4 细节 2:TXE 和 BTF 标志位

I2C_WaitOnTXEFlagUntilTimeout 与 TXE 标志位:

c 复制代码
static HAL_StatusTypeDef I2C_WaitOnTXEFlagUntilTimeout(I2C_HandleTypeDef *hi2c, uint32_t Timeout, uint32_t Tickstart)
{
  while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_TXE) == RESET)   // 核心:等待 TXE 标志置位
  {
    /* Check if a NACK is detected */
    // 检测应答失败(对方没应答 -> 高电平 -> NACK)
    if (I2C_IsAcknowledgeFailed(hi2c) != HAL_OK)
    {
      return HAL_ERROR;
    }

    /* Check for the Timeout */
    if (Timeout != HAL_MAX_DELAY)
    {
      if (((HAL_GetTick() - Tickstart) > Timeout) || (Timeout == 0U))
      {
        if ((__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_TXE) == RESET))
        {
          hi2c->PreviousState = I2C_STATE_NONE;
          hi2c->State         = HAL_I2C_STATE_READY;
          hi2c->Mode          = HAL_I2C_MODE_NONE;
          hi2c->ErrorCode     |= HAL_I2C_ERROR_TIMEOUT;
          __HAL_UNLOCK(hi2c);
          return HAL_ERROR;
        }
      }
    }
  }
  return HAL_OK;
}

#define I2C_FLAG_TXE  0x00010080U   // SR1 第 8 位:数据寄存器空

结论:

  • TXE 标志位表明数据寄存器 DR 是否为空:1 = 空(可以写下一字节),0 = 非空。
  • I2C_WaitOnTXEFlagUntilTimeout 等待的意义:DR 里的数据被硬件移到了移位寄存器(逐位发到 SDA),所以 DR 空了。
  • ⚠️ 注意:DR 空 ≠ 数据一定发出去了!只是说 DR 的数据被挪到了移位寄存器,尚未完成逐位发送。真正"发完"要看 BTF。

I2C_WaitOnBTFFlagUntilTimeout 与 BTF 标志位:

c 复制代码
static HAL_StatusTypeDef I2C_WaitOnBTFFlagUntilTimeout(I2C_HandleTypeDef *hi2c, uint32_t Timeout, uint32_t Tickstart)
{
  while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == RESET)   // 核心:等待 BTF 标志置位
  {
    // 发送数据收到的 ACK 是软件处理的(NACK 检测)
    if (I2C_IsAcknowledgeFailed(hi2c) != HAL_OK)
    {
      return HAL_ERROR;
    }

    if (Timeout != HAL_MAX_DELAY)
    {
      if (((HAL_GetTick() - Tickstart) > Timeout) || (Timeout == 0U))
      {
        if ((__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == RESET))
        {
          hi2c->PreviousState = I2C_STATE_NONE;
          hi2c->State         = HAL_I2C_STATE_READY;
          hi2c->Mode          = HAL_I2C_MODE_NONE;
          hi2c->ErrorCode     |= HAL_I2C_ERROR_TIMEOUT;
          __HAL_UNLOCK(hi2c);
          return HAL_ERROR;
        }
      }
    }
  }
  return HAL_OK;
}

#define I2C_FLAG_BTF  0x00010004U   // SR1 第 3 位:字节发送结束

结论:

  • 发送方向 :BTF = 1 表示数据寄存器 DR 和移位寄存器都为空(最后一个字节也发完了)。
  • 站在发送角度:BTF 就是"这个字节是否真的被发出去了"的标志。

发送时的两阶段理解:

  • TXE = 1:DR 空,可以把数据从 DR 挪到移位寄存器(开始"上路")。
  • BTF = 1:DR 和移位寄存器都空了,所有字节都真正发送完毕。

9.4 读相关:HAL_I2C_Mem_Read

读比写麻烦,核心是最后一个字节的处理 。之前学 I2C 理论时问过:为什么主机接收最后一个字节时回复 NACK 而不是 ACK?------这正是源码要解决的问题。

读取要完成的时序(先写地址再重新读取):

  1. START → 2. 从机地址+写 → 3. ACK → 4. 内存地址 → 5. ACK → 6. Repeated Start → 7. 从机地址+读 → 8. ACK → 9~10. 收数据/回 ACK ... → 收最后一个字节回 NACK → STOP

抛出结论:

  • 如果对 I2C 不做任何提前配置,那么收到最后一个字节之后,Master 会主动发送 ACK ------这不是我们想要的。我们要的是:收到最后一个字节后回复 NACK,然后发送 STOP
  • 要达到这个目的,需要 HAL 库帮我们做好配置------这个工作是由软件完成的
  • 为了让 Master 正确产生 NACK 和 STOP,软件必须在收到倒数第 2 个字节时立即进行提前配置

为什么源码要分 4 种情况?

这段代码被猜测是意法工程师"debug 出来"的,为了清楚地设置读取逻辑,把读取分成了读取 1 字节、读取 2 字节、读取 3 字节、读取 N 字节四种情况分别处理。意法的客户在接收代码上踩了不少坑,反馈后他们花了很长时间 debug,到现在这段代码都不敢轻易修改。

9.4.1 源码宏观轮廓
c 复制代码
HAL_StatusTypeDef HAL_I2C_Mem_Read(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
                                   uint16_t MemAddress, uint16_t MemAddSize,
                                   uint8_t *pData, uint16_t Size, uint32_t Timeout)
{
  ...
  if (hi2c->State == HAL_I2C_STATE_READY)
  {
    ...
    /* Send Slave Address and Memory Address */
    // 步骤1:产生"写地址"时序
    //(发送开始、设备地址+写位、字地址、重新发送开始、设备地址+读位)
    if (I2C_RequestMemoryRead(hi2c, DevAddress, MemAddress, MemAddSize, Timeout, tickstart) != HAL_OK)
    {
      return HAL_ERROR;
    }

    // 步骤2:根据接收字节数,提前配置 ACK/POS/STOP(决定什么时候回 NACK)
    if (hi2c->XferSize == 0U)   { ... }          // 0 字节:直接结束
    else if (hi2c->XferSize == 1U) { ... }       // 1 字节:关 ACK、提前设 STOP
    else if (hi2c->XferSize == 2U) { ... }       // 2 字节:开 POS、关 ACK
    else { ... }                                 // >=3 字节:开 ACK,正常收

    // 步骤3:开始接收
    while (hi2c->XferSize > 0U)
    {
      if (hi2c->XferSize <= 3U)   // 剩 3 字节以内,分 1/2/3 三种情况单独处理
      {
        if (hi2c->XferSize == 1U)   { ... }   // 收最后 1 字节
        else if (hi2c->XferSize == 2U) { ... } // 收最后 2 字节
        else { ... }                           // 收最后 3 字节
      }
      else   // 剩 >3 字节,普通接收 + 双缓冲优化
      {
        ...
      }
    }
    ...
    return HAL_OK;
  }
  else
  {
    return HAL_BUSY;
  }
}
9.4.2 步骤 1:产生"写地址"时序 ------ I2C_RequestMemoryRead
c 复制代码
static HAL_StatusTypeDef I2C_RequestMemoryRead(I2C_HandleTypeDef *hi2c, uint16_t DevAddress,
                                               uint16_t MemAddress, uint16_t MemAddSize,
                                               uint32_t Timeout, uint32_t Tickstart)
{
  /* Enable Acknowledge */
  // 0. 提前开启 ACK 应答功能:因为接下来要接收数据,收到数据要回 ACK
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);

  /* Generate Start */
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);   // 1. 发送起始条件

  /* Wait until SB flag is set */
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK)
  {
    if (READ_BIT(hi2c->Instance->CR1, I2C_CR1_START) == I2C_CR1_START)
    {
      hi2c->ErrorCode = HAL_I2C_WRONG_START;
    }
    return HAL_TIMEOUT;
  }

  /* Send slave address */
  // 2. 发送设备地址 + 写位(0):因为要先"写"字地址进去
  hi2c->Instance->DR = I2C_7BIT_ADD_WRITE(DevAddress);

  /* Wait until ADDR flag is set */
  // 3. 等待地址发送完成
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
  {
    return HAL_ERROR;
  }

  /* Clear ADDR flag */
  __HAL_I2C_CLEAR_ADDRFLAG(hi2c);

  /* Wait until TXE flag is set */
  // 4. 等待数据寄存器空
  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
  {
    if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
    }
    return HAL_ERROR;
  }

  /* If Memory address size is 8Bit */
  if (MemAddSize == I2C_MEMADD_SIZE_8BIT)
  {
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);   // 5a. 发送字地址(8 位只发一次)
  }
  else
  {
    // 16 位地址:先高字节再低字节
    hi2c->Instance->DR = I2C_MEM_ADD_MSB(MemAddress);
    if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
    {
      if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
      {
        SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
      }
      return HAL_ERROR;
    }
    hi2c->Instance->DR = I2C_MEM_ADD_LSB(MemAddress);
  }

  /* Wait until TXE flag is set */
  // 6. 等待字地址发送完成
  if (I2C_WaitOnTXEFlagUntilTimeout(hi2c, Timeout, Tickstart) != HAL_OK)
  {
    if (hi2c->ErrorCode == HAL_I2C_ERROR_AF)
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);
    }
    return HAL_ERROR;
  }

  /* Generate Restart */
  SET_BIT(hi2c->Instance->CR1, I2C_CR1_START);   // 7. 重新启动(Repeated Start):不释放总线再发一次 START

  /* Wait until SB flag is set */
  if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_SB, RESET, Timeout, Tickstart) != HAL_OK)
  {
    if (READ_BIT(hi2c->Instance->CR1, I2C_CR1_START) == I2C_CR1_START)
    {
      hi2c->ErrorCode = HAL_I2C_WRONG_START;
    }
    return HAL_TIMEOUT;
  }

  /* Send slave address */
  // 8. 再次发送从机地址,但这次是"读"方向
  hi2c->Instance->DR = I2C_7BIT_ADD_READ(DevAddress);

  /* Wait until ADDR flag is set */
  // 9. 等待地址被对方确认(收到 ACK)
  if (I2C_WaitOnMasterAddressFlagUntilTimeout(hi2c, I2C_FLAG_ADDR, Timeout, Tickstart) != HAL_OK)
  {
    return HAL_ERROR;
  }

  return HAL_OK;   // "写地址"时序完成,接下来进入真正的数据接收
}

细节 1:ACK 标志位配置

c 复制代码
#define I2C_CR1_ACK_Pos  (10U)
#define I2C_CR1_ACK_Msk  (0x1UL << I2C_CR1_ACK_Pos)   // 0x00000400
#define I2C_CR1_ACK      I2C_CR1_ACK_Msk              // Acknowledge Enable(应答使能位)

因为当前是读取,主机自己收到数据要给从机 ACK,所以要提前开启 ACK 自动应答

细节 2:I2C_7BIT_ADD_READ

c 复制代码
#define I2C_7BIT_ADD_READ(__ADDRESS__)   ((uint8_t)((__ADDRESS__) | I2C_OAR1_ADD0))

其实就是把 DevAddress最低位强制置 1 ,表示读操作。| I2C_OAR1_ADD0(即 | 0x01),最低位无论是 0 还是 1,或上 1 结果都是 1。

所以无论读写,调用 HAL_I2C_Mem_Write / HAL_I2C_Mem_Read 时传入设备地址都要 <<1

  • 写:HAL 内部用 I2C_7BIT_ADD_WRITE(清最低位 → 0)。
  • 读:HAL 内部用 I2C_7BIT_ADD_READ(置最低位 → 1)。
9.4.3 步骤 2:根据接收字节数,提前配置

怎么理解"提前配置"?

  • 一个问题:修改 I2C 的寄存器标志位,这些标志位是立即生效的吗?
  • 因为修改 I2C 寄存器是 CPU 通过代码直接改寄存器比特位,而改完之后 I2C 硬件接口还要在时序控制下,根据配置完成对应动作 。也就是说,I2C 配置生效往往具有"延后性"
  • 这就是为什么源码中有大量"等待完成"操作的根本原因:CPU 配置很快,I2C 硬件执行很慢
  • 我们之前(使用 HAL 角度)不用考虑这个问题,因为 HAL 库帮我们考虑了;但在源码角度,要理解接收,就必须理解"提前配置"的意义。

生活类比:坐地铁/公交,到站前会报"当前站",还会报"下一站"。报"下一站"就是在你大脑中提前配置,告诉你下一站是否要下车。这里的"配置"就是提前配置的样例。

提前配置的四种情况:

c 复制代码
    if (hi2c->XferSize == 0U)   // 情况 0:没有数据要接收
    {
      __HAL_I2C_CLEAR_ADDRFLAG(hi2c);   // 清 ADDR 标志
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 直接结束
    }
    else if (hi2c->XferSize == 1U)   // 情况 1:只有 1 字节要接收
    {
      CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);   // 关闭自动应答:收完最后 1 字节直接回 NACK
      __disable_irq();                 // 关闭中断(保护下面"清 ADDR + 设 STOP"两步操作不被中断打断)
      __HAL_I2C_CLEAR_ADDRFLAG(hi2c);  // 清 ADDR 标志
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 提前设置 STOP!
                                                   // 注意:STOP 不会立即发送,
                                                   // 而是未来收到最后一个字节、回复完 NACK 之后自动发送
      __enable_irq();                  // 重新开中断
    }
    else if (hi2c->XferSize == 2U)   // 情况 2:有 2 字节要接收
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_POS);    // 开启 POS(应答位置位,专门处理 2 字节)
      __disable_irq();
      __HAL_I2C_CLEAR_ADDRFLAG(hi2c);   // 清 ADDR 标志(必须)
      CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);  // 关闭自动应答(必须)
      __enable_irq();
    }
    else   // 情况 3:>= 3 字节,正常接收
    {
      SET_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);    // 开启自动 ACK
      __HAL_I2C_CLEAR_ADDRFLAG(hi2c);               // 清 ADDR 标志
    }

细节 1:重点看 STOP 标志位

提前设置的 STOP 不会立即发送,而是在硬件自动完成"最后一个字节接收 + 回复 NACK"之后才真正发出。这是 I2C 硬件的行为:收到最后一个字节后,Master 会先按 ACK 位配置回复 NACK,紧接着自动发送已置位的 STOP。

细节 2:理解 POS 位的意义

c 复制代码
#define I2C_CR1_POS_Pos  (11U)
#define I2C_CR1_POS_Msk  (0x1UL << I2C_CR1_POS_Pos)   // 0x00000800
#define I2C_CR1_POS      I2C_CR1_POS_Msk              // Acknowledge/PEC Position(应答位置,用于数据接收)
  • POS 位是专门用来处理"2 字节读取"的
  • POS 核心作用:决定 NACK 信号是发给"当前字节"还是"下一个字节"
  • 使用 POS 的配置是:POS = 1 且 ACK = 0
  • POS 位最"霸道"的地方:它临时剥夺了 ACK 位对当前字节的控制权 。当你设置 POS=1、ACK=0(关闭)时,硬件逻辑变成:对当前正在收的字节,强行无视 NACK 设置,必须回 ACK;把 NACK 留给后面那个字节。

关于"POS 控制 ACK 还是 NACK":参考手册写的是"应答当前字节或下一个字节的 ACK 和 NACK"。关闭 ACK 自动应答 → 发送 NACK;不关闭 → 下一个字节发送 ACK。虽然 ACK/NACK 都能控制,但"用 POS 控制发 ACK"这种情况不常用也没意义。

为什么需要 POS?

  • 首先,收到数据回复 ACK 是 I2C 硬件自动做的。
  • 其次,只要移位寄存器收到完整 1 字节数据(8 个比特,需要 8 个时钟周期),在第 9 个时钟周期就会自动回复 ACK,并把它移动到 DR 寄存器------这表示 I2C 收到数据。
  • 在收 2 个字节的情况下:如果没有设置 POS,你收倒数第二个数据时自动回复 ACK,收最后一个数据时默认只能继续 ACK。
  • 但我们需要收到最后一个数据后回复 NACK。那就需要在"倒数第二个数据收到、自动回复 ACK 之后"到"最后一个数据到达移位寄存器之前",让 CPU 执行"关 ACK"的代码。
  • 这个时间窗口极短,时序要求很高 ------CPU 很难在这个间隙里准确关闭自动应答(尤其还有中断干扰),几乎不可能、还要承担不确定性。所以需要配置 POS 标志来提前设置对 2 字节数据接收的处理

一句话:POS 存在的意义,是"为了一顿醋,包了顿饺子"------它是为了优雅地解决"收 2 个字节时,如何给最后一个字节回 NACK"这个棘手问题而设计的硬件机制。

9.4.4 步骤 3:开始接收

这部分逻辑我们"抓大放小",注重核心逻辑。

c 复制代码
    while (hi2c->XferSize > 0U)
    {
      if (hi2c->XferSize <= 3U)   // 剩余字节数 <= 3,进入收尾精细处理
      {
        /* One byte */
        // 剩余 1 字节:之前配置已关闭 ACK,所以收完立即回 NACK,符合预期
        if (hi2c->XferSize == 1U)
        {
          /* Wait until RXNE flag is set */
          // 等待 RXNE(数据寄存器非空):数据从移位寄存器转到 DR
          if (I2C_WaitOnRXNEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
          {
            return HAL_ERROR;
          }

          /* Read data from DR */
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读出数据到用户缓冲区

          hi2c->pBuffPtr++;   // 指针后移
          hi2c->XferSize--;   // 计数减
          hi2c->XferCount--;
        }
        /* Two bytes */
        // 剩余 2 字节:之前配置了 ACK=0 && POS=1
        // 所以收到倒数第二个数据回 ACK,收到最后一个回 NACK,符合预期
        else if (hi2c->XferSize == 2U)
        {
          /* Wait until BTF flag is set */
          // 2 字节时:一个在 DR,一个在移位寄存器,两个数据的应答已被硬件按配置回复
          // 这里只等"数据准备好了",并没有读取!
          if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BTF, RESET, Timeout, tickstart) != HAL_OK)
          {
            return HAL_ERROR;
          }

          __disable_irq();                  // 关中断,保护下面的临界区

          /* Generate Stop */
          SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 设置 STOP(NACK 已回复,就差 STOP 了)

          /* Read data from DR */
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读第一个字节
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;

          __enable_irq();                   // 开中断

          /* Read data from DR */
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读第二个字节(I2C 已自动回 NACK)
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;
        }
        /* 3 Last bytes */
        // 剩余 3 字节:之前配置只默认打开了 ACK 自动回复
        else
        {
          /* Wait until BTF flag is set */
          // 前两个字节已收,回复都是 ACK(BTF==1 说明移位和 DR 都有数据,ACK 已自动回复)
          if (I2C_WaitOnFlagUntilTimeout(hi2c, I2C_FLAG_BTF, RESET, Timeout, tickstart) != HAL_OK)
          {
            return HAL_ERROR;
          }

          /* Disable Acknowledge */
          // 此时还没读 DR,所以最后一个数据还没收。
          // 状态是:3 个字节,前 2 个已 ACK,最后 1 个还没收。
          // 关闭 ACK 自动应答后,未来读走 DR,移位寄存器数据移到 DR,
          // I2C 继续收第 3 个,收完第 9 个时钟周期就回复 NACK------完美!
          CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);

          __disable_irq();

          /* Read data from DR */
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 拿走倒数第 3 个字节
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;

          /* Wait until BTF flag is set */
          // 再次等待移位和 DR 都满:保证最后一个被完整放入移位寄存器并已回复 NACK
          count = I2C_TIMEOUT_FLAG * (SystemCoreClock / 25U / 1000U);
          do
          {
            count--;
            if (count == 0U)
            {
              // 超时处理
              hi2c->PreviousState = I2C_STATE_NONE;
              hi2c->State         = HAL_I2C_STATE_READY;
              hi2c->Mode          = HAL_I2C_MODE_NONE;
              hi2c->ErrorCode     |= HAL_I2C_ERROR_TIMEOUT;
              __enable_irq();
              __HAL_UNLOCK(hi2c);
              return HAL_ERROR;
            }
          }
          while (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == RESET);

          /* Generate Stop */
          SET_BIT(hi2c->Instance->CR1, I2C_CR1_STOP);   // 3 个数据应答都完成,发停止位

          /* Read data from DR */
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读取剩余 2 个字节
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;

          __enable_irq();

          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;
        }
      }
      else   // 剩余 > 3 字节,普通接收 + 双缓冲优化
      {
        /* Wait until RXNE flag is set */
        if (I2C_WaitOnRXNEFlagUntilTimeout(hi2c, Timeout, tickstart) != HAL_OK)
        {
          return HAL_ERROR;
        }

        /* Read data from DR */
        *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;   // 读一个字节
        hi2c->pBuffPtr++;
        hi2c->XferSize--;
        hi2c->XferCount--;

        // 双缓冲优化:如果 DR 和移位寄存器都有数据(BTF==1),再顺手读一个
        if (__HAL_I2C_GET_FLAG(hi2c, I2C_FLAG_BTF) == SET)
        {
          if (hi2c->XferSize == 3U)
          {
            // 剩余 3 个字节时提前关 ACK:为收尾做准备
            CLEAR_BIT(hi2c->Instance->CR1, I2C_CR1_ACK);
          }
          *hi2c->pBuffPtr = (uint8_t)hi2c->Instance->DR;
          hi2c->pBuffPtr++;
          hi2c->XferSize--;
          hi2c->XferCount--;
        }
      }
    }

    hi2c->State = HAL_I2C_STATE_READY;   // 恢复空闲
    hi2c->Mode  = HAL_I2C_MODE_NONE;     // 清空模式
    __HAL_UNLOCK(hi2c);                  // 解锁
    return HAL_OK;

细节 1:RxNE 标志位

c 复制代码
#define I2C_FLAG_RXNE  0x00010040U   // SR1 第 7 位:数据寄存器非空(已收到数据)

RxNE 只关心接收状态下,数据寄存器 DR 是否非空:1 = DR 有数据,可以读走;0 = DR 空。

细节 2:再看 BTF 标志位(接收方向)

在读取模式下,当 DR 寄存器有数据 && 移位寄存器有数据 时,BTF 被置 1。这意味着接收也是双寄存器缓冲

RxNE vs BTF:

标志 含义(接收方向) 用途
RXNE DR 寄存器非空,有数据可读 告诉我"有一个字节到了,可以读走"
BTF DR 和移位寄存器都有数据 告诉我"两个都满了",可以连续读两个

这两个标志组合使用,可以:

  1. 知道有 DR 数据可以读取了;
  2. 提高效率:在用户拷贝 DR 数据的同时,移位寄存器还能继续接收数据,CPU 获取数据和 I2C 接口收发同时进行,提高吞吐量

关于这段接收代码的感慨:意法工程师写这段代码时估计掉了很多头发,明显能看到"无奈和不优雅"。不过也没办法,这段代码现在没人敢轻易改。未来类似的情况还有很多------底层开发就是这样,能跑万岁


✨ 知识点总结:HAL 底层源码的三大核心点

提问:HAL_I2C_IsDeviceReady 是如何检测设备就绪的?读 EEPROM 时为什么最后一个字节要回 NACK?POS 位是干什么的?

答:这个问题可以分三层回答。

第一,设备就绪检测。HAL_I2C_IsDeviceReady 通过"三种就绪"综合判断:① 句柄状态 State 必须是 READY,说明 I2C 外设本身空闲、没被中断等其他代码占用(否则返回 HAL_BUSY);② 总线必须不忙,通过 I2C_WaitOnFlagUntilTimeout 检测 SR2 寄存器的 BUSY 标志位,超时则返回错误;③ 目标设备在线且地址匹配,方法是发送一次起始条件,把 7 位地址左移 1 位(最低位清 0 表示写)写入 DR 寄存器,然后循环检测 SR1 的 ADDR 标志位------收到 ACK 后 ADDR 置位就说明设备在线,随后发停止位并返回 HAL_OK。整个过程会按 Trials 参数多次重试,只要一次成功即认为设备就绪。

第二,为什么最后一个字节要回 NACK。I2C 读操作中,从机持续发数据,主机每收一个字节要回 ACK 表示"继续发",但主机收到最后一个字节后必须回 NACK,再发 STOP,通知从机"不要再发了"。而 I2C 硬件默认会在每个字节收到后自动回 ACK,所以 HAL 库必须用软件提前配置:收 1 个字节时直接关 ACK、预置 STOP;收 2 个字节时用 POS 机制;收 3 个字节时在中间关 ACK。因为寄存器配置对 I2C 硬件是"延后生效"的,所以必须在"倒数第二个字节"阶段就提前设置,才能让最后一个字节正确回 NACK。

第三,POS 位。POS(应答位置位)是专门解决"读 2 个字节"的硬件机制,配置为 POS=1、ACK=0。它临时剥夺 ACK 对当前字节的控制权:当前字节强制回 ACK,把 NACK 留给下一个字节。之所以需要它,是因为"倒数第二个字节回 ACK 之后、最后一个字节到达之前"这个时间窗口极短,CPU 不可能精准地在这期间关闭自动应答(尤其还有中断干扰),所以用 POS 提前预约"NACK 留给最后一字节"。

提问:MODIFY_REG 宏和"开启时钟 vs 设备使能"各是什么意思?

答:MODIFY_REG 是"先清空、再设置"修改寄存器特定位的标准宏,展开为 REG = ((REG & ~CLEARMASK) | SETMASK)。它先读取寄存器原值,用取反后的清除掩码把要改的位清零、保留其他位,再用设置掩码把目标位置成新值,最后写回。这样就能在不影响其他位的前提下安全修改指定字段。HAL 库配置 I2C 的 CR2/CR1/CCR/OAR1 等寄存器全部通过它完成,把结构体中的软件配置"翻译"成寄存器的硬件位。

关于"开启时钟 vs 设备使能":开启时钟(如 __HAL_RCC_I2C2_CLK_ENABLE,设置 RCC->APB1ENR 对应位)相当于给外设"接通电源",让寄存器可以被配置;设备使能(__HAL_I2C_ENABLE,置位 CR1 的 PE 位)相当于"按下启动键",让外设真正开始工作。两者缺一不可:不开时钟无法配置,不使能则配置好了也不工作。


10. 附录:HAL 手册与自学方法

10.1 HAL 官方手册

如果我们要查看 HAL 库中其他函数,可以到 STM32Cube 固件仓库中找到源码:

复制代码
C:\Users\XXX\STM32Cube\Repository\STM32Cube_FW_F1_V1.8.6\Drivers\STM32F1xx_HAL_Driver

HAL 还根据源码生成了一个官方手册(HTML/PDF 格式),可以查看函数签名和说明。当然,看个人习惯,也可以直接看源码来查看函数说明。

10.2 常见 HAL I2C 接口速查表

函数 作用
HAL_I2C_Init 初始化 I2C 外设(时钟、占空比、地址模式等)
HAL_I2C_Master_Transmit 主机发送(普通字节流:地址+数据)
HAL_I2C_Master_Receive 主机接收(普通字节流:地址+数据)
HAL_I2C_Mem_Write 内存型写入(地址 + 内存地址 + 数据)
HAL_I2C_Mem_Read 内存型读取(地址 + 内存地址 + 数据)
HAL_I2C_IsDeviceReady 检测设备是否就绪(地址探测 + ACK 判断 + 重试)
HAL_I2C_Slave_Transmit 从机发送(响应主机读取)
HAL_I2C_Slave_Receive 从机接收(响应主机写入)
HAL_I2C_DeInit 反初始化 I2C 外设

10.3 自学方法建议

一般"从无到有"的学习过程,可以结合 AI 辅助:

  1. 先罗列常见函数签名(理解每个参数);
  2. 再生成 Demo(跑通最小例子);
  3. 然后结合官方手册和自己的代码逐步深入。

习惯建议:学习期间尽量查阅可靠信源(官方数据手册、参考手册、官方规范),而不要只依赖二手信息------这是嵌入式开发者需要长期养成的好习惯。


写在最后:全篇知识点串讲

本次实战我们从"为什么要用 EEPROM"一路走到"I2C HAL 源码细节",把一条完整的知识链路走通了:

  1. 存储需求:需要"断电不丢 + 按字节改"的存储 → EEPROM(AT24C02)。
  2. 硬件连接:I2C2(PB10=SCL,PB11=SDA)接 AT24C02(地址 0x50),开漏 + 上拉。
  3. 速度与占空比:选大家都能支持的最快速度(400kHz),用 2:1 占空比满足 I2C 电气特性。
  4. CubeMX 配置:SWD、时钟树、I2C2、USART1、LED,生成工程。
  5. 应用代码:App 层交互配置 + BSP 层驱动封装(按页写、等待写周期)。
  6. HAL 接口:读写 EEPROM 用 Mem_Read / Mem_Write,就绪探测用 IsDeviceReady。
  7. 底层源码:从寄存器映射、MSP 初始化、MODIFY_REG,到设备就绪三条件、发送的 TXE/BTF、接收的 ACK/POS/RXNE/BTF,全部打通。

把这 7 步讲清楚,一个"I2C 阻塞通信"的完整知识体系就建立了。

最后送一句话:读 HAL 源码,核心是"流程梳理 + 寄存器设置",遇到 debug 出来的怪异代码要懂得"抓大放小"。能跑,万岁!


相关推荐
码上上岸3 小时前
基于stm32的智能荷兰豆生长舱监控系统
stm32·单片机·嵌入式硬件·物联网
星栖与芯4 小时前
STM32MP157 M4 指针避坑(一):先看清“客栈“——内存布局与指针基础
stm32·单片机·嵌入式硬件
国科安芯4 小时前
星载数据处理单元中单粒子翻转防护机制的设计考量与实现路径
网络·单片机·嵌入式硬件·架构·抗辐射·星载数据处理·单粒子
沐欣工作室_lvyiyi5 小时前
基于物联网的智慧路灯监控系统设计(论文+源码)
单片机·物联网·智能路灯
北京迅为5 小时前
【迅为开发板专属工具③】告别万用表串口助手|BoardLab一站式硬件测试平台
单片机·嵌入式硬件
沉淀的.晴天5 小时前
FreeRTOS任务挂起与恢复
stm32
HRTOS6 小时前
HRTOS应用示例:事件机制与高优先级任务抢占详解
单片机·51单片机
大漠飞鹰66666 小时前
【Uart串口-GD32无标题】
stm32·单片机·嵌入式硬件
西峰u6 小时前
Java Socket网络编程|TCP与UDP深度梳理
单片机·嵌入式硬件