Заметки дилетанта, или Сказ о том, как Scala-разработчик ПЛИС конфигурировал

Долгое время я мечтал научиться работать с FPGA, присматривался. Потом купил отладочную плату, написал пару hello world-ов и положил плату в ящик, поскольку было непонятно, что с ней делать. Потом пришла идея: а давайте напишем генератор композитного видеосигнала для древнего ЭЛТ-телевизора. Идея, конечно, забавная, но я же Verilog толком не знаю, а так его ещё и вспоминать придётся, да и не настолько этот генератор мне нужен… И вот недавно захотелось посмотреть в сторону RISC-V софт-процессоров. Нужно с чего-то начать, а код Rocket Chip (это одна из реализаций) написан на Chisel — это такой DSL для Scala. Тут я внезапно вспомнил, что два года профессионально разрабатываю на Scala и понял: время пришло…

Так что, если хотите почитать историю из жизни кусачек, цифрового мультиметра и осциллографа, который осознал себя, то добро пожаловать под кат.

Итак, что же будет в этой статье? В ней я опишу свои попытки генерации композитного PAL-видеосигнала (почему PAL? — просто мне попался хороший tutorial именно по генерации PAL) на плате Марсоход 2 за авторством nckma. Про RISC-V в этой статье я вообще ничего не скажу. 🙂

Для начала немного о Scala и Chisel: Scala — это язык, работающий поверх Java Virtual Machine и прозрачно использующий существующие Java-библиотеки (хотя также есть Scala.js и Scala Native). Когда я только начал его изучать, у меня сложилось ощущение, что это такой весьма жизнеспособный гибрид «плюсов» и Хаскеля (впрочем, коллеги не разделяют этого мнения) — уж больно продвинутая система типов и лаконичный язык, но из-за необходимости скрестить функциональщину с ООП обилие языковых конструкций местами навевало воспоминания о C++. Впрочем, не надо бояться Scala — это очень лаконичный и безопасный язык с мощной системой типов, на котором поначалу можно просто писать как на улучшенной Java. А ещё, насколько мне известно, Scala изначально разрабатывалась как язык для удобного создания Domain Specific Languages — это когда описываешь, скажем, цифровую аппаратуру или ноты на формальном языке, и этот язык выглядит вполне логично с точки зрения своей предметной области. А потом ты вдруг узнаёшь, что это был корректный код на Scala (ну, или Haskell) — просто добрые люди написали библиотечку с удобным интерфейсом. Chisel — это как раз такая библиотека для Scala, которая позволяет на удобном DSL описать цифровую логику, а потом запустить полученный Scala-код и сгенерировать код на Verilog (или ещё чём-нибудь), который можно будет скопировать в проект Quartus-а. Ну или сразу запустить стандартные scala-style unit-тесты, которые сами просимулируют тестбенчи и выдадут отчёт о результатах.

Для знакомства с цифровой схемотехникой очень рекомендую вот эту книгу (она уже есть и в печатном русскоязычном варианте). На самом деле, моё планомерное знакомство с миром FPGA почти заканчивается на этой книге, поэтому конструктивная критика в комментариях приветствуется (впрочем, повторюсь, книга чудесная: рассказывает от азов и до создания простенького конвееризованного процессора. А ещё там есть картинки 😉 ). Ну а по Chisel есть неплохой официальный tutorial.

Disclaimer: автор не несёт ответственности за погоревшую аппаратуру, и если надумаете повторять эксперимент — лучше проверьте уровни сигналов осциллографом, переделайте аналоговую часть и т.д. И вообще — соблюдайте технику безопасности. (Я вот, например, в процессе написания статьи осознал, что ноги — это тоже конечности, и нечего их совать в батарею центрального отопления, держась рукой за вывод платы…) Кстати, эта зараза ещё и помехи на телевизор в соседней комнате давала по ходу отладки…

Настройка проекта

Писать код мы будем в IntelliJ Idea Community Edition, в качестве системы сборки будет sbt, поэтому создадим каталог, положим туда .gitignore, project/build.properties, project/plugins.sbt отсюда и

несколько упрощённый build.sbt

