FPGA实现UART:从硬件时序视角解构异步串行通信
2026/6/7 16:27:58 网站建设 项目流程

1. 从“会用”到“造轮子”:FPGA视角下的UART深度解构

以前用51、STM32调串口,感觉就是配个寄存器、算个波特率、然后往缓存区读写数据就完事了。直到自己用Verilog在FPGA上从头实现了一遍UART,才真正看懂了这看似简单的“起止式异步串行通信”背后,时钟、状态、边沿这些硬件信号是如何精密咬合的。这种感觉,就像你一直开自动挡的车,突然有一天让你拆开发动机,自己组装一套变速箱和传动轴,你对“驾驶”的理解会完全不同。这次“造轮子”的经历,不仅让我彻底搞懂了UART,更重要的是,它提供了一种用硬件描述语言(HDL)去解构任何通信协议的通用思维框架。无论是I2C、SPI,还是更复杂的自定义协议,其核心无非是状态控制、时钟生成与同步、数据流采样这三板斧。今天,我就把自己从“软件配置思维”切换到“硬件时序思维”的过程,以及实现中的那些关键细节和踩过的坑,掰开揉碎了分享给你。

2. UART硬件实现的顶层设计与核心思路

2.1 异步串行的本质:没有公共时钟的握手

UART(Universal Asynchronous Receiver/Transmitter)之所以叫“异步”,核心在于通信双方没有共享的时钟线。这就像两个人约好,每隔1秒说一个字,但各自用自己的手表。只要双方手表(波特率)足够准,并且约定好一句话从什么时候开始(起始位),就能完成通信。这个“约定”就是协议的核心。

在FPGA中实现,我们的目标就是用同步逻辑(统一的系统时钟clk)去模拟和对接这个异步的世界。整个设计的顶层模块通常会包含以下几个核心部分:

  1. 波特率发生器(Baud Rate Generator):将高频的系统时钟clk分频,产生与目标波特率同步的时钟使能信号baud_tick。注意,这里通常不直接产生一个新时钟,而是产生一个脉冲信号,用于驱动状态机前进和数据采样。
  2. 接收器(UART Receiver):持续监测RXD线,检测起始位下降沿,启动波特率发生器,并在精确的时刻对数据位进行采样,完成串并转换。
  3. 发送器(UART Transmitter):在收到发送请求后,启动波特率发生器,按照起始位、数据位、停止位的顺序,将并行数据转换为串行比特流从TXD输出。
  4. 控制与状态逻辑:协调收发过程,产生中断或状态标志位(如rx_done,tx_busy),并提供数据缓冲接口。

2.2 核心挑战:如何精准地在“对的时间”做“对的事”

异步通信最大的挑战是同步问题。对于接收方,它不知道发送方何时开始发送,数据位会在何时稳定。我们的解决方案是:

  • 起始位检测:作为整个接收过程的“发令枪”。一旦检测到RXD从空闲高电平变为低电平,就认为数据传输开始,并立即启动本地波特率发生器,试图与发送方节奏对齐。
  • 中点采样:这是保证数据稳定可靠的关键策略。我们不会在数据位周期的一开始或快结束时采样,因为信号可能因传输延迟、抖动而尚未稳定或已开始变化。通常,我们在每个数据位周期的中间点(例如,对于8N1格式,在起始位后的第16个波特率时钟采样第1个数据位,第32个时钟采样第2个,以此类推)进行采样,此时信号最稳定。
  • 状态机(FSM)控制:无论是接收还是发送,都是一个按部就班的过程:等待起始→采样/发送数据位→处理停止位。用有限状态机来清晰地描述这个过程是最自然、最可靠的方式。状态机的跳转,就由波特率时钟baud_tick来驱动。

3. 核心模块的Verilog实现与细节剖析

3.1 波特率发生器的精确实现

波特率发生器的任务不是产生一个新时钟域,而是产生一个周期与波特率位时间相同、脉宽为一个系统时钟周期的使能脉冲。这样做的好处是避免了跨时钟域问题,所有逻辑仍在主时钟clk下同步。

