梁山派GD32F470移植AT24C02 EEPROMI2C驱动实现与数据掉电存储实战最近在做一个基于梁山派GD32F470的项目需要保存一些系统配置参数比如设备ID、校准值、运行时间等等。这些数据在设备断电后不能丢失这时候就需要用到EEPROM了。AT24C02是一款非常经典的I2C接口EEPROM芯片容量256字节价格便宜使用简单在很多项目里都能看到它的身影。今天我就来手把手教大家怎么在梁山派GD32F470开发板上驱动AT24C02实现数据的掉电存储。我会从最基础的硬件连接讲起然后一步步实现I2C的软件模拟驱动最后完成读写测试。跟着做一遍你就能在自己的项目里用上EEPROM了。1. 认识AT24C02你的数据“保险箱”1.1 什么是EEPROMEEPROMElectrically Erasable Programmable Read-Only Memory中文叫电可擦可编程只读存储器。这个名字听起来有点绕咱们简单理解一下只读存储器正常工作时主要是读取数据电可擦可编程可以用电信号来擦除和重新写入数据最关键的是EEPROM掉电后数据不会丢失这就是为什么我们要用它来保存重要参数。单片机内部的Flash也能保存数据但擦写次数有限一般10万次左右而EEPROM的擦写次数能达到100万次以上更适合频繁保存数据的场景。1.2 AT24C02关键参数根据数据手册AT24C02有以下几个关键参数需要了解参数规格说明工作电压1.8V-5.5V很宽的电压范围3.3V系统可以直接用工作电流最大3mA功耗很低适合电池供电设备通信接口I2C只需要两根线SDA、SCL存储容量2048位256字节可以存256个8位数据时钟速度5V时最大1000KHz其余400KHz3.3V系统下最高支持400KHz注意AT24C02的容量是256字节地址范围是0-255。如果你需要更大容量可以选AT24C04512字节、AT24C081KB等它们的驱动代码基本一样只是设备地址稍有不同。1.3 I2C通信基础I2CInter-Integrated Circuit是一种两线制的串行通信总线只需要两根线SCLSerial Clock时钟线由主机单片机控制SDASerial Data数据线双向传输I2C通信有几个关键时序需要掌握起始信号SCL为高电平时SDA从高变低停止信号SCL为高电平时SDA从低变高数据传输SCL为低电平时改变SDASCL为高电平时读取SDA应答信号每传输8位数据后接收方要拉低SDA作为应答这些时序我们后面都会用代码实现现在先有个概念就行。2. 硬件连接把AT24C02接到梁山派上2.1 引脚对应关系AT24C02模块一般有4个引脚VCC、GND、SDA、SCL。按照下面的表格连接到梁山派AT24C02引脚梁山派引脚说明VCC3V3接3.3V电源SDAPB8I2C数据线SCLPB9I2C时钟线GNDGND接地提示PB8和PB9是梁山派上默认的I2C0引脚但咱们这里用的是软件模拟I2C所以理论上任何GPIO都可以用。选择PB8和PB9是为了方便如果你要用硬件I2C这两个引脚也是对的。2.2 为什么用软件模拟I2C你可能会问GD32F470不是有硬件I2C外设吗为什么还要用软件模拟在实际项目中我经常选择软件模拟I2C原因有几个调试方便时序完全可控哪里出问题一目了然移植简单换到其他单片机平台改改GPIO操作就行避开硬件bug有些单片机的硬件I2C确实有小毛病引脚灵活不占用固定的I2C引脚可以自由分配对于AT24C02这种低速设备最高400KHz软件模拟完全够用而且更稳定。3. 驱动代码实现手把手写I2C时序3.1 头文件定义bsp_at24c02.h首先创建头文件定义引脚和基本操作宏#ifndef _BSP_AT24C02_H_ #define _BSP_AT24C02_H_ #include gd32f4xx.h // 端口定义 - 使用PB8和PB9 #define RCU_SDA RCU_GPIOB #define PORT_SDA GPIOB #define GPIO_SDA GPIO_PIN_8 #define RCU_SCL RCU_GPIOB #define PORT_SCL GPIOB #define GPIO_SCL GPIO_PIN_9 // SDA方向控制 #define SDA_OUT() gpio_mode_set(PORT_SDA, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SDA) #define SDA_IN() gpio_mode_set(PORT_SDA, GPIO_MODE_INPUT, GPIO_PUPD_PULLUP, GPIO_SDA) // 读取SDA引脚电平 #define SDA_GET() gpio_input_bit_get(PORT_SDA, GPIO_SDA) // SDA和SCL输出电平控制 #define SDA(x) gpio_bit_write(PORT_SDA, GPIO_SDA, (x ? SET : RESET)) #define SCL(x) gpio_bit_write(PORT_SCL, GPIO_SCL, (x ? SET : RESET)) // AT24C02设备地址 #define AT24C02_ADDRESS_READ 0xA0 // 读地址 #define AT24C02_ADDRESS_WRITE 0xA1 // 写地址 // 函数声明 void AT24C02_GPIO_Init(void); void AT24C02_WriteByte(unsigned char WordAddress, unsigned char Data); unsigned char AT24C02_ReadByte(unsigned char WordAddress); #endif这里有几个关键点SDA_OUT()和SDA_IN()用于切换SDA引脚的方向输出或输入SDA_GET()用于读取SDA引脚的电平状态AT24C02的设备地址是0xA0写和0xA1读这是由芯片硬件决定的3.2 GPIO初始化接下来初始化PB8和PB9引脚void AT24C02_GPIO_Init(void) { /* 使能GPIOB时钟 */ rcu_periph_clock_enable(RCU_SCL); rcu_periph_clock_enable(RCU_SDA); /* 配置SCL为开漏输出模式上拉50MHz */ gpio_mode_set(PORT_SCL, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SCL); gpio_output_options_set(PORT_SCL, GPIO_OTYPE_OD, GPIO_OSPEED_50MHZ, GPIO_SCL); /* 配置SDA为开漏输出模式上拉50MHz */ gpio_mode_set(PORT_SDA, GPIO_MODE_OUTPUT, GPIO_PUPD_PULLUP, GPIO_SDA); gpio_output_options_set(PORT_SDA, GPIO_OTYPE_OD, GPIO_OSPEED_50MHZ, GPIO_SDA); /* 初始状态SCL和SDA都拉高 */ SCL(1); SDA(1); }注意这里用的是开漏输出GPIO_OTYPE_OD这是I2C总线要求的。开漏输出意味着引脚只能拉低到GND不能主动输出高电平高电平靠外部上拉电阻实现。梁山派开发板内部已经有上拉电阻所以咱们直接配置为上拉模式就行。3.3 I2C起始和停止信号I2C通信的开始和结束由起始和停止信号控制/* 起始信号SCL高电平期间SDA从高变低 */ void IIC_Start(void) { SDA_OUT(); // 设置SDA为输出 SDA(1); // SDA拉高 delay_us(5); // 保持一段时间 SCL(1); // SCL拉高 delay_us(5); SDA(0); // SDA拉低产生起始信号 delay_us(5); SCL(0); // SCL拉低准备传输数据 delay_us(5); } /* 停止信号SCL高电平期间SDA从低变高 */ void IIC_Stop(void) { SDA_OUT(); SCL(0); // 确保SCL为低 SDA(0); // SDA拉低 SCL(1); // SCL拉高 delay_us(5); SDA(1); // SDA拉高产生停止信号 delay_us(5); }这里有个细节要注意起始和停止信号都是在SCL为高电平时通过SDA的变化来产生的。起始信号是SDA从高到低停止信号是SDA从低到高。3.4 数据传输发送和接收字节发送一个字节的数据/* 发送一个字节8位数据 */ void Send_Byte(uint8_t dat) { int i 0; SDA_OUT(); // SDA设置为输出 SCL(0); // 拉低时钟开始数据传输 for(i 0; i 8; i) { // 先发送最高位MSB SDA((dat 0x80) 7); // 取出最高位 __nop(); // 短暂延时让电平稳定 SCL(1); // 拉高时钟从机读取数据 delay_us(5); SCL(0); // 拉低时钟准备下一位 delay_us(5); dat 1; // 左移准备发送下一位 } }接收一个字节的数据/* 接收一个字节 */ unsigned char Read_Byte(void) { unsigned char i, receive 0; SDA_IN(); // SDA设置为输入准备读取 for(i 0; i 8; i) { SCL(0); // 拉低时钟 delay_us(5); SCL(1); // 拉高时钟主机准备读取 delay_us(5); receive 1; // 左移为下一位腾出空间 if(SDA_GET()) // 读取SDA电平 { receive | 1; // 如果为高对应位置1 } delay_us(5); } SCL(0); // 最后拉低时钟 return receive; }这里有个重要的时序要求数据在SCL为高电平期间必须保持稳定只有在SCL为低电平时才能改变数据。3.5 应答机制I2C每传输完8位数据接收方都要发送一个应答信号/* 主机发送应答(0)或非应答(1) */ void IIC_Send_Ack(unsigned char ack) { SDA_OUT(); SCL(0); // 时钟拉低 SDA(0); // 先拉低SDA delay_us(5); if(!ack) // 如果ack0发送应答拉低 SDA(0); else // 如果ack1发送非应答拉高 SDA(1); SCL(1); // 时钟拉高从机读取应答 delay_us(5); SCL(0); // 时钟拉低 SDA(1); // 释放SDA } /* 等待从机应答返回0表示有应答1表示无应答超时 */ unsigned char I2C_WaitAck(void) { char ack 0; unsigned char ack_flag 10; // 超时计数 SCL(0); SDA(1); // 释放SDA SDA_IN(); // SDA设置为输入 delay_us(5); SCL(1); // 时钟拉高 delay_us(5); // 等待SDA被从机拉低应答 while((SDA_GET() 1) (ack_flag)) { ack_flag--; delay_us(5); } if(ack_flag 0) // 超时没有收到应答 { IIC_Stop(); // 发送停止信号 return 1; // 返回错误 } else // 收到应答 { SCL(0); // 时钟拉低 SDA_OUT(); // SDA改回输出 } return ack; // 返回0表示成功 }注意AT24C02在写入数据后需要一定时间来完成内部编程一般是5ms这段时间内它不会应答。所以写入后要延时一下再读否则会失败。这个坑我踩过调了半天才发现。4. AT24C02读写函数有了基础的I2C函数现在来实现AT24C02的读写操作。4.1 写入一个字节void AT24C02_WriteByte(unsigned char WordAddress, unsigned char Data) { IIC_Start(); // 起始信号 Send_Byte(AT24C02_ADDRESS_READ); // 发送设备地址写操作 I2C_WaitAck(); // 等待应答 Send_Byte(WordAddress); // 发送要写入的地址 I2C_WaitAck(); // 等待应答 Send_Byte(Data); // 发送要写入的数据 I2C_WaitAck(); // 等待应答 IIC_Stop(); // 停止信号 delay_1ms(5); // 重要等待AT24C02内部编程完成 }写入流程是这样的发送起始信号发送设备地址0xA0最后一位为0表示写发送要写入的存储地址0-255发送要写入的数据发送停止信号等待至少5ms让芯片完成内部写入4.2 读取一个字节unsigned char AT24C02_ReadByte(unsigned char WordAddress) { unsigned char Data; // 先发送要读取的地址伪写操作 IIC_Start(); Send_Byte(AT24C02_ADDRESS_READ); // 发送写地址 I2C_WaitAck(); Send_Byte(WordAddress); // 发送要读的地址 I2C_WaitAck(); // 重新起始开始读操作 IIC_Start(); Send_Byte(AT24C02_ADDRESS_WRITE); // 发送读地址 I2C_WaitAck(); Data Read_Byte(); // 读取数据 IIC_Send_Ack(1); // 发送非应答表示读取结束 IIC_Stop(); // 停止信号 return Data; }读取流程稍微复杂一点先发送设备地址和要读取的地址这叫伪写重新发送起始信号发送读地址0xA1读取数据发送非应答信号然后停止这个先写地址再读的流程是AT24C02要求的目的是告诉芯片我们要读哪个地址的数据。5. 实战测试让代码跑起来5.1 主函数测试代码现在写个简单的测试程序验证EEPROM是否能正常工作#include gd32f4xx.h #include systick.h #include stdio.h #include bsp_usart.h #include bsp_at24c02.h int main(void) { unsigned char dat 0; // 系统初始化 nvic_priority_group_set(NVIC_PRIGROUP_PRE2_SUB2); // 中断优先级分组 systick_config(); // 系统滴答定时器初始化 usart_gpio_config(9600U); // 串口初始化用于打印调试信息 // AT24C02初始化 AT24C02_GPIO_Init(); printf(AT24C02 Test Start!\r\n); // 测试向地址0写入数据48 AT24C02_WriteByte(0, 48); printf(Write: address 0 48\r\n); // 等待写入完成AT24C02需要时间 delay_1ms(10); // 从地址0读取数据 dat AT24C02_ReadByte(0); printf(Read: address 0 %d\r\n, dat); // 验证数据是否正确 if(dat 48) { printf(Test PASS! EEPROM is working.\r\n); } else { printf(Test FAIL! Read value: %d\r\n, dat); } while(1) { // 主循环 } }5.2 测试结果分析如果一切正常你应该在串口助手中看到这样的输出AT24C02 Test Start! Write: address 0 48 Read: address 0 48 Test PASS! EEPROM is working.如果看到这个恭喜你AT24C02已经成功驱动起来了。5.3 常见问题排查在实际调试中你可能会遇到一些问题这里分享几个我踩过的坑问题1读取的数据总是0xFF检查硬件连接VCC、GND、SDA、SCL四根线都接对了吗检查上拉电阻I2C总线需要上拉电阻梁山派内部有但如果你外接模块模块上可能也有。两个上拉电阻并联会导致电阻值太小通信可能不正常。检查时序延时delay_us(5)的延时是否合适可以适当调整。问题2能写入但读不出来或者读出的数据不对检查地址AT24C02的地址是0xA0/A1如果你用的是AT24C04/08/16等地址可能不同。检查写入后的等待时间delay_1ms(5)是否足够可以增加到10ms试试。检查应答信号在I2C_WaitAck()函数中加个调试输出看看是否收到了应答。问题3通信不稳定时好时坏降低通信速度把所有的delay_us(5)改成delay_us(10)降低通信频率。检查电源用示波器看看电源是否有噪声可以在VCC和GND之间加个0.1uF的电容。检查总线冲突确保总线上没有其他设备干扰。6. 实际应用保存系统参数在实际项目中我们通常用EEPROM来保存系统参数。这里给你一个实用的例子// 定义参数结构体 typedef struct { uint8_t device_id; // 设备ID uint16_t run_time; // 运行时间小时 uint8_t calibration; // 校准值 uint32_t serial_num; // 序列号 } SystemParams; // 保存参数到EEPROM void Save_Params(SystemParams *params) { uint8_t *p (uint8_t *)params; // 将结构体拆分成字节逐个保存 for(int i 0; i sizeof(SystemParams); i) { AT24C02_WriteByte(i, p[i]); delay_1ms(5); // 每次写入后都要等待 } } // 从EEPROM读取参数 void Load_Params(SystemParams *params) { uint8_t *p (uint8_t *)params; // 从EEPROM读取字节并组合成结构体 for(int i 0; i sizeof(SystemParams); i) { p[i] AT24C02_ReadByte(i); } } // 使用示例 SystemParams my_params; void System_Init(void) { // 尝试从EEPROM加载参数 Load_Params(my_params); // 如果读取的device_id是0xFFEEPROM擦除后的值说明是第一次使用 if(my_params.device_id 0xFF) { // 设置默认参数 my_params.device_id 1; my_params.run_time 0; my_params.calibration 50; my_params.serial_num 0x12345678; // 保存到EEPROM Save_Params(my_params); printf(First use, set default params.\r\n); } else { printf(Load params from EEPROM.\r\n); printf(Device ID: %d, Run Time: %d hours\r\n, my_params.device_id, my_params.run_time); } }这个例子展示了如何用EEPROM保存结构体数据。实际使用时要注意AT24C02只有256字节不要存太大的数据频繁写入会缩短EEPROM寿命不要每秒都保存重要数据可以保存两份两个地址读取时比较防止数据错误好了关于梁山派GD32F470驱动AT24C02 EEPROM的内容就讲到这里。这套代码我在好几个项目里都用过稳定可靠。如果你在移植过程中遇到问题可以检查一下时序延时是否合适或者用逻辑分析仪抓一下I2C波形看看起始、停止、应答信号是否正确。最重要的是动手试试把代码下载到板子上跑一跑只有实际调通了才能真正掌握。