第二十七章 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。

图27.1.1 串口与PC的连接

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

图27.1.2 异步串行通信数据格式

  • 起始位:空闲时传输线保持高电平;发送端拉低一个波特率周期表示"开始",接收端以这个下降沿为同步起点;
  • 数据位: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.1.7 串口通信连接示意图

27.2 硬件设计

板载 USB_UART 接口内部是 CH340C(南京沁恒,国产芯片)。原理图上有三个值得注意的设计:

  1. 用独立的 3.3V(XC6206 稳压)给 CH340C 供电——开发板断电后电脑仍能识别到 COM 口;
  2. 74LVC1G125 缓冲芯片防止断电后 CH340 的引脚电压反灌进 FPGA;
  3. P5 排针用来选择 CH340 接 PS 还是 PL:接 PS 串口时跳帽连 1-3、2-4 脚;本次 PL 串口实验必须把 3-5 脚、4-6 脚分别用跳帽连起来——跳线帽插错位置,串口根本不通,这是上板前必须检查的第一件事。

图27.2.2 USB TypeC串口原理图

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,在复杂工程中用于提醒别的模块"发送期间别改数据"。

图27.4.2 系统框图

串口接收模块设计

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

图27.4.7 亚稳态时序图

接收模块的整体时序思路(以接收 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 拉高一拍。

图27.4.5 串口接收模块波形图

一个精妙细节:接收过程在停止位中间就提前结束(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,环回功能正确。

图27.4.17 顶层模块仿真波形

下载验证

  1. 引脚约束新增两个串口引脚:
信号名 方向 管脚 端口说明 电平标准
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]
  1. USB 线连接电脑与开发板 USB_UART 口,P5 跳帽接 3-5、4-6 脚(PL 串口模式);
  2. 首次使用需安装 CH340 驱动(开发板资料 A 盘自带);
  3. 打开串口助手 XCOM,选择 CH340 对应的 COM 口(设备管理器里可查),设置 115200、8 数据位、1 停止位、无校验;
  4. 发送框输入任意数据(如 "55")点发送——接收框立即出现同样的 "55",环回验证成功。

图27.5.9 串口环回验证

初学者容易踩的坑

  • 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 等外设实验,都建立在这套基础之上。