简介:这是一份面向51单片机初学者与嵌入式入门者的24C08 E²PROM读写实践资料,围绕I²C两线时序、器件地址与读写命令等关键环节,解决初学者在SCL、SDA时序控制与应答位判断上反复出错的问题,并可用于EEPROM选型对照等拓展场景。压缩包共16个文件,约36KB,包含可直接编译的C51源程序、Keil工程与编译配置、HEX烧录目标文件及编译日志等中间产物:源码可对照I²C起止信号、应答位与地址页写入流程逐行研读,工程与HEX便于快速烧录复现,日志有助于排查编译与链接问题。资源基于“24C08开机计数实验”,完整呈现上电后读取计数值、加一并写回、驱动数码管显示的闭环,读者可由此掌握非易失存储的掉电保存思路,并延伸出参数配置、运行状态记录等应用。已有1127人学习,适合作为嵌入式存储入门的实操参照。
1. 一块 8 脚芯片的 10 位地址:24C08 读写程序先要理清的三件事
某开发者调试一块带显示的控制板,亮度、音量、累计运行时长这些参数本来存在 24C08 里,结果断电重上电全回默认值。示波器挂上去,SCL 有波形,SDA 也有应答,看起来一切正常,问题出在地址拆位上——程序把 24C08 当成 24C02 在写,超过 256 字节的部分全落在同一块地址上互相覆盖。这类事故在 C51 项目里非常典型,因为 24C08 读写程序表面看只是几个时序函数,真正决定成败的是三件小事:1010开头的设备地址里那两位块选择怎么拼、16 字节一页的边界怎么切、写完那 10ms 的器件内部擦写怎么等。这篇把我自己拆过、也翻过车的做法完整写一遍,从软件 I2C 时序、地址拆位、页写拆分一路讲到避坑排查,适合手上正拿着 51 板子、需要一块掉电不丢参数的小容量存储的从业者照着复现。
2. 时序打底:软件 I2C 的四个基本动作与 11.0592MHz 下的延时标定
24C08 走的是标准两线接口,原理本身不复杂,麻烦在于 51 这类老平台上几乎没有可用的硬件 I2C 外设,时序得靠 GPIO 一位一位翻。这一章把最底层的四个动作写死、写对,后面所有读写函数都建立在它们之上。
2.1 为什么 C51 项目里基本都是软件模拟
24C08 的协议简单到只有起始、停止、字节写、字节读、应答五种状态,用两个普通 IO 口配合延时就能完整覆盖。硬件 I2C 的收益主要体现在多主、时钟同步、总线仲裁这些场景,而一块参数存储板通常是一主一从,用不上仲裁;反过来,硬件 I2C 的引脚往往是固定的第二功能脚,改板、换封装、临时把存储挂到别的口上都会卡住。
软件模拟的代价是 CPU 占用和时序精度。按 50kHz 的 SCL 算,传 1 个字节 9 个时钟约 180us,写满 1KB 需要 64 页 × (页写约 1ms + 写周期 5~10ms),整片刷一遍接近 0.7 秒,这段时间主循环是停着的。所以常见做法是把 EEPROM 写入放到开机初始化阶段或者参数变更后的空闲窗口,不在中断里做,也不在主循环高频路径上做。
另一个容易被忽略的点是 IO 口模式。传统 51 的 P0 口开漏、P1~P3 准双向,准双向口在读引脚之前必须先往锁存器写 1,否则读回来永远是 0。这条规则在软件 I2C 里体现得最直接:读 SDA 之前不写 1,ACK 永远判成应答,代码看着能跑,实际全是假数据。我一般会把这条当成硬性习惯,凡是if (SDA)之前必有一句SDA = 1。
2.2 引脚定义、延时函数与位操作细节
先落地头文件、引脚宏和延时。延时不用精确,只需要保证 SCL 高电平时间和低电平时间都不小于器件要求的下限,24C08 在 5V 下支持到 400kHz,半周期 1.25us;在 2.5V 下只支持 100kHz,半周期 5us。保守取 8~10us 半周期,对应 50~60kHz,全电压范围都能跑。
#include <reg52.h> sbit SCL = P1^0; /* 时钟线,外接 4.7k 上拉到 VCC */ sbit SDA = P1^1; /* 数据线,外接 4.7k 上拉到 VCC */ /* 11.0592MHz、12T 模式下约 8~9us,对应 SCL 约 55kHz。 想更快把 4 改成 2,想更稳把 4 改成 8,只影响速率不影响正确性。 */ static void i2c_delay(void) { unsigned char i = 4; while (--i); }i2c_delay里那个空循环的次数是整个模拟 I2C 唯一的“可调参数”。while (--i)在 C51 下通常编译成一条 DJNZ,2 个机器周期,11.0592MHz 晶振下一个机器周期约 1.085us,所以 i=4 时循环体约 8.7us,再叠上函数调用和跳转的开销,实际半周期在 9~10us 之间,SCL 频率落在 50kHz 附近。这个数字不是算出来的,是拿示波器量出来反推的,不同编译器优化等级下会有 ±20% 的偏差,所以换编译器或者改优化选项之后,第一件事是重新量一遍 SCL 周期。
提示:不要为了“看起来精确”去用定时器产生延时。定时器延时会把中断响应、其他中断嵌套全卷进来,反而让时序更难预测。空循环在单主场景下足够稳。
两个引脚一定要外接上拉电阻,4.7k 是常见起点,总线长度超过 20cm 或者挂了多片器件可以降到 2.2k。51 的准双向口内部上拉很弱,在 50kHz 下勉强能拉出上升沿,但边沿会拖得很缓,从机采样容易出错,这一类问题在实验室桌面短线上完全看不出来,装到机箱里就偶发失败。
2.3 起始、停止、字节收发:四个动作与 ACK 判定
起始条件是 SCL 保持高电平期间 SDA 由高变低,停止条件是 SCL 高电平期间 SDA 由低变高,其余时间 SDA 只能在 SCL 低电平期间变化。这四句是整个协议的骨架,写反任何一句都会表现为“偶尔能通、偶尔不通”。
/* 起始:SCL 高电平期间 SDA 下降沿 */ void i2c_start(void) { SDA = 1; SCL = 1; i2c_delay(); SDA = 0; i2c_delay(); SCL = 0; /* 起始后把时钟拉低,准备发第一位 */ } /* 停止:SCL 高电平期间 SDA 上升沿 */ void i2c_stop(void) { SDA = 0; SCL = 1; i2c_delay(); SDA = 1; i2c_delay(); } /* 发送 1 字节,返回 0 表示从机应答(ACK),返回 1 表示无应答(NACK) */ bit i2c_write_byte(unsigned char dat) { unsigned char i; bit nack; for (i = 0; i < 8; i++) { SDA = (dat & 0x80) ? 1 : 0; /* 高位先发 */ dat <<= 1; i2c_delay(); SCL = 1; i2c_delay(); SCL = 0; i2c_delay(); } SDA = 1; /* 释放 SDA,让从机拉低表示应答 */ i2c_delay(); SCL = 1; i2c_delay(); nack = SDA; /* 读到 0 说明从机把线拉低了 */ SCL = 0; i2c_delay(); return nack; } /* 接收 1 字节,ack=1 表示主机回 ACK(还要继续读),ack=0 表示回 NACK(最后一个字节) */ unsigned char i2c_read_byte(bit ack) { unsigned char i, dat = 0; SDA = 1; /* 准双向口读之前必须先写 1 */ for (i = 0; i < 8; i++) { dat <<= 1; SCL = 1; i2c_delay(); if (SDA) dat |= 0x01; SCL = 0; i2c_delay(); } SDA = ack ? 0 : 1; /* 拉低 = ACK;保持高 = NACK */ i2c_delay(); SCL = 1; i2c_delay(); SCL = 0; SDA = 1; /* 释放总线 */ return dat; }i2c_write_byte的返回值语义我定成“0 = 收到应答”,这样调用处写成if (i2c_write_byte(x)) { 出错处理 }读起来顺。很多例程反过来定义,移植的时候最容易在这里翻车,所以一定要在函数头注释里写死,别靠记忆。
i2c_read_byte的ack参数也是个高频错点。主机读完一个字节后必须给从机一个响应:还要继续读就拉低 SDA 给 ACK,读到最后一个字节就保持高电平给 NACK,从机收到 NACK 才会释放 SDA 让主机发停止。如果连续读多个字节时最后一个也给了 ACK,从机会继续往总线上推数据,主机再发停止就会和从机的输出打架,表现为最后一个字节偶尔错、偶尔对。
四个动作合起来,一次完整事务的字节流是这样的:
| 阶段 | 主机发出 | 从机回应 | 说明 |
|---|---|---|---|
| 起始 | SDA 下降沿 | — | SCL 高电平期间完成 |
| 设备地址+写 | 1010 A2 P1 P0 0 | ACK | P1P0 是 10 位地址的高 2 位 |
| 字地址 | A7~A0 | ACK | 只有 8 位,范围 0x00~0xFF |
| 数据 | 1 字节 | ACK | 每字节一个应答 |
| 停止 | SDA 上升沿 | — | 停止后器件才启动内部写 |
3. 地址拆位:把 10 位地址塞进设备地址字节的写法与读写子程序
时序通了,下一个卡点是地址。24C08 的 1024 字节不是一个连续可寻址的空间,它被切成 4 个 256 字节的块,块选信号不在字地址里,而是藏在设备地址字节的中间两位。这一段是整份 24C08 读写程序里最容易写错、也最难从现象上反推的部分。
3.1 设备地址字节里的 P1P0
标准 24C 系列的设备地址格式是1 0 1 0 A2 A1 A0 R/W,但在 24C08 上,A1 和 A0 这两个位置被复用成了地址高位 P1、P0,真正还能用引脚设置的只有 A2。也就是说 24C08 的从机地址是1 0 1 0 A2 P1 P0 R/W,其中 P1P0 等于目标地址的第 9、第 8 位。
| 地址范围 | A9 A8 | 设备地址字节(写方向) |
|---|---|---|
| 0x000 ~ 0x0FF | 0 0 | 0xA0 |
| 0x100 ~ 0x1FF | 0 1 | 0xA2 |
| 0x200 ~ 0x2FF | 1 0 | 0xA4 |
| 0x300 ~ 0x3FF | 1 1 | 0xA6 |
A2 引脚决定基址:接 GND 时上表成立,接 VCC 时全部加 0x08(0xA8 / 0xAA / 0xAC / 0xAE)。这里有个设计上的硬约束——24C08 只有 A2 一个片选脚,A1、A0 在封装内部不参与片选,所以同一对总线上只能挂一颗 24C08,想挂两颗必须换 24C02 或者用 IO 口单独控制。
#define EE_BASE 0xA0 /* 1010 A2=0;A2 接 VCC 时改成 0xA8 */ /* 由 10 位线性地址生成设备地址字节(读方向由调用方再 |0x01) */ unsigned char ee_dev_addr(unsigned int addr) { /* (addr >> 8) & 0x03 取 A9A8,左移 1 位落到 bit2..bit1 */ return EE_BASE | (unsigned char)((addr >> 7) & 0x06); }(addr >> 7) & 0x06这一步把 A9A8 搬到 bit2、bit1,掩码 0x06 是二进制0110,正好盖住这两位,同时把可能溢出的其他位挡掉。等价写法是先(addr >> 8) & 0x03再<< 1,两种写法编译出来的指令数差不多,我习惯前者,少一次移位。A2 接 VCC 的时候把EE_BASE改成0xA8就行,不要在每个函数里散着写0xA0,改起来会漏。
注意:字地址永远只有 1 个字节(0x00~0xFF),高位在设备地址里。有些从 24C16 移植过来的代码会把字地址写成 2 字节,24C08 收到第二个字节会当成数据,写进去的内容直接错位。
3.2 字节写与应答轮询
24C08 的写操作在收到停止条件之后才真正开始,器件内部把数据搬进存储阵列大概需要 5~10ms,这段时间总线不再响应任何命令。这段时间有两种等法:死等delay_ms(10)或者应答轮询。死等简单但慢,轮询能在器件刚准备好的第一时间继续,实测普遍能在 3~5ms 内完成,写满 1KB 时能省掉一半以上时间。
/* 应答轮询:器件准备好会重新响应设备地址,返回 0 表示就绪,1 表示超时 */ bit ee_wait_ready(void) { unsigned int guard = 0; while (guard++ < 200) { /* 200 次约 40~60ms,远超 10ms 上限 */ i2c_start(); if (i2c_write_byte(ee_dev_addr(0)) == 0) { i2c_stop(); return 0; /* 收到 ACK,内部擦写完成 */ } i2c_stop(); } return 1; /* 超时:器件没接上、坏了或者 WP 被拉高 */ } /* 写 1 字节,返回 0 表示成功 */ bit ee_write_byte(unsigned int addr, unsigned char dat) { i2c_start(); if (i2c_write_byte(ee_dev_addr(addr))) { i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr & 0xFF))) { i2c_stop(); return 1; } if (i2c_write_byte(dat)) { i2c_stop(); return 1; } i2c_stop(); return ee_wait_ready(); }ee_wait_ready里的guard上限 200 是按最坏情况留的:单次轮询大约 300us(起始 + 1 字节 + 停止,50kHz 下),200 次约 60ms,比手册给的 10ms 上限还宽 6 倍。这个函数还有一个额外价值——它同时验证了器件是否在线。如果上电自检时ee_wait_ready就返回 1,基本可以断定是接线、上拉或者 WP 的问题,不用再往下查数据。
写函数里每一处i2c_write_byte失败都要立刻i2c_stop()把总线放掉,不能直接 return。总线停在中间状态会导致下一次起始条件失效,从机一直等数据,现象是“错一次之后全错”。
3.3 随机读、顺序读与“哑写”的差别
读操作比写多一次起始:先发设备地址+写、再发字地址,然后不发停止,直接再来一个起始条件(重复起始),改发设备地址+读,之后开始收数据。中间那一段“只写了地址没写数据”的动作就是常说的哑写(dummy write),作用是给器件内部的地址计数器置初值。
/* 从 addr 开始连续读 len 个字节,返回 0 表示成功 */ bit ee_read_buf(unsigned int addr, unsigned char *buf, unsigned int len) { unsigned int i; i2c_start(); if (i2c_write_byte(ee_dev_addr(addr))) { i2c_stop(); return 1; } if (i2c_write_byte((unsigned char)(addr & 0xFF))) { i2c_stop(); return 1; } i2c_start(); /* 重复起始,不发停止 */ if (i2c_write_byte(ee_dev_addr(addr) | 0x01)) { i2c_stop(); return 1; } for (i = 0; i < len; i++) { /* 最后一个字节回 NACK,其余回 ACK */ buf[i] = i2c_read_byte((i == (len - 1)) ? 0 : 1); } i2c_stop(); return 0; }这里有两个细节值得单独说。第一,重复起始和“停止+起始”在 24C08 上都能工作,但前者不会释放总线,从机内部的地址计数器不会被复位,事务更紧凑,速度也快一点;后者更保险,遇到时序临界的情况容错性更好。我自己在 50kHz 以下用重复起始,超过 100kHz 就改成停止+起始。
第二,顺序读会自然跨块。地址计数器是 10 位的,读到 0x3FF 之后会回卷到 0x000,不会停在块边界。所以ee_read_buf(0x3F0, buf, 32)会读回 0x3F0~0x3FF 加上 0x000~0x00F,这个行为在只读一小段参数时完全用不到,但做整片校验的时候要知道这个特性,否则会以为读到了脏数据。
想读单个字节,直接套ee_read_buf(addr, &ch, 1)就行,没必要单独写一个函数。代码体积在这种小项目上比函数数量重要。
4. 页写与写周期:把 1024 字节整块写满不掉数据的做法
单字节写能跑通,只说明时序和地址对了。真正把 24C08 用起来,绕不开页写——1KB 一个字节一个字节写,光写周期就要 10 秒,产品上根本不能接受。页写能把 16 个字节合并成一次写周期,代价是必须处理页边界。
4.1 16 字节页边界与地址回卷
24C08 内部有一块 16 字节的页缓冲,一次事务里连续送进去的字节先落在缓冲区,收到停止条件后一次性搬进阵列。缓冲区里的写指针只看地址的低 4 位,低 4 位从 0x0F 加到 0x10 时会回卷到 0x00,不会向高位进位。
这个行为导致的典型现象是:从 0x0A 开始连续写 10 个字节,前 6 个(0x0A~0x0F)正确,后 4 个落到了 0x00~0x03,把那一块原有数据覆盖掉。更麻烦的是这个过程不报错,每个字节都有 ACK,函数返回成功,只有读回来比对才发现不对。
| 起始地址 | 低 4 位 | 本页剩余 | 写 10 字节的实际落点 |
|---|---|---|---|
| 0x00 | 0x0 | 16 | 0x00~0x09,正常 |
| 0x0A | 0xA | 6 | 0x0A~0x0F 加 0x00~0x03,回卷 |
| 0x0F | 0xF | 1 | 0x0F 加 0x00~0x08,回卷 |
所以页写函数必须自己算“这一页还能放几个”,不能指望器件帮你进位。这也是很多开源例程只提供单字节写的原因——页写要额外处理边界,写不好还不如不写。
4.2 按页拆分的整片写入接口
正确的做法是分成两层:底层ee_write_page只负责“不跨页的一段”,上层ee_write_buf按页切分循环调用。这样每一层的职责都很清晰,出问题时也容易定位是边界算错还是时序出错。
/* 页写:最多写满当前页剩余空间,返回实际写入字节数,0 表示失败 */ unsigned char ee_write_page(unsigned int addr, const unsigned char *buf, unsigned char len) { unsigned char i; unsigned char room = 16 - (unsigned char)(addr & 0x0F); /* 本页还能放几个 */ if (len > room) len = room; /* 强制不跨页 */ i2c_start(); if (i2c_write_byte(ee_dev_addr(addr))) { i2c_stop(); return 0; } if (i2c_write_byte((unsigned char)(addr & 0xFF))) { i2c_stop(); return 0; } for (i = 0; i < len; i++) { if (i2c_write_byte(buf[i])) { i2c_stop(); return 0; } } i2c_stop(); if (ee_wait_ready()) return 0; return len; } /* 整段写:自动按页拆分,返回 0 表示全部成功 */ bit ee_write_buf(unsigned int addr, const unsigned char *buf, unsigned int len) { unsigned char n; while (len) { n = ee_write_page(addr, buf, (unsigned char)((len > 16) ? 16 : len)); if (n == 0) return 1; /* 中途失败,直接退出 */ addr += n; buf += n; len -= n; } return 0; }room = 16 - (addr & 0x0F)这一行是整个页写的关键。当地址低 4 位是 0x0 时 room=16,可以写满一页;低 4 位是 0x0A 时 room=6,只能写 6 个,剩下的交给下一次循环,此时 addr 已经对齐到下一页起始,room 又变回 16。整个过程不需要判断“是否跨页”,靠 room 截断就自然完成了切分。
ee_write_buf里n用unsigned char而不是unsigned int,是因为单次最多 16 字节,用 char 省一个字节的栈空间,在 51 这种 128 字节 RAM 的平台上不算浪费。另外注意循环里buf += n依赖指针类型,如果数据表放在 code 区,指针要改成const unsigned char code *,否则通用指针访问 code 区会出错。
写满整片 1KB 的调用就是这样一行,64 页、每页一次写周期,按轮询平均 4ms 算总耗时约 260ms,比单字节写的 10 秒快了两个数量级:
unsigned char code factory_default[1024] = { /* 出厂默认参数 */ }; ee_write_buf(0x000, factory_default, 1024);4.3 数据完整性:CRC8 与双槽冗余
参数区最怕的不是写不进去,是写进去了但内容坏了。掉电发生在写周期中间、总线受干扰、器件老化,都可能导致某几个字节变成随机值。24C08 本身不带任何校验,完整性得应用层自己做。
最省的做法是加一个 CRC8。多项式用 0x31(Dallas/Maxim 那套),按位算,不占表空间,1024 字节算一遍在 11.0592MHz 上大概 30ms,只在开机时跑一次,完全可以接受。
/* CRC8,多项式 0x31(x^8 + x^5 + x^4 + 1),初值 0x00 */ unsigned char crc8(const unsigned char *p, unsigned int len) { unsigned char crc = 0x00; unsigned char i; while (len--) { crc ^= *p++; for (i = 0; i < 8; i++) { if (crc & 0x01) crc = (unsigned char)((crc >> 1) ^ 0x8C); else crc >>= 1; } } return crc; }CRC 放在参数块的最后一个字节,写入时先算前面所有字节的 CRC 再存进去,读取时重算一遍比对。校验不过就退回默认值,同时把这次失败记到一个独立的计数单元里,方便现场排查到底是偶发还是芯片快挂了。
如果参数块不大,更稳的做法是双槽冗余:0x000 存主份,0x020 存备份,两份都带 CRC 和版本号。写的时候交替写,读的时候优先读 CRC 正确的、版本匹配的那一份。这样即使一次写入过程中掉电,也总有一份是完整的。
提示:不要每次参数变更都整片重写。24C08 的擦写寿命是百万次量级,但那是按“每字节每次写周期”算的,整片写 1KB 相当于消耗 1024 次寿命,一天改 10 次参数,三年就接近上限了。改单个参数只写它自己所在的那一页。
5. 避坑与排查:24C08 调不通时先看的五处现场
这一章的五条全是我自己或者身边人真实撞过的,按“现象 → 原因 → 解决”写。顺序大致是从“完全不通”到“能通但不稳”,建议按顺序排查,别跳。
5.1 通信层的三个现场
现象一:上电读参数区,每个字节都是 0xFF,CRC 永远不对。
原因通常有三个。一是 SDA/SCL 忘了外接上拉,51 的准双向口内部上拉很弱,在 50kHz 下上升沿拖到几微秒,从机采样不到高电平;二是 A2 引脚悬空,被干扰抬成高电平,设备地址整体偏了 0x08,主机发的 0xA0 根本没有器件应答;三是 WP 引脚接到了 VCC,写保护生效,写操作全部被丢弃,读回来自然是擦除态的 0xFF。
解决的办法很土但有效:万用表量 SDA、SCL 静态电平,应该都接近 VCC;量 WP 引脚应该是 GND;然后写一段自检代码,把 0xA0、0xA2、0xA4、0xA6 四个地址依次发一遍,看哪个能收到 ACK,地址对不上立刻就能定位。
现象二:写函数返回成功,紧接着读同一地址,读回来是写之前的值。
原因是没等写周期。24C08 在收到停止条件之后才开始内部擦写,这段时间总线无响应,如果程序里只有delay_ms(1)或者干脆没延时,下一次起始会落在器件忙的窗口里,被静默丢弃。有些批次更坑——器件不返回 NACK,主机以为发出去了,实际什么都没发生。
解决就是把ee_wait_ready补上,实在不想写轮询就老老实实delay_ms(10)。判断有没有这个问题有个快招:在写和读之间插delay_ms(20),如果读对了,百分之百是写周期没等够。
现象三:短排线一切正常,接上 30cm 排线后偶发失败,冷机正常热机出错。
原因是总线电容和上拉电阻构成 RC 低通,线越长电容越大,上升沿越缓;温度升高后器件输入阈值还会漂一点,边沿刚好卡在阈值附近就出现“有时候认、有时候不认”。
解决分三步:上拉从 10k 降到 4.7k 甚至 2.2k;把 SCL 频率降到 30kHz 以下(延时函数的 4 改成 8);SDA 和 SCL 走线分开,中间夹一根地线。这三步做完还不行的,基本是板上走线本身有问题,得改板。
5.2 数据层的两个现场
现象四:从 0x0A 开始写 10 个字节,前 6 个对,后 4 个跑到 0x00 去了,把原来的数据冲掉。
原因就是页回卷。24C08 的页缓冲只有 16 字节,写指针只看地址低 4 位,到 0x0F 之后回卷到页首,不会向高位进位。这个过程每个字节都有 ACK,函数不报错,只有比对数据才发现。
解决办法是按页拆分,用16 - (addr & 0x0F)算出本页剩余空间,超过就截断,交给下一次循环。写完顺手做一次读回比对,这类错位问题在调试阶段就能拦住,不会流到现场。
现象五:24C02 的程序直接搬到 24C08,0x00~0xFF 正常,0x100 以上全乱。
原因是两者的地址模型完全不同。24C02 是 8 位地址,设备地址里的 A2A1A0 全做片选,发完设备地址只跟 1 个字节的字地址;24C08 是 10 位地址,高 2 位挪到了设备地址的 bit2、bit1,字地址仍然只有 1 个字节。直接搬过来的代码会把 0x100 当成字地址发出去,实际发的是 0x00,高位丢失,所有超过 255 的地址全部落到低 256 字节上互相覆盖。
解决就是把设备地址生成函数单独抽出来,按EE_BASE | ((addr >> 7) & 0x06)重写,然后写一个全片遍历测试:0x000 到 0x3FF 每个地址写自己低 8 位的反码,再整片读回比对,一遍就能确认地址映射对不对。
6. 进阶:参数区分槽存储与老化验证
参数区的最终形态我一般做成一个带版本号、CRC 和双槽的结构体,而不是散着一堆全局变量各写各的地址。理由很实际:产品迭代时参数结构一定会变,没有版本号就没法区分“旧固件写的合法数据”和“新固件读到的垃圾”;没有双槽就没有后悔药,写一半掉电直接回到出厂设置,用户会投诉。
typedef struct { unsigned char magic; /* 固定 0x5A,标识本区曾被正确初始化 */ unsigned char version; /* 结构体版本,升级后旧数据自动判废 */ unsigned char crc; /* 前面所有字节的 CRC8 */ unsigned char bright; /* 亮度 0~100 */ unsigned char volume; /* 音量 0~30 */ unsigned char mode; /* 工作模式 */ unsigned int run_hours; /* 累计运行小时 */ } sys_param_t;两个槽放在 0x000 和 0x020,各占 32 字节,留出扩展余量。写入时先读当前有效槽的序号,写到另一个槽,写完做一次 CRC 校验,通过了才算提交;读的时候两槽都读出来算 CRC,优先取校验通过且version匹配的,两个都坏才回默认值。
| 槽位 | 起始地址 | 用途 | 提交顺序 |
|---|---|---|---|
| 主槽 | 0x000 | 当前有效参数 | 后写 |
| 备槽 | 0x020 | 上一次有效参数 | 先写 |
老化验证不用搞得很复杂。我常用的土办法是写一个循环,把 0x000~0x3FF 轮流写 0x55 和 0xAA 各 200 遍,每遍整片读回比对,中途断电十几次看能不能靠双槽恢复。这个过程大概跑十几分钟,能把页边界、写周期、掉电恢复三类问题一次性暴露出来。另一个必做项是高低温,条件不允许的话至少用热风枪对着芯片吹到 60℃ 再跑一遍同样的循环——我见过一批片子常温一切正常,温度一上来写周期就从 5ms 涨到 12ms,死等 10ms 的代码全部失效。
从那以后我每次接新板子,第一件事不是写业务代码,而是先跑一遍全片 0x55/0xAA 遍历加 CRC 校验,把地址映射、页边界、写周期这三件事钉死,再往上叠参数区。这套流程帮我省掉的返工时间,远比写这几个函数花的时间多。希望帮到你。
本文还有配套的精品资源,点击获取