简介:这份资源面向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% 就能稳定通信。
| 系统时钟 | 目标波特率 | 分频系数 | 实际波特率 | 误差 |
|---|---|---|---|---|
| 50MHz | 9600 | 5208 | 9601 | 0.01% |
| 50MHz | 115200 | 434 | 115207 | 0.006% |
| 50MHz | 921600 | 54 | 925926 | 0.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 <= 1'b1; tx_busy <= 1'b0; clk_cnt <= 0; bit_cnt <= 0; shift_reg<= 10'h3FF; // 空闲全高 sending <= 1'b0; end else begin if (!sending) begin tx <= 1'b1; tx_busy <= 1'b0; if (tx_start) begin // 组装一帧:停止位(1) + 数据位(8) + 起始位(0) shift_reg <= {1'b1, tx_data, 1'b0}; sending <= 1'b1; tx_busy <= 1'b1; 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 <= {1'b1, shift_reg[9:1]}; if (bit_cnt == 9) begin // 10位发完 sending <= 1'b0; tx_busy <= 1'b0; 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 <= 1'b1; rx_d1 <= 1'b1; 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 <= 1'b0; rx_done <= 1'b0; clk_cnt <= 0; bit_cnt <= 0; data_temp <= 8'h00; rx_data <= 8'h00; end else begin rx_done <= 1'b0; // 默认拉低,只脉冲一拍 if (!receiving) begin if (rx_d1 == 1'b0) begin // 检测起始位下降沿 receiving <= 1'b1; 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 <= 1'b1; receiving <= 1'b0; 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 = 1'b0; #(BIT_PERIOD); // 起始位 for (i = 0; i < 8; i = i + 1) begin rx = data[i]; #(BIT_PERIOD); // 数据位 LSB 先发 end rx = 1'b1; #(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 == 1'b1); 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 写一遍串口回环是最短路径。代码量不大,但覆盖的知识点足够扎实。希望帮到你。
本文还有配套的精品资源,点击获取