§ Какие изменения

Сегодня я расширю возможности видеоадаптера, сделав его приблизительно равным стандартному текстовому режиму, который есть в MSDOS, например.
  • Сделаны все 256 символов CP866
  • Будет добавлен цвет в знакоместа
  • Мерцание символов
  • Курсор

§ Устройство видеобуфера

Как видимо, видеобуфер придется увеличить в 2 раза, а также в 2 раза увеличить знакогенератор, если 256 символов и каждый знак занимает 16 байт, то получится 4096 байт. Видеобуфер устроен таким образом, что одно знакоместо занимает 2 байта. Первый байт это сам знак, а второй байт это цвет этого знака.
7 | 6 5 4 | 3 2 1 0
B | Фон   | Цвет
Цвет отвечает за рисуемый пиксель, а фон, наоборот, за пиксель, который равен 0. B — это признак мерцания цвета, то есть, когда установлен B=1, то каждые полсекунды цвет убирается и превращается в фоновый.
Вообще цвета забираются из палитры, но палитру я пока делать не буду, потому все цвета будут жестко закреплены:
0  Черный        1 Синий       2 Зеленый       3 Бирюзовый
4  Красный       5 Пурпурный   6 Коричневый    7 Серый
8  Темносерый    9 Ярко-синий 10 Ярко-зеленый 11 Голубой
12 Ярко-красный 13 Розовый    14 Желтый       15 Белый

§ Перенос методов в класс App

Для начала, обновлю font.bin, добавив туда оставшиеся 128 знаков, но я хочу еще провести некоторую реорганизацию кода. Первое, я перенесу код инициализации видеопамяти в конструктор класса App:
1FILE* fp = fopen("font.bin", "rb");
2fread(vmemory + 4096, 1, 4096, fp);
3fclose(fp);
А сам vmemory добавлю в protected-область:
1protected:
2    ...
3    unsigned char vmemory[8192];
4    ...
5    Vvga* vga_mod;
6    Vps2* ps2_mod;
7    ...
8    int ps_clock, ps_data;
Поскольку нет более смысла хранить объекты модулей vga, ps2 в main.cc, я их также перенесу в класс, и выполню их инициализацию там же. Тут же, рядом с инициализацией, выполним заполнение тестовыми данными
1vga_mod = new Vvga;
2ps2_mod = new Vps2;
3
4ps_clock = 1;
5ps_data  = 1;
6
7for (int i = 0; i < 4096; i += 2) {
8
9    vmemory[i]   = i & 255; // Символ
10    vmemory[i+1] = 0x17;    // Синий фон, серые буквы
11}
И конечно, придется из-за изменений сделать большие замены в методе:
1void tick25() {
2
3    ps2_mod->clock = 0; ps2_mod->eval();
4    vga_mod->clock = 0; vga_mod->eval();
5
6    // Чтение из видеопамяти
7    vga_mod->data = vmemory[ vga_mod->address ];
8
9    // Эмуляция PS/2 кнопки
10    kbd_pop(ps_clock, ps_data);
11
12    // Контроллер PS/2
13    ps2_mod->ps_clock = ps_clock;
14    ps2_mod->ps_data  = ps_data;
15    if (ps2_mod->done) printf("%02x ", ps2_mod->data);
16
17    ps2_mod->clock = 1; ps2_mod->eval();
18    vga_mod->clock = 1; vga_mod->eval();
19
20    // Видеоконтроллер
21    vga(vga_mod->hs, vga_mod->vs, vga_mod->r*(128*65536) + vga_mod->b*(128*256) + vga_mod->g*128);
22}
И сам main.cc выглядит теперь так:
1#include <stdlib.h>
2#include "obj_dir/Vvga.h"
3#include "obj_dir/Vps2.h"
4#include "app.cc"
5
6int main(int argc, char **argv) {
7
8    Verilated::commandArgs(argc, argv);
9    App* app = new App();
10
11    while (app->main()) {
12
13        // 125k тактов 20 раз в секунду (2.5 Мгц)
14        for (int i = 0; i < 125000; i++) {
15            app->tick25();
16        }
17
18        if (Verilated::gotFinish()) break;
19    }
20
21    app->destroy();
22    return 0;
23}
Я закреплю файл на php, с помощью которого я генерирую данные для font.bin и font.mif. Это может пригодится.

§ Модификация для вывода символов

