简介:这是一份面向STM32初学者的标准波形识别示例工程,基于STM32F103内置ADC采集信号,通过计算RMS均方根区分三角波、方波、正弦波等常见波形。资源包共234个文件,压缩后约12.18MB,主体为C源码、H头文件、Keil工程文件(uvprojx)与STM32F10x固件库,另含编译中间文件、批处理脚本、说明文档等,目录按HARDWARE、DSP、SYSTEM、USER等模块划分,结构清晰,适合直接导入现有工程并对照理解代码组织。目前已有6876人学习或下载,可服务于嵌入式课程、数字信号处理入门、毕业设计等场景。通过完整示例,读者能掌握ADC通道与采样时序配置、RMS均方根推导与计算方法,理解正弦波RMS为峰值0.707倍、方波为峰值一半等波形判定依据;同时参考中断/轮询采集、RMS序列分析与结果输出等程序框架,为后续扩展更多波形识别功能提供可复用基础。
1. 为什么在单片机上也要分辨标准波形:一个“贴标签”的小模块就够了
三角波、方波、正弦波这类最基础的信号,过去在实验室里靠人眼看示波器,波形长什么样子一目了然。可一旦站到产线或自动化治具的角度,问题就变成了“如何让机器每毫秒自动说出一句它是正弦还是三角”。这时一个常见方案就落地了:STM32 用 ADC 按固定节奏抓一段波形,再靠软件在几十个采样点里提取峰峰值、过零次数、沿陡峭程度这几个特征,最后输出一个“波形标签”。
这类代码的价值不在测量精度,而在自动判型。信号频率已知、幅值基本稳定、只需要输出正弦/三角/方波其中的一个标签,比如信号源校准治具、变频器驱动板的自检前置环节、实验室波形模组的质检,都适用。从开始采集到出结果,控制在几个毫秒内,整块硬件成本只有几十元。
这篇文章不讲空泛的“波形识别框架”,而是按实际调试顺序走:先把采样参数调对,否则后面全是白算;接着提特征,再写判决逻辑;最后重点讲三个直接影响正确率的细节。整套代码不依赖上位机,也能在单片机里独立反复运行。方波、三角波、正弦波,三选一,这是最典型、也最容易上手的嵌入式波形判型场景。
2. 让 ADC“看”得准:采样触发与缓冲设计
2.1 为什么必须用定时器触发 ADC 而不是 while+delay
最早我见过有人用 delay 控制 ADC 采样,代码长这样:循环里先启动 ADC,等转换完成标志,读结果,塞进数组,然后 delay 固定时间。看起来也“等间隔”,但实际波形稍一抖动就出问题。因为 delay 本身有误差,循环里还有判断分支、中断抢先,间隔误差很容易到几个微秒。对 1kHz 信号做 FFT 时,这种非等间隔采样会让频谱泄漏明显增加,正弦波的能量分散到旁边一堆频点上,谐波特征直接失效。
定时器触发 ADC 是 STM32 上更常规的做法,由硬件 TIM 产生触发事件,ADC 在 TIM 的 TRGO 信号到来时自动开始转换,转换完成由 DMA 把结果搬进内存。整个过程不需要 CPU 中途干预,间隔严格由定时器计数决定,抖动可以忽略。这里直接给结论:只要你的算法里用到了过零、斜率微分或者 FFT 这些对采样间隔敏感的手段,就不要让循环去“凑”采样时间,交给片上的定时器更稳。
2.2 定时器触发 ADC:最小可用配置
下面这段是 F1 系列上的参考配置,思路对所有型号通用。关键是理解两条线:一条是 ADC 的外部触发源,必须选到 TIM2 的 TRGO;另一条是定时器的周期,决定了 ADC 触发频率。
#include "stm32f1xx_hal.h" #define ADC_BUF_LEN 128 #define TRIGGER_HZ 20000UL // 期望ADC触发频率:20kHz static ADC_HandleTypeDef hadc1; static TIM_HandleTypeDef htim2; static uint16_t adc_raw[ADC_BUF_LEN]; void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ScanConvMode = DISABLE; // 单通道采集 hadc1.Init.ContinuousConvMode = DISABLE; // 由TIM触发,不需要连续转换 hadc1.Init.DiscontinuousConvMode = DISABLE; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; // TIM2触发 hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; HAL_ADC_Init(&hadc1); } void MX_TIM2_Trigger_Init(void) { __HAL_RCC_TIM2_CLK_ENABLE(); htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz总线时钟,分频后1MHz htim2.Init.Period = (uint32_t)(1000000UL / TRIGGER_HZ) - 1; // 20kHz htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef master_cfg = {0}; master_cfg.MasterOutputTrigger = TIM_TRGO_UPDATE; master_cfg.MasterSlaveMode = TIM_MASTERSLAVEMODE_ENABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &master_cfg); HAL_TIM_Base_Start(&htim2); }这里的逻辑是:预分频器先把总线时钟降到 1MHz,ARR 寄存器再决定触发周期。Period 写 49 就是 1MHz / 50 = 20kHz。实际使用中,建议把期望触发频率定义成宏,后期根据信号频率统一改,而不是在多处硬编码。ADC 部分最需要注意的是触发源映射,F1 的 TIM2 TRGO 对应ADC_EXTERNALTRIGCONV_T2_TRGO,换到别的型号可能变成ADC_EXTERNALTRIGCONV_T3_TRGO或者另有编号。代码看起来差不多,但触发源没映射上,ADC 就一直不动,这是新手最容易踩的坑。
2.3 DMA 双缓冲:判决时不丢数据
采样点放哪个数组,直接决定后续判决能不能跑在“完整的一段波形”上。最简单的方式是 DMA 把 ADC 结果搬到adc_raw,搬满ADC_BUF_LEN个点后触发完成中断,在中断里做特征提取。但如果特征提取需要几毫秒,期间 DMA 继续采样就会覆盖旧数据,这时推荐双缓冲。
双缓冲的常规做法是准备两个ADC_BUF_LEN大小的数组,利用 DMA 的半传输和全传输两个中断事件:前半段写满时进第一个数组,后半段写满时进第二个数组。代码可以这样组织:
static uint16_t adc_raw[2][ADC_BUF_LEN]; static volatile uint8_t dma_buf_id; // 半传输完成:前一半数据已填满 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { dma_buf_id = 0; process_buffer(adc_raw[0], ADC_BUF_LEN); } } // 全传输完成:后一半数据已填满 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { if (hadc->Instance == ADC1) { dma_buf_id = 1; process_buffer(adc_raw[1], ADC_BUF_LEN); } }注意process_buffer必须足够快,在下一次 DMA 半中断到来之前结束,否则两段数据还是会有覆盖风险。实际工程里,特征提取只做加减乘除和少量比较,128 点数据在 72MHz 主频下耗时不长,基本够用。
| 信号频率 | 建议采样点数 | 建议触发频率 | 说明 |
|---|---|---|---|
| 50Hz | 128 | 6.4kHz | 覆盖约 1.6 个周期,适合过零和斜率特征 |
| 400Hz | 256 | 51.2kHz | 覆盖 5 个完整周期,FFT 频率分辨率 200Hz |
| 1kHz | 128 | 25.6kHz | 单周期 25.6 点,沿检测需要调高阈值 |
3. 把波形变成数字特征:时域与频域的两套指标
3.1 时域特征:均值、峰峰值、过零率、斜率标准差
波形分类并不需要完整恢复波形,只需要几个能拉开三类信号差异的数字。我先列一组在 STM32 上计算成本很低的时域特征。
均值就是整段 ADC 值的平均,用来估直流偏置。峰峰值是最大值减最小值,用来归一化后面的阈值。过零次数以均值为零参考点,统计采样序列正负符号变化的次数,用来确认“这段数据里至少包含一个完整周期”。如果一段数据连两次过零都没有,后面所有判断都不可信,直接归为未知波形。
斜率标准差是区分正弦和三角的关键:三角波在上升段斜率近似恒定,下降段斜率为负的同一常数;正弦波斜率每时每刻都在变。把每个采样点与前一个点做差分,得到一列斜率值,再取中心区间的标准差,三角波的标准差会明显小于正弦波。
3.2 频域特征:幅度谱、基波占比、谐波能量
时域特征解决大部分问题,但方波和三角波在有些情况下会靠得很近,比如采样率不高时方波的边沿恰好落在两个采样点之间,时域的“陡沿”特征会被抹平。这时频域特征能做补充:方波的奇次谐波能量占比很高,三角波虽然也含奇次谐波,但幅值按 1/n² 衰减得更快,正弦波则几乎只有基波。
在 STM32 上做 FFT,一般用基 2 的 64 或 128 点变换。频域分辨率的代价要提前估算,比如 20kHz 采样、128 点 FFT,频率分辨率是 156Hz;如果信号频率是 1kHz,基波落在一个明确的谱线附近,没问题;如果信号是 50Hz,156Hz 的分辨率根本看不出基波在哪,这时候该做的是降低采样率,或者把 FFT 点数加到 256/512。FFT 结果里我只看基波幅值、二次谐波、三次谐波和总谐波能量,这四个量足够支撑三种波形的区分。
3.3 特征提取函数:可直接改参的 C 代码
放在 2.3 里process_buffer调用的就是下面这个函数。它不依赖浮点库以外的任何东西,运算量也小。
typedef struct { uint16_t vmin; uint16_t vmax; float vmean; // 整段平均,用于去除直流偏置 uint16_t zero_cross; // 去除直流后的过零次数 float slope_std; // 中心区间斜率标准差,单位:LSB/采样点 uint16_t edge_cnt; // 超过沿阈值的陡变次数 } WaveFeature; void extract_features(const uint16_t* buf, uint32_t n, WaveFeature* f) { uint32_t i; uint32_t sum = 0; uint16_t min_v = 4095, max_v = 0; for (i = 0; i < n; i++) { sum += buf[i]; if (buf[i] < min_v) min_v = buf[i]; if (buf[i] > max_v) max_v = buf[i]; } f->vmean = (float)sum / n; f->vmin = min_v; f->vmax = max_v; // 以均值为零参考,统计过零次数 int16_t prev = (int16_t)buf[0] - (int16_t)f->vmean; f->zero_cross = 0; for (i = 1; i < n; i++) { int16_t cur = (int16_t)buf[i] - (int16_t)f->vmean; if ((prev >= 0 && cur < 0) || (prev < 0 && cur >= 0)) f->zero_cross++; prev = cur; } // 中心区间斜率标准差 float sum_sq = 0, cnt = 0; uint16_t edge = 0; for (i = 1; i + 1 < n; i++) { float s = ((float)buf[i + 1] - (float)buf[i - 1]) / 2.0f; if (i > n * 0.35f && i < n * 0.65f) { sum_sq += s * s; cnt++; } // 沿阈值按采样率和信号幅度调整,这里是经验值 if (s > 200.0f || s < -200.0f) edge++; } f->slope_std = sqrtf(sum_sq / (cnt > 0 ? cnt : 1)); f->edge_cnt = edge; }代码里有两个点说明一下:过零判断先把每个采样点减掉整段均值,相当于做了直流偏置消除,避免信号中心不在 0V 时过零次数变成 0;斜率标准差只统计中心 35% 到 65% 区间,是为了避开方波的沿和三角波的折点,这两处斜率变化大,会把正弦和三角的差异淹没掉。
4. 分类判决:用规则而不是网络,把三类特征分开
4.1 第一刀:方波有“陡沿”,正弦和三角没有
规则分类的思路是逐级排除。第一层判断哪个特征能“一刀切”?方波最突出的特征是沿:从低电平跳到高电平只发生在极短的采样间隔内。在 25.6kHz 采样率下,一个 12 位 ADC 采集的方波信号,沿前后两个采样点的数值差通常超过 200 LSB,而正弦波和三角波一个采样点内最多变化几十个 LSB。
这里的沿阈值不能写得太大。幅度一半的方波,边沿差 2000 LSB;三角波相同幅度下,每个采样点变化大约 200 LSB 不到。所以 200 这个经验值在 12 位 ADC、信号幅度接近满量程时是安全的,但信号幅度缩小时阈值也要同比例下调。更稳妥的做法是把沿阈值设置成“峰峰值的一半”,用相对值而不是绝对值。
4.2 第二刀:正弦与三角的“线性段斜率散布”不同
排除方波以后,只剩下正弦和三角。两者最大的区别是“上升段是不是一次函数”。三角波的上升段是一条直线,斜率恒定;正弦波在中点附近斜率最大,越靠近顶点斜率越小。于是前面提取的slope_std就派上用场了:中心区间斜率标准差小的是三角波,大的是正弦波。
但直接比绝对值会受幅度影响,比如同样一个三角波,幅值大一倍,斜率标准差也大一倍。所以判决前要做归一化:slope_std / (vmax - vmin),得到一个与幅度无关的比值。
4.3 完整判决函数与参数表
typedef enum { WAVE_UNKNOWN = 0, WAVE_SIN, WAVE_TRI, WAVE_SQUARE } WaveClass; typedef struct { uint16_t edge_th; // 沿识别阈值,单位LSB uint16_t edge_cnt_min; // 方波需要的最少沿个数 float slope_ratio_high; // 高于此值判正弦 float slope_ratio_low; // 低于此值判三角 } ClassifyParam; static const ClassifyParam param_default = { 200, // 沿阈值,经验值,需按幅值缩放 4, // 一个完整周期至少4个可识别沿(上升沿+下降沿各至少两次) 0.15f, // 斜率标准差/幅值的正弦阈值 0.08f // 斜率标准差/幅值的三角阈值 }; WaveClass classify_wave(const WaveFeature* f, const ClassifyParam* p) { float amp = (float)(f->vmax - f->vmin); if (amp < 50.0f) return WAVE_UNKNOWN; // 幅度太小,无分类意义 if (f->edge_cnt >= p->edge_cnt_min) { return WAVE_SQUARE; } if (f->zero_cross < 2) return WAVE_UNKNOWN; // 没有完整周期 float ratio = f->slope_std / amp; if (ratio > p->slope_ratio_high) return WAVE_SIN; if (ratio < p->slope_ratio_low) return WAVE_TRI; return WAVE_UNKNOWN; // 落在中间区间,交给上位机二次确认 }这段代码的运行逻辑很直白:先判断幅度是否有效,再查陡沿个数,最后用归一化斜率标准差做第二层判决。WAVE_UNKNOWN不是错误,而是“我不确定”的状态,它比硬判一类更实用,因为你的信号源或线缆出问题时,硬判的结果只会误导上位机。
| 参数 | 默认值 | 适用场景 |
|---|---|---|
| edge_th | 200 LSB | 12位ADC、幅度接近满量程 |
| edge_cnt_min | 4 | 至少包含一个完整周期的数据段 |
| slope_ratio_high | 0.15 | 幅度小或噪声大时适当上调到 0.20 |
| slope_ratio_low | 0.08 | 三角波含轻微噪声时可放宽到 0.10 |
5. 常见问题与避坑记录:为什么我的波形老判错
5.1 采样率不足:方波被判成三角波
现象:输入一个频率 2kHz 的方波,采样率还是 20kHz,每个周期只有 10 个点,方波边沿只占一两个采样点。结果发现分类结果一会儿是三角波、一会儿是方波。
原因:方波的沿在两个采样点之间完美躲开了 ADC 的触发时刻,采到的数据看起来就是一个比较陡的斜坡,edge_cnt达不到方波阈值。
解决:把沿检测的阈值从绝对值改为峰峰值的一半,同时把触发频率提高到信号频率的至少 20 倍。如果硬件上采样率已经无法提高,就退一步接受“低速采样下方波与三角波不可能完全可靠区分”的结论,在判决结果里把这种情况标记为WAVE_UNKNOWN,不要硬判。
5.2 直流偏置漂移:过零判断全乱
现象:信号源输出的波形带有 1.5V 直流偏置,输入到 STM32 后 ADC 值整体上抬。过零判断按 0 为参考,结果一整段数据都没有过零,zero_cross永远是 0,程序一直输出未知波形。
原因:ADC 采样的是绝对电压,不是交流信号,用 0 作为过零参考的前提是信号中心就在 0V。实际电路里运放输出、电阻分压都会引入偏置。
解决:过零参考点必须从数据本身算,也就是代码里的vmean。每个采样点减去均值后再判断符号变化,这已经在特征提取里做了。要注意的是,如果信号本身占空比不是 50%,均值参考也会让过零次数偏多或偏少,但对正弦、三角、方波这三类标准波形,均值参考完全够用。
5.3 采样窗口和信号周期没对齐
现象:同样一个正弦波,有时判断正确,有时被判定为三角波,而且频率越低越不稳定。
原因:数据窗口长度和数据点数没对齐。当采样点数只覆盖了信号周期的 0.7 倍时,中心区间的斜率统计采到的是“半个半周期”,斜率变化范围被截断,slope_std明显变小,正弦被误判成三角。
解决:用 5 个完整周期作为默认窗口。比如 1kHz 信号用 25.6kHz 采样,128 点覆盖 5.12 个周期,怎么截取都不会出现“半个半周期”的问题。若点数固定,就把输出窗口按预期频率重新算,不要一个参数用到底。
5.4 噪声让斜率散布失真
现象:三角波的斜率比值始终高于 0.15 阈值,被误判成正弦波。用示波器看波形很干净,但 STM32 采集的信号带有高频毛刺。
原因:ADC 前级没有滤波,或者信号源本身有一点开关噪声。斜率特征是差分运算,对高频噪声极其敏感,几个 LSB 的毛刺就足以让斜率标准差翻倍。
解决:在采样前用一阶 RC 低通把高频分量压掉,截止频率设在信号频率的 10 倍左右。软件上也可以加一个三点滑动平均再算斜率,但要注意滑动平均本身会轻微改变边沿斜率,方波的edge_cnt会下降,阈值要重新标定。
5.5 浮点运算拖慢主循环
现象:用了sqrtf和大量浮点除法后,一次特征提取加判决耗时超过 3ms。主循环的其他任务被打断,串口输出出现明显卡顿。
原因:STM32 F1 系列没有硬件 FPU,浮点运算主要靠编译器模拟,速度慢是正常的。代码里每条特征都要算浮点均值、浮点斜率标准差,成本确实不低。
解决:在不要求高精度时,把vmean、slope_std改造成定点数。比如均值用sum << 8存放 256 倍放大值,斜率标准差用平方和累加,最后在判决时才做一次浮点除法。这一步能把特征提取时间压缩掉七成。
6. 从“能分”到“分得准”:标定、验证和调试习惯
6.1 把判别结果和原始特征一起发出去
只发一个“SINE / TRI / SQUARE”标签,调起问题来会非常痛苦。我的习惯是把原始特征一起打到串口上,格式定成简单的 CSV:信号高度、过零次数、边沿数、斜率比值、判决结果。这样上位机拿到数据可以在线画曲线,对照示波器一眼就能看出是特征量本身算错了,还是判决阈值设歪了。
void print_verdict(WaveClass cls, const WaveFeature* f) { // 用简单整数协议输出,上位机按序号解析即可 printf("%d,%d,%d,%d,%d\n", f->vmax, f->vmin, f->zero_cross, f->edge_cnt, cls); }串口波特率建议 115200,输出频率不能太高,每 10ms 打一次足够。上位机把每一行画成 5 条曲线,波形类型变化时能直接看到是哪个特征最先越线。
6.2 标定三步:消除硬件差异后的通用做法
每块板子的电阻分压、基准电压不一样,直接套用默认阈值会产生不小的误差。我在给某检测治具调试时养成了一套标定习惯,每次换硬件后都花十分钟过一遍:
第一步,用一台信号发生器分别灌入 1kHz 正弦、三角、方波,记录每类波形的edge_cnt和slope_std,取相邻两类的中值作为新阈值。第二步,把信号幅度分别调到原始值的 0.8 倍和 1.2 倍,确认判决结果不变,如果变了说明某个特征量对幅度太敏感,需要增加归一化。第三步,人为串入一个电阻分压器模拟偏置,验证过零逻辑还能不能正常工作。
这套流程走完,误判率通常能压到 1% 以下。我也曾图省事跳过第一步,直接把默认阈值烧进固件,结果一台板子稳定输出三角波,另一台输出正弦,原因就是两个批次的分压电阻容差不同。波形识别这块,代码只占一半,另一半是采样参数和阈值能不能匹配你手里真实的信号源。
希望帮到你。
本文还有配套的精品资源,点击获取