STM32 GPIO模拟I2C驱动24LC512 EEPROM实现掉电数据保存

📅 发布时间:2026/9/1 9:40:03
STM32 GPIO模拟I2C驱动24LC512 EEPROM实现掉电数据保存
简介24LC512程序示例为电可擦除可编程只读存储器芯片提供了一套可直接运行的读写控制代码该芯片采用串行总线接口拥有512千比特存储容量工作电压2.5伏至5.5伏常用于嵌入式产品参数保存。这套代码适合单片机初学者、嵌入式开发人员及需要快速实现数据存储的工程师。压缩包共13个文件以C源文件、头文件为核心配合IAR工程文件、调试配置文件等解压后即可编译使用整体仅24KB。目前已有1036人学习。示例覆盖总线初始化、单字节读写、页写入、写周期等待和错误处理完整演示了器件寻址、命令构造与读写时序。通过移植这套代码读者可以掌握串行总线外设驱动的写法理解地址指针和状态检测机制也能为后续传感器记录、环境监测等存储应用打下基础。 做单片机项目最常见的一个需求就是掉电保存数据——设备参数、校准值、运行状态一断电全丢了下次开机还得重新初始化。24LC512就是干这个的一颗64KB的串行EEPROMI2C接口存储空间大价格也不贵很多工况表和仪表盘上都能看到它。这篇文章我把一套直接可用的24LC512驱动代码整理出来基于STM32F103的GPIO模拟I2C实现代码不依赖STM32的硬件I2C外设换成其他单片机改个IO口就能用。如果你是刚开始用EEPROM或者被硬件I2C卡了好几天这份代码可以直接拿去抄。1. 动手前先理清24LC512关键参数与选型逻辑1.1 关键参数与同类芯片对比24LC512是Microchip在I2C EEPROM里的老将了很多项目选它不是因为功能花哨而是三个字稳、大、省。容量方面512Kbit换算过来是64KB也就是65536个字节能存一个小型字库或者几百组设备参数内部组织方式是65536 x 8位按字节寻址地址空间是0x0000~0xFFFF。工作电压范围非常宽常见的3.3V和5V系统都能直接接这对使用不同主控的项目相当友好。最大时钟频率400kHz标准模式100kHz支持I2C快速模式。页大小为128字节这个参数后面写代码时会反复用到务必记住。每次写周期最长5ms在完成之前芯片不会响应任何I2C命令。擦写寿命100万次数据保持200年以上工业设备完全够用。这里先放一张常见型号的对比表格方便你理解选型定位。我经常看到有人在小项目里塞24LC512其实选型还是得看容量和成本型号容量字地址宽度页大小典型用途24C02256字节8位8字节存几个配置参数AT24C324KB16位32字节中等规模配置24C12816KB16位64字节日志记录24LC51264KB16位128字节字库、大批量数据这里有个特别容易踩的坑24C02的字地址是单字节8位而24LC512的字地址是双字节16位高字节在前。很多人从24C02移植过来照着老代码只发一个地址字节结果读写永远都在低256字节里打转数据错乱还找不出原因。1.2 为什么选GPIO模拟I2C而不是硬件外设这份代码我特意用GPIO模拟I2C实现没有用STM32自带的硬件I2C外设。原因很现实STM32的硬件I2C被吐槽不是一天两天了BUSY位卡死、状态机异常之类的问题网上随便一搜就是一堆踩坑帖。用GPIO模拟的话代码就几行时序完全透明出问题用示波器一量就知道卡在哪一步。另外一个优势是可移植性。硬件I2C的寄存器每个MCU厂商都不一样万一项目从STM32换到GD32、换到N76E003、或者换到ESP32这套模拟I2C代码只需要改宏定义里的引脚和延时函数主体逻辑一行都不用动。对于I2C EEPROM这种低速设备GPIO模拟的CPU开销其实很小完全够用。2. 硬件接线与电路设计照抄这个接法就能跑2.1 引脚功能与最小系统接线24LC512是标准的8引脚封装SOIC和DIP都有。每个引脚的作用都很明确接线不复杂但有些细节新手容易忽略。默认情况下A0、A1、A2三个地址引脚全部接地这样芯片的7位从机地址就是0x50对应的写地址控制字是0xA0读地址控制字是0xA1。这是代码里默认地址如果你的板子上这三个引脚接了其他电平代码里的控制字必须同步修改。最小接线表如下直接照着接就行芯片引脚功能接法1 (A0)地址选择位0接GND2 (A1)地址选择位1接GND3 (A2)地址选择位2接GND4 (GND)电源地接GND5 (SDA)数据线接MCU的PB7经4.7k上拉到VCC6 (SCL)时钟线接MCU的PB6经4.7k上拉到VCC7 (WP)写保护接GND允许写入8 (VCC)电源正接3.3V并联一个0.1uF去耦电容SDA和SCL各接一个4.7k上拉电阻到VCC。I2C总线的物理层是开漏结构没有上拉电阻SCL和SDA根本拉不高通信必然失败。如果你的开发板上已经带了I2C上拉电阻那就不用重复加如果是自己飞线搭的电路这两个电阻一定要有。2.2 上拉电阻、WP与地址引脚的几个坑上拉电阻的阻值选择有讲究。总线频率低、走线短用10k没问题400kHz快速模式下建议用4.7k甚至2.2k。阻值太大上升沿变缓导致电平翻转时间过长阻值太小总线上电流偏大功耗增加。实际项目里如果I2C线上挂的设备比较多超过4个总线电容会增大这时候优先用2.2k同时把速率降到100kHz。WP引脚是个隐蔽的问题点。WP接高电平时整个芯片被写保护无论你怎么发写命令数据都写不进去。我见过有人把WP悬空结果芯片有时候能写有时候不能写用万用表一量WP电平在临界值附近抖动。正确做法是需要写数据时WP直接接地绝不悬空。A0、A1、A2引脚也不能悬空。它们内部没有上拉下拉悬空时读到什么电平完全随机从机地址就会在8个地址之间漂移。三个引脚全部接地是大多数板子的默认设计如果你在总线上挂了多片24LC512就把它们的A0/A1/A2组合成不同的电平最多可以挂8片互不冲突。3. 可直接复用的完整驱动代码STM32标准库版3.1 底层I2C时序启动、停止、收发与应答先写最底层的软件I2C时序。这里用的是STM32标准外设库GPIO配置为开漏输出。开漏输出的好处是主机输出高电平相当于释放总线从机可以随时把SDA拉低来发送ACK如果改成推挽输出从机拉低SDA而主机输出高时两个输出级会直接打架轻则通信失败重则伤引脚。#define SCL_GPIO GPIOB #define SCL_PIN GPIO_Pin_6 #define SDA_GPIO GPIOB #define SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(SCL_GPIO, SCL_PIN) #define SCL_L() GPIO_ResetBits(SCL_GPIO, SCL_PIN) #define SDA_H() GPIO_SetBits(SDA_GPIO, SDA_PIN) #define SDA_L() GPIO_ResetBits(SDA_GPIO, SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(SDA_GPIO, SDA_PIN) #define I2C_ACK 0 #define I2C_NACK 1 static void I2C_Delay(void) { volatile uint32_t i; for (i 0; i 200; i); }GPIO初始化把PB6和PB7配成开漏输出。注意开漏模式下读SDA的输入数据寄存器读到的是引脚实际电平这正好满足I2C读操作的需求。void I2C_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin SCL_PIN | SDA_PIN; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); SCL_H(); SDA_H(); }I2C_Delay里的循环次数决定了总线速率。72MHz主频下这个循环实测大概微秒级总线速率落在100kHz~200kHz之间24LC512完全跑得动。如果你把主频提高到168MHz或者换别的MCU这个延时值可能需要微调最直观的验证方式就是用示波器看SCL波形周期。接下来是启动、停止、发送一字节、接收一字节。这四段代码是所有I2C通信的基础注释里标注了几个关键点void I2C_Start(void) { SDA_H(); SCL_H(); I2C_Delay(); SDA_L(); // SCL高电平期间SDA产生下降沿 启动信号 I2C_Delay(); SCL_L(); } void I2C_Stop(void) { SDA_L(); SCL_H(); I2C_Delay(); SDA_H(); // SCL高电平期间SDA产生上升沿 停止信号 I2C_Delay(); } uint8_t I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) { SDA_H(); } else { SDA_L(); } data 1; I2C_Delay(); SCL_H(); // SCL高电平期间SDA数据必须保持稳定 I2C_Delay(); SCL_L(); } // 第9个时钟释放SDA读取从机的ACK SDA_H(); I2C_Delay(); SCL_H(); I2C_Delay(); if (SDA_READ() 0) { SCL_L(); return 0; // 从机拉低SDA ACK } else { SCL_L(); return 1; // SDA仍然高 NACK } } uint8_t I2C_RecvByte(uint8_t ack) { uint8_t i, data 0; SDA_H(); // 释放SDA让从机控制 for (i 0; i 8; i) { data 1; SCL_H(); I2C_Delay(); if (SDA_READ()) { data | 0x01; } SCL_L(); I2C_Delay(); } if (ack) { SDA_H(); // 主机发送NACK表示不再读后续字节 } else { SDA_L(); // 主机发送ACK表示继续读下一字节 } I2C_Delay(); SCL_H(); I2C_Delay(); SCL_L(); SDA_H(); return data; }需要注意I2C协议里数据变化只允许发生在SCL低电平期间SCL高电平期间SDA必须保持稳定。所以每发一位都是先置好SDA再拉高SCL等从机读取再拉低SCL。3.2 EEPROM读写函数单字节、页写、随机读、连续读有了底层时序24LC512的驱动就是套用固定格式。写操作格式是启动 - 发送0xA0设备地址写 - 发送16位地址高字节 - 发送16位地址低字节 - 发送数据 - 停止 - 等待写周期。单字节写函数#define AT24LC512_ADDR 0xA0 // A0A1A20 #define AT24LC512_PAGE_SIZE 128 void AT24LC512_WriteByte(uint16_t addr, uint8_t data) { I2C_Start(); I2C_SendByte(AT24LC512_ADDR); I2C_SendByte((uint8_t)(addr 8)); // 地址高8位 I2C_SendByte((uint8_t)(addr 0xFF)); // 地址低8位 I2C_SendByte(data); I2C_Stop(); delay_ms(5); // 等待内部写周期完成 }页写函数一次最多写128字节而且不能跨越页边界。所谓页边界就是地址对128取模为0的位置。比如从地址0x00F0开始往0x0100方向写写到0x0100附近时地址会回卷到该页开头后写入的数据会覆盖页开头的字节这是24LC512内部硬件的逻辑无法绕过。为了让你用起来方便我封装了一个自动处理跨页的批量写函数调用方完全不用关心页边界只要传起始地址和数据指针即可void AT24LC512_WritePage(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); I2C_SendByte(AT24LC512_ADDR); I2C_SendByte((uint8_t)(addr 8)); I2C_SendByte((uint8_t)(addr 0xFF)); for (i 0; i len; i) { I2C_SendByte(buf[i]); } I2C_Stop(); delay_ms(5); } void AT24LC512_WriteBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { while (len 0) { uint16_t page_left AT24LC512_PAGE_SIZE - (addr % AT24LC512_PAGE_SIZE); uint16_t chunk len page_left ? len : page_left; AT24LC512_WritePage(addr, buf, chunk); addr chunk; buf chunk; len - chunk; } }这个写缓冲区的逻辑核心就是每次计算从当前地址到本页末尾还剩多少字节然后只写这么多下一轮循环再从下一页开头继续。无论数据长度是3字节还是300字节都能正确处理。随机读操作需要两个启动条件第一次启动后发送设备写地址和16位字地址把EEPROM的地址指针指到目标位置然后用重复启动信号切到读模式发送0xA1从机就会从当前位置开始输出数据。注意第二个启动叫重启启动Repeated START不是先停止再启动协议上推荐这样做。uint8_t AT24LC512_ReadByte(uint16_t addr) { uint8_t data; I2C_Start(); I2C_SendByte(AT24LC512_ADDR); I2C_SendByte((uint8_t)(addr 8)); I2C_SendByte((uint8_t)(addr 0xFF)); I2C_Start(); // 重复起始条件 I2C_SendByte(AT24LC512_ADDR | 0x01); // 设备地址 读 data I2C_RecvByte(I2C_NACK); // 读取一字节主机回NACK I2C_Stop(); return data; } void AT24LC512_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); I2C_SendByte(AT24LC512_ADDR); I2C_SendByte((uint8_t)(addr 8)); I2C_SendByte((uint8_t)(addr 0xFF)); I2C_Start(); I2C_SendByte(AT24LC512_ADDR | 0x01); for (i 0; i len - 1; i) { buf[i] I2C_RecvByte(I2C_ACK); // 前面的字节回ACK } buf[len - 1] I2C_RecvByte(I2C_NACK); // 最后一字节回NACK I2C_Stop(); }连续读的最后主机必须回一个NACK告诉从机我不想再要数据了。如果回ACKEEPROM会继续往总线发送下一字节总线会被它一直占着。读缓冲区函数要求len至少为1len等于0时len-1会下溢属于使用约定调用方自己保证。写周期等待还有更优解。EEPROM在内部写周期内不响应任何命令但我们可以用轮询的方式判断它是否写完不断发送写控制字直到从机返回ACK说明写周期结束。这种方式比固定delay_ms(5)更高效写4字节和写128字节时都能用最短时间。代码很简单void AT24LC512_WaitBusy(void) { I2C_Start(); while (I2C_SendByte(AT24LC512_ADDR) ! 0) { // 循环发送直到收到ACK表示写周期结束 } I2C_Stop(); }3.3 主函数调用示例写入并读回验证最后给一个完整的调用示例。演示向地址0x0000写入10个字节然后读回来比对在调试器里查看rbuf数组或者通过串口打印结果#include stm32f10x.h #include delay.h int main(void) { uint8_t wbuf[10] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; uint8_t rbuf[10] {0}; uint8_t i; delay_init(); I2C_Init(); // 写入10字节自动处理跨页 AT24LC512_WriteBuffer(0x0000, wbuf, 10); delay_ms(10); // 读回 AT24LC512_ReadBuffer(0x0000, rbuf, 10); // 在调试器查看rbuf数组或串口打印 for (i 0; i 10; i) { if (rbuf[i] ! wbuf[i]) { break; } } while (1); }如果你要验证大规模写入直接改wbuf为128字节或更多WriteBuffer会自动分页处理。这一点对批量初始化数据的场景特别有用。4. 调试记录这些Bug我踩过帮你排掉4.1 读出来全是0xFF先检查这4个地方读出来全是0xFF是所有EEPROM调试里最常见的现象。这本质上是读操作本身没问题但芯片里根本没写入数据或者读取地址和写入地址不一致。我遇到这种问题排查顺序固定是WP引脚是不是接到了GND。WP被拉高或悬空写入命令全部无效芯片里面自然还是出厂值0xFF。写周期是否等待足够。写完立刻读取EEPROM还在内部写周期读到的就是旧数据新芯片旧数据正好是0xFF。控制字有没有写错。A0/A1/A2接的是高电平还是低电平代码里的0xA0就要匹配。全部接地才是0xA0如果有引脚接高电平地址就变了。地址字节有没有漏发。24LC512是16位地址只有低地址字节或者高低字节发反了都会导致读写位置对不上。4.2 数据错乱和跨页问题数据写进去了但某一段对、某一段错乱这种问题十有八九是页写跨页。24LC512页大小128字节如果你从0x007F开始连续写5个字节写到0x0080时地址会回卷到0x0000数据整个错乱。我当初第一次用24LC512就从这翻过车要写一个200字节的配置表直接调PageWrite函数传了200字节进去结果前128字节正常后面72字节跑到地址开头把前面的配置覆盖得一塌糊涂。后来老老实实封装了自动分页的WriteBuffer问题才解决。所以只要写多字节一律优先用AT24LC512_WriteBuffer而不是直接调PageWrite。4.3 I2C总线卡死与解锁办法总线卡死的常见表现是程序运行到I2C通信位置就停住或者所有读写都返回超时。用示波器量SCL发现一直停在低电平或高电平不动这种情况通常是从机状态机卡住了。一个务实的解锁办法对SCL连续产生9~18个脉冲同时确保SDA保持高电平把从机内部的移位寄存器状态清掉。很多I2C从机在总线异常时可以通过这个方法恢复。如果还不行只能是EEPROM先断电再上电回到初始状态。另外排查一下GPIO模式是不是配成了推挽输出推挽模式下主机强拉高、从机强拉低的冲突会直接把总线搞死。4.4 快速问题排查表症状可能原因排查与解决读全为0xFFWP没接地、写周期没等、地址错测WP电平延长写后延时核对控制字数据对一部分错一部分页写跨页改用自动分页的本文还有配套的精品资源点击获取