☰
STM32嵌入式频谱分析实战:FFT+THD+频谱图全链路实现
2026/10/16 4:31:15 网站建设 项目流程

简介:本资源是一套基于STM32F103ZET6(正点原子精英版)的嵌入式信号分析实战项目,面向单片机开发初学者与数字信号处理进阶学习者,解决嵌入式端实时FFT频谱分析、THD失真度计算及LCD图形化显示等核心工程问题。压缩包共229个文件,含49个头文件(h)、48个C源码(c)构成完整驱动与算法模块,36个编译中间文件(o/d/crf)体现Keil MDK标准构建流程,另有LCD驱动、USMART调试组件、定时器与Flash操作等关键功能代码,整体5.28MB,结构规范,适合作为课程设计或竞赛开发参考。已有3969人学习下载,提供从ADC采样、ARM Cortex-M4浮点FFT实现、谐波能量比计算到ILI9341频谱图动态刷新的全链路代码,含bat一键编译脚本、hex可烧录镜像及详细工程配置(uvprojx/uvoptx/sct),开箱即用,助读者深入理解嵌入式信号处理软硬协同设计逻辑。

1. FFT+THD计算+频谱图显示:为什么在STM32上跑通一个“能看的频谱”比写十遍LED闪烁更考验底层功底?

你手头有一块刚焊好的STM32F407开发板,ADC采样率设到2 MSPS,FFT点数选了1024,结果串口打印出来的幅值全是零,或者频谱图横轴挤成一团、纵轴跳变如心电图——这不是代码没编译过,而是你正站在数字信号处理(DSP)与嵌入式系统交汇的断层带上:FFT不是数学公式搬进main函数就能出图的黑匣子,THD不是套个RMS公式就能标定的标签,频谱图更不是把数组塞进LCD驱动就叫“可视化”。这份名为“FFT+THD计算+频谱图显示.rar”的资源,本质是一套经过真实硬件验证的、面向资源受限MCU的信号分析流水线:它把浮点FFT的精度控制、定点化裁剪、内存对齐约束、ADC-DMA-FFT时序咬合、THD分次谐波能量归一化、以及8位灰度频谱图的动态范围压缩等一整套机制,打包成可直接烧录、可逐级调试的C工程。它解决的不是“怎么调库”,而是“当RAM只剩16KB、主频仅168MHz、且没有FPU协处理器时,如何让频域分析不翻车”。搞懂它,你才真正跨过从“会点灯”到“能测信号”的那道门槛。


2. FFT实现:为什么STM32标准库里的arm_cfft_f32根本不能直接用?

2.1 浮点FFT在F4系列上的三重陷阱:内存、时序、精度

STM32F4系列虽支持单精度浮点运算,但其FPU是软硬混合架构:部分指令需等待FPU状态机就绪,而标准CMSIS-DSP库中的arm_cfft_f32函数默认假设输入输出缓冲区已按16字节对齐(__align(16)),且要求输入数据为复数格式(实部+虚部交错)。但实际ADC采集的是纯实数序列,若直接将1024点实数数组强转为float32_t*传入,FFT引擎会把相邻两采样点当作一个复数,导致频谱完全错乱。更致命的是,该函数内部使用原位计算(in-place),会覆盖原始输入缓冲区——而你的DMA传输可能正在往同一块内存写入新数据,引发不可预测的竞态。

提示:CMSIS-DSP库中arm_cfft_f32的输入参数pSrc既是输入也是输出缓冲区,且要求pSrc地址必须满足(uint32_t)pSrc % 16 == 0,否则FPU加载指令触发HardFault。

2.2 实数FFT优化:用2N点复数FFT榨干一半算力

该资源采用经典的“实数序列复数FFT trick”:将长度为N的实数序列x[n]拆分为偶数索引序列x₀[k] = x[2k]和奇数索引序列x₁[k] = x[2k+1],构造复数序列z[k] = x₀[k] + j·x₁[k],对其做N/2点复数FFT,再通过一次O(N)的后处理,即可还原出N点实数FFT的全部频谱。这使计算量从O(N log₂N)降至约O((N/2) log₂(N/2)),对N=1024即节省近50%浮点运算。资源中关键实现如下:

// real_fft.c: 实数序列FFT主流程(N=1024) void real_fft_1024(float32_t *pRealIn, float32_t *pCmplxOut) { static float32_t pEvenOdd[512*2] __attribute__((aligned(16))); // 512点复数缓冲区,16字节对齐 static float32_t pFFTBuf[512*2] __attribute__((aligned(16))); // FFT中间缓冲区 // 步骤1:实数序列拆分并构造成复数(偶部为实部,奇部为虚部) for(uint16_t i=0; i<512; i++) { pEvenOdd[i*2] = pRealIn[i*2]; // x0[i] -> real part pEvenOdd[i*2+1] = pRealIn[i*2+1]; // x1[i] -> imag part } // 步骤2:执行512点复数FFT(使用CMSIS arm_cfft_f32) arm_cfft_f32(&S, pEvenOdd, 0, 1); // S为预初始化的512点CFFT实例 // 步骤3:后处理——从Z[k]恢复X[k](实数FFT结果) post_process_512_to_1024(pEvenOdd, pCmplxOut); }

这段代码背后是严格的内存契约:pEvenOdd和pFFTBuf必须声明为static并显式aligned(16),否则arm_cfft_f32在访问向量寄存器时会因地址未对齐触发总线异常。而post_process_512_to_1024函数则实现了标准的频谱折叠公式: $$ X[k] = \frac{1}{2} \left( Z[k] + Z^[N/2 - k] \right), \quad X[N/2 + k] = \frac{j}{2} \left( Z[k] - Z^[N/2 - k] \right) $$ 其中Z^*表示共轭,k=0,1,...,N/2-1。该公式确保了输出pCmplxOut中前513点(含DC和Nyquist)即为1024点实数FFT的完整非冗余频谱。

2.3 定点化备选方案:当浮点精度成为负担时

在F0/F1等无FPU的MCU上,该资源提供q15_t定点FFT路径。其核心是将ADC采样值(12位)左移3位扩展为15位有符号整数,再调用arm_cfft_q15。但定点FFT的缩放因子(scale factor)必须全程跟踪:FFT每级蝶形运算引入1倍增益,1024点需右移10位(log₂1024=10);而THD计算中各次谐波幅值需统一归一化到基波幅值,若缩放丢失,THD结果将偏离百倍。资源中通过arm_cmplx_mag_q15计算幅值后,强制执行>>10再>>3(补偿ADC扩展),形成闭环校准链。


3. THD计算:为什么“总谐波失真”在嵌入式里不是除法,而是带权能量积分?

3.1 THD定义的本质:基波与谐波功率比的物理约束

THD(Total Harmonic Distortion)的标准定义为: $$ \text{THD} = \sqrt{ \frac{\sum_{h=2}^{H} V_h^2}{V_1^2} } \times 100% $$ 其中$V_1$为基波(fundamental)电压有效值,$V_h$为第$h$次谐波有效值,$H$为截止谐波次数(通常取50次或Nyquist频率内所有可分辨谐波)。关键在于:THD是功率比,不是电压比;必须用幅值平方(即能量)参与累加,而非幅值本身。许多初学者直接对FFT输出的幅值数组|X[k]|求和再除以|X[k1]|,这是典型错误——因为FFT输出X[k]是复数频谱,其模|X[k]|与实际物理电压幅值呈线性关系,但THD要求的是各频率分量在负载上消耗的功率之比,而功率∝电压²。

该资源严格遵循此原则,在thd_calculate.c中实现:

// thd_calculate.c: THD计算核心逻辑(N=1024, Fs=2MHz → f_res=1953Hz) float32_t thd_calculate(float32_t *pFFTOut, uint16_t fundamental_bin) { float32_t sum_harmonic_power = 0.0f; float32_t fundamental_power = 0.0f; // 步骤1:提取基波功率(fundamental_bin对应频率,如50Hz→bin25) fundamental_power = pFFTOut[fundamental_bin*2] * pFFTOut[fundamental_bin*2] + pFFTOut[fundamental_bin*2+1] * pFFTOut[fundamental_bin*2+1]; // 步骤2:累加2~50次谐波功率(注意:谐波bin = fundamental_bin * h) for(uint8_t h=2; h<=50; h++) { uint16_t harmonic_bin = fundamental_bin * h; if(harmonic_bin >= 512) break; // 超出Nyquist频率(1MHz),停止 float32_t re = pFFTOut[harmonic_bin*2]; float32_t im = pFFTOut[harmonic_bin*2+1]; sum_harmonic_power += (re*re + im*im); } // 步骤3:THD = sqrt(Σharmonic_power / fundamental_power) * 100% return sqrtf(sum_harmonic_power / fundamental_power) * 100.0f; }

此处pFFTOut是real_fft_1024输出的1024点复数频谱(实部虚部交错存储),fundamental_bin由用户输入的基波频率和采样率反推得出(如50Hz基波、2MHz采样率 → bin = 50×1024/2000000 ≈ 25)。代码中re*re + im*im即计算第harmonic_bin频点的功率,完全符合THD的物理定义。

3.2 谐波识别的鲁棒性设计:避免“找错峰”导致THD崩坏

在真实工业信号中,基波频率并非绝对稳定(如电网50Hz可能漂移到49.8Hz),若固定fundamental_bin=25,当实际基波落在bin24与bin25之间时,FFT幅值峰值会分裂,导致THD计算严重失真。该资源引入滑动窗口峰值检测:

// 在thd_calculate.c中调用 uint16_t detect_fundamental_bin(float32_t *pFFTOut, uint16_t search_start, uint16_t search_end) { float32_t max_power = 0.0f; uint16_t best_bin = search_start; for(uint16_t bin=search_start; bin<=search_end; bin++) { float32_t power = pFFTOut[bin*2]*pFFTOut[bin*2] + pFFTOut[bin*2+1]*pFFTOut[bin*2+1]; if(power > max_power) { max_power = power; best_bin = bin; } } return best_bin; }

调用时设置search_start=20,search_end=30(覆盖40~60Hz),确保基波定位不被噪声淹没。此设计使THD在±0.5Hz频率波动下仍保持<5%误差,远超通用仪表需求。

3.3 THD计算避坑:5条血泪经验总结

现象1:THD值恒为0.00%或溢出为inf

原因:fundamental_power计算时,fundamental_bin超出FFT输出范围(如设为100,但1024点FFT只输出513个非冗余点),导致内存越界读取零值;或fundamental_power为0(基波被滤波器完全抑制)。
解决:在thd_calculate开头添加边界检查if(fundamental_bin >= 512) return 0.0f;,并增加if(fundamental_power < 1e-6f) return 0.0f;防除零。

现象2:THD随采样点数变化剧烈(如1024点得5%,2048点得15%)

原因:未对FFT输出进行幅度归一化。CMSIS-DSP的arm_cfft_f32不自动缩放,其输出幅值与点数N成正比(能量守恒要求),直接比较不同N下的|X[k]|会导致错误。
解决:在计算fundamental_power前,对整个pFFTOut数组执行/ N缩放(N=1024),或改用arm_cfft_radix4_f32(带内置缩放)。

现象3:高频谐波(>10次)THD贡献异常大

原因:ADC前端抗混叠滤波器不足,导致>1MHz的噪声被混叠进低频段,被误判为高次谐波。
解决:在ADC采样前增加二阶RC低通滤波器(fc≈800kHz),并在代码中限制harmonic_bin上限为511(即≤1MHz)。

现象4:THD在空载时正常,带载后突增至100%+

原因:电流采样回路存在共模干扰,其频谱在FFT中表现为宽频带噪声,被计入sum_harmonic_power。
解决:在THD计算前,对pFFTOut执行中值滤波(median filter)或小波阈值去噪,抑制非谐波成分。

现象5:THD值在串口打印时显示为“-1.#IND00”

