打印
[VHDL]

玩转VHDL007-FFT

[复制链接]
1174|5
手机看帖
扫描二维码
随时随地手机跟帖
跳转到指定楼层
楼主
ucx|  楼主 | 2017-10-6 12:14 | 只看该作者 回帖奖励 |倒序浏览 |阅读模式
007-FFT.rar (31.88 KB)

FFT算法
每当想介绍一下FFT算法的FPGA实现时,又总是觉得千头万绪不知从何处开始。每编辑一帖,总是力求达到如下目的:

  FPGA工程师提供一个取之即用的免费IP核。这要求程序实现的功能尽可能通用和常用,外部接口尽可能简单明了。使用者可以不关心内部实现细节,也与使用的硬件语言是VHDLverilog无关。

  通过对程序设计过程的分析,给同仁们分享设计过程中采用的技巧、以及优化资源提高速率的注意事项。这也与语言是VHDL还是verilog无关。

  通过代码的呈现,让读者感受到通过打造自己的库函数可以使VHDL书写起像C语言一样自由。

既然不知何处说起,那么就按照编辑帖子的目的顺序来叙说吧。先介绍FFT算法的功能模块DIT_FFT的参数、接口和性能。

FFT算法模块接口和性能
模块DIT_FFT实现N个点的FFTIFFT算**能,其实体头部:

Entity DIT_FFT is generic(

         N :integer := 1024;  DW :integer := 24; IFFT : boolean := false

         );

         Port(

         clock                           : in std_logic;

         bgn                              : in std_logic;

         dReal,dImag             : in std_vector(DW-1 downto 0) :=(others=>'0');

         qReal,qImag             : out std_vector(DW-1 downto 0);

         qEn,busy                    :out std_logic

         );

End DIT_FFT;

类属参数NFFT点数,其值只能为2的整数次幂否则编译报错,这里默认为1024FFTDW代表运算数据宽度,需要根据实际情况选取。例如,数据来自16AD,要实现256FFT,那么DW至少选择24才能保证计算结果不溢出。而对于2048点,DW要选取27位或以上。因为IFFT的表达式中有除以N的项,所以结果不会溢出。类属参数IFFT为真时DIT_FFT实现的是IFFT功能,这里默认为假,即实现的是FFT计算。

端口clock:时钟,对常见FPGA,上限可工作在130M

bgn:脉宽为一个时钟周期的开始运算脉冲,高电平有效。要求必须在输出busy为低电平状态下发起。

dReal,dImag:从名称可以看出分别表示输入数据的实部和虚部,格式为定点有符号数。在bgn有效的下一周期开始N个周期内连续输入N个数。不使用虚部输入时,则默认为全0输入。如果dReal数据源来自AD,那么需要按照有符号数规则把AD值扩展到DW位输入到dReal

qReal,qImag:结果输出,实部和虚部,顺序输出。

qEn:为1表示计算结束,qReal,qImag正在输出结果,持续时间N个周期。

busy:在bgn下一周期置高,在qEn指示输出第N个结果后拉低。busy为低指示可以进行新一轮运算。

DIT_FFT可划分为前后三步:数据输入、蝶形运算迭代、数据输出。数据输入和输出均刚好需要N个时钟周期,蝶形运算时间为N/2*lOG2(N)个时钟周期。busy指示忙的时间为这三个时间的总和。在N=10DW=24条件下Quartus II默认设置编译消耗926个逻辑单元,乘法器16个,选用器件EP4CE30F23I7时最高时钟超过150M

本人在256点模式下做了一次仿真, 全部仿真文件见附件007-FFT.rar,仿真运行32us时间见结果。fft_data.txt存放256个用17位表示的数据,取值范围是-32768+32768。以这256个数据进行FFT运算,结果保存在<fft_结果.txt>文件中,实部在第一列,虚部在第二列。在FFT结果输出的同时,又进行IFFT运算,其结果保存在<ifft_结果.txt>文件中。

