Verilog实现I2C从机模块:设计、仿真与上板调试全解析 简介面向在无硬件IIC控制器或需要灵活扩展的嵌入式平台实现从机通信的开发者这份基于通用输入输出GPIO模拟IIC从机协议的代码包提供了一套轻量且可直接落地的方案。压缩包仅2KB内含1个C源文件和1个头文件接口声明与业务逻辑分离代码完整覆盖从机地址自定义、启动与停止条件识别、读写方向判定、逐字节传输应答以及软件模拟时钟等核心机制并预留了中断处理与错误检测的扩展思路。已有411人学习非常适合正在调试IIC从机通信、需要核实时序细节的软硬件工程师。借助该资源可以快速掌握用普通输入输出模拟IIC从机的底层行为在缺少硬件IIC模块的微控制器上也能稳定响应主机读写同时简洁的代码结构便于二次开发可轻松扩展多地址支持、增加超时重试与数据校验从而显著缩短传感器、存储器等IIC外设的接入与调试周期。 拿到这个i2c_slave.zip的时候我第一反应是这年头还有人自己写 I2C 从机后来一想确实有而且很多时候是不得不自己写。做 FPGA 的人应该都有体会MCU 圈子里 I2C 从机几乎是标配外设但到了 FPGA 这边想找一个轻量、可裁剪、又不绑死在某家厂商 IP 核里的 I2C 从机反而不太容易。这个压缩包里打包的就是一个用 Verilog 写的 I2C 从机模块支持标准模式100 kbit/s和快速模式400 kbit/s参数化从机地址带读写寄存器的完整时序还带了一个基本的仿真测试平台。它能解决什么问题就是当你的 FPGA 需要被外部 MCU、树莓派或者其他主控芯片通过 I2C 接口读写寄存器、下发配置、上报状态时你不需要去翻厂商手册啃 IP 核文档直接把这份代码拉进去改改参数就能用。适合谁适合正在做 FPGA 嵌入式联调、想搞懂 I2C 从机协议细节、或者被公司 IP 核授权卡脖子想找一套开源替代方案的朋友。下面我把这个项目的设计思路、协议细节、核心代码实现和调试经验一次性讲透。1. 项目整体设计与方案选型1.1 为什么不用厂商 IP 核非要自己写很多 FPGA 开发者在需要 I2C 从机时第一反应是用 Vivado 里的 AXI IIC IP 核或者 Quartus 里的对应 IP。这在资源充足、时间紧迫的项目里没有任何问题但有几个情况会让人特别难受。首先是地址灵活性。厂商 IP 核往往把从机地址固定成某个参数你要换地址得重新生成 IP重新综合整个流程走一遍十几分钟没了。自己写的话地址就是一个寄存器输入或者一组参数改完直接重新编译甚至可以在线改明显灵活得多。其次是资源开销。Xilinx 的 AXI IIC IP 核带 AXI 接口、中断控制器、FIFO一套下来几百个 LUT 起步如果你只需要一个简单的从机寄存器组这个开销有点浪费。自己写一个精简状态机一百多个 LUT 就能搞定占用的资源几乎可以忽略不计。最后是学习价值。说实话把 I2C 从机协议自己完整实现一遍你对这个总线的理解会上一个台阶。以后遇到总线挂死、ACK 异常、时序违规这类问题排查起来思路会清晰很多因为你清楚每一根信号线在每个时钟周期应该是什么状态而不是对着 IP 核的黑盒文档瞎猜。1.2 方案选型的关键考量这个项目的核心决策有三个决策一用状态机还是用计数器硬凑时序。状态机是标准答案。I2C 协议本质上是事件驱动的SCL 上升沿采数据下降沿换数据START 条件、STOP 条件都是边沿事件。状态机天生适合表达这种逻辑状态转移清晰时序收敛容易。计数器方案在实现 SCL 低电平延展的时候会用到但那是辅助作用不是主线。决策二数据通路怎么设计。我见过不少人把 I2C 从机写成一个大的状态机里面直接操作寄存器数组代码几百行挤在一起看着就头大。这个项目里把数据通路拆成了两块位收发引擎和寄存器管理层。位收发引擎只负责处理 SCL 和 SDA 线上的比特流把收到的字节交给上层上层只管寄存器读写不关心时序。这样拆的好处是你想把 8 bit 地址改成 10 bit 地址只需要改位收发引擎想加寄存器只需要改管理层互不污染。决策三要不要支持时钟延展Clock Stretching。这个问题我在设计时犹豫了一下。时钟延展是从机可以拉低 SCL 让主机等待的机制用于慢速从机处理数据。标准模式下主机通常不要求从机做这个但如果你对接的主机时序很紧或者你的寄存器逻辑比较复杂需要额外时钟周期处理时钟延展就是保命功能。最终我选择支持但通过参数打开默认关闭因为它需要额外两三个状态和一小段时序逻辑会轻微增加资源占用。2. 核心细节解析与 I2C 从机协议要点2.1 协议基础状态梳理I2C 总线就两根线SCL时钟和 SDA数据但这两根线承载的逻辑不少。从机眼里总线状态可以归纳为几种事件START 条件SCL 高电平时 SDA 下降沿、STOP 条件SCL 高电平时 SDA 上升沿、数据位SCL 高电平时 SDA 必须保持稳定、ACK/NACK第 9 个时钟周期 SDA 由接收方控制。这里有个很容易踩的坑SDA 的上跳沿和下跳沿只有在 SCL 为高的时候才是有意义的协议事件。SCL 低电平时 SDA 怎么变都不算数只是总线在准备下一比特。很多第一次写从机的人会直接在posedge SDA或者negedge SDA上采逻辑结果状态机乱跳什么都是错的。正确做法是所有协议事件都以 SCL 的边沿为基准采样SDA 的电平变化只是被 SCL 边沿捕捉后的结果。2.2 从机地址匹配与读写方向判定主机发出 START 条件后紧接着发送的第一个字节高 7 位是从机地址最低位是读写方向位。从机需要在这种 8 bit 地址字节收到一半时就开始判断这个地址是不是我的如果是我在第 9 个时钟周期回 ACK如果不是我保持高阻态不回 ACK总线上的上拉电阻会把它拉成 NACK。这里面有个细节值得注意地址匹配的比较时机应该在 SCL 第 8 个下降沿之后因为第 8 个 bit读写位到达后从机才能确认整个地址字节完整了。比较逻辑如果放在第 8 位之前做会导致最后一比特的地址比较结果还没稳定就开始输出 ACK形成时序冒险。另外提一句 10 bit 寻址虽然不常用但原理不难先比对第一个字节是 11110XX 读写位若是写方向主机还会再发一个高字节地址。想支持的话在地址比较状态里加一层状态就行。2.3 读写操作的状态流转以 7 bit 地址从机为例复位后状态机处于空闲态IDLE此时 SDA 和 SCL 都应被外部上拉电阻拉高。当检测到 START 条件进入ADDR状态开始逐个采样地址位。地址匹配且方向位为写0时进入WRITE_DATA状态逐个接收数据字节每收完一字节由从机回 ACK方向位为读1时进入READ_DATA状态从机把寄存器数据逐位移到 SDA 上每发完一字节由主机回 ACK如果主机回了 NACK说明这次读操作结束从机回到等待 STOP 条件的状态。无论读写只要出现 STOP 条件状态机都必须回到IDLE并且把所有内部指针当前字节位索引、寄存器地址指针全部清零。这个任何状态遇到 STOP 都要能回到 IDLE的要求是很多初版代码的失败点——状态机只在正常路径下考虑回退一旦主机在数据中间发了个 STOP整个状态机就卡死了总线再也不能通信只能复位 FPGA。2.4 时钟延展的设计取舍时钟延展的本质是从机发现自己来不及处理数据时把 SCL 拉低让主机闭嘴等着。从机的 SCL 是输入输出双向口开漏输出的好处就在这里——谁都能拉低谁都不能强行拉高。实现时钟延展通常有两种做法。一种是直接拉低 SCL在从机进入某个需要额外时钟周期处理的状态时强制把 SCL 输出为低处理完了再释放。这个做法在 FPGA 里有个问题就是 SCL 作为输入和 output enable 的切换时序要处理干净否则容易产生毛刺。另一种是数据节拍延展检查到整个字节的 ACK 需要延迟进行时用内部时钟在 SCL 低电平持续期插入等待周期。我最终采用了后者因为它对时序收敛更友好代码也更稳定。3. 实操过程Verilog 实现 I2C 从机3.1 模块接口定义直接看代码。这是从机模块的顶层接口module i2c_slave #( parameter SLAVE_ADDR 7h28, // 从机地址 parameter REG_WIDTH 8, // 寄存器位宽 parameter REG_DEPTH 16, // 寄存器数量 parameter CLK_FREQ 50_000_000, // 用于时钟延展的内部时钟频率 parameter ENABLE_STRETCH 0 // 是否启用时钟延展 ) ( input wire clk, // 内部系统时钟 input wire rst_n, // 异步复位低电平有效 inout wire scl, // I2C 时钟线 inout wire sda, // I2C 数据线 output reg [REG_WIDTH-1:0] reg_out [0:REG_DEPTH-1], // 寄存器读出总线 input wire [REG_WIDTH-1:0] reg_in [0:REG_DEPTH-1], // 寄存器写入总线 output reg wr_strobe, // 寄存器写入完成信号 output reg rd_strobe // 寄存器读取完成信号 );接口设计上我把寄存器的读写通过组合总线暴露给上层逻辑wr_strobe和rd_strobe各拉高一个时钟周期方便上层逻辑做触发。如果用 AXI-Lite 或者 Wishbone也可以封装一层转换但大部分简单场景下这套组合读写接口够用了。3.2 核心状态机实现核心逻辑是下面的状态机。为了代码清晰我把 SDA 的分流逻辑分成了输入和输出两条通路这里展示主要框架localparam IDLE 4d0; localparam START_DET 4d1; localparam ADDR_BITS 4d2; localparam ADDR_ACK 4d3; localparam WR_DATA 4d4; localparam WR_ACK 4d5; localparam RD_DATA 4d6; localparam RD_ACK 4d7; localparam STOP_DET 4d8; reg [3:0] state, next_state; reg [3:0] bit_cnt; reg [6:0] addr_shift; reg [7:0] data_shift; // 边沿检测SCL 和 SDA 的上升/下降沿 wire scl_negedge ...; // SCL 下降沿检测 wire scl_posedge ...; // SCL 上升沿检测 wire start_event ...; // SCL 高时 SDA 下降沿 wire stop_event ...; // SCL 高时 SDA 上升沿 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; end else begin case (state) IDLE: begin if (start_event) state ADDR_BITS; else state IDLE; end ADDR_BITS: begin // 每个 SCL 上升沿采一 bit共 8 bit if (scl_posedge) begin addr_shift {addr_shift[5:0], sda_in}; if (bit_cnt 3d7) begin bit_cnt 0; state ADDR_ACK; end else bit_cnt bit_cnt 1b1; end end ADDR_ACK: begin // 在第9个SCL周期判断ACK if (addr_shift[7:1] SLAVE_ADDR) begin if (addr_shift[0]) state RD_DATA; // 读请求 else state WR_DATA; // 写请求 end else state IDLE; // 地址不匹配回到IDLE end WR_DATA: begin // 收数据字节每字节收完进 WR_ACK 回 ACK end WR_ACK: begin if (stop_event) state IDLE; else state WR_DATA; end RD_DATA: begin // 发数据字节每字节发完等主机 ACK/NACK end RD_ACK: begin if (stop_event || sda_in) state IDLE; // 主机 NACK收工 else state RD_DATA; end default: state IDLE; endcase end end上面是状态转移的主干实际代码里还有位计数、移位寄存器的使能控制、ACK 输出逻辑。重点说两个容易被忽略的点。第一SDA 是双向口输出使能必须精细控制。在从机驱动 ACK 或者读数据位的时候sda_en拉高此时 SDA 由从机驱动其他时刻比如读地址阶段、总线空闲阶段sda_en拉低SDA 释放为高阻靠上拉电阻维持高电平。如果sda_en时机打错一拍就会出现总线短路级别的现象——主机发数据从机同时驱动 SDA两边打架数据全乱。这种问题逻辑仿真基本测不出来上了板子用示波器才能看到而且特别难查。第二进入 RD_DATA 后第一次数据传送要特殊处理。读请求的第一个字节寄存器地址是哪来的呢按标准协议主机如果要读某个寄存器的值通常是先发一个写请求带寄存器地址再发一个重复 START变成读请求。也就是说从机在读数据之前要先暂存上一次写请求里收到的寄存器地址。这个地址寄存器 读写切换的逻辑是 I2C 从机最容易漏掉的部分很多初版代码直接读空寄存器变成读全零。3.3 仿真验证思路与测试要点写完 RTL 不能直接上板先仿真。这个压缩包里带了一个i2c_slave_tb.v我照着它说下验证思路。仿真平台要生成一个主机的行为模型发起 START发送地址 方向位按写波形发数据按读波形接收数据并对应给出 ACK 和 STOP。没有必要用 VIP验证 IP直接用任务task写主机的时序生成就行几十行代码搞定。关键测试用例有五个基本写操作主机写 3 个字节数据验证寄存器数组里对应的值正确写入wr_strobe每次写完成都拉高一个周期。基本读操作主机先写一个寄存器地址再发起读请求验证读出的值和预置的数据一致。地址不匹配主机发送一个和从机地址不同的字节验证从机不回 ACK状态机回到 IDLE不产生任何寄存器操作。START/STOP 乱序在数据字节中间插入 STOP验证从机能正确回到 IDLE不锁死。时钟延展场景如果开启该参数主机连续读写密集数据验证从机在需要延展时把 SCL 拉低的时序是否正确。第五个测试用纯 Verilog 写比较麻烦必要时用 Icarus Verilog 配合模拟 SCL 延时来做。实测下来时钟延展逻辑如果开了仿真里特别容易暴露问题——主机模型如果不支持等待从机拉低 SCL整个时序就乱了。这也是我默认把它关掉的原因之一。4. 常见问题与排查技巧实录4.1 上板调试时遇到的两个经典问题这个项目在调试阶段我踩过两个坑写出来供大家参考。第一个是从机不响应地址逻辑分析仪看 SDA 上没有任何 ACK。排查了半天最后发现是复位逻辑的问题复位时sda_en没有初始化成高阻状态导致 FPGA 上电后一直强力驱动 SDA 为低主机看到的 SDA 被拉死在地线上自然无法启动任何通信。解决办法是让sda_en在复位期间就输出 0高阻模式并且在状态机进入 IDLE 前强制释放 SDA这个初始化时序写在always (posedge clk or negedge rst_n)块里复位时sda_en 1b0;。第二个是SCL 上出现毛刺导致状态机误判 STOP。因为 SCL 是开漏总线如果板子上没有接上拉电阻或者上拉阻值选得太大比如用了 100 kΩ正常应该用 2.2 kΩ 到 10 kΩSCL 的上升沿会变得很平缓在 FPGA 输入缓冲的阈值附近来回抖动。当 SCL 和 SDA 同时处于高电平区间时SDA 的一个毛刺会被误认为 START 或 STOP。后来我把上拉电阻换成 4.7 kΩ这个问题就消失了。所以排查 I2C 问题先拿示波器看波形确认 SCL/SDA 的高电平和边沿质量再动逻辑。4.2 常见问题速查表现象可能原因排查方向从机不回应任何地址上拉电阻缺失或过大万用表量 SCL/SDA 静态电平确认空闲时为高地址匹配成功但数据写不进去sda_en时序提前抢占了主机数据线用仿真看第 9 个 SCL 周期 SDA 由谁驱动读出的数据全为零读操作前没缓存写操作里的寄存器地址检查地址暂存逻辑确认在 STOP 前保存了地址通信一段时间后总线卡死某个非正常状态下漏了 STOP 回 IDLE 的转移分支状态机检查标准每个状态遇到 STOP 都要能回 IDLE连上后第一个字节就 CRC 错如果用了 CRC地址比较在 SCL 第 8 个下降沿之前完成确认比较逻辑的触发沿必要时打一拍寄存器时钟延展开启后主机报超时主机驱动不支持从机拉低 SCL关闭延展或换支持延展的主机驱动4.3 调试技巧与独家经验最后分享几个独门的调试习惯。第一个仿真里嵌断言assert而不是肉眼盯波形。我习惯在 testbench 里写一个小的监控模块每次检测到 STOP 条件时就自动检查上一次写操作的寄存器和预期值是否一致不一致立刻报错。这样可以大批量跑随机测试不用每轮都打开波形图去一帧一帧看。第二个逻辑分析仪不够用的时候直接在 FPGA 里做 逻辑探针。用两个 GPIO 引脚把状态机的编码值引出来做成二进制输出接到台式示波器或者逻辑分析仪上看。这样能快速确认状态机是否卡在某个循环、状态跳转是否符合预期。我经常在调试阶段保留这个探针逻辑等验证稳定了再删掉省去反复综合的时间。第三个I2C 协议信号进入 FPGA 前最好加两级同步器。虽然是 I2C 本身是异步接口但状态机跑在系统时钟域下异步输入的亚稳态风险必须处理。SDA 在进模块后先打两拍SCL 检测边沿用的也是同步后的信号。同步器引入的延迟会让协议时序相对滞后几个时钟周期但只要不是跑超高速模式1 MHz 以上完全不影响功能。这次把i2c_slave.zip里的从机模块从设计思路一直讲到调试经验核心的代码框架、状态转移、测试用例和避坑点都覆盖到了。我在实际使用中发现自研 I2C 从机最大的价值不只是省掉了 IP 核授权而是当你面对一个不按常理出牌的主机时你能直接打开 RTL 代码针对主机的行为模式做适配而不是拿着门级网表干瞪眼。如果你也准备在项目里用这个模块我强烈建议先跑完那五个基本测试用例再上板特别是地址不匹配和中途 STOP这两个用例它们能救你几个晚上的调试时间。本文还有配套的精品资源点击获取