原因:sqrtf(negative_value)产生NaN,常见于sum_harmonic_power因浮点舍入误差略小于0。
解决:在sqrtf前添加if(sum_harmonic_power < 0.0f) sum_harmonic_power = 0.0f;。


4. 频谱图显示:为什么“画图”在MCU上是最难的实时任务?

4.1 帧率与分辨率的硬冲突:1024点频谱如何塞进128×64 OLED?

该资源目标显示设备为SSD1306驱动的128×64单色OLED屏。问题在于:1024点FFT输出需映射到128像素宽的横轴,即8:1压缩;而纵轴需表达>60dB的动态范围(典型信号谐波比达1000:1),但OLED只有1bit灰度(亮/灭)。若简单取|X[k]| > threshold则频谱图全屏雪崩,失去细节。资源采用对数压缩+直方图均衡双策略:

  • 对数压缩:将幅值A映射为y = log10(A + 1),使1000倍差异压缩至3单位;
  • 直方图均衡:统计当前帧所有y值分布,构建累积分布函数(CDF),将y映射到0~63的显示行号,确保暗部细节与亮部峰值均可见。
// spectrum_display.c: 频谱图生成核心 void generate_spectrum_buffer(uint8_t *pDispBuf, float32_t *pFFTOut) { static float32_t y_log[1024]; static uint16_t hist[64] = {0}; // 步骤1:1024点幅值转对数(加1防log0) for(uint16_t i=0; i<1024; i++) { float32_t mag = sqrtf(pFFTOut[i*2]*pFFTOut[i*2] + pFFTOut[i*2+1]*pFFTOut[i*2+1]); y_log[i] = log10f(mag + 1.0f); } // 步骤2:直方图统计(量化到64级) for(uint16_t i=0; i<1024; i++) { uint8_t level = (uint8_t)(y_log[i] * 10.0f); // 0~63映射 if(level >= 64) level = 63; hist[level]++; } // 步骤3:构建CDF并均衡 uint16_t cdf[64] = {0}; cdf[0] = hist[0]; for(uint8_t i=1; i<64; i++) { cdf[i] = cdf[i-1] + hist[i]; } uint16_t total = cdf[63]; // 步骤4:将每8个FFT点(1024/128=8)映射到1像素列 for(uint8_t x=0; x<128; x++) { float32_t avg_y = 0.0f; for(uint8_t offset=0; offset<8; offset++) { uint16_t idx = x*8 + offset; if(idx < 1024) avg_y += y_log[idx]; } avg_y /= 8.0f; // CDF均衡:y_mapped = round((cdf[round(avg_y*10)] * 63) / total) uint8_t y_level = (uint8_t)(avg_y * 10.0f); if(y_level >= 64) y_level = 63; uint8_t y_mapped = (uint8_t)((float32_t)cdf[y_level] * 63.0f / total); // 设置OLED像素:y_mapped行对应屏幕Y坐标(63-y_mapped,因OLED原点在左上) set_pixel(pDispBuf, x, 63 - y_mapped); } }

此算法将1024点频谱信息无损压缩至128列,并通过直方图均衡确保微弱谐波(如3次谐波)在屏幕上占据合理高度,而非被基波压制为一条线。

4.2 实时性保障:DMA+双缓冲规避显示撕裂

频谱图刷新需与ADC采样严格同步。资源采用双缓冲+DMA链式传输:

  • Buffer A:DMA正在向其写入新ADC数据;
  • Buffer B:CPU正在对Buffer B执行FFT→THD→频谱图生成;
  • 当DMA完成Buffer A填充,触发中断,交换A/B指针,CPU立即处理新数据。
