简介:基于正点原子精英版开发板与STM32ZET6,这份RAR工程包是一套完整的FFT频谱分析Demo,覆盖信号采集、快速傅里叶变换、总谐波失真THD计算及LCD实时频谱图显示,适合学习DSP算法与STM32外设开发的工程师对照实践。压缩包内共229个文件,约5.28MB,其中49个.h与48个.c为源码主体,涵盖LCD驱动、定时器、USMART及FFT模块;hex、axf、o等编译产物便于烧写或分析中间结果,Keil工程文件与批处理工具一并附上。已有3971人学习,目录结构清晰,源码、驱动和工程配置分层明确。通过项目可完整掌握FFT/THD计算流程与频谱显示实现思路,理解信号采集、频域分析、LCD图形刷新在嵌入式开发中的整合方法,适合在正点原子硬件平台上移植和二次开发。
1. STM32 上的 FFT 频谱分析工程:采样、谐波和一次画满的频谱图
搞单片机信号处理的人迟早会遇到这种场景:驱动电路输出的正弦波形在示波器上看着挺干净,但系统就是发热、啸叫,怀疑谐波超标。把信号引到这块板子的 ADC 引脚,跑一遍 FFT,LCD 上画出频谱图,总谐波失真 THD 百分比也直接算出来了。项目基于 STM32 标准外设库加 ARM DSP 库实现,ADC 定长采样、加窗、FFT、基波搜索、THD 计算、频谱绘制是一条完整闭环。
适合做电源谐波检测、音频频谱分析、电机电流监测的开发者。新手按工程配置改引脚就能编译烧录,熟手可以直接把 THD 计算和频谱绘制模块抽出来移植。整个工程没有依赖上位机,一块板子独立跑完采样到显示的活,也能通过串口把频谱数据丢给 PC 端做二次分析。
2. 项目架构与数据链路:从 ADC 触发到 LCD 刷屏的每一步
2.1 目录结构:工程文件与职责划分
这个工程的目录划分得很直白,应用层代码和驱动层代码是分开的,拿来改的时候不需要在 HAL 库里翻半天。核心布局如下:
FFT_THD_Demo/ ├── Core/ │ ├── Inc/ # main.h、stm32f1xx_hal_conf.h 等头文件 │ └── Src/ # main.c、system_stm32f1xx.c ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ # 标准 HAL 库 │ └── CMSIS/ │ ├── Include/ # arm_math.h、core_cm3.h │ └── DSP_Lib/ # arm_cfft_f32.c、arm_cmplx_mag_f32.c ├── App/ │ ├── fft_app.c # 采样初始化 + FFT 主流程 │ ├── thd_calc.c # 基波搜索与 THD 计算 │ └── spectrum_disp.c # 频谱柱状图绘制与刷新 └── LCD/ ├── ili9341.c # LCD 底层驱动(SPI 接口) └── fonts.c # 字符与数字字模App 目录是整个工程的核心。fft_app.c 负责把 ADC 采样、加窗、FFT 这些步骤串起来,对外只暴露一个 FFT_Process 函数;thd_calc.c 不关心采样细节,只接收频谱幅值数组和采样率,返回 THD 百分比;spectrum_disp.c 则把频谱数据映射成屏幕上的柱状图。这样的分层有个好处:如果想把 FFT 结果通过串口发到上位机,只要不动 thd_calc.c,直接在 main.c 里加一行调用就行。
2.2 数据流设计:采样、加窗、FFT、THD 的完整链路
整条数据链路从模拟信号进来到频谱画上屏,中间经过六个阶段,每个阶段的数据形态和存放位置都不一样:
| 阶段 | 数据形态 | 存放位置 | 说明 |
|---|---|---|---|
| 采样 | int16_t adc_buf[1024] | DMA 缓冲 | 定时器触发 ADC,定长采 1024 点 |
| 加窗 | float32_t fft_input[2048] | 内部数组 | 实部乘 Hanning 窗,虚部清零 |
| FFT | 复数频谱 | 内部数组 | arm_cfft_f32 正变换 |
| 取模 | float32_t fft_mag[1024] | 内部数组 | arm_cmplx_mag_f32 求幅值 |
| THD | float 百分比 | 局部变量 | 基波峰值 + 2~10 次谐波能量 |
| 绘制 | RGB565 像素 | LCD 显存 | 对数幅度转柱状图 |
采样环节用的是 DMA 循环模式,ADC 连续转换不需要 CPU 干预,转换结果直接写进内存。等一帧 1024 点采满,主循环里做 FFT 运算,算完 THD 和频谱再刷屏。这里有个很关键的取舍:FFT 运算期间 DMA 还在跑,所以工程里预留了双缓冲(或者 DMA 半满中断切一半缓冲),保证 FFT 读数据的时候 DMA 不会往同一块内存写。如果省掉这一步,频谱上会出现一堆莫名其妙的毛刺,后面第 5 章会专门讲。
2.3 技术选型:为什么用 DSP 库而不是自己写 FFT
自己写 FFT 的蝶形运算在教科书上看着不难,但真放到单片机上要处理定点溢出、位反转、旋转因子查表一堆细节,调出来的结果还不一定对。这个工程直接用 ARM CMSIS-DSP 库的 arm_cfft_f32,1024 点复数 FFT 在 72MHz 主频的 F1 上跑一次大概 1ms 左右,对这个应用场景绰绰有余。
F1 系列没有 FPU,理论上用 Q15 定点 FFT 更快,但 THD 计算和频谱绘制都要做浮点运算,混着用还得来回做格式转换。工程的做法是全部用 float32_t,牺牲一点点速度换代码简洁。如果换到 F4 系列,FPU 直接加速浮点 FFT,arm_cfft_f32 的接口完全一样,移植只是换一个 FFT 实例表的事。
注意:CMSIS-DSP 库不是默认编译进工程的,需要在 Keil 的工程选项里把 DSP_Lib 的源文件加进去,或者用 RTE 组件方式勾选。很多"编译报错找不到 arm_math.h"的问题都出在这一步。
3. 核心代码拆解:FFT 变换与 THD 计算的完整实现
3.1 定时器触发 ADC 采样:采样率怎么定
采样率决定频率分辨率。1024 点 FFT 配 10kHz 采样率,频率分辨率是 9.77Hz,能分辨 50Hz 基波和它的 2 到 10 次谐波。工程用 TIM2 的 PWM 输出触发 ADC,这样采样间隔由硬件定时器保证,比在主循环里延时调用 ADC 稳定得多。初始化代码如下:
static void ADC_Sample_Init(uint32_t fs) { // 1. TIM2 做触发源:72MHz 主频,PSC=71 分频后计数频率 1MHz TIM_HandleTypeDef htim2 = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz htim2.Init.Period = 1000000 / fs - 1; // 1000000/fs 得到触发间隔 htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_OC_Start(&htim2, TIM_CHANNEL_1); // 2. ADC1 选择 TIM2 捕获比较事件作为外部触发源 ADC_HandleTypeDef hadc1 = {0}; hadc1.Instance = ADC1; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_CC1; hadc1.Init.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_Init(&hadc1); HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, FFT_SIZE); }实际采样率 fs = 72MHz / (PSC+1) / (ARR+1)。PSC=71 时计数频率 1MHz,ARR 填 99 就是 10kHz,填 999 就是 1kHz。改采样率只需要改 ARR,频谱的频率分辨率会自动跟着变。采样时间选了 239.5 个 ADC 时钟周期,这是给高内阻信号源留的余量——如果信号源内阻大、采样时间却很短,采样电容充不满,测出来的幅值会偏低,而且不同频率衰减不一样,频谱会"翘起来"。
3.2 加窗与 FFT:调用 DSP 库的正确姿势
FFT 函数要求输入是实部虚部交错的复数数组,长度 2 倍于 FFT 点数。ADC 采到的是纯实数信号,所以虚部直接填 0,实部在转成浮点后要乘上 Hanning 窗。窗函数是为了抑制频谱泄漏——后面避坑章会详细说,这里先看代码:
#include "arm_math.h" #define FFT_SIZE 1024 static float32_t fft_input[FFT_SIZE * 2]; // 实部虚部交错存放 static float32_t fft_mag[FFT_SIZE]; // 幅值谱 static float32_t hanning[FFT_SIZE]; // 预计算好的窗函数表 void FFT_Process(int16_t *adc_buf) { // 加窗:ADC 原始整型转 float,乘 Hanning 窗系数 for (uint16_t i = 0; i < FFT_SIZE; i++) { fft_input[2 * i] = (float32_t)adc_buf[i] * hanning[i]; fft_input[2 * i + 1] = 0.0f; // 虚部清零 } // 1024 点复数 FFT:ifftFlag=0 表示正变换,doBitReverse=1 重排输出 arm_cfft_f32(&arm_cfft_sR_f32_len1024, fft_input, 0, 1); // 求幅值:计算 sqrt(re^2 + im^2),结果写回 fft_mag arm_cmplx_mag_f32(fft_input, fft_mag, FFT_SIZE); }arm_cfft_f32 第三个参数 ifftFlag 填 0 是正变换,填 1 是逆变换,这个别搞反。第四个参数 doBitReverse 一般填 1,库内部会做位反转重排,填 0 会得到乱序结果。arm_cmplx_mag_f32 输出的幅值不是真实电压幅值,加窗后还带着窗函数的增益衰减,不过 THD 算的是比值,基波和谐波被同等缩放,比值不受影响。
3.3 基波搜索与 THD 计算:谐波能量怎么累加
THD 的定义是全部谐波能量的平方和开根号,除以基波幅值,再乘 100%。但实际频谱里,基波频率对应的 bin 可能因为采样频率不是信号频率整数倍而有偏差,直接取固定 bin 会漏掉峰值。工程的做法是在预期基波位置附近做小范围搜索,每个谐波也取中心 bin 附近的能量:
#define THD_HARMONIC_MAX 10 float THD_Calculate(float *mag, float fs, float fund_freq) { float f_res = fs / FFT_SIZE; // 频率分辨率 uint16_t fund_bin = (uint16_t)(fund_freq / f_res); // 基波所在 bin // 在预期基波 bin 附近 ±3 个 bin 内找真正的峰值 float fund = 0; uint16_t fund_idx = fund_bin; for (uint16_t i = fund_bin - 3; i <= fund_bin + 3; i++) { if (mag[i] > fund) { fund = mag[i]; fund_idx = i; } } // 累加 2 到 10 次谐波附近的能量,每个谐波取 ±1 bin 防止漏掉 double harm_power = 0; for (uint16_t h = 2; h <= THD_HARMONIC_MAX; h++) { uint16_t center = fund_idx * h; for (uint16_t k = center - 1; k <= center + 1; k++) { if (k < FFT_SIZE / 2) harm_power += (double)mag[k] * mag[k]; } } return (float)(sqrt(harm_power) / fund * 100.0f); }谐波取 ±1 bin 而不是单点,是防止栅栏效应导致某次谐波正好落在两个频点中间而测不到。上限设到 10 次是因为对电源谐波检测,10 次以上的分量通常已经小到对 THD 值没实质影响;如果你做音频分析,把 THD_HARMONIC_MAX 加大到 20 即可。注意这里用 float64 累加能量,因为谐波能量是平方量级,float32 在小数累积时会有精度损失,这是血泪教训换来的。
3.4 幅值校准:从频谱 bin 到真实电压
THD 是比值不需要校准,但频谱图上的幅值如果想像示波器那样标成电压读数,就要做换算。单边频谱的换算规则是:非直流 bin 的真实幅值等于该 bin 的幅值乘以 2 再除以 FFT 点数;直流 bin 不乘 2。加窗后还要再除以窗函数的相干增益,Hanning 窗大约是 0.5,所以实际系数会变成 4 / N:
float mag_to_volt(float mag, float vref, uint16_t adc_full_scale) { // 加 Hanning 窗后:真实幅值 = mag * 2 / FFT_SIZE / 0.5 // 也就是 mag * 4 / FFT_SIZE,再乘每 LSB 对应的电压 float lsb = vref / adc_full_scale; // 例如 3.3V / 4096 return mag * 4.0f / FFT_SIZE * lsb; }vref 一般填 3.3,adc_full_scale 是 4096(12 位 ADC)。如果你的硬件上有分压电阻,换算结果还要再乘分压比。这个函数只影响显示,不影响 THD 计算,我一般先标定 1kHz 标准正弦,看显示幅值对不对,再决定要不要微调系数。
4. 频谱图显示与交互:把频点数据变成能看的柱状图
4.1 柱状图绘制:频段压缩和对数幅度
1024 点 FFT 去掉对称的另一半还有 512 个频点,LCD 横向只有 240 像素,不可能一个点画一列。工程的做法是把每 2 个 bin 合并成一列,取这一列里的峰值而不是平均值——取平均值会把窄带尖峰"抹平",窄峰恰恰是谐波检测最关心的东西:
#define LCD_W 240 #define LCD_H 320 void Spectrum_Draw(float *mag) { uint16_t bins_per_x = (FFT_SIZE / 2) / LCD_W; // 512 / 240 = 2 for (uint16_t x = 0; x < LCD_W; x++) { // 每个像素列取对应频段内的峰值 float peak = 0; for (uint16_t b = x * bins_per_x; b < (x + 1) * bins_per_x; b++) { if (mag[b] > peak) peak = mag[b]; } // 转 dB:参考满幅,动态范围取 80dB float db = 20 * log10f(peak + 1e-6f); uint16_t h = (uint16_t)((db + 80.0f) * 3.0f); // -80dB 映射到底部 if (h > LCD_H - 30) h = LCD_H - 30; LCD_FillRect(x, LCD_H - 30 - h, 1, h, BAR_COLOR); } }对数幅度是关键设计。线性幅度下,基波 3V 会占满整个屏幕,0.1V 的谐波根本看不见;转成 dB 后 30dB 的动态范围能同时显示基波和小谐波。+1e-6f 是防止 log10f(0) 产生负无穷,这个细节漏掉的话,静音状态下屏幕底部会刷出一片乱码。底部留 30 像素是给 THD 数值和频率刻度留的显示区。
4.2 刷新策略:只画变化区域避免整屏闪烁
直接全屏清掉再重画 240 根柱子,一帧要写几万个像素,SPI 屏会闪得没法看。工程的做法是保存上一帧每列柱高,只重绘高度发生变化的那一小条区域:
static uint16_t prev_h[LCD_W]; void Spectrum_Refresh(float *mag) { for (uint16_t x = 0; x < LCD_W; x++) { uint16_t h = calc_bar_height(mag, x); // 复用上面的映射逻辑 if (h > prev_h[x]) { // 新柱变高:从新柱顶向下补画差值 LCD_FillRect(x, LCD_H - 30 - h, 1, h - prev_h[x], BAR_COLOR); } else if (h < prev_h[x]) { // 新柱变矮:把旧柱顶到新柱顶之间的区域擦掉 LCD_FillRect(x, LCD_H - 30 - prev_h[x], 1, prev_h[x] - h, BG_COLOR); } prev_h[x] = h; } }这个策略把每帧的写入量从几万像素降到几百像素,帧率提升一个量级。注意比较的是上一帧的柱高数组,所以首次绘制前要把 prev_h 全部初始化为 0。如果柱子高度没变就不写,也省掉了大量无效的 SPI 传输。
4.3 显示模式与交互:线性/对数切换和峰值保持
纯频谱图只能看当前一帧,但很多场景需要观察谐波是否偶尔冒尖。工程在主循环里放了一个简单的状态机,按键切换三种显示模式:线性幅度、对数 dB、峰值保持。峰值保持模式用一张 max_hold 数组,每一帧都是当前值和历史峰值的较大者,然后每隔固定帧数整体衰减,让峰值"慢慢滑落":
typedef struct { uint8_t mode; // 0 线性 / 1 对数 / 2 峰值保持 uint16_t hold_decay_cnt; // 峰值衰减计数器 float max_hold[LCD_W]; // 每列峰值保持缓冲 } DispCtx; void Spectrum_UpdateMode(DispCtx *ctx, uint8_t new_mode) { ctx->mode = new_mode; memset(ctx->max_hold, 0, sizeof(ctx->max_hold)); // 切模式时清空保持值 }峰值保持对找偶发谐波特别有用,比如电机启动瞬间的电流谐波。切换模式时清零保持值,避免旧模式下积累的峰值误导判断。这个交互层和 FFT 计算层完全解耦,想加一个自动增益控制或者频段光标,都只需要在这个状态机里加分支。
5. 避坑与排查:采样错位、频谱泄漏和显示卡顿
5.1 DMA 半满中断与采样数据错位
现象:频谱整体噪声抬高,50Hz 的谱线旁边全是毛刺,THD 值跳来跳去,有时候直接从 2% 蹦到 30%。
原因:DMA 用循环模式,数据写满缓冲区后自动回卷继续写。如果只启用了传输完成中断,FFT 读前半段数据时 DMA 可能正在往后半段写,两边访问的其实是同一块数组的相邻区域,逻辑上没问题;但真正常见的是 DMA 中断处理里没有做"半满/全满"切换,或者只处理了半满中断,导致 CPU 在错误的时间点读取了还在被 DMA 写入的数据。
解决:把 1024 点缓冲拆成两段 512 点,DMA 半满中断处理第一段,全满中断处理第二段,两段交替使用,保证 CPU 读一段时 DMA 写另一段。检查方法是用串口把原始 ADC 值打出来,看波形是否连续、采样间隔是否均匀。如果数据错位,波形会是乱的或周期不对。
5.2 频谱泄漏与栅栏效应:THD 虚高的元凶
现象:输入一路用信号源产生的标准 50Hz 正弦,信号源本身失真低于 0.1%,但板子算出来的 THD 有 5% 甚至更高,频谱上 40Hz 到 60Hz 之间一坨能量。
原因:采样 1024 个点未必恰好是 50Hz 信号的整数个周期,直接做矩形截断会在频域产生旁瓣泄漏。另一个是栅栏效应:频率分辨率只有 9.77Hz,谐波分量落在两个 bin 中间时,能量会摊到相邻 bin 上,THD 读数自然偏高。
解决:先加 Hanning 窗,旁瓣直接从 -13dB 压到 -31dB;基波和谐波搜索用 ±1~3 个 bin 的峰值/能量累加,而不是死板地取固定 bin。如果还嫌不够,可以做双谱线插值估计真实频率和幅值。另外检查一下设置的基波频率和信号源实际输出是否一致,偏差超过几个 Hz,搜索窗口就会框错位置。
5.3 LCD 卡顿与闪烁:SPI 屏写入量过大
现象:频谱刷新明显一卡一卡的,一帧 200ms 以上,屏幕边缘闪烁。
原因:SPI 屏本身写像素就慢,如果每帧全屏填充背景、再逐像素画几百根柱子,每帧 SPI 写入量在几万像素级别,加上每个柱子单独设置光标,效率更低。有些代码每画一个点都调一次 setAddress 指令,时间全浪费在命令交互上。
解决:按 4.2 的方式改局部刷新,只重绘柱高变化的区域;SPI 时钟从默认分频往上调,F1 上跑到 18MHz 基本稳定;再不行就牺牲分辨率,把 240 列合并成 60 列,每列宽度 3~4 像素,写入量直接降一个量级。还有个很隐蔽的坑:如果用 GPIO 模拟 SPI,翻转速度再快也就几 MHz,有条件就换硬件 SPI。
5.4 THD 计算结果异常:负值或 NaN
现象:THD 偶尔算出来是负数或者 nan,屏上显示乱码。
原因:基波搜索时 mag 数组取到了 0 或者极小值,比如输入信号没接、ADC 采到纯噪声,fund 变量几乎为 0,除法直接产出无穷或 NaN。负值一般是显示层把浮点直接打上屏幕,忘了处理 THD 和显示格式之间的匹配。
解决:THD_Calculate 开头加保护,fund 小于某个阈值(比如最大值的一半)直接返回 0 或者一条错误提示"无信号";显示 THD 前用 isnan() 和 isfinite() 检查一遍。这种问题不是算法逻辑错,是边界没兜住,加上保护之后稳得多。
6. 二次开发思路:把固定 50Hz 检测改成通用频谱分析工具
工程现在把基波频率、采样率、FFT 点数全写死在宏定义里,改成可配置项是第一个值得做的改造。把硬编码抽成一个分析参数结构体,每次配置变化时重新计算 TIM 的 ARR、重新生成窗函数表:
typedef struct { uint32_t fs; // 采样率 uint16_t fft_size; // FFT 点数 float fund_freq; // 预期基波频率 uint8_t max_harmonic; // 参与 THD 计算的最高谐波次数 } AnalyzerCfg; void Analyzer_Reconfig(AnalyzerCfg *cfg) { ADC_Sample_Init(cfg->fs); // 重设定时器触发频率 GenerateHanning(cfg->fft_size); // 重新生成窗表 FFT_Process_Init(cfg->fft_size); // 重新配置 DSP 库实例 }改完参数结构体,可以顺手加频段功率统计:把频谱按低频、中频、高频分段,对每段所有 bin 的能量求和得到 RMS,这在音频场景下比单看一个 THD 值更有参考价值。如果是做上位机联调,再加一段协议打包函数,把 512 个频点压缩成每 4 个 bin 取峰值,一帧也就 100 多个 float,串口 115200 波特率完全跑得动。
验证这套改造是否靠谱,有个非常实用的校准方法:用信号源输出 50Hz 基波叠加上 150Hz、幅度为基波 1/3 的三次谐波,THD 理论上应该接近 33.3%,看板子算出来差多少。另一个更狠的校准是用 1kHz 方波做激励,理想方波 THD 理论值是 48.3%,这个值不依赖任何信号源精度,只要方波足够干净,就能验证从采样到 THD 计算的整条链路有没有系统性偏差。
我最初调这个工程时,方波校准怎么都卡在 45% 上不去,排查了半天发现是 ADC 采样时间配太短、信号源内阻又大,高频谐波被采样电容衰减掉了一截。从那以后我每次改采样参数或者移植到新板子,都强制先跑一遍方波校准再谈其他指标,这个习惯帮我省了大量排查时间。希望这套采样、FFT、THD 加频谱显示的流程,能帮你在自己的信号处理项目里少走几步弯路。
本文还有配套的精品资源,点击获取