☰
FPGA实战:基于I2C接口的EEPROM读写控制器设计
2026/10/27 12:06:03 网站建设 项目流程

简介:本资源为基于FPGA实现EEPROM(AT24C64)读写功能的完整工程实践方案,面向数字电路与嵌入式系统初学者、FPGA开发入门者及课程设计/毕业设计学生,解决非易失性存储器在可编程逻辑平台上的协议解析、时序控制与数据交互等核心问题。压缩包共234个文件,总计6.23MB,涵盖Verilog源码(.v)、Quartus综合/布局布线数据库(.cdb/.hdb/.rpt)、约束与配置文件(.qsf/.sdc)、仿真输出(.vo/.vcd)、IP信息与HTML报告等,体现从RTL设计到硬件下载的全流程工程结构。已有104人学习下载,资源包含可直接编译下载的sof文件、顶层模块e2prom_top的完整工程目录及关键时序调试日志,便于读者理解I²C协议软实现细节、掌握EEPROM地址映射与页写操作机制,并快速复现读写验证流程。 在FPGA的日常开发里,和外部芯片打交道是躲不掉的事,而EEPROM读写又是其中最基础、最常用的一类。不管你是做板卡配置信息存储、校准参数保存,还是搞系统上电自检,总会遇到“得存点东西,断电还不能丢”的需求。我最近刚把一个基于I2C接口的EEPROM读写控制器在Vivado里完整跑通,从时序设计到Verilog编码,再到仿真和上板调试验证,整个过程踩了一些坑,也沉淀了不少经验。这篇文章就把这个项目的完整思路和实现细节展开聊聊,给正在搞FPGA、尤其是刚接触I2C通信的朋友一个能直接拿来用的参考。

这个项目本质上要做的事情并不复杂:用FPGA做主控,通过I2C总线对EEPROM芯片进行字节读写和页读写操作。但“不复杂”不代表“没坑”,I2C协议本身看起来简单,真正落到FPGA时序实现上,时序约束、状态机设计、inout端口处理、跨时钟域问题,每一个环节都可能让你调试到怀疑人生。所以我这篇文章不会只贴一段能跑的代码就完事,而会把我为什么这么设计、当时遇到了什么问题、最后怎么解决的都讲清楚。

如果你正准备在FPGA上实现EEPROM读写,或者是刚接触I2C协议、想知道怎么用Verilog把时序变成能在板子上跑起来的东西,这篇文章可以帮你省掉不少弯路。

1. 整体设计与器件选型思路

1.1 为什么选择I2C接口的EEPROM

很多刚接触存储的人会问,FPGA要存数据,为什么不用SPI接口的Flash,或者直接用双口RAM?这里有一个很现实的考量:EEPROM的定位是“小容量、字节可擦写、断电保持”,它和Flash最大的区别在于按字节读写,而且写寿命长得多。AT24C系列典型擦写寿命在100万次左右,而Flash通常只有几万次到十几万次。所以我做参数存储、配置字保存这类场景,第一反应就是用I2C接口的EEPROM,而不是SPI Flash。

I2C总线还有个好处是节省IO资源,整个通信只需要两根线:SCL(时钟)和SDA(数据)。对于FPGA这种引脚资源很宝贵的芯片来说,两根线搞定一个存储器件是很划算的。而且I2C是多主总线协议,总线上可以挂多个设备,靠7位地址区分,这意味着后续如果你还想挂温度传感器、RTC时钟芯片,直接并到同一组I2C总线上就行,不需要额外占用FPGA引脚。

当然I2C也有它的短板,最典型的就是速度。标准模式100kbit/s,快速模式400kbit/s,高速模式3.4Mbit/s。对于初始化配置这种低频操作完全够用,但你要是想用EEPROM当大容量数据缓冲,那趁早换方案。这个项目里我用的AT24C64,容量64Kbit也就是8K字节,非常适合存配置参数、校准系数这类小数据块。

1.2 器件选型与硬件连接要点

我这次用的是AT24C64,这个芯片在市面上非常常见,Atmel(现在Microchip)的经典型号,国产替代也很成熟。工作电压1.8V到5.5V都能跑,和FPGA的IO电平匹配起来很灵活。AT24C64内部组织结构是8192个字节,每32个字节为一页,总共256页。这里要重点记住“32字节一页”这个数字,后面实现页写操作的时候,页边界处理全靠它。

