简介:面向FPGA初学者的EEPROM读写完整工程包,基于Altera Quartus开发环境,目标芯片为AT24C64,可直接学习I2C总线时序与非易失存储器的读写控制方法。压缩包共234个文件,核心设计源文件包括.v和.tdf,另含Quartus工程设置文件(qpf/qsf)、编译生成的.sof位流文件以及大量工程中间文件,整体大小6.23MB,目录结构完整,便于检索设计入口与编译产物。已有107人学习。工程中提供顶层模块及读写逻辑代码,便于在ModelSim或开发板上进行仿真验证,可帮助理解EEPROM的页写与随机读操作,同时可参照工程设置完成综合、布线到下载的完整流程,适合作为数字逻辑课程设计或FPGA入门进阶的参考资料。
1. FPGA读写EEPROM:为什么这个“老掉牙”的外设仍然值得自己写一份控制器
很多工程师第一次接触FPGA,都是从点亮LED、跑个流水灯开始的,等真正要往板子上保存参数、加载校准系数的时候,才发现EEPROM这个看起来不起眼的小芯片,在FPGA项目里其实是个绕不开的硬骨头。FPGA实现EEPROM的读写,说白了就是你自己用Verilog写一套I2C主机控制器,通过SCL和SDA两根线按协议把数据写进EEPROM或者读出来。这事难不难?单论协议只有起始、停止、应答、数据位几个动作,但自己做一遍就会发现,时序边界、三态门、跨页写入、写周期等待,任何一个点没处理好都会让你在示波器前怀疑人生。这篇文章的目标读者,是那些正准备在FPGA里接AT24C系列EEPROM,或者想彻底搞明白I2C时序而不再依赖“网上抄一段能跑就行”代码的人。我会从协议拆到状态机,再落到可复现的Verilog代码和排查手段,让你看完敢自己动手写。
2. 选型与协议基础:I2C时序、EEPROM页结构,以及FPGA软核实现的三个理由
2.1 从AT24C02说起:EEPROM的地址、页与写保护引脚
项目里最常见的EEPROM是AT24C02,容量2Kb,也就是256字节,挂在I2C总线上。它的从机地址是7位的,高4位固定为1010,接下来的3位由A2、A1、A0引脚决定,最后一位是读写方向位。比如A2A1A0全部接地,那么写地址就是0xA0,读地址就是0xA1。如果你的板子上有多片EEPROM,或者A0引脚被设计成接高电平,那地址就要跟着改,这一点在写死代码之前一定要先看原理图。
AT24C02内部按8字节一页组织,页写就是一次性最多写8个字节。但这里有个经典陷阱:如果写入字节数跨过了页边界,比如从地址0x05开始写5个字节,那就会绕回页首,覆盖掉页内前面的数据。所以设计写入函数时,必须做好“分页写入”的判断。除此之外,WP写保护引脚如果被拉高,芯片会拒绝写操作,但读操作不受影响,很多人调试半天写不进去,结果发现是WP被拉死了。
还有一个和FPGA关系很大的参数:写周期tWR,也就是一次页写完成后,EEPROM内部需要5毫秒左右的时间把数据真正固化到存储单元。在这段时间内,芯片不会响应任何新的I2C命令,甚至对总线上的数据也不做应答。FPGA状态机如果忽略这个等待,连续发第二条写命令就会失败,而且失败方式非常隐蔽——第一条看着成功了,第二条的数据丢了。
2.2 I2C时序拆解:起始/停止/应答/数据位,为什么不能用普通寄存器赋值硬凑
I2C时序的核心规则只有一条:数据线SDA只能在SCL为低电平期间变化,而在SCL为高电平期间必须保持稳定。起始条件定义为SCL在高电平时,SDA发生一个从高到低的跳变;停止条件则是SCL在高电平时,SDA从低到高跳变。数据位传输则是在SCL低电平时把SDA设置好,然后在SCL上升沿被从机采样。这里最容易犯的错就是“先给SCL拉高,再改SDA”,这在普通逻辑里顺序执行没问题,但放到物理总线上就是非法的,从机直接蒙圈。
很多新手觉得可以用一个计数器,在每个时钟周期里顺序改变SCL和SDA的值,像写流水灯一样把时序“凑”出来。这样做短时间仿真可能能过,但一到真实芯片上就不稳定,因为寄存器的赋值在时序逻辑里有固定的建立时间、保持时间要求,而I2C协议要求的是边沿关系,不是电平持续几个周期。另一种更稳的做法是用状态机,把起始、停止、发送8位、接收应答这些动作拆成明确的状态,每个状态内根据计数器产生对应宽度的SCL高低电平。这种方式虽然代码长一点,但每个边沿都是受控的,方便调试也方便加超时保护。
2.3 用FPGA软核实现I2C主机的三个理由:可裁剪、可控时序、可并行
有人会问,现在单片机里I2C硬件外设一大堆,为什么还要在FPGA里自己写?第一个理由是裁剪自由度。FPGA上可能同时接了ADC、DAC、温度传感器、EEPROM,每种器件的时序要求不同,硬件I2C外设往往固定了时钟频率和超时策略,软核实现可以精确匹配最慢的从机,也可以把SCL频率做到非标准值,比如为了兼容长走线降到10kHz。
第二个理由是时序可控性。在FPGA里你完全清楚SCL上升沿发生在哪个时钟周期,SDA相对于SCL的建立时间能精确到纳秒级,这在做信号完整性调试和高速I2C(400kHz甚至1MHz)时特别重要。第三个理由是并行性。FPGA天然是并行执行的,你可以在一个I2C主机上挂多路从机,或者同时跑两个I2C主机,一个管EEPROM,一个管传感器,互不干扰,这在单片机里要等DMA、等中断,但在FPGA里只是多分配几个状态机而已。
3. 搭建最小I2C主控制器:状态机与Verilog代码
3.1 顶层模块与引脚规划:SCL、SDA、写使能和数据接口
先给出一份我常用的I2C主机顶层的接口定义,它不需要依赖任何具体器件,只完成单字节发送和接收的基础动作。SCL是推挽输出,SDA则必须是三态门,因为I2C是开漏总线,FPGA的IO需要配置为OD(Open Drain),或者内部用inout双向口配合高阻态控制。
module i2c_master #( parameter CLK_FREQ = 50_000_000, // 输入时钟频率,单位Hz parameter I2C_FREQ = 100_000 // 期望的SCL频率,单位Hz )( input wire clk, input wire rst_n, // 主机接口 input wire start, // 启动一次传输,高有效 input wire rw, // 0写,1读;这里仅单字节模式 input wire [7:0] din, // 要发送的数据 output reg [7:0] dout, // 读到的数据 output reg done, // 传输完成标志 output reg ack_err, // 从机应答错误标志 // I2C总线 inout wire sda, output reg scl );参数里把时钟频率和I2C频率分开,方便在不同板卡间复用。CLK_FREQ / I2C_FREQ会得到分频比,这个值决定SCL半周期宽度。顶层只负责控制状态机启动和接收完成标志,具体的时序逻辑放到内部状态机里。ack_err用来告诉上层这次传输从机有没有给出应答,这样调试时就不用扒在示波器上数脉冲了。
3.2 I2C主控制器状态机:IDLE->START->SHIFT->ACK->STOP
状态机是I2C控制器的灵魂。我习惯把它拆成五个大状态:IDLE等待指令、START产生起始条件、SHIFT逐位发送或接收8位数据、ACK检测从机应答、STOP产生停止条件。每个大状态下再用一个计数器细分出SCL的高低电平阶段。
localparam IDLE = 4'd0; localparam START = 4'd1; localparam SHIFT = 4'd2; localparam ACK = 4'd3; localparam STOP = 4'd4; reg [3:0] state; reg [3:0] bit_cnt; // 0~7,控制移位次数 reg [7:0] shift_reg; // 发送/接收移位寄存器 reg sda_out; // 实际输出的SDA数值 reg sda_oe; // 输出使能,1时驱动SDA,0时释放 assign sda = sda_oe ? sda_out : 1'bz;在这个设计中,sda_oe这个信号特别关键。发送数据位时oe=1,接收数据位和等待应答时oe=0,让SDA浮空,从机才有机会拉低它来给出应答。时序推进用SCL_HALF计数,它等于CLK_FREQ / I2C_FREQ / 2。每过半个SCL周期就切换一次SCL电平,同时根据当前状态决定在这个边沿要做什么动作。
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; scl <= 1'b1; // ... 其他复位 end else begin case (state) IDLE: begin scl <= 1'b1; sda_oe <= 1'b1; sda_out <= 1'b1; // 总线空闲 done <= 1'b0; if (start) begin state <= START; half_cnt <= 0; end end START: begin // SCL高电平期间,SDA从高到低 half_cnt <= half_cnt + 1; if (half_cnt < SCL_HALF - 1) begin sda_out <= 1'b1; end else if (half_cnt == SCL_HALF - 1) begin sda_out <= 1'b0; // 起始沿 end else if (half_cnt == SCL_HALF * 2 - 1) begin scl <= 1'b0; // 拉低SCL,准备发送第一个数据位 half_cnt <= 0; bit_cnt <= 0; shift_reg <= din; state <= SHIFT; end end // ... 其余状态类似 endcase end end起始条件的时序图中,先保证SCL为高,再让SDA拉低,然后SCL才能拉低。代码里通过half_cnt控制,起始沿后还要保持一个高电平时间,再从低到高拉低SCL,这个时间就是起始建立时间和保持时间。实际设计时,我习惯把起始条件和停止条件做成两个独立状态,而不是用计数器硬凑,因为它们的边沿顺序是完全相反的,共用一个状态会越改越乱。
3.3 时钟分频与SCL/SDA三态控制:一半靠代码,一半靠约束
仿真里最容易忽视的一件事是FPGA的IO约束。很多初学者写完代码,直接在开发板上用万用表量SCL,发现一直是高电平,就以为死机了。其实可能是IO约束里把SDA配成了普通推挽输出,此时SDA永远被强驱高,EEPROM的应答根本拉不下来。
一般我在Xilinx或Altera的约束文件里会这样写:SCL用LVCMOS33,SDA也配LVCMOS33但加上PULLUP属性。如果FPGA引脚本身没有内部上拉,就必须在PCB板上加4.7kΩ的上拉电阻到3.3V。I2C协议要求SCL和SDA都是开漏结构,FPGA内部如果支持开漏输出模式,在约束里开启;如果不支持,就用代码里的sda_oe控制三态,外部上拉负责把线拉高。这一点是硬约束,不是软件能替代的。
分频参数也有讲究。假如时钟是50MHz,想得到100kHz的SCL,分频比就是500,那么SCL半周期就是250个时钟周期。我一般会把SCL_HALF做成局部参数,并且在仿真时单独定义一个小的参数,比如SCL_HALF=5,这样仿真波形能快速拉出来看,否则仿真跑一次要等半天。真实综合时再换成大分频比。这里要提醒的是,I2C标准规定SCL低电平时间要大于高电平时间,其实从分频对称来看两者差不多,但考虑到上升沿受上拉电阻和总线电容影响,低电平时间稍微留长一点更稳。
4. 实现读写操作:页写、随机读和顺序读的Verilog设计
4.1 一次完整的写操作:地址、数据、跨页边界处理
有了单字节I2C主控制器,就可以组合出写命令。EEPROM的页写命令格式是:起始 + 从机写地址 + 内部字节地址 + 第1个数据 + 第2个数据…… + 停止。AT24C02一页8字节,所以一次最多写8个字节。如果写的数据多,FPGA侧需要自己拆成多次页写,每次的起始地址加8。
这里给出一段基于状态机的多字节页写控制器,它接收一个起始地址和数据数组,自动完成分页和写周期等待。
module eeprom_page_write #( parameter PAGE_SIZE = 8, parameter ADDR_WIDTH = 8 )( input wire clk, input wire rst_n, input wire start, input wire [ADDR_WIDTH-1:0] addr, input wire [PAGE_SIZE*8-1:0] data_in, // 最多一页数据 input wire byte_cnt, // 实际写几个字节,1~8 output reg done, output reg ack_err, // 控制I2C主机,持续握手 output reg [7:0] i2c_din, output reg i2c_start, output reg i2c_rw, input wire i2c_done, input wire i2c_ack_err, // 还需要输出内部地址到主机的地址寄存器,这里简化成a_phase output reg load_addr_phase );这个模块的控制核心是一个发送序列,首先发送从机写地址(0xA0再加上A2A1A0位),然后发送内部字节地址,最后连续发送数据字节。每一个字节都复用同一个I2C主机状态机,用i2c_start脉冲触发,等i2c_done返回后再送下一个字节。发送过程中要特别注意从机写周期tWR,所以一旦本次页写发送完成,必须先等待一个延时,而不是立刻开始下一次写。
跨页处理逻辑可以用一个简单的预判:如果addr的低3位加上本次要写的字节数大于8,那就说明跨页了。常见做法是把要写的数据拆成两段,第一段写到页尾,第二段从下一页开头写。这个拆分在逻辑上并不复杂,但很容易被忽略,且一旦发生,不会报错,只会把前面的数据冲掉。
4.2 随机读与顺序读:读时怎么处理NACK和停止条件
读操作比写操作多一个环节:先要“空写”一个内部地址,然后重新发起起始条件,发送从机读地址,之后从机才会把数据放到SDA上。随机读命令格式是:起始 + 从机写地址 + 内部地址 + 重复起始 + 从机读地址 + 读取数据字节 + 主机回NACK + 停止。
读取单个字节时,主机在最后必须发送非应答(NACK),告诉从机不要再发了。NACK的产生方法是在SCL高电平期间让SDA保持高,而不是像ACK那样拉低。这又是一个容易被状态机搞错的地方,很多人的I2C主机只实现了发送数据时的移位逻辑,却没有单独处理接收端的NACK状态。
顺序读是随机读的连续版:在读取第一个字节后不回NACK,而是回ACK,从机就会继续输出下一个地址的数据,直到主机在某个字节回NACK并发送停止,读取才结束。FPGA实现时,只需要在每读完一个字节后判断是否继续读:如果继续,就回ACK并等待下个字节;如果停止,就回NACK并拉出停止条件。要注意,回ACK/NACK的时间必须控制在SCL低电平期间改变SDA,高电平期间SDA必须稳定,和发送数据是同一套规矩。
4.3 主机侧调度:用简单的寄存器接口让CPU或逻辑来触发读写
在工程上,FPGA里的EEPROM读写控制器往往不只服务一个模块。比如一个寄存器配置模块要写校准参数,另一个状态机要读版本信息。我会在上层做一个简单的寄存器接口,通过地址译码把不同的读写请求转成对I2C主机的操作。
reg [15:0] cmd_addr; // 目标EEPROM地址 reg [7:0] cmd_wdata; // 写入数据 reg cmd_wr; // 写请求 reg cmd_rd; // 读请求 wire cmd_done; wire [7:0] cmd_rdata; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin cmd_wr <= 1'b0; cmd_rd <= 1'b0; end else begin // 每收到一个写请求,就启动一次EEPROM写序列 if (cpu_wren) begin cmd_addr <= cpu_addr; cmd_wdata <= cpu_wdat; cmd_wr <= 1'b1; end if (i2c_cmd_finished) begin cmd_wr <= 1'b0; cmd_rd <= 1'b0; end end end这里的调度模块通常使用“忙等待”的方式,也就是发起请求后不发起新请求,直到cmd_done拉高。为了不阻塞其他任务,我会把cmd_done连接到中断或轮询标志上,让上层逻辑决定何时发起下一次请求。读写操作本身有原子性要求,所以在同一个调度器里,我不会让写请求和读请求同时产生,而是通过一个busy信号互斥。这个信号还能用来做写周期等待——在完成一次写后,对busy保持至少5ms的置位,然后再清除,这样后续读操作就不会撞上tWR窗口。
5. 常见问题与避坑排查:上拉电阻、应答失败、时序违例和三态冲突
5.1 现象:SDA一直低电平,被拉死了 —— 原因与解决
板子一上电,其他信号都正常,就是SDA一直为低,不管代码里怎么释放三态,示波器上依然是0V。这种情况十有八九是总线被某个设备拉住了,常见原因有两个:一是某个I2C从机进入了一种错误状态,它在等待一个停止条件,此时它把SDA拉低不放;二是FPGA代码在初始化时向SDA写了一个低电平,并且oe一直为高,导致强驱低。第二种原因对FPGA来说尤其常见,因为复位状态下如果没有把oe置0,SDA就会被驱动为0。
解决方法是:第一,在复位和IDLE状态下强制oe<=1'b0,让SDA处于高阻;第二,在总线死锁时,人为发送9个SCL时钟脉冲来释放死锁。很多成熟IP都会做这个处理,我一般在状态机里加一个RECOVER状态,当连续检测到SDA低电平超过一个时间阈值时,自动切进恢复状态,不断翻转SCL,直到SDA恢复高电平。这个方法对大多数从机都有效。
5.2 现象:写操作“成功”但数据读回来是0xFF —— 跨页和写周期问题
写操作明明每个字节都有ACK,但读出来的数据却是0xFF,这几乎可以锁定是跨页写入或者写周期没等够。跨页写入时,地址没有递增,写的数据从页首覆盖到页尾,但读地址指向的却是另一页,所以读到的是旧数据。写周期没等够则是另一个典型场景:写完一条命令后立刻读,EEPROM内部还在固化,此时读操作请求被忽略,读取的地址总线浮空,返回0xFF。
解决跨页方案就是前面说的分页拆分,不再重复。解决写周期问题最简单的方式是每次写命令结束之后,插入一个计数器延时,延时值至少大于数据手册里的tWR。注意这个延时必须在写命令的停止条件之后开始计时,而且要独立于I2C时钟,因为SCL可能已经停了。
5.3 现象:仿真正常,上板随机失败 —— 约束、上拉和时钟边沿
仿真环境里所有信号都是理想的,上电瞬间就是合法电平,但真实板卡上,上拉电阻的阻值、引脚寄生电容、PCB走线长度都会改变信号上升沿。当你发现读写偶尔成功偶尔失败,重点检查三件事:第一,SDA和SCL是否都挂了外部上拉电阻?如果没有,只靠FPGA内部弱上拉,很多时候上拉强度不够,尤其线长的时候。第二,约束文件里SDA是否配置成了正确的I/O标准?如果FPGA不支持OD模式,代码里的三态控制是否真的把引脚置成了高阻?第三,时钟分频是否导致SCL实际频率超标?如果FPGA输入时钟是100MHz,但分频参数按50MHz算,SCL就会比预期快一倍,器件时序裕量就不够了。
这里特别提一个“玄学”问题:仿真波形里看到的数据移位边界和时序图上应该的采样点之间有偏差,而实际芯片的采样点受工艺影响更大。所以我会在SHIFT状态里把数据在SCL低电平中段就设置好,给高电平采样留足裕量,而不是等到SCL上升沿才改SDA。
5.4 现象:读时序多了一个时钟 —— 状态机边界与SCL高电平期间SDA变化
用逻辑分析仪抓取读数据,发现读出来的8位数据右移了一位,总感觉多了一个时钟。这往往是在接收数据的最后一个字节里,从机释放SDA、主机准备回NACK的过程中,SDA状态发生了变化,而恰好SCL还处于高电平。按照协议,SCL高电平期间SDA的任何变化都会被从机解释为起始或停止条件。如果你在SCL高电平附近改动了SDA的oe或out,波形上就会出现一个不该有的停止条件,或者数据被提前采样。
解决做法是在读状态机的最后,明确一个“保持窗口”:在SCL低电平时完成所有SDA设置,包括从接收模式切换到发送模式(回ACK/NACK),在SCL上升到高之后再保持至少半个时钟周期不改变SDA。我的代码里通常会把shift_reg的更新和oe的切换放到SCL下降沿触发,这样天然保证了SDA只在低电平时变化。
5.5 现象:SCL频率超过规格导致通信不稳定 —— 分频参数怎么算
如果某个项目里换了主时钟,比如从50MHz换成100MHz,但分频参数忘记改,SCL就会从100kHz变成200kHz。对标准模式(100kHz)的EEPROM来说,200kHz已经不是合规频率了,虽然很多时候还能工作,但高温、长线、干扰下就会随机出错。正确的分频计算方法是:先查EEPROM手册支持的最高SCL频率,注意标准、快速、快速+三种模式分别对应100kHz/400kHz/1MHz,然后选择留有20%裕量的值,比如设计目标定在80kHz或320kHz。
分频参数的计算公式是:SCL_HALF = CLK_FREQ / I2C_FREQ / 2,但要注意这里的I2C_FREQ是理想时钟频率,实际SCL因为上升沿补偿会有偏差。如果想精确,可以在SCL低电平插入额外等待周期,把低电平时间延长到高电平时间的1.5倍。在代码里我会把低电平的计数阈值设成SCL_HALF + EXTRA_LOW,EXTRA_LOW一般取SCL_HALF/4,这样SCL波形占空比约为40%高、60%低,更加稳妥。
6. 进阶:让读写更通用的三个技巧 —— 参数化设计、总线仲裁与回读校验
6.1 参数化I2C主机:把时钟和地址位宽变成可配置参数
我的I2C主机一直是用Verilog参数写的,CLK_FREQ、I2C_FREQ、SLAVE_ADDR_W、REG_ADDR_W全部做成parameter。这样同一个控制器既能挂AT24C02,也能挂AT24C64(16位内部地址),只需要重新例化时传不同参数,不需要改状态机。在顶层写一个小脚本批量生成不同参数组合的例化,可以大大减少重复劳动。
6.2 多主机场景下的总线冲突检测与退避
如果板上有两个主机都访问同一根I2C总线,就要做仲裁检测。I2C协议的仲裁是逐位的:当SCL为高时,如果多个主机同时拉低SDA,先拉低的获胜。FPGA实现时,我在发送数据位的同时回读SDA,如果发现回读值和要发送的值不一致,就判定总线冲突,立即释放SDA并进入退避状态,等待一段随机时间后重试。这个功能对自测和稳健性很有价值,值得留出接口。
6.3 回读校验与错误重试:修复“假成功”的最后一道防线
经验之谈:所有EEPROM读写代码都应该留一个回读校验模式。写完数据后立刻读回来比对,如果不一致就重写一次,连续失败就报错。我在实际项目中就踩过“写入成功但上电丢参数”的坑,最后发现是写周期延时不够,回读在写命令发出的瞬间立刻发起,读到的还是旧数据。因此我把回读校验设计成:写完先等tWR,再发起随机读,比较数据;不等tWR的回读是没有意义的。这套回读重试逻辑放在上层状态机里,每次写操作的完成标志必须等回读通过才置位,这样下游模块永远不会读到脏数据。
FPGA读写EEPROM这件事,说难不难,说简单也远不是“抄一段代码”那么简单。我每次换板子,都会把上拉电阻、引脚约束、SCL频率这三个点重新过一遍,再跑一遍全地址回读测试。这个习惯帮我省了很多现场调试的时间。希望帮到你,愿你的I2C波形一次通过。
本文还有配套的精品资源,点击获取