Вначале мы поморгали диодом, затем посчитали семисегментником. Если с нуля, то уж лучше пройти эти этапы. Теперь приступим к задачке, которую с большой натяжкой можно применить где-нибудь на проде. С очень большой натяжкой.

На плате, которую я использую для примера (QMTECH Cyclone 10 Starter Kit), есть разъём HDMI, что недвусмысленно намекает нам, что к нему можно подключить дисплей соответствующим кабелем. На самом деле, разъём – это не обязательно. А вот наличие на чипе выходов, которые можно сконфигурировать как lvds, очень сильно приветствуется. Возможно, получится и без этого (просто 2 выхода, формируемые из одного инверсией), но я не пробовал, потому промолчу.

Когда мы embedded-программисты, то с дисплеем мы взаимодействуем, отправляя на него команды, либо, если всё хорошо с железом, то пишем в специальную область памяти.

В этой статье мы будем работать на более низком уровне. Делать на коленке прозрачные электроды, и наклеивать поляризационные плёнки, наверное, не надо. Будем формировать видео-сигнал, который идёт на монитор / телевизор.

Но совсем нахрапом не получится. Придётся сделать небольшой экскурс в тему передачи видео изображений.

Небольшой экскурс

Если вы думаете, что в 2025-м (или какой там сейчас у вас?) году ЭЛТ мониторы и телевизоры остались в далёком прошлом, то у меня для вас есть новость: Формат сигналов внутри проводов всё ещё напоминает сигнал, который идёт на одну из сеток большой вакуумной лампы, которой, по сути и является кинескоп.

Лучше всех, на мой взгляд, как работает развёртка на ЧБ-телевизоре и на VGA мониторе рассказал Ben Eater, в своей серии видеороликов. Там речь идёт не об HDMI, но общий принцип сильно похож. Ещё на бытовом, но очень хорошем и доступном уровне о принципах работы телевизора рассказал Алик с канала Technology Connections.

Я попробую парой абзацев описать свою версию, как оно работает. Имею в виду принцип телевидения.

У нас есть картинка (чёрно-белая, один кадр). Картинку надо превратить в одну линию (чтобы в провод запихнуть). Нарезаем кадр на очень тонкие горизонтальные полоски. К концу каждой полоски мы добавляем небольшой отрезочек с меткой, и эти полоски склеиваем одну за другой, начиная с самой верхней. Получается такая нитка, с тёмными и светлыми участками и метками. К концу картинки мы добавляем длинный отрезок-метку длиной в несколько линий, чтобы обозначить окончание кадра. Дальше можно распускать следующую картинку точно таким же образом. Темноту, свет и метки мы кодируем напряжением (чем выше напряжение, тем светлее. Метка по напряжению ниже, чем напряжение, которое мы принимаем за полностью чёрный на картинке).

Сигнал, вытянутый в нитку
Сигнал, вытянутый в нитку

Остаётся решить как из нитки собрать обратно картинку. Нарезаем нитку на отрезки (метки мы добавляли, чтобы знать, где резать), раскладываем их горизонтально один под другим. Получится исходная картинка, а за ней следующая и следующая (правда картинки получатся больше исходных – справа и снизу будут области из меток). Полоски (отрезки нитки), на которые мы нарезали картинку называют строками. Метки это синхроимпульсы.

Есть куча нюансов и дополнений (чересстрочная развёртка, цвет, разные форматы), но пока про них забудем, и в этой статье сюда возвращаться не станем – совсем оффтопик.

Это всё наши далёкие предки каким-то образом заставляли работать не то что без компьютеров и микроконтроллеров, но и без транзисторов вообще.

Концепт довольно-таки несложный, но только тогда, когда он уложен в голове. Я попробую ещё раз объяснить что такое развёртка. То же самое, но другими словами. Представьте, что вы в тёмной комнате проводите лазерной указкой по стене. За счёт инертности зрения, мы видим не точку от указки а полоску:

Котов пришлось успокаивать – они сезон охоты открыли
Котов пришлось успокаивать – они сезон охоты открыли

