
1. 整体设计思路与方案选型做嵌入式视觉或者高速数据采集的工程师手里十有八九都攒过几块FPGA板子。FPGA这边采集到数据之后怎么低成本、高效率地扔给PC端做处理始终是一个绕不开的环节。串口太慢千兆以太网需要协议栈PCIe对大多数板卡来说又过于奢侈。这两年我在项目里反复用到一个组合Lattice的Crosslink-NX FPGA加一颗WCH的CH346C让FPGA通过USB 2.0把数据送到电脑上。今天这篇连载就专门把这条路彻底走通。先说说为什么是CH346C。这颗芯片本质上是一个USB转8位并行FIFO桥接器FPGA作为主机去读写它的FIFO缓冲区PC端看到的则是一个标准的USB设备。它与大家更熟悉的FT245BM/FT2232H系列在思路上是类似的但CH346C最大的优势是采购容易、资料齐全、性价比高而且在国产化要求越来越普遍的今天这颗料几乎是零障碍。更关键的是CH346C内置了USB收发器和SIE串行接口引擎外部只需要一个12MHz晶振硬件成本压得非常低。那为什么又非要Crosslink-NX这颗FPGA本身带有硬核MIPI D-PHY、LVDS、灵活的逻辑单元封装从4mm x 4mm的小尺寸开始特别适合做图像传感器采集、屏幕桥接、边缘预处理这样的场景。数据在FPGA内部处理好之后通过一个简单的并行FIFO控制器送给CH346C整条链路是干净利落的。再说说Slave FIFO这种模式。我最早接触FIFO桥接是从FT245开始后来换到CH346之后发现Slave FIFO模式本质上就是让FPGA侧完全掌握读写的主动权PC下发数据时FPGA作为从设备检测标志位把数据从FIFO里读走PC想收数据时FPGA作为主机往FIFO里写。这种外部主机操作内部FIFO的模型比串口和SPI都要高效得多主要省去了协议帧的解析开销一个字节一个字节地并行吞吐USB 2.0的带宽利用率能高出一大截。这个连载系列一直以实战笔记的形式在写那么这一篇的定位就是硬件连接、FPGA读写时序、状态机代码、PC端驱动调用、实测带宽、常见坑位排查一条龙全部讲透。无论是刚开始用CH346C的初学者还是已经跑通串口想提升带宽的老手花二十分钟看完都能在自己的板子上复现出来。1.1 为什么USB 2.0桥接依然是性价比之王很多朋友一提到USB传输第一反应就是USB 3.0、甚至USB 4.0。但在实际工业项目里USB 2.0的480Mbps理论带宽依然能覆盖大量应用场景。举个例子一个720p60的灰度摄像头像素时钟大约在36MHz8位像素数据大概就是36MB/s而USB 2.0的实际有效吞吐能到35~40MB/s取决于端点和传输模式刚好卡在临界点以内。如果再对图像做一下简单的压缩或者降采样USB 2.0完全跑得动。CH346C作为USB 2.0全速/高速设备内部FIFO读写接口可以工作在异步或者同步模式。异步模式控制信号简单适合我们手工写状态机同步模式则需要芯片提供一个时钟输出吞吐量会更高但对时序要求更严格。我在文章后半部分会给出两者的取舍对比这篇以异步模式为主线展开因为它的读时序最直观也最容易排查问题。1.2 Crosslink-NX在链路中的角色Crosslink-NX在整个链路里做的事情并不多但非常重要它负责把传感器或者前级接口采集到的数据按FIFO协议的要求变成CH346C能识别的读写时序。这一层如果用单片机来做频率和并行总线宽度都受限如果用一颗大FPGA来做又浪费了成本。Crosslink-NX恰好卡在中间——逻辑规模不大但硬核接口丰富IO电平可配置非常适合当数据搬运工。我在工程里用的是LIFCL-40系列逻辑资源不到40K但足够放下图像采集逻辑、一个FIFO控制器、一个简单的命令解析器。开发环境用Lattice Radiant流程比Vivado轻量不少从新建工程到综合布局布线几分钟就能跑完一版。2. CH346C接口机制与Slave FIFO协议拆解2. CH346C接口机制与Slave FIFO协议拆解2.1 CH346C引脚功能与工作模式CH346C的并行接口侧引脚不算多核心信号也就十来根具体包括D[7:0]8位双向数据总线跟FPGA直接相连RD#读选通低电平有效外部主机FPGA拉低时从FIFO中读出一个字节WR#写选通低电平有效外部主机拉低时向FIFO中写入一个字节RXF#FIFO非空标志低电平表示FIFO内有数据可读TXE#FIFO非满标志低电平表示FIFO有空余空间可写OE#输出使能低电平时驱动数据总线有些设计里把OE#直接接地但建议由FPGA控制方便三态切换CLKOUT同步模式时钟输出异步模式下不可用RESET#芯片复位低有效。CH346C的工作模式由硬件引脚配置决定通常SLAVE模式是默认选择。WCH官方数据手册里有完整的引脚配置表我这里不多抄表只强调一点如果你想要异步Slave FIFO芯片配置脚必须接对否则插上USB后PC端枚举出来的设备类型都不一样后面所有时序全部白搭。2.2 异步Slave FIFO读写时序分析在动手写代码之前必须先在心里把波形画熟。CH346C异步读操作有几个关键时间参数t_RXDRD#拉低到数据总线有效的时间典型值大概在几十纳秒t_RFPWRD#低电平脉冲宽度最小值通常在40ns以上t_RFARD#拉低后RXF#变化的时间这个参数决定了我们在状态机里必须留出裕量。实际读一个字节的流程是先看RXF#是否为低FIFO里有货如果是低FPGA把RD#拉低等待数据总线稳定然后再把数据采样进寄存器最后拉高RD#。这里有一个新手非常容易踩的坑——总觉得数据在RD#拉低瞬间就有效立刻去采结果采回来的数据忽对忽错。正确做法是在RD#拉低之后至少等一个固定延时比如两个时钟周期再去读D[7:0]。写操作同理先看TXE#是否为低FIFO没满如果是低FPGA把数据放到D[7:0]上然后拉低WR#保持一段时间后再拉高数据被锁存进CH346C内部FIFO。注意数据在WR#上升沿被采样因此数据必须保证在上升沿前后都满足建立保持时间要求。整个读写控制器的本质就是一个跨时钟域握手的过程。CH346C内部的USB引擎始终在以480Mbps高速模式或者12Mbps全速模式的节奏搬运数据但我们FPGA侧用的是自己的时钟。两个时钟域之间通过FIFO标志位来异步握手RXF#和TXE#是唯一的通信桥梁。2.3 同步模式还是异步模式怎么选CH346C除了异步Slave FIFO还支持同步Slave FIFO。同步模式下CH346C会输出一个时钟CLKOUTFPGA用这个时钟的上升沿直接采样数据或者驱动WR#省去了异步握手等待的时间因此带宽更高。但同步模式的坏处也很明显所有信号都跟CLKOUT对齐跨时钟域时序分析起来要更小心而且CLKOUT对PCB布线长度更敏感。我自己的经验是异步模式适合验证功能和逻辑正确性如果工程已经稳定、需要极限吞吐再切换到同步模式做性能优化。异步模式跑通了切换成同步模式的改动量也比较小无非是时钟源从内部时钟换成CLKOUT状态机里去掉固定延时。3. 硬件电路搭建与注意事项3.1 最小系统连接关系先看一下Crosslink-NX与CH346C之间到底需要连多少根线我按实际工程给一张接线清单CH346C引脚Crosslink-NX引脚说明D[7:0]IO_Lx[7:0]8位双向数据总线建议走线等长RD#IO_Lx[8]读选通输出WR#IO_Lx[9]写选通输出RXF#IO_Lx[10]FIFO可读标志输入TXE#IO_Lx[11]FIFO可写标志输入OE#IO_Lx[12]输出使能可固定接地RESET#IO_Lx[13]芯片复位控制CH346C那一边的USB接口是标准USB 2.0 Type-A或者Type-CD/D-走差分对加22Ω串联电阻靠近芯片放置。12MHz晶振的负载电容一般取18~22pF具体以数据手册为准。如果使用无源晶振务必先示波器确认起振正常我见过不少板子因为晶振没振起来插USB完全没反应。3.2 电平匹配与电源去耦CH346C的工作电压是3.3VCrosslink-NX的Bank电压如果选3.3V可以直接相连不需要电平转换。如果Crosslink-NX的Bank用了1.8V那么必须加电平转换芯片或者在Crosslink-NX内部配置开漏输出外加上拉电阻但这种方法速度上不去不推荐在正常传输路径上使用。电源方面CH346C的VCC引脚需要3.3V供电建议单独用一颗LDO供电不要直接从数字核心的3.3V上取电。USB插拔瞬间会产生很大的电流冲击如果供电设计不结实轻则传输掉线重则芯片锁死。FPGA的IO Bank电源和CH346C电源做好Π型滤波0.1μF和10μF电容每种至少放两三个均匀分布在芯片周围。3.3 布局布线的几个细节数据总线D[7:0]尽量在同一层走避免过多过孔过孔会造成信号延时偏差RD#、WR#、RXF#、TXE#这四根控制信号长度控制在数据线的两倍以内避免时序偏差USB D/D-差分对要做100Ω差分布线并且包地处理在CH346C底下铺完整地平面不要有地平面开槽否则EMI会变得很难看。我第一版做的时候偷懒把USB差分线从板边走了长线又绕回来结果USB枚举时快时慢最后排查下来是差分阻抗不连续重新改版才解决。这属于看起来能跑但极其不稳定的典型问题第一次画板就要避开。4. FPGA端Slave FIFO读写控制器实现4.1 整体模块划分Crosslink-NX的工程里我没有把控制逻辑都堆在一个顶层文件里而是拆成了几个小模块方便后期维护和复用dcm_clk_gen时钟生成模块将Crosslink-NX内的PLL输出产生控制器使用的核心时钟slave_fifo_wr写通道模块负责把FPGA内部数据打包写入CH346C FIFOslave_fifo_rd读通道模块负责从CH346C FIFO读出数据并送入FPGA内部处理user_logic_top用户逻辑顶层可以是图像采集接口、数据采集接口或者自定义寄存器组。这种分层在调试阶段特别重要。如果出现问题可以先单独测试写通道用FPGA内部的计数器产生递增数据PC端读回来检查有没有乱序以此确认写通道时序是否OK。再用PC下发固定数据FPGA内部比对读取结果来验证读通道。两块通道各自验证通过后再联调。4.2 写通道状态机设计写通道的状态机相对简单关键在于用TXE#标志位和固定延时确保数据被可靠锁存。下面是我在工程里验证过的Verilog代码节选关键部分module slave_fifo_wr ( input wire clk, input wire rst_n, input wire txe_n, // 1: FIFO full; 0: can write output reg wr_n, output reg [7:0] data_out, input wire wr_req, // 写请求脉冲 input wire [7:0] data_in, output wire wr_busy, output wire wr_done ); localparam IDLE 2d0; localparam WAIT_TXE 2d1; localparam DRIVE_DATA 2d2; localparam PULSE_WR 2d3; reg [1:0] state; reg [1:0] delay_cnt; reg write_done; assign wr_busy (state ! IDLE); assign wr_done write_done; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; wr_n 1b1; data_out 8d0; delay_cnt 2d0; write_done 1b0; end else begin case (state) IDLE: begin write_done 1b0; wr_n 1b1; if (wr_req) state WAIT_TXE; end WAIT_TXE: begin if (!txe_n) begin data_out data_in; delay_cnt 2d0; state DRIVE_DATA; end end DRIVE_DATA: begin // wait for data stable delay_cnt delay_cnt 1b1; if (delay_cnt 2d2) begin wr_n 1b0; state PULSE_WR; end end PULSE_WR: begin delay_cnt delay_cnt 1b1; if (delay_cnt 2d2) begin wr_n 1b1; write_done 1b1; state IDLE; end end endcase end end endmodule这里有两个细节值得展开。第一个细节是数据先建立、再拉低WR#。代码里DRIVE_DATA状态空等两个时钟周期其实就是在让data_out稳定。如果直接把数据放上去立刻拉WR#数据建立时间可能不够CH346C采样到的字节就是错的。以小枫叶子经验很多传输数据的错误都源于这一下没等够。第二个细节是拉高WR#之后也要处理FIFO状态。PULSE_WR状态结束后回到IDLE此时如果下一笔数据立刻到来WAIT_TXE会再次判断TXE#。这里不用管TXE#何时变高因为CH346C内部的写操作在WR#上升沿已经完成TXE#的更新有一定延迟FPGA下一轮判断时看到的是新状态即可。4.3 读通道状态机设计读通道稍微复杂一点因为要同时控制OE#和RD#。下面给出关键代码module slave_fifo_rd ( input wire clk, input wire rst_n, input wire rxf_n, // 0: data available input wire [7:0] data_in, output reg rd_n, output reg oe_n, output reg [7:0] data_out, input wire rd_req, output wire rd_busy, output wire rd_valid ); localparam IDLE 2d0; localparam PULSE_RD 2d1; localparam CAPTURE 2d2; reg [1:0] state; reg [1:0] delay_cnt; reg read_valid; assign rd_busy (state ! IDLE); assign rd_valid read_valid; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; rd_n 1b1; oe_n 1b1; data_out 8d0; delay_cnt 2d0; read_valid 1b0; end else begin case (state) IDLE: begin read_valid 1b0; oe_n 1b1; rd_n 1b1; if (rd_req !rxf_n) begin oe_n 1b0; delay_cnt 2d0; state PULSE_RD; end end PULSE_RD: begin rd_n 1b0; delay_cnt delay_cnt 1b1; if (delay_cnt 2d2) begin rd_n 1b1; data_out data_in; // capture data read_valid 1b1; state CAPTURE; end end CAPTURE: begin oe_n 1b1; state IDLE; end endcase end end endmodule读通道里最容易犯的错误是在IDLE只检查rd_req没检查rxf_n结果FIFO里没数据时也去读读出来的是垃圾值。这里用rd_req !rxf_n同时判断没有数据就不发起读操作。另一个坑是数据采样时刻。RD#拉低后CH346C需要一段时间把数据总线驱动起来所以要在PULSE_RD状态里等待若干周期再采样data_in。等待周期数取决于系统时钟频率比如100MHz时钟2个周期就是20ns一般情况下够用了。如果时钟更快需要适当增加等待周期。4.4 写简单的FIFO背压处理在实际应用中FPGA内部的数据源不会每时每刻都有数据FIFO控制器的背压信号要处理好。我通常会在写通道外面再加一个内部FIFO数据源先写入内部FIFO然后Slave FIFO控制器按自己的节奏从内部FIFO读数据送往CH346C。这样数据源就不用卡着CH346C的节奏走逻辑设计舒服很多。Crosslink-NX内部有专门的硬件FIFO资源用Radiant里的FIFO IP核即可配置位宽设8位深度设512或者1024几乎不占用太多逻辑单元。内部FIFO的空满信号作为背压信号给上游模块上游看到满信号就暂停发送这是最稳妥的做法。4.5 时序约束不能省很多FPGA工程师在调试CH346C这类接口时觉得信号不多、频率不高就不写时序约束了。这是一个非常危险的想法。虽然没有约束时序照样能跑但综合器可能会把关键路径布局得不够紧凑等到环境温度变化或者电压波动时时序裕量不够系统就随机出错。在Radiant工程里我给这个接口写了简单的SDC约束create_clock -period 10.000 [get_ports clk_in] ;# 100MHz set_input_delay -clock clk -max 8 [get_ports {rxf_n txe_n}] set_output_delay -clock clk -max 6 [get_ports {rd_n wr_n oe_n}] set_output_delay -clock clk -min 2 [get_ports {rd_n wr_n oe_n}]input_delay和output_delay的数值要根据CH346C数据手册里的时序参数换算。换算方法是数据手册给出数据有效到时钟沿的建立时间用时钟周期减掉这个值就是FPGA侧该设的max delay。这些数字不同板子会略有不同建议学一下约束文件的基本原理不要死抄我的参数。5. 驱动安装与PC端软件联调5.1 WCH官方驱动的识别与安装FPGA端的控制器写完并综合下载之后先把CH346C插上电脑的USB口。正常情况iWindows会把设备枚举为一个USB-Serial或者USB FIFO设备WCH官方提供驱动安装之后设备管理器里能看到CH346字样的设备节点。如果插上之后一直显示未知设备先不要急着怀疑芯片坏了。检查顺序应该是供电是否正常 - 12MHz晶振是否起振 - D/D-差分线是否接反 - RESET#引脚是否被拉高。D/D-接反是一个极常见的低级错误USB插上去后的枚举过程完全不正常设备管理器就给你一个未知设备。5.2 调用官方DLL实现一次数据收发WCH官方提供了CH346的DLL动态库PC端用VC、C#、Python通过ctypes调用DLL都能驱动。下面我以C#为例展示最简单的打开设备、写数据、读数据流程// 打开设备 uint deviceIndex 0; bool ret CH346_OpenDevice(deviceIndex); if (!ret) { Console.WriteLine(打开CH346失败); return; } // 写数据向FIFO写入32字节 byte[] sendData new byte[32]; for (int i 0; i sendData.Length; i) sendData[i] (byte)i; uint written 0; ret CH346_WriteData(deviceIndex, sendData, (uint)sendData.Length, ref written, null); // 读数据从FIFO读取32字节 byte[] recvData new byte[32]; uint received 0; ret CH346_ReadData(deviceIndex, recvData, (uint)recvData.Length, ref received, null); Console.WriteLine($写入 {written} 字节读取 {received} 字节);用上面这段代码联调时建议先把FPGA端的写通道改成发送0x00~0x1F递增序列PC端读回来逐个字节比对看是否和预期一致。这一步能通过说明整个下行链路PC - CH346C - FPGA完全正常。再用PC端发一组数据FPGA端接收后通过Crosslink-NX的片上逻辑分析仪Radiant有内嵌逻辑分析仪功能类似ILA抓一遍看FPGA读到的数据是否和PC发的一致。5.3 定义简单的传输协议CH346C虽然是纯粹的FIFO通道没有内置协议智能但我们在PC端和FPGA端还是应该约定一个最简单的帧格式方便对数据和命令加以区分。不需要复杂我常用的格式是帧头0xAA 0x55长度2字节小端数据类型1字节0x01表示图像帧0x02表示寄存器配置0x03表示调试数据数据体N字节校验CRC16或者简单的累加和。加一个帧头有两个明显好处。第一PC端和FPGA端能快速对齐字节流即使中间丢了一个字节也能通过搜索帧头重新同步第二方便以后扩展命令类型比如让PC下发开始采集、停止采集、设置曝光时间等控制字。直接把裸数据送给FIFO也没有问题但后续扩展会很痛苦。6. 实测性能、信号质量与常见问题排查6.1 异步模式下的吞吐量实测我在100MHz时钟下用异步Slave FIFO模式做了读写测试。写通道实测吞吐量大约在9~10MB/s读通道略低一点大约8MB/s左右。这个数字比USB 2.0理论带宽低不少主要瓶颈在于异步FIFO的握手时序每传一个字节都必须等待TXE#或者RXF#有效再等待若干个时钟周期完成读/写脉冲整个过程的空等时间占了大半。如果不满足于这个速率有两个优化方向。第一把模式切换为同步Slave FIFOCH346C输出时钟后每个时钟周期都可以传输一个字节吞吐量能提升到接近30MB/s第二在FPGA内部做数据宽度转换比如每次把4个字节拼接成32位再通过8位FIFO分四次发送减少握手开销占总时间比例。第一个方向我建议在异步模式跑通后再做两个方向相互配合效果更好。6.2 用逻辑分析仪帮我做过的一次时序诊断有一次客户反馈PC端接收到的数据偶尔会多字节或者少字节。我先是怀疑FPGA内部FIFO溢出查看状态标志后发现并没有溢出。后来拿逻辑分析仪抓了CH346C的控制信号发现WR#低电平脉冲宽度只有20ns左右远小于数据手册要求的40ns最小值。这个问题的根源是我在写状态机时过于着急地拉高WR#节省延时过多导致脉冲宽度不足。把延时从2个时钟周期增加到4个之后问题彻底消失。所以在这里再强调一遍所有时序参数尤其是那些看起来不起眼的脉冲宽度、建立保持时间都要按数据手册来。因为集成在CH346C内部的USB引擎也不是无限快的它需要一个最小时间窗口去锁存数据。靠肉眼在示波器上觉得差不多够了在批量产品上几乎必然会出问题。6.3 常见问题速查表现象可能原因排查与解决办法设备管理器枚举为未知设备D/D-接反、晶振未起振、供电不足示波器量晶振波形检查差分走线极性PC读取到的数据乱序数据总线走线不等长、读写时序不满足手册要求降低时钟频率做对照测试优化PCB布线FPGA侧读不到PC下发数据RXF#判断逻辑错误、OE#没有拉低用逻辑分析仪抓RXF#和RD#确认读状态机触发条件传输一大会儿后USB掉线供电不稳、EMI干扰、USB线质量差改善电源滤波、换带屏蔽的USB线、检查地回路写通道数据偶尔丢字节WR#脉冲宽度不够、FIFO满时仍写入加宽WR#低电平时间确认TXE#为低再写数据全对但隔一段时间停止传输上位机读写线程没有处理超时和重试增加超时重试机制重新读/写前必须清空端点缓冲这张表是我实测中最常遇到的六类问题几乎覆盖了CH346C异步Slave FIFO的主流踩坑点。如果你也遇到过间歇性错误的疑难杂症建议优先怀疑时序余量其次才是怀疑FPGA逻辑错误。6.4 经验先跑一个极简的自测用例给新手一个最实用的建议在写复杂应用层之前先用FPGA内部计数器生成一个0~255循环的递增序列一直往CH346C写PC端接收端把收到的数据打印出来。如果递增序列连续且有序说明写通道没问题。再用PC端发同样的递增序列给FPGAFPGA内部做个比对比对结果通过IO口拉高点亮一颗LED验证读通道。这一步看起来很基础但能帮你把问题限定在一个很小的范围内。我在多个项目中都是这样做的至少省了三四次怀疑人生级别的排查——因为你永远不会知道问题到底是出在FPGA时序、CH346C配置、USB线材、上位机代码还是Windows驱动上。7. 从异步到同步模式下一步的优化路线最后再说一下后续优化的方向。如果你的项目最终需要超过10MB/s的吞吐量就要切换到CH346C的同步Slave FIFO模式。切换的关键点是CH346C提供一个CLKOUT时钟信号FPGA把该时钟作为读写控制的基准时钟而不是用自己内部的100MHz时钟。数据手册的时序图显示在CLKOUT的上升沿驱动WR#或者采样数据握手开销基本可以消除。切换到同步模式后之前编写的大部分状态机逻辑不需要推倒重来只需要把状态机里的等待时钟源换成CLKOUT并调整延时计数器的宽度。同时注意由于CLKOUT通常由CH346C内部PLL产生频率是多少要查看手册确认如果CLKOUT频率较高FPGA侧的布局布线要再做一轮时序收敛检查。另外同步模式下数据总线的建立保持时间只有不到10ns建议在Crosslink-NX的IO配置里打开输入寄存器和输出寄存器利用FPGA硬件资源直接打拍不要用通用逻辑单元去做。以我的经验同步模式跑通后实际吞吐量达到20~30MB/s是很轻松的这已经能覆盖不少工业相机和高速数据采集卡的需求。如果还不够那就不是CH346C这个级别的方案能解决的了得考虑USB 3.0桥接芯片或者FPGA内嵌USB控制器那属于另一个量级的工程。我这块板子从最初画原理图到异步模式稳定传输前后大概花了三个晚上。最耗时间的不是写状态机代码而是调试USB枚举和第一次抓时序波形那一段。如果你也是第一次接触CH346C别急着一次到位先把异步模式按照这篇文章的步骤跑通再去做优化。那样即便出问题你手里也有足够的经验去定位。