Implementación de una interfaz VGA sobre FPGA 
A lo largo de este post se abordará el diseño y la implementación en VHDL de una interfaz de salida VGA para FPGA. La interfaz lee una imagen de 64x48 pixels de una memoria (por ahora una ROM) interna y la renderiza usando el modo VGA estándar de 640x480 a 60Hz.

Señal VGA

Las señales más importantes que viajan por un cable VGA son:

- R (nivel de rojo, mínimo 0 voltios, máximo 0.7 voltios)

- G (nivel de verde, mínimo 0 voltios, máximo 0.7 voltios)

- B (nivel de azul, mínimo 0 voltios, máximo 0.7 voltios)

- Sincronismo horizontal (HSync, señal digital TTL que puede ser de 3.3 voltios)

- Sincronismo vertical (VSync, señal digital TTL que puede ser de 3.3 voltios)

Un flanco de bajada en HSync determina el fin de una línea horizontal de imagen y un flanco de bajada en VSync determina el fin de un fotograma, o de un cuadro). Hay unos "márgenes de seguridad" antes y después (front y back porch) de cada flanco de bajada de HSync de la misma forma que hay unos "márgenes de seguridad" (en forma de líneas en negro) antes y después (front y back porch) de cada flanco de bajada de VSync.

A continuación puede verse un diagrama de tiempos sobre cómo funcionan las señales de color y de sincronismo en un cable VGA:

(imagen copyright © 2017 Scott Larson, extraida de este artículo)


Si asumimos una resolución VGA estándar de 640 x 480 pixels a 60 Hz, tendremos los siguientes valores:
Pixel clockAnchura (pixels)Altura (líneas)
VisiblesNo visiblesVisiblesNo visibles
Front porchHSyncBack porchFront porchHSyncBack porch
25.175 MHz64016964848010233


Con estos valores vemos que, en efecto tenemos una tasa de refresco de:

$${1 \over {{{640+16+96+48} \over {25175000}} \times {\left(480+10+2+33\right)}}}=59.940\ Hz \approx 60\ Hz$$

Como los valores RGB son analógicos (entre 0 y 0.7 voltios), será necesario implementar un DAC (aunque sea de forma rudimentaria) por cada componente de color. Asumiremos una imagen de 8 colores con un bit por cada componente:

000001010011100101110111
negroazulverdecyanrojorosaamarilloblanco


De esta forma tendremos un DAC de 1 bit por cada color. Si la impedancia de entrada de las entradas RGB es de 75 Ohm vemos que con una sencilla resistencia de 270 Ohm en serie construimos un divisor de tensión que genera 0 voltios con un valor lógico 0 (0 voltios de salida digital) y 0.7 voltios con un valor lógico 1 (3.3 voltios de salida digital):

$${0 \times {75 \over {270+75}}}=0\ voltios$$
$${3.3 \times {75 \over {270+75}}}=0.71739\ voltios$$

Por cada píxel hay que almacenar 3 bits pero como las anchuras de bus de 3 bits son raras asumimos que cada pixel ocupa un byte del cual (por ahora) sólo se usan los 3 bits menos significativos.

Como el reloj de nuestra FPGA va a 50 MHz y el pixel clock es de 25.175 MHz se ha optado por asumir una imagen de 64 x 48 pixels. Esto es: cada pixel de la imagen en la memoria se corresponde con un cuadrado de 10 x 10 pixels en la pantalla. Usando esta aproximación, una imagen de 64 x 48 pixels necesita 64 x 48 = 3072 bytes de almacenamiento y el pixel clock se reduce de 25.175 MHz a 2.5175 MHz, que es una frecuencia más fácil de manejar por la FPGA.

Tenemos, por tanto, una memoria de 64 x 48 = 3072 bytes en la que cada byte posee los valores RGB en sus tres bits menos significativos. En cada línea de imagen se deben pintar 64 pixels (con un pixel clock de 2.5175 MHz en lugar de 25.175 MHz para que cada pixel de la memoria ocupe 10 pixels VGA de ancho) y cada línea debe ser pintada 10 veces de idéntica manera.

Ruta de datos y máquina de estados

A continuación puede verse una propuesta de ruta de datos a implementar para la interfaz VGA:


Leyenda:

PW = Pixel width
FP = Front porch (tiempo "en negro" antes del pulso HSync)
BP = Back porch (tiempo "en negro" después del puslto HSync)
LS = Line size, anchura total en pulsos de una línea en blanco de 640 pixels

El registro LineAddr almacena la dirección de comienzo de la línea actual. Es un registro que se incrementa de 64 en 64 y que ayuda a hacer el repetido de líneas (cada una de las 48 líneas de la imagen debe ser repetida 10 veces para renderizar las 480 líneas VGA).

El registro PixelAddr almacena la dirección de memoria del píxel actual que está siendo pintado. Este registro se inicializa siempre con el valor del registro LineAddr y es incrementado de uno en uno, 64 veces por cada línea (64 * 10 pixels = 640 pixels de anchura de la señal VGA).

El registro Pixel almacena el byte de cuyos tres bits más bajos se sacan las señales RGB de forma directa. Está gobernado por un multiplexor que decide si carga datos de la memoria o de la constante 0. Nótese que durante los márgenes de seguridad (front y back porch), durante los intervalos de sincronismo (tanto vertical como horizontal) y durante las lineas de retrazo (las que no se ven, de la 480 a la 524, 45 en total) debe emitirse una señal en negro por los pines RGB.

Se han habilitado además 3 contadores de propósito general que son utilizados por la máquina de estados para controlar los tiempos de cada fase de la señal VGA. La máquina de estados puede verse a continuación:

Los diferentes valores que se aplican a los contadores para controlar los tiempos del protocolo VGA se calculan teniendo en cuenta la frecuencia de reloj usada (50 MHz) y la frecuencia de pixel VGA (25.175 MHz). Por ejemplo el valor de PW (PIXEL_WIDTH) se usa para esperar un tiempo equivalente a 10 pixels:

$$PW={{1 \over 25175000} \times 50000000 \times 10}=20\ pulsos$$

De la misma forma se calcula el resto de valores:

$$FP={{1 \over 25175000} \times 50000000 \times 16}=32\ pulsos$$
$$HSYNC={{1 \over 25175000} \times 50000000 \times 96}=191\ pulsos$$
$$BP={{1 \over 25175000} \times 50000000 \times 48}=95\ pulsos$$
$$LS+BP={{1 \over 25175000} \times 50000000 \times \left(640+16\right)}=1303\ pulsos$$

Estos valores teóricos son luego ajustados ya que en esas ecuaciones sólo se tienen en cuenta los estados de espera y no se contabiliza el resto de estados, que también consumen ciclos de reloj.

Una técnica muy usada para compensar los tiempos de espera en las máquinas de estado es incluir estados que no hacen nada con transiciones vacías (épsilon). Como se puede ver en la imagen anterior, el estado 14 se alcanza siempre al final del renderizado de una línea horizontal VGA. Si Contador3 = 0 entonces se ha terminado una repetición de 10 líneas consecutivas y toca avanzar de línea, pero si Contador3 <> 0 entonces hay que volver a pintar la línea actual (la que está apuntada por el registro LA, LineAddress). En este último caso, la máquina de estados pasa por tres estados que no hacen nada (26, 27 y 28) pero que se han colocado ahí para que no haya diferencia entre la cantidad de ciclos que tarda una línea repetida y la cantidad de ciclos que tarda una línea nueva.

Buffers de salida para los sincronismos

Aunque la máquina de estados ya genera directamente las señales HSync y VSync, es necesario hacerlas pasar por un latch (biestable D) para garantizar que quedan libres de gitches.

    -- acondicionador de señales HSync y VSync
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            HSyncQBus <= HSyncDBus;
        end if;
    end process;

    HSyncDBus <= HSyncIn;
    HSyncOut <= HSyncQBus;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            VSyncQBus <= VSyncDBus;
        end if;
    end process;

    VSyncDBus <= VSyncIn;
    VSyncOut <= VSyncQBus;


Si no se colocasen estos latches y se cableasen directamente las salidas HSync y VSync a los pines de la PFGA podría ocurrir que transiciones intermedias espúreas entre estados generasen pulsos "fantasma" en dichas salidas. Haciendo pasar a estas señales por un latch se garantiza una carga atrasada y limpia que disminuye la probabilidad de que se produzcan estos glitches.