Если указку быстро включать и выключать, то мы видим отрезки. Если сподобиться и включать-выключать очень-очень быстро, то можно добиться, что отрезочки будут точками. если под одной линией впритык, сразу же, быстро прочертить вторую, затем следующую - следующую и так далее, при этом правильным образом включать-выключать луч, то можно на стене получить прямоугольную чёрно-белую (обойно-красную) картинку (какая картинка получится зависит от того, как мы включали и выключали указку). Горизонтальный синхроимпульс нужен, чтобы устройство, которое водит указкой, знало, где заканчивать предыдущую линию и начинать следующую. Вертикальный – чтобы знать, когда заканчивать этот кадр, и перемещать указку обратно вверх, чтобы начать следующий кадр.

Развёртка – это устройство, которое двигает указкой правильным образом ("разворачивает" путём быстрого движения точку в прямоугольник), а видеосигнал – это управление включением-выключением указки, вместе с импульсами для развёртки.

Сигнал VGA, по сути, это именно этот самый элементарный случай с ЧБ картинкой, с небольшими дополнениями. Вместо одного провода используется 5 (с землёй 6). 3 провода это сигналы яркости цвета (RGB), ещё 2 это горизонтальный и вертикальный синхроимпульсы.

Но это общее описание. Давайте ближе к делу. Чтобы приёмник и передатчик понимали друг друга, необходимо чтобы они придерживались стандартов. За ними я ходил в поисковик с запросом vga 640x480 timing. Первая ссылка сейчас вот. 640x480, в силу широкой поддержки устройствами. Посмотрим, что там:

640x480
640x480

Самая главная цифра здесь это Pixel freq. Время одного такта сигнала с этой частотой (его часто называют pixel clock) – это время между соседними пикселями по горизонтали. Когда я разбирался с этой темой мне очень не хватало такой картинки рядом с таблицей:

Пропорции здесь идеально учтены. По вертикали области называются так же как по горизонтали.
Пропорции здесь идеально учтены. По вертикали области называются так же как по горизонтали.

Visible area это 640 тактов pixel clock, на каждом клоке меняем уровни RGB, чтобы получилась нужная картинка, один клок - один пиксель. После Visible area ждём 16 тактов Front porch и включаем (прижимаем к земле) сигнал Sync горизонтали, держим его активным 96 тактов, отпускаем, и ждём ещё 48 тактов, прежде чем перейти к передаче следующей строки. Всего на строку приходится 800 тактов pixel clock. Рисуем таким образом 480 строк, ждём время равное 10 строкам (это вертикальный Front porch), затем на время двух строк включаем вертикальный синхроимпульс, выключаем его и досчитываем до 525-й строки, чтобы начать следующий кадр. Всё как в табличке стандарта.

Не совсем корректная, но очень простая реализация VGA на ПЛИС

Сначала я думал опустить описание реализации выхода VGA на ПЛИС в этой статье. во-первых, на моей плате разработки нет выхода VGA. Во-вторых, у меня нет VGA монитора, а ещё нет кабеля VGA. Но вход VGA есть на телевизоре, кабель стоит 3 копейки в магазине неподалёку, а отсутствие выхода на плате мы компенсируем наличием разноцветных соединительных проводов. К тому же, реализация VGA проще, чем реализация семисегментника из прошлой статьи. Заодно научимся формировать нужную частоту.

Итак, всё про нитку и лазерную указку держим в голове, в дополнение к этому надо принять следующее: Сигнал передаётся не по одному, но по 5 проводам (не считая земли). синхроимпульсы выведены на отдельные провода. Уровень яркости для R G и B каналов передаётся уровнем напряжения от 0 до 0.7 вольт (мы для простоты будем использовать дискретные выходы, либо ноль, либо максимум). Когда не надо передавать картинку (в области за visible area), сигналы RGB должны быть в состоянии 0 вольт. Cинхроимпульсы имеют уровни TTL, можно управлять логическими уровнями.

Исходя из вышесказанного, такой алгоритм: Сканируем с частотой pixel freq слева направо и сверху вниз переменные x и y, и в зависимости от их значений устанавливаем выходы синхроимпульсов в нужные уровни. Выходы сигналов цвета будем устанавливать так, чтобы получилось хоть что-то, что можно увидеть на экране. Пусть будет крест посередине экрана.

