UCSC-CSE228a-敏捷硬件设计笔记-全-

UCSC CSE228a 敏捷硬件设计笔记(全)

001:课程简介 🚀

在本节课中,我们将要学习敏捷硬件设计的基本概念、课程目标以及整体结构。我们将探讨为何需要采用敏捷方法来设计硬件,并了解本课程将如何帮助你掌握这些技能。


为什么需要敏捷硬件设计?

我们生活在一个极度数字化的时代,计算无处不在。从智能手机到物联网设备,再到数据中心,所有这些电子设备都由芯片驱动。那么,这些芯片从何而来?答案是我们需要设计它们。

随着应用场景的多样化,我们需要为各种不同的地方和应用设计芯片。这带来了几个关键挑战。

首先,你可能听说过摩尔定律。随着时间的推移,芯片制造技术的进步使我们能够以更低的成本获得更多的晶体管。几十年来,这确实是事实。然而,新闻头条也显示,这种进步开始放缓,遇到了一些阻碍。

无论摩尔定律是继续还是放缓,近二十年来,我们一直面临着一个重大的能源挑战。我们想要进行的计算需要能量。除非我们提高效率,否则进行更多计算将需要更多能量。因此,我们的许多设计和架构都致力于如何提高效率,以便完成更多工作。

提高效率最经典的方法之一是专业化。与其使用可以处理任何事情的通用处理器,我们不如定制硬件,使其专门执行我们想要的任务。这种专业化使其更加节能。

然而,专业化也带来了一定的后果。转向设计层面,我们想要构建更多专用芯片,那么设计这些芯片需要什么?随着晶体管尺寸越来越小,设计它们实际上变得越来越困难。尽管我们使用了许多工具来辅助,但各种约束也随之出现。因此,芯片的设计成本不断上升。

我们面临一个矛盾:一方面我们需要更多专用芯片,另一方面设计成本却在上升。如果无法解决这个问题,我们将面临一个可怕的后果:我们负担不起制造所需芯片的费用,一切可能停滞不前。

好消息是,这种情况并未发生。作为一个社区,我们已经找到了如何更高效、更富有成效地设计芯片的方法。因此,我们能够在提高生产力和效率的同时满足需求。以人工智能为例,对定制芯片的需求激增,这极大地激励了人们去解决这个问题,并且人们正在解决它。

本课程将探讨如何更高效地设计硬件,以及如何使设计过程本身更高效。


两种设计哲学:瀑布式与敏捷式

为了实现这一目标,我们可以从两种不同的设计风格来思考硬件设计,这不仅仅是硬件,更广泛地适用于工程领域。

经典的工程思维方式被称为瀑布式技术。其核心思想是进行非常周密的计划,确保每一步都正确无误,避免任何错误。这种方法在某些领域非常成功,例如土木工程。几百年前,桥梁会不时倒塌。随着土木工程变得更加严谨,能够防止这种情况发生,现在这类失败非常罕见。这是因为他们制定了非常详细的计划。

想象一下,你要建造一座大坝。你会进行所有建模、勘测和替代测试,每一步都非常谨慎。你会建造和测试模具,确保一切正确。如果一切都做对了,你就会得到一座坚固的大坝。但这里有一个关键约束:建造大坝时,你只能浇筑一次混凝土。一旦浇筑了数百万加仑的混凝土,大坝建成后,你就无法决定“我想把大坝向左移动一英尺”。这是不可能的。

那么,替代方案是什么?替代方案是敏捷。敏捷的理念是拥抱持续的变化和适应。敏捷哲学认为,你无法预先做出完美的设计。因此,你需要持续适应。这意味着你有一个迄今为止认为最好的计划,但这并不意味着没有计划,只是意味着你愿意并且能够在过程中改变计划。

举个例子,想象一名竞技运动员。当然,你会训练,保持良好的状态。但因为你是在与对手竞争,你如何能提前知道他们会做什么?你会有一些预感,一些计划,但你无法完全确定。你能做的是,识别他们的行动,适应,然后超越他们。

这两种风格都有其适用的领域。选择哪种哲学取决于你所在领域的约束条件。在只能做一次的事情上,我强烈推荐瀑布式设计方法。例如,如果你只能浇筑一次混凝土,你肯定不希望浇筑后说“让我们把它移一英尺”。你做不到。

然而,涉及软件的事情并非如此。你可以持续地适应和修改。例如,我们现在使用的许多应用程序,无论是在云端还是移动端,几乎不再向我们用户公开版本号。我们有时甚至无法分辨它是什么版本,因为它一直在持续适应和修改。

许多年轻人可能不知道,在我年轻的时候,软件发布是巨大的瀑布式过程。微软会发布全新的操作系统,一切从头开始。瀑布式方法在软件中导致了如此多的灾难性失败,因为事实证明,当你能改变时,这种适应性的方法要高效得多。

本课程的全部前提是:如果我们能够认识到硬件设计的某些方面可以使其更容易改变,而不是一成不变,更容易调整,我们就可以拥抱敏捷哲学。敏捷哲学使我们能够更好地适应我们的需求。这不仅会减少设计工作量,还会提高生产力,所有好处都会结合在一起。


传统硬件设计与敏捷硬件设计实践

将上述抽象理念带入硬件设计的实践,我们来看看它们的具体表现。

传统的硬件设计是一个非常审慎的过程,一次一步,完成一步再进行下一步。你需要设计芯片,指定各个部分,分解所有模块,每个模块都有特定的组件和规则。然后,你分配任务给团队去实现这些部分,每个部分都会进行独立测试。接着,你开始集成它们,并再次进行测试。在将所有功能部分整合在一起后,你开始尝试优化它们,可能是单独优化,也可能是集成优化。最后,你验证整个设计。这种方法有效,我们确实用这种方法设计出了功能正常的芯片。

然而,这种方法本身并不易于改变。相比之下,想象一下更敏捷的硬件设计哲学:我们会先让设计运行起来,实现一些东西,优化它,验证它,然后我们会继续调整它。每次迭代,我们都会审视设计中不满意的地方,然后去改变设计、改变实现、优化它,当然,同时持续重新测试,并一遍又一遍地循环这个过程。直到我们找到满意的结果。

在瀑布式方法中,很难提前知道最终的性能、功耗或面积(这些是评估硬件的主要指标)会如何。你可以尽力建模,可以为某些方面设定预算,但直到整个东西集成完毕,你才能确定是否达到了这些目标。相比之下,在敏捷方法中,你经常经历整个过程,所以你总是知道自己的位置。可能你的第一个设计并不出色,也许你从一个非常简单的设计开始,只是为了通过工具流程,性能数据并不好。但你知道这些数据是什么,然后你可以去优化并设定目标。

你可能会看到我用手势画圈,这是因为我在谈论将设计过程作为一个循环,以及使用工具的过程。这是本课程的一个关键点。你可以看到课程标志也是一个循环。


敏捷为何对硬件设计有益?

那么,为什么敏捷如此有帮助?答案在于认识到,当你所处的环境不是一成不变,你可以重复某些事情时,敏捷就非常有用。例如软件,或者在本例中,硬件设计,你可以再次运行编译器,再次运行工具。

想象一下,当你开始绘制模块和组件时,你可能不知道你的关键路径是什么。直到你将这些设计通过工具,实际完成布局和布线,你才知道关键路径是什么。看看那条关键路径,也许其延迟时间在性能上是可接受的,那么你可以提前完成。如果不行,你可以去攻击它、改进它。

在硬件设计中,有很多事情直到你真正动手实践,开始让设计通过工具流程,你才能知道结果。这就是敏捷对硬件如此有益的原因:你可以知道进展如何,可以看到设计通过工具时获得了什么样的性能指标等等。

验证也是如此。在行业实践中,验证通常采用一种类似“对抗性团队”的方法:设计者完成他们想要的硬件,然后把它“扔过墙”给验证工程师。验证工程师负责验证所有这些RTL代码。你可能听说过,在软件中,开发工程师与验证工程师的比例通常是1:1,而在硬件中,通常是一个RTL编写者对应两个验证工程师。部分原因是编写RTL的人并不是编写验证代码的人,他们有时甚至不进行测试。这就像把东西扔过墙一样,效果并不好。

敏捷方法将验证更早地引入流程,将测试更早地引入流程,确保所有人都在同一页上。正如我反复强调的,敏捷已经在软件领域被证明是成功的。我应该澄清一下,当我提到敏捷时,我指的是这种迭代和修订的方法,而不是特别关注Scrum、速率等具体框架术语。更广泛地说,就是拥抱修订、根据需要调整计划这一理念。

这就是我开设这门课程的原因:如何使硬件设计变得更加流畅和灵活?有趣的是,如果我们看看当前常用的语言Verilog,我认为它就像浇筑混凝土。这种语言可以构建功能正确且可靠的硬件,但人类很难一次性写对它。尽管其规范超过1000页,但Verilog有很多未定义的行为。因此,为了使代码健壮,编写Verilog的人不断使用称为linting的工具来检查他们的代码,以确保没有违反语言规则,从而导向更安全的行为。这是一个非常谨慎的交付过程,一旦写正确了,你就不想再动它。

混凝土也是如此,如果形状和位置正确,它可以持续很长时间。罗马人的混凝土至今仍在使用。问题是,一旦它在那里,就很难修改。这不仅是因为它有多“脆”,还因为你还需要改变验证。有人已经验证了那段Verilog代码,一旦你改变了它,你就打破了验证的封印,现在你必须重新验证所有东西。

使硬件能够进行敏捷设计的是新的语言和工具。新的语言和工具使我们能够适应并重新运行设计。


课程核心主题与目标

那么,我为什么如此热情地倡导敏捷硬件设计呢?原因如下:

  1. 降低设计成本:目标是减少每单位人力工作所产生的设计记录,我们希望产出更多的硬件组件。通过敏捷方法,我们可以将精力集中在需要的地方。当你看到整个设计的成本时,你会知道哪些地方做得好,哪些地方不好,从而可以有针对性地改进,避免在不必要的地方过度优化。
  2. 获得更好结果:在传统流程中,如果在设计后期集成时发现性能目标相差甚远,那将非常棘手。那时你能做的非常有限。你已经编写了所有RTL,验证了很多,现在却需要对架构进行大规模更改以达到性能目标,这简直是灾难。虽然你可以通过建模和计划来避免这种情况,但如果真的发生,你就会陷入困境。相比之下,采用更灵活的敏捷方法,你可以随时了解进展,并能在早期就认识到是否需要改变架构,从而避免陷入困境。它不仅能帮助你建模,还能让你使用真实的工具和设计来了解在这些方面的表现。
  3. 区分概念:本课程正式讨论敏捷技术,即我们所说的快速迭代等。这是一个独立的概念。开源硬件设计是另一个我同样充满热情的概念。本课程广泛使用开源硬件设计工具,但这两个概念在技术上是正交的。你可以用闭源工具进行敏捷开发,也可以用开源工具进行瀑布式开发。当然,更常见的是在同一产品中混合使用专有工具/组件和开源工具/组件。对于本课程,我们专注于敏捷,并且我所教授的一切都是开源的,但课程内容同样适用于闭源工具。

作为一名学者,我对此感到兴奋,是因为它真正实现了硬件设计的民主化。长期以来,硬件设计一直局限于拥有昂贵商业工具的大公司。现在,突然间,工具是免费的、开源的,所以等等,你不再需要大公司,也不需要大的许可证密钥来访问这些工具。再加上敏捷方法,即使是小团队甚至个人也能完成很多工作。因此,进入门槛大大降低,你需要更小的团队、更低的成本。随着成本降低,可以有更多的人生产硬件,他们可以生产符合自己特定场景的硬件。也许世界上某个应用领域被当前的产品所忽视,你现在可以走出去,用免费工具低成本进入,当然,敏捷方法让你能用更小的团队高效工作。

这太棒了。作为一名学者,我对此感到非常兴奋。我们可以让更多人参与硬件设计,纳入更多声音,让更多人参与进来,从而获得更具创造性的设计。


实现重用:生成器的作用

实现敏捷的一个关键方法是重用。开发硬件模块最快的方法是什么?当然是重用你已经有的模块。与其重新编写一个模块,不如直接从库中取出已有的模块。这是硬件设计的标准实践,人们会授权使用IP(知识产权),即现有的硬件设计,并将其集成到自己的芯片中。这在很多地方都很棒,但也有一些注意事项。

如果你要重用某个组件,它最好能做你需要的事情。这是一个技巧。如果它实现了你90%的需求,听起来很接近,你可能会想“我就改那最后的10%”。但有时这非常痛苦,特别是当修改很困难时。为了重用,你需要确保你试图重用的东西能做你想做的事,并且做得“正确”。

另外,假设有人给了你IP,如果你要将其集成到设计中,你需要确保它是正确的,你需要有信心它是正确的。那么,我们如何实现这些呢?这就是重用的目标:我们希望拥有一个能做我们想做的事、并且正确、我们有信心它正确的东西。如果我们实现了所有这些,那就太好了,我们可以重用这些组件,这使硬件设计过程更加高效。作为硬件设计师,我将把更多时间花在设计新颖独特的部分,而不是重复造轮子。

你会惊讶地发现,当你查看不同公司的内部库和组件时,这些组件看起来极其相似,因为你可能有各种交叉开关,四端口、五端口、六端口等等。原因是他们想要确切的组合,而你会惊讶于他们经常重复造轮子,因为虽然你想要的东西存在,但你只想改变10%,而这10%的改变用现有技术可能出奇地痛苦。

那么,如何使重用更容易实现呢?答案是使用生成器,而不是单一的静态组件。生成器可以生成许多组件。生成器不是随意生成任何硬件,它是针对特定组件的,只是根据一些调整生成该组件的不同变体。你给它参数,它就会为你生成代码。这就是我们构建本课程的方向:构建硬件生成器。

生成器的想法是,它比单一静态设计更灵活。更灵活意味着更具可重用性。通过能够根据我们的设计进行定制,我们可以在更多地方使用它。关于生成器的一个有趣之处在于,为了更具可用性,它们还有助于解决开源的第三个挑战。开源意味着你可以制作任何你想要的东西并放到网上,使其开源。但开源项目面临的一个挑战是获得足够的社区贡献。一个项目需要达到临界质量,即对足够多的人有用,才能吸引社区愿意回馈贡献。对于生成器来说,因为它更灵活,可以在更多地方使用,因此能够吸引更大的社区,有更多的人为其做出贡献。更大的社区将会有更大的组织来推动这项工作。

我预见到硬件设计的未来秘密在于生成器,这是让开源硬件真正普及的途径。设计生成是工具流程中的另一步。现在,工具流程是:将RTL交给综合工具等。而在RTL之前,将会有这个设计生成步骤。实际上,我们是在编写程序,而这些程序反过来为我们进行硬件设计。这不是任意的,它是在非常具体的计划控制下进行的,但这种灵活性仍然非常有帮助。


课程使用的工具:Chisel语言

在本课程中,我们将使用一种名为Chisel的语言。Chisel是一个缩写,代表“在Scala嵌入式语言中构建硬件”。我们在本课程中广泛使用这种语言。我想强调的是,本课程的目的不是学习Chisel本身,而是学习硬件设计,特别是敏捷硬件设计。Chisel是实现这一目标的绝佳工具。

那么,Chisel是什么?它是一种硬件设计语言,更准确地说,是一种硬件构造语言,用于构建生成器。它嵌入在一种名为Scala的语言中。Scala是Java家族中非常流行的语言,运行在Java虚拟机(JVM)上,因此任何可以运行Java的地方都可以运行Scala。它有很多特性:强大的静态类型系统、类型推断,还有一个解释器。在本课程中,你会看到我在Jupyter笔记本中编写代码,今天的讲座以及所有实验都在Jupyter中进行。有一个叫做Almond的工具,可以在Jupyter中提供Scala支持。这就像是一个很棒的软件环境。

我选择Scala是因为它被设计成易于创建新语言。这种语言的许多特性都是为了让你轻松创建自己的语言而设计的。因此,当加州大学伯克利分校的研究人员构建Chisel时,他们发现Scala是实现这一目标的绝佳平台,所以他们选择将硬件描述语言嵌入到Scala中。自Chisel以来,已经出现了不少下一代硬件设计语言,其中大多数都嵌入在某种其他语言中,即采用嵌入式DSL的方法。所以,Chisel是本课程的选择,但现在有很多选择。

15年前,基本上只有Verilog或VHDL。而现在,人们正在尝试新事物,这很有趣,尝试新的语言,做新的事情。就像学习软件一样,学习更多编程语言会使学习额外的编程语言变得更容易。硬件设计也是如此,学习更多硬件设计语言会使学习额外的硬件设计语言变得更容易。

Chisel能够很好地融合软件导向编程的优点,如面向对象和函数式编程,并将其引入硬件领域。它使得创建生成器变得容易,而生成器是一个强大的概念,并且已经在工业界广泛部署。

在Verilog中,人们如何实现生成器?Verilog本身是描述性的。答案是,他们采用双语言方法,使用Python、Java、Perl甚至Tcl来拼接Verilog字符串,以生成他们想要的内容。这对于像交叉开关这样的东西是有效的,你可以想象如何编写脚本来生成你想要的正确Verilog文本字符串。人们这样做,在工业界广泛使用。然而,你可以想象,这非常脆弱。你用一种语言(比如Python)运行程序,然后它只是拼接字符串。没有关于字符串需要是什么的概念。相比之下,像Chisel这样的语言,我们能够使用像Scala这样的适当编程语言直接在该语言中进行硬件设计,而不是处理字符串。很多事情会变得更好:你有更强大的类型系统,这样做生成更安全,使用单一语言也更顺畅。

看看所有下一代硬件设计语言,正如我所说,它们大多数都是嵌入式的,而不是独立的语言,正是因为他们想要构建生成器的能力。

对于那些不熟悉Chisel的人,它最初由加州大学伯克利分校构建,现在被一家名为SiFive的公司广泛使用,SiFive是主要的RISC-V公司之一。它已被谷歌和IBM部署在产品中,英特尔也尝试过但尚未发布产品。所以,这是一种有趣的语言,有一些有趣的采用案例。

正如我所说,对于本课程,我们更侧重于学习这种新浪潮的硬件设计,这种非常以软件为中心的硬件设计风格,而不是仅仅学习Chisel语言。如果你去工业界,他们可能使用其他下一代语言,但Chisel会帮助你入门。


课程核心主题总结

本课程的核心主题可以总结为以下几点,我将经常用手势画圈来强调:

  1. 尽早闭合循环:我所说的“闭合循环”指的是敏捷设计循环。换句话说,尽快让你的设计的某些方面在工具中运行起来,无论是在仿真中还是在物理设计流程中。一旦循环闭合,你有了结果,你就知道下一步应该把精力集中在哪里。我们将讨论如何将长期设计目标简化,以便尽早闭合循环,然后逐步增加功能和优化,以达到你想要的目标。
  2. 设计可重用性:通过生成器实现。我们如何设计组件以最大限度地实现可重用性?这就是为重用而设计。
  3. 让工具完成工作:自动化是关键。人工智能并不是自动化的第一次浪潮,硬件设计中已经有大量的自动化。看看硬件设计的历史,第一个晶体管大约由12个人设计,12个工程师对应1个晶体管。而现在,像苹果最新的芯片有数百亿个晶体管,由数百名甚至几千名工程师设计。从每个工程师对应的晶体管数量来看,这个数字飙升到了屋顶。这只有通过设计自动化才有可能实现。有各种各样的软件在进行这种自动化。本课程的目标是认识这些工具能做什么,它们提供了什么,以及我们如何最好地使用它们。在本课程中,使用Chisel语言使我们更容易创建硬件生成器,我们可以将以前只有人类必须做的事情交给工具来完成。当然,很多人对人工智能感到兴奋,人工智能肯定会在硬件设计中提供帮助,这又是另一种让工具完成工作的形式,即拥抱自动化。
  4. 注重代码可读性:你们都知道,代码被阅读的次数远远多于被编写的次数。因此,我们将讨论如何构建我们的设计,不仅是为了更高的性能和更强的健壮性,也是为了如何使代码更具可读性。你们将大量接触到我认为什么使代码更易读或更难读的观点。希望你们中的一些人会同意,一些人会不同意,这很好,我们可以进行讨论。

这些是核心主题:尽早闭合循环、让工具完成工作、通过生成器使我们的设计可重用,以及使我们的实际代码易于阅读。


课程结构与要求

现在进入更具体的后勤部分。谁应该学习这门课程?首先是对材料感兴趣的人。我认为有三类先验经验是有帮助的:

  1. 硬件设计经验:使用过Verilog或VHDL等语言。在加州大学圣克鲁兹分校,这对应于CSE 100或125等课程,当然还有CSE 225(125的研究生版本)。有这些经验不仅足够,甚至可以说是绰绰有余。如果你上过225,那很好,你为本课程做好了准备。
  2. 计算机体系结构知识:理解我们试图在硬件中构建什么是有用的。例如,在圣克鲁兹分校,本科生有CSE 12,研究生有CSE 124。在本课程中,我喜欢构建处理器,喜欢构建RISC-V处理器,但这并不是我们课程要做的。我们构建超低功耗设计,但坦率地说,我们不构建ISA驱动的处理器,原因有几个:首先,很多新兴硬件不是处理器;其次,处理器有很多现成的东西,比如我们知道流水线,我们想构建特定级数或特定技术,所以最终,这实际上是一个更受限制的空间。你会看到,我对硬件的看法要广泛得多,实际上有更多尝试新事物的有趣机会。所以,我不想设置不必要的障碍。总的来说,理解硬件的基本概念,比如组合逻辑、寄存器、多路复用器是什么,如何将它们连接在一起等等。
  3. 高级编程经验:本课程将利用面向对象编程和函数式编程。所以,我们希望你有这方面的经验。

如果你具备以上三项中的两项经验,那就太好了。如果你三项都有,那太棒了,你已经是一个罕见的人才了,因为通常根据你的背景,你可能更偏向电气工程、计算机工程或计算机科学培训。本课程的目标是将这些领域融合在一起。我很感激这门课程吸引了来自所有这些不同领域的学生。所以,如果你具备三项中的两项经验,你的状态很好。如果你只有一项经验,但感兴趣,我们很可能也能让你跟上。我教过这门课的学生中,有人完全没有硬件经验。他们是重返校园攻读研究生学位的行业人士,没有上过任何硬件设计课程或体系结构课程,但他们是专业的Scala开发人员。他们觉得课程有趣,阅读了Chisel的Scala源代码,他们花了时间,做得很好。我希望这门课程具有包容性,很高兴与你们讨论你们的背景。

即使这门课不完全关于硬件设计,希望你们也能拥抱这些敏捷方法,并看看它们如何应用到其他地方。


课程评估与项目

本课程的结构分为两部分。前三分之二的季度是结构化部分,你们将有像今天这样的讲座(尽管每周两次课,但内容相当于每周三讲)。然后,我们将转向项目部分。整个课程的重点实际上是项目,之前的部分只是训练你们为项目做好准备。

课程计划总是会根据需要改变和调整。例如,我的一位同事有一门课程时间完全冲突,如果有研究生想同时选修两门课,我们正在协调。

课程作业有三种类型。在结构化部分,你们每周会有一个实验和一个作业。实验设计得简单轻量,旨在直接尝试某个功能,快速且独立地完成。作业则涉及更多的编码。这是课程的初始部分。然后,你们将转向项目。项目可以单独完成,也可以与伙伴合作完成,我鼓励合作但不强制要求。在项目过程中,我们会提供一些支持,比如项目提案、反馈、代码审查等。最终,你们将构建一个项目,并在课程结束时进行展示。你们可能会注意到,这个列表中没有考试。作为一门研究生课程,我不要求考试。我认为用考试来评估这些材料是一种很奇怪的方式。

从评分权重可以看出,这门课程主要侧重于你们的项目。希望你们能做出很棒的项目。


课程资源与支持

我们拥有多种网络资源。首要去处是课程公共网站,一个静态HTML页面。所有内容都在那里,包括讲座录像、作业链接。我还编写了关于如何安装Chisel或各种工具的额外内容。这些完全对互联网开放,任何在YouTube上观看的人都可以访问。

对于在圣克鲁兹分校正式注册的学生,我们还有Canvas。Canvas的主要作用是显示你们的成绩,并通过Gradescope提交作业。在课程交流方面,我们有一个Slack。我想很多人已经加入了,邀请链接在Canvas网页上。这是一个寻求帮助的好方式,有不同作业类型的频道,你们可以向同学提问,我也会尽量快速回答。这是获得帮助的好方法。


总结

本节课中,我们一起学习了敏捷硬件设计的基本理念、其相对于传统瀑布式方法的优势,以及本课程的核心目标与结构。我们探讨了通过生成器实现设计重用、使用Chisel作为敏捷设计工具,并了解了课程的具体安排和评估方式。希望你们对即将开始的学习旅程充满期待,准备好拥抱硬件设计的敏捷未来!

002:Hello Chisel 第1部分(共2节)🚀

在本节课中,我们将开始学习Chisel,并首先了解其基础语言Scala。我们将通过实际操作来快速上手,并理解Scala的核心概念,为后续的硬件设计打下基础。


概述 📋

本节课是第二讲的第一部分,我们将初步接触Scala语言。我们的目标是快速“闭环”,即尽早开始运行代码。因此,我们将首先体验Scala,为下一节课深入学习Chisel做好准备。这种教学理念是:我们不一次性深入学习所有Scala、Chisel和硬件设计知识,而是逐步递进,先尝后学,逐步构建复杂的知识体系。


运行环境与工具 🛠️

上一节我们介绍了课程的整体安排,本节中我们来看看如何运行代码。

我们的课程幻灯片和代码已在GitHub上开源。你可以选择在本地安装Jupyter环境,但这有时会比较麻烦。为了方便,我们提供了云端运行服务Binder。

以下是关于Binder的几点说明:

  • 如果实例闲置时间过长,它会被终止。因此,在进行实验时,请记得保存你的工作。
  • 首次启动时可能需要一些时间(例如5-10分钟)来构建镜像,之后运行会更快。

如果你想在本地运行Jupyter,课程页面也提供了相关安装信息,包括由往届学生贡献的Docker文件。


为什么选择Scala?🤔

在开始编写代码之前,我们需要理解为什么Chisel选择Scala作为其宿主语言。

Chisel是一个领域特定语言(DSL)。从头开始构建一门新语言(包括语法解析器等)工作量巨大,因此团队决定将其嵌入到一门现有语言中。而Scala正是为嵌入DSL而设计的语言。

使用Scala的优势包括:

  • 强大的生态系统:Scala基于JVM,可以无缝利用海量现有的、稳定运行的Java库(例如读取JSON或数据库),这为构建复杂的硬件生成器提供了极大便利。
  • 优秀的语言特性:它具备面向对象、强大的静态类型系统、类型推断以及对函数式编程的支持。这些特性有助于我们编写健壮、灵活且高效的生成器代码。
  • 丰富的标准库:Scala提供的集合类型及其相关功能非常强大,许多常用操作都已内置,能显著提升开发效率。


Scala的运行方式 ⚙️

现在,让我们了解一下运行Scala代码的两种主要方式。

通常,你会编写代码文件,然后使用编译器将其编译为Java字节码,再由JVM执行。这种方式需要一个main函数作为入口。常用的构建工具是SBT(或较新的Mill),不过集成开发环境(IDE)通常会帮你处理这些细节。

对于本课程的教学和实验,我们将利用Scala的REPL(读取-求值-打印循环)机制。这允许我们逐行编写代码并立即看到结果,非常适合快速学习和测试。我们通过Jupyter笔记本集成了这种交互式执行环境。在REPL中,你不需要main函数,可以直接运行代码行。


Scala初体验:基础类型与变量 ✨

上一节我们介绍了Scala的运行方式,本节中我们来看看如何编写基础的Scala代码。

如果你熟悉Java,会觉得Scala的语法很相似。我们可以直接进行数学运算、处理字符串等。

1 + 4 // 结果为 Int: 5
3.0 + 2 // 结果为 Double: 5.0
"Hello" // 结果为 String: Hello

需要注意的几点:

  1. 分号:Scala通常不需要分号来结束语句,解析器能智能地判断行尾。
  2. 缩进:与Python不同,Scala的缩进仅为了代码美观,不影响功能。
  3. 万物皆对象:在Scala中,即使是基础类型(如整数2)也是对象。这意味着你可以对它们调用方法,例如4.toDouble


类型推断与注解 🏷️

Scala是静态类型语言,但拥有强大的类型推断能力。编译器通常能自动推断出表达式或变量的类型。

val anInt = 4 // 类型推断为 Int
val aDouble: Double = 4 // 显式注解为 Double 类型
val aChar: Char = 4 // 显式注解为 Char 类型

声明变量时,类型注解放在变量名之后,这是与C/Java语法的一个区别。


可变与不可变变量 🔒

Scala中有两种变量:var(可变)和val(不可变)。

  • var:声明一个可变变量,其值可以改变。
  • val:声明一个不可变变量(类似于常量),一旦赋值就不能再修改。

在本课程中,我们强烈建议并主要使用val 原因如下:

  • 避免错误:使用val可以防止因意外重新赋值而导致的难以察觉的bug。
  • 代码清晰:每个val都有单一、明确的用途,使得代码更易于理解和维护。
  • 利于优化:编译器知道值不变,可以进行更积极的优化。

尝试重新赋值给val会导致编译错误。

var mutableVar = 0
mutableVar = 5 // 正确

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/5897a48c08126e43c9ce7b1ae1b1c2c9_49.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/5897a48c08126e43c9ce7b1ae1b1c2c9_51.png)

val immutableVal = 42
immutableVal = 43 // 错误:无法对 val 变量重新赋值

虽然函数式编程中有其他技术(如递归)来处理状态变化,但在极少数确实需要改变值的场景下,可以谨慎考虑使用var。然而,本课程的所有作业都可以仅使用val来完成,过度使用var可能会导致扣分。


总结 🎯

本节课中我们一起学习了:

  1. 如何通过云端Binder或本地环境运行课程代码。
  2. Chisel选择Scala作为基础语言的原因:强大的JVM生态、对嵌入式DSL的良好支持以及优秀的语言特性。
  3. Scala的两种运行方式:传统的编译/执行模式与交互式REPL模式。
  4. Scala的基础语法,包括类型推断、万物皆对象的概念。
  5. 最重要的概念:使用不可变变量val而非可变变量var,这是编写安全、清晰、高效Scala代码的关键。

下一节课,我们将继续本讲内容,开始初步接触Chisel,并尝试运行第一个硬件生成器代码,完成学习的“闭环”。

003:Hello Chisel (第2部分) 🚀

在本节课中,我们将继续学习Chisel硬件描述语言。上一节我们介绍了Scala的基础知识,本节中我们来看看如何将Scala与Chisel结合,创建并测试一个简单的硬件模块。

概述

Chisel并非一门独立的语言,而是嵌入在Scala中的一个库。这意味着当你编写Chisel硬件设计时,实际上是在编写一个Scala程序。这个Scala程序在运行时,会生成对应的硬件电路。

Chisel类型与操作

在Chisel中,我们使用特定的类型来表示硬件信号。理解这些类型及其转换是编写正确代码的关键。

基本类型与转换

Chisel有自己的一套类型系统,与Scala的类型不同。我们经常需要在两者之间进行转换。

以下是常见的Chisel类型及其创建方式:

  • Bool: 表示单比特布尔信号。
    • 代码示例:val myBool: Bool = true.B
  • UInt: 表示无符号整数,可以指定或由工具推断位宽。
    • 代码示例:val myUint = 6.U // 推断为3比特
    • 代码示例:val myUint8 = 255.U(8.W) // 明确指定为8比特
  • SInt: 表示有符号整数。
    • 代码示例:val mySint = (-3).S

注意:使用 .B.U.S 等方法是将Scala字面量转换为对应Chisel类型的常见方式。这里的点号(.)表示调用一个转换函数。

运算符

Chisel支持大多数你期望的硬件操作符。但有一个重要的例外:相等性比较。

以下是Chisel中的关键运算符:

  • 逻辑与算术运算+, -, *, /, %, &, |, ^, ~
  • 比较运算=== (等于), =/= (不等于), >, <, >=, <=
  • 移位运算<<, >>

