嵌入式手写示波器实践:ADC采样、DMA搬运、波形绘制与测量全解析

📅 发布时间:2026/10/20 8:53:31
嵌入式手写示波器实践:ADC采样、DMA搬运、波形绘制与测量全解析
从零手写示波器显示代码采样、触发、绘波与测量的一条龙实现经验在嵌入式调试时遇到串口波形异常、电机驱动毛刺、传感器输出噪声第一反应往往是摸一台台式示波器。但很多时候手边没有或者信号本身只是几伏电压、几十千赫兹一台带LCD屏幕的开发板就能顶一阵子。所谓“示波器代码”简单说就是让MCU完成模拟信号采样、处理并绘制到屏幕上的一套程序它覆盖了ADC配置、DMA搬运、触发判断、波形坐标映射、LCD绘图、参数测量这些环节。这篇文章的内容我基于一个实际做过的DIY示波器项目整理而来——硬件很简单一块带屏幕的开发板加几颗电阻做前端衰减代码部分大约两千行左右。我尽量从整体设计、关键代码逻辑、踩坑记录几个角度把它讲透希望对准备做类似东西的人有帮助。这里先说明一下适用人群如果你手头有一个带LCD的MCU开发板想用它实现一个简单的双通道示波器或者写上位机波形显示程序或者纯粹想知道内部到底怎么完成“画波形”这件事这篇文章都可以参考。文中代码就按功能模块拆开来说并不要求你一次全看懂可以跟着章节挑自己需要的部分查。1. 手写一套示波器代码前先想清楚这三层问题动手写代码之前最好先拆清楚这个项目到底包含哪几层事情。我一开始直接跳到ADC采样写了两天发现全乱套后来重新梳理才理顺。所谓示波器代码不是单纯“读ADC然后画点”它至少要覆盖三个层次信号采集层负责控制采样率、触发时机、数据缓冲波形处理层负责把采样值换算成屏幕坐标、做必要的插值和缩放人机交互层负责处理时基调节、垂直档位调节、暂停/运行、测量结果显示。这三层的先后顺序最好不要反过来。设计上还要考虑一套取舍关系采样深度与显示刷新率之间的平衡硬件触发与软件触发的分工波形的实时性与ARM内核中断占用之间的冲突。以一个常见的MCU主频168MHz、自带12位ADC、带有1MB左右的SRAM为例如果只用单通道最高采样率能到2MSPS左右普通场景完全够用。但如果要显示几百毫秒长度的波形直接连续采样再一次性绘制并不现实更合理的做法是采用“分段采样、滚动输出”模式或者把屏幕宽度对应的采样点数量限制在几百个点再配合平均值、峰值检波等方式压缩数据。实际项目中我把目标锁定为以下几项指标输入电压范围 -5V到 5V通过外部电阻分压实现最高实时采样率1MSPS屏幕分辨率320x240显示时基范围从1us/div到1s/div垂直灵敏度从20mV/div到5V/div。这些指标跟主流台式的差距很大但对于看PWM波形、串口时序、传感器输出、音频信号包络这类任务绰绰有余。另外一个值得提前想清楚的问题是外围硬件。ADC输入前端最好要有电压钳位保护因为MCU的ADC引脚最多承受3.3V或5V采样电阻分压网络要用精密电阻温度漂移影响显示误差。如果输入带直流偏置还需要隔直电容测量交流小信号时最好加一级运放跟随缓冲。这些硬件细节直接影响代码里面“电压档位标定”和“零点校准”这两个模块的精度必须要放在一起讲。2. 采样侧代码的骨架ADC定时触发、DMA双缓冲与触发判定2.1 采样时钟链路的搭建思路示波器的本质是“定时拍照”它要求采样时刻的间隔严格均匀否则显示出来的波形频率和相位全是乱的。靠主循环里翻转引脚再延时来取样绝对不可行。正确思路是用定时器产生触发事件每次触发启动一次ADC转换转换完成标志位或DMA传输完成中断负责搬走数据。我用的方案是定时器直接映射到ADC的外部触发输入例如某个定时器的更新事件作为ADC的启动信号。假设定时器时钟108MHz需要得到1MSPS的采样率则定时器分频后更新频率设为1MHz。也就是定时器周期寄存器设为107即108MHz / (1071)所以预分频比和周期计数之间配合起来正好是1MHz。如果要把采样率改成500kSPS周期寄存器改成215即可那么108MHz / (2151) 500kHz。时钟链路搭建完ADC配置要选择注入组还是规则组。规则组通常就够了它的优先级低于注入组但支持DMA请求。配置顺序一般是ADC时钟分频设置为PCLK2的2分频或4分频采样时间设置为短采样比如1.5个周期或3.75个周期这样单次ADC转换时间才能跟得上1MSPS的触发频率。注意处理这类定时器触发ADC的场景关键词是“硬件链路同步”不要让CPU逐一干预每一次转换启动。一旦干预CPU中断延迟抖动就会反映成波形水平方向的抖动。正确做法是让DMA把连续转换结果自动搬到内存缓冲区转换过程不需要逐点干预。2.2 DMA双缓冲避免采集与绘制互相踩踏很多人第一次写这代码时是这么干的ADC转换完一整个缓冲区关中断把数据从缓冲A复制到显示缓冲再重新开启采集来回切换。这样做的后果是在复制期间有大量信号被丢掉波形不连续尤其观察低频慢信号时很明显。而且拷贝数据会让CPU长时间占住总线屏幕刷新也会跟着卡顿。HAL库或标准库都支持DMA双缓冲模式也就是内存里有两个缓冲区A和B。DMA先往A里写写满了自动切到B继续写同时可以产生“传输过半”和“传输完成”两个中断。代码里可以让过半中断表示“A已填满”完成中断表示“B已填满”。主循环在收到“A已填满”标志后立刻分析A缓冲区数据此时DMA仍然在写B互不干扰。分析完A以后下一个周期A又会被重新填充在这个时间差里完成数据处理和屏幕刷新就不需要人为停采。缓冲区大小要结合显示宽度和时基档位来选择。我的做法是单个缓冲区设成1024个采样点双缓冲一共2048点。在320x240屏幕上横轴只有320个像素按道理单缓冲512个点就够了但触发会消耗掉一部分点前置的预触发数据也需要一点余量1024点能给我较大的灵活度。ADC采样精度12位也就是0~4095的数字量用无符号短整型存一个点就够了一个缓冲区1024个点也就是2KB左右两个缓冲区占用约4KB内存接近我目标平台可用内存的上限附近但可以接受。采样数据的类型这里有个小心思如果用8位MCU内存可能特别紧张可以把ADC配置成8位精度缓冲区点数照样1024内存能少一半代价是垂直分辨率变差看细节波形时会比较吃力。如果你的主控是32位、内存超过20KB直接上16位采样值省心很多。2.3 触发判定代码是硬件触发器还是纯软件触发触发是示波器最难做对的部分之一。按我的实践如果只用软件触发实现方式是主循环里对缓冲区数据做边沿检测找到满足条件的采样点再以它为零时刻向前取预触发点向后取后触发点拼出一帧波形数据。这个逻辑在1MSPS时是可行的但缺点也很明显采样必须连续不停否则会漏掉边沿而且主循环要比较快地扫描缓冲区的每个点。硬件触发器通常用比较器或MCU内置的模拟看门狗。有一些MCU的ADC模块带有模拟看门狗可以设置阈值电压当采样值高于或低于阈值时触发中断。硬件触发的优势在于反应快、不占CPU只要模拟电压越过阈值线不管CPU在刷屏还是算测量值都会立刻记录当前时间点适合捕捉单次异常。在我的代码里做了个折中正常连续滚动显示用软件触发遇到需要捕捉单次异常事件时切换成模拟看门狗中断触发。触发条件可以配置为上升沿、下降沿或任意沿触发电平通过代码换算成ADC码与阈值寄存器比较。比较代码示意如下uint16_t triggerLevel (uint16_t)(voltageToCode(userTriggerVolt)); if (triggerMode TRIGGER_RISING) { for (int i 1; i bufLen; i) { if (buf[i-1] triggerLevel buf[i] triggerLevel) { triggerIndex i; break; } } } else if (triggerMode TRIGGER_FALLING) { for (int i 1; i bufLen; i) { if (buf[i-1] triggerLevel buf[i] triggerLevel) { triggerIndex i; break; } } }用软件检测边沿时要注意噪声问题。实际信号往往带高频毛刺本来是一个干净上升沿由于叠加了噪声会在一段区域内反复穿越阈值线导致触发点不稳定波形左右晃动。解决办法是加一个小的滞回窗口即电压必须从低于“阈值-滞回”变成高于“阈值滞回”才算可靠沿。滞回量一般取满量程的1%~2%换算成12位ADC大约41~82个码。多加这一条显示稳定性提升非常明显。2.4 时基档位与采样率的自动换算示波器面板上的“时基”意思是横轴每格代表多长时间。比如1ms/div就意味着屏幕上10个格子总共代表10ms。我的屏幕横向320像素划分为10格那么每格32像素。如果设置1ms/div则一帧波形要覆盖10ms每像素对应31.25us。为了显示得密实采样率至少应该是每像素采样点数的2倍以上。我通常让每像素采4到8个点这样既能保证波形细节又不至于刷新太慢。换算逻辑可以这么写uint32_t sampleRateHz; uint32_t timePerDivUs 1000; /* 1ms/div 为例 */ uint32_t totalTimeUs timePerDivUs * 10; /* 10格 */ uint32_t samplesPerPixel 4; uint32_t totalSamples SCREEN_WIDTH * samplesPerPixel; /* 1280点 */ sampleRateHz (uint32_t)((uint64_t)totalSamples * 1000000UL / totalTimeUs);比如上面的1ms/div就会得到1280 * 1000000 / 10000 128kSPS。再用前面那个定时器分频计算方法预分频器和周期寄存器就能算出来。代码里要维护一张时基档位表常见档位从1us/div开始按照1-2-5步进递增到1s/div每档都预计算好采样率、缓冲区长度、每像素采样点数。用查表方式比每次运行时实时计算更稳也更容易排查错误。3. 波形绘制坐标换算、峰值检波、插值与双缓冲刷新3.1 把ADC原始码转换成屏幕Y坐标拿到缓冲区数据之后波形绘制前最重要的一步是坐标换算。假设ADC是12位量程0~4095对应的模拟电压范围是0~3.3V。但考虑到输入前端经过分压和偏置实际电压范围可能是-5V到5V所以代码里要先做“电压标定”。标定的思路是在硬件校准阶段输入0V记录ADC读数为zeroCode再输入一个标准电压比如1V记录ADC读数为fullCode。每个档位有不同的缩放系数。实际使用时float voltage (adcCode - zeroCode) * (spanVolt / (fullCode - zeroCode)); int yPos (int)((voltage / voltPerDiv) * PIXELS_PER_DIV); yPos SCREEN_CENTER_Y - yPos; // 屏幕上正电压向上显示 if (yPos 0) yPos 0; if (yPos SCREEN_HEIGHT) yPos SCREEN_HEIGHT - 1;式中voltPerDiv是当前垂直档位PIXELS_PER_DIV表示每格对应多少个像素。如果屏幕是240像素高设置为10格那每格24像素大部分常用波形在这个档位下能完整显示。这里有个常被忽略的问题ADC输入可能有直流偏置零点电压不等于0V所以“零点校准”必须在代码启动阶段自动做一次或者提供一个校准触发按键。否则你输入一个0V信号屏幕上的波形却偏在某条线上。3.2 水平缩放与滚动显示的数据切片逻辑如果采集缓冲区里总共1024个点而屏幕只有320像素宽最简单的做法是“隔N点取1点”比如每3个点取一个丢掉中间两个。但丢了点以后突发窄脉冲就可能凭空消失。更好的做法是用“峰值检波”即每段区间内取最大值和最小值把这两个点都绘制在同一列的上下位置。这样显示出来的方波边沿、毛刺、窄脉冲不会因为抽稀而被遗漏。假设每像素对应M个采样点for (int x 0; x SCREEN_WIDTH; x) { int startIdx preTriggerBase x * M; int endIdx startIdx M - 1; int minVal 4095, maxVal 0; for (int i startIdx; i endIdx; i) { int v waveBuffer[i]; if (v minVal) minVal v; if (v maxVal) maxVal v; } int yTop codeToY(maxVal); int yBottom codeToY(minVal); drawVerticalLine(x, yTop, yBottom); }峰值检波对显示慢时基下的低频波形尤其重要。举个例子在100ms/div档位下一帧要覆盖1s时间如果缓冲区只有1024点每个像素间隔约31.25ms中间会经过很多个高频周期。如果只是抽稀你会发现显示的是随机散点根本看不出波形包络而峰值检波每列都保留最大值和最小值屏幕上的波形就会呈现一个非常漂亮的“包络带”带宽是多少、频率多高都能看个大概。3.3 波形插值线性插值还是正弦插值插值是很多初学者不太关注但显示效果差别很大的模块。在采样率刚好满足奈奎斯特条件时点与点之间连直线会出现明显的折线感尤其是观察音频波形时折线看起来非常不舒服。正弦插值能把波形画得圆滑一些但由于MCU性能有限完全逐点做正弦拟合不现实。我的实现方案是折中默认用线性插值因为计算量小、速度快在低频时基档位下才启用三次样条或正弦插值选段。因为低频时每像素对应的采样点数少插值点也少CPU压力不大。具体做法是先找到两个邻居点在它们之间均匀插入break个中间点用线性插值公式得到中间点的Y值。如果要更圆滑可以用Catmull-Rom样条它的实现并不复杂只需要前后各两个点公式网上都能查到但MCU上跑的时候要注意浮点运算的耗时尽量改为定点整数运算。我的经验是显示320x240的波形线性插值已经能满足绝大多数调试需求。正弦插值只有观察音频正弦波这类单一频率信号时才明显更养眼。不要在这上面花太多时间优先把触发和垂直档位做稳定收益更大。3.4 双缓冲绘图避免闪烁和撕裂LCD屏幕直接逐点写如果不做缓冲画面刷新时会明显看到旧波形被一点点擦掉刷出新波形闪烁很严重。解决办法是先在内存里建一块屏幕大小的显存缓冲把要显示的波形点和网格先画到显存再一次性把整个显存刷新到LCD。这块显存如果按RGB565格式256色算320x240大约是150KB很多入门级MCU承受不了所以通常降级成8位调色板索引或直接用单色显示。我的做法是LCD驱动本身带一个显存缓冲比如某些屏幕模块自带GRAMMCU端不额外开全屏显存而是开一个“行缓冲”一行一行地填充与刷新。这样可以显著降低内存占用内存占用约320字节或稍多一点还能消除闪烁因为每一行的绘制和上屏在同一轮循环中完成不存在整屏逐渐刷新的问题。逐行刷新的关键是把“计算波形点”的代码与“写入LCD”的代码合并在同一轮里。如果你的LCD控制器不支持局部窗口更新也可以整帧刷新显存那内存要求就高很多。这时候可以考虑把屏幕分辨率下调到240x160或者用RLE压缩波形数据解压后写入LCD。压缩这种操作其实没有想象中复杂但收益不小。4. 测量功能代码频率计数、电压测量与简易FFT的取舍4.1 过零检测频率计与周期法测频率示波器上最常用的读数就是信号频率和幅值。频率可以用过零检测实现在缓冲区里找信号穿过零点或触发电平的点统计穿越间隔取平均得到周期。过零检测要比触发检测简单因为它不需要应对噪声引起的误判那么苛刻但也得做一点消抖。简单做法是只有当信号从低于“零下滞回”变为高于“零上滞回”时才记录一次穿越并且连续穿越超过5次后再算频率防止随机噪声干扰。int crossings 0; uint32_t lastIdx 0; for (int i 1; i bufLen; i) { if (buf[i-1] zeroLow buf[i] zeroHigh) { if (lastIdx ! 0) { uint32_t periodSamples i - lastIdx; float freq sampleRateHz / (float)periodSamples; freqSum freq; count; } lastIdx i; crossings; } } float avgFreq freqSum / count;这里有一点必须提醒如果信号不是标准正弦波而是方波过零检测一点问题都没有如果是带直流偏置的正弦波零点应该设置为信号平均值或者触发电平而不是0V否则可能根本检测不到穿越。有效做法是先算缓冲区数据的平均值作为参考零点然后以平均值为中心做滞回穿越判断。4.2 峰峰值与幅值测量幅度测量用极值法最直接扫描缓冲区记录最大值和最小值两者相减得到Vpp最大值对应电压减去零点对应电压再除以根号2可得到RMS近似值。如果是标准正弦波RMS Vpp / (2 * sqrt(2))这可以用于估算但如果是任意波形就完全不成立。因此我提供两种读数一个是Vpp一个是基于过零检测的周期有效值。实际代码里极值扫描与峰值检波的循环可以合并因为绘制波形需要每列的min/max而全缓冲区的min/max也可以同步统计出来不必额外循环一遍。这个小优化在采样点数多的时候能减少大量时间。说到测量精度ADC只有12位测量结果大概能达到整数毫伏级分辨率前提是零点校准做准确。硬件的分压电阻如果有5%误差那显示的电压值会偏差较大有条件的话在代码初始化时做软件校准用两个标准电压源标定出斜率系数和偏移量这样精度能到1%以内。4.3 简易FFT的频率谱显示做成一台真正可用的示波器频谱显示功能往往非常加价值。但MCU上FFT需要谨慎选择大小。我用的是256点FFT这足够分辨100kHz以内大约几百赫兹的频率成分再多点会占用太多时间和内存。FFT的窗函数也很重要不加窗直接截断会产生严重的频谱泄漏观察小幅值谐波时基本就看不清。我用的汉宁窗实现简单通用性好for (int i 0; i FFT_SIZE; i) { float w 0.5f * (1.0f - cosf(2.0f * PI * i / (FFT_SIZE - 1))); fftInput[i] (waveBuffer[i] - zeroCode) * w; }做完FFT以后取功率谱幅值找出峰值对应的频率。FFT能覆盖的频谱范围受采样率限制一般最高分析频率为采样率的一半。由于时基不同会改变采样率频谱显示频率范围也会跟着变化这个特性反而和真实示波器类似用起来很直观。有一点需要特别注意如果显示正弦波频率为10kHz但采样率只有50kSPS那频谱图上可能会在40kHz附近出现一个假的镜像峰那是混叠造成的。做FFT之前必须确保当前采样率高于目标信号频率的2倍以上否则要先滤波或者把采样率调高。4.4 光标测量与网格读数除了自动测量我还会在屏幕上画两条垂直光标和两条水平光标方向键移动它们边缘显示当前时间和电压差。这个功能看起来很简单但涉及事件处理的优先级光标移动时波形区域需要重绘但后台采样不能停。我是把采样和显示分为两个状态机暂停状态下光标移动只改界面运行状态下才刷新波形。两条垂直光标之间的时间差换算成频率只需要用像素差乘以每像素时间即可公式不复杂但很实用。5. 调这套代码时我踩过的几个深坑和最终的解决方式5.1 DMA缓冲区大小与传输完成中断的配合问题第一次调试时DMA设为循环模式缓冲区1024点。我没开过半中断只在传输完成中断里取数据结果总是发现波形有一半区域是旧的、一半是新的形成一个撕裂台阶。根因是数据一直在更新而我每次读取时正好取到缓冲区更新的中间状态。解决办法开启DMA传输过半中断和完成中断主循环里先读标志位然后按顺序机械地取用对应的半区数据。而且读取数据后要立即清标志不能在处理完所有显示后再清否则下一轮标志被覆盖会丢失一帧。实际写的伪代码如下if (dmaHalfDone) { processFrame(bufferA); dmaHalfDone 0; } if (dmaFullDone) { processFrame(bufferB); dmaFullDone 0; }这样处理之后每秒能稳定刷新20到30帧肉眼看起来波形流畅也没再出现撕裂。5.2 触发点的抖动导致波形水平方向漂移软件触发存在一个老问题触发点找到了但如果在缓冲区内多次触发或触发位置落在缓冲区边缘显示就会跳动。我在代码里对触发点增加了“锁定”策略一旦找到触发沿至少在指定时间内不重新触发这个时间一般设为当前时基档位对应一帧周期的80%。这样每次屏幕刷新时触发点都稳定落在同一个横坐标视觉上波形固定不动。另外触发点最好安排在缓冲区的30%位置作为预触发深度这样既能保证看到触发点之前的波形又能给触发点之后的波形留出足够长度。预触发比例太小看脉冲前沿会不完整预触发比例太大后续数据不够画满一屏。5.3 浮点运算拖慢刷新率改用定点整数优化采集与显示逻辑里面用到了大量乘除法、浮点运算。比如每次坐标换算都乘浮点系数在168MHz MCU上偶尔卡顿。其实用定点数完全可以解决把电压档位的缩放系数预先计算成“每ADC码对应多少像素”的Q16定点数然后把乘法变成整数乘法和移位。屏幕高度240像素ADC范围4096那么“每ADC码对应像素值”大约为240/40960.0586。用Q16表示就是0.0586*65536约等于3840代码里int yOffset ((adcCode - zeroCode) * 3840) 16;这样处理以后Y坐标换算只花了大约3个周期比浮点快一个数量级。凡是在主循环和刷新路径上出现的浮点运算都尽量改成了定点。这个优化做完刷帧率从大概12帧提升到了25帧左右操作响应也明显好了很多。5.4 ADC采样时间过短导致小信号幅度显示偏小这是很多人不会注意的坑。如果输入阻抗高、信号源内阻大ADC采样时间太短采样电容还没充满就开始转换读数会偏低。尤其观察高阻抗传感器的信号时比如用分压电阻串在信号源后面等效源阻抗可能达到几十千欧这时1.5周期的采样时间完全不够。调法是把ADC采样时间调到最大比如239.5周期虽然单次转换时间变长最高采样率下降但测小信号和低频信号时精度明显更好。如果必须同时兼顾采样率和输入阻抗可以在前端加一级运放缓冲或者先把信号调理电路做好再进ADC。这个我只能说硬件设计阶段就要注意等代码写完了再补救能做得很有限。5.5 LCD刷屏太慢造成显示跟不上的问题LCD驱动如果通过GPIO模拟并口来写刷新会非常慢。我记得最开始的版本刷一屏大约要120ms显示起来像幻灯片。后来改用SPI DMA发送刷屏时间降到30ms左右效果立刻不同。所以如果板子支持SPI或并行DMA一定要直接走硬件传输别在阻塞式发送上浪费时间。如果你用的屏幕支持局部刷新还可以进一步优化只在波形变化区域刷新比如网格和文字保持不变只更新波形列所在的行。这样刷屏时间又会少一半。虽然实现起来有点繁琐但界面流畅度对使用感受的影响非常大。6. 这个小项目后续还能怎么演进整套代码跑通以后我会觉得它与其说是一台“简易示波器”不如说是一个信号采集与显示的通用框架。后续值得扩展的方向有几个把单通道改成双通道数据处理部分增加触发通道选择增加波形存储与回放用外部Flash保存几十帧波形加入自动测量功能比如上升时间、下降时间、占空比、脉宽等。其中占空比测量最容易做只要统计缓冲区里高电平样点数占总样点数的比例即可有兴趣的可以自己先加上。另外在文档和代码结构上建议把采样、绘制、交互这三块拆成独立的模块文件编译时通过条件编译开关选择是否启用FFT、是否启用双通道。这样维护和调试都方便想移植到其他板子时也能快速替换驱动层。如果看这篇文章的朋友和我当初一样是从零开始第一次写这类代码我的建议是先做单通道采样率固定不做FFT先把“显示出一条稳定不抖动的波形”跑通再逐步加功能。千万别一上来贪多。一步一步来这个项目真的能让你对“示波器”这个工具的理解提升一大截。