很长时间以来,我一直梦想着学习如何使用FPGA,因此我一直在仔细研究。 然后,他买了一个调试板,写了几个问候,并将该板放入盒子中,因为尚不清楚该如何处理。 然后这个想法就来了:让我们为古老的CRT电视编写一个复合视频生成器。 这个想法当然很有趣,但是我并不真正了解Verilog,我仍然必须记住它,并且我真的不需要这个生成器...最近,我想研究RISC-V软件处理器。 您需要从某个地方开始,然后用Chisel编写Rocket Chip代码(这是实现之一)-这是用于Scala的DSL。 然后我突然想起了我两年来一直在Scala上进行专业开发并意识到:时间到了...
因此,如果您想阅读剪线钳,数字万用表和示波器等已实现的故事,那么欢迎您。
那么这篇文章将会是什么呢? 在其中,我将描述由nckma在火星探测器2板上生成复合PAL视频信号的尝试(为什么要使用PAL?-我刚刚遇到了一个专门介绍PAL生成的很好的教程)。 关于本文中的RISC-V,我什么也不会说。 :)
首先,介绍一下Scala和Chisel:Scala是一种在Java虚拟机之上运行的语言,它透明地使用现有的Java库(尽管也有Scala.js和Scala Native)。 当我刚开始研究它时,我感到它是“ pluss”和Haskell的非常可行的混合体(但是,同事们对此看法不一) –这是一个痛苦的高级类型系统和简洁的语言,但是由于需要交叉使用功能主义OOP在某些地方丰富的语言构造让人联想起C ++。 但是,不要害怕Scala-它是一种非常简洁和安全的语言,具有强大的类型系统,起初您可以简单地将其编写为改进的Java。 而且据我所知,Scala最初是为方便创建领域特定语言而开发的一种语言-这是当您以一种正式语言描述例如数字设备或音符时,从其主题领域的角度来看,这种语言看起来很合逻辑。 然后,您突然发现这是Scala(或者Haskell)中正确的代码-恰好人们写了一个具有方便接口的库。 Chisel就是这样的Scala库,它允许您在便捷的DSL上描述数字逻辑,然后运行生成的Scala代码并在Verilog(或其他方式)上生成代码,并将其复制到Quartus项目中。 好吧,或者立即运行标准的Scala风格的单元测试,它们自己将模拟测试平台并发布结果报告。
为了熟悉数字电路,我强烈推荐这本书 (它已经印在俄语版本中)。 实际上,我对FPGA领域的系统了解几乎以这本书结尾,因此欢迎在评论中进行建设性的批评(但是,我再说一遍,这本书很棒:它从基础到创建一个简单的传送式处理器都在讲述,那里有图片;)。) 好吧,据Chisel所说,有一个很好的官方教程 。
免责声明:作者对烧毁的设备不承担任何责任,如果您决定重复实验,最好用示波器检查信号电平,重新制作模拟部分,等等。 通常,请遵守安全预防措施。 (例如,在撰写本文的过程中,我意识到腿也是四肢,没有任何东西可以将它们粘在中央加热电池中,从而保持住板子的输出...)顺便说一句,这种感染还对隔壁房间的电视造成了干扰。在调试过程中...
项目设置
我们将在IntelliJ Idea Community Edition中编写代码,而sbt将是构建系统,因此创建一个目录, 从此处开始将.gitignore
, project/build.properties
, project/plugins.sbt
和
稍微简化了build.sbt def scalacOptionsVersion(scalaVersion: String): Seq[String] = { Seq() ++ {
现在,在Idea中打开它,并要求导入sbt项目-sbt将下载必要的依赖项。
第一个模块
脉宽调制
首先,让我们尝试编写一个简单的PWM 。 我的逻辑大致如下:要生成占空比信号n / m,我们首先将0放入寄存器,然后在每一步中将n加到寄存器中。 当寄存器的值超过m时,减去m并在一个时钟周期内发出高电平。 实际上,如果n> m,这将是越野车,但我们将认为这是不确定的行为,这对于优化使用的实际案例是必需的。
我不会讲完整的初学者指南-它需要半个小时的阅读时间,我只会说,为了描述模块,我们需要导入chisel3._
并从抽象类Module
继承。 这是抽象的,因为我们需要用名称io
来描述io
它具有模块的整个接口。 同时, clock
和reset
输入将隐式显示-您无需分别描述它们。 这是发生了什么:
import chisel3._ class PWM(width: Int) extends Module { val io = IO(new Bundle { val numerator = Input(UInt(width.W)) val denominator = Input(UInt(width.W)) val pulse = Output(Bool()) }) private val counter = RegInit(0.asUInt(width.W)) private val nextValue = counter + io.numerator io.pulse := nextValue > io.denominator counter := Mux(io.pulse, nextValue - io.denominator, nextValue) }
注意,我们.W
常规int .W
调用.W
方法以获取端口宽度,而通常在整数常量上调用.asUInt(width.W)
方法! 这怎么可能? -好吧,在Smalltalk中,我们将为Integer类(或在那里所谓的任何东西)定义一个新方法,但是在JVM中,我们仍然没有整个对象-还有原始类型,Scala理解这一点(此外,有一些我们无法修改的第三方类)。 因此,存在各种隐式s:在这种情况下,Scala可能会发现类似
implicit class BetterInt(n: Int) { def W: Width = ... }
在目前的范围内,普通的int具有超能力。 这是使Scala更加简洁和易于创建DSL的功能之一。
为此添加一些测试。 import chisel3.iotesters._ import org.scalatest.{FlatSpec, Matchers} object PWMSpec { class PWMTesterConstant(pwm: PWM, denum: Int, const: Boolean) extends PeekPokeTester(pwm) { poke(pwm.io.numerator, if (const) denum else 0) poke(pwm.io.denominator, denum) for (i <- 1 to 2 * denum) { step(1) expect(pwm.io.pulse, const) } } class PWMTesterExact(pwm: PWM, num: Int, ratio: Int) extends PeekPokeTester(pwm) { poke(pwm.io.numerator, num) poke(pwm.io.denominator, num * ratio) val delay = (1 to ratio + 2).takeWhile { _ => step(1) peek(pwm.io.pulse) == BigInt(0) } println(s"delay = $delay") for (i <- 1 to 10) { expect(pwm.io.pulse, true) for (j <- 1 to ratio - 1) { step(1) expect(pwm.io.pulse, false) } step(1) } } class PWMTesterApproximate(pwm: PWM, num: Int, denom: Int) extends PeekPokeTester(pwm){ poke(pwm.io.numerator, num) poke(pwm.io.denominator, denom) val count = (1 to 100 * denom).map { _ => step(1) peek(pwm.io.pulse).toInt }.sum val diff = count - 100 * num println(s"Difference = $diff") expect(Math.abs(diff) < 3, "Difference should be almost 0") } } class PWMSpec extends FlatSpec with Matchers { import PWMSpec._ behavior of "PWMSpec" def testWith(testerConstructor: PWM => PeekPokeTester[PWM]): Unit = { chisel3.iotesters.Driver(() => new PWM(4))(testerConstructor) shouldBe true } it should "return True constant for 1/1" in { testWith(new PWMTesterConstant(_, 1, true)) } it should "return True constant for 10/10" in { testWith(new PWMTesterConstant(_, 10, true)) } it should "return False constant for 1/1" in { testWith(new PWMTesterConstant(_, 1, false)) } it should "return False constant for 10/10" in { testWith(new PWMTesterConstant(_, 10, false)) } it should "return True exactly once in 3 steps for 1/3" in { testWith(new PWMTesterExact(_, 1, 3)) } it should "return good approximation for 3/10" in { testWith(new PWMTesterApproximate(_, 3, 10)) } }
PeekPokeTester
是Chisel中的三个标准测试器之一。 它允许您在DUT(被测设备)的输入端设置值,并检查输出端的值。 如我们所见,通常的ScalaTest用于测试,测试占用的空间是实现本身的5倍,从原则上讲,这对于软件来说是正常的。 但是,我怀疑经验丰富的“铸硅”设备开发人员只会对如此微观的测试微笑。 发射和哎呀...
Circuit state created [info] [0,000] SEED 1529827417539 [info] [0,000] EXPECT AT 1 io_pulse got 0 expected 1 FAIL ... [info] PWMSpec: [info] PWMSpec [info] - should return True constant for 1/1 [info] - should return True constant for 10/10 *** FAILED *** [info] false was not equal to true (PWMSpec.scala:56) [info] - should return False constant for 1/1 [info] - should return False constant for 10/10 [info] - should return True exactly once in 3 steps for 1/3 [info] - should return good approximation for 3/10
是的,将其固定在PWM的io.pulse := nextValue > io.denominator
行中io.pulse := nextValue > io.denominator
登录>=
,重新开始测试-一切正常! 恐怕经验丰富的数字设备开发人员会以这种轻率的设计态度杀死我(有些软件开发人员会很乐意加入其中)...
脉冲发生器
我们还将需要一个生成器,该生成器将为“半帧”发出同步脉冲。 为什么半? 因为首先传输奇数行,然后传输偶数行(嗯,反之亦然,但是现在我们对脂肪不感兴趣)。
import chisel3._ import chisel3.util._ class OneShotPulseGenerator(val lengths: Seq[Int], val initial: Boolean) extends Module {
移除reset
信号后,它会以矩形脉冲发射,其长度由lengths
参数指定, lengths
切换之间的间隔时间lengths
,此后它将永远保持在最后状态。 本示例演示了使用VecInit
值表的使用以及获得所需寄存器宽度的方法: chisel3.util.log2Ceil(maxVal + 1).W
。 老实说,我不记得它是如何在Verilog中完成的,但是在Chisel中,要创建一个由值向量参数化的模块,只需用必需的参数调用类的构造函数即可。
您可能会问:“如果clock
和reset
输入是隐式生成的,那么我们将如何为“每一帧的脉冲发生器”充电? 凿子开发人员提供了一切:
val module = Module( new MyModule() ) val moduleWithCustomReset = withReset(customReset) { Module( new MyModule() ) } val otherClockDomain = withClock(otherClock) { Module( new MyModule() ) }
天真的实现信号发生器
为了使电视至少能以某种方式理解我们,您需要支持平均技巧级别的“协议”:有三个重要的信号级别:
为什么我称0V为特殊电压? 因为从0.3V到1.0V平滑过渡,所以我们可以从黑色平滑过渡到白色,并且在0V和0.3V之间平滑切换,据我所知,没有中间电平,并且0V仅用于同步。 (实际上,它甚至不会在0V-1V范围内变化,而是在-0.3V-0.7V范围内变化,但希望在输入端仍有一个电容器)
正如这篇精彩的文章所教导的那样,复合PAL信号由625条重复行组成的无尽流组成:其中大多数是行,实际上是图片(分别为偶数和奇数),有些用于同步目的(我们为它们做了发生器信号),有些在屏幕上不可见。 它们看起来像这样(我不会盗版,并提供原始链接):
让我们尝试描述模块的接口:
BWGenerator
将管理时间等,它需要知道其工作频率:
class BWGenerator(clocksPerUs: Int) extends Module { val io = IO(new Bundle { val L = Input(UInt(8.W)) val x = Output(UInt(10.W)) val y = Output(UInt(10.W)) val inScanLine = Output(Bool()) val millivolts = Output(UInt(12.W)) })
PalColorCalculator
将计算亮度信号以及其他颜色信号的电平:
class PalColorCalculator extends Module { val io = IO(new Bundle { val red = Input(UInt(8.W)) val green = Input(UInt(8.W)) val blue = Input(UInt(8.W)) val scanLine = Input(Bool()) val L = Output(UInt(8.W)) val millivolts = Output(UInt(12.W)) })
在PalGenerator
模块中, PalGenerator
只需PalGenerator
两个指定的模块:
class PalGenerator(clocksPerUs: Int) extends Module { val io = IO(new Bundle { val red = Input(UInt(8.W)) val green = Input(UInt(8.W)) val blue = Input(UInt(8.W)) val x = Output(UInt(10.W)) val y = Output(UInt(10.W)) val millivolts = Output(UInt(12.W)) }) val bw = Module(new BWGenerator(clocksPerUs)) val color = Module(new PalColorCalculator) io.red <> color.io.red io.green <> color.io.green io.blue <> color.io.blue bw.io.L <> color.io.L bw.io.inScanLine <> color.io.scanLine bw.io.x <> io.x bw.io.y <> io.y io.millivolts := bw.io.millivolts + color.io.millivolts }
现在我们可悲地画了第一只猫头鹰... package io.github.atrosinenko.fpga.tv import chisel3._ import chisel3.core.withReset import io.github.atrosinenko.fpga.common.OneShotPulseGenerator object BWGenerator { val ScanLineHSyncStartUs = 4.0 val ScanLineHSyncEndUs = 12.0 val TotalScanLineLengthUs = 64.0 val VSyncStart = Seq( 2, 30, 2, 30,
综合代码生成
一切都很好,但是我们希望将最终的设计缝到板上。 为此,您需要合成Verilog。 这是通过非常简单的方式完成的:
import chisel3._ import io.github.atrosinenko.fpga.common.PWM object Codegen { class TestModule(mhz: Int) extends Module { val io = IO(new Bundle { val millivolts = Output(UInt(12.W)) }) val imageGenerator = Module(new TestColorImageGenerator(540, 400)) val encoder = Module(new PalGenerator(clocksPerUs = mhz)) imageGenerator.io.x <> encoder.io.x imageGenerator.io.y <> encoder.io.y imageGenerator.io.red <> encoder.io.red imageGenerator.io.green <> encoder.io.green imageGenerator.io.blue <> encoder.io.blue io.millivolts := encoder.io.millivolts override def desiredName: String = "CompositeSignalGenerator" } def main(args: Array[String]): Unit = { Driver.execute(args, () => new PWM(12)) Driver.execute(args, () => new TestModule(mhz = 32)) } }
实际上,在两行方法main()
我们执行了两次,其余的代码是另一个粘贴的模块
绝对无聊的测试图片生成器 class TestColorImageGenerator(width: Int, height: Int) extends Module { val io = IO(new Bundle { val red = Output(UInt(8.W)) val green = Output(UInt(8.W)) val blue = Output(UInt(8.W)) val x = Input(UInt(10.W)) val y = Input(UInt(10.W)) }) io.red := Mux((io.x / 32.asUInt + io.y / 32.asUInt)(0), 200.asUInt, 0.asUInt) io.green := Mux((io.x / 32.asUInt + io.y / 32.asUInt)(0), 200.asUInt, 0.asUInt) io.blue := Mux((io.x / 32.asUInt + io.y / 32.asUInt)(0), 0.asUInt, 0.asUInt) }
现在,您需要将其推送到Quartus项目中。 对于火星探测器2,我们需要Quartus 13.1的免费版本。 如何安装, 写在火星探测器的网站上。 从那里,我下载了火星漫游者2板的“第一个项目”,将其放入存储库中,并进行了一些更正。 由于我不是电子工程师(而且我实际上对FPGA作为加速器比对接口卡更感兴趣),因此
就像那个笑话...程序员在调试方面坐得很深。
适合儿子:
“爸爸,为什么太阳每天在东方升起而在西方落下?”
“你检查了吗?”
-已检查。
-检查得好吗?
-好
-有效吗?
-有效。
-每天都可以吗?
-是的,每天。
-那么,上帝啊,儿子,不要触摸任何东西,不要改变任何东西。
...我只是删除了VGA信号发生器并添加了我的模块。

之后,我将模拟电视调谐器连接到另一台计算机(笔记本电脑),以便信号发生器和使用者的电源之间至少存在电气隔离,并且只是将信号从板的IO7(+)和GND(-)引脚发送到复合输入(减号)。外部联系人,再加上中心)。 好吧,就是说,“简单”……这仅仅是如果手从应该应该伸出的地方伸出,或者如果我有阴阳连接线。 但是我只有一堆公母电线。 但是我有坚韧和钳子! 通常,便秘只有一丝,我确实使自己成为了几乎两个工人,虽然很困难,但是却执着于董事会。 这是我所看到的:

实际上,我当然欺骗了您。 上面显示的代码是在“在硬件上”调试大约三个小时后得到的,但是,该死,我写了它,并且可以正常工作! 而且,考虑到我过去几乎不熟悉严肃的电子技术,我认为这项任务并不可怕,这是一项艰巨的任务。
彩色视频生成
好吧,那么事情就变得很小了-添加彩色视频信号发生器。 我学习了本教程,并开始尝试形成一个色同步信号(在色彩信号的载波频率上添加到正弦波的黑色电平中,该信号在HSync期间会短暂产生),实际上是根据公式来形成。 但是,即使您破解了它也不会出来...在某个时候,我突然意识到,尽管频率并不能使我快速浏览文档,但电视却很难调到任意一台。 搜索后,我发现PAL使用4.43 MHz的载波频率。 我想这是帽子。 “操你,”调谐器回答。 经过一整天的调试,并且仅一次看到图片中的彩色阴影(此外,当我告诉调谐器它通常是NTSC时)
然后我意识到没有示波器是我无法做到的。 而且,正如我已经说过的,我对电子产品并不熟悉,当然,我在家中没有这样的技术奇迹。 买? 对于一个实验来说有点贵...从膝盖上可以构筑什么呢? 将信号连接到声卡的线路输入? 是的,只有4个半兆赫-不太可能开始(至少没有改动)。 嗯,火星漫游者有一个20 MHz的ADC,但是将串行接口速度的原始流传输到计算机还不够。 嗯,在某个地方,您仍然必须处理信号以进行显示,实际上会有相当多的信息位,但是这也弄乱了串行端口,为计算机编写程序……然后,我认为工程师应该开发本身就具有健康的韧性:有一台空闲的彩色成像仪,一台ADC……但是黑白图像却稳定地输出了……好吧,让信号发生器调试一下!
抒情离题(正如他们所说,“学生的意见不必与老师的意见,常识和Peano的公理学相吻合”):当我将色彩生成与各种乘法和其他复杂事物相加时,Fmax对于信号调理器产生了强烈的下垂。 什么是Fmax? 据我从Harris&Harris教科书中了解到的那样,FPGA的CAD倾向于在任何情况下都不按标准编写Verilog,而是“按概念”编写:例如,结果应为同步电路-一种来自组合逻辑 (加法,乘法)的定向无环网络,除法,逻辑运算...),其输入和输出分别卡在触发器的输出和输入上。 时钟信号沿上的触发器会记住整个下一个时钟周期的输入值,该信号的电平必须在前端之前的某个时间和之后的某个时间(这两个时间常数)保持稳定。 继时钟信号开始运行之后,来自触发器输出的信号又流向组合逻辑的输出(以及其他触发器的输入。因此,还有微电路的输出),它的特征还在于两个间隔:没有输出的时间将有时间开始更改,之后时间将平静下来(假设输入已更改一次)。 这是组合逻辑确保满足触发器要求的最大频率-这是Fmax。 当两个时钟之间的电路有时间计数时,Fmax会减小。 当然,我希望频率更大,但是如果它突然跳了10次(甚至CAD报告中的频域数量减少了),请检查一下,也许您弄乱了某个地方,结果CAD找到了一个常数表达式并高兴地将其用于优化。
示波器促销
不,不是在示波器出现扭曲和少量其他零件之后,但是示波器自举就像编译器自举一样,仅适用于示波器。
我们将根据命令制作一个示波器,记录一些输入信号样本,之后仅显示记录的内容。 由于他将需要以某种方式发出命令进行记录,然后-在其中导航,我们将需要一些按钮控制器-我写的不是很方便,但相当原始,这里是:
class SimpleButtonController( clickThreshold: Int, pressThreshold: Int, period: Int, pressedIsHigh: Boolean ) extends Module { val io = IO(new Bundle { val buttonInput = Input(Bool()) val click = Output(Bool()) val longPress = Output(Bool()) })
震撼! 感觉! 要使其工作,您只需要... private val cycleCounter = RegInit(0.asUInt(32.W)) private val pressedCounter = RegInit(0.asUInt(32.W)) io.click := false.B io.longPress := false.B when (cycleCounter === 0.asUInt) { when (pressedCounter >= pressThreshold.asUInt) { io.longPress := true.B }.elsewhen (pressedCounter >= clickThreshold.asUInt) { io.click := true.B } cycleCounter := period.asUInt pressedCounter := 0.asUInt } otherwise { cycleCounter := cycleCounter - 1.asUInt when (io.buttonInput === pressedIsHigh.B) { pressedCounter := pressedCounter + 1.asUInt } } }
:
class Oscilloscope( clocksPerUs: Int, inputWidth: Int, windowPixelWidth: Int, windowPixelHeight: Int ) extends Module { val io = IO(new Bundle { val signal = Input(UInt(inputWidth.W)) val visualOffset = Input(UInt(16.W)) val start = Input(Bool()) val x = Input(UInt(10.W)) val y = Input(UInt(10.W)) val output = Output(Bool()) }) private val mem = SyncReadMem(1 << 15, UInt(inputWidth.W)) private val physicalPixel = RegInit(0.asUInt(32.W)) when (io.start) { physicalPixel := 0.asUInt } when (physicalPixel < mem.length.asUInt) { mem.write(physicalPixel, io.signal) physicalPixel := physicalPixel + 1.asUInt } private val shiftedX = io.x + io.visualOffset private val currentValue = RegInit(0.asUInt(inputWidth.W)) currentValue := ((1 << inputWidth) - 1).asUInt - mem.read( Mux(shiftedX < mem.length.asUInt, shiftedX, (mem.length - 1).asUInt) ) when (io.x > windowPixelWidth.asUInt || io.y > windowPixelHeight.asUInt) {
— , :
class OscilloscopeController( visibleWidth: Int, createButtonController: () => SimpleButtonController ) extends Module { val io = IO(new Bundle { val button1 = Input(Bool()) val button2 = Input(Bool()) val visibleOffset = Output(UInt(16.W)) val start = Output(Bool()) val leds = Output(UInt(4.W)) }) val controller1 = Module(createButtonController()) val controller2 = Module(createButtonController()) controller1.io.buttonInput <> io.button1 controller2.io.buttonInput <> io.button2 private val offset = RegInit(0.asUInt(16.W)) private val leds = RegInit(0.asUInt(4.W)) io.start := false.B when (controller1.io.longPress && controller2.io.longPress) { offset := 0.asUInt io.start := true.B leds := leds + 1.asUInt }.elsewhen (controller1.io.click) { offset := offset + (visibleWidth / 10).asUInt }.elsewhen (controller2.io.click) { offset := offset - (visibleWidth / 10).asUInt }.elsewhen (controller1.io.longPress) { offset := offset + visibleWidth.asUInt }.elsewhen (controller2.io.longPress) { offset := offset - visibleWidth.asUInt } io.visibleOffset := offset io.leds := leds }
(, ), - : — , — , ( — ). — ! , Verilog ?..
- , FPGA:

— ( IO7, VGA_GREEN R-2R ) :

, — , , . PAL — "Picture At Last (-, !)"
GitHub .
结论
Scala + Chisel — , Higher-kinded types. Scala- , Chisel , . . — !
: " -?" — ! ...