FFT运算结果与按照double型计算的FFT结果进行比较,误差绝对值不超过8IFFT运算结果与fft_data.txt中的输入数据比较,误差绝对值不超过1

FFTIFFT运算需要用到wi=e^(-j*i*2p/N)FPGA用到这些数值的可以用005帖中介绍的方法。这里为简化设计,直接用查表得实部和虚部,初始化数据分别存放在real.mif文件和imag.mif中。为了适合FFT运算,wi采用i的比特逆序形式存放,即wi=e^(-j*i反序*2p/N)。如此,我们发现对不同的N值,只要i相同则wi相同。由于wi要以定点数格式存放,所以乘以2^16 = 65536后取整。那么wi的实部和虚部的范围是-2^162^16,故而用18比特表示。

附件中给出的mif文件是以4096点生成的,当实际使用N<4096时,Quartus II编译器自动截取前N个初始化ROM。当大于4096点时,程序中DIT_FFT.vhd的第34行设置一个编译错误。如果需要超过4096点的FFT,则需要重新产生mif文件并修改或删除此34行。然而当用modelsim仿真时,mif文件必须刚刚好和实际的N值匹配。如果仿真N=256情况,那么需要把mif文件中原来的DEPTH=2048;改成DEPTH=128;只保留007F地址的内容,其余删除。

模块的接口和性能的介绍,是为了给使用者提供参考,便于考量模块是否适宜于自己的设计。如果你已经是成熟的FPGA工程师,只是懒得动脑再考虑FFT编程实现,那么下载附件返回,余下的内容就不需要再看了。

在解读DIT_FFT之前,还是先谈谈如何写程序。因为在程序中用到了这些思想。


相关帖子

沙发
ucx|  楼主 | 2017-10-6 12:15 | 只看该作者

本帖最后由 ucx 于 2017-10-6 12:17 编辑

NFFT算法描述
完整的FFT算法可分为三大步:数据输入、蝶形运算迭代、结果输出。这三部分细节如下所述。

顺序输入N个数存入数组X[N],按照W=e^(-j*i反序*2p/N)初始化数组W[N/2]

迭代算法用C语言完整精确描述为:

    for(D=N/2; D > 0; D /= 2){

       R= D*2;

       for(j=0; j < N/R; j++) {        

           for(i=0; i < D; i++) {

    1.         a = i+j*R;

    2.         b = a + D;

    3.         wb = W[j] * X[b];

    4.         X[b] = X[a] - wb;

    5.         X[a] = X[a] + wb;

           }

       }

}

按照索引比特逆序读取数组X[N],即得FFT计算结果。

算法由外、中、内三层循环构成。称外层循环为轮循环,中层和内层循环合称轮内循环。呵呵,写到这里,好像凭空多了轮循环和轮内循环两个概念。其实,如果你对FFT算法认识还不够清晰的话,这个概念有助于理解FFT。内层循环就是一次蝶形运算,而一次蝶形运算完成了两个点的数值更新。如果要把X[N]全部更新一遍,可以形象地定义为一轮,也就是中层和内层循环合起来完成的功能。

轮内循环为N/2次,轮循环为LOG2(N)次,则总循环次数为M=N/2*LOG2(N),正如教科书中介绍的一样。为了在叙述FPGA实现的时候便于和算法对应,接下来继续引入几个概念,一切都是为了后面的叙述方便。

称蝶形运算第1步为选出蝶形上翅X[a]的位置a,第2步为选出下翅X位置b,第3步求出旋转因子Wn与下翅的乘积WB,第4,5步为更新蝶形双翅。轮循环中的D为翅距,D的两倍R为蝶距。在不引起混淆的情况下,以下也常简称上翅的位置为上翅,简称下翅的位置为下翅。

在轮循环中可看出,每过一轮后翅距减半,最后一轮翅距为1,最后一轮结束则蝶形运算结束。





使用特权

评论回复
板凳
ucx|  楼主 | 2017-10-6 12:21 | 只看该作者
本帖最后由 ucx 于 2017-10-6 12:24 编辑

