简介面向嵌入式初学者的STM32F103单片机HAL库例程聚焦I2C接口读写AT24C02外部EEPROM存储芯片适用于物联网项目开发中的参数保存、掉电存储等场景。工程基于KEIL MDK编写当前在STM32F103上运行若更换同系列其他型号调整芯片型号与Flash容量即可正常使用同时需根据调试器选择JLINK或STLINK。压缩包共182个文件以C源文件68个和H头文件104个为主涵盖HAL库I2C、TIM、SPI、UART、ADC等底层驱动模块配套工程配置文件、编译脚本及其他说明文档整体体积仅1.29MB便于快速下载与查阅。代码中对单片机与模块的接线均有明确定义并附有注释方便读者对照硬件理解I2C时序与读写流程。目前已有530人学习适合需要快速上手STM32 HAL库外设开发的读者可作为项目实战的参考模板也可在此基础上扩展其他传感器驱动。1. 用 HAL 库读写 AT24C02问题总是在地址和页边界上一块 STM32F103 最小系统板、一片 AT24C02、两个上拉电阻这是多数人第一次接触 I2C 接口的开始。AT24C02 容量不大却适合当断电保存参数的小型外部存储HAL 库也提供了封好的 I2C 读写接口。但真正动手后读回全 0xFF、写一个字节把整页冲掉、总线卡死这些问题往往不是 I2C 协议没学会而是硬件地址、内部地址和页写边界没有对齐。这里会带着你在 STM32F103 最小系统上跑通 I2C 读写 AT24C02 的最小例程把 HAL 库的 Mem 类接口用熟再梳理一遍写周期、上拉电阻和总线调试技巧。新手能照着接线跑起来老手也能从最后那节找到排查方向。2. 认识 AT24C02 的 I2C 时序再决定用哪一组 HAL 接口2.1 AT24C02 的存储结构与 I2C 从机地址AT24C02 是 2Kbit 串行 EEPROM按字节寻址容量是 256 字节。芯片内部把存储区组织成 32 页每页 8 字节。页是写操作的最小连续单位也是例程出错最多的地方。硬件上 A2/A1/A0 三个引脚决定从机地址的低三位如果三个引脚都接地7 位地址是 0x50左移一位后得到 8 位写地址 0xA0读地址 0xA1。很多 HAL 例程直接把这个 0xA0 传给 HAL 库的 DevAddress 参数不要把它当成 7 位地址HAL 库期望的是左移后的 8 位地址。AT24C02 内部地址只有一个字节也就是说访问范围是 0x00~0xFF。这意味着 I2C 传输的第一个数据字节可以是内部地址也可以由 HAL 库的 MemAddress 参数单独指定。实际使用时要区分三个概念I2C 从机地址、片内字节地址、传输的数据缓冲区地址。它们很容易混在一起尤其在 I2C 接口上再加了逻辑分析仪之后反而会忽略内存地址帧是否真的正确。参数取值说明存储容量256 字节0x00~0xFF寻址位宽 8bit页大小8 字节页内写超过末尾会回绕到页首从机地址0x507bit/ 0xA08bit 写地址A2/A1/A0 接地时写周期 tWR典型 5ms停止条件后进入内部写周期2.2 HAL 库的三层 I2C 接口从机地址、内存地址和普通发送HAL 库在 STM32F103 上封装了几组 I2C 函数最常用的两组是 HAL_I2C_Master_Transmit 和 HAL_I2C_Mem_Write/Read。前者适合向从机发送一串普通字节例如控制寄存器型的传感器后者适合先给从机一个内部地址再读写该地址后面的数据例如 EEPROM。AT24C02 正好属于后者所以首选 Mem 接口。Mem 接口的函数原型是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_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);以写一个字节为例uint8_t data 0x5A; HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, 0xA0, 0x10, I2C_MEMADD_SIZE_8BIT, data, 1, 100); if (status ! HAL_OK) { // 处理超时或总线错误 }这段代码里0xA0 是左移后的从机地址0x10 是 AT24C02 内部字节地址I2C_MEMADD_SIZE_8BIT 告诉 HAL 库内部地址占一个字节。HAL 库在内部会依次发送起始条件、从机写地址、内存地址、数据最后产生停止条件。超时参数给 100ms 足够AT24C02 的写周期最长也就 5ms超时设太大反而不利于发现问题。如果误用 HAL_I2C_Master_Transmit也能实现写操作但需要自己把内部地址放到数据缓冲区第一字节。对 AT24C02 来说这可以工作却绕开了 Mem 接口的语义多字节写时更容易把地址当成数据且不复用 HAL 库对内存地址 size 的判断。读操作则不建议拆成 Master_Transmit 加 Master_Receive因为两个独立调用之间会产生 STOP接着再发 START无法形成读操作需要的 Repeated START而 HAL_I2C_Mem_Read 会在地址帧之后自动处理这种读方向切换。2.3 阻塞式 I2C 的时序保障以及写周期等待HAL 库的阻塞式接口靠超时循环驱动底层状态机调用期间 CPU 一直等待总线事件。对简单例程这是最可靠的做法不用额外处理中断标志。代价是如果总线上有慢速从机或者系统里有高优先级中断长时间抢占 CPU可能出现 HAL_BUSY 或 HAL_TIMEOUT。出现这类错误时先检查总线是否被拉低再检查超时参数是否合理。写周期等待是 AT24C02 最容易忽略的一步。数据手册要求在停止条件之后等待 tWR期间芯片不响应任何 I2C 地址。这里不推荐直接做一个固定 delay常见做法是发送一个空地址命令探测 ACK只要 AT24C02 还在写周期它就不会对地址帧返回 ACK。HAL 库已经封装了 HAL_I2C_IsDeviceReady可以直接用来轮询uint8_t AT24C02_WaitReady(I2C_HandleTypeDef *hi2c, uint32_t timeout) { uint32_t cnt 0; HAL_StatusTypeDef status HAL_ERROR; do { status HAL_I2C_IsDeviceReady(hi2c, 0xA0, 1, 1); cnt; } while (status ! HAL_OK cnt timeout); return status HAL_OK; }HAL_I2C_IsDeviceReady 的第三个参数是尝试次数每次调用内部会尝试发起地址帧并等待 ACK/NACK适合用来探测写周期是否结束。注意不要把它用于普通读写流程它只发送地址帧不传输数据。3. 在 STM32F103 最小系统上配置 I2CCubeMX 里先设这几项3.1 引脚分配、I2C 速率和开漏输出STM32F103 的 I2C1 默认映射到 PB6SCL和 PB7SDAI2C2 映射到 PB10 和 PB11。在最小系统板上我一般选 I2C1因为 PB6/PB7 离电源脚更近示波器探针也好夹。CubeMX 的 Pinout 视图里把 PB6 配成 I2C1_SCLPB7 配成 I2C1_SDA系统会自动把 GPIO Mode 设置为 Alternate Function Open-Drain。开漏输出必须保留这是 I2C 总线仲裁的基础不能改成推挽。CubeMX 中 I2C Speed Mode 默认是 100kHz 的 Standard ModeAT24C02 也支持 400kHz 的 Fast Mode。建议先在 100kHz 下跑通再用 400kHz 验证。很多 STM32F103 最小系统板上的上拉电阻在 10kΩ 左右外加杜邦线后总线电容变大400kHz 时沿变慢容易出现 CRC 错误或地址 NACK。I2C 引脚外部需要上拉电阻4.7kΩ 到 3.3V 是常用值如果板子上已经焊了 10kΩ也能工作只是速率余量小一些。涉及 CubeMX 的项目配置重点检查这四个位置配置项推荐值说明I2C1 Speed ModeStandard Mode 100kHz先跑通再试 Fast ModePB6/PB7 引脚模式Alternate Function Open-Drain由 CubeMX 自动生成外部上拉电阻4.7kΩ 到 VCC少数核心板用 10kΩ 也可以RCC/SYSHSE 晶振、Debug Serial Wire方便串口打印和调试3.2 生成工程后给 AT24C02 写最小读写函数CubeMX 生成代码之后I2C 外设初始化已经在 MX_I2C1_Init 里完成。剩下的工作是封装 AT24C02 的读写函数。下面的函数只处理单字节读写适用于先验证硬件连接。#define AT24C02_DEV_ADDR 0xA0 uint8_t AT24C02_WriteByte(I2C_HandleTypeDef *hi2c, uint16_t addr, uint8_t data) { if (addr 0xFF) { return 0; } if (HAL_I2C_Mem_Write(hi2c, AT24C02_DEV_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100) ! HAL_OK) { return 0; } return AT24C02_WaitReady(hi2c, 1000); } uint8_t AT24C02_ReadByte(I2C_HandleTypeDef *hi2c, uint16_t addr) { uint8_t data 0xFF; if (addr 0xFF) { return 0xFF; } if (HAL_I2C_Mem_Read(hi2c, AT24C02_DEV_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100) ! HAL_OK) { return 0xFF; } return data; }这段代码的返回值是简单的 0/1 或读到的数据。WriteByte 在 HAL_I2C_Mem_Write 返回 HAL_OK 后又调用了 WaitReady。这个顺序不能颠倒因为 HAL_OK 只表示 I2C 外设已经完成总线上的传输不代表 AT24C02 内部擦写完成。ReadByte 中把局部变量 data 初始化为 0xFF是为了配合超时场景EEPROM 擦除后的默认值就是 0xFF至少能区分总线错误和地址读到空数据。3.3 写周期比时序更容易踩坑如果把例程烧进 STM32F103 最小系统连上 AT24C02却发现读回值不对最可能的原因不是 I2C 协议没配对而是写周期没有等待。AT24C02 在收到停止条件后进入内部写周期此时芯片不响应任何地址。如果紧接着调用 Mem_ReadHAL 库会在地址帧处收到 NACK最后返回 HAL_TIMEOUT。一些代码为了更高效省略了 WaitReady改为固定延时几毫秒这在单个字节写时有效但在连续写多个字节时累计延时一旦小于实际写周期数据就会丢失。还有一类问题是同一地址反复写。AT24C02 的写周期由内部状态机管理不是每次都能在刚好 5ms 内完成温度、电压都会影响实际 tWR。用 HAL_I2C_IsDeviceReady 轮询 ACK 的好处是时间自动适配比固定延时更稳。这个函数在总线空闲时会快速返回只有写周期未结束时才会反复占用 I2C 总线最多耗费几毫秒对例程没有影响。情形建议等待方式原因单字节写WaitReady 轮询 ACK时间自适应最快连续多字节写每页写完后 WaitReady确保页写入完成再写下一页高低温环境测试轮询方式比固定延时可靠写周期随温度变化4. 读写例程的完整落地从单字节到页写4.1 单字节写和当前地址读的最小验证把最小验证写进工程时我习惯先做“写一个地址再读回比较”的流程。这样可以排除页边界和缓冲区的干扰先确认地址帧、ACK 和 I2C 方向切换都正常。下面的代码放在 main 函数中int main(void) { uint8_t val 0x5A; uint8_t back 0; HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_I2C1_Init(); if (AT24C02_WriteByte(hi2c1, 0x00, val)) { back AT24C02_ReadByte(hi2c1, 0x00); if (back val) { HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET); } } while (1) { } }这里先初始化 HAL 库、时钟、GPIO 和 I2C1然后向地址 0x00 写一个字节。如果 WriteByte 返回值非 0说明地址帧已经收到 ACK并且写周期结束。ReadByte 的结果和原值比较一致则点亮 LED。这样就把写时序、读时序分开了哪个环节失败可以从判断点看返回值。注意 main 中直接调用 AT24C02_WaitReady 的时机它在 WriteByte 内部已经调用main 里的 if 条件不要漏掉返回值。4.2 页写、页边界拆分与跨页处理AT24C02 每页只有 8 字节。页写操作允许一次写入最多 8 字节但条件是这些字节不能越过页尾。如果从地址 0x07 开始写 3 字节第 1 字节落在 0x07后续两字节会回绕到 0x00 和 0x01而不是写到 0x08 和 0x09。这是芯片硬件行为不是 HAL 库的缺陷。所以多字节写入必须自己拆分。uint8_t AT24C02_WriteBuffer(I2C_HandleTypeDef *hi2c, uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t sent 0; if ((addr len) 0x100) { return 0; } while (sent len) { uint8_t page_left 8 - ((addr sent) % 8); uint16_t chunk (uint16_t)page_left; uint16_t remain len - sent; if (remain chunk) { chunk remain; } if (HAL_I2C_Mem_Write(hi2c, AT24C02_DEV_ADDR, addr sent, I2C_MEMADD_SIZE_8BIT, buf sent, chunk, 100) ! HAL_OK) { return 0; } if (!AT24C02_WaitReady(hi2c, 1000)) { return 0; } sent chunk; } return 1; }这段代码先计算当前地址到页尾还剩多少字节用 page_left 存下来再和剩余待写长度比较取较小值作为本次 chunk。HAL_I2C_Mem_Write 每次写一块写完当前页后等待写周期结束然后继续下一块。addr sent 是实际内部地址不是缓冲区偏移。由于 AT24C02 只有 256 字节入口处检查 addr len 是否越界可以避免地址回绕的隐蔽错误。读多字节没有页边界问题因为读操作是地址自动递增可以从任意地址连续读到 0xFF然后回绕到 0x00。但如果只想读一段固定长度的数据直接用 HAL_I2C_Mem_Read 连续读即可uint8_t AT24C02_ReadBuffer(I2C_HandleTypeDef *hi2c, uint16_t addr, uint8_t *buf, uint16_t len) { if ((addr len) 0x100) { return 0; } if (HAL_I2C_Mem_Read(hi2c, AT24C02_DEV_ADDR, addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100) ! HAL_OK) { return 0; } return 1; }4.3 如何判断不是“假成功”HAL_I2C_Mem_Write 返回 HAL_OK 只表示主机把数据帧完整发完并不能证明 EEPROM 内部已经写入。要排除“假成功”需要做两步验证第一步确认 AT24C02 在地址帧处回了 ACK第二步读回数据并和原值比较。HAL_I2C_Mem_Write 返回 HAL_OK 时第一步已经通过但在写周期未结束时立刻去读则地址帧不会得到 ACKHAL_I2C_Mem_Read 返回超时。所以 WaitReady 或至少几毫秒延时必须存在。可以用一张表来做故障分类现象可能原因处理写函数返回 HAL_OK读回全 0xFF写周期未结束或地址超出 255增加 WaitReady 后重读地址帧 NACK函数返回 HAL_TIMEOUT上拉电阻缺失或接线错误检查 SCL/SDA 电平连续写 8 字节后前几字节被覆盖页写回绕按页边界拆分读 0x00 得到上次写入的数据没有真正写入仅内部缓存重新上电后验证5. 最后把 I2C 时序抠细一点总线卡死、上拉和总线恢复技巧5.1 总线空闲电平和 9 个时钟恢复I2C 总线上正常空闲时 SCL 和 SDA 都应该为高。如果 SDA 一直为低多半是主机 GPIO 被配成了推挽输出或某个从机把总线拉住了。先量电平再改软件不要急着重新烧录。如果确认总线被从机拉低可以用 9 个时钟脉冲让从机释放 SDA。常见做法是把两个引脚临时切成 GPIO 输出手动翻转 SCL 九个周期同时保持 SDA 空闲为高。在 STM32F103 上可以这样处理void I2C_Recovery(void) { GPIO_InitTypeDef gpio {0}; __HAL_I2C_DISABLE(hi2c1); gpio.Mode GPIO_MODE_OUTPUT_OD; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Pin GPIO_PIN_6; HAL_GPIO_Init(GPIOB, gpio); gpio.Pin GPIO_PIN_7; HAL_GPIO_Init(GPIOB, gpio); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); for (int i 0; i 9; i) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); } gpio.Mode GPIO_MODE_AF_OD; gpio.Pull GPIO_PULLUP; gpio.Speed GPIO_SPEED_FREQ_HIGH; gpio.Pin GPIO_PIN_6 | GPIO_PIN_7; HAL_GPIO_Init(GPIOB, gpio); __HAL_I2C_ENABLE(hi2c1); }这段代码先关闭 I2C 外设再把自己的 I2C 引脚重新初始化为开漏 GPIO然后只翻转 SCLSDA 保持高。从机在释放 SDA 前会在这 9 个时钟内完成一次内部状态复位。恢复后重新把引脚配回复用开漏再使能 I2C 外设。函数里把 PB6/PB7 写死了如果工程用的是 I2C2记得把引脚和端口一起改掉。5.2 用逻辑分析仪看地址帧的标志波形排查 I2C 读写 AT24C02 问题时逻辑分析仪比示波器更好用。抓 SCL 和 SDA先找 START 条件然后数地址字节低 7 位是否是 1010000第 8 位是 0 还是 1最后看第 9 个时钟的 ACK 位。AT24C02 正常应答时 SDA 会被拉低。如果地址帧正确但 ACK 位为高说明从机地址或写周期有问题。读多字节时HAL_I2C_Mem_Read 会产生一个 Repeated START在波形上会看到地址从写方向切到读方向。第一次抓波形时锁定这三个位置从机地址、内存地址、ACK。5.3 我建议长期保留的三个惯用手法一是把 WaitReady 和 WriteBuffer 绑定不让任何写入路径绕过它。二是把读写函数都带上 I2C_HandleTypeDef 指针参数这样以后换 I2C2、换 F103 系列的其他型号只需要改动句柄和引脚初始化。三是把读回校验做成宏或小函数每次写完数据都自动读回 8 字节并比较。这三件事加起来代码量不大但能避开 AT24C02 例程里大多数隐蔽 bug。I2C 的问题往往不在协议有多难而是时序细节和硬件状态叠加在一起保持一个慢速、开漏、可读回的例程基底后面再加 DMA、中断或移植 FreeRTOS 时才不会在奇怪的地方浪费半天。本文还有配套的精品资源点击获取