ARTICLE DETAIL

资讯详情

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

STM32软件I2C读写AT24C系列EEPROM驱动设计与实现

STM32软件I2C读写AT24C系列EEPROM驱动设计与实现 简介一套基于STM32F103C8T6的EEPROM读写方案配合AT24C01至AT24C256系列芯片使用并且经过实物测试验证。该工程面向嵌入式开发者与STM32初学者重点演示I²C协议底层驱动、外设初始化以及单个/连续字节的写入与读取操作能直接解决非易失性参数存储需求。压缩包共153个文件约2.88MB以C语言源码.c/.h、Keil工程文件.uvprojx、编译中间文件与目标文件.o/.axf/.hex为主体并附带LCD、ADC、定时器等外设例程方便对照学习STM32标准外设库的工程组织方式。目前已有2206人学习下载属于经过实测的完整案例。代码中I²C驱动基于GPIO开漏模式与硬件I²C配合实现注释清楚便于移植同时包含连续读写与掉电保存验证方法可作为开发模板快速接入自己的项目。 最近在做一个需要掉电保存参数的设备校准值和设备序列号不能每次上电都重新设置放Flash里又不合适这玩意儿擦写次数有限而且按扇区擦除对小数据太浪费。于是翻出最熟悉的STM32F103C8T6搭配AT24C系列EEPROM来做从AT24C01一路测到AT24C256全部用同一套驱动跑通实物验证过写入后断电再上电数据依然稳定。这篇文章把方案选型、I2C时序、代码实现和排坑经验完整记录下来正在被I2C读写EEPROM折磨的朋友可以直接抄作业。1. 方案选型与整体设计思路1.1 为什么选STM32F103C8T6和AT24C系列STM32F103C8T6这颗芯片在国产开发板圈子里几乎是“标配”。Cortex-M3内核主频72MHz64KB Flash20KB RAM内置I2C、SPI、USART、CAN等常用外设。最关键是价格便宜最小系统板几块钱就能到手资料和例程铺天盖地出了问题搜索一下基本都有答案。项目里我已经用它跑过不少逻辑这次加存储模块没理由换平台。EEPROM选AT24C系列是因为这个家族覆盖面够广从1Kbit到256Kbit都有完全相同的封装和引脚定义软件上只需要做少量配置就能兼容。实际测试中我用的是AT24C02、AT24C04、AT24C16和AT24C256这几颗分别代表小容量、中容量和大容量三种典型情况。如果你手头是AT24C01或者AT24C08参数稍微改改就能用。这里补充一点近几年STM32F103的供货价格波动比较大不少人转向了国产替代芯片比如GD32F103、APM32F103等。这些芯片的引脚和寄存器基本兼容但外设细节上可能有些小差异特别是I2C外设的时序行为。我的这套代码用的是软件模拟I2C不依赖芯片内置的I2C外设所以在国产替代芯片上同样能跑这也是我坚持用软件I2C的一个原因。1.2 AT24C系列全兼容的关键差异AT24C系列虽然看起来长得一模一样但不同容量的寻址方式、页大小都有差异。搞清楚这张表比闷头调代码重要得多。芯片型号容量页大小可用地址引脚字地址字节数AT24C011Kbit/128B8字节A2/A1/A01字节AT24C022Kbit/256B16字节A2/A1/A01字节AT24C044Kbit/512B16字节A2/A1A0作高位地址线P01字节AT24C088Kbit/1KB16字节A2A1/A0作页地址线1字节AT24C1616Kbit/2KB16字节A2/A1/A0全部作页地址线1字节AT24C3232Kbit/4KB32字节A2/A1/A02字节AT24C6464Kbit/8KB32字节A2/A1/A02字节AT24C128128Kbit/16KB64字节A2/A1/A02字节AT24C256256Kbit/32KB64字节A2/A1/A02字节这张表看起来简单里面的坑却不少。AT24C01/02是三个地址引脚都用I2C设备地址是1010 A2 A1 A0如果想挂多个EEPROM靠硬件电平区分地址没问题。但从AT24C04开始地址引脚开始被“借用”充当存储地址的高位。AT24C04的A0引脚不再作为设备地址位而是当作存储地址的第9位P0这就意味着硬件上不能通过A0来扩展出两个AT24C04。AT24C08更进一步A1和A0都变成页地址位。AT24C16最极端三个地址引脚全都不用于设备寻址硬件上只能挂一个。另一个关键差异是字地址字节数。AT24C01到AT24C16存储地址是8位的写地址时发一个字节就够了。AT24C32及以上容量超过256字节地址变成了16位写地址时要先发高字节再发低字节。这个差异不处理好的话读出来就是乱码或者干脆找不到设备。我的代码里用一个宏常量EEPROM_ADDR_LEN来控制1表示单字节地址2表示双字节地址换芯片时改一下就行。2. 核心原理拆解2.1 I2C通信协议速懂I2C总线两根线SCL时钟线和SDA数据线设备通过设备地址区分。空闲时两根线都被上拉电阻拉高发送起始条件后开始通信停止条件结束通信。起始条件是SCL为高时SDA由高变低停止条件是SCL为高时SDA由低变高这个顺序不能反。每个字节传输8位数据高位在前。发送完第8个时钟脉冲后主机释放SDA从机如果正常接收会拉低SDA作为应答信号ACK不拉低就是NACK。读操作时角色反过来主机在每收完一个字节后要给从机回ACK表示“继续发”但在收最后一个字节前要回NACK表示“够了别发了”。这里有个容易犯迷糊的点很多初学者看到代码里发送设备地址时最后一位是0或1不知道是什么意思。其实这8位数据高7位是设备地址最低位是方向位0表示主机要写1表示主机要读。AT24C系列基础设备地址是1010后三位根据容量不同可能是硬件地址位也可能是存储地址高位最低位就是读写方向位。2.2 AT24C系列的寻址与页写机制以AT24C02为例完整的设备地址格式是1010 A2 A1 A0 R/W。如果A2/A1/A0都接地写操作设备地址就是0xA0读操作就是0xA1。发送完设备地址后再发送一个字节的存储地址然后才是实际数据。AT24C256则需要先发高8位地址再发低8位地址地址范围是0x0000到0x7FFF。页写是AT24C系列的高效写入方式一次可以连续写入一整页数据比逐字节写快得多。但页写有个关键约束不能跨越页边界。比如AT24C02页大小16字节如果从地址0x0F开始写一个16字节的缓冲区数据不会写到0x10~0x1F而是回绕到0x00~0x0F把页首的数据覆盖掉。这个行为很多新手踩过坑写进去的数据读出来对不上还以为是时序问题。所以我写的写函数会先计算当前地址到页末还剩多少字节如果数据长度超过剩余空间就按页拆分一次写一页写完等EEPROM内部写周期结束再写下一页。这样既保证了效率又不会触发地址回绕问题。等待写周期结束的方式有两种固定延时和ACK轮询。AT24C系列写一个字节或一页数据后内部需要大约5ms时间把数据写入非易失存储介质这期间芯片不响应任何命令。固定延时简单粗暴但浪费CPU时间。ACK轮询好一些不断发送设备地址直到收到ACK就认为写周期结束。这么做的好处是芯片什么时候写完什么时候继续效率更高。实际测试下来ACK轮询的方式在连续写大量数据时能明显减少等待时间。3. 代码实现与实物测试3.1 软件I2C驱动实现项目里用的是软件模拟I2CGPIO直接控制SCL和SDA的电平变化。为什么不直接用STM32F103的硬件I2C外设F1系列硬件I2C在标准外设库和早期HAL库版本里确实踩坑概率高主发送状态机不清晰事件标志位处理不好就容易卡死在等待应答。虽然新版固件库处理了大部分问题但项目进度不等人软件I2C时序完全可控方便调试还能随便映射到任意GPIO引脚所以这里选择软件模拟方式。#define SCL_PIN GPIO_Pin_6 #define SDA_PIN GPIO_Pin_7 #define I2C_GPIO_PORT GPIOB #define I2C_GPIO_CLK RCC_APB2Periph_GPIOB #define SCL_HIGH() GPIO_SetBits(I2C_GPIO_PORT, SCL_PIN) #define SCL_LOW() GPIO_ResetBits(I2C_GPIO_PORT, SCL_PIN) #define SDA_HIGH() GPIO_SetBits(I2C_GPIO_PORT, SDA_PIN) #define SDA_LOW() GPIO_ResetBits(I2C_GPIO_PORT, SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(I2C_GPIO_PORT, SDA_PIN)GPIO初始化时SCL和SDA都配置为开漏输出但SDA要额外开启输入模式准备读操作。STM32的GPIO配置成开漏输出后写1时引脚其实是高阻态由外部上拉电阻拉高所以读取输入电平也能读到正确的值。这种接法不需要切换GPIO模式收发数据都方便。void I2C_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(I2C_GPIO_CLK, 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(I2C_GPIO_PORT, GPIO_InitStructure); SCL_HIGH(); SDA_HIGH(); }I2C时序的延时用简单的delay_us函数就行标准I2C速率100kHz对应半个时钟周期5μs400kHz对应1.25μs。我用的延时是4μs实际频率约125kHz完全满足AT24C系列的100kHz工作条件。如果后面要接多个设备时序裕量留大一点更稳妥。void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); delay_us(4); SDA_LOW(); delay_us(4); SCL_LOW(); } void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); delay_us(4); SDA_HIGH(); delay_us(4); } uint8_t I2C_SendByte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) SDA_HIGH(); else SDA_LOW(); data 1; delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); } SDA_HIGH(); delay_us(2); SCL_HIGH(); delay_us(4); if (SDA_READ()) return 1; SCL_LOW(); return 0; } uint8_t I2C_RecvByte(void) { uint8_t i, data 0; for (i 0; i 8; i) { data 1; SCL_HIGH(); delay_us(4); if (SDA_READ()) data | 0x01; SCL_LOW(); delay_us(4); } return data; }I2C_SendByte的返回值很关键返回0表示从机应答了ACK返回1表示从机没应答NACK。EEPROM是否在线、写周期是否结束都是靠这个返回值判断的。很多移植过来的代码没有把ACK状态传出来后面做ACK轮询就没法用了。3.2 EEPROM驱动层实现驱动层需要提供检测设备、等待写完成、连续写、连续读这四个核心能力。常量根据芯片型号配置#define EEPROM_DEV_ADDR 0xA0 #define EEPROM_ADDR_LEN 1 #define EEPROM_PAGE_SIZE 16 #define EEPROM_MAX_ADDR 0xFF测试AT24C256时改成EEPROM_ADDR_LEN 2、EEPROM_PAGE_SIZE 64、EEPROM_MAX_ADDR 0x7FFF。AT24C01则把页大小改成8最大地址改成0x7F。首先是设备探测函数发送一个设备地址看有没有ACK返回用来判断芯片是否存在、接线是否正确uint8_t EEPROM_Check(void) { uint8_t ack; I2C_Start(); ack I2C_SendByte(EEPROM_DEV_ADDR | 0); I2C_Stop(); return ack 0; }等待写完成用ACK轮询方式不断发设备地址试直到EEPROM给出应答说明内部写周期结束了void EEPROM_WaitWriteComplete(void) { while (1) { I2C_Start(); if (I2C_SendByte(EEPROM_DEV_ADDR | 0) 0) { I2C_Stop(); break; } I2C_Stop(); delay_us(400); } }写单页函数要求传入的起始地址和长度不能跨页跨页的拆分逻辑放在上层函数处理。这样函数职责单一不容易出乱子void EEPROM_WritePage(uint16_t memaddr, uint8_t *buf, uint8_t len) { uint8_t i; I2C_Start(); I2C_SendByte(EEPROM_DEV_ADDR | 0); if (EEPROM_ADDR_LEN 2) I2C_SendByte((uint8_t)(memaddr 8)); I2C_SendByte((uint8_t)(memaddr 0xFF)); for (i 0; i len; i) I2C_SendByte(buf[i]); I2C_Stop(); EEPROM_WaitWriteComplete(); }连续写函数负责按页拆分核心逻辑是计算当前地址到页末还剩多少空间void EEPROM_WriteBuffer(uint16_t memaddr, uint8_t *buf, uint16_t len) { uint16_t offset 0; while (offset len) { uint16_t remain EEPROM_PAGE_SIZE - (memaddr offset) % EEPROM_PAGE_SIZE; uint16_t n (len - offset remain) ? remain : (len - offset); EEPROM_WritePage(memaddr offset, buf offset, n); offset n; } }连续读函数更直接先写入设备地址和存储地址然后用重复起始条件切到读模式逐字节读。读最后一个字节前回NACK告诉芯片停止发送void EEPROM_ReadBuffer(uint16_t memaddr, uint8_t *buf, uint16_t len) { uint16_t i; I2C_Start(); I2C_SendByte(EEPROM_DEV_ADDR | 0); if (EEPROM_ADDR_LEN 2) I2C_SendByte((uint8_t)(memaddr 8)); I2C_SendByte((uint8_t)(memaddr 0xFF)); I2C_Start(); I2C_SendByte(EEPROM_DEV_ADDR | 1); for (i 0; i len; i) { buf[i] I2C_RecvByte(); if (i len - 1) I2C_SendAck(); } I2C_SendNAck(); I2C_Stop(); }这里少了I2C_SendAck和I2C_SendNAck的实现补上void I2C_SendAck(void) { SDA_LOW(); delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); SDA_HIGH(); } void I2C_SendNAck(void) { SDA_HIGH(); delay_us(2); SCL_HIGH(); delay_us(4); SCL_LOW(); delay_us(2); SDA_HIGH(); }3.3 主函数测试流程与实测结果主函数测试逻辑很简单写入0到255递增数据然后读回来逐一比对比对不一致时点亮错误指示灯结束后再比一次显示结果int main(void) { uint8_t write_buf[256]; uint8_t read_buf[256]; uint16_t i; delay_init(); I2C_GPIO_Init(); USART1_Init(115200); if (!EEPROM_Check()) { printf(EEPROM not found!\r\n); while (1); } for (i 0; i 256; i) write_buf[i] (uint8_t)i; EEPROM_WriteBuffer(0x0000, write_buf, 256); EEPROM_ReadBuffer(0x0000, read_buf, 256); uint8_t result 1; for (i 0; i 256; i) { if (write_buf[i] ! read_buf[i]) { result 0; printf(addr 0x%02X mismatch: %02X vs %02X\r\n, i, write_buf[i], read_buf[i]); } } if (result) printf(All data matched! 256 bytes RW test passed.\r\n); else printf(Data mismatch found!\r\n); while (1); }实际测试时我分别在AT24C02、AT24C04、AT24C16和AT24C256上跑过这段代码。AT24C02到AT24C16只需要改EEPROM_ADDR_LEN保持1页大小都填16测试逻辑完全一致。AT24C256改了两处配置后也顺利跑通写满32KB也没什么压力。测试中我还特意做了掉电验证把数据写入后断开电源等几秒钟再重新上电读回。读出来的数据和写入前完全一致说明数据确实落到了非易失存储介质上而不是只停留在芯片的SRAM缓冲里。另外用逻辑分析仪挂在SCL和SDA上抓波形起始条件、停止条件、ACK信号和写入的数据字节都符合I2C协议规范。串口输出的实测日志大致是这样EEPROM initialized, device found. Writing 256 bytes starting at 0x0000... Readback verify... All data matched! 256 bytes RW test passed.整个流程从初始化到验证完成在AT24C02上耗时约30ms因为一次写入256字节实际拆成了16次页写每次等一个写周期。如果用逐字节写256次等待写周期时间至少翻一倍。这能看出页写在批量数据场景下的价值。4. 常见问题与排查技巧4.1 设备无响应或地址错误最常见的问题是EEPROM_Check()返回失败也就是发送设备地址后收不到ACK。先检查接线SCL和SDA有没有接反这是新手最容易犯的错。然后是上拉电阻EEPROM的SDA是开漏输出必须在SDA和SCL上各接一个4.7kΩ上拉电阻到VCC不接上拉的话通信基本没法工作。STM32内部有上拉但强度不够带载能力弱最好还是用外部上拉。如果接线和上拉都没问题检查地址引脚。A0/A1/A2悬空时芯片内部默认下拉到地所以如果你在程序里用的设备地址是0xA0通常能匹配。但有些规格书的地址引脚不是内部下拉的稳妥起见把不用的地址引脚直接接地。AT24C04以上容量的A0/A1/A2并不都用于设备寻址接线时不要照抄AT24C02的经验。排查这类问题最快的方法是逻辑分析仪。不需要很贵几十块钱的8通道逻辑分析仪就能抓I2C波形。正常通信时能看到起始条件、地址字节、ACK拉低信号如果没有起始条件说明GPIO配置不对如果发完地址后SDA一直是高说明设备地址不对。4.2 读回全0xFF或数据错乱读回全0xFF说明数据压根没写进去或者读的时候读错了区域。先看WP写保护引脚WP接高电平会禁止写入必须接地。有些模块板把WP接了上拉电阻导致芯片一直处于写保护状态百思不得其解最后发现是这里的问题。数据错乱特别是写一批数据后只有前半段正确后半段变成之前别的数据典型的页回绕问题。比如AT24C02页大小16字节如果从地址0x0F连续写20字节后4个字节会写到0x00~0x03覆盖前面数据。我之前演示的EEPROM_WriteBuffer已经处理了跨页拆分如果你用的是一次性发很多字节的裸写函数务必检查跨页逻辑。另外有个容易忽略的点AT24C32以上是双字节地址写地址时先发高字节再发低字节。如果还按单字节地址的方式写高位地址没有发送芯片会把第一个数据字节当作地址后续数据全部移位错乱。换大容量芯片时先确认EEPROM_ADDR_LEN宏已修改。4.3 关于STM32硬件I2C的使用建议我在这套方案里用了软件I2C但不建议直接否定硬件I2C。STM32F103的硬件I2C外设实际功能是完整的配合中断或DMA在高效率传输和大数据量场景下表现更好。问题在于标准外设库的事件处理比较绕很多人卡在EV5、EV6这些事件标志上状态机跑不对就死等。如果非要使用硬件I2C建议用较新版本的HAL库按HAL_I2C_Master_Transmit和HAL_I2C_Master_Receive的接口封装配合超时处理稳定性比直接操作寄存器好很多。还有个偏方是让硬件I2C跑在100kHz标准模式不要上400kHz快速模式F1的I2C外设在快速模式下更容易出兼容性问题。这年头硬件I2C已经被无数人验证过可用写对了依然是高效选择只是开发效率上确实比软件I2C的“所见即所得”低一些。实际测试中我调试过一块AT24C16在硬件I2C模式下偶发卡死的问题后来定位到是读操作时在最后一个字节前没有正确发送NACK导致从机认为主机还要继续收数据一直霸占总线。这类问题在软件I2C里因为每一步都是自己控制的很少会出现。5. 写在最后的实操心得这套EEPROM驱动在四个容量档位的AT24C芯片上全部通过实物测试包括写读比对和断电保持验证这一点比任何理论分析都有说服力。整个工程代码也不复杂核心就是一张容量差异表加一个按页自动拆分的写函数掌握这两个关键点AT24C系列读写基本就拿捏了。如果你后续要在实际产品里用这套方案我建议再补两件事。一是加数据校验每包数据后面跟一个CRC或者简单的累加和上电读出来后先校验再使用能有效避免数据损坏导致的设备行为异常。二是考虑磨损均衡EEPROM的擦写寿命一般在100万次左右对于频繁更新的参数可以把存储区分成多个槽位轮流写延长整体寿命。这两个功能实现起来都不难但能显著提升系统的可靠性。最后再分享一个调试技巧写EEPROM代码时先把设备探测和单字节读写在最前面调通确认ACK正常、单字节读写没问题再上页写和连续读写功能。一次性写一大堆代码如果出问题排查范围会被拉得很大分步验证反而最快。本文还有配套的精品资源点击获取
返回列表