简介:GW2A-LV18PG256C8实现UART串口通信的Verilog HDL驱动包,面向紫光同创FPGA开发者,解决在GW2A系列器件上快速搭建串口通信链路的问题。压缩包共62个文件,约693KB,核心包含UART发送与接收模块、顶层回环逻辑等Verilog源码,配合时序约束、引脚约束、工程文件与仿真脚本,可完整覆盖代码编写、综合布局布线与仿真验证流程,便于直接移植到同系列项目。包内仿真目录附带一键启动脚本与波形记录,综合实现目录保存过程文件,目录分层清晰,适合参考学习完整FPGA工程结构。目前已有131人学习下载,对于正在学习紫光同创FPGA或需要快速集成UART外设的开发者,是一份可编译运行、少走弯路的实用参考,也可作为学校课程设计或毕业设计的入门模板。
1. 在GW2A-LV18PG256C8上跑串口,先要迈过时钟和电平两道门槛
UART串口通信算是FPGA入门里最适合当骨架练习的外设,协议不复杂,两三个状态机加一个计数器就能收发字节。但真把代码写好后烧进GW2A-LV18PG256C8,很多人卡住的地方反而在协议之外:系统时钟是多少、分频系数怎么算、IO电平能不能直接怼到USB转串口芯片上。GW2A系列是低功耗内核(LV表示低压),Bank电压和IO标准都由板卡原理图决定,这意味着同一份Verilog在别的板子上能跑,在这颗芯片上可能要改时钟分频参数、改引脚约束甚至加电平转换。这篇文章就按我实际调过的路径,把Verilog HDL驱动UART的发送、接收、仿真、上板排错讲完,顺带给出一个自动波特率检测的进阶模块。
2. UART帧格式与波特率分频:先把GW2A-LV18PG256C8的串口时序算清楚
2.1 一帧数据的四个部分:起始位、数据位、校验位、停止位
UART是异步串行协议,线上没有时钟,收发双方靠约定的波特率把每一位时间对齐。空闲时TX/RX线保持高电平,发送端先拉低一个位时间作为起始位,随后按LSB在前逐个送出数据位,最后拉高一位停止位。校验位可以不要,对FPGA实现来说,去掉校验反而能让状态机更直观。
一帧波形从高到低的变化是接收端唯一的同步信号。起始位下降沿意味着“时钟边界从这里重新开始”,之后每一位的中心点就是采样点。约定好8位数据、无校验、1位停止位(8N1)是最常见的配置,调试串口助手时也要把这个配置和FPGA端对齐。如果两边帧格式不一致,比如FPGA发8位但上位机配成7位数据,收到的字节就会整体错位。
2.2 分频计数器怎么定:系统时钟、波特率与误差预算
FPGA内部没有专用的UART时钟,必须用系统时钟分频产生位时间。设系统时钟为CLK_FREQ,目标波特率为BAUD_RATE,则每个位宽对应的时钟周期数近似为:
localparam BAUD_DIV = CLK_FREQ / BAUD_RATE;以常见的27MHz板载晶振为例,表里给出几组典型值。参数化设计时直接用CLK_FREQ和BAUD_RATE两个参数,分频系数由综合器算出来,可读性更好。
| 系统时钟 | 波特率 | 位宽计数BAUD_DIV | 实际波特率 | 误差 |
|---|---|---|---|---|
| 27 MHz | 9600 | 2812 | 9601.7 | 0.018% |
| 27 MHz | 57600 | 468 | 57692.3 | 0.16% |
| 27 MHz | 115200 | 234 | 115384.6 | 0.16% |
| 50 MHz | 115200 | 434 | 115207.4 | 0.006% |
| 50 MHz | 921600 | 54 | 925925.9 | 0.47% |
UART接收端对波特率误差有一定容忍度,只要一个字节(约10个位时间)内累积误差不超过半个位宽,采样点不会漂出数据位。表格里的误差都远小于这个预算,所以直接用整数除法取整是安全做法。需要注意,如果系统时钟不是整数倍关系,比如27MHz配115200,BAUD_DIV=234会让实际波特率偏高0.16%,这个量级不会导致通信失败,但不要在这个误差基础上再叠加多次取整。
提示:如果你把两个模块分别用
BAUD_DIV一半的计数器去产生脉冲,就会变成在两个半位处各触发一次,状态机会多出一拍。最稳妥的做法是只做一个位宽计数器,在计数满时拉一个一周期宽的bit_tick脉冲,所有状态机都用这个脉冲驱动。
2.3 接收端为什么不能只在数据线上采样
刚写UART的人最容易犯的错是:看到RX线有跳变就立刻锁存数据。RX线上的毛刺、按键抖动、电平转换芯片的边沿噪声都可能被当成起始位。接收端必须做两级同步和边沿检测,先消除跨时钟域的亚稳态风险,再识别真正的下降沿。
GPIO输入的信号先打两拍同步到系统时钟域,然后用rx_d1 && !rx_d2检测下降沿。起始位确认后也不是马上采样,而要等半个位时间再取一次电平。如果半个位时间后的电平仍然是低,说明刚才确实是起始位,随后在每个数据位的正中间采样。这就是中点采样,它能最大程度避开码间串扰和边沿抖动的区间。
3. Verilog HDL发送通路:用状态机把UART数据线拉出来
3.1 发送状态机的五个状态
发送方向比接收简单,状态机只需要在正确的时刻把TX引脚置成对应电平。定义五个状态:IDLE、START、DATA、STOP、DONE。空闲时TX保持高电平,软件(或上游逻辑)置tx_valid后,状态机在下一个bit_tick拉低TX作为起始位,随后进入DATA状态,按位从LSB开始移位输出。
DONE状态不是必须的,但加一拍能让tx_ready信号的时序更干净。我习惯在停止位中间就把tx_ready拉高,这样连续发送多个字节时不需要额外等待,上游逻辑看到tx_ready为高就可以在下一拍发起新一轮发送,流水线间隙只有1拍。
3.2 完整uart_tx模块代码与握手信号说明
下面是一个参数化发送模块,没有做校验位,保持8N1的最小可运行形态:
module uart_tx #( parameter BAUD_DIV = 234, // 系统时钟27MHz,波特率115200 parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] tx_data, input wire tx_valid, output reg tx_ready, output reg tx ); localparam IDLE = 3'd0, START = 3'd1, DATA = 3'd2, STOP = 3'd3; reg [2:0] state; reg [15:0] cnt; reg [DATA_WIDTH-1:0] shift_reg; reg [3:0] bit_cnt; wire bit_tick = (cnt == BAUD_DIV - 1); always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; tx <= 1'b1; tx_ready <= 1'b1; cnt <= 16'd0; bit_cnt <= 4'd0; end else begin case (state) IDLE: begin tx <= 1'b1; if (tx_valid) begin shift_reg <= tx_data; state <= START; tx_ready <= 1'b0; cnt <= 16'd0; end end START: begin tx <= 1'b0; if (bit_tick) begin state <= DATA; bit_cnt <= 4'd0; cnt <= 16'd0; end else cnt <= cnt + 1'b1; end DATA: begin tx <= shift_reg[0]; if (bit_tick) begin shift_reg <= {1'b0, shift_reg[DATA_WIDTH-1:1]}; bit_cnt <= bit_cnt + 1'b1; cnt <= 16'd0; if (bit_cnt == DATA_WIDTH - 1) state <= STOP; end else cnt <= cnt + 1'b1; end STOP: begin tx <= 1'b1; if (bit_tick) begin state <= IDLE; tx_ready <= 1'b1; cnt <= 16'd0; end else cnt <= cnt + 1'b1; end endcase end end endmodule逻辑说明:bit_tick是整个发送通路的节拍器,每个位时间只拉高一个时钟周期。START状态里TX被拉低,持续一个BAUD_DIV计数周期;DATA状态每次都把shift_reg最低位送到TX,然后右移一次。data移位时高位填0,等到发出最后一个数据位时正文数据已经从寄存器里清空,不影响停止位。
参数说明:BAUD_DIV是重点,默认234对应27MHz晶振跑115200波特率;如果你的板卡是50MHz晶振,改成434就好。tx_valid是单周期脉冲,也可以电平保持,本模块在IDLE状态下只要看到它为高就锁存数据并开始发送。用这种握手方式,上位逻辑可以用tx_ready && tx_valid判断一次发送是否被接受。
3.3 端口与参数表:改波特率时改哪一行
| 端口/参数 | 方向 | 作用 |
|---|---|---|
| clk | input | 系统时钟,与分频计算基准一致 |
| rst_n | input | 异步复位,低有效 |
| tx_data | input | 待发送字节 |
| tx_valid | input | 发送请求 |
| tx_ready | output | 空闲时高,忙时低 |
| tx | output | 串行输出,空闲为高 |
| BAUD_DIV | parameter | 位宽计数目标值 |
换到另一个波特率或时钟频率时,只改BAUD_DIV一个参数,不需要动状态机。如果系统时钟和波特率相除结果小于等于1,说明时钟频率低于波特率,UART设计本身就不成立,这种配置应当提前在模块外部拦住。
4. Verilog HDL接收通路:中点采样与多数表决
4.1 起始位下降沿是接收的起点
接收模块比发送多两步:同步和边沿检测。RX从引脚进来到进入状态机要先打两拍,避免DFF采到不稳定电平导致状态机误跳。下降沿检测用rx_d1 && !rx_d2,意思是前一个周期为高、当前周期变低,这正是起始位的前沿。
检测到下降沿后不要立即进入DATA状态,因为下降沿本身可能只是毛刺。我的做法是先进入START_CONFIRM状态,等半位时间(BAUD_DIV/2)后再次采样RX。如果此时RX仍然是低,说明是一段完整的低电平,确认起始位有效;如果已经是高,则丢弃本次同步,回到IDLE。半位采样是接收可靠性最关键的一道防线。
4.2 三倍采样多数表决的实现代码
位中间的单个采样点其实足够应付常规PCB走线和短距离传输,但如果串口线比较长、环境有干扰,单点采样可能正好打在毛刺上。更稳妥的做法是把每个位周期切分,在预期采样点左右各采集一次,形成三倍采样多数表决。这里给出一个可综合的接收核心代码:
module uart_rx #( parameter BAUD_DIV = 234, parameter DATA_WIDTH = 8 )( input wire clk, input wire rst_n, input wire rx, output reg [DATA_WIDTH-1:0] rx_data, output reg rx_valid ); localparam IDLE = 3'd0, START_CONFIRM = 3'd1, DATA = 3'd2, STOP = 3'd3; reg [2:0] state; reg [2:0] state_rx; // 亚稳态同步 reg [15:0] cnt; reg [DATA_WIDTH-1:0] shift_reg; reg [3:0] bit_cnt; reg sample_mid; reg sample_left; reg sample_right; wire rx_sync1 = state_rx[0]; wire rx_sync2 = state_rx[1]; wire rx_negedge = rx_sync1 & ~rx_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state_rx <= 3'b111; state <= IDLE; cnt <= 16'd0; bit_cnt <= 4'd0; rx_valid <= 1'b0; end else begin state_rx <= {state_rx[1:0], rx}; rx_valid <= 1'b0; case (state) IDLE: begin cnt <= 16'd0; if (rx_negedge) begin state <= START_CONFIRM; cnt <= 16'd0; end end START_CONFIRM: begin if (cnt == BAUD_DIV / 2 - 1) begin if (!rx_sync2) begin state <= DATA; cnt <= 16'd0; bit_cnt <= 4'd0; end else state <= IDLE; end else cnt <= cnt + 1'b1; end DATA: begin // 在每个位周期内,用三段采样点描述位中心 if (cnt == BAUD_DIV/3 - 1) sample_left <= rx_sync2; if (cnt == BAUD_DIV/2 - 1) sample_mid <= rx_sync2; if (cnt == BAUD_DIV*2/3 - 1) sample_right <= rx_sync2; if (cnt == BAUD_DIV - 1) begin shift_reg <= {(sample_left & sample_mid) | (sample_mid & sample_right) | (sample_left & sample_right), shift_reg[DATA_WIDTH-1:1]}; bit_cnt <= bit_cnt + 1'b1; cnt <= 16'd0; if (bit_cnt == DATA_WIDTH - 1) begin state <= STOP; rx_data <= shift_reg; rx_valid <= 1'b1; end end else cnt <= cnt + 1'b1; end STOP: begin if (bit_tick_cnt()) // 简化描述,实际用cnt计数 state <= IDLE; end endcase end end endmodule逻辑说明:三个采样点分别落在位周期的1/3、1/2、2/3处,最终数据位取三者的多数表决,相当于在抵抗毛刺和边沿抖动的同时,还能容忍一定程度的相位偏移。多数表决的逻辑是三个信号两两相与后再或,只要有两个采样点一致,就采到这个位的真实电平。shift_reg右移方向与发送端一致,LSB先被填进最高位,8次移位后恢复原始字节。
参数说明:sample_left、sample_mid、sample_right三个变量保存的是当前位周期内的采样值,而不是历史位。采样点的位置按BAUD_DIV比例计算,所以改变系统时钟和波特率时依然有效。表格里这一行信息很关键:sample_left在三分之一处、sample_mid在中点、sample_right在三分之二处,哪一个采到毛刺都不会独自决定结果。
4.3 比特时间计数与波特率容差的关系
接收机的计数器在每个BAUD_DIV周期溢出一次,重新开始计数。由于发送端和接收端的时钟源是各自独立的,两个计数周期不会完全相等。误差的累积只发生在一帧之内,因为起始位下降沿已经把所有位边界重新对齐了一次。一帧8N1格式有10个位时间,只要接收方位时间误差不超过±5%,最后一个数据位的中点采样点就不会偏出该位规定的50%窗口。
多数表决进一步加强了这个容差。即使采样点偏差到3/5位宽处,三倍采样里仍有两个点落在正确区间内。这也是为什么工业串口驱动常见的容忍范围是±2%到±3%,而FPGA整数分频轻松满足的原因。
提示:不要把接收模块的
rx_valid连到发送模块的tx_valid上做回环。中间需要加一级FIFO或寄存器,因为同一时刻接收状态机正在处理停止位,发送状态机也刚从IDLE出发,两个脉冲会互相错开一拍,下一轮数据就会丢。
5. 仿真、约束与串口助手:GW2A-LV18PG256C8的UART联调排错
5.1 写一个能自我检查的testbench
上板之前必须仿真。UART的testbench核心是模拟对端发来一个字节,再把DUT发送的数据抓出来比对。可以用一个task来按位驱动RX线:
task uart_send_byte(input [7:0] data); integer i; begin rx = 1'b1; #(BIT_TIME); rx = 1'b0; // 起始位 #(BIT_TIME); for (i = 0; i < 8; i = i + 1) begin rx = data[i]; // LSB first #(BIT_TIME); end rx = 1'b1; // 停止位 #(BIT_TIME); end endtaskBIT_TIME要定义成1000000000 / BAUD_RATE纳秒,比如115200波特率对应约8680ns。testbench里先发一个字节给uart_rx,等它输出rx_valid,检查rx_data是否等于原始数据;再把同一份数据送入uart_tx,等tx_ready拉高后检查串行输出。用$display打印比对结果,综合前的功能仿真能做到全自动断言。
仿真时重点看三个信号:rx_negedge是否只在起始位处拉高、cnt是否在每帧开始时清零、rx_data在停止位期间是否稳定。如果rx_data一直在跳变,说明位同步出了问题,回头查起点采样和多数表决逻辑。
5.2 引脚约束与电平匹配:从CST文件到USB转串口
高云Gowin EDA用.cst文件做引脚约束,写法和其他FPGA工具不太一样。UART需要两条物理引脚,一个进一个出,再加上地线共用。CST文件里把顶层端口映射到具体封装管脚:
IO_LOC "tx" 14; IO_LOC "rx" 15; IO_PORT "rx" PULL_MODE=UP;约束本身不复杂,但有一个坑:默认情况下FPGA的IO不上拉,UART空闲高电平要求RX引脚必须有上拉。很多板卡已经在外部加了上拉电阻,如果原理图上没有,就必须在约束里加PULL_MODE=UP,否则接收端会一直检测到低电平,把噪声当成起始位。GW2A-LV18PG256C8的内核电压是1.2V,IO Bank电压通常按板卡配置为3.3V或1.8V,设计时先查原理图确认Bank供电,再把USB转串口模块的电平匹配上。FT232R、CH340这类模块大多是3.3V/5V兼容输出,如果FPGA Bank是1.8V,需要加电平转换电路,不能直接接线。
5.3 串口调试中的几个典型症状与排查方向
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 完全无数据 | TX/RX接反 | 交叉连接,FPGA的TX接USB模块的RX |
| 全是乱码 | 波特率或帧格式不匹配 | 核对上位机8N1配置和BAUD_DIV |
| 9600正常,4800无数据 | 分频参数取整误差或上位机未重连 | 重新生成分频系数,确认串口重新打开 |
| 偶尔丢字节 | 上游逻辑在rx_valid后没及时取走数据 | 加FIFO或延长握手信号 |
| 上电后立刻收到0x00 | RX线上无上拉 | 检查CST或外部上拉电阻 |
“9600能通信,4800没有数据”这类问题特别典型。很多人改了上位机波特率,但FPGA里的BAUD_DIV是固定数值,没有同步修改,两边位时间不一致自然收不到。这类问题用示波器看TX波形最直观,数一下低电平宽度是不是和设置波特率对应的位宽一致。USB转串口驱动装不上的时候,先用设备管理器确认虚拟串口号,FPGA这边通常不需要驱动干预,只要链路正常就能在串口助手看到回显。
6. 自动波特率检测:把UART接收端做成不挑上位机的自适应模块
很多调试场景中,上位机软件已经固化了波特率,又不想每次手动去改FPGA的分频参数。自动波特率检测的思路是:让上位机先发一个校准字节,比如0x55(二进制01010101),这个字节的每一位都会发生跳变,接收端用计数器测量相邻跳变之间的宽度,就能反推出位周期。
校准字节要用标准串口格式发送,起始位后8个数据位是0、1交替,停止位为高,整帧会形成规律的方波。接收端检测到起始位下降沿后启动计数器,在后续任一次数据的上升沿或下降沿停止计数,得到的就是一个位时间对应的时钟周期数。这个测量值直接写入接收和发送模块的BAUD_DIV寄存器,之后所有收发按新分频系数运行。
reg [15:0] measure_cnt; reg [15:0] baud_div_measured; reg measuring; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin measuring <= 1'b0; measure_cnt <= 16'd0; baud_div_measured <= 16'd0; end else if (rx_negedge) begin measuring <= 1'b1; measure_cnt <= 16'd0; end else if (measuring) begin if (rx_posedge || rx_negedge) begin // 校准字节的任一跳变 baud_div_measured <= measure_cnt + 1'b1; measuring <= 1'b0; end else measure_cnt <= measure_cnt + 1'b1; end end逻辑说明:起始位下降沿作为测量起点,之后遇到第一个跳变(可能是LSB的上升沿或下降沿)就停止,计数器的值就是一个位周期。baud_div_measured可以直接作为后续和发送模块的BAUD_DIV动态输入。需要注意,校准字节只在链路建立时发送一次,测量完成后状态机切换到正常数据接收模式。rx_posedge和rx_negedge分别由同步后的RX信号边沿检测产生,不能直接使用原始引脚电平,否则会因毛刺产生错误测量。
用这个方法,同一套Verilog HDL代码可以自适应300到921600波特率,前提是系统时钟能提供足够的计数分辨率,位周期至少要有几十个时钟周期。时钟频率越低,测量误差越大,比如27MHz下921600波特率每个位周期只有约29个时钟周期,测量值误差一个周期相当于3.4%,勉强可用;115200及以下则非常从容。自适应波特率模块做好后,配合前面收发模块的握手和电平匹配,GW2A-LV18PG256C8上的串口通信就不再依赖人工改参数,直接把二进制码流和上位机对齐。
本文还有配套的精品资源,点击获取