上一篇文章我们聊到Crosslink-NX把MIPI图像数据采集进来之后,在FPGA内部完成了格式转换和缓存,评论区马上就有朋友问:数据攒在内存里不算完,怎么才能稳定地搬到电脑上?这篇我就把这条链路彻底打通,用CH346C这颗USB2.0 Slave FIFO桥接芯片,给Crosslink-NX接一条上行通道,让FPGA侧的数据能以FIFO接口的方式直接交到USB主机手里。整篇文章基于我实际调试这个项目的记录,思路、代码、坑位都放出来,给你做个参考。
1. 先交代选型背景:Crosslink-NX的上行数据通路,为什么是USB2.0 Slave FIFO
1.1 这个连载做到第14篇时的前置状态
前面13篇的进度,大致是这么个情况:Crosslink-NX通过MIPI D-PHY接收图像传感器的数据流,在FPGA内部做了处理、缓存,需要往外部传输。Crosslink-NX这颗芯片的定位本来就是低功耗桥接、视觉处理,适合在摄像头和上位机之间做一个“中间人”。但数据在上位机侧要能被识别、被读取,必须走一条和PC能对接的总线。
经常看到有朋友问:为什么不用UART?为什么不用千兆网?为什么不用PCIe?原因很简单。UART过顶天几Mbps,传个字体、传个温度没问题,传图像完全不现实。千兆网麻烦在MAC和PHY,Crosslink-NX没有现成的RGMII方案,自己要塞一个MAC核,占用资源不说,数据搬运逻辑也复杂。PCIe对Crosslink-NX来说能挂但是整体开销太重,为了一个工业相机或者数据采集盒专门堆PCIe链路,板卡成本、layout成本都上去了。
于是USB2.0就成了最务实的选择——每台电脑都有,开发工具链成熟,480Mbps的峰值速率跑图像预览级别的数据流,绰绰有余。
1.2 上行通路的四种方案,我为什么最终选了CH346C
市面上FPGA接USB这条路,大致有四种走法,我先对比一下再解释结论。
| 方案 | 典型代表 | FPGA侧接口复杂度 | 实际能达到的带宽 | 适合场景 |
|---|---|---|---|---|
| 串口转USB方案 | CH340, CP2102, FT232 | 极低,UART接口 | 3Mbps以下 | 低速调试、传感器数据回传 |
| SPI/I2C转USB方案 | FT232H, FT2232H | 低,SPI从机接口 | 10MB/s左右(受SPI时钟限制) | 低速采集、小流量传输 |
| FPGA直接外接USB PHY + 自己实现协议栈 | 无内置USB PHY的FPGA + USB3300等 | 高,需要自己实现ULPI、描述符、控制传输、批量传输状态机 | 理论可达40MB/s+,但开发量极大 | 极少数必须定制USB功能的场景 |
| USB2.0 Slave FIFO桥接芯片 | CH346C, CY7C68013A(FX2LP) | 低,FPGA侧就是异步/同步FIFO接口 | 40MB/s以上(受批量传输有效带宽限制) | 高速图像、数据连续传输、需要快速上手的项目 |
CH346C和CY7C68013A的Slave FIFO模式很相似,都适合做FPGA和USB之间的桥。但CH346C有一个明显优势:芯片内部把USB协议引擎、收发器、以及Slave FIFO控制逻辑都整合好了,FPGA侧不需要去碰USB协议栈,只需要对着FIFO信号读写。FX2LP本身要写8051固件去配置工作模式,虽然网上基础工程很多,但毕竟多一个MCU固件需要维护。CH346C这类芯片更加“桥”化,很多工作模式直接用硬件引脚或者配置寄存器固化,省掉了8051的维护量。
另外CH346C支持批量传输端点,别小看这一点。批量传输能占满整个USB带宽的80%以上,并且自带CRC校验和重传机制,对数据可靠性要求高的工业场景非常合适。相比之下,用中断传输虽然延迟固定,但带宽上不去;等时传输带宽高却不保证可靠交付。我们这次传输的是图像数据流,错一个字节都会花屏花线,批量传输是权衡之后最稳的选型。
1.3 这块板子上,CH346C具体承担什么角色
CH346C在整条数据链路里,扮演的角色就是“把FPGA的并行FIFO接到USB总线上”。数据从Crosslink-NX的IO引脚出来,以16位总线的形式进入CH346C的FIFO,芯片自动把它打包成USB批量传输包,提交给上位机。
FPGA侧看到的CH346C周边逻辑,就是一个可以往里面“倒水”的FIFO。你要做的,就是管理好FIFO的空满标志,在合适的时候把数据放到总线上,再给一个写选通信号。USB协议里那些令牌、握手、ACK/NACK、CRC,全是芯片内部处理掉的。这也是我强烈推荐Slave FIFO方案的原因:把复杂的东西封装进芯片,让FPGA开发者把精力集中在自己真正该管的数据搬运上。
2. CH346C Slave FIFO接口信号与读写时序:先吃透手册再写RTL
2.1 Slave FIFO接口的本质:USB端点变成一块“可读写的内存”
理解Slave FIFO,用生活里的例子最方便。PC和CH346C之间,USB总线上是批量传输管道;CH346C和FPGA之间,就是一组并行的FIFO引脚。PC端发一个“读端点”请求,CH346C就把自己FIFO里的数据“推”到USB总线上。FPGA端要发数据,就往CH346C的FIFO里“推”,芯片检测到FIFO非空,就会把数据“拉”到USB总线上。
开发者的体验,就好比PC软件在读取一块虚拟硬盘,而FPGA在往同一块硬盘里写数据。中间不需要关心USB的调度细节。
Slave FIFO接口一般在芯片手册里有两种模式:同步和异步。同步模式需要提供一个时钟,数据在时钟沿被采样;异步模式完全靠读/写选通的跳变来触发,不依赖时钟,但时序设计上更考究。CH346C的Slave FIFO接口可以选择同步模式,我会推荐你优先用同步模式——时钟沿统一采样,时序分析简单,FPGA里的逻辑也能直接和它对齐。
2.2 关键信号一览
我以16位数据总线为例,把CH346C Slave FIFO模式涉及到的核心信号列成一张表,方便你对着手册逐项核对:
| 信号名 | 方向(相对FPGA) | 作用 |
|---|---|---|
| CLKI / IFCLK | 输入 | 同步模式时钟,由FPGA提供,一般30MHz~48MHz |
| DATA[15:0] | 双向 | 数据总线,写入和读出共用 |
| SLCS_N | 输入 | 片选信号,拉低表示选中该FIFO通道 |
| SLWR_N | 输入 | 写选通,低有效;与时钟配合将数据写入FIFO |
| SLRD_N | 输入 | 读选通,低有效;从FIFO读出数据到数据总线 |
| SLOE_N | 输入 | 输出使能,控制数据总线输出方向 |
| FIFOADR[1:0] | 输入 | 选择端点FIFO,一般可以区分上传/下发通道 |
| FLAGA_N/FLAGB_N/FLAGC_N | 输出 | FIFO状态标志,常见的是空/满/可编程阈值 |
| PKTEND_N | 输入 | 包结束信号,强制把当前FIFO内容作为一个短包提交给USB |
| RST_N | 输入 | 复位信号,低有效 |
注意,不同芯片厂家的Flag定义会有差异,有的芯片把FLAGA定义为“可写”,有的定义为“空”,甚至有的是高有效。这些细节必须以CH346C的数据手册为准,我们项目里专门花了两个晚上核对这一组信号的有效电平,光在这个上面吃亏就很不值得。
2.3 同步写时序的关键点
同步Slave FIFO写数据的约定,通常是这样:
- 将FIFOADR设置到要写的端点,保持稳定。
- 将数据放到DATA[15:0]总线上。
- 等待FIFO的“可写”标志有效(表示还有空间)。
- 在时钟上升沿到来时,让SLWR_N拉低,芯片采样数据完成写入。
一个常见的错误是:数据总线的变化和SLWR_N的变化发生在同一个时钟沿,导致芯片采样时数据还没稳定。规范做法是让数据总线在SLWR_N拉低之前就稳定,最好用一个寄存器输出,在时钟下降沿更新数据、在上升沿让芯片采样,这样两边能错开半个周期,时序裕量会大很多。
我项目里设定的总线位宽是16bit,时钟跑30MHz,理论写速率就是16bit × 30MHz = 60MB/s。USB2.0批量传输的实测有效带宽通常在35~45MB/s之间,所以30MHz的写时钟不仅够用,还能留下一点余量处理FIFO满标志带来的等待周期。
2.4 硬件连接里的几个细节
- 电平匹配:Crosslink-NX的IO Bank如果配置成3.3V LVCMOS,可以直接和CH346C的3.3V逻辑相连。不过Crosslink-NX有的Bank只支持1.8V,这种情况要么换Bank,要么加电平转换芯片。建议在原理图阶段就把Bank电压和代码里的IO标准规划好,省得后期飞线。
- 引脚分配:DATA[15:0]尽量放在同一个Bank,并做等长约束。FIFO接口虽然是并行总线,跑30MHz不算高频,但16根线的skew如果太大,上升沿采样依然有风险。差分对和普通IO保持安全间距。
- 复位信号:RST_N上电后至少保持几十微秒低电平,FPGA里可以用一个计数器产生上电复位,再释放给CH346C。别直接连FPGA的全局复位,否则FPGA还没配置完成,芯片就处于不稳定状态。
- 串阻预留:每个数据线串联一个22Ω~33Ω电阻。USB2.0接口本身速度不高,但并联电阻能改善信号边沿,这在排查误码的时候会很有用。
3. Crosslink-NX侧RTL实现:状态机、FIFO缓存与端点管理
3.1 整条数据流,串起来是什么样
从Crosslink-NX内部到CH346C,数据流的路径是这样:
传感器数据 → MIPI接收 → Crosslink-NX内部处理 → 异步FIFO(跨时钟域缓冲) → Slave FIFO写状态机 → CH346C内部的FIFO → USB批量端点 → PC上位机这里最关键的组件是异步FIFO。Crosslink-NX内部处理模块的时钟,和Slave FIFO写状态机工作的时钟,往往是不同频率。传感器侧时钟可能是24MHz,USB桥侧时钟是30MHz,直接跨时钟域往下传数据必然出错。所以必须在中间放一个异步FIFO,左侧用一个时钟写,右侧用另一个时钟读,把数据安全地从一个时钟域搬到另一个时钟域。
Lattice的Crosslink-NX系列里,可以用Lattice Primitive里的异步FIFO,也可以用通用FIFO IP配置成不同读写时钟。重点是要把读侧的可编程满阈值(prog_full)引出来,用于控制上游停止写入,防止FIFO溢出。
3.2 写状态机的核心逻辑
写状态机是整个FPGA侧驱动CH346C的关键。它的任务就是:从异步FIFO读数据,然后按照Slave FIFO的时序要求,把数据搬运到CH346C的数据总线上。
我用Verilog实现了一个比较简洁的写状态机,状态划分为IDLE、WRITE、WAIT_FULL三个状态,思路如下:
localparam IDLE = 3'd0, WRITE = 3'd1, WAIT_FULL = 3'd2; reg [2:0] state_wr; reg [15:0] data_out_r; reg slwr_n_r; reg pktend_n_r; always @(posedge clk_usb or negedge rst_n) begin if (!rst_n) begin state_wr <= IDLE; slwr_n_r <= 1'b1; pktend_n_r <= 1'b1; data_out_r <= 16'd0; end else begin case (state_wr) IDLE: begin // 当上游FIFO非空、CH346C FIFO未满且当前包没有结束后,进入写状态 if (fifo_in_empty_n && !ch346c_full_n) begin slwr_n_r <= 1'b0; data_out_r <= fifo_in_data; state_wr <= WRITE; end end WRITE: begin // 数据在时钟上升沿被CH346C采样,同时拉高写选通,回到IDLE slwr_n_r <= 1'b1; fifo_in_rd <= 1'b0; // 在WRITE期间给出读请求 state_wr <= IDLE; end WAIT_FULL: begin // 当FLAG显示FIFO满时,等待满标志释放 if (!ch346c_full_n) begin state_wr <= IDLE; end end default: state_wr <= IDLE; endcase end end这里有个很重要的地方:写选通和数据的时序关系。我设计的是在IDLE状态先把数据更新到数据总线上,然后在下一个时钟沿到来时保持SLWR_N低电平,让芯片采样。由于数据在上升沿之前已经稳定,芯片采到的就是正确数值。如果你在同一个进程里同时翻转数据总线和SLWR_N,可能就违反建立时间要求了。
另外fifo_in_rd的时序要特别小心。异步FIFO的读请求不能和读出的数据在同一时刻判断,否则会漏读。建议把读请求做成组合逻辑,在进入WRITE状态时同时给出,然后下一个状态拉掉,这样能保证每拍读一个数据,不丢节拍。
3.3 为什么要在状态机里加入WAIT_FULL状态
CH346C内部的FIFO不会无限大,当USB总线上设备繁忙,或者PC端应用程序读数据不及时,FIFO就会满。这时候如果FPGA继续往里面写,数据就会被覆盖,产生不可恢复的丢失。
WAIT_FULL状态的作用是:发现满标志有效后,挂起写操作。我见过不少初版逻辑根本没加这个状态,满标志来了还硬写,最后图像数据断断续续、画面撕裂,定位问题花了一天。加一个WAIT_FULL状态并不会浪费带宽,因为USB批量传输本来就有间断性,满标志释放后继续写即可,实时性影响很小。
3.4 分包与PKTEND的处理
USB批量传输每次传输的最大包长度是512字节。如果数据总线上连续写入512字节,芯片会自动把这些数据组成一个USB包发送出去。但最后一帧的数据往往不足512字节,这时就需要PKTEND信号,告诉芯片:当前数据就这么多了,直接组成一个短包提交。
我的做法是:每传输一定长度后,拉一个时钟周期的PKTEND_N低电平。具体长度选择可以结合应用场景,比如图像的一行正好是2048字节,那我就每2048字节提交一次,PC端按行解析非常方便。如果PKTEND处理不正确,最后一个短包不会被发送,PC端会一直等下一条数据,导致卡死或超时。
3.5 多端点方向的考虑
有些项目既需要上行传图像,也需要下行接收控制命令。CH346C一般有多个FIFO端点,通过FIFOADR选择。我的做法是FIFOADR[0]固定处理上行数据,FIFOADR[1]处理下行命令。这样一个芯片同时完成双向通信,结构很清晰。
下行的读操作其实和写操作对称,也就是FPGA侧要实现对Slave FIFO的读时序。读操作的核心是SLRD_N和SLOE_N的配合:SLOE_N低电平让芯片输出数据到总线,SLRD_N低电平时钟沿把数据锁存到FPGA侧。实测下行带宽要求不高,用几个字节的控制命令完全够。
4. 实测与踩坑记录:带宽、误码、满标志这几个坑,我逐个排掉的
4.1 第一个坑:满标志有延迟,不能等它“变满”才停
板子调出来之后,第一版逻辑在连续传输几十秒钟后,偶发数据丢失。逻辑分析仪抓FLAGC_N信号,发现一个规律:CH346C的满标志相对实际FIFO状态存在固定延迟,大约有1~2个时钟周期。也就是说,FIFO内部实际已经满了,但满标志还没拉低;等你看见满标志再停,最后那两拍数据已经写爆了。
这就好比接水的时候,水杯已经满了,但眼睛看到“满了”的信息传到手上,中间还有半秒的延迟,手还是继续开着水龙头,水就溢出来了。
解决办法是给标志信号加一个“提前量”。我当时是做了一个两级同步器,把FLAG信号同步到FPGA时钟域之后,再额外延迟两拍,和一个比较阈值逻辑配合,模拟“软满”信号。当FIFO剩余空间少于预设阈值时,就认为FIFO即将满,提前停止写入。即使FLAG还有延迟,也留出了足够的反应时间。
4.2 第二个坑:带宽上不去,实测只有20MB/s左右
按30MHz、16bit位宽计算,理论写入速率有60MB/s,USB2.0一般极限也有40MB/s左右。但第一版实测只跑到20MB/s,差了一半。排查下来有两个致命问题。
第一个问题是状态机里插入了一拍多余的等待。我当时为了图省事,在IDLE到WRITE之间加了一个过渡态,导致每写一个16bit数据就要消耗两个时钟周期。带宽直接砍半。优化办法是把状态机压缩,让“数据就绪”和“写信号有效”尽可能在同一个状态内完成,连续写时没有气泡。
第二个问题是FT232的对比实验启发了我。USB批量传输是有调度间隙的,不是每毫秒都在传输,每个包的间隔芯片会有协议开销。如果每个周期都在频繁发送,反而不如一次连续发送多个512字节包来的高效。这个其实不是FPGA侧能控制的,但如果你发现带宽卡在某个值上,试着在PC端用更大的读取缓冲区(比如64KB)一次性读一堆数据,减少USB请求的调度开销,实测有效。
4.3 第三个坑:数据错位,图像全是斜条纹
带宽跑到40MB/s以后,新的问题出现了:PC端收到的数据顺序偶尔错位,整帧图像出现一条一条的斜线。我用回环测试的方法定位——FPGA侧发送一个递增计数器0、1、2……一直到65535循环,PC端接收后解析,发现大部分区间是连续的,但每隔一段会出现连续几个数错位,像是丢失了一个字节。
这个问题的根子还是在FIFO读时序上。异步FIFO的读请求和数据输出之间有时序约束,如果读请求信号在WRITE状态里同时拉高或拉低,导致数据实际上比预期晚了一拍,就相当于每N个数据里丢掉一个。解决办法是把读请求固定在WRITE状态的前半段给出,并保证数据到达数据总线的时间早于CH346C的采样沿。我修改之后,错位消失了。
另外我还做了一层保险:在数据流里插入了帧头。每一帧数据的开头都写0x5A5A和一个32位递增帧号,PC端只要解析到0x5A5A,就重新对齐数据流。这样就算偶发丢包,PC端也能自动恢复同步,不会一直错位下去。
4.4 第四个坑:USB端点STALL,PC端直接报设备错误
还有一种情况,上位机读数据读到一半,返回一个USB错误,设备直接STALL。排查下来是端点的配置描述符和FPGA这边FIFO选择不一致。我在FPGA里用FIFOADR[1:0]选择端点,但在CH346C配置里,这个端点被配置成了输出方向(PC到设备),FPGA这边却当成上行数据在写,芯片端到端的方向错了就STALL。
这提醒我:拿到一块CH346C板子,第一件事就是确认它的默认配置是什么,哪个FIFO是可写、哪个可读,千万别靠猜。我当时对照寄存器配置逐项改,把端点改成IN方向、批量传输,问题立刻消失。
4.5 一套可复用的排查链路
如果你也遇到类似问题,按这个顺序排查,效率会高很多:
- 先确认PC端能不能枚举到设备,确认端点方向和类型。
- 用逻辑分析仪抓FPGA侧写CH346C的时序,看SLWR_N和DATA配合是否满足手册要求。
- 在FPGA内部加一个计数器,统计写入CH346C的数据量;PC端统计收到的数据量,两边对比就能定位是FPGA没写进去,还是USB传输丢包。
- 数据流里插帧头帧尾,用递增序列做回环测试,验证数据的连续性和顺序性。
- 最后别忘检查地线。USB连接器和FPGA板之间如果参考地不干净,高速传输的时候误码率会莫名其妙上升。
5. PC端识别与端到端验证:让Windows和Linux都能顺畅读取CH346C
5.1 Windows下的设备识别
CH346C在Windows系统下,如果是用厂商自己的驱动或者WinUSB驱动,设备管理器里会显示一个USB设备。如果是批量传输的自定义设备,Windows默认不一定有可用驱动,这时可以用libusb或WinUSB来代替。常见做法是使用Zadig工具把设备驱动切换成WinUSB,然后上位机用libusb进行读写。
注意,Zadig切换驱动只对开发调试阶段好用,产品化的时候建议签一个正式的驱动或者使用微软的WinUSB免签方式部署。开发阶段图省事直接上Zadig没有毛病,踩坑少、上手快。
5.2 Linux下的免驱访问
Linux下USB设备可以用usbfs,也可以直接用libusb库。绝大多数情况下内核自带的usbfs驱动就会接管设备,你甚至不需要安装任何额外的驱动,只要确认设备有读权限就行。一般需要写一个udev规则,把设备节点的权限改成0666,或者把当前用户加入plugdev组。
我用pyusb做过一个简单的Python读取Demo,思路是找到VID/PID,打开设备,然后启动一个批量读线程:
import usb.core import usb.util DEV_VID = 0x1A86 # 替换成CH346C的实际VID DEV_PID = 0x???? # 替换成CH346C的实际PID dev = usb.core.find(idVendor=DEV_VID, idProduct=DEV_PID) if dev is None: raise ValueError("Device not found") # 如果系统已经有内核驱动占用,先释放 if dev.is_kernel_driver_active(0): dev.detach_kernel_driver(0) # 设置配置 dev.set_configuration() cfg = dev.get_active_configuration() intf = cfg[(0, 0)] # 假设EP1是批量输入端点 ep_in = usb.util.find_descriptor( intf, custom_match=lambda e: usb.util.endpoint_direction(e.bEndpointAddress) == usb.util.ENDPOINT_IN ) while True: data = ep_in.read(4096, timeout=1000) # 在这里解析数据 print(f"received {len(data)} bytes: {data[:16].tobytes().hex()}")这个脚本看起来简单,但已经把设备的打开、内核驱动释放、端点查找、批量读全部串起来了。实测在树莓派和Ubuntu主机上都能稳定跑到35MB/s以上,CPU占用也不高。
5.3 端到端数据一致性测试
硬件调通之后,一定要做一次端到端的数据一致性测试。测试方案很简单:FPGA内部用LFSR生成一段伪随机序列,打包成固定帧格式,每帧带帧号和数据长度,发送给PC。PC端解析每一帧,验证帧号递增、数据长度正确、LFSR校验值匹配。保留这个测试代码,后续每次改逻辑都可以回归。
矩阵如下:
| 测试项 | 预期结果 | 实际结果 | 结论 |
|---|---|---|---|
| 枚举测试 | 设备管理器/设备树中出现设备 | 正常 | 通过 |
| 小包回环测试 | 1000包0x5A5A,无丢包无错位 | 通过 | 通过 |
| 满负荷连续传输1小时 | 计数器无丢码,PC端数据递增连续 | 偶发两次丢包,加WAIT_FULL后消失 | 通过 |
| 比特错误测试 | LFSR校验正确 | 通过 | 通过 |
5.4 带宽为什么稳定在40MB/s左右,上不去了
明白人都知道USB2.0的理论速率480Mbps,折算下来是60MB/s,但实际批量传输一般只能跑到35~45MB/s。这中间的损耗来自USB令牌包、握手包、帧间隔、调度间隙协议开销。就算你FPGA侧写得再快,PC侧读得再快,也突破不了物理上限。
所以带宽设计的时候,不要只盯着USB2.0理论数值做预算。如果你的摄像头数据率就超过40MB/s,老老实实用USB3.0桥接芯片或者走千兆网。CH346C这种USB2.0方案,适合数据量在40MB/s以内的应用。我们这次图像流大约是30MB/s,留了余量,稳。
6. 这个方案最后再扩展点什么,我会怎么走
CH346C这个方案目前跑通之后,整条链路从Crosslink-NX到CH346C再到PC上位机,带宽稳定在35~40MB/s之间,图像数据连续传输一小时没有丢包。实际项目里这套方案最大的价值就是开发周期短、稳定可靠,不用陷进USB协议栈的泥潭里。
后续如果要做更大数据量的产品,我准备把桥接芯片换成USB3.0的Slave FIFO方案,FPGA侧状态机结构基本不变,主要是把数据总线位宽从16bit扩展到更高的位宽,时钟频率也可以往上提。同样的状态机框架、同样的PKTEND处理思路,迁移成本很低。这个改造思路我后面可能会单独写一篇,到时候再展开聊聊。
再分享一个小技巧:CH346C这类芯片做批量传输的时候,PC端读取缓冲区的长度尽量大于1MB,而且要用异步方式读取,不要阻塞在等待上,实测能再挤出两三MB/s的余量。这些细节通常数据手册不会告诉你,都是调试时一点一点试出来的。