10、UART串口通信:UART协议解析、波特率发生器设计、发送模块实现、接收模块实现、回环测试

UART串口,说白了就是嵌入式世界里最常用的“对讲机”。

我刚开始做FPGA那会儿,第一个调通的模块就是UART。为什么?因为它简单、直观,而且调试起来特别有成就感——你写个“A”,电脑上就能收到“A”,这种即时反馈对新手来说太重要了。

这一章,我们就来把UART串口通信彻底吃透。从协议解析到模块实现,再到回环测试,一步到位。

10.1 UART协议解析:一帧数据的“长相”

UART全称是Universal Asynchronous Receiver/Transmitter,通用异步收发器。注意“异步”这两个字——收发双方没有独立的时钟线,全靠约定好的波特率来同步。

一帧UART数据长什么样?我画个草图给你看:

空闲位(高电平) | 起始位(低) | 数据位(LSB先发) | 校验位(可选) | 停止位(高)

具体来说:

  • 空闲位:总线空闲时,TX线保持高电平
  • 起始位:一个低电平脉冲,告诉接收方“我要开始发了”
  • 数据位:5~8位,通常用8位。注意是LSB(最低有效位)先发
  • 校验位:奇校验或偶校验,可选。我一般不用,除非项目要求
  • 停止位:1位、1.5位或2位的高电平,表示帧结束

关键点:UART是“异步”的,所以收发双方的波特率必须一致。差一点没关系,但差太多就会丢数据。我曾经遇到过波特率偏差2%就导致通信不稳定的情况,嗯,那是个血泪教训。

10.2 波特率发生器设计:给UART一个“心跳”

波特率发生器,说白了就是一个分频器。系统时钟50MHz,我们要产生9600bps的波特率,怎么算?

公式很简单:

波特率分频值 = 系统时钟频率 / (目标波特率 × 采样倍数)

采样倍数通常取16。为什么是16?因为接收模块需要在每个数据位中间采样,16倍采样可以保证采样点落在位中间附近,容错性更好。

举个例子:50MHz时钟,目标波特率9600,采样倍数16:

分频值 = 50_000_000 / (9600 × 16) ≈ 325.52
取整:325

实际波特率 = 50_000_000 / (325 × 16) ≈ 9615bps,误差约0.16%,完全OK。

代码实现如下:

module baud_gen (
    input  wire        clk,
    input  wire        rst_n,
    output reg         baud_clk_en    // 波特率时钟使能信号
);

parameter   CLK_FREQ = 50_000_000;
parameter   BAUD_RATE = 9600;
localparam  DIV_CNT = CLK_FREQ / (BAUD_RATE * 16) - 1;

reg [15:0] cnt;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        cnt <= 16'd0;
        baud_clk_en <= 1'b0;
    end else if (cnt == DIV_CNT) begin
        cnt <= 16'd0;
        baud_clk_en <= 1'b1;
    end else begin
        cnt <= cnt + 1'b1;
        baud_clk_en <= 1'b0;
    end
end

endmodule

个人习惯:我通常把波特率参数化,这样在顶层模块里可以随时切换。比如调试时用115200,量产时用9600,改一个参数就行,不用动代码。

10.3 发送模块实现:把并行数据“串”出去

发送模块的任务很简单:把CPU给的8位并行数据,按照UART协议一比特一比特地发出去。

状态机设计:

  • IDLE:空闲状态,TX线拉高。检测到发送请求,跳转到START
  • START:发送起始位(低电平),持续1个位时间
  • DATA:发送8个数据位,从LSB开始
  • STOP:发送停止位(高电平),持续1个位时间

核心代码片段:

module uart_tx (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        baud_clk_en,
    input  wire [7:0]  data_in,
    input  wire        send_en,
    output reg         tx,
    output reg         tx_busy
);

localparam IDLE  = 2'd0;
localparam START = 2'd1;
localparam DATA  = 2'd2;
localparam STOP  = 2'd3;