硬件连接上有几个地方需要注意。首先是上拉电阻,I2C的SCL和SDA都是开漏输出,必须外部接上拉电阻到VCC,阻值一般在2.2kΩ到10kΩ之间。阻值选多大取决于总线速率和总线电容,400kHz快速模式我一般用4.7kΩ,100kHz标准模式用10kΩ也行。很多新手容易忽略这个,结果上板后通信不稳定,时好时坏。

AT24C64的地址引脚A0、A1、A2可以硬件配置器件地址。如果总线上只挂一片,三个引脚直接接地就行,对应器件地址0xA0(写)和0xA1(读)。如果你的板卡上有多片EEPROM,就得通过这三个引脚区分地址,FPGA这边就要在控制逻辑里把对应的地址字节拼出来。我一般习惯在顶层模块里把器件地址做成参数,这样换板卡或者挂多片的时候,改参数就行,不用动核心代码。

还有个细节是WP写保护引脚,AT24C64的WP引脚拉高后会禁止写操作。一开始我没注意,结果调试的时候发现写操作怎么都没反应,后来万用表一量是WP被外部拉高了。所以如果你的板子上有WP引脚,而且你是手工飞线搭的测试环境,一定记得把它接到地。

2. I2C时序拆解:理解时序才能写好代码

2.1 I2C基本通信机制

I2C协议本身不复杂,但它的时序约束非常明确,理解不透就容易写出一堆时序违例的代码。整个通信过程建立在起始条件、停止条件、数据传输、应答机制这四个基本要素上。

起始条件(START)的定义是:SCL为高电平时,SDA产生一个高到低的跳变。停止条件(STOP)的定义是:SCL为高电平时,SDA产生一个低到高的跳变。这两个条件是主设备主动产生的,注意它的前提都是“SCL为高”,这个细节在写FPGA状态机的时候需要特别小心。

数据位传输则是“SCL高电平期间,SDA数据必须保持稳定,SCL低电平期间SDA才允许变化”。I2C的数据传输遵循MSB-first原则,每个字节8位,传输完8个数据位后,第9个时钟周期是应答位。应答位是接收方拉低SDA表示ACK,主机发送完一个字节后释放SDA总线,从机如果正常接收到数据就会拉低SDA;如果从机没有应答,SDA保持高电平,就是NACK。

每个字节传输完后,主设备如果需要继续通信,就继续产生时钟;如果结束通信,就产生停止条件。另外还有一个比较隐蔽的知识点:起始信号和停止信号之间,SDA上的数据字节数理论上是没有限制的,只要每次字节都被正确应答就能继续传。这个特性正是页写和连续读功能的基础。

2.2 写操作时序:字节写和页写

AT24C64的写操作分为字节写和页写两种。字节写就是一次只写一个字节数据,时序是:START -> 发送器件地址(写方向)-> 等待ACK -> 发送16位字节地址(高字节在前)-> 等待ACK -> 发送8位数据 -> 等待ACK -> STOP。

这里要注意器件的地址长度是16位,AT24C64内部有8192个字节存储空间,需要13位地址线来寻址,但协议上统一用16位地址格式,高3位在芯片内部被忽略。这个和AT24C02那类8位地址的器件不一样,代码里一定要分清,别照搬C02的驱动过来直接用。

页写则是连续写多个字节。AT24C64支持一次连续写最多32字节,前提是这些字节必须落在同一页内,也就是地址的高11位相同。如果写入的字节数超过了当前页剩余空间,地址会回卷到该页的开头,导致你本来想写下一页的数据被覆盖到了当前页开头。这就产生了一个经典错误:跨页写数据错乱。

所以实现页写功能的时候,要么限制单次写入长度不超过32字节,要么在驱动代码里做跨页判断,把超过页边界的数据拆成两次写操作。我在驱动里加了一个自动拆页逻辑,上层只需要告诉驱动起始地址、数据指针和长度,驱动内部判断是否跨页,如果跨页就自动拆分,这样上层的使用体验很干净。

还有一个重要的时间参数:写周期(Write Cycle Time)。EEPROM在收到STOP信号后,内部开始进行擦写操作,这个过程需要一定时间,典型值5ms(tWR)。在这段时间内芯片不响应任何外部指令,你发任何东西它都不会ACK。所以驱动里在每次写操作结束后,必须要等待tWR时间才能进行下一次通信。我是在状态机里加了一个延时计数,每次写完成之后先等5ms再回空闲状态。如果不等这个时间,你会看到莫名其妙的“第二次写不进去”的bug。

2.3 读操作时序:当前地址读、随机读和连续读

读操作比写操作稍微绕一点。AT24C64支持三种读方式:当前地址读、随机读、连续读。

