В предыдущей статье я рассказал об основах композитного видеосигнала NTSC. Эта статья должна быть интереснее, так как она посвящена программной генерации такого видеосигнала. Тема интересна тем, что помимо самого видеосигнала вы ещё получаете множество практических навыков применения современных микроконтроллеров.

Сигнал CVBS можно получить, используя и FPGA-решения, но стоимость их выше, чем у микроконтроллеров, таких как Raspberry Pi Pico или ESP32.

Я использовал платы разработчика на базе микроконтроллера RP2040. На рынке существует несколько таких плат. Классика — это Raspberry Pi Pico, но есть несколько китайских аналогов, например, YD-2040. Отдельно хочется выделить RP2040 Zero от Waveshare — очень компактное решение, правда у него отсутствует порт для отладки, но можно обойтись и без порта.

Важный момент — СVBS-сигнал является аналоговым, поэтому стабильность напряжения на выходе играет важную роль, и для приемлемого качества сигнала китайские клоны Raspberry Pi Pico могут не подойти, так как они страдают нестабильным напряжением на выходах. Мой совет — используйте или оригинальный Raspberry Pi Pico или RP2040 Zero от Waveshare.

Желающих продолжить чтение приглашаю под кат.

Если вам не хочется читать множество буковок, которые я написал, а просто хотите увидеть в действии, как работает генерация видеосигнала, можете сразу открыть мой проект на GitHub.

Основы программной генерации композитного видеосигнала

Сигнал CVBS можно генерировать как аппаратно (с использованием электронных схем), так и программно, (программно-аппаратно, так как без небольших схем для цифро-аналогового преобразования не обойтись). Я рассматриваю только программно-аппаратный способ.

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

Дискретизация и квантование сигнала NTSC

CVBS-сигнал NTSC является аналоговым, но мы можем его «оцифровать», выполнив его дискретизацию и квантование. Делается это путём разбиения сигнала на временные промежутки и «замораживания» значения на каждом. Промежутки называют отсчётами.
Под дискретизацией подразумевается изменение значения сигнала только в определённые моменты времени, под квантованием то, что значения могут принимать только определённые значения.

Другими словами, дискретизация определяет, насколько часто мы будем вычислять значение сигнала, а квантование — насколько точно будет значение оцифрованного сигнала близко к аналоговому.

Чтобы не потерять частоты, которые присутствуют в сигнале, необходимо руководствоваться теоремой Котельникова (в иностранных источниках теоремой Найквиста — Шеннона). Частота дискретизации должна быть не менее, чем в два раза выше максимальной частоты спектра.

f*d \geq f*{max}

Спектр сигнала NTSC включает частоты от 0 до 6 МГц, причём часть частот используется для звука, которого нет в случае CVBS-сигнала NTSC. Полезный спектр яркостного сигнала до ~4.2 МГц, поэтому спектр частот можно принять равным 4.2 МГц.

Если мы возьмём частоту дискретизации f_d = 4f_{sc}, где f_{sc} — частота цветовой поднесущей, неравенство из теоремы Котельникова будет соблюдаться.

f_d = 4 \cdot f_{sc} = 4 \cdot 3.579545 MHz = 14.31818 MHz 14.31818 МГц \geq 2 \cdot 4.2 МГц

Такой выбор даёт ровно четыре отсчёта на период поднесущей, упрощая цифровое кодирование значений цветоразностных сигналов для фаз 0°, 90°, 180°, 270°.

Квантование определяется разрядностью цифроаналогового преобразователя.

Цифроаналоговый преобразователь

Цифроаналоговый преобразователь (DAC) — это электронная схема, превращающая цифровой сигнал в «аналоговое» изменение напряжения.

Оцифрованный сигнал CVBS может передаваться последовательно в случае использования PWM или параллельно в случае PIO. Соответственно, будут разные схемы для DAC.

Для PWM-сигнала это будет RC-фильтр, состоящий из одного резистора и одного конденсатора. Хотя у меня получался вывод изображения даже без использования фильтра, вероятно, срабатывали фильтры в телевизоре и карте видеозахвата. Я больше не буду рассматривать реализацию с таким DAC. Если интересно, вы можете ознакомиться со следующими проектами на GitHub:

Более качественного изображения можно добиться, используя R2R DAC. Он позволяет преобразовывать цифровой сигнал с n-разрядной шины в аналоговый.

Он требует всего лишь два номинала резисторов, но я использовал три, и далее в статье объясню почему.

Тайминги сигнала NTSC

Тайминги немного отличаются в зависимости от года и стандарта NTSC.

За основу я взял следующие тайминги:

Параметр

Значение

Единица измерения

Горизонтальный интервал (H)

63.556

мкс

Активное видео

52.656

мкс

Вертикальный гасящий импульс (HBI)

10.7

мкс

Front porch

1.5

мкс

Horizontal sync

4.7

мкс

Back porch

4.5

мкс

Вертикальный интервал (V)

16.683

мс

Вертикальный гасящий импульс (VBI)

20

строки

Vertical sync pulse interval

3

строки

Pre-equalizing pulse interval

3

строки

Post-Equalizing pulse interval

3

строки

Количество строк в кадре

525

строки

Количество строк в поле

262.5

строки

