![](https://habrastorage.org/getpro/habr/upload_files/2f1/0b7/8a7/2f10b78a79eb3f6985604ed842e44f54.jpg)
Наверное, каждый второй разработчик на ПЛИС в начале своего пути пытался визуализировать работу своих схем. Кто-то подключал TFT-дисплей, кто-то — VGA монитор. А у меня под рукой оказался только телевизор с композитным входом. Ну что ж, работаем с тем, что есть!
Дисклеймер
Сразу скажу, что статья рассчитана на новичков, которые пришли сюда со стартовым набором проектов за плечами и тоже хотят посмотреть мультики на ПЛИС. Мой проект не является туториалом, потому что содержит в себе спорные, расточительные и, возможно, ошибочные решения. Я просто хочу показать, что у меня получилось.
Как всё начиналось
Не так давно я начал работать с ПЛИС. Эта тема мне очень понравилась, и я решил начать делать различные мини проекты для того, чтобы попрактиковаться. Помигал светодиодами, сделал часы, вывел текст на LCD1602. И вот пришло время сделать что-то более крупное и интересное. Я захотел подключить к ПЛИС экран. Сначала думал подключить VGA монитор, но VGA монитора у меня не оказалось, зато оказался телевизор с композитным входом. Я начал искать статьи и видеоуроки, но не нашёл почти ничего про композитный PAL видеосигнал на ПЛИС. По теме композитного видеосигнала были либо толстенные серьёзные книжки, погружаться в которые я был не готов, либо картинки с временными диаграммами PAL и NTSC, взятые из этих самых книжек. В итоге моё нежелание потратить пару недель на чтение тяжёлой литературы повело меня по эмпирическому пути. Я обложился самыми понятными, на мой взгляд, рисунками временных диаграмм и приступил к их осмыслению.
Осмысление задачи
Для начала, нужно хотя бы в первом приближении понять, что из себя представляет композитный видеосигнал, почему он такой, что понадобится для его генерации и с какими проблемами мы можем столкнуться.
Во времена, когда разрабатывался композитный видеосигнал, передача данных сразу по нескольким проводам или радиоканалам была сложным и/или дорогим решением, поэтому видеоданные и синхроимпульсы передаются по одному проводу. Чтобы приёмник мог надёжно отделить видеоданные от синхроимпульсов, необходимо, чтобы они отличались по уровню напряжения и чтобы синхроимпульсы располагались вне области значений напряжения, используемых для видеоданных, а также следовали в строго определённые моменты времени. Для синхроимпульсов используется область значений напряжения от 0В до 0.3В, а для видеоданных от 0.3В до 1В. С видеоданными всё понятно, 0.3В — это чёрный, 1В — это белый, а вот с синхроимпульсами посложнее. Дело в том, что кадр состоит из 625 строк, но не все из них являются обычными строками, некоторые из них не являются видимыми, а некоторые разбиты на полустроки и служат для кадровой синхронизации. К тому же развёртка чересстрочная, то есть при частоте 50 кадров в секунду на самом деле будет только 25 полных кадров и между этими полукадрами тоже есть свой полукадровый синхроимпульс. Вдобавок строчные и кадровые синхроимпульсы имеют разную длительность. Чтобы было понятнее, посмотрим на какую-нибудь картинку.
![Временная диаграмма композитного видеосигнала Временная диаграмма композитного видеосигнала](https://habrastorage.org/getpro/habr/upload_files/19b/baa/cb9/19bbaacb9ff7942994b5136a17d33a6e.jpg)
Именно эту картинку я использовал при осмыслении PAL сигнала. Здесь видно, что 1 - 2.5 строки — это кадровый синхроимпульс, 2.5 – 5 строки — это уравнивающие импульсы, 6 строка — это какая-то дополнительная строка,7 – 23 строки я буду считать невидимыми, 24 – 310 строки могут содержать в себе видеоданные, 311 – 312.5 строки — это уравнивающие синхроимпульсы, 312.5 – 315 строки — это полукадровый синхроимпульс, 316 – 317.5 строки — это уравнивающие импульсы, 317.5 – 335 строки я буду считать невидимыми, 336 – 622 строки могут содержать в себе видеоданные, первая половина 623 строки будет считаться невидимой строкой, 622.5 – 625 строки — это уравнивающие импульсы. Но на этой картинке не видно, что длительность строчных синхроимпульсов и уравнивающих импульсов отличается, строчные синхроимпульсы имеют длительность 4.7мкс, а уравнивающие импульсы имеют длительность 2.35мкс. Полная строка имеет длительность 64мкс, а полустрока имеет длительность 32мкс. Видеоданные следует начать выдавать примерно на 12 микросекунде каждой видимой строки. Один полный кадр имеет длительность 625 * 64мкс = 40000мкс, то есть частота получается 1000000/40000 = 25 кадров в секунду, всё сходится.
Железо и логика
Я использовал для экспериментов ПЛИС MAX II и Cyclone II, которые тактируются кварцевым генератором на 50МГц. Первым делом нужно посчитать как частота тактового генератора будет соотноситься с частотой пикселей и кадровых импульсов. К счастью, тут не возникло никаких проблем, всё поделилось нацело. 50МГц/25 = 2000000 тактов на один полный кадр. 2000000/625 = 3200 тактов на одну строку. Чтобы посчитать количество тактов на пиксель, нужно выбрать разрешение изображения. Конечно, можно было бы использовать полное разрешение 720 * 576 пикселей, но это не удобное разрешение, я буду использовать какую-нибудь степень двойки, например, 512 * 512 пикселей. Далее будет понятно, почему я выбрал именно такое разрешение. При таком разрешении будет удобно использовать 4 такта на пиксель, изображение будет немного растянуто по горизонтали и занимать не всю площадь экрана, но мне это подходит. При большом желании вы сможете переделать мой проект под другое разрешение.
Следующим шагом нужно разобраться, как с помощью ПЛИС выдавать аналоговый сигнал напряжением от 0 до 1 вольта с нужной скоростью. В этих ПЛИС нет встроенного ЦАП, поэтому нужно городить свой. Микросхему ЦАП с последовательным вводом ставить не вариант, нам нужно выводить данные менее чем за 4 такта, поэтому единственный вариант – это параллельный ЦАП на резисторах, соединённых по схеме R-2R. Я сделал 6 битный ЦАП для экономии ножек, но ничего не мешает сделать 8 битный. К ЦАП добавлен ещё один резистор, который добавляет к выходному напряжению 0.3В, этот контакт будет синхронизирующим.
![Схема ЦАП в Multisim Схема ЦАП в Multisim](https://habrastorage.org/getpro/habr/upload_files/19b/150/654/19b150654496f597343f57a9a3e63320.png)
Описание на Verilog
Когда я только начал описывать схему, мне было ещё тяжело удержать всё в голове, поэтому я пошел напролом самым линейным путём из возможных. Я просто сделал глобальный счётчик на 2000000 тиков и с помощью оператора «case» начал писать в регистр синхровыхода нули и единицы на определенных значениях счётчика.
Модуль видеогенератора имеет вход тактового сигнала, вход видеоданных, выход синхросигнала, выход видеоданных и два 9 битных выхода адресной шины строк и столбцов.
Модуль видеогенератора
// Этот модуль является простым генератором композитного видеосигнала в формате PAL.
// Разрешение получилось 512 * 512 пикселей.
// Именно такое разрешение было выбрано из за того, что оно отлично уложилось в 18 битную адресную шину оперативной памяти.
// На вход необходимо подать тактовый сигнал с частотой ровно 50 МГц.
// Все тайминги были посчитаны таким образом, что на 1 пиксель приходится 4 тика, на строку 3200 тиков , на кадр 2 000 000 тиков.
module PAL_GEN (
input wire clk_in, // тиктирование 50 МГц
output wire sync_out, // синхронизация
input wire [7 : 0] video_in, // входные данные
output wire [7 : 0] video_out, // выходные данные
output wire [8 : 0] x_pix, // счетчик пикселей по горизонтали
output wire [8 : 0] y_line, // счетчик линий по вертикали
output wire [20 : 0] tick // глобальный счётчик тиков в кадре
);
parameter frame_tick_counter_max_value = 21'd2000000; // количество тиков в кадре
parameter line_tick_counter_max_value = 12'd3200; // количество тиков в строке
reg [8 : 0] x_pix_counter;
reg [7 : 0] y_line_counter;
reg [20 : 0] frame_tick_counter; // счётчик тиков в кадре
reg [11 : 0] line_tick_counter; // счётчик тиков в строке
reg [1 : 0] pc; // считает 4 такта для пиккселя
reg temp_sync_1; // регистр синхр
reg temp_sync_2; // регистр синхр
reg line_start; // старт линии
reg frame_start; // старт кадра
reg pix_start; //старт пиккселя
reg parity_line; // бит, который определяет четность строки
initial begin
frame_tick_counter = 21'd0;
line_tick_counter = 12'd0;
line_start = 1'b0;
frame_start = 1'b0;
pix_start = 1'b0;
temp_sync_1 = 1'b1;
temp_sync_2 = 1'b1;
pc = 2'b00;
end
// глобальный счётчик тиков
always @(posedge clk_in) begin
if(frame_tick_counter == (frame_tick_counter_max_value - 21'd1))begin
frame_tick_counter <= 21'd0;
end else begin
frame_tick_counter <= frame_tick_counter + 1;
end
end
// выдача серии кадровых синхроимпульсов и пуск видимых строк
always @(posedge clk_in) begin
case (frame_tick_counter)
// M1:
21'd0: temp_sync_1 <= 1'b0;
21'd1365: temp_sync_1 <= 1'b1;
21'd1600: temp_sync_1 <= 1'b0;
21'd2965: temp_sync_1 <= 1'b1;
21'd3200: temp_sync_1 <= 1'b0;
21'd4565: temp_sync_1 <= 1'b1;
21'd4800: temp_sync_1 <= 1'b0;
21'd6165: temp_sync_1 <= 1'b1;
21'd6400: temp_sync_1 <= 1'b0;
21'd7765: temp_sync_1 <= 1'b1;
21'd8000: temp_sync_1 <= 1'b0;
//N1
21'd8120: temp_sync_1 <= 1'b1;
21'd9600: temp_sync_1 <= 1'b0;
21'd9720: temp_sync_1 <= 1'b1;
21'd11200: temp_sync_1 <= 1'b0;
21'd11320: temp_sync_1 <= 1'b1;
21'd12800: temp_sync_1 <= 1'b0;
21'd12920: temp_sync_1 <= 1'b1;
21'd14400: temp_sync_1 <= 1'b0;
21'd14520: temp_sync_1 <= 1'b1;
//H1
21'd16000: temp_sync_1 <= 1'b0;
21'd16235: temp_sync_1 <= 1'b1;
//line_start
21'd19200: line_start <= 1'b1;
//frame_start
21'd124800:
begin
frame_start <= 1'b1;
parity_line <= 1'b0;
end
//frame_stop
21'd943999: frame_start <= 1'b0;
//line_stop
21'd991999: line_start <= 1'b0;
//L1
21'd992000: temp_sync_1 <= 1'b0;
21'd992120: temp_sync_1 <= 1'b1;
21'd993600: temp_sync_1 <= 1'b0;
21'd993720: temp_sync_1 <= 1'b1;
21'd995200: temp_sync_1 <= 1'b0;
21'd995320: temp_sync_1 <= 1'b1;
21'd996800: temp_sync_1 <= 1'b0;
21'd996920: temp_sync_1 <= 1'b1;
21'd998400: temp_sync_1 <= 1'b0;
21'd998520: temp_sync_1 <= 1'b1;
//M2
21'd1000000: temp_sync_1 <= 1'b0;
21'd1001365: temp_sync_1 <= 1'b1;
21'd1001600: temp_sync_1 <= 1'b0;
21'd1002965: temp_sync_1 <= 1'b1;
21'd1003200: temp_sync_1 <= 1'b0;
21'd1004565: temp_sync_1 <= 1'b1;
21'd1004800: temp_sync_1 <= 1'b0;
21'd1006165: temp_sync_1 <= 1'b1;
21'd1006400: temp_sync_1 <= 1'b0;
21'd1007765: temp_sync_1 <= 1'b1;
21'd1008000: temp_sync_1 <= 1'b0;
//N2
21'd1008120: temp_sync_1 <= 1'b1;
21'd1009600: temp_sync_1 <= 1'b0;
21'd1009720: temp_sync_1 <= 1'b1;
21'd1011200: temp_sync_1 <= 1'b0;
21'd1011320: temp_sync_1 <= 1'b1;
21'd1012800: temp_sync_1 <= 1'b0;
21'd1012920: temp_sync_1 <= 1'b1;
21'd1014400: temp_sync_1 <= 1'b0;
21'd1014520: temp_sync_1 <= 1'b1;
//line_start
21'd1017600: line_start <= 1'b1;
//frame_start
21'd1126400:
begin
frame_start <= 1'b1;
parity_line <= 1'b1;
end
//frame_stop
21'd1945599: frame_start <= 1'b0;
//line_stop
21'd1990399: line_start <= 1'b0;
//--||
21'd1990400: temp_sync_1 <= 1'b0;
21'd1990520: temp_sync_1 <= 1'b1;
//L2
21'd1992000: temp_sync_1 <= 1'b0;
21'd1992120: temp_sync_1 <= 1'b1;
21'd1993600: temp_sync_1 <= 1'b0;
21'd1993720: temp_sync_1 <= 1'b1;
21'd1995200: temp_sync_1 <= 1'b0;
21'd1995320: temp_sync_1 <= 1'b1;
21'd1996800: temp_sync_1 <= 1'b0;
21'd1996920: temp_sync_1 <= 1'b1;
21'd1998400: temp_sync_1 <= 1'b0;
21'd1998520: temp_sync_1 <= 1'b1;
default: temp_sync_1 <= temp_sync_1;
endcase
end
// счётчик тиков в линии
always @(posedge clk_in) begin
if(line_tick_counter == (line_tick_counter_max_value - 12'd1))begin
line_tick_counter <= 12'd0;
end else begin
line_tick_counter <= line_tick_counter + 1;
end
end
// строчный синхроимпульс и видимые пиксели
always @(posedge clk_in) begin
if(line_start == 1'b1) begin
case (line_tick_counter)
12'd1: temp_sync_2 <= 1'b0;
12'd235: temp_sync_2 <= 1'b1;
12'd876: pix_start <= 1'b1;
12'd2923: pix_start <= 1'b0;
endcase
end
end
// инкрементируем пиксели
always @(posedge clk_in) begin
pc <= pc + 2'b01;
if(pc == 2'b11 && frame_start == 1'b1 && pix_start == 1'b1) begin
x_pix_counter <= x_pix_counter + 1;
end
end
// инкрементируем линии
always @(posedge clk_in) begin
if((line_tick_counter == (line_tick_counter_max_value - 12'd1)) && (frame_start == 1'b1))begin
y_line_counter <= y_line_counter + 1;
end
end
// синхроимпульс и данные
assign sync_out = ~((~temp_sync_1) | (~temp_sync_2));
assign video_out = (pix_start & frame_start) ? video_in : 8'b00000000;
// адресация столбцов и строк
wire [8 : 0] y_line_temp;
assign y_line_temp[8 : 1] = y_line_counter[7 : 0];
assign y_line_temp[0] = parity_line;
assign x_pix = x_pix_counter;
assign y_line = y_line_temp;
assign tick = frame_tick_counter;
endmodule
В модуле верхнего уровня пускаем на вход видеоданных фрактал, полученный применением операции XOR к адресным шинам строк и столбцов.
Модуль верхнего уровня
module TV_TOP (
input wire clk_in, // тиктирование 50 МГц
output wire [7 : 0] video_out, // выходные данные
output wire sync_out // синхронизация
);
wire [7 : 0] video;
wire [8 : 0] x_pix;
wire [8 : 0] y_line;
assign video [7 : 0] = x_pix [7 : 0] ^ y_line [7 : 0]; // рисуем фрактал
//assign video [7 : 0] = (x_pix == 9'd0 || x_pix == 9'd511 || y_line == 9'd0 || y_line == 9'd511) ? 9'b11111111 : 9'b00000000; // рисуем рамку
//assign video [7 : 0] = y_line [8 : 1]; // градиент
PAL_GEN (
.clk_in(clk_in), // тиктирование 50 МГц
.sync_out(sync_out), // синхронизация
.video_in(video), // входные данные
.video_out(video_out), // выходные данные
.x_pix(x_pix), // счетчик пикселей по горизонтали
.y_line(y_line) // счетчик линий по вертикали
);
endmodule
Запускаем синтез и тестируем.
![Фрактал Фрактал](https://habrastorage.org/getpro/habr/upload_files/e23/b89/d5e/e23b89d5e6a017e15b7c22a5b1f3fa71.jpg)
Отлично, фрактал на месте, видеогенератор ведёт себя очень хорошо. А что там с ресурсозатратами?
![Отчёт о компиляции Отчёт о компиляции](https://habrastorage.org/getpro/habr/upload_files/1f1/153/d61/1f1153d61ef046d708b8b0d30e7bb7b0.png)
Какой ужас, даже не поместилось бы в EPM240T100C5N, нам такое не подходит, переделываем.
Описывать схему таким прямолинейным способом было очень расточительным решением, которое привело к образованию огромного количества защёлок. Повторяющиеся места в алгоритме не оптимизированы, а счётчики дублируются. Это описание работает стабильно, но не рекомендуется к использованию.
Теперь, когда я точно понимаю, как выглядит работающий видеосигнал, я готов направить свои мыслительные ресурсы на реализацию более оптимального решения задачи. Первое, что нужно сделать – сменить концепцию, теперь алгоритм будет задаваться с помощью конечного автомата. У автомата будет отдельное состояние на каждый участок временной диаграммы. M1 - кадровый синхроимпульс, N1 - уравнивающие импульсы, L1 - уравнивающие импульсы, M2 - полукадровый синхроимпульс, N2 - уравнивающие импульсы, L2 - уравнивающие импульсы, Hf - полная 6 строка, Hh - вторая половина 318 строки, Hs - первая половина 623 строки, F1 - нечетные строки, F2 - четные строки.
![Граф автомата Граф автомата](https://habrastorage.org/getpro/habr/upload_files/d0f/383/3dd/d0f3833ddea367c873fcc1b3bec06f63.png)
Это мой первый конечный автомат, поэтому он не идеален. Думаю, можно было бы как-то объединить некоторые состояния и использовать их повторно, но я не стал морочить голову и сделал ровную последовательную цепочку. Это слишком простой автомат с простыми состояниями, попытка объединить одинаковые состояния приведёт к усложнению условия перехода и к потребности в использовании дополнительных регистров.
Вторым шагом надо оптимизировать счётчики. Нет необходимости считать все 2000000 тактов, можно безболезненно пренебречь точностью и разделить тактовый сигнал на 4, ведь именно столько тактов приходится на 1 пиксель. Потребность в глобальном счётчике с введением автомата отпала, но не отпала потребность в счетчиках строк и тиков в строке для генерации синхроимпульсов и выдачи адреса текущего пикселя. Теперь в строке 3200/4 = 800 тиков, а в пикселе 1 тик. Но есть проблема. Счётчик тиков в строке и счётчик строк не может напрямую выдавать адрес столбца и строки, надо либо поставить условие и вычитатель, либо сделать отдельные счётчики, которые будут считать только видимые пиксели. Я взвесил оба варианта и решил, что появление в схеме лишних условий, сумматоров и защёлок хуже, чем появление ещё двух счётчиков. Понимаю, решение спорное, можете переделать.
Новый модуль видеогенератора
module PAL_GEN_2 (
input wire clk_in, // тиктирование 50 МГц
output wire sync_out, // синхронизация
input wire [7 : 0] video_in, // входные данные
output wire [7 : 0] video_out, // выходные данные
output wire [8 : 0] x_pix, // счетчик пикселей по горизонтали
output wire [8 : 0] y_line // счетчик линий по вертикали
);
// На полный кадр будет 500 000 пиксельтиков, где один пиксельтик это 4 такта тактовой частоты.
// Счетчик, который делит входную частоту на 4
// На выходе получается частота пикселей
reg [1 : 0] pix_tick;
wire clk_pix;
// Эти счетчики считают все строки и пиксели
reg [9 : 0] x_counter;
reg [8 : 0] y_counter;
// Эти счетчики считают видимые строки и пиксели
reg [8 : 0] x_pix_counter;
reg [7 : 0] y_line_counter;
// Количество итераций
reg [8 : 0] counter_target;
// Определяет видимость
wire visible_v;
wire visible_x;
wire visible_y;
// Временная шина
wire [8 : 0] y_line_temp;
// Регистр синхроимпульса
reg sync;
// Регистр определяющий четность строки
reg parity_line;
reg [9 : 0] X_END;
reg [9 : 0] X_NEG;
reg [9 : 0] X_POS;
localparam M1 = 4'd0; // Кадровый синхроимпульс
localparam N1 = 4'd1; // Уравнивающие импульсы
localparam L1 = 4'd2; // Уравнивающие импульсы
localparam M2 = 4'd3; // Полукадровый синхроимпульс
localparam N2 = 4'd4; // Уравнивающие импульсы
localparam L2 = 4'd5; // Уравнивающие импульсы
localparam Hf = 4'd6; // Полная 6 строка
localparam Hh = 4'd7; // Вторая половина 318 строки
localparam Hs = 4'd8; // Первая половина 623 строки
localparam F1 = 4'd9; // Нечетные строки
localparam F2 = 4'd10; // Четные строки
// Текущее и следующее состояние автомата
reg [3 : 0] current_state;
reg [3 : 0] next_state;
initial begin
X_END = 10'd0;
X_NEG = 10'd0;
X_POS = 10'd0;
current_state = M1;
next_state = M1;
end
// Получаем частоту пикселей
always @(posedge clk_in) begin
pix_tick <= pix_tick + 2'd1;
end
assign clk_pix = pix_tick [1];
//
// Автомат
always @(*) begin
case (current_state)
M1:
begin
X_NEG <= 10'd0;
X_POS <= 10'd341;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= N1;
end
N1:
begin
X_NEG <= 10'd0;
X_POS <= 10'd29;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= Hf;
end
Hf:
begin
X_NEG <= 10'd0;
X_POS <= 10'd59;
X_END <= 10'd799;
counter_target <= 9'd0;
next_state <= F1;
end
F1:
begin
X_NEG <= 10'd0;
X_POS <= 10'd59;
X_END <= 10'd799;
counter_target <= 9'd303;
next_state <= L1;
end
L1:
begin
X_NEG <= 10'd0;
X_POS <= 10'd29;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= M2;
end
M2:
begin
X_NEG <= 10'd0;
X_POS <= 10'd341;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= N2;
end
N2:
begin
X_NEG <= 10'd0;
X_POS <= 10'd29;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= Hh;
end
Hh:
begin
X_POS <= 10'd0;
X_END <= 10'd399;
counter_target <= 9'd0;
next_state <= F2;
end
F2:
begin
X_NEG <= 10'd0;
X_POS <= 10'd59;
X_END <= 10'd799;
counter_target <= 9'd303;
next_state <= Hs;
end
Hs:
begin
X_NEG <= 10'd0;
X_POS <= 10'd29;
X_END <= 10'd399;
counter_target <= 9'd0;
next_state <= L2;
end
L2:
begin
X_NEG <= 10'd0;
X_POS <= 10'd29;
X_END <= 10'd399;
counter_target <= 9'd4;
next_state <= M1;
end
endcase
end
// Счетчик тиков в линии
always @(posedge clk_pix)begin
if(x_counter == X_END) begin
x_counter <= 10'd0;
if(x_counter == X_END && y_counter == counter_target) begin
current_state <= next_state;
y_counter <= 9'd0;
end else begin
current_state <= current_state;
y_counter <= y_counter + 9'd1;
end
end else begin
x_counter <= x_counter + 10'd1;
end
end
// Выставляем моменты синхроимпульса
always @(posedge clk_pix) begin
case (x_counter)
X_POS: sync <= 1'b1;
X_NEG: sync <= 1'b0;
default: sync <= sync;
endcase
end
// Счтётчики видимых строк
always @(posedge clk_pix) begin
if(visible_v) begin
if(x_pix_counter == 9'd511) begin
x_pix_counter <= 9'd0;
if(y_line_counter == 8'd255) begin
y_line_counter <= 8'd0;
parity_line <= ~parity_line;
end else begin
y_line_counter <= y_line_counter + 1;
end
end else begin
x_pix_counter <= x_pix_counter + 1;
end
end
end
assign sync_out = sync;
assign visible_x = (x_counter >= 10'd200 && x_counter < 10'd712) ? 1'b1 : 1'b0;
assign visible_y = (y_counter >= 10'd34 && y_counter < 10'd290) ? 1'b1 : 1'b0;
assign visible_v = visible_x & visible_y;
assign y_line_temp [8 : 1] = y_line_counter [7 : 0];
assign y_line_temp [0] = ~parity_line;
assign x_pix = x_pix_counter;
assign y_line = y_line_temp;
assign video_out = visible_v ? video_in : 8'b00000000;
endmodule
Запускаем синтез и тестируем.
Отлично, на телевизоре такая же картинка. А что стало с ресурсозатратами?
![Отчёт о компиляции Отчёт о компиляции](https://habrastorage.org/getpro/habr/upload_files/fa1/7c2/927/fa17c29271f6f940d05f85522bf18a26.png)
Совсем другое дело. Можно было уложиться в 80 макроячеек, но меня полностью устраивает этот результат, он почти в 3 раза превзошёл предыдущий. Работает – не трогай!
Временная диаграмма синхроимпульсов снятая логическим анализатором:
![Кадровые импульсы Кадровые импульсы](https://habrastorage.org/getpro/habr/upload_files/593/23f/d38/59323fd38594a22b0b47f4d6ce5e3f21.png)
![Кадровые импульсы Кадровые импульсы](https://habrastorage.org/getpro/habr/upload_files/d60/5f8/750/d605f8750bb970680c10e9da068c3b70.png)
Буфер кадра
Мы не можем просто так указать видеогенератору адрес, по которому нужно вывести конкретный пиксель. Видеогенератор сам перебирает адреса на своём выходе и выводит пиксель, яркость которого соответствует значению на шине данных. Поэтому нам нужен буфер, который мы сможем сами перезаписывать. В качестве такого буфера отлично подходит микросхема статической оперативной памяти AS7C34098A. Память имеет 18 битную адресную шину, именно поэтому я выбрал разрешение 512 на 512 пикселей. У себя на работе я смог раздобыть несколько таких микросхем SRAM, поэтому я был лишён удовольствия потратить ещё неделю на попытки написать контроллер для более дешёвой и доступной SDRAM. Контроллер для SRAM написать всё-таки придётся. Нельзя одновременно перезаписывать и считывать память, поэтому я реализую двойную буферизацию. Двойная буферизация позволяет произвольным образом записывать в первый буфер, пока изображение считывается видеогенератором из второго буфера. Эти буферы должны быть двумя отдельными микросхемами памяти.
Теперь, когда стало понятно, как будет работать буфер кадра, можно подключить к ПЛИС микросхемы и написать простейший контроллер памяти. Этот контроллер будет очень простым. С одной стороны будет вход только для записи, а с другой стороны будет выход только для чтения. При необходимости можно сделать контроллер симметричным, но в этом проекте это избыточно.
![Подключение микросхемы SRAM Подключение микросхемы SRAM](https://habrastorage.org/getpro/habr/upload_files/fbb/aed/0ef/fbbaed0eff8fdd41acf4d7b4ac328321.jpg)
![Общий вид устройства Общий вид устройства](https://habrastorage.org/getpro/habr/upload_files/bbd/f3f/e8c/bbdf3fe8ce4a3c86ad3acb146e58c213.jpg)
Модуль контроллера SRAM
// Этот модуль реализует простейшую двойную буферизацию кадра.
// У модуля есть отдельные выводы адреса и данных для записи и для чтения.
// С одной стороны можно только записать , а с другой только прочитать данные.
// При select = 1 происходит запись в буфер А и чтение из буфера В, при select = 0 всё наоборот.
module SRAM_MUX
(
input wire ce, // запись
input wire select, // выбор буфера 0/1
input wire [17 : 0] adress_W, // адрес для записи
input wire [7 : 0]data_W, // данные для записи
input wire [17 : 0] adress_R, // адрес для чтения
output wire [7 : 0] data_R, // данные для чтения
output wire [17 : 0] adress_SRAM_A, // A выводы первого буфера
output wire [17 : 0] adress_SRAM_B, // A выводы второго буфера
inout wire [7 : 0] data_SRAM_A, // D выводы первого буфера
inout wire [7 : 0] data_SRAM_B, // D выводы второго буфера
output wire we_SRAM_A, // we вывод первого буфера
output wire ce_SRAM_A, // ce вывод первого буфера
output wire we_SRAM_B, // we вывод второго буфера
output wire ce_SRAM_B // ce вывод второго буфера
);
// Это мультиплексор, который меняет местами адреса двух буферов
assign adress_SRAM_A = select ? adress_W : adress_R;
assign adress_SRAM_B = ~select ? adress_W : adress_R;
// Здесь переключается шина данных.
assign data_SRAM_A = select ? data_W : 8'bzzzzzzzz;
assign data_SRAM_B = ~select ? data_W : 8'bzzzzzzzz;
assign data_R = ~select ? data_SRAM_A : data_SRAM_B;
// А вот тут внимательно. Чтобы избежать конфликта на шине памяти я использую режим работы "nCE controlled" !!
// То есть строб записи идет не на we, а на ce.
assign we_SRAM_A = ~select;
assign we_SRAM_B = select;
assign ce_SRAM_A = select ? ce : 1'b0;
assign ce_SRAM_B = ~select ? ce : 1'b0;
endmodule
Стоит обратить внимание на то, каким образом происходит запись в память. Если заглянуть в документацию на микросхему памяти, то можно узнать о двух способах записи данных в память.
Первый способ (nWE controlled) предполагает, что микросхема может быть постоянно активна при nCE равным нулю и её шина данных будет находиться в состоянии OUTPUT, пока не придёт импульс записи на контакт nWE. Но тут надо быть очень осторожным, дело в том, что при таком способе управления записью очень просто устроить конфликт на шине данных, особенно, если игнорировать сигнал nCE.
Второй способ (nCE controlled) показался мне более удобным и безопасным, тут намного проще избежать конфликта на шине. С помощью сигнала nWE выбираем режим записи или чтения, а сама запись производится коротким импульсом на контакте nCE. Этот способ записи отличается от предыдущего тем, что шина данных находится либо в состоянии INPUT, либо в Z состоянии.
Скриншот из документации на SRAM
![](https://habrastorage.org/getpro/habr/upload_files/f87/570/b5e/f87570b5eb798577770211a82166d487.png)
Вспомогательные модули
Два самых важных модуля уже позади, теперь надо подумать о том, каким образом изображение окажется в микросхеме памяти. Для решения этой задачи был написан модуль последовательного приёмника и модуль со счётчиком адресов, который будет управлять буфером кадра. Тут нечего объяснять, просто приведу описание.
Модуль последовательного приёмника
// Этот модуль реализует простейший приёмник последовательного интерфейса.
// Он работает только на приём.
module module_uart
(
input wire uart_clk, // тактирование 50 МГц
input wire uart_rx_wire, // вход порта
output wire uart_rx_valid, // достоверность данных
output wire [7 : 0] uart_rx_data // выходные данные
);
reg [7 : 0] rx_data;
reg [9 : 0] rx_tick_counter;
reg rx_start;
// T1 = 50 000 000 / (921600 * 2) = 27,12 округлим до 27
//parameter F_CLK = 50000000; // частота тактирования
//parameter SPEED_BOD = 921600; // скорость порта в бодах. Больше 921600 не получилось =(
//parameter T1 = F_CLK / (SPEED_BOD * 2);
parameter T1 = 27;
parameter T3 = (T1 * 3) - 1;
parameter T5 = (T1 * 5) - 1;
parameter T7 = (T1 * 7) - 1;
parameter T9 = (T1 * 9) - 1;
parameter T11 = (T1 * 11) - 1;
parameter T13 = (T1 * 13) - 1;
parameter T15 = (T1 * 15) - 1;
parameter T17 = (T1 * 17) - 1;
parameter T20 = (T1 * 20) - 1;
initial begin
rx_start = 1'b0;
end
always @(posedge uart_clk) begin
if(uart_rx_wire == 1'b0 && rx_tick_counter == 16'd0 && rx_start == 1'b0) rx_start <= 1'b1;
if(rx_start) begin
if(rx_tick_counter == T20) begin
rx_tick_counter <= 10'd0;
rx_start <= 1'b0;
end else begin
rx_tick_counter <= rx_tick_counter + 1;
end
case(rx_tick_counter)
T3: rx_data [0] <= uart_rx_wire;
T5: rx_data [1] <= uart_rx_wire;
T7: rx_data [2] <= uart_rx_wire;
T9: rx_data [3] <= uart_rx_wire;
T11: rx_data [4] <= uart_rx_wire;
T13: rx_data [5] <= uart_rx_wire;
T15: rx_data [6] <= uart_rx_wire;
T17: rx_data [7] <= uart_rx_wire;
endcase
end
end
assign uart_rx_data = rx_data;
assign uart_rx_valid = ~rx_start;
endmodule
Модуль счётчика
// Этот модуль реализует поочередную запись пикселей из порта в буфер.
module PIXEL_INCREMENT(
input wire increment, // инкремент счётчика
output reg [8 : 0] X_counter, // счётчик пикселей по горизонтали
output reg [8 : 0] Y_counter, // счётчик пикселей по вертикали
output reg select
);
// не забываем вместе с числом менять разрядность.
parameter size_x = 9'd511;
parameter size_y = 9'd511;
always @(posedge increment) begin
// здесь считаем пиксели
if(X_counter == size_x) begin
X_counter <= 9'd0;
Y_counter <= Y_counter + 1;
end else begin
X_counter <= X_counter + 1;
end
// переключаем буферы при переполнении счётчиков
if(X_counter == size_x && Y_counter == size_y) begin
Y_counter <= 9'd0;
select <= ~select;
end
end
endmodule
Модуль верхнего уровня
module TV_TOP (
// тактирование (50 МГц)
input wire clk_in,
// видеогенератор
output wire sync_out,
output wire [7 : 0] video_out,
// оперативная память, 2 буфера
output wire [17 : 0]adress_SRAM_A,
output wire [17 : 0]adress_SRAM_B,
inout wire[7 : 0]data_SRAM_A,
inout wire[7 : 0]data_SRAM_B,
output wire we_SRAM_A,
output wire ce_SRAM_A,
output wire we_SRAM_B,
output wire ce_SRAM_B,
// последовательный порт
input wire uart_rx_wire
);
// провода между модулями
wire [7 : 0] video;
wire [8 : 0] x_pix;
wire [8 : 0] y_line;
wire [17 : 0] temp_XY_1;
wire [8 : 0] X_counter;
wire [8 : 0] Y_counter;
wire [17 : 0] temp_XY_2;
wire select;
wire [7 : 0] uart_rx_data;
wire uart_rx_valid;
// Чтобы изменить разрешение, надо еще залезть в модуль PIXEL_INCREMENT и поменять там разрешение
// Урезанное разрешение
/* assign temp_XY_1 [8 : 3] = x_pix[5 : 0];
assign temp_XY_1 [17 : 12] = y_line[5 : 0];
assign temp_XY_2 [8 : 3] = X_counter[5 : 0];
assign temp_XY_2 [17 : 12] = Y_counter[5 : 0]; */
//
// Полное разрешение
assign temp_XY_1 [8 : 0] = x_pix[8 : 0];
assign temp_XY_1 [17 : 9] = y_line[8 : 0];
assign temp_XY_2 [8 : 0] = X_counter[8 : 0];
assign temp_XY_2 [17 : 9] = Y_counter[8 : 0];
//
PIXEL_INCREMENT(
.increment(uart_rx_valid),
.X_counter(X_counter),
.Y_counter(Y_counter),
.select(select)
);
PAL_GEN_2 (
.clk_in(clk_in),
.sync_out(sync_out),
.video_in(video),
.video_out(video_out),
.x_pix(x_pix),
.y_line(y_line)
);
SRAM_MUX(
.ce(uart_rx_valid),
.select(select),
.adress_W(temp_XY_2),
.data_W(uart_rx_data),
.adress_R(temp_XY_1),
.data_R(video),
.adress_SRAM_A(adress_SRAM_A),
.adress_SRAM_B(adress_SRAM_B),
.data_SRAM_A(data_SRAM_A),
.data_SRAM_B(data_SRAM_B),
.we_SRAM_A(we_SRAM_A),
.ce_SRAM_A(ce_SRAM_A),
.we_SRAM_B(we_SRAM_B),
.ce_SRAM_B(ce_SRAM_B)
);
module_uart(
.uart_clk(clk_in),
.uart_rx_wire(uart_rx_wire),
.uart_rx_valid(uart_rx_valid),
.uart_rx_data(uart_rx_data)
);
endmodule
Вспомогательное ПО
Работа на стороне ПЛИС завершена, теперь надо написать программу, которая будет отправлять в последовательный порт какую-нибудь картинку. Быстрее и проще всего мне было написать её на Java в среде Processing. Программа очень маленькая и простая, при желании вы можете написать свою на любом другом языке. Всё, что она делает, это переводит изображение в массив пикселей и отправляет этот массив в последовательный порт.
Код на Java для Processing
import processing.serial.*;
Serial myPort;
int halfImage;
void setup()
{
size(512, 512);
int halfImage = width * height;
String portName = Serial.list()[0];
myPort = new Serial(this, portName, 921600);
PImage myImage = loadImage("KOT4.png");
image(myImage, 0, 0);
filter(GRAY);
loadPixels();
updatePixels();
byte buf[] = new byte[halfImage];
for(int i = 0; i < halfImage; i++)
buf[i] = (byte)pixels[i];
myPort.write(buf);
delay(20);
myPort.stop();
}
void draw() {}
Демонстрация
Наконец-то можно насладиться плодами проделанной работы, вставляем USB-TTL конвертер в USB порт компьютера, прошиваем ПЛИС, запускаем программу и смотрим на телевизор.
![Кот Кот](https://habrastorage.org/getpro/habr/upload_files/62f/c11/d47/62fc11d4751cd9a043043906f980bff2.jpg)
![Ещё кот Ещё кот](https://habrastorage.org/getpro/habr/upload_files/7d1/3aa/e03/7d13aae03b2035456c9ba0c608164577.jpg)
Было бы преступлением не вывести Bad Apple
Заключение
Этот проект занял у меня немало времени, но я рад, что всё получилось. Подобные мини проекты очень сильно помогают прокачать навыки и получить практический опыт. Несмотря на то, что проект удался, не стоит забывать, что в нём могут быть ошибки. Если среди вас есть специалисты, которые нашли ошибку или хотят поделиться своим опытом, то пишите в комментарии или мне лично.
Комментарии (38)
zatim
14.02.2025 13:05А можно поподробнее, как физически реализовали двойной буфер? В какой момент происходит перезапись из одного буфера в другой?
Superzoos Автор
14.02.2025 13:05Буфер представляет собой 2 отдельные микросхемы. У буфера есть вход select. Когда select равен нулю , то происходит запись в первую микросхему и чтение из второй микросхемы. Когда select равен единице, то происходит запись во вторую микросхему и чтение из первой. Не происходит перезаписи из одной микросхемы в другую, они просто меняются местами.
zatim
14.02.2025 13:05Меняются местами по команде хоста?
Superzoos Автор
14.02.2025 13:05Каждый полученный байт инкрементирует счётчик пикселей. Когда счётчик досчитывает до конца, то сигнал select инвертируется. Получается, что буфер переворачивается, когда полностью заполняется.
checkpoint
14.02.2025 13:05Отрисовка в буфер обычно занимает существенно больше времени, чем время вывода одного кадра. Если программа не успеет, то могут появиться неприятные визуальные артефакты. Поэтому, ИМХО, было бы не плохо дать программе возможность управлять процессом переключения буферов.
Superzoos Автор
14.02.2025 13:05Я могу хоть 3 часа писать в первый буфер. Картинка то выводится из второго и они не поменяются местами, пока запись не завершится. Буферы меняются местами только после полной перезаписи записываемого буфера. При таком подходе они могут поменяться прямо посреди кадра , но это происходит так быстро , что этого не видно.
Если бы смена буфера осуществлялась между кадрами на каждом кадре , то действительно, надо было бы успевать перезаписывать буфер в течение одного кадра.
johnfound
14.02.2025 13:05Если они и правда меняются в середине кадра, то как бы вы быстро не меняли их, будут артефакты на быстрых движениях. Надо менять строго во время обратного хода луча. Это вообще обязательно.
zatim
14.02.2025 13:05Мне кажется, не критично. Ну, испортится один кадр артефактами. Следующие кадры ведь воспроизведутся верно. Глаз не сможет заметить повреждение одного кадра.
Superzoos Автор
14.02.2025 13:05Если смена буфера произойдёт посреди кадра, то там не будет битых пикселей, просто первая половина экрана покажет старый кадр, а вторая покажет новый. Если кадры отличаются не сильно, то этого не видно.
sintech
14.02.2025 13:05Чтобы всем было понятно, нужно нарисовать схему со всеми компонентами или хотябы блок-схему связей.
Интересный проект.
JerryI
14.02.2025 13:05Хабр Торт! Искренне радуюсь, когда вижу подобные статьи. Пробуйте больше с ПЛИС и пишите :)
checkpoint
14.02.2025 13:05Супер! И даже Bad Apple не забыли. :)
Superzoos Автор
14.02.2025 13:05Позавчера читал вашу статью. Глаза на лоб полезли. Очень круто получилось!
checkpoint
14.02.2025 13:05Я рад что Вам понравилось. :-)
Вашу статью я отправил своим студентам как пример того какие безграничные возможности есть у ПЛИС. И да, спасибо за то, что не стали рекламировать чей-то очередной телеграм канальчик. ;)
Superzoos Автор
14.02.2025 13:05Я сам студент 4 курса)
Рекламировать мне некого, я это для себя делаю.
checkpoint
14.02.2025 13:05То была шутка юмора. Несколько подзадрали авторы которые пишут ради рекламы своего дохлого канальчика.
r3dfx
14.02.2025 13:05Было бы круто если попробовали сделать полноценный TBC (корректор временны́х искажений), пусть даже строчный, а не полнокадровый... Очень нужная для работы с аналоговыми источниками, но редкая вещь и новых никто не производит(
TBC - это по сути АЦП, буффер и ЦАП. Строки цифруются и сохраняются в память, а потом "выдаются", но уже через равные промежутки времени, что позволяет это все потом без потерь кадров захватить...
SADKO
14.02.2025 13:05Ох, ковырял их и когда-то... Но сейчас ИМХО проще организовать оцифровку с избыточностью, причём на аппаратном уровне магнитофона. Ведь на не потребно реальное время, а вычислительной мощности сегодня предостаточно... Я был готов реализовать такой проект в середине нулевых, но не нашлось должного количества потенциальных заказов, в отличии на сканирование звуковых аналоговых лент.
alekseypro
14.02.2025 13:05Хоть я так и не полюбил ПЛИС-ы, но автору респект. Сам когда-то писал "велосипеды" для вывода картинок/текста на зомбоящик на AVR, STM32 и RISC-V.
HardWrMan
14.02.2025 13:05Годно. Но автору следует поизучать схемотехнику старых восьмибитных компьютеров с графическим экраном, например Специалист или Орион. И тогда окажется, что даже VGA развёртку можно уместить в 32 ячейки, лол.
128 ячеек самой простой CPLD хватает на синхрогенератор и контроллер обычной DRAM (для SDRAM понятно нужно чуть больше). И ещё бы на маппер хватило, но тут я упёрся в лимит ног, поэтому пришлось ставить второй CPLD. Что внутри CPLD:
По теме: как-то лет 20 назад была мысль собрать тупой терминал, который бы генерировал картинку на стандартный VGA монитор в альфавитно-цифровом виде или даже поддерживал графику и просто бы эмулировал какой-нибудь терминал на UART вроде VT52. Так же он должен был уметь в X/Y/Z-Modem. Смысл: подключаться к своим устройствам на МК, у которых для отладки просто UART. А потом кто-то сначала запилил телетерминал на AVR и PIC32 ив конце концов тема перестала быть актуальной, увы. С другой стороны, устройств с консолью на UART ещё хватает (вроде роутеров всяких), так что может быть такой терминальчик сделать на LCD панельке ещё есть смысл?
Скрытый текст
PS Вот, например, логгер-визуализатор активности шины Z80 на VGA мониторе. Требуется выводить регулярные структуры, поэтому хватило внутренней памяти M9K. Девборда:
Результат работы:
Код:
// Z80 VGA module Z80_VGA ( // Такты input CLK, // 50MHz // Кнопка input KEY0, // Лампочки output LED, output reg [2:0]ROW, output reg [7:0]DIGIT, // VGA output reg [4:0]R, output reg [5:0]G, output reg [4:0]B, output reg HSYNC, output reg VSYNC, // Z80 input [15:0]ADR, input nM1, input nBUSA, input nRD, input nWR, // Отладка output [3:0]Debug0, output [3:0]Debug1, output [3:0]Debug2, output [3:0]Debug3, output [3:0]Debug4, output [3:0]Debug5 ); // PLL wire MAINCLK; wire LEDCLK; PLL PLL1(CLK, MAINCLK, LEDCLK); // RAM wire [5:0]RamPix; RAM RAM1(RamAdr[15:0],MAINCLK,NewPix[5:0],RamUpdate,RamPix[5:0]); // Переменные reg [1:0]Button; reg Mode; reg [1:0]Scan; reg [11:0]Digits; reg [3:0]Arb; reg [9:0]X; reg [9:0]Y; reg WinSync; reg FrameX; reg FrameY; reg ArrowX; reg ArrowY; reg YMSync; reg YMX; reg YMY; reg PSGSync; reg PSGX; reg PSGY; reg BankSync; reg BankX; reg BankY; reg [15:0]SyncAdr; reg SyncM1; reg SyncBUSA; reg SyncRD; reg SyncWR; reg [15:0]DelayAdr0; reg [15:0]DelayAdr1; reg [15:0]DelayAdr2; reg [2:0]DelayM1; reg [2:0]DelayBUSA; reg [1:0]EdgeRD; reg [1:0]EdgeWR; reg [15:0]ReqAdr; reg [2:0]ReqCol; reg [15:0]WrAdr; reg [5:0]WrPix; reg [15:0]RamAdr; reg [5:0]NewPix; reg RamUpdate; reg [5:0]Frames; // Комбинаторика assign LED = ~Mode; wire WrStb; assign WrStb = ~EdgeWR[1] & EdgeWR[0]; wire RdStb; assign RdStb = ~EdgeRD[1] & EdgeRD[0]; wire Zoom; assign Zoom = (YMSync | PSGSync | BankSync) ? Y[4] & Y[3] & Y[2] & Y[1] & Y[0] & X[3] & X[2] & X[1] & X[0] : (Y[1] | Y[8]) & Y[0] & (X[0] | Y[8]); wire [2:0]Decay; assign Decay[2:0] = (~Frames[4] & ~Frames[3] & ~Frames[2] & ~Frames[1] & ~Frames[0] & Zoom) ? 3'h1 : 3'h0; // BIN wire [3:0]BIN; assign BIN[3:0] = (~Scan[1]) ? (~Scan[0]) ? Digits[3:0] : Digits[7:4] : (~Scan[0]) ? Digits[11:8] : 4'h0; // Отладка assign Debug0[3:0] = Arb[3:0]; assign Debug1[3:0] = {DelayBUSA[2],DelayM1[2], WrStb, RdStb}; assign Debug2[3:0] = DelayAdr2[3:0]; assign Debug3[3:0] = DelayAdr2[7:4]; assign Debug4[3:0] = DelayAdr2[11:8]; assign Debug5[3:0] = DelayAdr2[15:12]; // Экранчик always @(posedge LEDCLK) begin // Отображение экранчика Scan[1:0] <= Scan[1:0] + 2'h1; // Сканирование разрядов ROW[0] <= Scan[1] & ~Scan[0]; ROW[1] <= ~Scan[1] & Scan[0]; ROW[2] <= ~Scan[1] & ~Scan[0]; // Преобразование HEX case (BIN[3:0]) // D : HGCB AFED 4'h0 : DIGIT[7:0] <= 8'hC0; // 0 : 1100 0000 4'h1 : DIGIT[7:0] <= 8'hCF; // 1 : 1100 1111 4'h2 : DIGIT[7:0] <= 8'hA4; // 2 : 1010 0100 4'h3 : DIGIT[7:0] <= 8'h86; // 3 : 1000 0110 4'h4 : DIGIT[7:0] <= 8'h8B; // 4 : 1000 1011 4'h5 : DIGIT[7:0] <= 8'h92; // 5 : 1001 0010 4'h6 : DIGIT[7:0] <= 8'h90; // 6 : 1001 0000 4'h7 : DIGIT[7:0] <= 8'hC7; // 7 : 1100 0111 4'h8 : DIGIT[7:0] <= 8'h80; // 8 : 1000 0000 4'h9 : DIGIT[7:0] <= 8'h82; // 9 : 1000 0010 4'hA : DIGIT[7:0] <= 8'h81; // A : 1000 0001 4'hB : DIGIT[7:0] <= 8'h98; // B : 1001 1000 4'hC : DIGIT[7:0] <= 8'hF0; // C : 1111 0000 4'hD : DIGIT[7:0] <= 8'h8C; // D : 1000 1100 4'hE : DIGIT[7:0] <= 8'hB0; // E : 1011 0000 4'hF : DIGIT[7:0] <= 8'hB1; // F : 1011 0001 endcase end // Синхронная логика always @(posedge MAINCLK) begin // Считаем арбитер Arb[3:0] <= Arb[3:0] + 4'h1; // Синхронизация if (~Arb[0]) begin SyncAdr[15:0] <= ADR[15:0]; SyncM1 <= nM1; SyncBUSA <= nBUSA; SyncRD <= nRD; SyncWR <= nWR; end // Детектор строба if (~Arb[3] & ~Arb[2] & ~Arb[1] & ~Arb[0]) begin // Сохраняем адрес DelayAdr0[15:0] <= SyncAdr[15:0]; DelayAdr1[15:0] <= DelayAdr0[15:0]; DelayAdr2[15:0] <= DelayAdr1[15:0]; // Задержка M1 DelayM1[2:0] <= {DelayM1[1:0],SyncM1}; // Задержка BUSA DelayBUSA[2:0] <= {DelayBUSA[1:0],SyncBUSA}; // Синхронизируем стробы и адрес EdgeRD[1:0] <= {EdgeRD[0],SyncRD}; EdgeWR[1:0] <= {EdgeWR[0],SyncWR}; // Синхронизируем адрес ReqAdr[15:0] <= DelayAdr2[15:0]; // Формируем запрос ReqCol[0] <= WrStb | (RdStb & ~DelayBUSA[2]); ReqCol[1] <= RdStb; ReqCol[2] <= (WrStb & ~DelayBUSA[2]) | (RdStb & DelayBUSA[2] & ~DelayM1[2]); end // Только 1 раз из 4 if (Arb[1] & Arb[0]) begin // Арбитраж case (Arb[3:2]) // Состояние 1 2'h0 : begin // Считываем и отображаем текущий пиксель if (~(FrameX & FrameY)) begin R[4:0] <= 5'h00; G[5:0] <= 6'h00; B[4:0] <= 5'h00; end else if (WinSync | YMSync | PSGSync | BankSync) begin // Получаем и модифицируем пиксель if (RamPix[2] | RamPix[1] | RamPix[0]) NewPix[5:0] <= {RamPix[5:3],RamPix[2:0] - Decay[2:0]}; else NewPix[5:0] <= 6'h00; // Отображаем пиксель R[4:0] <= (RamPix[3]) ? {RamPix[2:0],RamPix[0] | (Y[8] & ~BankSync),RamPix[0] | (Y[8] & ~BankSync)} : {3'h0,Y[8] & ~BankSync,Y[8] & ~BankSync}; G[5:0] <= (RamPix[4]) ? {RamPix[2:0],RamPix[0] | (Y[8] & ~BankSync),RamPix[0] | (Y[8] & ~BankSync),RamPix[0] | (Y[8] & ~BankSync)} : {3'h0,Y[8] & ~BankSync,Y[8] & ~BankSync,Y[8] & ~BankSync}; B[4:0] <= (RamPix[5]) ? {RamPix[2:0],RamPix[0] | (Y[8] & ~BankSync),RamPix[0] | (Y[8] & ~BankSync)} : {3'h0,Y[8] & ~BankSync,Y[8] & ~BankSync}; // Заказываем обновление пикселя RamUpdate <= 1'b1; end else if (ArrowX & ArrowY & Mode) begin R[4:0] <= 5'h1F; G[5:0] <= 6'h3F; B[4:0] <= 5'h1F; end else begin R[4:0] <= 5'h08; G[5:0] <= 6'h10; B[4:0] <= 5'h08; end // Переносим запрос if (ReqCol[2] | ReqCol[1] | ReqCol[0]) begin WrAdr[15:0] <= ReqAdr[15:0]; WrPix[5:0] <= {ReqCol[2:0],3'h7}; ReqCol[2:0] <= 3'h0; end end // Состояние 2 2'h1 : begin // Снимаем флаг обновления RamUpdate <= 1'b0; // Подготавливаем пиксель NewPix[5:0] <= WrPix[5:0]; // Устанавливаем адрес записи RamAdr[15:0] <= WrAdr[15:0]; end // Состояние 3 2'h2 : begin // Управление стробом записи if (WrPix[5] | WrPix[4] | WrPix[3]) begin RamUpdate <= 1'b1; Digits[11:0] <= RamAdr[15:4]; end end // Состояние 4 2'h3 : begin // Синхронизируем окно WinSync <= ~Y[9] & ~(X[9] | X[8]); YMSync <= YMX & YMY; PSGSync <= PSGX & PSGY; BankSync <= BankX & BankY; // Считаем X if (X[9:0] == 10'h20F) X[9:0] <= 10'h000; else X[9:0] <= X[9:0] + 10'h001; // Синхронизация HSYNC if (X[9:0] == 10'h15C) begin HSYNC <= 1'b0; // Считаем Y if (Y[9:0] == 10'h273) Y[9:0] <= 10'h000; else Y[9:0] <= Y[9:0] + 10'h001; end else if (X[9:0] == 10'h19C) HSYNC <= 1'b1; // Синхронизация VSYNC if (Y[9:0] == 10'h22D) begin VSYNC <= 1'b0; Frames[4:0] <= Frames[4:0] + 5'h1; Button[1:0] <= {Button[0],KEY0}; if (Button[1] & ~Button[0]) Mode <= ~Mode; end else if (Y[9:0] == 10'h231) VSYNC <= 1'b1; // Рамка if (X[9:0] == 10'h1C8) FrameX <= 1'b1; else if (X[9:0] == 10'h148) FrameX <= 1'b0; if (Y[9:0] == 10'h249) FrameY <= 1'b1; else if (Y[9:0] == 10'h22C) FrameY <= 1'b0; // Стрелка if (X[9:0] == 10'h200) ArrowX <= 1'b1; else if (X[9:0] == 10'h20E) ArrowX <= 1'b0; if (Y[9:0] == 10'h07F) ArrowY <= 1'b1; else if (Y[9:0] == 10'h080) ArrowY <= 1'b0; // Поле YM if (X[9:0] == 10'h110) YMX <= 1'b1; else if (X[9:0] == 10'h120) YMX <= 1'b0; if (Y[9:0] == 10'h000) YMY <= 1'b1; else if (Y[9:0] == 10'h080) YMY <= 1'b0; // Поле PSG if (X[9:0] == 10'h110) PSGX <= 1'b1; else if (X[9:0] == 10'h120) PSGX <= 1'b0; if (Y[9:0] == 10'h0A0) PSGY <= 1'b1; else if (Y[9:0] == 10'h0C0) PSGY <= 1'b0; // Поле BANK if (X[9:0] == 10'h110) BankX <= 1'b1; else if (X[9:0] == 10'h120) BankX <= 1'b0; if (Y[9:0] == 10'h100) BankY <= 1'b1; else if (Y[9:0] == 10'h120) BankY <= 1'b0; // Снимаем строб записи RamUpdate <= 1'b0; // Снимаем запрос WrPix[5:0] <= 6'h00; // Выставляем адрес пикселя RamAdr[15:0] <= (YMX & YMY) ? {14'h1000,Y[6:5]} : (PSGX & PSGY) ? 16'h7F11 : (BankX & BankY) ? 16'h6000 : (Y[8]) ? {1'b1,Y[7:1],X[7:0]} : (Mode) ? {3'h0,Y[6:2],Y[7],X[7:1]} : {3'h0,Y[7:2],X[7:1]}; end endcase end end // Конец endmodule
Ресурсы (с учётом реализации HEX индикации и прочего не относящегося к VGA:
Superzoos Автор
14.02.2025 13:05Синхронизация VGA намного проще , чем синхронизация PAL. Я совсем не удивлён, что её можно впихнуть в 32 макроячейки. Если вы напишете модуль, который может то же самое, что и мой , но влезает в 32 макроячейки , то я буду аплодировать стоя :)
HardWrMan
14.02.2025 13:05У тебя не PAL, ибо цвета у тебя нет. В CPLD выше тоже формируется ТВ развёртка в ыормате 15625/50, но это не PAL. Я делал генератор для реального PAL, со вспышками и поднесущими. Там во-первых определённые частоты, а во-вторых нужен прям ЦАП, чтобы поднесущую можно было наложить на яркость. Это всё есть в моей реализации PPU NES на FPGA. Но я делал и для Спектрума тоже.
Если вы напишете модуль, который может то же самое, что и мой , но влезает в 32 макроячейки , то я буду аплодировать стоя :)
Такое в 32 не влезет только по причине обслуживания DRAM. Там мульииплексоров будет как раз на 32 LE. Но прилично ещё ужать ваш проект без потери функционала реально.
XenRE
14.02.2025 13:05Но ведь это ни разу не PAL. PAL - это стандарт цветного видеосигнала, а тут простой монохромный.
SADKO
14.02.2025 13:05Не, чё годно, кто плавал - оценит! Однако где здесь PAL? Это называется композитным видеосигналом ПТС, по частотам и разложению его можно отнести к PAL\SECAM-ам, но там речь о цвете...
...предлагаю для повышения кликбейтности переименовать PAL в SECAM, дабы попавши на глаза, он вызывал в мозге зрителя сигнал "ШТА!?!?" ;-)Superzoos Автор
14.02.2025 13:05Такое кликбейтное название было выбрано без злого умысла. Я взял временную диаграмму от сигнала PAL и на этом основании назвал так статью. Вы правы, что это не совсем корректно.
Vadrov1
14.02.2025 13:05И, все-таки, несмотря на то, что все это очень интересно... В очередной раз, упоминание PAL здесь совсем неуместно. Статью однозначно следовало назвать как-то так: "ч/б видеоадаптер к ТВ на FPGA". С таким же успехом можно было бы взять идентичную временную диаграмму от SECAM, или "неидентичную" от NTSC. А до PAL здесь еще очень далеко: генератор поднесущей, квадратурные модуляторы, коммутаторы четных/нечетных строк, сумматоры,...
Вот тоже баловался с программными кодерами PAL, NTSC, SECAM на stm32:
SADKO
14.02.2025 13:05Ой, а запилите хотя-бы пост, кратенько по итогу, сколько времени оно кушает...
Superzoos Автор
14.02.2025 13:05Что кушает? На что?
SADKO
14.02.2025 13:05это же stm32 :-) там вычисления занимают конкретное время и можно прикинуть на что его ещё остаётся, например подцепить камеру, пройтись по кадру свёртками, подписи какие-то добавить, итд...
Vadrov1
14.02.2025 13:05Это мне вопрос. :) В зависимости от типа кодера (PAL, NTSC, SECAM) по-разному. Для pal затраты на "видеоадаптер" составляют в пределах 15% процессорного времени, для NTSC - 13%. Для SECAM не смотрел, но, естественно, явно больше и косвенно можно оценить в сравнении с PAL. Если в PAL, например, демка дает до 36 fps, то в SECAM - до 20 fps. Соответственно, в SECAM "видеоадаптер" кушает в пределах 27% времени ядра.
Здесь надо смотреть еще и в сторону пропускной способности DMA. По дефолту 407 "камень" не потянет такую скорость (~18 Msps PAL, ~15 Msps NTSC, 13.5 Msps SECAM) для DAC. Плюс еще один поток 44100 Гц для звука. Плюс еще один DMA поток для обслуживания "видеоадаптера" ("заливка" и т.п.).
Brak0del
Клёвый проект!
В хаб FPGA не хотите добавить? Вроде релевантно.
Superzoos Автор
Действительно, добавил