做一台属于自己的示波器加频谱仪,是我玩FPGA以来一直惦记的事。市面上成品示波器贵不说,能调的参数其实就那几样,我想在采集到波形的同时直接看看频谱,还想把触发逻辑改成自己想要的规则,这些需求在通用仪器上很难完全满足。用FPGA做这个项目的好处在于,采集、存储、触发、显示、FFT全都在一片芯片里按你的思路并行跑,数据从ADC出来到屏幕像素点之间的每一步都是可控的。这篇文章就围绕这套基于FPGA的简易示波器、频谱仪,讲清楚整体方案、采样链路设计、显示与FFT实现,以及我在调板子时踩过的那些坑。适合正在学FPGA、想挑战一个完整数字系统的同学参考,也适合对自制测量仪器感兴趣的硬件爱好者。
1. 项目整体设计与方案选型
1.1 为什么用FPGA而不是单片机或树莓派
做示波器,核心矛盾是“采集速度”和“实时处理能力”。普通单片机哪怕主频跑到几百MHz,ADC数据进来后还是要经过中断、DMA搬运、CPU算地址这些流程,每个周期能干的事有限,想实现高速连续采样加波形更新就很吃力。树莓派这类带系统的方案虽然显示方便、Python写起来舒服,但采样和波形绘制的实时性很难保证,而且外挂高速ADC后,Linux的中断延迟和调度抖动会让你在做触发定位时非常痛苦。
FPGA的思维完全不同。ADC送进来的数据流是连续的,FPGA里可以用一个始终拉高的采样状态机去锁存数据,不需要等谁“批准”才处理。采集、写缓存、查触发条件、刷屏幕这些事在硬件逻辑上是并行的,各干各的,谁也不堵谁。这也决定了它能在一个低成本的板子上实现20M采样率、双通道切换、实时FFT显示,而这套需求放在MCU方案上可能连波形刷新都跑不动。
所以这个项目的选型思路很清晰:采集和控制核心交给FPGA,显示交给一片TFT LCD,ADC根据预算和带宽需求选外置并行芯片,或者先用FPGA内置的模数转换模块把流程打通。这样既能学到底层信号链路的每个环节,又不会因为一开始就要焊高速差分信号而劝退新手。
1.2 系统模块划分与数据流走向
整机如果从功能上切,可以分成四块:前端信号调理、ADC采集与存储、触发与时基控制、显示与FFT处理。
数据流大概是这样走的:被测信号先经过BNC输入口进入前端板,经过阻抗匹配、衰减/放大、直流偏置调整后,变成ADC输入范围内比较“干净”的信号。ADC把模拟电压量化成数字码流,FPGA端收到这个码流后,先连续写入一段循环缓存(Ring Buffer),同时触发检测模块一直在判断当前数据是不是满足你设定的触发条件。一旦满足,就会在缓存里打个标记,让后续读取的波形数据以触发点为中心展开。
被冻结下来的时域波形数据,一部分直接送去LCD绘制波形,另一部分送进FFT运算模块,计算出幅度谱后,再以对数或线性坐标画在屏幕的另一半区域。整个系统就是围绕“一段数据,两个用途”来设计的,跑通这条链路,仪器基本就成了。
1.3 关键性能指标的取舍原则
很多朋友一上来就问“你的示波器带宽多少”,这其实是个误区。作为自制仪器,最该先定的是采样率,因为采样率决定你能看到的信号频率上限。按奈奎斯特定理,采样率至少是信号最高频率的两倍,实际工程上要留余量,至少三到五倍。我给的参考指标是:采样率20MSPS,配合前端模拟带宽约几MHz,测个音频信号、PWM波形、传感器输出、通信协议的时序,完全够用。
另一个关键指标是存储深度,也就是FPGA里BRAM能存多少个采样点。我用了两块16K深度的RAM,一块做采集缓存,一块做显示缓冲,采样率最高档时能连续记录约0.8ms的数据,这个长度足够观察大多数周期性信号了。FFT点数方面,选了256点和512点两档可切,频率分辨率和计算延迟之间取得平衡。
输入端量化位数,8bit是一个比较舒服的起点,兼顾了ADC成本和FPGA内部运算复杂度。8bit在屏幕上其实已经能看出明显的波形层次,加上简单的显示插值,视觉上不会太粗糙。
2. 信号调理与ADC采样链路细节
2.1 前端模拟电路的核心考虑
很多人把前端电路当成一个“把电压调到ADC范围内”的黑盒,其实问题远不止这个。示波器探头输入阻抗必须是高阻,通常是1MΩ并联一个小电容,否则你测电路节点时会把原信号拉偏,测出来的波形就是被探头负载扭曲过的。这个1MΩ输入电阻加上探头补偿电容,和同轴线缆的分布电容一起,会形成一个低通滤波,所以前端板的PCB走线要短,铺地要连续,尽量避免寄生电容过大。
我用的方案是电阻分压衰减加运放跟随:输入先经过一组10:1/1:1可切换的衰减电阻网络,再进一个高速运放做电压跟随,最后经过一个可变增益级配合软件里的垂直档位设置。实测下来,关键是衰减切换用的继电器或模拟开关要选低电荷注入的型号,否则切换档位时会在屏幕上看到明显的瞬态毛刺。
保护电路也别忘了加。ADC芯片很贵,被测信号万一接触到高压,一烧就是整片。我在输入端加了一对反向并联的二极管到电源轨,配合串接电阻,能把过压钳位在安全范围。自制的仪器可以不追求很高的精度,但可靠性一定要保证。
2.2 采样率与时基档位的软件联动
ADC采样率不能只有一个固定值。你可以要一个固定20MSPS的采样率,但那样看低频信号时,一屏只有几个周期,密密麻麻看不出细节。所以我在FPGA里做了一个采样时钟分频链,从20MHz基准往下分频,提供20M、10M、5M、2M、1M、500K、200K、100K、50K、20K、10K、5K、2K、1K等档位。
时基档位和采样率是联动的:同一屏显示固定的1024个采样点,采样率越高,屏幕覆盖的时间跨度越短,波形被“拉伸”得越开,等效于示波器的水平扫描变快。这比生成一个PWM加RC滤波去模拟模拟示波器的扫描电压要简单得多,而且完全数字可控。每档位的采样时钟在FPGA里用计数器分频产生,切换到新档位时,要确保时钟相位是干净的,我一般会在切档后先丢弃两三个采样点的数据,避免显示器上出现半个时钟周期的畸形波形。
2.3 触发模块的实现逻辑
没有触发的示波器,波形在屏幕上就是一坨乱滚的线。触发的作用是让每次扫描都从信号的同一个相位开始,这样周期信号看起来就是静止的。我是用纯数字触发的办法实现的:ADC的每一位数据和一个可配置的触发电平寄存器做比较,配合触发极性选择,当数据跨过阈值并且方向正确时,就给出一个触发脉冲。
这里有个容易忽略的坑:原始ADC数据上叠加的毛刺会让触发抖动,表现为屏幕上波形左右晃动。解决办法不是在触发路径上拼命做模拟滤波,而是在数字逻辑里做一个简单的去抖:触发脉冲必须在连续采样N个周期内都满足条件才认作有效,我取了N=3,实测能消除大部分毛刺引起的误触发。
触发位置也很讲究。如果一触发就把缓存冻结,看到的是触发点之后的波形。为了能观察触发前的信号,我把缓存做成了环形,一直在写,触发后并不立刻停止,而是等写指针绕过一定深度后再冻结,这样缓存里既有触发前的数据也有触发后的数据。用户可以通过按键调节“触发位置百分比”,选择在屏幕上看到多少触发前波形和多少触发后波形。
3. 波形显示与频谱分析实现
3.1 LCD显示双缓冲与波形绘制优化
显示屏我用了一片480×272的TFT,RGB并口驱动,像素时钟大约9MHz。直接往LCD扔像素是不行的,因为FPGA采集数据和LCD刷新需要同时进行,如果显示模块申请一块BRAM占用了采集模块的带宽,会导致采样丢点。所以我把显示做成独立的双缓冲结构:两块同样大小的RAM,一块在后台由FFT和采集进程写入,一块由LCD控制器循环读出刷屏,当前帧读完后两块的职责交换。
画波形的时候,我把1024个采样点映射到屏幕的水平方向,垂直方向根据垂直灵敏度换算成屏幕的y坐标。每个点画一条竖线把相邻采样点连起来,速度很快,不需要做太复杂的抗混叠。实测在20MSPS采样率下,波形刷新率能做到每秒40帧左右,肉眼看不出停顿。
显示的内容分三个区域:波形区占上半屏,频谱区占下半屏,最右侧一列竖排显示当前采样率、触发电压、垂直档位等参数。这些字符是我在FPGA里用一个小型字库生成的,点阵放在另一个ROM里,绘制时按行扫描取模。一开始嫌麻烦想省掉字符显示,后来发现调试时没参数反馈就像闭着眼开车,所以还是加了。
3.2 FFT的FPGA实现与窗函数选择
谱分析的核心是用FFT把一段时域数据变成频域幅度。简易仪器不需要做到极高分辨率,我实现了256点和512点两种FFT模式,默认使用512点。这里有一个原理性概念必须搞清楚:FFT输出的N个频点均匀分布在0到采样率之间,频率分辨率等于采样率除以N。比如20MHz采样率下做512点FFT,每个频点间隔约39kHz,也就是说两个频率相差小于39kHz的信号在频谱上是分不开的。想分辨更近的频率,唯一办法是增加FFT点数或降低采样率,这是做频谱仪时最需要记住的约束。
FPGA上写FFT如果从零开始写蝶形运算单元,工作量大还不容易调。更实际的做法是用FPGA厂商提供的FFT IP核,配置好点数、位宽、缩放策略后直接调用。数据流上,我先把采集缓存里的时域数据按顺序送入FFT核,等转换完成后从输出接口读回实部和虚部,再做一个幅度计算。幅度谱在画到屏幕前先做取对数处理,因为信号功率动态范围很大,线性显示的话小信号根本看不见。
窗函数在这里是绕不开的。直接用矩形窗(就是不加窗)截断一段信号再做FFT,会遇到频谱泄漏,也就是单一频率的信号会“漏”到旁边一堆频点上,看起来像一片花边。我实测用20kHz正弦波做测试,不加窗时主峰旁边有很明显的旁瓣;换成汉宁窗后,主峰更尖锐,旁瓣被压低了约40dB。代价是主瓣宽度稍微变宽,频率分辨率略降,但对这个项目来说完全值得。所以代码里默认用的就是汉宁窗,窗系数在初始化时算好存在ROM里,做FFT前逐点和采样数据相乘。
3.3 频谱显示的频段映射与峰值保持
把FFT结果画到LCD上,还需要做一次频段到横坐标的映射。屏幕上可用宽度约440像素,而FFT有512个频点,所以并不是一个点对应一个像素,而是每隔几个频点取最大值画一条竖线。这点和时域波形不一样,时域可以把每个采样点画成一条线,频域点数超过像素数时就必须做“峰值抽取”,否则会漏掉窄带信号。
另外我做了一个简易的峰值保持功能:每个频点单独记录一个“历史最大值”,如果当前帧的值低于历史值,则按历史值画一条灰色迹线;只有当前值超过历史值时,灰色迹线才更新成亮色。这有点像频谱仪的“余辉”效果,调试天线或者观察信号漂移时会非常有用,频率上有没有杂散的微弱分量一目了然。
4. 实操过程与核心模块代码思路
4.1 采集缓存与触发冻结的Verilog实现
整个项目里最核心的一段逻辑就是采集状态机。我用一个状态机控制环形缓冲区的工作状态:空闲、填充、等待触发、冻结、读取。环形缓冲区在FPGA里就是一块普通BRAM加两个指针,一个写指针持续累加,另一个是冻结指针,始终指向触发时刻对应的地址。
触发冻结构思是这样的:正常采集时写指针每写入一个采样点就加1,同时触发模块并行比较当前数据。一旦检测到触发且当前状态允许触发,就把触发指针的值记录下来,等于写指针当前值减去一个预设偏移量。当写指针再次绕到触发指针地址时,停止写入,这样缓冲区里就保留了从触发前一段时刻到触发后一段时刻的完整数据,读取时从触发指针位置开始顺序读出,送到显示和FFT两个下游模块。
这里的关键经验是写指针不能因为触发就立刻清零,否则环形缓冲就“断”了,读取端拿到的数据点不是连续的。之前我第一版代码就是触发后把写指针清0重写,结果波形右侧总有一段乱码,排查了很久才意识到环形缓冲的指针关系出错了。
// 环形缓冲写使能与触发冻结判断逻辑 always @(posedge clk) begin if (sample_valid) begin mem[wr_ptr] <= adc_data; wr_ptr <= wr_ptr + 1; if (trig_detected && (state == TRIG_ARMED)) begin trig_addr <= wr_ptr + PRE_TRIG_DEPTH; state <= TRIG_FREEZE; end end if ((state == TRIG_FREEZE) && (wr_ptr == trig_addr)) state <= FROZEN; // 冻结完成,可以安全读取 end4.2 读取端仲裁与FFT数据整形
读取端需要同时服务波形绘制和FFT两个模块,这里用了一个简单的双通道仲裁器:一帧时间的前半段把缓存数据依次送给LCD波形绘制模块,后半段把数据送到FFT预处理模块。由于两块下游模块都在自己的RAM里做缓冲,仲裁器切换不会导致画面撕裂。
FFT预处理模块做两件事:第一是做窗函数乘法,第二是把采样数据的无符号二进制格式转换成有符号数送给FFT核。如果你的ADC数据是无符号格式,但FFT核需要补码,中间这步换算漏了的话,频谱上会莫名其妙冒出一个很大的直流分量和镜像频率,我调试时花了很长时间才从逻辑分析仪上看出是格式问题,所以写代码时千万别嫌麻烦,做好位宽和符号的收敛检查。
每次做完FFT,输出的频点顺序是0到N-1对应0到采样率,而不是我们惯常看到的以奈奎斯特频率为轴对称的排列。实际画谱线时,直接把0到N-1画在横坐标上就行,不用交换左右两半,因为0频直流在屏幕最左侧,高频在右边,习惯后也读得很顺。
4.3 按键交互与参数调节的状态机
整机配了五个按键:上、下、左、右、确认。按键输入要做消抖,我用一个10ms定时轮询加状态跳变检测,比单纯计数器延时可靠。参数调节分两个层级:短按上下键调节当前选中参数,长按确认键在“垂直档位”“水平时基”“触发电平”“触发模式”之间循环切换当前选中项。
参数寄存器的数值变化要实时生效,比如触发电平寄存器变了,触发比较器立刻用新阈值比较;时基分频系数变了,采样时钟分频器重新加载并复位时钟相位。这里面稍微麻烦的是垂直档位和FFT窗函数的切换:垂直档位切换时要重新换算ADC码到屏幕坐标的斜率,而窗函数切换如果不做平滑过渡,下一帧FFT结果会有短暂跳变,好在视觉上就是闪了一下,不需要特殊处理。
5. 常见问题与调试实录
5.1 波形显示错乱与环形缓冲指针问题
我这台仪器在调试过程中,波形画着画着就出现右侧四分之一的区域是乱的,和左侧波形根本不连续。用逻辑分析仪抓了读地址和读数据对比,发现读指针绕回头部后,首尾的数据本来应该连续,但写指针在环形缓冲里已经多转了一圈,把还没被读走的旧数据覆盖了。问题其实出在“冻结”逻辑上:独立判断触发条件时没有同步到写状态机的时序,导致冻结时刻到了之后还在写。
解决方式是让触发标志和写指针增量同步,并且在检测到冻结条件后额外加一个空时钟周期的气泡延迟,确保同一时刻只允许一个条件更新指针。另外,读取端在读一个完整帧之前,先等冻结状态稳定几个周期,再加一个“读取开始”握手信号,这样就不会读了一半被下一帧数据干扰。
5.2 频谱乱跳与数据窗口对齐问题
频谱第一次弄出来时,屏幕上谱线不断乱跳,本来一个稳定的1kHz正弦波,它的主峰一会儿在左边一会儿在右边。后来发现根因不在FFT本身,而在送给FFT的数据窗口和采集缓存的数据窗口没有对齐:采集缓存里一个帧的长度是1024点,但我FFT只取了前512点,而写指针还在持续累加,所以两次FFT用的数据段之间有一个滑动的偏移,等效于在时域上随机滑动截窗,频谱自然就飘了。
修复办法是给FFT单独开一个输入FIFO,每次从采集缓存读数据时,从帧起始位置连续读512点进去,读完才允许下一帧重新开始,不追着写指针跑。改完后再看频谱,主峰稳稳地钉在一个频点上,幅度波动也小了很多。
5.3 模拟噪声与数字地分割的调试心得
波形上如果出现高频毛刺,肉眼看起来像是正弦波上面叠了一层雪花,大概率不是信号源的问题,而是你自己的板子数字部分干扰了模拟部分。FPGA引脚翻转噪声和LCD像素时钟的谐波都会通过地平面耦合到模拟输入。我做了几项改动,效果明显:ADC的模拟电源和FPGA的IO电源分别用独立的LDO供电,地平面在ADC芯片下方做局部分割,模拟地和数字地在ADC底部单点连接。LCD数据线在PCB上尽量远离模拟输入走线,中间隔一条地线。
这部分的经验是:数字地和模拟地不是“完全分开就对了”,而是在器件密度不高时做成一个完整的地平面,靠布局拉开距离反而更可靠。分割得乱七八糟的地平面会造成回流路径过长,反而引入更大噪声。如果你是第一次画这种带ADC的板子,建议先查好ADC芯片数据手册里关于地平面处理的建议,多数芯片都写得非常明确。
5.4 常见问题排查速查表
| 现象 | 优先排查点 | 通常原因 |
|---|---|---|
| 波形静止但位置偏 | 触发位置设置 | 触发指针偏移量设置不对 |
| 波形左右晃动 | 触发去抖深度 | 毛刺导致误触发,增加触发确认次数 |
| 屏幕出现撕裂横线 | 双缓冲切换时序 | 显示读取时缓冲区被写端切走了 |
| 频谱主峰明显变宽 | 窗函数与FFT点数匹配 | 窗函数数据位宽截断或窗长不匹配 |
| 高频信号出现“假峰” | 采样率和信号频率关系 | 采样率低于信号频率,发生混叠 |
| 没有波形只有一条直线 | ADC数据有效位 | 检查采样时钟是否送达ADC的时钟引脚 |
| 参数按键无响应 | 消抖逻辑 | 确认是电平触发还是边沿触发,按键电平极性可能弄反 |
6. 从这台仪器上获得的经验与下一步扩展
整个项目做下来,我最大的体会是:仪器类项目不是“功能堆起来就完事”的,真正耗时间的是让各个功能在实时性上互相协作。FPGA的并行能力是优势,但并行也意味着模块之间更容易出现时序竞争,所以从一开始就要把每个模块的握手信号设计清楚。
下一步我准备做两个方向的扩展:一个是把采样率往上提到50MSPS以上,这需要换用LVDS接口的高速ADC,同时FFT点数也提到2048点,用于看更宽频段的频谱;另一个是加一个USB接口把原始采样数据上传到电脑,让上位机做更精细的分析,FPGA侧只要把USB IP核和采集缓存的读取仲裁加好就行,框架和现在完全兼容。
如果你也想动手做类似的东西,我建议别在选型和参数上纠结太久,先从最简配置跑通“采集—缓存—显示”这条主链路,看到自己通过探头采到的波形亮在屏幕上那一刻,后面所有的优化都会有明确动力。等主链路成熟了,再加上FFT和触发扩展,每加一个功能就做一次整体回归测试,这样踩坑的代价会小很多。