尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

FPGA串口通信:从Verilog手写到稳定实现与调试避坑

FPGA串口通信:从Verilog手写到稳定实现与调试避坑 1. 项目概述从零到一在FPGA上打通串口通信搞硬件的朋友尤其是玩FPGA的串口通信绝对是一个绕不开的“老朋友”。它简单、经典是验证硬件逻辑、与上位机交互最直接的方式之一。但就是这个看似简单的“接收发送”真要在FPGA里从零开始实现并且做到稳定可靠里面门道可不少。很多人可能直接调用现成的IP核或者从网上找个代码改改波特率就用了结果在实际项目中遇到数据错乱、丢包、时序不收敛等问题调试起来一头雾水。今天我们不依赖任何现成的黑盒IP就用手写Verilog/VHDL代码的方式彻底把串口通信UART的接收和发送模块实现一遍。我会带你理解每一个比特位传输的时序细节分析波特率时钟生成的原理并分享在实际工程中如何设计健壮的有限状态机FSM来处理起始位、数据位、停止位。更重要的是我会把调试过程中踩过的坑、积累的经验比如如何做跨时钟域处理、如何设计自校验的测试平台都毫无保留地分享出来。无论你是FPGA初学者想夯实基础还是有一定经验的工程师想优化自己的通信链路这篇内容都能给你带来可直接复现的参考价值。2. 串口通信核心原理与FPGA实现思路拆解2.1 串口协议本质异步时序与字节帧格式串口UART通信的核心在于“异步”。这意味着通信双方没有统一的时钟线来同步数据完全依靠事先约定好的波特率Baud Rate来对数据进行采样和发送。这就带来了第一个挑战如何在本地精确地产生与对方一致的波特率时钟以及如何在这个异步的比特流中准确地定位一个字节数据的开始和结束一个标准的UART数据帧格式非常简单但也非常关键1个起始位低电平 5-9个数据位通常为8位 可选的奇偶校验位 1个或2个停止位高电平。空闲状态下通信线保持高电平。当检测到从高到低的跳变即起始位时接收方就启动一个内部计时在数据位的中间位置进行采样以获得最稳定的数据值。在FPGA中实现我们的核心任务就是模拟这个行为发送端将一个并行数据如8位按照约定的波特率转换成符合上述帧格式的串行比特流一位一位地发送出去。接收端持续监测线路捕捉起始位下降沿然后以相同的波特率节奏在预定的时间点上对串行比特流进行采样重新组装成并行数据。思路清晰了但具体实现时有几个关键设计决策点波特率时钟生成是直接用系统时钟分频得到一个低频的波特率时钟还是使用高频系统时钟进行计数在计数器达到特定值时进行采样/移位后者通常称为“波特率生成器”或“过采样”更精确、更灵活也是主流做法。采样点策略为了对抗信号抖动和微小误差通常会在每个比特位的中间位置采样。这需要对波特率周期进行更细粒度的划分。状态机设计接收过程本质上是一个状态机空闲IDLE - 检测起始位START - 采样数据位DATA - 采样停止位STOP - 完成DONE。清晰的状态机是逻辑稳定的基石。2.2 FPGA实现方案选型为什么选择“过采样计数法”常见的FPGA串口实现有两种思路一是生成一个独立的波特率时钟域二是使用系统高频时钟进行计数。我强烈推荐第二种即“过采样计数法”。假设系统时钟clk为50MHz目标波特率为115200 bps。波特率周期 1 / 115200 ≈ 8.68 us。系统时钟周期 1 / 50MHz 0.02 us。一个波特率周期包含的系统时钟数 8.68 us / 0.02 us ≈ 434。如果我们直接分频产生一个115200Hz的时钟会引入一个新的时钟域处理跨时钟域信号会变得复杂。而“过采样计数法”的思路是我们仍然在50MHz的clk下工作但设置一个计数器。当需要发送或接收时这个计数器从0开始累加每计满434个时钟我们就认为“过”了一个比特位的时间。在接收时我们可以在计数器计到一半例如217时进行采样这就是比特位中间点。这种方法的优势非常明显避免多时钟域整个设计只有一个主时钟简化了时序分析和设计。采样点可调通过调整计数器的比较值可以轻松微调采样点以补偿线路延迟或时钟偏差。精度高只要系统时钟频率足够高波特率的相对误差可以做到非常小。计算误差434个时钟实际代表 434 * 0.02us 8.68 us完全匹配。资源占用少通常只需要几个寄存器和比较器比一个完整的时钟分频器跨时钟域同步链更节省资源。因此我们的发送和接收模块都将基于这个“过采样计数法”来构建。3. 核心模块详细设计与代码解析3.1 波特率生成与参数化设计首先我们需要一个可配置的波特率生成模块或者更准确地说是一个“比特周期计数器”模块。它不直接输出时钟而是输出一个标志信号告知主状态机“当前比特位的持续时间已经结束该处理下一位了”。module baud_rate_generator #( parameter CLK_FREQ 50_000_000, // 输入时钟频率单位Hz parameter BAUD_RATE 115200 // 目标波特率 ) ( input wire clk, input wire rst_n, input wire count_en, // 计数使能高电平有效 output reg bit_tick // 比特周期结束标志高电平一个时钟周期 ); // 计算一个比特位周期对应的时钟周期数 localparam integer CLKS_PER_BIT CLK_FREQ / BAUD_RATE; // 计数器 reg [15:0] counter; // 宽度根据CLKS_PER_BIT调整11520050M约为43416位足够 always (posedge clk or negedge rst_n) begin if (!rst_n) begin counter 0; bit_tick 1b0; end else begin bit_tick 1b0; // 默认拉低 if (count_en) begin if (counter CLKS_PER_BIT - 1) begin counter 0; bit_tick 1b1; // 计满一个比特周期产生标志脉冲 end else begin counter counter 1; end end else begin counter 0; // 不使能时清零计数器 end end end endmodule关键设计说明count_en信号至关重要。在发送或接收状态机启动时才使能这个计数器。空闲时计数器归零避免无谓的功耗和误触发。bit_tick是一个单周期脉冲信号。在接收模块我们会在计数器计到CLKS_PER_BIT/2时采样而不是在bit_tick时采样以确保采样点在比特位中央。bit_tick更多用于状态机切换到下一个比特位。参数化设计使用parameter使得模块可以轻松适配不同的系统时钟和波特率这是良好代码风格的基础。3.2 接收模块UART_RX设计与实现接收模块是串口实现中的难点因为它需要可靠地检测异步的起始位并在正确的时间点采样。我们将使用一个经典的状态机。module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200, parameter DATA_BITS 8, parameter STOP_BITS 1 ) ( input wire clk, input wire rst_n, input wire rx_serial, // 串行输入信号 output reg [DATA_BITS-1:0] rx_data, // 接收到的并行数据 output reg rx_done, // 接收完成标志高电平一个时钟周期 output reg rx_error // 错误标志如停止位错误 ); // 状态定义 localparam IDLE 3b000; localparam START 3b001; localparam DATA 3b010; localparam STOP 3b011; localparam DONE 3b100; reg [2:0] state, next_state; reg [15:0] clk_counter; reg [3:0] bit_index; // 用于计数已接收的数据位 reg [DATA_BITS-1:0] data_reg; wire bit_tick; wire sample_point; // 采样点标志 reg rx_serial_sync1, rx_serial_sync2; // 同步器用于亚稳态处理 // 实例化波特率计数器这里简化为本地逻辑实际可调用独立模块 localparam CLKS_PER_BIT CLK_FREQ / BAUD_RATE; localparam SAMPLE_OFFSET CLKS_PER_BIT / 2; // 在比特位中间采样 // 双级同步器处理跨时钟域问题虽然同源但rx_serial是异步输入 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_serial_sync1 1b1; // 空闲为高 rx_serial_sync2 1b1; end else begin rx_serial_sync1 rx_serial; rx_serial_sync2 rx_serial_sync1; end end // 状态机主进程 always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; clk_counter 0; bit_index 0; data_reg 0; rx_done 1b0; rx_error 1b0; end else begin state next_state; rx_done 1b0; // 默认拉低 rx_error 1b0; case (state) IDLE: begin clk_counter 0; bit_index 0; if (rx_serial_sync2 1b0) begin // 检测到起始位下降沿 next_state START; end else begin next_state IDLE; end end START: begin // 在起始位中间采样确认是有效的起始位低电平 if (clk_counter SAMPLE_OFFSET - 1) begin if (rx_serial_sync2 1b0) begin next_state DATA; // 确认起始位有效 clk_counter 0; end else begin next_state IDLE; // 毛刺回到空闲 end end else begin clk_counter clk_counter 1; next_state START; end end DATA: begin // 在每个数据位的中间采样 if (clk_counter SAMPLE_OFFSET - 1) begin data_reg[bit_index] rx_serial_sync2; // 采样数据 clk_counter 0; if (bit_index DATA_BITS - 1) begin next_state STOP; end else begin bit_index bit_index 1; next_state DATA; end end else begin clk_counter clk_counter 1; next_state DATA; end end STOP: begin // 在停止位中间采样检查是否为高电平 if (clk_counter SAMPLE_OFFSET - 1) begin if (rx_serial_sync2 1b1) begin rx_error 1b0; end else begin rx_error 1b1; // 停止位错误 end clk_counter 0; next_state DONE; end else begin clk_counter clk_counter 1; next_state STOP; end end DONE: begin rx_data data_reg; // 输出数据 rx_done 1b1; // 产生完成脉冲 next_state IDLE; end default: next_state IDLE; endcase end end endmodule实操心得与避坑指南同步器是必须的rx_serial是来自外部世界的异步信号直接使用会导致亚稳态。双寄存器同步是最基本的处理方式。虽然我们的设计在同一个clk下但信号源是异步的这一步绝对不能省。起始位再确认在START状态我们并没有在检测到下降沿后立刻进入数据采样而是等待到起始位中间点再次采样确认。这能有效滤除线路上的短时毛刺或噪声极大提高抗干扰能力。这是很多简易实现忽略的关键一步。采样点计算SAMPLE_OFFSET CLKS_PER_BIT / 2是理想情况。在实际中如果系统时钟不是波特率的整数倍这里会有舍入误差。为了更精确可以使用累加器或更细的计数逻辑。对于常见的50MHz和115200bps434是整数一半217也是整数没有问题。但如果波特率是960050M/9600≈5208.33就不是整数了这时需要更精细的设计如使用分数分频累加器。rx_done信号它是一个单周期脉冲非常适合用来触发后续处理逻辑如将数据写入FIFO或寄存器。确保你的下游逻辑能捕捉到这个脉冲。3.3 发送模块UART_TX设计与实现发送模块相对简单因为时序完全由本地控制。我们只需要将并行数据装入移位寄存器然后根据波特率节奏依次移出即可。module uart_tx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200, parameter DATA_BITS 8, parameter STOP_BITS 1 ) ( input wire clk, input wire rst_n, input wire tx_start, // 发送启动信号高电平有效 input wire [DATA_BITS-1:0] tx_data, // 待发送的并行数据 output reg tx_serial, // 串行输出信号 output reg tx_done // 发送完成标志高电平一个时钟周期 ); // 状态定义 localparam IDLE 2b00; localparam START 2b01; localparam DATA 2b10; localparam STOP 2b11; reg [1:0] state, next_state; reg [15:0] clk_counter; reg [3:0] bit_index; reg [DATA_BITS-1:0] data_reg; localparam CLKS_PER_BIT CLK_FREQ / BAUD_RATE; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state IDLE; clk_counter 0; bit_index 0; tx_serial 1b1; // 空闲时输出高电平 tx_done 1b0; data_reg 0; end else begin state next_state; tx_done 1b0; case (state) IDLE: begin tx_serial 1b1; // 停止位/空闲位 if (tx_start) begin data_reg tx_data; next_state START; clk_counter 0; end else begin next_state IDLE; end end START: begin tx_serial 1b0; // 起始位 if (clk_counter CLKS_PER_BIT - 1) begin clk_counter 0; next_state DATA; end else begin clk_counter clk_counter 1; next_state START; end end DATA: begin tx_serial data_reg[bit_index]; // 从LSB开始发送 if (clk_counter CLKS_PER_BIT - 1) begin clk_counter 0; if (bit_index DATA_BITS - 1) begin bit_index 0; next_state STOP; end else begin bit_index bit_index 1; next_state DATA; end end else begin clk_counter clk_counter 1; next_state DATA; end end STOP: begin tx_serial 1b1; // 停止位 if (clk_counter CLKS_PER_BIT - 1) begin clk_counter 0; tx_done 1b1; // 发送完成 next_state IDLE; end else begin clk_counter clk_counter 1; next_state STOP; end end default: next_state IDLE; endcase end end endmodule注意事项发送启动信号tx_start这个信号必须是一个单周期脉冲。在IDLE状态下检测到其上升沿后模块会锁存tx_data并开始发送流程。如果tx_start持续为高会导致发送完一帧后立即开始发送下一帧如果数据已变化这可能不是预期行为。通常由上层逻辑控制在数据准备好后产生一个脉冲。发送顺序代码data_reg[bit_index]默认从 bit_index0 开始发送即最低有效位LSB。这是最常见的UART配置LSB first。如果需要先发送最高有效位MSB需要调整索引顺序或发送前对数据位进行翻转。tx_done信号在停止位发送完毕后产生一个单周期脉冲指示一帧数据已发送完成。上层逻辑可以据此准备下一个要发送的数据。4. 系统集成、仿真测试与上板验证4.1 顶层模块集成与数据环路测试单独测试RX和TX模块比较麻烦一个经典的自我验证方法是构建一个“环路测试Loopback Test”顶层模块。将TX的输出直接连接到RX的输入。这样FPGA发送的数据又能被自己接收回来非常便于验证。module uart_top #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire uart_rxd, // 外部串口接收线 output wire uart_txd // 外部串口发送线 ); // 内部信号声明 wire [7:0] rx_data; wire rx_done; wire tx_done; reg [7:0] data_to_send; reg tx_start; wire loopback_signal; // 用于环路测试 // 实例化接收模块 uart_rx #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_rx ( .clk(clk), .rst_n(rst_n), .rx_serial(uart_rxd), // 连接外部引脚 .rx_data(rx_data), .rx_done(rx_done), .rx_error() ); // 实例化发送模块 uart_tx #( .CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE) ) u_tx ( .clk(clk), .rst_n(rst_n), .tx_start(tx_start), .tx_data(data_to_send), .tx_serial(uart_txd), // 连接外部引脚 .tx_done(tx_done) ); // 环路测试逻辑收到数据后原样发回 reg [1:0] state; localparam S_IDLE 2b00, S_WAIT_RX 2b01, S_SEND 2b10; always (posedge clk or negedge rst_n) begin if (!rst_n) begin state S_IDLE; tx_start 1b0; data_to_send 8h00; end else begin case (state) S_IDLE: begin tx_start 1b0; if (rx_done) begin // 检测到接收完成 data_to_send rx_data; // 锁存接收到的数据 state S_SEND; end end S_SEND: begin tx_start 1b1; // 启动发送 state S_IDLE; // 简化处理假设发送立即开始 // 更稳健的做法是等待tx_done再回到IDLE end default: state S_IDLE; endcase end end // 真正的环路测试直接将tx_serial连接到rx_serial屏蔽外部引脚 // assign loopback_signal u_tx.tx_serial; // assign u_rx.rx_serial loopback_signal; // 用于纯内部测试 // assign uart_txd 1bz; // 外部引脚置高阻 endmodule这个顶层模块做了两件事将内部的uart_rx和uart_tx模块连接到对应的外部引脚 (uart_rxd,uart_txd)。实现了一个简单的环路测试状态机当接收模块完成一帧数据的接收rx_done脉冲状态机就将接收到的数据rx_data赋值给data_to_send并触发tx_start脉冲让发送模块将其发送出去。4.2 仿真测试平台Testbench搭建仿真对于验证逻辑正确性至关重要。我们需要编写一个测试平台模拟上位机向FPGA发送数据并观察FPGA的接收和发送行为。timescale 1ns / 1ps module tb_uart_top(); reg clk; reg rst_n; reg sim_rxd; // 模拟上位机发送的数据线 wire sim_txd; // 监测FPGA发送的数据线 // 实例化被测设计 uart_top uut ( .clk(clk), .rst_n(rst_n), .uart_rxd(sim_rxd), .uart_txd(sim_txd) ); // 生成时钟50MHz always #10 clk ~clk; // 20ns周期即50MHz // 任务模拟UART发送一个字节 task uart_send_byte; input [7:0] data; integer i; real bit_time; begin bit_time 1.0 / 115200 * 1e9; // 计算比特位时间纳秒 sim_rxd 1b0; // 起始位 #(bit_time); for (i0; i8; ii1) begin // 发送8位数据LSB first sim_rxd data[i]; #(bit_time); end sim_rxd 1b1; // 停止位 #(bit_time); end endtask // 初始化与测试流程 initial begin // 初始化信号 clk 0; rst_n 0; sim_rxd 1b1; // 空闲高电平 #100; rst_n 1; #1000; // 等待稳定 // 测试用例1发送数据 0x55 (01010101) uart_send_byte(8h55); #10000; // 等待FPGA回环发送完成 // 测试用例2发送数据 0xAA (10101010) uart_send_byte(8hAA); #10000; // 测试用例3发送数据 0x5A (01011010) uart_send_byte(8h5A); #10000; $finish; end // 监视器打印关键信号 initial begin $monitor(Time%t, sim_rxd%b, sim_txd%b, uut.u_rx.state%h, uut.u_tx.state%h, $time, sim_rxd, sim_txd, uut.u_rx.state, uut.u_tx.state); end endmodule在仿真中我们主要观察sim_txd是否在接收完成后延迟一段时间处理时间发送时间后输出与sim_rxd相同的数据波形。接收状态机 (u_rx.state) 和发送状态机 (u_tx.state) 是否按预期在IDLE,START,DATA,STOP之间跳转。时序是否精确特别是比特位宽度是否与计算的bit_time一致。4.3 上板验证与调试技巧实录仿真通过后就可以进行上板验证了。你需要将FPGA的某个引脚连接到USB转串口模块的RXD另一个引脚连接到TXD。在PC端使用串口调试助手如SecureCRT, Putty, 或者国产的XCOM进行测试。常见问题与排查技巧收不到任何数据检查引脚约束确保顶层模块的uart_rxd和uart_txd信号正确分配到了FPGA芯片的物理引脚并且与你的USB转串口模块连接正确。RX接TXTX接RX这是最容易接反的地方。检查波特率确认FPGA代码中的CLK_FREQ和BAUD_RATE参数与你的硬件和PC软件设置完全一致。一个常见的错误是系统时钟频率设错比如开发板是50MHz代码里写了100MHz。检查复位信号确保rst_n信号在上电后已经释放变为高电平。可以加一个LED灯在复位完成后点亮作为调试信号。收到乱码比特顺序问题确认发送和接收的比特顺序LSB first / MSB first是否一致。绝大多数串口设备都是LSB first。我们的代码默认是LSB first。停止位/数据位/校验位不匹配确保FPGA代码中的帧格式数据位8停止位1无校验与串口调试助手的设置完全一致。时钟精度问题如果CLK_FREQ / BAUD_RATE不是整数会产生累积误差。对于高波特率或长数据流误差累积可能导致采样点漂移出错。此时需要更精确的波特率生成算法如使用DDS或分数分频。只能接收一次数据或发送一次后卡死状态机未正确返回IDLE仔细检查接收和发送状态机的转换条件特别是完成状态DONE或发送完成的STOP后是否无条件或有条件地回到了IDLE。控制信号冲突检查tx_start信号。它必须是一个单周期脉冲。如果它持续为高发送模块在完成一帧后如果tx_data变化了会立刻开始发送新数据但此时上层逻辑可能还没准备好导致状态混乱。确保你的发送触发逻辑是脉冲式的。使用内部逻辑分析仪如ChipScope, SignalTap这是FPGA调试的利器。将uart_rxd,uart_txd,rx_done,tx_done,rx_data,state等关键信号抓取出来可以像示波器一样直观地看到信号时序和状态跳转定位问题比盲目修改代码高效得多。一个实用的调试技巧先进行内部环路测试。在顶层文件中像注释里写的那样暂时将uart_tx的输出直接连接到uart_rx的输入并让外部发送引脚高阻。这样你可以写一个简单的测试逻辑让FPGA自己定时发送数据例如计数器值然后自己接收并验证。这完全排除了外部线路、USB转串口模块、PC软件带来的不确定性可以最纯粹地验证你的RTL逻辑是否正确。内部环路测试通过后再切换到真实的外部引脚连接进行联调。
返回列表