
说实话UART大概是FPGA入门里最容易被低估的一个接口。看起来就是一个起始位、八个数据位、一个停止位逻辑再简单不过但真到了上板调试那天你会发现乱码、丢字节、设备识别不到问题一个接一个。我接触FPGA这些年带过不少新人几乎每个项目的串口通信环节都会卡上一两天。这篇文章就围绕FPGA实现UART串口通信这条线从协议细节、模块划分、Verilog代码、仿真验证到板级调试把整个链路完整梳理一遍希望给正在学FPGA通信、或者准备拿串口做项目底板的人一些参考。1. UART协议里那些容易被忽略的细节1.1 帧格式8N1之外你还得知道什么UART的全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。说“异步”是因为收发双方没有独立的时钟线靠的是约定的波特率和帧格式来对齐数据。一次完整的传输叫一帧典型的帧结构是空闲状态高电平、起始位拉低一位时间、8个数据位低位在前、可选校验位、最后停止位拉高。大家最常用的就是8N1意思是8个数据位、无校验、1个停止位。一帧一共10个bit的时长其中第1个bit是起始位最后1个bit是停止位。很多新手写状态机的时候老想着去处理“数据”但其实起始位和停止位才是接收端对齐时钟的关键——起始位的下降沿告诉接收端“要开始了”停止位的高电平告诉接收端“这一帧结束了可以继续监听空闲”。有几点容易被忽略低位在前。很多刚从51单片机转过来的朋友习惯性认为最高位先传结果实现出来的移位方向反了收发双方都用同样的错误反而不容易发现一旦和PC或者别的设备对接立刻乱码。校验位不是必选。8E1、8O1在工业现场偶尔能看到就是为了多一个比特的检错能力。但从FPGA实现角度校验位就是多移位一次、多判断一个bit状态机加一个状态即可难度不大。停止位长度。有的设备要求2个停止位多见于低速率的工业仪表。实现的时候就是在STOP状态多等一个bit的时间。1.2 波特率与电平标准别把TTL和RS232搞混波特率代表每秒传输的码元个数常规的是9600、19200、115200。9600看起来慢但在长线、强干扰的环境下比115200稳得多115200是PC端最常用因为一条10bit的帧传1KB数据大约只要90ms体感上很流畅。除了波特率电平标准是另一个大坑。FPGA的IO一般工作在3.3V或2.5V TTL电平而老式PC串口是RS232电平逻辑1是-15V~-3V逻辑0是3V~15V。要是直接把FPGA的引脚接到PC串口上轻则通信失败重则烧引脚。所以中间必须加电平转换芯片最常见的方案是MAX3232或者SP3232。现在笔记本基本没有原生串口了大家普遍用USB转TTL模块这类模块内部已经做了电平转换直接3.3V/5V输出接FPGA很方便。这里提醒一句USB转TTL模块上的3.3V和5V跳线一定要根据FPGA BANK电压选对我见过有人用5V输出接3.3V的BANK虽然一次两次没烧但引脚已经处于过压状态长期跑不稳定偶发乱码排查半天找不到原因。2. FPGA实现UART的模块划分与设计思路2.1 顶层规划三个模块各管一摊FPGA做UART我习惯把它拆成三个独立模块波特率发生器、发送模块、接收模块。顶层负责例化和连线。这样做的好处是单个模块逻辑简单仿真和排查问题边界清晰。顶层接口大致是这样module uart_top #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire tx_start, input wire [7:0] tx_data, output wire txd, // 发送引脚 output wire tx_busy, input wire rxd, // 接收引脚 output wire rx_valid, output wire [7:0] rx_data );这里有几个设计决策值得展开说。第一点是统一使用系统时钟做同步设计不要在模块内部产生分频时钟再拿去驱动逻辑。很多新手喜欢在FPGA里用计数器分频出一个“波特率时钟”然后用这个时钟当触发边沿这种写法在仿真里没问题但上板之后容易因为时钟偏斜和毛刺出各种诡异问题。正确做法是分频逻辑产生一个“时钟使能脉冲”或者一个高电平持续一个周期的采样标记所有寄存器仍然由系统主时钟驱动。这样时序收敛好代码移植也简单。第二点是rxd信号进来必须先经过两级同步器。串口输入是异步信号和FPGA内部时钟没有任何相位关系直接采会出现亚稳态。最简单的处理就是打两拍让信号稳定后再参与逻辑判断。reg rxd_sync1, rxd_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_sync1 1b1; rxd_sync2 1b1; end else begin rxd_sync1 rxd; rxd_sync2 rxd_sync1; end end第三点是发送和接收逻辑完全独立。UART是双工的理论上发送和接收可以同时进行。虽然在入门工程中往往只有单向需求但模块设计的时候就要把接口留好不要写着写着把两个方向耦合在一起后面扩展会很痛苦。2.2 采样策略为什么16倍过采样而不是1倍接收端面临的核心问题是只知道波特率但不知道对方具体什么时刻发数据。如果只在理论上“每个bit中间”采样一次起始位的相位稍有偏差后面的采样点会越来越偏移最后落在bit边缘结果就是乱码。所以成熟的做法是过采样。最常见的是16倍过采样也就是说FPGA内部产生一个频率为“波特率×16”的采样时钟在这个时钟下把每个bit再切成16个采样点。检测到起始位下降沿后不从零开始数而是等8个采样周期半个bit再确认一次电平仍然为低此时判定起始位有效然后每16个采样周期采一次数据正好落在每个bit的中间位置。16倍过采样的优势在于抗干扰能力强单个毛刺造成的误触发概率低采样点落在bit中心对波特率误差的容忍度更高和1倍采样相比接收逻辑不需要精确锁定相位代码实现简单。有人会用8倍过采样来节省资源理论上也行但16倍是工程中最常见的折中方案。资源开销只是几个计数器在FPGA里完全不是瓶颈。3. 核心代码波特率生成、发送与接收的Verilog实操3.1 波特率分频器参数计算与误差控制波特率发生器的本质是把系统时钟分频成“波特率×16”的脉冲。分频值计算公式是DIV CLK_FREQ / (BAUD_RATE * 16)以50MHz系统时钟、115200波特率为例DIV 50_000_000 / (115200 * 16) 50_000_000 / 1_843_200 ≈ 27.13分频值必须是整数取27。这时实际采样时钟频率是50_000_000 / 27 ≈ 1_851_851.85 Hz 实际波特率 1_851_851.85 / 16 ≈ 115_740.74 bps误差约为0.47%。UART协议本身对波特率误差有容忍度一般要求在3%以内因为一帧最长也就10个bit每个bit累积一点误差到最后一个采样点也还在安全范围内。0.47%完全没问题。代码可以这样写module baud_gen #( parameter DIV 27 )( input wire clk, input wire rst_n, output reg baud_x16_en // 高电平持续一个时钟周期的使能脉冲 ); reg [11:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 12d0; baud_x16_en 1b0; end else if (cnt DIV - 1) begin cnt 12d0; baud_x16_en 1b1; end else begin cnt cnt 1b1; baud_x16_en 1b0; end end endmodule这里有两个细节值得注意。第一计数器的位宽要留足。如果系统时钟是100MHzDIV就会变成54计数器仍然够用但如果你把参数化设计做得通用位宽建议至少12位甚至16位避免换一个时钟频率就溢出的尴尬。第二使能脉冲只持续一个主时钟周期下游模块看到这个脉冲就加一个采样计数器而不是把它当作时钟边沿。参数化设计会方便很多。比如把CLK_FREQ和BAUD_RATE作为顶层参数模块内部实时计算DIV虽然综合工具往往会用常量除法直接优化成加法减法但代码可读性会好很多。初学阶段用常量参数更直观。3.2 发送模块状态机与移位寄存器发送模块相对简单核心是一个三段式状态机IDLE、START、DATA、STOP。IDLE状态下txd保持高电平当外部给出tx_start脉冲时把并行数据锁存到移位寄存器然后进入START状态txd拉低一个bit时间接着按bit依次送出数据最后STOP状态拉高一bit时间回到IDLE。发送侧关键在于“一个bit时间”怎么控制。由于波特率模块输出的baud_x16_en每16个脉冲对应一个bit所以内部用一个4位计数器每来一个baud_x16_en就加一加到15就说明一个bit结束。状态机在每个bit结束的节拍切换状态。核心代码module uart_tx #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire baud_x16_en, input wire tx_start, input wire [DATA_WIDTH-1:0] tx_data, output reg txd, output wire tx_busy ); localparam IDLE 2d0; localparam START 2d1; localparam DATA 2d2; localparam STOP 2d3; reg [1:0] state; reg [3:0] clk_cnt; reg [2:0] bit_cnt; reg [DATA_WIDTH-1:0] shift_reg; assign tx_busy (state ! IDLE); // 状态转移 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; txd 1b1; clk_cnt 4d0; bit_cnt 3d0; end else begin case (state) IDLE: begin txd 1b1; clk_cnt 4d0; bit_cnt 3d0; if (tx_start) begin shift_reg tx_data; state START; end end START: begin txd 1b0; if (baud_x16_en) begin if (clk_cnt 4d15) begin clk_cnt 4d0; state DATA; end else begin clk_cnt clk_cnt 1b1; end end end DATA: begin if (baud_x16_en) begin if (clk_cnt 4d15) begin clk_cnt 4d0; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end shift_reg {1b0, shift_reg[DATA_WIDTH-1:1]}; end else begin clk_cnt clk_cnt 1b1; end end txd shift_reg[0]; end STOP: begin txd 1b1; if (baud_x16_en) begin if (clk_cnt 4d15) begin clk_cnt 4d0; state IDLE; end else begin clk_cnt clk_cnt 1b1; end end end default: state IDLE; endcase end end endmodule注意一个细节我在DATA状态下是先用旧的shift_reg[0]输出然后在bit结束时移位。这种方式能保证第1个数据位原始数据的最低位在进入DATA状态后立刻出现在txd上。如果你把移位写在输出之前会丢掉一个bit。这种“输出和移位时序”的错位问题是仿真和上板行为不一致的常见来源。3.3 接收模块起始位检测与中间采样接收模块比发送复杂难点在于起始位检测和采样点对齐。我建议的接收逻辑分几步同步rxd信号检测到下降沿时说明可能有起始位等8个baud_x16_en周期半个bit后再看rxd如果还是低电平说明确实是起始位不是毛刺从确认点开始每16个baud_x16_en周期采样一次rxd正好落在每个bit的中间采满8个数据位后等待停止位再输出rx_valid脉冲。这段逻辑我习惯用一个计数器贯穿全程而不是让状态机处理每个采样点。核心代码module uart_rx #( parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire baud_x16_en, input wire rxd, output reg rx_valid, output reg [DATA_WIDTH-1:0] rx_data ); reg [3:0] clk_cnt; reg [2:0] bit_cnt; reg [7:0] shift_reg; reg rxd_sync1, rxd_sync2; reg rxd_d; reg receiving; // 同步与下降沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_sync1 1b1; rxd_sync2 1b1; rxd_d 1b1; end else begin rxd_sync1 rxd; rxd_sync2 rxd_sync1; rxd_d rxd_sync2; end end wire rxd_negedge rxd_d ~rxd_sync2; always (posedge clk or negedge rst_n) begin if (!rst_n) begin receiving 1b0; clk_cnt 4d0; bit_cnt 3d0; rx_valid 1b0; end else begin rx_valid 1b0; if (!receiving) begin // 检测到下降沿进入起始位确认 if (rxd_negedge) begin receiving 1b1; clk_cnt 4d1; // 从1开始留一个周期余量 bit_cnt 3d0; end end else begin if (baud_x16_en) begin if (clk_cnt 4d8) begin // 起始位中间确认低电平有效 if (rxd_sync2) begin receiving 1b0; // 毛刺放弃 end end else if (clk_cnt 4d15) begin clk_cnt 4d0; // 一个bit结束 if (bit_cnt DATA_WIDTH) begin // 已经采完8位这里是停止位时刻 bit_cnt 3d0; receiving 1b0; rx_data shift_reg; rx_valid 1b1; end else begin shift_reg {rxd_sync2, shift_reg[7:1]}; bit_cnt bit_cnt 1b1; end end else begin clk_cnt clk_cnt 1b1; end end end end end endmodule这段代码我是按照“确认起始位后接下来每个16采样周期的开始时刻采集数据”的思路写的。你可以看到在bit_cnt等于8的时候到了停止位此时完成收帧。接收模块比发送更容易踩坑的地方在于边界情况下指令时序和采样的具体对应关系。我的建议是仿真时把数据移位和clk_cnt的变化逐拍打印出来看到第1个数据bit确实是在起始位之后16个采样周期采到的再继续往下调。4. 仿真与板级联调验证链路怎么搭4.1 用Testbench做一轮完整的收发回环FPGA开发里仿真不能省尤其对于UART这种协议型接口仿真能帮你把95%的逻辑错误提前暴露出来。写Testbench时我通常会验证两类场景第一类是纯发送把一组数据通过uart_tx发出去在Testbench里用行为级模型接收并比对第二类是纯接收Testbench模拟外部设备按115200的时序把数据逐bit打进来看uart_rx能不能正确恢复。比较实用的做法是做一个回环测试例化顶层模块把txd直接连到rxd然后在Testbench里发送一串数据观察接收端能否原封不动收回来。module tb_uart_top(); reg clk; reg rst_n; reg tx_start; reg [7:0] tx_data; wire txd; wire tx_busy; wire rx_valid; wire [7:0] rx_data; uart_top #( .CLK_FREQ(50_000_000), .BAUD_RATE(115200) ) dut( .clk(clk), .rst_n(rst_n), .tx_start(tx_start), .tx_data(tx_data), .txd(txd), .tx_busy(tx_busy), .rxd(txd), .rx_valid(rx_valid), .rx_data(rx_data) ); initial begin clk 0; forever #10 clk ~clk; // 50MHz end task send_one_byte(input [7:0] data); begin (posedge clk); tx_start 1b1; tx_data data; (posedge clk); tx_start 1b0; wait (!tx_busy); end endtask integer i; initial begin rst_n 0; tx_start 0; tx_data 0; #100; rst_n 1; #1000; for (i 0; i 4; i i 1) begin send_one_byte(i * 8h11 8h23); end #5000; $finish; end endmodule仿真时重点看几个波形baud_x16_en是否按周期出现、txd波形是否满足“起始位拉低→数据位→停止位拉高”的时序、rx_valid脉冲是否在整帧结束后拉高、rx_data是否等于发送的数据。如果发现rx_data不对优先看是采样点偏移导致的错位还是移位方向反了。这两种问题在波形上的表现不一样采样点偏移通常只有1个bit的错误但数据整体移了一位移位方向反了是数据位序完全颠倒。4.2 上板调试串口助手连不上怎么排查仿真通过只是第一步上板才是真正考验工程能力的地方。先说连接方案。现在绝大多数人都用USB转TTL模块模块的TX接FPGA的RX引脚模块的RX接FPGA的TX引脚这个交叉关系我强调多少遍都不过分。板卡和模块之间还要共地不共地就会出现数据完全对不上或者偶发错误。接好之后打开串口助手设置好端口、波特率115200、数据位8、停止位1、无校验这时候如果用FPGA自发自收串口助手应该能看到回显。如果没有按这个顺序排查确认驱动。USB转串口芯片最常见的是FT232R、FT231X、CH340这些。Windows下驱动没装好设备管理器里会显示黄叹号串口助手根本找不到端口。确认引脚约束。很多入门板卡上UART引脚连接到了USB转串口芯片而不是直接引出引脚约束文件里把txd、rxd分配到正确引脚了吗确认TX/RX交叉。这个错误我用排除法验证过很多次一旦接反现象是完全无回应和驱动没装很像。用串口助手的“发送”功能发一个字节再用示波器或者逻辑分析仪看FPGA的rxd引脚有没有波形。没有波形说明外部数据没进来有波形但rx_valid没拉高问题在FPGA内部逻辑。到了这一步基本就能把问题边界划清楚是PC端/驱动问题还是连线问题还是FPGA逻辑问题。5. 常见问题与工程化避坑实录5.1 经典故障速查表下面这些是我在实际调试中反复遇到的场景整理成表格方便大家对照排查。现象可能原因排查方向串口助手完全收不到数据TX/RX接反、驱动没装、引脚约束错误先查设备管理器再量FPGA txd引脚有无波形收到数据但全是乱码波特率误差太大、系统时钟频率不是预期值、电平不匹配核对分频参数确认PLL配置检查USB转TTL电平跳线第一帧数据总是丢后面正常上电瞬间外部设备比FPGA早启动或者复位释放时机不合理增加上电延时或者复位释放后等待100ms再开始发送偶发错一个bit线缆过长、干扰大、共地不良缩短杜邦线检查GND连接尝试降低波特率到9600FPGA收不到PC发来的数据rxd引脚同步不到位、起始位检测太敏感确认两级同步器检查下降沿检测逻辑这里的“乱码”要分两种看。如果收到的字节完全不对但每帧长度都一样比如收到0x80而你发的是0x01大概率是移位方向反了。如果字节忽对忽错优先怀疑波特率误差和时钟频率。之前我一朋友在自定义板卡上忘记配置PLL的输出频率把50MHz输出了100MHz串口助手收到的东西能组成一首诗排查了一下午才想起来看时钟配置。5.2 跨时钟域与FIFO往真实工程再走一步基础收发跑通以后你会遇到一个更现实的问题FPGA内部主逻辑工作频率可能远高于波特率数据是一个字节一个字节进来的但主逻辑希望按“整包”或者“突发”来处理数据。这时候就需要FIFO做缓冲。我的做法是在uart_rx后面加一个异步FIFO接收侧用一个字节写入主逻辑侧在FIFO非空时读出。这个方案在ZYNQ和纯FPGA项目里都很常用。写入侧时钟可以用系统时钟读出侧时钟用AXI总线时钟异步FIFO天然解决了跨时钟域问题。发送方向同理。如果主逻辑产生数据的速度不均匀可以先写进发送FIFOuart_tx空闲时自动从FIFO取数发送这样主逻辑不会被串口的慢速节奏拖死。Xilinx的FPGA里直接调用FIFO IP核就行Altera也有对应IP不建议自己写异步FIFO除非你想深入研究读写指针和格雷码同步。6. 扩展方向从跑通到真正能用6.1 接RS485/RS422方向控制到底怎么做工业现场最常见的不是UART直连而是RS485总线。RS485是差分信号抗干扰能力强传输距离上百米典型应用是Modbus协议。FPGA接RS485一般通过MAX3485这类芯片把UART的TX/RX转换成差分A/B线。这里有一个很多新手想不到的点RS485是半双工的同一时刻只能发送或者接收所以需要一根方向控制线DE。发送数据前要把DE拉高发送结束后拉低这个切换时机要严格控制。如果DE切换太早或者太晚总线上会出现冲突轻则本帧错误重则烧驱动芯片。我的经验是发送一帧之前先把DE拉高然后空等至少一个bit时间再开始发送数据发送结束后等停止位结束再拉低DE。这样虽然牺牲了一点吞吐率但可靠得多。用FPGA实现这个逻辑非常容易本质上就是在uart_tx外面包一层方向控制状态机。6.2 多路串口与ZYNQ的软硬件协同FPGA的优势是并发一颗芯片里例化8路甚至16路UART毫无压力。每个UART模块独立工作只是顶层多几个引脚。这在多传感器采集、多设备控制场景里是硬需求。比如一个FPGA同时接多个激光雷达、多个电机驱动器每个设备一路串口在MCU方案里需要多个UART外设在FPGA里只是多复制几个模块。如果你用的是ZYNQ这种带ARM核的芯片串口还有一个更灵活的玩法把UART的收发逻辑放在PL端把数据通过AXI-Lite接口送给PS端Linux系统里直接生成一个tty设备。这样既保留了FPGA处理高速信号的能力又能用成熟的Linux网络协议栈把数据转发出去。这个方向适合做完了基础UART后想进一步进阶的人。6.3 通信协议增强加校验和帧解析裸UART传字节有一个问题字节边界清楚但“消息”边界不清楚。比如上位机发来一帧“AA 55 01 02 03 FF”作为控制指令接收端怎么知道从哪个字节开始算一帧这就要在UART之上再封一层协议。最简单的做法是帧头长度数据校验和。接收端状态机先找帧头找到后按长度收数据最后校验。这个过程用FPGA实现也不难本质上是一个查帧头的状态机加上一个CRC或者累加和校验模块。数据量不大的场景累加和就够用CRC32留给数据量大的场景。这块内容的工程价值很高。很多面试题也会问UART如何保证数据可靠性答案其实就是协议分层和校验而不是改动UART底层。我在实际项目里现在基本不会再用裸UART传业务数据至少会套一个简单的帧格式。哪怕只是调试用的日志输出也建议约定帧头和结束符这样上位机解析时不用全靠超时判断。结尾一点个人的体会做FPGA串口通信这个项目最大的收获不是把收发模块写出来而是建立了一套“仿真先行、分段排查”的调试习惯。我印象最深的一次翻车是波特率分频参数算错了一位小数Vivado综合没报错上板串口助手收到的全是乱码最后是回到仿真里一个一个数baud_x16_en的脉冲间隔才发现问题。从那以后我再也不敢口算分频值一律用计算器确认而且在代码里保留计算过程的注释。如果想继续深挖我的建议是跑通基础收发后先做回环测试再做PC互发之后尝试加FIFO和帧协议最后可以试试多路串口或者接RS485设备。每走一步你对FPGA时序设计的理解都会扎实一分。串口通信本身不难但它是一把非常好的钥匙能打开FPGA通信设计的大门后面无论是IIC、SPI还是PCIe、Ethernet调试思路都是相通的。