资讯动态

FPGA串口通信实现:从Verilog设计到系统集成的完整指南

发布时间:2026/8/29 8:18:50 来源:尧图企业网站定制
1. 项目概述为什么要在FPGA上实现串口通信如果你接触过嵌入式开发无论是单片机还是Linux系统串口通信UART几乎是你第一个要打交道的通信接口。它简单、古老却无处不在是调试、配置、数据交换的基石。那么当我们的平台从现成的MCU转向可编程逻辑门阵列FPGA时为什么还要不厌其烦地自己用Verilog或VHDL去实现一个串口模块直接调用一个硬核IP不是更省事吗这正是问题的关键也是这个项目的核心价值所在。在FPGA上设计串口通信远不止是实现一个通信功能那么简单。首先它是对数字逻辑设计基本功的一次绝佳锤炼。你需要亲手构建波特率发生器、移位寄存器、有限状态机FSM这些核心部件深刻理解数据从并行到串行、再同步接收还原的每一个时钟周期细节。这种理解是调用黑盒IP永远无法获得的。其次它赋予了设计极大的灵活性。你可以定制任何非标准的波特率设计带奇偶校验、多停止位、甚至自定义协议的变种串口或者轻松实现一个FPGA板卡与多个外部设备如多个传感器、显示屏、MCU同时通信的多串口系统而无需担心硬件UART资源不够。最后它是构建更复杂系统的起点。一个稳定可靠的串口模块可以作为FPGA与上位机PC、或其他处理器如STM32进行调试信息输出、参数配置、数据上传的“桥梁”是后续开发图像处理、信号分析、高速接口等高级应用的必备基础设施。因此这个项目适合所有希望深入理解数字通信底层逻辑的FPGA初学者以及需要在特定项目中定制化串口功能的中级开发者。通过亲手实现你收获的将不仅仅是一个能用的串口而是一套应对未来更复杂时序逻辑设计的方法论。2. 核心设计思路与方案选型在FPGA中实现串口本质上是在数字域模拟一个异步串行通信的物理过程。我们需要解决几个核心问题如何产生精确的波特率时钟如何将并行的数据字节转换成一位位的串行比特流发送出去又如何从看似连续的信号流中准确地识别出一个字节的开始、数据和结束2.1 整体架构设计一个完整的UART收发系统通常包含以下几个关键模块波特率时钟生成模块Baud Rate Generator这是整个系统的“心跳”。它从FPGA的主时钟如50MHz分频产生一个频率为16倍波特率16x Baud Rate的时钟信号。采用16倍过采样的目的是为了在接收端更精确地定位数据位的中心点提高抗干扰能力。发送模块UART Transmitter负责将输入的并行数据如8位转换为串行数据流。它内部包含一个移位寄存器和一个控制状态机。当收到发送指令后状态机控制移位寄存器先送出一个起始位低电平然后依次移出数据位LSB先行或MSB先行最后送出停止位高电平。接收模块UART Receiver这是设计中的难点。它需要持续监视串行输入线。一旦检测到起始位从高到低的跳变就启动波特率时钟计数器在数据位的理论中心点进行采样并将采样值移入接收移位寄存器。完成一个字节的接收后输出有效的并行数据和就绪信号。顶层封装与接口模块Top Module将发送和接收模块例化并对外提供简单的并行数据接口如数据总线、写使能、读使能、忙标志、数据就绪标志以及一对串行信号线Tx, Rx。2.2 关键方案选型与考量1. 过采样率选择为何是16倍常见的过采样率有16倍和8倍。16倍是工业标准因为它为定位数据位中心提供了充足的裕量。假设在每位数据的第7、8、9个采样点进行“三取二”或多数判决可以有效滤除信号线上的毛刺。对于低波特率或高噪声环境16倍过采样能显著提升可靠性。虽然这会消耗更多的逻辑资源计数器位宽更大但在现代FPGA资源面前微不足道。因此除非资源极端紧张否则一律推荐使用16倍过采样设计。2. 状态机设计米勒型Mealy还是摩尔型Moore对于发送模块输出Tx线直接取决于当前状态例如“发送数据位”状态和当前计数值这更接近米勒型但用摩尔型实现也很清晰。对于接收模块关键决策如“是否检测到起始位”、“是否在数据位中心采样”依赖于输入信号Rx线的跳变这通常需要引入米勒型的特性。在实际编码中我倾向于使用“三段式”状态机描述将状态转移逻辑、状态寄存器更新和输出逻辑分离。对于接收模块可以定义一个独立的“起始位检测”组合逻辑其输出作为状态机转移的条件之一这样结构更清晰。3. 数据帧格式定制标准格式是1位起始位、8位数据位、1位停止位无校验。但我们的FPGA设计可以轻松扩展。例如增加奇偶校验位生成与检查逻辑支持可配置的数据位宽5-9位甚至支持1.5或2个停止位。在顶层模块通过参数Verilog中的parameter或VHDL中的generic来定义这些格式可以使模块高度可重用。注意在项目初期建议先实现最基础的8N18数据位无校验1停止位格式并确保稳定。扩展功能可以作为后续优化迭代加入避免一开始就陷入复杂逻辑的调试泥潭。3. 核心模块详解与Verilog实现下面我们以最常用的8N1格式、16倍过采样为例拆解发送和接收模块的关键代码与设计要点。假设系统时钟clk为50MHz目标波特率BAUD_RATE为115200。3.1 波特率时钟生成模块这个模块的核心是一个计数器用于从系统时钟分频得到波特率时钟baud_x16_clk或其使能信号。我们通常不直接生成时钟而是生成一个周期性的高脉冲使能信号这更符合同步设计规范避免使用衍生时钟。module baud_rate_gen ( input wire clk, // 50MHz主时钟 input wire rst_n, // 低电平复位 output reg baud_en // 波特率使能信号16倍波特率频率 ); // 计算分频计数值50,000,000 / (115200 * 16) ≈ 27.13 - 取整27 parameter CLK_FREQ 50_000_000; parameter BAUD_RATE 115200; localparam BAUD_X16 BAUD_RATE * 16; localparam CNT_MAX CLK_FREQ / BAUD_X16 - 1; // 计数器最大值 reg [15:0] cnt; // 计数器位宽根据CNT_MAX调整 always (posedge clk or negedge rst_n) begin if (!rst_n) begin cnt 0; baud_en 1b0; end else begin if (cnt CNT_MAX) begin cnt 0; baud_en 1b1; // 计数到最大值时产生一个时钟周期的高脉冲 end else begin cnt cnt 1; baud_en 1b0; end end end endmodule关键点CNT_MAX的计算必须取整这会引入一定的波特率误差。误差率计算公式为|实际频率 - 目标频率| / 目标频率。对于50MHz和115200波特率误差约为0.16%远小于UART允许的误差容限通常3%完全可行。如果追求极致精度可以考虑使用FPGA内部的锁相环PLL进行小数分频。3.2 发送模块UART Transmitter设计与实现发送模块是一个典型的状态机其状态包括空闲IDLE、发送起始位START、发送数据位DATA、发送停止位STOP。module uart_tx ( input wire clk, input wire rst_n, input wire baud_en, // 来自波特率生成模块的使能 input wire [7:0] tx_data, // 待发送的并行数据 input wire tx_start, // 发送启动信号高有效 output reg tx_done, // 发送完成标志高脉冲 output reg tx_pin // 串行输出引脚 ); // 状态定义 localparam S_IDLE 2b00; localparam S_START 2b01; localparam S_DATA 2b10; localparam S_STOP 2b11; reg [1:0] state, next_state; reg [3:0] bit_cnt; // 计数已发送的数据位数 reg [7:0] data_reg; // 发送数据寄存器 reg [3:0] baud_cnt; // 波特率周期计数器0-15 // 状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else if (baud_en) state next_state; // 状态仅在波特率使能时更新 end // 下一状态逻辑与输出逻辑 always (*) begin next_state state; tx_done 1b0; case (state) S_IDLE: begin tx_pin 1b1; // 空闲时为高电平 if (tx_start) begin next_state S_START; data_reg tx_data; // 锁存发送数据 end end S_START: begin tx_pin 1b0; // 起始位为低电平 if (baud_en) begin // 起始位持续一个波特率周期 next_state S_DATA; bit_cnt 0; end end S_DATA: begin tx_pin data_reg[0]; // 先发送LSB if (baud_en) begin if (bit_cnt 7) begin // 已发送8位数据 next_state S_STOP; end else begin data_reg {1b0, data_reg[7:1]}; // 数据右移一位 bit_cnt bit_cnt 1; end end end S_STOP: begin tx_pin 1b1; // 停止位为高电平 if (baud_en) begin tx_done 1b1; // 产生完成脉冲 next_state S_IDLE; end end endcase end endmodule设计要点数据移位采用data_reg {1b0, data_reg[7:1]}实现右移每次将最低位data_reg[0]赋给tx_pin。这是LSB先行的标准做法。状态转换时机所有状态转换和内部计数都严格在baud_en有效时进行确保了每个位起始、数据、停止的持续时间精确为一个波特率周期。tx_done信号在停止位结束时产生一个时钟周期的高脉冲通知上层逻辑发送完成可以准备下一个字节。这个信号对于流控制非常重要。3.3 接收模块UART Receiver设计与实现接收模块是设计的难点核心在于起始位的可靠检测和数据位的精确采样。module uart_rx ( input wire clk, input wire rst_n, input wire baud_en, // 16倍波特率使能 input wire rx_pin, // 串行输入引脚 output reg [7:0] rx_data, // 接收到的并行数据 output reg rx_ready // 数据就绪信号高脉冲 ); // 状态定义 localparam S_IDLE 2b00; localparam S_START 2b01; localparam S_SAMPLE 2b10; localparam S_STOP 2b11; reg [1:0] state, next_state; reg [3:0] sample_cnt; // 采样点计数器0-15 reg [3:0] bit_cnt; // 已接收数据位计数器 reg [7:0] data_reg; // 接收移位寄存器 reg rx_sync1, rx_sync2; // 两级同步器用于消除亚稳态 // 输入同步非常重要将异步的rx_pin信号同步到clk时钟域 always (posedge clk or negedge rst_n) begin if (!rst_n) {rx_sync2, rx_sync1} 2b11; else {rx_sync2, rx_sync1} {rx_sync1, rx_pin}; end // 状态寄存器更新 always (posedge clk or negedge rst_n) begin if (!rst_n) state S_IDLE; else if (baud_en) state next_state; end // 下一状态逻辑与输出逻辑 always (*) begin next_state state; rx_ready 1b0; case (state) S_IDLE: begin sample_cnt 0; bit_cnt 0; // 检测起始位同步后的信号出现下降沿从1到0 if (baud_en rx_sync21b1 rx_sync11b0) begin next_state S_START; end end S_START: begin // 在起始位期间计数目标是在起始位的中心点第8个采样点确认 if (baud_en) begin if (sample_cnt 7) begin // 到达起始位中点 // 再次确认是低电平以防毛刺 if (rx_sync1 1b0) begin next_state S_SAMPLE; sample_cnt 0; // 重置为采样数据位做准备 end else begin next_state S_IDLE; // 不是有效的起始位回到空闲 end end else begin sample_cnt sample_cnt 1; end end end S_SAMPLE: begin if (baud_en) begin // 在每个数据位的中心点第8个采样点进行采样 if (sample_cnt 7) begin data_reg {rx_sync1, data_reg[7:1]}; // 采样值移入寄存器高位LSB最后接收 if (bit_cnt 7) begin // 已接收8位数据 next_state S_STOP; end else begin bit_cnt bit_cnt 1; end sample_cnt 0; end else begin sample_cnt sample_cnt 1; end end end S_STOP: begin // 在停止位期间可以不做采样仅用于计时 if (baud_en) begin // 等待停止位结束一个波特率周期 if (sample_cnt 15) begin rx_ready 1b1; // 产生数据就绪脉冲 rx_data data_reg; // 输出数据 next_state S_IDLE; end else begin sample_cnt sample_cnt 1; end end end endcase end endmodule设计要点与避坑指南同步器Synchronizerrx_pin是来自外部世界的异步信号必须通过两级D触发器rx_sync1,rx_sync2同步到FPGA的内部时钟域clk这是消除亚稳态风险的黄金法则。后续所有逻辑都应使用同步后的信号rx_sync1或rx_sync2。起始位检测与确认仅在S_IDLE状态检测到下降沿就进入S_START状态是不够的。我增加了在起始位中点第8个采样点再次确认信号为低电平的逻辑。如果此时为高则认为是毛刺干扰返回S_IDLE。这极大地增强了抗干扰能力。采样点的选择在S_SAMPLE状态当sample_cnt计数到7时正处于一个数据位的理论中心位置此时采样最稳定。采样值被移入data_reg的最高位因为我们是先接收LSB所以经过8次采样后data_reg中存储的就是正确的字节顺序。rx_ready信号在S_STOP状态结束时产生一个时钟周期的高脉冲表示一个字节接收完毕且数据有效。上层逻辑应在检测到这个脉冲时读取rx_data。实操心得接收模块的调试是难点中的难点。一个非常有效的方法是在仿真中不仅观察rx_data还要深入观察内部状态state、sample_cnt、bit_cnt和data_reg的变化。使用ModelSim或Vivado Simulator将波特率设置得比实际快很多比如用9600波特率仿真115200的逻辑可以快速看到多个字节的接收过程。确保在每个数据位的中心点采样值是正确的。4. 系统集成、仿真与上板调试4.1 顶层模块集成与测试平台搭建将上述模块集成到一个顶层文件中并添加必要的接口逻辑例如一个发送FIFO先入先出存储器或寄存器以及一个控制状态机。module uart_top #( parameter CLK_FREQ 50_000_000, parameter BAUD_RATE 115200 )( input wire clk, input wire rst_n, // 并行数据接口类似内存映射接口 input wire [7:0] wr_data, input wire wr_en, // 写使能触发发送 output wire tx_busy, // 发送忙标志 output wire [7:0] rd_data, output wire rd_ready, // 接收数据就绪标志 input wire rd_ack, // 接收数据确认清零rd_ready // 串行物理接口 output wire uart_tx, input wire uart_rx ); wire baud_en; wire tx_done; wire rx_ready_internal; reg [7:0] rx_data_buf; baud_rate_gen #(.CLK_FREQ(CLK_FREQ), .BAUD_RATE(BAUD_RATE)) u_baud_gen(.clk(clk), .rst_n(rst_n), .baud_en(baud_en)); uart_tx u_tx(.clk(clk), .rst_n(rst_n), .baud_en(baud_en), .tx_data(wr_data), .tx_start(wr_en), .tx_done(tx_done), .tx_pin(uart_tx)); assign tx_busy !(u_tx.state u_tx.S_IDLE); // 简化忙信号判断 uart_rx u_rx(.clk(clk), .rst_n(rst_n), .baud_en(baud_en), .rx_pin(uart_rx), .rx_data(rx_data_buf), .rx_ready(rx_ready_internal)); // 接收数据缓冲与就绪标志处理 reg rd_ready_reg; assign rd_ready rd_ready_reg; assign rd_data rx_data_buf; always (posedge clk or negedge rst_n) begin if (!rst_n) begin rd_ready_reg 1b0; end else begin if (rx_ready_internal) begin rd_ready_reg 1b1; // 接收到新数据置位就绪标志 end else if (rd_ack) begin rd_ready_reg 1b0; // 上层读取数据后清除就绪标志 end end end endmodule接下来编写一个测试平台Testbench来验证整个系统。测试平台需要模拟上位机行为向UART发送模块写入数据并检查接收模块是否能正确回环。timescale 1ns / 1ps module tb_uart_top(); reg clk; reg rst_n; reg [7:0] wr_data; reg wr_en; wire tx_busy; wire [7:0] rd_data; wire rd_ready; reg rd_ack; wire uart_tx; reg uart_rx; // 在testbench中我们将tx连接到rx进行回环测试 uart_top uut (.*); // 连接所有端口 // 生成50MHz时钟 always #10 clk ~clk; // 20ns周期 - 50MHz initial begin clk 0; rst_n 0; wr_en 0; rd_ack 0; uart_rx 1; // 空闲状态为高电平 #100 rst_n 1; // 释放复位 // 测试1发送单个字节 0x55 (01010101b) #200; wr_data 8h55; wr_en 1; #20 wr_en 0; // 产生一个脉冲 wait (tx_busy 0); // 等待发送完成 // 将发送端tx信号“连接”到接收端rx实现回环 // 在实际中你需要一个外部的环回或通过虚拟COM端口测试。 // 这里为了简单我们直接赋值在真实物理测试中不可行。 // 更严谨的测试平台需要模拟一个外部的UART主机。 // uart_rx uart_tx; // 简单回环仅用于逻辑初步检查 // 更真实的测试模拟一个外部设备发送数据给FPGA的RX #1000; fork begin // 任务模拟发送字节 0xAA uart_rx 1; // 空闲 #8680; // 等待一个字节时间 (1/115200 * 10 ≈ 8680ns) uart_rx 0; // 起始位 #8680; uart_rx 0; // bit0 LSB #8680; uart_rx 1; // bit1 #8680; uart_rx 0; // bit2 #8680; uart_rx 1; // bit3 #8680; uart_rx 0; // bit4 #8680; uart_rx 1; // bit5 #8680; uart_rx 0; // bit6 #8680; uart_rx 1; // bit7 MSB #8680; uart_rx 1; // 停止位 end begin // 任务监控接收端 wait (rd_ready 1); $display(Time%t: Received data: 0x%h, $time, rd_data); if (rd_data 8hAA) $display(PASS: Data match!); else $display(FAIL: Data mismatch!); rd_ack 1; #20 rd_ack 0; end join #10000; $finish; end endmodule4.2 上板调试与实测技巧仿真通过后就可以进行上板调试了。这是理论通往实践的关键一步。引脚分配与约束在FPGA厂商的开发工具如Xilinx Vivado、Intel Quartus中将顶层模块的uart_tx和uart_rx信号分配到开发板上真实的物理引脚。这些引脚通常通过一个“USB转UART”芯片如CH340、CP2102、FT232与电脑连接。务必查阅开发板原理图找到正确的引脚号。使用ILA进行在线调试这是FPGA开发中最强大的调试手段。在Vivado中你可以将ILA集成逻辑分析仪IP核插入到你的设计中抓取uart_tx、uart_rx、内部状态机信号、baud_en、rx_ready等关键信号。上板后通过JTAG连接你可以像示波器一样实时观察这些信号的波形直观地看到数据是否被正确发送和接收。与PC通信测试发送测试编写一个简单的FPGA测试程序循环发送固定的数据如递增的0-255。在PC端使用串口调试助手如SecureCRT、Putty、或者开源的RealTerm、CoolTerm设置正确的波特率、数据位、停止位无流控。你应该能看到接收区持续收到对应的字符或十六进制数据。接收测试在串口调试助手的发送区输入字符或十六进制数据并发送。在FPGA端你可以通过ILA观察rx_data和rx_ready信号或者设计一个简单的回环逻辑将接收到的数据直接赋值给发送模块并立即发送出去。如果PC端能收到自己发送的数据说明收发通路完全正确。波特率容错测试尝试在PC端将波特率设置为一个略有误差的值例如FPGA设计为115200PC端设为115000或115400测试通信是否还能成功。一个健壮的设计应该能容忍小范围的波特率偏差。5. 常见问题、性能优化与扩展思路5.1 典型问题排查速查表现象可能原因排查思路与解决方法PC端收不到任何数据1. 引脚分配错误。2. 波特率不匹配。3. USB转串口驱动未安装或端口号错误。4. FPGA程序未成功加载。1. 双查原理图与约束文件。2. 用示波器或ILA测量uart_tx引脚看是否有波形。计算波形周期是否对应目标波特率如115200波特率位周期约8.68us。3. 检查设备管理器中的COM口确保串口助手选择正确。4. 确认编程成功尝试用LED闪烁等简单程序验证系统运行。收到乱码1. 波特率误差过大。2. 数据位、停止位设置不匹配。3. 发送端数据移位顺序LSB/MSB错误。4. 时钟频率如50MHz计算错误。1. 精确计算分频计数器值或使用PLL。2. 确认FPGA设计与串口助手设置一致通常为8N1。3. 检查发送模块的移位代码标准UART是LSB先行。4. 核对顶层模块的CLK_FREQ参数是否与开发板实际晶振一致。只能接收第一个字节后续字节丢失1. 接收状态机未能正确返回S_IDLE。2.rx_ready信号未被上层及时响应和清除。3. 起始位检测过于敏感将数据误判为起始位。1. 仿真中观察接收状态机完整流程确保在停止位后回到S_IDLE。2. 检查顶层模块中rd_ready和rd_ack的握手逻辑。3. 强化起始位检测如要求持续多个采样点为低才确认。通信不稳定时好时坏1. 信号完整性差有毛刺。2. 未进行异步信号同步。3. 电源噪声大。1. 检查PCB走线确保UART信号线远离高速时钟线。可在FPGA输出端使用低速IO标准。2.绝对确保rx_pin信号经过了至少两级同步器。3. 测量电源电压纹波必要时增加去耦电容。高波特率如921600下出错1. 时序约束不满足。2. 组合逻辑路径过长。1. 对设计添加正确的时序约束create_clock,set_input_delay等。2. 对baud_en等关键信号使用寄存器打拍优化关键路径。5.2 性能优化与高级扩展一个基础可靠的串口模块建成后你可以在此基础上进行多种优化和扩展使其更实用、更强大。添加FIFO缓冲这是最实用的升级。为发送和接收模块分别添加一个FIFO可以使用FPGA厂商的IP核或用寄存器自己实现一个小深度FIFO。发送FIFO可以缓存上层应用快速产生的数据避免丢失接收FIFO可以缓存连续到来的数据让上层应用有更宽松的时间处理。FIFO的“空”、“满”、“可读数据量”等状态标志能极大简化流控制。实现硬件流控除了软件流控XON/XOFF可以实现硬件流控RTS/CTS。当接收FIFO快满时通过拉低CTS信号通知发送方暂停发送反之亦然。这需要增加两个物理引脚和对应的控制逻辑。支持多波特率与参数化将波特率分频计数值、数据位宽、停止位数、奇偶校验类型等全部设计为顶层parameter。这样一个模块就能通过例化时传递不同参数适应各种应用场景。构建多串口系统FPGA的逻辑资源可以轻松例化多个独立的UART模块。你可以设计一个“UART控制器”内部包含多个上述的UART通道通过一个统一的寄存器接口如AXI4-Lite进行配置和数据访问。这对于需要连接多个串口设备如多个传感器、多个显示屏的应用非常有用。与软核处理器集成如果你在FPGA中使用了像MicroBlaze或Nios II这样的软核CPU可以将这个UART模块作为其外设挂载到总线上。通过编写对应的驱动程序操作系统如FreeRTOS或裸机程序就可以像使用标准外设一样使用printf、scanf等功能极大提升开发效率。从在FPGA上实现一个简单的串口通信开始你踏出的每一步——理解状态机、处理异步信号、进行时序约束、集成FIFO、实现总线接口——都是在为更复杂的数字系统设计积累宝贵的经验。这个看似简单的项目是连接数字逻辑世界与真实物理世界的一座坚实桥梁也是你FPGA工程师之路上一个重要的里程碑。当你第一次通过自己编写的Verilog代码在电脑屏幕上看到从FPGA发来的“Hello World”时那种成就感是调用现成库函数无法比拟的。

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

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

免费获取报价