STM32F103 FFT频谱分析实战:采样链、定点算法与上位机全解析
2026/9/10 22:21:43 网站建设 项目流程

简介:面向STM32F103C8T6的FFT信号分析工程包,聚焦单片机ADC采样与快速傅里叶变换实现,适合嵌入式开发者、电子竞赛选手及信号处理初学者。工程以C代码为主,包含完整外设配置(ADC、USART、定时器)及FFT运算逻辑,通过串口将频域结果上传至上位机,便于观察频谱。压缩包共112个文件,主要类型为41个h头文件、41个c源文件、19个汇编s文件,另有IAR工程文件(ewp/ewd)、链接脚本、烧录脚本等,整体仅524KB,结构紧凑,可直接导入工程阅读。已有1719人学习。资源提供源代码、配置文件、编译脚本及上位机程序,可从中学习Cooley-Tukey等FFT分解方法、ADC采样率与分辨率对频谱的影响,以及嵌入式端与PC端的数据交互流程,是一份可实操的入门参考。

1. FFT能否在Cortex-M3上实时跑?关键不在算力在采样链

STM32F103C8T6 没有 FPU,不少人拿到这个 DSO 工程的第一反应是:FFT 会不会把 CPU 拖死。实际拆过之后会发现,256 点定点 FFT 在 72MHz 主频下只需要毫秒级计算,真正决定频谱质量的不是算力,而是 ADC 的采样时序。内部 ADC 若不配合定时器触发和 DMA 搬运,采集到的样本间隔会产生微秒级抖动,FFT 会把这种抖动直接折算成频谱噪底,后续算法再准也白搭。这个工程把 TIM 触发 ADC、DMA 循环搬运、定点 FFT、串口上传和 TFT 绘制串成一条完整链路,对入门嵌入式信号处理的人非常合适;对做工业采集的人,它的采样率分档、窗函数取舍和通信协议设计也有直接参考价值。

2. ADC 采样链路:TIM 触发、DMA 搬运和 256 点缓冲

2.1 为什么选择 TIM+ADC+DMA 而不是 while 轮询

FFT 对样本的等间隔性极其敏感。软件轮询 ADC 的做法在主循环里会受中断、分支和任务调度影响,采样间隔抖动可达微秒级;但 FFT 默认这些样本是均匀时间间隔的,抖动就等效于给信号叠了一层相位噪声,频谱上表现为本底抬升、小信号被淹没。工程上的常见做法是让定时器的比较事件直接触发 ADC 转换,再由 DMA 把结果搬进内存,CPU 只在整帧采集完成后做一次 FFT,主循环完全不参与采样时序。

这套链路在 F103 上的标准接法是:TIM2 输出 PWM1 信号,映射到 ADC1 的外部触发输入,每个上升沿启动一次转换;转换结束后数据自动写入 ADC1->DR,DMA1 通道1 把 DR 里的 16 位结果搬进用户数组。ADC 的连续转换模式要关掉,否则内部会自动启动下一轮,外部触发就失去意义了。DMA 开循环模式,256 次搬运完成后地址自动回卷,可以一帧接一帧地采,不需要每帧重新初始化。

2.2 定时器触发 ADC 的关键配置

2.2.1 TIM2 产生触发脉冲
TIM_TimeBaseInitTypeDef tim; TIM_OCInitTypeDef oc; // 72MHz 总线时钟,PSC=3 得到 18MHz 计数时钟 tim.TIM_Prescaler = 3; tim.TIM_Period = 899; // 18MHz / (899+1) = 20kHz tim.TIM_ClockDivision = TIM_CKD_DIV1; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim); oc.TIM_OCMode = TIM_OCMode_PWM1; oc.TIM_Pulse = 450; // 50% 占空比 oc.TIM_OutputState = TIM_OutputState_Enable; TIM_OC1Init(TIM2, &oc); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);

这段配置把采样率定在 20kHz。计算关系是:采样率 = 72MHz / ((TIM_Prescaler+1) * (TIM_Period+1)),所以 PSC=3、ARR=899 得到 20kSps;改成 PSC=3、ARR=359 就是 50kSps。TIM_Pulse 只影响触发边沿的相位,不影响触发频率,但占空比不要太小,否则输出比较边沿抖动会变大,间接增加 ADC 触发时间的不确定性。

2.2.2 ADC 与 DMA 的衔接
ADC_InitTypeDef adc; DMA_InitTypeDef dma; adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; adc.ADC_ContinuousConvMode = DISABLE; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC1; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = 256; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel1, &dma);