FFTFPGA实现设计思想
算法描述中提到的数组X[N]W[N/2],在FPGA中应分别以RAMROM形式存放,实部和虚部分开存放但读写地址相同。由于实部和虚部在处理时序上完全相同,变化规则上也几乎没有区别,为表述清晰从现在开始暂时不考虑实部和虚部的情况只把它们看做一个数。那么,X[N]需要地址空间为NRAM存放,W[N/2]需要地址空间为N/2ROM存放。将要实现的模块命名为DIT_FFT

在模块DIT_FFT接口部分已详细介绍了W[N/2]生成方法,不再赘述。FPGA实现的FFT之数据来源通常是AD采样所得,而AD采样在不同场合速率有高低。FFT的时钟clock可以和AD时钟相同,也可以无关。所以下文提到的时钟均指FFTclock

数据输入和数据输出的实现
DIT_FFT要求触发FFT计算时,首先给一个与时钟同步的触发信号bgn,接着按照时钟节拍连续送入N个数据,这就是算法的第一步即数据输入。数据输入的实现比较简单:用一个LOG2(N)位计数器d_sn,在触发信号bgn到达后d_sn0计数到N-1然后回到0保持不变,在这N个时钟周期的计数过程中以d_sn作为地址存入X[N]。数据输出部分也类似地需要一个计数器q_rdA用作地址读出X[N],不同于输入的是q_rdA要进行比特反序后再作为地址使用。

循环变量的实现
那么,接下来就剩下算法的主体部分——蝶形运算迭代了。算法描述部分中说迭代需要的总循环次数是M次,那么迭代过程能否只用M个时钟周期完成呢?如果要在M个周期内完成迭代运算,那么必须首先实现一个周期内能同时读到上翅和下翅两个数据,也必须能在一个时钟周期内写入这两个数据完成更新。如果用一块RAM来存放X[N],一次只能读写一个地址,上翅和下翅的地址是不相同的,则显然无法实现一个周期读到双翅写入双翅的功能。那么至少需要两块RAM来存放X[N],然而上翅和下翅的地址关系是变化的,如何分割RAM为两块呢?

ª©¨§乱码就乱码吧。这不是黑桃、红心、方片、梅花吗?呵呵,是的,意思是放松一下,对如何分割RAM感兴趣者可以先思考一下,答案稍后揭晓,先来看看更简单的问题:三层循环的实现。

轮内循环用一个lOG2(N)-1位的计数器F_sn轻易完成。F_sn只在迭代运算时计数,其他时间清零。F_sn计满全1的时刻nxt,则是进入下一轮的触发信号。轮循环用round计数器实现,round在数据输入即将结束时(程序中命名为bgn_FFT)被清零,否则nxt+1计数。round= LOG2(N)-1时为最后一轮运算。这时,我们注意到内层循环中i的数值在F_sn的低位,而中层循环中j的数值在F_sn的高位。唯一遗憾的是高位和低位的分界线在随轮数变化而移动。要产生ij可以分别用两个计数器实现,也可以从F_sn经移位操作得到。程序中介绍的是另外一种小方法,需要一个位数和F_sn相同的移位寄存器。下面以N=16需要4轮的情况举例列表说明,表中数值用二进制表示为了看清逻辑。

N=16条件下F_sn是一3位计数器,mask也是3位。maskbgn_FFT后置全1,然后每一轮右移一个比特,那么4轮中mask的值依次是111, 011, 001, 000。上翅和下翅数字相同的部分用01表示、不同的部分用红色的LH表示。

  
F_sn
  
  
000
  
  
001
  
  
010
  
  
011
  
  
100
  
  
101
  
  
110
  
  
111
  
  
mask
  
  
上翅a
  
下翅b
  
  
L000
  
H000
  
  
L001
  
H001
  
  
L010
  
H010
  
  
L011
  
H011
  
  
L100
  
H100
  
  
L101
  
H101
  
  
L110
  
H110
  
  
L111
  
H111
  
  
111
  
  
上翅a
  
下翅b
  
  
0L00
  
