Лисья Нора

Оглавление


§ Каркас SDL2

Наверное, самой сложной темой тут будет создание каркаса для того, чтобы выводить видеоданные на SDL2, а не сам видеоадаптер и верилятор.
Как обычно, начну с makefile:
all:
g++ -o tb tb.cc -lSDL2
Мы будем создавать приложение, которое использует возможности SDL2 для отображения информации на дисплее и постепенно это приложение улучшать и дополнять по мере разработки процессора и компьютера.
Надо создать новый файл tb.cc, который и будет testbench для проекта, где и будет все тестироваться. Я не буду поступать по канонам языка C++ и буду делать как получится.
#include <stdlib.h>
#include "app.cc"
 
int main(int argc, char **argv) {
 
App* app = new App();
while (app->main()) { /* Обработчик */ }
app->destroy();
return 0;
}
Это главный файл, в котором подключается библиотеке stdlib на всякий случай, и класс для работы с SDL2, который сейчас будем разрабатывать. Этот класс подключается в коде main, создается объект app. В цикле while обращается к методу main, который будет выдавать 1, если прошло 20 мс с последнего раза, и 0, если была нажата кнопка "закрыть" окно.
При создании объекта App будет создаваться графическое окно SDL2.
Теперь рассмотрим общий план класса App в файле app.cc:
#include <SDL2/SDL.h>
 
class App {
 
protected:
 
int width, height, frame_length, frame_prev_ticks;
 
SDL_Surface* screen_surface;
SDL_Window* sdl_window;
SDL_Renderer* sdl_renderer;
SDL_PixelFormat* sdl_pixel_format;
SDL_Texture* sdl_screen_texture;
SDL_Event evt;
Uint32* screen_buffer;
 
public:
 
// Реализация методов
};
В области protected я традиционно записываю различные переменные, которые касаются объекта класса.
Краткое пояснение, какие переменные за что отвечают:

§ Конструктор класса

Код конструктора класса будет достаточно объемным:
App() {
 
width = 2*640; // Удвоение пикселей
height = 2*400;
frame_length = 50; // 20 кадров в секунду
frame_prev_ticks = 0;
 
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) {
exit(1);
}
 
SDL_ClearError();
 
screen_buffer = (Uint32*) malloc(width * height * sizeof(Uint32));
sdl_window = SDL_CreateWindow("Verilated VGA Display", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width, height, SDL_WINDOW_SHOWN);
sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_PRESENTVSYNC);
sdl_pixel_format = SDL_AllocFormat(SDL_PIXELFORMAT_BGRA32);
sdl_screen_texture = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, width, height);
SDL_SetTextureBlendMode(sdl_screen_texture, SDL_BLENDMODE_NONE);
}
Вначале проставляется размер окна. Поскольку я буду нацелен только на 640 x 400, то размер окна будет таким. То есть, не совсем таким, а в 2 раза больше, поэтому 1 пиксель с виртуального экрана будет занимать 2 пикселя на реальном, чтобы изображение было кратно 2.
Теперь, по порядку, что создается и для чего:
Хорошо бы, после исполнения каждой процедуры, проверять, не вернулся ли там NULL, но тут все на страх и риск сделано, чтобы много не писать кода.

§ Методы обработки

Обработчик использует "вечный цикл" через for(;;), и ожидает поступления различных событий.
int main() {
 
for (;;) {
 
Uint32 ticks = SDL_GetTicks();
 
while (SDL_PollEvent(& evt)) {
switch (evt.type) {
case SDL_QUIT:
return 0;
}
}
 
if (ticks - frame_prev_ticks >= frame_length) {
frame_prev_ticks = ticks;
update();
return 1;
}
 
SDL_Delay(1);
}
}
В переменной ticks находится текущее время в миллисекундах.
Далее, выполняется цикл while (SDL_PollEvent(& evt)), в котором получаются все новые события, которые пришли с последнего обращения. Если же в процессе сканирования событий было получено событие SDL_QUIT, то происходит досрочный выход из main() с кодом 0. В случае, если события были успешно обработаны, то будет проверено, не прошло ли frame_length миллисекунд с последнего замера.
Если прошло, то будет записан новый замер в frame_prev_ticks, вызван метод update(), который обновит окно, перерисовав screen_buffer на поверхность и окна и далее – досрочный выход из main() с кодом 1.
Если же ни одного значимого события не было, то вызывается ожидание 1 мс, чтобы не нагружать процессор и цикл повторяется снова и снова.
void update() {
 
SDL_Rect dstRect;
 
dstRect.x = 0;
dstRect.y = 0;
dstRect.w = width;
dstRect.h = height;
 
SDL_UpdateTexture (sdl_screen_texture, NULL, screen_buffer, width * sizeof(Uint32));
SDL_SetRenderDrawColor (sdl_renderer, 0, 0, 0, 0);
SDL_RenderClear (sdl_renderer);
SDL_RenderCopy (sdl_renderer, sdl_screen_texture, NULL, &dstRect);
SDL_RenderPresent (sdl_renderer);
}
Что здесь выполняется:
void destroy() {
 
free(screen_buffer);
SDL_DestroyTexture(sdl_screen_texture);
SDL_FreeFormat(sdl_pixel_format);
SDL_DestroyRenderer(sdl_renderer);
SDL_DestroyWindow(sdl_window);
SDL_Quit();
}
Этот метод вызывается перед тем, как завершить программу, освобождаются ранее занятые ресурсы, память, закрывается окно и происходит выход из SDL.
И последняя важная вещь, это рисование в видеобуфере:
void pset(int x, int y, Uint32 cl) {
 
if (x < 0 || y < 0 || x >= 640 || y >= 400)
return;
 
for (int i = 0; i < 2; i++)
for (int j = 0; j < 2; j++)
screen_buffer[width*(2*y+i) + (2*x+j)] = cl;
}
Записывается точка тогда, когда она находится в пределах x=0..639, y=0..399. Код здесь достаточно очевиден, так что особо комментировать тут нечего, разве что только в screen_buffer информация о пикселях предствляет из себя одномерный массив, который рисует слева направо, сверху вниз. Один элемент массива – один пиксель на экране в формате RGBA.