reg [1:0] state;
reg [3:0] bit_cnt;
reg [7:0] data_reg;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state <= IDLE;
        tx <= 1'b1;
        tx_busy <= 1'b0;
        bit_cnt <= 4'd0;
    end else if (baud_clk_en) begin
        case (state)
            IDLE: begin
                tx <= 1'b1;
                tx_busy <= 1'b0;
                if (send_en) begin
                    data_reg <= data_in;
                    state <= START;
                    tx_busy <= 1'b1;
                end
            end
            START: begin
                tx <= 1'b0;
                state <= DATA;
                bit_cnt <= 4'd0;
            end
            DATA: begin
                tx <= data_reg[bit_cnt];
                if (bit_cnt == 4'd7) begin
                    state <= STOP;
                end else begin
                    bit_cnt <= bit_cnt + 1'b1;
                end
            end
            STOP: begin
                tx <= 1'b1;
                state <= IDLE;
                tx_busy <= 1'b0;
            end
        endcase
    end
end

endmodule

注意:发送使能信号send_en必须保持至少一个波特率时钟周期,否则可能漏触发。我习惯在外部做一个边沿检测,把脉冲展宽一下。

10.4 接收模块实现:从“串”里恢复并行数据

接收比发送稍微复杂一点。为什么?因为接收方不知道数据什么时候来,需要实时检测起始位。

接收的核心思路:

  1. 用16倍波特率时钟采样RX线
  2. 检测到下降沿(起始位开始)
  3. 在起始位中间(第8个采样点)确认确实是低电平
  4. 之后每隔16个采样点采集一个数据位

状态机设计:

  • IDLE:等待起始位。检测到RX下降沿,跳转到START
  • START:在起始位中间采样,确认是低电平。如果检测到高电平,认为是毛刺,回到IDLE
  • DATA:采集8个数据位,每个位在中间点采样
  • STOP:采集停止位,确认是高电平。完成后输出数据

核心代码片段:

module uart_rx (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        baud_clk_en,
    input  wire        rx,
    output reg [7:0]   data_out,
    output reg         data_valid
);

localparam IDLE  = 2'd0;
localparam START = 2'd1;
localparam DATA  = 2'd2;
localparam STOP  = 2'd3;

reg [1:0] state;
reg [3:0] sample_cnt;
reg [3:0] bit_cnt;
reg [7:0] data_reg;
reg       rx_sync, rx_prev;

// 同步和边沿检测
always @(posedge clk) begin
    rx_sync <= rx;
    rx_prev <= rx_sync;
end
wire rx_fall = (rx_prev && !rx_sync);

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        state <= IDLE;
        data_out <= 8'd0;
        data_valid <= 1'b0;
        sample_cnt <= 4'd0;
        bit_cnt <= 4'd0;
    end else if (baud_clk_en) begin
        case (state)
            IDLE: begin
                data_valid <= 1'b0;
                if (rx_fall) begin
                    state <= START;
                    sample_cnt <= 4'd0;
                end
            end
            START: begin
                if (sample_cnt == 4'd7) begin
                    if (!rx_sync) begin
                        state <= DATA;
                        sample_cnt <= 4'd0;
                        bit_cnt <= 4'd0;
                    end else begin
                        state <= IDLE;  // 毛刺,放弃
                    end
                end else begin
                    sample_cnt <= sample_cnt + 1'b1;
                end
            end
            DATA: begin
                if (sample_cnt == 4'd15) begin
                    data_reg[bit_cnt] <= rx_sync;
                    sample_cnt <= 4'd0;
                    if (bit_cnt == 4'd7) begin
                        state <= STOP;
                    end else begin
                        bit_cnt <= bit_cnt + 1'b1;
                    end
                end else begin
                    sample_cnt <= sample_cnt + 1'b1;
                end
            end
            STOP: begin
                if (sample_cnt == 4'd15) begin
                    if (rx_sync) begin
                        data_out <= data_reg;
                        data_valid <= 1'b1;
                    end
                    state <= IDLE;
                end else begin
                    sample_cnt <= sample_cnt + 1'b1;
                end
            end
        endcase
    end
end

endmodule

避坑指南:我曾经在接收模块里忘记做同步处理,直接把外部RX信号接到状态机里。结果因为亚稳态问题,偶尔会收到错误数据。后来老老实实加了两级同步寄存器,问题就解决了。记住:跨时钟域的信号一定要同步!

10.5 回环测试:验证收发是否正常

回环测试,就是把TX和RX短接,发送一个数据,看能不能正确收回来。这是验证UART模块最直接的方法。

测试思路:

  1. 顶层例化一个发送模块和一个接收模块
  2. 把发送模块的TX输出接到接收模块的RX输入
  3. 发送一个字节,等待接收完成
  4. 比较发送数据和接收数据是否一致

顶层模块示意:

module uart_loopback (
    input  wire        clk,
    input  wire        rst_n,
    input  wire        send_en,
    input  wire [7:0]  data_in,
    output wire [7:0]  data_out,
    output wire        data_valid,
    output wire        tx_busy
);

wire baud_clk_en;
wire tx_wire;

baud_gen #(
    .CLK_FREQ(50_000_000),
    .BAUD_RATE(9600)
) u_baud (
    .clk(clk),
    .rst_n(rst_n),
    .baud_clk_en(baud_clk_en)
);

uart_tx u_tx (
    .clk(clk),
    .rst_n(rst_n),
    .baud_clk_en(baud_clk_en),
    .data_in(data_in),
    .send_en(send_en),
    .tx(tx_wire),
    .tx_busy(tx_busy)
);

uart_rx u_rx (
    .clk(clk),
    .rst_n(rst_n),
    .baud_clk_en(baud_clk_en),
    .rx(tx_wire),
    .data_out(data_out),
    .data_valid(data_valid)
);

endmodule

仿真时,你只需要给一个send_en脉冲,然后等data_valid拉高,检查data_out是否等于data_in。如果相等,恭喜你,UART通信调通了!

调试技巧:如果回环测试不通过,先检查波特率发生器。用示波器或者逻辑分析仪看TX脚的波形,确认每个位的宽度是否正确。我遇到过好几次都是分频值算错了,导致波特率偏差太大。

嗯,UART串口通信这一块,说难不难,说简单也不简单。关键是把协议吃透,把状态机写稳。你想想看,只要掌握了UART,后面学SPI、I2C这些串行协议,思路都是相通的。

下一章,我们会把UART和之前学的按键、LED结合起来,做一个完整的串口控制实验。到时候你就知道,FPGA和PC通信有多爽了。