FPGA-入门指南-全-
FPGA 入门指南(全)
原文:
zh.annas-archive.org/md5/78aa1dd6d905f6c3a2e91dc131953757译者:飞龙
第一章:介绍

在大学毕业后的第一份工作中,作为一名初级电气工程师,我曾参与一个旧设计的工作,那个设计上有一个定时器电路。利用一个简单的电阻和电容,这个电路会等待 50 毫秒(ms)后触发一个动作。我们需要将这个 50 毫秒定时器改为 60 毫秒,但这个小小的改变却需要巨大的努力:我们必须将数百块电路板上的电容和电阻拆除并更换为新的。
幸运的是,我们有一支现场可编程门阵列(FPGA)设计师团队,他们挺身而出,提供了帮助。在他们的帮助下,我们能够在 FPGA 内部实现相同的功能。然后,在几分钟之内,我们就能修改代码,将定时器设置为任何我们想要的值,而无需触碰烙铁。这种更快的进展速度让我兴奋不已,我很快就迷上了 FPGA。
最终,我转向了全职从事 FPGA 的工作,并且大约在那个时候,我开始在 Stack Overflow 上阅读和回答与 FPGA 相关的问题。通常这些问题来自 FPGA 初学者,他们对基本概念感到困惑。我发现同样类型的问题反复出现,并意识到没有一个地方可以让人们以简单、易懂的方式学习 FPGA。当然,网上有很多关于 Verilog 和 VHDL 的参考资料,这两种是最流行的 FPGA 编程语言,但关于这些语言究竟在做什么的资料相对较少。当你编写某一行代码时,FPGA 内部到底创建了哪些组件?各个部分是如何连接的?并行操作与串行操作有何区别?
我不再重复回答同样的问题,而是创建了自己的网站,https://
这本书适合谁?
我尽力让本书尽可能容易理解,以便广泛的读者都能阅读和理解这些内容。本书的目标读者是那些对数字可编程逻辑的工作原理以及 FPGA 如何用于解决各种问题感兴趣的人。也许你是一个大学生,在课程中接触到 FPGA,产生了兴趣但又感到困惑,或者是电子行业的人,在工作中接触过 FPGA。也许你是一个爱动手的创客或硬件黑客,或者是一个对在比平时更低层次上进行编程感兴趣的软件开发者。本书对于所有这些群体来说都非常易于接近。
我假设你至少接触过一种传统的编程语言,比如 Python、C 或 JavaScript。如果你了解函数、条件语句(if…else)、循环和其他基本编程技巧,这将会对你有帮助。不过,你不需要具备 Verilog 或 VHDL 的任何先验经验;本书将介绍这些语言的基础知识。
FPGA 位于硬件和软件的交集处,因此对电子学有所兴趣会有所帮助。我们有时会讨论一些如电压和电流等与 FPGA 内部相关的概念。在这一点上,如果你对这些术语有所基本了解会更有帮助,但并不要求必须掌握,才能从这些部分中获得价值。
本书的内容不包括哪些
本书并不旨在教授你 Verilog 或 VHDL 的每个方面。正如我之前所说,如果你的目标是深入了解这些语言,网上有很多资源可以参考。相反,我的目标是让你理解 FPGA 的工作原理,这样你就能明白 Verilog 或 VHDL 在做什么,从而能做出更智能的设计选择。话虽如此,我们将在本书中查看大量代码。所有的代码都会得到充分的解释,因此你不需要有这些编程语言的先前经验就能跟得上。随着阅读的深入,你将获得扎实的 Verilog 和 VHDL 知识基础,并且能够通过独立学习提升这些知识。
本书包括一些项目,你可以在实际硬件上使用 Lattice Semiconductor 的 iCE40 系列 FPGA 来完成。我专注于这些相对便宜、简单的 FPGA,以便使本书的实践部分尽可能易于接触。更昂贵的 FPGA 拥有许多额外的功能;它们非常酷,但对于初学者来说可能会有些压倒性。iCE40 FPGA 仍然非常强大,但可用的高端功能较少。因此,本书不会以实践方式探讨诸如 SerDes 和硬核处理器等复杂特性,也不会深入讨论使用这些特性所需的更复杂的 FPGA 工具。不过,我们会从高层次讨论一些这些特性,这样你就能在选择升级到更高级的 FPGA 时,掌握必要的背景知识来使用它们。
书中内容有哪些?
本书结合了高层次的讨论、详细的代码示例和实际项目。每个代码示例都同时展示了 Verilog 和 VHDL,因此无论你选择哪种语言进行 FPGA 开发,都能跟得上。书的末尾还有一个详尽的术语表供参考。以下是每章内容的概述:
第一章: 认识 FPGA 介绍了 FPGA,并讨论了它们的优缺点。作为一名工程师,知道在什么场景下使用哪种工具非常重要。理解何时使用 FPGA——以及何时不使用——是至关重要的。
第二章: 设置你的硬件和工具 帮助你设置 Lattice iCE40 系列 FPGA。你将下载并安装 FPGA 工具,并学习如何运行这些工具来编程你的 FPGA。
第三章: 布尔代数与查找表 探讨了 FPGA 的两个最基础组件之一:查找表(LUT)。你将学习 LUT 如何执行布尔代数,并代替专用逻辑门。
第四章: 使用触发器存储状态 介绍了第二个基础 FPGA 组件:触发器。你将看到触发器如何在 FPGA 中存储状态,赋予设备记忆之前发生的事件。
第五章: 使用仿真测试你的代码 讨论了如何编写测试平台来模拟你的 FPGA 设计,并确保它们正常工作。在真实的物理 FPGA 中很难看到内部的运行情况,但仿真可以让你检查代码的行为,发现漏洞,并理解一些奇怪的现象。
第六章: 常见 FPGA 模块 展示了如何创建大多数 FPGA 设计中常见的一些基础构建块,包括多路复用器、解复用器、移位寄存器以及先进先出(FIFO)和其他存储结构。你将学习它们如何工作,以及如何将它们组合起来解决复杂问题。
第七章: 综合、布局与布线以及时钟域跨越 扩展了 FPGA 构建过程的内容,详细介绍了综合以及布局与布线阶段。你将学习时序错误及其避免方法,并了解如何安全地跨越 FPGA 设计中的时钟域。
第八章: 状态机 介绍了状态机,这是一个常见的模型,用于跟踪 FPGA 中事件序列的逻辑流程。你将使用状态机来实现一个互动记忆游戏。
第九章: 有用的 FPGA 原语 讨论了除了 LUT 和触发器之外的其他重要 FPGA 组件,包括块 RAM、DSP 模块和相位锁定环(PLL)。你将学习如何利用这些组件的不同策略,并看到它们如何解决常见问题。
第十章: 数字与数学 概述了在 FPGA 中处理数字和实施数学运算的简单规则。你将学习有符号与无符号数字、定点与浮点运算的区别,以及更多内容。
第十一章: 使用 I/O 和 SerDes 进行数据输入输出 探讨 FPGA 的输入/输出(I/O)能力。你将了解不同接口类型的优缺点,并接触到 SerDes,一种用于高速数据传输的强大 FPGA 功能。
附录 A: FPGA 开发板 推荐一些可以用于本书项目的 FPGA 开发板。
附录 B: FPGA 工程职业建议 概述了寻找 FPGA 相关工作的策略,适合那些希望专业从事 FPGA 设计的人。我会提出如何构建一份优秀的简历、准备面试以及谈判获得最佳工作机会的建议。
你需要的工具
虽然不是严格要求,但我建议拥有一个配备 Lattice iCE40 FPGA 的开发板,这样你就可以完成本书中的动手项目。没有什么比学习一个概念然后能够在真实硬件上实现它更令人满足的了。第二章详细讨论了选择开发板时需要注意的事项以及完成本书项目所需的具体内容。简而言之,开发板应具备 USB 连接和像 LED、按键开关、七段显示器等外设。附录 A 描述了一些适用的开发板。
处理 iCE40 FPGA 的软件工具在 Windows 上运行效果最佳。如果你没有 Windows 计算机,我建议在 Windows 虚拟机中运行这些工具。我们将在第二章中讨论如何安装这些工具。
在线资源
本书中介绍的代码可以通过 GitHub 仓库在线获取。你可以在https://
第二章:1 认识 FPGA

FPGA,全称现场可编程门阵列,是一种高度强大的集成电路,是一种将电子电路集成在一个封装中的技术。名称中的现场可编程部分意味着 FPGA 可以在现场重新编程(即无需返回给制造商)。门阵列部分表明 FPGA 是由一个二维网格组成,网格内包含大量的门,这些门是数字逻辑的基本单元,我们将在第三章中深入讨论。
这个名称实际上有些过时。事实上,有些 FPGA 并不是现场可编程的,而且大多数 FPGA 也不再仅仅是简单的门阵列。实际上,它们比这复杂得多。尽管有这些例外,FPGA 的名称还是沿用了多年,而且它突出了 FPGA 的一个独特特性:它们的惊人灵活性。FPGA 的用途仅受设计师想象力的限制。其他数字可编程设备,如微控制器,通常是根据特定功能设计的;你只能做那些已经内建的功能。相比之下,FPGA 的门阵列(或更现代的等效物)就像一块白纸,你可以编程、重新编程、再编程,使其做几乎任何你想做的事情,且限制较少。然而,这种自由也不是没有代价的,FPGA 开发需要一套独特的技能。
学习如何使用 FPGA 需要一种与传统计算机编程不同的思维方式。例如,传统的软件工程(如 C 语言编程)是串行的:先发生这个,然后发生那个,最后发生那个。这是因为 C 语言是编译后在单一处理器或 CPU 上运行的,而该 CPU 是一个串行机器,它一次处理一条指令。
FPGA 与传统的计算机处理器不同,它们是并行工作的:所有的操作同时进行。理解串行编程与并行编程的区别,对于使用 FPGA 至关重要。当你能够用并行方法来解决问题时,你的整体问题解决能力将得到提升。这些技能也会应用到其他非 FPGA 的场景中;你会开始以不同的方式看待问题,而不仅仅是以串行的思维去思考。学习如何用并行思维而非串行思维来解决问题,是成为一名 FPGA 工程师的关键技能,而且你将在本书中不断地培养这一技能。
FPGA 设计非常有趣。当你使用 Verilog 或 VHDL 创建 FPGA 设计时(关于这些语言将在本章后续部分介绍),你是在最低层次编写代码。你实际上是在创建电气组件之间、设备的输入/输出引脚之间的物理连接,真正的电线。这使你能够解决几乎所有数字问题:你拥有完全的控制权。这种编程方式远比使用具有处理器的微控制器要低级。例如,学习 FPGA 是熟悉硬件编程技术的一个极好方法,同时也有助于更好地理解数字逻辑在其他应用中的工作原理。一旦你开始使用 FPGA,你会对即使是最简单的集成电路的复杂性产生新的敬意。
本章通过提供一些背景信息,为你深入了解 FPGA 打下基础。我们将简要回顾 FPGA 的历史,从 1980 年代的初次诞生到今天,并探讨一些常见的应用。我们还将比较 FPGA 与其他常见数字组件的异同,例如微控制器和应用特定集成电路(ASIC)。最后,我们将讨论 Verilog 和 VHDL 这两种用于 FPGA 编程的主流语言之间的差异。
FPGA 的简短历史
第一个 FPGA 是由 Xilinx 在 1985 年创建的 XC2064。它非常原始,只有 800 个逻辑门,与今天 FPGA 所能执行的数百万个逻辑门操作相比微不足道。它的价格也相对较贵,售价为 55 美元,按通货膨胀调整后今天大约为 145 美元。尽管如此,XC2064 启动了整个行业,并且(与 Altera 一起)Xilinx 在过去 30 多年里一直是 FPGA 市场的主导公司之一。
像 XC2064 这样的早期 FPGA 只能执行非常简单的任务:布尔运算,例如对两个输入引脚进行逻辑“或”运算并将结果输出到一个输出引脚(你将在第三章中学习到更多关于布尔运算和逻辑门的知识)。在 1980 年代,这类问题需要专门的电路,由“或”门组成。如果你还需要对两个不同的引脚执行布尔“与”运算,你可能还需要增加另一个电路,将这些专用组件填充到电路板上。随着 FPGA 的出现,一个设备就可以替代许多离散的逻辑门组件,降低成本、节省电路板上的空间,并且随着项目需求的变化,设计可以重新编程。
从这些简单的起步开始,FPGA 的能力有了显著提升。多年来,这些设备已设计成拥有更多的硬知识产权(IP),即 FPGA 内部专门用于执行特定任务的组件(与可执行多任务的软组件不同)。例如,现代 FPGA 中的硬 IP 模块使得它们能够直接与 USB 设备、DDR 内存及其他外部组件进行接口连接。其中一些功能(如 USB-C 接口)没有某些专用硬 IP 是无法实现的。公司甚至将专用处理器(称为硬处理器)放入 FPGA 中,以便在 FPGA 内部运行普通的 C 代码。
随着设备的不断发展,FPGA 市场经历了许多并购。2020 年,芯片制造公司 AMD 以 350 亿美元收购了 Xilinx。这次收购可能是对其主要竞争对手 Intel 在 2015 年以 167 亿美元收购 Altera 的回应。有趣的是,两个主要以 CPU 为核心的公司决定收购 FPGA 公司,这也引发了很多关于其原因的猜测。一般认为,随着 CPU 的成熟,将部分芯片用于类似 FPGA 的可重编程硬件似乎是一个值得追求的想法。
除了 Xilinx 和 Altera(从现在开始我会分别使用它们的母公司名称,AMD 和 Intel),其他公司在 FPGA 市场中也找到了自己的定位。例如,Lattice Semiconductor 凭借主要生产较小且价格较低的 FPGA 取得了不错的成绩。Lattice 乐于在这个市场的低端独立发展,同时让 AMD 和 Intel 在高端市场中竞争。如今,开源社区已经接纳了 Lattice FPGA,并通过逆向工程使其能够进行低级别的黑客攻击。FPGA 领域的另一家中型公司 Actel,于 2010 年被 Microsemi 以 4.3 亿美元收购,而 Microsemi 本身在 2018 年被 Microchip Technology 收购。
流行的 FPGA 应用
在现代、高性能且灵活的形式下,FPGA 被广泛应用于许多有趣的领域。例如,它们是电信行业的关键组件,常常出现在手机信号塔中。它们负责路由互联网流量,将互联网带入你的智能手机,让你在通勤的公交车上观看 YouTube 视频。
FPGA 在金融行业中也被广泛应用于高频交易,公司利用算法自动进行快速的股票买卖。交易员发现,如果你能够比竞争对手稍微快一点地执行股票购买或销售,就能获得财务上的优势。执行速度至关重要;哪怕是微小的延迟,都可能让公司损失数百万美元。FPGA 非常适合这项任务,因为它们的速度非常快,并且能够根据新的交易算法进行重新编程。这是一个毫秒决定胜负的行业,而 FPGA 能够提供优势。
FPGA 在国防工业中也有应用,比如雷达数字信号处理。FPGA 可以使用数学滤波器处理接收到的雷达反射信号,从而发现几百英里外的小物体。它们还被用于处理和操控红外(IR)摄像头的图像,这些摄像头能够看到热量而非可见光,使得军事人员即使在完全黑暗的环境中也能看到人类。这些操作通常需要大量的数学运算,需要并行进行许多乘法和加法运算,而 FPGA 在这方面表现得尤为出色。
FPGA 在航天工业中也找到了自己的市场:它们可以通过编程冗余措施来应对辐射轰击的影响,因为辐射可能导致数字电路故障。在地球上,大气层保护电子设备(以及人类)免受大量的太阳辐射,但外太空没有这层“温暖的被子”,因此卫星上的电子设备面临着更为严苛的环境。
最后,FPGA 也受到了人工智能(AI)社区的关注。它们可以用于加速神经网络,这是一个大规模并行计算问题,因此它们正在帮助人类解决一些传统编程方法无法解决的问题:图像分类、语音识别与翻译、机器人控制、游戏策略等。
这篇关于 FPGA 应用的概览远远不够全面。总体而言,FPGA 是解决任何需要高带宽、低延迟或高处理能力的数字电子问题的良好选择。
常见数字逻辑组件的比较
尽管 FPGA 自早期发展以来已经取得了很大的进展,并且广泛应用于多个领域,但与微控制器和 ASIC 等其他数字逻辑组件相比,FPGA 仍然是一项相对小众的技术。在本节中,我们将对这三种技术进行比较。你将看到为什么 FPGA 对于某些问题是一个好的解决方案,但对于其他问题则不适用,并且它们面临来自其他设备,尤其是微控制器的激烈竞争。
FPGA 与微控制器
微控制器无处不在。如果你不是嵌入式软件工程师,你可能没有意识到有多少玩具、工具、小设备和仪器都由小型且廉价的微控制器控制:从电视遥控器到咖啡机,再到会说话的玩具。如果你是电子爱好者,你可能熟悉Arduino,它由 Atmel(现在的 Microchip Technology)出品的小型微控制器驱动(Microchip 也是以前的 Actel 公司)。全球已有数百万个 Arduino 售出给爱好者。它们便宜、有趣,并且相对容易操作。
那么,为什么微控制器到处可见,而 FPGA 却没有呢?为什么你的咖啡机或艾尔摩娃娃没有 FPGA 控制呢?主要原因是成本。消费电子行业是使用最多微控制器的行业,对成本的敏感度非常高。像你我这样的消费者希望购买尽可能便宜的产品,而制造这些产品的公司会尽可能压缩每一分钱,以达成这一目标。
微控制器种类繁多,每种微控制器都针对特定用途设计。这有助于公司降低成本。例如,如果你的产品需要一个模拟到数字转换器(ADC)、两个 USB 接口以及至少 30 个通用输入输出(GPIO)引脚,就有一款微控制器恰好满足这些规格。如果你意识到只需要一个 USB 接口呢?也许会有另一款微控制器符合这些规格。正因为种类繁多,企业不需要为多余的功能付费。公司可以找到一款满足最低需求的微控制器,并在这个过程中节省资金。
FPGA 则更加通用。通过单一的 FPGA,你可能会创建五个 ADC 接口而没有 USB 接口,或者三个 USB 接口而没有 ADC 接口。你几乎拥有一张空白的画布。可是,正如你将学到的,FPGA 需要许多内部线路(称为路由)来支持所有这些不同的可能性,这些路由增加了成本和复杂性。在许多情况下,你最终会为不需要的额外功能和灵活性付出更多的费用。
成本的另一个因素是数量。如果你购买 1000 万个微控制器,这在消费电子领域并不算不现实,那么每颗芯片的价格就会比购买 10 万个便宜。与此同时,FPGA 通常是以较小的数量生产和销售的,因此每个单元的价格较高。这有点像“先有鸡还是先有蛋”的问题,FPGA 本可以因为数量增多而便宜,但要想数量增多,价格必须降低。如果成本和微控制器相同,FPGA 的使用量会更多吗?我认为可能会更多,但 FPGA 的使用也更复杂,这也对它们造成了一定的制约。
由于微控制器是为特定目的设计的,因此它们非常容易设置。你可以在几个小时内就让微控制器运行基本的设计。相比之下,你需要在 FPGA 内编程所有内容,这非常耗时。虽然有一些硬件 IP 块可以帮助你入门,但设备的大部分是可编程逻辑——我们之前提到的那块空白画布——你需要自己设计。用像 Verilog 或 VHDL 这样的语言编写代码也比用 C 语言要花费更多时间,而 C 语言通常用于编程微控制器。使用 C 语言时,你在更高的层次上编写代码,因此可以用一行代码完成更多操作。而在 Verilog 和 VHDL 中,你则是在更低的层次上编写代码:你的代码实际上是在创建单个逻辑门和导线。你可以把低级编程想象成用单个 LEGO 积木来拼装,而高级编程则像是在使用已经构建好的 LEGO 套件。这增加了复杂性,从而增加了时间,也提高了成本。工程师们通常都希望找到最简单的解决方案,而大多数情况下,微控制器比 FPGA 更简单。
另一个需要考虑的因素是设备的功耗。许多电子设备依赖电池运行,因此至关重要的是通过尽可能降低设备的功耗来最大化电池寿命。例如,一节 AAA 电池就能为蓝牙鼠标提供数月的使用时间,因为微控制器是为特定用途设计的,可以优化为消耗极少的功率。相比之下,FPGA 拥有大量的布线资源,在功耗方面无法与微控制器竞争。这并不是说不能在电池供电的应用中使用 FPGA,但在同等条件下,微控制器每次都会在这一方面胜出。
总结来说,微控制器在成本、易用性和功耗方面几乎总是占据优势。那么,为什么还有人会选择 FPGA 而不是微控制器呢?还有其他因素需要考虑,比如速度和灵活性,在这些方面,FPGA 却能扭转局面。
当我说速度时,我指的是两件事:带宽和计算能力。带宽是数据通过路径传输的速率。FPGA 的带宽可以非常大,远超过任何微控制器能够达到的水平。它们可以毫不费力地处理每秒数百吉比特的数据。例如,在驱动多个 4K 显示器时,FPGA 的高带宽可以发挥巨大作用。FPGA 经常用于需要大量带宽以跟上数据流的视频编辑硬件。它们的高带宽使得它们能够以非常快的速度从各种外部接口(USB-C、以太网、模拟到数字转换器、内存等)传输大量数据。
至于计算速度,FPGA 每秒能够进行的数学计算远超任何微控制器的能力。微控制器通常只有一个处理器,所有计算都通过同一个处理器进行,因此每秒能够执行的计算数量有限。而 FPGA 则能够并行运行多个计算。例如,你可以同时运行数百次乘法运算,这在微控制器中是根本不可能实现的。这在处理大规模数学滤波数据时尤其有用,因为这些滤波过程通常涉及许多乘法和加法运算,且速度非常快。
FPGA 的另一个主要优点是其灵活性。我之前说过,微控制器有各种各样的类型,但当然,这只是一个小小的夸张。如果你的设计有一些特别的需求——比如需要 16 个 ADC 接口——那么世界上可能没有任何微控制器能够满足这些需求。FPGA 的限制要小得多。正如我提到的,FPGA 就像一块空白的画布,可以编程来做几乎任何事情,这为你提供了极大的灵活性来解决各种数字逻辑问题。
当你面临一个工程问题时,你需要选择最合适的工具来解决它。通常情况下,微控制器非常适用,但有时由于速度或灵活性问题,它可能无法胜任。在这种情况下,FPGA 是一个很好的选择。然而,还有一种值得考虑的设备:ASIC。
FPGAs 与 ASICs 的对比
ASIC 是一种为特定用途设计的集成电路。与可以适应多种用途的 FPGA 不同,ASIC 是专门为某一项任务而设计的,能够在这项任务上做到极致。你可能会认为拥有 FPGA 的灵活性总是更好,但实际上还是有一些权衡需要考虑。我们已经从成本、易用性、功耗、速度和灵活性等方面对比了 FPGA 和微控制器,现在让我们也从这些方面来比较 FPGA 和 ASIC。
由于 ASIC 在少量生产时的非经常性工程(NRE)成本非常高,因此其制造成本非常昂贵:你需要向半导体代工厂(或称fab)支付大量的前期费用,才能获得第一颗 ASIC 芯片。通常,ASIC 设计的 NRE 成本可能高达数百万美元。是否选择设计 ASIC 很大程度上取决于你需要多少芯片。如果你只生产少量的产品,即使是几万个,也很难收回 ASIC 的前期成本。然而,如果你需要数百万颗芯片,那么 ASIC 就会变得具有吸引力,因为第一颗芯片之外的每颗芯片非常便宜(通常不到 1 美元)。与此相比,FPGA 的单颗芯片往往超过 10 美元,你就会发现 FPGA 在大批量生产中经济上并不划算。总的来说,在较小数量的情况下,FPGA 通常优于 ASIC,但在较大数量时,FPGA 无法与 ASIC 竞争。
FPGA 在一个领域总是优于 ASIC,那就是易用性。设计一个 ASIC 的过程非常复杂。而且,在你去芯片制造厂(fab)制造芯片之前,必须确保你的设计没有错误,否则你就浪费了 NRE 成本。另一方面,大多数 FPGA 可以在现场修复(因此称为现场可编程),所以即使在将产品交付给客户后发现了错误,你仍然可以更新代码并解决问题。这对于 ASIC 来说则根本不可能做到。因此,在制造 ASIC 之前,你必须花费大量的工程时间和精力,验证你的 ASIC 设计尽可能没有漏洞。实际上,验证工程就是做这项工作的一个学科,我们将在第五章中更详细地探讨这一点。
ASIC 的一个主要优势是它可以针对低功耗进行优化。ASIC 被精细调校以适应特定的应用需求;它们只包含所需的部分,没有多余的功能。与此同时,回想一下,FPGA 具有大量的线路和互联,这为其提供了灵活性,但也意味着它使用更多的电力。ASIC 相对于 FPGA 的另一个优势是,它们可以使用优化低功耗的制造技术,甚至在晶体管级别上进行优化。举个现实中的例子,当比特币刚出现时,人们使用家用电脑(CPU)进行挖矿。这种方式每挖出一个比特币就会消耗大量电力。最终,人们意识到 FPGA 可以被编程用来挖掘比特币,比 CPU 消耗的电力更少。由于电力昂贵,使用 FPGA 挖掘比特币变得更具利润。再往后发展,人们意识到 ASIC 可以比 FPGA 更快速、更节能地挖掘比特币。制造一款专用于比特币挖矿的 ASIC 变得物有所值,因为它在降低功耗上的节省非常显著。如今,比特币几乎完全依赖 ASIC 进行挖矿。
在速度方面,FPGAs 和 ASICs 都具有大带宽,能够传输大量数据。它们在数学运算方面也非常强大,尤其是在乘法和加法运算上,且都可以并行执行这些操作。ASICs 在这一方面稍占优势:因为它们是专门为某一特定用途设计的,因此通常比 FPGAs 更快。
另一方面,FPGAs 提供了比 ASICs 更大的灵活性。设计中所具备的灵活性非常宝贵,特别是当你在做一个不完全明确的项目时。与固定的 ASICs 不同,FPGAs 可以反复重新编程,添加或移除功能和特性。此外,ASICs 的设计、制造和验证过程需要很长时间,而 FPGA 则可以立刻开始使用,因此进展速度通常更快。
一般来说,当生产量非常高时,ASICs 在成本上优于 FPGAs,但当生产量较低时则不然。ASICs 在功耗上优于 FPGAs,并且在速度上稍有优势,但在灵活性和易用性上则不如 FPGAs。然而,实际上,ASICs 和 FPGAs 经常是一起使用的。当一家公司想设计一个 ASIC 时,通常会先用 FPGA 设计一个原型,然后再生产 ASIC。这种方法使工程师可以更早地接触硬件,并在花费数百万美元定制芯片之前对产品充满信心。工程师可以通过 FPGA 原型在 Verilog 或 VHDL 代码中解决问题,并在成本较低、实现更简单时进行修正。此代码并非是一次性丢弃的代码,因为同样的 Verilog 或 VHDL 可以用来创建 ASIC。
FPGAs 与微控制器与 ASICs 的比较
这一部分信息有点多,所以下面我们简要总结一下关于 FPGAs、微控制器和 ASICs 的讨论。表 1-1 提供了每种设备在不同参数上的对比概况。虽然总有例外情况,但这张表格提供了一个很好的概括。
表 1-1: 比较 FPGA 与微控制器与 ASIC 的优劣
| FPGA | 微控制器 | ASIC | |
|---|---|---|---|
| 成本(小批量生产) | 适中 | 便宜 | 昂贵 |
| 成本(大批量生产) | 适中 | 便宜 | 便宜 |
| 速度 | 快速 | 适中 | 快速+ |
| 功耗 | 适中 | 低 | 低 |
| 灵活性 | 高 | 低 | 无 |
| 易用性 | 中等 | 容易 | 困难 |
成本通常是为什么微控制器或 ASIC 在大批量应用中被选择而不是 FPGA 的主要因素。它们更便宜,这在对成本高度敏感的行业中至关重要。当性能是最重要的考虑因素,并且高昂的初期成本和复杂性是可以接受的时,ASIC 通常是首选。FPGA 的最佳应用领域是那些低量但在带宽或计算上需要高速的应用,或者需要非常灵活和独特的设计(例如需要 16 个 ADC 接口的设计)。
最终,FPGA、微控制器和 ASIC 是工程师工具箱中的三种工具。在审视你特定问题的需求时,你需要决定这些工具中哪个提供了最佳的解决方案。你不会用锤子来拧螺丝;了解在何时使用哪种工具对于成为一名优秀工程师至关重要。审视多种可能性并选择技术解决方案的过程在工程学中通常被称为技术研究。
我喜欢 FPGA,但当我面对技术问题时,通常正确的解决方案是微控制器:它们易于使用且价格低廉。然而,微控制器并不总是正确的解决方案。有时候你需要更多的速度,或者问题是微控制器本身设计不适用的。选择合适的工具来解决你的问题将使解决问题的过程更加愉快。正如你希望看到的,使用 FPGA 确实是非常愉快的!这就像玩乐高或者在 Minecraft 中建造房子。使用简单的、低级的构建模块,你可以创造出令人惊叹的复杂作品。
Verilog 和 VHDL
正如我之前提到的,FPGA(和 ASIC)有两种主要的编程语言:Verilog 和 VHDL。这些语言被称为硬件描述语言(HDL),因为它们用于定义数字逻辑电路的行为。尽管在语法上,Verilog 和 VHDL 可能看起来与传统编程语言相似,但必须意识到,HDL 完全是另一种类型的语言。当你用 HDL 编写 FPGA 代码时,你直接操作的是 FPGA 上的电线、逻辑门和其他离散资源,而使用传统编程语言编程时,你并不直接控制设备的低级部分。理解你写的 Verilog 或 VHDL 代码背后的逻辑,并知道用这些代码实例化了哪些 FPGA 组件,是数字设计师的关键技能,也是我们在本书中会不断提到的内容。
FPGA 初学者经常会困惑是学习 Verilog 还是 VHDL 更好。没有正确的答案;这实际上取决于你的具体情况。为了帮助你做出决定,我们将对比这两种语言,找出你可能选择其中一种的原因。
注意
本书中的所有代码示例将同时展示 Verilog 和 VHDL 两种语言,因此无论你选择哪种语言,都能跟得上进度。
VHDL 代表 VHSIC 硬件描述语言,而 VHSIC 这个缩写中的缩写代表的是 Very High-Speed Integrated Circuit(超高速集成电路)。全称是超高速集成电路硬件描述语言——真是个绕口令!它是由美国国防部(DoD)于 1983 年开发的,并且借鉴了另一个由 DoD 开发的语言 Ada 的许多特性和语法。VHDL 和 Ada 一样,是强类型语言。
如果你从未接触过强类型语言,刚开始可能会有些挑战。强类型要求设计师在代码中非常明确。例如,在像 Python 或 C 这样的弱类型语言中,你可以将一个定义为整数的变量与一个定义为浮点数的变量相加,完全没有问题。然而,像 VHDL 这样的强类型语言是绝对不允许这样做的。在 VHDL 中相加两个数字时,它们的位宽(位数)和类型必须完全匹配,否则语法检查器会抛出一些难以理解的错误。在你理解语言执行的强类型检查之前,作为初学者,解决这些问题可能会变得非常麻烦。在 VHDL 中处理这些问题时,你通常需要创建正确类型的中介信号,或者在代码中使用大量的类型转换。这也是为什么 VHDL 通常比 Verilog 需要更多敲键盘输入(也就是更多代码)来实现相同功能的原因之一。如果你想使用 VHDL,成为一个快速打字员会对你有帮助。
与 VHDL 相比,Verilog 看起来更像 C 等软件语言,这使得一些人更容易阅读 Verilog 代码并理解其功能。此外,Verilog 是弱类型的。它允许你编写错误的代码,但更简洁。它在将浮点数与整数相加时不会出错,即使这个加法结果可能不正确。
另一个有趣的对比点是,Verilog 是区分大小写的,而 VHDL 则不是。这意味着在 Verilog 中,一个名为RxDone的变量与一个名为rxDone的变量不同,而在 VHDL 中,它们被视为相同。虽然强类型的 VHDL 不区分大小写,而弱类型的 Verilog 却区分大小写,这可能看起来有些奇怪,但这就是历史的安排。根据我的经验,Verilog 的大小写敏感性可能会造成一些难以诊断的问题。你可能认为两个信号是相同的,但由于大小写的差异,代码将它们视为不同的信号。
然而,最终这些点都不是最重要的因素。你应该根据你在学校或工作中更可能使用哪种语言来选择 Verilog 或 VHDL。如果你的大学使用 Verilog,那就学习 Verilog!如果你想要工作的公司使用 VHDL,那就学习 VHDL!谁使用 VHDL 而不是 Verilog 的分布高度依赖于你所在的地区。如果你通过 Google 趋势比较 VHDL 和 Verilog,你会更清楚应该首先学习哪种语言。
当你查看全球范围内这两个术语的整体搜索量时,你会发现“Verilog”的搜索量通常多于“VHDL”。也许是因为 Verilog 使用得更频繁,或者人们在使用 Verilog 时遇到更多问题,需要比 VHDL 更频繁地在线查找解决方案。不管怎样,按国家细分这些趋势更有启发性。图 1-1 展示了一些例子。

图 1-1:Verilog 与 VHDL 在选定国家的搜索量对比
印度和美国是两个在这两个术语的谷歌搜索量最大国家,但尽管在印度 VHDL 和 Verilog 的受欢迎程度大致相当,Verilog 在美国的受欢迎程度略高于 VHDL。事实上,根据我个人经验,在美国,国防行业偏好 VHDL,而商业行业偏好 Verilog。注意,在德国和法国,VHDL 明显比 Verilog 更受欢迎。如果你来自这两个国家,我强烈建议你首先学习 VHDL!相反,在中国和韩国,Verilog 远比 VHDL 更受欢迎,因此请根据情况调整优先顺序。
总的来说,VHDL 和 Verilog 是同样强大的语言。你应该根据你所在的地区和情况选择学习哪种语言。
总结
本章介绍了 FPGA,并提供了关于其历史和常见应用的概述。我们将 FPGA 与微控制器和 ASIC 进行了比较,并看到了每种集成电路类型的优势所在。你了解到,FPGA 在不太敏感于成本,但需要高速、最大灵活性或独特接口的应用中表现出色。最后,我们探讨了用于与 FPGA 配合工作的两种最流行的硬件描述语言:Verilog 和 VHDL,并讨论了如何根据你的需求和情况选择合适的语言。
第三章:10 数字与数学

在本书中,我一直在说 FPGAs 擅长快速执行数学运算。我还说过,FPGAs 擅长并行执行任务,而这两项优势——快速数学运算和并行处理——是它们的杀手级特性。然而,在低级 Verilog 或 VHDL 代码中,处理数字和数学是充满陷阱的。
在本章中,我们将探讨 FPGAs 如何管理数学运算,以便你可以避免那些陷阱。要了解加法、减法、乘法和除法等运算的细节,我们还需要了解如何在 FPGA 内部表示数字,不论是正数还是负数,是否带小数。现在是时候进入计算机算术的奇妙世界了。
数值数据类型
在 Verilog 或 VHDL 中表示数字有许多方法,这一点与所有编程语言一样。例如,如果你只需要存储整数,可以使用整数数据类型,但如果需要存储小数,则需要使用可以表示小数的数据类型。在任何编程语言中,为数据选择正确的类型至关重要。如果将数据分配给错误的数据类型,你可能会遇到编译错误,或者更糟糕的是,设计出现奇怪的行为。例如,试图将小数赋值给整数数据类型,可能会截断小数部分,导致意外的四舍五入操作。
此外,你还不想使用超过必要的资源。例如,你可以使用 64 位宽的数据类型来创建每个信号,但如果你只需要一个从 0 到 7 的计数器,显然这是一种过度设计。与大多数其他编程语言相比,FPGAs 对数据类型的控制更加精细。例如,C 语言有 uint8_t、uint16_t 和 uint32_t 数据类型,它们分别创建 8 位、16 位和 32 位的数据宽度,但没有中间值。相比之下,在 Verilog 和 VHDL 中,你可以创建 9 位、15 位、23 位宽的信号,或者任何其他位宽。本章后面我们将探讨信号尺寸的建议。
表示有符号与无符号值
当你处理数字时,需要知道它们是正数还是负数。有时候,比如当你计数时钟周期以跟踪时间,你会知道这些值都是正数。在这种情况下,你可以使用 无符号 数据类型来存储数字;符号(正数或负数)没有指定,默认假设为正数。其他时候,你需要处理负数:例如,当你读取温度值时,数字的符号可能会变化。在这些情况下,你需要使用 有符号 数据类型,其中符号(正数或负数)是明确指定的。
默认情况下,Verilog 和 VHDL 中的信号是无符号的。例如,如果我们需要一个从 0 到 7 的计数器,我们可以在 Verilog 中声明一个信号,如 reg [2:0] counter;,或者在 VHDL 中声明为 signal counter : std_logic_vector(2 downto 0);。我们在整本书中都使用了这样的代码。它会创建一个 3 位寄存器,且由于默认是无符号的,寄存器中的值将全部解释为正数。如果我们希望 counter 能表示负数和正数,我们必须显式声明它为有符号,使用 signed 关键字。在 Verilog 中,我们会写成 reg signed [2:0] counter;,在 VHDL 中,我们会使用 signal counter : signed(2 downto 0);。
注意
要在 VHDL 中访问 signed 关键字,你需要使用 numeric_std 包,可以通过在文件顶部添加一行 use ieee.numeric_std.all; 来实现。你可能会看到一些代码使用了 std_logic_arith 包,但这不是一个官方的 IEEE 支持库,我不推荐使用它。使用这个包比使用 numeric_std 容易出错。
使用 signed 关键字明确告诉工具,这个 3 位宽的寄存器可以表示负数和正数。但是我们实际能用它表示哪些值呢?表 10-1 比较了 3 位无符号寄存器和 3 位有符号寄存器表示的值。(我们将在下一节讨论如何确定有符号值。)
表 10-1: 3 位无符号与有符号十进制值
| 位 | 无符号十进制值 | 有符号十进制值 |
|---|---|---|
| 000 | 0 | 0 |
| 001 | 1 | 1 |
| 010 | 2 | 2 |
| 011 | 3 | 3 |
| 100 | 4 | –4 |
| 101 | 5 | –3 |
| 110 | 6 | –2 |
| 111 | 7 | –1 |
请注意,当一个寄存器被声明为带符号时,我们会失去一些正数范围内的数字(在这个例子中是 4, 5, 6 和 7),但会在负数范围内获得一些数字(–1, –2, –3 和–4)。无符号寄存器能够表示的数字范围是 0 到 2^N − 1,其中N是可用的位数。对于这个 3 位寄存器,如果寄存器是无符号的,我们可以表示从 0 到 2³ − 1 = 7。另一方面,带符号寄存器能够表示的数字范围是 –2(*N^(–1)) 到 2(*N^(–1)) − 1。在这种情况下,它给我们的范围是 –2^((3–1)) 到 2^((3–1)) − 1,即 –4 到 3。数据仍然是 3 位的二进制数据,但这些二进制数据表示的内容是不同的。
另一个需要注意的特点是在表 10-1 中,所有负值的最高有效位都为 1。实际上,在带符号的数值中,最高有效位是符号位,它表示所表示的数字是正数还是负数。对于带符号的二进制数,符号位为 0 表示数字是正数,而符号位为 1 表示数字是负数。
求二的补码
如何知道一个带负号的二进制数应该表示什么十进制值呢?你需要进行二补数运算,这是一种数学操作,其中你将数值的位反转,然后加 1。例如,考虑二进制数101。如果它是无符号数,我们会将其解释为十进制的 5,但如果它是有符号数,那么符号位上的 1 告诉我们,表示的值应该是负数,因此我们必须进行二补数运算。首先,我们将101的位反转,得到010。然后加 1,得到011,它在十进制中是 3。最后,我们加上负号得到 –3。回到表 10-1,你会看到在101这一行就是这个结果。
注意
反转加一法的替代方法是,从最右边(最低有效)位开始,向左移动,直到遇到第一个 1,然后将这个 1 左边的所有位反转。例如,100010 100 变成 011101 100。三个加粗的位,直到并包括右边第一个 1,保持不变,而其他位则反转。十进制中,011101100 是 236;加上负号后,我们知道 100010100 表示 –236。这个方法避免了加法操作,对于长数字可能更简单。
我们也可以反向进行二补数运算,将一个负的十进制数转换为其带符号的二进制表示。例如,如何用 3 位二进制表示 –1 呢?首先,去掉负号得到 1,它的二进制表示是001。然后反转位,得到110,再加 1,得到111。同样,查看表 10-1,你会发现这是正确的结果。
取二进制补码是我们人类用来更好理解如何解释有符号数字的一个有用技巧,但这种“反转并加一”的逻辑并不是 FPGA 在处理负值时实际执行的操作。无论一个数字是有符号还是无符号,数据都是二进制的 1 和 0。不同之处在于这些 1 和 0 的表示方式。当你有一个 3 位无符号信号,设置为101时,它表示十进制值 5。当你有一个 3 位有符号信号,设置为101时,它表示十进制值-3。FPGA 并不需要反转并加上位来知道这一点。它只需要知道该信号是有符号数据类型。这是一个重要的点,随着我们在二进制中探索数学运算,这一点会更加清晰。
正确地调整信号大小
当你编写处理有符号和无符号数据类型的 Verilog 或 VHDL 代码时,必须确保你创建的信号大小正确。如果你试图将一个过大的数字存储到一个过小的信号中,你将会丢失数据。如我们刚才讨论的那样,例如,一个 3 位无符号计数器的最大值是 7。如果你尝试从 7 开始递增,它不会变成 8;实际上,它会回到 0。这有时被称为回绕,如果你没有预料到这一点,你可能会丢失计数。如本章后面所述,确保信号足够大以容纳你的数据,在信号用来保存数学运算结果时尤其重要。
为了避免数据丢失,你可能会倾向于将所有信号做得比实际需要的更大,但这样做也有一个弊端:你将使用更多 FPGA 宝贵的资源,这比实际需要的要多。然而,这个问题可能没有你想象的那么严重。如果综合工具足够智能,它们可能会检测到你的值的可能范围小于你创建的信号,并去除未使用的高位,以节省资源。例如,如果工具对我们的counter寄存器进行优化,我们会在综合报告中看到类似Pruning register counter的警告。通常,收到这样的警告不是问题,但它可能表明你可以重新检查代码,并调整信号的大小。
通常来说,你应该根据信号预期存储的值来选择大小,但要知道,设置信号大小过大比设置过小要更好。当然,你还需要记住,使用给定的位数表示的最大值会根据是否为有符号或无符号值而有所不同。为了进行对比,表 10-2 总结了你可以使用 2 到 8 位表示的无符号和有符号值的范围。
表 10-2: 无符号和有符号数据类型的 N 位大小
| 宽度 | 类型 | 最小整数 | 最小二进制 | 最大整数 | 最大二进制 |
|---|---|---|---|---|---|
| 2 | 无符号 | 0 | 00 | 3 | 11 |
| 2 | 有符号 | –2 | 10 | 1 | 01 |
| 3 | 无符号 | 0 | 000 | 7 | 111 |
| 3 | 有符号 | –4 | 100 | 3 | 011 |
| 4 | 无符号 | 0 | 0000 | 15 | 1111 |
| 4 | 有符号 | –8 | 1000 | 7 | 0111 |
| 5 | 无符号 | 0 | 00000 | 31 | 11111 |
| 5 | 有符号 | –16 | 10000 | 15 | 01111 |
| 6 | 无符号 | 0 | 000000 | 63 | 111111 |
| 6 | 有符号 | –32 | 100000 | 31 | 011111 |
| 7 | 无符号 | 0 | 0000000 | 127 | 1111111 |
| 7 | 有符号 | –64 | 1000000 | 63 | 0111111 |
| 8 | 无符号 | 0 | 00000000 | 255 | 11111111 |
| 8 | 有符号 | –128 | 10000000 | 127 | 01111111 |
从 2 位宽开始,我们可以表示 0 到 3 的无符号数,或者 –2 到 1 的有符号数。在 8 位宽时,我们可以表示 0 到 255 的无符号数,或者 –128 到 127 的有符号数。
绕过大小问题的一种方法是动态地调整信号的大小,而不是将其设置为固定宽度。我们在本书中已经看过一些这样的示例。例如,如果你需要索引到深度为 32 的某个东西,但该深度可能会在未来发生变化,你可以在 Verilog 中编写类似 reg [$clog2(DEPTH)-1:0] index;,而不是 reg [4:0] index;,或者在 VHDL 中编写 signal index : integer range 0 to DEPTH-1;,而不是 signal index : std_logic_vector(4 downto 0);。这里,DEPTH 是一个可以动态更改的参数/泛型。使用它将生成一个精确的比特宽度信号,足以索引从 0 到 DEPTH-1 的所有可能值,不会有多余的空余空间。在这种情况下,你可以将 DEPTH 设置为 32,但如果你的索引需求增长到更大的值(例如 1,024),代码也不会崩溃;你只需要更改 DEPTH。相比之下,如果你随意地说 index 会被固定为 8 位宽(最大值为 255,如你在 表 10-2 中看到的那样),那么如果你的需求超出该范围,代码可能会在未来崩溃。
VHDL 中类型转换
VHDL 有许多数字数据类型,包括 signed 和 unsigned,其中二进制值被解释为正数或负数的十进制数;integer,可以直接在代码中输入数字;以及 std_logic_vector,其中默认情况下二进制值不会被解释为除二进制值以外的任何内容。由于 VHDL 是强类型语言,在处理数字时,通常需要在这些不同的数据类型之间进行转换。在进行任何数学运算之前,我们先看一些如何使用 numeric_std 包(而不是非官方的 std_logic_arith)实现常见 VHDL 类型转换的示例。
注意
Verilog 用户无需担心执行这些转换,因为 Verilog 是弱类型语言。VHDL 用户应根据需要参考本节内容。
从无符号或有符号到整数
这个示例说明了如何将无符号或有符号类型转换为 integer 类型。为了简化起见,我们假设所有信号都是 4 位宽,但这种转换适用于任何位宽:
signal in1 : unsigned(3 downto 0);
signal in2 : signed(3 downto 0);
signal out1 : integer;
signal out2 : integer;
out1 <= to_integer(in1);
out2 <= to_integer(in2);
对于这些转换,我们只需要调用来自 numeric_std 包的 to_integer() 函数。我们已经知道输入的宽度和符号,因此输出会自动调整大小。无论输入是无符号(如 in1)还是有符号(如 in2),该方法都适用。
从整数到无符号、有符号或 std_logic_vector
这个示例展示了如何将 integer 类型转换为其他类型。再次说明,我们假设信号为 4 位:
signal in1 : integer;
signal out1 : unsigned(3 downto 0);
signal out2 : signed(3 downto 0);
signal out3 : std_logic_vector(3 downto 0);
signal out4 : std_logic_vector(3 downto 0);
❶ out1 <= to_unsigned(in1, out1'length);
❷ out2 <= to_signed(in1, out2'length);
-- Positive integers:
❸ out3 <= std_logic_vector(to_unsigned(in1, out3'length));
-- Negative integers:
❹ out4 <= std_logic_vector(to_signed(in1, out4'length));
在这里,我们使用 to_unsigned() ❶ 和 to_signed() ❷ 函数,来自 numeric_std,将 integer 转换为 unsigned 或 signed 类型。除了要转换的值外,这些函数还需要输出信号的宽度作为参数。我们通过应用 VHDL 属性 'length 获取宽度,而不是手动输入。这样可以保持代码的灵活性;如果宽度发生变化,转换代码不需要做任何更改。
为了得到 std_logic_vector,我们必须将 integer 转换为 unsigned(如果整数是正数 ❸),或者转为 signed(如果整数是负数 ❹)。然后,一旦我们获得了具有适当宽度的无符号或有符号值,我们就可以使用 std_logic_vector() 进行类型转换。
从 std_logic_vector 到无符号、有符号或整数
最后,这是如何将 std_logic_vector 类型转换为其他数字类型的示例:
signal in1 : std_logic_vector(3 downto 0);
signal out1 : unsigned(3 downto 0);
signal out2 : signed(3 downto 0);
signal out3 : integer;
signal out4 : integer;
❶ out1 <= unsigned(in1);
❷ out2 <= signed(in1);
-- Demonstrates the unsigned case:
❸ out3 <= to_integer(unsigned(in1));
-- Demonstrates the signed case:
❹ out4 <= to_integer(signed(in1));
要获取 unsigned ❶ 或 signed ❷,我们使用一个简单的类型转换。然而,VHDL 需要知道 std_logic_vector 是无符号的还是有符号的,才能转换为 integer 类型。我们通过使用 unsigned() ❸ 或 signed() ❹ 来执行适当的转换,然后调用 to_integer() 函数进行最终转换。
执行数学运算
现在我们将考虑在 FPGA 中如何执行基本的加法、减法、乘法和除法操作,以及如何在 Verilog 和 VHDL 中实现它们。我将建议一些规则,如果遵循这些规则,将帮助你避免在进行二进制数学运算时出现许多常见的陷阱。探索这些概念的最佳方法是通过示例。为此,我们将创建一个大型测试平台,你可以在像 EDA Playground 这样的仿真工具中运行它。该测试平台将执行数十个不同的数学方程,展示二进制数学运算应该如何进行,以及它们如何出错。
一般来说,在处理数字并进行代数运算时,测试平台是一个非常强大的工具。代码中隐藏的数学问题可能以奇怪的方式表现出来。测试平台通过运行大量不同的输入来加大设计的压力,从而查看代码的工作情况。我发现,在我的测试平台中注入数据,尤其是那些对数学运算施加压力的值,包括最小和最大输入,是非常有价值的。这有助于确保设计的稳健性。
在我们进行任何数学运算之前,让我们通过在 VHDL 版本中声明所有必要的输入和输出,以及一些辅助函数,来设置我们的测试平台,名为 Math_Examples。这段设置代码为接下来整个章节中的示例提供了框架。每个示例的代码将放置在设置代码中的 -- snip-- 位置:
Verilog
module Math_Examples();
reg unsigned [3:0] i1_u4, i2_u4, o_u4;
reg signed [3:0] i1_s4, i2_s4, o_s4;
reg unsigned [4:0] o_u5, i2_u5;
reg signed [4:0] o_s5, i1_s5, i2_s5;
reg unsigned [5:0] o_u6;
reg unsigned [7:0] o_u8, i_u8;
reg signed [7:0] o_s8;
initial begin
`--snip--`
$finish();
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use std.env.finish;
entity Math_Examples is
end entity Math_Examples;
architecture test of Math_Examples is
-- Takes input unsigned, returns string for printing
❶ function str(val : in unsigned) return string is
begin
return to_string(to_integer(val));
end function str;
-- Takes input signed, returns string for printing
❷ function str(val : in signed) return string is
begin
return to_string(to_integer(val));
end function str; -- Takes input real, returns string for printing
❸ function str(val : in real) return string is
begin
return to_string(val, "%2.3f");
end function str;
begin
process is
variable i1_u4, i2_u4, o_u4 : unsigned(3 downto 0);
variable i1_u5, i2_u5, o_u5 : unsigned(4 downto 0);
variable i1_s4, i2_s4, o_s4 : signed(3 downto 0);
variable i1_s5, i2_s5, o_s5 : signed(4 downto 0);
variable i1_u6, i2_u6, o_u6 : unsigned(5 downto 0);
variable i1_u8, i2_u8, o_u8 : unsigned(7 downto 0);
variable i1_s8, i2_s8, o_s8 : signed(7 downto 0);
variable real1, real2, real3 : real;
begin
`--snip--`
wait for 1 ns;
finish;
end process;
end test;
这个测试平台的骨架设置了一个单独的 initial(在 Verilog 中)或 process(在 VHDL 中)块,该块只运行一次。我们将在本章后面用示例填充这个块。请注意,我们已经使用 reg(在 Verilog 中)和 variable(在 VHDL 中)声明了一些信号。这是我们第一次在 VHDL 中看到 variable 关键字:我们需要它以便在测试平台中编写阻塞赋值语句。有关更多信息,请参见第 214 页的“阻塞与非阻塞赋值”。
本章中的示例使用了一种通用的命名规则,以便快速识别信号的数据类型和宽度,这样你就不必不断回头查看信号定义。前缀 i 表示数学方程的输入,而 o 表示输出,即数学方程的结果。此外,我们有后缀 _uN 和 _sN,其中 u 代表无符号,s 代表有符号,N 代表信号的位宽。例如,o_s4 是一个 4 位宽的有符号输出。建立像这样的命名方案,可以帮助你更容易地记住数据类型和宽度,尤其是当一个文件中有很多值时,这对你的代码非常有帮助。
请注意,在 VHDL 中,我们声明了一个自定义函数 str(),用于将我们方程的输出转换为字符串以便打印。这将大大减少我们在后续示例中输入的工作量。我们实际上根据数据类型定义了三种不同的函数,因为 VHDL 是强类型语言,我们需要定义所有支持的函数输入,以便编译器知道使用哪个版本。第一个定义 ❶ 转换一个 unsigned 值,第二个 ❷ 转换一个 signed 值,第三个 ❸ 转换一个 real 值。这是函数 重载 的一个例子,重载是一个编程技巧,允许一个函数有多个实现。重载是一个相对高级的 VHDL 概念,但它非常有用。你甚至可以重载普通的 VHDL 运算符,如 + 和 –,使用任何你需要的实现,尽管我并不建议这样做。
现在我们已经设置好了测试平台,准备开始探索数学运算。
加法
加法二进制数据的方式与小学时教你加法的方式相同:逐位相加,从右向左。例如,以下是如何加法两个二进制数0010和1000:
0010
+ 1000
------
01010
要得出结果,你只需要逐位相加,从最低有效位开始,将该列的数字相加。如果在某一列中得到 1 + 1 = 10,那么你在该列的底部写下 0,并将 1 进位到左边的下一位。
请注意,将两个 4 位数相加后的结果是 5 位宽。这是 FPGA 数学的第一个规则:
规则 #1 在加法时,结果的位数应至少比最大输入大 1 位。
当加法的最高有效位需要进位操作时,额外的位是必要的。如果没有这个额外的位,我们就会截断结果,可能会得到错误的答案。在第一个例子中,丢弃最高有效位并不会产生问题,但考虑这个例子,拥有额外的位是至关重要的:
1001
+ 1011
------
10100
在这里,结果的最高有效位是 1。如果我们假设输出宽度与输入宽度相同,那么我们就会丢掉这个位,得到错误的答案。我们的结果将是 0100 而不是 10100。
也许你注意到,我还没有明确说明这些二进制数字代表什么,也没有说明它们是正数还是负数。例如,1001 是无符号并等于 9,还是有符号并等于 9 的二进制补码,即 –7(反转位得到 0110,然后加 1 得到 0111)?我之所以没有明确说明这一点,是因为二进制数据的表示方式最终不会影响数学运算的执行方式,只要输入和输出的位数合适。无论 1001 代表 +9 还是 –7,加法操作都会以相同的方式进行。当然,我们关心结果是正数还是负数,但加法的实现不会因为数据类型是有符号还是无符号而有所不同。让我们重新回到第一个例子,考虑一下当我们为其分配不同的有符号和无符号组合时会发生什么。以下是这个例子:
0010
+ 1000
------
01010
如果两个加法输入都声明为无符号类型,那么我们有 2 + 8 = 10。相当简单。如果两个加法输入都声明为有符号类型,那么第一个输入仍然是 2,但第二个输入是 –8。 (反转位得到 0111,加 1 得到 1000,然后应用负号得到 –8。)所以现在我们有 2 + –8,结果应该是 –6,但结果 01010 仍然是 10。这里出了点问题!
问题是我们没有对输入进行符号扩展。符号扩展是将二进制数的位数增加,同时保持该数的符号和值的操作。输入为有符号时,需要进行此操作。如果没有,它会导致不正确的答案,就像你刚才看到的那样。要对有符号值执行符号扩展,只需复制最重要的位。例如,1000 变成 11000,而 0010 变成 00010。让我们再次尝试这个运算,这次先对输入应用符号扩展:
00010
+ 11000
-------
11010
我们的输入仍然是 2 和 –8。 (对于后者,反转 11000 的位得到 00111,加 1 得到 01000,然后应用负号得到 –8。)答案 11010 是 –6 的有符号等效值,这正是我们想要的。符号扩展是确保我们得到预期答案的关键步骤。
符号扩展对于无符号值也非常有用。事实上,由于 VHDL 是强类型的,所有参与加法运算的输入和输出必须具有完全相同的宽度。例如,你不能将两个 4 位输入相加得到一个 5 位输出;所有的信号都必须是 5 位。这意味着我们应该重新审视规则 #1,并进行一个小的修改:
规则 #1(修改 #1) 在加法时,结果应至少比最大输入大 1 位,在符号扩展之前。一旦符号扩展应用,输入和输出的宽度应完全匹配。
对于无符号值,符号扩展意味着将 0 作为新的最高有效位。例如,无符号的 1000 变成 01000。对于使用 Verilog 的用户来说,好消息是,当你加法运算时,代码会自动执行符号扩展。然而,如果你使用的是 VHDL,你将需要通过 resize() 函数手动进行符号扩展,正如接下来的示例中所展示的那样。这两种方法各有优缺点。如果你知道自己在做什么,Verilog 更容易,因为你需要担心的东西较少,但也更容易出错(例如,试图将数据存储在一个过小的信号中)。VHDL 的额外步骤可能让初学者感到困惑,而且当规则没有遵循时,它会生成难以理解的错误。另一方面,VHDL 确保每一步都匹配宽度和类型,因此最终出错的可能性较小。
让我们通过一些代码示例来总结我们所学的内容。将以下代码添加到你的测试平台中,在你之前看到过的 -- snip-- 位置:
Verilog
// Unsigned + Unsigned = Unsigned (Rule #1 violation)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
o_u4 = i1_u4 + i2_u4;
$display("Ex01: %2d + %2d = %3d", i1_u4, i2_u4, o_u4);
// Signed + Signed = Signed (Rule #1 violation)
i1_s4 = 4'b1001; // dec -7
i2_s4 = 4'b1011; // dec -5
o_s4= i1_s4 + i2_s4;
$display("Ex02: %2d + %2d = %3d", i1_s4, i2_s4, o_s4);
// Unsigned + Unsigned = Unsigned (Rule #1 fix)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
o_u5 = i1_u4 + i2_u4;
$display("Ex03: %2d + %2d = %3d", i1_u4, i2_u4, o_u5);
// Signed + Signed = Signed (Rule #1 fix)
i1_s4 = 4'b1001; // dec -7
i2_s4 = 4'b1011; // dec -5
o_s5 = i1_s4 + i2_s4;
$display("Ex04: %2d + %2d = %3d", i1_s4, i2_s4, o_s5);
VHDL
-- Unsigned + Unsigned = Unsigned (Rule #1 violation)
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
o_u4 := i1_u4 + i2_u4;
report "Ex01: " & str(i1_u4) & " + " & str(i2_u4) & " = " & str(o_u4);
-- Signed + Signed = Signed (Rule #1 violation)
i1_s4 := "1001"; -- dec -7
i2_s4 := "1011"; -- dec -5
o_s4 := i1_s4 + i2_s4;
report "Ex02: " & str(i1_s4) & " + " & str(i2_s4) & " = " & str(o_s4);
-- Unsigned + Unsigned = Unsigned (Rule #1 fix)
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
❶ i1_u5 := resize(i1_u4, i1_u5'length);
i2_u5 := resize(i2_u4, i2_u5'length);
o_u5 := i1_u5 + i2_u5;
report "Ex03: " & str(i1_u5) & " + " & str(i2_u5) & " = " & str(o_u5);
-- Signed + Signed = Signed (Rule #1 Fix)
i1_s4 := "1001"; -- dec -7
i2_s4 := "1011"; -- dec -5
i1_s5 := resize(i1_s4, i1_s5'length);
i2_s5 := resize(i2_s4, i2_s5'length);
o_s5 := i1_s5 + i2_s5;
report "Ex04: " & str(i1_s5) & " + " & str(i2_s5) & " = " & str(o_s5);
以下是输出结果:
Verilog
# Ex01: 9 + 11 = 4
# Ex02: -7 + -5 = 4
# Ex03: 9 + 11 = 20
# Ex04: -7 + -5 = -12
VHDL
# ** Note: Ex01: 9 + 11 = 4
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex02: -7 + -5 = 4
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex03: 9 + 11 = 20
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex04: -7 + -5 = -12
# Time: 0 ns Iteration: 0 Instance: /math_examples
首先,我们有两个情况(Ex01 和 Ex02),这两个例子没有遵循规则 #1。我们使用 4 位输入,并将结果存储在 4 位输出中,而且没有执行符号扩展。在这两个例子中,我们得到了错误的结果。在 Ex01 中,我们加了两个无符号数,9 和 11,但得到了 4 作为结果。问题在于我们丢失了最高有效位,而这个位的值应该是 16。(事实上,4 + 16 = 20,这才是我们应该得到的答案。)在 Ex02 中,我们加了两个表示负值的有符号数,结果依然是错误的。
解决方法是将结果存储在一个 5 位的输出中,我们在Ex03和Ex04中都这样做了。我们已经满足了规则#1,所以数学运算是正确的。注意,在 Verilog 版本中,符号扩展是自动进行的:我们可以简单地将 4 位输入赋值给 5 位输出,例如通过写o_u5 = i1_u4 + i2_u4;。然而,在 VHDL 中,我们必须显式地匹配输入和输出的位宽,同时保留每个输入的符号和值。为此,我们调用resize()函数❶。我们使用 VHDL 的 tick 属性'length来引用输出信号的长度,正如我们在进行类型转换时所做的那样。再说一次,这比通过写类似resize(i1_u4, 5)这样的硬编码宽度更灵活。
进行成功的加法运算的另一个技巧是不要混合带符号和不带符号的值。输入和输出应该是相同类型的;否则,你可能会得到错误的结果。这引出了我们的第二条 FPGA 数学规则:
规则#2 输入和输出类型要匹配。
对于 VHDL 来说,遵循规则#2 很容易,因为如果你尝试进行一个输入为带符号、另一个输入为不带符号的数学运算,它会抛出错误。例如,假设你在测试平台中写下以下代码,尝试将一个 4 位的不带符号值(i1_u4)与一个 4 位的带符号值(i2_s4)相加:
o_u4 := i1_u4 + i2_s4;
你会看到一个错误消息,指出工具无法理解给定输入下的+运算符:
** Error: testbench.vhd(49): (vcom-1581) No feasible entries for infix
operator '+'.
Verilog 的容错性更强。它会很高兴地让你执行该数学运算,而且不会告诉你它实际上是把带符号输入当作不带符号处理的。这很可能导致错误的结果,因此在 Verilog 中务必小心始终匹配数据类型。
减法
减法与加法没什么不同。毕竟,减法只是将其中一个输入变为负数的加法运算。从这个角度看,我们一直在做减法;2 + –8 就等同于 2 – 8。同样,你可以把类似 5 – 3 的运算看作 5 + –3,并以加法运算的方式进行处理。
但是,在进行两个数的减法时,有一件事需要特别注意:虽然你可以使用无符号输入和输出进行减法,但我不推荐这样做。如果结果应该是负数会怎样呢?例如,3 – 5 = -2,但如果你尝试将 -2 存入无符号数据类型中,你将无法获得正确的结果。这引出了我们的下一个规则:
规则 #3 在进行减法时,使用有符号的输入和输出。
即使你认为减法的结果不会产生负数,你也应该使用有符号数据类型,以确保安全。
因为减法实际上就是负数加法,所以减法存在相同的风险,即如果输出的大小不合适,可能会导致结果被截断。再次强调,最好在执行数学运算之前将输出大小增加 1 位,并对输入进行符号扩展。这给我们带来了进一步修改的规则 #1:
规则 #1(修改版 #2) 在加法或减法时,结果应比最大输入大至少 1 位,符号扩展前如此。符号扩展应用后,输入和输出的位宽应该完全匹配。
通过这两条规则,我们扩展了 Math_Examples 测试平台,来观察 Verilog 和 VHDL 中的一些减法操作:
Verilog
// Unsigned - Unsigned = Unsigned (bad)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
o_u5 = i1_u4 - i2_u4;
$display("Ex05: %2d - %2d = %3d", i1_u4, i2_u4, o_u5);
// Signed - Signed = Signed (fix)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
❶ i1_s5 = i1_u4;
i2_s5 = i2_u4;
o_s5 = i1_s5 - i2_s5;
$display("Ex06: %2d - %2d = %3d", i1_s5, i2_s5, o_s5);
VHDL
-- Unsigned - Unsigned = Unsigned (bad)
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
i1_u5 := resize(i1_u4, i1_u5'length);
i2_u5 := resize(i2_u4, i2_u5'length);
o_u5:= i1_u5 - i2_u5;
report "Ex05: " & str(i1_u5) & " - " & str(i2_u5) & " = " & str(o_u5);
-- Signed - Signed = Signed (fix)
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
❷ i1_s5 := signed(resize(i1_u4, i1_s5'length));
i2_s5 := signed(resize(i2_u4, i2_s5'length));
o_s5:= i1_s5 - i2_s5;
report "Ex06: " & str(i1_s5) & " - " & str(i2_s5) & " = " & str(o_s5);
这是输出:
Verilog
# Ex05: 9 - 11 = 30
# Ex06: 9 - 11 = -2
VHDL
# ** Note: Ex05: 9 - 11 = 30
# Time: 0 nsIteration: 0 Instance: /math_examples
# ** Note: Ex06: 9 - 11 = -2
# Time: 0 ns Iteration: 0 Instance: /math_examples
在 Ex05 中,我们尝试计算 9 – 11,但结果却是 30,显然是错误的答案。问题在于我们使用了无符号类型进行减法,这违反了规则 #3。我们在 Ex06 中通过将输入值从无符号转换为有符号数据类型来修复这一问题。在此过程中,我们还进行了符号扩展,将 4 位输入转换为 5 位输入。在 Verilog 代码中,我们通过将 4 位无符号信号直接赋值给 5 位有符号信号来处理转换 ❶。Verilog 会自动处理细节。而 VHDL 需要我们多做一些工作。我们首先调整输入大小,这将进行符号扩展,但调整大小后的结果仍然是无符号类型,因此我们需要显式地使用 signed() 将其转换为有符号数据类型 ❷。这样做是安全的,因为我们已经调整了信号大小,所以最高位将是 0。因此,转换为有符号类型后的值不会改变。
乘法
乘法运算与加法运算类似;毕竟,乘法运算只是重复加法的一个过程(4 × 3 = 4 + 4 + 4)。在将两个输入相乘时,首先要考虑的是如何正确设置输出的位宽。这引出了我们的下一个规则:
规则 #4 在乘法运算时,输出位宽必须至少等于输入位宽的总和(在符号扩展之前)。
这个规则对有符号数和无符号数都适用。例如,假设我们正在尝试乘法运算无符号输入 111 和 11(等于 7 × 3)。根据规则 #4,输出应该是 3 + 2 = 5 位宽。你可以自己试一下这个乘法运算,使用你在学校学过的多位数相乘的技巧——逐位相乘并将结果相加:
111
× 11
------
111
+ 1110
------
10101
输出 10101(等于 21),确实是 5 位宽,符合预期。但如果我们将输入和输出视为有符号数而非无符号数,这个乘法运算会怎样呢?在这种情况下,相当于十进制的 –1 × –1,结果应该是 +1,但二进制的有符号 10101 等于十进制的 –11。那么这里到底出了什么问题呢?
问题在于,我们在进行乘法运算之前,没有将输入的位宽扩展到与输出位宽(5 位)相匹配。如果我们这样做,输入会变成 11111,乘法运算结果如下所示:
11111
× 11111
-----------
11111
111110
1111100
11111000
+ 111110000
-----------
000000001
现在我们得到 00000001,或者实际上在将结果截断为 5 位宽后,得到 00001,即十进制的 +1。符号扩展给出了我们预期的结果。然而,与加法和减法不同,在使用 Verilog 或 VHDL 进行乘法运算时,你实际上不需要手动进行符号扩展。工具会自动处理这一过程;你只需要正确设置输出信号的位宽。
VHDL 也能帮助处理这个问题:如果你违反规则 #4,未正确设置乘法运算的输出大小,VHDL 根本无法编译代码。而在 Verilog 中,你需要更加小心。如果输出的位宽不正确,它不会发出警告,可能会得到意外的结果。让我们在 Math_Examples 测试平台中添加一些例子:
Verilog
// Unsigned * Unsigned = Unsigned (Rule #4 violation)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
o_u4 = i1_u4 * i2_u4;
$display("Ex07: %2d * %2d = %3d", i1_u4, i2_u4, o_u4);
// Signed * Signed = Signed (Rule #4 violation)
i1_s4 = 4'b1000; // dec -8
i2_s4 = 4'b0111; // dec 7
o_s4 = i1_s4 * i2_s4;
$display("Ex08: %2d * %2d = %3d", i1_s4, i2_s4, o_s4);
VHDL
-- Unsigned * Unsigned = Unsigned
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
o_u4 := i1_u4 * i2_u4;
report "Ex07: " & str(i1_u4) & " * " & str(i2_u4) & " = " & str(o_u4);
-- Signed * Signed = Signed
i1_s4 := "1000"; -- dec -8
i2_s4 := "0111"; -- dec 7 o_s4 := i1_s4 * i2_s4;
report "Ex08: " & str(i1_s4) & " * " & str(i2_s4) & " = " & str(o_s4);
这是输出结果:
Verilog
# Ex07: 9 * 11 = 3
# Ex08: -8 * 7 = -8
VHDL
** Error (suppressible): testbench.vhd(89): (vcom-1272) Length of expected
is 4; length of actual is 8.
Verilog 允许我们执行数学运算,尽管我们违反了规则 #4,即将 4 位数与 4 位数相乘,并将结果存储在一个 4 位输出中。这对于无符号(Ex07)和有符号(Ex08)输入值都会产生错误结果。另一方面,VHDL 甚至无法构建此代码;我们会得到一个详细的错误信息,告诉我们工具正在尝试将一个 8 位宽的结果赋值给一个 4 位宽的变量,这是不允许的。让我们在测试平台中添加几个例子来修复这些问题:
Verilog
// Unsigned * Unsigned = Unsigned (Rule #4 fix)
i1_u4 = 4'b1001; // dec 9
i2_u4 = 4'b1011; // dec 11
o_u8 = i1_u4 * i2_u4;
$display("Ex09: %2d * %2d = %3d", i1_u4, i2_u4, o_u8);
// Signed * Signed = Signed (Rule #4 fix)
i1_s4 = 4'b1000; // dec -8
i2_s4 = 4'b0111; // dec 7
o_s8 = i1_s4 * i2_s4;
$display("Ex10: %2d * %2d = %3d", i1_s4, i2_s4, o_s8);
VHDL
-- Unsigned * Unsigned = Unsigned
i1_u4 := "1001"; -- dec 9
i2_u4 := "1011"; -- dec 11
o_u8 := i1_u4 * i2_u4;
report "Ex09: " & str(i1_u4) & " * " & str(i2_u4) & " = " & str(o_u8);
-- Signed * Signed = Signed
i1_s4 := "1000"; -- dec -8
i2_s4 := "0111"; -- dec 7
o_s8 := i1_s4 * i2_s4;
report "Ex10: " & str(i1_s4) & " * " & str(i2_s4) & " = " & str(o_s8);
这是输出:
Verilog
# Ex09: 9 * 11 = 99
# Ex10: -8 * 7 = -56
VHDL
# ** Note: Ex09: 9 * 11 = 99
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex10: -8 * 7 = -56
# Time: 0 ns Iteration: 0 Instance: /math_examples
在 Ex09 中,我们通过将两个无符号 4 位值相乘的结果存储到一个 8 位信号中,解决了 Ex07 中的问题。类似地,Ex10 修复了 Ex08 中有符号值的问题。请注意,我们在 Verilog 或 VHDL 中都不需要扩展输入的符号。工具会自动处理这一点。
乘以 2 的幂
在乘以 2 的幂(例如 2、4、8、16、32 等)时,我们可以使用一个技巧。与其实例化一堆乘法逻辑,我们可以简单地实例化一个移位寄存器并执行左移操作。左移 N 位等同于乘以 2^N。例如,0011(十进制 3)左移 2 位,得到 1100(十进制 12)。这等同于计算 3 × 4,或 3 × 2²。这个技巧适用于有符号和无符号数。让我们在测试平台中试试:
Verilog
i_u8 = 3;
o_u8 = i_u8 << 1;
$display("Ex11: %d * 2 = %d", i_u8, o_u8);
o_u8 = i_u8 << 2;
$display("Ex12: %d * 4 = %d", i_u8, o_u8);
o_u8 = i_u8 << 4;
$display("Ex13: %d * 16 = %d", i_u8, o_u8);
VHDL
i1_u8 := to_unsigned(3, i1_u8'length);
o_u8 := shift_left(i1_u8, 1);
report "Ex11: " & str(i1_u8) & " * 2 = " & str(o_u8);
o_u8 := shift_left(i1_u8, 2);
report "Ex12: " & str(i1_u8) & " * 4 = " & str(o_u8);
o_u8 := shift_left(i1_u8, 4);
report "Ex13: " & str(i1_u8) & " * 16 = " & str(o_u8);
这是输出:
Verilog
# Ex11: 3 * 2 = 6
# Ex12: 3 * 4 = 12
# Ex13: 3 * 16 = 48
VHDL
# ** Note: Ex11: 3 * 2 = 6
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex12: 3 * 4 = 12
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex13: 3 * 16 = 48
# Time: 0 ns Iteration: 0 Instance: /math_examples
我们从十进制值 3 开始,左移 1、2 和 4 位,分别将其乘以 2、4 和 16。在 Verilog 中,我们使用 << 运算符进行移位,而在 VHDL 中,我们使用函数 shift_left()。这两个方法的参数都是要移位的位数。
左移是节省 FPGA 资源的一个简单快捷的技巧,但你不一定需要显式地写出来。如果你硬编码了一个 2 的幂乘法,综合工具可能会足够聪明,自动识别出左移操作会占用更少的资源。
Division
不幸的是,除法不像加法、减法或乘法那样简单。除法会带来各种复杂问题,比如余数和分数。一般来说,如果可以的话,最好避免在 FPGA 内部进行除法。除法是一个资源密集型的操作,尤其是在你需要让这个操作在高时钟频率下运行时。
我曾经参与一个项目,需要在现场为 FPGA 添加一个除法操作。那个 FPGA 是一个非常老旧的型号,根本无法在现有资源下完成这项操作。为了支持除法操作,我们最终不得不升级到同一家族的高资源 FPGA,这使得硬件成本增加了超过 100 万美元。我一直把那一次额外的操作当作百万美元除法!
如果你必须进行除法操作,有几种方法可以使这个操作更节省资源。这些方法包括限制自己只进行 2 的幂除法,使用预先计算好的答案表,或者将操作分解到多个时钟周期中进行。
使用 2 的幂
我对减少在 FPGA 内部进行除法操作开销的最佳建议是将除数设为 2 的幂。类似于如何使用左移操作高效地进行 2 的幂乘法,2 的幂除法也可以通过右移操作高效地完成。右移N位相当于除以 2^N。让我们来看看几个例子:
Verilog
i_u8 = 128;
o_u8 = i_u8 >> 1;
$display("Ex14: %d / 2 = %d", i_u8, o_u8);
o_u8 = i_u8 >> 2;
$display("Ex15: %d / 4 = %d", i_u8, o_u8);
o_u8 = i_u8 >> 4;
$display("Ex16: %d / 16 = %d", i_u8, o_u8);
VHDL
i1_u8 := to_unsigned(128, i1_u8'length);
o_u8 := shift_right(i1_u8, 1);
report "Ex14: " & str(i1_u8) & " / 2 = " & str(o_u8);
o_u8 := shift_right(i1_u8, 2);
report "Ex15: " & str(i1_u8) & " / 4 = " & str(o_u8);
o_u8 := shift_right(i1_u8, 4);
report "Ex16: " & str(i1_u8) & " / 16 = " & str(o_u8);
下面是输出结果:
Verilog
# Ex14: 128 / 2 = 64
# Ex15: 128 / 4 = 32
# Ex16: 128 / 16 = 8
VHDL
# ** Note: Ex14: 128 / 2 = 64
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex15: 128 / 4 = 32
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex16: 128 / 16 = 8
# Time: 0 ns Iteration: 0 Instance: /math_examples
Ex14执行了右移 1 位,在 Verilog 中使用的是 >> 运算符,而在 VHDL 中使用的是 shift_right() 函数。这完成了一个除以 2 的操作。要除以 4,可以右移 2 个位位置,如 Ex15 所示。同样,右移 4 位相当于除以 16,如 Ex16 所示。
当我们没有一个能被 2 的幂整除的数字作为除数时会发生什么呢?在这种情况下,右移操作有效地完成了一个向下取整到最接近整数的除法。接下来的几个例子将说明这是如何工作的:
Verilog
i_u8 = 15;
o_u8 = i_u8 >> 1;
$display("Ex17: %d / 2 = %d", i_u8, o_u8);
o_u8 = i_u8 >> 2;
$display("Ex18: %d / 4 = %d", i_u8, o_u8);
o_u8 = i_u8 >> 3;
$display("Ex19: %d / 8 = %d", i_u8, o_u8);
VHDL
i1_u8 := to_unsigned(15, i1_u8'length);
o_u8 := shift_right(i1_u8, 1);
report "Ex17: " & str(i1_u8) & " / 2 = " & str(o_u8);
o_u8 := shift_right(i1_u8, 2);
report "Ex18: " & str(i1_u8) & " / 4 = " & str(o_u8);
o_u8 := shift_right(i1_u8, 3);
report "Ex19: " & str(i1_u8) & " / 8 = " & str(o_u8);
这是输出结果:
Verilog
# Ex17: 15 / 2 = 7
# Ex18: 15 / 4 = 3
# Ex19: 15 / 8 = 1
VHDL
# ** Note: Ex17: 15 / 2 = 7
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex18: 15 / 4 = 3
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex19: 15 / 8 = 1
在 Ex17 中,我们尝试执行 15 / 2\。这应该得到 7.5,但我们无法表示 .5 部分,因此最终会向下取整为 7。将其视为右移操作,我们从 00001111 变为 00000111。在 Ex18 中,我们尝试计算 15 / 4,这应该得到 3.75,但我们会去掉小数部分,只得到 3\。最后,在 Ex19 中,我们得到 15 / 8 = 1\。如果你没有预料到这种情况,这种舍入可能会引发问题,因此请注意在执行右移操作时可能会发生这种情况。
使用预计算表
另一种除法操作的选项是为所有可能的输入组合预先计算结果。例如,如果我们尝试将数字 1 到 7 中的任何数字除以 1 到 7 中的任何其他数字,我们可以在 FPGA 中创建类似表 10-3 的内容。
表 10-3: 全范围除法输入的预计算表
| 1 | 2 | 3 | 4 | 5 | 6 | 7 | |
|---|---|---|---|---|---|---|---|
| 1 | 1.00 | 0.50 | 0.33 | 0.25 | 0.20 | 0.17 | 0.14 |
| 2 | 2.00 | 1.00 | 0.67 | 0.50 | 0.40 | 0.33 | 0.29 |
| 3 | 3.00 | 1.50 | 1.00 | 0.75 | 0.60 | 0.50 | 0.43 |
| 4 | 4.00 | 2.00 | 1.33 | 1.00 | 0.80 | 0.67 | 0.57 |
| 5 | 5.00 | 2.50 | 1.67 | 1.25 | 1.00 | 0.83 | 0.71 |
| 6 | 6.00 | 3.00 | 2.00 | 1.50 | 1.20 | 1.00 | 0.86 |
| 7 | 7.00 | 3.50 | 2.33 | 1.75 | 1.40 | 1.17 | 1.00 |
在这个示例中,假设每一行代表一个可能的被除数,每一列代表一个可能的除数。给定的被除数和除数交汇处的值就是相应的商。例如,要找到分数 5/6 的十进制值,可以去第 5 行,再移动到第 6 列,得到值 0.83。为了在 Verilog 或 VHDL 中实现这一点,我们可以将这个二维表格存储在一个二维数组中。(你可以在第八章的状态机项目中看到二维数组是如何工作的。)行输入值提供了一个索引,列输入值提供了第二个索引,商则是这两个索引处的值。我们实际上并没有进行任何数学运算;我们只是索引到正确的结果,这个结果已经预先计算并存储在内存中。
注意
如果你在想如何在 FPGA 中表示像 0.50 和 0.33 这样的十进制值,问得好!我们很快会探讨这个话题。
随着可能输入范围的增大,当然,我们将需要一个更大更大的表来存储可能的输出。最终,一个单独的表可能会占用整个块 RAM,通常块 RAM 的大小为 16Kb。使用块 RAM 中的预计算表可以确保单次除法计算只需要一个时钟周期,因为我们只需一个时钟周期来读取内存(就像我们在第六章中讨论 RAM 时所学的那样)。然而,我们不能在同一个时钟周期内从多个位置读取内存,因此如果我们需要在完全相同的时钟周期内同时进行两次除法运算,我们就需要在另一个块 RAM 中实例化预计算表的第二个副本。
块 RAM 通常是宝贵的资源,因此将其用于并行除法运算并不具有良好的可扩展性。如果设计允许我们在连续的时钟周期中运行不同的除法操作,而不是同时进行,我们可以改为使用一个表格并进行时间共享。对单一资源进行时间共享需要对该资源进行仲裁,正如我们在第七章中讨论的那样。我们需要创建一个仲裁器,只允许一个模块在每次时钟周期访问该块 RAM 表。
到目前为止讨论的解决方案假设我们只有一个时钟周期来获得除法运算的结果。然而,如果我们可以等待多个时钟周期来得到除法结果,那么我们就可以使用另一个选项。
使用多个时钟周期
缓解合成工具在执行除法时的负担的另一种方法是创建一个算法,该算法通过使用更简单的数学运算(如加法和减法)在不止一个时钟周期内执行除法。除法的本质是计算一个数能在另一个数中被包含多少次。例如,你可以通过将除数反复加到自身,直到超出被除数的值,同时计数你执行了多少次循环。然后,减去被除数以得到余数。
还有各种其他方法可以通过更简单的数学运算执行除法。(具体实现超出了本书的范围;如果你想了解更多,可以在网上搜索FPGA 上的除法算法。)但当然,这些方法只有在你能够等待多个时钟周期得到结果的情况下才有效。在这个背景下,使用多个时钟周期得到结果与我们在第七章中讨论的流水线示例有所不同,在那个例子中,我们将一个复杂的数学运算分解到多个时钟周期以满足时序要求。在那个例子中,我们仍然能够在每个时钟周期获得一个结果,但输出相对于输入延迟了几个时钟周期。
在这种情况下,我们不知道除法算法需要多少时钟周期才能提供结果,因此我们不能依赖每个时钟周期的结果。最终,这是一个在更低资源利用和更多时钟周期之间进行权衡的问题。如果你真的需要在每个时钟周期都获得除法操作的结果,你将不得不使用之前讨论过的除法技术之一。
FPGA 如何实现数学运算
到目前为止,我们讨论的所有操作只是看到了数学如何运作,并没有真正考虑这些操作是如何在 FPGA 内部实现的。根据执行的具体操作,可能会涉及不同的 FPGA 组件。如果你参加了入门级的数字电子学课程,你可能会学习到半加法器和全加法器,这些是结合了各种逻辑门(如 XOR 和 AND)来执行加法操作的数字电路。这是一个很有趣的主题,但最终你可能会感到沮丧,因为你不需要知道这些电路是如何工作的,就能在现代 FPGA 代码中进行数学运算。如果你只是在做加法,你永远不需要通过手动编写所有必要的逻辑操作来实例化一个全加法器组件。相反,你只需在 Verilog 或 VHDL 中使用+运算符,就像在任何其他编程语言中一样,并且相信综合工具会处理实现过程。
工具可能会将加法和减法操作放入基本的 LUT 中。对于乘法,工具会使用触发器来实现左移方法,或者使用 LUT 或 DSP 块(如果可用)进行更复杂的计算。如第九章所讨论,DSP 块在加速大规模乘法-累加运算时非常有用,而且不会占用大量 LUT 逻辑。最后,除法操作需要寄存器来实现右移方法,或者使用预计算表的块 RAM,或者是 LUT。
然而,数学不仅仅是加法、减法、乘法和除法。看看你的计算器,考虑一下我们没有讨论的所有其他操作:正弦、余弦、平方根等等。在 FPGA 上运行这些操作当然是可能的,但它会变得复杂,并超出了本书的范围。如果你有兴趣了解更多,实际上有专门的算法可以实例化来执行这些操作,例如坐标旋转数字计算机(CORDIC)。你可以在 GitHub 上搜索FPGA CORDIC,会找到许多例子。
除了在 FPGA 上实际实现更复杂的数学运算外,如果有选择,可能值得将输入发送到专用处理器进行计算,然后将结果返回到 FPGA 逻辑中。我们将在下一节讨论浮点运算与定点运算,但处理器执行浮点运算的能力远超 FPGA。这个处理器可以是外部的专用组件,也可以是 FPGA 内部的。如果是内部的,它被称为硬核处理器或软核处理器,具体取决于它是否是专用硅片。
许多现代 FPGA 都具有内部硬件 ARM 核心。这种将 FPGA 逻辑与专用处理器结合的组件通常被称为系统级芯片(SoC)。你可以将来自 FPGA 查找表(LUT)/触发器逻辑的操作发送到 ARM 核心进行处理,ARM 核心会执行所需的操作并返回结果。这种解决方案更多是处理数据而不是进行数学运算,因为你可能需要为每个输入和输出设置 FIFO。使用单独的处理器是一个高级话题,但在高端应用中它非常有价值。
与小数一起工作
到目前为止,我们一直在处理整数,但在许多应用中,你需要让 FPGA 处理带有小数部分的数字。在本节中,我们将探讨如何使用非整数进行数学运算。首先,我们需要考虑如何使用二进制数字表示分数数字。有两种可能的系统可以选择:浮点数和定点数。
绝大多数电子设备中的数学运算使用浮点运算,因为大多数 CPU 设计用来处理浮点数。浮点的关键在于基数(小数点)是“浮动”的,取决于需要多少精度。我们不会详细讨论这如何运作,但关键是,使用 32 位可以表示一个巨大的数值范围,且具有不同的精度;你可以用高精度表示非常小的数字,或者用较低精度表示非常大的数字。另一方面,定点运算有一个固定的基数,这意味着有固定数量的整数位和固定数量的小数位。
FPGA可以执行浮点运算,但通常比定点运算需要更多的资源。因此,大多数 FPGA 数学运算使用定点运算,因此本章剩余部分将聚焦于定点运算。
为了说明定点表示法是如何工作的,我们来看一个例子。假设我们在 FPGA 中为表示一个数字分配了 3 位。到目前为止,我们一直假设每个比特的变化都代表一个整数值。例如,从 001 到 010,表示从 1 到 2。但我们只是随便决定每个比特代表一个整数值。我们也可以决定每个位的值是别的东西,比如 0.5。这样,001 就等于 0.5,010 就是 1.0,011 就是 1.5,以此类推。现在我们有了一个定点系统,其中最右边的比特表示数字的小数部分,另外两位则表示整数部分。我们还可以以其他方式解读这些比特,从而获得不同的定点表示。表 10-4 显示了 3 位无符号数的最常见小数表示方法。
表 10-4: 3 位无符号定点表示的可能性
| 比特 | U3.0 | U2.1 | U1.2 | U0.3 |
|---|---|---|---|---|
| 000 | 0 | 0 | 0 | 0 |
| 001 | 1 | 0.5 | 0.25 | 0.125 |
| 010 | 2 | 1.0 | 0.50 | 0.250 |
| 011 | 3 | 1.5 | 0.75 | 0.375 |
| 100 | 4 | 2.0 | 1.00 | 0.500 |
| 101 | 5 | 2.5 | 1.25 | 0.625 |
| 110 | 6 | 3.0 | 1.50 | 0.750 |
| 111 | 7 | 3.5 | 1.75 | 0.875 |
表 10-4 中的标题使用了修改版的 Q 表示法,这是一种指定二进制定点数格式参数的方法。例如,在 Q 表示法中,Q1.2 表示 1 位用于数字的整数部分,2 位用于小数部分。标准 Q 表示法假设值是带符号的;然而,在 FPGA 中,使用无符号和带符号值是非常常见的。这就是为什么我更喜欢使用带有前导字符的表示法来指定值是否为带符号(S)或无符号(U)。因此,S3.1 表示一个带符号值,其中 3 个比特用于整数部分,1 个比特用于小数部分,U4.8 表示一个无符号值,其中 4 个比特用于整数部分,8 个比特用于小数部分。
在表 10-4 中,U3.0 列是我们熟悉的;所有 3 个比特分配给数字的整数部分,因此我们只有整数。接下来考虑下一列 U2.1\。它是无符号的,其中 2 个比特用于整数部分,1 个比特用于小数部分。这意味着整数部分的取值范围可以是 00、01、10、11,小数部分的取值范围可以是 0 或 1。为了找出这些值的可能性,只需将原始的 U3.0 值除以 2。例如,111 在 U3.0 中是 7,但在 U2.1 中是 3.5(7 / 2 = 3.5)。一般来说,当有 N 个比特分配给数字的小数部分时,你将整数表示除以 2^N 来确定定点值。因此,111 在 U0.3 中是 7 / 2³ = 7 / 8 = 0.875。
在表 10-4 中,我们将所有值都视为无符号的。表 10-5 显示了在使用带符号数据类型时解释相同 3 个比特的最常见方式。
表 10-5: 3 位符号定点数可能性
| 位 | S3.0 | S2.1 | S1.2 | S0.3 |
|---|---|---|---|---|
| 000 | 0 | 0 | 0 | 0 |
| 001 | 1 | 0.5 | 0.25 | 0.125 |
| 010 | 2 | 1.0 | 0.50 | 0.250 |
| 011 | 3 | 1.5 | 0.75 | 0.375 |
| 100 | –4 | –2.0 | –1.00 | –0.500 |
| 101 | –3 | –1.5 | –0.75 | –0.375 |
| 110 | –2 | –1.0 | –0.50 | –0.250 |
| 111 | –1 | –0.5 | –0.25 | –0.125 |
S3.0 列显示了我们在本章早些时候看到的相同符号的整数值,参见表 10-1。我们可以通过将 S3.0 列中的值分别除以 2 得到 S2.1,除以 4 得到 S1.2,除以 8 得到 S0.3。
这里关于处理定点数的关键点是:当你对二进制数据进行操作时,二进制操作的行为不会因其表示形式而改变。加法、减法、乘法和除法的工作方式与之前将数字视为整数时完全相同。然而,要获得定点值的正确答案,仍然需要建立一些额外的规则。
你会注意到,在本章剩余部分,我尽量跟踪代码示例中的小数位。我发现,在我的 Verilog 或 VHDL 代码中添加注释记录数学操作的小数位宽非常有帮助。例如,当将两个 3 位数相加得到一个 4 位结果时,我会加上类似 // U2.1 + U2.1 = U3.1 的注释,这样我就知道小数位和整数位的宽度。这在进行多个数学操作并且操作过程中宽度可能发生变化时特别有用。
定点加法和减法
当执行带有定点小数的加法或减法时,实际过程并不会改变。数据仍然是二进制的。然而,在涉及小数时,我们必须应用另一条规则:
规则 #5 在进行加法或减法时,小数位数必须匹配。
小数点右侧的位数决定了每个位的值或权重,所以如果你尝试将两个具有不同小数位宽的输入相加或相减——例如,一个是 U3.1 输入,一个是 U4.0 输入——你会得到错误的答案。我们可以在下面的代码中看到这一点:
Verilog
// U3.1 + U4.0 = U4.1 (Rule #5 violation)
i1_u4 = 4'b0011;
i2_u4 = 4'b0011;
o_u5 = i1_u4 + i2_u4;
❶ $display("Ex20: %2.3f + %2.3f = %2.3f", i1_u4/2.0, i2_u4, o_u5/2.0);
VHDL
-- U3.1 + U4.0 = U4.1 (Rule #5 violation)
i1_u4 := "0011";
i2_u4 := "0011";
i1_u5 := resize(i1_u4, i1_u5'length);
i2_u5 := resize(i2_u4, i2_u5'length);
o_u5 := i1_u5 + i2_u5;
❶ real1 := real(to_integer(i1_u5)) / 2.0;
real2 := real(to_integer(i2_u5));
real3 := real(to_integer(o_u5)) / 2.0;
report "Ex20: " & str(real1) & " + " & str(real2) & " = " & str(real3);
这是输出结果:
Verilog
# Ex20: 1.500 + 3.000 = 3.000
VHDL
# ** Note: Ex20: 1.500 + 3.000 = 3.000
# Time: 0 ns Iteration: 0 Instance: /math_examples
Ex20 显示了不遵守规则 #5 的效果。在这里,我们试图将一个 U3.1 加到一个 U4.0 上。这将导致问题,因为被加在一起的位的权重没有匹配。事实上,打印输出告诉我们 1.5 + 3 = 3,所以显然出了问题。
请注意,我们已经将输入 U3.1 和输出 U5.1 除以 2.0,以正确打印这些定点值❶。对于 Verilog,我们可以简单地对无符号输入进行除法,并使用%f格式化结果为浮点数。在 VHDL 中,转换要复杂一些。首先,我们需要切换到real数据类型,它用于带小数的数字,然后我们就可以除以 2.0 来进行打印。
要修复这个例子,我们需要调整其中一个输入,使其与另一个输入具有相同的小数位数。我们可以将第一个输入从 U3.1 改为 U4.0,以匹配第二个输入,或者将第二个输入从 U4.0 改为 U4.1。以下代码尝试了这两种选项:
Verilog
// Convert U3.1 to U4.0
// U4.0 + U4.0 = U5.0 (Rule #5 fix, using truncation)
i1_u4 = 4'b0011;
i2_u4 = 4'b0011;
❶ i1_u4 = i1_u4 >> 1; // Convert U3.1 to U4.0 by dropping decimal
o_u5 = i1_u4 + i2_u4;
$display("Ex21: %2.3f + %2.3f = %2.3f", i1_u4, i2_u4, o_u5);
// Or Convert U4.0 to U4.1
// U3.1 + U4.1 = U5.1 (Rule #5 fix, using expansion)
i1_u4 = 4'b0011;
i2_u4 = 4'b0011;
❷ i2_u5 = i2_u4 << 1;
o_u6 = i1_u4 + i2_u5;
$display("Ex22: %2.3f + %2.3f = %2.3f", i1_u4/2.0, i2_u5/2.0, o_u6/2.0);
VHDL
-- Convert U3.1 to U4.0
-- U4.0 + U4.0 = U5.0 (Rule #5 fix, using truncation)
i1_u4 := "0011";
i2_u4 := "0011";
❶ i1_u4 := shift_right(i1_u4, 1); -- Convert U3.1 to U4.0
i1_u5 := resize(i1_u4, i1_u5'length);
i2_u5 := resize(i2_u4, i2_u5'length);
o_u5 := i1_u5 + i2_u5;
real1 := real(to_integer(i1_u5));
real2 := real(to_integer(i2_u5));
real3 := real(to_integer(o_u5));
report "Ex21: " & str(real1) & " + " & str(real2) & " = " & str(real3);
-- Or Convert U4.0 to U4.1
-- U3.1 + U4.1 = U5.1 (Rule #4 fix, using expansion)
i1_u4 := "0011";
i2_u4 := "0011";
i1_u6 := resize(i1_u4, i1_u6'length); -- expand for adding
i2_u6 := resize(i2_u4, i2_u6'length); -- expand for adding
❷ i2_u6 := shift_left(i2_u6, 1); -- Convert 4.0 to 4.1
o_u6 := i1_u6 + i2_u6; real1 := real(to_integer(i1_u6)) / 2.0;
real2 := real(to_integer(i2_u6)) / 2.0;
real3 := real(to_integer(o_u6)) / 2.0;
report "Ex22: " & str(real1) & " + " & str(real2) & " = " & str(real3);
这是输出:
Verilog
# Ex21: 1.000 + 3.000 = 4.000
# Ex22: 1.500 + 3.000 = 4.500
VHDL
# ** Note: Ex21: 1.000 + 3.000 = 4.000
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex22: 1.500 + 3.000 = 4.500
# Time: 0 ns Iteration: 0 Instance: /math_examples
在Ex21中,我们将 U3.1 转换为 U4.0,实质上丢弃了小数点。我们通过将位移 1 位到右来实现这一点❶。但是考虑这个操作的影响:我们正在去掉最不重要的位,如果该位是 1,那么我们就丢失了这部分数据。实际上,我们正在执行向下取整的舍入操作。我们可以看到,输入最初是 1.5,但去掉小数点后变为 1.0。数学上是正确的,1.0 + 3.0 = 4.0,但我们已经截断了输入。
Ex22展示了一个更好的解决方案,它保持了所有输入的精度。我们不是将第一个输入向右移,而是将第二个输入向左移❷。这样填充了最不重要的位为 0,将第二个输入从 U4.0 转换为 U4.1。注意,这意味着第二个输入现在占用了总共 5 个位。我们需要确保对其进行调整,否则在向左移位的过程中可能会丢失最重要位的数据。此外,我们的输出现在必须是 6 个位,以确保不违反规则#1。
现在,由于两个输入的小数宽度已经匹配且没有丢失精度,我们可以成功地计算出 1.5 + 3.0 = 4.5。如果你不想舍去任何小数值,扩展输入使其匹配是最好的解决方案。
注意
减法操作的定点数遵循与加法相同的规则,因此我们在这里不考虑具体示例。遵循本章介绍的规则,你的减法操作将按预期工作。
定点数乘法
使用定点数进行乘法运算不需要进行移位以匹配小数位宽。相反,只要我们跟踪输入宽度并适当调整输出宽度,就可以直接将两个输入相乘。我们已经有了一个关于乘法的规则:
规则 #4 进行乘法运算时,输出位宽必须至少是输入位宽的总和(符号扩展前)。
现在,我们需要添加另一个规则来处理定点数:
规则 #6 进行定点数乘法时,分别将输入的整数部分位宽和小数部分位宽相加,以确定输出格式。
例如,如果你试图将一个 U3.5 乘以一个 U1.7,结果格式为 U4.12。我们通过将整数部分(3 + 1 = 4)和小数部分(5 + 7 = 12)相加来确定输出宽度格式。对于带符号值,处理方法相同,所以 S3.0 × S2.4 = S5.4。请注意,我们仍然遵循规则 #4,因为输出宽度将是输入宽度的总和。只是整数部分和小数部分会分别处理。
让我们来看一些 Verilog 和 VHDL 的示例:
Verilog
// U2.2 * U3.1 = U5.3
i1_u4 = 4'b0101;
i2_u4 = 4'b1011;
o_u8 = i1_u4 * i2_u4;
$display("Ex23: %2.3f * %2.3f = %2.3f", i1_u4/4.0, i2_u4/2.0, o_u8/8.0);
// S2.2 * S4.0 = S6.2
i1_s4 = 4'b0110;
i2_s4 = 4'b1010;
o_s8 = i1_s4 * i2_s4;
$display("Ex24: %2.3f * %2.3f = %2.3f", i1_s4/4.0, i2_s4, o_s8/4.0);
VHDL
-- U2.2 * U3.1 = U5.3
i1_u4 := "0101";
i2_u4 := "1011";
o_u8 := i1_u4 * i2_u4;
real1 := real(to_integer(i1_u4)) / 4.0;
real2 := real(to_integer(i2_u4)) / 2.0;
real3 := real(to_integer(o_u8)) / 8.0;
report "Ex23: " & str(real1) & " * " & str(real2) & " = " & str(real3);
-- S2.2 * S4.0 = S6.2
i1_s4 := "0110";
i2_s4 := "1010";
o_s8 := i1_s4 * i2_s4;
real1 := real(to_integer(i1_s4)) / 4.0;
real2 := real(to_integer(i2_s4));
real3 := real(to_integer(o_s8)) / 4.0;
report "Ex24: " & str(real1) & " * " & str(real2) & " = " & str(real3);
这是输出结果:
Verilog
# Ex23: 1.250 * 5.500 = 6.875
# Ex24: 1.500 * -6.000 = -9.000
VHDL
# ** Note: Ex23: 1.250 * 5.500 = 6.875
# Time: 0 ns Iteration: 0 Instance: /math_examples
# ** Note: Ex24: 1.500 * -6.000 = -9.000
# Time: 0 ns Iteration: 0 Instance: /math_examples
在 Ex23 中,我们将一个 U2.2 乘以一个 U3.1,得到一个结果为 U5.3。我们可以从打印输出中看到,答案是正确的:1.25 × 5.5 = 6.875。与加法示例一样,请注意,我们必须先将值进行除法运算才能正确打印它们。我们将 U2.2 除以 4,U3.1 除以 2,U5.3 除以 8。在 Ex24 中,我们使用相同的技术来进行带符号数的乘法运算。我们将 1.5 乘以 -6.0,得到 -9.0,表示为 S2.2 × S4.0 = S6.2。
总结
由于 FPGA 以其能够在快速时钟频率和并行处理下执行大量计算而闻名,许多常见的 FPGA 应用都需要进行加法、减法、乘法和除法运算。在你的 FPGA 中,这些操作可能涉及查找表(LUT)、移位寄存器或 DSP 模块。然而,比知道操作是如何实现的更重要的是理解输入和输出是如何存储的,以及在编写 Verilog 或 VHDL 代码时,这些二进制数字代表的含义。它们是带符号的还是无符号的?整数还是定点数?
在本章中,我们已经制定了一套规则,用于成功执行 FPGA 数学运算并解释结果。它们是:
规则 #1 在进行加法或减法时,结果的位宽应该比最大的输入宽度大至少 1 位,且在进行符号扩展之前。符号扩展应用后,输入和输出的位宽应该完全匹配。
规则 #2 输入和输出之间的类型必须匹配。
规则 #3 在进行减法时,使用带符号的输入和输出。
规则 #4 在进行乘法运算时,输出的位宽必须至少是输入位宽的总和(在符号扩展之前)。
规则 #5 在进行加法或减法时,小数位宽必须匹配。
规则 #6 在进行定点数乘法时,分别将输入的整数部分位宽和小数部分位宽相加,得到输出格式。
这些规则并没有涵盖 FPGA 中数学运算的所有细节,但它们涵盖了你需要正确处理的主要细节。如果你遵循这六条规则,你的计算结果更有可能是正确的。每当你进行数学运算时,添加测试将帮助确保一切按预期工作。
第四章:11 通过 I/O 和 SERDES 进出数据

本书一直专注于 FPGA 的内部结构,这也是 FPGA 设计过程中的典型情况。FPGA 设计主要围绕编写面向内部组件(如触发器、查找表(LUTs)、块 RAM 和 DSP 块)的 Verilog 或 VHDL 代码。但设备边缘发生了什么呢?数据是如何进出 FPGA 的?
将数据输入和输出到 FPGA 涉及的复杂性超乎想象。根据我的经验,这也是大多数棘手的 FPGA 设计问题出现的地方。理解输入/输出 (I/O)的工作原理将帮助你解决这些问题。你将能够花更少的时间担心外部接口,更多的时间解决内部任务。
使用 I/O 是“软件人员”和“硬件人员”之间的分界线。你需要了解你正在接口的电信号的细节,以便正确配置 FPGA 引脚。它们在什么电压下工作?信号是单端还是差分的?(那到底意味着什么?)你如何使用双倍数据速率或串行/解串行器以非常高的速度发送数据?本章将回答这些问题以及更多内容。即使你没有电气工程背景,也会学到将 FPGA 与外部世界连接的基础知识。
使用 GPIO 引脚
FPGA 上的大多数引脚是通用输入输出(GPIO)引脚,意味着它们可以作为数字输入或输出。我们在本书的项目中使用了这些引脚来接收来自按钮的信号,并输出信号点亮 LED,但我们并未关注它们实际如何工作的细节。在这一节中,我们将探讨 GPIO 引脚如何与 FPGA 接口,以及如何将它们配置为输入数据、输出数据或两者兼有。
当我刚开始接触 FPGA 设计时,我完全不了解引脚配置中的细微差别。这里有许多需要调整的参数和设置。彻底理解你的 GPIO 引脚非常重要,尤其是在高速设计中,因为在整个设计中保持信号完整性和性能始于引脚。
I/O 缓冲器
GPIO 引脚通过缓冲器与 FPGA 接口,缓冲器是电子电路元件,用于将输入与输出隔离。这些缓冲器使得你可以将一些引脚配置为输入,而另一些配置为输出。正如你很快会看到的,它们甚至允许你在 FPGA 运行时将引脚在输入和输出之间切换。图 11-1 显示了一个简化的 Intel FPGA 上 GPIO 引脚接口的框图,说明了缓冲器如何充当引脚和 FPGA 内部逻辑之间的中介。

图 11-1:简化的 GPIO 模块图
图像右侧的框(内含 X)代表物理引脚。引脚的左边紧邻一个标记为 Buffer 的模块,代表 I/O 缓冲区。它包含两个主要组件,由三角形表示。指向右侧的三角形是输出缓冲区;它将数据推送到引脚上。指向左侧的三角形是输入缓冲区;它将数据从引脚传送到 FPGA。
图表最左边是一个标记为 GPIO 的模块,代表与引脚通过缓冲区直接交互的内部 FPGA 逻辑。这里需要注意的主要路径是 OE,代表输出使能。它控制输出缓冲区的开关,决定引脚是作为输出还是输入。当 OE 为高时,输出缓冲区会将输出路径上的数据驱动到引脚上。如果输出路径上的数据为低,引脚也会是低电平;如果输出路径上的数据为高,引脚则是高电平。当 OE 为低时,引脚被配置为输入模式,因此输出缓冲区停止将其输入传递到输出。此时,缓冲区的输出变为高阻抗(也称为高 Z或三态),意味着它几乎不接受任何电流。高阻抗的输出缓冲区不再影响引脚上的任何操作。相反,引脚的状态由外部输入信号决定。输入缓冲区可以自由读取该信号,并将其传递到输入路径,以供 FPGA 内部使用。
表 11-1 展示了一个输出缓冲区的真值表,总结了该行为。
表 11-1: 输出缓冲区的真值表
| 输入 | OE | 输出 |
|---|---|---|
| 0 | 0 | Z |
| 1 | 0 | Z |
| 0 | 1 | 0 |
| 1 | 1 | 1 |
从这个表格可以看出,当 OE 为高时,缓冲区输入的值会被直接传递到输出。但当 OE 为低时,缓冲区的输出变为高阻抗(通常用Z表示),无论输入值是什么。
在本书中的项目中,我们已在设计代码的顶层定义了输入和输出信号。输入通过关键字input(Verilog)或in(VHDL)表示,而输出则通过关键字output(Verilog)或out(VHDL)表示。在构建 FPGA 时,综合工具会看到每个方向定义了哪些信号,并相应地设置缓冲区。如果信号是输入,OE 将被设置为低电平。如果信号是输出,OE 将被设置为高电平。然后,在布置和布线过程中,物理约束文件将信号映射到 FPGA 的特定引脚。这就是 GPIO 引脚如何配置为专用输入或输出引脚的方式。
半双工通信的双向数据
虽然设计中的大多数引脚通常被固定为输入或输出,但 GPIO 引脚可以配置为双向,这意味着它可以在同一设计中在输入和输出之间切换。当 FPGA 需要通过双向引脚输出数据时,它会将 OE 信号驱动为高电平,然后将要传输的数据放到输出路径上。当 FPGA 需要通过双向引脚接收数据作为输入时,它会将 OE 信号驱动为低电平。这将把输出缓冲区设置为三态(高阻抗),使 FPGA 能够监听引脚上的数据并将其传递到输入路径。当引脚配置为这种双向时,它充当的是收发器,而不仅仅是一个发射器或接收器。
双向引脚对于半双工通信非常有用,其中两个设备通过一个共享的传输线(一个引脚)交换数据。任何一个设备都可以充当发射器,但每次只能有一个设备进行传输,而另一个设备进行接收。这与全双工通信相对,后者允许两个设备同时传输和接收数据。全双工通信需要两条传输线(两个引脚),一条用于从设备 1 到设备 2 发送数据,另一条用于从设备 2 到设备 1 发送数据,这与半双工通信中的单一传输线不同。
半双工通信的一个常见例子是双向无线电。讲解者只有在按住无线电上的按钮时才能传输信息。当讲解者在传输时,听者无法进行传输,因此讲解者和听者必须商定谁先发言。这也是为什么电影中人们在使用对讲机时总是会说“Over”——这是一个信号,表示讲解者已经讲完,听者现在可以自由回应。
对于物理线路,如果两端没有轮流共享通信通道,就会发生数据碰撞。这种碰撞可能会损坏数据,导致任何一方都无法接收到信息。为了避免这种情况,设备必须达成一种协议,即一组管理通信的规则。协议确定了设备如何发起事务,建立了其他设备在时间上有明确的时机进行回应(类似于说“完毕”),等等。有些协议甚至能够通过检测数据损坏并重新发送损坏的数据来处理数据碰撞,尽管这需要额外的复杂性。
半双工通信通常比使用专用的发送和接收通道更复杂,但仍然非常常见。例如,I2C(或 I²C,发音为“eye-squared-see”或“eye-two-see”,是集成电路间通信的缩写)是一种广泛使用的半双工协议。无数独特的集成电路——包括 ADC、DAC、加速度计、陀螺仪、温度传感器、微控制器等——都使用 I2C 来通信,因为它相对简单易实现,并且由于其半双工的特性,只需要极少的引脚。I2C 只使用两个引脚:时钟和数据,这也是为什么它有时被称为TWI(双线接口)的原因。
双向引脚实现
让我们看看如何使用 Verilog 或 VHDL 编写双向引脚代码。在查看此代码时,请参考图 11-2,以查看代码中的信号如何与图 11-1 中的框图匹配:
Verilog
❶ module bidirectional(inout io_Data,
`--snip--`
❷ assign w_RX_Data = io_Data;
❸ assign io_Data = w_TX_En ? w_TX_Data : 1'bZ;
`--snip--`
VHDL
entity bidirectional is
❶ port (io_Data : inout std_logic,
`--snip--`
❷ w_RX_Data <= io_Data;
❸ io_Data <= w_TX_Data when w_TX_En = '1' else 'Z';
`--snip--`
我们在 Verilog 和 VHDL 中使用关键字 inout 声明双向引脚 (io_Data) ❶。此时我们可以想象自己已经位于引脚处,正如 图 11-2 中标签 io_Data 所示。我们需要在物理约束文件中将这个信号映射到 FPGA 的一个引脚。在输入功能方面,我们仅需通过赋值操作将引脚的数据驱动到 w_RX_Data ❷。在输出端,我们通过使用信号 w_TX_En 来选择性地启用输出缓冲区 ❸。我们在 Verilog 中使用三元操作符,或在 VHDL 中使用条件赋值。驱动到 io_Data 上的数据将是 w_TX_Data 或高阻抗(在 Verilog 中表示为 1'bZ,在 VHDL 中为 'Z'),具体取决于输出使能信号 (w_TX_En) 的状态。这种代码模式对于双向数据来说非常常见,综合工具足够智能,能够识别并推断出一个 I/O 缓冲区。

图 11-2:带标签的双向接口
你可能会注意到,任何驱动到 w_TX_Data 的数据都会被接收到 w_RX_Data,因为它们通过 io_Data 连接在一起。你需要在代码的其他地方处理这个问题,通过告知接收器在 w_TX_En 为高电平时忽略 io_Data 上的任何数据。否则,你的 FPGA 会“听到”自己发出的数据。
电气特性
对于每个 GPIO 引脚,你可以指定许多不同的电气特性。我们将讨论三种:工作电压、驱动强度和上升/下降速率。我们还将看看单端和差分数据传输之间的电气差异。
阅读时,请记住,这些并不是你可以控制的唯一引脚设置。例如,你还可以将引脚设置为开漏,加入上拉或下拉电阻,或者终端电阻等。你的 FPGA 的 I/O 可以以多种方式配置,这取决于设备本身内置的 GPIO 属性。如果你需要实现简单信号接口之外的功能,建议查阅相关数据手册,确保你正确地使用 I/O 缓冲器。关于 FPGA I/O 的所有具体信息通常可以在 I/O 用户手册中找到,这是一个很好的参考,可以帮助你了解 FPGA 能够与哪些类型的电子设备进行接口。
操作电压
操作电压指定引脚在逻辑 1 输出时将被驱动到的电压,并设置逻辑 1 输入的预期电压。最常见的是,FPGA 引脚使用 0V 表示 0,3.3V 表示 1。这种标准称为LVCMOS33(LVCMOS 是低电压互补金属氧化物半导体的缩写)。你还可能遇到另一种标准:0V 表示 0,5V 表示 1,这称为TTL(晶体管–晶体管逻辑的缩写)。如今,TTL 在 FPGA 中不太常见,因为许多 FPGA 内部不允许使用高达 5V 的电压。还有 LVCMOS25 标准,它使用 0V 表示 0,2.5V 表示 1。
LVCMOS33、LVCMOS25 和 TTL 都是单端I/O 标准的例子,意味着相关信号是以地为参考的。如你很快会看到的,还有差分标准,其中信号不以地为参考。单端标准的种类比我提到的三种更多。一个典型的 FPGA 支持大约十几种单端 I/O 标准。
关于设置操作电压,有一个重要的注意事项:同一组引脚上的所有信号必须使用相同的操作电压。银行是指一组引脚,它们都使用一个共同的参考电压,通常称为VCCIO。你的 FPGA 上可能有八个银行,每个银行可以使用独特的操作电压。例如,你可能将银行 1 配置为使用 1.8V,将银行 2 配置为使用 3.3V,将银行 3 配置为使用 2.5V。关键在于同一银行内的所有引脚必须在相同电压下工作。你不能将一个 LVCMOS33 引脚与 LVCMOS25 引脚放在同一个银行,因为前者需要 3.3V 的 VCCIO,而后者需要 2.5V 的 VCCIO。在进行原理图审查时,始终检查确保每个银行的信号共享相同的参考电压。如果你尝试在同一银行中混合不同的电压,放置与布线工具很可能会生成错误,或者至少会发出一个非常强烈的警告。
驱动强度
驱动强度决定了一个引脚可以驱动的电流大小(以毫安 mA 为单位)。例如,一个设置为 8 mA 驱动强度的引脚将能够吸入或输出最多 8 mA 的电流。驱动强度可以根据每个引脚单独调整,并应足够高以匹配你所连接的电路需求。通常情况下,所有引脚的驱动强度设置可以保留为默认值。除非你有高电流需求,否则不太可能需要修改默认设置。
斜率
斜率设定了输出信号允许变化的速率。通常以定性术语表示,如快、中或慢。斜率设置会影响引脚从 0 变为 1 或从 1 变为 0 的速度。和驱动强度一样,斜率通常可以保留为每个引脚的默认设置。唯一的例外是,当你需要与某个要求非常快速数据传输速率的组件进行接口时,这时你可能需要选择最快的选项。然而,选择更快的斜率可能会增加系统噪声,因此除非确实需要,否则不建议使用更快的斜率。
差分信号传输
差分信号传输是一种数据传输方法,其中有两个信号,它们不是参考地面,而是相互参考。如我之前所提,这与单端信号传输形成对比,后者只有一个数据信号参考地面。图 11-3 展示了它们之间的区别。

图 11-3:单端与差分接口
图的上半部分展示了单端配置:我们有设备 1 通过单根线将数据传输给设备 2,另有一根线用于地面路径。地面线没有数据,但它是保持设备间一致地面参考所必需的。数据通过数据线以电压的形式传送:0 V 表示 0,或根据工作电压为 1 的其他值(如 3.3 V)。如果我们想增加另一条数据路径,只需在两个设备之间再加一根单独的线;地面参考可适用于多个数据路径。
图片的下半部分展示了一个差分配置。在这里,我们没有地参考线通过设备之间传递。相反,我们有一对数据线。注意到上行线起始处的气泡,这是设备 1 的传输缓冲区输出端。这看起来像我们在第三章中看到的 NOT 门和 NAND 门的气泡,且这是我们拥有差分对的常见标志。如果接收端的+端和–端之间的电压差超过某个阈值并为正电压,那么信号被解码为 1;如果电压差为负并低于某个阈值,则信号被解码为 0。具体细节取决于差分标准。例如,TIA/EIA-644,通常称为LVDS(低电压差分信号传输),规定两条电线之间的电压差应约为+/-350 毫伏(mV)。这个电压比大多数单端信号使用的电压要低得多,这意味着系统可以在更低的功耗下工作,这是差分通信相对于单端通信的一个优势。典型的 FPGA 支持的差分标准数量大致与单端标准相同(大约十几种)。
你可能已经注意到一个缺点,那就是差分通信每条数据路径需要使用两倍数量的电线。对于单端数据传输,每条数据路径只需要一根电线。如果我们想要 10 条数据路径,我们需要 10 根电线(通常还至少需要 1 根地线)。而要使用差分信号创建相同的 10 条数据路径,我们需要 20 根电线(但不需要地线)。这些额外的电线会增加成本,并需要更大的连接器。然而,差分信号具有一些独特的特性,在某些应用中,这种折衷是值得的。
一个重要的优势是,差分信号比单端信号对噪声或电磁干扰(EMI)的免疫能力要强得多。电磁干扰是一种由电磁场变化引起的现象——例如,来自附近微波炉、手机或电力线的变化——它会在其他系统中造成干扰。你可以把一根传输数据的电线想象成一个小天线,它会接收各种不需要的电信号,产生噪声,这些噪声会在电线上以电压波动的形式表现出来。单端信号上的电压波动足够大时,可能会破坏数据,使 0 变成 1,或者 1 变成 0。然而,对于差分信号来说,电压波动会对两条电线产生相同的影响,这意味着两根电线之间的电压差保持不变。由于重要的是电压差,而不是电压的精确值,因此噪声会被有效地抵消。
差分通信的另一个好处是发射器和接收器可以参考不同的地电压,同时仍然能够可靠地发送和接收数据。这可能听起来有些奇怪,但地电压并不总是完全为 0V。系统中的地电压可能会受到噪声的影响,就像数据线一样,因此如果你依赖地电压作为系统中唯一的参考电压,就可能出现问题。特别是,对于相距较远的两个设备,很难维持一个共同的地电压参考,这也是为什么差分信号常常被用于长距离传输数据的原因。例如,RS-485 作为一种差分电气标准,可以在近 1 英里的距离内以每秒 10 兆比特(Mb/s)的速度传输数据,而单端信号无法实现这一点。即使在较短的距离下,也有一些情况,其中一个系统可能根本不以地电压为参考,而是浮空或与地电压隔离。为了与隔离系统通信,你需要一种不依赖于共享地电压参考的通信方式,差分通信就是这样一种方式。
差分信号能够以比单端信号更快的速度传输数据。当发射器需要从 0 切换到 1 时,它必须将线路的电压从对应 0 的电压驱动到对应 1 的电压,这个过程需要一定的时间(称为变化速率)。电压之间的差异越大,需要驱动到线路上的电流就越大,过程所需的时间也越长。由于单端协议通常要求在 0 和 1 之间有更宽的电压波动,它们天生比差分协议更慢。例如,LVCMOS33 的电压摆幅为 3.3V,远大于 LVDS 的电压摆幅+/- 350 mV。因此,几乎所有高速应用都使用差分信号。我们将在本章后续讨论 SerDes 时详细了解这一点,但像 USB、SATA 和以太网这样的接口都使用差分信号,以实现尽可能高的数据传输速率。
如何修改引脚设置
如果你想为引脚指定工作电压、驱动强度或变化速率值,或者控制哪些引脚用于单端信号,哪些引脚用于差分信号,应该在物理约束文件中进行配置。回想一下,这个文件列出了 FPGA 的引脚如何与设计中的信号连接。除了指定引脚映射之外,你还可以添加这些其他参数,进一步定义 I/O 行为。以下是一个来自 Lattice 约束文件的片段,其中包含一些附加参数:
LOCATE COMP "o_Data" SITE "A13";
IOBUF PORT "o_Data" IO_TYPE=LVCMOS33 DRIVE=8 SLEWRATE=FAST;
第一行将信号o_Data映射到 A13 引脚。第二行将操作电压设置为LVCMOS33,驱动强度设置为8,上升率设置为FAST。你应参考你特定 FPGA 的约束用户指南,以了解如何设置这些参数;因为语法在不同设备之间并不通用。你也可以在你的 IDE 中使用 GUI 来设置这些参数,而无需学习所需的精确语法。
通过双倍数据速率实现更快的数据传输
快速发送数据是 FPGA 的优势所在,而加速传输的一种方式是使用双倍数据速率(DDR)。到目前为止,我已经指出,FPGA 中的信号应该与时钟的上升沿同步。然而,在双倍数据速率下,信号会在时钟的上升和下降沿发生变化。这使得你可以在相同的时钟频率下发送两倍的数据量,如图 11-4 所示。

图 11-4:单倍数据速率与双倍数据速率
如你所见,在单倍数据速率模式下,数据在每个上升时钟沿发送,你可以在三个时钟周期内移动三个位的数据(D0 到 D2)。相比之下,在双倍数据速率模式下,数据在上升沿和下降沿都发送,你可以在相同的三个时钟周期内发送六个位的数据(D0 到 D5)。这种技术广泛用于 LPDDR 内存,即低功耗双倍数据速率,这是一种常见于计算机、智能手机和其他电子设备中的 RAM。改变时钟的上下沿的数据可提高内存的带宽。
你需要在任何需要使用 DDR 进行数据传输的地方创建双倍数据速率输出(ODDR)缓冲区。不同 FPGA 厂商之间的具体实现有所不同,但我一般建议在 Verilog 或 VHDL 中直接创建这些 ODDR 缓冲区,通过实例化的方式,因为它们的配置并不复杂。作为例子,我们可以看看来自 AMD Virtex-7 库用户指南中的 ODDR 缓冲区实例化模板:
Verilog
ODDR #(
.DDR_CLK_EDGE("OPPOSITE_EDGE"),
.INIT(1'b0), // Initial value of Q: 1'b0 or 1'b1
.SRTYPE("SYNC") // Set/reset type: "SYNC" or "ASYNC"
) ODDR_inst (
❶ .Q(Q), // 1-bit DDR output
.C(C), // 1-bit clock input
.CE(CE), // 1-bit clock enable input
.D1(D1), // 1-bit data input (positive edge)
.D2(D2), // 1-bit data input (negative edge)
.R(R), // 1-bit reset
.S(S) // 1-bit set
);
VHDL
ODDR_inst : ODDR
generic map(
DDR_CLK_EDGE => "OPPOSITE_EDGE",
INIT => '0', -- Initial value for Q port ('1' or '0')
SRTYPE => "SYNC") -- Reset type ("ASYNC" or "SYNC")
port map (
❶ Q => Q, -- 1-bit DDR output
C => C, -- 1-bit clock input
CE => CE, -- 1-bit clock enable input
D1 => D1, -- 1-bit data input (positive edge) D2 => D2, -- 1-bit data input (negative edge)
R => R, -- 1-bit reset input
S => S -- 1-bit set input
);
理解这里的每一行并不关键。最重要的连接是输出引脚本身 ❶;这是ODDR模块连接到引脚的地方。两个数据输入端口,D1和D2,将以交替模式将数据传送到输出引脚。D1在上升(或正)沿驱动,D2在下降(或负)沿驱动。
双倍数据速率可以加速数据传输,但如果你真的想让数据飞速传输,一些 FPGA 配备了一种名为 SerDes 的专用接口,能够实现更高速的输入和输出。接下来,我们将探讨这个令人兴奋的 FPGA 特性。
SerDes
SerDes(即串行器/解串器)是某些(但并非所有)FPGA 的原始功能,负责以极高的速度输入或输出数据,达到每秒吉比特(Gb/s)的传输速率。简单来说,它通过将并行数据流转换为串行数据流进行高速传输。而在接收端,串行数据会被转换回并行数据。正因如此,FPGA 能够以非常快的数据速率与其他设备交换数据。虽然看起来串行传输——一次传输一位数据——比并行传输——一次传输多个比特——速度更快似乎不太直观,但这正是 SerDes 的魔力所在。我们稍后将讨论为什么这会有效。
SerDes 原语有时被称为SerDes 收发器,这反映了它们既可以发送也可以接收数据。话虽如此,SerDes 收发器几乎总是全双工的,意味着它们不必像我们之前在双向通信中看到的那样在发送器和接收器之间来回切换。你通常会将一个数据路径设置为从 FPGA 发出的发送器,另一个设置为输入到 FPGA 的接收器。
SerDes 收发器帮助 FPGA 以其他设备无法实现的速率发送或接收大量数据。这是一个关键特性,使得 FPGA 在接收来自视频摄像头的数据等应用场景中具有吸引力。高分辨率摄像头的像素空间可能为 1,920×1,080,每个像素 32 位数据,并且以 60 Hz 的频率捕获新图像。如果将这些数字相乘,就意味着每秒有 3.9Gb 的未压缩原始数据——非常大!——而有些摄像头甚至可以更高,达到 4K 和 120 Hz。SerDes 收发器使得 FPGA 能够接收大量数据,并以一种可以正确处理的方式解包数据。另一个常见的应用场景是网络通信,在这种情况下,以每秒数百吉比特的速率传输以太网数据包。你可能会有多个 SerDes 收发器共同工作,在一个设备上正确路由数据包,同样,这些数据传输速率在标准 I/O 引脚上是无法实现的。
SerDes 的核心功能是实现并行数据和串行数据之间的转换。为了理解这种转换的必要性,我们需要更详细地了解串行和并行数据传输之间的差异。
并行与串行通信
并行通信意味着我们使用多个通信通道(通常是线路)来发送数据,数据被分割到不同的通道上。串行通信则意味着我们在单一通道上传输数据,每次传输一个比特。图 11-5 展示了两者之间的差异。

图 11-5:并行与串行接口
图的上半部分展示了一个 8 位宽的同步并行接口。同步意味着我们在设备之间传送时钟信号,数据与时钟信号对齐。在这个接口下,我们可以在一个时钟周期内发送整个字节的数据,每根线传输一个比特。在这个例子中,我们传输的是值01100011,或者0x63。虽然在这个例子中我们有八条并行数据路径,但理论上你可以创建任何宽度的并行接口——你可以有一个 2 位宽的接口、一个 32 位宽的接口、一个 64 位宽的接口,或者任何其他任意大小的接口。
图的下半部分展示了相同的数据传输值01100011,但它是通过同步串行流发送的。再次强调,设备之间共享时钟信号,但现在数据通过一根单一的线传输,每个时钟周期传输一个比特。这样,传输0x63需要八个时钟周期,而在并行的情况下,只需一个时钟周期。
由于并行通信允许你在一个时钟周期内传输多个比特,似乎传输数据的并行方式总是能比串行传输在单位时间内发送更多的数据。事实上,随着带宽的增加,并行数据传输会遇到一些严重的限制。物理原理无法轻易地扩展以支持今天高速数据的需求,这也是为什么并行接口今天比过去更少见的原因。
如果你足够老,记得带状打印机的时代,那时它们通过 LPT 端口连接到计算机,LPT 是一种并行接口。另一个例子是老式的 PCI 总线,它是将调制解调器和声卡等设备插入桌面主板的常见方式。这些接口现在已经不常用了,因为它们无法跟上我们对更快数据的需求。
为了说明这一点,让我们考虑一下如何计算像 PCI 这样的并行接口上的数据传输速度(或带宽)。PCI 的第一个版本的时钟频率为 33 MHz,宽度为 32 位,这意味着有 32 根独立的数据线需要在两个设备之间连接。将这些数字相乘,我们得到 1,056Mb/s,或者每秒 132 兆字节(MB/s)的带宽。这对于 1990 年代的计算需求来说是足够的,但对更多数据的需求很快开始增加。举个例子,我们需要更好的显卡,而支持这种数据传输的总线也需要相应增长。PCI 设计师通过将时钟频率加倍到 66 MHz 来回应这一需求,从而将总带宽从 132MB/s 加倍至 264MB/s。这满足了需求几年,但还不够,于是 PCI 接下来将连接器的宽度从 32 位加倍到 64 位,这意味着现在有 64 根数据线。这提供了大约 528MB/s 的带宽,虽然又满足了几年需求,但仍然不够。
到这时,PCI 已经到了收益递减的阶段。对于像 PCI 这样的并行接口,增加数据吞吐量只有两种方式:让总线变宽或提高时钟频率。在 64 位宽时,PCI 连接器已经有几英寸长。要想再宽一些,比如 128 位,连接器将变得非常庞大。将这些连接器布线到电路板上也变得极为复杂。继续加宽总线显然没有意义。
增加时钟速度也面临着巨大的挑战。当你有一个宽的数据总线,并且你要在数据旁边发送时钟信号,如图 11-5 中的同步并行接口那样,你需要保持时钟和数据之间的紧密关系。时钟将被输入到接收端每个触发器的时钟输入中:例如,对于一个 128 位宽的总线,有 128 个独立的触发器,它们都需要相同的时钟。然而,随着并行触发器数量的增加,你开始遇到时钟偏移的问题,时钟由于传播延迟的原因在不同的时间到达每个触发器。
正如我们在第七章中讨论的那样,信号不会瞬间传播;相反,它存在一些延迟,信号传播的距离越长(也就是线路越长),传播延迟就越大。在一个 128 位宽的总线中,时钟信号传递到比特 0 触发器的距离与传递到比特 127 触发器的距离可能大不相同。随着时钟频率的增加,这种差异可能会产生足够的时钟偏移,触发亚稳态条件并导致数据损坏。
显然,并行通信存在问题。你可以达到的速度和一次性发送的位数有着基本的限制。最终,问题归结于需要在数据旁边发送单独的时钟信号。解决方案是将时钟和数据一起串行发送,作为一个单一的组合信号。
自同步信号
将时钟和数据结合成一个信号给你带来了一个叫做自同步信号的东西。创建这个信号的过程有时被称为将时钟嵌入数据中,这是通过 SerDes 实现高速串行数据传输的关键技术。如果你将时钟和数据作为一个信号一起发送,那么时钟偏移问题就不再是问题,因为时钟和数据是同时到达的;它们是同一个信号!随着时钟偏移问题的消失,你可以大幅度提高时钟频率(从而提高数据频率)。
有许多不同的系统,称为编码方案,用于将时钟嵌入数据中。常见的包括曼彻斯特编码、高级数据链路控制(HDLC)和 8B/10B。我们将重点讨论曼彻斯特编码,因为它是一个相对简单的编码方案,可以用来展示如何创建自同步信号。图 11-6 展示了曼彻斯特编码的工作原理。

图 11-6:使用曼彻斯特编码将时钟嵌入数据
要实现曼彻斯特编码,你需要对时钟信号和数据信号进行异或(XOR,异或运算),生成一个结合了这两者的新信号。在任意给定的时钟周期内,数据信号要么是 1,要么是 0,而时钟信号在周期的前一半是 1,后一半是 0。因此,生成的曼彻斯特编码信号也会在时钟周期的中点发生变化,以响应时钟信号的转换。根据该周期内的数据值,曼彻斯特信号要么是先低后高(当数据为 1 时),要么是先高后低(当数据为 0 时)。表 11-2 是基于不同数据/时钟组合的曼彻斯特信号真值表。你可以使用此表来理解图 11-6 中曼彻斯特信号的模式。
表 11-2: 曼彻斯特编码真值表
| 数据 | 时钟 | 曼彻斯特编码 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
这实际上是一个两输入异或逻辑门的真值表,其中输入为数据信号和时钟信号。正如我们在第三章中讨论的,异或执行的是“非此即彼”操作,而不是“既是又是”,因此当恰好一个输入为高电平时输出为高电平,而当两个输入都是高电平或两个输入都是低电平时,输出则为低电平。查看图 11-6 中的波形,可以注意到每当时钟和数据都为高电平时,编码值是低电平;当时钟和数据都为低电平时,编码值也是低电平;而当只有一个输入为高电平时,编码值为高电平。
曼彻斯特编码信号使你可以将时钟信号和数据信号通过一根电缆一起传输。如前所述,这就是实现高速串行数据传输的关键技术。你不再需要担心时钟与数据的对齐问题,因为时钟就是数据,数据就是时钟。
在接收端,你需要将时钟从数据中分离出来,这一过程称为时钟数据恢复(CDR)。这通过使用 XOR 门和接收端的一些小型附加逻辑来实现。然后,你可以将恢复的时钟作为时钟输入提供给触发器,并将恢复的数据送入同一触发器的数据输入。通过这种方式,你可以确保时钟和数据之间的完美同步。我们在并行数据传输中看到的时钟偏移问题消失了,从而使得数据传输速率能够远远超出并行数据传输的极限。
曼彻斯特编码只是生成自时钟信号的一种方式,它是一种简单的编码方案。它并不用于现代更复杂的 SerDes 应用,但它确实具有一些至关重要的特性。例如,曼彻斯特编码的信号保证在每个时钟周期都有转换。如果你正在发送一连串的 0 数据,编码后的信号中仍会有转换。这些转换对于接收端执行时钟数据恢复(CDR)至关重要。如果没有这些保证的转换,接收端将无法锁定输入数据流。它无法知道数据是否以 3 吉比特每秒(Gb/s)的速率传输,或者以 1.5Gb/s 的速率传输,或者是否根本没有传输。
曼彻斯特编码的另一个重要特性是它是直流平衡的,这意味着生成的数据流中高电平和低电平的数量是相等的。这有助于保持信号完整性,并在高速数据传输过程中克服导线上的非理想条件。我们通常认为导线是完美的导体,但实际上并非如此;每根导线都有一定的电容、电感和电阻。在低速数据传输时这些因素不太重要,但当数据速率达到 Gb/s 级别时,就需要考虑这些影响。例如,由于导线中存在电容,导线可以像电容器一样充电。当导线稍微被充电时,例如处于高电平状态,那么将其放电至低电平状态需要更多的能量。理想情况下,你不希望导线被充电:换句话说,你希望保持直流平衡。在 SerDes 中发送相等数量的高电平和低电平转换对于保持良好的信号完整性至关重要,所有时钟和数据编码方案都具备这一特性。
SerDes 的工作原理
现在,我们已经讨论了串行通信相较于并行通信的速度优势,并且了解了如何将时钟和数据合并为一个信号,接下来我们将深入探讨 SerDes 的实际工作原理。图 11-7 展示了一个简化的 SerDes 接口框图。

图 11-7:简化的 SerDes 框图
从整体上看,左侧是一个序列化器,充当发送器,右侧是一个反序列化器,充当接收器。我们有时钟和数据信号输入到左侧的序列化器,而时钟和数据信号则从右侧的反序列化器输出。这就是我们在做 SerDes 时真正想要实现的:从发送器发送一些数据和时钟信号到接收器。然而,要以高速数据传输进行这一操作,需要比我们在简单输入输出缓冲区中看到的更多的功能。
首先,注意在发送端的左下角有一个锁相环(PLL),位于图 11-7 中。通常这是一个专门为 SerDes 收发器设计的 PLL,它使用参考时钟(Clk In)生成将驱动序列化器的时钟。这个时钟决定了 SerDes 的整体运行速率。你实际想要发送的数据(Data In)以并行形式进入 SerDes 模块。我这里画了四条线,但也可以有任意数量的线。序列化器接收 PLL 的输出和并行数据,使用编码协议进行编码,并以所需的 SerDes 速率生成串行数据流。例如,如果你有一个并行接口,每次接受 4 位数据,时钟频率为 250 MHz,那么序列化器将生成一个该数据的串行版本,可以以 1Gb/s 的速度传输,即该速度的四倍。
注意
根据编码方案,实际的串行数据流可能会以超过 1Gb/s 的速度运行。例如,8B/10B 编码方案将 8 位数据编码为 10 位数据。这样做有两个目的:确保时钟中的跃迁,从而在接收器端进行时钟数据恢复,以及保持直流平衡。然而,将 8 位数据转化为 10 位数据会增加 20%的开销,因此,为了以 1Gb/s 的速度发送数据,我们需要以 1.2Gb/s 的速度发送实际的串行数据流。
接下来是输出阶段。注意,输出阶段包含一个差分输出缓冲区。出于之前讨论的原因,例如能够以较高的速率发送数据、低功耗以及抗噪声能力,SerDes 收发器使用差分数据。输出阶段还会执行一些额外的信号调理,以提高信号的完整性。一旦信号的调理完成,它就会通过数据通道传输。
以高速发送数据要求数据路径各个部分进行优化,包括数据通道本身。对于铜线,必须控制材料的阻抗,以确保良好的信号完整性。通道也可以使用不同于铜的材料。例如,可以使用光纤,其中光而非电流通过细小的玻璃或塑料线传输。光纤提供了出色的信号完整性,并且免受电磁干扰(EMI),但它比传统铜线更昂贵。
在接收端,输入阶段会执行自己的信号调节,以提取尽可能高质量的信号。然后,数据会被发送到 CDR 块和串行到并行转换与解码块。CDR 从数据流中恢复时钟信号,然后解串器利用提取的时钟信号对数据进行采样。可能看起来有些奇怪,你能从同一个信号中恢复时钟并利用该时钟采样数据,但这就是 SerDes 工作的魔力!最后,在输出端,数据再次被转换为并行格式。继续之前的例子,你将会恢复通过四根并行输出线传输的 250 MHz 数据流。
这个例子提到的是 1Gb/s 的数据速率,但现在这已经不算特别快了。随着数据速率的不断提升,我们需要持续优化这个过程的每一部分。维持高信号完整性对 SerDes 应用至关重要。在高速数据传输时,小的电阻、电容和电感都会影响线路的信号完整性。通过连接器(例如 USB 插头)传输数据时,也会在数据路径中造成一些小的缺陷,从而影响信号完整性,因此优化每个环节变得至关重要。
SerDes 是现代 FPGA 的杀手级特性之一,但它涉及了很多复杂的内容。正如你刚才所看到的,像高速数据传输这样看似简单的操作,实际上涉及多个步骤,包括数据的串行化、传输、接收以及再反序列化。即使有了这个过程,我们依然要与物理学作斗争,以便让数据能够以越来越快的速度传输。
总结
成为一名成功的 FPGA 设计师,有助于深入了解 I/O。这是 FPGA 工程师在电气工程和软件工程交汇处的工作领域。本章解释了 FPGA 如何使用缓冲区来接收数据并发送数据,并探讨了 FPGA 设计师在配置 I/O 时需要注意的一些常见设置,包括操作电压、驱动强度和上升/下降速率。你了解了单端和差分通信的区别,并且看到 DDR 如何利用时钟的上升和下降沿来更快地发送数据。我们还探讨了 SerDes,这一强大的输入/输出特性使得 FPGA 在高速数据应用中表现出色。
第五章:A FPGA 开发板

本附录列出了几款示例 FPGA 开发板,供你在本书的项目中使用。你可以编程的开发板是一个宝贵的学习工具。没有什么比让 LED 闪烁、按下按钮和与外部设备交互更令人满意了!你完全可以在不完成实际例子的情况下,从本书中学到很多内容。但要真正解锁它的价值,我建议你购买一款开发板,例如这里提到的设备,并通过物理硬件完成所有项目。
在第二章中,我们讨论了选择开发板的一些标准,包括完成本书项目所需的功能。特别是,我建议选择一款具有 Lattice iCE40 FPGA、USB 连接以及 LED、按钮和七段显示器等外设的开发板。这里介绍的开发板要么是现成符合这些要求,要么可以通过连接一些额外的外设来满足要求。当然,还有其他可用的开发板;你可以使用这些开发板,或将这些建议作为你自己研究的起点。
Nandland Go 开发板
我在 2016 年通过一项成功的 Kickstarter 众筹项目创建了 Nandland Go 开发板(图 A-1),填补了市场上的一个空白:缺乏有趣、实惠且易于初学者使用的 FPGA 开发板。我设计了这款开发板,配备了许多外设,以便实现多种有趣的项目。Go 开发板具备完成本书所有项目所需的一切,无需任何修改。

图 A-1:Nandland Go 开发板
Go 开发板上的 FPGA 是 iCE40 HX1K,相较于现代的 AMD 和 Intel FPGA,虽然它比较小,但足够强大,可以在 VGA 显示器上创建 Pong 游戏。该开发板有四个 LED,四个按钮开关,两个七段显示器,一个 Pmod 接口,一个 VGA 接口,以及一个用于供电、编程和通信的 USB 接口。它的价格约为 65 美元,价格适中,可以让你尝试多种不同的项目,使用 Verilog 或 VHDL 编程。它可以通过 https://
Lattice iCEstick
Lattice 设计了 iCEstick FPGA 开发板,可以像 U 盘一样直接插入计算机的 USB 端口。它采用与 Go 开发板相同的 FPGA(iCE40 HX1K),但在内建外设方面略显有限。它有五个 LED, 一个 Pmod 接口,以及一个用于发送和接收红外数据的 IrDA 收发器。
截至本文写作时,iCEstick 的价格大约为 50 美元,可以直接从 Lattice 的官网购买(https://
iCEstick 的 Pmod 连接器数量不足,无法同时与七段显示器和按钮模块连接,无法完成第八章中的状态机项目。然而,如果你想实现项目中的七段显示器部分,可以利用板边的 16 个通孔 I/O 连接器连接单个显示器。
Alchitry Cu
Alchitry Cu 与 iCEstick 类似,都是相对简单的开发板,拥有单个 FPGA 和连接器,并且板载外设不多。不同之处在于,Alchitry Cu 提供了更多的连接器,因此可以连接更多外设。此外,它使用的是更大的 FPGA,iCE40 HX8K,拥有更多资源,适用于更大、更复杂的项目。
Alchitry Cu 可以与 Alchitry Io 扩展板配合使用,以扩展其功能:Io 直接安装在 Cu 的顶部,类似于 Arduino 扩展板,增加了 4 个七段显示器、5 个按键、24 个 LED 和 24 个开关。这是这里讨论的三个选项中最贵的一种;截至本文写作时,Cu 和 Io 的组合大约需要 85 美元。你可以在https://
切换开发板
正如我在第二章中提到的,Verilog 和 VHDL 的优势在于它们是与 FPGA 无关的。你为一个开发板编写的代码可以很好地迁移到另一个开发板上,通常无需修改,前提是你没有使用特定设备的硬件 IP 特性,像第九章中讨论的那些特性。这样一来,开始使用低成本、易于使用的基于 iCE40 的开发板(如本书中描述的那些)进行 FPGA 学习,然后随着经验的积累,升级到更高级的开发板是非常自然的。你将能够将为第一个开发板开发的所有项目迁移到新的、更先进的开发板上,并且只需进行最小的修改。
第六章:B FPGA 工程师职业生涯建议

也许这本书已经激起了你对 FPGA 设计的兴趣,甚至让你考虑将其作为职业生涯。与 FPGA 工作是一项真正充实的工作:你每天早晨醒来,都会解决有趣且相关的问题。就像设计 FPGA 需要实践一样,申请工作也是一种可以磨练的技能。本附录讨论了如何获得 FPGA 工程师好工作的策略。
我曾经站在求职过程的两个角度——既是面试者也是面试官,所以我总结了一些能帮助你找到令人满意工作的技巧,也了解雇主的需求。在本附录中,我将首先分享一些如何改善简历的建议,帮助你获得面试机会;然后是如何在面试中表现出色,提升获得工作的机会;最后,我们将讨论如何谈判以获得最优的聘用条件。
简历
简历的目的是让你进入面试的门槛。就是这么简单。一旦进入面试,简历的作用就完成了。在我的职业生涯中,我审阅了数百份简历,在尝试填补职位时,只有少数简历促成了面试。我们来探讨一些技巧,帮助你让简历脱颖而出。
保持简洁
一位优秀的工程师知道如何迅速展示什么是重要的,什么是不重要的。通过在简历中直奔主题来展示这一点。如果你工作经验少于五年,那么简历不应该超过一页。许多求职者认为更长的简历会显得更有经验,但在简历中填充无关内容只会适得其反。招聘人员一眼就能看出填充内容。例如,“与来自世界各地的团队协调,分担责任和任务”这样的句子实际上毫无意义。去除多余的部分,直奔重点。
与其包括空洞的套话,不如利用简历突出你的实际技能、成功经验和成就。如果你没有工作经验,那也没关系,但你需要通过其他方式展示你的资格。例如,描述你在本科课程中学到的具体技能和技术。如果你参与了一个大型项目并与团队合作,那么这个项目应该在简历中占有一席之地。
包括学术信息
许多公司要求工程学本科学位作为最低的工作资格,因此你的简历应该包含有关你教育背景的信息。然而,我最喜欢工程学的地方之一就是它非常讲究实绩。最重要的不是你上的是哪所学校,而是你的知识和能力。这意味着,如果你没有去过最著名的私立学校,也不必担心(即使你去了,也未必能让你如你预期的那样占据优势)。当我看简历时,我通常不太关注申请人上的是哪所学校。我更关心的是看到他们学到了什么,以及他们如何利用这些知识取得成就。
很多人常常想知道是否应该在简历中列出自己的 GPA。个人而言,如果 GPA 高于 3.0,我建议列出;如果低于 3.0,可以不列。一些公司确实有 GPA 要求(如果你的 GPA 没有列出,或者低于某个标准,他们甚至不会看你的简历),但近年来这种情况似乎变得不那么常见。招聘经理明白,成为一名成功的工程师并不总是与在哲学课上取得最高分相关。
如果你没有工程学本科学位,你的求职之路可能会更加艰难。不过并不是所有公司都有学位要求。特别是小公司,通常对最低要求会更加宽松。你可能通过强调你的实践经验来弥补缺乏相关教育背景的不足。一定要描述你参与的项目,展示你在该领域的掌握情况。
根据职位描述定制简历
职位描述详细列出了公司期望新员工承担的所有角色和责任。公司正在描述他们理想中的候选人,因此你应尽可能通过简历体现这些理想。这意味着你应该准备根据每个职位申请的要求来调整简历。突出与公司需求相匹配的部分,并考虑删除不太相关的内容。包括相关的流行词汇和你有经验的特定技术。尤其是缩写,特别引人注目,能吸引眼球。
从招聘经理的角度考虑你的简历。职位描述是他们寻找的要素清单。不要让他们难以找到这些要素。简历中出现的职位描述关键词越多,越好。然而,请记住,在面试时,你应该能够通过详细的知识支持简历中写的任何内容。没有什么比在面试中证明你在简历上做了虚假陈述更糟糕的了。
为了说明如何量身定制简历,假设你的大部分经验都在 Verilog 中,但你也了解 VHDL。一份 FPGA 职位的招聘描述通常会说明公司使用的语言。如果你遇到一份要求 VHDL 的工作,你应该调整简历,突出你在 VHDL 方面的项目。甚至可能值得将一些 Verilog 代码转换为 VHDL,以提升你的技能。另一个例子是,公司可能需要有图像处理经验的人。在这种情况下,你应该在简历中增加一项,列出你在该领域的任何工作。相反,如果职位来自一个无线产品公司,重点突出你在无线通信方面的经验。确保你在突出技能时具体明确:“具有 BLE 通过 UART 的 FPGA 经验”要比“从事过无线通信”更好。
批评一个示例简历
让我们看一下一个名为 Russell Merrick 的应届大学毕业生的简历,看看可以改进的地方。图 B-1 展示了我申请大学毕业后第一份工作的实际简历。

图 B-1:示例简历
这并不是特别糟糕,但回头看,显然还有很大的改进空间。最大的问题是没有足够的技术内容或引人注目的流行词。我在大学时学到了很多关于工程的知识,但从这份简历来看,你是看不出来的。我没有包含任何展示我经验的具体项目细节;我做的只是列出我所上的课程名称。此外,我还详细描述了与我申请的工作无关的非工程类工作。让我们来改进一下。
首先,我们可以从教育部分删除“工程管理背景”。当时,我认为完成了一些管理课程会是一个资产,但我很快意识到,作为一名初级员工,你处于金字塔的底层。没有人需要管理,因此你的领导能力不具相关性。将来某一天你可能会晋升,拥有下属,但那肯定不是我当时申请的职位所能实现的。以同样的方式,我会从课程部分删除“管理、市场营销、会计、财务”。这些对一名初级 FPGA 工程师来说并不相关。
在相关工作经验部分,我描述了我在信息技术(IT)领域的一次短期实习。这对我来说是一次宝贵的经验,因为它让我学到了一项重要的教训:我不想从事 IT 工作!在这里,使用更多的流行词和具体细节会很有帮助。例如,我曾与 Linux 计算机和思科路由器一起工作,但我并没有在简历上写这些。
另一项工作经验,关于我作为 RA(研究助理)的经历,可以精简一下。它太长了,并且与 FPGA 工作没有特别的相关性。能力部分也可以删减。现在大家都认为求职者能使用 Microsoft Word,所以不需要包括这一项。通过这些删减,会腾出更多空间来突出我在学位课程中所做的一些技术项目。
现在让我们来看一下改进版的简历。图 Figure B-2 中的版本实施了这些建议的修改。

图 B-2:改进后的简历示例
这个修订版大大改进了。我增加了一个部分,详细描述了我参与的几个项目,通过具体示例展示我在大学期间学到的东西。在描述这些项目时,我列举了我使用过的许多具体技术,比如 UART、PID、LDO、IR 等。像这样的缩写词有助于吸引招聘经理的注意。我还删除了一些过于笼统的内容,同时在其他部分变得更加具体,比如描述我的 IT 实习经历。凭借这个新的改进版简历,过去的 Russell 可能能得到更多的面试机会!
面试
你的简历是为了让你有机会进入面试,但面试才是你真正需要表现出色的地方,才能超越竞争者,获得这份工作。面试实际上就是一个口试,像所有考试一样,准备充分会有帮助。你应该练习本节中的建议,直到你感到自如为止。尽管这可能会让你感到有些尴尬,但找一个朋友或亲戚和你一起练习这些技巧。与他人一起大声练习,远比自己在脑海里默默练习效果好得多。
展示你的热情
除了证明你的技术资质,面试还是一个展示你对当前职位热情的机会。为此,提前了解公司是非常有帮助的。确保你了解公司的产品,以及他们所在行业的整体情况。这样,你就可以通过展示你对公司的参与程度和对公司面临问题的了解,给面试官留下深刻印象。你甚至可以提出一些关于解决这些问题的想法!
在面试前直接联系公司的人也从不吃亏。你可能会认为这样做会让公司招聘人员感到烦恼,但实际上,他们很难分辨出哪些简历是那些仅仅回应他们遇到的每一条招聘广告的人提交的,哪些简历是那些真正对具体职位感兴趣的人。我曾有求职者通过 LinkedIn 或电子邮件直接联系我,每次遇到候选人采取这样的额外步骤时,我总是印象深刻。这表明他们真的对这份工作感兴趣,并且有决心实现自己的目标。
我遇到过的最有干劲的候选人,实际上是为了进入我的公司工作而开始了一个关于太空行业的播客。他写了、编辑了并发布了十几集节目,就为了能得到一个面试机会。我并不是建议你也要创建一个播客来找到梦想公司的工作,但只要知道,没人会因为过度的热情而被拒绝。
预见问题
在准备面试时,预测可能会被问到的问题非常有帮助。我推荐的第一件事就是回顾职位描述。描述中提到的主题最有可能在面试中出现,因为这些是招聘经理对该职位最感兴趣的内容。你可能没有每个描述中提到的领域的经验,这完全没问题。然而,我建议你做些研究,了解一些你不熟悉的主题,这样在讨论时至少有一些背景知识。如果你在简历上标明的技能是你擅长的内容,确保你能流利地讲解。
职位描述通常是一个很好的起点,但如果你想要更多的准备方法,以下是一些常见的 FPGA 相关职位面试问题:
-
描述触发器和锁存器之间的区别。
-
为什么你会选择 FPGA 而不是微控制器?
-
在可综合代码中,for循环的作用是什么?
-
PLL 的作用是什么?
-
描述推断与实例化之间的区别。
-
什么是亚稳态,如何防止它?
-
什么是 FIFO?
-
什么是块 RAM?
-
描述 UART 如何工作,它可能在哪里使用?
-
同步逻辑和异步逻辑之间有什么区别?
-
什么是移位寄存器?
-
描述 Verilog 与 VHDL 之间的一些区别。
-
在跨时钟域时你需要关注什么?
-
描述设置时间和保持时间。如果违反了这些要求会发生什么?
-
合成工具的作用是什么?
-
在布局与布线过程中会发生什么?
-
什么是 SerDes 收发器,它们在哪里使用?
-
DSP 块的作用是什么?
如果我面试一个初级 FPGA 职位的候选人,我一定会问这些类型的问题。好消息是,这些问题在本书中都有答案。所以,赶快复习,准备好迎接面试吧!
转折
当你不知道问题的答案时,我建议你通过提供一些你确实知道的相关信息来转移话题。例如,假设你只有使用 SVN 进行版本控制的经验,从未使用过 Git。如果面试官问,“你如何在 Git 中创建分支?”不要直接回答,“我不知道,我从未使用过 Git。”这个回答是不够的。你可以这样说:“嗯,我还没有使用 Git 进行版本控制,但在 SVN 中,我使用分支来跟踪独立的开发线——例如,当我修复一个 Bug 或添加一个新特性时。在 SVN 中,这可以通过命令SVN COPY来完成。”
这是一个很棒的回答。尽管面试官没有得到他们所期望的信息,但你已经展示了你理解分支的目的,并且有相关经验。面试官知道你不可能知道所有答案,而且新的工具和技能可以在工作中学到。即使你不能给出完整的答案,也要抓住机会解释你对提问问题的理解。
工作邀请与谈判
到此为止,你已经击败了竞争对手,招聘经理决定向你提供工作邀请。恭喜你,你现在是一名专业工程师了!是时候庆祝一下了。然后,等你冷静下来后,我总是建议你要求更好的报价。你可能会想,“但是,拉塞尔,那不是很不知足吗?我应该为得到任何一个邀请而高兴。如果我要求他们提高条件,他们会不会撤回我的邀请?”
这是你可以从这本书中获得的一个人生经验:问问看总是没坏处。入住酒店?问问看他们是否有房间升级。想从商店买贵重物品?问问看他们是否有可用的优惠券或折扣。收到了第一份工作邀请?问问看他们是否能提高薪资、股票期权或签约奖金。只要你礼貌专业,公司绝不会收回工作邀请,但每次我向公司要求更好的待遇时,他们都会答应。人力资源部门绝不会一开始就给出最优的报价。他们预期谈判是过程的一部分。他们希望以尽可能低的薪水聘请你(当然,不会侮辱你)。他们总是会留下些钱,但能不能拿到这些钱就看你自己了。所以要保持礼貌,但也不要害怕问他们是否能提高报价。
总结
我写这本书的目标是传播我在工程师职业生涯中积累的知识。我希望这本书能帮助你提升 FPGA 技能,成为世界级的表现者。当你成功时,发个消息告诉我。你可以的!
第七章:2 设置你的硬件和工具

本章将引导你完成选择 FPGA 开发板的过程,并设置相关的软件工具,这些工具将帮助你将 Verilog 或 VHDL 代码转换为 FPGA 上的物理电路。你将了解在选择开发板时需要关注的特性,下载并安装你需要的工具,并通过设计你的第一个 FPGA 项目来进行测试,以便在开发板上运行。这个项目还将为你提供 FPGA 开发过程的主要步骤概述。
使用这本书并不严格要求你拥有 FPGA 开发板。即使没有开发板,你仍然可以通过项目进行学习,而且你总是可以通过像 EDA Playground 这样的免费在线 FPGA 模拟器工具来测试你的 Verilog 或 VHDL 代码(这一主题将在第五章中讲解)。然而,编写一些代码,将其编程到开发板上,并看到结果付诸实践——即使只是像闪烁一个 LED 这样简单的功能——这总是令人满意的。因此,我强烈建议你在学习 FPGA 时拥有一块开发板。
选择 FPGA 开发板
FPGA 开发板(或 开发板)是一块带有 FPGA 的印刷电路板(PCB),可以让你用 Verilog 或 VHDL 代码对 FPGA 进行编程并进行测试。该开发板可能还会有连接到 FPGA 的外设,如 LED、开关和用于将 FPGA 与其他设备连接的接口。FPGA 开发板的价格从不到 100 美元的口香糖大小设备到几千美元的笔记本大小设备不等。由于选择范围如此广泛,选择开发板时有许多因素需要考虑,包括价格、易用性和乐趣:
成本
对于 FPGA 初学者,我建议从一块价格便宜的开发板开始。更大、价格更高的开发板通常具有许多额外的功能,如 SerDes 和 DDR 内存,这些对于新手来说并不必要,而且可能会让人感到困惑。随着你的技能提高,你可以随时投资购买这些更先进的开发板,并逐步超越你的第一块开发板。
简易性
你开始使用的开发板和所需的软件应该简单易用。了解 FPGA 的工作原理已经足够具有挑战性;如果还需要学习如何使用复杂的设计工具,过程将变得更加困难。我推荐关注基于 Lattice Semiconductor 的 iCE40 系列 FPGA 的开发板,因为这些 FPGA 与一套轻量且简单的软件工具兼容:iCEcube2 和 Diamond Programmer。这些程序经过简化,能够完成构建 FPGA 所需的最低要求,而不包含更高级程序中的繁杂功能。本章将教你如何使用这两款工具。
乐趣
FPGA 开发板应该易于使用,配备如 LED、按键和七段显示器等外设,以便你在不同的项目中加以利用。有些价格较便宜的开发板通过去除外设来削减成本,它们仅有一个 FPGA,没有其他功能。能将 FPGA 与其他设备连接,进行更多有趣的互动,能让 FPGA 开发变得更加有趣。
在考虑市面上的开发板时,记得牢记以下因素。
本书要求
如果你能跟随项目一起进行并编程自己的开发板,那么你将从本书中获得最大的价值。为了准确地按照书中的方式完成项目,你需要确保开发板具备以下特点(附录 A 列出了几款满足这些要求的开发板,或者经过一些修改后能满足要求的开发板):
Lattice iCE40 FPGA
iCE40 FPGA 已成为 FPGA 初学者的最佳选择。它们已经推出多年,价格适中,同时提供足够的资源来支持有趣的项目。iCE40 架构相对简单,功能不会过于复杂,因此你可以专注于重要的内容。正如我之前提到的,iCE40 FPGA 兼容免费且易用的 iCEcube2 和 Diamond Programmer 软件工具,本章我们将一一探索这些工具。iCE40 系列还兼容开源 FPGA 工具,如果你希望完全避免使用专有软件,也可以选择这些工具。
USB
你的开发板应该配备 USB 接口,以便为板子供电并进行编程。这样,你只需要一根 USB 数据线就可以开始使用了。较旧的 FPGA 开发板通常需要外接编程器(一个独立的硬件设备,可能需要数百美元),所以确保你选择的开发板能够支持简单的内建 USB 编程。
LED
本书的项目假设你的开发板上有四个 LED。这些 LED 是从 FPGA 获取输出的便捷方式。例如,本章的第一个项目将涉及点亮这些 LED,这样你就能立即得到反馈,确认你已成功编程 FPGA。没有什么比看到第一个 LED 点亮更令人满足的了!
开关
对于每个 LED,你需要一个相应的按键开关。这些开关为 FPGA 提供输入,使你能够轻松地改变开发板的状态。
七段显示器
你的开发板需要一个七段显示器来实现第八章中的记忆游戏项目。这种显示器提供了一种有趣的输出数据方式。点亮单个 LED 是一回事,但在七段显示器上显示数字和字母则要更有趣得多。
如果你的开发板不符合所有这些要求,也不用担心:你仍然可以通过做一些调整来完成本书的项目。例如,如果你更愿意使用基于另一种 FPGA 的开发板,你是可以的。正如我们在后面的章节中将讨论的那样,不同的 FPGA 具有不同的高级功能,但本书项目的代码足够通用,应该可以在任何现代 FPGA 上运行。这也是 Verilog 和 VHDL 的魅力之一:它们与 FPGA 无关。
但需要注意的是,如果你不是在使用 iCE40 FPGA,你将需要使用与本章讨论的不同的软件工具。每个 FPGA 厂商提供的工具都是专门为其 FPGA 设计的。例如,AMD(赛灵思)有 Vivado,Intel(阿尔特拉)有 Quartus。如果你的开发板使用了其中某家公司的 FPGA,可以在线查找关于使用相应软件的资源。
如果你没有本书项目所需的所有外设,你有几个选择。首先,你可以修改项目的 Verilog 或 VHDL 代码,使用更少的 LED 和开关。大多数情况下这样做是可行的,尽管如果你减少了 LED 和开关的数量,第八章中的记忆游戏项目可能就不那么令人满意了。
另外,许多 FPGA 开发板,包括附录 A 中讨论的一些开发板,都有用于连接外部外设的接口点。特别是,寻找带有 Pmod(外设模块)连接器的开发板。Pmod 是一个标准连接器,由 Digilent 公司使其广为人知,用于连接带有额外外设的附加板——不仅仅是本书中使用的外设,还包括温度传感器、加速度计、音频插孔、microSD 卡等设备。如果你曾经使用过 Arduino Shields,它们的概念是一样的。如果你的开发板有 Pmod 连接器,那将大大扩展你可以用 FPGA 进行的项目范围。
设置你的开发环境
要在你的开发板上使用 iCE40 FPGA,你需要在计算机上安装两个软件工具:iCEcube2 和 Diamond Programmer。这些来自 Lattice Semiconductor 的免费工具是专门为 iCE40 FPGA 设计的。本节将指导你完成这些工具的安装过程。如果你是 Windows 用户,将会最为方便,因为这些工具是为 Windows 操作系统设计的。对于 Linux 或 macOS 用户,我建议在你的计算机上创建一个 Windows 虚拟机,然后在其中运行 Lattice 工具。网上有许多关于如何使用 VirtualBox 或类似产品创建 Windows 虚拟机的教程。
iCEcube2
iCEcube2 是 Lattice 提供的免费集成开发环境(IDE),可以将你在计算机上编写的 VHDL 或 Verilog 代码转换成 FPGA 可编程的文件。它比其他 IDE(如 Vivado、Quartus,甚至 Lattice Diamond 工具,后者用于处理更复杂的 FPGA)更易于使用。它与 iCEcube2 的兼容性是 iCE40 FPGA 成为初学者特别好选择的原因之一。其他这些程序的体积都在几个 gigabytes 以上,而且非常复杂。它们有许多功能,大多数在刚开始时你并不需要。相反,iCEcube2 更加简洁,是学习 FPGA 的更直接的工具。
要下载并安装 iCEcube2,请按照以下步骤操作:
1. 访问 https://
2. 找到 iCEcube2 最新版本的 Windows 下载链接,无论你是直接使用 Windows 还是在虚拟机中使用。如果你是 Linux 用户,你可能会想下载 Linux 版本,但我不建议这样做。该版本存在一些漏洞,可能会成功,也可能不会。
3. 当你点击下载链接时,系统会要求你在 Lattice 网站上创建一个账户。你必须创建账户才能获取此工具的许可证。确保使用真实的电子邮件地址,因为他们会通过邮件将免费许可证发送给你。一旦创建了账户,你应该能够下载该软件。
4. 在软件下载的同时,找到下载页面上的 iCECube2 Software Free License 链接,点击它来请求许可证。
5. 你需要计算机的 MAC 地址来获取许可证。要在 Windows 上找到它,点击开始按钮并搜索“cmd”来打开命令提示符。然后在命令行中输入 ipconfig /all。你应该会看到类似这样的信息:
C:\> **ipconfig /all**
`--snip--`
Ethernet adapter Local Area Connection:
Connection-specific DNS Suffix . :
Description . . . . . . . . . . . : Intel(R) Ethernet Connection I217-V
Physical Address. . . . . . . . . : 38-D3-21-F5-A3-09
DHCP Enabled. . . . . . . . . . . : Yes
Autoconfiguration Enabled . . . . : Yes
6. 你的 MAC 地址是紧挨着 物理地址 的 12 位十六进制数字。将其复制到 Lattice 许可证请求表单中并提交,以便将许可证文件发送到你的电子邮件地址。
7. 下载完成后,启动 iCEcube2 安装程序,并指向你的许可证文件。
注意
如果你在获取许可证之前已安装了 iCEcube2,可以使用位于工具安装文件夹中的程序 LicenseSetup.exe 来指向你的许可证文件。
安装完成后,启动 iCEcube2。主窗口将类似于 图 2-1。

图 2-1:iCEcube2 主窗口
四处点击以熟悉程序的使用。我们将在本章的后续部分通过一个项目深入探索,带你完成整个 FPGA 构建过程。
Diamond Programmer
Diamond Programmer 是 Lattice 提供的免费独立编程工具,它接收 iCEcube2 的输出,并通过开发板的 USB 连接编程你的 FPGA。像 Vivado 和 Quartus 这样的更复杂的软件工具有内置的编程器,因此你不需要单独下载程序。遗憾的是,iCEcube2 并未内置编程器,但这就是 iCE40 FPGA 设计师的生活!以下是如何安装 Diamond Programmer:
1. 访问 https://
2. Diamond Programmer 页面有多个下载链接可供选择。找到并点击 Windows 版 64 位的 Programmer Standalone 最新版本的链接。
警告
请确保下载 Programmer Standalone 而不是 Programmer Standalone Encryption Pack。后者是不需要的。
3. Diamond Programmer 不需要许可证,因此下载后只需运行安装程序即可。
现在你已经准备好开始你的第一个 FPGA 项目,在这个项目中,你将学习如何使用这些工具并编程你的 FPGA。
项目 #1:开关与 LED 的连接
在这个项目中,你将通过创建一个简单的 FPGA 设计来熟悉构建过程:当你按下 FPGA 开发板上的某个按钮开关时,某个 LED 应该点亮。该项目假设你有四个开关和四个 LED,因此你需要设计和编程 FPGA,将每个开关与一个 LED 相连。(如前所述,如果需要,你可以调整项目使用更少的开关和 LED。)图 2-2 显示了我们要实现的目标示意图。

图 2-2:项目 #1 方框图
在左侧是板上的四个开关,标记为 SW1 到 SW4。默认情况下,这些开关将是打开状态(未连接),这意味着当开关未被按下时,FPGA 的对应输入引脚将有低电压,因为板载有下拉电阻。当你按下一个开关时,FPGA 将看到连接到该开关的输入引脚上有高电压。在输出侧,我们有四个 LED,标记为 D1 到 D4。我们希望创建一个 FPGA,使得例如当用户按下 SW1 时,D1 LED 会点亮。我们将实际创建一个物理连接,将 SW1 输入和 D1 输出连接起来,使用我们的 FPGA。换句话说,通过 FPGA,你在如此低的层级编程,实际上是为引脚之间创建了连接线,贯穿整个设备。
为了实现这个项目,我们将经过四个主要步骤。这些步骤在图 2-3 中做了总结,构成了 FPGA 构建过程的主要阶段。

图 2-3:FPGA 构建过程
你将在这个项目中对这些步骤有一个大致的了解。然后,在全书中,你将进一步扩展对每个步骤的认识。这四个步骤是:
-
设计(Design)。在这一步,你编写描述 FPGA 如何工作的 Verilog 或 VHDL 代码。你可能还会编写测试,确保你的代码按预期工作,这一概念我们将在第五章中讨论。
-
综合(Synthesis)。综合过程是将你的代码转化为在 FPGA 上执行实际功能的低级组件。这类似于编程语言(如 C 语言)中的编译器将你的 C 代码转换为汇编指令。在本书中,我们将使用 iCEcube2 作为综合工具。
-
布局与布线(Place and route)。这个过程将把你综合后的设计映射到你特定 FPGA 的物理布局上。它将布线(route)组件之间的连接,包括将输入输出引脚连接到 FPGA 内部组件。创建引脚和信号之间链接的一个重要目的就是物理约束文件。你将在这个项目中看到如何编写约束文件。iCEcube2 在处理综合的同时,也会处理布局与布线的步骤。
-
编程(Programming)。在这一步,你将把前一步的输出加载到实际的 FPGA 中。编程文件实际上在引脚和 FPGA 组件之间,以及 FPGA 内部,创建了连接。这个项目将仅创建引脚之间的连接线,但在未来的项目中,我们还将使用其他 FPGA 组件。编程步骤在 Diamond Programmer 中进行。
本书中的所有项目都将遵循相同的基本过程。当你进行后续项目时,如果需要复习如何使用 iCEcube2 和 Diamond Programmer,可以回顾本节内容。
编写代码
让我们设计一个 FPGA,用 Verilog 或 VHDL 将开关输入连接到 LED 输出。希望到这个时候你已经选择了想要学习的语言;我建议现在专注于学习一种,但你也可以稍后学习另一种。本书中的所有代码示例都以两种语言展示,所以你也可以进行对比和对照。
我成功地在 Visual Studio Code (VS Code) 中编写了 FPGA 代码,VS Code 是微软提供的一款免费的工具。你可以下载扩展插件,使其支持 Verilog 或 VHDL 语法高亮及其他实用功能,例如直接从代码编辑器连接到 GitHub 仓库。你也可以直接在 iCEcube2 中编写代码,但我不推荐这样做,因为它不支持语法高亮。
无论你选择什么工具,输入以下 Verilog 或 VHDL 代码并将其保存在电脑中。请记住文件名和位置,因为稍后会用到。书中的所有代码也可以在本书的 GitHub 仓库中找到,https://
Verilog
❶ module Switches_To_LEDs
❷ (input i_Switch_1,
input i_Switch_2,
input i_Switch_3,
input i_Switch_4,
❸ output o_LED_1,
output o_LED_2,
output o_LED_3,
output o_LED_4);
❹ assign o_LED_1 = i_Switch_1;
assign o_LED_2 = i_Switch_2;
assign o_LED_3 = i_Switch_3;
assign o_LED_4 = i_Switch_4;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
❶ entity Switches_To_LEDs is
port (
❷ i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
i_Switch_3 : in std_logic;
i_Switch_4 : in std_logic;
❸ o_LED_1 : out std_logic;
o_LED_2 : out std_logic;
o_LED_3 : out std_logic;
o_LED_4 : out std_logic);
end entity Switches_To_LEDs;
architecture RTL of Switches_To_LEDs is
begin
❹ o_LED_1 <= i_Switch_1;
o_LED_2 <= i_Switch_2;
o_LED_3 <= i_Switch_3;
o_LED_4 <= i_Switch_4;
end RTL;
让我们广泛地考虑一下这段代码的结构,因为我们所有的项目都将遵循相同的基本格式。FPGA 的设计被封装在一个或多个 模块(在 Verilog 中)或 实体(在 VHDL 中)中。这些模块/实体定义了代码块的接口。接口包含信号,这些信号可以是输入或输出。在 FPGA 的最高层次,这些信号将连接到设备上的物理引脚,从而创建与其他组件(如开关和 LED)的接口。
在 Verilog 中创建一个模块时,你需要使用 module 关键字,并提供一个描述性的名称——在这个例子中是 Switches_To_LEDs ❶。在模块内部,首先要声明所有的输入 ❷ 和输出 ❸ 信号,这些信号放在一对括号中。接下来是你希望模块执行的代码,我们稍后会详细讨论,最后是 endmodule 关键字。
查看 VHDL 版本时,首先你可能会注意到它比 Verilog 版本稍长。这是典型现象;与 Verilog 相比,VHDL 通常需要更多的输入来完成相同的任务。额外的长度主要出现在代码的最开始部分,在这里我们指定了将使用的 VHDL 库和包。在这种情况下,我们使用来自 ieee 库的 std_logic_1164 包。我们需要这个包来访问 std_logic 数据类型,它通常用来表示 FPGA 中的二进制值(0,1)。你需要习惯在每个 VHDL 设计中包含这个库和包。
在 Verilog 中,你声明输入和输出,并将模块的实际逻辑编码在同一个代码块中;而在 VHDL 中,你会用两个独立的代码块来完成这些操作。这是 VHDL 版本较长的另一个原因。首先,你使用 entity 关键字声明 VHDL 实体❶,为其命名并指定输入❷和输出❸。然后,在一个独立的代码块中,你使用 architecture 关键字声明实体的 架构,这是定义实体功能的代码。你几乎总是会在一个 VHDL 文件中使用一个实体/架构对,实体描述输入/输出接口,而架构描述功能。
现在我们已经讲解了代码的结构,让我们来看一下具体内容。在 Verilog 和 VHDL 版本中,我们都定义了对应于四个开关的四个输入信号❷:i_Switch_1、i_Switch_2、i_Switch_3 和 i_Switch_4。在 Verilog 中,这些输入默认定义为 1 位宽(单个 0 或 1),而在 VHDL 中,我们明确地将它们定义为 std_logic,这是一种 1 位宽的数据类型。我们同样定义了四个输出:o_LED_1、o_LED_2、o_LED_3 和 o_LED_4,对应四个 LED ❸。注意,我喜欢在输入信号名称前加上 i_,在输出信号名称前加上 o_。这样可以帮助我跟踪每个信号的方向。
注意
你可以按任意顺序定义输入和输出,但通常习惯先定义输入。
最后,我们通过分配输入和输出来定义设计的逻辑——实际上执行工作的代码❹。例如,我们将输入i_Switch_1的值分配给输出o_LED_1。当 FPGA 构建完成后,这将创建这两个引脚之间的物理连接。在 Verilog 中,我们使用assign关键字,并需要使用=进行实际的信号分配。在 VHDL 中,我们只需使用<=赋值符号,即可在输入和输出之间创建连接线。
创建新 iCEcube2 项目
完成编码后,就可以将设计导入到 iCEcube2 中进行构建。打开 iCEcube2,选择文件新建项目。系统会弹出一个窗口,要求输入有关 FPGA 板的信息,如图 2-4 所示。我们先来查看这个窗口中的设置。

图 2-4:iCEcube2 新建项目窗口
对于项目名称,给你的项目取一个名字;对于项目目录,选择你希望将项目保存到计算机的位置。接下来,你需要告诉工具你使用的是哪款 FPGA。工具需要知道 FPGA 的资源数量、引脚分配以及如何工作,才能正确地将你的代码转化为与你的特定设备兼容的格式。为此,选择iCE40作为设备系列,从设备和设备封装下拉框中选择你 FPGA 的具体设备名称和封装。例如,如果你使用的是 Nandland Go 板(附录 A 中讨论的板之一),你会选择 HX1K 作为设备,VQ100 作为封装,然后在 topBank、leftBank、bottomBank 和 rightBank 下拉框中选择 3.3。这表示所有引脚的电压为 3.3 伏特。窗口中的其他设置可以保持默认。完成后,点击下一步。
注意
你将在每个项目中使用这些相同的设置,因此每次创建新项目时,你可以参考这一部分内容。
系统将带你进入另一个对话框,提示你添加之前创建的 Verilog 或 VHDL 源文件。你可以添加文件,或者点击完成跳过此步骤。如果你选择跳过文件添加,可以稍后通过展开 iCEcube2 主项目窗口左侧的综合工具菜单,右击设计文件,选择添加文件,如图 2-5 所示。

图 2-5:将 Verilog 或 VHDL 源文件添加到你的项目中
此设计文件菜单还允许在项目创建后向现有项目添加额外文件,或删除和替换先前添加的文件。
添加引脚约束
构建过程中的下一步是将引脚约束添加到你的项目中。这些约束在.pcf(物理约束文件)文件中声明(有时称为引脚约束文件),它们告诉工具你在 Verilog 或 VHDL 代码中的哪些信号将连接到 FPGA 上的哪些物理引脚。这些信息对构建过程中的布局与布线阶段至关重要,在该阶段,综合过程的输出将映射到 FPGA 上的物理资源。工具需要知道哪些引脚连接到开关和 LED,以便将设计中的所有连线路由到它们需要去的地方。
每个 FPGA 制造商都有自己的约束编写关键字。要声明 Lattice iCEcube2 的引脚约束,你需要使用 set_io 关键字,后跟设计中信号的名称,然后是 FPGA 上对应的引脚号。以下是该项目的物理约束文件的示例,但请记住,实际的引脚编号将根据你的开发板有所不同。例如,这些引脚编号适用于 Nandland Go 开发板:
# LED pins:
❶ set_io o_LED_1 56
set_io o_LED_2 57
set_io o_LED_3 59
set_io o_LED_4 60
# Push-button switches:
set_io i_Switch_1 53
set_io i_Switch_2 51
set_io i_Switch_3 54
set_io i_Switch_4 52
每一行将我们代码中的一个信号映射到 FPGA 上的一个引脚。例如,我们将 Verilog/VHDL 信号o_LED_1设置为连接到 FPGA 上的引脚56 ❶。你在物理约束文件中使用的信号名称必须与 Verilog/VHDL 代码中的信号名称完全匹配。如果名称不匹配,工具将不知道哪个信号连接到设备上的哪个物理引脚。
注意
请注意,物理约束文件中的注释前面有一个#符号——这是一个井号、磅号或标签,具体取决于你的年龄。
在设置引脚约束时,你需要查看 FPGA 开发板的参考原理图。该原理图包含电路板的接线图,告诉你 FPGA 的哪个引脚连接到哪个 LED、按钮、连接器引脚或其他设备。学习如何阅读这些基本的原理图信息是 FPGA 设计师的关键技能,因为设置引脚约束是常见的任务。
要将物理约束文件添加到项目中,在 iCEcube2 项目窗口左侧菜单中找到 P&R 流程部分,展开 添加 P&R 文件,右键点击 约束文件,然后点击 添加文件,选择你的 .pcf 文件。完成后,你将看到该文件列在约束文件下。
忘记添加物理约束文件是使用 FPGA 时常见的错误。如果没有添加该文件,工具不会给你任何警告。它们会将你代码中的信号随机连接到设备上的引脚,这几乎肯定是错误的,导致你的设计无法按预期工作。
运行构建
现在你已经准备好在 iCEcube2 中运行构建了。为此,只需点击 工具全部运行。这将执行综合、布局与布线的过程,创建你将用来编程 FPGA 的 FPGA 映像文件。iCEcube2 会为每个步骤生成报告,可以在报告部分查看。你可以随意查看这些报告,了解它们包含了哪些信息;我们将在后续章节中详细探讨。
连接你的开发板
现在你需要将开发板连接到电脑,以便编程 FPGA。花一点时间确保这个连接正常,并且电脑能识别设备。拔掉开发板后,打开 Windows 中的设备管理器,并展开“端口(COM 与 LPT)”部分。然后通过 USB 插入开发板。你应该会看到两个标记为“USB 串口 (COMX)”的设备,如 图 2-6 所示。具体的 COM 端口编号不重要。如果一切正常,那说明你的开发板已成功连接到电脑,你可以开始工作了。

图 2-6:在设备管理器中查看开发板连接情况
如果在设备管理器中没有看到 USB 串口,可以尝试以下几种故障排除方法。首先,检查开发板上是否有电源指示灯,用于显示电源是否开启。如果有,但指示灯没有亮起,说明没有供电,请检查 USB 电缆是否已牢固插入开发板和电脑。如果指示灯 亮着,那么下一个最可能的问题就是 USB 电缆本身。一些 Micro-USB 电缆仅支持“充电”,即没有传输数据的线缆。请更换一条你知道可以正常进行数据传输的电缆。
编程 FPGA
过程的最后一步是使用 Diamond Programmer 将设计编程到 FPGA 中。FPGA 开发板通常配备一个集成电路,将其 USB 连接转为 SPI 接口,Diamond Programmer 使用该接口对安装在板上的闪存芯片进行编程。完成后,FPGA 将从闪存启动,你将看到努力的成果!
在连接好开发板后,打开 Diamond Programmer 开始操作。你将看到如图 2-7 所示的对话框。点击确定以创建新项目。

图 2-7:Diamond Programmer 对话框
一旦点击确定,工具将尝试扫描开发板并自动识别连接的 FPGA。这将失败。没关系;我们可以从下一个界面手动告诉 Diamond Programmer 目标 FPGA,具体内容如图 2-8 所示。

图 2-8:Diamond Programmer 设备选择界面
将设备系列设置为iCE40,并从设备下拉框中选择你的具体 FPGA,如图 2-8 所示。接下来,双击操作字段下的内容。你将看到一个新窗口,如图 2-9 所示。请注意,你可能需要将访问模式更改为 SPI 闪存编程,以查看此处显示的内容。

图 2-9:Diamond Programmer 设备属性窗口
这个窗口让你可以告诉 Diamond Programmer 如何编程 FPGA。在设备操作部分,将访问模式设置为SPI 闪存 编程。对于 SPI 闪存选项部分,你需要参考你的开发板的编程指南,以确定使用的是哪个 SPI 闪存设备。例如,对于 Go 开发板,你需要将系列设置为 SPI 串行闪存,供应商设置为 Micron,设备设置为 M25P10,如图 2-9 所示。
最后,在编程选项部分,点击编程文件框旁边的三个点,选择要编程到 FPGA 的.bin文件。这个文件是你通过 iCEcube2 生成的,位于保存 iCEcube2 项目的目录下的/<Project_Name>_Implmnt/sbt/outputs/bitmap/子目录中。保留其他所有设置为默认值,并点击确定以关闭此窗口。现在你已经准备好进行编程了。
打开设计菜单并选择程序。如果一切操作正确,几秒钟后你应该看到 INFO — 操作:成功。这意味着你的 SPI 闪存已被编程,FPGA 正在运行!尝试按下开发板上的每个开关。按住按钮时,相应的 LED 灯应亮起。恭喜你,你已经完成了第一个 FPGA 项目!
注意
我建议保存你的 Diamond Programmer 项目,这样你可以将设置用于书中的其他项目。你只需要选择一个不同的.bin文件来编程到 FPGA 中。
如果编程失败,你可能会遇到类似这样的CHECK_ID错误:
ERROR — Programming failed.
ERROR — Function:CHECK_ID
Data Expected: h10 Actual: hFF
ERROR — Operation: unsuccessful.
如果你看到这个错误,请转到 Diamond Programmer 右侧面板中的 Cable Settings 部分,并将端口从 FTUSB-0 更改为FTUSB-1,如图 2-10 所示。

图 2-10:排查 CHECK_ID 错误
一旦你进行更改,再次尝试编程设备。这次应该能成功。
总结
在本章中,你创建了一个 FPGA 开发环境,并学习了如何使用开发板。通过你的第一个项目,你了解了 FPGA 开发过程中的主要步骤:设计,即使用 Verilog 或 VHDL 编写 FPGA 代码;综合,即将代码转换为 FPGA 组件;布置与布线,即将综合设计映射到特定 FPGA 的资源;以及编程,即将设计物理地传输到 FPGA 中。我们将在本书后面更详细地探讨这些概念,但在进行其他项目时,如果你需要复习使用 FPGA 工具的基础知识,可以随时参考本章。
第八章:3 布尔代数与查找表

布尔代数是数学和逻辑领域的重要分支,理解它对于操作 FPGA 等设备至关重要。在布尔代数中,输入和输出的值只有真假两种,通常用 1 和 0,或者高低电压表示。与乘法和除法等运算不同,布尔代数包括与(AND)、或(OR)和非(NOT)等运算。每个运算接收一些 0 和 1 作为输入,评估它们并输出 0 或 1。如果代数课讲的是布尔代数,它肯定会简单很多!
你可能在其他编程语言中遇到过布尔运算,比如 C 或 Python。例如,你可能希望程序只有在用户选择后,并且文件名有效的情况下,才会写入文件。类似地,在 FPGA 中,你经常需要检查多个输入,以确定输出的状态。假设你想在按下两个开关中的任意一个时点亮 LED。一个 FPGA 可以通过 OR 运算来实现这一点:如果任意一个开关或另一个(或者两个开关同时)提供 1 作为输入,FPGA 就会输出 1 给 LED,LED 就会亮起。
布尔代数使得像这样的任务成为可能。但更重要的是,布尔代数描述了 FPGA 中所有底层的数据运算。将足够多的布尔运算串联在一起,你就能进行数学运算、存储数据,甚至更多。通过操作 1 和 0,你可以做出令人惊讶的事情。
在本章中,我们将探讨如何用逻辑门表示简单的布尔运算,并了解这些逻辑门如何组合成更复杂的布尔方程。然后,我们将研究 FPGA 如何通过将不同逻辑门的功能结合到一个叫做查找表的单一设备中来执行逻辑运算。如你所见,查找表是 FPGA 中最重要的组成部分之一。
逻辑门及其真值表
在设计 FPGA 时,我们用逻辑门表示简单的布尔运算,逻辑门是一种接收电信号作为输入,对其进行布尔运算,并输出相应电信号的设备。针对各种布尔运算,如与(AND)、或(OR)、非(NOT)、异或(XOR)和与非(NAND),都有不同的逻辑门。每种逻辑门都可以通过真值表来描述,真值表列出了布尔代数方程的所有可能输入组合,并展示了相应的输出。
我们接下来将讨论一些常见的逻辑门,并检查它们的真值表。但首先,重要的是要理解我们接下来要查看的真值表中的 1 和 0 究竟意味着什么。在 FPGA 内部,数字数据通过电压表示:0 伏特表示 0,某个高于 0 的电压,称为核心电压,表示 1。核心电压依赖于具体的 FPGA,但通常在 0.8 伏特到 1.2 伏特之间。当我们说一个信号是高时,我们指的是该信号处于核心电压状态,并表示数据值为 1。类似地,低信号为 0 伏特,表示数据值为 0。考虑到这一点,让我们来看一些逻辑门。
与门(AND Gates)
与门是一种逻辑门,当所有输入都为高电平时,其输出为高电平。我们将以一个两输入与门为例,但与门可以有任意数量的输入。对于一个两输入与门,当输入 A 和 输入 B 都为高电平时,输出才为高电平,这也是“与门”这个名称的由来。表 3-1 显示了该与门的真值表。请注意,只有当两个输入都为 1 时,输出才为 1。
表 3-1: 两输入与门的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
在真值表中,行通常按输入的十进制顺序排列。在与门的真值表中,第一行表示输入 A = 0 和输入 B = 0,对应的表示为 b00,即二进制的 00,或十进制的 0。接下来是 b01(十进制 1),然后是 b10(十进制 2),接着是 b11(十进制 3)。如果与门有额外的输入,那么我们的真值表中将有更多的行需要填写。例如,对于一个三输入的与门,真值表会有八行,从 b000 到 b111,即从十进制的 0 到 7。
注意
逻辑门的输出用 Q 表示。这个约定源于英国数学家艾伦·图灵,他在著名的图灵机中使用字母 Q 来表示状态。Q 代表量子,这是一个离散状态(例如 0 或 1),而不是一个可以具有连续值范围的状态。
每个逻辑门都有一个独特的符号,用于电路图中。两个输入的与门符号如图 3-1 所示。该符号表示输入 A 和 B 从左侧进入门,而输出 Q 从右侧出来。

图 3-1:与门符号
随着我们对逻辑门的进一步探索,大多数我们要研究的门都有两个输入和一个输出。和与门一样,这些其他类型的门也可能有额外的输入,但为了简化起见,我们将仅讨论两个输入的版本。(例外是非门,它只有一个输入和一个输出。)为了简便,从现在起提到某个逻辑门时,我将省略两个输入这一描述。
或门
或门(图 3-2)是一种逻辑门,当任一输入为高电平时,输出为高电平;即当输入 A 或 输入 B 为高电平时,输出为高电平。

图 3-2:或门符号
表 3-2 显示了或门的真值表。
表 3-2: 或门的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 1 |
请注意,当两个输入都是高电平时,OR 门的输出也是高电平。对于 OR 门,唯一重要的是至少有一个输入是高电平,这在两个输入都是高电平时也成立。
NOT 门
一个NOT 门(图 3-3)有一个输入和一个输出。这种门简单地将输入反转(输出不是输入),因此也称为反转器。

图 3-3:NOT 门符号
请注意 NOT 门符号三角形尖端的气泡,它表示反转。它也出现在 NAND 门中,我们稍后会讨论,还可能出现在某些输入上。NOT 门的真值表见于表 3-3。
表 3-3: NOT 门的真值表
| 输入 A | 输出 Q |
|---|---|
| 0 | 1 |
| 1 | 0 |
如真值表所示,无论门的输入值是什么,输出都是相反的。
XOR 门
XOR 门(发音为“ex-or”,即异或)的输出当且仅当其中一个输入为高电平时为高电平,而当两个输入都为高电平时输出为低电平。换句话说,门检测是否仅有一个输入为高电平。XOR 门的符号见于图 3-4。

图 3-4:XOR 门符号
该符号看起来像 OR 门的符号,但左侧的额外线条将其区分开来。表 3-4 展示了 XOR 门的真值表。
表 3-4: XOR 门的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
尽管这种类型的门乍一看似乎并不特别有用,但它的应用比你想象的要频繁。例如,XOR 门用于生成 循环冗余校验(CRC),一种验证数据完整性的方法,用于确保传输信息的正确性。
NAND 门
NAND 门(即 非与门)的输出与 AND 门相反。你可以从 NAND 门的电路符号中推断这一点,参见 图 3-5:它看起来与 AND 门完全相同,只是输出端多了一个圆圈表示取反。

图 3-5:NAND 门符号
因此,NAND 门的输出与 AND 门相同,但取反。如果输入 A 和输入 B 都为高电平,输出 Q 将为低电平。在其他所有情况下,输出 Q 将为高电平。这个逻辑在 表 3-5 的真值表中有所展示。
表 3-5: NAND 门的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 0 |
NAND 门广泛应用于 USB 闪存、固态硬盘(SSD)和其他类型的数据存储设备。它们也启发了我的网站名称,https://
其他门
我们在这里探讨了最常见的几种逻辑门,以帮助你理解它们是如何工作的,但这并不是一个详尽无遗的列表。还有其他类型的逻辑门,例如 NOR(即非或)和 XNOR(异或非)门。此外,正如前面所提到的,虽然我们在这里集中讲解了两输入版本,但所有这些门(除了 NOT)都可以有多个输入。本节旨在帮助你熟悉布尔代数中的标准逻辑运算。接下来,我们将探讨如何将这些运算组合起来,构建更复杂的表达式。
将门与布尔代数结合
你已经了解了单个逻辑门的工作原理。然而,通常你可能需要编写比单一逻辑运算更复杂的代码。好消息是,你可以将多个逻辑门链接起来,表示更复杂的布尔方程,并使用布尔代数来确定结果。
在布尔代数中,每个逻辑运算都有其独特的符号。一组常见的符号在表 3-6 中展示。例如,*表示与运算,而+表示或运算。这些符号使得编写更复杂的布尔代数方程变得更加简便。
表 3-6: 布尔代数符号
| 符号 | 含义 |
| --- | --- | --- |
| * | 与 |
| + | 或 |
| ′ | 非 |
| ^ | 异或 |
布尔代数还有自己的运算顺序。要解决一个布尔方程,首先要计算非(NOT),然后是与(AND),最后是或(OR)。与常规代数一样,你可以使用括号来绕过运算顺序;括号中的内容将首先计算。
现在,你已经知道如何编写和评估包含多个逻辑运算的布尔方程,比如 Q = A * B + A′。用简单的语言来讲,你可以把它读作:“输出 Q 等于 A 与 B 或非 A。”表 3-7 展示了这个方程的真值表。
表 3-7: A * B + A′ 的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 1 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
图 3-6 显示了这个方程的电路等效图,它是通过组合逻辑门构建的。

图 3-6:A * B + A′ 的电路图
如你所见,尽管我们依然只有两个输入,但由于这些输入经过三种不同的逻辑运算,我们的真值表的可能输出比单独的逻辑门更有趣。让我们考虑当两个输入都为 0 时(真值表中的第一行)这个方程的情况。方程中没有括号,所以我们首先看 NOT A,结果为 1。然后我们进行 A 和 B 的“与”操作,结果为 0。最后,我们对这两种表达式的结果进行“或”操作,得到输出 1。考虑到其他可能的输入,你应该会看到,每当 A 为 0,或者 A 和 B 都为 1 时,输出 Q 都为 1。否则,输出为 0。
虽然这个例子只有两个输入,但布尔方程可以有任意数量的输入。每增加一个输入,真值表中的行数就会增加一倍:一个输入时有两行,两个输入时有四行,三个输入时有八行,以此类推。用数学术语表示,对于 n 个输入,真值表有 2^n 行。
为了演示,让我们考虑一个有三个输入的示例方程:Q = A + (C * B′)。注意,括号表示在执行“或”操作之前,先进行“与非”操作。这实际上遵循了常规的布尔代数操作顺序,但括号使得方程更易读。包含三个输入的真值表见表 3-8。
表 3-8: A + (C * B′) 的真值表
| 输入 A | 输入 B | 输入 C | 输出 Q |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 0 | 1 | 1 |
| 0 | 1 | 0 | 0 |
| 0 | 1 | 1 | 0 |
| 1 | 0 | 0 | 1 |
| 1 | 0 | 1 | 1 |
| 1 | 1 | 0 | 1 |
| 1 | 1 | 1 | 1 |
对应的电路如图 3-7 所示。

图 3-7:A 的电路图 + (C * B′)
要生成这个真值表,首先我们应该执行括号内的操作。这是 C AND NOT B。在括号内,最高优先级是对 B 应用的取反运算,之后与 C 进行与(AND)运算。总的来说,当 C 为高电平且 B 为低电平时,括号内的部分会得到高电平。由于方程的其余部分是或(OR)运算,我们也可以知道当 C 为高电平且 B 为低电平时,整体输出为高电平。这个情况出现在真值表的第二行,也出现在第五行,所以我们可以将这两行填上 1。最后,考虑 OR 运算符另一边的 A。当 A 为高电平时,如真值表中的最后四行,输出将为高电平。我们可以将剩余的行填上 0 来完成真值表。
在编程中,将逻辑运算组合起来以执行更复杂的功能是很常见的。在 FPGA 中,通过将简单的逻辑门功能连接在一起,也可以实现相同的功能。
查找表
到目前为止,我们一直在学习个别的逻辑门,但你可能会惊讶地发现,这些逻辑门在 FPGA 中并不真正存在。没有一个与门和或门的阵列,供你随意取用并连接起来创建布尔代数逻辑。相反,存在着更好的东西:查找表(LUTs)。这些设备可以被编程为执行你能想到的任何布尔代数方程,无论涉及到哪些特定的逻辑门。如果你需要一个与门,一个查找表就可以完成。如果你需要一个异或门,查找表也能做到。一个查找表也可以评估一个包含多个逻辑门的方程,就像我们在前一节中考虑的那样。任何你能想到的真值表,查找表都能生成。这就是查找表的强大之处。
注意
早期的可编程逻辑设备,如可编程阵列逻辑(PAL),确实有与门和或门阵列。而在 FPGA 中,这些已被更强大的查找表(LUTs)所取代。
查找表(LUT)按它们能接受的输入数量分类。例如,最新的 FPGA 上有二输入、三输入、四输入、五输入,甚至六输入的查找表。大多数查找表产生单一的输出。图 3-8 展示了一个三输入查找表(通常称为 LUT-3)的样子。

图 3-8:一个三输入查找表(LUT)
这个查找表是一个空白的基板,可以编程执行任何具有三输入和一个输出的布尔代数操作。例如,回顾一下图 3-7 中的电路,该电路表示布尔方程 Q = A + (C * B′)。绘制该方程的电路需要三个逻辑门——一个非门、一个与门和一个或门——但我们可以用一个三输入查找表来替代这三个逻辑门。这个查找表也可以被编程为表示方程 Q = (A + B + C)′,或者 Q = (A + B)′ * C。
如果我们有一个包含超过三个输入的布尔代数方程,会发生什么呢?这没问题,因为可以将查找表串联在一起执行非常长的逻辑序列。实际上,典型的 FPGA 包含数百甚至数千个查找表,随时可以编程以执行你需要的任何逻辑操作。这就是为什么查找表是 FPGA 中两个最重要的组件之一:它们执行你代码中的逻辑操作。另一个关键组件是触发器,我们将在下一章讨论。
尽管我们在这里绘制了真值表和逻辑门图,但在实际应用中,你很少会用这种方式定义 FPGA 操作。相反,你会编写代码。通常,你写的代码会比单独的逻辑门更高层次:你可能会编写代码来比较两个数字、增加计数器或检查条件是否为真,然后综合工具会将代码拆解成必要的布尔逻辑操作,并将这些操作分配给查找表(LUT)。然而,本书的目的是教你理解 FPGA 的工作原理,从根本上说,FPGA 通过执行布尔代数来工作。一旦你了解了 FPGA 的工作原理,你将能够更深入地理解你用代码创建的内容,并能更好地使用 Verilog 或 VHDL。这将帮助你创建高效且可靠的 FPGA 设计。
项目 #2:用逻辑门点亮 LED
现在你已经准备好将你学到的布尔逻辑和查找表知识应用到 FPGA 开发板上的实际例子中了。这个项目应该会点亮一个 LED,但只有当两个开关同时被按下时才会点亮。换句话说,你通过实现与门(AND gate)来使用你的第一个 LUT。图 3-9 显示了这个项目的框图。

图 3-9:项目 #2 的框图
这个项目将整个 FPGA 转变为一个单一的与门。对于一个非常强大的 FPGA 来说,这可能有些过头,但它是一个很好的方法来直观展示 LUT 在实际中的工作原理。表 3-9 显示了与这个项目相对应的真值表。
表 3-9: 项目 #2 的真值表
| SW1 | SW2 | D1 |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 1 |
这个真值表看起来和我们为与门(AND gate)创建的真值表完全一样,不过列标签已经被替换为表示开发板上的两个开关和一个 LED。正如预期的那样,只有当输入 SW1 和 SW2 都为高电平时,输出 D1 才会为高电平。
编写代码
实现一个与门(AND gate)使用的资源非常少:三个连接(两个输入和一个输出)以及一个 LUT。让我们看看如何通过 Verilog 和 VHDL 使 LED 亮起:
Verilog
module And_Gate_Project
(input i_Switch_1,
input i_Switch_2,
output o_LED_1);
❶ assign o_LED_1 = i_Switch_1 & i_Switch_2;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity And_Gate_Project is
port (
i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
o_LED_1 : out std_logic);
end entity And_Gate_Project;
architecture RTL of And_Gate_Project is
begin
❶ o_LED_1 <= i_Switch_1 and i_Switch_2;
end RTL;
我们首先将输入定义为 i_Switch_1 和 i_Switch_2,输出定义为 o_LED_1。然后,我们持续将输出与两个输入的与运算进行赋值❶。在 Verilog 中,与运算的符号是 &,而在 VHDL 中,and 是一个保留关键字。
构建和编程 FPGA
现在,你已经准备好将你的 Verilog 或 VHDL 代码通过第二章中讨论的构建过程运行。综合工具将生成一份报告,概述 FPGA 上的资源利用情况。以下是报告中最有趣的部分:
`--snip--`
Resource Usage Report for And_Gate_Project
Mapping to part: ice40hx1kvq100
Cell usage:
SB_LUT4 1 use
❶ I/O ports: 3
I/O primitives: 3
SB_IO 3 uses
I/O Register bits: 0
Register bits not including I/Os: 0 (0%)
Total load per clock:
Mapping Summary:
❷ Total LUTs: 1 (0%)
本报告告诉我们,FPGA 上的三个 I/O 端口(输入/输出端口,或称引脚,意味着与外部世界的连接)已被用于实现我们的电路设计❶,最重要的是,我们使用了一个 LUT❷。最后一行中的 (0%) 表示 FPGA 上的资源利用率。在这个特定的 FPGA 上,有超过 1000 个 LUT 可供使用,而我们只使用了其中的 1 个。由于综合报告显示一个 LUT 的资源利用率为 0%,工具可能在此处进行了四舍五入(1 / 1,000 = 0.1)。
请继续编程你的开发板,注意到只有在两个开关同时按下时,LED 才会亮起。LUT 在正常工作!
你可以随意修改代码,使用与(AND)以外的其他布尔运算。例如,你可以使用 | 或 ^ 符号在 Verilog 中创建一个或门(OR gate)或异或门(XOR gate),或者使用 VHDL 中的 or 或 xor 关键字。你还可以尝试将多个运算结合起来,使 LED 根据你想到的任何复杂布尔代数方程亮起,或者尝试增加更多的开关输入或 LED 输出来实现更复杂的真值表。你可以通过编写你自己的真值表,使用开关作为输入,LED 作为输出,然后测试所有可能的开关组合,查看它们是否按预期工作,来检查综合工具是否真的根据你的代码生成了正确的 LUT。
总结
在本章中,你了解了 FPGA 两个最重要组成部分之一:查找表(LUT)。你已经看到,LUT 可以通过给定数量的输入实现任何布尔代数方程,从简单的逻辑门(如与门(AND)、或门(OR)、非门(NOT)、异或门(XOR)和与非门(NAND))到将这些门组合起来的更复杂的方程式。在下一章中,我们将重点介绍另一个关键的 FPGA 组件:触发器(flip-flop)。
第九章:4 使用触发器存储状态

除了查找表,FPGA 中的另一个主要组件是触发器。触发器使得 FPGA 能够记住或存储状态。在本章中,我们将探讨触发器的工作原理,并了解它们为何对 FPGA 的功能至关重要。
触发器弥补了查找表的一个不足。LUT(查找表)在接收到输入后立即生成输出。如果你只使用 LUT,FPGA 可以执行所有可能的布尔代数运算,但输出将仅仅基于当前的输入确定。FPGA 将无法知道它的过去状态,这会非常有限。例如,实现一个计数器就不现实,因为计数器需要知道一个可以递增的先前值;存储某些数学运算的结果作为变量也是不可能的。仅仅使用 LUT,甚至像时间的概念这样的关键操作也无法实现;你只能基于现在来计算值,而不能参考过去的任何信息。触发器赋予了 FPGA 这些有趣的能力,这也是它在 FPGA 操作中至关重要的原因。
触发器的工作原理
触发器通过高电平或低电平电压的形式存储状态,分别对应二进制的 1 或 0,或者真/假的值。它通过定期检查输入上的值,将该值传递到输出,并保持在那里。考虑图 4-1 所示的基本D 触发器图示。D 触发器是 FPGA 中最常见的触发器类型,也是本章的重点。(接下来我将省略D,直接称其为触发器。)

图 4-1:D 触发器的示意图
请注意,组件左侧有三个输入,右侧有一个输出。左上角的输入标记为D,是触发器的数据输入。数据(以 1 或 0 的形式)通过这里输入。左下角的输入标有类似大于号(>)的符号,是时钟输入,它同步触发器的工作。在固定的时间间隔内,时钟输入触发触发器,从数据输入获取值并将其传递到输出(在图示中标记为Q)。
中左侧的输入标记为En,即时钟使能。只要时钟使能信号为高电平,时钟输入就会持续触发触发器更新其输出。然而,如果时钟使能输入变为低电平,触发器将忽略时钟和数据输入,基本上会冻结当前的输出值。
为了更好地理解触发器的工作原理,我们需要更仔细地观察进入时钟输入的信号。
时钟信号
时钟信号,通常简称为时钟,是一种在高低之间稳定交替的数字信号,如图 4-2 所示。这个信号通常通过 FPGA 外部的专用电子元件提供。时钟是 FPGA 操作的关键:它触发其他组件,如触发器,执行它们的任务。如果你把 FPGA 想象成一组齿轮,那么时钟就像是那个带动其他齿轮转动的大齿轮。如果主齿轮不转动,其他齿轮也不会转动。你也可以把时钟看作系统的心脏,因为它为整个 FPGA 保持节拍。FPGA 中的每个触发器都会在时钟心跳的脉冲下更新。

图 4-2:时钟信号
注意时钟信号图中的竖直线,信号从低到高或从高到低的跳变。这些信号的突变称为边沿。当时钟从低到高时,称为上升沿;而当时钟从高到低时,称为下降沿。触发器通常在时钟的每个上升沿触发:每当时钟信号从低到高变化时,触发器就会更新其输出,以匹配数据输入。
注意
虽然可以通过时钟的下降沿触发触发器,但这比使用上升沿要少见得多。
每个时钟都有一个占空比,即信号为高的时间比例。例如,一个 25% 占空比的信号,信号高时占四分之一的时间,低时占四分之三的时间。几乎所有的时钟,包括图 4-2 中所示的时钟,都有 50% 的占空比:它们是半开半关的。
时钟还有频率,即每秒钟从低到高再到低(称为一个周期)的重复次数。频率以赫兹(Hz)来衡量,即每秒钟的周期数。你可能对电脑 CPU 的频率比较熟悉,通常用千兆赫兹(GHz)来表示,其中 1 GHz 等于 10 亿赫兹。FPGA 的运行速度通常没有那么快。更常见的是,FPGA 时钟信号的频率在几十到几百兆赫(MHz)之间,其中 1 MHz 等于 100 万赫兹。例如,围棋棋盘上的时钟(在附录 A 中讨论)运行在 25 MHz,即每秒 2500 万个周期。
描述时钟速度的另一种方式是指其周期,即单个时钟周期的持续时间。你可以通过计算 1 ÷ 频率 来得到周期。例如,在围棋棋盘的情况下,时钟周期是 40 纳秒(ns)。
触发器的工作实例
触发器在其时钟输入的跃迁上工作。如前所述,当触发器看到时钟的上升沿时,它会检查数据输入信号的状态,并将其复制到输出端——前提是时钟使能引脚设置为高电平。这个过程称为注册,也就是说,“触发器注册输入数据”。由于这一术语,一组触发器被称为寄存器,因此,单个触发器也可以称为一位寄存器。一个触发器本身能够注册一个比特的数据。
为了了解注册操作在实际中的工作方式,我们将检查几个触发器的输入及其对应的输出。首先,考虑 图 4-3。

图 4-3:触发器行为的一个例子
该图展示了三种波形:顶部的一个(Clk)代表 FPGA 的时钟信号,中间的一个(D)是触发器的数据输入,底部的一个(Q)是触发器的输出。假设时钟使能信号为高,触发器始终处于启用状态。我们可以看到时钟的三个周期波形;每个时钟周期的上升沿用数字 1、2 和 3 标示。在时钟的第一次和第二次上升沿之间,D 输入从低电平变为高电平,但请注意,输出不会在输入变高时立即跟随。相反,触发器需要一些时间来注册输入的变化。具体来说,直到下一个上升沿,触发器的输出才会跟随输入。
触发器只在时钟的上升沿查看输入数据,并使输出与输入匹配,绝不会在边沿之间变化。在这种情况下,在第二个时钟周期的上升沿,输出 Q 看到了 D 从低电平变为高电平。此时,Q 取与 D 相同的值。在第三个上升沿,Q 再次检查 D 的值并注册它。由于 D 没有变化,Q 依然保持高电平。Q 在第一个时钟周期的上升沿也注册了 D,但由于那时 D 和 Q 都是低电平,Q 并没有发生变化。
现在考虑 图 4-4,它展示了触发器如何响应另一个例子场景。

图 4-4:另一个触发器行为的例子
这里我们再次看到触发器在几个时钟周期内的操作。同样,假设触发器始终处于启用状态。在时钟的第一次和第二次上升沿之间,输入 D 从低电平变为高电平。在第二次上升沿,Q 看到 D 已变高,因此它也从低电平切换为高电平。在第三次上升沿,Q 看到 D 保持高电平,因此 Q 也保持高电平。在第三次和第四次上升沿之间,D 变为低电平,输出也在第四次上升沿变为低电平。在最后一次上升沿,D 仍为低电平,因此 Q 也保持低电平。
之前的示例都假设时钟使能输入为高电平。现在让我们展示当触发器的时钟使能不是始终为高时会发生什么。图 4-5 展示了与图 4-4 完全相同的时钟和数据波形,但时钟使能信号并不是始终为高,而是在第三个上升沿时才为高。

图 4-5:带有时钟使能信号的触发器行为
现在,随着时钟使能(En)的参与,生成了完全不同的输出 Q。Q 不再“看到”在时钟周期二时 D 变为高电平,因为此时时钟使能为低电平。相反,Q 只在时钟周期三时从低变为高,当时钟使能为高电平时。在时钟周期四时,D 已变为低电平,但 Q 并没有跟随 D 的变化。相反,Q 保持为高电平。这是因为此时时钟使能已变为低电平,锁定了输出状态。触发器将不再将 D 的变化注册到 Q 上。
这些示例展示了触发器的行为,说明触发器的活动如何通过时钟来协调。此外,我们已经看到关闭时钟使能引脚可以让触发器保持状态,即使输入 D 在变化。这使得触发器能够长时间存储数据。
触发器链
触发器通常是串联在一起的,一个触发器的输出直接进入另一个触发器的数据输入。例如,图 4-6 展示了四个触发器的链式连接。为了简化,假设这些触发器始终是使能的。

图 4-6:四个触发器的链式连接
四个触发器,从test1到test4,通过链式连接,其中test1的输出连接到test2的输入,test2的输出连接到test3的输入,依此类推。所有四个触发器都由相同的时钟驱动。时钟同步它们的操作:每当时钟上升沿到来时,所有四个触发器都会检查输入上的值,并将该值注册到输出端。
假设test1触发器在其输入端注册了一个变化。图 4-7 展示了这个变化如何传播通过触发器链,直到test4的输出端。

图 4-7:输入变化通过触发器链传播
图中显示了时钟信号、test1触发器的输入和输出(分别为test1_d和test1_q)以及每个后续触发器的输出。在第一个时钟周期的上升沿(标记为 1),test1_d为低,因此test1_q也保持为低。直到第二个上升时钟沿,触发器才“看到”输入变为高,并将其注册到输出中。test1触发器的输出也是test2触发器的输入,但注意到test2的输出并不会在test1的输出变高时立即变高。相反,test2_q会在第三个上升时钟沿时变化。然后,在第四个上升沿时,我们看到test3_q变高,最后在第五个上升沿时test4_q变高并保持高。
通过在test1后面添加三个触发器,我们将输出延迟了三个时钟周期,因为信号通过链条传播。链中的每个触发器都会增加一个时钟周期的延迟。这种通过添加触发器链来延迟信号的技术,在使用 FPGA 时是一个有用的设计实践。设计师们可以通过链式连接触发器来创建电路,以便延迟或记住数据一段时间,或将串行数据转换为并行数据(或反之)。
项目 #3:闪烁 LED
现在你已经了解了触发器的工作原理,我们将在一个项目中使用几个触发器,在这个项目中,FPGA 必须记住它自身状态的信息。具体来说,我们将每次释放开关时切换 LED 的状态。如果在释放开关之前 LED 是关闭的,它应该打开;如果 LED 是打开的,它应该关闭。
这个项目使用了两个触发器。第一个用于记住 LED 的状态:它是开着还是关着。如果没有这个记忆功能,FPGA 就无法知道每次释放开关时是否需要切换 LED 的状态;它无法知道 LED 是开着需要关闭,还是关着需要打开。
第二个触发器使得 FPGA 能够检测到开关被释放的时刻。具体来说,我们要检测的是开关电信号的下降沿:从高电平到低电平的过渡。在 FPGA 中检测下降沿的一个好方法是将信号通过触发器进行注册。当触发器的输入值(即未注册的值)为 0,而前一个输出值(即已注册的值)为 1 时,我们就知道发生了下降沿。开关的下降沿不应与时钟的上升沿混淆;我们仍然使用时钟的上升沿来驱动所有的触发器。图 4-8 显示了需要查找的模式。

图 4-8:使用触发器进行下降沿检测
在这里,i_Clk 是时钟信号;i_Switch_1 代表来自开关的电信号,该信号传递到触发器;r_Switch_1 是触发器的输出。在圈出的上升时钟沿上,我们可以看到 i_Switch_1 处于低电平,但 r_Switch_1 处于高电平。这个模式就是我们如何检测信号的下降沿。需要注意的是,虽然 r_Switch_1 在上升时钟沿会变为低电平,但当逻辑在同一上升时钟沿评估 r_Switch_1 的状态时,它仍然会“看到” r_Switch_1 为高电平。只有在短暂的延迟后,r_Switch_1 的输出才会变为低电平,跟随 i_Switch_1 的状态。
这个项目还需要在两个触发器之间实现一些逻辑,形式上将通过 LUT(查找表)来实现。这将是你第一次看到触发器和 LUT 如何在 FPGA 中协同工作来完成任务。图 4-9 显示了该项目的总体框图。

图 4-9:项目 #3 的框图
开发板上的一个开关(SW1)的输出进入 FPGA,其中实现了下降沿检测逻辑。该逻辑的输出驱动着板上的一个 LED(D1)。接下来我们将看看如何实现这个设计。
编写代码
我们可以使用 Verilog 或 VHDL 来编写 LED 切换的代码:
Verilog
module LED_Toggle_Project(
input i_Clk,
input i_Switch_1,
output o_LED_1);
❶ reg r_LED_1 = 1'b0;
reg r_Switch_1 = 1'b0;
❷ always @(posedge i_Clk)
begin
❸ r_Switch_1 <= i_Switch_1;
❹ if (i_Switch_1 == 1'b0 && r_Switch_1 == 1'b1)
begin
❺ r_LED_1 <= ~r_LED_1;
end
end
assign o_LED_1 = r_LED_1;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity LED_Toggle_Project is
port (
i_Clk : in std_logic;
i_Switch_1 : in std_logic;
o_LED_1 : out std_logic
);
end entity LED_Toggle_Project;
architecture RTL of LED_Toggle_Project is
❶ signal r_LED_1 : std_logic := '0';
signal r_Switch_1 : std_logic := '0';
begin
❷ process (i_Clk) is
begin
if rising_edge(i_Clk) then
❸ r_Switch_1 <= i_Switch_1;
❹ if i_Switch_1 = '0' and r_Switch_1 = '1' then
❺ r_LED_1 <= not r_LED_1;
end if;
end if;
end process;
o_LED_1 <= r_LED_1;
end architecture RTL;
我们首先定义两个输入(时钟和开关)和一个输出(LED)。然后我们创建两个信号❶:r_LED_1 和 r_Switch_1。我们通过使用 reg 关键字(即 寄存器 的缩写)在 Verilog 中定义,或者在 VHDL 中使用 signal 关键字。最终,这些信号将实现为触发器或寄存器,因此我们在它们的名称前加上字母 r。将任何你知道会变成寄存器的信号标记为 r_signal_name 是一种良好的编程习惯,因为它有助于保持代码的有序性和易于搜索。
接下来,我们在 Verilog 中初始化一个被称为 always 的代码块,或者在 VHDL 中初始化一个 process 代码块❷。这种类型的代码块会在一个或多个信号发生变化时触发,具体由代码块的 敏感列表 指定,敏感列表会在声明代码块时放在括号中。在这种情况下,该代码块对时钟信号 i_Clk 敏感。具体来说,每当时钟从 0 变为 1 时,这个代码块就会被触发,也就是说,在每个上升沿时触发。请记住,当你使用时钟来触发 FPGA 中的逻辑时,你几乎总是会使用时钟的上升沿。在 Verilog 中,我们通过关键字 posedge(即 正沿 的缩写,另一个说法是 上升沿)在敏感列表中进行指示:always @(posedge i_Clk)。然而,在 VHDL 中,我们只将信号名称放入敏感列表中,并在两行后指定观察上升沿,使用 if rising_edge(i_Clk) then。
在always或process块内,我们通过将输入信号i_Switch_1注册到r_Switch_1 ❸,创建了本项目的第一个触发器。此行代码将生成一个触发器,D 输入为i_Switch_1,Q 输出为r_Switch_1,时钟输入为i_Clk。此触发器的输出将对输入的任何变化产生一个时钟周期的延迟。这实际上让我们可以访问开关的前一个状态,而我们需要知道这个状态来检测开关信号的下降沿。
接下来,我们检查开关是否已经释放 ❹。为此,我们将开关的当前状态与其前一个状态进行比较,使用我们刚刚创建的触发器 ❸。如果当前状态(i_Switch_1)为 0,并且前一个状态(r_Switch_1)为 1,则表示我们检测到了下降沿,意味着开关已经释放。并且检查将通过查找表(LUT)来完成。
到此为止,也许你已经注意到了一些令人惊讶的地方。首先,我们将i_Switch_1赋值给了r_Switch_1 ❸,然后我们检查i_Switch_1是否为 0 且r_Switch_1为 1 ❹。你可能会想,既然我们刚刚将i_Switch_1赋值给了r_Switch_1,它们应该始终相等,那么if语句应该永远不会为真。对吧?错!在always或process块中的赋值语句,如果使用的是<=,赋值并不会立即发生。相反,它们会在每个时钟上升沿时进行,因此所有赋值同时执行。如果在时钟上升沿时i_Switch_1为 0 且r_Switch_1为 1,if语句将被评估为真,即使r_Switch_1同时从 1 切换到 0 以匹配i_Switch_1。
现在我们在并行思考,而不是串行思考!我们已经生成了同时发生的赋值,而不是逐个赋值。这与传统编程语言如 C 和 Python 完全不同,后者的赋值是一个接一个发生的。为了进一步说明这一点,你可以把r_Switch_1的赋值移到always或process块的最后一行,一切仍然会照常工作。从正式角度讲,我们称<=赋值为非阻塞赋值,意味着它不会阻止(“阻塞”)其他赋值同时发生。在第十章中,我们将重新审视这一概念,并比较非阻塞赋值和阻塞赋值。
一旦进入if语句,我们就切换 LED 的状态 ❺。这样就生成了该项目中使用的第二个触发器。我们获取r_LED_1的当前值,将其反转,并将结果存回触发器中。这个听起来可能不可能,但它完全有效。触发器的输出将通过一个查找表(LUT),在这里充当反向门(NOT gate),然后反馈到触发器的输入端。这样,如果 LED 是亮的,它就会熄灭,反之亦然。
添加约束
一旦代码准备好,就该运行工具来构建 FPGA 镜像并编程你的开发板了。首先,由于这个项目使用了时钟,你需要添加一个约束,告诉 FPGA 工具时钟的周期。时钟周期告诉时序工具有多少时间可以在触发器之间布线。随着时钟速度的增加,FPGA 要满足时序要求,即在每个时钟周期内完成所有任务,变得越来越困难。对于频率在数十兆赫兹数量级的较慢时钟,你不应该遇到时序问题。一般来说,只有当时钟速度超过 100 MHz 时,才可能会开始遇到时序问题。
时钟周期会因开发板不同而有所不同,可以在开发板的文档中找到。为了让 Lattice iCEcube2 知道时钟周期,创建一个新的文本文件,文件扩展名为 .sdc,内容可以像下面这样:
create_clock -period 40.00 -name {i_Clk} [get_ports {i_Clk}]
这将创建一个周期为 40 纳秒(25 MHz 频率)的时钟,并将该约束赋给设计中的信号i_Clk。这个约束适用于 Go Board 作为示例,但如果你的开发板时钟周期不同,请将40.00替换为适当的值。
右键点击约束文件,位于综合工具下,选择.sdc文件并将其添加到 iCEcube2 中的项目中。记住,在第二章中,我们之前有一个单一的.pcf约束文件,告诉工具将哪些信号映射到哪些引脚。现在,我们有了一个额外的专门用于时钟的约束文件。这两个文件对确保 FPGA 正常工作至关重要。
我们还需要更新.pcf文件,加入与新时钟信号对应的引脚。例如,在 Go Board 上,时钟连接到 FPGA 的引脚 15,因此您需要添加以下引脚约束:
set_io i_Clk 15
检查开发板的原理图,查看哪个引脚作为时钟输入,并根据需要替换15。
构建和编程 FPGA
现在您已经准备好运行构建了。运行时,工具将生成一些报告。综合报告应该类似于以下内容:
`--snip--`
Resource Usage Report for LED_Toggle_Project
Mapping to part: ice40hx1kvq100
Cell usage:
SB_DFF 2 uses
SB_LUT4 1 use
I/O ports: 3
I/O primitives: 3
SB_GB_IO 1 use
SB_IO 2 uses
I/O Register bits: 0
❶ Register bits not including I/Os: 2 (0%)
Total load per clock:
❷ LED_Toggle_Project|i_Clk: 1
Mapping Summary:
❸ Total LUTs: 1 (0%)
这份报告告诉我们,我们正在使用两个寄存器位❶,这意味着我们的设计包含两个触发器。这正是我们预期的。报告还显示我们使用了一个 LUT❸。这个单一的 LUT 将能够执行代码中所需的与、非操作。还要注意,工具将信号i_Clk识别为时钟❷。
现在让我们来看一下布局和布线报告,您可以通过在 iCEcube2 中转到P&R 流程输出文件报告来查看。这里有两个报告。第一个是引脚报告,它告诉您哪些信号被映射到哪些引脚。您可以用它来确认信号是否正确映射。第二个是时序报告。它有一个名为“时钟频率汇总”的部分,应该类似于下面的内容:
`--snip--`
1::Clock Frequency Summary
==========================================================
Number of clocks: 1
Clock: i_Clk | Frequency: 654.05 MHz | Target: 25.00 MHz |
`--snip--`
本节告诉你约束文件是否已正确接受。在这里,我们看到工具已经找到了我们的时钟,i_Clk。Target属性表明工具已识别时钟上施加了 25 MHz 的约束(你的数字可能不同,具体取决于开发板),而Frequency属性告诉我们 FPGA 理论上能够成功运行代码的最大频率。在这种情况下,我们可以将 FPGA 运行在 654.05 MHz 下,并且仍然可以保证正确工作。真是相当快!只要Frequency属性高于Target属性,你就不应当遇到任何问题。若目标时钟频率大于工具能够达到的频率,这里会出现时序错误。在第七章中,我们将更深入地探讨时序错误的原因以及如何修复它们。
现在你已经成功构建了 FPGA 设计,可以编程并测试你的项目。尝试多次按下开关。每次开关释放时,你应该看到 LED 的状态切换。恭喜你,第一次触发器已经工作了!
然而,你可能会注意到一些奇怪的现象。LED 的状态似乎没有随着每次开关释放而改变。你可能以为 FPGA 没有注册开关的释放信号,但实际上 LED 每次释放时都会迅速切换两次或更多次,以至于你的眼睛没有察觉到。其原因与开关本身的物理特性有关。为了解决这个问题,开关需要进行去抖动处理。你将在下一章中学习这是什么意思以及如何处理。
组合逻辑与时序逻辑
FPGA 内部有两种逻辑:组合逻辑和时序逻辑。组合逻辑是输出由当前输入决定的逻辑,不记忆先前的状态。这种逻辑通过 LUT 实现,正如你所记得,LUT 仅根据当前输入生成输出。时序逻辑则不同,它的输出由当前输入和先前的输出共同决定。时序逻辑通过触发器实现,因为触发器不会立即将输入变化反映到输出,而是等到时钟的上升沿才对新的输入数据做出反应。
注意
你可能也会看到组合逻辑和时序逻辑分别被称作组合逻辑和同步逻辑。
可能不太明显的是,触发器的输出取决于其先前的输出,因此让我们通过一个例子来更具体地说明这个问题。假设触发器已启用,输入为低电平,时钟为低电平,输出为低电平。然后输入突然变为高电平,之后又迅速回到低电平。那么输出会发生什么呢?什么都不发生!它保持低电平,因为没有时钟边缘来触发状态变化。那么,如果该触发器具有相同的初始条件,但输出为高电平,会发生什么呢?当然,在这种情况下,输出将保持高电平。但是,如果我们只看输入(D、En 和 Clk),我们将无法预测输出的状态。你需要知道触发器的输出是什么(它的先前状态),才能确定触发器的当前状态。这就是为什么触发器是顺序逻辑的原因。
知道你的代码是会实例化查找表(组合逻辑)还是触发器(顺序逻辑)对于成为一个优秀的 FPGA 设计师至关重要,但有时很难分辨二者的区别。特别是,一个 always 块(在 Verilog 中)或 process 块(在 VHDL 中)可以定义一个组合逻辑或顺序逻辑的代码块。我们将考虑每种情况的示例,以查看它们的区别。
首先,这里是一个在 Verilog 和 VHDL 中的组合逻辑实现示例:
Verilog
always @ (input_1 or input_2)
begin
and_gate <= input_1 & input_2;
end
VHDL
process (input_1, input_2)
begin
and_gate <= input_1 and input_2;
end process;
这里我们创建了一个带有敏感列表(括号中的信号)的 always 或 process 块,该敏感列表包括两个信号:input_1 和 input_2。该代码块对这两个信号执行与操作。
这段 Verilog 或 VHDL 代码只会生成查找表(LUT);它不会生成任何触发器。对于我们的目的来说,触发器需要一个时钟输入,而这里没有时钟。由于没有生成触发器,因此这是组合逻辑。
现在考虑对刚才展示的例子做一个轻微的修改:
Verilog
always @ (posedge i_Clk)
begin
and_gate <= input_1 & input_2;
end
VHDL
process (i_Clk)
begin
if rising_edge(i_Clk) then
and_gate <= input_1 and input_2;
end if;
end process;
这段代码看起来与之前的示例非常相似,唯一的不同是现在 always 或 process 块的敏感列表已经改变,变得对信号 i_Clk 敏感。由于该块对时钟敏感,它现在被认为是顺序逻辑。这个代码块实际上仍然需要一个 LUT 来执行与操作,但除了这个之外,输出将会使用一个触发器,因为时钟在控制输出的更新频率。
虽然本节中的所有示例代码都是有效的,但我要特别给 FPGA 初学者一个建议:编写代码时,只创建顺序的 always 块(在 Verilog 中)或 process 块(在 VHDL 中)。做到这一点的方法是确保该块的灵敏度列表中只有时钟(时钟和复位也可以,我们将在本章后面讨论)。组合 always 块和 process 块可能会导致问题:你可能会无意间生成锁存器。我们将在下一节中探讨锁存器,基本上,它们是坏的。此外,我发现,如果你知道每次遇到 always 块或 process 块时,它总是生成顺序逻辑,代码会更加易读。
对于仅含组合逻辑的部分,应将其写在< sAmp class="SANS_TheSansMonoCd_W5Regular_11">always 块或 process 块之外。在 Verilog 中,关键字 assign 很有用。在 VHDL 中,你可以简单地使用 <= 赋值语句来创建组合逻辑。
锁存器的危险性
锁存器 是一种可以在不使用时钟的情况下存储状态的数字组件。通过这种方式,锁存器执行的功能与触发器相似(即存储状态),但它们使用的方法不同,因为没有涉及时钟。锁存器很危险,在处理组合逻辑代码时可能会不小心生成它们。在我的职业生涯中,我从未故意生成过锁存器,都是意外产生的。你也很不可能真的需要生成锁存器,因此了解如何避免它们是很重要的。
你始终希望你的 FPGA 设计是可预测的。锁存器之所以危险,是因为它们违背了这一原则。FPGA 工具在理解锁存器的时序关系以及连接到它的其他组件的表现时非常困难。如果你确实通过代码生成了锁存器,FPGA 工具会用警告告诉你,你做了一个可怕的事情。请不要忽视这些警告。
那么如何会发生这种情况呢?当你编写一个组合型 process 块或条件赋值(在 VHDL 中)或组合型 always 块(在 Verilog 中)时,如果进行 不完全赋值,即在所有可能的输入条件下输出没有被赋值,就会创建一个锁存器。这是错误的,应该避免。表 4-1 显示了一个会生成锁存器的真值表示例。
表 4-1: 创建锁存器的真值表
| 输入 A | 输入 B | 输出 Q |
|---|---|---|
| 0 | 0 | 0 |
| 0 | 1 | 1 |
| 1 | 0 | 1 |
| 1 | 1 | 未定义 |
这个真值表有两个输入和一个输出。当两个输入都为 0 时,输出为 0;当输入 A 为 0 且输入 B 为 1,或者输入 A 为 1 且输入 B 为 0 时,输出为 1。但是,当两个输入都为 1 时会发生什么呢?我们没有明确说明会发生什么情况。在这种情况下,FPGA 工具假设输出应保持其先前的状态,就像触发器能够做到的那样,但不使用时钟。例如,如果输出为 0 且两个输入都变高,输出将保持为 0。如果输出为 1 且两个输入都变高,输出将保持为 1。这就是锁存器的行为:在没有时钟的情况下存储状态的能力。
让我们来看看如何在 Verilog 和 VHDL 中创建这个真值表。不要像这样写代码!
Verilog
❶ always @ (i_A or i_B)
begin
if (i_A == 1'b0 && i_B == 1'b0)
o_Q <= 1'b0;
else if (i_A == 1'b0 && i_B == 1'b1)
o_Q <= 1'b1;
else if (i_A == 1'b1 && i_B == 1'b0)
o_Q <= 1'b1;
❷ // Missing one last ELSE statement!
end
VHDL
❶ process (i_A, i_B)
begin
if i_A = '0' and i_B = '0' then
o_Q <= '0';
elsif i_A = '0' and i_B = '1' then
o_Q <= '1';
elsif i_A = '1' and i_B = '0' then
o_Q <= '1';
❷ -- Missing one last ELSE statement!
end if;
end process;
在这里,我们的< samp class="SANS_TheSansMonoCd_W5Regular_11">always或< samp class="SANS_TheSansMonoCd_W5Regular_11">process块是组合式的,因为在灵敏度列表❶或块本身中没有时钟,只有两个输入,< samp class="SANS_TheSansMonoCd_W5Regular_11">i_A和< samp class="SANS_TheSansMonoCd_W5Regular_11">i_B。我们通过条件检查模拟输出< samp class="SANS_TheSansMonoCd_W5Regular_11">o_Q的不完全真值表赋值。请注意,我们没有显式地检查< samp class="SANS_TheSansMonoCd_W5Regular_11">i_A和< samp class="SANS_TheSansMonoCd_W5Regular_11">i_B都为 1 的条件。大错误!
如果你尝试合成这段有问题的代码,FPGA 工具会生成一个锁存器,并在合成报告中给你警告。警告可能是这样的:
@W: CL118 :"C:\Test.v":8:4:8:5|Latch generated from always block for signal
o_Q; possible missing assignment in an if or case statement.
这些工具非常好。它们会告诉你是否有锁存器,并告诉你是哪一个信号(o_Q),还会告诉你为什么可能会发生这种情况。
为了避免生成锁存器,我们可以添加一个< samp class="SANS_TheSansMonoCd_W5Regular_11">else语句❷,它将覆盖所有剩余的可能性。只要对所有可能的输入定义了输出,我们就能确保安全。然而,一个更好的解决方案是根本不使用组合式< samp class="SANS_TheSansMonoCd_W5Regular_11">always或< samp class="SANS_TheSansMonoCd_W5Regular_11">process块。我不推荐使用组合式< samp class="SANS_TheSansMonoCd_W5Regular_11">always或< samp class="SANS_TheSansMonoCd_W5Regular_11">process块,正因为它容易犯下遗漏< samp class="SANS_TheSansMonoCd_W5Regular_11">else语句的错误。相反,我们可以使用顺序式< samp class="SANS_TheSansMonoCd_W5Regular_11">always或< samp class="SANS_TheSansMonoCd_W5Regular_11">process块。如下所示:
Verilog
❶ always @ (posedge i_Clk)
begin
if (i_A == 1'b0 && i_B == 1'b0)
o_Q <= 1'b0;
else if (i_A == 1'b0 && i_B == 1'b1)
o_Q <= 1'b1;
else if (i_A == 1'b1 && i_B == 1'b0)
o_Q <= 1'b1;
end
VHDL
❶ process (i_Clk)
begin
if rising_edge(i_Clk) then
if i_A = '0' and i_B = '0' then
o_Q <= '0';
elsif i_A = '0' and i_B = '1' then
o_Q <= '1';
elsif i_A = '1' and i_B = '0' then
o_Q <= '1';
end if;
end if;
end process;
现在我们有一个顺序的 always 或 process 块,因为我们在敏感列表 ❶ 中以及块内部使用了时钟。因此,o_Q 将创建一个触发器,而不是锁存器。触发器没有像锁存器那样不可预测的时序问题。记住,触发器可以利用其 en 输入保持一个值。当 i_A 和 i_B 都为高时,触发器的 en 输入将被禁用。这将使触发器保持其先前的输出状态,表现出与锁存器相同的行为,但以安全、可预测的方式进行。
切换到顺序 always 或 process 块的一个副作用是,现在输出更新需要一个完整的时钟周期。如果必须确保该逻辑是组合逻辑——即输出一旦某个输入变化就立即更新,且没有时钟延迟——那么你需要确保在所有可能的输入条件下都为输出指定值。
在 VHDL 中,还有一种生成锁存器的方式。VHDL 有一个关键字 when,可以用于条件赋值。Verilog 没有等效的语法,因此这段代码仅适用于 VHDL:
o_Q <= '0' when (i_A = '0' and i_B = '0') else
'1' when (i_A = '0' and i_B = '1') else
'1' when (i_A = '1' and i_B = '0');
这段代码位于 process 块之外,我们仍然没有明确声明当 i_A 和 i_B 都为 1 时 o_Q 应该赋值为什么,因此 FPGA 工具将推断出一个锁存器。在这种情况下,锁存器将允许输出保持其先前的状态,但这可能不是我们想要的。相反,我们应该在代码中明确指定,并确保有一个 else 条件来为所有可能的输入设置 o_Q。
重置触发器
触发器有一个额外的输入,我们还没有讨论过,叫做 set/reset,或者通常只是 reset。这个引脚将触发器重置回初始状态,可能是 0 或 1。复位触发器在 FPGA 上电初始化时非常有用。例如,你可能希望将控制状态机的触发器复位到初始状态(我们将在第八章讨论状态机)。你也可能希望将计数器复位为某个初始值,或者将滤波器复位为零。复位触发器是确保触发器在操作之前处于特定状态的一种方法。
复位有两种类型:同步复位和异步复位。同步复位与时钟边缘同时发生,而 异步复位 可以在任何时间发生。你可能通过外部按钮按下触发异步复位,例如,因为按钮按下可以在任何时刻发生。让我们先从同步复位开始,看看如何编写复位代码:
Verilog
❶ always @ (posedge i_Clk)
begin
❷ if (i_Reset)
o_Q <= 1'b1;
❸ else
`--snip--`
VHDL
❶ process (i_Clk)
begin
if rising_edge(i_Clk) then
❷ if i_Reset = '1' then
o_Q <= '1';
❸ else
`--snip--`
这里我们有一个带有普通敏感度列表的 always 或 process 块;它只对时钟变化敏感 ❶。在该块内部,我们首先检查 i_Reset 的状态 ❷。如果它为高电平,则将信号 o_Q 重置为 1。这是我们的同步复位,因为它发生在时钟的边缘。如果 i_Reset 为低电平,我们将继续执行该块的 else 分支 ❸,在这里我们会编写在正常操作(非复位)条件下希望执行的代码。
注意,在这个示例中,我们检查复位是否为高电平。有时复位是低电平有效的,通常通过信号名称末尾的 _L 或 _n 来表示。如果这是一个低电平有效的复位,我们将检查信号是否为 0,而不是 1。
现在让我们来看一个异步复位的例子:
Verilog
❶ always @ (posedge i_Clk or i_Reset)
begin
❷ if (i_Reset)
o_Q <= 1'b1;
❸ else
`--snip--`
VHDL
❶ process (i_Clk, i_Reset)
begin
❷ if (i_Reset = '1') then
o_Q <= '1';
❸ elsif rising_edge(i_Clk) then
`--snip--`
注意我们已将 i_Reset 添加到 always 或 process 块的敏感度列表中 ❶。现在,我们首先检查复位状态 ❷,而不是时钟状态。如果复位为高电平,我们执行所需的复位条件,在这种情况下将 o_Q 设置为 1。否则,我们按正常流程执行 ❸。
关于同步复位和异步复位的选择,应在你特定 FPGA 的用户指南中进行说明——某些 FPGA 优化了其中之一。此外,复位如果没有得到正确处理,可能会导致奇怪的错误。因此,我强烈建议查阅文档,确保你正确地为设备复位触发器。
真实 FPGA 中的查找表和触发器
现在你明白了 LUT 和触发器存在于 FPGA 中,但它们可能仍然显得有些抽象。为了更具体地了解它们的实际工作方式,让我们看看 LUT 和触发器是如何在真实 FPGA 中连接在一起的。图像来自图 4-10,取自 Lattice iCE40 LP/HX 系列 FPGA 的数据手册,这种 FPGA 与 iCEcube2 兼容。
数据手册被电子行业广泛用于解释组件的工作原理。每个 FPGA 至少有几本独特的数据手册,包含不同的信息,而更复杂的 FPGA 可能会有几十本。

图 4-10:真实 FPGA 中的 LUT 和触发器
每个 FPGA,无论是 Lattice、AMD、Intel 还是其他公司,都会在其特定系列的数据手册中包含类似于图 4-10 的图像。这个特定的图像展示了 Lattice iCE40 FPGA 的基本构建块,Lattice 称之为可编程逻辑块(PLB)。每个 FPGA 公司都有自己独特的名称来描述这些基本构建块;例如,AMD 称其为可配置逻辑块(CLB),而 Intel 则使用自适应逻辑模块(ALM)。我们将以 Lattice 的 PLB 为例,详细了解其工作原理。
从图像的左侧来看,我们看到每个 PLB 中有八个逻辑单元。右侧显示的是单个逻辑单元的放大版本。在其中,注意到有一个矩形框标有 LUT4\。这就是一个四输入查找表!还有一个深灰色的框,标有 DFF。这是一个 D 触发器!LUT 和触发器确实是 FPGA 内部最关键的两个组件。
这个图表告诉我们,在最基本的层面上,每个逻辑单元中都有一个查找表(LUT)和一个触发器(flip-flop),而一个 PLB 中包含八个逻辑单元。PLB 在 FPGA 内部被复制粘贴成百上千次,以提供足够的 LUT 和触发器来完成所有必要的工作。
在 DFF 组件(触发器)的左侧,注意到我们在图 4-1 中看到的三个输入:数据(D)、时钟使能(EN)和时钟(>)。组件底部的第四个输入是我们在前一节中讨论过的设置/复位(SR)输入。
正如你所看到的,时钟使能输入允许触发器在多个时钟周期中保持其输出状态。如果没有 En 输入,输出将仅跟随输入,并且有一个时钟周期的延迟。添加 En 输入后,触发器可以存储一个状态更长的时间。
在图示中最后需要注意的是进位逻辑块,它位于 LUT4 的上方和左侧。这个块主要用于加速算术运算,如加法、减法和比较。
虽然回顾这个图示让我们对 FPGA 内部有了一个有趣的了解,并突出了 LUT 和触发器的核心作用,但记住 PLB 架构的每一个细节并不是至关重要的。你不需要记住所有的连接以及它们如何与邻居连接。在实际应用中,你编写 Verilog 或 VHDL 代码,而 FPGA 工具会处理将代码映射到 FPGA 资源上的工作。如果你想从一种 FPGA 切换到另一种(比如从 Lattice 切换到 AMD),这点尤其有用。Verilog 和 VHDL 的优点在于代码通常是可移植的;相同的代码可以在不同的 FPGA 上运行,只要它们有足够的 LUT 和触发器来满足你的需求。
总结
在本章中,你学习了触发器,它与 LUT 一起,是 FPGA 中最重要的两个组件之一。你看到触发器如何通过仅在时钟信号的正边沿注册输入到输出的数据,让 FPGA 保持状态或记住过去的值。你了解了由触发器和时钟信号驱动的逻辑是顺序逻辑,这与 LUT 的组合逻辑不同,并且你通过一个切换 LED 的项目首次了解了触发器和 LUT 如何协同工作。你还学习了如何避免生成锁存器以及如何将触发器重置为默认状态。
在未来的章节中,当你构建更复杂的代码模块时,你将更加熟悉触发器和查找表(LUT)如何相互作用,并且你会看到如何仅通过这两种组件来创建大型、复杂的 FPGA 设计。你还会看到触发器在跟踪计数器和状态机中的作用。
第十章:5 使用仿真测试你的代码

有两种方法可以找到 FPGA 设计中不可避免出现的错误。第一种方法是编程 FPGA,运行它,看看发生了什么。这被称为在硬件上找错。另一种方法是使用计算机将测试用例注入到 FPGA 代码中,看看代码在你实际编程 FPGA 之前如何响应。这被称为在仿真中找错。
对于非常简单的项目,比如本书到目前为止我们所探索的项目,直接跳过任何形式的仿真,直接编程 FPGA 可能是一个合理的方式(这也是我们到目前为止所采取的方式)。然而,随着 FPGA 设计的复杂度增加,发现硬件中的错误变得极其困难。在几乎所有情况下,在仿真中发现错误要容易得多。在彻底仿真和调试设计之后,没有什么比最终编程 FPGA 并且第一次就让一切工作完美更令人满足的了。
在这一章中,你将学习仿真是如何工作的,并了解它为何是 FPGA 设计过程中的一个重要步骤。我们将探索一个免费的仿真工具,并且我将介绍测试平台,展示如何编写测试代码来测试你的设计。你将通过为前一章中的 LED 切换项目(项目#3)添加去抖电路并仿真设计来实践这些概念。最后,我们将看看验证,这是一种更正式和严格的 FPGA 和 ASIC 设计测试过程。
为什么仿真很重要
仿真很重要,因为你的 FPGA 本质上是一个黑箱,正如图 5-1 所示。当你编程 FPGA 时,你能够更改输入并查看输出如何响应,但你无法看到箱子内部发生的具体细节。你不能追踪各个变量和数据信号在 FPGA 内部的流动。

图 5-1: 箱子里有什么?
如果黑箱内部出了问题(而且它一定会出现问题),并且输出结果与预期不符,找出问题所在是非常困难的。解决方案是使用计算机仿真黑箱的内部工作原理,以一种你可以理解的方式来查看。仿真有效地打开了 FPGA 的黑箱,让你能够看到内部发生了什么。
让我给你一个这个工具有多么有用的例子。在我过去的一份工作中,有一个同事在尝试修复他 FPGA 设计中的一个问题。由于某种原因,数据在 FPGA 内部被混淆了。他花了几个星期用示波器和逻辑分析仪将数据从 FPGA 中送出,以便查找问题的根源。有一次,我问他是否做过仿真设计。他没有做过:他没有仿真经验,也觉得自己没有时间去学习。我从版本控制中取出了他的代码,并为其制作了仿真模型,在几个小时内找出了问题。
仿真你的设计能够对其进行压力测试,看看它的反应。在这种情况下,我能够在仿真中重新创建出相同的故障,并迅速修复问题。具有讽刺意味的是,我的同事在硬件上调试问题时所花费的时间,他完全可以花时间学习如何进行仿真。考虑到一旦掌握了仿真原理,你可以反复使用这一技能,这无疑是一个更具吸引力的选择。
FPGA 仿真工具
有几款流行的 FPGA 仿真工具可供选择。FPGA 构建工具通常会捆绑一个仿真工具,作为一个大型可下载的包提供;FPGA 公司知道他们的设计师需要运行仿真,因此他们希望简化这一过程。这些工具通常是免费的且方便使用,但它们的文件大小可能有好几个 GB,而且对初学者来说,工具的复杂性可能会让人不知所措。
针对大型 FPGA 工具的另一种解决方案是使用独立的仿真器。这样做的好处是,如果你从 Intel(Altera)切换到 AMD(Xilinx),例如,你不需要学习一个全新的工具;你的仿真器可以保持不变。我通常推荐两款流行的独立仿真工具:ModelSim 和 EDA Playground。ModelSim 可能是最流行的商业仿真器。它可以在 Windows 和 Linux 上下载并安装。完整版许可证很贵,价格大约为 2000 美元,但有一个功能有限的免费版本。
相比之下,EDA Playground 是一个免费提供的基于 Web 的仿真工具。我推荐在你刚开始学习 FPGA 设计时使用它,原因有几个。首先,它是免费的。其次,由于它是基于 Web 的,因此无需下载。最后,EDA Playground 允许你通过网页链接与他人共享代码。对于本书的目的,我们将重点介绍这个工具。
要开始使用 EDA Playground,首先访问 https://

图 5-2:EDA Playground 主界面
注意,有两个主要的代码窗口。右侧窗口,标题为design.sv,是你要测试的 FPGA 设计代码所在的地方。这个代码通常被称为被测试单元(UUT)或被测试设备(DUT)。左侧窗口,称为testbench.sv,是你编写测试平台的地方,测试平台代码将在仿真过程中对你的 FPGA 设计进行操作。我们将在下一节讨论测试平台的工作原理。
默认情况下,EDA Playground 配置为 SystemVerilog/Verilog 设计,这就是为什么两个窗口标签有 .sv(SystemVerilog)文件扩展名的原因。如果你希望将 EDA Playground 配置为 VHDL,请在窗口左侧的 Testbench + Design 下拉菜单中选择VHDL。
在你可以在 EDA Playground 中运行代码之前,你需要选择一个仿真工具。这是实际运行你代码的产品。你可以在“工具和仿真器”下拉菜单中尝试不同的工具,看看是否更喜欢其中的某一个。通常,我发现它们的行为非常相似,尽管有些工具是专门为 Verilog 或 VHDL 设计的。我使用 Mentor Questa 或 Aldec Riviera 取得过不错的成果。
EDA Playground 的另一个有趣功能是工具栏中的示例部分。在这里,你可以查看一些免费的示例测试平台。你可以了解它们的工作原理并修改它们用于自己的实验,或许还能获得一些写自己代码的巧妙思路。
测试平台
测试平台的目的是在仿真环境中对你的 UUT 进行操作,以便你可以分析它并查看它是否按预期工作。测试平台代码实例化了 UUT。如图 5-3 所示,测试平台提供了所有必需的输入给 UUT,并监控所有的输出。

图 5-3:测试平台对 UUT 进行操作,以便你进行分析。
如果你的被测试单元(UUT)有一个时钟作为输入,例如,测试平台将需要生成该时钟并将其馈送到 UUT 中。类似地,如果 UUT 有数据接口,测试平台可能需要生成一些样本数据并将其提供给该接口。测试平台会监控 UUT 的所有输出,从而了解 UUT 如何响应输入数据。在仿真过程中,你还可以深入查看 UUT 本身,了解其所有内部信号如何响应测试平台的输入。你可以监控设计中的每个元素——每个寄存器、时钟、信号线、存储器等等——并确保它们按预期工作。
编写测试平台
让我们通过为第三章(项目#2)中的与门项目编写一个测试平台来查看一个简单的例子。首先,回顾一下,这是我们要测试的原始项目代码:
Verilog
module And_Gate_Project
(input i_Switch_1,
input i_Switch_2,
output o_LED_1);
assign o_LED_1 = i_Switch_1 & i_Switch_2;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity And_Gate_Project is
port (
i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
o_LED_1 : out std_logic);
end entity And_Gate_Project;
architecture RTL of And_Gate_Project is
begin
o_LED_1 <= i_Switch_1 and i_Switch_2;
end RTL;
将模块或实体的代码输入到 EDA Playground 右侧的design.sv或design.vhd窗口中。为了完全测试代码,我们需要对其进行操作,确保输出在所有可能的输入组合下表现如预期。在这种情况下,输入组合的总范围相当小:由于有两个输入,因此只需要测试四种可能的组合,就能完全验证 UUT 的功能。我们将分别在 Verilog 和 VHDL 中创建测试平台,以实例化 UUT 并通过传入每种输入组合来进行测试。将以下代码输入到 EDA Playground 中的testbench.sv或testbench.vhd窗口中:
Verilog
❶ module And_Gate_TB();
reg r_In1, r_In2;
wire w_Out;
❷ And_Gate_Project UUT
(.i_Switch_1(r_In1),
.i_Switch_2(r_In2),
.o_LED_1(w_Out));
❸ initial
begin
❹ $dumpfile("dump.vcd"); $dumpvars;
r_In1 <= 1'b0;
r_In2 <= 1'b0;
#10;
r_In1 <= 1'b0;
r_In2 <= 1'b1;
#10;
r_In1 <= 1'b1;
r_In2 <= 1'b0;
#10;
r_In1 <= 1'b1;
r_In2 <= 1'b1;
#10;
$finish();
end
endmodule
VHDL
library IEEE;
use IEEE.std_logic_1164.all;
use std.env.finish;
❶ entity And_Gate_TB is
end entity And_Gate_TB;
architecture behave of And_Gate_TB is
signal r_In1, r_In2, w_Out : std_logic;
begin
❷ UUT : entity work.And_Gate_Project
port map (
i_Switch_1 => r_In1,
i_Switch_2 => r_In2,
o_LED_1 => w_Out);
❸ process is
begin
r_In1 <= '0';
r_In2 <= '0';
wait for 10 ns;
r_In1 <= '0';
r_In2 <= '1';
wait for 10 ns;
r_In1 <= '1';
r_In2 <= '0';
wait for 10 ns;
r_In1 <= '1';
r_In2 <= '1';
wait for 10 ns;
wait for 10 ns;
finish;
end process;
end behave;
首先,请注意,这是我们第一次看到没有声明任何输入或输出的 Verilog 模块或 VHDL 实体❶。这是因为该测试平台没有连接任何外部信号;正如你在图 5-3 中看到的,测试平台本身提供输入。
在模块/实体内部,我们实例化了 UUT ❷。我们将 UUT 的输入连接到在测试平台中声明的r_In1和r_In2信号。这些信号将作为刺激输入,用于观察 UUT 的响应。我们将监控输出w_Out,以查看它如何响应变化的输入。我喜欢在信号名上使用w_前缀,表示 FPGA 内的电线或互连。记住,我们需要确保与门按预期工作。
我们开始在 Verilog 中的initial块或 VHDL 中的process块内驱动刺激(输入)。这个块会在仿真开始时执行,并按顺序从上到下执行。我们将逐一将四种可能的输入组合发送到 UUT。使用延时语句,我们在每个输入组合之间添加 10 纳秒的暂停,以便仿真有时间在每次变化后更新w_Out信号。在 Verilog 中,我们使用#10延时特性,在 VHDL 中则使用wait for 10 ns;。正如你将在本章稍后看到的,这些基于时间的延时——事实上,任何关于时间流逝的引用——都是不可综合的,意味着它们在实际 FPGA 上无法工作;然而,在仿真中它们可以完美运行。
在 Verilog 版本中,请注意 EDA Playground 需要$dumpfile指令❹。这使得模拟器能够生成波形,我们将在下一节进行讲解。VHDL 中不需要此行代码。
运行测试平台并查看波形
运行测试平台会生成波形,即测试环境中信号的可视化表示,展示信号如何随时间变化。波形是用于检查 FPGA 设计在仿真过程中失败的强大工具;你与 FPGA 打交道的时间越长,越会花时间盯着波形看。EDA Playground 通过内置的波形查看器 EPWave 使得查看波形变得更加简便。
让我们运行与门测试平台,并在 EPWave 中查看结果波形。首先,在 EDA Playground 窗口左侧的工具和仿真器部分勾选运行后打开 EPWave复选框。如果你使用的是 VHDL,你需要指定设计的顶层实体。为此,在“顶层实体”对话框中输入And_Gate_TB。然后从下拉菜单中选择一个仿真器工具并点击运行。图 5-4 显示了生成的波形。

图 5-4:与与门测试平台波形输出
在这里,我们可以看到设计中所有的信号,并且可以记录下每个信号从高到低或从低到高变化时的时间,单位为纳秒。顶部的三个信号(r_In1、r_In2、w_Out)是测试平台信号。底部的三个信号(i_Switch_1、i_Switch_2、o_LED_1)则位于被测单元(UUT)中。由于我们在实例化 UUT 时将测试平台和 UUT 信号连接在一起,因此对应的测试平台/UUT 信号是相同的。例如,r_In1的波形与i_Switch_1相同。注意,如果 UUT 中有其他内部信号没有从模块中引出,你也可以看到这些信号的波形,而且它们不会有对应的测试平台信号。
从波形图中,我们可以看到 UUT 的工作情况符合预期。只有当两个输入都为高时,AND 门的输出(o_LED_1 和 w_Out)才为高。当只有一个输入为高,或两个输入都为低时,输出为低。在检查波形时,回顾测试平台代码,注意波形中的变化是如何对应到initial或process块中的语句。例如,在代码中,两个输入一开始为低,之后r_In2在 10 ns 后变为高。查看波形中的 10 ns 位置,你可以看到r_In2和i_Switch_2从低变为高。
尽管这是一个简单的示例,但它展示了测试平台的强大功能,能够模拟你的 FPGA 设计并让你看到所有发生的事情。你可以监控设计中的所有交互,如果某个信号未按预期工作,你可以调查原因,修改代码,并重新运行测试平台生成新的波形。在调试问题时,我通常会重新运行数十次模拟,直到设计按预期工作为止。
在这种情况下,由于我们只测试了一个基本模块,因此我们可以使用一个测试平台文件来评估所有内容。然而,对于更复杂的模拟,测试平台可能包含多个文件,这些文件协同工作,模拟、监视并检查你的设计,以确保它按预期工作。
在我们的下一个项目中,你将更详细地了解测试平台的工作原理,我们将在项目中编写一个测试平台,并在编程硬件之前模拟 FPGA 设计。这将帮助你确认代码是否正常工作,并允许你在过程早期发现并修复任何 bug。该项目还展示了 FPGA 上时间概念的工作方式,即使你没有 FPGA 可以编程,我仍然建议阅读这一部分。
项目#4:去抖动一个开关
在第四章中,我们编程了一个 FPGA,使其在按下按钮时切换 LED 的状态。然而,存在一个问题:按下按钮并不能稳定地切换 LED 的状态。这是因为任何物理开关,包括按钮或切换开关,都容易受到抖动的影响,抖动是指当开关被切换或翻转时,信号发生快速波动。抖动发生在开关内部的金属触点快速接触后又迅速分开,未能及时稳定到稳定状态。图 5-5 展示了这一过程如何影响开关的输出信号。

图 5-5: 机械开关中的抖动
如果你不了解抖动,你可能会期待开关表现得像图 5-5 的上半部分。按钮被按下时,输出立即从低变高。然而,在现实世界中,抖动会在输出信号中产生毛刺,这表现为输出信号的快速低到高再到低的过渡,直到它最终保持高电平。这再次是因为机械开关接触点在快速接合和分开后,才稳定在一个稳定的输出状态。
我们在 LED 切换项目中的代码是寻找一个单一的下降沿来指示按钮的按下和释放,但由于抖动,FPGA 在每次按下/释放时都会看到多个下降沿。如果在抖动过程中看到了奇数个下降沿,那么 LED 就成功切换。如果看到的是偶数个下降沿,LED 则没有改变状态,因为每对下降沿实际上是相互抵消的。
开关上的抖动次数是有些随机的,因此按下开关足够多次,LED 才会成功切换。然而,如果每次按下和释放开关时,LED 都能按预期切换,那将更好。为了实现这一点,我们需要在开关上添加一个去抖动滤波器。也就是说,我们需要编程 FPGA 来忽略抖动。图 5-6 展示了这将如何工作。

图 5-6: 项目 #4 块图
我们将会在前一个项目的代码中加入一个去抖动滤波器,以确保按钮的单次按下只切换一次 LED。来自开关的信号将通过去抖动滤波器,然后再传递给我们在上一章中编写的 LED 切换逻辑。
我们通过确保来自开关的输入在允许驱动 LED 输出发生变化之前保持稳定一段时间来创建去抖动滤波器。因此,我们需要在 FPGA 中有某种关于时间流逝的概念。然而,将时间引入 FPGA 设计带来了一些有趣的挑战。
在 FPGA 上测量时间
时间在 FPGA 中本身并不存在。FPGA 不会自动知道现在是否是星期六上午 11:00,或者如何等待 100 毫秒,例如。确实,Verilog 和 VHDL 代码中有些部分是与时间相关的。例如,我们已经看到如何在 Verilog 中使用 #10 或在 VHDL 中使用 wait for 10 ns; 来给我们的与门测试平台添加 10 ns 的延迟。再举个例子,在 Verilog 中,你可以使用 $time 来获取当前时间,而在 VHDL 中,保留字 now 会获取当前时间的时间戳。然而,像这些特性在仿真中可以正常工作,但它们 100% 无法在你的 FPGA 上运行。它们是无法综合的。
我们已经讨论过几次综合。综合是构建过程中,FPGA 工具将你的 Verilog 或 VHDL 代码转化为触发器、查找表(LUT)和其他组件的部分。不幸的是,综合工具无法综合任何与时间相关的内容。这是无法实现的。因此,像 $time 和 now 这样的语言构造将被简单地忽略,或者在综合过程中产生错误。在第七章中,我们将更详细地了解哪些 VHDL 和 Verilog 特性无法在 FPGA 上综合。目前,先接受这样一个事实:我们无法使用与时间相关的某些内建特性。
如果时间在 FPGA 中不存在,如何追踪已过去的时间,以便执行去抖动开关或其他许多与你希望 FPGA 执行的时间相关的任务呢?答案是计算时钟周期。如果你知道已经发生了多少个时钟周期,并且你知道时钟的周期,就能知道已经过去了多少时间。让我们通过一个例子来演示。
假设你有一个时钟,它的频率为 25 MHz,时钟的周期——即单个周期的持续时间——是 40 ns。根据这些规格,经过 400 ns 需要多少个时钟周期?答案:10。那经过 4,000 ns 需要多少个时钟周期?答案:100。只需将你想要等待的时间除以时钟的周期,就能得到在该时间过去之前需要计数的时钟周期数。这项技术将在我们的去抖动项目中至关重要。
编写代码
让我们来看一下如何实现去抖动滤波器。我们将从顶层模块开始,它实例化并连接两个低层模块,一个用于去抖动开关,另一个用于切换 LED:
Verilog
module Debounce_Project_Top
(input i_Clk,
input i_Switch_1,
output o_LED_1);
wire w_Debounced_Switch;
❶ Debounce_Filter ❷ #(.DEBOUNCE_LIMIT(250000)) Debounce_Inst
(.i_Clk(i_Clk),
.i_Bouncy(i_Switch_1),
.o_Debounced(w_Debounced_Switch));
❸ LED_Toggle_Project LED_Toggle_Inst
(.i_Clk(i_Clk),
.i_Switch_1(w_Debounced_Switch),
.o_LED_1(o_LED_1));
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Debounce_Project_Top is
port (
i_Clk : in std_logic;
i_Switch_1 : in std_logic;
o_LED_1 : out std_logic
);
end entity Debounce_Project_Top;
architecture RTL of Debounce_Project_Top is
signal w_Debounced_Switch : std_logic;
begin
❶ Debounce_Inst : entity work.Debounce_Filter
generic map(
❷ DEBOUNCE_LIMIT => 250000)
port map (
i_Clk => i_Clk,
i_Bouncy => i_Switch_1,
o_Debounced => w_Debounced_Switch);
❸ LED_Toggle_Inst : entity work.LED_Toggle_Project
port map (
i_Clk => i_Clk,
i_Switch_1 => w_Debounced_Switch,
o_LED_1 => o_LED_1);
end architecture RTL;
这段代码与图 5-6 中的框图相匹配。在最高层,我们有 Debounce_Project_Top,它实例化了两个其他模块。第一个是新的去抖动滤波器 ❶,我们将在下文中检查它。第二个是我们在上一章创建的 LED_Toggle_Project 模块 ❸。值得花点时间跟踪这里的信号。我们可以看到输入信号 i_Switch_1 进入去抖动滤波器。从中输出 w_Debounced_Switch,这是该输入的去抖动版本。这个信号被传入 LED_Toggle_Project 模块。该模块的输出是 o_LED_1,它将连接到你开发板上的 LED 引脚。请注意,通过信号的名称来指示信号的方向,如我们在这里使用的 i_ 和 o_ 前缀,在设计变得更大且包含更多信号时非常有帮助。
在编写 FPGA 代码时,创建可重用模块的价值非常重要。与其从头开始编写所有项目的代码,不如在这里复用上一章的 LED_Toggle_Project 模块,并通过与另一个模块接口来改进其功能。另一个使模块可重用的方法是将 Verilog 的 参数 或 VHDL 的 泛型 纳入模块中。这些是模块中的变量,可以通过更高层的代码覆盖它们。当我们实例化 Debounce_Filter 模块时,就会这样做。具体来说,我们用值 250000 ❷ 来覆盖模块的参数/泛型 DEBOUNCE_LIMIT。正如你稍后会看到的,这个值设置了去抖动开关时需要等待的时钟周期数。将其编码为参数/泛型使得修改该值变得容易。通常,Verilog 中的参数和 VHDL 中的泛型是保持代码可移植的非常有用的方法。它们让你可以改变模块的行为,而无需实际修改模块的文件。
现在让我们来看看去抖动滤波模块的代码:
Verilog
module Debounce_Filter #(parameter DEBOUNCE_LIMIT = 20) (
input i_Clk,
input i_Bouncy,
output o_Debounced);
❶ reg [$clog2(DEBOUNCE_LIMIT)-1:0] r_Count = 0;
reg r_State = 1'b0;
always @(posedge i_Clk)
begin
❷ if (i_Bouncy !== r_State && r_Count < DEBOUNCE_LIMIT-1)
begin
r_Count <= r_Count + 1;
end
❸ else if (r_Count == DEBOUNCE_LIMIT-1)
begin
r_State <= i_Bouncy;
r_Count <= 0;
end
else
begin
❹ r_Count <= 0;
end
end
❺ assign o_Debounced = r_State;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity Debounce_Filter is
generic (DEBOUNCE_LIMIT : integer := 20);
port (
i_Clk : in std_logic;
i_Bouncy : in std_logic;
o_Debounced : out std_logic
);
end entity Debounce_Filter;
architecture RTL of Debounce_Filter is
❶ signal r_Count : integer range 0 to DEBOUNCE_LIMIT := 0;
signal r_State : std_logic := '0';
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
❷ if (i_Bouncy /= r_State and r_Count < DEBOUNCE_LIMIT-1) then
r_Count <= r_Count + 1;
❸ elsif r_Count = DEBOUNCE_LIMIT-1 then
r_State <= i_Bouncy;
r_Count <= 0;
else
❹ r_Count <= 0;
end if;
end if;
end process;
❺ o_Debounced <= r_State;
end architecture RTL;
该模块的整体目的是去除输入(i_Bouncy)中的任何抖动或故障,并产生一个稳定的输出(o_Debounced)。为了做到这一点,我们检查输入和输出是否不同。如果不同,我们知道输入正在变化,但我们不希望立即更新输出,因为开关可能还在抖动。相反,我们希望确保输入在更新输出之前已经稳定了一段足够长的时间。由于 FPGA 没有固有的时间概念,我们通过计数时钟周期来实现延迟。
假设我们希望输入在更新输出之前稳定 10 毫秒。我们需要计算出一个表示经过 10 毫秒(或 1000 万纳秒)时间的时钟周期数。例如,Go 板的时钟周期为 40 纳秒,所以在这种情况下,我们将 1000 万除以 40,得到 250,000 个时钟周期的延迟。这就是我们在顶层模块Debounce_Project_Top中使用的< samp class="SANS_TheSansMonoCd_W5Regular_11">DEBOUNCE_LIMIT参数/通用项的值。如果你的开发板有不同的时钟周期,你需要相应地调整DEBOUNCE_LIMIT。
用于创建我们时钟周期计数器❶的代码在 Verilog 和 VHDL 版本之间有所不同。在 Verilog 中,我们使用一种常见的技巧:内建函数\(clog2()(表示向上取整的以 2 为底的对数)计算我们想要计数的时钟周期数量的对数[2],并向上取整。这告诉我们实现计数器所需的二进制位数。得益于\)clog2()函数,我们可以根据输入参数动态调整r_Count寄存器的大小,因此如果输入参数发生变化(因为你的时钟周期不同,或者因为你想要延长等待时间),代码将会自动适应并合成出合适大小的r_Count。这比将r_Count硬编码为某个任意限制要好得多,因为在代码重用时,硬编码可能会导致问题。
在 VHDL 中,我们能够以更简单的方式实现相同的动态大小调整,使用range关键字。这不仅会正确地调整变量的大小,而且还会在模拟时产生警告,如果r_Count的值超出了整数范围限制。模拟器能够在运行测试平台时提供这些类型的警告,这也是使用仿真的另一个重要原因。
我们通过一系列在每个时钟周期评估的 if 语句来实现去抖动滤波器。首先,我们处理输入与输出不同的情况(即输入发生变化),但 r_Count 小于 DEBOUNCE_LIMIT-1 ❷。这意味着我们还没有等待足够的时间,确保开关停止抖动,所以我们将时钟周期计数器加 1。在这个 if 语句中,我们实际上是在等待一定的时间,以确保输入是稳定的,然后再更新输出值。
接下来,我们处理计数器达到上限的情况,这样我们就知道我们已经等待了完整的 10 毫秒(或与 DEBOUNCE _LIMIT 对应的时间长度)❸。此时,我们可以将输入的当前值(i_Bouncy)注册到 r_State 中,其值接着被赋给输出(o_Debounced)❺。我们还将计数器重置为 0,以便为下一个事件做准备。最后,else 语句 ❹ 处理输入和输出状态相同的情况。在这种情况下,我们重置计数器,因为没有需要去抖动的内容,并且我们希望我们的去抖动滤波器始终准备好处理下一个事件。
创建测试平台和仿真
现在,我们将创建一个测试平台来测试我们的项目,并确保它按预期工作。回想一下,测试平台将实例化我们需要测试的单元并模拟其输入,同时监控其输出。在这种情况下,我们希望测试平台模拟来自抖动开关的不稳定输入,以便确认去抖动滤波器在开关稳定后才会延迟输出。以下是代码:
Verilog
module Debounce_Filter_TB ();
reg r_Clk = 1'b0, r_Bouncy = 1'b0;
❶ always #2 r_Clk <= !r_Clk;
❷ Debounce_Filter #(.DEBOUNCE_LIMIT(4)) UUT
(.i_Clk(r_Clk),
.i_Bouncy(r_Bouncy),
.o_Debounced(w_Debounced));
❸ initial begin
$dumpfile("dump.vcd"); $dumpvars;
repeat(3) @(posedge r_Clk);
❹ r_Bouncy <= 1'b1; // toggle state of input pin
@(posedge r_Clk);
❺ r_Bouncy <= 1'b0; // simulate a glitch/bounce of switch
@(posedge r_Clk);
❻ r_Bouncy <= 1'b1; // bounce goes away
repeat(6) @(posedge r_Clk);
$display("Test Complete");
$finish();
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use std.env.finish;
entity Debounce_Filter_TB is
end entity Debounce_Filter_TB;
architecture test of Debounce_Filter_TB is
signal r_Clk, r_Bouncy, w_Debounced : std_logic := '0';
begin
❶ r_Clk <= not r_Clk after 2 ns;
❷ UUT : entity work.Debounce_Filter
generic map (DEBOUNCE_LIMIT => 4)
port map (
i_Clk => r_Clk,
i_Bouncy => r_Bouncy,
o_Debounced => w_Debounced);
❸ process is
begin
wait for 10 ns;
❹ r_Bouncy <= '1'; -- toggle state of input pin
wait until rising_edge(r_Clk);
❺ r_Bouncy <= '0'; -- simulate a glitch/bounce of switch
wait until rising_edge(r_Clk);
❻ r_Bouncy <= '1'; -- bounce goes away
wait for 24 ns;
finish; -- need VHDL-2008
end process;
end test;
与我们的与门测试平台不同,这个测试平台必须向 UUT 提供一个时钟信号,以及其他输入。我们用一个简单的技巧来创建时钟信号❶:在固定的时间间隔后反转信号,以生成一个 50% 占空比的信号,整个测试平台执行过程中信号会不断切换。该信号每 2 纳秒反转一次,每周期时钟周期为 4 纳秒。这个速度远远超过典型 FPGA 开发板的实际时钟周期,但对于此次模拟来说是可以的。
当我们实例化 UUT ❷时,我们将DEBOUNCE_LIMIT的值覆盖为4。这意味着我们的去抖动滤波器在认为输出已去抖动之前,将仅检查四个时钟周期的稳定性。在真实的 FPGA 中,这段时间非常短(不到 1 微秒),可能不足以实际解决问题。然而,请记住这个测试平台的目的:我们希望确保 FPGA 逻辑按预期工作。无论我们等待 4 个时钟周期还是 250,000 个时钟周期,这些逻辑在功能上是相同的。使用较小的数字将使仿真更快速,且更容易评估波形,同时仍能提供关于设计是否有效的真实反馈。缩短计数器的值是处理大规模设计时的一个实用技巧:此类设计的仿真可能需要很多分钟才能完成,但使用较小的计数器限制将加速仿真,使你能够更快速地调试代码。一旦设计完全调试并验证完毕,你可以用实际的计数器长度更新仿真,以验证最终设计。这意味着,在任何代码问题通过缩短仿真时间解决后,你只需忍受一次较长的仿真时间。
接下来,我们开始向 UUT ❸提供刺激信号。在同步设计中,我们希望确保输入信号与时钟同步。因此,我们设置代码,在测试平台时钟的上升沿更改刺激信号。否则,我们可能会引入一些在真实 FPGA 设计中不存在的奇怪时序效应。(记住,UUT 中的所有触发器都将使用时钟的上升沿,因此你的测试平台刺激信号也应当响应时钟的上升沿。)
当测试开始时,输入为低电平。经过一段短时间后,输入变为高电平,持续一个时钟周期 ❹,然后再次变为低电平 ❺,以模拟因抖动引起的故障。我们希望确保该模块的去抖动输出不会对这个故障做出反应。在测试的后期,我们将输入再次拉高并保持高电平 ❻。这时,我们希望输出与输入一致,但仅在去抖动滤波器完成四个时钟周期的计数后才会匹配。
在 EDA Playground(或任何你偏好的仿真器)中运行此测试平台代码后,你应该会得到一个类似于图 5-7 所示的波形。

图 5-7:去抖动仿真波形
波形显示,当i_Bouncy仅在一个时钟周期内变高时,输出o_Debounced保持低电平。然后,在模拟的后期,我们看到输出变为高电平,与输入匹配,但只有当输入保持高电平达到四个时钟周期后。去抖动滤波器工作正常!
虽然我们编写的测试平台比没有测试要好,但它肯定可以改进。例如,我们没有检查输入再次变低时的情况,以确保输出正确响应。此外,我们可能还需要检查更高的DEBOUNCE_LIMIT值(大于4)是否会导致任何问题。设置多个测试用例来测试你设计的主要部分,尤其是任何极限情况,是良好的测试设计实践。
构建和编程 FPGA
在模拟设计后,我们现在有了一定的信心,如果继续编程 FPGA,它很可能会按预期工作。让我们试试看!在 iCEcube2 中创建一个新项目,并将以下模块添加到项目中:Debounce_Filter、Debounce_Project_Top和LED_Toggle_Project。确保你还包括了时钟约束文件以及物理约束文件。
当一切准备就绪后,构建项目。然后再仔细检查是否有错误,并检查你的利用率报告。构建 FPGA 后的合成报告将类似于以下内容:
`--snip--`
Register bits not including I/Os: 21 (1%)
Total load per clock:
i_Clk: 1
Mapping Summary:
Total LUTs: 31 (2%)
`--snip--`
从这份报告中,我们可以看到我们使用的 LUT 和触发器比第 3 号项目多。这是有道理的;去抖动滤波器需要这些额外的资源。尽管如此,FPGA 仍然有充足的资源可供使用。
继续编程你的 FPGA,然后尝试按下按钮以开关 LED。你应该会注意到,LED 现在会随着每次按下按钮而稳定切换。我们已经成功地过滤掉了开关的抖动。
如你在这个项目中所见,模拟对于建立设计信心和调试 Verilog 与 VHDL 中的问题是不可或缺的。然而,即使在这个相对简单的示例中,你可能也注意到一个缺点:查看波形以确定设计是否正常工作可能非常繁琐,尤其是当你需要不断修改设计并重新运行模拟时。如果测试平台能直接告诉你模拟是否成功,而无需你去分析波形,那将方便得多。正如我们接下来要探讨的,完全可以编写具备这种能力的测试平台。
自检测试平台
一个自检测试平台是一个你编程的测试平台,用来验证你的 UUT 是否按预期工作,而无需手动检查输出。自检测试平台将运行一系列步骤,并告知你是否有任何步骤失败,在这种情况下你可以检查故障并修复它。这可以避免你在仿真过程中需要视觉检查波形,以确定设计是否按预期工作。虽然设置自检测试平台需要更多的努力,但几乎总是值得花时间去做。
设置自检测试平台时,你的目标是向你的 UUT 注入许多不同的测试用例,然后监控输出并检查,或断言,它们是否符合你的预期。断言是在仿真过程中某一特定时刻信号值的声明,它们可能是自检测试平台中最关键的部分。通常,自检测试平台会包含数百个断言,每个断言都能为设计的正确性提供更多的信心。
自检测试平台在你为旧代码添加新功能时特别有用。它可能是你几年没看过的东西,而突然间你需要尝试回忆(或者如果是别人写的,你需要学习)它是如何工作的。根据经验,我可以告诉你,开始时使用一个包含许多检查的测试平台是一个巨大的优势。你可以打开仿真,查看自检测试平台中的所有断言,并确保旧代码中的所有功能仍然有效。然后你可以添加新代码,并为其添加新的测试。一旦所有旧的和新的测试都通过,你就可以非常有信心地认为新代码按预期执行——同样重要的是,你没有破坏任何旧代码。
为了说明自检测试平台是如何工作的,让我们回到本章前面我们为与门项目编写的简单测试平台。以下的 Verilog 和 VHDL 代码基于我们写的原始测试平台,并在其中添加了一些断言检查。这些断言将自动运行,以验证实际输出是否处于预期状态。新的代码以粗体显示:
Verilog
`--snip--`
initial
begin
$dumpfile("dump.vcd"); $dumpvars;
r_In1 <= 1'b0;
r_In2 <= 1'b0;
#10;
❶ **assert (w_Out** == **1'b0);**
r_In1 <= 1'b0;
r_In2 <= 1'b1;
#10;
**assert (w_Out** == **1'b0);**
`--snip--`
VHDL
`--snip--`
process is
begin
r_In1 <= '0';
r_In2 <= '0';
wait for 10 ns;
❶ **assert (w_Out** = **'0') severity failure;**
r_In1 <= '0';
r_In2 <= '1';
wait for 10 ns;
**assert (w_Out** = **'0') severity failure;**
`--snip--`
在这段来自测试平台的摘录中,我们添加了两个检查。我们首先使用assert关键字 ❶确认当两个输入都为低时,输出为低,然后确认当一个输入为低,另一个输入为高时,输出依然为低。assert关键字仅存在于 SystemVerilog 中,而在普通的 Verilog 中并没有。这个例子展示了 SystemVerilog 如何为测试平台提供了改进的功能。与此同时,VHDL 中内建了assert,其严重性可以是note、warning或failure,具体取决于你希望检查的断言级别。每个级别有不同的升级方式,因此你可以在报告中进行过滤。在这种情况下,我们选择了failure,因为当输入为低时,我们肯定不希望 AND 门的输出为高。
如果这个断言评估为true,则仿真会继续进行。然而,如果发生了问题并且断言失败,你将看到屏幕上打印出的输出。在 Verilog 中,你会看到类似这样的内容:
# ASSERT: Error: ASRT_0301 testbench.sv(20): Immediate assert
condition (w_Out==1'b1) FAILED at time: 10ns, scope: And_Gate_TB
在 VHDL 中,失败信息将如下所示:
# ** Failure: Assertion violation.
# Time: 10 ns Iteration: 0 Process: /and_gate_tb/line__22
File: testbench.vhd
这非常有帮助!我们不仅知道测试平台失败了,而且知道它在仿真开始的第 10ns 时发生了失败,这使得我们可以立即在波形查看器中定位到失败位置。我们还知道导致失败的确切代码行:在 Verilog 中是第 20 行,VHDL 中是第 22 行。这些信息使得我们更容易调查问题、理解原因并修复它。我建议在可能的情况下将断言添加到你的测试中。
自检测试平台是 SystemVerilog 特别出色的一个方面。除了常规 Verilog 提供的功能,许多新增的特性都旨在编写更好的测试平台。例如,SystemVerilog 提供了验证事件序列的功能。这对分析不同信号之间的交互非常有用,可以确保它们按正确顺序发生(也就是说,首先发生一件事,然后在下一个时钟周期发生另一件事)。SystemVerilog 还提供了类,使你能够使用面向对象编程技术来简化测试平台代码。其他 SystemVerilog 功能允许你将数据随机注入到设计中,从而使测试变得更加全面和健壮。这些功能的详细内容超出了本书的范围,但当你开始编写更多的测试平台,特别是自检测试平台时,我鼓励你深入了解 SystemVerilog。
初始信号条件
默认情况下,如果信号没有赋予初始条件,那么在开始仿真时它将处于未知状态。通常,这会在波形查看器中通过红色信号和 X 标记表示。仿真器告诉你,在测试平台开始运行时,它不知道如何处理该信号。应该是 0 还是 1?仿真器不知道。
有两种方法可以将默认状态分配给信号,以便它们从已知状态开始。第一种方法是使用复位信号。正如我们在第四章中讨论的,复位信号为触发器分配一个初始默认值。在仿真开始时驱动复位输入将把信号设置为已知状态,从而开始测试。这适用于所有分配了复位条件的信号。
另一种将信号设置为初始状态的方法是使用 Verilog 和 VHDL 中的初始化功能。这在仿真中尤其有用。它和为信号赋值一样简单。在 Verilog 中,例如,reg r_State = 1'b0; 将< s r_State信号初始化为 0。在 VHDL 中,signal r_State : std_logic := '0'; 也可以达到相同效果。你可以使用信号可以合法设置的任何状态作为初始化值。
初始信号赋值仅在某些 FPGA 上可合成,因为并非所有 FPGA 在编程后启动时都能将初始状态加载到触发器中。由于并非所有 FPGA 都支持此功能,我通常不推荐依赖它。一种更好的、具有更好可移植性的解决方案是使用复位信号将信号设置为某个默认值。复位信号在所有 FPGA 制造商中广泛支持,因此如果你需要更换 FPGA,代码会更加可移植。
FPGA 上调试
本章早些时候,我告诉过你,一旦进入硬件,你就像是在看一个黑箱。你可以看到输入和输出,但看不清楚内部发生了什么。事实上,这并不完全正确。确实有一种方法可以进行有限的 FPGA 上调试。然而,这种方法有显著的缺点,应该仅在必要时使用。
在 FPGA 上调试是通过添加一个逻辑分析仪来实现的,逻辑分析仪是一种工具,能够同时显示多个数字信号的状态(高或低)。这可以让你实时监控 FPGA 的内部信号。通过查看这些信号,你可以调试问题,看到数据为何未按预期行为运行。
每个主要的 FPGA 公司在其工具套件中都有一个独特的产品,用于在 FPGA 内部创建逻辑分析仪。AMD 有一个叫做集成逻辑分析仪(ILA)的功能,Intel 有 Signal Tap,Lattice 有 Reveal。它们的工作方式基本相同:它们将 FPGA 的一部分资源转化为逻辑分析仪。你运行 FPGA 代码,逻辑分析仪“嗅探”数据,然后将结果展示在你的计算机屏幕上,以便你调试设计。
然而,这个过程存在几个问题。第一个问题是它非常耗时。如果你想在 FPGA 中添加逻辑分析仪,你需要重新构建并重新编程整个设计。你还需要提前决定你希望用逻辑分析仪监控哪些信号,因为你的 FPGA 资源可能不足以查看所有内容。如果你想在 FPGA 正在运行时更改查看的内容,那就算了!你必须从头开始重新构建整个 FPGA,并重新开始整个过程。另一方面,模拟可以轻松地查看 FPGA 上所有信号的状态;你不必挑选和选择。
FPGA 上调试的另一个问题是,添加逻辑分析仪基本上是一次性的工作。一旦你找到并修复了一个问题,你就不再需要调试工具了。事实上,由于它使用 FPGA 的资源(而资源是有限的),你可能不希望将其保留在设计中。你可以保存并重新运行模拟,但逻辑分析仪是一次性调试工作。
最后一个,可能也是最糟糕的问题是,当你在 FPGA 中添加逻辑分析仪时,你实际上是在改变 FPGA 的设计,这可能会带来意想不到的后果。那些可能受到小的时序变化影响的问题,可能会因为添加逻辑分析仪的动作而得到解决,或者可能会出现新的问题。例如,如果你正试图使用逻辑分析仪来调试 FPGA 内部的竞态条件,添加逻辑分析仪所带来的设计变化可能会使竞态条件消失。科学家称之为观察者效应,即现象因调查行为而发生变化。
这并不是说这些在 FPGA 上的调试工具完全没有用。当你试图调查一个难以模拟的情况时,它们是有帮助的。例如,假设你的 FPGA 与某个外部接口存在问题,但这些问题只在硬件上出现,而模拟却正常。在这种情况下,你可能想启动一个逻辑分析仪,看看为什么你的模拟与现实不符。一旦弄明白了原因,你应该努力让模拟尽可能逼真,将通过真实世界测试识别到的故障模式加入其中。
这些工具在我的职业生涯中救过我几次,但一般来说,我尽量避免使用它们。
验证
验证是确保 FPGA 或 ASIC 设计按预期工作的一种过程。这是一个全面的过程,远远超出仅仅编写几个测试平台和运行仿真的范围——以至于有些人专门从事验证工作,这些人被称为验证工程师。验证的具体细节超出了本书的范围。实际上,有整本书专门讲解这个主题。本节只是简要介绍这个话题,让你了解验证在现实世界中的 FPGA 和 ASIC 设计中可以发挥的重要作用。
设想一个像 DVD 播放器这样的设备。如果 DVD 正在播放,然后用户暂停播放、弹出 DVD,并按下快进按钮,会发生什么?代码是否正确处理了这一系列事件?或者,意外的快进指令是否让处理器陷入了一个奇怪的状态?验证工程师必须测试所有这些极端情况,确保设计不会在处理一些奇怪的情况时出错。
在第一章中,我提到过制造 ASIC 是一个非常昂贵且耗时的过程。一旦 ASIC 在铸造厂完成制造,你就得付出一大笔费用。如果设计中有关键的错误,导致 ASIC 无法按预期工作,那么你就失去了所有的钱,而且还需要重新进行 ASIC 制造过程。验证工程师的工作就是确保设计在一开始就正确,因为事后发现并修复错误是非常昂贵的。
排除错误是很棒的,但这只是好处的一半。验证的另一个主要目标是确保设计按预期执行。如果你收到了一份关于 ASIC 性能的规格说明,可能会有一些模糊或缺失的信息。通常,一个或多个设计师会根据规格进行设计,而一个或多个独立的验证工程师会同时验证设计是否符合规格。如果出现任何不一致,两个团队可以聚在一起,更新规格,以便大家都能明确理解设计意图。
大多数验证工程师会利用 SystemVerilog 内置的额外功能来彻底测试设计。自检查的测试平台是绝对必须的。随机测试设计也是非常有帮助的,因此有一些代码块可以将随机的测试用例注入到设计中,确保它按预期工作。
像这样的代码验证可不是小事。通常,验证设计是否正确工作比创建设计本身还要昂贵且耗时!因此,与 ASIC 不同,并不是所有 FPGA 设计都经过专门的验证过程。在大多数情况下,这实在是太昂贵了。记住,FPGA 代表现场可编程门阵列,所以如果允许一些错误漏网,设备总是可以在现场或客户手中进行更新。
通常,只有那些要求非常高可靠性或在现场无法更新的 FPGA 设计才会进行验证。例如,一些 FPGA 是一次性可编程(OTP)的,意味着它们只能编程一次;之后,功能被锁定且无法更改。一些外太空应用使用这些 OTP FPGA,因为它们对辐射更具抵抗力。此外,OTP FPGA 被认为不容易受到逆向工程的影响,因此它们更适用于高安全性的应用。OTP FPGA 设计通常需要验证;然而,这对于典型的 FPGA 设计来说并不常见。
对于我们的目的,测试平台足以在 FPGA 设计中找到漏洞,但对于需要验证的 ASIC 或 FPGA 来说,验证至关重要。
总结
在这一章中,你了解了如何模拟你的 FPGA 代码,并看到运行模拟是非常值得的。与硬件调试相比,模拟让你能够可视化设计中的所有信号,观察它们如何相互作用,并创建刺激来测试你的代码并查看其响应。你练习了编写测试平台的代码,或者是实例化 UUT、注入样本输入并监控输出的代码。你看到了通过观察模拟过程中生成的波形是一种很好的方法来检查设计是否正常工作,但更好的是添加自检测试,使你的测试平台能够自我验证。减少硬件调试,增加漂亮的模拟:这就是让 FPGA 设计师快乐的原因。
第十一章:6 常见的 FPGA 模块

使用 FPGA 的工作方式有点像用乐高积木搭建:你手头有有限种类的小积木,但通过巧妙地堆叠它们,你可以创造出非常复杂的设计。在最低层次上,你正在使用查找表(LUT)和触发器(flip-flops)。在稍微高一点的层次上,FPGA 设计中反复出现几个基本构件,包括多路复用器和解复用器、移位寄存器、先进先出(FIFO)以及其他类型的存储器。
这些元件都是非常常见的。事实上,很可能在你以后参与的每一个 FPGA 项目中,都至少会用到其中一个或多个。在本章中,我将向你展示这些基本构件如何工作,以及如何用 Verilog 和 VHDL 实现它们。对于每一个常见的元素,你将创建一个自包含模块,以便在 FPGA 设计中需要该元素时随时重用。这将巩固你的 FPGA 编程知识,并为你的项目打下坚实的基础。
多路复用器和解复用器
多路复用器和解复用器是电路元件,它们允许你在两个或更多的选项之间进行选择。对于多路复用器(有时拼写为 multiplexor,通常缩写为 mux),你有多个输入信号,并选择其中一个信号发送到单个输出。而解复用器(简写为 demux)正好相反:你有一个输入信号,选择将其发送到多个输出中的哪一个。
多路复用器和解复用器有很多应用。例如,一个 mux 可以用来选择风扇的运行速度:低-中-高开关可能作为一个 mux 来控制哪个设置被发送到风扇控制器。一个解复用器可以与开关配合工作,用来选择点亮哪个四个 LED:每次只会点亮一个 LED,但你可以指定点亮的是哪个。
Mux 和 demux 根据输入和输出的数量进行分类。例如,一个 4-1(发音为 四对一)mux 有四个输入和一个输出。相反,一个 1-4(发音为 一对四)demux 有一个输入和四个输出。你可以根据电路的需求设计具有任意数量输入的 mux:你可以有一个 2-1 mux、一个 3-1 mux、一个 8-1 mux、一个 13-1 mux,或者任何你想要的设计。同样,你也可以设计一个具有所需输出数量的 demux。
实现多路复用器
让我们考虑如何在 FPGA 上创建一个多路复用器。具体来说,我们将查看如何创建一个 4-1 mux,但你可以将相同的逻辑应用到任何输入数量的 mux 上。图 6-1 展示了一个 4-1 mux 的框图。

图 6-1:一个 4-1 多路复用器(mux)
我们的多路复用器左侧有四个输入:i_Data0、i_Data1、i_Data2 和 i_Data3。右侧是单一输出,称为 o_Data。底部是两个额外的输入,标记为 i_Sel1 和 i_Sel0。Sel 是 select 的缩写。这些选择输入决定哪个数据输入传递到输出。 表 6-1 的真值表显示了 i_Sel1 和 i_Sel0 如何一起工作以确定多路复用器的输出。
表 6-1: 4-1 多路复用器的真值表
| i_Sel1 | i_Sel0 | o_Data |
|---|---|---|
| 0 | 0 | i_Data0 |
| 0 | 1 | i_Data1 |
| 1 | 0 | i_Data2 |
| 1 | 1 | i_Data3 |
从 表 6-1 中我们可以看到,当 i_Sel1 和 i_Sel0 都为 0 时,i_Data0 被连接到输出。当 i_Sel1 为 0,i_Sel0 为 1 时,输出得到 i_Data1;当 i_Sel1 为 1,i_Sel0 为 0 时,输出得到 i_Data2;当两个选择器都为 1 时,输出得到 i_Data3。
注意
由于多路复用器(mux)用于选择哪些输入连接到哪些输出,因此它们通常被称为 选择器。实际上,select 是 VHDL 中的一个保留字,可以用来生成多路复用器。
在 Verilog 或 VHDL 中实现这个真值表,只需评估 i_Sel1 和 i_Sel0 并将适当的数据输入分配给输出即可。以下代码显示了如何实现(我省略了信号定义,以便专注于实际的多路复用器代码,更多上下文可以在书籍的 GitHub 仓库中找到,链接为 <wbr>github<wbr>.com<wbr>/nandland<wbr>/getting<wbr>-started<wbr>-with<wbr>-fpgas):
Verilog
assign o_Data = !i_Sel1 & !i_Sel0 ? i_Data0 :
!i_Sel1 & i_Sel0 ? i_Data1 :
i_Sel1 & !i_Sel0 ? i_Data2 : i_Data3;
VHDL
o_Data <= i_Data0 when i_Sel1 = '0' and i_Sel0 = '0' else
i_Data1 when i_Sel1 = '0' and i_Sel0 = '1' else
i_Data2 when i_Sel1 = '1' and i_Sel0 = '0' else
i_Data3;
Verilog 版本使用条件(或三元)运算符,用问号(?)表示。这是一个简写方式,用于在不使用 if…else 语句的情况下编写条件表达式。该运算符的工作原理是首先评估问号前的条件(例如,!i_Sel1 & !i_Sel0)。如果条件为真,表达式选择冒号前的条件;如果条件为假,则选择冒号后的条件。在这里,我们将多个 ? 运算符串联在一起,以处理两个选择输入的所有可能组合。
在 VHDL 版本中,我们通过链式使用多个 when/else 语句来实现相同的功能。由于 VHDL 版本使用了更多的显式关键词,因此可读性稍强,但 Verilog 更加简洁。在 Verilog 和 VHDL 版本中,逻辑检查会被逐一评估,直到某个检查为真。如果没有任何检查为真,则使用链中的最后一个赋值语句。
实现解复用器
对于一个 1-4 解复用器,块图看起来像是一个 4-1 多路复用器的镜像版本,正如在 图 6-2 中所示。

图 6-2:一个 1-4 解复用器(demux)
这个解复用器(demux)接受左侧的单个数据输入(i_Data),并选择将其连接到哪个输出。该解复用器进行的是 1-4 选择,因此需要两个输入选择器来在四个可能的输出之间进行选择。表格 6-2 显示了所有可能的组合。
表 6-2: 1-4 解复用器的真值表
| i_Sel1 | i_Sel0 | o_Data3 | o_Data2 | o_Data1 | o_Data0 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | i_Data |
| 0 | 1 | 0 | 0 | i_Data | 0 |
| 1 | 0 | 0 | i_Data | 0 | 0 |
| 1 | 1 | i_Data | 0 | 0 | 0 |
从表格中可以看到,i_Data一次只能连接到四个输出中的一个,这取决于i_Sel1和i_Sel0选择器输入。当i_Sel1和i_Sel0都为 0 时,o_Data0获取i_Data;否则它的值为 0。当i_Sel1为 0 且i_Sel0为 1 时,o_Data1获取i_Data;否则它的值为 0。当i_Sel1为 1 且i_Sel0为 0 时,o_Data2获取i_Data;否则它的值为 0。最后,当i_Sel1为 1 且i_Sel0为 1 时,o_Data3获取i_Data;否则它的值为 0。接下来我们来看看如何在 Verilog 和 VHDL 中实现这个真值表:
Verilog
module Demux_1_To_4
(input i_Data,
input i_Sel1,
input i_Sel0,
output o_Data0,
output o_Data1,
output o_Data2,
output o_Data3);
❶ assign o_Data0 = !i_Sel1 & !i_Sel0 ? i_Data : 1'b0;
assign o_Data1 = !i_Sel1 & i_Sel0 ? i_Data : 1'b0;
assign o_Data2 = i_Sel1 & !i_Sel0 ? i_Data : 1'b0;
assign o_Data3 = i_Sel1 & i_Sel0 ? i_Data : 1'b0;
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Demux_1_To_4 is
port (
i_Data : in std_logic;
i_Sel0 : in std_logic;
i_Sel1 : in std_logic;
o_Data0 : out std_logic;
o_Data1 : out std_logic;
o_Data2 : out std_logic;
o_Data3 : out std_logic);
end entity Demux_1_To_4;
architecture RTL of Demux_1_To_4 is
begin
❶ o_Data0 <= i_Data when i_Sel1 = '0' and i_Sel0 = '0' else '0';
o_Data1 <= i_Data when i_Sel1 = '0' and i_Sel0 = '1' else '0';
o_Data2 <= i_Data when i_Sel1 = '1' and i_Sel0 = '0' else '0';
o_Data3 <= i_Data when i_Sel1 = '1' and i_Sel0 = '1' else '0';
end architecture RTL;
注意到这段代码中的每个输出都是独立设置的。输入i_Data一次只能赋值给一个输出。例如,当两个选择器输入都为 0 ❶时,我们将其赋值给第一个输出o_Data0。当输出没有连接到输入数据时,它会被设置为 0,以禁用该输出。
实际上,由于多路复用器(mux)或解复用器(demux)可以通过几行代码创建,因此你不太可能会创建一个模块来实例化一个单独的多路复用器或解复用器。通常情况下,你最好将构建 mux 或 demux 的代码直接放入需要它的模块中。然而,多路复用器和解复用器是非常常见的电路设计元件,因此理解如何实现它们非常重要。接下来,我们将看另一个常见组件:移位寄存器。
移位寄存器
一个移位寄存器是由一系列触发器组成,其中一个触发器的输出连接到下一个触发器的输入。在第四章中,我们讨论过触发器链,但为了简单起见,我当时没有介绍这个术语。回顾一下,图 6-3 展示了一个四个触发器组成的链条,我们现在可以称之为4 位移位寄存器。如第四章所讨论,链中的每一个附加触发器都会为输出增加一个时钟周期的延迟。

图 6-3:移位寄存器
移位寄存器有很多用途。例如,它们可以延迟数据若干固定的时钟周期,或者将数据从串行转换为并行,或从并行转换为串行,甚至可以创建线性反馈移位寄存器。在本节中,我们将看到每种应用的示例。
延迟数据
在 FPGA 中创建延迟是移位寄存器最常见的应用。延迟通常用于对齐数据的时间。例如,当你通过数学运算发送输入数据时,可能需要几个时钟周期才能产生结果。如果需要将输出结果与原始输入数据对齐,那么原始输入数据就需要延迟与数学运算所需时钟周期的数量相同的时间。
正如我们所看到的,移位寄存器仅仅是由一串触发器组成,链中触发器的数量决定了输入数据传播到输出所需的时钟周期数。考虑到这一点,下面的代码将创建一个移位寄存器,它在某些输入数据上生成四个时钟周期的延迟:
Verilog
❶ reg [3:0] r_Shift;
always @ (posedge i_Clk)
begin
❷ r_Shift[0] <= i_Data_To_Delay;
❸ r_Shift[3:1] <= r_Shift[2:0];
end
VHDL
❶ signal r_Shift : std_logic_vector(3 downto 0);
process (i_Clk)
begin
if rising_edge(i_Clk) then
❷ r_Shift(0) <= i_Data_To_Delay;
❸ r_Shift(3 downto 1) <= r_Shift(2 downto 0);
end if;
end process;
这里我们创建了一个名为r_Shift的移位寄存器,它的长度为四个触发器❶。请记住,名字中的r_是一个提示,表示信号由触发器组成,并将在一个时钟驱动的always块(在 Verilog 中)或process块(在 VHDL 中)中赋值。我们将链中的第一个触发器(位置0)加载为输入信号i_Data_To_Delay❷。然后,我们使用一个技巧,通过一行代码创建剩余三个触发器的赋值,而不是三行:我们将触发器0到2的值赋给触发器1到3❸。这样,链中第一个触发器中的数据被移到第二个触发器,第二个触发器中的数据被移到第三个,以此类推。如果你愿意,也可以将此步骤分解为单独的操作,如下所示:
r_Shift[3] <= r_Shift[2];
r_Shift[2] <= r_Shift[1];
r_Shift[1] <= r_Shift[0];
这个例子展示的是 Verilog 版本。对于 VHDL,使用括号替换方括号。
分别写出每个赋值操作可以更明确地展示数据如何逐位通过移位寄存器移动,但这两种方法的效果是一样的。现在我们可以将r_Shift的第3位用于我们的目的,因为这是代表输入数据i_Data_To_Delay延迟四个时钟周期的触发器。如果我们需要一个三时钟周期的延迟,可以使用第2位的数据,或者我们可以在链中添加更多的触发器来创建更长的延迟。
串行与并行数据之间的转换
将串行数据转换为并行数据,反之亦然,是移位寄存器的另一个常见用途。当与外部芯片接口进行通信时,可能需要进行这种转换,尤其是当这些接口串行地发送和接收数据时。一个具体的例子是与通用异步接收发送器(UART)的接口。这是一种通过将字节数据拆分成单个比特来发送的设备,然后在接收端重新组装成字节。当数据发送时,它会从并行转换为串行:一个字节中的八个并行比特依次串行发送。当数据接收时,它会从串行(单个比特)转换回并行(一个完整的字节)。
UART 被广泛用于设备之间的数据发送和接收,因为它们简单且高效,且非常适合应用于移位寄存器。一个八位移位寄存器可以通过一次读取一个触发器来发送一个字节的数据,或者通过将比特逐个移入触发器链来接收一个字节的数据。例如,假设我们要发送和接收 ASCII 编码的字符,每个字符都可以在一个字节的数据中表示。首先,我们来看 UART 的接收端。表 6-3 中的每一行表示接收到一个数据比特。右侧的列展示了通过移位寄存器将比特逐个移入后,如何构建完整的字节。
表 6-3: 通过 UART 接收一个字节的数据
| 比特索引 | 接收到的比特 | 字节内容 |
|---|---|---|
| 0 | 1 | 1 |
| 1 | 1 | 11 |
| 2 | 0 | 011 |
| 3 | 1 | 1011 |
| 4 | 0 | 01011 |
| 5 | 0 | 001011 |
| 6 | 1 | 1001011 |
| 7 | 0 | 01001011 |
| ASCII=0x4B='K' |
UART 通常从最低有效位(最右边的位)开始接收数据。接收到的第一个比特会从最左边的最高有效位位置通过移位寄存器移到最低有效位位置,随着更多比特的到来。让我们来看看这个过程是如何工作的。
在表格的第一行,我们接收到第一个比特,它的值是1。我们将其放入最重要的比特位置,即移位寄存器中的第一个触发器。当我们接收到第二个比特,它也是1时,我们将现有的比特向右移动,并将新接收到的比特放入最重要的比特位置。我们接收到的第三个比特是0。我们再次将其放入最重要的位置,其余的比特向右移动。当我们接收到所有八个比特时,移位寄存器已满,最后一个比特被放入最重要的比特位置,第一个比特被放入最不重要的位置。此时,字节已经完成。在我们的示例中,我们接收到的是01001011,它等同于0x4B(即十六进制的4B),这是字母 K 的 ASCII 编码。通过一次接收一个比特,并使用移位寄存器将接收到的比特向右移动,我们将串行数据转换为并行数据。
现在让我们来看一下 UART 的传输端。表格 6-4 显示了如何传输字节00110111,即0x37,它在 ASCII 中的表示是数字 7。
表格 6-4: 通过 UART 传输字节数据
| 比特索引 | 字节内容 | 传输的比特 |
|---|---|---|
| ASCII=0x37='7' | ||
| 0 | 00110111 | 1 |
| 1 | 0011011 | 1 |
| 2 | 001101 | 1 |
| 3 | 00110 | 0 |
| 4 | 0011 | 1 |
| 5 | 001 | 1 |
| 6 | 00 | 0 |
| 7 | 0 | 0 |
在这种情况下,我们从一个 8 位移位寄存器中加载整个字节的数据。再次强调,UART 从最低有效位到最高有效位传输数据,因此这里我们从最右边的位开始发送,并在每一步将整个字节向右移位。通过使用移位寄存器一次发送一位,并将剩余的位向右移,我们正在将并行数据转换为串行数据。
创建线性反馈移位寄存器
移位寄存器的最后一个常见应用是创建线性反馈移位寄存器(LFSR)。这是一个移位寄存器,其中链中的某些触发器被接入,并作为输入传递给 XOR 或 XNOR 门(我们将使用 XNOR)。该门的输出被反馈到移位寄存器的起始位置,因此在名称中有反馈一词。线性来自于这一安排生成的输入位是 LFSR 前一个状态的线性函数。图 6-4 显示了一个 3 位 LFSR 的示例,但请记住,LFSR 可以具有任意数量的位。

图 6-4:一个 3 位 LFSR
这个 LFSR 由三个链式触发器组成,表示移位寄存器中的位 0 到位 2。位 1 和位 2 触发器的输出通过 XNOR 门,门的输出被送到移位寄存器第一个位的输入。LFSR 在任何给定时钟周期的值就是这三个触发器输出的值。
注意
图 6-4 中的触发器与我们通常看到的情况相反,输入 D 在右侧,输出 Q 在左侧。我将它们画成这样是为了让最低有效位(位 0)出现在右侧,以便与我们写数字的方式匹配,但这里没有特别之处;这些触发器和我们熟悉的没什么区别,只是镜像了。
当一个 LFSR(线性反馈移位寄存器)运行时,单个触发器生成的模式是伪随机的,这意味着它接近但并不完全是随机的。它之所以是伪随机,是因为从 LFSR 模式的任何状态中,你都可以预测到下一个状态。表 6-5 展示了当 3 位 LFSR 初始化为零时,然后时钟开始切换时发生的情况。
表 6-5: 3 位 LFSR 的伪随机输出
| 时钟周期 | LFSR 数据(按二进制表示) | LFSR 数据(按十进制表示) |
|---|---|---|
| 0 | 000 | 0 |
| 1 | 001 | 1 |
| 2 | 011 | 3 |
| 3 | 110 | 6 |
| 4 | 101 | 5 |
| 5 | 010 | 2 |
| 6 | 100 | 4 |
| 7 | 000 | 0 |
| 8 | 001 | 1 |
| 9 | 011 | 3 |
| 10 | 110 | 6 |
| … | … | … |
LFSR 在第一个时钟周期从 000 转变为 001。这很有意义,因为位 2(0)和位 1(0)的 XNOR 结果是 1,这个值被写入到位 0。在下一个时钟周期,LFSR 从 001 转变为 011。我们再次进行了位 2(0)和位 1(0)的 XNOR,得到了新的位 0 值 1。与此同时,旧的位 0 值(1)已经移到位 1。根据表格中的其他值,它们看起来相对随机——甚至是伪随机的!
请注意,表格在第七个时钟周期时会重复,因此 3 位 LFSR 可以有七个独特的值:000、001、010、011、100、101 和 110。它永远不会有 111 的值。如果你想知道为什么,请考虑如果这个值出现会发生什么。在下一个时钟周期,新的位 0 将是 1 和 1 的 XNOR,结果是 1,而其他位将移位,得到 111。LFSR 将永远停留在 111,因此它会实际上停止运行!作为规则,对于一个长度为 N 位的 LFSR,它运行遍历所有组合的最大时钟周期数是 2^N − 1。对于 3 位,是 2³ − 1 = 7;对于 4 位,是 2⁴ − 1 = 15;依此类推。
由于其伪随机性,LFSR 具有许多应用。它们可以作为低利用率计数器、测试模式生成器、数据加扰器,或用于加密。LFSR 轻量化,因此这些数学运算可以以少量资源进行,这对于节省珍贵的 FPGA 触发器和查找表(LUT)以执行其他任务非常有利。
让我们看看 图 6-4 中的 LFSR 如何在 Verilog 和 VHDL 中实现:
Verilog
❶ reg [2:0] r_LFSR;
wire w_XNOR;
always @(posedge i_Clk)
begin
❷ r_LFSR <= {r_LFSR[1:0], w_XNOR};
end
❸ assign w_XNOR = r_LFSR[2] ^~ r_LFSR[1];
VHDL
❶ signal r_LFSR : std_logic_vector(2 downto 0)
signal w_XNOR : std_logic;
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
❷ r_LFSR <= r_LFSR(1 downto 0) & w_XNOR;
end if;
end process;
❸ w_XNOR <= r_LFSR(2) xnor r_LFSR(1);
首先,我们声明一个 3 位宽的 LFSR ❶。我们执行移位并通过连接操作合并 XNOR 运算的结果 ❷。在 Verilog 中,我们通过将值放在大括号中,{},用逗号分隔它们来进行连接,而在 VHDL 中我们使用单个与号(&)。移位和连接操作一起构建出一个单一的 3 位宽值,其中w_XNOR位于最低有效位位置。最后,我们基于寄存器中第 2 位和第 1 位的值,赋值给w_XNOR门 ❸。这是一个连续赋值,发生在always或process块之外,并将由 FPGA 中的 LUT 实现。
注意
这个例子展示了一个非常简单的 3 位宽 LFSR,但 LFSR 通常会有初始化和复位逻辑,帮助避免和从任何不允许的状态中恢复。更完整的代码,包括复位逻辑和将 LFSR 大小调整为任意位数的功能,可以在本书的 GitHub 仓库中找到。
LFSR 是一种简单而高效的方式,用于执行若干有用的任务。它们还突出了 FPGA 的一个优势,即能够用较少的资源快速执行数学运算。试想一下,您可以在单个 FPGA 上并行运行数百个 LFSR,且不会有任何问题,这就能让您看到 FPGA 如何在并行执行快速数学运算方面表现出色。
项目 #5:选择性地闪烁 LED
现在我们已经介绍了一些基本构件,接下来让我们开始将它们组合在一起。这个项目的要求是让开发板上的四个 LED 逐个闪烁,但每次只能有一个 LED 闪烁。您将使用两个开关来选择要闪烁的 LED。表 6-6 展示了 LED 选择是如何进行的。
表 6-6: LED 选择
| i_Switch_2 | i_Switch_1 | 要闪烁的 LED | 信号名称 |
|---|---|---|---|
| 0 | 0 | D1 | o_LED_1 |
| 0 | 1 | D2 | o_LED_2 |
| 1 | 0 | D3 | o_LED_3 |
| 1 | 1 | D4 | o_LED_4 |
从表格中我们可以看到,当两个输入开关都为 0(未按下)时,D1 LED 会闪烁。当仅按下开关 1(将其设置为 1)时,我们选择让 D2 LED 闪烁。当仅按下开关 2 时,D3 应该闪烁,最后,当我们同时按下两个按钮时,D4 应该闪烁。这听起来像是解复用器的工作!我们将有一个开关信号,时常切换开和关,我们需要将它路由到四个 LED 中的一个。那么,我们如何生成这个切换信号呢?
开发板上的时钟速度相当快。例如,在 Go Board 上(在附录 A 中讨论过),时钟频率为 25 MHz。如果我们直接将这个信号传送到 LED 上,那么 LED 的闪烁频率将是 25 MHz。对于人眼来说,这看起来就像 LED 一直亮着,因为这个频率太快,人眼无法察觉。我们需要生成一个自身切换的信号,但频率要比时钟慢得多,例如 2 至 4 Hz。这个频率足够快,以至于你能看出 LED 正在快速闪烁,但不会太快到人眼无法看清。然而,记住,FPGA 并没有内建的时间概念,因此我们不能通过编写类似于下面的代码来让 LED 闪烁:
r_LED <= 1;
wait for 0.20 seconds
r_LED <= 0;
wait for 0.20 seconds
正如在第五章中讨论的那样,FPGA 可以通过计数时钟周期来确定经过了多少时间。为了等待 0.20 秒的时间,我们需要计数每秒时钟周期的五分之一。在 Go Board 上,由于每秒有 25,000,000 个时钟周期(25 MHz 时钟),我们需要计数到 25,000,000 / 5 = 5,000,000。一旦计数到达这个限制,我们可以将其重置为零,并切换 LED 的状态。
但还有另一种方法!回想一下,LFSR(线性反馈移位寄存器)的一个可能用途是创建一个低资源消耗的计数器。以某种模式(例如全零)启动 LFSR,它将需要 2^N − 1 个时钟周期才能使该模式再次出现,其中 N 是构成 LFSR 的触发器数量。创建一个具有足够多触发器的 LFSR,并且其值循环的速度足够慢,就可以以令人满意的频率切换 LED。例如,一个 22 位的 LFSR 会在每 2²² − 1 = 4,194,303 个时钟周期后重复其模式。使用 Go Board 的 25 MHz 时钟,这将约等于 0.20 秒。
注意
如果你的板子有不同的时钟频率,你需要尝试调整 LFSR 中的位数。例如,对于 Alchitry Cu 上的 100 MHz 时钟(参见 附录 A),可以尝试使用 24 位:2**²⁴ − 1 = 16,777,215 周期,约 0.17 秒。
每次 LFSR 返回到全零状态时,它将切换一个信号,我们将使用这个信号来闪烁当前选中的 LED。所有这些操作所需的 FPGA 资源,比传统计数器少得多。图 6-5 显示了它如何工作的框图。

图 6-5:项目 #5 的框图
本项目将实例化两个模块:LFSR 和 1-4 线选择器。在这两个模块之间,我们将有一个触发器和一个 NOT 门(它将变成一个查找表 LUT)。LFSR 的输入是时钟,输出是一个信号,当 LFSR 达到极限并重新开始其模式时,信号会在一个时钟周期内变为高电平。我们称之为 完成脉冲。脉冲是一个在信号上持续一个时钟周期的 1(高电平),这个特定的脉冲信号表示 LFSR 完成了每次模式循环。
我们不能直接使用 LFSR 输出信号来闪烁 LED,但我们可以利用它来生成一个切换信号。我们通过将 LFSR 输出信号输入到触发器的使能端来实现这一点。触发器的输出将是其输入的反转(使用一个 NOT 门)。这样,每次 LFSR 完成一次模式循环时,完成脉冲将使触发器在一个时钟周期内启用,并触发触发器输出的变化,无论是从 0 到 1,还是从 1 到 0。最终结果是一个占空比为 50% 且频率约为 3 Hz 的信号,非常适合以人眼能看到的频率切换 LED。这个切换信号将作为输入传递给 demux 模块。1-4 线选择器根据两个开关(SW1 和 SW2)的值来选择将哪个 LED 接收切换信号。每次只有一个 LED 会闪烁,而开关未选择的 LED 将保持关闭。
编写代码
让我们来看一下这个项目的 Verilog 和 VHDL 代码,从顶层代码开始:
Verilog
module Demux_LFSR_Project_Top
(input i_Clk,
input i_Switch_1,
input i_Switch_2,
output o_LED_1,
output o_LED_2,
output o_LED_3,
output o_LED_4);
reg r_LFSR_Toggle = 1'b0;
wire w_LFSR_Done;
❶ LFSR_22 LFSR_Inst
(.i_Clk(i_Clk),
❷ .o_LFSR_Data(), // unconnected
❸ .o_LFSR_Done(w_LFSR_Done));
always @(posedge i_Clk)
begin
❹ if (w_LFSR_Done)
r_LFSR_Toggle <= !r_LFSR_Toggle;
end
❺ Demux_1_To_4 Demux_Inst
(.i_Data(r_LFSR_Toggle),
.i_Sel0(i_Switch_1),
.i_Sel1(i_Switch_2),
.o_Data0(o_LED_1),
.o_Data1(o_LED_2),
.o_Data2(o_LED_3),
.o_Data3(o_LED_4));
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Demux_LFSR_Project_Top is
port (
i_Clk : in std_logic;
i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
o_LED_1 : out std_logic;
o_LED_2 : out std_logic;
o_LED_3 : out std_logic;
o_LED_4 : out std_logic);
end entity Demux_LFSR_Project_Top;
architecture RTL of Demux_LFSR_Project_Top is
signal r_LFSR_Toggle : std_logic := '0';
signal w_LFSR_Done : std_logic;
begin
❶ LFSR_22 : entity work.LFSR_22
port map (
i_Clk => i_Clk,
❷ o_LFSR_Data => open, -- unconnected
❸ o_LFSR_Done => w_LFSR_Done);
process (i_Clk) is
begin
if rising_edge(i_Clk) then
❹ if w_LFSR_Done = '1' then
r_LFSR_Toggle <= not r_LFSR_Toggle;
end if;
end if;
end process;
❺ Demux_Inst : entity work.Demux_1_To_4
port map (
i_Data => r_LFSR_Toggle,
i_Sel0 => i_Switch_1,
i_Sel1 => i_Switch_2,
o_Data0 => o_LED_1,
o_Data1 => o_LED_2,
o_Data2 => o_LED_3,
o_Data3 => o_LED_4);
end architecture RTL;
我们的项目有三个顶层输入——时钟和两个开关——以及四个输出用于四个 LED。在声明这些后,我们实例化 LFSR 模块 ❶。我们接下来会仔细查看这个模块,但现在请注意它的 o_LFSR_Done 输出 ❸,我们将其连接到 w_LFSR_Done。这个输出将在每次 LFSR 循环时发出脉冲。
对于这个项目,我们实际上并不需要 LFSR 输出其寄存器中的当前值,但在其他情况下这可能很重要,因此 LFSR 模块为此目的提供了一个o_LFSR_Data输出。实例化模块时,如果输出未使用,有一个方便的小技巧是将这些输出保持不连接,这里我们就用o_LFSR_Data ❷。在 Verilog 中,我们只需将输出名称后的括号保持为空,而在 VHDL 中,我们使用open关键字。当这个设计被综合时,综合工具将会修剪掉任何未使用的输出,移除不再使用的逻辑。这样,你就可以复用模块,而不用担心将宝贵的 FPGA 资源浪费在未使用的功能上。综合工具足够智能,可以优化你的设计,去除不必要的信号。
在我们的顶层逻辑中,我们检查w_LFSR_Done是否为高电平,这意味着 LFSR 已经输出了完成脉冲 ❹。如果是,我们会反转r_LFSR_Toggle信号。这个信号将被发送到 1-4 解复用器,我们接下来将实例化该解复用器 ❺。选择由两个输入开关执行,解复用器的输出直接连接到四个输出 LED。
我们已经在第 94 页的《实现解复用器》一章中看过了 1-4 解复用器模块的代码。现在我们来看一下 LFSR 模块:
Verilog
module LFSR_22 (
input i_Clk,
output [21:0] o_LFSR_Data,
output o_LFSR_Done);
❶ reg [21:0] r_LFSR;
wire w_XNOR;
always @(posedge i_Clk)
begin
❷ r_LFSR <= {r_LFSR[20:0], w_XNOR};
end
❸ assign w_XNOR = r_LFSR[21] ^~ r_LFSR[20];
❹ assign o_LFSR_Done = (r_LFSR == 22'd0);
❺ assign o_LFSR_Data = r_LFSR;
endmodule
VHDL
library IEEE;
use IEEE.std_logic_1164.all;
entity LFSR_22 is
port (
i_Clk : in std_logic;
o_LFSR_Data : out std_logic_vector(21 downto 0);
o_LFSR_Done : out std_logic);
end entity LFSR_22;
architecture RTL of LFSR_22 is
❶ signal r_LFSR : std_logic_vector(21 downto 0);
signal w_XNOR : std_logic;
begin
process (i_Clk) begin
if rising_edge (i_Clk) then
❷ r_LFSR <= r_LFSR(20 downto 0) & w_XNOR;
end if;
end process;
❸ w_XNOR <= r_LFSR(21) xnor r_LFSR(20);
❹ o_LFSR_Done <= '1' when (r_LFSR = "0000000000000000000000") else '0';
❺ o_LFSR_Data <= r_LFSR;
end RTL;
本模块类似于我们在本章前面讨论过的 3 位 LFSR,但 LFSR 寄存器已扩展为 22 位宽 ❶。(如果需要不同的位宽,可以根据板子的时钟速度修改代码。)该模块还包含额外的逻辑来生成完成脉冲,并输出 LFSR 数据,这在其他应用中可能会有用。
我们将 LFSR 寄存器向右移,并将结果与最右边的位的新值❷连接,就像我们在 3 位 LFSR 模块中做的那样。然后,我们对寄存器中最左边的两位进行异或操作,得到新的最右边位值❸。当所有构成 LFSR 的触发器的输出为零时,我们在o_LFSR_Done输出上生成完成脉冲❹。由于这个状态会持续一个时钟周期,因此脉冲的宽度为一个时钟周期。否则,o_LFSR_Done会保持低电平。最后,我们将 LFSR 寄存器的内容分配给o_LFSR_Data输出❺。这样,模块提供了对 LFSR 数据本身的访问,但请记住,在这种情况下,o_LFSR_Data输出不会被合成,因为我们在这个特定应用中不需要这些数据。
现在,你可以构建并编程 FPGA。当项目开始运行时,你应该能看到其中一个 LED 在闪烁,但你可以通过按下两个开关中的一个或两个来选择不同的 LED 进行闪烁。
尝试另一种方式
这个项目展示了像 LFSR 和解复用器这样的简单构建模块如何组合起来构建更大的项目,并且展示了 LFSR 的一个有趣应用。然而,在现实世界中,你可能不会像这样使用 LFSR 作为计数器,因为它提供的灵活性不大。假设我们想要改变计数上限。对于 LFSR 的实现,我们只能根据 LFSR 中位数的不同,选择少数几种可能的选项。对于 LED 闪烁来说,这完全可以接受,因为我们不在乎 LED 到底闪烁得多快——2 到 4 赫兹之间的任何频率都可以。但如果我们需要计数到一个非常具体的值——比如,4000000,而不是 4194303——我们就很难用 LFSR 来实现了。下一个最低的选择是使用 21 位 LFSR 代替 22 位 LFSR,这样我们只能计数到 2²¹ − 1 = 2097151。对于 2097151 到 4194303 之间的任何值,我们都无能为力。
为了提供更多的灵活性,我创建了另一个使用传统计数器的版本。图 6-6 显示了这个替代代码的框图。

图 6-6:修订后的项目#5 框图
在这里,我们用一个模块替换了 LFSR 模块,这个新模块只会计数到某个值,然后切换其输出。这个方法还让我们能够去除项目中两个模块之间的触发器和非门。让我们来看看这个新模块Count_And_Toggle的代码:
Verilog
module Count_And_Toggle #(COUNT_LIMIT = 10)
(input i_Clk,
input i_Enable,
output reg o_Toggle);
❶ reg [$clog2(COUNT_LIMIT-1):0] r_Counter;
always @(posedge i_Clk)
begin
if (i_Enable == 1'b1)
begin
❷ if (r_Counter == COUNT_LIMIT - 1)
begin
❸ o_Toggle <= !o_Toggle;
❹ r_Counter <= 0;
end
else
❺ r_Counter <= r_Counter + 1;
end
else
o_Toggle <= 1'b0;
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity Count_And_Toggle is
generic (COUNT_LIMIT : natural);
port (
i_Clk : in std_logic;
i_Enable : in std_logic;
o_Toggle : out std_logic);
end Count_And_Toggle;
architecture RTL of Count_And_Toggle is
❶ signal r_Counter : natural range 0 to COUNT_LIMIT - 1;
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
if i_Enable = '1' then
❷ if r_Counter = COUNT_LIMIT - 1 then
❸ o_Toggle <= not o_Toggle;
❹ r_Counter <= 0;
else
❺ r_Counter <= r_Counter + 1;
end if;
else
o_Toggle <= '0';
end if;
end if;
end process;
end RTL;
这段代码比 LFSR 代码更容易阅读和理解。我们声明了一个将作为计数器的寄存器,使用COUNT_LIMIT参数/通用参数来定义其大小 ❶。如果模块已启用,我们检查计数器是否已达到其限制 ❷。如果是这样,我们反转输出信号 ❸并重置计数器 ❹。如果计数器没有达到限制,它只会简单地增加 1 ❺。通过这段代码,我们可以将计数器设置为任何任意值,它将准确地计数到该值。
注意
在 VHDL 代码中,我们在赋值的右侧有一个输出信号 o_Toggle ❸,这意味着我们正在访问输出的值。这在 VHDL-2008 及以后版本中有效,但在旧版本的 VHDL 中会抛出错误。我建议在设计中使用 VHDL-2008,因为像这样的改进值得采用。
现在让我们看看使用这个新的Count_And_Toggle模块代替 LFSR 所需的顶层代码的更改:
Verilog
`--snip--`
output o_LED_3,
output o_LED_4);
// Equivalent to 2²² - 1, which is what the LFSR counted up to
localparam COUNT_LIMIT = 4194303;
wire w_Counter_Toggle;
❶ Count_And_Toggle #(.COUNT_LIMIT(COUNT_LIMIT)) Toggle_Counter
(.i_Clk(i_Clk),
.i_Enable(1'b1),
.o_Toggle(w_Counter_Toggle));
Demux_1_To_4 Demux_Inst
❷ (.i_Data(w_Counter_Toggle),
.i_Sel0(i_Switch_1),
.i_Sel1(i_Switch_2),
.o_Data0(o_LED_1),
.o_Data1(o_LED_2),
.o_Data2(o_LED_3),
.o_Data3(o_LED_4));
endmodule
VHDL
`--snip--`
architecture RTL of Demux_LFSR_Project_Top is
-- Equivalent to 2²² - 1, which is what the LFSR counted up to
constant COUNT_LIMIT : integer := 4194303;
signal w_Counter_Toggle : std_logic;
begin
❶ Toggle_Counter : entity work.Count_And_Toggle
generic map (
COUNT_LIMIT => COUNT_LIMIT)
port map (
i_Clk => i_Clk,
i_Enable => '1',
o_Toggle => w_Counter_Toggle);
Demux_Inst : entity work.Demux_1_To_4
port map (
❷ i_Data => w_Counter_Toggle,
i_Sel0 => i_Switch_1,
i_Sel1 => i_Switch_2,
o_Data0 => o_LED_1,
o_Data1 => o_LED_2,
o_Data2 => o_LED_3,
o_Data3 => o_LED_4);
end architecture RTL;
我已经剪掉了相同部分。LFSR 已被移除,并用Count_And_Toggle模块替代 ❶。由于该模块生成了一个切换信号,我们不再需要两个模块之间的触发器。相反,我们可以将w_Counter_Toggle(Count_And_Toggle模块的输出)直接送入解复用器 ❷。
比较两种方法
正如你所看到的,使用传统计数器比使用 LFSR 更简单、更灵活。然而,之前我曾断言实现 LFSR 比传统计数器需要更少的资源。让我们比较一下这两种方法在该项目中的资源使用报告,看看资源节省有多显著。首先,这里是 LFSR 版本的报告:
`--snip--`
Register bits not including I/Os: 23 (1%)
Mapping Summary:
Total LUTs: 13 (1%)
这是计数器版本的报告:
`--snip--`
Register bits not including I/Os: 24 (1%)
Mapping Summary:
Total LUTs: 36 (2%)
LFSR 方法比计数器少用了 1 个触发器和 23 个 LUT,因此 LFSR 确实需要更少的资源。然而,将其放到具体情况中来看更有帮助。现代 FPGA 拥有成千上万个 LUT,你真的不应该每一个都去计算。通过使用 LFSR,我们可能会节省 1%的 FPGA 总资源(甚至更少),但我们在设计上失去了可读性和灵活性。总的来说,我更倾向于实现那些合理且简单的解决方案,而在这种情况下,LFSR 并不是最简单的解决方案。
除了向你展示如何闪烁 LED 并通过组合各种基础构建块创建一个复杂的项目外,这个项目还说明了简洁性与资源之间常常存在权衡。你会发现,在 FPGA 中通常有几种方法可以解决问题,你需要确定哪种解决方案最适合你。可能最节省资源的解决方案并不是最简单的,但另一方面,最简单的解决方案可能并不会显著增加资源的使用。在许多情况下,你可能会用不同的方法对一个设计进行迭代,测试每一种方法。这始终是一个很好的练习;当你探索多种编写代码的方式时,你会成为一名更强的 FPGA 工程师。
随机存取存储器
随机存取存储器(RAM) 允许你在 FPGA 中存储数据并在稍后读取。这在 FPGA 设计中是一个非常常见的需求。例如,你可能想要存储从相机、计算机或微控制器接收到的数据,并在稍后处理时提取它,或者你可能需要为数据创建一个存储空间,然后将其保存到 microSD 卡中。这些只是 RAM 的一些使用场景的例子。名称中的随机存取部分意味着你可以以任何顺序访问数据。例如,在一个时钟周期内,你可以读取内存的第一个位置,然后在下一个时钟周期读取内存的最后一个位置。
RAM 通常设计为单端口或双端口。在单端口 RAM 中,只有一个接口进入内存,因此在一个时钟周期内,你要么读取内存,要么写入内存,但不能同时进行。双端口 RAM 允许你在同一个时钟周期内既读取又写入内存。后者更具多功能性且使用更为广泛,因此我们将重点讲解如何在 FPGA 上实现这一功能。图 6-7 高层次地展示了我们将要创建的内容。请注意,这只是一个可能的实现方式;确切的信号名称可能会有所不同。

图 6-7:双端口 RAM 框图
在图形的中间,内存本身由大矩形表示。内存的大小由其宽度和深度定义。深度决定了可用内存位置的数量,而宽度决定了每个位置可以存储多少位数据。例如,如果内存的宽度为 8 位,那么每个位置可以存储一个字节的数据。将宽度乘以深度可以告诉你可用内存的总位数。例如,如果我们有一个宽度为 8 位、深度为 16 的内存,那么总共有 8 × 16 = 128 位内存。
内存有两个端口,一个用于写入(在左侧),一个用于读取(在右侧)。每个端口都有各自的时钟信号,i_Wr_Clk 和 i_Rd_Clk。为了方便起见,我们将这两个信号连接到同一个时钟,但需要注意的是,每个端口也可以根据其独立的时钟工作。我们将在第七章讨论如何处理多个时钟,或者说是 跨时钟域 问题。现在,先知道这个模块在设计时就考虑了这一特性。
每个端口都有一个地址信号,i_Wr_Addr 和 i_Rd_Addr,它们用于传递写入或读取操作应该进行的内存位置。如果你有过 C 语言编程经验,这就像是数组的索引。这些索引通常从 0 到(depth - 1),因此我们在物理内存中总共有 depth 个位置。
在写入数据时,我们需要正确设置写入地址,将要写入的数据放在 i_Wr_Data 上,并在一个时钟周期内脉冲 i_Wr_DV。这里的 DV 代表 数据有效,通常用来表示数据信号应该被模块“关注”。如果我们想继续写入内存,可以更改地址和数据,并持续脉冲数据有效信号。
在读取数据时,我们将 i_Rd_En 信号置高,同时将读取地址设置为我们想要读取的地址。执行读取操作的模块可以简单地监控输出 o_Rd_DV,观察它何时变高;这表明 o_Rd_Data 上有有效数据,这是从内存中读取的数据。
RAM 实现
现在你已经大致了解了 RAM 的工作原理,我们来看看实现内存的代码:
Verilog
module RAM_2Port ❶ #(parameter WIDTH = 16, DEPTH = 256)
(
// Write signals
input i_Wr_Clk,
input [$clog2(DEPTH)-1:0] i_Wr_Addr,
input i_Wr_DV,
input [WIDTH-1:0] i_Wr_Data,
// Read signals
input i_Rd_Clk,
input [$clog2(DEPTH)-1:0] i_Rd_Addr,
input i_Rd_En,
output reg o_Rd_DV,
output reg [WIDTH-1:0] o_Rd_Data
);
❷ reg [WIDTH-1:0] r_Mem[DEPTH-1:0];
always @ (posedge i_Wr_Clk)
begin
❸ if (i_Wr_DV)
begin
❹ r_Mem[i_Wr_Addr] <= i_Wr_Data;
end
end
always @ (posedge i_Rd_Clk)
begin
❺ o_Rd_Data <= r_Mem[i_Rd_Addr];
❻ o_Rd_DV <= i_Rd_En;
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity RAM_2Port is
❶ generic (
WIDTH : integer := 16;
DEPTH : integer := 256
);
port (
-- Write signals
i_Wr_Clk : in std_logic;
i_Wr_Addr : in std_logic_vector; -- sized at higher level
i_Wr_DV : in std_logic;
i_Wr_Data : in std_logic_vector(WIDTH-1 downto 0);
-- Read signals
i_Rd_Clk : in std_logic;
i_Rd_Addr : in std_logic_vector; -- sized at higher level
i_Rd_En : in std_logic;
o_Rd_DV : out std_logic;
o_Rd_Data : out std_logic_vector(WIDTH-1 downto 0)
);
end RAM_2Port;
architecture RTL of RAM_2Port is
type t_Mem is array (0 to DEPTH-1) of std_logic_vector(WIDTH-1 downto 0);
❷ signal r_Mem : t_Mem;
begin
process (i_Wr_Clk)
begin
if rising_edge(i_Wr_Clk) then
❸ if i_Wr_DV = '1' then
❹ r_Mem(to_integer(unsigned(i_Wr_Addr))) <= i_Wr_Data;
end if;
end if;
end process;
process (i_Rd_Clk)
begin
if rising_edge(i_Rd_Clk) then
❺ o_Rd_Data <= r_Mem(to_integer(unsigned(i_Rd_Addr)));
❻ o_Rd_DV <= i_Rd_En;
end if;
end process;
end RTL;
我们已经将内存实现为一个名为RAM_2Port的模块。请注意,该模块有两个参数(在 Verilog 中)或泛型(在 VHDL 中):WIDTH和DEPTH ❶。这使我们能够灵活地创建任何大小的 RAM,而无需修改模块代码。如果我们需要一个宽度为 4 位,深度为 16 个位置的内存,这段代码可以实现;如果我们需要一个宽度为 16 位,深度为 1,024 的内存,这段代码也可以实现。我们只需在实例化模块时选择不同的WIDTH和DEPTH值。
查看模块的信号声明,我们可以看到在图 6-7 中显示的所有信号,这些信号构成了写入和读取接口。信号i_Wr_Addr和i_Rd_Addr将分别提供写入和读取位置的索引。这些地址信号的位宽足够大,可以表示内存中包含DEPTH个元素的任何索引。例如,如果需要访问 128 个内存位置(DEPTH = 128),则需要 7 位(2⁷ = 128),因此地址信号将为 7 位宽。在 Verilog 中,这种地址大小与第五章中描述的$clog2()技巧配合使用。在 VHDL 中,我们可以将向量的长度未定义,并在更高层次的模块中实例化此内存时设置它。实例化本身必须是固定宽度的,这将指定此模块中的地址信号宽度。我们使用参数/泛型来调整信号大小的最后一个地方是i_Wr_Data和o_Rd_Data。它们分别承载写入或读取的实际数据,并根据WIDTH的值进行大小调整,以适应内存中每个位置的完整宽度。
我们将内存本身实例化为r_Mem ❷。它将具有WIDTH位宽和DEPTH深度,总存储量为WIDTH × DEPTH位。这在代码中实例化了一个二维(2D)数组。在 Verilog 中,我们通过设置特定宽度的寄存器来创建它,就像以前做过的那样,但在末尾加上额外的括号,以根据DEPTH指定内存位置的数量。在 VHDL 中,我们需要创建一个名为t_Mem的自定义数据类型,定义二维数组;然后我们可以创建类型为t_Mem的内存信号r_Mem。
接下来,我们为写操作和读操作分别设置自己的always或process块,这些块分别由i_Wr_Clk和i_Rd_Clk时钟信号触发。(再说一次,除非需要跨时钟域,否则可以在实例化该模块的更高层代码中将这些信号连接到相同的时钟。)对于写操作,我们首先检查i_Wr_DV信号是否为高电平 ❸。如果是,我们将i_Wr_Data上的数据存储到i_Wr_Addr指定的位置 ❹。这看起来很像更新数组中的一个值,因为我们基本上就是在做这件事。
对于读操作,o_Rd_Data输出将更新为由i_Rd_Addr给定地址的存储器中的值 ❺。同时,i_Rd_En上的值会传递到o_Rd_DV ❻。当高层模块确实尝试读取数据时,它将把i_Rd_En设置为高,并将此信号传递给o_Rd_DV,从而生成一个数据有效脉冲,告知高层模块数据可以安全读取。然而,请注意,i_Rd_En并不真正控制数据何时在该模块中读取。实际上,更新o_Rd_Data ❺的代码会在每个时钟周期运行,并更新为存储在i_Rd_Addr地址位置的任何内容,无论我们是否明确尝试从存储器中读取数据。这没关系!即使我们最终忽略被读取的数据,在每个时钟周期读取内存也不会造成任何 harm。
要在仿真中查看双端口存储器的工作情况,请从代码库下载代码并运行此模块的测试平台。
FPGA 上的 RAM
我们已经编写了一个双端口 RAM 的代码,但构成内存本身的 FPGA 组件是什么?答案是,这取决于情况。如果内存足够小——例如,宽度为 4,深度为 8——则存储元件将是单独的触发器。然而,如果内存足够大,综合工具将决定使用块 RAM(BRAM)。我们将在第九章中详细讨论块 RAM。现在,只需知道它是 FPGA 中存在的一个大型内存存储组件,专门用于这个目的。
你不想使用触发器来存储大型内存,因为你会受到可用于存储内存的触发器数量的限制。你希望将这些宝贵的触发器用于 FPGA 中的主要工作,而不仅仅是将单个位的数据存储在大型内存中。综合工具很聪明;它们知道将大型内存实例化为一个或多个块 RAM 是最好的做法。
FIFO:先进先出
先进先出(FIFO)是 FPGA 中用于存储和检索数据的另一个常见构建模块。FIFO 的概念非常简单:数据一次一个条目地进入,然后按从最旧到最新的顺序读取。图 6-8 显示了 FIFO 的高级表示。

图 6-8:FIFO 的高级框图
如你所见,FIFO 有一个写接口,左侧将数据推入,右侧有一个读接口将数据拉出。与图 6-7 中的双端口 RAM 图相比,注意到这里我调换了宽度和深度。这有助于可视化 FIFO 的关键行为:先写入的数据是先读出的数据。从这个角度看,数据通过 FIFO 的流动就像车通过隧道一样。第一辆车进隧道,也是第一辆车出隧道。其他编程语言通常有某种队列结构,表现方式与此相同。然而,在 FPGA 中的 FIFO,你是用真实的组件构建了一个真实的队列!
FIFO 在 FPGA 设计中被广泛使用。每当你需要在生产者和消费者之间缓冲一些数据时,FIFO 充当了这个缓冲区。例如,要将数据写入外部存储元素,如低功耗双倍数据速率(LPDDR)内存,你会使用多个 FIFO 来排队数据,然后将其迅速从 FPGA 传输到 LPDDR。同样,如果你与摄像头接口,可能会将像素数据的行存储到 FIFO 中,以进行图像处理,如模糊或亮度增强。最后,每当你需要跨时钟域发送数据时,FIFO 就能胜任:一个时钟协调将数据加载到 FIFO 中,而另一个时钟协调将数据读取出来。
当 FIFO 没有更多可用的存储位置来写入新数据时,FIFO 就是满的。当 FIFO 没有任何内容时,它就是空的。这导致了两个关键规则,你必须遵循这些规则,以确保 FIFO 按预期行为工作:
1. 永远不要写入满的 FIFO。
2. 永远不要从空 FIFO 读取。
写入满的 FIFO 是坏的,因为它可能导致数据丢失:你最终会覆盖之前存储的数据。从空 FIFO 读取也是不好的,因为你无法知道会从中读取到什么数据。违反这两个规则之一是我在职业生涯中遇到过的最常见的 FPGA 错误之一。它也是较难发现的错误之一,因为写入满的 FIFO 或从空 FIFO 读取可能会导致奇怪的行为,如意外的数据和数据丢失。通常这些损坏的数据看起来像是数据本身有问题,而不是 FIFO 的问题,因此导致错误的原因很难诊断。我们在讨论 FIFO 如何工作的细节时,请记住这些规则。
输入和输出信号
FIFO 基本上是双端口 RAM 的一个版本,额外添加了一些信号来实现 FIFO 的行为。在我们查看 FIFO 的代码之前,让我们先考虑一下这些信号是什么。图 6-9 展示了 FIFO 的更详细的块图。

图 6-9:详细的 FIFO 块图
像双端口 RAM 一样,FIFO 有用于写入和读取的端口。每个端口都有自己的专用时钟。FIFO 通常用于跨时钟域,因此i_Wr_Clk与i_Rd_Clk不同。然而,我们要探索的 FIFO 具有一个单独的时钟,既用于写入端口也用于读取端口,为了简化设计并提高在不同 FPGA 上的可移植性。
接下来,在写入端,i_Wr_DV(数据有效)输入信号表示当i_Wr_Data上有数据需要写入时,将数据推送到 FIFO 中。在读取端,i_Rd_En和i_Rd_DV信号类似地表示何时希望读取数据,而o_Rd_Data输出则检索实际的数据。所有这些就像我们在 RAM 中看到的一样。然而,与 RAM 不同的是,我们不再需要担心记录要写入或读取的地址。FIFO 知道在读写时会顺序循环通过内存地址,一个接一个。因此,我们不再需要像 RAM 中那样的i_Wr_Addr和i_Rd_Addr输入信号。相反,剩余的输入和输出信号帮助跟踪 FIFO 的使用情况,同时确保我们不会向已满的 FIFO 写入或从空 FIFO 读取。
在写入端,当 FIFO 中的所有位置都被写入时,o_Full输出会变高。当用户看到o_Full变高时,他们必须停止写入 FIFO,直到有空间释放并且o_Full再次变低。正如你现在所知道的,写入已满的 FIFO 是非常不好的,应该避免。
i_AF_Level 和 o_AF_Flag 信号,同样在写入端,并不总是包含在 FIFO 模块中,但它们非常有用。AF 是 几乎满 的缩写,这些信号允许用户在 FIFO 填满之前设置一个水位线。如果 FIFO 中的元素数量(有时称为 字)大于或等于由 i_AF_Level 设置的值,则 o_AF_Flag 将为高电平。否则,o_AF_Flag 将为低电平。这个特性在数据批量写入 FIFO 的情况下尤其有用。例如,假设写入接口 必须 在一个突发中最少写入四个元素,意味着一旦接口开始写入,即使 o_Full 标志在突发过程中变为高电平,它也不能停止。为了防止数据丢失,我们希望将 i_AF_Level 设置为 深度 − 4,然后在每次写入四个元素的突发前,检查 o_AF_Flag 是否为低电平。这样可以确保在开始写入操作之前,FIFO 中有足够的空间容纳所有四个元素。
读端有一组类似的 FIFO 特定信号。o_Empty 当 FIFO 中没有数据时将为高电平。为了确保我们不会从空的 FIFO 中读取数据,我们应该在尝试读取数据之前检查 o_Empty 标志,以了解是否有数据可供读取。
i_AE_Level 和 o_AE_Flag 信号的行为类似于 i_AF_Level 和 o_AF_Flag,保证在 FIFO 中不会在读突发过程中变为空(AE 是 几乎空 的缩写)。例如,假设你的 FIFO 深度为 1,024 位,宽度为 1 字节,而你有一个 LPDDR 接口,要求数据以 256 字节的突发模式写入。同样,由于突发不能被中断,如果 FIFO 在读过程中变为空,你不能简单地停止读取。为了保证在发送 256 字节的数据突发到 LPDDR 之前,FIFO 中至少有 256 字节可供读取,可以将 i_AE_Level 设置为 256,并在读取数据之前检查 o_AE_Flag 是否为低电平。
注意
如果您的应用不需要几乎满或几乎空的行为,您可以在设计中忽略 i_AF_Level、o_AF_Flag、i_AE_Level 和 o_AE_Flag 信号。
图 6-10 显示了一些总结我们学到的 FIFO 信号的示例。

图 6-10:FIFO 标志示例
该图示例了一个深度为 12 字(宽度不重要)的 FIFO。假设我们将<samp class="SANS_TheSansMonoCd_W5Regular_11">i_AE_Level设置为 4,<samp class="SANS_TheSansMonoCd_W5Regular_11">i_AF_Level设置为 8。在第一行中,我们可以看到,如果 FIFO 为空,则计数为零,且<samp class="SANS_TheSansMonoCd_W5Regular_11">o_Empty和<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AE_Flag信号都被设置为 1。请记住,当计数小于或等于<samp class="SANS_TheSansMonoCd_W5Regular_11">i_AE_Level时,<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AE_Flag会被设置。接下来,我们看到有四个字被写入,FIFO 不再为空,但<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AE_Flag仍然被设置。直到第五个字被写入,<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AE_Flag才会变低。从第五个字到第七个字,所有标志都为低,但当 FIFO 中有八个字时,<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AF_Flag变为高(因为<samp class="SANS_TheSansMonoCd_W5Regular_11">i_AF_Level被设置为 8)。当 FIFO 满时,我们看到<samp class="SANS_TheSansMonoCd_W5Regular_11">o_AF_Flag和<samp class="SANS_TheSansMonoCd_W5Regular_11">o_Full都为高。
FIFO 实现
现在我们将考虑用于实现图 6-9 所示 FIFO 的 Verilog 和 VHDL 代码。该代码在“RAM 实现”一节中讨论的<samp class="SANS_TheSansMonoCd_W5Regular_11">RAM_2Port模块的基础上,增加了将 RAM 转换为 FIFO 的功能。完整代码可在本书的 GitHub 代码库中找到,那里还有测试平台。此处不展示模块信号或内存(来自前一节的双端口 RAM)的实例化,我们将专注于实现 FIFO 功能的代码:
Verilog
`--snip--`
always @(posedge i_Clk or negedge i_Rst_L)
begin
❶ if (~i_Rst_L)
begin
r_Wr_Addr <= 0;
r_Rd_Addr <= 0;
r_Count <= 0;
end
else
begin
❷ if (i_Wr_DV)
begin
if (r_Wr_Addr == DEPTH-1)
r_Wr_Addr <= 0;
else
r_Wr_Addr <= r_Wr_Addr + 1;
end
❸ if (i_Rd_En)
begin
if (r_Rd_Addr == DEPTH-1)
r_Rd_Addr <= 0;
else
r_Rd_Addr <= r_Rd_Addr + 1;
end
❹ if (i_Rd_En & ~i_Wr_DV)
begin
if (r_Count != 0)
begin
r_Count <= r_Count - 1;
end
end
❺ else if (i_Wr_DV & ~i_Rd_En)
begin
if (r_Count != DEPTH)
begin
r_Count <= r_Count + 1;
end
end
if (i_Rd_En)
begin
o_Rd_Data <= w_Rd_Data;
end
end // else: !if(~i_Rst_L)
end // always @ (posedge i_Clk or negedge i_Rst_L)
❻ assign o_Full = (r_Count == DEPTH) ||
(r_Count == DEPTH-1 && i_Wr_DV && !i_Rd_En);
assign o_Empty = (r_Count == 0);
assign o_AF_Flag = (r_Count > DEPTH - i_AF_Level);
assign o_AE_Flag = (r_Count < i_AE_Level);
`--snip--`
VHDL
`--snip--`
process (i_Clk, i_Rst_L) is
begin
❶ if not i_Rst_L then
r_Wr_Addr <= 0;
r_Rd_Addr <= 0;
r_Count <= 0;
elsif rising_edge(i_Clk) then
❷ if i_Wr_DV then
if r_Wr_Addr = DEPTH-1 then
r_Wr_Addr <= 0;
else
r_Wr_Addr <= r_Wr_Addr + 1;
end if;
end if;
❸ if i_Rd_En then
if r_Rd_Addr = DEPTH-1 then
r_Rd_Addr <= 0;
else r_Rd_Addr <= r_Rd_Addr + 1;
end if;
end if;
❹ if i_Rd_En = '1' and i_Wr_DV = '0' then
if (r_Count /= 0) then
r_Count <= r_Count - 1;
end if;
❺ elsif i_Wr_DV = '1' and i_Rd_En = '0' then
if r_Count /= DEPTH then
r_Count <= r_Count + 1;
end if;
end if;
if i_Rd_En = '1' then
o_Rd_Data <= w_Rd_Data;
end if;
end if;
end process;
❻ o_Full <= '1' when ((r_Count = DEPTH) or
(r_Count = DEPTH-1 and i_Wr_DV = '1' and i_Rd_En = '0'))
else '0';
o_Empty <= '1' when (r_Count = 0) else '0';
o_AF_Flag <= '1' when (r_Count > DEPTH - i_AF_Level) else '0';
o_AE_Flag <= '1' when (r_Count < i_AE_Level) else '0';
`--snip--`
这段代码的大部分是主要的always块(在 Verilog 中)或process块(在 VHDL 中),它处理内存寻址、计数 FIFO 中的元素数量以及读写操作。请注意,这个块在灵敏度列表中有一个复位信号i_Rst_L,除此之外还有时钟信号。如果复位信号为低电平,则我们处于复位状态,并复位控制读地址、写地址和 FIFO 计数❶的信号。复位信号名称末尾的_L提示它是低有效信号。
注意
正如我之前提到的,FIFO 在跨时钟域时非常有用,但这种特定实现的 FIFO 不能实现这一点。它只有一个时钟,即 i_Clk 信号。跨时钟域是一个高级特性,我们在本书的这个阶段还没有准备好实现。
接下来,我们创建写地址❷和读地址❸的逻辑。对于这两个地址,我们每次执行写操作或读操作时,都简单地递增地址。当我们到达 FIFO 中的最后一个地址,即DEPTH-1时,我们会从地址 0 重新开始。得益于这个系统,元素按顺序写入内存,并且以相同的顺序从内存中读取,确保遵循先进先出的原则。
为了跟踪 FIFO 中的元素数量,首先我们检查是否正在进行读操作但没有写操作❹。在这种情况下,FIFO 中的总元素数减少 1。接下来,我们检查是否正在进行写操作但没有读操作❺,在这种情况下,FIFO 中的总元素数增加 1。也有可能同时进行读写操作,但请注意代码并未显式处理这种情况。这是故意的;在这种情况下,计数将保持不变。我们可以通过写r_Count <= r_Count;来显式处理,但这并不是必须的。默认情况下,计数变量保持其值不变。
我们还在always或process块外执行了几个信号赋值 ❻。回想一下,这将生成组合逻辑(与顺序逻辑相对)。首先,我们赋值o_Full标志,当r_Count等于DEPTH,或者当r_Count等于DEPTH-1 并且有写操作 并且没有读操作时,标志为高。这第二种情况允许满标志“预示”写操作,并告诉上层模块停止写入,因为 FIFO 即将满。
接下来是o_Empty赋值,它稍微简单一些。当计数为零时,FIFO 为空;否则,它不为空。之后,我们赋值几乎满(o_AF_Flag)和几乎空(o_AE_Flag)标志。对于这些,我们需要将 FIFO 的计数与由i_AF_Level和i_AE_Level分别确定的阈值进行比较。这是我们第一次在 Verilog 和 VHDL 中看到<和>比较操作符的使用。这些在组合信号赋值中是完全有效的。
通过从上层模块监控这些状态标志,你将能够精确控制数据何时可以进出 FIFO。
总结
在本章中,你学习了 FPGA 设计中几个常见的构建模块,包括多路复用器和解多路复用器、移位寄存器、RAM 和 FIFO。你了解了这些组件的工作原理,并学会了如何用 Verilog 和 VHDL 实现它们。通过这些基础的代码模块,你可以开始看到如何将非常大的 FPGA 设计分解成许多较小的模块并将它们结构化组合起来。
第十二章:7 综合、放置与布线,以及跨时钟域

在第二章中,我提供了 FPGA 构建过程的概述,帮助你熟悉运行本书项目所需的工具。接下来,我们将更深入地了解构建过程,帮助你更清楚地理解当你点击“构建 FPGA”按钮时究竟发生了什么。一旦你对 FPGA 工具的工作原理有了清晰的了解,你将能够避免许多常见的错误,并编写高可靠性的代码。
正如你在第二章中学到的,写完 Verilog 或 VHDL 代码后,FPGA 设计会经历三个阶段:综合、放置与布线、以及编程。如果其中任何一个过程失败,FPGA 构建将无法成功。在本章中,我们将重点讨论前两个阶段。我们将详细讨论综合,并区分可综合和不可综合的代码。之后,我们将回顾放置与布线过程,并探讨在这一阶段常见的一个问题:时序错误。你将学习这些错误的原因,并了解如何修复它们。最后,我们将详细探讨一个在 FPGA 设计中尤其容易遇到时序问题的情况:当信号跨越运行在不同时钟频率下的 FPGA 设计部分时。你将学习如何安全地跨越时钟域。
综合
综合是将你的 Verilog 或 VHDL 代码分解并转换为在特定 FPGA 上存在的简单组件(如查找表、触发器、块 RAM 等)的过程。从这个意义上讲,FPGA 综合工具类似于编译器,它将类似 C 语言的代码分解为 CPU 能够理解的非常简单的指令。
为了确保过程正常工作,综合工具需要确切知道你正在使用的 FPGA 类型,以便它知道有哪些资源可用。然后,由于这些资源是有限的,综合工具的任务就是找出如何尽可能高效地使用它们。这被称为逻辑优化(或逻辑最小化),它是综合过程中的一个重要部分。正如我在第三章中提到的,你无需手动执行逻辑优化;你可以简单地将其交给综合工具。但这并不意味着,编写能够智能利用可用资源的代码不重要。了解你的代码将如何综合成硬件是成为一个优秀 FPGA 设计师的关键。
合成过程的一个重要输出是你的资源使用报告,它告诉你在设计中使用了多少 LUT、触发器、块 RAM 和其他资源。我们在过去的章节中曾经分析过部分资源使用报告;我建议你总是通读这个报告,以确保你的预期与实际使用的资源相符。
注释、警告和错误
合成过程通常会生成大量的注释和警告,即使在运行成功时也是如此。若运行失败,过程还会生成错误。注释大多是信息性的,告诉你工具是如何解读你的代码的。警告则值得关注,以确保你没有犯错误。然而,在大型设计中,可能会有数百个警告,这会让人感到不堪重负。一些工具允许在你对警告感到满意时将其隐藏。这是一个有用的功能,可以帮助你专注于真正的问题。
有一个特别值得注意的警告是推断锁存器警告。正如你在第四章中学到的,锁存器是有问题的。它们通常是偶然创建的,而工具在 FPGA 设计的时序上下文中可能会很难分析它们。如果你创建了一个锁存器,在合成过程中会收到通知。你会收到如下警告:
[Synth 8-327] inferring latch for variable 'o_test' [test_program.vhd:19]
不要忽视这个警告。除非你确信真的需要在设计中使用这个锁存器,否则你应该尽量去除它。我做 FPGA 设计已经很多年了,我从来没有需要使用锁存器,因此如果你打算保留它,必须有非常充分的理由。
如果在合成过程中出现问题,你将得到一个错误而不是警告。你会遇到的两个最常见的错误是语法错误和资源使用错误;我们接下来会详细讨论这两种错误。
语法错误
当你开始合成过程时,工具首先会检查你的 Verilog 或 VHDL 代码是否有语法错误。这些是你最常遇到的错误。代码中可能潜藏着数百种语法错误;例如,你可能忘记定义一个信号、错误拼写了一个关键字,或者遗漏了分号。在最后这种情况下,你可能会在 Verilog 中看到如下错误信息:
** Error: (vlog-13069) design.v(5): near "endmodule": syntax error,
unexpected endmodule, expecting ';' or ','.
或者在 VHDL 中出现这样的警告:
** Error: design.vhd(5): near "end": (vcom-1576) expecting ';'.
合成工具会告诉你在哪个文件的哪一行遇到了错误。例如,在前面的错误信息中,Verilog 中的design.v(5)或 VHDL 中的design.vhd(5)告诉你检查名为design的文件的第 5 行。你可以利用这些信息来编辑代码,以通过语法检查。
有时,你可能会遇到大量的语法错误。此时最好的做法是先找到第一个错误并修复它。通常,错误的级联是由第一个错误引起的。这是工程中的一个好规则:先解决第一个问题。一旦解决了第一个语法错误,就重新运行综合过程。如果仍然有错误,再找到第一个错误,修复它,然后重新运行综合。这一过程是迭代的,通常需要几个循环才能解决所有语法错误并成功完成综合。
资源利用错误
一旦你的代码通过语法检查,接下来你最常遇到的错误就是资源利用错误,这意味着你的设计需要的组件数量超出了 FPGA 可用的资源。例如,如果你的 FPGA 有 1,000 个触发器,而你的代码要求 2,000 个,那么你就会遇到资源利用错误。设计根本无法适配到你的 FPGA 上,所以你需要想办法精简代码,以便实例化更少的触发器。一个好的经验法则是,尽量确保所用的 LUT 或触发器不超过可用资源的 80%。这样可以让布局和布线过程更容易完成,以确保你的设计满足时序要求(更多内容将在本章后面讨论),同时也能为未来修改设计或增加新功能提供更多灵活性。
如果你的代码无法适配到选定的 FPGA 上,你有几种选择:
1. 切换到更大的 FPGA。
2. 识别最占资源的模块并重新编写它们。
3. 移除功能。
切换到更大的 FPGA 可能是个大问题,但并不总是如此。许多 FPGA 厂商提供相同物理封装的高资源 FPGA 与低资源版本。高资源版本通常价格稍贵,因此你需要为额外的资源支付一些额外费用,但新的 FPGA 不会占用电路板上的额外空间。在为项目选择 FPGA 时,最好选择一个可以升级资源的 FPGA 家族和封装,以防你需要的资源超过预期。
如果你不能切换到不同的 FPGA,那么下一步是分析你的代码,看看它是否使用了比必要更多的资源。这并不是为了优化低级逻辑,去削减一些 LUT 或触发器——这些工具会为你做这些工作。实际上,有些方法可能会让你无意中写出使用比预期更多资源的代码。例如,我曾追踪一个高资源利用率的错误,最终发现是因为一行代码在做两个数字的除法操作。正如你将在第十章中学到的,除法通常是 FPGA 中一个非常资源密集的操作。我通过创建一个输入输出映射表,将除法操作转化为内存操作。这使用了块 RAM,但释放了用于除法的 LUT 和触发器,帮助 FPGA 顺利通过综合过程。以减少资源利用为重点的代码重写是你随着 FPGA 经验的积累而不断提高的技能。你可以深入查看每个模块的利用率报告,找出哪些模块使用了最多资源,然后单独检查它们。
降低资源利用率的另一种方法是让不同的输入共享同一个 FPGA 资源。FPGA 经常在多个输入通道上执行相同的操作。与其为每个通道分配专用的 FPGA 资源,不如使用一个单一的实现,让每个通道轮流共享硬件。例如,假设你有 100 个通道,每个通道每秒都需要执行一次余弦运算。你可以让第 1 个通道在前 10 毫秒执行余弦运算,然后让第 2 个通道在接下来的 10 毫秒执行相同的余弦运算,依此类推。通过这种方式,用于执行余弦运算的硬件可以在所有通道之间共享,并且只需要实例化一次,而不是为每个通道实例化 100 次。
这样可以大大降低整体资源利用率,但只有在你有足够时间共享资源的情况下才有效。如果你的时间安排非常紧张,这种方法可能行不通。此外,这确实增加了一些复杂性,因为现在你需要构建一个组件来协商资源共享。我们将共享资源的过程称为仲裁,而执行共享的组件通常被称为仲裁者。仲裁者也可以被构建来共享 FPGA 之外的资源。例如,我们可能有多个模块需要将数据写入 MicroSD 卡。可以设计一个仲裁者,让这些模块共享同一张 MicroSD 卡,并防止两个模块在同一时间尝试写入数据,从而导致数据丢失或损坏。
如果你已经编写了非常高效的代码,但仍然无法将其放入 FPGA,唯一的选择就是去除某些功能。也许在同一块板子上有一个微控制器,可以执行 FPGA 本来应该完成的某些任务。或者,可能你只需要告诉你的团队,代码无法实现。FPGAs 确实有其能容纳的限制。
不可综合代码
在 Verilog 和 VHDL 语言中,许多关键字无法通过综合工具转化为 FPGA 组件;它们是不可综合的。如果在项目代码中包含这些关键字,综合工具将简单地忽略它们。它可能会生成警告或提示,但不会产生错误。工具会继续进行综合过程,忽略最终设计中的不可综合部分——如果你依赖这些不可综合代码的功能,可能会导致问题。
在 Verilog 和 VHDL 中出现不可综合的关键字可能看起来很奇怪,但它们对于仿真和测试平台是有用的。正如我们在第五章中讨论的,测试代码至关重要,而这些语言提供了帮助测试的关键字。实际上,你可以在项目代码中包含不可综合的元素用于仿真目的,并在运行代码通过综合时保留它们,因为工具会忽略这些部分。为了安全起见,你可以明确告诉工具不要尝试综合这些代码部分,通过在其前面加上 synthesis translate_off,在其后面加上 synthesis translate_on。这种技巧适用于 Verilog 和 VHDL。例如,如果你在设计一个 FIFO,你可能希望在仿真代码时断言不向满的 FIFO 写入数据,或者不从空的 FIFO 读取数据。synthesis translate_off 和 synthesis translate_on 指令让你将这些断言直接嵌入到实际的设计代码中,而不必担心维护仿真和综合的独立代码。
非可综合代码常见的几个领域包括跟踪时间、打印文本、处理文件和循环。我们现在来考虑这些问题。
跟踪时间
正如你所知道的,FPGA 本身没有内建的时间测量方式。相反,我们依赖于计数时钟周期。然而,VHDL 和 Verilog 中有些部分是与时间相关的:例如,Verilog 中的 $time 或 VHDL 中的 now 会提供当前的时间戳,而像 Verilog 中的 #100 或 VHDL 中的 wait for 100 ns; 这样的语句会创建一个短暂的延迟。这些特性对于运行仿真非常有用——例如,用来在精确的时间间隔触发输入信号——但它们不能被合成。
打印
在测试过程中,获取反馈的常见方式之一是将文本发送到终端。例如,在 C 和 Python 中,你有像 printf() 和 print() 这样的函数,它们会将文本发送到控制台,让你看到发生了什么。Verilog 和 VHDL 中也有类似的函数。在 Verilog 中,你可以使用 $display() 将文本发送到终端。在 VHDL 中,这有点复杂,提供了几种选择。例如,你可以使用 assert 后跟 report 和 severity note 将文本发送到屏幕,示例如下:
assert false report "Hello World" severity note;
这些文本输出只在仿真中有效。它们无法被合成,因为物理 FPGA 上没有控制台或终端的概念。
与文件的工作
在大多数情况下,你不能合成涉及读取或写入文件的 Verilog 或 VHDL 代码。FPGA 没有“文件”或任何操作系统的概念;如果你真的需要这些功能,你必须自己构建。考虑一下从温度传感器存储数据的情况。你可能希望每秒从传感器读取数据,并将这些值写入文件。这在仿真中是可以做到的,通过 Verilog 中的函数 $fopen() 和 $fwrite(),或者 VHDL 中的 file_open() 和 write(),但是在综合时,忘了它吧。
一个例外是,一些 FPGA 允许你使用文本文件来预加载(初始化)块 RAM。不同厂商实现的具体方式有所不同,如果你需要做这件事,参阅你 FPGA 的内存使用指南。
循环
循环语句是可以合成的,但它们可能不会按你预期的方式工作。你可能熟悉像 C 或 Python 这样的软件语言中的 for 循环:它们允许你编写简洁的代码,重复执行某个操作特定次数,依次进行。在仿真中,Verilog 或 VHDL 的 for 循环就是这样工作的。然而,在可合成的 FPGA 代码中,for 循环的工作方式不同;它们用于压缩重复的逻辑,提供了一种简便的方式来编写几条相似的语句,这些语句是为了同时执行,而不是依次执行。为了演示,考虑这个 4 位移位寄存器的示例代码:
Verilog
always @(posedge i_Clk)
begin
r_Shift[1] <= r_Shift[0];
r_Shift[2] <= r_Shift[1];
r_Shift[3] <= r_Shift[2];
end
VHDL
process (i_Clk)
begin
if rising_edge(i_Clk) then
r_Shift(1) <= r_Shift(0);
r_Shift(2) <= r_Shift(1);
r_Shift(3) <= r_Shift(2);
end if;
end process;
每个时钟周期,这段代码将数据通过 r_Shift 寄存器进行移位。位 0 的值被移到位 1,位 1 的值被移到位 2,依此类推。完成这一操作的赋值语句遵循完全可预测的模式:r_Shift[i] 的值被赋给 r_Shift[i+1]。可合成的 Verilog 和 VHDL for 循环提供了一种更简洁的方式来编写像这样的可预测代码。使用 for 循环,我们可以将移位寄存器代码重写如下:
Verilog
always @(posedge i_Clk)
begin
❶ for(i=0; i<3; i=i+1)
❷ r_Shift[i+1] <= r_Shift[i];
end
VHDL
process (i_Clk)
begin
if rising_edge(i_Clk) then
❶ for i in 0 to 2 loop
❷ r_Shift(i+1) <= r_Shift(i);
end loop;
end if;
end process;
在这里,我们声明了一个增量变量为 for 循环 i ❶。每次迭代时,执行将位 i 的值赋给位 i + 1 ❷ 的语句。例如,在循环的第一次迭代中,i 为 0,所以执行的语句是 r_Shift[0 + 1] <= r_Shift[0]。第二次循环时,i 为 1,所以我们得到 r_Shift[1 + 1] <= r_Shift[1]。在第三次也是最后一次迭代时,我们得到 r_Shift[2 + 1] <= r_Shift[2]。
这里需要意识到的重要一点是,这一切都发生在一个时钟周期内。实际上,所有的循环迭代是同时执行的,就像没有 for 循环的版本中那三条独立的赋值语句会同时执行一样。两种版本做的事情完全一样(并且会合成到完全相同的 FPGA 资源上),只是 for 循环版本写得更简洁。
初学者常犯的一个错误是将 for 循环放在时钟驱动的 always 或 process 块中,并期望每次循环迭代都需要一个时钟周期。例如,考虑以下这段 C 代码:
for (i=0; i<10; i++)
data[i] = data[i] + 1;
这里我们有一个数组,data,我们通过一个for循环将数组中的每个值增加 1。(我们假设data有 10 个元素。)如果您尝试使用 Verilog 或 VHDL 中的for循环,期待它运行 10 个时钟周期,您会非常困惑,因为该循环实际上会在单个时钟周期内执行。如果您确实希望将这样的操作运行多个时钟周期,可以在一个if语句中更新值,该语句检查索引值是否超过某个阈值,像这样:
Verilog
always @(posedge i_Clk)
begin
❶ if (r_Index < 10)
begin
❷ r_Data[r_Index] <= r_Data[r_Index] + 1;
❸ r_Index <= r_Index + 1;
end
end
VHDL
process (i_Clk)
begin
if rising_edge(i_Clk) then
❶ if r_Index < 10 then
❷ r_Data(r_Index) <= r_Data(r_Index) + 1;
❸ r_Index <= r_Index + 1;
end if;
end if;
end process;
在这里,我们使用if语句来复制停止for循环的检查 ❶。在这种情况下,我们希望操作运行 10 次,或者直到r_Index不再小于 10。(我们假设索引值从 0 开始,尽管在代码中没有显示。)接下来,我们使用r_Index访问数组中的正确项,增加r_Data的值 ❷。最后,我们增加r_Index ❸,该值将在下一个时钟周期中用于更新数组中的下一个值。总的来说,这将需要 10 个时钟周期来执行。一般来说,当试图编写像常规for循环那样迭代的代码时,通常只需添加一个计数信号(如r_Index)并使用if语句来监控它,正如您在这里看到的那样。
在您非常确信 FPGA for循环的工作原理之前,我建议在任何可综合代码中避免使用它们。
放置与布线
放置与布线是将合成设计映射到特定 FPGA 上的物理位置的过程。放置与布线工具决定了 FPGA 中将使用哪些查找表(LUT)、触发器、块 RAM(以及我们尚未讨论的其他组件),并将它们全部连接起来。过程结束时,您将获得一个可以加载到 FPGA 上的文件。如您所见,实际上使用此文件对 FPGA 进行编程通常是一个单独的步骤。
布局与布线,顾名思义,实际上是两个过程:将合成后的设计放入 FPGA 中,然后使用物理布线将设计连接起来。布线过程通常是构建过程中最耗时的步骤,特别是对于大型设计。在单台计算机上,布线一个复杂的 FPGA 可能需要几个小时。这也是模拟非常重要的一个主要原因。由于构建过程非常耗时,每天你可能只有几次机会在实际 FPGA 上测试你的设计,因此在开始这个过程之前,通过模拟尽可能解决问题是最好的。*
约束
要运行布局与布线过程,你需要约束设计中的至少两个方面:引脚和时钟(如果你有多个时钟域,稍后我们会讨论)。当然,也可以约束其他元素——输入/输出延迟、特定的布线路径等等,但这两个是最基本的。
引脚约束告诉布局与布线工具,Verilog 或 VHDL 代码中的哪些信号映射到 FPGA 上的哪些物理引脚。当你在处理电路板时,你需要查看 PCB 原理图,以了解哪些 FPGA 引脚连接到开关,哪些引脚连接到 LED,等等。这是一个在 FPGA 设计师中,具备一定原理图阅读知识非常有帮助的例子。
时钟约束告诉工具用于驱动 FPGA 的时钟频率(或者如果你有多个时钟域,时钟频率可能不同,正如我们将在本章稍后讨论的那样)。时钟约束对布线过程至关重要,尤其是因为信号的传输距离和在单个时钟周期内可以处理的内容有物理限制。当布局与布线过程完成时,它将生成一个时序报告,并考虑到时钟约束。如果在指定的时钟约束下,所有内容都能正常工作,那么设计就被认为是符合时序的,报告会显示这一点。但是,如果工具判断时钟约束可能对你的设计来说过于严格,它将在时序报告中显示时序错误。正如你接下来会看到的,时序错误是非常严重的问题!
时序错误
时序错误发生在你的设计和时钟约束要求 FPGA 组件和连线以比规划和布局工具保证它们能够处理的更快的速度工作时。这意味着你的 FPGA 可能无法按照预期工作。我说可能是因为尽管存在时序错误,它也有可能完美运行——事先没有办法确定这一点。这部分是因为 FPGA 的性能受到其工作条件的影响;例如,它的表现可能会因电压和温度的变化而有所不同。听起来可能很奇怪,FPGA 在低温和高温下的表现稍有不同,但这就是现实。
规划和布局工具的任务是对你的设计进行压力测试,并分析它在所有可能的操作条件下如何表现,包括最坏情况。如果设计能够在所有这些条件下以你指定的时钟频率运行,工具可以保证 FPGA 满足时序要求;否则,它将报告时序错误。这些工具不会阻止你用包含时序错误的设计编程 FPGA。也许它们应该阻止,但实际上没有。原因在于无法确定时序错误如何表现出来。工具并不知道你是把设计运行在室温下的桌面上,还是运行在真空空间中的卫星上。在任何一种情况下,设计可能正常工作,也可能失败,或者在运行了五分钟后,才表现出一个小错误。时序错误会导致奇怪的行为。
我曾经参与过一个相机产品的 FPGA 设计,这个设计充满了时序错误,而之前的设计师并没有处理这些问题。相反,他们设计了这个方案,查看了报告,发现报告中有数十个时序错误,但仍然将 FPGA 编程完成。然后,他们在自己的桌面上测试了这个设计,查看它是否能正常工作。他们运行了几分钟,没有遇到问题,于是认为它没问题,并将其集成到产品中。随后,产品开始以奇怪的方式出现故障。像素会闪烁,或者场景会闪烁,但只是偶尔发生,所以用户可能会忽视。更奇怪的是,只有部分产品出现问题,且问题的严重程度在不同单元之间有所不同。
一旦有人意识到问题的严重性,就开始了一个认真的努力,修复时序错误,并制作一个能够 100% 正常运行的 FPGA 设计。FPGA 工具一直在尝试告诉原设计师,可能存在问题。这并不是一个功能性问题——代码在理论上是正确的——但是在给定的时钟约束下,它有可能在所有操作条件下无法正确运行。这个故事的寓意是,当 FPGA 行为奇怪时,很有可能是你没有仔细查看时序报告(或者,像我以前的同事一样,完全忽视了它!)。
从根本上讲,时序错误的产生是因为 FPGA 受限于物理限制。到目前为止,我们一直在一个理想的世界中工作。我们假设所有信号可以立即从源头传输到目标,并且所有触发器在看到上升沿时可以立即改变其输出。我们一直假设如果代码正确,那么一切都会正常工作。
欢迎来到现实世界!在真实世界中,什么事情都不是真正的瞬时发生的,当组件被要求过快工作时,它们的表现会变得不可预测。导致 FPGA 时序错误的三个物理限制因素是设置时间、保持时间和传播延迟。让我们快速看看这些因素,然后我们将探讨如何修复时序错误。
设置时间和保持时间
设置时间是指触发器输入信号在时钟沿到来之前需要保持稳定的时间,以确保触发器能够在该时钟沿上准确地注册输入数据到输出。保持时间是指触发器输入信号在时钟沿之后需要保持稳定的时间,以确保触发器能够可靠地保持当前的输出值,直到下一个时钟沿。这在图 7-1 中有所示意。

图 7-1:设置时间(tsu)和保持时间(th)
我们期望触发器在图中间的上升沿处注册一些数据。上升沿之前的时间是设置时间,标记为tsu;上升沿之后的时间是保持时间,标记为th。如果触发器的数据输入在设置时间和保持时间之外变化,那么一切都能正常工作。然而,如果数据输入在设置时间和保持时间窗口内变化,就会发生不良情况。具体来说,触发器可能变得亚稳,进入一个输出不稳定的状态:它可能是 1,可能是 0,甚至可能处于两者之间。图 7-2 显示了一个亚稳事件的例子。

图 7-2:亚稳态条件
这里我们看到一个时钟信号以及触发器的输入和输出信号。输入信号中标记为tsu 的阴影区域表示触发器的设置时间,即在上升沿之前的时间段。如你所见,触发器的数据输入在设置窗口期间从低电平过渡到高电平。这导致输出在一段时间内处于亚稳态,之后才会稳定为 0 或 1。
为了理解亚稳态,人们常常用一个平衡在山顶的球的类比,如图 7-3 所示。球可能朝左或朝右滚下山,无法预测它会滚向哪个方向。一个随机的阵风可能把它吹向任何方向。如果它滚向左边,那就是状态 0;如果滚向右边,那就是状态 1。当触发器的输出处于亚稳态时,就像是一个摇摇欲坠的球,试图找到一个更稳定的状态来停靠。

图 7-3:亚稳态
除了不知道球会朝哪个方向滚动外,还无法知道球滚下山的时间。它可能快速滚下,也可能需要一段时间。这被称为亚稳态解析时间,即亚稳态变为稳定状态所需的时间。
无法提前知道输出最终会稳定在哪个状态。有时它可能是 0,而其他时候,当这种情况发生时,它可能是 1。如果数据输入没有再次变化,则在下一个上升沿时,输出肯定会是 1,因为触发器会再次将输入值传送到输出。然而,在这一个时钟周期内,输出究竟会是什么是无法预测的,这种行为在 FPGA 中是不期望出现的。
如果你的设计有时序错误,FPGA 工具会告诉你,某些触发器的设置时间和保持时间窗口可能被违反,这可能导致它们进入亚稳态。然而,亚稳态是概率性的,因此无法保证它一定会发生。你的设计可能在报告时序错误的情况下完全正常,但也有可能 FPGA 会表现出奇怪且不可预测的行为。在 FPGA 设计中,我们喜欢预测性,因此即使是亚稳态发生的微小可能性也是一个问题。
亚稳态可能在违反设置时间或保持时间时发生,但设置时间和保持时间是 FPGA 的物理属性,无法控制。你不能以改变设置时间或保持时间的方式修改设计。为了修复时序错误,必须集中精力解决 FPGA 的另一个主要物理限制:传播延迟。
传播延迟
传播延迟是信号从源到目的地传播所需的时间。如前所述,在现实世界中,这不是瞬时的:电压变化在电线中传播需要一些时间,尽管这个时间非常短。一个不错的经验法则是,信号沿着电线传播的速度为每纳秒 1 英尺。听起来可能不算很长延迟,但考虑到 FPGA 内部有成千上万条细小的电线,电线的物理长度加起来可能非常长,考虑到芯片的尺寸如此之小。这会导致显著的传播延迟,尤其是信号从一个触发器传播到另一个触发器时。
此外,信号经过的每一段逻辑——例如,表示与门的查找表(LUT)——都会增加一些传播延迟,因为这些逻辑操作也不是完全瞬时的。这个概念在图 7-4 中有所说明。

图 7-4:两个触发器之间的传播延迟
这里有两个触发器,数据从一个触发器的输出传输到另一个触发器的输入。触发器之间的逻辑和布线可能包括电线和/或 LUT。传播延迟发生在这里,且云中包含的物体越多——例如,更长的电线或更多的 LUT——从触发器 1 的输出到触发器 2 的输入所需的时间就越长。如果传播延迟过长,设计将无法满足请求的时钟约束。
这里的问题是,两个触发器都由同一个时钟驱动。如果触发器 1 在一个上升时钟沿上看到输入变化并将该变化注册到输出,我们期望触发器 2 在下一个上升时钟沿看到该变化并将其注册。信号只有一个时钟周期的时间从触发器 1 传播到触发器 2。如果信号能够在这个时间内安全到达,设计将正常工作。但如果触发器之间的逻辑和布线产生的传播延迟过长,我们就会遇到时序错误。FPGA 设计中可能有成千上万的触发器,放置和布线工具的责任是分析每一条路径,并从时序角度向我们显示最严重的问题。
事实上,信号从触发器 1 传播到触发器 2 所需的时间比一个时钟周期的长度还要短,因为我们还需要考虑建立时间。传播延迟可能小于时钟周期,但正如我们刚才看到的,如果信号在触发器 2 的建立窗口内到达,触发器 2 的输出将是不确定的。这就导致了以下计算设计正常工作的时钟周期所需的公式:
tclk(min) = tsu + tp
在这里,tclk(min) 是设计正常工作且没有时序错误所需的最小时钟周期,tsu 是设置时间,tp 是设计在两个触发器之间可能出现的最坏传播延迟。例如,假设 FPGA 上所有触发器的设置时间固定为 2 ns,且我们的设计在两个特定触发器之间会产生最多 10 ns(在最坏情况下)的传播延迟。我们的公式告诉我们,时钟周期需要至少为 2 + 10 = 12 ns,这相当于 83.3 MHz 的频率。如果我们想,我们完全可以使用更慢的时钟来运行设计,这样周期会更长;但如果我们想让 FPGA 更快运行,例如 100 MHz,则时钟周期会太短,导致时序错误。
如何修复时序错误
如你所见,时钟周期、设置时间和传播延迟是导致时序错误的主要因素。由于设置时间是固定的,解决时序错误有两种基本方法:
-
降低时钟频率。
-
通过将逻辑划分为多个阶段来减少传播延迟。
降低时钟频率可能看起来是最直观的选择。如果你能够让 FPGA 运行得更慢,你的时序会得到改善。然而,你不太可能自由地更改时钟频率;通常它会由于某些特定原因被固定,比如你可能需要与一个必须在特定频率下运行的外设进行接口。很可能你无法仅仅为了放松时序而降低时钟频率。
将逻辑划分为多个阶段,也称为 流水线,是更为稳健(且通常是唯一的)选择。如果你在任何两个触发器之间做得“更少”,传播延迟将会减少,设计也更容易满足时序要求。图 7-5 展示了这一过程如何工作。

图 7-5:通过流水线减少传播延迟
在图的上半部分,两个触发器之间有大量的逻辑——多到设计的传播延迟过长,无法通过时序验证。解决方案(如图下半部分所示)是将逻辑拆分为两个阶段,并在中间增加另一个触发器。这样,部分逻辑可以在触发器 1 和 2 之间完成,另一部分则在触发器 2 和 3 之间完成。每个阶段的传播延迟应足够短,以便每个阶段可以在一个时钟周期内完成,整体上,工具将有两个时钟周期来完成我们最初打算在一个时钟周期内完成的任务。
当你将设计中的单一阶段拆分为多个阶段时,你实际上是在创建一个流水线操作,并在每个阶段之间使用触发器来与时钟同步操作。一个良好的流水线设计将大大提高在高时钟频率下满足时序要求的机会。为了演示,我们来看一个时序表现不佳的代码示例,然后探讨如何对逻辑进行流水线处理以避免时序错误。首先,这里是有问题的代码:
Verilog
module timing_error
(input i_Clk,
input [7:0] i_Data,
output reg [15:0] o_Data);
reg [7:0] r0_Data = 0;
always @(posedge i_Clk)
begin
r0_Data <= i_Data;
❶ o_Data <= ((r0_Data / 3) + 1) * 5;
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity timing_error is
port (
i_Clk : in std_logic;
i_Data : in unsigned(7 downto 0);
o_Data : out unsigned(15 downto 0));
end entity timing_error;
architecture RTL of timing_error is
signal r0_Data : unsigned(7 downto 0);
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
r0_Data <= i_Data;
❶ o_Data <= ((r0_Data / 3) + 1) * 5;
end if;
end process;
end RTL;
我无法想象为什么有人会写出这样的代码,但它足以用作演示。问题出现在我们对r0_Data ❶进行一些数学运算——除法、加法和乘法——时。所有这三种操作都在同一行内、在一个同步的always或process块内执行,这意味着它们必须在一个时钟周期内完成。为了执行这些数学运算,8 位宽的寄存器r0_Data的输出将通过一堆 LUT,然后进入o_Data的触发器输入,所有这些操作都在一个时钟周期内完成。这使得我们进入了图 7-5 的上半部分:这些数学操作需要大量的逻辑,并且会产生相当大的传播延迟。
让我们看看当我们使用 100 MHz 时钟约束将这段代码通过布局与布线时会发生什么。以下是生成的时序报告:
`--snip--`
4.1::Critical Path Report for i_Clk
***********************************
Clock: i_Clk
❶ Frequency: 89.17 MHz | Target: 100.00 MHz
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
❷ Path Begin : r0_Data_fast_5_LC_1_9_5/lcout
❸ Path End : o_DataZ0Z_7_LC_5_12_5/in3
Capture Clock : o_DataZ0Z_7_LC_5_12_5/clk
Setup Constraint : 10000p
❹ Path slack : -1215p
`--snip--`
我们可以看到,我们尝试将时钟频率提升到 100 MHz,但布线工具只能保证时序达到 89.17 MHz ❶。当目标频率高于最大可实现的频率时,就会出现时序错误。时序报告接着告诉我们设计中最严重的路径,虽然表达有些模糊。首先,报告标识了每条问题路径的开始 ❷ 和结束 ❸。注意,r0_Data 出现在 Path Begin 的信号名称中,而 o_Data 出现在 Path End 的信号名称中,但那里还有一些额外的内容。工具加入了这些附加信息,以便识别 FPGA 中相关组件的确切位置。缺点是这些信息不太容易理解,但由于核心信号名称保持不变,我们可以看出,从 r0_Data 到 o_Data 的路径是失败的路径。此外,报告还精确告诉我们路径的失败程度 ❹。路径裕度是指路径有多大的可调整空间来满足时序要求,负值则告诉我们时序过慢;我们需要额外的 1,215 皮秒(ps),即 1.215 纳秒(ns),来消除这个时序错误。这是有道理的,因为 89.17 MHz 和 100 MHz 之间的时钟周期差正好是 1,215 ps。
现在我们已经确定了失败的路径,可以通过使用触发器将数学运算分解,来实现该路径的流水线处理。下面是可能的样子:
Verilog
module timing_error
(input i_Clk,
input [7:0] i_Data,
output reg [15:0] o_Data);
reg [7:0] r0_Data, r1_Data, r2_Data = 0;
always @(posedge i_Clk)
begin
r0_Data <= i_Data;
❶ r1_Data <= r0_Data / 3;
❷ r2_Data <= r1_Data + 1;
❸ o_Data <= r2_Data * 5;
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
entity timing_error is
port (
i_Clk : in std_logic;
i_Data : in unsigned(7 downto 0);
o_Data : out unsigned(15 downto 0));
end entity timing_error;
architecture RTL of timing_error is
signal r0_Data, r1_Data, r2_Data : unsigned(7 downto 0);
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
r0_Data <= i_Data;
❶ r1_Data <= r0_Data / 3;
❷ r2_Data <= r1_Data + 1;
❸ o_Data <= r2_Data * 5;
end if;
end process;
end RTL;
我们把原本一行的代码拆分成了三行。首先,我们只执行除法操作,并将结果写入中间信号 r1_Data ❶。接着,我们对 r1_Data 执行加法操作,并将结果赋值给 r2_Data ❷,最后对 r2_Data 执行乘法操作,并将结果赋值给我们的原始输出 o_Data ❸。我们引入了新的信号,将原本在一个时钟周期内发生的大数学运算分布到多个时钟周期中。这样应该能减少传播延迟。实际上,如果我们将新的流水线设计进行布线,就会得到以下的时序报告:
`--snip--`
4.1::Critical Path Report for i_Clk
***********************************
Clock: i_Clk
Frequency: 110.87 MHz | Target: 100.00 MHz
`--snip--`
现在我们已经满足了时序要求:布图和布线工具可以保证在目标频率为 100 MHz 时,性能达到 110.87 MHz 的时钟频率。如你所见,修复时序错误涉及到一些权衡。我们不得不在设计中添加触发器,将逻辑拆分为多个阶段,因此我们的设计现在比以前使用了更多的 FPGA 资源。此外,原本应该在一个时钟周期内完成的数学运算,现在需要三个时钟周期。然而,请记住,我们的模块仍然能够以 100 MHz 的速率接收新的输入值,并以 100 MHz 的速率输出计算结果,这正是我们在原始设计中的预期;只是第一个从流水线输出的结果由于添加的触发器需要额外的两个时钟周期。
意外的时序错误
你可以通过对设计进行流水线化来修复大多数时序错误,从而减少传播延迟并避免亚稳态条件。然而,布图和布线工具无法预测所有时序错误。这些工具并不完美;它们只能根据所拥有的信息分析你的设计。即使你在时序报告中没有看到任何错误,也有两种情况下亚稳态条件仍然可能发生,而布图和布线工具无法可靠地预测:
-
当采样一个与 FPGA 时钟不同步的信号时
-
当跨时钟域时
当你有一个外部信号作为输入信号传递到 FPGA 时,采样一个与 FPGA 时钟不同步的信号是非常常见的。输入信号会直接传递到设计中触发器的输入端,但它是异步的,也就是说,它没有由主 FPGA 时钟协调。例如,想象一下有人按下按钮。这个按钮的按下可以在任何时刻发生,因此如果它恰好发生在触发器的建立时间内,那么这个触发器将处于亚稳态。布图和布线工具并不了解这个潜在的问题,因此不会将其标记为时序错误。但你,亲爱的 FPGA 设计师,可以预见并解决这个问题。解决方案是通过让输入数据通过一个额外的触发器来实现双触发器,如图 7-6 所示。

图 7-6:通过双触发器修复亚稳态
在这个图中,一个与时钟不同步的信号正在被第一个触发器采样。由于我们无法保证这个输入相对于时钟的时序,它可能会违反设置时间或保持时间,并在第一个触发器的输出处产生亚稳态。正如前面讨论的那样,亚稳态的发生是概率性的,且相当罕见。即使第一个触发器进入了亚稳态,也极不可能第二个触发器也进入亚稳态。事实上,将第二个触发器与第一个串联起来,可以将亚稳态在输出处的可能性降到几乎为零。(如果再加一个第三个触发器,亚稳态的可能性会进一步降低,但 FPGA 专家已经得出结论,两个触发器串联就足够了。)我们现在可以使用设计内部的稳定信号,并确信不会看到奇怪的行为。
另一种可能遇到亚稳态的情况是,当你在 FPGA 中跨越时钟域时。这是一个很大的话题,值得单独成章讨论。
跨越时钟域
正如我之前提到的,单个 FPGA 中可能有多个时钟域,不同的时钟驱动设计的不同部分。例如,你可能需要一个工作在 25.725 MHz 的摄像头接口和一个工作在 148.5 MHz 的 HDMI 接口。如果你想将摄像头的数据发送到 HDMI,以便在显示器上显示,这些数据就必须跨越时钟域,从由 25.725 MHz 时钟控制的部分移动到由 148.5 MHz 时钟控制的部分。然而,无法保证这些时钟域之间的对齐;它们可能会彼此偏移然后再对齐。即使时钟之间看似有可预测的关系,比如 50 MHz 时钟和 100 MHz 时钟,你也不能确定它们是否在同一时刻开始。
注意
例外情况是,如果你使用了一种名为相位锁定环(PLL)的 FPGA 组件,它可以生成独特的时钟频率并在它们之间建立关系。PLL 在第九章中有详细讨论。
最终结论是,当你有彼此不同步的时钟域时,跨越这些时钟域的信号可能会在某些触发器中产生亚稳态。在本节中,我们将探讨如何安全地跨越时钟域,从慢时钟到快时钟,反之亦然,并避免亚稳态。我们还将讨论如何使用 FIFO 在时钟域之间传送大量数据。
从慢时钟到快时钟的跨越
最简单的情况是从较慢时钟域切换到较快时钟域。为避免问题,您只需要在数据进入更快的时钟域时进行双触发器处理,如图 7-7 所示。这与我们修复外部异步信号时采用的方法相同,因为它本质上是相同的问题:来自较慢时钟域的信号与它进入的较快时钟域是异步的。

图 7-7:从较慢时钟域到较快时钟域的跨越
在图 7-7 中,我们有三个串联的触发器。第一个由较慢的时钟驱动,后面的由较快的时钟驱动。较慢的时钟是您的源时钟域,较快的时钟是您的目标时钟域。由于时钟彼此之间是异步的,我们无法保证来自较慢时钟域的数据不会违反中间触发器(即较快时钟域中的第一个触发器)的建立时间或保持时间,从而触发亚稳态。然而,我们知道,第二个触发器的输出将是稳定的,可以在较快的时钟域中使用该数据。让我们看看如何用代码实现这个设计:
Verilog
always @(posedge i_Fast_Clk)
begin
❶ r1_Data <= i_Slow_Data;
❷ r2_Data <= r1_Data;
end
VHDL
process (i_Fast_Clk) is
begin
if rising_edge(i_Fast_Clk) then
❶ r1_Data <= i_Slow_Data;
❷ r2_Data <= r1_Data;
end if;
end process;
该代码由一个always或process块组成,该块在较快时钟的正沿上运行。首先,来自较慢时钟域的信号i_Slow_Data进入触发器r1_Data ❶。如果i_Slow_Data的变化违反了建立时间或保持时间,这个触发器的输出可能会变成亚稳态,但我们通过双触发器处理来解决这个亚稳态问题,将数据传递给第二个触发器r2_Data ❷。此时,我们得到了稳定的数据,可以在较快时钟域中使用,而无需担心亚稳态问题。
关于为使用两个时钟域的 FPGA 编写代码,有一点需要特别注意:一定要小心将两个时钟域的代码分开。将所有较慢的信号放在一个always或process块中,与较快的信号在另一个always或process块中明确分开(跨时钟域的信号是个例外)。实际上,我发现将运行在不同时钟域中的代码放在完全不同的文件中是很有帮助的,这样可以确保我不会将信号混合使用。
从更快的时钟域到较慢的时钟域
从一个更快的时钟域到一个较慢的时钟域要比反过来更复杂,因为在较慢的时钟域看到数据之前,较快的时钟域中的数据可能已经发生变化。例如,考虑一个在 100 MHz 时钟域中发生的脉冲,它持续一个时钟周期,而你试图在一个 25 MHz 时钟域中检测这个脉冲。很有可能你永远也看不到这个脉冲,如图 7-8 所示。

图 7-8:从更快的时钟域到较慢的时钟域的失败示例
该图显示了 25 MHz 时钟的两个周期。在时钟的第一次上升沿之后,但在第二次上升沿之前,100 MHz 脉冲来去匆匆,速度太快,以至于 25 MHz 时钟从未“看到”并注册它。这是因为脉冲并没有出现在 25 MHz 时钟的上升沿。因此,这个脉冲在 25 MHz 时钟域中完全没有被察觉。解决这个问题的方法是将任何从更快时钟域传入较慢时钟域的信号进行拉伸,直到它们足够长,确保它们能被注意到。图 7-9 展示了这个原理的实际运作。

图 7-9:从更快的时钟域到较慢的时钟域的成功示例
查看 100 MHz 脉冲的新波形,我们可以看到它已经从一个 100 MHz 时钟周期拉伸到了多个时钟周期,确保在 25 MHz 时钟域中的上升沿可以看到这个脉冲。一般来说,从一个更快的时钟域传递到一个较慢的时钟域的脉冲应该拉伸,至少持续两个时钟周期。这样,即使脉冲违反了较慢时钟域中第一个时钟周期的建立和保持时间,并触发了一个亚稳态,它也会在第二个时钟周期稳定下来。在我们的例子中,我们应该将 100 MHz 脉冲拉伸至至少八个 100 MHz 时钟周期,相当于两个 25 MHz 时钟周期。如果需要,您可以将信号拉伸得更长。
使用 FIFO
在前面的例子中,我们研究了如何在时钟域之间传输简单的脉冲。但如果你想在两个时钟域之间传输大量数据,比如将相机数据发送到 HDMI 接口呢?在这种情况下,最常见的方法是使用 FIFO。你按照一个时钟将数据写入 FIFO,然后按照另一个时钟将其读取出来。当然,这里最关键的要求是 FIFO 必须支持两个不同的时钟频率,而我们在第六章中看到的 FIFO 只支持一个时钟。
要跨时钟域使用 FIFO,你可能需要使用一个原语,这是由制造商为特定 FPGA 设计的专用 FPGA 组件。例如,Intel 会有预编写的 FIFO 原语,可以在不同的时钟域中运行,但创建这些原语的 Verilog 或 VHDL 代码将与 AMD 的 FIFO 原语不同。(我们将在第九章中探索更多关于原语的示例。)
使用 FIFO 时,始终记住不要违反两个基本规则:不要从空的 FIFO 中读取,也不要写入到满的 FIFO 中。许多 FIFO 原语提供了 FIFO 中单词数(元素数)的输出计数,但我不建议依赖它。相反,我建议多使用在“输入和输出信号”(第 117 页)中介绍的AF和AE(几乎满和几乎空)标志。最好在跨时钟域时,以固定大小的突发方式从 FIFO 读取和写入数据,并利用这个突发大小来确定你的 AF 和 AE 阈值。将你的 AE 水平设置为固定的突发大小,将你的 AF 水平设置为 FIFO 深度减去突发大小。通过这种设置,你可以确保永远不会违反这两个关键规则。AF 标志可以通过确保 FIFO 没有足够的空间容纳另一个突发时,禁用写时钟接口来调节写入速度。同样,AE 标志可以通过确保 FIFO 中没有完整的突发数据时,调节读时钟接口,避免读端尝试从 FIFO 中取出数据。
让我们来看一个例子。假设有一个模块在 33 MHz 的时钟下向 FIFO 写入数据。在读取端,我们尽可能快速地将数据转存到外部存储器中。假设读取时钟频率为 110 MHz。在这种情况下,由于读取时钟比写入时钟快得多,读取端大部分时间都会处于空闲状态,即使写入操作在每个时钟周期内都在进行。为了避免从空的 FIFO 中读取数据,你可以将 AE 标志设置为某个数值,指示读取代码有一批数据准备好被读取。例如,如果你将其设置为 50 个单词,那么一旦 FIFO 中有 50 个单词,AE 标志将从 1 变为 0,这将触发读取端的逻辑,从 FIFO 中精确地读取 50 个单词。
这通常是跨时钟域实现的方式。如果你正在使用你的AE/AF标志,那说明你做得对。尽量不要依赖于那些告诉你 FIFO 是否完全满或空的标志,也绝对不要使用一些 FIFO 支持的计数器。
解决时序错误
在将包含多个时钟域的设计通过布局与布线过程时,你需要在时钟约束中包含每个时钟的频率。布局与布线工具会分析在这些时钟接口之间发送和接收的数据,并报告它所观察到的任何时序错误。正如你所看到的,这是工具告诉你,可能会遇到设置时间和保持时间被违反的情况,这可能会触发亚稳态条件。
假设你已经很好地处理了时钟域跨越,使用了前面章节中讨论的双触发器、数据拉伸和 FIFO 方法。你知道可能会出现亚稳态,并且你已经做好了准备。然而,工具并不知道你所采取的步骤。它无法看到你是一个聪明的 FPGA 设计师,且你已经掌控了一切。为了抑制时序报告中的错误,你需要创建一些独特的时序约束,这些约束将放宽工具的要求,告诉它们你是一个有能力的设计师,并且你知道你的设计可能会出现亚稳态。如何创建这些时序约束超出了本书的范围,因为每个 FPGA 厂商都有其独特的风格。
你应该始终力求设计中没有时序错误。然而,作为 FPGA 设计师,你不可避免地会遇到跨越时钟域的情况。你需要清楚地理解这些情况中可能出现的常见陷阱。如果跨越简单,你可以通过双触发器或者执行数据拉伸来解决。对于许多情况,你可能需要使用支持两个时钟的 FIFO,一个用于读取,一个用于写入。在编写处理时钟域跨越的代码时,要特别小心,不要将来自不同时钟域的信号混合使用。
总结
在本章中,我们详细探讨了 FPGA 构建过程,仔细分析了 FPGA 代码在综合和通过布局与布线过程中发生了什么。你了解了不同类型的不可综合代码,特别是你看到for循环的综合方式可能与你预期的不同。在检查布局与布线过程时,你了解了 FPGA 的一些物理限制,并看到了这些限制如何导致时序错误。最后,你学习了一些修复时序错误的策略,包括在跨越时钟域时可能出现的错误。有了这些知识,你将能够更自信地编写 Verilog 或 VHDL 代码,并能够解决构建过程中常见的问题。
第十三章:8 状态机

状态机 是一种控制一系列动作的模型。在状态机中,一项任务被分解成一系列的阶段,或状态。系统沿着预定的路径在这些状态之间流动,根据输入或其他触发条件从一个状态转换到另一个状态。状态机被广泛用于组织 FPGA 中的操作,因此理解它们的工作原理对于开发复杂的 FPGA 设计至关重要。
状态机的常见示例包括电梯、交通信号灯和自动售货机。这些设备在任何给定时刻只能处于一个独特的状态,并且可以根据输入执行不同的动作。例如,在电梯的情况下,电梯厢会停留在当前楼层,直到有人按下按钮请求乘坐。电梯所在的楼层就是它的状态,而按下按钮就是触发状态变化的输入。交通信号灯的情况也是类似,可能的状态有红色、黄色和绿色,信号灯会根据某种输入变化——可能是定时器或运动传感器。某些状态转换是可能的,例如从红色到绿色,或者从黄色到红色,而其他转换,比如从黄色到绿色,则不可能。
在 FPGA 中,可能有多个状态机执行不同的独立任务,所有任务同时运行。例如,你可能有一个状态机初始化 LPDDR 内存,另一个接收来自外部传感器的数据,第三个用于与外部微控制器通信。由于 FPGA 是并行设备,这些状态机会并行运行,每个状态机协调其自己的复杂操作序列。
本章中,你将学习状态机背后的基本概念,并了解如何使用 Verilog 和 VHDL 设计它们。你将学习保持状态机简洁清晰的策略,从而减少设计中出现 bug 的可能性。最后,你将通过设计一个 Simon 风格的记忆游戏来获得使用状态机的实践经验,适用于你的开发板。
状态、转换与事件
状态机围绕三个相互关联的概念展开:状态、转换和事件。状态描述的是系统在等待执行转换时的状态。以电梯为例,如果从未按下按钮,电梯将保持在当前状态,也就是停留在当前楼层。状态机一次只能处于一个状态(电梯不能同时在两个楼层),而且它只能处于有限数量的状态(我们还没有解决如何建造一个具有无限楼层的建筑的问题)。因此,状态机也被称为有限状态机(FSMs)。
转换是指从一个状态移动到另一个状态的动作。对于电梯来说,这包括开关门、运行电机升降轿厢等。转换通常由事件引起,事件可以是按键或计时器到期等输入。状态之间的转换也可能在没有外部事件的情况下发生,这称为内部转换。相同的事件可能根据当前状态触发不同的转换。例如,如果电梯在十楼,按下 5 号按钮会让电梯向下行驶;如果电梯在一楼,按下 5 号按钮则会让电梯向上行驶。电梯的下一个状态受到当前状态和输入事件的共同影响。
设计状态机需要确定所有可能的状态,规划状态之间的转换,并识别可以触发这些转换的事件。最简单的方法是绘制一个图表。为了演示这一点,让我们探讨一个简单的状态机例子,它控制着一个硬币投币的旋转门,类似于你在地铁站入口处可能会使用的设备。图 8-1 展示了这个状态机的图示。

图 8-1:一个旋转门的状态机
在状态机图中,状态通常用带标签的圆圈表示,转换用圆圈之间的箭头表示,事件则用触发这些转换的文本表示。我们的旋转门状态机有两个可能的状态:锁定和解锁。锁定状态下方的黑点表示锁定是机器的初始状态。当首次通电或用户按下重置按钮时,状态机将进入此状态。
让我们考虑一旦我们处于 Locked 状态时会发生什么。有两个可能的事件可以触发转换:推旋转门或投币。Push 事件导致从 Locked 状态返回到 Locked 状态,这由图中左侧的箭头表示。在这种情况下,状态机保持在 Locked 状态。直到用户投币(Coin 事件)时,我们才会转换到 Unlocked 状态。此时,如果用户推旋转门,它将让他们通过,然后转换回 Locked 状态以供下一个用户使用。最后,请注意,如果用户将硬币投到一个已经是 Unlocked 状态的系统中,它将转换回 Unlocked 状态。
通过这种方式定义地铁旋转门的行为可能显得微不足道,但它是了解状态机如何组织和表示的好方法。对于那些有大量状态、事件和转换的系统,明确地记录状态机对于生成期望的行为至关重要。即使是像计算器这样简单的东西,也可能需要一个出人意料复杂的状态机。
实现状态机
让我们看看如何在 FPGA 中使用 Verilog 或 VHDL 实现我们的基本地铁旋转门状态机。我们将考虑两种常见的方法:第一种方法使用两个 always 或 process 块,而第二种方法只使用一个。理解这两种实现状态机的方法非常重要,因为它们都被广泛使用。然而,正如你将看到的,有理由更倾向于后一种方法。
使用两个 always 或 process 块
使用两个 always 或 process 块是实现状态机的更传统方法。历史上,FPGA 合成工具并不十分出色,它们在尝试合成状态机时可能会出错。两个块的方法就是为了解决这些限制而设计的。一个 always 或 process 块控制同步逻辑,使用寄存器来跟踪当前状态。另一个 always 或 process 块控制组合逻辑;它寻找触发事件并确定下一个状态应该是什么。图 8-2 说明了这种安排。

图 8-2:使用两个 always 或 process 块的状态机框图
请注意,图中的下一个状态逻辑没有时钟作为输入。它基于当前状态和任何输入(事件)立即确定下一个状态。只有当前状态寄存器才有时钟输入,用来注册下一个状态逻辑的输出。通过这种方式,它存储机器的当前状态。
以下是使用这种双块方法实现转闸状态机的方式:
Verilog
module Turnstile_Example
(input i_Reset,
input i_Clk,
input i_Coin,
input i_Push,
output o_Locked);
❶ localparam LOCKED = 1'b0;
localparam UNLOCKED = 1'b1;
reg r_Curr_State, r_Next_State;
// Current state register
❷ always @(posedge i_Clk or posedge i_Reset)
begin
if (i_Reset)
❸ r_Curr_State <= LOCKED;
else
❹ r_Curr_State <= r_Next_State;
end
// Next state determination
❺ always @(r_Curr_State or i_Coin or i_Push)
begin
r_Next_State <= r_Curr_State;
❻ case (r_Curr_State)
LOCKED:
if (i_Coin)
❼ r_Next_State <= UNLOCKED;
UNLOCKED:
if (i_Push)
r_Next_State <= LOCKED;
❽
endcase
end
❾ assign o_Locked = (r_Curr_State == LOCKED);
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Turnstile_Example is
port (
i_Reset : in std_logic;
i_Clk : in std_logic;
i_Coin : in std_logic;
i_Push : in std_logic;
o_Locked : out std_logic);
end entity Turnstile_Example;
architecture RTL of Turnstile_Example is
❶ type t_State is (LOCKED, UNLOCKED);
signal r_Curr_State, r_Next_State : t_State;
begin
-- Current state register
❷ process (i_Clk, i_Reset) is
begin
if i_Reset = '1' then
❸ r_Curr_State <= LOCKED;
elsif rising_edge(i_Clk) then
❹ r_Curr_State <= r_Next_State;
end if;
end process;
-- Next state determination
❺ process (r_Curr_State, i_Coin, i_Push)
begin
r_Next_State <= r_Curr_State;
❻ case r_Curr_State is
when LOCKED =>
if i_Coin = '1' then
❼ r_Next_State <= UNLOCKED;
end if;
when UNLOCKED =>
if i_Push = '1' then
r_Next_State <= LOCKED;
end if;
❽
end case;
end process;
❾ o_Locked <= '1' when r_Curr_State = LOCKED else '0';
end RTL;
我们使用枚举 ❶ 来创建状态,意味着每个状态名称都有一个分配的编号。在 Verilog 中,你需要手动创建状态列表。我喜欢使用 localparam 来定义每个状态,并按递增顺序为它们分配编号。在 VHDL 中,你则创建一个用户定义的类型来表示状态机(t_State)。然后按顺序列出这些状态,VHDL 会自动为它们分配编号。如果你熟悉 C 编程,VHDL 的方法类似于 C 中枚举的工作方式。
注意
SystemVerilog 支持自动枚举,但常规 Verilog 中没有此功能,因此我们在 Verilog 代码中手动编号状态。
第一个 always 或 process 块 ❷,当前状态寄存器,由时钟驱动。它通过在每个时钟上升沿 ❹ 时将 r_Next_State 赋值给 r_Curr_State 来跟踪当前状态。请注意,这个块的敏感列表中还包含 i_Reset 信号,并且该信号在块中会被检查。包括一种方法来将状态机重置到初始状态非常重要,我们使用 i_Reset 来实现。块中的 if…else 语句(Verilog)或 if…elsif 语句(VHDL)会先检查 i_Reset 是否为高电平,再检查时钟是否有上升沿。这意味着我们使用的是 异步复位;复位可以在任何时间发生,而不必在时钟的上升沿发生。当 i_Reset 为高时,我们将当前状态设置为 LOCKED ❸。这与 图 8-1 中的初始状态表示一致。
第二个 always 或 process 块 ❺ 是组合逻辑块。它包含了确定如何设置 r_Next_State 的逻辑。请注意,敏感列表 ❺ 和块本身并不包含时钟,因此该块不会生成任何触发器,只会生成查找表(LUT)。我们通过与当前状态 ❻ 相关联的 case 语句来设置 r_Next_State,并根据输入来确定下一状态。例如,如果当前状态是 LOCKED 且 i_Coin 输入为高电平,那么下一个状态将是 UNLOCKED ❼。将 case 语句和条件逻辑与 图 8-1 中的状态机图进行对比,你会看到我们已经涵盖了图中所有导致状态实际变化的转换和事件。对于那些不会导致信号变化的转换,我们无需编写代码。例如,如果当前状态是 LOCKED 且 i_Push 为高电平,我们将保持在 LOCKED 状态。我们本可以在 LOCKED 情况下添加对 i_Push 的检查,并写出 r_Next_State <= LOCKED 来明确表达这一点,但这是不必要的。添加这一行可以让设计者的意图更加明确,但也会使代码变得臃肿,增加了不必要的赋值。究竟选择哪种风格,取决于你的个人偏好。
我们也可以在 endcase 或 end case 语句 ❽ 之前添加一个 default 情况(在 Verilog 中)或 when others 情况(在 VHDL 中),以覆盖状态机中没有显式列出的所有条件。同样,这不是强制要求的,但这可能是一个好主意;如果你忘记或遗漏了某个情况,默认情况会捕捉到它。在这种情况下,我选择不包括默认情况。事实上,当我尝试在 VHDL 中包括它时,我的代码编辑器会显示一个建议:
Case statement contains all choices explicitly. You can safely remove the
redundant 'others'(13).
这段代码通过赋值模块的单个输出o_Locked❾来结束。当我们处于LOCKED状态时,它会为高电平,否则为低电平。如果这段代码实际上是在控制一个物理的旋转门,我们会使用这个输出的变化来触发状态转换过程中发生的动作,比如启用或禁用旋转门的锁定机制。
使用一个 always 或 process 块
实现状态机的另一种方法是将所有逻辑合并到一个单独的always或process块中。随着综合工具的不断改进,它们在理解你想要创建状态机的场景上变得更加精准。曾经这种单块方法可能难以综合,现在它完全可行(并且可以说更加简单易懂)。以下是使用单个always或process块实现的相同旋转门状态机:
Verilog
module Turnstile_Example
(input i_Reset,
input i_Clk,
input i_Coin,
input i_Push,
output o_Locked);
localparam LOCKED = 1'b0;
localparam UNLOCKED = 1'b1;
reg r_Curr_State;
// Single always block approach
❶ always @(posedge i_Clk or posedge i_Reset)
begin
if (i_Reset)
r_Curr_State <= LOCKED;
else
begin
❷ case (r_Curr_State)
LOCKED:
if (i_Coin)
r_Curr_State <= UNLOCKED;
UNLOCKED:
if (i_Push)
r_Curr_State <= LOCKED;
endcase
end
end
assign o_Locked = (r_Curr_State == LOCKED);
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Turnstile_Example is
port (
i_Reset : in std_logic;
i_Clk : in std_logic;
i_Coin : in std_logic;
i_Push : in std_logic;
o_Locked : out std_logic);
end entity Turnstile_Example;
architecture RTL of Turnstile_Example is
type t_State is (LOCKED, UNLOCKED);
signal r_Curr_State : t_State;
begin
-- Single always block approach
❶ process (i_Clk, i_Reset) is
begin
if (i_Reset) then
r_Curr_State <= LOCKED;
elsif rising_edge(i_Clk) then
❷ case r_Curr_State is
when LOCKED =>
if i_Coin = '1' then
r_Curr_State <= UNLOCKED;
end if;
when UNLOCKED =>
if i_Push = '1' then
r_Curr_State <= LOCKED;
end if;
end case;
end if;
end process;
o_Locked <= '1' when r_Curr_State = LOCKED else '0';
end RTL;
在这两种方法中,一切运行方式相同;它们的区别完全是风格上的,而不是功能上的。在这一版本的状态机中,我们有一个单独的always或process块❶,它对时钟和复位信号敏感。与其需要同时关注r_Curr_State和r_Next_State,现在我们只需要关注r_Curr_State。然而,实际的逻辑并没有改变。我们所做的只是将原本在组合always或process块中执行的工作移动到了顺序块中,因此case语句将在每个时钟上升沿❷时被评估。
我不太喜欢我们刚才看过的第一种方法,涉及两个always或process块。原因有几个。首先,将基于 LUT 的逻辑和基于触发器的逻辑分成两个独立的块可能会让人困惑,特别是对于初学者来说。与单一块的解决方案相比,设计更复杂、不够直观,也更容易出错。其次,正如我在第四章中所说,我倾向于避免使用仅由组合逻辑构成的always或process块。如果不小心,它们可能会生成锁存器,从而导致不期望的行为。我建议将状态机逻辑保持在一个单独的always或process块内。这样代码更容易阅读和理解,而且如今工具足够强大,可以正确构建状态机。
测试设计
让我们为这个状态机生成一个测试平台,以确保我们得到期望的输出:
Verilog
module Turnstile_Example_TB();
❶ reg r_Reset = 1'b1, r_Clk = 1'b0, r_Coin = 1'b0, r_Push = 1'b0;
wire w_Locked;
Turnstile_Example UUT
(.i_Reset(r_Reset),
.i_Clk(r_Clk),
.i_Coin(r_Coin),
.i_Push(r_Push),
.o_Locked(w_Locked));
always #1 r_Clk <= !r_Clk;
initial begin
$dumpfile("dump.vcd");
$dumpvars;
#10;
❷ r_Reset <= 1'b0;
#10;
❸ assert (w_Locked == 1'b1);
❹ r_Coin <= 1'b1;
#10;
assert (w_Locked == 1'b0);
r_Push <= 1'b1;
#10;
assert (w_Locked == 1'b1);
r_Coin <= 1'b0;
#10;
assert (w_Locked == 1'b1);
r_Push <= 1'b0;
#10;
assert (w_Locked == 1'b1);
$finish();
end
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
use std.env.finish;
entity Turnstile_Example_TB is
end entity Turnstile_Example_TB;
architecture test of Turnstile_Example_TB is
❶ signal r_Reset : std_logic := '1';
signal r_Clk, r_Coin, r_Push : std_logic := '0';
signal w_Locked : std_logic;
begin
UUT : entity work.Turnstile_Example
port map (
i_Reset => r_Reset,
i_Clk => r_Clk,
i_Coin => r_Coin,
i_Push => r_Push,
o_Locked => w_Locked);
r_Clk <= not r_Clk after 1 ns;
process is
begin
wait for 10 ns;
❷ r_Reset <= '0';
wait for 10 ns;
❸ assert w_Locked = '1' severity failure;
❹ r_Coin <= '1';
wait for 10 ns;
assert w_Locked = '0' severity failure;
r_Push <= '1';
wait for 10 ns;
assert w_Locked = '1' severity failure;
r_Coin <= '0';
wait for 10 ns;
assert w_Locked = '1' severity failure;
r_Push <= '0';
wait for 10 ns;
assert w_Locked = '1' severity failure;
finish; -- need VHDL-2008
end process;
end test;
这个测试平台驱动所有可能组合的输入,并监控单一输出(w_Locked)来查看它的行为。例如,r_Reset在开始时被初始化为高电平❶,这应该将我们置于<|samp class="SANS_TheSansMonoCd_W5Regular_11">LOCKED状态。然后,在 10ns 后,我们将<|samp class="SANS_TheSansMonoCd_W5Regular_11">r_Reset拉低❷。这应该对状态没有影响,因此我们在 VHDL 和 Verilog 中都使用assert关键字来验证我们是否处于<|samp class="SANS_TheSansMonoCd_W5Regular_11">LOCKED状态(由w_Locked值为1指示)❸。然后,我们继续操作其他输入,并断言预期的输出(例如,将r_Coin拉高❹应该将我们置于<|samp class="SANS_TheSansMonoCd_W5Regular_11">UNLOCKED状态)。我们通过使用assert语句自动提醒我们任何失败,使得这个测试平台成为一个自检的工具。
注意
对于 Verilog 用户,请记住,assert 仅在 SystemVerilog 中存在。请确保告诉模拟器你的测试平台是 SystemVerilog 文件,而不是常规的 Verilog 文件。
状态机最佳实践
在继续之前,我想分享一些关于开发成功状态机的建议。这些是我在编写 FPGA 状态机时发现有帮助的指导原则,它们都以我们在上一节回顾过的转闸示例为模型:
每个文件包含一个状态机。
当然,你可以在一个文件中编写多个状态机,但我强烈建议你将任何给定的 Verilog 或 VHDL 文件的范围限制为一个状态机。当你把两个或更多的状态机放在同一个文件里时,它们很容易逻辑上交织在一起。虽然将其分解为多个文件可能需要更多的输入,但它将在调试阶段节省时间。
使用单块方法。
如我所说,我发现如果只有一个always或process块需要关注,而不是两个,会更容易写出更干净、出错更少的状态机。这种单块方法还避免了需要组合性always或process块的情况,如果不小心,可能会生成锁存器。
为你的状态命名有意义的名称。
阅读一个包含实际单词的case语句要容易得多,前提是你为你的状态命名时考虑周到。例如,使用描述性的名称,如IDLE、START_COUNT、LOCKED、UNLOCKED等,而不是像S0、S1、S2、S3这类通用名称。具有意义的状态名称可以帮助其他阅读代码的人理解发生了什么。此外,当你几个月后重新回到自己的代码时,你会感谢自己为状态命名时的描述性命名。枚举允许你做到这一点。枚举是一种常见的编程技术,它允许你在代码中用单词代替整数。这可以通过 Verilog 中的localparam或 VHDL 中的用户定义类型来实现。
在编码之前绘制状态机流程。
直接跳进编写状态机的代码是灾难的开始。首先绘制你想要实现的状态机图表,就像你在图 8-1 中看到的那样。这将帮助你确保你已经思考了整个流程,从初始状态到所有可能的变换。如果你在开始编写代码后发现自己漏掉了什么,也没关系;只要确保回去更新你的图表,使其与正在编写的代码保持同步。如果你有这些文档,你未来的自己会感谢你的。
遵循这些建议绝非强制性的,但这样做将有助于你避免一些常见的陷阱,并在你的 FPGA 中创建无错、易懂且易于维护的状态机。你的状态机越复杂,这些最佳实践就越有帮助。
项目 #6:创建一个记忆游戏
现在,我们将把你学到的关于状态机的知识付诸实践,创建一个在你的开发板上运行的记忆游戏。玩家需要记住并重现一个随着游戏进展而变得越来越长的模式,类似于 Simon 游戏。如果玩家能记住整个模式,他们就赢了。
这个模式通过四个 LED 显示。它从简单的一个 LED 开始点亮。然后轮到玩家通过按下与 LED 对应的开关来重现模式。如果按错了开关,游戏就结束。如果按对了开关,游戏继续,模式扩展到两个 LED 的闪烁序列。模式将继续扩展,直到达到七次闪烁(虽然你可以调整代码使其更长)。如果玩家正确重现了最后的模式,他们可以选择再来一局,生成新的模式。
这个项目利用了我们之前没有使用过的外设:七段显示器。这个设备使用七个 LED 的排列显示数字 0 到 9(以及一些字母),就像你在数字时钟上看到的那样。它将作为记分板,跟踪玩家在模式中的进度。我们还将使用它来显示 F(表示失败),如果玩家犯错,或者显示 A,当游戏获胜时。
注意
如果你的开发板没有四个 LED 和开关,你可以调整项目的代码以适应可用的资源。如果没有七段显示器,可以尝试将一个连接到你的板子上,例如使用 Pmod 连接器。
规划状态机
为了创建这个游戏,我们需要控制 FPGA 在不同操作状态之间的流转,比如显示模式和等待玩家的回应。听起来这是使用状态机的完美时机!按照我们的最佳实践,我们会在编写任何代码之前,使用图示来规划状态机。图 8-3 展示了一个满足游戏描述的状态机图示。

图 8-3:记忆游戏状态机图示
从左上角开始,我们有一个复位/初始条件,它会让状态机从任何其他状态跳转到开始状态(Start)。为了避免图示杂乱,我没有从每个状态画回到开始状态的箭头;只要记住,当复位条件发生时,您总是可以从任何状态跳回开始状态。我们会保持在开始状态,直到复位被清除,此时我们过渡到模式关闭(Pattern Off)状态。在此状态下,所有 LED 灯会关闭,并保持一段时间,然后我们过渡到模式显示(Pattern Show)状态,在这里我们点亮模式中的一个 LED,同样保持一段时间。如果这是模式中的最后一个 LED(模式已完成),我们则过渡到等待玩家(Wait Player)状态,等待玩家的回应。如果 LED 模式尚未完成,我们则过渡回模式关闭状态。我们在模式显示和模式关闭状态之间不断循环,一次点亮模式中的一个 LED,直到模式完成。过渡回模式关闭状态为每次闪烁之间增加了暂停,避免了在模式中有相同 LED 连续两次出现时的歧义。这部分是游戏的关键,LED 模式正在展示给玩家,以便他们稍后尝试重新创建。
注意
图中的菱形位于模式显示(Pattern Show)和等待玩家(Wait Player)状态之间,表示一个守卫条件,它是一个布尔表达式,用于确定状态机的流转。在这种情况下,守卫条件检查模式是否完成。
一旦进入等待玩家(Wait Player)状态,FPGA 会监控按钮的输入,直到发生以下两种情况之一。如果玩家按错了序列中的按钮,我们就过渡到失败(Loser)状态,并在七段显示器上显示 F。如果玩家成功地重新创建了整个模式,我们就过渡到增加得分(Incr Score)状态。在这里,我们检查游戏是否完成,如果完成,则玩家获胜,我们过渡到胜利(Winner)状态,并在七段显示器上显示 A。如果游戏尚未完成,我们则回到模式关闭状态,准备再次显示模式,并在序列中增加一个额外的 LED 闪烁。
设计状态机的可能性几乎是无穷无尽的,因此图 8-3 所示的排列并不是唯一的选择。例如,我们可以将“Pattern Off”和“Pattern Show”合并为一个状态,用于处理 LED 的开关。但我们的设计在状态数量和每个状态的复杂性之间找到了一个平衡。一般来说,如果一个状态负责多个动作,那么这可能是一个信号,表明该状态应该被拆分成两个或更多状态。
组织设计
接下来,我们将查看项目的整体组织。图 8-4 显示了设计的框图。

图 8-4:项目#6 的框图
让我们追踪数据流通过框图的过程。首先,我们有四个开关(按钮),用于控制整个游戏。记住,这些是机械开关,因此它们会有抖动。为了获得可靠的按钮响应,这些输入必须进行消抖,这是我们对每个开关信号进行的第一步处理,处理时信号进入 FPGA。我们将使用在第五章中实现的消抖滤波模块来处理它们。FPGA 还有一个时钟输入,我们将用它来驱动设计中的所有触发器。
接下来是记忆游戏模块本身,就是状态机所在的地方。我们将很快详细探讨这段代码。请注意,这个模块实例化了两个子模块:Count_And_Toggle和 LFSR 模块,你在第六章中见过它们。记住,LFSR 是伪随机模式生成器,因此我们将在这里使用它来创建游戏的随机模式。我们将使用<сamp class="SANS_TheSansMonoCd_W5Regular_11">Count_And_Toggle模块来跟踪每个 LED 在模式序列中显示的时间;该模块的切换将触发状态之间的转换。
最后,我们有<сamp class="SANS_TheSansMonoCd_W5Regular_11">Binary_To_7Segment模块,它接收一个表示玩家得分的二进制输入,并驱动七段显示器点亮该分数。我们接下来将探讨这个模块的工作原理。
使用七段显示器
七段显示器由七个 LED 组成,可以通过不同的组合点亮这些 LED,从而产生不同的模式。图 8-5 显示了显示器的七个部分,标记为 A 到 G。在这个项目中,我们将使用其中一个显示器来跟踪得分,每当玩家成功重复模式时,得分会递增。

图 8-5:七段显示器
通常,七段显示器用于显示十进制数字 0 到 9,但我们可以通过显示十六进制数字 A 到 F(即 10 到 15)来扩展显示器的显示范围。然而,我们不能简单地告诉显示器点亮某个特定的数字,因为显示器中的每一段都是单独控制的。因此,我们的Binary _To_7Segment模块接收要显示的数字,并将其转换为驱动显示器的适当信号。让我们看看代码:
Verilog
module Binary_To_7Segment
(input i_Clk,
❶ input [3:0] i_Binary_Num,
output o_Segment_A,
output o_Segment_B,
output o_Segment_C,
output o_Segment_D,
output o_Segment_E,
output o_Segment_F,
output o_Segment_G);
reg [6:0] r_Hex_Encoding;
always @(posedge i_Clk)
begin
❷ case (i_Binary_Num)
4'b0000 : r_Hex_Encoding <= 7'b1111110; // 0x7E
4'b0001 : r_Hex_Encoding <= 7'b0110000; // 0x30
4'b0010 : r_Hex_Encoding <= 7'b1101101; // 0x6D
4'b0011 : r_Hex_Encoding <= 7'b1111001; // 0x79
4'b0100 : r_Hex_Encoding <= 7'b0110011; // 0x33
4'b0101 : r_Hex_Encoding <= 7'b1011011; // 0x5B
4'b0110 : r_Hex_Encoding <= 7'b1011111; // 0x5F
❸ 4'b0111 : r_Hex_Encoding <= 7'b1110000; // 0x70
4'b1000 : r_Hex_Encoding <= 7'b1111111; // 0x7F
4'b1001 : r_Hex_Encoding <= 7'b1111011; // 0x7B
4'b1010 : r_Hex_Encoding <= 7'b1110111; // 0x77
4'b1011 : r_Hex_Encoding <= 7'b0011111; // 0x1F
4'b1100 : r_Hex_Encoding <= 7'b1001110; // 0x4E
4'b1101 : r_Hex_Encoding <= 7'b0111101; // 0x3D
4'b1110 : r_Hex_Encoding <= 7'b1001111; // 0x4F
4'b1111 : r_Hex_Encoding <= 7'b1000111; // 0x47
default : r_Hex_Encoding <= 7'b0000000; // 0x00
endcase
end
❹ assign o_Segment_A = r_Hex_Encoding[6];
assign o_Segment_B = r_Hex_Encoding[5];
assign o_Segment_C = r_Hex_Encoding[4];
assign o_Segment_D = r_Hex_Encoding[3];
assign o_Segment_E = r_Hex_Encoding[2];
assign o_Segment_F = r_Hex_Encoding[1];
assign o_Segment_G = r_Hex_Encoding[0];
endmodule
VHDL
library ieee;
use ieee.std_logic_1164.all;
entity Binary_To_7Segment is
port (
i_Clk : in std_logic;
❶ i_Binary_Num : in std_logic_vector(3 downto 0);
o_Segment_A : out std_logic;
o_Segment_B : out std_logic;
o_Segment_C : out std_logic;
o_Segment_D : out std_logic;
o_Segment_E : out std_logic;
o_Segment_F : out std_logic;
o_Segment_G : out std_logic
);
end entity Binary_To_7Segment;
architecture RTL of Binary_To_7Segment is
signal r_Hex_Encoding : std_logic_vector(6 downto 0);
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
❷ case i_Binary_Num is
when "0000" =>
r_Hex_Encoding <= "1111110"; -- 0x7E
when "0001" =>
r_Hex_Encoding <= "0110000"; -- 0x30
when "0010" =>
r_Hex_Encoding <= "1101101"; -- 0x6D
when "0011" =>
r_Hex_Encoding <= "1111001"; -- 0x79
when "0100" =>
r_Hex_Encoding <= "0110011"; -- 0x33
when "0101" =>
r_Hex_Encoding <= "1011011"; -- 0x5B
when "0110" =>
r_Hex_Encoding <= "1011111"; -- 0x5F
❸ when "0111" =>
r_Hex_Encoding <= "1110000"; -- 0x70
when "1000" =>
r_Hex_Encoding <= "1111111"; -- 0x7F
when "1001" =>
r_Hex_Encoding <= "1111011"; -- 0x7B
when "1010" =>
r_Hex_Encoding <= "1110111"; -- 0x77
when "1011" =>
r_Hex_Encoding <= "0011111"; -- 0x1F
when "1100" =>
r_Hex_Encoding <= "1001110"; -- 0x4E
when "1101" =>
r_Hex_Encoding <= "0111101"; -- 0x3D
when "1110" =>
r_Hex_Encoding <= "1001111"; -- 0x4F
when "1111" =>
r_Hex_Encoding <= "1000111"; -- 0x47
when others =>
r_Hex_Encoding <= "0000000"; -- 0x00
end case;
end if;
end process;
❹ o_Segment_A <= r_Hex_Encoding(6);
o_Segment_B <= r_Hex_Encoding(5);
o_Segment_C <= r_Hex_Encoding(4);
o_Segment_D <= r_Hex_Encoding(3);
o_Segment_E <= r_Hex_Encoding(2);
o_Segment_F <= r_Hex_Encoding(1);
o_Segment_G <= r_Hex_Encoding(0);
end architecture RTL;
该模块接收一个 4 位二进制输入❶,并使用七个输出根据输入点亮显示屏中相应的段。case语句❷捕捉所有可能的输入,从0000到1111(0 到 15),并通过 7 位的r_Hex_Encoding寄存器将每个数字转换为正确的输出模式。寄存器中的每一位都映射到显示器中的一个段:位 6 映射到段 A,位 5 映射到段 B,依此类推。为了理解这一点,我们可以考虑一个特定的输入——比如0111,即数字 7——作为例子。图 8-6 展示了如何点亮七段显示器以显示这个数字。

图 8-6:在七段显示器上点亮数字 7
如图所示,我们需要点亮段 A、B 和 C,同时保持其他段关闭,以显示数字 7。在❸处的代码中,我们将r_Hex_Encoding设置为0x70,即二进制的1110000,将 1 放在对应段 A、B 和 C 的三个位上。然后,在case语句外,我们从寄存器中提取每一位,并通过连续赋值❹将其传递到相应的输出。这种将模式编码到r_Hex_Encoding寄存器中的方法节省了大量的代码;我们不需要在每个case语句的每个分支中分配所有七个输出。
编码顶层模块
接下来,让我们进入项目的顶层模块,看看一切是如何在最高层级连接的。如果你回顾一下 图 8-4 中的框图,你会看到这个模块由带虚线的方框表示:
Verilog
module State_Machine_Project_Top
(input i_Clk,
// Input switches for entering pattern
input i_Switch_1,
input i_Switch_2,
input i_Switch_3,
input i_Switch_4,
// Output LEDs for displaying pattern
output o_LED_1,
output o_LED_2,
output o_LED_3,
output o_LED_4,
// Scoreboard, 7-segment display
output o_Segment2_A,
output o_Segment2_B,
output o_Segment2_C,
output o_Segment2_D,
output o_Segment2_E,
output o_Segment2_F,
output o_Segment2_G);
❶ localparam GAME_LIMIT = 7; // Increase to make game harder
localparam CLKS_PER_SEC = 25000000; // 25 MHz clock
localparam DEBOUNCE_LIMIT = 250000; // 10 ms debounce filter
wire w_Switch_1, w_Switch_2, w_Switch_3, w_Switch_4;
wire w_Segment2_A, w_Segment2_B, w_Segment2_C, w_Segment2_D;
wire w_Segment2_E, w_Segment2_F, w_Segment2_G;
wire [3:0] w_Score;
// Debounce all switch inputs to remove mechanical glitches
❷ Debounce_Filter #(.DEBOUNCE_LIMIT(DEBOUNCE_LIMIT)) Debounce_SW1
(.i_Clk(i_Clk),
.i_Bouncy(i_Switch_1),
.o_Debounced(w_Switch_1)); Debounce_Filter #(.DEBOUNCE_LIMIT(DEBOUNCE_LIMIT)) Debounce_SW2
(.i_Clk(i_Clk),
.i_Bouncy(i_Switch_2),
.o_Debounced(w_Switch_2));
Debounce_Filter #(.DEBOUNCE_LIMIT(DEBOUNCE_LIMIT)) Debounce_SW3
(.i_Clk(i_Clk),
.i_Bouncy(i_Switch_3),
.o_Debounced(w_Switch_3));
Debounce_Filter #(.DEBOUNCE_LIMIT(DEBOUNCE_LIMIT)) Debounce_SW4
(.i_Clk(i_Clk),
.i_Bouncy(i_Switch_4),
.o_Debounced(w_Switch_4));
❸ State_Machine_Game #(.CLKS_PER_SEC(CLKS_PER_SEC),
.GAME_LIMIT(GAME_LIMIT)) Game_Inst
(.i_Clk(i_Clk),
.i_Switch_1(w_Switch_1),
.i_Switch_2(w_Switch_2),
.i_Switch_3(w_Switch_3),
.i_Switch_4(w_Switch_4),
.o_Score(w_Score),
.o_LED_1(o_LED_1),
.o_LED_2(o_LED_2),
.o_LED_3(o_LED_3),
.o_LED_4(o_LED_4));
❹ Binary_To_7Segment Scoreboard
(.i_Clk(i_Clk),
.i_Binary_Num(w_Score),
.o_Segment_A(w_Segment2_A),
.o_Segment_B(w_Segment2_B),
.o_Segment_C(w_Segment2_C),
.o_Segment_D(w_Segment2_D),
.o_Segment_E(w_Segment2_E),
.o_Segment_F(w_Segment2_F),
.o_Segment_G(w_Segment2_G));
❺ assign o_Segment2_A = !w_Segment2_A;
assign o_Segment2_B = !w_Segment2_B;
assign o_Segment2_C = !w_Segment2_C;
assign o_Segment2_D = !w_Segment2_D;
assign o_Segment2_E = !w_Segment2_E;
assign o_Segment2_F = !w_Segment2_F;
assign o_Segment2_G = !w_Segment2_G;
endmodule
VHDL
library IEEE;
use IEEE.std_logic_1164.all;
entity State_Machine_Project_Top is port (
i_Clk : in std_logic;
-- Input switches for entering pattern
i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
i_Switch_3 : in std_logic;
i_Switch_4 : in std_logic;
-- Output LEDs for displaying pattern
o_LED_1 : out std_logic;
o_LED_2 : out std_logic;
o_LED_3 : out std_logic;
o_LED_4 : out std_logic;
-- Scoreboard, 7-segment display
o_Segment2_A : out std_logic;
o_Segment2_B : out std_logic;
o_Segment2_C : out std_logic;
o_Segment2_D : out std_logic;
o_Segment2_E : out std_logic;
o_Segment2_F : out std_logic;
o_Segment2_G : out std_logic);
end entity State_Machine_Project_Top;
architecture RTL of State_Machine_Project_Top is
❶ constant GAME_LIMIT : integer := 7; -- Increase to make game harder
constant CLKS_PER_SEC : integer := 25000000; -- 25 MHz clock
constant DEBOUNCE_LIMIT : integer := 250000; -- 10 ms debounce filter
signal w_Switch_1, w_Switch_2, w_Switch_3, w_Switch_4 : std_logic;
signal w_Score : std_logic_vector(3 downto 0);
signal w_Segment2_A, w_Segment2_B, w_Segment2_C, w_Segment2_D : std_logic;
signal w_Segment2_E, w_Segment2_F, w_Segment2_G : std_logic;
begin
❷ Debounce_SW1 : entity work.Debounce_Filter
generic map (
DEBOUNCE_LIMIT => DEBOUNCE_LIMIT)
port map (
i_Clk => i_Clk,
i_Bouncy => i_Switch_1,
o_Debounced => w_Switch_1);
Debounce_SW2 : entity work.Debounce_Filter
generic map (
DEBOUNCE_LIMIT => DEBOUNCE_LIMIT)
port map (
i_Clk => i_Clk,
i_Bouncy => i_Switch_2,
o_Debounced => w_Switch_2);
Debounce_SW3 : entity work.Debounce_Filter
generic map (
DEBOUNCE_LIMIT => DEBOUNCE_LIMIT) port map (
i_Clk => i_Clk,
i_Bouncy => i_Switch_3,
o_Debounced => w_Switch_3);
Debounce_SW4 : entity work.Debounce_Filter
generic map (
DEBOUNCE_LIMIT => DEBOUNCE_LIMIT)
port map (
i_Clk => i_Clk,
i_Bouncy => i_Switch_4,
o_Debounced => w_Switch_4);
❸ Game_Inst : entity work.State_Machine_Game
generic map (
CLKS_PER_SEC => CLKS_PER_SEC,
GAME_LIMIT => GAME_LIMIT)
port map (
i_Clk => i_Clk,
i_Switch_1 => w_Switch_1,
i_Switch_2 => w_Switch_2,
i_Switch_3 => w_Switch_3,
i_Switch_4 => w_Switch_4,
o_Score => w_Score,
o_LED_1 => o_LED_1,
o_LED_2 => o_LED_2,
o_LED_3 => o_LED_3,
o_LED_4 => o_LED_4);
❹ Scoreboard : entity work.Binary_To_7Segment
port map (
i_Clk => i_Clk,
i_Binary_Num => w_Score,
o_Segment_A => w_Segment2_A,
o_Segment_B => w_Segment2_B,
o_Segment_C => w_Segment2_C,
o_Segment_D => w_Segment2_D,
o_Segment_E => w_Segment2_E,
o_Segment_F => w_Segment2_F,
o_Segment_G => w_Segment2_G);
❺ o_Segment2_A <= not w_Segment2_A;
o_Segment2_B <= not w_Segment2_B;
o_Segment2_C <= not w_Segment2_C;
o_Segment2_D <= not w_Segment2_D;
o_Segment2_E <= not w_Segment2_E;
o_Segment2_F <= not w_Segment2_F;
o_Segment2_G <= not w_Segment2_G;
end RTL;
我编写设计顶层模块的目标,尤其是当设计变得越来越复杂时,是尽量减少其中的功能代码量。理想情况下,执行功能的代码应该被推送到更低的层级,这样顶层就只需要包含信号线和模块实例化。这样有助于保持代码的整洁,并确保每个模块专注于执行其需要完成的任务,而不是将功能分散到多个层次。
对于这个项目,我们首先实例化四个去抖动滤波模块,每个按钮一个 ❷。然后我们实例化了 State_Machine_Game 模块,它包含了状态机和游戏本身的逻辑 ❸。这个模块的输入,w_Switch_1 到 w_Switch_4,是去抖动滤波器的输出,因此该模块可以信任输入信号是稳定的。请注意,该模块有两个参数(Verilog)或泛型(VHDL),CLKS_PER_SEC 和 GAME_LIMIT,这两个参数在之前已设置 ❶。前者指定每秒钟的时钟周期数(用于跟踪时间),并且它的存在是为了适应设计在不同时钟频率下运行。后者控制模式的最大长度。
接下来,我们实例化了 Binary_To_7Segment 模块 ❹,它将游戏中的 w_Score 输出作为输入,以便将分数显示给玩家。不过需要注意的是,我们在将显示模块的所有输出传递到顶层之前,都会先对其进行反转 ❺。由于七段显示器的连接方式可能不同,输出端可能需要低电平而不是高电平来点亮每个段。如果你的显示器行为不符合预期,尝试去掉 Verilog 中的 ! 或 VHDL 中的 not,避免反转输出。
我们的顶层模块并没有直接实例化 LFSR 或 Count_And_Toggle 模块:这些模块是在 State_Machine_Game 模块中实例化的。你开始能看到 FPGA 内部如何建立层次结构,以及如何从相对简单的模块构建起复杂的设计。
状态机编码
现在让我们进入项目的核心部分:状态机本身。我们将分部分地检查 State_Machine_Game 模块,但请记住,你可以在书籍的 GitHub 仓库中查看完整的代码列表(https://
Verilog
module State_Machine_Game # (parameter CLKS_PER_SEC = 25000000,
parameter GAME_LIMIT = 6)
(input i_Clk,
input i_Switch_1,
input i_Switch_2,
input i_Switch_3,
input i_Switch_4,
output reg [3:0] o_Score,
output o_LED_1,
output o_LED_2,
output o_LED_3,
output o_LED_4
); ❶ localparam START = 3'd0;
localparam PATTERN_OFF = 3'd1;
localparam PATTERN_SHOW = 3'd2;
localparam WAIT_PLAYER = 3'd3;
localparam INCR_SCORE = 3'd4;
localparam LOSER = 3'd5;
localparam WINNER = 3'd6;
❷ reg [2:0] r_SM_Main;
reg r_Toggle, r_Switch_1, r_Switch_2, r_Switch_3;
reg r_Switch_4, r_Button_DV;
❸ reg [1:0] r_Pattern[0:10]; // 2D array: 2 bits wide x 11 deep
wire [21:0] w_LFSR_Data;
reg [$clog2(GAME_LIMIT)-1:0] r_Index; // Display index
reg [1:0] r_Button_ID;
wire w_Count_En, w_Toggle;
`--snip--`
VHDL
library IEEE;
use IEEE.std_logic_1164.all;
use IEEE.numeric_std.all;
entity State_Machine_Game is
generic (
CLKS_PER_SEC : integer := 25000000;
GAME_LIMIT : integer := 6);
port(
i_Clk : in std_logic;
i_Switch_1 : in std_logic;
i_Switch_2 : in std_logic;
i_Switch_3 : in std_logic;
i_Switch_4 : in std_logic;
o_Score : out std_logic_vector(3 downto 0);
o_LED_1 : out std_logic;
o_LED_2 : out std_logic;
o_LED_3 : out std_logic;
o_LED_4 : out std_logic);
end entity State_Machine_Game;
architecture RTL of State_Machine_Game is
❶ type t_SM_Main is (START, PATTERN_OFF, PATTERN_SHOW,
WAIT_PLAYER, INCR_SCORE, LOSER, WINNER);
❷ signal r_SM_Main : t_SM_Main;
signal w_Count_En, w_Toggle, r_Toggle, r_Switch_1 : std_logic;
signal r_Switch_2, r_Switch_3, r_Switch_4, r_Button_DV : std_logic;
type t_Pattern is array (0 to 10) of std_logic_vector(1 downto 0);
❸ signal r_Pattern : t_Pattern; -- 2D Array: 2-bit wide x 11 deep signal w_LFSR_Data : std_logic_vector(21 downto 0);
signal r_Index : integer range 0 to GAME_LIMIT;
signal w_Index_SLV : std_logic_vector(7 downto 0);
signal r_Button_ID : std_logic_vector(1 downto 0);
signal r_Score : unsigned(3 downto 0);
`--snip--`
我们使用章节前面描述的枚举方法来命名每个状态 ❶。r_SM_Main 信号 ❷ 将跟踪当前状态。它需要足够的位数来表示所有可能的状态。在这种情况下,我们总共有七个状态,可以适配一个 3 位宽的寄存器。在 Verilog 中,我们明确声明该信号为 3 位。而在 VHDL 中,我们只是创建一个自定义的状态机信号 t_SM_Main 数据类型(我们之前创建的枚举 ❶),它会自动调整大小。
我们正在创建的另一个重要信号是 r_Pattern,它存储游戏模式 ❸。这是我们在 FPGA 中创建的第二个二维信号(第一次是在第六章时,我们创建了 RAM)。具体来说,r_Pattern 宽度为 2 位,深度为 11 项,总共存储 22 位。该信号中的每一对位对应四个 LED 之一(00 表示 LED1,01 表示 LED2,以此类推),从而为我们提供一个 LED 序列来点亮(以及一个按键序列)。表 8-1 显示了这个 2D 寄存器中数据的示例。
表 8-1: 模式存储示例
| 索引 | 二进制 | LED/开关 |
|---|---|---|
| 0 | 01 | 2 |
| 1 | 11 | 4 |
| 2 | 11 | 4 |
| 3 | 00 | 1 |
| 4 | 10 | 3 |
| 5 | 00 | 1 |
| 6 | 01 | 2 |
| 7 | 01 | 2 |
| 8 | 11 | 4 |
| 9 | 00 | 1 |
| 10 | 10 | 3 |
在这个例子中,索引为 0 的值是 01,它与第二个 LED/开关相关联,索引为 1 的值是 11,它与第四个 LED/开关相关联,依此类推。我们可以使用索引逐步遍历寄存器,每个索引获取 2 位数据。二进制模式本身来自 LFSR,每次都会是随机的。LFSR 的宽度是 22 位,因此 LFSR 输出的每一位都映射到该 2D 寄存器中的一位。这意味着我们可以创建的最大内存模式长度是 11 个 LED 闪烁。但在多次玩这个游戏之后,我可以告诉你,记住这么高的模式会变得非常有挑战性。正如我之前提到的,游戏的实际限制由参数/泛型 GAME_LIMIT 设置,可以从顶层模块覆盖。如果你想将游戏设置为最大难度,可以尝试将 GAME_LIMIT 改为 11。
模块继续处理复位条件:
Verilog
`--snip--`
always @(posedge i_Clk)
begin
// Reset game from any state
❶ if (i_Switch_1 & i_Switch_2)
r_SM_Main <= START;
else
begin
// Main state machine switch statement
❷ case (r_SM_Main)
`--snip--`
VHDL
`--snip--`
begin
process (i_Clk) is
begin
if rising_edge(i_Clk) then
-- Reset game from any state
❶ if i_Switch_1 = '1' and i_Switch_2 = '1' then
r_SM_Main <= START;
else
-- Main state machine switch statement
❷ case r_SM_Main is
`--snip--`
玩家必须同时按下开关 1 和开关 2 才能触发 START 状态。我们通过 if 语句 ❶ 来检查这一点。请注意,这个检查发生在状态机的主要 case 语句之外,我们在 else 分支中启动它 ❷。这意味着在每个时钟周期,我们都会检查两个开关是否都被按下,如果是的话,就进入 START 状态,否则运行游戏的状态机。如果我们有一个专门用于重置状态机的第五个按钮,那就容易多了,但遗憾的是,我没有,所以我不得不在这里稍微动点脑筋。
现在让我们看看 case 语句中的前几个状态:
Verilog
`--snip--`
// Main state machine switch statement
case (r_SM_Main) // Stay in START state until user releases buttons
❶ START:
begin
// Wait for reset condition to go away
❷ if (!i_Switch_1 & !i_Switch_2 & r_Button_DV)
begin
o_Score <= 0;
r_Index <= 0;
r_SM_Main <= PATTERN_OFF;
end
end
❸ PATTERN_OFF:
begin
if (!w_Toggle & r_Toggle) // Falling edge found
r_SM_Main <= PATTERN_SHOW;
end
// Show the next LED in the pattern
❹ PATTERN_SHOW:
begin
if (!w_Toggle & r_Toggle) // Falling edge found
❺ if (o_Score == r_Index)
begin
❻ r_Index <= 0;
r_SM_Main <= WAIT_PLAYER;
end
else
begin
❼ r_Index <= r_Index + 1;
r_SM_Main <= PATTERN_OFF;
end
end
`--snip--`
VHDL
`--snip--`
-- Main state machine switch statement
case r_SM_Main is
-- Stay in START state until user releases buttons
❶ when START =>
-- Wait for reset condition to go away
❷ if (i_Switch_1 = '0' and i_Switch_2 = '0' and
r_Button_DV = '1') then
r_Score <= to_unsigned(0, r_Score'length);
r_Index <= 0;
r_SM_Main <= PATTERN_OFF;
end if;
❸ when PATTERN_OFF =>
if w_Toggle = '0' and r_Toggle = '1' then -- Falling edge found r_SM_Main <= PATTERN_SHOW;
end if;
-- Show the next LED in the pattern
❹ when PATTERN_SHOW =>
if w_Toggle = '0' and r_Toggle = '1' then -- Falling edge found
❺ if r_Score = r_Index then
❻ r_Index <= 0;
r_SM_Main <= WAIT_PLAYER;
else
❼ r_Index <= r_Index + 1;
r_SM_Main <= PATTERN_OFF;
end if;
end if;
`--snip--`
首先我们处理 START 状态 ❶,在这个状态中,我们等待复位条件被解除。这个条件在释放开关 1 和开关 2 时发生 ❷。请注意,我们不仅仅是检测两个开关的低电平,还要检测 r_Button_DV 的高电平。我们在整个模块中使用这个信号来检测下降沿——也就是开关的释放——你将在代码中看到它是如何工作的。当复位被清除时,我们将得分和模式索引设置为 0,然后进入 PATTERN_OFF 状态。
PATTERN_OFF 状态 ❸ 仅仅等待由 Count_And _Toggle 模块驱动的计时器到期。当这种情况发生时,我们过渡到 PATTERN _SHOW 状态 ❹,在此期间我们将点亮其中一个 LED(稍后你将看到点亮 LED 的代码)。从 PATTERN_SHOW 状态的过渡也是由 Count_And_Toggle 模块中的计时器触发的。当计时器到期时,我们需要决定是否完成了模式的显示,方法是检查玩家的得分(o_Score)是否等于当前模式索引(r_Index) ❺。如果不相等,说明我们没有完成,于是我们增加 r_Index,准备点亮下一个 LED ❼,并回到 PATTERN_OFF 状态。如果完成了,我们将 r_Index 重置为 0 ❻ 并过渡到 WAIT_PLAYER 状态。现在我们来看一下:
Verilog
`--snip--`
WAIT_PLAYER:
begin
❶ if (r_Button_DV)
❷ if (r_Pattern[r_Index] == r_Button_ID && r_Index == o_Score)
begin
r_Index <= 0;
r_SM_Main <= INCR_SCORE;
end
❹ else if (r_Pattern[r_Index] != r_Button_ID)
r_SM_Main <= LOSER;
❺ else
r_Index <= r_Index + 1;
end
`--snip--`
VHDL
`--snip--`
when WAIT_PLAYER =>
❶ if r_Button_DV = '1' then
❷ if (r_Pattern(r_Index) = r_Button_ID and
❸ unsigned(w_Index_SLV) = r_Score) then
r_Index <= 0;
r_SM_Main <= INCR_SCORE;
❹ elsif r_Pattern(r_Index) /= r_Button_ID then
r_SM_Main <= LOSER;
❺ else
r_Index <= r_Index + 1;
end if;
end if;
`--snip--`
在 WAIT_PLAYER 状态下,我们等待 r_Button_DV 变高,表示玩家已经按下并释放了一个开关 ❶。然后我们检查玩家是否正确按下了下一个模式中的开关。正如你稍后将看到的,每次有开关被释放时,r_Button_ID 会被设置为指示是哪一个开关 (00 代表开关 1,01 代表开关 2,依此类推),因此我们将 r_Button_ID 与 r_Pattern 中的值进行比较,使用 r_Index 作为二维数组的索引。这里有三种可能性。如果开关是正确的并且我们已经到达模式的末尾 ❷,我们会重置 r_Index 并转换到 INCR_SCORE 状态。如果开关是错误的,我们会转换到 LOSER 状态 ❹。否则,开关是正确的,但我们还没有完成模式,所以我们会增加 r_Index 并等待下一个按下 ❺。注意,在这种情况下,我们没有显式地分配状态,因此 r_SM_Main 会保留其之前的分配(WAIT_PLAYER)。我们可以在 else 语句的末尾添加一行代码:r_SM_Main <= WAIT_PLAYER;,但这并不是必须的。如果 r_SM_Main 没有被分配值,那么我们知道该路径不会导致状态变化。
Verilog 和 VHDL 之间的一个区别是,在后者中我们需要非常明确地指定我们要比较的类型。在 VHDL 中,我们需要将 w_Index_SLV 强制转换为 unsigned 类型 ❸,这样我们才能将其与同样是 unsigned 类型的 r_Score 进行比较。Verilog 则宽容得多,因此我们不需要这个额外的转换。我们将在 第十章 中详细讨论数值数据类型。
现在让我们看一下 case 语句中的其余状态:
Verilog
`--snip--`
// Used to increment score counter
❶ INCR_SCORE:
begin
o_Score <= o_Score + 1;
if (o_Score == GAME_LIMIT-1)
r_SM_Main <= WINNER;
else
r_SM_Main <= PATTERN_OFF;
end
// Display 0xA on 7-segment display, wait for new game
❷ WINNER:
begin
o_Score <= 4'hA; // Winner!
end
// Display 0xF on 7-segment display, wait for new game
❸ LOSER:
begin
o_Score <= 4'hF; // Loser!
end
❹ default:
r_SM_Main <= START;
endcase
end
end
`--snip--`
VHDL
`--snip--`
-- Used to increment score counter
❶ when INCR_SCORE =>
r_Score <= r_Score + 1;
if r_Score = GAME_LIMIT then
r_SM_Main <= WINNER;
else
r_SM_Main <= PATTERN_OFF;
end if;
-- Display 0xA on 7-segment display, wait for new game
❷ when WINNER =>
r_Score <= X"A"; -- Winner!
-- Display 0xF on 7-segment display, wait for new game
❸ when LOSER =>
r_Score <= X"F"; -- Loser!
❹ when others =>
r_SM_Main <= START;
end case;
end if;
end if;
end process;
`--snip--`
在INCR_SCORE ❶ 中,我们递增得分变量并与GAME_LIMIT进行比较,检查游戏是否结束。如果是,我们进入WINNER状态;如果不是,我们返回到PATTERN_OFF状态继续记忆序列。请注意,我们将在此状态中停留一个时钟周期。你或许可以认为INCR_SCORE不是一个必要的状态,且这段逻辑应当在WAIT_PLAYER中执行。我选择将INCR_SCORE作为一个独立的状态,以避免使WAIT_PLAYER变得过于复杂。
对于WINNER ❷ 和 LOSER ❸ 状态,我们简单地设置得分值,在七段显示器上显示 A 或 F,并保持当前状态。状态机只有在重置条件下才会离开这些状态,重置条件是同时按下开关 1 和开关 2。
我们还在case语句 ❹ 的末尾包括了一个默认子句,用于指定当r_SM_Main不是之前定义的状态时应该采取什么行为。虽然这种情况不应该发生,但创建一个默认的情况是好的做法,在这里我们会返回到START状态。这个清单末尾的end语句结束了case语句、if…else语句以及包含状态机的always或process块。
我们现在已经完成了状态机本身的编码。模块中的其余代码处理帮助完成在不同状态下发生的任务的逻辑。首先是生成模式的代码:
Verilog
`--snip--`
// Register in the LFSR to r_Pattern when game starts
// Each 2 bits of LFSR is one value for r_Pattern 2D array
always @(posedge i_Clk)
begin
❶ if (r_SM_Main == START)
begin
r_Pattern[0] <= w_LFSR_Data[1:0];
r_Pattern[1] <= w_LFSR_Data[3:2];
r_Pattern[2] <= w_LFSR_Data[5:4];
r_Pattern[3] <= w_LFSR_Data[7:6];
r_Pattern[4] <= w_LFSR_Data[9:8];
r_Pattern[5] <= w_LFSR_Data[11:10];
r_Pattern[6] <= w_LFSR_Data[13:12];
r_Pattern[7] <= w_LFSR_Data[15:14];
r_Pattern[8] <= w_LFSR_Data[17:16];
r_Pattern[9] <= w_LFSR_Data[19:18];
r_Pattern[10] <= w_LFSR_Data[21:20];
end
end
`--snip--`
VHDL
`--snip--`
-- Register in the LFSR to r_Pattern when game starts
-- Each 2 bits of LFSR is one value for r_Pattern 2D array
process (i_Clk) is
begin
if rising_edge(i_Clk) then
❶ if r_SM_Main = START then
r_Pattern(0) <= w_LFSR_Data(1 downto 0);
r_Pattern(1) <= w_LFSR_Data(3 downto 2);
r_Pattern(2) <= w_LFSR_Data(5 downto 4);
r_Pattern(3) <= w_LFSR_Data(7 downto 6);
r_Pattern(4) <= w_LFSR_Data(9 downto 8);
r_Pattern(5) <= w_LFSR_Data(11 downto 10);
r_Pattern(6) <= w_LFSR_Data(13 downto 12);
r_Pattern(7) <= w_LFSR_Data(15 downto 14);
r_Pattern(8) <= w_LFSR_Data(17 downto 16); r_Pattern(9) <= w_LFSR_Data(19 downto 18);
r_Pattern(10) <= w_LFSR_Data(21 downto 20);
end if;
end if;
end process;
❷ w_Index_SLV <= std_logic_vector(to_unsigned(r_Index, w_Index_SLV'length));
`--snip--`
我们需要在每次游戏开始时生成不同的模式,同时确保模式一旦开始游戏就会“锁定”。为此,我们首先检查是否处于START状态 ❶。如果是,则说明游戏当前没有进行,因此我们使用 LFSR 生成新模式。回想一下,我们的 LFSR 输出是一个伪随机的位串,每个时钟周期都会变化。我们从 LFSR 的输出中提取 2 位的片段,并将它们放入r_Pattern的 11 个槽位中。每个时钟周期都会继续进行,直到玩家释放开关 1 和开关 2,从而触发从START状态的转换。此时,r_Pattern的当前值将被锁定,直到游戏结束。
在 VHDL 中,我们还需要创建一个中间信号w_Index_SLV ❷,它只是r_Index的std_logic_vector表示。再次强调,由于 VHDL 是强类型的,你经常会看到使用中间信号来生成“正确”的信号类型。我本可以把这行代码放在任何地方,因为它是一个组合赋值;只要它不在process块内,它在文件中的位置对功能没有任何影响。
接下来是用于点亮四个 LED 的代码:
Verilog
`--snip--`
assign o_LED_1 = (r_SM_Main == PATTERN_SHOW &&
r_Pattern[r_Index] == 2'b00) ? 1'b1 : i_Switch_1;
assign o_LED_2 = (r_SM_Main == PATTERN_SHOW &&
r_Pattern[r_Index] == 2'b01) ? 1'b1 : i_Switch_2;
assign o_LED_3 = (r_SM_Main == PATTERN_SHOW &&
r_Pattern[r_Index] == 2'b10) ? 1'b1 : i_Switch_3;
assign o_LED_4 = (r_SM_Main == PATTERN_SHOW &&
r_Pattern[r_Index] == 2'b11) ? 1'b1 : i_Switch_4;
`--snip--`
VHDL
`--snip--`
o_LED_1 <= '1' when (r_SM_Main = PATTERN_SHOW and
r_Pattern(r_Index) = "00") else i_Switch_1;
o_LED_2 <= '1' when (r_SM_Main = PATTERN_SHOW and
r_Pattern(r_Index) = "01") else i_Switch_2;
o_LED_3 <= '1' when (r_SM_Main = PATTERN_SHOW and
r_Pattern(r_Index) = "10") else i_Switch_3;
o_LED_4 <= '1' when (r_SM_Main = PATTERN_SHOW and
r_Pattern(r_Index) = "11") else i_Switch_4;
`--snip--`
这里有四个连续赋值语句,每个 LED 一个。在每个语句中,我们使用三元运算符(?)在 Verilog 中,或者在 VHDL 中使用
代码的下一部分使用下降沿检测来识别超时和按钮按下事件:
Verilog
`--snip--`
// Create registers to enable falling edge detection
always @(posedge i_Clk)
begin
❶ r_Toggle <= w_Toggle;
❷ r_Switch_1 <= i_Switch_1;
r_Switch_2 <= i_Switch_2;
r_Switch_3 <= i_Switch_3;
r_Switch_4 <= i_Switch_4;
❸ if (r_Switch_1 & !i_Switch_1)
begin
r_Button_DV <= 1'b1;
r_Button_ID <= 0;
end
else if (r_Switch_2 & !i_Switch_2)
begin
r_Button_DV <= 1'b1;
r_Button_ID <= 1;
end
else if (r_Switch_3 & !i_Switch_3)
begin
r_Button_DV <= 1'b1;
r_Button_ID <= 2;
end
else if (r_Switch_4 & !i_Switch_4)
begin
r_Button_DV <= 1'b1;
r_Button_ID <= 3;
end
❹ else
begin
r_Button_DV <= 1'b0;
r_Button_ID <= 0;
end
end
`--snip--`
VHDL
`--snip--`
-- Create registers to enable falling edge detection
process (i_Clk) is
begin if rising_edge(i_Clk) then
❶ r_Toggle <= w_Toggle;
❷ r_Switch_1 <= i_Switch_1;
r_Switch_2 <= i_Switch_2;
r_Switch_3 <= i_Switch_3;
r_Switch_4 <= i_Switch_4;
❸ if r_Switch_1 = '1' and i_Switch_1 = '0' then
r_Button_DV <= '1';
r_Button_ID <= "00";
elsif r_Switch_2 = '1' and i_Switch_2 = '0' then
r_Button_DV <= '1';
r_Button_ID <= "01";
elsif r_Switch_3 = '1' and i_Switch_3 = '0' then
r_Button_DV <= '1';
r_Button_ID <= "10";
elsif r_Switch_4 = '1' and i_Switch_4 = '0' then
r_Button_DV <= '1';
r_Button_ID <= "11";
❹ else
r_Button_DV <= '0';
r_Button_ID <= "00";
end if;
end if;
end process;
`--snip--`
请注意,我们仍然使用时钟的上升沿;我们只是为了超时和按钮按下事件寻找下降沿。回想一下,下降沿用于在状态机中推进。我们在Count_And_Toggle模块的输出上执行下降沿检测,该输出表示定时器过期。我们首先注册其输出w_Toggle,并将其分配给r_Toggle ❶。(Count_And_Toggle模块的实际实例化将在稍后处理。)这会创建一个w_Toggle在r_Toggle上的一个时钟周期延迟版本。然后,如前所示,我们寻找当前值(w_Toggle)为低,而前一个值(r_Toggle)为高的情况。我们之前用这个来触发从PATTERN_OFF和PATTERN_SHOW状态的过渡。
对于我们的开关,当开关按下时,其值为 1;当开关未按下时,其值为 0。我们正在寻找开关从 1 到 0 的情况,即开关的下降沿,表示开关被释放。我们注册每个开关❷以检测开关释放时的下降沿。接下来是实际的边缘检测逻辑❸。对于每个开关,当我们看到下降沿时,我们将r_Button_DV设置为高。如你在其他代码中看到的,这个信号作为标志,指示某个开关(任意开关)已被释放。我们还将r_Button_ID设置为开关的 2 位二进制代码,这样我们就知道是哪个开关被释放。else语句❹清除r_Button_DV和r_Button_ID,为下一个下降沿做好准备。
注意
我选择让状态机响应按钮释放事件,而不是按钮按下事件。你可以尝试反转测试用例 ❸ 来查看区别。我认为如果游戏在按钮按下的瞬间做出响应,而不是在按钮释放的瞬间响应,你可能会觉得有点不自然。
代码的最后一部分实例化了Count_And_Toggle和LFSR_22模块。记住,你之前在第六章中已经见过这些模块的代码:
Verilog
`--snip--`
// w_Count_En is high when state machine is in
// PATTERN_SHOW state or PATTERN_OFF state, else low
❶ assign w_Count_En = (r_SM_Main == PATTERN_SHOW ||
r_SM_Main == PATTERN_OFF);
❷ Count_And_Toggle #(.COUNT_LIMIT(CLKS_PER_SEC/4)) Count_Inst
(.i_Clk(i_Clk),
.i_Enable(w_Count_En),
.o_Toggle(w_Toggle));
// Generates 22-bit-wide random data
❸ LFSR_22 LFSR_Inst
(.i_Clk(i_Clk),
.o_LFSR_Data(w_LFSR_Data),
❹ .o_LFSR_Done()); // leave unconnected
endmodule
VHDL
`--snip--`
-- w_Count_En is high when state machine is in
-- PATTERN_SHOW state or PATTERN_OFF state, else low
❶ w_Count_En <= '1' when (r_SM_Main = PATTERN_SHOW or
r_SM_Main = PATTERN_OFF) else '0';
❷ Count_Inst : entity work.Count_And_Toggle
generic map (
COUNT_LIMIT => CLKS_PER_SEC/4)
port map (
i_Clk => i_Clk,
i_Enable => w_Count_En,
o_Toggle => w_Toggle);
-- Generates 22-bit-wide random data
❸ LFSR_Inst : entity work.LFSR_22
port map (
i_Clk => i_Clk,
o_LFSR_Data => w_LFSR_Data,
❹ o_LFSR_Done => open); -- leave unconnected
❺ o_Score <= std_logic_vector(r_Score);
end RTL;
首先,我们实例化Count_And_Toggle模块❷。正如你在第六章中看到的,它通过在每个时钟周期递增寄存器,直到达到COUNT_LIMIT参数/泛型,从而计量出一定的时间。在这里,我们将COUNT_LIMIT设置为CLKS_PER_SEC/4,使得每个PATTERN_OFF和PATTERN_SHOW状态持续四分之一秒,但你可以自由修改这个值,使游戏运行得更快或更慢。请记住,CLKS_PER_SEC/4是一个常量(在此情况下为 25,000,000 / 4 = 6,250,000),综合工具会提前计算出这个值,因此除法操作(通常会消耗大量资源)不必在 FPGA 内部执行。持续分配w_Count_En❶仅在PATTERN_OFF和PATTERN_SHOW状态下启用计数器,因为我们不希望它在游戏的其他阶段运行。
接下来,我们实例化LFSR_22模块❸。回顾第六章,这个模块有两个输出:o_LFSR_Data用于数据本身,o_LFSR_Done用于指示每次 LFSR 周期的重复。对于这个项目,我们不需要< samp class="SANS_TheSansMonoCd_W5Regular_11">o_LFSR_Done,因此我们在 Verilog 中将未使用的输出保持未连接,或者在 VHDL 中使用open关键字❹。当我们编写像这样的通用模块时,并不总是需要每个应用中的每个输出。当我们不使用某个输出时,综合工具足够智能,能够去除相关的逻辑,因此即使我们有未使用的代码,也不会影响我们的资源利用率。
最后,在 VHDL 中我们需要执行另一个操作:将r_Score(无符号类型)转换为std_logic_vector,以便我们可以将该值赋给o_Score❺。由于 VHDL 是强类型的,因此在查看 VHDL 代码时,你会经常看到这种类型转换。
测试记忆游戏
我们在这里看到的代码和状态机图表示的是游戏的最终版本,但在我开发过程中,它经历了一些改进和修复。很多更改是我实际玩游戏并通过实验来了解我喜欢和不喜欢的部分的结果。例如,当我最初设计状态机时,我直接从START跳到PATTERN_SHOW,没有经过PATTERN_OFF。这导致第一个 LED 立即亮起,令人困惑,很难判断游戏是否已经开始。因此,我调整了顺序,在开始时加入了一个延迟。
我做的大多数更改都遵循了这个相同的模式:编程板子,玩游戏,发现不喜欢的行为,修改代码,继续玩游戏。另一个例子是最初 LED 的点亮时间太长了,因此我将其缩短,以使游戏节奏更快。这类问题更多是关乎“感觉”,而运行仿真是无法发现这些问题的。
仿真和测试平台对于理解错误发生的原因及如何修复它们非常有价值。我的大多数“问题”并不是错误,而是基于我玩游戏的经验,我希望改变的行为。不过,我确实创建了一个测试平台,允许我模拟按键按下,以观察State_Machine_Game模块的响应。如果你有兴趣查看,这段代码可以在本书的 GitHub 仓库中找到。这是一个简单的测试平台,并没有执行任何自检,但它在我最初编写状态机时,确实帮助我找到了几个 bug。
添加引脚约束
由于我们在最高层添加了一个新接口(七段显示器),因此需要将这些信号添加到我们的物理约束文件中。如果我们忘记这一步,放置和布线工具可能会自动为我们选择引脚,而这些引脚几乎肯定是错误的。你需要参考开发板的原理图,追踪七段显示器到 FPGA 的信号路径。以下是 Go 板所需的约束示例:
set_io o_Segment2_A 100
set_io o_Segment2_B 99
set_io o_Segment2_C 97
set_io o_Segment2_D 95
set_io o_Segment2_E 94
set_io o_Segment2_F 8
set_io o_Segment2_G 96
请参阅第二章以回顾如何将物理约束添加到你的 iCEcube2 项目中。
构建和编程 FPGA
现在,我们准备为 FPGA 构建代码。让我们看一下 Verilog 和 VHDL 的综合结果。你的报告应该类似于以下内容:
Verilog
`--snip--`
Register bits not including I/Os: 164 (12%)
`--snip--`
Total LUTs: 239 (18%)
VHDL
`--snip--`
Register bits not including I/Os: 163 (12%)
`--snip--`
Total LUTs: 225 (17%)
Verilog 和 VHDL 版本的结果非常接近;我们为这个项目使用了大约 12%的可用触发器和 18%的可用查找表(LUT)。我们有一个包含几百行代码的完整记忆游戏,而且我们只用了 FPGA 主资源的不到 20%。不错!
编程你的开发板并玩游戏。看看你能不能赢,如果能,试着通过将GAME_LIMIT增加到最大难度 11 来提高挑战性。我发现这很有挑战性!
总结
在本章中,你学习了状态机,它是许多编程学科中的关键构建块,包括 FPGA。状态机用于精确控制一系列操作的流动。这些操作被组织成一个状态网络,事件触发状态之间的转换。在回顾一个简单的例子后,你设计并实现了一个复杂的状态机来控制开发板上的记忆游戏。该项目结合了我们在书中讨论的许多元素,包括去抖动逻辑来清理来自开关的输入,一个用于伪随机数生成的 LFSR,以及一个用于跟踪时间的计数器。你还学会了使用七段显示器来创建游戏的得分板。
第十四章:9 有用的 FPGA 原语

到目前为止,您已经学习了两个最基本的 FPGA 组件:LUT 和触发器。这些通用组件是 FPGA 中的主要工作单元,但也有其他专用组件,常用于 FPGA 设计中执行更为专业的任务。这些组件通常被称为原语,但有时也称为硬 IP 或 核心。
使用原语可以帮助您最大限度地发挥 FPGA 的性能。事实上,许多现代 FPGA 开发都围绕着将这些预先存在的原语链接在一起,必要时添加自定义代码以实现特定应用的逻辑。在本章中,我们将探讨三个重要的原语:块 RAM(BRAM)、数字信号处理器(DSP)块和锁相环(PLL)。您将了解它们在 FPGA 中的作用,并学习如何通过 Verilog 或 VHDL 代码创建它们,或者借助构建工具的帮助来创建它们。
我们将讨论的原语在高端 FPGA 上尤其重要,这些 FPGA 比我们之前关注的 iCE40 FPGA 更加先进。在这些功能丰富的 FPGA 中,配套的 GUI 软件已经成为构建过程中的关键部分。这些 GUI 比我们一直使用的 iCEcube2 工具更加复杂,其复杂性的一大部分来自于这些原语的创建和连接。一旦您理解了原语的工作原理,您将更有准备开始使用这些更先进的工具,并充分利用专业级 FPGA 的常见内置功能。
如何创建原语
创建 FPGA 原语有几种不同的方法。到目前为止,我们一直在编写 Verilog 或 VHDL 代码,并让综合工具为我们决定如何将这些代码转化为原语。我们信任这些工具能理解我们何时需要创建触发器或查找表(LUT)。这被称为推断,因为我们让工具根据我们的代码推断(或做出合理的猜测)我们想要什么。
通常,工具能够很好地理解我们的意图。然而,某些原语是综合工具无法推断的。要创建这些原语,您需要使用另一种方法:您可以在代码中显式实例化原语,或者使用大多数综合工具内置的 GUI 来自动化创建过程。
实例化
实例化是通过 FPGA 厂商编写的代码模板来创建一个原始组件。当你实例化一个原始组件时,看起来就像在实例化一个 Verilog 或 VHDL 模块——但在这种情况下,你实例化的模块并不是你自己创建的。相反,它是为你特定 FPGA 构建的工具的一部分。这些原始组件背后的实际模块代码通常无法查看;它是 FPGA 厂商喜欢保密的“秘方”之一。
让我们看一个如何实例化块 RAM 的示例(我们将在本章稍后讨论这些原始组件):
Verilog
RAMB18E1 #(
// Address Collision Mode: "PERFORMANCE" or "DELAYED_WRITE"
.RDADDR_COLLISION_HWCONFIG("DELAYED_WRITE"),
// Collision check: Values ("ALL", "WARNING_ONLY", "GENERATE_X_ONLY" or "NONE")
.SIM_COLLISION_CHECK("ALL"),
// DOA_REG, DOB_REG: Optional output register (0 or 1)
.DOA_REG(0),
.DOB_REG(0),
// INITP_00 to INITP_07: Initial contents of parity memory array
.INITP_00(256'h0000000000000000000000000000000000000000000000000000000000000000),
`--snip--` .INIT_3F(256'h0000000000000000000000000000000000000000000000000000000000000000),
// INIT_A, INIT_B: Initial values on output ports
.INIT_A(18'h00000),
.INIT_B(18'h00000),
// Initialization File: RAM initialization file
.INIT_FILE("NONE"),
// RAM Mode: "SDP" or "TDP"
.RAM_MODE("TDP"),
// READ_WIDTH_A/B, WRITE_WIDTH_A/B: Read/write width per port
.READ_WIDTH_A(0), // 0-72
.READ_WIDTH_B(0), // 0-18
.WRITE_WIDTH_A(0), // 0-18
.WRITE_WIDTH_B(0), // 0-72
// RSTREG_PRIORITY_A, RSTREG_PRIORITY_B: Reset or enable priority ("RSTREG" or "REGCE")
.RSTREG_PRIORITY_A("RSTREG"),
.RSTREG_PRIORITY_B("RSTREG"),
// SRVAL_A, SRVAL_B: Set/reset value for output
.SRVAL_A(18'h00000),
.SRVAL_B(18'h00000),
// Simulation Device: Must be set to "7SERIES" for simulation behavior
.SIM_DEVICE("7SERIES"),
// WriteMode: Value on output upon a write ("WRITE_FIRST", "READ_FIRST", or "NO_CHANGE")
.WRITE_MODE_A("WRITE_FIRST"),
.WRITE_MODE_B("WRITE_FIRST")
)
RAMB18E1_inst (
// Port A Data: 16-bit (each) output: Port A data
.DOADO(DOADO), ❶ // 16-bit output: A port data/LSB data
.DOPADOP(DOPADOP), // 2-bit output: A port parity/LSB parity
// Port B Data: 16-bit (each) output: Port B data
.DOBDO(DOBDO), // 16-bit output: B port data/MSB data
.DOPBDOP(DOPBDOP), // 2-bit output: B port parity/MSB parity
// Port A Address/Control Signals: 14-bit (each) input: Port A address and control signals
// (read port when RAM_MODE="SDP")
.ADDRARDADDR(ADDRARDADDR), // 14-bit input: A port address/Read address
.CLKARDCLK(CLKARDCLK), // 1-bit input: A port clock/Read clock
`--snip--`
VHDL
RAMB18E1_inst : RAMB18E1
generic map (
-- Address Collision Mode: "PERFORMANCE" or "DELAYED_WRITE"
RDADDR_COLLISION_HWCONFIG => "DELAYED_WRITE",
-- Collision check: Values ("ALL", "WARNING_ONLY", "GENERATE_X_ONLY" or "NONE")
SIM_COLLISION_CHECK => "ALL",
-- DOA_REG, DOB_REG: Optional output register (0 or 1)
DOA_REG => 0,
DOB_REG => 0,
-- INITP_00 to INITP_07: Initial contents of parity memory array
INITP_00 => X"0000000000000000000000000000000000000000000000000000000000000000",
`--snip--`
INIT_3F => X"0000000000000000000000000000000000000000000000000000000000000000", -- INIT_A, INIT_B: Initial values on output ports
INIT_A => X"00000",
INIT_B => X"00000",
-- Initialization File: RAM initialization file
INIT_FILE => "NONE",
-- RAM Mode: "SDP" or "TDP"
RAM_MODE => "TDP",
-- READ_WIDTH_A/B, WRITE_WIDTH_A/B: Read/write width per port
READ_WIDTH_A => 0, -- 0-72
READ_WIDTH_B => 0, -- 0-18
WRITE_WIDTH_A => 0, -- 0-18
WRITE_WIDTH_B => 0, -- 0-72
-- RSTREG_PRIORITY_A, RSTREG_PRIORITY_B: Reset or enable priority ("RSTREG" or "REGCE")
RSTREG_PRIORITY_A => "RSTREG",
RSTREG_PRIORITY_B => "RSTREG",
-- SRVAL_A, SRVAL_B: Set/reset value for output
SRVAL_A => X"00000",
SRVAL_B => X"00000",
-- Simulation Device: Must be set to "7SERIES" for simulation behavior
SIM_DEVICE => "7SERIES",
-- WriteMode: Value on output upon a write ("WRITE_FIRST", "READ_FIRST", or "NO_CHANGE")
WRITE_MODE_A => "WRITE_FIRST",
WRITE_MODE_B => "WRITE_FIRST"
)
port map (
-- Port A Data: 16-bit (each) output: Port A data
DOADO => DOADO, ❶ -- 16-bit output: A port data/LSB data
DOPADOP => DOPADOP, -- 2-bit output: A port parity/LSB parity
-- Port B Data: 16-bit (each) output: Port B data
DOBDO => DOBDO, -- 16-bit output: B port data/MSB data
DOPBDOP => DOPBDOP, -- 2-bit output: B port parity/MSB parity
-- Port A Address/Control Signals: 14-bit (each) input: Port A address and control signals
-- (read port when RAM_MODE="SDP")
ADDRARDADDR => ADDRARDADDR, -- 14-bit input: A port address/Read address
CLKARDCLK => CLKARDCLK, -- 1-bit input: A port clock/Read clock
`--snip--`
这段代码是一个实例化 AMD FPGA 中的 RAMB18E1 组件(块 RAM 一种类型)的示例。该代码通过将块 RAM 内部信号连接到块 RAM 外部的信号,使块 RAM 可供使用:例如,它将块 RAM 的内部 DOADO 信号(16 位输出)连接到同名的外部信号 ❶。我省略了许多其他行的代码,这些代码完成类似的连接。理解这些代码的细节并不重要;这只是用来展示实例化是什么样子的。显然,块 RAM 是一个复杂的组件,具有许多功能可供你使用。实例化指定了原始组件的每个输入和输出,并允许你根据需要精确地设置它们。然而,它也要求你对正在实例化的原始组件有深入的了解。如果连接不当,它就无法按预期工作。
如果你愿意,实际上是可以实例化,而不是推断,甚至是像触发器这样的简单组件。以下是 AMD 的 Verilog 模板,用于实例化一个单一的触发器(AMD 称之为 FDSE):
Verilog
FDSE #(
.INIT(1'b0) // Initial value of register (1'b0 or 1'b1)
) FDSE_inst (
.Q(Q), // 1-bit data output
.C(C), // 1-bit clock input
.CE(CE), // 1-bit clock enable input
.S(S), // 1-bit synchronous set input
.D(D) // 1-bit data input
);
VHDL
FDSE_inst : FDSE
generic map (
INIT => '0') -- Initial value of register ('0' or '1')
port map (
Q => Q, -- Data output
C => C, -- Clock input
CE => CE, -- Clock enable input
S => S, -- Synchronous set input
D => D -- Data input
);
请注意,这个原始组件具有我们期望从触发器中看到的正常连接,包括数据输出(Q)、时钟输入(C)、时钟使能(CE)和数据输入(D)。在实例化这个触发器之后,你就可以在代码中使用这些连接了。然而,如果你必须实例化整个 FPGA 中的每一个触发器,那将需要写非常多的代码!
注意
我在 AMD 的在线库指南中找到了 RAM18E1 块 RAM 和 FDSE 触发器的模板,该指南包含了 AMD FPGA 所有原始组件的模板。每个 FPGA 厂商都有类似的资源,你可以在其中找到它的原始组件的实例化模板。
实例化原语的好处在于它能精确地给你你想要的功能。你不需要依赖综合工具来猜测你想要做什么。然而,显然也有一些缺点。正如你刚才看到的,实例化比推断需要更多的代码。它还要求你正确连接每个连接点,否则设计将无法按预期运行。这意味着你需要深入理解原语。最后,每个原语都需要使用特定于你的 FPGA 供应商的专用模板来实例化,有时甚至仅针对 FPGA 系列中的某些设备。例如,我们之前实例化的 RAMB18E1 块 RAM 组件只存在于 AMD FPGA 中;Intel 和 Lattice FPGA 有自己的块 RAM。因此,实例化使得你的代码不如编写更通用的 Verilog 或 VHDL 便于移植,在这些语言中,工具可以根据你所针对的 FPGA 来推断原语。接下来,我们将看看另一种选择:使用 GUI。
图形用户界面方法
每个 FPGA 供应商都有自己的 GUI 或 IDE 用于 FPGA 开发,且该 GUI 将有一个部分让你查看可用原语库。你可以选择一个要添加到项目中的原语,工具会引导你完成整个过程。此外,GUI 还会解释原语是如何工作的以及每个设置控制的内容。图 9-1 展示了使用 Lattice Diamond GUI 创建块 RAM 的示例。如第二章中提到的,这就是 Lattice 的 IDE,用于处理具有本章讨论的原语等功能的高端 FPGA。(iCEcube2 IDE 没有用于创建原语的 GUI,因为它主要用于处理简单的 FPGA。)

图 9-1:使用 GUI 实例化块 RAM
窗口左侧的框图直观地展示了块 RAM 的输入和输出。在右侧的配置部分,可以清楚地看到哪些原语的选择是互斥的。这些通过单选按钮表示,例如初始化为全 0 或初始化为全 1。我们还可以看出哪些选项可以启用或禁用。这些通过复选框表示,例如启用输出寄存器或启用输出时钟使能。此外,右下角还有一个方便的帮助按钮,如果你不确定该选择什么,它可以帮助你做出决策。
一旦你使用 GUI 配置了原语,你将获得一个实例化模板,可以将其放入你的 Verilog 或 VHDL 代码中,就像我们在上一节中看到的那样。该模板将根据你在 GUI 中选择的具体设置进行定制,这样你就可以连接你的原语,而不需要猜测如何配置它。
与直接实例化相比,GUI 方法对于初学者来说更容易上手。使用 GUI 时,由于菜单的引导,你更不容易出错,但你仍然可以像实例化时一样精确控制所得到的内容。然而,这种方法也有一个重要的缺点。如果你需要更改原语中的某个设置,就需要打开 GUI 并重新执行整个过程。这听起来可能不算什么大事,但如果你的设计中有许多使用 GUI 创建的原语,进行调整可能会变得非常繁琐且耗时。
块 RAM
块 RAM (BRAM) 是内建于 FPGA 中的专用存储组件。仅次于查找表(LUTs)和触发器,块 RAM 是第三种最常见的 FPGA 原语。在第六章中,我们简要提到过块 RAM,当时我们讨论了常见的内存模块,如 RAM 和 FIFO。如同我在那一章中提到的,当你需要一个超过某个大小的内存时,它将使用块 RAM 而非触发器来创建。
为存储数据创建内存是 FPGA 中非常常见的任务。你可能会使用块 RAM 来存储只读数据,如校准值,或者你可能会定期将数据从外部设备(如模数转换器(ADC))写入块 RAM,然后稍后再从中读取。块 RAM 也常用于在生产者和消费者之间缓冲数据,包括在时钟域之间传输数据的情况。在这种情况下,块 RAM 可以配置为 FIFO,具有专门设计的功能来处理跨越时钟域时出现的亚稳态问题(我们在第七章中讨论过如何跨时钟域传输数据)。
可用的块 RAM 数量以及每个块 RAM 的具体特性在不同 FPGA 和不同供应商之间会有所不同。你应该始终查阅你 FPGA 的 datasheet 和内存指南,以获取与你的型号相关的详细信息。例如,图 9-2 展示了 Intel Cyclone V 系列 FPGA 上的块 RAM 的 datasheet。

图 9-2:Cyclone V 产品系列上的块 RAM
Intel 将块 RAM 称为内存块。datasheet 中标出的一行首先告诉我们每三种 FPGA 型号中有多少个内存块:5CEA2 FPGA 有 176 个,5CEA4 有 308 个,5CEA5 有 446 个。datasheet 中的下一行显示了可用块 RAM 存储的总千比特数(Kb)。每个内存块可容纳 10Kb(因此名称中的M10K),所以 5CEA2 FPGA 上有 1,760Kb 的 BRAM 存储,5CEA4 上有 3,080Kb,5CEA5 上有 4,460Kb。
你可能会对其实际存储容量感到惊讶。即便是最大的 4,460Kb,也不到 1MB!考虑到你可以以大约 10 美元的价格购买一个 32GB 的 MicroSD 卡,它的存储空间比这个大成千上万倍,你就会开始理解 FPGA 并不是为了存储大量数据而设计的。相反,块 RAM 用于在 FPGA 上缓冲数据,以供临时使用。如果你需要存储大量数据,你将需要使用外部芯片来实现这一点。MicroSD 卡、DDR 内存、SRAM 和闪存是 FPGA 可能连接的常见芯片类型,以扩展其内存存储和检索能力。
你还应该注意,在 图 9-2 中,块 RAM 是 Cyclone V 数据表中 FPGA 资源列表中的第四项,位于 LEs、ALMs 和寄存器之后。这些是 Intel 用来描述 LUT 和触发器的术语(LE 代表逻辑单元,ALM 代表自适应逻辑模块)。虽然你可能并不总是需要很多块 RAM 来满足应用需求,但在数据表中的这一重要位置突出显示了块 RAM 通常是选择 FPGA 时必须考虑的最重要原始组件之一。
特性与限制
在使用块 RAM 时,有一些常见的特性和一些限制需要记住。首先,块 RAM 通常在 FPGA 上只有一种尺寸;每个块 RAM 16Kb 是常见的。这种“一刀切”的方法意味着,如果你只需要使用 16Kb 中的 4Kb,你仍然需要使用整个块 RAM 元件。无法将单个块 RAM 组件划分成多个内存,因此块 RAM 在这方面具有一定的限制。
然而,从其他角度来看,块 RAM 还是相当灵活的。你可以按照自己需要的宽度存储数据:例如,使用 16Kb 的块 RAM,你可以存储宽度为 1 位、深度为 16,384 位(2¹⁴)的数据,或者宽度为 8 位、深度为 2,048 位,或者宽度为 32 位、深度为 512 位等。还可以创建比单个块 RAM 更大的存储器。例如,如果你需要存储 16KB 的数据,就需要使用八个单独的块 RAM(16Kb × 8 = 16KB)。工具足够智能,能够级联这些块 RAM,使它们看起来像一个大存储器,而不是八个需要单独索引的组件。
其他常见的功能包括错误检测和修正,其中块 RAM 预留了一些额外的位来检测和修正内存本身可能发生的任何错误(即,当 1 变为 0,或反之)。如果内存发生这种情况,某个值可能会被完全破坏,并且当 FPGA 尝试分析它时会产生非常奇怪的行为。
错误检测和纠正是两个独立但相关的过程:FPGA 可以检测一些位错误并通知你它们的存在,另外,它也可以自动纠正一些位错误。通常,能够纠正的位错误数量小于能够检测的位错误数量。这里重要的一点是,块 RAM 中的错误检测和纠正是自动执行的,你无需做任何操作。
许多块 RAM 还可以初始化为默认值。如果你需要存储大量初始值,或者如果你想创建只读内存(ROM),这可能是一个有用的功能。将这些值推送到块 RAM,而不是占用触发器进行数据存储,是节省资源的一种有价值的方法。我们在第七章中提到过这个想法,当时我们在看可综合和不可综合的 Verilog 和 VHDL 部分时,虽然从文件读取通常是不可综合的——记住,FPGA 上没有文件系统,除非你自己创建——但是我们可以作为综合过程的一部分,从文件读取数据,以便将默认值预加载到块 RAM 中。同样,我建议你查阅特定 FPGA 的内存指南,以了解它支持哪些功能。
创建
在你的设计中使用块 RAM 时,我通常建议推导出它。正如你在第六章中看到的,当我们创建一个二维内存元素时,工具会轻松识别它。这个内存是否会被推送到块 RAM 取决于其大小。再说一次,综合工具在这方面很聪明:它知道你创建了多少位内存,如果超过某个阈值,它就会将其推送到块 RAM,否则,工具只会使用触发器。例如,如果你创建了一个存储 16 字节的内存,它很可能会被推送到触发器。你只需要 16 × 8 = 128 位内存,因此,使用整个 16Kb 的块 RAM 来存储这点数据就不太有意义。
工具开始将内存推送到块 RAM 而不是使用触发器的时机,通常取决于具体情况。要了解你的工具为特定设计做出的决定,请在综合后查阅你的利用率报告。以下是一个示例:
`--snip--`
Number of registers: 1204 out of 84255 (1%)
`--snip--`
Number of LUT4s: 1925 out of 83640 (2%)
`--snip--`
❶ Number of block RAMs: 3 out of 208 (1%)
利用率报告列出了所需的块 RAM 数量❶,就像它列出了触发器(寄存器)和 LUT(LUT4,或者在此情况下是四输入 LUT)的数量一样。如果你看到没有使用块 RAM,那么你的内存可能被推导为触发器了。作为提醒,我总是建议你仔细检查利用率报告,确保工具推导出了你预期的结果。
如果你对推断大容量内存元素感到担忧,或者不确定在块 RAM 中应该利用哪些特性,使用图形用户界面(GUI)创建它是你最好的选择。GUI 会引导你完成整个过程,因此对于初学者来说非常有帮助。使用 GUI 也是确保在跨时钟域时正确使用 FIFO 的最佳方式,因为它可以帮助你处理其中的复杂性。
数字信号处理模块
数字信号处理(DSP) 是对数字系统中的信号执行基于数学的操作的统称。这些数学操作通常需要非常快速地并行执行,这使得 FPGA 成为执行此任务的极好工具。由于 DSP 是 FPGA 中一个非常常见的应用,另一种 FPGA 原语,DSP 块,也为此目的存在。DSP 块(也称为 DSP 瓦片)专门用于执行数学操作,特别是 乘法–累加(MAC) 操作,这是一个先进行乘法操作再进行加法操作的过程。然而,在我们更深入地研究这些原语之前,值得先回顾一下模拟信号和数字信号之间的区别。
模拟信号与数字信号
模拟信号是一个连续的信号,表示某种物理量的测量。一个常见的例子是储存在黑胶唱片上的音频信号(这是一种大而黑的光亮物体,上面有音乐,有时可以在老电影或新潮的酒吧里看到)。唱片上刻有一条连续的凹槽,反映了音频的连续波形。然后,唱片播放机用针头读取这个波形,并放大得到的信号来回放声音。信息始终是模拟的,无需转换。
数字信号则不同,它们不是连续的,而是在各个时间点上由离散的测量值组成,中间有间隔。一个常见的例子是储存在 CD 上的音频信号,其中声音以一系列的 1 和 0 来表示。如果你有足够的离散测量值,你可以填补这些间隔,从这些数字值中创建出一个 reasonably accurate 逼真的模拟信号近似值。CD 播放器读取这些数字值,并从中重建出模拟波形。结果总是原始模拟信号的近似,因此一些音响爱好者更喜欢唱片的真实模拟信号,而不是 CD 或 MP3 等数字媒体。
在你常见的 FPGA 结构中,例如查找表(LUT)和触发器,数据是以数字形式表示的。那么,如果你有一个需要输入到 FPGA 中的模拟信号该怎么办呢?这就是 ADC 的作用:它通过 采样,即在离散的时间点记录其值,将模拟信号转换为数字信号。图 9-3 显示了这一过程是如何工作的。

图 9-3:模拟信号的数字采样
图中从左到右移动的波动线表示一个连续的模拟信号,而沿着这条线的黑点则表示对该信号进行采样并将其转换为数字形式。注意,采样是按规律的时间间隔进行的。模拟信号的采样频率被称为采样频率或采样率。采样率越高,我们就能越准确地表示模拟信号,因为将离散的点连接成类似于原始波形的形状会更加容易。然而,较高的采样率也意味着我们需要处理更多的数据:每个点代表一定数量的数字数据,所以点越多,我们处理的位数就越多。
常见的 DSP 任务
FPGA 通常将模拟信号作为输入,将其数字化,然后进行一些数学运算来处理这些数字数据。例如,假设我们有一个音频信号,我们已经在 FPGA 中对其进行了采样。进一步假设录制的数据太小,播放时很难听清。我们如何操作数字信号,使输出音量更大呢?我们可以做的一件简单的事是将每个数字值乘以一个常数,例如 1.6。这就是对信号应用增益。那么我们如何在 FPGA 中实现这一点呢?其实非常简单:
gain_adjusted <= input_signal * 1.6;
我们获取 input_signal,将信号中每个离散的数字值乘以 1.6,然后将结果存储在 gain_adjusted 输出中。这时,DSP 原语就派上用场了。当我们编写这样的代码时,综合工具会看到我们在执行乘法操作,并自动推断出一个 DSP 块来处理这一操作。
对输入信号应用增益不需要并行处理。每个数据样本只有一次乘法运算,数据样本可以一个接一个地处理。然而,通常你需要通过同时运行多个 DSP 块来执行多个数学运算。一个常见的例子是创建一个滤波器,它对信号执行数学运算,以减少或增强输入信号的某些特征。例如,低通滤波器 (LPF) 会保留信号中低于某个截止频率的频率成分,同时减少高于该截止频率的频率,这对于去除输入信号中的高频噪声非常有用。降低音响系统中高音的滑块是应用低通滤波器的一个现实世界例子,因为它会减少音频中的高频部分。数字低通滤波器的实现细节超出了本书的范围,但由于它需要同时进行许多乘法和加法运算,因此 FPGA 非常适合完成这一任务。
另一个可能在 FPGA 中执行的并行数学运算的例子是处理视频数据以创建模糊效果。视频模糊涉及用一组邻近像素的平均值替换单个像素值。这需要同时对图像中的许多像素进行数学运算,并且由于视频数据每秒包含许多图像,这必须迅速完成。FPGA 能够使用 DSP 块高效地执行这些并行数学运算。
功能
DSP 块是多功能的原语,提供许多有助于执行不同数学运算的功能。你并不总是需要为你的应用程序使用所有功能——通常,你只需要进行乘法或加法运算——但对于更复杂的场景,DSP 块可以配置来解决广泛的问题。图 9-4 详细展示了 AMD FPGA 中的 DSP 块。每个制造商的 DSP 原语有所不同,但这个示例代表了典型的可用功能。

图 9-4:DSP 原语的框图
这个图实际上展示了 DSP 块的简化版本。理解原语的完整结构并非至关重要,但值得指出几点。首先,注意到这个 DSP 块最多可以接受四个输入并有两个输出。这使得它比仅仅进行两个数字相乘的应用更具扩展性:例如,MAC,其中乘法结果在下一个时钟周期反馈到输入以进行加法运算。
在框图的左侧,你可以看到一个预加法器块。如果请求在执行其他数学操作之前进行加法操作,可以启用该块。在它的右侧,接近图的中间位置,是一个带有 X 的圆圈。这就是乘法器,它是 DSP 块的核心,执行高速的乘法操作。在它的右侧是一个标有 ALU 的圆圈,ALU 代表算术逻辑单元,可以执行更多操作,如加法和减法。最后,还有内建的输出寄存器,可以启用以采样输出并帮助在快速数据速率下满足时序要求。
与块 RAM 数量一样,可用的 DSP 块数量因 FPGA 而异。一些高端 FPGA 内部有成千上万个 DSP 块;同样,你应该查阅你 FPGA 的数据手册,以获取与你型号相关的具体细节。举个例子,图 9-5 显示了 Intel Cyclone V 产品线数据手册中关于 DSP 块的信息。

图 9-5:Cyclone V FPGA 上的 DSP 块
请注意,DSP 块的信息紧跟在块 RAM 信息之后,这再次强调了这些原语在 FPGA 开发中的重要性。5CEA2 FPGA 有 25 个 DSP 块,但 5CEA4 为 66 个,5CEA5 为 150 个。每个 DSP 块有两个乘法器,因此在第二行高亮显示的地方,我们可以看到 18×18 乘法器的数量是 DSP 块的两倍(其中 18 是输入的位宽)。
注意
如果你的 FPGA 上没有可用的 DSP 块,这并不意味着你不能执行这些类型的操作。乘法和加法操作将仅使用 LUT 实现,而不是使用专用的 DSP 块。我们将在第十章进一步讨论这个问题。
创建
与块 RAM 一样,我通常建议使用推理来创建 DSP 块。你需要执行的大多数乘法操作将需要两个输入和一个输出,就像我们之前对信号应用增益时看到的那样。用 Verilog 或 VHDL 编写相关代码并让工具处理其余部分非常简单。记得检查你的综合报告,以确保你得到了预期的结果,但我在使用综合工具理解我的加法和乘法意图时运气不错,它们会将这些操作推送到相关的 DSP 中。你所使用的 FPGA 的用户指南还将提供如何编写 Verilog 或 VHDL 代码的建议,帮助确保工具理解你的意图。
如果你的 DSP 块有更复杂的需求,或者你想深入探索它们的所有特性和功能,那么你可能应该通过 GUI 来创建它们,以确保能够得到你想要的功能。图 9-6 展示了在 Lattice Diamond GUI 中创建乘法器的示例。

图 9-6:通过 GUI 创建 DSP 块
这里需要特别强调的是“块实现”下拉菜单。你可以将其从 DSP 改为 LUT,以使用查找表而非 DSP 块来执行乘法运算。如前所述,LUT 和 DSP 都可以执行数学运算,包括乘法。然而,使用 DSP 块,你可以节省 LUT 资源,并且能够以更高的时钟频率运行数学运算,因为你将使用一个专门为数学运算高度优化的原语。
锁相环(PLL)
锁相环(PLL) 是一种常用于作为整个 FPGA 主时钟生成器的原语。通常情况下,你会有一个外部时钟芯片,它运行在某个频率上。在某些 FPGA 上,你可以直接使用这个输入时钟来驱动所有同步逻辑,就像我们在本书的项目中做的那样。在这种情况下,你的逻辑频率将固定为你选择的外部时钟频率。但是,如果你需要更改频率会怎样呢?没有 PLL 的话,你需要物理地移除外部时钟芯片,并将其替换为一个生成你想要切换到的时钟频率的不同组件。然而,使用 PLL 后,你可以通过改变几行代码,在 FPGA 内部生成不同的时钟频率,而无需新的外部组件。
PLL 还使得在 FPGA 设计中使用多个时钟域变得更加容易。假设你有一些外部内存,它的运行频率是 100 MHz,但你希望主逻辑运行在 25 MHz。如果你想要实现这一点,你可以购买第二个外部时钟并将其输入到 FPGA 中,但更好的解决方案是使用 PLL,因为这个原语可以同时生成多个时钟频率。
不是所有的 FPGA 都有 PLL,但许多 FPGA 至少有一个,而有些 FPGA 则有多个。数据手册会告诉你有哪些可用的 PLL。例如,图 9-7 展示了英特尔 Cyclone V 产品线上的 PLL。

图 9-7:Cyclone V 产品线上的 PLL
5CEA2 和 5CEA4 FPGA 都有四个 PLL,而 5CEA5 则有六个。鉴于每个 PLL 都可以生成多个时钟频率,这应该足以满足你所有的时钟需求。
工作原理
PLL 作为你在 FPGA 中时钟分发的源头,通过接收一个单一的时钟输入,通常称为参考时钟,并从中生成一个或多个时钟输出。输入时钟来自一个专用的外部组件,输出时钟可以与输入时钟以及彼此的频率完全不同。图 9-8 中的框图展示了 PLL 上最常见的信号。

图 9-8:常见的 PLL 信号
PLL 通常有两个输入:一个时钟信号和一个复位信号。当复位信号被激活时,PLL 将停止运行。
在输出端,PLL 有一定数量的输出时钟,范围从 1 到N,最大值取决于 FPGA。输出时钟的频率可以不同,具体取决于设计的需求。这些频率通过对输入参考时钟进行乘法和/或除法运算来实现,从而得到所需的值。例如,假设你有一个 10 MHz 的输入参考时钟,并且你需要一个 15 MHz 的输出时钟。PLL 会将参考时钟乘以 3(得到 30 MHz),然后再除以 2 得到 15 MHz。乘法和除法的因子必须是整数,因此必须注意你不能从 PLL 获得任何任意的频率。例如,无法从 10 MHz 的时钟输入获得 π MHz 的时钟输出,因为 π 是一个无法表示为两个整数比值的无理数。
除了可以改变输出时钟的频率外,PLL 还可以改变它们的相位。信号的相位是信号在其重复波形中的当前位置,以 0 到 360 度的角度来测量。通过比较两个共享相同频率但时间上不同步的信号,可以更容易地理解这一点。图 9-9 演示了一些常见的时钟信号相位偏移。

图 9-9:常见的相位偏移
如图所示,时钟信号的相位偏移会导致其上升沿的位置发生变化。将没有相位偏移的< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk的第一个上升沿与相位偏移 90 度的< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk+90°的第一个上升沿进行比较。Clk+90°的上升沿比< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk延迟了一个时钟周期的四分之一。每增加 90 度,相位偏移都会使信号再延迟一个四分之一周期。继续图中的示例,得到< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk+180°,该信号比< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk+90°延迟了 90 度,相比< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk延迟了 180 度。请注意,Clk+180°实际上与如果你将< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk信号反转(交换高低电平)得到的波形是相同的。最后,Clk+270°比原始< samp class="SANS_TheSansMonoCd_W5Regular_11">Clk信号延迟了三个四分之一时钟周期。如果你将相位偏移到 360 度,你将恢复到原始信号。这个例子演示了正相位偏移,但相位也可以是负的,意味着信号相对于另一个信号在时间上被向后偏移。当然,你可以以任何任意角度来调整相位,而不仅限于 90 度的步长。
注意
在简单设计中,使用相位偏移来创建时钟并不常见,但在某些应用中可能会很有用。例如,这对于与外部组件的接口可能很重要,比如一些非 FPGA 内存。
回到图 9-8 中的框图,PLL 通常还有一个锁定输出信号,告诉下游的任何模块 PLL 正在工作,你可以“信任”这些时钟。使用这个锁定信号作为依赖于 PLL 时钟的其他模块的复位信号是一种常见的设计实践。当 PLL 没有锁定时,PLL 下游的模块将保持复位状态,直到 PLL 锁定并准备好为止,这意味着输出时钟可以被你 FPGA 设计中的其他模块使用。当 PLL 的复位输入被驱动为高电平时,其锁定输出将变为低电平,将下游模块恢复到复位状态。
如果你在设计中使用 PLL(相位锁定环),建议仅使用 PLL 的输出端来满足所有时钟需求。即使你设计中的一部分与外部参考时钟的频率相同,也不应直接使用外部时钟来驱动该部分设计。相反,应该让 PLL 输出与外部参考时钟相同频率的时钟信号。通过只使用 PLL 的输出,你可以精确控制输出时钟之间的关系。此外,你可以放心地使用 PLL 锁定的输出作为复位电路,知道它反映了你设计中所有时钟的工作状态。
创建
我推荐使用 GUI 来创建 PLL,因为综合工具无法推断 PLL。虽然也可以通过实例化来创建 PLL,但这容易出错。你需要选择兼容的 PLL 设置才能成功工作,在实例化时很容易选择不兼容的设置。例如,如果你有一个 10 MHz 的参考时钟,并且想生成一个 15 MHz 的输出和一个独立的 89 MHz 输出,这可能根本行不通,但你可能会在实例化过程中忽略这个事实。
当你通过 GUI 创建 PLL 时,你需要提供输入参考时钟和期望的输出时钟频率,工具会告诉你是否能找到一个可行的解决方案。以 10/15/89 MHz 为例,GUI 可能告诉你它能够提供的最接近 89 MHz 的频率是 90 MHz(因为 90 MHz 是 10 MHz 和 15 MHz 的倍数,这应该是可行的)。然后,你需要决定 90 MHz 是否适合你的设计,或者你是否真的需要 89 MHz,如果是这样,可能需要使用一个单独的 PLL 或者更改你的参考时钟。图 9-10 显示了 Lattice Diamond 中 PLL GUI 的示例。

图 9-10:使用 GUI 创建 PLL
正如你所看到的,GUI 帮助我们引导 PLL 创建过程。在这个例子中,我们在CLKI上有一个 30 MHz 的参考频率,我们将期望的输出频率设置为:在CLKOP上为 30 MHz,CLKOS上为 60 MHz,CLKOS2上为 15 MHz,以及CLKOS3上为 89 MHz。请注意,除了CLKOS3之外,每个时钟的实际频率(最右侧)都与期望频率相匹配。对于CLKOS3,当我第一次尝试以 0.0%的容差创建一个 89 MHz 的时钟时,我得到了图 9-11 中显示的错误信息。

图 9-11:无效 PLL 设置的可操作反馈
直到我将容差改为 2.0%时,错误信息才消失;工具选择了 90 MHz 的实际频率,这在请求频率的 2.0%范围内。如果你尝试直接实例化 PLL,工具不会提供这种类型的指导。
GUI 的另一个有用功能是 PLL 框图,显示在图 9-10 的左半部分。如果你修改了输入或输出,这个图示会更新。例如,如果我们禁用了CLKOS3,该输出将在框图中消失,表示我们只希望输出三个时钟信号。这个功能对于确保你正在创建预期的内容非常有用。请注意,窗口顶部还有一个单独的“Phase”标签,允许我们为输出时钟指定相位偏移。
在 GUI 中设计 PLL 之后,你可以通过正常的综合过程运行你的设计。利用率报告将确认你确实得到了 PLL,因为它是报告中突出的主要原语之一。以下是一个示例:
`--snip--`
Number of PLLs: 1 out of 4 (25%)
这表示在这个特定 FPGA 中,正在使用四个 PLL 中的一个。
总结
你大部分的 Verilog 和 VHDL 代码将用于创建 LUTs 和触发器,这两者是 FPGA 中最基本的组件。然而,正如你在本章中所看到的,FPGA 还包含其他原语组件,如块 RAM、DSP 块和 PLL,它们增加了专用的功能。块 RAM 提供了专用内存,DSP 块启用了高速并行数学运算,而 PLL 则允许你生成不同的内部时钟频率。通过这些 FPGA 构建模块的组合,你将能够高效地解决广泛的问题。


浙公网安备 33010602011771号