
简介一份基于C51的24C08读写程序完整工程源码包面向51单片机学习者和嵌入式入门开发者解决I²C接口E2PROM的数据存储与读写时序编程问题。压缩包共16个文件大小仅36KB包含Keil工程文件.uvproj、C语言源程序.c、编译生成的HEX烧录文件及列表、映射、目标文件.hex/.lst/.m51/.obj另有多个自动备份文件目录简洁便于直接打开工程查看或烧录验证。目前已有1121人学习下载代码精简但覆盖了读写流程、应答检测和错误处理等关键环节。配套“24C08开机计数实验”演示上电读取计数值、加一写回并送数码管显示的完整流程适合对照源码理解起始信号、器件寻址、存储地址发送等细节也可作为其他I²C器件驱动或非易失性存储应用的项目模板。1. 先搞清楚24C08是个什么东西1KB存储背后的分块陷阱很多刚开始接触I2C EEPROM的工程师第一块芯片往往是24C02或者24C16。24C02容量小、页写逻辑简单照着网上的示例程序跑一遍就通了于是潜意识里形成了一个错觉凡是24C系列无非就是起始信号 设备地址 字节地址 数据四板斧。直到换上24C08数据写到一半变成全FF或者读出来的数据错位才开始重新审视这块芯片。我当年就干过这种事——在门禁项目里用24C02调试好的代码直接把宏定义改成24C08结果第256字节之后的数据全部丢失排查了一整天才发现24C08根本不是你以为的那种地址自动连续的芯片。24C08的容量是8Kbit也就是1024字节。但它内部并不是一个连续的1024字节地址空间而是被分成了4个256字节的存储块Bank每个块对应一个独立的器件地址。换句话说24C08虽然只有一颗芯片但在I2C总线上它表现为4个独立的从设备。这和24C02那种一整块256字节、只有一个设备地址0xA0的模型完全不同踩坑的人十有八九都是栽在这个分块设计上。为什么芯片厂商要这么设计原因在于I2C协议本身的限制。I2C在传输字节地址时标准模式下只能携带8位地址也就是最大256字节的寻址空间。要做到更大容量一种方案是增加地址字节但这样会破坏对旧代码的兼容另一种方案就是像24C08这样把高地址位挪到设备地址字节里用多设备地址的方式变相扩展容量。24C08的地址位A8第9位地址和控制位A1组合在一起构成了4个块的选择信号。还有一个容易被忽略的规格差异24C08的引脚封装比24C02少了一个A0硬件地址引脚。24C02有A0、A1、A2三个硬件地址引脚24C08只有A2和A1而A0引脚被内部用作了块选择位。这意味着同一根I2C总线上24C08最多只能挂2颗通过A2引脚区分而24C02可以挂8颗。做硬件设计的时候这个差异直接影响地址跳线和多设备扩展方案我见过不止一个同事把24C08当成24C02画了3位地址跳线结果A0引脚空着怎么配置都无法区分两块芯片。2. 器件寻址的正确姿势控制字节怎么算才算对理解了分块结构接下来最核心的问题就是怎么通过I2C协议准确访问到某个字节地址。24C08的I2C控制字节也叫设备地址字节、从机地址字节格式是MSB 1 0 1 0 A2 A1 A8 R/W LSB其中高4位固定是1010I2C协议为EEPROM分配的地址前缀A2来自芯片硬件引脚的电平A1和A8来自你要访问的目标内存地址的两个最高位。等等这里要仔细说因为正好是这个地方最容易算错。对于存储地址addr0~1023我需要做的是A8 addr的第8位即addr 0x100判断addr是否 256但又小于512A1 addr的第9位即addr 0x200判断addr是否 512写字节时R/W位置0所以控制字计算方法为uint8_t at24c08_get_dev_addr(uint16_t addr, uint8_t rw) { uint8_t a2_pin_level 0; // 取决于你的硬件A2接GND则为0接VCC则为1 uint8_t dev_addr; dev_addr 0xA0; dev_addr | (a2_pin_level 2); dev_addr | ((addr 0x200) 7); // 第9位地址映射到A1位置 dev_addr | ((addr 0x100) 8); // 第8位地址映射到A8位置 dev_addr | rw; return dev_addr; }看明白这段代码你就掌握了24C08寻址的精髓。我再用一个分布表把4个块和控制字节的对应关系列出来方便对比存储地址范围A1位A8位控制字节A20不含R/W属于哪个块0x000 ~ 0x0FF000xA0块00x100 ~ 0x1FF010xA2块10x200 ~ 0x2FF100xA4块20x300 ~ 0x3FF110xA6块3也就是说一旦要访问的地址跨过了256字节边界I2C从机地址就变了。写驱动代码时必须动态计算控制字不能把设备地址固定死。这也是很多人移植24C02驱动到24C08时最常犯的错——仍然固定用0xA0作为设备地址结果只能访问到前256字节后面的数据全写到块0或块1的错误位置去了。顺带说一句读操作同样要遵循这个寻址规则。读的时候R/W位置1控制字分别是0xA1、0xA3、0xA5、0xA7。顺序读模式下如果从块0读到块1的边界通常需要手动切换设备地址重新发起读命令芯片本身不会跨块自动连续读取。这一点在后续代码实现里我会专门处理。3. 读写驱动代码实现手写一套软件I2C驱动实际项目中24C08通常挂在MCU的两个普通GPIO口上用软件模拟I2C时序因为很多STM32型号上硬件I2C模块用起来并不省心尤其是老款F1系列硬件I2C的状态机比较复杂处理总线错误和应答超时有时候比软件模拟还费劲。我自己的经验是项目里如果I2C总线只挂一颗EEPROM软件模拟I2C反而是最稳定可控的方案。先给出GPIO和基础时序函数。我这里以STM32F103标准外设库为例但代码逻辑完全可以用到任何单片机上。#include stm32f10x.h #include at24c08.h #define EEPROM_SCL_PORT GPIOB #define EEPROM_SCL_PIN GPIO_Pin_6 #define EEPROM_SDA_PORT GPIOB #define EEPROM_SDA_PIN GPIO_Pin_7 #define SCL_H() GPIO_SetBits(EEPROM_SCL_PORT, EEPROM_SCL_PIN) #define SCL_L() GPIO_ResetBits(EEPROM_SCL_PORT, EEPROM_SCL_PIN) #define SDA_H() GPIO_SetBits(EEPROM_SDA_PORT, EEPROM_SDA_PIN) #define SDA_L() GPIO_ResetBits(EEPROM_SDA_PORT, EEPROM_SDA_PIN) #define SDA_READ() GPIO_ReadInputDataBit(EEPROM_SDA_PORT, EEPROM_SDA_PIN)初始化时SCL配置为推挽输出SDA配置为开漏输出。开漏输出的好处是既能拉低又能读取外部上拉电阻决定的高电平状态模拟双向IO时非常方便。void at24c08_i2c_init(void) { GPIO_InitTypeDef GPIO_InitStruct; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStruct.GPIO_Pin EEPROM_SCL_PIN; GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(EEPROM_SCL_PORT, GPIO_InitStruct); GPIO_InitStruct.GPIO_Pin EEPROM_SDA_PIN; GPIO_InitStruct.GPIO_Mode GPIO_Mode_Out_OD; GPIO_InitStruct.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(EEPROM_SDA_PORT, GPIO_InitStruct); SCL_H(); SDA_H(); }然后是I2C最底层的时序起始信号、停止信号、发送一字节、读取一字节。这里有几个关键时序参数要特别注意。24C08的标准I2C时钟频率是100kHz快速模式支持400kHz但软件模拟时我建议把时钟控制在10~50kHz级别特别是PCB走线较长或者上拉电阻偏大的时候慢一点可以有效降低通信异常的概率。static void i2c_delay(void) { volatile uint8_t i 8; while (i--) ; } static void i2c_start(void) { SDA_H(); SCL_H(); i2c_delay(); SDA_L(); // 时钟高电平期间SDA从高变低 i2c_delay(); SCL_L(); i2c_delay(); } static void i2c_stop(void) { SDA_L(); SCL_H(); i2c_delay(); SDA_H(); // 时钟高电平期间SDA从低变高 i2c_delay(); } static uint8_t i2c_wait_ack(void) { uint8_t timeout 250; SDA_H(); // 释放SDA让从设备控制 i2c_delay(); SCL_H(); i2c_delay(); while (SDA_READ() ! 0) { if (--timeout 0) { i2c_stop(); return 1; // 无ACK } } SCL_L(); i2c_delay(); return 0; } static void i2c_send_byte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) SDA_H(); else SDA_L(); dat 1; i2c_delay(); SCL_H(); i2c_delay(); SCL_L(); i2c_delay(); } } static uint8_t i2c_read_byte(uint8_t ack) { uint8_t i; uint8_t dat 0; SDA_H(); // 释放SDA总线 for (i 0; i 8; i) { dat 1; SCL_H(); i2c_delay(); if (SDA_READ()) dat | 0x01; SCL_L(); i2c_delay(); } if (ack) { SDA_L(); // 主机发送ACK表示还要继续读 } else { SDA_H(); // 主机发送NACK表示这是最后一字节 } i2c_delay(); SCL_H(); i2c_delay(); SCL_L(); i2c_delay(); SDA_H(); i2c_delay(); return dat; }有了这些基础函数24C08的字节读写逻辑就水到渠成了。注意下面的关键点控制字必须根据目标地址动态计算。static uint8_t dev_addr(uint16_t addr, uint8_t rw) { uint8_t a2 0; // 按实际硬件接线修改 return 0xA0 | (a2 2) | ((addr 0x200) 7) | ((addr 0x100) 8) | rw; } uint8_t at24c08_write_byte(uint16_t addr, uint8_t dat) { uint8_t ack; i2c_start(); i2c_send_byte(dev_addr(addr, 0)); // 写控制字 if (i2c_wait_ack()) return 1; i2c_send_byte((uint8_t)(addr 0xFF)); // 块内低8位地址 if (i2c_wait_ack()) return 1; i2c_send_byte(dat); if (i2c_wait_ack()) return 1; i2c_stop(); at24c08_wait_write_complete(); // 等待内部写周期结束 return 0; } uint8_t at24c08_read_byte(uint16_t addr) { uint8_t dat; i2c_start(); i2c_send_byte(dev_addr(addr, 0)); // 先发送写控制字告知内部字节地址 i2c_wait_ack(); i2c_send_byte((uint8_t)(addr 0xFF)); i2c_wait_ack(); // 重新发起起始信号进入读模式 i2c_start(); i2c_send_byte(dev_addr(addr, 1)); // 读控制字 i2c_wait_ack(); dat i2c_read_byte(0); // 单字节读主机回NACK i2c_stop(); return dat; }注意读字节的流程我多了一步先写后读的伪写操作。这是符合I2C EEPROM标准的当前地址读/随机读模式先写入目标地址再重新发起起始信号读数据。网上那些读出来全FF的帖子十有八九是漏了这一步或者设备地址计算错误。4. 最容易翻车的几个细节页写边界、写周期等待与总线状态字节读写能通只是第一步。实际项目里要存的是几十上百字节的配置数据如果逐个字节写虽然功能没问题但写入速度太慢——24C08的一次内部写周期约5ms存100字节就得500ms这在门禁刷卡、设备开机加载配置的场景里完全无法接受。于是大家都用页写Page Write方式一次连续写入一个页。24C08的页大小是16字节但这里有个隐蔽条件页写不能跨16字节边界。什么意思假设当前页起始地址是0x10你从0x1E开始连续写6个字节写到0x23时这6个字节不会正确落到0x1E~0x23而是会回卷到当前页的起始地址0x10~0x15。这是EEPROM页写缓冲区的环形回卷特性几乎所有24C系列芯片都这样。所以跨边界连续写数据时必须自己处理分页。我封装了一个通用的多字节写函数先判断剩余空间是否会被当前16字节页边界截断如果会就拆成两段或更多段来写uint8_t at24c08_write_buf(uint16_t addr, uint8_t *buf, uint16_t len) { uint8_t page_size 16; while (len 0) { uint8_t remain_in_page page_size - (addr % page_size); uint8_t chunk (len remain_in_page) ? len : remain_in_page; if (at24c08_write_page(addr, buf, chunk)) return 1; addr chunk; buf chunk; len - chunk; } return 0; }at24c08_write_page和单字节写类似区别是在控制字和地址后连续发送chunk个字节中间每发一个字节都检查ACK最后统一stop然后等待全部写周期完成。等待写周期这里也有讲究。很多人直接delay_ms(5)但实际环境温度和电压会影响内部写时间最稳妥的办法是轮询应答法向芯片发送一个写控制字如果收到ACK说明内部写周期已经结束如果收不到ACK说明芯片还在忙。我上面用的at24c08_wait_write_complete就是干这个的void at24c08_wait_write_complete(void) { uint8_t timeout 200; while (timeout--) { i2c_start(); i2c_send_byte(dev_addr(0, 0)); // 随便发一个写控制字这里地址0的块0 if (i2c_wait_ack() 0) { i2c_stop(); return; } i2c_stop(); } }注意这里发送控制字后如果收到ACK立即发STOP信号不要再发多余的数据否则会把一个无意义的字节写进当前地址。这个细节写错的人不少我见过有人在这里发完控制字后又发了地址字节结果导致写周期重新开始永远轮询不完。再者是SDA释放的时机。软件模拟I2C最容易出现的问题就是SDA方向切换错误。在STM32开漏输出模式下拉低靠输出0释放靠输出1靠外部上拉电阻拉高。读SDA电平之前必须确保本机没有拉低也就是要先SDA_H()再延时再读。我见过很多新手把GPIO设成推挽输出还去读IDR读回来的永远是自己输出的电平排查半天发现SDA引脚上的电平根本不受从设备控制。还有一个硬件层面的选型建议I2C上拉电阻取4.7kΩ~10kΩ比较稳妥。24C08的I2C总线如果上拉电阻太小比如1kΩ虽然信号翻转快但在低功耗MCU上灌电流可能超标如果太大比如100kΩ总线上升沿太慢传输距离稍长就会误码。我一般取4.7kΩ配合10cm以内的短线非常稳。另外SCL和SDA不要接太长飞线高频噪声会导致时序错乱表现为偶发性数据错误这种问题最难排查。5. 进阶玩法让24C08给RC522门禁系统当数据库回到热搜词里的RC522和STM32。在门禁读卡项目里RC522负责读取Mifare卡的UID但RC522本身并没有大容量存储空间来保存卡号和权限数据。如果MCU内部Flash不够用或者方便后续升级门禁系统比如换MCU板子但保留白名单外挂一颗24C08做刷卡记录/白名单存储是一个非常经典且经济的组合方案。以我的一个实际项目为例一颗24C08的1024字节可以这样规划地址范围用途说明0x000 ~ 0x007系统标志位存魔数0x5A 0xA5用于判断EEPROM是否已初始化0x008 ~ 0x00F卡片总数当前已存储的卡号数量0x010 ~ 0x1FF白名单卡号区每个卡号占4字节UID可存约124张卡0x200 ~ 0x3FF刷卡记录区循环覆盖记录最近100次刷卡时间和卡号在这个场景里24C08的4个块恰好对应不同的逻辑区域块0放系统参数块1放白名单块2、块3放流水记录。由于块之间设备地址不同在写刷卡记录区时不会误伤白名单区这种天然隔离反而是个优势。RC522流程很简单读卡器检测到卡片获取UID比如4字节先去白名单区域查找该UID是否存在。查找的过程就是at24c08_read_buf配合循环比对。找到就开门找不到就拒绝。新增卡片时把新UID追加到白名单区。这里有一个我踩过的大坑白名单满员后如果直接覆盖已有卡号区域可能把卡片UID拆成两半——因为我的卡号区域是按4字节对齐设计的但块1的起始地址0x200对于16字节页来说本来就对齐但每次增长未必对齐24C08的页边界于是我在at24c08_write_buf里不仅做了页边界拆分还做了4字节对齐处理确保每次写入不会把一个卡号拦腰截断。uint8_t card_db_add(uint32_t uid) { uint8_t count; uint16_t base 0x010; at24c08_read_buf(0x008, count, 1); count 0x7F; // 防止异常值 if (count 124) return 1; // 白名单已满 uint16_t target_addr base (uint16_t)count * 4; uint8_t buf[4]; buf[0] (uid 24) 0xFF; buf[1] (uid 16) 0xFF; buf[2] (uid 8) 0xFF; buf[3] uid 0xFF; if (at24c08_write_buf(target_addr, buf, 4)) return 2; count; at24c08_write_byte(0x008, count); // 更新卡数 return 0; }这个过程中我还发现了一个值得分享的原则写白名单前先写内容最后再更新卡数。因为卡数这个字段相当于事务标志如果先更新卡数、再写UID内容中途断电就会出现卡数增加了但UID数据还没写全的情况导致系统认为有一张空卡。先写数据、最后写数量即使中途断电卡数还是旧值下次上电会重新覆盖那个位置不会有脏数据。最后再说一个大家经常忽略的寿命问题。EEPROM的擦写寿命通常在100万次左右。门禁系统里如果每次刷卡都向24C08写一条流水记录高频刷卡场景下几年内就可能逼近寿命极限。所以我一般在流水区采用每10次刷卡合并写一次或者环形缓冲报告头的策略降低写入频率。设计存储方案的时候把寿命问题提前考虑进去比后期做磨损均衡要省事得多。关于24C08的读写程序我目前能想到的要点基本都在这了。核心就是两条一是务必掌握分块寻址机制二是把页写边界和写周期等待处理好。这两条通了不管后面接RC522门禁模块还是给设备存校准参数、用户配置思路都是一样的。如果你正在调试24C08但数据总是不正常按我上面的排查链路走一遍大概率能找到问题所在。本文还有配套的精品资源点击获取