Por claridad, estos latches no se muestran en el diagrama con la ruta de datos mostrado anteriormente.

Código fuente e implementación

La máquina de estados se ha implementado, como otras veces, siguiendo el modelo estándar de máquina de Moore:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity FSM is
    port (
        Clk               : in std_logic;
        Reset             : in std_logic;
        Counter1IsZero    : in std_logic;
        Counter2IsZero    : in std_logic;
        Counter3IsZero    : in std_logic;
        LineAddrIsVisible : in std_logic;
        Counter2IsVSync   : in std_logic;
        Counter1Mux       : out std_logic_vector(2 downto 0);
        Counter2Mux       : out std_logic_vector(1 downto 0);
        Counter3Mux       : out std_logic_vector(1 downto 0);
        LineAddrMux       : out std_logic_vector(1 downto 0);
        PixelAddrMux      : out std_logic_vector(1 downto 0);
        PixelMux          : out std_logic_vector(1 downto 0);
        HSync             : out std_logic;
        VSync             : out std_logic
    );
end entity;

architecture A of FSM is
    signal DBus : std_logic_vector(4 downto 0);
    signal QBus : std_logic_vector(4 downto 0);
begin
    -- lógica de estado siguiente
    DBus <= "00000" when (Reset = '1') else
            "00001" when ((QBus = "00000") or ((QBus = "11101") and (Counter2IsZero = '1'))) else
            "00010" when ((QBus = "00001") or (QBus = "11100") or (QBus = "11001")) else
            "00011" when ((QBus = "00010") or ((QBus = "00110") and (Counter2IsZero = '0'))) else
            "00100" when ((QBus = "00011") or ((QBus = "00100") and (Counter1IsZero = '0'))) else
            "00101" when ((QBus = "00100") and (Counter1IsZero = '1')) else
            "00110" when (QBus = "00101") else
            "00111" when ((QBus = "00110") and (Counter2IsZero = '1')) else
            "01000" when ((QBus = "00111") or ((QBus = "01000") and (Counter1IsZero = '0'))) else
            "01001" when ((QBus = "01000") and (Counter1IsZero = '1')) else
            "01010" when ((QBus = "01001") or ((QBus = "01010") and (Counter1IsZero = '0'))) else
            "01011" when ((QBus = "01010") and (Counter1IsZero = '1')) else
            "01100" when ((QBus = "01011") or ((QBus = "01100") and (Counter1IsZero = '0'))) else
            "01101" when ((QBus = "01100") and (Counter1IsZero = '1')) else
            "01110" when (QBus = "01101") else
            "01111" when ((QBus = "01110") and (Counter3IsZero = '1')) else
            "10000" when (QBus = "01111") else
            "10001" when ((QBus = "10000") and (LineAddrIsVisible = '0')) else
            "10010" when ((QBus = "10001") or ((QBus = "11101") and (Counter2IsZero = '0'))) else
            "10011" when ((QBus = "10010") or ((QBus = "10011") and (Counter1IsZero = '0'))) else
            "10100" when ((QBus = "10011") and (Counter1IsZero = '1')) else
            "10101" when ((QBus = "10100") or ((QBus = "10101") and (Counter1IsZero = '0'))) else
            "10110" when ((QBus = "10101") and (Counter1IsZero = '1')) else
            "10111" when ((QBus = "10110") or ((QBus = "10111") and (Counter1IsZero = '0'))) else
            "11000" when ((QBus = "10111") and (Counter1IsZero = '1')) else
            "11001" when ((QBus = "10000") and (LineAddrIsVisible = '1')) else
            "11010" when ((QBus = "01110") and (Counter3IsZero = '0')) else
            "11011" when (QBus = "11010") else
            "11100" when (QBus = "11011") else
            "11101" when (QBus = "11000") else
            "00000";

    -- lógica de salida
    Counter1Mux <= "001" when (QBus = "00011") else   -- cargar anchura de pixel
                   "010" when (QBus = "00111") else   -- cargar front porch
                   "011" when ((QBus = "01001") or (QBus = "10100")) else   -- cargar HSYNC
                   "100" when ((QBus = "01011") or (QBus = "10110")) else   -- cargar back porch
                   "101" when (QBus = "10010") else   -- cargar anchura línea + front porch
                   "110" when ((QBus = "00100") or (QBus = "01000") or (QBus = "01010") or (QBus = "01100") or (QBus = "10011") or (QBus = "10101") or (QBus = "10111")) else   -- decrementar
                   "000";
    Counter2Mux <= "01" when (QBus = "00010") else   -- cargar 64 (la anchura de línea)
                   "10" when ((QBus = "00101") or (QBus = "11000")) else   -- decrementar
                   "11" when (QBus = "10001") else   -- cargar 45 (cantidad de líneas de blanqueo)
                   "00";
    Counter3Mux <= "01" when ((QBus = "00001") or (QBus = "11001")) else   -- cargar 10 (líneas a repetir)
                   "10" when (QBus = "01101") else   -- decrementar
                   "00";
    LineAddrMux <= "01" when (QBus = "00001") else   -- cargar 0
                   "10" when (QBus = "01111") else   -- incrementar en 64
                   "00";
    PixelAddrMux <= "01" when (QBus = "00010") else
                    "10" when (QBus = "00101") else
                    "00";
    PixelMux <= "01" when ((QBus = "00001") or (QBus = "00111")) else  -- cargar 0
                "10" when (QBus = "00011") else                        -- cargar dato de la ROM
                "00";
    HSync <= '0' when ((QBus = "01001") or (QBus = "01010") or (QBus = "10100") or (QBus = "10101")) else
             '1';
    VSync <= '0' when (((QBus = "10010") or (QBus = "10011") or (QBus = "10100") or (QBus = "10101") or (QBus = "10110") or (QBus = "10111") or (QBus = "11000") or (QBus = "11101")) and (Counter2IsVSync = '1')) else
             '1';

    -- biestables
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            QBus <= DBus;
        end if;
    end process;
end architecture;


El resto de la implementación consiste en codificar lo registros y los multiplexores:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity AllButFSM is
    port (
        Clk               : in std_logic;
        LineAddrMux       : in std_logic_vector(1 downto 0);
        PixelAddrMux      : in std_logic_vector(1 downto 0);
        PixelMux          : in std_logic_vector(1 downto 0);
        PixelValue        : out std_logic_vector(7 downto 0);
        HSyncIn           : in std_logic;
        HSyncOut          : out std_logic;
        VSyncIn           : in std_logic;
        VSyncOut          : out std_logic;
        Counter1Mux       : in std_logic_vector(2 downto 0);
        Counter1IsZero    : out std_logic;
        Counter2Mux       : in std_logic_vector(1 downto 0);
        Counter2IsZero    : out std_logic;
        Counter2IsVSync   : out std_logic;
        Counter3Mux       : in std_logic_vector(1 downto 0);
        Counter3IsZero    : out std_logic;
        LineAddrIsVisible : out std_logic
    );
end entity;

architecture A of AllButFSM is
    component Rom is
        generic (
            Log2NumRows : integer := 12   -- 4096 bytes
        );
        port (
            AddressIn : in std_logic_vector((Log2NumRows - 1) downto 0);
            DataOut   : out std_logic_vector(7 downto 0)
        );
    end component;
    component Counter1 is
        port (
            Clk    : in std_logic;
            Mux    : in std_logic_vector(2 downto 0);
            IsZero : out std_logic
        );
    end component;
    component Counter2 is
        port (
            Clk     : in std_logic;
            Mux     : in std_logic_vector(1 downto 0);
            IsZero  : out std_logic;
            IsVSync : out std_logic
        );
    end component;
    component Counter3 is
        port (
            Clk     : in std_logic;
            Mux     : in std_logic_vector(1 downto 0);
            IsZero  : out std_logic
        );
    end component;
    signal LineAddrDBus : std_logic_vector(11 downto 0);   -- 12 bits = 4096 bytes (sólo se usan los 64 * 48 = 3072 primeros bytes)
    signal LineAddrQBus : std_logic_vector(11 downto 0);
    signal PixelAddrDBus : std_logic_vector(11 downto 0);
    signal PixelAddrQBus : std_logic_vector(11 downto 0);
    signal RomOut : std_logic_vector(7 downto 0);
    signal PixelDBus : std_logic_vector(7 downto 0);
    signal PixelQBus : std_logic_vector(7 downto 0);
    signal HSyncDBus : std_logic;
    signal HSyncQBus : std_logic;
    signal VSyncDBus : std_logic;
    signal VSyncQBus : std_logic;
    constant FIRST_NO_VISIBLE_LINE_ADDRESS : integer := 3072;     -- Dirección de memoria de la primera línea no visible