Важным моментом в генерировании композитного видеосигнала является соблюдение таймингов. Несмотря на то, что сигнал является аналоговым, и принимающая сторона менее чувствительна к их нарушению, цветовая поднесущая в сгенерированном сигнале должна быть максимально приближена к значению 3.579545 МГц.

Так как я буду использовать псевдопрогрессивную развёртку 240p, количество строк в поле будет 262. А два кадра будут состоять из двух одинаковых нечётных полей.

Тайминги сигналов синхронизации тоже необходимо соблюдать, но с ними всё менее строго.

Программное генерирование сигнала с использованием задержек (busy-wait) является неэффективным и не гарантирует стабильности из-за джиттера. Код программы при изменении требует постоянного анализа, чтобы соблюдались тайминги.

Поэтому, как правило, используют подход с использованием DMA, прерываний и периферии микроконтроллера (PWM или PIO в случае RP2040 и I2S в случае ESP32). Это требует дополнительных знаний, но значительно упрощает реализацию.

Технические аспекты генерации CVBS

Использование палитры

Теоретически в сигнале CVBS из-за его аналоговой природы можно представить практически неограниченное количество цветов и яркостей. Но использование дискретизации их ограничивает.

Рассчитывать цвет каждого пиксела нерационально, поэтому на низкопроизводительном железе используют палитры. Палитра с точки зрения программирования — это массив, который содержит информацию о предопределённых цветах. Обратившись к элементу массива по индексу, можно получить эту цветовую информацию.

В случае NTSC цветовая информация — это оцифрованный сигнал цветности (синусоидальный сигнал, у которого фаза представляет оттенок, а амплитуда — насыщенность). При выводе пиксела из палитры выбирается этот оцифрованный сигнал.

Конфигурирование DMA

Как я говорил ранее, для генерации композитного видеосигнала важно, чтобы данные передавались DAC со строгими таймингами. Использование DMA гарантирует стабильность и снижает джиттер.

Direct Memory Access (DMA), или прямой доступ к памяти — технология, позволяющая перемещать участки памяти в периферийные устройства или, наоборот, минуя обработку процессором, что значительно разгружает последний.

В RP2040 существует 12 независимых каналов DMA, которые могут работать параллельно. Каждый канал DMA можно рассматривать как независимый поток передачи данных. Мы будем использовать только два из них.

DMA в RP 2040 может напрямую перемещать данные в PIO или PWM, что делает его особенно удобным для генерации видеосигнала.

Канал DMA взаимодействует с процессором через конфигурационные регистры микроконтроллера и прерывания.

Основная задача — правильно сконфигурировать DMA и обработчик прерываний. При этом SDK упрощает настройку, позволяя сосредоточиться на генерации данных.

DMA в микроконтроллере RP2040 поддерживает Chain Mode, который позволяет запускать следующий DMA-канал по окончании предыдущего. Альтернативой Chain Mode является использование двух каналов DMA — один для передачи данных, второй для конфигурирования DMA-канала. Например, в реализации CVBS в Мурмуляторе используется именно этот подход.

Я использовал подход с использованием Chain Mode и так называемых ping-pong буферов.

Использование ping-pong буферов

Для обеспечения непрерывной передачи данных используются буфера. Процессор занимается их наполнением, а DMA их передачей на периферийные устройства.

Возможны две реализации с использованием буфера:

  • с использованием кольцевого буфера,

  • с использованием ping-pong буферов.

Суть реализации с кольцевым буфером в том, что процессор заполняет один буфер по кругу, а DMA читает данные, записанные в буфер.

Использование ping-pong буферов подразумевает, что процессор пишет в один буфер, а DMA передаёт данные из другого. После того как DMA передал буфер, он начинает передавать буфер, который заполнил процессор, а процессор начинает заполнение буфера, уже переданный DMA, новыми данными. Так продолжается, пока нужна передача данных.

Переключение буфера выполняется в прерывании, которое генерируется системой DMA после передачи буфера. Но при использовании DMA chaining в прерывании только заполняется следующий для передачи буфер, а переключение каналов осуществляется самой системой DMA.

Если немного абстрагироваться, процесс действительно напоминает игру в пинг-понг, когда один игрок готовится к отбитию мячика, а другой отбивает, а потом наоборот. Только пропуск мячика — это уже экстраординарная ситуация в случае DMA.

Периферийным устройством, куда DMA передаёт данные в случае CVBS, может быть PWM или PIO. Я использовал PIO, поэтому остановимся на нём.

Конфигурирование PIO

Programmable input/output (PIO) — это, можно сказать, уникальная технология, используемая в микроконтроллерах RP 2040/RP 2350. Она заключается в том, что позволяет выводить на GPIO-выводы данные, минуя процессор.

Совмещая её с DMA, можно получить очень эффективное решение для передачи оцифрованного CVBS-сигнала на пины микроконтроллера. И не нужно разных ухищрений, таких как использование PWM или I2S.

PIO взаимодействует с процессором или DMA при помощи FIFO-буферов и прерываний. В RP 2040 его основу составляют два блока по 4 машины состояний в каждом, которые программируются на специально разработанном для него языке ассемблера.

Язык ассемблера очень ограниченный, но с основной задачей, с точными таймингами вывода на пины, справляется. Да и нам понадобится минимальная программа, состоящая всего из одной инструкции — взять из буфера и вывести на GPIO-выводы данные.

