FPGA-入门笔记-全-
FPGA 入门笔记(全)
FPGA编程入门:P01:什么是FPGA?🔌
在本节课中,我们将要学习什么是现场可编程门阵列(FPGA)。我们将探讨FPGA的基本概念、它与传统微控制器的区别、其典型应用场景,以及如何开始为FPGA创建设计。课程内容将尽可能简单直白,适合初学者理解。
概述
FPGA代表现场可编程门阵列。它是一种集成电路或芯片,允许你设计完全自定义的数字逻辑电路。在接下来的课程中,我将展示如何开始使用FPGA,以便你能创建自己的自定义数字电路。
我建议先理解一些数字逻辑的基础知识,例如二进制以及与门、或门、非门的工作原理。因为我们将主要专注于使用硬件描述语言来实现这些设计。
然后,我将展示如何将你的设计上传到FPGA,以便你能看到它们实际工作。
让我们开始吧。
FPGA是什么?🧩
我经常听到的一个核心问题是:我能用FPGA做什么?你可能会得到诸如“它比微控制器更快”或“它允许你并行处理事情”之类的回答。这些说法可能正确,但并未完整描绘出FPGA的全貌。
如果你只追求快速和并行,或许直接购买一块强大的显卡或将多个处理器连接在一起会是更好的选择。
FPGA由许多逻辑单元组成,这些单元是创建数字电路的基本构建模块。我们将在本系列后续课程中深入探讨这些单元的内部结构,但现在你可以将它们想象成一堆积木。
你可以配置单个单元以特定方式工作,并将这些单元连接起来,形成任何数字电路的基础。这就像用乐高积木搭建一辆玩具车一样。你还可以访问时钟信号和用于存储数据的RAM块等资源。请注意,一些FPGA可能还包含其他外设,如模数转换器或模拟输出。
逻辑单元通常被分组到逻辑块中,这种可重构的、相互连接的硬件组通常被称为FPGA架构。
FPGA与微控制器的对比 ⚖️
回到我们的汽车类比,如果你只是想要一辆玩具车来玩跳跃,直接购买一辆玩具车可能更好。这很可能比自己动手制作更容易,也可能更便宜。
这类似于使用微控制器。微控制器能够做很多事情,但它是一个具有特定用途的处理器,附带一些静态外设,你可以用来连接传感器、电机、灯光等。
与此形成对比的是FPGA,它为你提供了构建更多东西的模块。事实上,你可以使用这些构建模块在FPGA内部制作你自己的处理器。这被称为软核处理器,它允许你像在微控制器或微处理器上一样运行代码。
如果你有足够的空间,甚至可以在一个FPGA上实现多个处理器。请注意,你可能无法访问微控制器上常见的某些外设,比如那些模数转换器。
实现软核处理器非常流行,因为它允许你既自定义处理器,又将其连接到你在FPGA架构上创建的其他数字电路。
如果时间允许,我的目标是在本系列后续课程中展示如何在某个FPGA架构上加载一个非常简单的RISC-V处理器。但在那之前,让我们先看看一些你可能想使用FPGA而非微控制器或微处理器的示例用例。
FPGA的应用实例 💡
例如,这些令人着迷的LED立方体就是由FPGA控制的。大多数微控制器都难以以如此高的速率向所有那些面板提供如此大量的数据。
你还可以发现FPGA被用于通信设备中,执行各种数字信号处理任务,如滤波、压缩和计算傅里叶变换。虽然处理器也可用于DSP,但FPGA中的自定义逻辑可能更适合处理更高的频率和更高的数据速率。
你甚至可以在一些消费电子产品中找到FPGA。例如,对梅赛德斯-奔驰信息娱乐中心的拆解显示,其PCB上有一个赛灵思FPGA。有时,使用像FPGA这样的可重构逻辑比生产自己的芯片更便宜,因为后者可能产生高昂的模具成本。
除了制造自己的芯片,你还可以使用FPGA作为快速原型设计的方法,因为它们可以被反复重新配置。如果你打算生产成千上万或数百万个单元,并且现成的集成电路无法满足需求,你可以制造一个专用集成电路。
请注意,有时,如果你只生产少量单元(就像我们刚才看到的梅赛德斯-奔驰信息娱乐中心的情况),将FPGA直接放入最终产品中可能更具成本效益。
如果我拆开我心爱的Analog Discovery设备,你可以在这里找到另一个赛灵思FPGA。它被用于数据采集,可以非常快速地从示波器的模拟前端或作为逻辑分析仪的一部分采样和缓冲数据。
你还可以发现非常庞大和强大的FPGA被用于专门的计算,例如挖掘加密货币或训练神经网络。这些操作所使用的特定计算可以在硬件层面进行优化。因此,你经常会发现,对于某一特定应用,FPGA的性能可以超过通用处理器或图形处理单元。
我希望这些例子能让你对FPGA的应用领域有一些概念。
为何选择FPGA? 🤔
让我们花点时间总结一下,为什么你可能想使用FPGA而不是传统的微控制器或微处理器。
最重要的一点是,你可以在FPGA中创建自定义的、可重构的数字逻辑电路。如果你找不到一个拥有你所需外设的处理器(比如,三个USB主机控制器),你或许可以在FPGA中自己制作一个。
你可以将FPGA用作外部芯片来增强你的处理器,或者你可以在FPGA本身内部实现一个处理器。一些CPU设计,例如许多RISC-V实现,是开源的。这意味着你可以查看和修改源代码,从而开启了改变处理器功能的可能性。
如果你需要添加一个独特的汇编指令或支持一些专门功能(例如在单个时钟周期内完成乘加运算的能力),你可以在FPGA中实现。它们还提供了创建优化数字电路的能力,用于执行专门计算,例如在仅几个时钟周期内计算快速傅里叶变换。对于特定应用,微控制器或微处理器可能太慢,无法满足你的需求。
最后,如果你打算将你的数字逻辑制造成芯片用于销售或作为大型项目的一部分,FPGA是一个帮助你进行原型设计的好工具。
如何为FPGA设计? 🛠️
现在,让我们谈谈如何“编程”或更准确地说,如何为你的FPGA创建设计。
在大多数情况下,你将使用硬件描述语言(如Verilog或VHDL)来描述你想要创建的电路。虽然其中一些语法可能看起来像C或Python,但重要的是要注意,HDL并不像编程语言那样运行,因为我们不是在为处理器创建一系列顺序执行的指令。
相反,将HDL更多地看作是你用来设计网站的标记语言。所有事情或多或少都是同时发生的,因为各个行不是按顺序执行的。我展示的这段Verilog代码片段将定义这个简单的逻辑电路。请注意,FPGA如何实现这个电路可能并不完全是一堆逻辑门的集合。我们将在未来的课程中探讨这是如何工作的。
你将遇到两种最常见的硬件描述语言:VHDL和Verilog。VHDL代表超高速集成电路硬件描述语言,Verilog是“验证”和“逻辑”的合成词。两者都于20世纪80年代初被引入。
VHDL由美国国防部开发。它是一种强类型语言,通常被认为非常严格和冗长。Verilog由Gateway Design Automation公司创建,该公司后来被Cadence Design Systems收购。它是一种弱类型语言,语法更接近C语言。
这两种语言都是寄存器传输级设计的例子。它们描述了电路如何在寄存器之间移动和操作数据,但并未描述实现这一功能所需的精确硬件。你会在网上找到许多讨论,争论一种语言相对于另一种语言的优点。事实是,它们都是行业标准,并且足够相似,一旦你熟练掌握了其中一种,另一种就相对容易上手。
因为本系列课程中我们将要使用的开源工具集目前仅支持Verilog,所以我们将使用它。
其他工具与设计流程 📋
你还应该了解一些其他语言和工具。SystemVerilog非常流行,因为它简单地扩展了2005版Verilog的功能,增加了帮助你编写测试平台的功能,这些测试平台用于验证你的设计是否正常工作。
许多大型FPGA供应商正在推广高级综合工具。这些工具是强大的程序,可以自动将C、C++和MATLAB等高级语言转换为RTL代码。它们允许新手用更熟悉的语言编写程序,然后将其转换为FPGA的设计。虽然这可能不如手动编写RTL代码那样优化,但它有可能为你节省大量的设计时间。
你还可以通过使用知识产权核来节省时间。你通常可以从主要的FPGA供应商或其合作伙伴那里下载或购买IP核。这些就像你在编写软件时可能使用的闭源库。它们会占用你的一些逻辑块,并具有你可以连接的文档化接口。然而,你看不到内部是什么。它们可以提供各种功能,如软核处理器、专用DSP滤波器、压缩、神经网络等。有了IP核,你通常不需要担心制作困难的部分,而是可以专注于为你的特定应用创建连接逻辑。
创建FPGA应用的术语与你可能听说的编写软件的术语有很大不同,因为这里没有编译器或汇编器。以下是一个典型的设计流程:
- 首先,你用硬件描述语言编写代码(本课程我们将使用Verilog)。
- 之后,你通常希望仿真你的设计。许多FPGA开发环境都包含仿真器,但我们将使用开源的GTKWave作为可视化工具。你通常需要编写Verilog来测试你的原始设计,这被称为测试平台。虽然通常建议在综合之前进行仿真,但我们将把它留到本系列课程的后面,因为在真实硬件上操作设计要有趣得多。
- 接下来,我们综合我们的代码,我们将为此使用开源的Yosys工具。综合工具将你的HDL代码转换为门级表示。此步骤的输出类似于一个网表,它告诉FPGA各种单元、逻辑和寄存器应如何连接。然而,这个网表是相当通用的,我们特定的FPGA可能不知道如何处理它。
- 因此,我们有一个布局布线步骤,就像你设计印刷电路板时会做的那样。这是另一个自动化工具,它将综合输出的网表转换为需要在我们的特定FPGA中建立的实际门和连线连接。我们将为此使用nextpnr。像其他工具一样,这也是一个开源工具。
- 我们的PNR工具的输出是一个ASCII文件,它确切地告诉FPGA需要做什么才能创建我们在代码中定义的电路。然而,它主要是人类可读的格式,所以我们使用icepack工具将其转换为FPGA配置过程实际可以读取的二进制文件。
- 最后,我们使用iceprog将该二进制文件上传到连接到FPGA的外部闪存芯片。
为了使整个过程更容易,并且你不需要记住所有这些工具的名称,我们将使用apio,这是一个为我们调用所有这些底层工具的工具。请注意,我们将要使用的这套工具仅适用于莱迪思的iCE40系列FPGA。这些通常被认为是相当低功耗且价格低廉的FPGA,但这对于我们的学习体验来说是完美的。
虽然你可以将这些独立工具用于iCE40产品线的大部分,但apio是为开发板设计的。如果你查看nextpnr的GitHub页面,你可以看到其图形化布局布线工具的示例。这是一个可选功能,欢迎你尝试,但我们在本系列课程中不需要使用它。你可以看到该工具如何在你的FPGA中的各个单元之间进行实际的布线连接。
如果你访问apio的GitHub页面并在README上向下滚动,你可以看到apio支持的各种开发板。虽然我不能保证它们都适用于本系列课程,但大多数很可能可以。我将使用莱迪思的iCEstick。
这是一款围绕iCE40 HX1K FPGA构建的开发板。我建议为它准备一根USB延长线,并且你需要一些基本组件,如面包板、跳线和一些按钮。我将在每节课中告知你是否需要额外的组件。


总结
在本节课中,我们一起学习了FPGA的基本概念。我们了解到FPGA是一种可重构的数字逻辑芯片,由可配置的逻辑单元组成,能够实现从简单逻辑电路到完整处理器的各种设计。我们对比了FPGA与微控制器的区别,探讨了FPGA在高速数据处理、原型设计、专用计算等领域的应用优势。最后,我们介绍了使用Verilog硬件描述语言进行FPGA设计的基本流程,以及相关的开源工具链。
本系列课程的目标是为你提供开始使用FPGA创建项目所需的基本构建模块。在下一节课中,我们将安装apio工具集并上传我们的第一个FPGA设计。


祝你学习愉快!
002:工具链安装与第一个项目
在本节课中,我们将学习如何为FPGA开发安装免费的开源工具链,并使用这些工具在开发板上运行第一个简单的Verilog项目。我们将重点介绍Yosys、nextpnr、IceStorm和Apio等工具。
概述
上一节我们介绍了FPGA是什么以及它的用途。本节中,我们来看看如何搭建开发环境并运行第一个示例项目。我们将使用一套免费的开源工具,它们支持我们开发板上的Lattice iCE40 FPGA。
工具链简介
在开始编写Verilog代码之前,我们需要安装相应的工具链。幸运的是,有免费的开源工具可以与我们开发板上的FPGA配合工作。




访问YosysHQ.net可以了解这些工具。Yosys工具用于综合Verilog RTL代码。请注意,它专门支持Verilog-2005标准,因此我们将使用该版本。在FAQ中,可以查看它支持的不同FPGA目标。我们将使用Lattice iCE40系列。
点击“nextpnr”会跳转到一个GitHub页面。这里可以看到nextpnr工作原理的可视化图。它本质上是在FPGA内部的单元之间建立连接。布局布线步骤的输出会传递给IceStorm项目套件中的工具。
向下滚动可以阅读我们将要使用的各个工具。icepack将布局布线工具输出的ASCII文件转换为FPGA可读的二进制文件。最后,iceprog将二进制文件上传到FPGA。




使用Apio简化流程
为了让工作更简单,我们将使用Apio。这是一个管理我们刚才看到的所有工具的工具。它提供了通过Python在所有主流操作系统上安装这些工具的方法。它还附带了许多可以作为良好起点的示例。
需要注意的是,虽然这些工具可以配置为仅支持自定义板上的芯片,但Apio主要是为了支持您可以购买的各种开发板。如果您制作了自定义开发板,可以通过提交Pull Request来让Apio支持它。
我还想指出IceStudio项目。IceStudio是一个基于图形块的Verilog编码环境。您可以在一些GIF动图中看到它的实际效果。它在后台运行Apio和我们之前看到的其他工具,因此目前仅支持Lattice iCE40芯片。
目前,我想坚持使用Verilog和命令行工具,以便您能了解原始HDL的工作原理。如果大家有兴趣,我们可能会在本系列后期再回到IceStudio。例如,这里展示了一个在IceStudio中使用的RISC-V软核CPU。对于某些人来说,这种图形环境可能比纯硬件描述语言更容易可视化和组织大型项目。
Apio仍然是一个相对较新的项目,因此预计在更新过程中会有一些变化。Apio的文档页面是我找到的关于安装和使用信息的最佳位置。您需要按照适用于您特定操作系统的安装指南进行操作,但我将在Windows上演示该过程。


请注意,您的计算机上需要安装Python 3.5或更高版本。在用户指南部分,您可以看到如何使用Apio来构建、仿真设计并将其上传到开发板。请注意,这些命令中的每一个实际上都在后台调用了一个或多个工具,如Yosys和nextpnr。


安装步骤
以下是安装和配置Apio及其工具链的详细步骤。
首先,确保您使用的是Python 3.5或更高版本。如果系统上尚未安装Python,请先完成安装过程。
接下来,我们将使用pip安装Apio。但请注意,我特意安装了Apio的0.6.7版本。至少目前,最新版本在安装某些工具时存在问题,因此我将坚持使用已知在我的系统上可用的0.6.7版本。未来,当前版本可能可以工作,您可能不会遇到一些错误。但在Apio的GitHub仓库的问题页面中提到,他们目前正在更新Apio的许多内容和后端,因此我建议暂时坚持使用0.6.7。
安装命令如下:
pip install apio==0.6.7
安装后,Apio应该可以在您的控制台中作为命令行工具使用。如果遇到无法识别为路径一部分的问题,您可能需要将Apio的安装位置添加到系统路径中。我在某些Linux发行版上遇到过这个问题,但在Windows上,使用pip安装后似乎可以直接使用。
接下来,我们需要安装工具套件。这包括Yosys和IceStorm等工具。它们没有与Apio打包在一起,因此我们需要使用Apio来安装它们。


然后,我们告诉Apio安装各种驱动程序以支持与我们的开发板通信。查看您的开发板,如果它有一个FTDI芯片,您将需要安装特定的驱动程序来与该FTDI芯片配合工作。如果您像我一样使用iCEstick,它确实包含一个FTDI芯片,因此我们需要执行这个额外的步骤。
如果您在Windows上,需要以管理员身份打开命令提示符。


安装FTDI驱动程序
要安装FTDI驱动程序,我们需要调用apio drivers --ftdi-enable。在Windows上运行此命令时,至少会弹出Zadig窗口,我们必须手动完成此过程。如果您在其他操作系统上,则不需要执行此步骤。
在Zadig中,点击“Options”并选择“List All Devices”。插入您的FPGA开发板,点击下拉列表,您应该会看到您的开发板列出。它可能显示为“Lattice”之类的名称。如果您以前使用过这样的板子,它也可能是FTDI芯片或其他类似的东西。您也可以在插入开发板前后查看列表,以找到新添加的设备。
点击我的Lattice开发板,Apio希望安装“libusbK”驱动程序。因此我们选择该驱动程序并点击“Replace Driver”。给它一些时间来安装用于与FTDI芯片通信的特定驱动程序。
完成后,点击“Close”,关闭Zadig,然后可以关闭管理员命令提示符。
现在,您应该能够运行apio system --lsftdi来列出各种连接的FTDI设备。您应该会看到一个“Lattice FTUSB Interface Cable”,这意味着它已连接到我们的开发板。
我发现在Windows上有一个特定问题:如果您除了开发板之外还连接了另一个FTDI芯片,您可能会遇到“libusb_open failed”错误。在这个例子中,我插入了我的Analog Discovery 2,它使用类似的方式与USB通信,同时连接iCEstick和Analog Discovery 2导致了此错误。因此,如果您看到此错误,可能意味着您需要断开除特定开发板之外的所有其他FTDI设备。当我断开Analog Discovery 2后,再次运行该命令,果然,我的开发板显示为已连接。
创建并运行第一个项目
首先,我们想创建一个文件夹来存放我们将要创建的所有项目。对我来说,我将进入我的文档文件夹,在我的主目录下创建一个名为“apio”的目录。我们将进入apio目录并在此处存储我们的各种项目。
Apio的一个很酷的功能是它附带了许多示例。我们可以调用apio examples --list来列出可用的示例。随意浏览这些示例,了解我们可以实例化、查看和修改的内容。
我将使用iCEstick示例。让我们从LEDs示例开始,因为它非常简单。我们可以使用示例名称调用apio examples --dump,然后加上icestick\examples。