当前地址读是最简单的,直接发送器件地址(读方向),然后读一个字节就停止。这里要注意,当前地址是芯片内部维护的地址寄存器,上次操作结束时的地址加1,并不是你心里想的那个地址。所以这个功能一般用得少,适合快速检查最近操作位置。

随机读就稍微绕一些,它实际上是“伪写操作+读操作”的组合:先发送器件地址(写方向),再发送16位目标地址,发送完毕后,重新发送START信号,再发送器件地址(读方向),然后读取数据。这个中间的重复起始条件(Repeated START)是I2C协议里一个很重要的机制,它允许主设备在不释放总线的情况下改变数据传输方向。很多第一次写I2C从机的码农会把这里错误地实现成发送STOP再重新START,虽然也能通信,但总线上就会多一个停止条件,有些严格要求从机可能就不买账。

连续读是在随机读的基础上,接收到第一个数据字节后继续产生时钟,从机就会持续输出后续地址的数据。每次读完一个字节,主设备回ACK,从机就继续输出下一个字节;直到主设备回NACK并发送STOP,从机才停下来。这里有一个很容易出错的地方:读最后一个字节之前必须回NACK,否则从机不知道你要结束,会一直输出数据。很多人在连续读多个字节的代码里,最后一个字节也回了ACK,结果总线就卡死了。

3. FPGA实现流程:从模块划分到Verilog落地

3.1 系统模块架构

整个设计我按照FPGA开发的标准思路,把系统拆成了三个层次。最顶层是eeprom_top模块,负责实例化控制器和用户逻辑、分配引脚;中间层是核心的i2c_eeprom_ctrl控制器,所有I2C时序都在这一层实现;最上层是用户接口,也就是与FPGA内部其他逻辑交互的读写请求接口。

用大白话解释一下这个分层思路:你如果把所有代码全写在一个模块里,最后就是一团乱麻,改一个参数都可能引发连锁问题。分层的核心好处是让每个模块只干一件事。控制器只负责“把数据通过I2C写到指定地址”或者“从指定地址读数据”,它不关心数据是什么含义;用户逻辑只需要发请求、给数据、收数据,不关心I2C时序细节。这样一旦出问题,你能很快定位是哪一层出了bug。

控制器模块对外接口我设计成这种风格:一个写请求信号、一个读请求信号、一个地址总线、一个写数据总线、一个读数据总线,加上一个忙信号标志。忙信号非常重要,上层逻辑必须在wren或rden拉高前检查busy信号是不是低电平,否则就是往一个没准备好的模块里乱塞数据,时序上必然出问题。还有一个rdy信号用来表示一次读操作的数据有效,这也是典型的“请求-应答”握手模式。

3.2 I2C控制器的核心架构

I2C控制器内部,我设计了一个主状态机加一个字节位计数器的结构。主状态机负责调度整个通信流程,位计数器负责控制8个数据位和一个应答位的切换。这两个东西配合起来,就能精确产生I2C协议要求的各种时序。

SCL时钟的产生有两种常见方案:一种是直接用FPGA系统时钟分频得到SCL,在SCL的高电平阶段和低电平阶段分别处理SDA数据;另一种是更精细的“每个SCL周期分成四个相位”的方案,分别在四个阶段处理数据。我采用的是第二种方案,把每个SCL周期分成4个状态:IDLE(SCL低)、SETUP(SCL低到高)、HIGH(SCL高)、HOLD(SCL高到低)。这样做的好处是SDA数据变化和SCL边沿之间的建立时间、保持时间能非常精确地控制,不容易出现毛刺。

具体来说,假设系统时钟50MHz,目标SCL频率100kHz,那么一个SCL周期是500个系统时钟周期,分成四个阶段每个阶段125个时钟周期。通过一个计数器在这四个阶段之间循环,SCL信号在SETUP和HIGH阶段输出高电平,IDLE和HOLD阶段输出低电平。SDA信号的改变只在IDLE阶段进行,SCL为高时SDA保持不变,这就满足了I2C协议的时序要求。

下面给出控制器核心参数定义和分频逻辑的代码示例,这个可以直接参考:

