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 = 4'd0; localparam START = 4'd1; localparam SEND_ADDR= 4'd2; localparam ACK1 = 4'd3; ... // 其他状态省略 // 状态寄存器 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状态,容易因应答检测不完整导致时序违规。我的经验是把每个字节传输拆分为四个阶段:

  1. 发送bit7-bit1(7个时钟周期)
  2. 发送bit0(1个时钟周期)
  3. 释放SDA等待应答(1个时钟周期)
  4. 检测应答信号(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的SCL:

  • cnt=0-3:SCL低电平期
  • cnt=4-7:SCL高电平期
  • cnt=1和5时更新SDA数据

这样设计的好处是时序可调,当需要支持不同速率的I2C设备时,只需修改分频系数即可。

4. 典型操作时序实现

4.1 单字节写操作

单字节写需要5个状态:

  1. 发送起始条件
  2. 发送器件地址+写标志
  3. 发送字地址
  4. 发送数据字节
  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]; end

4.2 随机读操作

随机读操作更复杂,需要"虚写"过程:

  1. 发送起始条件
  2. 发送器件地址+写标志
  3. 发送要读取的地址
  4. 发送重复起始条件
  5. 发送器件地址+读标志
  6. 读取数据字节
  7. 发送非应答+停止条件

这里有个性能优化技巧:页读取。EEPROM支持连续读取,读完一个字节后发送应答可以继续读下一个地址的数据。我在项目中实测,连续读64字节比单字节读快20倍以上。

5. 代码优化与调试经验

5.1 三态总线处理

SDA是双向信号,Verilog中需要用inout类型声明。我推荐这种实现方式:

assign sda = sda_dir ? sda_out : 1'bz; assign sda_in = sda; // 方向控制示例 always @(posedge dri_clk) begin case(state) ACK: sda_dir <= 0; // 释放总线 default: sda_dir <= 1; // 驱动总线 endcase end

5.2 亚稳态预防

当FPGA采样来自EEPROM的应答信号时,容易发生亚稳态。我的解决方案是:

  1. 使用两级寄存器同步
  2. 在SCL高电平中点采样(cnt=6时)
  3. 添加超时检测
// 亚稳态处理 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 end

5.3 调试技巧

在调试I2C时,我总结出三个必备工具:

  1. SignalTap逻辑分析仪:抓取SCL/SDA实际波形
  2. 模拟EEPROM模型:用于仿真测试
  3. LED状态指示:快速判断操作阶段

这里分享一个仿真模型的使用示例:

task send_byte; input [7:0] data; begin for(i=0; i<8; i=i+1) begin sda = data[7-i]; #(CLK_PERIOD/2); scl = 1; #(CLK_PERIOD/2); scl = 0; end // 释放SDA检测应答 sda = 1'bz; #(CLK_PERIOD/2); scl = 1; if(sda !== 0) $display("No ACK received!"); #(CLK_PERIOD/2); scl = 0; end endtask

6. 工程实践建议

在实际项目中,我建议采用模块化设计

  • i2c_master:纯I2C时序实现
  • eeprom_ctrl:EEPROM特定命令封装
  • user_logic:业务逻辑层

这种分层架构的好处是:

  1. I2C核心模块可以复用
  2. 更换存储芯片只需修改eeprom_ctrl
  3. 上电初始化流程更清晰

对于需要频繁读写的场景,可以添加写缓冲队列。我在某个数据采集项目中实现了一个16字节的FIFO,当写操作触发时:

  1. 检查EEPROM是否忙(通过超时判断)
  2. 批量写入FIFO中的数据
  3. 进入低功耗状态等待下次触发

最后强调一个容易忽视的细节: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