简介:面向FPGA入门与嵌入式存储应用开发者,这份工程以开拓者开发板上的AT24C64为例,完整演示了EEPROM的读写时序与FPGA控制逻辑实现。EEPROM作为电可擦除可编程只读存储器,掉电不丢失,广泛用于物联网与可穿戴设备的小量数据存储;AT24C64支持百万次擦写,数据可保存百年,工程从协议层到寄存器级展示了如何通过状态机驱动I2C接口完成字节读写与页操作。文件包共234个文件,大小6.23MB,内容以tdf硬件描述语言源码、Verilog模块、qpf/qsf工程配置、sof下载文件为主,辅以编译生成的cdb/hdb数据库与rpt/qmsg报告文件,便于直接查看综合布局结果和时序约束。已有107人学习下载。结合完整工程源码与目录结构,读者可掌握地址寻址、读写流程、应答检测及多字节传输等关键点,并能快速将代码移植到其他AT24C系列或自定义存储项目中。
1. FPGA读写EEPROM:为什么这是绕不开的时序基本功
新手做FPGA项目,十个有九个把第一块硬骨头啃在EEPROM读写上。仿真波形明明全绿,一上板子读回全0xFF;波形抓出来起始条件都对了,就是ACK不对;好不容易写进去,断电再来数据没了。这些都是FPGA读写EEPROM最常见也最打击人的场景。这个标题背后其实就两件事:把I2C总线协议吃透,再把状态机写成符合时序的电路。它不需要DDR、PCIe那种高速难题,但会把跨时钟域、三态门、时序约束这些基本功一次性串起来。做完这个模块,你以后接传感器、接RTC、接各类I2C从设备,全部是同一套思路。适合刚入门FPGA、做过LED流水灯但又想真正碰一下总线协议的开发者。
2. I2C链路怎么搭:从AT24C系列芯片到FPGA引脚规划
2.1 AT24C02到AT24C128:容量、地址引脚和选型差异
市面上FPGA开发板最常用的EEPROM是AT24C02和AT24C64,一个2Kbit,一个64Kbit,都是I2C接口。选型时最该关心的是两个参数:容量和页写大小。
| 型号 | 容量 | 字节数 | 页写大小 | 器件地址(7位) |
|---|---|---|---|---|
| AT24C02 | 2Kbit | 256字节 | 8字节 | 0x50 |
| AT24C04 | 4Kbit | 512字节 | 16字节 | 0x50/0x51 |
| AT24C16 | 16Kbit | 2KB | 16字节 | 0x50~0x57 |
| AT24C64 | 64Kbit | 8KB | 32字节 | 0x50 |
AT24C02在开发板上最常见,因为地址引脚A0/A1/A2全接低电平时,I2C从机地址固定为0x50。如果你的板子上A0/A1/A2跳线接了高电平,地址就要相应加上偏移,这是很多人忽略的第一步。引脚上除了SCL、SDA、VCC、GND之外,还有个WP写保护脚,用来禁止写操作。如果WP被拉高,写时序再标准也没用,数据进不去,这个坑后面细说。
AT24C04以上容量,页写大小是16字节起,跨页地址回卷的坑更容易踩。开发板上做实验用AT24C02就够了,项目要存较大参数时再考虑AT24C64或更大容量。有一点要注意:容量超过16Kbit的型号(如AT24C256),器件地址里会包含高位地址字节,地址是16位的,写流程多一个地址字节。
2.2 总线帧结构:起始位、停止位、ACK与读写方向的排布
I2C的精髓就一句话:SCL为高时SDA上的跳变是控制信号,SCL为高时SDA必须保持稳定才是数据。起始条件是SCL高时SDA从高拉低,停止条件是SCL高时SDA从低拉高。
一次完整写入帧排布是:起始位 → 器件地址(7位)+写方向位(0) → ACK → 字节地址 → ACK → 数据字节 → ACK → 停止位。整个过程中,每发完一个字节,主设备必须释放SDA总线,让从设备拉低回应一个ACK。九个SCL脉冲里,前八个是数据,第九个是ACK应答。
重点在方向位的排布:器件地址0x50左移一位得到0xA0是写方向地址,0xA1是读方向地址。很多人在代码里直接把0x50当成发送字节,导致地址错位一位,整个通信直接失败。读操作更特殊,它要先发一个伪写帧把寄存器指针指到目标地址,再发一个重复起始条件,然后换读方向继续读。这个流程拆开看就是两个起始条件夹一个地址字节,中间没有停止位。
2.3 FPGA侧的引脚规划:时钟分频、SCL生成与sda三态口
FPGA这边连I2C总线和单片机不同,没有开漏输出的硬件结构,必须自己用三态门模拟。sda端口要声明为inout,内部需要两个信号:sda_out_data负责输出数据,sda_en控制方向。sda_en为1时FPGA驱动总线,为0时释放总线给外部上拉。
时钟分频是另一个关键点。I2C标准速率是100kHz和400kHz,开发板主时钟常见50MHz或100MHz。做法是先用计数器做分频,生成一个SCL半周期脉冲,再用这个脉冲驱动状态机。比如50MHz要得到100kHz SCL,每个SCL周期是500个主时钟周期,半周期是250个。
localparam HALF_CLK = 250 - 1; // 50MHz -> 100kHz SCL reg [8:0] half_cnt; reg scl_r; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin half_cnt <= 9'd0; scl_r <= 1'b1; end else if (half_cnt == HALF_CLK) begin half_cnt <= 9'd0; scl_r <= ~scl_r; // 每250个主时钟翻转一次SCL end else begin half_cnt <= half_cnt + 1'b1; end end这段代码生成了低电平有效的SCL翻转脉冲,scl_r就是内部SCL寄存器,scl_r翻转一次代表一个半周期完成。这里要留意,真正的SCL输出还应该在I/O约束里设置ODT和驱动强度,但核心逻辑就是靠这个翻转信号推进状态机。不要试图用计数器直接算SCL的高电平宽度,那会让状态机变得琐碎难调。用半周期脉冲作为采样节拍,状态机在scl_r的上升沿和下降沿分别做不同动作,这是最清晰的写法。
3. 用状态机实现I2C主机:一个能跑的基础Verilog骨架
3.1 核心状态机与字节移位:SCL低电平换数据、高电平采样
写I2C主机状态机,我一般采取四个状态:IDLE、START、SHIFT、ACK。START状态负责拉出起始条件,SHIFT状态按位发送或接收数据,ACK状态判断从设备是否应答。下面这段代码是字节发送的核心骨架,只保留关键路径。
localparam IDLE = 3'd0; localparam START_S = 3'd1; localparam SHIFT_S = 3'd2; localparam ACK_S = 3'd3; reg [2:0] fsm; reg [2:0] bit_cnt; reg sda_out; reg sda_en; reg [7:0] tx_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin fsm <= IDLE; bit_cnt<= 3'd0; sda_en <= 1'b1; end else begin case (fsm) IDLE: begin sda_out <= 1'b1; sda_en <= 1'b1; if (start_req) fsm <= START_S; end START_S: begin sda_out <= 1'b0; // SCL为高时SDA拉低 if (scl_neg_edge) fsm <= SHIFT_S; end SHIFT_S: begin sda_out <= tx_data[bit_cnt]; if (scl_neg_edge) begin // SCL低电平期间改写SDA if (bit_cnt == 3'd0) begin fsm <= ACK_S; sda_en <= 1'b0; // 释放总线,等从机ACK end else begin bit_cnt <= bit_cnt - 1'b1; end end end ACK_S: begin if (scl_rise_edge) begin o_ack_err <= sda_in; fsm <= IDLE; end end endcase end end这段代码的核心观点是:SDA必须在SCL低电平时变化,在SCL高电平时被从设备采样。所以我只在scl_neg_edge时更新sda_out,在scl_rise_edge时读取从设备的ACK。bit_cnt一路递减到0,表示8个数据位全部发送完毕,然后释放总线、转入ACK状态。注意tx_data必须在进入SHIFT状态前由上层赋值,否则第一拍发送的数据是旧值。
3.2 ACK检测与停止条件:主从握手最容易忽略的一拍
ACK状态是整个I2C通信最容易翻车的位置。原因很常见:发送完8位数据后,主设备没有释放SDA总线,导致从设备想拉低ACK却拉不动,或者说主设备读到的SDA永远为高,误判为NACK。
// ACK正在采样——该剥除sda延时的对齐 reg sda_d1; always @(posedge clk) sda_d1 <= sda_in; // 在SCL高电平中点采样一个sda wire ack_sampled = (fsm == ACK_S) && (half_cnt == HALF_CLK/2); always @(posedge clk) begin if (ack_sampled) o_ack_err <= sda_d1; end这里连着三个问题要一起解决。第一,采样时机在SCL高电平的中点,不要用上升沿,上升沿太快容易采到数据翻转瞬间的毛刺。第二,sda_in经过一级寄存器打拍,消除亚稳态风险,这是跨时钟域的基本动作。第三,如果o_ack_err为1说明从设备没应答,就直接进IDLE并向上层报错,不要再继续发后续数据,否则总线状态全乱。
跳线地址不对、EEPROM芯片焊反、上电时序没满足,最终表现形式都是ACK一直为高。所以调试时先看ACK位,再谈数据内容。
3.3 模块接口设计:如何把i2c_master挂到上层
I2C主机不要直接让业务逻辑穿梭sda,建议封装成独立模块,对外暴露一个最简单的手握级接口:start_req、rw方向、要发送的字节、读回的数据、done脉冲、错误标志。上层只管发请求,状态机内部管时序。
module i2c_master #( parameter CLK_DIV = 250 )( input wire clk, input wire rst_n, inout wire sda, output wire scl, input wire req_valid, input wire req_rw, // 1:读 0:写 input wire [7:0] req_data, output reg [7:0] resp_data, output reg resp_done, output reg resp_ack_err );数据线和控制线分离是这套设计的关键。你写EEPROM驱动时,上层模块关心的只有:地址发过去没有、数据写进去没有、读回来的字节是什么。至于起始位宽多少、SCL频率多少,那是i2c_master内部的事。以后项目里要接其他I2C设备,OS操作平台相同,整个主机原封不动复用。
这种模块化设计还有个好处:仿真时可以单独给i2c_master写testbench,验证完时序再挂EEPROM逻辑。如果一锅炖在一个大模块里,出问题根本分不清是状态机错了还是地址逻辑错了。
4. EEPROM读写模块:从字节地址到页写边界的完整实现
4.1 写操作:控制字节、寄存器地址与数据字节的排布
有了i2c_master做底层,写EEPROM就成了拼帧的过程。AT24C02只有256个字节,地址一个字节就够。EEPROM写一个字节需要连续发送三个字节:0xA0(设备地址+写方向)、地址字节、数据字节。每次发送之间都要检查ACK,任何一拍的ACK不对,整个过程退回IDLE。
module eeprom_write_ctrl #( parameter CLK_DIV = 250 )( input wire clk, input wire rst_n, inout wire sda, output wire scl, input wire wr_start, input wire [7:0] wr_addr, input wire [7:0] wr_data, output reg wr_done, output reg wr_err ); localparam S_IDLE = 3'd0; localparam S_DEV = 3'd1; // 发送器件地址 localparam S_ADDR = 3'd2; // 发送EEPROM内部字节地址 localparam S_DATA = 3'd3; // 发送数据 localparam S_STOP = 3'd4; // 停止并等待写周期 reg [2:0] state; reg [1:0] send_cnt; wire m_done; wire m_ack_err; reg req_valid; reg req_rw; reg [7:0] req_data; i2c_master #(.CLK_DIV(CLK_DIV)) u_master ( .clk(clk), .rst_n(rst_n), .sda(sda), .scl(scl), .req_valid(req_valid), .req_rw(req_rw), .req_data(req_data), .resp_data(), .resp_done(m_done), .resp_ack_err(m_ack_err) ); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; req_valid <= 1'b0; end else begin case (state) S_IDLE: begin if (wr_start) begin send_cnt <= 2'd0; state <= S_DEV; req_rw <= 1'b0; req_data <= 8'hA0; // 1010_0000 req_valid <= 1'b1; end end S_DEV: begin if (m_done && !m_ack_err && send_cnt == 2'd0) begin send_cnt <= send_cnt + 1'b1; req_data <= wr_addr; state <= S_ADDR; end else if (m_done && m_ack_err) begin state <= S_IDLE; wr_err <= 1'b1; end end S_ADDR: begin if (m_done && !m_ack_err) begin send_cnt <= send_cnt + 1'b1; req_data <= wr_data; state <= S_DATA; end end S_DATA: begin if (m_done && !m_ack_err) begin req_valid <= 1'b0; state <= S_STOP; end end S_STOP: begin wr_done <= 1'b1; state <= S_IDLE; end endcase end end endmodule这个状态机的关键点是:每次发送之前都把req_valid拉高一个周期,等m_done回来再切状态。req_valid不能一直维持高电平,因为i2c_master每次只处理一个字节,如果req_valid常高,它会重复发送。wr_err标志在任意一帧ACK出错时拉高,并且整个控制状态回到IDLE,等待上层重新发起。写周期这部分我没在状态机里处理,因为EEPROM在收到停止位后需要5ms内部擦写时间,这期间不能访问。
4.2 读操作:虚写地址、重复起始与连续读的搭法
读比写绕一点。要读地址0x10的数据,必须先发0xA0、0x10这两个字节,让EEPROM内部地址指针指向0x10,再发0xA1方向位从头读。这个流程叫伪写,本质上是欺骗从设备把地址装好,再换方向读。AT24C02支持连续读,主设备持续给时钟,EEPROM会自动递增地址往外吐数据,直到主设备回一个NACK再发停止位。
伪写和真正的写有两个区别:第一,伪写发完地址后不停止,而是发重复起始条件;第二,方向位从0翻成1。重复起始位不能让状态机先发停止位再发起始位,那样就是两次独立的传输,EEPROM会当成伪写已经结束,数据读不出来。
搞定重复起始的i2c_master需要一个restart功能。实践中,我在i2c_master里增加一个restart_req信号,在ACK状态结束时检测到restart_req,就直接回START状态而不回IDLE。完整读模块的思路是:写地址帧 → 拉restart → 发0xA1 → 连续读N个字节 → 最后一个字节回NACK → 停止。
4.3 页写与tWR等待:EEPROM自己限制的两个边界
页写是I2C EEPROM最容易出现玄学错误的特性。AT24C02每页8字节,写入时如果地址从0x07继续往0x08写,实际上不会写入0x08,而是回卷写入0x00。这是芯片内部硬件行为,不是总线协议能约束的。
AT24C64每页32字节,写跨页时同样回卷。处理办法只有两个:要么一次只写单字节,要么在编程逻辑里检测当前地址是否在页边界,超出就拆成两次页写。页写确实能把效率提升好几倍,因为8字节或32字节一次写周期就完成,但对应的风险是数据错乱。
写周期tWR也一样没人情味。EEPROM接受停止位后,内部控制逻辑开始擦写,这期间最多5ms不响应任何总线操作。如果你不等它写完就去发下一个写命令,地址和数据根本进不去,而且ACK会异常。一个常规做法是在S_STOP状态里加一个延时计数器,默认等待5ms再回IDLE。也可以做ACK轮询,就是反复发一个伪写命令直到收到ACK为止,但这一般是单片机才用的省事手段,FPGA这边直接延时最稳妥。
5. 避坑记录:从仿真全绿到上板翻车的六条纪录
5.1 读回全0xFF:先查上拉和WP脚,别怀疑芯片
现象:上板测试,无论写什么地址,读回来全是0xFF。仿真里明明都正常。原因大概率不是你的状态机,而是硬件连接。EEPROM的SDA需要外部上拉到VCC,如果板上没接上拉电阻,或者FPGA引脚配置成内部下拉,SDA读进来永远是0。再加上WP写保护脚悬空或接高,写入动作被芯片拒绝,读回自然就是初始值0xFF。解决:先用万用表量SDA引脚静态电平,总线空闲时应该是高。再把WP脚确认拉低,排除写保护。
5.2 时序仿真完美,上板却卡死:sda三态门方向控制写错了
现象:ModelSim波形看着起始条件、ACK、停止一切都对,一上板I2C总线直接挂住,SCL正常跳但SDA一直低。原因:很多人在仿真里把sda_out和sda_en简单写成了赋值,没有真正模拟open-drain行为。FPGA管脚如果是推挽输出持续驱动低电平,从设备拉到低后根本释放不了。解决:检查三态门,sda_en为低时必须输出高阻z,而不是强行输出1。用IOBUF原语是最稳的写法。
IOBUF #( .DRIVE(12), .IOSTANDARD("LVCMOS33") ) iobuf_sda ( .O (sda_in), .IO(sda), .I (sda_out), .T (sda_en_n) );5.3 写第二个字节稳定,第三个字节开始丢:tWR没等够
现象:连续写三个不同地址,前两个断电后验证正常,第三个总是丢。原因:EEPROM写周期tWR是5ms,你写完第一个字节没等够,紧接着发第二个字节的命令,结果第二个写操作直接无效。解决:每次写入结束加5ms延时,或者在状态机里对写命令间隔做最小间隔约束。速度快不是目的,数据稳才是。
5.4 页写数据串位:跨页地址回卷是硬件行为,不是bug
现象:用页写模式连续写20个字节,发现数据从第8个开始错乱,但单字节写全部正常。原因:AT24C02页大小8字节,地址位最低3位归零回卷。解决:写之前做地址判断,当前地址距离页边界不足计划长度时,拆开写。
5.5 多设备总线上A设备正常B设备异常:跳线地址冲突
现象:开发板上同时挂了EEPROM和一个温度传感器,EEPROM读写正常,传感器读不到。原因:两个设备硬件地址相同,或者EEPROM的A0/A1/A2跳线被拨到了和另一个器件冲突的位置。解决:查两个芯片的地址位,把EEPROM跳线拨到不冲突的组合,总线协议层面无解。
6. 从仿真到ILA实测:验证读写的三板斧
6.1 用testbench快速逼出起始、ACK、停止
仿真时不要一上来就写完整读写流程,先给i2c_master发一个单字节写请求,抓三个节点:scl_r电平、sda_in、fsm状态。起始条件看SDA是否在SCL高时拉低;ACK看第9个时钟SDA是否被从设备拉低;停止看SDA是否在SCL高时拉高。这三个点都对了,基本协议就通了一半。模型里EEPROM用仿真模型,注意模型里通常有tWR参数,仿真时默认写周期极短,真正上板才暴露问题。
6.2 ILA实测抓I2C总线三个检查点
上板调试用ILA抓实际波形,比仿真多了两个检查点:SCL信号边沿是否干净、SDA的电平是否在SCL高时稳定。抓取时把sda设为输入引脚,时钟用100MHz,深度4096足够看一次完整读写。实测中最常见的问题,是SCL上升沿上叠加了毛刺,导致从设备误判时钟沿。这本质是驱动强度太强加上总线电容导致的过冲,在约束文件里把I/O的SLEW降下来能明显改善。
我自己的调试习惯是:把ILA触发放到ACK错误标志上,先看是不是每个字节都有ACK,再看数据内容。如果ACK全对但数据错,那问题在地址排列;如果ACK都错,那问题在硬件电平。这个思路帮我排除过无数个看似玄学的I2C故障。
读操作验证时,建议用连续读模式一次读16字节,观察地址自动递增是否正确。只要连续读的数据和写入的数据完全一致,整个读写链路就基本闭环了。你以后接任何I2C从设备,这套调试方法都能直接复用,希望帮到你。
本文还有配套的精品资源,点击获取