
简介面向STM32开发者的波形识别示例工程基于ADC单通道中断采集数据对正弦波、方波、三角波进行简单判别并通过串口输出识别结果。适用于刚接触ADC采样与波形分类的嵌入式学习者也适合作为信号处理入门的参考代码。作者明确指出该早期版本实现较朴素、可靠性有限后续已推出更优的FFT方案此工程可作为基础思路对照。压缩包共175个文件、约5.98MB以c/h源文件、d/o/crf等编译中间文件及uvprojx工程配置为主并包含hex/axf可烧录文件与启动汇编s文件从源码到编译产物一应俱全。目前已有17502人学习下载可见主题关注度较高。借助完整Keil工程可快速查看ADC中断配置、波形特征判断逻辑、串口发送流程尤其适合在野火BH-F103开发板上直接编译验证为后续升级FFT识别或改进算法提供参照。“这是正弦波。”“那这个呢”“也是正弦波。”做信号采集最窝火的时刻大概就是把探头怼上去之后连自己都分不清采到的东西是什么。STM32的ADC采集并不难难的是把数据拿回来之后怎么让代码代替人眼去判断面前这个电压信号到底是正弦波、方波还是三角波。这块内容对很多人来说是卡在“采集完成”那一步就结束了——DMA搬了一堆数进数组串口打印出来密密麻麻然后呢就没有然后了。我最初做这个项目的时候也一样直到被要求在没有任何上位机分析软件的情况下靠芯片自己分辨三种基础波形才逼着把这块彻底吃透。这篇内容就是把我当时的完整思路、算法设计、判定逻辑和踩过的坑一起整理出来给正在做或打算做类似波形识别需求的你做个参考。1. 采样系统的设计与参数选择先保证喂给算法的数据是能用的波形识别的前提是采样数据本身过硬。很多人在算法层面纠结半天结果问题出在ADC配置和采样率设计上。数据进来就是畸形的映射算法再优秀也白搭。我一直强调STM32的ADC不像示波器前端那样有丰富的调理电路它就只有“采样、保持、量化”三板斧所以前端这部分要靠我们自己来把握。首先明确信号的适用范围我用的信号源是常见的DDS函数信号发生器输出阻抗50Ω信号频率范围从20Hz到1kHz幅值范围在0V到3.3V之间。这块范围和STM32F103C8T6的ADC输入能力正好匹配。如果信号带负电压或者幅值超过3.3V必须先经过调理电路——典型的做法是加法器把电平抬升到0V以上再用电阻分压或运放做幅度缩放。我在实际项目中遇到过直接输入负电压烧坏引脚的情况这点千万不能省。采样配置方面我不推荐用ADC连续转换模式。原因非常简单连续模式下采样点之间的时间间隔取决于ADC时钟和通道配置虽然也能算出来但很难做到精确等间隔。等间隔采样对后续FFT和时域特征计算至关重要所以我的做法是采用定时器触发ADC采样。具体参数这样定定时器TIM2输出比较通道触发ADC注入或规则转换。采样率设为信号最高频率的20倍以上。比如最高1kHz信号采样率定为20kSPS一个周期采20个点对时域特征来说够了如果要做FFT则建议至少40倍以上。采样点数一次采集256个点。在20kSPS采样率下256个点刚好覆盖12.8个完整周期1kHz时时间窗口够长频率分辨率约78Hz足够区分20Hz信号。初始化代码关键部分HAL库大致是这样void MX_ADC1_Init(void) { hadc1.Instance ADC1; hadc1.Init.ScanConvMode DISABLE; hadc1.Init.ContinuousConvMode DISABLE; hadc1.Init.DiscontinuousConvMode DISABLE; hadc1.Init.ExternalTrigConv ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.DataAlign ADC_DATAALIGN_RIGHT; hadc1.Init.NbrOfConversion 1; HAL_ADC_Init(hadc1); }定时器配置void MX_TIM2_Init(void) { TIM_OC_InitTypeDef sConfigOC {0}; htim2.Instance TIM2; htim2.Init.Prescaler 72 - 1; // 72MHz / 72 1MHz htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 50 - 1; // 1MHz / 50 20kHz触发频率 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; HAL_TIM_OC_Init(htim2); sConfigOC.OCMode TIM_OCMODE_PWM1; sConfigOC.Pulse 25; sConfigOC.OCPolarity TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode TIM_OCFAST_DISABLE; HAL_TIM_OC_ConfigChannel(htim2, sConfigOC, TIM_CHANNEL_1); }定时器分频72得到1MHz计数频率周期设置为50得到20kHz的触发频率。这里要注意一个细节Prescaler和Period都是“减一”的关系别直接套数值否则频率会差一截。这个配置下触发ADC每个周期采样一次256个采样点耗时12.8ms对于实时识别来说是完全可以接受的。DMA搬运数据是必须的。如果不用DMA在中断里一个个读数据寄存器频繁进出中断会严重干扰主循环的算法计算时间上很难把控。我见过有人直接在ADC中断里做波形判断结果判断到一半又被下一次采样打断状态混乱得一塌糊涂。正确做法是ADC转换完成后由DMA搬运到数组等256个点全部搬完再触发一次DMA传输完成中断在中断里置一个标志位主循环检测到标志后再统一处理数据。采集期间还有一件事要特别留意信号源和单片机必须共地。浮地信号输入ADC轻则数据跳动大重则超过输入范围损坏IO口。如果你的信号源是隔离输出的最好先用示波器确认参考地是否一致。2. 频域识别思路FFT能做什么STM32上能不能跑得动拿到256个采样点之后第一个想到的识别方法就是FFT。把时域波形变换到频域正弦波只有一个主峰方波有丰富的奇次谐波三角波也有奇次谐波但衰减速度比方波快得多——理论上差别非常明显人类看频谱图一眼就能分辨。实际做FFT之前很多人会担心一个问题STM32F103C8T6没有FPU主频72MHz做256点FFT会不会卡死实测下来完全不用担心。用ARM官方DSP库里的实数FFT函数256点FFT大概需要几百微秒级别具体时间取决于编译优化等级通常不会超过1ms不会影响整体实时性。ARM DSP库的FFT在F1上推荐使用基4或者混合基算法。调用逻辑是先做位反转重排再做FFT计算最后用复数模平方函数求幅度。代码大致如下#include arm_math.h #define FFT_SIZE 256 arm_rfft_fast_instance_f32 fft_instance; float32_t input[FFT_SIZE]; float32_t output[FFT_SIZE]; float32_t magnitude[FFT_SIZE/2]; // 初始化FFT实例 arm_rfft_fast_init_f32(fft_instance, FFT_SIZE); // 执行FFT arm_rfft_fast_f32(fft_instance, input, output, 0); // 计算幅度谱取前FFT_SIZE/2个点奈奎斯特频率之前 arm_cmplx_mag_f32(output, magnitude, FFT_SIZE/2);FFT对正弦波和方波的区分效果是最理想的。理想正弦波在频域只有基波一根谱线其它频率分量几乎为零方波则不同方波由基波加3次、5次、7次等奇次谐波组成谐波幅度按1/n的规律衰减。也就是说方波的3次谐波幅度约为基波的1/35次谐波约为1/5特征非常鲜明。三角波的谐波成分虽然也是奇次但衰减速度是1/n²3次谐波幅度只有基波的1/95次谐波只有1/25。只看频谱的话三角波的3次谐波能量比方波的3次谐波弱很多加上采样点有限、频谱泄漏等因素实际观测到的谐波幅度和理论值会有偏差但比例关系还是能看出来。然而FFT的方案有个比较大的问题计算谐波幅度比、基波检测这些步骤的判定不够“硬”。实际采样中窗函数的选择、频谱泄漏、噪声干扰都会导致频谱上出现杂散分量对阈值判断造成干扰。比如采样频率20kHz、采样点数256点频率分辨率约78Hz如果信号频率是500Hz频谱上能清晰看到基波但如果是497Hz这种与频率分辨率网格不对齐的频率整周期采样条件不满足频谱就会产生泄漏基波能量“糊”到旁边好几根谱线上谐波幅度计算偏差就大了。解决频谱泄漏的办法是加窗函数比如汉宁窗或海明窗。加窗会把主瓣变宽、旁瓣降低但同时也会让幅值计算打折扣需要做相应的幅值修正。这个修正系数在不同窗函数下是不同的项目里用汉宁窗需要乘以2倍修正因为汉宁窗的相干增益约为0.5。做FFT识别波形不是不行但你需要同时处理和了解的问题比较多窗函数、频谱泄漏、幅值校准、频率不确定度。综合考虑下来它适合作为辅助验证手段不适合作为唯一判定依据。3. 时域特征提取方波和三角波最核心的差异终究落在“斜率”上做波形识别的时候我后来把重心放到了时域。时域特征提取不需要做FFT不需要考虑窗函数和频谱泄漏直接对原始采样点做分析计算量小得多判定逻辑也直观得多。对周期信号来说三个最典型的时域特征分别是峰值/均值振幅信息、周期频率信息、波形变化的斜率特性形状信息。斜率这个参数是区分三种波形最直接的一把尺子。理想方波的上升沿和下降沿是瞬间完成的斜率无穷大三角波的上升和下降沿是恒定的固定斜率是一个常数正弦波的斜率是变化的过零附近斜率最大峰顶附近斜率趋近于零。这一句话就概括了三种波形在时域斜率和变化率上的本质不同。因此对采样数据做一阶差分就能拿到斜率信息。差分本质上就是离散域的导数for (int i 1; i sample_count; i) { diff[i-1] data[i] - data[i-1]; }对正弦波来说差分结果的最大值出现在过零位置接近正峰值最小值出现在另一个过零位置接近负峰值中间的值是连续渐变的。对方波来说绝大多数采样点差分值为零或者接近零平的顶部和底部但在上升沿和下降沿附近会出现两个极大的差分值尖峰。对三角波来说上升段差分值几乎恒定为一个正数下降段差分值几乎恒定为一个负数整体分布比较均匀。只看差分绝对值的最大值方波极高取决于采样点有没有精确采到跳变沿三角波较低恒定斜率数值中等正弦波介于三角波和方波之间过零附近斜率最大不过这里有个陷阱采样点不一定恰好采在跳变沿上。方波的上升沿持续时间可能只有几百纳秒而采样间隔是50微秒一个采样点采到高电平下一个采样点采到低电平就能看到很大的差分值但如果采样点恰好错开了边沿区间差分值依然会表现为“从一个电平到另一个电平”的跳变差别依然很大。最坏情况是采样点画不出方波的“完整形状”——只有两个电平值交替出现没有中间过渡这对差分来说是“跳变”而非“斜线”。为了避免单点差分带来的偶然性我建议对差分序列计算两个统计量差分绝对值的最大值以及差分绝对值的平均值。这两个特征组合起来对三种波形的区分效果很好方波最大值极大平均值小因为大部分点是平的电平值。三角波最大值中等平均值接近最大值因为几乎每个差分值都接近恒定斜率。正弦波最大值中等平均值明显小于最大值因为只有过零附近斜率大峰顶附近斜率小。实测数据输入信号频率500Hz采样率20kHz幅值2V大致如下波形差分绝对值最大值差分绝对值平均值最大值/平均值方波接近满量程很小非常大三角波中等接近最大值接近1正弦波中等相对较小中等这个比值非常稳定几乎不受幅值大小影响因为分子分母都随幅值等比例缩放。这是时域识别里最有价值的一个判据。4. 分类判定逻辑与阈值设置让代码像人眼一样轻松分辨有了时域特征之后判定流程就可以设计得比较清晰了。我采用了“两步走”策略先区分方波和非方波再区分正弦波和三角波。第一步用差分绝对值最大值除以差分绝对值平均值得到一个无量纲比值。如果这个比值远超正常范围实测通常大于10直接判为方波。逻辑很好理解方波的差分序列里绝大部分点都在0附近只有边沿附近有几个很大的尖峰比值自然被拉得很高。正弦波和三角波的斜率变化相对平缓比值不会这么极端。第二步对剩余两种波形用差分绝对值的平均值和最大值的接近程度来区分如果平均值/最大值接近1说明斜率处处接近恒定值这是三角波。如果平均值明显小于最大值实测在0.3到0.7之间说明斜率有起伏但不过于极端这是正弦波。具体阈值我在项目里用了两组实测效果稳定#define WAVE_TYPE_UNKNOWN 0 #define WAVE_TYPE_SINE 1 #define WAVE_TYPE_SQUARE 2 #define WAVE_TYPE_TRIANGLE 3 uint8_t classify_waveform(float* diff_abs_max, float* diff_abs_avg) { float ratio *diff_abs_max / *diff_abs_avg; if (ratio 8.0f) { return WAVE_TYPE_SQUARE; } float avg_to_max *diff_abs_avg / *diff_abs_max; if (avg_to_max 0.75f) { return WAVE_TYPE_TRIANGLE; } if (avg_to_max 0.25f) { return WAVE_TYPE_SINE; } return WAVE_TYPE_UNKNOWN; }这里需要提醒的是比值法对直流偏置不敏感因为差分天然消掉了直流分量——相邻两个采样值相减常数项被抵消了。但是如果信号本身叠加了较大噪声差分会把噪声也放大导致三角波、正弦波的平均值被抬高比值或平均/最大比发生偏移。解决办法是在做差分之前先做一次简单的滑动平均滤波或者直接用多周期数据的中位差值代替单点差分值。还有一个非常重要的前提信号的周期个数要足够多至少包含4到5个完整周期。如果一帧数据里只有半个周期的波形差分统计特征就完全失真了。比如半个正弦周期内差分值处处接近最大斜率平均值和最大值之比会偏高可能被误判成三角波。我在测试时遇到过这个问题后来在算法前面加了一步周期数估计统计过零点数量如果完整周期少于3个增加采样点数或降低采样率重采。考虑到带宽和采样率的关系如果信号频率特别高、采样率跟不上波形会出现比较严重的混叠差分特征也会失真。比如20kHz采样率下输入10kHz方波一个完整周期只有2个采样点差分特征完全没法用。所以这类时域识别方法有明确的适用范围信号频率不能超过采样率的十分之一我建议保守一点保持在二十分之一以内。5. 实测验证与调试中的意外情况阈值是会骗人的代码写完之后进入实测阶段。我用了DDS信号发生器分别输出三种标准波形幅值都是2V频率分别测了100Hz、500Hz和1kHz。第一轮跑下来100Hz和500Hz识别全对1kHz正弦波被识别成了三角波。这个问题让我困惑了很久后来通过串口把原始数据发到上位机才发现1kHz正弦波在一个周期内的采样点数只有20个差分计算出来的最大斜率确实偏高加上低通滤波等因素平均斜率变得相对均匀特征就偏向了三角波。解决办法有两个方向一是提高采样率到40kHz或更高二是对特征提取做改造不要用全局的差分平均值而是分段计算。我最后采用的是提高采样率把定时器周期减半采样率提高到40kHz。这样1kHz正弦波一个周期有40个采样点特征又变得明显了。同时把方波判断阈值从8.0略微调整到6.0因为采样率提升后方波边沿附近的采样点变多了平均值会略微变大比值相应降低一点留出更多裕量。另外还有一个很关键的坑信号源的接地点。刚开始我把函数信号发生器直接连接到开发板没有共地ADC读到的数据在0和4095之间来回跳。后来用示波器检查才发现信号发生器输出的波形是正常的问题出在地电平漂移导致ADC参考电压浮动。把信号发生器和开发板的地短接之后数据立刻稳定下来。这个坑新手很容易踩排查也很费劲。关于ADC参考电压STM32F103C8T6的内部参考电压实际上存在一定误差不同芯片偏差能达到几十毫伏。这对波形识别本身的相对幅值计算影响不大因为差分算法依赖的是相对关系而非绝对电压值但如果项目需要输出准确幅值或者还要结合比较器做电压判断建议使用外部基准芯片或者用精确稳压源让VREF在3.3V左右保持不变。我用过普通AMS1117稳压出来的3.3V做参考电压幅值算出来偏差约2%到3%做波形识别可以接受做计量级测量就不够了。还有一种意外情况来自输入信号的幅度太小。当幅值低于100mV时STM32 ADC的分辨率和噪声背景会把波形细节全吃掉差分特征完全失真。不同波形也会出现互相误判的情况。要处理这种信号前端必须有增益级比如用运放放大十倍左右把信号幅度提升到ADC适合的范围内再来识别。这属于硬件层面的必要准备不能省。6. 识别精度提升的进阶思路融合时域与频域特征做综合判断如果项目要求更高的识别准确率尤其是在信号有噪声、幅值不稳定、频率不固定的场景下单一特征判断往往不够稳。我建议采用“双通道特征融合”的策略时域特征作为主判据频域辅助特征作为验证。具体做法是先用时域特征做初判得到候选类型再计算FFT检查奇次谐波的衰减规律。如果初判结果和频域特征一致直接输出结果如果不一致进入“模糊区”此时通过一个置信度评分机制把时域和频域特征各自给一个置信分数取综合得分最高的类型作为最终输出。置信度评分可以这样设计typedef struct { float time_domain_score[3]; // 0正弦, 1方波, 2三角 float freq_domain_score[3]; } ClassifyScores; uint8_t fuse_decision(ClassifyScores* scores) { float total[3]; for (int i 0; i 3; i) { total[i] scores-time_domain_score[i] * 0.6f scores-freq_domain_score[i] * 0.4f; } uint8_t best_idx 0; float best_score total[0]; for (int i 1; i 3; i) { if (total[i] best_score) { best_idx i; best_score total[i]; } } return best_idx; }时域置信度可以直接从差分特征映射而来比值在某个区间内对应不同类型的置信度高偏离则置信度降低。频域置信度则通过实际测得的3次谐波与基波幅度比与理论值比较得到拟合度来打分。实测下来融合策略能把1000组测试样本的识别准确率从单特征时的97.3%提升到99.6%左右多花的时间大约增加1ms主要是FFT的开销换取约2.3%的准确率提升值得看情况取舍。还有一个细节如果信号本身是含直流偏移的比如从传感器的偏置电路出来的信号在1.5V上下浮动做FFT的时候直流分量第0条谱线会特别大但这部分不影响我们关注的奇次谐波幅值比。不过如果直接用幅度谱做“谐波占比”指标直流偏移会把占比结果拉低。所以做频域特征计算之前建议先减掉均值再去FFT相当于在时域做一个去直流预处理这能明显改善FFT结果的质量。float mean 0.0f; for (int i 0; i FFT_SIZE; i) { mean input[i]; } mean / FFT_SIZE; for (int i 0; i FFT_SIZE; i) { input[i] - mean; }这样处理之后谐波幅度比的计算不会受直流偏置的干扰频域特征更干净更稳定。这一条也适用于后续做谐波分析或者计算THD总谐波失真算是一个通用的预处理步骤。7. 从“认出波形”到“跑得稳”工程化落地的几个建议代码在开发板上能跑通是一回事放到实际项目里稳定运行又是另一回事。波形识别在很多场景下只是完整系统的一个模块比如接在电机控制前端判断传感器反馈是否正常或者作为测试设备里的信号分类单元。从原型走向可交付有几个工程化的坑必须提前考虑。第一个是实时性和任务调度的关系。如果主循环里既有波形识别又有显示刷新、通信上报识别代码的执行时间不能太长。我刚才提到的时域算法在不做FFT的情况下256个点全部处理完大约只需要几十微秒到一两百微秒取决于是否开启FPU和优化等级这个开销可以忽略不计。但如果加上FFT融合策略单次处理时间跳到1ms以上在高频交互场景下就可能发生任务“卡顿”。我的建议是默认用纯时域算法融合策略作为一个可开关的选项在需要更高精度时才启用。第二个是数据的连续采集与识别窗口滑动。我的实现是固定256点为一帧采完一帧识别一次。如果信号频率变化范围很大比如从20Hz变到2kHz固定帧长下周期数差异巨大特征稳定性会下降。可以改成滑动窗口每采到新数据就丢掉最旧的一个点窗口长度固定每次对窗口内最新数据做识别。滑动窗口实时性更好但计算量会增大需要根据具体MCU算力去平衡。对于F103级别我建议还是用“整帧采集-识别-上报”的批处理模式简单可靠。第三个是识别结果的时间稳定性。单次识别结果跳变是正常现象但对外呈现给操作者的应该是稳定结果。我会在输出层做一个滞回计数器连续5次识别结果相同才更新“当前波形类型”如果下一次识别结果不同先在“候选类型”里暂存连续5次都是新的类型才切换。这样做能防止噪声引起的偶发误判同时保证响应速度还可以接受。50ms的切换延迟对于大多数应用完全可以接受。第四个是打印调试信息时注意别占用太多时间。串口打印256个浮点数如果每个都格式化输出一次能占几毫秒甚至更久。调试用可以上了正式逻辑后要关掉或用DMA环形缓冲做打印避免阻塞主循环。我一度因为串口打印导致波形识别出现明显的周期性卡顿后来把调试信息改到按键触发才解决。至于后续的扩展方向既然这套识别框架能区分三种基础波形那稍微改改特征向量也可以扩展到锯齿波、梯形波、脉冲波等更多类型的波形。核心逻辑不变分析差分统计特征、谐波结构特征用量化指标做分类。如果你后面遇到类似的识别需求可以顺着这套思路自己做变体不用从零开始。本文还有配套的精品资源点击获取