Использование прерываний для непрерывной работы

Прерывание — это остановка выполнения операций с целью уведомить подсистему и выполнения каких-либо действий в этом случае. Прерывания в RP2040 могут выполняться в фоне, например, как прерывание от PIO, уведомляющее, что требуется следующее значение для вывода на GPIO-пины, или требовать явного обработчика прерывания после передачи всего буфера при помощи DMA.

Особенности моей реализации генерации композитного видеосигнала на Raspberry Pi Pico

Программная генерация цветного композитного видеосигнала требует значительных ресурсов. Как я говорил ранее, я рассматриваю сигнал NTSC. Одно из самых важных требований — это обеспечить как можно более точную генерацию частоты цветовой поднесущей fsc = 3.579545 МГц.

Дискретизация самого композитного сигнала должна быть 4*fsc (14.318 МГц).
или выше. Я использовал частоту дискретизации 14.318 МГц.

Вывод сигнала с такой частотой с использованием только процессора и GPIO затруднителен; к тому же бо́льшую часть времени процессор будет занят выводом сигнала. Как я говорил ранее, на микроконтроллере RP2040 эту задачу удобно решить с помощью DMA и PIO.

Упрощённо принцип работы следующий:

Микроконтроллер генерирует цифровой сигнал в буфер, а DMA посредством PIO выводит содержимое из буфера на пины GPIO. DMA и PIO можно настроить таким образом, что каждое n-разрядное значение будет передаваться на входы с частотой, на которую мы настроим PIO. В англоязычных источниках это называется Paced transfer on DREQ.

Сигнал должен передаваться непрерывно; для этого нужны два буфера: пока один выводится посредством DMA, значения в другом рассчитываются и записываются микроконтроллером (ping pong buffering).

Также необходимо настроить автоматическое переключение на другой буфер после завершения передачи из предыдущего (DMA chaining).

Для того чтобы микроконтроллер знал, когда закончился вывод одной строки и нужно пересчитать значения, используется механизм прерываний.

Забавно, что задача расчёта и вывода сигнала использует столько технологий. Но я думаю, что если вы осмысленно повторите её решение, понимание должно улучшиться, так как вы увидите применение на практике. Всё-таки мигание светодиодом не создаёт такого эффекта, как получение изображения на телевизоре. А эффективное применение технологий вдохновляет и даёт силы для дальнейшего изучения.

Электронная схема устройства, генерирующего сигнал CVBS

Схема устройства достаточно простая. Она состоит из Raspberry Pi Pico, R2R Ladder DAC, и разъёма TRRS. Вместо разъёма TRRS можно использовать разъём RCA, но у меня уже был breakout-модуль TRRS и кабель от китайской новодельной приставки TRS-RCA.

Электронная схема
Электронная схема

На схеме я с информативной целью отобразил RCA-вход и резистор на 75 Ом, подтянутый к земле, которые присутствуют в телевизоре и карте видеозахвата.

Вероятно, у вас возникнет вопрос: каким образом подбирались номиналы резисторов? Ведь мы вроде бы можем выбрать любое значение R для R2R Ladder DAC. Но всё-таки есть небольшие нюансы

На выходах GPIO напряжение 3.3 В для логической единицы и 0 В для логического нуля. Согласно спецификации NTSC размах сигнала от должен быть 171 IRE или 1.22 В. Если мы создадим 8-ми разрядный R2R Ladder DAC, то теоретически напряжение будет от 0 до 3.3 В. Т. е., после DAC нам необходимо организовать делитель напряжения.

Принимающее устройство на входе содержит сопротивление 75 Ом, подтянутое к земле. Используя это сопротивление в качестве одного плеча делителя напряжения и сопротивление DAC в качестве другого, мы получим необходимый делитель. Исходя из этого подбирается сопротивление DAC.

U_{вых}=U_{вх}\frac{R_{2}}{R_{1}+R_{2}}

где:

U_{вых} = 1.22 В

U_{вx} = 3.3 В

R_{2} = 75 Ом

R_{1} — искомое сопротивление R2R Ladder DAC

Находим сопротивление R2R Ladder DAC:

R_{1}=R_{2}\left(\frac{U_{вх}}{U_{вых}}-1\right) = 75 \left(\frac{3.3}{1.22}-1\right)\approx128Ом

Ближайший из ряда резистор номиналом 120 Ом. 2R будет равно 240 Ом, но так как у нас выходы Pico имеют тоже сопротивление около 40 Ом, то принимаем 2R равным 200 Ом. 240 Ом только один резистор, который подтянут к земле.

Резистор, подтянутый к земле, очень важен, так как напряжения для уровней сигнала CVBS нужно замерять именно при нагрузке в 75 Ом.

8-ми разрядный DAС, наверное, избыточен, и ещё он будет перегружать микроконтроллер при сигнале с большой яркостью, но я уже сделал так, и для проверки идеи сойдёт. Я понижал количество разрядов DAC и не увидел значительного ухудшения изображения.

Схему можно улучшить, разгрузив выводы микроконтроллера транзисторами, но это усложнит и увеличит стоимость схемы. Любопытный читатель может это опробовать.