def scalacOptionsVersion(scalaVersion: String): Seq[String] = {   Seq() ++ {     // If we're building with Scala > 2.11, enable the compile option     //  switch to support our anonymous Bundle definitions:     //  https://github.com/scala/bug/issues/10047     CrossVersion.partialVersion(scalaVersion) match {       case Some((2, scalaMajor: Long)) if scalaMajor < 12 => Seq()       case _ => Seq("-Xsource:2.11")     }   } }  name := "chisel-example"  version := "1.0.0"  scalaVersion := "2.11.12"  resolvers ++= Seq(   Resolver.sonatypeRepo("snapshots"),   Resolver.sonatypeRepo("releases") )  // Provide a managed dependency on X if -DXVersion="" is supplied on the command line. val defaultVersions = Map(   "chisel3" -> "3.1.+",   "chisel-iotesters" -> "1.2.+"   )  libraryDependencies ++= (Seq("chisel3","chisel-iotesters").map {   dep: String => "edu.berkeley.cs" %% dep % sys.props.getOrElse(dep + "Version", defaultVersions(dep)) })  scalacOptions ++= scalacOptionsVersion(scalaVersion.value)

Теперь откроем это в Идее и попросим импортировать sbt-проект — при этом sbt скачает необходимые зависимости.

Первые модули

ШИМ

Для начала давайте попробуем написать простенький ШИМ. Логика у меня была примерно следующая: чтобы сгенерировать сигнал коэффициента заполнения n/m, изначально положим в регистр 0 и будем прибавлять к нему по n каждый шаг. Когда значение регистра превысит m — вычтем m и выдадим высокий уровень на один такт. Вообще-то, оно будет глючить, если n > m, но будем считать это неопределённым поведением, которое нужно для оптимизации реально используемых случаев.

Не буду пересказывать весь beginner’s guide — он читается за пол-часа, скажу лишь, что для того, чтобы описать модуль, нам нужно импортировать chisel3._ и отнаследоваться от абстрактного класса Module. Абстрактный он потому, что нам нужно описать Bundle под названием 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 у обычного инта, чтобы получить ширину порта, а метод .asUInt(width.W) мы вообще вызываем у целочисленного литерала! Как такое возможно? — ну, в Smalltalk мы бы просто определили новый метод у класса Integer (или как он там называется), но в JVM у нас всё-таки не всё объект — есть ещё и примитивные типы, и Scala это понимает (и, кроме того, есть сторонние классы, которые мы не можем изменять). Поэтому есть разнообразные implicit-ы: в данном случае Scala, вероятно, находит что-то вроде

implicit class BetterInt(n: Int) {   def W: Width = ... }

в текущей области видимости, поэтому у обычного инта появляются сверхспособности. Вот одна из особенностей, делающая 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 (device under test) и проверять значения на выходах. Как мы видим, для тестов используется обычный 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 знак на >=, перезапустим тесты — всё работает! Боюсь, тут бывалые разработчики цифровой аппаратуры захотят меня убить за столь легкомысленное отношение к проектированию (и некоторые разработчики софта к ним с радостью присоединятся)…

Генератор импульсов

Также нам понадобится генератор, который будет выдавать импульсы синхронизации для «полукадров». Почему «полу-«? потому что сначала передаются нечетные строки, потом чётные (ну, или наоборот, но нам сейчас не до жиру).

import chisel3._ import chisel3.util._  class OneShotPulseGenerator(val lengths: Seq[Int], val initial: Boolean) extends Module {    // Add sentinel value here, so no output flip required after the last state   private val delayVecValues = lengths.map(_ - 1) :+ 0    val io = IO(new Bundle {     val signal = Output(Bool())   })    private val nextIndex = RegInit(1.asUInt( log2Ceil(delayVecValues.length + 1).W ))   private val countdown = RegInit(delayVecValues.head.asUInt( log2Ceil(lengths.max + 1).W ))    private val output    = RegInit(initial.asBool)   private val delaysVec = VecInit(delayVecValues.map(_.asUInt))    private val moveNext = countdown === 0.asUInt   private val finished = nextIndex === delayVecValues.length.asUInt    when (!finished) {     when (moveNext) {       countdown := delaysVec(nextIndex)       nextIndex := nextIndex + 1.asUInt       output := !output     }.otherwise {       countdown := countdown - 1.asUInt     }   }    io.signal := output }

При снятии сигнала reset он выстреливает прямоугольными импульсами с длинами промежутков между переключениями, заданными параметром lengths, после чего навечно остаётся в последнем состоянии. Этот пример демонстрирует использование таблиц значений с помощью VecInit, а также способ получения необходимой ширины регистра: chisel3.util.log2Ceil(maxVal + 1).W. Не помню, честно говоря, как оно в Verilog сделано, но в Chisel для создания такого параметризованного вектором значений модуля достаточно вызвать конструктор класса с нужным параметром.

Вы, наверное, спросите: «Если входы clock и reset генерируются неявно, то как мы будем на каждый кадр «перезаряжать» генератор импульсов?» Разработчики Chisel всё предусмотрели:

  val module = Module( new MyModule() )   val moduleWithCustomReset = withReset(customReset) {     Module( new MyModule() )   }   val otherClockDomain = withClock(otherClock) {     Module( new MyModule() )   }

Наивная реализация генератора сигнала

Для того, чтобы телевизор хоть как-то нас понял, нужно поддержать «протокол» среднего уровня хитрости: есть три важных уровня сигнала:

  • 1.0В — белый цвет
  • 0.3В — чёрный цвет
  • 0В — специальный уровень

Почему 0В я назвал специальным? Потому что при плавном переходе от 0.3В к 1.0В мы плавно переходим от чёрного к белому, а между 0В и 0.3В, насколько я сумел понять, нет никаких промежуточных уровней и используется 0В только для синхронизации. (На самом деле, оно изменяется даже не в диапазоне 0В — 1В, а -0.3В — 0.7В, но, будем надеяться, на входе всё равно стоит конденсатор)

Как учит нас эта замечательная статья, композитный 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))   })    // Заглушка -- пока Ч/Б   io.L := (0.asUInt(10.W) + io.red + io.green + io.blue) / 4.asUInt   io.millivolts := 0.asUInt }

