第二十七章 UART串口通信实验
对应《正点原子 ZYNQ FPGA 开发指南》第二十七章(约 P631~P660),硬件环节需要 USB Type-C 数据线和 P5 跳线帽,上位机用串口调试助手(XCOM)验证。
本章在讲什么?(先看这个)
前面 26 章里,FPGA 一直在"自说自话"——所有的输入都来自板上的晶振和按键。本章 FPGA 第一次和外部芯片(USB 转串口芯片 CH340C)以及你的电脑对话:电脑发一个字节,FPGA 原样发回来,这就是串口环回实验。
UART 是最经典的异步串行接口:只有一根 TX、一根 RX,没有时钟线,收发双方靠事先约定好的波特率和帧格式(起始位+数据位+校验位+停止位)对上节奏。本章要写的两个核心模块——串并转换的接收模块 uart_rx 和并串转换的发送模块 uart_tx——是所有串行接口(SPI、I2C 也好)的通用基本功,而且本章会正式引出亚稳态这个 FPGA 设计的重要概念及其防护手段(多级寄存器同步)。
核心概念拆解
27.1 UART 串口简介
串行 vs 并行:并行通信多条线同时传一个字节(快但线多、贵,距离 <30m);串行通信一条线一比特接一比特传(省线但慢),适合长距离低速场合。
同步 vs 异步:同步串行(如 I2C、SPI)双方共享时钟;异步串行(UART)没有时钟线,发送端可随时开发,靠起始位/停止位来界定每一帧——这就是为什么 UART 帧里起止位必不可少。
接线规则:PC 的 TX 接 FPGA 的 RX,PC 的 RX 接 FPGA 的 TX,交叉相连,别接成 TX-TX。

一帧数据的格式(本章采用最常用的 8N1:8 位数据、无校验、1 位停止位):

- 起始位:空闲时传输线保持高电平;发送端拉低一个波特率周期表示"开始",接收端以这个下降沿为同步起点;
- 数据位:5/6/7/8/9 位可选,8 位最常用;LSB(最低位)先发——发 0x41(0100_0001)时线上顺序是 1-0-0-0-0-0-1-0;
- 校验位(可选):奇/偶校验,让"数据位+校验位"里 1 的个数凑成约定的奇数或偶数,只能查单个比特错误;
- 停止位:强制拉高 1(或 2)个波特率周期,为下一帧的起始下降沿做准备。
波特率:每秒传输的位数。115200bps 时一位持续 1/115200 ≈ 8.68μs,一帧(10 位)约 86.8μs。本章用它做参数化计算:50MHz 时钟下,一位数据对应 50_000_000/115200 ≈ 434 个时钟周期——这个 434 就是代码里的 BAUD_CNT_MAX。
电平标准与接口:RS232 定义了电气特性(DB9 接口),现代电脑没有 DB9,开发板用 USB Type-C + CH340C 芯片把 USB 虚拟成串口(COM 口),电脑端装 CH340 驱动即可。

27.2 硬件设计
板载 USB_UART 接口内部是 CH340C(南京沁恒,国产芯片)。原理图上有三个值得注意的设计:
- 用独立的 3.3V(XC6206 稳压)给 CH340C 供电——开发板断电后电脑仍能识别到 COM 口;
- 74LVC1G125 缓冲芯片防止断电后 CH340 的引脚电压反灌进 FPGA;
- P5 排针用来选择 CH340 接 PS 还是 PL:接 PS 串口时跳帽连 1-3、2-4 脚;本次 PL 串口实验必须把 3-5 脚、4-6 脚分别用跳帽连起来——跳线帽插错位置,串口根本不通,这是上板前必须检查的第一件事。

27.3 实验任务
上位机通过串口调试助手发数据给开发板,FPGA 接收后再原样发回,实现串口数据环回。
27.4 程序设计
总体模块设计
三个模块:顶层 uart_loopback + 接收模块 uart_rx(串转并,输出 uart_rx_data 和接收完成标志 uart_rx_done)+ 发送模块 uart_tx(并转串)。环回的关键连线:uart_rx_done 作为 uart_tx 的发送使能,uart_rx_data 作为待发送数据——收到一字节立刻发回一字节。uart_tx 还输出忙信号 uart_tx_busy,在复杂工程中用于提醒别的模块"发送期间别改数据"。

