简介:本资源为基于FPGA实现EEPROM(AT24C64)读写功能的完整工程实践方案,面向数字电路与嵌入式系统方向的初学者及FPGA开发入门者,解决非易失性存储器在可编程逻辑平台上的协议时序控制、地址管理与数据交互等核心问题。压缩包共234个文件,总计6.23MB,涵盖Verilog源码(.v)、Quartus综合/布局布线数据库(.cdb/.hdb/.rpt)、编译日志(.qmsg/.logdb)、引脚约束(.qsf)、顶层模块信息(.ammdb/.bpm)及配置说明(.txt/.readme)等关键类型,结构完整、工程可直接编译下载。已有104人学习下载,资源包含可运行的e2prom_top顶层模块及配套测试逻辑,提供I²C时序仿真验证路径、寄存器映射说明与擦写寿命保障设计参考,便于读者深入理解EEPROM底层通信机制并快速迁移至物联网终端、穿戴设备等需小容量掉电保存场景的FPGA应用开发。
1. 项目思路与整体架构
1.1 为什么大家都在用EEPROM做读写验证
EEPROOM在FPGA开发板的出场率非常高,基本属于必备外设。我见过很多初学者一开始不理解:明明FPGA内部有Block RAM,又大又快,为什么还要挂一颗I2C接口的EEPROM在外面,读写速度还慢得不行?这个问题的答案其实就藏在EEPROM的“E”上——Electrically Erasable,电可擦除,掉电不丢数据。FPGA内部的BRAM一断电数据全没,而EEPROM可以长期保存配置参数、校准系数、设备序列号这类“上电之后必须马上能用”的信息。
另一个很实际的原因是接口简单。EEPROM用的是I2C总线,总共就两根线:SCL时钟、SDA数据,硬件连接极其省引脚。我之前做过一个多通道采集板,FPGA引脚紧张到要抠着用,但挂两颗EEPROM总共只占了两个IO口,比SPI Flash还省一根线。对FPGA来说,I2C虽然是慢速接口,但恰恰因为慢,非常适合作为入门级通信协议来练手——状态机思路、时序约束、跨时钟域处理这些基本功都能在这个小项目里过一遍。
所以这个项目的核心价值不在于“把EEPROM读写跑通”这个结果,而在于通过实现I2C主控制器,把FPGA里最常用的“状态机设计方法”和“时序分析方法”真正落到代码上。芯片手册里的时序图是死的,只有自己用状态机实现一遍,才会对建立时间、保持时间、ACK时序窗口这些概念有切身体会。
1.2 I2C总线与EEPROM的基础关系
I2C总线是Philips(现在的NXP)搞出来的两线制串行总线,标准模式100kbit/s,快速模式400kbit/s,高速模式能到3.4Mbit/s。EEPROM这类存储器用I2C接口再合适不过,因为存储器的读写天然就是“地址+数据”的模式,跟I2C总线“先发地址、再传数据”的帧结构非常契合。
以最常见的AT24C02为例,容量2Kbit(256字节),挂在I2C总线上有一个7位器件地址,高4位固定为1010,低3位由A2、A1、A0引脚电平决定。所以理论上一条I2C总线上最多可以挂8颗AT24C02,通过A2/A1/A0区分。实际项目中,我习惯把A2A1A0全部接地,器件地址就是0xA0(写方向)/0xA1(读方向),省事且不容易出错。
顺便说一句,I2C总线上可以同时挂多个不同器件,EEPROM、温度传感器、RTC时钟芯片都能共存。只要每个器件的7位地址不冲突,通信时通过地址就能精准选中目标器件。FPGA做主控制器时,总线上所有从机的地址规划是硬件设计阶段就要确定下来的,否则后面调试全是坑。
1.3 我的架构设计思路
我在这颗AT24C02读写控制器的实现上,最终确定的是“一个主状态机+两个分频器”的结构。
整个I2C控制器以状态机为绝对核心,划分了IDLE、START、SEND_ADDR、SEND_DATA、STOP等十几个状态,每个状态下根据当前操作类型(读还是写)推进不同的分支。SCL时钟由系统时钟分频得到,比如50MHz系统时钟跑100kHz的I2C,就需要500分频。分频器同时要生成SCL高电平中点的采样窗口和低电平中点的切换窗口,这是保证数据稳定的关键——I2C协议规定数据只能在SCL低电平期间变化,高电平期间必须保持稳定。
我遇到过很多人写I2C控制器,用延时来模拟时序,结果SCL生成和SDA切换全挤在同一条时间线上,仿真能过,上板就莫名其妙出问题。正确的做法是:SCL高/低电平的时长要独立可控,SDA数据的切换只能在SCL为低时发生,采样则只能在SCL为高时进行。这个三者的时间关系捋清楚了,I2C基本就通了一半。
数据通路方面,我不建议把写缓冲和读缓冲做成FIFO,这个场景数据量不大,直接做寄存器堆即可。发送时用一个字节的移位寄存器,每个SCL时钟沿移出一位;接收时同样移位,第9个时钟周期判断ACK。寄存器级的设计逻辑清晰、时序收敛容易,对刚入门FPGA的人来说也更友好。
2. I2C时序核心与EEPROM读写协议
2.1 SCL/SDA时序基础与起始停止条件
先花点篇幅把I2C最底层的时序说透。I2C是半双工同步串行总线,SCL是主控制器产生的时钟,SDA是双向数据线。SDA的数据变化严格受限于SCL的电平,有两条铁律:
- 数据只能在SCL低电平期间切换。
- SCL高电平期间,SDA必须保持稳定。
这两条铁律是I2C区别于SPI等接口的本质特征。SPI的MOSI、MISO是独立的线,主从方向分离,所以可以做到全双工。I2C的SDA是同一根线,如果数据跳变发生在SCL高电平期间,就会被从机误判为起始或停止条件,直接导致通信错乱。
起始条件(START)的定义:SCL保持高电平期间,SDA从高跳变为低。停止条件(STOP)的定义:SCL保持高电平期间,SDA从低跳变为高。特别注意“SCL高电平期间”这个前提,所以起始和停止条件的产生必然要用到SCL高电平的周期。我在设计中专门留了一个SCL_HIGH状态,在SCL为高时先插入半个周期延时,然后翻转SDA,确保起始/停止条件的建立时间满足芯片手册要求。
AT24C02的数据手册上,起始条件建立时间(tSU;STA)典型值是600ns,停止条件建立时间(tSU;STO)典型值也是600ns,SCL低电平时间(tLOW)典型值1.3us,高电平时间(tHIGH)典型值0.6us。以100kHz总线频率计算,半个周期是5us,完全满足这些时序要求。事实上只要分频后SCL能稳定在100kHz左右,所有建立/保持时间都远大于手册最小要求,这也是I2C接口很“皮实”的原因之一。
2.2 单字节写与页写操作详解
AT24C02的写操作分两种:字节写(Byte Write)和页写(Page Write)。
字节写的帧格式是:起始条件 -> 器件地址(写方向)-> 字地址(8位)-> 待写数据 -> 停止条件。每次传输过程中,EEPROM会在收到每个字节后拉低SDA应答(ACK)。发送完数据并收到ACK之后,EEPROM还需要一段时间把数据写入内部的非易失存储单元,这段时间称为写周期(Write Cycle Time),典型值5ms。在写周期内,EEPROM对任何外部命令都不响应,更准确说是不产生ACK应答。所以代码里在写完一个字节后,必须等待至少5ms才能发起下一次操作。
页写则是把同一页内的多个字节连续写入。AT24C02的页大小是8字节,意味着一次页写最多可以写8个字节。例如字地址是0x00,可以连续写0x00到0x07;但如果从0x06开始写4个字节,地址走到0x07之后再继续写,地址会回卷到0x00,把之前写的0x00和0x01覆盖掉。这个回卷特性是所有EEPROM都有的,很多新手在这里踩坑——我见过有人在页写时跨页连续写了16个字节,结果前8个和后8个互相覆盖,数据全乱了。
我给控制器做的写操作是“任意长度写”,在状态机里判断:起始地址加上剩余字节数是否超过当前页边界。如果超过,就拆成两次页写,或者干脆逐字节写。对于256字节空间的AT24C02来说,逐字节写虽然慢一点(每字节5ms),但逻辑最简单、最不容易出错。实际项目如果对写入时间不敏感,我强烈建议直接逐字节写。
2.3 随机读与连续读操作详解
读操作比写操作要绕一些,因为I2C总线在读取过程中有个“方向切换”的动作。
随机读(Random Read)的帧格式是:起始条件 -> 器件地址(写方向)-> 字地址 -> 重复起始条件 -> 器件地址(读方向)-> 读出数据 -> 非应答(NACK)-> 停止条件。
第一次发器件地址时方向位是0(写),目的是告诉EEPROM“我要操作的地址是哪个”;重新发送起始条件后,方向位变成1(读),EEPROM才会把该地址的数据放到SDA上。这里容易出错的地方在于:地址发送完进入读方向之后,要等一个SCL周期让EEPROM切换SDA方向,如果紧接着就去采样,经常采到的是高阻态下的不确定电平。
连续读(Sequential Read)则是在随机读的基础上,读出一个字节后主机回ACK,EEPROM自动将地址加1,继续输出下一个字节。除了最后一个字节回NACK,前面每一字节都要回ACK。我在设计中让主状态机在读状态内通过一个计数器控制当前是第几个字节,读到最后一个字节时,在第9个SCL周期保持SDA为高(即不回ACK),然后生成停止条件。
3. 硬件环境与信号定义
3.1 上拉电阻怎么选
I2C总线是开漏结构,SCL和SDA都有内部的开漏输出晶体管,只能主动拉低,不能主动拉高。高电平靠外部上拉电阻提供。经典上拉电阻取值是4.7kΩ,这个值在100kHz标准模式下足够。如果总线电容较大、挂载器件较多,可以改用2.2kΩ或1kΩ,代价是功耗稍微增加。
在FPGA开发板上用EEPROM时,重点检查板卡原理图上是否已经带了上拉电阻。像黑金、正点原子、Digilent这些开发板,EEPROM附近的SCL/SDA都会预留4.7kΩ上拉。自己画板的话,上拉电阻一定不要省,这是I2C通信不稳定的头号原因。我调试时有块转接板就是因为SDA缺上拉,读出来的数据每隔一段就出现一个错误的0,查了半天才发现是上拉电阻虚焊。
3.2 FPGA引脚约束与PCB布线注意点
FPGA侧,SCL和SDA都设置为普通的双向IO或者OD输出。Xilinx 7系列里,SDA需要实例化IOBUF原语,IOBUF的控制端(T端)为高时输出高阻,为低时输出数据。SCL则可以直接用OBUF或普通输出引脚跑。
SDA必须设置为双向端口(inout),并且这个双向引脚的方向切换时机必须和状态机严格配合。在发送数据期间,方向控制信号拉低,引脚输出;在释放总线等待ACK期间,方向控制信号拉高,引脚变为高阻,由外部上拉电阻拉到高电平。这里有个细节:从“输出数据”切换到“输入采样”的时刻,最好安排在SCL低电平时完成,避免切换瞬间在SDA上产生毛刺。
引脚约束(XDC文件)里,我习惯将SCL约束到支持LVCMOS33电平的普通IO,SDA同样。由于I2C频率低,SI分析基本没有压力,但最好加上PULLUP属性,双保险。
3.3 开发板与EEPROM型号选择
开发板上最常见的EEPROM是AT24C02、AT24C04、AT24C64、M24C02这些,引脚兼容,主要区别是容量和多出来的地址线。AT24C02的A2/A1/A0引脚都是地址脚;AT24C04则只用A2/A1,A0变成最高位地址线;AT24C08以上,地址脚进一步减少。所以代码里I2C控制器的器件地址寄存器应该做成可配置的,方便切换不同型号。
如果你手头是STM32、树莓派这些平台,EEPROM的读写代码就完全不是这个思路了,因为MCU自带I2C外设,直接调库函数就行。FPGA上没有现成的I2C控制器,必须从底层时序用Verilog手搓,这也就是这个项目最有学习价值的地方。
我建议从AT24C02开始,如果做项目需要更大容量,再上AT24C64。容量越大,内部页大小也越大(AT24C64的页大小是32字节),写满整片反而更快。
4. 关键代码实现与仿真
4.1 状态机核心代码解析
我采用三段式状态机实现I2C主控制器,下面给出最关键的核心代码,解释每个状态的作用和跳转条件。
module i2c_eeprom_ctrl #( parameter CLK_FREQ = 50_000_000, parameter SCL_FREQ = 100_000 )( input wire clk, input wire rst_n, // 用户接口 input wire start, input wire wr_en, // 1写 0读 input wire [7:0] addr, input wire [7:0] wdata, output wire [7:0] rdata, output wire done, output wire ack_err, // I2C接口 inout wire sda, output wire scl ); localparam IDLE = 5'd0; localparam START1 = 5'd1; localparam START2 = 5'd2; localparam SEND_ADDR = 5'd3; localparam SEND_SUBA = 5'd4; localparam RESTART = 5'd5; localparam SEND_WD = 5'd6; localparam STOP1 = 5'd7; localparam STOP2 = 5'd8; localparam READ_DAT = 5'd9; localparam REG_WR = 5'd10; localparam ACK_CHK = 5'd11;状态机从IDLE收到start信号后,先进入START1产生起始条件,随后在SCL低电平期间逐位发送器件地址和字地址。每次发送完8位数据后,进入ACK_CHK状态,释放SDA总线并采样ACK。写数据的路径走SEND_SUBA之后的SEND_WD,读数据的路径则在RESTART状态重新发送起始条件后切换方向。
实际代码中,我最看重的是“字节内位计数器”和“状态机状态计数器”的协同。数据位计数器(bit_cnt)在0到7之间循环,每来一个SCL低电平沿移出一位;第8位之后的下一个周期就是ACK窗口。之前写代码时试图把ACK检测逻辑直接放在SEND_ADDR状态下,用if/else判断,结果分支条件越来越多,状态机变得不可维护。后来把ACK检测独立成状态,每个发送类状态结束都跳转到ACK_CHK,结构立刻清爽了。
4.2 关键代码:ACK检测与SDA方向控制
// SDA方向控制 reg sda_dir; wire sda_in; assign sda = sda_dir ? 1'bz : sda_out; // ACK检测 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin ack_flag <= 1'b1; end else if (state == ACK_CHK) begin ack_flag <= sda_in; // 低电平为ACK,高电平为NACK end end在ACK_CHK状态,必须保证SDA方向已经切为输入(高阻),让从机能够拉低SDA应答。方向切换的时机需要在进入ACK_CHK状态之前的一个SCL低电平周期完成,否则从机的ACK信号会被主机的输出驱动掩盖掉。这是I2C调试中非常经典的一个问题:方向切换慢了半拍,导致主机永远检测不到ACK,错误地认为从机不响应。
sda_out寄存器的设计也有讲究:我采用一个8位移位寄存器作为发送缓冲,每次SCL低电平期间移位一次,高位在前。读方向的数据则从sda_in采样后写入移位寄存器,读完一个字节再锁存到rdata输出寄存器。这样发送和接收共用一套移位逻辑,代码量可以省不少。
4.3 Testbench仿真过程
仿真时,除了例化EEPROM控制器的DUT,还需要一个I2C从机模型来模拟EEPROM行为。最省事的办法是直接使用AT24C02的Verilog仿真模型,网上有现成的,或者在testbench里写一个简化的I2C从机行为模型。下面给出testbench的核心骨架:
module tb_i2c_eeprom; reg clk; reg rst_n; reg start; reg wr_en; reg [7:0] addr; reg [7:0] wdata; wire [7:0] rdata; wire done; wire ack_err; wire sda; wire scl; // 待测控制器例化 i2c_eeprom_ctrl #( .CLK_FREQ(50_000_000), .SCL_FREQ(100_000) ) dut ( .clk(clk), .rst_n(rst_n), .start(start), .wr_en(wr_en), .addr(addr), .wdata(wdata), .rdata(rdata), .done(done), .ack_err(ack_err), .sda(sda), .scl(scl) ); // EEPROM仿真模型例化 AT24C02 u_eeprom ( .A0(1'b0), .A1(1'b0), .A2(1'b0), .WP(1'b0), .SDA(sda), .SCL(scl) );仿真的时序检查要点有三个:起始条件是否满足SDA先于SCL跳变的要求、写入数据在SCL高电平期间是否稳定、停止条件后是否有足够的总线空闲时间。Vivado的仿真器在这些方面做得不错,Modelsim则需要自己加一些断言来检查。我一般会在testbench里加两个简单断言:一是SCL高电平期间SDA不能跳变,二是start信号拉高后必须在若干个时钟周期内观察到SDA上的起始条件。这两个断言能省下大量手工看波形的精力。
仿真波形上万用鼠标拖动波形看信号变化太费眼了。我通常直接把sda、scl、state、bit_cnt这几个关键信号拖到同一个窗口,按字节为单位放大波形检查。如果发现某个字节的ACK漏了,第一时间找SCL的上升沿是否和SDA的拉低重叠。
4.4 上板实测要点
仿真通过后,上板实测的流程是:先通过串口或者按键触发一次写操作,把一组固定数据写入EEPROM,然后断电重启,再触发读操作,把读回来的数据和预期值比对。我没有在板子上跑操作系统的条件,就直接用ILA(集成逻辑分析仪)抓内部信号,抓的状态向量包括state、bit_cnt、sda_out、sda_in、done等,全部声明为mark_debug。
上板后最容易发现的两个问题:
- 第一个是电平不匹配。EEPROM供电3.3V,FPGA的IO bank如果配成1.8V,SDA/SCL电平就对不上,通信完全失败。查一下原理图,确认EEPROM的VCC和FPGA的IO bank电压一致。
- 第二个是复位时序。FPGA上电瞬间,EEPROM可能还没准备好,此时如果控制器立刻发起操作,会收到NACK。我在控制器里加了一个100ms的上电延时,确保EEPROM稳定后再允许start信号触发操作。
5. 常见问题排查与调试技巧
5.1 问题速查表
| 问题现象 | 可能原因 | 排查方向 |
|---|---|---|
| 写操作后回读全为0xFF | 地址发送错误或器件应答异常 | 用ILA抓器件地址发送帧,确认方向位是否正确 |
| SDA始终为高,器件无ACK | 上拉电阻虚焊/缺失 | 示波器测SCL/SDA静态电平,应为高电平 |
| ACK时有时无 | 写周期未等待或时钟过快 | 写操作后至少延时5ms再发下一次命令 |
| 连续读数据错位 | 最后一个字节没有回NACK | 检查读状态机的最后一个字节ACK控制逻辑 |
| 页写数据被覆盖 | 跨越页边界写数据 | 将写地址按页边界拆分 |
| 上板运行偶尔卡死 | 状态机在无响应时死等 | 在状态机中加入超时跳转,NACK后回到IDLE |
| I2C信号电平不对 | FPGA IO bank电压与EEPROM不匹配 | 核对原理图电压域 |
5.2 关于ACK检测不稳定
遇到最多的还是ACK检测不稳定的问题。I2C从机拉低SDA响应有一个最大延迟时间,标准模式是0.9us,也就是在SCL低电平结束前必须完成拉低。如果主机的ACK采样窗口太靠近SCL上升沿,就有可能在SDA尚未完全拉低时采到高电平。
解决方案是让ACK采样点落在SCL高电平的中段,而不是边沿处。我在设计中将ACK_CHK状态设置为在SCL高电平的中点采样,以100kHz的SCL频率计算,高电平持续5us,中点采样点距离上升沿2.5us,远大于从机的应答延迟时间,鲁棒性足够。
5.3 写周期等待的细节与优化
前面提到EEPROM写入需要5ms左右的内部写周期。这个等待不能省,但可以用“启动下一次操作前查询”的方式做优化,而不是硬等。AT24C02支持一种“ACK查询”机制:写周期内器件不响应,一旦写周期结束,再次发送器件地址时就会收到ACK。利用这个特性,代码可以在发出写命令后,反复发送器件地址+写方向,直到收到ACK表示写周期结束。
不过我实测下来,AT24C02的写周期大概在3-5ms之间,查询机制虽然省时,但增加了一部分状态机复杂度。如果系统对写入时间不敏感,比如配置参数只在开机时写一次,直接延时5ms更稳妥。如果频繁写入,比如每秒记录一次日志,用ACK查询机制可以显著提升效率。
5.4 在FPGA里调试I2C的独门技巧
最后分享一个个人经验:调试I2C时序时,单纯靠FPGA内部的ILA抓信号,往往因为触发条件设置不对而抓不到故障现场。我的做法是先在testbench里把时序跑熟,对每个状态的耗时心中有数,然后把ILA的触发条件设置为“检测到NACK”,这样一旦上板出现总线异常,ILA能立刻抓下现场数据。
另一个技巧是使用逻辑分析仪。FPGA的IO引脚被I2C占用的同时,板卡上通常会引出测试点,把逻辑分析仪的探头夹在SCL和SDA上,直接在总线上抓包观察时序波形,比FPGA内部ILA更直观。这也是为什么我在硬件设计阶段就会预留I2C测试点——调试效率和便利性会大幅提升。
对EEPROM这种成熟器件,FPGA端的控制器实现完全可以做成通用IP,换不同型号的EEPROM时只需修改器件地址和页大小参数。这个项目做完之后,整套I2C主控制器代码可以被复用到温度传感器、RTC、ADC配置寄存器读写等场景,性价比非常高。你在自己实现的过程中遇到问题,欢迎回来交流具体波形和状态机状态,很多时候就是一两根信号的时序关系没对齐。
本文还有配套的精品资源,点击获取