Для начала я сделаю небольшую модификацию существующего кода vga.v для того, чтобы скорректировать вывод символов так, чтобы они вообще выводились.
Как я ранее рассказывал, теперь на одно знакоместо требуется 2 байта. Ну и придется в два раза увеличить память (с 4к до 8к), а значит, в адресную шину добавить +1 бит:
1module vga
2(
3    ...
4    output reg [12:0] address,
5    ...
6);
А теперь, какие изменения необходимо сделать в коде always, чтобы получить результат:
1case (xv[2:0])
2
3    3'h0: begin address <= xv[9:3]*2 + yv[8:4]*160; end
4    3'h1: begin address <= {1'b1, data, yv[3:0]}; end
5    3'h7: begin rmask   <= data;  end
6
7endcase
  • В такте 0 умножаем на 2 (то есть, это будет просто сдвиг влево), что будет означать, что на одно знакоместо будет 2 байта.
  • В такте 1 меняем размерность data[6:0] (7 бит) на data (8 бит); также, убираем запись в tcolor — потому что уже не требуется
  • И в такте 2 убирается запись в rcolor. Так что два регистра rcolor / tcolor можно уже удалить из модуля.
И эти два небольших изменения уже покажут правильную картинку на экране, правда, пока что черно-белую. А теперь уберем выставление инверсного цвета:
1wire pix = rmask[ ~xv[2:0] ];
Результатом, после всех модификации, будет вот такой экран:

§ Добавление цветности

Итак, пришло время для вычисления цветов. Теперь, раз мы собираемся делать 16 цветов, придется расширить количество цветов на выходе вдвое и сделать исходящие данные шире, чтобы получилось вывести нужную палитру.
1module vga
2(
3    input               clock,
4    output [1:0]        r,
5    output [1:0]        g,
6    output [1:0]        b,
7    output              hs,
8    output              vs,
9    output reg [12:0]   address,
10    input      [ 7:0]   data
11);
То есть, вместо одного бита, будет 2 бита r/g/b. Соответственно, теперь придется поменять формирование цвета:
1assign {r, g, b} = visible & pix ? 6'b11_11_11 : 6'b00_00_00;
То есть, теперь будет назначаться белый цвет на выход. Подправим еще один момент в методе tick25():
1int cl =
2    ((vga_mod->r&1)*0x7F + (vga_mod->r&2)*0x40) * 65536 +
3    ((vga_mod->g&1)*0x7F + (vga_mod->g&2)*0x40) * 256 +
4    ((vga_mod->b&1)*0x7F + (vga_mod->b&2)*0x40);
5
6if (cl == 0x808080) cl = 0xc0c0c0; // Цвет 7
7if (cl == 0x7F7F7F) cl = 0x606060; // Цвет 8
8
9vga(vga_mod->hs, vga_mod->vs, cl);
Если ранее выводился серый цвет при r=1,g=1,b=1, то теперь при r=11,g=11,b=11 будет выводиться реально белый цвет. Я сделал так, что в младших 7 битах цвета было значение r[0], а в старшем — r[1], своеобразное квантование сигнала. Теперь цвет будет на экране более корректным.
Специальные случаи для цвета 7 и 8, для более корректного их выведения на экран.
Теперь важный момент — это палитра, которая будет представлена 16 цветами и преобразована в 3 цвета по 2 бита:
1wire [ 5:0] outcolor =
2    //                    RR GG BB
3    curcolor == 4'h0 ? 6'b00_00_00 : // Черный
4    curcolor == 4'h1 ? 6'b00_00_01 : // Синий
5    curcolor == 4'h2 ? 6'b00_01_00 : // Зеленый
6    curcolor == 4'h3 ? 6'b00_01_01 : // Бирюзовый
7    curcolor == 4'h4 ? 6'b01_00_00 : // Красный
8    curcolor == 4'h5 ? 6'b01_00_01 : // Пурпурный
9    curcolor == 4'h6 ? 6'b01_01_00 : // Коричневый
10    curcolor == 4'h7 ? 6'b10_10_10 : // Светло-серый
11    curcolor == 4'h8 ? 6'b01_01_01 : // Темно-серый
12    curcolor == 4'h9 ? 6'b00_00_11 : // Ярко-синий
13    curcolor == 4'hA ? 6'b00_11_00 : // Ярко-зеленый
14    curcolor == 4'hB ? 6'b00_11_11 : // Голубой
15    curcolor == 4'hC ? 6'b11_00_00 : // Ярко-красный
16    curcolor == 4'hD ? 6'b11_00_11 : // Розовый
17    curcolor == 4'hE ? 6'b11_11_00 : // Желтый
18                       6'b11_11_11;  // Белый
Провод outcolor будет принимать одно из 16 значений с помощью вот такой вот цепочки из тернарных операторов. Мне жаль, что в верилоге нельзя использовать для этой цели case, потому что его надо обязательно записать в always. Хотя, можно и в case все это запихнуть, но оставлю так.
В этом фрагмента кода цвет выбирается из провода curcolor, который будет принимать текущее значение цвета для точки. Для начала, необходимо этот цвет получить.
Внесем некоторые изменения теперь в блок always:
1
2always @(posedge clock) begin
3
4    x <= xborder ? 1'b0 : x + 1'b1;
5    y <= yborder ? 1'b0 : (xborder ? y + 1'b1 : y);
6
7    /* verilator lint_off CASEINCOMPLETE */
8    case (xv[2:0])
9
10    // Запрос символа
11    3'h0: begin address     <= xv[9:3]*2 + yv[8:4]*160; end
12    // Сохранить символ в tdata, запрос цвета (соседний байт)
13    3'h1: begin address[0]  <= 1'b1; tdata <= data; end
14    // Запрос знакогенератора, сохранение цвета
15    3'h2: begin address     <= {1'b1, tdata, yv[3:0]}; tdata <= data; end
16    // Новые значения для конвейера
17    3'h7: begin rmask       <= data; rcolor <= tdata; end
18
19    endcase
20
21end
Теперь объясню, что тут происходит.
  • Такт 0: ничего не меняется, устанавливается необходимый адрес для извлечения номера символа, этот адрес всегда четный
  • Такт 1: устанавливается нечетный адрес, чтобы извлечь оттуда информацию о цвете знакогенератора, а только что полученные данные (номер символа), сохраняется во временный регистр tdata
  • Такт 2: сохраненный ранее в tdata номер символа записывается как часть адреса в знакогенератор, а полученный из памяти цвет (data) теперь сохраняется в tdata
  • Такт 7: ранее сохраненный в tdata цвет теперь переходит в тот цвет, который будет отображаться на следующем такте (rcolor), и полученное значение data из знакогенератора будет теперь в регистре rmask. Оба эти регистра не будут меняться 8 тактов, то есть, они пошли в конвейер на отрисовку
