FPGA驱动EEPROM的I2C状态机设计:从时序解析到Verilog三段式实现

📅 2026/7/15 18:22:04
FPGA驱动EEPROM的I2C状态机设计:从时序解析到Verilog三段式实现
1. I2C协议与EEPROM基础认知第一次用FPGA操作EEPROM时我对着示波器抓到的波形发呆了半小时——SCL和SDA线上那些高低电平的变化就像摩尔斯电码一样神秘。后来才明白理解I2C协议的关键在于抓住三个核心要素起始条件、数据有效性和停止条件。当SCL为高电平时SDA从高到低的跳变就是起始信号START这个瞬间就像敲门告诉EEPROM我要开始通信了。数据传输时有个重要规则SCL高电平期间SDA必须保持稳定只有在SCL低电平时才能改变数据线状态。这就像两个人配合跳舞时钟信号SCL是舞步节奏数据信号SDA需要在节奏间隙变换动作。以AT24C系列EEPROM为例它的7位器件地址通常是1010开头后面跟着由硬件引脚决定的3位地址。比如电路板上A2A1A0都接地时写操作地址就是1010000十六进制0xA0。这里有个容易踩的坑地址字节最后一位是读写标志0表示写1表示读。我曾经因为忽略这个细节调试了一整天都写不进数据。2. 状态机设计方法论设计I2C控制器时状态机就像交通指挥中心。我推荐使用Moore型三段式状态机它的特点是输出只与当前状态有关避免了组合逻辑带来的毛刺风险。具体实现时可以分为三个always块第一个块用同步时序实现状态寄存器第二个块用组合逻辑实现状态转移条件第三个块用时序逻辑实现状态输出// 状态定义 localparam IDLE 4d0; localparam START 4d1; localparam SEND_ADDR 4d2; localparam ACK1 4d3; ... // 其他状态省略 // 状态寄存器 always (posedge clk or negedge rst_n) begin if(!rst_n) curr_state IDLE; else curr_state next_state; end // 状态转移逻辑 always (*) begin case(curr_state) IDLE: next_state i2c_start ? START : IDLE; START: next_state (cnt 3) ? SEND_ADDR : START; ... // 其他状态转移条件 endcase end实测发现状态划分粒度直接影响代码可靠性。比如发送字节数据时如果简单用一个SEND_DATA状态容易因应答检测不完整导致时序违规。我的经验是把每个字节传输拆分为四个阶段发送bit7-bit17个时钟周期发送bit01个时钟周期释放SDA等待应答1个时钟周期检测应答信号1个时钟周期3. 时钟域处理技巧I2C标准模式时钟频率是100kHz而FPGA系统时钟通常是50MHz以上这就涉及跨时钟域处理。我的做法是生成一个中间时钟比如1MHz它是SCL的4倍频这样可以在SCL的每个电平阶段进行精确控制。// 时钟分频示例50MHz-1MHz reg [4:0] clk_cnt; always (posedge clk or negedge rst_n) begin if(!rst_n) begin clk_cnt 0; dri_clk 0; end else if(clk_cnt 24) begin clk_cnt 0; dri_clk ~dri_clk; // 翻转生成1MHz end else begin clk_cnt clk_cnt 1; end end在波形生成时我习惯用计数器来划分SCL周期。比如对于250kHz的SCLcnt0-3SCL低电平期cnt4-7SCL高电平期cnt1和5时更新SDA数据这样设计的好处是时序可调当需要支持不同速率的I2C设备时只需修改分频系数即可。4. 典型操作时序实现4.1 单字节写操作单字节写需要5个状态发送起始条件发送器件地址写标志发送字地址发送数据字节发送停止条件关键点在于字地址处理。AT24C16需要2字节地址而AT24C01只需要1字节。我的解决方案是添加bit_ctrl信号if(bit_ctrl) begin // 16位地址 next_state ADDR_HIGH; addr_temp i2c_addr[15:8]; end else begin // 8位地址 next_state ADDR_LOW; addr_temp i2c_addr[7:0]; end4.2 随机读操作随机读操作更复杂需要虚写过程发送起始条件发送器件地址写标志发送要读取的地址发送重复起始条件发送器件地址读标志读取数据字节发送非应答停止条件这里有个性能优化技巧页读取。EEPROM支持连续读取读完一个字节后发送应答可以继续读下一个地址的数据。我在项目中实测连续读64字节比单字节读快20倍以上。5. 代码优化与调试经验5.1 三态总线处理SDA是双向信号Verilog中需要用inout类型声明。我推荐这种实现方式assign sda sda_dir ? sda_out : 1bz; assign sda_in sda; // 方向控制示例 always (posedge dri_clk) begin case(state) ACK: sda_dir 0; // 释放总线 default: sda_dir 1; // 驱动总线 endcase end5.2 亚稳态预防当FPGA采样来自EEPROM的应答信号时容易发生亚稳态。我的解决方案是使用两级寄存器同步在SCL高电平中点采样cnt6时添加超时检测// 亚稳态处理 always (posedge dri_clk) begin ack_meta sda_in; ack_sync ack_meta; end // 超时检测 always (posedge dri_clk or negedge rst_n) begin if(!rst_n) timeout_cnt 0; else if(state ACK) begin if(timeout_cnt 255) begin timeout 1; next_state STOP; end else begin timeout_cnt timeout_cnt 1; end end else begin timeout_cnt 0; end end5.3 调试技巧在调试I2C时我总结出三个必备工具SignalTap逻辑分析仪抓取SCL/SDA实际波形模拟EEPROM模型用于仿真测试LED状态指示快速判断操作阶段这里分享一个仿真模型的使用示例task send_byte; input [7:0] data; begin for(i0; i8; ii1) begin sda data[7-i]; #(CLK_PERIOD/2); scl 1; #(CLK_PERIOD/2); scl 0; end // 释放SDA检测应答 sda 1bz; #(CLK_PERIOD/2); scl 1; if(sda ! 0) $display(No ACK received!); #(CLK_PERIOD/2); scl 0; end endtask6. 工程实践建议在实际项目中我建议采用模块化设计i2c_master纯I2C时序实现eeprom_ctrlEEPROM特定命令封装user_logic业务逻辑层这种分层架构的好处是I2C核心模块可以复用更换存储芯片只需修改eeprom_ctrl上电初始化流程更清晰对于需要频繁读写的场景可以添加写缓冲队列。我在某个数据采集项目中实现了一个16字节的FIFO当写操作触发时检查EEPROM是否忙通过超时判断批量写入FIFO中的数据进入低功耗状态等待下次触发最后强调一个容易忽视的细节EEPROM写周期。AT24C系列典型写周期是5ms连续写入时必须保证间隔。我的做法是在每个写操作后启动定时器// 写周期等待 always (posedge clk) begin if(write_done) begin wait_cnt 0; write_busy 1; end else if(write_busy) begin if(wait_cnt WAIT_CYCLES) begin write_busy 0; end else begin wait_cnt wait_cnt 1; end end end