注意:Chisel使用三个等号 === 进行相等性判断,而不是两个。这是因为双等号在Scala语言中有特殊含义,重载它会导致意想不到的行为。

如果你忘记了某个操作符,可以随时查阅 Chisel速查表

创建你的第一个模块

现在,让我们将理论知识付诸实践,创建一个简单的硬件模块。

上一节我们介绍了Scala语法,本节中我们来看看如何定义一个Chisel模块。我们将构建一个异或门(XOR Gate)。

import chisel3._

class MyXor extends Module {
  val io = IO(new Bundle {
    val a = Input(Bool())
    val b = Input(Bool())
    val c = Output(Bool())
  })
  io.c := io.a ^ io.b // XOR 操作
}

这个模块定义了两个输入 ab,以及一个输出 c。核心逻辑只有一行:将输入 ab 进行异或运算,结果赋值给输出 c

生成Verilog

创建模块后,我们可以让Chisel将其转换为标准的Verilog代码,以便在其他工具链中使用或查看其具体实现。

println(getVerilogString(new MyXor))

生成的Verilog代码结构清晰,与我们的Chisel描述基本对应。你可能会注意到生成的代码中包含了时钟(clock)和复位(reset)信号,这些在Chisel模块中是隐式存在的,为设计提供了全局的同步时序基准。

测试你的设计

设计完成硬件后,我们需要验证其功能是否正确。这就是测试平台(Testbench)的作用。

在Chisel中,我们可以使用 ChiselTest 来编写强大的测试程序。我们可以设置(poke)输入信号,检查(expect)输出信号是否符合预期。

以下是一个测试我们 MyXor 模块的例子:

import chisel3._
import chiseltest._
import org.scalatest.flatspec.AnyFlatSpec

class MyXorTester extends AnyFlatSpec with ChiselScalatestTester {
  "MyXor" should "work" in {
    test(new MyXor) { dut =>
      // 测试所有四种输入组合
      dut.io.a.poke(false.B); dut.io.b.poke(false.B); dut.io.c.expect(false.B)
      dut.io.a.poke(false.B); dut.io.b.poke(true.B);  dut.io.c.expect(true.B)
      dut.io.a.poke(true.B);  dut.io.b.poke(false.B); dut.io.c.expect(true.B)
      dut.io.a.poke(true.B);  dut.io.b.poke(true.B);  dut.io.c.expect(false.B)
    }
  }
}

这个测试遍历了异或门所有可能的输入组合(00, 01, 10, 11),并验证输出是否与真值表一致。如果任何一个 expect 断言失败,测试就会报错,帮助我们快速定位问题。

优势:由于测试代码本身就是Scala程序,你可以利用完整的编程语言特性来构建复杂的测试场景,例如从文件读取测试向量、调用软件参考模型进行比较等。

工具链与版本说明

了解Chisel的工具链和版本差异有助于避免困惑。

从设计到仿真的流程

  1. 前端:你用Chisel(Scala)编写硬件生成器。
  2. 电路生成:运行Scala程序,生成中间表示(FIRRTL格式的电路)。
  3. 后端处理
    • 仿真:通过ChiselTest直接进行软件仿真,验证逻辑功能。
    • 生成Verilog:通过FIRRTL工具将电路转换为Verilog代码。
    • 物理实现:将Verilog代码交给FPGA或ASIC工具链进行综合、布局布线。

在本课程中,我们主要关注前端设计和逻辑仿真验证。

课程使用的Chisel版本

本课程使用 Chisel 3.6.1。虽然这不是最新版本,但它稳定且包含了我们课程核心所需的 ChiselTest 功能。较新的主版本(如5.x, 6.x)在底层架构上有重大更新,主要服务于超大规模设计,但暂时移除了 ChiselTest,因此不适合当前的教学需求。

请确保参考课程资料中针对 Chisel 3.6.1 的文档和示例。

总结

本节课中我们一起学习了Chisel硬件描述语言的核心入门知识。我们了解了Chisel的基本类型和操作符,动手创建并测试了一个简单的异或门模块,还梳理了从Chisel代码到可仿真、可综合电路的完整工具链。记住,Chisel的本质是嵌入在Scala中的硬件构造库,结合两者的力量,你可以高效地生成和验证复杂的硬件设计。

004:组合逻辑

在本节课中,我们将深入学习组合逻辑在Chisel中的实现。我们将探讨如何构建基本的逻辑门、多路选择器,并理解Scala编程与Chisel硬件描述之间的关系。通过本讲,你将掌握使用Chisel描述组合逻辑电路所需的大部分知识。


组合逻辑基础

上一节我们介绍了Chisel的“Hello World”示例。本节中,我们来看看如何构建更复杂的组合逻辑电路。

多路选择器

多路选择器是一种根据选择信号在两个输入之间进行选择的电路。在Chisel中,可以使用 Mux 原语轻松实现。

公式

output = (select == 0) ? input0 : input1

代码示例

val myMux = Mux(select, in1, in0)

注意:Mux 的参数顺序是 (条件, 条件为真时的值, 条件为假时的值),这与许多编程语言中的三元运算符语法一致。

以下是创建一个参数化位宽的多路选择器模块的步骤:

  1. 定义一个继承自 Module 的类。
  2. 在IO中声明选择信号和两个输入信号。
  3. 使用 Mux 将输出连接到相应的输入。

Scala类与参数

为了创建可重用的硬件模块,我们需要使用Scala的类。类可以接受参数,这使得模块(如多路选择器的位宽)可以灵活配置。

代码示例

class MyMux(W: Int) extends Module {
  val io = IO(new Bundle {
    val select = Input(Bool())
    val in0 = Input(UInt(W.W))
    val in1 = Input(UInt(W.W))
    val out = Output(UInt(W.W))
  })
  io.out := Mux(io.select, io.in1, io.in0)
}

在这个例子中,W 是一个整数参数,它决定了输入和输出信号的位宽。


Scala控制流与Chisel硬件生成

理解Scala代码(生成器)和Chisel代码(硬件描述)之间的区别至关重要。

If-Else 语句

Scala的 if-else 语句用于控制生成器程序的执行流。它决定在硬件生成时创建什么样的电路结构。

代码示例

// Scala if-else:决定生成哪种硬件
val invert = true
val signal = if (invert) ~x else x // 程序运行时决定,硬件是固定的

在这个例子中,invert 的值在程序运行时确定,并决定 signal 是连接到 x 还是 ~x。生成的硬件是静态的。

Mux 与 When

Chisel的 Muxwhen 语句用于描述硬件本身的条件行为。它们会在生成的电路中实例化实际的多路选择器逻辑。

代码示例

// Chisel Mux:硬件中的条件选择
io.abs := Mux(io.x < 0.S, -io.x, io.x)

// Chisel when:用于条件性连接,通常用于多个信号的赋值块
val w = Wire(UInt(8.W))
w := 1.U
when(io.cond) {
  w := 7.U
}
io.out := w

when 块通常用于需要根据条件改变多个信号连接的情况,它比多个 Mux 语句更简洁。wire 用于在模块内部声明一个可被多次赋值的信号节点。

重要概念最后连接语义。在Chisel中,对同一个信号(如 w)的多次连接,只有最后一次有效的连接会生效。这允许我们设置一个默认值,然后在特定条件下覆盖它。


位宽推断与操作

Chisel具有强大的位宽推断功能,可以减少手动指定位宽的错误。

算术操作与位宽

对于加法等操作,Chisel提供了不同的运算符来处理溢出和位宽增长。

代码示例

val a = 3.U(4.W)
val b = 5.U(4.W)
val c1 = a + b // “+” 可能截断,结果位宽为 max(4,4)=4
val c2 = a +& b // “+&” 保留进位,结果位宽为 5
  • + 操作符的结果位宽是操作数位宽的最大值,可能发生截断。
  • +& 操作符会增长结果位宽以避免溢出。

位选择与拼接

以下是常用的位操作:

  1. 位选择:使用 (n) 选择单个位,或使用 (high, low) 选择范围。

    val bit = myUInt(3) // 选择第3位
    val range = myUInt(7, 4) // 选择第7到第4位
    

    注意:在Chisel 3.6.1中,不能将位选择(如 myUInt(3))放在连接操作符 := 的左侧进行部分赋值。

  2. 位拼接:使用 Cat 将多个信号拼接起来。

    val combined = Cat(highByte, lowByte) // 拼接两个字节
    
  3. 符号扩展:一个常见的应用是将有符号数扩展到更宽的位宽。

    class SignExtend(InW: Int, OutW: Int) extends Module {
      require(InW > 0, "输入位宽必须为正")
      require(OutW > InW, "输出位宽必须大于输入位宽")
      val io = IO(new Bundle {
        val in = Input(SInt(InW.W))
        val out = Output(SInt(OutW.W))
      })
      val signBit = io.in(InW-1) // 获取符号位
      val extension = Fill(OutW - InW, signBit) // 重复符号位进行扩展
      io.out := Cat(extension, io.in).asSInt // 拼接并转换为有符号数
    }
    

    此模块通过获取输入的最高位(符号位),将其重复填充至所需的额外位数,然后与原始输入拼接,实现符号扩展。require 语句用于在参数无效时给出清晰的错误提示。


总结

本节课中我们一起学习了Chisel中组合逻辑的核心概念。我们掌握了如何使用 Muxwhen 实现条件逻辑,理解了通过Scala类参数化硬件模块的方法,并区分了Scala程序流控制与Chisel硬件描述。我们还探讨了位宽推断、算术运算符以及位操作(如选择、拼接和符号扩展)。这些知识构成了使用Chisel进行数字电路设计的基础,接下来我们将学习时序逻辑部分。

005:时序电路

在本节课中,我们将要学习时序电路,即包含状态的电路,例如寄存器。我们将探讨如何在Chisel中实现寄存器,构建带有寄存器的组件,并设计状态机。最后,我们将学习如何在仿真中观察电路行为,包括使用打印语句和波形图。


寄存器基础

上一节我们介绍了组合逻辑,本节中我们来看看如何存储数据。寄存器是硬件中存储数据的基本单元。我们熟悉的寄存器模型是:在时钟的上升沿,输入端的值会被捕获并成为寄存器的输出值,直到下一个上升沿到来。

寄存器通常还包含其他功能,例如复位和写使能。复位信号可以将寄存器设置为一个已知的初始值。写使能信号可以控制寄存器是否在时钟沿更新其值。

在Chisel中,时钟和复位信号通常是隐式处理的,这意味着它们会自动连接到设计中的所有模块。这是最常见的情况,因为大多数设计都使用同一个时钟和复位信号。当然,Chisel也支持多时钟域和同步复位,但本课程不要求掌握这些高级特性。


在Chisel中声明寄存器

在Chisel中,有几种声明寄存器的方式,它们最终都会生成一个寄存器,区别在于集成的功能多少。最基本的方法是实例化一个Reg组件并进行连线。

以下是声明一个简单寄存器的代码:

val reg = Reg(UInt(4.W))
reg := io.in
io.out := reg

对应的Verilog代码会推断出一个正边沿触发的D触发器。在Verilog中,寄存器是通过特定的always块语法推断出来的,而不是直接实例化。

如果想为寄存器指定复位值,可以使用RegInit

val reg = RegInit(0.U(4.W))
reg := io.in
io.out := reg

RegNext可以更简洁地同时指定复位值和下一个输入值:

val reg = RegNext(io.in, 0.U)
io.out := reg

如果需要写使能功能,可以使用RegEnable

val reg = RegEnable(io.in, 0.U, io.enable)
io.out := reg

这些不同的构造方法都是为了简化代码,你可以根据需求选择最合适的一种。


构建计数器

现在,让我们应用寄存器来构建一个计数器。计数器的功能是:在使能信号有效时,每个时钟周期计数值加一,达到最大值后归零。

以下是计数器的一个初始实现,它使用了多个多路选择器来处理复位、使能和归零逻辑:

class Counter(maxVal: Int) extends Module {
  val io = IO(new Bundle {
    val enable = Input(Bool())
    val out = Output(UInt())
  })
  val width = log2Ceil(maxVal + 1)
  val count = Reg(UInt(width.W))
  val nextVal = Mux(count < maxVal.U, count + 1.U, 0.U)
  val nextValEn = Mux(io.enable, nextVal, count)
  val nextValReset = Mux(reset.asBool, 0.U, nextValEn)
  count := nextValReset
  io.out := count
}

我们可以通过使用RegInit来简化复位逻辑,从而减少一个多路选择器:

val count = RegInit(0.U(width.W))
val nextVal = Mux(count < maxVal.U, count + 1.U, 0.U)
val nextValEn = Mux(io.enable, nextVal, count)
count := nextValEn

更优雅的方式是使用Chisel的when语句,它能让代码意图更清晰:

val count = RegInit(0.U(width.W))
when(io.enable) {
  when(count < maxVal.U) {
    count := count + 1.U
  }.otherwise {
    count := 0.U
  }
}
io.out := count

我们也可以使用RegEnable在一行内完成,但代码可读性会降低:

val count = RegEnable(Mux(count < maxVal.U, count + 1.U, 0.U), 0.U, io.enable)
io.out := count

以下是测试该计数器的一个例子:

test(new Counter(3)) { c =>
  c.io.enable.poke(true.B)
  c.clock.step()
  c.io.out.expect(0.U)
  c.clock.step()
  c.io.out.expect(1.U)
  c.clock.step()
  c.io.out.expect(2.U)
  c.clock.step()
  c.io.out.expect(3.U)
  c.io.enable.poke(false.B)
  c.clock.step()
  c.io.out.expect(3.U)
}

clock.step()用于让仿真时间前进一个时钟周期。你可以传递一个整数参数来前进多个周期。


枚举与状态机

为了构建清晰的状态机,我们使用枚举来为状态值赋予有意义的名称,而不是使用难以理解的数字。

在Chisel中,使用ChiselEnum来定义枚举:

object RaccoonAction extends ChiselEnum {
  val Hiding, Wandering, Rummaging, Eating = Value
}

枚举值默认从0开始自动分配,你也可以显式指定值,例如val Eating = Value(5)。枚举的主要优势是提供了单一的真相来源,使代码更易于维护和理解。

现在,让我们实现一个浣熊行为状态机。该状态机有四个状态:躲藏、徘徊、翻找和进食。输入信号包括:噪音、发现垃圾桶、发现食物。输出是当前的行为状态。

以下是使用when语句实现的状态机:

class RaccoonFSM extends Module {
  val io = IO(new Bundle {
    val noise = Input(Bool())
    val trash = Input(Bool())
    val food = Input(Bool())
    val action = Output(RaccoonAction())
  })
  val state = RegInit(RaccoonAction.Hiding)
  when(state === RaccoonAction.Hiding) {
    when(!io.noise) { state := RaccoonAction.Wandering }
  }.elsewhen(state === RaccoonAction.Wandering) {
    when(io.noise) { state := RaccoonAction.Hiding }
      .elsewhen(io.trash) { state := RaccoonAction.Rummaging }
  }.elsewhen(state === RaccoonAction.Rummaging) {
    when(io.noise) { state := RaccoonAction.Hiding }
      .elsewhen(io.food) { state := RaccoonAction.Eating }
      .otherwise { state := RaccoonAction.Wandering }
  }.elsewhen(state === RaccoonAction.Eating) {
    when(io.noise) { state := RaccoonAction.Hiding }
  }
  io.action := state
}

使用switch语句可以使代码结构更整齐:

switch(state) {
  is(RaccoonAction.Hiding) {
    when(!io.noise) { state := RaccoonAction.Wandering }
  }
  is(RaccoonAction.Wandering) {
    when(io.noise) { state := RaccoonAction.Hiding }
      .elsewhen(io.trash) { state := RaccoonAction.Rummaging }
  }
  is(RaccoonAction.Rummaging) {
    when(io.noise) { state := RaccoonAction.Hiding }
      .elsewhen(io.food) { state := RaccoonAction.Eating }
      .otherwise { state := RaccoonAction.Wandering }
  }
  is(RaccoonAction.Eating) {
    when(io.noise) { state := RaccoonAction.Hiding }
  }
}

仿真调试:打印与波形

在硬件设计中,调试至关重要。Chisel提供了两种主要的仿真调试方法:打印语句和波形图。

打印语句适用于快速查看特定时刻的值。你可以在Scala代码生成阶段使用println,或者在仿真运行时使用Chisel的printf

以下是仿真时使用printf的例子:

val count = RegInit(0.U(8.W))
when(io.enable) {
  count := count + 1.U
  printf(p"Incrementing from $count\n")
}

printf支持传统的C风格格式符(如%d),也支持更现代的字符串插值(使用p前缀)。

波形图能提供整个仿真过程中所有信号的变化历史,是更强大的调试工具。要生成波形,需要在测试中启用WriteVcdAnnotation

test(new MyModule).withAnnotations(Seq(WriteVcdAnnotation)) { c =>
  // ... 测试逻辑
}

运行测试后,会生成一个.vcd文件。你可以使用波形查看器(如GTKWave、Surfer或厂商工具)打开这个文件,直观地观察时钟、复位、数据等所有信号随时间的变化。

需要注意的是,打印和波形主要用于调试。而最终的测试应该是自动化的,使用pokeexpect来验证电路行为是否符合预期,无需人工检查输出。


数值字面量表示

在编写测试或硬件代码时,我们经常需要使用各种格式的数值。Chisel提供了灵活的数值字面量表示方法:

  • 十进制:5.U-3.S
  • 十六进制:"hdeadbeef".U
  • 二进制:"b1010".U
  • 八进制:"o777".U
  • 你可以在字符串中使用下划线提高可读性:"hff_ee_dd_cc".U
  • 可以显式指定位宽:"h1f".U(8.W)5.U(8.W)


本节课中我们一起学习了时序电路的核心——寄存器,探讨了在Chisel中声明寄存器的多种方式,并利用寄存器构建了计数器和状态机。我们还介绍了如何使用枚举使状态机代码更清晰,以及如何进行仿真调试,包括打印关键信息和生成波形图进行可视化分析。掌握这些内容是设计复杂数字系统的基础。

006:第5讲-集合(第1部分)

在本节课中,我们将要学习如何在Chisel中使用Scala的集合(Collections)来构建参数化的硬件生成器。通过集合,我们可以根据参数动态地创建和连接多个硬件组件,从而编写出更灵活、更强大的硬件设计代码。


集合简介

上一节我们介绍了顺序逻辑和寄存器。本节中我们来看看如何利用Scala的集合来管理多个硬件组件。

Scala中最常用的集合是Seq,它是“序列”(Sequence)的缩写。你可以把它想象成一个向量(Vector),它是有序的,可以通过索引(如0, 1, 2)访问元素。默认情况下,Seq是不可变的,这意味着创建后其大小和内容就固定了。

以下是一个简单的Seq示例:

val mySeq = Seq(1, 2, 3) // 创建一个包含元素1, 2, 3的序列
val firstElement = mySeq(0) // 访问索引0的元素,值为1
val isEmpty = mySeq.isEmpty // 检查序列是否为空
val length = mySeq.length // 获取序列长度

Seq提供了许多便捷的方法。例如,fill方法可以创建包含多个相同元素的序列:

val filledSeq = Seq.fill(5)(8) // 创建一个包含5个元素8的序列

请注意,fill方法接受两组参数,这是Scala函数式编程的一种风格,目前可以将其视为固定语法。

关于Scala集合库,有几点需要注意:

  • 丰富的库:Scala的集合库非常强大,许多我们认为的语言特性实际上是库提供的功能。
  • 使用Seq:在本课程中,我们主要使用不可变的Seq。虽然底层可能是ListVector,但对于硬件设计来说,性能差异可以忽略,使用Seq能让代码更清晰。
  • 查阅文档:如果你想知道Seq有哪些可用方法,可以查阅Scaladoc(Scala API文档)。

范围(Range)与循环(For Loops)

在深入硬件设计之前,我们需要了解两个关键的Scala概念:范围(Range)和循环(For Loops)。

范围提供了一种简洁的语法来生成数字序列:

val rangeExclusive = 0 until 4 // 生成序列 0, 1, 2, 3 (不包含4)
val rangeInclusive = 0 to 3    // 生成序列 0, 1, 2, 3 (包含3)
val stepByTwo = 0 until 10 by 2 // 生成序列 0, 2, 4, 6, 8
val descending = 3 to 0 by -1   // 生成序列 3, 2, 1, 0

Scala的for循环非常强大,我们通常使用迭代器风格,类似于Python:

// 基本for循环
for (i <- 0 until 4) {
  println(i) // 依次打印 0, 1, 2, 3
}

// 带条件的嵌套循环(也称为for推导式)
for {
  i <- 0 until 4
  j <- i until 4
  if i + j > 3
} {
  println(s"($i, $j)") // 打印满足条件的(i, j)对
}

这个嵌套循环相当于两层循环,并附加了一个过滤条件。for循环功能非常丰富,你可以查阅Scala文档了解更多高级用法。


实践:构建一个可参数化的延迟线(移位寄存器)

掌握了集合和循环后,我们现在将它们应用于硬件设计。让我们构建一个称为“延迟线”或“移位寄存器”的生成器。它的功能是将输入信号延迟N个时钟周期后输出。

版本1:使用Seq和fill

以下是第一种实现方式:

import chisel3._

class DelayLine(val n: Int) extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(8.W))
    val out = Output(UInt(8.W))
  })

  // 使用fill创建包含n个寄存器的Seq
  val regs = Seq.fill(n)(RegInit(0.U(8.W)))

  // 连接逻辑
  regs(0) := io.in
  for (i <- 1 until n) {
    regs(i) := regs(i-1)
  }
  io.out := regs(n-1)

  // 防止n为0导致错误
  require(n >= 1, "Delay must be at least 1 cycle")
}

代码解析

  1. Seq.fill(n)(RegInit(0.U(8.W))) 创建了一个包含n个8位寄存器的序列。
  2. 通过for循环将这些寄存器串联起来。
  3. require语句确保参数n至少为1。

这个生成器很棒,给定不同的n,它会生成结构不同的硬件。这体现了“生成器”的概念:我们用程序化的方式构建硬件设计。

版本2:使用变量(var)优化连接

第一个版本无法处理n=0(零周期延迟)的情况。我们可以使用一个var变量来优化连接逻辑,使其支持n >= 0

class DelayLineBetter(val n: Int) extends Module {
  val io = IO(new Bundle {
    val in  = Input(UInt(8.W))
    val out = Output(UInt(8.W))
  })

  var last = io.in // 初始连接指向输入
  for (i <- 0 until n) {
    val reg = RegInit(0.U(8.W))
    reg := last     // 当前寄存器连接上一个输出
    last = reg      // 更新“上一个输出”为当前寄存器
  }
  io.out := last    // 最终输出
}

代码解析

  1. 我们使用一个变量last来追踪需要连接的下一个节点。
  2. 在循环中,每个新寄存器都连接到last,然后last更新为该寄存器的引用。
  3. 这种方法更简洁,且自然地支持n=0(此时io.out直接连接io.in)。

两种方法都是有效的,你可以根据清晰度或功能需求进行选择。


在测试中使用循环

循环不仅能用于构建硬件,还能让测试变得更加高效。之前我们逐个编写测试用例非常繁琐,现在可以使用循环进行穷举测试。

假设我们有一个三输入的组合逻辑模块:

class MyLogic extends Module {
  val io = IO(new Bundle {
    val a = Input(Bool())
    val b = Input(Bool())
    val c = Input(Bool())
    val out = Output(Bool())
  })
  io.out := (io.a & io.b) | io.c
}

我们可以用嵌套循环测试所有可能的输入组合(共2³=8种):

test(new MyLogic) { c =>
  // 三层循环遍历所有布尔值组合
  for (a <- Seq(true, false)) {
    for (b <- Seq(true, false)) {
      for (cVal <- Seq(true, false)) {
        c.io.a.poke(a.B)
        c.io.b.poke(b.B)
        c.io.c.poke(cVal.B)
        c.clock.step(1) // 需要步进时钟才能让printf在每个新输入下生效
        val expected = (a & b) | cVal
        c.io.out.expect(expected.B)
        // 可以使用printf调试,但需要clock.step配合
        // printf(p"a=$a, b=$b, c=$cVal, out=${c.io.out.peek()}\n")
      }
    }
  }
}

要点

  • 循环极大地减少了编写重复测试代码的工作量。
  • 注意,如果测试中使用Chisel的printf,需要调用clock.step(1)来推进仿真时间,才能在每个新输入下看到打印输出。
  • 在这个简单例子中,预期值计算与硬件逻辑几乎相同,测试的健壮性有限。但在更复杂的设计中,我们可以用更独立的方式计算预期值,从而进行更有意义的验证。

总结

本节课中我们一起学习了Chisel硬件设计中的关键抽象工具:Scala集合。

  • 我们介绍了Seq集合的基本用法,它是管理多个硬件组件的基石。
  • 我们学习了Range和强大的for循环语法,它们能用于生成序列和迭代。
  • 我们实践了如何使用Seq.fill和循环来构建一个可参数化的延迟线(移位寄存器)生成器,这体现了用代码生成硬件结构的思想。
  • 最后,我们看到了如何在测试器中利用循环进行穷举测试,从而更高效地验证设计。

通过结合Scala的集合和流程控制,我们现在能够编写出适应不同参数、结构可变的硬件生成器,这是迈向高级硬件设计的重要一步。在下一部分,我们将继续探索更专门的硬件集合类型。

007:集合(第二部分)

在本节课中,我们将继续学习 Chisel 中的集合。上一节我们介绍了 Scala 集合(如 Seq)在硬件生成过程中的应用。本节中,我们来看看 Chisel 中用于描述硬件结构的集合类型,特别是 VecMem,并理解它们与 Scala 集合的关键区别。


硬件中的向量:Vec

Vec 是 Chisel 中的一种硬件集合类型,它表示在生成的硬件中实际存在的一组元素。与 Scala 的 Seq 不同,Vec 允许在硬件运行时动态地通过索引访问其中的元素。

核心概念是:如果你需要在硬件运行时(例如,在一个时钟周期内)根据某个信号选择不同的数据,那么你需要使用 Vec。如果只是为了在生成器代码中组织数据,则使用 Scala 集合。

以下是一个使用 Vec 实现参数化多路选择器的例子:

class MuxNWay(n: Int, w: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(Vec(n, UInt(w.W)))
    val sel = Input(UInt(log2Ceil(n).W))
    val out = Output(UInt(w.W))
  })
  io.out := io.in(io.sel) // 动态索引选择
}

这个例子中,io.in 是一个 Vecio.sel 信号在硬件运行时决定了输出哪个输入。

关于 Vec 的一个重要细节是它的类型顺序。如果你想要一组寄存器,并且需要在硬件中索引它们,正确的类型是 Reg(Vec(...)),而不是 Vec(Reg(...))。这是一个常见的混淆点。


使用 Vec 构建归约单元

让我们看一个更复杂的例子:构建一个参数化的 N 输入加法归约单元。这个单元将 N 个输入相加,最终输出一个和。

以下是实现方式:

class ReductionSum(n: Int, w: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(Vec(n, UInt(w.W)))
    val out = Output(UInt(w.W))
  })
  
  var totalSoFar = io.in(0) // 使用 var 暂存中间结果
  for (i <- 1 until n) {
    totalSoFar = totalSoFar + io.in(i)
  }
  io.out := totalSoFar
}

这段代码创建了一个不平衡的加法树。目前我们使用了 varfor 循环来实现,在后续课程学习了函数式编程概念(如 fold)后,我们可以用更优雅的方式重写它。

生成的硬件就是一连串的加法器。如果 N 很大,关键路径会很长。在实际设计中,你可能需要在中间插入寄存器来流水线化这个设计,但这取决于你的性能目标。


只读存储器:使用 Vec 初始化

Vec 可以用来方便地创建只读存储器。通过 VecInit 函数,我们可以将一个 Scala 序列转换为硬件中的常量向量。

假设我们想构建一个查找表,判断输入数是否能被某个值 X 整除:

val tableSize = 16
val divisor = 3
// 构建 Scala 序列
var seq = Seq[Bool]()
for (i <- 0 until tableSize) {
  seq = seq :+ (i % divisor == 0).B // :+ 操作符向序列追加元素
}
// 转换为硬件 ROM
val rom = VecInit(seq)
// 在硬件中,可以通过索引访问 rom
val output = rom(someIndex)

VecInit 会在硬件中生成一系列多路选择器来实现这个查找表。虽然生成的 Verilog 看起来有很多 2 选 1 MUX,但下游的综合工具会将其优化为更高效的存储结构。


存储器:Mem

Mem 代表硬件中的存储器,它不仅是可索引的,而且是有状态的(即具有存储能力)。硬件中常见的存储单元是寄存器和 SRAM。

  • 寄存器:写入需要时钟周期,但读出是组合逻辑(零周期延迟)。
  • SRAM:通常读写都需要时钟周期。

在 Chisel 中,默认的 Mem 是组合逻辑读(类似寄存器堆)。如果你需要同步读(即读也有一个周期延迟,以映射到 SRAM),可以使用 SyncReadMem

让我们看一个使用 Mem 实现寄存器文件的例子:

class RegFileImplicit extends Module {
  val io = IO(new Bundle {
    val raddr = Input(Vec(2, UInt(5.W))) // 两个读地址
    val rdata = Output(Vec(2, UInt(64.W))) // 两个读数据
    val wen   = Input(Bool())              // 写使能
    val waddr = Input(UInt(5.W))           // 写地址
    val wdata = Input(UInt(64.W))          // 写数据
  })
  
  // 使用 Mem 声明 32 个 64 位寄存器
  val regfile = Mem(32, UInt(64.W))
  
  // 读端口:组合逻辑读出
  io.rdata(0) := regfile(io.raddr(0))
  io.rdata(1) := regfile(io.raddr(1))
  
  // 写端口:在时钟上升沿,如果使能则写入
  when(io.wen) {
    regfile(io.waddr) := io.wdata
  }
}

这段代码非常简洁。使用 Mem 生成的 Verilog 比直接使用 Reg(Vec(...)) 更清晰,因为它会使用 Verilog 的数组语法。

你也可以使用更显式的读写方法:

// 显式读
io.rdata(0) := regfile.read(io.raddr(0))
// 显式写
when(io.wen) {
  regfile.write(io.waddr, io.wdata)
}

参数化的多读端口寄存器文件

生成器的强大之处在于参数化。我们可以轻松地将寄存器文件扩展为具有 N 个读端口:

class ParametricRegFile(nReads: Int) extends Module {
  val io = IO(new Bundle {
    val raddr = Input(Vec(nReads, UInt(5.W)))
    val rdata = Output(Vec(nReads, UInt(64.W)))
    val wen   = Input(Bool())
    val waddr = Input(UInt(5.W))
    val wdata = Input(UInt(64.W))
  })
  
  val regfile = Mem(32, UInt(64.W))
  
  for (i <- 0 until nReads) {
    io.rdata(i) := regfile(io.raddr(i))
  }
  
  when(io.wen) {
    regfile(io.waddr) := io.wdata
  }
}

有趣的是,这个参数化版本的代码甚至比固定两个读端口的版本更简短、更清晰。这体现了软件工程中的一个原则:有时使设计更通用、更参数化,反而能让代码更简单。


总结

本节课中我们一起学习了 Chisel 中用于硬件描述的集合类型。

  • Vec:用于描述硬件中可动态索引的向量。当需要在硬件运行时根据信号选择数据时使用它。
  • Mem:用于描述硬件中的存储器,具有状态。默认提供组合逻辑读,使用 SyncReadMem 可获得同步读以映射到 SRAM。
  • 核心区别:牢记 Seq(Scala 集合,用于生成时)和 Vec/Mem(Chisel 硬件集合,用于运行时)之间的区别。是否需要硬件动态寻址是选择的关键。

通过结合 Scala 的强大的集合操作和 Chisel 的硬件原语,我们可以构建出高度参数化且高效的硬件生成器。在接下来的课程中,我们将探索更多用于组织复杂硬件设计的结构。

008:硬件中间表示 (IR) 🧠

