§ Задачи на день

Сегодня я разберу то:
  • Как написать на верилоге простой текстовый видеоадаптер
  • Симулировать его на verilator
  • И запустить на реальном чипе
По поводу запуска реального чипа, использоваться будет отладочная плата DE0 Cyclone V. Они все очень похожи между собой, потому запуск на других отладочных платах не будет представлять особенной сложности.

§ Видеоадаптер

Сам по себе принцип работы видеоадаптера я уже разбирал однажды в одном из своих дневников. В этой статье я только вкратце повторю основные принципы работы видеовывода на VGA, не заостряя на деталях внимания.
Я долго раздумывал, как же мне сделать видеоадаптер, чтобы он был простой и достаточный, чтобы работать с ним и придумал сделать его похожим на NES (или Денди) PPU. Видеопамять будет располагаться по следующим адресам:
$F000 Тайловая страница 1 (1kb)
$F400 Тайловая страница 2 (1kb)
$F800 Знакогенератор 8x8 (2kb)
По итогу, вся видеопамять будет занимать 4кб. Первые 2кб, как и в Денди, будут отведены под тайловую карту, две страницы. Одна страница умещается на экран размером 256x192, или, 32x24 тайла. Картинка будет черно-белой. Знакогенератор располагается по адресам $F800-$FFFF, количество знаков 256. Поначалу генератор знаков будет заполнен шрифтом 8x8, но его можно будет менять в процессе, то есть, это не является ПЗУ, но при загрузке FPGA, информация о знакогенераторе заполняется из прошивки.
Ну что, достаем ручки и тетради и начинаем записывать модуль видеоадаптера:
module ga
(
    input               clock,     // 25 Мгц
    output  reg [ 3:0]  r,
    output  reg [ 3:0]  g,
    output  reg [ 3:0]  b,
    output              hs,
    output              vs,
    input       [ 7:0]  xs,
    input       [ 7:0]  ys,
    output  reg [11:0]  address,
    input       [ 7:0]  data
);
endmodule
Это самый простой код, в котором обозначили порты ввода-вывода. В данный момент их немного и некоторые из них можно даже было написать иначе, но как есть, так и есть. Порт clock должен прицепляться внешним тактовым сигналом, равным 25 мгц, и это обязательно.
Значения xs, ys - это отступ скроллинга слева и сверху. Их рассмотрю чуть позже по тексту.
Порты r,g,b, а также hs,vs — это выводы видеоадаптера, формирующие видеосигнал. В порту address содержится адрес от $000-$FFF, который маппится потом на $F000 + address путем добавления 4'b1111 в старшие 4 бита. И, накоенц, порт data - это данные от памяти видеоадаптера.
Вкратце рассмотрю следующий блок кода:
// ---------------------------------------------------------------------
// Тайминги для горизонтальной|вертикальной развертки (640x400)
// ---------------------------------------------------------------------
parameter
    hz_visible = 640, vt_visible = 400,
    hz_front   = 16,  vt_front   = 12,
    hz_sync    = 96,  vt_sync    = 2,
    hz_back    = 48,  vt_back    = 35,
    hz_whole   = 800, vt_whole   = 449;
// ---------------------------------------------------------------------
assign hs = x  < (hz_back + hz_visible + hz_front); // NEG.
assign vs = y >= (vt_back + vt_visible + vt_front); // POS.
// ---------------------------------------------------------------------
wire   xmax  = (x == hz_whole - 1);
wire   ymax  = (y == vt_whole - 1);
wire   shown =  x >= hz_back && x < hz_visible + hz_back &&
                y >= vt_back && y < vt_visible + vt_back;
wire   paper = (x >= hz_back + 64) && (x < hz_back + 64 + 512) &&
               (y >= vt_back +  8) && (y < vt_back +  8 + 384);
Здесь нет ничего нового и необычного. Параметры задают значения развертки, количество тактов на задний, передний порожек, на видимую область и синхросигнал. Все эти параметры участвуют в вычислении текущих значений сигналов горизонтальной (hs) и вертикальной синхронизации (vs).
На проводе shown, в случае единицы, будет означать, что луч в данный момент находится в видимой области рисования, а если paper = 1, то значит, что луч в данный момент проходит по области, где будет рисоваться тайловая карта. Вне рисования тайлов, вокруг будет бордер очень близкого к черному цвету. Я сделал так намеренно, чтобы монитор "не терялся" и смог точно установить, где начинается видимая область рисования при автоматической подстройке изображения.
В проводе xmax и ymax будет единица тогда, когда регистр x или y достигли максимального значения, то есть луч находится либо в самой правой крайней точке, либо в самой нижней.
Регистры x,y объявляются так:
reg  [ 9:0] x    = 1'b0;
reg  [ 8:0] y    = 1'b0;
reg  [ 7:0] mask;
Здесь mask потребуется для временного хранения рисуемого знакоместа.
Для этого видеоадаптера будет реализован "скроллинг" экрана, а значит, я должен сразу же заложить функционал изменения страницы. Это, на самом деле, делается очень просто:
wire [ 9:0] X = (x - hz_back - 48) + (xs << 1);
wire [ 9:0] Y = (y - vt_back -  8) + (ys << 1);
wire        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 рисуемых пикселей) вперед.
Перейду к главному рабочему коду:
always @(posedge clock) begin

    {r, g, b} <= 12'h000;

    // Кадровая развертка
    x <= xmax ?         0 : x + 1;
    y <= xmax ? (ymax ? 0 : y + 1) : y;

    if (shown) {r, g, b} <= paper ? 12'hCCC : 12'h111;

