Оглавление


§ Настройка

Перед разработкой кода всегда необходимо правильно настроиться и настроить окружающих. Для тестирования кода я буду использовать верилятор (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;
        }

        // Обновление экрана 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;
}
Помним о том, что здесь экран увеличен вдвое, чтобы глаза не ломать и видеть все насквозь. Очень много кода выше, но это необходимый базовый минимум.

§ Простейший видеоадаптер на верилог

Я уже десятки раз писал один и тот же код, можно ознакомиться с ним, например, тут, так что повторю только очень вкратце.
Snimok_ekrana_ot_2023-05-30_08-34-23.png
На картинке изображены области рисования. Там где синий цвет — это области горизонтальной синхронизации, там где зеленый — вертикальной. Белым цветом обозначены задний и передний порожек. Задний порожек по горизонтали находится слева, передний справа. По вертикали задний порожек находится сверху, и передний, соответственно, снизу.
Для видеоадаптера 640 x 480 тайминги будут такими.
ГоризонтальВертикаль
Задний порожек4833
Видимая область640480
Передний порожек1610
Синхронизация962
Общее800525
ПолярностьНегативНегатив
Что такое негативная полярность? Это просто. На выходе 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.
Первым делом, добавим в объявление методов новый объект:
Vppu* ppu;
А также в конструкторе класса добавится его инициализация:
ppu = new Vppu;
Теперь же подойдем к непосредственно, обработчику фрейма:
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;
А также инициализировать в конструкторе.
    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.
Исходный файл проекта