Поскольку, добавился цвет, нужно поправить пару строк
1wire [ 3:0] curcolor = pix ? rcolor[3:0] : {1'b0, rcolor[6:4]};
2...
3assign {r, g, b} = visible ? outcolor : 6'b00_00_00;
Теперь, если текущий рисуемый пиксель равен 1, то тогда достается цвет из младших битов атрибута цветов (rcolor), иначе из 3 битов в старшем ниббле. Так теперь выглядит цветная версия:

§ Добавление мерцания

Перед тем, как добавить мерцание, я понял, что сделал ошибку в коде и должен исправить:
1x <= xborder ? 1'b0 : x + 1'b1;
2y <= xborder && yborder ? 1'b0 : (xborder ? y + 1'b1 : y);
То есть, y переносится в 0 тогда и только тогда, когда y=448 и x=799. Это важно, хотя и работало раньше.
Теперь же добавим новые регистры:
1reg         flash  = 1'b1;
2reg [ 3:0]  ticker = 1'b0;
Здесь flash - это текущее состояние мерцания у экрана, оно меняется каждые 0.25 сек на противоположное, а ticker - счетчик кадров, для того, чтобы пересбросить flash = ~flash.
Сам же счетчик кадров программируем в always-секции:
1if (xborder && yborder) begin
2
3    ticker <= ticker + 1'b1;
4    flash  <= ticker ? flash : ~flash;
5
6end
То есть, когда xborder и yborder равны 1, то это значит, что точка находится в самом крайнем углу внизу, и следующий такт перебросит (x,y) в (0,0). Это отсчет кадров.
Регистр ticker 4-х битный, он отсчитывает кадры с переполнением. Когда ticker становится равным 0, то flash меняется на противоположный. Если делается 70 кадров в секунду, то тогда 16/70 = 0.22 сек, за такое время перебрасывается flash.
Логика работы мерцания такова, что когда атрибут цвета (старший бит), установлен в 1, то тогда, в случае если flash стал равным 1, то для таких знакомест цвет переднего плана становится цветом фона.
1wire enfore  = ~(flash & rcolor[7]);
Здесь если enfore=0, то условие мерцания выполняется и весь символ становится цветом фона, а это делается так:
1wire [ 3:0] curcolor = pix & enfore ? rcolor[3:0] : rcolor[6:4];
Просто к pix добавляем enfore. Мерцание работает! Все отлично.