0H00
  
  
0L01
  
0H01
  
  
0L10
  
0H10
  
  
0L11
  
0H11
  
  
1L00
  
1H00
  
  
1L01
  
1H01
  
  
1L10
  
1H10
  
  
1L11
  
1H11
  
  
011
  
  
上翅a
  
下翅b
  
  
00L0
  
00H0
  
  
00L1
  
00H1
  
  
01L0
  
01H0
  
  
01L1
  
01H1
  
  
10L0
  
10H0
  
  
10L1
  
10H1
  
  
11L0
  
11H0
  
  
11L1
  
11H1
  
  
001
  
  
上翅a
  
下翅b
  
  
000L
  
000H
  
  
001L
  
001H
  
  
010L
  
010H
  
  
011L
  
011H
  
  
100L
  
100H
  
  
101L
  
101H
  
  
110L
  
110H
  
  
111L
  
111H
  
  
000
  


由表可看出标红的右半部分可由maskF_sn按位与得到,而左半部分可由mask按位取反后再按位与F_sn得到。用C语言的语法表述为:

a = ( F_sn & ~mask) << 1 |  (F_sn & mask);

b = a |(( mask << 1 | 1) & ~mask);

对应程序中下面两行代码

AN <= ((F_sn and not msk) & '0') or ('0' & (F_sn andmsk));

BN <= AN or (msk & '1' and '1' & not msk);
其实在硬件程序中,移位常数的逻辑根本不消耗任何逻辑资源。

使用特权

评论回复
地板
ucx|  楼主 | 2017-10-6 12:27 | 只看该作者
本帖最后由 ucx 于 2017-10-6 12:37 编辑

上翅和下翅同时读写解决思路
仔细分析算法描述语句或者浏览示例表格数据,可以发现:
  最后一轮,下翅和上翅的地址只有最低位不同。
  其他轮,无论上翅还是下翅随着时钟总是一偶一奇地变化,奇数在偶数之后,且奇数始终比前面的偶数刚好大1
那么,把原来的一块RAM按照奇偶地址分成两块操作可能就是一条可行的路径。这也相当于地址空间减小一半数据位宽增加一倍的改变,这样首先实现了一个时钟可以读取到两个数功能。偶块和奇块RAM同时寻址(读地址记为rd_A,写地址记为wr_A),奇块代表的实际X[N]中的位置比偶块大1。偶块记为eRAM,奇块记为oRAM
现在令rd_A交替选择ab(去掉它们的最低位),最后一轮时由于ab的高位相同顾可任意从二者选一。那么偶奇RAM输出如下,最后四列表示刚好进入最后一轮。A代表上翅,B代表下翅。
eRAM:      A0    B0    A2    B2    A4    B4    ...     A14  B14  A0    A1    A2    A3
oRAM:      A1    B1    A3    B3    A5    B5    ...     A15  B15  B0    B1    B2    B3
那么,接下来的问题就是如何让下翅和上翅同时出现在一个时钟周期里了。对输出延时12个时钟周期,结果如下。其中o_e是奇偶时刻信号,last是最后一轮指示信号。
e1RAM:   XX     A0    B0    A2    B2    A4    B4    ...     A14  B14  A0    A1    A2    A3
o1RAM:   XX     A1    B1    A3    B3    A5    B5    ...     A15  B15  B0    B1    B2    B3
o2RAM:   XX     XX     A1    B1    A3    B3    A5    B5    ...     A15  B15  B0    B1    B2    B3
o_e:          XX     0       1       0       1       0       1       ...   0       1       0       1       0       1
last:           0       0       0       0       0       0       0       ...   0       0       1       1       1       1
C语言语法,表述上下翅选择逻辑:
X[a] = last || !o_e ? e1RAM : o2RAM;
X[ b ] = last || o_e ? o1RAM : eRAM;
结果可自行验证如下所示
X[a]:          A0    A1    A2    A3    A4    A5    ...     A14  A15  A0    A1    A2    A3
X[ b ]:        B0     B1    B2    B3     B4    B5...        B14   B15  B0   B1     B2    B3
到此为止,完成了在同一个时钟内读到上翅和下翅,可以进行乘和加减运算了。经蝶形运算获得新值时,再按照奇偶RAM到双翅时序对齐的过程实现从双翅到奇偶RAM的时序对齐,然后就可以同时保存双翅的计算结果了。
本文花较大的力气介绍时序对齐的问题,是因为时序对齐对于FPGA编程尤为重要。可以不夸张地说,FPGA的程序编写过程,就是一个时序对齐的过程,剩下的问题都是简单的问题。那么接下来,相乘和相加就没有什么可介绍的了,是不是该分析一下程序代码了呢?还是把这个最不重要的问题放在最后再说吧,因为在本贴的开头提到了第3个目的。先讲讲为什么要、和如何打造自己的库函数。

