news 2026/9/16 15:48:25

FPGA简易示波器设计:从触发逻辑到异步FIFO的完整Verilog实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
FPGA简易示波器设计:从触发逻辑到异步FIFO的完整Verilog实现

简介:一套基于 FPGA 与 Verilog 的简易数字存储示波器工程,适合电子工程、嵌入式及数字系统设计初学者,也适合教学实验和原型验证。项目已在 EP2C8Q208C8 上验证,覆盖数据采集、存储缓冲、触发控制、显示接口及时序分析等核心模块,可帮助读者理解简易示波器的完整设计流程,并给出各模块在 Quartus 下的层次组织。资源包为 rar 格式,共 757 个文件,约 1.73MB,以 Quartus 工程中间文件(cdb/hdb)居多,同时包含 Verilog 源码(.v)、TDF 定义、QSF/QPF 工程设置、sof/pof 配置文件以及 readme 等说明文档,目录保留了完整工程构建与综合结果,便于对照学习与二次开发。目前已有 901 人学习/浏览。通过源码、工程文件与网表映射等材料,读者能够查看采样、存储、触发和显示模块的具体实现,并了解 Quartus 环境中从设计输入到布局布线、生成下载文件的完整过程。

1. 从“跑马灯之外”看FPGA示波器:为什么采样率不是唯一指标

在学校实验室或自己的桌面调试台上,当你把一个信号发生器输出的方波接到自制“示波器”输入端,屏幕上却没有出现预期的边沿时,问题往往不出在ADC有多快,而在触发逻辑和跨时钟域处理上。FPGA简易示波器这个题目,几乎所有做数字IC和FPGA开发的人都动过手:要么为了课程设计,要么为了给自家板卡加一个便携调试工具。表面上看,它不过是“ADC采集数据 + 显示”,但真正把verilog代码写完整、跑通上板的人都知道,核心难点在三处:采样时钟与显示刷新时钟的异步交互、触发条件的准确捕获、以及FIFO深度和有效存储深度之间的取舍。这篇文章会把从架构拆分到可综合代码的完整路径讲清楚,适合已经有基础verilog语法、想认真完成一个FPGA数据采集项目的工程师。

2. 从需求到架构:FPGA示波器的五级处理链路

2.1 先定采集端的时钟和位宽,再谈处理逻辑

FPGA示波器的第一个关键是决定ADC接口长什么样。多数简易示波器使用ADC0809这类8位并行ADC,时钟直接由FPGA的PLL输出驱动。采样率决定了你能看到的信号最高频率,根据奈奎斯特准则,实际可观测信号频率大约是采样率的十分之一到二十分之一。也就是说,50MSPS的采样率,看5MHz以内的波形才是实际可用的。位宽则决定了垂直分辨率,8位对应256个量化级别,显示在屏幕上已经能得到像样的波形曲线。

架构上,我一般把整个系统拆成五级:

信号调理 -> ADC采样 -> 触发检测 -> 异步FIFO -> 显示刷新

信号调理负责把外部信号的电平调整到ADC输入范围,通常是0到3.3V或者0到5V,这部分是模拟电路的事。ADC采样模块在FPGA里不过是一段同步逻辑,难点在触发检测和异步FIFO。触发检测要根据设定的边沿类型判断什么时候开始存储数据,而FIFO要解决采样时钟域和显示时钟域不一致的问题。如果跳过FIFO直接做跨时钟域,显示端采到的数据会有大量亚稳态,波形看起来像噪声。

2.2 触发模式决定波形是否稳定,边沿触发是常用起点

示波器能稳定显示周期性信号,本质上靠的是触发。上升沿触发的意思是:当信号从低到高跨越阈值电压时,开始把ADC数据写入FIFO。这样的话,每次存储的起点都对应信号同一位置,显示出来波形就能左右稳定。实现上不复杂,用两个寄存器把当前采样值和上一拍采样值做比较,前一个低于阈值、后一个高于阈值,就产生一个trigger信号。

