简介:这是一份面向C#开发者与数字信号处理学习者的FFT实战代码包,演示如何在Windows Forms界面中实现快速傅里叶变换。工程基于Cooley-Tukey算法,包含DFT基础、蝶形运算、位反转、数据预处理等核心步骤,并展示了如何利用Math.NET Numerics等库进行频域分析,适合需要做音频、图像或周期信号频谱分析的技术人员参考。压缩包共84个文件,以.cs源代码、Visual Studio解决方案/项目文件、.dll运行库及NuGet依赖包为主,同时包含配置文件和可执行文件,整体约9.27MB,目录结构清晰,便于对照学习。已有247人学习/下载,读者可从中获得一个可直接运行的FFT项目,通过界面输入数据并查看频谱图,也能学习C#工程组织、UI事件处理与数值计算的整合方法。
1. 先想清楚:C# 快速傅里叶变换要解决上位机里的什么问题
搞 C# 上位机的人迟早会碰到一个坎:数据采回来了,波形画出来了,但客户问“这个信号的频率是多少”,你只能让客户自己看示波器。快速傅里叶变换(FFT)就是把时域波形换成频域谱线,C# 里写它不是为了证明数学能力,而是为了在 WinForm 或 WPF 界面上按时算出峰值。网上常能看到快速傅里叶变换代码.zip 这类资源包,但真正到项目里能稳定跑的,还是把蝶形运算和窗函数讲清楚的人。这篇文不评价哪个包最全,而是把你自己能写出来、能调到可用状态、面试也能讲明白的方案理清楚。
2. 从 DFT 到 C# 快速傅里叶变换:先算清复杂度再决定写法
2.1 为什么 C# 里做 FFT 多数选基 2 时间抽取
DFT 的常见写法是X[k] = Σ x[n] * e^( - j * 2π * k * n / N ),每个输出点都要做 N 次复数乘加,N 个输出点就是 N² 次。这个复杂度在采集卡 8192 点甚至 16384 点一帧时完全不可接受。FFT 能落地,靠的是旋转因子W_N = e^( - j * 2π / N )的周期性和对称性:同一段序列可以按下标奇偶拆成两个 N/2 点序列,分别做 DFT 再合并,每拆一层复杂度就降一档,最后得到 O(N log N)。
C# 项目里最常见的实现是“基 2 时间抽取”,因为它只要求 N 是 2 的幂,而振动采集、音频采集、功率分析这类场景里,点数天然就是 1024、4096、16384。基 2 的蝶形结构规律,循环边界好写,也容易改成并行版本。如果输入长度不是 2 的幂,常见做法是先补零到下一个 2 的幂,或者优先用即时傅里叶变换的思路处理,而不是一上来就上混合基或 Bluestein。基 2 的意义可以先用一张算量表看明白:
| 点数 N | 直接 DFT 复数乘法次数 | 基 2 FFT 复数乘法次数 | 运算量差距 |
|---|---|---|---|
| 1,024 | 1,048,576 | 5,120 | 约 205 倍 |
| 4,096 | 16,777,216 | 24,576 | 约 683 倍 |
| 16,384 | 268,435,456 | 114,688 | 约 2,340 倍 |
这张表说明一件事:不把算法换掉,单纯优化循环体,在数据帧变大后是徒劳的。FFT 的提速来自数学结构的改变,不是编译器替你省几个加法。
2.2 C# 数组布局、double 精度和位序颠倒代码
实现 FFT 之前要先决定数据放在什么结构里。System.Numerics.Complex[]可以直接用,结构体数组在内存里是连续的,访问也方便。但我做上位机时更常用两个独立的 double 数组,一个存实部、一个存虚部,原因是后续算幅值谱不用反复做类型转换,而且蝶形运算里实部和虚部是交替访问的,两个数组在循环里可以被 JIT 更好地缓存。
类型选择上不要图省事用 float。FFT 是大量乘加的累积过程,float 只有约 7 位有效数字,到 8192 点时中间误差会被放大,频谱图上的旁瓣可能多出几个假峰。内存翻一倍换准确度,对现在动辄 8GB 以上的机器来说很值得。
位序颠倒(bit reversal)是基 2 时间抽取的第一步。它的作用是把输入序列按下标二进制位反转重排,让后续蝶形运算可以原地进行。以下代码是常用的迭代写法,不需要额外递归:
static void BitReverse(double[] real, double[] imag) { int n = real.Length; int j = 0; for (int i = 0; i < n - 1; i++) { if (i < j) { double t = real[i]; real[i] = real[j]; real[j] = t; t = imag[i]; imag[i] = imag[j]; imag[j] = t; } int m = n >> 1; while (m >= 1 && j >= m) { j -= m; m >>= 1; } j += m; } }这个循环的核心不是交换,而是维护 j 这个“反向计数变量”。每次 i 加 1,j 就按二进制从最高位开始进位,进位到头就回到 0。i 小于 j 时交换,保证每个位置只交换一次。注意这里要求real.Length和imag.Length相等,而且长度必须等于 2 的幂,调用方在拆分采集帧时就要保证这一点。
3. C# 快速傅里叶变换的蝶形运算与窗函数参数设置
3.1 蝶形运算核心循环与旋转因子的累积误差
位序颠倒完成后,进入 FFT 的蝶形循环。每一级做 N/2 个蝶形,每级步长翻倍,总共 log2 N 级。下面的代码是完整的原地 FFT,输入输出共用 real / imag 数组:
public static void FftCore(double[] real, double[] imag) { int n = real.Length; BitReverse(real, imag); for (int len = 2; len <= n; len <<= 1) { // 本级旋转因子基准角度 double angle = -2.0 * Math.PI / len; double wR = Math.Cos(angle); double wI = Math.Sin(angle); for (int i = 0; i < n; i += len) { double curR = 1.0, curI = 0.0; int half = len >> 1; for (int j = 0; j < half; j++) { int u = i + j; int v = u + half; // 复数乘法:cur * data[v] double tr = curR * real[v] - curI * imag[v]; double ti = curR * imag[v] + curI * real[v]; // 蝶形加减:原地更新 real[v] = real[u] - tr; imag[v] = imag[u] - ti; real[u] += tr; imag[u] += ti; // 每次循环累积旋转因子,避免重复调用 cos/sin double nextR = curR * wR - curI * wI; double nextI = curR * wI + curI * wR; curR = nextR; curI = nextI; } } } }这段代码有两点要特别留意。第一,内层循环里real[v] = real[u] - tr; real[u] += tr;的顺序是先算 tr 再更新,否则real[u]被覆盖后,后面的real[u] += tr就错了。第二,curR/curI是逐次乘出来的旋转因子,当 len 很大、内层循环次数多时,浮点误差会累积。N 到 65536 以上,建议改成预计算旋转因子表,也就是在初始化阶段把cos(2πk/N)和sin(2πk/N)放进数组,内层直接查表。
处理完 FFT 后,频谱是复数结果,需要转成幅值谱。单边幅值谱的计算方式是保留前一半 bin,每个 bin 的幅值乘 2/N,但直流分量不乘 2:
double[] spectrum = new double[n / 2]; double scale = 2.0 / n; for (int i = 0; i < n / 2; i++) { double mag = Math.Sqrt(real[i] * real[i] + imag[i] * imag[i]); spectrum[i] = mag * scale; } spectrum[0] *= 0.5; // 直流分量只有单侧,不能乘 2为什么要单独处理直流?因为实数输入信号的频谱是对称的,正负频率各贡献一半能量,只有 k=0 这个直流项没有负频率配对。这个细节很容易被忽略,但恰恰是频谱图里 0Hz 处幅值偏高一倍的常见原因。
3.2 幅值谱、窗函数和频率分辨率怎么配合
采集到的连续信号往往不是整周期截断,直接做 FFT 会发生频谱泄漏,也就是本该集中在一个 bin 的能量扩散到旁边。解决办法是加窗。常用窗函数代码如下:
static double[] CreateWindow(int n, string kind) { var w = new double[n]; for (int i = 0; i < n; i++) { if (kind == "hann") w[i] = 0.5 * (1.0 - Math.Cos(2.0 * Math.PI * i / (n - 1))); else if (kind == "hamming") w[i] = 0.54 - 0.46 * Math.Cos(2.0 * Math.PI * i / (n - 1)); } return w; }加窗的调用方式是先把采集到的样本乘上窗函数,再进 FFT:
for (int i = 0; i < n; i++) { real[i] = samples[i] * window[i]; imag[i] = 0; } FftCore(real, imag);加窗之后,幅值校正公式要从 2/N 改成2 / (N * coherentGain),其中coherentGain是窗函数所有采样值的平均值。Hann 窗的平均值是 0.5,所以实际幅值校正因子是 4/N;Hamming 窗约是 1.852/N。下表是常见窗函数的参数:
| 窗类型 | 主瓣宽度 | 旁瓣电平 | 幅值校正因子 |
|---|---|---|---|
| 矩形 | 2 bins | -13 dB | 1.0 |
| Hann | 4 bins | -31 dB | 2.0 |
| Hamming | 4 bins | -41 dB | 约 1.85 |
频率分辨率由采样率 fs 和点数 N 共同决定,公式是Δf = fs / N。以下参数表可以直接用来估算采集任务:
| 采样率 fs | 点数 N | 频率分辨率 | 可分析最高频率 |
|---|---|---|---|
| 1,000 Hz | 1,024 | 约 0.977 Hz | 500 Hz |
| 10 kHz | 4,096 | 约 2.441 Hz | 5 kHz |
| 50 kS/s | 8,192 | 约 6.104 Hz | 25 kHz |
这里有个常被搞混的细节:频率分辨率只由帧长度决定,不是“采样率越高分辨率越好”。把采样率从 10kHz 提到 50kHz,如果点数不跟着增,分辨率反而变差。想要更密的谱线,就要加 N,也就是采集更长时间。
4. 把 C# 快速傅里叶变换放进上位机:采集线程、UI 刷新和性能参数
4.1 数据采集循环里塞 FFT,UI 刷新必卡
C# 循环数据采集和 UI 刷新卡顿,最直接的原因就是采集回调里做了太多事。很多人的第一版代码是在 PLC 或传感器数据到达事件里直接调用 FFT,然后立刻把结果画到 Chart 上。FFT 本身再快也是计算任务,UI 线程一旦被它占住,鼠标拖动、按钮点击、波形缩放全部无响应。
正确做法是把 FFT 丢到后台线程,UI 只负责定时读取最新结果。常见做法是用一个带锁的字段保存最近一次频谱,采集线程更新它,UI 定时器每 100ms 取一次。下面是一个预分配缓冲区的 FFT 分析器骨架:
public sealed class FftAnalyzer { private readonly int _n; private readonly double[] _real; private readonly double[] _imag; private readonly double[] _window; private readonly object _lock = new object(); public FftAnalyzer(int n) { _n = n; _real = new double[n]; _imag = new double[n]; _window = CreateWindow(n, "hann"); } public bool TryCalculate(float[] frame, out float[] spectrum) { spectrum = null; if (frame.Length < _n) return false; lock (_lock) { for (int i = 0; i < _n; i++) { _real[i] = frame[i] * _window[i]; _imag[i] = 0; } FftCore(_real, _imag); spectrum = new float[_n / 2]; double scale = 4.0 / _n; // 配合 Hann 窗的幅值校正 for (int i = 0; i < spectrum.Length; i++) { spectrum[i] = (float)(Math.Sqrt(_real[i] * _real[i] + _imag[i] * _imag[i]) * scale); } return true; } } }调用方在采集线程拿到的spectrum只是一个计算结果,不要让 UI 线程和采集线程同时写这份数组。可以把 spectrum 存入一个volatile或带锁字段,UI 画图前再取引用。
4.2 预分配、预计算和最小化 GC 的写法
上面类里的spectrum每次调用都 new 一个新的 float 数组。如果采集帧率是每秒 50 帧,一秒钟就是 50 个数组进入托管堆,频繁触发 GC。稍微优化一点的写法是把这个数组当成字段提前分配,TryCalculate里只往里面填值,调用方通过参数传入输出数组:
public bool TryCalculate(float[] frame, float[] spectrum) { if (frame.Length < _n || spectrum.Length < _n / 2) return false; // 省略循环部分,直接填充 spectrum 数组 return true; }FFT 内部也一样。BitReverse每次都会重排数据,对于固定点数的上位机程序,可以预先算好一份索引表,把重排逻辑变成查表复制。旋转因子也可以预先计算,这两个优化加起来,4096 点 FFT 的耗时能省下一半左右。
还需要注意Array.Clear(_imag, 0, _n)这行不要漏。虚部数组如果不每帧清零,上一帧残留数据会污染下一帧结果。习惯用System.Numerics.Complex[]的人反而不会踩这个坑,因为复数数组每个元素都是整体赋值;换成分离数组后,清零步骤必须显式写出来。
4.3 点数、刷新率和运算量的平衡
不同点数对应的 FFT 运算量差别很大,下表用相对值表示,方便在选型时估算:
| 点数 N | 复数乘法次数 | 相对 256 点运算量 |
|---|---|---|
| 256 | 1,024 | 1 倍 |
| 1,024 | 5,120 | 5 倍 |
| 4,096 | 24,576 | 24 倍 |
| 16,384 | 114,688 | 112 倍 |
这个倍数关系直接决定了刷新率的上限。如果你的产品要求 50Hz 屏幕刷新,4096 点 FFT 在普通桌面上算完还有余量;但如果是电池供电的嵌入式上位机或虚拟机里跑,点数就该降到 2048 或 1024。另一个经验是 UI 波形刷新频率不需要等于 FFT 计算频率,把 10 帧频谱合并显示成 3 帧,人眼根本分辨不出差别,CPU 却省下一大截。
还有一点容易被忽略:采样率和点数决定了频率分辨率,但“分辨率”和“谱线间隔”不是一回事。FFT 输出的 bin 间隔是 fs/N,但两个频率要能被区分开,至少相差一个主瓣宽度。加 Hann 窗后主瓣宽 4 个 bin,意味着 50kS/s、8192 点的情况下,频率差小于约 24Hz 的两个分量会被看成一座山。做振动诊断时这个参数必须出现在需求评审里,而不是等现场实测时才发现。
5. 验证 C# 快速傅里叶变换结果:正弦波、直流分量和 Goertzel 交叉检查
5.1 构造已知信号验证幅值
写完 FFT,第一件事不是接真实传感器,而是用一段自己生成的信号做验证。下面的控制台思路适合任何 C# 工程:生成一个直流加正弦的测试信号,然后比较频谱里的幅值。
信号构造方式为x[n] = 2.0 + 1.5 * sin(2π * f * n / fs),选择采样率 fs = 4096,点数 N = 4096,频率 f 选为 128Hz。128Hz 正好落在 bin 索引 128 上,不存在频谱泄漏,这时频谱中第 128 个 bin 的幅值应当接近 1.5,直流分量接近 2.0。加 Hann 窗后幅值校正用 4/N,实测偏差应该在 1% 以内。如果偏差到 5% 以上,优先检查窗函数平均值算得对不对,其次检查直流分量是否也乘了 2。
频率可以故意选一个非整数 bin 的位置,比如 129.3Hz,然后观察主瓣附近是否还有功率扩散。扩散是正常现象,但如果峰值出现在 129Hz 而 130Hz 完全为 0,说明你的频率轴算错了,也就是k * fs / N里的k没对应到数组下标。
5.2 用 Goertzel 算法复核单点频率
FFT 算一整条频谱后,经常不确定单个频率点的幅值是否正确。这时可以用 Goertzel 算法做交叉验证。它本质上是只计算特定频率的 DFT,比完整 FFT 简单,适合用来验证一个 bin:
static double GoertzelMagnitude(double[] samples, double freq, double fs) { int n = samples.Length; double w = 2.0 * Math.PI * freq / fs; double coeff = 2.0 * Math.Cos(w); double s0 = 0, s1 = 0, s2 = 0; for (int i = 0; i < n; i++) { s0 = samples[i] + coeff * s1 - s2; s2 = s1; s1 = s0; } double power = s1 * s1 + s2 * s2 - coeff * s1 * s2; return Math.Sqrt(power); }这个函数返回的是该频率点的复数幅度平方根,是一个相对值。用同一段数据分别跑 FFT 和 Goertzel,两个结果在相同频率上的变化趋势必须一致。如果 FFT 在 128Hz 处给出峰值而 Goertzel 在 129Hz 处更强,说明 FFT 输出数组的下标和频率轴没对齐。这个交叉检查也常被用作 C# 面试题,问法就是“如何确认你写的 FFT 没有 bug”,答案就是标准信号加单点复核。真正调频谱时,先把信号频率固定,再改窗函数观察旁瓣变化,比直接看采集数据更容易暴露问题。
本文还有配套的精品资源,点击获取