ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 HAL库驱动AT24C02 EEPROM:I2C读写实战与避坑指南

STM32 HAL库驱动AT24C02 EEPROM:I2C读写实战与避坑指南 1. 为什么AT24C02是I2C入门的最佳练手对象搞STM32的人迟早要跟I2C打交道而AT24C02这颗EEPROM芯片几乎是所有人学习I2C协议时绕不开的第一块“试验田”。原因很简单它便宜、好买、时序标准、寄存器逻辑清晰而且容量只有2Kbit256字节小到你可以用逻辑分析仪把整个读写过程的波形从头看到尾不会因为数据量太大而迷失在时序里。我手上这块STM32F103C8T6最小系统板配上AT24C02的8脚DIP封装芯片再加上两个4.7kΩ的上拉电阻就是一套完整的I2C读写实验平台。这篇文章要拆解的就是从硬件接线、CubeMX配置、HAL库函数调用到最终能稳定读写每一个字节的完整流程。不管你是刚学完GPIO点灯的新手还是从标准库转HAL库的老玩家这套流程都能直接拿去复现。先明确一个核心问题为什么选I2C而不是SPI或者UART来读写EEPROMI2C最大的优势是引脚少——两根线SCLSDA就能挂载多个从设备AT24C02的7位地址是1010xxx其中低三位由A0/A1/A2引脚决定这意味着同一条I2C总线上最多可以挂8片AT24C02总容量扩展到2KB。对于需要存储配置参数、校准数据、运行日志的场景来说这种扩展方式既省引脚又省成本。SPI虽然速度更快但每个从设备都要独立的CS片选线挂多了引脚就不够用了。STM32F103的I2C外设支持标准模式100kHz和快速模式400kHzAT24C02在5V供电时支持400kHz3.3V供电时也支持400kHz但需要注意时序参数略有不同。我实测下来用400kHz时钟读写256字节的完整数据耗时大约在10ms左右对于绝大多数嵌入式应用来说完全够用。注意STM32F103的I2C外设有一个众所周知的老毛病——在某些特定时序下会出现总线锁死或BUSY标志无法清除的问题。这个问题在ST的官方勘误手册里有详细描述后面我会专门讲怎么绕过这个坑。2. 硬件设计与电路连接的关键细节2.1 AT24C02引脚功能与最小系统搭建AT24C02的8个引脚定义如下引脚编号名称功能说明1A0地址选择位02A1地址选择位13A2地址选择位24GND地5SDA串行数据线6SCL串行时钟线7WP写保护高电平禁止写入8VCC电源1.8V~5.5VA0/A1/A2三个引脚决定了芯片在I2C总线上的7位地址。AT24C02的地址格式是1010 A2 A1 A0所以当三个引脚全部接地时7位地址就是1010000即0x50。写操作时地址字节是0xA0读操作时是0xA1。这个细节很多人第一次接触时会搞混——7位地址和8位读写地址是两回事HAL库函数用的是7位地址而逻辑分析仪上抓到的是8位地址。WP引脚是写保护控制。接地时允许正常读写接VCC时禁止写入但读取不受影响。我在实际项目中会把这个引脚接到MCU的一个GPIO上需要写入时拉低写入完成后拉高防止程序跑飞时误改EEPROM里的关键参数。这个做法在工业控制类项目里特别实用。2.2 上拉电阻的选择与计算I2C总线是开漏输出结构SCL和SDA都必须接上拉电阻才能输出高电平。这个电阻的取值不是随便选的需要根据总线电容和上升时间来计算。标准模式100kHz下上升时间tr最大允许1000ns快速模式400kHz下最大允许300ns。总线电容Cb包括PCB走线电容、引脚电容和器件电容通常估算为10pF~20pF每厘米走线。假设总线电容为100pF快速模式下tr ≈ 0.847 × R × Cb300ns 0.847 × R × 100pFR ≈ 3.5kΩ所以4.7kΩ是一个比较保守的选择既能保证上升时间满足要求又不会因为电阻太小导致灌电流过大I2C规范要求低电平灌电流不超过3mA3.3V/4.7kΩ≈0.7mA完全安全。如果总线上挂了多个设备或者走线较长可以适当减小到2.2kΩ。我试过用10kΩ上拉在400kHz下波形上升沿明显变缓逻辑分析仪解码偶尔会出错所以4.7kΩ是经过实测验证的稳妥值。2.3 STM32F103的I2C引脚映射STM32F103C8T6有两个I2C外设I2C1和I2C2。I2C1的默认引脚是PB6SCL和PB7SDA重映射后可以到PB8/PB9。I2C2的默认引脚是PB10SCL和PB11SDA。我一般用I2C1的默认引脚因为PB6/PB7在最小系统板上通常没有其他复用功能冲突。这里有一个容易踩的坑STM32F103的I2C引脚必须配置为开漏复用输出模式而不是推挽输出。开漏模式下引脚只能拉低或释放由外部上拉电阻拉高这正是I2C总线所需要的。如果错误地配置成推挽输出当两个设备同时输出不同电平时会发生短路长期运行可能损坏引脚。在CubeMX中选择I2C外设后引脚会自动配置为开漏复用模式但如果你手动配置GPIO一定要记得把Output Type改成Open Drain。3. CubeMX配置与HAL库初始化流程3.1 时钟树与I2C参数设置打开CubeMX选择STM32F103C8T6芯片首先配置时钟。外部晶振8MHz经过PLL倍频到72MHz作为系统时钟。I2C1挂载在APB1总线上APB1的时钟是36MHz。在I2C1的配置界面中主要设置以下几个参数I2C Speed Mode选择Fast Mode400kHzClock Speed400000 HzDuty Cycle选择2:1快速模式下Tlow/Thigh2Analog Filter使能Digital Filter使能系数设为0CubeMX会自动计算CCR寄存器的值。以36MHz的APB1时钟为例快速模式下CCR 36MHz / (3 × 400kHz) 30实际写入寄存器的值就是30。这个计算过程在参考手册RM0008的I2C章节有详细说明感兴趣可以自己验算一遍。3.2 GPIO配置与工程生成I2C1的PB6和PB7会自动配置为复用开漏模式上拉电阻在CubeMX中可以选择“Pull-up”但我强烈建议使用外部上拉电阻因为STM32内部的上拉电阻阻值大约在30kΩ~50kΩ之间对于400kHz的快速模式来说太大了上升沿会严重变缓。外部4.7kΩ上拉是必须的。生成工程时Toolchain选择MDK-ARM V5勾选“Generate peripheral initialization as a pair of .c/.h files”这样I2C的初始化代码会单独放在i2c.c和i2c.h中方便管理。生成后的工程中MX_I2C1_Init()函数会自动完成I2C外设的初始化。这个函数在main.c的main()函数开头被调用我们不需要手动修改它。3.3 HAL库I2C读写函数原型解析HAL库提供了三个核心函数用于I2C通信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); HAL_StatusTypeDef HAL_I2C_IsDeviceReady(I2C_HandleTypeDef *hi2c, uint16_t DevAddress, uint32_t Trials, uint32_t Timeout);这三个函数是操作AT24C02的全部家当。HAL_I2C_Mem_Write用于向指定内存地址写入数据HAL_I2C_Mem_Read用于读取HAL_I2C_IsDeviceReady用于检测设备是否在线。参数说明DevAddressAT24C02的7位地址左移一位即0x50 1 0xA0MemAddressAT24C02内部的字节地址范围0x00~0xFFMemAddSize内存地址字节数AT24C02是8位地址所以填I2C_MEMADD_SIZE_8BITpData数据缓冲区指针Size要读写的数据字节数Timeout超时时间单位毫秒注意DevAddress参数在HAL库中要求传入的是8位地址7位地址左移一位而不是7位地址本身。这个细节在HAL库文档中写得比较隐晦我第一次用的时候传了0x50结果一直返回HAL_ERROR排查了半天才发现问题。4. AT24C02读写时序与页写入机制4.1 字节写入与页写入的区别AT24C02支持两种写入模式字节写入Byte Write和页写入Page Write。字节写入一次只写一个字节页写入一次可以写最多8个字节一页的大小是8字节。页写入的时序是START → 设备地址写→ 字地址 → 数据1 → 数据2 → ... → 数据8 → STOP。AT24C02内部有一个8字节的页缓冲区接收到数据后先存入缓冲区收到STOP信号后再一次性写入EEPROM存储单元。这里有一个非常重要的限制页写入不能跨页。也就是说如果你从地址0x07开始写8个字节会写到0x07、0x08、...、0x0E但0x08是下一页的起始地址实际上0x07之后的地址会回卷到0x00导致数据写错位置。这个坑我在实际项目中踩过——当时从地址0x05开始连续写10个字节结果前3个字节写到了0x05~0x07后7个字节回卷到了0x00~0x06把之前存的数据全覆盖了。正确的做法是每次写入前计算当前地址到页边界的剩余空间如果剩余空间不够就分多次写入。下面这段代码是我实际项目中用的分页写入函数#define AT24C02_ADDR 0xA0 #define PAGE_SIZE 8 HAL_StatusTypeDef AT24C02_WriteBuffer(uint16_t addr, uint8_t *data, uint16_t len) { HAL_StatusTypeDef status; uint16_t bytes_written 0; while (bytes_written len) { uint16_t page_remain PAGE_SIZE - (addr % PAGE_SIZE); uint16_t write_len (len - bytes_written page_remain) ? (len - bytes_written) : page_remain; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data[bytes_written], write_len, 100); if (status ! HAL_OK) { return status; } // 等待EEPROM内部写入完成典型值5ms HAL_Delay(5); addr write_len; bytes_written write_len; } return HAL_OK; }4.2 写周期等待与应答查询AT24C02在收到STOP信号后内部会启动一个写周期Write Cycle把页缓冲区的内容写入EEPROM存储单元。这个写周期典型耗时5ms最大不超过10ms。在这段时间内AT24C02不会响应任何I2C命令也就是“忙”状态。有两种方式等待写周期完成第一种是固定延时简单粗暴直接HAL_Delay(5)或HAL_Delay(10)。优点是代码简单缺点是如果写周期提前完成CPU就白白等待了如果写周期超过10ms极端情况又可能出错。第二种是应答查询Acknowledge Polling不断发送START设备地址如果AT24C02返回ACK说明写周期完成如果返回NACK说明还在忙。这种方式效率更高但代码稍复杂HAL_StatusTypeDef AT24C02_WaitReady(uint32_t timeout) { uint32_t tickstart HAL_GetTick(); while (HAL_I2C_IsDeviceReady(hi2c1, AT24C02_ADDR, 1, 100) ! HAL_OK) { if ((HAL_GetTick() - tickstart) timeout) { return HAL_TIMEOUT; } } return HAL_OK; }我一般用应答查询方式因为它在批量写入时能节省不少时间。实测下来连续写入256字节分32页用固定延时需要160ms用应答查询只需要约80ms。4.3 读取操作的时序特点AT24C02的读取有三种模式当前地址读取、随机地址读取、顺序读取。当前地址读取不需要发送字地址直接发送START设备地址读AT24C02会从上次操作后的地址继续输出数据。这个模式适合连续读取场景但第一次使用前需要先设置好地址。随机地址读取先发送START设备地址写字地址然后重新发送START设备地址读AT24C02会从指定地址输出数据。这是最常用的读取方式HAL_I2C_Mem_Read函数实现的就是这个流程。顺序读取在随机地址读取的基础上主机每收到一个字节后返回ACKAT24C02就继续输出下一个地址的数据直到主机返回NACK并发送STOP。地址到达0xFF后会自动回卷到0x00。HAL_I2C_Mem_Read函数内部已经处理了随机地址读取的完整时序我们只需要调用即可。但要注意读取操作没有写周期等待的问题因为读取不改变EEPROM内部数据不需要内部写入时间。5. 完整读写代码实现与实测验证5.1 基础读写函数封装基于前面的分析我封装了一套完整的AT24C02驱动函数包括字节读写、缓冲区读写、页写入优化版本// at24c02.h #ifndef __AT24C02_H #define __AT24C02_H #include main.h #define AT24C02_ADDR 0xA0 #define AT24C02_PAGE_SIZE 8 #define AT24C02_WRITE_TIMEOUT 100 HAL_StatusTypeDef AT24C02_Init(void); HAL_StatusTypeDef AT24C02_WriteByte(uint8_t addr, uint8_t data); HAL_StatusTypeDef AT24C02_ReadByte(uint8_t addr, uint8_t *data); HAL_StatusTypeDef AT24C02_WriteBuffer(uint16_t addr, uint8_t *data, uint16_t len); HAL_StatusTypeDef AT24C02_ReadBuffer(uint16_t addr, uint8_t *data, uint16_t len); HAL_StatusTypeDef AT24C02_WaitReady(uint32_t timeout); #endif// at24c02.c #include at24c02.h extern I2C_HandleTypeDef hi2c1; HAL_StatusTypeDef AT24C02_Init(void) { // 检测设备是否存在 if (HAL_I2C_IsDeviceReady(hi2c1, AT24C02_ADDR, 3, 100) ! HAL_OK) { return HAL_ERROR; } return HAL_OK; } HAL_StatusTypeDef AT24C02_WriteByte(uint8_t addr, uint8_t data) { HAL_StatusTypeDef status; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); if (status ! HAL_OK) { return status; } return AT24C02_WaitReady(20); } HAL_StatusTypeDef AT24C02_ReadByte(uint8_t addr, uint8_t *data) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, 1, 100); } HAL_StatusTypeDef AT24C02_WriteBuffer(uint16_t addr, uint8_t *data, uint16_t len) { HAL_StatusTypeDef status; uint16_t bytes_written 0; while (bytes_written len) { uint16_t page_remain AT24C02_PAGE_SIZE - (addr % AT24C02_PAGE_SIZE); uint16_t write_len (len - bytes_written page_remain) ? (len - bytes_written) : page_remain; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data[bytes_written], write_len, 100); if (status ! HAL_OK) { return status; } status AT24C02_WaitReady(20); if (status ! HAL_OK) { return status; } addr write_len; bytes_written write_len; } return HAL_OK; } HAL_StatusTypeDef AT24C02_ReadBuffer(uint16_t addr, uint8_t *data, uint16_t len) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR, addr, I2C_MEMADD_SIZE_8BIT, data, len, 100); } HAL_StatusTypeDef AT24C02_WaitReady(uint32_t timeout) { uint32_t tickstart HAL_GetTick(); while (HAL_I2C_IsDeviceReady(hi2c1, AT24C02_ADDR, 1, 100) ! HAL_OK) { if ((HAL_GetTick() - tickstart) timeout) { return HAL_TIMEOUT; } } return HAL_OK; }5.2 实测数据与波形分析我用这套代码做了完整的读写测试。测试流程是先向地址0x00~0xFF写入0x00~0xFF的递增数据然后逐个读取并比对。写入256字节耗时约85ms含写周期等待读取256字节耗时约3ms400kHz时钟无写周期等待。用逻辑分析仪抓取的波形显示SCL频率稳定在400kHzSDA数据在SCL高电平期间保持稳定符合I2C规范。有一个细节值得注意HAL_I2C_Mem_Read函数在读取多个字节时最后一个字节前会发送NACK这是I2C协议的要求——主机在接收最后一个字节后必须返回NACK告诉从机停止发送。如果错误地返回ACK从机会继续输出下一个地址的数据导致总线无法正常释放。5.3 数据校验与错误处理在实际项目中EEPROM存储的数据往往很关键写入后需要校验。我通常采用“写入-回读-比对”的三步策略HAL_StatusTypeDef AT24C02_WriteVerify(uint8_t addr, uint8_t *data, uint16_t len) { uint8_t *readback malloc(len); if (readback NULL) { return HAL_ERROR; } HAL_StatusTypeDef status AT24C02_WriteBuffer(addr, data, len); if (status ! HAL_OK) { free(readback); return status; } status AT24C02_ReadBuffer(addr, readback, len); if (status ! HAL_OK) { free(readback); return status; } if (memcmp(data, readback, len) ! 0) { free(readback); return HAL_ERROR; } free(readback); return HAL_OK; }这个函数在写入关键参数如校准系数、设备ID时特别有用。虽然多了一次读取操作但对于可靠性要求高的场景来说这点时间开销完全值得。6. 常见问题排查与避坑经验6.1 I2C总线BUSY标志无法清除这是STM32F103 I2C外设最臭名昭著的问题。现象是调用HAL_I2C_Mem_Write后返回HAL_BUSY检查I2C1-SR2寄存器的BUSY位一直是1即使总线上没有任何活动。产生这个问题的原因通常是在I2C通信过程中发生了异常如从机突然断电、总线被拉低导致STM32的I2C状态机卡死。ST的官方勘误手册ES096里描述了这个问题但给出的解决方案比较复杂。我实际使用的解决方案是在I2C初始化之前手动模拟I2C时序发送9个时钟脉冲强制从机释放SDA线。具体做法是把SCL和SDA临时配置为普通GPIO发送9个SCL脉冲然后发送一个STOP条件再重新配置为I2C复用模式。这段代码我放在MX_I2C1_Init()之前调用void I2C_BusRecovery(void) { GPIO_InitTypeDef GPIO_InitStruct {0}; // 配置SCL和SDA为普通开漏输出 __HAL_RCC_GPIOB_CLK_ENABLE(); GPIO_InitStruct.Pin GPIO_PIN_6 | GPIO_PIN_7; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_OD; GPIO_InitStruct.Pull GPIO_PULLUP; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, GPIO_InitStruct); // 发送9个时钟脉冲 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); } // 发送STOP条件 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET); HAL_Delay(1); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET); HAL_Delay(1); }6.2 读写数据错位或返回全0xFF如果读取AT24C02返回的数据全是0xFF通常有三种可能第一种是设备地址错误。AT24C02的7位地址是0x50但HAL库要求传入8位地址0xA0。如果你传了0x50HAL库会把它当作8位地址处理实际发送的地址就变成了0x50AT24C02不会响应。第二种是上拉电阻缺失或阻值过大。没有上拉电阻时SDA和SCL无法拉高I2C通信根本无法进行。用万用表测量SDA和SCL对VCC的电阻正常应该在4.7kΩ左右。第三种是WP引脚被拉高。WP接高电平时AT24C02处于写保护状态写入操作会被忽略但读取应该正常。如果读取也返回0xFF说明问题不在WP引脚。6.3 页写入跨页导致数据覆盖前面已经提到过页写入不能跨页。我整理了一个快速排查表现象可能原因排查方法写入后读取数据错位页写入跨页检查写入起始地址和长度计算是否跨页部分数据写入成功部分失败写周期未等待完成在每次写入后调用AT24C02_WaitReady读取数据全为0xFF设备地址错误或上拉电阻缺失用逻辑分析仪抓波形检查地址字节写入后立即读取数据不对写周期未完成增加HAL_Delay(10)或使用应答查询总线BUSY标志无法清除I2C状态机卡死执行I2C_BusRecovery后重新初始化6.4 电源噪声导致通信失败AT24C02对电源噪声比较敏感尤其是在写周期期间。如果电源纹波较大可能导致内部写入失败或数据错误。我在实际项目中遇到过一个问题电机启动时AT24C02的写入成功率从100%降到约70%。解决方案是在AT24C02的VCC和GND之间并联一个0.1μF的陶瓷电容位置尽量靠近芯片引脚。如果条件允许再并联一个10μF的钽电容。这个改动成本不到一毛钱但效果立竿见影——加了电容之后电机启动时的写入成功率恢复到100%。6.5 读取速度优化技巧如果项目中需要频繁读取EEPROM数据可以考虑以下优化第一减少不必要的写入。EEPROM的写入寿命是100万次虽然看起来很多但如果每秒钟写一次不到12天就用完了。我通常会在RAM中维护一份数据副本只在数据真正变化时才写入EEPROM。第二使用顺序读取。如果需要读取连续地址的数据用一次HAL_I2C_Mem_Read读取多个字节比多次调用单字节读取函数效率高得多。因为每次单字节读取都需要发送START、设备地址、字地址、重复START、设备地址开销很大。第三适当提高I2C时钟。AT24C02支持400kHz快速模式如果总线电容较小短走线、少设备可以尝试提高到400kHz。但要注意提高时钟频率后上拉电阻可能需要相应减小。7. 从AT24C02延伸到其他I2C器件的通用方法掌握了AT24C02的读写之后你会发现其他I2C器件如OLED显示屏、温度传感器、数字电位器的驱动逻辑大同小异。核心都是发送START → 设备地址写→ 寄存器地址 → 数据 → STOP或者START → 设备地址写→ 寄存器地址 → 重复START → 设备地址读→ 读取数据 → NACK → STOP。以SSD1306 OLED为例它的I2C地址是0x3C7位写命令时先发送0x00再发送命令字节写数据时先发送0x40再发送数据字节。这个“控制字节数据”的格式和AT24C02的“字地址数据”格式在本质上是一样的——都是先告诉从机接下来要做什么然后传输具体内容。我建议你在调通AT24C02之后立刻找一块I2C OLED或者I2C温度传感器如LM75、TMP102练手。因为这时候你对I2C的时序、HAL库函数、常见问题都有了直观认识再学其他器件会快很多。我自己的经验是AT24C02调通后调OLED只花了不到一个小时因为大部分坑已经在AT24C02上踩过了。最后分享一个我常用的调试技巧用逻辑分析仪抓取I2C波形时把触发条件设置为“地址字节不等于0xA0”。这样当总线上出现异常设备地址或者噪声干扰时逻辑分析仪会立即触发帮你快速定位问题。这个技巧在调试多设备I2C总线时特别有用能帮你排除掉大量无关的正常通信波形。
返回列表