Расскажу о том, как дружил STM32F103C8T6 c WS2812B через RGB и HSV, и том зачем это было нужно. Исходники прилагаются, красивых картинок не будет.
Ловко проведу по граблям, на которые в этом процессе наступил, любезно предложу опереться на созданные костыли и великодушно подсажу на собственноручно изобретённый велосипед.
Собственно была поставлена цель: сделать гирлянду на ёлку, и для этого написать на «таблетку» прошивку, которая бы позволяла работать с лентой практически любой длины. При чём «работать» означало «выводить анимацию не зацикленную короткими интервалами, а ту, что можно будет как-то рассчитать в МК». Такой подход позволяет создавать неповторимую анимацию как на ленте, так и, нарезав её полосками, создать из неё «экран». И такие экраны в интернете я потом увидел. Вот пример: https://www.youtube.com/watch?v=POMQOsVfDEM .
И пока я не углубился в дерби реализации сразу укажу на свою ошибку номер один. Не берите ленту в роли гирлянды! Я купил ленту в силиконовой оболочке (чтобы она была защищена от случайного замыкания на ёлке). Вот только оказалось, что чипы на ленте перед изломом беззащитны! Лента годится только для раскатывания по ровной поверхности. На гирлянду берите чипы на проводках (собственно гирлянду). Однако в плотном расположении чипов на ленте есть свой шарм, что меня и склонило в своё время в пользу ленты. Так вот, сломанный на моей ленте чип пропускал через себя сигнал странным образом: заливка красным или зелёным всей ленты работала прекрасно, а попытка залить любыми другими цветами приводила к совершенно непредсказуемым результатам. И я, виня во всём свои кривые руки, долго искал ошибку в коде (даже генетическом), пока не понял, что оболочку надо вскрыть, а чип выпаять и заменить. После выпаивания чип сразу развалился пополам. Я с лентой обращался как с хрустальной, но всё равно не могу с полной уверенностью заявить, что он сломался не по моей вине.
К реализации. Общение с лентой решил реализовать через связку DMA->Timer->PWM. И тут совершил ошибку номер 2. На плате рядом находятся выводы GND, +5V, B9. Такой соблазн был воткнуть их вместе в один разъём и подсоединить его прямиком на ленту! Вот тебе сразу и земля, и питание, и сигнальный вывод. А вот нет! Нет канала DMA, который бы позволял регулировать длительность импульса импульсов Tim4_Ch4! Период — да, длительность импульса — нет. На поиск ответа почему не работает DMA – PWM так, как хочется, времени по зелени потратил не мало. Брать нужно тот таймер и канал, который может принимать данные от DMA. Например, Tim3 Ch3.
Определившись с методой общения с лентой надо решить, а как, собственно, анимацию считать / хранить? Каждый бит данных ленты — это байт (хватит и одного) который определяет длительность импульса импульса для таймера. 1 СД = 24 байта, 300 СД = 7200 байт, а менять данные в буфере, на лету, если из него же сейчас данные выдаются не ленту — неправильно, значит буфер должен быть двойным! (Благо в даташите упоминается, что есть прерывание на достижение середины буфера.) А это уже 14400 байт! А если диодов больше? А памяти всего 20КБ…
Было решено сократить количество СД в странице буфера до 10-50, оставить две страницы. А сам кадр хранить в более компактном формате целиком. А можно и два кадра, один выводим, второй — считаем, как на видео картах.
Какой формат будет более компактным? На ум сразу приходит RGB. Лента RGB, формат RGB, полное побитовое соответствие, и видимая простота реализации. Если подумать об экране, то картинки опять таки из RGB выводить на него будет удобно. Короче, RGB быть! Однако, поскольку изначальная цель была — расчёт анимации, создание динамических и масштабируемых эффектов — оказалось, что RGB для этого дела не очень-то подходит. Как внятно написать в терминах RGB пульсацию яркости? Как её вообще регулировать? А как сделать правильную радугу и растянуть её на произвольной длины ленту? Изучение вопроса привело меня в пространство HSV (H — цвет, S — насыщенность, V — яркость). Ещё посматриваю в сторону HSL, но это отдельная тема.
Итак, описание алгоритма, имеем: двух-кадровый буфер с анимацией. Один — текущий, второй рассчитывается. Текущий кадр кусками по прерываниям выгружается в одну из двух страниц, откуда DMA берёт длительность импульса. Всё то время, от того момента, как прерывание закончит подготовку следующей страницы, до возникновения очередного прерывания доступно для расчёта нового кадра. Время выполнения прерывания DMA определяется числом СД на одной странице.
Ну и последнее лирическое отступление и третья моя ошибка, исправленная и преодолённая. Творить решил с помощью модных и мало известных мне инструментов: CubeMX, HAL… Я говорю не об их качестве (кто я такой, чтобы судить!), а о полном отсутствии у меня опыта на тот момент работы с ними. Я наивно полагал, что если в сгенерированных процедурах инициализации, в отведённых местах, допишу свои правки, то они никуда не денутся. Как бы не так! Нужно очень внимательно изучать код библиотеки, которой пользуешься (особенно при такой документации), а с большой и серьёзной библиотекой это может быть непросто. А в Кубе есть куча разных настроек для того же таймера. И очень важно реально понимать, что к чему. Я готов был мышке хвост зубами откусить, когда не понимал, почему лента плюётся в меня какими-то случайными цветами, а не тем, что я в неё посылаю при стандартных, правильных по паспорту таймингах, но начинает работать адекватно, если все тайминги завысить (а, значит, скорость передачи данных занизить) в 10 раз.
Не работает лента WS2812B? Проверь настройку GPIO output speed для пина вывода PWM сигнала. Должна стоять в максимум. Я до этого дошёл цистерну кофе спустя, после того, как плюнув на всё, написал ручками весь код на SPL. И внезапно многое заработало как и было задумано!
С лирикой всё. Дальше только пошаговые инструкции и код. На высокий уровень не претендую, пишу как умею.
Для тех, кому важно, CubeMX v 4.27.0, STM32Cube MCU Package for STM32F1 Series Version 1.6.1.
<здесь должна была быть картинка 1>
Тактируемся от 8МГц кварца через PLL с умножением до 72 МГц.
Настраиваем Таймер.
Вкладка DMA: получатель TIM3_CH3, память-в-периферию, режим циклический, инкремент памяти по байту.
Вкладка константы (люблю их): T_PERIOD 90 (отсчёт таймера), T_RESET 0 (режим сброса на сигнальной линии), T0H 26 (бит 0), T1H 65 (бит 1).
Вкладка параметры: Устанавливаем период счёта T_PERIOD-1
Вкладка GPIO: скорость на максимум (см моя ошибка № 3).
Всё, генерируем проект.
Для ясности определю два термина, которые я всё время использую:
«страница» — часть буфера из которого по DMA берёт ширину импульсов таймер;
«кадр» — собственно кадр, буфер, где хранятся сведения о цвете LED-точек.
Далее понадобятся
- набор функций для работы с лентой и подготовки буфера — файл ws2812b.c и заголовочный к нему ws2812b.h,
- набор функций для работы с выбранным цветовым пространством RGB / HSV — color_schemes.c, и заголовочный color_schemes.h,
- коллекция анимаций в виде однотипных функций — animations.c, animations.h,
- ну и заготовка предустановленных цветов, чтобы было удобно — colors.h
Связываем всё это вместе в ws2812b.h, включаем его в main.h и понеслась.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
#ifndef __WS2812B_H__ #define __WS2812B_H__ #ifdef __cplusplus extern "C" { #endif #include <inttypes.h> #include "color_schemes.h" #include "animations.h" #include "main.h" #ifdef __cplusplus } #endif #endif |
Цветовые схемы.
scheme_colors.h объявляет те схемы, которые я использую.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
#ifndef __COLOR_SCHEMES_H__ #define __COLOR_SCHEMES_H__ #ifdef __cplusplus extern "C" { #endif #include <inttypes.h> #include <stdlib.h> #define DMA_BITS_PER_LED 24 #define SET_COLOR(PTR_LED, COLOR) {*((uint32_t *)(PTR_LED)) = COLOR;} typedef struct __RGBA { uint8_t B; uint8_t R; uint8_t G; uint8_t alpha; } RGBA_t, RGB_t, *RGBA_p, *RGB_p; RGB_t rgb(uint8_t r, uint8_t g, uint8_t b); RGB_t GetRandomRgbaColor(void); typedef struct __HSV { uint16_t H; // Hue: 0 to MAX_H_VALUE uint8_t S; // Saturation: 0 to 255 uint8_t V; // Value: 0 to 255 Brightness } HSV_t, *HSV_p; typedef uint16_t bit_unit_t, *bit_unit_p; typedef HSV_t led_unit_t, *led_unit_p; #define H_SECTOR_SIZE 256 // удобно использовать значения кратные 2^n, дабы упростить деление. #define MAX_H_VALUE 6 * H_SECTOR_SIZE // с этого значения начинается обнуление. HSV_t hsv(uint8_t h, uint8_t s, uint8_t v); HSV_t GetRandomHsvColor(void); RGB_t ConvertHsvToRgb(HSV_p hsv); // переводит цвет формата hsv в rgb структуру, aplha всегда макс. #ifdef __cplusplus } #endif #endif |
Порядок каналов в структуре RGB таков потому, что так удобнее побитовым сдвигом извлекать и подавать в ленту данные. Канал alpha нужен больше для выравнивания структуры до 4 байт. Преобразование структуры в uint32_t и обратно оказалось довольно удобно. А вот попытка использовать alpha канал для регулирования яркости горения СД — нет. Ну и здесь же пара говорящих самих за себя функций про RGB.
С цветовой схемой HSV стоит самостоятельно ознакомиться подробнее в интернете. Но если кратко, H задаёт цвет на радуге, S — его интенсивность от разбавленного в 0, до концентрированного 255, V — яркость (0 не горит, 255 сияет во всю). H-радугу условно можно поделить на 6 секторов, и размер сектора определяет, сколько цветов можно задать. H_SECTOR_SIZE 256 выбрана не просто так. С одной стороны это очень много цветов, с другой стороны на 2^8 МК умеет делить быстро. А при преобразовании HSV в RGB ему это делать придётся. Ну и пара говорящих за себя функций.
Добавляю в ws2812b.h объявления длины ленты (оно же размер кадра), размер буфера кадров, длину страницы DMA, и размер двустраничного буфера DMA. Декларирую существование буферов.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <inttypes.h> #include "color_schemes.h" #include "animations.h" #include "main.h" #define STRIP_LEDS_NUM 300 // сколько светодиодов ленты используем #define LEDS_BUFFER_LENGTH 2 * STRIP_LEDS_NUM // размер буфера кадров #define DMA_BUFF_LEDS 10 // сколько целых светодиодов содержит страница буфера DMA #define DMA_PAGE_LENGTH DMA_BITS_PER_LED * DMA_BUFF_LEDS #define DMA_BUFF_LENGTH 2 * DMA_PAGE_LENGTH // у нас же 2 страницы, так что умножаем на 2. extern led_unit_t g_leds[LEDS_BUFFER_LENGTH]; // глобальный буфер кадров extern bit_unit_t g_dma_double_buffer[DMA_BUFF_LENGTH]; // глобальный двустраничный буфер dma extern bit_unit_p const g_dma_page_a; // первая страница extern bit_unit_p const g_dma_page_b; // вторая страница void InitBuffers2812B(void); void CalcNextFrame(void); void UpdateDmaPage(bit_unit_p dma_page); // либо g_dma_page_a, либо g_dma_page_b. Третьего не дано. |
Поясню про типы bit_unit_t и led_unit_t. Они появились из экспериментов с типами и размерами единицы данных в буферах. Оказалось, что сохранение этих типов даже несколько облегчает чтение программы человеком. Ну и публикую прототипы функций.
В файле ws2812b.c реализация этих функций и ряд служебных. В основном они очень просты для понимания, но некоторые я поясню. Хотел использовать систему флагов, но в итоге полезными оказались только два флага:
- расчёт следующего кадра окончен — можно курить бамбук, пока он не понадобится;
- при транслировании кадра в задержки достигнут конец кадра — на линии данных выставляем сброс, проверяем готовность следующего кадра, и если он готов, переключаемся на него и считаем новый.
По сути оба этих действия осуществляются в функции UpdateDmaPage.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
void UpdateDmaPage(bit_unit_p dma_page) { static uint32_t frame_position = 0; // ползунок по кадру, чтобы помнить, где закончили в прошлый раз. static uint32_t resets_num = 1; // 1, чтобы до тех пор, пока 1й кадр при запуске МК не расчитается, линия была в сбросе. if (IsFrameEnd()) { SetDmaPageValue(dma_page, T_RESET); resets_num--; if (resets_num == 0) { if ( IsFrameReady() ) { SwapFrames(); frame_position = 0; // новый кадр, начинаем сначала ClearFrameEnd(); ClearFrameReady(); } else // следующий кадр ещё не готов. Нужно продлить его ожидание. { resets_num = 1; // @todo: можно добавить сюда сигнал о том, что следующий кадр слишком долго подготавливается. // позволит выбрать более подходящий fps для эффекта. } } } else // кадр ещё не полностью выгружен { TranslateFrameToDmaPage(dma_page, p_dma_frame, frame_position); frame_position += DMA_BUFF_LEDS; if (frame_position >= STRIP_LEDS_NUM) // кадр кончился { SetFrameEnd(); resets_num = MAXRESET_HOLD_30FPS; } } } |
Она вызывается из прерываний DMA при достижении середины и конца буфера, т. е. кончилась первая страница — её можно переписывать новыми данными из готового кадра, кончилась вторая — пишем её. И так, пока весь кадр не будет исчерпан. После чего очередную страницу заполняю сигналом сброса, или даже не одну. Сброс можно держать, пока новый кадр не будет готов. У меня таких долгих расчётов кадров не было.
1 2 3 4 5 6 7 8 9 10 |
bit_unit_p RGB2PWM(RGB_p pRGBA, bit_unit_p pPWM_buff) { uint32_t color = *(uint32_t *)pRGBA; for (uint8_t i = 0; i < DMA_BITS_PER_LED; i++) { *pPWM_buff = (color & (uint32_t)(1 << ((DMA_BITS_PER_LED-1)-i)) ) ? T1H : T0H; pPWM_buff++; } return pPWM_buff; } |
Структура RGB_t такая, как я описал, не просто так. Её запросто можно превратить в 4х-байтный int и накладывая на него битовую маску из одного ползущего бита снять с неё (со структуры) значения всех 24 задержек в страницу DMA, и на выходе дать указатель, откуда следует продолжить.
Остальное, мне кажется здесь достаточно тривиальным.
Перейдём к анимациям.
Их пока не много. Кому интересно, несколько больше проектов есть в архиве, там я пробую и моделирую различные эффекты на веб странице. В файле animations.h есть только указатель на функцию анимации, именно через него она всегда вызывается в CalcNextFrame(), и ряд доступных анимаций. Функция анимации принимает два параметра: 1-й указатель на кадр, в который можно писать, 2-й указатель на предыдущий кадр по сути, на тот случай, если оттуда что-то понадобится прочитать.
Можно рассмотреть функцию создания плывущей радуги
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
static inline HSV_t getNextRainbowColor(HSV_t current, const uint8_t step) { current.H += step; if (current.H >= MAX_H_VALUE) { current.H -= MAX_H_VALUE; } return current; } /** * */ void Animation_Rainbow(led_unit_p leds_frame, led_unit_p prev_frame) { const uint8_t step = MAX_H_VALUE / STRIP_LEDS_NUM; static uint32_t frame = 0; if (!frame) { leds_frame[0] = hsv(0, 255, 0x40); } else { leds_frame[0] = getNextRainbowColor(prev_frame[0], 1); } for(uint16_t i = 1; i < STRIP_LEDS_NUM; i++) { leds_frame[i] = getNextRainbowColor(leds_frame[i-1], step); } frame++; } |
getNextRainbowColor — это вспомогательная для определения следующего цвета с заданным шагом. А сама радуга строится теперь просто — весь диапазон значений H в модели HSV — готовая радуга. Надо лишь растянуть её на всю ленту, определив шаг разности цвета между ближайшими сведодиодами:
1 |
const uint8_t step = MAX_H_VALUE / STRIP_LEDS_NUM; |
рассчитать цвет первого в ленте, а все остальные уже с заданным шагом отступая от предыдущего.
1 |
leds_frame[0] = getNextRainbowColor(prev_frame[0], 1); |
такой расчёт цвета первого СД позволяет задать радуге плавное движение-перелив. Яркость установил жёстко, в четверть максимума (0x40), большего на ёлке мне не надо. Но можно легко установить светимость в максимум (0xFF), или же пускать по радуге волну, например.
Осталось всего-ничего, в сгенерированных файлах прописать вызовы нужных функций.
В main нужно немногое. После инициализации периферии добавим вызов инициализации буферов и всякого, что сейчас создадим, запустим таймер в связке с DMA, и бесконечный цикл сунем расчёт кадров.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
/* USER CODE BEGIN 2 */ InitBuffers2812B(); HAL_TIM_PWM_Start_DMA(&htim3, TIM_CHANNEL_3, (uint32_t *)g_dma_double_buffer, DMA_BUFF_LENGTH); /* USER CODE END 2 */ /* Infinite loop */ /* USER CODE BEGIN WHILE */ while (1) { CalcNextFrame(); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } |
В dma.c написать две функции, которые будут вызываться по прерываниям на середине буфера (конец первой страницы) и в конце его (конец второй страницы).
1 2 3 4 5 6 7 8 9 10 |
void DMA_HalfTransfer_Callback(DMA_HandleTypeDef *hdma) { UpdateDmaPage(g_dma_page_a); } void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim) { UpdateDmaPage(g_dma_page_b); } |
прототип первой функции объявим ещё и в dma.h, он сейчас понадобится
1 2 3 |
/* USER CODE BEGIN Prototypes */ void DMA_HalfTransfer_Callback(DMA_HandleTypeDef *hdma) /* USER CODE END Prototypes */ |
а вторая — это переопределение HAL'овской, которая объявлена как __weak. Про это я тоже сейчас поясню. Осталось последнее: сообщить DMA, что по обозначенным событиям надо что-то делать. Об этом сообщим в файле tim.c: в функцию HAL_TIM_PWM_MspInit добавим всего одну строчку:
1 2 3 |
/* USER CODE BEGIN TIM3_MspInit 1 */ hdma_tim3_ch3.XferHalfCpltCallback = DMA_HalfTransfer_Callback; /* USER CODE END TIM3_MspInit 1 */ |
Спросите а как же, hdma_tim3_ch3.XferCpltCallback ? Он же должен вызываться по окончании буфера? Это один из камней, о который я споткнулся. Это поле будет переписано при вызове HAL_TIM_PWM_Start_DMA в main. И чтобы поучаствовать в этом коллбэке надо переопределить HAL_TIM_PWM_PulseFinishedCallback, что и было сделано выше.
Всё. Можно собирать и заливать. Любуемся плавной радугой. А главное — МК теперь знает о RGB и HSV. Дальше можно делать многое! Хоть мультики в окно показывать!
Добавить комментарий