
简介面向51单片机开发者的24C08读写程序下载包聚焦I²C接口E2PROM的驱动实现适合正在学习C51编程、需要了解I²C协议或快速移植存储器读写功能的电子工程师与电子竞赛学生。压缩包共16个文件以C源码和Keil工程文件uvproj为核心附带编译生成的hex、lst、obj以及多个bak备份文件整体仅36KB结构精简便于直接打开查看或烧录验证。已有1121人学习下载。资源配套的“24C08开机计数实验”提供了完整可运行的读写例程不仅清晰演示了起始信号、器件地址、数据收发和停止信号等I²C时序关键步骤还通过每次上电读取、加一写回并显示的方式体现了非易失性存储在实际系统中的典型应用对理解E2PROM与51单片机的软硬件协同有直接帮助。除核心的C51代码外工程中还保留备份与中间文件便于对照编译流程和排查问题可作为后续扩展其他I²C器件读写的参考模板。 做嵌入式的朋友手里基本都绕不开一颗24C08。它个头不大、价格便宜、逻辑简单却是很多设备里保命的关键一块老电视的台位数据、控制板的出厂校准值、仪表里的累计参数甚至读卡器里的白名单断电之后就靠它撑着。我最早认真研究24C08读写程序是帮朋友修一台开机状态错乱的设备读出来发现里面的数据早被写花了。从那时候起我把这颗芯片的读写逻辑完整捋了一遍后面再做STM32项目不管是存配置参数还是配合RC522存卡号白名单这套I2C读写基本功几乎每天都在用。这篇文章就把24C08读写程序从头到尾拆一遍从芯片地址、总线时序讲到STM32上的具体代码实现再补上实际调试中踩过的几类坑。适合刚接触I2C的入门读者也适合想在项目里快速落地EEPROM读写的老手。1. 为什么是24C08这颗8Kbit EEPROM到底能干什么1.1 芯片底细容量、页结构、关键参数24C08全称一般写作AT24C08或24C08C属于I2C接口的串行EEPROM。容量是8Kbit换算过来是1024字节也就是常说的1KB。这里特别容易搞混8Kbit不等于8KB它只有1KB存不了几张照片但存几十组配置参数、几百个卡号绰绰有余。它的内部并不是一个连续的1K线性空间而是分成4个256字节的块Block每个256字节块内部又以16字节为一页组织。这种结构直接决定了后面写程序时的两个难点块选择位怎么算、页写时怎么防止跨页覆盖。我把常用参数整理在下表后面写代码和排查问题都用得上。参数名典型值工作电压1.8V 5.5V容量8Kbit / 1024字节块结构4 × 256字节页大小16字节最大时钟频率400kHz快速模式内部写周期5ms典型建议等10ms擦写寿命100万次数据保持100年1.2 应用场景从家电维修到STM32项目24C08最常见的地方其实是各种主板的工厂参数区。比如电视机的高压设置、白平衡参数空调外机板的运行参数很多都存在24C08里。设备开机后主控通过I2C把这些参数读进来一旦芯片数据损坏设备就会出现各种奇奇怪怪的状态。维修行业非常依赖这类读写程序先读出一份好板的24C08数据做备份再往坏板上写回去。到了单片机项目里24C08的定位更简单就是“掉电不丢的小本本”。比如用STM32做RC522读卡器时门禁卡号和权限名单存哪SD卡太浪费Flash又怕频繁擦写外挂一颗24C08就很合适刷卡校验时读EEPROM发卡时写EEPROM接口统一、逻辑清晰。所以我在这篇文章里给的代码虽然是围绕24C08展开的但改改器件地址就能用在24C02、24C04、24C16上属于通用性很强的一套基础代码。2. 写程序前必须搞懂的I2C与24C08寻址2.1 I2C总线传输流程起始、应答、停止I2C只有两根线SCL时钟线和SDA数据线。STM32作为主机控制SCL节奏SDA是半双工的发送和接收共用一根线。一次完整传输以START起始以STOP结束中间发送的每个字节接收方都要在第9个时钟沿回复一个ACK或NACK。24C08写一个字节的完整流程是START → 发送器件地址写方向→ 等ACK → 发送字地址 → 等ACK → 发送数据 → 等ACK → STOP。读一个字节的流程稍微特殊标准做法叫“伪写读”START → 发送器件地址写方向→ 等ACK → 发送字地址 → 等ACK → STOP → 再次START → 发送器件地址读方向→ 等ACK → 读数据 → 发NACK → STOP。第一步先告诉芯片“我想定位到哪个地址”第二步才真正把数据读出来。很多新手第一次看代码会困惑为什么读操作前面要先发一个写地址其实就是先写地址指针再切换方向去读芯片内部记住了当前字地址后面就按顺序输出。2.2 24C08器件地址A2/A1和块选择位P1/P0的计算这是整个24C08读写程序里最绕、也最容易出错的地方。I2C标准里从机地址是7位24C08的7位地址固定由“1010”开头后面跟着A2、A1两个硬件引脚电平和两位内部块选择位P1、P0。发送到总线上时还要在最低位拼上R/W方向位组成一个8位字节。器件地址字节8位 1 0 1 0 A2 A1 P1 P0 R/W多数电路里A2、A1引脚都接地所以实际能改的就是P1、P0。这两个位恰恰对应着一个字节字地址0255之外的高两位地址也就是决定你要访问4个块里的哪一个。用代码算的话uint8_t dev_addr_w (uint8_t)(0xA0 | (((word_addr 8) 0x03) 1)); uint8_t dev_addr_r dev_addr_w | 0x01;这里的细节在于3个位word_addr 8取出地址的高2位对应块选择与0x03做掩码保证不会多出来高位干扰其他bit左移1位因为P1、P0在器件地址字节里占据bit2和bit1的位置不是bit7和bit6。我见过太多人写出0xA0 | (word_addr 8)少了左移这一下导致访问0x100以上地址时器件地址完全不对读出来的数据永远错位。如果笔算可以按表来地址范围0x0000x0FF对应写器件地址0xA00x1000x1FF对应0xA20x2000x2FF对应0xA40x3000x3FF对应0xA6读器件地址在基础值上加1也就是0xA1、0xA3、0xA5、0xA7。3. STM32读写24C08硬件连接与完整代码实现3.1 硬件连接与上拉电阻选择24C08最常见的封装是SOIC-8引脚功能很清晰A0/A1/A2是地址引脚一般直接接地GND接地SDA接数据线SCL接时钟线WP是写保护接地才允许写入接高电平会保护一部分地址VCC接电源旁边放一颗0.1μF去耦电容。STM32和24C08的连接就两根线的事。重点坑在于GPIO模式必须配置为开漏输出Open-Drain并且SDA和SCL都要接上拉电阻到VCC。开漏模式配合外部上拉才能保证任何设备都能主动拉低总线不操作时总线保持高电平这正是I2C应答机制能工作的前提。上拉电阻的选择我一般这么判断只有一颗芯片、线很短10kΩ也稳定总线上有多颗设备或者线比较长用4.7kΩ如果总线电容大、通信速率想跑到400kHz可以降到2.2kΩ。上拉太小会让上升沿变差上拉太大会增加功耗实际调试时可以拿示波器看一眼上升沿哪个便宜用哪个。3.2 软件I2C的五个核心函数STM32自带硬件I2C外设但在EEPROM这类简单场景里我反而推荐GPIO模拟软件I2C。原因有三代码完全可控不依赖厂商奇怪的Bug可以任意选引脚布板灵活K和移植到51、GD32、ESP32都很容易。24C08对时序并不苛刻软件模拟完全够用。核心就是五个基础函数起始、停止、发字节、收字节、等待ACK。下面的代码基于STM32 HAL库但思路是通用的。#define I2C_SCL_PORT GPIOB #define I2C_SCL_PIN GPIO_PIN_6 #define I2C_SDA_PORT GPIOB #define I2C_SDA_PIN GPIO_PIN_7 #define I2C_SCL_H() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_SET) #define I2C_SCL_L() HAL_GPIO_WritePin(I2C_SCL_PORT, I2C_SCL_PIN, GPIO_PIN_RESET) #define I2C_SDA_H() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_SET) #define I2C_SDA_L() HAL_GPIO_WritePin(I2C_SDA_PORT, I2C_SDA_PIN, GPIO_PIN_RESET) #define I2C_SDA_READ() HAL_GPIO_ReadPin(I2C_SDA_PORT, I2C_SDA_PIN) static void I2C_Delay(void) { for (volatile int i 0; i 10; i); } void I2C_Start(void) { I2C_SDA_H(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_L(); // SCL为高时SDA拉低产生起始条件 I2C_Delay(); I2C_SCL_L(); } void I2C_Stop(void) { I2C_SDA_L(); I2C_SCL_H(); I2C_Delay(); I2C_SDA_H(); // SCL为高时SDA拉高产生停止条件 I2C_Delay(); } void I2C_SendByte(uint8_t data) { for (int i 7; i 0; i--) { I2C_SCL_L(); if (data (1 i)) I2C_SDA_H(); else I2C_SDA_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); } I2C_SCL_L(); } uint8_t I2C_RecvByte(void) { uint8_t data 0; I2C_SDA_H(); // 释放SDA交给从机控制 for (int i 0; i 8; i) { I2C_SCL_H(); I2C_Delay(); data (data 1) | I2C_SDA_READ(); I2C_SCL_L(); I2C_Delay(); } return data; } uint8_t I2C_WaitAck(void) { uint8_t nack 0; I2C_SDA_H(); // 释放SDA I2C_SCL_H(); I2C_Delay(); if (I2C_SDA_READ() GPIO_PIN_SET) { nack 1; // 读不到低电平表示从机没有应答 } I2C_SCL_L(); I2C_Delay(); return nack; } void I2C_Ack(void) { I2C_SCL_L(); I2C_SDA_L(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); I2C_SDA_H(); } void I2C_NAck(void) { I2C_SCL_L(); I2C_SDA_H(); I2C_Delay(); I2C_SCL_H(); I2C_Delay(); I2C_SCL_L(); }这里有个细节值得一提SDA配置成开漏后I2C_SDA_H()实际做的不是“输出高”而是“释放总线”由外部上拉电阻把电平拉高I2C_SDA_L()才是真正把总线拉低。理解了这一点就不会在调试时纠结为什么读到的高电平不是GPIO输出的。3.3 读写的关键函数地址装入、页写限制有了基础函数24C08的读写就只是按照时序图搭积木。单片字节写是最简单的但要注意写完以后必须等芯片内部写周期结束否则后续操作无效。uint8_t AT24C08_WriteByte(uint16_t word_addr, uint8_t data) { uint8_t dev_addr (uint8_t)(0xA0 | (((word_addr 8) 0x03) 1)); I2C_Start(); I2C_SendByte(dev_addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte((uint8_t)(word_addr 0xFF)); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte(data); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Stop(); HAL_Delay(10); // 等待写周期典型5ms留余量用10ms return 0; }页写一次最多写16字节但芯片内部地址计数器在到达当前页末尾后会回卷到本页开头不会自动跨到下一页。如果从地址0x0E开始连续写4个字节写到0x0F就会跳到0x00覆盖掉页头数据。我写了一个页写函数在写入前先根据当前地址计算本页剩余空间超出的部分等下一轮再写。uint8_t AT24C08_WritePage(uint16_t addr, uint8_t *buf, uint16_t len) { uint8_t remaining 16 - (addr % 16); // 本页剩余可写字节数 if (len remaining) { len remaining; } uint8_t dev_addr (uint8_t)(0xA0 | (((addr 8) 0x03) 1)); I2C_Start(); I2C_SendByte(dev_addr); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte((uint8_t)(addr 0xFF)); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint16_t i 0; i len; i) { I2C_SendByte(buf[i]); if (I2C_WaitAck()) { I2C_Stop(); return 1; } } I2C_Stop(); HAL_Delay(10); return 0; } uint8_t AT24C08_WriteBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { while (len 0) { uint16_t chunk 16 - (addr % 16); if (chunk len) { chunk len; } if (AT24C08_WritePage(addr, buf, chunk)) { return 1; } addr chunk; buf chunk; len - chunk; } return 0; }读操作我一般分成单个读和连续读。24C08在连续读模式下读到当前256字节块的末尾会回绕到本块开头不会跨块所以跨块读取需要拆成两次不同的器件地址。uint8_t AT24C08_ReadBuffer(uint16_t addr, uint8_t *buf, uint16_t len) { uint8_t dev_addr_w (uint8_t)(0xA0 | (((addr 8) 0x03) 1)); uint8_t dev_addr_r dev_addr_w | 0x01; I2C_Start(); I2C_SendByte(dev_addr_w); // 伪写定位字地址 if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_SendByte((uint8_t)(addr 0xFF)); if (I2C_WaitAck()) { I2C_Stop(); return 1; } I2C_Start(); // 重复起始 I2C_SendByte(dev_addr_r); if (I2C_WaitAck()) { I2C_Stop(); return 1; } for (uint16_t i 0; i len; i) { buf[i] I2C_RecvByte(); if (i len - 1) { I2C_NAck(); // 最后一个字节回NACK } else { I2C_Ack(); } } I2C_Stop(); return 0; }主程序里验证读写是否成功最朴素也最有效的办法就是写一串已知数据再读回来逐字节比较。uint8_t write_buf[32]; uint8_t read_buf[32]; for (int i 0; i 32; i) { write_buf[i] 0xA0 i; } if (AT24C08_WriteBuffer(0x100, write_buf, 32) 0) { if (AT24C08_ReadBuffer(0x100, read_buf, 32) 0) { if (memcmp(write_buf, read_buf, 32) 0) { // 读写一致验证通过 } } }4. 调试中踩过的坑与排查实录4.1 总线锁死SDA被一直拉低这是我最早做EEPROM时最头疼的问题。现象是程序跑着跑着就卡在某个I2C操作里用逻辑分析仪看SDA一直停留在低电平。原因通常是通信中途发生了复位比如看门狗突然复位、或者调试时在传输中间停了断点从机芯片还没等到停止条件总线就被占住不放。软件模拟I2C没有硬件外设那种自动恢复机制一旦总线锁死需要手动救回来。我的处理办法是在每次I2C_Start之前加一个简单的总线检查如果SDA读到低且持续超过一定时间就给SCL发送9个额外脉冲再从机就会释放总线。批量调试的时候这个方法很管用。更省事的办法是给24C08的电源加一个可控开关卡死了直接断电重新初始化。4.2 读出来全是0xFF新买的芯片出厂数据就是全0xFF所以读回来全是FF并不代表芯片坏了大概率是没写成功。先查三件事WP引脚有没有被拉高如果WP接高电平芯片会拒绝写入尤其是上半部分地址写函数执行后有没有等够写周期没等够就读读出来自然是旧数据器件地址和字地址是否匹配有时候写进了0x100读的时候地址算错读到了另一个块的空白区。如果在STM32调试器里单步执行写完一个字节立刻读回很容易踩到写周期没结束的坑。我建议写完之后直接延时10ms再读回验证不要迷信手册上的5ms典型值不同批次的芯片可能有差异。4.3 只有前256字节能读写后面的地址全无效出现这种问题十有八九是器件地址里的块选择位没处理好。比如沿用24C02的写法器件地址只发0xA0那就只能操作第一个256字节块后面的地址再大也是映射到前256字节上。排查方法很简单在代码里打印出每次计算出来的dev_addr对照前面那张表就知道对不对。还有一个容易踩的坑7位地址和8位地址混淆。I2C规范里地址是7位但实际发送时是左移一位加上R/W位。24C08的7位地址范围是0x50到0x57左移后才是0xA0到0xAF。如果用逻辑分析仪抓包注意区分这两种表示方式否则看波形时会怀疑人生。4.4 页写覆盖导致数据丢失我已经不止一次遇到有人问“我从0x0E开始写20个字节怎么最先写的几个数据没了”这就是页写回卷问题。24C08的16字节页边界是硬性的从地址0x0E写入时地址自动递增到0x0F后不是进0x10而是回到0x00。解决办法就是代码里对地址做页边界判断拆成多页写。这也是我推荐用AT24C08_WriteBuffer而不是直接循环调AT24C08_WriteByte的原因。写周期的问题也值得多说一句页写之后每个页都要等一次内部写周期如果写长数据写完一页立刻写下一页需要保证每次之间至少间隔10ms。用WriteBuffer这类封装函数时我已经把等待时间放在每个page write调用了这是最稳妥的吃法。5. 一点个人经验最后说几条我在实际项目里的体会。第一个是给24C08的数据加个简单的校验至少用累加和或者CRC8。EEPROM本身写错概率不高但设备运行环境里电磁干扰、写中途断电都可能让数据损坏。我在RC522读卡器项目里存白名单就是在每个记录尾部放一个校验字节读出来不对就当作无效记录不会误判放行。第二个是用逻辑分析仪抓波形这是排查I2C问题最快的路径。软件模拟I2C虽然简单但光靠肉眼盯着代码很难看出时序问题。一个几十块钱的逻辑分析仪抓一遍起始条件、地址、ACK时序基本就能定位问题在哪。我调试24C08遇到的所有疑难问题最后都是通过波形看出来的。第三个经验是24C08这类I2C EEPROM的代码一定要写得模块化。你这次用24C08下次可能就是24C16或者更大容量的24C256到时候只需要改器件地址计算和页大小宏定义其余函数全部复用。我在项目里把软件I2C和EEPROM驱动拆成了两个文件稍微封装一下直接换芯片类型就像换衣服一样简单。这也是为什么我一直建议掌握24C08因为它就是理解所有I2C EEPROM的钥匙这一颗芯片吃透了未来遇到任何I2C器件都不会怵。本文还有配套的精品资源点击获取