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倍过采样的工作流程:
- 检测到起始位下降沿后,启动一个计数器
- 计数器从0数到15,对应一个位时间的16个采样点
- 在每个计数器的中间位置(比如第7或第8个时钟)采样数据
- 这样就能保证采样点落在数据位的正中间
关键点:起始位检测后的第一次采样,要等到半个位时间之后。也就是计数器数到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接收模块的核心逻辑,从信号输入到数据输出的完整流程:
这张图把整个接收流程串起来了。从rx信号进来,经过同步、边沿检测、起始位确认,再到过采样、数据位采样、串并转换,最后输出并行数据。每一步都有对应的Verilog代码实现。
好了,UART接收模块的核心内容就这些。代码可以直接用,但建议你根据自己的项目需求调整参数。仿真一定要做,而且要多做几个case,包括正常数据、毛刺干扰、校验错误等场景。这样上板子的时候才能心里有底。
公众号:蓝海资料掘金营,微信deep3321