// I2C时序参数定义 parameter CLK_FREQ = 50_000_000; // 系统时钟50MHz parameter SCL_FREQ = 100_000; // I2C时钟100kHz localparam HALF_CLK_CNT = CLK_FREQ / (SCL_FREQ * 4); // 每个相位周期数 // 相位状态机 localparam S_IDLE = 2'd0; localparam S_SETUP = 2'd1; localparam S_HIGH = 2'd2; localparam S_HOLD = 2'd3; reg [7:0] phase_cnt; reg [1:0] phase_state; // SCL生成逻辑 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin phase_cnt <= 8'd0; phase_state <= S_IDLE; scl_out <= 1'b1; end else begin if (phase_cnt == HALF_CLK_CNT - 1) begin phase_cnt <= 8'd0; case (phase_state) S_IDLE: phase_state <= S_SETUP; S_SETUP: phase_state <= S_HIGH; S_HIGH: phase_state <= S_HOLD; S_HOLD: phase_state <= S_IDLE; endcase end else begin phase_cnt <= phase_cnt + 8'd1; end // SCL输出 case (phase_state) S_HIGH, S_SETUP: scl_out <= 1'b1; default: scl_out <= 1'b0; endcase end end

在这个设计里我还加了一个关键机制:当主状态机处于IDLE(总线空闲)状态时,相位状态机也自动停在IDLE阶段,并且SCL保持高电平、SDA释放为高阻态。这样总线就处于一个“没人控制”的状态,符合I2C总线空闲时序。只有当主状态机发出开始条件的时候,相位状态机才开始转动。

3.3 主状态机与读写的完整状态流

主状态机的设计是整个控制器的灵魂。我画状态机的时候把操作分成了几大类分支:写操作、读操作、等待写周期这三个主要分支。每个分支内部的具体状态会有些差异,但整体思路是一致的。

写操作的状态流是这样的:IDLE(空闲)-> START(发送起始条件)-> DEV_ADDR_W(发送器件地址写方向)-> ACK1(等待应答)-> ADDR_H(发送地址高字节)-> ACK2 -> ADDR_L(发送地址低字节)-> ACK3 -> DATA(发送数据字节)或者PAGE_DATA(连续发送多字节数据)-> ACK4 -> STOP(发送停止条件)-> WRITE_WAIT(等待5ms写周期)-> IDLE。

读操作的状态流要复杂一些,因为随机读需要先“假写”再“真读”:IDLE -> START1 -> DEV_ADDR_W -> ACK1 -> ADDR_H -> ACK2 -> ADDR_L -> ACK3 -> START2(重起始条件)-> DEV_ADDR_R -> ACK4 -> READ_DATA(读取数据)-> NACK(读最后一字节前回非应答)-> STOP -> IDLE。如果是连续读,READ_DATA状态会循环,直到外部逻辑给出“读完最后一个字节”的信号,这时才回NACK并发送STOP。

看着状态多,其实核心就是一组“发送字节-等待应答”的循环。我用位计数器在发送数据状态内部数8个bit,数完之后自动切到等待应答状态,这样就不用为每一个字节单独设计状态了。代码结构上用一个op_type寄存器和几个辅助状态变量来记录当前操作类型和步骤,代码量就精简不少。

贴一段核心状态机片段,展示发送字节和等待应答的处理方式:

// 主状态机部分代码 localparam IDLE = 8'd0; localparam START1 = 8'd1; localparam SEND_ADDR = 8'd2; // 发送器件地址或字节地址 localparam ACK_WAIT = 8'd3; localparam SEND_DATA = 8'd4; // 发送数据 localparam REPEAT_START = 8'd5; localparam READ_DATA = 8'd6; localparam SEND_NACK = 8'd7; localparam SEND_STOP = 8'd8; localparam WRITE_WAIT = 8'd9; reg [7:0] main_state; reg [3:0] bit_cnt; reg [3:0] wr_bytes_left; // 剩余需要写入的字节数 reg [3:0] rd_bytes_left; // 剩余需要读取的字节数 reg [7:0] shift_reg; // 数据移位寄存器 reg sda_out_en; // SDA输出使能 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin main_state <= IDLE; bit_cnt <= 4'd0; sda_out_en <= 1'b0; end else begin case (main_state) IDLE: begin // 检测到外部请求则启动 if (write_req) begin main_state <= START1; wr_bytes_left <= wr_length; end else if (read_req) begin main_state <= START1; rd_bytes_left <= rd_length; end end START1: begin // 产生起始条件:SCL高时SDA拉低 main_state <= SEND_ADDR; end SEND_ADDR: begin if (bit_cnt == 4'd7) begin bit_cnt <= 4'd0; main_state <= ACK_WAIT; end else begin bit_cnt <= bit_cnt + 4'd1; end end ACK_WAIT: begin // 在这里释放SDA总线,检测从机应答 main_state <= NEXT_STEP; // 根据操作状态机跳转 end // ... 后面状态类似 endcase end end

