
今天我们就去看看常春藤盟校Cornell University 康奈尔大学开设的FPGA项目课程,大部分课程是有源码的,而且和国内使用习惯类似都是Verilog开发,还是很有借鉴意义的。
https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/
2024年春季 开发板:CycloneV DE1-SoC

最终项目是使用 Reiter 模型在 VGA 屏幕上可视化的雪花元胞自动机。
项目采用Reiter雪花扩散模型,基于三个主要参数计算雪花的生成。该计算在DE1-SOC板的HPS上完成,然后使用FPGA将输出绘制到VGA屏幕上。使用HPS进行模型计算简化了用户界面(可更改参数)与计算之间的同步,而使用FPGA绘制雪花则显著提高了整个程序的运行速度。最终结果使我们能够观察雪花在不同环境下的演变过程。
受到 Yan Huck 的元胞自动机项目和模拟(https://itp.uni-frankfurt.de/~gros/StudentProjects/Projects_2020/projekt_yan_huck)的启发。希望扩展这个基于 Web 的模拟,利用 DE1-SoC 开发板的计算能力,创建一个更大、更快、更具交互性的模拟模型。
Huck模型是一个元胞自动机,它基于三个参数模拟雪花的生长。由于雪花具有六边形对称性,该模型用正六边形对平面进行镶嵌。每个六边形被称为一个元胞,每个元胞都有一个状态变量 s,表示该元胞是否处于冻结状态。
该模型利用三个参数α、β和γ来模拟雪花生长的环境。α是扩散系数,可以理解为表示一个细胞如何将水分配到其相邻细胞。β是环境中初始的水蒸气背景浓度。γ是添加到系统中的恒定水蒸气浓度。附加变量u和v分别表示每个细胞中用于扩散和未用于扩散的水蒸气量。u和v之和是细胞中的总水量,即状态变量s。当s大于或等于1时,细胞被冻结。冻结细胞的相邻细胞称为边界细胞,冻结细胞和边界细胞的并集称为可接收细胞的集合。
为了初始化平面,首先将一个种子单元格设置为冻结状态(s=1),然后将其他所有单元格的u值设置为β。雪花将从这个种子单元格开始生长。
初始化之后,每次迭代都包含以下步骤:
可接受:u = 0,v = s;不可接受:u = s,v = 0
该算法使用拉普拉斯近似法,利用 u 值计算扩散,而变量 v 则允许我们追踪环境中添加到感受细胞中的水。
虽然模型采用六边形平面,但认为在 VGA 显示器上使用六边形会牺牲分辨率,因为最初方案中的六边形最宽处为 10 个像素。本项目通过使用偏移列来模拟六边形网格;每隔一列偏移半个单元格。选择 2x2 像素的单元格,因此每隔一列偏移一个单元格。这样,每个单元格仍然有六个相邻单元格,而不会降低生成图像的分辨率。网格示例如下所示。

最初决定如何分配硬件(FPGA)和软件(ARM)之间的工作负载时,认为最好使用FPGA来执行大部分计算,因为在之前的实验中,曾使用FPGA执行类似的数学运算。然后,会在FPGA和ARM之间提供一个接口,将相关数据发送到ARM,以便在VGA屏幕上绘制图形。然而,发现使用FPGA实现算法所需的众多循环比最初预想的要复杂得多。数据存储的复杂性使得最终选择用C语言实现算法并在ARM上运行,然后使用FPGA来加速VGA绘图。
采用迭代设计方法,首先构建 Python 模型,然后构建全 C 语言模型,最后使用 FPGA 通过共享 SRAM 块将图像绘制到 VGA 显示屏上。最终设计的高级示意图如下图所示。

项目始于编写一个 Python 模型。该模型帮助在深入研究 C 和 Verilog 等底层实现之前,对算法有了更深入的理解。 Python 模型使用了下面代码片段中所示的 Cell 类:
class Cell:
def __init__(self,s,coor,is_receptive):
#water used in diffusion (this models the "diffusion view" of the cells, zero-ing out the receptive cells)
self.u = 0
#water not used in diffusion ("non diffusion view" of the cells, zero-ing out the nonreceptive cells)
self.v = 0
self.s = s #total water
self.coor = coor
self.next_u = 0
self.next_v = 0
self.is_receptive = is_receptive
def get_s(self):
return self.s
使用二维数组来存储 Cell 对象,这样可以轻松地对网格进行索引。辅助函数get_neighbors()返回一个数组,其中包含任意 Cell 坐标的所有相邻 Cell 对象。这有助于计算扩散系数。有一个初始化循环和一个迭代函数 one_iter(),用于更新网格中所有 Cell 对象,以完成算法的一次迭代,如第 2.1 节所述。最后,还有一个 update_frozen_cells()函数,它使用 matplotlib 的 FuncAnimation 类来绘制每次迭代的冻结细胞。下面的 GIF 动画展示了 Python 模型的输出结果。

请注意,此方案并未尝试像第 2.2 节中描述的那样偏移网格中的列,因此雪花的可视化效果会倾斜。
使用 Python 模型,通过修改 get_neighbors()函数来探索不同邻居数量下的输出结果。由于项目尚处于初期阶段,不确定如何平铺网格才能实现六边形图案。因此,尝试了 8 邻居方案,因为这样可以直接使用矩形网格,无需平铺。最终,发现 8 邻居方案无法像上文所示的 6 邻居方案那样生成理想的“雪花状”图案,因此继续研究网格设计。8 邻居方案的结果如下图 GIF 所示。

在编写并测试了 Python 仿真程序后,在 HPS 平台上用 C 语言进行了相同的仿真。与 Python 中使用类不同,使用结构体来存储与每个单元格相关的变量。网格被存储为一个二维数组,数组中包含指向 Cell 结构体的指针。
我们的get_num_neighbors()函数考虑了前面提到的带有偏移列的网格设计。它接受三个参数:一个 Cell 结构体指针数组,以及我们要查找其邻居的单元格的 x 和 y 坐标。该函数将邻居添加到传入的数组中,并返回单元格的邻居数量。它首先查找给定单元格的上邻居和下邻居,这仅取决于单元格的 y 坐标是位于网格的第一行还是最后一行。如果单元格位于第一行,则没有上邻居;如果单元格位于最后一行,则没有下邻居。所有其他邻居都由单元格位于偶数列还是奇数列来确定。
一般来说,每个单元格左右两侧各有两个相邻单元格。给定一个坐标为 (x, y) 的单元格,其左侧相邻单元格为 (x-1, y-1) 和 (x-1, y),右侧相邻单元格为 (x+1, y-1) 和 (x+1, y)。类似地,如果一个单元格位于奇数列,则其左侧相邻单元格为 (x-1, y) 和 (x-1, y+1),右侧相邻单元格为 (x+1, y) 和 (x+1, y+1)。通过绘制网格的一部分来寻找单元格相邻单元格的规律,从而获得这些坐标。
这些规则的例外情况是单元格位于网格边界时,因为最左侧的列没有左侧相邻单元格,最右侧的列也没有右侧相邻单元格。网格和单元格坐标如下图所示。请注意,相邻单元格的计算方式取决于网格的设置方式。如果偶数列偏移半个单元格,那么上述奇数列的左右相邻单元格的计算方法将适用于偶数列,偶数列的左右相邻单元格的计算方法将适用于奇数列。

我们的one_iter()函数通过执行 Reiter 模型中概述的计算来更新网格。它确定网格的感受位点,执行扩散并修改单元格值,然后通过检查非感受单元格的邻居来更新边界单元格。如果其邻居被冻结,则该单元格将被更新为感受单元格,使其成为边界单元格。
我们的run_snow()函数会遍历单元格网格,并将它们绘制到 VGA 屏幕上。它首先需要根据单元格是否冻结来确定要绘制的颜色。此外,它还会考虑单元格位于偶数列还是奇数列,因为绘制的坐标会有所不同。单元格坐标到 VGA 像素的映射关系如下所示。
绘图是使用 VGA_box 完成的,VGA_box 是已定义的 VGA 图形例程之一。
在main()函数中,使用 for 循环调用one_iter()和run_snow()函数。之所以选择 for 循环而不是 while 循环,是为了防止雪花在到达网格边界后自身重叠生长。下面展示了一些雪花及其参数。

α = 2.003,β = 0.3,γ = 0.0001

α = 1,β = 0.8,γ = 0.01

α = 1,β = 0.35,γ = 0.001
在编写算法的过程中遇到了一些问题。最初的get_num_neighbors()函数无法获取正确的邻居,也无法获取正确的邻居数量。调试过程中,发现函数处理的情况并非互斥,因此存在重复计数的情况。这促使我们重新设计该函数,最终提出了上述方案。
使用 FPGA 来加速 VGA 显示器的写入。这是通过 GPU 和 SRAM 示例中的 FAST 显示功能实现的,示例链接在此(https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2024/ens57_mll264_kz273/ece5760-snowflakes-final/%E2%80%9Dhttps://people.ece.cornell.edu/land/courses/ece5760/DE1_SOC/HPS_peripherials/Examples_version_18.html%E2%80%9D)。使用 FPGA 和 HPS 之间的共享 SRAM,将绘图数据从 HPS 传输到 FPGA,然后再输出到 VGA 显示器。为此,使用了Dancing Boid 最终项目(https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2024/ens57_mll264_kz273/ece5760-snowflakes-final/%E2%80%9Dhttps://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2023/yw575_mk2592_rc627/yw575_mk2592_rc627/index.html%E2%80%9D)中的VGA_disc()函数,并将 r=0。该函数接收一个坐标值,然后将其写入共享 SRAM 的相应地址。之后,FPGA 读取该数据并将其绘制到 VGA 显示器上。
其余大部分 C 代码保持不变,但进行了一些优化。最初的 C 模拟程序会绘制屏幕上的所有单元格,无论它们是否处于冻结状态,这很浪费时间,因为实际上只需要绘制冻结的单元格。修改了代码以实现这一优化。
在设计过程中,尝试使用FPGA计算算法,并使用HPS进行绘图。然而,很快遇到了同步和逻辑方面的问题。由于最初使用了PIO端口,我们在FPGA和HPS之间的数据传输同步方面遇到了困难。后来,通过使用双端口SRAM解决了这个问题。FPGA和HPS都可以读写SRAM,这样HPS只需设置一个同步信号,FPGA即可将其取消设置以进行确认。
项目使用了两种用户界面:命令行界面和鼠标界面。命令行界面包含以下功能:
以下是我们生成的一些我们最喜欢的雪花图案:



通过鼠标界面,用户可以点击屏幕上所需的中心坐标,使用当前参数开始生成新的雪花。
仿真结果表明,与使用HPS绘制相比,使用FPGA绘制图形可使仿真时间缩短43.74%。仿真速度取决于计算网格中每个值并将其可视化到VGA屏幕上所需的时间,以及冻结的单元格数量。因此,网格的大小也会影响总时间。在测试中,使用了整个屏幕作为网格。发现,如果所有操作都用C语言完成,则耗时763.39 us;而如果使用FPGA绘制,则耗时429.52 us。这些时间是从仿真开始时(此时冻结的单元格较少)开始计算的。
总的来说,成功地实现了雪花生长的可视化。最终项目显著扩展了我们之前在网上找到的模型的功能,允许用户动态更改输入参数,从而模拟组合式雪花形态和图案。此外,动画利用了完整的VGA屏幕,生成320x240的网格,因此可以模拟更大的雪花,而在线版本主要模拟的是小于100x100的雪花。即使在更大的尺寸下,雪花动画速度也比在线版本更快,并且增加了同时计算多个雪花的功能。还加入了添加新种子单元的功能,使用户可以在不同时间生长出新的雪花。这些新增功能使用户能够探索不同参数和不同生长阶段的雪花之间的相互作用。
https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2024/ens57_mll264_kz273/ece5760-snowflakes-final/index.html
https://github.com/eshitasangani/snowflake-cellular-automata
https://youtu.be/ymdbJTT7XSY?si=Dv9bMik7Q2hWr1tU

创建了一个海洋生态系统模拟模型,模拟各种海洋生物在相互互动以及与环境互动时的行为。
该模拟将这些生物建模为类人猿,并着重研究共生关系和捕食者-猎物动态,为观察生态互动提供了一个交互式教育工具。
该系统在DE1-SoC板上实现,该板载FPGA和HPS处理器。使用HPS来实现我们的boid算法并与Python GUI进行交互,使用FPGA进行绘图和音频合成。
想法的缘由和来源
选择这个项目是因为对模拟粒子间的相互作用很感兴趣。我们都对动物很感兴趣,并且对观察动物间的互动充满期待。讨论过几种模拟动物的算法,最终选择了 Boids 算法,因为在 ECE 4760 课程中学习过它,并且很想深入研究一下。
算法基于在ECE 4760课程(https://vanhunteradams.com/Pico/Animal_Movement/Boids-predator.html)中学习的Boids算法。该算法模拟动物的集群行为。其运动可以用以下规则概括:
分离:累加远离保护范围内邻居的反向运动的总和。然后,我们根据该总和更新小队的速度,使其远离保护范围内的邻居:
close_dx = close_dy = 0
loop:
close_dx += boid.x - neighbor.x
close_dy += boid.y - neighbor.y
boid.vx += close_dx * avoidfactor
boid.vy += close_dy * avoidfactor
对齐:计算目标体视觉范围内所有邻近目标体的平均速度,并更新目标体的速度以与之匹配:
xvel_avg = yvel_avg = neighboring_boids = 0
loop:
xvel_avg += neighbor.vx
yvel_avg += neighbor.vy
neighboring_boids += 1
if neighboring_boids > 0:
xvel_avg = xvel_avg/neighboring_boids
yvel_avg = yvel_avg/neighboring_boids
boid.vx += (xvel_avg - boid.vx)*matchingfactor
boid.vy += (yvel_avg - boid.vy)*matchingfactor
凝聚力:计算鸟群视野范围内所有邻近个体的平均位置,并轻轻地向质心方向移动:
xpos_avg = ypos_avg = neighboring_boids = 0
loop:
xpos_avg += otherboid.x
ypos_avg += otherboid.y
neighboring_boids += 1
if neighboring_boids>0:
xpos_avg = xpos_avg/neighboring_boids
ypos_avg = ypos_avg/neighboring_boids
boid.vx += (xpos_avg - boid.x)*centeringfactor
boid.vy += (ypos_avg - boid.y)*centeringfactor
为了模拟生态系统,共生关系通过动物之间的吸引力和排斥力来表示。
Python GUI 通过向 FIFO 队列发送命令与 ARM 通信,以自定义生成的动物种类和数量。ARM 根据上述数学原理计算每个生物体的位置和速度。更新所有生物体后,ARM 和 FPGA 进行握手,FPGA 将生物体绘制在 VGA 显示器上,并发出与种群规模对应的音频。
在FPGA方面,主要目标是实现加速。最初,考虑在FPGA上执行所有计算,但未能找到有效的并行化方法,无法充分利用FPGA的性能,而且乘法器数量也会耗尽。我们考虑过将部分计算任务卸载到FPGA上,但这需要每个boid至少传输四次值(x, y, vx, vy),对于处理700个boid来说,这显然是不切实际的。为了满足帧速率要求,我们在FPGA上加速绘制boid,并将boid的数量从450个增加到700个。因此,所有boid的交互和计算都在ARM上完成。
通过创建一个代表动物及其共生关系的结构,来添加动物之间的吸引力和排斥力。
typedef struct animal {
short color;
fix15 speed;
int rels[9];
} animal;
color 变量表示绘制在 VGA 屏幕上的动物的颜色。speed 定义了动物的最小速度和最大速度范围:[speed, 2*speed]。rels 数组的索引方式如下:
#define NAME 0
#define MUTUAL 1
#define HOST1 2
#define HOST2 3
#define PARASITE 4
#define PREY 5
#define PREDATOR 6
#define COMMENSAL 7
#define FLOCK 8
数组中的每个元素都包含共生关系中另一动物的唯一整数标识符,而 rels[NAME] 则包含该动物自身的 ID。关系的格式为“我的 [rel] 是 [id]”,例如“我的猎物是鱼”。如果动物群居,则 rels[FLOCK] 元素的值为 1。
boid 结构体包含一个指向其动物的指针,以及“time”属性。每当 boid 被捕食者吃掉、感染寄生虫或靠近同种其他 boid 进行繁殖时,“time”属性都会递增。一旦“time”属性达到某个阈值,boid 就会死亡,或者会生成一只幼崽。此外,还有一个名为 is_alive 的属性,用于指示 boid 是否因寄生或捕食行为而存活。
typedef struct boid {
fix15 x;
fix15 y;
fix15 vx;
fix15 vy;
animal *a;
int eat_time;
int is_alive;
int parasite_time;
int baby_time;
} boid;
在 Boids 算法的基础上,分离、对齐和内聚规则会根据动物之间的关系进行更新。如果动物彼此吸引(互惠、集群、寄主或猎物关系),则 Boids 的内聚和对齐规则会更新。如果动物彼此排斥(寄生或捕食关系),则通过累加名为 avoid_dx 和 avoid_dy 的变量来增强 Boids 的分离规则。除互惠、寄主、猎物和共生关系外,均应用常规分离规则,因为我们希望这些关系能够重叠在同一空间内(例如,捕食者追上猎物)。
维护一个对象列表,对象由图形用户界面 (GUI) 中指定的数字初始化。动物对象通过其关系进行初始化,对象则通过指向其所属动物的指针进行初始化。我们遍历该列表,并根据每个对象与其所属动物定义的关系更新每个对象。
HPS 有两个线程:一个用于更新对象,另一个用于从 FIFO 读取数据。从 FIFO 读取数据的线程将在 GUI 部分(如下)进行更详细的描述。
动画线程必须满足时间约束。它会在 while 循环开始和结束时分别获取时间,然后计算经过的时间。如果经过的时间小于帧速率(每帧的微秒数),线程将等待,直到经过的时间等于帧速率。同时,它会在左上角绘制一个绿色圆盘,表示时间已达到预期。如果经过的时间大于帧速率,则会绘制一个红色圆盘。
在FPGA方面,主要目标是加速。最初考虑过在FPGA上完成所有计算,但我们没有很好的方法来并行化系统并充分利用FPGA的性能。我们也意识到乘法器的数量可能会不够用。
接下来的想法是只转移一部分计算,但这需要来回发送至少是我们拥有的 boid 数量(x、y、vx、vy)的 4 倍的值,对于 700 个 boid 来说这样做没有意义。
决定使用FPGA进行绘图。以Quartus 18.1示例页面中基于SRAM的FAST显示GPU为基础来加速绘图。修改了HPS上的磁盘绘图函数,使其使用SRAM指针。
还实现了音频功能。希望音频频率与种群规模相关。创建了一个名为“nomnom”的PIO端口,作为HPS到FPGA的输入。它的值表示存活的boid数量,并在boid生成或死亡时更新。我们使用同一网页上的音频输出总线主控示例实现了DDS,并将nomnom PIO端口用作累加器。我们还在QSYS中实现了音频子系统和总线主控音频。

QSYS 连接
创建了一个图形用户界面 (GUI),使用户能够控制模拟过程。用户可以暂停、播放、加速和减速模拟。用户还可以更改模拟参数,例如每种动物的数量和边界模式。预设了一些我们认为能够展现有趣行为的预设,用户可以选择这些预设并输入他们想要观察的动物总数。

图形用户界面
建立图形用户界面(GUI)和ARM处理器之间的通信着实费了一番功夫。最初的想法是使用串口通信,但未能成功。接下来,考虑直接在ARM处理器上运行GUI(这样就不需要设备间的通信),但我们无法安装必要的Python模块。最终,想到了使用FIFO(先进先出)的方法。
Python 图形用户界面(运行在我们的个人笔记本电脑上)首先使用 paramiko 模块通过 SSH 连接到 DE1-SoC。按下按钮后,它会向 ARM 的命令行发送一条命令。该命令“echo instruction > /home/root/Final/FIFO_Testing/test_fifo”会将指令写入 FIFO 队列。然后,我们的 HPS 从该 FIFO 队列读取数据,处理指令,并相应地更新仿真。
HPS 上的 read_fifo 线程以只读模式打开文件,然后持续尝试从 FIFO 中读取数据。如果 FIFO 中有内容,它会首先去除其中的所有换行符(\n 或 \r)。接下来,它会检查是否存在如下表所述的指令。

图形用户界面操作说明
每次用户更改动物数量时,所有动物的编号都会被发送。用于指示每种动物数量的全局变量会随着每次指令更新。收到完成指令后,我们知道动物数量不再发生变化,因此我们会清空屏幕并更新全局变量 num_boids。最初,我们在每次更改数量后都会更新并清空全局变量,但这会导致模拟频繁重置,从而出现短暂的故障。我们发现,仅在所有动物编号更新完毕后才重置模拟效果要好得多。
通过更新全局变量 play_animation 来暂停和播放模拟。如果该变量为真,则运行更新代码。如果该变量为假,则不执行任何计算或更新,因此球体将重新绘制在相同的位置。
如果检测到仿真速度发生变化,我们会修改全局变量 FRAME_RATE,该变量存储的是每帧所需的微秒数。降低该值可以加快仿真速度,增加该值则可以减慢仿真速度。
边界模式也会通过更改全局变量进行更新。我们有一个全局变量 boundary_mode,其值会被更新为 0(表示正方形)、1(表示墙壁)或 2(表示无边界)。我们还有一个全局变量 wall_width,当用户选择墙壁模式时,该变量的值会被更新。
如果用户选择了预设值,则全局变量 num_boids 会更新为用户输入的数字。然后调用相应的创建世界函数。
模拟中的物体运动流畅,没有闪烁、停顿或抖动。觉得每秒 30 帧的帧率下模拟效果相当不错,因此决定将其作为初始帧率值。
使用HPS进行绘图时,只能绘制大约450个BID,同时才能满足帧速率要求。而使用FPGA进行绘图时,可以绘制大约700个BID。这是一个显著的提升。
https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2024/agh93_bb584_eys29/ece5760website/index.html
https://people.ece.cornell.edu/land/courses/ece5760/FinalProjects/s2024/agh93_bb584_eys29/ece5760website/index.html
https://youtu.be/JGJd_eQJKk0?si=COXrRPqAwDWq_wNV
本文整理了康奈尔大学 2024 年春季 ECE5760 课程基于 CycloneV DE1-SoC 开发板的 2 个 FPGA 实战项目,全部采用 Verilog 开发。
END