STM32示波器源码解析:定时器触发ADC与DMA双缓冲
2026/9/16 19:41:48 网站建设 项目流程

简介:一套完整的 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 触发 ADC1,DMA 双缓冲搬运数据

下面这段代码是我常用的初始化逻辑,基于 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,可选prescaler=1period=360,计算得36MHz / 360 = 100kHzADC_SampleTime_28Cycles5表示单次转换需要 28.5 个 ADC 时钟周期,ADC 时钟设为 12MHz 时单次转换约 2.4us,对应最大采样率约 400kSps,这个档位的 ADC 采样时间够用。如果还想继续往上提采样率,就要缩小 ADC 采样时间。

2.3 采样率、时基与缓冲长度的关系表

示波器的水平轴不是简单地画采样点序号,而是要把时间和像素对应起来。假定屏幕横向 400 像素,每格 50 像素,8 格水平刻度,那么不同时基档位下需要的缓冲长度差异非常大:

时基档位总时间窗口50kSps 时所需点数1MSps 时所需点数每像素时间
1ms/格8ms400800020us
100us/格0.8ms408002us
10us/格80us4800.2us
1us/格8us820ns

从这个表能看出一个关键取舍:如果固定采样率 1MSps,在 1ms/格档位下需要一个可以容纳 8000 个采样点的缓冲区;如果缓冲区只有 4KB(2048 个 uint16_t),那只能看到前 2ms 的波形,屏幕上剩下的部分就是空的。所以示波器程序必须根据当前时基档位动态调整采样率或者抽点策略。常见做法是「高档位用高采样率,低档位降低采样率并抽点」——比如 1ms/格配 50kSps,1us/格配 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/格用 100kSps,100us/格就用 1MSps。这个思路没错,但实现起来要时刻记得:改采样率的时刻,ADC 采集链路和 DMA 缓冲区的数据尺寸都要一起改,而且改的过程中会丢掉一部分数据,导致波形闪烁一下。

另一种更平滑的做法是固定一个高采样率,比如始终用 1MSps,在不同时基档位下对缓冲区做抽点。水平 8 格、像素 400 列,1ms/格需要 8ms 窗口,对应 8000 个采样点,每像素 20 个点;100us/格对应 800 个点,每像素 2 个点。此时只需要从 8000 点的缓冲区里均匀取出 800 点或者对每组 10 点取平均即可。平均抽取比直接丢弃点能保留更多垂直幅度信息,和「每像素取 min/max」配合效果最好。

档位采样率 (固定)窗口时间原始样本数抽取后点数每像素原始点
1ms/格1MSps8ms8000800020
100us/格1MSps0.8ms8008002
10us/格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.15V
2V/格1:20.8x±6.6V0.15~3.15V
5V/格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_f32arm_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 Gain),Hann 窗约等于 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 缓冲区始终保持初始值,表现形式恰好就是「程序跑起来了,但示波器什么都没显示」。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询