读操作的数据接收和写操作的数据发送在代码上是镜像对称的:发送数据时,移位寄存器在每个SCL低电平期装载下一比特,SCL高电平期保持不变,同时sda_out_en保持有效;接收数据时,sda_out_en无效(SDA为高阻输入),在每个SCL高电平期采样SDA电平并移位进寄存器。这里还有一个容易踩的坑:数据移位寄存器在接收模式下,移位时钟要选SCL的高电平阶段采样值,不能在SCL上升沿就立即采样,因为I2C从机输出数据延迟时间(tAA)的存在,上升沿之后数据才稳定,要等到SCL稳定为高一段时间后再采样才可靠。我实际实现时是在SCL高电平阶段的中间点采样,用相位状态机的HIGH状态作为采样窗口,这样从机数据早就稳定了,不会有亚稳态问题。

3.4 inout端口SDA的三态处理

在FPGA里实现inout双向端口是个经典难点。SDA引脚的电气特性决定了它的控制方式:数据输出时用开漏输出,数据输入时释放总线为高阻态。很多新手直接写assign sda = data_out; 这种代码,仿真没问题,上板必挂,因为你把输入模式的数据也强制驱动了。

正确的做法是用一个输出使能信号来控制:

// SDA三态控制 assign i2c_sda = sda_out_en ? sda_out_reg : 1'bz; assign sda_in = i2c_sda;

这里sda_out_en是输出使能信号,当我们需要输出数据或ACK时,把它拉高;当我们需要读取从机的响应或数据时,把它拉低,让SDA引脚变成高阻态输入。要特别注意的是,当sda_out_en为低时,sda_out_reg的值是什么其实无所谓,因为外层三态门把输出断开了。

还有一个细节:sda_out_reg在“释放总线”时要输出高电平。原因是I2C是线与逻辑,从机要发送ACK或数据时会把SDA拉低,如果此时主机还在驱动低电平,两个驱动源就会打架,轻则通信失败,重则烧引脚。所以主机在释放总线前,一定要先把sda_out_reg置1,再拉低sda_out_en,这样输出端是高阻态加上内部上拉(或者外部上拉)的作用,总线自然回到高电平。

4. 仿真验证与上板调试经验

4.1 仿真激励的搭建方法

在FPGA开发里,仿真验证的价值怎么强调都不过分。I2C时序调试,如果你直接上板用逻辑分析仪抓波形,效率会很低,因为问题定位很慢。好的做法是先在PC上仿真验证控制器逻辑的正确性,然后再上板做实测。

仿真环境的搭建包括两部分:一个测试顶层(testbench)和一个I2C从机模型(I2C EEPROM模型)。EEPROM模型是仿真中关键的虚拟设备,它模拟了AT24C64的时序行为:响应ACK、存储数据到内部RAM数组、根据主机命令返回数据。我一般直接写一个behavioral model,用一个reg数组模拟EEPROM内部存储,根据I2C时序在SCL/SDA上做出响应。

测试流程我设计了这么几个阶段:

第一阶段,单字节写测试。地址0x0000写入数据0xA5,然后停止。等5ms写周期后,读取数据,比对是否一致。

第二阶段,单字节读测试。读取刚才写入的地址,检查数据是否是0xA5。

第三阶段,多字节页写测试。从地址0x0010开始连续写入20个字节,验证驱动是否能正确处理连续写操作,不会跨页错乱。

第四阶段,跨页自动拆分测试。从地址0x001F开始写4个字节,按32字节页边界来算,0x001F、0x0020、0x0021、0x0022这4个字节跨越了第0页和第1页,驱动应该自动拆成两次写操作:先写0x001F(2个字节),再写0x0020、0x0021、0x0022(3个字节)。

写完仿真激励后,在Vivado里跑行为仿真,把波形窗口拉开,对照I2C时序逐段检查。重点检查的点包括:起始条件是否有SDA下降沿发生在SCL高电平期间、地址字节的bit顺序是不是MSB-first、ACK是否在正确的时间出现、停止条件是否符合协议。

在仿真的过程里,我发现了一个比较隐蔽的问题:我的控制器的SDA在起始条件之前一直保持高电平,但起始条件的下降沿和SCL下降沿有冲突。仔细查波形发现,因为相位状态机在IDLE时停止了,SCL一直保持高,而SDA在进入START状态时才被拉低,这个拉低过程是在自己的时钟域里完成的,如果SDA拉低的边沿和SCL的某个边沿靠得太近,就可能让从机误判。解决办法是把起始条件也纳入相位状态机的控制流程,让SDA下降沿精确发生在SCL为高电平的稳定阶段,而不是在异步时钟域的某个随机时刻。

