4、EEPROM驱动开发:AT24Cxx系列芯片驱动编写与读写验证
好,咱们进入第四章。这一章要啃的,是嵌入式系统里最常用的存储芯片——AT24Cxx系列EEPROM。说实话,这芯片我用了十几年,从最早的24C02到后来的24C512,几乎每个项目都离不开它。你想想看,设备掉电了,配置参数、校准数据、用户设置这些不能丢的东西,往哪儿存?EEPROM就是干这个的。
4.1 AT24Cxx系列芯片概述
AT24Cxx是Atmel(现在被Microchip收购了)出的一套I2C接口的EEPROM。型号里的xx代表容量,比如24C02是2Kbit(256字节),24C64是64Kbit(8K字节),24C512是512Kbit(64K字节)。
我个人习惯,小项目用24C02或24C04就够了,存个WiFi密码、设备ID什么的。要是做血糖仪这种需要存大量历史数据的,至少得上24C256或24C512。
| 型号 | 容量(bit) | 容量(字节) | 页大小 | 地址位数 |
|---|---|---|---|---|
| AT24C02 | 2K | 256 | 8字节 | 8位 |
| AT24C04 | 4K | 512 | 16字节 | 9位 |
| AT24C08 | 8K | 1024 | 16字节 | 10位 |
| AT24C16 | 16K | 2048 | 16字节 | 11位 |
| AT24C32 | 32K | 4096 | 32字节 | 12位 |
| AT24C64 | 64K | 8192 | 32字节 | 13位 |
| AT24C128 | 128K | 16384 | 64字节 | 14位 |
| AT24C256 | 256K | 32768 | 64字节 | 15位 |
| AT24C512 | 512K | 65536 | 128字节 | 16位 |
核心要点:页大小决定了你一次能连续写多少字节。超过页边界会自动回卷,这个坑我踩过不止一次。
4.2 I2C通信协议基础
AT24Cxx用的是标准I2C协议。说白了就是两根线:SCL(时钟)和SDA(数据)。主设备(比如STM32)控制时钟,从设备(EEPROM)响应。
I2C的通信流程其实很简单:
- 起始条件:SCL高电平时,SDA从高变低
- 发送设备地址:7位地址+读写位
- 等待应答:从设备拉低SDA表示收到
- 发送/接收数据:每字节后跟一个应答位
- 停止条件:SCL高电平时,SDA从低变高
AT24Cxx的设备地址怎么算?看芯片的A0、A1、A2引脚。比如24C02,地址格式是:
// 设备地址格式(7位)
// 1 0 1 0 A2 A1 A0 R/W
// 如果A0=A1=A2=GND,则地址为0x50(写)或0x51(读)
#define EEPROM_ADDR_WRITE 0xA0 // 1010 0000
#define EEPROM_ADDR_READ 0xA1 // 1010 0001
小技巧:我习惯把写地址和读地址都定义成宏,这样代码可读性高。别在代码里写死0xA0这种魔法数字,三个月后你自己都看不懂。
4.3 驱动编写:从底层到上层
写EEPROM驱动,我一般分三层:
- 硬件层:I2C的起始、停止、发送、接收
- 芯片层:针对AT24Cxx的读写操作
- 应用层:封装成方便调用的接口
4.3.1 硬件层函数
这部分依赖具体的MCU。以STM32的HAL库为例:
// 硬件层 - I2C基础操作
static void i2c_start(void)
{
// 使用HAL库的I2C起始条件
// 实际项目中这里调用HAL_I2C_Master_Transmit等
}
static void i2c_stop(void)
{
// 停止条件
}
static uint8_t i2c_send_byte(uint8_t data)
{
// 发送一个字节,返回应答位
// 0=应答(ACK),1=非应答(NACK)
}
static uint8_t i2c_recv_byte(uint8_t ack)
{
// 接收一个字节,ack=1时发送应答
}
注意:别直接用HAL库的阻塞式传输。我在项目中遇到过,HAL_I2C_Master_Transmit在时钟延展时可能卡死。建议用中断或DMA方式,或者加超时处理。
4.3.2 芯片层:写操作
写EEPROM有两种方式:字节写和页写。先看字节写:
// 字节写 - 向指定地址写入一个字节
uint8_t eeprom_write_byte(uint16_t addr, uint8_t data)
{
i2c_start();
// 发送设备地址(写)
if (i2c_send_byte(EEPROM_ADDR_WRITE) != 0) {
i2c_stop();
return 1; // 设备无应答
}
// 发送存储地址(高字节 + 低字节)
// 注意:24C02只用8位地址,24C256用16位
if (addr > 0xFF) {
// 16位地址芯片
i2c_send_byte((addr >> 8) & 0xFF);
}
i2c_send_byte(addr & 0xFF);
// 发送数据
i2c_send_byte(data);
i2c_stop();
// 等待写完成(内部擦写时间约5ms)
eeprom_wait_ready();
return 0;
}
页写就更有意思了。一次能写多个字节,效率高很多:
// 页写 - 一次写入多个字节(不超过页大小)
uint8_t eeprom_write_page(uint16_t addr, uint8_t *data, uint8_t len)
{
uint8_t page_size = eeprom_get_page_size();
uint8_t page_offset = addr % page_size;
// 检查是否跨页
if (page_offset + len > page_size) {
// 跨页了!需要分两次写
// 我曾经在这里吃过亏,数据写乱了
return 2; // 跨页错误
}
i2c_start();
i2c_send_byte(EEPROM_ADDR_WRITE);
// 发送地址
if (addr > 0xFF) {
i2c_send_byte((addr >> 8) & 0xFF);
}
i2c_send_byte(addr & 0xFF);
// 连续发送数据
for (uint8_t i = 0; i < len; i++) {
i2c_send_byte(data[i]);
}
i2c_stop();
eeprom_wait_ready();
return 0;
}
避坑指南:我曾经在一个血糖仪项目里,用页写存血糖记录。没检查跨页,结果数据写到一半翻到下一页开头,把之前的数据覆盖了。用户投诉说历史记录对不上号……从那以后,我每次页写都先算页偏移。
4.3.3 芯片层:读操作
读操作相对简单,不需要等待擦写时间:
// 随机读 - 从指定地址读一个字节
uint8_t eeprom_read_byte(uint16_t addr, uint8_t *data)
{
// 先写地址(伪写操作)
i2c_start();
i2c_send_byte(EEPROM_ADDR_WRITE);
if (addr > 0xFF) {
i2c_send_byte((addr >> 8) & 0xFF);
}
i2c_send_byte(addr & 0xFF);
// 重新发起起始条件,切换为读
i2c_start();
i2c_send_byte(EEPROM_ADDR_READ);
// 读取数据,发送NACK表示读完
*data = i2c_recv_byte(0); // 0 = NACK
i2c_stop();
return 0;
}
// 连续读 - 从指定地址读多个字节
uint8_t eeprom_read_buffer(uint16_t addr, uint8_t *buffer, uint16_t len)
{
i2c_start();
i2c_send_byte(EEPROM_ADDR_WRITE);
if (addr > 0xFF) {
i2c_send_byte((addr >> 8) & 0xFF);
}
i2c_send_byte(addr & 0xFF);
i2c_start();
i2c_send_byte(EEPROM_ADDR_READ);
// 连续读取,最后一个字节发NACK
for (uint16_t i = 0; i < len; i++) {
uint8_t ack = (i == len - 1) ? 0 : 1;
buffer[i] = i2c_recv_byte(ack);
}
i2c_stop();
return 0;
}
4.3.4 等待写完成
EEPROM写完数据后,内部需要时间擦写(典型值5ms)。怎么知道写完了?轮询应答:
// 等待EEPROM写完成
void eeprom_wait_ready(void)
{
uint32_t timeout = 10000; // 超时计数
while (timeout--) {
i2c_start();
// 发送设备地址,如果返回ACK说明写完了
if (i2c_send_byte(EEPROM_ADDR_WRITE) == 0) {
i2c_stop();
return;
}
i2c_stop();
// 延时一下再试
delay_us(100);
}
// 超时了,说明芯片可能挂了
// 我一般在这里加个错误标志
}
经验之谈:这个等待函数很关键。我见过有人不加等待,连续写数据,结果后面的数据把前面的覆盖了。还有人不加超时,芯片一旦卡死,整个系统就hang住了。
4.4 读写验证:确保数据正确
驱动写完了,怎么验证对不对?我一般做三件事:
- 回读验证:写进去什么,读出来必须是什么
- 边界测试:地址0、地址最大值、跨页边界
- 压力测试:反复写读1000次,看会不会出错
来个简单的验证函数:
// 读写验证函数
uint8_t eeprom_verify(void)
{
uint8_t test_data[] = {0x55, 0xAA, 0x00, 0xFF, 0x12, 0x34};
uint8_t read_back[6];
uint8_t errors = 0;
// 测试1:字节写读
eeprom_write_byte(0x0000, 0xA5);
eeprom_read_byte(0x0000, &read_back[0]);
if (read_back[0] != 0xA5) {
errors++;
// 记录错误
}
// 测试2:页写读
eeprom_write_page(0x0100, test_data, 6);
eeprom_read_buffer(0x0100, read_back, 6);
for (int i = 0; i < 6; i++) {
if (read_back[i] != test_data[i]) {
errors++;
}
}
// 测试3:边界测试 - 最后一个地址
uint16_t last_addr = eeprom_get_size() - 1;
eeprom_write_byte(last_addr, 0x5A);
eeprom_read_byte(last_addr, &read_back[0]);
if (read_back[0] != 0x5A) {
errors++;
}
return errors; // 返回0表示全部通过
}
4.5 实际项目中的注意事项
最后,分享几个我在项目中踩过的坑:
- 上电时序:EEPROM的VCC和I2C引脚的上电顺序有要求。我遇到过MCU先启动,去读EEPROM,结果EEPROM还没准备好,返回全0xFF。
- 写寿命:AT24Cxx的写寿命是100万次。听起来很多,但如果你每秒写一次,11天就挂了。血糖仪这种设备,别频繁写。
- 数据备份:重要数据写两份。我曾经因为EEPROM某位坏了,导致设备配置全丢。后来学乖了,关键数据存两份,加CRC校验。
- 中断干扰:写EEPROM时如果来了中断,I2C时序会被打断。我建议写操作时关中断,或者用DMA。
重要提醒:AT24Cxx的WP引脚(写保护)一定要处理好。如果WP接高电平,芯片就只读不写了。我有个同事调试了两天,发现是WP没拉低……
好了,这一章的内容就到这儿。EEPROM驱动看着简单,但细节不少。下一章咱们讲如何用EEPROM存储血糖仪的历史记录,包括数据格式设计、循环存储、磨损均衡这些实战内容。到时候见。