在本节课中,我们将要学习硬件设计中的一个核心概念:硬件中间表示。我们将探讨为什么需要它,它如何工作,以及它如何帮助我们构建更强大、更灵活的硬件设计工具。


课程概述

到目前为止,我们一直在宣扬采用更程序化的视角来构建硬件。使用 Chisel 这样的语言,你可以构建“生成器”,而不是构建单个硬件实例。我们构建的是一个可以根据某些规则生成任意硬件的程序。核心思想是,我们利用软件的最佳实践,使硬件设计更具生产力、更正确等等。

Chisel 是一个非常棒的工具。今天我们要讨论的是超越 Chisel 本身。这意味着什么?展望未来,构建你自己的硬件设计工具是可能的,并且我们可以在从使用一门语言到编写整个工具之间,找到一些更渐进的、可以做的事情。

上一节我们介绍了程序化生成硬件的理念,本节中我们来看看如何通过硬件中间表示来增强和扩展这种能力。


为什么需要超越生成器?🤔

我们一直在使用编程来实例化硬件。在 Chisel 中,我们编写 Scala 程序来编排硬件的生成。生成器非常棒,我们可以自动化连接和实例化。

生成器的好处在于,我们可以让设计是“生成的”,而不是反复重写。随着你构建这些生成器,你会不断改进它们。

然而,你可能会遇到一些情况:你想要实现的灵活性和功能超出了你目前所知的范围。为了处理更特殊、更复杂的场景,你可能需要编写大量代码,最终得到一个非常复杂、非常精巧的生成器。所有这些精力和洞察力都被锁定在那个特定的生成器里。

如果能有一种方法,让人们能够在需要时构建更复杂的生成器,并跨项目共享这些专业知识和基础设施,那将会非常酷。


从生成器到工具 🛠️

让我们更具体地思考一下。想象你正在构建一个生成器。它不仅仅是生成硬件,你还需要花时间运行设计通过一些 CAD 工具。你发现:“等等,这个效率不如我预期。这是一个关键组件,我需要花时间优化它。”

于是你进去做了一个优化。如果你的生成器不只是提交这个设计,而是提交一个经过优化的设计。然后你意识到:“你知道吗,其他生成器可能也想做同样的优化。”

突然之间,你面对的是一个完全不同类型的东西。到目前为止,在这门课程中,我们一直在讨论生成器,它们生成提供特定功能的组件。但现在,我想要的不是一个生成器,而是一种可以应用于其他代码以优化它的代码。我有了关于优化是什么样子的洞察,我想在其他生成器中重用这个优化。

如果你非常聪明,是一个优秀的 Scala 程序员,你可能会尝试在 Scala 中完成这一切。你可以创建一个非常精巧的 Scala 类,它有一些泛型特性,也许通过继承,这样如果其他人想构建一个使用我的优化的生成器,他们只需要扩展我的类并实现某些抽象方法。

起初,这似乎可行。但很快你就会遇到一些真正的挑战,例如兼容性和可组合性。如果你的优化是一个类,而其他人想在他们的生成器中使用两个优化怎么办?Scala 不支持多重继承。虽然可以继承多个特质,但如果你需要你的优化能感知其他优化,事情就开始变得复杂。

另一个问题是作用域和复杂性。在生成器内部,阶段可能很复杂。Scala 本身是一个非常大的领域,要处理任意的 Scala 类是非常困难的。

那么解决方案是什么?


转变思路:先构建,后优化 🔄

答案是,重新思考你的自动化流程如何融入。我之前提出的方法是:在构造设计时,将优化作为 Scala 模板应用。另一种方法是:如果我先构造一个设计,然后在它存在之后再进行优化呢?

现在,我的优化过程的输入输出行为发生了变化。之前,输入输出是生成器的 Scala 接口,这非常复杂。现在,输入输出变成了:我接收一个硬件设计,然后输出一个硬件设计。这是一个更简单、更受限的领域。

你现在也可以看到它如何变得更兼容、更可组合。如果有人想使用多个优化,他们可以按顺序调用多个优化。优化一接收硬件设计,优化它,然后优化二接收优化后的设计并进行第二次优化,这没有问题,因为这些工具现在都操作设计本身,而不是任意的生成器。

换句话说,有时你想构建的不是一个生成器,而实际上是一个工具。一个接收设计并修改该设计的工具。

你可以想象这些工具会非常棒。它们可能更易用、更通用、更清晰。但随之而来的问题是:构建一个工具需要什么?你如何决定将自动化放在哪里——是放在生成器里,还是放在工具里?当你熟悉两者后,你会发现这更像一个连续体,而不是一个巨大的飞跃。


复杂度与难度权衡曲线 📈

为了说明这一点,我们来看一个复杂度与难度的权衡曲线。横轴代表你想要进行的硬件转换的复杂程度,纵轴代表构建它的难度。

正如你在这门课程中已经体验到的,有时为一个非常简单的对象编写生成器,比直接编写该对象要难一点。对于一个非常简单的设计,直接编写组件可能更容易。

随着设计变得更复杂,在某个点上,单一的静态设计会变得非常复杂,相比之下,生成器可能更省力。然而,使用我们目前所学的生成器方法,在某些情况下会遇到挑战,难度会急剧上升。

我们的目标是,不让难度出现那种巨大的跃升,而是有一个更平缓的增长。这就是我们称之为自定义转换的东西。换句话说,我们不是从一开始就正确地生成设计,而是以某种方式修改已经存在的设计。


中间表示 (IR) 的作用 🧩

我们如何做到这一点呢?答案是,你需要一个中间表示。IR 是一种精心描述工具输入输出的方式。在编译器和编程语言中,IR 非常常见。你有一个定义良好的语法和语义来描述组件,这样工具就可以在这个表示上操作。

例如,你可能听说过 LLVM,它是一个用于软件的 IR 格式,它使得人们可以互换前端和后端,这正是它如此强大的原因。如果你想添加一门新语言,你只需要添加一个新的前端,将你的语言转换为 IR。如果你想支持一个新目标,你编写一个后端,接收 IR 并产生输出。如果你想进行优化,你编写一个“Pass”,它接收 IR,修改 IR,然后传递给工具流程的下一个部分。

长期以来,IR 在编译器中是内部专有的细节。LLVM 努力将其标准化并公开,这对社区来说是一个分水岭时刻。

对于硬件设计,硬件 IR 在历史上也是封闭、专有和任意的。它们也开始变得更加开放和共享。正如上一讲提到的,Chisel 1 和 Chisel 2 有某种 IR,但它没有非常形式化,也没有暴露出来。Chisel 3 是一个巨大的飞跃,它拥有了一个 IR,这使得很多事情成为可能。

使用 IR 是进行此类转换和工具构建的成熟实践,硬件领域早就该这么做了,并且过去几年已经开始这样做了。


理想的硬件工具生态系统 🌐

这展示了事情如何组合在一起。这里展示了一个假设的世界(虽然不完全存在),但你可以看到它可能的样子。

想象你在中间有一个非常棒的硬件 IR。你可以为不同的语言编写不同的前端,例如 Verilog、Chisel、PyRTL 等。它们都生成 IR。在 IR 层面,你可以编写转换 Pass,接收 IR 并输出 IR。然后,你当然有后端,可以输出 Verilog,或者针对 FPGA 或 ASIC 的代码,或者模拟器,甚至输出不同的 IR。

这就是你想要的灵活性。使用这个大型开源代码库,你可以做你想做的事情。你想创建一门新语言?当然,你只需要编写一个前端,将你的语言转换为 IR,然后你就可以重用所有现有代码。你想做一个新的转换?很好,你只需要编写一个在 IR 上操作的转换 Pass,你不需要编写整个工具链。你想支持一个新的输出范式?当然,编写一个新的后端。

这个顶层图景清楚地展示了 IR 的用处,这也是为什么编译器领域早就这么做了。我们的工具现在也开始拥抱这一点。


聚焦 FIRRTL ⚡

我之前提到过 FIRRTL。FIRRTL 是 Chisel 3 中引入的 IR,也被其他项目使用。

Chisel 2 及之前版本是一个庞大、单一、复杂的代码库,即使做一个很小的改动也需要触及大量代码,非常困难,也很难吸引贡献者,因为复杂性壁垒太高,而且有很多 Bug。因此,他们进行了一次大规模重写,让 Chisel 拥有一个前端并发出 FIRRTL,然后 FIRRTL 本身有转换 Pass 来操作和优化 FIRRTL,最后再转换为 Verilog。

当你听到我使用 FIRRTL 时,需要知道它指代多个东西:

  1. FIRRTL IR:一种规范格式,有在线文档描述其语义。
  2. .fir 文件:具体的 FIRRTL 文件,即硬件设计的实际表示。
  3. FIRRTL 库:处理 FIRRTL 的 Scala 库。这就是那个有弃用警告的库。它已进入生命周期末期,但我们今天的讲座仍会使用它,因为 FIRRTL 现在是 Circuit 中的一个方言。


Chisel 工具流程 🔄

Chisel 工具流程如下:你的硬件设计是用 Chisel 编写的 Scala 程序。当你的 Scala 程序运行时,Chisel 前端会发出 FIRRTL(你可以将其视为电路)。然后 FIRRTL 被传递给后端。

多年来,后端是那个 Scala FIRRTL 库,它生成 Verilog。现在,它是一个不同的程序,一个不同的代码库,叫做 firtool,它使用 Circuit 这个新的代码库。但从 Chisel 前端的角度来看,它仍然发出 FIRRTL。这很酷,因为引入这个新的后端不需要改变用户设计,也不需要改变 Chisel 前端。Chisel 前端仍然输出 FIRRTL,这没问题。只是后端变了。

今天我们讨论的是 FIRRTL 库(原始的后端),但原理是相通的。


FIRRTL 内部结构 🔍

让我们深入了解一下 FIRRTL,因为它仍然是广泛使用的 IR。硬件在 FIRRTL 中是如何表达的呢?

它基本上是一个语句的集合。你的硬件设计,在最高层是一个电路。电路内部可以有多个模块。模块有端口(输入和输出)。模块内部有语句,比如连接操作。语句内部可以包含表达式。例如,Connect(A, B) 是一个语句,其中对 A 的引用是一个表达式。语句可以包含其他语句,表达式也可以包含其他表达式。所有这些都有类型,类型系统将它们联系在一起。

让我们回到我们整个学期都看到的经典延迟模块。这里我们不参数化,只是一个寄存器。

Verilog 表示:

module Delay(
  input clock,
  input reset,
  input [7:0] in,
  output [7:0] out
);
  reg [7:0] reg_;
  always @(posedge clock) begin
    if (reset) reg_ <= 8‘h0;
    else reg_ <= in;
  end
  assign out = reg_;
endmodule

对应的 FIRRTL 表示 (简化概念):

circuit Delay:
  module Delay:
    input clock: Clock
    input reset: UInt<1>
    input in: UInt<8>
    output out: UInt<8>

    reg reg_: UInt<8>, clock with: (reset => (reset, UInt<8>(“h0”)))
    connect(reg_, in)
    connect(out, reg_)

在 FIRRTL 中,reg 明确表示寄存器,没有 Verilog 中那些模拟语义的混淆。它包含了所有细节:名称、类型、使用的时钟、复位方式等。连接语句也很清晰。


FIRRTL 的工作原理:转换 Pass 🚶‍♂️

FIRRTL 库如何处理输入的 FIRRTL 呢?它通过一系列 Pass 来实现。

与其用一个庞大的函数直接从 FIRRTL 到 Verilog,不如将其分解成许多小步骤。每个 Pass 只做一件事,许多 Pass 按顺序执行。这使得指定每个 Pass 应该做什么、推理其行为以及(最重要的)测试它变得容易得多。这是编译器中的最佳实践,现在被硬件领域重用。

FIRRTL 实际上在多个层次上指定了这些抽象。有 High FIRRTL(基本上是从 Chisel 前端刚出来的 FIRRTL)和 Low FIRRTL(非常接近 Verilog 的 FIRRTL)。这个编译器的过程称为“ lowering ”:你从 High FIRRTL 开始,逐步降低到 Low FIRRTL,将高级抽象简化为低级抽象。

例如,High FIRRTL 中的 when 语句在 Verilog 中并不直接存在,需要被转换为多路选择器。此外,在 High FIRRTL 中,位宽有时可以被推断,位宽推断就是一个 Pass。从技术上讲,High FIRRTL 就是所有 FIRRTL,而 Low FIRRTL 是 FIRRTL 的一个子集,其特性非常接近 Verilog 的要求。从 Low FIRRTL 到 Verilog 是一小步。


FIRRTL 转换的用途 🎯

这些 Pass 和转换可以做很多事情:

  1. 分析设计:获取分析数据或细节。
  2. 优化设计:这是我们今天讲座的原始动机。如果你想封装一个优化,你可以将其编写为 FIRRTL 转换。
  3. 插桩或特殊化:例如,在实际工业项目中,你可能需要处理扫描链。你可以在 Chisel 中通过修改模块或使用特质来实现。但如果你有一个非常特定的扫描链想为你的项目构建,你可以将其构建为一个工具,一个 FIRRTL 转换,这样就不需要修改原始设计了。


示例:FIRRTL 的强大应用案例 🚀

一个闪亮的例子是 FireSim 项目。如果你有一个大型的功耗设计并想模拟它,你可能会发现模拟器慢得离谱。FireSim 技术可以获取 Chisel 设计并在 FPGA 上运行。关键挑战在于:设计太大无法放入单个 FPGA 时如何分割?FPGA 运行速度与真实内存速度不匹配时如何处理时间虚拟化?

FireSim 本质上就是通过 FIRRTL 对设计进行插桩和修改。这是一组由研究生构建的令人印象深刻的项目,否则可能需要数十名工程师多年的工作。这一切之所以可能,就是因为有 FIRRTL 库。他们不需要担心解析输入设计和输出,只需要编写 FIRRTL 转换,并利用 Chisel 前端和 FIRRTL 库中已完成的所有工作。

另一个例子是我自己研究小组的工作:ESSENT,一个 RTL 模拟器。我们接收硬件设计并制作出非常快的模拟器。我们是如何做到的?我们不必构建整个工具链,只需要接收 FIRRTL,然后用 C++ 编写代码。我们的核心模拟器代码只有大约 5000 行 Scala。相比之下,Verilator 有超过 10 万行 C++ 代码。因为我们利用了 Scala 和 FIRRTL 库的生产力优势,许多处理硬件工具的基础工作已经完成,我们可以将所有时间花在思考新的解决方案和优化上,因此我们的模拟器更快。


未来方向:CIRCT 与 MLIR 🌉

当前的前沿是 CIRCT,它正在吸纳一切。它利用了 LLVM 的基础设施。LLVM 对编译器的影响巨大,现在其组织和部分代码正被用于硬件领域。

CIRCT 项目就是做这个的。它的结构是:CIRCT 是一个非常通用、灵活、包容的 IR,它有很多不同的部分,它们称之为“方言”。FIRRTL 就是其中一个方言。还有其他描述硬件的方式也被支持。

CIRCT 也是一个工具,相比原始的 Scala FIRRTL 库,它更快,内存消耗更少。这是未来的方向。虽然 Chisel 前端仍然输出 FIRRTL 并输入到 CIRCT,但这已成为标准。

如果你想构建一个自定义的独立工具,你可以用 CIRCT 做一些非常酷的事情。它的入门门槛比基于 Scala 的工具高一些,但你仍然可以编写自己的自定义工具。


总结 📝

本节课中我们一起学习了硬件中间表示的核心概念。

  1. 动机:我们讨论了为什么有时需要超越生成器,去构建可以操作已有硬件设计的工具。
  2. IR 的作用:IR 提供了一个定义良好、标准化的中间层,使得前端、后端和转换 Pass 可以独立开发和组合,极大地提高了工具构建的灵活性和可复用性。
  3. 聚焦 FIRRTL:我们深入了解了 Chisel 使用的 FIRRTL IR,包括其结构、工作原理(通过一系列 lowering 和优化 Pass)以及强大的应用案例(如 FireSim、ESSENT)。
  4. 生态系统:我们看到了一个理想的硬件工具生态系统蓝图,以及当前向更强大框架(如 CIRCT)发展的趋势。
  5. 核心思想:关键在于转变思维,不仅将硬件视为生成的对象,更将其视为可以被分析和转换的数据结构(IR)。这为硬件设计自动化打开了新的大门。

希望今天的课程鼓励你不仅思考如何生成设计,更思考如何构建操作设计的转换乃至完整的工具。掌握 IR 的概念是迈向高级硬件设计自动化的重要一步。

009:封装 🧩

在本节课中,我们将要学习如何通过封装来组织和管理复杂的硬件生成器代码。封装的核心思想是隐藏内部细节,提供清晰的接口,从而提高代码的可重用性、安全性和可读性。我们将学习 Scala 中的函数、递归、单例对象(工厂模式)以及 Chisel 中的 Bundle 等关键概念。


Scala 中的函数与方法

上一节我们介绍了使用循环和集合来构建参数化生成器。本节中,我们来看看如何通过定义函数来封装和复用代码逻辑。

在 Scala 中,我们使用 def 关键字来定义方法(函数)。函数可以接受参数,并可以指定返回类型。

定义简单函数:

def plusOne(n: Int): Int = n + 1

这个函数 plusOne 接受一个 Int 参数 n,并返回 n + 1

带默认参数的函数:

def plusX(n: Int, x: Int = 1): Int = n + x

调用 plusX(5) 会使用默认值 x=1,返回 6。调用 plusX(5, 2) 则返回 7

多行函数:
对于多行函数,使用花括号 {} 包裹函数体,最后一行表达式的值即为函数的返回值。

def multiLineExample(a: Int, b: Int): Int = {
    val sum = a + b
    sum * 2 // 这是返回值
}

递归函数

函数不仅可以封装逻辑,还可以通过递归来实现迭代。这对于构建某些硬件结构(如移位寄存器)非常有用。

递归求和函数:
对于递归函数,必须显式指定返回类型。

def recursiveSum(n: Int): Int = {
    if (n <= 0) 0
    else n + recursiveSum(n - 1)
}

这个函数计算从 n0 的整数和。例如,recursiveSum(4) 返回 10

斐波那契数列:

def fibonacci(n: Int): Int = {
    if (n <= 1) n
    else fibonacci(n - 1) + fibonacci(n - 2)
}

虽然这种递归实现算法效率不高,但它清晰地展示了递归思想。在硬件生成器中,递归代码只在生成时运行一次,因此不会影响最终硬件的效率。

关于递归与硬件:
一个常见的问题是:Scala 递归如何应用于组合逻辑或时序逻辑?答案是:我们使用 Scala 代码(包括递归)来决定实例化哪些硬件组件以及如何连接它们。递归是构建复杂生成器的强大工具。


使用递归构建移位寄存器

让我们将递归应用于硬件设计。回顾之前用 varfor 循环构建的移位寄存器,我们可以用递归实现相同的功能。

递归实现移位寄存器:

def shiftRegisterRecursive(in: UInt, n: Int): UInt = {
    if (n <= 0) in
    else {
        val reg = RegNext(shiftRegisterRecursive(in, n - 1))
        reg
    }
}

这个辅助函数通过递归调用自身,并逐级插入寄存器(RegNext),实现了 n 个周期的延迟。它能优雅地处理 n=0 的情况。

与使用可变变量(var)的循环实现相比,递归实现没有可变状态,完全依靠函数调用栈来管理中间值。两种方式功能相同,但递归风格更符合函数式编程的不可变思想,有时能让复杂问题的代码更清晰。


单例对象与工厂模式

随着生成器变得复杂,我们需要更好的方式来组织代码。Scala 的单例对象object)和伴生对象是实现封装和灵活构造的利器。

类与伴生对象:

// 类:可以创建多个实例
class MyPair(a: Int, b: Int) {
    def sum: Int = a + b // 无参方法可以省略括号
}

// 单例对象:与类同名,称为伴生对象
object MyPair {
    private var numPairs = 0
    def apply(a: Int, b: Int): MyPair = {
        numPairs += 1
        new MyPair(a, b) // 工厂方法,负责创建实例
    }
    def totalPairs: Int = numPairs
}
  • MyPair 类代表一个数据对。
  • object MyPair 是单例的伴生对象。它的 apply 方法是一个工厂方法。当用户写 MyPair(2, 3) 时,实际上调用了 MyPair.apply(2, 3)
  • 工厂方法可以封装构造逻辑,例如这里用于统计创建了多少个 MyPair 实例。

为什么使用工厂模式?
它将对象的构造逻辑与对象本身的行为分离开。伴生对象可以定义多个 apply 方法,以支持不同的参数组合(构造方式),这比在类内部定义多个构造函数更灵活、清晰。


在 Chisel 中应用工厂模式

我们可以将工厂模式应用于 Chisel 模块,使生成器的接口更友好、更强大。

传统的计数器模块:

class MyCounter(val max: Int) extends Module {
    val io = IO(new Bundle {
        val enable = Input(Bool())
        val count = Output(UInt())
    })
    val reg = RegInit(0.U(log2Ceil(max+1).W))
    when(io.enable && reg < max.U) {
        reg := reg + 1.U
    }.otherwise {
        reg := 0.U
    }
    io.count := reg
}

实例化时需要 new MyCounter(10)

使用伴生对象的计数器:

// 类定义
class MyCounter(val max: Int) extends Module { ... } // 同上

// 伴生对象
object MyCounter {
    def apply(max: Int): MyCounter = new MyCounter(max)
}

现在,用户可以用更简洁的 MyCounter(10) 来实例化计数器。这为未来添加更多构造方法(例如,支持范围或步长)奠定了基础。

Chisel 标准库中的计数器:
Chisel 已经提供了强大的 Counter 对象,它包含多个工厂方法。

import chisel3.util.Counter

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4f3a8a4902b64641feace121425aec2a_9.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4f3a8a4902b64641feace121425aec2a_11.png)

// 方法1:计数到 n
val (countValue, wrap) = Counter(io.enable, n)
// 方法2:按范围计数
val (countValue2, wrap2) = Counter(0 until 10 by 2)

Counter 返回一个元组 (count, wrap),其中 wrap 在计数器达到上限时变为高电平。使用标准库组件可以减少错误,提高开发效率。

模块 vs 函数:
Counter 是一个返回硬件的函数,而不是一个 Module。有时,将功能实现为函数而非模块更有优势:

  • 函数:更轻量,无需定义 IO 端口,输入输出直接作为参数和返回值。适用于小型、组合逻辑为主的部件。
  • 模块:会生成 Verilog 中的模块边界。某些 EDA 工具可能不会跨模块边界进行优化。对于需要明确层次结构或大型功能单元,使用模块更合适。

使用 Bundle 组织接口

为了管理复杂的端口信号,Chisel 提供了 Bundle。可以将其理解为类似 C 语言中的 struct,用于将相关的信号分组。

定义简单的 Bundle:

class Mag extends Bundle {
    val m = UInt(4.W) // 一个4位无符号数
}

在模块中使用 Bundle:

class MyModule extends Module {
    val io = IO(new Bundle {
        val out = Output(new Mag()) // 输出一个Mag类型的Bundle
    })
    io.out.m := 2.U // 访问Bundle内的字段
}

继承与嵌套 Bundle:
Bundle 可以继承和嵌套,以构建更复杂的数据结构。

// 继承:添加符号位
class SignedMag extends Mag {
    val s = Bool() // 符号位
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4f3a8a4902b64641feace121425aec2a_19.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4f3a8a4902b64641feace121425aec2a_21.png)

// 嵌套:包含一个SignedMag的向量
class PairBundle extends Bundle {
    val nums = Vec(2, new SignedMag())
}

在模块中,可以方便地访问嵌套的字段:io.someBundle.nums(0).s

批量连接操作符 <>
当两个 Bundle 类型兼容时,可以使用 <> 操作符进行批量连接,无需手动连接每个字段。

io.outputBundle <> io.inputBundle

这个操作符非常智能,可以处理方向(Input/Output)甚至不完全匹配的 Bundle 类型。

带方向的 Bundle:
可以在 Bundle 内部为字段指定方向。

class Handshake extends Bundle {
    val ready = Input(Bool())
    val data = Output(UInt(8.W))
}

使用 .flipped 方法可以反转 Bundle 内所有字段的方向,这在定义接口的双向端口时非常有用。


可选端口与 Option 类型

有时,我们希望模块的某些端口是可选的(例如,根据参数生成)。Scala 的 Option 类型与 Chisel 结合可以实现这一点。

Scala 的 Option 类型:
Option[T] 表示一个可能存在的 T 类型值。它有两个子类:Some(value) 表示存在值,None 表示不存在。

val maybeNum: Option[Int] = Some(4)
val emptyNum: Option[Int] = None

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4f3a8a4902b64641feace121425aec2a_25.png)

// 安全访问
if (maybeNum.isDefined) {
    println(maybeNum.get) // 输出 4
}
// 对 None 调用 .get 会抛出异常

在 Bundle 中定义可选端口:

class MaybePair(hasY: Boolean) extends Bundle {
    val x = Output(UInt(8.W))
    val y = if (hasY) Some(Output(UInt(8.W))) else None
}

在模块内部,可以根据 y.isDefined 来判断是否需要处理该端口。

class MyModule(hasY: Boolean) extends Module {
    val io = IO(new MaybePair(hasY))
    io.x := 1.U
    io.y.foreach { y => y := 2.U } // 如果y存在,则连接
}

如果 hasYfalse,则 io.yNone,不会生成对应的硬件端口和逻辑。


匿名函数与集合构造

最后,我们介绍一个有用的 Scala 特性:匿名函数。它常用于对集合进行变换操作。

使用 tabulate 构造序列:
Seq.tabulate(n)(function) 创建一个长度为 n 的序列,其中每个元素是调用 function(i) 的结果,i0n-1

// 使用完整匿名函数
val seq1 = Seq.tabulate(4)(i => i * i) // Seq(0, 1, 4, 9)

// 使用占位符语法(更简洁)
val seq2 = Seq.tabulate(4)(_ * 2) // Seq(0, 2, 4, 6)

占位符 _ 代表匿名函数的第一个参数。这种语法在函数体简单时非常方便。


总结 🎯

本节课中我们一起学习了硬件设计中的封装技术。

我们首先学习了 Scala 函数,包括如何定义、使用默认参数和编写递归函数。递归是构建参数化生成器的强大工具。

接着,我们探讨了 单例对象和工厂模式,通过伴生对象提供灵活的构造接口,并学习了如何在 Chisel 中应用此模式,包括使用标准库的 Counter

然后,我们深入研究了 Chisel Bundle,它是组织复杂接口的关键,支持继承、嵌套和方便的批量连接操作。

最后,我们了解了如何使用 Option 类型 创建可选端口,以及 匿名函数 如何简化集合操作。

封装的目标是管理复杂度:通过函数、对象和 Bundle 将细节隐藏起来,提供清晰、安全的接口。这使我们的生成器代码更易于编写、阅读、重用和维护。请务必在实验和作业中尝试这些新概念,以巩固你的理解。

010:解耦

在本节课中,我们将要学习硬件设计中的一个核心概念:解耦。我们将探讨如何让设计中的不同组件能够更灵活、独立地交互,从而提高设计的可重用性和效率。首先,我们会介绍Scala中的case class,这是一种封装参数的便捷方式。然后,我们将深入理解Chisel中的解耦接口,特别是就绪-有效协议,并学习如何使用Chisel标准库中的队列来构建解耦系统。

使用Case Class封装参数

在深入解耦之前,我们先来看一个能简化代码组织的Scala特性:case class。这是一种特殊的类,它使得封装一组相关参数变得非常简单。

以下是case class的一些关键特性:

  • 自动生成方法:无需使用new关键字即可实例化,并且自动提供了toStringequalshashCode方法的实现。
  • 方便的字段访问:可以直接通过名称访问字段。
  • copy方法:可以方便地创建现有实例的副本,并仅修改指定的字段。

例如,我们可以定义一个表示电影的case class:

case class Movie(name: String, year: Int, genre: String)
val m1 = Movie("The Avengers", 2012, "Action")
println(m1.name) // 输出: The Avengers
val m2 = m1.copy(year = 2015) // 创建一个年份不同的新实例

在硬件生成器中,case class非常适合用来封装配置参数。例如,一个计数器的参数可以这样封装:

case class CounterParams(max: Int, start: Int = 0) {
  val width = log2Ceil(max + 1) // 根据最大值计算所需的位宽
}

这样,生成器代码可以更清晰、更健壮,所有相关参数和计算逻辑都集中在一个地方,成为“单一事实来源”。

理解就绪-有效协议

上一节我们介绍了如何更好地组织代码参数,本节中我们来看看组件间通信的核心协议。当设计变得复杂,包含许多交互组件时,我们需要一种机制让它们不必在每个周期都紧密同步。这就是解耦的目标。

解耦的核心是一种称为就绪-有效的握手协议。它涉及两个组件:生产者(发送数据)和消费者(接收数据)。

该协议使用两根关键信号线:

  • valid:由生产者驱动。当valid为真时,表示生产者当前在bits信号线上提供了有效数据。
  • ready:由消费者驱动。当ready为真时,表示消费者当前可以接收数据。

数据实际传输发生的时刻,是当同一个时钟上升沿采样到validready同时为真时。我们称这一刻为“握手成功”或“触发”。

这种协议的优势在于:

  • 生产者可以暂停:当没有数据要发送时,它可以置valid为假。
  • 消费者可以施加反压:当无法处理数据时,它可以置ready为假,迫使生产者等待。

这实现了组件间的分布式协调,对于构建大型、复杂系统至关重要,因为它避免了将所有控制逻辑集中在一处的瓶颈。

在Chisel中使用解耦接口

理解了协议原理后,我们来看看如何在Chisel中方便地使用它。Chisel标准库内置了对解耦通信的支持,让我们无需手动实现这些信号。

Chisel提供了两种主要的包装器:

  • Valid:仅包含validbits。这类似于Scala的Option类型,表示“可能存在的值”。消费者必须接收数据,生产者无法感知消费者是否就绪。
  • Decoupled:包含validreadybits。这是完整的双向握手接口。

定义一个解耦输出端口非常简单:

val io = IO(new Bundle {
  val out = Decoupled(UInt(8.W)) // 一个8位宽的解耦输出端口
})

在模块内部,io.out将自动拥有validreadybits字段。

方向性Decoupled()默认表示数据从本模块输出(valid是输出,ready是输入)。如果需要接收解耦输入,则使用Flipped(Decoupled(...))

为了编写更简洁的代码,Chisel为Decoupled接口提供了一些辅助方法:

  • io.out.fire(): 当valid && ready为真时返回真,表示握手成功。
  • io.out.enq(data): 等同于同时设置bits := datavalid := true
  • io.out.noenq(): 等同于设置valid := false

避免组合环路

在使用就绪-有效信号时,一个需要警惕的陷阱是意外引入组合环路。组合环路是指信号不经过任何寄存器而直接形成反馈路径。

例如,一个错误的加法器:io.out := io.out + 1.U。这会导致输出直接反馈到输入,产生不可预测的行为。

在就绪-有效协议中,如果ready信号的生成逻辑依赖于valid信号,或者反之,就可能创建组合环路。例如,消费者逻辑是“仅当valid为真时,我才置ready为真”,而生产者的valid逻辑又依赖于消费者的ready,这就构成了环路。

最佳实践是确保模块内readyvalid信号的生成逻辑,尽可能只依赖于该模块自身的内部状态,而不直接依赖于对端的握手信号。Chisel编译器通常会检测并报告组合环路错误。

使用队列进行缓冲

纯粹的端到端解耦接口要求生产者和消费者严格同步握手。为了提供更大的灵活性,我们可以在它们之间插入一个队列

队列的作用类似于现实生活中的排队:

  • 生产者只要队列未满,就可以将数据放入(enq),而无需立即等待消费者。
  • 消费者只要队列非空,就可以从队列取出(deq)数据,而无需立即等待生产者。
  • 队列缓冲了生产速度和消费速度之间的短期波动。

在Chisel中,我们可以直接实例化标准库中的队列:

import chisel3.util.Queue
val queue = Module(new Queue(UInt(8.W), entries = 4)) // 4个条目的8位宽队列
io.in <> queue.io.enq // 连接输入到队列的入队端口
queue.io.deq <> io.out // 连接队列的出队端口到输出

