简介:面向嵌入式开发者的STM32信号处理实践资源,聚焦在STM32F407平台借助标准库实现FFT,把时域正弦波采样转换为频域分析结果,从而精准测量幅值、频率与相位差。内容覆盖ADC采样、窗函数抑制频谱泄露、FFT变换、频谱分析与结果输出等完整链路,适合已具备C语言与STM32外设基础、希望把快速傅里叶变换落到工业控制、电机控制、音频分析或无线通信等实时场景的中级学习者。压缩包共283个文件,约9.98MB,包含46个c与60个h源文件以及Keil工程文件、库文件、hex与map等,分别对应算法实现、外设驱动、编译配置与最终产物,便于直接编译验证与二次移植。已有1668人学习下载,可据此复现从采集到频域计算的完整流程,理解FFT结果与真实信号参数的对应关系,并借鉴其工程组织方式排查采样、窗函数与频谱换算中的常见偏差。
1. 1024 点 FFT 跑通的那一刻,精度问题才刚刚开始
很多人第一次在 STM32 上跑通 FFT,看到串口吐出一串频域数据、峰值点正好落在某个 bin 上,就觉得这事已经成了。可把信号源调到准确的 1 kHz,读数却跳出 1003.9 Hz;把输入幅值从 1.0 V 压到 0.5 V,算出来的幅值没有线性减半;两路同相的正弦波,相位差却稳定偏了十几度。快速傅里叶变换本身没算错,错的是从模拟前端到频谱读数之间那几段没人讲透的链路:采样时刻、窗函数增益、栅栏效应、双通道同步。搞懂这套机制的价值在于,你能分清哪些误差是算法固有的、哪些是自己接线的锅,而不是对着一个 0.3% 的频率偏差反复重写蝶形运算。
2. 从 DFT 到蝶形:O(N²) 是怎么被旋转因子对称性砍成 O(N log N) 的
离散傅里叶变换的定义本身很短,N 点变换要做 N² 次复数乘加。1024 点就是一百多万次复数乘法,在 72 MHz 的 Cortex-M 上按软件浮点跑,单次测量要几十毫秒,根本谈不上实时。FFT 不是新算法,它只是把 DFT 里大量重复的乘法提前算好、复用起来。理解这一层,才能明白为什么定点实现里旋转因子的位宽、查表大小会直接影响最终精度。
2.1 旋转因子的周期性与共轭对称
DFT 的核心因子是 W_N^k = e^(-j2πk/N)。它有两个性质:周期性 W_N^(k+N) = W_N^k,共轭对称性 W_N^(k+N/2) = -W_N^k。这意味着 N 点变换里真正独立的因子只有前 N/2 个,后一半只是前一半取负。基 2 分解正是靠这条性质把一次 N 点变换拆成两个 N/2 点变换,再把两个 N/2 点变换的结果用一对加减组合起来,组合时只需要乘一次旋转因子。递归下去,乘法次数从 N² 降到 (N/2)·log₂N。
落到实现上,旋转因子通常预生成成一张表,长度 N/2,元素是 Q15 格式的 cos 和 sin。表越长精度越高,但占的 Flash 也越多;1024 点、两个 q15_t 数组,一共 4 KB,对多数 STM32 型号是可以接受的。常见的实现思路是把表放在 Flash 里用 const 修饰,运行时只读不写,省掉 RAM。
2.2 基 2 蝶形与位反转重排
时域抽取的基 2 FFT 有一个前置动作:把输入序列按二进制位反转的顺序重排。原因是每一级分解都把偶数下标和奇数下标分到两组,分解到底之后,原始下标的顺序被打乱了,重排回去才能保证输出是自然顺序。位反转本身不涉及乘法,但它是原地运算的前提,跳过它直接算,出来的频谱是乱序的,峰值位置对不上任何物理频率。
蝶形运算是整个算法的原子操作:取一对输入 (u, v),做 u+v·W 和 u−v·W,两个输出写回原来的两个位置。每一级的所有蝶形互相独立,可以原地进行,这是 FFT 能省内存的关键。
#define FFT_N 1024 /* 预生成旋转因子表:cos(-2*pi*k/N)、sin(-2*pi*k/N),Q15 定标 */ extern const q15_t tw_cos[FFT_N / 2]; extern const q15_t tw_sin[FFT_N / 2]; /* 基 2 时域抽取 FFT,原地运算,进入前必须完成位反转重排 */ void fft_radix2_q15(q15_t *xr, q15_t *xi) { uint16_t len, i, j, k; for (len = 2; len <= FFT_N; len <<= 1) { /* 逐级扩大蝶形跨度 */ uint16_t half = len >> 1; uint16_t step = FFT_N / len; /* 旋转因子表索引步进 */ for (i = 0; i < FFT_N; i += len) { /* 遍历本级所有蝶形块 */ k = 0; for (j = i; j < i + half; j++) { /* 块内每一对 */ q15_t ur = xr[j], ui = xi[j]; q15_t vr = xr[j + half], vi = xi[j + half]; /* 复数乘法:(vr + j*vi) * (cos + j*sin),32 位承接防溢出 */ int32_t tr = (int32_t)vr * tw_cos[k] - (int32_t)vi * tw_sin[k]; int32_t ti = (int32_t)vr * tw_sin[k] + (int32_t)vi * tw_cos[k]; q15_t wr = (q15_t)(tr >> 15); /* Q30 右移回 Q15 */ q15_t wi = (q15_t)(ti >> 15); xr[j] = (q15_t)(ur + wr); xi[j] = (q15_t)(ui + wi); xr[j + half] = (q15_t)(ur - wr); xi[j + half] = (q15_t)(ui - wi); k += step; /* 跳到下一个旋转因子 */ } } } }这段代码有几处值得展开。step = FFT_N / len是旋转因子索引的步进:跨度越大,块内需要的因子越稀疏,索引每次跳step,正好对应 W_N^(j·N/len)。中间乘积用int32_t承接是因为两个 Q15 相乘会得到 Q30,直接存回 q15_t 会截断符号位。>> 15完成 Q30 到 Q15 的定标回退,但要注意这是算术右移,负数才能保持正确。蝶形最后的加减法是最容易翻车的地方:|ur| 和 |wr| 各自最大接近 32767,相加轻松超过 int16 范围,所以输入信号必须做功率归一化或者整体右移一位,否则高电平输入下频谱会出鬼影。
2.3 定点 Q15 下的动态范围取舍
Q15 表示 [-1, 1) 区间的定点小数,分辨率 1/32768。ADC 是 12 位,原始码值 0~4095,转成 Q15 时要先减去直流偏置 2048,再左移四位(相当于乘 16),这样满量程 2048 映射到 32768 附近。注意左移四位后最大值 2048×16 = 32768 已经溢出 int16 正半轴,实际实现里通常只左移三位,或者减偏置后乘 15。
代价是动态范围变窄。一个 1 mV 的小信号叠在 1 V 的直流上,减完偏置后可能只剩几十个码值,量化噪声占比很高。这是定点 FFT 的固有问题,不是代码写错。如果测量对象是小信号,要么在前端加交流耦合和放大,要么换成浮点 FFT——后者在带 FPU 的型号上单次 1024 点大约 1~2 ms,比定点慢但仍可用。
3. 采样链路:为什么定时器触发 + ADC + DMA 是标准库下的稳妥组合
FFT 的结果只对“等间隔采样”这个前提负责。一旦采样时刻抖动,频谱就会糊掉,再精确的蝶形运算也救不回来。标准库时代没有 HAL 的便利封装,定时器 TRGO 触发 ADC、DMA 搬运、半满中断处理这套组合几乎是唯一能把抖动压到纳秒级的做法。
3.1 定时器触发决定了频率刻度的准确性
用软件循环里调ADC_SoftwareStartConvCmd采一个点,两次转换之间的间隔受中断响应、其他任务抢占影响,抖动轻松到几微秒。对 10 kHz 采样率来说,这就是百分之几的周期误差,频率读数直接跟着漂。正确做法是让定时器在更新事件时输出 TRGO,ADC 配置为外部触发上升沿启动,硬件自动完成“到点就采”,CPU 完全不参与。
采样率 fs 由定时器周期决定:fs = TIM_CLK / (PSC+1) / (ARR+1)。72 MHz 时钟、预分频到 1 MHz、ARR 设成 99,得到 10 kHz。这个 fs 必须和代码里传给 FFT 的频率刻度保持一致,任何一处改了指另一处没改,频率读数就会整体缩放——这是排查时第一个要核对的地方。
#define ADC_BUF_LEN 2048 /* 双通道交织:每通道 1024 点 */ volatile uint16_t adc_buf[ADC_BUF_LEN]; void sample_chain_init(uint32_t fs_hz) { /* 1. TIM2 更新事件作为 TRGO,决定采样节拍 */ TIM_TimeBaseInitTypeDef tb; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_TimeBaseStructInit(&tb); tb.TIM_Prescaler = 72 - 1; /* 72 MHz -> 1 MHz */ tb.TIM_Period = (1000000 / fs_hz) - 1; /* 决定 fs */ tb.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tb); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); TIM_Cmd(TIM2, ENABLE); /* 2. ADC1 规则组,外部触发源选 TIM2_TRGO */ ADC_InitTypeDef adc; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); ADC_StructInit(&adc); adc.ADC_ContinuousConvMode = DISABLE; /* 单次,由触发驱动 */ adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; adc.ADC_NbrOfChannel = 2; /* 双通道交替扫描 */ ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_28Cycles); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_28Cycles); ADC_ExternalTrigConvCmd(ADC1, ENABLE); /* 3. DMA1 通道1 循环搬运,半满/全满各触发一次中断 */ DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_StructInit(&dma); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = ADC_BUF_LEN; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_IT_TC | dma.DMA_IT_HT; DMA_Init(DMA1_Channel1, &dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); }关键参数逐个说。ADC_ExternalTrigConv_T2_TRGO让转换起点由硬件给出,CPU 只负责事后收数据。ADC_NbrOfChannel = 2配成扫描模式,两个通道在同一触发下依次转换,但它们并非严格同时刻采样——这个细节在第 5 章会引发相位差问题。DMA_Mode_Circular让缓冲区循环复用,配合半满和全满两个中断,就能实现“算前一半时采后一半”的双缓冲,避免读写同一块内存。
3.2 双缓冲与数据就绪的边界
单缓冲最常见的写法是:等 DMA 全满中断,进去把 1024 点拷出来,再调 FFT。问题是从中断触发到拷贝完成这段时间里,DMA 已经开始覆盖缓冲区头部,拷出来的数据前半段是上一轮、后半段是下一轮,频谱直接裂开。双缓冲把 2048 个 uint16_t 分成两半,半满中断处理前半、全满中断处理后半,处理期间另一半正在被填充,互不干扰。
中断服务函数里只做一件事:置标志位。FFT 和后续计算都放到主循环,否则中断执行时间过长会拖慢采样节拍,甚至丢触发。常见的实现思路是维护一个volatile uint8_t half_ready状态机,主循环轮询到标志就取走对应半区,取完清标志。
3.3 从 12 位码值到 Q15 的映射
ADC 输出是无符号 12 位,FFT 输入要求有符号定点。转换分三步:减直流偏置、转有符号、定标。偏置取 2048(半量程),减完得到 [-2048, 2047],再乘一个定标系数搬到 Q15 的高位。
/* 把交织缓冲区里指定通道的 1024 点转成 Q15 实序列 */ void pack_channel_q15(const uint16_t *src, int ch, q15_t *dst, uint16_t n) { for (uint16_t i = 0; i < n; i++) { int32_t v = (int32_t)src[i * 2 + ch] - 2048; /* 去直流偏置 */ v <<= 3; /* 2048*8 = 16384,留半量程余量 */ if (v > 32767) v = 32767; if (v < -32768) v = -32768; dst[i] = (q15_t)v; } }左移三位而不是四位,是给蝶形运算的中间累加留出 2 倍余量。如果输入信号本来就只有满量程的一半,可以改成左移四位提升信噪比,代价是大幅值信号会削顶,削顶在频域表现为谐波和底噪抬升,比量化噪声更难排查。
4. 幅值、频率、相位差:三个测量量各自的血泪细节
FFT 输出的是复数数组,索引 k 对应频率 k·fs/N。从数组里读一个峰值就能得到频率、幅值、相位,看起来是三个mag[k]和atan2的事。实际测量里,这三个量各自的误差来源完全不同,必须分开处理。
4.1 幅值:归一化系数与窗函数增益补偿
单频信号的频谱是一根谱线,但只有信号频率正好落在 k·fs/N 上时才成立。不落在整数 bin 上时,能量会泄漏到邻近若干 bin,这叫频谱泄露。为了压制泄露,通常会加窗(汉宁窗最常用),代价是主瓣变宽,峰值幅值也被窗函数的相干增益压低。
幅值计算分两步。先算复数的模:mag = sqrt(re² + im²)。再乘归一化系数:单边谱要乘 2,加窗后还要除以窗函数的相干增益。汉宁窗的相干增益是 0.5,所以总系数是 4;矩形窗是 1,总系数是 2。这个系数如果漏乘,幅值会恒定偏小一半,而且不随频率变化,很容易被误判成前端增益问题。
/* 从 1024 点频谱中提取单频分量的幅值、频率、相位 */ void analyze_tone(const q15_t *xr, const q15_t *xi, float fs, tone_t *out) { float mag[FFT_N / 2]; uint16_t kmax = 1; for (uint16_t k = 1; k < FFT_N / 2; k++) { float re = xr[k] / 32768.0f; float im = xi[k] / 32768.0f; mag[k] = sqrtf(re * re + im * im); if (mag[k] > mag[kmax]) kmax = k; } /* 抛物线插值修正栅栏效应,delta 落在 [-0.5, 0.5] */ float a = mag[kmax - 1], b = mag[kmax], c = mag[kmax + 1]; float delta = 0.5f * (a - c) / (a - 2.0f * b + c); out->freq_hz = (kmax + delta) * fs / FFT_N; /* 单边谱 x2,汉宁窗再除以 0.5 相干增益 */ float scale = 2.0f / 0.5f; out->amp_vpp = mag[kmax] * scale; /* 相位取插值后主峰处的 atan2 */ out->phase_rad = atan2f(xi[kmax], xr[kmax]); }scale这个系数是本段的重点:除以 0.5 就是补偿汉宁窗的相干增益,乘 2 是把双边谱折成单边谱的物理幅值。窗口类型一换,这个系数必须跟着换,否则幅值读数会成比例失真。有些实现图省事直接乘 4,凑巧对上汉宁窗,换窗后就全错。
4.2 频率:栅栏效应与插值校正
频率分辨率是 fs/N。10 kHz 采样、1024 点,每个 bin 是 9.77 Hz。如果信号是 1000 Hz,它落在第 102.4 个 bin 上,峰值会出现在第 102 和第 103 个 bin 之间,直接读 kmax 得到的是 996.1 Hz 或 1005.9 Hz,误差最大可达半个 bin。
抛物线插值用峰点及左右邻点拟合一条抛物线,把峰位偏移 delta 估出来,精度能提升一个数量级。前提是频谱确实像抛物线——加窗不当或者信号含两个相近频率时,插值反而会引入偏差。更严谨的做法是 Jacobsen 估计或者基于相位差的频率估计,但计算量更大,在 MCU 上要算一笔账。
还有一种“免费”提高分辨率的方法:补零。把 1024 点补到 4096 点再做 FFT,bin 间隔变成 fs/4096。但补零不增加信息量,只是把频谱插值得更密,主瓣宽度不变,对实际频率精度的提升有限,主要作用是让插值更容易定位峰位。
4.3 相位差:两路 FFT 的相位相减为什么不准
测两路同频信号的相位差,直觉做法是分别做两次 1024 点 FFT,各自取峰值 bin 的atan2,再相减。这个做法在仿真里很准,在实际硬件上经常偏出一个固定角度,而且这个偏差随频率变化。
根因在采样时刻。标准库下配置 ADC 双通道扫描时,通道 1 和通道 2 是同一个触发沿启动,但它们之间隔了一次完整的转换时间(采样 + 转换,几十个 ADC 时钟周期)。这个时间差对 1 kHz 信号来说很小,等于相位偏移2π·f·Δt,但对 10 kHz 甚至更高频率,偏移就不可忽略。频率越高,固定时间差引入的相位误差越大,这正是“偏差随频率变化”的来源。
解决方向有两条。一是改用双 ADC 同步规则模式,两个 ADC 在同一触发下各自采样同一时刻的不同通道,时间差压到纳秒级。二是承认这个时间差,在软件里按2π·f·(n·T_adc)做补偿,其中 T_adc 是单次转换周期。前者硬件成本高一点但一劳永逸,后者只在频率已知且不高的场景下可靠。
另外,峰值 bin 的相位对频率偏移很敏感:信号频率不完全落在 bin 上时,相邻 bin 的相位会围绕真实值旋转,插值位置稍有偏差,相位就跟着偏。更稳的办法是用插值后的频率和复数谱在峰点附近做最小二乘拟相位,或者用两路信号的互谱X1·conj(X2)取相位,互谱法对幅度不敏感,只依赖相位一致性。
5. 避坑与排查:五条把结果带偏的常见问题
5.1 频率读数整体偏移、幅值随频率变化
现象:输入 1 kHz,读数稳定在 980 Hz 附近;换 5 kHz 后误差更大;幅值读数在低频段正常,高频段明显偏低。
原因分两类。频率整体偏移通常是 fs 标称值和实际值不符——定时器预分频或 ARR 算错,或者晶振本身偏差。幅值随频率下降是前端抗混叠滤波器的滚降造成的,滤波器在通带边缘已经衰减了几个 dB,但代码里仍按理想通带归一化。
解决:先用已知频率的信号源反推实际 fs,即fs_real = fs_nominal × f_measured_known / f_read,核对定时器参数;幅值补偿则要在标定环节测出每个频点的衰减系数,建一张小表做修正,或者把测量范围限制在滤波器平坦段。
5.2 两路相位差恒定偏十几度
现象:两路同相正弦输入,相位差读数稳定在 12° 左右,改变信号频率后偏角跟着变。
原因:ADC 双通道扫描不是同时采样,通道之间存在固定的转换时间差,如 4.3 节所述,这个时间差乘上信号角频率就是相位误差。另外如果两路走线长度差异大,也会叠加一个固定延时。
解决:换双 ADC 同步模式,或在软件里按已知 Δt 做相位补偿Δφ = 2π·f·Δt。补偿前先确认 Δt 是“采样保持时间 + 转换时间”的总和,只算转换时间会补偿不足。
5.3 偶发数据整段为 0 或频谱乱跳
现象:串口打印的 1024 点里有整段 0,或者某次 FFT 结果完全对不上,重跑一次又正常。
原因:DMA 缓冲区被覆盖。用了单缓冲,主循环取数据时 DMA 已经回卷到头部;或者中断里置了标志但主循环处理太慢,下一轮中断到来时数据已经被改写。
解决:改成双缓冲 + 半满/全满中断,主循环处理一块时另一块在填充;同时把 FFT 耗时测量出来,确保“处理一块的时间 < 填充一块的时间”,否则要降采样率或减点数。
5.4 频谱上出现不该有的谐波和底噪
现象:输入是纯正弦,频谱里却能看到 2 次、3 次谐波,底噪也偏高。
原因:ADC 输入过载削顶,或者输入阻抗不匹配导致波形失真;也可能是 Q15 定标时左移太多,蝶形运算中间结果溢出,产生回绕。
解决:示波器看 ADC 引脚波形有没有顶部削平;把 Q15 定标系数降低一档,观察谐波是否消失;确认蝶形加减法之前有没有做整体右移保护。
5.5 直流附近有一根巨大的谱线
现象:k=0 处幅值远大于信号峰,且随输入直流偏置变化。
原因:去偏置没做或偏置值不准。ADC 的实际零点可能不是 2048,受基准电压和前端失调影响。
解决:采一段静态输入,求平均值作为真实偏置,替代固定 2048;同时在 FFT 前对数据做一次均值扣除,双重保险。注意扣除均值会轻微改变频谱形状,对极低频测量要谨慎。
6. 设计取舍:定点还是浮点、点数选多少、精度换什么
把这条链路走完,会发现每一个参数都是拿一种资源换另一种资源。
点数 N 决定分辨率和耗时。1024 点、10 kHz 采样,分辨率 9.77 Hz,频率精度靠插值能到 1 Hz 量级;换成 4096 点,分辨率提到 2.44 Hz,但内存占用翻四倍,FFT 耗时大约变成 4.5 倍(N log N 的比例)。在 SRAM 只有几十 KB 的型号上,4096 点 Q15 复数数组就要 32 KB,加上旋转因子表和双缓冲,很容易顶到天花板。我一般会先把 N 定在 1024 或 2048,用插值补精度,而不是盲目加点数。
定点还是浮点,本质是速度和动态范围的交换。Q15 定点在无 FPU 的型号上比软件浮点快三到五倍,但小信号叠大直流时量化噪声压不住;带 FPU 的型号跑浮点 FFT,1024 点约 1~2 ms,动态范围直接拉满,代价是代码体积和功耗略高。如果测量对象是固定幅度的工频或音频信号,定点足够;如果是宽动态范围的传感器输出,浮点省心得多。
窗函数和插值是另一组交换。矩形窗主瓣最窄、频率分辨率最高,但泄露严重,幅值读数对频偏极其敏感;汉宁窗泄露小、幅值稳定,但主瓣变宽,两个相近频率分不开。测单一正弦、要求幅值准,选汉宁窗加相干增益补偿;要求频率分辨率、信号又是整周期采样,矩形窗反而更好。插值校正能补频率精度,但它假设主瓣形状规整,多频信号下会失效,这时候要么加点数,要么改用更复杂的频率估计算法。
相位差这一环最容易被低估。软件补偿能救急,但补偿系数依赖对 ADC 时序的准确建模,换一款芯片或改一次采样时间就得重标定。我现在的习惯是:只要涉及两路相位,优先确认硬件是否支持同步采样,不支持就在设计阶段把测量频率上限压下来,让固定时间差引入的相位误差落在可接受范围内,而不是事后靠公式硬掰。
从那以后我每次搭这套链路,都强制走一遍“已知信号源标定 → 核对 fs → 查削顶 → 验相位”的流程,任何一步跳过,后面调参都是盲调。希望这套拆解能帮你在自己的板子上少绕几圈——文末附带的工程里把采样链路、定点 FFT、插值校正和双缓冲都拆成了独立文件,可以直接对照着改参数验证。
本文还有配套的精品资源,点击获取