Оглавление
§ Настройка
Перед разработкой кода всегда необходимо правильно настроиться и настроить окружающих. Для тестирования кода я буду использовать верилятор (verilator), который запускается только на linux, и icarus verilog (он работает везде). Как и обычно, начнем с того, что создадим простые файлы. К примеру makefile:
INC=/usr/share/verilator/include
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.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, с помощью которого просто будет компилироваться.
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;
}
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 |
| Полярность | Негатив | Негатив |
|---|
Что такое негативная полярность? Это просто. На выходе HSync и VSync будет 0 во время как горизонтальной (96 тактов), так и вертикальной (2 строки) синхронизации. Сразу приведу сюда код на верилоге видеоадаптера 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.
INC=/usr/share/verilator/include
LIB=-I$(INC) $(INC)/verilated.cpp obj_dir/Vppu__ALL.a
all: verilate
g++ -o tb tb.cc $(LIB) -lSDL2
./tb
verilate:
verilator -cc ppu.v
cd obj_dir && make -f Vppu.mk
В первой строчке указан жесткий путь к библиотекам верилятора, в линуксе они там обычно и лежат. Если это не так, то можно поменять на другой путь. Во второй строчке дается указание компилятору подключить библиотечную директорию, файл 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.
Первым делом, добавим в объявление методов новый объект:
А также в конструкторе класса добавится его инициализация:
Теперь же подойдем к непосредственно, обработчику фрейма:
void TB::frame() {
Uint32 timet = SDL_GetTicks();
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++;
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;
А также инициализировать в конструкторе.
x = y = 0;
_hs = _vs = 1;
Значения _hs и _vs обязательно должны быть равным 1, чтобы первый кадр не нарисовался криво из-за того, что будет ложноположительное срабатывание горизонтальной и вертикальной синхронизации.
Теперь, если собрать этот код и скомпилировать, на выходе мы получим серый экран, как и должно быть. Обращу только внимание на одну интересную деталь. Слева на экране есть полоска черного цвета, которая выводится из-за не совсем правильно написанного кода в ppu.v. Там используется регистр для записи следующей пикселя, поэтому на столбце 48 появляется черный цвет – от предыдущего значения регистра, который равен 0. Если код немного изменить, сдвинув на 1 влево, то все будет в порядке. Либо же можно поменять на pset(x-49, y-33, color), что будет равнозначным решением. Для экрана монитора такой сдвиг на 1 пиксель не играет роли, все равно при автоподстройке он исчезнет. Поэтому, чтобы исправить этот небольшой баг, я поставлю x-49 вместо x-48.
§ Тайминги
Дело в том, что укладываться я буду не в 341 x 262 пикселей, а в 800 x 525, поэтому пиксели будут размером 2x2, то есть, удвоение. Что это значит? То, что в целом в линию вмещается 341 x 2 = 682, то меньше 800 и это очень хорошо. В предыдущей главе я рассказал, что область рисования будет проходить на самом деле, в 256 x 240 области, а 341 x 262 – это общее количество, включая горизонтальную, вертикальную синхронизации и разного рода порожеки.
Моя задача это правильно уложиться в отведенные тайминги.
[задний порожек 48 пк] [ видимая область 640 пикселей ] [передний порожек 16] [синхронизация 96]
Здесь показаны тайминги для строки VGA. Но требуется сделать так, чтобы в видимой области рисовались 256 пикселей, умноженные на 2.
[бордер слева 64] [512 видимая область] [бордер справа 64]
Скорость, с которой будет работать видевывод, будет 25 мгц, и отрабатывать будет следующим образом:
- Задний порожек 48 – пропуск
- Бордер слева 64 – пропуск, рисование цвета заднего фона
- Видимая область 512 пк, по 256 тактов PPU
- Бордер справа – обработка 64 тактов PPU
- Передний порожек 16 – обработка 16 тактов PPU
- Синхронизация 5 PPU + 91 такт пропуск
Вот такая вот будет сложная схема обработки одной строки. Помимо этого, в память будет записываться временное значения для отрисовки второй строки, поскольку требуется удвоение не только по X, но и по Y. Когда будет рисоваться вторая строка, ни PPU, ни CPU работать не должны. Кстати говоря, 1 такт CPU = 3 такта PPU.