Ещё удобно подключить светодиод на один из пинов, на который выводить сигнал, о том, что программа работает. Не забывайте при подключении светодиода об ограничивающем резисторе. Я не включил его в окончательную схему, потому что светодиод немного просаживал СVBS-сигнал, но вы можете его добавить, если будете модифицировать и отлаживать мой код.

Программная реализация

Всю программную реализацию вы можете посмотреть в моём репозитории на GitHub

Ниже я привожу листинги участков кода, которые, как я считаю, являются наиболее важными.

Настройка DMA

Функция init_dma_channel() настраивает канал DMA (Direct Memory Access), чтобы он автоматически передавал данные из буфера в память периферии — в данном случае, в PIO TX FIFO (передающий буфер программируемого ввода-вывода).

Это позволяет CPU не участвовать в передаче данных — DMA сам копирует 16-битные значения из буфера scanline_buffer в pio0_hw->txf[0] (FIFO PIO0, state machine 0).

int init_dma_channel(int dma_channel, uint16_t *scanline_buffer,
                     uint chain_to) {
  dma_channel_config channel_config =
      dma_channel_get_default_config(dma_channel);
  channel_config_set_transfer_data_size(&channel_config, DMA_SIZE_16);
  channel_config_set_read_increment(&channel_config, true);
  channel_config_set_write_increment(&channel_config, false);
  channel_config_set_dreq(&channel_config, DREQ_PIO0_TX0);
  channel_config_set_chain_to(&channel_config, chain_to);
  dma_channel_configure(
      dma_channel, &channel_config,
      &pio0_hw->txf[0],
      scanline_buffer, NTSC_SAMPLES_PER_LINE,
      false // Don't start yet
  );
}
  • dma_channel_config channel_config = dma_channel_get_default_config(dma_channel); — получаем базовую конфигурацию DMA для этого канала с дефолтными значениями.

  • channel_config_set_transfer_data_size(&channel_config, DMA_SIZE_16); — размер передаваемых элементов = 16 бит (2 байта). Значит, DMA будет брать и писать по uint16_t за раз.

  • channel_config_set_read_increment(&channel_config, true); — при чтении DMA увеличивает адрес источника (т. е. будет идти по массиву scanline_buffer). Источник — буфер в памяти, и его нужно проходить последовательно.

  • channel_config_set_write_increment(&channel_config, false); — адрес назначения не увеличивается. То есть DMA всё время пишет в одно и то же место — pio0_hw->txf[0]. Это FIFO (очередь) внутри PIO, и каждый раз запись идёт в тот же регистр (FIFO сам обрабатывает поступление данных).

  • channel_config_set_dreq(&channel_config, DREQ_PIO0_TX0); DREQ — сигнал запроса данных. Здесь указано, что DMA будет срабатывать по запросу от PIO0 TX FIFO 0. То есть, когда PIO FIFO готов принять очередное слово, DMA сам передаст следующее значение из буфера.

  • channel_config_set_chain_to(&channel_config, chain_to); — Настраивает цепочку DMA: после завершения передачи этот канал автоматически запустит другой (chain_to). Это позволяет, например, «по кругу» обновлять буфера (двойная буферизация) без участия CPU.

  • dma_channel_configure(...); — основная настройка DMA: dma_channel — номер канала, &channel_config — структура с конфигурацией, &pio0_hw->txf[0] — адрес назначения (куда писать), scanline_buffer — адрес источника (откуда читать), NTSC_SAMPLES_PER_LINE — количество передаваемых элементов, false — не запускать DMA сразу (только подготовить).

Настройка PIO

static void init_pio() {
  uint offset = pio_add_program(pio0, &pio_serialiser_program);
  pio_serialiser_program_init(pio0, 0, offset, 0, PIO_SERIAL_CLKDIV);
}

pio_add_program(pio0, &pio_serialiser_program) — загружает PIO-программу в инструкционную память PIO0 и возвращает смещение (offset) — адрес первой инструкции программы в памяти PIO.

.program pio_serialiser

.wrap_target
      out pins, 8
.wrap

Это вся программа, состоящая из одной инструкции. PIO получает 8 бит из своего сдвигового регистра (OSR — Output Shift Register) и выводит их на 8 пинов.

  • out pins, 8 — берёт 8 бит из OSR (Output Shift Register) и выводит их на 8 сконфигурированных GPIO. После этого (если OSR пуст) — ждёт, пока из TX FIFO придут новые данные от CPU или DMA.

  • .wrap_target / .wrap — определяют начало и конец цикла выполнения. Программа будет повторять out pins, 8 бесконечно.

