FPGA实战:用Verilog手搓一个支持多字节地址的IIC主控制器(附完整代码)
在FPGA开发中,IIC(Inter-Integrated Circuit)总线因其简单的两线制(SCL时钟线和SDA数据线)和灵活的多设备连接能力,成为连接低速外设(如EEPROM、传感器、RTC等)的首选方案。然而,商业IP核往往价格昂贵或灵活性不足,而开源实现又难以满足多字节地址访问等高级需求。本文将带你从零开始,用Verilog实现一个参数化、可配置的IIC主控制器,支持1/2字节地址和多字节数据读写,并提供完整的Testbench验证方案。
1. 为什么需要自研IIC控制器?
商业IP核通常存在三个痛点:
- 灵活性差:难以适配特殊时序要求的设备
- 扩展性弱:多数只支持单字节地址访问
- 成本高:优质IP核授权费用可能占项目预算的20%以上
自研方案的优势体现在:
- 完全可控的时序调整:可精确匹配从设备时序要求
- 参数化设计:通过宏定义即可切换单/双字节地址模式
- 零成本复用:一次开发可在多个项目中重复使用
提示:当项目中需要连接超过3个IIC设备时,自研控制器的成本优势会显著体现。
2. IIC协议核心时序解析
2.1 基础通信时序
IIC通信由以下几个关键时序组成:
| 信号类型 | 时序特征 | 实现要点 |
|---|---|---|
| START | SDA在SCL高电平时拉低 | 需严格满足tHD;STA时间参数 |
| STOP | SDA在SCL高电平时拉高 | 需满足tSU;STO最小脉冲宽度 |
| ACK | 第9个时钟周期SDA被从机拉低 | 需在SCL上升沿前检测SDA状态 |
| DATA | SDA在SCL低电平时变化,高电平稳态 | 建立/保持时间必须满足规格书 |
2.2 多字节地址访问时序
双字节地址写操作典型流程:
- 主设备发送START条件
- 发送从设备地址(写模式)
- 发送高字节寄存器地址
- 发送低字节寄存器地址
- 发送数据字节
- 主设备发送STOP条件
// 双字节地址写操作状态跳转示例 localparam [3:0] SEND_ADDR_H = 4'd1, SEND_ADDR_L = 4'd2, SEND_DATA = 4'd3;3. Verilog实现详解
3.1 模块接口设计
module iic_master #( parameter CLK_FREQ = 50_000_000, // 系统时钟频率(Hz) parameter IIC_FREQ = 100_000, // IIC时钟频率(Hz) parameter ADDR_WIDTH = 16, // 地址总线宽度(8/16) parameter DATA_WIDTH = 8 // 数据总线宽度 )( input clk, // 系统时钟 input rst_n, // 异步复位 // 用户接口 input [6:0] dev_addr, // 从设备地址 input [ADDR_WIDTH-1:0] reg_addr, // 寄存器地址 input [DATA_WIDTH-1:0] wr_data, // 写数据 output [DATA_WIDTH-1:0] rd_data, // 读数据 input wr_en, // 写使能 input rd_en, // 读使能 output reg done, // 操作完成标志 // IIC物理接口 output scl, // 时钟线 inout sda // 数据线 );3.2 状态机设计
采用三段式状态机实现协议控制:
// 状态编码(独热码) localparam [7:0] IDLE = 8'b00000001, START = 8'b00000010, SEND_ADDR = 8'b00000100, SEND_DATA = 8'b00001000, RECV_DATA = 8'b00010000, STOP = 8'b00100000, WAIT_ACK = 8'b01000000; always @(posedge clk or negedge rst_n) begin if(!rst_n) begin state <= IDLE; end else begin case(state) IDLE: if(wr_en || rd_en) state <= START; START: if(scl_gen) state <= SEND_ADDR; // ...其他状态转移 endcase end end3.3 关键时序实现
START条件生成:
// START信号生成逻辑 always @(posedge clk) begin if(state == START) begin if(scl_high) sda_out <= 1'b0; // SCL高时拉低SDA end end数据移位发送:
// 数据移位发送过程 always @(posedge clk) begin if(state == SEND_ADDR || state == SEND_DATA) begin if(scl_low) begin shift_reg <= {shift_reg[6:0], 1'b0}; // 左移 sda_out <= shift_reg[7]; // 输出MSB end end end4. 测试验证方案
4.1 Testbench设计要点
// 模拟EEPROM从设备行为 task eeprom_response; input [7:0] addr; begin // 检查设备地址 if(shift_in[7:1] == DEV_ADDR) begin // 发送ACK force sda = 0; #(IIC_PERIOD/2); release sda; end end endtask4.2 上板调试技巧
信号完整性检查:
- 使用示波器确认SCL频率是否符合预期
- 检查START/STOP条件的上升/下降时间
常见问题排查:
- 无ACK响应:检查从设备地址是否正确
- 数据错误:确认时序参数是否满足从设备要求
- 总线锁死:确保每次操作都有完整的STOP条件
注意:调试时建议先在低速模式(如10kHz)下验证功能,再逐步提高时钟频率。
5. 高级功能扩展
5.1 多主机仲裁支持
通过监测总线状态实现冲突检测:
// 总线冲突检测逻辑 always @(negedge sda) begin if(scl == 1'b1 && sda_out == 1'b1) begin $display("Bus collision detected!"); state <= IDLE; end end5.2 时钟拉伸处理
应对从设备时钟拉伸需求:
// 时钟拉伸检测 always @(negedge scl) begin if(sda == 0) begin scl_en <= 0; // 暂停时钟 @(posedge sda); // 等待从设备释放 scl_en <= 1; end end完整实现代码
`timescale 1ns/1ps module iic_master #( // ...参数定义同上 )( // ...端口定义同上 ); // 时钟生成 reg [15:0] clk_cnt; reg scl_en; wire scl_high = (clk_cnt == (DIV_CNT >> 1)); wire scl_low = (clk_cnt == DIV_CNT); always @(posedge clk) begin if(!rst_n) clk_cnt <= 0; else if(scl_en) begin if(clk_cnt == DIV_CNT) clk_cnt <= 0; else clk_cnt <= clk_cnt + 1; end end assign scl = (scl_en && clk_cnt <= (DIV_CNT >> 1)) ? 1'b1 : 1'b0; // 状态机实现 // ...完整状态机代码 // 数据移位寄存器 reg [7:0] shift_reg; always @(posedge clk) begin if(state == IDLE) begin if(wr_en) shift_reg <= {dev_addr, 1'b0}; // 写地址 else if(rd_en) shift_reg <= {dev_addr, 1'b1}; // 读地址 end end // SDA三态控制 reg sda_out; reg sda_oen; // 输出使能 assign sda = sda_oen ? sda_out : 1'bz; // ...其他实现细节 endmodule在实际项目中验证该控制器时,发现对某些特殊传感器需要调整SCL低电平持续时间,这时只需修改时钟分频参数即可快速适配。这种灵活性正是自研方案的最大价值所在。