Queue模块本身具有Decoupled类型的enq(入队)和deq(出队)端口。

队列有两个常用的可选参数,可以优化行为:

  • pipe (管道):当设置为true时,即使队列已满,但如果同一周期也在进行出队操作,则也允许入队。这提高了吞吐量,但可能增加关键路径或引入组合逻辑需谨慎。
  • flow (流水):当设置为true时,如果队列为空,且入队和出队在同一周期发生,数据将直接“绕过”队列存储单元,从输入传到输出,减少了一拍延迟。

实战示例:带队列的计数器

让我们通过一个例子将所学知识串联起来。我们将构建一个模块,包含一个计数器,其输出通过一个队列发送。

以下是模块的核心逻辑:

  1. 计数器在使能信号enable和队列入队端口就绪io.nq.ready同时为真时递增。
  2. 计数器的值被送入队列的入队端口。
  3. 队列的出队端口连接到模块的输出。
class QueuedCounter(maxVal: Int, queueEntries: Int) extends Module {
  val io = IO(new Bundle {
    val enable = Input(Bool())
    val count = Output(UInt()) // 用于调试的计数器值
    val out = Decoupled(UInt()) // 模块的解耦输出
  })

  val counter = Counter(maxVal)
  val queue = Module(new Queue(UInt(counter.width.W), entries = queueEntries))

  // 计数器在使能且队列可接收时递增
  when(io.enable && queue.io.enq.ready) {
    counter.inc()
  }

  // 将计数器值入队到队列
  queue.io.enq.enq(counter.value)

  // 将队列输出连接到模块输出
  queue.io.deq <> io.out

  io.count := counter.value
}

在测试中,我们可以模拟不同的场景:

  • 填充阶段:使能enable,但让输出ready为假。计数器值会填入队列。
  • 排空阶段:关闭enable,但让输出ready为真。队列中的数据会被消费。
  • 同时进行:使能enable且输出ready为真。计数和消费同时进行,队列起到平滑流量的作用。

通过调整队列深度以及pipeflow参数,可以观察系统行为的变化。

总结

本节课中我们一起学习了硬件设计中的解耦技术。我们从使用Scala case class 更好地封装参数开始,然后深入探讨了就绪-有效握手协议的原理,这是实现组件间异步通信的基础。我们学习了如何在Chisel中通过DecoupledValid 接口方便地应用该协议,并警惕了组合环路这一常见陷阱。最后,我们引入了队列作为关键的缓冲元件,它能够解耦生产者和消费者的速度,并演示了如何使用Chisel标准库的Queue模块以及其pipeflow参数来优化设计。掌握这些概念对于构建模块化、可重用且高效的大型数字系统至关重要。

011:仲裁(第一部分)

在本节课中,我们将学习硬件设计中的仲裁概念。我们将从一些基础组件开始,包括独热编码和优先级编码器,这些是构建仲裁器的重要基石。

上一讲我们讨论了去耦、队列和就绪-有效信号接口。本节中,我们将利用这些抽象来构建更高级的抽象——仲裁。当多个硬件资源需要访问同一事物时,如何协调这种访问就是仲裁。

独热编码 🎯

独热编码是硬件设计中的一种常见模式。通常,我们可以用 log n 位来表示 n 个值。但在独热编码中,我们使用 n 根线,并且在同一时刻,只有一根线为真(1),其余所有线都为假(0)。

与传统的二进制编码相比,独热编码需要更多的连线。那么为什么我们还要使用它呢?原因在于,独热编码可以使许多其他逻辑的构建变得更加简单。对于中等大小的 n,我们经常使用独热编码,因为它易于操作。

一个常见的场景是,我们有一组硬件对象,我们只想修改、选择或启用其中的一个。

以下是独热编码的一些例子:

  • 想象你在构建一个处理器,其中有一个寄存器文件。当一条指令试图写入寄存器时,它只写入一个特定的寄存器。因此,你只希望一个寄存器收到写使能信号(1),其余寄存器收到0。
  • 或者,对于一个SRAM存储器,你每次只写入一行,这就是字线。同样,只有一根字线为真。这通常被称为解码器,它将行号转换为只有对应线为真的信号。

如果我们在设计过程中有意识地使用独热编码,并保持在整个流程中都使用它,而不是频繁地在二进制和独热码之间转换,就可以避免转换开销。虽然这需要更多连线,但这些连线通常不会传输很远,CAD工具可以很好地优化,并且这种设计实际上非常分布式,易于布局。

如何实现独热编码

假设我们想实现一个模块,将二进制输入转换为独热编码输出。

输入宽度为 inWidth,例如4位。4位可以表示0到15(2^4 - 1)共16个值。因此,输出需要16根线来实现独热编码。所以输出宽度 outWidth2^inWidth

我们要求输入宽度大于0。

以下是一种使用递归的实现方法:

def toOneHot(in: UInt, outWidth: Int): UInt = {
  require(in.getWidth > 0)
  def helper(idx: Int): UInt = {
    if (idx == outWidth - 1) {
      (in === idx.U).asUInt
    } else {
      Cat(helper(idx + 1), (in === idx.U).asUInt)
    }
  }
  helper(0)
}

其工作原理是:递归函数 helper 遍历从0到 outWidth-1 的所有索引。在每一步,它检查输入 in 是否等于当前索引 idx。如果是,则对应输出位为1。最终,所有比较结果被连接起来,形成一个独热编码的向量。这本质上相当于为每个可能的输入值(0到15)创建了一个比较器。

对于2位输入,输出是4位独热码。例如,输入 0 (b00) 对应输出 0001,输入 1 (b01) 对应输出 0010,以此类推。

在Chisel标准库中,已经提供了更高效的实现:

import chisel3.util.UIntToOH
val oneHotOut = UIntToOH(binaryIn)

标准库的实现通常更巧妙,例如使用动态移位:1.U << binaryIn。虽然动态移位在硬件中并非零成本,但相比于 2^n 个比较器,它通常更高效。对于标准库组件,开发人员已经投入了大量时间进行优化,因此通常可以信任其效率。

优先级编码器 🔝

接下来我们讨论优先级编码器。它将是我们后续构建仲裁器的有用工具。

优先级编码器的作用是:给定一组信号线(一个总线),它会告诉我们第一个为真的位所在的位置(索引)。这个“第一个”可以从最高有效位或最低有效位开始,这取决于你如何定义优先级。这种排序赋予了位次优先级的概念。

这在硬件中很常见。例如:

  • 在经典的五级流水线处理器中,如果有多条指令正在写回同一个寄存器,而后续指令需要读取该寄存器,就需要进行数据前递。如果有多个写操作,你应该选择最新的值。这就存在一个优先级顺序。
  • 另一个场景是,你有一组组件,它们可能处于“满”或“空”状态,你需要找到第一个空闲槽。虽然顺序可能不重要,但你需要一个确定的答案,优先级编码器提供了一种确定性的选择方式。

在Chisel标准库中,有 PriorityEncoderPriorityEncoderOH(输出为独热码)等模块。优先级编码器常与多路选择器结合使用。

一个常见的问题是:如果输入到编码器的所有位都是0(没有位为真),正确的行为是什么?对此并没有统一标准。在某些设计中,这可能被视为无效输入;在另一些实现中,它可能返回最大索引值。在你的设计中,如果遇到这种情况,最好确保不依赖此时编码器的输出。

如何构建优先级编码器

以下是两种构建优先级编码器的思路:

第一种思路:使用与门链
这种方法使用一个由与门构成的链。输出是独热码。对于输出位 i 为真,不仅要求输入位 i 为真,还要求所有优先级高于 i 的位(例如索引更小的位)都为假。这可以通过一个逐渐增大的与门链来实现,检查 input(i) 为真,且所有 j < iinput(j) 为假。

第二种思路:使用多路选择器链
这种方法使用一系列级联的多路选择器。从最高优先级位(例如位0)开始检查。如果该位为真,则选择对应的输出值。如果为假,则检查下一个优先级位,依此类推。这本质上是一个优先级多路选择器。

在Chisel中,我们可以用递归来实现这两种结构。

使用与门链的实现示例:

def priorityEncoderGate(in: Vec[Bool]): UInt = {
  val n = in.size
  def helper(idx: Int, expr: Bool): UInt = {
    if (idx >= n) {
      0.U
    } else {
      val bit = in(idx) && expr
      Cat(helper(idx + 1, expr && !in(idx)), bit.asUInt)
    }
  }
  helper(0, true.B)
}

递归函数 helper 遍历输入位。expr 初始为真,它累积表示“之前所有更高优先级的位都为假”。当前输出位是输入位 in(idx)expr 的与。然后递归调用时,expr 更新为 expr && !in(idx),以确保后续位只有在所有更高优先级位都为假时才可能输出。

使用多路选择器链的实现示例:

def priorityEncoderMux(in: Vec[Bool]): UInt = {
  val n = in.size
  def helper(idx: Int): UInt = {
    if (idx >= n - 1) {
      n.U // 默认值,当没有位为真时返回
    } else {
      Mux(in(idx), idx.U, helper(idx + 1))
    }
  }
  helper(0)
}

递归函数 helper 检查当前位 in(idx)。如果为真,则返回当前索引 idx。如果为假,则递归检查下一个位。当到达最后一位时,返回一个默认值(例如 n,表示无效)。

Chisel标准库中的 PriorityEncoderPriorityEncoderOH 内部实现可能使用了类似多路选择器链或其他优化方法。库代码可能看起来很复杂,因为它封装了实现细节并进行了优化。对于大多数设计,建议首先使用标准库组件以获得正确功能。如果后续性能分析(如时序、面积、功耗)发现瓶颈,再针对性地进行优化。

关于多路选择器的成本:在ASIC中,多路选择器并不总是非常昂贵。CAD工具可以识别某些多路选择器模式并进行优化。在FPGA中,多路选择器资源可能更需关注,但也不应过度避免使用它们而增加设计复杂性。

本节课中我们一起学习了硬件仲裁的基础知识,重点介绍了独热编码和优先级编码器的概念与实现。独热编码通过确保单线有效简化了选择逻辑,而优先级编码器则为解决资源争用提供了确定性的选择机制。这些组件是构建复杂仲裁器(如下一部分将介绍的交叉开关)的核心模块。请继续关注第二部分,我们将利用这些基础来构建完整的仲裁系统。

012:第8讲-仲裁(第2部分/共2节)🚀

概述

在本节课中,我们将继续学习仲裁器。我们将首先研究Chisel标准库提供的仲裁器,然后动手构建自己的仲裁器,最后利用我们构建的仲裁器来创建一个交叉开关。我们将通过不断构建抽象、组件和生成器,来搭建越来越复杂的硬件模块。

什么是仲裁?⚖️

上一节我们介绍了独热编码和优先级编码器,本节中我们来看看仲裁。

仲裁出现在需要决定谁有权访问稀缺资源的情况下。当消费者数量超过资源提供者数量时,就需要一种方法来决定谁可以访问该资源,这个过程就是仲裁。

例如,当只有一个请求者时,处理很简单。但当多个请求者同时请求,而我们无法满足所有请求时,就需要仲裁。仲裁器内部的算法决定了当出现多个请求时,谁将获胜。

以下是几种常见的仲裁算法:

  • 固定优先级:某个实体始终拥有更高的优先级。
  • 轮询:尝试以更公平的方式分配访问权。

具体使用哪种算法取决于应用场景。今天,我们将处理只有一个资源可供授予的情况。这是一个非常常见的硬件设计模式。

Chisel标准库中的仲裁器🔧

与Chisel标准库中的许多组件一样,仲裁器也经过了精心设计,使其易于使用。

仲裁器接口

如下图所示,仲裁器有一个参数化的输入端口数量和一个输出端口。

每个输入端口都使用Decoupled接口,因此可以指示请求是否有效。仲裁器会通过ready信号(图中未明确画出)进行响应,告知请求者其请求是否成功被授予。

输出端口也是Decoupled接口,它也有ready信号。这个ready信号来自仲裁器所竞争的资源,表示该资源是否准备好接收请求。

基本上,如果资源就绪,信息会传递到仲裁器。仲裁器会看到多个有效的请求,并通过其内部逻辑决定谁获胜。获胜者将收到ready信号。

这是一个需要注意组合逻辑循环的情况:ready信号可能依赖于valid信号。但在这里,这是不可避免的,因为仲裁器不会向没有发出请求的端口发送ready信号。

仲裁器类型

Chisel提供了几种类型的仲裁器:

  • Arbiter:默认仲裁器,使用固定优先级。优先级高的请求者总是获胜,但这可能导致饥饿问题。
  • RRArbiter:轮询仲裁器。在所有请求者中轮转授予权限,试图更公平。
  • LockingRRArbiter:带传输锁定的轮询仲裁器。默认仲裁器可能每个周期都重新决定,而这个仲裁器允许获胜者保持访问权多个周期。

使用示例

我们将演示如何使用标准库中的仲裁器。这个模块主要是对Chisel仲裁器进行封装。

首先,我们定义模块的IO,包含nDecoupled输入端口和一个Decoupled输出端口。

为了连接所有n个输入端口,我们使用一个for循环。这里我们使用了批量连接操作符<>,因为它可以一次性连接Decoupled接口中的所有信号(readyvalidbits)。

// 示例:连接仲裁器输入(使用for循环和批量连接)
for (i <- 0 until n) {
  arb.io.in(i) <> io.in(i)
}
io.out <> arb.io.out

批量连接操作符非常有用。如果没有它,我们需要手动连接每个信号,代码会冗长很多。

// 如果没有批量连接操作符,需要这样写:
io.in(i).ready := arb.io.in(i).ready
arb.io.in(i).valid := io.in(i).valid
arb.io.in(i).bits := io.in(i).bits

我们还可以使用更简洁的批量连接方式,一次性连接整个向量:

// 更简洁的向量批量连接
arb.io.in <> io.in

在测试平台中,我们将输出设置为始终就绪。对于每个输入端口,我们根据周期数设置其valid信号,以模拟不同的请求模式。

运行测试后,我们可以通过打印信息观察仲裁行为。例如,使用固定优先级仲裁器时,端口0总是获胜。切换到轮询仲裁器后,获胜者会按顺序轮转。

构建我们自己的仲裁器🔨

看过了库中的仲裁器,现在让我们尝试自己构建一个。如果觉得复杂,我们可以一步步来分析。

设计思路

仲裁器的核心功能是:接收N个请求,选出一个获胜者。对于我们构建的这个基础仲裁器,其核心是一个优先级编码器。

基本设计如下:优先级编码器接收所有端口的valid信号作为请求输入。根据编码结果,我们可以通过一个多路选择器将获胜者的bits数据路由到输出。同时,我们还需要告诉获胜的端口它获得了授权。

输出端口的valid信号是所有输入valid信号的逻辑或。如果输出端口没有就绪,那么没有人应该获得授权。

代码实现

以下是实现代码。首先定义IO,包括输入端口向量和输出端口。

为了方便后续操作,我们创建一些线网来收集所有输入的valid信号和bits数据。同时,将所有输入端口的ready信号初始设置为false

仲裁的关键是将所有valid信号输入到一个优先级编码器。我们使用PriorityEncoderOH来获得一个独热编码的获胜者向量。

然后,我们使用Mux1H(独热编码多路选择器)根据获胜者向量来选择要输出的数据。

输出valid信号是所有输入valid信号的逻辑或归约(valids.reduce(_ || _))。

最后,我们需要在事务完成时(即输出valid且输出ready为真时),将获胜端口的ready信号设置为true。我们可以将独热编码的获胜者解码为索引,然后设置对应端口的ready信号。

// 自定义仲裁器核心逻辑示例
val valids = io.in.map(_.valid)
val bits = io.in.map(_.bits)
io.in.foreach(_.ready := false.B) // 初始化为false

val winner = PriorityEncoderOH(valids)
io.out.bits := Mux1H(winner, bits)
io.out.valid := valids.reduce(_ || _)

val fire = io.out.valid && io.out.ready
when (fire) {
    val winnerIndex = OHToUInt(winner)
    io.in(winnerIndex).ready := true.B
}

代码优化

我们可以进一步优化代码,使其更接近最初的框图设计。例如,可以直接根据优先级编码器的输出和与门逻辑来设置ready信号,这样就无需将独热编码解码为索引。

// 优化后的ready信号设置
io.in.zip(winner).foreach { case (port, win) =>
    port.ready := win && io.out.ready && io.out.valid
}

运行这个自定义仲裁器,其行为与之前的基础仲裁器一致。

使用仲裁器构建交叉开关🔀

现在我们有了仲裁器,可以继续向上构建更复杂的抽象:交叉开关。

交叉开关是一种网络组件,它有若干输入和输出端口。在一个交叉开关中,任何输入都可以连接到任何输出。

这带来一个问题:多个输入端口可能同时请求同一个输出端口,这就产生了仲裁的需求。在我们的设计中,我们将在每个输出端口前放置一个仲裁器

从每个输入端口到每个输出端口的仲裁器之间都有连线,因此内部有很多连线。但逻辑主要集中于每个输出端口的仲裁器上。我们继续使用Decoupled接口来进行流控通信。

交叉开关实现

首先,我们需要定义通过网络的消息类型。消息包含目标地址(用于标识要访问哪个输出端口)和实际的数据负载。

然后定义交叉开关的IO:一个Decoupled消息类型的输入向量,和一个Decoupled消息类型的输出向量。

在交叉开关内部,我们为每个输出端口声明一个仲裁器。这里我们选择使用轮询仲裁器以实现公平性。

关键的连接部分通过嵌套的for循环完成:

  1. 对于每个输入端口,我们需要收集来自所有仲裁器的ready信号。
  2. 对于每个输出端口(及其仲裁器),我们需要将每个输入端口连接上去。这里有一个细节:输入端口发送的消息中包含地址字段。只有当这个地址与当前输出端口索引匹配时,该输入端口对这个仲裁器的请求才是有效的。我们通过一个比较器来实现这一点。

最后,将每个仲裁器的输出连接到对应的交叉开关输出端口。

// 交叉开关连接核心逻辑示例
for (i <- 0 until nIn) {
    // 收集该输入端口的ready信号(来自所有仲裁器)
    val arbsReady = io.out.map(o => o.ready) // 简化示意
    // ...
}
for (o <- 0 until nOut) {
    for (i <- 0 until nIn) {
        // 将输入i连接到仲裁器o
        val arb = arbiters(o)
        // 请求有效条件是:输入有效,且其地址指向当前输出o
        arb.io.in(i).valid := io.in(i).valid && (io.in(i).bits.addr === o.U)
        arb.io.in(i).bits := io.in(i).bits.data
        // ready信号连接已在另一个循环处理
    }
    io.out(o) <> arb.io.out
}

这个大约20行Chisel代码实现的交叉开关,可以参数化输入/输出端口数量以及消息负载大小。如果用Verilog编写,代码量会大得多。这展示了生成器的强大之处。

运行测试,我们可以看到输入端口根据其消息中的地址,将数据发送到不同的输出端口。由于使用了轮询仲裁器,当多个输入竞争同一输出时,访问权会公平轮转。

总结🎯

本节课中,我们一起深入探讨了仲裁器。

我们首先回顾了仲裁的概念,即解决多个请求者竞争单一资源的问题。接着,我们研究了Chisel标准库中提供的几种仲裁器,包括固定优先级、轮询和带锁定的轮询仲裁器,并学习了如何使用它们。

然后,我们亲自动手,从零开始构建了一个自己的仲裁器,其核心是利用了之前学过的优先级编码器。通过这个练习,我们加深了对仲裁器内部工作原理的理解。

最后,我们将仲裁器作为基础构建块,创建了一个功能更强大的组件——交叉开关。在交叉开关中,我们为每个输出端口配备了一个仲裁器,从而解决了多输入对单输出的竞争问题。

通过从优先级编码器到仲裁器,再到交叉开关的构建过程,我们实践了利用抽象和生成器来逐步搭建复杂硬件系统的设计方法。在后续课程中,我们还将看到如何让这些代码变得更加简洁和优雅。

013:测试 🧪

在本节课中,我们将要学习硬件设计中的测试。测试是确保硬件功能正确性的关键环节,也是敏捷开发流程中不可或缺的一部分。我们将探讨测试的重要性、如何测试组合逻辑与时序逻辑模块,并介绍如何利用Scala、Chisel和ChiselTest等工具高效地组织和执行测试。

上一节我们介绍了如何使用优先级编码器和独热编码构建仲裁器与交叉开关。本节中,我们来看看如何为这些设计建立可靠的测试。

为什么需要测试? 🤔

测试至关重要,原因如下:

  1. 确保硬件按预期工作。
  2. 向自己和他人证明硬件功能正确。
  3. 在开发过程中,测试有助于设计出更好的硬件。如果测试不仅是最终环节,而是贯穿整个开发流程,将有助于设计出更正确、更易于测试的硬件,并提高测试质量。

因此,测试是开发流程的一部分,也是我们今天课程的核心论点。

测试的组成部分 🧩

一个完整的测试包含三个主要部分:

  1. 测试用例:定义需要测试的具体场景。最初可以手动编写,对于输入较多的情况,可能需要使用代码生成,例如随机生成或定向随机生成。
  2. 正确输出:对于给定的输入,硬件模块应产生的预期结果。初期可以手动指定,但更可靠的方法是使用模型生成。我们编写一个纯Scala的功能模型,用它来计算预期输出,并与Chisel设计的结果进行比较。
  3. 测试执行:驱动硬件模块、施加输入、获取输出并进行比较的机制。本课程使用基于Scala的模拟器Treadle来执行Chisel代码的仿真。测试的组织和执行通过ChiselTestScalaTest来完成。

以下是测试流程的简要概述:

  • 输入同时提供给被测设计功能模型
  • 模型计算出预期输出。
  • 测试框架自动比较DUT的输出与模型的预期输出。

测试的考量与最佳实践 💡

在编写测试时,需要考虑以下几点:

  • 尽早闭环:采用测试驱动开发,尽快让最简单的测试用例在最简单的模块版本上运行起来。
  • 覆盖率:确保测试覆盖了硬件模块的所有功能。
  • 测试视角
    • 黑盒测试:不了解模块内部实现,只根据功能规格进行测试,有助于全面验证。
    • 白盒测试:了解内部实现,可以针对代码中的边界情况和特殊逻辑进行测试。建议结合两者。

测试应成为开发流程的有机组成部分,而不仅仅是最终验证。这包括:

  • 持续集成:让测试在后台自动运行。
  • 协作:当外部贡献者提交代码时,运行测试是验证其正确性的关键。
  • 设计空间探索:对于参数化的硬件生成器,可以使用测试来评估不同参数下的性能。

为测试而设计:在早期考虑测试,可以调整模块边界和抽象层次,使测试更容易。例如,将设计分解为无状态的组合逻辑部分和有状态的时序逻辑部分,可以简化测试。

测试参数化生成器 🛠️

Chisel鼓励编写生成器而非单一设计。测试生成器意味着需要测试多种参数组合。我们推荐的方法是参数化测试环境。用户可以为生成器的特定参数实例化测试平台,从而验证他们实际使用的设计,而无需为每个可能的参数组合编写独立的测试。

测试组合逻辑模块 ➕

组合逻辑模块无内部状态,其输出仅取决于当前输入,因此相对容易测试。如果输入空间和参数组合的可能性足够小,可以进行穷举测试,从而获得极高的置信度。

让我们以符号-数值加法器为例。其功能是:若符号相同,则输出同号,数值相加;若符号不同,则输出绝对值较大者的符号,数值相减。

首先,我们建立一个功能模型。由于硬件设计有固定的位宽 W,需要考虑截断行为,而Scala的Int类型是32位。模型需要模拟这种位宽限制下的加法与截断。

// 符号-数值加法模型示例(概念)
def signMagAddModel(a: (Boolean, Int), b: (Boolean, Int), w: Int): (Boolean, Int) = {
  // 实现考虑位宽w的符号-数值加法与截断逻辑
  // ...
}

有了模型后,我们可以编写一个测试函数来自动化输入激励和输出验证。

def testAddition(a: Int, b: Int, dut: SignMagAdder): Unit = {
  // 根据a, b设置dut的输入(符号和数值)
  poke(dut.io.a.sign, a < 0)
  poke(dut.io.a.mag, a.abs)
  // ...
  // 计算模型预期输出
  val expected = signMagAddModel((a<0, a.abs), (b<0, b.abs), dut.w)
  // 验证dut输出
  expect(dut.io.out.sign, expected._1)
  expect(dut.io.out.mag, expected._2)
}

这样,添加新的测试用例就变得非常简单,只需一行代码调用 testAddition 函数,无需手动计算和验证输出。

对于小位宽,我们可以轻松进行穷举测试。当位宽增大导致穷举不可行时,可以采用随机测试,随机生成大量输入进行验证。

使用 ScalaTest 组织测试 📚

我们使用 ScalaTest 作为测试框架。它提供了清晰的结构来组织测试用例。ChiselTest 构建于 ScalaTest 之上。

import org.scalatest.flatspec.AnyFlatSpec
import chiseltest._
import chisel3._

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/9030807e2298d0684ac3f1365257bccc_83.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/9030807e2298d0684ac3f1365257bccc_85.png)

class SignMagAdderTest extends AnyFlatSpec with ChiselScalatestTester {
  behavior of "SignMagAdder"
  // 测试用例1
  it should "add positive numbers correctly" in {
    test(new SignMagAdder(4)) { dut => testAddition(2, 3, dut) }
  }
  // 测试用例2
  it should "handle overflow with truncation" in {
    test(new SignMagAdder(3)) { dut => testAddition(4, 4, dut) }
  }
}

behavior ofit should 语法使得测试报告非常易读。测试运行时会显示清晰的通过/失败信息。

测试时序逻辑模块:队列 ⏳

时序逻辑模块(如队列)包含内部状态(寄存器),测试更具挑战性,因为输出不仅取决于当前输入,还取决于历史输入。穷举测试通常不可行。

我们为Chisel的 Queue 模块构建一个测试。关键在于建立一个行为模型。我们可以使用Scala的 mutable.Queue 来模拟队列的行为,但需要仔细建模其就绪-有效握手接口和内部状态更新时机。

模型需要模拟:

  • deq 端是否有效(valid):取决于内部队列是否非空。
  • 尝试出队(deq):仅在 deq.readydeq.valid 同时为真时发生,从模型队列中弹出元素。
  • 尝试入队(enq):取决于队列是否未满,或是否启用了管道模式(pipe)。

关键点:在软件模型中更新内部状态(如 mutable.Queue)时,需模拟硬件寄存器的行为。最佳实践是,在一个“周期”内,先读取所有寄存器的值用于计算,最后再更新寄存器的值,以避免组合逻辑反馈问题。

建立模型后,我们编写一个自动化交互函数,在每个周期:

  1. 根据测试意图(是否入队、出队、数据值)设置DUT的输入。
  2. 同时更新模型的状态。
  3. 比较DUT的输出与模型的预期输出。

def stepQueue(enqValid: Boolean, enqData: Option[Int], deqReady: Boolean, dut: Queue[UInt], model: QueueModel): Unit = {
  // 1. 设置DUT输入
  poke(dut.io.enq.valid, enqValid)
  enqData.foreach(data => poke(dut.io.enq.bits, data.U))
  poke(dut.io.deq.ready, deqReady)
  // 2. 更新模型并获取预期输出
  val (expectedDeqValid, expectedDeqData) = model.step(enqValid, enqData, deqReady)
  // 3. 比较DUT输出
  expect(dut.io.deq.valid, expectedDeqValid)
  if(expectedDeqValid) {
    expect(dut.io.deq.bits, expectedDeqData.get.U)
  }
}

有了这个基础框架,编写有意义的测试用例就变得非常简洁:

// 填充队列
for (i <- 0 until 5) {
  stepQueue(enqValid = true, enqData = Some(i), deqReady = false, dut, model)
}
// 排空队列
for (i <- 0 until 5) {
  stepQueue(enqValid = false, enqData = None, deqReady = true, dut, model)
}

我们也可以轻松实现随机测试,随机决定每个周期是入队、出队还是两者同时进行。

总结 🎯

本节课中我们一起学习了硬件设计测试的核心概念和方法:

  1. 自动化是关键:目标是构建自动化的测试,通过编写代码和抽象来生成测试用例、执行验证,减少人工干预。
  2. 测试三要素:测试用例、预期输出(通常来自功能模型)和测试执行框架。
  3. 模型生成法:使用高级语言(Scala)编写参考模型,是生成可靠预期输出的有效方法。
  4. 分层测试:对于组合逻辑,可进行穷举或随机测试。对于时序逻辑,需构建状态模型并自动化周期级交互。
  5. 工具链:利用 ChiselTest(基于 ScalaTest)和 Treadle 模拟器,可以高效地组织、运行和调试测试。
  6. 测试是开发的一部分:采用测试驱动开发、持续集成和为测试而设计的思想,能显著提高硬件开发的质量和效率。

记住,查看波形和打印语句对于调试很有用,但那不是自动化测试。断言是测试的补充,可以帮助在问题发生时立即捕获,但也不能替代完整的测试。通过构建强大的测试基础设施,你可以更自信地进行迭代和优化你的硬件设计。

014:函数式编程介绍 🚀

在本节课中,我们将要学习函数式编程(FP)的核心概念,并了解如何将其应用于Scala和Chisel,以编写更简洁、可重用且易于阅读的硬件生成器代码。函数式编程强调使用函数来转换集合中的元素,而不是通过传统的循环步骤来操作数据。


什么是函数式编程? 🤔

从标题幻灯片可以看到,我们将讨论函数式编程。许多人可能想知道函数式编程具体指什么。它指的是编写正确的、功能性的程序,并在程序中使用函数。函数式编程包含一系列特定的理念、哲学和原语。

在本课程中,我们不会深入探讨语言理论,而是以一种非常非正式、直观的方式来讨论它。我们之所以讨论它,是因为它与我们敏捷硬件设计的四大目标紧密相关:

  1. 尽早闭环(如课程徽标所示)。
  2. 设计可重用性
  3. 让工具完成工作
  4. 设计可读性

事实证明,如果正确运用函数式编程,我们可以在所有这四个目标上获得收益。正确使用函数式编程可以让我们更快地编写代码,从而更早地闭环。它还能增加代码的重用性,减少我们需要手动完成的工作,并且在明智地使用时,可以使代码更易于阅读。当然,这中间需要平衡,我们将在课程中讨论这一点。

接下来的三讲(本周的两节面授课)将全部围绕函数式编程展开,探讨如何将其应用于Scala和Chisel。我们很幸运,这三讲恰好安排在同一周内。

函数式编程的核心理念是,思考如何将函数应用于一个元素集合,而不是像我们传统编程教学那样,思考操作数据的步骤。这意味着,我们考虑的是“我有一堆东西,以及我希望如何转换这些东西”,并使用一种结构化的、有规则的方式来描述这个过程。这就是本课程中函数式编程(FP)的含义。

为了做到这一点,我们需要像往常一样,先学习一些Scala知识,因为Chisel构建于Scala之上。


为什么需要集合和函数? 📦

Chisel的目标是创建可读的硬件生成器。如果你只是编写顺序执行的代码,虽然也有好处,但真正的威力在于参数化的生成器,它能带来高度的重用性。

正如我们在过去一两周看到的,这些生成器不仅仅是简单的模板(比如改变位宽),它们实际上可以改变内部拓扑结构,自由地构建组件,例如最近看到的交叉开关例子。所有这些操作都是在集合上进行的。

因此,使用函数来处理集合是实现这一目标的绝佳方式。任何通用编程语言都能实现这些功能,但如果我们能善用语言内置的特性,识别并重用某些模式,而不是每次都重新发明轮子,那么不仅能减少编写代码的工作量,也能让后来者更容易阅读代码。

本周的目标就是学习如何使用函数式编程来识别那些高度抽象的通用模式,并反复重用它们。作为开发者,你可能需要稍微调整问题以适应这些抽象,但这是值得的。如果做得正确,你还能获得性能上的好处。

