EEPROM驱动实战:从裸机I2C到Linux内核与FPGA实现

📅 发布时间:2026/8/29 8:48:37
EEPROM驱动实战:从裸机I2C到Linux内核与FPGA实现 如果你点进这篇文章多半是手里拿到了一颗EEPROM或者正在自己的板子上查为什么明明连着I2C总线却读不出数据。EEPROM驱动这件事看起来就是“写个函数读一下地址”那么简单实际做起来牵扯到硬件时序、内核驱动框架、操作系统的设备模型甚至还要处理意外掉电后的数据一致性。这篇文章从裸机I2C驱动一路讲到Linux内核的AT24驱动包含BeagleBone Black上踩过的坑以及FPGA侧Verilog实现EEPROM控制器的思路希望对正在搞嵌入式、Linux驱动和FPGA开发的朋友都有点参考价值。1. 先看清EEPROM和传统Flash的根本差异1.1 页写、擦除与寿命模型很多人一开始觉得EEPROM不过是“慢一点的Flash”这个误解会在驱动设计时埋雷。EEPROM最核心的特性是可按字节擦写这比Flash的按扇区/块擦除灵活很多但代价是写速度慢、有写寿命限制。以最常见的AT24C02为例写入一个字节后芯片内部要花几毫秒完成编程这期间芯片不接受新的写命令一次页写最多可以写入8个字节2Kbit容量下page是8字节。如果地址跨越页边界控制器必须自动截断否则会发生回绕覆盖当前页开头导致数据错乱。驱动设计者需要时刻记住这个寿命模型。EEPROM典型擦写寿命是100万次听起来很多但如果驱动里有循环写日志、频繁更新配置的逻辑用不了多久就会出问题。我见过一个产品因为不小心把运行计数写进EEPROM结果设备运行半年后EEPROM读写开始不稳定最后不得不换方案。所以在驱动里做写次数控制、磨损均衡、只在数据变化时才执行写操作这些不是可选项而是必备项。1.2 地址引脚、设备地址和物理拓扑I2C接口的EEPROM地址由器件型号和外部硬件引脚共同决定。AT24C系列有A0、A1、A2三个地址引脚配合芯片自身的类型标识组成7位从机地址。比如AT24C02的固定地址是1010加上A2A1A0和读写位读地址是0xA1写地址是0xA0。不少新人在设备树或驱动里写错这个地址明明用的是0x50的设备地址却给了0xA0结果i2cdetect探测不到设备。实际上i2cdetect显示的是7位地址所以0x50对应写字节0xA0、读字节0xA1。容量不同EEPROM内部地址指针的宽度也不同。2Kbit到16Kbit的器件通常用1字节字地址能寻址256字节32Kbit以上则需要2字节字地址。如果你的驱动选择“直接给偏移量”底层就必须根据容量决定发送1字节还是2字节地址不能写死。实际项目中我通常建议在配置结构里声明模型参数包括页大小、地址宽度、容量驱动根据参数决定访问策略。硬件上遇到多个EEPROM挂在同一条总线时还要确认A0~A2跳线有没有正确区分地址否则总线地址冲突会导致所有器件都无法正常访问。1.3 为什么驱动不能只靠“读寄存器”实现做过普通传感器驱动的人会习惯性认为设备可能有一个状态寄存器可以查询忙闲或错误状态但大部分EEPROM没有这种寄存器。写入命令发出后芯片进入内部编程周期外部只能通过“不响应ACK”来感知。因此在驱动实现中写操作结束后必须等待一种做法是固定延时至少5ms另一种做法是发一个当前地址读命令如果芯片返回ACK说明内部编程完成如果NACK说明还在忙需要重试。第二种做法效率更高但要注意不要把它做成死循环忙等否则在单核MCU上会卡住其他任务。另一个容易被忽略的点是EEPROM没有类似Flash的“读ID”标准命令很多型号根本读不出厂家和容量信息。驱动无法在运行时自动识别设备必须从配置层面指定型号或参数。初学Linux驱动时我总想写一个“万能驱动”自动探测最后发现这条路走不通老老实实把设备信息放进设备树才是正解。2. 驱动分层从I2C收发器到用户态API2.1 I2C控制器驱动与EEPROM client驱动的关系在嵌入式系统里驱动通常分两层一层是I2C控制器驱动负责操作具体MCU里的I2C外设寄存器产生起始位、时钟和字节收发另一层是挂在I2C总线上的client驱动比如EEPROM驱动。前者提供收发一帧数据的能力后者定义数据的格式和含义。写EEPROM驱动时你真正要写的是后者。在Linux里I2C控制器对应struct i2c_adapterclient驱动对应struct i2c_driver两者通过I2C core匹配驱动代码里只需要拿到adapter提供的函数指针。这个分层很重要因为它决定了驱动能不能跨芯片复用。我在裸机上写EEPROM驱动时也沿用同样的思路底层提供i2c_start、i2c_stop、i2c_write_byte、i2c_read_byte这些原语上层EEPROM驱动只调用这些原语不直接摸寄存器。这样迁移到不同MCU时只需要重写底层原语上层协议完全不变。如果一开始就把I2C时序和EEPROM逻辑揉在一起后面换芯片就是灾难。我在STM32、NXP和国产MCU上都这么干过切换平台时压缩得很干净。2.2 一次完整读写事务的协议拆解一次随机读操作是最能体现I2C协议细节的场景。主控先发起START发送设备写地址发送要读取的字地址然后重新发起START也就是repeat START再发送设备读地址随后读取一个字节并发送NACK最后STOP。注意中间这个重复起始位很多简化版的软件I2C实现会漏掉或者把它实现成STOP加START这在大多数EEPROM上也能工作但极少数情况下会让器件状态机产生混乱所以我建议严格按数据手册的时序图走。页写则是另一种典型事务START加设备写地址加字地址加最多page size个数据字节加STOP。芯片内部自动把连续字节写入页缓冲区最后统一编程。驱动层需要判断当前地址离页边界还剩多少字节决定本次最多写多少字节。如果不做这个操作就会遇到我之前说的回绕问题。我自己写了一个eeprom_write_bytes函数每次都先计算page_remain page_size - (offset % page_size)然后取它和剩余长度的最小值作为本次写入长度。这样无论是跨页还是对齐都能保证数据落在预期位置。2.3 在裸机和RTOS下使用HAL层驱动在MCU上做项目时很多人会选择STM32CubeMX生成的HAL库它提供了HAL_I2C_Mem_Read和HAL_I2C_Mem_Write这类高层接口直接传入设备地址、内存地址、数据指针和长度HAL库会负责生成正确的起始位和停止位。但这不代表驱动就写完了HAL接口默认还会做超时处理而且写后等待tWR需要自己加如果使用DMA或中断模式还要处理传输完成的信号量。我在RTOS里通常会封装成三个APIeeprom_read(offset, buf, len)、eeprom_write(offset, buf, len)和eeprom_erase_byte(offset)写函数内部加互斥锁避免多任务并发访问同一片EEPROM导致地址混乱。对于STC32G这类增强型单片机EEPROM经常是集成在芯片内部而不是通过I2C外挂。这种内部EEPROM的驱动更加简单通常就是调用库提供的IAP接口但依然要注意页擦除和写保护。网上搜“stc32 eeprom”能找到很多示例代码核心点是设置IAP相关寄存器再触发IAP命令。别小看这些细节触发命令写反了会导致命令不执行。遇到这种情况我会先用逻辑分析仪确认IAP命令寄存器确实被正确写入再判断是硬件还是代码问题。3. Linux内核里实现EEPROM驱动的几种标准姿势3.1 从零写一个I2C EEPROM client driverLinux内核里最简单的EEPROM驱动结构非常清晰。先定义一个struct i2c_driver注册probe和id_table在probe里创建设备然后在文件操作或内核接口里实现读写。最核心的读写接口是i2c_smbus_read_i2c_block_data和i2c_smbus_write_i2c_block_data。这两个接口的好处是内核会帮你处理I2C事务拆分和重复起始位不需要自己拼i2c_master_send和i2c_master_recv。但要注意i2c_smbus的block data接口实际传输长度有限制超过页大小必须自己循环。数据手册上的页写约束在这层仍然要遵守。一个最常见的错误是在probe里做太多初始化导致驱动加载慢或者失败。EEPROM驱动本身很简单probe里主要工作是分配私有结构、初始化互斥锁、可能创建一个miscdevice或者nvmem设备。如果设备树里没有匹配的compatibleprobe根本不会被调用所以调试时先检查/sys/bus/i2c/devices/下有没有对应目录比在probe里加打印更直接。3.2 用AT24框架、nvmem和MTD的区别和选型其实大多数情况不需要自己从头写。Linux内核自带drivers/misc/eeprom/at24.c它已经支持AT24全系列和兼容型号也支持SPI接口的AT25。把这个驱动编进去然后在设备树里声明一个at24c02节点指定compatible atmel,24c02和pagesize、size系统启动后就会自动生成一个nvmem设备。这个设备会出现在用户态路径通常是/sys/bus/nvmem/devices/.../nvmem。你要做的只是理解它的能力边界比如EEPROM不支持去掉写保护的连续大块擦写不能用MTD的命令去擦除。那什么时候需要自己写驱动如果你的EEPROM连接到非标准总线上比如用GPIO模拟的I2C或者某些私有接口或者你需要在读数据时做一些校验、缓存、挂到其他内核子系统这时再考虑写独立驱动。另一个选择是挂到MTD子系统把EEPROM当作一个小块设备使用适合放轻量文件系统。MTD带来的好处是支持分区、坏块管理和现成工具但它的架构更复杂存在“写放大”问题对寿命敏感的EEPROM要谨慎。我自己的原则是能上nvmem的绝不上MTD能上AT24框架的绝不自己造轮子。3.3 BeagleBone Black板载EEPROM的实战案例BeagleBone Black主板上有一颗I2C EEPROM默认挂在I2C0总线设备地址是0x50。它的主要用途是记录板卡信息很多官方镜像会读取它来识别板卡型号。很多人在给BeagleBone Black写设备树覆盖时遇到驱动不加载的问题。我在实际项目中的排查步骤是这样的先执行i2cdetect -y 0如果看到0x50地址有设备说明硬件通路正常接着看/sys/bus/i2c/devices/0-0050/目录如果存在eeprom属性文件说明AT24驱动已经绑定如果只有name和power这类节点说明驱动没匹配上。常见原因是设备树里compatible字符串写错。BeagleBone Black的官方设备树里用的可能是compatible atmel,24c32但你把自己的板子改成24c02时忘了改成atmel,24c02驱动会因为不匹配而拒绝绑定。另外注意BeagleBone Black默认的I2C0地址并不是所有内核版本都开放给用户有些镜像把它保留给板级管理用户态访问会被拒绝。排查时要先确认内核配置里CONFIG_EEPROM_AT24已经打开且没有其他驱动占用这个client地址。如果以上都正常再用hexdump /sys/bus/i2c/devices/0-0050/eeprom | head看数据能读出发货时的板卡信息就说明通路完全没问题。4. 没有I2C接口SPI/单总线EEPROM驱动要点4.1 SPI EEPROM与MTD设备的接口很多高压和工业场景会用SPI EEPROM比如Microchip的25AA系列。SPI EEPROM的指令集和SPI NOR Flash很像有WREN写使能、WRITE、READ、RDSR读状态寄存器等指令。差别在于EEPROM可以在任意地址写入且不需要擦除这跟Flash完全不同。在Linux内核里这类芯片通常使用at25驱动可以实现为MTD设备或nvmem设备。你的设备树里需要声明spi-max-frequency和pagesize否则驱动不知道页大小写操作可能出错。我调SPI EEPROM时踩过一个坑芯片的HOLD和WP引脚如果悬空可能会受到噪声干扰导致芯片进入保持状态SPI总线上的时钟和数据都不再响应。把两个引脚按数据手册要求接上正确的电平后问题消失。驱动层面排查这类问题很难因为所有读请求都超时先检查硬件电平比查代码效率高得多。另外SPI EEPROM常见的状态寄存器里有WIP位写操作后要等待该位清零而不是固定延时这样可以显著提高连续写入速度。4.2 1-Wire EEPROM与软件时序模拟Dallas/Maxim的单总线EEPROM比如DS2431只有一根数据线靠严格的时序区分复位脉冲、存在检测、写时隙和读时隙。单总线驱动本质上是一个位敲打的时序驱动在Linux下可以用w1子系统实现也有现成的w1-2431驱动。时序要求非常严格写0时隙和写1时隙都要求主机先拉低总线区别只在于释放总线的时间点。如果主机用的是GPIO模拟必须关掉中断或使用实时线程否则调度延迟会让时序失真。在MCU端写单总线驱动时建议用逻辑分析仪抓一次完整的复位和读写波形和数据手册上的时序图对比。一个很常见的错误是复位后的存在检测窗口太短主机提前释放总线器件还没来得及拉低产生存在脉冲结果驱动以为总线上没有设备。这种现象往往在温度变化或线缆较长时更明显所以驱动里要留出足够的时序余量。调单总线比I2C更容易抓狂因为一个位时序错误不会立刻报错而是表现为读出的数据间歇性不对。4.3 在FPGA/Verilog里实现EEPROM控制器的思路如果你在FPGA项目里要对EEPROM进行读写同样需要写一个I2C控制器或者借用现成的IP核。很多开源项目会直接提供i2c_master之类的Verilog模块用状态机管理I2C协议的START、STOP、ACK和数据位。常用状态机是IDLE、START、WRITE_BYTE、READ_BYTE、CHECK_ACK、STOP。SCL时钟由系统时钟分频产生发送数据时在SCL低电平期间改变SDA接收数据时在SCL高电平期间采样SDA。这个流程几乎每个人都能写出来但真正决定稳定性的细节在于状态切换时有没有考虑时序保持时间和建立时间尤其在不同IP核频率下分频参数不匹配会导致EEPROM间歇性读写失败。我建议在FPGA里不要自己重复造轮子先搜一下OpenCores上的I2C控制器熟悉它的接口后把EEPROM的状态控制放在上层。这样你实际上做的是一个“EEPROM控制器”而非从零实现物理层I2C。你在网络热搜里看到“i2c读写eeprom代码 verilog”大概也是这个诉求这类代码的共通套路就是先发设备地址再发字地址然后连续读或写最后等待器件内部编程完成。只要你理解了协议例子里的代码改一改就能用。要注意的是FPGA里的逻辑分析仪抓I2C波形很方便但如果你用SDA和SCL双向IO务必在信号进入FPGA前加电平转换或至少做好上拉处理否则时序余量会非常紧张。5. 调试验证与可靠性设计5.1 用i2c-tools快速验证硬件通路调I2C EEPROM驱动之前先用i2c-tools把硬件通路确定下来能省去一大半排查时间。以Linux系统为例i2cdetect -y 1会扫描总线上所有地址如果能看到0x50汇报UU或50说明设备存在且地址正确。注意UU表示这个地址已经被某个驱动占用了这通常也是驱动已经绑定成功的间接证据但如果你在对驱动本身进行开发看到UU反而说明它被其他驱动抢占了需要先解绑或改设备树。然后可以执行i2cget -y 1 0x50 0x00读取偏移0处的一个字节如果返回0xff不一定代表芯片是空白的也可能是读时序有问题需要结合写操作和再读来验证。我更推荐用i2ctransfer直接发送原始事务比如i2ctransfer -y 1 w20x50 0x00 0xaa r1表示写两个字节数据到设备0x50然后读取1字节。这样能精确控制事务结构定位问题在I2C控制器驱动还是EEPROM驱动。串接一个逻辑分析仪抓波形是在嵌入式里最快确认问题的手段比反复改代码高效得多。我见过不少工程师在驱动代码里打了几天日志最后发现板子SDA上拉电阻忘了焊白白浪费时间。5.2 写保护、掉电与数据完整性许多EEPROM都有WP引脚拉高后禁止写入。很多板子会把WP引脚固定拉低导致驱动裸奔产品可靠性堪忧。我的做法是把WP接到GPIO在写操作前拉低写完后拉高这样正常运行时设备始终处于写保护状态只有真正需要写配置时短暂开放。配合驱动里的写使能标志位也能防止误操作把生产数据冲掉。如果你的硬件已经固定拉低至少在驱动层加一个“写保护标志”拒绝异常写请求避免应用层误调用。掉电问题更隐蔽。写入期间如果系统掉电EEPROM可能处于不确定状态导致整页数据损坏。驱动层面能做的有限只能尽量缩短写入时间、合并写操作。实际工程中更可靠的方案是在EEPROM里放两份配置数据每份带CRC校验写入时先更新备份再更新主记录启动时读主记录如果校验失败则尝试回退到备份。这个模式在汽车电子里很常见项目成本不高但效果非常好。我上次用这个方案处理一批产品配置经常被偶然改写的问题之后几乎没再返修过。5.3 驱动测试的常见坑时序、Acknowledge和分页边界测试EEPROM驱动别只测“读出来是对的”要专门构造边界条件。第一是页边界比如对一个页大小8字节的AT24C02分别测试从偏移7写2字节、从偏移0写9字节后者必须被拆分或截断否则会有意想不到的回绕写入。第二是NACK处理当器件在内部写周期时主控发送地址会接收到NACK驱动要能识别并重试而不是直接返回错误。第三是异常时序SCL高电平期间SDA变化会被视为START或STOP信号如果总线信号受干扰驱动可能误判帧结构。在使用软件模拟I2C时我习惯在每步操作后加短延时保证信号稳定。还要注意EEPROM写操作的最小单位虽然是一个字节但很多芯片要求写完一个字节后等待tWR。如果你在连续写两个字节之间没有等待第二个字节可能被丢失或者写入错误地址。调试这类问题最好的办法是构造一个“写全0xAA模式然后读回来对比”的测试程序跑几千轮随机偏移随机长度一旦发现某个偏移的数据异常立刻定位到是跨页问题还是等待时间问题。6. 实测心得几个可以拿来就用的代码片段6.1 跨页写函数重构思路这里给出一个处理跨页写的简化函数基本思路是先按页边界拆分再逐页提交int eeprom_write_region(uint16_t offset, uint8_t *data, uint16_t len) { while (len 0) { uint16_t page_remain page_size - (offset % page_size); uint16_t chunk min(page_remain, len); int ret i2c_write_block(dev_addr, offset, data, chunk); if (ret) return ret; msleep(tWR); offset chunk; data chunk; len - chunk; } return 0; }当然这只是示例实际驱动还要处理设备地址、字地址宽度和多字节读的连续读问题。测试时建议用随机偏移和随机长度持续读写再配合CRC校验防止偶发错误漏掉。我在做EEPROM驱动单元测试时会在断电前写一个特殊标记上电后读回验证专门测试掉电写保护逻辑。6.2 写周期等待与ACK重试策略上面说的固定延时实现简单但会拖慢连续写的效率查询方式代码稍多却能最大程度利用时间。我在裸机上写过一个查询版本核心逻辑如下int eeprom_wait_ready(uint8_t dev_addr) { int retries 100; while (retries--) { if (i2c_start(dev_addr | I2C_WRITE) 0) { // ACK i2c_stop(); return 0; } } return -ETIMEDOUT; }用写地址来探测因为任何地址都能被ACK除了正在内部编程周期。轮询间隔建议1ms左右100次就是100ms已经远大于正常tWR时间。如果这样还超时优先怀疑硬件问题而不是继续加长重试时间。在Linux驱动里你可以用udelay短期等待或者直接使用usleep_range让出CPU避免阻塞整个系统。6.3 先波形后代码的调试顺序与硬件确认清单最后整理一份我在画板或拿到新板子时必查的清单EEPROM的电源去耦电容是否靠近VCC引脚SCL和SDA上拉电阻是否装配且阻值正确常见4.7kΩ总线较长或设备多时改用2.2kΩWP引脚电平是否符合设计A0~A2地址引脚有没有因为复用或跳线导致地址冲突挂载EEPROM的I2C总线有没有接其他设备导致地址冲突逻辑分析仪探头是否接触良好测量时会不会引入额外电容导致边沿变缓。这个清单看起来琐碎但每次驱动调不通最后查出来基本都是这里的问题。不要一上来就调内核代码先用最简单的读写程序确认硬件通路。如果硬件没通驱动写得再漂亮也只是在错误的地基上盖楼。我从做第一个Linux外设驱动开始就养成了“先看波形再写代码先跑i2c-tools再引设备树”的习惯。这个顺序帮我省下了无数个无意义的调试黑夜也希望这篇关于EEPROM驱动的东西能让你在下一个项目里少踩几个类似的坑。