ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32示波器源码解析:定时器触发ADC与DMA双缓冲

STM32示波器源码解析:定时器触发ADC与DMA双缓冲 简介一套完整的 STM32 示波器设计源码工程基于 STM32F103ZET6 微控制器覆盖嵌入式软硬件开发全流程适合电子专业学生、嵌入式初学者及进阶开发者用于课程设计、毕业设计或自学参考。资源包含硬件原理图与 PCB 版图可帮助理解电路布局、信号采集路径和电源设计软件部分提供采样、滤波、触发等核心算法并带有 PC 端上位机测试程序便于串口通信与波形显示调试。设计文档和解释文档则从项目目标、技术选型到调试过程进行系统梳理便于复现整套方案并深入理解 STM32 外设应用。压缩包为 zip 格式大小约 19.27MB已有 1388 人学习下载。借助该工程开发者可从硬件原理图分析、PCB 布线参考、固件算法实现到 PC 端界面联调逐一实践系统掌握 STM32 在示波器类信号处理项目中的完整设计思路。1. 为什么 STM32 示波器的难点不在“显示”而在“时基”拿到一个标注「STM32示波器的设计源码程序」的工程很多人的第一反应是去翻 LCD 的绘制代码觉得波形画得漂不漂亮决定成败。实际上做过一版能看的示波器就会发现真正卡人的地方是采样率、存储深度和水平时基三者之间的换算关系——你要在一格 1ms 的时间基准下看到完整波形屏幕上 10 格就需要 10ms 的数据如果采样率是 100kSps就要在内存里攒下 1000 个点。而一套 ADC 连续采样加 DMA 搬运的代码从配置到调试占用的时间往往比绘图多一倍以上。这篇文章就是顺着「STM32 示波器的设计源码程序」这个标题把一套可复现的工程方案讲清楚从定时器触发 ADC 与 DMA 双缓冲的采集前端到波形压缩绘制、软件触发、电压标定再到 FFT 和余晖这类进阶功能。源码程序不是一把梭把外设全开一遍而是每一段代码都要回答一个问题这个参数和示波器的哪个档位对应。适合正在做课程设计、毕业设计或者想把手头的 STM32 开发板变成一台简易虚拟示波器的开发者和硬件爱好者。读完后你可以对照着把工程搭起来也能在别人问「你这个示波器最高采样率多少」时不心虚地给出计算过程。2. STM32 示波器的数据采集链路定时器触发、ADC 与 DMA 双缓冲2.1 为什么采样不能用 delay 读寄存器很多人写 STM32 示波器程序第一步就是把 ADC 设成软件触发在while(1)里读ADC_GetConversionValue为了控制采样间隔再塞一个delay_us()。这个做法在 1kSps 采样率下勉强能看但一旦把采样率提到 100kSps问题就全出来了delay_us(10)本身有误差中断服务和绘图代码又会让主循环的抖动达到几百微秒实际采样间隔乱跳波形在示波器上看起来就是一片毛刺。示波器这类设备对采样时钟的稳定性要求和普通传感器采集完全不同。所以正规一点的 STM32 示波器源码程序都会用「定时器触发 ADC」的方式来做等间隔采样。STM32 的 ADC 支持外部触发源比如 TIM2 的 TRGO 事件配置好定时器的更新频率后ADC 会在每个定时器事件到来时自动启动一次转换转换完成的结果再通过 DMA 搬到内存循环缓冲区里。整个过程不需要 CPU 参与主循环只负责在缓冲区满了之后做数据处理和波形绘制。2.2 用 TIM2 触发 ADC1DMA 双缓冲搬运数据下面这段代码是我常用的初始化逻辑基于 STM32F103 的 ADC1、TIM2 和 DMA1 通道 1比较有代表性。先配置定时器产生触发脉冲再配置 ADC 为外部触发采样模式最后把 DMA 设为循环模式搬数据。void osc_ADC_DMA_Init(uint32_t sample_rate) { // 1. 配置 TIM2 产生 ADC 触发事件频率 sample_rate TIM_TimeBaseInitTypeDef tim; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); uint32_t timer_clk SystemCoreClock / 2; // APB1 时钟72MHz 主频下为 36MHz uint16_t prescaler (timer_clk / sample_rate / 65535) 1; uint16_t period (timer_clk / prescaler / sample_rate) - 1; TIM_TimeBaseStructInit(tim); tim.TIM_Prescaler prescaler - 1; tim.TIM_Period period; tim.TIM_ClockDivision 0; tim.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); // 更新事件作为触发输出 TIM_Cmd(TIM2, ENABLE); // 2. 配置 ADC1外部触发右对齐单通道采样 ADC_InitTypeDef adc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitTypeDef gpio {GPIO_Pin_0, GPIO_Speed_50MHz, GPIO_Analog}; GPIO_Init(GPIOA, gpio); // PA0 作为模拟输入 ADC_StructInit(adc); adc.ADC_Mode ADC_Mode_Independent; adc.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; // TIM2 TRGO 触发 adc.ADC_ExternalTrigConvCmd ENABLE; // 允许外部触发 adc.ADC_DataAlign ADC_DataAlign_Right; adc.ADC_NbrOfChannel 1; ADC_Init(ADC1, adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_28Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); // 3. DMA 循环模式搬运 ADC 转换值到缓冲区 DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); dma.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; dma.DMA_MemoryBaseAddr (uint32_t)osc_dma_buf; dma.DMA_DIR DMA_DIR_PeripheralSRC; dma.DMA_BufferSize ADC_SAMPLES_TOTAL; dma.DMA_PeripheralInc DMA_PeripheralInc_Disable; dma.DMA_MemoryInc DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; dma.DMA_Mode DMA_Mode_Circular; dma.DMA_Priority DMA_Priority_High; dma.DMA_M2M DMA_M2M_Disable; DMA_Init(DMA1_Channel1, dma); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); // 4. 打开 DMA 和定时器采集开始 DMA_Cmd(DMA1_Channel1, ENABLE); TIM_Cmd(TIM2, ENABLE); }代码逻辑分成四段定时器负责产生等间隔的触发脉冲ADC 在每个脉冲到来时转换一次DMA 把转换结果按顺序搬进osc_dma_buf数组循环模式意味着缓冲区写满后从头再写始终保存最近一段时间的数据。这样的数据链路保证了采样间隔和硬件定时器完全同步不受主循环代码执行时间影响。参数上最值得注意的变量是sample_rate。它由预分频系数和自动重装值共同决定公式为sample_rate timer_clk / (prescaler * period)。比如想要 100kSps 采样率STM32F103 的 APB1 定时器时钟是 36MHz可选prescaler1、period360计算得36MHz / 360 100kHz。ADC_SampleTime_28Cycles5表示单次转换需要 28.5 个 ADC 时钟周期ADC 时钟设为 12MHz 时单次转换约 2.4us对应最大采样率约 400kSps这个档位的 ADC 采样时间够用。如果还想继续往上提采样率就要缩小 ADC 采样时间。2.3 采样率、时基与缓冲长度的关系表示波器的水平轴不是简单地画采样点序号而是要把时间和像素对应起来。假定屏幕横向 400 像素每格 50 像素8 格水平刻度那么不同时基档位下需要的缓冲长度差异非常大时基档位总时间窗口50kSps 时所需点数1MSps 时所需点数每像素时间1ms/格8ms400800020us100us/格0.8ms408002us10us/格80us4800.2us1us/格8us—820ns从这个表能看出一个关键取舍如果固定采样率 1MSps在 1ms/格档位下需要一个可以容纳 8000 个采样点的缓冲区如果缓冲区只有 4KB2048 个 uint16_t那只能看到前 2ms 的波形屏幕上剩下的部分就是空的。所以示波器程序必须根据当前时基档位动态调整采样率或者抽点策略。常见做法是「高档位用高采样率低档位降低采样率并抽点」——比如 1ms/格配 50kSps1us/格配 1MSps这样缓冲区长度始终够用屏幕上的波形密度也合理。2.4 采样数据不足时的抽点策略当缓冲长度小于屏幕需要的像素点数时不能直接扔掉数据因为波形会丢失细节。常用的做法是「区间取最大值最小值」抽取法。把缓冲区按屏幕宽度分成 N 段每段取最小值和最大值绘制时在同一列上画一条垂直线贯穿两者。这样即使每像素只有 2 个采样点波形的毛刺和幅度范围也不会被吞掉视觉上比单纯均匀抽取清晰很多。代码在下一章绘图部分给出。3. 波形压缩绘制与虚拟示波器协议设计3.1 两种显示路线的取舍STM32 示波器程序的显示方式通常分两类接一块 TFT LCD 直接在屏幕上画波形或者通过串口把采样数据发给上位机显示。前者适合做成手持式仪器无需外部依赖但受限于 STM32F103 的主频刷新率做到 30fps 就很吃力后者就是常说的「虚拟示波器」把绘图负担全部交给 PC单片机的 DMA 采集和串口发送可以同时工作采样率上限明显更高。我一般建议课程设计用 LCD 方案因为它把「波形绘制」这个核心流程放在单片机上思路更完整如果项目要求显示流畅且分析功能多再用上位机方案。3.2 LCD 波形绘制最小值最大值压缩法下面是一个典型的 LCD 波形绘制函数输入是采集缓冲区、采样点数、屏幕宽度和高度。它先按屏幕宽度分段再从每一段里找出最小值和最大值映射到屏幕坐标系后画一条竖线。这样能在不牺牲幅度精度的情况下把任意长度数据画到有限宽度的屏幕上。void osc_DrawWaveform(uint16_t *buf, uint32_t len, uint16_t screen_w, uint16_t screen_h) { uint32_t seg_len len / screen_w; // 每像素对应的采样点数 uint16_t x, y_min, y_max; uint32_t i; if (seg_len 0) seg_len 1; // 数据不够时至少取一点 lcd_ClearRegion(0, 40, screen_w, screen_h - 40); // 清波形区保留标题栏 for (x 0; x screen_w; x) { uint16_t min_val 0xFFFF; uint16_t max_val 0; for (i x * seg_len; i (x 1) * seg_len i len; i) { if (buf[i] min_val) min_val buf[i]; if (buf[i] max_val) max_val buf[i]; } // 把 ADC 值映射到屏幕 Y 坐标0~4095 映射到屏幕高度 y_min screen_h - 1 - (uint32_t)(min_val * screen_h / 4095); y_max screen_h - 1 - (uint32_t)(max_val * screen_h / 4095); lcd_DrawVLine(x, y_min, y_max, LCD_COLOR_GREEN); } }这个函数里screen_h用的是 4095 作为满量程因为 STM32 ADC 是 12 位。设计时如果电压输入经过运放调理成 0~3.3V这个映射关系刚好对应 0~3.3V 的垂直灵敏度。段内扫描seg_len个点的成本是 O(n)对 400 像素宽度、每段 20 个点来说总扫描量 8000 次在 72MHz 主频下耗时约 1~2ms完全可以接受。如果想做得更快可以先把 min/max 数组算好再批量画竖线但基本逻辑不变。拖影和闪烁是 LCD 示波器最常见的视觉问题。解决方法是把波形区单独开一个显存数组每次刷新先清显存再画新波形最后一次性刷到 LCD。直接整屏清屏会造成明显的闪烁因为 LCD 控制器逐行刷新需要时间人眼能感知到中间状态。3.3 虚拟示波器的串口帧协议设计如果走串口上位机路线帧协议直接决定了数据的可靠性和解析难度。不要直接发原始采样值因为 0x00 和 0xFF 在串口调试助手里看不出边界也别用 printf 发 ASCII 码100kSps 的原始数据根本编不完码。实用的二进制帧格式如下字段长度说明帧头2字节固定0xAA 0x55用于接收端同步数据长度2字节表示后面数据区有几个字节小端通道ID1字节0xA1 表示 CH1预留多通道扩展位采样率编码1字节0~10对应上位机预设采样率表数据区N字节每两个字节一个 uint16_t 采样值小端校验1字节数据区所有字节异或错误就丢帧打包代码并不复杂我这里贴一个简化版本。它把一个缓冲区内容封装成协议帧通过 DMA 串口发送出去。void osc_PackAndSend(uint16_t *buf, uint32_t len, uint8_t rate_code) { static uint8_t tx[1024]; // 发送缓冲按帧长度调整 uint32_t data_bytes len * 2; // uint16_t 转字节 uint32_t i; tx[0] 0xAA; tx[1] 0x55; tx[2] data_bytes 0xFF; tx[3] (data_bytes 8) 0xFF; tx[4] 0xA1; // CH1 tx[5] rate_code; uint8_t xor_sum 0; for (i 0; i len; i) { tx[6 2*i] buf[i] 0xFF; tx[6 2*i 1] (buf[i] 8) 0xFF; xor_sum ^ tx[6 2*i]; xor_sum ^ tx[6 2*i 1]; } tx[6 data_bytes] xor_sum; // 串口 DMA 发送整包 USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE); DMA_Cmd(DMA1_Channel4, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel4, 6 data_bytes 1); DMA_Cmd(DMA1_Channel4, ENABLE); }这段代码里的小端字节序值得特别强调。STM32 是小端架构直接把 uint16_t 的指针转成 uint8_t 或按位拆分时低字节在前。如果上位机用 C# 的BinaryReader.ReadUInt16()默认也是小端不用处理如果用 Python 的struct.unpack要注意指定H格式。校验用异或而不是 CRC 是为了省 CPU 时间串口传输场景下偶发错误丢一帧就行不需要重传机制因为示波器是连续流数据用户看到的是最新几帧。上位机解析时还有一个常见坑数据里可能恰好出现0xAA 0x55接收端会被误导去解析一个新帧。处理办法有两个一是帧头后面紧跟的「数据长度」字段必须与当前状态机匹配长度不对直接丢弃重新同步二是用奇偶校验或更长的魔数0xAA 0x55 0x5A 0xA5来降低误判概率。我在自己的虚拟示波器上位机里用的是后一种方案多两个字节的代价换来更好的同步稳定性。4. 触发与时基档位的程序实现让混乱的波形“站住”4.1 波形乱跑的本质每次刷屏的起点不一致接上信号发生器输出一个 1kHz 方波采样率 100kSps缓冲区里存了 2000 个点。每次刷屏都从缓冲区头画到缓冲区尾你会看到波形在屏幕上水平滚动永远停不下来。原因很简单DMA 是循环写入的每次缓冲区满时开始处理数据的时刻完全不同相当于每个屏幕波形取的相位起点是随机的。示波器若要稳定显示周期信号必须在数据中找到一个固定条件例如电压跨过某个阈值的上升沿把那个位置作为显示的起点。这个动作就是「触发」。软件触发不增加任何硬件成本只需要在缓冲区里做一次线性扫描非常适合单片机实现。上升沿触发的判定条件是当前采样值大于等于触发阈值trigger_level且前一个采样值小于该阈值。找到满足条件的位置后以它为第 0 个点绘制整屏波形。4.2 软件触发实现找上升沿并重排显示起点下面这段代码演示了如何从缓冲区内找到最近的上升沿触发点并计算出显示缓冲区应该从哪里开始取数据。int32_t osc_FindTriggerIndex(uint16_t *buf, uint32_t len, uint16_t level) { uint32_t i; for (i 1; i len; i) { // 上升沿前一个点低于阈值当前点高于等于阈值 if (buf[i-1] level buf[i] level) { // 简单线性插值把触发点细化到亚采样间隔 // 返回值为定点数低 8 位表示小数部分 uint32_t frac ((uint32_t)(level - buf[i-1]) 8) / (buf[i] - buf[i-1] 1); return (int32_t)((i - 1) 8) frac; } } return -1; // 没找到触发点 }函数返回一个定点数触发索引整数部分是缓冲区下标小数部分用于跨采样点插值。frac的计算利用了相似三角形原理在前后两个采样点之间认为信号是线性的由此估算精确的触发时刻。绘制时从触发索引对应的位置开始向后取满一屏的点。如果找不到触发点说明垂直阈值设置得不在信号幅度范围内这时应该退回「自由滚动」模式以免屏幕变黑。触发逻辑放在主循环里每次 DMA 传完一整批数据后执行一次。要注意 DMA 循环模式下处理线程读到的缓冲区位置是不断变化的无法保证快照一致性。所以一般在 DMA 传输完成中断里先把这次要处理的数据用memcpy拷到处理缓冲区再做触发和绘制。拷一份数据会占用几十微秒但对正确性至关重要。4.3 时基档位与采样率联动抽点而不是改定时器很多人会理所当然地认为改时基档位时应该重新配置采样率定时器比如 1ms/格用 100kSps100us/格就用 1MSps。这个思路没错但实现起来要时刻记得改采样率的时刻ADC 采集链路和 DMA 缓冲区的数据尺寸都要一起改而且改的过程中会丢掉一部分数据导致波形闪烁一下。另一种更平滑的做法是固定一个高采样率比如始终用 1MSps在不同时基档位下对缓冲区做抽点。水平 8 格、像素 400 列1ms/格需要 8ms 窗口对应 8000 个采样点每像素 20 个点100us/格对应 800 个点每像素 2 个点。此时只需要从 8000 点的缓冲区里均匀取出 800 点或者对每组 10 点取平均即可。平均抽取比直接丢弃点能保留更多垂直幅度信息和「每像素取 min/max」配合效果最好。档位采样率 (固定)窗口时间原始样本数抽取后点数每像素原始点1ms/格1MSps8ms8000800020100us/格1MSps0.8ms800800210us/格1MSps80us804001 (内插)1us/格1MSps8us84001 (内插)10us/格以下档位数据明显不足1MSps 在每个像素上只有不到两个点波形会显得很「秃」。这时两个选择提高采样率到 10MSps 以上——这通常超过 STM32F103 的 ADC 极限或者采用等效采样技术多次触发采样叠加重构。等效采样要求触发非常稳定适合周期性信号对非周期信号无效。F103 这类 MCU 建议档位范围控制在 10us/格到 10ms/格之间用户体验最好。4.4 触发电平如何用按键调节触发电平的调节直接影响触发可靠性。我习惯用三个按键加、减、切换通道。加/减每次改变 AD 值 16 个单位约 13mV按 0~3.3V 量程算长按则连加速度从 1x 到 10x 递增。逻辑放主循环里用非阻塞方式检测按键状态void osc_HandleTriggerLevelKey(void) { if (key_plus.IsPressedLong()) { trig_level 32; // 长按加速步进 } else if (key_plus.IsPressed()) { trig_level 16; } if (key_minus.IsPressedLong()) { trig_level - 32; } else if (key_minus.IsPressed()) { trig_level - 16; } if (trig_level 4095) trig_level 4095; if (trig_level 0) trig_level 0; }设置完触发电平后把当前触发电平对应的电压值通过 LCD 或上位机显示出来方便用户理解。另一个经常被忽略的细节是触发电平不能大于信号峰值或小于信号谷值否则找不到触发点程序必须对这种状态做出指示——比如在屏幕上显示「NO TRIG」而不是让波形随意滚动否则使用者会误以为硬件坏了。5. 电压量程标定与频率测量从 AD 值走向真实物理量5.1 输入调理电路为什么 ADC 不能直接接信号STM32 的 ADC 输入范围是 0~3.3V超过这个范围会烧毁引脚或得到错误的削顶波形。实际测量中要面对 ±10V、±5V 或 0~10V 的常规信号因此前端必须有一级信号调理电路。常见做法是电阻分压加电压跟随器高阻输入保证不拉低被测信号再用同相放大或反相放大把信号搬到 ADC 的合适量程内。因为在单片机系统里用正负双电源比较麻烦很多开源示波器程序会配合一个单电源运放电路把双极性信号抬升到 1.65V 中点让 ADC 在 0~3.3V 范围内工作。一个典型的三档量程方案如下档位分压比后续增益输入电压范围ADC 电压范围1V/格1:10.5x±3.3V0.15~3.15V2V/格1:20.8x±6.6V0.15~3.15V5V/格1:51.0x±16.5V0.15~3.15V运放选用 LMV358 这类轨到轨器件带宽 5MHz 以上不至于衰减信号的高频分量。这个电路的输出中点设计在 1.65V对应 ADC 读数约 2048当输入为 0V 时采样值应该正好在中点附近实际会因为运放失调而有一个几十 mV 的偏差必须软件校准不能假设它是完全对称的。5.2 软件标定线性映射与零点偏移修正软件标定的目标是把 ADC 原始值0~4095转换成真实的电压值。公式很简单voltage (adc_value - zero_offset) * scale_factor。其中zero_offset是输入 0V 时读到的 AD 值scale_factor由分压电阻和运放增益共同决定。理论上可以按原理图计算但电阻有 5% 误差运放增益也不是理想的所以实际做法是输入已知直流电压测量 ADC 读数自动算出两个点两点确定一条直线。typedef struct { int32_t zero_offset; // 输入 0V 时的 AD 值 float scale_factor; // 每 LSB 对应的电压 } osc_cal_t; float osc_ConvertToVoltage(uint16_t adc_val, osc_cal_t *cal) { return ((float)((int32_t)adc_val - cal-zero_offset)) * cal-scale_factor; } // 校准函数输入已知电压 v_ref读出 adc_measured修正 scale_factor void osc_CalibrateScale(osc_cal_t *cal, float v_ref, uint16_t adc_measured) { float v_at_zero ((float)(0 - cal-zero_offset)) * cal-scale_factor; cal-scale_factor (v_ref - v_at_zero) / (float)(adc_measured - cal-zero_offset); }zero_offset的获取建议在上电时自动完成一次将输入通道短接到地连续采样 16 次取平均写入 Flash 对应的校准参数区。scale_factor可以在出厂时或首次使用时校正一次校正值存入外部 EEPROM 或 STM32 内部 Flash 的最后一个扇区。注意内部 Flash 擦写寿命有限校准数据不要频繁写入每次采样都写会很快磨损芯片。标定之后垂直灵敏度显示就可以做到和真实信号一致把电压值除以垂直格数在屏幕上标出每格电压。实际上用户更习惯看「Y 轴每格代表多少伏」而不是看一串 ADC 原始值。5.3 频率测量用输入捕获还是用采样数据分析测量信号频率有两种路线。一种是硬件方法配置一个定时器的输入捕获通道监测被测信号的上升沿两次捕获的计数值差即为周期。这种方案精度高、实时性好但它对信号调理电路有要求通道必须送到支持输入捕获的引脚上信号幅度还要被整形到触发阈值附近否则会误触发。另一种是软件方法直接用采样数据做过零检测或 FFT 峰值检测。后者不需要额外的引脚资源但受采样率限制对高频信号测不准。实际工程做法是两者结合如果采样率是信号频率的 10 倍以上软件测频足够直接在缓冲区里数过零次数除以窗口时间如果信号频率接近甚至超过采样率的 1/2说明欠采样必须改用硬件捕获。代码里可以用输入捕获模式做上升沿检测两次中断之间计数器增量就是周期void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) ! RESET) { static uint32_t last_capture 0; uint32_t now TIM_GetCapture1(TIM3); uint32_t period_clk now - last_capture; // 周期对应的定时器时钟数 last_capture now; if (period_clk 0) { freq_hz (float)TIM3_CLK_HZ / (float)period_clk; } TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); } }这段中断代码里有个经典的计数器溢出问题如果被测信号频率特别低now - last_capture会跨过定时器的自动重装值。STM32 定时器是 16 位以 72MHz 计数时最大测量周期约 0.9ms对应最低频率约 1.1kHz。测 10Hz 信号时计数器早就溢出多次了结果完全不正确。所以测量低频时必须开启TIM_Prescaler加大分频或者用 32 位定时器如 TIM2、TIM5或者用「溢出次数 剩余值」组合计数。我在自己的程序里是用溢出中断叠加的方式计算完整周期保证 1Hz~1MHz 范围内都有能看的精度代价是中断处理稍复杂一些。6. 进阶玩法FFT 频谱显示与余晖模式6.1 FFT 点数选择和频率分辨率的关系示波器除了看时域波形最常见的附加功能是频谱分析。STM32 做 FFT 的常用做法是调用 ARM 的 CMSIS-DSP 库arm_cfft_f32支持 16、64、256、1024 等点数的实数/复数 FFT。频率分辨率等于采样率除以 FFT 点数1MSps 采样率做 1024 点 FFT分辨率约 976Hz只能分清 kHz 级别的频率成分如果测试对象是 50Hz 工频干扰就完全看不出频率网格。因此做 FFT 功能时采样率和 FFT 点数的选择必须对应好输入信号的频率范围不是随便填一个 1024 就完事。先要明确一点FFT 分析的输入数据不宜直接拿触发后的波形缓冲那个数据的长度随时基档位变化而不固定抽取后的点数也不规则。尽量单独进行一次固定频率采样比如 100kSps、1024 点做 FFT分析范围 0~50kHz分辨率约 97.7Hz或者 10kSps、1024 点范围 0~5kHz分辨率 9.8Hz。这样每个档位对应一个「分析频率范围」和「频率分辨率」表格比每次动态计算清晰得多。CMSIS-DSP 做 FFT 的完整流程分三步定义浮点数组并填充采样值、用窗函数逐个乘进去、调用arm_cfft_f32和arm_cmplx_mag_f32求幅度。核心代码如下#define FFT_SIZE 1024 float32_t fft_input[FFT_SIZE * 2]; // 复数数组实部虚部交替 float32_t fft_output[FFT_SIZE]; // 各频率点的幅度 void osc_CalcFFT(uint16_t *adc_buf, float32_t sample_rate) { uint16_t i; // 1. 搬数据并加 Hann 窗 for (i 0; i FFT_SIZE; i) { float32_t win 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); fft_input[2*i] (adc_buf[i] - 2048.0f) * win; // 减直流分量 fft_input[2*i 1] 0.0f; // 虚部置零 } // 2. 执行复 FFT arm_cfft_f32(arm_cfft_sR_f32_len1024, fft_input, 0, 1); // 3. 求复数幅度结果在 fft_output[i] arm_cmplx_mag_f32(fft_input, fft_output, FFT_SIZE); // 4. 前 FFT_SIZE/2 个点对应 0 ~ sample_rate/2 的频率 for (i 0; i FFT_SIZE/2; i) { float32_t freq (float32_t)i * sample_rate / FFT_SIZE; // 用 freq 和 fft_output[i] 绘制光谱图 } }代码里adc_buf[i] - 2048.0f是为了去除直流分量。如果输入信号含有 2.5V 直流偏置FFT 的 0Hz 位置会出现一个巨大的分量把其他频率成分压得看不见。减掉 ADC 中值后直流分量基本消失。Hann 窗的作用是减少频谱泄漏因为 FFT 默认把截断信号当周期信号处理对非整数周期的正弦波会产生严重的旁瓣泄漏加窗后主瓣变宽但泄漏大幅降低。6.2 FFT 幅度校正为什么测出来的 1V 变成了 0.5V加 Hann 窗后信号能量会被窗函数「吃掉」一部分因为窗函数在边缘衰减到 0。实际幅度会偏小正弦波的峰值会缩到原来的约 0.5 倍。要还原真实幅度需要对幅度结果乘以校正系数。常见做法是用窗函数的相干增益Coherent GainHann 窗约等于 0.5所以幅度要近似乘以 2。更精确的做法是根据输入输出能量算 RMS 校正通常在该档位下用一个标准正弦波实测得出校正系数并存表。工程上最省事的就是上面说的在基准档位输入已知幅度的正弦波程序自动算出每个 FFT 点对应的电压换算常数后续直接查表乘。处理频谱峰值时只用arm_cmplx_mag_f32输出最大值还不够。两相邻频点之间的峰值会被量化到最近的频点上差值可能有几 Hz 的误差。想读出更精确的信号频率可以在峰值点附近做抛物线插值取峰值的左右两个频点用二次函数拟合顶点位置这样能把测量精度提升一个数量级代码也就多十几行。6.3 余辉模式的等效实现用叠加代替色阶数字示波器的余辉模式能让高频毛刺以较暗的颜色留在屏幕上帮助观察偶发异常。LCD 方案做真正的多级余辉需要显存支持 Alpha 混合对无显存的 STM32 来说开销太大。等效做法是维护一个「余辉帧缓冲」每次新波形绘制后不直接清屏而是把余辉缓冲整体做一次衰减比如每个像素的亮度减半然后把新波形以最大亮度画上去。这样旧波形会逐渐淡出新波形保持高亮视觉上就形成了余辉效果。#define SCREEN_W 400 #define SCREEN_H 240 uint8_t phosphor_buf[SCREEN_W][SCREEN_H]; // 余辉强度 void osc_UpdatePhosphor(void) { uint16_t x, y; for (x 0; x SCREEN_W; x) { for (y 0; y SCREEN_H; y) { if (phosphor_buf[x][y] 1) { phosphor_buf[x][y] 1; // 每帧衰减一半 } } } // 新波形对应的像素点设为最大强度 255 // 再一次性把 phosphor_buf 按灰度刷到 LCD }实现时注意 1会让亮点的半衰期固定为 1 帧衰减速度无法调节。想做成可调衰减速度可以把右移位数做成变量phosphor_buf[x][y] - decay_step的效果更像模拟示波器的荧光余晖。余辉模式的典型应用场景是捕捉偶发的毛刺信号这类信号频率通常较低单次触发很难抓到而余晖模式让所有触发看到的历史波形都留在屏幕上异常尖峰自然会以不同亮度的轨迹出现。配套使用时建议把采集模式设为自由运行并关闭单次触发让波形持续刷新余晖的累积效果才会好。FFT 和余辉这两个功能加进去之后STM32 示波器程序在功能上就比较接近一台入门级数字示波器了。调试中如果屏幕一片黑或者波形幅度异常优先检查的不是绘图函数而是 ADC/DMA 配置里最容易漏掉的ADC_ExternalTrigConvCmd(ENABLE)——很多人配了外部触发源却忘了使能外部触发命令ADC 就会一直不启动转换DMA 缓冲区始终保持初始值表现形式恰好就是「程序跑起来了但示波器什么都没显示」。本文还有配套的精品资源点击获取
返回列表