如您所见,这会创建一个icestick目录。如果我们进入那里,可以看到它在icestick内部创建了一个LEDs目录。进入该目录,我们可以看到作为此示例一部分创建的所有文件。



让我们进入文件浏览器,看看实际创建了什么。我们创建了这个apio目录,在其中创建了icestick和LEDs。我们的主要设计(它实际上不是一个程序)在leds.v文件中。


让我们打开它看看。请随意浏览,这是一个基本的“Hello World”示例,它所做的只是将所有LED连接到高电平,从而点亮它们。

另一个重要的文件是.pcf文件,即物理约束文件。它告诉我们实际板子或芯片上的连接方式。这并非真正的Verilog代码,注释由井号#给出。


它的作用是给出一个标签(在本例中为D1),并将其分配给芯片上的物理引脚号。因此,在我们的FPGA上,物理引脚99映射到D1,这是板子上的第一个LED。这就是我们在leds.v Verilog文件中调用这些名称时如何获取它们的方式。
如果我们访问Lattice Semi的iCE40页面,向下滚动寻找文档。我们使用的是iCE40HX1K,这是我们iCEstick上的FPGA芯片。点击下载此电子表格,您需要在Excel之类的软件中打开它。iCEstick采用144引脚TQFP封装,因此这些是标有1到144的物理部件编号。
我提到过99连接到第一个LED。所以这里是P99。您可以看到它确实是一个连接到某个IO Bank的PIO。
接下来您要做的是找到iCEstick用户手册,如果滚动浏览,通常可以找到原理图之类的内容。这里是LED。所以D1,这就是标签,它连接到一个名为LED0的网络。
实际的FPGA原理图符号被分成许多这样的块,但您可以看到这里引脚99,物理P99连接到LED0。您也可以找到这个图表:“User IOs and LEDs”,所以D1连接到99,依此类推。它还给出了LED的颜色。希望这两个文档能帮助您弄清楚引脚如何连接到开发板上的各种部件,以及在创建.pcf文件时在哪里可以找到物理引脚编号。




测试平台
另一个值得一看的是测试平台。第一个测试平台是用Verilog编写的,您可以在这里看到。我们至少在前几集中不会使用测试平台或编写我们自己的测试平台,因为我们想在开始编写这些之前掌握Verilog的基础知识。然而,在将设计发送到FPGA之前,尤其是在部署之前,创建一个测试平台来仿真您的设计并查看它是否工作总是一个好主意。

leds_tb.v是实际运行测试平台的Verilog测试平台。读取并运行仿真的程序是GTKWave。这是一个保存状态,因此当我们加载GTKWave时,它会读入此文件并立即运行仿真。
您还会注意到这个apio.ini初始化文件。如果我们查看里面,它只是说这个项目支持的开发板是iCEstick。我们实际上可以通过使用apio来创建这个文件。我在这里要做的是删除它。







然后回到我的控制台,我们可以输入apio boards --list,您可以看到支持的开发板列表(至少对于这个特定版本),我们想要iCEstick。所以我要输入apio init --board icestick。




这所做的就是重新创建那个apio.ini文件。因此,如果您从头开始创建一个项目,您可能需要这样做,以便告诉Apio您正在使用iCEstick。您会发现语法略有不同,但两者都应该可以工作。

现在我们已经将项目的所有组件放在一起了。





构建与上传流程
我建议做的第一件事是调用apio verify,这执行一个验证步骤。


它使用这个iverilog工具,基本上是说:“语法看起来不错,我们认为您可以综合这个,您已经准备好了。”
从那里,您可能想要进行仿真。在实际将项目或设计发送到实际板子之前,这总是一个好主意。


在这种情况下,仿真步骤会打开GTKWave,加载那个测试平台Verilog代码,并且也加载那个.gtkw保存状态,以便GTKWave以某些信号打开并以某种模式切换它们。但现在我们只看输出LED,它们应该只是常亮。当您对显示效果满意时,关闭GTKWave。
我们想要输入apio build。

这会调用Yosys来执行我们的Verilog代码的综合步骤,然后调用nextpnr进行布局布线。这就是读取.pcf引脚配置文件或物理约束文件的地方,它计算出如何路由各个连接并设置芯片本身(即iCE40HX1K)中的单元。因为Verilog代码是与特定芯片无关的,但布局布线随后会获取那个与芯片无关的设计,并创建一些特定于此芯片的输出。

然后,icepack获取那个ASCII文件并将其打包成芯片本身可以读取的二进制文件。
假设我们在这里获得成功,我们可以调用apio upload。确保您的开发板已插入并通电。这将调用iceprog,它会获取二进制文件并将其发送到您开发板的闪存中。
假设上传过程成功,板子上的所有LED都应该点亮。

深入了解iCE40 FPGA架构
我强烈建议回到我们下载引脚分配文档的Lattice页面。点击“Data Sheet”并找到iCE40HX部件的数据手册。我们将下载它。
打开它,滚动浏览,我建议将其用作参考。您将找到这张图,它展示了实际FPGA的内部工作原理。这些PLB(可编程逻辑块)包含许多单元,实际上在下一页,您可以看到每个块包含八个逻辑单元。这些就是我们第一集中看到的单元,我们将在未来的集中中查看单元的各个部分,并在学习查找表、组合逻辑以及D触发器等内容时进行分解,了解这些如何组合以创建您的数字逻辑。
但对于这个整体视图,请注意这些块可以相互连接。我们可以访问一些RAM,还有IO Bank,这些IO Bank有它们自己的一些逻辑。您可以将许多引脚视为类似于微控制器上的引脚,您可以将它们配置为输入引脚、输出引脚。有时您可以配置这些IO Bank内部连接到每个引脚或引脚组的上拉或下拉电阻。
这个特定部件还有一些专用的SPI逻辑。还有一个锁相环,将为我们提供时钟信号,我们将在未来的一集中探讨这些。
您还有这个非易失性配置存储器。请注意,这非常重要:此部件上的这个存储器是一次性可编程的。因此,如果您对设计满意,不再想更改它,并且准备部署,您可以将其烧录到这个少量存储器中。但一旦这样做,就完成了,该部件已被烧录并准备就绪。
否则,有配置逻辑将从通常通过SPI连接的外部闪存芯片读取,该芯片被读入并配置芯片,将单元设置为以特定方式行为,在单元之间以及块之间建立连接,从而在实际组件本身上创建您的设计。
在FPGA上,您可以多次读入该配置。这就是为什么您经常看到外部SPI闪存芯片伴随板子上的FPGA组件。确实,我们的iCEstick上有一个外部闪存芯片。因此,当我们使用apio upload发送程序(那个二进制文件)时,它会被加载到SPI闪存芯片中。每次重启或断开FPGA的电源时,它会丢失所有存储器和所有配置。因此,每次启动时,它必须从那个闪存芯片读取,然后重新配置一切。这就是为什么在给FPGA通电后,需要几秒钟它才会开始做任何事情。
本节挑战
本节课的挑战是修改leds.v示例,使得其中一个LED熄灭。您可以在这里看到我已经关闭了绿色LED。您的解决方案可能略有不同,但我会确保在描述中提供我的解决方案链接,如果您想比较答案的话。


总结
在本节课中,我们一起学习了如何为Lattice iCE40 FPGA安装开源工具链,包括Yosys、nextpnr、IceStorm和Apio。我们配置了FTDI驱动程序,使用Apio创建了一个示例项目,并理解了项目文件的结构,特别是.v设计文件和.pcf约束文件的作用。最后,我们完成了验证、仿真、构建和上传的完整流程,成功在开发板上运行了第一个FPGA程序。


至此,我们已经准备好开始创建自己的FPGA项目。在下一集中,我将向您展示如何在Verilog中创建逻辑门的组合。
003:Verilog入门与组合逻辑电路设计 🚀
在本节课中,我们将学习如何使用Verilog语言创建基本的数字设计,并了解这些设计如何在FPGA的查找表中实现。我们将从连接硬件开始,逐步编写代码,最终实现一个简单的与门电路和一个更复杂的向量控制电路。
硬件连接与引脚约束 🔌
上一节我们安装了FPGA工具链,现在可以开始创建自己的数字设计了。本节中,我们首先需要连接硬件并定义引脚约束。
我们将使用iCEstick开发板末端的PMOD连接器。这个连接器提供了电源、地线以及两侧的一些输入/输出引脚。这种连接器格式由Digilent公司推广,在许多FPGA开发板上都能找到,方便连接常见的扩展板以添加按钮、LED、传感器等功能。
以下是iCEstick上该连接器的引脚定义:
- 引脚6和12是3.3V电源。
- 引脚5和11是地线。
- 其余引脚是直接连接到FPGA的IO引脚。
我们将从连接按钮开始。将按钮连接到右侧的引脚1至4。请注意,这些引脚对应FPGA上的物理引脚78、79、80和81。本视频只需要三个按钮,但连接四个也无妨。
以下是连接示意图:

请注意,我们不需要为按钮添加外部上拉电阻。我们可以在FPGA内部为每个引脚启用上拉电阻,稍后会展示如何操作。
第一个电路:与门设计 💡
这是我们即将制作的第一个电路。它是一个非常基础的与门。准确地说,它是一个在每个输入端都带有非门的与门。这是因为当我们按下按钮时,线路会从高电平变为低电平(从1变为0)。我们希望只有当两个按钮同时被按下时,其中一个LED才会点亮。
该电路的另一种表示方法是使用真值表:
| 输入A (按钮0) | 输入B (按钮1) | 输出 (LED) |
|---|---|---|
| 0 | 0 | 1 |
| 0 | 1 | 0 |
| 1 | 0 | 0 |
| 1 | 1 | 0 |
从表中可以看到,当两个输入都为0时,输出为1,这意味着LED应该点亮。否则,LED应保持熄灭。
如果你熟悉布尔代数,可能会知道我们可以使用德摩根定律将这个等式简化为 LED = ~(pmod0 | pmod1)。但实际上我们不需要这样做。这里重要的是真值表,以及我们的代码要易于理解。

创建项目文件与引脚约束文件 📁
首先,我们需要创建一个文件夹来存放所有文件,包括Verilog文件和物理约束文件。
- 进入之前创建
icestick示例项目的目录。 - 创建一个新文件夹,命名为
and_gate。
接下来,创建引脚约束文件。
- 创建一个新的空白文本文档。
- 将其命名为与项目相同的名称:
and_gate.pcf。 - 如果操作系统询问,确认更改文件后缀名。

注意:nextpnr工具会在特定文件夹中查找第一个可用的.pcf文件。根据经验,最好不要有多个.pcf文件,因为看起来只有第一个会被使用。
用文本编辑器(如Notepad++)打开这个.pcf文件。.pcf文件的语法与Verilog完全不同,注释使用井号#。
以下是定义LED和按钮引脚的示例:
# 定义LED引脚
set_io LED0 99
# 定义PMOD IO引脚并启用内部上拉电阻
set_io --pullup yes pmod0 78
set_io --pullup yes pmod1 79

