
简介FPGA入门学习者常需掌握IIC这类低速总线接口这份Verilog工程代码恰好提供完整参考。资源定位于FPGA与EEPROM之间的字节级读写访问包含IIC主控模块、仿真testbench及基于Quartus的完整工程文件适合初学者对照理解起始停止条件、7位地址、ACK机制与时钟分频等关键知识点。压缩包共85个文件以cdb、hdb、rpt等Quartus工程辅助文件为主同时保留核心的.v源码、.v.bak备份、modelsim仿真脚本及README说明整体约390KB轻量易下载。已有3469人学习浏览说明该示例对入门群体具备一定参考价值。通过学习这份代码可直观掌握IIC时序设计、EEPROM命令序列构造以及仿真验证的完整流程便于后续扩展多设备支持或优化通信速率。 做FPGA的人迟早要和IIC打交道。不管你是驱动摄像头 sensor、读 EEPROM 里的校准参数还是跟 MCU 联调IIC 都是出镜率最高的低速总线之一。我之前写过一个 eMMC 的项目其中一路寄存器配置就得靠 IIC 完成但真正静下心来把 IIC 主机模块从头到尾用 Verilog 重写一遍是在一个数据采集卡项目里。当时板子上的 ADC、温度传感器、DAC 全部挂在同一条 IIC 总线上不同器件地址不同还要分别配置用现成的 IP 核反而不方便最后干脆自己写了一个参数化的 IIC 主机。这篇文章我就把整个思路、代码、仿真和坑都梳理一遍希望能帮到正在折腾 FPGA IIC 的你。其实 IIC 本身不难难的是把时序吃透并处理好 SDA 方向切换、ACK 检测、时钟分频这类看似不起眼、实则决定成败的细节。本文内容适合两种人一是刚入门 FPGA、想通过 IIC 串起“时序概念 状态机 仿真验证”这一整套知识链的初学者二是项目里需要灵活定制 IIC 时序、想摆脱对 IP 核依赖的进阶开发者。我会从协议要点、代码实现、仿真验证到实战排错完整讲一遍代码可以直接抄但建议你边抄边想因为 IIC 的很多设计取舍抄完一遍才算真正理解。1. 为什么在 FPGA 上要自己写 IIC而不是直接调 IP 核后端工程师开 FPGA 项目第一反应一般是“IP 核有没有现成的”。Xilinx 有 AXI IICIntel 有 I2C 核Altera 时代的 megafunction 也带。但我个人建议除非是纯 AXI 总线环境、完全不想碰时序细节否则自己用 Verilog 写一个 IIC 主机往往更划算。原因是多方面的。1.1 IP 核的“黑盒”在项目里并不好用IP 核最大的问题是制造了额外的复杂度。它把协议封装成了寄存器接口比如你要发起一笔传输得先查手册知道往哪个寄存器写控制字、往哪个寄存器写数据、什么时候读状态标志。这个学习成本其实比我自己写一个状态机要高。而且 IP 核带一堆 AXI 接口逻辑综合资源不见得少配置起来还得选时钟频率、滤波参数、地址位宽选错了又得回头翻手册。有个项目里我要同时控制三路 IIC 总线每一路速率都不一样一个 IP 核实例化三遍还要配三套 AXI光是配置脚本就够折腾的。还有一个更现实的理由IP 核不好改。比如某个从器件要求 SCL 高电平时间稍微拉长一点或者从机在 ACK 之后要求主时钟加一个很小的延时才能发下一次 SCL这些对时序的要求很微妙。用 IP 核时这些参数通常不开放给你调自己写就不一样了改一个计数器数值的事。1.2 自己写 IIC 主机的收益自己写的好处最直接的一点是代码完全可控。整个状态机摆在面前SDA 什么时候输出、什么时候释放、SCL 什么时候拉高、什么时候拉低每一步都可以精确到时钟周期。调试时如果波形不对打开波形文件对照代码一步步追很快就能定位。用 IP 核遇到问题你只能猜是配置错了还是从机没响应还是总线竞争排查范围大一圈。另一个收益是代码可以参数化复用。把器件地址、寄存器地址、写数据/读数据这些参数全部做成端口一个模块就能通吃板子上所有 IIC 从设备。像我们那个项目温度传感器、频率合成器、光模块的寄存器配置全用这一个模块只是实例化时拉了不同的地址和数据。这在项目后期迭代、换器件、改配置时特别重要改一行实例化的参数就行主模块不用动。所以我的结论很明确学习阶段和中小规模项目自写 IIC 是值得的只有当项目里总线数量极多、且时间紧到没空调试底层时序时才考虑 IP 核。2. IIC 协议核心机制速览看懂时序图的三个关键写代码前必须把协议“刻”在脑子里。IIC 和 SPI、UART 最大的区别是它只有 SCL 和 SDA 两根线但两根线上既要传地址又要传数据还得分时控制读写方向。理解下面三件事代码逻辑就自然浮现了。2.1 起始条件、停止条件、数据有效性总线空闲时SCL 和 SDA 都是高电平。主设备想发起传输要先拉低 SDA然后拉低 SCL这个“SDA 在 SCL 为高时由高变低”的动作叫起始条件 START。反过来数据传完后主设备先拉高 SCL再在 SCL 为高时把 SDA 拉高这个“SDA 在 SCL 为高时由低变高”的动作叫停止条件 STOP。这两个条件都要求 SDA 的变化发生在 SCL 为高电平期间这一点和普通数据传输时 SDA 必须在 SCL 低电平期间变化完全不同是新手最容易搞混的地方。数据有效性规则也很简单SDA 上的数据必须在 SCL 为高电平期间保持稳定在 SCL 为低电平期间才能切换。换句话说SCL 拉高的时候从机采样 SDASCL 拉低的时候主机才能改变 SDA。这在 Verilog 实现里意味着每一次 SDA 赋值都要卡在 SCL 低电平期间否则就可能产生错误的边沿。2.2 地址帧、ACK/NACK 与读写位IIC 传输以字节为单位。主设备发完起始条件后先发一个地址字节其中高 7 位是从机地址最低位是读写控制位——为 0 表示写为 1 表示读。地址字节发送完成后SCL 释放从机如果在总线上且地址匹配会在第 9 个时钟周期把 SDA 拉低作为 ACK 应答。如果没有从机应答SDA 保持高电平就是 NACK这时主机应该主动发送停止条件结束传输。写数据的过程主发地址写方向→ 从机 ACK → 主发寄存器地址 → 从机 ACK → 主发数据字节 → 从机 ACK → 主发停止条件。读数据的过程稍微绕一点主发地址写方向→ 从机 ACK → 主发寄存器地址 → 从机 ACK → 主发重复起始条件 → 再发地址读方向→ 从机 ACK → 此时数据方向翻转从机开始往 SDA 上放数据主机负责拉高或释放 SCL → 主机如果还想继续读下一字节就在第 9 个周期拉低 SDA 发送 ACK如果只想读一字节就保持 SDA 为高发送 NACK → 主机发停止条件。初次写 IIC 的人最容易在这一步卡住读操作时第 9 个时钟的 ACK/NACK 是由主机发出的方向正好反转了一次不仔细会把自己绕晕。2.3 时序参数不是“拍脑袋”而是有标准速率约束的IIC 的标准速率有 100 kbit/s 普通模式、400 kbit/s 快速模式和 1 Mbit/s 快速模式。具体到时序图上最重要的几个参数是SCL 半周期时间、起始条件建立时间tSU;STA、起始条件保持时间tHD;STA、停止条件建立时间tSU;STO、数据建立时间tSU;DAT、数据保持时间tHD;DAT。比如 100k 模式下SCL 半周期大约 5 微秒400k 模式下大约 1.25 微秒。写代码时用系统时钟分频出一个合适位周期的使能信号再在这个基础上细分出 SCL 高、低电平区间是最常见的做法。我给你的建议是代码里不要直接写死 SCL 频率而是设计一个“时钟分频系数”参数比如 sys_clk 50MHz想要 100k 速率位周期是 10 微秒500 个时钟周期想要 400k 速率就是 125 周期。把分频系数暴露成参数仿真时用大分频系数看波形上板时再改成实际值非常方便。3. 完整版 IIC 主机模块Verilog 代码与状态机设计下面这段代码是我在实际项目中用过的去掉了业务细节只保留核心的 IIC 主机框架。它支持单字节写、单字节读带参数化时钟分频代码风格是“一个进程一个状态”的经典写法方便移植和修改。我先把框架放出来然后再逐段解释为什么这么写。3.1 模块接口与参数设计module i2c_master #( parameter SYS_CLK_FREQ 50_000_000, // 系统时钟频率单位 Hz parameter I2C_RATE 100_000, // IIC 总线速率单位 Hz parameter ADDR_WIDTH 8, // 器件地址位宽7 或 10 parameter REG_WIDTH 8 // 寄存器地址位宽 )( input wire clk, input wire rst_n, // 用户接口 input wire start, // 启动一次传输高有效 input wire rw, // 1: 读操作0: 写操作 input wire [ADDR_WIDTH-1:0] dev_addr, // 从机器件地址 input wire [REG_WIDTH-1:0] reg_addr, // 寄存器地址 input wire [7:0] din, // 写数据 output reg [7:0] dout, // 读数据 output reg done, // 一次完整传输完成 output wire i2c_scl, inout wire i2c_sda );接口里我刻意把“器件地址、寄存器地址、数据”分开了这样的好处是上层逻辑不用去拼地址字节。真实项目里往往是一长串寄存器列表要顺次配置比如 sensor 初始化十几页寄存器写一个简单的状态机按列表逐条调用这个模块就行。参数方面SYS_CLK_FREQ 和 I2C_RATE 简化了不同板子主频不同、不同从机支持速率不同的适配问题。两个参数一除分频值就出来了。3.2 内部信号与状态机状态定义localparam IDLE 3d0, START 3d1, SEND 3d2, ACK 3d3, REG_ADDR 3d4, DATA 3d5, RD_DATA 3d6, STOP 3d7; reg [ADDR_WIDTH:0] addr_buf; // 器件地址读写位 reg [REG_WIDTH-1:0] reg_buf; // 寄存器地址缓存 reg [7:0] data_buf; // 写数据缓存 reg [7:0] bit_cnt; // 位计数0~8 共 9 位 reg [7:0] clk_cnt; // 时钟分频计数 reg scl_en; // SCL 高电平窗口使能 reg sda_dir; // 1: 主输出数据0: 主机释放 SDA reg [3:0] state, next_state; reg scl_o; reg sda_o; assign i2c_scl scl_en ? ~scl_o : 1b1; // 高电平时释放低电平时主动拉低 assign i2c_sda (sda_dir 1b1) ? sda_o : 1bz;SCL 输出我用了一个技巧scl_en 为高时输出反相的 scl_o这样在 scl_o1 时 SCL 实际输出高电平且释放在 scl_o0 时输出低电平scl_en 为低时直接输出高电平。这个做法的意义后面在“SDA 方向切换的细节处理”一节会展开讲。3.3 状态机核心逻辑状态机的设计目标是“每一位占一个稳定的 SCL 周期”。我用 clk_cnt 对系统时钟计数得到 SCL 的周期信号 tick。代码里没有显式画出 SCL 翻转而是通过状态迁移 位计数器来控制。下面是状态迁移的关键部分我把写寄存器的路径和读数据的路径都考虑了always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; clk_cnt 0; bit_cnt 0; sda_dir 1b1; done 1b0; end else begin case (state) IDLE: begin done 1b0; if (start) begin addr_buf {dev_addr, rw}; reg_buf reg_addr; data_buf din; bit_cnt 0; state START; end end START: begin // 产生起始条件SCL 高电平期间SDA 由高变低 if (clk_cnt 0) begin sda_o 1b0; clk_cnt clk_cnt 1; end else if (clk_cnt SP_DIV - 1) begin scl_en 1b1; clk_cnt 0; state SEND; end else clk_cnt clk_cnt 1; end SEND: begin // 发送一个字节地址或数据 if (clk_cnt SP_DIV/2 - 1) begin scl_en 1b0; // SCL 拉低 end if (clk_cnt SP_DIV/2) begin sda_o addr_buf[ADDR_WIDTH]; // 发送最高位 addr_buf addr_buf 1; bit_cnt bit_cnt 1; end if (clk_cnt SP_DIV - 1) begin clk_cnt 0; if (bit_cnt 8) begin sda_dir 1b0; state ACK; end end else clk_cnt clk_cnt 1; end // 其他状态的类似结构省略完整逻辑见下文文字说明这段代码只是个“缩略”版本但关键思想已经体现clk_cnt 从 0 到 SP_DIV-1对应一个完整的 SCL 周期。前半段 SCL 为低后半段 SCL 拉高并在拉高后采样 SDA 上的 ACK。如果把整段写完会是一百五十行左右但核心就是这几个状态之间的切换发地址、收 ACK、发寄存器地址、收 ACK、发写数据/发重复起始读地址、收数据、发 ACK/NACK、发 STOP。我这里并没有贴完整的一百五十行代码因为大多数项目的 IIC 主机状态机本质上就是沿着上面这个骨架走的。网上到处都是完整版但容易“抄完就忘”。我更想强调的是你的状态机里必须有明确的“数据方向切换点”。比如 SEND 状态最后一位发送完SDA 方向要从输出变成输入否则 ACK 检测读到的其实是自己在 SDA 上拉的值而不是从机的真实应答。很多项目“看起来正常但总超时”多半就是这里出的问题。3.4 SDA 方向切换的细节处理这是所有 IIC Verilog 代码里最容易踩坑的地方值得单独强调。SDA 是 inout 端口主机不发数据时要把它置为高阻 z让从机可以把 SDA 拉低发 ACK或拉高发数据。但 IIC 总线上不能出现两个设备同时驱动 SDA 的情况所以方向切换必须发生在 SCL 低电平期间。如果方向切换点落在 SCL 高电平期间SDA 可能被主机和从机同时驱动出现毛刺严重的甚至会把器件烧坏。我的做法是用一个 sda_dir 寄存器标记当前 SDA 方向在状态机里把 sda_dir 的切换点尽量安排在 clk_cnt SP_DIV/2 附近因为这是 SCL 由低转高的前一刻总线正处于低电平窗口。具体来说发送字节时sda_dir 为 1发送完第 8 位在 clk_cnt SP_DIV - 1 时先把 sda_dir 置 0同时进入 ACK 状态ACK 状态结束时如果后续还要发送数据再在 clk_cnt 走到一半时把 sda_dir 置回 1。写读方向时最后一次从机发完数据后sda_dir 也要适时切回 1以便主机发 ACK/NACK 或者 STOP。很多参考代码干脆不处理 sda_dir一直输出 sda_o结果仿真能过上板就是读写失败。真实原因就是方向切换时总线冲突。做在 FPGA 上的 IIC 特别容易遇到这个问题因为片上三态门在高阻时容易受毛刺影响不加以保护就很脆弱。4. 仿真与板级验证如何确认时序真的没问题写完代码先别急着上板。仿真能帮你省掉大量示波器抓波形的痛苦尤其是 IIC 这种对时序敏感的总线。我一般按下面三步验证。4.1 testbench 怎么写才能快速验证testbench 的首要任务是产生 start 信号并给定一组器件地址、寄存器地址和读写数据。我建议把 div 参数调大比如在 50MHz 时钟下用 5000 周期分频这样 SCL 周期就有 100 微秒波形拉得开肉眼就能分辨每个状态。然后要有一个虚拟从机至少能对地址做匹配并回复 ACK当然最直接的是实例化一个 EEPROM 仿真模型比如 AT24C02 的仿真模型网上随便一搜就有。// 一个精简的 IIC 从机响应模型只做地址匹配和 ACK module i2c_slave_model ( inout scl, inout sda ); // 这里可以简单实现 // 监控 SDA 下降沿在 SCL 为高时记录起始条件 // 逐位接收地址字节比较是否等于 0xA0 // 匹配则在第 9 个周期拉低 SDA 发 ACK // 之后每个字节同样回 ACK // 写模式接收数据字节后回 ACK读模式主机发完重复起始后从机向 SDA 写入数据。 endmodule这段话不是让你直接复制而是告诉你方向别自己造轮子写一个复杂从机模型尽量用现成的 EEPROM 模型。仿真时最需要观察的是起始条件后 SDA 上的地址字节逐位是否正确每字节的第 9 拍是否有 ACK读操作时数据方向切换是否平稳停止条件是否干净。4.2 波形里最容易忽略的几个细节第一是看 SDA 的三态切换。ModelSim 或 VCS 里高阻显示为“Z”如果你在主机的 ACK 检测窗口之前看到 SDA 上的 Z 和从机拉低的 0 之间有毛刺多半是方向切换时机不对。第二是看 SCL 的占空比。理想情况下 SCL 为低和高的时间应该各占一半左右如果你发现 SCL 高电平时间特别短可能是在分频计数时高低半周期划分不当这会影响从机的建立时间。第三是看停止条件。停止条件要求 SCL 为高时 SDA 由低变高如果波形里 SDA 的上升沿落在 SCL 低电平期间从机可能不认这个 STOP总线会一直挂住。检查这几处波形没问题后才算“仿真通过”。上板之前我还会做一次静态检查打开综合后的 schematic确认 i2c_scl 和 i2c_sda 被综合成真正的三态引脚而不是普通逻辑引脚确认引脚约束里的 IO 标准是 LVCMOS33 或 LVCMOS25和从机工作电压匹配。这些细节看似琐碎其实特别影响 IIC 的稳定性。4.3 上板前必做的检查清单上板调试 IIC强烈建议先准备好逻辑分析仪或者是示波器探头而不是上来就跑业务逻辑。一个推荐的流程是先用 ChipScope / ILA 抓 FPGA 内部信号确认状态机走到了哪个状态SDA 方向切换的时机是否和设计一致如果内部信号都正常再用示波器抓 SCL/SDA 引脚波形看是否和仿真一致。很多 IIC 通信失败其实是物理层的上拉电阻太大导致边沿过缓或者从机地址焊错、跳过芯片等。我的个人经验里第一板最常出问题的不是逻辑而是上拉电阻。IIC 是开漏总线SCL 和 SDA 必须有上拉电阻才能工作。上拉电阻的取值要算过典型值 4.7kΩ 比较安全如果总线上挂的设备很多比如十个以上总线电容变大就要用 2.2kΩ 或者 1kΩ如果追求低功耗总线电容小可以用 10kΩ。但注意上拉电阻太大边沿会变缓影响高速模式采样太小又会导致低电平电压抬升。这个我在第 5 部分还会展开讲。5. 常见问题与排查技巧实录写到这里我把这几年在 IIC 上踩过的一些坑整理成一个速查表供你对照排查。5.1 我遇到过的五类问题对照表问题现象直接原因定位手段解决办法从机无 ACK每次读写都超时器件地址拼错器件没上电总线地址冲突示波器抓地址帧比对数据手册逐个器件确认地址引脚尽量只挂一个从机排查仿真正常上板偶发失败SDA 方向切换点距离 SCL 高电平窗口太近物理层毛刺ILA 抓内部信号示波器抓引脚波形把方向切换点再往 SCL 低电平中点靠或在方向切换后加一拍延时SCL 波形高低电平不对称分频计数器划分不合适示波器看占空比从 clk_cnt0 到 SP_DIV/2 为低SP_DIV/2 到 SP_DIV-1 为高保证等分总线一直 SDA 为低拉死某个从机没释放 SDA逐段断开器件给总线上每个器件单独续流检查对不适用的从机地址做 NACK 处理高频模式400k下读写不稳定上拉电阻偏大、总线电容大示波器量上升沿时间换 2.2kΩ 或 1kΩ 上拉缩短总线走线降低速率验证稳定性第一个问题好多人会忽略IIC 地址并不总是“地址字节高 7 位”。很多器件手册会把地址写成 7 位形式比如 0x50而地址字节左移一位后才是 0xA0。如果直接拿 0x50 发给从机写方向从机匹配的是高 7 位为 0xA0自然不理你。我的习惯是统一在代码里做{dev_addr[6:0], rw}拼接并且在仿真模型和实际器件上都验证过地址字节和手册一致再继续。第二个问题是最隐蔽的“玄学 bug”。有一次项目里 IIC 在常温下一切正常温度一高就开始偶发失联排查到最后才发现是 SDA 方向切换点太靠近 SCL 上升沿总线走线过长时形成反射干扰了对 ACK 的采样。解决方式很简单就是让方向切换后多等一拍并且把 sda_dir 的切换放在 clk_cnt 的低半区离 SCL 上升沿留足余量这个问题就消失了。5.2 排查用的几个独门技巧这里分享两个我比较常用的排查手法。第一个是“剥离验证”如果一条总线上挂了三个器件全部一起初始化失败先别急着改代码把其中两个从机的 SDA/SCL 引脚从物理上断开只留一个最确定、最简单的器件地址固定、时序要求最低比如一个 EEPROM。然后把初始化脚本里只剩对它的读写跑一遍。这一步能帮你快速区分“逻辑层问题”和“物理层问题”。如果是逻辑问题单个器件也跑不通如果是物理层问题单个器件大概率能通。我试过好几次问题出在总线上挂了某个默认地址冲突的器件一剥离马上定位。第二个技巧是“用计数器当简易逻辑分析仪”。没有逻辑分析仪的时候在 FPGA 内部用一个 shadow 寄存器把 SDA 和 SCL 的电平实时锁存下来再通过串口或 SPI 发出去在 PC 端画出来。这个方法调试低速 IIC 足够用而且能直接抓到你关心的那一段波形比示波器方便得多。我一般会在空闲时采样 SDA/SCL加上时间戳存到一个环形缓冲区出错时把缓冲区内容 dump 出来基本能还原出错的上下文。6. 代码改良与项目扩展建议最后简单聊聊这个模块后续可以怎么扩展也算是我在自己项目里踩过之后的真实体会。基础版本只做单字节读写但真实项目里寄存器往往要连续写一大片或者要刷新一批数据。所以常见扩展有连续写、连续读、重复起始条件支持、多主机仲裁、错误超时处理。连续写和连续读本质上就是“一个字节完成后自动发下一个字节”状态机里加一个“字节长度计数器”即可地址和数据来源从寄存器列表里取。重复起始条件是读多字节的必备能力状态机的 RD_DATA 部分要在读完一个字节后判断“是否还要继续读”如果要继续就再发一个 START。错误超时处理是我后来加上去的因为实际项目中真的有从机“死掉”的情况。我加了一个超时计数器当从机 NACK 或者总线长时间无响应时主动产生停止条件并报错同时复位整个状态机防止总线被永久占用。这个细节救过项目一命某次传感器初始化时寄存器配错导致芯片挂住总线被从机死死拉住如果没有超时退出机制整个 FPGA 的启动流程都会被卡住。多主机仲裁比较少见但如果你想给代码再加点分可以关注一下“SDA 冲突检测”。当两个主机同时向总线发送数据时如果本机发送的是高电平、却读到 SDA 为低说明总线被另一主机占用此时本机应放弃驱动并等待下一个 START。逻辑上就是在 ACK 位和数据位采样时加一个比较代码不像想象中复杂但对于完整掌握 IIC 协议很有帮助。我个人在实际操作中还有一个体会写完这个模块之后再回去看那些 EEPROM 芯片数据手册里的时序图感觉完全不一样了。以前是“每行参数都认识但看不出一套完整的通信过程到底是怎么走下来的”现在看到 tSU;STA、tHD;DAT、tSU;DAT 这些符号脑子里能自动浮现出对应的 Verilog 状态和计数器区间。这也是我建议新手别直接抄代码的最大原因——抽一个晚上把状态机和时序图的对照关系梳理清楚比盲写一百行代码有价值得多。本文还有配套的精品资源点击获取