
简介面向 FPGA 初学者的 USB 转串口通信工程包基于 Verilog 在 Quartus 环境中实现 UART 收发逻辑并集成 FIFO 缓冲与 USB 相关接口设计适合学习硬件描述语言、串口协议及 FPGA 综合下载流程的人群。压缩包共 93 个文件约 1.29 MB以 test_usb.v 源码、QPF/QSF 工程配置、FIFO 的 QIP 文件、SOF 位流文件为核心辅以综合/适配/装配报告与 QMSG 日志可完整还原一次 FPGA 工程的编译、布局布线与下载验证过程。已有 200 人学习下载。通过学习可掌握 UART 帧格式与波特率生成方法理解 Quartus 各阶段输出文件的作用并能参考 README 和配套脚本快速复现实验、排查综合或管脚分配问题是一份小巧但完整的串口通信实验参考。1. 用 FPGA 和 Verilog 做 USB2.0 串口通信先过自己这一关的往往是 FIFO把 USB2.0 转串口拿到 FPGA 台架上验证板子一上电就有两种报错等着你要么主机下发命令后UART 端一个字节都收不到要么收到了却比预期多一截像把上一轮调试的“旧数据”全翻了出来。这个问题通常和波特率无关。USB2.0 在桥接芯片一侧是并行总线UART 是串行位流两边的时钟、包长和背压方式全不一样要在 FPGA 里把它们接对最应该先想清楚的是数据从哪个时钟域进来、又在哪个时钟域被消费掉。本文按我实际搭建这种测试平台的顺序来说先定桥接架构再写 UART 收发和测试 ID 帧最后接 USB 并行端点并用带编号的测试数据把链路自检做出来。新手上手能少出错做 FPGA 验证的人也能看到参数取舍。2. USB2.0 串口链路里真正要命的是两侧时钟不对称2.1 从主机端看是虚拟串口从 FPGA 端看是并行总线常见做法是用 USB 桥接芯片Cy7C68013A、FT2232H、CH347 这一类把 USB 包翻译成同步或异步从 FIFO。FPGA 看到的不是串行位流而是一组并行接口数据线 FD[7:0] 或 FD[15:0]读选通 SLRD、写选通 SLWR作为端点地址的 FIFOADR[1:0]再加上表示端点空/满的 FLAGA 到 FLAGD。同步模式下芯片还给一个 IFCLK数据在时钟沿上被锁存。主机的 USB 传输速率和 UART 的波特率没有任何共享时钟连频率基准都不一样这决定了整个设计必须先把异步边界划清楚。USB 端点以包为单位移动数据一次批量传输可以灌入 512 字节UART 却只能按波特率慢慢吐字节115200 波特率、8N1 帧格式下有效数据速率只有约 11520 B/s。这个吞吐差就是缓冲存在的理由。USB 侧突发到达的数据必须先落进跨时钟域 FIFO再由 UART 发送模块按自己的节拍取走反过来UART 收到的数据也要攒成字节等主机通过 USB 轮询取走。两侧一旦没有缓冲慢的一侧必然成为丢数据的口子。参数USB 侧典型值UART 侧典型值接线时要注意的点时钟频率48MHz/60MHz1.8432MHz115200 的 16 倍频率差由跨时钟 FIFO 吸收数据宽度8 或 16 bit1 bit位宽差异在 FIFO 数据端口处统一成字节数据粒度64B/512B 包10 bit 一帧短包判断、ZLP 处理背压机制FIFO 满/空标志固定波特率无硬件流控UART 侧必须有足够深度兜底相位关系与 FPGA 时钟异步与 FPGA 时钟同域跨时钟点只允许出现在 USB 接口附近2.2 发送方向看 UART 的消化速度USB 满标志只保护桥芯片不少第一次调板子的人会死盯着 USB 桥芯片的 FLAG 引脚不放。FLAG 反映的是桥芯片内部端点 FIFO 的状态它并不能告诉你 UART 是否已经把刚才那个字节发完了。真正有效的背压是 FPGA 内部 FIFO 的满标志当 UART 还在缓慢移位输出主机却已经把 FPGA FIFO 填满时满标志拉高后续数据不能再写入等 UART 取走若干字节后满标志撤销写入继续。如果图省事把芯片内部 FIFO 当唯一缓冲不做跨时钟域处理USB 芯片的 48MHz 写时钟和 FPGA 内部 100MHz 读时钟之间必然出现亚稳态。我一般会在 FPGA 里例化异步 FIFO写时钟接 USB 侧读时钟接 UART 侧空满标志经两级触发器同步后再进状态机。异步 FIFO 的格雷码指针在这里是必要的用普通读写计数器跨时钟域很容易在某次临界采样时得到一个既吉又凶的空满状态背压判断随即失效。提示异步 FIFO 的深度至少要比 USB 一次批量传输的最大包长大一档。批量端点做 512 字节突发时FIFO 深度 1024 是起步更多是在给抖动留余量。2.3 把时钟域边界画在 USB 接口附近而不是画在 UART 附近边界画法决定后续调试难度。我习惯把 FPGA 内部划成两个时钟域系统时钟域负责 UART 收发、测试序列生成和寄存器配置USB 芯片的 IFCLK 域只负责和芯片对接中间用异步 FIFO 隔开。UART 侧数据不跨第二次时钟波特率发生器、接收采样、测试 ID 计数全部在同一时钟下单步仿真和在线逻辑分析仪抓信号都容易做。这样设计之后大部分调试只需要在系统时钟域里抓 rxd、txd 和 FIFO 读写计数。唯一要留神的是异步 FIFO 的满标志同步需要 1 到 2 个时钟周期背压响应不可能瞬时完成突发长度必须把这段延迟产生的额外字节也考虑进去。带宽算完再选具体 FIFO 深度比先定深度再回头调逻辑要省事得多。3. 串口通信收发的 Verilog 实现波特率、起始位采样和测试 ID 打包3.1 波特率发生器先做 16 倍过采样再决定采样点UART 接收的容错能力来自过采样。常见做法是把波特率频率扩展 16 倍让每一位对应 16 个采样 tick这样可以在位中心附近取点避开边沿抖动。除法因子由系统时钟和波特率算出下面是一个可综合的模块module baud_tick_gen #( parameter integer CLK_FREQ 50_000_000, parameter integer BAUD_RATE 115200, parameter integer OVS 16 )( input wire clk, input wire rst_n, output reg tick ); localparam integer DIV CLK_FREQ / (BAUD_RATE * OVS) - 1; localparam integer WID (DIV 0) ? $clog2(DIV 1) : 1; reg [WID-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt {WID{1b0}}; tick 1b0; end else if (cnt DIV) begin cnt {WID{1b0}}; tick 1b1; end else begin cnt cnt 1b1; tick 1b0; end end endmodule逻辑很简单每个系统时钟周期加一计数器到 DIV 后清零并输出一个 tick。tick 频率是波特率的 16 倍所以一个 UART 位周期内有 16 个 tick。50MHz 时钟配 115200 波特率时DIV 计算为 26实际位速率约 115740误差 0.47%在 8N1 帧的可接受范围内如果配 921600整数分频误差会上到 3% 左右这时我更倾向换 100MHz 或 120MHz 系统时钟或者改用累加器实现小数分频避免高波特率下停止位采样裕量不够。3.2 接收状态机起始位中点确认数据位中心采样接收状态机要做三件事检测下降沿进入起始位在第 7/8 个 tick 确认 rxd 确实是低电平然后在每个数据位的中心点采样。毛刺过滤不用做太复杂起始位中点确认这一下就能滤掉大部分窄脉冲。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rxd, output reg [7:0] rx_data, output reg rx_done ); localparam IDLE 3d0; localparam START 3d1; localparam DATA 3d2; localparam STOP 3d3; reg [3:0] tick_cnt; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg [2:0] state; wire baud_tick; baud_tick_gen #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_btick ( .clk(clk), .rst_n(rst_n), .tick(baud_tick) ); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; tick_cnt 4d0; bit_cnt 3d0; shift_reg 8d0; rx_data 8d0; rx_done 1b0; end else begin rx_done 1b0; if (baud_tick) begin case (state) IDLE: begin tick_cnt 4d0; if (rxd 1b0) state START; end START: begin tick_cnt tick_cnt 1b1; if (tick_cnt 4d7) begin if (rxd 1b1) state IDLE; // 毛刺丢弃 end if (tick_cnt 4d15) begin state DATA; tick_cnt 4d0; bit_cnt 3d0; end end DATA: begin tick_cnt tick_cnt 1b1; if (tick_cnt 4d7) begin shift_reg {rxd, shift_reg[7:1]}; // 位中心采样 end if (tick_cnt 4d15) begin if (bit_cnt 3d7) begin state STOP; tick_cnt 4d0; end else begin bit_cnt bit_cnt 1b1; tick_cnt 4d0; end end end STOP: begin tick_cnt tick_cnt 1b1; if (tick_cnt 4d7) begin rx_data shift_reg; rx_done 1b1; state IDLE; end end endcase end end end endmodule这段代码的关键参数是tick_cnt 4d7的采样时刻。起始位从下降沿进入 START 开始计时到第 7 个 tick 时确认电平到第 15 个 tick 时结束起始位并进入数据位数据位同样在第 7 个 tick 采样。这样每个位周期都保持了 16 tick 的长度采样点落在位中心附近对发送端时钟误差容忍度最高。实际产品里还应该把毛刺过滤和接收 FIFO 接口拆成独立模块这里为了演示把状态机收在一起。3.3 把 usbtestid 写进帧的第二个字节用来区分调试会话调试 USB 转串口这类链路最烦的是上一轮测试的残余数据混进本轮结果。我的做法是把测试会话编号做成一个 8 位寄存器 test_id在 UART 发送侧拼帧帧头 0xAA第二字节是 test_id第三字节是数据长度后面才是实际数据。主机端解析时先看帧头再看 test_id 是否等于本轮下发的编号不等于就直接丢弃。// 帧格式0xAA test_id length data[0..length-1] reg [7:0] test_id; reg [7:0] tx_buf [0:15]; reg [4:0] tx_index; reg [1:0] tstate; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tstate 2d0; tx_index 5d0; end else begin case (tstate) 2d0: begin tx_index 0; tstate 2d1; end 2d1: begin // 发帧头 if (tx_busy 1b0) begin tx_data 8hAA; tstate 2d2; end end 2d2: begin // 发测试 ID tx_data test_id; tx_index tx_index 1b1; tstate 2d3; end 2d3: begin // 发数据字节 tx_data tx_buf[tx_index]; if (tx_index 5d15) tstate 2d0; else tx_index tx_index 1b1; end endcase end end这一段看起来琐碎但它解决了实际问题usbtestid 这个编号只要主机每次打开串口会话时重写一次就能在日志里快速定位数据到底是哪个时刻进来的。没有编号的话回环测出来一堆字节你很难判断是本次数据还是前一版固件残留。3.4 主机侧先做最小收发闭环在接 USB 并行端点之前我先用 USB 转 TTL 小板或桥芯片直接测 UART 路径。Linux 下用串口工具发一帧测试数据python3 - EOF import serial ser serial.Serial(/dev/ttyUSB0, 115200, timeout0.2) ser.write(b\xaa\x03\x55\xaa\x03\x66) print(ser.read(32).hex()) EOF如果回读结果里能看到 0xaa 和 0x03 成对出现说明 UART 收发路径已经通了。这一步把问题锁定在串口链路内部后面接 USB 端点时如果出错至少不用再怀疑波特率配置。4. 把 USB2.0 的并行数据流接进 FPGASlaveFIFO 端点的读写路径4.1 USB 桥芯片本质是个双口包搬运工桥芯片在主机侧负责 USB 协议在 FPGA 侧是一组可寻址的端点 FIFO。主机往 OUT 端点写数据FPGA 从 OUT 端点读走FPGA 往 IN 端点写数据主机通过 USB 轮询把 IN 端点数据读回。FPGA 侧操作就是给 FIFOADR 赋值然后按时序拉 SLRD 或 SLWR。同步 FIFO 模式下数据在 IFCLK 的上升沿被锁存。写一个字节到 IN 端点先让 FIFOADR 指向 IN 端点拉低 SLWR把数据放到 FD 总线上等下一个上升沿到来后释放 SLWR。读 OUT 端点类似但还要管 SLOE也就是数据总线的输出使能FPGA 从芯片读数据时必须让芯片把数据推上 FD 总线。4.2 读 OUT 端点的最小状态机从 FPGA 角度看读 USB 芯片的 OUT 端点就是一个带空标志的 FIFO 读取。下面是同步模式下的简化状态机module usb_fifo_read #( parameter DATA_W 8 )( input wire clk, // 与 IFCLK 同源 input wire rst_n, input wire fifo_empty, // FLAGB 的同步后版本 input wire [DATA_W-1:0] usb_data, output reg usb_oe, // SLOE低有效 output reg usb_rd, // SLRD低有效 output reg [DATA_W-1:0] data_out, output reg data_valid ); localparam IDLE 1b0; localparam READ 1b1; reg state; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; usb_oe 1b1; usb_rd 1b1; data_valid 1b0; end else begin data_valid 1b0; case (state) IDLE: begin usb_oe 1b1; if (fifo_empty 1b0) begin state READ; usb_rd 1b0; end end READ: begin usb_rd 1b1; data_out usb_data; data_valid 1b1; state IDLE; end endcase end end endmodule逻辑说明IDLE 状态下先看空标志空标志为低说明 OUT 端点里有数据于是进入 READREAD 状态里拉高 SLRD 表示读完成同时把当前数据总线上的值锁存并断言 data_valid。注意这里 data_valid 只持续一个时钟周期如果下游接的是异步 FIFO 写端口需要把 data_valid 与写使能直接相连。提示FLAG 引脚的电平和 FPGA 内部时钟不同步跨时钟域采样后必须等两个周期再用于判断。上面代码里的 fifo_empty 已经是同步后的信号不能再拿芯片引脚直接进状态机。4.3 常用端点信号速查做 SlaveFIFO 对接时手边值得放一张信号表。下面这张是按同步模式整理的异步模式下的时序略有不同但信号角色一样。信号方向作用注意点FD[7:0]双向数据线读时需要 SLOE 控制方向FIFOADR[1:0]输出选择端点缓冲区改变地址后要留建立时间SLRD输出读选通低有效一个时钟周期最小脉冲SLWR输出写选通低有效数据须先于或同时建立SLOE输出数据线输出使能读数据时拉低否则高阻FLAGB输入对应端点的空标志必须做两级同步IFCLK输入/输出同步模式时钟建议由 FPGA 输出方便对齐4.4 最容易踩的坑标志同步后立即读取数据还没准备好读状态机写完仿真时一切正常上板之后第一个字节总是坏的这大多不是时序约束问题而是标志同步带来的 1 到 2 个周期延迟。USB 芯片内部的空标志在 IFCLK 上升沿更新FPGA 侧两级同步打完看到空标志为低时实际数据可能要在下一个时钟周期才稳定出现在 FD 总线上。如果状态机在同一个时钟直接采样 usb_data采到的多半是上一拍的总线残留。解决方法是让 READ 状态多等一拍或者在数据锁存时用时序约束明确告诉工具usb_data 必须在 SLRD 释放沿之后才有效。fer 注意到Modelsim 仿真里很难暴露这类问题因为仿真模型的总线和真实芯片行为略有差异上板后建议用 ILA 抓 SLRD 和 usb_data确认锁存点落在总线上稳定的窗口内。5. usbtestid 自检里最值得加的两个动作顺序号回环和 FIFO 余量上报5.1 顺序号比 CRC 更早暴露链路问题在帧格式里除了 test_id再加一个 8 位顺序号 seq。每次 FPGA 发送一帧seq 加一主机回环时把收到的帧再发回 FPGAFPGA 检查 seq 是否连续。这个检查比 CRC 更能说明问题CRC 只能判断字节对错顺序号能指出丢包发生在哪一段。如果 USB 端点缓冲没有及时清理主机读回来的 seq 会出现跳号但数据本身 CRC 完全正常。我一般会把 seq 断点信息连同 test_id 一起记进日志打印格式类似test_id0x03 seq_expected0x2c got0x2e就能看出是 IN 端点丢了一个短包。5.2 上报 FIFO 余量量化背压余量第二个推荐动作是把 FPGA 内部 FIFO 的余量作为状态帧主动上报。实现很简单写指针减读指针就是当前占用深度把占用深度的最大值记到一个 16 位寄存器主机通过 UART 或 USB 的调试通道读回。// 记录 FIFO 最大占用深度 reg [15:0] fifo_occ_max; always (posedge clk or negedge rst_n) begin if (!rst_n) fifo_occ_max 16d0; else if (fifo_occ fifo_occ_max) fifo_occ_max fifo_occ; end有了这个数可以判断当前缓冲深度是否匹配 USB 突发节奏。比如 115200 波特率下UART 每字节约 86.8us如果 FIFO 深度 1024USB 一次性灌入 512B 后大约需要 44ms 才能全部排空在下次突发间隔小于这个数时最大占用就会持续增长并最终溢出。把这个指标压测一轮后再决定是把 FIFO 加深还是把 USB 端点的批量调度调慢。这样就把“usbtestid 测试”从简单的收发通断升级成了有量化依据的链路带宽评估。本文还有配套的精品资源点击获取