ADC_SampleTime 选 239.5 周期是刻意的。F103 的 ADC 在 12 位模式下采样时间越长,内部采样电容充电越充分,输入源阻抗带来的增益误差越小。ADC 的 12 位结果是右对齐存在 DR 低 16 位,所以 DMA 宽度必须配成半字。循环模式在这里是关键,它让采集和 FFT 计算可以错峰进行:DMA 搬运后半帧数据时,CPU 正在算前半帧的 FFT,互不阻塞。

2.3 采样率分档与频率分辨率

采样率TIM2 PSCTIM2 ARR奈奎斯特上限256点FFT频率分辨率
20 kSps389910 kHz78.125 Hz
50 kSps335925 kHz195.3125 Hz

频率分辨率等于采样率除以点数,也就是相邻两个频点之间的间距。做音频频谱,20kSps 已经够用;做宽频噪声或脉冲信号观测,可以切到 50kSps,但频点间隔变粗。想同时提高采样率和分辨率,只能增加 FFT 点数,比如 1024 点 FFT 在 F103 的 20KB SRAM 里也放得下,代价是计算时间按 N·log2(N) 增长,屏幕刷新率会明显下降。

不要把采样率往上调太狠。F103 的 ADC 时钟上限约 14MHz,单通道 12 位转换需要 12.5 个周期加采样时间,239.5 周期采样在 12MHz ADC 时钟下大约 20μs,所以 50kSps 已经是这个配置下的稳妥上限。超过之后 ADC 结果开始跳动,FFT 出现的就不再是真实频谱而是量化噪声。

2.4 抗混叠滤波与采样抖动

FFT 的结果默认输入信号带宽在奈奎斯特频率以内。如果被测信号里混入了高于采样率一半的频率分量,它会折叠回低频段,在频谱上形成假的尖峰。工程上的常见做法是在 ADC 输入引脚前加一阶 RC 低通,截止频率取采样上限的 60%~70%。以 20kSps 为例,把截止频率放在 12kHz 附近,R=120Ω、C=100nF 的标称值算出来约 13.3kHz,够用,也不会明显衰减目标频段的信号。

采样抖动这块更容易被忽略的是硬件来源。PCB 走线过长、VREF+ 纹波过大、触发边沿不够陡,都会让 ADC 的采样时刻漂移。抖动对高频分量的影响比对低频明显,因为时间误差折算成相位误差时要乘以信号频率。把 VREF+ 的 100nF 滤波电容尽量贴近芯片引脚,减少模拟输入走线长度,多数噪底偏高的问题能消掉一大半,这比在软件里做任何平滑都管用。

3. 定点 FFT:位反转、蝶形运算与旋转因子表

3.1 在 F103 上做 FFT 的选型:定点优于软浮点

Cortex-M3 没有硬件浮点单元,C 库里的 float 运算会编译成软浮点子程序调用,一次复数蝶形涉及多个函数跳转和栈操作,256 点 FFT 跑下来往往是十几毫秒。定点实现的思路是把信号归一化到 [-1,1) 区间,用 Q15 格式表示,复数乘法用 32 位中间变量完成再移位回 16 位,速度通常比软浮点快一个量级。

STM32 官方提供的 CMSIS-DSP 库里 arm_cfft_q15 确实高效,但这个工程里看不到对 DSP 库的依赖,旋转因子表和蝶形运算都是自己实现的。对学习来说这反而是好事:你能看到每一级蝶形的索引变化,而不是把 FFT 当成黑盒。对工程来说,手写定点 FFT 的内存占用非常可控,实部、虚部各 256 个 int16_t,加上旋转因子表 512 字节,总共约 1.5KB,在 F103C8 的 20KB SRAM 里留出了充足的余量。

工程里带 stm32f10x_flash.c,比较顺手的做法是把旋转因子表在 PC 端算好,以 const 数组烧进 Flash,避免上电时调用 sin/cos。三种实现路径的取舍关系如下:

实现方式相对速度额外资源维护成本
软件浮点最低,随便改
手写 Q15 定点旋转因子表约 512B中等,适合教学
CMSIS-DSP最快依赖库,内存需对齐低,但偏黑盒

3.2 基2时间抽取 FFT 的工程化实现

3.2.1 位反转排序
static void bit_reverse(int16_t *re, int16_t *im, uint16_t n) { for (uint16_t i = 1, j = 0; i < n; i++) { uint16_t bit = n >> 1; for (; j & bit; bit >>= 1) j ^= bit; j |= bit; if (i < j) { int16_t t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; } } }

