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状态,容易因应答检测不完整导致时序违规。我的经验是把每个字节传输拆分为四个阶段:
- 发送bit7-bit1(7个时钟周期)
- 发送bit0(1个时钟周期)
- 释放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的SCL:
- cnt=0-3:SCL低电平期
- cnt=4-7:SCL高电平期
- cnt=1和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 : 1'bz; 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高电平中点采样(cnt=6时)
- 添加超时检测
// 亚稳态处理 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(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 endtask6. 工程实践建议
在实际项目中,我建议采用模块化设计:
- i2c_master:纯I2C时序实现
- eeprom_ctrl:EEPROM特定命令封装
- 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