简介:基于I²C总线的24LC512 EEPROM程序示例,面向嵌入式开发者和单片机学习者,代码结构简洁、注释详细,可直接加入项目使用。示例完整演示了从I²C接口初始化、从机地址匹配,到字节随机读写、地址指针递增、写周期等待及错误检查的全过程,并针对EEPROM写入耗时较长的特点加入延时处理,适合智能仪表、数据记录仪等需要非易失性存储的场合,亦可用于设备参数保存、系统配置存储等应用。压缩包共13个文件,除C语言源程序(主程序与模块实现)和头文件外,还包含IAR工程配置(.eww/.ewp)、调试描述(.ewd)以及编译调试生成的中间文件,可直接打开工程进行仿真验证,整体仅24KB。已有1036人学习下载。通过这套代码,开发者能快速掌握I²C时序和24LC512(512K位/64KB)的分页读写机制,并能方便地移植到其他微控制器平台,减少底层驱动开发工作量。资源虽小,但驱动逻辑完整,对初学者和实际项目都有参考价值。 做嵌入式项目这么多年,我有个很强烈的体会:凡是要存标定参数、设备序列号、历史告警记录、用户配置的项目,方案兜兜转转最后都会落到外挂EEPROM上,而24LC512就是这类“存得多、用得省心”的代名词。最近社区里好几个朋友都在伸手要这颗芯片的例程,诉求出奇一致——“别给我讲一堆理论,代码要能直接跑”。我上一款工业传感器仪表正好用的24LC512做数据记录,驱动器层代码到现在还在用,所以这篇直接把能复制的完整读写程序、页写边界处理、写周期等待逻辑全部整理出来。不管你是第一次用外部EEPROM,还是已经踩过坑回来搜例程,下面这套东西都能直接搬进工程。
1. 为什么说24LC512是嵌入式存储里最省心的选择
很多初学者不理解:单片机内部不是有Flash吗,为什么还要外挂一颗EEPROM?答案是使用方式和寿命逻辑完全不同。内部Flash通常按扇区擦除、按字或半字写入,写之前必须先擦,而擦写次数大多在1万到10万次这个量级。你用内部Flash去保存仪表每秒钟刷新一次的运行参数,一个月下来磨损就接近警戒线。EEPROM则天生支持按字节读写,不需要先擦除,Microchip标称的擦写寿命是100万次,数据保持时间在25摄氏度环境下是200年以上。这颗芯片定位就是“频繁小量保存关键数据”,和内部Flash构成互补关系。
容量上24LC512是512Kbit,换算下来正好64KB,也就是65536个字节。这个容量在外部EEPROM里属于中大型,放配置表、字库、标定曲线、故障记录都够用,还能把多份固件参数分区存放。
1.1 三种存储方案的真实差异
选型时经常有人拿RAM、内部Flash和24LC512对比,实际使用差别很大:
- RAM:读写最快,但掉电数据全没,只能做运行期缓存。
- 内部Flash:免多加芯片,可擦写寿命短,而且擦除粒度大,频繁小规模更新太浪费。
- 24LC512等I2C EEPROM:按字节写、掉电不丢、寿命长,缺点是写入速度受I2C总线频率限制,但参数保存这种低频操作根本无所谓。
实际项目里我通常把两者结合:运行时的临时数据放RAM,设备掉电前要把关键状态写进24LC512。频繁更新的磨损计数器、标定参数也放24LC512,心里踏实。
1.2 选型前必须看懂的芯片参数
24LC512有一组参数直接影响代码设计和稳定性,选型时先确认清楚:
| 参数 | 数值 | 说明 |
|---|---|---|
| 容量 | 512Kbit / 64KB / 65536字节 | 地址需要2字节 |
| I2C地址 | 1010 A2 A1 A0 | A0-A2引脚决定,最多级联8片 |
| 页写大小 | 128字节 | 页内连续写,跨页地址会回卷 |
| 写周期时间 | 典型2ms,最大5ms | 写完必须等待,否则下一个命令被忽略 |
| 电压范围 | 1.8V-5.5V | 24LC512后缀对应工业级,电压适应宽; |
| 最大总线频率 | 400kHz(常规)/ 1MHz(5V时) | 不要当漏极开路死等 |
这里最需要警觉的是“页写回卷”和“写周期等待”,这两个点都是实测中最容易翻车的地方,后面专门细说。芯片封装常见的是DIP-8、SOIC-8、TSSOP-8,我习惯选SOIC-8,回流焊方便,调试时用DIP转接板也容易飞线。
2. 先弄懂I2C帧格式:24LC512的地址和普通24C02不一样
I2C总线本身不难,难在器件手册里的时序细节。24LC512和常见的24C02最大的区别就在于:它需要两字节地址。别小看这个差异,不少人拿24C02的例程直接改,结果写进去的数据总是错位、读出全是FF,卡了好几天找不到原因。
2.1 设备地址字节与片选
24LC512的I2C从机地址格式是固定的:
- 高4位:固定1010
- 后3位:A2、A1、A0引脚电平
- 最低位:读/写标志,0表示写,1表示读
以A0=A1=A2全部接地为例,完整设备地址就是0b1010000。写操作用0xA0,读操作用0xA1。如果A0、A1、A2引脚接了不同的高电平,设备地址会跟着变,所以同一个I2C总线上最多能挂8片24LC512,每片的总容量就是64KB乘以8。很多人在电路上把A0-A2悬空,这是不可靠的,内部没有默认下拉,必须明确接GND或VCC,否则静电或噪声会让地址漂掉,导致总线丢失设备。
2.2 两字节地址:新手最大分水岭
24C02容量256字节,地址用一个字节就够了,发完设备地址再发一个地址字节就能操作。24LC512有65536字节,地址必须是16位,顺序是先高字节、再低字节。举个例子,想访问地址0x1234,I2C帧里要依次发送0x12、0x34。
这听起来简单,实际容易踩坑的有两处。第一,地址变量必须用16位无符号类型,如果用8位,访问地址超过255后高字节被截断,数据全写到低256字节里。第二,写代码时地址高低字节顺序不能搞反,先发高字节再发低字节,一旦反过来,芯片会把“高地址”当作要操作的页,读写区域完全错乱。
我见过最典型的错误,是把24LC512当24C02用,发完设备地址后只发一个字节地址,然后开始写数据。芯片本身不会报错,它会认为第二字节(也就是数据)是16位地址的低字节,然后数据全被吞进地址里,之后发的内容又变成了真正要写的数据,结果整片数据排列全部错位。调试这类问题最好的办法就是拿逻辑分析仪抓I2C时序,一眼就能看出帧格式对不对。
2.3 写周期与ACK Polling
任何写操作(单字节写或页写)完成后,24LC512会进入一段内部写周期,把数据真正写入存储阵列。这个期间芯片不响应任何I2C命令,典型时长2ms到5ms。代码上处理等待有两条路:
- 固定延时,每次都等5ms以上,简单粗暴,但写大量数据时效率很低;
- 用ACK Polling,这是官方推荐的做法。
ACK Polling的原理是:每次写操作结束后,芯片在内部写周期中不会拉低SDA线来回应ACK。你只要不断发送“START + 设备写地址”,直到芯片回一个ACK,就说明内部写周期结束,可以继续下一条命令。这个方式既不会等太久,也不会出现时序竞争。后续代码里我会把两个方案都写出来。
3. 可直接抄走的读写代码:从单字节到页写完整实现
下面这套代码是我在STM32的软I2C环境下实测通过的,不依赖特定厂商的HAL库,底层只要求提供SDA/SCL两个GPIO引脚的拉高、拉低和读取操作,移植到任何单片机平台都很顺利。为了方便理解,先给底层驱动,再给EEPROM操作函数,最后给一个完整验证例程。
3.1 先搭底层I2C接口
这里用GPIO模拟I2C,好处是非常直观,也方便在裸机工程里直接调试。SCL和SDA引脚需要外接上拉电阻,常规4.7k,总线频率高就换成2.2k甚至1k。引脚宏根据你自己的工程修改即可。
#include "stm32g0xx_hal.h" // 按实际MCU头文件修改 #include <string.h> #define IIC_SCL_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_SET) #define IIC_SCL_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_6, GPIO_PIN_RESET) #define IIC_SDA_H() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_SET) #define IIC_SDA_L() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_7, GPIO_PIN_RESET) #define IIC_SDA_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_7) #define EEPROM_ADDR_W 0xA0 #define EEPROM_ADDR_R 0xA1 #define EEPROM_PAGE_SIZE 128 static void iic_delay_us(void) { // 普通循环延时,频率约400kHz时,每次延时约1-2us for (volatile int i = 0; i < 8; i++) ; } static void iic_start(void) { IIC_SDA_H(); IIC_SCL_H(); iic_delay_us(); IIC_SDA_L(); iic_delay_us(); IIC_SCL_L(); iic_delay_us(); } static void iic_stop(void) { IIC_SDA_L(); IIC_SCL_H(); iic_delay_us(); IIC_SDA_H(); iic_delay_us(); } static uint8_t iic_write_byte(uint8_t byte) { for (int i = 7; i >= 0; i--) { if (byte & (1 << i)) IIC_SDA_H(); else IIC_SDA_L(); IIC_SCL_H(); iic_delay_us(); IIC_SCL_L(); iic_delay_us(); } // 释放SDA,等待从机ACK IIC_SDA_H(); iic_delay_us(); IIC_SCL_H(); iic_delay_us(); uint8_t ack = (IIC_SDA_READ() == 0) ? 0 : 1; // 0表示ACK IIC_SCL_L(); iic_delay_us(); return ack; } static uint8_t iic_read_byte(uint8_t ack) { uint8_t data = 0; IIC_SDA_H(); for (int i = 7; i >= 0; i--) { IIC_SCL_H(); iic_delay_us(); if (IIC_SDA_READ()) data |= (1 << i); IIC_SCL_L(); iic_delay_us(); } // 主机发送ACK(=0)继续读;发送NACK(=1)表示最后一字节 if (ack) IIC_SDA_H(); // NACK else IIC_SDA_L(); // ACK iic_delay_us(); IIC_SCL_H(); iic_delay_us(); IIC_SCL_L(); IIC_SDA_H(); iic_delay_us(); return data; }底层这段逻辑是所有I2C器件操作的基础,虽然每个平台寄存器不同,但时序骨架完全一致。移植时唯一要确认的是延时是否符合所选总线的速度,如果出现SDA边沿过缓,优先检查上拉电阻而不是盲改延时。
3.2 核心读写函数:单字节、页写、顺序读
在底层驱动之上,我封装了下面这组函数。单字节写每次读写一个字节,适合参数量小的场景;页写一次能写最多128字节;顺序读是连续读多个字节,适合导出日志或加载配置表。
void EEPROM_WaitReady(void) { uint16_t timeout = 10000; while (timeout--) { iic_start(); if (iic_write_byte(EEPROM_ADDR_W) == 0) { iic_stop(); return; } iic_stop(); } } void EEPROM_WriteByte(uint16_t addr, uint8_t data) { iic_start(); iic_write_byte(EEPROM_ADDR_W); iic_write_byte((uint8_t)(addr >> 8)); // 高字节地址 iic_write_byte((uint8_t)(addr & 0xFF)); // 低字节地址 iic_write_byte(data); iic_stop(); EEPROM_WaitReady(); // 等待本次写周期完成 } void EEPROM_WritePage(uint16_t addr, uint8_t *data, uint16_t len) { if (len == 0) return; // 防止跨页,一次最多写到页边界 uint16_t page_left = EEPROM_PAGE_SIZE - (addr % EEPROM_PAGE_SIZE); if (len > page_left) len = page_left; iic_start(); iic_write_byte(EEPROM_ADDR_W); iic_write_byte((uint8_t)(addr >> 8)); iic_write_byte((uint8_t)(addr & 0xFF)); for (uint16_t i = 0; i < len; i++) { iic_write_byte(data[i]); } iic_stop(); EEPROM_WaitReady(); } void EEPROM_WriteBytes(uint16_t addr, uint8_t *data, uint16_t len) { while (len > 0) { uint16_t page_left = EEPROM_PAGE_SIZE - (addr % EEPROM_PAGE_SIZE); uint16_t chunk = (len < page_left) ? len : page_left; EEPROM_WritePage(addr, data, chunk); addr += chunk; data += chunk; len -= chunk; } } uint8_t EEPROM_ReadByte(uint16_t addr) { uint8_t data = 0; iic_start(); iic_write_byte(EEPROM_ADDR_W); iic_write_byte((uint8_t)(addr >> 8)); iic_write_byte((uint8_t)(addr & 0xFF)); // 重复START,转为读操作 iic_start(); iic_write_byte(EEPROM_ADDR_R); data = iic_read_byte(1); // 最后一字节回NACK iic_stop(); return data; } void EEPROM_ReadBytes(uint16_t addr, uint8_t *out, uint16_t len) { if (len == 0) return; iic_start(); iic_write_byte(EEPROM_ADDR_W); iic_write_byte((uint8_t)(addr >> 8)); iic_write_byte((uint8_t)(addr & 0xFF)); iic_start(); iic_write_byte(EEPROM_ADDR_R); for (uint16_t i = 0; i < len; i++) { uint8_t ack = (i == len - 1) ? 1 : 0; // 最后一字节发NACK out[i] = iic_read_byte(ack); } iic_stop(); }关键点再说一遍:页写内部计数器在页边界会回卷,所以EEPROM_WriteBytes才要做分片处理,每写一片最多到128字节边界,这能彻底避免跨页写入导致的数据乱序。另外,EEPROM_WriteByte和EEPROM_WritePage都会等写周期,真实项目里不要省掉这一步。
3.3 一个完整的验证例程
下面这段例程完成一次完整的“写入-读回-比对”,可以作为上电自检或者产线测试的模板。测试通过后,基本说明硬件和驱动都是正常的。
void EEPROM_SelfTest(void) { uint8_t write_buf[256]; uint8_t read_buf[256]; for (int i = 0; i < 256; i++) write_buf[i] = (uint8_t)i; // 从地址0x100开始,写入256字节 EEPROM_WriteBytes(0x0100, write_buf, 256); memset(read_buf, 0x00, sizeof(read_buf)); EEPROM_ReadBytes(0x0100, read_buf, 256); uint8_t pass = 1; for (int i = 0; i < 256; i++) { if (read_buf[i] != write_buf[i]) { pass = 0; break; } } if (pass) { // 通过:数据一致 } else { // 失败:检查硬件连接和I2C上拉 } }这个自测试写入的数据是0x00到0xFF,回读比对,能覆盖全地址位和全数据位。如果失败,优先查地址帧、A0-A2接线和上拉电阻,大概率就是这三样。
4. 实测踩坑记录:写不进去、读出FF、死等ACK的根源
代码是一回事,到了真实电路板上又是另一回事。这一章记录的都是我实际调试中踩过的坑,每一个都有对应的现象和定位过程。能帮你省下几天的排查时间。
4.1 写周期没有等待,页写后立刻读就翻车
现象:单字节写偶发正常,连续写多个字节再读回,中间某些字节是0xFF。
原因:上一次写还没结束,芯片正处于内部写周期,新的I2C命令发过去芯片根本不理会,主机却认为已经收到ACK(实际上是用超时或误读代替),后续数据自然丢了。这个坑在固定延时方案中特别容易踩到,因为延时不足或系统中断打断了延时,时序就错位了。
解决:所有写命令后面都调用EEPROM_WaitReady()。如果用了MCU自带的I2C硬件外设,也要注意必须在发送停止条件后检查总线忙状态,再发下一次写命令,不能看到发送完成中断就立刻关外设。
4.2 页写跨128字节边界,数据被写飞
现象:从地址250开始写10字节,结果250-255地址的内容正确,但后面几个字节出现在128附近的页首,完全不符合预期。
原因:24LC512页写入时内部地址计数器在页尾回卷到页首,不会自动进位到下一页。跨页写入时,后续字节会覆盖本页开头区域。
解决:要么在设计时保证所有写入都从页边界对齐(比如按128字节分区),要么像我的EEPROM_WriteBytes那样,在驱动层自动分片。我在多个项目里都用分片方案,业务层不用管地址边界,省心很多。
4.3 随机读少了一个重复START
现象:读操作返回的数据是上次地址的值,无论怎么改地址都读不对。
原因:随机读的时序不能停在写地址之后直接发读位。正确流程是:START + 设备写地址 + 地址高字节 + 地址低字节,然后重新发START + 设备读地址,再读数据。缺少重复START,芯片会认为还是在执行写操作的后续,地址指针不会切换到读模式。用stop再start理论上也能凑效,但重复START更规范,兼容性也更好。
解决:严格按照EEPROM_ReadByte的实现来,这个例程是从手册时序直接抄下来的,没必要自己diy。
4.4 上拉电阻与总线电平的隐性麻烦
现象:短距离调试一切正常,换上稍长的排线后,偶发写失败、数据校验错误。
原因:I2C是开漏总线,SDA/SCL必须通过上拉电阻拉高。上拉电阻太大(比如10k)且线缆电容大时,上升沿过慢,主设备和从设备对电平采样不一致;上拉电阻太小(比如100欧)则电流过大,倒灌到器件引脚,可能损坏器件,也可能导致总线无法拉低。
解决:400kHz总线下,推荐4.7k到2.2k之间,线较长或者多设备并联时用1k到2.2k。3.3V系统和5V系统选型不同,最好用示波器看边沿,要求上升沿时间不超过1us。另外,如果EEPROM用的是5V供电而MCU是3.3V,需要确认SDA/SCL引脚是否容忍5V,否则要加电平转换。
4.5 复位状态把总线拉死
现象:单片机程序跑飞或人工复位后,I2C总线SDA一直为低,24LC512不再响应任何操作。
原因:如果主设备在传输中途复位,此时SDA可能正处于低电平状态,EEPROM则认为传输还在进行,在等待SCL的后续时钟。复位后主设备重新初始化I2C,但总线状态并不干净,双方时序对不上。
解决:在I2C初始化前,把SCL和SDA都配置为输出高电平,然后手动翻转SCL九次以上,这样可以让从设备从错误状态中恢复。更保险的做法是在EEPROM驱动初始化函数里写一个bus_recover函数:
void EEPROM_BusRecover(void) { // 模拟9个时钟周期,让从机退出异常状态 IIC_SCL_H(); IIC_SDA_H(); for (int i = 0; i < 9; i++) { IIC_SCL_L(); iic_delay_us(); IIC_SCL_H(); iic_delay_us(); } iic_start(); iic_stop(); }初始化时先调用这个,能解决大部分“上电后第一笔读写失败”的怪问题。这个函数在量产设备里尤其有用,因为工业现场的电源毛刺和复位更频繁。
老生常谈但必须强调:外部EEPROM虽然寿命高达100万次,也不是无限次。关键参数写入前最好做数据有效性校验,比如在参数区头部写一个魔数,每次上电先读魔数判断是否合法。如果某个区域反复高频写,建议做磨损均衡,把写入地址轮换到不同页,不要永远怼着同一个地址写。这是个细心活,但做习惯了,产品可靠性会明显上一截。
本文还有配套的精品资源,点击获取