I2C(Inter-Integrated Circuit)是一种简单但功能强大的串行通信协议,它只需要两根信号线就能实现多设备之间的通信。我第一次接触I2C是在设计一个温度传感器项目时,当时需要将多个传感器数据汇总到主控制器。I2C的简洁性让我印象深刻,但也踩过不少坑,比如忘记处理双向端口导致信号冲突。
I2C的两根信号线分别是:
这两根线都需要通过上拉电阻连接到电源电压,这是I2C总线能够实现多设备共享的关键。在实际项目中,我遇到过上拉电阻取值不当导致信号完整性问题的案例。虽然电阻值选择属于硬件设计范畴,但作为数字IC工程师,了解这个机制对调试很有帮助。
I2C的核心工作机制包括几个关键点:
设计I2C控制器时,状态机是最核心的部分。根据我的项目经验,一个完整的I2C主控制器至少需要包含以下状态:
verilog复制typedef enum logic [3:0] {
IDLE, // 空闲状态
START, // 起始条件
ADDR, // 发送地址
WAIT_ACK, // 等待应答
WRITE_DATA, // 写数据
READ_DATA, // 读数据
STOP // 停止条件
} i2c_state_t;
在实际实现中,每个状态的转换都需要精确控制。比如从IDLE到START的转换,需要确保SCL为高时SDA产生下降沿。这里分享一个我踩过的坑:早期版本中我忽略了时钟同步,导致起始条件有时无法被从设备识别。
边沿检测是另一个关键点。在Verilog中实现边沿检测的经典方法是:
verilog复制// 下降沿检测
always @(posedge clk) begin
sda_dly <= SDA;
scl_dly <= SCL;
end
assign sda_negedge = sda_dly & ~SDA;
assign scl_posedge = ~scl_dly & SCL;
这个电路在检测起始、停止条件时非常有用。我在一个EEPROM项目中就曾因为边沿检测不准确导致数据写入失败。
I2C的SDA线是双向的,这在Verilog中需要使用inout端口类型。处理inout端口是很多初学者的痛点,我来分享几个实用技巧:
verilog复制assign SDA = sda_oe ? sda_out : 1'bz;
verilog复制always @(posedge clk) begin
if (!sda_oe) sda_in <= SDA;
end
verilog复制always @(posedge clk) begin
if (sda_oe && (SDA != sda_out))
collision <= 1'b1;
end
在实际项目中,我建议为inout端口设计专门的控制器模块。我曾经在一个多主设备系统中因为没有处理好双向端口导致系统死锁,后来通过添加冲突检测机制解决了问题。
现在我们来实战一个完整的I2C主控制器,目标是与24LC256 EEPROM通信。以下是核心模块的接口定义:
verilog复制module i2c_master (
input wire clk, // 100MHz系统时钟
input wire rst_n, // 异步复位
inout wire sda, // I2C数据线
output wire scl, // I2C时钟线
input wire [6:0] dev_addr, // 从设备地址
input wire [15:0] mem_addr, // 存储器地址
input wire [7:0] data_in, // 写入数据
output reg [7:0] data_out, // 读取数据
input wire wr_en, // 写使能
input wire rd_en, // 读使能
output reg busy, // 忙标志
output reg error // 错误标志
);
时钟生成是第一个关键点。对于标准模式(100kbps),我们需要从100MHz系统时钟分频:
verilog复制// 时钟分频计数器
reg [9:0] clk_div;
wire scl_en = (clk_div == 10'd499); // 100MHz/1000 = 100kHz
always @(posedge clk or negedge rst_n) begin
if (!rst_n) clk_div <= 10'd0;
else if (clk_div == 10'd999) clk_div <= 10'd0;
else clk_div <= clk_div + 1'b1;
end
// SCL生成
reg scl_out;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) scl_out <= 1'b1;
else if (scl_en) scl_out <= ~scl_out;
end
assign scl = (state == IDLE) ? 1'b1 : scl_out;
EEPROM写操作的完整流程包括:
我在实现时发现EEPROM需要5ms的写入周期,所以添加了延时计数器:
verilog复制// 写周期等待
reg [19:0] write_delay;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) write_delay <= 20'd0;
else if (write_delay > 0) write_delay <= write_delay - 1'b1;
else if (write_complete) write_delay <= 20'd499999; // 5ms @100MHz
end
EEPROM读操作稍微复杂些,需要先发送"伪写"来设置地址指针:
在实际调试中,我发现很多初学者容易忽略重复起始条件这个步骤,导致读取失败。
验证I2C设计时,我推荐采用分层验证策略:
verilog复制initial begin
// 检查起始条件
#100;
@(negedge sda) begin
if (scl !== 1'b1) $error("起始条件错误");
end
...
end
verilog复制task write_eeprom;
input [15:0] addr;
input [7:0] data;
begin
@(posedge clk);
dev_addr = 7'h50;
mem_addr = addr;
data_in = data;
wr_en = 1'b1;
@(posedge clk);
wr_en = 1'b0;
wait(!busy);
#1000;
end
endtask
我在项目中总结的几个实用调试技巧:
一个常见的错误是忽略从设备的应答超时。我建议添加超时计数器:
verilog复制reg [15:0] ack_timeout;
always @(posedge clk or negedge rst_n) begin
if (!rst_n) ack_timeout <= 16'd0;
else if (state == WAIT_ACK) begin
if (ack_timeout == 16'd10000) begin
error <= 1'b1;
state <= IDLE;
end
else ack_timeout <= ack_timeout + 1'b1;
end
else ack_timeout <= 16'd0;
end
完成基本功能后,我们可以考虑一些优化和扩展:
verilog复制always @(negedge scl) begin
if (scl_stretch) begin
wait(!scl_stretch);
#10;
end
end
verilog复制task burst_read;
input [15:0] start_addr;
input [7:0] length;
begin
// 设置起始地址
write_addr(start_addr);
// 连续读取
for (int i=0; i<length; i++) begin
read_byte();
buffer[i] = data_out;
end
end
endtask
verilog复制if (addr_mode == ADDR_10BIT) begin
// 发送11110+A9+A8+W
send_byte({4'b1110, dev_addr[9:8], 1'b0});
wait_ack();
// 发送A7-A0
send_byte(dev_addr[7:0]);
wait_ack();
end
verilog复制always @(negedge scl_out) begin
if (scl_sync) begin
scl_out <= 1'b0;
#(SCL_LOW/2);
scl_out <= 1'b1;
end
end
在实际项目中,我发现添加DMA支持可以显著提高大数据量传输的效率。通过将I2C控制器与DMA引擎配合,可以实现后台数据传输,减轻CPU负担。
在多个I2C项目实践中,我总结了一些典型问题及其解决方法:
verilog复制reg [23:0] sda_low_timeout;
always @(posedge clk) begin
if (SDA == 1'b0) begin
if (sda_low_timeout == 24'hFFFFFF) begin
force_reset <= 1'b1;
end
else sda_low_timeout <= sda_low_timeout + 1;
end
else sda_low_timeout <= 24'd0;
end
在最近的一个项目中,我遇到了一个棘手的问题:系统上电后I2C总线偶尔无法正常工作。经过仔细排查,发现是电源时序问题——从设备的上电时间比主设备慢。解决方法是在主设备初始化前添加100ms延时。这个案例告诉我,除了关注协议本身,系统级的时序问题也不容忽视。