Любая ретро компьютероподобная DIY конструкция должна иметь видеовыход. А выбор видеорежима — это одно из ключевых решений проекта. Можно получить высокую четкость и потратить много ресурсов, можно сделать просто, но будет страшно. Что выбрать? Наверное это определяется первыми детскими впечатлениями: кого‑то вдохновили тайлы MSX, а кому‑то графика ZX‑Spectrum оставила непоправимую детскую травму.
Есть еще старый добрый VGA, он же 320×200 на 256 цветов, он же mode 13, знакомый по игрушкам времен DOS. Очень комфортен из‑за своей линейной адресации, достаточно большому количеству цветов, и совместимостью со множеством алгоритмов и библиотек, которые накопились за многие годы.

В тоже время, огромным недостатком является то, что этот режим требует огромное количество BRAM, а результирующий пиксель очень толстый, что делает фонты некрасивыми, палитра в 256 цветов избыточна для простых вещей и недостаточна для сложных. Да и сам по себе режим 320×200×256 это уже эпоха IBM PC и вряд ли является настоящим тру ретро.
Как показала практика, много цветов не надо(к примеру Anоther World с 16 цветами), а вот точку, чтобы шрифты были аккуратными, надо поменьше. Поэтому возникла мысль, что монохромный адаптер возможно будет приемлемым вариантом для таких поделок (Hercules тоже был в свое время неплох, но я его мельком видел), а как накинуть цвета — там придумаем, сделаем, если что, зеленым в стиле Matrix или жёлто‑белым как на топовых монохромных мониторах 80-х.

Надо отметить, что есть желание остаться в ресурсах ретро‑эпохи, то есть объём компонента ОЗУ не должен быть сильно больше 64К. VGA 640×480 с частой в 25 МГц удобен для поделок на FPGA и один бит на пиксель это всего 38К BRAM. Я использую плату E0-CV которую мне подарил @KeisN13. В ней памяти 300Кб. Так что на все хватит.
В отличии от ленивого mode 13, у подхода с чёрно‑белыми пикселями есть одна очень‑очень бесячая штука — невозможно взять и просто поставить пиксель который представлен битом используя только операции записи. Потому как меньше байта в микросхему запихать нельзя.