相关新闻

最新新闻

8051+Proteus仿真100例:从点灯到总线的单片机学习路径

8051+Proteus仿真100例:从点灯到总线的单片机学习路径

简介:一套围绕《单片机C语言程序设计实训100例——基于8051Proteus仿真》整理的案例压缩包,面向8051单片机学习者和Proteus仿真爱好者,覆盖从入门到进阶的典型实验,适合在缺少实体开发板的情况下完成程序逻辑验证与调试。资源以RA…

2026/9/2 2:47:54
Chord源码阅读指南:掌握DHT与一致性哈希的工程实现

Chord源码阅读指南:掌握DHT与一致性哈希的工程实现

简介:这份Chord源码分析包面向分布式系统学习者与P2P网络开发者,以C实现斯坦福大学提出的Chord算法,完整展现DHT环形空间、节点查找与数据存储机制,是理解对等网络核心思想的良好素材。压缩包共48个文件,以22个C源文件…

2026/9/2 2:47:54
Agent Skill赋能课题设计:从流程封装到科研效率提升

Agent Skill赋能课题设计:从流程封装到科研效率提升

用 Agent Skill 做课题设计,重点不是让 AI 替你拍板研究什么,而是把一套可复用的课题设计流程封装成技能,让 AI 在收到你的选题和背景材料后,按固定方法输出结构化的设计初稿。这个思路比较适合研究生、高校青年教师、科研助理&am…

