PAL видеоадаптер на FPGA с буфером кадра
- воскресенье, 16 февраля 2025 г. в 00:00:09
Наверное, каждый второй разработчик на ПЛИС в начале своего пути пытался визуализировать работу своих схем. Кто-то подключал TFT-дисплей, кто-то — VGA монитор. А у меня под рукой оказался только телевизор с композитным входом. Ну что ж, работаем с тем, что есть!
Сразу скажу, что статья рассчитана на новичков, которые пришли сюда со стартовым набором проектов за плечами и тоже хотят посмотреть мультики на ПЛИС. Мой проект не является туториалом, потому что содержит в себе спорные, расточительные и, возможно, ошибочные решения. Я просто хочу показать, что у меня получилось.
Не так давно я начал работать с ПЛИС. Эта тема мне очень понравилась, и я решил начать делать различные мини проекты для того, чтобы попрактиковаться. Помигал светодиодами, сделал часы, вывел текст на LCD1602. И вот пришло время сделать что-то более крупное и интересное. Я захотел подключить к ПЛИС экран. Сначала думал подключить VGA монитор, но VGA монитора у меня не оказалось, зато оказался телевизор с композитным входом. Я начал искать статьи и видеоуроки, но не нашёл почти ничего про композитный PAL видеосигнал на ПЛИС. По теме композитного видеосигнала были либо толстенные серьёзные книжки, погружаться в которые я был не готов, либо картинки с временными диаграммами PAL и NTSC, взятые из этих самых книжек. В итоге моё нежелание потратить пару недель на чтение тяжёлой литературы повело меня по эмпирическому пути. Я обложился самыми понятными, на мой взгляд, рисунками временных диаграмм и приступил к их осмыслению.
Для начала, нужно хотя бы в первом приближении понять, что из себя представляет композитный видеосигнал, почему он такой, что понадобится для его генерации и с какими проблемами мы можем столкнуться.
Во времена, когда разрабатывался композитный видеосигнал, передача данных сразу по нескольким проводам или радиоканалам была сложным и/или дорогим решением, поэтому видеоданные и синхроимпульсы передаются по одному проводу. Чтобы приёмник мог надёжно отделить видеоданные от синхроимпульсов, необходимо, чтобы они отличались по уровню напряжения и чтобы синхроимпульсы располагались вне области значений напряжения, используемых для видеоданных, а также следовали в строго определённые моменты времени. Для синхроимпульсов используется область значений напряжения от 0В до 0.3В, а для видеоданных от 0.3В до 1В. С видеоданными всё понятно, 0.3В — это чёрный, 1В — это белый, а вот с синхроимпульсами посложнее. Дело в том, что кадр состоит из 625 строк, но не все из них являются обычными строками, некоторые из них не являются видимыми, а некоторые разбиты на полустроки и служат для кадровой синхронизации. К тому же развёртка чересстрочная, то есть при частоте 50 кадров в секунду на самом деле будет только 25 полных кадров и между этими полукадрами тоже есть свой полукадровый синхроимпульс. Вдобавок строчные и кадровые синхроимпульсы имеют разную длительность. Чтобы было понятнее, посмотрим на какую-нибудь картинку.
Именно эту картинку я использовал при осмыслении 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В, этот контакт будет синхронизирующим.
Когда я только начал описывать схему, мне было ещё тяжело удержать всё в голове, поэтому я пошел напролом самым линейным путём из возможных. Я просто сделал глобальный счётчик на 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
Запускаем синтез и тестируем.
Отлично, фрактал на месте, видеогенератор ведёт себя очень хорошо. А что там с ресурсозатратами?
Какой ужас, даже не поместилось бы в EPM240T100C5N, нам такое не подходит, переделываем.
Описывать схему таким прямолинейным способом было очень расточительным решением, которое привело к образованию огромного количества защёлок. Повторяющиеся места в алгоритме не оптимизированы, а счётчики дублируются. Это описание работает стабильно, но не рекомендуется к использованию.
Теперь, когда я точно понимаю, как выглядит работающий видеосигнал, я готов направить свои мыслительные ресурсы на реализацию более оптимального решения задачи. Первое, что нужно сделать – сменить концепцию, теперь алгоритм будет задаваться с помощью конечного автомата. У автомата будет отдельное состояние на каждый участок временной диаграммы. M1 - кадровый синхроимпульс, N1 - уравнивающие импульсы, L1 - уравнивающие импульсы, M2 - полукадровый синхроимпульс, N2 - уравнивающие импульсы, L2 - уравнивающие импульсы, Hf - полная 6 строка, Hh - вторая половина 318 строки, Hs - первая половина 623 строки, F1 - нечетные строки, F2 - четные строки.
Это мой первый конечный автомат, поэтому он не идеален. Думаю, можно было бы как-то объединить некоторые состояния и использовать их повторно, но я не стал морочить голову и сделал ровную последовательную цепочку. Это слишком простой автомат с простыми состояниями, попытка объединить одинаковые состояния приведёт к усложнению условия перехода и к потребности в использовании дополнительных регистров.
Вторым шагом надо оптимизировать счётчики. Нет необходимости считать все 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
Запускаем синтез и тестируем.
Отлично, на телевизоре такая же картинка. А что стало с ресурсозатратами?
Совсем другое дело. Можно было уложиться в 80 макроячеек, но меня полностью устраивает этот результат, он почти в 3 раза превзошёл предыдущий. Работает – не трогай!
Временная диаграмма синхроимпульсов снятая логическим анализатором:
Мы не можем просто так указать видеогенератору адрес, по которому нужно вывести конкретный пиксель. Видеогенератор сам перебирает адреса на своём выходе и выводит пиксель, яркость которого соответствует значению на шине данных. Поэтому нам нужен буфер, который мы сможем сами перезаписывать. В качестве такого буфера отлично подходит микросхема статической оперативной памяти AS7C34098A. Память имеет 18 битную адресную шину, именно поэтому я выбрал разрешение 512 на 512 пикселей. У себя на работе я смог раздобыть несколько таких микросхем SRAM, поэтому я был лишён удовольствия потратить ещё неделю на попытки написать контроллер для более дешёвой и доступной SDRAM. Контроллер для 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 состоянии.
Два самых важных модуля уже позади, теперь надо подумать о том, каким образом изображение окажется в микросхеме памяти. Для решения этой задачи был написан модуль последовательного приёмника и модуль со счётчиком адресов, который будет управлять буфером кадра. Тут нечего объяснять, просто приведу описание.
// Этот модуль реализует простейший приёмник последовательного интерфейса.
// Он работает только на приём.
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. Программа очень маленькая и простая, при желании вы можете написать свою на любом другом языке. Всё, что она делает, это переводит изображение в массив пикселей и отправляет этот массив в последовательный порт.
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 порт компьютера, прошиваем ПЛИС, запускаем программу и смотрим на телевизор.
Было бы преступлением не вывести Bad Apple
Этот проект занял у меня немало времени, но я рад, что всё получилось. Подобные мини проекты очень сильно помогают прокачать навыки и получить практический опыт. Несмотря на то, что проект удался, не стоит забывать, что в нём могут быть ошибки. Если среди вас есть специалисты, которые нашли ошибку или хотят поделиться своим опытом, то пишите в комментарии или мне лично.