4.2 实测波形与嵌入式逻辑分析仪抓取

仿真通过后,我直接把工程下载到FPGA开发板上跑起来。手头没有逻辑分析仪的情况下,我用Vivado集成的ILA(Integrated Logic Analyzer)核来抓内部信号。ILA的用法是:在综合后的网表里插入探针,选择要观察的信号(比如main_state、bit_cnt、sda_out、sda_in等),设置触发条件,然后在板上运行。

实际调试的时候,通过ILA抓到的波形基本能够确认控制器是否正常工作。我遇到过一个很有意思的现象:看起来所有时序都对,字节读出来却是错的。反复排查发现,问题出在从FIFO缓存的数据读取上。我的控制器在做连续读操作时,从机连续输出的数据流经8位移位寄存器后,每个字节的接收完成标志是通过位计数器产生的,但这个标志持续了一个SCL周期,如果用户逻辑在这个周期内又触发了读取逻辑,就会把同一个字节读两次,导致数据错位。

解决办法很简单:加入一个读数据有效信号,它只在数据稳定后的下一个时钟周期拉高一次,用户逻辑看到这个脉冲才去读数据总线。这个“单脉冲有效”的设计思路在跨模块数据交换时非常常用,可以有效避免数据被重复读取或者漏读取。

4.3 上电初始化与写保护的处理

实际工作中还有一类问题不是时序问题,而是系统层面的问题。上电初始化就是一个典型的例子:FPGA配置完成后,IO引脚在配置前可能处于不确定状态,如果这些引脚直接连到了EEPROM的SCL和SDA上,那么在配置完成前,可能会产生一些无效的I2C信号,导致EEPROM进入错误状态。

解决这个问题有两个思路。一个是在FPGA引脚上设置内部上拉或者下拉,确保配置期间引脚处于已知状态。另一个是在控制器里加上“上电等待”逻辑,在复位释放后,让控制器等待一段时间(我一般是100ms)再开始第一次通信,这样即使EEPROM配置期间被干扰了,它在这段时间内也能自行恢复。

还有前面提到的WP写保护,我在调试时曾因为WP引脚没固定而出现写失败。如果你用的是开发板,注意看原理图,很多开发板的WP引脚是通过跳线帽控制的,默认可能拉到高电平。如果你用的EEPROM是SOP-8封装自己飞线焊的,记得WP要直接接地。

4.4 一次完整的项目调试复盘

在实际项目中,我遇到过最棘手的问题是在一次批量写操作后,连续读出的数据出现了间歇性错误。整个排查过程让我意识到,I2C调试不能只看信号,还要综合考量整个系统的时序占用和代码逻辑。

当时的表现是:单字节写读完全正常,页写也没问题,但在写了几十个字节后,随机读回来的数据偶尔会错一个bit。先用ILA抓波形,看起来所有信号都是正常的,ACK正常,数据位电平也和我预期一致。后来我注意到在读取操作还没结束的时候,我的用户逻辑就已经发起了下一次写请求,控制器的busy信号起到了保护作用,但读操作内部的字节缓冲区被下一次写请求提前刷新了,导致数据错乱。

修复方案是在控制器内部增加一个FIFO缓冲区,读操作读取回来的数据先写入FIFO,等用户逻辑准备好后再从FIFO中读取。这样读写请求之间的时序耦合就彻底解开了,问题随之消失。这个经验后来被我应用到了很多外设控制器的设计中:凡是跨时钟域、跨模块的数据交互,中间加一个FIFO或者寄存器缓存,都能显著增强系统的稳定性和可维护性。

5. 常见问题与排查技巧实录

5.1 I2C总线通信失败的排查清单

在实际调试中,I2C的问题往往具有“症状一致但病因不同”的特点。通信失败、数据错乱、ACK异常这些现象,背后可能是同一个原因,也可能是完全不同的原因。我整理了一张排查清单,按优先级排列,基本上能覆盖80%以上的常见故障。

故障现象可能原因排查方法解决方案
完全无通信,SDA始终为高缺少上拉电阻或上拉电阻阻值过大万用表量SDA/SCL对地电压,空载时应接近VCC按速率选择合适上拉电阻,通常4.7kΩ
发送地址后无ACK器件地址配置错误检查A0/A1/A2引脚电平和代码中地址参数确认实际器件地址与代码一致
写操作无响应WP引脚被拉高测WP引脚电平接地或通过代码控制
写入数据后读回全FF写周期未等待,第二次操作太早添加写周期延时每次写结束后延时5ms以上
跨页写数据错乱页写超过页边界产生地址回卷检查写入地址和长度驱动增加跨页自动拆解逻辑
数据错位或偶发错误读数据采样时机不对或数据缓冲竞争检查采样窗口和读数据FIFO调整采样点,增加缓存
总线上出现毛刺/误触发引脚状态配置不当查看原理图和引脚约束设置内部上拉,增加电源滤波电容