Код модуля VGA я спрячу под спойлер, там нет ничего такого чего я не описывал раньше.

Простая реализация VGA
module VGA(
	input wire pix_clk, //сюда мы подключим клок
                        //с частотой 25.175МГц (для VGA 640x480 60fps)  
	output wire h_sync, //Выход горизонтального синхроимпульса
	output wire v_sync, //Выход вертикального синхроимпульса
    output wire r,      //Сигналы интенсивности RGB
    output wire g,      //они, конечно, должны быть аналоговыми,
  	output wire b       //и иметь правильные напряжения, но нет. И так сойдёт
);
	reg [9:0] x; //макимальное число больше 512 и меньше 1024. Хватит 10 бит
	reg [9:0] y;

    //Волшебные цифры взяты из стандарта. Здесь это 800 x 525
    //Это "whole line" и "whole frame"
	//Считаем по горизонтали, в случае, если это последний пиксель,
    //то x возвращаем в ноль, и прибавляем y. Если последний пиксель
    //последней строки, то ещё и y в ноль.
    always @(posedge pix_clk) begin
		x <= (x == 799) ? 0 : x + 1; 
		if(x == 799) begin
			y <= (y == 524) ? 0 : y + 1;
		end
	end
	
	//Включаем сигналы синхронизации, если значения счётчиков находятся
    //в правильных пределах (цифры опять в стандарте)
    assign h_sync = !((x > (639 + 16)) && (x < (639 + 16 + 96)));
	assign v_sync = !((y > (479 + 10)) && (y < (479 + 10 +  2)));
	
	//Здесь можно формировать изображение. В данном случае это
    //белый крест посередине экрана
    assign r = (x >= 299 && x < 340) || (y >= 239 && y < 260);
	assign g = r;
	assign b = r;
	
endmodule

А вот код модуля main опять содержит нюансы

module main(
	input wire clk, //Входы и выходы как и у модуля VGA, только тут клок - это 
                	//входная тактовая платы разработки (50МГц)
	output wire h_sync,
	output wire v_sync,	
	output wire r,
	output wire g,	
	output wire b
);	
	wire pix_clk; //Провод для клока VGA
	
	alt_pll_var1 alt_pll_var1_inst( // Формирование нужного клока с помощью PLL
		.inclk0(clk), // названия входов и выходов берём в файле bb вариации 
		.c0(pix_clk)  // (что это такое есть в тексте статьи дальше)
	);

	VGA VGA_inst(
		.pix_clk(pix_clk),
		.h_sync(h_sync),
		.v_sync(v_sync),
		.r(r),
		.g(g),
		.b(b)
	);
	
endmodule

Чтобы добавить в проект формирователь нужной частоты (PLL), в квартусе жмём сюда:

А мог бы просто написать: "добавьте в проект PLL"
А мог бы просто написать: "добавьте в проект PLL"

Дальше там длинный визард. Не буду шаги описывать, в большинстве там next->next->next
Скажу, что нужно выбрать язык verilog (по умолчанию), дать вразумительное имя (вроде alt_pll_var1). Вводим частоту inclk0 (в моём нередком случае 50.000 MHz), убираем создание доп. выводов (assert и locked) – они нужны, но не в минималистичных рабочих примерах, как этот.

На шаге Output clocks переставляем выбор на "Enter output clock frequency", и вводим нужные нам 25.175. При этом, точку в поле ввода клока нельзя удалять (софт кривой - не даёт поставить точку, так как мы в русском регионе (разделитель здесь запятая), при этом не понимает разделитель - запятую и не воспринимает число после запятой. Штош...). Нужная частота может и не получиться совсем точно (pll имеет ограниченное число разных параметров, с помощью комбинации которых получается формируемая частота), тогда пробуем частоту, которую он нам предложит из возможных. На последнем шаге визард предлагает создать Verilog HDL black-box file (галочка стоит по умолчанию). Мы это предложение милостиво принимаем. В этом файле мы сможем подсмотреть названия входов и выходов получившегося модуля (он будет называться alt_pll_var1.v, если мы ввели alt_pll_var1 в диалоговом окошке перед стартом визарда). Собственно на этом нюансы модуля закончились.

