
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

目录
- [什么是 EEPROM](#什么是 EEPROM)
- [AT24C02 生态介绍](#AT24C02 生态介绍)
- [AT24C02 读写介绍](#AT24C02 读写介绍)
- [AT24C02 与灵犀板(硬件连接)](#AT24C02 与灵犀板(硬件连接))
- 实战目标与需求分析
- [软件设计:CubeMX 图形化配置](#软件设计:CubeMX 图形化配置)
- 代码实现与解析(应用层)
- [I2C 核心 HAL 函数解析](#I2C 核心 HAL 函数解析)
- [HAL 底层源码深度解析](#HAL 底层源码深度解析)
- [附录: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 实战目标
- 掌握 I2C 主机阻塞模式的配置过程,包括 CubeMX 软件配置以及生成代码的理解。
- 掌握 I2C 主机阻塞模式数据发送、接收相关函数接口的使用方法。
- 掌握 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 → PB10 ,SDA → 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 通信速度的方法论:
- I2C 属于通信,在可靠 的前提下,越快越好。
- 参与通信的设备,各自支持的速度频率不同,就要取所有设备都支持的速度中"最快的那个"。
- 说白了:在保证可靠的前提下,取所有设备支持速度的交集(最小值),然后选其中最快的那个------没必要"没苦硬吃"。
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:1 或 16:9 。
- 16:9 时,低电平 1.6μs,高电平 0.9μs,也满足最低要求。
- 工程实践中,2:1 较为常见。
细节:为什么 I2C 特别在意低电平,低电平时间普遍比高电平时间长?
言简意赅:
- SCL 低电平期间才允许 SDA 电平变化,而变化需要更多时间保证;SCL 高电平时 SDA 不变,I2C 只需要采样数据,没有太多时间成本。
- 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 点:
- 写入需要物理时间 :向 AT24C02 写入数据时,一个完整的写入周期是 5ms +(来自数据手册)。
- 从机以 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 个字节):
- 主机(MCU)发送起始标志(START)。
- 主机发送从机地址 + 写标志位(注意没写错!需要先"写"给 AT24C02 未来要读取的地址信息,这个地址对 AT24C02 来说就是接下来要发送的数据)。
- 从机(EEPROM)发送 ACK 应答位。
- 主机发送要读取的内存起始地址(字地址)。
- 从机发送 ACK 应答位。
- 主机发送重新起始标志(Repeated Start)。
什么是重新起始标志?
- 在没有发送停止标志的情况下再次发送起始标志。
- 不释放总线控制权 ,是一个原子的、不可分割的总线事务。
- 保证操作序列的连续性和独占性。
- 说白了就是:已经告诉 AT24C02 我要访问哪一个地址了,下面我"重新开始"读取。
- 主机发送从机地址 + 读标志位。
- 从机发送 ACK 应答位。
- 从机发送数据字节 0。
- 主机发送 ACK 应答位。
- ......(循环)
- 从机发送数据字节 n。
- 主机发送 NACK(非应答)位------通知从机"我不要再收了"。
- 主机发送停止标志(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)))
这个宏的核心作用 :在不改变寄存器其他位 的情况下,安全地修改特定几位的值。
宏的执行顺序(第①步到第⑤步):
- 第①步:从寄存器 REG 读取当前 32 位原始数值(把寄存器当右值)。
- 第②步:将 CLEARMASK(清除掩码)按位取反------"要保留的位置置 1,要清除的位置置 0"。
例如 CLEARMASK =0000 0011(要清低 2 位),则 ~CLEARMASK =1111 1100。 - 第③步:
REG & (~CLEARMASK)------ 把指定位置清零,同时保持其他位不变。 - 第④步:
| SETMASK------ 在清零的位置上,设置目标值。 - 第⑤步:把结果写回目标寄存器 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 设备就绪,背后其实是"三种就绪"必须同时满足:
- I2C 设备本身没人访问且正常工作 (
hi2c->State == HAL_I2C_STATE_READY)。- 因为存在中断:中断处理函数也可能访问同一个 I2C 设备,导致重复访问。为避免冲突,HAL 在句柄中定义
State变量表示状态。谁要访问 I2C,必须先检测 State 并设置它------先来的先设置,后来的被拦截。
- 因为存在中断:中断处理函数也可能访问同一个 I2C 设备,导致重复访问。为避免冲突,HAL 在句柄中定义
- I2C 总线本身是就绪的,没有处于 busy 状态 。
- 因为存在"发送时对方来不及接收,接收方通过线与机制拉低 SCL,不让发送方继续发"的情况,此时总线处于 busy 状态。
- 所以要发数据,总线必须不是 busy 的。
- 目标设备在线并且地址匹配 。
- 当 Master 发送信息时,必须保证 Slave 在线且能正常应答 ACK。
用生活类比(和女朋友看电影):
- 女朋友愿意去(她要是约好了闺蜜去图书馆,就处于"忙"状态)。
- 从出发地到电影院道路畅通(堵车了就去不了)。
- 电影院正常营业且你导航的地址正确(影院关门或导航错了也看不了)。
三个条件同时满足,电影才能看成。检测设备是否 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 为例,代入宏:
(I2C_FLAG_BUSY >> 16U) = 0x0010,(uint8_t)强转后 = 0x10,不等于 0x01 ,所以三目运算符选中 SR2 寄存器分支。I2C_FLAG_BUSY & 0x0000FFFFU = 0x0002,即低 16 位是位 1(第 2 个比特位)。- 展开后就是
((SR2 & 0x2) == 0x2) ? SET : RESET------检测 SR2 寄存器的第 1 位是否为 1。 - 翻参考手册:SR2 的第 1 位就是 BUSY(总线忙) 标志。
编码细节:为什么"自己和自己比较"(全比较)?
形如 (SR2 & 0x2) == 0x2,看起来像是自己和自己比。这样做是为了实现 "全比较":寄存器标志位可能是几位连起来的,需要同时确认这几位的值。
用取款类比(掩码 0x03 = 需要同时满足"输入密码 0x01"和"插入银行卡 0x02"两个条件):
- "不比自己"
if (动作 & 规定):你只输了密码(0x01),0x01 & 0x03 = 0x01(真)→ 判定通过,钱吐出来了!------这银行要倒闭了,没插卡也能取钱。 - "自己比自己"
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 = 0x00000001U,I2C_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?------这正是源码要解决的问题。
读取要完成的时序(先写地址再重新读取):
- 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 和移位寄存器都有数据 | 告诉我"两个都满了",可以连续读两个 |
这两个标志组合使用,可以:
- 知道有 DR 数据可以读取了;
- 提高效率:在用户拷贝 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 辅助:
- 先罗列常见函数签名(理解每个参数);
- 再生成 Demo(跑通最小例子);
- 然后结合官方手册和自己的代码逐步深入。
习惯建议:学习期间尽量查阅可靠信源(官方数据手册、参考手册、官方规范),而不要只依赖二手信息------这是嵌入式开发者需要长期养成的好习惯。
写在最后:全篇知识点串讲
本次实战我们从"为什么要用 EEPROM"一路走到"I2C HAL 源码细节",把一条完整的知识链路走通了:
- 存储需求:需要"断电不丢 + 按字节改"的存储 → EEPROM(AT24C02)。
- 硬件连接:I2C2(PB10=SCL,PB11=SDA)接 AT24C02(地址 0x50),开漏 + 上拉。
- 速度与占空比:选大家都能支持的最快速度(400kHz),用 2:1 占空比满足 I2C 电气特性。
- CubeMX 配置:SWD、时钟树、I2C2、USART1、LED,生成工程。
- 应用代码:App 层交互配置 + BSP 层驱动封装(按页写、等待写周期)。
- HAL 接口:读写 EEPROM 用 Mem_Read / Mem_Write,就绪探测用 IsDeviceReady。
- 底层源码:从寄存器映射、MSP 初始化、MODIFY_REG,到设备就绪三条件、发送的 TXE/BTF、接收的 ACK/POS/RXNE/BTF,全部打通。
把这 7 步讲清楚,一个"I2C 阻塞通信"的完整知识体系就建立了。
最后送一句话:读 HAL 源码,核心是"流程梳理 + 寄存器设置",遇到 debug 出来的怪异代码要懂得"抓大放小"。能跑,万岁!