这段位反转手法在数字信号处理教材里叫比特逆序,核心是让 j 按二进制从高位向低位进位,等效于把索引的二进制位序颠倒。n=256 时 bit 从 128 开始右移;如果 j 的当前位为 1,就异或清除并继续向左试探,直到找到一个 0 位再置 1。if (i < j)保证每一对元素只交换一次,避免换过去又换回来。位反转完成后,后续蝶形就可以按 2、4、8……的跨度逐级合并,不再需要处理乱序索引。

3.2.2 蝶形运算主体
void fft_q15(int16_t *re, int16_t *im, uint16_t n) { const int16_t *cos_tab; /* Q15 余弦表,长度 n/2 */ const int16_t *sin_tab; /* Q15 正弦表,长度 n/2 */ uint16_t len, i, k; bit_reverse(re, im, n); for (len = 2; len <= n; len <<= 1) { uint16_t half = len >> 1; uint16_t step = n / len; for (i = 0; i < n; i += len) { for (k = 0; k < half; k++) { int16_t w_re, w_im; int32_t t_re, t_im; w_re = cos_tab[k * step]; w_im = -sin_tab[k * step]; t_re = ((int32_t)re[i + k + half] * w_re - (int32_t)im[i + k + half] * w_im) >> 15; t_im = ((int32_t)re[i + k + half] * w_im + (int32_t)im[i + k + half] * w_re) >> 15; re[i + k + half] = re[i + k] - t_re; im[i + k + half] = im[i + k] - t_im; re[i + k] += t_re; im[i + k] += t_im; } } } }

外层 len 是当前蝶形组长度,每轮翻倍。旋转因子的索引不是直接用 k,而是 k 乘以 step,因为越靠后的级,旋转因子在整表里取点越稀疏,这是基 2 FFT 分治结构的固有规律。Q15 乘法最容易出错的是移位位置:两个 16 位定点数相乘得到 32 位中间结果,低 15 位是小数部分,必须右移 15 位才能回到 Q15 格式。用 int32_t 承接乘积再移位,防止中间结果溢出。

如果换成 arm_cfft_q15,官方库会用块浮点机制自动做中间级缩放,防止溢出;手写版为了安全,输入信号最好预留 6dB 余量,或者全部样本先右移一位再进 FFT。DSO 场景里 ADC 很少满幅输入,做一次整体衰减是常见做法,对显示影响很小。

3.3 幅值计算:整数开方替代 sqrt()

FFT 的结果是复数,显示频谱时需要模长 sqrt(re² + im²)。F103 的 C 库 sqrt 是软浮点实现,128 个频点逐个调用会拖慢刷新率。常见做法是用整数逐位逼近开方,或者干脆用近似公式。逐位开方在 Cortex-M3 上编译后只有几十个周期:

static uint16_t isqrt32(uint32_t x) { uint32_t res = 0; uint32_t bit = 1u << 30; while (bit > x) bit >>= 2; while (bit) { if (x >= res + bit) { x -= res + bit; res = (res >> 1) + bit; } else { res >>= 1; } bit >>= 2; } return (uint16_t)res; }

这个函数的循环次数固定,不依赖浮点库,每次调用开销很低。FFT 输出幅度会随 N 缩放,显示前统一乘一个固定增益系数就行,不必每次计算。还有一个细节:直流分量在 FFT 结果里是第一个点,幅值通常远大于其他频点,绘图前可以单独把它裁剪掉,否则纵轴自动缩放后交流分量会贴到底部。

3.4 频谱泄漏与窗函数

矩形窗看起来最简单,但 DSO 采集的信号很难做到整周期截断,频谱主瓣旁边会出现明显旁瓣泄漏。工程上的常见做法是在时域乘汉宁窗:w(n) = 0.5 * (1 - cos(2πn / (N-1)))。用 Q15 实现时先把窗函数表算好,采样缓冲乘完窗再进 FFT,避免运行时反复算三角函数。

加窗后主瓣变宽,靠近的两个频率分量更容易混在一起;同时幅度会有约 0.5 的加权损失,显示时乘 2 补偿回来。想要更准的频率读数,可以对峰值附近的 3 个点做质心插值,公式是f_est = f_k + (X[k-1] - X[k+1]) / (2*(X[k-1] - 2*X[k] + X[k+1])) * Δf。在 78.125Hz 分辨率的 20kSps 档下,这能把频率读数做到十几赫兹以内的精度,代价只有几次整数运算,很适合 DSO 里的频率游标功能。