begin
    -- dirección de inicio de la línea actual de pantalla
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            LineAddrQBus <= LineAddrDBus;
        end if;
    end process;

    LineAddrDBus <= std_logic_vector(to_signed(0, 12)) when (LineAddrMux = "01") else
                    std_logic_vector(to_signed(to_integer(signed(LineAddrQBus)) + 64, 12)) when (LineAddrMux = "10") else    -- 64 bytes (pixels) por línea
                    LineAddrQBus;
    LineAddrIsVisible <= '0' when (to_integer(unsigned(LineAddrQBus)) = FIRST_NO_VISIBLE_LINE_ADDRESS) else
                         '1';

    -- dirección del actual pixel
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            PixelAddrQBus <= PixelAddrDBus;
        end if;
    end process;

    PixelAddrDBus <= LineAddrQBus when (PixelAddrMux = "01") else
                     std_logic_vector(to_signed(to_integer(signed(PixelAddrQBus)) + 1, 12)) when (PixelAddrMux = "10") else    -- 1 byte = 1 pixel
                     PixelAddrQBus;

    -- ROM con la imagen de 64 x 48 pixels (1 byte por pixel, 64 * 48 = 3072 bytes)
    R : Rom generic map (
        Log2NumRows => 12
    )
    port map (
        AddressIn => PixelAddrQBus,
        DataOut => RomOut
    );

    -- buffer de salida de la ROM (pixel actual)
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            PixelQBus <= PixelDBus;
        end if;
    end process;

    PixelDBus <= RomOut when (PixelMux = "10") else
                 std_logic_vector(to_signed(0, 8)) when (PixelMux = "01") else
                 PixelQBus;
    PixelValue <= PixelQBus;

    -- contadores
    C1 : Counter1 port map (
        Clk => Clk,
        Mux => Counter1Mux,
        IsZero => Counter1IsZero
    );

    C2 : Counter2 port map (
        Clk => Clk,
        Mux => Counter2Mux,
        IsZero => Counter2IsZero,
        IsVSync => Counter2IsVSync
    );

    C3 : Counter3 port map (
        Clk => Clk,
        Mux => Counter3Mux,
        IsZero => Counter3IsZero
    );

    -- acondicionador de señales HSync y VSync
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            HSyncQBus <= HSyncDBus;
        end if;
    end process;

    HSyncDBus <= HSyncIn;
    HSyncOut <= HSyncQBus;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            VSyncQBus <= VSyncDBus;
        end if;
    end process;

    VSyncDBus <= VSyncIn;
    VSyncOut <= VSyncQBus;
end architecture;


La imagen se aloja en una ROM cuyos datos se especifican directamente en el código fuente de Rom.vhd. Para generar la imagen en formato VHDL usando el GIMP se hicieron los siguientes pasos:

- Se creó una nueva imagen de 64 x 48 pixels.

- Menú Ventanas -- Diálogos empotrables -- Paletas -- Botón derecho dentro del listado de paletas -- Importar paleta -- En "Seleccionar origen" se marcó "Archivo de la paleta" y se seleccionó el fichero "vga_fpga.gpl" que se ha incluido dentro del proyecto. Esto creó dentro del GIMP una nueva paleta de 8 colores que se correspondía con los 8 colores que genera nuestra FPGA.

- Se trabajó la imagen de 64x48 con esa paleta.

- Cuando se terminó de trabajar con la imagen, menú Imagen -- Modo -- Indexado -- En "Mapa de colores" se marcó la opción "Usar paleta personal", se seleccionó la paleta acabante de crear a partir del fichero "vga_fpga.gpl", se desmarcó la opción "Eliminar los colores sin usar de la paleta final" y "Aceptar".

- Menú Archivo -- Exportar como -- Se seleccionó como tipo de archivo "Cabecera de código fuente en C (.h)" -- Exportar

Esto creó un fichero .h con un array con el mapa de color (la paleta) y otro array de 3072 bytes con la imagen completa. Afortunadamente el formato de datos de array de C y VHDL es relativamente similar por lo que simplemente hubo que trabajar un poco con el comando "sed" para adaptar los datos. Por ejemplo, un bloque de texto de esta forma:

0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,
7,0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,7,
7,0,0,0,0,0,0,0,0,0,0,0,0,3,0,0,
0,0,0,0,0,0,0,0,0,0,0,0,0,3,3,3,
3,0,0,3,3,3,0,0,0,0,3,3,3,0,0,0,


Puede convertirse a formato de datos VHDL de esta forma:

$ cat datos.txt | sed -e 's/0/x"00"/g' | sed -e 's/1/x"01"/g' | sed -e 's/2/x"02"/g' | sed -e 's/3/x"03"/g' | sed -e 's/4/x"04"/g' | sed -e 's/5/x"05"/g' | sed -e 's/6/x"06"/g' | sed -e 's/7/x"07"/g'


Generando la salida:
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"07",
x"07",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"03",x"03",
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"07",
x"07",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"03",x"00",x"00",
x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"00",x"03",x"03",x"03",
x"03",x"00",x"00",x"03",x"03",x"03",x"00",x"00",x"00",x"00",x"03",x"03",x"03",x"00",x"00",x"00",


Que es fácilmente incluible en un fichero VHDL como un array (ver Rom.vhd).

Circuito

El circuito externo a la FPGA sólo requiere las líneas de reset, de reloj y de sincronismo conectadas directamente y cada una de las tres líneas de componentes de color (RGB) conectada con una resistencia en serie de 270 Ohm.



El resultado:





Como siempre, todo el código fuente está disponible en la sección soft.

[ añadir comentario ] ( 1669 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 3530 )
Conversor analógico-digital basado en registro de aproximaciones sucesivas sobre FPGA 
Incluir una entrada analógica en una FPGA pasa, normalmente, por agregar al menos un integrado que haga de ADC. Sin embargo, existen alternativas al ADC tradicional que, aprovechando las características de una FPGA, nos permiten implementar un conversor analógico-digital utilizando muy pocos componentes externos.

Registro de aproximaciones sucesivas (SAR)

Esta técnica utiliza un comparador, una resistencia y un condensador como únicos componentes externos y consume dos pines E/S de la FPGA. Se consigue una resolución en bits arbitraria que sólo dependerá de los valores de R, de C y de la frecuencia de reloj de la FPGA.



El principio de funcionamiento es muy sencillo: inicialmente el condensador se encuentra descargado y la salida de realimentación (el pin conectado a R) se pone a 1. Esto hace que el condensador empiece a cargarse. En el instante en que el voltaje en el condensador llegue hasta la mitad de Vcc (1.65v en el caso de una FPGA alimentada a 3.3v), la FPGA lee el valor de la entrada digital (el otro pin). Esta entrada está conectada a la salida de un comparador que emitirá 3.3v si el voltaje en la entrada V+ es superior al voltaje en la entrada V- y 0v en caso contrario.

Si la FPGA lee un 0 en esa entrada significará que el voltaje que estamos midiendo en la entrada V+ se encuentra por debajo de Vcc/2 por lo que ya podemos determinar que el bit más significativo del valor convertido es un 0. Si la FGPA lee un 1 en esa entrada significará que el voltaje que estamos midiendo en la entrada V+ se encuentra por encima de Vcc/2 por lo que ya podemos determinar que el bit más significativo del valor convertido es en este caso un 1.



Si la FPGA ha leido un 0 en la entrada de comparación (V+ está por debajo del voltaje que hay en ese instante en los extremos del condensador, Vcc/2), significará que para determinar el siguiente bit hay que bajar el voltaje de la entrada V- (descargar el condensador), por lo que se emite un 0 por el pin conectado a R. Esto provoca que el condensador empiece a descargarse.