В модуле 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,  // 623 / 311     2, 30, 2, 30   // 624 / 312   )    val VSyncEnd = Seq(     30, 2, 30, 2,  // 2 / 314     30, 2, 30, 2,  // 3 / 315     2, 30, 2, 30,  // 4 / 316     2, 30, 2, 30   // 5 / 317   )    val VSync1: Seq[Int] = VSyncStart ++ Seq(     2, 30, 2, 30,  // 625     30, 2, 30, 2   // 1   ) ++ VSyncEnd ++ (6 to 23).flatMap(_ => Seq(4, 60))    val VSync2: Seq[Int] = VSyncStart ++ Seq(     2, 30, 30, 2   // 313   ) ++ VSyncEnd ++ (318 to 335).flatMap(_ => Seq(4, 60))    val BlackMv = 300.asUInt(12.W)   val WhiteMv = 1000.asUInt(12.W)    val FirstHalf = (24, 311)   val SecondHalf = (336, 623)   val TotalScanLineCount = 625 }  class BWGenerator(clocksPerUs: Int) extends Module {   import BWGenerator._    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))   })    private val scanLineNr = RegInit(0.asUInt(10.W))   private val inScanLineCounter = RegInit(0.asUInt(16.W))   when (inScanLineCounter === (TotalScanLineLengthUs * clocksPerUs - 1).toInt.asUInt) {     inScanLineCounter := 0.asUInt     when(scanLineNr === (TotalScanLineCount - 1).asUInt) {       scanLineNr := 0.asUInt     } otherwise {       scanLineNr := scanLineNr + 1.asUInt     }   } otherwise {     inScanLineCounter := inScanLineCounter + 1.asUInt   }    private val fieldIActive = SecondHalf._2.asUInt <= scanLineNr ||                              scanLineNr < FirstHalf._1.asUInt   private val fieldIGenerator = withReset(!fieldIActive) {     Module(new OneShotPulseGenerator(VSync1.map(_ * clocksPerUs), initial = false))   }   private val fieldIIActive = FirstHalf._2.asUInt <= scanLineNr &&                               scanLineNr < SecondHalf._1.asUInt   private val fieldIIGenerator = withReset(!fieldIIActive) {     Module(new OneShotPulseGenerator(VSync2.map(_ * clocksPerUs), initial = false))   }    private val inFirstHalf  = FirstHalf ._1.asUInt <= scanLineNr &&                              scanLineNr < FirstHalf ._2.asUInt   private val inSecondHalf = SecondHalf._1.asUInt <= scanLineNr &&                              scanLineNr < SecondHalf._2.asUInt   io.inScanLine :=     (inFirstHalf || inSecondHalf) &&       ((ScanLineHSyncEndUs * clocksPerUs).toInt.asUInt <= inScanLineCounter)    io.x := Mux(     io.inScanLine,     inScanLineCounter - (ScanLineHSyncEndUs * clocksPerUs).toInt.asUInt,     0.asUInt   ) / 4.asUInt   io.y := Mux(     io.inScanLine,     Mux(       inFirstHalf,       ((scanLineNr - FirstHalf ._1.asUInt) << 1).asUInt,       ((scanLineNr - SecondHalf._1.asUInt) << 1).asUInt + 1.asUInt     ),     0.asUInt   )    when (fieldIActive) {     io.millivolts := Mux(fieldIGenerator .io.signal, BlackMv, 0.asUInt)   }.elsewhen (fieldIIActive) {     io.millivolts := Mux(fieldIIGenerator.io.signal, BlackMv, 0.asUInt)   }.otherwise {     when (inScanLineCounter < (ScanLineHSyncStartUs * clocksPerUs).toInt.asUInt) {       io.millivolts := 0.asUInt     }.elsewhen (inScanLineCounter < (ScanLineHSyncEndUs * clocksPerUs).toInt.asUInt) {       io.millivolts := BlackMv     }.otherwise {       io.millivolts := (BlackMv + (io.L << 1).asUInt).asUInt     }   } }

