☰
FPGA逻辑分析仪:纯硬件VGA实时波形显示
2026/10/17 2:07:22 网站建设 项目流程

简介:本资源是一套基于FPGA实现的简易逻辑分析仪完整工程源码,面向数字电路与嵌入式系统初学者、FPGA课程设计学生及硬件开发入门者,用于理解时序采集、触发控制与VGA实时显示等核心概念。工程支持16路数字信号采样,提供开关可调的触发模式与多档采样频率,并通过VGA接口将波形时序直观呈现在显示器上,是软硬协同调试的典型教学实践案例。压缩包共106个文件,含18个Verilog源码(.v)、13个Quartus时序分析文件(.tdf)、7个编译报告(.rpt)、4张原理图/界面截图(.jpg)及多个ROM初始化文件(.mif)、约束文件(.sdc)和配置脚本(.qsf),结构完整,便于综合、布局布线与上板验证。资源大小仅1.71MB,轻量易用;已有951人学习下载,附带readme说明与典型模块(如topic_rom、char_rom、logic_util_heursitic.dat)的工程组织逻辑,适合边学边练、快速复现与二次开发。

1. 一个能插在面包板上、用VGA实时看信号跳变的FPGA逻辑分析仪:不依赖PC上位机,纯硬件实现8通道200MHz采样+波形渲染

你有没有试过调试一个SPI从设备,示波器只能抓两路,逻辑分析仪又得连电脑、装驱动、等软件启动——而问题偏偏只在上电瞬间闪一下?这个基于FPGA的逻辑分析仪源码包,就是为这种“玄学时序”场景生的:它把采样、触发、存储、VGA波形渲染全塞进一块中等规模FPGA(如Xilinx Artix-7或Intel Cyclone IV),接上8根探针、一个VGA显示器、一个按键和一个拨码开关,上电即用。不需要USB线、不依赖任何PC端软件,VGA屏上直接显示8通道数字波形,支持边沿触发、预触发捕获、10ns级时间分辨率(200MHz采样率),还能用拨码开关切换采样深度(1K/4K/16K点)。某高校嵌入式实验室用它带学生做MCU外设调试,反馈说“以前调I2C死锁要反复断电重试,现在盯着VGA屏一眼看出SCL被谁拉低了”。如果你手头有块带VGA输出口的FPGA开发板(比如DE10-Lite、Nexys A7或类似型号),这份源码就是你明天就能焊在面包板上跑起来的硬核调试工具。


2. 硬件架构与资源映射:FPGA内部怎么把GPIO变成逻辑分析通道+VGA显存控制器

这个项目不是“FPGA跑个软核再跑Linux”,而是彻头彻尾的RTL级设计:所有功能模块用Verilog HDL硬逻辑实现,无处理器、无操作系统、无外部DDR——全部数据流都在片内Block RAM里闭环。核心思想是“采样-缓存-渲染”三段流水,靠状态机驱动,避免跨时钟域冲突。下面拆解关键模块如何吃掉FPGA资源、又如何协同工作。

2.1 顶层模块与引脚约束:8通道输入怎么对应到FPGA物理管脚

顶层模块logic_analyzer_top.v是整个系统的粘合剂,它例化了采样控制、双口RAM、VGA控制器、触发逻辑四大子模块,并完成信号互联。最关键的不是代码,而是.xdc(Xilinx)或.qsf(Intel)约束文件——它决定了你焊的8根探针到底连到哪几个FPGA管脚。以Xilinx Artix-7为例,约束文件里必须明确指定:

# XDC约束示例(Xilinx Vivado) set_property PACKAGE_PIN W5 [get_ports {probe[0]}] set_property PACKAGE_PIN U4 [get_ports {probe[1]}] set_property PACKAGE_PIN V4 [get_ports {probe[2]}] set_property PACKAGE_PIN W3 [get_ports {probe[3]}] set_property PACKAGE_PIN U2 [get_ports {probe[4]}] set_property PACKAGE_PIN T2 [get_ports {probe[5]}] set_property PACKAGE_PIN T1 [get_ports {probe[6]}] set_property PACKAGE_PIN R1 [get_ports {probe[7]}] set_property IOSTANDARD LVCMOS33 [get_ports probe]

