Cyclone IV FPGA 开发踩坑记录

上学期,学校开设了一门数字电路课程,使用 FPGA 进行开发。在课程结尾,我们需要分成小组,利用 FPGA 自由设计电路的特性实现复杂的功能,例如制作一款游戏、运行卷积神经网络等等,并且可以按自己喜好加入各种额外的功能。

我们小组实现了一款类似《雷电》的游戏,实际上就是操控飞机发射子弹攻击敌人。实现的额外功能(课程内容除外)包括:

  • 640x480,16 位色深的 VGA Framebuffer,存储在 SRAM 芯片上
    • 相应的,内置了简体中文字库(包含整个 UTF-8 中文范围,但不包括标点符号(懒))
    • 也支持修改 Y 轴偏移量快速滚动屏幕,为了实现飞行效果
    • SRAM 控制器/芯片以二倍频率工作,让 FPGA 上的软 CPU 和 VGA 控制器同时访问,不存在抢占问题
  • 最多 8 架飞机(包括敌方我方),可以完全自定义图片(非调色板,非索引),全屏幕自由移动
  • 最多 56 发子弹(包括敌方我方),可以修改大小颜色,全屏幕移动
  • 使用 WM8731 音频芯片循环播放长达约 5 分钟的 BGM,双声道 16 位深度 8000Hz 采样率
    • 使用了课程网站提供的 WM8731 控制器模块(先前学生所写),但踩了坑,见后文
  • 使用 Marvell 88E1111 网络芯片及相连的 RJ45 接口联网,上传游戏记录,下载积分榜
  • 使用开发板上的 USB 芯片连接键盘实现用户输入
    • 由于我们的键盘连接不稳定(普遍现象),我们将键盘独立一个软 CPU 进行控制,可以分别 Reset,方便调试,避免频繁 Reset 主 CPU

演示视频如下:

在开发时,我们使用的开发板是 DE2-115,对应的 FPGA 芯片是 Cyclone IV EP4CE115F29C7,开发软件是 Quartus Prime 18.1 的免费版。

这篇文章记录了我在实现额外功能时踩过的坑。

学术诚信警告及代码开源

注意:本文与课程覆盖的内容没有交集,即本文不含课程中已经教学过的内容,对学生完成大作业以外的内容无帮助。参考本文及代码请务必列出参考文献,防止被判抄袭或剽窃。

我们将代码开源在 https://github.com/xddxdd/zjui-ece385-final

网络部分

我们在开发板上实现了网络功能,以实现游戏记录的上传和积分榜的产生。这也是我们最看重的额外功能。本大类记录我们在使用以下组件时踩的坑:

Intel Triple Speed Ethernet IP 不工作

DE2-115 开发板上自带两块 Marvell 88E1111 网络芯片,分别对应两个网络接口。我们在与芯片对接时,看到 Quartus 提供了以太网 IP,并且带有丰富的设置选项,就直接使用了。

不得不说 Intel 官方的 IP 功能齐全,支持 GMII/RGMII 等多种接口,自带 FIFO,可以方便地和网络芯片 MDIO 模块通信,还有一大堆我们用不上的功能。但是我们添加 IP 后尝试调用时发现,网卡上没有发出任何数据,TX 灯不闪,RX 灯虽闪但软件没有收到任何数据。就算照着 ftp://ftp.intel.com/pub/fpgaup/pub/Intel_Material/17.0/Tutorials/DE2-115/Using_Triple_Speed_Ethernet.pdf 这篇 Intel 官方教程来也不行。

经过长达三天的 Debug,发现 Intel 的这个 IP 实际上是收费的。由于我们显然没有付费,这个 IP 是在试用模式,只能在开发板连接上电脑并且Quartus 上传界面的 Licensing 窗口打开时工作。其它情况下,它会禁用自己,不会发送任何数据,也不会处理任何接收的数据。

我们一气之下决定直接换 GitHub 上的开源模块,就是第二条的那个 IP。不过这个 IP 虽然开源免费,但也不代表它没有坑,接着看。

开源网络模块缺少 MDIO 部分