// main.c 中的DMA配置片段 hdma_adc1.Init.Mode = DMA_CIRCULAR; // 循环模式,持续采样 hdma_adc1.Init.Priority = DMA_PRIORITY_HIGH; HAL_DMA_Start(&hdma_adc1, (uint32_t)&ADC1->DR, (uint32_t)adc_buffer_a, 1024); // ADC中断服务程序 void ADC_IRQHandler(void) { if(__HAL_ADC_GET_FLAG(&hadc1, ADC_FLAG_EOC)) { __HAL_ADC_CLEAR_FLAG(&hadc1, ADC_FLAG_EOC); // 触发FFT处理(使用当前active_buffer) process_spectrum(active_buffer); // 切换缓冲区指针 if(active_buffer == adc_buffer_a) { active_buffer = adc_buffer_b; HAL_DMA_Start(&hdma_adc1, (uint32_t)&ADC1->DR, (uint32_t)adc_buffer_b, 1024); } else { active_buffer = adc_buffer_a; HAL_DMA_Start(&hdma_adc1, (uint32_t)&ADC1->DR, (uint32_t)adc_buffer_a, 1024); } } }

此设计确保频谱图更新帧率严格等于ADC采样率/1024(如2MHz采样率→1953Hz刷新),无丢帧、无撕裂。

4.3 频谱图显示避坑:3个玄学问题排查指南

现象1:频谱图左右颠倒或上下颠倒

原因:FFT输出顺序为“DC-bin1-bin2-...-bin511-Nyquist”,而OLED横轴从左到右对应频率递增,但部分开发者误将pFFTOut[0](DC)画在最右端。
解决:确认generate_spectrum_buffer中x循环从0到127,对应FFT索引0到1023,而非倒序。

现象2:频谱图出现规律性竖条纹(间隔8像素)

原因:x*8 + offset索引计算中,offset未限制在[0,7],当x=127时idx=1023合法,但若x=128则越界读取随机内存,产生伪影。
解决:在for(offset=0; offset<8; offset++)内添加if(x*8+offset >= 1024) break;。

现象3:频谱图亮度随信号幅度增大而变暗

原因:直方图均衡中total = cdf[63]计算错误,如cdf数组未初始化为0,导致total为随机大数,y_mapped趋近于0。
解决:声明static uint16_t hist[64] = {0};并确保cdf数组在每次调用前清零。


5. 系统级协同:ADC-DMA-FFT-THD-Display的时序咬合设计

5.1 关键时序约束:从采样到显示的延迟链分析

整个信号分析流水线存在四级时序依赖,任一环节超时即导致系统崩溃:

模块计算耗时(F407@168MHz)时序约束违反后果
ADC+DMA1024点采样 @2MSPS = 512μsDMA传输必须在下一帧开始前完成采样数据被覆盖,FFT输入脏
FFTarm_cfft_f321024点 ≈ 120μs必须在DMA完成中断后立即启动CPU忙于其他任务,FFT延迟累积
THD50次谐波搜索+计算 ≈ 15μs必须在FFT完成后启动THD基于过期频谱,结果无效
Display128×64像素刷新 ≈ 8ms(I²C)必须在下一帧ADC开始前完成屏幕残留旧图像,显示卡顿

