三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

FPGA实现I2C主机控制器:从协议理解到健壮架构设计

FPGA实现I2C主机控制器:从协议理解到健壮架构设计

1. 项目概述与核心价值

最近在做一个传感器数据采集的项目,主控用的是FPGA,传感器那边挂了好几个,用的都是I2C接口。一开始想着I2C协议简单,网上找个IP核或者自己随便写个状态机就搞定了。结果真上手才发现,坑是一个接一个:从设备地址没响应、SCL时钟被从设备拉低(Clock Stretching)、多主机仲裁、不同速率下的时序裕量……这些问题在软件模拟或者用MCU的硬件I2C外设时可能不太明显,但在FPGA里用纯逻辑实现,每一个细节都得自己抠,时序不对整个通信就废了。

所以,我决定把这个“基于FPGA实现I2C主机控制器”的过程好好梳理一下,写成系列文章。这第一篇,咱们不急着上代码,先把地基打牢。我会重点聊聊怎么从一个“协议说明书”读者,转变为一个“协议实现者”的思考过程。我们得彻底搞懂I2C协议里那些容易被忽略的细节,并基于这些理解,设计出一个健壮、可配置、易于集成的FPGA模块架构。很多人调不通I2C,问题往往不是出在代码语法,而是从一开始对协议的理解和模块划分上就埋了雷。这篇文章,就是帮你排掉这些“架构雷”。

2. I2C协议深度解析与FPGA实现难点

I2C(Inter-Integrated Circuit)协议确实以其简洁的两线制(串行数据线SDA和串行时钟线SCL)而闻名,但它的简洁性恰恰对实现者的理解深度提出了更高要求。在FPGA中实现,意味着你需要用硬件描述语言(如Verilog或VHDL)精确地模拟出协议规定的所有时序和状态,没有任何现成的硬件状态机可以依赖。

2.1 协议核心状态与信号机制

首先我们必须抛弃“发送字节”和“接收字节”这种软件层面的抽象,在硬件视角下,I2C通信是由一系列精确定义的信号事件组成的。

  1. 起始(S)与停止(P)条件:这是总线控制权的标志。SDA在SCL高电平期间的下跳变是起始条件;SDA在SCL高电平期间的上跳变是停止条件。在FPGA中,我们需要持续采样SDA和SCL线,用边沿检测逻辑来准确捕获这两个事件。这里第一个坑就来了:由于总线是开漏结构,主设备只能拉低线路,释放后靠上拉电阻回到高电平。这个上升沿可能比较缓慢,如果你的采样时钟不够快,或者边沿检测逻辑对毛刺敏感,就可能误判。

  2. 数据传输与应答(ACK/NACK):每个字节(8位)传输后,必须跟一个应答位。注意,时钟(SCL)完全由主设备控制(除了时钟拉伸)。主设备在发送完8个比特后,会在第9个时钟周期释放SDA线(即输出高阻态,由上拉电阻拉高),并在这个时钟周期内读取SDA线的电平。如果从设备成功接收字节,它应该在这个时钟周期内将SDA拉低,这表示应答(ACK);如果保持高电平,则表示非应答(NACK)。很多初学者写的代码,在这里依然试图去“驱动”SDA输出一个高电平来表示等待应答,这是错误的,会导致主从设备同时驱动总线产生冲突。

  3. 时钟拉伸(Clock Stretching):这是从设备“反客为主”的核心机制。当从设备需要更多时间处理数据(例如,内部EEPROM正在写入)时,它可以在接收到一个比特后,将SCL线主动拉低并保持。只要SCL为低,总线就进入等待状态。主设备的I2C控制器必须检测到这一情况,并暂停产生SCL时钟,直到检测到从设备释放了SCL线(即SCL被上拉电阻拉回高电平)。不支持时钟拉伸的I2C主机是无法与许多低速从设备(如EEPROM)可靠工作的。在FPGA中实现,要求我们的SCL输出逻辑不是一个简单的时钟分频器,而是一个能被从设备反馈信号“暂停”的状态机。

2.2 FPGA实现面临的独特挑战