2026/9/2 2:47:54
基于Python与FFmpeg的自动化视频剪辑:从数据处理到工程化创作

基于Python与FFmpeg的自动化视频剪辑:从数据处理到工程化创作

如果你是一位关注中国乒乓球多年的开发者或技术爱好者,最近在技术社区里,可能会发现一个有趣的现象:一些看似与代码无关的“跨界”内容,比如体育赛事的混剪视频,正在成为技术博主们分享创意、展示工具链和探讨数据处理…

2026/9/2 2:47:54
Python面向对象编程:从代码混乱到清晰组织的实战指南

Python面向对象编程:从代码混乱到清晰组织的实战指南

你有没有过这样的经历:写了几百行 Python 脚本,一开始跑得挺顺,但随着功能越加越多,代码开始变得像一团乱麻?改一个地方,三个地方报错;加一个新功能,得把老代码翻个底朝天。变量名冲…

2026/9/2 2:47:54
SaaS产品如何实现用户自定义功能:Vendo架构与React低代码实践

SaaS产品如何实现用户自定义功能:Vendo架构与React低代码实践

如果你正在开发一个SaaS产品,是否曾面临这样的困境:用户总是提出五花八门的定制化需求,从简单的字段调整到复杂的业务流程集成。你的团队疲于应付,要么拒绝用户导致流失,要么投入大量研发资源,最终产品变得…

2026/9/2 2:42:54