4. 串口帧协议:一帧 516 字节的带宽边界

4.1 帧格式设计

频谱数据要送往上位机,UART 是最省事的通道。工程用 USART1 跑 115200、8N1,帧格式设计得很紧凑:

字节偏移内容长度说明
0~10xAA 0x552同步头
2~513幅度谱512256 个 uint16_t,小端
514~5150x0D 0x0A2帧尾

定长帧的好处是接收端逻辑简单:找同步头、收满长度、校验帧尾,完成一帧。115200bps 下每个字节实际传输 10 bit,516 字节需要约 44.8ms。如果目标是 20fps 刷新率,也就是 50ms 一帧,串口占用接近 90% 带宽,已经没有余量给上位机处理和屏幕绘制了。我一般会把刷新率控制在 10~15fps,留出 20% 以上余量,否则数据会出现排队积压,旧的频谱还没画完新的就来了。

4.2 DMA 双缓冲发送

发送端直接阻塞等 TXE 标志会卡死主循环,516 字节约 44.8ms 的发送时间里 CPU 什么正事都干不了。常见做法是 DMA 发送,外加双帧缓冲。DMA 配置如下:

DMA_InitTypeDef dma_tx; dma_tx.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; dma_tx.DMA_MemoryBaseAddr = (uint32_t)tx_buf; dma_tx.DMA_DIR = DMA_DIR_PeripheralDST; dma_tx.DMA_BufferSize = 516; dma_tx.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma_tx.DMA_MemoryInc = DMA_MemoryInc_Enable; dma_tx.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; dma_tx.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; dma_tx.DMA_Mode = DMA_Mode_Normal; DMA_Init(DMA1_Channel4, &dma_tx); USART_DMACmd(USART1, USART_DMAReq_Tx, ENABLE);

双缓冲的思路是:A 缓冲通过 DMA 发送时,CPU 往 B 缓冲写下一帧,DMA 发送完成中断里交换两个缓冲的指针。这样 ADC 采集、FFT 计算、串口发送三者并行,主循环几乎不被阻塞。需要注意 DMA 发送完成中断触发时,最后一个字节可能还在移位寄存器里,此时立刻改写缓冲会破坏它。稳妥的做法是在发送完成中断里先等一下USART_FLAG_TC再换指针。

4.3 接收端容错与 Modbus 式超时判断

定长帧在链路质量差的时候会丢同步。协议上可以借鉴 Modbus RTU 的 3.5 字符超时规则:接收端如果两个字节间隔超过 3.5 个字符时间,就认为上一帧结束,状态机复位。对 115200bps 来说,3.5 字符约 304μs,在 PC 上位机里可以用时间戳判断,实现成本很低。帧头 0xAA 0x55 和帧尾 0x0D 0x0A 提供双重校验,即使中间丢了几个字节,状态机也会在下一帧同步头处自动恢复,不需要复位设备或清空缓冲。

这里容易被漏掉的是采样率切换的通知。帧里只有幅度谱,没有采样率信息,上位机如果固定按 20kSps 画横轴,切换到 50kSps 档后频率全错。工程做法是在帧头后面加一个字节的采样率档位,或者用不同的同步头区分两档,上位机解析时把采样率作为参数传入,而不是硬编码。

5. Python 上位机:帧同步、绘制与 MATLAB 交叉验证

5.1 上位机选型

串口数据用 Python 处理非常直接,pyserial 负责收数据,matplotlib 负责绘图,整个调试环境可以在一台没有专业软件的普通电脑上跑起来。这个链路还有一个额外好处:调试期可以把接收到的原始帧导出成 CSV,再用 MATLAB 或者 numpy 独立做一次 FFT,和板端结果对比,很快就能判断是采集问题还是算法问题。如果只在串口助手里看十六进制,频谱曲线形态很难目测出来,排查效率会低很多。

5.2 状态机解码

import serial import struct import numpy as np import matplotlib.pyplot as plt ser = serial.Serial('COM5', 115200, timeout=0.5) state = 0 buf = bytearray() while True: chunk = ser.read(256) for v in chunk: if state == 0 and v == 0xAA: state = 1 elif state == 1 and v == 0x55: state = 2 buf.clear() elif state == 2: buf.append(v) if len(buf) == 512: mag = struct.unpack('<256H', bytes(buf)) np.savetxt('fft_mag.csv', mag, delimiter=',') # 20kSps 下正频域仅前 128 个点有效 freq = np.linspace(0, 10000, 128) plt.clf() plt.plot(freq, mag[:128]) plt.pause(0.05) state = 0