5.2 仿真通过但上板失败的经典原因

仿真通过并不代表硬件就能正常工作,这句话在I2C这种低速总线上也完全适用。我遇到过的几种典型情况是:

第一种是电平不匹配。仿真环境里所有电平都是理想的,但实际板子上,如果FPGA的IO电压是3.3V而EEPROM是1.8V供电,没有做电平转换,SDA拉低的电平可能达不到FPGA的低电平判定阈值,导致采样错误。我遇到过一种更隐蔽的情况:上拉电阻用的10kΩ和板上大量的总线电容导致信号上升沿非常缓慢,SCL到了100k还能凑合用,升到400k就完全不通信了。最后用示波器看了一下波形,SCL上升沿已经变成了圆弧,快到不像个方波了。解决方法是换小一点的上拉电阻(4.7kΩ或2.2kΩ),同时在FPGA引脚约束里设置slew rate为fast。

第二种是启动时的总线竞争。FPGA配置过程中引脚悬空,外部EEPROM可能检测到伪起始条件并进入错误状态。上电复位后的等待时间如果不够,第一次通信也可能失败。我一般在上电后先执行一次“总线复位”操作:在SCL上连续产生9个时钟脉冲,同时保证SDA为高,这样可以让总线上任何进入错误状态的从机复位。这个技巧对I2C总线尤其有效,虽然EEPROM本身不要求这么做,但它能提高系统的鲁棒性。

第三种是时钟频率过高。如果你把SCL频率设到400k以上,从机可能跟不上。AT24C64在快速模式下虽然支持400k,但主机在产生时序时,状态机状态切换本身要消耗几个系统时钟周期,如果这些开销没算进SCL周期里,实际SCL频率就会超过目标值。我建议在参数计算时留一些余量,比如目标400kHz,实际配置按360kHz计算,这样即使有时钟抖动也不会超限。

5.3 逻辑分析仪/LIA的调试技巧

调试I2C最直接有效的手段还是逻辑分析仪。如果你有独立的逻辑分析仪,直接抓SCL和SDA两根线的波形,用协议解析功能直接解码出I2C数据帧,效率极高。没有独立设备的话,Vivado的ILA也能做到差不多的效果,区别在于ILA只能抓到FPGA内部信号,看不到真实的引脚电平。如果怀疑引脚电气特性有问题,ILA是无能为力的,它只能看到控制器输出的逻辑值。

用ILA调试时我有几个建议。一是触发条件设置要合理。比如你想抓写操作完整波形,可以把触发条件设置为main_state == SEND_STOP,这样ILA在停止条件即将产生时开始采集,就能看到完整的写流程。二是采样的信号要包含关键的状态变量,除了数据信号之外,把主状态机的状态值、位计数器、相位状态都加进去。这样回放波形时就能把状态跳转和数据变化关联起来看,定位问题要快得多。

另外一个实用的小技巧是:如果ILA存储深度有限,你可以把采集窗口配置为“触发前采集90% + 触发后采集10%”,这样既能看清触发点之前的情况,也能看到触发点之后的状态连续变化。

5.4 与EEPROM读写相关的常见代码错误

写I2C代码容易犯的错误挺有规律性的,我总结几个高频的,希望后来者能直接绕开:

代码里最经典的错误之一就是把地址位宽写错。AT24C64是16位地址,很多从AT24C02移植代码的人只用了8位地址,导致高地址读写异常,低地址看着正常。还有一个是移位方向写反,I2C是MSB-first,但不少人习惯性地用右移,结果数据就颠倒了。我习惯在代码里把发送数据的过程写成循环移位右移,配合位计数器从7到0递减,这样直观不容易错。当然你也可以用左移配合计数器从0到7递增,效果一样,关键是前后要一致。

还有一个很容易出错的点是启动重复起始条件(Repeated START)时,对SCL和SDA状态的控制。有些代码复用同一个START状态的逻辑,但在重复起始条件时,SDA是从低变高再变低的过程,需要先释放SDA,等待SCL变为高,再拉低SDA。如果你直接复用起始条件逻辑,SDA在STOP后已经处于高电平,重复起始时执行“拉低SDA”这个动作,实际波形里缺失了“SCL高时SDA从高到低”的条件,从机可能无法识别这个重复起始条件。