Генерация синтезируемого кода

Это всё хорошо, но мы хотим зашить полученный дизайн в плату. Для этого надо синтезировать 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-сигнала и добавил свой модуль.

Коммутация в Quatus-е

После этого я подключил аналоговый ТВ-тюнер к другому компьютеру (ноутбуку), чтобы была хоть какая-то гальваническая развязка между питанием генератора и потребителя сигналов и просто подал сигнал с пинов IO7 (+) и GND (-) платы на композитный вход (минус на наружный контакт, плюс — в центр). Ну, то есть как «просто»… Просто было бы, если бы руки откуда надо росли, ну или если бы у меня были соединительные провода female-male. Но у меня есть только связка male-male проводов. Зато у меня есть упоротость и кусачки! В общем, запоров один провод, я таки сделал себе два почти рабочих — с трудом, но цепляющихся к плате. И вот, что увидел:

Первое Ч/Б изображенте

На самом деле, я вас, конечно, немного обманул. Показанный выше код у меня получился после где-то трёх часов отладки «на железе», но, блин, я его написал, и оно работает!!! И, учитывая, что раньше с серьёзной электроникой я был почти не знаком, считаю, что задача оказалась не жуть, какая сложная.

Генерация цветного видеосигнала

Ну, что же, дело осталось за малым — дописать генератор цветного видеосигнала. Я взял туториал и начал пытаться формировать color burst (прибавленная к уровню чёрного цвета синусоида на несущей частоте цветового сигнала, на небольшое время выдаваемая во время HSync) и, собственно, цветовой сигнал по формуле. Но вот не выходит, хоть ты тресни… В какой-то момент до меня дошло, что, несмотря на то, что частота при беглом взгляде в документ в глаза не бросалась, телевизор едва ли подстроится под произвольную. Поискав, я нашёл, что в PAL используется частота несущей 4.43 МГц. «Дело в шляпе» — подумал я. «Хрен тебе» — ответил тюнер. Спустя целый день отладки и всего один раз увидев проблески цвета на картинке (причём, когда сказал тюнеру, что это вообще NTSC)

… я понял, как на самом деле выглядит безысходность

Тут я понял, что без осциллографа мне не обойтись. А, как я уже говорил, с электроникой я знаком плохо, и такого чуда техники у меня, естественно, дома не водится. Покупать? Дороговато для одного эксперимента… А из чего его можно соорудить на коленке? Подключить сигнал на линейный вход звуковой карты? Ага, 4 с половиной мегагерца — едва ли заведётся (по крайней мере без переделки). Хм, а ведь у Марсохода есть АЦП на 20 МГц, но вот передавать в компьютер сырой поток скорости последовательного интерфейса не хватит. Ну, где-то всё равно придётся обрабатывать сигнал для вывода на экран, и фактически битов информации там будет вполне приемлемое количество, но это же ещё с последовательным портом возиться, программы для компьютера писать… Тут-то мне и подумалось, что инженер должен развивать в себе здоровую упоротость: есть неработающий формирователь цветного изображения, есть АЦП… Но чёрно-белое-то изображение выводится стабильно… Ну так пусть генератор сигнала сам себя и отлаживает!

