1. 项目概述与核心价值
最近在折腾一个FPGA的小项目,需要和上位机进行简单的数据交互,第一时间就想到了UART(通用异步收发传输器)。这玩意儿可以说是数字通信里的“老黄牛”了,结构简单、可靠性高,几乎是所有嵌入式开发者和硬件工程师的入门必修课。虽然市面上现成的IP核一抓一大把,但自己动手用Verilog从头实现一个,对于深入理解串行通信的时序、状态机设计以及跨时钟域处理这些核心概念,有着不可替代的价值。这次我们要剖析的,就是一个来自开源社区的、结构清晰、非常适合学习和二次开发的8位UART Verilog实现。
这个UART模块麻雀虽小,五脏俱全。它严格遵循了最经典的异步串行通信格式:每个数据帧包含1个起始位、8个数据位和1个停止位。没有使用奇偶校验,这在实际的简单调试和低速通信中非常常见,能让我们更专注于核心收发逻辑。模块的接口设计得相当规整,将接收(RX)和发送(TX)路径清晰地分离,并提供了完备的状态指示信号,如rxBusy、txDone、rxErr等,这使得它与外部控制器(无论是软核CPU还是纯逻辑状态机)的交互变得非常直观和友好。
注意:自己编写UART的核心目的,绝不仅仅是“让它跑起来”。更重要的是理解其内部三个核心子模块——波特率发生器、接收器、发送器——是如何协同工作的,以及如何根据系统时钟和目標波特率来精确地生成采样点。这是后续进行性能优化、添加硬件流控或自定义协议的基础。
对于初学者而言,这个项目是一个绝佳的起点。对于有经验的开发者,其简洁的架构也便于你快速集成到自己的项目中,或者以此为蓝本,扩展出支持可变数据位宽、奇偶校验、多停止位等更复杂的功能。接下来,我们就一层层剥开它的设计,看看里面到底是如何运作的。
2. 模块架构与接口深度解析
一个完整的UART模块,可以清晰地划分为三个功能性子模块:波特率发生器、接收器和发送器。这个项目的结构图(uart_structure.png)直观地展示了这一点。整个顶层模块(uart)主要扮演了“接线板”和“参数传递者”的角色,将三个子模块实例化,并把正确的信号连接起来。
2.1 顶层接口信号详解
让我们逐一审视顶层模块的每一个输入输出端口,理解其设计意图和时序要求。这是将模块成功集成到你工程中的第一步。
控制信号:
clk:这是整个模块的“心跳”,所有同步逻辑都基于此时钟的上升沿动作。它通常直接连接到FPGA的全局时钟网络。模块内部没有使用任何异步复位,其初始状态依赖于Verilog的初始值或依赖于后续使能信号进入正确状态。
接收端(RX)接口:
rx:串行数据输入线。空闲时为高电平。当检测到持续一个波特率周期的低电平时,模块将其识别为起始位。rxEn:接收使能信号。高电平有效。当它为低时,接收器应被禁用,可能忽略rx线上的数据,并复位内部状态。这是一个重要的节能和控制特性。out[7:0]:并行数据输出。当一帧数据接收完成且无误时,接收到的8位数据会出现在这组端口上。数据会保持稳定,直到下一帧数据接收完成并被覆盖。rxDone:接收完成脉冲信号。高电平有效,仅持续一个时钟周期。它标志着out总线上的数据是新鲜且有效的,可以供外部电路读取。这是读取数据最可靠的握手信号。rxBusy:接收忙状态指示。高电平表示接收器正在处理一帧数据(从检测到起始位开始,到停止位采样结束)。在此期间,新的起始位将被忽略。rxErr:接收错误标志脉冲。高电平有效,持续一个时钟周期。当接收器检测到帧错误(例如,在预期的停止位位置采样到的不是高电平)时,会拉高此信号。同时,rxDone信号不会产生,因为该帧数据被视为无效。
发送端(TX)接口:
txEn:发送使能信号。高电平有效。禁用时,发送器应停止工作,并将tx输出置为高电平(空闲状态)。txStart:发送启动脉冲信号。高电平有效,仅需持续一个时钟周期。当txBusy为低且txEn为高时,一个txStart的上升沿会触发发送器开始发送一帧数据,同时锁存此时in总线上的值。in[7:0]:并行数据输入。需要发送的8位数据。在txStart有效时被锁存进发送器内部。tx:串行数据输出线。空闲时为高电平。发送时,依次输出起始位(低)、8位数据位(LSB先发)、停止位(高)。txDone:发送完成脉冲信号。高电平有效,持续一个时钟周期。它标志着一帧数据已完全送出,发送器回归空闲,可以接受新的发送任务。txBusy:发送忙状态指示。高电平表示发送器正在发送一帧数据。在此期间,新的txStart脉冲将被忽略。
2.2 关键参数:CLOCK_RATE与BAUD_RATE
模块的两个参数CLOCK_RATE和BAUD_RATE是理解其工作原理的钥匙。它们不是直接以Hz为单位的频率值,而是一种“比例因子”或“分频系数”。
CLOCK_RATE:可以理解为,系统时钟(clk)频率是目标波特率时钟的多少倍。目标波特率时钟是指波特率发生器输出的、一个周期等于一个比特位时间的时钟使能脉冲。BAUD_RATE:通常设为1。它定义了波特率发生器输出脉冲的占空比或模式。在这个实现中,BAUD_RATE=1意味着波特率时钟是一个单周期脉冲。
那么,如何根据实际的FPGA系统时钟频率和所需的通信波特率来计算这两个参数呢?公式如下:CLOCK_RATE = FPGA系统时钟频率 / 目标波特率
举个例子:你的FPGA板载晶振是50MHz(即clk频率为50,000,000 Hz),你想要实现115200的波特率。 计算:CLOCK_RATE = 50,000,000 / 115,200 ≈ 434这意味着,波特率发生器需要每计数434个系统时钟周期,就产生一个单周期脉冲。这个脉冲的周期就是1/115200秒,即一个比特位的时间。
在代码中,你需要这样实例化模块:
uart #( .CLOCK_RATE(434), // 50MHz / 115200 ≈ 434 .BAUD_RATE(1) ) my_uart_instance ( .clk(clk_50m), // ... 其他端口连接 );实操心得:计算出的
CLOCK_RATE可能不是整数。例如,50MHz时钟对于9600波特率,分频系数是5208.333...。这时必须取整(5208),这会引入微小的时序误差。误差率 = (理论值 - 实际值) / 理论值。对于5208.333取整到5208,误差约为0.006%,远低于RS-232标准允许的误差范围(通常<3%),完全可接受。但如果你使用低频主时钟(如1MHz)去实现高波特率(如115200),分频系数仅为8.68,取整为9带来的误差就高达3.7%,可能导致通信失败。因此,选择主时钟频率时,应使其能被目标波特率整除或得到足够大的分频系数。
3. 核心子模块原理与实现细节
理解了顶层框架后,我们深入三个核心子模块,看看它们是如何用Verilog状态机来实现的。
3.1 波特率发生器:精准的“节拍器”
波特率发生器是整个UART的时序基准。它的任务不是产生一个新的时钟,而是产生一个周期性的使能脉冲(通常称为tick或baud_en),其频率等于目标波特率。
在这个设计中,它本质上是一个自由运行的分频器。内部有一个计数器,从0计数到CLOCK_RATE-1,然后归零,同时输出一个单周期的高电平脉冲。这个脉冲的上升沿标志着“一个比特位时间窗口”的开始,接收器和发送器都依据这个脉冲来推进各自的状态。
代码逻辑简析:
always @(posedge clk) begin if (counter == CLOCK_RATE - 1) begin counter <= 0; baud_tick <= 1'b1; // 产生波特率使能脉冲 end else begin counter <= counter + 1; baud_tick <= 1'b0; end end接收器和发送器内部会有更细粒度的计数器(例如,计数16个baud_tick来定位一个比特位的中间采样点),但所有时序的根源都来自于这个稳定的baud_tick。
3.2 接收器:在噪声中捕捉数据
接收器是UART设计中挑战性较高的部分,因为它需要从异步的串行信号中可靠地恢复出数据。其核心是一个状态机,通常包含以下状态:IDLE(空闲)、START_BIT(检测起始位)、DATA_BITS(接收数据位)、STOP_BIT(检测停止位)。
工作流程与关键技巧:
空闲与起始位检测:在
IDLE状态且rxEn有效时,持续监测rx线。一旦检测到rx变为低电平(起始位开始),并非立即跳转状态,而是等待半个比特位时间(例如,在波特率使能脉冲baud_tick计数到CLOCK_RATE/2时)。这样做是为了避开起始位开始边沿可能存在的毛刺和不确定性,并将采样点对准每个数据位的中央,这里是数据最稳定的时刻。数据位采样:进入
DATA_BITS状态后,每等待一个完整的比特位时间(即baud_tick计数满一个周期),就在该比特位时间的中心点对rx线进行一次采样,并将采样值移入移位寄存器。通常从最低有效位(LSB)开始接收。停止位验证与完成:在接收完第8个数据位后,状态机进入
STOP_BIT状态。在停止位的中心点再次采样rx线。这里是一个关键的检错点:如果采样到高电平,则认为帧格式正确,产生rxDone脉冲,并将移位寄存器中的数据输出到out总线;如果采样到低电平,则产生rxErr脉冲,表示发生了帧错误(可能是波特率不匹配、线路干扰或发送方故障)。抗抖动与过采样:在一些更稳健的设计中,会对每个比特位进行多次采样(如16次),然后取中间值或多数值作为最终采样结果,这能有效抑制信号上的毛刺。本实现采用了经典的“中点单次采样”,在环境良好时完全够用。
注意事项:接收器的
rx信号对于FPGA内部同步逻辑来说是异步的。虽然本模块可能没有显式地进行同步器处理(这依赖于外部输入),但在实际工程中,强烈建议在顶层模块对rx信号使用两级D触发器进行同步化,以防止亚稳态传播到接收状态机中。这是一个常见的可靠性设计。
3.3 发送器:按部就班的“广播员”
相比接收器,发送器的逻辑更为直接,因为它完全由内部时钟和状态机控制。它也是一个状态机,包含IDLE、START_BIT、DATA_BITS、STOP_BIT等状态。
工作流程:
- 触发与锁存:当
txEn有效且txBusy为低时,一个txStart的上升沿会触发发送过程。发送器首先将当前in总线上的数据锁存到内部寄存器中,以防止发送过程中外部数据变化。 - 顺序发送:状态机从
IDLE进入START_BIT,将tx线拉低一个比特位时间。然后依次遍历DATA_BITS状态,将锁存数据的每一位(从LSB开始)放到tx线上,每位持续一个比特位时间。最后进入STOP_BIT状态,将tx线拉高一个比特位时间。 - 完成与空闲:停止位发送完毕后,状态机回到
IDLE,产生txDone脉冲,并将txBusy拉低。tx线保持高电平(空闲状态)。
发送器的设计难点较少,主要确保时序精确即可。tx信号是由模块内部同步产生的,不存在异步问题。
4. 仿真验证与测试平台搭建
作者提供的功能仿真波形图(uart_func_model.png等)是理解模块行为的绝佳资料。从图中可以看到,在设定的极低频时钟和波特率下(方便观察),各个信号(rx,tx,busy,done等)之间的时序关系一目了然。
4.1 如何解读仿真波形
以接收器仿真为例(rx_func_model.png):
en(即rxEn)为高,使能接收器。rx输入线上出现一个低电平脉冲(起始位),随后是8位数据(例如01010101),最后是一个高电平(停止位)。- 可以看到
busy信号在起始位后立即拉高,在整个帧接收期间保持高电平。 - 帧接收完成后,
busy拉低,同时done信号产生一个单周期脉冲,此时out总线上的数据有效。 - 如果
rx线上的停止位为低,则err信号会拉高,而done不会产生。
这些波形完美印证了之前描述的状态机行为。
4.2 构建自己的测试平台
虽然项目TODO里提到了testbench,但我们完全可以自己动手编写一个简单的测试来验证模块功能。使用Verilog或SystemVerilog编写测试平台(Testbench)是硬件设计的基本功。
一个基础的UART测试平台通常包括:
- 时钟生成:用
always块产生周期性的clk信号。 - 任务驱动:编写两个主要任务:
task uart_tx_byte:模拟上位机行为,根据设定的波特率,将1位起始位、8位数据、1位停止位依次驱动到UART模块的rx输入端。task uart_rx_check:监控UART模块的tx输出端,按照波特率采样,将接收到的串行数据转换为并行字节,并与预期值比较。
- 测试序列:在
initial块中,初始化信号,然后使能收发器,接着用uart_tx_byte任务发送几个特定字节(如8’h55、8’hAA、8’h00、8’hFF),同时用uart_rx_check任务检查发送端输出的数据是否正确。可以故意发送一个错误的停止位,验证rxErr功能。 - 波形输出与断言:使用
$display在控制台打印测试信息,或使用assert语句进行自动检查。
实操心得:在仿真中,为了加快速度,可以使用比实际高得多的“虚拟”系统时钟频率和“虚拟”波特率,只要保持它们的比例(
CLOCK_RATE)正确即可。例如,设置clk周期为10ns(100MHz),目标“虚拟波特率”为10MHz,那么CLOCK_RATE就设为10。这样,仿真发送一个字节只需要1微秒左右,能极大提升仿真调试效率。等到功能验证无误后,再替换为真实的时钟和波特率参数进行时序仿真。
5. 集成到FPGA项目:从仿真到上板
将仿真通过的UART模块集成到真实的FPGA项目中,并连接到物理引脚,还需要一些步骤。
5.1 引脚分配与约束
你需要根据FPGA开发板的原理图,将UART模块的rx和tx信号分配到特定的芯片引脚上,这些引脚应连接到板载的USB-UART桥接芯片(如CH340、CP2102、FT232等)的对应RXD和TXD。
以Quartus为例,你需要编写一个.qsf文件或通过GUI进行设置:
set_location_assignment PIN_AB12 -to rx set_location_assignment PIN_AB13 -to tx set_location_assignment PIN_Y2 -to clk同时,还需要为clk引脚创建时钟约束,告诉时序分析工具时钟的频率:
create_clock -name sys_clk -period 20.000 [get_ports clk]5.2 添加同步器与消抖
如前所述,对于来自外部世界的rx信号,务必添加同步器。可以在顶层模块中实例化UART之前这样做:
reg rx_sync1, rx_sync2; always @(posedge clk) begin rx_sync1 <= rx_pin; // rx_pin是直接来自FPGA引脚的信号 rx_sync2 <= rx_sync1; end // 将rx_sync2连接到uart模块的rx端口对于按键等机械开关产生的信号,可能还需要消抖电路,但对于UART通信,通常由桥接芯片处理,直接连接即可。
5.3 环回测试
最简单的上板验证方法是进行“环回测试”。将FPGA内部UART的tx输出引脚直接连接到rx输入引脚(可以在PCB上用杜邦线短接,或者在顶层代码中将tx信号直接赋给rx输入逻辑)。然后编写一个简单的逻辑,让FPGA每隔一秒发送一个递增的字节,同时接收该字节并验证是否一致。通过LED显示结果,或者通过另一个UART端口打印调试信息。
一个简单的环回测试顶层模块框架:
module top_loopback( input clk, input uart_rx_pin, output uart_tx_pin, output reg led_ok ); // 时钟分频,产生1秒周期脉冲 reg [31:0] counter; wire send_pulse = (counter == 32‘d50_000_000); // 假设50MHz时钟 always @(posedge clk) counter <= (send_pulse) ? 0 : counter + 1; // 待发送数据 reg [7:0] data_to_send = 8‘h00; wire [7:0] data_received; wire tx_busy, rx_done; // UART实例化 uart #(.CLOCK_RATE(434), .BAUD_RATE(1)) uart_inst( .clk(clk), .rx(uart_rx_pin), // 实际测试时可改为:.rx(uart_tx_pin) 进行内部环回 .rxEn(1‘b1), .out(data_received), .rxDone(rx_done), .txEn(1‘b1), .txStart(send_pulse && !tx_busy), .in(data_to_send), .tx(uart_tx_pin) ); // 逻辑:每秒发送一个字节,并检查接收到的字节 always @(posedge clk) begin if (send_pulse && !tx_busy) begin data_to_send <= data_to_send + 1; // 数据递增 end if (rx_done) begin // 比较发送和接收的数据(注意:内部环回时,收到的是自己刚发的) led_ok <= (data_received == data_to_send); end end endmodule5.4 常见上板问题排查
完全没有数据收发:
- 检查引脚分配:确认
tx/rx引脚是否与USB-UART芯片连接正确。一个常见的坑是交叉连接:FPGA的tx应接桥接芯片的rx,FPGA的rx应接桥接芯片的tx。 - 检查波特率:用示波器或逻辑分析仪测量
tx引脚。发送一个固定的字节(如0x55,二进制01010101),测量一个比特位的时间,换算成波特率,看是否与PC端串口工具的设置一致。 - 检查空闲电平:UART空闲时,
tx线应为持续高电平。如果为低,可能是模块未正确初始化或使能。
- 检查引脚分配:确认
收到乱码:
- 波特率不匹配:这是最常见的原因。重新计算
CLOCK_RATE,确保FPGA系统时钟频率准确(有些开发板可能使用PLL分频后的时钟)。 - 数据位/停止位设置不一致:确保PC端串口工具设置为8数据位、1停止位、无校验。
- 信号质量问题:对于长导线或高速波特率,可能需要进行阻抗匹配或使用差分UART(如RS-422)。
- 波特率不匹配:这是最常见的原因。重新计算
只能发送不能接收(或反之):
- 检查使能信号:确认
rxEn和txEn是否已上拉为高电平。 - 检查流控:确保PC端串口工具和代码中都没有启用硬件流控(RTS/CTS)。
- 环回测试:进行内部环回测试,排除外部线路和PC软件的问题。
- 检查使能信号:确认
通过仿真、约束、上板调试这个完整的流程,你不仅能将这个UART模块用起来,更能深刻理解数字系统设计中“从代码到物理信号”的每一个环节。这个简洁的Verilog UART实现,就像一块优质的积木,为你构建更复杂的FPGA通信系统打下了坚实的基础。