这里有一点建议:整个学期我们都在提倡使用 val 而非 var,尽量编写不可变的代码。然而,Chisel本身包含一些固有的可变性,例如当你使用连接操作符 := 时,你实际上是在改变那些对象的状态。函数式编程本意是无副作用和不可变的,但Chisel允许一些副作用。因此,需要注意这种区别。今天我会展示一些例子,说明如何表明你的代码将产生副作用,而不仅仅是纯函数。


核心操作:mapforeach 🔄

我们首先介绍的两个操作符是 mapforeach。它们都用于对集合中的每个元素应用一个函数,但有一个关键区别。

map,顾名思义,是将一个函数映射到集合的每个元素上。给定一个包含 N 个元素的集合,map 会将该函数 F 逐个应用于每个元素,并产生一个新的输出集合

foreach 同样对每个元素应用一个函数,但它不产生输出集合。那么它有什么用呢?foreach 用于当被应用的函数具有副作用时。例如,在今天的讲座中,副作用可能是向控制台打印信息(println)。在Chisel中,副作用可能是执行连接操作。

从简单性的角度来看,你可能会想:既然两者都对每个元素应用函数,有时我想要输出,有时我不想要,那么 map 不是更通用吗?如果我不关心输出,我直接忽略它不就行了,而且函数仍然可以有副作用。答案是:技术上可行,但不鼓励这样做。

原因是,当别人阅读你的代码时,看到 map,他们会认为这个操作的重点在于输出,函数内部不应该有副作用。而看到 foreach,他们会立刻意识到这里有副作用发生。这种关键字本身就传达了意图,甚至在阅读函数内容之前就让人明白了代码的目的。


匿名函数(Lambda表达式) λ

在使用这些操作之前,我们需要更正式地了解一下我们将使用的函数,特别是匿名函数的概念。在许多语言中,这被称为 lambda 表达式。在 Scala 中,它们被称为函数字面量

它的基本写法是描述一个函数,例如,接受一个 Int 类型的参数 x(在 Scala 中,类型写在变量名的右边),然后是一个箭头 =>,接着是函数体。这种写法非常适合简短的单行函数。你也可以使用花括号 {} 来编写多行的匿名函数,但如果你发现需要写多行,更推荐直接编写一个传统的命名函数。

让我们通过代码来理解。首先,我们有一个匿名函数,它接受一个 Int x 并返回 x + 1。我们可以将这个匿名函数赋值给一个 val,比如 inc。现在,inc 就是一个指向这个函数字面量的引用。我们当然可以调用它。

这里有一个细微的区别:我们之前也用 def 关键字定义过函数。在这个简单的例子中,对于程序员来说,val inc = (x: Int) => x + 1def inc(x: Int) = x + 1 看起来是等价的。在某些语言语义上它们确实不同,但在本课程中,这种区别通常不重要。我建议:如果你在编写一个函数,使用 def;如果你需要将一个匿名函数赋值给一个变量,使用 val。匿名函数通常是无名的,并且直接嵌入到操作中。

匿名函数之所以强大,是因为函数在 Scala 中是一等公民,意味着它们可以像任何其他值一样被传递和使用。

关于类型标注:由于 Scala 有强大的类型推断,你通常不需要显式写出参数和返回值的类型。编译器会根据上下文推断出来。例如,对 Int 进行数学运算,结果会被推断为 Int。但在复杂的表达式中,或者为了确保代码清晰,显式添加类型标注也是一个好习惯。


动手实践 map 操作 🗺️

现在让我们实际操作一下 map 操作符。记住,map 对集合中的每个元素应用一个函数,并产生一个新的输出集合。

map 的用法是:集合在左边,调用 .map 方法,然后将函数作为参数放在右边。需要澄清的是,map 并不是 Scala 语言本身的关键字特性,而是 Scala 标准库中为几乎所有集合类型实现的一个方法。由于其写法高度一致,所以感觉像是语言的一部分。

以下是一些示例。我们有一个范围 0 until 5(即 0, 1, 2, 3, 4)。我们可以用多种方式应用 map

  1. 使用之前定义的 inc 函数。
  2. 直接在 map 内联地写出匿名函数 i => i + 1
  3. 使用更简洁的占位符语法 _ + 1(稍后会详细解释)。

执行后,原始集合 [0, 1, 2, 3, 4] 被转换为 [1, 2, 3, 4, 5]

关于语法风格:Scala 允许省略点号 . 和括号的写法,但我个人推荐使用更明确的 collection.map(function) 风格。这是为了照顾那些不熟悉 Scala 但可能阅读代码的程序员(例如来自 Java 或 Python),以提高代码的可读性。不过,在你自己的项目中,可以选择你觉得更清晰的风格。


动手实践 foreach 操作 🖨️

接下来是 foreach。它同样对每个元素应用一个函数,但没有输出集合,主要用于产生副作用。

典型的副作用例子是打印到控制台。我们有一个范围,然后调用 .foreach(println),每个元素都会被打印出来。

那么,能用 map 实现打印吗?可以,collection.map(println) 也会打印每个元素。但是,map 会返回一个新的集合,其每个元素都是 println 的返回值,即 Unit 类型(类似于 void)。在 Scala 中,Unit 用空括号 () 表示。所以最终你会得到一个充满 () 的集合,这毫无用处。使用 foreach 不仅能避免这个无用的输出,还能立即向代码阅读者传达“此处操作旨在产生副作用”的意图。


在 Chisel 中应用 🛠️

现在,让我们在 Chisel 中尝试这些概念。好消息是,无论是 Scala 标准库的 Seq(我们常用),还是 Chisel 的 Vec,都实现了这些函数式编程操作符。Vec 特意添加了 map 等方法,以与 Scala 的集合操作无缝衔接。

这里有一个简单的模块示例,它接收一个常量参数 nElements,并输出一个包含 nElements 个常量的向量。

首先,我们使用 Scala 的 Seq(0 until nElements).map(_.U)。这行代码将整数范围映射为 Chisel 的 UInt 类型。
然后,我们有一个 Chisel 的 Vec 输出 io.outs。我们调用 io.outs.zipWithIndex.foreach { case (o, i) => o := constants(i) } 来建立连接。这里我们用了 zipWithIndex(它相当于 zip(collection.indices)),将输出端口和索引配对,然后使用 foreach 进行连接操作(连接操作具有副作用)。

这个例子虽然简单,但展示了如何将 Scala 集合和 Chisel 硬件类型通过 mapforeach 结合起来。


元组(Tuple)和 zip 操作 🤝

在深入更复杂的例子前,我们先介绍元组zip 操作。

元组是一种将多个值组合成一个对象的方式。其中的元素可以是不同类型,通常用于组合少量(如2-3个)值。如果元素很多,使用样例类(case class)或 Chisel 的 Bundle 更合适。元组的元素没有名称,通过 ._1._2 这样的索引访问(注意索引从1开始)。你可以将元组“解构”为独立的变量。

zip 操作接收两个集合,并将它们逐元素地配对,形成一个新的集合,其中每个元素都是一个元组(通常是二元组)。如果两个集合长度不同,结果集合的长度将以较短的那个为准。

zip 的强大之处在于,它常与 map 结合使用。当你需要同时处理两个集合中对应的元素时,可以先 zip 将它们配对,然后对配对后的元组集合进行 map 操作。

例如,有两个列表 ab,你想计算它们的元素之和。你可以写:a.zip(b).map { case (x, y) => x + y }。这里的 case 关键字用于模式匹配,将元组解构为 xy 两个独立的变量。


更复杂的 Chisel 示例:仲裁器重构 ⚖️

让我们回顾一下上周的仲裁器(Arbiter)代码,看看如何使用函数式编程使其更简洁。

原始的仲裁器代码中,我们做了以下几件事:

  1. 收集所有输入端口的 valid 信号,然后进行“或”归约得到 out.valid
  2. 收集所有输入端口的 bits 信号,用于后续的多路选择。
  3. 根据选择结果,设置各个输入端口的 ready 信号。

我们可以用函数式编程重构:

  1. 收集 valid 信号:原来需要用 Wire 声明并循环赋值。现在可以:val valids = io.in.map(_.valid)。一行代码直接得到了所有 valid 信号的集合,清晰明了。
  2. 收集 bits 信号:同样,io.in.map(_.bits)。我们甚至不需要中间变量,可以直接传入后续的 Mux1H
  3. 设置 ready 信号:原来需要循环遍历。现在可以:io.in.zip(chosenOH).foreach { case (in, sel) => in.ready := sel && io.out.fire }。这行代码将输入端口和对应的选择信号位配对,然后为每个端口设置 ready 信号。

通过这样的重构,代码行数减少了,意图也更清晰。例如,map(_.valid) 明确表达了“提取所有 valid 字段”。zip 结合 foreach 清晰地表达了“将这两个序列对应起来,并对每一对执行某个带副作用的操作”。

当然,也需要权衡可读性。过长的函数式链(例如连续多个 mapfilterzip)可能难以调试。建议将链条限制在一到两个操作内,或者使用中间变量增加可读性。最终目标是写出既正确又易于理解的代码。


占位符语法 _ 的更多细节 🔧

我们之前提到了简洁的占位符语法 _。它允许我们以更简短的方式编写匿名函数。

规则是:每个 _ 按顺序代表一个参数,并且每个 _ 在函数体中只能出现一次。

  • _.valid 等价于 x => x.valid
  • _ + _ 等价于 (a, b) => a + b
  • _ + _ 不能写成 _ * 2(因为这里 _ 出现了两次,代表两个不同的参数?这里需要更正:_ * 2 是合法的,它等价于 x => x * 2_ 只出现一次。而 _ + _ 中的两个 _ 代表两个不同的参数)。关键是,每个 _ 代表一个新的参数。

这种语法非常简洁,但只适用于简单的、参数使用不复杂的场景。如果逻辑变得复杂,还是推荐使用完整的 case 模式匹配或显式的参数列表。


总结 📚

本节课中,我们一起学习了函数式编程在敏捷硬件设计中的基础应用。

我们首先了解了函数式编程的核心理念:通过对集合应用函数来转换数据,而不是描述详细的操作步骤。这有助于实现代码的简洁性、可重用性和可读性。

我们重点掌握了两个核心操作:

  • map:对集合中每个元素应用函数,产生新的集合,用于无副作用的转换。
  • foreach:对集合中每个元素应用函数,不产生新集合,用于执行带有副作用的操作。

我们还学习了支撑这些操作的匿名函数(函数字面量) 的写法,以及如何使用 _ 占位符语法让代码更简洁。

接着,我们探讨了 zip 操作,它可以将两个集合配对,常与 mapforeach 结合,以同时处理两个相关集合中的对应元素。

最后,我们通过重构 Chisel 仲裁器的实例,看到了函数式编程如何将传统的循环结构转化为更声明式、更易读的代码片段。

记住,使用函数式编程的目的是为了写出更好的代码,而不是为了使用而使用。始终要在简洁性、表达力和可读性之间找到平衡点。在接下来的课程中,我们将继续探索更多函数式编程模式及其在硬件设计中的强大应用。

015:浮点数深入(第1部分)

在本节课中,我们将要学习函数式编程中两个重要的高阶操作:reducefold。我们将探讨它们如何将集合中的多个元素“缩减”为单个结果,并理解它们与之前学过的 mapforeachzip 操作的区别。

概述:从一对一映射到聚合操作

上一节我们介绍了 mapforeachzip 等一对一操作的函数式编程构造。本节中我们来看看 reducefold 操作符,它们用于将集合中的多个元素聚合为单个结果。

mapforeachzip 操作的输入项数量与输出项数量相同。reducefold 则不同,它们接收一个包含 N 个元素的集合,最终输出一个结果。

reduce 操作符

reduce 操作符接收一个二元运算符(函数),并将其连续应用于集合中的元素,直到将集合缩减为单个元素。

例如,给定一个包含 0 到 5 的整数集合,我们可以使用 reduce 进行求和操作。

val sum = Seq(0, 1, 2, 3, 4, 5).reduce(_ + _)

reduce 期望一个接收两个参数的二元函数。我们可以使用占位符语法 _ + _,其中第一个 _ 代表第一个参数,第二个 _ 代表第二个参数。

我们也可以将 mapreduce 链式调用。例如,先计算平方,再求和:

val sumOfSquares = Seq(0, 1, 2, 3, 4, 5).map(x => x * x).reduce(_ + _)

或者写成更简洁的一行代码:

val sumOfSquares = Seq(0, 1, 2, 3, 4, 5).map(x => x * x).reduce(_ + _)

reduce 操作非常有用,但它存在一些边界情况需要考虑。例如,当集合为空或只有一个元素时,reduce 会如何工作?此外,如果输入类型与输出类型不同,又该如何处理?

fold 操作符:更通用的聚合

fold 操作符是比 reduce 更通用的聚合操作。它需要一个初始值和一个二元函数。fold 有两种主要变体:foldLeftfoldRight

foldLeft 从集合的左侧开始,将初始值与第一个元素结合,然后将结果与下一个元素结合,依此类推。其过程可以形象地表示为:

初始值 --(函数)--> 元素[0] => 中间结果1
中间结果1 --(函数)--> 元素[1] => 中间结果2
...
中间结果N-1 --(函数)--> 元素[N-1] => 最终结果

foldRight 则从集合的右侧开始操作。对于满足结合律的操作(如加法),foldLeftfoldRight 的结果相同。但对于非结合律的操作,顺序就很重要了。

在 Scala 中,foldLeft 是更常用的默认选择,因为它符合从左到右遍历集合的直觉。

以下是使用 foldLeft 求和的示例:

val sum = Seq(1, 2, 3, 4, 5).foldLeft(0)((totalSoFar, elem) => totalSoFar + elem)

我们可以使用占位符语法使其更简洁:

val sum = Seq(1, 2, 3, 4, 5).foldLeft(0)(_ + _)

对于简单的求和,Scala 集合还提供了内置的 .sum 方法:

val sum = Seq(1, 2, 3, 4, 5).sum

foldLeft 的强大之处在于它可以处理更复杂的聚合逻辑。例如,查找集合中的最大值:

val maxValue = Seq(3, 1, 4, 1, 5, 9).foldLeft(0)((maxSoFar, elem) => if (elem > maxSoFar) elem else maxSoFar)

当然,Scala 也提供了 .max 方法:

val maxValue = Seq(3, 1, 4, 1, 5, 9).max

在 Chisel 硬件描述库中,集合类型同样实现了 reducefoldLeftfoldRight 等方法。这使得我们可以在硬件生成代码中利用这些强大的函数式模式。

柯里化与部分函数应用

柯里化是指将接收多个参数的函数转换为一系列接收单个参数的函数的过程。这允许我们对函数进行部分应用。

例如,我们定义两个加法函数:

def sum(a: Int, b: Int): Int = a + b
def plusX(x: Int)(y: Int): Int = x + y

sum 函数一次性接收两个参数。而 plusX 函数使用了两个参数列表,这允许我们部分应用它:

val addOne = plusX(1)_ // 部分应用,创建一个将输入加1的新函数
val result = addOne(5) // 结果为 6

部分函数应用在函数式编程中非常有用,特别是在与高阶函数(如 map)结合使用时:

val numbers = Seq(1, 2, 3)
val numbersPlusTen = numbers.map(plusX(10)_) // 结果为 Seq(11, 12, 13)

柯里化允许我们创建灵活的函数工厂,动态生成具有特定行为的函数。

关于多个参数列表的部分应用,需要遵循从左到右的顺序,不能跳过中间的参数列表。

总结

本节课中我们一起学习了函数式编程中的聚合操作。我们探讨了 reduce 操作符如何将集合缩减为单个值,以及更通用的 foldLeftfoldRight 操作符如何通过一个初始值和二元函数实现聚合。我们还了解了柯里化和部分函数应用的概念,它们允许我们创建更灵活、可复用的函数。

这些高阶函数抽象是编写简洁、表达力强的 Chisel 硬件生成器代码的重要工具。在下一讲中,我们将继续深入探讨函数式编程在硬件设计中的应用。

016:变量与数据类型 📚

在本节课中,我们将要学习Python编程中最基础也是最重要的两个概念:变量数据类型。理解它们是如何工作的,是编写任何Python程序的第一步。

什么是变量? 🤔

上一节我们介绍了课程概述,本节中我们来看看什么是变量。

变量可以理解为一个存储数据的盒子。你给这个盒子起一个名字(变量名),然后把数据(值)放进去。之后,你就可以通过这个名字来使用盒子里的数据。

在Python中,创建一个变量并赋值非常简单,只需要使用等号 =

# 创建一个名为 message 的变量,并将字符串 "Hello, World!" 存储在其中
message = "Hello, World!"

现在,变量 message 就代表了 "Hello, World!" 这个值。当你打印 message 时,计算机会输出它存储的内容。

print(message)  # 输出:Hello, World!

变量的值是可以改变的,这也是它被称为“变”量的原因。

message = "Hello, Python!"  # 将 message 的值改为新的字符串
print(message)  # 输出:Hello, Python!

基本数据类型 🔢

变量可以存储不同类型的数据。Python有一些内置的基本数据类型,最常见的有以下几种:

以下是几种核心的数据类型及其示例:

  • 整数 (int): 表示没有小数部分的数字,可以是正数或负数。
    age = 25
    temperature = -10
    
  • 浮点数 (float): 表示带有小数点的数字。
    price = 19.99
    pi = 3.14159
    
  • 字符串 (str): 表示文本信息,需要用单引号 ' ' 或双引号 " " 括起来。
    name = "Alice"
    greeting = 'Hello there!'
    
  • 布尔值 (bool): 表示逻辑值,只有两种可能:True(真)或 False(假)。注意首字母大写。
    is_student = True
    has_passed = False
    

你可以使用 type() 函数来查看任何变量或值的数据类型。

print(type(age))        # 输出:<class 'int'>
print(type(price))      # 输出:<class 'float'>
print(type(name))       # 输出:<class 'str'>
print(type(is_student)) # 输出:<class 'bool'>

变量的命名规则 📝

为了写出清晰易懂的代码,给变量起一个好名字非常重要。Python有一些必须遵守的规则和推荐的惯例:

以下是命名变量时需要遵循的规则:

  1. 只能包含字母、数字和下划线 _
  2. 不能以数字开头。例如 2nd_place 是无效的,但 second_place 是有效的。
  3. 不能使用Python的关键字(如 if, for, while, print 等)作为变量名。
  4. 名称是区分大小写的。myVariablemyvariableMYVARIABLE 是三个不同的变量。

除了规则,还有一些让代码更易读的惯例(良好实践):

  • 使用描述性的名称,比如用 user_age 而不是 a
  • 对于多个单词组成的变量名,通常使用蛇形命名法:用小写字母,单词之间用下划线连接。例如:first_name, total_amount

简单的操作与类型转换 ⚙️

变量和不同类型的数据可以进行操作。例如,数字可以参与数学运算。

x = 10
y = 3
print(x + y)  # 加法,输出:13
print(x - y)  # 减法,输出:7
print(x * y)  # 乘法,输出:30
print(x / y)  # 除法,输出:3.333...

有时,我们需要在不同数据类型之间进行转换。这可以通过一些内置函数实现:

  • int(): 将数据转换为整数。
  • float(): 将数据转换为浮点数。
  • str(): 将数据转换为字符串。
num_str = "100"
num_int = int(num_str)  # 将字符串 "100" 转换为整数 100
print(num_int + 50)     # 输出:150

# 尝试将包含小数的字符串转为整数会报错
# int(“99.9”)  # 这会引发 ValueError

# 正确的做法是先转为浮点数,再转为整数
num_float = float("99.9")
print(int(num_float))   # 输出:99 (直接截断小数部分)

总结 🎯

本节课中我们一起学习了Python编程的基石:

  1. 变量是存储数据的命名容器,通过 = 赋值。
  2. 基本数据类型包括整数(int)、浮点数(float)、字符串(str)和布尔值(bool)。
  3. 变量命名需遵守特定规则,并推荐使用描述性的蛇形命名法。
  4. 可以使用 type() 函数检查类型,以及 int(), float(), str() 等函数进行类型转换。

理解并熟练运用这些概念,将为后续学习更复杂的Python知识打下坚实的基础。

017:第12讲 - 浮点数并发与模式匹配

在本节课中,我们将完成关于函数式编程的讨论,并引入一个名为“模式匹配”的新概念。我们将看到如何利用这些工具,以更结构化、更可测试和更高效的方式来处理集合数据,并编写更简洁的代码。


函数式编程收尾:flatMapfilter 及其他

上一节我们介绍了mapreducefold等操作。本节中,我们将补充几个剩余的重要概念:flatMapfilter以及一些便捷的集合方法。

理解 flatMap

map 操作是“一对一”的:一个元素输入,一个元素输出。但有时,我们处理的对象本身就是一个集合,而映射函数返回的也是一个集合。这时,我们就得到了一个“集合的集合”。

flatMap 的作用是:对集合中的每个元素应用一个返回集合的函数,然后将所有这些结果集合“扁平化”地连接成一个单一的集合。你可以将其理解为先进行 map,然后对结果调用 flatten

代码示例:

val range = 0 until 5
// 使用 map 生成集合的集合
val listOfLists = range.map(x => List.fill(x)(x))
// 使用 flatten 扁平化
val flattened = listOfLists.flatten
// 使用 flatMap 一步到位
val flatMapped = range.flatMap(x => List.fill(x)(x))

flatMap 的一个更常见用途是创建一种“一对多”或“过滤式”的映射。例如,映射函数可以返回零个、一个或多个元素。

代码示例:

val numbers = 0 until 10
// 使用 flatMap 实现过滤:只保留偶数
val evens = numbers.flatMap { x =>
  if (x % 2 == 0) Some(x) else None
}

使用 filter

filter 用于根据条件筛选集合中的元素。它接受一个返回布尔值的函数(谓词),保留使谓词为 true 的元素。

代码示例:

val isEven = (x: Int) => x % 2 == 0
val evens = numbers.filter(isEven)
// 使用 `filterNot` 筛选非偶数
val odds = numbers.filterNot(isEven)

便捷的集合谓词:forallexists

以下是两个常用的集合谓词操作:

  • forall:检查集合中的所有元素是否都满足某个条件。
  • exists:检查集合中是否至少存在一个元素满足某个条件。

代码示例:

val allEven = evens.forall(isEven) // true
val anyEven = numbers.exists(isEven) // true

这些高阶函数封装了常见的遍历模式(如循环),使代码更声明式、更易于并行化,因为它们的语义不依赖于执行顺序。

综合示例:埃拉托斯特尼筛法求质数

让我们结合所学,用几行代码实现一个质数生成器。

代码示例:

def removeMultiplesOf(x: Int)(nums: Seq[Int]): Seq[Int] =
  nums.filterNot(_ % x == 0)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_53.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_55.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_57.png)

def sieve(nums: Seq[Int]): Seq[Int] = {
  if (nums.isEmpty) Seq.empty
  else {
    val head = nums.head
    head +: sieve(removeMultiplesOf(head)(nums.tail))
  }
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_59.png)

val primesUnder100 = sieve(2 to 100)

这段代码优雅地展示了如何通过组合 filter 和递归来构建算法。

内置的便捷方法

Scala集合提供了许多便捷方法,如 sumproductminmax 等。在可能的情况下,应优先使用这些内置方法,它们使代码更清晰、更高效。

代码示例:

val seq = Seq(1, 2, 3, 4)
val total = seq.sum
val smallest = seq.min


模式匹配 🧩

模式匹配是Scala中一个非常强大的特性,它远不止是其他语言中的 switch 语句。它允许你根据数据的结构进行匹配和解构。

基础模式匹配

你可以匹配字面值、使用“或”逻辑、绑定变量,甚至添加守卫条件。

代码示例:

val x = 5
x match {
  case 0 => "zero"
  case 1 | 3 => "one or three"
  case n if n % 2 == 0 => s"$n is even"
  case _ => "something else"
}

匹配案例类

模式匹配在处理像案例类这样的代数数据类型时尤其强大。

代码示例:

case class Vehicle(color: String, driver: String)
case class Helicopter(color: String, driver: String) extends Vehicle(color, driver)
case class Submarine(color: String, driver: String) extends Vehicle(color, driver)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_113.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_115.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_117.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_119.png)

val fleet = Seq(
  Helicopter("gray", "Alice"),
  Helicopter("blue", "Bob"),
  Submarine("yellow", "Charlie")
)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_121.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_123.png)

fleet.foreach {
  case Helicopter("blue", pilot) => println(s"$pilot flies a blue helicopter")
  case Submarine(color, _) if color != "yellow" => println(s"A $color submarine")
  case _ => // 忽略其他情况
}

无处不在的模式匹配

你其实已经在不知不觉中使用模式匹配了,例如在解构元组时。

代码示例:

val pair = (1, "hello")
val (num, str) = pair // 这里使用了模式匹配


优雅处理 Option 类型

Option 类型表示一个可能存在也可能不存在的值(Some(value)None)。在Chisel中,它常用于表示可选的硬件端口。

传统方式与改进方式

传统上,你需要使用 isDefinedget 来访问 Option 的值,但这不够安全。

代码示例:

val maybeNumber: Option[Int] = Some(42)
// 传统方式
if (maybeNumber.isDefined) {
  val value = maybeNumber.get
}

更优雅的方式包括:

  • getOrElse:提供默认值。
  • flatten:处理 Option 的集合,移除所有 None 并解开 Some
  • 模式匹配。
  • foreach:仅在值存在时执行操作。

代码示例:

val numbersWithOptions = Seq(Some(1), None, Some(3), None)

// 使用 getOrElse
val firstOrDefault = numbersWithOptions.head.getOrElse(-1)

// 使用 flatten 提取所有有效值
val allValues = numbersWithOptions.flatten // 得到 Seq(1, 3)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_155.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_157.png)

// 使用模式匹配处理
numbersWithOptions.foreach {
  case Some(value) => println(value)
  case None => println("No value")
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/c88e4c9a563ee724c0424acc68066893_159.png)

// 使用 foreach (自动跳过 None)
numbersWithOptions.foreach(_.foreach(println))

课程项目概览 🚀

现在,你们已经掌握了相当多的Scala和Chisel知识。课程的重点将转向项目实践,你们将利用这些工具构建自己的硬件生成器。

项目流程

  1. 组队与构思:建议两人一组。构思一个具有参数化生成能力的项目创意。
  2. 提案:撰写一份一页纸的提案,描述项目目标、最终形态及初步实施计划。
  3. 开发与迭代:基于反馈进行开发,逐步添加功能。
  4. 代码审查:与另一组交换项目,进行代码审查,互相学习。
  5. 最终演示:在课程最后一周进行项目演示。
  6. 修订与提交:根据反馈修订项目和演示材料后正式提交。

项目灵感

项目应涉及一定程度的“生成”和“参数化”。过去的项目包括:

  • 数据压缩协议生成器。
  • GPS相关算法硬件实现。
  • 曼德博集分形生成器。
  • 游戏音乐播放器(甚至能在FPGA上通过HDMI输出)。

下一步行动

  • 寻找队友,讨论项目想法。
  • 随时通过办公室时间或邮件与教师讨论想法,获取早期反馈。
  • 关注课程主页,获取具体的项目模板、时间表和往届项目示例。

本节课中,我们一起学习了函数式编程的最后几个关键概念——flatMapfilter,并探索了强大的模式匹配机制。我们还了解了如何更优雅地处理Option类型,并正式开启了课程项目阶段。掌握这些工具将极大地提升你们编写简洁、健壮且富有表达力代码的能力,为成功完成硬件生成器项目打下坚实基础。

018:第13讲 - 队列设计案例研究 🧠

在本节课中,我们将通过一个具体的案例研究——设计一个队列(FIFO)——来学习敏捷硬件设计流程。我们将从一个极其简单的版本开始,然后通过迭代的方式逐步添加功能、优化性能并完善设计。在这个过程中,你将看到如何将宏大的目标分解为可管理的步骤,如何构建和复用测试环境,以及如何最终得到一个功能强大且高效的硬件生成器。


概述 📋

队列(FIFO)是数字系统中常见的组件,用于缓冲数据。我们的目标是设计一个参数化的队列,支持任意深度和数据类型,并追求高性能和低功耗。我们将遵循敏捷开发原则,从一个最简单的单入口队列开始,逐步迭代,最终实现一个功能完备、接近标准库实现的队列。

上一节我们介绍了敏捷设计的核心理念,本节中我们来看看如何将这些理念应用到一个具体的硬件组件设计中。


设计流程:从简单开始 🚀

敏捷设计流程的关键在于快速启动。我们不应试图一次性构建完美的最终产品,而是先构建一个能工作的、最简单的版本。

我们的第一步是设计一个最简单的队列。为了降低初始复杂度,我们做出以下简化:

  • 固定深度:先实现一个单入口队列,不考虑参数化深度。
  • 固定数据类型:先使用简单的位宽(如UInt),不考虑泛型数据类型。
  • 简化性能:先实现正确但可能非最优的逻辑,暂不考虑流水线(pipe)或旁路(flow)等优化。

这个最简单的队列将实现标准的解耦接口(DecoupledIO),包含入队(enq)和出队(deq)两端。其核心是一个数据寄存器(entry)和一个表示队列是否已满的标志位(full)。

以下是其控制逻辑的公式描述:

  • 入队就绪io.enq.ready := !full || io.deq.fire (队列未满,或正在出队以腾出空间)。
  • 出队有效io.deq.valid := full (队列有数据时才可出队)。
  • 数据路径
    • 入队时:entry := io.enq.bits (当io.enq.fire为真时写入)。
    • 出队时:io.deq.bits := entry (直接连接到输出)。
  • 状态更新
    • io.deq.fire为真时,full := false(数据被取出)。
    • io.enq.fire为真时,full := true(数据被存入)。
    • 若两者同时为真(即队列满时同时进行入队和出队),则full保持为true(实现管道行为)。

通过这个简单设计,我们快速建立了一个可工作的、接口正确的模块,为后续迭代奠定了基础。


构建测试环境与复用 🔧

在迭代过程中,保持接口稳定至关重要,这样我们才能复用测试平台和其他依赖此模块的代码。

我们为队列构建了一个参考模型(QueueModel),它能在仿真中预测队列的正确行为。然后,我们创建一个测试平台,将我们的设计(DUT)与这个模型连接起来,在同一个仿真中比较两者的输出。

以下是连接设计与模型的代码框架:

// 这是一个简化的示意代码
val dut = Module(new SimpleQueue) // 我们的设计
val model = Module(new QueueModel) // 参考模型

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/20056405d2392dea74d159c448bee472_1.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/20056405d2392dea74d159c448bee472_2.png)

// 将相同的输入同时提供给设计和模型
dut.io.enq <> model.io.enqSource
model.io.deqSink <> dut.io.deq

// 在仿真中,比较 model 的内部状态或输出与 dut 的行为是否一致

通过这种方式,我们可以在每次设计迭代后快速验证功能的正确性,而无需重写复杂的测试激励。

上一节我们建立了基本的队列和测试框架,本节中我们来看看如何扩展队列的深度。


迭代一:参数化深度(移位寄存器方案) 🔄

第一个主要的迭代目标是支持参数化的队列深度。一个直观的想法是使用移位寄存器(Shift Register)。

架构描述
我们使用一个寄存器数组(entries)来存储数据,并用一个对应的布尔数组(fullBits)来跟踪每个位置是否存有有效数据。数据从入队端(尾部)进入,逐步向出队端(头部)移动。

以下是该方案的关键逻辑:

  • 移位条件:当进行出队操作(io.deq.fire)时,或者当检测到队列中间有空位(fullBits(i)为假)时,数据需要向前移动以压缩气泡。
  • 入队位置:数据总是写入entries数组的最后一个位置(entries(n-1))。
  • 接口信号
    • io.enq.ready := !fullBits.last (最后一个位置为空即可入队)。
    • io.deq.valid := fullBits.head (第一个位置有效即可出队)。
    • io.deq.bits := entries.head

存在的问题
此方案的主要问题是延迟固定且与队列深度成正比。即使队列为空,新数据也需要经过N个周期才能到达输出端,并且队列内部容易产生“气泡”,导致性能不佳。


迭代二:优化延迟(优先级编码器方案) ⚡

