ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

FPGA读写EEPROM:I2C时序与状态机设计实战

FPGA读写EEPROM:I2C时序与状态机设计实战 简介面向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位AT24C022Kbit256字节8字节0x50AT24C044Kbit512字节16字节0x50/0x51AT24C1616Kbit2KB16字节0x50~0x57AT24C6464Kbit8KB32字节0x50AT24C02在开发板上最常见因为地址引脚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 9d0; scl_r 1b1; end else if (half_cnt HALF_CLK) begin half_cnt 9d0; scl_r ~scl_r; // 每250个主时钟翻转一次SCL end else begin half_cnt half_cnt 1b1; 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 3d0; localparam START_S 3d1; localparam SHIFT_S 3d2; localparam ACK_S 3d3; 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 3d0; sda_en 1b1; end else begin case (fsm) IDLE: begin sda_out 1b1; sda_en 1b1; if (start_req) fsm START_S; end START_S: begin sda_out 1b0; // 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 3d0) begin fsm ACK_S; sda_en 1b0; // 释放总线等从机ACK end else begin bit_cnt bit_cnt - 1b1; 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 3d0; localparam S_DEV 3d1; // 发送器件地址 localparam S_ADDR 3d2; // 发送EEPROM内部字节地址 localparam S_DATA 3d3; // 发送数据 localparam S_STOP 3d4; // 停止并等待写周期 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 1b0; end else begin case (state) S_IDLE: begin if (wr_start) begin send_cnt 2d0; state S_DEV; req_rw 1b0; req_data 8hA0; // 1010_0000 req_valid 1b1; end end S_DEV: begin if (m_done !m_ack_err send_cnt 2d0) begin send_cnt send_cnt 1b1; req_data wr_addr; state S_ADDR; end else if (m_done m_ack_err) begin state S_IDLE; wr_err 1b1; end end S_ADDR: begin if (m_done !m_ack_err) begin send_cnt send_cnt 1b1; req_data wr_data; state S_DATA; end end S_DATA: begin if (m_done !m_ack_err) begin req_valid 1b0; state S_STOP; end end S_STOP: begin wr_done 1b1; 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从设备这套调试方法都能直接复用希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表