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)
~50pF4.7kΩ2.2kΩ
~100pF2.2kΩ1.0kΩ
~200pF1.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位寻址怎么工作的?其实很简单:

  1. 主机先发送一个特殊头:11110 + 地址高2位 + R/W
  2. 从机确认后,主机再发送地址低8位
  3. 从机再次确认

说白了,就是分两次发送地址。我个人习惯:除非必要,尽量用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各有千秋。好了,今天就到这里,有问题随时交流。