做了这么多年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还是RS232
FPGA开发板上的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直接接TTL,RS232必须先转换,否则轻则收不到数据,重则烧引脚。我见过不止一位朋友把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 = 2'd0; localparam S_START = 2'd1; localparam S_DATA = 2'd2; localparam S_STOP = 2'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin baud_cnt <= 16'd0; baud_pulse <= 1'b0; end else begin if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 16'd0; baud_pulse <= 1'b1; end else begin baud_cnt <= baud_cnt + 1'b1; baud_pulse <= 1'b0; end end end always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx <= 1'b1; tx_busy <= 1'b0; bit_cnt <= 4'd0; tx_shift <= {DATA_WIDTH{1'b0}}; state <= S_IDLE; end else begin case (state) S_IDLE: begin tx <= 1'b1; tx_busy <= 1'b0; if (tx_start) begin state <= S_START; tx_shift <= tx_data; tx_busy <= 1'b1; bit_cnt <= 4'd0; end end S_START: begin tx <= 1'b0; 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 <= 4'd0; state <= S_STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end end S_STOP: begin tx <= 1'b1; 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 = 2'd0; localparam S_START = 2'd1; localparam S_DATA = 2'd2; localparam S_STOP = 2'd3; // 打拍同步和下降沿检测 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_r0 <= 1'b1; rx_r1 <= 1'b1; 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 <= 16'd0; end else if (!baud_cnt_en) begin baud_cnt <= 16'd0; end else if (baud_cnt == BAUD_CNT_MAX) begin baud_cnt <= 16'd0; end else begin baud_cnt <= baud_cnt + 1'b1; end end // 中点采样脉冲和常规采样脉冲 always @(*) begin baud_mid_pulse = 1'b0; baud_sample_pulse = 1'b0; if (baud_cnt_en) begin if (baud_cnt == HALF_CNT_MAX) baud_mid_pulse = 1'b1; if (baud_cnt == BAUD_CNT_MAX) baud_sample_pulse = 1'b1; end end // 状态机 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= S_IDLE; bit_cnt <= 4'd0; rx_shift <= {DATA_WIDTH{1'b0}}; rx_data <= {DATA_WIDTH{1'b0}}; rx_done <= 1'b0; baud_cnt_en <= 1'b0; end else begin case (state) S_IDLE: begin rx_done <= 1'b0; if (rx_negedge) begin state <= S_START; baud_cnt_en <= 1'b1; baud_cnt <= 16'd0; end end S_START: begin if (baud_mid_pulse) begin if (!rx_r0) begin state <= S_DATA; bit_cnt <= 4'd0; end else begin state <= S_IDLE; baud_cnt_en <= 1'b0; 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 <= 4'd0; state <= S_STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end end S_STOP: begin if (baud_sample_pulse) begin state <= S_IDLE; baud_cnt_en <= 1'b0; rx_data <= rx_shift; rx_done <= 1'b1; 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,记得重新计算分频参数。如果时钟频率过高,比如200MHz,16位的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设成1000,BAUD_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 = 8'h00; #100; rst_n = 1; // 发送两个字节 0xA5 和 0x5A for (i = 0; i < 2; i = i + 1) begin @(posedge clk); tx_data = (i == 0) ? 8'hA5 : 8'h5A; 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芯片的RX,FPGA的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项目时,调试串口这个基本功会一直为你兜底。