该资源通过中断优先级分级解决:ADC DMA完成中断(IRQ#11)设为最高优先级(NVIC_SetPriority(DMA2_Stream0_IRQn, 0)),确保其抢占一切;FFT/THD在中断中快速执行,Display刷新放在主循环中,利用DMA传输间隙完成。实测端到端延迟稳定在512μs + 120μs + 15μs = 647μs,留有353μs余量应对最坏情况。

5.2 内存布局的生死线:如何避免栈溢出杀死FFT

F407默认栈空间仅1KB,而1024点float32_t数组需4KB。资源强制将所有大缓冲区声明为static并置于.bss段:

// fft_buffers.h #define FFT_SIZE 1024 extern float32_t adc_buffer_a[FFT_SIZE] __attribute__((section(".bss.fft"))); extern float32_t adc_buffer_b[FFT_SIZE] __attribute__((section(".bss.fft"))); extern float32_t fft_output[FFT_SIZE*2] __attribute__((section(".bss.fft"))); // 复数输出

并在链接脚本STM32F407VGTx_FLASH.ld中新增:

.bss.fft (NOLOAD) : { . = ALIGN(16); _bss_fft_start = .; *(.bss.fft) _bss_fft_end = .; } > RAM_D2

此举将FFT缓冲区从栈移至D2域RAM(128KB),彻底规避栈溢出。若忽略此步,arm_cfft_f32调用时因局部变量过大触发HardFault_Handler,现象为程序随机重启。

5.3 动态功耗管理:当FFT计算成为系统功耗瓶颈时

实测显示,连续运行FFT使F407核心温度升高12℃,待机电流从150μA升至8mA。资源引入自适应采样率调节:当THD < 0.5%且连续10帧稳定,自动将ADC采样率从2MSPS降至500kSPS,FFT点数减半至512,功耗下降62%;一旦THD突升至>2%,立即切回高性能模式。此逻辑嵌入thd_calculate返回后:

// power_control.c void adjust_sampling_rate(float32_t thd_value) { static uint8_t stable_count = 0; if(thd_value < 0.5f) { stable_count++; if(stable_count >= 10) { set_adc_sampling_rate(500000); // 切至500kSPS fft_size = 512; stable_count = 0; } } else { set_adc_sampling_rate(2000000); // 恢复2MSPS fft_size = 1024; stable_count = 0; } }

这不仅是省电,更是延长工业设备寿命的关键设计。


6. 设计取舍的真相:为什么这份资源没有用FreeRTOS,也不支持WiFi上传?

6.1 实时性与确定性的终极权衡

有人会问:为何不用FreeRTOS创建FFT任务、Display任务,用队列传递数据?答案藏在arm_cfft_f32的汇编实现里——该函数内部大量使用VLD1,VST1,VMUL,VADD等NEON指令,其执行周期高度依赖内存带宽和Cache命中率。在FreeRTOS环境下,任务切换会清空指令Cache,且多任务抢占导致DMA缓冲区访问竞争,实测FFT耗时从120μs飙升至350μs,超出647μs安全窗口。该资源选择裸机中断驱动,正是为了换取纳秒级的确定性:ADC中断触发后,CPU在12个周期内进入中断向量,3个周期内跳转至ISR,全程无OS调度开销。这种取舍在电机控制、电源监控等硬实时场景中不可妥协。

6.2 接口精简:放弃“炫技功能”换取可靠性

资源刻意剔除了所有非核心功能:

  • 无USB CDC虚拟串口:避免USBD_CDC_Transmit_FS占用30% CPU;
  • 无SD卡日志:规避FATFS文件系统带来的不可预测延迟;
  • 无WiFi/蓝牙上传:射频模块唤醒时间>100ms,远超FFT周期。

所有通信仅保留USART1透传(115200bps),THD值与频谱图数据以二进制帧格式发送,上位机(如Python脚本)负责解析绘图。这种“哑终端”设计,使固件体积压至84KB(Flash),RAM占用仅42KB,为未来升级预留充足空间。

6.3 我的教训:从“想加个WiFi”到“先保FFT不死”的转变

三年前,我在某高校电力电子实验室调试一台逆变器THD监测仪。最初版本集成了ESP8266 WiFi模块,计划将频谱图实时上传云端。结果连续两周无法复现THD突变故障——直到用逻辑分析仪抓取ADC-DMA信号,才发现WiFi模块的AT指令响应抖动(10~200ms)导致DMA缓冲区被覆盖,FFT输入数据错位。我砍掉整个WiFi层,改用UART+PC端Qt程序接收数据,故障瞬间消失。从那以后,我每次设计嵌入式信号处理系统,都强制走一遍“时序链审查”:列出每个模块的最坏执行时间,画出Gantt图,确保总和小于采样周期。当实时性成为红线,所有“锦上添花”的功能都必须让路。

这份“FFT+THD计算+频谱图显示.rar”不是功能堆砌的玩具,而是一份用铜墙铁壁般的时序约束、内存契约和物理定律写就的工程契约。它不承诺炫酷,但保证每一次FFT都精准落地,每一次THD都经得起校准,每一帧频谱图都忠于信号本质。希望帮到你。

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

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

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

立即咨询