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


Долгое время я мечтал научиться работать с 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 не знающего, на нём ещё и очень приятно программировать. Причём всё это бесплатно и без привязки к САПР производителя конкретных микросхем ПЛИС. В общем — красота!


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




К сожалению, не доступен сервер mySQL