STM32 I2C实战:AT24C02 EEPROM从入门到精通

发布时间:2026/10/21 1:08:52
STM32 I2C实战:AT24C02 EEPROM从入门到精通 1. 为什么AT24C02是I2C入门的最佳陪练如果你刚开始接触STM32的I2C外设手头又没有逻辑分析仪那我建议你第一个练手的目标器件就选AT24C02。这颗2Kbit256字节的EEPROM芯片几乎把I2C通信里所有容易踩的坑都浓缩在了一个极小的场景里7位地址、写周期等待、页写边界、应答位检测一个都不少。而且它便宜、皮实、接线简单就算你把SDA和SCL接反了它也不会烧顶多是不应答。我见过太多人在学I2C的时候一上来就去驱动OLED屏幕或者MPU6050结果被一堆寄存器配置和初始化时序搞得晕头转向最后连起始条件到底长什么样都没搞清楚。AT24C02的好处在于它的数据手册只有二十来页读写时序图清晰明了你完全可以在一个下午的时间里从GPIO模拟I2C开始一路做到硬件I2C的DMA传输把整个链路吃透。这篇文章我会按照实际调试的顺序来写先讲清楚AT24C02的地址和存储结构再对比GPIO模拟和硬件I2C两种方式的取舍然后给出完整的读写流程和代码框架最后重点讲那些数据手册上不会写、但实际调试中一定会遇到的问题。不管你是用标准库还是HAL库是F103C8T6还是其他型号核心逻辑都是通用的。注意本文所有代码基于STM32F103系列和AT24C02但I2C的通信原理和调试思路适用于任何具备I2C外设的MCU和任何兼容的EEPROM器件。2. AT24C02的地址、页与写周期三个必须刻在脑子里的参数2.1 7位设备地址的固定位与可编程位AT24C02的设备地址是7位格式是1010 A2 A1 A0。其中高四位1010是Atmel现在归Microchip给24系列EEPROM分配的固定标识你改不了。低三位A2、A1、A0对应芯片上的三个地址引脚接GND就是0接VCC就是1。这意味着同一条I2C总线上最多可以挂8片AT24C02地址范围从0x50到0x57。这里有个新手最容易犯的错误把7位地址直接当成8位地址往寄存器里写。STM32的硬件I2C外设要求你写入的是左对齐的8位地址也就是7位地址左移一位最低位是读写位。比如设备地址是0x507位那么写操作时你要发送的字节是0xA0读操作时是0xA1。如果你直接把0x50写进去STM32会认为你要操作的是地址0x28的设备自然不会有任何应答。我在早期调试时就被这个问题卡了半天用示波器看波形发现SCL和SDA都有动作但就是没有ACK。后来逐位对时序图才发现地址字节发错了。所以我的建议是在代码里统一用宏定义来管理地址写操作和读操作分开定义避免手动移位出错。#define AT24C02_ADDR_W 0xA0 #define AT24C02_ADDR_R 0xA12.2 页写边界8字节一页的硬性约束AT24C02的内部存储阵列是32页每页8字节总共256字节。这个页的概念在写操作时至关重要。当你执行写操作时芯片内部有一个3位的页地址计数器低3位在每写入一个字节后自动加1高5位保持不变。这意味着如果你从页内偏移6的位置开始连续写4个字节写到第2个字节时计数器就会从7回绕到0把数据写到本页的开头而不是下一页。举个例子你想从地址0x06开始写入0x11, 0x22, 0x33, 0x44四个字节。实际写入的结果是0x06写0x110x07写0x22然后计数器回绕0x00写0x330x01写0x44。你原本想写的数据被拆散到了两个地方而且覆盖了原来的内容。这个坑我在做一个参数存储功能时踩过当时要保存一个12字节的结构体我直接从结构体首地址开始连续写结果每次重启后读出来的数据都是乱的。后来才意识到结构体的起始地址没有对齐到8字节边界。解决办法很简单要么在写之前计算好跨页边界分多次写要么干脆每次只写一个字节虽然慢但绝对不会错。对于参数存储这种低频操作我通常选择后者代码简单可靠性高。2.3 写周期时间5ms的等待不是可选项AT24C02在收到一个写命令和停止条件后会启动内部写周期把数据从缓冲区写入EEPROM存储单元。这个过程中芯片不会响应任何I2C命令直到写周期完成。数据手册给出的最大写周期时间是5ms。如果你在5ms内又发起一次写操作芯片不会应答你的程序如果没做超时处理就会卡死在等待ACK的循环里。很多教程只告诉你写完之后延时一下但没说清楚这个延时的本质是什么。它不是为了让信号稳定而是给芯片内部电荷泵足够的时间把数据烧录进去。我实测过几批不同厂家的AT24C02有的3ms就完成了有的确实需要接近5ms。所以保险起见统一延时5ms或者用应答轮询的方式写完一个字节后反复发送起始条件设备地址直到收到ACK为止说明芯片内部写周期结束了。应答轮询的代码实现如下void AT24C02_WaitReady(void) { uint32_t timeout 0; do { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_W, I2C_Direction_Transmitter); timeout; } while(I2C_GetFlagStatus(I2C1, I2C_FLAG_AF) timeout 10000); I2C_ClearFlag(I2C1, I2C_FLAG_AF); I2C_GenerateSTOP(I2C1, ENABLE); }这种方式比固定延时更高效尤其是在批量写入时可以节省大量等待时间。3. GPIO模拟I2C与硬件I2C的选型对比3.1 什么时候该用GPIO模拟GPIO模拟I2C的最大优势是引脚灵活。STM32F103的硬件I2C1固定在PB6/PB7I2C2固定在PB10/PB11如果你画板子的时候不小心把EEPROM接到了其他引脚上硬件I2C就用不了了。这时候GPIO模拟就是唯一的出路。另外STM32F103早期的硬件I2C外设确实存在一些已知的缺陷在某些极端情况下会出现总线锁死或者状态机卡住的问题。虽然ST在后来的勘误手册里给出了规避方法但如果你不想折腾GPIO模拟反而更省心。GPIO模拟的另一个好处是调试方便。你可以在任意位置插入延时来观察波形也可以用示波器逐位分析时序。对于学习I2C协议本身来说GPIO模拟能让你真正理解每一个时钟脉冲和数据位的关系而不是把一切都交给硬件外设。但GPIO模拟的缺点也很明显占用CPU时间。每一个位的翻转都需要CPU参与在100kHz的标准速率下传输一个字节大约需要90us如果加上延时和函数调用开销实际可能超过150us。对于高速率或大数据量的场景这个开销是不可接受的。3.2 硬件I2C的配置要点与常见问题硬件I2C的配置看起来复杂但一旦跑通就很稳定。以标准库为例关键配置项包括时钟频率、占空比、自身地址、应答使能等。这里我重点讲几个容易出问题的地方。首先是GPIO模式。I2C的SDA和SCL必须配置为复用开漏输出AF_OD并且外部要接上拉电阻。我见过有人配置成推挽输出结果两个设备同时拉低时电流过大长期运行后引脚损坏。开漏输出配合上拉电阻才能实现真正的线与逻辑。其次是时钟频率的计算。STM32F103的I2C时钟源是PCLK1默认是36MHz。标准库的I2C_Init函数会根据你传入的I2C_ClockSpeed自动计算CCR寄存器的值。但如果你用的是HAL库需要注意ClockSpeed参数的单位是Hz而不是kHz。我曾经把100000写成了100结果I2C速率变成了几百Hz读写一个字节要等好几毫秒。还有一个隐蔽的问题硬件I2C的状态标志清除顺序。在发送起始条件后必须等待SB标志置位然后才能发送地址。发送地址后必须等待ADDR标志置位然后清除该标志才能继续发送数据。如果顺序错了或者漏掉了清除步骤状态机会卡在某个中间状态表现为SCL一直被拉低总线死锁。3.3 两种方式的性能实测对比我在同一块F103C8T6板子上分别用GPIO模拟和硬件I2C做了测试主频72MHzI2C速率100kHz连续写入256字节分32页每页8字节每页之间等待5ms写周期。对比项GPIO模拟硬件I2C单字节写入耗时约180us约120us256字节总耗时含写周期约210ms约190msCPU占用率高全程参与低可中断/DMA引脚灵活性任意GPIO固定引脚调试难度低可逐位观察中状态机复杂总线锁死风险低中需超时处理从数据可以看出在100kHz速率下两者的差距并不大主要时间都花在了5ms的写周期等待上。但如果把速率提到400kHz硬件I2C的优势就会明显体现出来GPIO模拟很难稳定跑到400kHz。我的建议是如果是学习目的或者引脚受限用GPIO模拟如果是产品开发且引脚固定用硬件I2C但一定要加超时处理和总线恢复机制。4. 从零实现AT24C02读写完整流程与代码框架4.1 单字节写入的时序拆解单字节写入的完整时序是起始条件 - 设备地址写位 - 等待ACK - 字地址 - 等待ACK - 数据字节 - 等待ACK - 停止条件 - 等待写周期。每一步都有对应的状态标志需要检查。以标准库为例起始条件后等待I2C_EVENT_MASTER_MODE_SELECT发送地址后等待I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED发送数据后等待I2C_EVENT_MASTER_BYTE_TRANSMITTED。这些事件宏实际上封装了多个标志位的组合判断用起来比直接操作寄存器方便得多。这里有一个细节在发送停止条件之前必须确保最后一个字节的BTFByte Transfer Finished标志已经置位否则停止条件可能会在数据传输完成前被发出导致数据丢失。标准库的I2C_EVENT_MASTER_BYTE_TRANSMITTED事件已经包含了BTF判断所以直接等待这个事件是安全的。void AT24C02_WriteByte(uint8_t addr, uint8_t data) { // 发送起始条件 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); // 发送设备地址写 I2C_Send7bitAddress(I2C1, AT24C02_ADDR_W, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); // 发送字地址 I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 发送数据 I2C_SendData(I2C1, data); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // 发送停止条件 I2C_GenerateSTOP(I2C1, ENABLE); // 等待写周期完成 AT24C02_WaitReady(); }4.2 单字节读取的伪写操作读操作比写操作多了一个步骤你需要先执行一次伪写把要读取的字地址发送给芯片然后再发送起始条件Restart和设备地址读位才能开始读取数据。这个伪写的目的是设置芯片内部的地址计数器。很多初学者不理解为什么读之前要先写。其实AT24C02内部有一个地址计数器每次读写操作后它会自动加1。上电时这个计数器的值是0如果你直接发读命令读到的就是地址0的数据。只有通过伪写操作才能把计数器设置到你想要的地址。伪写操作和真正的写操作的区别在于伪写不发送数据字节也不发送停止条件而是直接发送Restart。Restart条件看起来和起始条件很像但它在时序上是在SCL为高时把SDA从高拉低而不是像起始条件那样在SCL为高时把SDA从高拉低实际上两者波形相同区别在于发生在停止条件之后还是数据传输过程中。uint8_t AT24C02_ReadByte(uint8_t addr) { uint8_t data; // 伪写设置字地址 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_W, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // Restart发送设备地址读 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_R, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); // 读取数据发送NACK表示不再继续读 I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); data I2C_ReceiveData(I2C1); // 恢复ACK使能为下次通信做准备 I2C_AcknowledgeConfig(I2C1, ENABLE); return data; }注意读取最后一个字节前要发送NACK告诉芯片我不需要更多数据了然后发送停止条件。如果发送的是ACK芯片会继续输出下一个地址的数据而你可能没有准备好接收导致总线状态混乱。4.3 页写入与跨页处理页写入的时序和单字节写入类似只是在发送字地址后连续发送多个数据字节最多8个。但正如前面所说如果起始地址不是页对齐的数据会在页内回绕。所以安全的页写入函数应该先计算当前页还能写多少字节然后分多次写入。void AT24C02_WritePage(uint8_t addr, uint8_t *data, uint8_t len) { uint8_t page_remain 8 - (addr % 8); uint8_t write_len (len page_remain) ? page_remain : len; // 写入当前页剩余空间 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_W, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); for(uint8_t i 0; i write_len; i) { I2C_SendData(I2C1, data[i]); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); } I2C_GenerateSTOP(I2C1, ENABLE); AT24C02_WaitReady(); // 如果还有剩余数据递归写入下一页 if(len write_len) { AT24C02_WritePage(addr write_len, data write_len, len - write_len); } }这个递归实现简洁明了但要注意递归深度。对于AT24C02的256字节最多递归32次栈空间完全够用。如果你用的是其他型号的EEPROM页大小可能不同需要相应调整。4.4 连续读取的地址回绕特性AT24C02的读操作有一个很方便的特性地址计数器在到达255后会自动回绕到0。这意味着你可以从任意地址开始连续读取任意长度的数据不需要担心中途地址溢出。这个特性在读取整个存储区时非常有用。void AT24C02_ReadBuffer(uint8_t addr, uint8_t *buf, uint16_t len) { // 伪写设置起始地址 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_W, I2C_Direction_Transmitter); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_TRANSMITTER_MODE_SELECTED)); I2C_SendData(I2C1, addr); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_TRANSMITTED)); // Restart切换为读模式 I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, AT24C02_ADDR_R, I2C_Direction_Receiver); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_RECEIVER_MODE_SELECTED)); for(uint16_t i 0; i len; i) { if(i len - 1) { // 最后一个字节发送NACK I2C_AcknowledgeConfig(I2C1, DISABLE); I2C_GenerateSTOP(I2C1, ENABLE); } while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); buf[i] I2C_ReceiveData(I2C1); } I2C_AcknowledgeConfig(I2C1, ENABLE); }这段代码的关键在于最后一个字节的处理在读取之前就要把ACK禁用并发出停止条件而不是读完之后再处理。因为停止条件需要在NACK之后发出如果顺序反了芯片可能会认为你还要继续读。5. 调试中一定会遇到的五个坑与排查思路5.1 上拉电阻阻值选错导致波形畸变I2C总线的上拉电阻不是随便选一个4.7k就完事了。阻值的选择取决于总线电容和通信速率。总线电容包括PCB走线电容、引脚电容和器件电容通常在10pF到100pF之间。上拉电阻和总线电容构成RC电路上升时间约为0.847 * R * C从0.3VCC到0.7VCC。以100kHz速率为例上升时间不能超过1us。如果总线电容是50pF那么R不能超过1us / (0.847 * 50pF) ≈ 23.6k。但阻值也不能太小否则低电平时灌电流过大超过器件的驱动能力。AT24C02的SDA引脚最大灌电流是3mA在3.3V供电下R不能小于3.3V / 3mA 1.1k。所以4.7k是一个比较折中的选择适用于大多数场景。但如果你的总线走线很长电容超过100pF4.7k可能就不够了需要降到2.2k甚至1k。我遇到过一块板子EEPROM离MCU有15cm远用4.7k上拉时读写偶尔出错换成2.2k后问题消失。用示波器看波形4.7k时的上升沿明显变缓接近1.5us已经超出了100kHz的时序要求。5.2 地址引脚悬空导致的设备地址不确定AT24C02的A0、A1、A2引脚不能悬空。悬空时引脚电平不确定可能被外部噪声干扰导致设备地址随机变化。我见过一个案例某块板子上A0引脚没有接任何东西上电后有时能读写有时不能。用示波器测量A0引脚发现它浮在一个中间电平上大约1.6V。这种情况下芯片内部的地址比较器会处于亚稳态设备地址在0x50和0x51之间随机切换。解决办法很简单把不用的地址引脚明确接地或接VCC。如果板子上没有预留下拉电阻可以直接用镊子短接引脚到GND但这不是长久之计。正规的做法是在PCB设计时就加上10k的下拉或上拉电阻确保地址引脚有确定的电平。5.3 写周期未等待导致的随机写入失败前面讲过写周期是5ms但实际调试中这个问题的表现往往不是每次都失败而是偶尔失败。因为芯片内部写周期的实际时间受温度、电压和制造工艺影响可能在3ms到5ms之间波动。如果你的延时是3ms大部分时候能成功但偶尔会失败。这种随机性故障最难排查因为你很难复现。我的建议是不要用固定延时用应答轮询。应答轮询不仅能适应不同批次的芯片还能在写周期完成后立即继续提高效率。如果非要用延时至少给到6ms留出足够的余量。5.4 总线死锁与恢复机制I2C总线死锁的典型表现是SCL或SDA被某个设备一直拉低主机无法发出起始条件。这种情况通常发生在通信过程中主机复位或断电从机还在等待时钟脉冲把SDA拉低表示我还没发完。主机重新上电后发现SDA是低的无法产生起始条件。恢复的方法是在SCL上手动发送9个时钟脉冲让从机把剩余的数据位发完然后发送停止条件。具体操作是把SCL配置为GPIO输出SDA配置为输入然后手动翻转SCL 9次最后发送一个停止条件。这个恢复函数应该放在I2C初始化之前调用确保总线处于空闲状态。void I2C_BusRecovery(void) { GPIO_InitTypeDef gpio; // SCL配置为推挽输出SDA配置为浮空输入 gpio.GPIO_Pin GPIO_Pin_6; gpio.GPIO_Mode GPIO_Mode_Out_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, gpio); gpio.GPIO_Pin GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOB, gpio); // 发送9个时钟脉冲 for(int i 0; i 9; i) { GPIO_ResetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); } // 发送停止条件SCL高时SDA从低变高 gpio.GPIO_Pin GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOB, gpio); GPIO_ResetBits(GPIOB, GPIO_Pin_7); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_6); delay_us(5); GPIO_SetBits(GPIOB, GPIO_Pin_7); delay_us(5); // 重新配置为I2C复用开漏 gpio.GPIO_Pin GPIO_Pin_6 | GPIO_Pin_7; gpio.GPIO_Mode GPIO_Mode_AF_OD; GPIO_Init(GPIOB, gpio); }5.5 电源电压与I2C电平的匹配问题AT24C02的工作电压范围是1.8V到5.5V看起来很宽但I2C总线的电平取决于供电电压。如果你用3.3V的MCU去驱动5V供电的AT24C02SDA和SCL的高电平只有3.3V而AT24C02的VIH输入高电平阈值是0.7*VCC3.5V3.3V达不到这个阈值通信会不稳定甚至完全失败。反过来如果AT24C02用3.3V供电MCU用5V虽然AT24C02能识别5V的高电平但5V可能会超过AT24C02引脚的绝对最大额定值通常是VCC0.3V长期运行会损坏芯片。所以最稳妥的方案是MCU和EEPROM使用相同的供电电压。如果必须混用需要在总线上加电平转换电路或者至少确认两边的VIH/VIL参数匹配。我见过有人用5V的Arduino直接连3.3V的AT24C02刚开始能用几天后EEPROM就坏了就是因为5V的高电平超过了3.3V供电时的引脚耐压值。6. 进阶优化DMA传输与数据完整性校验6.1 用DMA解放CPU当需要读写大量数据时硬件I2C配合DMA可以大幅降低CPU占用。以读取256字节为例用轮询方式CPU要全程参与而用DMA只需要配置好传输长度CPU就可以去处理其他任务传输完成后DMA产生中断通知。配置DMA的关键是设置正确的通道和外设地址。STM32F103的I2C1_TX对应DMA1通道6I2C1_RX对应DMA1通道7。传输方向是外设到存储器读或存储器到外设写数据宽度都是8位。需要注意的是I2C的DMA请求是在TXE或RXNE标志置位时产生的所以DMA传输完成后还要等待BTF标志置位才能发送停止条件。void AT24C02_ReadDMA(uint8_t addr, uint8_t *buf, uint16_t len) { // 伪写设置地址同前略 // 配置DMA DMA_InitTypeDef dma; DMA_DeInit(DMA1_Channel7); dma.DMA_PeripheralBaseAddr (uint32_t)I2C1-DR; dma.DMA_MemoryBaseAddr (uint32_t)buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize len; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_Byte; dma.DMA_MemoryDataSize DMA_MemoryDataSize_Byte; dma.DMA_Mode DMA_Mode_Normal; dma.DMA_Priority DMA_Priority_High; dma.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel7, dma); // 使能I2C的DMA请求 I2C_DMACmd(I2C1, ENABLE); DMA_Cmd(DMA1_Channel7, ENABLE); // 等待DMA传输完成 while(!DMA_GetFlagStatus(DMA1_FLAG_TC7)); DMA_ClearFlag(DMA1_FLAG_TC7); // 等待BTF发送停止条件 while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_BYTE_RECEIVED)); I2C_GenerateSTOP(I2C1, ENABLE); I2C_DMACmd(I2C1, DISABLE); }DMA方式在读写大量数据时优势明显但要注意DMA传输完成不等于I2C传输完成还需要等待BTF标志。另外DMA传输过程中如果出现错误需要及时关闭DMA和I2C避免总线卡死。6.2 数据完整性校验和与双备份EEPROM虽然号称可以擦写100万次数据保存100年但在实际应用中电源波动、静电放电、宇宙射线都可能导致数据位翻转。对于存储关键参数的应用建议加上校验和或者双备份机制。最简单的校验和是累加和把所有字节相加取低8位作为校验值存在最后一个字节。读取时重新计算并比较。如果校验失败说明数据损坏可以恢复到默认值或者从备份区读取。双备份则是把同一份数据存两个地方比如地址0x00和0x80各存一份。读取时比较两份数据如果一致就使用不一致则用校验和判断哪份是正确的。如果两份都损坏就只能用默认值了。typedef struct { uint8_t data[6]; uint8_t checksum; } ParamBlock; void SaveParams(ParamBlock *p) { p-checksum 0; for(int i 0; i 6; i) { p-checksum p-data[i]; } AT24C02_WritePage(0x00, (uint8_t*)p, sizeof(ParamBlock)); AT24C02_WritePage(0x80, (uint8_t*)p, sizeof(ParamBlock)); } uint8_t LoadParams(ParamBlock *p) { ParamBlock p1, p2; AT24C02_ReadBuffer(0x00, (uint8_t*)p1, sizeof(ParamBlock)); AT24C02_ReadBuffer(0x80, (uint8_t*)p2, sizeof(ParamBlock)); uint8_t sum1 0, sum2 0; for(int i 0; i 6; i) { sum1 p1.data[i]; sum2 p2.data[i]; } if(sum1 p1.checksum sum2 p2.checksum) { // 两份都有效比较是否一致 if(memcmp(p1, p2, sizeof(ParamBlock)) 0) { memcpy(p, p1, sizeof(ParamBlock)); return 0; } } if(sum1 p1.checksum) { memcpy(p, p1, sizeof(ParamBlock)); return 1; } if(sum2 p2.checksum) { memcpy(p, p2, sizeof(ParamBlock)); return 2; } return 3; // 两份都损坏 }这套机制虽然简单但在实际产品中非常有效。我做过一个统计在电源不稳定的环境下EEPROM数据年损坏率大约在0.1%左右加了双备份和校验和后系统从未因为数据损坏而出现异常。6.3 磨损均衡延长EEPROM寿命的实用技巧AT24C02的擦写寿命是100万次看起来很多但如果你每秒写一次不到12天就用完了。对于需要频繁记录数据的应用比如每分钟记录一次传感器数据一天就是1440次一年就是52万次两年就接近寿命极限了。磨损均衡的思路是不要每次都写同一个地址而是轮流写到不同的页。维护一个写指针每次写入后指针加1到达末尾后回绕。读取时从指针的前一个位置读最新数据。这样每个页的擦写次数就均匀分布了整体寿命可以延长32倍32页。#define WEAR_PAGE_COUNT 32 #define WEAR_PAGE_SIZE 8 uint8_t wear_index 0; void WearLevel_Write(uint8_t *data, uint8_t len) { uint8_t addr wear_index * WEAR_PAGE_SIZE; AT24C02_WritePage(addr, data, len); wear_index (wear_index 1) % WEAR_PAGE_COUNT; } void WearLevel_Read(uint8_t *data, uint8_t len) { uint8_t last (wear_index 0) ? (WEAR_PAGE_COUNT - 1) : (wear_index - 1); uint8_t addr last * WEAR_PAGE_SIZE; AT24C02_ReadBuffer(addr, data, len); }这个方案需要额外记录wear_index可以存在EEPROM的固定位置或者用RTC备份寄存器保存。如果连wear_index也不想存可以在每页数据中加一个序号读取时找序号最大的那页。7. 我的调试工具箱与经验总结调试I2C问题光靠printf是不够的。我最常用的工具是一个几十块钱的逻辑分析仪配合开源的上位机软件可以实时抓取SDA和SCL的波形自动解析出起始条件、地址、数据和ACK/NACK。有了它大部分通信问题都能在几分钟内定位。如果没有逻辑分析仪示波器也能凑合。用双通道分别接SDA和SCL触发方式设为SCL下降沿可以观察到数据位的建立和保持时间。重点看起始条件和停止条件的波形是否干净有没有过冲或振铃。万用表则用来检查硬件连接SDA和SCL对地电阻是否正常应该在几k到几十k之间上拉电阻是否焊接地址引脚电平是否正确。我遇到过好几次问题都是因为虚焊用万用表一量就发现了。最后分享一个我个人的习惯在代码里加一个I2C扫描函数上电时自动扫描总线上所有可能的地址把应答的设备地址打印出来。这个函数只需要几十行代码但能快速确认硬件连接和地址配置是否正确。void I2C_Scan(void) { printf(I2C Scan:\n); for(uint8_t addr 0x00; addr 0x80; addr) { I2C_GenerateSTART(I2C1, ENABLE); while(!I2C_CheckEvent(I2C1, I2C_EVENT_MASTER_MODE_SELECT)); I2C_Send7bitAddress(I2C1, addr 1, I2C_Direction_Transmitter); if(I2C_GetFlagStatus(I2C1, I2C_FLAG_AF)) { I2C_ClearFlag(I2C1, I2C_FLAG_AF); } else { printf( Found: 0x%02X\n, addr); } I2C_GenerateSTOP(I2C1, ENABLE); delay_ms(1); } }这个扫描函数在调试新板子时特别有用。如果扫描不到任何设备说明硬件有问题如果扫描到了但地址不对说明地址引脚配置错了如果扫描到了正确的地址但读写失败那就是时序或协议的问题了。按照这个思路排查基本不会走弯路。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询