Долгое время я мечтал научиться работать с 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
отсюда и
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-сигнала и добавил свой модуль.
После этого я подключил аналоговый ТВ-тюнер к другому компьютеру (ноутбуку), чтобы была хоть какая-то гальваническая развязка между питанием генератора и потребителя сигналов и просто подал сигнал с пинов 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 ЦАП) и оцифрованный обратно с помощью микросхемы АЦП Марсохода:
В общем долго ли, коротко — и так пытался, и эдак, а цвет всё не появлялся. На Википедии даже есть шуточная расшифровка аббревиатуры PAL — "Picture At Last (Наконец-то, картинка!)"
Выводы
Scala + Chisel образуют современный язык описания цифровой аппаратуры — если для выразительности потребуется, то и с поддержкой функциональщины и всяких Higher-kinded types. А с помощью обычного Scala-плагина Идеи, ничего про Chisel не знающего, на нём ещё и очень приятно программировать. Причём всё это бесплатно и без привязки к САПР производителя конкретных микросхем ПЛИС. В общем — красота!
Читатель возможно спросит: "А где же хэппи-энд?" — А НЕТ ЕГО! Но есть осциллограф...
Комментарии (10)
xFFFF
02.07.2018 20:53Verilog ну очень прост в освоении, пиши сразу на нем. Вместо осциллографа сигнал можно смотреть в ModelSim, или других пакетах симуляции.
atrosinenko Автор
02.07.2018 21:36Chisel, вроде, тоже проще некуда при условии, что уже знаешь Scala. Вопрос, на чём мне будет комфортнее писать от случая к случаю. Я при этом не утверждаю, что ответ — Chisel — у меня банально опыта нет для таких сравнений. Но учитывая, что на основной работе мой язык — Scala, то вкусовщина, скорее всего, будет именно на его стороне. Вот что интересно, так это узнать у людей, знакомых как с Verilog, так и с "хипстерской функциональщиной", что безопаснее в плане "не выстрелить в ногу".
А ещё, помню, натыкался на вопрос на Quora, о том, стоит ли учить Chisel или Verilog для промышленного применения. Ответ был "Verilog учить однозначно — стандарт, остальное — по желанию". И при этом указывалась полезность большого кругозора даже не в плане "профессионально знаю десять языков, поддержу что угодно", а в плане знакомства с мотивациями "а нафига вам ещё один язык понадобился, что плохого/опасного в существующих решениях". Знакомство с
Maybe[A]
иEither[A, B]
по Хаскелю может и при программировании на C++ пригодиться.
Ну и, гипотетически, если вам нужно что-то очень хитро кастомизировать каждый раз — то в Chisel к вашим услугам вся мощь ФП и экосистемы Java — хоть в БД лезь. Нужно ли это кому-то и насколько это будет удобно — другой вопрос.
Khort
03.07.2018 08:08Улыбнуло, спасибо :-)
На самом деле, очень показательно, что Chisel вообще работает, и на нем можно писать и получать рабочее железо. У меня были большие сомнения по этому поводу еще с тех времен, когда только начали появляться новости про risk-5, и как его создавали. Отличный bridging the gap между программистом и железом. Но на мой взгляд, до конкуренции Chisel и HDL — как до до луны пешком.atrosinenko Автор
04.07.2018 17:34Отличный bridging the gap между программистом и железом. Но на мой взгляд, до конкуренции Chisel и HDL — как до до луны пешком.
О, это было бы как раз интересно: комментарий от человека, хорошо знакомого с классическими HDL о том, почему Chisel не конкурент HDL. Правильно ли я понимаю, что подразумевается примерно то же, что "Java не конкурент C/C++" (то есть, что Chisel — это упрощённое безопасное подмножество, на котором сложнее выстрелить в ногу, но некоторые низкоуровневые вещи выразить существенно сложнее, как и понять, во что оно оттранслируется на самом низком уровне)? Всё-таки Chisel — это просто язык описания, а не попытка автоматически транслировать Scala в Verilog.
Khort
04.07.2018 19:21Сейчас процесс разработки микросхемы выглядит следующим образом: код на HDL синтезируется в базис логических элементов, а полученная схема затем реализуется в виде программируемой логики, бмк или эсик. Синтезатор (аналогия с компилятором) может по разному трактовать код, и от этого сильно зависят хар-ки результата (частота, потребление, площадь и т.д.). Chisel добавляет еще одну стадию в описанный процесс разработки — конверсию в HDL, поэтому результат теперь зависит уже от двух стадий интерпретации исходного кода. Это, очевидно, плохо. А то, что теперь программисту не обязательно становится схемотехником (обязательно при разработке железа на HDL), это здорово. Это я и имел ввиду под bridging the gap. Коммерческие тулы C-2-GDS пытаются написать уже более 20 лет, они бы хорошо продавались. Да только, ничего приличного до сих пор не сделали. А Chisel — вот он, вполне доступен, бери да пользуйся, бесплатно причем.
Насчет «не выстрелить в ногу» — не факт. Может это так, а может и нет: очень мало людей пишет на Chisel, нет статистики. Транслятор в верилог могут годами доводить до ума, а может, он уже сейчас близок к идеалу.
An_private
03.07.2018 08:51> Поискав, я нашёл, что в PAL используется частота несущей 4.43 МГц. «Дело в шляпе» — подумал я. «Хрен тебе» — ответил тюнер.
Тут есть проблема, что частота несущей должна быть очень точной, да еще и привязанной к частоте строк. Иначе PLL в декодере телевизора её просто не захватит.
The QAM subcarrier frequency is 4 433 618.75 ±5Hz
www.radios-tv.co.uk/Pembers/World-TV-Standards/Colour-Standards.html
На самом деле задача формирования полноценного PAL сигнала очень непроста и вряд ли решится вот так «на коленке». Если хочется быстро и надёжно, то проще купить модуль PAL encoder и отдавать на него стандартный параллельный CCIR 601/656, который без проблем формируется FPGA.
По таймингам очень хорошо всё расписано здесь:
martin.hinner.info/vga/pal.htmlatrosinenko Автор
04.07.2018 17:23Спасибо! Первая ссылка вообще шикарна! (А вторую я как раз тоже использовал при подготовке статьи.) Но всё равно с ходу не помогло. :) Кстати, автор одной из статей говорил, что на микроконтроллере он PAL так и не завёл, но завёл NTSC.
AlexanderS
Вот что получится, если дать программисту писать для FPGA — внятного HDL кода не жди )))
Хотя, на самом деле, получилось довольно нетривиально и это интересно.
Я так понял всё это делалось на Altera, которая нынче Intel'ом зовётся. У Xilinx есть интересный инструмент под названием Vivado HLS, в котором код можно писать прямо на Си, а на этапе синтеза САПР сам его преобразует в нужный RTL. Еще года три назад представители Xilinx сильно описывали преимущество такого подхода с т.з. экономии времени и ресурсов. Но там всё равно есть свои моменты, которые надо знать и учитывать, однако сделать какой-нибудь фильтр или сложную математику, где не требуются сотни мегагерц, на HLS действителньо будет попроще, чем его HDL описанием делать, а потом упорно моделировать.
atrosinenko Автор
Насколько я знаю, для некоторых устройств Altera/Intel тоже доступны инструменты, конвертирующие OpenCL (в каком-то смысле, почти C) в дизайн для FPGA.
Спасибо :) Раз получилось "нетривиально" при не очень большом количестве кода и почти полном отсутствии опыта, значит, возможно, Chisel мог бы дать возможность не-суровым-профессионалам написать что-то осмысленное и относительно близкое к железу (то есть всё же не на С), как в своё время экосистема Arduino многих приобщила к электронике. Впрочем, Chisel выглядит серьёзнее, чем Arduino IDE. :)
ИМХО, есть несколько путей "оюзерфрендливания" языка:
Есть подозрение, что Chisel — это вариант 3.
AlexanderS
У нас как-то один подрядчик сдавал «цифру» — так у него исходники вообще в матлабе были))