1. 先从最底层说起:UART 到底在干什么
搞 FPGA 的人,十有八九第一个实战项目就是 UART。原因很简单:它简单、直观、调试方便,而且几乎每个嵌入式系统都离不开它。但越是这样"入门级"的东西,越值得把它吃透——后面做 PCIe、DDR、以太网的时候,很多调试手段可都建立在会玩串口的基础上。
拿我自己的经历来说,刚接触 FPGA 那会儿,手里一块开发板、一根 USB 转 TTL 线、一个串口助手,就能把上板验证这件事跑通。当时觉得不就是收发几个字节吗?后来做的项目多了,发现 UART 的坑其实比想象中多:波特率误差、亚稳态处理、FIFO 溢出、丢字节、时序约束……每一个都够你喝一壶。
这篇文章我想从一个"踩过坑、填过坑"的从业者角度,把 UART 串口通信在 FPGA 上的实现完整过一遍。从协议原理讲到 RTL 设计,从仿真验证讲到上板调试,再附上我实际操作中总结出来的经验教训。不管你是刚接触 FPGA 的新手,还是想回头把基础打牢的老手,这篇应该都能给你点东西。
先交代一下本文的定位:不涉及具体开发板型号,以 Verilog 为例,代码思路可以平移到任意 FPGA 平台(Xilinx、Intel、国产厂商都行)。核心就是让你真正理解 UART 模块内部发生了什么,以及怎么把它用得稳。
2. 协议层拆解:为什么 UART 能"对得上话"
2.1 串行通信的“约定”:起始位、数据位、校验位、停止位
UART(Universal Asynchronous Receiver/Transmitter)是一种异步串行通信协议。所谓异步,就是收发双方没有独立的时钟线,靠的是事先约定好的波特率(Baud Rate)来采样数据。也就是大家常说的 9600、115200 这些数字,它代表每秒传输多少个比特。
一帧 UART 数据长这样:
- 空闲状态:线路保持高电平。
- 起始位:发送方拉低一个比特周期,告诉接收方"我要开始发数据了"。
- 数据位:从最低位(LSB)开始,依次发送 5~8 个数据位,最常用的是 8 位。
- 校验位(可选):奇校验或偶校验,用来做简单的错误检测,实际项目中用得越来越少,但工业现场总线上偶尔还会见到。
- 停止位:拉高 1 个、1.5 个或 2 个比特周期,表示一帧结束。
为什么一定要有起始位和停止位?因为接收端就是靠起始位的下降沿来"对齐"采样时钟的。这是 UART 能可靠通信的关键——不是靠绝对时间对齐,而是靠边沿触发重新同步。
2.2 波特率与采样时钟:不是越快越好
波特率决定了每个比特占多长时间。以 115200 为例,一个比特周期大约是 8.68 微秒。FPGA 内部通常跑 50MHz 或 100MHz 的时钟,所以我们需要用一个计数器来"数"出这个比特周期。
这里有一个非常关键的设计点:接收端应该在每个比特的中间位置采样。为什么?因为信号从发送端到接收端有传播延迟,而且收发双方的时钟存在微小偏差,如果在一个比特刚开始或快结束的时候采样,很容易采到边沿附近的跳变,造成误码。在比特正中间采样,容错能力最强。
一般做法是:FPGA 内部用波特率的 16 倍频时钟来做过采样。比如 115200 波特率,16 倍就是 1.8432MHz。每个比特周期采 16 个点,取中间那个点作为有效数据。当然,直接用系统时钟分频产生一个采样时钟也行,只要误差控制在可接受范围内。
我把常见波特率的比特周期和分频计数值整理成了表格,大家可以对照参考(假设系统时钟 50MHz):
| 波特率 | 比特周期(us) | 50MHz 分频计数值 | 16倍过采样计数值 |
|---|---|---|---|
| 9600 | 104.17 | 5208 | 325 |
| 19200 | 52.08 | 2604 | 162 |
| 38400 | 26.04 | 1302 | 81 |
| 57600 | 17.36 | 868 | 54 |
| 115200 | 8.68 | 434 | 27 |
注意:分频计数值 = 系统时钟频率 / 波特率,然后取整。取整带来的误差只要小于 2%~3%,一般都能正常通信。115200 在 50MHz 下取整误差很小,这也是它成为"默认波特率"的原因之一。
2.3 RS232、TTL 电平与 USB 转串口:实物链路里发生了什么
很多人一开始会被 RS232、TTL、USB 转串口这些词搞晕。我理一下:
- TTL 电平:FPGA 的引脚直接输出的就是这种电平,0V 表示逻辑 0,3.3V(或 1.8V、2.5V,取决于 Bank 电压)表示逻辑 1。FPGA 和单片机之间短距离通信,直接连 TTL 就行。
- RS232 电平:这是老式串口(DB9 接头)用的标准,逻辑 1 是 -12V ~ -3V,逻辑 0 是 +3V ~ +12V。之所以搞这么极端的电压,是为了提高抗干扰能力和传输距离。FPGA 不能直接接 RS232 电平,必须经过 MAX3232 这类电平转换芯片。
- USB 转 TTL:现在笔记本电脑上没有串口了,大家普遍用 USB 转串口线(比如 FT232、CH340 这类芯片做的)连接 FPGA 和电脑。这类线内部已经做好了电平转换,输出端就是 TTL 电平,可以直接接 FPGA 引脚。
所以说,FPGA 项目里说的"UART 串口通信",绝大多数场景其实是:FPGA 的 TTL 引脚 —— USB 转 TTL 模块 —— 电脑上的串口助手。理解这条链路很重要,因为很多"通信不上"的问题,根源就出在这条链路的某个环节上(后面我会专门讲排查方法)。
3. 从零手写 RTL:发送端、接收端与顶层模块
3.1 发送端设计:状态机驱动,层层拆解
发送端相对简单,核心就是一个状态机:空闲、发起始位、发数据位、发停止位。我直接给出一个保守但好用的设计思路:
module uart_tx #( parameter CLK_FREQ = 50_000_000, // 系统时钟 50MHz parameter BAUD_RATE = 115200 ) ( input wire clk, input wire rst_n, input wire tx_start, // 发送使能,上升沿触发 input wire [7:0] tx_data, // 要发送的数据 output reg tx, // 串行输出 output reg tx_busy // 忙信号 ); localparam BIT_PERIOD = CLK_FREQ / BAUD_RATE; reg [15:0] cnt; // 波特率分频计数器 reg [2:0] bit_index; // 当前发送到第几个 bit(0~7 数据位) reg [3:0] state; localparam IDLE = 4'd0; localparam START = 4'd1; localparam DATA = 4'd2; localparam STOP = 4'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; tx <= 1'b1; // 空闲时拉高 tx_busy <= 1'b0; cnt <= 0; bit_index <= 0; end else begin case (state) IDLE: begin tx <= 1'b1; tx_busy <= 1'b0; if (tx_start) begin state <= START; tx_busy <= 1'b1; cnt <= 0; end end START: begin tx <= 1'b0; // 拉低一个比特周期 if (cnt == BIT_PERIOD - 1) begin cnt <= 0; bit_index <= 0; state <= DATA; end else begin cnt <= cnt + 1'b1; end end DATA: begin tx <= tx_data[bit_index]; if (cnt == BIT_PERIOD - 1) begin cnt <= 0; if (bit_index == 3'd7) begin state <= STOP; end else begin bit_index <= bit_index + 1'b1; end end else begin cnt <= cnt + 1'b1; end end STOP: begin tx <= 1'b1; // 停止位拉高 if (cnt == BIT_PERIOD - 1) begin cnt <= 0; state <= IDLE; end else begin cnt <= cnt + 1'b1; end end endcase end end endmodule这段代码的核心逻辑就是:每个状态停留 BIT_PERIOD 个时钟周期,然后切换到下一个状态。tx_start 信号由上层逻辑给出,tx_busy 用来告诉上层"我现在忙,别往我这儿塞数据"。
一个我吃了亏才明白的细节:tx 在空闲状态必须保持高电平。UART 协议里,线路空闲就是高电平。如果复位后 tx 误拉低了,接收端会误判为起始位,然后收到一堆垃圾数据。这个问题在仿真里往往看不出来,上板后表现为主机一直收到乱码。
3.2 接收端设计:边沿检测、过采样与数据恢复
接收端比发送端复杂,因为接收方不知道数据什么时候会来,必须时刻监视线路。设计上分三块:空闲检测、起始位确认、数据采样。
我在实际项目中用的是一个带 16 倍过采样的版本。思路是:检测到下降沿后,启动一个采样时钟,在每个比特周期的第 8 个采样点(也就是中点附近)读取电平值。这样能最大化避开边沿区域的毛刺。
module uart_rx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 ) ( input wire clk, input wire rst_n, input wire rx, // 串行输入 output reg [7:0] rx_data, // 接收到的数据 output reg rx_done // 一帧接收完成脉冲 ); localparam CLK_PER_BIT_16X = CLK_FREQ / BAUD_RATE / 16; reg [15:0] cnt; reg [3:0] sample_cnt; reg [3:0] bit_cnt; reg rx_d1, rx_d2; // 打两拍,消除亚稳态 reg rx_negedge; reg [2:0] state; localparam IDLE = 3'd0; localparam START = 3'd1; localparam DATA = 3'd2; localparam STOP = 3'd3; // 同步打拍 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d1 <= 1'b1; rx_d2 <= 1'b1; end else begin rx_d1 <= rx; rx_d2 <= rx_d1; end end assign rx_negedge = rx_d2 & ~rx_d1;这里有一个非常关键的细节:rx 是外部异步信号,必须打两拍同步后再使用。为什么?因为如果不打拍,外部信号可能刚好在时钟上升沿附近发生变化,触发寄存器进入亚稳态,导致整个状态机跑飞。打两拍之后,亚稳态被"消化"掉的概率在工程上可以接受。
接收状态机的核心逻辑:
always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; rx_data <= 0; rx_done <= 1'b0; end else begin rx_done <= 1'b0; case (state) IDLE: begin cnt <= 0; sample_cnt <= 0; bit_cnt <= 0; if (rx_negedge) begin state <= START; cnt <= 0; sample_cnt <= 0; end end START: begin // 在起始位中点再做一次确认,排除毛刺干扰 if (cnt == CLK_PER_BIT_16X - 1) begin cnt <= 0; sample_cnt <= sample_cnt + 1'b1; if (sample_cnt == 4'd7) begin if (~rx_d2) begin state <= DATA; sample_cnt <= 0; end else begin state <= IDLE; // 误触发,回到空闲 end end end else begin cnt <= cnt + 1'b1; end end DATA: begin if (cnt == CLK_PER_BIT_16X - 1) begin cnt <= 0; sample_cnt <= sample_cnt + 1'b1; if (sample_cnt == 4'd15) begin sample_cnt <= 0; rx_data[bit_cnt] <= rx_d2; if (bit_cnt == 3'd7) begin state <= STOP; bit_cnt <= 0; end else begin bit_cnt <= bit_cnt + 1'b1; end end end else begin cnt <= cnt + 1'b1; end end STOP: begin if (cnt == CLK_PER_BIT_16X - 1) begin cnt <= 0; sample_cnt <= sample_cnt + 1'b1; if (sample_cnt == 4'd7) begin // 停止位中点采样,应为高电平 if (rx_d2) begin rx_done <= 1'b1; end state <= IDLE; end end else begin cnt <= cnt + 1'b1; end end endcase end end endmodule很多教材上只做一次起始位采样,其实不够严谨。在起始位中点再确认一次电平是否为低,可以有效地把窄毛刺造成的误触发挡在门外。这是我做工业级产品时总结下来的经验——在强电磁干扰环境下,这个"二次确认"能明显降低误码率。
3.3 顶层模块与回环测试:先证明自己能发能收
发送端和接收端写完之后,把它们封装成一个顶层模块,对外暴露的接口就三个:时钟、复位、以及收发引脚。为了便于板级验证,我在顶层做了一个"回环"逻辑:收到的数据直接发回去。这样在电脑串口助手上就能看到"发什么、回什么"的效果。
module uart_loopback #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 ) ( input wire clk, input wire rst_n, input wire uart_rx, output wire uart_tx ); wire [7:0] rx_data; wire rx_done; wire tx_busy; reg tx_start; reg [7:0] tx_data; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx_start <= 1'b0; tx_data <= 8'd0; end else if (rx_done && !tx_busy) begin tx_data <= rx_data; tx_start <= 1'b1; // 产生一拍脉冲 end else begin tx_start <= 1'b0; end end uart_tx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (tx_start), .tx_data (tx_data), .tx (uart_tx), .tx_busy (tx_busy) ); uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (uart_rx), .rx_data (rx_data), .rx_done (rx_done) ); endmodule这里有一个工程上很容易踩的坑:tx_start 必须是"单脉冲"信号,不能一直拉高。我见过有人直接写成tx_start <= rx_done;,结果发送端在同一帧数据期间反复触发,状态机直接乱套。正确的做法是:检测到 rx_done 后,把 tx_start 拉高一拍,下一拍立刻拉低。
4. 仿真验证:上板之前,先让波形开口说话
4.1 为什么仿真能发现 90% 的设计问题
我个人的习惯是:代码写完先不着急综合布线,先跑行为仿真。很多逻辑错误(状态机跳转不对、计数器边界算错、信号时序不对)在仿真里一眼就能看出来。等仿真通过了,再上板,这时候抓到的问题基本只剩物理层面的问题(引脚约束、时钟约束、电气特性等)。
给发送端写一个简单的 testbench,模拟一个 115200 波特率发送端往我们的接收模块发数据:
`timescale 1ns / 1ps module tb_uart_rx; reg clk; reg rst_n; reg uart_rx_in; wire [7:0] rx_data; wire rx_done; // 生成 50MHz 时钟 initial clk = 0; always #10 clk = ~clk; // 20ns 一个周期 // 生成复位 initial begin rst_n = 0; #100; rst_n = 1; end // 模拟发送一个字节 0x55 (01010101) // 波特率 115200 -> 位周期约 8680ns reg [7:0] send_data; integer i; task uart_send_byte; input [7:0] data; begin uart_rx_in = 1'b1; // 空闲 #8680; uart_rx_in = 1'b0; // 起始位 #8680; for (i = 0; i < 8; i = i + 1) begin uart_rx_in = data[i]; // LSB 先发 #8680; end uart_rx_in = 1'b1; // 停止位 #8680; end endtask initial begin uart_rx_in = 1'b1; #200; send_data = 8'h55; uart_send_byte(send_data); #100000; $finish; end uart_rx #( .CLK_FREQ (50_000_000), .BAUD_RATE (115200) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (uart_rx_in), .rx_data (rx_data), .rx_done (rx_done) ); endmodule跑完这个 testbench,重点看三个信号:rx_done 是否在停止位之后拉高一拍、rx_data 是否等于 0x55、以及采样点是否落在每个比特的中点附近。
4.2 验证波特率误差:8700ns 与 8680ns 的较量
这里补一个仿真中的小细节:我在 testbench 里模拟发送端时,故意把 bit 周期写成 8680ns(根据 115200 算出来是 8680.56ns,取整后是 8680),而接收端内部用的是 50MHz 时钟分频,实际比特周期也是 8680ns(因为 50MHz 分频计数 434 个周期,434 × 20ns = 8680ns)。两边几乎完全一致,所以采样没问题。
但实际工程中,如果收发两端的时钟精度不同,比如发送方是 52MHz 的 MCU,接收方是 50MHz 的 FPGA,连续传几十个字节就可能因为误差累积导致采样点偏移出比特区间。怎么检查?在仿真里把 testbench 里#8680改成#8800,再看看还能不能正确接收。如果还能收到,说明你的接收端容错能力不错;如果收不到,那就得考虑用小数分频或者更高倍过采样来提升容错。
5. 上板调试:引脚约束、硬件连接与常见问题
5.1 第一步:正确分配引脚
综合通过之后,进入实现(Implementation)阶段,这时候需要写 XDC 约束文件(Xilinx 平台)或 QSF 文件(Intel 平台)。以 Xilinx 为例,至少要有以下约束:
set_property PACKAGE_PIN U9 [get_ports {uart_rx}] set_property IOSTANDARD LVCMOS33 [get_ports {uart_rx}] set_property PACKAGE_PIN V11 [get_ports {uart_tx}] set_property IOSTANDARD LVCMOS33 [get_ports {uart_tx}] create_clock -period 20.000 -name sys_clk [get_ports {clk}]这里有个高频翻车点:FPGA 引脚所在的 Bank 供电电压必须与 IOSTANDARD 匹配。如果你的 uart_rx 引脚所在 Bank 的 VCCO 是 1.8V,那你必须把 IOSTANDARD 写成 LVCMOS18,否则电气不匹配,轻则通信不稳定,重则烧引脚。我见过有人在 1.8V 的 Bank 上接了 3.3V 的 USB 转 TTL 模块,结果 FPGA 引脚被灌入过高电压,那一片 Bank 直接报废。选引脚之前,一定先看原理图或者开发板手册,确认 Bank 电压。
5.2 第二步:硬件连接与电平匹配
USB 转 TTL 模块和 FPGA 开发板连接时,除了 TX/RX 交叉连接(FPGA 的 TX 接模块的 RX,FPGA 的 RX 接模块的 TX),还要注意共地。必须把 GND 连上,否则信号没有参考地,通信时有时无,现象非常诡异。
另外,很多 USB 转 TTL 模块支持 3.3V 和 5V 电平切换。FPGA 的 GPIO 绝大多数是 3.3V(或更低)电平,所以跳线帽要拨到 3.3V 位置。如果你接到 5V 上,哪怕 FPGA 引脚本身有钳位二极管保护,长期运行也有隐患。
注意:不同开发板的引脚位置差异很大,接线前务必以板卡原理图为准。哪怕同一个芯片型号,不同厂家的开发板引脚定义也可能完全不同。
5.3 一台电脑如何同时调试 Windows 和虚拟机里的串口
现在很多开发环境是 Windows 宿主机 + VMware 虚拟机(里面跑 Ubuntu 做编译或跑上位机)。这里有一个很实用的经验:不要在 Windows 和虚拟机里同时打开同一个串口,会冲突。而且 VMware 里挂载串口的方式有讲究。
我的做法是:先在 Windows 设备管理器里确认 USB 转串口的 COM口号(比如 COM5),然后 "把串口从 Windows 中释放",在 VMware 虚拟机的虚拟机设置里添加串行端口,选择"使用物理串口",指定 COM5,然后在 Ubuntu 里用 minicom 或者 Python 的 pyserial 打开/dev/ttyS0(或/dev/ttyUSBx,取决于映射方式)。
调试时的选择建议:
- 发几个字节看回显:直接在 Windows 上用串口助手就行,快速又直观。
- 跑脚本做压力测试(比如连续发几百 KB 随机数据):建议在 Ubuntu 里写 Python,串口库成熟,数据分析也方便。
- 避免 Windows 串口助手和虚拟机串口工具同时打开同一个串口——打开会失败,或者出现设备被占用报错。
5.4 经典故障排查:发不出 / 收不到 / 乱码
我把这些年遇到的 UART 通信问题整理成了一张速查表,基本覆盖了 90% 的故障现象:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 完全无响应,助手没有任何显示 | 引脚约束错误、TX/RX 接反、共地问题、模块没供电 | 先用万用表量模块 VCC 是否有 3.3V;核对原理图引脚;交换 TX/RX |
| 收到乱码 | 波特率不一致、时钟频率设置错误、电平不匹配 | 确认 PC 端波特率与 RTL 参数一致;检查系统时钟实际频率;用示波器看波形 |
| 发送方 busy 一直为高 | tx_start 持续拉高、状态机卡死 | 检查 tx_start 是否为单脉冲;在仿真里看状态机是否回到 IDLE |
| 偶尔丢字节 | 上层逻辑在 rx_done 后未及时取走数据、FIFO 溢出 | 加 FIFO 缓存;提高取数频率;降低持续发送速率 |
| 连续发送若干字节后死掉 | 波特率误差累积、接收端状态机收到错误起始位 | 重新计算分频参数;增加起始位二次确认;考虑改用 16 倍过采样 |
这里特别说下"乱码"这种最常见的故障。我调试时第一个反应不是怀疑 RTL 代码,而是先确认串口助手右下角的波特率是不是 115200。多少次了,都是设置被无意改动,或者是"波特率"和"数据位/停止位/校验位"组合不对(比如默认是 8-N-1,结果被改成 7-E-1)。协议参数先对齐,再谈代码问题。
6. 进阶扩展:从裸收发到工程化,UART 还能怎么玩
6.1 用 FIFO 解决数据处理速度不匹配
实际项目中,UART 很少单独作为一个裸收发模块用,基本都是挂在系统总线上。比如 FPGA 收到上位机指令后,需要解析、执行、返回数据。这时如果上位机连续发来 100 个字节,而 FPGA 里的处理逻辑比较慢,后面的字节就会覆盖前面的数据。解决办法是在接收端加 FIFO。
Xilinx 的 Vivado 里有现成的 FIFO IP,Intel 的 Quartus 里也有,甚至可以直接用 FPGA 原语(例如 Xilinx 的xpm_fifo_async)例化一个异步 FIFO。设计思路是:UART 接收模块把 rx_done 当作 FIFO 写使能,rx_data 当作写数据;上层逻辑通过读 FIFO 取数据。这样即使上层处理慢,数据也不会丢,前提是 FIFO 深度够用。
FIFO 深度的选择有个简单估算方法:如果上位机一次突发发 1KB 数据,而你的处理速率只有接收速率的十分之一,那 FIFO 至少需要 900 字节左右的余量。保守一点,配 2KB 深度。
6.2 指令协议设计:为什么不能直接发裸字节
做产品级项目时,我强烈建议不要直接发裸字节,而是定义一套简单的帧格式。我常用的格式是:
| 帧头 | 命令字 | 数据长度 | 数据区 | 校验和 |
|---|---|---|---|---|
| 0xAA 0x55 | 1 字节 | 1 字节 | N 字节 | 1 字节 |
接收端状态机从"找帧头"开始,找到了 0xAA,再等 0x55,然后读命令字和数据长度,最后对数据区做累加校验,和帧尾的校验和比对。这样的好处是:即使线路噪声造成某个字节出错,校验和能发现;即使数据流中某个 0xAA 是偶然出现的数据内容,帧头双字节机制也能降低误判概率。
使用 51 单片机或者 MCU 做上位机配合 FPGA 调试时,这套协议可以直接复用,两边代码逻辑一致,对接很顺畅。
6.3 高速场景:UART 的极限与替代方案
有人会问,UART 最高能跑到多快?如果直接用 FPGA 做,标准 UART 跑 921600 甚至 2M 波特率都没问题,但实际瓶颈在于另一端(电脑上的 USB 转串口芯片或 MCU 的 UART 外设)是否支持。很多 USB 转 TTL 芯片标称支持 3Mbps,但实际在 Windows 驱动下跑 2M 以上时丢包率明显上升。
如果大数据量传输,比如 FPGA 采集的图像要回传到电脑,就别死磕 UART 了——这时候应该上 USB、千兆以太网或者 PCIe。UART 的定位是低速控制、调试、指令交互,不是大吞吐数据通道。这也是很多做 FPGA 图像处理项目的人踩过的坑:以为提高波特率就能把图像数据传完,结果发现 USB 转串口芯片根本稳不住,最后换成了 USB 3.0 或者以太网方案。
6.4 在 SoC 平台上的扩展:Zynq 与软核
如果用的是 Zynq 这类带 ARM 核的 FPGA,UART 不仅可以用 PL 逻辑实现,还能直接用 PS 端自带的 UART 控制器,PL 端通过 AXI 总线读写。这个方案的优点是:波特率配置、FIFO 管理、中断处理都由 ARM 端搞定,PL 逻辑只需要通过 AXI-Lite 接口读写寄存器即可。
我个人的建议是:如果你在 Zynq 上跑 Linux,优先用 PS 端 UART,省事又稳定。PL 端写一个 UART 裸机收发模块,更适合做资源紧张的纯逻辑设计,或者追求极低延迟的硬实时场景。
再补充一个热词里提到的"Zyng Linux 动态加载 FPGA"场景:我们在 Linux 下通过设备树和 FPGA Manager 框架,可以在系统运行过程中动态加载比特流,配合 PL 端的 UART 模块,能实现"ARM 端 Linux 应用 + FPGA 高速逻辑 + UART 调试"的完整链路。这种架构在工业控制、仪器仪表里很常见。
7. 性能提升与资源优化:把 UART 做到极致
7.1 用 16 倍过采样还是直接用分频时钟?
前面接收端设计里,我给的代码带 16 倍过采样,这是工业上最稳妥的方案。但有些资源紧张的项目,希望把逻辑做小一点,也可以直接用分频时钟驱动采样。16 倍过采样相比直接分频有以下优势:
- 可以在比特周期内多次采样,然后取多数表决(三取二或五取三),有效抑制毛刺。
- 起始位检测后可以精确地从比特中点开始采样,容错能力强。
- 波特率误差容忍度更高。实测下来,16 倍过采样方案可以容忍 ±4%~5% 的时钟偏差,直接分频方案只能容忍 ±1%~2%。
代价是计数器位数更多、状态机逻辑更复杂一点。对于现代 FPGA 来说,这点资源差异几乎可以忽略。所以我一律推荐 16 倍过采样。
7.2 多通道 UART:复用模块,减少代码量
如果项目里需要多路串口(比如 4 路 RS485 分别控制不同的传感器),不需要把代码复制四份。把发送端和接收端写成参数化模块,然后用 generate 语句例化多路:
genvar i; generate for (i = 0; i < 4; i = i + 1) begin : uart_ch uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx_in[i]), .rx_data (rx_data[i]), .rx_done (rx_done[i]) ); uart_tx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (tx_start[i]), .tx_data (tx_data[i]), .tx (tx_out[i]), .tx_busy (tx_busy[i]) ); end endgenerate多路复用带来的一个问题是:如果四条链路同时收发,每一路都需要独立的 FIFO,否则数据会互相污染。这是一个很容易被忽视的资源规划问题——看着逻辑面积不大,但 FIFO 的 BRAM 消耗会悄悄增加。
7.3 时序约束与严谨性:不仅仅是"能跑"
很多初学者上板验证通过之后就认为大功告成了,这在教学场景没问题,但做产品必须严谨。我建议至少完成以下检查:
- 时序收敛:综合实现后看时序报告,确保建立时间和保持时间都满足。UART 频率不高,一般不会有时序问题,但如果系统里还有其他高速逻辑,串扰可能影响 UART 引脚上的信号质量。
- 上电复位时序:FPGA 上电后,状态机和外部芯片需要时间完成初始化。如果上位机一上电立刻发数据,而 FPGA 还没完成配置,数据就丢了。在设计中可以加一个上电延时逻辑,等 FPGA 初始化完成后才允许 UART 模块工作。
- 跨时钟域处理:如果你的 UART 模块工作在 50MHz,而系统总线工作在 100MHz,那么 UART 模块与总线之间的握手信号需要通过异步 FIFO 或脉冲同步器处理。UART 本身是低速接口,跨时钟域问题容易被忽视,但恰恰是造成随机死机的原因。
8. 一个完整的实战记录:从零到回环通信成功
这里记录一次真实的调试验证过程,给大家一个完整的操作范本。假设环境是:Xilinx Artix-7 开发板,50MHz 系统时钟,使用 FT232 芯片的 USB 转 TTL 模块,波特率 115200。
第一步:生成比特流
在 Vivado 里创建工程,添加顶层uart_loopback和子模块uart_tx、uart_rx,写好约束文件。综合、实现、生成比特流,这一步大约耗时十分钟。如果时序报告有警告,先不急着上板,回到代码里检查是否有明显的异步逻辑问题。
第二步:连接硬件
USB 转 TTL 模块插入电脑 USB 口,确认设备管理器里出现 COM 端口。把模块的 TX 接到 FPGA 的 uart_rx 引脚,模块的 RX 接到 FPGA 的 uart_tx 引脚,GND 和 GND 相连。这里最容易犯的错就是 TX/RX 没有交叉,连接完成后我习惯用万用表量一下模块 VCC 和 GND 之间的电压,确认是 3.3V 左右再上电。
第三步:下载比特流,打开串口助手
打开串口助手,选择对应 COM 口,波特率 115200,数据位 8,停止位 1,无校验。然后发送十六进制字节0x55,正常情况下立刻能看到回显0x55。
如果没反应,按顺序检查:引脚约束有没有写对(这里划重点,约束文件的引脚号必须和板卡原理图一一对应,不能用开发板默认的示例引脚,除非你确认该引脚接到了 PMOD 或其他可用接口);串口助手波特率是否一致;TX/RX 是否接反。
第四步:压力测试
回显成功后,我习惯做一轮压力测试:在串口助手里选择"按十六进制发送",连续发送 1000 字节递增数据(比如 0x00 到 0xFF 循环 4 次),观察回显是否完全一致。如果中间有缺失或乱码,多半是拔插线不稳、共地不好,或者驱动出了问题。
提示:如果连续发送时出现丢字节,先检查 USB 转 TTL 模块的驱动版本。FT232 老版本驱动在 Windows 10/11 下偶尔出现兼容问题,表现为波特率设置不生效或丢数据,换一个较新的驱动版本往往能解决。另外,Windows 的 USB 选择性暂停设置也可能导致串口设备"睡过去",在电源管理里关掉该选项能提升长时间传输的稳定性。
第五步:仿真对照
压力测试通过后,再用仿真验证几个边界场景:波特率偏移 3% 能否正确接收、起始位毛刺能否被过滤、连续发送能否保持不丢帧。这些在仿真里都验证过,上板基本就不用再担心 UART 本身的问题了。
9. 卡尔曼滤波、图像处理、Biss-C…… UART 和它们的交集
热词里有不少看起来和 UART 无关的词,比如卡尔曼滤波、图像处理、Biss-C。既然提到了,我简单聊聊它们在工程中如何和 UART 产生交集,这也是多项目背景下绕不开的经验。
卡尔曼滤波与 FPGA:常见的路径是:FPGA 读取传感器数据(可能是 UART 接口的 IMU、GPS 模块),先用 UART 模块把数据解出来,再在 FPGA 内部做卡尔曼滤波,最后把融合后的结果通过另一路 UART 发送给上位机。整个链路里,UART 是数据进出的"门",卡尔曼是"处理器"。很多初学者一上来就想把卡尔曼滤波的矩阵运算写好,结果卡在传感器数据根本读不出来——所以先把 UART 搞稳定,后续算法才有意义。
图像处理与 FPGA:图像处理的核心数据通道一般是 MIPI、LVDS 或千兆以太网进,HDMI 或 DisplayPort 出。UART 在这里通常扮演"控制信道"的角色:上位机通过 UART 给 FPGA 下发参数(比如曝光时间、增益、处理算法的阈值),FPGA 处理完图像后再把状态信息通过 UART 上报。所以哪怕你在做图像项目,UART 依然是一个必须稳的辅助模块。
Biss-C 编码器与 FPGA:Biss-C 是一种高速串行编码器协议,它的时序和 UART 完全不同(同步串行,带时钟线),但在 FPGA 实现层面,很多经验是相通的:要用状态机、要处理边沿、要注意异步信号的同步化。如果你已经熟练掌握了 UART 的实现,再去看 Biss-C 的时序图,会发现本质都是"边沿检测 + 按位采样 + 状态切换"。
RS485 与 FPGA:工业现场常用 RS485 总线,它和 UART 的关系是:RS485 是物理层标准(差分信号),UART 是数据链路层协议(数据帧格式)。FPGA 的 UART 模块输出的 TTL 电平,经过一个 RS485 收发器(比如 MAX3485)转换成差分信号,就能在长距离、多节点的工业总线上通信。代码层面的区别只是多了一个方向控制信号(DE/RE),发送时使能驱动,接收时禁用驱动。
10. 常见名词与概念速查:别再被面试题问懵
最后整理一份和 UART 强相关的名词解释,方便在面试或项目汇报时快速查阅。这些概念我见过太多次被混淆了,放在一起对比更容易记住。
| 名词 | 全称/含义 | 与 UART 的关系与区别 |
|---|---|---|
| UART | Universal Asynchronous Receiver/Transmitter | 通用异步收发器,定义了数据帧格式和时序,是协议+控制器的统称 |
| USART | Universal Synchronous/Asynchronous Receiver/Transmitter | 在 UART 基础上增加了同步模式(带时钟线),比如 SPI 模式。MCU 上常见的 STM32 的 USART 可以配置成异步模式,这时它就是一个 UART |
| RS232 | 老牌串行通信标准 | 定义了电气特性(±12V)、连接器(DB9)和信号定义。UART 是逻辑协议,RS232 是物理层标准 |
| RS485 | 差分串行总线标准 | 物理层采用差分信号,抗干扰强,支持多点通信。协议层常用 UART 帧格式 |
| TTL | Transistor-Transistor Logic | 泛指 0~3.3V/5V 的数字电平,UART 在 FPGA/MCU 上输出的就是 TTL 电平 |
| SPI | Serial Peripheral Interface | 同步串行接口,需要时钟线,4 根线(SCLK/MOSI/MISO/CS),速度比 UART 高,协议简单但占用引脚多 |
| I2C | Inter-Integrated Circuit | 同步串行接口,2 根线(SDA/SCL),支持多设备总线,带应答机制,速度通常比 UART 高 |
| CAN | Controller Area Network | 差分串行总线,专为工业/汽车设计,带仲裁、错误检测,速度中等,可靠性高 |
| MIPI | Mobile Industry Processor Interface | 高性能串行接口规范,用于摄像头/显示屏,与 UART 完全不同层级,属于高速 SerDes 范畴 |
| LVDS | Low-Voltage Differential Signaling | 低电压差分信号,高速传输,FPGA 中用于引脚到引脚的高速数据传输 |
| Biss-C | BiSS-C 编码器协议 | 同步串行协议,类似 SPI 但有更严格的时序约定,用于高精度位置反馈 |
| FIFO | First In First Out | 先进先出数据缓存,UART 接收/发送侧常挂 FIFO 解决速率不匹配问题 |
| 亚稳态 | Metastability | 时序器件在时钟边沿附近采样外部信号时出现的中间状态,跨时钟域处理的核心概念。UART 接收外部异步信号时,必须用两级寄存器同步 |
面试官如果要问 UART 相关的问题,多半会从这些角度切入:UART 和 SPI/I2C 的区别、异步通信为什么需要起始位和停止位、波特率误差怎么算、接收端为什么要用 16 倍过采样、如何处理亚稳态。把这些概念吃透,面试和实战都能稳得住。
更重要的是,理解这些协议之间的本质区别后,你在做 FPGA 系统集成时会明白什么事该用 UART、什么事该上 SPI、什么时候需要 RS485。选型不盲目,方案才有底气。
写在最后的一点个人经验
回头看这些年用 FPGA 做过的项目,UART 几乎贯穿始终。它是最容易上手的通信协议,也是系统联调时最可靠的调试手段。用逻辑分析仪或者示波器抓 UART 波形,看着那串高低电平的变化,其实就是在阅读 FPGA 和外部世界之间最朴素的语言。
我个人最深刻的体会是:越是简单的东西,越要把它做稳。UART 模块几十行代码就能写完,但要做到在工业现场长时间无差错运行,需要关心的地方一点不少——引脚约束是否正确、电平是否匹配、FIFO 是否够深、协议是否有校验、驱动是否有兼容问题。每一个细节,都可能成为系统稳定性的短板。
如果你正准备入门 FPGA,不妨从 UART 开始,不只是照着教程把代码敲一遍,而是把发送、接收、回环、仿真、上板、排查这一整套流程完整走一遍。这个过程积累的不只是 UART 本身的知识,更是一套"FPGA 工程化"的方法论:状态机怎么设计、异步信号怎么处理、怎么用波形调试、遇到问题怎么定位。这套方法论,到了后面做 PCIe、DDR、千兆以太网的时候,依然管用。