理解了基本信号机制,我们来看看在FPGA里实现时,哪些地方和用MCU的硬件I2C外设截然不同:

  • 纯数字逻辑模拟:没有专用的模拟电路处理开漏、毛刺和亚稳态问题。所有信号都经过离散采样,抗干扰能力完全依赖于你的逻辑设计。
  • 时序精确性:协议规定了t_{HD,STA},t_{LOW},t_{HIGH},t_{SU,DAT}等一系列时间参数。在MCU中,这些通常由硬件自动满足。在FPGA中,你需要一个基准时钟(比如50MHz),并通过计数器精确产生满足这些时序要求的SCL周期和SDA切换点。例如,标准模式(100kHz)下,SCL低电平时间t_{LOW}需大于4.7μs。如果你的系统时钟是50MHz(周期20ns),那么你需要计数4.7μs / 20ns = 235个周期来保证低电平时间。
  • 双向端口(SDA)处理:这是Verilog编码中的经典难点。SDA线在主机模式下,有时需要输出0(驱动低),有时需要输出高阻态(释放总线,以便读取从机应答或从机发送的数据)。在Verilog中,这通常通过一个inout端口,并结合一个输出使能信号(sda_oe)来实现。当sda_oe=1时,sda_out的值驱动到总线上;当sda_oe=0时,端口呈高阻,此时可以读取sda_in上的电平。设计时必须确保输出使能逻辑的切换严格发生在SCL为低电平期间,避免在SCL高时改变SDA导致产生意外的起始或停止条件。
  • 多主机仲裁:虽然我们这个系列先实现单主机,但一个健壮的架构应该为未来支持多主机仲裁留出可能性。仲裁的本质是:当多个主机同时发起传输时,它们会监听SDA线。如果某个主机输出高电平(释放总线),但检测到SDA线为低(被另一个主机拉低),那么它就仲裁失败,必须立即转为从机模式并停止驱动总线。这要求我们的主机模块在输出每一位数据后,都能即时读取SDA的实际状态并与自身输出进行比较。

3. 控制器架构设计与状态机规划

基于以上分析,我们不能直接写一个“一坨”的状态机把从起始到停止的所有步骤都塞进去。那样会导致状态爆炸,代码难以维护和调试。我采用的是一种分层化、模块化的设计思想,将整个I2C主机控制器划分为几个协同工作的子模块。

3.1 系统级模块划分

整个I2C主机控制器可以看作一个“命令执行引擎”,它接收来自用户逻辑(如一个微处理器软核或另一个状态机)的指令,然后精确地控制物理引脚产生对应的I2C波形。

  1. I2C顶层模块(i2c_master):对外提供简单的寄存器或FIFO接口,用于接收命令(如:写操作,从机地址0x50,写入数据[0x00, 0x12, 0x34])和返回状态。它内部包含一个主控制状态机(Main FSM)比特位传输引擎(Bit-Level Engine)
  2. 主控制状态机(Main FSM):这是大脑,负责解析高层命令,将其分解为一系列原子操作,例如:START->SEND_BYTE(0xA0)->CHECK_ACK->SEND_BYTE(0x00)->CHECK_ACK->STOP。它不关心一个比特具体怎么发,只关心“发一个字节”、“收一个字节”、“检查应答”这些动作的次序和结果。
  3. 比特位传输引擎(Bit-Level Engine):这是四肢,负责执行最底层的操作。它接收来自主状态机的“原子命令”(如TX_BIT,RX_BIT,GEN_ACK,GEN_NACK),并精确地控制SCL的高低电平、SDA的输出使能和输出值,同时采样SDA输入。它会处理时钟拉伸:在每产生一个SCL高电平脉冲前,先检查SCL线是否已被从设备拉低(即检测到时钟拉伸),如果是,则进入等待状态。
  4. 时序发生器(Timing Generator):这是一个计数器,根据配置的I2C速率(如100k, 400k, 1M),产生控制比特引擎步进的节拍。它定义了SCL低电平、高电平的持续时间,以及SDA数据建立/保持时间对应的精确时钟周期数。

3.2 主控制状态机(Main FSM)状态设计

主状态机的状态不宜过多,应围绕“事务(Transaction)”来设计。一个典型的写事务包括:起始、发送地址(含读写位)、检查应答、发送数据、检查应答……停止。

