UART串口通信的FPGA实现:协议解析、Verilog代码与调试经验
2026/9/8 6:37:35 网站建设 项目流程

UART串口通信FPGA实现,这算是我在FPGA路上做的第一个正经项目,也是很多初学者躲不开的坎。看起来不就是一根信号线拉高拉低吗?但真要把它调通,协议解析、波特率分频、状态机设计、异步信号处理、板级联调全得过一遍。做完这个小项目之后,你再去看SPI、I2C、甚至PCIe,会发现很多设计套路都是相通的。

这篇文章适合两类人:刚入门FPGA、准备从点灯进阶到通信协议的;以及单片机玩得很熟、但想知道FPGA怎么用纯逻辑实现串口收发的人。我会从UART协议的基础时序讲起,然后给出完整的发送、接收模块Verilog代码,最后聊一聊仿真和上板调试中我实际踩过的坑。保证你看完能干完活,也搞明白里面每个参数为什么这么定。

1. UART串口协议拆解:FPGA里要先搞懂帧格式与时序

很多人拿到UART项目第一反应是搜代码,这个思路不对。串口协议本身非常简单,但它的时序特征直接决定了FPGA内部模块怎么设计。先花十分钟把波形看懂,写代码就是顺水推舟的事。

1.1 帧格式:起始位、数据位、停止位怎么排

UART全称是Universal Asynchronous Receiver/Transmitter,异步收发器。所谓异步,就是发送端和接收端之间没有独立的时钟线,两端各用各的时钟,靠双方约定的波特率来保证节奏一致。

线在空闲状态下保持高电平。要开始发送一个字节时,先把线拉低一个位时间,这就是起始位。接收端正是通过检测这个下降沿来知道“数据要来了”,所以起始位是整帧的同步信号。起始位之后依次发送数据位,通常是8位,注意顺序是LSB first,也就是最低位先发。数据位之后可以加一个可选的校验位,最后是停止位,停止位必须拉高至少一个位时间,用来告诉接收端这一帧结束了。

最常用的格式是8N1,即8位数据、无校验(None)、1位停止位。一帧总共10个位时间:1个起始位 + 8个数据位 + 1个停止位。有的场景会用7E1、8O1、8N2,对FPGA实现来说区别不大,无非是状态机里多加一个校验状态。

帧格式起始位数据位校验位停止位常见场景
8N1181最常用
8E118偶校验1工业通信
8O118奇校验1旧式设备
8N2182抗干扰场景

1.2 波特率与时序:一位数据在线上持续多久

波特率就是每秒传输的比特数,单位是bps。9600bps和115200bps是最常见的两个档位。每一位在线上持续的时间是1/波特率:

  • 115200bps:每个位约8.68微秒,一帧8N1约86.8微秒
  • 9600bps:每个位约104.17微秒,一帧8N1约1041.7微秒

这个时间参数非常关键。FPGA设计UART时,本质就是用一个计数器,把系统时钟分频成“位时间”的节奏。比如系统时钟是50MHz,周期20纳秒,那么115200bps下每个位大约是434个时钟周期。发送端每434个时钟周期翻转一次数据线的状态,接收端同样以这个节奏去采样。

这里有个所有新手都会问的问题:既然是异步通信,发送端和接收端时钟频率不完全一致怎么办?答案是靠误差容忍。UART协议规定,只要收发双方波特率误差控制在2%到3%以内,一帧10个位累积下来的偏移不会超过半个位时间,接收端在数据位中心采样就不会出错。这也是为什么5600bps能通、4800bps不通这类问题,多半是波特率分频值算错了。

2. FPGA端整体架构:从数据流到波特率分频的计算

搞懂协议之后,再看FPGA内部应该怎么划分模块。UART的完整应用场景是:PC通过USB转串口工具发一个字节给FPGA,FPGA的接收模块把串行数据解析成并行8位数据,内部逻辑处理完,再由发送模块把并行数据转成串行数据发回PC。

