在数字IC设计和FPGA开发领域,I2C总线因其简单的两线制结构和灵活的多主从配置,成为连接低速外设的首选方案。无论是与EEPROM通信、读取传感器数据,还是配置显示设备参数,I2C都展现出了极高的实用价值。本文将带你从零开始,用Verilog实现一个通过APB总线控制的I2C Master控制器,重点解决实际工程中的关键问题:如何设计可配置的时钟分频、处理双向IO的PAD接口、构建高效的状态机,以及优化寄存器交互逻辑。
I2C协议的精妙之处在于仅用两根线(SCL时钟线和SDA数据线)就实现了完整的主从通信。与UART不同,I2C是真正的多主设备总线,这意味着我们的控制器需要处理总线仲裁和时钟同步等复杂场景。在硬件设计层面,有几个关键特性需要特别注意:
典型的I2C传输时序包含以下几个阶段:
提示:I2C标准模式(100kHz)和快速模式(400kHz)对上升时间要求不同,设计PAD时需要根据目标速率选择合适的驱动强度。
APB(Advanced Peripheral Bus)作为ARM AMBA协议族中的低功耗外设总线,非常适合连接I2C这类低速设备。我们的控制器需要实现完整的APB接口信号:
| APB信号 | 方向 | 描述 |
|---|---|---|
| PCLK | 输入 | 总线时钟 |
| PRESETn | 输入 | 低有效复位 |
| PADDR | 输入 | 32位地址总线 |
| PSEL | 输入 | 设备选择 |
| PENABLE | 输入 | 使能信号 |
| PWRITE | 输入 | 读写控制 |
| PWDATA | 输入 | 写数据 |
| PRDATA | 输出 | 读数据 |
| PREADY | 输出 | 传输完成 |
| PSLVERR | 输出 | 错误指示 |
寄存器组的设计直接影响控制器的灵活性。我们采用以下寄存器布局:
verilog复制module i2c_regs (
input wire PCLK,
input wire PRESETn,
input wire [31:0] PADDR,
input wire PSEL,
input wire PENABLE,
input wire PWRITE,
input wire [31:0] PWDATA,
output reg [31:0] PRDATA,
output reg PREADY
);
// 寄存器定义
reg [15:0] prescale; // 0x00: 时钟分频系数
reg [7:0] ctrl; // 0x04: 控制寄存器
reg [7:0] tx_data; // 0x08: 发送数据
reg [7:0] rx_data; // 0x0C: 接收数据
reg [7:0] status; // 0x10: 状态寄存器
reg [7:0] command; // 0x14: 命令寄存器
// APB接口逻辑
always @(posedge PCLK or negedge PRESETn) begin
if (!PRESETn) begin
// 复位逻辑
end else if (PSEL && PENABLE) begin
// 寄存器读写逻辑
end
end
endmodule
I2C的时钟生成需要考虑两个关键因素:系统时钟到SCL的精确分频,以及不同模式下的时序要求。我们的设计采用可编程预分频器,支持标准模式(100kHz)和快速模式(400kHz)。
分频系数的计算公式为:
code复制分频系数 = (系统时钟频率) / (5 × 目标SCL频率) - 1
例如,当系统时钟为50MHz,目标SCL为100kHz时:
code复制分频系数 = 50,000,000 / (5 × 100,000) - 1 = 99
在Verilog中实现时,我们需要一个16位计数器:
verilog复制module i2c_clock_gen (
input wire clk,
input wire reset_n,
input wire [15:0] prescale,
output reg scl_out,
output reg scl_en
);
reg [15:0] counter;
reg [2:0] phase; // 5相位计数器
always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin
counter <= 0;
phase <= 0;
scl_out <= 1'b1;
scl_en <= 1'b1;
end else begin
if (counter == prescale) begin
counter <= 0;
phase <= phase + 1;
case (phase)
0: begin scl_out <= 1'b1; scl_en <= 1'b1; end // SCL高电平
1: begin /* 保持高电平 */ end
2: begin scl_out <= 1'b0; scl_en <= 1'b0; end // SCL下降沿
3: begin /* 保持低电平 */ end
4: begin scl_out <= 1'b1; scl_en <= 1'b1; end // SCL上升沿
endcase
end else begin
counter <= counter + 1;
end
end
end
endmodule
注意:I2C规范要求SCL高电平时间(tHIGH)和低电平时间(tLOW)必须满足最小要求,标准模式下分别为4.0μs和4.7μs。设计时需要根据系统时钟频率确保分频后的时序符合规范。
I2C的SDA线是典型的双向开漏信号,在RTL设计中需要特别注意三态控制。我们采用以下接口信号:
sda_out: 主设备输出数据sda_in: 主设备输入数据sda_en: 输出使能信号(0表示驱动,1表示高阻)verilog复制module i2c_pad (
inout wire sda_io, // 物理SDA引脚
input wire sda_out, // 内部输出数据
output reg sda_in, // 内部输入数据
input wire sda_en // 输出使能
);
// 三态驱动逻辑
assign sda_io = sda_en ? 1'bz : sda_out;
// 输入同步逻辑
always @(*) begin
sda_in = sda_io;
end
endmodule
在实际FPGA实现中,需要特别注意:
I2C主控制器的核心是一个精心设计的状态机,需要处理以下主要状态:
状态机的Verilog实现框架:
verilog复制module i2c_master_fsm (
input wire clk,
input wire reset_n,
input wire [7:0] command,
input wire [7:0] tx_data,
output reg [7:0] rx_data,
output reg busy,
output reg int_status,
// 其他控制信号...
);
// 状态定义
typedef enum {
ST_IDLE,
ST_START,
ST_ADDR,
ST_DATA_TX,
ST_DATA_RX,
ST_ACK,
ST_STOP,
ST_ERROR
} i2c_state_t;
reg [2:0] state;
reg [2:0] next_state;
reg [3:0] bit_counter;
reg [7:0] shift_reg;
reg ack_received;
// 状态转移逻辑
always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin
state <= ST_IDLE;
// 其他复位逻辑...
end else begin
state <= next_state;
case (state)
ST_IDLE: begin
if (start_condition) begin
next_state <= ST_START;
shift_reg <= {command[7:1], 1'b0}; // 地址 + 写
end
end
ST_START: begin
// 生成START时序
next_state <= ST_ADDR;
end
// 其他状态处理...
endcase
end
end
// 输出逻辑
always @(*) begin
case (state)
ST_IDLE: begin
sda_en = 1'b1;
scl_en = 1'b1;
end
// 其他输出控制...
endcase
end
endmodule
一个可靠的I2C控制器需要全面的验证。我们建议采用分层验证策略:
模块级验证:使用Verilog仿真测试每个子模块
系统级验证:模拟实际I2C从设备
FPGA原型验证:使用逻辑分析仪抓取实际信号
以下是一个典型的测试用例序列:
verilog复制initial begin
// 初始化
i2c_reset();
// 测试1:写入EEPROM
i2c_set_prescale(99); // 100kHz @ 50MHz
i2c_enable();
i2c_start();
i2c_send_addr(0xA0, 0); // EEPROM写地址
i2c_send_data(8'h00); // 内存地址高字节
i2c_send_data(8'h10); // 内存地址低字节
i2c_send_data(8'h55); // 测试数据
i2c_stop();
// 测试2:从EEPROM读取
i2c_start();
i2c_send_addr(0xA0, 0); // EEPROM写地址
i2c_send_data(8'h00); // 内存地址高字节
i2c_send_data(8'h10); // 内存地址低字节
i2c_start(); // 重复START
i2c_send_addr(0xA0, 1); // EEPROM读地址
i2c_read_data(1); // 读取带NACK
i2c_stop();
end
在基本功能实现后,可以考虑以下优化和扩展:
一个实用的优化是添加FIFO缓冲,减少APB总线交互频率:
verilog复制module i2c_fifo #(
parameter DEPTH = 8
)(
input wire clk,
input wire reset_n,
input wire [7:0] data_in,
input wire wr_en,
input wire rd_en,
output wire [7:0] data_out,
output wire full,
output wire empty
);
reg [7:0] mem [0:DEPTH-1];
reg [3:0] wptr, rptr;
reg [3:0] count;
always @(posedge clk or negedge reset_n) begin
if (!reset_n) begin
wptr <= 0;
rptr <= 0;
count <= 0;
end else begin
case ({wr_en, rd_en})
2'b10: if (!full) begin
mem[wptr] <= data_in;
wptr <= wptr + 1;
count <= count + 1;
end
2'b01: if (!empty) begin
rptr <= rptr + 1;
count <= count - 1;
end
2'b11: begin
mem[wptr] <= data_in;
wptr <= wptr + 1;
rptr <= rptr + 1;
end
endcase
end
end
assign data_out = mem[rptr];
assign full = (count == DEPTH);
assign empty = (count == 0);
endmodule
在实际项目中,我发现最常遇到的问题集中在时序控制和异常处理上。特别是在多主环境中,总线仲裁失败后的恢复流程需要特别注意。建议在状态机中添加专门的错误处理状态,确保在任何异常情况下都能安全回到IDLE状态。