Для самых смелых

Дальше я должен предупредить. Есть вероятность спалить монитор или телевизор. У меня не сгорело ни одного (пробовал на трёх мониторах и двух телевизорах), но, может быть, я просто везучий. "Не повторять дома", в общем. Уровни сигналов должны быть верными (плюс 0.7 – белый, 0 – чёрный), а этого можно достичь только схемотехнически (6 резисторов, как минимум). Быстро это не делается. Нарушаем запреты и принципы, получается так:

6 разноцветных проводяшек
6 разноцветных проводяшек

Выходы я назначил себе так:

  set_location_assignment PIN_G1 -to clk
  set_location_assignment PIN_C20 -to r
  set_location_assignment PIN_D20 -to g
  set_location_assignment PIN_C19 -to b
  set_location_assignment PIN_D19 -to h_sync
  set_location_assignment PIN_A20 -to v_sync

Чтобы спалить монитор / телевизор с меньшей вероятностью, в пин планнере назначаем IO_STANDARD "1.2 V" на все выходы (Хотя я по ошибке пару раз назначил 2.5 вольт, вроде всё оборудование цело). Подключение проводов VGA легко гуглится, но всё же добавлю крупную фотку от себя для наглядности. Вот с таким подключением оно проверено работает. Красный 1, зелёный 2 и синий 3 провода это RGB, жёлтый провод 13 – h_sync, оранжевый – 14 v_sync, чёрный 10 – земля:

надпись внутри похожая на "0" меня тоже с толку сбивает. наверное, это нолик от "10"
надпись внутри похожая на "0" меня тоже с толку сбивает. наверное, это нолик от "10"

Куда подключать к плате разработки – сверяемся с документацией, какие пины куда выведены. У меня с учётом выбранных выводов получилось так:

Очень легко напутать, подключая провода к плате.
Очень легко напутать, подключая провода к плате.

Если всё правильно, то на телевизоре должно получиться такое:

Здесь IO стандарт 2.5 вольт. Телевизору не очень, скорее всего.
Здесь IO стандарт 2.5 вольт. Телевизору не очень, скорее всего.

Картинка-крест формируется этими строками:

    assign r = (x >= 299 && x < 340) || (y >= 239 && y < 260);
	assign g = r;
	assign b = r;

Несмотря на явный косяк, что горизонтальная белая полоса рисуется и за пределами видимой области по горизонтали (когда x > 639 и y находится в диапазоне 239 - 260), скалер это проглатывает и отображает.

При этом, если мы хотим, чтобы белый крест рисовался, например, на зелёном фоне, то просто изменение на "assign g = 1;" у меня не срабатывает. Здесь уже нужно делать "правильно" (в кавычках, потому что мы тут дискретные сигналы используем) и задать wire, который будет сигнализировать, что мы находимся в visible area, и его ставить через && к условиям формирования изображения. Ну, это на усмотрение смельчаков экспериментаторов. Всего таким образом мы можем отображать 8 цветов, включая чёрный и белый.

Кстати, для VGA не обязательно использовать ПЛИС. VGA может и ардуинка. Мы здесь собрались для другого.

HDMI

Теперь про HDMI. Здесь, хотя бы аппаратно, на моей плате можно реализовать так, чтобы у профессионалов не возникало желание побить меня стандартом. Уровни яркости цвета передаются в цифровом виде.

В первую очередь, надо сказать, что HDMI это проприетарный формат. Для его использования в своём оборудовании вы должны заплатить дяде. Поэтому, не удивляйтесь, что в гугле вы не найдёте информацию про формат сигналов HDMI. Но дядя хотела не только рыбку съесть, поэтому добавила в формат поддержку DVI, который уже open, royalty-free standard. Так что настоящего HDMI со звуком, простите, здесь не будет, а будет передача сигнала DVI через разъём HDMI. Что, впрочем, вполне себе норм, так как будет работать на любом устройстве с разъёмом HDMI (конечно, если разрешение поддерживается).