2.1 顶层模块与数据流设计

整个设计可以分成三块:接收模块、发送模块、波特率生成模块。接收模块负责把RX引脚上的串行波形变成8位并行数据;发送模块负责把8位并行数据变成TX引脚上的串行波形;波特率生成模块负责给收发模块提供统一的位时间基准。

实际编码时,我更倾向于把波特率计数器直接做在收发模块内部,而不是单独拉出来。原因是收发两个模块对计数器的用法不同,发送端需要从起始位开始计数,接收端需要从检测到下降沿之后延迟半个位时间再采样,分开写更清晰,也便于后续单独仿真。

顶层模块的数据流是:外部串行数据进入uart_rx,解析完成后输出rx_data和rx_valid;内部逻辑(比如一个LED指示、一个FIFO、一个协议栈)拿到这个字节,需要回发时把数据给uart_tx,同时拉高tx_start信号;uart_tx把并行数据串行化之后从TX引脚输出。

2.2 波特率分频计数器的设计与误差计算

波特率分频的核心公式是:

分频计数值DIV = 系统时钟频率 / 波特率

以50MHz系统时钟、115200bps为例:50_000_000 / 115200 ≈ 434.03。计数器的做法是从0计数到DIV-1,产生一个周期等于位时间的脉冲,或者直接在这个计数范围内保持数据线状态不变。

这里要注意,DIV的取整误差会直接影响实际波特率。比如取434,实际波特率是50_000_000 / 434 ≈ 115207bps,与标准的115200相差约0.006%,完全没问题。但如果系统时钟是典型的27MHz、33MHz这类非整数倍频率,分频误差会更大,选型时要先算一笔账。

计算误差时不要只看一位的误差,要看整帧累积。8N1帧共10个位,最大允许偏差应为半个位时间,换算成百分比就是5%。实际经验是控制在1%以内最稳,因为对端PC的串口芯片也会有自己的晶振误差,两侧误差还会叠加。

2.3 为什么不在FPGA内部再生成一个波特率时钟

很多从单片机转过来的朋友,第一反应是用一个分频器生成115200Hz的时钟,然后用这个时钟去驱动发送和接收逻辑。这种做法在FPGA里要尽量避免。

原因有两个。第一,FPGA内部全局时钟网络的资源有限,用组合逻辑生成时钟会引起时钟偏斜和毛刺,时序收敛很难保证。第二,接收端需要在数据位中间采样,如果只是简单地用波特率时钟驱动寄存器,采样点的位置会落在位边界附近,一旦有干扰就容易采错。

更稳妥的方式是:所有寄存器仍然由系统时钟驱动,波特率分频器只产生一个“脉冲使能”信号,每到一个位时间就拉高一个时钟周期。发送和接收模块看到这个脉冲,就知道该切换数据位了。这个思路叫“时钟使能”或者“脉冲分频”,是FPGA开发里非常核心的时序设计技巧。

3. 发送与接收模块的Verilog实现,可直接抄作业

下面给出完整的发送和接收模块代码。风格上采用简单的三段式状态机,参数化系统时钟频率和波特率,换板子改参数就能用,不需要重新改逻辑。

3.1 发送模块:状态机怎么写才不飘

