简介:面向嵌入式开发者的STM32周期波形信号识别与参数测量完整工程,覆盖ADC采样、DMA传输、定时器中断、数字滤波及串口输出等关键环节。压缩包共207个文件,约747KB,以100个.h头文件与96个.c源文件为主体,并包含Keil工程文件、编译生成的hex固件、辅助脚本及相关配置文件,可直接打开工程编译调试。工程实现了频率、周期、幅度、占空比等参数的实时测量,通过定时器捕获与ADC+DMA协同工作,结合滑动平均、中值滤波等算法提升抗干扰能力,代码结构清晰,注释完整。已有1554人学习使用,适合嵌入式入门及中级开发者学习波形分析思路,也可直接移植至工业控制、自动化设备等场景进行二次开发。
1. STM32周期波形识别不只是测频率,先分清测量原理
同样是测一路 50Hz 方波,用定时器输入捕获拿到 50.012Hz,而用 ADC 采样加过零检测算出来却是 50.000Hz——差距不在硬件,在测量原理。STM32周期波形信号识别和参数测量,解决的不是"读个频率寄存器"那么简单:你得先判断信号是不是周期波、波形类型是什么、一个周期从哪里开始,再谈频率、脉宽、占空比和幅值。
"识别"两个字决定了这条路以采样加算法为主,不单靠外设。电机测速、电源纹波分析、传感器校准这类场景里都能直接套用。适合手里有一块开发板、想自己写信号处理代码的人,也适合做产品原型时需要把波形参数量化出来的工程师。时域算法做周期测量,比 FFT 的频率分辨率更高,也更直观,本文就按"采集—识别—测参—精调"这条线展开。
2. STM32波形识别的两条路线:定时器输入捕获与ADC+DMA采样
2.1 先搞清"识别"和"测量"的硬件边界
定时器输入捕获本质上是一种事件计数:信号上升沿来了,硬件把当前计数器的值锁进捕获寄存器,两次捕获值相减就是周期。它只能告诉你"沿"什么时候到,完全看不到波形中间长什么样。所以 stm32定时器捕获测频率 这类经典教程做得再好,也解决不了"这是正弦波还是方波"这种识别问题。
ADC 采样才是形态识别的正确出发点。把波形按固定间隔搬进内存后,周期、脉宽、幅值、RMS、波形类型全都变成数组上的数学运算。代价是采样率和存储空间要吃资源。STM32 的 ADC+DMA 组合,正是为了把"采样"这个动作从 CPU 里解放出来,让算法只面对一块连续的内存。
2.2 两条路线的原理与误差特性
输入捕获测频率的公式很简单:f = TIM_CLK / (CCR_n − CCR_(n−1))。误差来源有三个:时基时钟本身的 ppm、信号噪声引起的边沿抖动、以及用中断读 CCR 时的延迟。频率越高,计数器计数值越小,同样 ±1 个计数的抖动在误差里占比越大。所以测高频脉冲的正确姿势是"测 N 个周期再除以 N",而不是卡在单周期上。
ADC+DMA 路线的核心是采样率。采样定理要求 fs > 2f,实际做参数测量我一般取信号最高频率的 10 到 20 倍。比如 1kHz 正弦波,采样率至少 10kSPS,想看清上升沿细节就要更高。时间基准由定时器触发保证,采样点间隔严格对齐,之后所有参数都建立在这个时间轴上。
| 对比项 | 定时器输入捕获 | ADC+DMA 采样 |
|---|---|---|
| 频率测量 | 快且准,CPU 开销极低 | 受采样率限制,但算法可补偿 |
| 占空比测量 | 双沿捕获,精度高 | 需阈值判断,依赖基线 |
| 波形类型识别 | 不能,只看到边沿 | 能,靠形状判断 |
| 抗噪声 | 边沿抖动直接进结果 | 滞后比较+插值可抑制 |
| 适用信号 | 方波、脉冲、电机转速 | 正弦波、方波、三角波、任意周期波 |
2.3 怎么选:什么时候该放弃输入捕获
只测频率和转速,输入捕获是性价比最高的方案,一个定时器搞定,连 DMA 都省了。但只要需求里出现"幅值、RMS、波形类型、占空比同时要"这些字眼,输入捕获就无能为力了。
我一般这样判断:信号是纯方波脉冲,走捕获;信号有可能是正弦波、三角波、畸变波形,或者要求同时输出频率和幅度,走 ADC+DMA。标题里的"信号识别"决定了本文以 ADC 方案为主线,输入捕获只作为对拍验证的手段。两边同时测,还能交叉检验算法有没有算错周期。
3. STM32波形采集链路:定时器触发ADC+DMA双缓冲
3.1 为什么是定时器触发ADC,而不是ADC连续采样
ADC 自由运行模式下,转换间隔由 ADC 时钟和采样时间决定,软件很难精确控制"每个点隔多久"。频率测量一旦建立在不确定的时间间隔上,结果就不可信。定时器触发把采样时刻锁到硬件上:TIMx 的更新事件通过 TRGO 引脚直接触发 ADC 转换,转换完成再由 DMA 搬进内存。CPU 全程不参与采样时刻的决定,这是参数测量里时间戳准确的关键。
CubeMX 里勾选 ADC 的"External Trigger Conversion Source"为 Timer 2 Trigger Out,同时把 DMA 设为 Circular 模式,硬件链路就通了。用 HAL 库写,寄存器细节被封装掉,但要注意 ADC 的触发边沿和定时器 TRGO 的映射关系。工程从一款 MCU 换到另一款时,这部分通常在重新生成时钟树后要重新确认。
3.2 最小采集配置:TIM2触发ADC1,DMA双缓冲
// 假设 ADC1 挂在 APB2,TIM2 时钟 84MHz,预分频后 1MHz #define ADC_BUF_LEN 1024 static uint16_t adc_buf[ADC_BUF_LEN]; void Waveform_ADC1_Init(void) { // ADC1:12 位分辨率,单通道,扫描关闭,外部上升沿触发 hadc1.Instance = ADC1; hadc1.Init.ClockPrescaler = ADC_CLOCK_SYNC_PCLK_DIV4; hadc1.Init.Resolution = ADC_RESOLUTION_12B; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode = DISABLE; hadc1.Init.EOCSelection = ADC_EOC_SINGLE_CONV; hadc1.Init.ContinuousConvMode = DISABLE; // 关键:不自由运行 hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; HAL_ADC_Init(&hadc1); // DMA:外设到内存,半字对齐,循环模式 hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.Mode = DMA_CIRCULAR; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, hdma_adc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN); } void Waveform_TIM2_Trigger_Init(uint32_t sample_interval_us) { // 预分频 84-1 得 1MHz,Period 决定触发间隔 htim2.Instance = TIM2; htim2.Init.Prescaler = 84 - 1; htim2.Init.Period = sample_interval_us - 1; HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef sMasterConfig = {0}; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); HAL_TIM_Base_Start(&htim2); }触发间隔由Prescaler和Period共同决定:预分频把 84MHz 降到 1MHz,Period 填 999 时每隔 1000 个计数即 1ms 触发一次 ADC。1ms 对应采样率只有 1kSPS,适合 50Hz 工频信号;测 kHz 级波形时把 Period 调到 9,采样率就变成 100kSPS。采样时间参数在HAL_ADC_ConfigChannel的SamplingTime里,信号源内阻大时选 28.5 周期以上,高速信号选最短档,避免采样电容没充满就断开了。
双缓冲的两种中断标志分别代表前半段和后半段数据就绪,处理主循环只需要看标志位:
volatile uint8_t adc_half_done = 0, adc_full_done = 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { adc_half_done = 1; // adc_buf[0 .. 511] 已就绪 } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { adc_full_done = 1; // adc_buf[512 .. 1023] 已就绪 } while (1) { if (adc_half_done) { adc_half_done = 0; Process_Waveform(adc_buf, ADC_BUF_LEN / 2); } if (adc_full_done) { adc_full_done = 0; Process_Waveform(adc_buf + ADC_BUF_LEN / 2, ADC_BUF_LEN / 2); } }这两个回调跑在中断上下文,里面只置标志位,不要在回调里做浮点运算或调用 HAL_Delay。DMA 还在持续往内存写数据,处理函数消耗的时间一旦超过半个缓冲的时间窗口,新数据就会覆盖正在算的旧数据,波形会"撕裂"。这是 stm32 dma+adc hal 方案里最常见的坑。
3.3 验证采集链路的三板斧
第一,用示波器看一个空闲 GPIO 的翻转频率,确认定时器实际触发间隔和设定值一致,这一步排除时钟配置错误。第二,把 ADC 输入短接到 GND 和 VREF,分别读回 0x000 和 0xFFF 附近,确认量程正确。第三,输入一路已知 50Hz 正弦波,先用串口把 adc_buf 发出来在 PC 上画图,肉眼看波形轮廓正常再往下写算法。链路没验证就调算法,出了问题很难分清楚是采样坏了还是计算坏了。
4. 周期识别与参数测量算法:阈值、过零与RMS计算
4.1 先估算基线、幅值范围和信号质量
拿到一块缓冲数据后第一件事不是找周期,而是算基线。对纯交流信号,直接求平均值就是直流分量;对叠加了偏置的信号,平均值就是那个偏置。最大最小值和平均值一起决定了后面所有阈值的放法。
uint16_t vmin = 0xFFFF, vmax = 0; uint32_t sum = 0; for (int i = 0; i < ADC_BUF_LEN; i++) { if (adc_buf[i] < vmin) vmin = adc_buf[i]; if (adc_buf[i] > vmax) vmax = adc_buf[i]; sum += adc_buf[i]; } uint16_t vmid = (vmin + vmax) / 2; // 粗略基线 uint16_t vspan = vmax - vmin; // 峰峰值 float vscale = 3.3f / 4096.0f * (1.0f / gain); // 分压系数修正vmid是用最大最小值的中心估的基线,速度快但受噪声尖峰影响。如果信号本身很干净,这个值够用;如果现场干扰大,改成先排掉前后 5% 的极值再取中心更稳。vspan太小说明信号没进来或者幅度太低,识别无从谈起,我一般设一个门槛:vspan < 50时直接报"无有效信号"。这里的 50 是 12 位 ADC 的码值,对应约 40mV,具体值按自己系统噪声底调。
4.2 带滞回的过零检测,找到所有上升沿
用单一阈值判断电平会死在噪声上:信号在阈值附近抖动几次,软件就数出好几个假周期。带滞回的比较器能解决这个问题,思路是离开中心线一段距离才算状态改变。
#define HYST_RATIO 0.05f // 滞回宽度取幅值 5% float low_th = vmid - vspan * HYST_RATIO; float high_th = vmid + vspan * HYST_RATIO; int rising_edges[16]; int edge_count = 0; int state_prev = (adc_buf[0] < low_th) ? 0 : (adc_buf[0] > high_th ? 1 : 0); for (int i = 1; i < ADC_BUF_LEN && edge_count < 16; i++) { int state_now = (adc_buf[i] < low_th) ? 0 : (adc_buf[i] > high_th) ? 1 : state_prev; if (state_prev == 0 && state_now == 1) { rising_edges[edge_count++] = i; // 找到一个上升沿 } state_prev = state_now; }滞回宽度取幅值的 5%,是折中值:太窄抗不住噪声,太宽会把小幅度的真实波动也滤掉。状态机的意思是,信号必须从低于low_th一路涨到高于high_th,中途回来不算数,这样在过零区域来回抖动的样本就被挡掉了。数组只存下标,之后算周期要用;容量 16 足够覆盖 1024 点缓冲里的低频信号,如果高频信号边沿多,要相应加大数组。
4.3 用线性插值把过零点算准,识别周期稳定性
采样点下标是整数,直接拿两个边沿下标相减,周期精度最多到 1 个采样间隔。1kSPS 下测 50Hz,一个点误差就有 0.1% 的量级。线性插值把过零点细化到分数位置,不需要提高采样率就能把分辨率提上去。
// 已知 i-1 点低于 vmid,i 点高于 vmid,求穿过 vmid 的分数下标 float t_cross = (float)(i - 1) + (float)(vmid - adc_buf[i - 1]) / (float)(adc_buf[i] - adc_buf[i - 1]);插值的前提是两点之间信号近似线性。过零附近正弦波本来就接近直线,所以这个假设成立;方波沿很陡,两点间样本差异大,插值依然稳定。得到所有上升沿的t_cross后,相邻差值乘以采样间隔就是一个周期的时间;更稳的做法是取(最后一个沿 - 第一个沿) / (边数 - 1),把每一个周期的随机抖动平均掉。
"识别周期波形"里"周期"二字应该落在稳定性上。连续几个周期长度偏差超过 5%,说明信号不是稳定的周期波,也许是变频器输出、调制的脉冲或者随机噪声。这时候频率值即使算出来也没有意义,算法应该输出"周期不稳定"的提示而不是硬给一个数。
4.4 频率、占空比、峰峰值与 RMS 计算
频率和占空比建立在过零序列上。占空比方波测量简单:统计一个周期内高于vmid的样本数除以总样本数,再用端点插值修正。正弦波和三角波的占空比概念不强,一般报告 50%,真正有意义的是 RMS。
float period_us = (cross[edge_count - 1] - cross[0]) / (edge_count - 1) * sample_interval_us; float freq_hz = 1000000.0f / period_us; float sum_sq = 0; for (int i = 0; i < ADC_BUF_LEN; i++) { float v = (adc_buf[i] - vmid) * vscale; sum_sq += v * v; } float rms = sqrtf(sum_sq / ADC_BUF_LEN);RMS 计算里先减掉基线vmid再平方,否则直流偏置会被算进功率里。得到的 RMS 是交流分量有效值,对 220V 市电采样也是这个思路。峰峰值就是vspan * vscale,记得乘分压系数和参考电压的缩放。占空比如果由脉宽和周期两个量分别算出来再相除,误差会叠加;直接在同一个过零区间里数点数,误差更小。
4.5 顺带识别波形类型:RMS/峰值比和最大斜率
波形类型识别不需要上深度学习,一个比值就够了。对偶极性周期信号,RMS 除以峰值幅度(峰峰值的一半)有固定特征:
| 波形 | RMS / 峰值 | 识别阈值参考 |
|---|---|---|
| 正弦波 | 0.707 | 0.62 ~ 0.78 |
| 三角波 | 0.577 | 0.50 ~ 0.62 |
| 方波 | 1.000 | > 0.85 |
比值算出来后,再补一个斜率校验:对数组做一阶差分找最大斜率。方波沿上相邻点差值接近满量程,正弦波最大斜率是 2πfA,三角波斜率恒定且较小。这两个特征组合起来,正弦、方波、三角波基本不会认错。噪声大的环境里多采几帧取平均,比值的方差会明显降下来。
5. 提高参数精度的三个技巧:过采样、周期平均与误差标定
5.1 用硬件过采样把 12 位 ADC"变"成接近 16 位
STM32 系列部分型号的 ADC 外设自带硬件过采样,把连续多次转换结果累加再右移,等效提高分辨率。4 次过采样加 2 位移位就能提升 1 位有效位,16 次加 4 位移位提升 2 位。代价是有效采样率下降,过采样率 16 时实际能吃进的信号带宽只有原来的 1/16。所以这个技巧只适合慢信号,比如 50Hz 工频或者传感器缓变波形。
hadc1.Init.OversamplingMode = ENABLE; hadc1.Init.Oversampling.Ratio = ADC_OVERSAMPLING_RATIO_16; hadc1.Init.Oversampling.RightBitShift = ADC_RIGHTBITSHIFT_4;配完这段,ADC 转换时间会明显变长,定时器触发间隔要相应放大,否则触发追不上转换,DMA 会漏数据。软件平均是另一种等价手段,对同一触发点多次采样求均值,能压掉随机噪声。硬件过采样不占 CPU,软件平均更灵活,二者取舍看 CPU 余量。
5.2 多点周期平均,消除沿抖动
单周期测出的频率总是带随机抖动。把连续 N 个上升沿之间的总时间除以 N,抖动按平方根关系摊薄。我在 4.3 里已经用了这个思路,工程上更讲究的做法是对所有相邻边沿间隔做一次标准差统计:
float mean_interval = (cross[edge_count - 1] - cross[0]) / (edge_count - 1); float variance = 0; for (int i = 1; i < edge_count; i++) { float d = (cross[i] - cross[i - 1]) - mean_interval; variance += d * d; } variance /= (edge_count - 1);标准差如果超过平均周期的 1%,说明信号本身有抖动或噪声污染,测出来的频率可信度要打个问号。这个统计量还能用来判断周期稳定性,比单看频率数字更全面。软件锁相是另一个方向,用上一次测出的频率去预测下一个过零点位置,然后在预测点附近小窗口里搜真实过零,对强噪声信号特别有效。
5.3 用信号发生器做整机标定
算法写的再漂亮,没标定都是自嗨。把信号发生器接到 ADC 输入,分别输出 100Hz、1kHz、10kHz 正弦波和方波,记录单片机算出的频率和幅值。频率误差应该主要来自晶振偏差;幅值误差几乎都来自参考电压VREF和分压电阻的精度。VREF 不准时,用芯片内部的VREFINT通道做比例校准,这是成本最低的补偿手段。
标定的另一个内容是确认 ADC 采样保持时间。信号源内阻 50Ω 一般没问题,但如果是高阻传感器,不配缓冲器直接接 ADC,采样时间不够会看到高频幅度明显衰减。对比信号发生器面板读数和 STM32 算出的峰峰值,高频段衰减超过预期就先查这个,别急着改算法。
5.4 4 个容易踩的坑
第一,DMA 半传输标志写进回调后,主循环处理时间太长导致覆盖,用示波器量处理函数的耗时,保证小于半缓冲周期。第二,定时器触发选错了 TRGO 事件,TIM2 用 PWM 输出模式时OCxREF和UPDATE两个触发源别混,配了 PWM 输出就不要再期望更新事件触发的频率和占空比设置一致。第三,调试器占用的 JTAG 引脚和 ADC 通道冲突,PB3、PB4 这类引脚做模拟输入时要先禁用对应调试端口,否则读数直接不对。第四,回调里用HAL_Delay或任何阻塞调用,中断优先级处理不好直接死锁,DMA 停在那,采样链路整个卡死。
验证精度最快的方法:同一路信号同时接示波器和 STM32,连续读 20 次数值,系统误差看平均值差,随机误差看标准差。两者都稳定后再固化标定系数,这套测量链路基本就算收工了。
本文还有配套的精品资源,点击获取