1. 从“点灯”到“通信”为什么FPGA工程师绕不开I2C如果你刚开始接触FPGA可能还在为点亮一个LED、实现一个计数器而兴奋。但很快你就会发现FPGA真正的价值在于与外部世界“对话”。一个孤立的FPGA芯片就像一座信息孤岛它的强大算力需要感知数据、输出结果这就离不开各种通信协议。而在众多协议中I2CInter-Integrated Circuit以其简洁的两线制、多主多从的灵活架构成为了连接传感器、存储器如EEPROM、实时时钟RTC等低速外设的首选桥梁。可以说掌握了I2C在FPGA上的实现你就拿到了开启FPGA系统级应用大门的钥匙。我见过不少初学者对着I2C的时序图感觉无从下手或者写出的Verilog代码在仿真里看着都对一上板子就通信失败。这太正常了因为I2C协议虽然简单但细节很多尤其是它那独特的“线与”逻辑、起始/停止条件、以及应答机制用纯数字逻辑去模拟需要非常精确的状态机控制。网上能找到的代码片段很多但往往只解决了“有没有”的问题离“稳不稳”、“好不好用”还差得远。这个系列我就想从一个FPGA工程师的视角和你一起从零开始搭建一个健壮、可复用、便于调试的I2C控制器核心。我们不只追求功能实现更要理解每一个状态转换背后的原因以及在实际项目中可能踩到的坑。2. 协议核心深入理解I2C的“线与”逻辑与状态机在动手写代码之前我们必须把I2C协议的几个核心特性吃透否则写出来的状态机就是空中楼阁。很多人一上来就研究时序图这没错但容易忽略底层电气特性对逻辑设计的影响。2.1 “线与”逻辑开漏输出的真正含义I2C总线只有两根线SDA串行数据线和SCL串行时钟线。它们都通过上拉电阻连接到正电源。最关键的一点是总线上的所有设备其SDA和SCL引脚都必须配置为开漏输出模式。注意在FPGA内部我们通常用三态门来模拟开漏输出。当需要输出高电平时我们不是驱动一个‘1’而是将输出置为高阻态Z让上拉电阻将总线拉高当需要输出低电平时则驱动一个强‘0’。读取总线状态时则直接读取输入引脚的电平。这个“线与”特性带来了两个重要结果第一任何设备都可以在任意时刻将总线拉低第二总线空闲时必须为高电平。这直接决定了起始条件和停止条件的定义起始条件S在SCL为高电平期间SDA出现一个下降沿。这只能由主动拉低SDA的主设备产生。停止条件P在SCL为高电平期间SDA出现一个上升沿。这发生在主设备释放SDA变为高阻后由上拉电阻拉高。理解这一点你就能明白为什么在Verilog代码中控制SDA输出需要非常小心必须在正确的时钟相位切换输出使能和电平。2.2 数据有效性时钟的“钳制”作用I2C的数据传输以字节为单位每个字节8位高位MSB在前。协议规定在SCL高电平期间SDA线上的数据必须保持稳定。只有在SCL为低电平时SDA线上的数据才允许改变。这就为我们的状态机设计提供了清晰的节拍。我们可以把SCL的一个完整周期低-高-低作为一个基本状态步进单元。主设备通过控制SCL的翻转来“推动”数据传输的进程。在FPGA实现中我们通常会用一个比SCL频率高得多的系统时钟例如100MHz来产生SCL并通过计数器精确控制SCL高、低电平的持续时间以满足协议对最小建立时间和保持时间的要求。2.3 应答机制通信可靠性的基石每个字节传输后都会紧跟一个应答位。这是I2C协议保证数据可靠送达的关键。发送方主或从在发送完8个数据位后会释放SDA输出高阻。接收方则在第9个时钟脉冲期间将SDA拉低表示一个有效的应答ACK如果保持SDA为高则表示非应答NACK。对于主设备来说作为接收方时必须在第9个时钟周期去读取SDA的状态判断从设备是否应答。而作为发送方时则在第9个时钟周期检测SDA是否为低以判断从设备是否成功接收。很多通信失败的问题都出在应答位的处理上比如状态机没有为等待和检测应答留出专门的状态。3. 架构设计一个模块化、可配置的I2C控制器理解了协议我们就可以开始设计FPGA内部的硬件电路了。一个好的设计应该是模块化、可配置的便于在不同项目中复用和调整。我建议将整个I2C控制器分为几个层次分明的模块。3.1 顶层接口与状态划分首先定义顶层模块的接口。除了时钟和复位信号至少需要以下几组用户控制接口包括启动命令、读写选择、从设备地址、待发送数据、数据有效标志等输入信号以及接收数据、操作完成标志、应答错误标志等输出信号。I2C物理接口双向的SDA和SCL信号线。在FPGA顶层它们需要被声明为inout类型。核心是一个主状态机它至少应包含以下状态IDLE空闲状态等待用户命令。START产生起始条件。SEND_ADDR发送7位从机地址 1位读写方向位。WAIT_ACK_ADDR等待并检查从机对地址的应答。SEND_DATA发送一个数据字节写操作。RECV_DATA接收一个数据字节读操作。SEND_ACK作为接收方发送应答位ACK或NACK。WAIT_ACK_DATA等待并检查从机对数据的应答写操作。STOP产生停止条件。ERROR处理无应答等错误情况。状态机的跳转必须严格遵循I2C时序每个状态持续的时间对应SCL的周期数需要根据你设定的I2C总线速度如100kHz标准模式或400kHz快速模式和FPGA系统时钟频率来精确计算。3.2 SCL时钟生成与SDA数据对齐这是实现中的精细活。我们不能直接用系统时钟去驱动SCL而是要用一个计数器来分频。例如系统时钟clk为100MHz目标SCL频率为100kHz那么一个SCL周期就是1000个clk周期。我们可以将其分为四相SCL_LOW相位计数器0-499SCL输出0。SCL_HIGH相位计数器500-999SCL输出1。但更重要的是数据SDA的变化必须发生在SCL为低电平的中间时刻而数据的采样读取则发生在SCL为高电平的中间时刻。这给了信号足够的建立和保持时间。因此我们可以在状态机中基于这个计数器来触发SDA的改变和采样。例如当计数器计到250低电平中点时改变下一个要发送的比特位到SDA输出寄存器当计数器计到750高电平中点时采样SDA输入线的值。3.3 双向SDA端口的三态控制实现在Verilog中实现双向端口的标准方法是inout sda_io; reg sda_out; reg sda_oe; // 输出使能1为驱动0为高阻 assign sda_io sda_oe ? sda_out : 1bz; wire sda_in sda_io;你需要根据状态机的当前状态精确控制sda_oe和sda_out。例如在发送数据包括地址、数据字节、起始条件的下降沿、停止条件的上升沿时sda_oe置1并驱动sda_out为所需电平。在释放总线如等待应答、接收数据时时sda_oe置0此时通过sda_in读取总线实际电平。4. 仿真验证搭建完备的测试平台Testbench代码写完了千万别急着上板。一个严谨的仿真环境能帮你发现90%以上的逻辑错误。对于I2C这种有时序要求的模块Testbench必须模拟真实的从设备行为。4.1 模拟从设备EEPROM模型你可以写一个简单的I2C从设备模型比如模拟一个AT24C02 EEPROM。这个模型需要监听总线上的起始条件。在收到与自己匹配的7位地址后回ACK。根据接下来的读写位进入接收数据或发送数据模式。在收到数据字节后回ACK并内部维护一个存储数组。在收到主设备的停止条件后结束本次传输。在Testbench中将你的I2C主控制器模块和这个从设备模型连接起来让它们通过sda和scl线对话。4.2 关键测试场景设计你的测试用例应该覆盖所有主要路径和边界情况单字节写入主设备向从设备指定地址写入一个字节。观察起始、地址写、ACK、数据、ACK、停止的完整波形。多字节连续写入页写入测试控制器在发送完一个字节后能否不产生停止条件继续发送下一个字节。这需要你的用户接口有流控机制如FIFO。当前地址读取主设备发送起始条件、从机地址读、然后直接读取数据并回NACK、停止。这是最简单的读操作。随机地址读取更常见的流程是“写地址读数据”。即先发送一个“伪写”操作写入要读取的内存地址然后发送重复起始条件Repeated Start再发送读命令进行读取。重复起始条件是很多初学者忽略的难点它是在不释放总线不发停止条件的情况下直接发起一个新的起始条件。你的状态机必须支持从WAIT_ACK_DATA等状态跳转到START状态而不是只能到STOP。错误处理测试模拟从设备不回ACKNACK的情况。你的控制器是否能够检测到并跳转到ERROR状态或安全地产生停止条件用户接口是否能收到“应答错误”标志4.3 使用ModelSim/QuestaSim进行波形调试在仿真器中将SCL、SDA、内部状态机状态state、数据计数器bit_cnt、发送移位寄存器tx_shift等关键信号添加到波形窗口。对照I2C协议时序图逐一检查起始和停止条件的波形是否标准SDA数据变化是否都在SCL低电平期间在SCL高电平期间SDA数据是否稳定应答位的位置和电平是否正确状态机的跳转是否符合预期我个人的习惯是会把期望的SDA波形根据发送的数据也作为一个参考信号画出来与实际波形对比一目了然。5. 上板调试与实战中的“坑”仿真通过只是万里长征第一步。上板调试才是真正的试金石。这里分享几个我踩过的坑和对应的解决办法。5.1 上拉电阻与信号完整性FPGA的IO引脚驱动能力很强但I2C是开漏总线必须外接上拉电阻。阻值典型值为4.7kΩ对于标准模式总线电容较小时如果总线较长、设备较多电容增大可能需要减小阻值如2.2kΩ以保证上升沿速度。用示波器测量SCL和SDA的上升沿如果过于缓慢上升时间超过协议规定就会导致采样失败。如果手头没有示波器一个简单的判断方法是尝试降低I2C总线频率比如从400kHz降到100kHz如果通信变正常了很可能就是信号完整性问题。5.2 亚稳态与跨时钟域处理你的I2C控制器内部可能有一个由系统时钟驱动的状态机而SDA作为输入是一个异步信号。当你在SCL高电平中点采样SDA时这个采样动作相对于系统时钟是异步的有可能导致亚稳态。虽然I2C速度慢亚稳态导致系统崩溃的概率极低但为了设计稳健建议对sda_in信号进行两级寄存器同步reg sda_sync1, sda_sync2; always (posedge clk) begin sda_sync1 sda_io; sda_sync2 sda_sync1; end // 使用 sda_sync2 作为内部逻辑采样的信号5.3 从设备无应答的排查思路如果上板后通信完全失败首先用逻辑分析仪或示波器抓取SCL和SDA的波形。如果没有应答检查从设备地址这是最常见错误很多从设备的7位地址是由部分固定位和可配置的引脚电平组成的。务必确认硬件连接如EEPROM的A0, A1, A2引脚与代码中地址一致。别忘了地址后面还要拼接读写位0为写1为读。检查电源和复位确保从设备已正确上电且不在复位状态。检查总线竞争总线上是否有其他设备包括另一个FPGA引脚错误配置为输出在一直拉低总线可以尝试断开其他设备只连主从两者。简化测试先尝试进行最基本的“设备探测”即只发送地址写看是否有ACK。排除数据段的影响。5.4 利用ILA集成逻辑分析仪进行在线调试对于Xilinx FPGAVivado的ILA工具是调试利器。你可以把sda_in,sda_oe,sda_out,state,bit_cnt等内部信号添加到ILA核中触发条件设置为状态机进入ERROR状态或者用户启动命令。上板运行后触发抓取波形就能像仿真一样看到FPGA内部的实际运行情况这对于排查那些只在特定条件下出现的偶发故障非常有效。6. 性能优化与功能扩展当基础功能稳定后我们可以考虑让它变得更强大、更好用。6.1 支持时钟拉伸Clock Stretching一些低速从设备如某些传感器可能需要更多时间处理数据它们会在应答或发送数据期间主动拉低SCL线迫使主设备等待这就是时钟拉伸。一个健壮的主控制器应该能检测到SCL被从设备拉低即scl_in为0而自己的scl_oe为1并暂停内部时钟计数器直到scl_in被释放为止。这需要在SCL驱动逻辑中增加检测和等待机制。6.2 封装成带FIFO的AXI4-Lite接口模块为了让处理器如软核MicroBlaze或硬核ARM能够方便地访问我们可以将I2C控制器封装成一个带标准接口的IP。例如实现一个AXI4-Lite从接口。用户通过写特定的控制寄存器来发起操作设置从地址、读写、写入数据通过读状态寄存器获取状态忙/闲、错误通过读数据寄存器获取接收到的数据。在控制器内部可以加入一个小深度的FIFO来缓存要发送或已接收的数据流从而实现连续的多字节传输而无需处理器频繁干预。这样上层软件驱动开发就变得非常标准化和简单。6.3 多主仲裁与监控模式标准的I2C协议支持多主仲裁。如果两个主设备同时开始传输它们会继续发送直到其中一个发送了‘1’而另一个发送了‘0’。发送‘0’的设备会检测到总线实际为‘1’因为“线与”从而知道自己仲裁失败退回到从设备监听模式。实现这个功能比较复杂需要控制器能同时监控总线即使在空闲状态也要监听起始条件并在自己作为主设备发送时持续比较自己发出的SDA和总线实际的SDA。对于大多数嵌入式应用单主系统已足够但了解这个机制有助于你理解协议的全貌。一个更实用的扩展是“监控模式”或“嗅探器模式”即控制器不作为主或从仅仅监听总线上的所有流量并记录下来这在调试复杂的多设备I2C网络时非常有用。从头实现一个FPGA上的I2C控制器是一个绝佳的将数字逻辑设计、协议理解、仿真验证和硬件调试串联起来的项目。它没有复杂的算法但对细节的要求极高。当你第一次用自己的代码成功从EEPROM中读取出数据时那种对硬件和协议的控制感是直接用现成IP核无法比拟的。这个过程中培养的调试思维和严谨习惯会让你在后续面对更复杂的接口如SPI、UART甚至高速SerDes时更加从容。在下一部分我们将探讨如何将这个控制器应用于具体的EEPROM读写并处理像“页写入”、“随机读”这样的具体操作序列让这个IP真正用起来。