habrahabr

PAL видеоадаптер на FPGA с буфером кадра

  • воскресенье, 16 февраля 2025 г. в 00:00:09
https://habr.com/ru/articles/882626/

Наверное, каждый второй разработчик на ПЛИС в начале своего пути пытался визуализировать работу своих схем. Кто-то подключал 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В, этот контакт будет синхронизирующим.

Схема ЦАП в Multisim
Схема ЦАП в Multisim

Описание на 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
Подключение микросхемы 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

Заключение

Этот проект занял у меня немало времени, но я рад, что всё получилось. Подобные мини проекты очень сильно помогают прокачать навыки и получить практический опыт. Несмотря на то, что проект удался, не стоит забывать, что в нём могут быть ошибки. Если среди вас есть специалисты, которые нашли ошибку или хотят поделиться своим опытом, то пишите в комментарии или мне лично.