FPGA实战:手写UART串口通信模块的完整设计思路

📅 发布时间:2026/9/7 1:14:57
FPGA实战:手写UART串口通信模块的完整设计思路 做了这么多年FPGA开发我越来越觉得UART串口通信是入门学习里最值得手写一遍的模块。它不像SPI、I2C那样带时钟线时序上更考验对异步信号的判断也不像PCIe、DDR那样复杂到必须依赖IP核自己用Verilog写一个稳定可用的UART收发器完全可行。很多朋友问我FPGA该怎么入门我的回答基本都是先别碰那些花哨的图像处理和高速接口先把UART调通串口助手能稳定收到数据你就算正式踏入数字逻辑设计的大门了。这篇博文我就根据自己的实际工程经验把UART在FPGA上的完整实现思路拆开揉碎讲清楚从协议本质、RTL代码设计到仿真上板验证再到我踩过的那些坑全部整理出来。内容面向的是已经掌握基本Verilog语法、想动手做第一个通信项目的同学也适合工作中需要自己写串口模块的工程师做参考。我会尽量讲得直白把“为什么这么设计”讲透而不是简单丢一份代码让你抄。1. 先想明白UART到底在传什么东西1.1 一次串口传输其实就是一串带“起止标记”的脉冲UART全称是Universal Asynchronous Receiver/Transmitter通用异步收发器。很多人一上来就背协议什么起始位1位、数据位8位、停止位1位背完还是写不出代码。我建议换一个视角你拿着一台示波器去看TX引脚看到的是一串随时间变化的高电平和低电平UART协议就是规定这串电平怎么切分、怎么解释。线路空闲时保持高电平这是RS232时代就定下的规矩TTL电平的UART也沿用了这个习惯。发送方要发数据时先拉低电平一个位时间这叫起始位硬件接收方看到这个从高到低的跳变就知道“对方要开口说话了”。接着按约定好的波特率一位一位输出数据低位在前然后是校验位可省略最后拉高一个位时间甚至更长这叫停止位。数据位、停止位这些名词不用死记你把它想成快递包裹起始位是面单上的“收件人电话”停止位是“签收确认”数据位才是包裹里的实际货物。这里有个关键点UART传输的是字节的低位在前。比如你要发送0x53二进制是0101 0011那么线上先出现的不是最高位而是最低位1再是1、0、0、1、0、1、0。我早期调试时经常在逻辑分析仪里看到数据对不上后来才反应过来是自己把位序搞反了。1.2 波特率决定了每个位在线上“占多宽”波特率是UART最核心的参数它表示每秒传输多少个码元。对UART来说一个码元就是一个bit所以9600波特率就是每秒传9600个bit。一个bit在线上停留的时间就是波特率的倒数约104.17微秒。这个时间宽度就是整个设计的“心跳”。FPGA内部用的是MHz级别的系统时钟常见的有50MHz、100MHz一个时钟周期只有20纳秒或10纳秒比bit时间短得多。因此我们必须对系统时钟计数分频产生一个周期近似等于bit时间的节拍信号这个节拍就是发送和接收的基准。例如系统时钟50MHz波特率9600主频50_000_000除以9_600等于5208.33所以每计数5208个系统时钟就翻转一次采样节拍这样生成的节拍跟理想波特率只差3235ppm即0.32%。这个误差远小于UART协议容忍的2%到3%完全够用。1.3 电平标准要分清TTL还是RS232FPGA开发板上的UART多半是TTL电平高电平3.3V表示1低电平0V表示0。但如果要跟电脑的串口调试助手通信通常需要经过USB转串口芯片比如CP2102、CH340、FT232这些。有些板子直接集成了USB转串口电路插上USB线就能在电脑上看到COM口这种情况板子上走的还是TTL信号只是USB芯片帮你完成了TTL和USB之间的转换。如果是跟老式设备通信可能需要RS232电平它的逻辑1是-3V到-15V逻辑0是3V到15V属于负逻辑。这种情况下需要加MAX3232之类的电平转换芯片。我的建议是不管做板子还是调试先确认对方接口的电平标准TTL直接接TTLRS232必须先转换否则轻则收不到数据重则烧引脚。我见过不止一位朋友把5V的RS232信号直接接到3.3V的FPGA引脚上运气好的没烧运气差的那一路引脚直接报废。2. 设计前的思维转换为什么接收比发送难2.1 发送器像广播接收器像在嘈杂现场听人说话发送数据其实很“傻瓜”只要按固定节奏把每一位放到TX引脚上就行。因为节奏是你自己控制的时钟分频倍率一致数据就一定能按预期发出去这本质上是一个计数器加移位寄存器的体力活。接收就麻烦多了。接收端看到的是一个没有时钟线伴随的异步信号你只知道波特率但不知道对方什么时候开始说话也不知道当前线上这个电平到底是数据里的第几位。想要正确解码需要自己从信号变化中“猜”出节奏还要在噪声和误差中做出判断。这就是为什么很多教程里发送模块很简单接收模块却要花大篇幅去讲采样窗口、起始位确认。2.2 用“中点采样”对抗边沿误差如果发送方和接收方的时钟频率存在误差或者信号在传输中有抖动bit边沿不会精确落在理想位置。如果我们在bit刚开始的地方采样很容易采到前一个bit的尾巴或者边沿的毛刺。正确的思路是在已知的bit中心附近采样给边沿误差留出容错空间。具体实现方法是检测到起始位的下降沿之后不从下降沿立刻采样而是等一个半bit时间。比如系统时钟50MHz、波特率9600一个bit是5208个时钟周期那么先等5208个周期确认起始位确实是低电平防止毛刺误触发再等2604个周期到达第一个数据位的中心点从那里开始每5208个周期采样一次正好采在每一位的中间位置。这个“中点采样”原则是整个UART接收设计里最值得记住的一句话。2.3 过采样是最简单的滤波手段有些工程为了提高可靠性会采用16倍过采样也就是每个bit时间采样16次然后取中间几位的多数判决结果。这在FPGA里并不复杂只要把波特率分频器改成产生更高速率的采样时钟就行。但我个人建议入门阶段先别上过采样用单点中点采样加简单的边沿消抖就足够了。等把基础逻辑调通再回头加过采样和FIFO那时你会更清楚每一步在解决什么问题。3. RTL代码实现发送器和接收器的逐段拆解3.1 发送器分频计数加上状态机就够用发送模块我习惯写成三段式状态机空闲态IDLE、发送态SEND和停止态STOP三个状态。空闲时TX线保持高电平检测到发送使能信号后进入发送态先输出起始位低电平然后移位寄存器按位输出数据数据位发完进入停止态拉高电平一个bit时间最后回到空闲态。这里有三个细节值得注意。第一计数器的参考点是bit时间不是系统时钟本身。我一般用一个baud_cnt计数器计数到预设的分频值就归零并产生一个baud_pulse单周期脉冲所有位的输出动作都对齐到这个脉冲上。第二数据位个数要是可配置的用参数DATA_WIDTH控制而不是写死在8位。第三发送完一个字节后不要立刻允许发送下一个最好等停止位结束再拉高busy信号外部逻辑看到busy拉低才算发送完成。以下是发送器的完整代码示例这个版本我实际在板上跑过直接可综合module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9600, parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire tx_start, input wire [DATA_WIDTH-1:0] tx_data, output reg tx, output reg tx_busy ); localparam BAUD_CNT_MAX CLK_FREQ / BAUD_RATE - 1; localparam HALF_CNT_MAX CLK_FREQ / BAUD_RATE / 2 - 1; reg [15:0] baud_cnt; reg baud_pulse; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] tx_shift; reg [1:0] state; localparam S_IDLE 2d0; localparam S_START 2d1; localparam S_DATA 2d2; localparam S_STOP 2d3; always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 16d0; baud_pulse 1b0; end else begin if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; baud_pulse 1b1; end else begin baud_cnt baud_cnt 1b1; baud_pulse 1b0; end end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; bit_cnt 4d0; tx_shift {DATA_WIDTH{1b0}}; state S_IDLE; end else begin case (state) S_IDLE: begin tx 1b1; tx_busy 1b0; if (tx_start) begin state S_START; tx_shift tx_data; tx_busy 1b1; bit_cnt 4d0; end end S_START: begin tx 1b0; if (baud_pulse) state S_DATA; end S_DATA: begin if (baud_pulse) begin tx tx_shift[0]; tx_shift tx_shift 1; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 4d0; state S_STOP; end else begin bit_cnt bit_cnt 1b1; end end end S_STOP: begin tx 1b1; if (baud_pulse) state S_IDLE; end default: state S_IDLE; endcase end end endmodule这段代码的核心逻辑就一句话空闲高电平起始拉低数据逐位移出停止拉高。baud_cnt计数器全局只有一个所有状态的切换都靠baud_pulse对齐这样能保证每个bit持续的时间完全一致不会出现累积误差。3.2 接收器起始位确认和位中心采样是关键接收模块的逻辑复杂度明显更高。我的习惯是先把接收数据线rx打两拍第一拍同步到系统时钟域第二拍做边沿检测。这样做的好处是可以消除一部分亚稳态同时在起始位检测时不容易被毛刺干扰。起始位检测的逻辑是采样到rx从高变低的跳变后不立即判定为起始位而是等一个半bit时间也就是HALF_BAUD_CNT再去采一次rx如果这时候还是低电平才确认是真正的起始位。如果在半个bit时间后采到的是高电平那就说明刚才的低脉冲只是干扰状态机回到空闲继续等待。这个机制非常重要没有它接收机很容易被一两个时钟周期的毛刺触发然后解码出一堆乱码。确认起始位之后每过一个完整bit时间采样一次rx采到的电平就是当前数据位的值。用移位寄存器把采样结果串起来等采满DATA_WIDTH位就拼成一个完整的字节。最终在停止位阶段输出接收完成脉冲和并行数据。以下是接收器代码的核心部分module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 9600, parameter DATA_WIDTH 8 )( input wire clk, input wire rst_n, input wire rx, output reg [DATA_WIDTH-1:0] rx_data, output reg rx_done ); localparam BAUD_CNT_MAX CLK_FREQ / BAUD_RATE - 1; localparam HALF_CNT_MAX CLK_FREQ / BAUD_RATE / 2 - 1; reg rx_r0, rx_r1; wire rx_negedge; reg baud_cnt_en; reg [15:0] baud_cnt; reg baud_mid_pulse; reg baud_sample_pulse; reg [3:0] bit_cnt; reg [DATA_WIDTH-1:0] rx_shift; reg [1:0] state; localparam S_IDLE 2d0; localparam S_START 2d1; localparam S_DATA 2d2; localparam S_STOP 2d3; // 打拍同步和下降沿检测 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_r0 1b1; rx_r1 1b1; end else begin rx_r0 rx; rx_r1 rx_r0; end end assign rx_negedge rx_r1 (~rx_r0); // 可控制的波特率计数 always (posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt 16d0; end else if (!baud_cnt_en) begin baud_cnt 16d0; end else if (baud_cnt BAUD_CNT_MAX) begin baud_cnt 16d0; end else begin baud_cnt baud_cnt 1b1; end end // 中点采样脉冲和常规采样脉冲 always (*) begin baud_mid_pulse 1b0; baud_sample_pulse 1b0; if (baud_cnt_en) begin if (baud_cnt HALF_CNT_MAX) baud_mid_pulse 1b1; if (baud_cnt BAUD_CNT_MAX) baud_sample_pulse 1b1; end end // 状态机 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; bit_cnt 4d0; rx_shift {DATA_WIDTH{1b0}}; rx_data {DATA_WIDTH{1b0}}; rx_done 1b0; baud_cnt_en 1b0; end else begin case (state) S_IDLE: begin rx_done 1b0; if (rx_negedge) begin state S_START; baud_cnt_en 1b1; baud_cnt 16d0; end end S_START: begin if (baud_mid_pulse) begin if (!rx_r0) begin state S_DATA; bit_cnt 4d0; end else begin state S_IDLE; baud_cnt_en 1b0; end end end S_DATA: begin if (baud_sample_pulse) begin rx_shift {rx_r0, rx_shift[DATA_WIDTH-1:1]}; if (bit_cnt DATA_WIDTH - 1) begin bit_cnt 4d0; state S_STOP; end else begin bit_cnt bit_cnt 1b1; end end end S_STOP: begin if (baud_sample_pulse) begin state S_IDLE; baud_cnt_en 1b0; rx_data rx_shift; rx_done 1b1; end end default: state S_IDLE; endcase end end endmodule有两点要提醒。第一接收器的移位方向跟发送器相反发送器是右移把低位先发出去接收器则要把后采到的高位往高位移比如用{rx_r0, rx_shift[DATA_WIDTH-1:1]}把新采到的位放到最高位。第二rx_done信号只拉高一个周期外部模块要用它作为读使能把rx_data取走。如果接收端接的是FIFO就把{ rx_done }当成写使能rx_data当成写入数据。3.3 顶层模块和跨时钟域的思考正常情况下发送端由内部逻辑产生发送数据接收端要把收到的数据交给其他模块处理。我的做法是例化两个独立模块tx和rx之间没有直接联系只是共用同一个系统时钟和复位。如果系统时钟不是50MHz记得重新计算分频参数。如果时钟频率过高比如200MHz16位的baud_cnt可能不够用需要扩位计算方式保持不变。还有个容易被忽略的问题如果TX和RX模块要接到一个AXI总线或自定义总线上接收完成信号rx_done可能需要打一拍转换成电平使能或者再加一个简单握手。我的建议是第一版先不加FIFO直接在顶层模块里把rx_done连到一个LED或者把rx_data连到数码管上先证明数据能正确接收再考虑加FIFO缓冲。4. 从仿真到上板调通一个完整回环4.1 仿真Testbench的编写思路写testbench的时候我的习惯是把两个被测模块的TX和RX交叉连接形成自发自收的回环这样不需要外部设备也能验证逻辑正确性。比如顶层例化uart_tx和uart_rx把tx模块的tx引脚直接连到rx模块的rx引脚用testbench给tx模块发送一个字节0xA5二进制1010 0101因为高低电平交替明显容易在波形里观察然后检查rx模块输出的rx_data是不是0xA5。仿真时要把baud_rate参数设得小一点比如50MHz时钟配9600波特率仿真时间会比较长一个字节要大约11个bit时间接近10毫秒肉眼等得着急。可以把参数改成仿真专用参数比如把CLK_FREQ设成1000BAUD_RATE设成100这样10个时钟周期就是一个bit仿真速度极快。等仿真通了再改回真实参数上板这个技巧能节省大量仿真时间。以下是testbench的关键部分module tb_uart_loopback(); parameter CLK_FREQ 1_000_000; parameter BAUD_RATE 100; reg clk 0; reg rst_n 0; wire tx_line; wire [7:0] rx_data; wire rx_done; // 例化发送和接收模块 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(tx_line), .tx_busy(tx_busy) ); uart_rx #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_rx ( .clk(clk), .rst_n(rst_n), .rx(tx_line), .rx_data(rx_data), .rx_done(rx_done) ); always #5 clk ~clk; integer i; initial begin rst_n 0; tx_start 0; tx_data 8h00; #100; rst_n 1; // 发送两个字节 0xA5 和 0x5A for (i 0; i 2; i i 1) begin (posedge clk); tx_data (i 0) ? 8hA5 : 8h5A; tx_start 1; (posedge clk); tx_start 0; wait (tx_busy 0); end #1000; $finish; end endmodule仿真波形里重点检查三处起始位出现后rx的中位采样是否落在低电平上数据位采样得到的电平是否与tx输出的电平一致停止位结束后rx_done是否出现单周期脉冲rx_data是否等于发送值。只要这三处都对逻辑基本就稳了。4.2 上板验证串口助手常见的三个坑仿真通过只是第一步上板才会遇到真正的“现实问题”。我的上板调试流程是这样的用开发板上的USB转串口芯片连接电脑把FPGA的TX接到USB芯片的RXFPGA的RX接到USB芯片的TX然后就出现了三个典型的坑。第一个坑是TX和RX交叉连接问题。很多人只接一根线或者接成直连结果电脑发出去的数据FPGA收不到FPGA发出去的数据电脑也收不到。记住串口交叉连接一端的TX一定要接另一端的RX。第二个坑是乱码。乱码的原因一般是波特率不匹配或者分频计算有误。常见的错误是忘减一比如50MHz除9600得5208但计数器的比较值应该是5207否则实际波特率偏低。还有一种是系统时钟频率没看准有的开发板默认用12MHz而不是50MHz导致分频数完全不对。出现乱码时先用逻辑分析仪抓波形或者把收到的字节用十六进制显示看是不是固定偏移比如收到的全是0xFF或0x00那大概率是时钟或分频问题。第三个坑是USB转串口芯片的驱动和映射问题。Windows上插上USB线设备管理器里应该多出一个COM口如果驱动没装好串口助手就找不到设备。把设备管理器截图发给我之前先自己确认COM口号以及串口助手的波特率、数据位、停止位、校验位是否和FPGA配置一致。这类问题里80%都是参数设置不一致造成的。4.3 加一个发送使能的交互方式回环测试验证了收发链路但如果想体验“按一下按键发送一个字节”可以加一个按键消抖模块按键按下时产生一个单周期脉冲送给tx_start。按键消抖模块的写法跟UART起始位检测有点像也是等一段时间再采样确认建议自己写一遍。我自己的调试流程是先用回环确认链路然后用按键主动发送固定字节用LED显示接收到的字节的低四位。这样每按一次按键LED就会变一次状态直观又方便。5. 常见问题速查与调试心得5.1 问题速查表现象可能原因排查方法发送端仿真正常上板后电脑收不到数据TX/RX接反、USB芯片供电异常、引脚约束错误用示波器或逻辑分析仪抓TX引脚看是否有波形翻转电脑发数据FPGA收不到RX引脚约束错误、接收状态机卡死先用回环把FPGA的TX短接到RX排除接收逻辑问题收到乱码但波形看起来正常波特率分频参数错误、收发双方波特率不一致核对分频参数计算公式再用串口助手切换波特率测试能收到首字节后续全错接收状态机没有正确回到IDLE、rx_done占用时间太长检查停止位后的状态切换确认rx_done只拉高一个周期偶尔收到错误字节时钟抖动、信号质量差、中位采样不够稳考虑加两级同步器、过采样或把信号线缩短5.2 用一个范围决定“多少波特率误差可以接受”不少同学会纠结波特率误差。UART接收的容错能力取决于采样窗口理论上一个bit的采样误差不超过半个bit就不会出错。考虑到起始位需要精确检测实际工程建议波特率误差控制在2%以内。举例来说50MHz时钟配9600波特率实际波特率误差是0.32%完全安全但如果系统时钟是33MHz除9600得到3437.5取整后误差约0.44%也能用如果系统时钟是8MHz除9600得到833.33取整后误差约0.64%依然能工作但误差已经明显偏大发送长数据帧时要留意。最稳妥的方法还是选择能够整除的时钟频率或者用小数分频器NCO方式来产生精确的波特率。5.3 从UART到更复杂的通信外设思路是通用的写完UART之后你会发现SPI、I2C这些带时钟线的同步协议其实更简单因为总线自带时钟不需要自己去猜采样点。而RS485只是在UART的电平基础上增加了差分驱动和方向控制数据链路层跟UART一模一样。我的建议是先把UART吃透然后趁热打铁去写SPI Slave再挑战I2C最后回头看UART你会对整个串行通信有更立体的理解。根据我个人的实际调试经验每调一个新板子我都会先用回环测试验证硬件链路再用固定数据跑一遍最后再上完整逻辑。这样一步步排查能省下大量抓狂的时间。学习写代码时多一份耐心把每个模块的时序画清楚比多啃十篇教程更有用。的希望这篇内容能帮你把UART这个老朋友彻底“焊”在脑子里后续再做FPGA项目时调试串口这个基本功会一直为你兜底。