发送模块有4个状态:IDLE空闲、START起始位、DATA数据位、STOP停止位。空闲时TX线保持高电平,检测到tx_start有效就把数据锁存到移位寄存器,然后拉低TX进入起始位状态。数据位阶段每个位时间移出一位,LSB first。最后停止位拉高,回到空闲。

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 ); localparam DIV = CLK_FREQ / BAUD_RATE; localparam DIV_WIDTH = $clog2(DIV); reg [DIV_WIDTH-1:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] tx_shifter; reg [1:0] state; localparam IDLE = 2'd0; localparam START = 2'd1; localparam DATA = 2'd2; localparam STOP = 2'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx <= 1'b1; baud_cnt <= {DIV_WIDTH{1'b0}}; bit_cnt <= 3'd0; tx_shifter <= 8'd0; state <= IDLE; end else begin case (state) IDLE: begin tx <= 1'b1; baud_cnt <= {DIV_WIDTH{1'b0}}; bit_cnt <= 3'd0; if (tx_start) begin state <= START; tx_shifter <= tx_data; end end START: begin tx <= 1'b0; if (baud_cnt == DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; state <= DATA; end else begin baud_cnt <= baud_cnt + 1'b1; end end DATA: begin tx <= tx_shifter[0]; if (baud_cnt == DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; tx_shifter <= {1'b0, tx_shifter[7:1]}; if (bit_cnt == 3'd7) begin bit_cnt <= 3'd0; state <= STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end else begin baud_cnt <= baud_cnt + 1'b1; end end STOP: begin tx <= 1'b1; if (baud_cnt == DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; state <= IDLE; end else begin baud_cnt <= baud_cnt + 1'b1; end end default: state <= IDLE; endcase end end endmodule

这里有个要注意的地方:tx_start信号最好只保持一个时钟周期。如果外部逻辑一直拉高tx_start,状态机在数据发送完成回到IDLE后会立刻再次启动,导致连续发送同一个字节。所以调用方要做上升沿检测,或者用一个握手信号。

3.2 接收模块:下降沿检测与中间采样

接收模块比发送稍麻烦一点,因为RX引脚是外部异步信号,不能直接用状态机的时钟去采,必须先做同步处理,否则会产生亚稳态。代码里用rx_sync0和rx_sync1打两拍,把异步信号同步到系统时钟域,同时用组合逻辑检测下降沿。

采样策略采用“延迟半位 + 中间采样”:检测到下降沿后,先等半个位时间,确认这是真正的起始位中心,之后每隔一个位时间采样一次,正好落在每个数据位的正中间。这个方案在抗干扰和实现复杂度之间平衡得最好,也是大多数UART IP核采用的做法。

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 rx_valid, output reg [7:0] rx_data ); localparam DIV = CLK_FREQ / BAUD_RATE; localparam HALF_DIV = DIV / 2; localparam DIV_WIDTH = $clog2(DIV); reg [DIV_WIDTH-1:0] baud_cnt; reg [2:0] bit_cnt; reg [7:0] rx_shifter; reg [2:0] state; reg rx_sync0, rx_sync1; wire rx_negedge; localparam IDLE = 3'd0; localparam START = 3'd1; localparam DATA = 3'd2; localparam STOP = 3'd3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rx_sync0 <= 1'b1; rx_sync1 <= 1'b1; end else begin rx_sync0 <= rx; rx_sync1 <= rx_sync0; end end assign rx_negedge = rx_sync1 & ~rx_sync0; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; baud_cnt <= {DIV_WIDTH{1'b0}}; bit_cnt <= 3'd0; rx_shifter <= 8'd0; rx_valid <= 1'b0; rx_data <= 8'd0; end else begin rx_valid <= 1'b0; case (state) IDLE: begin baud_cnt <= {DIV_WIDTH{1'b0}}; bit_cnt <= 3'd0; if (rx_negedge) begin state <= START; end end START: begin if (baud_cnt == HALF_DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; state <= DATA; end else begin baud_cnt <= baud_cnt + 1'b1; end end DATA: begin if (baud_cnt == DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; rx_shifter <= {rx_sync1, rx_shifter[7:1]}; if (bit_cnt == 3'd7) begin bit_cnt <= 3'd0; state <= STOP; end else begin bit_cnt <= bit_cnt + 1'b1; end end else begin baud_cnt <= baud_cnt + 1'b1; end end STOP: begin if (baud_cnt == DIV - 1) begin baud_cnt <= {DIV_WIDTH{1'b0}}; state <= IDLE; rx_valid <= 1'b1; rx_data <= rx_shifter; end else begin baud_cnt <= baud_cnt + 1'b1; end end default: state <= IDLE; endcase end end endmodule

接收模块里我最想提醒的一点是:HALF_DIV如果遇到奇数分频会向下取整,这在极低波特率下会有微小的时间偏移,但在正常范围内不影响通信。真正影响通信的是DIV计算时取整误差太大,这个前面已经算过账了。

3.3 顶层回环验证:把TX和RX接在一起

写完收发两个模块后,最快的验证方式是做回环。把接收模块输出的rx_data和rx_valid直接接到发送模块的tx_data和tx_start,FPGA外部用一根杜邦线把TX和RX短接。这样PC发什么,FPGA就原样发回来,串口助手能看到回显就是通过。

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] data; wire valid; uart_rx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_rx ( .clk (clk), .rst_n (rst_n), .rx (rx), .rx_valid(valid), .rx_data (data) ); uart_tx #( .CLK_FREQ (CLK_FREQ), .BAUD_RATE (BAUD_RATE) ) u_tx ( .clk (clk), .rst_n (rst_n), .tx_start (valid), .tx_data (data), .tx (tx) ); endmodule

回环验证最大的价值是先把FPGA自身的收发逻辑跑通,把问题边界缩小。如果回环都不通,问题一定在FPGA内部;回环通了再连PC,串口线、驱动、电平的问题才有意义去查。

4. 仿真验证与板级调试实录:乱码、收不到、波特率不对的排查

代码写完之后,直接上板是最容易心态爆炸的。我强烈建议先仿真,再上板。仿真能把你逻辑里的结构性问题揪出来,上板时只需要面对真实世界的电气问题。

4.1 编写Testbench做仿真验证

仿真的核心思路是模拟一个外部串口设备:按照波特率时序,把并行数据变成串行波形灌进RX引脚。同时在FPGA侧发起一次发送,观察TX引脚波形是否符合协议。

一个实用的testbench可以这样写:用任务task生成一个Byte的串行波形,支持任意波特率。比如模拟发送0x55,这正好是01010101交替的字节,能直观看到波形翻转。用#延时模拟每个位的时间,例如115200bps下每位约8.68微秒。发送时先拉低一个位时间作为起始位,然后依次输出8个数据位,LSB first,最后拉高一个位时间作为停止位。

仿真时重点看两个地方:一是rx_valid脉冲是否在停止位结束后拉高,且rx_data是否是预期字节;二是观察状态机的切换是否按IDLE→START→DATA→STOP顺序执行。如果发现rx_valid一直不来,优先检查分频参数和时钟频率是否匹配。

另外建议仿真时故意输入一个错误的波特率,比如实际发送的是9600bps的数据,但模块配置成115200,观察接收到的数据是不是乱码。这能帮你建立“波特率不匹配会产生什么现象”的直觉。

4.2 上板实测:接线、电平与串口工具

仿真通过之后就可以上板。这里有个容易忽略的问题:FPGA引脚和PC之间一般不能直接连RS232。PC串口是RS232电平,正负电压范围在正负3V到15V之间,FPGA引脚是TTL/CMOS电平,一般是3.3V。直接用会烧引脚。

常见的做法是用USB转TTL模块,比如CP2102、CH340、FT232R这类芯片。这些模块把USB信号转成TTL电平的串口信号,直接和FPGA的3.3V引脚相连。接线时注意交叉连接:模块的TX接FPGA的RX,模块的RX接FPGA的TX,GND必须共地。

我在实际调试中遇到最多的低级错误就是地线没接。USB转TTL模块和FPGA开发板各自独立供电,如果不共地,两者的电平参考点不一致,收发数据必然是乱码或者完全收不到。所以上板第一步一定是检查共地。

开发板自带的USB转串口芯片也需要正确安装驱动。Windows系统一般会自动识别CH340和CP2102,FT232R有时需要手动安装驱动。装好驱动后,用串口助手打开对应COM口,波特率、数据位、停止位、校验位要和FPGA内部配置完全一致。

另外提一个场景:宿主机是Windows,虚拟机里跑Linux,想通过串口与虚拟机内的Linux通信。方法是把USB转串口设备在虚拟机软件中直通给虚拟机,或者使用VMware的串口映射功能,把宿主机物理串口映射为虚拟机内的/dev/ttyS0。然后在虚拟机里用minicom或screen打开串口设备。注意物理串口被宿主机占用时虚拟机无法读取,要避免同时打开同一个COM口。

4.3 常见问题排查实录

我做这个项目时调了一晚上,最后发现只是分频参数少写了一个零。这种低级错误其实很常见,整理几个典型问题供你对照排查。

现象可能原因排查方法
完全收不到数据地线没接、RX/TX接反、引脚约束错误回环测试排除FPGA内部问题,用万用表量电平
收到乱码波特率不匹配、分频参数算错、电平不稳用示波器或逻辑分析仪抓波形,数位时间
9600能通4800不通分频值溢出、对端串口配置不一致检查DIV计算,确认4800的计数常数是否被优化掉
发一个字节收到多个tx_start保持时间过长改为单周期脉冲触发,或加握手逻辑
数据偶尔错一位采样点靠近位边沿、干扰毛刺改用16倍过采样,增加起始位确认
单片机通信正常但PC不行USB转TTL模块驱动异常、串口被占用更换串口工具,检查设备管理器

“9600能通4800不通”这个问题值得单独说。这类问题多半不是FPGA逻辑有问题,而是分频参数设置不合理。比如50MHz时钟下,4800bps的DIV是10416,这个值很大,如果你定义的分频计数器位宽不够,高位就被截断,实际分频数完全不对,通信自然失败。还有一种情况是代码里参数化做得不好,改波特率时只改了发送端没改接收端,或者反过来。

4.4 调通之后还能往哪个方向做

回环跑通只是第一步。想让这个UART模块真正可用,还可以在现有基础上做几件事。

第一是加FIFO缓冲。回环场景里rx_valid一来就发,逻辑简单,但真实应用中上位机下发一串数据时,接收端处理速度可能跟不上,需要一个FIFO做速率匹配。Xilinx和Intel都有免费的FIFO IP核,用异步FIFO可以顺便解决跨时钟域问题。

第二是增加校验位支持。8N1没有校验,但有些工业现场协议会要求偶校验或奇校验,这需要在状态机里加一个PARITY状态,在数据位和停止位之间插入校验位。

第三是RS485方向控制。RS485是半双工总线,发送时要把方向引脚拉高,发送完拉低。把UART发送模块的IDLE状态和STOP状态做一个方向信号输出,就能直接在现有模块上扩展。

第四是提高抗干扰能力。目前的接收模块只做了一次采样,可以改成16倍过采样,在每个数据位内采样16次,用多数表决或中间三次采样取多数的方式判决,能明显增强抗噪能力。这个优化在实际工业环境中非常有效。

如果在调试过程中手边有逻辑分析仪或者FPGA内嵌的逻辑分析仪ILA,一定要用起来。有一次我怎么都调不通,后来用ILA抓到内部状态机,发现是复位信号在板子启动过程中出现毛刺,导致状态机跳到了default分支。这种问题靠肉眼盯代码根本盯不出来,抓波形一秒钟就明白了。

我个人在做这个项目时最大的体会是:UART虽然简单,但它逼着你把同步设计、计数器思想、状态机写法、异步信号处理这些FPGA基本功全部过了一遍。做完以后再学SPI和I2C,明显轻松很多。如果你正在被UART调不通折磨,别灰心,按照先仿真后上板、先回环后接PC的顺序一步步来,大部分问题半天之内都能解决。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询