为了解决固定延迟的问题,我们需要优化数据插入策略。目标是让新数据能够插入到第一个可用的空位,从而最小化输出延迟。

架构描述
我们仍然使用寄存器数组(entries)和有效位数组(fullBits)。但入队逻辑发生了改变:

  • 我们使用一个优先级编码器(Priority Encoder)来查找fullBits中第一个为假(即空)的位置。
  • 新数据直接写入这个找到的空位,而不是固定写入末尾。

以下是该方案的核心改进代码逻辑:

// 找到第一个空位的索引
val freeIdx = PriorityEncoder(fullBits.map(!_))

// 入队时,将数据写入 freeIdx 指向的位置
when(io.enq.fire) {
  entries(freeIdx) := io.enq.bits
  fullBits(freeIdx) := true.B
}
// 出队时,从头部取出数据,并清除对应有效位
when(io.deq.fire) {
  fullBits.head := false.B
  // 需要将后续所有有效数据向前移动一位以填充空出的头部
  // ... 实现数据前移的逻辑 ...
}

优点与缺点

  • 优点:延迟现在与队列的占用率相关,而非固定深度。空队列的延迟很低。
  • 缺点
    1. 优先级编码器和数据前移逻辑在队列深度较大时会产生较长的关键路径。
    2. 功耗较高,因为数据需要在寄存器间频繁移动。
    3. 仍然不支持在队列满时同时进行入队和出队(pipe特性)。

迭代三:提升效率(环形缓冲区方案) 🎯

为了降低功耗和关键路径延迟,我们采用一种更高效的架构:环形缓冲区(Circular Buffer),也称为循环队列。

核心思想
数据被写入寄存器后便保持不动,直到被读出。我们使用两个指针(索引)来跟踪下一个要写入的位置(enqPtr)和下一个要读出的位置(deqPtr)。

初始简化版逻辑

  • 空/满判断(简化版,浪费一个条目)
    • 空:enqPtr === deqPtr
    • 满:enqPtr + 1 === deqPtr (这导致实际可用深度为 N-1)
  • 操作
    • 入队:写入entries(enqPtr),然后enqPtr := enqPtr + 1
    • 出队:读出entries(deqPtr),然后deqPtr := deqPtr + 1
  • 通过将深度限制为2的幂,+1后的环绕(wrap-around)可以自动通过位溢出实现。

优点

  • 低功耗:数据只写入和读出时变化,中间不移动。
  • 低延迟逻辑:指针比较和加减法的逻辑路径很短,且随深度对数增长,可扩展性好。

迭代四:完善功能(解决遗留问题) 🛠️

我们的环形缓冲区方案还有几个待解决的问题:1) 浪费一个条目;2) 深度需为2的幂;3) 不支持满队列下的同时出入队(pipe)。

以下是逐步的解决方案:

1. 解决条目浪费问题
引入一个额外的状态位maybeFull来区分指针相等时是“空”还是“满”。

  • 空:(enqPtr === deqPtr) && !maybeFull
  • 满:(enqPtr === deqPtr) && maybeFull
  • maybeFull的更新逻辑:当入队而不出队时置为true,当出队而不入队时置为false

2. 支持任意深度
使用Chisel的Counter对象来管理指针,它可以自动处理任意最大值的环绕,无需我们手动实现取模运算。

val enqPtr = Counter(numEntries)
val deqPtr = Counter(numEntries)
when(io.enq.fire) { enqPtr.inc() }
when(io.deq.fire) { deqPtr.inc() }

3. 支持Pipe特性
修改io.enq.ready的逻辑,使其在队列满时,如果同时能出队(io.deq.ready为真),也允许入队。

val pipe = true.B // 可作为参数
io.enq.ready := (enqPtr =/= deqPtr) || !maybeFull || (pipe && io.deq.ready)

这引入了一个从io.deq.readyio.enq.ready的组合路径,因此需要将其作为可选特性(通过参数pipe控制)。

4. 支持Flow特性(旁路)
Flow特性允许当队列为空时,入队的数据可以直接旁路到出队端,实现零延迟。其逻辑是:如果队列为空且入队和出队同时发生,则出队数据直接来自入队端口。

val flow = true.B // 可作为参数
val empty = (enqPtr === deqPtr) && !maybeFull
val bypass = flow && empty && io.enq.fire && io.deq.ready

when(bypass) {
  io.deq.bits := io.enq.bits
  // 注意:这种情况下不更新指针和 maybeFull
}.otherwise {
  io.deq.bits := entries(deqPtr.value)
  // ... 原有的指针更新逻辑 ...
}

总结 🎉

本节课中我们一起学习了通过迭代式、敏捷的方法来设计一个复杂的硬件组件——队列。

我们从一个功能正确但极其简单的单入口队列开始,逐步经历了以下迭代:

  1. 建立基础:实现了解耦接口和基本队列行为。
  2. 参数化深度:采用移位寄存器方案,但发现了性能问题。
  3. 优化延迟:采用优先级编码器方案,根据占用率决定延迟,但存在面积和功耗开销。
  4. 提升效率:转向环形缓冲区方案,大幅降低功耗和逻辑深度。
  5. 完善功能:通过添加状态位、使用计数器、条件化组合路径,最终解决了条目浪费、深度限制、pipe和flow特性支持等问题。

整个过程中,我们始终坚持:

  • 接口稳定:确保每次迭代不影响外部连接,便于测试复用。
  • 快速验证:利用模型和测试平台在每一步进行验证。
  • 按需优化:根据实际评估结果(如时序、面积、功耗)决定是否需要进行下一轮优化,避免过度设计。

这个案例充分展示了敏捷硬件设计的威力:通过小步快跑、持续集成和测试,我们能够高效、可靠地构建出高质量、可配置的硬件生成器。

019:继承(第一部分)

在本节课中,我们将要学习Scala和Chisel中的继承概念。继承是面向对象编程的核心特性之一,它允许我们复用代码并构建模块化的设计。我们将从Scala的基础继承开始,然后探讨如何在Chisel硬件设计中应用这些概念。

上一节我们通过一个案例研究探讨了渐进式开发。本节中,我们来看看如何通过继承更有效地实现代码复用。

为什么使用继承?

继承的主要目标是识别组件之间的关键相似性和差异性。当多个组件功能相似但不完全相同时,我们可以通过继承来复用它们之间相同的代码,同时为不同的部分提供定制化的实现。这有助于创建易于重用、大小和形状合适的代码模块。

Scala中的继承类型

Scala提供了多种面向对象的继承机制。虽然看起来有些繁多,但我们可以逐一理解。以下是几种主要的继承方式。

常规类继承

这是最基本的继承形式,我们可能已经在不知不觉中使用过它。一个类(子类)可以通过 extends 关键字继承另一个类(父类)。子类将拥有父类的所有特性,除非它明确地覆盖了某些部分。

以下是常规类继承的一个简单示例:

class Parent(val name: String) {
  val phrase: String = "Hello"
  def greet(): Unit = println(s"$phrase, $name")
}

class Child(name: String) extends Parent(name) {
  override val phrase: String = "Ola"
}

在这个例子中:

  • Child 类继承了 Parent 类。
  • Child 类使用 override 关键字覆盖了 phrase 的值,但 greet 方法仍然来自父类。
  • 如果不使用 override 关键字尝试覆盖父类成员,编译器会报错,要求明确意图。
  • Scala只支持单继承,即一个类只能直接继承自一个父类。

抽象类继承

抽象类与常规类类似,但使用 abstract 关键字声明。关键区别在于抽象类本身不能被实例化,它们被设计出来就是为了被继承。

抽象类的优势在于,我们不需要完全实现它,可以只定义接口或框架,而将具体实现留给子类。

以下是抽象类继承的一个示例:

abstract class AbstractParent(val name: String) {
  val phrase: String // 抽象字段,没有初始值
  def greet(): Unit = println(s"$phrase, $name") // 具体方法
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/a49c2b14ce693bb7c3676471fc4e43b8_11.png)

class EnglishChild(name: String) extends AbstractParent(name) {
  val phrase: String = "Hello" // 子类必须提供抽象字段的值
}

class SpanishChild(name: String) extends AbstractParent(name) {
  val phrase: String = "Hola"
}

在这个例子中:

  • AbstractParent 是一个抽象类,它定义了一个抽象字段 phrase 和一个具体方法 greet
  • 子类 EnglishChildSpanishChild 在继承时必须为抽象字段 phrase 提供具体的值。
  • 由于 phrase 在父类是抽象的,子类赋值时不需要 override 关键字。

Scala集合库中的继承实例

为了理解继承如何用于构建复杂的层次结构,我们可以看看Scala标准库中的集合类层次图。该图展示了一个清晰的单继承树,其中蓝色框代表抽象类。

例如,所有集合都继承自 Iterable(可遍历)特质。在此基础上,根据不同的特性进行细分:

  • Set(集合)要求元素唯一。
  • Seq(序列)要求元素有连续的整数索引。
  • Map(映射)允许使用任意类型的键进行索引。

然后,这些抽象类又有各种具体的实现,如 ListVectorHashSet 等。通过这种层次结构,所有集合类型都共享一套统一的API(如 .size.isEmpty 方法),并且公共的功能(如迭代逻辑)可以在高层级中实现并被所有子类复用。

在Chisel中应用继承

在硬件设计中,我们同样追求代码复用。继承可以帮助我们创建可定制的模块家族,这些模块共享核心逻辑,但可能在具体操作或接口上有所不同。

我们一直在Chisel中使用继承,例如:

  • class MyModule extends Module:让自定义模块继承Chisel的基础Module类。
  • class MyBundle extends Bundle:让自定义Bundle继承Chisel的基础Bundle类。

一个Chisel继承示例:一元操作符生成器

假设我们想创建一系列执行一元操作(一个输入,一个输出)的模块。我们可以先定义一个抽象类来捕获这个通用模式。

首先,我们定义一个抽象类 UnaryOp,它规定了输入输出的宽度和IO接口,但将具体的操作 op 留为抽象:

import chisel3._

abstract class UnaryOp(width: Int) extends Module {
  val io = IO(new Bundle {
    val in = Input(UInt(width.W))
    val out = Output(UInt(width.W))
  })
  def op(x: UInt): UInt // 抽象方法,定义操作
  io.out := op(io.in) // 具体逻辑:将操作应用于输入
}

接下来,我们可以轻松地通过继承这个抽象类来创建具体的模块:

  1. 直通模块:输出等于输入。

    class PassThrough(width: Int) extends UnaryOp(width) {
      def op(x: UInt): UInt = x // 实现op为恒等操作
    }
    

  1. 取反模块:输出等于输入的按位取反。

    class Negation(width: Int) extends UnaryOp(width) {
      def op(x: UInt): UInt = ~x // 实现op为取反操作
    }
    

通过这种方式,我们复用了 UnaryOp 中定义IO和连接逻辑的代码,只需在不同的子类中实现核心的 op 方法即可。这使得创建新的、行为不同的一元操作模块变得非常简单。

关于覆盖Chisel IO的注意事项

在尝试通过继承修改IO接口时可能会遇到问题。直接覆盖父模块中的 io 字段可能不会按预期工作,并可能导致运行时错误。

Chisel提供了其他模块类型来更好地支持这种场景,例如:

  • RawModule:不自动包含时钟和复位信号,提供更底层的控制。
  • MultiIOModule:允许以更灵活的方式定义和组合多个IO块。

当你的设计需要通过继承来改变IO结构时,可以考虑使用这些模块作为基类。

总结

本节课中我们一起学习了Scala和Chisel中的继承。

  • 我们了解了常规类继承和抽象类继承的基本概念与语法。
  • 我们看到了继承如何用于构建像Scala集合库那样清晰的层次结构,促进代码复用和API统一。
  • 我们探讨了如何在Chisel中利用继承来创建可定制的硬件模块家族,通过抽象类捕获通用模式,再通过具体子类实现特定行为。
  • 我们还注意到了在Chisel中覆盖IO接口的特殊性,并了解了RawModule等替代方案。

继承是构建灵活、可复用硬件生成器的重要工具。下一节我们将继续探讨继承的其他高级特性,如特质(Traits)和类型参数化。

020:继承(第2部分)🎓

在本节课中,我们将继续学习Scala语言中的继承特性,并探讨如何利用这些特性来构建更模块化、更易维护的硬件设计。我们将通过一个更复杂的例子来加深理解,并介绍特质(Trait)和类型参数化(Generics)的概念。

回顾与引入

上一节我们介绍了简单的类继承、抽象类以及Scala集合。本节中,我们将构建一个更复杂的例子,并探讨如何通过继承和特质来复用代码。

构建解耦运算符库 🔌

假设我们想构建一个包含解耦运算符的库。这些运算符在其输入和输出端具有解耦接口,内部会缓冲输出,以确保关键路径上只有一个运算符,从而实现平滑的数据流操作。

我们的目标是将“运算符”的通用概念封装在一个父基类中,然后让子类去实现具体的功能。

定义抽象父类

首先,我们定义一个抽象父类 DecoupledOperator。抽象类意味着不能直接实例化它,必须通过扩展它的子类来实例化。

abstract class DecoupledOperator extends Module {
  val io = IO(new Bundle {
    val a = Flipped(Decoupled(UInt(8.W)))
    val b = Flipped(Decoupled(UInt(8.W)))
    val c = Decoupled(UInt(8.W))
  })

  // 抽象方法,由子类定义具体操作
  def op(a: UInt, b: UInt): UInt

  val buffer = Reg(UInt(8.W))
  val full = RegInit(false.B)

  // 输入就绪条件:当缓冲区不满时
  io.a.ready := !full
  io.b.ready := !full

  // 输出有效条件:当缓冲区有数据时
  io.c.valid := full
  io.c.bits := buffer

  // 当输入有效且缓冲区不满时,执行操作并填充缓冲区
  when(io.a.valid && io.b.valid && !full) {
    buffer := op(io.a.bits, io.b.bits)
    full := true.B
  }

  // 当输出被取走时,清空缓冲区
  when(io.c.fire) {
    full := false.B
  }
}

这个抽象类定义了输入 ab 和输出 c 的解耦接口。它包含一个缓冲区 buffer 和一个状态位 full。核心操作 op 被声明为抽象方法,等待子类实现。

实现具体子类

现在,我们可以通过扩展父类来轻松创建具体的运算符,例如加法器和减法器。

以下是加法器子类的实现:

class AddOperator extends DecoupledOperator {
  override def op(a: UInt, b: UInt): UInt = a + b
}

以下是减法器子类的实现:

class SubOperator extends DecoupledOperator {
  override def op(a: UInt, b: UInt): UInt = a - b
}

使用工厂模式

为了更方便地创建运算符,我们可以使用工厂模式。工厂根据传入的字符串返回对应的运算符对象。

object OperatorFactory {
  def apply(opType: String): DecoupledOperator = opType match {
    case "add" => new AddOperator
    case "sub" => new SubOperator
    // 可以轻松添加更多运算符
    case _ => throw new IllegalArgumentException(s"Unknown operator: $opType")
  }
}

通过这种方式,添加新运算符变得非常容易,只需在工厂中添加一行代码即可。所有子类都可以复用父类的测试框架和接口,大大提高了代码的复用性。

特质(Traits)与混入(Mixins)🎭

Scala只允许单类继承,但允许混入多个特质。特质类似于抽象类,但更灵活,用于向类中“混入”额外的功能。

特质的用途

特质不能有构造参数,它主要用于向类中注入功能。在Chisel设计中,特质和抽象类各有用途,因此Scala两者都提供。

示例:为模块添加打印功能

假设我们有一个计数器模块,并希望为其添加一个标准的打印功能,以便在仿真中进行日志记录。

首先,我们定义一个特质 PrintInSim

trait PrintInSim extends Module {
  val io = IO(new Bundle {
    val printEnable = Input(Bool())
  })
  def printMsg(msg: String): Unit = {
    when(io.printEnable) {
      printf(cf"$msg\n")
    }
  }
}

然后,在计数器模块中混入这个特质:

class CounterMod extends Module with PrintInSim {
  val io = IO(new Bundle {
    val count = Output(UInt(8.W))
  })

  val counter = RegInit(0.U(8.W))
  counter := counter + 1.U
  io.count := counter

  // 使用混入特质带来的方法
  val message = "Counter value: %d"
  printMsg(message)
}

通过混入 PrintInSim 特质,CounterMod 模块获得了 printMsg 方法和 printEnable 输入端口。这是一种强大的代码复用机制。

特质的注意事项

然而,过度使用特质或深层继承可能导致代码可读性下降,因为功能可能分散在多个文件中。使用IDE的“转到定义”功能可以缓解这个问题。另外,需要注意名称冲突,如果特质和类定义了同名字段,可能需要使用 override 关键字。

Scala面向对象机制总结 📚

到目前为止,我们在课程中涵盖了以下Scala面向对象概念:

  • 类(Class):用于创建可多次实例化的蓝图。Chisel中的 ModuleBundle 通常通过继承类来实现。
  • 对象(Object):单例对象,常用于伴生对象、存放常量或实现工厂模式。
  • 样例类(Case Class):一种特殊的类,自动提供 copy 方法、友好的 toString 实现,并支持模式匹配,常用于参数传递。
  • 抽象类(Abstract Class):用于定义通用模板,不能被直接实例化,需要子类实现其抽象成员。
  • 特质(Trait):用于多重继承和混入功能,可以灵活地为类添加能力。

一个高级的特质使用例子是Chisel项目“Rocket Chip”中的“Diplomacy”系统,它允许SoC中的不同组件在生成时通过混入的特质来协商参数。

类型参数化(Generics)🔧

类型参数化(或泛型)允许我们编写可处理多种数据类型的通用代码,提高代码的复用性。

Scala泛型的特点

Scala的泛型支持类型边界(Type Bounds),可以限制类型参数必须是某个类的子类。但Scala/Java泛型存在“类型擦除”问题,在运行时类型信息可能丢失,进行模式匹配时需要额外处理(如使用 TypeTag)。

在Chisel中使用类型参数化

在Chisel中,类型参数化非常常见。我们通常将数据类型作为参数传递给模块。

以下是一个通用模块的例子,它接受一个 Data 子类型作为参数:

class MyModule[T <: Data](gen: => T) extends Module {
  val io = IO(new Bundle {
    val in = Input(gen)
    val out = Output(gen)
  })
  io.out := io.in
}

这里,[T <: Data] 是类型参数,<: Data 是类型上界,表示 T 必须是 Data 的子类型(如 UIntSIntBundle)。gen 是一个“生成器”,用于创建该类型的实例。

应用:参数化队列

我们可以将之前学到的队列模块进行类型参数化,使其能够处理任意 Data 类型的数据。

class ParamQueue[T <: Data](gen: T, depth: Int) extends Module {
  // ... 队列实现,使用类型参数 gen ...
}

这样,我们就可以轻松创建存储 UIntSInt 或自定义 Bundle 的队列了。

项目建议 💡

关于课程项目,请记住以下几点:

  1. 选题:从你熟悉的领域或感兴趣的新领域入手。
  2. 核心:项目应是一个“生成器”,而不仅仅是固定功能的模块。它应该有一些可配置的参数或“旋钮”。
  3. 规模:选择大小适中的项目,最好能支持增量开发。即使最终只完成核心功能,也应有可展示的成果。
  4. 调研:查看往年的项目,确保你的想法有创新之处。
  5. 规划:在提案中明确最简单的起点、开发路线图和潜在扩展功能。

过去的项目涵盖哈希函数、AI加速器组件、Bloom过滤器、分形生成器、生物信息学算法等多种领域。

总结

本节课中,我们一起深入学习了Scala继承的第二部分。我们通过构建一个解耦运算符库,实践了抽象类和工厂模式的应用。接着,我们介绍了特质的概念,展示了如何通过混入特质来为模块灵活添加功能。然后,我们总结了Scala中主要的面向对象机制。最后,我们探讨了类型参数化,学习了如何在Chisel中编写通用的、可处理多种数据类型的模块。掌握这些概念将帮助你构建更灵活、更可复用的硬件生成器代码。

021:第15讲 - 网络设计案例研究 🧠

在本节课中,我们将通过一个网络设计的案例研究,学习如何运用继承、参数化等特性来构建一个可复用的网络组件库。我们将从最简单的交叉开关(Crossbar)开始,逐步扩展到环形网络(Ring),并最终构建一个参数化的网络生成器库。

概述

我们将遵循渐进式设计流程:从一个简单的、可工作的设计开始,然后逐步扩展其功能。在这个过程中,我们将重点关注如何利用继承来共享代码、提高生产力,并最终构建一个灵活的网络库。


从交叉开关开始

上一节我们介绍了设计流程,本节中我们来看看如何构建一个基础的网络组件——交叉开关。

交叉开关是一种简单的网络组件,它拥有固定数量的输入端口和输出端口,可以实现任意输入到任意输出的连接。其核心挑战在于,当多个输入试图同时写入同一个输出端口时,需要进行仲裁。

定义消息类型

首先,我们需要定义在网络中传输的消息类型。消息不仅包含有效载荷,还需要包含目的地址。

class Message[T <: Data](val addrWidth: Int, val dataType: T) extends Bundle {
  val addr = UInt(addrWidth.W)
  val data = dataType.cloneType
}

交叉开关的IO端口

交叉开关的IO端口需要指定输入数量、输出数量和有效载荷大小。

class CrossbarIO[T <: Data](nIn: Int, nOut: Int, dataType: T) extends Bundle {
  val in = Flipped(Vec(nIn, Decoupled(new Message(log2Ceil(nOut), dataType))))
  val out = Vec(nOut, Decoupled(dataType.cloneType))
}

交叉开关的实现

以下是交叉开关的核心实现代码。我们为每个输出端口实例化一个仲裁器,并使用函数式编程和循环来简洁地连接所有逻辑。

class Crossbar[T <: Data](nIn: Int, nOut: Int, dataType: T) extends Module {
  val io = IO(new CrossbarIO(nIn, nOut, dataType))
  // 为每个输出实例化一个仲裁器
  val arbiters = Seq.fill(nOut)(Module(new RRArbiter(new Message(log2Ceil(nOut), dataType), nIn)))
  // 连接输入就绪信号
  for (i <- 0 until nIn) {
    io.in(i).ready := arbiters.map(_.io.in(i).ready).reduce(_ && _)
  }
  // 连接仲裁器输入和输出
  for ((arb, outPort) <- arbiters.zip(io.out)) {
    arb.io.in <> io.in.map(_.bits)
    outPort <> arb.io.out
  }
}

通过这种方式,我们可以轻松实例化一个交叉开关:val myCrossbar = Module(new Crossbar(4, 4, UInt(64.W)))


使用Case Class优化参数管理

为了使设计更易于管理,我们可以使用Scala的Case Class来封装参数。

定义参数Case Class

case class CrossbarParams(nHosts: Int, payloadWidth: Int)

修改模块以使用参数

然后,我们修改交叉开关模块,使其接收一个参数对象,而不是多个独立参数。

class Crossbar(params: CrossbarParams) extends Module {
  val io = IO(new CrossbarIO(params.nHosts, params.nHosts, UInt(params.payloadWidth.W)))
  // ... 内部实现逻辑与之前类似,但使用params.nHosts等字段
}

实例化时只需传入参数对象:val myCrossbar = Module(new Crossbar(CrossbarParams(4, 64)))。这使代码更简洁,尤其在参数变得复杂时更具可扩展性。


引入类型参数化

为了进一步提高灵活性,我们可以为有效载荷引入类型参数。

参数化消息和端口

case class CrossbarParams[T <: Data](nHosts: Int, payloadType: T)
class Crossbar[T <: Data](params: CrossbarParams[T]) extends Module {
  val io = IO(new CrossbarIO(params.nHosts, params.nHosts, params.payloadType))
  // ... 实现逻辑
}

现在,交叉开关可以支持任意数据类型的有效载荷,例如UIntSInt或自定义的Bundle。


从单跳网络到多跳网络

随着端点数量的增加,单跳的交叉开关在可扩展性上会遇到瓶颈(布线复杂、逻辑集中)。因此,我们需要转向多跳网络拓扑,例如环形网络。

环形网络简介

在环形网络中,每个路由器连接其邻居,并有一个端口连接主机。消息沿着环传递:如果目的地是当前节点,则消息被取出;否则,继续向前传递。这是一种简单的起点。

环形路由器实现草图

环形路由器的核心逻辑是判断消息目的地并决定转发方向。

class RingRouter[T <: Data](params: NetworkParams[T], myId: Int) extends Module {
  val io = IO(new RouterIO(3, params.payloadType)) // 3个端口:左、右、主机
  // 判断消息是否发往本机
  val isForMe = io.in.bits.addr === myId.U
  // 输出选择逻辑:发往主机或转发
  when (isForMe) {
    io.hostOut <> io.in
    io.networkOut.valid := false.B
  } .otherwise {
    io.hostOut.valid := false.B
    io.networkOut <> io.in
  }
}

连接成环

使用函数式编程的foldLeft可以优雅地将多个路由器连接成一个环。

class RingNetwork[T <: Data](params: NetworkParams[T]) extends Module {
  val routers = Seq.tabulate(params.nHosts)(id => Module(new RingRouter(params, id)))
  // 使用foldLeft连接相邻路由器
  routers.zipWithIndex.foldLeft(routers.last) { case (prev, (curr, idx)) =>
    curr.io.left <> prev.io.right
    curr
  }
  // 连接主机端口
  routers.zip(io.ports).foreach { case (r, p) => p <> r.io.host }
}

利用继承构建网络库

现在,我们有了交叉开关和环形网络。它们都是网络,可以共享很多代码。我们可以创建一个抽象的网络基类。

定义抽象网络类

首先,定义通用的网络参数和IO端口。

case class NetworkParams[T <: Data](nHosts: Int, payloadType: T)
abstract class Network[T <: Data](params: NetworkParams[T]) extends Module {
  val io = IO(Vec(params.nHosts, new BidirectionalPort(params.payloadType)))
}

让具体网络继承基类

然后,让交叉开关和环形网络都继承这个抽象类。这样,它们就自动拥有了定义好的IO端口。

class Crossbar(params: CrossbarParams[T]) extends Network(params) {
  // 无需再声明io,直接使用继承来的io
  // ... 交叉开关的具体实现
}
class RingNetwork(params: NetworkParams[T]) extends Network(params) {
  // ... 环形网络的具体实现
}

这减少了重复代码,并建立了一个统一的设计模式。


构建更通用的路由器与网络

为了支持更复杂的拓扑(如双向环、网格、环面),我们需要进一步抽象。

通用路由器抽象

一个通用路由器包含一个内部交叉开关和路由逻辑函数。

abstract class Router[T <: Data](params: NetworkParams[T], numPorts: Int) extends Module {
  val io = IO(new RouterIO(numPorts, params.payloadType))
  val crossbar = Module(new Crossbar(CrossbarParams(numPorts, params.payloadType)))
  // 路由逻辑:根据目的地址决定下一跳端口
  def routeLogic(addr: UInt): UInt
  // 连接输入到交叉开关,并使用routeLogic决定输出端口
}

多跳网络抽象

一个多跳网络由多个路由器按照特定拓扑连接而成。

abstract class MultiHopNetwork[T <: Data, R <: Router[T]](params: NetworkParams[T]) extends Network(params) {
  val routers: Seq[R]
  def connectRouters(routers: Seq[R]): Unit // 抽象方法,由子类定义具体连接方式
  // 连接路由器到外部端口
  routers.zip(io.ports).foreach { case (r, p) => p <> r.io.hostPort }
}

实现双向环

利用上述抽象,实现双向环变得非常简洁。我们只需提供具体的路由逻辑和连接方式。

class BidirectionalRingRouter[T <: Data](params: NetworkParams[T], myId: Int) extends Router(params, 3) {
  override def routeLogic(addr: UInt): UInt = {
    val dist = Mux(addr > myId.U, addr - myId.U, myId.U - addr)
    val distWrap = params.nHosts.U - dist
    Mux(dist < distWrap, 1.U, 2.U) // 选择更近的方向
  }
}
class BidirectionalRingNetwork[T <: Data](params: NetworkParams[T]) extends MultiHopNetwork[T, BidirectionalRingRouter[T]](params) {
  override val routers = Seq.tabulate(params.nHosts)(id => Module(new BidirectionalRingRouter(params, id)))
  override def connectRouters(routers: Seq[BidirectionalRingRouter[T]]): Unit = {
    // 使用foldLeft连接成环
  }
}

实现2D环面网络

同样,我们可以用类似的模式实现更复杂的2D环面网络,只需定义其特定的路由逻辑(考虑行和列)和2D连接方式即可。大部分基础功能都已由抽象类提供。


工厂模式与模式匹配

最后,我们可以创建一个工厂对象,根据传入的参数类型,动态生成对应的网络实例。这为库的使用者提供了极大的便利。

object Network {
  def apply[T <: Data](params: NetworkParams[T]): Network[T] = params match {
    case p: CrossbarParams[T] => new Crossbar(p)
    case p: RingParams[T] => new RingNetwork(p)
    case p: TorusParams[T] if p.dim == 1 => new RingNetwork(p.toRingParams) // 模式匹配优化
    case p: TorusParams[T] => new TorusNetwork(p)
    case _ => throw new IllegalArgumentException("Unknown network parameters")
  }
}
// 用户使用方式
val myNet = Network(TorusParams(dim=2, nHosts=16, payloadType=UInt(32.W)))

总结与设计经验

本节课中我们一起学习了如何通过渐进式设计构建一个参数化的网络生成器库。

核心设计经验总结:

  1. 快速启动:从一个小型、可运行的设计开始,确保其功能正确。
  2. 渐进扩展:逐步添加功能和优化,每次迭代都保持一个可工作的状态。
  3. 识别抽象:当编写多个相似模块或发现复制粘贴的代码时,就是提取通用抽象的好时机。
  4. 利用语言特性:充分利用Scala和Chisel的特性,如Case Class、类型参数、继承、函数式编程和模式匹配,来构建灵活、可复用的设计。
  5. 构建库:通过抽象基类和工厂模式,将相关组件组织成易于使用的库,提高未来项目的开发效率。

通过这个案例,我们展示了如何将敏捷开发思想应用于硬件设计,通过迭代和重构,最终得到一个强大而优雅的解决方案。

022:设计优化与存储器入门 🚀

在本节课中,我们将学习硬件设计优化的基本概念,并深入探讨存储器设计的重要性。我们将了解如何通过优化设计来提升效率,并掌握存储器设计中的关键技术与策略。


概述

硬件设计不仅仅是实现功能,还需要考虑物理约束和性能指标。本节课将介绍设计优化的基本概念,并重点讨论存储器设计,因为存储器通常是硬件设计中成本最高、性能影响最大的部分。


为什么需要设计优化?🤔

优化硬件设计的主要目的是提升效率。效率的提升取决于具体的设计目标,例如性能、功耗或面积。然而,优化并非总是必要的。如果设计目标非常简单,可能只需要使用现成的微控制器并运行软件即可。但如果需要更高的效率或更低的成本,就需要进行定制化硬件设计。

因此,在设计过程中,我们需要权衡优化的收益与成本。这意味着我们需要明确设计目标,并选择合适的技术和工具来实现这些目标。


设计优化的三大指标 📊

在硬件设计中,我们通常关注三个关键指标:功耗、性能和面积。这三个指标通常缩写为PPA。

  1. 功耗:功耗直接影响设备的电池寿命、散热和运行成本。降低功耗可以延长电池使用时间、减少散热需求并降低电费。
  2. 性能:性能通常指完成特定应用所需的时间。高性能意味着更快的处理速度,但可能需要更高的功耗和更大的面积。
  3. 面积:面积指硬件设计所占用的物理空间。面积越小,成本通常越低,因为所需的芯片尺寸和封装成本都会减少。

这三个指标之间存在权衡关系。优化其中一个指标可能会牺牲其他指标。因此,在设计过程中,我们需要根据具体需求做出合理的权衡。


硬件设计与软件设计的差异 💻🆚🔧

在软件设计中,编写代码并确保其正确性通常是主要任务。一旦代码通过编译,就可以在不同平台上运行,且性能通常具有可移植性。编译器会自动进行优化,开发者通常不需要过多关注底层细节。