§ Вывод курсора

Все, теперь финишная прямая. Единственное, что осталось сделать, это вывод курсора. Курсор опирается на мерцание, и обычно выводится как две линии по 8 пикселей. Положение курсора задается номером знакоместа от 0 до 1999. Номер 0 находится в самом верхнем левом углу, а номер 1999 в самом нижнем правом.
Вычисляется он так cursor = x + 80*y. Это, кстати, в точности совпадает с номером адреса, который устанавливается в память для чтения нового символа, потому я выведу его в отдельный провод:
1wire [10:0] cplace = yv[8:4]*80 + xv[9:3];
И теперь можно замену в always сделать:
13'h0: begin address <= {cplace, 1'b0}; end
cplace сдвигается влево на 1 бит, что равнозначно умножению на 2. Добавим входящий провод cursor размерностью 11 бит, чтобы можно было адресовать от 0 до 1999.
1module vga
2(
3    input               clock,
4    ...
5    input       [10:0]  cursor
6);
Этот параметр передается, например, от процессора или от контроллера, он задает то, где находится курсор в данный момент. Если мы хотим скрыть курсор, то надо установить cursor >= 2000, и тогда курсор исчезнет с экрана.
Вот теперь один из самых интересных моментов:
1wire acursor  = (cursor + 1 == cplace) & (yv[3:0] >= 4'hE);
Что значит этот код? Если курсор (прибавляем +1 к нему), равен номеру знакоместа, который сейчас рисуется, а также младшие 4 бита по Y равны либо 14, либо 15 — это две линии снизу, то тогда на проводе acursor будет 1, что значит, есть разрешение рисовать курсор.
Почему тут cursor+1? Опять-таки, из-за конвейера, который рисует символ до того, как он появится на экране. Поэтому, если x=40, то это считается первым символов, что не так на самом деле, ибо только начиная с x=48 начинается рисование знакоместа, и это значит, что cplace = 1 для x=48. Поэтому и прибавляется cursor+1.
Вновь сделаем правку кода:
1wire [ 3:0] curcolor = (pix & enfore) | (acursor & flash) ? rcolor[3:0] : rcolor[6:4];
В котором добавится (acursor & flash), который будет выводить пиксель в том случае, если 1) сейчас рисуется курсор 2) и если flash=1. Интересный факт, что курсор мерцать будет в противоположную сторону от мерцания символа. Это сделано намеренно, чтобы, в случае нахождения курсора в мерцающей области, его можно было бы увидеть.
Вот и все, текстовый видеоадаптер готов.

§ Сохранение фреймов

Решил, что без этой темы никуда не обойтись. Дописал код, который сохраняет фреймы в файлы, если есть папка out.
1void saveframe(const char* dir) {
2
3    char fn[256];
4    sprintf(fn, "%s/%08d.ppm", dir, frame_id);
5
6    FILE* fp = fopen(fn, "wb");
7    if (fp) {
8
9        fprintf(fp, "P6\n# Verilator\n640 400\n255\n");
10        for (int y = 0; y < 400; y++)
11        for (int x = 0; x < 640; x++) {
12
13            int cl = screen_buffer[2*(y*width + x)];
14            int vl = ((cl >> 16) & 255) + (cl & 0xFF00) + ((cl&255)<<16);
15            fwrite(&vl, 1, 3, fp);
16        }
17
18        fclose(fp);
19    }
20
21    frame_id++;
22}
Необходима модификация метода vga:
1if (_vs == 1 && vs == 0) { x = 0; y = 0; saveframe("out"); }
А также объявить frame_id = 0. Но это уже слишком просто и очевидно. И конечно же, нужно показать, как эти фреймы склеить в один файл mp4:
1ffmpeg -framerate 70 -r 60 -i out/%08d.ppm \
2  -vf "scale=w=1280:h=800,pad=width=1920:height=1080:x=320:y=140:color=black" \
3  -sws_flags neighbor -sws_dither none -f mp4 -q:v 0 -vcodec mpeg4 -y record.mp4
Итоговый файл с проектом находится тут. Также в файле находится папка de0 для тестового синтеза в реальной схеме.