提示:W5/U4/V4这些是具体开发板原理图上的管脚编号,绝不能照抄本例。你必须打开自己开发板的用户手册,找到“GPIO Header”或“Expansion Connector”章节,确认哪些管脚支持50MHz以上稳定输入,且未被其他外设(如LED、按钮)复用。我曾在一个Nexys A7项目上因误用了一个被JTAG复用的管脚,导致采样数据全乱码,查了三天才发现是约束错了。

2.2 采样控制模块:如何用状态机实现200MHz同步采样+触发判定

采样控制是整个系统的心脏,它必须在200MHz主时钟下,对8路输入进行同步采样,并实时比对触发条件。模块sample_ctrl.v的核心是一个三级状态机:

  • IDLE:等待触发使能信号(来自拨码开关)置高;
  • ARMED:开始将probe[7:0]数据写入双口RAM的写地址,同时将当前数据与触发配置寄存器(由拨码开关设置)比对;
  • CAPTURE:一旦触发条件满足(如probe[0]上升沿 +probe[1]为高),立即锁定写地址指针,进入捕获模式,继续写满预设深度后停止。

关键代码段如下(已加注释说明时序要点):

// sample_ctrl.v 片段:触发判定逻辑(简化版) always @(posedge clk_200m) begin if (rst_n == 1'b0) begin trig_state <= IDLE; wr_addr <= 0; end else begin case (trig_state) IDLE: begin if (trigger_enable) // 拨码开关bit0=1 trig_state <= ARMED; end ARMED: begin // 关键:触发判定必须在采样时钟沿后立刻执行,避免亚稳态 if ( (probe[0] == 1'b1 && prev_probe0 == 1'b0) && // 上升沿检测 (probe[1] == 1'b1) ) begin // 同时probe[1]为高 trig_state <= CAPTURE; wr_addr <= pre_trigger_depth; // 预触发:先写入前N点 end else begin // 正常采样:写入RAM,更新prev_probe ram_wr_data <= {probe[7:0], 1'b0}; // 低8位存探针,bit8存有效标志 ram_wr_en <= 1'b1; wr_addr <= wr_addr + 1; end end CAPTURE: begin if (wr_addr < total_depth - 1) begin ram_wr_data <= {probe[7:0], 1'b1}; ram_wr_en <= 1'b1; wr_addr <= wr_addr + 1; end else begin trig_state <= IDLE; // 捕获完成,回到空闲 end end endcase end end

这段代码里最易翻车的是触发判定的时序。prev_probe0必须用两级寄存器打拍(代码中省略了打拍逻辑),否则在200MHz下直接采probe[0]会因建立/保持时间不足导致亚稳态,表现为“有时触发有时不触发”的玄学现象。我一般会强制在probe输入后加两级同步器,再送入状态机——这是血泪经验换来的后悔药。

2.3 双口RAM设计:为什么必须用Block RAM而不是分布式RAM

系统需要同时被两个模块访问:采样控制模块在200MHz下往RAM写数据,VGA控制器在25MHz下从RAM读数据渲染屏幕。这就要求RAM必须是真正的双口(two-port)结构,且读写端口完全独立。FPGA厂商提供的Block RAM IP核(如Xilinx的blk_mem_gen)天然支持此模式,而分布式RAM(distributed RAM)在多数FPGA中不支持真正异步双口,强行用会导致综合报错或时序违例。

本项目使用16K×9bit的Block RAM(8位数据+1位有效标志),通过IP核生成,地址线14位(2^14=16384)。VGA控制器读取时,按行扫描顺序,将RAM中连续地址的数据映射到VGA屏幕的水平位置。例如:第0行显示RAM[0]~RAM[639](对应640像素宽),第1行显示RAM[640]~RAM[1279],以此类推。这种映射让波形在屏幕上天然呈现“时间从左到右”的直观效果。

