
1. 项目概述为什么要在FPGA上折腾EEPROM如果你玩过单片机对EEPROM电可擦可编程只读存储器肯定不陌生它常用来存点系统配置、校准参数或者掉电后不能丢的小数据。但当你从单片机转向FPGA现场可编程门阵列开发时可能会发现事情有点不一样。在FPGA里你面对的不是现成的库函数I2C_Write()而是一堆需要你自己用Verilog或VHDL描述的寄存器、状态机和时序逻辑。实现EEPROM的读写本质上是在FPGA内部用硬件描述语言“造”出一个符合特定总线协议如I2C或SPI的主机控制器去跟外部那颗小小的EEPROM芯片“对话”。这活儿听起来有点底层但意义重大。首先这是掌握FPGA对外设通信接口设计的绝佳练手项目I2C、SPI这些总线协议是嵌入式世界的普通话搞懂了它们FPGA才能跟传感器、ADC、DAC、乃至其他处理器顺畅交流。其次在很多实际应用中比如需要保存用户设置、设备序列号、或者作为小容量非易失性缓存的场景外挂一颗EEPROM是既经济又可靠的选择。最后这个过程能让你深刻理解软硬件协同的差异——在FPGA里通信的每一步时序、每一个应答ACK都需要你用精确的时钟周期来刻画这比调用软件API要“硬核”得多但也更能让你触及硬件工作的本质。我最初接触这个项目是因为一个图像处理板卡需要保存镜头校正参数。参数不算多几十个字节但要求上电就能读取且偶尔需要更新。用FPGA内部的Block RAM存不了掉电就丢用外部Flash又杀鸡用牛刀时序也复杂。一颗几毛钱的24系列EEPROM正好解决问题于是就有了用FPGA直接驱动它的需求。下面我就把从设计思路到代码实现再到仿真调试的完整过程以及踩过的坑和总结的经验详细拆解一遍。2. 核心设计思路与协议选型2.1 为什么首选I2C协议EEPROM的接口常见的有I2C、SPI和并口。对于容量不大通常几Kbit到几百Kbit、速度要求不高通常几百KHz的应用I2C协议是绝对的主流尤其是24Cxx系列芯片几乎成了行业标准。选择I2C主要基于以下几点考量引脚经济I2C只需要两根线串行数据线SDA和串行时钟线SCL极大地节省了FPGA宝贵的IO引脚资源。这对于引脚数量受限或者需要连接多个从设备的系统至关重要。多设备支持I2C支持总线拓扑和多主多从虽然常见的是单主多从通过7位或10位地址寻址一根总线上可以挂多个EEPROM或其他I2C设备方便扩展。协议成熟资料丰富I2C协议标准、规范相关的时序图、芯片手册Datasheet非常容易获取。社区里Verilog实现的例子也很多学习成本和调试成本相对较低。速度满足需求标准模式100Kbps快速模式400Kbps对于EEPROM的读写操作主要是字节写和页写来说完全够用因为EEPROM本身的写入周期Write Cycle Time通常在5ms左右总线速度再快也得等它写完。所以我们的项目核心就明确了在FPGA内部实现一个I2C主机控制器Master Controller用于读写外部基于I2C总线的EEPROM芯片。2.2 自顶向下的模块划分一个稳健的FPGA设计始于清晰的模块划分。我们不能把所有的逻辑都塞进一个i2c_eeprom模块里那样会难以维护和调试。合理的划分应该是这样的顶层模块Top Module负责例化所有子模块定义FPGA芯片的输入输出引脚如时钟、复位、I2C的SDA/SCL以及可能的用户接口信号并完成模块间的互联。I2C主控制器核心I2C Master Core这是最核心的模块。它负责产生符合I2C协议的所有时序起始条件START、停止条件STOP、发送数据位、接收数据位、产生时钟、检测应答ACK/NACK。它应该是一个状态机根据输入的命令如“写字节”、“读字节”来执行相应的序列。EEPROM读写协议封装模块EEPROM Protocol WrapperI2C核心只负责最底层的位传输。而读写EEPROM需要遵循特定的命令序列。例如随机写一个字节需要START - 发送设备地址含写标志 - 等待ACK - 发送内存地址高字节 - 等待ACK - 发送内存地址低字节 - 等待ACK - 发送数据字节 - 等待ACK - STOP。这个模块的作用就是封装这些针对EEPROM的操作序列向I2C核心模块发送更高级的指令如“写一个字节到地址0x0100”并接收其状态反馈。用户接口/控制模块User Interface/Control提供友好的接口给FPGA内部的其他逻辑或外部处理器如通过寄存器映射。它可能是一个简单的状态机接收用户“读”或“写”的请求调用EEPROM协议封装模块并返回结果和状态忙/闲、成功/失败。对于更复杂的系统这个模块可以是一个AXI4-Lite或Avalon-MM总线从机方便SoC系统中的处理器通过内存映射访问EEPROM。时钟分频与同步模块Clock Divider SynchronizationFPGA的主时钟频率如50MHz、100MHz远高于I2C总线速度如400KHz。我们需要一个分频器来产生I2C操作所需的时钟。同时由于SDA是双向线在FPGA端需处理为inout且外部信号可能异步需要同步电路来处理亚稳态问题。这样的分层设计使得每一层职责单一易于仿真、测试和复用。I2C核心可以单独拿出来驱动其他I2C设备EEPROM协议模块如果换用不同型号的EEPROM如地址宽度不同也只需修改这一层。3. I2C主控制器核心的Verilog实现详解这是整个项目的基石必须做到稳定可靠。我们以实现400KHz快速模式为例。3.1 状态机设计一切行为的指挥官I2C通信是典型的顺序过程用有限状态机FSM来描述再合适不过。状态机的状态需要覆盖I2C协议的所有基本环节localparam [3:0] IDLE 4d0; // 空闲状态 localparam [3:0] START 4d1; // 产生起始条件 localparam [3:0] SEND_ADDR 4d2; // 发送7位地址1位读写位 localparam [3:0] WAIT_ACK1 4d3; // 等待从机对地址的应答 localparam [3:0] SEND_DATA 4d4; // 发送一个数据字节 localparam [3:0] WAIT_ACK2 4d5; // 等待从机对数据的应答 localparam [3:0] RECV_DATA 4d6; // 接收一个数据字节 localparam [3:0] SEND_ACK 4d7; // 主机发送应答位ACK/NACK localparam [3:0] STOP 4d8; // 产生停止条件 // 可能还需要一些中间状态如重复起始条件Repeated START等状态机的跳转由内部计数器bit_cnt用于计数一个字节内的8位clk_cnt用于产生SCL时钟沿和外部命令i_cmd共同控制。例如在IDLE状态下如果收到“开始传输”命令则进入START状态。3.2 关键时序的产生SCL与SDA的舞蹈I2C协议对时序有严格规定比如起始条件SCL高电平时SDA产生一个下降沿、数据有效性SDA在SCL高电平期间必须保持稳定。我们需要用计数器精确控制。假设系统时钟clk为50MHz要产生400KHz的SCL时钟。那么一个SCL周期需要50_000_000 / 400_000 125个系统时钟周期。我们可以设置一个计数器clk_div_cnt计数到124后归零。将SCL的高电平和低电平各分配大约一半的时间比如低电平占62个clk高电平占63个clk。在计数器运行到特定值时产生SCL的上升沿和下降沿信号用于触发状态机进行数据采样或改变。SDA线的控制是难点因为它是双向的。在FPGA中我们通常这样处理将SDA端口声明为inout类型。在模块内部使用一个三态缓冲器逻辑来控制输出和读取。当主机需要驱动SDA为低时如发送‘0’或产生START/STOP条件设置一个输出使能信号sda_oe为高并将要输出的数据sda_out赋给SDA线。当主机需要释放SDA线如接收数据、等待从机ACK、或SDA需要被从机拉低时设置sda_oe为低此时FPGA引脚处于高阻态Z然后通过一个输入信号sda_in来读取SDA线上的实际电平。// 示例SDA引脚的三态控制 assign sda (sda_oe) ? sda_out : 1bz; assign sda_in sda; // 同步后使用 // 在状态机中控制sda_oe和sda_out always (posedge clk or posedge rst) begin if(rst) begin sda_oe 1b0; sda_out 1b1; state IDLE; end else begin case(state) START: begin // SCL高电平期间将SDA从高拉低 if(clk_edge_pos) begin // 某个SCL高电平中的点 sda_oe 1b1; sda_out 1b0; end // ... 状态转移 end SEND_DATA: begin // 根据要发送的数据位在SCL低电平期间更新SDA if(scl_low_center) begin sda_oe 1b1; sda_out data_to_send[7-bit_cnt]; end end RECV_DATA: begin // 释放SDA线准备读取 sda_oe 1b0; // 在SCL上升沿采样sda_in if(scl_pos_edge) begin rx_data[7-bit_cnt] sda_in; end end // ... 其他状态 endcase end end3.3 应答处理与错误检测应答ACK机制是I2C保证数据可靠传输的关键。主机每发送完8位数据地址或数据会在第9个时钟周期释放SDA线输出高阻并检查SDA是否被从机拉低。如果被拉低表示ACK正常应答如果保持高电平表示NACK非应答可能意味着从机地址错误、设备忙或通信故障。在我们的状态机中WAIT_ACK1和WAIT_ACK2状态就是专门用于处理这个的。在这两个状态下主机释放SDA并在SCL高电平期间采样SDA。如果采样到低电平则产生ack_received信号状态机继续如果采样到高电平则产生nack_received信号状态机可以跳转到错误处理或直接发起STOP条件终止本次传输。一个健壮的控制器还应该包含超时检测。例如在发送START条件后如果长时间没有完成或者等待ACK超时应该能自动复位到IDLE状态避免总线锁死。4. EEPROM读写协议的具体实现有了通用的I2C核心我们还需要为具体的EEPROM芯片以AT24C64为例8KB容量地址线2位编写“驱动程序”。这层模块负责将用户的高级操作读/写某个地址翻译成I2C核心能理解的低级命令序列。4.1 写操作字节写与页写字节写Byte Write 这是最基本的操作。时序序列如下主机发送START条件。主机发送8位控制字Control Byte。格式为1 0 1 0 A2 A1 A0 R/W。其中1010是固定标识A2,A1,A0是芯片的硬件地址引脚电平决定了芯片在I2C总线上的从机地址R/W位为0表示写操作。从机EEPROM回应ACK。主机发送16位内存地址的高8位对于24C64地址是16位的。从机回应ACK。主机发送16位内存地址的低8位。从机回应ACK。主机发送要写入的8位数据。从机回应ACK。主机发送STOP条件。关键点发送STOP条件后EEPROM进入内部写周期t~WR~典型值5ms在此期间它不会响应I2C总线。我们的控制器必须等待这段时间可以通过计数器或外部中断后才能发起下一次操作。一个简单的做法是在用户接口模块中发送STOP后设置一个“忙”标志并启动一个计时器计时结束后才清除“忙”标志允许下一次操作。页写Page Write EEPROM通常支持页写即在一个写序列中连续写入多个字节如24C64页大小为32字节。这比分多次字节写快得多。操作序列与字节写类似只是在发送完第一个数据字节并收到ACK后不发送STOP而是继续发送下一个数据字节直到发送完一页数据或主机主动发送STOP。注意事项写入的地址必须是页的起始地址且不能跨页。如果写入的起始地址不是页首或者写入的字节数会导致地址跨页那么超出页边界的数据会从当前页的起始地址“回卷”覆盖写入这是硬件特性必须由软件或我们的FPGA逻辑来避免。4.2 读操作当前地址读、随机读与顺序读读操作稍微复杂一点因为它涉及一次“虚写”来设置地址指针然后再发起一次读。随机读Random Read 这是最常用的读模式读取任意指定地址的数据。首先执行一个“哑写Dummy Write”序列发送START - 控制字R/W0- ACK - 高8位地址 - ACK - 低8位地址 - ACK。注意这里不发数据也不发STOP。然后主机再次发送START条件这叫做重复起始条件Repeated START。主机发送控制字但这次R/W位为1表示读操作。从机回应ACK并开始在第9个时钟周期输出第一个数据字节。主机接收完一个字节后在下一个时钟周期发送一个ACK如果是读取多个字节则发送ACK如果是最后一个字节则发送NACK。主机发送STOP条件结束传输。当前地址读Current Address Read 直接发送读控制字读取EEPROM内部地址计数器当前指向的位置。这个地址是上一次读/写操作完成后的地址1。这种操作更快但地址不可控。顺序读Sequential Read 在随机读或当前地址读之后如果主机在接收完一个字节后回复ACK而不是NACK并且不发送STOPEEPROM会自动将内部地址计数器加1并继续输出下一个地址的数据。这样可以连续读取多个字节直到主机发送NACK和STOP。4.3 用户接口设计为了让FPGA内部其他模块方便地使用EEPROM我们需要设计一个简洁的接口。一个典型的接口可以包含以下信号clk,rst_n: 时钟和复位。i_addr[15:0]: 16位读写地址输入。i_data_wr[7:0]: 待写入的8位数据。o_data_rd[7:0]: 读取到的8位数据。i_cmd: 命令输入如CMD_IDLE,CMD_WRITE_BYTE,CMD_READ_BYTE。i_cmd_valid: 命令有效脉冲上升沿触发一次操作。o_busy: 模块忙标志高电平表示正在处理命令或EEPROM处于内部写周期此时不应发送新命令。o_cmd_done: 命令完成脉冲高电平一个时钟周期表示一次读写操作完成。o_error: 错误标志如收到NACK或超时。用户模块只需要在o_busy为低时设置好地址和数据给一个i_cmd_valid脉冲然后等待o_cmd_done。读操作时在o_cmd_done有效的周期o_data_rd上的数据就是有效的。5. 仿真、调试与板级实测的避坑指南代码写完了千万别急着上板充分的仿真能节省你大量的调试时间。5.1 搭建仿真测试平台Testbench用Verilog或SystemVerilog写一个测试平台主要做两件事实例化你的设计DUT。实例化一个EEPROM的行为模型。很多FPGA开发环境如Xilinx Vivado的IP Catalog里就有I2C Slave的行为模型可以直接用。如果没有你需要根据芯片Datasheet里的时序自己写一个简单的模型。这个模型至少要能响应I2C命令对特定地址进行读写并模拟内部写周期的等待。在测试平台中编写初始化和激励任务初始化时钟和复位。等待一段时间后发起一个写操作比如向地址0x0000写入0xAA。等待写完成检测o_cmd_done。非常重要等待至少5ms模拟EEPROM的tWR否则紧接着的读操作会失败。发起一个读操作从地址0x0000读取。检查读回的数据是否为0xAA。可以增加更多测试用例如跨页写入、连续读等。使用仿真工具如ModelSim, Vivado Simulator查看波形。重点关注SCL和SDA的波形是否符合I2C协议规范。起始、停止条件是否正确。数据位和ACK位的时序是否正确。状态机的跳转是否符合预期。在写操作后的等待期间是否真的没有发起任何I2C通信。5.2 板级调试的常见问题与排查当仿真通过把程序下载到FPGA后可能依然不工作。以下是几个常见的坑和排查手段上拉电阻问题I2C总线是开漏输出必须接上拉电阻通常4.7kΩ ~ 10kΩ到VCC。如果原理图上忘了加或者加的阻值不对太大导致上升沿太慢太小导致电流过大都会导致通信失败。用示波器测量SCL和SDA看高电平是否能拉到接近VCC上升沿是否陡峭。引脚分配与约束错误检查.xdc或.qsf文件确保SDA和SCL信号分配到了正确的FPGA引脚并且引脚的电平标准如LVCMOS3.3V设置正确。一个低级错误是把SDA和SCL引脚分配反了。时序不满足虽然I2C速度不高但也要注意FPGA内部信号到IO口的延迟。确保在约束文件中为I2C信号设置了正确的set_input_delay和set_output_delay如果时钟频率很低有时不约束也能工作但规范的做法是约束。用示波器测量FPGA引脚输出的波形看其建立/保持时间是否满足EEPROM芯片的要求。电源与噪声确保给EEPROM的供电电压稳定、干净。噪声可能导致通信误码。如果条件允许可以在电源引脚附近加一个0.1uF的退耦电容。从机地址错误仔细核对EEPROM芯片的型号和Datasheet。确认硬件地址引脚A2, A1, A0的接法接高电平、低电平或悬空悬空时内部可能有上拉/下拉。计算出的7位从机地址是否正确。一个快速验证的方法是可以用一个I2C协议分析仪或一块带有I2C主控功能的开发板先单独测试一下EEPROM芯片是否正常。内部写周期未等待这是最容易出错的地方。如果你的程序连续快速发起两次写操作第二次很可能因为EEPROM还在处理第一次的写入而返回NACK。务必在每次写操作后等待足够长的时间必须大于Datasheet中tWR的最大值如5ms或10ms。可以在FPGA内部用一个计数器来实现这个延时。双向引脚处理不当如前所述SDA作为inout必须正确处理三态控制。在仿真中可能没问题但在实际硬件中如果inout端口在应该读取的时候被驱动就会发生总线冲突。用示波器看如果发现SDA线在主机应该释放的时候仍然有输出就是这个问题。仔细检查状态机中sda_oe的控制逻辑。调试建议准备一个示波器最好是带I2C协议解码功能的。直接抓取SCL和SDA的波形对照I2C协议时序图和你代码中预期的序列逐位分析是定位问题最快的方法。6. 性能优化与高级功能扩展当基本功能稳定后可以考虑一些优化和扩展让这个控制器更实用、更强大。6.1 添加写缓冲与队列机制如果应用需要频繁写入小数据每次写都要等待5ms的tWR效率极低。一个优化思路是设计一个写缓冲队列FIFO。用户写入请求和数据被暂时存入FIFO由后台状态机依次执行。这样用户逻辑无需等待可以连续发起多个写请求。状态机在执行时会自动处理每个写操作之间的等待时间。但需要注意EEPROM的写入寿命通常10万到100万次避免对同一地址进行过于频繁的写操作。6.2 实现中断与DMA接口对于集成处理器如Zynq SoC中的ARM Cortex-A核的系统可以将EEPROM控制器封装成AXI-Lite从机并支持中断。当一次读写操作完成或发生错误时产生中断通知处理器。更进一步可以设计一个简单的DMA引擎当需要读取或写入一大块数据时例如读取存储的配置文件由DMA自动完成多次I2C传输将数据搬运到指定的内存区域极大减轻处理器负担。6.3 兼容多种EEPROM型号我们的设计是针对24C6416位地址。市场上还有24C028位地址、24C256需要两个地址字节但控制字略有不同等。可以设计一个可配置的地址宽度参数ADDR_WIDTH并在协议封装模块中根据该参数决定发送几个地址字节。控制字中的设备类型标识1010也可能不同这也可以通过参数来配置。这样一个核心设计就能通过不同的实例化参数适配多种型号的EEPROM。6.4 增加CRC校验与错误重试为了提高数据可靠性可以在写入时计算数据的CRC循环冗余校验并一起存储读取时再计算校验。如果校验失败可以自动重试读取操作若干次。对于关键数据甚至可以实现在不同物理地址存储多份副本镜像读取时进行投票纠错。实现EEPROM的读写是一个典型的FPGA接口开发项目。它串联了状态机设计、协议理解、时序分析、仿真验证和硬件调试等多个核心技能点。把这个项目吃透不仅意味着你掌握了I2C和EEPROM更重要的是你获得了独立开发FPGA与外部低速串行设备通信接口的能力。下次再遇到SPI Flash、温湿度传感器、RTC时钟芯片你会发现它们虽然协议不同但解决问题的思路和流程是相通的——研读Datasheet、设计状态机、精确控制时序、仿真验证、上板调试。这个过程就是硬件工程师的成长之路。