4、I2C协议详解:从物理层到Verilog实战
各位同学,今天我们来聊聊I2C。这个协议在嵌入式领域太常见了,传感器、EEPROM、ADC/DAC,几乎每个板子上都能看到它的身影。我个人觉得,I2C是入门通信协议最好的选择——它不像UART那样简单到没啥可讲,也不像SPI那样需要操心片选信号。I2C的优雅之处在于,只用两根线就能挂一堆设备。
4.1 I2C总线架构:SDA与SCL
I2C总线就两根线:SDA(串行数据线)和SCL(串行时钟线)。这两根线都是开漏输出,需要外部上拉电阻。为什么用开漏?说白了,就是为了实现“线与”功能——任何设备都可以拉低总线,不会发生短路。
我在项目中遇到过一个问题:某次调试时,I2C总线一直卡在低电平。查了半天,发现是上拉电阻焊错了,用了10kΩ,总线电容又大,上升沿慢得像蜗牛。嗯,这里要注意:上拉电阻的选择跟总线电容和通信速率有关。标准模式下,4.7kΩ是个常用值。
总线电容与上拉电阻的关系:
| 总线电容 | 推荐上拉电阻(100kHz) | 推荐上拉电阻(400kHz) |
|---|---|---|
| ~50pF | 4.7kΩ | 2.2kΩ |
| ~100pF | 2.2kΩ | 1.0kΩ |
| ~200pF | 1.0kΩ | 470Ω |
4.2 起始条件与停止条件
I2C总线上,什么时候开始传输?什么时候结束?这由起始条件和停止条件决定。
- 起始条件(START):SCL为高电平时,SDA从高电平跳变到低电平。
- 停止条件(STOP):SCL为高电平时,SDA从低电平跳变到高电平。
你想想看,这两个条件都是SCL为高时SDA发生变化。而在数据传输过程中,SDA只能在SCL为低时变化。这就是I2C协议的巧妙之处——用SDA的变化时机来区分控制信号和数据信号。
个人经验:我曾经在写Verilog代码时,把起始条件的检测逻辑写反了。结果仿真时总线一启动就检测到“起始”,实际上那是数据线上的毛刺。后来我加了一个数字滤波器——连续采样三次都为低,才认为是真正的起始条件。这个技巧在噪声环境中特别管用。
4.3 7位寻址与10位寻址
I2C设备怎么区分谁在跟谁说话?靠地址。标准I2C用7位地址,理论上可以挂127个设备(地址0x00保留)。但7位地址只有128个,实际应用中经常不够用。于是就有了10位寻址。
10位寻址怎么工作的?其实很简单:
- 主机先发送一个特殊头:11110 + 地址高2位 + R/W
- 从机确认后,主机再发送地址低8位
- 从机再次确认
说白了,就是分两次发送地址。我个人习惯:除非必要,尽量用7位地址。10位寻址的兼容性问题比较多,有些老设备不支持。
避坑指南:我曾经在一个项目里用了10位寻址,结果发现某款传感器芯片的勘误表里写着“10位寻址模式下,ACK时序有偏差”。折腾了两天才找到问题。所以,选型时一定要看芯片手册里关于寻址方式的说明。
4.4 ACK/NACK机制
I2C的每个字节后面都跟着一个应答位。发送方在第9个时钟周期释放SDA,接收方拉低SDA表示ACK(应答),保持高电平表示NACK(非应答)。
为什么需要这个机制?我举个例子:你往EEPROM里写数据,写完后它需要时间把数据存到非易失存储器里。这时候你发下一个字节,它就会回复NACK。你收到NACK就知道——哦,它还没准备好。
常见的NACK场景:
- 从机地址不匹配
- 从机正忙(比如EEPROM在擦写)
- 主机想结束传输(最后一个字节后发NACK)
4.5 时钟拉伸
时钟拉伸是I2C协议里一个容易被忽视的特性。从机可以拉低SCL,告诉主机“我还没准备好,你等等”。主机检测到SCL被拉低,就会等待,直到从机释放SCL。
为什么会这样?有些从机处理速度慢,比如一个温度传感器,你让它采集数据,它需要几十毫秒。这时候它拉低SCL,主机就乖乖等着。等数据准备好了,它释放SCL,主机继续发时钟。
注意:不是所有I2C主机都支持时钟拉伸。有些MCU的I2C外设不支持这个特性,用软件模拟I2C时也要注意。我建议:如果从机支持时钟拉伸,主机最好也支持,否则可能通信失败。
4.6 Verilog实现I2C从机接口
好了,理论讲完了,我们来点实战。下面是一个简化的I2C从机接口Verilog代码。这个代码实现了7位地址识别、数据接收和发送功能。
module i2c_slave (
input wire clk, // 系统时钟
input wire rst_n, // 复位
inout wire sda, // 数据线
inout wire scl, // 时钟线
input wire [6:0] slave_addr, // 从机地址
output reg [7:0] rx_data, // 接收到的数据
input wire [7:0] tx_data, // 要发送的数据
output reg rx_valid, // 接收有效
input wire tx_req // 发送请求
);
// 状态定义
localparam IDLE = 3'd0;
localparam ADDR = 3'd1;
localparam ACK_ADDR = 3'd2;
localparam DATA = 3'd3;
localparam ACK_DATA = 3'd4;
localparam STOP = 3'd5;
reg [2:0] state, next_state;
reg [3:0] bit_cnt; // 位计数器
reg [7:0] shift_reg; // 移位寄存器
reg sda_in, scl_in;
reg sda_out, scl_out;
reg sda_oe, scl_oe;
reg start_flag, stop_flag;
reg rw_flag; // 0:写, 1:读
// 同步采样(防亚稳态)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sda_in <= 1'b1;
scl_in <= 1'b1;
end else begin
sda_in <= sda;
scl_in <= scl;
end
end
// 起始/停止检测
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
start_flag <= 1'b0;
stop_flag <= 1'b0;
end else begin
// 起始:SCL高时,SDA下降沿
start_flag <= scl_in && !sda_in && sda_dly;
// 停止:SCL高时,SDA上升沿
stop_flag <= scl_in && sda_in && !sda_dly;
end
end
// 状态机
always @(posedge clk or negedge rst_n) begin
if (!rst_n)
state <= IDLE;
else
state <= next_state;
end
always @(*) begin
next_state = state;
case (state)
IDLE: begin
if (start_flag) next_state = ADDR;
end
ADDR: begin
if (bit_cnt == 4'd8) next_state = ACK_ADDR;
end
ACK_ADDR: begin
// 在第9个时钟沿判断
if (scl_in && sda_in) next_state = STOP; // NACK
else if (scl_in && !sda_in) begin
if (rw_flag) next_state = DATA; // 读
else next_state = DATA; // 写
end
end
DATA: begin
if (bit_cnt == 4'd8) next_state = ACK_DATA;
end
ACK_DATA: begin
if (stop_flag) next_state = IDLE;
else if (scl_in) next_state = DATA;
end
STOP: next_state = IDLE;
endcase
end
// 数据移位与地址匹配
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
shift_reg <= 8'd0;
bit_cnt <= 4'd0;
rx_data <= 8'd0;
rx_valid <= 1'b0;
end else begin
case (state)
ADDR: begin
// 在SCL上升沿采样数据
if (scl_in && !scl_dly) begin
shift_reg <= {shift_reg[6:0], sda_in};
bit_cnt <= bit_cnt + 1'b1;
end
if (bit_cnt == 4'd8) begin
// 检查地址是否匹配
if (shift_reg[7:1] == slave_addr)
rw_flag <= shift_reg[0]; // 0:写, 1:读
else
rw_flag <= 1'b0; // 不匹配
end
end
DATA: begin
if (scl_in && !scl_dly) begin
shift_reg <= {shift_reg[6:0], sda_in};
bit_cnt <= bit_cnt + 1'b1;
end
if (bit_cnt == 4'd8) begin
rx_data <= shift_reg;
rx_valid <= 1'b1;
end
end
default: begin
bit_cnt <= 4'd0;
rx_valid <= 1'b0;
end
endcase
end
end
// SDA输出控制(ACK和数据发送)
always @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
sda_out <= 1'b1;
sda_oe <= 1'b0;
end else begin
case (state)
ACK_ADDR: begin
// 地址匹配时拉低SDA(ACK)
if (scl_in && !scl_dly) begin
if (shift_reg[7:1] == slave_addr) begin
sda_out <= 1'b0;
sda_oe <= 1'b1;
end else begin
sda_out <= 1'b1;
sda_oe <= 1'b0;
end
end
end
DATA: begin
if (rw_flag) begin
// 读模式:在SCL低电平时更新数据
if (!scl_in && scl_dly) begin
sda_out <= tx_data[7 - bit_cnt];
sda_oe <= 1'b1;
end
end
end
ACK_DATA: begin
// 写模式:接收完数据后发ACK
if (scl_in && !scl_dly) begin
sda_out <= 1'b0;
sda_oe <= 1'b1;
end
end
default: begin
sda_out <= 1'b1;
sda_oe <= 1'b0;
end
endcase
end
end
// 三态门控制
assign sda = sda_oe ? sda_out : 1'bz;
assign scl = scl_oe ? scl_out : 1'bz;
endmodule
代码说明:这个代码实现了基本的I2C从机功能。我特意保留了状态机的结构,方便大家理解。实际项目中,你还需要考虑时钟拉伸、多字节传输、错误恢复等场景。我曾经在一个项目中,因为没处理总线冲突,导致从机死锁。后来加了一个超时复位逻辑——如果SCL被拉低超过10ms,就强制复位状态机。
4.7 小结
I2C协议看似简单,但坑不少。从物理层的上拉电阻,到协议层的时钟拉伸,再到Verilog实现时的时序细节,每一步都可能出问题。我个人建议:
- 先理解协议,再动手写代码
- 仿真时多考虑边界情况(比如NACK、时钟拉伸)
- 上板调试时,用逻辑分析仪抓波形,别靠猜
下一章我们会讲SPI协议,到时候对比着看,你会发现I2C和SPI各有千秋。好了,今天就到这里,有问题随时交流。