简介这是基于STM32与HAL库实现的数字示波器完整工程面向单片机开发者尤其适合学习定时器采样、ADC采集和中断处理的进阶人员。包内共1261个文件以C源码604个、头文件298个为主还包含定时器、ADC相关驱动以及Keil MDK工程配置uvprojx、uvoptx等并附有编译生成的hex、axf、map等固件文件整体约40.33MB便于对照工程结构与输出结果。目前已有597人浏览学习。工程核心覆盖HAL库初始化、定时器触发放样、ADC多通道采集、串口发送波形数据等关键流程同时包含CubeMX生成的.ioc和.mxproject配置方便二次开发。对想要自制示波器或深入理解STM32 HAL库外设调用方式的读者这份资料提供了从源码到编译产物的完整参考有助于快速掌握采样率设置、触发逻辑及中断回调的工程实现。1. 示波器不是非得买用STM32自己搓一台可行吗调试电机驱动板时手边只有万用表只能判断引脚有没有电压看不出PWM占空比、振铃和毛刺到底长什么样。示波器要么在实验室排队要么价格劝退。但STM32内部ADC在单通道下能做到每秒百万次采样级别配合DMA搬运和定时器触发再加一块小尺寸TFT屏就能组成一台能测频率、幅值、看基本波形的简易示波器。标题里的HAl指的就是HAL库即STM32Cube的标准外设驱动框架。用HAL库做示波器意味着把采集、触发、显示整条链路自己走一遍。这个方向适合手里有一块开发板就想排查信号问题的嵌入式开发者也适合刚入门、想理解ADC、DMA、定时器如何协作的初学者。2. 示波器第一课采样链路里每个环节都在决定你看到什么2.1 先定指标自制示波器的边界在哪里做之前先明确目标。按采样率、带宽、垂直分辨率、输入范围几个维度列一张表心里就有数了。以常见的STM32系列为例不要和台式示波器比参数要和自己的测试需求比。参数台式示波器参考STM32简易方案量级采样率每秒GSa/s级别单通道约1MSa/s受具体型号ADC时钟限制带宽数十MHz至GHz按奈奎斯特定律推算实际受前端和ADC采样时间限制垂直分辨率8~12位12位ADC理想情况约4096级输入范围可调衰减通常±几V至几十V受参考电压和分压电路限制默认0~3.3V存储深度MB级别受RAM限制常见几千到几万点这个表的核心结论是STM32示波器适合看几十kHz以内的信号比如PWM输出、开关电源纹波、I2C和UART的帧结构、RC充放电波形。想测几十MHz的射频信号这个方向不合适。先承认边界后面所有设计才有依据。采样率决定你能看到多高频的信号。比如要稳定显示一个1kHz方波采样率至少要有10倍以上才能看到基本的边沿形状要看PWM的振铃细节振铃频率往往在几十MHz1MSa/s只能看到包络细节完全丢失。所以第一步不是写代码而是确定你要测什么信号再倒推采样率和存储深度。2.2 前端信号调理不要直接把信号怼进引脚新手最常见的翻车就是把被测信号直接接到单片机引脚上尤其是测电机驱动和开关电源时。直接接有三个问题一是信号幅度超过3.3V会烧引脚二是负半周信号会触发内部钳位二极管波形被削掉一半三是信号源阻抗太高时ADC内部采样电容还没充满就被切断了读数严重偏低。常见做法是先做一级信号调理。幅度大的信号用电阻分压衰减到ADC量程内比如10:1分压带负半周的信号要加直流偏置把信号整体抬升到0V以上担心接错线烧芯片的在引脚前加串联电阻和钳位二极管做保护。即使没有运放用电阻和二极管也能搭出第一版可用的前端代价是输入阻抗偏低对被测电路有一定负载效应。有运放的话我一般会加一级电压跟随器做阻抗变换再进入ADC引脚。电压跟随器的输出阻抗很低ADC的采样电容充电速度会快很多这直接关系到高频分量能不能被采准。没有运放先用分压电阻也能跑通流程只是拿到的高频波形幅度会偏小不要以为是程序问题。2.3 ADC采样加DMA搬运为什么非要DMA不可如果不使用DMA每次ADC转换完成后CPU都要进中断读数据寄存器。假设转换一次需要1us中断响应和读寄存器再快也要几百nsCPU几乎全部时间都在读数据没有任何余力做显示和界面处理。更麻烦的是中断响应时间不稳定采样间隔会出现抖动波形在触发点附近会左右摇晃。DMA的职责是ADC转换完成后硬件自动把数据寄存器里的值搬到内存数组不需要CPU参与。这样CPU可以专心做触发检测和屏幕绘制。连续采样模式下DMA把ADC数据源源不断地搬进缓冲区缓冲区满了再从开头覆盖形成一个环形数据流。为了不让显示层读到一半被新数据覆盖推荐用双缓冲。定义两个长度相同的数组DMA先填前半段再填后半段。半传输完成中断通知CPU处理前半段数据传输完成中断通知CPU处理后半段数据。两个缓冲区交替使用CPU和DMA各写各的数据撕裂问题基本消失。2.4 定时器触发让采样率变得可计算自由运行模式下ADC的采样间隔由转换时间决定不可控也不精确。要做示波器采样间隔必须稳定所以需要定时器来触发采样。定时器输出一路PWM信号接到ADC的外部触发引脚或者使用定时器的触发输出直接映射到ADC。每个上升沿启动一次转换采样周期就是PWM的周期。采样率的计算公式为采样率 定时器时钟频率 / ((预分频值 1) × (自动重载值 1))关键点是定时器时钟不一定是主频。以定时器时钟72MHz为例预分频值为1、自动重载值为71采样率就是72MHz / (2 × 72) 0.5MHz。通过修改这两个值就可以得到不同的采样率档位。这里有一个容易踩的坑ADC转换本身需要时间比如12位分辨率下约14个ADC时钟周期。如果定时器触发频率高于ADC能承受的转换速率ADC会丢转换表现为数据重复或相位错乱。所以配置采样率时要确认定时器触发频率低于ADC最大转换速率。3. 基于HAL库的采集端实现定时器触发ADC加DMA搬运的代码骨架3.1 在CubeMX里勾选哪些配置使用HAL库开发我习惯先在CubeMX里做初始化图形配置再回到代码里补业务逻辑。这样外设时钟、引脚复用和DMA请求映射都由工具生成避免手写寄存器时把复用关系搞错。需要勾选的配置按顺序做选择芯片型号配置时钟源开启调试接口在ADC1配置页使能需要的通道把采样时间选为较短档位在DMA设置里为ADC1添加DMA请求模式选循环Circular配置一个定时器比如TIM2使用内部时钟输出比较通道1在ADC配置页把触发方式选为外部触发触发边沿选上升沿生成代码选择HAL库生成代码后重点检查ADC1的初始化结构体里ExternalTrigConv字段是否为定时器触发以及DMA的Mode是否为DMA_CIRCULAR。这两个配置在手写代码时最容易漏。3.2 定时器配置与采样率换算先算明白再动手CubeMX生成的定时器初始化代码一般直接操作底层寄存器为了方便改采样率我通常把定时器重载值单独抽成一个函数。这里以定时器时钟72MHz为例。// 定时器触发配置TIM2输出PWM占空比不影响ADC触发 static void TIM2_Trigger_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim2.Instance TIM2; htim2.Init.Prescaler 1; // 预分频值决定分频倍数 htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 71; // 自动重载值配合预分频得到目标采样率 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_OC_Init(htim2); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 35; // 脉宽多少无所谓边沿触发只看极性 sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; HAL_TIM_OC_ConfigChannel(htim2, sConfigOC, TIM_CHANNEL_1); HAL_TIM_OC_Start(htim2, TIM_CHANNEL_1); // 启动后立刻开始输出触发脉冲 }这里Prescaler和Period共同决定采样率。计算公式是72MHz / (11) / (711) 500kHz。如果改成Prescaler71, Period9采样率变成 72MHz / 72 / 10 100kHz。想换档位时优先调预分频值让自动重载值保持在几十到几百的量级这样触发脉冲的频率稳定性更好。另一个要注意的字段是Pulse。它决定PWM的占空比但ADC触发关注的是上升沿所以只要Pulse小于Period产生一个正常的PWM波形即可。把Pulse设成Period的一半最省心。3.3 ADC加DMA连续采集双缓冲代码与中断回调ADC的初始化由CubeMX生成需要手动确认两件事连续转换模式要关闭因为外部触发决定采样节奏DMA请求要开启并设置为循环模式。下面是核心启动代码。#define ADC_BUF_LEN 2048 // 每段缓冲长度两段共4096点 uint16_t adc_buf[2][ADC_BUF_LEN]; // 双缓冲DMA交替填充 volatile uint8_t dma_done_flag 0; // 0前半段就绪 1后半段就绪 // 启动ADC的DMA采集这里只启动一次之后靠定时器持续触发 void adc_start_capture(void) { HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf[0], ADC_BUF_LEN); } // 前半段填满CPU处理adc_buf[0]同时DMA正在填adc_buf[1] void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ! ADC1) return; dma_done_flag 0; } // 后半段填满CPU处理adc_buf[1]DMA从头开始覆盖adc_buf[0] void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ! ADC1) return; dma_done_flag 1; }启动时传入的地址是adc_buf[0]长度是ADC_BUF_LEN。HAL库的内部逻辑是DMA先填adc_buf[0]填满后触发半传输完成中断继续填adc_buf[1]填满触发传输完成中断然后循环回adc_buf[0]。所以一个完整波形是两段拼接的显示层需要知道当前哪一段就绪。dma_done_flag就是在两个回调里标记的就绪状态。主循环读取这个标志读到0就处理adc_buf[0]读到1就处理adc_buf[1]。注意不要在中断回调里做屏幕绘制绘制耗时几千个周期会拖慢DMA节奏应该只标记一下把绘制放到主循环。3.4 改采样率时别忘了停定时器我在调试中遇到过一个情况采样率从500kHz切到50kHz后波形频率显示没变数据却乱跳。后来发现是切换档位时只改了定时器重载值没有先停定时器。定时器正在运行时修改重载值会等到当前计数周期结束才生效期间混入了新老两种采样率的采样点波形自然乱了。正确的切换流程是先HAL_TIM_OC_Stop停掉定时器再HAL_ADC_Stop_DMA停掉DMA然后把缓冲区清一遍重新配置定时器参数最后按顺序启动DMA和定时器。代码里这个顺序不能反先启DMA再启定时器保证定时器第一个触发脉冲到来时DMA已经在等待状态。void adc_set_samplerate(uint32_t sample_rate) { uint32_t timer_freq 72000000; // 定时器时钟按实际工程调整 uint32_t prescaler, period; HAL_TIM_OC_Stop(htim2, TIM_CHANNEL_1); HAL_ADC_Stop_DMA(hadc1); // 先算预分频再做重载值保证period落在合理范围 prescaler (timer_freq / sample_rate / 256) 1; period (timer_freq / (prescaler * sample_rate)) - 1; __HAL_TIM_SET_PRESCALER(htim2, prescaler); __HAL_TIM_SET_AUTORELOAD(htim2, period); HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf[0], ADC_BUF_LEN); HAL_TIM_OC_Start(htim2, TIM_CHANNEL_1); }这段代码里的prescaler计算先粗调让period落在255以内的范围避免重载值过大导致定时器计数精度下降。__HAL_TIM_SET_PRESCALER在定时器停止时调用会立即生效运行时调用要在下一个周期才生效这也是为什么前面必须先停定时器。4. 波形显示与触发逻辑从裸数据到可读波形4.1 把ADC原始值映射到屏幕坐标拿到缓冲区数据后第一件事是把12位ADC值变成屏幕上的像素坐标。假设屏幕是320×240波形显示区高度取200像素顶部留20像素给坐标信息底部留20像素给刻度。横向映射的核心是压缩缓冲区有2048点屏幕只有320列不可能逐点显示需要把索引映射到屏幕列。纵向映射相对简单ADC值0对应显示区底部4095对应顶部做一个线性变换即可。下面是一段最简映射代码。#define WAVE_TOP 20 // 波形区顶部y坐标 #define WAVE_WIDTH 320 // 波形区宽度 #define WAVE_HEIGHT 200 // 波形区高度 void draw_waveform(const uint16_t *buf, uint32_t len) { uint32_t i, col; uint16_t y; lcd_clear_area(0, WAVE_TOP, WAVE_WIDTH, WAVE_HEIGHT); // 只清波形区不清全屏 for (col 0; col WAVE_WIDTH; col) { // 把屏幕列映射到缓冲区索引len/ WAVE_WIDTH是每列对应的点数 i col * len / WAVE_WIDTH; if (i len) break; // ADC 12位范围0~4095映射到WAVE_HEIGHT高度 y WAVE_TOP (uint16_t)((uint32_t)buf[i] * WAVE_HEIGHT / 4095); lcd_draw_pixel(col, y, WAVE_COLOR); } }col * len / WAVE_WIDTH这一步是在做线性采样不是平均。如果每列对应多个采样点这种方法会漏掉窄毛刺。想保留毛刺应该取每列范围内的最大值和最小值分别画两个点这就是示波器里的峰值检测模式。先跑通线性映射再考虑峰值检测。lcd_clear_area只清理波形区而不是全屏可以明显减少闪烁。如果用的屏幕控制器不支持区域清屏就退化为全屏清屏但帧率会下降可以先接受后续再优化为批量写像素。4.2 边沿触发让波形停在一个稳定的位置没有触发的示波器波形在屏幕上不断滚动人眼很难读频率和幅值。触发的作用是规定显示起点每次检测到信号穿过某个电平且方向正确时就从这一点开始往后画固定长度的数据。这样重复性波形会稳定叠加在屏幕上。#define TRIGGER_RISING 1 // 上升沿触发 #define TRIGGER_FALLING 0 // 下降沿触发 int16_t find_trigger_edge(const uint16_t *buf, uint32_t len, uint16_t level, uint8_t edge) { uint32_t i; for (i 1; i len; i) { if (edge TRIGGER_RISING buf[i - 1] level buf[i] level) { return (int16_t)i; // 返回触发点在缓冲区中的下标 } if (edge TRIGGER_FALLING buf[i - 1] level buf[i] level) { return (int16_t)i; } } return -1; // 本段数据未找到触发点 }level是触发电平用ADC原始值表示而不是电压值。比如要触发在1.65V就传1.65 / 3.3 × 4095 ≈ 2048。这样比较逻辑里全是整数不涉及浮点运算在单片机里跑得更快。这个实现有一个明显缺陷信号带噪声时波形在电平附近反复穿越每次找到的触发点位置都不同波形显示会左右抖动。改进办法是加回滞上升沿触发要求从低于level - hyst到高于level hyst才算有效穿越把噪声过滤掉。hyst取整个波形垂直范围的1%到3%比较合适。4.3 从触发点绘制两段缓冲拼接的坑前面提到双缓冲结构一个完整显示帧由adc_buf[0]和adc_buf[1]拼接而成。触发检测必须在整帧数据上进行不能只在半段里找。因为触发点可能落在缓冲区的前半段也可能落在后半段画图时要绕过缓冲区首尾。void draw_from_trigger(const uint16_t *frame, uint32_t frame_len, int16_t trig_idx) { uint32_t start (trig_idx 0) ? (uint32_t)trig_idx : 0; uint32_t i, col; for (col 0; col WAVE_WIDTH; col) { i start col * (frame_len - start) / WAVE_WIDTH; if (i frame_len) break; // 从这里开始逐列画点注意i要限制在有效数据范围内 } }这里的要点是从触发点开始映射列索引而不是从缓冲区头部开始。否则触发起点的位置会随着信号频率变化而滑动波形看起来在左右漂移。frame_len应该是两个缓冲区的总长度处理时先根据dma_done_flag拼出一个逻辑上的完整帧或者用memcpy把两段拷贝到一个连续数组里。拷贝方案更简单代价是浪费一次内存搬运时间。如果RAM够用我一般定义frame[4096]半传输完成时拷贝adc_buf[0]传输完成时拷贝adc_buf[1]然后在frame上做触发和绘制逻辑清晰很多。RAM紧张时再改成两段分别处理但要额外处理跨段拼接的索引计算。4.4 时基档位采样率、点数和观察时长的关系示波器上的时基旋钮本质是在改变“屏幕上每一格代表多长时间”。在自制方案里改变时基就是改变采样率或显示点数。观察时长由公式决定观察时长 总点数 / 采样率。目标波形频率推荐采样率总点数观察时长一个周期占屏比例1kHz方波200kSa/s4096点约20.5ms约1.5个周期10kHz方波1MSa/s4096点约4.1ms约4个周期100Hz慢信号10kSa/s4096点约409ms约40个周期50Hz交流电源5kSa/s4096点约819ms约40个周期理想状态下屏幕上显示2到5个完整周期是最好读数的。周期太少看不出波形形状太多又看不清细节。切换时基档位时先确定目标频率再倒推采样率而不是盲目调高采样率。一个容易被忽视的点采样率高时观察时间变短缓冲区里可能只有不到一个完整周期。这时候即使触发成功了画面上也可能只有半个波形。我的习惯是让缓冲区至少容纳两个完整周期触发点落在缓冲区中间位置这样画出来的波形左边有触发前的情况右边有完整的后续波形观察效果接近真实示波器。5. 示波器调试避坑采样、触发与显示的五个高频翻车点5.1 现象DMA只采了一次就停止波形画出来是空的数据完全没变化或者屏幕上只在开头有一段线后面全部空白。原因通常是DMA配置成了单次模式而不是循环模式第一次传输完成后DMA停止后续ADC数据不再搬运到内存。另一个常见原因是ADC的连续转换模式和外部触发配置冲突导致只触发了一次。解决方法是回到CubeMX的DMA设置页把DMA模式从Normal改为Circular重新生成代码。如果手写代码注意hdma_adc1.Init.Mode DMA_CIRCULAR这一行。改完之后用调试器挂上变量adc_buf观察数据是否持续变化这是验证DMA是否在搬数据的最快办法。5.2 现象波形上半段正常下半段被削平信号波形顶部或底部出现一条直线看起来像是限幅了。原因是输入信号超出ADC测量范围高于参考电压的部分被钳位到满量程低于GND的部分被二极管钳位到0附近。测电机驱动波形时特别容易发生。解决方法是先确认输入电压范围。用万用表测被测点的静态电压如果峰值超过3.3V就在输入端加电阻分压把信号缩小到3.3V以内。如果信号本身是0到5V的PWM直接一个1k和2k电阻分压就够用。要注意分压后的输出阻抗变高ADC读数会偏低建议在两电阻中间对地接一个0.1uF电容做缓冲能改善高频响应。5.3 现象数据每隔一段长度跳变一次波形出现断裂屏幕上波形在前半段正常到某个位置突然跳变之后又恢复。这个现象和双缓冲拼接有关。DMA循环搬运时缓冲区末尾和开头是首尾相连的如果信号周期和缓冲区长度不成整数倍缓冲区末尾截断的位置恰好是信号中部拼接处就会出现相位跳变。排查方法是打印出整个缓冲区的前几十个点观察是否存在明显的幅度突变。如果是说明不是代码逻辑错而是缓冲区和信号周期的对齐问题。解决办法就是前面提到的从触发点开始绘制不要从缓冲区头部直接画图。只要显示起点是触发的、跨越缓冲区首尾的部分被跳过跳变就会消失。5.4 现象采样率档位切换后波形频率读起来不对切换采样率从高到低后同样的信号在屏幕上看起来频率变高了或者变得乱七八糟。原因多半是定时器时钟频率不同。查一下芯片的时钟树把定时器时钟算对。我见过有人以72MHz计算但实际定时器挂在APB1上分频后只有36MHz算出来的采样率差一倍读取到的频率也差一倍。另一个原因更隐蔽ADC的采样时间配置太长。如果ADC时钟14MHz采样周期设为239.5个周期一次转换要近20us定时器触发频率再高也触发不出有效数据。解决方法是把ADC采样时间改到较短档位比如1.5到13.5个周期。注意采样时间短了输入阻抗要求更高前端分压电阻太大时读数会偏低需要平衡。5.5 现象触发点附近波形左右晃动稳定不下来波形大体形状正确但在触发点位置附近不断抖动看起来像是有噪声叠加。原因有两个一是触发电平设在了信号变化缓慢的区域噪声导致穿越时刻不断变化二是信号本身带高频毛刺每次触发时穿过电平的位置差了几个采样点。解决方法是先用幅值测量看整个波形的最大值和最小值把触发电平设在上升沿的中间位置。比如信号从0.5V跳到2.5V触发电平设1.5V比设0.8V稳定得多。如果还抖就在触发判断里加回滞量比如必须从低于1.4V到高于1.6V才算上升沿触发这样噪声毛刺被直接过滤。回滞量取信号垂直幅度的2%左右比较合适太大会丢失真正的小信号边沿。6. 从能显示到能用频率测量、自动量程与滚动模式的落地技巧6.1 软件测频率和峰值不需要额外硬件波形已经显示稳定之后下一步是让示波器告诉你这个信号的频率和幅值。测峰值最简单扫一遍缓冲区找最大值和最小值差乘以参考电压再除以4096就是Vpp。测频率稍微麻烦一点思路是统计信号穿过中值的次数穿越次数除以2就是周期数再除以观察时长得到频率。float measure_vpp(const uint16_t *buf, uint32_t len, float vref) { uint32_t i; uint32_t vmax 0, vmin 4095; for (i 0; i len; i) { if (buf[i] vmax) vmax buf[i]; if (buf[i] vmin) vmin buf[i]; } return (float)(vmax - vmin) * vref / 4095.0f; } float measure_freq(const uint16_t *buf, uint32_t len, float sample_rate) { uint32_t i, crossings 0; uint32_t mid (buf[0] buf[len / 2]) / 2; // 粗略中值 for (i 1; i len; i) { if (buf[i - 1] mid buf[i] mid) crossings; } return (float)crossings * sample_rate / (float)len; }这个频率测量方法精度取决于缓冲区里有多少个完整周期。理想情况是10个以上周期交叉点统计误差小于一个周期宽度。测正弦波误差在1%以内测方波更准。但测方波时中值附近的噪声会产生误判必要时先做一次简单的中值滤波再统计。6.2 自动量程先粗采一次再切换到合适档位手动切换时基档位很麻烦自动量程解决的是“按下按键后示波器自己找到合适的采样率和触发电平”。常见做法是先用一个中等采样率快速采集一段数据测量信号频率和峰值然后根据频率自动选择采样率根据峰值自动设置触发电平。自动量程的切换逻辑要加防抖信号频率抖动时频繁换档位会让显示连续跳动。我习惯在切换后保持300ms到500ms的锁定时间期间不响应新的量程调整请求等波形稳定了再允许下一次切换。这个细节直接决定自动量程好不好用很多人做出来之后没有锁定逻辑用户体验就是屏幕一直跳。6.3 滚动模式低频慢信号的显示救星低频信号比如几十赫兹的传感器输出在普通触发模式下屏上只有一个线头在缓慢移动很难看清波形全貌。这时候需要滚动模式把缓冲区当环形队列每采到一批新数据就把它追加到屏幕右侧旧数据向左平移看起来就像纸带记录仪。滚动模式实现要点是不要每来一个点就重画整屏那样CPU撑不住。折中做法是每攒够50到100个点再刷新一次屏幕左侧旧波形直接平移只在最右侧画新数据块。LCD的平移可以用批量读显存再写回的方式或者干脆清屏重画帧率够慢时重画也不会闪得太厉害。这个模式不用触发靠时间轴本身排列数据适合观察上电瞬态和低频漂移。在我自己做这个方向时最深刻的教训是先把采样率公式和触发逻辑理顺再碰界面代码。界面只是最后一层纸采样链路不稳界面做得再好看也白搭。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?