§ Задачи на день
Сегодня я разберу то:- Как написать на верилоге простой текстовый видеоадаптер
- Симулировать его на verilator
- И запустить на реальном чипе
§ Видеоадаптер
Сам по себе принцип работы видеоадаптера я уже разбирал однажды в одном из своих дневников. В этой статье я только вкратце повторю основные принципы работы видеовывода на VGA, не заостряя на деталях внимания.Я долго раздумывал, как же мне сделать видеоадаптер, чтобы он был простой и достаточный, чтобы работать с ним и придумал сделать его похожим на NES (или Денди) PPU. Видеопамять будет располагаться по следующим адресам:
$F000 Тайловая страница 1 (1kb) $F400 Тайловая страница 2 (1kb) $F800 Знакогенератор 8x8 (2kb)По итогу, вся видеопамять будет занимать 4кб. Первые 2кб, как и в Денди, будут отведены под тайловую карту, две страницы. Одна страница умещается на экран размером 256x192, или, 32x24 тайла. Картинка будет черно-белой. Знакогенератор располагается по адресам $F800-$FFFF, количество знаков 256. Поначалу генератор знаков будет заполнен шрифтом 8x8, но его можно будет менять в процессе, то есть, это не является ПЗУ, но при загрузке FPGA, информация о знакогенераторе заполняется из прошивки.
Ну что, достаем ручки и тетради и начинаем записывать модуль видеоадаптера:
1module ga 2( 3 input clock, // 25 Мгц 4 output reg [ 3:0] r, 5 output reg [ 3:0] g, 6 output reg [ 3:0] b, 7 output hs, 8 output vs, 9 input [ 7:0] xs, 10 input [ 7:0] ys, 11 output reg [11:0] address, 12 input [ 7:0] data 13); 14endmoduleЭто самый простой код, в котором обозначили порты ввода-вывода. В данный момент их немного и некоторые из них можно даже было написать иначе, но как есть, так и есть. Порт
clock
должен прицепляться внешним тактовым сигналом, равным 25 мгц, и это обязательно.Значения xs, ys - это отступ скроллинга слева и сверху. Их рассмотрю чуть позже по тексту.
Порты r,g,b, а также hs,vs — это выводы видеоадаптера, формирующие видеосигнал. В порту address содержится адрес от $000-$FFF, который маппится потом на $F000 + address путем добавления 4'b1111 в старшие 4 бита. И, накоенц, порт data - это данные от памяти видеоадаптера.
Вкратце рассмотрю следующий блок кода:
1// --------------------------------------------------------------------- 2// Тайминги для горизонтальной|вертикальной развертки (640x400) 3// --------------------------------------------------------------------- 4parameter 5 hz_visible = 640, vt_visible = 400, 6 hz_front = 16, vt_front = 12, 7 hz_sync = 96, vt_sync = 2, 8 hz_back = 48, vt_back = 35, 9 hz_whole = 800, vt_whole = 449; 10// --------------------------------------------------------------------- 11assign hs = x < (hz_back + hz_visible + hz_front); // NEG. 12assign vs = y >= (vt_back + vt_visible + vt_front); // POS. 13// --------------------------------------------------------------------- 14wire xmax = (x == hz_whole - 1); 15wire ymax = (y == vt_whole - 1); 16wire shown = x >= hz_back && x < hz_visible + hz_back && 17 y >= vt_back && y < vt_visible + vt_back; 18wire paper = (x >= hz_back + 64) && (x < hz_back + 64 + 512) && 19 (y >= vt_back + 8) && (y < vt_back + 8 + 384);Здесь нет ничего нового и необычного. Параметры задают значения развертки, количество тактов на задний, передний порожек, на видимую область и синхросигнал. Все эти параметры участвуют в вычислении текущих значений сигналов горизонтальной (hs) и вертикальной синхронизации (vs).
На проводе
shown
, в случае единицы, будет означать, что луч в данный момент находится в видимой области рисования, а если paper = 1
, то значит, что луч в данный момент проходит по области, где будет рисоваться тайловая карта. Вне рисования тайлов, вокруг будет бордер очень близкого к черному цвету. Я сделал так намеренно, чтобы монитор "не терялся" и смог точно установить, где начинается видимая область рисования при автоматической подстройке изображения.В проводе xmax и ymax будет единица тогда, когда регистр x или y достигли максимального значения, то есть луч находится либо в самой правой крайней точке, либо в самой нижней.
Регистры x,y объявляются так:
1reg [ 9:0] x = 1'b0; 2reg [ 8:0] y = 1'b0; 3reg [ 7:0] mask;Здесь mask потребуется для временного хранения рисуемого знакоместа.
Для этого видеоадаптера будет реализован "скроллинг" экрана, а значит, я должен сразу же заложить функционал изменения страницы. Это, на самом деле, делается очень просто:
1wire [ 9:0] X = (x - hz_back - 48) + (xs << 1); 2wire [ 9:0] Y = (y - vt_back - 8) + (ys << 1); 3wire K = X[9] ^ Y[9];Здесь X принимает значение от 0 до 1023 (или 0-511, так как пиксели удвоены), Y тоже от 0-511. На проводе K будет находиться признак переполнения, то есть, если либо X превысил 256, либо Y. Отмечаю то, что тут работается с удвоенными точками на экране, поэтому есть сдвиг
<< 1
каждого из компонент.Значение K содержит активную рисуемую в данный момент страницу. Всего 2 страницы, каждая по 1кб.
Замечу, что
x-hz_back-48
, вычитается именно 48, а не 64, как должно было быть, но тут есть объяснение: дело в том, что видеоадаптер работает как конвеер, сначала извлекается следующее знакоместо и уже через 16 тактов начинает отрисовываться, потому заранее выбирается знакоместо, идущее +16 реальных пикселей (8 рисуемых пикселей) вперед.Перейду к главному рабочему коду:
1always @(posedge clock) begin 2 3 {r, g, b} <= 12'h000; 4 5 // Кадровая развертка 6 x <= xmax ? 0 : x + 1; 7 y <= xmax ? (ymax ? 0 : y + 1) : y; 8 9 if (shown) {r, g, b} <= paper ? 12'hCCC : 12'h111; 10 11endПри достижении X, равному xmax, а именно, последнему номеру пикселя в строке, X станет равным 0, а Y либо увеличится на +1, либо, если Y достиг ymax, станет 0.
Если луч находится в области рисования (shown), то либо рисуется серый цвет в области PAPER, либо же темно-серый цвет в другом случае (бордер). Так будет выглядеть экран:
Для того, чтобы вывести на экран какие-то символы, надо дописать код:
1case (X[3:0]) 2 3 4'h0: begin address <= {2'b0, K, Y[8:4], X[8:4]}; end 4 4'h1: begin address <= {2'b11, data[7:0], Y[3:1]}; end 5 4'hF: begin mask <= data; end 6 7endcaseЦикл будет исполняться в пределах 16 тактов (или 16 пикселей, которые, на самом деле, будет выглядеть как 8 пикселей).
- В такте 0 очередного знакоместа установится номер страницы (K = 0 или 1) и адрес внутри страницы. На каждый компонент потребуется по 5 бит (5+5=10 бит), где всего вмещается символов по X от 0 до 31, а также по Y от 0 до 31. Символы от 24 до 31 не видны, если нет скроллинга
- Второй такт вычитывает номер символа из тайловой карты и устанавливает адрес для извлечения знакоместа (высота знакоместа 8 строк)
- Третий - и последний такт, обязательно должен выполниться 15-м, записывается полученное знакоместо во временный регистр mask, биты и пиксели из которого будут показываться уже в следующем раунде считывания
Чтобы видеоадаптер заработал, необходимо сделать небольшое изменение в одной строке:
1if (shown) {r, g, b} <= paper & mask[ ~X[3:1] ] ? 12'hCCC : 12'h111;То есть, на каждом пикселе будет считываться бит из mask. Слева направо будут идти биты от старшего к младшему, поэтому в мультиплексоре поставлено именно значение
~X[3:1]
, что эквивалентно выражению 7-X[3:1]
.Результат работы видеоадаптера показан выше на рисунке. Тайловая карта заполнена тестовыми данными, так же, как заполнен данными знакогенератор.
§ Перекладывание на Verilator
Поскольку код видеокарты на верилоге был написан, неплохо бы посмотреть на то, как он работает "по настоящему", симулировать его с помощью верилятора.Чтобы это сделать, нужно внести некоторые небольшие поправки. Начну с makefile:
1LIB=/usr/share/verilator/include 2 3all: icarus verl app 4 ./tb 5icarus: 6 iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v 7 vvp tb.qqq >> /dev/null 8 rm tb.qqq 9verl: 10 verilator -cc ga.v 11 cd obj_dir && make -f Vga.mk 12app: 13 g++ -o tb -I$(LIB) $(LIB)/verilated.cpp tb.cc obj_dir/Vga__ALL.a -lSDL2 14 strip tb 15clean: 16 rm -rf tb tb.vcd obj_dirКак видно из makefile, добавилось новое правило
verl
, которое, при сборке, будет всегда выполняться перед правилом app
. Через верилятор проходит пока что только один файл ga.v
, то есть, графический адаптер, для него создается класс Vga
, доступный через подключение в самом начале tb.cc
:1#include "obj_dir/Vga.h"Изменяется также и строка компиляции, добавляя библиотеку с h-файлами для верилятора, а также исходного кода
verilated.cpp
из это библиотеки. Помимо исходных файлов, также добавляется архив Vga__ALL.a
, в котором как раз и находится собранный класс Vga
.В область описания класса App добавляются новые свойства:
1int _hs = 1, _vs = 0, x = 0, y = 0; 2uint8_t* memory; 3Vga* vga;Здесь свойства _hs, _vs, x и y служат для запоминания движения луча по экрану для его эмуляции. Объявляется память
memory
и, конечно же, класс Vga
. В конструкторе класса объявляем новые объекты и вызывается метод init:1vga = new Vga; 2init();В данном случае, метод init я вынес за пределы класс App в главный файл tb.cc:
1void App::init() { 2 3 memory = (uint8_t*) malloc(65536); 4 5 for (int i = 0; i < 2048; i++) { 6 7 memory[0xF000 + i] = (i < 1024 ? i & 255 : 0x01); 8 memory[0xF800 + i] = font8x8[i]; 9 } 10}В нем при загрузке окна создается память размером 64Кб, и заполняется тестовыми данными. Как можно заметить, присутствует массив font8x8, который я здесь выкладывать не буду, по причине его большого размера. Его можно посмотреть в прикрепленном файле к статьей, файл
font.h
.Теперь один из самых важных моментов, реализация метода эмуляции движения луча по экрану.
1 2// Рендеринг области VGA рисования 3void dsub(int hs, int vs, uint color) { 4 5 if (hs) x++; 6 7 // Отслеживание изменений HS/VS 8 if (_hs == 0 && hs == 1) { x = 0; y++; } 9 if (_vs == 1 && vs == 0) { x = 0; y = 0; } 10 11 // Сохранить предыдущее значение 12 _hs = hs; 13 _vs = vs; 14 15 // Вывод на экран 16 pset(x - 48, y - 35, color); 17}В общем-то, как это работает, я уже описывал и ранее, но кратко повторю. Если HS=1, то это значит, что луч что-то рисует в данный момент, а не находится в статусе горизонтальной синхронизации. При изменении hs с 0 на 1 — завершение синхронизации — луч отправится в X=0, а Y увеличится на 1. Аналогично, если vs сменится из 1 в 0, то луч вообще вернется в начальное положение. Учитывая передний порожек, рисовать луч будет только через 48 пикселей (тактов) по горизонтали и 35 строки по вертикали. Вот, собственно, и всё.
Последнее, что осталось сделать, это запустить процесс. Для этого необходимо реализовать отрисовку одного фрейма.
1void App::frame() { 2 3 // 100k x 50 = 5.0 Mhz (в 5 раз медленнее) 4 for (int i = 0; i < 100000; i++) { 5 6 vga->data = memory[0xF000 + vga->address]; 7 8 vga->clock = 0; vga->eval(); 9 vga->clock = 1; vga->eval(); 10 11 dsub(vga->hs, vga->vs, 65536*(vga->r*16) + 256*(vga->g*16) + vga->b*16); 12 } 13}На самом деле, скорость работы симулятора намного ниже, чем реальная скорость работы в ПЛИС, поэтому, для каждого фрейма я выделяю 100к тактов. Это, между прочим, еще очень оптимистически определенное число. При подключении процессора, реальное количество тактов на фрейм будет находится от 50к до 75к, в зависимости от того хост-процессора, на котором выполняется этот код.
Как можно обратить внимание, данные для видеокарты берутся из 0xF000-0xFFFF адреса, после чего выполняется две eval(), сначала с clock=0, потом clock=1, симулируя тем самым один такт.
После симуляции цвет точки отсылается в метод dsub, разобранный ранее. На экране появляется изображение с видеоадаптера, если все правильно было написано. Рабочий код всегда можно найти в прикрепленном файле, так что необязательно его тут копипастить.
§ Прошивка в FPGA
И последнее, что необходимо сделать, это сделать код, который будет реально работать в чипе.Минимальный проект в Quartus — программе, где компилируется и прошивается верилог и не только верилог, состоит из следующих файлов:
de0.qpf - файл проекта de0.qsf - конфигурация проекта и пинов de0.v - топ-уровень, главная схемаЭто минимальное количество файлов для запуска. Все файлы сконфигурированы для отладочной платы DE0 с Cyclone V, потому если делать для другой, то надо проставлять собственные пины и конфигурации. К тому же, здесь используется VGA выход и многие специфические порты ввода-вывода, которые есть только здесь. Так что эту схему нельзя использовать везде, она лишь только для примера.
Теперь в топ-уровне необходимо сделать следующее:
- Объявить генератор частот для получения 25 Мгц
- Объявить модуль ga (который находится в файле ga.v)
- Создать модуль памяти на 64Кб и встроить в схему, подключить к видеоадаптеру
de0pll.v
файле) в файле de0.v
:1wire locked; 2wire clock_25; 3 4de0pll PLL_inst 5( 6 .clkin (CLOCK_50), 7 .m25 (clock_25), 8 .locked (locked) 9);Между прочим, здесь locked играет достаточно серьезную роль. Если locked=0, это значит, что модуль автопостройки частоты находится в режиме автоматической конфигурации и еще не готов к работе. Обычно я использую этот выход для первичного сброса процессора и других схем. Пин clkin отвечает за входящий тактовый генератор из отладочной платы, где установлен кварц и этот кварц там установлен на 50 Мгц (пин CLOCK_50). Бывает, что кварц стоит и на 100 мгц, это надо каждый раз учитывать.
Модуль видеоадаптера объявляется так:
1wire [11:0] address; 2wire [ 7:0] data; 3 4ga U1 5( 6 .clock (clock_25), 7 .r (VGA_R), 8 .g (VGA_G), 9 .b (VGA_B), 10 .hs (VGA_HS), 11 .vs (VGA_VS), 12 .address (address), 13 .data (data), 14);В общем-то, ничего сложного нет, но надо не забывать указывать
wire
, чтобы синтезатор был в курсе, какая битность пинов используется, иначе он создаст провод на 1 бит и будет ошибка, при этом синтезатор выдаст только предупреждение, и это легко упустить из виду.Да, поскольку
ga.v
находится не в той же папке, что и проект, то я в конце файла подключаю этот модуль:1`include "../ga.v"`Теперь, остается последнее что сделать, это создать блок памяти на 64Кб. У меня есть специальный раздел на сайте, где можно это сделать для некоторых моделей чипов (тех, которые у меня есть). В проекте у меня этот модуль называется
ram
и находится в файле ram.v
. Но, одно дело создать модуль, нужно, чтобы память была изначально инициализирована данными. Как мы знаем, в последних 2кб в памяти должна храниться таблица символов 256 символов по 8 байт каждый. В предпоследних 2кб находятся 2 страницы видеопамяти. Они могут быть и нулевыми, но я все равно их заполнил какими-то значениями, чтобы хотя бы что-то увидеть кроме черного экрана.Файл с данными находится у меня в файле
ram.mif
. Вообще, MIF переводится как Memory Initialization File и выглядит он так:1WIDTH=8; 2DEPTH=65536; 3ADDRESS_RADIX=HEX; 4DATA_RADIX=HEX; 5CONTENT BEGIN 6 [0..F000]: 00; 7 F001: 01; 8 F002: 02; 9... 10END;Есть еще и другие способы инициализировать память, например, файлы
intel hex
, но они нет так интуитивно понятны, как mif, хоть последний и не очень компактно выглядит.На этой прекрасной ноте и закончим рассказ.
Скачать исходники к проекту