reg [7:0] sample_d1; reg trigger_rise; wire [7:0] threshold = 8'd128; always @(posedge clk_adc or negedge rst_n) begin if (!rst_n) begin sample_d1 <= 8'd0; trigger_rise <= 1'b0; end else begin sample_d1 <= adc_data; if (sample_d1 < threshold && adc_data >= threshold) trigger_rise <= 1'b1; else trigger_rise <= 1'b0; end end

这段代码里,clk_adc是ADC采样时钟,rst_n是低电平复位的异步清零信号。寄存器的非阻塞赋值保证了sample_d1adc_data的边沿比较是基于前后两个连续的采样点,而不是同一拍内的不确定值。阈值8'd128对应ADC中点电压,也就是信号电平的一半。收到触发信号后并不会立刻停止采集,而是要等FIFO填满预设点数才停止。这段时间差对应触发位置在波形上的左右偏移,后文会进一步说明。

2.3 存储深度的计算公式:采样率乘时间窗

FIFO深度等于采样率乘上你需要显示的时间窗口长度。50M采样率,要显示10微秒的波形窗口,深度就需要500个点。这看起来不多,但实际上考虑到需要先采集、后处理、再显示,深度往往做到4096或8192。选择深度时有一条经验规则:深度是显示水平分辨率的四到八倍。如果显示端是800像素宽,那么每像素对应5到10个采样点,波形曲线会非常平滑,也能做后端的插值显示。

采样率时间窗FIFO深度显示分辨率(建议)
50 MSPS10 us500 -> 取512128-256像素
50 MSPS100 us5000 -> 取4096或8192512-1024像素
100 MSPS10 us1000 -> 取1024256-512像素

FIFO深度建议使用2的幂次,这样地址回卷只需要做位与操作,不需要判断是否到最大值,综合后的资源占用会小幅下降。在异步FIFO设计里,深度还影响格雷码跨时钟域传递的可靠性,稍后会说明为什么格雷码是必选项而不是可选项。

3. 核心Verilog模块实现:采样、FIFO与控制逻辑

3.1 ADC采样接口时序与状态机

ADC在FPGA里通常是一个简单的并行接口,但数据不是每个时钟周期都有效,需要根据ADC芯片手册搭建一个握手机制。以一个典型的8位并行ADC为例,接口信号包含:转换启动信号、忙信号或数据有效信号、并行数据总线。用状态机来控制采样的节奏,而不是直接在每个时钟上升沿读取数据。

localparam IDLE = 2'd0; localparam CONV = 2'd1; localparam READ = 2'd2; reg [1:0] state; reg adc_start; reg [7:0] adc_data_reg; always @(posedge clk_sys or negedge rst_n) begin if (!rst_n) begin state <= IDLE; adc_start <= 1'b0; adc_data_reg <= 8'd0; end else begin case (state) IDLE: begin adc_start <= 1'b1; state <= CONV; end CONV: begin adc_start <= 1'b0; if (adc_busy) begin state <= READ; end end READ: begin adc_data_reg <= adc_data; state <= IDLE; end endcase end end

这个状态机里,clk_sys是系统时钟,不一定是采样时钟。许多实际项目中,系统时钟和ADC采样时钟是分开的,系统时钟跑更高频率来执行控制逻辑,采样时钟则是PLL分频得到的固定速率。状态机从IDLE启动转换,检测到ADC的busy信号变高后等待转换完成,再在READ状态锁存数据。关键参数是每个状态持续几个时钟周期,必须大于ADC芯片手册规定的最小转换时间。如果你使用的是ADC0809这类芯片,转换时间大约100微秒,那么系统时钟50MHz下每个状态可能需要持续多个周期,单纯用状态机跳转是不够的,需要引入计数器来延长状态持续时间。

很多初学者会直接在这个状态机里写数据存储逻辑,这就埋了一个隐患:一次采样转换只能得到一个数据点,而存储逻辑需要连续的数据流。更合理的做法是把状态机产生的adc_data_reg通过一个简单的握手信号(比如data_valid)交给FIFO写入端,由写侧逻辑负责连续存储。

