§ Для чего?

Какую задачу я ставлю? Как и обычно, просто рассказать о том, как я в очередной раз создаю очередной процессор, и еще как-то начать его использовать для некоторых целей. Например, написать программы для него, создать управляющий софт, да и просто для души.
Итак, я снова начинаю свой дневник разработки процессора, похожего на процессор Z80. Отличия будут в том, что:
  • Количество инструкции будет неполным
  • Частота работы процессора — 25 мгц (моя типовая частота)
  • Количество тактов на инструкцию будут не совпадать с оригиналом
В этом цикле статей я последовательно расскажу о том, как с нуля создать процессор, похожий на Z80, симуляции в icarus/gtkwave, а также симуляции с верилятором в связке с SDL.

§ Самый первый testbench

Прежде чем приступать к разработке процессора, нужно создать первичный файл tb.v, который я буду далее тестировать с помощью компилятора icarus verilog. Как работает icarus? С его помощью можно откомпилировать файл, после чего выполнить симуляцию проекта и просмотреть результаты работы verilog-кода в программе gtkwave. Но для этого, нужно сделать тест-бенч tb.v:
1`timescale 10ns / 1ns
2module tb;
3
4// -----------------------------------------------------------------------------
5reg clock;      always #0.5 clock    = ~clock;
6reg clock_25;   always #1.0 clock_25 = ~clock_25;
7// -----------------------------------------------------------------------------
8initial begin clock = 0; clock_25 = 0; #2000 $finish; end
9initial begin $dumpfile("tb.vcd"); $dumpvars(0, tb); end
10// -----------------------------------------------------------------------------
11
12endmodule
Он у меня всегда выглядит типично. Создается два источника тактовых частот clock — 100 mhz, clock_25 — 25 mhz, соответственно. Это виртуальные пины генерации частоты. Изначально они установлены в 0 в секции initial, и продолжительность всей симуляции указывается в #2000 $finish, что говорит о том, что симуляция составляет 10 ns * 2000 = 20 микросекунд. Один отсчет устанавливается в параметре timescale в самой первой строке, и он равен 10 наносекунд.
После компиляции проекта, дамп всех регистров ($dumpvars) из модуля tb выгружается в дампфайл ($dumpfile) по имени tb.vcd.
Перейдем к файлу makefile, который я активно использую для компиляции проектов. Для win-платформ такой файл тоже работает, если установить необходимые инструменты. На всякий случай, можно также использовать bat-файлы для этого, но только я привык работать с линукса, потому буду делать именно так.
1all:
2	iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v
3	vvp tb.qqq >> /dev/null
4	rm tb.qqq
В makefile крайне важно, чтобы отступы справа были именно табами, а не пробелами, так что это надо обязательно учитывать и проверять.
В файле сборки выполняются три команды.
Команда первая iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v
  • iverilog это программа-компилятор, ее сначала надо установить
  • Опция -g2005-sv означает, что можно использовать system-verilog файлы и синтаксис. На всякий случай включаю всегда
  • Опция -DICARUS=1 передает define ICARUS 1 в файл tb.v и во все вложенные файлы, если они будут
  • Опция -o tb.qqq указывает, что скомпилированные файлы будут находится в файле tb.qqq
  • Далее идут перечисления всех файлов, первый из которых tb.v будет является главным файлом, а последующие - модулями
Строка с командой vvp tb.qqq >> /dev/null выполняет симуляция скомпилированного файла tb.qqq в tb.vcd, указанному в тестбенче, и третья строка rm tb.qqq удаляет временный файл, поскольку он более не нужен будет далее.

§ Просмотр в gtkwave

Теперь, после того как получилось выполнить симуляцию проекта, его можно просмотреть в gtkwave. Для начала, нужно, чтобы эта программа вообще была установлена. Если это так, то из консоли папки с проектом надо запустить сначала следующую команду:
gtkwave tb.vcd
После запуска, откроется чистое окно, где потребуется добавить необходимые нам провода, за которыми будет вестись наблюдение.

Чтобы добавить провод на схему справа, нужно нажать правой кнопкой мыши на список проводов слева снизу в панели и нажать Append. В случае если необходимо поменять цвет у сигнала, нужно нажать в списке сигналов (панель посередине) правой кнопкой мыши, появится выпадающее меню, там выбрать Color Format и необходимый цвет. На картине я выбрал оранжевый вместо зеленого для clock.
После всех действий можно сохранить проект через комбинацию клавиш CTRL+S. Обычно я сохраняю в tb.gtkw и далее уже вызываю именно этот файл вместо tb.vcd.

§ SDL-приложение

Поскольку я буду делать процессор не только для icarus verilog, но также проверять его в verilator, для этого мне потребуется окно вывода для видеоадаптера собственного изготовления. Можно было бы не делать, но я все-таки, сделаю, потому что этот способ оказался наиболее удобным.
Тестовый и простой видеоадаптер будет иметь разрешение 640x400, но сам по себе вывод на экран будет в окне 256x192, но это не спектрумский экран, а текстовый, и его разрешение 32x24, что само по себе составляет 768 символов. Для начала — хватит. Количество символов не превышает 1кб. В данный момент текстовый режим полностью черно-белый, только 2 цвета. Для видеоадаптера требуется знакогенератор 8x8 на 256 символов. Каждый символ занимает 8 байт по 8 бит, и значит, потребуется 2Кб памяти ПЗУ для хранения этих данных. По итогу, нужно будет 3Кб памяти для тестового видеоадаптера. Еще раз повторю, что это минимальный видеоадаптер, потому что возможно, что он может потом как-то меняться в будущем на другой.
Пожалуй, начнем создание приложения tb.cc.
1#include "tb.h"
2
3int main(int argc, char* argv[]) {
4
5    App* app = new App();   // Создать окно приложения
6    while (app->main()) {}  // Выполнение цикла каждые 1/50 сек
7    return app->destroy();  // Закрыть окно
8}
В этом коде нет ничего сложного. Вначале подключается класс tb.h, в котором будет описан class App, отвечающий за работу приложения. В цикле while, а именно в методе main класса App будет ожидание 20 мс, после чего этот метод возвратит 1, что означает что необходимо обработать еще один фрейм. Я выбрал 1/50 в дань 25 (или 50 Гц) экранам. На самом же деле, экран VGA обновляется в 60 Гц, но здесь это заметно будет очень мало, да и еще потому что 1000 делить на 50 будет намного удобнее, чем на 60.
Если метод main возвращает 0, то это признак того, что окно надо закрыть и выйти из приложения.
Ниже приведу код самого минимального класса, который бы заработал в данном контексте:
1#include <SDL2/SDL.h>
2#include <stdlib.h>
3#include <stdio.h>
4
5class App {
6protected:
7public:
8
9    void frame();
10    int  main()    { return 0; }
11    void update()  { }
12    int  destroy() { return 0; }
13    void pset(int x, int y, Uint32 color) { }
14};
Этот код пока что ничего не выполняет. Далее по тексту буду дописывать и дополнять код в методах.
[~] Кстати, хочу сразу сказать, что то, как я пишу код сейчас — очень убого и плохой стиль кода, но мне все равно. Дело в том, что я не хочу лишний раз разбивать код на разные файлы и части.
Приведу обновленный makefile:
1all: icarus app
2icarus:
3	iverilog -g2005-sv -DICARUS=1 -o tb.qqq tb.v
4	vvp tb.qqq >> /dev/null
5	rm tb.qqq
6app:
7	g++ -o tb tb.cc -lSDL2
8	strip tb
Как он работает? Первым делом, при запуске задачи all (или запуск make без параметров), будет выполнены две подзадачи icarus и app. С первой задачей выше уже было разобрано, а со второй все просто. Компилируется файл tb.cc, результат выдается в tb, причем захватывая стадию как компиляции, так и линковки, с подключением библиотеки SDL2.

§ Создание и уничтожение окна SDL

Для того, чтобы создать минимально рабочий код, я разделю его на три части:
1) Объявление переменных, в которых будет храниться данные о буферах SDL
2) Открытие окна в конструкторе класса
3) Закрытие окна в методе destroy
Объявляются переменные в классе App, в области protected:
1protected:
2
3    SDL_Window*         sdl_window;           // Объект окна SDL
4    SDL_Renderer*       sdl_renderer;         // Класс объекта-рисовщика на окне
5    SDL_Texture*        sdl_screen_texture;   // Текстура с экранным буфером
6    Uint32*             screen_buffer;        // Область памяти, где хранятся данные для текстуры
Собственно, здесь и так в комментариях я достаточно объяснил, что к чему, потому сразу переходим к коду конструктора класса. Сразу же замечу, что создается окно размером 1280 на 800 (вдвое больше), а не 640 на 400, поскольку разглядеть пиксели такого размера очень сложно на современных дисплеях, которые имеют разрешение как минимум 1920x1080, так что размер окна будет именно таким.
1App() {
2
3    if (SDL_Init(SDL_INIT_VIDEO)) {
4        exit(1);
5    }
6
7    screen_buffer = (Uint32*) malloc(640 * 400 * sizeof(Uint32));
8    sdl_window    = SDL_CreateWindow("M80", SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED, 1280, 800, SDL_WINDOW_SHOWN);
9    sdl_renderer  = SDL_CreateRenderer(sdl_window, -1, SDL_RENDERER_PRESENTVSYNC);
10    sdl_screen_texture = SDL_CreateTexture(sdl_renderer, SDL_PIXELFORMAT_BGRA32, SDL_TEXTUREACCESS_STREAMING, 640, 400);
11    SDL_SetTextureBlendMode(sdl_screen_texture, SDL_BLENDMODE_NONE);
12}
Теперь распишу то, что происходит в коде:
  • Инициализация SDL, запрашивается поддержка поддержка видео. Если такой поддержки нет, то ответ функции будет не нулевой и программа досрочно завершится
  • Создается screen_buffer для буфера экрана, одна точка в котором занимает 32-х битное беззнаковое целое
  • При помощи функции SDL_CreateWindow создается окно с именем "M80", центрированное по x и y, размером 1280 x 800, окно сразу же показывается
  • Функция SDL_CreateRenderer создает рендерер для окна sdl_window, где -1 выбирает первый доступный драйвер для рендера, а опция SDL_RENDERER_PRESENTVSYNC указывает, что обновляться окно будет только при достижении вертикальной синхронизации. Это позволяет избежать "разрезания" окна при перемещении его. Есть такой не очень приятный артефакт
  • Объект sdl_screen_texture опирается на рендерер, задает параметры текстуры, которая будет рендерится на окне. Текстура имеет формат BGRA, где B-компонента (синий цвет) является младшим байтом, и A-компонента (Alpha-канал) находится в старшем байте. Опция SDL_TEXTUREACCESS_STREAMING означает, что текстура обновляется прямо из памяти, который доступен процессору, в видеопамять, не требуя постоянного обновления видеопамяти. Текстура имеет размер 640 x 400.
  • И последняя процедура SDL_SetTextureBlendMode устанавливает режим смешивания для текстуры для работы альфа-канала, а именно - никакого смешивания, только чистый вывод текстуры на экран
Метод destroy() так же достаточно прост, и даже не требует пояснений.
1int destroy() {
2
3    free(screen_buffer);
4    SDL_DestroyTexture  (sdl_screen_texture);
5    SDL_DestroyRenderer (sdl_renderer);
6    SDL_DestroyWindow   (sdl_window);
7    SDL_Quit();
8    return 0;
9}

§ Главный цикл ожидания события SDL

Постепенно переходим к основному циклу, где будут как фиксироваться входящие события, такие как нажатие на клавишу или движение, клик мыши, закрытия окна (и многих других), так и ожидающий 20 мс, после истечения этого времени происходит выход из процедуры main для последующей передачи управления в тело цикла while в главном файле tb.cc.
Ниже я приведу код метода main класса App:
1int main() {
2
3    SDL_Event evt;
4
5    for (;;) {
6
7        Uint32 ticks = SDL_GetTicks();
8
9        // Обработать все новые события
10        while (SDL_PollEvent(& evt)) {
11
12            switch (evt.type) {
13
14                // Выход из программы
15                case SDL_QUIT: return 0;
16            }
17        }
18
19        // Истечение таймаута: обновление экрана
20        if (ticks - pticks >= 20) {
21
22            pticks = ticks;
23
24            frame();   // Вызов обработчика кадра
25            update();  // Вызов обновления экрана
26
27            return 1;
28        }
29
30        SDL_Delay(1);
31    }
32}
Теперь объясняю то, как работает алгоритм. Сам по себе код крутится в бесконечном цикле, в теле которого есть 2 части:
  • Опрос всех новых пришедших событий из SDL (процедура SDL_PollEvent). Единственное событие, которое обрабатывается — это выход, то есть, нажатие на "крестик" в окне. Тип события — SDL_QUIT, при достижении которого функция main возвращает 0, что завершает по итогу программу.
  • Вторая часть данного бесконечного цикла заключается в проверке, чтобы прошло 20 мс с последнего момента вызова. Если это произошло, то устанавливается новое время в pticks, который, между прочим, требуется задать в объявлении класса:
1int pticks = 0;
Если событие не произошло и все события SDL были успешно обработаны, то для предотвращения загрузки процессора на 100% я делаю вызов SDL_Delay(1); который ожидает 1 мс. Этим методом, я снижаю ужасную нагрузки на CPU. Если не сделать, то будет плохо. Вот так.
  • Вызов обработчика кадра — процедура, которая исполняет действия, например, симуляция тактов процессора, количество которых равно одному фрейму (кадру)
  • Вызов обновления экрана — процедура обновления экрана.
Обработчик кадра вызывается отдельно и я его позже реализую в tb.cc:
1void App::frame() { /* Заглушка */ }
Сейчас можно поставить лишь заглушку в виде метода, который никак не реализован.
Перейду к реализации метода update() для копирования из памяти в окно:
1void update() {
2
3    SDL_Rect dstRect;
4
5    dstRect.x = 0;
6    dstRect.y = 0;
7    dstRect.w = 1280;
8    dstRect.h = 800;
9
10    SDL_UpdateTexture       (sdl_screen_texture, NULL, 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}
Вначале проставляется размерность той области, куда именно будет выведена текстура, то есть, на полную поверхность окна (1280 x 800).
  • SDL_UpdateTexture обновляет текстуру из памяти (screen_buffer), указывается длина одной строки в байтах, здесь это будет 640*4 байт
  • SDL_SetRenderDrawColor — цвет отрисовки по умолчанию
  • SDL_RenderClear — очистка области рисования
  • SDL_RenderCopy — нарисовать данные из буфера
  • SDL_RenderPresent — передать нарисованные пиксели на экран
И последнее, что сегодня будет реализовано, это базовая процедура рисования точки в памяти (буфер экрана):
1void pset(int x, int y, Uint32 color) {
2
3    if (x < 0 || y < 0 || x > 640 || y >= 400)
4        return;
5
6    screen_buffer[y*640 + x] = color;
7}
Думаю, тут достаточно все очевидно.
Сегодня получилось создать минимальное SDL-приложение и минимальный код на верилоге для проверки. На этом, сегодня я завершу первые приготовления к следующему этапу, о котором я расскажу в следующей статье.
Скачать исходники к проекту