我设计的状态集合大致如下:

  • IDLE:空闲状态,等待命令。
  • START:发起起始条件。
  • TX_ADDR:发送7位地址+1位读写方向。
  • RX_ACK:接收从机应答。
  • TX_DATA:发送一个数据字节。
  • RX_DATA:接收一个数据字节。
  • TX_ACK:在读取数据后,主机发送应答位(ACK或NACK)。
  • STOP:发起停止条件。
  • ERROR:处理错误(如无应答)。

状态之间的转换由当前状态、比特引擎的“完成”信号以及ACK/NACK结果共同决定。例如,在TX_ADDR状态,主状态机命令比特引擎发送8个比特。发送完成后,引擎发出bit_done信号,主状态机转入RX_ACK状态,命令引擎接收1个比特(即应答位)。收到后,判断此比特是0(ACK)还是1(NACK),如果是NACK,则可能跳转到ERRORSTOP状态。

3.3 比特位传输引擎(Bit-Level Engine)设计

这是最核心也是最容易出错的部分。它可以是一个更细粒度的状态机,包含以下状态:

  • BIT_IDLE:等待主状态机命令。
  • SCL_LOW:将SCL输出驱动为低,并根据命令准备SDA数据(在SCL低电平期间改变SDA是安全的)。
  • SCL_HIGH_PRE:准备释放SCL为高。关键点:在此状态,先检查SCL输入引脚是否为低(被从机拉伸)。如果是,循环等待;如果不是,则进入SCL_HIGH状态。
  • SCL_HIGH:将SCL输出驱动为高(实际上是释放SCL输出使能,由上拉电阻拉高),并在此状态的中间时刻采样SDA线(以满足数据建立时间)。
  • BIT_DONE:一个比特处理完成,通知主状态机。

这个引擎确保了每一个比特的传输都严格遵守“SCL低时改变SDA,SCL高时数据稳定”的铁律,并自动处理了时钟拉伸。

4. 关键模块的Verilog实现要点与仿真

有了清晰的架构,我们就可以着手编写代码了。这里我给出一些最关键部分的实现思路和代码片段,并解释为什么这么做。

4.1 顶层接口与参数化设计

一个好的模块应该是可配置的。我们通过参数来定义系统时钟频率和所需的I2C时钟频率。

module i2c_master #( parameter SYSTEM_CLK_FREQ = 50_000_000, // 50 MHz 系统时钟 parameter I2C_CLK_FREQ = 100_000 // 100 kHz I2C时钟 )( input wire clk, input wire rst_n, // 用户控制接口 input wire i_cmd_valid, input wire [7:0] i_cmd_data, output reg o_cmd_ready, output reg o_busy, output reg o_error, // I2C物理接口 output reg scl_o, // SCL输出值 output reg scl_oe, // SCL输出使能 (1:驱动, 0:高阻) output reg sda_o, output reg sda_oe, input wire scl_i, // SCL输入值 (用于检测拉伸) input wire sda_i );

注意scl_o/scl_oesda_o/sda_oe是分开的。这是实现开漏输出的标准做法。*_oe=1时,驱动*_o的值到引脚;*_oe=0时,引脚为高阻态,此时读取*_i的值才是总线上的真实电平。绝对不要*_o直接连接到inout型引脚而不使用使能信号。

4.2 时序发生器的实现

时序发生器根据两个频率参数,计算出各个时序段需要的时钟周期数。

// 计算一个I2C时钟周期对应的系统时钟周期数 localparam integer CLK_DIVIDER = SYSTEM_CLK_FREQ / (I2C_CLK_FREQ * 2); // 标准模式下,高低电平时间各占一半周期。但协议要求t_{HIGH}和t_{LOW}有最小值。 // 这里简单对半分,实际应根据具体模式微调。 localparam integer HALF_PERIOD_CNT = CLK_DIVIDER - 1; reg [15:0] clk_cnt; reg scl_tick; // 用于比特引擎步进的节拍信号 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin clk_cnt <= 0; scl_tick <= 0; end else if (o_busy) begin // 只在总线忙时计数 if (clk_cnt == HALF_PERIOD_CNT) begin clk_cnt <= 0; scl_tick <= ~scl_tick; // 每半个I2C周期翻转一次 end else begin clk_cnt <= clk_cnt + 1; end end else begin clk_cnt <= 0; scl_tick <= 0; end end

Scl_tick信号就是一个频率为I2C时钟频率两倍的节拍,它的每个上升沿或下降沿都可以作为比特引擎状态机切换状态(如从SCL_LOW切换到SCL_HIGH_PRE)的触发条件。