% c-sdk {
static inline void pio_serialiser_program_init(PIO pio, uint sm, uint offset, uint data_pin, float clk_div) {
    for (int i = 0; i < 8; i++ ){
        pio_gpio_init(pio, data_pin + i);
    }
    pio_sm_set_consecutive_pindirs(pio, sm, data_pin, 8, true);
    pio_sm_config c = pio_serialiser_program_get_default_config(offset);
    sm_config_set_clkdiv(&c, clk_div);
    sm_config_set_out_pins(&c, data_pin, 8);
    sm_config_set_out_shift(&c, true, true, 8);
    sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX);
    pio_sm_init(pio, sm, offset, &c);
    pio_sm_set_enabled(pio, sm, true);
}
%}
  • Инициализируем 8 GPIO (от data_pin до data_pin + 7) как выходы. PIO получит контроль над ними (PIO override GPIO function). Эти 8 пинов образуют параллельную шину данных.

  • pio_sm_config c = pio_serialiser_program_get_default_config(offset); — создаётся базовая конфигурация (с адресом начала программы).

  • sm_config_set_clkdiv(&c, clk_div); — устанавливает делитель частоты для state machine. Определяет, как быстро выполняются инструкции PIO.

  • sm_config_set_out_pins(&c, data_pin, 8); — PIO-программа использует 8 пинов, начиная с data_pin.

  • sm_config_set_out_shift(&c, true, true, 8);shift_right = true — выводить младшие биты первыми, autopull = true — при каждом out pins, 8, если OSR пуст, автоматически загружается 8 новых бит из TX FIFO, pull_threshold = 8 — каждые 8 бит загружать новые данные.

  • sm_config_set_fifo_join(&c, PIO_FIFO_JOIN_TX); — объединяет два FIFO (TX0 и TX1) в один большой TX FIFO (глубиной 8 слов). Это нужно, чтобы дольше передавать данные без остановок.

  • pio_sm_init(pio, sm, offset, &c); — загружает конфигурацию в state machine.

  • pio_sm_set_enabled(pio, sm, true); — запускает SM — теперь она готова выводить байты, как только они поступят в FIFO.

Настройка прерываний

static void init_interrupt_handler() {
  dma_set_irq0_channel_mask_enabled(
      1u << ntsc_dma_chan_primary | 1u << ntsc_dma_chan_secondary, true);
  irq_set_exclusive_handler(DMA_IRQ_0, ntsc_dma_irq_handler);
  irq_set_enabled(DMA_IRQ_0, true);
}
  • dma_set_irq0_channel_mask_enabled(...) — разрешает обоим DMA-каналам генерировать прерывания на общий вектор DMA_IRQ_0. То есть, когда любой из этих каналов закончит передачу, сработает одно общее прерывание.

  • irq_set_exclusive_handler(DMA_IRQ_0, ntsc_dma_irq_handler) — Назначает функцию ntsc_dma_irq_handler как обработчик прерывания.

  • irq_set_enabled(DMA_IRQ_0, true) — включает само прерывание на уровне контроллера.

static void __time_critical_func(ntsc_dma_irq_handler)() {
  // Read and clear DMA interrupt flags
  const volatile uint32_t interrupt_flags = dma_hw->ints0;
  dma_hw->ints0 = interrupt_flags;

  const uint8_t scanline_buffer_index =
      interrupt_flags & (1u << ntsc_dma_chan_secondary) ? 1 : 0;
  buffer_handler(scanline_buffer_index);
  // Determine which channel completed and prepare its buffer
  if (scanline_buffer_index) {
    dma_channel_set_read_addr(ntsc_dma_chan_secondary,
                              ntsc_scanline_buffers[scanline_buffer_index],
                              false);
  } else {
    dma_channel_set_read_addr(ntsc_dma_chan_primary,
                              ntsc_scanline_buffers[scanline_buffer_index],
                              false);
  }
}
  • const volatile uint32_t interrupt_flags = dma_hw->ints0; Регистр dma_hw->ints0 содержит флаги завершения передачи для всех каналов DMA, связанных с DMA_IRQ_0. Каждый бит = один канал DMA. Например, если бит 0 установлен, то DMA0 завершил передачу.

  • dma_hw->ints0 = interrupt_flags; Запись тех же битов обратно в ints0 очищает эти флаги. (Так реализовано в RP2040: запись 1 в бит = очистить флаг). Таким образом, мы сбрасываем «сработавшие» прерывания. То есть мы читаем, какие каналы закончили работу, и сразу очищаем их статус.

  • const uint8_t scanline_buffer_index = interrupt_flags & (1u << ntsc_dma_chan_secondary) ? 1 : 0; Определение, какой канал закончил передачу: если установлен бит, соответствующий ntsc_dma_chan_secondary, значит, закончил второй DMA-канал.

  • buffer_handler(scanline_buffer_index); Вызывает пользовательскую функцию, которая обновляет содержимое буфера строки, чтобы подготовить новые данные для следующего кадра/строки. Пока другой DMA-канал будет работать, CPU спокойно подготавливает данные в этом освобождённом буфере — получается двойная буферизация.

  • if (scanline_buffer_index) {dma_channel_set_read_addr(...);} else {dma_channel_set_read_addr(...);} Настройка DMA для следующего цикла dma_channel_set_read_addr() задаёт новый адрес источника данных (в памяти) для DMA-канала. Параметр false означает, что передача ещё не запускается, просто подготавливается.

Создание палитры

Функция для расчёта палитры не такая сложная, как может показаться на первый взгляд. Как я говорил ранее, палитра позволяет на слабом железе выводить ограниченное предопределённое количество цветов. Обращение к цвету осуществляется по индексу, соответственно, у нас на входе функции индекс в палитре, для которого необходимо рассчитать значения и значения синего, красного и зелёного.

Сама функция заполняет глобальный массив ntsc_palette. В массиве для каждого значения палитры сохраняется 4 значения, которые должны передаться на DAC, когда фаза цветовой поднесущей 0°, 90°, 180° и 270° (вспомните, что дискретизацию мы выполняли именно по фазам цветовой поднесущей).

