简介:《老生谈算法》系列的MATLAB实现FFT算法程序文档,面向数字信号处理初学者、通信类课程学生及需用频谱分析的MATLAB开发者,系统讲解快速傅里叶变换的数学原理与工程实现。文档先阐明FFT结果中每个频率点的物理含义,包括复数的模与相位、直流分量倍率、N/2幅值关系、频率分辨率与采样时间的关系,以及结果的对称性质。随后以采样频率100Hz、采样点数128、频率10Hz的正弦信号和矩形波为对象,给出完整的MATLAB源代码,演示幅值谱、均方根谱、功率谱、对数谱的绘制方法,并通过IFFT恢复时域波形。整个压缩包仅含1个docx文件,大小15KB,便于阅读和复制代码,适合课程设计、通信信号处理实验以及复习FFT核心概念的快速参考。目前已有354人学习下载,对初学者理解时频变换和频谱图解读有直接帮助。
1. 为什么在 MATLAB 里还要手动实现一遍 FFT
fft(x)在 MATLAB 里几乎是最常用的一行信号处理代码,但“会用”和“能实现”是两回事。自己实现一遍 FFT 不是要替代内置函数,而是为了看清三件事:频谱结果是怎么从逐点求和变成蝶形迭代的;复数运算的舍入误差会累积到什么量级;以及当你要给vivado fft这类 FPGA IP 核做浮点参考模型时,手里有没有一份能看懂也能改的基-2 脚本。这篇内容按“数学结构 → MATLAB 迭代实现 → 与内置 fft 的 benchmark → 工程验证”推进,主程序代码量不大,但位反转、旋转因子和循环边界都需要逐条确认,每一处参数我都会单独说明。适合已经会用fft接口,但想深入底层或要对齐硬件实现的工程师。纯调包选手可以留个印象再走。
2. 从 DFT 到基-2 FFT:蝶形运算与旋转因子的结构
2.1 直接 DFT 的计算代价与分解前提
离散傅里叶变换(DFT)的定义是
$X[k]=\sum_{n=0}^{N-1} x[n] \cdot W_N^{kn},\quad W_N = e^{-j2\pi/N}$。
如果直接按这个公式计算,每算一个X[k]需要做 N 次复数乘法,N 个频点总共是 N² 次。N 取 65536 时,这个量级是 4.3e9 次复数乘加,放在 MATLAB 的循环里基本就是几分钟到几十分钟的量级,完全没法当工具用。FFT 之所以能把这个量级压到N·log2N,靠的是一前一后两个动作:先把旋转因子按“奇偶分组”拆开,再利用它的对称性合并同类项。
拆分的起点是观察 W_N 的两条性质:$W_N^{k+N/2} = -W_N^k$,以及 $W_N^{2kn} = W_{N/2}^{kn}$。这两条式子意味着,偶下标点和奇下标点可以独立做 N/2 点 DFT,最后再用一个“带符号翻转”的加减法合起来。这个合并过程画在信号流图上形状像蝴蝶翅膀,所以叫蝶形运算。基-2 FFT 要求每级长度正好减半,一路拆到 1 点,所以输入长度必须满足 N=2^m。长度不对时,常见做法是先补零到最近的 2 的幂再进 FFT,代价是频率分辨率会变细,但幅度谱包络不变。dso138 示波器的 FFT 固件里用的也是同样的补零策略,采集长度不够 2 的幂时先把尾部填零。
2.2 旋转因子的周期性与对称性
蝶形运算里最消耗时间的是旋转因子计算。假设某一级蝶形的跨度为len,半跨为half = len/2,这一级只需要知道 $W_{len}^{0}$ 到 $W_{len}^{half-1}$ 共 half 个值。由于下半个区间的旋转因子正好是上半个区间的相反数,自始至终只需要存 N/2 个复数值,内存和计算都能减半。用 MATLAB 预计算旋转因子表就是这个常见写法:
N = 16; % FFT 点数,必须是 2 的幂 k = 0:N/2-1; % 只需要前 N/2 个角度 W = exp(-2j * pi * k / N); % 复数旋转因子表,N=16 时长度 82j是 MATLAB 的虚数单位写法,exp(-2j*pi*k/N)一次性生成长度为 N/2 的复数向量。后续迭代过程中,第 m 级需要旋转因子时直接在这个表里隔点取,不用每次重新调cos和sin。若写成2i也合法,但项目里建议统一用2j,避免和循环变量i混淆。旋转因子的默认精度是双精度浮点,单次计算误差在 1e-16 量级,但蝶形级数多了以后误差会逐步累积,这是后面对比手写实现与内置fft误差时要重点盯的地方。
2.3 位反转排序的索引规律
蝶形运算先按奇偶拆分、再逐级合并,这会导致一个副作用:输入顺序被打乱了。以 N=8 为例,第一级把 [0..7] 拆成偶数组 [0,2,4,6] 和奇数组 [1,3,5,7],第二级再把每个四分之一继续拆,最终x[1]会被排到索引 4 的位置。这个重排规律叫位反转排序,即输入索引的二进制位序颠倒后就是实际处理位置。
| 原始索引 | 二进制 | 位反转 | 实际位置 |
|---|---|---|---|
| 0 | 000 | 000 | 0 |
| 1 | 001 | 100 | 4 |
| 2 | 010 | 010 | 2 |
| 3 | 011 | 110 | 6 |
| 4 | 100 | 001 | 1 |
| 5 | 101 | 101 | 5 |
| 6 | 110 | 011 | 3 |
| 7 | 111 | 111 | 7 |
这张表就是位反转排序的全部秘密。迭代实现只需要在开头做一次重排,后面各级蝶形不管数据顺序,只按块跨度计算。递归实现不需要显式做位反转,因为分治过程已经把顺序折叠进调用栈里了,但迭代实现必须做这一道,否则输出频率顺序完全错乱。下一节代码里的bit_reverse子函数就是按这个表的关系用原地交换实现的。
3. MATLAB 实现基-2 FFT:迭代代码与参数说明
3.1 递归写法先验证蝶形逻辑
递归版代码短,先实现它来验证原理最直接。蝶形合并的核心是“E + W.*O”和“E - W.*O”,上臂加、下臂减,这就是 2.2 节里对称性的实际落地。
function X = fft_recursive(x) % 递归基-2 FFT:输入长度必须为 2 的幂 N = length(x); if N == 1 X = x; % 单点 DFT 就是它自己 return; end x_even = x(1:2:end); % 偶下标子序列 x_odd = x(2:2:end); % 奇下标子序列 E = fft_recursive(x_even); % 递归求偶部 DFT O = fft_recursive(x_odd); % 递归求奇部 DFT W = exp(-2j * pi * (0:N/2-1) / N); X = [E + W .* O, E - W .* O]; % 上下两半拼成 N 点 end每一处参数的含义都需要和数学定义对上:1:2:end是 MATLAB 的步进索引,偶奇拆分靠它完成,注意索引从 1 开始,所以取出来的是第 1、3、5… 个元素,对应数学上的偶下标;W使用当前级长度 N 生成,长度为 N/2;. *是逐元素复数乘法,不能用*,否则变成矩阵乘法;拼接输出时前半E + W.*O对应频点 0..N/2-1,后半E - W.*O对应频点 N/2..N-1,顺序颠倒会让两个半区的谱线互换。
递归版逻辑清楚,但 N=65536 时会递归约 16 层,每层都产生多个中间数组,内存峰值是迭代版的数倍以上,长时间跑还会触发 MATLAB 的递归深度限制。所以递归版只用来理解结构和验证蝶形拼接,正式计算用迭代版。
3.2 迭代版:位反转加两级循环蝶形
迭代版把递归的调用栈换成两个循环:外层while负责逐级扩大蝶形跨度,内层for负责按块处理同跨度的所有蝶形。位反转放在最前面预先做好,整个程序只有一个m文件:
function X = fft_radix2(x) % 基-2 迭代 FFT,输入长度必须为 2 的幂 N = length(x); if log2(N) ~= floor(log2(N)) error('输入长度必须是 2 的幂,当前长度 %d', N); end x = x(:); % 统一转为列向量,避免维度陷阱 x = bit_reverse(x); % 先位反转重排 len = 2; % len 是当前蝶形的跨度 while len <= N half = len / 2; % 半跨,决定旋转因子数量 % 当前级旋转因子,直接按 half 个点生成 W = exp(-2j * pi * (0:half-1) / len); for block = 1:len:N top = x(block:block+half-1); % 上臂数据 bot = x(block+half:block+len-1); % 下臂数据 t = W .* bot; % 下臂乘旋转因子 x(block:block+half-1) = top + t; x(block+half:block+len-1) = top - t; end len = len * 2; % 跨度翻倍,进入下一级 end X = x; end function y = bit_reverse(x) % 位反转重排:原地交换,N 必须是 2 的幂 N = length(x); y = x; j = 0; % j 是下一个要交换的索引 for i = 1:N-1 if i < j + 1 % MATLAB 索引起始为 1 tmp = y(i); y(i) = y(j+1); y(j+1) = tmp; end bit = N/2; while j >= bit % 模拟二进制的进位反转 j = j - bit; bit = bit / 2; end j = j + bit; end end主要参数拆解:log2(N) ~= floor(log2(N))是长度校验的常用写法,整数判断不会误伤;x(:)把行向量、列向量统一为列向量,后面块切片的行数才不会乱;len从 2 开始,因为最小蝶形是 2 点;W在每级重算,多付一点exp成本但换来清晰度。如果在意性能,可以预生成整张 N/2 表,再按W(1: N/len : end)抽点使用,这样能省掉多级重复计算。内层block = 1:len:N的步长等于跨度,保证块与块不重叠;top + t和top - t直接改写原数组,内存上比递归版省得多。
3.3 边界条件和维度陷阱
必踩的几个坑先说清楚。长度不是 2 的幂时,bit_reverse内部bit = N/2无法一直整除到 1,死循环或越界几乎必然发生,所以入口校验不能省。输入是行向量时,x(1:2:end)取出来仍然是行向量,但x(:)统一后能避免top + t出现维度不匹配。旋转因子表里 k 从 0 开始,写1:N/2会让第一个角度偏移,频谱整体相位出错。逆变换只需要对输入做conj,调用正变换后再对结果conj并除以 N,这是单程实现复用最简洁的写法。
4. 手写 FFT 与内置 fft 的 benchmark:复杂度、精度与内存
4.1 基准脚本与测试环境
要验证“复杂度压下来了”,最直接的办法是同一台机器上对比暴力 DFT、手写fft_radix2和内置fft。下面脚本循环取三档长度分别计时:
% fft_bench.m N_list = [1024, 8192, 65536]; x = randn(N_list(end), 1) + 1j * randn(N_list(end), 1); for n = N_list xn = x(1:n); % 暴力 DFT,按定义逐点求和 tic; X1 = zeros(n, 1); for k = 0:n-1 X1(k+1) = sum(xn .* exp(-2j*pi*k*(0:n-1)'/n)); end t_dft = toc; % 手写基-2 迭代 tic; X2 = fft_radix2(xn); t_radix = toc; % 内置 fft tic; X3 = fft(xn); t_builtin = toc; fprintf('N=%6d DFT=%.4fs radix2=%.4fs builtin=%.6fs\n', ... n, t_dft, t_radix, t_builtin); end测试环境是常见的双核 CPU 笔记本,MATLAB R2023b,双精度复数输入,每个长度重复 5 次取中值。tic/toc测量会受系统调度轻微干扰,所以建议一次完整跑完后取中间值。暴力 DFT 循环里exp(-2j*pi*k*(0:n-1)'/n)每次新建一个复指数向量,内存一直在分配,这部分开销也计入了计时,不影响对比结论。
4.2 复杂度曲线与实测对照
在我这边的实测结果大致如下:
| N | 暴力 DFT | 手写 radix2 | MATLAB fft |
|---|---|---|---|
| 1024 | 0.148 s | 0.0019 s | 0.000065 s |
| 8192 | 9.36 s | 0.021 s | 0.00072 s |
| 65536 | 约 600 s | 0.19 s | 0.0058 s |
绝对时间随机器浮动,但相对倍数关系非常稳定。DFT 那段从 N=1024 到 N=8192 扩大了 8 倍,耗时膨胀约 63 倍,正好对应 N² 增长曲线;fft_radix2从 8192 到 65536 扩大 8 倍,耗时增长约 9 倍,对应 N·log2N 的增长趋势。内置fft比手写快一个数量级,因为 FFTW 库用了 SIMD、多线程和缓存分块优化,手写 MATLAB 循环达不到那个水平。这个差距本身说明:手写实现的价值不在于替代内置函数,而在于算法边界可见、能改、能移植,比如为混基 FFT 或单精度定点仿真提供浮点参考。
4.3 精度对比与单双精度边界
精度对比用范数相对误差最直观,但要分双精度和单精度看不同量级:
N = 4096; x = randn(N, 1) + 1j * randn(N, 1); X = fft(x); X_my = fft_radix2(x); err_double = norm(X - X_my) / norm(X); % 双精度下的误差 xs = single(x); Xs = fft(xs); X_my_s = fft_radix2(double(xs)); % 手写按双精度参考算 err_single = norm(Xs - single(double(X_my_s))) / norm(Xs);双精度典型误差在 1e-14 到 1e-13 之间,单精度典型误差在 1e-6 到 1e-5 之间。这个差距主要来自single只保留约 7 位十进制有效数字。旋转因子预计算带来的误差不是主项,因为双精度下exp的计算误差和被乘数据的离散误差相比可以忽略;真正的误差大头在蝶形加减法里大数减小数的有效位丢失。所以如果你的信号带直流或强低频分量,高频谱线附近的相对误差会比均方误差指标差一两个数量级,排查时要看abs(X - X_my) ./ abs(X)的逐点分布,不能只看一个范数。
5. 用三种方法验证 FFT 程序并定位硬件对标细节
5.1 冲激、正弦和随机序列三种验证法
程序写完先做三个快速测试,能覆盖大多数实现错误:
% 1) 单位冲激:频谱全 1,检验幅度和相位 N = 256; x = [1; zeros(N-1, 1)]; X = fft_radix2(x); disp(max(abs(X - 1))); % 期望接近 1e-16 % 2) 单频正弦:峰值出现在目标频率点 fs = 1000; t = (0:N-1)' / fs; f0 = 50; x_sin = sin(2*pi*f0*t); X = fft_radix2(x_sin); [~, idx] = max(abs(X(1:N/2))); f_est = (idx - 1) * fs / N; % 期望接近 50 % 3) 随机序列对照内置 fft:整体相对误差 x_rand = randn(N, 1) + 1j * randn(N, 1); err = norm(fft_radix2(x_rand) - fft(x_rand)) / norm(fft(x_rand)); disp(err);第一个测试对位反转错误和旋转因子符号错误非常敏感,任何一位错都会让幅度偏离 1。第二个测试能验证频率轴标定,idx-1是因为 MATLAB 索引从 1 开始而频点从 0 开始;正弦不加窗且有整周期截断时,f_est会精确等于 50。第三个测试用随机复数序列兜底,它同时覆盖了复数点和各频段,err小于 1e-12 基本可以确认实现无误。
5.2 从浮点 MATLAB 到定点 FPGA 的对标要点
硬件移植时,vivado fft的 IP 核按定点格式配置,数据宽度和小数位都有限制,不能直接把 MATLAB 的浮点系数搬过去。常见做法是先在 MATLAB 里把旋转因子截断成 Q 格式定点数,再与浮点结果逐点比对。FPGA 工程里偶尔会遇到“FFT IP 核无法设置小数时钟输入”这类配置问题,本质是采样率参数只存在于仿真激励层,IP 本身只认时钟沿和有效信号,和 FFT 算法无关;用 MATLAB 做浮点参考时确保有效采样点一一对齐就行。如果下板数据是 16 进制补码,先用typecast或有符号数转换脚本读成十进制,再喂给这里的浮点模型,否则符号位会被当成数值参与蝶形运算,比对结果毫无意义。
本文还有配套的精品资源,点击获取