
做UART串口通信FPGA实现这个项目说简单也简单说难也难。我在刚接触FPGA那会儿花了整整两天才把收发两端的时序彻底弄清楚后来带新同事时发现大家踩的坑都非常一致波特率计算错误、采样点没对准、接收端没做同步处理导致亚稳态。这篇我把UART串口通信FPGA实现的完整思路写透从协议本身、Verilog代码、仿真验证到上板调试全程按我实际操作过的流程来讲适合刚学完Verilog基础语法、想通过一个串口项目把状态机和时序概念串起来的人也适合正在做嵌入式联调、需要自己写一套UART收发逻辑的工程师参考。1. 整体设计UART协议拆解与FPGA方案选型1.1 先把协议读懂一帧数据到底长什么样UART串口通信的本质是异步串行传输发送端和接收端共用一个约定的波特率不需要时钟线。空闲状态下TX线保持高电平要发数据时先拉低一个bit时间作为起始位然后按低位在前、高位在后的顺序发送8个数据位最后拉高一个bit时间作为停止位。完整的一帧就是10个bit时间1个起始位、8个数据位、1个停止位没有校验位。我之前被问过很多次“串口为什么是低电平起始”原因很简单UART空闲就是高电平接收端靠检测下降沿来知道“对方要发数据了”。如果起始位是高电平空闲也是高电平接收端根本没法判断什么时候开始所以起始位必须是下降沿。这是整个协议里最基础但最关键的一点后面写接收模块时所有逻辑都围绕这个下降沿展开。波特率就是每秒传输的bit数9600bps表示每个bit占1/9600秒约104.2微秒115200bps时每个bit约8.68微秒。FPGA内部跑的一般是几十兆甚至上百兆赫兹的时钟要产生符合波特率的节奏靠的不是睡延时函数而是分频计数。以50MHz系统时钟为例发送9600bps的一个bit需要计数50000000/9600约5208.33个时钟周期取5208后实际波特率是9600.61bps误差只有0.006%完全在容忍范围内。115200bps时更是接近整除所以如果条件允许我用这个经典的50MHz搭配115200组合居多。1.2 为什么用状态机而不是“读完再拼线”写UART收发逻辑很多初学者第一反应是用循环判断byte的bit但Verilog是硬件描述语言代码最终会综合成物理电路没法像C语言那样“等到某一位再继续”。正确的做法是用有限状态机在时钟沿驱动下按状态迁移。发送端的状态机只有四个状态IDLE空闲、START发送起始位、DATA逐位发送数据、STOP发送停止位。接收端也是四个状态但START状态的进入不是自己触发而是检测RX线出现下降沿。这个差异值得多写一句发送端是主动的状态切换靠发送使能信号接收端是被动的状态切换靠外部信号变化。之前看过不少新手代码将波特率分频后的时钟直接当always块的时钟使用比如用分频得到的baud_clk作为敏感列表里的时钟沿。这在实际工程里不太推荐因为分频时钟会造成新的时钟域后续时序约束和STA分析都不方便。更稳妥的做法是保留系统时钟作为主时钟分频逻辑产生一个单时钟周期宽度的脉冲状态机用这个脉冲作为“推进信号”。后面代码里我会把这个写好这也是FPGA实现UART的常见工程实践。2. 工程实现发送与接收模块逐个落地2.1 分频器波特率的正确计算与误差控制先写一个通用的波特率分频模块它不直接产生时钟而是产生一个bit_tick脉冲每个bit周期来一拍。模块内部用一个计数器从0数到分频系数减1数满时输出一个高电平脉冲同时计数器清零重来。module baud_gen #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, output wire bit_tick ); localparam DIV_CNT CLK_FREQ / BAUD_RATE - 1; reg [$clog2(DIV_CNT1)-1:0] cnt; always (posedge clk or negedge rst_n) begin if (!rst_n) cnt 0; else if (cnt DIV_CNT) cnt 0; else cnt cnt 1b1; end assign bit_tick (cnt DIV_CNT); endmodule这个写法把分频系数DIV_CNT做了localparam用$clog2算出计数器位宽避免自己手数位宽出错。分频系数的计算公式是系统时钟频率除以波特率再减1因为我判断的是“cnt走到DIV_CNT时产生脉冲”如果直接写CLK_FREQ/BAUD_RATE计数器会多走一拍实际波特率就会偏慢。这个问题我也见过好几次代码看着对波形一量发现少了一拍。另一个实操建议是尽量选择能整除的组合。比如12MHz时钟配115200波特率分频系数是104.17取整后误差约0.16%长时间大量传数据时累计误差可能让帧错位。50MHz配9600、50MHz配115200、100MHz配115200这类组合都挺好误差极小。2.2 发送模块从空闲到停止位的四状态流转发送模块的核心是状态机和移位寄存器。我设计的接口包括发送使能tx_en、8位并行数据tx_data、串行输出txd、忙信号tx_busy。发送使能只在空闲状态下被响应一旦进入发送流程tx_busy拉高外部模块就知道不能再塞新数据了。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire tx_en, input wire [7:0] tx_data, output reg txd, output reg tx_busy ); localparam DIV_CNT CLK_FREQ / BAUD_RATE - 1; localparam IDLE 2d0, START 2d1, DATA 2d2, STOP 2d3; reg [1:0] state; reg [7:0] data_buf; reg [2:0] bit_cnt; reg [15:0] baud_cnt; wire bit_tick (baud_cnt DIV_CNT); always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; txd 1b1; tx_busy 1b0; data_buf 8d0; bit_cnt 3d0; baud_cnt 16d0; end else begin baud_cnt (bit_tick) ? 16d0 : baud_cnt 16d1; case (state) IDLE: begin txd 1b1; tx_busy 1b0; if (tx_en) begin data_buf tx_data; tx_busy 1b1; state START; baud_cnt 16d0; end end START: begin txd 1b0; if (bit_tick) state DATA; end DATA: begin if (bit_tick) begin txd data_buf[0]; data_buf {1b0, data_buf[7:1]}; if (bit_cnt 3d7) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end end STOP: begin txd 1b1; if (bit_tick) state IDLE; end endcase end end endmodule这里有几个容易写错的地方。DATA状态下bit_tick到来时先把data_buf[0]赋给txd再右移数据所以第一个bit周期里发送的是最低位符合UART协议的低位在前。bit_cnt统计的是已经发送了多少个数据位第8个bit发送完后就切到STOP状态这里要特别注意如果等到bit_cnt等于8再进停止位那会产生9个数据位。我习惯从0计数到7完成8个bit后就切换。tx_busy的设计也是经验点。空闲状态下它保持低一旦tx_en有效立即拉高这样外部模块可以在发送过程中继续准备下一帧数据。但要注意tx_busy的拉高是寄存器输出有一个时钟周期的延迟外部逻辑不能把tx_en持续拉高否则IDLE状态一进入就会再次触发发送一帧会变成连续两帧。2.3 接收模块16倍采样与上升沿检测接收模块比发送模块复杂因为发送方什么时候发数据不由我们控制我们看到的RX线平时是高电平突然来一个下降沿表示起始位到了。所以接收模块的核心是“检测下降沿、计算采样点、在数据位中间采样”。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire rxd, output reg [7:0] rx_data, output reg rx_done ); localparam DIV_CNT CLK_FREQ / BAUD_RATE - 1; localparam HALF_BIT CLK_FREQ / BAUD_RATE / 2 - 1; localparam IDLE 2d0, START 2d1, DATA 2d2, STOP 2d3; reg [1:0] state; reg [2:0] bit_cnt; reg [15:0] cnt; reg [7:0] data_buf; reg rxd_d1, rxd_d2; wire rxd_negedge rxd_d2 !rxd_d1; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rxd_d1 1b1; rxd_d2 1b1; end else begin rxd_d1 rxd; rxd_d2 rxd_d1; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; cnt 16d0; bit_cnt 3d0; data_buf 8d0; rx_data 8d0; rx_done 1b0; end else begin rx_done 1b0; case (state) IDLE: begin cnt 16d0; bit_cnt 3d0; if (rxd_negedge) state START; end START: begin if (cnt HALF_BIT) begin cnt 16d0; state DATA; end else begin cnt cnt 16d1; end end DATA: begin if (cnt DIV_CNT) begin cnt 16d0; data_buf {rxd, data_buf[7:1]}; if (bit_cnt 3d7) begin bit_cnt 3d0; state STOP; end else begin bit_cnt bit_cnt 1b1; end end else begin cnt cnt 16d1; end end STOP: begin if (cnt HALF_BIT) begin rx_data data_buf; rx_done 1b1; state IDLE; end else begin cnt cnt 16d1; end end endcase end end endmodule接收端为什么要把rxd打两拍这是做单bit信号跨时钟域同步的标准做法防止亚稳态。rxd来自外部引脚和FPGA内部时钟完全无关直接采可能会采到电平跳变中的不稳定状态打两拍后能很大概率保证同步到稳定电平。rxn_edge写成了rxd_d2 !rxd_d1我用的是两拍之后的组合来判断下降沿检测范围是rxd_d1相对rxd_d2从1变0这组写法在实际仿真里效果很好。采样点位置也需要解释。起始位下降沿被检测到后START状态延时HALF_BIT也就是半个bit时间然后切到DATA状态。DATA状态下每隔完整bit周期采一次数这样保证采样点正好在每个数据位的中间位置。为什么不在bit开始时采样因为边沿附近信号可能还在抖动中间位置是最稳定的。这就是“中间采样”原则的落地方式。关于16倍过采样其实原理是一样的把每个bit分成16份用更高频率的采样时钟去采集电平然后做多数判决。我这里直接用分频计数确定采样点本质是1倍过采样配合中心采样足够应对实验室环境。如果要做工业级抗干扰设计再把RX滤波、16倍过采样、多数判决这些加进去。2.4 顶层连接与握手信号处理发送和接收模块写好后用顶层模块连接起来。最简单也最有用的验证方式是回环测试把接收到的数据直接送给发送端这样上位机发什么就回什么。module uart_loopback #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9_600 )( input wire clk, input wire rst_n, input wire rxd, output wire txd ); wire [7:0] rx_data; wire rx_done; wire tx_busy; uart_rx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_rx ( .clk (clk), .rst_n (rst_n), .rxd (rxd), .rx_data (rx_data), .rx_done (rx_done) ); uart_tx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_tx ( .clk (clk), .rst_n (rst_n), .tx_en (rx_done), .tx_data (rx_data), .txd (txd), .tx_busy (tx_busy) ); endmodule这个回环结构看起来简单但非常实用。上板调试时先把这种回环程序烧进去用串口助手发一个字符能收到相同字符说明物理链路、电平芯片、下载bit、时钟和复位都没问题收不到那问题就在前级链路上而不是在你自己写的收发逻辑里。这是我调试串口项目时第一个执行的验证步骤节省过很多排查时间。3. 仿真验证不接板子也能肉眼观察串口时序3.1 搭建最小testbench写仿真Testbench时我习惯用行为级模拟一个串口发送端把激励送给待测试的uart_rx。这样可以直接验证接收模块。反过来测试发送模块时用一个行为级串口接收模型去采集txd上的数据。timescale 1ns / 1ps module tb_uart_rx; reg clk 0; reg rst_n 0; reg rxd 1; wire [7:0] rx_data; wire rx_done; localparam CLK_PERIOD 20; localparam BIT_TIME 104167; uart_rx #(.CLK_FREQ(50_000_000), .BAUD_RATE(9_600)) dut ( .clk(clk), .rst_n(rst_n), .rxd(rxd), .rx_data(rx_data), .rx_done(rx_done) ); always #(CLK_PERIOD/2) clk ~clk; task send_byte(input [7:0] byte); integer i; begin rxd 1; #(BIT_TIME); rxd 0; #(BIT_TIME); for (i 0; i 8; i i 1) begin rxd byte[i]; #(BIT_TIME); end rxd 1; #(BIT_TIME); end endtask initial begin #100; rst_n 1; #100; send_byte(8h55); #(BIT_TIME*2); send_byte(8hA5); #(BIT_TIME*2); $stop; end endmodule以9600波特率为例BIT_TIME在50MHz仿真时钟下为104167ns对应104.2微秒。这个task模拟完整的一帧起始位拉低、8个数据位LSB-first、停止位拉高和真实串口行为一致。0x55和0xA5这两个测试数据是经典选择0x55的二进制是0101_0101发送时每一位都在跳变能有效检查收发模块边界处理是否正确0xA5是1010_0101带有连续的“1、1”可以验证连续高电平期间的采样是否正确。3.2 波形怎么看起始位、数据位、采样点逐一核对仿真结果出来后最先看的是rxd的下降沿是否成功触发状态迁移。在波形里rxd从高到低那一刻state应该从IDLE跳到START如果state没动检查rxd_negedge逻辑特别是两级同步器的时序。接着看每个数据位的采样点是否落在bit中心。这需要把rxd的波形数据和内部cnt值结合起来看。rxd拉低后经过HALF_BITcnt计数到HALF_BIT此时切到DATA再过DIV_CNT个时钟采第一个数据位。如果采样点偏前或偏后检查HALF_BIT和DIV_CNT这两个parameter是否计算正确经常见到有人把HALF_BIT写成DIV_CNT除以2或者把DIV_CNT算错一位。最后看rx_done脉冲。rx_done应该在停止位采样点之后拉高一个时钟周期。此时rx_data里应该已经有了完整字节。如果rx_data和发送端激励的数据不一致逐位比对rxd原始波形和data_buf移位过程确认是哪一位采样出错。我调试过的最多的问题是数据整体右移了一位原因就是DATA状态切换时bit_cnt的时序没有对齐。仿真通过后不要急着上板我建议再做一个回归测试连续发送多组不同的8位字节中间间隔一个完整帧时间确认没有帧粘连。帧粘连问题在RX侧主要表现为停止位还没采样完就触发了下一次下降沿检测原因是STOP状态的延时不够或者是IDLE状态下对rxd_negedge的检测过早。正常的单帧间隔应该至少是一个bit时间的空闲高电平。4. 上板调试真枪实弹连PC问题逐个击破4.1 连线与电平检查第一个教训就是交叉接仿真通过后终于到了上板环节。把综合后的bit文件烧进FPGA用USB转TTL串口线连接FPGA开发板的UART引脚和电脑。这里第一个容易踩的坑就是TUART的RX和TX要交叉连接FPGA开发板上的UART_TX应该接USB转串口模块的RXDFPGA开发板上的UART_RX应该接USB转串口模块的TXD。我见过不少次因为两边都按“TX接TX、RX接RX”直连结果发出去的数据全进了自己怀里自然收不到任何回显。电平匹配也需要看一眼。老式开发板的FPGA引脚电压可能是3.3VUSB转串口芯片输出一般也是3.3V可以不转换直接连。但如果你的板子用的是5V供电电平或者串口模块输出5V就得加电平转换芯片或者确认FPGA引脚支持5V容忍。硬接上去虽然很多情况下能工作但不稳定我吃过亏还是老老实实看原理图。上电后打开串口调试助手波特率设置成9600数据位8、停止位1、无校验、无流控。这个配置和UART模块参数必须一致有一项不对都会乱码。发送0x55正常情况下回显0x55。如果收到的是0xAA那是数据位高低顺序搞反了如果收到乱码先检查波特率误差和采样点。4.2 常见问题速查表与定位思路把我在调试UART过程中遇到过的常见问题整理成一张表按照从物理层到协议层的顺序排查能省很多时间。现象可能原因排查方法完全无回显RX/TX没有交叉连接检查连线USB转串口模块的TXD接FPGA的RX完全无回显FPGA没复位rst_n一直为低检查复位按键和拨码开关完全无回显串口助手流控被打开关闭DTR/DSR/RTS/CTS流控乱码波特率不匹配确认上位机波特率和模块parameter一致乱码分频系数算错用示波器量txd空闲高电平和数据帧时长回显数据每位取反数据极性错误检查发送端txd初始电平空闲时必须是1回显数据顺序颠倒数据位顺序错误确认发送端先发最低位偶尔多发或丢字节tx_en握手问题检查rx_done到tx_en的时序是否在busy时又一次发送乱码问题如果只在长时间大流量传输时出现大概率是波特率累计误差造成的。我之前用某个开发板的12MHz时钟跑115200分频系数104.17取整后每一bit误差约0.16%单个字节误差累积到停止位已经有接近1.5%的偏差偶尔还能扛住但连续传几千字节后肯定会错。后来改成50MHz时钟就再没出现过。所以选时钟和波特率组合时最好先用计算器把误差算一下。串口助手里还有个容易忽略的坑很多上位机软件默认会控制DTR/RTS有些USB转串口芯片会把这些信号接到复位电路上一打开串口就触发FPGA复位表现为数据发出去后模块被复位回显全无。遇到这种问题在串口助手里取消勾选“打开串口时发送DTR/RTS脉冲”之类的选项或者干脆关掉流控。5. 从UART出发RS485、Modbus和更多扩展5.1 把单端信号改成差分RS485与UART的关系UART本身只是电平协议物理层用什么电平可以由外部收发器决定。最常见的三种是TTL电平、RS232电平和RS485电平。FPGA开发板上直接引出的UART引脚通常是TTL电平的如果接的是DB9串口线需要MAX232之类的芯片把TTL转成RS232电平。RS485则用MAX485、SP3485这类收发芯片把UART的单端信号转成A/B两线的差分信号好处是抗干扰强、传输距离可以做到上百米。在FPGA里实现RS485并不需要改UART核心逻辑只多一个方向控制信号。RS485是半双工的发送时要把收发器切到发送模式发送完成后切回接收模式。方向切换的时机是整个RS485联调里最微妙的地方发送完最后一个停止位后如果立刻切回接收模式可能最后一位还在线上如果切换得太晚又会错过对方的第一帧响应。工程上常用的做法是发送完停止位后延时至少额外一个bit周期再拉低收发器的DE/RE引脚。这个延时可以通过状态机再加一级等待状态实现。Modbus RTU是基于串口的应用层协议帧格式是地址加功能码加数据加CRC校验。UART层只负责把字节正确传过去Modbus的帧管理和CRC计算要在更高层逻辑里做。如果你用FPGA做Modbus从站通常会先做好UART收发模块然后在其上实现状态机解析报文帧、计算CRC16校验再把请求映射到具体寄存器操作。这个层次关系理清楚后写Modbus协议栈才不会把页面搅成一锅粥。5.2 学习建议怎么把这个项目变成简历亮点UART串口通信FPGA实现作为入门项目非常经典但如果你做完一个回环测试就结束那面试时很难说出什么亮点。我建议在基础功能上做三个方向的扩展第一个方向是把回环改成真正的数据通路比如用状态机构造一条自定义命令协议通过串口控制LED的开关、读取按键状态、读取温度传感器数据。这样面试时能讲清楚“串口收上来的数据是如何解包、如何映射到外设控制”的这比单纯回环有价值得多。第二个方向是把发送和接收模块加FIFO缓冲。当上位机连续高速下发大量数据时CPU侧逻辑来不及处理FIFO就能缓存数据解决背压问题。这一步能引出跨时钟域、异步FIFO、读写指针、almost_full信号等一堆面试高频考点。第三个方向是给接收模块增加抗干扰能力比如实现16倍过采样、连续滤波、多次判决。这种设计在工业现场环境下区分噪声和数据非常关键也能展示你不仅会写功能代码还考虑过信号完整性和可靠性设计。我个人的体会是UART这个项目的天花板其实很高。协议本身很古老但实现方式从51单片机的寄存器操作到STM32的HAL库再到FPGA的纯逻辑实现每一层都有完全不同的思考和陷阱。用FPGA写一遍UART等于是把通信时序、波特率计算、同步器设计、状态机编码这些硬件基本功全部练了一遍。后面再去看IIC、SPI这些接口会发现很多思路是相通的。最后再分享一个追时序的小习惯遇到串口数据错乱时先不要急着改代码把波形窗口拉开从空闲电平开始一个bit一个bit地数看看起始位、数据位、停止位是不是严格按照10个bit时间排列的。绝大多数UART问题靠这招都能定位到具体是哪一位出了偏差比你反复改参数编译下载高效得多。