十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

STM32通用驱动设计:一码适配AT24C32/64/128 EEPROM

STM32通用驱动设计:一码适配AT24C32/64/128 EEPROM 简介一套可直接嵌入STM32工程的AT24C系列EEPROM驱动程序覆盖AT24C32、AT24C64、AT24C128三种容量适用于物联网设备、智能家居、工业控制等需要存储配置信息与用户数据的嵌入式场景。基于I²C接口设计既适配硬件I²C外设配置也兼顾软件模拟方式驱动代码中包含初始化、读写函数、设备地址与寄存器偏移处理、错误处理等关键模块便于开发者快速移植到不同STM32型号。压缩包内共2个文件即1个头文件与1个源文件整体仅3KB结构精简可直接加入现有工程后按需修改。已有4242人学习下载对于正在学习STM32 I²C通信或需要快速完成AT24C系列驱动接入的开发者这份源码能节省查阅手册与调试时间并提供清晰的代码参考。 最近在做一款带参数存储的小设备主控是STM32要存校准系数、设备序列号、运行日志这类掉电不能丢的数据于是又把AT24C系列EEPROM翻了出来。选型时在AT24C32、AT24C64、AT24C128之间犹豫了一下后来发现这三颗芯片在驱动层面高度统一干脆写了一套通用驱动一套代码直接兼容三颗芯片。这篇文章就把这几颗芯片的差异、驱动设计思路、完整代码实现和踩过的坑一次性说清楚给正在用STM32驱动AT24C系列的朋友做个参考。1. 项目概述AT24C32/64/128驱动为什么可以合并成一套很多人在第一次接触AT24C系列时会习惯性地按照型号分别找驱动觉得AT24C32、AT24C64、AT24C128是三颗完全不同的芯片。实际拆开来看它们都是I2C接口的EEPROM核心协议完全一致区别只在容量、页大小和器件地址格式上。搞清楚这三点就能用一套驱动统一搞定而不是维护三份几乎一样的代码。1.1 三颗芯片的差异容量、地址格式、页大小先看一张参数对比表这是选型和写驱动之前必须明确的信息。参数AT24C32AT24C64AT24C128容量32Kbit4KB64Kbit8KB128Kbit16KB字地址范围0x0000 - 0x0FFF0x0000 - 0x1FFF0x0000 - 0x3FFF字地址字节数2字节2字节2字节页大小32字节32字节64字节器件地址位A2/A1/A0 三引脚可选A2/A1/A0 三引脚可选仅A2引脚A1/A0位置为P1/P0典型写周期5ms5ms5ms这里最容易被忽略的是AT24C01/02和AT24C32及以上型号的分水岭AT24C01/02使用单字节字地址而AT24C32开始全部使用双字节字地址。如果你以前写的是AT24C02驱动直接拿来改AT24C32寄存器地址会乱套数据读出来永远是错位。这是第一个需要划重点的设计差异。页大小决定了页写操作的边界。所谓页写就是一次I2C总线传输最多能连续写的字节数。AT24C32/64一页是32字节AT24C128一页是64字节。如果写入的数据跨越了页边界地址计数器会从页首重新开始把已经写入的数据覆盖掉。关于这个问题的细节后面第2章和第3章会仔细讲。1.2 驱动分层的整体设计思路既然三颗芯片协议一致驱动就应该分成两层底层是I2C通信函数负责起始/停止信号、字节收发、ACK应答上层是AT24C协议处理负责器件地址拼装、双字节字地址发送、页边界拆分。底层与具体芯片无关上层通过一个参数结构体区分不同型号。我在实际项目中把AT24C驱动设计成下面这种结构i2c_port层提供I2C起始、停止、写字节、读字节、等待应答等基础函数这里用软件模拟I2C方便在各型号STM32之间移植。at24c_dev层定义设备参数结构体包含器件基地址、页大小、容量上限提供读写接口。app层业务数据直接调用读写接口存入偏移地址不关心底层怎么传输。这样分层的好处很直接换芯片型号时只需要改一下参数结构体换MCU平台时只需要重写i2c_port层。我在项目里从STM32F103换到STM32G030上层业务代码一行没动只重新实现了底层GPIO翻转和延时函数。2. 硬件电路与I2C协议的基础要点软件写得再好硬件上出了问题一样白搭。AT24C系列虽然简单但电路连接和I2C协议里有几个地方特别容易翻车尤其是上拉电阻和器件地址几乎每个新项目都会有人来问我。2.1 引脚接法和上拉电阻怎么选AT24C系列通常只有8个引脚VCC、GND、SCL、SDA、WP、A0/A1/A2或A2/P1/P0。其中WP是写保护引脚接高电平时芯片禁止写入接低电平时允许正常写入。很多人的数据写不进去查了半天发现是WP引脚悬空被内部上拉或外部干扰拉高了。上拉电阻是I2C总线的必需品因为SDA和SCL都是开漏输出。我一般选4.7kΩ在VCC是3.3V、总线长度不超过10cm的情况下很稳定。如果总线距离较长或挂载设备较多可以降到2.2kΩ。最小阻值可以用欧姆定律算保证IO口灌电流不超过规格书的极限值。比如VCC3.3VVOL0.4V灌电流以3mA算Rpmin (3.3 - 0.4) / 0.003 ≈ 967Ω。也就是说上拉电阻不能小于1k左右否则灌电流可能超限。最大阻值则受总线电容和上升时间约束10k在短距离下问题不大但长线场景不建议。顺便提醒一点3.3V的STM32和5V的AT24C混接时SCL和SDA上必须做电平匹配。AT24C系列虽然宽电压支持但如果总线电平不匹配轻则通信不稳定重则损坏IO口。最简单的方案是选支持1.8V-5.5V的型号并把上拉电阻接到3.3V电源轨SCL/SDA信号都由STM32输出总体就是3.3V电平逻辑。2.2 器件地址最容易踩坑的地址位配置AT24C的I2C器件地址格式是1010 3位地址位 R/W位。发送地址时最低位是读写标志所以写地址是0xA0读地址是0xA1这是默认情况下的值。如果A2/A1/A0引脚接了不同电平器件地址会相应改变。AT24C32/64的3位地址位对应A2/A1/A0三个引脚理论上可以在一条I2C总线上挂8颗芯片互不冲突。但AT24C128就特殊了它只有A2引脚是硬件地址引脚原来的A1/A0位置被固定为P1/P0。这导致一个典型问题如果你在总线上挂了两颗AT24C128想通过A1/A0区分抱歉做不到。A2只能区分两颗P1/P0是在器件地址里直接写死的不是外部引脚。实际项目中如果总线上只有一颗AT24C128直接把A2接地器件地址写成0xA0就行。但如果你曾经把AT24C64驱动直接搬到AT24C128上且误把A1/A0引脚当成地址位去配置就会遇到设备无响应或者访问到错误地址空间的问题。这是AT24C系列里比较隐蔽的坑。2.3 页写边界与写周期延时页写是提高写入效率的关键操作。AT24C32/64一次页写最多32字节AT24C128一次页写最多64字节。但页写有个致命限制写入过程中如果地址越过当前页的末尾地址计数器不会自动进入下一页而是回卷到本页页首。这意味着你在页边界附近连续写入时如果没有做拆分后写的数据会把页首的数据覆盖掉。举个例子AT24C32的页大小是32字节页0地址范围0x00到0x1F。如果从0x1E开始连续写5个字节地址会这样走0x1E写入0x1F写入然后地址回卷到0x00继续写入。于是0x00和0x01处本来保存的数据就被新数据覆盖了。这个错误在调试时很难发现因为读出来的数据看起来有规律但跟预期就是差了几个字节。另一个必须处理的是写周期延时。EEPROM在接收到一帧写命令后内部擦写需要时间典型值5ms。在这段时间内芯片不响应任何I2C命令包括对它自己器件地址的应答。有两种等待方式固定延时5ms到10ms或者用ACK轮询。前者简单但浪费时间后者效率更高写入完成后不断发送器件地址直到收到应答位就说明内部写操作已经结束。我倾向于在驱动里实现ACK轮询批量写入大量参数时可以节省不少时间。3. 驱动代码实现一套代码兼容三颗芯片下面给出一个可直接使用的驱动实现思路用软件模拟I2C再在AT24C协议层做统一封装。代码以C语言编写依赖标准库的GPIO操作不绑定具体型号稍作修改就能移植到任何STM32工程。3.1 软件模拟I2C底层函数硬件I2C在STM32上的口碑一直两极分化早期版本确实存在一些灵异问题。为了最大程度减少调试成本我在项目里选择软件模拟I2C时序完全可控换平台成本也低。#define AT24C_SCL_H() GPIO_SetBits(GPIOB, GPIO_Pin_6) #define AT24C_SCL_L() GPIO_ResetBits(GPIOB, GPIO_Pin_6) #define AT24C_SDA_H() GPIO_SetBits(GPIOB, GPIO_Pin_7) #define AT24C_SDA_L() GPIO_ResetBits(GPIOB, GPIO_Pin_7) #define AT24C_SDA_READ() GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_7) static void i2c_delay(void) { // 约2.5us在72MHz下约为180个时钟周期 delay_us(2); } static void i2c_start(void) { AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); AT24C_SDA_L(); i2c_delay(); AT24C_SCL_L(); } static void i2c_stop(void) { AT24C_SDA_L(); AT24C_SCL_H(); i2c_delay(); AT24C_SDA_H(); } static void i2c_ack(void) { AT24C_SDA_L(); AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); AT24C_SDA_H(); } static void i2c_nack(void) { AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); } static uint8_t i2c_write_byte(uint8_t data) { uint8_t i; for (i 0; i 8; i) { if (data 0x80) AT24C_SDA_H(); else AT24C_SDA_L(); data 1; AT24C_SCL_H(); i2c_delay(); AT24C_SCL_L(); i2c_delay(); } // 释放SDA读取应答 AT24C_SDA_H(); AT24C_SCL_H(); i2c_delay(); if (AT24C_SDA_READ()) { AT24C_SCL_L(); return 1; // 无应答 } AT24C_SCL_L(); return 0; } static uint8_t i2c_read_byte(void) { uint8_t i, data 0; AT24C_SDA_H(); // 释放SDA线 for (i 0; i 8; i) { data 1; AT24C_SCL_H(); i2c_delay(); if (AT24C_SDA_READ()) data | 0x01; AT24C_SCL_L(); i2c_delay(); } return data; }这段代码的关键点在i2c_write_byte的释放SDA动作。每次写完8位数据后必须把SDA线释放为高阻输入模式否则MCU一直驱动SDA低电平EEPROM无法拉低SDA来发ACK。我在早期写驱动时就是这个细节没处理好导致每颗芯片都不应答。另外SCL空闲时必须保持低电平只有产生起始/停止信号时才拉高这能避免误触发。3.2 AT24C协议层与跨页写入AT24C协议层用一个结构体保存设备参数根据不同型号初始化不同的页大小和地址上限。typedef struct { uint16_t dev_addr; // 器件写地址如0xA0 uint16_t page_size; // 页大小AT24C32/64为32AT24C128为64 uint16_t max_addr; // 最大地址AT24C32为0x0FFFAT24C64为0x1FFFAT24C128为0x3FFF } at24c_dev_t; static void at24c_select_device(const at24c_dev_t *dev, bool is_read) { uint8_t addr dev-dev_addr; if (is_read) addr | 0x01; // 最低位置1表示读操作 i2c_write_byte(addr); } static void at24c_send_word_addr(const at24c_dev_t *dev, uint16_t addr) { i2c_write_byte((addr 8) 0xFF); // 高字节地址 i2c_write_byte(addr 0xFF); // 低字节地址 } static uint8_t at24c_wait_ready(const at24c_dev_t *dev) { uint8_t retry 0; while (1) { i2c_start(); if (i2c_write_byte(dev-dev_addr) 0) // 收到应答说明内部写完成 { i2c_stop(); return 0; } i2c_stop(); if (retry 200) return 1; delay_ms(1); } }跨页写入是驱动设计里最容易出错的部分我用一个边界检查来拆分数据长度。计算当前地址到页尾还剩多少空间要写的长度超过这个空间就只先写一段然后继续下一页。uint8_t at24c_write(const at24c_dev_t *dev, uint16_t addr, const uint8_t *buf, uint16_t len) { if (addr len dev-max_addr 1) return 1; // 超出容量 while (len 0) { uint16_t remain_in_page dev-page_size - (addr % dev-page_size); uint16_t write_len len; if (write_len remain_in_page) write_len remain_in_page; i2c_start(); if (i2c_write_byte(dev-dev_addr) ! 0) { i2c_stop(); return 2; } at24c_send_word_addr(dev, addr); for (uint16_t i 0; i write_len; i) { if (i2c_write_byte(buf[i]) ! 0) { i2c_stop(); return 3; } } i2c_stop(); if (at24c_wait_ready(dev) ! 0) return 4; // 写超时 addr write_len; buf write_len; len - write_len; } return 0; }remain_in_page dev-page_size - (addr % dev-page_size)这行代码是整个跨页处理的核心。它先算出当前地址在页内的偏移再用页大小一减得到本页剩余可写的字节数。每次写完后地址递增循环继续直到所有数据写完。没有这层处理直接在页边界附近写入长数据必然会出现回卷覆盖。读取操作相对简单AT24C的随机读是先发送要读的字地址然后重新发送起始信号切换成读模式让EEPROM输出数据。连续读取时除了最后一个字节需要发NACK之外前面的字节都要发送ACK否则EEPROM会提前结束输出。uint8_t at24c_read(const at24c_dev_t *dev, uint16_t addr, uint8_t *buf, uint16_t len) { if (addr len dev-max_addr 1) return 1; i2c_start(); if (i2c_write_byte(dev-dev_addr) ! 0) { i2c_stop(); return 2; } at24c_send_word_addr(dev, addr); i2c_start(); // 重复起始信号 if (i2c_write_byte(dev-dev_addr | 0x01) ! 0) { i2c_stop(); return 3; } for (uint16_t i 0; i len; i) { buf[i] i2c_read_byte(); if (i len - 1) i2c_nack(); else i2c_ack(); } i2c_stop(); return 0; }读操作里的重复起始信号是个易错点。在写完字地址之后不能直接发送停止信号再开始而是要在SCL为高电平时把SDA拉低产生重复起始信号然后立即发送读地址。如果先STOP再START虽然多数情况下也能工作但不符合I2C规范部分严格的从设备可能出现异常。3.3 实际调用示例驱动写好后调用起来非常简洁。下面是一个完整的示例开机初始化设备和驱动然后把一个结构体参数写入EEPROM再读回来校验。const at24c_dev_t at24c128_dev { .dev_addr 0xA0, .page_size 64, .max_addr 0x3FFF }; typedef struct { uint16_t calib_offset; uint8_t gain; uint8_t mode; uint16_t threshold; } sys_param_t; void param_save(const at24c_dev_t *dev) { sys_param_t param {100, 30, 2, 500}; if (at24c_write(dev, 0x0000, (uint8_t *)param, sizeof(param)) 0) { // 写入成功 } }这种直接把结构体指针强制转换成uint8_t *的写法要求结构体成员紧凑排列。C语言结构体默认有对齐如果你用的是类似#pragma pack(1)的设置或者精心设计成员顺序直接整体写入没问题。但为了避免不同编译器带来的对齐差异我建议在自定义协议格式时把每个字段手动按偏移写入缓冲区这样更稳。4. 常见问题与排查技巧AT24C系列调试过程中我几乎把能踩的坑都踩了一遍。整理成一张速查表给正在调试的朋友做个对照参考。问题现象可能原因解决办法写入数据读出来全是0xFFWP引脚被拉高写保护生效将WP接地检查外部上拉干扰写入数据读取后偏移几个字节器件地址或字地址字节时序不对确认AT24C32及以上用双字节字地址写入长度超过页大小数据被覆盖触发页回卷用跨页拆分逻辑限制单次写入长度写入后立即读数据是旧值未等待写周期完成增加延时或使用ACK轮询总线卡死SCL/SDA被拉低通信异常导致从设备锁死尝试9个时钟恢复或重新上电ACK一直无应答器件地址错误或芯片未供电用逻辑分析仪抓I2C波形确认地址掉电后部分参数丢失写期间掉电或写入未完成增加CRC校验和双备份存储4.1 总线锁死的恢复逻辑I2C从设备偶尔会把SDA线拉死尤其是通信中途被打断或者主设备发送半截数据时。一旦EEPROM检测到异常状态可能一直等待一个完整的停止信号SDA被它拽着不放。解决方法是让主机主动发送9个SCL时钟脉冲把从设备内部的移位寄存器状态清干净然后发送停止信号。我实际测试下来这个办法对AT24C系列的恢复率很高能解决大部分总线锁死问题省去断电重启的麻烦。4.2 逻辑分析仪的重要性调I2C通信强烈建议备一个逻辑分析仪最便宜的二三十块钱的就能用。写驱动遇到ACK无应答、数据错乱这类问题时逻辑分析仪抓一下起始条件、地址、数据帧和停止条件基本一眼就能定位问题。比对着代码盲猜快得多。我调试上位机软件时也习惯在I2C的SCL和SDA上同时观察时序确认每一位的电平翻转是否符合预期。5. 进阶优化参数存储的可靠性设计驱动能跑通只是第一步产品化过程中真正考验人的是可靠性设计。EEPROM本身硬件可靠性还不错但系统级错误仍然会导致数据损坏比如写入过程中掉电、写入非法地址、数据结构不完整等。所以参数存储层一定要做校验和备份这个思路对AT24C32/64/128完全通用。5.1 数据布局与CRC校验在EEPROM中保存参数时我建议把存储区按“信息头数据校验”的格式组织。信息头里放一个固定的魔数用来判断该区域是否已经初始化。校验值用CRC16或者简单的累加和每次写入时计算每次读取时重新校验。如果校验失败说明存储区数据不可信需要走恢复流程。具体布局可以这样定义0x0000 - 0x0003魔数比如0xAA55A55A上电读取时先判断这个值。0x0004 - 0x0005数据版本号方便以后升级参数结构。0x0006 - 0x001F业务参数区具体按需定义。0x0020 - 0x0021CRC16校验值覆盖上面的数据区。这样设计的好处是任何时候读出数据后先校验CRC再信任内容。如果CRC不对可以继续尝试读备份区。如果两个区都坏了才恢复出厂默认值。这个策略虽然会占用双倍存储空间但在产品里值得。5.2 双备份与磨损均衡EEPROM的擦写寿命通常在100万次左右看起来很多但如果系统每秒写一次参数不到两周就会磨穿。因此需要做两点一是尽量减少无意义的写入次数只有参数真正变化时才写二是必要时做磨损均衡。双备份的思路是维护A/B两个参数区每次写入时轮流写两个区。读取时先读A区校验失败再读B区如果B区校验通过就把它恢复到A区。这样即使某次写入过程中掉电也只损坏一个区另一个区仍然可用。磨损均衡则更进一步把多个小区域轮流作为写入目标避免一直擦写同一片地址。最简单的做法是增加一个“写入计数区”或者使用“滑动窗口”策略每次从不同的偏移地址写入。不过对于大多数应用来说双备份已经足够。我当时在做设备参数存储时就是A/B双区加CRC用了半年多没有出现一次参数丢失。最后一个经验如果你要存设备掉电时的实时状态比如运行累计时间建议用多次写入加末位有效的方式来抗掉电抖动每次在同一个逻辑记录里追加写入上电后按最新有效记录恢复。单纯靠EEPROM掉电保护是不现实的因为写入过程本身需要时间掉电时刻不可控必须用软件来容错。这套驱动和存储方案我在几个项目里都已经跑量稳定性比较有数。代码不复杂但每一处设计都有实际案例支撑。如果你在移植时遇到具体问题欢迎带着现象和波形图来聊这样比猜快得多。本文还有配套的精品资源点击获取
返回列表