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