然而,在硬件设计中,功能正确性只是起点。实际制造芯片还需要考虑物理设计、验证和制造等多个复杂步骤。硬件设计的优化通常与具体的技术工艺紧密相关,且工具链更为复杂,容易出错。

因此,硬件设计需要更多的迭代和反馈。敏捷设计方法可以帮助我们尽早发现问题并进行调整,从而降低开发风险。


存储器设计的重要性 🧠

存储器是硬件设计中的关键组成部分,通常占用大部分芯片面积和成本。无论是片上存储器还是片外存储器,其设计和优化都对整体性能有重大影响。

存储器可以看作是一种动态连接器,允许数据在不同时间点被读取和写入。这种灵活性使得存储器在硬件设计中具有重要作用,但也带来了延迟、带宽和端口数量等挑战。


存储器的基本术语 📖

以下是存储器设计中常用的术语:

  1. 容量:存储器能够存储的数据量,通常以比特或字节为单位。
  2. 延迟:从发出请求到获取数据所需的时间。
  3. 带宽:存储器在单位时间内能够传输的数据量,通常以字节/秒为单位。
  4. 端口:存储器与外部系统交互的接口,可以是读端口、写端口或读写端口。
  5. 请求:向存储器发出的读取或写入操作。
  6. 并行度:同时处理的请求数量。

利特尔定律的应用 ⚖️

利特尔定律描述了系统中延迟、吞吐量和并行度之间的关系。其公式为:

吞吐量 = 并行度 / 延迟

通过利特尔定律,我们可以理解如何通过增加并行度或减少延迟来提升存储器的吞吐量。例如,如果延迟固定,增加并行度可以显著提高吞吐量。


存储器优化策略 🛠️

为了降低存储器成本并提升性能,我们可以采用以下策略:

  1. 减少数据需求:通过优化算法或数据结构,减少需要存储和传输的数据量。
  2. 选择低成本存储器:在满足性能需求的前提下,选择成本更低的存储器技术。
  3. 容忍高延迟:通过缓存或预取技术,减少高延迟对系统性能的影响。
  4. 降低带宽需求:通过数据压缩或流水线技术,减少对存储器带宽的需求。

存储器分层结构 📚

存储器技术通常按照速度、成本和密度进行分层。以下是一些常见的存储器类型:

  1. 寄存器:速度最快,但成本最高,密度最低。
  2. SRAM:速度较快,成本较高,常用于缓存。
  3. DRAM:速度较慢,成本较低,常用于主内存。
  4. 闪存:速度慢,成本低,密度高,常用于存储设备。

在设计过程中,我们需要根据具体需求选择合适的存储器类型。


存储器优化技术 🔧

以下是几种常见的存储器优化技术:

1. 分块技术 🧩

分块技术通过将一个大存储器划分为多个小存储器(块)来提升并行度。每个块可以独立处理请求,从而增加整体吞吐量。分块技术有两种实现方式:

  • 独立分块:每个块存储不同的数据,适用于均匀访问模式。
  • 复制分块:每个块存储相同的数据,适用于高读取负载的场景。

2. 平滑流量技术 ⏳

存储器访问通常具有突发性,即流量在短时间内急剧增加。为了应对这种情况,我们可以采用以下技术:

  • 流水线技术:将计算和通信操作重叠执行,减少空闲时间。
  • 双缓冲技术:使用两个缓冲区交替进行读写操作,实现计算与通信的并行。

3. 工具与实现 🛠️

在硬件设计中,我们可以使用以下工具和技术来实现存储器优化:

  • 存储器编译器:根据参数自动生成存储器设计。
  • 硬件描述语言:使用Chisel或Verilog描述存储器行为,让工具自动推断最优实现。
  • 黑盒封装:将特定的存储器设计封装为模块,以便在高层设计中调用。

总结 🎯

本节课中,我们一起学习了硬件设计优化的基本概念,并深入探讨了存储器设计的重要性。我们了解了设计优化的三大指标(功耗、性能和面积),掌握了利特尔定律的应用,并学习了多种存储器优化技术。

通过本节课的学习,你应该能够理解如何在硬件设计中进行有效的优化,并掌握存储器设计中的关键策略。在接下来的课程中,我们将继续探讨更多优化技术,帮助你在实际项目中应用这些知识。


023:开源项目开发(第一部分)

在本节课中,我们将学习如何开发高质量的开源项目。我们将探讨一系列能够提升代码质量、可维护性和协作效率的实践与工具。


概述

开发一个理想的代码项目意味着什么?理想的代码应该是正确的、易于使用的、文档齐全的,并且高效的。然而,高质量的代码很少是第一次写就成功的。它通常需要经过反复的修订和改进,无论是通过工具、个人努力还是团队协作。本节课,我们将深入探讨实现这一目标的几个关键方面:持续集成、代码管理、代码审查和文档编写。


持续集成:让工具为你工作

上一节我们讨论了理想代码的特性,本节中我们来看看如何通过自动化流程来维护这些特性。持续集成是一种通过自动化测试来尽早发现代码错误的实践。

其核心思想是:当项目规模变大,或者有多人协作时,尽管我们尽力避免,错误仍会悄悄潜入代码。我们希望尽快发现这些错误,因为发现得越早,修复起来就越容易。持续集成通过自动化工具,在每次代码变更(如提交或合并请求)时自动运行测试套件,从而实现这一目标。

以下是持续集成带来的主要好处:

  • 尽早发现错误:错误在引入后很快就能被发现,而不是潜伏数周或数月。
  • 提升信心:公开的CI状态能让其他用户或贡献者对你的代码质量更有信心。
  • 自动化审查:在人工审查合并请求之前,CI可以自动检查该请求是否破坏了现有功能。

如何设置持续集成

要设置CI,你需要两样东西:

  1. 测试:这是CI的基础。你需要在开发过程中编写测试。
  2. 自动化脚本:利用现有的CI/CD工具(如GitHub Actions、Jenkins)来配置自动化流程。

例如,在GitHub上,你可以通过项目仓库的“Actions”标签页查看CI运行状态。配置通常通过一个YAML文件(如 .github/workflows/ci.yml)完成,其中定义了运行环境、步骤和要执行的测试命令。

测试的类型

在CI中,你可以运行多种类型的测试:

  • 单元测试:测试单个模块或组件在隔离环境下的功能。这是构建高质量代码的基石。
  • 集成测试:测试多个模块组合在一起是否能正常工作。
  • 回归测试:确保新的更改没有破坏之前正常工作的功能。
  • 冒烟测试:一组快速运行的基础测试,用于快速验证核心功能是否正常。
  • 性能测试:确保代码更改没有导致性能下降。对于硬件设计,这可能意味着检查时序是否恶化。

你可以根据测试的耗时和重要性来配置CI策略。例如,每次提交都运行冒烟测试,而全面的回归测试或性能测试可以每晚或每周运行一次。


代码管理:使用Git进行高效协作

在讨论了自动化测试之后,我们来看看如何有效地管理代码本身。版本控制系统,特别是Git,是现代软件开发(包括硬件设计)的基石。

使用Git不仅仅是把代码存放到服务器上。它关乎协作追踪变更记录历史。一个良好的提交历史应该清晰、原子化。这意味着每次提交都应该代表一个逻辑上独立且完整的更改,并附有清晰的提交信息。如果一条提交信息难以描述,那通常意味着这次提交包含的更改过多。

Git的高级功能与实践

以下是Git中一些有用的功能及使用建议:

  • 子模块:允许你将一个Git仓库的特定提交作为依赖引入到你的主仓库中。这对于引用你无法控制的第三方代码库非常有用。然而,对于你自己控制的大型项目,通常更推荐使用单一仓库(monorepo)模式,除非子项目确实有独立的用途。
  • 分支:分支非常适合短期开发任务。例如,你可以为每个新功能或错误修复创建一个分支,完成后通过合并请求将其合并回主分支。应避免创建大量长期存在的分支,这会导致合并变得复杂。
  • 合并请求:这是GitHub等平台提供的一种协作机制。贡献者不是直接向主仓库推送代码,而是创建一个合并请求,请求维护者审查并合并他们的更改。这为代码审查提供了结构化的机会。

代码审查:借助他人之力提升代码质量

在设置了自动化测试和代码管理流程后,我们还需要一个关键步骤来提升代码质量:代码审查。代码审查是获取他人对你代码的反馈的过程。

可以将其类比为写作:你很少能一次性写出完美的文章。初稿完成后,你需要反复修改、重写、调整结构。代码也是如此。第一次能让代码运行通过,这只是完成了“初稿”。接下来需要改进其清晰度、简洁性和可读性。

代码审查的好处

  • 发现盲点:审查者可能发现你未考虑到的逻辑错误或边界情况。
  • 提升可读性与一致性:确保代码符合团队的编码规范,使项目代码风格统一,便于后人阅读。
  • 知识共享:审查过程本身就是一个学习机会,审查者和被审查者都能从中受益。
  • 内在激励:知道自己的代码将被同事审查,会促使你在提交前进行更仔细的检查和优化。

代码审查的流程

典型的流程如下:

  1. 开发者完成代码并自我检查后,发起审查请求(例如,在GitHub上创建一个合并请求并指定审查者)。
  2. 工具先行:CI自动运行测试,代码风格检查工具(Linter)自动检查格式。这为人工审查扫清了基础障碍。
  3. 审查者仔细阅读代码,提出修改建议或疑问。这些讨论通常直接在代码变更的行上进行。
  4. 开发者根据反馈修改代码,并更新审查请求。
  5. 经过可能的多轮迭代后,审查者批准合并(常用“LGTM”表示通过)。
  6. 代码被合并到主分支。

在审查时,审查者通常会关注:

  • 正确性:更改是否引入了错误?是否添加了相应的测试?
  • 可读性:代码是否清晰易懂?命名是否恰当?
  • 设计:实现方式是否合理?是否有更简单或更高效的方法?
  • 文档:是否更新了相关的注释或文档?

文档:让项目易于理解和使用

最后,我们来谈谈项目的门面:文档。优秀的文档对于项目的采用和外部贡献至关重要。编写代码的人可能最不关心文档,因为他们知道代码如何工作。但受缺乏文档影响最大的,恰恰是用户潜在的贡献者

文档的层次

文档可以分为三个层次:

  1. README文件:这是项目的“首页”,是绝对的最低要求。它应该在第一段就清晰地概括项目是做什么的。一个好的README还应包含快速入门指南、安装说明和基本用法示例。
  2. 代码内文档:直接在源代码中编写的文档,用于解释模块、类、函数的功能和参数。例如,在Scala/Chisel中可以使用Scaladoc注释,这些注释可以被工具提取并生成漂亮的API文档网站。
  3. 独立文档网站:对于复杂的项目,可以构建一个独立的文档网站,提供教程、深入的概念解释、设计决策等。可以使用如Sphinx、Docusaurus、GitBook等静态网站生成器。

编写优秀文档的建议

  • 首要任务:说明用途:确保文档(尤其是README)开篇明义地说明项目的整体功能和目的。
  • 强调“做什么”,而非“怎么做”:对于用户文档,应着重描述接口、功能和使用方法,而不是过早深入内部实现细节。
  • 保持同步:过时的文档比没有文档更糟糕。尽量将文档靠近代码(如代码内文档),并建立更新文档的流程。


总结

本节课我们一起学习了开源项目开发中的四个核心实践。

  1. 持续集成:通过自动化测试尽早发现错误,让工具承担重复的检查工作。
  2. 代码管理:使用Git等工具进行有效的版本控制和团队协作,善用分支和合并请求。
  3. 代码审查:通过同行反馈来提升代码的正确性、可读性和一致性,将“初稿”代码打磨成高质量成品。
  4. 文档编写:创建清晰、全面的文档来帮助用户理解和使用你的项目,并吸引外部贡献者。

这些实践共同构成了一个迭代和改进的敏捷开发循环,能够显著提升任何软件或硬件设计项目的质量与可持续性。在下一部分,我们将继续探讨开源许可、社区建设等相关主题。

024:开源项目开发(第2部分)🚀

概述

在本节课中,我们将继续探讨开源项目开发。上一节我们介绍了项目开发中的持续集成、代码审查和版本控制等实践。本节中,我们将聚焦于“开源”本身,探讨为何要开源、如何选择许可证、以及如何吸引社区贡献者。


为何要开源?🤔

将你的工作开源,有以下几个核心原因:

  1. 帮助世界:你投入时间创造的作品,可以改善他人的体验。开源能让更多人受益。
  2. 社区改进:开源后,社区可以帮助你修复漏洞、提升性能、增加功能,你无需独自承担所有改进工作。
  3. 提升个人履历:拥有一个包含有意义的开源项目的GitHub个人主页,远比仅仅上传课程作业更能吸引招聘者的注意。
  4. 回馈社区:我们所有人都极大地受益于开源软件。将你的工作开源,是对你所依赖的生态系统的回馈。
  5. 商业趋势:许多公司(如Meta)发现,开源其非核心优势的技术,能通过广泛的采用来降低自身成本并推动生态发展。


优秀开源项目的要素🌟

一个有趣或优秀的开源项目通常具备以下特点:

  • 实用性:它解决了人们实际存在的问题。
  • 正确性:代码经过充分测试,功能可靠。
  • 良好文档:清晰的文档帮助他人理解和使用。
  • 适当宣传:通过社交媒体或GitHub等平台让项目被更多人看到。
  • 明确的许可证:这是开源的法律基础,允许他人合法使用你的代码。

开源许可证:法律基石⚖️

仅仅将代码放在网上并不等同于“开源”。你的代码默认受版权保护。他人若要使用,需要你的许可。开源许可证就是一份授予他人特定使用权限的法律文件。

许可证的核心要素

大多数开源许可证都允许他人使用修改代码,但在以下方面存在差异:

  • 商业用途:是否允许用于商业目的。
  • 修改后分发:修改代码后,是否必须公开修改内容(即“Copyleft”特性)。
  • 商标使用:是否允许使用项目相关的商标。
  • 专利授权:一个关键但常被忽视的点。代码可能实现了受专利保护的方法。某些许可证(如Apache 2.0)包含专利授权条款,承诺贡献者不会因使用该代码而因内含的专利被起诉。

重要提示:请使用成熟的标准许可证,不要自行发明。标准许可证经过法律实践检验,能减少使用者的疑虑和法律风险。

常见许可证对比

以下是几种常见的开源许可证:

  • BSD / MIT:非常宽松。允许任何用途(包括商业用途),通常不要求公开修改。源自大学,是个人项目的绝佳选择。
  • Apache 2.0:同样宽松,但增加了关键的专利授权条款。深受企业欢迎,是许多工业级项目的选择。
  • GPL (v3):具有强Copyleft特性。使用该许可证的代码,其衍生作品也必须以GPL开源。这可能导致一些公司因其限制而避免使用。
  • Unlicense / WTFPL:最大程度的宽松,近乎放弃所有权利。通常用于个人或实验性项目。

公式/代码示例:许可证文件
通常,在项目根目录放置一个名为 LICENSE 的文件。

Copyright (c) [年份] [版权持有者]
基于 MIT 许可证授权。
...


如何应用许可证?📄

  1. 单一许可证项目:在项目根目录创建 LICENSE 文件,并填入所选许可证的全文。GitHub等平台能自动识别并展示。
  2. 复杂项目与SPDX:对于包含多个贡献者或不同许可证代码的大项目,可以使用 SPDX 标准。在每个源文件头部添加机器可读的许可证标识。
    // SPDX-License-Identifier: Apache-2.0
    package mypackage
    
    这有助于自动化工具扫描和确认整个项目的许可证合规性。


如何吸引贡献者?👥

将项目开源后,你可能会希望建立社区并获得贡献。以下方法有助于达成这一目标:

  • 项目本身具有吸引力:解决一个对群体有价值的问题。
  • 完善的测试:测试不仅保证质量,也让潜在贡献者相信项目的稳健性,并方便他们验证提交的代码。
  • 清晰的文档:帮助新贡献者快速上手。
  • 积极回应:及时处理Issue和Pull Request(几天内),保持社区的活跃度。
  • 提供沟通渠道:如GitHub Discussions、Matrix/Slack频道等,方便社区交流。
  • 列出贡献指南:明确说明如何贡献,甚至可以设置“Good First Issue”标签,引导新手参与。


总结

本节课中,我们一起学习了开源项目开发的核心法律与社区层面。我们探讨了开源的价值、如何通过选择合适的许可证(如宽松的MIT/BSD或包含专利授权的Apache 2.0)来合法地分享代码,以及如何通过完善文档、测试和积极互动来培育一个健康的开源社区。结合上一节的项目管理实践,你现在已经掌握了启动和维护一个成功开源项目所需的关键知识。

025:嘉宾讲座 - Bluespec 概述

在本节课中,我们将学习由 Bluespec 公司 CTO Rishiyur Nikhil 带来的关于 Bluespec 硬件设计语言的特别讲座。我们将了解 Bluespec 的核心概念、设计哲学,以及它与其他硬件描述语言(如 Chisel、Verilog)的区别。


概述

Bluespec 是一对通用的高级硬件设计语言,包含 BSV 和 BH 两种语法变体。它借鉴了 Haskell 的函数式编程思想来描述电路结构,并采用“受保护的原子动作”来描述电路行为。自 2020 年起,Bluespec 已成为免费开源工具。


核心概念:电路描述与行为

在硬件描述语言中,设计活动可分为两部分:电路描述电路行为

上一节我们介绍了课程的整体框架,本节中我们来看看 Bluespec 在这两个方面的独特之处。

电路描述:借鉴 Haskell

Bluespec 在电路描述方面借鉴了 Haskell 的纯函数式编程范式,拥有强大的类型系统,包括代数类型、多态类型和类型类。

  • BSV 语法:类似于 SystemVerilog,用于定义包、参数化类型和模块。
  • BH 语法:语法与 Haskell 完全相同。
  • 增强类型系统:支持精确的位宽规范和匹配,防止静默的位丢失或溢出。
  • 多时钟域支持:支持多个时钟和复位域,并进行静态域检查,确保信号在跨越时钟域时必须通过同步器。

电路行为:受保护的原子动作

电路行为通过“受保护的原子动作”来描述。这不是一个新概念,在 TLA+、Unity 等软件并发规范语言中早有应用。Bluespec 的创新在于将其应用于硬件设计,并能以模块化方式构建这些原子动作。

一个原子动作(规则)包含两部分:

  • 条件/守卫:一个布尔表达式。
  • 动作:更新状态的操作。

其执行语义非常简单:

  1. 选择任意一个条件为真的规则。
  2. 执行该规则的动作。
  3. 重复此过程。

原子性保证了规则执行时不会发生交错,极大地简化了并发状态更新的正确性推理。


与 Chisel 和 HLS 的对比

理解了 Bluespec 的双重特性后,我们可以将其与其他流行工具进行比较。

  • 计算模型
    • Bluespec:受保护的原子动作。
    • Chisel/Verilog:时钟驱动的数字逻辑。
    • HLS:C/C++ 程序执行。
  • 微架构
    • Bluespec/Chisel/Verilog:是硬件描述语言,代码精确描述目标电路。
    • HLS:微架构由编译器生成,用户可能不清楚其细节。

原子动作示例:缓存一致性协议

让我们通过一个具体的例子来理解原子动作如何简化复杂系统的描述。

考虑一个简化的缓存一致性系统,有两个 L1 缓存(A 和 B)和一个共享的 L2 缓存。初始状态是 L1A 以独占模式持有数据 X,L2 持有旧值 Y,L1B 无效。

当 L1B 收到一个读请求时,期望的状态转换是:

  1. 将 L1A 降级为共享状态。
  2. 将 L2 更新为值 X 并标记为共享。
  3. 将 L1B 更新为共享状态并持有值 X。
  4. 向 CPU 返回响应 X。

在 Bluespec 中,这可以用一个原子规则直接表达:

rule read_request_on_B (req_fifo.first().type == READ && l1b.tag == INVALID && l2.tag == EXCLUSIVE);
    let x = l1a.value;
    l1a.update(SHARED, x);
    l1b.update(SHARED, x);
    l2.update(SHARED, x);
    resp_fifo.enq(x);
endrule

这个规则是一个高级规范。为了实现它,我们需要将其细化为多个更细粒度、只涉及本地操作和消息传递的规则(实现)。Bluespec 允许使用相同的语言词汇在不同抽象级别进行设计和推理,便于验证规范与实现之间的等价性。


模块接口与方法

Bluespec 中模块间的交互通过接口和方法进行,类似于面向对象编程。但有两个关键增强:隐式条件调度约束

隐式条件

每个方法都有一个隐式的布尔条件,决定该方法能否被合法调用。例如,一个 FIFO 的入队方法 enq 在 FIFO 满时不应被调用。规则的整体启用条件是显式规则条件与所有被调用方法的隐式条件逻辑与。编译器会自动处理这些条件,无需程序员显式编写流控代码。

调度约束

调度约束规定了同一时钟周期内方法如何被并发调用。例如:

  • 流水线 FIFO:逻辑上 deq 发生在 enq 之前,允许同一周期内出队和入队。
  • 旁路 FIFO:逻辑上 enq 发生在 deq 之前,允许数据在同一周期“穿过”FIFO。
  • 互斥约束:两个规则不能在同一周期并发执行(需要仲裁)。

这些约束作为类型信息的一部分由编译器检查并强制执行,确保生成的 RTL 代码符合预期语义。


高级特性示例:蝶形交换机

Bluespec 的强大之处在于能简洁地描述复杂、参数化的硬件。以下是一个递归蝶形网络交换机的核心代码片段,展示了 Haskell 式的强大抽象能力:

module mkButterflySwitch#(Integer n, function OutPort destinationOf(T x), module mkMerge2to1) (Switch#(n, T));
    if (n == 1) begin
        // 基础情况:实例化一个 FIFO
        FIFO#(T) f <- mkFIFO;
        return tuple2(cons(f.enq, nil), cons(f.deq, nil));
    end else begin
        // 递归情况:实例化两个半尺寸的子交换机
        Switch#(n/2, T) leftSwitch <- mkButterflySwitch(...);
        Switch#(n/2, T) rightSwitch <- mkButterflySwitch(...);
        // 实例化一列合并模块
        Vector#(n, Merge2to1) merges <- replicateM(mkMerge2to1);
        // 连接逻辑(此处省略)...
        // 行为规则:路由单元
        for (Integer i = 0; i < n; i = i + 1) begin
            rule doRouting;
                let pkt = inputPorts[i].first();
                inputPorts[i].deq();
                let out = destinationOf(pkt);
                if (out % 2 == 0) merges[i/2].in0.enq(pkt);
                else merges[i/2].in1.enq(pkt);
            endrule
        end
    end
endmodule

这段代码展示了:

  1. 高阶模块参数化:将合并模块作为参数传入。
  2. 递归模块实例化:轻松构建递归硬件结构。
  3. 强大的静态生成:使用 replicateM 等函数生成硬件实例。
  4. 清晰的规则描述doRouting 规则简洁地描述了每个路由单元的行为。

编译流程与工具链

Bluespec 源代码(.bsv 或 .bh)通过 BSC 编译器处理:

  • 仿真路径:可编译生成 C++ 代码,链接成原生仿真可执行文件(Bluesim)。支持导入 C/C++ 代码进行协同仿真或测试。
  • 综合路径:可编译生成标准的、可综合的 Verilog 代码。此 Verilog 可交由其他工具进行仿真、FPGA 实现或 ASIC 综合。

BSC 编译器的核心任务包括:

  1. 保留源代码中的微架构状态(寄存器、FIFO 等)。
  2. 保留计算逻辑,并进行局部优化。
  3. 将规则转换为 RTL:这是最复杂的部分。编译器会分析所有规则,插入仲裁逻辑,在最大化规则并发性的同时,严格保证规则的原子性。它内部使用 SMT 求解器来分析规则条件之间的关系,以生成更优化的仲裁电路。

总结与资源

本节课我们一起学习了 Bluespec 硬件设计语言的核心思想。Bluespec 通过结合 Haskell 的函数式抽象和受保护的原子动作模型,提供了一种高表达力、高可靠性的硬件设计方法。它强调正确的构造和安全的并发,支持从高级规范到低级实现的平滑细化,并具备强大的静态检查能力,有助于实现敏捷的硬件开发。

主要特点总结

  • 通用高级硬件设计语言。
  • 两种语法:类 SystemVerilog 的 BSV 和类 Haskell 的 BH。
  • 电路描述借鉴 Haskell,电路行为基于受保护的原子动作。
  • 强大的类型系统(含位宽检查)和多时钟域静态检查。
  • 隐式条件和调度约束简化接口设计。
  • 编译器保证原子性,并自动生成仲裁逻辑。
  • 自 2020 年起免费开源。

相关资源

  • 编译器、教程、教科书、IP 库等资源链接可在讲座幻灯片中找到。
  • 一个重要的生态系统工具是 BlueCheck,一个受 QuickCheck 启发的、可综合的属性随机测试框架,支持在 FPGA 上运行测试并进行用例收缩。

026:使用Chisel进行形式验证

概述

在本节课中,我们将学习形式验证的基本概念,特别是如何将其应用于使用Chisel设计的硬件电路。我们将从布尔可满足性问题(SAT)和可满足性模理论(SMT)的基础知识开始,然后探讨如何利用这些理论来验证组合电路和时序电路。课程将结合理论讲解与在Jupyter Notebook中运行的代码示例,帮助你理解形式验证的核心思想与实践方法。


布尔可满足性问题(SAT)🔍

首先,我们来理解形式验证的一个核心基础:布尔可满足性问题。

布尔可满足性问题是指:给定一个由布尔变量(x1, x2, ..., xn)组成的布尔公式 F,是否存在一种对这些变量的赋值(真或假),使得整个公式 F 的最终结果为真(true)。如果存在这样的赋值,我们称公式 F可满足的;如果不存在,则称其为不可满足的

我们可以通过一个简单的函数来询问SMT求解器某个公式是否可满足。

// 示例:检查公式 `b0 && !b0` 是否可满足
// 变量 b0 只有两种赋值可能:true 或 false。
// 无论 b0 是 true 还是 false,公式 `b0 && !b0` 的结果总是 false。
// 因此,该公式是不可满足的。
solve(b0 && !b0) // 预期结果:UNSAT(不可满足)

对于公式 b0 && b1,情况则不同。

// 示例:检查公式 `b0 && b1` 是否可满足
// 当 b0 = true 且 b1 = true 时,公式结果为 true。
// 因此,该公式是可满足的,求解器会提供一个满足条件的例子。
solve(b0 && b1) // 预期结果:SAT(可满足),并给出一个例子,如 b0=true, b1=true

求解方法的挑战

一种直观的求解方法是列出真值表,检查是否存在结果为真的行。然而,这种方法存在一个严重问题:其复杂度随着变量数量的增加呈指数级增长。对于一个有 n 个变量的公式,真值表将有 2^n 行。对于具有数十个甚至数百个输入的硬件电路来说,这是完全不现实的。

尽管SAT问题在最坏情况下是指数级复杂的(NP完全问题),但研究人员发现,对于许多实际中遇到的问题,存在高效的启发式算法可以在合理时间内求解。这使得基于SAT的验证方法变得可行。


可满足性模理论(SMT)🧩

上一节我们介绍了基础的布尔可满足性问题。然而,硬件设计通常涉及更高级的概念,如整数运算和内存访问,而不仅仅是单个比特的操作。本节中,我们来看看可满足性模理论如何扩展SAT,以支持这些更丰富的数据类型。

SMT在布尔逻辑的基础上,增加了对特定“理论”的支持,使我们能够直接表达像位向量(固定宽度的整数)和数组(用于模拟内存)这样的高级概念。

例如,我们可以询问是否存在一个整数 a,满足 a + a == 8

// 定义位向量变量 a
val a = BitVec("a", 32)
// 构造公式:a + a == 8
val formula = (a + a) === 8
// 求解
solve(formula) // 预期结果:SAT, a = 4

我们也可以尝试求解 a + a == 9

val formula2 = (a + a) === 9
solve(formula2) // 预期结果:UNSAT,因为不存在整数解满足 2*a = 9

SMT求解器还能帮助我们验证编译器优化。例如,我们可以检查“强度折减”优化(将乘以4的运算替换为左移2位)是否总是等价。

val c = BitVec("c", 32)
val a = BitVec("a", 32)
// 检查是否存在常数 a,使得对于所有 c,有 c * 4 == c << a
// 实际上,我们固定一个具体的 c 值(如145)来寻找 a
val formula3 = (c * 4) === (c << a)
solve(formula3) // 预期结果:SAT, a = 2 (因为 4 是 2 的 2 次方)

如果尝试将4改为非2的幂,比如5,则求解器会返回不可满足。

val formula4 = (c * 5) === (c << a)
solve(formula4) // 预期结果:UNSAT

组合电路的形式验证⚙️

理解了SAT和SMT的基础后,我们现在可以将其与硬件电路联系起来。本节我们将探讨如何对组合电路(即没有状态元素,如寄存器和内存的电路)进行形式验证。

在Chisel中,我们使用 assert 语句来声明电路必须始终满足的属性。在仿真中,如果断言被违反,仿真会停止并报错。在形式验证中,我们的目标是证明对于所有可能的输入,该断言都永远不会被违反。

验证的思路是:我们请SMT求解器寻找一个能使断言为假的输入赋值。如果找到了这样的赋值(公式可满足),那就发现了一个错误(Bug)。如果找不到这样的赋值(公式不可满足),则证明该断言对于所有输入都成立。

考虑一个简单的电路,其断言是 b > a,其中 b = a + 1

// 这是一个概念性描述,并非完整Chisel代码
Input: a
Wire: b = a + 1
Assert: b > a

为了验证,我们构造以下SMT公式,询问是否存在使断言失败的情况:

// 构造公式:存在 a,使得 b = a + 1,但 b <= a
val a = BitVec("a", 32)
val b = a + 1
val bad_condition = !(b > a) // 即 b <= a
solve(bad_condition) // 寻找反例

运行后,求解器会找到一个反例:当 a 是32位全1(即最大值)时,a+1 会发生溢出,结果为0。此时 b(0) 并不大于 a(最大值),断言被违反。这揭示了一个潜在的溢出错误。

在Chisel Test中,我们可以更直接地进行验证:

import chisel3._
import chiseltest._
import chiseltest.formal._

class MyModule extends Module {
  val a = IO(Input(UInt(32.W)))
  val b = IO(Output(UInt(32.W)))
  b := a + 1.U
  assert(b > a, "b should be greater than a")
}

// 使用 verify 进行有界模型检查(对于组合电路,界限为1即可)
test(new MyModule).verify(_.a, 1) // 这将发现并报告溢出错误

实际案例:格雷码电路验证

以下是一个更实际的例子,验证格雷码编码器的两个关键属性:

  1. 无损性:二进制数转换为格雷码再转换回来,应得到原数。
  2. 单比特翻转:递增一个二进制数时,其对应的格雷码每次只改变一个比特。
class GrayCodeProperty(width: Int) extends Module {
  val in = IO(Input(UInt(width.W)))
  val gray = BinaryToGray(in)
  val out = GrayToBinary(gray)
  // 属性1:无损
  assert(in === out, "Gray code conversion should be lossless")

  // 属性2:单比特翻转(使用汉明距离)
  val inPlusOne = in + 1.U
  val grayPlusOne = BinaryToGray(inPlusOne)
  val hammingDist = PopCount(gray ^ grayPlusOne)
  assert(hammingDist === 1.U, "Only one bit should flip in Gray code on increment")
}
// 对64位宽进行验证,穷举测试是不可能的(2^64种输入),但形式验证可以完成。
test(new GrayCodeProperty(64)).verify(_.in, 1)

总结:对于组合电路,形式验证可以为我们提供完全证明,确保在所有可能输入下,所声明的属性都成立。在Chisel Test中,只需使用 verify(..., 1) 即可。


时序电路的形式验证⏱️

上一节我们处理了没有状态的组合电路。本节中,我们来看看更复杂的、带有状态(寄存器或内存)的时序电路。验证这类电路的关键在于,我们需要考虑状态在多个时钟周期内的演变。

考虑一个简单的计数器,当使能信号有效且计数值不为22时递增,达到22时归零。我们断言计数值永远不会达到10。