Si la FPGA leyó un 1 en la primera comparación (V+ está por encima del voltaje que hay en ese instante en los extremos del condensador, Vcc/2), significará que para determinar el siguiente bit hay que seguir cargando el condensador (la FPGA debe seguir emitiendo un 1 por la pata que conecta a R).

Independientemente de si la FPGA emite un 0 (para descargar el condensador) o un 1 (para seguir cargándolo), espera la mitad de tiempo que la primera vez antes de volver a leer la entrada conectada al comparador. En esta segunda lectura vuelve a hacer lo mismo: Si vale 0 significa que el voltaje del condensador está por encima del voltaje que estamos midiendo (V+) mientras que si vale 1 significa que el voltaje del condensador está por debajo del voltaje que estamos midiendo. En el primer caso el siguiente bit valdrá 0 y se emite un 0 por el pin conectado a R para descargar del condensador. En el segundo caso el siguiente bit valdra 1 y se emitirá un 1 por el pin conectado a R para cargar el condensador. Como puede observar el bit que se emite como valor es el mismo que el que alimenta a la salida conectada a R.

En cada iteración del proceso se espera la mitad de tiempo que la iteración anterior, se empieza siempre por el bit más significativo y se pueden realizar tantas iteraciones como se deseen: La cantidad de bits de resolución de la conversión vendrá determinada por la cantidad de comparaciones que hagamos.

Implementación: el circuito

En este caso se ha implementado una pequeña prueba de concepto: un conversor de 4 bits de resolución usando un amplificador operacional barato LM358N (por AliExpress se pueden adquirir 10 unidades por menos de 2¤ en el momento que escribo estas líneas) para usarlo como comparador, un condensador de 1uF y una resistencia de 10K.

Como FPGA he utilizado una Cyclone II de Altera que pillé hace poco. No es tan potente como la Spartan-3E de Xilinx que he usado en otros montajes pero es compacta, barata y más que suficiente para el ADC.

Implementación: el software

Como se vió en la descripción varios párrafos más arriba, la FPGA debe leer la entrada de comparación en unos instantes determinados: inicialmente debe esperar hasta que el condensador llegue a Vcc/2 para realizar la primera medida, la segunda medida la realizará en la mitad de tiempo que la primera, la tercera en la mitad de tiempo que la segunda y así sucesivamente.

En nuestro caso, asumiendo un condensador de 1uF (C = 0.000001), una resistencia de 10K (R = 10000), la salida de la FPGA que está conectada a R, a la que etiquetaremos como Vi con un valor de 3.3v y la entrada inversora del comparador, a la que llamaremos Vo con un valor de 1.65v (3.3 / 2), tenemos que:

$$t=-R \times C \times log\left({{V_o - V_i} \over {V_o(0) - V_i}}\right)$$
$$t = -10000 \times 0.000001 \times log\left({{1.65 - 3.3} \over {0 - 3.3}}\right) = 0.006931471805599453 \; s$$

Es el tiempo que tarda Vo en valer 1.65v (la mitad de Vcc = 3.3v) partiendo de 0v (condensador totalmente descargado). Esta ecuación es la solución analítica a la ecuación diferencial de la carga de un circuito RC que vimos en este post anterior.

Si asumimos una frecuencia de reloj de 50MHz tenemos que hacen falta:

$$50000000 * 0.006931471805599453 = 346574 \; ciclos$$

Para esperar desde que el condensador está totalmente descargado hasta que Vo = 1.65v. Como se va a hacer una conversión de 4 bits los puntos de comparación se deberán hacer en los siguientes instantes:

1ª comparación (bit 3): 346574 ciclos de reloj
                           ↓
                      346574 / 2
                           ↓
2ª comparación (bit 2): 173286 ciclos de reloj (instante 519860)
                           ↓
                      173286 / 2
                           ↓
3ª comparación (bit 1):  86643 ciclos de reloj (instante 606503)
                           ↓
                       86643 / 2
                           ↓
4ª comparación (bit 0):  43321 ciclos de reloj (instante 649824)

Tras hacer la 4ª comparación es necesario realizar una descarga completa del condensador para iniciar la siguiente conversión. Si asumimos el peor de los casos, que el condensador esté totalmente cargado (Vo(0) = 3.3v), tenemos que para llegar a Vo = 0.01v con Vi = 0 se necesitan:

$$-10000 \times 0.000001 \times log\left({{0.01 - 0} \over {3.3 - 0}}\right) = 0.057990926544605255 \; segundos$$

Que equivalen a:

$$50000000 \times 0.057990926544605255 = 2899546 \; ciclos$$

Desde un punto de vista teórico el condensador nunca se descarga del todo (siempre tiene carga residual). Si se pusiese 0 en lugar 0.01 en la ecuación anterior el tiempo tendería a infinito, por lo que hay que poner una cantidad muy baja que no sea cero. De forma global se ve que cada conversión requiere:

649824 ciclos para la conversión en sí + 2899546 ciclos para descargar el condensador del todo antes de iniciar una nueva conversión = 3549370 ciclos

Para contar 3549370 ciclos (desde 0 hasta 3549369) hacen falta como mínimo 22 bits ($2^{21} = 2097152$, se queda corto, y $2^{22} = 4194304$). Por tanto el "motor" de nuestro conversor analógico-digital de 4 bits será un contador de 22 bits que emitirá las siguientes señales de control:

     0 --> forzar biestable de comparación = 1

346574 --> cargar el biestable con el 1º valor de comparación
346575 --> empujar valor biestable en registro de desplazamiento (bit 3)

519860 --> cargar el biestable con el 2º valor de comparación
519861 --> empujar valor biestable en registro de desplazamiento (bit 2)

606503 --> cargar el biestable con el 3º valor de comparación
606504 --> empujar valor biestable en registro de desplazamiento (bit 1)

649824 --> cargar el biestable con el 4º valor de comparación
649825 --> empujar valor biestable en registro de desplazamiento (bit 0)
           forzar biestable de comparación = 1
649826 --> cargar registro de salida desde registro de desplazamiento

El proceso de descarga se realizará entre el ciclo 649825 y el 4194303 ($2^{22} - 1$). No se va a reiniciar el contador en el ciclo 3549370 para evitar sobrecargar con más puertas lógicas el diseño, sino que se va a dejar que el contador se desborde de forma natural en el ciclo 4194304 ($2^{22}$). Se pierde una centésima de segundo en resolución temporal pero para el caso que nos ocupa no es relevante. Aprovechar el mismo registro contador de tiempo como máquina de estados simplifica enormemente el diseño.



La implementación en VHDL partiendo de este diagrama es muy sencilla:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity ADC is
    port (
        Clock     : in std_logic;
        Reset     : in std_logic;
        CompIn    : in std_logic;
        ChargeOut : out std_logic;
        DataOut   : out std_logic_vector(3 downto 0)
    );
end entity;

architecture A of ADC is
    signal DComp          : std_logic;
    signal QComp          : std_logic;
    signal CompSel        : std_logic_vector(1 downto 0);
    signal DCounter       : std_logic_vector(21 downto 0);
    signal QCounter       : std_logic_vector(21 downto 0);
    signal DShiftReg      : std_logic_vector(3 downto 0);
    signal QShiftReg      : std_logic_vector(3 downto 0);
    signal ShiftRegEnable : std_logic;
    signal DOutReg        : std_logic_vector(3 downto 0);
    signal QOutReg        : std_logic_vector(3 downto 0);
    signal OutRegEnable   : std_logic;
    constant Conversion1Cycle : integer := 346574;
    constant Conversion2Cycle : integer := 519860;
    constant Conversion3Cycle : integer := 606503;
    constant Conversion4Cycle : integer := 649824;
