
1. 项目缘起与整体设计思路1.1 为什么还要折腾USB 2.0高速接口这几年做FPGA项目但凡涉及到和上位机通信大家第一反应都是“上个千兆网口”或者“直接PCIe走起”。但实际在工业现场、医疗设备、消费类仪器的板卡上USB 2.0高速High Speed480Mbps接口依然是绕不开的选项。原因很实在主机侧不需要额外驱动开发插上就能识别线缆便宜且供电方便协议栈成熟芯片方案多如牛毛。尤其是一些便携式采集设备USB接口几乎是唯一合理的选择。这个项目的核心目标就是在一块中等规模的FPGA上用纯Verilog实现一个USB 2.0高速设备控制器能够和PC端进行稳定的大批量数据传输。注意这里说的是“设备控制器”不是拿现成的USB IP核直接例化。市面上确实有成熟的USB IP但授权费不低而且很多场景下我们需要根据具体应用裁剪功能、优化时序自己写反而更可控。1.2 整体架构怎么搭整个系统从物理层到应用层大致分成四块USB物理层芯片PHY、链路层控制器SIE、端点缓冲区管理、应用接口逻辑。PHY这块通常用现成的芯片比如经典的ULPI接口PHY或者UTMI接口PHYFPGA这边只需要处理并行接口时序即可。SIE层是核心负责处理令牌包、数据包、握手包的收发以及CRC校验、位填充/解填充、NRZI编解码等底层细节。端点缓冲区管理则决定数据怎么在FPGA内部RAM和USB端点之间搬运。最上面的应用接口逻辑就是根据具体项目需求把数据流对接给ADC、DAC或者其它逻辑模块。选择这种分层架构的理由很简单每一层可以独立验证。PHY层用示波器看波形就能确认SIE层可以用USB协议分析仪抓包端点管理可以用仿真验证应用层则直接看数据对不对。如果一锅粥全写在一起出了问题根本无从下手。1.3 为什么选ULPI而不是UTMIULPIUTMI Low Pin Interface和UTMI都是USB 2.0 PHY的标准接口。ULPI的优势在于引脚少——8位双向数据总线加几个控制信号就够了而UTMI通常需要16位或8位数据线加上更多的状态信号。对于引脚资源紧张的FPGA来说ULPI明显更友好。另外ULPI的时钟频率是60MHz在FPGA内部做时序约束相对轻松不像有些高速接口动辄上百兆的时钟域跨越那么头疼。当然ULPI也有坑它的双向数据总线需要仔细处理三态控制方向切换的时机如果不对轻则数据错乱重则PHY直接挂死。这个后面会详细说。1.4 端点缓冲区的设计取舍端点缓冲区这块我试过两种方案一种是直接用FPGA内部的Block RAM做双口RAMUSB侧和用户侧各一个端口另一种是用FIFO做跨时钟域缓冲。双口RAM的好处是地址可控适合需要精确控制数据包大小的场景FIFO的好处是逻辑简单适合流式数据。最终我选了双口RAM加分时复用的方案因为USB协议里端点0的控制传输和端点2/4/6的批量传输需要不同的缓冲策略双口RAM更容易实现灵活的地址映射。这里有个关键点USB 2.0高速模式下一个微帧是125微秒每个微帧最多可以传输3个事务。这意味着你的缓冲区深度必须能扛住至少一个微帧的数据量否则就会丢包。以480Mbps算125微秒最多能传7500字节左右所以每个端点的缓冲区至少得准备8KB才比较稳妥。当然实际应用中不会跑满带宽但设计时得留够余量。2. 核心细节解析与实操要点2.1 ULPI接口的时序陷阱ULPI接口看起来简单但有几个时序细节特别容易翻车。第一个是方向切换的建立时间。当FPGA要往PHY写数据时需要先把dir信号拉低然后等待至少一个时钟周期才能驱动数据总线。如果提前驱动PHY和FPGA会同时驱动总线轻则信号冲突重则烧毁IO口。我一般会在状态机里加一个IDLE状态专门用来处理方向切换。第二个是NXT信号的采样。PHY通过nxt信号告诉FPGA当前是否可以接收数据。很多新手会忽略这个信号直接按固定节奏往PHY灌数据结果就是PHY内部FIFO溢出数据丢失。正确的做法是在每个时钟上升沿采样nxt只有nxt为高时才更新数据总线。如果nxt为低数据总线保持上一次的值不变。第三个是60MHz时钟的约束。ULPI的时钟由PHY提供FPGA需要用它来采样所有ULPI信号。这个时钟的抖动和偏斜必须严格控制否则建立/保持时间很容易违例。我的做法是在FPGA内部用这个60MHz时钟驱动一个PLL生成一个相位偏移90度的时钟来采样数据这样采样点正好落在数据眼图的中心。2.2 位填充与NRZI编解码的实现USB 2.0高速模式使用NRZI编码并且每6个连续的同极性位之后要插入一个填充位。接收端则要做相反的操作检测到6个连续的同极性位后自动丢弃下一个位。这个逻辑用Verilog实现不难但有几个边界情况要小心。先看发送端的位填充逻辑。假设当前要发送的数据是8b11111100前6位都是1那么在第6个1之后必须插入一个0。实现时可以用一个移位寄存器加一个计数器计数器记录当前连续同极性位的个数当计数达到6时强制在输出流中插入一个翻转位。注意插入的位不参与后续的计数计数器要清零重新开始。接收端的解填充逻辑更微妙。因为填充位是发送端插入的接收端必须准确识别并丢弃它。如果接收端错误地丢弃了一个有效数据位整个包就废了。我的做法是用一个状态机当检测到6个连续的同极性位后下一个位无论是什么都直接丢弃同时状态机回到初始状态。这里的关键是同步——接收端的位时钟必须和发送端严格对齐否则计数会错位。实际调试时我建议先用一个已知的测试序列比如8b10101010来验证编解码逻辑确认无误后再接入真实数据。2.3 CRC校验的硬件实现USB协议里有两种CRC令牌包用CRC5数据包用CRC16。CRC5的生成多项式是x^5 x^2 1CRC16的是x^16 x^15 x^2 1。用Verilog实现CRC有两种常见方法一种是线性反馈移位寄存器LFSR另一种是查表法。LFSR占用资源少但每个时钟周期只能处理一位查表法速度快但需要额外的ROM。对于USB 2.0高速来说480Mbps意味着每个位的时间只有2纳秒左右。如果用LFSR逐位处理FPGA的时序压力会很大。所以我选了8位并行CRC的方案每个时钟周期处理8位数据用组合逻辑算出新的CRC值。具体做法是把CRC16的生成矩阵展开推导出8位输入的并行计算公式。这个推导过程有点繁琐但网上有现成的工具可以自动生成Verilog代码。我实测下来用并行CRC后时序余量从原来的0.3纳秒提升到了2.1纳秒效果非常明显。注意CRC校验的初始值很重要。USB协议规定令牌包的CRC5初始值是5b11111数据包的CRC16初始值是16hFFFF。如果初始值设错接收端会一直报CRC错误。2.4 端点0的控制传输处理端点0是USB设备的“默认控制管道”所有枚举过程、标准请求、类请求都走这里。控制传输分三个阶段建立阶段、数据阶段可选、状态阶段。建立阶段是一个8字节的SETUP包里面包含了请求类型、请求码、参数等信息。FPGA需要解析这个包然后决定怎么响应。我的实现方案是用一个独立的状态机专门处理端点0。当SIE层收到SETUP包后把8字节数据写入一个专用的寄存器组然后触发端点0状态机。状态机根据请求码查表决定是返回数据还是只发握手包。这里有个坑SETUP包的PID是4b1101和普通数据包的PID不同。如果SIE层不区分PID把SETUP包当普通数据包处理枚举就会失败。另一个坑是状态阶段的握手方向。对于控制读传输设备往主机发数据状态阶段是主机发OUT包设备回ACK对于控制写传输主机往设备发数据状态阶段是主机发IN包设备回DATA1加ACK。如果搞反了主机会认为设备没有正确响应枚举过程就会卡住。2.5 批量传输的流控与双缓冲批量传输是实际数据吞吐的主力。USB 2.0高速下批量传输使用DATA0/DATA1交替的PID接收端通过PID来判断是否是新包。如果收到重复的PID说明是重传直接丢弃即可。发送端则需要根据接收端的ACK/NYET/STALL握手包来决定下一步动作。为了提高吞吐量我用了双缓冲机制每个端点分配两个缓冲区一个用于USB侧写入一个用于用户侧读取。当USB侧写满一个缓冲区后自动切换到另一个同时通知用户侧读取已满的缓冲区。这样USB传输和用户逻辑可以并行工作不会互相阻塞。实测下来双缓冲比单缓冲的吞吐量提升了将近一倍。不过双缓冲也带来了新的问题缓冲区切换的同步。如果USB侧和用户侧同时访问同一个缓冲区就会产生冲突。我的做法是用一个简单的令牌机制每个缓冲区有一个owner标志USB侧写之前先检查owner是否为USB用户侧读之前先检查owner是否为USER。只有owner匹配时才能操作操作完成后翻转owner。这个逻辑虽然简单但非常有效。3. 实操过程与核心环节实现3.1 硬件选型与连接先说一下我用的硬件平台。FPGA选的是一款中等规模的器件逻辑单元大概在50K左右内部Block RAM足够放下所有端点缓冲区。PHY芯片用的是ULPI接口的独立芯片支持USB 2.0高速和全速模式。连接方式很简单PHY的8位数据总线、dir、nxt、stp、clk这几个信号直接连到FPGA的IO口。注意clk是PHY输出的60MHz时钟必须接到FPGA的全局时钟引脚上否则时钟偏斜会很大。电源这块要特别注意USB PHY通常需要3.3V和1.8V两路供电1.8V是给内部核心逻辑用的。如果1.8V不稳PHY可能会工作异常。我在第一版板子上就吃了这个亏1.8V的LDO选型不当纹波太大导致高速模式下误码率很高。后来换了一个低噪声的LDO问题才解决。3.2 Verilog代码结构整个工程的Verilog代码分成以下几个模块ulpi_interface.v负责ULPI接口的时序控制包括方向切换、NXT采样、数据收发。sie.v链路层控制器处理PID解析、CRC校验、位填充/解填充、NRZI编解码。endpoint_ctrl.v端点缓冲区管理包括双口RAM的读写仲裁、双缓冲切换。ep0_fsm.v端点0控制传输状态机。bulk_fsm.v批量传输状态机。top.v顶层模块例化所有子模块并连接。代码风格上我坚持一个模块只做一件事。比如ulpi_interface.v只负责ULPI时序不涉及任何协议解析sie.v只负责协议层不直接操作RAM。这样模块之间的接口非常清晰调试时也容易定位问题。3.3 关键状态机的实现细节以批量传输的发送状态机为例核心状态包括IDLE等待用户侧有数据要发送。SEND_TOKEN发送IN令牌包等待PHY的响应。WAIT_HANDSHAKE等待接收端的ACK/NYET/STALL。SEND_DATA发送数据包。ERROR处理超时或协议错误。状态转移的条件需要仔细设计。比如在SEND_TOKEN状态如果PHY返回nxt为低说明PHY忙需要保持当前状态等待如果nxt为高且dir为低说明令牌包已发送完毕可以进入WAIT_HANDSHAKE。在WAIT_HANDSHAKE状态如果收到ACK说明数据被正确接收可以发送下一个包如果收到NYET说明接收端忙需要重试如果收到STALL说明端点被挂起需要上报错误。这里有个经验超时计数器必不可少。USB协议规定如果设备在指定时间内没有响应主机会认为设备出错。所以FPGA内部也要有超时机制比如等待握手包超过1毫秒就自动重试或报错。我一般用16位计数器60MHz时钟下大概1.09毫秒溢出一次正好符合协议要求。3.4 仿真验证与实测仿真阶段我用了一个USB协议分析仪的仿真模型来模拟主机行为。测试序列包括枚举过程、端点0控制读写、批量传输、错误注入比如故意发错CRC。仿真通过后再上板实测。上板实测时我先用了一个简单的回环测试FPGA收到主机发来的数据后原样发回去。用PC端的USB调试工具发送1MB的随机数据对比收发是否一致。第一次测试就发现了问题数据在传输到第32768字节时开始出错。排查后发现是端点缓冲区的地址计数器位宽不够16位计数器在32768处溢出导致地址回绕。把计数器改成17位后问题解决。第二个问题是枚举不稳定。有时候PC能识别设备有时候识别不了。用协议分析仪抓包后发现设备在响应GET_DESCRIPTOR请求时返回的数据长度偶尔会多一个字节。检查代码后发现是状态机在发送完数据后没有及时拉低stp信号导致PHY多发了一个字节。在状态机里加了一个STOP状态专门处理stp信号后枚举就稳定了。3.5 吞吐量测试与优化最终的性能测试结果在批量传输模式下持续传输1GB数据平均吞吐量达到了38MB/s左右。这个数字虽然离USB 2.0高速的理论上限60MB/s还有差距但已经能满足大多数采集类应用的需求。瓶颈主要在FPGA内部的RAM带宽上。双口RAM的每个端口在每个时钟周期只能读写一次60MHz下就是60MB/s的理论上限。实际因为仲裁开销和协议开销能跑到38MB/s已经不错了。如果想进一步提升可以考虑用更宽的RAM接口比如32位或者用多个RAM并行。不过对于大多数应用来说38MB/s足够了。4. 常见问题与排查技巧实录4.1 枚举失败问题速查表现象可能原因排查方法PC完全不识别设备PHY供电异常用万用表测PHY的3.3V和1.8V识别为未知设备枚举过程中断用协议分析仪抓包看在哪一步失败识别后频繁掉线电源纹波大用示波器看1.8V的纹波应小于50mV枚举成功但无法通信端点配置错误检查端点描述符和实际端点号是否匹配4.2 ULPI接口常见故障ULPI接口最常出的问题是方向切换时序错误。具体表现是FPGA往PHY写数据时PHY没有正确接收或者PHY往FPGA发数据时FPGA采样到全0或全1。排查方法很简单用示波器同时抓dir、nxt、clk和数据总线。正常情况下dir变化后至少一个时钟周期数据总线才应该变化。如果dir和数据总线同时变化说明方向切换逻辑有问题。另一个常见问题是NXT信号被忽略。有些开发者为了简化逻辑直接假设PHY永远准备好接收数据不采样nxt。这在低速下可能没问题但在高速下PHY内部FIFO很容易溢出。正确的做法是在每个时钟上升沿采样nxt只有nxt为高时才更新数据总线。4.3 CRC校验错误的排查CRC错误是USB通信中最常见的错误之一。如果协议分析仪显示大量CRC错误可以从以下几个方面排查初始值是否正确令牌包CRC5初始值5b11111数据包CRC16初始值16hFFFF。位序是否正确USB协议规定CRC计算时数据是LSB先出但CRC本身是MSB先出。如果搞反了CRC永远算不对。是否包含了PIDUSB协议规定CRC计算不包含PID字段。如果误把PID也纳入计算结果肯定错。并行CRC的位宽是否匹配如果用8位并行CRC但实际数据不是8位对齐的需要做特殊处理。4.4 双缓冲的竞态问题双缓冲机制虽然能提升吞吐量但也引入了竞态风险。我遇到过一次USB侧正在写缓冲区A用户侧同时读缓冲区A结果读出来的数据一半是新的一半是旧的。排查后发现是owner标志的更新时机不对。正确的做法是USB侧写完之后先更新owner为USER再触发用户侧读取用户侧读完之后先更新owner为USB再允许USB侧写入。这个顺序不能乱否则就会产生竞态。实操心得双缓冲的owner标志最好用格雷码编码这样跨时钟域传输时不会产生亚稳态。虽然USB侧和用户侧通常在同一个时钟域但如果你用了异步FIFO做跨时钟域格雷码就是必须的。4.5 时序收敛的实用技巧FPGA设计最终都要过时序这一关。USB 2.0高速接口的时序约束主要有两个60MHz的ULPI时钟域和内部逻辑时钟域。我的经验是把ULPI接口的所有信号都约束到60MHz时钟域建立时间和保持时间都留20%的余量。内部逻辑如果跑在100MHz以上建议用流水线设计把组合逻辑拆成多级。跨时钟域的信号一定要用同步器最好是两级触发器加握手协议。综合和布局布线时把ULPI接口相关的逻辑放在同一个区域减少布线延迟。我实测下来只要时序约束写对了这款中等规模的FPGA跑USB 2.0高速接口是完全没问题的。关键是要舍得花时间在约束文件上不要等到布局布线报错了才去补约束。4.6 调试工具与手段调试USB接口光靠仿真是不够的必须上真机。我常用的工具组合是USB协议分析仪抓取USB总线上的所有包能看到PID、数据、CRC、握手包。这是最直接的调试手段。示波器看ULPI接口的波形确认时序是否正确。逻辑分析仪抓FPGA内部的信号比如状态机的状态、计数器的值。PC端调试软件比如用Python的pyusb库写一个简单的测试脚本发送和接收数据。这套组合下来基本上任何问题都能定位到具体的信号和时钟周期。5. 项目扩展与个人体会这个USB 2.0高速接口的Verilog实现前后折腾了大概两个月中间踩了不少坑但也积累了很多经验。后来我又在这个基础上做了几个扩展比如增加了USB音频类UAC的支持让FPGA可以直接作为USB声卡使用还试过用USB 2.0高速接口做图像传输把CMOS传感器的数据实时传到PC上。如果你也想做类似的项目我的建议是先从全速模式12Mbps开始。全速模式的时序要求比高速模式宽松很多更容易调通。等全速模式稳定了再切换到高速模式这时候你已经有了一套可用的调试流程和代码框架切换起来会顺利很多。另外不要吝啬在仿真上花时间。我见过太多人跳过仿真直接上板结果出了问题连是硬件问题还是逻辑问题都分不清。一个好的测试平台能帮你省下大量的调试时间。最后说一个我个人的小技巧在FPGA内部加一个错误计数器记录CRC错误、超时错误、协议错误的次数。这个计数器可以通过端点0的厂商自定义请求读出来。这样即使没有协议分析仪你也能大致判断通信质量。我在现场调试时这个计数器帮了大忙。