Marvell 88E1111 芯片有一个 MDIO 接口,提供了对 32 个寄存器的读写,用来设置链路速度(10/100/1000M),获取连接状态(连线/断线)等等。然而我们在开源模块里翻了一圈,发现并没有这个模块。

Intel 倒是提供了一个免费的独立 MDIO 模块,但是它对应的是 Clause 45 的新版协议,无法与这块使用 Clause 22 老协议的芯片正常通信。

所以我们自己写了一个。https://github.com/xddxdd/zjui-ece385-final/blob/master/comp/lantian_mdio/lantian_mdio.sv

这个模块直接将 MDIO 寄存器连接在 Avalon-MM 内存总线上,可以直接进行内存操作。

开源网络模块能接收不能发送,TX 灯不闪

此时已经可以正常接收到数据包了,说明我们已经成功了一大半。但是对端电脑上用 Wireshark 抓包,什么都没收到。

TX 灯不闪说明 FPGA 传给网卡的数据是坏的,网卡不识别自然也不会发送。经过研究,发现开源网络模块的 IFG Delay 参数错误。这个参数是线缆上两个数据包之间的间隔周期数,一般设置为 12。我们之前把这个参数删了没管。。。

开源网络模块能接收不能发送,但 TX 灯会闪

。。。然后加上参数就变成了这样。

我们读了 3 个小时开源模块的代码然后发现,这个开源模块要求同一个数据包的内容连续发送,中间不能有任何一个周期的等待。千兆以太网模块跑在 125 MHz,每次发送 8 位数据,而我们的软 CPU 及 DMA 模块频率是 50 MHz,每次也发送 8 位,导致以太网模块需要等待数据。这时以太网模块会直接告诉网卡「数据损坏」,FPGA 端网卡把这个信号发了过去,电脑接收端网卡就直接把这个数据包扔了,而操作系统还什么都不知道。

解决方案也很简单:DMA 发送端支持 32 位宽度,那就直接把 DMA 和 FIFO 这头拉到 32 位(另一头还是 8 位),再顺手把 DMA FIFO 的深度也加大点提高稳定性。这样 DMA 的带宽从 400 Mbps 增大到了 1600 Mbps,以太网模块就不用等数据了。

音频部分

由于课程网站上提供了前几届学生的 WM8731 驱动模块,我们本以为添加音频功能很简单。

我们错了。

音频芯片只输出噪音

我在先前一个学期的操作系统课中(在这节 FPGA 课之前),给自己组的操作系统加上了 Sound Blaster 16 声卡支持。一开始,我把界面做成了和 SB16 一样,提供一段内存,将音频数据存在这里,读取过半了就发个中断。

然后音频芯片只输出噪音。

我们觉得这个操作太复杂了,就直接用 DMA+FIFO(类似网络部分)来做。

还是只有噪音。

我们开始读那个驱动。但是,那个驱动是 VHDL 语言写的,和我们课上学的 SystemVerilog 完全不一样。而且驱动内的模块设计莫名其妙,有个信号一路找进去然后发现没用上你敢信?

然后我们发现,我们以为这个驱动会给出一个 8000 Hz(等于采样率)的信号,告诉我们发下一个信号,然而这个信号完全不是这个意思。

8000 Hz 的信号源我们得自己搞。然后我们就看到了 Intel 提供的 Interval Timer IP(定时器)。

为了简化设计(和偷懒),我们直接给主 CPU 加了一个 8000 Hz 的中断,然后直接在中断处理部分把音频信号通过 PIO 输出,简单粗暴。

我们的成品游戏对 CPU 的消耗并不大,可以支撑这样的高频中断。

然后 CPU 只处理了一个中断就不继续了。

我们读了一遍 Interval Timer 的 Datasheet,发现每次中断后要写一个寄存器它才会继续。

这样,在一整天的 Debug 后,我们的板子终于传出了动听的音乐。

视频部分

视频部分是整个项目的难点。我们一开始使用纯 Framebuffer 方法,没有使用 Sprite,而这个频率仅 50 MHz 的,不带缓存、指令预测、多级流水线的免费版 CPU 软核无法承担逐像素刷新的工作量。因此问题就出现了:

FPS 小于 1

没错,性能就是这么差。此时由于 SRAM 限制(10 ns 一次操作,折合 100 MHz,由于 SRAM 两倍频率所以总线只能跑 50 MHz),我们不能再超频了。

我们也不会去试付费完整版的 CPU IP,网卡模块的惨剧给我们留下了深刻的印象。

我们觉得编译器优化以下可能有戏,就加了 -O2。FPS 瞬间暴涨到 7-8 左右。

FPS 小于 30

但是这个数肯定不够这个游戏用,至少要到 30 FPS 左右,最好达到 60。因此我们只能做了一套 Sprite(精灵块/图块)系统,通过修改坐标快速移动每个物体。这样,帧率总算达到了 60,可以正常玩耍了。

另外,我们又加了一个 DMA 控制器。DMA 控制器专门用来做内存复制等操作,效率比免费版 CPU 高的不是一点半点。

但是还没完:

64 图块造成逻辑超时

飞机和子弹是有透明度的。在处理每个像素时,需要用一个超长的组合逻辑(Combinational Logic)来逐层判断。这就导致这块区域完全无法在 40 ns(对应 25 MHz,640x480 VGA 的像素频率)内跑完,VGA 上输出的结果可能不稳定。

我们最后采用了树形结构。由于 Cyclone IV 系列 FPGA 的 LUT 是 2 输入的,我们这样操作:

  1. 将 64 个物体(包括飞机和子弹)分成 32 组,每组进行透明度判断,生成 32 组输出;
  2. 然后将 32 组输出分成 16 组再来一次;
  3. 然后将 16 组输出分成 8 组再来一次;
  4. 然后将 8 组输出分成 4 组再来一次;
  5. 然后将 4 组输出分成 2 组再来一次;
  6. 将 2 个输出判断后产生最终输出。

这样就将这条路径的长度从 64 次判断降到了 6 次。

如何将数据存入 SDRAM

最后,就要考虑如何将飞机、背景等图片,音频数据等存入 SDRAM 了。

课程中讲的方法是使用 DE2-115 Control Panel,实质上是加载了一个自定义的程序到 FPGA 上,然后写入 SDRAM。但是在先前的作业中就会出现,烧录回自己的程序后 SDRAM 上的数据会随机出错。

我们采用的方法就是直接在上传程序同时将 SDRAM 进行写入。在 Eclipse 的 BSP Editor 中,新开一个 ELF 文件的 Section,可以取以英文句点开头的任意名字,例如 .resources,但不能与 Platform Designer 中的模块重名。然后,指定这个 Section 存储在 SDRAM 中。

然后以类似如下的格式创建数组:

unsigned char arr[1000000] __attribute__((section(".resources"))) = { 0, 1, ... };

这样数组就会被上传并存储在 SDRAM 里。并且由于不需要二次烧写 FPGA 的程序,不存在数据损坏问题。

键盘部分

虽然这部分实际上已经被课程内容覆盖,但是按照课程内容直接操作的话,USB 键盘识别会极其不稳定。我们当时经常需要 Reset 十几次才能识别到键盘。不过一旦识别成功,键盘就可以一直用下去。

如何避免不停 Reset 影响调试

我们首先想到的就是将主程序和 USB 键盘部分的 Reset 分开,这样两侧调试就互不影响了。

具体来说,我们给键盘部分单独设置了一颗软 CPU,只用来与键盘通信。这个 CPU 及 USB 芯片使用独立的一路按钮进行 Reset,避免影响主程序调试。两个 CPU 使用双接口的芯片内存(Dual-port On-Chip Memory)进行通信,没有加锁机制(一端只读,一端只写,没有必要)。

如何避免不停 Reset 影响手部健康

我们给 USB 部分程序加上了超时机制,也就是如果一段时间没能连上键盘,它就会自己 Reset 自己。

实际用下来,自动重试机制的确有用,但作用不大。但由于我们没能解决 USB 部分的问题(教授都解决不了),只能用这样的 Workaround 聊胜于无。