Оглавление
§ Настройка
Перед разработкой кода всегда необходимо правильно настроиться и настроить окружающих. Для тестирования кода я буду использовать верилятор (verilator), который запускается только на linux, и icarus verilog (он работает везде). Как и обычно, начнем с того, что создадим простые файлы. К примеруmakefile
:all: icarus verilate g++ -o tb -I$(INC) $(INC)/verilated.cpp tb.cc obj_dir/Vppu__ALL.a -lSDL2 ./tb icarus: iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v vvp tb.qqq >> /dev/null rm tb.qqq verilate: verilator -cc ppu.v cd obj_dir && make -f Vppu.mkINC=/usr/share/verilator/include Как можно отметить, здесь, помимо верилятора, используется также icarus и это нормально. Остается добавить следующие файлы.
-
tb.v
— для тестирования работы процессора и видеопроцессора через icarus -
tb.cc
иtb.h
— здесь будет эмулятор приставки -
ppu.v
— видеопроцессор
Порассуждаю просто на эту тему. Строго говоря, необходимо сделать связку из нескольких модулей, это CPU — центральный процессор, PPU — видеопроцессор и APU — звуковой процессор. Каждый из них я буду делать по отдельности, но нельзя забывать, что все они работают в одной связке. На один такт центрального процессора приходится по 3 такта видеопроцессора. Поскольку я буду работать все-таки с VGA, то базовая моя частота будет составлять 25 мгц. Но это не беда, я придумал как сделать так, чтобы все хорошо работало.
Ширина строки для VGA, для разрешения 640 x 480, составляет 800 пикселей, и количество всех строк 525. Это дает 800 x 525 x 60 = 25.2 Мгц. Чуть выше 25 мгц, не страшно. Для Денди видеоразрешение составляет 341 x 262, что гораздо меньше, чем выводится на VGA. Соответственно, 341 x 262 x 60 ~ 5.36 Мгц.
Как быть в этом случае? Видимая область у Денди 256 по ширине и 240 по высоте. Как видно, можно увеличить вдвое каждый пиксель и сделать видимой область 512 x 480. По высоте идеально помещается, а по ширине нет, придется отступить слева и справа по 64 пикселя, которые будут бордюром.
§ Базовый каркас SDL2
Уже достаточно давно я использую такой каркас для создания разных своих приложений. Напишем для начала простой makefile, с помощью которого просто будет компилироваться.all: g++ -o tb tb.cc -lSDL2 ./tbЗдесь лишь только происходит компиляция tb.cc в исполняемый файл, с подключением libSDL2-dev. Ниже представлен файл
tb.cc
, основная программа эмулятора.#include "tb.h" int main(int argc, char** argv) { TB* tb = new TB(argc, argv); while (tb->main()) { } return tb->destroy(); }То что я везде все называю
tb (test bench)
— это уже привычка такая. Звучит в целом неплохо. Подключается файл tb.h
, который содержит реализацию класса, в самом теле программы создается новый класс и вращается в цикле до тех пор, пока не будет нажата кнопка "закрыть", и в самом конце программы будет вызван метод destroy
.Упрощенно, класс будет выглядеть так.
#include <SDL2/SDL.h> class TB { protected: SDL_Window* sdl_window; SDL_Renderer* sdl_renderer; SDL_Surface* sdl_screen_surface; SDL_Texture* sdl_screen_texture; SDL_Event sdl_evt; Uint32* sdl_screen_buffer; Uint32 pticks; public: TB(int argc, char** argv); int main(); void update(); int destroy(); void frame(); void pset(int x, int y, Uint32 cl); void vga(int hs, int vs, int color); };В нем пока что ничего не делается. Первым делом, требуется создать инициализировать SDL2 и создать окно необходимого размера, а именно 640 x 480. Здесь целая куча всяких переменных, которые потребуется некоторым образом инициализировать и делать я это буду в конструкторе класса.
TB::TB(int argc, char** argv) { if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO)) { exit(1); } SDL_ClearError(); sdl_window = SDL_CreateWindow("NES", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1280, 960, SDL_WINDOW_SHOWN); sdl_renderer = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_PRESENTVSYNC); sdl_screen_buffer = (Uint32*) malloc(640 * 480 * sizeof(Uint32)); sdl_screen_texture = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, 640, 480); SDL_SetTextureBlendMode(sdl_screen_texture, SDL_BLENDMODE_NONE); pticks = 0; }Алгоритм работы.
- Инициализация SDL. Видео и аудио. Если не получилось инициализировать хоть один, то происходит выход из программы с кодом ошибки 1.
- Создается окно с названием "NES", размером 1280x960, располагается по центру SDL_WINDOWPOS_CENTERED, и сразу же показывается
- На основе окна создается рендерер, учитывающий вертикальную синхронизацию, чтобы не "ехало" ничего во время перемещения окна
- Создание в памяти области sdl_screen_buffer, это и будет то, откуда будут копироваться пиксельные данные
- На основе рендерера создается текстура 640x480, 32-х битный цвет
- И устанавливается ей стандартный режим смешивания, то есть, никакого
- А также очищается таймер pticks, который будет отсчитывать время между кадрами
int TB::destroy() { free(sdl_screen_buffer); SDL_DestroyTexture(sdl_screen_texture); SDL_DestroyRenderer(sdl_renderer); SDL_DestroyWindow(sdl_window); SDL_Quit(); return 0; }Реализация функции main будет немного сложнее.
int TB::main() { for (;;) { Uint32 ticks = SDL_GetTicks(); // Прием событий while (SDL_PollEvent(& sdl_evt)) switch (sdl_evt.type) { case SDL_QUIT: return 0; } // Обновление экрана 60 FPS if (ticks - pticks >= 16) { pticks = ticks; update(); return 1; } SDL_Delay(1); } }Как это работает: с помощью ticks получается текущее время и сверяется с предыдущим значение в pticks. Если с момента последнего замера прошло 16 миллисекунд, то записывается новое значение в pticks и вызывается функция update. Перед этим принимаются все события, которое произошли за последний период. Здесь я устанавливаю SDL_Delay(1) для того, чтобы не вызывать нагрузку процессора на 100%. Мне это точно ни к чему.
void TB::update() { SDL_Rect dstRect; dstRect.x = 0; dstRect.y = 0; dstRect.w = 1280; dstRect.h = 960; SDL_UpdateTexture (sdl_screen_texture, NULL, sdl_screen_buffer, 640 * 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 TB::pset(int x, int y, Uint32 cl) { if (x < 0 || y < 0 || x >= 640 || y >= 480) return; sdl_screen_buffer[640*y + x] = cl; }Помним о том, что здесь экран увеличен вдвое, чтобы глаза не ломать и видеть все насквозь. Очень много кода выше, но это необходимый базовый минимум.
§ Простейший видеоадаптер на верилог
Я уже десятки раз писал один и тот же код, можно ознакомиться с ним, например, тут, так что повторю только очень вкратце.На картинке изображены области рисования. Там где синий цвет — это области горизонтальной синхронизации, там где зеленый — вертикальной. Белым цветом обозначены задний и передний порожек. Задний порожек по горизонтали находится слева, передний справа. По вертикали задний порожек находится сверху, и передний, соответственно, снизу.
Для видеоадаптера 640 x 480 тайминги будут такими.
Горизонталь | Вертикаль | |
---|---|---|
Задний порожек | 48 | 33 |
Видимая область | 640 | 480 |
Передний порожек | 16 | 10 |
Синхронизация | 96 | 2 |
Общее | 800 | 525 |
Полярность | Негатив | Негатив |
ppu.v
, который просто работает.module ppu ( input clock, output reg [3:0] r, output reg [3:0] g, output reg [3:0] b, output hs, output vs ); // --------------------------------------------------------------------- assign hs = X < (48 + 640 + 16); assign vs = Y < (33 + 480 + 10); // --------------------------------------------------------------------- reg [10:0] X = 0; reg [ 9:0] Y = 0; wire xmax = (X == 799); wire ymax = (Y == 524); wire [10:0] x = (X - 48); wire [ 9:0] y = (Y - 33); // --------------------------------------------------------------------- always @(posedge clock) begin // Черный цвет вне окна {r, g, b} <= 12'h000; // Кадровая развертка X <= xmax ? 0 : X + 1; Y <= xmax ? (ymax ? 0 : Y + 1) : Y; // Вывод окна if (X >= 48 && X < 48 + 640 && Y >= 33 && Y < 33 + 480) {r, g, b} <= 12'h888; end endmoduleВ этом коде формируются сигналы
vs, hs
в конце столбцов и строк, а также выводится серый цвет в видимую область. Чтобы скомпилировать данный файл, обновим наш скромный makefile.all: verilate g++ -o tb tb.cc $(LIB) -lSDL2 ./tb verilate: verilator -cc ppu.v cd obj_dir && make -f Vppu.mkINC=/usr/share/verilator/include LIB=-I$(INC) $(INC)/verilated.cpp obj_dir/Vppu__ALL.a В первой строчке указан жесткий путь к библиотекам верилятора, в линуксе они там обычно и лежат. Если это не так, то можно поменять на другой путь. Во второй строчке дается указание компилятору подключить библиотечную директорию, файл
verilated.cpp
, а также наш скомпилированный ppu.v
, который компилируется в правиле verilate
двумя строчками.§ Встраиваем верилятор в код
Теперь необходимо полученный компилированный файл встроить в код. Как видно, он находится вobj_dir/Vppu__ALL.a
, и является архивом для подключения к g++. В связи с этим обновляем main-файл.#include "obj_dir/Vppu.h" #include "tb.h" int main(int argc, char** argv) { Verilated::commandArgs(argc, argv); TB* tb = new TB(argc, argv); while (tb->main()) tb->frame(); return tb->destroy(); }Из нового здесь добавлен заголовочный файл Vppu.h, включен
Verilated::commandArgs
и в цикл добавилась обработки tb->frame(). Теперь приступим к реализации метода frame
.Первым делом, добавим в объявление методов новый объект:
Vppu* ppu;А также в конструкторе класса добавится его инициализация:new Vppu;ppu = Теперь же подойдем к непосредственно, обработчику фрейма:
void TB::frame() { Uint32 timet = SDL_GetTicks(); // 25 mhz / 60 fps ~ 415000 for (int i = 0; i < 415000; i++) { ppu->clock = 0; ppu->eval(); ppu->clock = 1; ppu->eval(); vga(ppu->hs, ppu->vs, 65536*(ppu->r*16) + 256*(ppu->g*16) + (ppu->b*16)); } timet = SDL_GetTicks() - timet; }Здесь я замеряю время в миллисекундах
timet
, чтобы понять, за сколько времени получилось обработать один фрейм. На один фрейм получится 1000/60 = 16 мс. На моем компе, однако, он обрабатывается в пределах 18-20 мс. За одну секунду выполняются 25 000 000 тактов, а значит, за 1 кадр должно выполниться 415 тысяч тактов. Для того, чтобы полностью выполнить 1 цикл, необходимо сначала установить clock=0, после чего вызвать eval() и далее, установить 1 и снова вызвать eval(), и тогда эмулятор верилятора выполнит то, что должно было выполняться на позитивном фронте в модуле ppu.Теперь же остается сделать так, чтобы видеосигнал каким-то образом снимать и выводить его на виртуальном экране. Для этого как раз и передаются данные в метод
vga
, где передается горизонтальная, вертикальная синхронизация, а также цвет точки.Осталось сделать только обработку входящего сигнала так, чтобы имитировался ход луча. Это делается очень просто.
void TB::vga(int hs, int vs, int color) { if (hs) x++; // Отслеживание изменений HS/VS if (_hs == 0 && hs == 1) { x = 0; y++; } if (_vs == 0 && vs == 1) { x = 0; y = 0; } // Вывод на экран pset(x - 48, y - 33, color); // Сохранить предыдущее значение _hs = hs; _vs = vs; }Как работает этот код.
- Каждый новый такт добавляет +1 к положению луча по X, поскольку частота пиксель-клока составляет 25 Мгц
- Здесь значения _hs и _vs — это предыдущие значения hs и vs
- Если новое значение hs стало 1, а было 0, то луч перекидывается назад (горизонтальная синхронизация), а значение Y увеличивается на +1, имитируя ход луча слева направо, сверху вниз
- Аналогично, если vs было 0, а стало 1, то луч переходит в самый верхний левый угол, устанавливая X = 0 и Y = 0
- Выводится пиксель на экран, учитывая горизонтальный задний порожек (48 пикселей не выводим), и вертикальный задний порожек (33 пикселя).
protected: int x, y, _hs, _vs;А также инициализировать в конструкторе.
0; _hs = _vs = 1;x = y = Значения _hs и _vs обязательно должны быть равным 1, чтобы первый кадр не отрисовывался криво из-за того, что будет ложноположительное срабатывание горизонтальной и вертикальной синхронизации.
Теперь, если собрать этот код и скомпилировать, на выходе мы получим серый экран, как и должно быть. Обращу только внимание на одну интересную деталь. Слева на экране есть полоска черного цвета, которая выводится из-за не совсем правильно написанного кода в ppu.v. Там используется регистр для записи следующей пикселя, поэтому на столбце 48 появляется черный цвет — от предыдущего значения регистра, который равен 0. Если код немного изменить, сдвинув на 1 влево, то все будет в порядке. Либо же можно поменять на
pset(x-49, y-33, color)
, что будет равнозначным решением. Для экрана монитора такой сдвиг на 1 пиксель не играет роли, все равно при автоподстройке он исчезнет. Поэтому, чтобы исправить этот небольшой баг, я поставлю x-49
вместо x-48
.Исходный файл проекта