LED0对应物理引脚99,连接到iCEstick上的D1 LED。pmod0和pmod1是我们为按钮定义的网络名称,分别连接到物理引脚78和79。--pullup yes参数启用了这些引脚的内置上拉电阻。
保存这个文件。
编写第一个Verilog模块 ⌨️
现在,创建我们的Verilog源文件。
- 创建一个新文档。
- 将其重命名为与项目相同的名称:
and_gate.v。 - 用文本编辑器打开它。
我们将开始编写实际的Verilog代码。Verilog支持C风格的注释(// 和 /* */)。
首先,我们定义一个模块。模块是Verilog中的一个关键字,用于定义一个功能代码块。请记住,这不一定是顺序执行的代码,它不会逐行运行。当我们运行综合时,这个功能块会被实现为硬件。
// 当两个按钮同时按下时,点亮LED
module and_gate (
// 输入
input pmod0,
input pmod1,
// 输出
output LED0
);
module and_gate声明了一个名为and_gate的模块。模块名不必与文件名相同,但保持一致是个好习惯。- 在括号内,我们定义了模块的接口,即输入和输出。输入输出之间用逗号分隔。
- 我们命名输入为
pmod0和pmod1,输出为LED0。这些名称应与.pcf文件中创建的名称对齐。
保持输入和输出的良好组织很重要,特别是当模块有很多端口时。使用制表符或空格对齐可以使代码更易读。
接下来,完成模块的功能定义,并以 endmodule 结束。
// 连续赋值:创建一个与门,输入取反后相与,结果连接到输出
assign LED0 = ~pmod0 & ~pmod1;
endmodule
assign LED0 = ~pmod0 & ~pmod1;这是一个连续赋值语句。在Verilog或HDL中,这不是在处理器上执行的代码。相反,我们是在定义硬件。- 我们是在说:创建一个与门(
&),将pmod0和pmod1线路取反(~)后的信号进行“与”操作,然后将结果连接到我们的输出线路LED0。 - 因为按钮是低电平有效(按下时变为0),所以我们需要使用非运算符(
~)。 - “连续”意味着没有时钟,没有代码执行。你可以把它想象成在面包板上连接硬件。
保存这个文件。

构建与上传到FPGA ⚙️
现在,打开命令行终端,进入我们的项目目录。
- 首次构建前,需要运行
apio init并指定开发板型号。
这会创建一个apio init --board icestick.ini文件,告诉Apio我们使用iCEstick作为目标板。 - 运行构建命令。
如果一切顺利,你会看到构建成功的消息。暂时可以忽略关于“no clocks”的警告,我们将在后续课程中讨论时钟。apio build - 将开发板连接到电脑,然后上传比特流文件。
apio upload
上传完成后,测试电路功能。单独按下任一按钮,LED不会亮。只有同时按下两个按钮时,LED才会点亮。这表明我们的设计成功了!


FPGA内部原理:查找表 🔍
让我们花点时间了解一下FPGA内部实际发生了什么。
查看iCE40的数据手册,可以找到一个显示单个逻辑单元内部结构的框图。一个逻辑单元有两个主要部分:一个查找表和一个D触发器(我们稍后会研究D触发器)。现在让我们关注查找表。
请注意,查找表有四个输入,标记为I0到I3。下图展示了我们简单与门示例的完整真值表在查找表中的映射。我们假设 pmod0 和 pmod1 被映射到了这个特定查找表的I0和I1。

与我们最初的想象不同,查找表不是逻辑门的集合。相反,它是一块简单的内存,有16个条目,每个条目包含1位数据。
在启动时,FPGA从外部闪存读取配置数据,找到如何编程这个特定查找表中随机存取存储器(RAM)的信息。真值表的输出部分被复制到LUT的内存中。四个输入线随后被用作索引来寻址内存,因为输入的值包含了应该读取哪个内存元素的地址。这类似于使用一个16选1的多路复用器从各个内存元素中选择输出。查找表的输出是一个单比特:0或1。
实际上,在综合过程中将我们代码的组合逻辑部分转换为查找表值,正是FPGA魔力的重要体现。
进阶示例:使用向量 🎛️
现在让我们看一个稍微复杂一点的例子,并引入向量的概念。

Verilog中的向量是一种将输入、输出和其他命名的容器分组在一起的方式。这类似于在C或Python等其他编程语言中使用数组。我们不再为每个输入和输出分配单独的名称,而是将它们分组在一起。我们在上一个示例中使用的单个命名网络被称为标量。
以下是我们想要实现的功能真值表。注意,我们现在有多个输出。
| pmod[1] | pmod[0] | LED[2] | LED[1] | LED[0] | 描述 |
|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 无按钮按下 |
| 0 | 1 | 0 | 1 | 1 | 按下按钮0,点亮LED0和LED1 |
| 1 | 0 | 0 | 0 | 0 | 按下按钮1,无变化 |
| 1 | 1 | 1 | 1 | 1 | 同时按下按钮0和1,点亮所有LED |
我们可以写出布尔方程如下:
LED[0] = ~pmod[0]LED[1] = ~pmod[0]LED[2] = ~pmod[0] & ~pmod[1]
我们希望当按下 pmod[0] 按钮时,LED0和LED1点亮;当 pmod[0] 和 pmod[1] 同时被按下时,所有三个LED都点亮。
让我们在Verilog代码中实现它。
首先,修改.pcf文件以使用向量形式定义引脚。
# 定义LED引脚向量
set_io LED[4] 95
set_io LED[3] 96
set_io LED[2] 97
set_io LED[1] 98
set_io LED[0] 99
# 定义按钮引脚向量并启用上拉
set_io --pullup yes pmod[0] 78
set_io --pullup yes pmod[1] 79
注意,LED引脚95到99的顺序看起来是反的,这是因为开发板上的物理布局如此。我们定义了5个LED位,但本设计只使用0、1和2,预计在构建过程中会收到3和4未使用的警告。
接着,更新Verilog模块的接口定义。
module and_gate_vector (
// 输入:2位宽的按钮向量,pmod[1]是最高位
input [1:0] pmod,
// 输出:5位宽的LED向量,LED[4]是最高位,但我们只使用低3位
output [4:0] LED
);
input [1:0] pmod定义了一个名为pmod的2位宽输入向量,位序为[1:0](最高位在前)。output [4:0] LED定义了一个5位宽的输出向量,但我们只使用LED[0]、LED[1]和LED[2]。
现在,在模块内部使用连续赋值来实现逻辑。
// 定义一个内部连线(wire),用于存储取反后的pmod[0]信号
wire not_pmod0;
assign not_pmod0 = ~pmod[0];
// 使用复制操作:将not_pmod0的值复制两份,分别赋值给LED[1]和LED[0]
assign LED[1:0] = {2{not_pmod0}};
// 实现第三个LED的逻辑:当pmod[0]和pmod[1]都取反后相与时点亮
assign LED[2] = ~pmod[0] & ~pmod[1];
// 将未使用的LED输出置零以避免警告(可选,但更规范)
assign LED[4:3] = 2'b00;
endmodule
代码解释:
wire not_pmod0;:在模块内部定义了一个名为not_pmod0的连线。它不是一个变量(尽管这是最接近C语言的类比),而是一个命名的网络,用于在模块内部进行连接。你可以把它想象成PCB布局中命名的网络或导线。assign not_pmod0 = ~pmod[0];:assign关键字本质上是在硬件中建立一个连接。我们将取反后的pmod[0]信号连接到这个命名的网络上。assign LED[1:0] = {2{not_pmod0}};:这是Verilog中的复制操作。花括号{}通常用于连接,但这里{2{not_pmod0}}表示将not_pmod0这个信号复制两份。因此,LED[1]和LED[0]都接到了not_pmod0这个网络上。这相当于一个T型连接点,将一根线分成了两路。assign LED[2] = ~pmod[0] & ~pmod[1];:这是第三个LED的逻辑,当两个按钮同时按下(取反后相与为1)时点亮。assign LED[4:3] = 2'b00;:将未使用的LED输出显式地驱动为低电平,这是一个更规范的做法,可以避免综合工具警告。
保存并构建这个项目。由于是同一个项目,可以直接运行 apio build 而无需再次运行 apio init。预计会收到关于L3和L4未使用的警告。构建成功后,上传到板子。

测试功能:按下第一个按钮(对应 pmod[0]),LED0和LED1点亮。只有同时按下两个按钮时,第三个LED才会与前两个一起点亮。
挑战任务:实现一个全加器 🏆
你的挑战是:在Verilog中实现一个全加器,并在你的设备上运行它。

全加器是一个非常重要的电路,它执行两个比特之间的基本加法操作。它在构成中央处理器(CPU)计算部分的算术逻辑单元(ALU)中扮演着关键角色。
你可以参考维基百科的“全加器”条目,那里提供了真值表和逻辑图,帮助你理解如何在Verilog中实现它。

注意:如果你希望按钮按下代表逻辑高电平,可能需要对输入进行取反。
以下是我的iCEstick上全加器的工作情况:
- 当我按下任意一个按钮时,“和”输出LED点亮。
- 当我按下任意两个按钮时,“进位”输出LED点亮。
- 当我按下所有三个按钮时,“和”与“进位”输出LED都点亮。

总结与下节预告 📚
本节课中,我们一起学习了:
- 如何为FPGA项目创建引脚约束文件(
.pcf)。 - 如何编写一个基本的Verilog模块,使用连续赋值语句描述组合逻辑电路。
- 理解了FPGA如何利用查找表来实现组合逻辑功能。
- 引入了向量的概念,以更简洁的方式处理多位宽信号。
- 完成了一个简单的与门和一个多输出逻辑电路的实现与测试。
到目前为止,我们只研究了连续赋值,其中输出的变化或多或少会立即响应输入的变化,中间可能包含一些组合逻辑。
下一次课,我们将学习如何引入时钟和触发器,这样我们就可以存储数据并在一个时钟周期到下一个时钟周期之间传递数据。

祝你编程愉快!
004:时钟与过程赋值
在本节课中,我们将学习如何在Verilog中利用FPGA逻辑单元中的D触发器,来创建按顺序执行的过程代码。我们将通过构建一个4位计数器来演示这一概念。
上一节我们介绍了如何在Verilog中创建连续赋值块。本节中,我们来看看如何利用D触发器来编写顺序执行的代码。
D触发器简介
查看iCE40数据手册时,可以看到逻辑单元中还有一个我们尚未讨论的部分,那就是D触发器。这是一个由晶体管和电阻构成的基本数字逻辑元件。它允许我们在时钟脉冲之间存储一位信息。
在逻辑单元图中,一位输入可以来自查找表。时钟信号可以来自多个源。我们还有一个复位引脚和一个使能引脚来控制输出。单元的输出可以来自D触发器,也可以通过多路复用器绕过触发器。
以下是D触发器的简化工作原理:
- 我们有一个时钟信号作为输入之一。
- 每当D触发器检测到时钟的上升沿(也称为正边沿)时,它会采样输入端的值。
- 如果输入为低电平,则输出线变为低电平。
- 如果输入为高电平,则输出线变为高电平。
- 输出线将保持该状态,直到下一个时钟上升沿到来。
本课中我们不使用使能线。使能线用于允许或阻止在时钟边沿进行采样。如果使能线为高电平,触发器正常工作。如果使能线为低电平,无论输入线如何变化,触发器都将保持输出线上的值。
最后是复位线。如果复位线变为高电平,输出线将被驱动为低电平。这可以异步发生,意味着它不需要在时钟边沿发生。只要复位线为高电平,输出线就保持低电平,即使有上升时钟沿。
由于FPGA首次初始化时,输出线可能随机为高或低,我们可以使用复位线为触发器设置初始状态。因此,通常会看到同一个复位线连接到许多不同的触发器。
构建4位计数器
我们可以将触发器与组合逻辑结合,创建各种数字电路。例如,我们将在Verilog中创建一个4位计数器并在FPGA上测试它。
以下是计数器的逻辑图。请注意,我们实际上不需要实现加法器,因为Verilog支持加法等基本数学符号。综合工具会找出实现比特相加目标的最佳逻辑,可能是我们设计的全加器,也可能是其他组合逻辑。
我们在逻辑中添加触发器,使得在每个正时钟边沿,我们的4位数加一。如果需要递增下一个比特,则使用进位输出来通知计数器的下一级需要将其值增加一。
你可能注意到我们的输出或Q总线没有设置初始值。为此,我们需要给复位线一个脉冲,以便将输出设置为0,这将使计数器从头开始计数。

计数器时序图


以下是4位计数器的时序图。虽然FPGA首次启动时可能将Q0到Q3线初始化为低电平,但最好不要假设它们一定是低电平。因此,在给出复位脉冲之前,我们认为这些线的状态是未知的。
一旦复位线变为高电平,整个总线被设置为0。然后在每个上升时钟边沿,Q值加一。这使我们能够使用四条线和一些数字逻辑创建一个从0到15的计数器。当输出为15(二进制1111)时,下一个正时钟边沿将导致计数器复位或回滚到0。
计数器是非常有用的电路,用于计时事件或构成脉宽调制信号的基础。如果你使用过微控制器,可能遇到过计数器(也可称为定时器)。它们是几乎完全像这样操作的硬件逻辑,允许我们设置定时事件,在特定间隔发生,而无需浪费CPU周期。我们还可以生成硬件中断信号,每当定时器达到某个值或发生回滚事件时触发。
在Verilog中实现4位计数器
和之前一样,我们进入包含项目的文件夹,为要创建的新项目创建一个新文件夹,命名为button_counter。进入该文件夹,创建物理约束文件button_counter.pcf。
约束文件内容与之前类似。我们将创建一个连接到LED的引脚向量(一个4位宽的总线),并对两个按钮(特别是连接到物理引脚78和79的按钮)做同样处理,创建一个总线或向量。
接着创建Verilog文件。我们将创建一个加载到FPGA上的模块。目标是按下按钮时,将其作为时钟信号,每次按下按钮计数器加一,计数值以二进制形式显示在LED上。
我们声明按钮引脚(PMOD引脚0和1)为输入,它们将作为时钟信号或敏感信号。注意,我们将LED引脚声明为reg类型。reg(寄存器)关键字告诉综合工具,我们希望将这些线连接到D触发器,这意味着它们将成为过程赋值的一部分。
和之前一样,我们声明几条wire,因为想重命名按钮,并假设按钮信号(按下按钮)将像时钟信号一样工作,只是由按钮控制其高低电平切换。同样,复位信号也是如此。拥有一个公共复位信号是个好主意,这样当开始一个always块时,它可能不知道某些变量应处于什么状态。
我们想让复位和时钟信号为高电平有效,这与按钮的工作方式相反。因此,我们进行连续赋值,第一个按钮的信号通过一个非门,成为复位信号。对时钟信号也做同样的处理,只是重命名第二个按钮的反相信号。
我们使用always关键字定义过程块或过程代码。此块内的任何内容都顺序执行,类似于C或Python等编程语言的执行方式,但由于信号可以通过D触发器传播,事情按顺序发生。然后我们需要定义一个敏感列表,告诉硬件信号应如何传播或何时传播,这是通过使用时钟信号或复位信号来完成的,它寻找这些信号的上升沿或下降沿,以执行always块内的内容。
第一种情况,我们定义正边沿,即时钟信号的上升沿。这里,它是按钮按下的反相。所以当我们按下按钮时,根据我们创建的这条线,它算作一个上升沿,并将执行always块内的任何内容。
现在需要指出,由于这些简单按钮存在大量去抖动问题,每次按钮按下可能会执行多次,这是预期行为。在FPGA中实现按钮去抖动是可行的,如果你想挑战,可以尝试解决按钮去抖动问题,但这不会是本课的挑战。
一个always块可以根据多个信号的输入来执行。这里,时钟上升沿或复位上升沿都会导致此块执行。注意,你也可以使用负边沿。
begin在这里的作用类似于C或C++中的花括号,表示always块的内容。然后我们创建一个if语句,其工作方式与你在C或Python中见过的if语句非常相似。
这里,条件在括号内给出。如果复位信号等于1,则执行if语句下的内容。注意,Verilog中的常量可能有点奇怪,通常需要定义位数,因为这决定了进行比较所需的线或导线数量。这里只需要一位,复位只能是0或1。
然后我们说用二进制定义,这是1。所以这可以是1‘b0或1’b1,这是一位宽二进制数的唯一选项。这里我们希望它是1。我们调用begin,并在其下缩进以便阅读。这里是我们对LED总线或向量的赋值。所以如果有复位信号变为高电平(这里是按钮按下),那么我们希望将4位宽的二进制数0(也可以更明确地写成4‘b0000)赋值或加载到LED总线中。这里我们有4位用于LED[3:0]。记住我们将其声明为reg,所以可以进行此赋值。
我们使用这个特殊的<=运算符,它不代表小于等于,而是表示将这个数字加载到这个总线中。所以4‘b0000意味着我们LED阵列上的所有线都将变为低电平。
我们用end结束那个if语句。正如你可能预期的,我们有一个else语句,并再次用begin打开它。这基本上等同于这样做,但该语法在Verilog中无效,所以我们用else begin。
如果因为复位线变高而执行此块,那么它应该执行第一个if部分,因为复位线为高。或者,如果因为时钟变高而执行此块,并且你仍然按住复位线(即按住按钮),那么0将持续加载到这个LED总线中。
然而,如果复位线为低(即你没有按下按钮)且时钟变高(对我们来说就是另一个按钮),那么我们想增加该总线上的值。所以这个值被读取,这里一个二进制1被加到该值上,然后存回LED。这就是我们在这里使用这种赋值运算符风格的部分原因,因为这都是寄存的。它不像导线那样连续发生。这样做不行,你可能会将低线连接到高线,这不好。所以我们使用这种顺序或过程赋值风格,表示:读取这个LED值(这里是总线上的4位值),加1,然后存回该值,因为它是寄存的。这样工作正常。
注意,这里的+运算符有效。综合工具知道如何处理基本加法,这意味着综合工具能够创建某种加法器电路来实现此功能。它可以通过多种不同方式实现这个加法电路,可能是你作为上一个挑战设计的全加器,也可能是其他东西。但综合工具知道如何处理+,这很好,它可以创建执行各种算术运算所需的逻辑电路。请记住这一点,并建议查阅一些Verilog语法,看看它支持哪些操作。但请记住,很多都取决于综合工具能支持什么。这里,加法没问题。
我们结束if-else块,然后结束always块。在这里可以看到,我们可以将连续赋值(创建一些逻辑电路)和这些过程赋值结合到一个功能模块中,加载到我们的FPGA上。
保存文件后,我们打开命令提示符,进入刚创建的button_counter示例文件夹。调用apio init -b iTick来定义开发板并创建初始化文件。然后告诉它构建,希望综合工具正常工作且不抛出任何错误。最后用apio upload将其发送到已插入并通电的iTick开发板。
测试与挑战

上传成功后,让我们测试它。每当时钟信号变低时,计数值递增。我也可以按下复位按钮来重启计数器。注意,由于按钮去抖动,每次按下按钮时,我的计数器可能会跳过几个值。我们现在不处理按钮去抖动问题。

使用按钮作为时钟信号似乎有点傻,但它是一个有用的演示。查看iCE40数据手册,可以看到开发板上有一个12 MHz振荡器。这个振荡器连接到FPGA的物理引脚21,这应该是一个更好的时钟信号。

你可以在PCF文件中给时钟信号命名,就像我们对FPGA上的所有其他IO引脚所做的那样。这将给我们一条以12 MHz速率切换的线。
你的挑战是为这条线创建一个时钟分频器,并用它来驱动你的计数器,而不是使用按钮。你的分频器输出应该是一个1 Hz的方波,以便你的计数器每秒递增一次。提示:你可能需要创建第二个计数器来实现这一点。另外请注意,你可以在Verilog模块中拥有多个always块。

祝你好运,我们都指望你了。在下一集中,我们将看看状态机。祝你编程愉快。

总结

本节课我们一起学习了FPGA中D触发器的工作原理,以及如何在Verilog中使用always块和过程赋值(<=)来创建顺序执行的逻辑。我们通过一个使用按钮作为时钟的4位计数器实例,演示了复位、时钟敏感列表以及寄存器类型变量的使用。最后,我们提出了一个挑战:利用板载12MHz晶振,通过时钟分频生成1Hz信号来驱动计数器,这需要你运用多个always块和计数器来实现。
005:有限状态机 🧮
在本节课中,我们将要学习有限状态机(FSM)这一核心概念。有限状态机是用于表示顺序控制流的数学模型,在FPGA设计中能帮助你组织思路并保持代码整洁有序。我们将探讨两种主要类型的状态机:摩尔型和米利型,并通过Verilog代码示例来理解它们的实现与区别。
概述
有限状态机包含一个或多个离散状态,代码或硬件在这些状态之间转移。与处理器不同,FPGA没有“等待”的概念,因此我们需要构建能够延迟操作的硬件逻辑。状态机提供了一种基于输入条件顺序执行操作的简单结构。
摩尔型状态机 🏗️

上一节我们介绍了状态机的基本概念,本节中我们来看看第一种类型:摩尔型状态机。在摩尔型状态机中,输出仅与当前状态有关,而与输入无关。
我们将构建一个简单的计数状态机,计数完成后会输出一个脉冲信号。以下是我们需要定义的状态:
- 空闲状态:状态机等待启动信号。
- 计数状态:计数器从零开始递增。
- 完成状态:输出一个脉冲信号,然后返回空闲状态。
硬件设计与代码实现
以下是该状态机的核心Verilog代码结构。我们使用多个always块来并行处理不同功能,例如时钟分频和状态转移。
// 状态定义
localparam STATE_IDLE = 2'd0;
localparam STATE_COUNT = 2'd1;
localparam STATE_DONE = 2'd2;
// 状态寄存器
reg [1:0] state, next_state;
// 状态转移逻辑(时序逻辑)
always @(posedge clk_div or posedge reset) begin
if (reset) begin
state <= STATE_IDLE;
end else begin
state <= next_state;
end
end
// 下一状态逻辑(组合逻辑)
always @(*) begin
case (state)
STATE_IDLE: begin
if (go) next_state = STATE_COUNT;
else next_state = STATE_IDLE;
end
STATE_COUNT: begin
if (led_counter == MAX_COUNT) next_state = STATE_DONE;
else next_state = STATE_COUNT;
end
STATE_DONE: begin
// 无条件返回空闲状态
next_state = STATE_IDLE;
end
default: next_state = STATE_IDLE;
endcase
end
// 输出逻辑(仅取决于当前状态 - 摩尔型特点)
assign done = (state == STATE_DONE);
在摩尔型状态机中,输出信号(如done)仅在当前状态为STATE_DONE时置为高电平,并持续一个时钟周期。
阻塞赋值与非阻塞赋值 ⚙️
在编写状态机时,理解赋值操作符至关重要。Verilog中有两种赋值方式:
-
阻塞赋值 (
=):顺序执行。该行赋值完成后,才执行下一行代码。通常用于组合逻辑的always块中。// 示例:执行后,a和b的值都变为1 always @(posedge clk) begin a = b; b = a; // 此时a已是新值(等于b的旧值) end -
非阻塞赋值 (
<=):并行执行。块内所有赋值语句同时计算右侧表达式,然后在时间步结束时统一更新左侧变量。推荐在时序逻辑的always块中使用。// 示例:执行后,a和b的值互换 always @(posedge clk) begin a <= b; b <= a; // 同时计算,a和b使用彼此原来的值 end
米利型状态机 🔄
现在,让我们看看第二种类型:米利型状态机。它与摩尔型的关键区别在于,其输出不仅取决于当前状态,还取决于当前的输入。
这意味着输出可以写在状态转移箭头上。有时,米利型状态机可以用更少的状态实现相同的功能。例如,我们的计数例子可以省去独立的完成状态,在从计数状态转移到空闲状态的瞬间产生输出脉冲。
代码转换
将摩尔型转换为米利型,主要修改输出逻辑部分:
// 状态定义(减少一个状态)
localparam STATE_IDLE = 1'b0;
localparam STATE_COUNT = 1'b1;


// 输出逻辑(取决于当前状态和输入 - 米利型特点)
always @(*) begin
case (state)
STATE_IDLE: begin
done = 1'b0; // 空闲时输出低
end
STATE_COUNT: begin
// 在计数状态,当计数器达到最大值时(可视为一个条件输入),输出高
// 注意:这里done作为转移时的输出
if (led_counter == MAX_COUNT)
done = 1'b1;
else
done = 1'b0;
end
// 注意:没有独立的STATE_DONE
endcase
end
// 状态转移逻辑也需要调整
always @(*) begin
case (state)
STATE_IDLE: begin
if (go) next_state = STATE_COUNT;
else next_state = STATE_IDLE;
end
STATE_COUNT: begin
if (led_counter == MAX_COUNT)
next_state = STATE_IDLE; // 直接回到空闲,而不是完成状态
else
next_state = STATE_COUNT;
end
default: next_state = STATE_IDLE;
endcase
end

米利型状态机有时硬件利用率更高,但逻辑可能稍难理解。摩尔型状态机则更直观,易于调试。
实践挑战:按键消抖 🎯
我们可以运用状态机解决一个实际问题:按键消抖。回顾第4部分的简单计数器,由于机械按键抖动,按一次可能触发多次计数。
你的挑战是:设计一个状态机来实现按键消抖逻辑,确保每次按下按键,计数器只增加一次。你可以选择使用摩尔型或米利型状态机。
消抖状态机通常包含以下状态:
- 等待:等待按键按下。
- 消抖:按键按下后,进入一个延时状态(如20ms),过滤抖动。
- 确认:延时结束后确认按键有效,产生单次脉冲信号。
- 释放等待:等待按键释放,同样可能需要消抖处理。
总结


本节课中我们一起学习了有限状态机(FSM)在FPGA设计中的重要作用。我们详细探讨了:
- 摩尔型状态机:输出仅与当前状态有关。
- 米利型状态机:输出与当前状态和输入都有关,有时效率更高。
- Verilog实现关键:包括状态编码、多
always块设计、阻塞与非阻塞赋值的正确使用。 - 实际应用:通过将状态机应用于按键消抖挑战,巩固了学习内容。

状态机是构建复杂数字逻辑和控制流程的基石,熟练掌握它们将极大提升你的FPGA设计能力。下一课,我们将学习如何使用模块和参数来创建复杂的层次化设计。
006:Verilog模块与参数 🧩
概述
在本节课中,我们将学习如何将Verilog代码组织成模块,并使用参数来定制模块的行为。通过模块化设计,我们可以创建可重用的代码块,从而构建更复杂的数字系统。
从单一模块到模块化设计
到目前为止,我们一直将所有Verilog代码放在单个文件的单个模块中。对于非常简单的设计来说,这没有问题。但可以想象,随着代码量的增加,这种方式会很快变得难以控制且难以调试。本节我们将学习如何创建模块化代码,以便开始构建更复杂的设计。
在之前的章节中,我提出了一个挑战:创建一个驱动计数器的时钟分频器。我们将专门为这个时钟分频器创建一个模块,以便它能独立于其他逻辑使用。
为基本构建块创建模块的一大优势是,我们可以轻松地实例化该模块的多个副本,从而避免重写或复制粘贴代码。通常,建议将一个Verilog模块的代码放在单个文件中,这样便于查找和调试模块中的错误。
因此,我们的时钟分频器代码将放在一个文件中,并实例化它的两个副本(也称为实例)。为此,我们需要另一个文件来存放我们的顶层设计。这个顶层模块将实例化两个具有不同参数的时钟分频器,并包含一些必要的“胶合”逻辑,例如反转复位按钮信号,以及将输入和输出连接到我们的物理引脚名称。

通过允许顶层模块使用不同参数实例化时钟分频器,我们可以在实例化时改变每个分频器的功能,而无需添加额外的信号线或逻辑。请注意,我将两个分频器的输出合并到一个总线中。在图表中,有时会用较粗的线表示总线,或者用一条斜线加数字来标明该总线包含多少条线。
在本例中,我们将设置分频器的参数,使两个输出LED以不同的速率闪烁。让我们用Verilog来实现这个设计。
创建时钟分频器模块
首先,为我们的项目创建一个新文件夹,我将其命名为 clock_dividers_1。我们将在这里展示两个不同的例子。
以下是创建基本模块的步骤,如果你做过任何面向对象编程,这应该与创建一个类非常相似。我们将能够创建这个模块的实例。
创建模块的声明与我们之前做过的所有其他例子非常相似。我们将声明端口,在本例中是输入和输出。请注意,输出需要被寄存,因为我们将使用时钟来控制它,并在模块的输出端存储其信号电平。
之前我们使用 localparam 来为模块定义常量。现在我将引入一个名为 parameter 的新关键字。localparam 仅在模块内部定义,不能在模块外部更改。而 parameter 则不同,我们在这里声明它,它会创建一个初始值,可以在整个模块中用作常量。然而,参数可以在模块外部更改。在我们的顶层设计中,当实例化一个特定对象(或更准确地说,是这个模块的一个实例)时,我们将能够更改这些参数。
我在这里为每个参数设置默认值。这样,如果在实例化此特定模块时没有定义它们,它们仍然会获得某个值。请注意,我可以使用参数来定义总线宽度。例如,COUNT_WIDTH 我设置为24位。因此,当我创建 MAX_COUNT 值时,我可以声明该总线的宽度,在本例中是24位,因为我使用这个变量(更确切地说是常量)来声明这个向量的宽度。
和以前一样,我们可以声明内部信号,如 wire 和 reg,以帮助存储值,或者为模块内的特定连线命名。如果你完成了之前关于计数器的挑战(需要创建自己的时钟分频器),那么这里的大部分代码应该看起来很熟悉。实际上,我使用的代码与我的解决方案中的代码相同。唯一的区别是,这里没有任何反转复位信号的逻辑,因为现在我假设它是高电平有效。我们将把反转该信号的任务留给顶层设计,因为我们将把它连接到一个按钮上(但如果你使用时钟分频器,情况可能并非总是如此)。
代码示例:时钟分频器模块 (clock_divider.v)
module clock_divider #(
parameter COUNT_WIDTH = 24,
parameter MAX_COUNT = 12000000 - 1
)(
input wire clk,
input wire rst,
output reg out
);
reg [COUNT_WIDTH-1:0] counter;
always @(posedge clk) begin
if (rst) begin
counter <= 0;
out <= 0;
end else begin
if (counter == MAX_COUNT) begin
counter <= 0;
out <= ~out;
end else begin
counter <= counter + 1;
end
end
end
endmodule
创建物理约束文件
接下来,我需要创建物理约束文件。和之前一样,我将声明几个命名的信号或连线,并将它们与FPGA上的物理引脚关联起来。
在本例中,我将连接到物理引脚21的线命名为 clk,因为它连接到iCE40板上的12 MHz振荡器。我还为我的LED(引脚98和99)连接了两个引脚。最后,我有一个复位按钮,它使用了我们在前几集中使用的四个按钮的相同硬件连接,但这里只需要一个,即复位按钮。
代码示例:物理约束文件 (top.pcf)
set_io clk 21
set_io rst 99
set_io led0 98
set_io led1 97
编写顶层设计
现在让我们来编写顶层设计。和任何模块一样,我将声明模块名称及其端口(输入和输出)。请注意,即使我的时钟分频器模块的输出是寄存的,我也不能将其直接连接到另一个寄存元件。我只能将模块的输出连接到 wire。这是因为模块的输出可能随时改变(类似于按钮按下)。然后,你可以寄存那个输出,但必须通过类似 always 块的东西连接它,然后用时钟驱动该输出并将其保存在一个寄存元件中。
正如我们在图中看到的,我们将获取复位按钮并在这里创建一点胶合逻辑:我们只是反转该信号,称其为 rst,然后将该信号发送给我们两个实例化的时钟分频器。
要实例化一个模块,首先调用模块名称 clock_divider(这应该与 clock_divider.v 文件中的模块名一致)。综合工具接受我们提供的Verilog文件。在本例中,Apio会将它在项目文件夹中找到的所有文件(本例中是 clock_divider.v 和 top.v)发送给Yosys,Yosys会识别出:我在这里调用 clock_divider 来实例化模块,这与我在此另一个文件中找到的 clock_divider 模块名称一致。
目前,我建议将一个项目的所有文件保存在同一文件夹中,以便Apio能找到它们。然而,你可能会遇到更复杂的设计,其中不同的模块和测试平台存储在不同的文件夹中,你必须将它们整合在一起。在那种情况下,你可能需要直接调用Yosys并指定这些源文件的位置。
回到我们的顶层设计。一旦我们调用了该模块并赋予它一个名称(本例中我称它为 div1 代表分频器一),我们然后将这个顶层设计内部的连线和寄存元件连接到被实例化模块内部的输入和输出。为此,我们使用 .端口名(连线名) 的语法。例如,.clk(clk) 表示时钟分频器模块内的 clk 端口连接到顶层设计中的 clk 连线。这两者现在通过一根线连接在一起。我们将对时钟分频器中的复位信号做同样的处理,它连接到 rst 连线(这是复位按钮信号的反相信号)。
为了设置这个实例化单元(或模块)中的参数,我们将使用 defparam 关键字。在本例中,我们将定义仅在 div1 实例化模块中找到的 COUNT_WIDTH 参数,将默认的24位覆盖为32位(实际上这里并不需要32位,但假设出于某种原因你希望它是32位宽)。我们还将用 15000000-1 覆盖 MAX_COUNT 的默认参数值,这应该使LED的闪烁速度比此处设置的默认值快一些。
请注意,这是一种定义参数的旧方法。我将向你展示新的方法(随着2001版Verilog更新),但这种方式可以让你了解 defparam,以防你在其他人的代码中遇到它。
现在,我将实例化第二个时钟分频器模块,称它为 div2。用于实例化的代码看起来与实例化第一个时钟分频器模块的代码相同,但我们将输出连接到 led1。你会注意到,它与第一个时钟分频器共享时钟和复位线。我们也不会为 div2 定义参数,这意味着它应该使用我们在时钟分频器源代码中设置的默认参数。
代码示例:顶层设计 - 旧式参数传递 (top_old.v)
module top (
input wire clk,
input wire rst_button,
output wire led0,
output wire led1
);
wire rst;
assign rst = ~rst_button; // 反转复位按钮信号(假设按钮低电平有效)
// 实例化第一个时钟分频器 (div1) 并使用 defparam 覆盖参数
clock_divider div1 (
.clk(clk),
.rst(rst),
.out(led0)
);
defparam div1.COUNT_WIDTH = 32;
defparam div1.MAX_COUNT = 15000000 - 1;
// 实例化第二个时钟分频器 (div2),使用默认参数
clock_divider div2 (
.clk(clk),
.rst(rst),
.out(led1)
);
endmodule