Код функции приведён ниже:

static void ntsc_set_color(const uint8_t palette_index, const uint8_t blue,
                           const uint8_t red, const uint8_t green) {

  // Y = 0.587*G + 0.114*B + 0.299*R

  const double luminance = (0.587 * green + 0.114 * blue + 0.299 * red);

  // Original formula: signal = Y + 0.4921*(B-Y)*sin(θ) + 0.8773*(R-Y)*cos(θ)

  // Phase 0° and 180° components

  const double blue_chroma_0 = (blue - luminance) * 0.4921 *0; // 0 = sin(0)

  const double red_chroma_0 = (red - luminance) * 0.8773 * 1; // 1 = cos(0)

  // Phase 90° and 270° components
  const double blue_chroma_90 = (blue - luminance) * 0.4921 * 1; // 1 = sin(90)

  const double red_chroma_90 = (red - luminance) * 0.8773 * 0; // 0 = cos(90)

  // Generate composite signal values for each subcarrier phase

  // Phase 0°: Y + chroma
  int32_t composite_signal =
      (int32_t)(NTSC_SCALE_Y * (luminance + blue_chroma_0 + red_chroma_0) +
                NTSC_SETUP_IRE) *
          DAC_SCALE +
      NTSC_LEVEL_BLANK_DAC;
  ntsc_palette[palette_index * 4] = composite_signal < 0 ? 0 : composite_signal;

  //   // Phase 90°: Y + chroma(90°)
  composite_signal =
      (int32_t)(NTSC_SCALE_Y * (luminance + blue_chroma_90 + red_chroma_90) +
                NTSC_SETUP_IRE) *
          DAC_SCALE +
      NTSC_LEVEL_BLANK_DAC;

  ntsc_palette[palette_index * 4 + 1] =
      composite_signal < 0 ? 0 : composite_signal;

  // Phase 180°: Y - chroma
  composite_signal =
      (int32_t)(NTSC_SCALE_Y * (luminance - blue_chroma_0 - red_chroma_0) +
                NTSC_SETUP_IRE) *
          DAC_SCALE +
      NTSC_LEVEL_BLANK_DAC;
  ntsc_palette[palette_index * 4 + 2] =
      composite_signal < 0 ? 0 : composite_signal;

  // // Phase 270°: Y - chroma(90°)
  composite_signal =
      (int32_t)(NTSC_SCALE_Y * (luminance - blue_chroma_90 - red_chroma_90) +
                NTSC_SETUP_IRE) *
          DAC_SCALE +
      NTSC_LEVEL_BLANK_DAC;

  ntsc_palette[palette_index * 4 + 3] =
      composite_signal < 0 ? 0 : composite_signal;
}

Инициализация и запуск

Думаю, что функция в комментариях не нуждается.

static inline void ntsc_init() {

  const uint32_t system_clock_khz = 157500;

  vreg_set_voltage(VREG_VOLTAGE_1_30);

  set_sys_clock_khz(system_clock_khz, true);

  init_pio();

  ntsc_dma_chan_primary = dma_claim_unused_channel(true);
  ntsc_dma_chan_secondary = dma_claim_unused_channel(true);
  init_dma_channel(ntsc_dma_chan_primary, ntsc_scanline_buffers[0],
                   ntsc_dma_chan_secondary);
  init_dma_channel(ntsc_dma_chan_secondary, ntsc_scanline_buffers[1],
                   ntsc_dma_chan_primary);

  init_interrupt_handler();

  start_dma();
}

Генерация строк

Функция в комментариях не нуждается, как и предыдущая.

static inline void ntsc_generate_scanline(uint16_t *output_buffer,
                                          const size_t scanline_number) {

  uint16_t *buffer_ptr = output_buffer;
  switch (scanline_number) {
  case 0:
  case 1:
  case 2:
  case 6:
  case 7:
  case 8:
    equalizing(buffer_ptr);
    break;
  case 3:
  case 4:
  case 5:
    vertical_sync(buffer_ptr);
    break;
  default:
    if (scanline_number < NTSC_ACTIVE_VIDEO_START_LINE ||
        scanline_number >= NTSC_ACTIVE_VIDEO_START_LINE + NTSC_FRAME_HEIGHT) {
      blank_line(buffer_ptr);
    } else {
      active_video(buffer_ptr, scanline_number);
    }
  }
}

Мой набор инструментов для тестирования

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

USB-осциллограф

Лет 10 назад я купил такой осциллограф на известном китайском сайте. Долгое время осциллограф лежал без дела в шкафу, но в тестировании NTSC-сигнала он мне значительно помог, и теперь осциллограмма NTSC сигнала кажется знакомой и понятной. Вслепую без осциллографа трудно было бы отлаживать приложение.

USB-осциллограф Intrustar ISDS205B
USB-осциллограф Intrustar ISDS205B

USB-карта видеозахвата

Я использовал дешёвую USB-карту видеозахвата, которая при подключении к компьютеру распознаётся как USB-видеокамера. Покупал давно на AliExpress. Они существуют на различных чипсетах, но думаю, для моих целей чипсет не сильно важен.

USB-карта видеозахвата
USB-карта видеозахвата

Она мне очень пригодилась, когда мне нужно было оценить точность цветопередачи моего устройства на базе Raspberry Pi Pico.

