3、UART接收模块设计:起始位检测、数据位采样与串并转换

UART接收模块,说白了就是要把串行数据“接住”并还原成并行数据。很多初学者觉得发送简单接收难,确实如此。接收端要面对时钟不同步、噪声干扰、数据抖动等问题。我当年第一个FPGA项目就是做UART接收,踩了不少坑,今天把这些经验分享给你。

3.1 起始位检测:边沿检测与采样

UART协议规定,空闲时总线为高电平。发送端要发数据时,先把总线拉低一个位时间——这就是起始位。接收端要做的第一件事,就是检测到这个下降沿。

为什么不能直接检测低电平?你想想看,如果总线上有噪声毛刺,瞬间拉低又恢复,那不就误触发了?所以我们要做的是边沿检测 + 确认采样

核心思路:先检测下降沿,然后在下降沿之后的1/2位时间处采样,确认确实是低电平。这样能滤掉大部分毛刺。

边沿检测的Verilog实现很简单:

// 边沿检测:检测下降沿
reg rx_d0, rx_d1;
wire start_edge;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_d0 <= 1'b1;
        rx_d1 <= 1'b1;
    end else begin
        rx_d0 <= rx;
        rx_d1 <= rx_d0;
    end
end

assign start_edge = rx_d1 & !rx_d0;  // 下降沿检测

这里用了两级寄存器做同步和边沿检测。我个人习惯用三级:第一级同步,第二级打拍,第三级做边沿。为什么?因为异步信号进来,第一级可能亚稳态,第二级稳定了,第三级才用来做判断。

经验之谈:我曾经在一个高速项目中只用两级,结果偶尔出现误触发。后来改成三级,问题就消失了。对于低速UART(比如9600波特率),两级其实够用。但如果你追求可靠性,三级更稳妥。

3.2 数据位采样:过采样技术

检测到起始位后,接下来要在每个数据位的中间位置采样。问题来了:接收端的时钟和发送端不同步,怎么保证采到正确位置?

答案就是过采样。说白了,就是用比波特率高得多的时钟去采样。常用的过采样率有8倍、16倍、32倍。我个人最常用16倍过采样。

为什么是16倍?

  • 8倍:精度不够,容易采到边沿附近
  • 16倍:精度适中,实现简单
  • 32倍:精度高,但资源消耗大

16倍过采样的工作流程:

  1. 检测到起始位下降沿后,启动一个计数器
  2. 计数器从0数到15,对应一个位时间的16个采样点
  3. 在每个计数器的中间位置(比如第7或第8个时钟)采样数据
  4. 这样就能保证采样点落在数据位的正中间

关键点:起始位检测后的第一次采样,要等到半个位时间之后。也就是计数器数到7或8时,确认起始位确实是低电平。如果此时采到高电平,说明是毛刺,放弃本次接收。

过采样计数器的Verilog实现:

// 16倍过采样计数器
reg [3:0] sample_cnt;
reg [3:0] bit_cnt;
reg [7:0] data_reg;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        sample_cnt <= 4'd0;
        bit_cnt <= 4'd0;
        data_reg <= 8'd0;
    end else if (start_edge) begin
        // 检测到起始位,启动计数器
        sample_cnt <= 4'd0;
        bit_cnt <= 4'd0;
    end else if (receiving) begin
        if (sample_cnt == 4'd15) begin
            sample_cnt <= 4'd0;
            // 在中间位置采样(第7个时钟)
            if (sample_cnt == 4'd7) begin
                // 采样数据
                data_reg[bit_cnt] <= rx_d1;
                bit_cnt <= bit_cnt + 1'b1;
            end
        end else begin
            sample_cnt <= sample_cnt + 1'b1;
        end
    end
end

注意:上面的代码是简化版,实际项目中还要考虑起始位确认、停止位检测、超时处理等。我曾经在一个项目中忘记处理停止位,结果接收到的数据总是错位一个bit,排查了半天才发现。

3.3 串行转并行逻辑

UART数据是一bit一bit过来的,但我们要把它拼成8位(或9位)的并行数据。这个过程叫串并转换。

实现方式很简单:用一个移位寄存器,每收到一个bit就左移一位,新数据放在最低位。8个bit收完后,把移位寄存器的值锁存到输出寄存器。

// 串行转并行
reg [7:0] shift_reg;
reg [7:0] data_out;
reg data_valid;

always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        shift_reg <= 8'd0;
        data_out <= 8'd0;
        data_valid <= 1'b0;
    end else if (bit_cnt == 4'd7 && sample_cnt == 4'd7) begin
        // 第8个bit采样完成
        data_out <= {shift_reg[6:0], rx_d1};  // 拼接最新bit
        data_valid <= 1'b1;
    end else if (bit_cnt < 4'd8 && sample_cnt == 4'd7) begin
        // 正常移位
        shift_reg <= {shift_reg[6:0], rx_d1};
        data_valid <= 1'b0;
    end else begin
        data_valid <= 1'b0;
    end
end

这里有个细节:数据位是LSB先发还是MSB先发?UART标准是LSB first,也就是最低位先发。所以第一个收到的bit是bit0,第二个是bit1...第八个是bit7。移位寄存器左移,新数据放低位,正好符合这个顺序。

避坑指南:我曾经接手过一个项目,对方说UART通信不正常。我一看代码,他把移位方向搞反了,用右移存数据。结果收到的数据高低位颠倒,每个字节都要手动翻转。这种低级错误,其实花10分钟画个时序图就能避免。

3.4 奇偶校验实现

奇偶校验是UART的可选功能,用来检测传输错误。分两种:

  • 奇校验:数据位 + 校验位中,1的个数为奇数
  • 偶校验:数据位 + 校验位中,1的个数为偶数

接收端收到数据后,要重新计算校验位,和收到的校验位对比。不一致就报错。

Verilog实现奇偶校验计算:

// 奇偶校验计算
wire parity_even = ^data_reg;      // 异或:结果为1表示奇数个1
wire parity_odd  = !parity_even;   // 偶校验取反

// 接收端校验
reg parity_error;
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        parity_error <= 1'b0;
    end else if (data_valid) begin
        // 假设配置为偶校验
        if (parity_even != received_parity) begin
            parity_error <= 1'b1;  // 校验错误
        end else begin
            parity_error <= 1'b0;
        end
    end
end

这里用到了Verilog的归约异或运算符^,它能快速计算一个向量中1的个数是奇数还是偶数。这个小技巧很实用,我经常在项目中用。

实际项目中的建议:奇偶校验只能检测奇数个bit的错误。如果两个bit同时翻转,奇偶校验是检测不出来的。对于要求高的场景,建议用CRC校验。但UART的奇偶校验实现简单、资源少,在大多数场景下够用。

3.5 完整接收模块的Verilog代码

下面给出一个完整的UART接收模块代码,包含起始位检测、16倍过采样、串并转换和奇偶校验:

module uart_rx (
    input  wire       clk,        // 系统时钟
    input  wire       rst_n,      // 复位,低有效
    input  wire       rx,         // UART接收引脚
    input  wire       parity_en,  // 奇偶校验使能
    input  wire       parity_odd, // 1:奇校验, 0:偶校验
    output reg  [7:0] data_out,   // 接收到的数据
    output reg        data_valid, // 数据有效标志
    output reg        parity_err  // 校验错误标志
);

// 参数定义
parameter CLK_FREQ = 50_000_000;  // 系统时钟频率50MHz
parameter BAUD_RATE = 115200;     // 波特率
localparam BAUD_CNT = CLK_FREQ / BAUD_RATE / 16;  // 16倍过采样计数

// 信号定义
reg [15:0] baud_cnt;
reg [3:0]  sample_cnt;
reg [3:0]  bit_cnt;
reg [7:0]  shift_reg;
reg        rx_sync1, rx_sync2, rx_sync3;
wire       start_edge;
reg        receiving;
reg        parity_bit;

// 同步与边沿检测
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        rx_sync1 <= 1'b1;
        rx_sync2 <= 1'b1;
        rx_sync3 <= 1'b1;
    end else begin
        rx_sync1 <= rx;
        rx_sync2 <= rx_sync1;
        rx_sync3 <= rx_sync2;
    end
end

assign start_edge = rx_sync3 & !rx_sync2;

// 波特率时钟生成
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        baud_cnt <= 16'd0;
    end else if (receiving || start_edge) begin
        if (baud_cnt == BAUD_CNT - 1)
            baud_cnt <= 16'd0;
        else
            baud_cnt <= baud_cnt + 1'b1;
    end else begin
        baud_cnt <= 16'd0;
    end
end

// 采样计数器
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        sample_cnt <= 4'd0;
    end else if (baud_cnt == BAUD_CNT - 1) begin
        if (sample_cnt == 4'd15)
            sample_cnt <= 4'd0;
        else
            sample_cnt <= sample_cnt + 1'b1;
    end
end

// 接收状态机
always @(posedge clk or negedge rst_n) begin
    if (!rst_n) begin
        receiving <= 1'b0;
        bit_cnt <= 4'd0;
        shift_reg <= 8'd0;
        data_out <= 8'd0;
        data_valid <= 1'b0;
        parity_bit <= 1'b0;
        parity_err <= 1'b0;
    end else begin
        data_valid <= 1'b0;
        parity_err <= 1'b0;
        
        if (start_edge) begin
            // 检测到起始位,准备接收
            receiving <= 1'b1;
            bit_cnt <= 4'd0;
        end else if (receiving && sample_cnt == 4'd7 && baud_cnt == BAUD_CNT - 1) begin
            // 在数据位中间采样
            if (bit_cnt == 4'd0) begin
                // 确认起始位
                if (rx_sync2 == 1'b0)
                    bit_cnt <= bit_cnt + 1'b1;
                else
                    receiving <= 1'b0;  // 毛刺,放弃
            end else if (bit_cnt <= 4'd8) begin
                // 接收8个数据位
                shift_reg <= {shift_reg[6:0], rx_sync2};
                bit_cnt <= bit_cnt + 1'b1;
            end else if (bit_cnt == 4'd9) begin
                // 接收校验位(如果使能)
                if (parity_en) begin
                    parity_bit <= rx_sync2;
                    bit_cnt <= bit_cnt + 1'b1;
                end else begin
                    bit_cnt <= bit_cnt + 1'b1;
                end
            end else if (bit_cnt == 4'd10) begin
                // 接收停止位,输出数据
                data_out <= shift_reg;
                data_valid <= 1'b1;
                receiving <= 1'b0;
                
                // 奇偶校验检查
                if (parity_en) begin
                    if (parity_odd) begin
                        // 奇校验:数据+校验位中1的个数为奇数
                        if (^(shift_reg) == parity_bit)
                            parity_err <= 1'b0;
                        else
                            parity_err <= 1'b1;
                    end else begin
                        // 偶校验:数据+校验位中1的个数为偶数
                        if (^(shift_reg) != parity_bit)
                            parity_err <= 1'b0;
                        else
                            parity_err <= 1'b1;
                    end
                end
            end
        end
    end
end

endmodule

使用建议:这段代码可以直接用在你的项目中。参数化设计,改波特率只需要修改BAUD_RATE参数。我建议你在仿真时先跑一遍,看看波形是否和预期一致。特别是起始位检测和采样点位置,一定要确认。

3.6 仿真验证

写完了代码,不仿真等于白写。下面是一个简单的testbench:

module uart_rx_tb;

reg clk;
reg rst_n;
reg rx;
wire [7:0] data_out;
wire data_valid;
wire parity_err;

// 实例化接收模块
uart_rx #(
    .CLK_FREQ(50_000_000),
    .BAUD_RATE(115200)
) uut (
    .clk(clk),
    .rst_n(rst_n),
    .rx(rx),
    .parity_en(1'b1),
    .parity_odd(1'b0),
    .data_out(data_out),
    .data_valid(data_valid),
    .parity_err(parity_err)
);

// 时钟生成
always #10 clk = ~clk;  // 50MHz

// 测试序列
initial begin
    clk = 0;
    rst_n = 0;
    rx = 1;
    #100 rst_n = 1;
    
    // 发送数据 0x55 (01010101)
    // 起始位 + 8位数据(LSB first) + 偶校验 + 停止位
    #100;
    rx = 0;  // 起始位
    #8680;  // 115200波特率,一个位时间约8.68us
    
    rx = 1;  // bit0
    #8680;
    rx = 0;  // bit1
    #8680;
    rx = 1;  // bit2
    #8680;
    rx = 0;  // bit3
    #8680;
    rx = 1;  // bit4
    #8680;
    rx = 0;  // bit5
    #8680;
    rx = 1;  // bit6
    #8680;
    rx = 0;  // bit7
    #8680;
    
    // 偶校验:0x55有4个1,偶数,校验位应为0
    rx = 0;  // 校验位
    #8680;
    
    rx = 1;  // 停止位
    #8680;
    
    #1000;
    $finish;
end

endmodule

仿真时重点关注:

  • 起始位检测是否准确
  • 采样点是否在数据位中间
  • 串并转换后的数据是否正确
  • 奇偶校验标志是否正确

仿真陷阱:我见过有人仿真时用理想时钟,结果实际板子上跑不起来。建议你在仿真中加入时钟抖动和噪声,模拟真实环境。比如在rx信号上叠加一些毛刺,看看你的模块能不能正确滤除。

3.7 本章核心知识图谱

下面这张图总结了UART接收模块的核心逻辑,从信号输入到数据输出的完整流程:

UART接收模块核心逻辑 串行输入 rx 三级同步 边沿检测 起始位确认 (半位时间采样) 16倍过采样 计数器 数据位采样 (每个位中间位置) 串行转并行 (移位寄存器) 奇偶校验检查 (可选) 并行输出 data_out[7:0] 关键参数 • 波特率:115200 • 过采样率:16倍 • 数据位:8位 • 校验方式:奇/偶可选 避坑要点 • 三级同步防亚稳态 • 半位时间确认起始位 • 采样点选在数据位中间

这张图把整个接收流程串起来了。从rx信号进来,经过同步、边沿检测、起始位确认,再到过采样、数据位采样、串并转换,最后输出并行数据。每一步都有对应的Verilog代码实现。

好了,UART接收模块的核心内容就这些。代码可以直接用,但建议你根据自己的项目需求调整参数。仿真一定要做,而且要多做几个case,包括正常数据、毛刺干扰、校验错误等场景。这样上板子的时候才能心里有底。


公众号:蓝海资料掘金营,微信deep3321