一、UART发送模块设计:从状态机到Verilog实现

UART发送模块,说白了就是把并行的数据变成串行的一比特一比特发出去。这个模块我做过不下几十次了,每次新项目用到串口通信,我都会把老代码翻出来改一改。今天咱们就把它彻底讲透。

1.1 状态机设计:IDLE、START、DATA、STOP

UART发送的核心是一个状态机。为什么用状态机?因为发送过程是分步骤的——空闲、起始位、数据位、停止位,每一步做的事情不一样。用状态机来管理,逻辑清晰,不容易出错。

我个人习惯把状态机分成四个状态:

  • IDLE(空闲态):发送线保持高电平。等待发送使能信号。
  • START(起始态):拉低发送线,持续一个波特率周期。告诉接收端“我要发数据了”。
  • DATA(数据态):逐位发送数据,从LSB到MSB。每比特持续一个波特率周期。
  • STOP(停止态):拉高发送线,持续一个波特率周期。表示一帧结束。

嗯,这里要注意:有些设计会把STOP态和IDLE态合并,但我建议分开。为什么?因为STOP态结束后,你可能需要检查是否有新的数据要发,或者直接回到IDLE。分开写,代码可读性更好,后期维护也方便。

状态转移条件:

  • IDLE → START:发送使能信号有效(tx_start = 1)
  • START → DATA:起始位发送完成(baud_tick计数到)
  • DATA → DATA:数据位未发完,继续发下一位
  • DATA → STOP:所有数据位发送完毕
  • STOP → IDLE:停止位发送完成

我曾经在一个项目中,因为状态机里漏写了DATA到DATA的自循环,结果数据只发了第一位就跳到STOP了。查了两天才发现,教训深刻啊。

1.2 波特率时钟生成

波特率时钟,说白了就是控制发送速度的节拍器。比如你要发9600bps,那每比特的持续时间就是1/9600 ≈ 104微秒。

怎么生成这个节拍?用计数器。假设系统时钟是50MHz,那一个时钟周期是20ns。要得到104微秒的间隔,需要计数:

BAUD_DIV = 50_000_000 / 9600 = 5208

但这里有个坑:你实际上需要的是波特率时钟的使能信号,而不是真正的时钟。为什么?因为用分频后的时钟去驱动状态机,容易产生跨时钟域问题。我建议的做法是:

// 波特率时钟生成
reg [12:0] baud_cnt;
wire baud_tick;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        baud_cnt <= 0;
    else if (baud_cnt == BAUD_DIV - 1)
        baud_cnt <= 0;
    else
        baud_cnt <= baud_cnt + 1;
end

assign baud_tick = (baud_cnt == BAUD_DIV - 1);

这样,baud_tick就是一个脉冲信号,每个波特率周期产生一次。状态机在baud_tick有效时进行状态转移,既简单又可靠。

小技巧:如果你需要支持多种波特率,可以把BAUD_DIV做成参数或者寄存器。我在一个项目中就做过9600、19200、115200三档可调,用case语句选择不同的分频值。

1.3 并行转串行逻辑

并行转串行,就是把8位数据(比如tx_data[7:0])变成一比特一比特地发出去。实现方式很简单:用一个移位寄存器。

我的做法是这样的:

reg [7:0] tx_shift;
reg [2:0] bit_cnt;

