Оглавление


§ Настройка

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

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

Я уже десятки раз писал один и тот же код, можно ознакомиться с ним, например, тут, так что повторю только очень вкратце.
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, который просто работает.
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.
Исходный файл проекта