做嵌入式开发这些年,隔一阵子就会看到有人提起“示波器代码”这个词。一问基本都是同一个想法:想用手里那块单片机,自己攒一台能看波形的仪器。有的是为了学习采样原理,有的是实验室缺一台便携波形监视设备,更多的纯粹是觉得“用代码在屏幕上画出实时波形”这件事本身很酷。这个方向确实值得聊,因为它的知识密度非常高,几乎涵盖了嵌入式开发的全部基本功:ADC采样、DMA搬运、定时器触发、显示驱动、状态机交互。哪怕你最后做出来的只是一个功能简单的玩具,整个过程的收获也远超过“能测波形”这件事本身。
这篇文章我就围绕“示波器代码”这个主题,从方案选型、采样与触发原理、代码模块实现到常见问题排查,把整个项目拆开讲一遍。重点放在思路和坑上,代码给出核心片段和关键逻辑,不会逐行贴完整工程,因为不同芯片的寄存器操作差异太大,照搬没有意义,理解机制才是关键。
1. 项目设计与方案选型
1.1 先搞清楚你要做的到底是哪种示波器
很多人一上来就想做“示波器”,但这个词的范围太大了。显示波形只是表象,背后涉及采样率、带宽、触发、存储深度、垂直分辨率这些硬件指标。你不可能用几十块钱的单片机做出一台100MHz带宽的数字示波器,物理限制摆在那里。所以第一步是明确需求边界。
通常“示波器代码”可以分成三类:
- 低速信号波形显示仪:采集音频信号、传感器输出、PWM波形等低频信号,采样率几百kSPS就够,MCU内置ADC完全能胜任。
- 逻辑分析仪:只关心数字信号的高低电平变化,不关心模拟电压精度,对采样率要求高但对分辨率要求低。
- 带前端调理的简易示波器:加运放做信号缩放、偏置调节,加比较器做硬件触发,接近商品示波器的架构,但精度和带宽有限。
我见过最多的需求是第三类,但真正做成的往往是第一类。原因是很多人低估了模拟前端的难度。示波器前端要处理的是幅值从毫伏到几十伏、频率从直流到几百kHz不等的宽动态范围信号,单靠MCU的ADC直接采集是非常局限的。信号太大烧引脚,信号太小分辨率不够,偏置不对波形削顶。这些问题不是代码能解决的,所以在选型之前,先确定你到底要测什么信号,这决定了你的系统架构和器件选型。
1.2 主控芯片与外设选型思路
选型原则很简单:ADC采样率够用、DMA通道够用、RAM能存下至少一帧波形数据、屏幕接口不冲突。我用过的方案里,某款Cortex-M3内核的入门级单片机就非常合适,72MHz主频,12位ADC,最高采样率1MSPS,RAM 20KB,带SPI接口驱动小尺寸TFT屏幕,成本也不高,非常适合这类项目。这个配置大概能覆盖到几十kHz的信号,性价比很均衡。
如果你测的信号频率更高,有两个方向可以升级:一是选带高速ADC或者更高主频的芯片,二是外置高速ADC芯片,比如常见的并行接口ADC,采样率能到几十MSPS。但外置ADC带来的问题马上就会暴露:数据量暴涨,单片机处理和存储跟不上,显示系统也跟不上。到了这个阶段,往往就需要FPGA介入了。所以说,用普通单片机做示波器,物理上限就在几十kHz到一两百kHz的模拟带宽范围,新入门的朋友不要在这个问题上钻牛角尖,先把手上的方案做透比什么都强。
1.3 整体系统架构划分
这套系统从信号到显示,要经过完整的数据链路,我习惯把整个架构拆成几个子系统来看待:
- 信号采集前端:如果信号范围超出ADC输入范围,需要分压、放大、电平搬移。这部分是模拟电路,代码管不到,但ADC的参考电压和采样保持时间配置会直接影响采集精度。
- 采样与存储系统:ADC在定时器触发下以固定间隔采样,结果通过DMA直接搬进内存,不占用CPU时间。这一步是整个示波器的核心机制,决定了你能否做到连续采样。
- 触发与同步系统:波形要稳定显示,必须让每次扫描的起点都对应信号的同一个相位点,否则屏幕上就是一片乱跑的波形。最简单的触发方式是软件阈值触发,进阶一点用芯片内部的模拟比较器做硬件触发。
- 显示系统:把内存里的原始采样点映射到屏幕坐标,绘制波形网格和文字信息,处理按键交互和菜单状态。
分清楚这四块,整个项目的思路就清晰了,后面每一个模块出问题都能快速定位。
2. 采样与触发机制的原理拆解
2.1 采样率和带宽到底有什么讲究
示波器最核心的指标就是采样率,它决定了你最高能测多快的信号。根据奈奎斯特定理,采样率至少要是信号最高频率的两倍以上才能不产生混叠。但实际应用中两倍远远不够,因为你会关心波形的形状而不仅仅是频率,方波的上升沿、正弦波的失真细节都需要更多的采样点来还原。工程上合理的比例是5到10倍,比如你测10kHz的正弦波,采样率至少50kSPS到100kSPS,屏幕上每个周期才能有5到10个点,连线画出来才有波形该有的样子。
实际代码里,采样率往往不是随意设置的,而是配套固定的时基档位使用。比如你设定的时基是1ms/格,屏幕水平方向10格,一屏就是10ms的时间跨度。如果一帧需要500个采样点,那么每两个采样点之间的间隔就是20us,采样率就是50kSPS。把这些参数做成表格放在代码里,切档位时直接查表,不用每次重新计算,逻辑清晰也方便调试。
2.2 触发机制:波形稳定的关键
按固定间隔反复采样并刷新显示,你会在屏幕上看到什么?波形在横向上不断滚动或乱跳。因为每次采样的起始时刻和被测信号的相位没有任何关系,这一帧的起点可能落在波形的上升沿,下一帧就可能落在下降沿,人眼看到的自然就是乱七八糟的叠加。
解决这个问题的机制叫触发。它的本质是:等待信号满足某个条件(比如电压上升穿过1.5V),找到这个时刻之后才开始采集一帧数据。这样每一帧的起点都对应信号的同一个位置,波形在屏幕上就稳定下来了。
软件实现触发很简单但需要技巧。因为ADC数据一直在通过DMA往缓冲区里写,触发判断必须赶上数据产生的速度。通常的做法是让DMA循环写入一个环形缓冲区,同时CPU不断检查最新数据点是否满足触发条件。一旦满足,就记录当前位置,继续采集半个缓冲区深度之后停止,然后把环形缓冲区的数据按触发点重新排列,生成一帧完整的、起点对齐的波形数据。这里的核心细节是:触发点不能强行定义为一帧的第一个点,而是要给触发点前面的数据留出空间,因为触发时刻之前的信号状态也很重要,商品示波器管这个叫预触发。
2.3 存储深度与显示映射的计算
存储深度指的是示波器一帧最多能存多少个采样点。MCU内存有限,所以这个值往往不是固定的,而是和当前时基档位关联。例如慢时基档位下,一屏对应的时间跨度大,采样间隔可以放宽,存储深度可以小一点;快时基档位下,为了让波形细节更丰富,采样率要拉高,存储深度也要相应增加。协调好这两者的关系,既能保证当前档位下波形完整,又不会把内存撑爆。
内存够的前提下,我一般倾向于把一帧的采样点数设置成屏幕水平像素数的两倍以上。比如屏幕宽240像素,一个像素放一个采样点,波形锯齿感会很明显,因为相邻像素点的电压跳变没有任何过渡。如果一帧有512个采样点,映射到240像素上,平均每两个采样点映射到一个像素,绘制时可以做简单的最小值/最大值保持,把这两点之间的波形细节压缩保留,波形明显更平滑,也更接近真实信号形态。
3. 核心代码模块实现解析
3.1 ADC采样与DMA双缓冲的实现要点
这一部分代码量不大,但是出问题最多的地方。我见过太多人把ADC采样写在中断回调函数里,每个采样点进一次中断,CPU被频繁打断,采样率根本上不去,还顺手把其他任务卡死了。正确思路是用定时器触发ADC转换,DMA自动搬运结果,整个过程CPU全程不参与。
我是这么操作的:启用定时器的更新事件作为ADC的外部触发源,配置ADC为扫描模式,开启DMA循环模式,数据目标是内存里的双缓冲区。所谓双缓冲,就是把内存分成AB两个区,DMA先填满A区再填满B区,交替进行。每当一个区被填满,就触发一次DMA传输完成中断,CPU在中断里处理这一区数据,而DMA同时已经在往另一区写入新数据,互不干扰。这样做的核心价值是:数据采集不中断,CPU又有充足时间处理上一帧数据。
有个细节容易忽略,ADC每次转换的启动时间要留够。如果定时器触发间隔太短,ADC还没完成上一次采样保持和转换,下一次触发就到了,数据会错乱。这个参数在代码里体现为 ADC 采样时间配置,通常建议设置为芯片允许的最大值,特别是采集高阻抗信号源时,长采样时间能显著改善精度。
3.2 触发判断代码的思路
触发判断我建议放在软件层做,先用最简单的阈值触发跑通整个流程,再去优化硬件触发。代码逻辑如下:
uint8_t check_trigger(uint16_t *buffer, uint32_t len, uint16_t level, uint8_t edge) { // edge = 0 上升沿触发,level为触发电平 // 遍历缓冲区,查找连续两个点跨越触发电平 for (uint32_t i = 1; i < len; i++) { uint16_t prev = buffer[i - 1]; uint16_t cur = buffer[i]; if (edge == 0 && prev < level && cur >= level) { return i; // 找到触发点 } if (edge == 1 && prev > level && cur <= level) { return i; } } return 0xFF; // 未触发 }这个函数返回触发点在缓冲区中的索引。拿到索引之后,把它作为波形的起点,截取从触发点到缓冲区末尾的数据,再加上预触发数据(缓冲区开头到触发点之前的部分),合成一帧完整波形。这里有一个我踩过坑的经验:触发电平判断不能只看单点,因为实际信号带有噪声,在目标电平附近会反复穿越,触发点会抖动,反映到屏幕上就是波形左右晃。解决办法是加迟滞,代码里体现在比较阈值时留出一个窗口,比如上升沿触发电平设为1.5V,但只有信号从低于1.3V的位置上升到超过1.5V时才触发,把噪声影响压到最低。
软件触发受限于单片机处理速度和缓冲区数据更新的实时性,触发精度不会很高,但对于几百kHz以下的信号是完全够用的。做到位之后波形稳定度可以媲美入门级商品示波器的显示效果。
3.3 波形绘制与屏幕刷新
数据处理好之后,下一步就是画波形。这一部分想写出不容易闪屏的效果,也要花点心思。
最笨的办法是清屏、画波形、再清屏、再画波形,这种方式直接用肉眼就能看到屏幕闪烁。正确的方式是把显示内容先画在一个内存缓冲区里,全部绘制完成之后一次性刷新到屏幕。很多彩色TFT屏模块本身内置了显存控制器,可以指定一个显示区域批量写入像素数据,这正好用来做局部刷新。我把整个屏幕分成波形区域和菜单区域,每次只刷新波形区域,菜单区域的文字在参数变化时才更新,刷新速度明显改善。
波形绘制方式上有两种选择:点阵方式每个采样点画一个像素点,速度快但没有连线效果,波形看起来是离散的;连线方式在相邻采样点之间画线段,需要调用画线函数,计算量增大但视觉效果自然得多。我推荐用连线方式,配合前面说的“双采样点映射到单像素”的最小最大值保持,波形既平滑又能保留细节。
另外一个很关键的显示细节是网格。示波器屏幕上的网格不仅仅是装饰,它直接对应电压和时间的刻度值,读数全靠它。我用代码画网格线时,刻意让网格线和波形在亮度上区分开,网格线画成灰色,波形画成亮绿色,对比更清晰,长时间观察也不累眼睛。
4. 常见问题排查与实用技巧
4.1 波形抖动、乱跑或者不稳定
这是做示波器最常遇到的现象,原因基本都是触发部分没做好。先检查触发逻辑是否执行,用串口把触发点和缓冲区数据的开头打印出来,观察触发点索引是否每次都不一样。如果索引值跳变但信号本身是稳定的周期信号,那说明触发电平或迟滞窗口设置有问题。触发点太靠近缓冲区边缘时,预触发的数据量不足,波形会显示不全;触发点频繁找不到时,波形会一直不刷新。这里可以加一个超时机制,若长时间未触发则强制扫描一次,保证屏幕至少能看到当前信号,不会黑到像死机一样。
还有一个我调试时反复遇到的坑:DMA缓冲区的数据是不断更新的,但触发判断读取数据时可能正好读到一半,导致取到的触发索引失效。解决办法是在DMA半满和全满中断里加一个忙标志,触发判断和波形合成逻辑只在忙标志清空时执行,保证每一帧数据的一致性。
4.2 波形有毛刺、噪声特别大
噪声来源主要有三种:电源纹波、信号源本身太脏、ADC采样保持时间设置太短。优先检查供电,MCU的模拟电源和数字电源要分开走线,ADC的参考电压引脚附近要加滤波电容,这些对采集精度的改善立竿见影。代码层面可以开启ADC的硬件过采样功能,把多次采样求平均,等效提高分辨率,代价是降低有效采样率。软件上可以加一个简单的滑动平均滤波算法,但这个要慎用,因为滤波会让波形边缘变缓,看起来不真实。
另外ADC的输入引脚阻抗匹配也很关键。如果信号源输出阻抗很高,ADC采样保持电容充电不充分,测量值会偏低且跳动。这种情况先在输入端加一个电压跟随器或者在代码里延长采样时间,都能改善精度。对于入门级的示波器项目来说,处理好电源和采样时间这两个因素,波形质量能上一个台阶。
4.3 屏幕刷新卡顿、波形延迟严重
波形刷新速度受限于两个瓶颈:数据采集时间和屏幕绘制时间。如果时基档位比较慢,采样一帧数据本身就好几十毫秒,刷新速度自然上不去。如果采集很快但屏幕绘制慢,那就优化绘制逻辑。一个非常有效的优化是只更新变化的部分,波形区域某一行像素没有明显变化时,不重复写入数据,这在低频信号场景下能减少大量的刷写工作量。
我实际测量到的数据显示,在240x320分辨率的TFT屏上,全区域刷新一帧彩色图像大约需要几十毫秒,这对实时波形显示来说是可以接受的,但要保证CPU不在刷新过程中被其他中断长时间打断。把屏幕刷新放在主循环里,把ADC采样和DMA中断的优先级设高,这样波形绘制过程再慢也不会丢采样数据。
下面是几个常见问题以及排查手段的整理:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 波形上下跳动 | 触发电平设置不当或地线接触不良 | 检查探头接地,调触发电平 |
| 波形左右漂移 | 触发点不稳定,信号噪声偏大 | 增加迟滞窗口,检查信号源 |
| 波形削顶 | 输入信号超出ADC量程 | 调整前端增益,修改参考电压 |
| 屏幕闪烁 | 全屏刷新伴随清屏操作 | 改用局部刷新,画到内存缓冲再上屏 |
| 采样值普遍偏小 | ADC采样时间不足,输入阻抗过高 | 延长采样时间,加电压跟随器 |
| 波形明显失真 | 采样率太低,不满足奈奎斯特定理 | 提高采样率,降低时基档位 |
4.4 从硬件上排除干扰的经验
我在调这个系统时发现一个有意思的现象:明明程序逻辑完全正确,但波形在屏幕上的显示就是不稳定,用万用表量各路电压也没发现异常。排查到最后发现是杜邦线连接过长、布局混乱导致的串扰问题。示波器项目对模拟信号的采集链路非常敏感,建议尽量用短导线连接输入信号和ADC引脚,模拟地与数字地单点相连,触发信号和采样信号不要平行走线。这虽然是硬件层面的问题,但给“示波器代码”调试带来的阻碍远超过代码本身,值得单独提醒。
5. 扩展方向与个人经验小结
5.1 从“能显示波形”到“能测波形”
一次完整的示波器项目做到波形稳定显示,已经算是一个阶段性的成果。但如果想让它真正变成一个实用的测量工具,还要往几个方向做深度扩展。测量功能方面,可以加频率测量、峰峰值计算和电压读数显示,这些在采集到一帧数据之后做数值分析就能实现,运算量不大但实用性非常强。存储方面,可以加波形冻结功能,按下按钮之后停止刷新,保留当前帧用于仔细观察细节。再进一步,可以加FFT频谱分析,把时域波形变换到频域显示,这个功能会大幅拓宽这个工具的使用场景,但要注意MCU处理FFT的性能开销,点数不宜太大,512点已经能用。
这些功能每加一个,都会让你对信号处理的理解更深一层,而且都在原有代码框架上迭代,不会推翻重来。我就见过一个朋友,本来只是做一台简易示波器,做着做着把频率计、电压表、信号发生器全部融合进去了,最后做成了一个多功能仪器台,整个过程中他对定时器、DMA、ADC、中断优先级的理解已经远超同期水平。
5.2 调试示波器代码的三条心得
调试这类代码和调普通业务逻辑完全不同,因为数据是持续流动的,无法像单步调试那样追踪。我自己的经验是:先把数据链路打通,再优化显示效果。刚上电时不要急着看屏幕波形,先用串口把ADC采样值打印到电脑上,检查数据是否连续、幅值是否合理,因为串口工具查看数据比屏幕直观得多。触发逻辑单独测试,用信号发生器给一个稳定的方波或正弦波,观察触发点索引是否稳定,这一步是后续一切绘制优化的基础。屏幕显示单独与数据采集解耦,先用内置的测试数据生成波形,确认绘图和刷新逻辑没有BUG之后再把真实采样数据接入,这样隔离调试,能快速定位问题所在。
最后分享一个我常用的调试技巧:在采集缓冲区中人为注入一个固定幅值的脉冲信号作为测试码,验证整个数据链路的完整性。比如每1024个点插入一个满幅度的尖峰,如果屏幕上能稳定看到这个尖峰,说明DMA搬移、触发定位和波形绘制整条链路都是通的,剩下的问题就只出现在信号源头。这种方法排查问题定位精准,能省非常多调试时间。
这个项目看起来很“小”,但坑全部踩一遍之后你会发现,自己已经把嵌入式系统里最难啃的那几块硬骨头都摸了一遍。之后不管你是转去做数据采集、电机控制还是仪器仪表,这些经验都会成为你手里面非常重要的底子。