简介:基于FPGA的VGA示波器设计是一套围绕FPGA与VGA接口实现的完整工程资源,面向电子设计竞赛参赛者、FPGA入门学习者以及需要自制测试仪器的硬件工程师。项目以FPGA为控制核心,通过编程生成正弦、方波、三角、锯齿四种波形,幅度与频率均可动态调整,并借助VGA接口完成数字信号到显示器像素的映射、时序控制与平滑滤波,内容覆盖波形发生器、参数调整、坐标计算、显示驱动和模块化设计等关键环节。压缩包共318个文件,主要包含FPGA源码(v、tdf)、Quartus工程与配置文件(qpf、qsf、qdb、qip)、仿真波形与后端时序文件(sdo、vo、wlf)、mif初始化数据以及可下载的sof配置文件等,整体大小18.95MB,目录结构清晰。当前已有277人参与学习,可直接参考这些文件还原项目设计流程,或提取其中VGA接口驱动、任意波形生成和参数动态调节的实现思路,用于课程设计、毕业设计或实际仪器开发。
1. 打开第一台FPGA示波器:入门带宽够用,先把信号“看见”再说
你手头需要看一个1kHz方波、一条I²C总线、一块电源芯片的输出纹波,买台式示波器预算不够,用逻辑分析仪又看不见模拟量,这时最划算的路线不是省吃俭用,而是自己写一个FPGA示波器。用一块入门级FPGA开发板加一块并行ADC,把采样数据缓存到内部RAM,再通过VGA接口实时显示波形——这个方案做出来的数字示波器,带宽做到20MHz左右、采样率做到100MSPS左右,对应三五千元成品仪器的入门水平,成本只有其中的零头。更重要的是,做完这一套,你对时序逻辑、跨时钟域、存储带宽、接口协议的理解,比翻十遍课本都扎实。这篇文章适合已经会写基本Verilog、手头有一块开发板和VGA显示器的工程师,照着做,一路踩坑一路排,最终拿到一台能触发、能测频、能看波形的桌面仪器。
2. 为什么这个方案用FPGA而不是单片机:先把带宽账算明白
2.1 单片机和ARM卡在哪:不是CPU频率不够,是“实时”两个字
先看一个常见误区:有人拿主频200MHz的ARM Cortex-M4去采10MHz正弦波,ADC采样率标称5MSPS,按奈奎斯特采样定理,2.5MHz以下信号“理论上”能采。但实际一接信号源,波形完全失真。原因不是ADC位数不够,而是采样不均匀:CPU要响应中断、搬运数据、刷新屏幕,每次采样的时间间隔抖动达到几十纳秒甚至几百纳秒,这种抖动在频域表现为相位噪声,直接拉高波形本底噪声,让正弦波变成一条粗线。
我做这个方案的时候同样踩过这个坑,后来想明白一个道理:示波器前端必须有一个不依赖CPU的“硬实时”采样链路。FPGA的优势就在这儿——ADC的转换时钟直接由FPGA引脚输出,采样时刻由硬件时钟沿锁定,抖动在皮秒级。采样数据进来之后,要么进RAM,要么进FIFO,全程没有“等待”这个动作。这正是示波器这类设备必须用FPGA或ASIC来实现的原因,单片机的CPU再快,也只是“事后处理快”,采样瞬间的确定性做不到。
2.2 采样率与带宽的关系:200MSPS为什么只能标40MHz带宽
示波器带宽的定义是正弦波幅度衰减到实际幅度的70.7%时对应的频率,不是“能看多少频率的信号”。一个标称40MHz带宽的示波器,测40MHz正弦波幅度已经衰减了3dB,测100MHz方波可能已经只剩一条带毛刺的基线。原因是方波的频谱分量延伸很远,基频只是其中能量最大的那一根。
工程上常用的换算关系有三档:采样率除以5到8,得到“靠谱带宽”;除以10,得到“能看个大概”的带宽;除以25,得到“能准确测量幅度”的带宽。上面说的200MSPS采样率,除以5是40MHz,这就是为什么多数200MSPS示波器标称带宽40MHz——不是硬件做不到更高,是标高了用户会拿去测100MHz信号,测出来幅度不对就投诉。
同样道理,ADC的模拟输入带宽如果设计成200MHz,那前端电路本身也会引入衰减。做这个FPGA示波器项目时,我把目标定在10MHz以内就收手了,原因很现实:ADC选型容易,前端放大电路不必做超高速运放,PCB布局布线压力小一个量级,而10MHz带宽已经能覆盖大部分日常调试场景。
2.3 主控选型与系统级面板设计:入门级FPGA够不够用
先列一个最小系统需要的东西,再谈选型。这个系统由五块组成:ADC前端电路、ADC芯片、FPGA主控、VGA接口、输入面板(按键或旋钮)。下面是一个参考配置表:
| 模块 | 选型参考 | 关键参数 | 备注 |
|---|---|---|---|
| ADC | 8bit 并行ADC,30~50MSPS | 量化位数、ENOB、输入范围 | ENOB有6.5bit以上即可 |
| FPGA | 入门级逻辑单元5k~15k | BRAM容量需至少够一帧波形 | 内部RAM不够就外挂SRAM |
| 时钟 | 板上50MHz晶振 + PLL倍频 | 像素时钟25.175MHz、采样时钟30MHz | PLL产生,不用外部时钟源 |
| VGA | 15针D-Sub接口 | RGB三路,用3bit~8bit电阻网络分压 | 不用DAC也行,电阻分压 |
| 前端 | 两个运放做偏置与驱动 | 带宽≥50MHz,增益1~2 | 关键看共模范围 |
入门级FPGA逻辑单元一般在5k到15k之间,这个项目主要资源消耗是:VGA时序控制器大约占200~400个逻辑单元,采样时钟域控制大约占100个,RAM深度2k×8bit大约占几个BRAM块,整体资源占用非常低。唯一要提前确认的是BRAM容量:如果一屏要存2048个采样点,每点8bit,就是16kbit,入门级FPGA一般都有几百kbit的BRAM,足够。我见过有人一上来就外挂SRAM芯片,结果逻辑复杂度翻倍,实际上内部RAM不够时,优先减小存储深度而不是外挂芯片——示波器又不是要存几十秒的波形。
3. VGA时序是第一个大坑:行场参数和像素时钟差一点都不行
3.1 VGA协议的最小模型:三路颜色与两个同步信号
VGA接口的本质就五个信号:三路模拟颜色(R/G/B)和两个同步信号(行同步HS、场同步VS)。行同步信号标志着一行像素扫描的开始和结束,场同步信号标志着一帧画面的开始和结束。显示器内部有晶体振荡器,它只做一件事:严格跟随行同步信号的节奏逐行移动电子束,再跟随场同步信号逐帧换行。因此FPGA要做的只有两件事:精确输出HS和VS脉冲,以及在可见区域里按行列坐标送出对应的颜色数据。
分辨率的每一个参数都有行业标准,不能自己拍脑袋。640×480@60Hz是VGA最基础、对FPGA最友好的模式,像素时钟只要25.175MHz,而这个频率在FPGA的PLL里很容易倍频出来,几乎不需要外部时钟芯片。注意一个问题:VGA时序里的大多数“后沿”“前沿”数值因分辨率不同而不同。我见过有人把800×600的行参数套在640×480上用,结果是画面整体偏移、左右有宽黑边、显示内容被压缩——时序这东西严格到每一个像素,没有“差不多”的余地。
3.2 640x480@60Hz完整时序参数表,收藏这一张就够
下面的参数是VESA标准的640×480@60Hz非隔行模式,FPGA工程里直接按这个表写计数器:
| 项目 | 像素/行数 | 说明 |
|---|---|---|
| 可见行宽 | 640 | 显示有效像素 |
| 行前沿 | 16 | 行同步脉冲之前的空白 |
| 行同步脉冲 | 96 | HS拉低的时间 |
| 行后沿 | 48 | 行同步脉冲之后的空白 |
| 整行长度 | 800 | 640+16+96+48 |
| 可见帧高 | 480 | 显示有效行数 |
| 帧前沿 | 10 | 场同步脉冲之前的空白行 |
| 场同步脉冲 | 2 | VS拉低的时间(单位:行) |
| 帧后沿 | 33 | 场同步脉冲之后的空白行 |
| 整帧长度 | 525 | 480+10+2+33 |
| 像素时钟 | 25.175MHz | 上述参数全部以该时钟为基准 |
| HS极性 | 负极性 | 低电平有效 |
| VS极性 | 负极性 | 低电平有效 |
这里有个容易写错的地方:HS脉冲宽度96个像素时钟,VS脉冲宽度2行,意思是场同步期间正好跳过2个完整的行周期。这两者之间存在“行同步包含在场同步里”的嵌套关系,编码实现时一般用两个计数器:一个像素计数器(0~799)负责行内定位,一个行计数器(0~524)负责帧内定位,行计数器在像素计数器归零时加一,这样逻辑最清晰。
3.3 像素时钟的选择:PLL精确到25.175MHz还是用25MHz凑合
FPGA开发板上的晶振一般是50MHz,用PLL倍频到25.175MHz是可以做到的,但有些入门级FPGA的PLL频率步进不够精细,分频系数组合后只能得到25MHz或25.25MHz。这时有两种选择。第一种是用25MHz将就,代价是每行800个像素时钟里少了7个,显示器会从场同步信号推算行扫描频率,画面会出现在轻微的水平偏移,同时每行右侧有几像素的裁切,肉眼几乎看不出来。第二种是用PLL的“分数分频”能力,配置出精确的25.175MHz,如果FPGA不支持分数分频,就放弃。
我一般会直接选25MHz,同时把行后沿从48改成55、行前沿从16改成9,让整个行周期仍然是800个时钟,这样显示器侧的时序完全和标准一致,只是把“多出来的7个像素”安排进了消隐区。这种做法比死磕25.175MHz要省事得多,而且不影响显示效果。另外注意,像素时钟的抖动直接影响画面稳定性,PLL电源引脚上加0.1μF和10μF并联去耦电容是必须的操作,不是可选优化项。
3.4 行场同步的Verilog骨架:两个计数器就够了
VGA控制器的核心代码不长,但要注意计数器置位逻辑,下面给出一个最简骨架:
// VGA 640x480@60Hz 时序控制器骨架 // 时钟: pixel_clk (25MHz) reg [9:0] h_cnt = 0; // 行像素计数器 0~799 reg [9:0] v_cnt = 0; // 帧行计数器 0~524 wire h_valid = (h_cnt < 640); // 行内可见区 wire v_valid = (v_cnt < 480); // 帧内可见区 wire valid = h_valid & v_valid; // 像素有效标志 // 行计数: 像素计满800归零, 同时行计数器加1 always @(posedge pixel_clk) begin if (h_cnt == 799) begin h_cnt <= 0; if (v_cnt == 524) v_cnt <= 0; else v_cnt <= v_cnt + 1'b1; end else begin h_cnt <= h_cnt + 1'b1; end end // 同步输出: 负极性 assign hsync = (h_cnt >= 640+16) && (h_cnt < 640+16+96) ? 1'b0 : 1'b1; assign vsync = (v_cnt >= 480+10) && (v_cnt < 480+10+2) ? 1'b0 : 1'b1;这段代码把HS的下降沿放在第656个像素时钟,持续96个时钟后恢复高电平;VS下降沿放在第490行,持续2行后恢复。注意v_cnt的最大值524是从“480+10+2+33=525行”减一得到的,因为计数器从0开始。写代码时最容易犯的错是把“总共525行”直接当成最大值525,结果帧有526行,显示器的帧率就偏了。
4. 从采样到显示的核心链路:RAM缓存与双口读写是成败关键
4.1 模块划分:先切成四个独立的部分再动手
整个工程不复杂,但不分模块直接写一个顶层文件的话,综合后的时序约束会非常乱。常见做法是分四个模块:采样控制模块负责接收ADC数据和产生写地址;触发模块负责判断是否满足触发条件、是否重置写地址;存储模块用FPGA内部的真双口RAM,写时钟接采样时钟、读时钟接像素时钟;显示控制模块产生行列坐标并映射成读地址和RGB颜色。四个模块之间用简单的握手信号连接,不要搞复杂的总线协议。
这种划分的核心原因是“跨时钟域”——采样时钟是30MHz,像素时钟是25MHz,两者频率不同、相位不同,如果直接把采样数据用像素时钟去读,会出现数据冲突和显示错位。RAM必须用带独立读写时钟的真双口RAM,写端口时钟接adc_clk,读端口时钟接pixel_clk,两边地址独立,读写互不干扰。这是整个项目最关键的技术决策,没有之一。
4.2 采样存储:写地址生成与写使能的完整逻辑
写地址的逻辑要回答一个问题:每次采样到来时,数据写到哪个RAM地址。未触发状态下,数据按顺序从0写到2047再循环,像流水一样不断刷新,这就是“滚动模式”;触发之后,写地址清零重写,这就是“触发模式”。两种模式靠写使能和地址重置信号切换。下面这段代码示意触发模式的核心逻辑:
// 采样写入逻辑, 时钟为adc_clk(30MHz) // 设定触发条件: 信号上升沿跨过阈值 reg trigger_armed; reg last_above; reg [10:0] wr_addr; reg wr_en; always @(posedge adc_clk) begin // 电平检测: 当前采样值高于阈值 且 上一个时钟低于阈值 => 上升沿 if (adc_data > 8'd128 && last_above == 1'b0) begin trigger_armed <= 1'b1; end if (trigger_armed) begin wr_addr <= 11'd0; // 触发点写入RAM起始地址 trigger_armed <= 1'b0; end else if (wr_addr < 2047) begin wr_addr <= wr_addr + 1'b1; end wr_en <= (wr_addr < 2047); // 写满2048点后停止 last_above <= (adc_data > 8'd128); // 延迟一拍用于边沿检测 end注意这段代码里的last_above寄存器必须在每次时钟沿都刷新,它的作用是保存“上一个时钟周期信号是否在阈值之上”这个历史状态。触发判断必须用last_above,不能用adc_data当前值,否则边界条件会有1个周期的不确定性。阈值8'd128是把ADC的量程中点当作触发点,具体数值应跟ADC的输入偏置配合调整,比如输入信号被偏置在128附近,那么128就是零点。
另外,wr_addr在触发瞬间被赋值为0,但wr_en信号是组合产生的,可能比wr_addr早一拍,同一时钟沿上addr已经归零但wr_en仍有效,这就是一个典型的竞争条件。实际工程中我会把wr_en改成寄存器,并且把它置为无效;上面代码是示意逻辑,真做工程时要在wr_en路径上加一拍,否则触发后RAM的0地址会被多写一拍数据。
4.3 VGA读取显示:行列坐标到RAM地址的转换关系
显示侧的任务是:当扫描到屏幕上第X列、第Y行时,找到RAM中对应的采样点,把该点的幅度转换为对应的Y坐标,再决定这一像素要不要点亮。假设RAM深度2048、显示横向使用640像素,那一个采样点对4列像素(640÷2048≈0.3,实际做法是取前512个采样点对应640列,按列号右移一位得到地址)。常见做法是每2个像素列对应1个采样点,这样水平方向正好显示1024个点,超出屏幕宽度的部分忽略。
确定“点亮哪个像素”的映射是另一个关键:采集的数据是8bit无符号数,0对应波形底部,255对应波形顶部;屏幕垂直方向有480行,需要把数据范围压缩到屏幕中部,留出上下边框。下面这段代码是显示映射的常见写法:
// VGA显示映射, 时钟为pixel_clk // 计算当前像素对应的RAM读地址 wire [9:0] ram_addr = h_cnt[9:1]; // 每2个像素读1次RAM, 取640点 // RAM读出的8bit数据转换成屏幕Y坐标: 数据范围0~255映射到屏幕Y=80~400 wire [8:0] wave_y = 400 - {1'b0, ram_data[7:0] >> 1}; // 幅度大则Y小, 符合屏幕坐标 // 当前像素位置是否落在波形线上: 行坐标与波形Y坐标相等时点亮 wire wave_on = (v_cnt == wave_y) && valid;这里有个值得注意的细节:wave_y的计算中使用了减法,而ram_data的高位在前,也就是说采样值越大,屏幕上的Y坐标越小,对应“屏幕上波形上移”。这是常规示波器的方向约定。如果你发现波形上下颠倒,就把减数与被减数交换位置,不用动其他逻辑。
4.4 滚动模式与触发模式的共存方案
很多第一次做示波器的人只在“滚动模式”下看到了波形,但两个信号频率接近时,波形会左右游走,无法稳定观察。稳定的关键是触发模式。实现时不能把滚动和触发做成两个独立模块,而应该用同一个写RAM逻辑,只是写地址的产生方式不同。滚动模式写地址一直递增,触发模式写地址在触发条件满足时清零并写满一次后暂停。
一个实用的中间做法是“预触发”:持续滚动写入RAM,当触发条件到达时,停止更新RAM并冻结当前内容,这样触发点其实位于RAM中间,画面左侧是触发前的波形,右侧是触发后的波形。预触发深度为总RAM深度的一半。这种做法能观察到触发点之前的信号形态,对定位故障非常有帮助。工程实现上它只是把“清零写地址”改成“写满后禁止写”,其他逻辑完全复用。
一个需要特别提醒的坑:RAM的读写端口虽然独立,但WRITE_FIRST、READ_FIRST、NO_CHANGE三种模式要选对。默认的WRITE_FIRST模式在同地址写时会把新数据同时输出到读端口,显示就会随机出现“亮点毛刺”。示波器场景应该配置为NO_CHANGE模式,或者让读写地址永远错开——我一般是把显示读取的分频因子做成略大于写侧的采样深度,从根源上不让两边访问同一地址区间。
5. FPGA示波器踩坑与排查:从黑屏到波形毛刺的5条血泪记录
5.1 黑屏,只有行场不同步:同步极性搞反了
现象:接上VGA显示器,屏幕提示“无信号”或满屏粗斜纹,偶尔能抖出一帧画面。
原因:640×480@60Hz的HS和VS都是负极性,即“低电平有效”。新手容易照抄别人的代码却忽略极性定义,或者直接把800×600的正极性参数搬过来用,显示器无法识别同步信号。
解决:在代码里检查hsync和vsync的默认电平。负极性时,无同步区域保持高电平,同步脉冲拉低;如果写成平时低电平、脉冲拉高,就把极性位取反。最快的验证方法是下载一个只输出纯色块(不依赖任何RAM数据)的测试工程,如果纯色画面正常,时序就没问题;此时再接上波形显示逻辑,黑屏问题一定在数据通路。
5.2 画面整体右移或左移:消隐区的参数配错了
现象:波形显示正常,但整幅波形不在屏幕中心,左侧空出一段黑边,右侧波形被截断一部分。
原因:把640×480的时序参数不完整地抄进代码——只写了可见区域和脉冲宽度,漏了前沿/后沿,或者数值抄错。
解决:用511行中的“行消隐总宽度=160”来倒推:行同步占96、前沿16、后沿48,如果屏幕偏右(即内容被挤到右边),说明前沿参数设大了,后沿参数设小了;偏左则反过来。调整时只改后沿数值,让“前沿+同步+后沿”总和保持160不变,画面即可回到中心。前端时间参数影响的是“一行内容从哪里开始和结束”,肉眼判断方向后微调这个值最直接。
5.3 波形在屏幕上有毛刺、有叠影:RAM的同步写读模式没配
现象:波形主轮廓正确,但上面叠加了一层随机的亮点或短线,看起来像第二道影子。用手按开发板时毛刺还会变。
原因:读写地址发生竞争。WRITE_FIRST模式下,写端口同一地址写入新数据时,读端口也看到新数据,此刻VGA正在读“新写进”的那一个点,但屏幕上对应的像素已经扫过了,于是出现错位亮点。同时,开发板内部逻辑电源纹波干扰了读端口的地址建立时间,表现为随机毛刺。
解决:把RAM配置成NO_CHANGE模式,或者让读写地址区间彻底分离。我是把写地址映射到RAM的前2048个单元,读地址永远走另一片地址区间,通过地址重映射错开。另一种思路是加大显示存储深度到4K,把写地址限制在偶地址、读地址限制在奇地址,两边永远不碰同一个物理单元。这个办法的代价是多耗一倍BRAM,但对显示稳定性的提升非常明显。
5.4 采样值全0或全满量程:ADC的共模输入范围被压死了
现象:波形只有一条直线,贴在屏幕底部(全0)或顶部(全满),信号源输入怎么调都不动。
原因:ADC的输入范围一般不支持负压,比如单端AD转换器的输入通常要求在0~Vref之间。但信号源的输出是双极性的,例如±1V的正弦波,如果不加偏置直接进ADC,负半周被钳位到0,正半周可能超过Vref被削顶。更隐蔽的情形是输入信号幅值很小,共模电压又不在ADC允许范围内,ADC把直流分量都读成了0。
解决:在前端加一级运算放大器,把双极性信号抬升到ADC量程中点。例如Vref=2V时,把输入信号偏置到1V的中点,再做±0.8V摆幅。运放用±5V供电,单电源供电的方案要选能“轨到轨”输出的型号。调试时先断开信号源,用万用表量ADC输入引脚的直流电压,确认在量程中点附近再接入信号。这条验证路径比看波形快得多。
5.5 波形本身正常但背景噪声大:电源与地的处理不干净
现象:没有输入信号时,屏幕上已经不是一条细线,而是一条宽约2~3格的粗带,时长久了还会缓慢上下漂移。
原因:VGA的模拟地、ADC的模拟地和FPGA的数字地在开发板上一般是共地的,采样时钟的开关噪声通过地通路径耦合进ADC输入,等效为示波器本底噪声。如果前端放大了100倍,这个噪声会放大得很可观。
解决:如果开发板是两层板,优先把采样时钟频率降低一档(例如从30MHz降到24MHz,带宽损失可以接受),同时把ADC模拟供电引脚上的去耦电容从0.1μF换成1μF加0.01μF并联,噪声改善立竿见影。如果板子上ADC和VGA接口在物理上离得很远,走线会形成一个大环路,此时可以用“一点接地”的方式,在ADC附近把模拟地单独分出一块,用0欧电阻或磁珠连接到数字地平面。
6. 让示波器真正能用:用1kHz方波做全链路校准与时基验证
做到前几章这一步,示波器能显示波特形了,但能不能拿它换掉一台成品示波器来做调试,就得看一件事:时基准不准,幅度标不标。任何一个测试工具,必须要有可追溯的校准流程。我的习惯是第一步先接标准的1kHz方波信号源,周期为1ms,如果时基档位设的是“每格1ms”,那么屏幕10格宽的显示区内应当恰好看到一个完整周期,左右对齐。如果差一点,就调整显示读取侧的分频系数。
示波器的时基本质上就是“水平方向每格代表多少时间”,它由两个因素决定:采样时钟频率和每格显示的采样点数。比如采样时钟30MHz,屏幕横向10格,一格显示512个采样点,那么一格时间就是512/30MHz≈17μs。校准公式是:一格时间 = 采样点数 / 采样时钟频率。如果你发现一格实际是17.5μs而示波器标称17μs,说明采样时钟不是30MHz而是29.8MHz——这时去检查PLL配置,而不是改代码。幅度校准同样重要:输入标准1Vpp方波,屏幕上波形峰峰之间占的格数乘以垂直灵敏度(比如每格0.2V)应该等于1V,如果不符,调整前端运放增益,不要靠软件补偿硬撑。
实际验证我还有一套更实用的做法:直接把开发板上某个GPIO引出,写一个分频逻辑输出一个占空比50%、频率精确等于1kHz的方波,把这个输出接到示波器的输入端自测。这样不用外部信号发生器也能完成90%的校准验证——因为那个GPIO方波来自FPGA同一个晶振,相位严格同步,不会产生两个独立时钟源之间的相对漂移。等自测通过,再接外部信号源做最终标定。
最后建议你一定要把这个“最小可用版本”先跑通,不要一上来就加光标测量、波形存储、FFT这些功能。示波器最难的部分永远不是“功能多”,而是“显示稳定、读数可信”。把基础校准做完,再考虑给项目加一个频率计数功能——最直接的做法是用先前那根1kHz自检方波做对照,数一帧内波形周期数,乘以帧率就得到输入信号的频率,精度足以判断电路“大概坏了没有”。这台设备的价值不在于对标几百块的成品,而在于你完全知道它内部每一个环节是怎么工作的,出问题手到擒来,这比产品本身值钱得多。希望这篇笔记能帮你把第一台FPGA示波器在实验桌上点亮,少走几条弯路。
本文还有配套的精品资源,点击获取