串口接收模块设计
先解决一个理论问题——亚稳态:寄存器采样要求建立时间和保持时间,而 uart_rxd 是异步输入,无法保证这两项,时钟恰好在信号跳变沿采样时,Q 端可能进入不确定状态,且这个不确定值会沿触发器链传播下去。防护方法:单比特信号用 2~3 级寄存器打拍同步;多比特信号用异步 FIFO 或握手。本章对 uart_rxd 打三拍(uart_rxd_d0/d1/d2),后两拍同时用于下降沿检测。

接收模块的整体时序思路(以接收 8'h55 为例):
start_en捕获起始位下降沿:uart_rxd_d2 & ~uart_rxd_d1 & ~rx_flag,产生一个时钟周期的脉冲;- rx_flag 拉高后,波特率计数器 baud_cnt 循环计 0~433(一个位周期),rx_cnt 每逢 baud_cnt 计满加一,标识当前收到第几位;
- 在每位中间采样(baud_cnt == BAUD_CNT_MAX/2-1 时把 uart_rxd_d2 存入 rx_data_t 的对应位)——数据位的正中间电平最稳定,这是所有串行接收的黄金法则;
- rx_cnt 计到 9(起始位+8 数据位+停止位共 10 位)且 baud_cnt 到停止位中间时,rx_data_t 赋给 uart_rx_data、uart_rx_done 拉高一拍。

一个精妙细节:接收过程在停止位中间就提前结束(rx_flag 提前拉低),而不是等停止位完整传完。因为数据位早已寄存完毕,提前半位结束可以给下一帧的起始位检测留出充足时间,连续收数据时不会丢帧。
module uart_rx(
input clk , //系统时钟
input rst_n , //系统复位,低有效
input uart_rxd , //UART 接收端口
output reg uart_rx_done, //UART 接收完成信号
output reg [7:0] uart_rx_data //UART 接收到的数据
);
//parameter define
parameter CLK_FREQ = 50000000; //系统时钟频率
parameter UART_BPS = 115200 ; //串口波特率
//为得到指定波特率,对系统时钟计数BPS_CNT 次
localparam BAUD_CNT_MAX = CLK_FREQ/UART_BPS;
//reg define
reg uart_rxd_d0; //uart_rxd 延迟一个时钟周期输出的信号
reg uart_rxd_d1; //uart_rxd 延迟两个时钟周期输出的信号
reg uart_rxd_d2; //uart_rxd 延迟三个时钟周期输出的信号
reg rx_flag ; //接收过程标志信号
reg [3:0 ] rx_cnt ; //接收数据计数器
reg [15:0] baud_cnt ; //波特率计数器
reg [7:0 ] rx_data_t ; //接收数据寄存器
//wire define
wire start_en;
//------------------------------------------------
//捕获接收端口下降沿(起始位),得到一个时钟周期的脉冲信号
assign start_en = uart_rxd_d2 & (~uart_rxd_d1) & (~rx_flag);
//针对异步信号的同步处理
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
uart_rxd_d0 <= 1'b0;
uart_rxd_d1 <= 1'b0;
uart_rxd_d2 <= 1'b0;
end
else begin
uart_rxd_d0 <= uart_rxd;
uart_rxd_d1 <= uart_rxd_d0;
uart_rxd_d2 <= uart_rxd_d1;
end
end
//给接收标志赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_flag <= 1'b0;
else if(start_en) //检测到起始位
rx_flag <= 1'b1; //接收过程中,标志信号rx_flag 拉高
//在停止位一半的时候,即接收过程结束,标志信号rx_flag 拉低
else if((rx_cnt == 4'd9) && (baud_cnt == BAUD_CNT_MAX/2 - 1'b1))
rx_flag <= 1'b0;
else
rx_flag <= rx_flag;
end
//波特率的计数器赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
baud_cnt <= 16'd0;
else if(rx_flag) begin //处于接收过程时,波特率计数器(baud_cnt)进行循环计数
if(baud_cnt < BAUD_CNT_MAX - 16'b1)
baud_cnt <= baud_cnt + 16'b1;
else
baud_cnt <= 16'd0; //计数达到一个波特率周期后清零
end
else
baud_cnt <= 16'd0; //接收过程结束时计数器清零
end
//对接收数据计数器(rx_cnt)进行赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_cnt <= 4'd0;
else if(rx_flag) begin //处于接收过程时rx_cnt 才进行计数
if(baud_cnt == BAUD_CNT_MAX - 4'b1) //当波特率计数器计数到一个波特率周期时
rx_cnt <= rx_cnt + 4'b1; //接收数据计数器加1
else
rx_cnt <= rx_cnt;
end
else
rx_cnt <= 4'd0; //接收过程结束时计数器清零
end
//根据rx_cnt 来寄存rxd 端口的数据
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
rx_data_t <= 8'b0;
else if(rx_flag) begin //系统处于接收过程时
if(baud_cnt == BAUD_CNT_MAX/2 - 1'b1) begin //判断baud_cnt 是否计数到数据位的中间
case(rx_cnt)
4'd1 : rx_data_t[0] <= uart_rxd_d2; //寄存数据的最低位
4'd2 : rx_data_t[1] <= uart_rxd_d2;
4'd3 : rx_data_t[2] <= uart_rxd_d2;
4'd4 : rx_data_t[3] <= uart_rxd_d2;
4'd5 : rx_data_t[4] <= uart_rxd_d2;
4'd6 : rx_data_t[5] <= uart_rxd_d2;
4'd7 : rx_data_t[6] <= uart_rxd_d2;
4'd8 : rx_data_t[7] <= uart_rxd_d2; //寄存数据的高低位
default : ;
endcase
end
else
rx_data_t <= rx_data_t;
end
else
rx_data_t <= 8'b0;
end
//给接收完成信号和接收到的数据赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
uart_rx_done <= 1'b0;
uart_rx_data <= 8'b0;
end
//当接收数据计数器计数到停止位,且baud_cnt 计数到停止位的中间时
else if(rx_cnt == 4'd9 && baud_cnt == BAUD_CNT_MAX/2 - 1'b1) begin
uart_rx_done <= 1'b1 ; //拉高接收完成信号
uart_rx_data <= rx_data_t; //并对UART 接收到的数据进行赋值
end
else begin
uart_rx_done <= 1'b0;
uart_rx_data <= uart_rx_data;
end
end
endmodule
串口发送模块设计
发送是接收的镜像过程(并转串):uart_tx_en 拉高时寄存数据并拉高 uart_tx_busy;tx_cnt 计数 0~9,case 依次输出起始位(0)、8 个数据位(LSB 在前)、停止位(1);空闲时 uart_txd 保持高电平。发送结束的判定在停止位完整发完(tx_cnt==9 且 baud_cnt 计满一个位周期)——注意这和接收侧"停止位中间提前结束"不同:发送端发完整停止位,接收端提前半位收工,两者刚好互补。
module uart_tx(
input clk , //系统时钟
input rst_n , //系统复位,低有效
input uart_tx_en , //UART 的发送使能
input [7:0] uart_tx_data , //UART 要发送的数据
output reg uart_txd , //UART 发送端口
output reg uart_tx_busy //发送忙状态信号
);
//parameter define
parameter CLK_FREQ = 50000000; //系统时钟频率
parameter UART_BPS = 115200 ; //串口波特率
localparam BAUD_CNT_MAX = CLK_FREQ/UART_BPS;
//reg define
reg [7:0] tx_data_t; //发送数据寄存器
reg [3:0] tx_cnt ; //发送数据计数器
reg [15:0] baud_cnt ; //波特率计数器
//------------------------------------------------
//当uart_tx_en 为高时,寄存输入的并行数据,并拉高BUSY 信号
always @(posedge clk or negedge rst_n) begin
if(!rst_n) begin
tx_data_t <= 8'b0;
uart_tx_busy <= 1'b0;
end
//发送使能时,寄存要发送的数据,并拉高BUSY 信号
else if(uart_tx_en) begin
tx_data_t <= uart_tx_data;
uart_tx_busy <= 1'b1;
end
//当计数到停止位结束时,停止发送过程
else if(tx_cnt == 4'd9 && baud_cnt == BAUD_CNT_MAX - 1) begin
tx_data_t <= 8'b0; //清空发送数据寄存器
uart_tx_busy <= 1'b0; //并拉低BUSY 信号
end
else begin
tx_data_t <= tx_data_t;
uart_tx_busy <= uart_tx_busy;
end
end
//波特率的计数器赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
baud_cnt <= 16'd0;
else if(uart_tx_en)
baud_cnt <= 16'd0;
//当处于发送过程时,波特率计数器(baud_cnt)进行循环计数
else if(uart_tx_busy) begin
if(baud_cnt < BAUD_CNT_MAX - 16'b1)
baud_cnt <= baud_cnt + 16'b1;
else
baud_cnt <= 16'd0; //计数达到一个波特率周期后清零
end
else
baud_cnt <= 16'd0; //发送过程结束时计数器清零
end
//tx_cnt 进行赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
tx_cnt <= 4'd0;
else if(uart_tx_en)
tx_cnt <= 4'd0;
else if(uart_tx_busy) begin //处于发送过程时tx_cnt 才进行计数
if(baud_cnt == BAUD_CNT_MAX - 4'b1) //当波特率计数器计数到一个波特率周期时
tx_cnt <= tx_cnt + 4'b1; //发送数据计数器加1
else
tx_cnt <= tx_cnt;
end
else
tx_cnt <= 4'd0; //发送过程结束时计数器清零
end
//根据tx_cnt 来给uart 发送端口赋值
always @(posedge clk or negedge rst_n) begin
if(!rst_n)
uart_txd <= 1'b1;
else if(uart_tx_busy) begin
case(tx_cnt)
4'd0 : uart_txd <= 1'b0 ; //起始位
4'd1 : uart_txd <= tx_data_t[0]; //数据位最低位
4'd2 : uart_txd <= tx_data_t[1];
4'd3 : uart_txd <= tx_data_t[2];
4'd4 : uart_txd <= tx_data_t[3];
4'd5 : uart_txd <= tx_data_t[4];
4'd6 : uart_txd <= tx_data_t[5];
4'd7 : uart_txd <= tx_data_t[6];
4'd8 : uart_txd <= tx_data_t[7]; //数据位最高位
4'd9 : uart_txd <= 1'b1 ; //停止位
default : uart_txd <= 1'b1;
endcase
end
else
uart_txd <= 1'b1; //空闲时发送端口为高电平
end
endmodule
顶层模块编写
顶层用参数把 CLK_FREQ/UART_BPS 传递到收发两个模块,改波特率只需改一处(想跑 9600 就把 UART_BPS 改成 9600):
module uart_loopback(
input sys_clk , //外部50MHz 时钟
input sys_rst_n, //系外部复位信号,低有效
//UART 端口
input uart_rxd , //UART 接收端口
output uart_txd //UART 发送端口
);
//parameter define
parameter CLK_FREQ = 50000000; //定义系统时钟频率
parameter UART_BPS = 115200 ; //定义串口波特率
//wire define
wire uart_rx_done; //UART 接收完成信号
wire [7:0] uart_rx_data; //UART 接收数据
//------------------------------------------------
//串口接收模块
uart_rx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS)
)
u_uart_rx(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.uart_rxd (uart_rxd ),
.uart_rx_done (uart_rx_done),
.uart_rx_data (uart_rx_data)
);
//串口发送模块
uart_tx #(
.CLK_FREQ (CLK_FREQ),
.UART_BPS (UART_BPS)
)
u_uart_tx(
.clk (sys_clk ),
.rst_n (sys_rst_n ),
.uart_tx_en (uart_rx_done), //接收完成即触发发送,实现环回
.uart_tx_data (uart_rx_data),
.uart_txd (uart_txd ),
.uart_tx_busy ( )
);
endmodule
27.5 仿真验证与下载验证
TB 需要模拟一个真实的串口发送过程:复位释放 1000ns 后拉低 uart_rxd 产生起始位,之后每 8680ns(=434×20ns,一个位周期)按 8'h55 的 LSB-first 顺序跳变数据位,最后拉高发送停止位和空闲电平:
`timescale 1ns/1ns //仿真的单位/仿真的精度
module tb_uart_loopback();
parameter CLK_PERIOD = 20;//时钟周期为20ns
reg sys_clk ; //时钟信号
reg sys_rst_n; //复位信号
reg uart_rxd ; //UART 接收端口
wire uart_txd ; //UART 发送端口
//------------------------------------------------
//发送8'h55 8'b0101_0101
initial begin
sys_clk <= 1'b0;
sys_rst_n <= 1'b0;
uart_rxd <= 1'b1;
#200
sys_rst_n <= 1'b1;
#1000
uart_rxd <= 1'b0; //起始位
#8680
uart_rxd <= 1'b1; //D0
#8680
uart_rxd <= 1'b0; //D1
#8680
uart_rxd <= 1'b1; //D2
#8680
uart_rxd <= 1'b0; //D3
#8680
uart_rxd <= 1'b1; //D4
#8680
uart_rxd <= 1'b0; //D5
#8680
uart_rxd <= 1'b1; //D6
#8680
uart_rxd <= 1'b0; //D7
#8680
uart_rxd <= 1'b1; //停止位
#8680
uart_rxd <= 1'b1; //空闲状态
end
//50MHz 的时钟,周期则为1/50MHz=20ns,所以每10ns,电平取反一次
always #(CLK_PERIOD/2) sys_clk = ~sys_clk;
//例化顶层模块
uart_loopback u_uart_loopback(
.sys_clk (sys_clk ),
.sys_rst_n (sys_rst_n),
.uart_rxd (uart_rxd ),
.uart_txd (uart_txd )
);
endmodule
仿真结果:uart_rxd 收到 8'h55 后,稍延时(接收耗时 + 环回路径)uart_txd 发出同样的 8'h55,环回功能正确。

下载验证:
- 引脚约束新增两个串口引脚:
| 信号名 | 方向 | 管脚 | 端口说明 | 电平标准 |
|---|---|---|---|---|
| sys_clk | input | U18 | 系统时钟,50MHz | LVCMOS33 |
| sys_rst_n | input | N16 | 系统复位按键,低电平有效 | LVCMOS33 |
| uart_rxd | input | T19 | UART接收端口 | LVCMOS33 |
| uart_txd | output | J15 | UART发送端口 | LVCMOS33 |
create_clock -period 20.000 -name sys_clk [get_ports sys_clk]
set_property -dict {PACKAGE_PIN U18 IOSTANDARD LVCMOS33} [get_ports sys_clk]
set_property -dict {PACKAGE_PIN N16 IOSTANDARD LVCMOS33} [get_ports sys_rst_n]
set_property -dict {PACKAGE_PIN T19 IOSTANDARD LVCMOS33} [get_ports uart_rxd]
set_property -dict {PACKAGE_PIN J15 IOSTANDARD LVCMOS33} [get_ports uart_txd]
- USB 线连接电脑与开发板 USB_UART 口,P5 跳帽接 3-5、4-6 脚(PL 串口模式);
- 首次使用需安装 CH340 驱动(开发板资料 A 盘自带);
- 打开串口助手 XCOM,选择 CH340 对应的 COM 口(设备管理器里可查),设置 115200、8 数据位、1 停止位、无校验;
- 发送框输入任意数据(如 "55")点发送——接收框立即出现同样的 "55",环回验证成功。

初学者容易踩的坑
- P5 跳线帽位置决定串口通不通。PL 实验必须跳 3-5、4-6(CH340 连 PL 引脚);跳成 1-3、2-4 就接到 PS 串口去了,怎么发数据都收不到。上板不通时第一个查它。
- 异步输入必须打拍,且边沿检测用的是打拍后的信号。直接对 uart_rxd 做边沿检测有亚稳态风险;本章
start_en = uart_rxd_d2 & ~uart_rxd_d1 & ~rx_flag用的都是 d1/d2 这两级同步后的信号。打三拍会让检测比线上电平晚 2 拍,但它换来的是稳定。 - 每位数据必须在位中间采样。起始位下降沿只给出"大概同步",双方时钟的微小偏差会累积,只有在每位中央(baud_cnt == BAUD_CNT_MAX/2-1)采样才最可靠。写在位开始或结尾处的采样代码,波特率稍偏就会错位。
- 接收提前半位结束、发送完整结束,不是不对称的 bug。接收侧在停止位中间就拉低 rx_flag,是为了连续收帧时能及时捕获下一个起始位;发送侧则必须发满停止位。两个模块各自有理,别"顺手统一"。
- 波特率允许小偏差但不能太离谱。收发双方的波特率各偏差一点点(通常 <2~3%)是允许的;但如果 CLK_FREQ 参数填错(比如板子时钟不是 50MHz),434 这个计数基准全错,收到的就是乱码。乱码先查参数和实际时钟是否一致。
- LSB 先发。UART 数据位从最低位开始发送,TB 里手工拼数据位顺序时容易按直觉从高位排,结果环回数据位序全反(0x55 这种回文数据测不出来,用 0x01、0x80 这类数据一测就现形)。
- COM 口/驱动问题不是代码问题。设备管理器里找不到 CH340 串口、或串口被别的程序占用,都会导致"发不出去",先确认驱动和端口,再怀疑设计。
小结 & 下一步
本章是 FPGA 与外部世界通信的第一课:掌握了 UART 的帧格式与波特率本质(BAUD_CNT_MAX = 时钟频率/波特率),写出了带参数化的串并转换接收模块(三拍同步 + 下降沿捕获起始位 + 位中间采样 + 停止位中间提前结束)和并串转换发送模块,并正式认识了亚稳态及其防护(单 bit 打拍同步 / 多 bit 走 FIFO)。环回实验在上位机串口助手上验证成功。
至此第 19~27 章的实验笔记全部完成。这九章覆盖了 FPGA 入门的关键拼图:按键与消抖、触摸输入、PWM 与 ILA 调试、时钟管理(PLL)、三类存储资源(单端口/伪双端口 RAM、FIFO)以及第一个对外通信接口 UART——后续章节的以太网、HDMI 等外设实验,都建立在这套基础之上。