构建与上传
我们将打开一个终端,导航到我们的 clock_dividers 项目目录。我将调用 apio init --board icestick 来指定我的开发板。验证代码以确保理论上应该能综合是一个好习惯。然后我们将调用 apio build。综合完成后,如果没有错误,我们将把设计上传到我们的开发板。
如果你使用Windows,可能会遇到“LibUSB open failed”的错误。如果你已经使用Zadig安装了驱动程序并且一切正常,但突然发现它不再工作,可能有两种情况:首先,检查你是否没有插入其他FTDI设备(例如我的Analog Discovery)。确认后,尝试将iCEstick(或你用于此的任何FTDI设备)移到最初安装驱动程序的USB端口。Windows喜欢记住你将特定USB设备插入哪个端口,然后当设备插入该端口时总是使用该驱动程序。所以,尝试将其移到不同的端口,看看是否再次工作。
如果更换端口仍然不行,你可能需要使用 apio drivers --ftdi-enable 重新安装驱动程序。使用下拉列表,确保为你的FPGA开发板选择了 Interface 0。在这种情况下,Windows可能试图使用FTDI总线驱动程序,而我们希望将其切换回 libusbK。点击“替换驱动程序”并让其安装。
一旦完成,apio upload 理论上应该可以工作,并将我综合后的设计发送到我的FPGA。正如你所看到的,两个LED以不同的速率闪烁。第一个应该以大约4 Hz的频率闪烁,第二个应该以大约1 Hz的频率闪烁。
ANSI风格的参数定义
2001版Verilog引入了一种定义和使用参数的新方法。它更接近于C语言中参数在函数间传递的方式。因此,这种风格在Verilog中通常被称为ANSI风格参数或C风格参数。我们来使用它们。
回到我们的时钟分频器模块。我们不在模块内部定义参数,而是将其定义为一组额外的、类似于端口列表的东西,只不过这是一个在端口之前定义的参数列表。我们用井号 # 和一组新的括号来表示这个参数列表。端口列表被推到下面,因为我们先定义参数。就像我们看到端口一样,参数以列表样式定义,每个参数用逗号分隔(而不是我们之前使用的分号)。我们仍然可以使用等号为它们提供默认值。
代码示例:时钟分频器模块 - ANSI风格 (clock_divider_ansi.v)
module clock_divider #(
parameter COUNT_WIDTH = 24,
parameter MAX_COUNT = 12000000 - 1
)(
input wire clk,
input wire rst,
output reg out
);
// ... 模块内部逻辑与之前相同 ...
endmodule

有了这种ANSI风格的参数定义,你不再需要在这里调用 defparam 关键字。相反,你可以在声明实例化模块的名称之前,使用另一个井号 # 和另一组括号。在这个新列表中,我们将再次使用点符号,将 COUNT_WIDTH 设置为32,将 MAX_COUNT 设置为 15000000-1。
代码示例:顶层设计 - ANSI风格参数传递 (top_ansi.v)
module top (
input wire clk,
input wire rst_button,
output wire led0,
output wire led1
);
wire rst;
assign rst = ~rst_button;
// 实例化第一个时钟分频器,使用ANSI风格覆盖参数
clock_divider #(
.COUNT_WIDTH(32),
.MAX_COUNT(15000000 - 1)
) div1 (
.clk(clk),
.rst(rst),
.out(led0)
);
// 实例化第二个时钟分频器,使用默认参数
clock_divider div2 (
.clk(clk),
.rst(rst),
.out(led1)
);
endmodule
验证代码,如果看起来没问题,就构建它并发送到FPGA。如果一切顺利,它的工作方式应该和之前完全一样。确实,LED的闪烁方式与之前完全相同。
本课挑战 🎯
本部分的挑战是创建一个模块化设计,使LED先向上计数,然后向下计数。有多种方法可以实现这一点,但我建议使用我们刚刚制作的时钟分频器来驱动两个不同的计数状态机,这两个状态机被实例化为模块。当一个模块完成向上计数时,它向另一个模块发送一个信号,后者将开始向下计数。你必须思考胶合逻辑应该是什么样子,以便让两个不同的模块控制同一组LED。


祝你好运,这个挑战可能有点棘手。下次,我们将学习如何使用模块化代码创建测试平台。能够模拟我们的设计可以避免在实际硬件上调试的许多麻烦。
总结
在本节课中,我们一起学习了:
- 模块化设计的重要性:将代码组织成独立的模块,提高可重用性和可维护性。
- 创建Verilog模块:如何定义模块的端口、内部信号和行为。
- 使用参数:利用
parameter关键字创建可配置的模块,区分了parameter(可在实例化时覆盖)和localparam(模块内常量)。 - 模块实例化:如何在顶层设计中实例化其他模块,并通过
.端口名(连线名)语法进行连接。 - 参数传递的两种方式:
- 旧式:使用
defparam关键字。 - ANSI风格(推荐):在实例化时使用
#(.参数名(值))的语法,更清晰、更现代。
- 旧式:使用
- 实践流程:从编写模块、约束文件到顶层设计,最后进行综合、构建和上传到FPGA的完整过程。

通过掌握模块和参数,你已经迈出了构建复杂、结构化数字系统的关键一步。
007:Verilog测试平台与仿真 🧪

在本节课中,我们将学习如何为Verilog模块编写测试平台(Testbench),并使用仿真工具来验证硬件设计的正确性,而无需每次都将其烧录到实际的FPGA硬件上。这是一种高效发现和修复设计缺陷的方法。

概述
在之前的第5集中,我们的有限状态机代码存在一个错误。当时我们是在人类可感知的时间尺度上操作,LED多亮一个时钟周期并不明显。然而,在严格的时序要求下,一个时钟周期的偏差可能导致错过采样周期或无意中降低系统速度。为了调试运行在千赫兹或兆赫兹频率的硬件,通常需要示波器或逻辑分析仪等设备。
将设计综合并上传到FPGA需要时间。我们可以通过编写测试代码来模拟模块,并用波形查看器观察输出,从而节省大量调试时间。本节将展示如何创建基本的Verilog测试平台,使用Icarus Verilog进行仿真,并使用GTKWave查看输出波形。


测试平台基础

与顶层设计类似,测试平台会实例化我们想要测试的模块。这个被测试的模块通常被称为“被测单元”(Unit Under Test, UUT)。一个测试平台可以同时测试多个模块,但本节我们只测试一个。
在上一集中,顶层设计用于连接多个模块,并将输入/输出信号连接到物理引脚。测试平台是纯仿真的,没有物理引脚,因此我们使用测试平台逻辑来驱动输入信号。我们可以添加一些“胶合逻辑”,但主要关注的是切换测试模块的输入引脚,以便测量输出波形是否符合预期。