Передача цифрового видеосигнала, как ни странно, мало чем отличается от всё той же нитки, лазерной указки, прабабушкиного чёрно-белого телевизора и сигнала VGA.

Элементарно! В DVI / HDMI вместо 5 сигнальных проводов у нас осталось их всего 4. Но не 4 а 8.

Теперь без шуток. В формате VGA, pixel clock у нас не передавался на самом деле на монитор. Мы его использовали внутри для соблюдения таймингов, когда включать и выключать синхроимпульсы и в каком месте экрана отображать пиксель с нужными значениями RGB. В DVI pixel clock передаётся явно, по отдельному каналу.

Ещё, если в VGA на каждый клок мы просто устанавливали аналоговое значение яркости каналов цвета, то здесь мы за время одного такта pixel clock теперь последовательно передаём биты, с зашифрованной в них яркостью.

Настало время для аббревиатур LVDS и TMDS. 2 длинные статьи на вики говорят, что не всё так просто, я же скажу, что "ща разберёмся".

LVDS, это когда вместо одного (если с землёй, то двух) провода для передачи последовательных данных мы используем 2 провода. Один это, собственно, последовательные данные, как в уарте, например, а второй провод это точно такой же сигнал, но инвертированный. Дифференциальная пара, в общем. LV означает, что применяется низкое напряжение (Low Voltage). Да, это настолько просто.

TMDS это способ перекодировки данных так, чтобы минимизировать переходы. То есть на входе алгоритма у нас, к примеру, 8 бит данных, а на выходе у нас 10, но переорганизованных таким образом, что по проводам (дифференциальной паре) им было проще пролезть в потоке один-за-другим себе подобных. Дальше эти данные мы можем запихнуть в дифференциальную пару. При этом, требования к качеству проводов будут ниже, чем при том же битрэйте, но без TMDS. Если использовать низкие напряжения, то получится TMDS, переданный через LVDS.

Также надо отметить, что в TMDS, который используется в DVI, который мы будем гнать через разъём HDMI, используется перекодирование именно из 8 в 10 бит. Но это не 8b/10b encoding, которую IBM придумали, это 8b/10b для DVI. Вообще, способов закодировать 8 бит в 10 может быть много. Смысл этого в следующем: избежать длинных последовательностей из единиц и нулей, а также сделать так, чтобы единиц и нулей в потоке было приблизительно поровну.

С новыми знаниями возвращаемся к передаче видеосигнала. На каждый такт пиксель клок мы передаём по 10 бит информации о интенсивности R G и B, каждый в своей отдельной диф. паре. Но реальной информации в этих 10 битах закодировано лишь по 8 бит. То есть у нас 4 диф. пары, а значит, 8 проводов. В чипе 10CL16YU484C8G есть возможность настроить выход как LVDS (хотя в пин-планнере надо указывать LVDS_E_3R, так как просто LVDS это вход). При указании выхода как тип LVDS_E_3R, автоматически назначается парный ему выход. Управлять изнутри проекта в этом случае нам надо только одним выходом, хотя физически, провода 2.

Так как 10 бит больше чем 8, которые нужны для кодирования одного канала цвета пикселя, появляется некоторая избыточность. Есть комбинации, которые по-прежнему удовлетворяют условиям отсутствия длинных последовательностей и балансировки, при этом не кодируют ни одно из 256 значений интенсивности. Этим и воспользовались разработчики. Горизонтальный и вертикальный синхроимпульсы они закодировали в синем канале с помощью спец. последовательностей.

Если построчно описывать код получившегося рабочего модуля, то никакого терпения у читателя не хватит (у писателя, если честно, тоже). В конце будет ссылка на гитхаб проекта. Я добавлю лишь ключевые моменты. Алгоритм кодирования 8b/10b, применяемый в DVI есть в стандарте. На странице 29. Приведу здесь:

На входе алгоритм принимает несколько значений:

  • DE значит Data Enabled. Когда лог. 1 – передаётся яркость канала цвета. То есть, на выходе получается закодировано значение D. Когда 0 – данные D игнорируются, и используются C0 и С1

  • D это яркость канала цвета (8 бит)

  • С0 и C1 это контрольные сигналы. Для варианта DVI, который мы тут реализуем, они означают горизонтальный и вертикальный синхроимпульсы соответственно. И только в синем канале. В красном и зелёном их входное значение будет ноль. Когда DE = 0, выход алгоритма зависит от этих двух значений.

  • cnt(t-1) это значение, означающее количество бит, которое посчитано на предыдущем шаге алгоритма. Неудачное обозначение для значения, я бы назвал его как-нибудь, вроде cnt_prev. Скобочки сильно сбивают с толку.

Выход алгоритма - это

  • q_out – 10 бит, закодированные правильным образом

  • cnt(t) – количество бит, посчитанное на текущем шаге. Тоже скобочки сбивающее с толку. Я бы сказал: bit_cnt

Внутри алгоритма есть тоже странные обозначения. Создателям стандарта, они, по-видимому, показались не требующими пояснения (ну ладно, ладно, всё там расписано и разжёвано)

  • N1{D} – количество единиц в D

  • q_m – промежуточное девятибитное значение для вычисления q_out

  • N1{q_m[0:7]} – количество единиц в первых восьми битах q_m

  • N0{q_m[0:7]} – количество нулей там же.

В общем, этот алгоритм должен выполняться за один такт. Я, как программист embedded, глядя на это в первый раз, сделал что-то вроде "mindblow.jpg". А, на самом деле, для ПЛИС такое, вполне себе, оказывается норма.

Кодировку для TMDS можно поместить в один модуль, в котором даже не будет блока always. Но для удобства, он там всё же, может и быть, только не настоящий, а чтобы писать было удобнее. Это блок always @*. То есть, без тактового сигнала. На выходе такого модуля голая комбинаторика. Мы ему на вход несколько значений, а он нам на выходе "мгновенно" выдаёт нужные нам значения. "мгновенно", а не мгновенно. Скорость света, как минимум, к сожалению, конечна.

За исключением этого маааленького нюанса с цифровым, а не аналоговым представлением, вывод изображения через hdmi (DVI) один-в-один как вывод через VGA.

Код проекта на гитхабе.

Разрешение 1360x768, 30 FPS. Волшебные цифры для DVI искать сложнее. Теперь приходится копаться в коде драйверов, и в разных других, не столь оформленных источниках. Есть, например, в этом документе. Для 30 FPS, на самом деле, я не нашёл описания стандарта. Есть для 60 FPS. Но вот загвоздка. Pixel clock Для "1360x768 @ 60Hz" это 85.5 МГц. А сигнал TMDS должен идти с битрэйтом в 10 раз выше. Это 855 МГц. А это уже многовато для моей ПЛИСины. Я решил, что никому не наврежу, если тупо снижу частоту в 2 раза. 427.5 МГц уже норм. Получилось. Скалер телевизора не ругается, хотя я совсем не уверен, что другие устройства не станут твердить: "unknown format"

А, чуть не забыл! Когда мы зашиваем проект в чип, то он работает до снятия питания или перезагрузки. Можно сделать так, чтобы он загружался при включении через встроенную в него flash. В проекте на гитхабе есть документация на плату и описание работы в квартусе. На странице 18, перечёркнутая красными китайскими иероглифами, есть подробная инструкция, куда тыкать, чтобы прошить флешку через тот же программатор и разъём, который у нас есть уже.

В этот раз я решил вывести на экран не белый крест, а самую простую игру, какую можно придумать. И её упростил до предела:

В следующий раз, если разберусь сам, попробую по-простому рассказать, как задействовать чип DRAM, который на плате. А ещё про симуляцию.

Комментарии (2)


  1. avitek
    06.10.2025 11:58

    Хорошая работа!

    Некогда сам подключал LVDS панель к спартану-3.

    Скрытый текст


    1. av-86 Автор
      06.10.2025 11:58

      А в двух словах можно там как всё работает? А то у меня лежит лишний дисплей от ноутбука, который не жалко совсем.