§ Каркас SDL2
Наверное, самой сложной темой тут будет создание каркаса для того, чтобы выводить видеоданные на SDL2, а не сам видеоадаптер и верилятор.Как обычно, начну с makefile:
1all: 2 g++ -o tb tb.cc -lSDL2Мы будем создавать приложение, которое использует возможности SDL2 для отображения информации на дисплее и постепенно это приложение улучшать и дополнять по мере разработки процессора и компьютера.
Надо создать новый файл tb.cc, который и будет testbench для проекта, где и будет все тестироваться. Я не буду поступать по канонам языка C++ и буду делать как получится.
1#include <stdlib.h> 2#include "app.cc" 3 4int main(int argc, char **argv) { 5 6 App* app = new App(); 7 while (app->main()) { /* Обработчик */ } 8 app->destroy(); 9 return 0; 10}Это главный файл, в котором подключается библиотеке stdlib на всякий случай, и класс для работы с SDL2, который сейчас будем разрабатывать. Этот класс подключается в коде main, создается объект
app
. В цикле while обращается к методу main
, который будет выдавать 1, если прошло 20 мс с последнего раза, и 0, если была нажата кнопка "закрыть" окно.При создании объекта
App
будет создаваться графическое окно SDL2.Теперь рассмотрим общий план класса App в файле app.cc:
1#include <SDL2/SDL.h> 2 3class App { 4 5protected: 6 7 int width, height, frame_length, frame_prev_ticks; 8 9 SDL_Surface* screen_surface; 10 SDL_Window* sdl_window; 11 SDL_Renderer* sdl_renderer; 12 SDL_PixelFormat* sdl_pixel_format; 13 SDL_Texture* sdl_screen_texture; 14 SDL_Event evt; 15 Uint32* screen_buffer; 16 17public: 18 19 // Реализация методов 20};В области
protected
я традиционно записываю различные переменные, которые касаются объекта класса.Краткое пояснение, какие переменные за что отвечают:
- screen_surface — объект поверхности окна
- sdl_window — объект самого окна
- sdl_renderer — с помощью него происходит рендеринг (отображение данных)
- sdl_pixel_format — задание формата пикселей
- sdl_screen_texture — текстура, с которой копируется на поверхность окна
- evt — возникающие события
- screen_buffer — непосредственно, сами данные, куда будут записываться цвета, для рендера на окне
§ Конструктор класса
Код конструктора класса будет достаточно объемным:1App() { 2 3 width = 2*640; // Удвоение пикселей 4 height = 2*400; 5 frame_length = 50; // 20 кадров в секунду 6 frame_prev_ticks = 0; 7 8 if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { 9 exit(1); 10 } 11 12 SDL_ClearError(); 13 14 screen_buffer = (Uint32*) malloc(width * height * sizeof(Uint32)); 15 sdl_window = SDL_CreateWindow("Verilated VGA Display", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, width, height, SDL_WINDOW_SHOWN); 16 sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_PRESENTVSYNC); 17 sdl_pixel_format = SDL_AllocFormat(SDL_PIXELFORMAT_BGRA32); 18 sdl_screen_texture = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, width, height); 19 SDL_SetTextureBlendMode(sdl_screen_texture, SDL_BLENDMODE_NONE); 20}Вначале проставляется размер окна. Поскольку я буду нацелен только на 640 x 400, то размер окна будет таким. То есть, не совсем таким, а в 2 раза больше, поэтому 1 пиксель с виртуального экрана будет занимать 2 пикселя на реальном, чтобы изображение было кратно 2.
Теперь, по порядку, что создается и для чего:
- SDL_ClearError - убирает предыдущие ошибки SDL, если они там были
- frame_length = 20, это количество миллисекунд между событием, когда возвращается 1 из метода. Это нужно для периодического обновления экрана. То есть, в методе main() будет ожидать 20 мс, прежде чем будет выход из этого метода.
- SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO) — инициализация SDL фреймворка. Если не получилось, то программа будет экстренно завершена.
- screen_buffer — выделяется область памяти необходимого размера для содержания там данных о фреймбуфере
- sdl_window — создание окна SDL2 c именем окна "Verilated VGA Display", по центру, размером width x height, окно будет сразу же показано
- sdl_renderer — создается рендерер, который будет рисовать текстуру на поверхности окна
- sdl_pixel_format — формат пикселей 32 бита RGBA
- sdl_screen_texture — собственно, сама текстура, которая создается на основе объекта sdl_renderer, размер текстуры будет совпадать с размером окна (width x height), а также текстура будет находится в быстрой памяти (SDL_TEXTUREACCESS_STREAMING), не загружаясь в видеопамять
- SDL_SetTextureBlendMode — удаляет всякое смешивание для текстуры, чтобы не просвечивалась прозрачность при наложении
§ Методы обработки
Обработчик использует "вечный цикл" черезfor(;;)
, и ожидает поступления различных событий.1int main() { 2 3 for (;;) { 4 5 Uint32 ticks = SDL_GetTicks(); 6 7 while (SDL_PollEvent(& evt)) { 8 switch (evt.type) { 9 case SDL_QUIT: 10 return 0; 11 } 12 } 13 14 if (ticks - frame_prev_ticks >= frame_length) { 15 frame_prev_ticks = ticks; 16 update(); 17 return 1; 18 } 19 20 SDL_Delay(1); 21 } 22}В переменной ticks находится текущее время в миллисекундах.
Далее, выполняется цикл
while (SDL_PollEvent(& evt))
, в котором получаются все новые события, которые пришли с последнего обращения. Если же в процессе сканирования событий было получено событие SDL_QUIT, то происходит досрочный выход из main() с кодом 0. В случае, если события были успешно обработаны, то будет проверено, не прошло ли frame_length миллисекунд с последнего замера.Если прошло, то будет записан новый замер в frame_prev_ticks, вызван метод update(), который обновит окно, перерисовав screen_buffer на поверхность и окна и далее - досрочный выход из main() с кодом 1.
Если же ни одного значимого события не было, то вызывается ожидание 1 мс, чтобы не нагружать процессор и цикл повторяется снова и снова.
1void update() { 2 3 SDL_Rect dstRect; 4 5 dstRect.x = 0; 6 dstRect.y = 0; 7 dstRect.w = width; 8 dstRect.h = height; 9 10 SDL_UpdateTexture (sdl_screen_texture, NULL, screen_buffer, width * sizeof(Uint32)); 11 SDL_SetRenderDrawColor (sdl_renderer, 0, 0, 0, 0); 12 SDL_RenderClear (sdl_renderer); 13 SDL_RenderCopy (sdl_renderer, sdl_screen_texture, NULL, &dstRect); 14 SDL_RenderPresent (sdl_renderer); 15}Что здесь выполняется:
- Создается объект dstRect, в котором указываются координаты, где будет нарисована текстура.
- SDL_UpdateTexture — из данных screen_buffer переписывается в текстуру, указывается размер строки, равный в байтах width * sizeof(Uint32)
- SDL_SetRenderDrawColor — установка RGBA цвета рисования для SDL_RenderClear
- SDL_RenderClear — очистка области
- SDL_RenderCopy — копирование из текстуры в окно
- SDL_RenderPresent — показать результат
1void destroy() { 2 3 free(screen_buffer); 4 SDL_DestroyTexture(sdl_screen_texture); 5 SDL_FreeFormat(sdl_pixel_format); 6 SDL_DestroyRenderer(sdl_renderer); 7 SDL_DestroyWindow(sdl_window); 8 SDL_Quit(); 9}Этот метод вызывается перед тем, как завершить программу, освобождаются ранее занятые ресурсы, память, закрывается окно и происходит выход из SDL.
И последняя важная вещь, это рисование в видеобуфере:
1void pset(int x, int y, Uint32 cl) { 2 3 if (x < 0 || y < 0 || x >= 640 || y >= 400) 4 return; 5 6 for (int i = 0; i < 2; i++) 7 for (int j = 0; j < 2; j++) 8 screen_buffer[width*(2*y+i) + (2*x+j)] = cl; 9}Записывается точка тогда, когда она находится в пределах x=0..639, y=0..399. Код здесь достаточно очевиден, так что особо комментировать тут нечего, разве что только в screen_buffer информация о пикселях предствляет из себя одномерный массив, который рисует слева направо, сверху вниз. Один элемент массива - один пиксель на экране в формате RGBA.
Скачать код шаблона можно здесь.
§ Создание модуля от верилятора
Вот теперь как раз самое главное, надо скомпилировать файл vga.v в код на C++.Добавим новый код в makefile:
1vga: 2 verilator -Wall -Wno-unused -Wno-width -Wno-caseincomplete -cc vga.v 3 cd obj_dir && make -f Vvga.mkОпции -Wno-unused -Wno-width -Wno-caseincomplete устанавливают, что компилятор будет выводить предупреждения, если 1) не совпадают битности при сравнении, 2) есть элементы, которые не используются в схеме, 3) в case используются не все варианты.
После компиляции в obj_dir будут находится нужные компоненты для работы.
Полный вид makefile:
1VINC=/usr/share/verilator/include 2 3all: vga 4 g++ -o tb -I$(VINC) tb.cc $(VINC)/verilated.cpp obj_dir/Vvga__ALL.a -lSDL2 5 ./tb 6vga: 7 verilator -Wall -Wno-unused -Wno-width -Wno-caseincomplete -cc vga.v 8 cd obj_dir && make -f Vvga.mkВ первой строке задается путь до файлов для заголовков верилятора, это важно. В опциях компилятора устанавливается путь к -I (включениям), а также компилируется файл verilated.cpp, который там находится и прицепляется сгенерированный файл Vvga__ALL.a из obj_dir. Если модулей больше, то необходимо их все прицеплять сюда, в строку компилятора.
Обязательный минимум это -I$(VINC) и $(VINC)/verilated.cpp
1#include <stdlib.h> 2#include "app.cc" 3#include "obj_dir/Vvga.h" 4 5int main(int argc, char **argv) { 6 7 App* app = new App(); 8 9 // ------------------------------------- 10 Verilated::commandArgs(argc, argv); 11 Vvga* top = new Vvga; 12 // ------------------------------------- 13 14 while (app->main()) { if (Verilated::gotFinish()) break; } 15 app->destroy(); 16 return 0; 17}Добавляем новый код в tb.cc:
-
#include "obj_dir/Vvga.h"
— включаются заголовки для класса Vvga -
Verilated::commandArgs(argc, argv);
— инициализация модуля Verilated -
Vvga* top = new Vvga;
— создается объект top модуля Vvga -
if (Verilated::gotFinish()) break;
— на случай, если в синтезируемом вериляторе коде есть команда остановки симуляции
Чтобы запустить модуль в работу, надо организовать тактовую частоту, а для этого нужно включить в главный цикл код:
1for (int i = 0; i < 125000; i++) { 2 3 top->clock = 0; top->eval(); // Низкий сигнал clock 4 top->clock = 1; top->eval(); // Позитивный фронт clock 5}В данном примере используется 125k тактов 20 раз в секунду, что равно 2.5 Мгц, или ровно в 10 раз медленнее, чем если бы работало в реальной схеме (там 25 мгц). Можно увеличить скорость, но это создаст большую нагрузку на процессор.
В этом примере выполняется симуляция переброса из clock из 0 в 1 и наоборот.
А теперь одна из важных вещей — это симуляция видеосигнала для 640 x 400 x 70
1void vga(int hs, int vs, int color) { 2 3 if (hs) x++; 4 5 // Отслеживание изменений HS/VS 6 if (_hs == 0 && hs == 1) { x = 0; y++; } 7 if (_vs == 1 && vs == 0) { x = 0; y = 0; } 8 9 // Сохранить предыдущее значение 10 _hs = hs; _vs = vs; 11 12 // Вывод на экран 13 pset(x-48, y-35, color); 14}Этот метод я добавил в класс App, и его можно вызывать для того, чтобы нарисовать очередную точку в области рисования после каждого такта.
- hs - это сигнал горизонтальной синхронизации, он на протяжении большей части равен 1, поскольку hsync отрицательной полярности. Когда hs=1, увеличиваем x++
- При переходе hs от 0 к 1 (начало новой строки), сбрасываем x=0 и увеличивается y++
- При переходе vs от 1 к 0 (вертикальная развертка), переходим к x=0, y=0
- Сохраняем _hs, _vs — для того, чтобы отслеживать переход сигнала из одного состояния в другое
- Рисуется точка. Поскольку область рисования начинается после порожеков, из x вычитается значение заднего порожека 48, а из y вычитается 35.
1x = 0; 2y = 0; 3_hs = 1; // Важно начать с 1 4_vs = 0; // Важно начать с 0Сигнал _hs крайне важно начать с 1, потому что следующий hs, который придет из видеоадаптера, будет 1. Если _hs будет 0, то на самой же первой линии будет прибавлен y++, что очень нежелательно.
Теперь после каждого такта пиксель-клока необходимо вызывать метод:
1app->vga(top->hs, top->vs, top->r*(128*65536) + top->b*(128*256) + top->g*128);В который передается hs, vs и значение цвета в RGB. Цвет подстроен так же, как он реализован в и "в железе", то есть либо серый, либо черный. Здесь можно использовать любой цвет, но архитектура самого vga-модуля, который я делал ранее, предполагает только такие цвета в данный момент.
§ Блоки памяти
Но, если запустить сейчас этот код, будет показан черный экран и вот почему — нет данных в памяти, там будет на входеdata
ноль, то есть, выводится ничего не будет. Значит, создаем блок памяти на 4кб и загружаем туда заранее подготовленный файл с данными. Первые 2кб будет заполнены неким "мусором", чтобы было хотя бы что-то для вывода, а вторые 2кб - знакогенератором.1unsigned char memory[4096]; 2 3// Чтение 2кб в знакогенератор 4FILE* fp = fopen("font.bin", "rb"); 5fread(memory + 2048, 1, 2048, fp); 6fclose(fp); 7 8// Заполнение первичными данными 9for (int i = 0; i < 2048; i++) 10 memory[i] = i & 255;И остался самый последний штрих, это чтение данных
data
из памяти address
перед пиксель-клоком:1top->data = memory[ top->address ];
То есть, перед каждым симулятором тактовой частоты, до этого события будет установлено необходимое значение из памяти по адресу address
и записано в порт data
.Так выглядит окно SDL, в котором симулируется видеоадаптер. Все в порядке. Так и должно быть.
Код к данному материалу можно скачать тут.