需要注意的是,在测试平台中,我们可以使用Verilog中不一定可综合的部分。例如,我们可以使用延迟和for循环来创建波形。然而,综合工具无法处理这些命令,因为它们并非用于转换为FPGA上的实际硬件。
仿真流程
以下是仿真设计的基本步骤:
- 编写或复制要测试的模块。
- 编写测试平台:使用Verilog实例化模块,并切换必要的信号以执行测试。
- 使用Icarus Verilog仿真:仿真器将输出一个“值变化转储”(Value Change Dump, VCD)文件,记录测试中所有信号的变化。
- 查看波形:在波形查看器(如GTKWave)中加载VCD文件,查看信号变化。
请注意,通常需要了解你使用的仿真器类型,因为这会影响测试平台中使用的某些函数和关键字。
动手实践:为时钟分频器创建测试平台
让我们通过一个具体例子来实践。我们将为之前创建的时钟分频器模块编写一个测试平台。
首先,进入项目目录并创建一个名为testbench的文件夹。我们将测试之前编写的时钟分频器模块。将该模块的Verilog文件复制到测试平台项目中。
接下来,创建测试平台文件,命名为clock_div_tb.v。使用_tb后缀是因为一些工具(如本项目使用的F4PGA工具链)会识别此后缀以表明这是一个测试文件。
以下是测试平台代码的详细解析:

1. 定义时间尺度
`timescale 1ns/10ps
timescale编译器指令用于设置仿真时间单位。1ns表示时间单位为1纳秒,10ps表示仿真精度为10皮秒。这意味着代码中的延迟(如 #41.667)将以纳秒为单位,并且时间值会四舍五入到最近的10皮秒。这个指令是Icarus Verilog特有的,不同仿真器可能语法不同。

2. 定义测试模块
module clock_div_tb;
// 内部信号声明
reg clk = 0;
reg rst = 0;
wire out;
测试平台模块没有输入输出端口列表,因为所有信号都在内部。我们定义了寄存器clk和rst来驱动被测单元的输入,并定义了线网out来观察其输出。注意,我们为寄存器设置了初始值0,这在仿真中是允许的,但在可综合代码中需谨慎使用。
3. 定义仿真参数
localparam DURATION = 10000;
DURATION定义了仿真运行的总时间单位数(这里是10000个1纳秒,即10微秒)。限制仿真时间可以防止VCD文件无限增大。
4. 生成时钟信号
always begin
#41.667 clk = ~clk;
end
这个always块没有敏感列表,在仿真中它会永远运行。它每隔41.667纳秒就翻转一次clk信号,从而生成一个周期约为83.334纳秒(频率接近12MHz)的时钟。这种使用#延迟的代码是不可综合的,仅用于仿真。
5. 实例化被测单元
clock_div #(
.COUNT_WIDTH(4),
.MAX_COUNT(6)
) uut (
.clk_in(clk),
.rst_in(rst),
.clk_out(out)
);
这里实例化了clock_div模块,并覆盖了其默认参数。我们将计数器宽度设为4位,最大计数值设为6。在硬件中,这会使输出时钟每6个输入时钟周期翻转一次(理论周期500纳秒)。我们将其命名为uut。
6. 控制复位信号
initial begin
#10 rst = 1;
#1 rst = 0;
end
initial块在仿真开始时只执行一次。这里,我们在仿真开始10纳秒后将rst拉高,保持1纳秒后再拉低,产生一个复位脉冲。initial块通常只用于仿真代码。
7. 设置波形转储
initial begin
$dumpfile("clock_div_tb.vcd");
$dumpvars(0, clock_div_tb);
#(DURATION) $display("Finished!");
$finish;
end
endmodule
另一个initial块用于控制仿真过程。
$dumpfile:指定输出的VCD文件名。$dumpvars(0, clock_div_tb):指示仿真器记录clock_div_tb模块及其所有子模块(层级0表示所有层级)中的所有信号变化。#(DURATION):等待定义的仿真时长。$display:在控制台打印信息。$finish:结束仿真。务必添加此语句,否则仿真可能不会停止。
运行仿真与查看结果

保存测试平台文件后,在终端中进入项目目录,使用工具链命令(例如opio sim)运行仿真。该命令会调用Icarus Verilog进行仿真,生成VCD文件,并自动用GTKWave打开。
在GTKWave中:
- 左侧面板找到测试平台模块(
clock_div_tb)及其内部的被测单元(uut)。 - 选择想要观察的信号(如
clk、rst、out以及uut内部的counter),点击“Insert”将其添加到波形视图。 - 使用鼠标滚轮缩放,拖动波形进行查看。
- 可以添加标记(Marker)来测量时间间隔。例如,测量输出时钟
out的周期。
通过观察波形,我们发现了一个问题:输出时钟的周期略大于500纳秒。将标记拖动到理论上的500纳秒位置,发现out信号并未翻转。这表明我们的计算有误。
问题根源:计数器从0开始计数。当MAX_COUNT设为6时,计数器需要经历0,1,2,3,4,5,6(共7个状态)才会使out翻转,这需要7个时钟周期,而不是6个。
解决方案:将参数设置为MAX_COUNT(6-1)或MAX_COUNT(5)。这样,计数器从0数到5(共6个状态)后翻转,周期正好是6个输入时钟周期。
修改测试平台中的参数后,重新运行仿真。现在可以看到输出时钟的周期准确地为500纳秒了。
保存GTKWave设置


在GTKWave中配置好要显示的信号后,可以点击File -> Save Waveform As...保存为一个.gtkw文件。下次用GTKWave打开同一个VCD文件时,可以点击File -> Read Save File...加载这个.gtkw文件,自动恢复之前的信号布局和视图设置,非常方便。

挑战任务
你的任务是:为第5集中编写的按钮消抖代码编写一个测试平台并进行仿真。
要求:
- 每次“递增”按钮被按下(信号从高到低并保持一段时间),LED计数器应加1。
- 你可能需要修改消抖代码,使其更适合作为模块测试(例如,将最大时钟计数值改为参数)。
- 尝试查看状态机的内部状态。
- 提示:为了不让VCD文件过大,可以将消抖的稳定时间参数(如40毫秒)在测试中改为一个较小的值(如400微秒)。
- 进阶提示:测试平台中可以使用
for循环和integer类型变量。你还可以研究如何使用$random生成随机数,来模拟按钮的抖动或噪声。



总结
本节课中,我们一起学习了Verilog测试平台的编写与仿真。我们了解了测试平台的基本结构,如何生成时钟和复位信号,如何实例化被测单元,以及如何使用系统任务$dumpvars来记录波形。通过使用Icarus Verilog和GTKWave,我们成功地发现并修复了时钟分频器模块中的一个时序错误。仿真技术是硬件设计,尤其是FPGA设计中极其重要的一环,它能帮助我们在设计早期快速验证功能、排查问题,从而大大提高开发效率。


在下一集中,我们将探索如何在FPGA中使用各种类型的存储器来存储数据。祝你好运,一如既往,享受创造的乐趣!
008:存储器与块RAM
在本节课中,我们将要学习如何在FPGA中存储数据。我们将探讨使用逻辑单元中的D触发器、查找表构成的分布式RAM,以及FPGA中专门的块RAM。重点是学习如何用Verilog代码描述块RAM,并让综合工具自动推断出硬件结构。
概述:FPGA中的数据存储方式
数据在FPGA中可以通过多种方式存储。
正如之前提到的,你可以使用逻辑单元中的D触发器。然而,每个D触发器只能存储1比特数据。如果需要存储超过几个字节的数据,将会消耗大量逻辑单元。
在某些情况下,你也可以将数据存储在查找表中。如果不需要很大的存储空间,这被称为分布式RAM。你的综合工具可能会决定使用一些查找表来创建分布式RAM,而不是使用块RAM。
大多数FPGA提供独立于可编程逻辑块的RAM块。这些有时被称为嵌入式块RAM或EBR。
你可以将它们配置为单端口、双端口或先入先出存储器块,并且可以设置不同的宽度和深度。例如,你可以创建256个元素,每个元素16比特宽。这将占用一个4K的RAM块。你也可以将块配置为2比特宽、2048个元素深。
这个配置图表对于确定地址和数据端口需要多少比特宽非常有用。
块RAM的硬件结构

大多数FPGA厂商会为每个FPGA系列提供存储器使用指南。如果你正在使用器件内部的存储器块,强烈建议查阅这份指南。
让我们来看看iCE40系列的嵌入式块RAM。下图展示了双端口RAM在FPGA硬件上的实现方式。

请注意,你可以在独立的数据总线上进行读写操作,读写地址也有不同的总线,你甚至可以使用独立的时钟进行读写。为了简化,我们不会使用掩码或时钟使能线,并且将使用同一个时钟进行读写。


我们不会深入探讨先入先出配置,但要知道这些配置在某些设计中非常有用。附录A提供了一些创建单端口和双端口存储器配置的示例代码,如果需要入门帮助,建议查看。
在Verilog中实现块RAM
上一节我们介绍了块RAM的硬件结构,本节中我们来看看如何用Verilog代码来描述它。

如果你使用像iCEcube2或开源工具链这样的图形化开发环境,通常可以将存储器元素拖放到画布上,然后配置该元素并将其连接到设计的其他部分。
有时,你的综合工具允许你直接使用系统函数或任务来实例化块存储器。在这种情况下,你需要遵循特定综合软件的说明。
我们将采用另一种方法:使用纯Verilog编写块存储器代码,并让综合工具从我们的代码中推断出块存储器。这种方法更具可移植性,但意味着我们必须信任工具能正确完成工作。
下图展示了我们将要创建的块存储器结构。


请注意,我省略了数据手册中显示的一些信号,我们不需要它们。这是一个相当小的存储器集合,只有16个元素,每个元素8比特宽。
我们将要写入的数据设置在 w_data 总线上,将要写入的地址设置在 w_addr 总线上。注意,我们的 w_addr 总线有4比特,这是寻址最多16个元素所需的宽度。我们也只有一条时钟线,同时作为读写数据的时钟。
在读取侧,我们设置要读取的地址,并在下一个时钟边沿(很可能是上升沿)设置 r_enable 线,数据将出现在 r_data 总线上。在极少数情况下,如果你试图在同一时间对同一地址进行读写,你需要查阅数据手册以了解会发生什么。根据对iCE40的一些测试,我发现存储器中的旧数据会先被读取到 r_data,然后新值才会被写入。这被称为“先读后写”。有些FPGA可能实现“先写后读”,即新数据会出现在 r_data 总线上。

编写Verilog代码
让我们尝试在Verilog中实现这个块RAM。
用Verilog编写代码,让综合工具推断出一块块存储器并不困难。我们需要做的就是声明一个存储器模块,然后创建一个 always 块来展示我们如何与该存储器模块交互。综合工具将(或希望会)由此意识到我们打算实例化或使用片上的块存储器。
我们首先定义输入,包括时钟 clk、写使能 w_en、读使能 r_en、写地址 w_addr、读地址 r_addr(两者都是4比特宽),写入数据 w_data(输入,8比特宽)和读取数据 r_data(输出,8比特宽)。
以下是核心的Verilog模块代码:
module memory (
input wire clk,
input wire w_en,
input wire r_en,
input wire [3:0] w_addr,
input wire [3:0] r_addr,
input wire [7:0] w_data,
output reg [7:0] r_data
);
// 声明一个8比特宽、16个元素深的存储器数组
reg [7:0] mem [0:15];
always @(posedge clk) begin
// 写操作
if (w_en) begin
mem[w_addr] <= w_data;
end
// 读操作
if (r_en) begin
r_data <= mem[r_addr];
end
end

endmodule
要声明存储器,只需创建一个寄存器类型的信号名。这里我称它为 mem。我们在 reg 关键字后声明总线宽度(这里是8比特,与我们的写入和读取数据对齐),然后声明存储器的深度(这里是16个元素)。
接下来,我们需要告诉综合工具我们打算如何与块RAM交互。我们将其放入一个 always 块中,操作发生在时钟的上升沿。在某些情况下,你可以使用时钟的下降沿,或者如果使用双端口存储器,你可能有两个不同的时钟,一个用于读,一个用于写。
在每个时钟上升沿,硬件会查看写使能位是否设置为高电平。如果是,它会将 w_data 总线上的数据复制到由 w_addr 变量给出的地址处的存储器中。
此外,如果读使能线为高电平,它会将 r_addr 给出的读地址处的存储器值复制到 r_data 输出总线。
我们省略了其他一些功能,如掩码和读写时钟使能线,但我们不需要担心这些。这足以让一个基于单时钟的、非常简单的双端口存储器工作。
综合与资源利用
保存文件并运行综合后,我们可以查看器件利用率报告。这将告诉我们使用了哪些逻辑单元、多少RAM以及与我们特定FPGA相关的任何其他外设。
在这种情况下,我们使用了1280个逻辑单元中的2个,这在设计中使用的逻辑单元不多,这很好。然而,这确实意味着我们使用了一个RAM块,而我们总共只有16个。请记住,无论我们在Verilog代码中定义和声明的特定存储器有多小,我们都必须至少使用一个RAM块。综合工具可能会决定使用分布式RAM并改用你的查找表,但在本例中,它决定从那些4K的RAM块中取出一个,用于我们的特定存储器设计。
因此,当你在设计中声明RAM时,必须非常小心你使用了多少块RAM。
编写测试平台
正如我们之前所做的那样,我们将编写一个测试平台来验证存储器功能。
我们将定义时间尺度,创建测试模块,实例化我们的存储器单元(称为被测单元或UUT),并连接所有信号。

对于第一个测试,我们将等待几个时钟周期,然后将读地址设置为 hex10,将读使能设置为1,再等待几个时钟周期(这应允许存储器在读取数据线上输出该地址的值之前,将读地址时钟输入),然后重置读地址和读使能线。
请注意,一个前面没有任何内容的单引号或撇号意味着我告诉综合工具(或本例中的仿真工具),当我写 hex 10 时,它应该自行确定合适的比特数。如果你之前已经定义了比特数,并且不想每次写常量时都在这里重新定义,这很有用。
类似地,我可以在这里写没有基数的数字,这通常意味着我暗示一个十进制数。这对于像0或1这样的数字很有用,任何基数在这里都有效,综合工具将尝试理解我的意思。你总是可以更明确地设置比特数,使用撇号或单引号,后跟基数字符,然后是你希望表达的数字,但有时我只是偷懒,不写比特数和基数。
在第一个测试中,我们将读取地址 Hex 10 处的任何内容。因为我们没有初始化存储器,我预计这将是零或垃圾值,即当时从存储器中读出的任何内容。然后,我们将在该地址写入一些内容(本例中为 Hex A5),通过设置写入数据总线、写入地址总线和使能写入线来实现。我们将等待一个时钟周期以允许该值被注册到存储器中,然后重置这些值。
之后,我们将执行与第一个测试基本相同的操作:设置读地址和读使能,等待一个时钟周期,然后重置它们。这有望在我们的读取数据线上打印出我们写入存储器的内容(本例中为 Hex A5)。

仿真与调试
运行仿真并在GTKWave中查看波形后,我们发现了一个问题:我们试图写入第16个元素(地址 hex10,即十进制的16),但我们的存储器深度只有16个元素(地址0到15)。地址16不存在。

这揭示了Verilog的一个重要特性:如果我创建一个只有4比特宽的东西,并尝试做一个5比特的操作(例如,尝试将其设置为一个5比特的数字),它只会使用能放入该总线的最低有效4比特。这正好验证了正在发生的情况,因为 10(十六进制,即十进制的16)不存在,所以它回绕并变成了0。

为了解决这个问题,我们将写入最后一个元素,将地址从 10 改为 F(十六进制,即十进制的15)。对初始读取也做同样的修改。

重新运行仿真后,我们可以看到读地址现在是 F,我们得到了垃圾值(因为之前我们得到的是0,所以那里一定被设置了什么),但除非你特别设置存储器,否则你必须假设它将是垃圾值或未知值。然后我们将 A5 写入地址 F,当我们读取它时,我们得到了 A5 输出,这验证了这块块存储器确实在工作。
初始化存储器值
让我们讨论一下如何为存储器设置初始值。一种可能性是,你可能会找到一种方法创建一个 for 循环,遍历每个元素并将其设置为零。但我有一个更好的方法:在综合期间从文件读取并设置这些初始值,以便在FPGA上电配置后立即准备就绪。
要从文件读取,我们首先需要创建该文件。在大多数情况下,你可以使用逗号分隔、空格分隔或换行分隔的值集。这里我将使用换行分隔的值集,并从0计数到7,然后跳过一些,再从 B8 到 BF。这些显然是十六进制数。它们是8比特宽,应该与我们的存储器宽度和深度(16个值)对齐。
我们将其命名为 mem_init.txt,它是一个基本的文本文件。
回到我们的存储器模块,在 always 块下方,我们将放置一个 initial 块。这是我们可以将 initial 块放入可综合Verilog代码的特殊情况之一。
在这个 initial 块中,如果设置了 init_file 参数(我们稍后会在顶部定义),它将调用系统函数 $readmemh,并从我们定义为该参数一部分的文件中读取,并将所有值设置到 mem 中。
就像我们之前对模块所做的那样,我们将声明一个参数,可以在初始化或实例化这块存储器时设置。我会说 init_file 默认为 null。如果我们不设置这个参数,init_file 将默认为 null,这意味着这个 initial 块不会运行,存储器也不会用任何初始值实例化。
回到测试平台,在我们实例化被测单元的地方,我将定义那个参数为文本文件的名称 mem_init.txt。这应该告诉综合工具它需要读取该文件,并将所有存储器元素设置为我们在该文本文件中定义的各个值。
在运行仿真之前,让我们从存储器中读取所有数据,而不是仅仅读取一个特定元素。我将用一个简单的 for 循环来实现,读地址将是 i(一个我们需要声明的变量)。当 for 循环运行时,它应该从每个地址0到15读取,我们应该看到那些值(来自存储器初始化文本文件的值)出现在我们的数据上。

运行仿真并查看波形,可以确认每当读使能在时钟的特定上升沿变高时,你应该得到我们在文本文件中初始化的值。首先是0,然后是1,依此类推,直到 BF。然后第二个测试运行,在地址 Hex F 处,A5 被加载进去,然后在这个时钟周期被读取,A5 出现在输出端。这验证了存储器正在工作,并且我们可以默认使用文本文件中的值来初始化它。
创建只读存储器


这对于查找表甚至处理器的指令等用途非常有用。在这种情况下,你可能实际上需要只读存储器。
你可以通过去掉写入能力来创建只读存储器。如果我在这里去掉各种写使能、写地址和写数据线,我就创建了只读存储器。将值放入其中的唯一方法是通过这个文本文件。这是创建某种不变存储器的好方法,例如,你可以为CPU读取指令。如果你想编写汇编程序,甚至更好的是,你可以创建自己的汇编器,甚至编译器,将像C这样的高级语言转换为汇编语言,然后汇编器创建单独的指令,你将它们输出到这样的文本文件中,这些指令被加载到你的RAM中,然后由你的处理器执行。

挑战:制作一个基本的LED音序器
在音乐制作中,音序器是一种将声音或音符按顺序组合在一起的设备或软件。它们可以被录制或预设。然后,音序器连续循环播放音符或声音以创建音乐。音乐家可以与音序器一起演奏,或从不同的音序器叠加声音。
你的挑战是制作一个超级基本的LED音序器。不用担心在每个步骤或时间点(比如每秒或半秒)产生声音。FPGA在LED上显示一个2比特的值。


我将按住模式按钮上的一个2比特值,然后按下设置按钮。这会将右侧两个按钮上的模式 0,0 记录到第一个存储器元素中。我再做另一个模式,比如 0,1,并将其设置到下一个存储器元素中。我将继续记录 0,0, 0,1, 1,0, 1,1, 0 和 0,1。音序器将这些值记录到存储器中。它只需要8个元素长。在整个过程中,音序器在LED上一次播放一个元素的序列。当它循环回到开头时,它应该准确地向我回放那个序列:0,1,0,1,2,3,2,1。
请注意,你可能需要使用许多早期课程中的概念和模块。我使用了单独的模块用于时钟分频器、按钮消抖和块RAM。我也强烈建议为你的设计编写一个或多个测试平台。它们在追踪错误时对我帮助极大。
祝你这个挑战好运。
总结

在本节课中,我们一起学习了FPGA中存储数据的多种方式,重点是如何使用Verilog描述和推断块RAM。我们了解了块RAM的硬件结构,编写了可综合的双端口RAM代码,并通过仿真验证了其读写功能。我们还学习了如何通过文本文件初始化存储器内容,以及如何创建只读存储器。最后,我们提出了一个制作LED音序器的实践挑战,以巩固所学知识。


在下一节课中,我们将看看FPGA中的锁相环,看看是否可以使用它将时钟速度提升到几百兆赫兹。
009:锁相环(PLL)与毛刺
在本节课中,我们将学习如何使用FPGA内部的锁相环来生成更高频率的时钟信号,并探讨在高速数字设计中可能出现的信号毛刺问题。
概述
到目前为止,我们一直在iCEstick开发板上使用12 MHz的时钟。这对于许多应用来说已经足够,但我们可以利用FPGA的锁相环来生成高达数百MHz的时钟。然而,随着时钟频率的提高,信号传播延迟和毛刺等问题会变得更加突出。本节将指导你配置和使用PLL,并理解高速设计中的基本时序问题。
使用锁相环提升时钟频率
上一节我们使用了板载的固定频率时钟。本节中,我们来看看如何利用FP相环来生成我们所需的高频时钟。
锁相环是一种有用的电路,它可以产生一个与输入信号频率和相位相匹配的重复波形(如正弦波或方波)。
锁相环的基本工作原理如下:
- 压控振荡器 产生一个信号。
- 鉴相器 将输出信号与参考信号进行比较,每当两个信号的频率或相位不匹配时,就会产生一系列脉冲。波形偏差越大,产生的脉冲越多。
- 该输出信号通过一个低通滤波器进行平滑,得到一个接近直流的电压。
- 该电压被馈送到VCO,VCO根据给定的电压调整输出频率。
这个过程持续进行,直到输出波形的频率和相位与参考信号相同。

锁相环有多种用途,例如同步或解调信号。我们的目标是从12 MHz信号创建一个时钟倍频器。需要注意的是,我们FPGA中的PLL是为处理数字信号而非模拟波形而设计的。
我们在振荡器和鉴相器之间添加一个简单的时钟分频电路。现在,VCO必须产生一个频率是参考信号三倍的波形。因此,一个36 MHz的方波被三分频,产生一个与输入信号匹配的12 MHz波形。我们可以利用这种技术,以一个相当低的参考频率信号,产生数百MHz甚至GHz范围的时钟信号。
Lattice公司有一份系统时钟设计和使用指南,值得翻阅以了解如何使用片内PLL。在第3章,你可以找到锁相环的框图。

PLL有时设计和调谐起来比较棘手。所以我们需要查看手册,了解我们可以获得哪些频率。这个PLL比我们刚才看的简单模型有更多的设置。
我们需要关心的设置是输入分频器、滤波器范围、VCO分频器和反馈分频器。向下滚动,你可以看到每个参数的可接受值。请注意,其中a0实际上意味着除以一。因此,分频器数字需要偏移一位。
我们还想将PLLOUT参数设置为GEN_CLK,这样内部生成的PLL信号就没有相移。再向下滚动,你会找到一些用于计算PLL输出的公式。我们将使用简单的反馈路径,因此公式在3.5.2节中给出。

在第4节,你可以找到一些参数限制。参考时钟需要在10 MHz到133 MHz之间。我们将给它12 MHz时钟,所以应该没问题。请注意,VCO的输出必须在533 MHz到1066 MHz之间。我们可以对该输出进行分频来得到输出频率,输出频率必须在16 MHz到275 MHz之间。
虽然手动计算可能有点复杂,但好消息是我们有一个工具可以帮助我们。我们将使用icepll工具来帮助我们进行一些计算,并为我们提供那些时钟分频器的推荐值。

要使用它,我们调用icepll -i,然后输入我们想要的输入时钟频率(以MHz为单位),本例中是12,以及-o我们想要的输出时钟频率。


在本例中,我们想要120 MHz。当我们运行这个工具时,它应该为我们计算分频器和滤波器范围。
我们希望DIVR设置为0,DIVF设置为79,DIVQ设置为3,滤波器范围设置为1。这些是我们需要记住的参数,以便在代码中使用PLL。它还为我们提供了一些关于压控振荡器的信息,它说VCO应该运行在960 MHz,但这会被分频到我们输出的120 MHz。
让我们记住这些参数值并创建一个项目来测试它们。
在Verilog中实例化PLL
了解了PLL的基本原理和参数后,本节我们动手在代码中配置并使用它。


我们将创建一个新项目,称之为pll_test。在这里,我将创建一个新文档,也叫做pll_test.v。



让我们在你喜欢的文本编辑器中打开它。这将是一个非常简单的模块。我们要做的就是输入我们的参考时钟,并输出一个PLL时钟(本例中是倍频后的时钟)。我们将输入12 MHz,输出120 MHz。
为此,我们将使用这个SB_PLL40_CORE原语,它是我们iCE40器件特有的。这很像一个模块,我们将给它一些参数、输入和输出信号。它将告诉综合工具需要使用我们iCE40 FPGA内部的PLL核心。
正如我们在数据手册中看到的,我们希望使用简单的反馈路径。这意味着我们不会使用任何精细的延迟调整,这是目前最容易实现和测试的。接下来,我们希望将PLLOUT_SELECT参数设置为GEN_CLK。这告诉PLL核心我们不希望输出有任何相移。我们再次尝试保持简单。
第一个值是参考时钟分频器。这是我们从icepll工具得到的数字之一。在本例中,它应该是0。接下来,我们定义反馈时钟分频器。如果你还记得工具的输出,这是十进制79或二进制1001111。我们将把它放在这里作为DIVF。



然后我们定义VCO时钟分频器,同样来自icepll工具,这是DIVQ参数,应该是3。最后,我们定义滤波器范围。


根据我们的icepll工具,它应该是1。我们将给这个模块命名,并将我们的输入参考时钟连接到锁相环中的参考时钟信号。输出时钟信号叫做PLLOUTCORE,因此我们将我们的输出时钟信号连接到那个端口。
锁相环可以做的其中一件事是给我们一个锁定信号作为输出。这个信号让你知道PLL正在工作,并且已将输出相位锁定到输入相位。有时你可能想等待那个信号再做其他事情,但现在我们并不特别关心它。所以我们不会在我们的顶层设计中将该信号连接到任何东西。
这个特定的PLL有一个低电平有效的复位。所以我们实际上只想给它一个静态的高电平信号让它运行。最后,我们希望禁用PLL中的旁路,以便我们可以使用它作为输出。
这就是我们Verilog代码需要做的全部。然而,我们确实想创建一个物理约束文件,我将把输入参考时钟定义为P21,那是我们iCEstick上12 MHz时钟连接的物理引脚21。我们还有一个输出时钟信号,我们想用示波器测量,它将连接到物理引脚P87,该引脚在Pmod连接器上。

将该文件保存为我们的引脚约束文件后,我们将打开命令提示符并进入我们的pll_test目录。在这里,我们想为我们的iCEstick初始化项目,就像我们为所有项目做的那样。至少如果你在使用iCEstick,最好验证你的代码,确保一切看起来正常。
我们将调用apio build,没有错误。然后我们将调用apio upload。看起来一切已上传到iCEstick,所以让我们连接示波器确保它工作。
当我用示波器测量iCEstick上引脚87的输出时,你可以看到时钟信号。我测量频率,看起来是120 MHz,正如我们预测的那样。请注意,这不是一个非常漂亮的方波。当我们开始使用更高的频率时,线路中的电容和电感开始成为问题,这将使波形变形。我的示波器也遇到了带宽限制。
在FPGA内部,这可能不是大问题,但如果你在布局PCB或将外部部件连接到FPGA,这是你必须记住的事情。
理解传播延迟与毛刺


成功生成了高速时钟后,我们需要意识到随之而来的设计挑战。本节我们将探讨数字电路中的传播延迟及其导致的毛刺现象。
当我们创建数字设计时,我们经常想象一个理想的世界,输出会随着输入的变化而立即变化。然而,这在现实世界中并不会发生。所有导线和门都有传播延迟。电压电平的变化需要一小段时间才能出现在导线或门的另一端。
在FPGA内部,我们通常可以假设大多数导线几乎没有传播延迟,因为它们非常短。然而,门可能会有明显的延迟。例如,这个非门在接收到输入变化后,可能需要一纳秒来更新其输出。请记住,FPGA使用查找表而不是真正的门来创建你的可编程逻辑。所以实际上,传播延迟发生在查找表之间,而不是在门级。
时序和传播延迟可能很快变得非常复杂,可以成为它自己的视频系列。我们将在本节中介绍传播延迟这个话题,以便你在数字设计中开始使用更快的时钟时,能够意识到一些陷阱。
如果你查看iCE40数据手册,你可以找到我们特定器件传播延迟的最大值。但请注意,这是引脚到引脚信号的延迟,即信号从一个外部引脚通过一个查找表再到另一个引脚所需的时间。数据手册没有提到内部信号。所以我们必须假设这种传播延迟可以忽略不计,或者布局布线工具将确保能够满足我们设计的任何时序要求。

让我们看一个稍微设计的例子来演示传播延迟这个概念。这是一个由四个半加器和四个触发器组成的四位纹波计数器。每个半加器的和构成了计数器输出的一位。进位输出位被馈送到下一个半加器级。输出在每个时钟周期被寄存,以产生四位计数输出。
一旦该值被寄存,电路就将该值加一以产生新的输出,然后该输出再次被寄存。只要有时钟信号,这个过程就会一直持续下去。


当我们引入门延迟时,事情变得有点复杂。就在计数更新后,第一个输出在一纳秒后更新。然后第二位在那之后一纳秒更新。第三位在一纳秒后更新,最后第四位在一纳秒后更新。输出值更新总共需要4纳秒,在此期间,输出会随着位的变化而变化,然后才稳定在正确的值上。
再次强调,这是一个理论上的例子,因为FPGA使用查找表而不是门来实现这一点,并且内部延迟可能更小。此外,当你在Verilog代码中使用加法时,综合工具可能会实现纹波进位加法器以外的其他东西来完成这个功能,以避免此类延迟。
让我们在Verilog中看看这个。
仿真观察毛刺
理论了解了传播延迟,现在我们通过仿真来直观地观察毛刺是如何产生的。
与其让你看我手动写出这个测试,我创建了一个glitch_test项目。请注意,apio需要测试平台之外的某种Verilog代码才能工作,但我要在测试平台内部完成所有事情。所以我创建了一个空的Verilog文件只是为了满足apio的要求。如你所见,里面什么都没有。我把所有东西都放在这个测试平台里。我知道我提到过你可能应该每个文件使用一个模块,但由于我们要做一些不可综合的事情,我把一个硬件模块(本例中是我们的半加器)和测试平台作为单独的模块放在同一个文件中。
这是半加器。它非常简单。它只是一个异或门和一个与门,带有和输出与进位输出。然而,使用我们的仿真工具,我们可以使用#符号来表示延迟,就像我们之前做的那样。但如果我们在赋值之前使用它,就像你在这里看到的,它意味着等待一个时间单位,在更新这个值或该逻辑的输出之前获得这个值。
请注意,有更好的工具可以进行门级仿真。有一种方法可以让Icarus Verilog进行门级仿真,但就我们目前使用的东西而言,它默认不执行门级仿真。所以我们可以尝试通过这种方式来模拟它,比如假设计算这个异或逻辑需要一个时间单位(本例中是一纳秒)。当你进行实际的门级仿真时,你用于那些门的模型应该与实际FPGA(比如你的iCE40)中的查找表延迟紧密匹配。然而,没有这些模型,我们必须依靠这个来演示传播延迟。
所以我手动创建了带有模拟门延迟的半加器。我们将定义我们的测试平台。如果你看过测试平台的视频,这一切看起来应该很熟悉。
我们将创建一些内部信号,我们将创建一些寄存器。在本例中,out应该与你在图中看到的匹配,它是纹波计数器的输出,而count是该寄存值的输出。对于这个例子,我们将生成接近120 MHz的时钟,以演示当你开始使用更快的时钟速度时可能发生的情况。
我们将实例化我们的半加器。在本例中,我们将我们的信号连接在一起。请注意,第一个半加器的进位输出连接到下一个半加器的a输入,那个半加器的进位输出连接到下一个半加器的a输入,而计数值是另一个输入。请记住,那是寄存后的输出值。半加器的和构成了我们的out向量或out总线的位。

这是作为四位纹波计数器一部分的寄存器。在每个时钟周期,out值被寄存到count值。一旦count更新,就会导致半加器链开始工作,并立即(减去一些传播延迟)更新它们的输出值,然后这些值再次被时钟采样,这个循环继续。
我这里有一个异步复位信号,只是为了复位一切。这就是我们要为这个测试做的全部。我们要做的就是脉冲那个复位线,然后让计数器做它的事情。
这一切看起来应该很熟悉,我们运行仿真,创建值变化转储文件,然后说我们完成了。

让我们打开命令提示符,进入我们的glitch_test项目。我相信我已经为iCEstick初始化了项目,但以防万一,让我们再做一次。让我们验证一下,然后我们将进行仿真。一旦GTKWave启动,请随意移动并观察发生了什么。
一旦我们更新计数值(或者它从零开始初始化),几纳秒后,输出就更新了。现在让我们移动到值被寄存的地方。我们有一个时钟上升沿,count取out的值。如果我们递增1,它应该是2。然而,你注意到在很短的时间内,这个值是0。这就是这个值不正确的一纳秒。1加1应该是2。为什么这里是0?这被称为毛刺。
这是因为这些值在半加器中需要时间更新。所以第一个半加器更新它的值,它说a需要是0,然后它变成了0。第二个半加器更新它的值需要一纳秒。所以我将在这里和这里放置标记,这样你可以看到它更新需要一纳秒。所以在很短的时间内,这个值是不正确的,这是一个毛刺。一旦它更新到2,希望时钟的上升沿会发生,正如我们在这里看到的,count总线用那个输出值更新,所以2来到这里,我们看到更多的毛刺,因为将2递增到3,我们不应该看到1和7,但正如你所看到的,这些位需要时间(每个一纳秒)来更新它们的值,然后才实际变成3。
在这个设计中,这通常不是问题。当你使用这样的计数器时,你通常关心的是这个寄存值,这个count值。如果你看这里,它是0, 1, 2, 3,依此类推。只有当你在观察这个out值,如果你在使用这个值时,才可能是个问题。但在大多数使用计数器的情况下,你关心的是这个寄存后的输出。这就是为什么如果你使用这样的计数器,你可能不会看到这成为问题。
高速下的时序挑战
然而,当你开始提高时钟速度时,如果我们假设这些传播延迟是正确的,并且它们不随我们提高时钟速度而改变,那么我们开始采样点越来越接近这些变化发生的地方。如果我们把时钟速度提高到,比如说,300或400 MHz,你可以开始看到我们可能在这里采样。在这种情况下,这个7被寄存到这个计数值中,这是一个真正的问题。
这个毛刺被寄存并成为新的计数输出,计数器不再工作,因为我们违反了这些时序限制。这开始涉及一些相当高级的FPGA设计讨论,关于如何适当地进行布局布线或创建避免此类毛刺的逻辑。
目前,我们的iCEstick应该能够管理我们想做的一些基本事情,比如加法器和计数器,这些应该不会违反任何时序限制。就本入门课程而言,这只是你在进行FPGA设计时需要记住的事情。在你开始进入更快的时钟速度之前,或者如果你开始看到奇怪的事情发生,你应该不需要担心这些。要知道那可能是一个毛刺,你必须开始查看你的测试平台,并可能运行专门的门级仿真软件工具来捕捉这些,因为毛刺可能很难发现、追踪和修复。

总结与挑战
本节课中,我们一起学习了如何配置和使用FPGA内部的锁相环来生成高频时钟,并深入探讨了数字电路中因传播延迟而产生的毛刺现象。我们了解到,在低速设计中,毛刺可能不是问题,但随着时钟频率的提高,它们可能导致电路功能错误。

纹波进位计数器是一个简单的设计,但在高时钟速度下容易产生毛刺。这是给你的挑战:你能想出另一种实现计数器的方法,消除或至少减少毛刺的数量吗?看看你能否为你的设计搭建一个测试平台。如果你想比较答案,我将在描述中发布我的解决方案链接。
在下一节中,我们将讨论亚稳态和跨时钟域问题。祝你编程愉快。


010:亚稳态与跨时钟域处理 ⚙️
在本节课中,我们将要学习FPGA设计中一个关键且常见的问题:亚稳态。当设计需要处理异步信号或跨越不同时钟域时,就可能遇到这个问题。我们将了解亚稳态的成因、影响以及如何通过同步器等技术来避免它,从而确保设计的稳定性和可靠性。
亚稳态的成因 ⚡
上一节我们介绍了同步设计的概念。本节中我们来看看当设计不再完全同步时会发生什么。
大多数我们目前接触的设计都是同步的,这意味着它们工作在同一个时钟信号下。然而,你可能会遇到需要采样异步信号,或者与工作在完全不同时钟信号下的模块交互的情况。这可能导致一个被称为亚稳态的问题。
让我们观察一个基本的D触发器。我们假设这个触发器在时钟脉冲的上升沿寄存数据。仔细观察这个边沿。实际上,输入信号需要在采样时钟边沿之前的某个时间段内保持其预期值。输入信号D在时钟事件发生前需要保持稳定的这段时间被称为建立时间。信号在时钟边沿之后也必须保持该电平一段时间,输出才能稳定。这被称为保持时间。
只要输入信号不在建立时间和保持时间窗口内发生变化,输出Q就会更新为寄存的值,并保证是稳定的。
然而,如果D在建立或保持时间窗口内发生变化,就构成了时序违规。这可能导致D触发器无法正确锁存输入信号,并进入亚稳态。此时,触发器的输出在一段不确定的时间内处于未知状态。通常,输出会迅速达到稳定状态,但这种亚稳态输出可能会根据电压稳定所需的时间长短,在你的设计中产生错误的信号和值。
需要注意的是,在建立或保持时间内的毛刺或短脉冲也属于违规,同样可能引发亚稳态。大多数建立和保持时间都在纳秒量级。你无法保证时序违规一定会导致亚稳态,因为这是概率性的。你最多只能计算平均故障间隔时间来了解其发生的可能性。我们不会深入这个计算,因为它属于更高级的主题。
此外,你可能无法在示波器上直接观察到这种亚稳态,因为它只发生在FPGA内部的触发器上。大多数FPGA在触发器输出和物理引脚之间有一些逻辑和缓冲器。这意味着亚稳态输出在引脚上看起来可能只是一个电平变化。然而,如果你反复监控这个引脚并记录亚稳态输出,你会看到由于亚稳态的概率特性,电平变化发生的时间点会有所不同。
如果我们查看数据手册,可以看到HX1器件列出的建立和保持时间。但请注意,与传播延迟一样,这些时间仅针对来自物理引脚的信号给出,内部时序并未给出。因此我们必须假设布局布线工具会确保所有内部路径满足时序要求。

有趣的是,建立时间竟然是负值。这意味着你的信号变化实际上可以在时钟边沿之后略微到达,但仍然没有问题。
Colin O‘Flynn有一篇优秀的文章,他通过寄存一个分频时钟信号,然后以不同量延迟时钟,来演示亚稳态。你可以在这里看到他的测试设置。他使用一个48 MHz时钟信号来驱动触发器的信号线和时钟线。信号线被分频到24 MHz,以便可以采样上升沿和下降沿。然后他仔细调整时钟相位,直到发生建立或保持时间违规。你可以在这里看到他的结果。如果没有违规,数据输出信号看起来很干净。然而,当发生亚稳态事件时,数据输出线的转换会随机延迟一段时间。
我曾尝试复现这个实验。但是,为了让亚稳态事件更明显,你需要降低FPGA的核心电压。我发现我需要拆焊几个元件来控制核心电压。虽然我肯定能做到,但我不想冒险损坏我唯一的iCEstick,因为我知道目前很难买到它们。
解决方案:同步器 🔄

上一节我们了解了亚稳态的成因和风险。本节中我们来看看如何解决这个问题。
如果你正在处理异步信号或跨时钟域,标准的修复方法是在采样链中添加第二个触发器。虽然这不能完全消除亚稳态,但它会降低其对最终输出的影响和发生几率。即使第一个触发器经历了亚稳态,第二个触发器也会采样那个未知状态,并有更大的机会产生一个稳定状态。
请注意,这确实会在你的设计中引入一个延迟,因为输入值现在需要额外一个时钟周期才能出现在输出上。有时你甚至可能看到三个或更多触发器串联在一起,以真正降低亚稳态的几率。使用这样的触发器链来减少亚稳态被称为同步器。
以下是一个简单的触发器示例,其中输入可能是一个异步信号,我们用它来创建一个同步器:


// 定义一个同步器
reg pipe_0;
reg output_reg;
always @(posedge clk) begin
pipe_0 <= async_input; // 第一级寄存
output_reg <= pipe_0; // 第二级寄存
end
我们定义一个新的寄存器元素,称之为pipe_0。然后我们使用非阻塞赋值将输入信号寄存到pipe寄存器。我们添加另一行,将pipe寄存器的内容寄存到输出寄存器。这样就完成了。
时钟域与跨时钟域处理 🕐

到目前为止,在我们的设计中,我们主要工作在单个时钟域中。这意味着我们所有的逻辑和触发器都由单一源时钟驱动,对我来说就是板载的12 MHz振荡器。然而,你可能会遇到其他模块使用独立时钟,且该时钟与你的系统时钟相位不对齐的情况。
时钟域是指所有由单一时钟信号或其分频版本驱动的逻辑。
假设clock1由某个外部传感器生成。它可能是一个5 MHz的SPI信号,我们无法保证它与我们内部的系统时钟(称之为clock2)相位对齐。我们想要采样输入数据以用于计算,因此我们创建了这样一个电路。然而,正如我们刚才所示,这两个时钟域之间的边界是危险的,因为有可能发生亚稳态。这种情况被称为跨时钟域,它可能导致很多令人头疼的问题。亚稳态是随机的,可能引发难以追踪的bug,也可能不会。
通常,解决方案是在接收时钟域创建一个同步器电路,正如我在这里展示的。在大多数情况下,这将降低亚稳态发生的可能性。
为了让你的设计保持简单,尤其是当你刚刚入门时,你可能希望尽量避免跨时钟域。尽可能让你的设计同步在单一时钟域内。
在早期的课程中,我们制作了简单的时钟分频器,看起来像这样:我们的12 MHz时钟输入一个简单的分频电路,然后我们使用该信号以较慢的速率驱动设计的其余部分。当你刚开始学习使用FPGA时,这样做是可以的。然而,这被认为是糟糕的设计。逻辑和触发器可能会引入延迟,现在你从技术上创建了一个独立的时钟域。你应该避免使用其他触发器的输出来驱动时钟输入。

我要感谢Patrick Lehman向我展示了一种更好的创建时钟分频器的方法。以下是那个更好的时钟分频器:
module better_clk_divider #(
parameter MAX_COUNT = 120
)(
input wire clk,
output reg tick
);
localparam COUNTER_BITS = $clog2(MAX_COUNT);
reg [COUNTER_BITS-1:0] count = 0;
always @(posedge clk) begin
if (tick) begin
count <= 0;
end else begin
count <= count + 1;
end
end
assign tick = (count == MAX_COUNT - 1);
endmodule
我们像以前一样,给出一个最大计数值作为参数。但请注意,现在输出不是分频时钟,而是这个tick输出。我们使用内置的$clog2函数来计算计数器需要多少位。这里的关键在于,与其产生一个分频时钟,tick信号在计数结束时仅在一个时钟周期内变为高电平,然后计数器重新开始。always块持续递增计数器,并在tick为高时(即计数器达到模数参数值时)复位计数器。

以下是这个分频器在仿真中的示例。模数参数设为120,所以每当count达到119时,tick线会变为高电平一个时钟脉冲。


以下是我们如何使用这个新分频器的例子。这是我们在系列课程开始时看到的1Hz闪烁LED示例:
better_clk_divider #(.MAX_COUNT(12_000_000)) clk_div_inst (
.clk(clk_12mhz),
.tick(one_hz_tick)
);
always @(posedge clk_12mhz) begin
if (one_hz_tick) begin
led <= ~led; // 每秒翻转一次LED
end
end
我们实例化时钟分频器。与其使用其输出驱动敏感列表,我们只需在执行操作前条件检查tick线是否为高。在这个例子中,我们只是翻转LED。这种方法使所有逻辑都与我们的主时钟完美同步,我们无需担心亚稳态。我将在描述中发布此示例以及更好的按键消抖方法的链接。请随意查看并在你自己的FPGA上尝试。

先进先出队列 📥
FIFO是跨时钟域工作时另一个极好的工具。你可能也知道它叫队列。它是一种使用一块内存块在发送方和接收方之间传递数据的方式。
假设我们有一个已经存储了两个元素的FIFO。发送方将另一段数据写入FIFO,该数据存储在其他两个数据之后。接收进程可以异步地从FIFO读取数据。这将把位于FIFO前端的元素取出,其他元素则向下滑动。
创建或使用FIFO时需要遵循一些规则。以下是需要记住的几个要点:
- 元素按照写入FIFO的顺序被读取。
- 任何已被读取的元素都会从FIFO中移除。
- 你需要一种方法来检查FIFO是空还是满,因为你不想从空的FIFO读取,也不想向满的FIFO写入。如果你这样做,可能会破坏FIFO或无意中覆盖部分内存。

让我们再看一下数据手册中块内存的框图。你会注意到iCE40的块RAM有两个不同的时钟输入:读时钟和写时钟。这允许我们在不同的时钟域中对块RAM进行读写操作。因此,我们可以围绕这块内存构建一些逻辑来创建一个异步FIFO。
这样的队列非常有用,因为它们允许你在时钟域之间传递数据。例如,你可以从传感器采集样本,并将结果存储在FIFO中。然后,一个独立的微控制器可以使用其自己的时钟域从FIFO读取结果。
然而,创建一个能抵抗亚稳态问题的FIFO可能有点棘手。幸运的是,数字设计专家们已经为我们创建了健壮的FIFO。以下是Clifford Cummings在其2002年提交给Synopsys用户组的论文中的一个例子。虽然他提到有第二个FIFO设计获得了奖项,但我建议坚持使用这第一个设计。我强烈建议阅读这篇论文,看看Clifford是如何创建这个设计的。
在第9页,你会看到FIFO的框图。你可以看到逻辑是如何围绕双端口块RAM构建以构成FIFO的。注意,他使用了两个同步器电路在两个时钟域之间传递读写地址。再往下翻,你可以看到源代码已经慷慨地提供给我们使用。
你的挑战是实现这个FIFO,然后用Verilog编写一个测试平台来证明它能工作。以下是我的仿真示例。你可以看到我将值0、1、2和3写入FIFO。当我读取它们时,我在每个读时钟周期得到0、1、2和3,并且注意两个时钟频率是不同的。每当FIFO为空时,读空线变为高电平。如果我填满了FIFO,写满线变为高电平,并且FIFO会忽略之后给出的任何值。当我读出数值时,你可以看到十六进制F之后没有给出任何值,因为FIFO已经满了。
总结与展望 🎯
本节课中我们一起学习了FPGA设计中的亚稳态问题及其解决方案。我们了解到,当信号在触发器的建立或保持时间窗口内发生变化时,会引发亚稳态,导致输出在一段不确定时间内处于未知状态。为了解决跨时钟域通信带来的亚稳态风险,我们引入了同步器(使用两级或多级触发器链)和异步FIFO这两种关键技术。同时,我们学习了使用使能脉冲(tick信号)而非分频时钟来驱动低速逻辑,以保持设计在单一同步时钟域内,这是避免亚稳态问题的最佳实践。


至此,你应该已经掌握了在FPGA中开始创建自己设计所需的大部分基本构建模块。虽然这结束了本系列的入门部分,但我想在接下来的两节课中讨论一个有趣的话题:软核处理器。我将向你展示如何使用一个现有的RISC-V实现,并让它运行在iCEstick上。然后我们将修改设计,加入一个自定义的硬件外设。


祝你编程愉快!
011:在FPGA上运行RISC-V软核处理器 🚀
在本节课中,我们将学习如何在FPGA上构建并运行一个开源的RISC-V软核处理器。我们将使用一个名为FemtoRV32的轻量级实现,并将其部署到iCEstick开发板上,最终编写一个简单的C程序来控制板载LED。
概述
RISC-V是一种开源指令集架构,这意味着任何人都可以免费使用它来开发CPU。通过将RISC-V CPU实现在FPGA上,我们可以深入了解CPU的工作原理。本节教程将指导你完成一个现有RISC-V实现的构建过程,并运行代码进行测试。
准备工作与环境搭建
上一节我们介绍了项目背景,本节中我们来看看如何搭建开发环境。我们将使用Raspberry Pi作为开发主机,但理论上任何Linux系统都可以。

首先,我们需要安装一系列工具链,包括FPGA开发工具和RISC-V编译器。

以下是安装步骤:
- 更新系统包管理器:在开始安装任何软件包之前,建议先更新系统。
sudo apt update

-
安装Yosys依赖并编译:Yosys是一个开源的Verilog综合工具。
# 安装依赖 sudo apt install build-essential clang bison flex \ libreadline-dev gawk tcl-dev libffi-dev git \ graphviz xdot pkg-config python3 libboost-system-dev \ libboost-python-dev libboost-filesystem-dev zlib1g-dev # 克隆并编译Yosys git clone https://github.com/YosysHQ/yosys.git cd yosys make -j$(nproc) sudo make install cd .. -
安装仿真工具:安装Icarus Verilog和Verilator用于仿真测试。
sudo apt install iverilog verilator -
安装Project IceStorm:这是Lattice iCE40 FPGA的开源工具链。
# 安装依赖 sudo apt install libftdi-dev # 克隆并编译 git clone https://github.com/YosysHQ/icestorm.git cd icestorm make -j$(nproc) sudo make install cd ..

-
安装nextpnr:这是一个开源的FPGA布局布线工具。
git clone --recursive https://github.com/YosysHQ/nextpnr.git cd nextpnr cmake -DARCH=ice40 -DCMAKE_INSTALL_PREFIX=/usr/local . make -j$(nproc) sudo make install cd .. -
配置USB规则:为了让Linux系统能够通过USB与iCEstick通信,需要添加udev规则。
sudo nano /etc/udev/rules.d/53-lattice-ftdi.rules在打开的文件中添加以下内容并保存:
# 允许普通用户访问FTDI设备 SUBSYSTEM=="usb", ATTR{idVendor}=="0403", ATTR{idProduct}=="6010", MODE="0660", GROUP="plugdev"保存后,重新加载udev规则:
sudo udevadm control --reload-rules sudo udevadm trigger
获取并配置FemtoRV32项目
环境搭建完成后,我们开始处理具体的RISC-V处理器项目。
首先,克隆FemtoRV32的代码仓库:
git clone https://github.com/BrunoLevy/learn-fpga.git
cd learn-fpga/FemtoRV32

接下来,我们需要根据iCEstick开发板的资源限制来配置处理器,禁用不需要的外设以节省逻辑资源。
进入配置目录并编辑配置文件:
nano RTL/CONFIGS/icestick.vh
在配置文件中,找到并注释掉你不需要的外设定义。例如,对于基础的LED闪烁实验,可以注释掉红外、OLED屏幕和LED矩阵等外设:
// `define NRV_IO_IRDA
// `define NRV_IO_MAX7219
// `define NRV_IO_SSD1351
确保使用的是最小的处理器内核(Quark版本)并从SPI Flash启动:
`define NRV_FEMTORV32_QUARK
`define NRV_RUN_FROM_SPI_FLASH
RAM大小设置为8KB,其中2KB用于处理器寄存器,剩余6KB可供程序使用。
构建并烧录处理器到FPGA
配置完成后,就可以将处理器设计综合并烧录到FPGA了。
首先,将iCEstick开发板连接到电脑的USB口。
然后,在 FemtoRV32 目录下执行构建命令。注意:第一次运行时会下载并安装RISC-V工具链,这可能需要较长时间。
make ICESTICK
当你在终端看到 Programming done. Reading verify OK. Bye. 的信息时,说明RISC-V处理器及其外设系统已经成功合成、布局布线并烧录到了你的FPGA中。现在,FPGA里有一个“空白”的处理器,正等待执行指令。
编写并运行第一个C程序