逐字节状态机的好处是不会被串口缓冲里的粘包带偏。ser.read(256)一次读出的数据可能包含多帧内容,但状态机只有完整收到 512 字节数据后才切回同步头检测;如果中间出现坏字节,下一帧的 0xAA 0x55 会自动让它重新同步。struct.unpack('<256H')按小端解析 256 个无符号短整型,必须和板端发送字节序一致,ARM 默认小端,所以这里没有问题。

Linux 下把端口名改成/dev/ttyUSB0,Windows 保持COM5timeout=0.5让 read 超时返回空字节,方便 Ctrl+C 退出。刷新率由plt.pause(0.05)控制,对应 20Hz;实际使用建议改成 0.1,减轻 CPU 负载,避免上位机把整台机器的串口缓冲垫高。

5.3 导出 CSV 到 MATLAB 做算法级核对

上面代码里的np.savetxt每次循环都会覆盖fft_mag.csv,这个文件可以直接被 MATLAB 读取:

data = csvread('fft_mag.csv'); plot(abs(fft(data, 256)));

把 MATLAB 的结果和板端串口发出来的幅度谱叠图,如果峰值位置和相对幅度对得上,说明 ADC 采集、定点 FFT、串口协议整条链路没有逻辑错误。如果板端幅度系统性偏小,重点检查定点 FFT 里的移位位置,最常见的是复数乘法后忘了>>15,导致每级蝶形结果被放缩;如果峰值位置偏了一个或两个频点,检查位反转和旋转因子表索引是否一致。

用 MATLAB 交叉验证这一招不需要额外硬件成本。它把“板端算法错误”和“上位机解析错误”快速分开:板端和 MATLAB 结果一致但频谱不对,问题在采集或输入信号;不一致,问题在 FFT 或协议解析。

6. TFT 频谱显示优化:局部重绘与峰值保持

6.1 局部刷新与峰值保持

工程里带着 pic.c、picture.c 这类位图资源,以及 TFT.cspy.bat 这种带 cspy 命名的批处理脚本,后者通常是 IAR EWARM 调试器生成的命令行烧录脚本,说明原工程是在 TFT 屏上直接绘制频谱的。全屏刷新的瓶颈不在 FFT,而在屏幕写像素的速度,SPI 驱动的小屏全屏刷新一帧要几十毫秒。常见做法是只更新频谱曲线经过的像素点:上一帧用背景色把旧位置画掉,这一帧再画新位置,每根谱线只操作一个点,256 根谱线就是 256 个点,比整屏填充快一个数量级。

峰值保持是 DSO 很实用的功能。开一个int16_t peak[128]数组,每次算完幅度谱后做一次更新:

for (i = 0; i < 128; i++) { if (mag[i] > peak[i]) peak[i] = mag[i]; else peak[i] -= 2; /* 缓慢衰减 */ }

每帧刷新时把 peak 数组用另一种颜色叠加在主谱线上方。偶发毛刺在普通刷新下可能一闪而过,峰值保持可以让它停留在屏幕上直到衰减结束。peak[i] -= 2的衰减速度适配 10fps 刷新率,改小留得更久,改大则接近实时显示。注意 peak 数组要初始化为全 0,并且只在采样率切换时清空,否则切换档位后旧峰值会跨频率范围残留。

6.2 下载与信号验证

最后提两个容易卡的硬件点。SWD 下载失败时先看 BOOT0、BOOT1 跳线,BOOT0 必须拉低才会执行用户 Flash 里的程序;DAP 仿真器接长杜邦线时通信不稳定,把 SWD 时钟从 4MHz 降到 1MHz 大多能解决。这类最小系统板还要确认复位电容和 VREF+ 旁路电容确实存在,否则 ADC 采样值会漂移,频谱上表现为低频段毛刺增多。

验证 FFT 是否正确的办法很直接:用一个引脚输出已知频率的 PWM 方波,飞线到 ADC 输入。方波在频域里是基波加奇次谐波,如果基波位置和设置频率一致,说明采样率、FFT 点数、上位机横轴三者是吻合的。把信号源换到 3kHz 正弦波,重复上面的频率读数核验,两个频点都对得上,FFT 的频标就是可信的,后面再改显示和协议都只是在已有骨架上加肉。

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

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

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

立即咨询