一次 I2C slave 的补充仿真查出 6 个问题:最严重的写通路失效被原 testbench 的“友好主机”行为完整掩盖,另外 5 个是地址应答、重启事务、SCL 毛刺和当前地址读。本文给诊断方法、采样点裕量账和复验矩阵。
1. 结论先行
被测对象是一个用高频时钟(约 70 MHz)过采样 SCL/SDA、靠 4 级同步延时补偿定位采样点的 I2C slave。原有仿真“全过”,补充仿真后得到 6 个问题、2 处修复:
| 编号 | 问题 | 机理一句话 | 严重度 | 状态 |
|---|---|---|---|---|
| A | 写通路在真实主机下完全失效 | R/W 位用裸输入 sda_i 采样,采样点落在 SCL 下降沿之后约 112 ns(SCL 低电平期) |
高(合规主机必现) | 已修(4 处改 sda_i_sync_d4) |
| B | 地址不匹配仍拉低 SDA 应答 | sda_oe 在地址第 8 拍无条件驱动 |
高 | 已修(加 dev_chk_pass 门控) |
| C | 重启后的读地址不校验 | 只判 i2c_ack,不判 dev_chk_pass → 应答非本机地址并驱动数据 |
高(总线冲突) | 未修 |
| D | 重启 + 写地址时抢占总线 | RESTART 固定跳 SLV_ADDR_RD,不看重启后首字节的 R/W 位 |
高 | 未修 |
| E | SCL 40 ns 毛刺造成虚假寄存器写 | 边沿检测无毛刺滤波,一次毛刺 = 多一个 bit | 中 | 未修 |
| F | 复位后直接读会静默改写地址指针 | 读地址被当成写流程的地址字节,随后 8 个读时钟被解析成寄存器地址(全 1 → 0xFF) | 中 | 未修 |
一句话总结:修复的两条都在“采样时刻”和“输出使能门控”这类一行改动上,代价不在于改代码,而在于先把现象量化到能证明“这行确实错了”。
2. 问题零:原仿真为什么看不见最严重的问题
原 testbench 的主机 BFM 在发完每个字节后一直保持驱动 SDA 直到 ACK 结束(tb_sda_oe 不释放)。这恰好满足“主机在 SCL 下降沿之后仍然保持数据”这种非标准用法,于是 Bug A 永远不触发。
task i2c_wr_byte(...);
// wait ack
tb_scl_o = 1'b0; // 只拉低 SCL;SDA 仍被主机驱动为最后一个 bit 的值
#T tb_scl_o = 1'b1;
教训:验证环境会把 DUT 的缺陷固化进“正常行为”。判断一个慢速串行从机的仿真是否可信,先看 BFM 三件事:① 每字节第 8 拍后有没有释放 SDA;② SDA 相对 SCL 有没有偏斜;③ 位周期是不是只试了一个值。
3. 诊断环境:把主机行为参数化,再加三个探针
新建的回归环境把主机时序做成 4 个参数:
| 参数 | 含义 | 扫描范围 |
|---|---|---|
T |
1/4 位周期(一个 bit = 4T) | 总线 100 kHz ~ 1 MHz |
THD |
SCL 下降沿 → SDA 改变的延时,即主机 t_HD;DAT |
0 ~ 625 ns(对齐规范) |
TS_BYTE |
1 = 第 8 拍后释放 SDA(真实主机);0 = 像原 TB 一样一直驱动 | 两种都跑 |
MSCL_D |
主机侧 SCL 到达延时,模拟偏斜 | 0 ~ 120 ns |
三个观测手段是这次能定性定量的关键:
- 记分板:把 DUT 实际驱动出的寄存器写(
wr_en边沿采样)与预期写序列逐条比对——地址、数据、顺序、条数。这样“一个寄存器都没写进去”是判据,不是印象。 - 非法驱动探针:
sda_oe=1若出现在读数据/ACK 窗口之外就打印[ILLEGAL-DRIVE],判总线冲突,并按采样点计数(用例里出现的 699、88 都是这个计数)。 - 采样点探针:直接打印 R/W 位被判决的时刻、当时的
sda_i/d2/d4/ 总线 SDA / 主机是否仍驱动。
17 个用例,./run.sh 一次跑完,每个用例一份 logs/{case}.log;EXTRA="+TV=130" 可指定主机 t_HD;DAT。(后续针对具体 bug 又补了地址译码表、重启事务、直接读等用例,全量 21 个。)
4. Bug A:一行采样点,先量化再动代码
4.1 定位
地址 R/W 位的判据(判定“写”、“读”、以及 RDDATA 续传的三处)直接用了未同步、未做延时补偿的裸输入 sda_i;而移位寄存器用的是 4 级延时补偿的 sda_i_sync_d4,地址比对用 d2。三者里只有 sda_i 那一处落在 SCL 低电平期。
4.2 实测采样时刻
探针与波形对齐后:R/W 位的判决发生在 SCL 下降沿(从机管脚)后约 112 ns ≈ 4 个时钟——这正是“2 级同步 + 2 级边沿检测”的固定延时。此时合规主机早已释放 SDA(规范 t_HD;DAT 下限是 0 ns),从机采到的是释放后的 1,于是:
- 写地址 0x40 被判成“读”(
wr_op=0)→ 地址字节 ACK 后 FSM 走到寄存器地址阶段就回 IDLE; - 后续数据字节全部 NACK,一个寄存器都不写;
- 读序列全部返回 0xFF(因为重启识别依赖
wr_op,写地址判错 → 重启识别不了)。
读数时的一个坑:探针打印的 “+140.9 ns” 比真正的判决拍(112 ns)晚一个时钟,因为探针要等 ACK 更新后才触发。探针时刻 ≠ 判决时刻,裕量必须按判决拍算,否则会把这行的错误归因到别处。
4.3 阈值扫描:把“猜”变成“数”
主机真实释放(TS_BYTE=1),扫 t_HD;DAT:
t_HD;DAT |
结果 |
|---|---|
| 625 / 300 / 200 / 150 / 130 / 120 / 115 ns | 全过(写 + 读回正确) |
| 110 / 105 / 100 / 95 / 90 / 85 / 80 / 70 / 0 ns | 全失败(数据字节 NACK、0 次寄存器写、读回 0xFF) |
临界点在 110~115 ns,与 112 ns 的采样点完全吻合。
关键的对照用例:让主机一路保持 SDA(TS_BYTE=0),t_HD;DAT 从 625 扫到 0 全过。这一条排除了“速率/位周期/相位”的错误归因——失败只取决于“主机何时释放 SDA”。
4.4 裕量账(本次最值得复用的写法)
把仿真里的一切换算到总线侧、相对 SCL 下降沿的窗口,再和规范要求对表:
| 采样源 | 等效采样时刻(相对 SCL 下降沿) | 对主机的要求 | 与规范相容性 |
|---|---|---|---|
sda_i(原) |
之后 +86 … +114 ns(SCL 低电平期) | 主机须把 SDA 保持 ≥115 ns | 与 t_HD;DAT ≥ 0 冲突,合规主机 100% 失效 |
d2(地址比对,一轮后仍在用) |
之前 0 … 29 ns | 几乎无裕量,靠“下降沿之前”侥幸正确 | 薄 |
d4(移位寄存器 / 修复后的判据) |
之前 57 … 86 ns(SCL 高电平期内) | 只要求 SDA 在下降沿前约 86 ns 已稳定 | 即规范的 t_SU;DAT(250 ns 量级),合规主机都满足 |
判据不是“改完跑过了”,而是“改完之后从机不再要求主机做规范之外的动作”。
4.5 修法与复验
// 第一轮:R/W 判据 + RDDATA 续传
194: else if (bit_cnt == 4'd8 && ~sda_i_sync_d4) i2c_ns = RDDATA;
266: else if (cs == SLV_ADDR_WR && bit_cnt == 4'd7 && sda_i_sync_d4 == 1'b0) wr_op <= 1;
282: else if (cs == SLV_ADDR_RD && bit_cnt == 4'd7 && sda_i_sync_d4 == 1'b1) ...
// 第二轮:地址 A0 位比对,统一采样点
223: if (bit_cnt == 4'd6 && {p_dat[5:0], sda_i_sync_d4} == addr)
以最苛刻的 t_HD;DAT = 0 为主判据复验:
| 用例 | 内容 | 结果 |
|---|---|---|
mon |
写 + 读全流程 + 采样点探针 | PASS |
wr_ts |
t_HD;DAT 16 点(625 → 0 ns) |
PASS 192 项,每点失败数 0 |
thd0 |
THD=0,100 k ~ 1 MHz 共 8 个速率 | PASS 96 项 |
thd0_rd16 |
THD=0,连读 20 字节(含 0xFF 回卷) | PASS |
thd0_ph |
时钟相位扫 8/15 个点 | 全 PASS |
addr_a0 |
从机地址 A0=1(地址字节 0x42/0x43),15 个相位点 | 全 PASS |
回归 wr_hold / rd_basic |
旧“主机保持 SDA”用法 | PASS → 没改坏旧用法 |
5. Bug B:未选中地址也 ACK,一个门控修掉
SLV_ADDR_WR 第 8 拍时 sda_oe/i2c_ack 无条件拉低,不判 dev_chk_pass。多从机总线上,未选中设备必须 NACK,否则主机误判“设备存在”。
修法(一行门控):
if (bit_cnt == 4'd7 && (((cs == SLV_ADDR_WR) && dev_chk_pass) ||
cs == REG_ADDR || cs == WRDATA ||
cs == RESTART || cs == SLV_ADDR_RD))
sda_oe <= 1'b1;
复验除了原来的地址不匹配用例,另加一张地址译码表(DUT 7 位地址 0x20,逐字节发 START+地址+看 ACK):
| 主机发送 | 0x40 | 0x41 | 0x42 | 0x43 | 0x20 | 0x21 | 0x60 | 0x61 | 0xA0 | 0xA1 | 0xBF | 0xFE |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 期望 | ACK | ACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK |
| 实测 | ACK | ACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK | NACK |
12/12 正确,包括“A0 位只差一位”的 0x42/0x43。全量回归 21 个用例:16 PASS,未通过的 5 个正是尚未修的 C/D/E/F 加一个预期失败的偏斜用例——这就是“没有回归”的证据形态。
一个遗留的不一致:i2c_ack 寄存器本身仍无条件下拉(它只决定 FSM 走 SLV_ADDR_WR → REG_ADDR/IDLE 的分支,不驱动总线)。外部行为已经对了,但内部状态与总线不一致;要不要一起门控,属于“可选统一”,不是 bug。
6. 仍未修的四个:现象、机理、判据
| 编号 | 实测现象 | 机理 | 建议修法 |
|---|---|---|---|
| C | restart + 非本机读地址 0xA1:从机 ACK 并驱动出 mem 数据(实测返回 0x3C) |
读地址阶段只判 i2c_ack 不判 dev_chk_pass,而 dev_chk_pass 又在 START 时被清零 |
读地址阶段同样门控 dev_chk_pass |
| D | 写数据 → restart → 写地址(组合事务):从机无条件进 RDDATA,699 个采样点非法驱动,主机的寄存器地址字节被 NACK | RESTART → SLV_ADDR_RD 固定跳转,不看重启后首字节 R/W |
重启后先按地址字节解析,再按 R/W 决定去向 |
| E | SCL 上 40 ns 低脉冲(高电平期间):出现提前 ACK 和一次虚假写 mem[0x40] <= 0xA0,事后 bit_cnt 残留在 3 |
边沿只要被 2 级同步打到(≥ 约 29 ns)就 bit_cnt + 1,无毛刺滤波 |
用 3 拍稳定确认边沿,或对 SCL 加与 SDA 同等的抗毛刺处理(规范本来要求 50 ns 级抑制) |
| F | 复位后直接发读地址 0x41:地址 ACK 正确,但读回 0xFF,内部指针被改成 0xFF | 地址字节阶段“只要地址匹配就去寄存器地址阶段”,不看 R/W → 随后的 8 个读时钟被当成寄存器地址解析 | 地址匹配后按 R/W 分流;或在文档里明确只支持“写地址 + 寄存器地址 + [重启 + 读地址]” |
对 C/D 要额外注意:单从机系统里它们只是浪费,挂多颗器件时就是真实的总线冲突——会把本机数据驱动到别人的地址上,并破坏对其它器件的访问。
7. 顺手发现的三个非 bug 风险点
sda_o是纯组合输出(由寄存器堆读数据直接选择),读数据没有输出寄存 → 从寄存器堆读延时到布线的总和必须落在半个位周期内;建议在 SCL 低电平期把读数据打一拍出去。- 一个内部读使能信号只产生了、没引出端口,是死逻辑。
- 4 级同步链路约 86 ns 的延时补偿与时钟频率硬绑定:一旦时钟不是 70 MHz,所有采样时刻常数都要重算。建议参数化,或改写成“在 SCL 高电平中点采样”这种不依赖绝对延时的形式——这属于结构性问题,不是这次 bug 的修补范围。
8. 过程中踩的坑(流程,不是 RTL)
| 坑 | 现象 | 处理 |
|---|---|---|
| 环境变量 | 直接跑脚本报工具找不到 | 先 module load vcs/2023 verdi/2023,或显式指定工具安装路径 |
| 错误归因 | 一开始以为是总线速率太快 | 用“主机一直保持 SDA”的对照用例排除速率/相位,锁定“何时释放” |
| 探针误导 | [MON] 打印的时刻比判决拍晚一个时钟 |
按判决拍算裕量,并在笔记/报告里写清两者差 1 个时钟 |
| 只改一半 | 第一轮只改了 3 处判据,漏掉地址比对处 d2(裕量只有 0~29 ns) |
第二轮把 4 个采样点全部统一为 d4,再全量复跑 |
| 门控不彻底 | 修 B 后总线行为对了,内部 i2c_ack 仍无条件下拉 |
明确记录为“外部正确、内部待统一”,不当作已修 |
| 报告可读性 | 终端/emacs 里看 md 表格对不齐 | 写了个 md→HTML 转换脚本(表格带边框),源 md 一改就重生成 |
9. 可复用清单(慢速串行从机验证)
- 先审 BFM:会不会一直是“友好主机”(持续驱动、单一速率、无偏斜)。
- 把所有主机时序参数化,尤其
t_HD;DAT/t_SU;DAT要扫到规范下限。 - 三个探针一个都不能少:记分板(比“写进去没有”)、非法驱动(判总线冲突)、采样时刻(判裕量)。
- 结论写成裕量账:把仿真时刻换算到总线侧相对 SCL 下降沿的窗口,与规范参数对表;不要只写“改完通过了”。
- 每个“失败”都要配一个对照用例,用来排除最容易被误信的那个错误归因。
- 修一处,跑相关用例 + 全量回归一次;用“总数 / PASS / FAIL 且 FAIL 都能解释”来声明没有回归。
- 明确哪些失败是合法主机行为之外的(例:SDA 在 SCL 高电平期间变化本身就是 START/STOP,不算 bug),否则会被当成噪音淹掉真问题。
10. 数字速查
| 项 | 值 |
|---|---|
| 裸输入采样点 | SCL 下降沿后约 112 ns ≈ 4 个时钟 |
失效临界 t_HD;DAT |
110~115 ns 之间(115 过、110 失败) |
| 原采样窗口 | 下降沿后 +86 … +114 ns |
改后采样窗口(d4) |
下降沿前 57 … 86 ns |
| 回归规模 | 21 个用例,16 PASS,5 FAIL(4 个未修 + 1 个预期失败) |
| 地址译码表 | 12/12 正确 |
| 非法驱动计数(D/E) | 699 个采样点 / 88 个采样点 |
11. 术语表
t_HD;DAT(data hold time):SCL 下降沿之后数据必须保持的时间,规范下限 0 ns。本文最核心的那个参数。t_SU;DAT(data setup time):数据在 SCL 上升沿之前必须稳定的时间(标准/快速模式 250 ns 量级)。- current address read:不带寄存器地址字节、直接读“当前指针”位置的事务。
- repeated START:不发 STOP、直接再发一个 START 切换方向或换目标。
- combined transaction:写 + repeated START + 写/读 的组合事务,I2C 允许。
- open-drain / 线与:总线只有“拉低”和“释放”两态,多设备只能拉低,靠上拉拉高。
- 过采样:用远高于总线速率的高频时钟采样 SCL/SDA,靠同步链定位采样点;优点是省时钟域,代价是采样时刻与时钟频率绑定。
- 2 级同步 / 4 级延时补偿:跨时钟域同步链,级数决定采样点相对边沿的位置,也就是本文的裕量来源。
- scoreboard(记分板):把 DUT 输出与预期逐条比对的检查器,“写进去没有”要靠它证明。
- BFM(bus functional model):testbench 里模拟主机的模型;它的“习惯”会决定 DUT 的哪些缺陷能被看见。