简介:这份资源面向数字信号处理初学者与嵌入式DSP开发者,聚焦在TI TMS320F28035浮点DSP上实现快速傅里叶变换的完整代码工程,可用于频谱分析、滤波等实时信号处理场景的学习与移植参考。压缩包共约2000个文件,整体10.33MB,以528个C源码与237个头文件为核心,配合304套CCS工程与构建配置、104个链接命令文件、49个汇编文件及若干GEL调试脚本、PDF文档与库文件,完整保留了Code Composer Studio下的工程结构,便于直接导入编译与调试。内容涵盖蝶形运算、位反转、旋转因子预计算、复数乘法优化以及循环缓冲等关键环节,读者可据此理解FFT算法在28035上的落地方式,并借助C28x浮点数学库对比验证结果。目前已有548人学习下载,适合希望从原理到工程实践打通FFT实现路径的开发者参考。
1. TMS320F28035 上跑 FFT:从「算得动」到「算得准」的分水岭
很多人第一次在 TMS320F28035 上写 FFT 代码,都会经历同一个反直觉的瞬间:代码编译通过、烧录成功、串口也能打印出频谱,但把信号源换成标准正弦波一看,幅值对不上、频率有偏移、旁瓣一塌糊涂。问题往往不在 FFT 算法本身,而在这颗定点 DSP 的「数据表示」和「内存布局」上。TMS320F28035 属于 C2000 系列,主频常见配置到 60MHz,没有硬件浮点单元,所有运算靠定点或 IQmath 库完成,这就决定了 FFT 代码的核心矛盾不是「怎么调库」,而是「怎么在有限位宽和有限 RAM 里把精度守住」。这篇笔记面向正在用这颗芯片做电机控制、电力监测或音频采集的工程师,把 FFT 代码从选型、定点化、内存分配到实测验证的完整路径拆开讲,新手能照着复现,熟手能对照边界参数。
2. 定点 FFT 的数学底子与库选型:为什么不能直接抄浮点代码
2.1 定点 Q 格式与溢出边界
TMS320F28035 是 32 位定点内核,做 FFT 时数据通常用 Q15 或 Q31 表示。Q15 把 1.0 映射成 32767,动态范围约 90dB,适合 16 位 ADC 采进来的原始数据;Q31 精度更高但乘法后容易溢出,需要 64 位中间结果。FFT 的蝶形运算里,每一级都有加法和乘法,加法会让位宽增长,乘法会让位宽收缩,如果不做缩放,N=256 的 FFT 中间结果可能超出 32 位。常见做法是每级蝶形后右移 1 位,代价是损失约 0.5bit 信噪比,换来的是绝不溢出。这个「每级缩放」是定点 FFT 和浮点 FFT 最大的代码差异,也是很多人抄浮点代码后结果发散的根本原因。
2.2 C2000 上可用的 FFT 实现路径
在 F28035 上做 FFT,实际可选的路子有三条:一是 TI 官方 C28x FPU 不适用(这颗没 FPU),所以走 C28x IQmath 库配合手写蝶形;二是用 C2000 的 RFFT 库(部分版本对定点有支持);三是自己用查表法写旋转因子。我一般推荐 IQmath 加手写基 2 蝶形,原因是 IQmath 把 Q 格式的乘加封装好了,_IQmpy、_IQsin这些函数在 ROM 里有查表,执行周期可预测,调试时也能单步看 Q 值。RFFT 库虽然省事,但版本兼容和内存对齐要求比较苛刻,换芯片或换编译器版本时容易翻车。
2.3 旋转因子的存储与精度取舍
旋转因子 $W_N^k = \cos(2\pi k/N) - j\sin(2\pi k/N)$ 如果用 Q15 存,N=256 时需要 256 个 16 位字,占 512 字节;用 Q31 存则翻倍。F28035 的 RAM 通常只有几 KB 到十几 KB 可用,所以旋转因子一般放 Flash 里用const修饰,运行时通过memcpy搬到 RAM 或直接寻址。精度上,Q15 旋转因子在 N=256 时理论信噪比约 80dB,够电力谐波分析用;如果做音频或需要 90dB 以上动态范围,就得用 Q31 旋转因子配合 Q31 数据,但 RAM 和周期都要重新算。
2.4 最小可运行工程的文件结构
一个能跑通的最小工程通常包含这几个文件:fft.c放蝶形和位反转,fft.h放宏定义和函数声明,twiddle_q15.c放旋转因子表,main.c做初始化和调用。下面是一个 Q15 基 2 FFT 的核心蝶形代码片段,N 固定为 256,输入输出都是 Q15。
// fft_q15.c - 256点基2 FFT,Q15格式,每级缩放1位 #include "fft.h" // 旋转因子表,Q15格式,实部虚部交替存放 extern const int16_t twiddle_q15[256]; void fft_256_q15(int16_t *real, int16_t *imag) { uint16_t i, j, k, m; uint16_t le, le2; int16_t tr, ti, ur, ui; int32_t acc_r, acc_i; // 第一步:位反转重排 j = 0; for (i = 0; i < FFT_N - 1; i++) { if (i < j) { tr = real[i]; real[i] = real[j]; real[j] = tr; ti = imag[i]; imag[i] = imag[j]; imag[j] = ti; } k = FFT_N >> 1; while (k <= j) { j -= k; k >>= 1; } j += k; } // 第二步:蝶形运算,共 log2(N) 级 for (m = 1; m <= LOG2_N; m++) { le = 1 << m; // 当前级的蝶形跨度 le2 = le >> 1; // 半跨度 for (j = 0; j < le2; j++) { // 取旋转因子,索引按级和位置计算 uint16_t tw_idx = j * (FFT_N / le); int16_t wr = twiddle_q15[2 * tw_idx]; int16_t wi = twiddle_q15[2 * tw_idx + 1]; for (i = j; i < FFT_N; i += le) { // 蝶形:t = w * x[i+le2] acc_r = (int32_t)wr * real[i + le2] - (int32_t)wi * imag[i + le2]; acc_i = (int32_t)wr * imag[i + le2] + (int32_t)wi * real[i + le2]; tr = (int16_t)(acc_r >> 15); // Q15乘法后右移15位 ti = (int16_t)(acc_i >> 15); ur = real[i]; ui = imag[i]; // 每级缩放1位,防止溢出 real[i] = (ur + tr) >> 1; imag[i] = (ui + ti) >> 1; real[i + le2] = (ur - tr) >> 1; imag[i + le2] = (ui - ti) >> 1; } } } }这段代码里,FFT_N和LOG2_N在头文件里定义为 256 和 8。位反转部分用标准的 Gold-Rader 算法,蝶形部分用三层循环:最外层m控制级数,中间j控制旋转因子索引,最内层i做同址运算。关键参数是acc_r >> 15这个右移,它把 Q15 乘 Q15 的 Q30 结果转回 Q15;以及每级末尾的>> 1,这是定点 FFT 的缩放策略。如果去掉>> 1,N=256 时输出会溢出成噪声;如果改成每两级缩放一次,中间结果需要 34 位以上,32 位寄存器扛不住。
2.5 旋转因子表的生成方式
旋转因子表不建议手写,用脚本生成最稳。下面这段 Python 生成 Q15 格式的 256 点旋转因子,输出成 C 数组。
# gen_twiddle.py - 生成Q15旋转因子表 import math N = 256 scale = 32767.0 print("const int16_t twiddle_q15[%d] = {" % (2 * N)) for k in range(N): angle = 2.0 * math.pi * k / N re = int(round(math.cos(angle) * scale)) im = int(round(-math.sin(angle) * scale)) # 限幅,防止round后超出int16范围 re = max(-32768, min(32767, re)) im = max(-32768, min(32767, im)) print(" %d, %d," % (re, im)) print("};")生成时注意-sin的符号,因为 FFT 正变换的旋转因子是负指数。如果符号搞反,频谱会镜像,很多人第一次调的时候看到频率轴反了就是这个原因。生成后把数组放进twiddle_q15.c,用const修饰,链接时编译器会把它放到 Flash 的只读段。
3. 在 F28035 上把 FFT 跑起来的完整步骤
3.1 工程配置与内存分配
在 Code Composer Studio 里新建 C2000 工程后,第一件事是改链接器命令文件(.cmd),把 FFT 的输入输出缓冲和旋转因子放到合适的内存段。F28035 的 RAM 分 M0、M1、L0、L1 几块,L0 和 L1 通常各 1KB,M0/M1 各 1KB。256 点 FFT 需要实部虚部各 256 个 int16,共 1KB,加上旋转因子 1KB,至少要 2KB 连续空间。常见做法是把输入输出放 L0,旋转因子放 Flash 的.const段,运行时不用搬。下面是一个典型的 cmd 片段。
MEMORY { PAGE 0: FLASH: origin = 0x3E8000, length = 0x10000 PAGE 1: L0L1RAM: origin = 0x008000, length = 0x0800 } SECTIONS { .fft_buffer : > L0L1RAM, PAGE 1 .const : > FLASH, PAGE 0 }然后在 C 代码里用#pragma DATA_SECTION把数组指到.fft_buffer。
#pragma DATA_SECTION(fft_real, ".fft_buffer") int16_t fft_real[FFT_N]; #pragma DATA_SECTION(fft_imag, ".fft_buffer") int16_t fft_imag[FFT_N];这样链接器会把两个数组紧挨着放,避免跨段访问带来的额外周期。如果 RAM 不够,可以把虚部数组省掉,用实序列 FFT 的打包技巧,但代码复杂度会上升,新手不建议一上来就这么做。
3.2 ADC 采样与数据搬移
FFT 的输入通常来自 ADC。F28035 的 ADC 是 12 位,结果左对齐或右对齐可配。做 Q15 FFT 时,把 12 位结果左移 4 位变成 16 位有符号数,再减去直流偏置。采样率要和 FFT 点数匹配:如果分析 50Hz 工频谐波,想分辨到 5 次谐波(250Hz),采样率至少 1kHz,256 点对应 256ms 窗口,频率分辨率约 3.9Hz。下面是一段 ADC 中断里填缓冲的代码。
// ADC中断服务函数,每次转换完成调用 __interrupt void adc_isr(void) { static uint16_t idx = 0; int16_t raw; // 读取12位ADC结果,减去偏置2048,左移4位转Q15 raw = (int16_t)(AdcResult.ADCRESULT0 >> 4) - 2048; raw = raw << 4; if (idx < FFT_N) { fft_real[idx] = raw; fft_imag[idx] = 0; // 实序列,虚部清零 idx++; } else { // 缓冲满,置标志,主循环处理 fft_ready = 1; idx = 0; } AdcRegs.ADCINTFLGCLR.bit.ADCINT1 = 1; PieCtrlRegs.PIEACK.all = PIEACK_GROUP1; }这里>> 4是因为 12 位结果在 16 位寄存器里左对齐,右移 4 位得到实际值;减 2048 是去掉 1.65V 左右的直流偏置;再左移 4 位是为了让信号占满 Q15 的动态范围。如果信号幅度小,可以少移几位,但要注意别溢出。
3.3 主循环调用与结果读取
主循环里检测fft_ready标志,调用fft_256_q15,然后计算幅值。幅值是实部虚部平方和开根号,定点开根号用牛顿迭代或查表。下面是一段计算幅值并找峰值的代码。
// 主循环中处理FFT if (fft_ready) { fft_ready = 0; fft_256_q15(fft_real, fft_imag); // 计算前N/2个点的幅值,找最大值 uint16_t peak_idx = 0; int32_t peak_mag = 0; for (uint16_t i = 1; i < FFT_N / 2; i++) { // 幅值平方,Q15*Q15=Q30,右移15位回Q15 int32_t mag_sq = ((int32_t)fft_real[i] * fft_real[i] + (int32_t)fft_imag[i] * fft_imag[i]) >> 15; if (mag_sq > peak_mag) { peak_mag = mag_sq; peak_idx = i; } } // peak_idx 对应频率 = peak_idx * sample_rate / FFT_N g_peak_freq = (uint32_t)peak_idx * SAMPLE_RATE / FFT_N; }注意这里只算前 N/2 个点,因为实序列 FFT 的频谱对称,后半部分是镜像。mag_sq用 Q30 表示,比较时不用开根号,省周期。如果要真实幅值,再对peak_mag做一次开根号即可。
3.4 用信号源验证:从时域到频域的对照
验证时不要一上来就接复杂信号,先用信号发生器给一个 50Hz、1Vpp 的正弦波,采样率设 1kHz,看 FFT 峰值是否落在 bin 13(50*256/1000≈12.8,取整 13)。如果峰值在 bin 13 但幅值偏小,检查 Q15 缩放级数;如果峰值在 bin 243(镜像位置),检查旋转因子符号;如果整个频谱是噪声,检查 ADC 数据是否真的填进了缓冲。我一般会在fft_256_q15调用前后各设一个 GPIO 翻转,用示波器量执行时间,256 点 Q15 FFT 在 60MHz 下大约 200~300 微秒,如果超过 1 毫秒,说明编译器优化没开或内存没放对。
4. 避坑与排查:定点 FFT 最容易翻车的五个地方
4.1 频谱镜像:旋转因子符号搞反
现象是峰值出现在 N-k 的位置,比如 50Hz 应该在第 13 根谱线,结果出现在第 243 根。原因是旋转因子生成时用了+sin而不是-sin,或者蝶形里虚部乘法的符号写错。解决方法是重新生成旋转因子表,确认im = -sin(angle),并检查蝶形里acc_i的表达式是wr*imag + wi*real而不是减号。这个坑我见过至少三次,每次都是抄代码时没注意符号。
4.2 输出溢出:缩放策略没对齐
现象是频谱底部整体抬高,或者大信号时出现平顶。原因是蝶形运算没有每级缩放,中间结果超出 int16。解决方法是确认每级蝶形后都有>> 1,并且输入信号幅度不要超过 Q15 的 1/4,留足余量。如果做 N=1024 的 FFT,每级缩放 1 位后总缩放 10 位,输出幅值只有输入的 1/1024,需要在最后乘回来,否则看起来信号很弱。
4.3 执行时间超标:内存没放对或优化没开
现象是 FFT 跑一次要几毫秒,导致主循环卡顿。原因是输入输出数组放在了 Flash 或跨 RAM 块,每次访问都有等待周期。解决方法是用#pragma DATA_SECTION把数组放到 L0/L1 连续 RAM,并在工程设置里开-o3优化和--opt_for_speed。另外旋转因子如果放 RAM 会比 Flash 快,但 RAM 紧张时放 Flash 也能接受,因为旋转因子访问有规律,缓存命中率高。
4.4 频率分辨率不够:采样率和点数没匹配
现象是 50Hz 和 55Hz 分不开,或者峰值落在两个 bin 之间。原因是频率分辨率等于采样率除以点数,1kHz 采样 256 点只有 3.9Hz 分辨率。解决方法是提高点数到 512 或 1024,或者降低采样率,但降低采样率会限制最高分析频率。如果必须用 256 点,可以在 FFT 后做插值,但插值只能改善显示,不能真正提高分辨率。
4.5 直流偏置没去掉:0 号 bin 淹没有用信号
现象是频谱最左边一根谱线特别高,其他谱线被压制。原因是 ADC 有 1.65V 左右的偏置,没减掉就做 FFT,直流分量会泄漏到相邻 bin。解决方法是在填缓冲时减去偏置,或者在 FFT 后把 0 号 bin 置零。更彻底的做法是加一个简单的高通滤波器,但会引入相位失真,看应用是否在意。
5. 进阶技巧:用实序列打包和查表开根号再省一半周期
5.1 实序列 FFT 的打包技巧
如果输入是实信号,可以把 2N 个实数打包成 N 个复数,做一次 N 点 FFT,再用旋转因子分离出 2N 点实序列的频谱。这样 256 点实序列只需要 128 点复数 FFT,周期直接减半。代价是分离步骤需要额外的乘加,但总体还是划算。下面是一个简化的分离代码框架。
// 假设已对打包数据做完N/2点复数FFT // 分离出原实序列的频谱 for (k = 0; k <= FFT_N / 4; k++) { // 取对称点 uint16_t k2 = (FFT_N / 2) - k; int16_t xr = fft_real[k], xi = fft_imag[k]; int16_t yr = fft_real[k2], yi = -fft_imag[k2]; // 共轭 // 用旋转因子组合,具体公式略,核心是加减和乘 // ... }这个技巧在 RAM 和周期都紧张时特别有用,但调试难度比直接做 256 点高,建议先把直接版跑通再改。
5.2 定点开根号的查表实现
幅值计算如果每个 bin 都开根号,256 点要 128 次,每次牛顿迭代十几次乘法,周期很可观。实际做峰值检测时不需要开根号,比较平方值即可。如果一定要幅值,可以用查表加线性插值:把 Q15 的平方值分成 256 段,每段存一个近似根号值,查表后插值。下面是一个查表开根号的示例。
// 查表开根号,输入Q15平方值,输出Q15根号值 int16_t sqrt_q15(int32_t sq) { // sq范围0~2^30,取高8位做索引 uint16_t idx = (uint16_t)(sq >> 22); int32_t base = sqrt_table[idx]; int32_t next = sqrt_table[idx + 1]; // 线性插值 int32_t frac = (sq >> 14) & 0xFF; return (int16_t)(base + ((next - base) * frac >> 8)); }sqrt_table有 257 个条目,每个 16 位,占 514 字节。精度在 Q15 下约 1%,够大多数监测应用。如果嫌占空间,可以减到 65 个条目,精度降到 3% 左右。
5.3 用 GPIO 和定时器做周期测量
调 FFT 时最怕「感觉慢但不知道慢在哪」。我的习惯是在 FFT 函数入口拉高一个 GPIO,出口拉低,用示波器量高电平宽度。F28035 的 GPIO 翻转只要几个周期,对测量影响很小。如果手头没有示波器,可以用 CPU 定时器读计数,算差值。下面是一段用定时器测周期的代码。
// 测FFT执行周期 CpuTimer0Regs.TCR.bit.TSS = 1; // 停定时器 CpuTimer0Regs.TIM.all = 0xFFFFFFFF; // 重置 CpuTimer0Regs.TCR.bit.TSS = 0; // 启动 GpioDataRegs.GPASET.bit.GPIO0 = 1; // 拉高 fft_256_q15(fft_real, fft_imag); GpioDataRegs.GPACLEAR.bit.GPIO0 = 1; // 拉低 CpuTimer0Regs.TCR.bit.TSS = 1; // 停 uint32_t cycles = 0xFFFFFFFF - CpuTimer0Regs.TIM.all;cycles就是 FFT 占用的 CPU 周期数,除以主频就是时间。60MHz 下 256 点 Q15 FFT 如果超过 20000 周期(约 333 微秒),就要检查优化和内存布局了。
5.4 从 256 点扩到 1024 点的参数调整
如果应用需要更高分辨率,把 N 从 256 改成 1024,需要同步改这几个地方:FFT_N和LOG2_N宏、旋转因子表长度、位反转循环的FFT_N >> 1、蝶形级数LOG2_N、内存分配大小(实部虚部各 2KB,共 4KB)。F28035 的 RAM 可能不够,这时要把旋转因子放 Flash,输入输出用实序列打包技巧省一半空间。另外每级缩放 1 位后总缩放 10 位,输出幅值只有输入的 1/1024,需要在最后乘 1024 或调整 Q 格式。我一般会在 1024 点版本里把输入左移 6 位而不是 4 位,补偿一部分缩放损失。
5.5 一个我常犯的错误:忘了清虚部
实序列 FFT 要求虚部初始为零,但如果在中断里填缓冲时只填了实部,虚部保留了上一轮的值,频谱就会混入莫名其妙的成分。这个 bug 很隐蔽,因为第一轮结果是对的,第二轮开始才出错。后来我养成了习惯:在fft_ready置位后、调用 FFT 前,用memset把虚部数组清零,或者干脆在填缓冲时显式写fft_imag[idx] = 0。多这一条语句,省下几个小时的排查。希望帮到你。
本文还有配套的精品资源,点击获取