
上周帮朋友看一块返修板子现象很典型设备在现场跑了三个月突然所有标定参数变成默认值屏幕上显示的零点偏移直接归零。拆下来用编程器读存储区里整整齐齐全是0xFF像是从来没被写过。换一片新的EEPROM烧进去好了过两周又坏。这种偶发、不规律、换片就好的故障最后八成能追到EEPROM的写入时序和数据管理上。EEPROM这三个字在嵌入式圈子里出现频率极高但真正把它从存储原理、I2C时序、到FPGA和单片机两端的读写实现完整串一遍的资料并不多大多数人都停留在调个库函数能用就行的阶段。这篇文章就按我自己的理解路径从底层浮栅结构讲起一路讲到I2C帧结构、Verilog主机状态机、中颖单片机上的寄存器操作以及量产项目里真正管用的可靠性手段。不管你是刚接触存储器件的新手还是已经写过几版驱动、但总在页写和掉电保护上翻车的老手应该都能从中找到点能直接抄走的东西。1. 掉电不丢的那一小块存储EEPROM到底站在哪个位置1.1 RAM、Flash、EEPROM三者的分工不是随便定的理解EEPROM的价值最好的方式是看它在系统里替谁干活。RAM读写快、可以按字节无限次改但一断电内容全没Flash容量大、单位成本低但写入必须按扇区或页擦除擦一次几百毫秒到几秒而且擦写寿命通常只有一万次上下。EEPROM恰好卡在中间它像RAM一样支持字节级读写不需要先擦后写掉电后数据还在擦写寿命能到百万次量级。它容量小、单位价格贵所以注定不会用来存程序或大数据它的战场只有一个——存那些改动不频繁、但绝对不能丢的小块数据。具体是哪些数据设备的序列号、MAC地址、出厂校准值比如传感器的零点、增益、用户设置亮度、音量、语言、运行累计时长、故障记录、加密密钥。这些东西有个共同特点总量可能只有几十到几百字节但每次开机都要读每次参数调整都要写而且要经得起几千上万次的反复擦写。用Flash存这些数据会面临改一个字节要擦整个扇区的尴尬而且很快就把扇区寿命耗尽用RAM加备用电池又有电池寿命和可靠性问题。所以EEPROM在这类场景里几乎没有替代方案。我在选型时的经验是先估总写入次数 × 单次写入字节数再乘一个安全系数。如果一块板子在十年寿命内对某个存储区的写入次数超过十万次就必须考虑磨损均衡而不能简单地改一次覆盖一次。这个估算很多人在设计初期会漏掉等到现场返修才发现某个计数器每分钟写一次EEPROM。1.2 浮栅晶体管一个bit到底是怎么被写进去的EEPROM的存储单元本质是一个带浮栅的MOS管。它比普通MOS管多了一层多晶硅浮栅这层浮栅被绝缘氧化层完全包裹四周不接任何电极处于电学上的悬空状态。正因为悬空注入到浮栅上的电荷没有泄放路径断电后也能长期保留这就是非易失的物理根源。写入和擦除靠的是隧穿效应。当控制栅和漏极之间加上足够高的电压典型十几伏由芯片内部的电荷泵从3.3V或5V升压得到氧化层里会形成强电场电子就有一定概率穿过氧化层到达浮栅或者从浮栅回到衬底。电子注入浮栅后管子的开启阈值电压被抬高电子移出后阈值恢复偏低。读出时给控制栅加一个介于两个阈值之间的参考电压管子导通就读作一种状态截止就读作另一种状态。整个过程的判断依据是阈值电压的高低而不是有没有电荷这种绝对量这也解释了为什么反复擦写会让阈值窗口逐渐收窄最终擦写失效。这里有个容易被忽略的点擦写次数之所以有限是因为每次隧穿都会在氧化层里留下微量陷阱电荷和结构损伤积累到一定程度浮栅上的电荷就会缓慢泄漏数据保持能力下降。厂商标称的100万次擦写、40年数据保持是在特定温度、特定电压、单字节反复擦写条件下测出来的实际使用中温度越高、电压越偏离标称值寿命衰减越快。工业级应用留一倍余量是比较稳妥的做法。1.3 一个字节的读写为什么会占用毫秒级时间很多人第一次写EEPROM驱动时会困惑I2C总线跑400kHz一个字节加ACK也就九个时钟周期二十多个微秒的事为什么写完还要delay(5)原因是EEPROM芯片内部在收到数据后需要启动电荷泵把电压升上去然后完成浮栅注入这个过程是物理级的快不了。典型的字节写周期是5ms页写周期也是5ms左右——注意写一页和三十二个字节的耗时几乎一样因为时间主要花在电荷泵升压和隧穿过程上而不是数据传输上。这个特性直接决定了驱动的设计思路能用页写就绝不用字节写。写32字节字节写要160ms页写只要5ms差三十倍。同时它也决定了写操作必须等完成——写完一个字节后器件进入内部写周期这段时间它不会响应任何I2C命令你发过去的地址和数据会被直接忽略。所以要么死等5ms要么用应答查询ACK polling的方式反复发器件地址直到器件回复ACK说明内部写周期结束。后者能显著提升写入效率尤其是在连续写多页时。2. 从存储阵列到地址映射一次读取背后发生了什么2.1 行译码、列译码与灵敏放大器EEPROM内部并不是一个平坦的字节数组而是按行列组织的存储矩阵。假设一片2Kbit的器件内部可能是64行×32列的单元阵列每个单元存1bit。要给某个bit定位行译码器负责选中一行把该行的字线拉高列译码器负责把选中的列接到读出通路上。真正把微弱信号放大成数字电平的是灵敏放大器它比较目标单元和参考单元的电流差输出稳定的0或1。为什么存储原理对写驱动有实际意义因为它决定了读和写对时序的要求完全不同。读操作只需要把地址送进去内部译码和放大在几百纳秒内完成所以在I2C上读数据可以连续流式输出。写操作却要经过锁存地址和数据 → 启动电荷泵 → 逐行编程 → 校验这一整套流程每一步都有内部状态机在跑。理解了这一点就能理解为什么随机读EEPROM可以很快而随机写一定要等也就能理解为什么有些器件在写周期内拉低SCL会被忽略。2.2 页缓冲页写为什么能一次写多个字节页写之所以高效靠的是芯片内部一个页缓冲寄存器。主机通过I2C把若干个字节连续送进这个缓冲区每收到一个字节内部地址指针自动加一器件会回一个ACK。当主机发出停止条件STOP后器件才把整个缓冲区的内容一次性并行写入存储阵列。注意这里的关键点数据和地址是先攒在缓冲里STOP之后才真正落盘。这就引出了两个必须小心的行为。第一缓冲区的写入是在STOP之后才启动的如果你在STOP之前发起了新的START整个写序列会被当作异常终止数据不会写入。第二缓冲区的大小等于页大小不同型号不一样24C02是8字节24C08/24C16是16字节24C32及以上多为32字节也有一些是64或128字节。写之前一定要去datasheet里确认页大小而不是凭型号猜。2.3 容量代际带来的地址结构差异从24C02到24C512看起来只是容量变大实际上地址结构发生了两次跳变这是驱动移植时最容易出错的地方。型号区间容量字地址字节数器件地址中的地址位页大小典型值24C01/24C02128B/256B1A2 A1 A0 全用作片选8字节24C04/08/16512B/1K/2K1部分引脚转为页地址位16字节24C32 及以上4K以上2A2 A1 A0 全用作片选32字节24C02的地址空间只有256字节一个字节的字地址刚好够用所以器件地址里的A2、A1、A0三个引脚全部用来做片选一条总线上最多挂八片。到了24C04容量涨到512字节需要9位地址于是把A0引脚征用为地址最高位实际片选只剩A2、A1。24C08、24C16同理分别占用了A1、A0和A0。等到24C32容量超过2K字节一个字地址不够了于是改成两个字节的字地址A2、A1、A0又全部回归片选功能。我踩过的坑就在这里把一个基于24C02写的驱动直接换到24C16上器件地址的宏定义没改结果访问低256字节正常访问高256字节全乱——因为原本当作片选用的A0位在24C16上其实是地址位写成了固定值就永远只落到一半空间里。移植时先看地址表比事后debug省事得多。3. I2C时序逐帧拆解写一个字节为什么能翻车3.1 开漏、上拉与速率三件套I2C的电气结构是开漏输出加上拉电阻所有设备只能把线拉低不能主动拉高。这个设计的目的是实现线与支持多主多从和总线仲裁但也带来几个实际约束。上拉电阻不是随便选的它和总线电容构成RC充电回路决定了上升沿的速度。总线电容典型估算为每挂一个器件加10pF左右加上走线电容一条总线上四五个器件很容易到50pF以上。100kHz标准模式下4.7kΩ上拉通常够用跑到400kHz快速模式上拉要降到2.2kΩ甚至1.5kΩ否则上升沿太缓在高速下根本来不及到达高电平门限表现为通信偶发失败。我见过现场问题是常温正常、高温偶发读失败最后查到是上拉偏大加上线缆电容偏大高温下漏电流增加让上升沿更差。另一个容易忽略的是SCL和SDA的走线必须尽量等长、远离开关电源的SW节点和电机驱动线。EEPROM的写入电流很小但对干扰敏感一条贴着DC-DC电感走的SDA线可能让写周期莫名其妙地被复位。3.2 器件地址、字地址、页地址谁管谁I2C通信的第一帧永远是器件地址帧七位地址加一位读写方向位。以24C02为例固定前缀是1010加上三个片选引脚写成二进制1010 A2 A1 A0 R/W。假设A2A1A0都接地那么写操作器件地址就是10100000即0xA0读操作是0xA1。紧接着是字地址Word Address也就是要访问的存储单元在芯片内部的偏移。24C02用一个字节表示0x00到0xFF。之后才是数据字节。所以一次典型的写一个字节到偏移0x10的完整帧是主发START主发0xA0从回ACK主发0x10字地址从回ACK主发数据从回ACK主发STOP从启动内部写周期读操作要分成两步先用写方向发一次器件地址和字地址这叫伪写或设定地址指针然后重新发START叫重复起始条件再发读方向器件地址0xA1之后从机开始吐数据。这里有个常见错误有人在设定地址后直接发STOP然后再发START读这样也能工作但中间给了总线空闲的机会多主机环境下可能被别的设备抢走。正确做法是用重复起始条件把两次操作连起来不让总线释放。3.3 页写回卷最经典的一次数据覆盖事故现在说到最重要的一个坑。页写的内部地址指针只有页内低位一旦越过页边界它不是跳到下一页而是回卷到当前页的开头。举个具体例子24C02页大小8字节页边界在0x00、0x08、0x10……如果你从0x06开始连续写8个字节写入过程是0x06、0x07、然后指针回到0x00接着写0x00到0x05。最终结果是你原本想写的数据和你之前存在0x00到0x05的数据互相覆盖一大片数据被毁了而且器件不会有任何报错。这个坑在存参数表时特别致命。假设参数结构体有24字节代码直接调一次页写从偏移0x00开始写24字节而页大小只有8实际结果是数据在页内来回覆盖最终存储的内容取决于覆盖顺序读出来完全乱套。正确的做法是写之前计算起始偏移把这一页能写的字节数算出来写满一页就换页中间发STOP触发写周期再重新发起下一段写。/* 按页边界拆分写入避免回卷 */ void eeprom_write_page_safe(uint16_t addr, const uint8_t *buf, uint16_t len) { while (len) { uint16_t page_rest PAGE_SIZE - (addr % PAGE_SIZE); uint16_t chunk (len page_rest) ? len : page_rest; eeprom_write_page(addr, buf, chunk); /* 内部以STOP收尾并等待写完成 */ addr chunk; buf chunk; len - chunk; } }注意拆分点不是每页大小而是距离本页末尾还剩多少。上面的page_rest就是干这个的少算这一步64字节的页写照样会回卷。4. FPGA侧用Verilog搭I2C主机状态机怎么分才不会锁死4.1 分频、边沿与三态控制的写法FPGA没有硬件I2C外设要连EEPROM就得自己用逻辑搭一个主机。核心是三件事时钟分频、SCL/SDA的三态控制、以及一个能处理应答和超时的状态机。以50MHz系统时钟和100kHz I2C速率为例I2C一个完整位周期需要500个系统时钟每个位分成四段拉低、上升沿采样、拉高、下降沿更新每段125个时钟。实际写的时候不用真分四段比较常见的做法是维护一个计数器在特定计数值上翻转SCL并更新SDA。// 50MHz - 100kHz每位500个时钟半周期250 localparam HALF 8d249; reg [7:0] div_cnt; reg scl_r; always (posedge clk or negedge rst_n) begin if (!rst_n) begin div_cnt 8d0; scl_r 1b1; end else if (busy) begin if (div_cnt HALF) begin div_cnt 8d0; scl_r ~scl_r; // 每250个时钟翻转一次SCL end else begin div_cnt div_cnt 1b1; end end else begin div_cnt 8d0; scl_r 1b1; // 空闲时SCL拉高 end end三态控制的关键是SDA不能写成assign sda sda_out必须分成输出值和输出使能两个信号。因为I2C是开漏主机只在一部分时间驱动SDA其余时间要让线浮空由从机或上拉决定电平。写错了会直接烧总线的数据方向表现为读回来的永远是主机自己输出的值。assign sda sda_oe ? sda_out : 1bz; // sda_oe0 时释放总线 assign scl scl_oe ? scl_out : 1bz;在FPGA上SDA和SCL要接到带真实上拉的引脚上不能依赖内部上拉大多数FPGA IO的内部上拉很弱几百微安级别跑400kHz根本拉不上去。板级上必须是实打实的电阻上拉。4.2 状态划分按字节事务而不是按位来切新手写I2C状态机最常见的错误是把状态切得太细比如START、BIT0、BIT1……BIT7、ACK这样状态数量爆炸而且很难处理写周期等待这种跨字节的流程。我的建议是按字节级事务来分状态一个状态里用一个位计数器把八个位循环发完。状态划分大致是这样状态动作出口条件IDLE等待启动命令收到start_reqSTART产生起始条件延时完成SEND_BYTE移出8位含器件地址/字地址/数据bit_cnt到8RECV_ACK释放SDA采样从机应答SCL上升沿后采样RECV_BYTE主机接收8位数据bit_cnt到8SEND_ACK主机发送应答或非应答延时完成STOP产生停止条件启动写周期延时完成WAIT_ACK_POLL轮询器件应答直到就绪收到ACK这个划分的好处是SEND_BYTE可以复用给器件地址、字地址和数据只要在进入前把tx_byte寄存器和is_ack_expect设好。整个状态机只有八九个状态却覆盖了读写和写周期等待。4.3 超时保护与上板实测的几个细节FPGA状态机最大的风险是死锁如果从机因为任何原因不回复ACKSEND_BYTE和RECV_ACK之间就会互等整个状态机卡住上层再也发不出命令。所以每个等待状态都必须挂一个计数器做超时比如超过一个位周期的四倍还没等来时钟变化就强制跳到STOP或者IDLE并置一个错误标志。上板调试时我会把关键内部信号引到逻辑分析仪上状态机的当前状态、bit_cnt、div_cnt的高几位、sda_oe。真正有效的判断方法是看sda_oe在ACK位是否释放——如果ACK位sda_oe还是1说明主机没让出总线从机根本没机会应答读回的ACK永远是0。这个错误我见过不止一次根源就是把SDA的驱动和采样混在一个always块里忘了释放。另一个实测细节是写周期等待的时序。发出STOP后器件进入5ms的内部写周期这段时间它不响应任何命令。如果你立即发下一次START加器件地址会收到NACK。用ACK轮询是更高效的做法每隔100微秒发一次器件地址帧收到ACK说明写完了。在Verilog里可以用一个计时器控制轮询节奏注意每次轮询失败也要走完整的START-STOP流程不能只发地址帧就停否则总线状态会残留在中间。5. 中颖单片机上的EEPROM操作寄存器视角与固件层设计5.1 内建EEPROM区和外挂I2C器件的选择逻辑中颖的很多MCU比如常用的SH79F、SH88F系列片内自带EEPROM或数据Flash区不需要外挂器件这对成本和板面积都是好事。选择内建还是外挂我一般从三个维度判断。第一看写入频率和寿命。片内EEPROM的擦写寿命通常在十万次量级低于外挂的百万次如果应用里有高频写入比如每分钟记录一次运行状态内建方案就必须配磨损均衡否则寿命扛不住。第二看隔离性程序区和数据区如果是同一块Flash但分区使用一旦程序擦写出错可能波及数据区而独立的外挂EEPROM天然隔离。第三看掉电时机外挂器件的写周期有明确的5ms预算主机可以在检测到掉电时靠大电容撑住这5ms而片内EEPROM的操作由CPU直接发起掉电瞬间更容易出现半写状态。对于参数存储这类低频写入场景片内EEPROM完全够用省掉一颗料和两根上拉线对于记录类的高频写入我宁可加一颗外挂的24C系列把风险隔离出去。5.2 写使能、忙等待与中断冲突片内EEPROM的操作模式和绝大多数Flash类似大致路径是解锁/使能写操作 → 写入目标地址和数据 → 触发编程 → 等待忙标志清零 → 关闭写使能。不同型号的寄存器名和位定义不一样动手前一定去翻对应的数据手册不要照抄别的型号。这里有两个实际项目里反复出现的问题。第一是写使能和实际操作之间的时间窗。有些型号的写使能是一个软件置位、编程完成后自动清除的标志如果在置位和触发编程之间插入了中断中断里又恰好有别的操作打乱了状态编程就会失败。稳妥的做法是在写EEPROM的临界区里关闭总中断操作完再恢复。第二个是忙等待。忙标志置位期间CPU去读这个区域读到的是不确定值。有些人的代码在忙等待里做了别的事结果读回来的参数是半写状态。我的习惯是写操作一定要走写 → 查标志 → 确认成功 → 再读回校验这个完整链路中间不做任何读取。提示如果MCU支持在EEPROM编程期间执行其他代码有些型号允许也尽量别用除非你能确认编程期间CPU不会访问同一块存储区域。5.3 参数表的结构设计从第一版就考虑到坏块固件层的参数存储我建议从一开始就按版本号 长度 内容 校验的结构来做而不是简单地memcpy一整个结构体。一个实用的布局是每个参数块前面放一个两字节的魔数比如0x5A5A、一字节版本号、一字节长度然后是数据本身最后是CRC16校验。开机读取时先验魔数再验长度和校验任何一项不过就认为这块数据无效回落到默认值并把参数无效标志传给上层。这样做的好处是即使写入过程中掉电导致数据损坏系统也不会拿着错误的参数去控制硬件——这一点在电机、电源这类应用里是安全底线。版本号的作用是参数结构升级。产品迭代时新增了一个字段如果没有版本号旧固件读新数据或者新固件读旧数据都可能错位解析。带上版本号后读到不认识的版本就丢弃并重建逻辑简单又安全。另外建议把经常变的和几乎不变的参数分开存放。出厂校准值可能终身只写一次用户设置可能每天改几次把两者混在同一个页里会导致每次改用户设置都要重写出厂校准值的那个页白白消耗它的擦写次数。拆成两个独立区域寿命互不影响。6. 让参数真正存得住的几招工程手段6.1 双区备份与写入顺序参数区做双备份是最简单也最有效的抗掉电手段。准备A、B两个相同的参数块写入时永远写当前非活动的那一块写完后校验成功之后再把活动标志切过去。这样即使写B的过程中掉电B可能是坏的但A完好开机读A就恢复了。代价是存储空间翻倍但对于几十字节的参数表来说这点空间完全值得。写入顺序有个细节先写数据区再写标志位而且标志位本身也要带校验。如果把活动块标志放在数据之前写掉电就会造成标志说这块有效、数据其实是旧的这种更危险的情况。顺序反了故障模式就从数据是旧的变成系统相信了一块错误数据后者更糟。6.2 简易磨损均衡别让一个地址被写死磨损均衡听起来复杂实际在参数存储场景里有个非常朴素的实现把同一份数据在存储区的多个槽位轮流写。比如分配8个槽位每个槽位存一份完整的参数块加序号。写入时找到序号最大的槽位把新数据写到它后面那个槽位序号加一。读的时候扫描所有槽位取序号最大且校验通过的那一份。八个槽位轮一圈单点擦写次数就降为八分之一寿命直接翻八倍。这个方案的额外好处是天然的掉电保护写入过程中掉电最多是当前槽位损坏前一个槽位仍然完好。代价是每次读取要扫描多个槽位但对上电初始化来说十几微秒和几百微秒的差别可以忽略。唯一要注意的是序号回卷。序号写到最大值后会归零此时需要一次全区域擦除/重排把最新的一份数据挪到槽位0并把序号重置。这个操作要做得足够健壮因为它是唯一会同时动到多个槽位的时刻。6.3 掉电瞬间的风险与对策前面反复提到写周期要5ms这5ms就是对掉电保护提出的硬指标。如果系统在发出写命令后10微秒就断电了写操作被中途打断那一页数据大概率是损坏的。对策分两层。硬件层在电源输入端放一个足够大的电容保证掉电后主控还能工作几十毫秒足以完成一次正在进行的写操作并做校验。具体电容值要按系统电流算假设掉电后需要维持30mA、持续20ms允许电压从5V掉到3.3V那么C I×t/ΔV 0.03×0.02/1.7 ≈ 353μF取470μF留余量。这个计算很多人在设计时完全没做结果就是偶尔丢参数。软件层的对策是让掉电检测可以打断写操作。在发出写命令前先检查掉电标志如果检测到电压已经开始下降就直接放弃这次写入宁可丢一次更新也不要写坏一整页。同时配合双备份即使放弃写入上一次的完整数据还在。硬件加软件两层兜住才能真正做到量产环境下参数不丢。我个人在实际项目里的体会是EEPROM的问题几乎从来不在能不能读写而在边界条件处理得够不够细。页回卷、写周期等待、掉电半写、地址结构差异这四个点覆盖了我遇到过的大多数EEPROM相关的疑难故障。写完驱动后我习惯专门做一轮异常测试在写操作的不同时刻强制断电、把起始地址故意设在页中间、把上拉电阻换成大一档看通信是否还稳定。这几项测试跑下来问题基本就暴露得差不多了。