
1. 从“会用”到“造轮子”FPGA视角下的UART深度解构以前用51、STM32调串口感觉就是配个寄存器、算个波特率、然后往缓存区读写数据就完事了。直到自己用Verilog在FPGA上从头实现了一遍UART才真正看懂了这看似简单的“起止式异步串行通信”背后时钟、状态、边沿这些硬件信号是如何精密咬合的。这种感觉就像你一直开自动挡的车突然有一天让你拆开发动机自己组装一套变速箱和传动轴你对“驾驶”的理解会完全不同。这次“造轮子”的经历不仅让我彻底搞懂了UART更重要的是它提供了一种用硬件描述语言HDL去解构任何通信协议的通用思维框架。无论是I2C、SPI还是更复杂的自定义协议其核心无非是状态控制、时钟生成与同步、数据流采样这三板斧。今天我就把自己从“软件配置思维”切换到“硬件时序思维”的过程以及实现中的那些关键细节和踩过的坑掰开揉碎了分享给你。2. UART硬件实现的顶层设计与核心思路2.1 异步串行的本质没有公共时钟的握手UARTUniversal Asynchronous Receiver/Transmitter之所以叫“异步”核心在于通信双方没有共享的时钟线。这就像两个人约好每隔1秒说一个字但各自用自己的手表。只要双方手表波特率足够准并且约定好一句话从什么时候开始起始位就能完成通信。这个“约定”就是协议的核心。在FPGA中实现我们的目标就是用同步逻辑统一的系统时钟clk去模拟和对接这个异步的世界。整个设计的顶层模块通常会包含以下几个核心部分波特率发生器Baud Rate Generator将高频的系统时钟clk分频产生与目标波特率同步的时钟使能信号baud_tick。注意这里通常不直接产生一个新时钟而是产生一个脉冲信号用于驱动状态机前进和数据采样。接收器UART Receiver持续监测RXD线检测起始位下降沿启动波特率发生器并在精确的时刻对数据位进行采样完成串并转换。发送器UART Transmitter在收到发送请求后启动波特率发生器按照起始位、数据位、停止位的顺序将并行数据转换为串行比特流从TXD输出。控制与状态逻辑协调收发过程产生中断或状态标志位如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 1b0; end else begin baud_tick 1b0; // 默认拉低 if (baud_en) begin if (counter BAUD_DIV - 1) begin counter 0; baud_tick 1b1; // 计数满产生一个脉冲 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} 3b111; // 假设空闲为高 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 2b00; // 空闲等待起始位 localparam START_BIT 2b01; // 确认起始位 localparam DATA_BITS 2b10; // 接收数据位 localparam STOP_BIT 2b11; // 接收停止位 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 8h00; rx_done 1b0; baud_en_rx 1b0; end else begin rx_done 1b0; // 默认清零完成标志 case (state) IDLE: begin baud_en_rx 1b0; if (neg_edge_rxd) begin // 检测到起始位下降沿 state START_BIT; baud_en_rx 1b1; // 启动波特率时钟 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 // 理论上应检查rxd1这里简化处理 rx_done 1b1; // 产生接收完成脉冲 state IDLE; baud_en_rx 1b0; // 关闭波特率发生器 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 2b00; localparam START_BIT 2b01; localparam DATA_BITS 2b10; localparam STOP_BIT 2b11; 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 1b1; // 空闲时TXD为高电平 tx_busy 1b0; baud_en_tx 1b0; bit_index 0; baud_counter 0; end else begin case (state) IDLE: begin txd 1b1; if (tx_start !tx_busy) begin state START_BIT; tx_busy 1b1; baud_en_tx 1b1; tx_data_latch tx_data; // 锁存待发送数据 baud_counter 0; end end START_BIT: begin txd 1b0; // 发送起始位 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 1b1; // 发送停止位 if (baud_tick) begin if (baud_counter 16) begin // 停止位发送完毕 state IDLE; tx_busy 1b0; baud_en_tx 1b0; // 关闭波特率发生器 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) ); endmodule4.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 1b1; // 空闲位 #8680; // 等待一个波特率周期(1/115200 ≈ 8.68us)的整数倍模拟空闲 rxd_tb 1b0; // 起始位 #8680; for (i0; i8; ii1) begin // 发送LSB first rxd_tb data[i]; #8680; end rxd_tb 1b1; // 停止位 #8680; end endtask // 主测试流程 initial begin // 初始化 clk 0; rst_n 0; rxd_tb 1b1; tx_start_tb 0; tx_data_tb 0; #100 rst_n 1; // 测试1FPGA接收数据 $display(Test 1: FPGA接收数据 0x55); send_byte(8h55); // 发送0x55 (01010101b) wait(rx_done_tb); // 等待FPGA接收完成 #100; if (rx_data_tb 8h55) $display(RX PASS: Received 0x%h, rx_data_tb); else $display(RX FAIL: Expected 0x55, Got 0x%h, rx_data_tb); // 测试2FPGA发送数据 $display(\nTest 2: FPGA发送数据 0xAA); tx_data_tb 8hAA; 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(8h11); #5000; send_byte(8h22); 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 endmodule4.3 板上实测与常见问题排查实录仿真通过后烧录到FPGA开发板进行实测。这里会遇到仿真中遇不到的真实世界问题。问题1数据错位或乱码现象PC端串口助手发送0x55接收到0xAA或其它不规则数据。排查思路检查波特率这是最常见的问题。确认FPGA系统时钟频率CLK_FREQ参数设置是否与板上晶振一致是50MHz还是其他。重新计算分频系数BAUD_DIV。使用逻辑分析仪或示波器测量TXD引脚看一个位的时间是否为1/BAUD_RATE秒。检查采样点确认接收状态机是否在数据位正中间采样。仿真时仔细查看baud_counter在采样点的值。如果采样点太靠前或靠后容易因时钟累积误差或信号抖动采到错误值。检查数据位顺序UART通常是LSB最低位先发送。确认你的发送和接收模块位顺序一致。0x5501010101b如果MSB先发就会变成0xAA10101010b。问题2只能接收第一个字节后续字节丢失现象上电后第一次通信正常之后再也收不到数据或需要重新上电才能收一次。排查思路检查状态机复位确保每个字节接收完成后状态机正确回到了IDLE状态并且baud_en_rx被正确拉低。最常见的原因是停止位处理完后没有清除baud_en_rx导致波特率发生器一直运行干扰了下一次起始位的检测。检查rx_done信号这个标志位是否只持续一个时钟周期如果它一直为高可能会让上层逻辑误以为一直在接收完成从而覆盖缓冲区。在状态机中像rx_done 1b1;这样的语句一定要在下一个周期将其清零。毛刺滤波过严如果起始位确认逻辑在START_BIT状态中点采样的容错范围太窄或者系统时钟偏差较大可能导致有效的起始位也被当作毛刺过滤掉。可以适当调整确认点或者增加一个超时机制在IDLE状态检测到下降沿后即使中点采样不是完美的低电平也尝试进入接收流程。问题3高波特率下通信不稳定现象波特率在9600以下很稳定升到115200或以上就频繁出错。排查思路时序约束在FPGA工具中为clk和相关的信号添加正确的时序约束。高波特率意味着波特率时钟baud_tick的间隔更小组合逻辑和布线延迟必须在这个间隔内稳定。使用set_max_delay约束baud_tick到采样逻辑的路径。同步器深度提高边沿检测电路中同步寄存器的级数例如从2级增加到3级虽然增加了延迟但显著降低了亚稳态导致错误起始位检测的概率。PCB与信号完整性检查硬件连接。UART在高速时长导线、不匹配的端接会产生反射和振铃。尽量使用短导线如果必须用长线考虑使用RS-232电平转换芯片如MAX3232而不是直接TTL连接。问题4与某些特定设备通信不正常现象和A电脑通信正常和B工控机通信就乱码。排查思路停止位长度有些设备可能使用1.5或2个停止位。检查你的发送模块是否只发了1个停止位而对方期望更多。你的接收模块对停止位是否做了足够的“容忍”一个健壮的接收器可以在停止位采样为高时完成接收即使它只等待了1个停止位时间也能兼容1.5或2个停止位的发送方只要在下一个起始位下降沿之前回到高电平即可。校验位你的实现是否支持奇偶校验位如果对方开启了校验而你按无校验8N1去解析数据位就会错一位。实现一个可选的校验位生成与校验模块是提升鲁棒性的好方法。流量控制是否涉及RTS/CTS硬件流控如果对方使用了流控而你的设计没有处理这些信号对方在缓冲区满时可能会通过拉低CTS来阻止你发送如果你无视它继续发就会丢数据。5. 从UART出发硬件设计思维的延伸通过亲手实现UART你掌握的不仅仅是一个通信协议更是一套硬件设计的核心方法论从行为到电路你学会了如何将“发送一个字节”这样的高级行为翻译成由状态机、计数器、数据选择器构成的精确时序电路。这是硬件描述语言HDL思维的精髓。同步与异步的桥梁你掌握了用同步逻辑安全、可靠地处理异步事件如起始位的标准模式同步器边沿检测使能控制。精准的时间管理通过波特率发生器你理解了如何在数字系统中用计数器来度量“时间”并基于此产生精确的控制动作采样、移位。模块化与接口将系统划分为独立的、功能单一的模块RX、TX、波特率生成通过清晰的接口握手信号如start/busy/done连接。这极大地提高了代码的可读性、可复用性和可测试性。这套方法论完全可以平移到其他串行协议如I2C、SPI甚至更复杂的USB、Ethernet PHY控制。例如I2C的START条件检测类似于UART的起始位检测但其后的时钟拉伸、应答位处理则需要更复杂的状态机。SPI则更简单因为它有同步时钟但需要处理多种时钟极性和相位模式。当你再回头看STM32的HAL库中HAL_UART_Transmit()函数时你看到的就不再是一个黑盒API而是你脑海中那一套清晰的状态流程和寄存器操作。这种“透视”能力是区分嵌入式软件工程师和真正理解硬件的系统工程师的关键。