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的通信流程其实很简单:

  1. 起始条件:SCL高电平时,SDA从高变低
  2. 发送设备地址:7位地址+读写位
  3. 等待应答:从设备拉低SDA表示收到
  4. 发送/接收数据:每字节后跟一个应答位
  5. 停止条件: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 读写验证:确保数据正确

驱动写完了,怎么验证对不对?我一般做三件事:

  1. 回读验证:写进去什么,读出来必须是什么
  2. 边界测试:地址0、地址最大值、跨页边界
  3. 压力测试:反复写读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存储血糖仪的历史记录,包括数据格式设计、循环存储、磨损均衡这些实战内容。到时候见。