上个月帮一个朋友的项目调摄像头采集链路,折腾完 SCCB 总线之后,我突然发现自己这几年做 FPGA 接口逻辑,绕来绕去始终逃不开 UART、I2C、SPI、SCCB 这四张脸。很多刚入门的同学喜欢把这些协议当成单纯的黑盒子去调 IP 核,出了问题就一脸懵。实际上,协议本身并不难,难的是你想不想得清楚每一个信号跳变背后的原因。这篇文章就把这四个协议在 FPGA 上的实现思路一次性讲透,包括状态机怎么搭、分频系数怎么算、采样点怎么选、时序怎么调,全程用我实际调板子的经验来讲。
不管你是准备自己写 RTL 控制外设,还是想彻底搞懂底层时序方便排查问题,这篇文章都能给你一套可以直接参考的落地方案。
1. 四个协议放一起,核心差异在哪
网上讲单协议的教程很多,但很少有人把 UART、I2C、SPI、SCCB 这四兄弟放一张桌子上对比。我先把它们放在一个坐标里看:除 UART 是异步协议外,其余三种都是同步协议,需要主设备主动提供时钟。同步协议的共同点是都有时钟线,难点是时钟沿与数据对准;异步协议没有时钟线,所以必须在数据流里自行解决"什么时候采样"的问题。
1.1 不同的应用场景决定了设计目标
UART 最常见,用于板间调试、串口屏通信、GPS 模块数据接收,或者蓝牙模组的透传。它速率相对低(常见 9600、115200、460800),实现思路最直接:本地分频产生波特率,用状态机按位收发。
I2C 用于板内小数据量控制,典型场景是配置传感器寄存器、读取温湿度数据、访问 EEPROM。两根线 SDA 和 SCL,其中 SDA 是双向线,所以你在 FPGA 里要处理三态门,也就是 inout 端口的用法,这一点比 UART 多一层麻烦。
SPI 适合高速数据搬运,例如 ADC 采样数据读回、Flash 读写、显示屏刷新。它四根线分工明确:SCLK、MOSI、MISO、CS。没有应答机制,时序一错,数据就是错的,调试起来比 I2C 直观但要求时序严格。
SCCB 是摄像头传感器配置总线的"专用变种",最典型的应用就是配置摄像头模组的寄存器。它本质上是 I2C 的简化或改写版本,但多了片选性质的使能信号,读时序也略有差别,不能照搬 I2C 的时序,必须单独写。
1.2 共性特征带来统一思路
四个协议在 FPGA 实现上有一个共通点:都逃不过状态机加计数器这两个基本盘。状态机管数据的字节序列和握手关系,计数器管位的划分和时序长度。你可以把这四种协议都看作"按照时钟节拍把位流推送到 IO 口"的过程,区别只是推的方式不同。
另外一个共性是电平标准。FPGA 的 IO 电平通常配置为 3.3V LVCMOS,这些协议本身跑在 3.3V 或 1.8V 电平上,连接时要确认外设是否兼容,电平不一致就得加转换,否则容易烧引脚。
2. UART 协议实现详解
写 UART 模块是我带新人时让他们做的第一个练习。它的门槛低,不需要外部时钟同步,也不需要双向 IO,只需一根发送线 TX 和一根接收线 RX。但是要把收发做好了,波特率发生器和接收采样点两个地方得有讲究。
2.1 波特率发生器先算明白
UART 所谓"异步",指的是收发双方没有共享时钟,但双方约定相同的波特率。波特率就是每秒传输的比特数,单位是 bps。常用波特率有 9600、115200、460800。波特率发生器的任务是:在系统时钟域下,产生一个周期性脉冲,每个脉冲对应传输一个 bit。
举个例子,系统时钟是 50MHz,目标波特率是 115200,那每个 bit 的时长为 50_000_000 / 115200 = 434 个时钟周期。这个 434 就是分频计数的目标值。在实际算的时候我一般保留两到三位小数,先把除法结果算出来,然后取接近的整数。这里的误差不会致命。因为 UART 规定一个字节从起始位到停止位的累计误差控制在一定范围内即可。
我当时用的做法是定义一个计数器 baud_cnt,每个时钟周期加 1,计数到 433(对应 434 个周期后归零),然后产生一个 baud_tick 脉冲。发送和接收逻辑都以 baud_tick 作为节拍信号。这样写的好处是清晰,分频逻辑与收发逻辑完全解耦,后期想换波特率只改一个常量即可。
localparam BAUD_CNT_MAX = 50_000_000 / 115200 - 1; // 434 - 1 reg [8:0] baud_cnt; wire baud_tick = (baud_cnt == BAUD_CNT_MAX); always @(posedge clk) begin if (!rst_n) baud_cnt <= 0; else if (baud_tick) baud_cnt <= 0; else baud_cnt <= baud_cnt + 1; end提示:分频系数不是波特率本身,而是系统时钟频率除以波特率的商。很多人写错成波特率直接作为计数器阈值,这是新手最常见的问题。
2.2 发送状态机:空闲、起始、数据、停止
发送逻辑相对简单,因为主动权在自己手里,想什么时候发就什么时候发。状态定义按标准时序来:空闲态发送线保持高电平,发送开始时先拉低一个 bit 时间形成起始位,然后依次发送 8 个数据位(可配置),最后拉高一个 bit 时间作为停止位,再回到空闲态。
我习惯把发送模块设计成带 FIFO 接口的形式,输入是数据字节和发送使能,输出是 txd 线。状态机如下:IDLE 状态下检测到 send_en 后进入 START 态,START 态持续一个 bit 时间,然后进入 DATA 态,DATA 态用 bit_cnt 计数 0 到 7,每个 bit 发送对应位,之后进入 STOP 态,STOP 态持续一个 bit 时间后回到 IDLE,同时拉高发送完成标志。
多字节连续发送时要注意一个坑:发送完成标志不能只拉高一个周期,因为上层逻辑可能没来得及采样。我通常把 tx_done 输出一个周期脉冲,但如果上层也是状态机驱动,则可以用握手信号,或者在 STOP 态停留期间持续置位标志,直到上层给出新的发送使能。每条链路的设计习惯不同,自己把握好就行。
2.3 接收端采样点:为什么不能随意采
接收逻辑比发送难,因为 FPGA 不知道数据什么时候来,也不知道对方时钟的相位。UART 空闲时 RX 线是高电平,发送方拉低表现起始位。所以接收模块第一件事是检测下降沿,但光检测到下降沿还不够,因为毛刺也会造成下降沿。
我常用的方法是用系统时钟对 RX 线打两拍同步,消除跨时钟域引入的亚稳态。然后在同步后的信号里检测下降沿,进入起始位确认。接着启动分频计数器。采样点选择在每位数据的中间时刻,即每个 bit 的 0.5 倍周期处。这个中间采样的原理不用太纠结,就是避开数据在跳变沿附近的不稳定区,取最稳的中间点。
如果系统时钟快、波特率低,你还可以在一个 bit 周期内多次采样并取多数决,以此滤除毛刺。例如在每位中央附近连续采三个点,取多数值作为最终采样值。这个方法不是必须的,一般 115200 波特率、50MHz 系统时钟下,单点采样就够了;但如果你要从工业现场环境读取数据,多采样几次能显著提升抗干扰能力。
接收状态机里还要注意位计数器的清零时机,我习惯在检测到起始位下降沿时同步清零,而不是等计数器自然跑完再清零,这样能避免上一帧数据的残留计数值干扰下一帧的起始位判断。
3. I2C 协议实现详解
I2C 在 FPGA 里的实现,跟 UART 最大的区别就是 SDA 是双向口。你要在合适的时间让 SDA 输出数据,在合适的时间释放 SDA(变为高阻态),让从设备能拉低它。这个"释放"操作是用三态门实现的,新手第一次写的时候很容易忘了这个细节。
3.1 起始停止条件和 ACK 不是玄学
I2C 协议的基础是 SCL 和 SDA 两根线的配合。总线空闲时两者都为高。起始条件是:SCL 为高期间,SDA 由高变低。停止条件是:SCL 为高期间,SDA 由低变高。这个规则是 I2C 设备的"通用语言",任何设备都靠这个来识别传输开始和结束。
数据位的有效性也有严格规定:SDA 上的数据必须在 SCL 为高期间保持稳定,SDA 只能在 SCL 为低期间变化。这是因为 I2C 在 SCL 高电平期间采样 SDA,如果数据在采样期间跳变,就会和起始停止信号混淆。
ACK 机制很关键。当主机发送完一个字节(8 个数据位)后,它会释放 SDA,然后在第 9 个时钟周期内采样 SDA。如果从设备正常接收,它会将 SDA 拉低表示应答;如果从设备忙或者地址不匹配,SDA 保持高电平,这就是 NACK。主机在收到 NACK 后要决定是否终止传输,或者发送停止条件。
搞清楚了这三件事,你写状态机的时候手就不抖了。很多人把 ACK 判断简单化,以为只要有应答就行,实际上对于读操作,主机发送读地址后的第 9 个时钟周期内,主机应当处于输入模式,读取从设备的 ACK;而在读数据阶段,主机作为接收者,要在读完最后一个字节后主动不回 ACK(发送 NACK),这样从设备才知道"别再发了"。
3.2 主控状态机的具体设计
I2C 主控状态机我一般把它拆成两类操作:写操作和读操作。写操作流程是:起始 → 发送从机地址加写位 → 等待 ACK → 发送寄存器地址 → 等待 ACK → 发送数据 → 等待 ACK → 停止。读操作流程是:起始 → 发送从机地址加写位 → 等待 ACK → 发送寄存器地址 → 等待 ACK → 重复起始 → 发送从机地址加读位 → 等待 ACK → 读取数据 → 主机发 NACK → 停止。
状态机实现时,我习惯把时钟分频逻辑单独做。I2C 的 SCL 时钟是从系统时钟分频得到的,标准模式 100kHz,快速模式 400kHz。以 50MHz 系统时钟为例,100kHz 的分频系数是 50_000_000 / 100_000 / 2 = 250,也就是说 SCL 半周期计数 250。我通常用一个半周期计数器,计满翻转 SCL 电平,同时产生一个 scl_tick 信号,所有状态跳转都以 scl_tick 为节奏。发送数据时只在 SCL 为低电平时更新 SDA,置数据、释放 SDA 等操作都放在 SCL 低电平区间内。
这里有一个实际调试中的体会:不要把所有状态塞进同一个计数器里。SCL 半周期计数只在分频模块内部运行,状态机只关心 scl_tick 脉冲。这样设计的好处是,无论你要改成快速模式还是低速模式,都不需要改动状态机主体逻辑。
从设备如果支持时钟拉伸,它会在需要时间时把 SCL 拉低,迫使主机等待。FPGA 做主控时,我建议在 SCL 高电平期间采样读取 SCL 的实际电平,如果发现被拉低,就暂停分频计数,直到 SCL 恢复高电平再继续。如果不处理时钟拉伸,对于不支持的从设备没问题,但兼容性会差一些。
3.3 双向 IO 和三态门的处理
inout 端口在 FPGA 里需要用三态门控制。Verilog 写法是:
assign sda_out = (sda_dir) ? sda_data_out : 1'bz; assign sda_in = sda_pad;sda_dir 为 1 时,FPGA 主动驱动 SDA;sda_dir 为 0 时,SDA 释放为高阻,由外部上拉电阻拉高,或由从设备拉低。这个 sda_dir 信号必须在正确的时机切换。发送数据位时要驱动,等待 ACK 时要释放,释放之后才能采样。
我踩过一个很典型的坑:释放 SDA 后立刻采样 ACK,结果发现一直是高电平。原因不是设备没应答,而是 FPGA 引脚还处于输出驱动状态,外部设备根本拉不动。解决办法是释放 SDA 后的第一个 scl_tick 只做方向切换,到了第二个 scl_tick 再采样。这个细节不写进代码里,光看图你是发现不了的。
4. SPI 协议实现详解
SPI 的四根线结构讲起来很爽,因为它没有 ACK、没有双向口,主从收发各用一根独立的线。但它的设计陷阱藏在模式和时钟相位里面。
4.1 CPOL/CPHA 模式怎么定
SPI 的四种模式由 CPOL(时钟极性)和 CPHA(时钟相位)决定。CPOL 决定 SCLK 空闲时的电平,CPHA 决定数据是在上升沿还是下降沿采样。我个人总结最常用的模式是模式 0:CPOL=0、CPHA=0,即空闲时 SCLK 为低,第一个跳变沿(上升沿)采样数据。很多 Flash 芯片默认也支持模式 0,但你要用到的外设不一定,看手册的时候先手动查一下这组参数。
模式 0 的时序逻辑是:SCLK 上升沿采样数据,下降沿之后数据变化。FPGA 作为主机时,数据输出必须提前在下降沿之后布置好,确保在上升沿到来时数据已经稳定。这个"提前布置"通常发生在下降沿时刻,也就是在下降沿之后的下一拍更新 MOSI。
判断模式可以参考这个表:
| 模式 | CPOL | CPHA | 空闲电平 | 采样沿 | 数据变化沿 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 低 | 上升沿 | 下降沿 |
| 1 | 0 | 1 | 低 | 下降沿 | 上升沿 |
| 2 | 1 | 0 | 高 | 下降沿 | 上升沿 |
| 3 | 1 | 1 | 高 | 上升沿 | 下降沿 |
模式选错的症状是通信数据全对但却是反的,或者偶尔对但边缘错误。我的经验是先把 SCLK 空闲电平和采样沿两个参数在代码里做成参数,调试时用顶层配置直接切换,免得改完还要重新综合半天。
4.2 主模式收发一体的移位寄存器
SPI 主模式的核心是一个移位寄存器。发送和接收往往共用同一个移位过程:每个时钟沿,移位寄存器左移一位,同时从 MISO 采入一位到最低位,从最高位移出一位到 MOSI。这样一个字节收发完毕,移位寄存器里存的就是从设备返回的数据。
我设计 SPI 主控时通常会加一个缓存机制。因为 SPI 没有 ACK,对方的数据什么时候准备好完全由时序决定,主机得无条件地按约定的时钟沿收发。所以我在主模式模块内部用 FIFO 做数据的输入输出缓存,让上层逻辑不必每一步都盯着 SPI 时钟,而是只需要拉高发送请求,然后把数据写进 FIFO。移位时钟一旦启动,就自动完成一个字节的收发。上层读走 FIFO 的返回值。
FIFO 深度不用很大,16 或者 32 就够了,关键是要处理好 FIFO 空满状态与片选时序的关系。比如读 Flash 时,主机要先把命令字节发出去,然后继续发空数据同时接收返回数据,这期间 CS 必须保持低电平,不能中途拉高。状态机上要区分"发送阶段"和"接收阶段",或者直接用位计数控制。
4.3 片选管理与跨时钟域细节
CS(片选)信号负责选中从设备。SPI 总线上可以挂多个从设备,每个设备一根独立的 CS 线。CS 拉低表示选中设备,读写结束后拉高。这里有两个细节值得注意。
第一个是片选时序:有的设备要求 CS 拉高和拉低之间有最小间隔时间,也就是 tCS。如果主机太快地连续发起两次传输,设备来不及释放总线,就会导致通信紊乱。我在主控里加了一个可配置的 cs_idle 计数,每次传输结束拉高 CS 后,强制等待 N 个时钟周期才能开始下一次传输。
第二个是跨时钟域问题:如果你的 FPGA 里 SPI 主机时钟和外设的系统时钟不同,MISO 采回的数据相对系统时钟是异步的,必须先打两拍同步再做逻辑判断。特别是 OSD 和图像数据回传这类连续数据流,不同步直接采样很容易把 data valid 判断得乱七八糟。
5. SCCB 协议实现详解
SCCB 是摄像头图像传感器配置寄存器的专用总线协议。提到它,大多数人的第一反应是"这不就是 I2C 吗",但实际上两者的时序规矩有细微差异,而这些差异恰恰决定了摄像头能不能正确初始化。
5.1 SCCB 与 I2C 的差异点
SCCB 可以用两线制,也可以用三线制。三线制在 I2C 的 SDA、SCL 之外,多了一条 SCCB_E(使能信号),等同于片选。通信期间 SCCB_E 保持低电平,相当于告诉摄像头"我现在正在操作你"。写数据和读数据的底层层面上,SCCB 的数据传输格式类似 I2C,但 ACK 机制和 I2C 有差别。
SCCB 的"应答"全程分为三大块。第一,地址字节发送完成后,从设备要拉低数据线表示 ACK;第二,每个数据字节后也有对应 ACK;第三,读取数据时,主机完成数据字节接收后,要发送一个 NACK(数据线保持高),然后产生停止条件。I2C 在多数场景中主机也可以发 NACK 结束读取,但 SCCB 把这条规则规定得更死板,写错的话可能整个寄存器都读不回来。
还有一个容易忽略的点:SCCB 的地址格式。摄像头传感器的从机地址通常是一个 7 位 ID,加上读写位组成 8 位地址,写入时地址位最后是 0,读取时地址位最后是 1。比如常见的某款摄像头的写地址是 0x42,读地址是 0x43。很多人把它当成 0x21 的 7 位地址然后用 8 位方式操作,结果初始化半天没反应。
5.2 摄像头寄存器配置的写读流程
配置摄像头寄存器时,你需要往摄像头内部寄存器写入配置值。写单个寄存器的流程是:起始 → 发送写地址(ID_ADDR | 0)→ 等待 ACK → 发送寄存器地址高字节 → 等待 ACK → 发送寄存器地址低字节 → 等待 ACK → 发送配置值 → 等待 ACK → 停止。这里注意寄存器地址往往是 16 位,分两个字节发送。这也是 SCCB 和普通 I2C 外设的一个不同点:很多 I2C 传感器的寄存器地址是 8 位,而摄像头多数用 16 位。
读取单个寄存器的流程则更绕。你得先发一个"伪写"操作把寄存器地址告诉摄像头,然后重复起始条件,再发送读地址(ID_ADDR | 1),等 ACK,接着读数据。读完最后一个字节时主机不要回 ACK,直接发送 NACK,再发停止条件。我之前用过一段代码,当时直接把 I2C 读流程套到 SCCB 上,地址和 ACK 部分都对了,但最后一个字节多回了一个 ACK 导致摄像头状态错乱,调试了两天才发现。
如果你要一次连续配置几十个寄存器,不用每个寄存器都单独调用状态机,可以设计一个 FIFO 队列,每项存寄存器地址和配置值,SCCB 主控自动从队列里读取并逐条执行。这样让初始化代码变得很干净,也方便后续在其上增加"跳过寄存器"功能。
5.3 可复用的 SCCB 主控设计
SCCB 主控的核心仍然是一个带方向控制的状态机。相比 I2C,只是额外增加了一条 SCCB_E 信号,以及固定了 16 位寄存器地址格式。我在实际项目中把它做成一个模块,对外接口包括时钟、复位、开始信号、寄存器地址、寄存器值、完成标志和错误标志。内部状态机的状态包括预置、发送 ID、发送地址高字节、等待 ACK、发送地址低字节、等待 ACK、发送数据、等待 ACK、停止等。
在状态机里,数据线方向的切换同样很讲究。发送地址和数据字节时,SDA 方向为输出;等待 ACK 时,方向切为输入。读操作时,最后读取数据阶段主机也要输出方向,只是不回 ACK。
一个很重要的经验:SCCB 通信只要出一次错,后续整个寄存器初始化序列就可能全部错位。所以主控里最好加上超时判断。比如等待 ACK 超过 64 个时钟周期还没有应答,就主动拉高完成标志并标记错误,上层再决定是重试还是跳过。这个超时机制排障时特别有用,尤其是在飞线连接摄像头模组、接触不良的场景下。
6. 仿真验证和实测排查手记
RTL 写完之后,重点考验的是验证和调试能力。这里讲的仿真不是跑一遍看波形,而是怎么通过仿真暴露问题,再结合实测快速定位。
6.1 Testbench 别只做"发一个字节"
很多新手写 Testbench 只发一个字节的激励,看到波形就删掉,其实意义不大。我的建议是至少做三件事:第一,连续发送多个字节,验证状态机在高负载下不丢数据;第二,模拟外部设备响应慢的情况,比如在 I2C 的 ACK 阶段额外插入几个周期的低电平,验证时钟拉伸处理是否有效;第三,模拟干扰信号,比如在 UART 的 RX 线上注入毛刺,验证接收模块能否正确识别起始位。
Testbench 的结构可以做得稍微复杂一些。例如模拟 I2C 从设备时,写一个小模型,按协议规定回 ACK,并且可以配置是否启用时钟拉伸。这听起来要花时间,但等你遇到一个真实传感器因为时序问题无法响应时,就知道这个模型的价值了。调试那两天,我几乎全靠仿真模型快速排除问题,而不是把代码下载到板子上反复试。
6.2 用 ILA 和逻辑分析仪抓接口异常
仿真只能验证功能逻辑,真正验证时序还得上板。FPGA 里最常用的调试工具是逻辑分析仪 IP,Vivado 里直接搜 ILA 就能用。把内部状态机状态值、分频计数器、SDA 和 SCL 信号拉出来,设置好触发条件,然后运行。
我在调 I2C 时习惯用两种视角:一种是看内部状态,方便确认状态机走了哪个状态;另一种是看外部引脚电平,方便对照协议手册。把这两组信号放同一窗口,对照实际波形看状态跳转是否符合预期。
实测过程中发现的最奇怪的一类问题是:功能正常,但接上示波器就出错。这时候往往是测试点探头电容影响了信号边缘,尤其是 SDA 这种开漏加上拉电阻的信号线。解决办法也很直接:先在仿真阶段把所有时序调到位,板上只做最小化验证。
6.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| UART 收到的数据乱码 | 波特率不匹配、接收采样点偏移 | 核对分频系数,确认采样点在位中间 |
| UART 收不到起始位 | RX 线电平反向或毛刺滤除太强 | 用示波器看空闲电平和下降沿,确认同步逻辑 |
| I2C 无 ACK | 地址错误、SDA 未释放、从设备供电异常 | 检查发送地址和方向位,确认释放时机 |
| I2C 时钟拉伸卡死 | 未处理 SCK 被拉低 | 加超时跳转并检测 SCL 电平 |
| SPI 全 0 或全 1 | MOSI/MISO 配反、模式不对 | 确认连接和 CPOL/CPHA |
| SPI 数据错位 | 采样沿和数据变化沿相对错了 | 切换模式或者提前/延后一个周期采样 |
| SCCB 初始化失败 | 寄存器地址高低字节顺序错误 | 确认摄像头寄存器地址是 16 位,先高后低 |
| SCCB 读回全 FF | 读时序最后未发 NACK | 最后一个字节后主机主动发 NACK |
排查问题有一条通用顺序:先看供电和电平、再看波形、再看代码。我至少有三次把问题定位到"从设备压根没上电"这种低级原因上,因此别跳过前面几步直接改代码。
另外,我在实际调板时发现,把示波器探头夹到 SDA 和 SCL 上本身就容易引起不稳定,因为 I2C 的上拉电阻通常也就几 k 欧,探头的等效电容会影响信号边沿。如果遇到"带探头就正常、不带就异常"的情况,不要慌,基本就是探头负载导致的问题,换低电容探头或者用 FPGA 内部 ILA 调试就好。
7. 个人实测中的一点总结
这四个协议写下来,我发现 FPGA 里做接口协议这件事,本质上就是"在正确的时间把正确的电平放到正确的引脚上"。每个协议差别不大,但每个协议的细节都藏在它的时序图里。时序图里三步之内能看清的东西,往往就是你调试一整天都找不到的 bug。遇到问题时,先别急着怀疑工具或环境,回到波形图,一步一步对照手册检查,多半能找到答案。这片经验,是我反复踩坑换来的,希望你能少走一些弯路。