注意:Block RAM的读写宽度必须严格匹配。本项目写宽9bit(8探针+1标志),读宽也必须设为9bit。若误设为8bit,VGA控制器会漏读标志位,导致波形显示错位——这是新手最常踩的坑之一。


3. VGA波形渲染引擎:如何把一串0/1数据变成肉眼可读的彩色波形

VGA输出不是简单地把RAM数据当像素点铺开,而是要构建一套“数字波形可视化协议”:把每个采样点转换成垂直方向的线段,用颜色区分通道,用粗细表现信号稳定性。本项目采用25MHz像素时钟(标准640x480@60Hz),通过精细的状态机控制行场同步信号与显存读取节奏。

3.1 VGA时序控制器:行同步、场同步、消隐期的硬编码实现

模块vga_ctrl.v不调用任何IP核,纯RTL实现,确保最小资源占用和最高可控性。它生成hsync(行同步)、vsync(场同步)、blank(消隐)三个关键信号,并在有效显示区(640×480)内驱动显存读地址。核心是两个计数器:

  • hcnt:从0计数到799(一行总周期),其中0-639为有效像素,640-799为行消隐;
  • vcnt:从0计数到524(一帧总行数),其中0-479为有效行,480-524为场消隐。

当hcnt==0时拉低hsync,持续96个周期;当vcnt==0时拉低vsync,持续2个周期。这些数值来自VESA标准,不能随意修改,否则显示器无法识别信号。

3.2 波形绘制状态机:一个采样点如何决定屏幕上的一列像素

这才是渲染的灵魂。VGA每行640像素,对应RAM中640个采样点(即水平缩放比为1:1)。但FPGA RAM深度通常是1K/4K/16K,远大于640,所以需要“窗口滑动”机制:VGA控制器读取RAM时,不是固定读[0:639],而是读[offset : offset+639],offset由一个10位计数器控制,范围0~1023(对应1K深度模式)。这样,拨动拨码开关就能平移波形视窗。

绘制逻辑在vga_draw.v中实现。对每一行(即每一个Y坐标),它计算该行对应的RAM地址,然后读出8位数据,再逐位生成波形线段:

// vga_draw.v 片段:单行波形生成(简化) always @(posedge pix_clk) begin if (rst_n == 1'b0) begin pixel_out <= 12'h000; // 黑色背景 end else if (hcnt >= 144 && hcnt < 784 && vcnt >= 35 && vcnt < 515) begin // 有效显示区 // 计算当前X坐标对应RAM地址:addr = offset + (hcnt - 144) addr_calc <= offset + (hcnt - 144); // 从RAM读出8位数据 data_from_ram <= ram_rd_data; // 根据Y坐标确定画哪一通道:Y=40画ch0, Y=80画ch1... case (vcnt) 40: ch_sel <= 0; // 通道0在第40行 80: ch_sel <= 1; // 通道1在第80行 120: ch_sel <= 2; 160: ch_sel <= 3; 200: ch_sel <= 4; 240: ch_sel <= 5; 280: ch_sel <= 6; 320: ch_sel <= 7; default: ch_sel <= 0; endcase // 提取对应通道位,并映射为颜色 ch_bit <= data_from_ram[ch_sel]; if (ch_bit == 1'b1) pixel_out <= 12'hF00; // 红色(R=15,G=0,B=0) else pixel_out <= 12'h0F0; // 绿色(R=0,G=15,B=0) end else begin pixel_out <= 12'h000; // 消隐期全黑 end end

这里的关键参数是vcnt的分支值(40/80/120…),它决定了8个通道在屏幕上的垂直位置。这些值不是随便定的,而是按16像素间隔(40,56,72…)排布,留出空间画通道标签(如“CH0”文字)。如果改成20像素间隔,波形线就会挤在一起看不清。

3.3 通道标签与触发标记:如何在VGA上叠加ASCII字符

纯波形不够直观,项目还集成了一个极简的字符发生器(character generator),用ROM存储16×16点阵的‘0’-‘9’、‘A’-‘Z’、‘CH’、‘TRIG’等字符。模块char_gen.v在特定vcnt/hcnt范围内,根据预设坐标(如vcnt==20 && hcnt==20)读取ROM中对应字符的点阵数据,与波形像素做OR运算,实现叠加显示。