end
При достижении X, равному xmax, а именно, последнему номеру пикселя в строке, X станет равным 0, а Y либо увеличится на +1, либо, если Y достиг ymax, станет 0.
Если луч находится в области рисования (shown), то либо рисуется серый цвет в области PAPER, либо же темно-серый цвет в другом случае (бордер). Так будет выглядеть экран:

Для того, чтобы вывести на экран какие-то символы, надо дописать код:
case (X[3:0])

    4'h0: begin address <= {2'b0,  K, Y[8:4], X[8:4]}; end
    4'h1: begin address <= {2'b11, data[7:0], Y[3:1]}; end
    4'hF: begin mask    <= data; end

endcase
Цикл будет исполняться в пределах 16 тактов (или 16 пикселей, которые, на самом деле, будет выглядеть как 8 пикселей).
  • В такте 0 очередного знакоместа установится номер страницы (K = 0 или 1) и адрес внутри страницы. На каждый компонент потребуется по 5 бит (5+5=10 бит), где всего вмещается символов по X от 0 до 31, а также по Y от 0 до 31. Символы от 24 до 31 не видны, если нет скроллинга
  • Второй такт вычитывает номер символа из тайловой карты и устанавливает адрес для извлечения знакоместа (высота знакоместа 8 строк)
  • Третий - и последний такт, обязательно должен выполниться 15-м, записывается полученное знакоместо во временный регистр mask, биты и пиксели из которого будут показываться уже в следующем раунде считывания
То есть, пока считывается следующий символ, в то время рисуется предыдущий. Это простой конвеер.
Чтобы видеоадаптер заработал, необходимо сделать небольшое изменение в одной строке:
if (shown) {r, g, b} <= paper & mask[ ~X[3:1] ] ? 12'hCCC : 12'h111;
То есть, на каждом пикселе будет считываться бит из mask. Слева направо будут идти биты от старшего к младшему, поэтому в мультиплексоре поставлено именно значение ~X[3:1], что эквивалентно выражению 7-X[3:1].

Результат работы видеоадаптера показан выше на рисунке. Тайловая карта заполнена тестовыми данными, так же, как заполнен данными знакогенератор.

§ Перекладывание на Verilator

Поскольку код видеокарты на верилоге был написан, неплохо бы посмотреть на то, как он работает "по настоящему", симулировать его с помощью верилятора.
Чтобы это сделать, нужно внести некоторые небольшие поправки. Начну с makefile:
LIB=/usr/share/verilator/include

all: icarus verl app
	./tb
icarus:
	iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v
	vvp tb.qqq >> /dev/null
	rm tb.qqq
verl:
	verilator -cc ga.v
	cd obj_dir && make -f Vga.mk
app:
	g++ -o tb -I$(LIB) $(LIB)/verilated.cpp tb.cc obj_dir/Vga__ALL.a -lSDL2
	strip tb
clean:
	rm -rf tb tb.vcd obj_dir