3.2 跨时钟域方案:异步FIFO与格雷码采样的关系

要理解为什么异步FIFO是FPGA示波器的必需品,先看一个数据流关系:ADC采样时钟来自PLL,显示刷新时钟来自像素时钟或串口波特率时钟,二者不仅频率不同,相位也完全无关。如果直接用读时钟去采写时钟域的信号,在边沿附近会进入亚稳态,值可能非0非1,也可能在采样后抖动。

解决亚稳态的常见做法有两种:一种是用两级同步器打两拍,适用于单bit跨时钟域信号;另一种是使用异步FIFO,适用于多位宽数据跨时钟域。而多位宽数据如果直接同步,是不能保证所有bit在同一拍到达的。异步FIFO的巧妙之处在于,写地址和读地址分别用二进制递增,但在跨时钟域传递前转换成格雷码。格雷码相邻两个数值之间只有一位变化,这样即使同步器采样到中间状态,读出的地址偏差也只有一个单位,不会导致数据读写指针错乱。

module async_fifo #( parameter DATA_WIDTH = 8, parameter ADDR_WIDTH = 10 )( input wire wr_clk, input wire wr_rst_n, input wire wr_en, input wire [DATA_WIDTH-1:0] wr_data, output wire wr_full, input wire rd_clk, input wire rd_rst_n, input wire rd_en, output wire [DATA_WIDTH-1:0] rd_data, output wire rd_empty ); reg [ADDR_WIDTH:0] wr_ptr_gray; reg [ADDR_WIDTH:0] rd_ptr_gray; reg [ADDR_WIDTH:0] wr_ptr_gray_sync1; reg [ADDR_WIDTH:0] wr_ptr_gray_sync2; reg [ADDR_WIDTH:0] rd_ptr_gray_sync1; reg [ADDR_WIDTH:0] rd_ptr_gray_sync2; wire [ADDR_WIDTH-1:0] wr_addr = wr_ptr_gray[ADDR_WIDTH-1:0]; wire [ADDR_WIDTH-1:0] rd_addr = rd_ptr_gray[ADDR_WIDTH-1:0]; reg [DATA_WIDTH-1:0] mem [0:(1<<ADDR_WIDTH)-1]; assign wr_full = (wr_ptr_gray == {~rd_ptr_gray_sync2[ADDR_WIDTH:ADDR_WIDTH-1], rd_ptr_gray_sync2[ADDR_WIDTH-2:0]}); assign rd_empty = (rd_ptr_gray == wr_ptr_gray_sync2); always @(posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) wr_ptr_gray <= 0; else if (wr_en && !wr_full) begin mem[wr_addr] <= wr_data; wr_ptr_gray <= wr_ptr_gray + (wr_ptr_gray[0] ^ 1); end end always @(posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) rd_ptr_gray <= 0; else if (rd_en && !rd_empty) rd_ptr_gray <= rd_ptr_gray + (rd_ptr_gray[0] ^ 1); end always @(posedge wr_clk or negedge wr_rst_n) begin if (!wr_rst_n) begin rd_ptr_gray_sync1 <= 0; rd_ptr_gray_sync2 <= 0; end else begin rd_ptr_gray_sync1 <= rd_ptr_gray; rd_ptr_gray_sync2 <= rd_ptr_gray_sync1; end end always @(posedge rd_clk or negedge rd_rst_n) begin if (!rd_rst_n) begin wr_ptr_gray_sync1 <= 0; wr_ptr_gray_sync2 <= 0; end else begin wr_ptr_gray_sync1 <= wr_ptr_gray; wr_ptr_gray_sync2 <= wr_ptr_gray_sync1; end end assign rd_data = mem[rd_addr]; endmodule

