RISC-V c ZX-Spectum like адаптером с 12 битным цветом и атомиками на плате DE0-CV

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

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

Надо отметить, что есть желание остаться в ресурсах ретро-эпохи, то есть объём компонента ОЗУ не должен быть сильно больше 64К. VGA 640x480 с частой в 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);Шрифт реальный - модель сделала английские глифы 8x5, но вот русские глифы сгенерировать не смогла, но не страшно они у меня и так уже есть.
Или зелёный вариант, зря что-ли цвет добавляли:


Ссылка на видео с палитрой: https://youtube.com/shorts/baBjAgAbFRA?feature=share
Или можно с gitHub скачать: https://github.com/DmitryZlobec/yrv-spectrum/rawspectrum/demo.mp4
Ссылка на репу:https://github.com/DmitryZlobec/yrv-spectrum/blob/spectrum/Plus/design/yrv_mcu.v
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.