Управление RGB светодиодами через блок UDB микроконтроллеров PSoC фирмы Cypress
Введение
Я давно хотел изучить методику программирования блоков UDB в контроллерах PSoC фирмы Cypress, но всё руки как-то не доходили. И вот, возникла задачка, на которой это можно было сделать. Разбираясь с материалами из сети, я понял, что практические рекомендации по работе с UDB ограничиваются теми или иными вариациями счётчиков и ШИМов. Все авторы почему-то делают свои вариации этих двух канонических примеров, поэтому описание чего-то иного вполне может быть интересно читателям.
Итак. Возникла задачка динамически управлять длинной линейкой из RGB светодиодов WS2812B. Классические подходы к этому делу известны. Можно взять банальную Arduino, но там вывод идёт программно, поэтому пока данные выводятся — всё остальное простаивает, иначе собьются временные диаграммы. Можно взять STM32 и выводить данные либо через DMA в ШИМ, либо через DMA в SPI. Методики известны. Я даже, в своё время, лично через SPI уже линейкой из шестнадцати диодов управлял. Но накладные расходы велики. Один бит данных в светодиодах занимает 8 бит в памяти для случая с ШИМ и от 3 до 4 бит (зависит от крутости PLL в контроллере) для SPI. Пока светодиодов мало, это не страшно, но если их, скажем, пара сотен, то 200×24 = 4800 бит = 600 байт полезных данных должны физически храниться в буфере, объёмом более 4 килобайт для ШИМ-варианта или более 2 килобайт для SPI-варианта. Для динамической индикации буферов должно быть несколько, а у STM32F103 ОЗУ на всё про всё 20 килобайт. Не то, чтобы мы упёрлись в нереализуемую задачу, но повод для проверки, можно ли это реализовать на PSoC без необходимости расходования лишнего ОЗУ, вполне весомый.
Ссылки на теорию
Сначала разберёмся, что же это за зверь такой UDB и как с ним работают. В этом помогут замечательные учебные фильмы от производителя контроллеров.
Начать просмотр стоит отсюда https://www.youtube.com/watch? v=dnKuXRkhoj0, а затем в конце каждого видео будет ссылка на следующую серию. Шаг за шагом, вы получите базовые знания и рассмотрите канонический пример «счётчик». Ну, и систему управления светофорами.
Примерно то же самое, но нарезанное на мелкие куски, можно посмотреть здесь http://www.cypress.com/training/psoc-creator-video-tutorial-series-how-use-udb-editor. У меня видео не воспроизводилось, но его можно скачать и посмотреть локально. Среди прочего, там имеется и канонический пример реализации ШИМ.
Поиск готовых решений
Чтобы не изобретать велосипед (а наоборот — изучать методику на чужом опыте), я порылся по сети в поисках готовых решений для управления RGB светодиодами. Самое популярное решение — StripLightLib.cylib. Но у него уже много лет в планах числится «Добавить поддержку DMA». А хочется испытать именно решение, никак не зависящее от центрального процессора. Хочется запустить процесс и забыть о нём, сосредоточившись на подготовке следующего кадра.
Решение, соответствующее моим желаниям, нашлось по адресу https://github.com/PolyVinalDistillate/PSoC_DMA_NeoPixel.
Там всё реализовано на UDB (а ведь светодиоды — это всего лишь повод, цель — изучить UDB). Там есть поддержка DMA. И проект там явно красиво организован.
Проблемы выбранного за основу решения
Как устроена «прошивка» в проекте PSoC_DMA_NeoPixel, желающие могут посмотреть после прочтения статьи. Это позволит закрепить материал. Пока лишь я скажу, что сначала упростил логику оригинальной микропрограммы без уменьшения потребляемых ресурсов (зато её стало проще понимать). Затем начал экспериментировать с заменой логики автомата, что сулило выигрыш в ресурсах, но нарвался на серьёзную проблему. Так и этак решал — не устраняется она! И стали терзать меня смутные сомнения, нет ли той же проблемы у английского автора? Его демка очень красиво мигает светодиодами. Но что, если мы заменим красивое заполнение на «все единицы» и проконтролируем вывод не глазами, а осциллографом?
Вот так, максимально грубо (можно даже сказать «брутально») формируем данные:
memset (pPixelArray,0xff,sizeof(pPixelArray));
//Call NeoPixel update function (non blocking) to trigger DMA pixel update
NP_Update();
И вот такую картинку наблюдаем на осциллографе:
У первого бита ширина отличается от всех остальных. Я просил посылать все единицы, а уходят не все. Среди них затесался ноль! Меняем развёртку:
Ширина отличается у каждого восьмого бита.
В общем, этот пример как самостоятельное решение не годится, а как источник вдохновения — просто идеален. Во-первых, глазом его неработоспособность не видна (светодиоды зажигаются всё равно ярко, глаз не видит, что они светят наполовину максимума), но зато код хорошо структурирован, его приятно взять за основу. Во-вторых, этот пример даёт пространство для поиска путей упрощения, а в-третьих, он заставляет задуматься, как устранить дефект. Самое то, чтобы постичь матчасть! Так что ещё раз рекомендую после прочтения статьи попытаться разобрать оригинальный пример, поняв, как он работает.
Практическая часть
Теперь начинаем практиковаться. Прощупаем основные аспекты разработки микропрограмм для UDB. Рассмотрим взаимосвязи и основные приёмы. Для этого открываем мой вариант проекта. В левом блоке хранится информация о рабочих файлах. По умолчанию открыта вкладка Source. Главный исходник проекта — файл main.c. Собственно, в группе Source Files других рабочих файлов и нет.
Группа Generated Source содержит библиотечные функции. Их лучше не править. После каждого изменения «прошивки» UDB эта группа будет генерироваться заново. Итак, где в этой идиллии размещено описание кода для UDB? Чтобы его увидеть, надо переключиться на вкладку Components:
Автор оригинального проекта сделал двухуровневый набор компонентов. На верхнем уровне лежит схема NeoPixel_v1_2.cysch. Это видно из основной схемы:
Компонент выглядит следующим образом:
Программную поддержку этой схемы мы рассмотрим позже. Пока же выясним, что на ней самой располагается штатный блок DMA и некий символ NeoPixDrv_v1. Этот загадочный блок описан выше в дереве, что следует из следующей всплывающей подсказки:
«Прошивка» UDB
Открываем тот компонент (файл с расширением .cyudb). Открывшийся рисунок просто огромен. Начинаем разбираться, что там к чему.
В отличие от автора оригинального проекта, я рассматриваю передачу каждого бита данных в виде трёх равновеликих (по времени) частей:
- Стартовая часть (всегда 1)
- Часть с данными
- Стоповая часть (всегда 0)
При таком подходе не требуется большое количество счётчиков (в оригинале их было целых три штуки, на что расходовалось большое количество ресурсов). Длительность всех частей одинакова и может быть задана при помощи одного регистра. Таким образом, граф переходов микропрограммного автомата содержит следующие состояния:
Состояние покоя (Idle). В нём автомат пребывает, пока в FIFO не пришли новые данные.
Из учебных видео мне было не совсем понятно, как состояния автомата связаны с АЛУ. Авторы пользуются связью, как чем-то само собой разумеющимся, но я, как новичок, не сразу смог её разглядеть. Давайте сразу разберёмся детально. На рисунке выше видно, что состояние Idle кодируется значением 1'b0. Правильнее будет 3'b000, но редактор всё равно всё переделает. Входы блока Datapath описываются вот так:
Если по ним дважды щёлкнуть, то появится более детальный вариант:
Это значит, что нулевому биту адреса инструкции АЛУ соответствует нулевой бит переменной, задающей состояние автомата. Первому — первый, второму — второй. При желании, битам адреса инструкции АЛУ можно сопоставлять любые переменные и даже выражения (в оригинальном варианте второму биту адреса инструкции АЛУ сопоставлялось именно выражение, причём в текущей версии оно явно не используется, но как выносящий мозг пример — очень наглядно, потом можете глянуть).
Итак. При текущей настройке входов, какой у автомата двоичный код состояния, такая инструкция АЛУ и используется. Когда мы находимся в состоянии Idle, имеющем код 000, используется нулевая инструкция. Вот она:
Я уже умею по этой записи понимать, что это банальный NOP. Но вы можете дважды щёлкнуть по ней и прочесть полный вариант:
Везде вписаны NOPы. Регистры ничем не заполняются.
Теперь разберёмся, что это за загадочный флаг ! NoData, заставляющий автомат покинуть состояние покоя. Это выход из блока Datapath. Всего можно описать до шести выходов. Просто Datapath может вырабатывать намного больше флагов, но трассировочных ресурсов на всех не хватит, поэтому надо выбрать, какие шесть (или менее) нам реально нужны. Вот список на рисунке:
Если по нему дважды щёлкнуть — раскроются подробности:
Вот так выглядит полный список флагов, которые можно было бы вывести:
Выбрав требуемый флаг, следует присвоить ему имя. С этого момента в системе имеется флаг. Как видно, флаг NoData — это имя для цепи F0 block status (empty). То есть признак, что во входном буфере нет данных. А ! NoData, соответственно, его инверсия. Признак наличия данных. Как только данные попадут в FIFO (программно или при помощи DMA), флаг будет сброшен (а его инверсия взведена), и на следующем такте автомат выйдет из состояния покоя и перейдёт в состояние GetData.
Как видим, из этого состояния автомат выйдет безусловно, пробыв в нём ровно один такт. На графе переходов для этого состояния не обозначено никаких действий. Но всегда надо смотреть, что при этом сделает АЛУ. Код состояния 1'b1, то есть, 3'b001. Смотрим соответствующий адрес в АЛУ:
Что-то есть. Не имея опыта чтения того, что здесь написано, раскрываем, дважды щёлкнув по соответствующей ячейке:
Отсюда следует, что само АЛУ по-прежнему не выполняет никаких действий. Но в регистр A0 будет помещено содержимое FIFO0, то есть, данные, поступающие от программы или блока DMA. Забегая вперёд, я скажу, что A0 используется как регистр сдвига, из которого байт будет выходить в последовательном виде. В регистр A1 будет помещено значение регистра D1. Вообще, все регистры D обычно заполняются программно до начала активной работы аппаратуры. Потом, при рассмотрении API, мы увидим, что в этот регистр кладётся число тактов автомата, задающее длительность трети бита. Итак. В A0 попало сдвигаемое значение, а в A1 — значение длительности стартовой части бита. И на следующем такте автомат безусловно перейдёт в состояние Constant1.
Как следует из имени состояния, здесь вырабатывается константа 1. Давайте рассмотрим документацию на светодиод. Вот так должна передаваться единица:
А вот так — ноль:
Красные линии добавил я. Если принять, что длительности третей равны, то требования к длительности импульсов (приведённые в той же документации) выполняются. То есть, любой импульс состоит из стартовой единицы, бита данных и стопового нуля. Собственно, стартовая единица и передаётся, когда автомат находится в состоянии Constant1.
В этом состоянии автомат защёлкивает единицу в своём внутреннем триггере. Имя триггера CurrentBit. В оригинальном проекте это вообще был триггер, задающий состояние вспомогательного автомата. Я решил, что тот автомат только всех запутает, поэтому просто завёл триггер. Он нигде не описан. Но если войти в свойства состояния, в таблице видна такая запись:
И под состоянием на графе имеется такой текст:
Не пугайтесь символа «Равно». Это особенности редактора. В результирующем Verilog коде (автоматически созданным этой же системой) будет стрелка:
Constant1 :
begin
CurrentBit <= (1);
if (( CycleTimeout ) == 1'b1)
begin
MainState <= Setup1 ;
end
end
Значение, защёлкнутое в этом триггере, является выходным сигналом всего нашего блока:
То есть, когда автомат вошёл в состояние Constant1, на выход разрабатываемого нами блока попадёт единица. Теперь смотрим, как запрограммировано АЛУ для адреса 3'b010:
Раскрываем этот элемент:
Из регистра A1 вычитается единица. Выходное значение АЛУ попадает в регистр A1. Выше мы рассматривали, что A1 — это счётчик тактовых импульсов, используемый для задания длительности выходного импульса. Напомню, что он загрузился из D1 на прошлом шаге.
Какое условие выхода из состояния? CycleTimeOut. Оно описано среди выходов следующим образом:
Итак, сводим логику воедино. В прошлом состоянии в регистр A1 попало содержимое заранее заполненного программой регистра D1. На этом шаге автомат переводит триггер CurrentBit в единицу, а в АЛУ регистр A1 уменьшается на каждом такте. Когда A1 станет равен нулю, будет автоматически взведён флаг, которому автор дал имя CycleTimeout, в результате чего автомат перейдёт в состояние Setup1.
Состояние Setup1 готовит данные для передачи полезного импульса.
Смотрим на инструкцию АЛУ по адресу 3'b011. Я сразу раскрою её:
Казалось бы, у АЛУ нет никаких действий. Операция же NOP. И выход АЛУ никуда не попадает. Но это не так. Чрезвычайно важным действием является сдвиг данных в АЛУ. Дело в том, что бит переноса среди выходов связан с нашей цепью ShiftOut:
И в результате этой операции сдвига само сдвинутое значение никуда не попадёт, но цепь ShiftOut примет значение старшего бита регистра A0. То есть, тех данных, которые следует передавать. Под состоянием графа видно, что это значение, вышедшее из АЛУ в цепь ShiftOut, будет защёлкнуто в триггер CurrentBit. Давайте я ещё раз покажу рисунок, чтобы не отматывать статью вверх:
Начинается передача второй части бита — непосредственного значения 0 или 1.
Возвращаемся к инструкции для АЛУ. Кроме того, что уже сказано, там видно, что попутно в регистр A1 снова будет положено содержимое регистра D1, чтобы снова можно было отмерять длительность второй трети импульса.
Состояние DataStage очень похоже на состояние Constant1. Автомат просто вычитает единицу из A1 и выходит в следующее состояние по достижении нуля. Давайте я даже покажу это вот так:
и вот так:
Затем идёт состояние Setup2, суть которого мы уже тоже знаем.
В этом состоянии триггер CurrentBit сбрасывается в ноль (так как будет передаваться третья треть импульса, стоповая часть, а она всегда нулевая). АЛУ же загружает содержимое D1 в A1. Можно даже намётанным глазом увидеть это в краткой записи:
Состояние Constant0 полностью идентично состояниям Constant1 и DataStage. Вычитаем единицу из A1. Когда значение достигло нуля, выходим в состояние ShiftData:
Состояние ShiftData более сложное. В соответствующей инструкции для АЛУ выполняются следующие действия:
Регистр A0 сдвигается на 1 бит, и результаты помещаются назад в A0. В A1 же снова кладётся содержимое D1, чтобы начать отмерять стартовую треть для следующего бита данных.
Выходные стрелки лучше рассматривать с учётом приоритетов, для чего дважды щёлкнем по состоянию ShiftData.
Если передан не последний бит (о том, как формируется этот флаг, чуть ниже), то передаём единицу для следующего бита текущего байта.
Если передан последний бит и в FIFO уже нет данных, идём в состояние покоя.
Наконец, если передан последний бит, но в FIFO имеются данные, идём на выборку и передачу очередного байта.
Теперь о счётчике битов. В АЛУ есть только два аккумулятора: A0 и A1. Они уже заняты под регистр сдвига и счётчик задержки, соответственно. Поэтому счётчик битов используется внешний.
Дважды щёлкнем по нему:
Значение при загрузке равно шести. Загружается оно по флагу LoadCounter, описанному в секции переменных:
То есть, когда берётся очередной байт данных, попутно загружается эта константа.
Когда автомат попадает в состояние ShiftData, счётчик уменьшает значение. При достижении нулевого значения, взводится выход TerminalCount, подключённый к цепи нашей семы FinalBit. Именно эта цепь задаёт, пойдёт автомат передавать следующий бит текущего байта или передавать новый байт (ну, или ждать новую пачку данных).
Собственно, из логики — всё. Как формируется сигнал SpaceForData, задающий состояние выхода Hungry (информирующий блок DMA, что можно передавать очередные данные), читателям предлагается отследить самостоятельно.
Программная поддержка
Автор оригинального проекта предпочёл сделать программную поддержку для всей системы в блоке, описывающем комплексное решение. Напомню, речь идёт об этом блоке:
С этого уровня идёт управление как библиотечным блоком DMA, так и всеми частями, входящими в UDB-шную часть. Для реализации API автор оригинала добавил заголовочный и программный файлы:
Формат тела этих файлов навевает тоску. Всему виной любовь разработчиков PSoC Designer к «чистым Сям». Отсюда ужасные макросы и километровые имена. Классовая организация на C++ пришлась бы здесь как нельзя кстати. По крайней мере, мы это проверили при реализации своей ОСРВ МАКС: получилось красиво и удобно. Но здесь можно много рассуждать, а пользоваться придётся тем, что нам спущено сверху. Я только коротко покажу, как выглядит функция API, содержащая эти самые макросы:
volatile void* `$INSTANCE_NAME`_Start(unsigned int nNumberOfNeopixels, void* pBuffer, double fSpeedMHz)
{
//work out cycles required at specified clock speed...
`$INSTANCE_NAME`_g_pFrameBuffer = NULL;
if((0.3/(1.0/(fSpeedMHz))) > 255) return NULL;
unsigned char fCyclesOn = (unsigned char)(0.35/(1.0/(fSpeedMHz)));
`$INSTANCE_NAME`_g_nFrameBufferSize = nNumberOfNeopixels*3;
//Configure for 19.2 MHz operation
`$INSTANCE_NAME`_Neo_BITCNT_Start(); //Counts bits in a byte
//Sets bitrate frequency in number of clocks. Must be larger than largest of above two counter periods
CY_SET_REG8(`$INSTANCE_NAME`_Neo_DPTH_D1_PTR, fCyclesOn+1);
//Setup a DMA channel
`$INSTANCE_NAME`_g_nDMA_Chan = `$INSTANCE_NAME`_DMA_DmaInitialize(`$INSTANCE_NAME`_DMA_BYTES_PER_BURST,
`$INSTANCE_NAME`_DMA_REQUEST_PER_BURST,
HI16(`$INSTANCE_NAME`_DMA_SRC_BASE),
HI16(`$INSTANCE_NAME`_DMA_DST_BASE));
if(pBuffer == NULL)
...
Эти правила игры придётся принять. Теперь вы знаете, где можно черпать вдохновение при разработке своих функций (лучше всего это делать в оригинальном проекте). А рассказывать о деталях я предпочту, взяв уже обработанный генератором вариант.
После генерации кода (описанной ниже) этот файл будет храниться вот здесь:
И вид уже будет отлично читаемый. Функций пока две. Первая инициализирует систему, вторая запускает передачу данных из буфера в линейку светодиодов.
Инициализация затрагивает все части системы. Там присутствует инициализация семибитного счётчика, входящего в состав UDB-системы:
NP_Neo_BITCNT_Start(); //Counts bits in a byte
Там имеется вычисление константы, которую следует загрузить в регистр D1 (напомню, что она задаёт длительность каждой из трети битов):
unsigned char fCyclesOn = (unsigned char)(0.35/(1.0/(fSpeedMHz)));
CY_SET_REG8(NP_Neo_DPTH_D1_PTR, fCyclesOn+1);
Настройка блока DMA занимает бОльшую часть этой функции. В качестве источника используется буфер, а в качестве приёмника — FIFO0 блока UDB (в километровой записи — NP_Neo_DPTH_F0_PTR). У автора часть этой настройки находилась в функции передачи данных. Но, на мой взгляд, делать все вычисления ради каждой передачи, слишком расточительно. Особенно, если учесть, что одно из действий внутри функции выглядит весьма и весьма объёмно.
//work out cycles required at specified clock speed...
NP_g_pFrameBuffer = NULL;
NP_g_nFrameBufferSize = nNumberOfNeopixels*3;
//Setup a DMA channel
NP_g_nDMA_Chan = NP_DMA_DmaInitialize(NP_DMA_BYTES_PER_BURST,
NP_DMA_REQUEST_PER_BURST, HI16(NP_DMA_SRC_BASE), HI16(NP_DMA_DST_BASE));
...
NP_g_nDMA_TD = CyDmaTdAllocate();
CyDmaTdSetConfiguration(NP_g_nDMA_TD, NP_g_nFrameBufferSize, CY_DMA_DISABLE_TD, TD_INC_SRC_ADR | TD_AUTO_EXEC_NEXT);
CyDmaTdSetAddress(NP_g_nDMA_TD, LO16((uint32)NP_g_pFrameBuffer), LO16((uint32)NP_Neo_DPTH_F0_PTR));
CyDmaChSetInitialTd(NP_g_nDMA_Chan, NP_g_nDMA_TD);
Вторая функция на фоне первой — верх лаконизма. Просто первая вызывается на этапе инициализации, когда требования к быстродействию весьма вольны. Во время работы же, лучше не тратить такты процессора ни на что лишнее:
void NP_Update()
{
if(NP_g_pFrameBuffer)
{
CyDmaChEnable(NP_g_nDMA_Chan, 1);
}
}
Здесь явно не хватает функциональности для работы с несколькими буферами (чтобы обеспечивать двойную буферизацию), но в целом, обсуждение функциональности API выходит за рамки темы статьи. Сейчас главное — показать, как к разработанной микропрограммной части добавить программную поддержку. Теперь мы знаем, как это делается.
Генерация проекта
Итак, вся микропрограммная часть готова, API добавлено, что делать дальше? Выбирать пункт меню Build→Generate Application.
Если всё пройдёт успешно, можно открыть вкладку Results и посмотреть файл с расширением rpt.
В нём видно, какой объём системных ресурсов ушёл на реализацию микропрограммной части.
Когда я сравниваю приведённые результаты с теми, которые были в оригинальном проекте, на душе становится теплее.
Теперь переходим на вкладку Source и начинаем работать с программной частью. Но это уже тривиально и особых объяснений не требует.
Заключение
Надеюсь, из этого примера читатели узнали что-то новое и интересное про практическую работу с блоками UDB. Я постарался акцентировать внимание как на конкретной задаче (управление светодиодами), так и на методике проектирования, так как некоторые аспекты, очевидные для специалистов, мне пришлось долго постигать. Я постарался отметить их, пока свежи воспоминания о поисках. Что касается решённой задачи, то временные диаграммы у меня получились не такими идеальными, как у автора оригинальной разработки, но они прекрасно вписываются в допуски, определяемые в документации на светодиоды, а ресурсов системы при этом ушло существенно меньше.
На самом деле, это только часть найденной нестандартной информации. В частности, из большинства материалов может показаться, что UDB хорошо работает только с последовательными данными, но это не так. Найден Application Note, из которого кратко видно, как можно гонять и параллельные данные. Можно было бы рассмотреть конкретные примеры, базирующиеся на этих сведениях (правда, затмить FX2LP, другой контроллер от Cypress, не удастся: у PSoC скорость шины USB ниже).
У меня в голове крутятся идеи, как решить давно мучающую меня проблему «прошивки» 3D-принтера. Там прерывания, обслуживающие шаговые двигатели, пожирают просто безумный процент процессорного времени. Вообще, про прерывания и процессорное время я много рассуждал в статье про ОСРВ МАКС https://habr.com/post/340032/. Есть прикидки, что для обслуживания шаговых двигателей можно вынести все времянки полностью на откуп UDB, оставив процессору чисто вычислительную задачу без опасения, что он не успеет это сделать в выделенный временной слот.
Но об этих вещах можно будет порассуждать, только если тема окажется интересной.