
上个月帮一个朋友的项目调摄像头采集链路折腾完 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 : 1bz; assign sda_in sda_pad;sda_dir 为 1 时FPGA 主动驱动 SDAsda_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 决定数据是在上升沿还是下降沿采样。我个人总结最常用的模式是模式 0CPOL0、CPHA0即空闲时 SCLK 为低第一个跳变沿上升沿采样数据。很多 Flash 芯片默认也支持模式 0但你要用到的外设不一定看手册的时候先手动查一下这组参数。模式 0 的时序逻辑是SCLK 上升沿采样数据下降沿之后数据变化。FPGA 作为主机时数据输出必须提前在下降沿之后布置好确保在上升沿到来时数据已经稳定。这个提前布置通常发生在下降沿时刻也就是在下降沿之后的下一拍更新 MOSI。判断模式可以参考这个表模式CPOLCPHA空闲电平采样沿数据变化沿000低上升沿下降沿101低下降沿上升沿210高下降沿上升沿311高上升沿下降沿模式选错的症状是通信数据全对但却是反的或者偶尔对但边缘错误。我的经验是先把 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 里最常用的调试工具是逻辑分析仪 IPVivado 里直接搜 ILA 就能用。把内部状态机状态值、分频计数器、SDA 和 SCL 信号拉出来设置好触发条件然后运行。我在调 I2C 时习惯用两种视角一种是看内部状态方便确认状态机走了哪个状态另一种是看外部引脚电平方便对照协议手册。把这两组信号放同一窗口对照实际波形看状态跳转是否符合预期。实测过程中发现的最奇怪的一类问题是功能正常但接上示波器就出错。这时候往往是测试点探头电容影响了信号边缘尤其是 SDA 这种开漏加上拉电阻的信号线。解决办法也很直接先在仿真阶段把所有时序调到位板上只做最小化验证。6.3 常见问题速查表问题现象可能原因排查方法UART 收到的数据乱码波特率不匹配、接收采样点偏移核对分频系数确认采样点在位中间UART 收不到起始位RX 线电平反向或毛刺滤除太强用示波器看空闲电平和下降沿确认同步逻辑I2C 无 ACK地址错误、SDA 未释放、从设备供电异常检查发送地址和方向位确认释放时机I2C 时钟拉伸卡死未处理 SCK 被拉低加超时跳转并检测 SCL 电平SPI 全 0 或全 1MOSI/MISO 配反、模式不对确认连接和 CPOL/CPHASPI 数据错位采样沿和数据变化沿相对错了切换模式或者提前/延后一个周期采样SCCB 初始化失败寄存器地址高低字节顺序错误确认摄像头寄存器地址是 16 位先高后低SCCB 读回全 FF读时序最后未发 NACK最后一个字节后主机主动发 NACK排查问题有一条通用顺序先看供电和电平、再看波形、再看代码。我至少有三次把问题定位到从设备压根没上电这种低级原因上因此别跳过前面几步直接改代码。另外我在实际调板时发现把示波器探头夹到 SDA 和 SCL 上本身就容易引起不稳定因为 I2C 的上拉电阻通常也就几 k 欧探头的等效电容会影响信号边沿。如果遇到带探头就正常、不带就异常的情况不要慌基本就是探头负载导致的问题换低电容探头或者用 FPGA 内部 ILA 调试就好。7. 个人实测中的一点总结这四个协议写下来我发现 FPGA 里做接口协议这件事本质上就是在正确的时间把正确的电平放到正确的引脚上。每个协议差别不大但每个协议的细节都藏在它的时序图里。时序图里三步之内能看清的东西往往就是你调试一整天都找不到的 bug。遇到问题时先别急着怀疑工具或环境回到波形图一步一步对照手册检查多半能找到答案。这片经验是我反复踩坑换来的希望你能少走一些弯路。