begin
    -- comparator d flip-flop
    process (Clock)
    begin
        if (Clock'event and (Clock = '1')) then
            QComp <= DComp;
        end if;
    end process;

    DComp <= '0' when ((CompSel = "00") or (Reset = '1')) else
             '1' when (CompSel = "01") else
             CompIn when (CompSel = "10") else
             QComp;
    ChargeOut <= QComp;

    -- shift register
    process (Clock)
    begin
        if (Clock'event and (Clock = '1')) then
            QShiftReg <= DShiftReg;
        end if;
    end process;

    DShiftReg <= (QShiftReg(2 downto 0) & QComp) when (ShiftRegEnable = '1') else
                 QShiftReg;

    -- output register
    process (Clock)
    begin
        if (Clock'event and (Clock = '1')) then
            QOutReg <= DOutReg;
        end if;
    end process;

    DOutReg <= QShiftReg when (OutRegEnable = '1') else
               QOutReg;
    DataOut <= QOutReg;

    -- 22 bit counter & fsm
    process (Clock)
    begin
        if (Clock'event and (Clock = '1')) then
            QCounter <= DCounter;
        end if;
    end process;

    DCounter <= std_logic_vector(to_unsigned(0, 22)) when (Reset = '1') else
                std_logic_vector(to_unsigned(to_integer(unsigned(QCounter)) + 1, 22));

    CompSel <= "00" when (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 1)) else
               "01" when (to_integer(unsigned(QCounter)) = 0) else
               "10" when ((to_integer(unsigned(QCounter)) = Conversion1Cycle) or (to_integer(unsigned(QCounter)) = Conversion2Cycle) or (to_integer(unsigned(QCounter)) = Conversion3Cycle) or (to_integer(unsigned(QCounter)) = Conversion4Cycle)) else
               "11";
    ShiftRegEnable <= '1' when ((to_integer(unsigned(QCounter)) = (Conversion1Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion2Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion3Cycle + 1)) or (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 1))) else
                      '0';
    OutRegEnable <= '1' when (to_integer(unsigned(QCounter)) = (Conversion4Cycle + 2)) else
                    '0';
end architecture;

Conclusión

Usar un registro de aproximaciones sucesivas (SAR) es la forma más sencilla y barata de implementar un ADC, aunque tiene sus inconvenientes:

- Las conversiones son lentas. Aunque pongamos condensadores y resistencias pequeños, es complicado aumentar la frecuencia de muestreo por encima de unos pocos KHz.

- La pendiente de carga de un condensador en un circuito típico RC no es lineal por lo que la conversión resultante tampoco será lineal. Este escollo puede superarse con un circuito externo más elaborado que garantice una corriente de carga constante en el condensador y, por tanto, una pendiente de carga constante en el mismo.

También tiene sus ventajas :-):

- Usa relativamente pocos recursos de la FPGA.
Muy pocos componentes externos. El comparador podría implementarse incluso utilizando las entradas diferenciales que todas las FPGAs tienen (LVDS, mini-LVDS, etc.).

- La resolución en bits es arbitraria y sólo depende de la implementación interna en la FPGA.

A continuación puede verse un vídeo con el conversor implementado al que se le han conectado cuatro leds:



Todo el código puede descargarse de la sección soft.

[ añadir comentario ] ( 1356 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 4836 )
Transmisor de radio FM basado en FPGA 
Transmitir una señal modulada en FM dentro de la banda de frecuencias de la FM comercial utilizando FPGAs es un tópico ampliamente cubierto en decenas de webs y vídeos online pero que en pocos casos es desgranado y explicado de forma entendible y rigurosa. A lo largo de este post se desarrollará tanto la base teórica como una prueba de concepto de un transmisor FM basado en FPGA.

Los fundamentos

La modulación FM consiste en hacer variar la frecuencia de la señal que estamos mandando a la antena en función de la amplitud de la señal moduladora (sonido, por ejemplo). El rango de frecuencias reservado para emisoras comerciales de audio abarca desde los 87.5 MHz hasta los 108 MHz (es el rango que recoge cualquier receptor FM analógico) con una profundidad de modulación de +- 75 KHz. Esto significa que si queremos emitir en la frecuencia de 100 MHz debemos hacer variar la frecuencia entre 99.925 MHz y 100.075 MHz en función de la amplitud de la señal moduladora.



(imagen extraida de Wikipedia, con licencia Creative Commons Attribution - Share Alike 2.0, autor Gvf)

Generar la señal en el dominio digital

Intentar hacer un transmisor FM utilizando una FPGA nos hace chocar, a priori, con un primer impedimento: Poder generar una señal que varíe de frecuencia de una forma tan ligera. Hay que tener en cuenta que con una anchura de modulación de 150 KHz (75 KHz + 75 KHz) y asumiendo una señal sonora muestreada a 16 bits tenemos que la variación de la señal de salida de la antena debe de ser en pasos de:

$${150000 \over {2^{16}}} = {150000 \over 65536} = 2.2888 Hz$$

Esos son muy pocos hercios. En el dominio digital lo habitual para modificar la frecuencia de una señal es multiplicarla por una constante (mediante un PLL) o dividirla entre una constante (mediante un divisor de frecuencias). Los PLLs son circuitos híbridos (entre analógicos y digitales), no son todo lo rápido que nos gustaría (lo normal es que un PLL necesite varios ciclos hasta estabilizarse en la frecuencia deseada), normalmente trabajan con factores constantes (no variables como en nuestro caso) y no están siempre disponibles en las FPGAs para uso directo del usuario. Los divisores de frecuencia son más fáciles de hacer (no dejan de ser baterías de biestables) pero, como su propio nombre indica, sólo son capaces de dividir la frecuencia entre un valor entero.

Acumuladores de fase

Existe, sin embargo, una forma bastante ingeniosa para la síntesis directa de señales digitales sin necesidad del uso de PLLs ni de divisores de frecuencia: los acumuladores de fase. Un acumulador de fase es una de las partes que integran un oscilador digital basado en tabla de ondas. Imaginemos que queremos generar una onda senoidal mediante una tabla de ondas.



Asumiremos, por simplificar, que la tabla de la onda (realmente sería una ROM o una RAM) tiene un tamaño de 256 muestras ($2^8$). Por otro lado tenemos un registro (al que llamaremos acumulador de fase) de 16 bits y lo que hacemos es indexar la tabla de ondas utilizando los 8 bits más significativos del acumulador de fase. Las entradas D de los biestables de este registro se conectan a la salida un sumador que suma las salidas Q (el valor actual de registro) más un valor de entrada de 16 bits. En cada flanco de subida del reloj del sistema el registro (el acumulador de fase) se carga con el valor de las entradas D procedentes del sumador. En cada tick de reloj el acumulador va acumulando el valor de la entrada de 16 bits.



Si en la entrada de 16 bits ponemos un 0, el registro se quedará "quieto", no cambiará su valor y, por tanto la salida de datos de la ROM indexada por dicho registro sacará un valor constante (frecuencia de 0 Hz). Si en la entrada de 16 bits ponemos un 1, en registro se irá incrementando de 1 en 1, sin embargo, como lo que indexa a la tabla de valores son los 8 bits más significativos, este índice sólo avanzará cada 256 pulsos de reloj del sistema. Esto significa que con una frecuencia de reloj de 32 KHz, nuestro oscilador generaría una señal con una frecuencia de:

$${32000 \over 256} = 125 Hz$$

Yéndonos al otro extremo, si en la entrada de 16 bits metemos un valor de $2^{15} = 32768$, el acumulador de fase, al ser de 16 bits, se desbordará cada dos ciclos de reloj: el acumulador de fase generará la secuencia de valores 0, 32768, 0, 32768, 0, 32768, etc. que, a su vez, indexarán los valores mínimo y máximo de la onda senoidal que tenemos en la tabla. Esto significa que la salida del oscilador ha alcanzado su frecuencia máxima (su frecuencia de Nyquist, de 16 KHz).



Como se puede comprobar, los incrementos en los pasos son lineales por lo que podemos establecer una especie de regla de tres para el cálculo de la frecuencia de salida de nuestro oscilador imaginario:

$$I_{acumulador fase} = {f_{deseada} \over 32000} \times 2^{16}$$

Vemos que si frecuencia_deseada = 0, entonces incremento_acumulador_fase = 0. Si frecuencia_deseada = 16000 (la frecuencia Nyquist, para 32 KHz), entonces:

$$I_{acumulador fase} = {16000 \over 32000} \times 2^{16} = {1 \over 2} \times 2^{16} = 2^{15} = 32768$$

Para 440 Hz (la nota LA de la cuarta octava del piano) tenemos que:

$$I_{acumulador fase} = {440 \over 32000} \times 2^{16} = 901.12$$

En estos casos, cuando el resultado es fraccionario, hay dos opciones: O quedarse con la parte entera (redondeando o truncando, incremento_acumulador_fase = 901), o, si no queremos perder precisión, incrementar el número de bits para reducir el error. Por ejemplo, utilizando un acumulador de fase de 59 bits conseguiríamos un valor de incremento entero (sin parte fraccionaria):

$$I_{acumulador fase} = {440 \over 32000} \times 2^{59} = 7926335344172073$$

De forma general se puede plantear la ecuación de la siguiente manera:

$$I_{acumulador fase} = {f_{deseada} \over f_{muestreo}} \times 2^{bits}$$

Nótese que la cantidad de bits que indexa la tabla de ondas no es relevante. Lo único importante es que la tabla de ondas está indexada por la parte alta del registro acumulador de fase. Como se puede apreciar, con este método es posible generar frecuencias arbitrarias con pasos relativamente pequeños y sólo limitados por el número de bits que utilicemos en el acumulador de fase.

El transmisor

Imaginemos que la frecuencia de muestreo es ahora de 320 MHz y que queremos generar una frecuencia de 100 MHz. Utilizando un acumulador de fase de 32 bits (la cantidad de bits elegida es arbitraria) tendríamos que:

$$I_{acumulador fase} = {100000000 \over 320000000} \times 2^{32} = 1342177280$$

Como se comentó antes, la cantidad de bits de resolución a la hora de indexar la tabla de ondas no es relevante a efectos de frecuencia (aunque sí a efectos de distorsión armónica y de relación señal/ruido). En nuestro caso, como no disponemos de un DAC sino que vamos a generar una señal cuadrada directamente, en teoría lo que necesitamos es una tabla con una onda cuadrada de tal forma que cuando esté en el máximo emita un 1 y cuando esté en el mínimo emita un 0.

Si asumimos que en la tabla de ondas vamos a meter una onda cuadrada con un ciclo de trabajo del 50% (perfectamente cuadrada), esto significará que la mitad de la tabla de ondas va a estar al valor mínimo (0 por ejemplo) y la otra mitad al valor máximo (255 por ejemplo, si es una ROM de 8 bits sin signo). Si al final vamos a traducir la salida de la tabla de ondas como un 0 si está en el valor mínimo y como un 1 si está en el valor máximo, es obvio que la salida de la tabla de ondas coincidirá con el valor del bit más significativo del acumulador de fase.

Y he aquí la "magia" del invento: Usando un único registro con un sumador podemos hacer un oscilador de onda cuadrada para el que podemos controlar la frecuencia de forma precisa entre 0 Hz y la mitad de la frecuencia de reloj. La precisión a la hora de ajustar la frecuencia nos la dará la cantidad de bits que usemos.

El jittering

Cualquier incremento en el acumulador de fase que no sea potencia de dos va a generar un efecto jitter en la señal de salida haciendo que ésta muchas veces diste de ser una señal cuadrada perfecta. Esto, como es obvio, provocará que la cantidad de armónicos que se generen se dispare. Vamos a verlo con un ejemplo.

Imaginemos el caso anterior: 320 MHz de frecuencia de reloj, un acumulador de fase de 32 bits y un incremento para el acumulador de fase igual a 1342177280. Aplicando este acumulador de fase obtenemos la siguiente salida (correspondiente al bit más significativo):

1 1 0 1 1 0 1 1 0 0 1 0 0 1 0 0 ... la secuencia se repite indefinidamente

Como se puede observar esta secuencia de bits dista mucho de parecerse a una señal cuadrada con ciclo de trabajo del 50%, en concreto genera tres pulsos anchos más juntos y luego dos pulsos estrechos más separados. Sin embargo si hacemos el análisis de Fourier de esta secuencia, tratándola como si fuese una señal, calculándole la transformada de Fourier usando un software numérico como Octave:

octave> abs(fft([1 1 0 1 1 0 1 1 0 0 1 0 0 1 0 0]))

ans =
   8.00000   1.79995   0.00000   1.01959   0.00000
   5.12583   0.00000   1.20269   0.00000   1.20269
   0.00000   5.12583   0.00000   1.01959   0.00000
   1.79995


Vemos que la transformada de Fourier resultante es efectivamente simétrica (quitando la posición 0, que es la componente de continua), al tratarse de una señal real, y que, descartando la componente de continua (el índice 0 del vector), hay un máximo en el índice 5 del vector. Por las propiedades de la transformada de Fourier en este caso la posición 8 (el centro del vector y el centro de simetría) se corresponde con la frecuencia Nyquist que, al ser la frecuencia de reloj de 320 MHz, sería de 160 MHz (la mitad de la frecuencia del reloj). El máximo situado en la posición 5 del vector se corresponderá, siguiendo una regla de tres, con la frecuencia de:

$${{160000000 \times 5} \over 8} = 100000000 = 100 MHz$$

En efecto, la frecuencia fundamental (el máximo en la transformada de Fourier) de la señal de salida es de 100 MHz, que era nuestro objetivo, aunque como se puede ver en el análisis de Fourier, también se emitirán armónicos de 20 MHz (el valor 1.79995 se corresponde con la frecuencia ${{160 \times 1} \over 8}$), de 60 MHz (${{160 \times 3} \over 8}$) y de 140 MHz (${{160 \times 7} \over 8}$), aunque de menor amplitud.

Implementación y prueba de concepto

Como se va a emitir el sonido por radio FM y aún no disponemos de un ADC que permita la lectura de una fuente externa de audio, se generará la señal de audio dentro de la propia FPGA.



El sistema consta de dos entradas conectadas a sendos botones y que permiten seleccionar la frecuencia del tono a transmitir: cuando no se pulsa ningún botón no se modula (se genera la portadora sin modular), cuando se pulsa el botón “440” la portadora se modula con una señal cuadrada de 440 Hz (nota LA en la cuarta octava del piano), mientras que si se pulsa el botón “880” la portadora se modula con una señal cuadrada de 880 Hz (nota LA en la quinta octava del piano).

La única salida del sistema es la salida de la antena, que va a un trozo de cable que se coloca al aire. No es necesario nada más si vamos a colocar el receptor a pocos metros de la FPGA. En el caso de que queramos conectar la salida a una antena real y que queramos más potencia habría que colocar circuitos acondicionadores y/o amplificadores a la salida y, sobretodo, filtros: hay que recordar que la señal de salida es una onda cuadrada repleta de armónicos.



En reposo, las entradas “440” y “880” valen 0, por lo que el MUX inferior selecciona la entrada del incremento correspondiente a los 90 MHz (portadora sin modular). Cuando se pulsa sólo el botón “440” el MUX inferior deja pasar la señal moduladora (ya convertida en secuencias de incrementos en lugar de en 0 y 1) a la entrada del oscilador para modularlo, y cuando se pulsa el botón “880” ocurre lo mismo con el MUX inferior y, además, al cambiar la entrada de selección del MUX superior, cambia el incremento del oscilador que genera la señal moduladora para que genere 880 Hz en lugar de 440 Hz.

El módulo combinacional CONV convierte la entrada de 1 bit (0 o 1) proveniente del oscilador de 440 u 880 Hz, en una salida de 32 bits que es el incremento de fase de correspondiente a cada nivel de la señal moduladora:

0 --> 1341058799
1 --> 1343295761

En este caso se ha realizado una implementación sobre una FPGA Spartan-3E de Xilinx con un reloj externo de 32 MHz (papilio one). Las Spartan-3E disponen de varios DCM (Digital Clock Managers) que permiten subir la frecuencia de reloj mediante multiplicadores. En este caso, con un reloj a 32 MHz la máxima frecuencia que se puede alcanzar es de 288 MHz, por lo que ajustamos los cálculos a dicha frecuencia y asumiendo que vamos a transmitir en la banda de 90 MHz.

$$I_{acumulador fase} = {90000000 \over 288000000} \times 2^{32} = 1342177280$$

Como la señal moduladora (los tonos de 440 y 880 Hz) van a ser también ondas cuadradas, sólo hay que calcular los incrementos para el 0 y el 1 de la señal moduladora (en este caso no hay valores intermedios). El 0 de la señal moduladora lo asociaremos a 89,925 MHz y el 1 de la señal moduladora lo asociaremos a 90,075 MHz (recordemos que la profundidad de modulación en la FM comercial es de 75 KHz).

$$I_{89.925} = {89925000 \over 288000000} \times 2^{32} \approx 1341058799$$

$$I_{90.075} = {90075000 \over 288000000} \times 2^{32} \approx 1343295761$$

A continuación puede verse el código fuente:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity FMTransmitter is
    port (
        Clk         : in std_logic;
        Reset       : in std_logic;
        Button440   : in std_logic;
        Button880   : in std_logic;
        AntOut      : out std_logic
    );
end entity;

architecture Architecture1 of FMTransmitter is
    component Oscillator is
        generic (
            NBits : integer := 32
        );
        port (
            IncrementIn : in std_logic_vector(31 downto 0);
            Clk         : in std_logic;
            Reset       : in std_logic;
            DataOut     : out std_logic
        );
    end component;
    component Mux2Inputs is
        generic (
            NBits : integer := 32
        );
        port (
            Sel     : in std_logic;
            DataIn0 : in std_logic_vector((NBits - 1) downto 0);
            DataIn1 : in std_logic_vector((NBits - 1) downto 0);
            DataOut : out std_logic_vector((NBits - 1) downto 0)
        );
    end component;
    signal Mux1Out : std_logic_vector(31 downto 0);
    signal Mux1Sel : std_logic;
    signal ConvOut : std_logic_vector(31 downto 0);
    signal ConvIn  : std_logic;
    signal Mux2Out : std_logic_vector(31 downto 0);
begin
    RadioOsc : Oscillator generic map (
        NBits => 32
    )
    port map (
        Clk => Clk,
        Reset => Reset,
        IncrementIn => Mux1Out,
        DataOut => AntOut
    );
    Mux1Sel <= Button440 or Button880;
    Mux1 : Mux2Inputs generic map (
        NBits => 32
    )
    port map (
        Sel => Mux1Sel,
        DataIn0 => std_logic_vector(to_unsigned(1342177280, 32)),   -- center freq = 90.0 MHz
        DataIn1 => ConvOut,
        DataOut => Mux1Out
    );
    -- center freq - 75 KHz when 0
    -- center freq + 75 KHz when 1
    ConvOut <= std_logic_vector(to_unsigned(1341058799, 32)) when (ConvIn = '0') else
               std_logic_vector(to_unsigned(1343295761, 32));
    AudioOsc : Oscillator generic map (
        NBits => 32
    )
    port map (
        Clk => Clk,
        Reset => Reset,
        IncrementIn => Mux2Out,
        DataOut => ConvIn
    );
    Mux2 : Mux2Inputs generic map (
        NBits => 32
    )
    port map (
        Sel => Button880,
        DataIn0 => std_logic_vector(to_unsigned(6562, 32)),   -- 440 Hz
        DataIn1 => std_logic_vector(to_unsigned(13124, 32)),  -- 880 Hz
        DataOut => Mux2Out
    );
end architecture;


Tanto el oscilador de salida (el de alta frecuencia) como el oscilador de audio se han implementado usando el mismo componente “Oscillator.vhd”.

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;
use ieee.std_logic_signed.all;

entity Oscillator is
    generic (
        NBits : integer := 32
    );
    port (
        IncrementIn : in std_logic_vector(31 downto 0);
        Clk         : in std_logic;
        Reset       : in std_logic;
        DataOut     : out std_logic
    );
end entity;

architecture Architecture1 of Oscillator is
    component Reg is
        generic (
            NBits : integer := 32
        );
        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 MuxOut : std_logic_vector((NBits - 1) downto 0);
    signal RegOut : std_logic_vector((NBits - 1) downto 0);
begin
    PhaseAcc : Reg generic map (
        NBits => NBits
    )
    port map (
        Enable => '1',
        Clk => Clk,
        DataIn => MuxOut,
        DataOut => RegOut
    );
    MuxOut <= (others => '0') when (Reset = '1') else
              (RegOut + IncrementIn);
    DataOut <= RegOut(NBits - 1);
end architecture;


El tipo de sumador

En otros proyectos FPGA anteriormente abordados en este blog, cada vez que hacía falta un sumador se tiraba de un sumador estándar implementado mediante lógica combinatoria (“Adder.vhd”). Hasta ahora se ha hecho así por razones pedagógicas. En este caso, sin embargo, al estar el reloj a una frecuencia extremadamente alta para la FGPA ha sido necesario el uso del operador “+”. Este operador garantiza “la mejor implementación” de la suma para la plataforma y esto se traduce, en el caso del Spartan-3E y en el caso de la mayoría de las FPGAs existentes, en que se va a hacer uso de sumadores que ya se encuentran integrados (“hardwired”) en el sustrato de todas las FPGA (todos los fabricantes los incluyen, de mayor o menor cantidad de bits).

¿Qué ventajas tienen estos sumadores con respecto al sumador que hemos estado usando hasta ahora? La principal diferencia es que en nuestro “Adder.vhd” el acarreo es en cascada, mientras que los sumadores implementados a fuego en las FPGAs están basados siempre en circuitos CLA (Carry Look Ahead), que permiten precalcular los acarreos de cada bit sin necesidad de que estén calculados los bits anteriores. Aún siendo circuitos combinacionales tanto los unos como los otros, el tiempo de propagación del resultado en el caso de sumadores con CLA es mucho menor que en el caso de sumadores con acarreo en cascada (como el “Adder.vhd” que hemos usado hasta ahora en los proyectos).

En nuestro caso concreto se da además la circunstancia que, con un reloj a 288 MHz, el sumador con acarreo en cascada (el “Adder.vhd” de siempre) da problemas de timing o, lo que es lo mismo, “no le da tiempo de sumar tan rápido” y no queda otra opción que tirar del operador “+” (cosa que, por otro lado, es lo recomendable ya que se garantiza siempre la mejor implementación).

Vídeo

A continuación un pequeño fragmento de vídeo donde se puede ver y escuchar el invento en funcionamiento. La calidad del audio es bastante baja: usé mi radio-despertador como receptor, en el receptor del móvil se oye mucho mejor… pero no hubiese podido grabarlo :-)



