一、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图把整个发送模块的逻辑串起来,方便你理解:
这张图把整个流程串起来了:输入信号进入后,波特率生成模块产生节拍,状态机根据节拍和当前状态决定下一步做什么,同时移位寄存器把并行数据转成串行,最终从tx引脚输出。
1.7 避坑指南
最后,分享几个我在项目中踩过的坑:
- 复位后tx状态不确定:我曾经忘记在复位时把tx拉高,结果上电后接收端一直检测到低电平,以为有数据来。记住,IDLE态tx必须为高。
- 连续发送时丢帧:如果上一帧刚发完,下一帧的tx_start就来了,状态机可能来不及回到IDLE。我的解决办法是在STOP态的最后几个时钟周期就检测tx_start,提前加载数据。
- 波特率误差累积:如果BAUD_DIV计算有舍入误差,长时间发送会累积偏移。对于9600bps,50MHz时钟下误差很小,但如果是1MHz时钟发115200bps,误差就大了。建议用公式:误差 = |目标波特率 - 实际波特率| / 目标波特率,控制在2%以内。
好了,UART发送模块的核心内容就这些。代码可以直接拿去用,但建议你根据实际项目需求调整参数。下一节咱们聊接收模块,那个比发送稍微复杂一点,因为要处理采样和噪声。