ADDR_WIDTH = 10对应1024个存储单元,实际工程中根据存储深度的需要改成12或13即可。wr_ptr_grayrd_ptr_gray的最高位用于指示是否跨越了半圈,比较满空状态时考虑最高位可以区分“全满”和“全空”的边界条件。写侧比较满信号时,使用同步后的读指针的格雷码并与当前写指针格雷码做比较;读侧同理。这段代码省略了读写指针的二进制转格雷码逻辑,直接用wr_ptr_gray + (wr_ptr_gray[0] ^ 1)这种递增方式来生成格雷码序列,需要注意的是,标准格雷码计数器和这个表达式的等价性,是基于“最低位翻转则进位”的特性。实际工程中建议写成独立的二进制计数加组合逻辑转换,可读性更好,也方便仿真调试。

3.3 读侧与显示刷新:如何把采样点映射到扫面线

数据读出来之后,怎么变成屏幕上的一条波形?如果使用VGA接口显示,需要把FIFO读出的采样点按坐标映射成一幅位图;如果通过串口传到上位机,则只需要简单的协议打包。简易示波器工程里,VGA直连显示是常见做法,省去上位机软件,也更容易在板级验证时看到效果。

wire [9:0] pixel_x; wire [9:0] pixel_y; wire vga_clk; reg [11:0] vga_data; always @(posedge vga_clk) begin if (pixel_y == (10'd240 - adc_value)) begin vga_data <= 12'hFFF; // 白色点显示波形 end else begin vga_data <= 12'h000; // 黑色背景 end end

这里用一个极简单的思路:把ADC采样值当作Y坐标偏移量,X坐标直接对应FIFO读地址。每一行像素扫描时,从FIFO读取一个数据点,计算出对应的Y坐标,点亮该像素。VGA像素时钟通常是25MHz或40MHz,而FIFO读时钟可以直接用像素时钟,这样无需额外的时钟域转换,读使能信号接像素扫描的列有效信号即可。

这种映射方式存在两个问题:一是波形看起来是一条线,缺少余辉效果;二是当采样点比像素少时,波形会出现断续。工程上常见的解决手段是把每个采样点扩展成上下几行的垂直点亮,或者用简单线性插值填补相邻点的空白。插值逻辑用一个组合逻辑完成,代价很小,但显示效果提升明显。

4. 仿真与上板验证:看波形之前先看仿真波形

4.1 编写行为级ADC模型,检验触发边界条件

FPGA工程调试的第一步永远是仿真,而不是上板。示波器工程里需要仿真的对象是触发检测和异步FIFO的读写协同。编写一个简单的ADC行为模型来产生已知特征信号,比如频率已知的正弦波或带毛刺的方波。

module adc_model ( input wire clk, input wire start, output reg [7:0] data, output reg busy ); reg [7:0] phase; reg [7:0] sine_lut [0:255]; initial begin $readmemh("sine_lut.hex", sine_lut); phase = 8'd0; busy = 1'b0; data = 8'd0; end always @(posedge clk) begin if (start) begin busy <= 1'b1; end else if (busy) begin busy <= 1'b0; data <= sine_lut[phase]; phase <= phase + 8'd4; end end endmodule

$readmemh从外部十六进制文件加载正弦查找表,这个文件可以用MATLAB或Python生成,也可以手写一小段C代码导出。phase每次加4,相当于查表步长为4/256,输出的正弦波周期是64个时钟周期。挂上这个模型之后,用testbench给触发模块施加一个低到高的信号跳变,观察trigger_rise是否出现在正确的时钟沿上,以及FIFO写入的起始位置是否与触发点对齐。这一步能把大部分边沿判断的逻辑错误在仿真阶段找出来,上板后才不会对着毛刺抓瞎。

testbench里需要注意的一点是,FIFO复位信号必须比写时钟和读时钟的有效沿早至少一个周期释放,否则地址格雷码的初始状态可能不对,导致rd_empty无法拉低,数据读不出来。你可以打一个计数器,在复位释放后等待50个周期再开始对FIFO进行操作。

4.2 ModelSim与Vivado仿真中的常用调试命令

进行仿真时不必把整个工程的波形全部翻出来看,关注几个关键信号足够了:trigger_risewr_enfifo_wr_ptr_grayrd_emptyadc_value。在ModelSim中执行如下命令可以快速检查:

add wave -hex /testbench/u_adc_model/data add wave -hex /testbench/u_fifo/rd_data add wave /testbench/u_trigger/trigger_rise add wave /testbench/u_fifo/wr_en add wave /testbench/u_fifo/rd_empty run -all examine -hex /testbench/u_fifo/rd_data

add wave命令把关心的信号加入波形窗口,run -all运行到仿真结束,examine直接在命令行读取当前时间点的数据值。当你发现rd_empty始终为高、FIFO数据读不出来时,检查写侧使能信号wr_en是否在触发之后被正确拉高。常见错误是触发信号只维持了一个时钟周期,但FIFO写使能需要持续整个存储窗口,需要在触发后用计数器接续拉高写使能。

reg wr_en_reg; reg [13:0] write_cnt; always @(posedge clk_adc or negedge rst_n) begin if (!rst_n) begin wr_en_reg <= 1'b0; write_cnt <= 14'd0; end else if (trigger_rise) begin wr_en_reg <= 1'b1; write_cnt <= 14'd0; end else if (write_cnt == 14'd4095) begin wr_en_reg <= 1'b0; end else if (wr_en_reg) begin write_cnt <= write_cnt + 1'b1; end end

这段代码在触发后把写使能拉高,写入4096个点后自动拉低。由此引出一个细节:触发点并不在FIFO的最前端,而是在中间某个位置。也就是说,触发发生前的数据并没有被保存。如果你希望看到信号触发前的状态(实际测量中非常重要),需要增加预触发逻辑,例如让FIFO始终处于写入状态,触发后不停止写入,而是等FIFO满了再停止,并从中取出数据块。这样FIFO里既有触发前的数据,也有触发后的数据,显示端再做左右偏移动画。这种方案对存储深度的需求会提高到8192或16384。

4.3 板上调试,用ILA抓片内信号

仿真只能验证逻辑的正确性,上板后的时序问题需要依靠FPGA厂商自带的逻辑分析仪来抓取,Xilinx平台是ILA,Intel平台是SignalTap。ILA可以把FPGA内部信号实时采样并上传到PC端查看,等效于把一个逻辑分析仪塞进你的示波器工程里。

create_debug_core u_ila_0 ila set_property C_DATA_DEPTH 1024 [get_debug_cores u_ila_0] set_property C_TRIGIN_EN false [get_debug_cores u_ila_0] set_property C_TRIGOUT_EN false [get_debug_cores u_ila_0] connect_debug_port u_ila_0/CLK [get_nets clk_adc] connect_debug_port u_ila_0/PROBE0 [get_nets trigger_rise] connect_debug_port u_ila_0/PROBE1 [get_nets adc_data_reg] connect_debug_port u_ila_0/PROBE2 [get_nets wr_en_reg]

create_debug_core在Vivado的Tcl控制台里创建一个ILA核,C_DATA_DEPTH 1024表示采样深度为1024个时钟周期,PROBE0PROBE2分别连接触发信号、ADC数据和FIFO写使能。上板运行后触发ILA捕获,观察的实际时序情况与仿真是否一致。尤其要注意trigger_rise信号是否有毛刺,如果毛刺存在,说明ADC数据的建立时间可能不满足,需要在数据线上增加约束或者把采样时钟沿调整半个周期。

5. 三种提升测量精度的工程技巧

5.1 用等效时间采样扩展高频信号观察能力

当采样率不足以覆盖信号频率时,常规做法是提高ADC时钟,但受限于FPGA和ADC芯片的速度等级,频率总有一个上限。等效时间采样是另一个思路:假设信号是严格周期性的,每次触发后只采集一个点,但在不同触发周期里通过调整采样时钟相位滑动采样点,最终把这些点按相位重新排列成一个完整波形。实现上需要一个高精度的相位累加器和一个可变延迟链,代码量不大,但要注意信号必须真的是周期信号,否则还原出的波形是乱码。

5.2 用均值滤波降低随机噪声

对于高频噪声引起的波形毛刺,可以在显示端做多次采集后按点取平均,工程上称为滚动平均或滑动窗口滤波。verilog实现滑动窗口滤波的核心是维护一个固定长度的移位寄存器和一个累加器,每次进来新数据,累加器加新值、减最旧值,输出为平均值。

reg [7:0] shift_reg [0:15]; reg [11:0] sum; reg [3:0] index; always @(posedge clk_disp or negedge rst_n) begin if (!rst_n) begin sum <= 12'd0; index <= 4'd0; end else if (rd_en) begin sum <= sum + rd_data - shift_reg[index]; shift_reg[index] <= rd_data; index <= index + 1'b1; end end assign filtered_data = sum[11:4];

滤波窗口从数据流的视角看,是每16个点求一次平均。sum[11:4]相当于除以16,用移位代替除法节省DSP资源。这个技巧适用于信号本身变化比较缓慢、噪声频率高的场景,如果信号本身就是高速变化的方法,均值滤波会把边沿磨平,取舍要按待测信号特征来定。

5.3 显示端加余辉效果提升肉眼可读性

波形在VGA屏幕上如果只显示一条亮线,视觉上会显得单薄,也难以看出信号的历史轨迹。简单的数字余辉实现办法是:在屏幕刷新中断里,读取上一帧的波形数据,把当前采样值和历史值做加权合并后写入缓存。代码上不过是一个加法和移位:

assign display_value = (current_value >> 2) + (old_value >> 2) + (old_value >> 1);

这个公式等价于0.25倍当前值 + 0.75倍历史值,每次刷新后旧波形逐渐变暗但并不立刻消失,形成类似模拟示波器余辉的视觉残留。权重参数可以按显示刷新率调节,余辉时间过长会让快速变化的信号出现拖影,过短则失去余辉意义。从调试角度说,余辉效果能直观地帮你观察信号抖动范围,比盯着单个波形线要高效得多。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 15:46:43

STM32模拟SPI驱动TLC3578实现8路±10V高精度采集

简介&#xff1a;针对STM32开发者&#xff0c;这份资源围绕TLC3578模数转换芯片&#xff0c;提供基于模拟SPI的驱动方案&#xff0c;解决8通道10V信号采集与单片机通信问题。资源共152个文件&#xff0c;整体4.35MB&#xff0c;包含H/C源文件、Keil工程文件、PDF说明文档、编译…

作者头像 李华
网站建设 2026/9/16 15:46:36

MATLAB中SA-PSO混合优化算法原理与神经网络训练实战

简介&#xff1a;本资源是一套面向MATLAB初学者与优化算法实践者的融合型算法实现代码包&#xff0c;聚焦于智能优化领域中模拟退火&#xff08;SA&#xff09;与粒子群&#xff08;PSO&#xff09;算法的协同改进思路&#xff0c;适用于高校课程设计、毕业设计及科研原型验证等…

作者头像 李华
网站建设 2026/9/16 15:46:09

用 mcp-agent 构建带日志与进度通知能力的 MCP Server 实战指南

用 mcp-agent 构建带日志与进度通知能力的 MCP Server 实战指南 【免费下载链接】mcp-agent Build effective agents using Model Context Protocol and simple workflow patterns 项目地址: https://gitcode.com/GitHub_Trending/mc/mcp-agent 导读 本篇基于 mcp-agen…

作者头像 李华
网站建设 2026/9/16 15:44:59

AI短漫剧工业化生产全链路方案解析

1. 项目概述&#xff1a;这不是“用AI画几张图”&#xff0c;而是一整套工业化短漫剧流水线“腾讯云AIGC全链路方案&#xff1a;降低AI短漫剧制作成本并提升产能”——这个标题里藏着三个被很多人忽略的关键词&#xff1a;全链路、工业化、短漫剧。不是单点工具&#xff0c;不是…

作者头像 李华