Лирическое отступление (как говорится, «Мнение студента не обязано совпадать с мнением преподавателя, здравым смыслом и аксиоматикой Пеано»): Когда я добавил генерацию цвета со всякими там умножениями и прочими сложными вещами, сильно просела Fmax для формирователя сигнала. Что же такое Fmax? Насколько я это понял из учебника Harris&Harris, САПР для FPGA предпочитает, когда на Verilog пишут не абы как в пределах стандарта, а «по понятиям»: например, в итоге должна получаться синхронная схема — этакая направленная ациклическая паутинка из комбинационной логики (сложение, умножение, деление, логические операции, …), прилепленная своими входами и выходами к выходам и входам триггеров, соответственно. Триггер по фронту тактового сигнала запоминает на весь следующий такт значение своего входа, уровень которого должен быть стабилен сколько-то времени до фронта и сколько-то — после (это две временные константы). Сигналы с выходов триггеров, в свою очередь, после тактового сигнала начинают свой забег к выходам комбинационной логики (а значит, входам других триггеров. Ну, и выходам микросхемы), которая характеризуется также двумя интервалами: время, в течение которого ни один выход ещё не успеет начать изменяться, и время, через которое изменения успокоятся (при условии, что вход изменился единожды). Вот максимальная частота, при которой комбинационная логика обеспечивает выполнение требований триггеров — и есть Fmax. Когда схема между двумя тактами должна больше успеть посчитать, Fmax уменьшается. Конечно, хочется, чтобы частота была побольше, но если она вдруг подскочила в 10 раз (а то и количество частотных доменов в отчёте САПР уменьшилось) — проверьте, возможно, вы где-то что-то напутали, и в результате САПР нашёл константное выражение и радостно его использовал для оптимизации.

Раскрутка осциллографа

Нет, не та, после которой идёт скрутка осциллографа и горстка лишних деталей, а oscilloscope bootstrapping — это как compiler bootstrapping, только для осциллографа.

Мы будем делать осциллограф, по команде записывающий сколько-то отсчётов входного сигнала, после чего лишь отображающий записанное. Поскольку ему нужно будет как-то дать команду на запись, а после — навигироваться по ней, нам потребуются некие контроллеры кнопок — я написал не очень удобный, но совсем примитивный, вот он:

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) {     // Нарисуем 1мс чёрно-белую шкалу     io.output := !(       io.y > (windowPixelHeight + 10).asUInt && io.y < (windowPixelHeight + 20).asUInt &&         (io.x / clocksPerUs.asUInt)(0)       )   } otherwise {     // Нарисуем, собственно, сигнал     // signal / 2^inputWidth ~ y / windowPixelHeight     // signal * windowPixelHeight ~ y * 2^inputWidth     io.output :=       (currentValue * windowPixelHeight.asUInt >= ((io.y - 5.asUInt) << inputWidth).asUInt) &&       (currentValue * windowPixelHeight.asUInt <= ((io.y + 5.asUInt) << inputWidth).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 ЦАП) и оцифрованный обратно с помощью микросхемы АЦП Марсохода:

В аналог, потом в &quot;цифру&quot;, а потом --- на график

В общем долго ли, коротко — и так пытался, и эдак, а цвет всё не появлялся. На Википедии даже есть шуточная расшифровка аббревиатуры PAL — «Picture At Last (Наконец-то, картинка!)»

Код на GitHub.

Выводы

Scala + Chisel образуют современный язык описания цифровой аппаратуры — если для выразительности потребуется, то и с поддержкой функциональщины и всяких Higher-kinded types. А с помощью обычного Scala-плагина Идеи, ничего про Chisel не знающего, на нём ещё и очень приятно программировать. Причём всё это бесплатно и без привязки к САПР производителя конкретных микросхем ПЛИС. В общем — красота!

Читатель возможно спросит: «А где же хэппи-энд?» — А НЕТ ЕГО! Но есть осциллограф…

FavoriteLoadingДобавить в избранное
Posted in Без рубрики

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *