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

1. 为什么AT24C02是I2C入门的最佳陪练

如果你刚开始接触STM32的I2C外设,手头又没有逻辑分析仪,那我建议你第一个练手的目标器件就选AT24C02。这颗2Kbit(256字节)的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位地址左移一位,最低位是读写位。比如设备地址是0x50(7位),那么写操作时你要发送的字节是0xA0,读操作时是0xA1。如果你直接把0x50写进去,STM32会认为你要操作的是地址0x28的设备,自然不会有任何应答。

我在早期调试时就被这个问题卡了半天,用示波器看波形发现SCL和SDA都有动作,但就是没有ACK。后来逐位对时序图才发现,地址字节发错了。所以我的建议是:在代码里统一用宏定义来管理地址,写操作和读操作分开定义,避免手动移位出错。

#define AT24C02_ADDR_W 0xA0 #define AT24C02_ADDR_R 0xA1

2.2 页写边界:8字节一页的硬性约束

AT24C02的内部存储阵列是32页,每页8字节,总共256字节。这个"页"的概念在写操作时至关重要。当你执行写操作时,芯片内部有一个3位的页地址计数器,低3位在每写入一个字节后自动加1,高5位保持不变。这意味着如果你从页内偏移6的位置开始连续写4个字节,写到第2个字节时计数器就会从7回绕到0,把数据写到本页的开头,而不是下一页。

举个例子:你想从地址0x06开始写入0x11, 0x22, 0x33, 0x44四个字节。实际写入的结果是:0x06写0x11,0x07写0x22,然后计数器回绕,0x00写0x33,0x01写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/PB7,I2C2固定在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做了测试,主频72MHz,I2C速率100kHz,连续写入256字节(分32页,每页8字节,每页之间等待5ms写周期)。

对比项GPIO模拟硬件I2C
单字节写入耗时约180us约120us
256字节总耗时(含写周期)约210ms约190ms
CPU占用率高(全程参与)低(可中断/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。这些事件宏实际上封装了多个标志位的组合判断,用起来比直接操作寄存器方便得多。

这里有一个细节:在发送停止条件之前,必须确保最后一个字节的BTF(Byte 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是一个比较折中的选择,适用于大多数场景。但如果你的总线走线很长,电容超过100pF,4.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供电的AT24C02,SDA和SCL的高电平只有3.3V,而AT24C02的VIH(输入高电平阈值)是0.7*VCC=3.5V,3.3V达不到这个阈值,通信会不稳定甚至完全失败。

反过来,如果AT24C02用3.3V供电,MCU用5V,虽然AT24C02能识别5V的高电平,但5V可能会超过AT24C02引脚的绝对最大额定值(通常是VCC+0.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通道6,I2C1_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); } }

这个扫描函数在调试新板子时特别有用。如果扫描不到任何设备,说明硬件有问题;如果扫描到了但地址不对,说明地址引脚配置错了;如果扫描到了正确的地址但读写失败,那就是时序或协议的问题了。按照这个思路排查,基本不会走弯路。

相关新闻

AI眼镜第一股背后:芯片高管、光波导与端侧算力的产业博弈

AI眼镜第一股背后:芯片高管、光波导与端侧算力的产业博弈

我第一次听到“AI眼镜第一股”这个说法时&#xff0c;脑子里冒出来的第一个疑问不是“能不能成”&#xff0c;而是“凭什么是他”。当时的情况也确实有点戏剧性&#xff1a;一位在大厂体系里打拼多年的前高管&#xff0c;前脚刚从某国际芯片巨头离职&#xff0c;后脚就对外放风…

2026/10/21 1:08:52 阅读更多 →
汽车标准体系四层结构解析:从法规到项目规范,避开认证踩坑

汽车标准体系四层结构解析:从法规到项目规范,避开认证踩坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 1:07:50 阅读更多 →
电流型电压互感器隔离采样电路设计:从原理到PCB实操

电流型电压互感器隔离采样电路设计:从原理到PCB实操

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 1:07:50 阅读更多 →

最新新闻

douyin-downloader 教程:从扫码登录到抖音主页作品批量去水印下载

douyin-downloader 教程:从扫码登录到抖音主页作品批量去水印下载

douyin-downloader 教程&#xff1a;从扫码登录到抖音主页作品批量去水印下载 【免费下载链接】douyin-downloader A practical Douyin downloader for both single-item and profile batch downloads, with progress display, retries, SQLite deduplication, and browser fal…

2026/10/21 1:43:29 阅读更多 →
KNECC608 中 SecureBoot 机制详解

KNECC608 中 SecureBoot 机制详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 1:43:29 阅读更多 →
Jasna 完整指南:从显卡检查到出片,免费实现 JAV 视频马赛克修复

Jasna 完整指南:从显卡检查到出片,免费实现 JAV 视频马赛克修复

Jasna 完整指南&#xff1a;从显卡检查到出片&#xff0c;免费实现 JAV 视频马赛克修复 【免费下载链接】jasna Video restoration tool 项目地址: https://gitcode.com/GitHub_Trending/ja/jasna 手上有带马赛克的视频想清理&#xff0c;但先要确认两件事&#xff1a;显…

2026/10/21 1:43:29 阅读更多 →
东芝双通道标准数字隔离器:低功耗工业隔离选型与设计指南

东芝双通道标准数字隔离器:低功耗工业隔离选型与设计指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 1:43:29 阅读更多 →
ESP-Mosaico模块化协作框架:解决嵌入式多模块开发痛点

ESP-Mosaico模块化协作框架:解决嵌入式多模块开发痛点

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/21 1:43:29 阅读更多 →
点了「应用」却没生效?dsh-purge 提示词注入机制与规则集切换实用指南

点了「应用」却没生效?dsh-purge 提示词注入机制与规则集切换实用指南

点了「应用」却没生效?dsh-purge 提示词注入机制与规则集切换实用指南 【免费下载链接】dsh-purge DeepSeek Harness 破甲&#xff1a;让所有模型都能破甲&#xff0c;不同模型可换不同提示词&#xff1b;默认提示词面向国模「小码酱」。Jailbreak for every model — swap pr…

2026/10/21 1:42:28 阅读更多 →

日新闻

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

统一内存+量化:锐龙AI Max PRO 400如何本地跑320B大模型

这段时间硬件圈和AI圈都在传一个事&#xff1a;AMD锐龙AI Max PRO 400系列把最高192GB统一内存搬进了PC形态的产品里&#xff0c;按宣传口径&#xff0c;本地跑320B参数的大家伙不再是纸面概念。我第一反应倒不是“内存真大”&#xff0c;而是这话背后的含金量——它把过去只能…

2026/10/21 0:00:11 阅读更多 →
AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

AI助手深度使用体验:对话质量、长文本处理与指令遵循如何留住用户

1. 从“封号重开”这个行为说起&#xff1a;用户到底在追求什么“封号重开”这四个字&#xff0c;放在任何产品语境里都带着一股决绝的味道。一个账号从零开始积累使用记录、调教偏好、沉淀工作流&#xff0c;背后是大量的时间和精力成本。正常情况下&#xff0c;没有人愿意主动…

2026/10/21 0:00:11 阅读更多 →
ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

ROS中YOLOv3工程落地:通信同步、坐标变换与性能调优

1. 为什么在ROS里硬刚YOLOv3不是“炫技”&#xff0c;而是工程落地的必经之路我第一次在某高校机器人实验室看到那台搭载Jetson TX2的移动底盘原地打转、反复识别错垃圾桶时&#xff0c;就意识到&#xff1a;单纯跑通darknet_ros demo和真正让YOLOv3在ROS里“干活”&#xff0c…

2026/10/21 0:00:11 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型&#xff08;GMM&#xff09;的语音识别系统&#xff0c;很多人第一反应就是一堆数学公式&#xff0c;觉得门槛很高。但如果你真的在MATLAB里把它落地跑通&#xff0c;会发现核心其实就是三件事&#xff1a;把声音变成特征向量&#xff0c;用GMM给每个类别…

2026/10/20 10:02:13 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人&#xff0c;但落到具体操作上&#xff0c;无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程&#xff1a;对图像做反白、改调色板、彩色转灰度&#xff0c;最后把像素数据导出成txt文件。单看每一…

2026/10/20 20:43:59 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词&#xff0c;你会发现一个很有意思的现象&#xff1a;每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型&#xff0c;有人指的是运动控制系统&#xff0c;还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/20 2:50:50 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 10:02:08 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:17 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/20 9:50:45 阅读更多 →