class MyCounter extends Module {
  val enable = IO(Input(Bool()))
  val count = RegInit(0.U(32.W))
  when(enable) {
    when(count === 22.U) {
      count := 0.U
    }.otherwise {
      count := count + 1.U
    }
  }
  assert(count =/= 10.U, "Count should never reach 10")
}

如果我们只验证一个周期(bound = 1),验证会通过,因为从初始状态0开始,一个周期内确实无法到达10。要发现这个错误,我们需要验证足够多的周期,让计数器有时间计数到10。

// 界限为1,验证通过(但结论是错的)
test(new MyCounter).verify(_.enable, 1)
// 界限为10,会发现错误,并给出导致count=10的输入序列(trace)
test(new MyCounter).verify(_.enable, 10)

理论基础:有界模型检查

时序电路的验证通常通过有界模型检查 来实现。其核心思想是将电路抽象为一个迁移系统

  • 状态:由所有寄存器的值构成。
  • 初始状态谓词 I(s):描述系统复位后的状态(如 count == 0)。
  • 迁移关系 T(s, s’):描述在给定输入下,如何从当前状态 s 转移到下一个状态 s’
  • 属性谓词 P(s):描述在状态 s 下,我们期望的属性是否成立(如 count != 10)。

BMC会“展开”这个迁移系统K步,构造一个大的组合逻辑公式,询问是否存在一条长度不超过K的状态迁移路径,最终到达一个违反属性P的状态(坏状态)。如果SMT求解器返回SAT,则找到了一个反例(Bug)。如果返回UNSAT,则证明在K步之内不存在错误。

重要提示:BMC只能证明在指定的步数K内没有错误。它不能证明电路在无限步数下永远正确。不过,实践中许多错误都能在相对较小的K值内被发现。


实践案例:队列验证🔎

理论学习之后,让我们看一个复杂的实践案例:验证一个FIFO队列的正确性。这展示了形式验证在发现隐蔽角落案例错误方面的威力。

我们希望验证队列的一个核心属性:数据完整性。即,进入队列的数据,必须以其进入的相同顺序被取出,且最终一定会被取出。

在Chisel中,我们需要将这个属性也编写为硬件电路(包含assert)。我们创建一个包装模块,内部例化待测队列,并添加用于追踪和断言属性的逻辑。

首次尝试的属性可能只追踪第一个进入队列的数据包,检查它是否正确取出。但这可能漏掉错误,因为当队列中有多个数据包时,逻辑会混乱。

改进后的属性会记录数据包进入时的队列深度,只有当该数据包到达队头(即队列深度减到1)时才检查其输出值。这个更精确的属性成功发现了一个队列实现中的错误:在特定时序下,maybeFull信号的生成逻辑有误,导致队列在应为空时却输出了数据。

// 修复前的错误逻辑
val maybeFull = risingEdge && io.enq.fire === io.deq.fire
// 修复后的正确逻辑
val maybeFull = risingEdge && io.enq.fire =/= io.deq.fire

形式验证工具给出了导致断言失败的具体波形(trace),清晰地展示了错误发生的过程,极大地辅助了调试。尽管它没有直接指出代码中的哪一行错了,但提供的反例极大地缩小了排查范围。

经验总结:编写有效的属性是形式验证中最具挑战性的部分之一。好的属性应该能捕捉设计的关键行为,并且本身足够精确以避免误报。通常需要结合领域知识和调试经验。


高级主题:时序断言与K归纳法🚀

时序断言

之前的断言大多是关于单个周期的。时序断言允许我们表达跨多个周期的属性。Chisel Test提供了paststable等构造来简化这类属性的编写。

例如,对于AXI4流协议中的“Valid稳定”要求:一旦valid信号拉高,在ready信号拉高完成传输之前,valid不能拉低,且传输的数据必须保持稳定。

class AxiStreamProperty extends Module {
  val valid = IO(Input(Bool()))
  val ready = IO(Input(Bool()))
  val data = IO(Input(UInt(32.W)))

  // 使用 past 引用上一周期的值
  when(past(valid && !ready)) {
    assert(valid, “Valid cannot be deasserted without ready”)
    assert(stable(data), “Data must be stable while valid and !ready”)
  }
}

这些时序断言不仅可用于形式验证,也可用于动态仿真测试,实现验证组件的重用。

K归纳法

有界模型检查(BMC)只能证明K步内无错。K归纳法是一种试图证明属性对于所有步数都成立的技术。

  1. 基础步:证明属性在初始状态(以及可能的开头K-1个状态)成立。
  2. 归纳步:假设属性在连续的K个状态中成立,证明在第K+1个状态属性也成立。

如果两者都成立,根据归纳法,属性对所有状态都成立。

然而,K归纳法可能因为归纳假设不够强而失败(即“不归纳”)。这时需要用户提供额外的强化引理来帮助证明。例如,要证明计数器count永远不会达到500,除了主断言count != 500,我们可能还需要一个强化断言count <= 22(因为计数器逻辑决定了它只能在0-22之间循环)。结合这个强化断言,K归纳法才能成功。

使用建议:首先用BMC进行“漏洞狩猎”,因为它总能给出明确的反例。只有当BMC在合理步数内都找不到错误,且你对属性的正确性有高度信心时,才考虑使用K归纳法进行完全证明。


总结

本节课我们一起学习了硬件形式验证的核心概念与实践方法。

我们从布尔可满足性SMT求解器的基础讲起,理解了形式验证的底层引擎。接着,我们探讨了如何对组合电路进行验证,获得关于所有可能输入的完全证明。然后,我们深入到时序电路的验证,介绍了有界模型检查的原理与局限性。通过队列验证的案例,我们看到了形式验证在发现复杂角落案例错误中的强大能力。最后,我们简要了解了时序断言K归纳法这两个高级主题。

形式验证的核心优势在于其穷举性,能够发现仿真测试难以触发的错误。在Chisel生态中,通过Chisel Test库,我们可以相对便捷地将形式验证集成到敏捷硬件开发流程中。虽然编写有效的属性需要经验,但它能极大地提升我们对硬件设计正确性的信心。

027:第18讲 - 延迟优化 🚀

在本节课中,我们将要学习硬件设计中的延迟概念、其来源以及如何优化延迟以提升性能。我们将从物理层面理解延迟的产生,并探讨一系列减少延迟的技术和策略。


概述 📋

延迟是硬件设计中限制性能的关键因素。它主要分为门延迟和线延迟。理解这些延迟的来源,并掌握优化方法,对于设计高性能电路至关重要。本节我们将深入探讨延迟的测量、关键路径分析以及具体的优化技术。


延迟的来源与测量

上一节我们介绍了物理设计的基本概念,本节中我们来看看延迟的具体来源以及如何量化它。

在组合电路中,存在两种主要延迟:

  1. 门延迟:信号通过逻辑门(晶体管)所需的时间。
  2. 线延迟:信号在门与门之间传输所需的时间。

这两种延迟的相对重要性取决于实现技术。在ASIC中,门延迟可能是主要因素;而在FPGA中,线延迟可能占总延迟的70%至80%。

影响延迟的因素包括:

  • 扇入:门的输入数量。扇入越大,门延迟通常越高。
  • 扇出:一个门驱动的负载数量。扇出越大,线延迟通常越高,因为需要更多电流来驱动多个负载。

测量延迟的单位通常是时间,如纳秒或皮秒。在高速设计中,也常用扇出为4的延迟作为技术无关的度量单位。

F04 的定义是:一个反相器驱动四个相同反相器所产生的延迟。通过将电路的总延迟除以该技术的F04延迟,可以得到一个以“F04数量”表示的延迟值,便于在不同工艺节点间进行比较。

例如,奔腾4处理器的关键路径延迟为16.3 F04,这是一个高度优化的设计。


关键路径与静态时序分析

理解了延迟的构成后,我们需要找到设计中的瓶颈——关键路径。

关键路径 是指电路中在最坏情况下,从输入到输出延迟最长的路径。时钟周期必须大于关键路径的延迟,否则电路将无法正确工作。

缩短关键路径延迟有两个主要好处:

  1. 可以缩短时钟周期,提高运行频率。
  2. 在保持相同频率下,可以降低工作电压,从而节省功耗。

工具使用静态时序分析 来识别关键路径。STA为每个逻辑单元和连线分配延迟模型,然后通过前向传播和后向追踪算法,计算出所有路径的延迟,并找到最长的路径。

STA可以在设计流程的不同阶段运行(如综合后、布局布线后),后期阶段的模型更精确,会考虑工艺、电压、温度变化等因素。


优化延迟的策略

当STA工具指出关键路径过长时,我们需要着手优化。优化应遵循“让工具完成工作”的原则,但设计师需要提供合适的结构和约束。

以下是优化延迟的主要方法:

1. 流水线化 ⚙️

这是最经典的减少组合逻辑深度的方法。通过在长组合路径中插入寄存器,将原本的一个长路径分割为多个较短的阶段。

公式T_clock > max(T_stage1, T_stage2, ..., T_stageN)

理想情况下,各阶段延迟应均衡。流水线化通常需要数据并行性,这在许多硬件加速应用中很常见。

2. 寄存器重定时 🔄

这是一种由CAD工具执行的自动化优化。它在不改变电路整体行为(输入/输出关系)的前提下,将寄存器在组合逻辑中向前或向后移动,以平衡各路径的延迟,从而缩短关键路径。

需要注意的是,重定时会改变中间寄存器的值,因此验证流程需要配合逻辑等效性检查 工具,以确保变换前后的设计在功能上等价。

设计师可以通过在代码中预留额外的寄存器(例如使用参数化的pipe模块),为工具执行重定时创造机会。

3. 重构逻辑结构 🌳

有时,关键路径过长是因为逻辑结构本身是深度的线性链。工具通常会尝试优化,但若RTL代码的语义限制了优化空间,则需要手动重构。

一个典型例子是将线性结构的操作(如递归求和)改为树形结构。

代码示例:线性求和 vs. 树形求和

// 线性深度 O(n) - 延迟大
def popCountLinear(vec: Seq[Bool]): UInt = {
    if (vec.isEmpty) 0.U
    else vec.head.asUInt + popCountLinear(vec.tail)
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4702d06830745499394024f242f61a21_40.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4702d06830745499394024f242f61a21_42.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4702d06830745499394024f242f61a21_44.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4702d06830745499394024f242f61a21_46.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/ucsc-cse228a-agl-hw-dsn/img/4702d06830745499394024f242f61a21_48.png)

// 对数深度 O(log n) - 延迟小
def popCountTree(vec: Seq[Bool]): UInt = {
    if (vec.length <= 1) vec.headOption.map(_.asUInt).getOrElse(0.U)
    else {
        val (left, right) = vec.splitAt(vec.length/2)
        popCountTree(left) + popCountTree(right) // 树形递归
    }
}

将加法器组织成树形结构,可以显著减少逻辑级数,从而降低延迟。

重要提示:在投入精力优化某段代码前,务必通过STA确认它是否真的位于关键路径上。工具可能已经对看似低效的代码(如大型多路选择器)进行了出色的优化。


总结 🎯

本节课中我们一起学习了硬件设计中的延迟优化。

  • 我们首先分析了延迟的两个物理来源:门延迟和线延迟,并介绍了F04这一技术无关的延迟度量单位。
  • 接着,我们理解了关键路径的定义及其对电路性能的决定性作用,以及工具如何通过静态时序分析来识别它。
  • 最后,我们探讨了三种核心的优化策略:流水线化寄存器重定时逻辑结构重构。我们强调,设计师的任务是编写易于工具优化的RTL代码(如提供流水线阶段、使用平衡的树形结构),并充分利用现代CAD工具的强大功能,遵循“让工具完成工作”的核心原则。

通过应用这些技术,我们可以有效地缩短关键路径,从而提升电路性能或降低其功耗。

028:功耗与设计空间探索(第1部分)

在本节课中,我们将要学习芯片设计中的功耗概念。功耗是衡量芯片性能、面积和功耗(PPA)三大指标中的关键一环,对于移动设备和数据中心都至关重要。我们将探讨功耗与能量的区别,分析影响动态功耗的主要因素,并介绍几种降低功耗的常用技术。

功耗与能量

上一节我们介绍了课程背景,本节中我们来看看功耗的基本概念。

功耗是能量消耗的速率。换句话说,能量是功率对时间的积分,功率是能量对时间的导数。

  • 能量:电池寿命和电费账单主要与总消耗的能量相关。
  • 功率:峰值功率决定了散热需求和供电基础设施的成本。设备过热时,系统会通过降频来限制功率,这是一种常见的技术。

动态功耗公式

理解了功耗的重要性后,我们来看看作为硬件设计师,如何影响设计的功耗。

动态功耗的一阶近似公式如下:

P_dynamic ∝ α * C * V² * f

以下是公式中各项的含义:

  • α (活动因子):设计中信号发生翻转的比例。
  • C (电容):电路节点的总电容,通常与设计规模相关。
  • V (电压):供电电压。由于与电压平方成正比,降低电压对减少功耗效果显著。
  • f (频率):时钟频率。

因此,降低功耗的主要手段包括:减小设计规模(降低C)、降低工作电压(V)、减少不必要的信号翻转(降低α)以及在满足性能要求的前提下降低时钟频率(f)。

降低功耗的技术

了解了动态功耗的构成后,本节我们来看看两种具体的降低功耗技术。

电源门控

电源门控是指完全关闭整个模块或组件的电源。这种方法可以节省大量功耗,但代价是重新唤醒模块需要时间。因此,它适用于可以预测空闲时间的场景,例如手机蜂窝射频模块在协议规定的空闲时隙进入睡眠。

时钟门控

时钟门控是一种更细粒度的技术,通过切断暂时不用的寄存器或模块的时钟信号来节省功耗。现代CAD工具可以自动进行时钟门控优化。

作为设计师,我们可以通过使用带有明确使能端的寄存器(例如Chisel中的RegEnable)来辅助工具进行优化。这向工具表明,在使能信号无效的周期内,寄存器的值可以是“无关”状态,从而为时钟门控创造了条件。

功耗管理策略

介绍了具体技术后,我们来看看在系统层面管理功耗的两种策略。

假设有一个周期性任务(如解码视频帧),需要在截止时间前完成。为了最大化能效,有两种策略:

  1. 冲刺后休眠:以最高性能(高电压、高频率)快速完成任务,然后让系统进入深度睡眠,直到下一个任务开始。这种策略节省了睡眠期间的静态功耗,但冲刺阶段消耗了更高的动态功耗。
  2. 匀速爬行:以刚好满足截止时间要求的最低性能(低电压、低频率)匀速执行任务,并始终保持活动状态。这种策略避免了高动态功耗,但需要持续支付静态功耗。

最佳策略取决于动态功耗与静态功耗的权衡。现代设备普遍采用的动态电压频率调节(DVFS)技术,就是根据实时负载在多个预设的电压-频率工作点之间切换,本质上是“匀速爬行”策略的自动化实现。而“冲刺后休眠”策略也广泛应用于智能手机等设备中对周期性任务的处理。

总结

本节课中我们一起学习了芯片功耗的基础知识。我们区分了功耗与能量的概念,掌握了动态功耗的近似计算公式 P ∝ αCV²f,并了解了通过降低电压、减少活动因子和门控时钟/电源来优化功耗的方法。最后,我们探讨了“冲刺后休眠”和“匀速爬行”两种系统级功耗管理策略及其应用场景。理解这些概念对于进行高效的硬件设计空间探索至关重要。

029:功耗与设计空间探索(第2部分)🎯

在本节课中,我们将继续学习设计空间探索。上一节我们介绍了功耗分析,本节中我们来看看如何系统地探索设计空间,评估不同设计方案的优劣,并理解其中的权衡。

设计评估指标 📊

要探索设计空间,首先需要知道如何评估一个设计的好坏。以下是评估设计时需要考虑的各类指标:

  • PPA(性能、功耗、面积):这是硬件设计的核心指标。
    • 性能:可能关注单次操作的延迟或系统的整体吞吐量。
    • 功耗:可能关注平均功耗、峰值功耗、能效或热约束。
    • 面积:指芯片上的硅面积、I/O引脚数量或所需的外部存储器组件。
  • 其他量化指标
    • 可用性:设计能处理的应用场景比例。
    • 安全性:抵御特定攻击的能力。
    • 可测试性:设计在制造前后进行测试的难易程度。
  • 非量化但重要的考量
    • 可制造性:将设计投入生产的难易程度。
    • 容错性:系统在部分故障时继续运行的能力。
    • 可重用性:设计模块在不同项目中复用的潜力。
    • 可持续性:制造芯片所蕴含的碳成本等环境影响。

在现实世界中,你需要考虑众多指标。但作为工程师,我们通常首先关注那些更可量化、更受工程控制的指标。

设计参数与探索挑战 ⚙️

评估指标告诉我们什么是“好设计”,而设计参数则是我们可以调整以生成不同设计变体的“旋钮”。

以下是设计参数的类型:

  • 外部参数:从生成器外部可见的接口定义,例如模块的接口类型。
  • 内部参数:生成器内部实现细节,用户通过参数调整,但外部接口不变,例如微架构、并行度或内部路由算法。

面临的挑战是,参数组合的数量可能非常庞大。如何智能地调整这些旋钮?首先,你需要明确哪些指标是真正必要的。其次,尝试将一些指标转化为约束条件(例如“功耗必须低于1瓦”),而不是同时优化所有指标。最后,如果仍需优化多个指标,尝试为它们建立优先级顺序。

对于参数本身,可以采取以下策略来缩小搜索空间:

  • 识别参数间的依赖关系:许多参数并非完全独立。例如,一个队列的深度可能需要与另一个队列的深度相匹配,或者一个参数必须大于另一个参数。识别这些关系可以极大地减少需要探索的组合。
  • 识别高影响力参数:有些参数对设计性能、资源成本的影响远大于其他参数。应优先探索这些关键参数。

设计空间探索策略 🧭

即使经过简化,设计空间探索仍然是一个复杂的优化问题。这本质上是一个非凸优化问题,意味着简单的“爬山”算法可能会陷入局部最优解,而无法找到全局最优。

以下是几种常见的探索策略:

  • 基于知识的引导搜索:利用你对问题的理解,手动指导搜索过程,例如先调整高影响力参数。
  • 经典算法:可以使用分支定界、线性规划或随机搜索等算法。使用Chisel等生成器框架,可以方便地将这些算法与设计生成代码集成。
  • 高级算法:包括遗传算法、进化计算,以及近年来非常热门的机器学习方法。机器学习有望在未来成为设计空间探索的主流工具。

一个常见的方法是使用轻量级模型进行快速探索。你可以构建一个能快速评估设计点近似性能的模型,用它来扫描整个设计空间,找到有希望的区域,然后再使用精确但耗时的“重量级”工具(如完整的RTL综合)进行详细评估。这种方法的关键在于模型的准确性。

实例:矩阵乘法单元设计 🔢

让我们以设计一个矩阵乘法单元为例,应用上述概念。

以下是设计矩阵乘法单元时需要考虑的参数和步骤:

  1. 明确问题与约束:确定矩阵的尺寸、灵活性要求,以及目标是ASIC还是FPGA。
  2. 初步建模与估算:使用电子表格或简单模型进行“信封背面”计算。估算达到目标吞吐量所需的计算单元数量,判断片上资源(如SRAM)是否足够存储矩阵数据。
  3. 实现与评估:使用Chisel实现一个基础设计,并建立能输出PPA指标的工作流。
  4. 参数化与探索:将设计参数化,然后运用前述策略(如引导搜索、算法搜索)来调整参数,生成并评估不同变体。
  5. 迭代与学习:根据探索结果,你可能会发现新的有希望的参数组合,甚至需要调整架构。这是一个敏捷的、迭代式的学习与优化过程。

理解权衡与帕累托前沿 ⚖️

当你试图同时优化多个指标时,就会遇到权衡。一个核心概念是帕累托前沿

假设我们有两个指标X和Y(例如面积和功耗),且都是数值越低越好。一个设计点被称为帕累托最优,是指:为了改进其中一个指标(使其更优),就不得不使另一个指标变差

将所有设计变体根据这两个指标绘制成图,那些帕累托最优的点会形成一条边界线,即帕累托前沿。位于前沿上的点意味着设计已经达到了在当前技术下的最佳权衡状态。如果某个点不在前沿上,则意味着存在一个设计能在不损害任一指标的情况下至少改进一个指标,这是一个无需动脑的优化选择。

拥有设计生成器的优势在于,你可以生成大量设计点,从而清晰地描绘出帕累托前沿,并确保你的最终选择位于这条前沿上,这意味着你已为所关心的指标做出了最佳可能的权衡。

帕累托前沿实例 📈

以下是来自学术论文的两个帕累托前沿实例:

  • 实例一:展示了性能(时间,越低越好)与功耗(几何平均值,越低越好)的权衡。图中可以清晰地看到一条曲线状的帕累托前沿,大多数设计点都位于前沿内部,只有前沿上的点才是需要认真考虑的最佳权衡选择。
  • 实例二:展示了不同处理器架构(顺序/乱序,单发射/多发射)在不同频率下的性能(每秒百万指令,越高越好)与能效(能耗,越低越好)权衡。每条曲线代表一种处理器架构在动态电压频率调节下的表现。该图清晰地揭示:对于不同的性能目标,最节能的处理器架构是不同的(例如,低性能时单发射顺序核心最节能,高性能时则需要多发射乱序核心)。

这些图表在工程实践中非常常见,能帮助你直观地理解不同设计选择带来的权衡,并做出明智的决策。


本节课总结:本节课我们一起学习了设计空间探索的系统方法。我们首先了解了评估设计所需的各种指标,然后探讨了如何通过参数调整来生成设计变体,并面对组合爆炸的挑战。我们介绍了几种探索策略,从基于知识的引导到使用算法和机器学习。通过矩阵乘法单元的实例,我们看到了如何将理论应用于实践。最后,我们深入理解了多目标优化中的核心概念——权衡与帕累托前沿,并通过实例图表直观地认识了这一重要工具。掌握这些方法,将帮助你在未来的硬件设计项目中更高效地找到最优解决方案。

030:第20讲 - Chisel 工具包概览 🧰

在本节课中,我们将回顾并深入探讨一系列关于 Chisel 语言及其使用技巧的要点。这些内容涵盖了常见用法、常见误解以及一些能帮助你写出更清晰、更健壮代码的最佳实践。

Chisel 工作原理回顾 🔄

上一节我们介绍了本讲的主题,本节中我们来看看 Chisel 的基本工作原理。经过近一个季度的使用,这应该不再神秘。

Chisel 是一个嵌入在 Scala 中的领域特定语言(Embedded DSL)。你编写的 Chisel 代码本质上是一个合法的 Scala 程序。如果你在 Scala 层面有错误,编译器会报错。

Chisel 库在你的 Scala 程序运行时被调用。当你的程序运行时,你实际上是在实例化 Chisel 对象,这些对象代表硬件组件。即使是像字面量 4.U 或对某个引用的取反操作 ~io.in,也都是代表硬件的 Chisel 对象。

每个对象内部都有输入和输出,代表硬件本身的输入和输出。这一切都在幕后进行。例如,当我们编写一个模块并声明 extends Module 时,我们使用的是来自 Chisel 库的类继承。

我们的代码最终归结为两件事:实例化对象和连接对象。连接操作符(:=)或批量连接操作符(<>)会在这些 Chisel 对象中引入副作用,即在输入、输出或其他部分之间建立连接。

因此,你的 Chisel 设计就是一个实例化和连接 Chisel 对象的 Scala 程序。所有的参数化和灵活性实际上都来自 Scala 程序本身。Chisel 的核心非常简单:实例化和连接。围绕它的所有功能都是 Scala 提供的。

硬件是静态的 ⚙️

接下来,我们需要明确一个核心概念:硬件设计在结构上是静态的。

这意味着物理硬件、连线、连接都是固定不变的,不会改变。然而,硬件连线上的值在仿真或实际运行中是可以改变的。

例如,一个多路选择器(Mux)总是在两个输入之间进行选择,但它与两个输入的连接是静态的,不会改变。只是它读取哪个输入的值会变化。

同时,寄存器或内存等状态元件具有内部状态,它们只在时钟上升沿改变。在 Chisel 中,时钟通常是隐式的,但请记住,设计中大部分会变化的部分都发生在时钟节拍上。

最后连接语义 ⚡

现在我们来回顾 Chisel 中的“最后连接语义”概念。

这意味着如果对同一个输入有多个连接,Chisel 必须选择一个胜出。这看起来像是运行时行为,但实际上是静态的。胜出的连接是在 Scala 程序(生成器)中最后执行的那个连接,并且会被编码到设计中,该设计是静态的,不会改变。

when 语句为例。在底层,when 语句是通过 Mux 实现的。即使 Mux 有能力在不同输入间选择,但连接本身是静态的,只是它读取哪个输入会变化。

以下是一个阈值(限幅)效果的例子:

io.out := io.in
when (io.in > 3.U) {
    io.out := 3.U
}

默认情况是直通,当条件满足时,则输出 3。Chisel 编译器会将其转换为一个 Mux。

优先使用 val 而非 var 🚫

这是一个在课程中多次出现的重要风格点,我们需要非常清楚地区分。

不要混淆 Scala 中的可变性(即使用 var)与硬件设计中连线值的变化。valvar 指的是对 Scala 中 Chisel 对象的引用,完全不是描述 Chisel 对象本身的可变性。Chisel 对象代表硬件,其值可以在仿真中每个周期都变化。

使用 var 不仅可能导致错误行为,有时还难以调试。例如,尝试用 var 写计数器:

var counter = 0.U
counter = counter + 1.U // 错误!这只是一个 Scala 赋值,不是硬件连接。
io.out := counter

这会导致输出始终为 1(0+1),因为这里使用了错误的赋值操作符。更好的方式是使用 val 和正确的连接操作符 :=,或者直接使用寄存器 RegInit

另一个常见错误是 when 语句作用域逃逸,这通常也是由使用 var 引起的。请尽可能使用 val

使用函数式集合操作 🧮

在课程的一些地方,我鼓励大家少用可变集合。就像 var 一样,我没有完全禁止,但建议谨慎使用。

现在你学习了函数式编程,可以使用 mapfoldLeft 等操作来生成新的集合,而不是改变原有集合。这减少了迭代和突变带来的复杂性,使代码更清晰,更不易出错。

例如,递增一个序列:

// 使用可变方式和迭代
val buffer = ArrayBuffer.tabulate(6)(i => i.U)
for (i <- 0 until 6) {
    buffer(i) = buffer(i) + 1.U
}
// 使用函数式方式
val seq = Seq.tabulate(6)(i => i.U)
val newSeq = seq.map(_ + 1.U)

函数式方式没有突变和显式迭代,更简洁,更不容易出错。

减少特殊情况的处理 🎯

我鼓励大家减少代码中特殊情况的处理。有时,让代码更通用、参数化,反而会使它更简单。

例如,foldLeft 这样的结构可以优雅地处理零元素的情况,同时也能处理非零元素的情况。如果你有很多 if-else if 分支来处理不同的参数值,这可能是一个“代码异味”,试着看能否重构。

forforeach 的选择 🔁

学生有时会问,在 Chisel 的上下文中,forforeach 有什么区别?

两者在 Scala 中都非常灵活,都可以在很多场景下使用。for 是 Scala 语言的一个关键字,而 foreach 是许多 Scala 对象实现的方法。

通常,当你的目标是产生某种迭代或副作用时,使用 forforeach。如果你希望直接产生一个结果,那么 map 可能更合适。

个人建议:

  • 当集合或范围已经存在时,使用 foreach 进行迭代。
  • 当你需要创建一个范围进行迭代时,使用 for 更自然。
    当然,你可以互换使用,这在一定程度上是个人风格问题。

硬件效率考量 ⚡

虽然本课程主要关注正确性而非性能,但作为计算机工程师,我们仍需考虑逻辑元件的使用。

CAD 工具很智能,会优化掉一些低效设计,但有些操作它们难以优化。例如,在硬件中,除法、取模和乘法操作非常昂贵,应尽量避免。

在“生命游戏”作业中,有人使用取模和除法从单个索引计算行和列。更好的方法是使用两个独立的计数器。

此外,在 Chisel 中进行位操作时,应使用位选择(如 bits(6, 4)head/tail)和连接(Cat),而不是像在 C 语言中那样使用位移和位掩码。这样更清晰,CAD 工具也更容易理解。

大型硬件实例化的位置 🏗️

这是一个风格问题。硬件是静态的,放在 when 块内的硬件实例并不会因为条件不满足而消失,它始终存在。when 控制的是连接何时生效。

有时,将大型硬件实例声明在 when 块之外,可以使模块的结构对阅读者更清晰。将主要的模块实例化放在模块顶部,可以让人一目了然地看到模块的主体结构,然后再填充细节。

两种方式在功能上都是正确的,但后者通常更利于代码阅读和维护。

Scala 范围:tountil ↔️

Scala 提供了丰富的范围操作。until 是排他的,to 是包含的。

例如,0 until 4 得到 0, 1, 2, 3。如果你需要包含上界,应该使用 to,而不是手动 n+1n-1。充分利用语言特性可以使代码更简洁。

assertrequire 的区别 🛡️

我们有时用 require,有时用 assert,实际上有两种断言。

Chisel 断言(assert)用于硬件设计,其参数类型是 Chisel 的 Bool。它只在仿真中存在,如果条件为假,会导致仿真失败。在工业中,大量使用 Chisel 断言来编写测试模块。

Scala 断言(assert)用于 Scala 程序本身,在构造/生成期间运行,不会存在于硬件中。它的参数类型是 Scala 的 Boolean

require 也是一个 Scala 特性,用于检查输入合理性(例如参数范围)。assertrequire 都是运行时检查。区别在于,可以通过编译器标志禁用 assert,但 require 不会。因此,风格上通常用 require 进行输入合法性检查,用 assert 保护内部不变量。

抽象:模块、函数与类 🧩

我们应该如何考虑抽象?什么应该放在模块里,什么应该放在函数或类里?

目标是让代码对人类清晰易懂,并易于复用你构建的组件。其他方面(如效率)是次要的,可以交给 CAD 工具。

对于大多数情况,为每个组件使用一个模块就很好。其他机制(如非模块的类)出现在一些特定场景。

考虑抽象时,请思考:

  1. 复用性:人们会如何使用这个功能?他们是否只想要其中一半?也许应该拆分成更小的模块。
  2. 可测试性:如果一个模块太大,难以编写测试用例或覆盖所有组合,这可能是一个信号,提示你应该将其分解。
  3. 向下隐藏复杂性:尽可能将复杂性封装在模块内部,使用者只需关心接口和参数,无需理解内部实现。

有时,一个返回组件的函数可能比一个类或模块更简单,尤其是在使用函数式构造时。有时,为了在波形图等下游工具中保持清晰的层次结构,可能会将一些小实体合并到模块中。但作为初始版本,为每个逻辑单元使用一个模块是很好的起点。

内存打包技巧 💾

这是一个非常具体的优化点。当你用 Chisel 创建一个内存(Mem),其类型是一个 Bundle 时,Chisel 会为 Bundle 的每个字段创建独立的内存。

例如,一个包含 1 位 a 字段和 7 位 b 字段的 Bundle,创建 256 个这样的元素,会得到 256 个 1 位宽的内存和 256 个 7 位宽的内存。

物理设计团队可能希望将这些字段打包成一个 8 位宽的内存。这时可以使用一个技巧:创建一个 UInt 类型的内存,宽度等于 Bundle 的总宽度。在访问时,再将读取的 UInt 转换回 Bundle 类型。这样可以实现内存打包。

总结 📚

本节课中我们一起学习了 Chisel 工具包中的多个重要概念和最佳实践。我们回顾了 Chisel 的静态本质和最后连接语义,强调了使用 val 和函数式编程的重要性。我们还探讨了代码风格、效率考量、断言的使用、抽象的选择以及一些实用技巧。掌握这些内容将帮助你写出更清晰、更健壮、更高效的 Chisel 硬件设计代码。

posted @ 2026-03-29 09:28  布客飞龙II  阅读(12)  评论(0)    收藏  举报