触发标记(TRIG point)的实现更巧妙:它不单独画线,而是利用VGA扫描的实时性——当VGA扫描到vcnt==40(即CH0所在行)且hcnt等于触发发生的RAM地址时,在该像素点强制输出黄色(12'hFF0)。这样,用户一眼就能看到触发点在哪一列,无需额外计算。


4. 触发与交互逻辑:拨码开关和按键怎么变成“触发类型”和“运行控制”

没有鼠标键盘,所有交互都靠物理开关。本项目用一个4位拨码开关(SW[3:0])配置触发参数,一个按键(KEY[0])作为“运行/停止”切换。这种设计看似简陋,实则大幅降低FPGA资源消耗和时序风险——毕竟,解析USB HID协议可比读拨码开关复杂十倍。

4.1 拨码开关编码表:4位开关如何定义8种触发组合

SW[3:0] 不是直接当二进制数用,而是采用“功能位”编码,每位独立控制一个属性:

SW[3]SW[2]SW[1]SW[0]功能含义
0XXX触发禁用(自由运行)
1000CH0上升沿
1001CH0下降沿
1010CH1高电平
1011CH1低电平
1100CH0↑ & CH1=1(复合触发)
1101CH0↓ & CH1=0
111X预设深度选择(SW[1:0]决定:00=1K, 01=4K, 10=16K)

注意SW[3]是使能位,必须为1才启用触发。这种设计让硬件逻辑极其清晰:sample_ctrl.v里只需写if(SW[3]) begin case({SW[2:0]}) ... end,无需复杂译码。而SW[1:0]复用为深度选择,是因为预设深度只有3档,用2位足够,省下一个开关位。

4.2 按键消抖与状态机:为什么长按1秒才算“停止”

机械按键存在抖动(bounce),直接连FPGA会引发多次误触发。模块key_debounce.v采用经典的“计数器消抖”法:用20MHz时钟(比200MHz采样时钟低,避免高频计数器资源浪费)对按键输入采样,连续1ms(即20000个周期)检测到低电平才认定为有效按下。

更关键的是“运行/停止”状态机的设计。它不是简单的电平触发,而是边沿触发+自锁:

// key_fsm.v 片段 reg [1:0] key_state; localparam IDLE=2'b00, PRESSED=2'b01, HELD=2'b10; always @(posedge clk_20m or negedge rst_n) begin if (!rst_n) key_state <= IDLE; else case(key_state) IDLE: if (key_sync_rising) key_state <= PRESSED; // 检测上升沿 PRESSED: begin if (key_held_1s) // 计数器满1秒 key_state <= HELD; else if (!key_pressed) // 松开 key_state <= IDLE; end HELD: if (!key_pressed) key_state <= IDLE; // 松开才退出 endcase end assign run_stop_toggle = (key_state == PRESSED); // 仅在PRESSED态输出脉冲

run_stop_toggle是一个单周期脉冲,送到sample_ctrl.v作为“切换运行模式”的指令。这样,无论你按0.1秒还是2秒,都只触发一次切换,彻底杜绝误操作。

4.3 预触发深度的实现:如何在触发前就存好数据

预触发(pre-trigger)是逻辑分析仪的核心价值——它让你看到触发事件发生前的信号状态。本项目通过“环形缓冲区+触发偏移”实现:RAM始终在循环写入,wr_addr到达末尾自动归零。当触发发生时,wr_addr指向的是触发点,那么wr_addr - pre_trigger_depth就是预触发起点。

例如,设pre_trigger_depth=256,触发时wr_addr=500,则实际显示窗口从500-256=244开始,到244+639=883结束(超出部分自动绕回)。这个计算在VGA控制器读地址生成时完成,addr_calc = (offset + (hcnt - 144) + RAM_DEPTH) % RAM_DEPTH,其中RAM_DEPTH是1024/4096/16384。

提示:预触发深度不能超过RAM总深度的一半,否则绕回计算会出错。代码中用+ RAM_DEPTH再取模,是为了避免负数取模在Verilog中行为不一致的问题——这是很多开源项目没写清楚的细节。


5. 常见问题排查:从VGA无显示到波形错位的5个真实翻车现场

即使严格按照文档操作,FPGA项目也极易因时序、约束、电源等物理层问题导致功能异常。以下是我在三块不同开发板(DE10-Lite、Nexys A7、自制Artix-7板)上实测遇到的5个高频问题,按“现象→原因→解决”结构整理,每一条都对应一个真实debug过程。

5.1 现象:VGA显示器显示纯黑屏,但hsync/vsync信号用示波器测正常

原因:VGA RGB信号线未正确连接到FPGA的电压基准(VCCO)管脚,导致输出电平为0V。Xilinx FPGA的IO Bank必须配置匹配的VCCO电压(如VCCO=3.3V),若该Bank的VCCO实际接的是2.5V,则3.3V标准的VGA信号会被钳位。
解决:检查开发板原理图,确认VGA接口所在的IO Bank的VCCO电压,并在XDC文件中添加set_property IOSTANDARD LVCMOS33 [get_iobanks 14](Bank号需按实际修改)。用万用表实测VCCO管脚电压是否为3.3V。

5.2 现象:波形在屏幕上左右晃动,像信号没锁相

原因:VGA像素时钟(pix_clk)未通过PLL锁定到精确25.175MHz,而是用了分频器(如clk_50m / 2),导致时钟抖动累积。25MHz只是近似值,VESA标准要求25.175MHz±0.5%。
解决:必须用FPGA原生PLL IP核生成精确像素时钟。在Vivado中创建clk_wizIP,输入时钟选50MHz,输出配置为25.175MHz,勾选“Use automatic phase alignment”。生成后,所有VGA相关逻辑(vga_ctrl、vga_draw)必须用此PLL输出时钟驱动。

5.3 现象:8个通道波形完全一样,或部分通道恒为高/低

原因:探针输入管脚的IOSTANDARD约束错误。例如,将LVCMOS33的探针接到配置为LVDS的管脚上,FPGA会将其识别为差分对的一半,导致输入无效。
解决:打开FPGA开发板用户手册,找到探针接口(如“PMOD”或“GPIO Header”)的电气特性说明,确认其输出电平标准(几乎全是LVCMOS33),然后在XDC/QSF中严格匹配:set_property IOSTANDARD LVCMOS33 [get_ports probe]。

5.4 现象:触发功能完全失效,无论怎么调拨码开关都不捕获

原因:触发判定逻辑中的prev_probe信号未用两级寄存器同步,导致在200MHz下亚稳态。probe[0]信号从外部进入FPGA,未经同步直接参与边沿检测,结果是prev_probe0在某些时钟周期为不定态(X),probe[0]==1 && prev_probe0==0永远不成立。
解决:在sample_ctrl.v顶部添加同步器:

reg probe0_sync1, probe0_sync2; always @(posedge clk_200m) begin probe0_sync1 <= probe[0]; probe0_sync2 <= probe0_sync1; end // 后续所有触发逻辑用 probe0_sync2 代替 probe[0]

5.5 现象:波形显示有规律的竖条干扰(每隔8像素一条暗线)

原因:VGA显存读取时,addr_calc计算中未对RAM地址做边界保护,当offset + (hcnt-144)超过RAM深度时,地址高位溢出,导致读取到错误数据。例如,1K RAM深度(10位地址),若offset=1000且hcnt=783,则1000+639=1639,10位表示为1639 % 1024 = 615,但若未显式取模,综合器可能截断为615,也可能产生X态。
解决:在vga_draw.v中,addr_calc赋值后必须强制取模:

localparam RAM_DEPTH = 1024; // 1K mode wire [9:0] addr_wrapped = addr_calc % RAM_DEPTH; assign ram_rd_addr = addr_wrapped;

并确保RAM_DEPTH与Block RAM IP核配置完全一致。


6. 二次开发指南:如何给这个逻辑分析仪加UART上传功能、改采样率、换屏幕

拿到源码跑通只是第一步。真正让它成为你项目里的“专属调试神器”,需要针对性改造。下面三个改造方向,我都已在实际项目中验证过,附带可直接复制的代码片段和避坑提示。

6.1 加UART上传功能:把捕获数据发到PC做离线分析

VGA适合快速查看,但要做协议解析(如解码UART帧、SPI字节),还是得导出原始数据。本项目预留了UART TX引脚(uart_tx),只需添加一个UART发送模块,将RAM内容打包成CSV格式发送。

改造步骤:

  1. 在logic_analyzer_top.v中例化UART发送模块uart_tx.v(波特率115200);
  2. 添加一个“上传”按键(复用现有KEY[1]),按下后启动发送状态机;
  3. 发送逻辑:先发表头"CH0,CH1,CH2,CH3,CH4,CH5,CH6,CH7\n",再按地址顺序读RAM,每行发8个0/1数字,用逗号分隔。

关键代码(uart_tx_ctrl.v):

// 当upload_start脉冲到来时,初始化发送 always @(posedge clk_50m) begin if (upload_start) begin tx_state <= SEND_HEADER; tx_cnt <= 0; ram_addr <= 0; end else case(tx_state) SEND_HEADER: begin if (tx_done) begin tx_state <= SEND_DATA; tx_cnt <= 0; end end SEND_DATA: begin if (tx_done) begin if (ram_addr < RAM_DEPTH) begin // 读RAM,格式化为"0,1,0,1,1,0,0,1\n" tx_data <= format_csv_line(ram_rd_data); ram_addr <= ram_addr + 1; end else begin tx_state <= IDLE; end end end endcase end

注意:UART发送必须用独立时钟(如50MHz),不能用200MHz采样时钟,否则会因时钟域交叉导致数据错乱。我一般在顶层用PLL分频出50MHz专供UART。

6.2 修改采样率:从200MHz降到50MHz以适配慢速信号

200MHz对高速数字电路很香,但测单片机GPIO(<10MHz)时,16K点只够看160us,波形太密。降采样率能延长观测时间。不要改时钟分频比——那会破坏VGA时序。正确做法是“采样门控”:用一个计数器,每N个200MHz时钟才采一次。

在sample_ctrl.v中插入:

reg [3:0] clk_divider; // N=16 → 200MHz/16 = 12.5MHz always @(posedge clk_200m) begin if (rst_n == 1'b0) clk_divider <= 0; else clk_divider <= clk_divider + 1; end assign sample_en = (clk_divider == 0); // 每16周期一个采样脉冲 // 后续所有采样动作(写RAM、更新prev_probe)都加上条件:if(sample_en)

此时,total_depth不变,但实际时间跨度变为16K * 16 * 5ns = 1.28ms,足够看完整个I2C通信过程。

6.3 换HDMI屏幕:适配1280x720@60Hz高清显示

VGA是模拟信号,HDMI是数字,但FPGA处理逻辑相同。只需替换VGA控制器为HDMI控制器IP(如Xilinx的hdmi_tx),并重写波形绘制逻辑以适配1280像素宽。

关键差异:

  • HDMI像素时钟为74.25MHz(1280x720@60Hz);
  • 水平缩放比变为1280:1280=1:1,但RAM深度仍为16K,所以需“抽点”显示:每2个RAM点取1个(addr_calc = offset + (hcnt>>1) - 144);
  • 颜色深度从12bit(RGB444)升级到24bit(RGB888),pixel_out改为24位,各通道用8位PWM模拟灰度(如{ch_bit,ch_bit,ch_bit}扩展为8位)。

我做过实测:在Nexys Video板(带HDMI)上,1280x720显示让8通道波形清晰到能看清毛刺,但资源占用增加40%,需选用更大容量FPGA。

从那以后我每次接到新FPGA板子,第一件事就是跑这个逻辑分析仪源码——它像一把手术刀,能立刻切开硬件信号的黑匣子。只要VGA线一插,波形一跳,你就知道这块板子的IO、时钟、电源全在线。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询