
Наверное, каждый второй разработчик на ПЛИС в начале своего пути пытался визуализировать работу своих схем. Кто-то подключал 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В, этот контакт будет синхронизирующим.
Описание на 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
Запускаем синтез и тестируем.
Отлично, фрактал на месте, видеогенератор ведёт себя очень хорошо. А что там с ресурсозатратами?
Какой ужас, даже не поместилось бы в 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 написать всё-таки придётся. Нельзя одновременно перезаписывать и считывать память, поэтому я реализую двойную буферизацию. Двойная буферизация позволяет произвольным образом записывать в первый буфер, пока изображение считывается видеогенератором из второго буфера. Эти буферы должны быть двумя отдельными микросхемами памяти.
Теперь, когда стало понятно, как будет работать буфер кадра, можно подключить к ПЛИС микросхемы и написать простейший контроллер памяти. Этот контроллер будет очень простым. С одной стороны будет вход только для записи, а с другой стороны будет выход только для чтения. При необходимости можно сделать контроллер симметричным, но в этом проекте это избыточно.
Модуль контроллера 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

Вспомогательные модули
Два самых важных модуля уже позади, теперь надо подумать о том, каким образом изображение окажется в микросхеме памяти. Для решения этой задачи был написан модуль последовательного приёмника и модуль со счётчиком адресов, который будет управлять буфером кадра. Тут нечего объяснять, просто приведу описание.
Модуль последовательного приёмника
// Этот модуль реализует простейший приёмник последовательного интерфейса. // Он работает только на приём. 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 порт компьютера, прошиваем ПЛИС, запускаем программу и смотрим на телевизор.
Было бы преступлением не вывести Bad Apple
Заключение
Этот проект занял у меня немало времени, но я рад, что всё получилось. Подобные мини проекты очень сильно помогают прокачать навыки и получить практический опыт. Несмотря на то, что проект удался, не стоит забывать, что в нём могут быть ошибки. Если среди вас есть специалисты, которые нашли ошибку или хотят поделиться своим опытом, то пишите в комментарии или мне лично.
ссылка на оригинал статьи https://habr.com/ru/articles/882626/
Добавить комментарий