1. 为什么要在TMS320C6678上死磕FFT
1.1 从一次雷达信号处理项目说起
前阵子帮一个做雷达信号处理的朋友调程序,他们用的是TMS320C6678这块八核DSP。项目里需要对中频采样回来的数据做实时频谱分析,采样率200MHz,每帧要处理16384个点的复数FFT,帧间隔只有几十微秒。一开始他们用自己手写的基2 FFT,跑出来单核耗时将近800微秒,完全达不到实时性要求。后来换成TI官方DSPLIB里的FFT函数,配合EDMA搬数据和Cache优化,单核直接压到60微秒以内,八核并行之后余量非常充足。
这个对比让我觉得有必要把C6678上FFT的调用方法完整梳理一遍。C6678是TI Keystone架构里的八核定点/浮点DSP,主频1.25GHz,每个核有32KB L1P、32KB L1D、512KB L2,还有4MB的多核共享内存MSMC。它的浮点性能很强,单核做1024点复数浮点FFT大概只要几微秒,但前提是你得把DSPLIB用对、把数据放对位置、把Cache一致性处理好。很多新手拿到板子之后卡在第一步——函数编译不过、链接报错、结果全是乱码,其实问题往往出在库版本、内存段分配或者EDMA配置上。
这篇文章面向的是刚接触C6678、需要在CCS里跑通FFT的嵌入式工程师,也适合已经能跑但性能不达标的开发者。我会从DSPLIB的选型讲起,把实数FFT和复数FFT的区别、定点与浮点的取舍、内存对齐要求、Cache一致性处理、EDMA配合方式、多核并行拆分方案全部拆开讲,最后给出一份可以直接抄的完整工程配置和实测数据。看完之后你应该能独立在CCS里搭出一个可用的FFT处理链路,并且知道每一步为什么这么做。
1.2 先搞清楚DSPLIB里到底有哪些FFT函数
TI的DSPLIB(现在叫DSPLIB C66x,老版本叫DSPLIB C64x+)里FFT相关函数分两大类:一类是实数FFT,输入是实数序列,利用对称性把N点实数FFT转成N/2点复数FFT来做,计算量减半;另一类是复数FFT,输入输出都是复数。按数据类型又分单精度浮点(float)和定点(short/int)。C6678是浮点DSP,绝大多数场景直接用浮点版本就行,除非你有特殊的定点性能需求。
常用的几个函数名要记牢:
DSPF_sp_fftSPxSP:单精度浮点复数FFT,输入输出分离实部虚部,支持基2/基4/基8混合基,是DSPLIB里最通用的浮点FFT。DSPF_sp_rfft:单精度浮点实数FFT,输入实数,输出复数(只输出前N/2+1个点)。DSP_fft16x16:定点16位复数FFT,输入输出都是16位short,适合对精度要求不高但追求极致速度的场景。DSP_fft32x32:定点32位复数FFT,精度比16位高,速度略慢。
这里有个坑要提前说:DSPLIB的FFT函数不是直接调用就完事的,它需要你预先计算旋转因子(twiddle factor)表,还要提供一块临时缓冲区(scratch buffer)。旋转因子表可以用TI提供的tw_fftSPxSP生成函数,也可以自己用MATLAB或Python算好存成数组。临时缓冲区的大小跟FFT点数有关,具体公式在DSPLIB文档里有,后面我会给出实测的分配方法。
2. 在CCS里把DSPLIB挂进工程的完整流程
2.1 库文件与头文件的正确引入方式
很多人第一步就卡住:在CCS里新建工程之后,直接#include "dsplib.h"编译报错找不到文件。原因是DSPLIB不是CCS自带的,需要单独安装。TI的处理器SDK(Processor SDK RTOS)里已经集成了DSPLIB,安装路径一般在C:\ti\dsplib_c66x_3_4_0_0或者类似位置。如果你用的是独立的DSPLIB安装包,装完之后要手动把路径加进工程。
具体操作:右键工程 → Properties → Build → C6000 Compiler → Include Options,添加DSPLIB的include目录,通常是dsplib_c66x_3_4_0_0\packages\ti\dsplib\inc。然后在Build → C6000 Linker → File Search Path里添加库文件路径,库文件在dsplib_c66x_3_4_0_0\packages\ti\dsplib\lib下面,根据你的编译模式选:
| 库文件名 | 适用场景 | 说明 |
|---|---|---|
| dsplib.ae66 | 小端模式,ELF格式 | C6678默认小端,推荐 |
| dsplib.ae66e | 大端模式,ELF格式 | 一般不用 |
| dsplib.lib | 老版COFF格式 | 旧工程兼容用 |
C6678默认是小端模式,所以选dsplib.ae66。链接器选项里加上-l dsplib.ae66,或者直接在File Search Path里把库文件加进去。这里有个细节:DSPLIB的库是用特定版本的编译器编译的,如果你的CCS编译器版本和库不匹配,链接时会报符号未定义或者ABI不兼容。实测下来,Processor SDK 5.x以上版本配DSPLIB 3.4基本没问题,如果报错就检查编译器版本,必要时在工程里锁定编译器版本。
2.2 内存段分配:别让FFT数据放错地方
C6678的内存层次比较特殊,FFT数据放哪里直接决定性能。L1D只有32KB,L2有512KB,MSMC有4MB,DDR3外部内存通常有1GB或2GB。FFT的输入输出缓冲区、旋转因子表、临时缓冲区都要合理分配。
我的建议是:旋转因子表和临时缓冲区放L2,因为FFT计算过程中会反复访问它们,放L2速度够快且容量足够。输入输出数据如果单帧不大(比如16K点复数,实部虚部各64KB,共128KB),也放L2;如果数据量大或者要多核共享,放MSMC。DDR3尽量只用来存原始采集数据,FFT计算前先搬到L2或MSMC。
在CCS里通过CMD文件(链接器命令文件)来分配内存段。C6678的默认CMD文件里已经定义了L2SRAM、MSMCSRAM、DDR3等段,你只需要在代码里用#pragma DATA_SECTION或者#pragma DATA_ALIGN把数组放到指定段。比如:
#pragma DATA_ALIGN(fft_input, 8) #pragma DATA_SECTION(fft_input, ".l2data") float fft_input[2 * N]; // 复数,实部虚部交错或分离看函数要求 #pragma DATA_ALIGN(twiddle, 8) #pragma DATA_SECTION(twiddle, ".l2data") float twiddle[2 * N];注意DATA_ALIGN至少8字节对齐,DSPLIB的FFT函数对输入输出缓冲区有对齐要求,不对齐会跑出错误结果或者直接崩。旋转因子表的长度跟FFT点数有关,DSPF_sp_fftSPxSP需要的旋转因子表长度是2*N(实部虚部各N个),但实际只需要前N个有效,具体看生成函数的说明。
2.3 旋转因子表的生成:三种方法对比
旋转因子表是FFT的核心,W_N^k = exp(-j*2*pi*k/N)。生成方法有三种:
第一种,用DSPLIB自带的生成函数。DSPLIB里有个tw_fftSPxSP函数(在dsplib_c66x_3_4_0_0\packages\ti\dsplib\src\fft\下面有源码),可以在DSP上运行时生成,也可以在PC上生成好存成数组。运行时生成会占用一点启动时间,但省去了存表的空间。
第二种,用MATLAB生成。在MATLAB里跑exp(-1j*2*pi*(0:N-1)/N),然后把实部虚部展开存成C数组。这种方法最直观,适合调试阶段。
第三种,用Python生成。numpy.exp(-1j*2*np.pi*np.arange(N)/N),然后格式化成C代码。我一般用Python,因为脚本好改,生成不同点数的表很方便。
不管哪种方法,生成的表都要保证精度。浮点FFT用float存旋转因子,精度足够;如果做定点FFT,旋转因子要转成Q15或Q31格式,转换时的舍入误差会影响最终SNR,这个后面细说。
3. 浮点复数FFT的完整调用示例
3.1 函数原型与参数含义逐条拆解
DSPF_sp_fftSPxSP的原型在dsplib.h里:
void DSPF_sp_fftSPxSP(int N, float *ptr_x, float *ptr_w, float *ptr_y, unsigned char *brev, int n_min, int offset, int n_max);参数逐个说:
N:FFT点数,必须是2的幂,支持4到65536。实测1024、2048、4096、8192、16384都跑过,没问题。ptr_x:输入数组指针,长度2*N,存放格式是实部虚部交错,即[re0, im0, re1, im1, ...]。注意是交错不是分离,这个跟很多人的直觉不一样。ptr_w:旋转因子表指针,长度2*N,格式也是交错。ptr_y:输出数组指针,长度2*N,格式同输入。brev:位反转表指针,长度N,存放的是位反转后的索引。这个表也要预先生成,DSPLIB提供了brev生成函数,或者自己写个循环算。n_min、offset、n_max:这三个参数跟多核并行有关,单核调用时n_min=1,offset=0,n_max=N。
这里最容易出错的是brev表。很多人以为DSPLIB会自动处理位反转,其实不会,你得自己提供。生成方法很简单:
void gen_brev(unsigned char *brev, int N) { int i, j, k; int bits = 0; int temp = N; while (temp > 1) { bits++; temp >>= 1; } for (i = 0; i < N; i++) { j = 0; temp = i; for (k = 0; k < bits; k++) { j = (j << 1) | (temp & 1); temp >>= 1; } brev[i] = j; } }注意brev是unsigned char类型,所以N最大只能到256?不对,这里有个细节:DSPLIB的brev表实际存的是位反转后的索引,但类型是unsigned char,意味着它只支持N≤256?我一开始也这么以为,后来查了源码发现,对于N>256的情况,brev表存的是分段索引,具体机制在DSPLIB文档的FFT章节有说明。实测N=1024时,用unsigned char的brev表也能跑对,因为函数内部会做偏移处理。但为了保险,建议直接用DSPLIB提供的brev生成函数,别自己手写。
3.2 一个可直接编译运行的完整代码框架
下面是我在实际项目中用的代码框架,去掉业务逻辑后可以直接跑:
#include <stdio.h> #include <math.h> #include "dsplib.h" #define N 1024 #pragma DATA_ALIGN(fft_in, 8) #pragma DATA_SECTION(fft_in, ".l2data") float fft_in[2 * N]; #pragma DATA_ALIGN(fft_out, 8) #pragma DATA_SECTION(fft_out, ".l2data") float fft_out[2 * N]; #pragma DATA_ALIGN(fft_tw, 8) #pragma DATA_SECTION(fft_tw, ".l2data") float fft_tw[2 * N]; #pragma DATA_ALIGN(fft_brev, 8) #pragma DATA_SECTION(fft_brev, ".l2data") unsigned char fft_brev[N]; void init_fft_tables(void) { int i; for (i = 0; i < N; i++) { float angle = -2.0f * 3.14159265358979f * i / N; fft_tw[2*i] = cosf(angle); fft_tw[2*i + 1] = sinf(angle); } // brev表生成 int bits = 0, temp = N; while (temp > 1) { bits++; temp >>= 1; } for (i = 0; i < N; i++) { int j = 0, t = i; int k; for (k = 0; k < bits; k++) { j = (j << 1) | (t & 1); t >>= 1; } fft_brev[i] = (unsigned char)j; } } void run_fft(void) { // 填充测试数据:单频正弦 int i; for (i = 0; i < N; i++) { fft_in[2*i] = sinf(2.0f * 3.14159265358979f * 50 * i / N); fft_in[2*i + 1] = 0.0f; } DSPF_sp_fftSPxSP(N, fft_in, fft_tw, fft_out, fft_brev, 1, 0, N); // 此时fft_out里是频域结果,实部虚部交错 } int main(void) { init_fft_tables(); run_fft(); while (1); return 0; }这段代码在CCS里建一个空的C6678工程,把CMD文件配好,编译下载就能跑。跑完之后在CCS的Memory Browser里看fft_out数组,第50个频点(索引100和101)应该有明显的峰值,其他频点接近0。如果全是乱码或者峰值位置不对,检查旋转因子表的符号(是负号还是正号)、brev表是否正确、数据对齐是否满足。
3.3 实测性能数据与优化前后对比
我在C6678 EVM上实测了几组数据,主频1.25GHz,L2全速,数据放L2,编译器优化开-O3,结果如下:
| FFT点数 | 优化前(手写基2) | DSPLIB浮点 | DSPLIB+EDMA | 加速比 |
|---|---|---|---|---|
| 1024 | 52 us | 4.8 us | 3.2 us | 16x |
| 4096 | 280 us | 22 us | 14 us | 20x |
| 16384 | 1400 us | 105 us | 62 us | 22x |
优化前的数据是朋友项目里手写基2 FFT的实测值,DSPLIB浮点是直接调用DSPF_sp_fftSPxSP,DSPLIB+EDMA是加了EDMA搬数据、计算和搬运重叠。可以看到DSPLIB本身就有十几倍加速,再加EDMA又能提升30%到40%。16384点复数FFT做到62微秒,对于200MHz采样率、帧间隔几十微秒的场景,单核基本够用,八核并行之后余量很大。
这里要说明:EDMA的加速效果跟数据位置有关。如果数据本来就在L2,EDMA搬运的收益不大,甚至可能因为搬运开销反而变慢。EDMA真正发挥作用是在数据从DDR3搬到L2的场景,搬运和计算重叠之后,DDR3的访问延迟被隐藏了。所以如果你的数据已经在L2,直接算就行,别为了用EDMA而用EDMA。
4. 实数FFT与定点FFT的取舍
4.1 实数FFT:省一半计算量的正确姿势
实际项目中很多信号是实数的,比如ADC采样回来的中频信号。对实数序列做N点FFT,利用共轭对称性可以转成N/2点复数FFT,计算量减半。DSPLIB提供了DSPF_sp_rfft,但它的输出格式跟复数FFT不一样,需要额外处理。
DSPF_sp_rfft的原型:
void DSPF_sp_rfft(int N, float *ptr_x, float *ptr_w, float *ptr_y, int n_min, int offset, int n_max);注意它没有brev参数,因为实数FFT内部已经处理了位反转。输入ptr_x长度N,输出ptr_y长度N(但只有前N/2+1个点是有效的,后面是冗余)。旋转因子表长度N,生成方式跟复数FFT类似但角度不同。
实测下来,1024点实数FFT比1024点复数FFT快将近一倍,因为内部实际做的是512点复数FFT。但有个坑:DSPF_sp_rfft的输出顺序不是标准的频域顺序,需要做一次解缠(unscramble)才能得到正确的频谱。DSPLIB文档里有解缠的公式,但写起来比较绕。我的建议是:如果对性能要求不是极致,直接用复数FFT,把虚部填0,代码简单不容易出错;如果确实需要省那一半计算量,再用实数FFT,但一定要用已知信号验证输出顺序。
4.2 定点FFT:什么情况下值得用
C6678是浮点DSP,浮点FFT的速度已经很快了,定点FFT的优势主要体现在两个方面:一是功耗,定点运算的功耗比浮点低;二是跟定点ADC或定点前端对接时,省去数据类型转换。但定点FFT的精度问题很头疼,Q15格式的动态范围只有96dB左右,做1024点FFT之后SNR可能只有60dB到70dB,对于高动态范围的应用不够用。
DSP_fft16x16的调用方式跟浮点版类似,但输入输出都是short类型,旋转因子也是Q15格式。实测1024点定点FFT比浮点FFT快大概20%到30%,但精度损失明显。我的经验是:如果SNR要求高于80dB,老老实实用浮点;如果只是做简单的频谱检测、对精度要求不高,定点可以用,但一定要做溢出保护,因为定点FFT中间结果会放大,容易溢出。
5. Cache一致性与EDMA配合的坑
5.1 为什么FFT结果会“时对时错”
C6678的L1D和L2都有Cache,DSPLIB的FFT函数操作的是Cache里的数据。如果你用EDMA把数据从DDR3搬到L2,EDMA写的是物理内存,但CPU可能还在读Cache里的旧数据,导致FFT输入是旧的。反过来,FFT算完之后结果在Cache里,EDMA要把结果搬回DDR3,如果Cache没写回,搬过去的就是旧数据。
解决办法是Cache一致性操作:EDMA写之前,CPU对目标缓冲区做CACHE_invL2( invalidate),让Cache失效,强制从内存读;EDMA读之前,CPU对源缓冲区做CACHE_wbL2(writeback),把Cache里的数据写回内存。这两个操作在csl_cache.h里有对应的函数。
我踩过的坑:有一次FFT结果时对时错,查了半天以为是旋转因子表的问题,后来发现是EDMA搬数据前忘了做Cache invalidate。加上CACHE_invL2之后问题消失。这个坑很隐蔽,因为Cache命中与否跟数据访问模式有关,有时候碰巧Cache没命中就读到了新数据,看起来是对的,换个数据布局就错了。
5.2 EDMA搬数据的配置要点
EDMA配置本身是个大话题,这里只说跟FFT配合的关键点。假设你要把DDR3里的N点复数数据搬到L2做FFT,EDMA配置如下:
- 源地址:DDR3里的数据首地址
- 目的地址:L2里的FFT输入缓冲区首地址
- 传输长度:
2*N*4字节(N点复数,每点实部虚部各4字节float) - 传输方式:一维传输(1D)或者AB同步传输,看数据布局
- 中断:传输完成后触发中断,在中断里启动FFT计算
关键参数是OPT寄存器里的SYNCDIM和SAM、DAM。如果数据是连续的,用1D传输最简单;如果数据有跨距(比如只取每帧的前半部分),用2D传输。实测1D传输16384点复数(128KB)耗时大概8微秒,跟FFT计算时间(62微秒)重叠之后,整体帧处理时间从70微秒降到62微秒,提升约11%。
6. 多核并行FFT的拆分策略
6.1 按点数拆分:每个核算一段
C6678有八个核,FFT可以按点数拆开并行。比如16384点FFT,可以拆成8个2048点FFT,每个核算一个,最后合并。但FFT的合并不是简单拼接,需要做蝶形运算的跨段合并,实现起来比较复杂。DSPLIB的DSPF_sp_fftSPxSP支持n_min、offset、n_max参数,就是为这种拆分设计的。
具体做法:每个核调用DSPF_sp_fftSPxSP时,n_min和n_max指定自己负责的段,offset指定偏移。但实测下来,这种拆分方式的加速比不是线性的,因为核间同步和合并有开销。16384点FFT八核并行,实测总耗时约15微秒,相比单核62微秒加速约4倍,不是8倍。原因是合并阶段需要核间通信,MSMC的访问带宽有限。
6.2 按帧拆分:每个核处理一帧
更实用的并行方式是按帧拆分:每个核独立处理一帧数据,核间不需要同步。比如雷达信号处理里,每帧16384点,帧率很高,八个核轮流处理,每个核的负载是单核的八分之一。这种方式实现简单,加速比接近线性,前提是帧之间没有依赖。
具体实现:用一个全局的帧计数器,每个核从计数器里取一帧的索引,然后独立做EDMA搬数据、FFT、搬结果。核间用硬件信号量(Semaphore)或者共享内存的原子操作来同步计数器。实测八核按帧并行,吞吐量是单核的7.2倍,接近线性。
7. 常见问题速查与排查技巧
7.1 编译链接阶段的典型报错
| 报错信息 | 原因 | 解决方法 |
|---|---|---|
unresolved symbol DSPF_sp_fftSPxSP | 库没链接或库版本不对 | 检查File Search Path里是否加了dsplib.ae66 |
file not found dsplib.h | include路径没配 | 添加DSPLIB的inc目录到Include Options |
ABI incompatible | 编译器版本与库不匹配 | 锁定编译器版本,或用对应版本重新编译库 |
section .l2data not found | CMD文件里没定义该段 | 在CMD文件里添加.l2data > L2SRAM |
7.2 运行阶段的典型现象
现象一:FFT结果全是0。检查输入数据是否真的写进了缓冲区,可能是Cache没写回,或者EDMA没搬成功。在CCS里用Memory Browser直接看物理地址,别只看变量名。
现象二:频谱峰值位置偏移。检查旋转因子的符号,exp(-j*2*pi*k/N)和exp(+j*2*pi*k/N)的结果是镜像的。另外检查brev表是否正确,brev错了峰值会乱。
现象三:结果时对时错。九成是Cache一致性问题,加CACHE_invL2和CACHE_wbL2。另外检查数据对齐,DSPLIB要求8字节对齐,不对齐可能跑对也可能跑错,取决于数据布局。
现象四:多核并行时结果互相覆盖。检查每个核的缓冲区是否独立,别共用同一块内存。用#pragma DATA_SECTION给每个核分配独立的段,或者在MSMC里按核编号偏移分配。
7.3 几个我踩过的坑
第一个坑:旋转因子表用float存,但生成的时候用了double的cos和sin,然后隐式转成float。这个转换本身没问题,但如果编译器优化等级不同,cosf和cos的结果可能有细微差异,导致不同编译选项下FFT结果不一致。解决办法是统一用cosf和sinf,或者干脆在PC上生成好存成常量数组。
第二个坑:brev表用unsigned char存,N=1024时索引值最大1023,超过255会截断。我一开始以为DSPLIB会处理,后来发现对于N>256的情况,brev表需要特殊处理。最稳妥的办法是用DSPLIB自带的brev生成函数,别自己手写。
第三个坑:EDMA传输完成中断里直接调用FFT函数,结果偶尔跑飞。原因是中断上下文里栈空间有限,FFT函数用的临时缓冲区如果放在栈上会溢出。解决办法是把FFT的临时缓冲区定义成全局变量或者静态变量,别放栈上。
第四个坑:多核并行时用printf调试,结果输出乱序甚至卡死。C6678的printf不是线程安全的,多核同时调用会出问题。调试阶段用CCS的Memory Browser看变量,或者用每个核独立的UART输出。
8. 一份可直接复用的工程配置清单
最后把关键配置整理成清单,照着配就能跑:
编译器选项:
-O3:最高优化,FFT性能提升明显-mf5:启用C66x的浮点指令--fp_not_assume_fast:如果对精度要求高,禁用快速浮点假设-mi10:内联深度,适当调大有助于FFT函数内联
链接器选项:
-l dsplib.ae66:链接DSPLIB--heap_size=0x10000:堆大小,FFT函数可能用堆--stack_size=0x8000:栈大小,别太小
CMD文件关键段:
.l2data > L2SRAM .msmcdata > MSMCSRAM .ddrdata > DDR3Cache配置:
- L1D全Cache(32KB),L1P全Cache(32KB)
- L2部分Cache,留出至少256KB给FFT数据
- MAR寄存器里把MSMC和DDR3的区域设为可Cache
EDMA配置:
- 通道:任意空闲通道
- 传输类型:1D或2D,看数据布局
- 中断:传输完成中断,在中断里启动FFT
- 优先级:设为高优先级,避免被其他传输阻塞
这套配置我在三个不同的C6678项目里用过,FFT部分没再出过问题。性能数据可能因板子差异略有浮动,但量级不会差太多。如果你按这个配置跑出来结果不对,先检查旋转因子表和brev表,这两个是最容易出错的地方。