使用特权

评论回复
5
ucx|  楼主 | 2017-10-6 12:33 | 只看该作者

打造自己的库函数
函数可提高代码的可读性,这一点毋庸多说。特别是C语言中的内联函数,纯粹是为了提高可读性而生。在编写FFT变换时,少不了比特逆序转换运算,可定义函数如下:

         Function reverse(iv           : std_vector) return std_vector is

                   variable q                   :std_vector(iv'reverse_range);

         begin

                   for i iniv'range loop

                            q(i):= iv(i);

                   end loop;

                   return q;

         End;

逆序在软件中实现,需要折腾半天。而在硬件中,虽然也需要函数实现,但是这是个不消耗资源的函数,当然更不需要时间。再强调一遍:reverse是一个零耗函数。此函数不仅在FFT中会用到,在以太网MAC层也会经常用到。也就是说reverse是一个比较通用的函数,那么我们就把它放到一个公共的库(或者说包,这里不严格区分)里。我已在前面的帖子中提到,本人用的是ucx_2008pkg

类似地,像二选一和三选一功能也很常用,我们也把它命名为OneOf放在公共库里。本人在编程中偏爱使用计数器,不用状态机,所以对计数器的常用操作制作了一些过程放在库里。一旦你稍微画点时间熟悉这些函数或过程,就会对这些函数熟练使用,而一旦使用了这些库函数,相信你再也舍不得丢掉它了。

随着编程量的积累,自会感觉到哪些函数该留下来,哪些需要再增补进去。beleive it or not, ucx_2008pkg.vhd是本人经验的结晶。

FFT计算中,用到了两个数相乘然后截取一定的位数使用,这在多处出现。那么就只在使用它的文件内编写一个函数fix_FFT_mul作为私有函数,也可放在work库的一个包内。有时,虽然只在一处使用了某一功能,而为了突出强调也可以用一个函数替换部分代码。





代码简析
butterfly.vhd中大部分代码都是在延时对齐。变量命名规则:以A开头的表示上翅,以B开头的表示下翅,以R结尾表示实部,以I结尾表示虚部,ood表示奇RAMeev表示偶RAM,数字0,1,2...表示相对于输入的延时量。

44round(v)认为v含有1位小数进行四舍五入处理。SX1B(v)是把v看做有符号数扩展1位用于加减运算防溢出。

61,62完成奇偶RAM到双翅时序对齐

118,119完成双翅到奇偶RAM的时序对齐

120-128完成写地址与数据对齐

102-105表明在IFFT功能下,蝶形运算后要除以2,从而最终结果实现除以N



DIT_FFT.vhd中变量名设计思想中表述一致,不再分析

44-51中的qRegis=>false,表示geR:/RamRomqrdA一个时钟周期出现

52-55中无qRegis=>false,表示gwR:/RamRomq rdA两个时钟周期出现


使用特权

评论回复
6
ucx|  楼主 | 2017-10-6 12:34 | 只看该作者
不知为何从WORD贴到这里,格式变来变去。

使用特权

评论回复
发新帖 我要提问
您需要登录后才可以回帖 登录 | 注册

本版积分规则

ucx

28

主题

85

帖子

5

粉丝