Espero que haya resultado interesante. Todo el código fuente se puede descargar de la sección soft.

[ añadir comentario ] ( 2235 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 3954 )
Implementación sobre FPGA de una interface para leds RGB de tipo neopixel (I) 
Gracias a su bajo costo, su relativo poco consumo y su facilidad de programación, los leds RGB basados en el chip WS2812 se están convirtiendo en estándar de facto para leds RGB. A lo largo de esta mini serie de posts se implementará sobre FPGA una interface que facilite el acceso a estos leds abstrayendo las complejidades de su programación.

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.

[ añadir comentario ] ( 1575 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 1703 )
Diseño e implementación de un procesador RISC desde cero (III) 
En la anterior entrega de la serie se llegó hasta la fase de simulación y se comprobó, usando el software GHDL, el funcionamiento del procesador V1. En esta tercera entrega se ha implementado y probado el diseño en una FPGA real: una Spartan-3E de Xilinx.

>>> Enlace a la segunda entrega de la serie.

Cuando se va a implementar un diseño VHDL sobre un dispositivo real siempre nos podemos topar con problemas o limitaciones inherentes al hardware: por eso el paso final debería ser siempre el sintetizado del código sobre una FPGA. En este caso se optó por el sintetizado sobre una FPGA Spartan-3E de Xilinx, en concreto la que viene incluida en la placa de desarrollo Papilio One.