4.3 比特引擎中处理时钟拉伸

这是比特引擎SCL_HIGH_PRE状态的关键逻辑:

always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bit_state <= BIT_IDLE; // ... 其他复位 end else begin case (bit_state) // ... 其他状态 SCL_HIGH_PRE: begin // 准备释放SCL为高,先检查是否被从设备拉低 if (scl_i == 1'b0) begin // 从设备正在拉伸时钟,保持在此状态等待 bit_state <= SCL_HIGH_PRE; end else begin // 从设备未拉伸,可以释放SCL scl_oe <= 1'b0; // 释放SCL线(输出高阻) bit_state <= SCL_HIGH; end end SCL_HIGH: begin if (scl_tick) begin // 半个周期到,该拉低SCL了 scl_oe <= 1'b1; // 驱动SCL scl_o <= 1'b0; // 驱动为低 bit_state <= SCL_LOW; // ... 其他逻辑,如通知主状态机一个比特完成 end end // ... endcase end end

关键点:在SCL_HIGH_PRE状态,我们检查的是scl_i(输入引脚的电平),而不是scl_o(我们试图输出的值)。因为即使我们想释放SCL(scl_oe=0),如果从设备把它拉低了,总线上的实际电平(scl_i)仍然是低。我们必须等待scl_i变高,才能认为从设备结束了拉伸,我们才能安全地进入SCL_HIGH状态。

4.4 仿真测试平台(Testbench)搭建要点

FPGA开发,仿真先行。一个完善的测试平台能节省大量板上调试时间。对于I2C主机,我们需要模拟一个从设备(如EEPROM)。

  1. 从设备模型:编写一个简单的任务(task)来模拟从设备行为。例如:
    task i2c_slave_response; input [7:0] slave_addr; input rw; // 0:写, 1:读 inout sda; inout scl; // ... 内部变量 begin // 1. 等待起始条件 // 2. 接收8位地址,并与自身地址比较 // 3. 如果匹配,在第9个时钟周期拉低SDA发送ACK // 4. 根据RW位,进入发送或接收数据循环 // 5. 在适当的时候模拟时钟拉伸:在某个ACK后,主动将scl拉低一段时间。 // 6. 等待停止条件 end endtask
  2. 关键检查点:在Testbench中要加入断言(assertion)或$display语句,检查:
    • 起始/停止条件是否在SCL高时,SDA有正确的跳变。
    • 数据位是否在SCL低时改变,在SCL高时稳定。
    • 主机在接收应答位时,是否正确地释放了SDA线。
    • 当从设备模型拉低SCL时,主机是否停止了SCL的翻转(即scl_oe是否保持为0)。
  3. 波形查看:使用ModelSim、Vivado Simulator等工具,仔细对照I2C协议时序图查看SDA和SCL的波形。重点关注切换边沿和数据采样点。

5. 常见问题、调试技巧与实战心得

即使设计再仔细,第一次上板调试也难免遇到问题。下面是我在多个项目中总结出的I2C调试“血泪史”。

5.1 典型问题排查清单

