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 Wrapper):I2C核心只负责最底层的位传输。而读写EEPROM需要遵循特定的命令序列。例如,随机写一个字节需要:START -> 发送设备地址(含写标志) -> 等待ACK -> 发送内存地址高字节 -> 等待ACK -> 发送内存地址低字节 -> 等待ACK -> 发送数据字节 -> 等待ACK -> STOP。这个模块的作用就是封装这些针对EEPROM的操作序列,向I2C核心模块发送更高级的指令(如“写一个字节到地址0x0100”),并接收其状态反馈。
- 用户接口/控制模块(User Interface/Control):提供友好的接口给FPGA内部的其他逻辑或外部处理器(如通过寄存器映射)。它可能是一个简单的状态机,接收用户“读”或“写”的请求,调用EEPROM协议封装模块,并返回结果和状态(忙/闲、成功/失败)。对于更复杂的系统,这个模块可以是一个AXI4-Lite或Avalon-MM总线从机,方便SoC系统中的处理器通过内存映射访问EEPROM。
- 时钟分频与同步模块(Clock Divider & Synchronization):FPGA的主时钟频率(如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 = 4'd0; // 空闲状态 localparam [3:0] START = 4'd1; // 产生起始条件 localparam [3:0] SEND_ADDR = 4'd2; // 发送7位地址+1位读写位 localparam [3:0] WAIT_ACK1 = 4'd3; // 等待从机对地址的应答 localparam [3:0] SEND_DATA = 4'd4; // 发送一个数据字节 localparam [3:0] WAIT_ACK2 = 4'd5; // 等待从机对数据的应答 localparam [3:0] RECV_DATA = 4'd6; // 接收一个数据字节 localparam [3:0] SEND_ACK = 4'd7; // 主机发送应答位(ACK/NACK) localparam [3:0] STOP = 4'd8; // 产生停止条件 // 可能还需要一些中间状态,如重复起始条件(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 : 1'bz; assign sda_in = sda; // 同步后使用 // 在状态机中控制sda_oe和sda_out always @(posedge clk or posedge rst) begin if(rst) begin sda_oe <= 1'b0; sda_out <= 1'b1; state <= IDLE; end else begin case(state) START: begin // SCL高电平期间,将SDA从高拉低 if(clk_edge_pos) begin // 某个SCL高电平中的点 sda_oe <= 1'b1; sda_out <= 1'b0; end // ... 状态转移 end SEND_DATA: begin // 根据要发送的数据位,在SCL低电平期间更新SDA if(scl_low_center) begin sda_oe <= 1'b1; sda_out <= data_to_send[7-bit_cnt]; end end RECV_DATA: begin // 释放SDA线,准备读取 sda_oe <= 1'b0; // 在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/W=0)-> 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),并且不发送STOP,EEPROM会自动将内部地址计数器加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型号
我们的设计是针对24C64(16位地址)。市场上还有24C02(8位地址)、24C256(需要两个地址字节,但控制字略有不同)等。可以设计一个可配置的地址宽度参数(ADDR_WIDTH),并在协议封装模块中根据该参数决定发送几个地址字节。控制字中的设备类型标识(1010)也可能不同,这也可以通过参数来配置。这样,一个核心设计就能通过不同的实例化参数,适配多种型号的EEPROM。
6.4 增加CRC校验与错误重试
为了提高数据可靠性,可以在写入时计算数据的CRC(循环冗余校验)并一起存储,读取时再计算校验。如果校验失败,可以自动重试读取操作若干次。对于关键数据,甚至可以实现在不同物理地址存储多份副本(镜像),读取时进行投票纠错。
实现EEPROM的读写,是一个典型的FPGA接口开发项目。它串联了状态机设计、协议理解、时序分析、仿真验证和硬件调试等多个核心技能点。把这个项目吃透,不仅意味着你掌握了I2C和EEPROM,更重要的是你获得了独立开发FPGA与外部低速串行设备通信接口的能力。下次再遇到SPI Flash、温湿度传感器、RTC时钟芯片,你会发现它们虽然协议不同,但解决问题的思路和流程是相通的——研读Datasheet、设计状态机、精确控制时序、仿真验证、上板调试。这个过程,就是硬件工程师的成长之路。