El problema de la memoria

Todas las FPGAs que hay en el mercado incluyen zonas de su sustrato especialmente diseñadas para implementar memorias RAM y/o ROM. Lo ideal, por lo tanto es que el código VHDL que implemente la ROM y la RAM sea fácilmente detectable o “inferible”, para que el entorno de desarrollo del fabricante utilice estos recursos de forma eficiente y que lo que queremos que sea una RAM se implemente realmente como una RAM y que lo que queremos que sea una ROM se implemente realmente como una ROM.

En el caso del código original que se hizo en la segunda entrega de la serie, a la hora de sintetizar para la FPGA de Xilinx, el entorno de desarrollo ISE detecta y sintetiza la ROM como una ROM pero no es capaz de inferir el código VHDL de la RAM como una RAM: de hecho para la RAM de 8192 palabras de 16 bits sintetiza ¡131072 biestables!

Si nos fijamos en el código VHDL de la RAM de la anterior entrega se puede observar cómo se integró la salida de proposito general (un puerto de salida que se denominó “GPOut”) como salida adicional en la RAM. Para facilitar que el entorno de Xilinx detectase este módulo como una RAM real se pasó la lógica de este puerto GPOut de la RAM al módulo de mas alto nivel “Memory.vhd”. Así mismo se incluyó una entrada “Enable” en el módulo RAM acorde con los estándares de diseño recomendados por los fabricantes. Esta entrada se deja permanentemente a 1 pero facilita al entorno de desarrollo la detección del codigo y su posterior sintetizado como una RAM real.

