Небольшая статья о простом тесте управляемых светодиодов WS2812 и SK6812RGBW, как их правильно подключить к 3,3 В IO ПЛИС и как этим всем крайне просто можно управлять из приложения на ПК.
Посмотрев OpenSource драйверы для WS2812, я несколько удивился — на Verilog нашелся только один и тот показался не шибко оптимальным, поэтому было решено написать свой. Со своими костылями и багами. :)
Так как под рукой есть неплохая отладочная плата Intel Cyclone 10 LP FPGA Evaluation Kit, то на ней и буду тестировать. В закромах нашлась ардуиновская плата-щит-шилд для прототипирования (крайне паршивой разводки, покупать себе такую не советую! Выиграл пару штук на аукционе eBay, но вот покупать такое я точно больше не буду и вам не советую!) — ардуино-стайл проект по морганию светодиодами на ПЛИС должен удасться на славу! ;D
Железячная часть
Начнём с самого простого — подключим UART на любые свободные IO 3,3 В. На этой плате все IO с уровнями 3,3 В, я подключил к GPIO0 (Tx) и GPIO1 (Rx). На своей отладочной плате с этим будьте внимательны!
Следующий шаг — это согласование логического уровней для светодиодов. Им требуется по даташиту 0,7*Vdd для логической 1-цы, а это = 3,5 В при питании 5 В. На ардуино-форумах лепят как попало — может и так заработать, если питание просядет, и тогда 3,3 В будет хватать, иногда советуют диод поставить по питанию светодиодов, чтобы искусственно занизить их питание, и другие различные костыли. Мы так делать не будем, а сделаем по уму:
Обычный шинный повторитель-согласователь уровней SN74AVC1T45, который имеет раздельное питание для портов и вход переключения направления. Внутри он прост и незатейлив:
Работает только в одну сторону, задержка сигнала в худшем случае 3,3 нс (при питании 1,2 В), скорость передачи до 500 Мбит (1,8 В и 3,3 В согласование) и имеет удобный корпус SOT как для пайки в любительском применении, так и DSBGA для более компактных устройств.
Спаял на плате 2 таких согласователя и по 3 шт каждого типа светодиодов, вышло как-то так:
По питанию 5 В добавил с запасом емкостей 10 мкФ, один 10 мкФ у микросхем по 3,3 В питанию, а также у каждого ряда светодиодов по 0,1 мкФ:
Также на фото выше можно видеть, что все питающие дорожки я продублировал, потому что плата имеет кривую разводку — все тонкими линиями разведено и зачастую довольно длинными…
В итоге спаянный макет для тестирования светодиодов выглядит вот так:
Но это не всё, на светодиоды сверху наклеена светорассеивающая полоска пластика, подобные стоят в ЖКИ телевизорах или мониторах, для распределения и выравнивания подсветки матрицы:
ПЛИСовая часть
Теперь дело за «прошивкой» для ПЛИС. Общая структура получилась следующая (кликабельно):
Условно всё можно разбить на 4 основных модуля:
- UART — самый простой вариант для отправки\приёма данных или команд (взят из статьи Реализация стабильного UART с минимальными изменениями);
- LedDataSelector — простейшая реализация модуля для разбора принятых данных из UART и распределение принятой «структуры» в ту или иную память светодиодов;
- RAM_2P — двухпортовая память для хранения состояния светодиодов всей линейки, их тут две — на каждый тип светодиодов свой модуль памяти (практически без изменений из примера True Dual-Port RAM with a Single Clock);
- WS2812\SK6812RGBW — собственно, сами драйверы светодиодов, автоматически отправляющие данные на всю линейку светодиодов и автоматически считывающие необходимые значения из памяти по инкриминируемому адресу;
Модуль UART я рассматривать не буду, это и так хорошо сделано автором в его статье.
Следующий модуль — LedDataSelector. Его код:
module LedDataSelector ( input wire clock, input wire reset, input wire [7:0] UART_Rx, input wire UART_RxReady, output reg [31:0] LED0_Data, output reg [31:0] LED0_Addr, output reg LED0_Write, output reg [31:0] LED1_Data, output reg [31:0] LED1_Addr, output reg LED1_Write ); localparam STATE_RX_0_BYTE = 3'd0; localparam STATE_RX_OTHER = 3'd1; reg [2:0] current_state = STATE_RX_0_BYTE; reg [4:0] current_byte = 0; reg [31:0] LED_Data; reg [31:0] LED_Addr; always @(posedge UART_RxReady or posedge reset) begin if(reset) begin LED0_Data <= 32'd0; LED0_Addr <= 32'd0; LED1_Data <= 32'd0; LED1_Addr <= 32'd0; current_state <= STATE_RX_0_BYTE; end else begin case (current_state) STATE_RX_0_BYTE: begin current_byte <= 1; LED0_Write <= 1'b0; LED1_Write <= 1'b0; LED_Addr <= {24'd0, UART_Rx[7:0]}; current_state <= STATE_RX_OTHER; end STATE_RX_OTHER: begin if(UART_RxReady) begin case (current_byte) 1: LED_Addr <= {16'd0, UART_Rx[7:0], LED_Addr[7:0]}; 2: LED_Addr <= {8'd0, UART_Rx[7:0], LED_Addr[15:0]}; 3: LED_Addr <= {UART_Rx[7:0], LED_Addr[23:0]}; 4: LED_Data <= {24'd0, UART_Rx[7:0]}; 5: LED_Data <= {16'd0, UART_Rx[7:0], LED_Data[7:0]}; 6: LED_Data <= {8'd0, UART_Rx[7:0], LED_Data[15:0]}; 7: begin LED_Data = {UART_Rx[7:0], LED_Data[23:0]}; if(LED_Addr[31] == 1'b1) begin LED_Addr[31] = 1'b0; LED1_Data <= LED_Data; LED1_Addr <= LED_Addr; LED1_Write <= 1'b1; end else begin LED0_Data <= LED_Data; LED0_Addr <= LED_Addr; LED0_Write <= 1'b1; end current_state <= STATE_RX_0_BYTE; end endcase // current_byte current_byte <= current_byte + 1; end end endcase // current_state end end endmodule
И получившийся автомат конечных состояний крайне прост:
Логика работы следующая — ждём фронта сигнала UART_RxReady, который обозначает, что UART принял новый байт данных, если состояние конечного автомата STATE_RX_0_BYTE , то сбрасываются все внутренние регистры и записывается принятый байт как младший для LED_Addr (адрес светодиода).
Здесь стоит забежать вперёд и для прозрачности понимания описать, какой формат имеет посылка данных по UART:
[StructLayout(LayoutKind.Explicit, Pack = 1)] struct LedData { [MarshalAs(UnmanagedType.U4)] [FieldOffset(0)] public UInt32 addr; [MarshalAs(UnmanagedType.U4)] [FieldOffset(4)] public UInt32 color; // RGBW (0...31) }
Да, да, банально 2-ва unsigned Int-32 подряд. Последовательно отсылаются в UART, начиная с младшего байта, сначала адрес, потом данные (цвет светодиода), причём для WS2812 используются только первые 24-бита данных, т. к. белого отдельного в них нет, а формат для упрощения реализации можно использовать такой же, как и для SK6812RGBW.
Так вот, из состояния STATE_RX_0_BYTE автомат переходит в следующее состояние — STATE_RX_OTHER, и здесь просто тупо принимаются последующие байты и последовательно записываются в свои места адреса и данных, а по приему 8-го (последнего для 1-й структуры) на основе принятого адреса (смотрится наличие 1-цы в самом старшем разряде) и выставляются принятые данные (цвет) в RAM для WS2812 (старший бит адреса = 0) или в RAM для SK6812RGBW (старший бит адреса = 1, перед записью он сбрасывается).
Логическая реализация модуля в графическом виде (RTL Viewer) выглядит, конечно, монструозно в сравнении с HDL описанием на Verilog:
По хорошему, здесь необходимо реализовать ещё две вещи, которые просто необходимы в боевом применении, а не на столе в идеальных условиях:
- Таймаут приёма — если n-времени не было принято ожидаемое количество байт для одной структуры, то состояние конечного автомата необходимо сбросить в изначальное, иначе при неожиданном прекращении передачи и последующим её возобновлением данные уже будут сдвинуты и ПЛИС будет «принимать» совсем не то, что в неё передаётся;
- Контрольная сумма — как дополнение первого, если время не вышло или банально были помехи на линии, то от приёма мусора или некорректных данных защитит добавление контрольной суммы (даже банального сложения всего с переполнением в один байт хотя бы).
Но эти пункты я оставлю на реализацию Вами. :)
Модуль RAM_2P — ничего интересного в целом… код:
module RAM_2P #( parameter ADDR_WIDTH = 32, // Сколько бит содержит адресация parameter DATA_WIDTH = 32 // Сколько бит содержат данные ) ( input [DATA_WIDTH-1:0] data_a, data_b, input [ADDR_POINTER_WIDTH:0] addr_a, addr_b, input we_a, we_b, input clock, output reg [DATA_WIDTH-1:0] rd_a, rd_b ); localparam integer ADDR_POINTER_WIDTH = $clog2(ADDR_WIDTH); reg [DATA_WIDTH-1:0] ram[ADDR_WIDTH-1:0]; always @ (posedge clock) begin if (we_a) begin ram[addr_a] <= data_a; rd_a <= data_a; end else begin rd_a <= ram[addr_a]; end end always @ (posedge clock) begin if (we_b) begin ram[addr_b] <= data_b; rd_b <= data_b; end else begin rd_b <= ram[addr_b]; end end endmodule
Из изменений оригинального примера здесь только добавление параметров для задания битности адреса и данных.
Логическая реализация модуля памяти в графическом виде (RTL Viewer) крайне проста и в целом представляет из себя использование встроенного блока SYNC_RAM:
Ну и последний модуль для WS2812\SK6812RGBW, ради которого всё это и затевалось. В нём так же реализован конечный автомат, но уже побольше состояний, чем 2 :) Его работа выглядит вот так (здесь не показан только синхронный сброс с любого состояния в STATE_RESET):
По состояниям, если кратко описать, что, как и зачем, логика работы следующая:
- STATE_RESET — состояние, в котором сбрасываются все счетчики и выходная линия ws_data в 0, после чего устанавливается следующим состояние STATE_PREPARE_LATCH;
- STATE_PREPARE_LATCH — состояние, в течение которого держится 1-ца на линии new_data_req, и оно длится заданное количество тактов параметром PREPARE_LATCH_DELAY, после чего устанавливается следующим состояние STATE_LATCH;
- STATE_LATCH — в этом состоянии происходит защелкивание данных с шины color_rgb во внутренние регистры цветов, устанавливается первый передаваемый цвет (зелёный) и устанавливается следующим состояние STATE_PREPARE_TRANSMIT;
- STATE_PREPARE_TRANSMIT — здесь сбрасываются счетчики передаваемого бита цвета и тактов, а также на основе текущего цвета задаётся регистр передаваемого цвета led_current_color, после чего устанавливается следующим состояние STATE_TRANSMIT;
- STATE_TRANSMIT — в этом состоянии на основе счетчика тактов генерируется протокол для управления светодиодом и отправляется в выходную линию ws_data, каждый бит передаётся в заданный период, который устанавливается параметром CLOCK_CYCLE_COUNT (количество тактов на период), а также значение выходной линии задаётся параметрами T1H_CYCLE_COUNT (сколько держать в 1 выходную линию, если текущий бит = 1) и T0H_CYCLE_COUNT (сколько держать в 1 выходную линию, если текущий бит = 0). После того, как счёт текущего бита доходит до 0 (передача ведётся начиная со старших бит), производится проверка, был ли это последний цвет текущего светодиода, если нет — устанавливается следующий цвет и устанавливается состояние STATE_PREPARE_TRANSMIT, если был последний, то проверяется, был ли это последний светодиод, если нет — увеличивается адрес текущего светодиода и устанавливается состояние STATE_PREPARE_LATCH для считывания следующего значения данных, если же это был последний светодиод, то устанавливается состояние STATE_SEND_RESET;
- STATE_SEND_RESET — это состояние, простой счётчик паузы между посылками данных на всю линейку светодиодов, в нём выходная линия ws_data устанавливается в 0 на время, заданное параметром RESET_CYCLE_COUNT, светодиоды на этот момент принимают переданные в них данные и отображают соответствующие цвета, после окончания паузы устанавливается состояние STATE_RESET и всё повторяется по кругу;
Код для SK6812RGBW (аналогичен WS2812 за исключением того, что отправляется не 32 бита, а 24, и нет работы с байтом «белого цвета»):
module SK6812RGBW #( parameter LEDS_NUM = 3, // Сколько светодиодов parameter PREPARE_LATCH_DELAY = 10, // сколько тактов ожидать новые данные parameter CLOCK_FRQ = 50_000_000 // Частота тактового сигнала ) ( input wire clock, input wire reset, input wire [31:0] color_rgbw, // Используется все биты, по байтам на каждый цвет (младший - R, старший - W) output reg new_data_req, output reg [LED_ADDR_WIDTH-1:0] current_ledN, output reg ws_data ); localparam integer CLOCK_CYCLE_COUNT = CLOCK_FRQ / 800_000; localparam integer T0H_CYCLE_COUNT = 0.35 * CLOCK_CYCLE_COUNT; localparam integer T1H_CYCLE_COUNT = 0.9 * CLOCK_CYCLE_COUNT; localparam integer RESET_CYCLE_COUNT = 600 * CLOCK_CYCLE_COUNT; // с запасом localparam integer LED_ADDR_WIDTH = $clog2(LEDS_NUM); localparam integer CLK_COUNTER_WIDTH = $clog2(RESET_CYCLE_COUNT); localparam STATE_RESET = 3'd0; localparam STATE_PREPARE_LATCH = 3'd1; localparam STATE_LATCH = 3'd2; localparam STATE_PREPARE_TRANSMIT = 3'd3; localparam STATE_TRANSMIT = 3'd4; localparam STATE_SEND_RESET = 3'd5; reg [CLK_COUNTER_WIDTH-1:0] clk_counter; reg [2:0] current_state = STATE_RESET; reg [2:0] current_color; reg [2:0] current_bit; reg [7:0] led_red; reg [7:0] led_green; reg [7:0] led_blue; reg [7:0] led_white; reg [7:0] led_current_color; always @ (posedge clock) begin if (reset) begin ws_data <= 0; current_state <= STATE_RESET; end else begin case (current_state) STATE_RESET: begin ws_data <= 0; clk_counter <= 0; current_ledN <= 0; current_state <= STATE_PREPARE_LATCH; end STATE_PREPARE_LATCH: begin new_data_req = PREPARE_LATCH_DELAY) current_state <= STATE_LATCH; else clk_counter <= clk_counter + 1'b1; end STATE_LATCH: begin new_data_req <= 0; led_red <= color_rgbw[7:0]; led_green <= color_rgbw[15:8]; led_blue <= color_rgbw[23:16]; led_white <= color_rgbw[31:24]; current_color <= 0; // зеленый current_state <= STATE_PREPARE_TRANSMIT; end STATE_PREPARE_TRANSMIT: begin clk_counter <= 0; current_bit <= 3'd7; if(current_color == 2'd0) // зеленый led_current_color <= led_green; else if(current_color == 2'd1) // красный led_current_color <= led_red; else if(current_color == 2'd2) // синий led_current_color <= led_blue; else if(current_color == 2'd3) // белый led_current_color <= led_white; current_state = T1H_CYCLE_COUNT) ws_data = T0H_CYCLE_COUNT) ws_data <= 0; else ws_data = CLOCK_CYCLE_COUNT) begin clk_counter <= 0; if(current_bit == 3'd0) begin if(current_color == 2'd3) begin if(current_ledN == LEDS_NUM) current_state <= STATE_SEND_RESET; else begin // следующий светодиод current_ledN <= current_ledN + 1'd1; current_color <= 0; // зеленый clk_counter <= 0; current_state <= STATE_PREPARE_LATCH; end end else begin current_color <= current_color + 1'b1; current_state <= STATE_PREPARE_TRANSMIT; end end else current_bit <= current_bit - 1'b1; end else clk_counter <= clk_counter + 1'b1; end STATE_SEND_RESET: begin if(clk_counter < RESET_CYCLE_COUNT) begin clk_counter <= clk_counter + 1'b1; ws_data <= 0; end else current_state <= STATE_RESET; end endcase // current_state end end endmodule
Логическая реализация модуля в графическом виде (RTL Viewer):
Можно зашить получившийся проект в ПЛИС и … ничего не светится. Правильно. :) Надо ведь теперь задать в памяти значения, чего выводить-то…
Примечание: Разве что можно инициализировать память начальными значениями, отличными от нуля, таким способом (добавить в RAM_2P модуль):
initial begin $readmemb("raminit.txt", ram); end
Файл raminit.txt должен иметь вид (каждый элемент памяти на своей строке в порядке возрастания, данные в HEX формате):
00АА0033 BB00CC00 FFAA2700 ...
WPF\C# часть
Ну и последняя часть, самая простая в целом — написание тестового приложения, в котором можно будет задавать цвет\оттенок, оно будет отправлять необходимые структуры в UART.
Само приложение состоит из следующих классов и, собственно, описания управляющей структуры:
Большая часть представленных выше классов — вспомогательные, визуальные или банально конвертеры величин для XAML. Основным же является единственный статичный класс — ColorGen, в метод которого передаётся массив цветовых свойств и возвращается массив байт, который уже просто отсылается в UART без каких либо преобразований.
Вот один из методов для преобразования свойств типа Color (из System.Windows.Media) в массив байт для светодиодов :
public static byte[] GenWS2812(Color[] cc) { UInt32 count = 0; if (cc.Length > 0) { byte[] data = new byte[cc.Length * 4 * 2]; // кол-во светодиодов * 4 байта в UINT32 * кол-во параметров в структуре foreach (Color c in cc) { byte r = (byte)(c.R * c.ScA); byte g = (byte)(c.G * c.ScA); byte b = (byte)(c.B * c.ScA); LedData led = new LedData() { addr = count, color = (UInt32)(r + (g << 8) + (b << 16)) }; byte[] data_b = getBytes(led); data_b.CopyTo(data, count * 4 * 2); count++; } return data; } return null; }
Метод сделан универсальным, на основе длины входного массива сс генерируется необходимой длины массив байт и он уже заполняется по образу структуры LedData. Напомню, структура LedData, описывающая один светодиод, выглядит так:
[StructLayout(LayoutKind.Explicit, Pack = 1)] struct LedData { [MarshalAs(UnmanagedType.U4)] [FieldOffset(0)] public UInt32 addr; [MarshalAs(UnmanagedType.U4)] [FieldOffset(4)] public UInt32 color; // RGBW (0...31) }
В целом больше ничего примечательного в приложении по коду нет. Размечаем интерфейс в XAML (а-ля веббер):
Компилируем, запускаем:
Пруф-оф-ворк
Настало время проверять работоспособность! И оно работает как и ожидалось (тест WS2812):
Или тест белых светодиодов в SK6812RGBW (RGB часть аналогична WS2812):
Или вот несколько попыток сфотографировать на телефон, но увы, ШИМ светодиодов (хоть и аж 400 Гц) даёт о себе знать — или все пересвечено, но без полос или цвет лучше передаётся, но с полосами:
Если есть сомнения в работоспособности — качаем проект, проверяем сами. ;) Найдете баги — буду рад, если сообщите!
Ссылки
- ADElectronics/Cyclone10_WS2812_SK6812RGBW — мой драйвер для WS2812, SK6812RGBW на Verilog + тестовый проект для ПЛИС и ПК;
- dhrosa/ws2812-verilog — другой открытый драйвер для ws2812;
- True Dual-Port RAM with a Single Clock — пример реализации двухпортовой памяти;
- Реализация стабильного UART — хорошая статья-пример по реализации простого UART на Verilog;
- SN74AVC1T45 — даташит на шинный буфер-согласователь уровней;
- WS2812B — даташит на светодиод;
- SK6812RGBW — даташит на светодиод;
- Intel Cyclone 10 LP FPGA Evaluation Kit — документация на отладочную плату.