Чтобы изменить пиксель, нужно сначала считать байт из видеопамяти, (а это значит надо еще и реализовать чтение на уровне шины), наложить маску и записать обратно. Из‑за этого какой‑нибудь алгоритм Брезенхема превращается в кошмар. Жу‑у‑уть! Конечно тут есть подвох, в стародавние времена x86 команда OR установила бы маску непосредственно в памяти и никакой команды чтения не надо.
or es:[di], al #где di байт, а al устанавливаема маска.
Акак же RISC‑V с его явным Load‑Store? В спецификации есть расширение атомарных операций (Atomic Instructions). Команды, изначально созданные для синхронизации и параллельных вычислений, показались мне тем инструментом, который и нужен для ретро‑видеоадаптера. Ведь используя инструкции AMO.OR и AMO.AND, мы можем полностью избавиться от явного чтения памяти в коде, подобно x86. (На шине‑то понятно чтение будет).
Возникла мысль взглянуть, а как будет выглядеть монохромный адаптер с атомиками от RISC‑V, тем более что мой любимый YRV поддерживает атомарные операции, правда с одним исключением: первоначальный стандарт предусматривал атомики в виде 32-битных (или 64 битный) слов, а вот 8-битный атомики появились только в 2024 году в стандарте Zabha, который YRV не поддерживает, так что придется вычитывать 32 бита, что несколько отличалось от моих первоначальных намерений.
Для реализации я взял проект на котором делал рейтрейсер. Сам модуль VGA я использую из проекта Basic Graphic Music. С точки зрения шины тут ничего не меняется, со стороны процессора все равно будет приходить байт (или два, или четыре, и не забываем, что у AHB‑Lite данные прилетают в следующем такте, поэтому адрес сохраняем в регистре).
always @(posedge clk) begin if (vga_wr_byte[3]) vga_mem3[mem_addr_reg[15:2]] <= mem_wdata[31:24]; if (vga_wr_byte[2]) vga_mem2[mem_addr_reg[15:2]] <= mem_wdata[23:16]; if (vga_wr_byte[1]) vga_mem1[mem_addr_reg[15:2]] <= mem_wdata[15:8]; if (vga_wr_byte[0]) vga_mem0[mem_addr_reg[15:2]] <= mem_wdata[7:0]; if (mem_trans[0]) begin vga_rdata[31:24] <= vga_mem3[mem_addr[15:2]]; vga_rdata[23:16] <= vga_mem2[mem_addr[15:2]]; vga_rdata[15:8] <= vga_mem1[mem_addr[15:2]]; vga_rdata[7:0] <= vga_mem0[mem_addr[15:2]]; end end
И чтобы добавить чтение из видеопамяти, нужно в слелектор добавить новый флаг чтения. Для этого добавил флаг vga_rdata.
assign mcu_rdata = (mem_rd_reg) ? mem_rdata : (vga_rd_reg) ? vga_rdata : (port10_dec) ? {port1_reg, port0_reg} : (port32_dec) ? {port3_reg, port2_reg} : (port54_dec) ? {port5_reg, port4_reg} : (port76_dec) ? {port7_dat, port6_reg} : 32'h0;
Сделал я все по образу и подобию оригинального YRV. У меня была мысль сделать более похоже на шину AHB‑Lite, но оказывается бесплатной она является только для нищебродов учебных заведений, а так стоит денег.
Так как у Cyclone V память двухпортовая, то проблемы с одновременным чтением данных со стороны процессора для атомиков и для отображения на дисплее не возникает. Я использовал предыдущий проект c VGA и оставил вычисление адреса для чтения по‑байтово (что не совсем хорошо, так как реально все равно вычитывается слово).
reg [9:0] x_prev; always @(posedge clk) x_prev <= x; wire x_trigger = (x != x_prev) && (x[2:0] == 3'b111); always @(posedge clk) begin if(x_trigger) begin if (x == 799) begin if (y == 524) begin pixel_addr <= 17'd0; end else begin pixel_addr <= ((y+1) << 6) + ((y+1) << 4); end end else begin pixel_addr <= (y << 6) + (y << 4) + ((x+1) >> 3); end end end
Тактовая проекта 50Мгц, а пиксельная тактовая частота 25Мгц, на каждый пиксель выходит по два такта. На последнем пикселе (младший бит в байте) в предпоследнем такте, вычисляем адрес следующего байта для этого используем флаг x_trigger, и затем на втором такте защелкиваем все слово и адрес следующего байта в слове pixel_addr_low_reg.
always @(*) begin case(pixel_addr_low_reg) 2'b00: byte_comb = vga_word_out[7:0]; 2'b01: byte_comb = vga_word_out[15:8]; 2'b10: byte_comb = vga_word_out[23:16]; 2'b11: byte_comb = vga_word_out[31:24]; endcase end assign color_reg = (byte_comb & (8'h80 >> x[2:0])) ? 16'hffff : 8'h00; assign vga_r = display_on ? color_reg[12:8] : 4'b0000; assign vga_g = display_on ? color_reg[7:4] : 4'b0000; assign vga_b = display_on ? color_reg[3:0] : 4'b0000;
Пиксель выводится так. Вообще можно и сдвиговый регистр поставить, но на этом этапе я решил сделать логику работы с пикселем и в C коде и в Verilog одинаково — двигаем единичку из старшего бита. Координаты считаются от левого верхнего угла, поэтому старший бит содержит младшую координату, пиксель с адресом 0 находится в 8м бите.

И так адаптер реализован и простейший вид записи будет такой:
// Код оформлен моделью Qwen 3.7 volatile char *VGA=(char *)0xA0000000L; void plot(int x, int y, char color) { // 1. Находим адрес байта. Строка занимает 80 байт: (y * 64) + (y * 16) + (x / 8) int byte_offset = (y << 6) + (y << 4) + (x >> 3); // 2. Вычисляем маску для бита внутри байта. // Берем крайний левый бит (128 или 0x80) и сдвигаем его вправо на остаток от деления x на 8. // x % 8 = 0 -> 128 >> 0 = 128 (10000000b) -> крайний левый пиксель // x % 8 = 7 -> 128 >> 7 = 1 (00000001b) -> крайний правый пиксель char bit_mask = 128 >> (x & 7); // Читаем текущее значение из памяти VGA char cur_byte = VGA[byte_offset]; if (color) { // Устанавливаем нужный бит в 1 (белый) VGA[byte_offset] = cur_byte | bit_mask; } else { // Сбрасываем нужный бит в 0 (черный) VGA[byte_offset] = cur_byte & ~bit_mask; } }
Если посмотрим ассемблерный код, то увидим ту самую операцию чтения из видеопамяти. В коде можем заметить некоторый бонус в виде команды not, так как YRV поддерживает расширение операции с битами.
plot: slli a5,a1,0x2 lui a4,0x2 lw a4,548(a4) # 2224 <VGA> add a5,a5,a1 srai a3,a0,0x3 slli a5,a5,0x4 add a5,a5,a3 add a4,a4,a5 lbu a5,0(a4) andi a0,a0,7 li a3,128 sra a3,a3,a0 zext.b a5,a5 beqz a2,328 <mtvec+0x23> or a5,a5,a3 zext.b a5,a5 sb a5,0(a4) ret not a3,a3 and a5,a5,a3 sb a5,0(a4) ret
А теперь посмотрим на вариант с атомиками,.
// Код офрмлен моделью Qwen 3.7 volatile char *VGA=(char *)0xA0000000L; void plota(int x, int y, char color) { int byte_offset = (y << 6) + (y << 4) + (x >> 3); // 2. Получаем выровненный по 4 байтам адрес 32-битного слова // Операция & ~3 сбрасывает два младших бита адреса volatile unsigned int *word_ptr = (unsigned int *)((uintptr_t)VGA + (byte_offset & ~3)); // 3. Вычисляем позицию бита в 32-битном слове (с учетом Little-Endian) // (byte_offset & 3) * 8 -> сдвиг байта внутри слова // 7 - (x & 7) -> позиция пикселя внутри байта (от старшего к младшему) int bit_shift = ((byte_offset & 3) << 3) + (7 - (x & 7)); unsigned int bit_mask = 1U << bit_shift; // 4. Модификация памяти происходит без промежуточного чтения в регистры CPU if (color) { // OR: устанавливает нужный бит в 1 __asm__ __volatile__ ( "amoor.w zero, %1, (%0)" : : "r"(word_ptr), "r"(bit_mask) : "memory" ); } else { // AND: сбрасывает нужный бит в 0 с помощью инвертированной маски unsigned int inv_mask = ~bit_mask; __asm__ __volatile__ ( "amoand.w zero, %1, (%0)" : : "r"(word_ptr), "r"(inv_mask) : "memory" ); } }
Операция чтения из памяти видеоадаптера отсутствует. Упс, лучше так не использовать использовать адрес VGA так как он вызывает операцию чтения памяти [lw a4,548(a4)], это не та память которая нам нужна, задача кейса — использовать только команды записи.
plota: slli a5,a1,0x2 add a1,a5,a1 slli a1,a1,0x4 srai a5,a0,0x3 add a1,a1,a5 lui a4,0x2 slli a5,a1,0x3 lw a4,548(a4) # 2224 <VGA> not a0,a0 andi a0,a0,7 andi a5,a5,24 or a5,a5,a0 andi a1,a1,-4 bset a5,zero,a5 add a4,a4,a1 beqz a2,3b8 <plota+0x48> amoor.w zero,a5,(a4) ret not a5,a5 amoand.w zero,a5,(a4) ret
Лучше использовать константу:
#define VGA_BASE_ADDR ((volatile unsigned int *)0xA0000000L) volatile unsigned int *word_ptr = &VGA_BASE_ADDR[byte_offset >> 2];
И все это можно реализовать не вызывая жуткий ассемблерный макрос, через встроенные функции GCC (оказывается это стандарт С++11).
void plot(int x, int y, int color) { int byte_offset = (y * 80) + (x >> 3); int bit_pos = ((byte_offset & 3) << 3) | (~x & 7); // Получаем выровненный указатель, используя константный адрес volatile unsigned int *word_ptr = &VGA_BASE_ADDR[byte_offset >> 2]; unsigned int bit_mask = 1U << bit_pos; // Используем встроенные атомики GCC, которые развернутся в AMO-инструкции if (color) { __atomic_fetch_or(word_ptr, bit_mask, __ATOMIC_RELAXED); } else { __atomic_fetch_and(word_ptr, ~bit_mask, __ATOMIC_RELAXED); } }
А если еще добавить немного магии, то можно убрать ветвление. Это еще одна задача кейса, так как RISC‑V не любит ветвление, и при записи байта в память VGA в старом проекте никакого ветвления не было.
void plot(int x, int y, bool color) { int byte_offset = (y * 80) + (x >> 3); int bit_pos = ((byte_offset & 3) << 3) | (~x & 7); // Адрес вычисляется на базе константы (в ассемблере превратится в lui) volatile unsigned int *word_ptr = &VGA_BASE_ADDR[byte_offset >> 2]; unsigned int bit_mask = 1U << bit_pos; // Branchless-магия для генерации масок без if-else: // Если color == 0, то c_mask = 0x00000000. Если color != 0, то c_mask = 0xFFFFFFFF unsigned int c_mask = -(unsigned int)(color != 0); // Маска для ИЛИ: bit_mask если цвет 1, иначе 0 unsigned int or_mask = bit_mask & c_mask; // Маска для И: инвертированная bit_mask если цвет 0, иначе 0xFFFFFFFF (не меняет биты) unsigned int and_mask = or_mask | ~bit_mask; // Вызов встроенных атомиков, __atomic_fetch_and(word_ptr, and_mask, __ATOMIC_RELAXED); __atomic_fetch_or(word_ptr, or_mask, __ATOMIC_RELAXED); }
Мы убрали все обращения к памяти и ветвления.
plot: slli a5,a1,0x2 add a5,a5,a1 srai a4,a0,0x3 slli a5,a5,0x4 add a5,a5,a4 slli a4,a5,0x3 not a0,a0 andi a0,a0,7 andi a4,a4,24 or a4,a4,a0 bset a3,zero,a4 sll a2,a2,a4 not a3,a3 lui a4,0xa0000 andi a5,a5,-4 or a3,a3,a2 add a5,a5,a4 amoand.w zero,a3,(a5) amoor.w zero,a2,(a5) ret
Если мы сравним с оригинальной(VGA) функцией установки пикселя.
void plot(int x, int y, char color) { VGA[(y<<8) + (y<<6) + x] = color; }
В три раза больше, но мы и пиксель в байте считали, на это нужны такты.
plot: slli a5,a1,0x2 add a1,a5,a1 slli a1,a1,0x6 add a1,a1,a0 lui a5,0xa0000 add a1,a1,a5 sb a2,0(a1) ret
А потом у меня возникла мысль — а не добавить ли мне цвета. Интересно посмотреть как будет выглядеть ZX‑Spectrum фрейм‑буфер только в разрешении 640×480 с 12 битным цветом и спрайтом 8×8.
Реализация тут совсем простая и ничем не отличается от VGA, так как процессору читать цвет из памяти не надо. Так как на плате ЦАП 12 битный, я решил использовать 16 бит на цвет (у ZX еще там вроде моргание было, но в данных условиях штука странная). У спрайта есть цвет фона и цвет изображения на каждый цвет по 16 бит — одно слово в 32 бита. Итого сверху по ресурсам еще 20Кб, в условия задачи укладываемся.
Делаем отдельную область памяти в адресах 0xA0010000. Так же как и в VGA считаем адрес в памяти: первый такт — защелкиваем адрес, второй такт защелкиваем слово содержащее цвет фона и цвет пикселей. Тут вся сложность что спрайт 8 на 8 и адрес по‑другому считается, но такой расчет у меня был в самом первом проекте, для текстового видеоадаптера.
always @(posedge clk) begin if(x_trigger) begin if (x == 799) begin if (y == 524) begin color_addr <= 13'd0; end else begin color_addr <= (((y+1) >> 3) << 6) + (((y+1) >> 3) << 4); end end else begin color_addr <= ((y >> 3) << 6) + ((y >> 3) << 4) + ((x+1) >> 3); end end end
На монитор выводим так: если слово цвета пустое '0 то значит это чёрно‑белый режим — чтобы не заморачиваться с первоначальной установкой цвета, когда нужен просто текст:
assign color_reg = sprite_color =='0 ? (byte_comb & (8'h80 >> x[2:0])) ? 16'hffff : 8'h00 : (byte_comb & (8'h80 >> x[2:0])) ? sprite_color[15:0] : sprite_color[31:16];
Итого, я получил разрешение 640×480 с 12 битным цветом и нормальным использованием ресурсов платы. Так как художник я так себе, этого мне достаточно.
Где процессе работы у меня получилась вот такая картинка с Шоном Бином в матрице, которая частично ушла на КДПВ, и я решил ее использовать как тестовое изображение (уж лучше чем девица в шляпе с перьями) для вывода на монитор. Изображение было сконвертировано при помощи GIMP в однобитный XBM. Код написала модель Qwen 3.8 (иногда приятно пообщаться с другими разработчиками).


drawXBM(60, 40); drawStringElvish(200, 380, "One does not simply place a Pixel", true); set_screen_color(240);
Шрифт реальный — модель сделала английские глифы 8×5, но вот русские глифы сгенерировать не смогла, но не страшно они у меня и так уже есть.
Или зелёный вариант, зря что‑ли цвет добавляли:


Ссылка на репу