Ahora “Ram.vhd” es una implementacion más estándar de lo que es una RAM (sin puertos de salida y con una entrada "Enable"):

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity Ram is
    port (
        AddressIn   : in std_logic_vector(12 downto 0);
        DataOut     : out std_logic_vector(15 downto 0);
        DataIn      : in std_logic_vector(15 downto 0);
        WriteEnable : in std_logic;
        Enable      : in std_logic;
        Clk         : in std_logic
    );
end entity;

architecture Architecture1 of Ram is
    type RamType is array(0 to 8191) of std_logic_vector(15 downto 0);
    signal Data : RamType;
begin
    process (Clk)
    begin
        if ((Clk'event) and (Clk = '1')) then
            if (Enable = '1') then
                if (WriteEnable = '1') then
                    Data(to_integer(unsigned(AddressIn))) <= DataIn;
                end if;
                DataOut <= Data(to_integer(unsigned(AddressIn)));
            end if;
        end if;
    end process;
end architecture;

Y la lógica relacionada con el puerto de salida GPOut se ha trasladado a “Memory.vhd”:

library ieee;
use ieee.std_logic_1164.all;
use ieee.numeric_std.all;

entity Memory is
    generic (
        GPOutAddress : integer := 12288
    );
    port (
        AddressIn   : in std_logic_vector(15 downto 0);
        DataIn      : in std_logic_vector(15 downto 0);
        DataOut     : out std_logic_vector(15 downto 0);
        WriteEnable : in std_logic;
        GPOut       : out std_logic_vector(15 downto 0);
        Clk         : in std_logic
    );
end entity;

architecture Architecture1 of Memory is
    component Rom is
        port (
            AddressIn : in std_logic_vector(12 downto 0);
            DataOut   : out std_logic_vector(15 downto 0)
        );
    end component;
    component Ram is
        port (
            AddressIn   : in std_logic_vector(12 downto 0);
            DataOut     : out std_logic_vector(15 downto 0);
            DataIn      : in std_logic_vector(15 downto 0);
            WriteEnable : in std_logic;
            Enable      : in std_logic;
            Clk         : in std_logic
        );
    end component;
    signal RomOut : std_logic_vector(15 downto 0);
    signal RamOut : std_logic_vector(15 downto 0);
    signal GPOutD : std_logic_vector(15 downto 0);
    signal GPOutQ : std_logic_vector(15 downto 0);
begin
    -- GPOut register
    process (Clk)
    begin
        if ((Clk'event) and (Clk = '1')) then
            GPOutQ <= GPOutD;
        end if;
    end process;
    GPOutD <= DataIn when (to_integer(unsigned(AddressIn)) = GPOutAddress) else
              GPOutQ;
    GPOut <= GPOutQ;

    -- ROM
    Rom1 : Rom port map (    -- 8192 words (16 bit)
        AddressIn => AddressIn(12 downto 0),
        DataOut => RomOut
    );

    -- RAM
    Ram1 : Ram port map (    -- 8192 words (16 bit)
        AddressIn => AddressIn(12 downto 0),
        DataOut => RamOut,
        WriteEnable => WriteEnable,
        Enable => '1',
        DataIn => DataIn,
        Clk => Clk
    );

    -- RAM vs ROM multiplexer
    DataOut <= RomOut when (AddressIn(13) = '0') else     -- bit 13 = 0 --> ROM,  bit 13 = 1 --> RAM
               RamOut;
end architecture;

Con estas pequeñas modificaciones el entorno de desarrollo ISE de Xilinx sí infiere correctamente la RAM a partir del código VHDL y la implementa como una RAM real dentro de la FPGA.

Estados de espera para la memoria

Introducir este modelo de memoria RAM síncrono tanto para la lectura como para la escritura de datos obliga a introducir un estado de espera antes de cada estado que habilite el registro DATAin en la unidad de control. En concreto es necesario introducir el estado de espera en tres sitios de la máquina de estados: en la secuencia le lectura de instrucciones, en el microcódigo de la instrucción POP y en el microcódigo de la instrucción LOAD.

Para la lectura de instrucciones la secuencia de microcódigo quedaría como sigue:

0. MUX6 := "0", Habilitar PC (El vector de reset es el 0)

1. MUX1 = PC, Habilitar ADDR (Se carga IR con la instrucción apuntada por PC)

2. Estado de espera para la lectura de la memoria

3. Habilitar DATAin

4. Habilitar IR

5. MUX5 := FSM, ALU := inc, MUX4 := PC, Habilitar PC (Se hace PC := PC + 1)

6. EJECUTAR EL MICROCÓDIGO DE LA INSTRUCCIÓN ALMACENADA EN IR

7. Ir al estado 1

El microcódigo de la instrucción POP quedaría así:

POP
MUX1 := SP, Habilitar ADDR
Estado de espera
Habilitar DATAin
Habilitar RA, MUX2 := DATAin
MUX4 := SP, MUX5 := FSM, ALU := dec, Habilitar SP

Mientras que el microcódigo de la instrucción LOAD sería el siguiente:

LOAD
MUX1 := RB, Habilitar ADDR
Estado de espera
Habilitar DATAin
MUX2 := DATAin, Habilitar RA

Estos tres nuevos estados de espera se introducen como nuevos estados en la FSM de la unidad de control.

Blinker

Para probar el procesador se inicializa la ROM (el fichero “Rom.vhd”) con el código máquina asociado al siguiente código ensamblador V1. El programa simplemente cambia de forma alternativa los bits del puerto GPOut (0x0000, 0xFFFF, 0x0000, 0xFFFF, 0x0000, etc.).

    # [12288] <-- 0
    loadi 12288
    op rb, ra, assign
    loadi 0
    store
loop:
    # RA <-- [12288]
    loadi 12288
    op rb, ra, assign
    load
    # RA <-- NOT RA
    op ra, ra, not
    # [12288] <-- RA
    store
    # wait loop
    loadi 100
    op rb, ra, assign
waitLoop1:
    op ra, rb, assign
    jz waitLoop1End
    loadi 8192
waitLoop2:
    jz waitLoop2End
    op ra, ra, dec
    j waitLoop2
waitLoop2End:
    op rb, rb, dec
    j waitLoop1
waitLoop1End:
    # end of wait loop
    j loop

Se trata de un bucle infinito en el que en cada iteración se cambia de estado el puerto GPOut (alternativamente 0x0000 y 0xFFFF) y que, entre iteración e iteración, incluye dos bucles anidados que provocan un retardo significativo y visible entre cada cambio de estado.

El V1 tiene dos entradas de un bit cada una (Clk y Reset) y una salida de 16 bits (GPOut). La entrada de reloj (Clk) se encuentra conectada internamente en la placa Papilio One a un oscilador de cristal de 32MHz, la entrada Reset se mapea al pin A0 de la placa y la salida GPOut se mapea a los pines B0 a B15 (16 bits). Para comprobar que el blinker funciona basta con conectar un led a cualquiera de los pines B0 a B15.

Resultado

Como se puede ver en el siguiente vídeo, nuestro procesador V1 implementado en la FPGA ejecuta perfectamente el código de la ROM.



En la sección soft puede descargarse todo el código VHDL del proyecto.

[ añadir comentario ] ( 1427 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 1561 )

<< <Anterior | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | Siguiente> >>