简介:基于Verilog语言完成的FPGA简易数字存储示波器设计项目,适合FPGA入门与进阶学习者、数字电路开发者以及电子测量技术爱好者参考。项目以EP2C8Q208C8芯片为载体,完整覆盖数据采集、存储缓冲、触发系统、显示接口、用户界面与时序分析等示波器设计的关键环节,通过ADC转换、片上存储、逻辑比较与屏幕显示等模块化设计,帮助读者深入理解FPGA并行处理优势与数字存储示波器工作原理,还可根据实际需求灵活调整采样率、分辨率与触发条件,适合课堂实验与赛前演练等场景。资源包共757个文件,压缩后约1.73MB,除核心.v源码外,还包含Quartus工程配置(qsf、qpf)、引脚约束(pin)、编译烧写文件(sof、pof)及大量工程辅助缓存文件,各类文件分工明确,可作为完整工程模板直接复现和二次开发。已有901人学习下载,适合以此为基础进行实验教学或作为原型系统开发工具。
1. 为什么用FPGA做一台简易示波器
调试FPGA内部信号时,逻辑分析仪不总是刚好在手边,台式示波器又常常被团队借走。于是很多人想到用FPGA自己造一台简易示波器:把ADC采到的波形存进FIFO,再通过VGA或串口显示。这件事看起来像“玩具”,但它能跑通采样时钟管理、触发逻辑、跨时钟域FIFO这几个FPGA设计里最常踩坑的点。对于熟悉Verilog但没碰过完整采集链路的工程师,它是一个性价比很高的练手项目。一台以100M采样率、8位垂直精度为目标的FPGA示波器,足够观察uart、spi、pwm这类低速协议信号,也能配合外部信号发生器验证你的触发条件写没写对。本文从采样原理讲到Verilog实现,再到仿真和ILA验证,给出一套可以直接抄的小系统方案。
2. FPGA示波器架构:从ADC到触发存储的原理
2.1 示波器前端:ADC采样率与垂直分辨率
一台简易示波器最核心的指标是采样率和垂直分辨率。采样率决定你能看到的信号最高频率,根据奈奎斯特定理,理论最高信号频率是采样率的一半,但实际工程中通常只保证“能看到波形形状”,要测量上升沿至少需要5到10倍过采样。垂直分辨率一般取8位,即256级灰度或256个电压台阶,对应2V量程时每一级约7.8mV。FPGA引脚直接采数字信号时可以省掉ADC,但如果你要测模拟电压,就需要一片如AD9280、ADC0820之类的并行ADC,数据位宽8位,采样时钟由FPGA提供。
常见做法是让ADC工作在“free-run”模式:FPGA给一个固定采样时钟,ADC持续输出数据,FPGA只需要在每个时钟上升沿把数据锁存到寄存器里。这个接口简单,但会引入两个问题:一是采样时钟和FPGA内部时钟可能不同源,需要用异步FIFO或双端口RAM跨时钟域;二是触发逻辑必须能看到连续的采样流,不能漏掉每一个点,这对FPGA组合逻辑时序有要求。如果你使用Xilinx或Intel的FPGA,有现成的DDR接口或LVDS输入,但简易实现通常直接接在普通IO上,走单端CMOS电平。
下面是ADC数据锁存的基准代码框架:
module adc_capture #( parameter DATA_WIDTH = 8 )( input wire clk_adc, input wire rst_n, input wire [DATA_WIDTH-1:0] adc_data, output reg [DATA_WIDTH-1:0] data_out ); always @(posedge clk_adc or negedge rst_n) begin if (!rst_n) data_out <= {DATA_WIDTH{1'b0}}; else data_out <= adc_data; // 在采样时钟上升沿锁存ADC输出 end endmodule这段代码的作用是把ADC输出的并行总线在采样时钟clk_adc的上升沿打一拍,消除引脚上由于走线延迟产生的数据不稳定区。data_out随后进入触发比较器或FIFO。rst_n必须是异步复位,这符合大多数FPGA的全局复位惯例。如果你用的是同步复位,需要确认ADC芯片数据手册里是否要求复位信号保持特定时序,否则容易在启动时采到几个无效点。
2.2 触发:为什么是硬件触发,而不是看完再找
示波器和单纯的数据记录仪最本质区别在触发。没有触发的示波器就像一直在录像的摄像头,你事后翻录像带找事故瞬间,速度慢且容易漏帧。硬件触发是在数据流经FPGA时就实时和预设条件做比较,一旦满足条件,立即启动一段固定深度的存储,然后送显示。这样每次看到的都是事件发生前后的波形,重复信号能稳定显示,眼睛不疲劳。
FPGA实现触发比MCU有优势:MCU靠中断处理,通常只能做到微秒级响应,而FPGA触发比较器在采样时钟域内工作,一个时钟周期就能完成电平或边沿判断,且不占用处理器资源。常见触发类型有:上升沿触发、下降沿触发、电平触发、毛刺触发。其中毛刺触发需要在判定窗口内检测到脉冲宽度小于设定值,这要多个时钟周期连续比较,适合捕获偶发异常。
触发逻辑的核心是一个“比较-产生标志-启动存储”的状态机:
module trigger_logic #( parameter DATA_WIDTH = 8, parameter TRIG_LEVEL = 128 )( input wire clk_adc, input wire rst_n, input wire [DATA_WIDTH-1:0] sample_data, input wire trigger_enable, output reg trigger_flag ); wire current_above_level = (sample_data >= TRIG_LEVEL); reg previous_above_level; always @(posedge clk_adc or negedge rst_n) begin if (!rst_n) begin previous_above_level <= 1'b0; trigger_flag <= 1'b0; end else begin previous_above_level <= current_above_level; // 上升沿触发:之前低于触发电平,现在高于或等于电平均视为上升沿 trigger_flag <= trigger_enable && current_above_level && !previous_above_level; end end endmoduleTRIG_LEVEL是触发电平的量化值,对于8位ADC且输入满量程0V到3.3V,TRIG_LEVEL=128对应约1.65V。若想要下降沿触发,只需把最后一个条件改成!current_above_level && previous_above_level。这段代码在trigger_flag为高一个时钟周期时,通知存储控制模块开始记录。注意previous_above_level必须用寄存器缓存当前电平历史,否则组合逻辑会形成竞争。
2.3 存储与显示:FIFO缓存和VGA/串口输出
采集完成后的数据要先存起来再显示。存储介质可以是简单的FPGA内部块RAM,用环形FIFO实现。环形FIFO的好处是写入持续进行,触发信号到来时,FIFO里已经保存了触发前一段数据;触发后继续写入N个点,然后停止写入,把FIFO整体读出到显示模块。这样你既能看触发前波形,也能看触发后波形,这是数字示波器“预触发”功能的基本原理。
显示方式有两条路:第一条是FPGA直接驱动VGA显示器,用VGA时序把FIFO中的波形点映射到屏幕坐标;第二条是通过UART把数据发送到PC上位机,用Python或串口调试助手绘制虚拟示波器曲线。后者实现起来更快,但刷新率受波特率限制,一般每秒几十帧。前者实时性强,但VGA时序锥和像素时钟管理需要额外资源。对多数人来说,先用UART打通数据链路,再上VGA,是更稳妥的路线。
存储深度的选择直接决定你能观察多长时间的波形。假设采样率为100MHz,存储深度为4096点,那么一屏波形对应40.96微秒,适合观察1MHz左右方波。若想看慢速的PWM,需要把采样率降下来,或用更大深度。下表是常见参数组合:
| 采样率 | 存储深度 | 一屏时间长度 | 适合观察信号频率范围 |
|---|---|---|---|
| 100 MHz | 1024 | 10.24 us | 500 kHz ~ 10 MHz |
| 100 MHz | 4096 | 40.96 us | 100 kHz ~ 5 MHz |
| 10 MHz | 4096 | 409.6 us | 10 kHz ~ 500 kHz |
| 1 MHz | 4096 | 4.096 ms | 1 kHz ~ 50 kHz |
这里“适合观察”不是指能测频率,而是指屏幕上能看到完整波形并测量幅值。频率再高,波形会变得过于致密,看不清细节,建议换更高采样率的方案或用等效时间采样。
3. Verilog实现采样与触发模块
3.1 用计数器分频产生采样时钟
如果FPGA有锁相环(PLL),优先用PLL产生精确的采样频率。比如板载50MHz晶振,通过PLL倍频到100MHz甚至200MHz。但如果你的FPGA没有PLL,或只想快速验证逻辑,可以用计数器分频,但分频得到的时钟是占空比50%的“普通时钟”,不能直接用来给FFT等高速逻辑供电。对于ADC采样,分频时钟只要抖动可接受就能用,因为ADC本身是并行接口,数据稳定时间有ns级余量。
下面给出一个可复现的分频模块:
module clk_divider #( parameter DIVISOR = 10 // 输出频率 = clk_in / DIVISOR )( input wire clk_in, input wire rst_n, output reg clk_out ); reg [$clog2(DIVISOR)-1:0] counter; always @(posedge clk_in or negedge rst_n) begin if (!rst_n) begin counter <= 0; clk_out <= 1'b0; end else begin if (counter == DIVISOR/2 - 1) begin counter <= 0; clk_out <= ~clk_out; end else begin counter <= counter + 1'b1; end end end endmoduleDIVISOR必须为偶数,否则占空比不是50%,ADC的采样保持电路虽然通常能容忍,但后续沿触发逻辑可能不稳定。$clog2(DIVISOR)是Verilog-2001系统函数,用于计算计数器位宽。若DIVISOR不能被2整除,例如15,则会产生非50%占空比,这时建议改用counter == (DIVISOR-1)/2和counter == DIVISOR-1分别翻转,但这样逻辑消耗稍大。
3.2 边沿触发与触发电平比较器
上一节已经给出了上升沿触发代码。实际使用时要注意触发标志只能在采样时钟域内使用,不能直接跨时钟域到UART或VGA域。常见做法是触发标志作为FIFO写使能控制信号。我们设计一个有限状态机控制FIFO写入流程:
localparam IDLE = 2'd0; localparam WAIT_TRIG = 2'd1; localparam FILL_AFTER = 2'd2; reg [1:0] state; reg [11:0] post_count; // 触发后需要写入的点数 wire write_enable = (state == WAIT_TRIG) | (state == FILL_AFTER); always @(posedge clk_adc or negedge rst_n) begin if (!rst_n) begin state <= IDLE; post_count <= 0; end else begin case (state) IDLE: if (trigger_enable) state <= WAIT_TRIG; WAIT_TRIG: begin if (trigger_flag) begin state <= FILL_AFTER; post_count <= 0; end end FILL_AFTER: begin if (post_count == POST_DEPTH - 1) state <= IDLE; else post_count <= post_count + 1'b1; end endcase end endPOST_DEPTH决定触发后继续记录多少个点。假设总存储深度是4096,预触发深度是1024,那么POST_DEPTH就是3072。FIFO写入数据始终来自ADC,但写使能只有state == WAIT_TRIG或state == FILL_AFTER时才有效。注意WAIT_TRIG状态下FIFO也在写入,因为要用预触发数据。触发到来后,状态变为FILL_AFTER,继续写直到填满,然后回到IDLE等待下一次触发。
3.3 异步FIFO跨时钟域缓冲
ADC采样时钟和FPGA系统时钟(比如UART的波特率生成时钟)往往不一致。如果直接把采样数据接入UART模块,会造成跨时钟域的亚稳态问题。解决办法是用异步FIFO,或者在单一时钟下统一采样和发送。简易示波器常用方式是让UART模块使用clk_adc作为自己的时钟,但这样波特率精度可能受到采样时钟抖动影响。更稳妥的是用异步FIFO隔离。
如果你使用Xilinx Vivado,可以直接调用FIFO IP核,并配置为“Independent Clocks”。下面是原语例化风格的接口定义:
fifo_generator_0 your_fifo_inst ( .rst (~rst_n), // 注意IP核常为高有效复位 .wr_clk (clk_adc), // ADC采样时钟 .rd_clk (clk_sys), // UART或显示时钟 .din (adc_data), // ADC采样数据 .wr_en (write_enable), .rd_en (read_enable), .dout (fifo_data_out), .full (fifo_full), .empty (fifo_empty), .rd_data_count (rd_count) );rd_data_count可以用于判断FIFO里有多少数据,进而控制UART发送节奏。如果不用IP核,可以自己用双端口RAM和读写指针实现,但要处理好格雷码指针同步,这是FPGA进阶设计中常考的点。对简易示波器而言,用IP核节省时间,同时也能让逻辑把精力集中在数据流控制上。
4. 波形显示:VGA输出与串口上位机
4.1 VGA时序与波形绘制
VGA显示的好处是“所见即所得”,刷新率由硬件像素时钟保证,不受串口速度限制。VGA标准时序要求每行有同步脉冲、后沿、有效显示区、前沿,每帧也有对应的垂直同步区域。常见640x480@60Hz需要25.175MHz像素时钟,但你可以放宽到25MHz,FPGA内部PLL直接产生。显示一个波形点需要把一个量化值映射到屏幕Y坐标:屏幕高度480像素,8位ADC数据范围0~255,映射公式为y = 479 - (data * 479) / 255。这个除法用移位和乘法器完成,或直接用预计算查找表。
波形绘制的基本思路是:把FIFO中的N个点和屏幕水平像素一一对应。如果FIFO深度大于屏幕宽度,则抽取;如果小于,则复制。例如FIFO深度4096,屏幕宽度640,则每6个点抽1个显示。抽取时最简单的办法是用计数器做分频,每隔几个点读一次FIFO。下面是VGA显示模块中对波形绘制的一部分代码:
// 水平像素坐标 0~639,映射到FIFO读地址 wire [11:0] fifo_rd_addr = (pixel_x >= 160 && pixel_x < 800) ? (pixel_x - 160) * 4 : 12'd0; wire [7:0] waveform_y = 480 - 8'd255; // 占位,实际应为FIFO数据映射上面的代码只是示意,真实的waveform_y应来自FIFO读取端口,并经过一个匹配的行场时序区域使能。绘制连线时,需要比较当前像素行号是否等于波形数据点的y坐标,或者是否等于前后两个点之间的插值,否则波形会显示为一个个离散点。一个简单技巧是当两个相邻点的Y坐标差小于等于2时,直接用暴力竖线填充;否则用插值,但插值会消耗乘法器,建议只在调试时开启。
4.2 通过串口把波形发送到PC虚拟示波器
如果你不想折腾VGA时序,串口是最短的路径。PC端可以安装虚拟示波器软件(如SerialPlot或python绘制的matplotlib脚本),FPGA只负责定时发送波形数据包。这里的关键在于设计串口协议:每帧发送一个固定数据头,比如0xAA 0x55,随后是采样点数,然后是原始ADC数据。PC端根据数据头识别有效数据。
下面是一个UART发送模块(8位、无校验、1停止位,波特率由参数控制):
module uart_tx #( parameter CLK_FREQ = 50_000_000, parameter BAUD_RATE = 115200 )( input wire clk, input wire rst_n, input wire [7:0] tx_data, input wire tx_start, output reg tx_line, output reg tx_busy ); localparam BIT_PERIOD = CLK_FREQ / BAUD_RATE; reg [15:0] cnt; reg [3:0] bit_idx; reg [7:0] shift_reg; reg [1:0] state; parameter IDLE = 0; parameter START = 1; parameter DATA = 2; parameter STOP = 3; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin tx_line <= 1'b1; tx_busy <= 1'b0; state <= IDLE; cnt <= 0; end else begin case (state) IDLE: begin if (tx_start && !tx_busy) begin tx_busy <= 1'b1; shift_reg <= tx_data; state <= START; cnt <= 0; end end START: begin tx_line <= 1'b0; if (cnt == BIT_PERIOD - 1) begin cnt <= 0; bit_idx <= 0; state <= DATA; end else cnt <= cnt + 1'b1; end DATA: begin tx_line <= shift_reg[0]; if (cnt == BIT_PERIOD - 1) begin cnt <= 0; if (bit_idx == 7) begin state <= STOP; end else begin bit_idx <= bit_idx + 1'b1; shift_reg <= {1'b0, shift_reg[7:1]}; end end else cnt <= cnt + 1'b1; end STOP: begin tx_line <= 1'b1; if (cnt == BIT_PERIOD - 1) begin cnt <= 0; state <= IDLE; tx_busy <= 1'b0; end else cnt <= cnt + 1'b1; end endcase end end endmoduletx_start信号由顶层模块在FIFO非空时拉高一个周期。注意必须确保tx_data在tx_start上升沿前已经稳定,否则会发出错误字节。实际使用时,你需要在串行发送打包模块中先发送帧头、再发送数据,这样PC端才能定位同步。
4.3 参数表:采样率、存储深度与刷新率的关系
| 串口波特率 | 每帧数据量(含协议头) | 理论最大刷新率 |
|---|---|---|
| 115200 | 1026 | 约 14 帧/秒 |
| 115200 | 4098 | 约 3.5 帧/秒 |
| 921600 | 1026 | 约 112 帧/秒 |
| 921600 | 4098 | 约 28 帧/秒 |
实际刷新率还要扣除CPU处理时间、串口缓冲溢出等因素,能达到理论值的一半就算不错。如果要求连续观察快速变化信号,建议用VGA显示或者将串口波特率提高到921600以上,此时PCB布线需要注意串口走线阻抗连续。参数表说明:存储深度越大,单帧传的时间越长,刷新率越低;要提升刷新率只能增加波特率或减少每帧点数。
5. 实战调参:采样率、存储深度与触发边沿的配合
5.1 根据信号频率选择采样率
用FPGA示波器观察不同频率信号时,最常犯的错误是采样率固定不变。以一个24MHz时钟输出的MIPI信号为例,虽然数据率很高,但I2C控制信号只有几百kHz。不要用100MHz采样率去看I2C,那样一屏只能看到几百微秒的波形,浪费存储深度。建议根据信号周期计算:一屏显示2~5个周期比较合适。公式是采样率 = 存储深度 / (信号周期 * 4)。例如要观察100kHz方波,周期10us,一屏40us,存储深度4096,则采样率约100MHz。若要观察1kHz信号,则采样率降到约10MHz,或者把存储深度加大到65536。在Verilog中可以用一个多路选择器选择不同的分频系数:
reg [15:0] divider_select; always @(*) begin case (sample_rate_sel) 2'd0: divider_select = 1; // 100MHz 2'd1: divider_select = 10; // 10MHz 2'd2: divider_select = 100; // 1MHz 2'd3: divider_select = 1000; // 100kHz default: divider_select = 1; endcase enddivider_select送入分频模块作为DIVISOR参数,但参数是编译期值,运行期需要改成可加载寄存器。实际上更灵活的方法是让分频模块的DIVISOR作为输入端口,而不是参数。这样在运行时可以动态调节采样率,不用重新综合。
5.2 存储深度决定你能看到多长一段波形
存储深度不仅影响刷新率,还决定你能否捕捉到突发异常。比如你要抓一个串口字节的错误毛刺,毛刺只存在于某个特定的bit间隙,假设毛刺宽度1ns,100MHz采样率下你只能采到一个点,可能恰好丢在FIFO里。如果存储深度只有1024,那么一屏覆盖10us,毛刺的位置可能恰好在窗口外。把存储深度增加到8192,一屏覆盖80us,抓到的概率提高8倍。当然,存储深度增加会消耗更多BRAM,Xilinx 7系列FPGA一般有几十个36Kb BRAM,8192x8位只占用2个BRAM,仍很宽裕。
读取方向也需要考虑。FIFO读数据时,最佳方式是从触发点开始,先读触发前的数据(从FIFO开头),再读触发后的数据,拼成完整波形。但普通FIFO只能顺序读,无法跳过预触发段。更简单的方案是使用双端口RAM,写地址连续,触发后把写地址锁存为停止值,然后从起始地址连续读到停止地址。下面是一个用RAM实现循环缓冲区并读取的思路:
reg [12:0] wr_addr; reg [12:0] rd_addr; reg [12:0] trig_addr; // 写操作始终进行 always @(posedge clk_adc) begin ram[wr_addr] <= adc_data; wr_addr <= wr_addr + 1'b1; end // 触发时锁存停止地址 always @(posedge clk_adc) begin if (trigger_flag) trig_addr <= wr_addr; end // 读取时从 trig_addr 对应的预触发点开始 // 实际实现需根据 trig_addr 计算起始读数地址这种方案的难点在于计算早期地址和跳变,但比异步FIFO更容易理解,也便于实现“滚动显示”。如果你不想碰地址计算,继续用FIFO也行,只是预触发数据会丢失部分,这取决于FIFO的类型。
5.3 触发条件:边沿、电平和毛刺
偏振触发适用于周期信号,但对于随机串口数据,你可能需要毛刺触发。毛刺触发的实现原理是:设定一个窗口宽度(比如3个采样时钟),在窗口内如果信号出现一个超过阈值的短暂脉冲,且脉冲宽度小于窗口宽度,就触发。Verilog里可以这样检测:
reg [2:0] history; always @(posedge clk_adc) begin history <= {history[1:0], sample_data[7]}; end wire spike_detected = (history == 3'b000) ? 1'b1 : 1'b0;这段代码检测的是下降沿后紧跟低电平的连续低电平是否超过两个周期,但它的逻辑反了。要检测毛刺需要比较当前采样值和前后两个采样值的变化。例如若sample_data从高变低再变高,中间只持续了一个时钟,就是毛刺。实现方法是用三位移位寄存器存储连续三次采样,然后判断第一和第三为高、第二为低。这样你就能捕获窄脉冲。注意毛刺检测的窗口宽度不能小于采样周期,否则根本采不到。
5.4 调试中常踩的坑:时钟域、亚稳态、FIFO溢出
最常见的坑是触发标志跨时钟域。如果你用clk_adc产生trigger_flag,又把它直接接到UART的clk_sys上,可能会出现亚稳态,导致误触发。正确做法是让触发标志在clk_adc域内控制FIFO写使能,等FIFO读侧看到非空时再生成读取信号。所有的同步逻辑必须在各自时钟域内完成,这是FPGA示波器能否稳定工作的关键。
另一个坑是FIFO溢出。当触发条件频繁满足时,FILL_AFTER状态结束后FIFO还没被读完,写使能再次拉高会导致数据覆盖。在状态机里必须在FILL_AFTER结束后等待FIFO变为空或剩余空间足够才再次进入等待触发状态。简单方案是在IDLE状态判断fifo_empty,只有FIFO空时才允许开始下一次触发采集。
时基模块的分频计数器位宽不够也会出问题。比如你想要1kHz采样率,50MHz主频分频到1kHz需要50000分频,计数器位宽需要16位,若只写了8位计数器,分频结果会异常。建议用$clog2(DIVISOR-1)或直接定义parameter COUNTER_WIDTH = 16,并在仿真中检查输出波形周期。
6. 进阶:用ILA和ModelSim验证你的FPGA示波器
在FPGA上debug自己的示波器,最直接的工具是Xilinx ILA(集成逻辑分析仪)。你可以把adc_data、write_enable、fifo_full、state等信号接入ILA,触发条件设为trigger_flag==1'b1,然后观察波形。这里的关键不是看信号,而是验证触发时机是否正确:可能你发现触发后只记录了几十个点就停了,那通常是post_count比较的深度参数和FIFO实际深度不匹配。
ModelSim仿真也必不可少。仿真测试平台不需要真实示波器,只需要模拟ADC输出一个正弦波或方波,然后检查FIFO读出的数据顺序。建议先写一个生成简单波形的testbench:
reg [7:0] test_signal; always #10 test_signal = test_signal + 1; // 锯齿波通过验证FIFO写入的波形数据是否等于test_signal,可以很快定位采样链路的问题。仿真是免费的,多跑几个方向能节省大量上板调试时间。除了ILA和仿真,还有一个小技巧:用示波器本身来校FPGA示波器——把FPGA输出的PWM通过电阻分压接到自己的ADC输入,然后调节PWM占空比,看看显示曲线的占空比是否一致。这实际上是让两块示波器互相校准,能同时发现信号源和采集链路的误差。
最后谈一下探头。简易FPGA示波器通常不配高阻抗探头,直接把信号线接在ADC引脚上。如果你用示波器探头,探头切换在x1和x10挡位时,衰减比例不同。x10挡会把信号衰减10倍,若你的ADC量程是0~3.3V,实际最大测量范围变成0~33V,但分辨率会降低到原始量程的十分之一。使用x1挡时,要特别注意输入电压不能超过FPGA引脚绝对最大值,否则会烧毁芯片。建议给ADC输入端加一个二极管钳位保护电路,或者在FPGA内部打开IO端口的弱下拉和钳位二极管。对于核心技术验证,这些保护可以暂缓,但如果你想长时间运行,不要把超过3.6V的电压直接接入ADC。
本文还有配套的精品资源,点击获取