Мы хотим построить на ПЛИС множество Мандельброта - мне кажется, это хорошая тренировка как работы с большими (1920х1080) изображениями, так и выполнения арифметических операций на ПЛИС, включая работу с комплексными числами, хотя "в явном виде" вводить их здесь необязательно.
И действительную, и мнимую часть комплексного числа мы представим 16-битными знаковыми числами с фиксированной точкой с 2 битами перед запятой. Иногда такой формат называют Q2.14 (Q намекает, что мы работаем с рациональными числами, 2 бита перед запятой, 14 бит - после неё). В таком формате можно представить числа в диапазоне от -2 до 2-2-14. Для построения множества Мандельброта этого диапазона вполне достаточно - если число выходит за его границы - назад оно вернуться уже не сможет (последовательность получается расходящейся), и множеству Мандельброта данная точка не принадлежит.
Мы хотим, чтобы по оси Y помещались значения от -1 до +1, а по оси X - от -2 до ≈1,56 (для соблюдения пропорций).
И первой же задачей становится вычисление выражений
y0 = (j-540.0) / 540.0; x0 = -2.0 + i / 540.0;
то есть, пересчитать координаты очередного пикселя (i, j) в комплексное число z0=x0 + iy0, которое этому пикселю соответствует.
Оказывается, что если мы идём от пикселя к пикселю слева направо, сверху вниз, то на каждом шаге нам достаточно двух сложений и проверки знака.
К сожалению, не знаю, есть ли специальные обозначения, чтобы не путаться, когда мы думаем о 16-битном числе как принимающем значения от -2 до 2-2-14, а когда - в качестве целого числа в диапазоне от -32768 до +32767. В формулах выше мы имели в виду первое, т.е j = 0 даст y0 = -1, а j = 1080 приведёт к y0 = +1.
Но если записать x0, y0 как целочисленные, то формулы надо домножить на 214 = 16384:
В большинстве языков программирования, целочисленное деление "округляет вниз", т.е 8 / 9 даст 0. Чтобы заставить его "округлять до ближайшего целого", надо к числителю прибавить половинку знаменателя:
(кстати, деление - очень мерзопакостная операция ещё и в том, что округляет скорее не "вниз", а "к нулю", т.е в большинстве реализаций -8 / 9 также даст 0, что нарушает "изотропность" операций - вокруг нуля имеем "зону нечувствительности" расширенную вдвое. Здесь мы по счастью делим только положительные числа - одной проблемой меньше...)
Для осуществления на 32-разрядном процессоре, у которого есть команда деления - это уже неплохие формулы. Они дадут правильный результат, в пределах тех 16 бит, в которых мы храним результат.
Но когда у нас ПЛИС, то даже такие формулы кажутся слишком сложными: нам нужно делать умножение с сохранением результата в 32-битном аккумуляторе, затем ещё и провести деление - мягко говоря не самая любимая операция для реализации "в железе" (причём как ни странно, целочисленное деление оказывается в чём-то даже сложнее деления с плавающей запятой, поскольку там оба операнда фактически принимают значения от 1 до 2, а тут - какие угодно!).
По счастью, наша задача чуточку проще: мы знаем, что первоначально каждая из переменных i,j равна нулю, а затем мы прибавляем единичку к i во время движения по строке, после чего снова зануляем её и прибавляем единичку к j при переходе на новую строку.
Так что всё, что нам нужно - это правильно обновлять значения x0, y0 на каждом шаге.
Мы видим, что на каждом шаге к переменной прибавляется 30 (за счёт первого слагаемого), и ещё должно добавляться 46 / 135 ≈ 0,34, но поскольку мы считаем в целых числах, то эти 0,34 будут "пропадать", но достаточно накопившись, дадут прибавление ещё единицы.
Собственно, если честно протабулировать значения y0, мы получаем:
Получается, что на каждом шаге нам нужно лишь "принять решение" - прибавлять ли единицу? Само прибавление осуществить легко - в сумматоре есть вход переноса с младших разрядов - а ведь именно это у нас и происходит по сути :)
Чтобы принять решение, мы по сути храним "остаток от деления". Во время инициализации у нас уже осталась дробь 67/135, которую мы округлили до нуля. Знаменатель всегда будет 135 - его мы хранить не должны, он "сам собой подразумевается". А вот значение 67 мы запомним.
Пошёл первый шаг, и теперь дробь стала (67 + 46) / 135 = 113 / 135. Числитель всё ещё меньше знаменателя, поэтому единицу не прибавляем. Запоминаем значение 113.
На следующем шаге мы снова прибавляем к числителю 46, получая дробь (113 + 46) / 135 = 159 / 135. Числитель стал больше знаменателя - значит, мы прибавляем единицу к значению y0, а из нашего "остатка" эту единицу надо убрать, для чего мы вычитаем из числителя знаменатель, т.е получаем (159 - 135) / 135 = 24 / 135.
Вот так и работаем - ничего кроме сложений, вычитаний (а это одно и то же по сути) и сравнений нам не нужно. Есть целое семейство методов, схожих с этим, с помощью которых рисуют прямые, окружности, кривые Безье и прочие "графические примитивы" на растровом изображении.
Осталось только максимально "приспособить" эту задачу под специфику ПЛИС. Сравнение двух чисел "в лоб" - операция довольно неприятная, лучше было бы, если достаточно было посмотреть на знак числа. Для этого нужно "сдвинуть" дробь, чтобы именно "пересечение нуля" означало необходимость добавить единицу.
То есть, работа должна начинаться не с 67 / 135, а с 67 / 135 - 1 = -68 / 135. Прибавляем 46 первый раз - получаем -22 / 135 (пока ещё минус), прибавляем 46 второй раз - получаем 24 / 135 - это положительное число, поэтому прибавляем единицу к y0, вычитаем из числителя 135 и приходим к -111 / 135.
Вероятно, лучше всего будет ещё и "инвертировать" нашу дробь, чтобы старший разряд нашего "остатка" (в дополнительном коде появление единицы в старшем разряде означает - число стало отрицательным) напрямую подключался к входу переносу сумматора, не требуя дополнительной логики. То есть, начинаем мы с 68 / 135, на каждом шагу вычитаем 46, а если результат стал отрицательным - прибавляем единицу к y0, а к числителю прибавляем 135.
Наконец, замечаем, что не нужно спешить с прибавлением 135. Пусть по окончании шага результат остаётся отрицательным - просто в следующий раз, если числитель отрицательный, то вместо вычитания 46 мы будем прибавлять к нему 89. Это, к тому же, уменьшит диапазон чисел, который нам надо представлять в этом "остатке". Теперь самым большим числом может оказаться только 88 (ведь мы прибавляем значения лишь к отрицательным числам), а самым маленьким - число (-46), поскольку в худшем случае мы однажды придём к нулю, и в следующий раз вычтем из него 46. В нашем примере хватит 8-битного числа со знаком.
Пора приступать к кодированию на Verilog!
Первым делом, поиграемся с функциями и выражениями, исполняющимися на этапе компиляции:
Мы решили "довериться" компилятору в плане нахождения всех значений. Мы задаём ему 3 параметра: начальное значение, числитель и знаменатель, т.е говорим, что хотим получать значения выражения
Q = InitValue + Round (Numenator * i / Denominator);
для последовательных значений i.
Дальше это дело компилятора сообразить, где что надо прибавлять или вычитать. Мы в кои-то веки написали функцию нахождения общего делителя (алгоритм Эвклида), и применять её мы будем только на этапе компиляции.
(вообще, функции могут быть доступны и в рантайме, но с этим связано множество проблем - иногда код просто не будет синтезироваться, поскольку не знает, сколько итераций будет внутри, а иногда даже синтезируется, но окажется чрезвычайно громоздким и медленным)
Затем мы сократили числитель и знаменатель, обозвав эти значения SimpleNum и SimpleDenom.
После этого мы выделили целую часть WholePart, и остаток Residue, и нашли исходное значение для нашего вспомогательного регистра, RInitValue (об этом чуточку позже). И напоследок, находим, что мы должны прибавить к остатку, когда он с прошлого раза остался отрицательным, это число мы назвали DenomMinusRes (знаменатель минус остаток).
Чтобы проверить правильность данных операций, мы решили задействовать несинтезируемую конструкцию $display - Квартус её тупо игнорирует, но есть симуляторы verilog, которые воспримут её и "напишут в консоль" то, что мы попросим. В том числе, есть онлайн-ресурсы, например, https://www.tutorialspoint.com/compile_verilog_online.php
(многие ютуберы показывают примеры на EDAboard, но там требуется регистрация, причём они ненавидят бесплатные почтовые сервисы и почтовые ящики, лежащие на серверах "конкурентов". Ну их нафиг...)
Вот что мы получаем:
$iverilog -o main *.v $vvp main CommonDivider= 8 SimpleNum= 4096 SimpleDenom= 135 WholePart= 30 Residue= 46 RInitValue= 22 DenomMinusRes= 89
135, 30, 46, 89 - эти числа мы совсем недавно использовали :)
Интерфейс довольно простой: у нас есть вход тактовой частоты clk, вход для синхронного сброса sclr и вход разрешения счёта ce. Результирующее число мы получаем на выходе Q, причём оно устанавливается по положительному фронту clk во время подачи сигнала разрешения счёта ce, и сохраняет верное значение до последующих действий (сброса или счёта). Таким образом, "отлавливать", когда значение на выходе верное, нам не нужно.
Выход мы сразу определили как регистр. Ещё один регистр нужен для хранения "остатка". Его ширина может варьироваться в зависимости от дроби, которую мы пытаемся посчитать. Максимальное отрицательное значение не может превысить числа (-Residue), максимальное положительное значение - числа DenomMinusRes. Мы можем найти требуемую ширину регистра следующим образом:
в строку. Единица прибавилась к последнему варианту. Чтобы такого не происходило, надо скобки ставить, чем больше - тем лучше! Всё как в препроцессоре C.
В нашем примере, ширина регистра составит 8 бит. Мы будем рассматривать его как число в дополнительном коде, т.е значения от -128 до +127.
Напишем, наконец, логику нашего модуля! На "чистом верилоге" она очень проста:
always @(posedge clk) if (ce | sclr) begin D <= sclr? RInitValue : IsNeg? D + DenomMinusRes : D - Residue;
Q <= sclr? InitValue : Q + WholePart + IsNeg; end
И действительно, этот код заработает так, как нам надо. Но результат синтеза меня не радует: для наших параметров получилось 78 ЛЭ. Точнее, само по себе число 78 - не такое уж страшное (если только это не заголовок Zlib), хуже другое: в логах мы видим, что в этих двух строках компилятор усмотрел аж 4 сумматора!
Как всегда, он не понял, что прибавление единички не требует дополнительного сумматора, ведь у нас есть вход переноса, ну ё-моё! И не понял, что можно применить один сумматор в первой строке, просто подставить ему разные значения.
Так что нам ничего не остаётся, как вставить сумматоры ручками:
always @(posedge clk) if (ce | sclr) begin D <= sclr? RInitValue : DResult;
Q <= sclr? InitValue : QResult; end
Теперь мы получаем те же самые результаты, но используя лишь 50 ЛЭ, а что даже важнее - мы вдвое сократили комбинаторные пути, так что этот модуль спокойненько компилится и на медленном кристалле "-3", при заданной частоте в 80 МГц (даже остался запас по частоте аж в 10 МГц). Исходная схема уже ругалась...
Под спойлером - код модуля целиком (включая заголовок и толпу локальных параметров): [Spoiler (click to open)]
Ну и объясним напоследок значение RInitValue - оно сюда внесено "с опережением на шаг"! Т.е в наших размышлениях мы начинали со значения 68 (половинка от знаменателя, округлённая "вверх"), а здесь сразу же вычли ещё 46, получив значение 22. Это позволяет сумматору для Q брать значение не с выхода сумматора для D (как мы делали в своих устных вычислениях - сначала вычитаем здесь, потом, если получили отрицательное значение - прибавим здесь единицу), а с выхода регистра. А для этого нужно, чтобы к началу такта в регистре уже хранилось правильное значение. По счастью, в этой задаче мы заранее знали, как его посчитать - вот и посчитали заблаговременно!
Для нашего генератора множества Мандельброта нужно два таких модуля, с разными параметрами. Заметим, что они не только вычисляют выражение, но и служат регистрами для хранения текущего Z0.
Смотрите в следующей части - "аппаратная" реализация выражения Z = Z2 + Z0, с проверкой на переполнение.