处理器已经在FPGA中运行,接下来我们需要为它编写程序。我们将创建一个简单的程序,让LED闪烁并向串口打印信息。
首先,为你的项目创建一个新目录并进入:
mkdir -p ~/fpga/femtorv32_projects
cd ~/fpga/femtorv32_projects
mkdir blinky
cd blinky
创建一个C源文件 main.c:
#include <femtorv32.h>
int main() {
// 配置串口,波特率115200
femtosoc_tty_init();
// 打印欢迎信息
printf("Hello, RISC-V World!\n");
// 打印CPU频率
printf("CPU Frequency: %d Hz\n", FEMTORV32_FREQ);
while(1) {
// 点亮前两个LED (二进制 0011)
*(volatile uint32_t*)(IO_LEDS) = 3;
// 延时500毫秒
delay(500);
// 关闭所有LED
*(volatile uint32_t*)(IO_LEDS) = 0;
// 再次延时500毫秒
delay(500);
// 在循环中打印消息
printf("LED Blink!\n");
}
// 程序理论上不会运行到这里
return 0;
}
代码解释:
IO_LEDS是一个在femtorv32.h中定义的宏,它对应着控制LED的硬件寄存器的内存映射地址。- 通过向这个地址写入数据(如
3,二进制0011),我们可以控制LED的亮灭。 delay()函数用于产生延时。printf()函数会将文本发送到串口,你可以在电脑上通过串口终端查看。

