Motivación
El WS2812 es un led RGB con interface digital mediante tren de pulsos modulado en anchura (PWM) a través de una única línea serie asíncrona. Cada led acepta 24 bits, cuando el tren de pulsos PWM supera esa cantidad de bits, el led envía los bits “sobrantes” a través de otro de sus pines, de esta forma pueden encadenarse tantos leds RGB en serie como se quiera. Aquí puede descargarse la hoja de datos del fabricante.
El tren de pulsos debe tener unos tiempos muy específicos.
- Para mandar un 0 hay que poner la entrada a nivel alto durante 350 ns y luego a nivel bajo durante 800 ns.
- Para mandar un 1 hay que poner la entrada a nivel alto durante 700 ns y luego a nivel bajo durante 600 ns.
Con estos tiempos, con un microcontrolador de gama media o baja, si no tenemos una salida específica que soporte este protocolo hay que recurrir a “trucos”:
- Bitbanging: Lo bueno es que funciona en cualquier micro que tenga GPIO (todo tienen pines GPIO), lo malo es que los tiempos que hay que manejar obligan a inhibir las interrupciones y dejar de hacer el resto de tareas cada vez que el micro quiera refrescar el estado de los leds. Esta es la solución más utilizada actualmente.
- Aprovechar la interface SPI o I2S que tenga el microcontrolador para simular el tren de pulsos: Lo bueno es que es una solución menos “soft” que la anterior pero es una solución muy específica que debe ser programada en función de las características de cada micro y que nos obliga a prescindir de dicho interface (SPI o I2S) de la forma habitual. Por otro lado, aunque se utilicen controladores DMA internos del microcontrolador para aligerar la carga de la CPU, lo cierto es que un controlador DMA no deja de ser un máster de bus más, por lo que siempre provoca un incremento en los estados de espera de la RAM del procesador.
Solución hardware
La idea es utilizar una FPGA para abstraer el acceso a los WS2812. La FPGA implementará una RAM que hará las veces de RAM de vídeo: De cara a los neopixels habrá una máquina de estados encargada de generar el tren de pulsos necesario para representar en los neopixels conectados el contenido de la RAM interna. De cara al procesador la FPGA se mostrará como una RAM con interface SPI estándar. De esta forma el procesador para iluminar un led RGB lo que hará será escribir el valor RGB en la posición correspondiente de la RAM de la FPGA. Los tres primeros bytes se corresponden con el primer pixel (formato GRB), los tres siguiente con el siguiente píxel y así sucesivamente.
Primera iteración
Como primera iteración de la solución se planteará la implementación sólo del interface con los leds RGB y que por ahora lea los datos de una ROM simulada dentro de la FPGA. La conexión SPI se dejará, por tanto, para la segunda iteración del proyecto.
Ruta de datos
La ruta de datos planteada para el interface de la FPGA con los neopixels es la siguiente:
A continuación se enumeran los elementos de forma agrupada.
La memoria ROM
Por ahora es una ROM ya que sólo se va a emitir su contenido y no será aún accesible mediante SPI.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity Rom is port ( AddressIn : in std_logic_vector(12 downto 0); DataOut : out std_logic_vector(7 downto 0) ); end entity; architecture Architecture1 of Rom is type RomType is array (0 to 8191) of std_logic_vector(7 downto 0); constant Data : RomType := ( -- format = GRB "10111010", -- first pixel = green "00100110", "00001000", "00000000", -- second pixel = red "10001000", "00110011", "01000000", -- third pixel = blue "11111111", "10010110", "10000000", -- fourth pixel = yellow "10000000", "00000000", others => "00000000" ); begin DataOut <= Data(to_integer(unsigned(AddressIn))); end architecture;
Registro de desplazamiento (SR)
Se trata de un registro de desplazamiento estándar con multiplexor de carga. Cuando la entrada LOAD está a 1 el multiplexor dirige los datos de la ROM hacia la entrada del registro, mientras que cuando la entrada LOAD está a 0 el multiplexor dirige los datos del desplazador de 1 bit a la izquierda.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity ShiftReg is generic ( NBits : integer := 8 ); port ( Clk : in std_logic; Enable : in std_logic; BitOut : out std_logic; DataIn : in std_logic_vector((NBits - 1) downto 0); Load : in std_logic ); end entity; architecture Architecture1 of ShiftReg is component Reg is generic ( NBits : integer := 16 ); port ( Enable : in std_logic; Clk : in std_logic; DataIn : in std_logic_vector((NBits - 1) downto 0); DataOut : out std_logic_vector((NBits - 1) downto 0) ); end component; signal InputMuxOut : std_logic_vector((NBits - 1) downto 0); signal DataOut : std_logic_vector((NBits - 1) downto 0); begin R : Reg generic map ( NBits => NBits ) port map ( Enable => Enable, Clk => Clk, DataIn => InputMuxOut, DataOut => DataOut ); BitOut <= DataOut(7); InputMuxOut <= DataIn when (Load = '1') else DataOut(6 downto 0) & '0'; end architecture;
Contador con un único límite (BC y ADDR)
Los contadores BC (Bit Counter) y Addr son dos instancias de un mismo contador. El contador implementado permite definir en tiempo de compilación VHDL tanto el valor de inicialización como el valor límite así como el valor de incremento (que puede ser negativo en complemento a dos). En el caso del contador ADDR el valor de inicio es 0 y el incremento es +1. El caso del contador BC es más laxo ya que no se necesita el valor de la cuenta: sólo hace falta saber si se ha llegado al final.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity FixedLimitCounter is generic ( NBits : integer := 16; LimitValue : integer := 0; ResetValue : integer := 1000; Increment : integer := -1 ); port ( Clk : in std_logic; Enable : in std_logic; Reset : in std_logic; LimitReached : out std_logic; DataOut : out std_logic_vector((NBits - 1) downto 0) ); end entity; architecture Architecture1 of FixedLimitCounter is component Reg is generic ( NBits : integer := 16 ); port ( Enable : in std_logic; Clk : in std_logic; DataIn : in std_logic_vector((NBits - 1) downto 0); DataOut : out std_logic_vector((NBits - 1) downto 0) ); end component; component Adder is generic ( NBits : integer := 16 ); port ( A : in std_logic_vector((NBits - 1) downto 0); B : in std_logic_vector((NBits - 1) downto 0); Y : out std_logic_vector((NBits - 1) downto 0) ); end component; signal TCOut : std_logic_vector((NBits - 1) downto 0); signal AdderOut : std_logic_vector((NBits - 1) downto 0); signal InputMuxOut : std_logic_vector((NBits - 1) downto 0); signal LimitReachedPulse : std_logic; signal LimitReachedDBus : std_logic; signal LimitReachedQBus : std_logic; begin C : Reg generic map ( NBits => NBits ) port map ( Enable => Enable, Clk => Clk, DataIn => InputMuxOut, DataOut => TCOut ); A : Adder generic map ( NBits => NBits ) port map ( A => std_logic_vector(to_signed(Increment, NBits)), B => TCOut, Y => AdderOut ); InputMuxOut <= std_logic_vector(to_signed(ResetValue, NBits)) when (Reset = '1') else AdderOut; LimitReachedPulse <= '1' when (TCOut = std_logic_vector(to_signed(LimitValue, NBits))) else '0'; DataOut <= TCOut; -- LimitReached D flip-flop process (Clk) begin if (Clk'event and (Clk = '1')) then LimitReachedQBus <= LimitReachedDBus; end if; end process; LimitReachedDBus <= '0' when (Reset = '1') else (LimitReachedQBus or LimitReachedPulse); LimitReached <= LimitReachedQBus; end architecture;
Contador de límite variable (TC)
El contador TC (Time Counter) es un contador parecido al anterior. La diferencia es que el límite siempre es 0, el incremento es siempre -1 y el valor de inicialización de la cuenta es la salida de un multiplexor de 5 entradas. Este contador se utiliza para medir tiempos. En el caso de los neopixels hay que medir cinco tiempos: el valor alto para el 0 (T0H), el valor bajo para el 0 (T0L), el valor alto para el 1 (T1H), el valor bajo para el 1 (T1L) y el tiempo de pausa entre frames que, en el caso de los neopixels, debe ser de, al menos, 50 microsegundos.
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity Fixed5LimitsCounter is generic ( NBits : integer := 16; Limit0 : integer := 10; Limit1 : integer := 20; Limit2 : integer := 30; Limit3 : integer := 40; Limit4 : integer := 50 ); port ( Clk : in std_logic; Enable : in std_logic; Reset : in std_logic; LimitReached : out std_logic; LimitSelect : in std_logic_vector(2 downto 0) ); end entity; architecture Architecture1 of Fixed5LimitsCounter is component Reg is generic ( NBits : integer := 16 ); port ( Enable : in std_logic; Clk : in std_logic; DataIn : in std_logic_vector((NBits - 1) downto 0); DataOut : out std_logic_vector((NBits - 1) downto 0) ); end component; component Adder is generic ( NBits : integer := 16 ); port ( A : in std_logic_vector((NBits - 1) downto 0); B : in std_logic_vector((NBits - 1) downto 0); Y : out std_logic_vector((NBits - 1) downto 0) ); end component; signal LimitMuxOut : std_logic_vector((NBits - 1) downto 0); signal TCOut : std_logic_vector((NBits - 1) downto 0); signal AdderOut : std_logic_vector((NBits - 1) downto 0); signal InputMuxOut : std_logic_vector((NBits - 1) downto 0); signal LimitReachedPulse : std_logic; signal LimitReachedDBus : std_logic; signal LimitReachedQBus : std_logic; begin TC : Reg generic map ( NBits => NBits ) port map ( Enable => Enable, Clk => Clk, DataIn => InputMuxOut, DataOut => TCOut ); A : Adder generic map ( NBits => NBits ) port map ( A => std_logic_vector(to_signed(-1, NBits)), B => TCOut, Y => AdderOut ); LimitMuxOut <= std_logic_vector(to_unsigned(Limit0, NBits)) when (LimitSelect = "000") else std_logic_vector(to_unsigned(Limit1, NBits)) when (LimitSelect = "001") else std_logic_vector(to_unsigned(Limit2, NBits)) when (LimitSelect = "010") else std_logic_vector(to_unsigned(Limit3, NBits)) when (LimitSelect = "011") else std_logic_vector(to_unsigned(Limit4, NBits)); InputMuxOut <= LimitMuxOut when (Reset = '1') else AdderOut; LimitReachedPulse <= '1' when (TCOut = std_logic_vector(to_signed(0, NBits))) else '0'; -- LimitReached D flip-flop process (Clk) begin if (Clk'event and (Clk = '1')) then LimitReachedQBus <= LimitReachedDBus; end if; end process; LimitReachedDBus <= '0' when (Reset = '1') else (LimitReachedQBus or LimitReachedPulse); LimitReached <= LimitReachedQBus; end architecture;
Máquina de estados
La máquina de estados que se ha realizado consta de 16 estados (cabe justito en 4 biestables si codificamos los estados de forma binaria estándar). Se trata de una máquina de estados de tipo Moore en la que la salida depende del estado actual y el estado siguiente depende del estado actual y de las entradas:
La implementación de una máquina de Moore en VHDL es sistemática:
library ieee; use ieee.std_logic_1164.all; entity FSM is port ( -- inputs Clk : in std_logic; Reset : in std_logic; DrawDisplay : in std_logic; TCLimitReached : in std_logic; BitCounterLimitReached : in std_logic; AddrLimitReached : in std_logic; CurrentBit : in std_logic; -- outputs AddrEnable : out std_logic; AddrReset : out std_logic; RAMEnable : out std_logic; SRLoad : out std_logic; SREnable : out std_logic; BitCounterEnable : out std_logic; BitCounterReset : out std_logic; TCLimitSelect : out std_logic_vector(2 downto 0); -- 000=t0h, 001=t0l, 010=t1h, 011=t1l, 1XX=50us TCEnable : out std_logic; TCReset : out std_logic; NeopixelOutput : out std_logic ); end entity; architecture Architecture1 of FSM is signal QBus : std_logic_vector(3 downto 0); signal DBus : std_logic_vector(3 downto 0); begin process (Clk, Reset) begin if (Clk'event and (Clk = '1')) then if (Reset = '1') then QBus <= (others => '0'); else QBus <= DBus; end if; end if; end process; -- next state logic DBus <= "0001" when ((QBus = "0000") and (DrawDisplay = '1')) else "0010" when ((QBus = "0001") or ((QBus = "1101") and (AddrLimitReached = '0'))) else "0011" when (QBus = "0010") else "0100" when ((QBus = "0011") or (QBus = "1011")) else "0101" when (QBus = "0100") else "0110" when ((QBus = "0101") or ((QBus = "0110") and (TCLimitReached = '0'))) else "0111" when ((QBus = "0110") and (TCLimitReached = '1')) else "1000" when ((QBus = "0111") or ((QBus = "1000") and (TCLimitReached = '0'))) else "1001" when ((QBus = "1000") and (TCLimitReached = '1')) else "1010" when (QBus = "1001") else "1011" when ((QBus = "1010") and (BitCounterLimitReached = '0')) else "1100" when ((QBus = "1010") and (BitCounterLimitReached = '1')) else "1101" when (QBus = "1100") else "1110" when ((QBus = "1101") and (AddrLimitReached = '1')) else "1111" when ((QBus = "1110") or ((QBus = "1111") and (TCLimitReached = '0'))) else "0000" when ((QBus = "1111") and (TCLimitReached = '1')) else "0000"; -- output logic AddrEnable <= '1' when ((QBus = "0001") or (QBus = "1100")) else '0'; AddrReset <= '1' when (QBus = "0001") else '0'; RAMEnable <= '1' when (QBus = "0010") else '0'; SRLoad <= '1' when (QBus = "0011") else '0'; SREnable <= '1' when ((QBus = "0011") or (QBus = "1011")) else '0'; BitCounterEnable <= '1' when ((QBus = "0011") or (QBus = "1001")) else '0'; BitCounterReset <= '1' when (QBus = "0011") else '0'; TCLimitSelect <= "000" when ((QBus = "0101") and (CurrentBit = '0')) else "001" when ((QBus = "0111") and (CurrentBit = '0')) else "010" when ((QBus = "0101") and (CurrentBit = '1')) else "011" when ((QBus = "0111") and (CurrentBit = '1')) else "100" when (QBus = "1110") else "000"; TCEnable <= '1' when ((QBus = "0101") or (QBus = "0110") or (QBus = "0111") or (QBus = "1000") or (QBus = "1110") or (QBus = "1111")) else '0'; TCReset <= '1' when ((QBus = "0101") or (QBus = "0111") or (QBus = "1110")) else '0'; NeopixelOutput <= '1' when (QBus = "0110") else '0'; end architecture;
Como se puede observar hay una señal de entrada adicional que no se encuentra reflejada en la ruta de datos: DRAW. La máquina de estados, al arrancar se pone en el estado 0 (el estado de reset) y permanece en ese estado hasta que la entrada DRAW se ponga a 1, en ese momento es cuando se desencadena todo el proceso (le lee la ROM y se manda bit a bit usando el PWM específico de los neopixels). Cuando terminan de mandarse todos los bytes, la máquina de estados espera el tiempo de pausa (mínimo 50 microsegundo) y vuelve al estado 0. Estado en el que se queda a menos que desde fuera se le vuelva a indicar que dibuje de nuevo (DRAW=1).
Nótese también que la máquina de estados está pensada para interactuar con una RAM ya que el estado 2 pone a 1 la línea ENABLE de la RAM. Esta línea no se encuentra en esta implementación conectada a nada (la ROM no tiene ENABLE, es estática). Se ha dejado ya que en su momento, cuando se utilice una RAM sí que será necesario.
Ajuste de los tiempos
Los tiempos de nivel alto y nivel bajo en función del bit que se envía son críticos en el caso del WS2812. Como se puede ver en el grafo de la máquina de estados el tiempo que está a nivel alto la salida depende exclusivamente del tiempo que permanece la máquina de estados en el estado 6. Dicho tiempo viene determinado por los tiempos T0H y T1H (en función del bit que se esté mandando). El problema viene con el tiempo que debe estar la salida a nivel bajo (T0L y T1L):
- Cuando estamos dentro de los 8 bits del registro de desplazamiento y no es necesario realizar una carga en memoria, además del estado 8 en el que se espera el tiempo T0L o T1L, la máquina de estados pasa por otros estados: 7, 9, 10, 11, 4 y 5 (6 estados adicionales).
- Cuando es necesario cargar el siguiente byte de la memoria en el registro de desplazamiento la cantidad de estados por los que pasa la máquina teniendo la salida a nivel bajo (tiempos T0L y T1L) además del estado 8 son los estados: 7, 9, 10, 12, 13, 2, 3, 4 y 5 (9 estados adicionales).
Teniendo en cuenta que, a 32 MHz, cada estado consume 1 / 32000000 segundos = 31.25 nanosegundos, el desfase de tiempo entre un caso y otro no es trivial. En estos casos hay que echar mano de la tolerancia de las señales de entrada y procurar que la ruta más corta (la primera) entre dentro de la tolerancia de forma negativa para que la ruta más larga (la segunda) caiga dentro de la tolerancia de forma positiva.
Utilizando diferentes testbenchs se consiguieron ajustar los tiempos de esta manera:
T1H
teórico: 700±150 ns (550 a 850 ns)
real con inicio de cuenta en el valor 21:
718750 ps = 718.750 ns = 0.718750 us
T1L
teórico: 600±150 ns (450 a 750 ns)
real con inicio de cuenta en el valor 10:
562500 ps = 562.500 ns = 0.562500 us (ruta “corta”)
656250 ps = 656.250 ns = 0.656250 us (ruta “larga”)
T0H
teórico: 350±150 ns (200 a 500 ns)
real con inicio de cuenta en el valor 9:
343750 ps = 343.750 ns = 0.343750 us
T0L
teórico: 800±150 ns (650 a 950 ns)
real con inicio de cuenta en el valor 16:
750000 ps = 750.000 ns = 0.750000 us (ruta “corta”)
843750 ps = 843.750 ns = 0.843750 us (ruta “larga”)
Estos valores se obtuvieron implementando un reloj a 32 MHz en el testbench (la misma frecuencia del reloj de la placa FPGA Papilio One) y midiendo los tramos correspondientes sobre la simulación.
Implementación física
La implementación física fue la parte más sencilla en este caso. Se asigna la entrada de reloj, se configura la salida para los neopixels y la entrada de reset para la máquina de estados.
El circuito funcionó a la primera :-).
Siguiente entrega
En la siguiente entrega se implementará la parte de interface con el procesador mediante protocolo SPI, simulando una RAM SPI externa.
Todo el código fuente puede descargarse de la sección soft.
Lo sentimos. No se permiten nuevos comentarios después de 90 días.