现象可能原因排查方法
从设备无应答(NACK)1. 从设备地址错误(7位 vs 8位混淆)。
2. 从设备未上电或硬件连接问题。
3. 总线被意外拉低(SCL/SDA短路)。
4.主机在应答位周期没有释放SDA线(最常见的设计错误)。
1. 用逻辑分析仪抓取波形,确认发送的8位地址值(7位地址+1位R/W)是否正确。
2. 检查电源、上拉电阻(通常4.7kΩ)。
3. 断电测量SCL/SDA对地电阻。
4.重点查看波形:在第9个SCL高电平期间,主机的SDA线(输出)是否变为高阻(表现为缓慢上升的模拟波形),以及从设备是否在此期间拉低了SDA。
通信速度极慢或不稳定1. 上拉电阻过大,导致上升沿太慢,违反t_{R}(上升时间)要求。
2. FPGA引脚约束错误,驱动能力或斜率设置不当。
3. 总线电容过大,多个设备并联导致。
1. 测量SCL/SDA上升沿时间,标准模式应小于1000ns。可适当减小上拉电阻(如从4.7kΩ换为2.2kΩ)。
2. 检查FPGA工程的引脚约束文件,确保I/O标准(如LVCMOS)正确,可尝试增强驱动电流。
3. 减少总线上的负载,或使用缓冲器。
只能读写第一个字节,后续失败1. 多字节读写时序错误,特别是重复起始条件(Repeated Start)处理不当。
2. 从设备(如EEPROM)页写等待时间不足,内部正在编程,主机未检测其时钟拉伸。
1. 确认协议:写EEPROM时,发送数据地址后是写数据还是读数据?是否需要发重复起始条件?用逻辑分析仪对比标准波形。
2.在发送每个字节后的ACK周期,以及发送地址后的ACK周期,仔细检查SCL线是否被从设备拉低(拉伸)。确保你的主机支持并正确处理了拉伸。
逻辑分析仪显示波形正确,但数据不对1. 数据字节的位序(MSB/LSB)弄反。I2C协议规定先传最高位(MSB)。
2. 采样点不对。主机应在SCL高电平的中间采样SDA,确保数据稳定。
1. 核对代码中数据移位的方向。{data[6:0], 1‘b0}{1‘b0, data[6:0]}结果完全不同。
2. 在比特引擎的SCL_HIGH状态,延迟若干个系统时钟周期后再采样sda_i,以避开边沿。

5.2 板上调试必备工具与技巧

  1. 逻辑分析仪(Logic Analyzer):这是调试数字通信协议的“眼睛”。推荐使用带I2C协议解码功能的型号(如Saleae)。它能直接将SDA/SCL波形解析成地址、数据、起始/停止等符号,一目了然。没有它,调试I2C就像在黑暗中摸索。
  2. 嵌入式ILA(Integrated Logic Analyzer):对于Xilinx FPGA(Vivado)或Intel FPGA(Quartus SignalTap),一定要学会使用片内逻辑分析仪。它可以将FPGA内部的关键信号(如状态机状态state、计数器clk_cnt、输出使能sda_oe、输入值sda_i等)实时抓取出来,与外部逻辑分析仪抓到的引脚波形进行对照。这是定位问题发生在“内部逻辑”还是“外部物理链路”的关键。
  3. 分步调试法:不要一开始就进行完整的数据读写。按顺序验证: a.起始/停止条件:先让FPGA循环发送起始-停止信号,用逻辑分析仪看波形是否正确。 b.发送地址:验证发送一个地址字节(如0xA0)及其后的ACK周期波形。 c.时钟拉伸:编写一个简单的从设备模拟程序(可以用另一个FPGA IO口模拟),在ACK周期主动拉低SCL,观察主机是否暂停。 d.完整事务:最后再测试完整的读写序列。

5.3 我的几点核心心得

  • 状态机要“瘦”:主状态机只负责流程,比特引擎负责最底层的时序和信号。这样结构清晰,出错也容易定位。如果把所有细节都塞进一个状态机,很快就会变得难以维护。
  • 仿真覆盖率很重要:Testbench里要模拟各种情况:正常读写、从设备NACK、时钟拉伸、甚至模拟另一个主机发送仲裁失败。仿真的时间投入,会在板级调试时十倍地回报你。
  • 参数化设计:把系统时钟频率、I2C速率、超时计数器长度等都做成模块参数。这样你的IP核可以轻松复用在不同的项目中。
  • 重视输入同步scl_isda_i是来自异步外部世界的信号,必须经过两级寄存器同步后再在内部逻辑中使用,以避免亚稳态问题。
  • 从设备是“老师”:当你调不通时,逻辑分析仪的波形就是最好的教材。多找几个标准的I2C从设备芯片(如AT24C02 EEPROM)的数据手册,仔细研究它们的时序图,特别是关于页写周期、时钟拉伸的说明,然后对比你的波形,差异点往往就是问题所在。

这篇文章我们深入探讨了在FPGA中实现I2C主机控制器的设计思路、架构规划和关键实现细节,并分享了大量的调试经验。下一篇文章,我们将把上述架构转化为具体的Verilog代码,构建一个完整的、参数化的I2C Master IP核,并提供一个清晰的用户接口,让你可以像调用函数一样轻松地在你的FPGA项目中使用I2C功能。我们会从最基础的比特引擎状态机开始写起,一步步集成成完整的模块,并给出更加详尽的仿真与测试案例。

← 返回列表