Качество изображения на телевизоре и получаемое при помощи карты видеозахвата разнится, так как телевизор, скорее всего, содержит более качественные фильтры и компоненты для декодирования.

Карта видеозахвата позволила увидеть результат на экране ноутбука, а также с плагином Vectorscope для OBS studio увидеть, насколько корректно генерируется цветовой сигнал.

Логический анализатор

Использование логического анализатора является необязательным, но мне он помог при анализе, какой именно цифровой сигнал передаётся на DAC, и отладке работы DMA совместно с PIO.

Логический анализатор
Логический анализатор

Логический анализатор я использовал для проверки отсчётов, которые передавались на R2R DAC.

Частота оцифрованного сигнала NTSC превышает 14 МГц, и «помигать светодиодом» или вывести в консоль уже не получится.

Можно цифровой сигнал посмотреть и на осциллографе, но обычно частота дискретизации осциллографа меньше, чем у логического анализатора, да и количество каналов больше 4 — это редкость для осциллографа.

Вообще, логический анализатор — полезное устройство для анализа того, что выводится на пины микроконтроллера. Часто программное обеспечение позволяет декодировать протоколы передачи данных, например, UART, SPI, I2S, I2C, и другие. Для оцифрованного сигнала NTSC, естественно, таких декодеров нет, но посмотреть, как выглядит передача циферок, можно.

Я использовал Kingst Logic Analyzer LA1010.

Плагин Vectoroscope в OBS Studio

Карта видеозахвата получаемый аналоговый композитный видеосигнал оцифровывает и представляет в виде цифрового. В цифровом видеосигнале используется то же цветовое пространство YUV, что и в PAL или NTSC. (В NTSC может использоваться как пространство YIQ, так и YUV, использование YIQ позволяет передать более точно оттенки кожи). Поэтому мы можем проверить корректность сгенерированных цветов при помощи плагина Vectoroscope для OBS Studio.

Базового функционала плагина хватит, чтобы оценить корректность генерируемых цветов. Полностью доверять результатам нельзя, так как карта видеозахвата может искажать цвета, но для черновой проверки этот способ сгодился.

Что у меня получилось в результате

Устройство, собранное на макетной плате:

Устройство, собранное на макетной плате
Устройство, собранное на макетной плате

Картинка, сгенерированная Raspberry Pi Pico и полученная на карте видеозахвата:

Картинка, сгенерированная Raspberry Pi Pico и полученная на карте видеозахвата
Картинка, сгенерированная Raspberry Pi Pico и полученная на карте видеозахвата

Также я проверял, как выглядит картинка при подключении к телевизору и к скейлеру, на них картинка выглядит получше, вероятно, электронная начинка на них получше.

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

Для снятия осциллограммы необходимо к выходу подсоединить нагрузку в 75 Ом, иначе вы увидите не те напряжения.

Осциллограмма строчной развёртки сигнала NTSC
Осциллограмма строчной развёртки сигнала NTSC
Осциллограмма вертикального гасящего импульса (VBI)
Осциллограмма вертикального гасящего импульса (VBI)

Приведённые осциллограммы получены для тестовой таблицы EBU Color Bars с яркостью 100%. Из неё вы видите, что размах между SYNC level и White level около 940 мВ, это достаточно близко к 1 В, требуемые по спецификации.

Для сигнала цветности амплитуда может немного отличаться, так как дискретизация сигнала всего лишь 4 отсчёта на период цветовой поднесущей.

Теперь самое интересное — векторограммы для статической эталонной тестовой таблицы EBU Color Bars и для картинки, которую я получил через карту видеозахвата.

Векторограмма статической эталонной тестовой таблицы EBU Color Bars
Векторограмма статической эталонной тестовой таблицы EBU Color Bars
Векторограмма для картинки с карты видеозахвата
Векторограмма для картинки с карты видеозахвата

Векторограммы также приведены для яркости 100%. Как вы видите, существует небольшая погрешность, но в целом можно говорить, что сигнал цветности я сгенерировал корректно.

Выводы

Аналоговые композитные видеосигналы уходят в прошлое, но разбираться в них полезно — это затрагивает множество дисциплин и помогает увидеть, как физика и математика воплощаются в реальных системах.

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

Понимание принципов NTSC и CVBS делает вас более продвинутым в ретротехнике и ретрогейминге: вы начинаете лучше понимать, зачем нужны скейлеры и как формируется видеосигнал.

Программный код, представленный в статье, не идеален, но служит хорошей иллюстрацией для изучения сути композитного видеосигнала. Полученная система — учебная, но может стать основой для более сложных проектов: терминала, генератора тестовых сигналов или даже эмулятора ретрокомпьютера.

Интересным направлением развития будет создание объектно-ориентированного кода на C++ для поддержки различных стандартов (NTSC, PAL, SECAM) и типов развёртки. Это уже более сложная задача, открывающая путь к универсальной библиотеке для видеогенерации.

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