// 数据加载与移位
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        tx_shift <= 8'd0;
        bit_cnt <= 3'd0;
    end else if (state == IDLE && tx_start) begin
        tx_shift <= tx_data;  // 加载并行数据
        bit_cnt <= 3'd0;
    end else if (state == DATA && baud_tick) begin
        tx_shift <= {1'b0, tx_shift[7:1]};  // 右移
        bit_cnt <= bit_cnt + 1;
    end
end

// 发送线输出
assign tx = (state == START) ? 1'b0 :
            (state == DATA)  ? tx_shift[0] :
            (state == STOP)  ? 1'b1 :
            1'b1;  // IDLE状态也保持高电平

你想想看,这里为什么用tx_shift[0]而不是tx_shift[7]?因为UART协议规定先发LSB(最低位)。所以数据加载后,每次右移,最低位先出去。等8个比特发完,bit_cnt正好等于7,状态机就跳到STOP了。

注意:数据加载的时机一定要对。我见过有人把加载放在START态,结果起始位还没发完,数据就被覆盖了。正确的做法是在IDLE态检测到发送使能时立即加载,然后下一个时钟周期进入START态。

1.4 完整Verilog代码实现

好了,把上面几块拼起来,就是一个完整的UART发送模块。我直接贴代码,注释写得很清楚:

module uart_tx (
    input  wire       clk,        // 系统时钟
    input  wire       rst_n,      // 异步复位,低有效
    input  wire       tx_start,   // 发送使能
    input  wire [7:0] tx_data,    // 待发送数据
    output reg        tx          // 串行输出
);

// 参数定义
parameter BAUD_DIV = 5208;  // 50MHz时钟,9600bps

// 状态编码
localparam IDLE  = 2'b00;
localparam START = 2'b01;
localparam DATA  = 2'b10;
localparam STOP  = 2'b11;

// 信号声明
reg [1:0] state, next_state;
reg [12:0] baud_cnt;
wire baud_tick;
reg [7:0] tx_shift;
reg [2:0] bit_cnt;

// 状态机 - 时序逻辑
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        state <= IDLE;
    else
        state <= next_state;
end

// 状态机 - 组合逻辑
always @(*) begin
    next_state = state;
    case (state)
        IDLE:   if (tx_start) next_state = START;
        START:  if (baud_tick) next_state = DATA;
        DATA:   if (baud_tick && bit_cnt == 3'd7) next_state = STOP;
        STOP:   if (baud_tick) next_state = IDLE;
        default: next_state = IDLE;
    endcase
end

// 波特率计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n)
        baud_cnt <= 0;
    else if (baud_cnt == BAUD_DIV - 1)
        baud_cnt <= 0;
    else
        baud_cnt <= baud_cnt + 1;
end

assign baud_tick = (baud_cnt == BAUD_DIV - 1);

// 移位寄存器与位计数
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        tx_shift <= 8'd0;
        bit_cnt <= 3'd0;
    end else if (state == IDLE && tx_start) begin
        tx_shift <= tx_data;
        bit_cnt <= 3'd0;
    end else if (state == DATA && baud_tick) begin
        tx_shift <= {1'b0, tx_shift[7:1]};
        bit_cnt <= bit_cnt + 1;
    end
end

// 发送线输出
always @(*) begin
    case (state)
        IDLE:   tx = 1'b1;
        START:  tx = 1'b0;
        DATA:   tx = tx_shift[0];
        STOP:   tx = 1'b1;
        default: tx = 1'b1;
    endcase
end

endmodule

1.5 仿真验证

代码写完了,不仿真等于白写。我习惯用Vivado或者ModelSim做仿真。下面是一个简单的testbench:

module tb_uart_tx;

reg clk, rst_n;
reg tx_start;
reg [7:0] tx_data;
wire tx;

uart_tx #(.BAUD_DIV(52)) uut (  // 仿真时用更小的分频值
    .clk(clk),
    .rst_n(rst_n),
    .tx_start(tx_start),
    .tx_data(tx_data),
    .tx(tx)
);

initial begin
    clk = 0;
    forever #10 clk = ~clk;  // 50MHz
end

initial begin
    rst_n = 0;
    tx_start = 0;
    tx_data = 8'h00;
    #100 rst_n = 1;
    #100;
    
    // 发送数据 0xA5
    @(posedge clk);
    tx_data = 8'hA5;
    tx_start = 1;
    @(posedge clk);
    tx_start = 0;
    
    // 等待发送完成
    #20000;
    $finish;
end

endmodule

仿真时要注意:把BAUD_DIV设小一点,比如52,这样仿真时间短。你可以在波形里看到:起始位拉低、8个数据位(0xA5 = 10100101,LSB先发,所以顺序是1-0-1-0-0-1-0-1)、停止位拉高。如果波形对得上,说明模块工作正常。

仿真检查要点:

  • 起始位持续时间是否等于一个波特率周期?
  • 数据位顺序是不是LSB first?
  • 停止位之后是否回到高电平?
  • 连续发送两帧数据,帧间间隔是否正确?

1.6 核心逻辑流程图

下面我用一张SVG图把整个发送模块的逻辑串起来,方便你理解:

UART发送模块核心逻辑流程图 clk, rst_n tx_start, tx_data 波特率时钟生成 baud_tick 计数器: 0 ~ BAUD_DIV-1 状态机 (FSM) IDLE START DATA STOP 转移条件: tx_start, baud_tick, bit_cnt 并行转串行 移位寄存器 + bit_cnt tx 输出 串行数据

这张图把整个流程串起来了:输入信号进入后,波特率生成模块产生节拍,状态机根据节拍和当前状态决定下一步做什么,同时移位寄存器把并行数据转成串行,最终从tx引脚输出。

1.7 避坑指南

最后,分享几个我在项目中踩过的坑:

  • 复位后tx状态不确定:我曾经忘记在复位时把tx拉高,结果上电后接收端一直检测到低电平,以为有数据来。记住,IDLE态tx必须为高。
  • 连续发送时丢帧:如果上一帧刚发完,下一帧的tx_start就来了,状态机可能来不及回到IDLE。我的解决办法是在STOP态的最后几个时钟周期就检测tx_start,提前加载数据。
  • 波特率误差累积:如果BAUD_DIV计算有舍入误差,长时间发送会累积偏移。对于9600bps,50MHz时钟下误差很小,但如果是1MHz时钟发115200bps,误差就大了。建议用公式:误差 = |目标波特率 - 实际波特率| / 目标波特率,控制在2%以内。

好了,UART发送模块的核心内容就这些。代码可以直接拿去用,但建议你根据实际项目需求调整参数。下一节咱们聊接收模块,那个比发送稍微复杂一点,因为要处理采样和噪声。


专注资料整理