从零构建FPGA串口交互系统:Vivado实战与Verilog设计精要
很多刚接触FPGA的朋友,在点亮第一个LED之后,往往会陷入一个迷茫期:下一步该做什么?那些复杂的通信协议、状态机、数据解析,听起来就让人头大。我刚开始学的时候,也是对着开发板上的串口接口发愣,不知道如何让电脑和这块小小的芯片“说上话”。直到后来,我意识到一个道理:最好的学习路径,是从一个具体、完整、能跑起来的项目入手。今天,我们就来一起动手,用Vivado 2018.3和Verilog,打造一个可以通过电脑串口助手自由控制的LED闪烁系统。这不仅仅是点亮一个灯,而是构建一个完整的“命令-响应”交互框架,这个框架能延伸到更复杂的应用,比如电机控制、传感器数据采集,甚至是自定义的通信协议栈。
这个项目特别适合已经掌握Verilog基础语法,但还没做过完整数据流项目的朋友。你会接触到串口字节接收的状态机设计、自定义应用层协议的制定与解析、多模块的协同与接口定义,以及如何在Vivado中进行完整的仿真与调试。我不会只给你一堆代码让你照抄,而是会拆解每个设计决策背后的思考过程,告诉你为什么这里要用寄存器打拍,那里要设计特定的握手信号。当你完成这个项目,你收获的将不仅仅是一个闪烁的LED,而是一套可复用的FPGA与上位机通信的“脚手架”。
1. 项目环境搭建与Vivado工程创建
在开始写第一行代码之前,确保你的“战场”已经准备就绪。我推荐使用Vivado 2018.3这个版本,它在稳定性和对教育版开发板的支持上做得不错,网上相关的教程和问题解答也最丰富。当然,如果你用的是更新的版本,大部分操作也是兼容的。硬件方面,任何一款带有UART转USB芯片(如CH340、CP2102)和用户LED的FPGA开发板都可以,比如Xilinx的Basys 3、Nexys 4 DDR,或者国产的很多高性价比型号。
1.1 创建与管理Vivado工程
打开Vivado,点击“Create Project”,跟着向导一步步走。有几个关键点需要注意:
- 项目类型:选择“RTL Project”,并勾选“Do not specify sources at this time”。这样我们可以先搭好框架,再慢慢添加文件。
- 目标器件:这里必须和你手头的开发板完全匹配。通常可以在板子的官网或手册上找到芯片型号。例如,Basys 3使用的是Xilinx Artix-7 XC7A35T-1CPG236C。选错型号会导致后续的管脚约束和生成比特流失败。
- 添加设计源文件:创建完工程后,在“Sources”窗口的“Design Sources”上右键,选择“Add Sources”。我个人的习惯是为每个功能模块创建独立的
.v文件,例如uart_rx.v,cmd_parser.v,led_controller.v,最后用一个top.v把它们连接起来。这种模块化的管理方式,在项目变复杂时会让你省心很多。
提示:建议在项目根目录下创建一个
src文件夹,专门存放所有的Verilog源文件。再创建一个constrs文件夹,存放管脚约束文件(.xdc)。这样工程结构清晰,也方便版本管理工具(如Git)进行管理。
1.2 理解串口通信的基础参数
我们的目标是让FPGA通过串口接收来自电脑的命令。在动手设计硬件逻辑之前,必须和电脑端的串口助手软件“对好暗号”。串口通信有几个核心参数,它们必须两端一致:
| 参数 | 典型值 | 说明 |
|---|---|---|
| 波特率 (Baud Rate) | 115200 | 每秒传输的符号数。这是最重要的参数,收发双方必须严格一致。115200是常见的高速选择。 |
| 数据位 (Data Bits) | 8 | 每个字节由8位数据构成。这也是最常用的设置。 |
| 停止位 (Stop Bits) | 1 | 用于标识一个字节传输结束。通常为1位。 |
| 校验位 (Parity) | None | 用于简单的错误检测。为简化设计,我们选择“无校验”。 |
在Vivado中设计我们的接收模块时,波特率需要通过系统时钟频率来计算分频系数。例如,系统时钟Clk是100MHz(即100,000,000 Hz),目标波特率是115200,那么每个比特的周期是 1 / 115200 ≈ 8.68 us。为了更精确地采样,我们通常使用16倍过采样,即产生一个频率为 115200 * 16 = 1.8432 MHz 的时钟来检测数据。分频系数就是 100,000,000 / 1,843,200 ≈ 54。这个计算过程我们会用Verilog的参数和函数来实现,使其易于配置。
2. 串口字节接收引擎的设计与实现
串口接收是本次项目的核心,也是很多新手觉得抽象的地方。它的本质是一个状态机,持续监听一根信号线(uart_rx)上的电平变化,从起始位开始,按顺序采集8个数据位,最后确认停止位,完成一个字节的接收。
2.1 接收状态机与边沿检测
串口线在空闲时为高电平。当检测到一个比特时间长度的低电平时,标志着起始位的开始。为了稳定地检测到这个下降沿,我们需要对输入信号进行同步化处理,并做边沿检测,以消除亚稳态和毛刺的影响。
// 同步寄存器链,用于消除亚稳态
reg [1:0] uart_rx_sync;
always @(posedge Clk) begin
uart_rx_sync[0] <= uart_rx; // 第一级同步
uart_rx_sync[1] <= uart_rx_sync[0]; // 第二级同步
end
// 下降沿检测:检测到从1变为0
wire rx_falling_edge;
assign rx_falling_edge = (uart_rx_sync == 2'b10);
当rx_falling_edge信号拉高时,我们就认为检测到了起始位,可以启动接收过程。此时,需要使能一个波特率生成器,产生前面提到的16倍波特率时钟(bps_clk_16x),用于在数据位的中间位置进行采样,这是采样最稳定的时刻。
2.2 比特采样与数据组装
在使能接收(RX_EN信号为高)后,我们用计数器bps_cnt在bps_clk_16x的驱动下从0计数到159(对应10个比特 * 16次采样)。采样点通常选择在每个比特周期的中间,例如对于第0个数据位,在第7到第8个采样点(即bps_cnt为24附近)进行多次采样,然后取多数值作为最终比特值,这种方法称为多数判决,能有效提高抗干扰能力。
// 示例:对第一个数据位(bit 0)进行采样
reg [2:0] sample_reg [7:0]; // 为每个数据位分配一个3位寄存器用于多数判决
always @(posedge Clk) begin
if (RX_EN && bps_clk_16x) begin
case (bps_cnt)
21,22,23,24,25,26,27: // 在bit0的稳定区间内采样
sample_reg[0] <= sample_reg[0] + uart_rx_sync[1]; // 累加采样值
... // 其他数据位的采样区间
endcase
end
end
// 一个字节接收完成后,进行多数判决并输出
always @(posedge Clk) begin
if (byte_receive_done) begin
rx_data[0] <= (sample_reg[0] >= 4) ? 1'b1 : 1'b0; // 如果7次采样中高电平>=4次,则判为1
// ... 处理其他7个数据位
end
end
当bps_cnt计数到159(停止位采样结束)时,产生一个rx_done脉冲信号,标志着一个字节数据rx_data已经有效,可以供上层模块读取。这个rx_done信号是整个数据流中的关键握手信号。
3. 自定义应用层协议与命令解析器设计
如果只能接收单个字节,那功能就太有限了。我们想要的是发送一条包含“闪烁模式”和“时间参数”的完整命令。这就需要定义一套应用层协议,并设计一个命令解析器(Parser)。这是将原始字节流转化为有意义控制信息的关键步骤。
3.1 设计一个简单高效的帧协议
一个好的协议应该具备帧头识别、数据承载和帧尾校验的基本结构,同时兼顾简单和高效。这里我们设计一个8字节的固定长度帧:
| 帧头 (2字节) | 数据区 (5字节) | 帧尾 (1字节) |
|--------------|----------------|--------------|
| 0x55 0xA5 | T3 T2 T1 T0 C | 0xF0 |
- 帧头 (0x55, 0xA5):用于在连续的字节流中定位一帧的开始。选择两个特定的字节组合,可以极大地降低误触发概率。
- 数据区:
T3, T2, T1, T0:这是一个32位(4字节)的时间参数,用于控制LED闪烁的周期或占空比。注意字节顺序,这里我们约定T0是最低字节(LSB),T3是最高字节(MSB),即小端模式。C:这是一个8位的控制字。我们可以定义它的每一个比特控制一种LED模式。例如:C[0] = 1: LED常亮C[1] = 1: LED以1Hz频率闪烁C[2] = 1: LED以时间参数T设定的周期闪烁C[3] = 1: LED呼吸灯模式- ... 其他比特可自定义
- 帧尾 (0xF0):用于验证帧的完整性。当解析器识别到正确的帧头和帧尾,且长度符合时,才认为收到了一帧有效数据。
3.2 实现滑动窗口式协议解析器
解析器需要持续监视串口接收模块送来的rx_data和rx_done。一种高效的方法是使用一个移位寄存器数组作为滑动窗口。
module cmd_parser (
input wire Clk,
input wire Reset_n,
input wire [7:0] rx_data,
input wire rx_done,
output reg [7:0] ctrl_reg,
output reg [31:0] time_reg,
output reg cmd_valid
);
// 8字节的移位寄存器,构成一个滑动窗口
reg [7:0] frame_buffer [7:0];
integer i;
always @(posedge Clk) begin
if (rx_done) begin
// 将新字节移入缓冲区,最旧的字节被移出
for (i = 0; i < 7; i = i + 1) begin
frame_buffer[i] <= frame_buffer[i+1];
end
frame_buffer[7] <= rx_data;
end
end
// 延迟一拍rx_done,确保缓冲区在移位完成后才被检查
reg rx_done_dly;
always @(posedge Clk) rx_done_dly <= rx_done;
// 协议检查与数据提取
always @(posedge Clk or negedge Reset_n) begin
if (!Reset_n) begin
ctrl_reg <= 8'h00;
time_reg <= 32'h0000_0000;
cmd_valid <= 1'b0;
end else if (rx_done_dly) begin
// 检查帧头、帧尾和长度(通过rx_done_dly间接判断)
if ((frame_buffer[0] == 8'h55) &&
(frame_buffer[1] == 8'hA5) &&
(frame_buffer[7] == 8'hF0)) begin
// 提取时间参数和控制字
time_reg[7:0] <= frame_buffer[2]; // T0
time_reg[15:8] <= frame_buffer[3]; // T1
time_reg[23:16] <= frame_buffer[4]; // T2
time_reg[31:24] <= frame_buffer[5]; // T3
ctrl_reg <= frame_buffer[6]; // C
cmd_valid <= 1'b1; // 产生一个时钟周期的有效脉冲
end else begin
cmd_valid <= 1'b0;
end
end else begin
cmd_valid <= 1'b0;
end
end
endmodule
这个解析器的工作流程非常清晰:每当收到一个新字节,就将其推入8字节的缓冲区,并检查此时缓冲区的内容是否符合我们定义的协议格式。如果匹配,则提取出time_reg和ctrl_reg,并产生一个cmd_valid脉冲信号,通知下游的LED控制器:“新命令已就绪,请执行”。
4. 可配置的LED控制器模块
命令解析出来了,接下来就需要一个执行器来驱动LED。这个LED控制器应该能根据ctrl_reg的不同模式,结合time_reg的参数,产生丰富的视觉效果。
4.1 多模式状态机设计
我们可以把LED控制器设计成一个多路选择器+计数器的结构。核心是一个32位的大计数器,其计数上限由time_reg决定。
module led_controller (
input wire Clk,
input wire Reset_n,
input wire [7:0] ctrl,
input wire [31:0] time_set,
input wire cmd_valid, // 来自解析器的新命令有效信号
output reg Led
);
reg [31:0] period_counter;
reg [2:0] pattern_index; // 用于某些模式下的子状态
// 更新参数:仅在收到新命令时更新
reg [31:0] period_reg;
reg [7:0] ctrl_reg;
always @(posedge Clk or negedge Reset_n) begin
if (!Reset_n) begin
period_reg <= 32'd12_500_000; // 默认0.5秒周期(假设100MHz时钟)
ctrl_reg <= 8'b0000_0001; // 默认模式
end else if (cmd_valid) begin
period_reg <= time_set;
ctrl_reg <= ctrl;
end
end
// 周期计数器
always @(posedge Clk or negedge Reset_n) begin
if (!Reset_n) begin
period_counter <= 32'd0;
end else begin
if (period_counter >= period_reg - 1) begin
period_counter <= 32'd0;
end else begin
period_counter <= period_counter + 1;
end
end
end
// LED输出逻辑 - 一个简单的多模式选择器
always @(posedge Clk or negedge Reset_n) begin
if (!Reset_n) begin
Led <= 1'b0;
pattern_index <= 3'd0;
end else begin
case (ctrl_reg)
8'b0000_0001: // 模式1:常亮
Led <= 1'b1;
8'b0000_0010: // 模式2:1Hz闪烁 (period_reg控制)
Led <= (period_counter < (period_reg >> 1)) ? 1'b1 : 1'b0; // 50%占空比
8'b0000_0100: // 模式3:根据ctrl_reg的比特位循环点亮
begin
if (period_counter == period_reg - 1) begin
pattern_index <= (pattern_index == 3'd7) ? 3'd0 : pattern_index + 1;
end
Led <= ctrl_reg[pattern_index];
end
// ... 可以在这里扩展更多模式,如呼吸灯(PWM)
default:
Led <= 1'b0;
endcase
end
end
endmodule
这里有一个关键细节:period_counter >= period_reg - 1这个判断条件。为什么是“大于等于”而不是“等于”?这是为了代码的健壮性。考虑一种情况:当period_reg的值通过串口命令更新时,period_counter可能已经是一个很大的值,远超新的period_reg。如果使用“等于”判断,计数器可能永远无法匹配到新的、更小的period_reg值,导致计数器溢出(从32‘hFFFF_FFFF回到0)后才能清零,这会产生一个非常长的错误周期。使用“大于等于”可以确保在新的period_reg生效后,计数器能在下一个周期就正确归零,行为更符合直觉。
5. 系统集成、仿真与上板调试
各个模块都准备好了,现在需要把它们像拼图一样组合起来,并进行验证。
5.1 顶层模块设计与管脚约束
顶层模块(top.v)的任务非常明确:实例化所有子模块,并连接它们的端口。它也是与外部物理管脚对接的桥梁。
module top (
input wire Clk, // 系统时钟,连接至板载晶振
input wire Reset_n, // 低电平有效的复位按键
input wire uart_rx, // 串口接收线,连接至USB-UART芯片的TX端
output wire Led // LED输出
);
// 内部连线声明
wire [7:0] rx_data;
wire rx_done;
wire [7:0] ctrl;
wire [31:0] time_set;
wire cmd_valid;
// 模块实例化
uart_byte_rx u_uart_rx (
.Clk(Clk),
.Reset_n(Reset_n),
.Baud_Set(3'd4), // 对应115200波特率,根据你的时钟频率调整
.uart_rx(uart_rx),
.Data(rx_data),
.Rx_Done(rx_done)
);
cmd_parser u_cmd_parser (
.Clk(Clk),
.Reset_n(Reset_n),
.rx_data(rx_data),
.rx_done(rx_done),
.ctrl_reg(ctrl),
.time_reg(time_set),
.cmd_valid(cmd_valid)
);
led_controller u_led_ctrl (
.Clk(Clk),
.Reset_n(Reset_n),
.ctrl(ctrl),
.time_set(time_set),
.cmd_valid(cmd_valid),
.Led(Led)
);
endmodule
接下来是管脚约束(XDC文件)。这是将逻辑信号映射到物理芯片引脚的关键一步。你需要查阅开发板原理图,找到时钟、复位键、串口RX和LED对应的FPGA引脚号。
# 示例:Basys 3开发板的约束(部分)
set_property PACKAGE_PIN W5 [get_ports Clk] # 100MHz系统时钟
set_property IOSTANDARD LVCMOS33 [get_ports Clk]
set_property PACKAGE_PIN U18 [get_ports Reset_n] # 中央按钮,低电平有效
set_property IOSTANDARD LVCMOS33 [get_ports Reset_n]
set_property PACKAGE_PIN B18 [get_ports uart_rx] # USB-UART的TX端(对应FPGA的RX)
set_property IOSTANDARD LVCMOS33 [get_ports uart_rx]
set_property PACKAGE_PIN U16 [get_ports Led] # LD0
set_property IOSTANDARD LVCMOS33 [get_ports Led]
5.2 编写Testbench进行仿真
在烧录到板子之前,必须用仿真来验证逻辑的正确性。Vivado自带的仿真工具(XSim)足够完成这个任务。一个好的Testbench应该模拟真实的上位机行为,发送符合协议的完整数据帧。
`timescale 1ns / 1ps
module tb_top();
reg Clk;
reg Reset_n;
reg uart_rx;
wire Led;
// 实例化被测设计
top u_top (.*); // 使用 .* 进行自动连接,要求端口名一致
// 生成100MHz时钟
always #5 Clk = ~Clk; // 周期10ns -> 100MHz
// 任务:模拟UART发送一个字节
task uart_send_byte;
input [7:0] data;
integer i;
begin
uart_rx = 1'b0; // 起始位
#8680; // 1个比特时间 @115200 baud (约8.68us)
for (i=0; i<8; i=i+1) begin
uart_rx = data[i];
#8680;
end
uart_rx = 1'b1; // 停止位
#8680;
end
endtask
initial begin
// 初始化
Clk = 0;
Reset_n = 0;
uart_rx = 1'b1; // 空闲状态为高电平
#100;
Reset_n = 1;
#1000;
// 发送一帧测试数据:0x55, 0xA5, 0x78, 0x56, 0x34, 0x12, 0x01, 0xF0
// 含义:时间参数 0x12345678,控制字 0x01(模式1:常亮)
uart_send_byte(8'h55);
uart_send_byte(8'hA5);
uart_send_byte(8'h78); // Time LSB
uart_send_byte(8'h56);
uart_send_byte(8'h34);
uart_send_byte(8'h12); // Time MSB
uart_send_byte(8'h01); // Ctrl
uart_send_byte(8'hF0);
// 观察一段时间LED输出
#1000000; // 1ms
$finish;
end
endmodule
在仿真波形中,你需要重点观察:
uart_rx线上模拟的字节波形是否正确。rx_done信号是否在每个字节接收完成后产生一个脉冲。cmd_valid信号是否在收到完整且正确的8字节帧后产生一个脉冲。ctrl和time_set信号是否被正确解析为0x01和0x12345678。Led输出是否根据控制字做出了预期的变化(例如,在收到0x01后变为常亮)。
5.3 上板验证与调试技巧
通过仿真后,就可以生成比特流文件并下载到开发板了。打开电脑上的串口助手(如Putty、SecureCRT或Xshell),进行如下设置:
- 端口:选择你的开发板对应的COM口(在设备管理器中查看)。
- 波特率:115200
- 数据位:8
- 停止位:1
- 校验位:None
发送数据时,注意选择十六进制(Hex)发送模式。按照我们的协议,发送:55 A5 78 56 34 12 01 F0。如果一切正常,你应该能看到板载的LED被点亮。
如果LED没有反应,可以按以下步骤排查:
- 检查硬件连接:确认USB线已连接,开发板已上电。
- 检查串口设置:确认串口助手的所有参数与FPGA设计完全一致,特别是波特率。
- 检查约束文件:确认
.xdc文件中的管脚编号与原理图完全一致。这是最容易出错的地方。 - 使用内部逻辑分析仪(ILA):Vivado的ILA是强大的片上调试工具。你可以在设计中插入ILA核,抓取
uart_rx、rx_done、cmd_valid、ctrl等内部信号的实时波形,就像在板子上运行一个示波器一样。这能最直观地看到数据是否被正确接收和解析。 - 简化测试:先发送单个字节(如
0x55),在ILA中观察rx_data是否正确。再逐步发送完整帧。
当你看到LED随着你从电脑发送的不同命令而变换各种闪烁模式时,那种成就感是无与伦比的。这个项目虽然不大,但它串联起了FPGA开发中从底层驱动到应用协议,再到系统集成与调试的完整链条。你可以在此基础上轻松地扩展更多命令,控制更多外设,或者将串口接收改为发送,让FPGA把传感器数据传回电脑。这套由物理层驱动、协议解析层、应用控制层构成的架构,是许多更复杂嵌入式系统的缩影。
129



被折叠的 条评论
为什么被折叠?



