# I2C slave 仿真复盘：6 个 Bug、2 处修复与一套可复用验证方法

一次 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 永远不触发。

```systemverilog
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             |

三个观测手段是这次能定性定量的关键：

1. **记分板**：把 DUT 实际驱动出的寄存器写（`wr_en` 边沿采样）与预期写序列逐条比对——地址、数据、顺序、条数。这样“一个寄存器都没写进去”是判据，不是印象。
2. **非法驱动探针**：`sda_oe=1` 若出现在读数据/ACK 窗口之外就打印 `[ILLEGAL-DRIVE]`，判总线冲突，并按采样点计数（用例里出现的 699、88 都是这个计数）。
3. **采样点探针**：直接打印 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 修法与复验

```verilog
// 第一轮：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，否则主机误判“设备存在”。

修法（一行门控）：

```verilog
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 风险点

1. `sda_o` 是纯组合输出（由寄存器堆读数据直接选择），读数据没有输出寄存 → 从寄存器堆读延时到布线的总和必须落在半个位周期内；建议在 SCL 低电平期把读数据打一拍出去。
2. 一个内部读使能信号只产生了、没引出端口，是死逻辑。
3. 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. 可复用清单（慢速串行从机验证）

1. 先审 BFM：会不会一直是“友好主机”（持续驱动、单一速率、无偏斜）。
2. 把所有主机时序参数化，尤其 `t_HD;DAT` / `t_SU;DAT` 要扫到规范**下限**。
3. 三个探针一个都不能少：记分板（比“写进去没有”）、非法驱动（判总线冲突）、采样时刻（判裕量）。
4. 结论写成**裕量账**：把仿真时刻换算到总线侧相对 SCL 下降沿的窗口，与规范参数对表；不要只写“改完通过了”。
5. 每个“失败”都要配一个对照用例，用来排除最容易被误信的那个错误归因。
6. 修一处，跑相关用例 + 全量回归一次；用“总数 / PASS / FAIL 且 FAIL 都能解释”来声明没有回归。
7. 明确哪些失败是**合法主机行为之外**的（例：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 的哪些缺陷能被看见。

