资讯动态

纯Verilog手写UART串口回环:从时序原理到上板调试

发布时间:2026/10/14 15:57:33 来源:尧图企业网站定制
简介这份资源面向FPGA初学者与数字电路设计入门者提供一套基于纯Verilog实现的串口通信与串口回环工程可用于理解串行通信协议、掌握收发状态机设计并完成板级验证。工程实现FPGA接收串口助手数据后原样回传的回环逻辑也可按需改为纯发送或纯接收模式覆盖起始位、8位数据位、奇偶校验位与停止位等数据帧要点以及TX与RX交叉连接的接线方式。压缩包共187个文件约5.92MB以Quartus工程文件为主包含qpf、qsf等工程配置v源码、sof烧录文件、vo与sdo仿真输出以及rpt、summary等综合报告和wlf波形文件另附readme说明便于直接打开复现与二次修改。已有367人学习。作者在描述中坦诚指出连续多字节回环时存在隔字节丢失的已知问题并说明单字节发送正常为读者提供了明确的排错方向与改进切入点适合作为串口协议入门与调试练手的参考工程。1. 串口回环这件事为什么值得用纯 Verilog 从头写一遍很多人第一次接触 FPGA 串口通信都是直接调用现成 IP 核或者抄一段网上代码跑通了就收工。但如果你真的想搞清楚 UART 的时序到底怎么对齐、波特率误差怎么算、采样点为什么选在中间我建议你老老实实用纯 Verilog 从零写一遍串口回环。所谓串口回环就是把 FPGA 收到的数据原封不动发回去——PC 发一个字节FPGA 收完立刻回传同一个字节PC 端看到自己发什么就收到什么。这个功能听起来简单但它把 UART 发送、接收、波特率生成、跨时钟域处理这几个核心问题全串起来了。适合谁适合刚学完 Verilog 语法、想找一个完整项目练手的同学也适合工作中需要快速验证串口链路的工程师。下面我按实际调试顺序把整个方案拆开讲。2. 先把 UART 帧格式和波特率算清楚时序不对后面全白搭2.1 一帧数据到底长什么样UART 是异步串行通信没有单独的时钟线收发双方靠约定的波特率来对齐每一位。一帧完整的 UART 数据包含1 个起始位逻辑 0、8 个数据位LSB 先发、1 个停止位逻辑 1。空闲时线路保持高电平。接收端检测到起始位下降沿后等待半个波特率周期再开始逐位采样这样采样点正好落在每一位的中间容错能力最强。这里有个容易翻车的地方很多人以为起始位检测到下降沿就立刻开始计数结果采样点偏到数据位边缘稍微有点时钟偏差就读错。正确做法是检测到下降沿后先等半个位周期再采第一个数据位。2.2 波特率分频系数怎么算假设系统时钟 50MHz目标波特率 115200分频系数 50_000_000 / 115200 ≈ 434.03。取整数 434实际波特率 50_000_000 / 434 ≈ 115207误差约 0.006%完全可接受。一般要求误差小于 2% 就能稳定通信。系统时钟目标波特率分频系数实际波特率误差50MHz9600520896010.01%50MHz1152004341152070.006%50MHz921600549259260.47%提示分频系数最好用参数化写法换时钟频率时只改一个 parameter不用满篇找数字。2.3 采样点为什么选在中间异步通信没有时钟同步接收端和发送端的波特率总有微小偏差。如果采样点选在位的边缘偏差累积几个位之后就可能采到相邻位。选在中间左右各有半个位周期的容错窗口即使波特率有 2% 偏差采 8 个数据位也不会出错。我一般用「计数到分频系数一半时采第一个数据位之后每计数满一个分频周期采下一位」的方式实现。3. 发送模块和接收模块怎么写两个状态机搞定3.1 发送模块的状态机设计发送模块的核心是一个状态机空闲态等发送请求加载态把数据锁存到移位寄存器发送态按波特率逐位输出停止态发完停止位回到空闲。// 串口发送模块参数化波特率 module uart_tx #( parameter CLK_FREQ 50_000_000, // 系统时钟频率 parameter BAUD_RATE 115200 // 目标波特率 )( input wire clk, input wire rst_n, input wire tx_start, // 发送启动脉冲 input wire [7:0] tx_data, // 待发送字节 output reg tx, // 串口输出线 output reg tx_busy // 忙标志高电平表示正在发送 ); localparam BAUD_CNT CLK_FREQ / BAUD_RATE; // 分频系数 reg [15:0] clk_cnt; // 波特率计数器 reg [3:0] bit_cnt; // 已发送位数 reg [9:0] shift_reg; // 移位寄存器起始位8数据位停止位 reg sending; always (posedge clk or negedge rst_n) begin if (!rst_n) begin tx 1b1; tx_busy 1b0; clk_cnt 0; bit_cnt 0; shift_reg 10h3FF; // 空闲全高 sending 1b0; end else begin if (!sending) begin tx 1b1; tx_busy 1b0; if (tx_start) begin // 组装一帧停止位(1) 数据位(8) 起始位(0) shift_reg {1b1, tx_data, 1b0}; sending 1b1; tx_busy 1b1; clk_cnt 0; bit_cnt 0; end end else begin if (clk_cnt BAUD_CNT - 1) begin clk_cnt 0; tx shift_reg[0]; // 先发最低位 shift_reg {1b1, shift_reg[9:1]}; if (bit_cnt 9) begin // 10位发完 sending 1b0; tx_busy 1b0; end else begin bit_cnt bit_cnt 1; end end else begin clk_cnt clk_cnt 1; end end end end endmodule这段代码的关键点shift_reg初始装载为{停止位, 数据, 起始位}移位时从最低位开始输出正好是先发起始位、再发 8 个数据位、最后发停止位。tx_busy信号用来告诉上层模块「我还在发别给我新数据」。参数CLK_FREQ和BAUD_RATE在例化时传入换时钟或换波特率只改这两个值。3.2 接收模块的采样逻辑接收模块比发送复杂一点因为它要检测起始位、对齐采样点、还要做简单的抗干扰。我的做法是空闲时监测rx下降沿检测到之后启动一个半波特率延时然后每隔一个完整波特率周期采样一次连续采 8 位数据最后确认停止位。// 串口接收模块中点采样 module uart_rx #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, // 串口输入线 output reg [7:0] rx_data, // 接收到的字节 output reg rx_done // 接收完成脉冲 ); localparam BAUD_CNT CLK_FREQ / BAUD_RATE; localparam HALF_CNT BAUD_CNT / 2; reg [15:0] clk_cnt; reg [3:0] bit_cnt; reg [7:0] data_temp; reg receiving; reg rx_d0, rx_d1; // 同步打拍消除亚稳态 // 输入同步防止亚稳态 always (posedge clk or negedge rst_n) begin if (!rst_n) begin rx_d0 1b1; rx_d1 1b1; end else begin rx_d0 rx; rx_d1 rx_d0; end end always (posedge clk or negedge rst_n) begin if (!rst_n) begin receiving 1b0; rx_done 1b0; clk_cnt 0; bit_cnt 0; data_temp 8h00; rx_data 8h00; end else begin rx_done 1b0; // 默认拉低只脉冲一拍 if (!receiving) begin if (rx_d1 1b0) begin // 检测起始位下降沿 receiving 1b1; clk_cnt 0; bit_cnt 0; end end else begin if (clk_cnt (bit_cnt 0 ? HALF_CNT : BAUD_CNT) - 1) begin clk_cnt 0; if (bit_cnt 8) begin data_temp {rx_d1, data_temp[7:1]}; // LSB先收 bit_cnt bit_cnt 1; end else begin // 第9次采样对应停止位直接结束 rx_data data_temp; rx_done 1b1; receiving 1b0; end end else begin clk_cnt clk_cnt 1; end end end end endmodule这里有两个细节值得说。第一rx_d0和rx_d1两级触发器做输入同步这是跨时钟域处理的基本操作少了这一步在板子上跑起来会偶发误码而且很难查。第二第一次采样等待HALF_CNT之后每次等待BAUD_CNT这样采样点就落在每一位的中间。bit_cnt从 0 到 8 共采 9 次前 8 次是数据位第 9 次是停止位采完就拉高rx_done一拍。3.3 回环顶层怎么连回环逻辑很简单接收模块的rx_done触发发送模块的tx_start把收到的数据直接送回去。// 串口回环顶层 module uart_loopback #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, input wire rx, output wire tx ); wire [7:0] rx_data; wire rx_done; wire tx_busy; uart_rx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx), .rx_data (rx_data), .rx_done (rx_done) ); uart_tx #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (rx_done ~tx_busy), // 收完且发送空闲才启动 .tx_data (rx_data), .tx (tx), .tx_busy (tx_busy) ); endmoduletx_start的条件是rx_done ~tx_busy这样保证不会在发送过程中被新数据打断。如果 PC 端连续快速发数据而发送还没完成新来的接收数据会覆盖rx_data这是这个简化版本的边界——实际项目中如果要做全双工连续传输需要加 FIFO 缓冲。4. 避坑指南串口回环调试中最容易翻车的 5 个地方4.1 收到乱码但波特率算出来没错现象PC 端发 0x55收到 0xAA 或者完全随机的字节。原因采样点没对齐或者输入没有做同步打拍亚稳态导致某一位采错。解决先确认接收模块第一次采样等待的是半个波特率周期再检查rx信号是否经过了两级触发器同步。我一般会在仿真里把rx的每一位打印出来对照发送端时序逐位核对。4.2 第一个字节正确后续字节全错现象上电后第一次通信正常之后每次收到的数据都偏移或错误。原因状态机没有正确回到空闲态或者rx_done脉冲太宽导致重复触发发送。解决检查接收状态机在采完停止位后是否干净地回到空闲rx_done是否只拉高一拍。另外确认tx_start用的是脉冲而不是电平。4.3 仿真通过上板子就挂现象ModelSim 里波形完美下载到 FPGA 后 PC 端收不到任何数据。原因引脚约束没写对或者时钟频率和参数不一致。解决先确认CLK_FREQ参数和板子实际晶振频率一致再检查引脚约束文件里rx、tx是否绑到了正确的管脚。还有一个常见问题是电平标准不匹配比如 FPGA 是 3.3V 而外部设备是 5V需要加电平转换。4.4 波特率误差在临界值附近现象大部分数据正确偶尔出现一两个误码换一台电脑就好了。原因波特率误差接近 2% 的临界值加上 PC 端串口芯片本身的误差累积起来就超了。解决优先选误差更小的分频系数或者提高系统时钟频率。比如 50MHz 下 921600 的误差是 0.47%如果换成 100MHz 系统时钟误差会降到 0.23%。4.5 回环测试时发送和接收互相干扰现象回环功能单独测都正常但连在一起就偶尔丢字节。原因rx_done和tx_busy的握手逻辑有竞争或者没有做跨时钟域处理。解决在tx_start条件里加上~tx_busy确保发送空闲才启动新发送。如果收发时钟不同源还需要加异步 FIFO。5. 进阶技巧用参数化仿真脚本把调试效率提上去5.1 参数化设计让一份代码适配多种场景上面三个模块都用了parameter来传递CLK_FREQ和BAUD_RATE这不是为了好看而是实战需要。同一份代码在 50MHz 的板子上跑 115200在 100MHz 的板子上跑 921600只需要在例化时改参数不用动内部逻辑。我一般还会加一个PARITY_EN参数来控制是否启用校验位虽然回环场景用不上但后面做正式项目时直接扩展就行。5.2 写一个自动对比的 testbench仿真阶段最耗时间的不是写激励而是逐位核对波形。我的习惯是写一个发送任务和一个接收任务让 testbench 自动发一批随机数据然后对比回环回来的数据是否一致。// 自动对比 testbench 核心片段 task send_byte(input [7:0] data); integer i; begin rx 1b0; #(BIT_PERIOD); // 起始位 for (i 0; i 8; i i 1) begin rx data[i]; #(BIT_PERIOD); // 数据位 LSB 先发 end rx 1b1; #(BIT_PERIOD); // 停止位 end endtask initial begin // 发送 100 个随机字节自动比对 for (integer k 0; k 100; k k 1) begin test_data $random % 256; send_byte(test_data); // 等待回环数据返回并比对 wait (rx_done 1b1); if (rx_data ! test_data) begin $display(FAIL: sent %02x, got %02x, test_data, rx_data); $stop; end end $display(ALL PASS); $finish; endBIT_PERIOD根据波特率算出来比如 115200 对应约 8.68us。这个 testbench 跑一遍只要几秒钟能覆盖绝大多数边界情况。比手动看波形效率高一个数量级。5.3 用板载 LED 做快速状态指示上板调试时如果手头没有逻辑分析仪可以先把rx_done和tx_busy接到 LED 上。每收到一个字节 LED 闪一下发送时另一个 LED 亮起。这样不用接串口线就能判断模块有没有在工作。我一般还会把收到的最后一个字节的低 4 位接到 LED 上肉眼就能看出数据对不对。5.4 一个我踩过的坑复位释放时机最后说一个血泪经验。早期我做串口回环时复位信号用的是按键直接输入没有做消抖和同步。结果按键按下的瞬间复位信号在时钟边沿附近跳变状态机进入了一个莫名其妙的状态必须重新上电才能恢复。后来我在复位路径上加了同步器和消抖逻辑问题再没出现过。如果你也遇到「复位后偶尔不工作」的情况先查复位信号的同步处理。这个方案值不值得做我的判断是如果你只想快速验证串口链路用 IP 核更省事但如果你想真正理解 UART 的时序本质、想拥有一份能随时改参数适配不同项目的代码、想在面试或技术评审时讲清楚每一个采样点的来龙去脉那纯 Verilog 写一遍串口回环是最短路径。代码量不大但覆盖的知识点足够扎实。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