Печатные издания

  1. Wyszecki, G., Stiles, W. S. Color Science: Concepts and Methods, Quantitative Data and Formulae. — 2nd ed. — New York: Wiley, 1982. — 950 p.

  2. Colorimetry: Understanding the CIE System / ed. by J. Schanda. — Hoboken, NJ: Wiley-Interscience, 2007. — 459 p.

  3. Benson, K. B., Whitaker, J. C. Television Engineering Handbook: Featuring HDTV Systems. — Rev. ed. — New York: McGraw-Hill, 1992. — 1500 p.

  4. Conrac Corp. Raster Graphics Handbook. 2nd ed. New York: Van Nostrand Reinhold, 1985. 345 p.

  5. Jack, K. Video Demystified: A Handbook for the Digital Engineer. — 5th ed. — Oxford: Newnes, 2005. — 928 p.

  6. Gayler, W. D. The Apple II Circuit Description. — Indianapolis: Howard W. Sams, 1983. — 176 p.

  7. Айсберг, Е. Д., Дури, Ж. Цветное телевидение?… Это почти просто! — 2-е изд. — М.: Знание, 1975. — 144 с.

  8. Мамчев, Г. В. Основы радиосвязи и телевидения: учебное пособие. — М.: Горячая линия — Телеком, 2007. — 416 с.

  9. Tektronix 1725 (SN B040000 and Above) PAL/NTSC Vectorscope: Instruction Manual [Электронный ресурс]. — Beaverton, OR: Tektronix, Inc., 1994. — 150 p. — URL: https://download.tek.com/manual/070763504_0.pdf (дата обращения: 06.10.2025).

Стандарты

  1. SMPTE 170M-2004. Television — Composite analog video signal — NTSC for studio applications / Society of Motion Picture and Television Engineers. — White Plains, NY : SMPTE, 2004.

  2. SMPTE EG 27:2004. Supplemental Information for SMPTE 170M and Background on the Development of NTSC Color Standards / Society of Motion Picture and Television Engineers. — White Plains, NY : SMPTE, 2004.

  3. ГОСТ 24375-80. Радиосвязь. Термины и определения. — Введ. 01.01.1982. — М. : Изд-во стандартов, 1980. — 53 с.

  4. ГОСТ 21879-88. Телевидение вещательное. Термины и определения. — Введ. 01.01.1990. — М. : Изд-во стандартов, 1988. — 23 с.

Интернет-ссылки

  1. ICCV 2019 Tutorial: Understanding Color and the In-Camera Image Processing Pipeline for Computer Vision — https://www.eecs.yorku.ca/~mbrown/ICCV19_Tutorial_MSBrown.pdf

  2. Цвет: от шестнадцатеричных кодов до глаза — https://habr.com/ru/articles/353582/

  3. Color: From Hexcodes to Eyeballs — https://jamie-wong.com/post/color/

  4. Аддитивные цветовые пространства в колориметрии — https://habr.com/ru/articles/332776/

  5. Квадратурная модуляция — https://radioprog.ru/post/415

  6. Understanding I/Q Signals and Quadrature Modulation — https://www.allaboutcircuits.com/textbook/radio-frequency-analysis-design/

  7. Video Basics — https://www.analog.com/en/resources/technical-articles/basics-of-analog-video.html

  8. Composite Video Signals (CVBS) — https://www.ni.com/docs/en-US/bundle/video-measurement-suite/page/nivms/signals_cvbs.html

  9. Главы из книги Raster Graphics Handbook — https://ece-classes.usc.edu/ee459/library/documents/RS170.pdf

  10. Let's Code: NTSC Part 1B (Basics) — https://www.youtube.com/watch?v=xxqXcTstmHE

  11. Let's Learn: NTSC Part 2A (Chroma) — https://www.youtube.com/watch?v=mHXDX0MXcyk

  12. BreakingNESWiki — https://github.com/emu-russia/breaks/tree/master/BreakingNESWiki

  13. How to interpret this NTSC color waveform? — https://electronics.stackexchange.com/questions/428353/how-to-interpret-this-ntsc-color-waveform

  14. Composite Video Signals (CVBS) — https://www.ni.com/docs/en-US/bundle/video-measurement-suite/page/nivms/signals_cvbs.html

  15. Standard Color Bars — https://www.mivs.com/standard-color-bars/

  16. RP2040 Datasheet — https://datasheets.raspberrypi.com/rp2040/rp2040-datasheet.pdf

© 2025 ООО «МТ ФИНАНС»

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


  1. VBKesha
    11.11.2025 13:20

    Спасибо за статью!


  1. nikolz
    11.11.2025 13:20

    До кучи...

    Возможно знаете, но не увидел упоминание в статье.

    10 лет назад пользователь под ником cnlohr сделал аналоговый интерфейс NTSC на ESP8266.

    https://github.com/cnlohr/channel3

    статья на хабре от 2016 https://habr.com/ru/articles/391857/

    2 года назад добавлен интерфейс PAL.

    это повтор решения 2 года назад:

    https://www.instructables.com/ESP8266-NTSC-Analog-TV-Broadcast-on-Channel-3/

    это решение на ESP32 6 лет назад:

    https://bitluni.net/esp32-color-pal

    -----------------------------

    это Библиотека ESP32, которая генерирует композитный видеосигнал для PAL, SECAM и NTSC. 3 года назад.

    https://github.com/aquaticus/esp32_composite_video_lib

    пример видео на ESP32.

    https://www.instructables.com/Hacking-Idea/


    1. artyomsoft Автор
      11.11.2025 13:20

      Спасибо, за подробное дополнение.