简介:一套面向 Verilog 数字逻辑学习者与 FPGA 初学者的 I2C 从设备(I2C slave)设计实现包,目标是解决 I2C 总线从机时序控制与数据通路设计的核心问题。内容以 Verilog 源码为主,围绕 SCL 边沿检测、状态机切换、数据缓冲、地址解析、应答位生成和错误恢复等关键模块展开,同时给出 i2cAES 相关端点与 AES 子模块(如 mixc_mul)设计,便于理解加密传输与 I2C 通信的结合方式。压缩包共 161 个文件、1.39MB,除 .v/.vhd 源码外,还包含 .prj 等 Xilinx/ModelSim 工程文件、.dat/.bin 仿真数据、.log/.xreport 报告日志以及 .bak 历史备份,资源包内各类辅助文件齐全,适合按模块对照学习与排错。已有 577 人学习下载。读者可借此快速搭建 I2C slave 验证环境,掌握主从通信中的 ACK/NACK 时序、读写操作与状态机设计方法,并从工程组织方式中积累可复用的调试思路。
1. 需求拆解与整体架构设计
1.1 I2C从机到底在解决什么问题
I2C从机(slave)虽然代码量不大,但设计难度一点都不比主机端低。主机端的事务是自己发起的,节奏完全由自己控制;而从机端是被动响应方,外部时钟和数据的到来时机完全不可控,主机可能在任意时刻发起通信、任意时刻终止通信,从机必须在这条两线总线上“随机应变”。做一个可靠的i2c slave,本质上就是在受限的资源里,把一个外部异步事件驱动的协议栈,转化为内部同步可控的读写逻辑。
以最常见的7位地址模式为例,从机需要处理的事情包括:检测START和STOP条件、对比地址、产生ACK/NACK、接收写入的数据字节、发送读出的数据字节、处理主机端的重复起始条件(Sr)等。听起来不多,但每个环节都对时序有硬性要求。比如SDA在SCL高电平期间必须保持稳定,只有在SCL低电平期间才能变化;又比如从机响应ACK时需要在一个严格的窗口内把SDA拉低,早了会破坏数据,晚了主机已经采样结束,等于没应答。这些细节决定了从机模块不能只停留在“大概能用”的程度,必须把每个时序窗口都吃透。
我见过很多刚从MCU裸机开发转到FPGA/RTL设计的工程师,第一版i2c slave往往会把“检测到地址匹配之后,直接进入一个循环把数据读进来”这种思路带到RTL里,结果综合出来的逻辑要么状态机漏状态,要么hold timing一团糟。原因很简单:MCU里跑I2C从机是软件顺序执行的,状态天然有弹性;而RTL是并行逻辑,所有状态跳转和数据通道需要显式描述清楚,否则综合器和时序分析根本不知道你想干什么。
1.2 架构选型:状态机为什么是主流方案
设计i2c slave的架构方案我总结下来大致有三类:纯状态机方案、计数器辅助状态机方案、内嵌微控制器方案。三类方案各有适用场景,但绝大多数场景下,纯状态机配合字节计数器是最稳妥的选择。
纯状态机方案的核心思想是把协议过程拆成有限个稳定状态:IDLE、地址匹配、ACK、数据接收/发送、停止位等,每个状态对应一到两个SCL周期。优点是时序完全确定,每一个SCL边沿做什么事都在RTL里显式写清楚了,方便做静态时序分析,也方便仿真验证。缺点是当需要处理大量寄存器的时候,状态数会膨胀,代码容易变得很长。
计数器辅助状态机方案是在状态机的基础上增加一个bit计数器和byte计数器,状态机只关注“当前字节的第几位”“当前字节是第几个字节”这两个维度。实际工程里我基本都用这种写法,因为I2C协议里“过程”是分层的:bit是底层节拍,byte是上层单位,寄存器地址和数据的语义都建立在byte层面。计数器可以把底层bit的重复劳动收拢到一起,让状态机只处理“这个字节是地址还是数据”“这个数据是写入还是读出”这些高层逻辑。
内嵌微控制器方案(比如在FPGA里放一个软核,靠软件模拟I2C从机)只适合极少数需要复杂协议扩展的场景,比如需要支持类似SMBus的PEC校验告警、需要动态处理多页寻址等。日常的寄存器配置类从机(温度传感器、EEPROM、电源管理芯片等),用状态机方案两三百行RTL就能搞定,资源开销低,时序又可控,完全不必要上软核。
1.3 模块划分与接口定义
我习惯把i2c slave设计拆成三个子模块,尽量保持单一职责。第一个是i2c_slave_core,负责协议解析,输出读写请求和字节流;第二个是register_file,负责寄存器读写语义,这部分通常是项目定制的,比如配置寄存器、状态寄存器等;第三个是顶层wrapper,负责时钟域处理、上下拉配置和外部IO连线。如果设计中还涉及中断,我一般会在register_file里加中断状态和使能寄存器,不在core层处理。
接口定义上,推荐把SDA分成sda_in、sda_out、sda_oe三根内部信号,而不是直接在顶层用一根inout连到core。这样做的原因是内部逻辑本身没有真正的三态门,三态控制需要在顶层IO buffer上做,把SDA拆成三根信号后,core里只需要维护sda_oe和sda_out即可,sda_in永远读的是外部引脚的电平,逻辑会清晰很多。SCL则直接作为输入,只在内部做同步处理。
下面是我常用的接口表格,新的设计基本都复用这套定义:
| 信号名 | 方向 | 功能说明 |
|---|---|---|
| scl | in | I2C时钟,需先打拍同步 |
| sda_in | in | SDA电平采样输入(打拍后) |
| sda_out | out | SDA驱动数据输出 |
| sda_oe | out | SDA输出使能,1为驱动总线 |
| scl_falling | out | 内部产生的SCL下降沿脉冲 |
| scl_rising | out | 内部产生的SCL上升沿脉冲 |
| dev_addr[6:0] | in | 从机地址配置(可静态或动态) |
| reg_addr[7:0] | out | 当前访问的寄存器地址 |
| wdata[7:0] | out | 写入数据 |
| wr_pulse | out | 写寄存器脉冲 |
| rd_data[7:0] | in | 读取数据(来自寄存器文件) |
| rd_req | out | 读请求脉冲 |
这样的接口划分有一个好处,就是core层完全不关心寄存器文件具体有多少寄存器、地址怎么编码,只负责把字节流和读写信令输出。验证的时候可以直接用一个假的寄存器文件(比如RAM)替换,很快能跑通协议层面的测试。
2. 核心细节解析与实操要点
2.1 输入同步与毛刺过滤:别让亚稳态毁了你的第一版
I2C是典型的异步总线,外部SCL和SDA相对内部时钟完全是异步的,所以进RTL之后第一件事不是去检测边沿,而是先同步。我做过的小项目里,最容易被忽视的就是这个环节,有人直接拿外部SDA去触发状态机,结果仿真波形无比漂亮,一上板子就乱跳,最后定位到是亚稳态和毛刺问题。
标准做法是:外部scl和sda各打两拍同步,同步后的信号再送进边沿检测逻辑。如果内部时钟频率够高,建议在打拍之后加一个简单的数字毛刺滤波:用内部时钟对同步后的scl连续计数,只有当scl连续保持某个电平超过N个周期(比如3~5个周期)才认为电平有效。这个滤波能滤掉总线上的窄毛刺,尤其是当I2C线束较长、靠近电机或电源开关时,这种毛刺非常常见。
毛刺滤波的计数长度不是随便定的,需要和内部时钟频率及I2C速率匹配。比如内部时钟50MHz、SCL跑400kHz,一个SCL高电平时间约1.25us,对应62个内部时钟周期,这时把滤波窗口设成5个周期完全没问题,不会把正常电平误滤掉。但如果内部时钟只有2MHz、SCL也跑400kHz,一个高电平只有5个周期,滤波窗口设3个周期就已经很极限了,再大就会把有效边沿吃掉。所以滤波深度的选择要按“内部时钟频率 / I2C速率”来算,留出至少5倍以上的余量。
同步和滤波完成之后,scl和sda都变成了内部时钟域的干净信号,接下来才能做边沿检测。我习惯在module里统一产生scl_rising和scl_falling两个单周期脉冲,后续所有状态机都以这两个脉冲为节拍信号。这样状态机的敏感列表直接是内部时钟,但只有在scl边沿到来那一拍才会真正跳转,既保证了同步性,又让代码可读性高很多。
2.2 起始/停止条件的实时检测
I2C的START条件定义是:SCL为高电平期间,SDA发生一个高到低的跳变。STOP条件则是SCL为高电平期间,SDA发生一个低到高的跳变。这两个条件的关键在于“SCL为高电平期间”,所以检测逻辑可以这样设计:当scl同步信号为高时,对sda信号做边沿检测,检测到下降沿就产生start_pulse,检测到上升沿就产生stop_pulse。
这里的坑在于:START/STOP可能发生在任何总线状态,包括从机正在发送数据、正在响应ACK的时候。比如主机在写数据的某个bit中间突然发一个STOP,从机必须立刻回到IDLE状态,把当前传输的一切上下文都清掉。所以start_pulse和stop_pulse的优先级要高于状态机的正常跳转逻辑,最好在状态机的每一个分支里都优先判断这两个脉冲。
还有一个小细节:START条件本身也是一次“SDA变化”,如果在从机发送数据的字节中间出现了非预期的START,那其实就是主机要重新发起一次传输(重复起始条件)。这时从机需要回到地址接收阶段,而不是回到完全IDLE,因为整个传输还没有结束。正确的做法是:检测到start_pulse时,无论当前状态是什么,都跳回地址检测状态,同时清掉字节计数器和读写方向标志。
2.3 地址比较与第一字节处理
7位地址模式下,主机发送的第一个字节由7位从机地址加1位读写标志(LSB)构成。0x1E这类地址,实际总线上是0x3C(写)或0x3D(读),很多新手在这里犯迷糊:寄存器里面配的DEV_ADDR到底要不要左移一位?
我的经验是:在core内部保存的dev_addr_cfg[6:0]就是纯7位地址,不做移位。接收到第一个字节后,解析时取rx_byte[7:1]作为收到的地址,rx_byte[0]作为读写方向。这样配置从机地址的时候直接写0x1E就是0x1E,代码里对比也直观。如果你偏要在外部配置时左移一位,也不是不行,但一定要在寄存器说明里写清楚,否则后续维护的人一定会踩坑。
地址对比通过后,需要在第9个SCL周期(ACK周期)拉低SDA。这个ACK时序的实现要点是:在ADDR状态的bit_counter == 8时(即第8个数据位采样完成后),把sda_oe拉高、sda_out拉低,然后在第9个SCL时钟的下降沿之后的下一个状态再把sda_oe释放(拉高让外部上拉电阻把SDA拉回高电平)。简单说,ACK在SCL高电平期间要稳定为低,释放也要在SCL低电平期间做,不要在SCL高电平期间去翻转sda_oe,否则会被主机误判为数据。
2.4 ACK与数据移位逻辑
从机接收数据时,每个字节是在SCL上升沿被采样,SDA电平必须在SCL高电平期间保持稳定。所以数据接收逻辑应该以scl_rising为节拍,在每个上升沿把sda_in移入移位寄存器。移位方向是MSB先接收,也就是sda_in移入寄存器的最高位,然后整个寄存器右移。等到bit_counter从0数到7,shift_reg[7:0]刚好是一个完整的字节,这时在下一个bit周期(第9个bit)拉低SDA表示ACK。
从机发送数据时方向相反,每个bit要在SCL下降沿之后、SCL高电平到来之前把数据放到SDA上。这里有个重要的时序经验:SDA数据更新一定要用scl_falling来触发,在SCL低电平期间就把SDA切到下一bit数据。不要等到SCL上升沿再去切SDA,那样SDA在SCL高电平期间还在变化,主机采样到的数据就错位了。
读多字节时,主机在最后一个字节结束后会发送NACK,表示“别再发了,我要停了”。从机需要检测这个NACK并在下一个STOP或START到达时退出发送状态。NACK检测的方法很简单:在第9个bit的SCL高电平期间采样sda_in,如果为高,说明主机没有拉低SDA,从机就知道传输要结束了。这个逻辑要放在发送状态的ACK/NACK采样分支里,不要漏掉,否则会出现“从机一直发、主机一直收错”的诡异现象。
3. 实操过程与核心环节实现
3.1 RTL代码骨架:三段式状态机
下面给出一个简化但完整的i2c slave core RTL骨架,重点展示状态机的组织方式。代码故意省略了寄存器文件部分,只保留协议解析层,方便你把它嵌入到自己的设计里。
module i2c_slave_core #( parameter DEV_ADDR = 7'h0F )( input wire clk, input wire rst_n, input wire scl_i, input wire sda_i, output reg sda_o, output reg sda_oe, output reg [7:0] reg_addr, output reg [7:0] wdata, output reg wr_pulse, output reg rd_req, input wire [7:0] rd_data ); // 同步打拍 reg scl_sync1, scl_sync2; reg sda_sync1, sda_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin scl_sync1 <= 1'b1; scl_sync2 <= 1'b1; sda_sync1 <= 1'b1; sda_sync2 <= 1'b1; end else begin scl_sync1 <= scl_i; scl_sync2 <= scl_sync1; sda_sync1 <= sda_i; sda_sync2 <= sda_sync1; end end // 边沿检测 wire scl_rising = scl_sync2 & ~scl_sync1; wire scl_falling = ~scl_sync2 & scl_sync1; wire start_pulse = scl_sync2 & ~sda_sync2 & (sda_sync1); wire stop_pulse = scl_sync2 & sda_sync2 & ~sda_sync1; // 状态定义 localparam IDLE = 3'd0; localparam ADDR = 3'd1; localparam ACK1 = 3'd2; localparam DATA = 3'd3; localparam ACK2 = 3'd4; localparam SEND = 3'd5; reg [2:0] state, next_state; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg rw_flag; reg nack_flag; // 状态跳转 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= IDLE; else if (start_pulse) state <= ADDR; else if (stop_pulse) state <= IDLE; else state <= next_state; end // 次态逻辑(节拍由scl_rising / scl_falling控制) always @(*) begin next_state = state; case (state) IDLE: if (start_pulse) next_state = ADDR; ADDR: if (scl_rising && bit_cnt == 3'd7) next_state = ACK1; ACK1: if (scl_falling) next_state = rw_flag ? SEND : DATA; DATA: if (scl_rising && bit_cnt == 3'd7) next_state = ACK2; ACK2: if (scl_falling) next_state = rw_flag ? SEND : DATA; SEND: if (scl_rising && bit_cnt == 3'd7) next_state = ACK1; default: next_state = IDLE; endcase end // 时序逻辑:移位、bit计数、读写寄存器 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bit_cnt <= 3'd0; shift_reg <= 8'd0; rw_flag <= 1'b0; wr_pulse <= 1'b0; rd_req <= 1'b0; sda_o <= 1'b1; sda_oe <= 1'b0; end else begin wr_pulse <= 1'b0; rd_req <= 1'b0; if (start_pulse) begin bit_cnt <= 3'd0; sda_oe <= 1'b0; sda_o <= 1'b1; end else begin case (state) ADDR: begin if (scl_rising) begin if (bit_cnt < 3'd7) bit_cnt <= bit_cnt + 3'd1; shift_reg <= {shift_reg[6:0], sda_sync2}; end end ACK1: begin bit_cnt <= 3'd0; sda_oe <= 1'b1; sda_o <= 1'b0; // 产生ACK if (scl_falling) begin if (rw_flag) rd_req <= 1'b1; // 从移位寄存器解析地址或数据 if (rw_flag) begin shift_reg <= rd_data; end end end DATA: begin if (scl_rising) begin if (bit_cnt < 3'd7) bit_cnt <= bit_cnt + 3'd1; shift_reg <= {shift_reg[6:0], sda_sync2}; end end ACK2: begin bit_cnt <= 3'd0; sda_oe <= 1'b1; sda_o <= 1'b0; if (scl_falling) begin wr_pulse <= 1'b1; wdata <= shift_reg; reg_addr <= reg_addr; // 由上层寄存器文件更新 end end SEND: begin if (scl_falling) begin if (bit_cnt < 3'd7) begin bit_cnt <= bit_cnt + 3'd1; shift_reg <= {shift_reg[6:0], 1'b0}; sda_o <= shift_reg[7]; end else begin sda_oe <= 1'b0; // 释放SDA,准备接收NACK sda_o <= 1'b1; end end end endcase end end end endmodule这个骨架虽然简化了,但把关键跳转都保留下来了。需要说明的是,状态机跳转在scl_rising / scl_falling脉冲下进行,但状态寄存器本身由内部时钟驱动,所以不能直接写成“只在scl_rising时跳转”的样子,而是要在case里判断对应脉冲。上电后主机会先发START,所以从机在IDLE状态的时长由主机决定,没有任何问题。
3.2 写时序与寄存器更新逻辑
写方向上,主机发出START后,先发从机地址+写标志(比如0x3C),从机ACK后,再发寄存器地址字节,从机ACK,再发数据字节,从机ACK,主机发STOP。这里有个容易忽略的点:第一个字节之后,后续所有字节协议上是一样的,但语义完全不同——第二个字节是寄存器地址,第三个字节才是写入该寄存器的数据。
所以master存储区必须维护一个“字节序列”的概念。实现上我一般用一个address_phase标志,初始为1,表示下一个数据字节是寄存器地址。当收到寄存器地址后,address_phase清零,并把地址锁存到reg_addr。之后到达的每一个数据字节都作为写数据,产生wr_pulse的同时把reg_addr对应寄存器的值更新。如果设计支持自增(像EEPROM那样连续写),还要在每次wr_pulse后把reg_addr加1。
wr_pulse这个信号的产生时机要特别注意。我习惯在ACK2状态的scl_falling沿产生wr_pulse,因为这个时刻刚好是第9个bit(ACK bit)结束、字节数据已经完全确定且从机已经拉低SDA应答了。这拍产生写脉冲,寄存器文件在下一拍上升沿把wdata锁存进去,时序刚好对得上。如果你在DATA状态的最后一个scl_rising就产生wr_pulse,data可能还没完全移位完,会引入竞争冒险。
3.3 读时序与数据回读
读操作序列是:START + 从机地址+读标志(如0x3D),从机ACK后,主机直接开始读取数据字节。如果是要先写寄存器地址再读,通常需要一次重复起始条件(Restart):先写地址+写标志,写入寄存器地址,然后发Restart,再发地址+读标志。所以从机在有Restart输入时,必须在start_pulse中清掉address_phase,但要注意保留上一次写入的reg_addr,不能连寄存器地址也一起清了。
读数据的时候,从机的SEND状态在第一个bit就要把寄存器文件返回的数据放到SDA上。我的做法是在ACK1状态(响应地址后)的scl_falling沿给寄存器文件发rd_req脉冲,寄存器文件在下一拍返回rd_data,然后在SEND的第一个bit周期把rd_data放入移位寄存器。
最后一个字节发送完成后,主机返回NACK。从机在SEND状态的第9个bit高电平期间采样sda_in,如果为高,表示主机不想再收了。此时从机要继续等待STOP或START,不能立即回IDLE。一旦检测到STOP就彻底回到IDLE;检测到START,则回到ADDR阶段等待新的地址。这个逻辑在状态跳转的start_pulse/stop_pulse分支里已经覆盖了,唯一要确认的是nack_flag不要影响下一次传输的初始状态。
3.4 在FPGA上验证的最小测试流程
写完RTL,验证是重头戏。我习惯用一套带I2C master BFM(总线功能模型)的testbench来跑。BFM内部是一个简单的状态机,能发START、发字节、收字节、发ACK/NACK、发STOP。testbench里配置好DEV_ADDR后,依次发起四种典型操作:单字节写、连续写两字节、当前地址读、先写地址再读。
仿真时最值得关注的是两类信号:一是sda_oe和sda_o的组合是否符合预期,这决定了外部上拉电阻能否正确把总线拉回高;二是状态机跳转时刻和scl边沿是否严格对应。我一般会在testbench里加入自动检查:每个SCL高电平期间(scl_sync2为1)如果sda_in发生了变化,就报错。这个检查能把SDA在SCL高电平期间抖动的问题在第一时刻抓住。
在FPGA上实测时,要注意IO约束。SCL和SDA建议设置在同一个bank,IO电平标准按板卡实际电压(3.3V或1.8V)配置,并且加上内部上拉(如果外部没有上拉电阻的话)。SDA作为inout端口,在约束文件里要设置为open-drain模式,很多FPGA的IO primitive需要单独例化,不能直接写一个inout就完事,否则综合时会报错或者行为异常。
4. 常见问题与排查技巧实录
4.1 SDA被拉死、总线“短路”的常见原因
现象:上电后,逻辑分析仪抓SCL有正常的脉冲,但SDA一直是低,主机的访问永远不会成功。排除了外部上拉电阻虚焊后,问题基本发生在RTL内部:从机的sda_oe被错误拉高,且sda_out为低,导致从机一直占着总线。
最常见的原因有两个:一是状态机在IDLE状态下没有把sda_oe复位为0,仿真时看起来没问题,因为仿真里SDA默认是高阻,但综合到FPGA后IO输出可能保持上一个状态;二是检测到STOP时没有把一切状态清干净,特别是sda_oe和sda_o,导致下一轮START到来时总线还是被从机拉着。
排查技巧:在testbench里给SDA加上仿真上拉模型(比如用pullup语句或者将一个10k电阻模型接到SDA),然后观测sda_oe信号,一旦sda_oe被拉高且sda_out为低,立刻停止仿真查看状态机当前状态。我通常会在sda_oe拉高超过9个SCL周期时打印一条警告,这种断言能在验证早期就把问题扼杀。
4.2 读数据最后一个字节多了一个时钟
现象:主机读N个字节,前N-1个字节完全正常,最后一个字节读完后,从机又额外输出了一位0,导致下一帧数据的对齐全部错乱。
这个问题几乎都是因为最后一个字节的NACK处理出了问题。主机在最后一个字节后返回NACK,从机收到NACK后,应该在下一个bit周期就把SDA释放并停止发送。但很多初版代码里,从机在NACK之后仍然在scl_falling沿继续把sda_o切到下一个bit,导致主机看到多余的一位。
修正方法是在SEND状态的bit_cnt等于7且在第9个bit检测到NACK时,直接把sda_oe清零、sda_o拉高,并且阻塞后续的移位操作。同时可以顺手清掉bit_cnt,让状态机进入等待STOP的静默状态。这里不要依赖主机的STOP来解决,因为如果紧接着是Restart,主机不会发STOP,你必须在NACK后立即收手。
4.3 高频SCL下出现建立时间不足
这个坑我在400kHz转1MHz的项目里遇到过。现象是仿真一切正常,板子上一跑主机偶尔读回错误数据,且错误bit位置不固定。用示波器抓SDA会发现:SCL下降沿之后,SDA并不是立刻更新,而是延迟了将近半个SCL周期才变,导致SCL上升沿到来时SDA还没有稳定。
根因是我在SEND状态用了组合逻辑把shift_reg[7]直接输出到sda_out,而shift_reg的更新依赖scl_falling,组合逻辑链上还有几级判断,整体延迟累加后超过了时序预算。解决办法有两条路:第一,在scl_falling到来之前提前一拍把数据更新到输出寄存器,让SDA在SCL下降沿后立刻稳定;第二,SEND状态下不要用组合逻辑直接输出,而是增加一级寄存器专门做SDA输出锁存。对于低速I2C(100k/400k),组合逻辑直出问题不大,但跑1MHz以上时一定要提前做寄存输出。
4.4 和上拉电阻、总线电容相关的经验
I2C总线是开漏结构,上拉电阻的取值直接影响信号边沿的上升时间。电阻太大,RC常数大,SCL/SDA上升沿变得平缓,在高速模式(Fast-mode Plus 1MHz)下会出现边沿不符合规格的情况,甚至被从机毛刺滤波误判成多个脉冲。电阻太小,总线灌电流过大,低电平噪声和功耗飙升。
我一般按总线电容估算:标准模式100kHz建议上拉4.7k~10k,Fast模式400kHz建议2.2k~4.7k,Fast Plus 1MHz建议1k~2.2k。总线上每多挂一个设备,电容会增加几pF到十几pF,板级布线过长的话还得多留余量。调试时如果遇到不明原因的数据错误,先拿示波器看SDA的上升沿,如果上升沿时间超过1us(400kHz下),多半是上拉电阻选大了。
4.5 总线异常后从机“锁死”的恢复策略
I2C协议没有超时机制,如果主机在传输中途掉电或者死机,总线可能停留在SCL低或SDA低的中间态,从机如果一直等某个SCL边沿,就会卡死在当前状态。这在单板系统中影响不大,但在热插拔或电源域独立的系统里,总线异常恢复是必须考虑的问题。
我的做法是在core里加一个超时计数器,统计从最近一次有效SCL边沿(scl_rising或scl_falling)到当前的时间,如果超出设定阈值(比如25ms,远大于任何正常I2C传输的位时间),就强制把状态机复位到IDLE。这样即使总线被拉死,从机也能在下一轮START到达时正常响应。具体阈值要根据系统里最慢的I2C速率来定,如果总线上挂了实时性要求高的传感器,阈值可以设成100ms,避免误复位正常但慢速的传输。
5. 从工程角度再聊几句
写i2c slave的这些年,我最大的体会是:协议本身不难,难的是把边界情况想全。数据正常收发谁都能写出来,但START/STOP在任意bit位置到来、主机中途放弃传输、地址匹配后NACK、总线上多个从机同时响应这些异常场景,才是真正拉开设计水平的差距。
如果你是从零开始做,建议先用100kHz模式把功能跑通,再加毛刺滤波和超时复位,最后再优化到400kHz/1MHz的时序。一步一步来,每一个阶段都有明确的验证手段,调试起来会轻松很多。仿真验证的时候,不要把SPICE级的上拉模型做得太理想,真实总线有噪声有电容,testbench里模拟得越贴近实际,上板后面临的意外就越少。
最后再分享一个小技巧:调试I2C时,把SCL和SDA都引到示波器或逻辑分析仪上,同时让排查软件能把RTL内部的state和sda_oe信号一起抓出来。这样协议波形和内部状态一一对应,定位问题基本是分钟级的事。你要是手里有支持交叉触发的逻辑分析仪,体验会更好,可惜多数项目里我只能靠仿真波形硬扛,后来养成了“RTL内部状态信号都要testbench导出”的习惯,算是我踩过无数次坑之后的保留节目了。
本文还有配套的精品资源,点击获取