假设系统时钟频率CLK_FREQ = 50_000_000 Hz,目标波特率BAUD_RATE = 115200 bps。 分频系数BAUD_DIV = CLK_FREQ / BAUD_RATE = 50_000_000 / 115200 ≈ 434。 这意味着,每434个系统时钟周期,对应一个波特率位时间。

module baud_gen ( input wire clk, input wire rst_n, input wire baud_en, // 波特率使能信号,高电平有效 output reg baud_tick // 波特率时钟脉冲,在分频点产生一个clk宽的高脉冲 ); parameter CLK_FREQ = 50_000_000; parameter BAUD_RATE = 115200; localparam BAUD_DIV = CLK_FREQ / BAUD_RATE; reg [15:0] counter; // 计数器宽度需能容纳BAUD_DIV always @(posedge clk or negedge rst_n) begin if (!rst_n) begin counter <= 0; baud_tick <= 1'b0; end else begin baud_tick <= 1'b0; // 默认拉低 if (baud_en) begin if (counter == BAUD_DIV - 1) begin counter <= 0; baud_tick <= 1'b1; // 计数满,产生一个脉冲 end else begin counter <= counter + 1; end end else begin counter <= 0; // 使能无效时,计数器清零 end end end endmodule

注意:这里的BAUD_DIV计算是理想值。实际上,434不是整数,这会导致波特率有微小误差(误差率约0.16%,在可接受范围内)。对于精度要求极高的场合,可以考虑使用分数分频或DDS(直接数字频率合成)技术来生成更精确的波特率时钟。

3.2 起始位检测:边沿检测电路的妙用

起始位是一个从高到低的下降沿。在异步电路中,我们需要一个同步器来将异步的RXD信号同步到clk时钟域,并检测其边沿。经典的“打两拍”同步器加边沿检测电路是标准做法。

module edge_detector ( input wire clk, input wire rst_n, input wire async_sig, // 来自外部的异步信号,如RXD output wire pos_edge, // 上升沿脉冲 output wire neg_edge // 下降沿脉冲 ); reg sync_reg0, sync_reg1, sync_reg2; // 三级寄存器同步,减少亚稳态传播风险 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin {sync_reg2, sync_reg1, sync_reg0} <= 3'b111; // 假设空闲为高 end else begin sync_reg0 <= async_sig; sync_reg1 <= sync_reg0; sync_reg2 <= sync_reg1; end end // 边沿检测:比较最后两级同步寄存器的值 assign pos_edge = (~sync_reg2) & sync_reg1; // sync_reg1上升为1, sync_reg2为0 assign neg_edge = sync_reg2 & (~sync_reg1); // sync_reg1下降为0, sync_reg2为1 endmodule

实操心得:为什么用三级寄存器?第一级(sync_reg0)同步,其输出可能处于亚稳态。第二级(sync_reg1)极大地降低了亚稳态继续传播的概率,其输出可视为已稳定的同步信号。第三级(sync_reg2)用于边沿检测的参考。对于UART起始位检测,我们只关心下降沿(neg_edge)。将这个下降沿脉冲作为接收状态机启动的信号,非常干净可靠。

3.3 接收器(RX)状态机设计与中点采样

接收器是UART实现中最精细的部分。它必须足够“耐心”和“精准”。

module uart_rx ( input wire clk, input wire rst_n, input wire rxd, // 串行输入 output reg [7:0] rx_data, // 接收到的并行数据 output reg rx_done // 接收完成标志,高电平一个时钟周期 ); // 状态定义 localparam IDLE = 2'b00; // 空闲,等待起始位 localparam START_BIT = 2'b01; // 确认起始位 localparam DATA_BITS = 2'b10; // 接收数据位 localparam STOP_BIT = 2'b11; // 接收停止位 reg [1:0] state, next_state; reg [3:0] bit_index; // 数据位索引 (0 to 7) reg [15:0] baud_counter; // 波特率周期计数器 reg baud_en_rx; // 接收波特率使能 wire baud_tick; // 来自波特率发生器的脉冲 wire neg_edge_rxd; // RXD下降沿 // 实例化边沿检测和波特率发生器 edge_detector edge_det_inst(.clk(clk), .rst_n(rst_n), .async_sig(rxd), .neg_edge(neg_edge_rxd)); baud_gen baud_gen_inst(.clk(clk), .rst_n(rst_n), .baud_en(baud_en_rx), .baud_tick(baud_tick)); // 状态机主进程 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; bit_index <= 0; baud_counter <= 0; rx_data <= 8'h00; rx_done <= 1'b0; baud_en_rx <= 1'b0; end else begin rx_done <= 1'b0; // 默认清零完成标志 case (state) IDLE: begin baud_en_rx <= 1'b0; if (neg_edge_rxd) begin // 检测到起始位下降沿 state <= START_BIT; baud_en_rx <= 1'b1; // 启动波特率时钟 baud_counter <= 0; end end START_BIT: begin if (baud_tick) begin // 在起始位周期中点(约第8个波特率计数)采样,确认是起始位而非毛刺 if (baud_counter == 8) begin if (!rxd) begin // 确认仍是低电平 state <= DATA_BITS; baud_counter <= 0; bit_index <= 0; end else begin // 是毛刺,回到空闲 state <= IDLE; end end else begin baud_counter <= baud_counter + 1; end end end DATA_BITS: begin if (baud_tick) begin // 在每个数据位的中间点采样(例如第16, 32, 48...个计数) if (baud_counter == 16) begin rx_data[bit_index] <= rxd; // 采样数据位 bit_index <= bit_index + 1; baud_counter <= baud_counter + 1; end else if (baud_counter == 32) begin // 一个完整的波特率周期结束,准备下一个位或停止位 baud_counter <= 0; if (bit_index == 8) begin // 8位数据收完 state <= STOP_BIT; end end else begin baud_counter <= baud_counter + 1; end end end STOP_BIT: begin if (baud_tick) begin // 在停止位周期中点采样,应为高电平。也可不采样,只用于计时。 if (baud_counter == 16) begin // 理论上应检查rxd==1,这里简化处理 rx_done <= 1'b1; // 产生接收完成脉冲 state <= IDLE; baud_en_rx <= 1'b0; // 关闭波特率发生器 end else begin baud_counter <= baud_counter + 1; end end end default: state <= IDLE; endcase end end endmodule

关键细节解析:为什么在START_BIT状态要等到计数到8才确认?这是毛刺滤波。一个有效的起始位低电平会持续整个波特率周期。如果在起始位周期开始后不久(如中点)采样仍是低电平,那就基本可以确认是真正的起始位,而不是一个窄脉冲的噪声。这大大增强了抗干扰能力。

3.4 发送器(TX)状态机设计与数据组装

发送器相对简单,它是一个“开环”过程,由FPGA主动控制节奏。

module uart_tx ( input wire clk, input wire rst_n, input wire tx_start, // 发送启动脉冲 input wire [7:0] tx_data, // 待发送并行数据 output reg txd, // 串行输出 output reg tx_busy // 发送忙标志 ); localparam IDLE = 2'b00; localparam START_BIT = 2'b01; localparam DATA_BITS = 2'b10; localparam STOP_BIT = 2'b11; reg [1:0] state; reg [3:0] bit_index; reg [15:0] baud_counter; reg baud_en_tx; wire baud_tick; reg [7:0] tx_data_latch; // 数据锁存器 baud_gen baud_gen_inst(.clk(clk), .rst_n(rst_n), .baud_en(baud_en_tx), .baud_tick(baud_tick)); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; txd <= 1'b1; // 空闲时TXD为高电平 tx_busy <= 1'b0; baud_en_tx <= 1'b0; bit_index <= 0; baud_counter <= 0; end else begin case (state) IDLE: begin txd <= 1'b1; if (tx_start && !tx_busy) begin state <= START_BIT; tx_busy <= 1'b1; baud_en_tx <= 1'b1; tx_data_latch <= tx_data; // 锁存待发送数据 baud_counter <= 0; end end START_BIT: begin txd <= 1'b0; // 发送起始位 if (baud_tick) begin if (baud_counter == 16) begin // 起始位发送完毕 state <= DATA_BITS; baud_counter <= 0; bit_index <= 0; end else begin baud_counter <= baud_counter + 1; end end end DATA_BITS: begin txd <= tx_data_latch[bit_index]; // 从LSB开始发送 if (baud_tick) begin if (baud_counter == 16) begin baud_counter <= 0; bit_index <= bit_index + 1; if (bit_index == 7) begin // 8位数据发送完毕 state <= STOP_BIT; end end else begin baud_counter <= baud_counter + 1; end end end STOP_BIT: begin txd <= 1'b1; // 发送停止位 if (baud_tick) begin if (baud_counter == 16) begin // 停止位发送完毕 state <= IDLE; tx_busy <= 1'b0; baud_en_tx <= 1'b0; // 关闭波特率发生器 end else begin baud_counter <= baud_counter + 1; end end end endcase end end endmodule

注意事项:发送模块的tx_start信号最好是一个时钟周期的脉冲,并且需要在tx_busy为低时才能被响应。在发送过程中,tx_busy信号拉高,可以防止新的发送请求打断当前传输。发送数据的锁存(tx_data_latch)至关重要,必须确保在整个发送过程中,源数据tx_data即使变化也不会影响正在进行的串行化输出。

4. 系统集成、测试与深度调试技巧

4.1 顶层模块集成与接口设计

将RX、TX、波特率发生器(或各自独立)集成在一起,形成一个完整的UART IP核。一个典型的顶层模块接口如下:

module uart_top #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input wire clk, input wire rst_n, // 外部UART接口 input wire rxd, output wire txd, // 用户逻辑接口 input wire [7:0] tx_data_i, input wire tx_start_i, output wire tx_busy_o, output wire [7:0] rx_data_o, output wire rx_done_o ); // 内部连线 wire baud_tick_rx, baud_tick_tx; wire baud_en_rx, baud_en_tx; // 实例化接收模块 uart_rx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_rx ( .clk(clk), .rst_n(rst_n), .rxd(rxd), .rx_data(rx_data_o), .rx_done(rx_done_o), .baud_en(baud_en_rx), .baud_tick(baud_tick_rx) ); // 实例化发送模块 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), .txd(txd), .tx_busy(tx_busy_o), .baud_en(baud_en_tx), .baud_tick(baud_tick_tx) ); // 可以共享一个波特率发生器,也可以分开。分开更灵活,互不影响。 baud_gen #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) baud_gen_rx ( .clk(clk), .rst_n(rst_n), .baud_en(baud_en_rx), .baud_tick(baud_tick_rx) ); baud_gen #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) baud_gen_tx ( .clk(clk), .rst_n(rst_n), .baud_en(baud_en_tx), .baud_tick(baud_tick_tx) ); endmodule

4.2 仿真测试:用ModelSim/QuestaSim验证时序

硬件设计,仿真先行。一个完善的测试平台(Testbench)能帮你提前发现90%的逻辑错误。

`timescale 1ns/1ps module uart_tb(); reg clk; reg rst_n; reg rxd_tb; wire txd_tb; reg [7:0] tx_data_tb; reg tx_start_tb; wire tx_busy_tb; wire [7:0] rx_data_tb; wire rx_done_tb; // 实例化被测设计 uart_top uut ( .clk(clk), .rst_n(rst_n), .rxd(rxd_tb), .txd(txd_tb), .tx_data_i(tx_data_tb), .tx_start_i(tx_start_tb), .tx_busy_o(tx_busy_tb), .rx_data_o(rx_data_tb), .rx_done_o(rx_done_tb) ); // 生成时钟 (20ns周期,50MHz) always #10 clk = ~clk; // 任务:模拟PC发送一个字节给FPGA task send_byte; input [7:0] data; integer i; begin rxd_tb = 1'b1; // 空闲位 #8680; // 等待一个波特率周期(1/115200 ≈ 8.68us)的整数倍,模拟空闲 rxd_tb = 1'b0; // 起始位 #8680; for (i=0; i<8; i=i+1) begin // 发送LSB first rxd_tb = data[i]; #8680; end rxd_tb = 1'b1; // 停止位 #8680; end endtask // 主测试流程 initial begin // 初始化 clk = 0; rst_n = 0; rxd_tb = 1'b1; tx_start_tb = 0; tx_data_tb = 0; #100 rst_n = 1; // 测试1:FPGA接收数据 $display("Test 1: FPGA接收数据 0x55"); send_byte(8'h55); // 发送0x55 (01010101b) wait(rx_done_tb); // 等待FPGA接收完成 #100; if (rx_data_tb == 8'h55) $display("RX PASS: Received 0x%h", rx_data_tb); else $display("RX FAIL: Expected 0x55, Got 0x%h", rx_data_tb); // 测试2:FPGA发送数据 $display("\nTest 2: FPGA发送数据 0xAA"); tx_data_tb = 8'hAA; tx_start_tb = 1; #20 tx_start_tb = 0; // 一个时钟周期脉冲 wait(tx_busy_tb == 0); // 等待发送完成 // 这里可以检查txd_tb的波形,或者连接到一个虚拟接收机进行验证 $display("TX data sent. Check waveform for TXD signal."); // 测试3:连续收发 $display("\nTest 3: 连续收发测试"); fork begin: send_block send_byte(8'h11); #5000; send_byte(8'h22); end begin: receive_and_send wait(rx_done_tb); // 收到第一个字节 $display("Received first byte: 0x%h", rx_data_tb); tx_data_tb = rx_data_tb + 1; // 回传数据+1 tx_start_tb = 1; #20 tx_start_tb = 0; wait(tx_busy_tb == 0); wait(rx_done_tb); // 收到第二个字节 $display("Received second byte: 0x%h", rx_data_tb); end join #1000; $display("\nAll tests completed."); $finish; end // 波形记录 initial begin $dumpfile("uart_wave.vcd"); $dumpvars(0, uart_tb); end endmodule

4.3 板上实测与常见问题排查实录

仿真通过后,烧录到FPGA开发板进行实测。这里会遇到仿真中遇不到的真实世界问题。

问题1:数据错位或乱码

  • 现象:PC端串口助手发送0x55,接收到0xAA或其它不规则数据。
  • 排查思路
    1. 检查波特率:这是最常见的问题。确认FPGA系统时钟频率CLK_FREQ参数设置是否与板上晶振一致(是50MHz还是其他?)。重新计算分频系数BAUD_DIV。使用逻辑分析仪或示波器测量TXD引脚,看一个位的时间是否为1/BAUD_RATE秒。
    2. 检查采样点:确认接收状态机是否在数据位正中间采样。仿真时仔细查看baud_counter在采样点的值。如果采样点太靠前或靠后,容易因时钟累积误差或信号抖动采到错误值。
    3. 检查数据位顺序:UART通常是LSB(最低位)先发送。确认你的发送和接收模块位顺序一致。0x55(01010101b)如果MSB先发,就会变成0xAA(10101010b)。

问题2:只能接收第一个字节,后续字节丢失

  • 现象:上电后第一次通信正常,之后再也收不到数据,或需要重新上电才能收一次。
  • 排查思路
    1. 检查状态机复位:确保每个字节接收完成后,状态机正确回到了IDLE状态,并且baud_en_rx被正确拉低。最常见的原因是停止位处理完后,没有清除baud_en_rx,导致波特率发生器一直运行,干扰了下一次起始位的检测。
    2. 检查rx_done信号:这个标志位是否只持续一个时钟周期?如果它一直为高,可能会让上层逻辑误以为一直在接收完成,从而覆盖缓冲区。在状态机中,像rx_done <= 1'b1;这样的语句,一定要在下一个周期将其清零。
    3. 毛刺滤波过严:如果起始位确认逻辑(在START_BIT状态中点采样)的容错范围太窄,或者系统时钟偏差较大,可能导致有效的起始位也被当作毛刺过滤掉。可以适当调整确认点,或者增加一个超时机制,在IDLE状态检测到下降沿后,即使中点采样不是完美的低电平,也尝试进入接收流程。

问题3:高波特率下通信不稳定

  • 现象:波特率在9600以下很稳定,升到115200或以上就频繁出错。
  • 排查思路
    1. 时序约束:在FPGA工具中为clk和相关的信号添加正确的时序约束。高波特率意味着波特率时钟baud_tick的间隔更小,组合逻辑和布线延迟必须在这个间隔内稳定。使用set_max_delay约束baud_tick到采样逻辑的路径。
    2. 同步器深度:提高边沿检测电路中同步寄存器的级数(例如从2级增加到3级),虽然增加了延迟,但显著降低了亚稳态导致错误起始位检测的概率。
    3. PCB与信号完整性:检查硬件连接。UART在高速时,长导线、不匹配的端接会产生反射和振铃。尽量使用短导线,如果必须用长线,考虑使用RS-232电平转换芯片(如MAX3232)而不是直接TTL连接。

问题4:与某些特定设备通信不正常

  • 现象:和A电脑通信正常,和B工控机通信就乱码。
  • 排查思路
    1. 停止位长度:有些设备可能使用1.5或2个停止位。检查你的发送模块是否只发了1个停止位,而对方期望更多。你的接收模块对停止位是否做了足够的“容忍”?一个健壮的接收器可以在停止位采样为高时完成接收,即使它只等待了1个停止位时间,也能兼容1.5或2个停止位的发送方(只要在下一个起始位下降沿之前回到高电平即可)。
    2. 校验位:你的实现是否支持奇偶校验位?如果对方开启了校验,而你按无校验(8N1)去解析,数据位就会错一位。实现一个可选的校验位生成与校验模块是提升鲁棒性的好方法。
    3. 流量控制:是否涉及RTS/CTS硬件流控?如果对方使用了流控,而你的设计没有处理这些信号,对方在缓冲区满时可能会通过拉低CTS来阻止你发送,如果你无视它继续发,就会丢数据。

5. 从UART出发:硬件设计思维的延伸

通过亲手实现UART,你掌握的不仅仅是一个通信协议,更是一套硬件设计的核心方法论:

  1. 从行为到电路:你学会了如何将“发送一个字节”这样的高级行为,翻译成由状态机、计数器、数据选择器构成的精确时序电路。这是硬件描述语言(HDL)思维的精髓。
  2. 同步与异步的桥梁:你掌握了用同步逻辑安全、可靠地处理异步事件(如起始位)的标准模式:同步器+边沿检测+使能控制。
  3. 精准的时间管理:通过波特率发生器,你理解了如何在数字系统中用计数器来度量“时间”,并基于此产生精确的控制动作(采样、移位)。
  4. 模块化与接口:将系统划分为独立的、功能单一的模块(RX、TX、波特率生成),通过清晰的接口(握手信号如start/busy/done)连接。这极大地提高了代码的可读性、可复用性和可测试性。

这套方法论完全可以平移到其他串行协议,如I2C、SPI,甚至更复杂的USB、Ethernet PHY控制。例如,I2C的START条件检测类似于UART的起始位检测,但其后的时钟拉伸、应答位处理则需要更复杂的状态机。SPI则更简单,因为它有同步时钟,但需要处理多种时钟极性和相位模式。

当你再回头看STM32的HAL库中HAL_UART_Transmit()函数时,你看到的就不再是一个黑盒API,而是你脑海中那一套清晰的状态流程和寄存器操作。这种“透视”能力,是区分嵌入式软件工程师和真正理解硬件的系统工程师的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询