FPGA安路赛项第十讲:uart接收驱动设计

第 10讲:UART 发送驱动设计 —— 串行帧状态引擎、握手协议与定时发报实战


课前导读:从“节拍心跳”到“真正的发报员”

在上一讲中,我们完成了串口通信最核心的时钟分频基石——波特率发生器(baud_tick)。它以每秒 115,200 次的频率,精确吐出一个个宽度为 20ns 的时钟使能脉冲。

这一讲,我们要把这个节拍器作为动力源,组装出负责向外界电脑发送数据的“发报员”驱动引擎(uart_tx

很多初学者觉得发送很简单:“不就是把 8 位数据按顺序一位一位挪出去吗?”
但一个工业级可靠的发送驱动,绝不仅仅是简单的移位:

  • 外界给的数据在中途如果突然变化了,会不会导致发到一半的数据错乱撕裂?
  • 怎么告诉外界“我正在忙,请不要给我塞新数据”(忙碌标志 busy)?
  • 发送完成的瞬间,怎么精准敲响铃铛通知上层(完成脉冲 done)?
  • 为什么全世界所有硬件工程师在测试串口时,最喜欢发送大写字母 'U'(十六进制 0x55

本讲将带你深入串行发射引擎底层,彻底攻克数据帧状态机编码、前级快照锁存机制、流水线握手协议以及顶层 1 秒自动发报系统的构建


第一章:发报员的物理使命与接口握手模型

在进入代码前,我们先把发送引擎 uart_tx 当成一个独立的芯片黑盒子,看看它与上下游之间是如何协同工作的:

    【上游总指挥 (CPU / 控制逻辑)】
                │
                ├─► data[7:0] (准备发送的 8 位字节内容)
                ├─► start     (启动命令脉冲:拍一下发报员肩膀)
                │
                ▼
    +--------------------------------------------------------------+
    |                 【uart_tx 发报员核心】                       |
    |                                                              |
    |  * 听从外界注入的 tick 节拍器指挥 (每隔 434 拍前进一步)       |
    |  * 将 8 位并行数据切片,打包成符合 8-N-1 标准的 10 位串行波形 |
    +--------------------------------------------------------------+
                │                               │
                ├─► tx (单根物理发送线,连电脑)   ├─► busy (忙碌红灯)
                │                               └─► done (发毕绿灯)
                ▼
    【外部世界 (电脑屏幕 / USB串口芯片)】

1.1 关键接口信号的物理定义:

  • input wire [7:0] data:上层系统递给发报员的 8 位数据包裹(例如字符 'A' 或数值 8'h55);
  • input wire start:发射触发脉冲。平时为 0,当上层想发送时,猛跳 1 个周期的脉冲(持续 20ns);
  • input wire tick:第 10 讲做的波特率发生器送进来的使能脉冲。每来一个高电平,发报员才往前走一步;
  • output reg tx:芯片对外的物理输出引脚。平时空闲时牢牢锁死在 高电平 1
  • output reg busy忙碌标志红灯
    • busy = 0:空闲中,欢迎送新数据;
    • busy = 1:发报员正在专心发信,严禁外界打扰!
  • output reg done完成报告绿灯。发完停止位的那一瞬间,猛敲一下铃铛(持续 20ns 的高电平脉冲),通知上层:“这封信送到了,可以下达下一项任务了!”

第二章:uart_tx.v 核心驱动源码与手术级解剖

下面是符合工业标准的完整 UART 发送驱动核心代码:

module uart_tx (
    input  wire       clk,   // 50MHz 系统主时钟
    input  wire       rst_n, // 系统复位信号 (低有效)
    input  wire       tick,  // 波特率发生器给出的 115200 节拍脉冲
    input  wire       start, // 上层下达的发信启动脉冲 (高有效,1拍)
    input  wire [7:0] data,  // 待发送的 8 位并行数据载荷
    output reg        tx,    // 物理串行输出信号线 (连电脑 RXD)
    output reg        busy,  // 状态汇报:1 代表忙碌,0 代表空闲
    output reg        done   // 完成汇报:发毕瞬间敲响 1 拍高电平
);
    reg [3:0] phase;        // 阶段计数器 (记着当前进行到第几步,0~10)
    reg [7:0] data_latched; // 内部数据快照抽屉 (防止外界中途窜改)

    always @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            tx           <= 1'b1; // 空闲态总线必须焊死在高电平 1!
            busy         <= 1'b0; // 开机不忙
            done         <= 1'b0; // 没完成
            phase        <= 4'd0;
            data_latched <= 8'd0;
        end else begin
            done <= 1'b0; // 【门铃弹簧设计】:默认每一拍都把完成信号按住为 0

            // 状态一:发报员处于空闲态 (busy == 0)
            if (!busy) begin
                tx <= 1'b1;          // 空闲时维持高电平
                if (start) begin     // 上级下达启动命令!
                    data_latched <= data; // 核心:瞬间拍照锁存数据!
                    busy         <= 1'b1; // 升起“忙碌”红灯!
                    phase        <= 4'd0; // 阶段归零,准备开跑
                end
            end
            // 状态二:发报员处于忙碌发信态 (busy == 1)
            else if (tick) begin // 只有当波特率节拍敲响时,才往前推移一步!
                case (phase)
                    // 步骤 0:发送起始位
                    4'd0: begin
                        tx    <= 1'b0; // 强制拉低为 0!唤醒电脑
                        phase <= 4'd1;
                    end
                    // 步骤 1 至 8:低位先行,逐位送出 8 个数据位
                    4'd1, 4'd2, 4'd3, 4'd4,
                    4'd5, 4'd6, 4'd7, 4'd8: begin
                        tx    <= data_latched[phase - 1'b1]; // 核心:抽取对应位送上导线
                        phase <= phase + 1'b1;
                    end
                    // 步骤 9:发送停止位
                    4'd9: begin
                        tx    <= 1'b1;  // 强制拉高为 1!通知信件封口
                        phase <= 4'd10;
                    end
                    // 步骤 10:发毕总结收工
                    default: begin
                        tx    <= 1'b1;  // 总线重回空闲高电平
                        busy  <= 1'b0;  // 熄灭红灯,解除忙碌!
                        done  <= 1'b1;  // 叮!敲响完成电铃!
                        phase <= 4'd0;  // 阶段归零,静待下次任务
                    end
                endcase
            end
        end
    end
endmodule

2.1 显微镜剖析与设计哲学

① 防撕裂黑科技:为什么必须有 data_latched <= data;

在第 10 讲我们算过,发完 1 个字节(10 个波特位)需要花费:
$$10 \times 8.68\mu\text{s} = 86.8\text{ 微秒 } = \mathbf{4340\text{ 个时钟周期!}}$$

  • 物理危机:上层的 data 信号是由外部传感器或内存送过来的。在这漫长的 4340 拍里,外部数据线随时可能更新变动。如果发报员直接拿 data[0] ~ data[7] 往外发,发到一半外界数据变了,发出去的字节将变成前半段属于旧数据、后半段属于新数据的**“杂交畸变数据包(Data Tearing)”**!
  • 解法:在 start 命令触发的最初 20ns 窗口内,发报员立刻使用抽屉 data_latched 对输入数据进行一次性“拍照复印(Latch)”!此后无论外界数据怎么翻江倒海,发报员只按拍好的快照发信,从物理上杜绝了数据撕裂。

else if (tick) —— 节拍驱动的绝对时序严谨性

请注意这一层的嵌套结构:

  • 整个发送状态机不是随时钟沿乱动的,而是被紧紧包裹在 else if (tick) 内部!
  • 微观效果:平时 50MHz 时钟跳动 434 次期间,tick 恒为 0,发报员就纹丝不动、绝对稳定地保持当前导线电平
  • 只有当第 434 拍 tick = 1 脉冲到来的那唯一一个上升沿瞬间,case (phase) 才准许往前走一步!波特率的精准度在此得到了完美的硬件继承。

data_latched[phase - 1'b1] 的数学之美

很多同学惊叹于这一行的简洁:

tx <= data_latched[phase - 1'b1];

我们代入实际数字推算:

  • phase = 4'd1 时:发送 data_latched[1 - 1] $\to$ 即送出最低位 data_latched[0]
  • phase = 4'd2 时:发送 data_latched[2 - 1] $\to$ 送出第 1 位 data_latched[1]
  • phase = 4'd8 时:发送 data_latched[8 - 1] $\to$ 送出最高位 data_latched[7]
  • 单行代码,直接把一个 8 位并行寄存器,拆解成了时间轴上逐位飞驰的串行波形!低位先行(LSB First)规则在此被完美兑现!

第三章:微观发送状态转移真值推演表

假设我们要向电脑发送字符 'U'(十六进制 8'h55,展开成二进制就是 0101_0101)。
从外界按下发送按键那一刻起,各变量在连续 11 个 tick 节拍下的微观推演真值表:

节拍序号 阶段 phase 发报员微观动作 物理引脚 tx 电平 忙碌标志 busy 完成标志 done 物理帧对应的通信含义
平时空闲 0 无任务发呆中 1 0 0 串口空闲态(IDLE)
启动瞬时 0 收到 start=1,锁存快照,挂起红灯 1 0 -> 1 0 握手建立,闭门谢客
第 1 个 tick 0 -> 1 强制将导线拉低 0 1 0 起始位(Start Bit)
第 2 个 tick 1 -> 2 发送 data_latched[0] 1 1 0 数据位 Bit 0 (值为1)
第 3 个 tick 2 -> 3 发送 data_latched[1] 0 1 0 数据位 Bit 1 (值为0)
第 4 个 tick 3 -> 4 发送 data_latched[2] 1 1 0 数据位 Bit 2 (值为1)
第 5 个 tick 4 -> 5 发送 data_latched[3] 0 1 0 数据位 Bit 3 (值为0)
第 6 个 tick 5 -> 6 发送 data_latched[4] 1 1 0 数据位 Bit 4 (值为1)
第 7 个 tick 6 -> 7 发送 data_latched[5] 0 1 0 数据位 Bit 5 (值为0)
第 8 个 tick 7 -> 8 发送 data_latched[6] 1 1 0 数据位 Bit 6 (值为1)
第 9 个 tick 8 -> 9 发送 data_latched[7] 0 1 0 数据位 Bit 7 (最高位,值为0)
第 10 个 tick 9 -> 10 强制将导线拉高封口 1 1 0 停止位(Stop Bit)
第 11 个 tick 10 -> 0 结算收工,熄灭红灯,敲响绿灯 1 1 -> 0 1 帧结束结算!发出 done 脉冲!
下一拍 (20ns) 0 完成信号自动回弹熄灭 1 0 0 重回空闲态,等待下一次发信

第四章:顶层系统组装——uart_tx_demo_top.v 实战演练

现在,我们站在芯片架构师的视角,用一个顶层模块把**“1 秒定时器”“第 10 讲的 baud_tick 节拍器”以及“刚写好的 uart_tx 发报员”焊接在一起,实现开发板每隔整整 1.0 秒,自动向电脑串口发送一个英文字母 'U'**!

module uart_tx_demo_top #(
    parameter integer CLK_FREQ_HZ        = 50_000_000, // 50MHz 系统时钟
    parameter integer BAUD_RATE          = 115_200,    // 115200 波特率
    parameter integer SEND_INTERVAL_CLKS = 50_000_000, // 5000万拍 = 1.0秒发送间隔
    parameter [7:0]   DEMO_BYTE          = 8'h55       // 测试字符:大写字母 'U'
)(
    input  wire sys_clk,   // 板载 50MHz 晶振
    input  wire sys_rst_n, // 复位按键
    output wire uart_tx    // 直通开发板 USB 转串口芯片的 TXD 引脚!
);
    reg [31:0] interval_cnt; // 1 秒秒表计数器
    reg        tx_start;     // 触发脉冲:拍发报员肩膀的信号
    wire       tick_115200;  // 导线:接节拍器的 tick
    wire       tx_busy;      // 导线:接发报员的 busy 红灯
    wire       tx_done;      // 导线:接发报员的 done 绿灯

    // 1. 业务调度大脑:1 秒倒计时与发信保护
    always @(posedge sys_clk or negedge sys_rst_n) begin
        if (!sys_rst_n) begin
            interval_cnt <= 32'd0;
            tx_start     <= 1'b0;
        end else begin
            tx_start <= 1'b0; // 经典门铃弹簧:默认置 0

            // 核心安全保护:只有当发报员“不忙”时,才允许计时并发动下一次发送!
            if (!tx_busy) begin
                if (interval_cnt == SEND_INTERVAL_CLKS - 1) begin
                    interval_cnt <= 32'd0;
                    tx_start     <= 1'b1; // 1秒到!猛拍发报员肩膀!
                end else begin
                    interval_cnt <= interval_cnt + 1'b1;
                end
            end
        end
    end

    // 2. 安装节拍器 (第 10 讲成果)
    baud_tick #(
        .CLK_FREQ_HZ(CLK_FREQ_HZ),
        .BAUD_RATE  (BAUD_RATE)
    ) u_baud_tick (
        .clk (sys_clk),
        .rst_n(sys_rst_n),
        .tick (tick_115200) // 吐出的单脉冲挂在 tick_115200 线上
    );

    // 3. 安装发报员 (本讲核心成果)
    uart_tx u_uart_tx (
        .clk  (sys_clk),
        .rst_n(sys_rst_n),
        .tick (tick_115200), // 听节拍器的指挥!
        .start(tx_start),    // 听 1 秒定时器的命令!
        .data (DEMO_BYTE),   // 把测试字符 8'h55 递给它
        .tx   (uart_tx),     // 发报线直通外部物理引脚!送往电脑!
        .busy (tx_busy),     // 红灯拉回给总指挥看
        .done (tx_done)      // 绿灯拉回备用
    );
endmodule

第五章:为什么全世界硬件工程师都深爱字符 'U'8'h55)?

在代码中,参数定义了:

parameter [7:0] DEMO_BYTE = 8'h55;

很多初学者问:“老师,为什么不发字母 'A',不发数字 '0',偏偏挑大写字母 'U'?”

这是整个电子通信界一个令人拍案叫绝的工程智慧

示波器下的方波奇迹:

  • 我们把十六进制 8'h55 展开成完整的 8-N-1 帧格式:
    1. 起始位:0
    2. 数据位(低位先行):1, 0, 1, 0, 1, 0, 1, 0
    3. 停止位:1
  • 把这 10 个位按发送顺序连起来看:
    $$\mathbf{0 \ 1 \ 0 \ 1 \ 0 \ 1 \ 0 \ 1 \ 0 \ 1}$$
  • 看到了吗?从头到尾,每一个位都与相邻位严格高低反转!
  • 物理意义
    在示波器屏幕上,如果发送别的字符,电平会忽长忽短;唯独发送 8'h55 时,示波器上展现的是一道极其完美的标准等宽对称方波!
    • 工程师只要拿示波器点在 TX 引脚上,量一下这个方波的脉冲宽度是不是正好等于 $8.68\mu\text{s}$,就能在 1 秒钟内彻底确诊波特率是否精准无误、上升沿是否有阻抗畸变
    • 而在电脑串口助手里,8'h55 按照 ASCII 码表解析出来的字符,正好就是大写的英文字母 'U'

第六章:实验室上板测试与看图识 Bug

把工程编译生成比特流烧录到板子上,用 USB 数据线连上电脑:

1. 电脑端标准配置

打开任意串口助手软件(如 XCOM、SSCOM、MobaXterm、Putty):

  • 端口号(COM Port):在设备管理器中查看“端口(COM和LPT)”,选择对应的 USB Serial Port
  • 波特率(Baudrate):填入 115200
  • 数据位(Data bits):选择 8
  • 校验位(Parity):选择 None(无校验)
  • 停止位(Stop bits):选择 1
  • 接收设置:勾选“ASCII / 字符显示”。

2. 正常实验现象

点击“打开串口”,你将亲眼见证电脑屏幕上,每隔整整 1 秒钟,极为稳定、安静地自动弹出一个大写字母 'U'

U
U
U
U
...

3. 常见现场翻车排错指南

故障一:屏幕上一片死寂,连一个字符都收不到

  • 排查 1:管脚约束中 uart_tx 引脚是否与板载原理图对齐?(注意:FPGA 的 uart_tx 应该连到板载 USB 转串口芯片的 RXD 端!有的图纸标注混淆,尝试把 TX/RX 引脚对调);
  • 排查 2:确认 1 秒定时器是否因为 if (!tx_busy) 死锁。若发报员复位未完成导致 busy 一直为 1,定时器将永远被挂起。

故障二:屏幕每秒都在弹东西,但弹出来的全是乱七八糟的乱码(如 ? 或黑色问号菱形)

  • 排查 1:波特率对错!电脑端是否误选成了 9600?
  • 排查 2:板载晶振频率与代码参数不符!若板载晶振实际是 25MHz,而代码写的是 50_000_000,实际发出的波特率相差一倍,电脑必然乱码。

本讲核心总结

  1. 发送的核心本质:在时间轴上将 8 位并行寄存器通过阶段计数器(phase)解构成 10 位的标准串行方波。
  2. 前级快照锁存(data_latched:彻底粉碎发送中途数据变化导致的“数据撕裂(Tearing)”。
  3. busydone 的握手机制busy 建立防打扰闭门保护,done 提供单周期完成触发,构成高鲁棒性的系统闭环。
  4. 字符 'U'8'h55)的示波器传奇:交替的 01010101 构筑起硬件测试中无可替代的标准方波基准。