§ Создание модуля от верилятора

Вот теперь как раз самое главное, надо скомпилировать файл vga.v в код на C++.
Добавим новый код в makefile:
vga:
verilator -Wall -Wno-unused -Wno-width -Wno-caseincomplete -cc vga.v
cd obj_dir && make -f Vvga.mk
Опции -Wno-unused -Wno-width -Wno-caseincomplete устанавливают, что компилятор будет выводить предупреждения, если 1) не совпадают битности при сравнении, 2) есть элементы, которые не используются в схеме, 3) в case используются не все варианты.
После компиляции в obj_dir будут находится нужные компоненты для работы.
Полный вид makefile:
VINC=/usr/share/verilator/include
 
all: vga
g++ -o tb -I$(VINC) tb.cc $(VINC)/verilated.cpp obj_dir/Vvga__ALL.a -lSDL2
./tb
vga:
verilator -Wall -Wno-unused -Wno-width -Wno-caseincomplete -cc vga.v
cd obj_dir && make -f Vvga.mk
В первой строке задается путь до файлов для заголовков верилятора, это важно. В опциях компилятора устанавливается путь к -I (включениям), а также компилируется файл verilated.cpp, который там находится и прицепляется сгенерированный файл Vvga__ALL.a из obj_dir. Если модулей больше, то необходимо их все прицеплять сюда, в строку компилятора.
Обязательный минимум это -I$(VINC) и $(VINC)/verilated.cpp
#include <stdlib.h>
#include "app.cc"
#include "obj_dir/Vvga.h"
 
int main(int argc, char **argv) {
 
App* app = new App();
 
// -------------------------------------
Verilated::commandArgs(argc, argv);
Vvga* top = new Vvga;
// -------------------------------------
 
while (app->main()) { if (Verilated::gotFinish()) break; }
app->destroy();
return 0;
}
Добавляем новый код в tb.cc:
В данном коде используется только один модуль. На самом деле можно сделать много модулей, но их необходимо также правильно сцепить между собой.
Чтобы запустить модуль в работу, надо организовать тактовую частоту, а для этого нужно включить в главный цикл код:
for (int i = 0; i < 125000; i++) {
 
top->clock = 0; top->eval(); // Низкий сигнал clock
top->clock = 1; top->eval(); // Позитивный фронт clock
}
В данном примере используется 125k тактов 20 раз в секунду, что равно 2.5 Мгц, или ровно в 10 раз медленнее, чем если бы работало в реальной схеме (там 25 мгц). Можно увеличить скорость, но это создаст большую нагрузку на процессор.
В этом примере выполняется симуляция переброса из clock из 0 в 1 и наоборот.
А теперь одна из важных вещей – это симуляция видеосигнала для 640 x 400 x 70
void vga(int hs, int vs, int 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);
}
Этот метод я добавил в класс App, и его можно вызывать для того, чтобы нарисовать очередную точку в области рисования после каждого такта.
В связи с тем, что x, y, _hs, _vs должны быть обязательно проинициализированы, в конструктор записывается следующее:
x = 0;
y = 0;
_hs = 1; // Важно начать с 1
_vs = 0; // Важно начать с 0
Сигнал _hs крайне важно начать с 1, потому что следующий hs, который придет из видеоадаптера, будет 1. Если _hs будет 0, то на самой же первой линии будет прибавлен y++, что очень нежелательно.
Теперь после каждого такта пиксель-клока необходимо вызывать метод:
app->vga(top->hs, top->vs, top->r*(128*65536) + top->b*(128*256) + top->g*128);
В который передается hs, vs и значение цвета в RGB. Цвет подстроен так же, как он реализован в и "в железе", то есть либо серый, либо черный. Здесь можно использовать любой цвет, но архитектура самого vga-модуля, который я делал ранее, предполагает только такие цвета в данный момент.

§ Блоки памяти

Но, если запустить сейчас этот код, будет показан черный экран и вот почему – нет данных в памяти, там будет на входе data ноль, то есть, выводится ничего не будет. Значит, создаем блок памяти на 4кб и загружаем туда заранее подготовленный файл с данными. Первые 2кб будет заполнены неким "мусором", чтобы было хотя бы что-то для вывода, а вторые 2кб – знакогенератором.
unsigned char memory[4096];
 
// Чтение 2кб в знакогенератор
FILE* fp = fopen("font.bin", "rb");
fread(memory + 2048, 1, 2048, fp);
fclose(fp);
 
// Заполнение первичными данными
for (int i = 0; i < 2048; i++)
memory[i] = i & 255;
И остался самый последний штрих, это чтение данных data из памяти address перед пиксель-клоком:
top->data = memory[ top->address ];
То есть, перед каждым симулятором тактовой частоты, до этого события будет установлено необходимое значение из памяти по адресу address и записано в порт data.
SDL-окно с симуляцией видеоадаптера
SDL-окно с симуляцией видеоадаптера
Так выглядит окно SDL. Все в порядке. Так и должно быть.