6. 项目扩展与后续优化方向

这个项目做完之后,其实可以往很多方向扩展。我在这里分享几个比较有价值的思路,大家可以根据自己的实际需求来选。

6.1 支持多片EEPROM与多设备总线管理

当前实现里,器件地址是写死的参数。要做成多片,只需要在顶层把器件地址暴露出来,用寄存器控制A0、A1、A2引脚和对应地址字节。更进一步,如果总线上既有EEPROM又有其他I2C设备(比如温度传感器、RTC),那就要引入总线仲裁和设备的使能管理。最简单的做法是做一个简单的I2C总线管理器,对每个设备分配一个句柄,上层用统一的读写接口,根据设备ID自动选择器件地址和寄存器协议。

这样做的好处是你的代码复用性会大幅提高,从“控制单个EEPROM”升级为“通用的I2C外设管理框架”。代价是代码复杂度上升,状态机需要考虑多设备的地址切换和协议差异。我之前在一个项目里用过类似的框架,最终状态机几乎没动,只是在设备层做了适配。

6.2 加入CRC校验与磨损均衡

EEPROM本身不带CRC校验,如果你的数据很关键(比如校准参数、设备唯一标识),建议在写入的时候给每个数据块附加CRC校验码。读出时先校验CRC,如果出错就丢弃或者重新读取。这个功能在FPGA里做并不复杂,写一个CRC-8模块,在写入时计算校验值放在数据末尾,读取时同样计算并比对。

磨损均衡的思路则是针对写寿命优化。EEPROM虽然有100万次的擦写寿命,但如果你频繁地写同一个地址(比如记录运行次数),这个地址会先坏掉。磨损均衡就是让写入操作分散到不同的物理地址上,用一个逻辑地址映射表来跟踪。这个做法在EEPROM场景下稍微有点“杀鸡用牛刀”,但如果你的产品需要长时间稳定运行,还是值得考虑的。

6.3 与FPGA内部FIFO结合实现大数据量存储

EEPROM的容量很小,但如果你用DMA的方式,把FPGA内部FIFO的数据自动搬运到EEPROM,还是能实现一定规模的数据存储的。这个思路特别适合数据采集卡、环境监测设备这类应用:平时把数据缓存在FPGA内部RAM里,等到缓冲区满了,自动通过控制器写入EEPROM,掉电后数据不丢。

我做过一个版本的实现,控制器增加了FIFO接口,写操作时数据从FIFO读出来,不再需要外部逻辑每个字节都发起一次写请求。这样可以用很小的CPU占用(FPGA里甚至可以用硬核处理器)连续写入大量字节,效率提升非常明显。

7. 实操总结与个人心得

这个FPGA实现EEPROM读写的项目,技术上不算复杂,但它麻雀虽小五脏俱全。通过这个项目,我把I2C协议从“看懂时序图”变成了“能写代码并让它稳定工作”,这个过程中收获最大的不是代码本身,而是对时序细节和状态机设计的理解。

有一点我特别想强调:代码结构很重要。一开始我觉得这功能小,随手写在一个模块里就行了。但随着调试的深入,发现自己反复在同一个代码文件里来回翻找,状态机变量和信号的作用域也变得不清晰。后来还是老老实实拆成了三段式状态机,把组合逻辑和时序逻辑分开,把控制信号和数据信号分开。虽然代码量多了一倍,但可读性和可维护性有了质的提升。状态机的三段式写法在FPGA开发里被反复推荐,真的不是没有道理的。

另外一点是仿真和实测的互补关系。仿真能帮你验证逻辑正确性,但不可能覆盖所有硬件特性。反过来,实测中暴露的问题往往能帮你发现仿真模型的不足。我发现很多初学FPGA的人容易走两个极端:要么觉得仿真浪费时间直接上板调,要么过度依赖仿真忽略硬件特性。这两种都不可取。正确的节奏应该是:先用仿真把逻辑问题消灭掉,再上板验证,遇到问题回头补仿真的场景,形成“仿真-实测-补仿真”的闭环。

最后再说一个小技巧:调试I2C的时候,如果没有逻辑分析仪,你可以用FPGA内部ROM做个简易的“数据记录仪”,在关键信号变化时打上时间戳数据。我就是用这个方法排查到了一个偶发的读数据错位问题。原理很简单,就是把关键的时序信号和状态值存储到内部RAM里,然后通过串口打印出来。虽然不如逻辑分析仪方便,但在手头设备不足的情况下是个不错的备选方案。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询