Как видно из makefile, добавилось новое правило verl, которое, при сборке, будет всегда выполняться перед правилом app. Через верилятор проходит пока что только один файл ga.v, то есть, графический адаптер, для него создается класс Vga, доступный через подключение в самом начале tb.cc:
#include "obj_dir/Vga.h"
Изменяется также и строка компиляции, добавляя библиотеку с h-файлами для верилятора, а также исходного кода verilated.cpp из это библиотеки. Помимо исходных файлов, также добавляется архив Vga__ALL.a, в котором как раз и находится собранный класс Vga.
В область описания класса App добавляются новые свойства:
int      _hs = 1, _vs = 0, x = 0, y = 0;
uint8_t* memory;
Vga*     vga;
Здесь свойства _hs, _vs, x и y служат для запоминания движения луча по экрану для его эмуляции. Объявляется память memory и, конечно же, класс Vga. В конструкторе класса объявляем новые объекты и вызывается метод init:
vga = new Vga;
init();
В данном случае, метод init я вынес за пределы класс App в главный файл tb.cc:
void App::init() {

    memory = (uint8_t*) malloc(65536);

    for (int i = 0; i < 2048; i++) {

        memory[0xF000 + i] = (i < 1024 ? i & 255 : 0x01);
        memory[0xF800 + i] = font8x8[i];
    }
}
В нем при загрузке окна создается память размером 64Кб, и заполняется тестовыми данными. Как можно заметить, присутствует массив font8x8, который я здесь выкладывать не буду, по причине его большого размера. Его можно посмотреть в прикрепленном файле к статьей, файл font.h.
Теперь один из самых важных моментов, реализация метода эмуляции движения луча по экрану.
// Рендеринг области VGA рисования
void dsub(int hs, int vs, uint color) {

    if (hs) x++;

    // Отслеживание изменений HS/VS
    if (_hs == 0 && hs == 1) { x = 0; y++; }
    if (_vs == 1 && vs == 0) { x = 0; y = 0; }

    // Сохранить предыдущее значение
    _hs = hs;
    _vs = vs;

    // Вывод на экран
    pset(x - 48, y - 35, color);
}
В общем-то, как это работает, я уже описывал и ранее, но кратко повторю. Если HS=1, то это значит, что луч что-то рисует в данный момент, а не находится в статусе горизонтальной синхронизации. При изменении hs с 0 на 1 — завершение синхронизации — луч отправится в X=0, а Y увеличится на 1. Аналогично, если vs сменится из 1 в 0, то луч вообще вернется в начальное положение. Учитывая передний порожек, рисовать луч будет только через 48 пикселей (тактов) по горизонтали и 35 строки по вертикали. Вот, собственно, и всё.
Последнее, что осталось сделать, это запустить процесс. Для этого необходимо реализовать отрисовку одного фрейма.
void App::frame() {

    // 100k x 50 = 5.0 Mhz (в 5 раз медленнее)
    for (int i = 0; i < 100000; i++) {

        vga->data = memory[0xF000 + vga->address];

        vga->clock = 0; vga->eval();
        vga->clock = 1; vga->eval();

        dsub(vga->hs, vga->vs, 65536*(vga->r*16) + 256*(vga->g*16) + vga->b*16);
    }
}
На самом деле, скорость работы симулятора намного ниже, чем реальная скорость работы в ПЛИС, поэтому, для каждого фрейма я выделяю 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:
wire locked;
wire clock_25;

de0pll PLL_inst
(
    .clkin      (CLOCK_50),
    .m25        (clock_25),
    .locked     (locked)
);
Между прочим, здесь locked играет достаточно серьезную роль. Если locked=0, это значит, что модуль автопостройки частоты находится в режиме автоматической конфигурации и еще не готов к работе. Обычно я использую этот выход для первичного сброса процессора и других схем. Пин clkin отвечает за входящий тактовый генератор из отладочной платы, где установлен кварц и этот кварц там установлен на 50 Мгц (пин CLOCK_50). Бывает, что кварц стоит и на 100 мгц, это надо каждый раз учитывать.
Модуль видеоадаптера объявляется так:
wire [11:0] address;
wire [ 7:0] data;

ga U1
(
    .clock      (clock_25),
    .r          (VGA_R),
    .g          (VGA_G),
    .b          (VGA_B),
    .hs         (VGA_HS),
    .vs         (VGA_VS),
    .address    (address),
    .data       (data),
);
В общем-то, ничего сложного нет, но надо не забывать указывать wire, чтобы синтезатор был в курсе, какая битность пинов используется, иначе он создаст провод на 1 бит и будет ошибка, при этом синтезатор выдаст только предупреждение, и это легко упустить из виду.
Да, поскольку ga.v находится не в той же папке, что и проект, то я в конце файла подключаю этот модуль:
`include "../ga.v"`
Теперь, остается последнее что сделать, это создать блок памяти на 64Кб. У меня есть специальный раздел на сайте, где можно это сделать для некоторых моделей чипов (тех, которые у меня есть). В проекте у меня этот модуль называется ram и находится в файле ram.v. Но, одно дело создать модуль, нужно, чтобы память была изначально инициализирована данными. Как мы знаем, в последних 2кб в памяти должна храниться таблица символов 256 символов по 8 байт каждый. В предпоследних 2кб находятся 2 страницы видеопамяти. Они могут быть и нулевыми, но я все равно их заполнил какими-то значениями, чтобы хотя бы что-то увидеть кроме черного экрана.
Файл с данными находится у меня в файле ram.mif. Вообще, MIF переводится как Memory Initialization File и выглядит он так:
WIDTH=8;
DEPTH=65536;
ADDRESS_RADIX=HEX;
DATA_RADIX=HEX;
CONTENT BEGIN
  [0..F000]: 00;
  F001: 01;
  F002: 02;
...
END;
Есть еще и другие способы инициализировать память, например, файлы intel hex, но они нет так интуитивно понятны, как mif, хоть последний и не очень компактно выглядит.
На этой прекрасной ноте и закончим рассказ.
Скачать исходники к проекту
31 окт, 2022
© 2007-2022 Таффи штырит на дому