ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OTP/EEPROM读取与数据解析:从I2C时序到掉电保护完整指南

OTP/EEPROM读取与数据解析:从I2C时序到掉电保护完整指南 我做过一套湖泊水质监测的遥测终端要求断电重连之后所有校准参数和累计流量必须原样恢复。板子用的是中颖单片机片上Flash存固件参数就得往外部EEPROM里放而且不同批次的产品还要区分设备地址和出厂日期。当时遇到的问题很典型掉电丢参数、连续写入后数据错乱、I2C总线上偶尔出现死锁。后来整套逻辑重写我才把OTP和EEPROM这两类存储器的读取和处理摸透。最近又接到一个活儿要从一块报废仪表的板上把配置参数捞出来。芯片丝印被磨掉了PCB上只有四个脚连着拿万用表一量发现是I2C接口大概率是AT24C系列的EEPROM。另外一个案子更麻烦客户想知道某个传感器出厂时校准数据怎么存的非要逆向固件里那段读取OTP的代码折腾了好几天。这些项目串起来我发现很多工程师其实都卡在“怎么把OTP/EEPROM的数据完整读出来并且正确处理成可用的业务数据”这一步上。这篇文章我就围绕OTP/EEPROM读取与处理把硬件层面怎么接线、I2C时序怎么把握、读回来的原始二进制怎么解析、文件系统怎么设计、以及那些踩过的坑全部写清楚。1. 内容整体设计与思路拆解1.1 EEPROM 是什么、能用在哪EEPROM全称是Electrically Erasable Programmable Read-Only Memory电可擦除可编程只读存储器。名字里带“只读”但实际可以擦写只是擦写次数有限AT24C02标称100万次工业级可能更保守实际用就按10万次去设计比较稳妥。它的核心价值是掉电不丢失。MCU内部的Flash虽然也能存数据但很多低成本的8位单片机Flash擦写次数只有1万次左右而且按页擦除不适合频繁修改小数据。EEPROM按字节寻址、按字节改写有几个字节要更新就写几个字节非常适合存设备参数、校准系数、序列号、计数值。OTP就完全是另一回事了。OTP全称One-Time Programmable一次性可编程。内部结构通常是熔丝或者反熔丝阵列出厂时全部为1或0每次编程就是把某些位永久翻转。一旦写入不能改也不能擦。它不存在“把0写成1”的操作空间。断开的熔丝不可能再物理接回去。OTP最常见的用途是存根密钥、唯一ID、校准常数、安全启动的哈希值。很多MCU内部自带一小块OTP区域比如STM32的Option Bytes里就有一块用户OTP区。1.2 为什么要把读取和处理放在一起分析很多开发老手都有同一个毛病以为EEPROM读回来就是最终数据直接强转结构体指针去用。结果数据错位、字节序不对、CRC校验失败查了半天发现是存储布局和读取逻辑没有统一规划。读取不只是“把字节从芯片里调出来”还包含三件套物理读取正确时序拿到原始字节流逻辑校验验证数据是否完整、未损坏语义解析把原始字节翻译成温度值、MAC地址、计数字等OTP也一样。虽然物理上只能烧录一次但读出的时候要考虑读保护位是否被置位、访问地址是否需要解锁、读回来的数据是否需要经过SM3或者某种校验算法解密才能还原出真正的明文。我接触过的好几个方案都是把OTP里存的内容当做密文MCU上电后先读出OTP再算一遍哈希做比对对不上就拒绝启动。所以读取和处理必须当成一套完整流程来设计。1.3 不同接口方案的对比与选型读取OTP/EEPROM接口方案常见有三种。方案接口适用场景优点缺点板载MCU直读I2C/SPI产品自检、正常运行无需额外硬件成本低程序逻辑复杂调试工具少编程器离线读取专用引脚夹/座产线烧录、逆向分析读取稳定安全可靠需要适配芯片型号逻辑分析仪抓包I2C/SPI总线监听实际通信定位软件问题低成本无侵入需要有正常通信做参考选型原则很简单如果是自己产品里的芯片直接用MCU读取最方便如果是陌生板卡上的芯片先查PCB走线确定接口再用逻辑分析仪监听上电阶段是否有初始化读操作最后再看要不要动用编程器。我用过WELLON的编程器也用过热风枪把芯片拆下来换到测试座里读。说实话能不拆就不拆。拆焊有风险而且某些OTP芯片读取次数也有限制断电重焊可能影响引脚氧化接触。能飞线读取的尽量飞线。2. 核心细节解析与实操要点2.1 I2C 读取EEPROM的原理与代码实现I2C总线读取EEPROM的标准流程是起始条件SDA在SCL高电平期间拉低发送设备地址写位例如AT24C02的设备地址是0xA0等待ACK发送寄存器地址EEPROM内部的存储地址等待ACK重新发送起始条件发送设备地址读位0xA1接收数据每收完一个字节发ACK收最后一个字节发NACK停止条件SDA在SCL高电平期间拉高以AT24C02为例它的存储容量是2Kbit也就是256字节地址范围0x00-0xFF。如果是AT24C512容量64KByte地址就需要两个字节来存放。我用单片机读取的时候软件I2C最怕的就是时序不对。EEPROM对时钟频率很敏感标准模式最大100kHz快速模式400kHz。我用GPIO模拟干脆把时钟降到50kHz稳定第一。关键代码如下这是标准的软件I2C读一字节uint8_t eeprom_read_byte(uint8_t dev_addr, uint16_t mem_addr) { uint8_t ret 0xFF; i2c_start(); i2c_write_byte(dev_addr 0xFE); // 写位 i2c_wait_ack(); if (mem_addr 0xFF) // 16位地址 { i2c_write_byte(mem_addr 8); i2c_wait_ack(); } i2c_write_byte(mem_addr 0xFF); i2c_wait_ack(); i2c_restart(); i2c_write_byte(dev_addr | 0x01); // 读位 i2c_wait_ack(); ret i2c_read_byte(); // 读一个字节 i2c_send_nack(); i2c_stop(); return ret; }如果希望连续读例如读128个字节的配置块就用顺序读模式。EEPROM支持地址自动递增主机持续发送ACK直到读够了再发NACK结束。这样比单字节循环读快得多而且减少了总线切换次数出错概率也低。2.2 OTP 的读取方式与特殊处理OTP的读取方式和普通EEPROM有一定差别。有些OTP芯片内部结构是独立的熔丝阵列地址映射到固定的存储区间有些则是挂在MCU内部总线上的特殊寄存器区。我处理过几颗国产MCU内部的OTP区读取方式一般是配置选项字节开启OTP读取使能然后像读普通Flash一样按地址读取。但要注意的是OTP读出来的是原始物理比特不代表真的业务数据。我曾经接手过一个案子客户声称OTP里存了校准温度但读出来全是0x00。查来查去发现写入的时候固件把校准温度先做了异或加密再烧进OTP。我这边直接读物理地址当然全是0因为物理地址烧录的是密文。后来找到固件里的解密函数又用Python去模拟才算把真实值还原出来。所以处理OTP读取的时候要始终问自己我读到的这些字节是最终数据还是某种编码之后的中间产物OTP还有一个很坑的点读保护。有些OTP区域如果被设置了读保护位那么任何外部工具都无法再读出内容只能读出全0或全1。这种保护一旦生效就没有后悔药。我做逆向的时候先用编程器读一下配置区看看读保护位是否置位。如果置位了就不浪费时间了直接告诉客户芯片内容不可读。2.3 电平匹配与总线隔离的工程问题I2C总线最常出现的故障就是电平不匹配。MCU供电3.3VEEPROM供电5V上拉电阻接到了5V那MCU的I2C引脚就可能被钳位到5V而损坏。我踩过一次真实的大坑板子上主控是STM32F1033.3V供电AT24C256的VCC接5V结果总线上拉电阻直接接到了5V电源。读数据时前面几十次正常后来I2C引脚烧了整板报废。正确的做法是如果电平不一致要么用PCA9306做电平转换要么把EEPROM的供电改成和MCU一致。EEPROM工作电压范围通常很宽1.8V到5.5V都有直接统一用3.3V供电上拉电阻也接3.3V简单又可靠。SPI接口的EEPROM相对I2C通讯速率更高但是引脚多一根而且读时序上要注意读指令后先等一个dummy字节再取数。25系列SPI EEPROM读取的流程是拉低CS发0x03读指令发24位地址然后连续读数据最后一个字节读完拉高CS。3. 实操过程与核心环节实现3.1 从一张陌生板卡上抓出EEPROM数据有一次客户送来一块报废的PLC模块说里面有组IP地址和网关配置想读出来。我拿到板子先肉眼观察发现一个SOIC-8封装的芯片丝印被磨得只剩一个角PCB丝印标着U5。旁边的走线很细顺着走线量到主控的PB6和PB7基本可以断定是I2C。这时候我没急着拆芯片而是先把逻辑分析仪的通道夹到SDA和SCL上GND接板子的地然后给板子上电。结果发现主控上电后大约200ms就开始发起读操作地址是0xA8读0x00到0x0F。这个地址看起来怪正常EEPROM都是从0xA0开始。后来我查了主控型号发现它把EEPROM挂在了一个带偏移的地址上。既然主控自己会读说明读时序是对的芯片型号大概率也是对的。我没拆芯片直接用编程器夹子夹住SOIC-8选择AT24C512全片读出64KB数据。打开Hex文件一看0x00-0xFF区间里能找到一串ASCII码正是客户要的IP和掩码。剩下的数据是梯形图逻辑跟本次目的无关。整个过程核心在于先分析接口再决定读取手段不要一上来就拆芯片。如果逻辑分析仪能看到通信就按照抓到的设备地址和寄存器范围去读如果看不到任何通信那就只能拆下来放测试座。3.2 用 STM32 写一个完整的 EEPROM 读取与处理函数项目需要从一个传感器模块里把校准表读出来校准表由300个float数组成存在外部EEPROM中。主控是STM32L051用硬件I2C1。我实现的完整处理流程分三步初始化I2C、读取原始字节到缓冲区、解析浮点数组并做有效性校验。float calibration_table[300]; void load_calibration_from_eeprom(void) { uint8_t buf[1200]; uint16_t base_addr 0x0800; uint32_t crc_stored, crc_calc; // 1. 连续读取1200字节 if (HAL_I2C_Mem_Read(hi2c1, EEPROM_ADDR, base_addr, I2C_MEMADD_SIZE_16BIT, buf, 1200, 1000) ! HAL_OK) { error_handler(I2C read failed); return; } // 2. 解析float数组 for (int i 0; i 300; i) { memcpy(calibration_table[i], buf[i * 4], 4); } // 3. 读取尾部存好的CRC32 crc_stored (buf[1196] 24) | (buf[1197] 16) | (buf[1198] 8) | buf[1199]; crc_calc crc32_buf(buf, 1196); if (crc_stored ! crc_calc) { error_handler(CRC mismatch, calibration invalid); return; } // 4. 把解析结果通过串口输出 for (int i 0; i 300; i) { printf(%.4f\n, calibration_table[i]); } }这里有两个容易忽略的点。第一是HAL_I2C_Mem_Read的第三个参数地址宽度要选对。AT24C02是8位地址AT24C512是16位地址。选错会导致读回的数据错位或者直接超时。第二是CRC校验。校准表这种数据对完整性要求极高少一个字节整个曲线就偏了。我的习惯是存储区尾部预留4字节CRC32每次软件启动时全表校验不一致就报错并启用默认参数而不是带病运行。3.3 OTP 在固件烧录环节的实测记录去年做一款网关设备要求每个设备出厂烧录唯一的设备密钥到OTP区域。MCU选了国民技术N32G45x它内部有一个独立的OTP区域共128字节地址映射到0x1FFF_F800。烧录流程是先用烧录器连接SWD编写一段临时测试程序通过芯片厂商库函数往OTP写数据。写进去之后紧接着读回验证。OTP不能擦除一旦写错就废了一块芯片。所以我在烧录前增加了一步先把要写入的临时数据在RAM里算好CRC确认无误后再执行OTP写入指令。验证阶段还发现一个有意思的现象OTP区读出时某些字节始终显示的0x00无论写入的是0x5A还是0xA5。后来查芯片手册原来厂商把OTP区的部分地址预留给芯片ID和测试信息这些区域硬件锁定用户不可写。所以在设计时就把使用地址区间限定在用户区范围内避开预留区。OTP读取处理中还有一类特殊情况熔丝型OTP逻辑分析仪读回的数据是“每一位独立映射到一个熔丝”。此时如果要判断一个1字节的值必须取出8根数据线或者通过寄存器位域拼装。有一次我做识别读回来的字节居然不是简单的0xFF或0x00而是像0x7F这样说明其中一个熔丝断了而另外7个完好。这个细节提醒我OTP处理逻辑不能简单做全字节比较必须做位级解析。4. 数据处理与文件系统管理4.1 原始二进制数据的分析流程读回来一大片原始字节怎么处理很多人第一步就错了。他们直接盯着Hex看试图用肉眼找规律。效率太低。我的做法是先做统计分析。用Python把整个BIN文件读进来看三个指标数据分布哪些值出现频率高ASCII可见字符串熵值分析是否经过加密或压缩有段代码一直在用就是简单扫描可读字符串import re import math with open(dump.bin, rb) as f: data f.read() # 找出所有连续可打印ASCII字符串 strings re.findall(rb[\x20-\x7e]{4,}, data) for s in strings: print(s.decode()) # 计算香农熵 freq [0] * 256 for b in data: freq[b] 1 entropy 0 for cnt in freq: if cnt 0: continue p cnt / len(data) entropy - p * math.log2(p) print(fEntropy: {entropy:.4f})如果熵值接近8说明数据高度随机大概率是密文或者已经压缩此时不要试图人工解析直接找固件里的加解密函数。如果熵值在4到6之间通常是结构化的参数表。数据解析的时候最核心的是确定字段边界。EEPROM里面通常不会有自动对齐所有结构体数据都是手动排列的。我的习惯是在程序里显式定义一个打包结构体用#pragma pack(1)强制紧凑排列然后用memcpy整体读取。不要在单片机里用“取结构体指针指向EEPROM缓冲区”的做法因为编译器对齐策略可能导致读出来的字段错位。4.2 EEPROM文件系统的组织方式EEPROM不像Flash那样有专门的磨损均衡算法。它的擦写次数虽然比Flash高但也不能无限写。如果在固定地址反复写计数值用不了几个月这个字节就报废了。EEPROM文件系统的核心目标就是两件事磨损均衡和数据掉电保护。业界常用的一种方案是“Value Counter”双区策略。例如记录一个电流累计值每次更新时交替写到两个槽位槽位0写当前值计数槽位1写下一个计数值。读取时比较两个槽位的计数计数值大的就是最新数据。如果某次掉电导致写入中断那下次上电时发现两个槽位计数相同就取校验通过的那个。如果管理的数据较多可以考虑把所有数据做成一个个“记录”每条记录包含ID、长度、数据、CRC。写入时动态寻找可用空间而不是固定地址写。这样每次修改只会消耗当前记录所在空间下次写到下一个空闲区实现循环使用。小型设备的EEPROM文件系统不需要搞复杂日志式管理。我之前做过一个简单的三层结构区段起始地址长度用途Header0x000016魔数0xAA55、版本号、设备序列号Data Area0x0010224业务参数区Backup0x00F016关键参数备份每次修改前先把旧值写入Backup区成功后再写Data区写入完成后清Backup区。上电时检测Backup区是否为空不为空说明上次中途掉电就用Backup区的数据回填Data区。这个机制虽然简单但能杜绝百分之八十的掉电丢数据问题。4.3 字节序、对齐和CRC校验的工程细节字节序问题在EEPROM处理中特别容易被忽视。假设MCU是Little-Endian把一个uint32_t数值0x12345678存进EEPROM用memcpy直接写那么内存中低地址存放0x78高地址存放0x12。但如果另一个设备是大端模式同样地址读出来就会变成0x78563412完全错误。解决方案是在设备间交换数据时统一规定使用网络字节序Big-Endian或者自定义一个字段布局协议明确每个多字节字段高低字节的摆放顺序。我一般会在代码里写两个宏#define EEPROM_WRITE_U16(addr, val) do { \ eeprom_write_byte(addr, (uint8_t)((val) 8)); \ eeprom_write_byte((addr)1, (uint8_t)(val)); \ } while(0) #define EEPROM_READ_U16(addr) ((uint16_t)((eeprom_read_byte(addr) 8) | \ eeprom_read_byte((addr)1)))这样不管MCU本身是什么端序EEPROM里的字节顺序都是固定的程序可移植性就好多。CRC方面我推荐CRC16-CCITT查表法实现速度极快而且检出错误能力足够。注意一定不要把CRC值为0的情况排除因为0是合法值只要计算和存储一致就行。5. 常见问题与应用场景拓展5.1 常见问题速查与排查实录下面这些问题大概是EEPROM读取处理中最高频的每一条我都亲身踩过。问题现象可能原因排查方法I2C读不到ACK设备地址错误、上拉电阻缺失用示波器看SDA/SCL波形逐一确认地址读回数据全0xFF芯片是空白、焊盘虚焊、选了错误型号换一片确认万用表测供电和GND读回数据全0x00芯片损坏或地址线被拉高量A0/A1/A2引脚电平前几个字节正常后面全错页写边界处理错误、地址递增逻辑bug单字节读取对比掉电后再上电参数丢失复位时序导致写入未完成增加掉电检测延迟200ms再写每次读取结果不一致电源纹波大、时钟线干扰加重上拉、降低I2C频率、加去耦电容最典型的是全0xFF情况。有次同事拿了一块板子来说读EEPROM返回全FF我第一反应是芯片空的。但测供电发现VCC只有1.2V明显是电压被拉低了。查了半天发现是芯片焊盘底下有残留锡珠把VCC和相邻的WP引脚短路了。WP引脚被拉低才会允许写被拉高了只能读不能写。VCC短路到WP导致WP为高写操作自然失败读操作又因为VCC电压不足而不稳定。所以调整思路之后看到全FF不要先怀疑芯片先用万用表量供电和WP引脚电压。I2C死锁也是个常见问题。SDA被从设备拉死不释放是因为通信中途掉电或者时序异常导致从设备一直等主机给时钟。解决方法有两种一种是给SCL额外产生9个脉冲让从设备完成内部状态机复位另一种是直接切换GPIO为推挽输出手动拉高SDA几毫秒强行走完停止条件。5.2 应用场景拓展产线烧录和OTA升级EEPROM读取处理不止用于开发和调试在产线工作量也非常大。批量生产时设备序列号、蓝牙MAC地址、WiFi校准参数、ADC增益校准系数所有这些都要在出厂前写入EEPROM。人工一台一台用编程器写太慢我见过的高效率做法是产线PC通过串口和工装板通信工装板用I2C总线访问待烧录模块的EEPROMPC端用Python脚本批量烧录。烧录完自动读回校验校验通过才打PASS标签。Python端读取逻辑大概是这样import serial import struct import crcmod.predefined def write_eeprom(ser, addr, data_list): payload bytes([0xAA, 0x55, addr 8, addr 0xFF, len(data_list)]) bytes(data_list) crc crcmod.predefined.mkCrcFun(crc16) checksum crc(payload) ser.write(payload struct.pack(H, checksum)) ack ser.read(1) return ack b\x06 def read_eeprom(ser, addr, length): payload bytes([0x55, 0xAA, addr 8, addr 0xFF, length]) ser.write(payload) data ser.read(length) return data这样产线工人只需要扫一下产品条码脚本自动从MES系统拉取对应参数写入EEPROM后读回比对。整个流程耗时不到两秒出错率远低于人工按键输入。OTA升级场景下EEPROM通常保存的是“当前固件版本号”和“回滚标识”。升级开始时先把新版本号写入暂存区升级完成后再把正式版本号写入EEPROM。如果升级过程中断电下次上电读暂存区发现版本号不完整就知道要回滚旧固件。这个设计思路我在多个物联网产品上都验证过简单可靠。5.3 一些值得注意的安全与可靠性细节如果产品涉及计费、加密或者其他对数据真实性要求极高的场景普通EEPROM的明文存储是不够的。攻击者可以通过I2C探针直接把整片内容抓走。这时需要把关键数据用密钥加密后再存入EEPROM密钥存储在OTP区。OTP区本身不可擦除密钥无法被静态修改安全性就提高很多。我还习惯在EEPROM中存放一个随机填充区用于干扰分析。每次写入时在数据区尾部追加一段随机数再整体计算CRC这样即使两次写入相同业务数据EEPROM原始内容也不会完全一致防止侧信道攻击时直接比对差异化数据。写入保护脚WP也很关键。平时保持拉高电平仅在真正需要写入的那一小段时间内拉低写完再拉高。这样即使程序跑飞或者被干扰也无法意外修改EEPROM内容。这是很多工程师忽略的可靠性设计。另外不要在系统掉电瞬间原地等待EEPROM写入完成。正确做法是检测到掉电先开启掉电中断把重要的几个字节快速写入EEPROM后立刻进入低功耗休眠。AT24C系列写一个字节的时间大约5ms如果电容只够撑10ms就可能写一半掉电。稳妥的做法是把关键词和参数放在同一个页内一次页写操作完成而不是分多次。6. 总结与实战收尾写了这么多最后再聊一点个人的实际体会。OTP/EEPROM读取与处理看着是底层基本功但真正把它做扎实的人不多。问题大多出在只会读不会解析只会解析不考虑掉电保护只考虑掉电保护不关注磨损均衡。这三个层次环环相扣跳过任何一步长期跑下来都会出问题。我给自己的项目立了几条规矩所有EEPROM数据必须带CRC、关键参数必须有备份区、多字节数据必须显式固定字节序、写入之前先确认WP引脚状态。简单几条但能挡住绝大多数诡异故障。如果你现在正卡在“数据读不出来”或者“读出来不会用”的阶段建议先不要着急改代码先拿逻辑分析仪抓一次总线波形确认硬件层没问题再往软件层面走。很多问题其实是一根上拉电阻没焊或者地址位跳线帽插错了位置导致的白白调试了好几天。最后送大家一条非常实用的经验用可编程电源给EEPROM供电在读取过程中人为快速通断电源反复测试如果能稳定读出正确数据说明你的处理逻辑在掉电场景下也站得住脚。这是我做产品可靠性测试时最常用的手段效果明显。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进