接下来,创建一个简单的 Makefile 来编译和烧录程序:
include ../../learn-fpga/FemtoRV32/makefile.inc
最后,编译程序并将其烧录到FPGA的SPI Flash中:
make main.prog
这个命令会调用RISC-V编译器编译你的C代码,然后将生成的可执行文件通过SPI Flash编程器烧录到开发板上。
查看运行结果
程序烧录完成后,处理器会自动从Flash中读取并执行它。
要查看串口打印的信息,你需要在电脑上打开一个串口终端工具(如 picocom, screen, minicom 或 PuTTY)。
连接参数如下:
- 波特率: 115200
- 数据位: 8
- 停止位: 1
- 校验位: 无
- 流控制: 无
在Linux上,可以使用以下命令(具体设备名可能是 /dev/ttyUSB0 或 /dev/ttyUSB1):
sudo picocom -b 115200 /dev/ttyUSB0
如果一切正常,你将首先看到 Hello, RISC-V World! 和CPU频率信息,然后会周期性地看到 LED Blink! 输出。同时,iCEstick开发板上的前两个LED应该会以1秒的周期闪烁。
按 Ctrl+A,再按 Ctrl+X 退出picocom。
挑战任务:添加按钮输入

现在你已经让处理器输出了,下一个挑战是添加输入功能。
你的任务是修改设计,使板载按钮能够作为输入来控制LED。这需要你:
- 修改硬件描述:查看
RTL/femtorv32.v文件,了解按钮引脚是如何定义的。对于iCEstick,你可能需要禁用一些默认的外设(如Pmod引脚上的外设)来释放引脚给按钮使用。你还需要修改ICESTICK.pcf引脚约束文件,将按钮连接到具体的FPGA引脚。 - 注意:在PCF文件中使用
PULLUP指令可能无效。你需要查阅Lattice iCE40的技术库文档,使用SB_IO原语直接在Verilog代码中配置引脚的上拉电阻和输入模式。 - 编写C程序测试:在C程序中,读取按钮对应的内存映射寄存器地址,根据其值(按下或松开)来控制LED的闪烁行为。例如,可以实现“当按下第一个按钮时,LED常亮;松开时,LED熄灭或恢复闪烁”。
这是一个综合性的练习,需要你阅读和理解现有的Verilog和C代码。建议你从FemtoRV32仓库中已有的外设驱动代码(如LED驱动)开始模仿和学习。

总结
本节课中我们一起学习了如何在FPGA上构建和运行一个RISC-V软核处理器。我们完成了从环境搭建、项目配置、处理器烧录到编写并运行C程序的完整流程。你掌握了:
- 配置和综合一个开源RISC-V处理器(FemtoRV32)到iCE40 FPGA。
- 理解内存映射I/O的概念,并通过C语言访问硬件外设(如LED)。
- 使用串口与FPGA上的处理器进行通信。


通过挑战任务,你将进一步加深对软核处理器系统、硬件描述语言和嵌入式编程之间联系的理解。下一节课,我们将探索如何修改这个处理器,并添加自定义的硬件外设模块。
012:RISC-V自定义外设 🧩
在本节课中,我们将学习如何为Femto RV RISC-V处理器创建一个自定义的硬件外设。我们将制作一个简单的PWM(脉冲宽度调制)驱动来控制LED的亮度,并将其集成到我们的片上系统中。
概述
上一节我们成功将RISC-V处理器上传到FPGA,并运行了让LED闪烁的代码。本节中,我们将更进一步,创建自己的硬件外设来完成这个任务。为此,我们需要先理解Femto RV实现中的内存寻址机制。
内存寻址机制
Femto RV实现中的RISC-V核心使用32位寻址方案。但为了降低逻辑单元数量以适配小型FPGA(如iCEstick),其在内存处理上采用了一些技巧。
内存寻址方式如下:
- 地址的前8位和后2位被完全忽略。忽略后2位是因为内存按字(4字节)寻址。
- 第22和23位用于确定内存的目标或“页面”。
- 如果设置为
00,则寻址到RAM(iCEstick的块RAM)。 - 如果设置为
01,则意味着我们要与硬件外设通信。注意,这个I/O页面没有分配实际的内存。硬件看到我们正在寻址I/O页面,然后使用地址中红色的位(指特定比特位)来读写该外设。 - 如果设置为
10,则寻址到iCEstick的SPI Flash,这是CPU查找程序指令的地方。
- 如果设置为

寻址方案如下所示:
0x00000000到0x003FFFFC:寻址到RAM。iCEstick只有8KB块RAM空间,其中2KB留给通用寄存器,因此实际RAM只有6KB。0x00400000到0x00480000:是我们的I/O页面。它使用独热编码方案与外设通信。0x00800000到0x00BFFFFC:用于SPI Flash。
以下是寻址LED外设的示例。如果我们向地址 0x00400004 写入数据,就是向LED硬件驱动器写入。驱动器接收数据并执行相应操作(例如,点亮或熄灭LED)。
以下是UART数据寄存器和控制寄存器地址的示例。注意,地址不是顺序递增的,它使用独热编码方案,即地址空间中每次只有一个比特位为高电平。虽然这损失了大量潜在的外设地址空间,但节省了许多逻辑单元,因为驱动器每次只需检查一个比特位来确定是否被寻址。

在我们的C代码中,我们通过以下公式计算外设地址:
基地址 + (1 << (2 + 外设编号))
其中基地址为 0x400000。这为我们提供了20个外设的空间,因为可用地址空间中有20个比特位。
配置外设槽位

如果我们查看Femto RV的RTL代码中的 devices 目录,可以找到一个 hardware_config_bits.v 文件。打开它,可以看到已经为iCEstick预定义了12个外设。我们禁用了其中一些不需要的(如OLED控制器、LED矩阵和SD卡),以节省宝贵的逻辑单元。最后三个外设槽位被具有常量值的寄存器占用,因此也无法使用。这为我们留下了第12到第16号槽位来添加自己的外设。
我们将制作一个简单的硬件PWM驱动器来控制其中一个LED的亮度,并将其集成到这个片上系统中。
PWM外设工作原理
以下是PWM外设的工作方式:
- 我们将创建一个简单的12位计数器,持续从0计数到4095,然后重置为0并重新开始。
- 我们创建一个寄存器来存储PWM值。通过向一个I/O内存地址写入数据,我们可以写入这个寄存器。
- 例如,假设我们向该寄存器写入值1365。现在,每当计数器值小于此值时,LED点亮;当计数器值等于或大于此值时,LED熄灭。在这个PWM值下,LED将具有约30%的占空比。只要LED切换速度足够快,在人眼看来它就是稳定发光的。由12MHz时钟控制的计数器速度足够快。
- 注意,这是一个简单的PWM方案,意味着我们可以将LED完全关闭,但无法将其完全点亮。有方法可以修正这一点,但为了简单起见,我们暂时保持现状。
创建PWM硬件外设
以下是我的PWM硬件外设代码。如果你一直跟随本系列,这里的大部分内容应该看起来很熟悉。
module pwm #(parameter COUNTER_BITS = 12) (
input wire clk,
input wire io_wstrb,
input wire io_select,
input wire [31:0] io_wdata,
output reg led
);
reg [COUNTER_BITS-1:0] counter = 0;
reg [COUNTER_BITS-1:0] pwm_count = 0;
always @(posedge clk) begin
if (io_select && io_wstrb) begin
pwm_count <= io_wdata[COUNTER_BITS-1:0];
counter <= 0;
end else begin
counter <= counter + 1;
end
if (counter < pwm_count) begin
led <= 1;
end else begin
led <= 0;
end
end
endmodule
代码解析:
- 我定义了PWM模块,并给它一个参数
COUNTER_BITS来定义PWM计数器的最大计数值位数(默认为12位,即计数从0到4095)。 - 有一个时钟输入(全局12MHz时钟)和一个写选通信号
io_wstrb。这是由SOC硬件控制的信号。每当硬件确定我们正尝试写入特定地址时,此线会拉高或产生一个时钟周期的高脉冲,这让我们知道正在尝试写入一个内存地址(可能不是真实内存地址,而是硬件驱动器)。我们需要查看该选通信号是否变高,以便寄存io_wdata中的值。 - 仅当
io_select线也为高时,此驱动器才接受数据。因此,当io_wstrb变高且io_select为高时,我们需要寄存该数据。 - 理想情况下控制4个LED,但实际上我只控制其中一个。你可以根据需要扩展以控制多个LED。
- 有一些内部寄存器元素来帮助我们记录计数值以及从代码设置的已寄存的最大计数值。
- 在每个时钟周期,首先检查
io_select和io_wstrb线是否为高。如果是,则获取IO总线上传入的io_wdata数据,并将其存储在此驱动器内部的pwm_count寄存器中。同时重置计数器。 - 如前所述,我将这些寄存器元素初始化为某个值。这在Yosys中有效,但并非对所有综合工具或所有FPGA都有效。对于Yosys和iCE40 FPGA,这种初始化设置可以节省引入复位线的需要。
- 如果
io_select和io_wstrb线不为高,计数器将持续递增。一旦达到最大值,它将回滚到0。 - 我们将当前计数值与从IO总线写入代码设置的最大计数值进行比较。如果当前计数值小于该值,则驱动LED为高电平;如果当前计数值大于或等于该值,则LED为低电平。这就产生了我们之前看到的快速闪烁效果,从而可以控制占空比,进而控制LED的亮度或电机的速度。
仿真测试


在将代码复制到SOC之前,我们先进行仿真。我位于PWM文件夹中,里面有我的Verilog代码和测试平台。


测试平台实例化了驱动器。我将写入0%占空比,设置 strobe 和 select 为1,向PWM计数寄存器写入0。然后等待几个时钟周期,将 strobe 和 select 线设回0。让其运行一段时间后,尝试约33%的占空比,然后是100%的占空比,最后结束仿真。

运行仿真后,GTKWave打开。可以看到,我脉冲了 strobe 和 select,这应该导致 wdata 被写入驱动器寄存器,并且计数器在这个时钟周期重新开始。果然,LED在整个过程中保持恒定的低电平。


然后,我向PWM计数值写入5。需要说明的是,在测试平台中,我没有使用全部12位,而是只用了4位,这样我们可以用更少的时钟周期看到PWM工作,而不必等待4000个时钟周期。因此,它应该只计数到十六进制F。
果然,可以看到计数器计数到十六进制F,然后回滚到0。然而,一旦达到5,LED熄灭,并保持熄灭状态,直到计数器值从0回滚到1,然后再次点亮。
同样,如果我们将F设置为PWM值,理想情况下LED应常亮。但由于我们的方案限制,在这个简单的PWM控制器中,它将有一个周期是熄灭的。对于我的目的来说,这可以接受。有方法可以制作更精确的PWM驱动器,实现常关和常开,但这需要额外的硬件。为了简单起见,我对LED不能始终完全点亮感到满意。


集成到SOC
现在,让我们将PWM代码集成到系统中。
首先,打开PWM代码,复制全部内容。然后,进入Raspberry Pi上的 learn_fpga 目录,再进入 femtorv,接着进入 rtl 下的 devices 目录。这里可以找到所有硬件外设的源代码。我创建自己的文件 pwm.v,并将所有代码粘贴进去,保存。
返回 femtorv 目录,我们需要开始集成刚创建的PWM驱动器。首先,修改 hardware_config_bits.v 文件。在这里,我添加自己的本地参数,定义PWM驱动器的地址位置。第12到16号槽位可用,因此让我们使用第12号作为地址。我将其定义为可写地址,可以向该寄存器写入占空比值以控制LED亮度。保存文件。
接下来,编辑大的SOC Verilog文件(femtorv_soc.v)。首先,在包含列表中包含我的PWM驱动器,就像在C/C++中使用头文件一样,这会在综合设计时将代码复制到此处。然后,向下找到端口列表。我寻找 NRV_IO_LEDS 并复制类似的内容。注意,这并不完全健壮,因为我们只能有LED或PWM定义之一。如果你尝试同时实例化两者,可能会失败,因为我们将使用D1、D2、D3、D4命名法来驱动这些引脚。你可能需要做一些额外的工作以使两者协同工作。但在这里,我只想用PWM控制板载LED(本例中仅一个),所以它们会有一点冲突。我们将在其他地方创建 NRV_IO_PWM 定义,但请注意,如果包含它,将启用D1到D5输出。如前所述,这些与LED定义冲突,因此你只能启用LED或PWM之一。你可以自由寻找允许你同时使用两者的方法,但现在我们只使用PWM驱动器,不实例化LED驱动器。
向下滚动,找到处理器实例化的地方。我将在此处添加PWM驱动器的实例化代码。你可以查看上面的内容,了解其他驱动器是如何实例化的。我将遵循他们的注释风格。查看其他外设(如SD卡)的实例化方式,可以了解连接方式和参数设置。这基本上是在一个 ifdef 块内实例化你的驱动器。
同样,我们将说如果定义了 NRV_IO_PWM,就实例化我们的PWM模块。即使12是默认值,我们也在此处明确为计数器位宽定义12。我们将其命名为 pwm,并连接一些线路:时钟连接到主时钟信号,写选通线连接到 io_wstrb,选择线连接到 io_select[12](因为我们定义了 IO_PWM_BIT 参数为12),写入数据通过 io_wdata 总线完成,LED输出连接到 D1。完成后,关闭 ifdef 块。
你可能会问,我们如何避免错误地写入此外设?如果第12位是1,无论我们写入什么地址,都会选中此外设。这与 io_wstrb 位有关。如果我们向上滚动,就在SPI Flash部分之后,可以看到写选通线仅在 mem_write_strobe 为高且内存地址的 io 位为高时才变高。这是通过查看第22位实现的,正如我们之前在幻灯片中看到的,只有当尝试写入I/O页面时,第22位才为高。如果为低,意味着我们正尝试写入RAM或SPI Flash。通过这个与门,我们就知道正在写入的是硬件外设,而不是RAM或闪存。
保存文件。
如前所述,我们需要确保LED硬件驱动器不与新的PWM外设冲突。为此,进入 configs 目录,然后找到我们特定板子的配置文件。在这里,可以看到 NRV_IO_LEDS 被定义了,我们将其注释掉,使其不被定义。然后添加我们自己对 IO_PWM 的定义。这将启用我们在 femtorv_soc.v 文件中定义的PWM驱动器。保存此文件。
现在,运行 make icestick 命令。这将编译(综合)所有内容,然后将其上传到我的iCEstick。请确保从 femtorv 目录调用此命令。这将需要几分钟时间进行综合、布局布线和上传。
软件端设置
希望一切已正确综合并上传到FPGA。现在,是时候处理软件端了。虽然我们添加了从硬件端控制驱动器所需的所有钩子,但软件端还缺少一些东西。
例如,如果你查看 femtorv32.h 头文件,可以看到里面定义了许多内容,包括帮助你和所有硬件外设正确地址空间通信的IO宏。你可以更新此头文件以包含PWM外设地址,但我不打算这样做,因为我将直接在C语言中手动完成所有操作。
退出头文件,进入 femtorv32 项目空间,我在这里保存该处理器的所有软件。可以看到我们在上一节创建的 blinky 项目。我将创建一个 pwm_test 项目并进入该目录。
和上次一样,我将包含那个 femtorv32.h 头文件,并定义 main 函数。我将创建一个永久循环,在其中从0计数到4095。这里我使用了魔数,你真正应该做的是在 femtorv32.h 头文件中,根据你在PWM设备驱动器或外设中创建的内容来定义一些常量。
我将向这个地址空间写入计数值。这个地址空间是 0x404000。简单说明一下,这应该是我的基地址 0x400000 加上 (1 << (2 + 12)) 的结果,其中12是我们在写入PWM外设时要查找的比特位。计算器验证:0x400000 + (1 << 14) = 0x404000,这就是我们得到的值。
我将延迟一毫秒,以便你能实际看到LED在亮度增加之前保持其亮度片刻。我们将缓慢增加LED的亮度,然后将其降回零,基本上创建一个亮度上的锯齿波。最后返回0以符合 int main 函数要求,尽管程序永远不应执行到这里。
保存文件。我们需要创建Makefile,并包含那个 makefile.include 文件,它应该包含我们使用RISC-V编译器构建此程序所需的所有内容,然后将其上传到iCEstick的闪存。

保存Makefile。输入命令 make main.prog,意思是构建 main.c 文件(main 是我们的目标),然后 .prog 表示将其上传到iCEstick。这应该很快。

测试结果
检查一下是否工作。运气好的话,我们应该看到LED慢慢变亮,然后在重置为关闭状态之前达到最亮。因为它需要经过超过4000个步骤,每个步骤等待一毫秒,所以完成一个周期可能需要超过四秒钟。
总结

本节课中,我们一起学习了如何为Femto RV RISC-V处理器创建并集成一个自定义的硬件PWM外设。我们从理解其内存寻址机制开始,然后设计了PWM驱动器的工作原理,编写并仿真了Verilog代码,最后将其集成到SOC中并编写了控制软件。这个过程涵盖了硬件描述、仿真验证、系统集成和软件驱动开发的基本流程。
本节没有具体的挑战任务,但我鼓励你尝试为Femto RV处理器制作自己的硬件外设。这可以是任何你想要的东西,比如SPI驱动器或控制NeoPixel的东西。如果你用Femto RV做了很酷的东西,或者实现了此外设,欢迎在Twitter上标记我们。


本FPGA入门系列到此结束。希望你在观看视频和尝试挑战时和我制作它们时一样开心。感谢你坚持到最后。一如既往,祝你 hacking 愉快!

浙公网安备 33010602011771号