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 ] ( 1634 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1804 )
Diseño e implementación de un procesador RISC desde cero (II) 
En esta segunda entrega de la serie se profundiza en el diseño de la unidad de control, en la implementación en VHDL de los diferentes elementos y en la realización de una prueba de concepto sobre un simulador.

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

Lógica combinatoria: los multiplexores

Un multiplexor es un circuito combinacional con varias entradas y una salida que permite, mediante una entrada adicional de selección, decidir qué entrada se enruta a la salida.

El código VHDL para un multiplexor de, por ejemplo, 3 entradas sería el siguiente:

library ieee;
use ieee.std_logic_1164.all;

entity Mux3Inputs is
    generic (
        NBits : integer := 16
    );
    port (
        Sel     : in std_logic_vector(1 downto 0);
        DataIn0 : in std_logic_vector((NBits - 1) downto 0);
        DataIn1 : in std_logic_vector((NBits - 1) downto 0);
        DataIn2 : in std_logic_vector((NBits - 1) downto 0);
        DataOut : out std_logic_vector((NBits - 1) downto 0)
    );
end entity;

architecture Architecture1 of Mux3Inputs is
begin
    DataOut <= DataIn0 when (Sel = "00") else
               DataIn1 when (Sel = "01") else
               DataIn2;
end architecture;


Lógica combinatoria: el expansor del signo

El expansor del signo (EXP) es un bloque combinacional que expande el signo de un valor de M bits a N bits siendo M < N.

En nuestro caso, el expansor del signo incluye una entrada de selección de un bit para elegir entre M=12 (instrucciones de salto relativo) y M=15 (sólo para la instrucción LOADI).

library ieee;
use ieee.std_logic_1164.all;

entity SignExp is
    generic (
        NBitsToExpand0 : integer := 15;
        NBitsToExpand1 : integer := 12;
        NBits : integer := 16
    );
    port (
        SelIn   : in std_logic;
        DataIn  : in std_logic_vector((NBits - 1) downto 0);
        DataOut : out std_logic_vector((NBits - 1) downto 0)
    );
end SignExp;

architecture Architecture1 of SignExp is
signal Expansion0 : std_logic_vector((NBits - NBitsToExpand0 - 1) downto 0);
signal Expansion1 : std_logic_vector((NBits - NBitsToExpand1 - 1) downto 0);
begin
    Expansion0 <= (others => DataIn(NBitsToExpand0 - 1));
    Expansion1 <= (others => DataIn(NBitsToExpand1 - 1));
    DataOut <= Expansion0 & DataIn((NBitsToExpand0 - 1) downto 0) when (SelIn = '0') else
               Expansion1 & DataIn((NBitsToExpand1 - 1) downto 0);
end Architecture1;


Lógica combinatoria: ALU

La ALU es en este caso incluye dentro dos multiplexores, un sumador y un negador.

Partiendo de este diseño, la implementación en VHDL es directa.

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

entity Alu is
    generic (
        NBits : integer := 16
    );
    port (
        DIn   : in std_logic_vector((NBits - 1) downto 0);
        SIn   : in std_logic_vector((NBits - 1) downto 0);
        RAIn  : in std_logic_vector((NBits - 1) downto 0);
        DOut  : out std_logic_vector((NBits - 1) downto 0);
        SelIn : in std_logic_vector(3 downto 0)
    );
end entity;

architecture Architecture1 of Alu is
    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;
    component Neg is
        generic (
            NBits : integer := 16
        );
        port(
            DataIn  : in std_logic_vector((NBits - 1) downto 0);
            DataOut : out std_logic_vector((NBits - 1) downto 0)
        );
    end component;
    signal AddOut : std_logic_vector((NBits - 1) downto 0);
    signal SInNeg : std_logic_vector((NBits - 1) downto 0);
    signal FirstOperand : std_logic_vector((NBits - 1) downto 0);
    signal SMuxOut : std_logic_vector((NBits - 1) downto 0);
begin
    -- mux for the first operand of the adder
    FirstOperand <= std_logic_vector(to_signed(1, NBits)) when (SelIn = "0111") else                              -- inc
                    std_logic_vector(to_signed(-1, NBits)) when (SelIn = "1000") else                             -- dec
                    DIn when ((SelIn = "0001") or (SelIn = "0010") or (SelIn = "1100") or (SelIn = "1101")) else  -- add, sub, jz, jn
                    (others => '0');                                                                              -- assign
    -- neg Y
    Negate : Neg generic map (
        NBits => NBits
    )
    port map (
        DataIn => SIn,
        DataOut => SInNeg
    );
    -- src mux
    SMuxOut <= SInNeg when (SelIn = "0010") else  -- sub
               (others => '0') when ((SelIn = "1100") and (RAIn /= std_logic_vector(to_unsigned(0, NBits)))) or ((SelIn = "1101") and (RaIn(NBits - 1) = '0')) else  -- jz, jn
               SIn;
    -- adder
    Add : Adder generic map (
        NBits => NBits
    )
    port map (
        A => FirstOperand,
        B => SMuxOut,
        Y => AddOut
    );
    -- final mux
    DOut <= AddOut when ((SelIn = "0000") or (SelIn = "0001") or (SelIn = "0010") or (SelIn = "0111") or (SelIn = "1000") or (SelIn = "1100") or (SelIn = "1101")) else
            (DIn and SIn) when (SelIn = "0011") else
            (DIn or SIn) when (SelIn = "0100") else
            (DIn xor SIn) when (SelIn = "0101") else
            (not(SIn)) when (SelIn = "0110") else
            ('0' & SIn(15 downto 1)) when (SelIn = "1001") else         -- slr
            (SIn(15) & SIn(15 downto 1)) when (SelIn = "1010") else     -- sar
            (SIn(14 downto 0) & '0') when (SelIn = "1011") else         -- sll
            (others => '0');
end architecture;

Nótese que el componente “Neg” es el negador y calcula el complemento a dos (también se trata, a su vez, de un circuito combinacional)

Lógica secuencial: los registros

Un registro no es más que una colección de biestables D en paralelo, uno por cada bit.

La forma más portable de implementar una entrada “enable” es poniendo un multiplexor en la entrada D que seleccione entre la entrada exterior y realimentar la propia Q. De esta forma simulamos un “enable” con lógica “estándar”.

La implementación en VHDL de biestables D es directa:

library ieee;
use ieee.std_logic_1164.all;

entity 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 Reg;

architecture Architecture1 of Reg is
signal QBus : std_logic_vector((NBits - 1) downto 0);
signal DBus : std_logic_vector((NBits - 1) downto 0);
signal PreDBus : std_logic_vector((NBits - 1) downto 0);
begin
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            QBus <= DBus;
        end if;
    end process;

    DBus <= PreDBus when (Enable = '1') else QBus;
    PreDBus <= DataIn;
    DataOut <= QBus;
end Architecture1;


Lógica secuencial: la memoria

La unidad de memoria viene con una RAM y una ROM. Una memoria ROM no requiere secuencialidad y puede ser implementada como una LUT de forma combinatoria:

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(15 downto 0)
    );
end entity;

architecture Architecture1 of Rom is
type RomType is array (0 to 8191) of std_logic_vector(15 downto 0);
constant Data : RomType := (
    "0001000000000000",      --     load
    "0010000000001000",      --     op ra, ra, dec
    "0011000000000000",      --     store
    others => "0000000000000000"
);
begin
    DataOut <= Data(to_integer(unsigned(AddressIn)));
end architecture;

La memoria RAM sí requiere de la señal de reloj ya que es un circuito secuencial. La implementación VHDL usada es la recomendada por la mayoría de los fabricantes (usando un array de std_logic_vector):

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

entity Ram is
    generic (
        GPOutAddress : integer := 4096
    );
    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;
        GPOut       : out std_logic_vector(15 downto 0);
        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 (WriteEnable = '1') then
                Data(to_integer(unsigned(AddressIn))) <= DataIn;
            end if;
        end if;
    end process;

    DataOut <= Data(to_integer(unsigned(AddressIn)));
    GPOut <= Data(GPOutAddress);
end architecture;


La RAM incluye un puerto “GPOut” que mapea la dirección de memoria 4096 de la RAM en un puerto de salida de 16 bits. Este añadido se usará más adelante, en la prueba de concepto, para facilitar la depuración del procesador.

La unidad de control

Como se vio en la primera entrega, la unidad de control es realmente una FSM (máquina de estados finita) cuyas salidas gobiernan las entradas “enable” de los registros, las entradas de selección de los multiplexores y el resto de la lógica del procesador.

La FSM de la unidad de control va avanzando el contador de programa, carga las instrucciones en el IR y ejecuta el microcódigo de cada instrucción. Los estados de la FSM comunes a cualquier instrucción que se ejecute son los siguientes (extraído del anterior post):

0. MUX6 := "0", Habilitar PC

1. MUX1 = PC, Habilitar ADDR

2. Habilitar DATAin

3. Habilitar IR

4. MUX5 := FSM, ALU := inc, MUX4 := PC, Habilitar PC

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

6. Ir al estado 1

A continuación puede verse el grafo completo de la FSM.

El estado 0 es al estado que se va en el reset. A continuación pueden verse también las señales que unen la unidad de control con el resto de la lógica del procesador:

Se ha optado por implementar la FSM como una máquina de tipo Moore (la salida depende sólo del estado actual y el estado siguiente depende de las entradas y del estado actual)

library ieee;
use ieee.std_logic_1164.all;

entity FSM is
    port (
        DataOutEnable  : out std_logic;
        DataInEnable   : out std_logic;
        RAEnable       : out std_logic;
        RBEnable       : out std_logic;
        RCEnable       : out std_logic;
        SPEnable       : out std_logic;
        PCEnable       : out std_logic;
        AddrEnable     : out std_logic;
        IREnable       : out std_logic;
        Mux1Sel        : out std_logic_vector(1 downto 0);
        Mux2Sel        : out std_logic_vector(1 downto 0);
        Mux3Sel        : out std_logic_vector(2 downto 0);
        Mux4Sel        : out std_logic_vector(2 downto 0);
        Mux5Sel        : out std_logic;
        Mux6Sel        : out std_logic;
        AluOpSel       : out std_logic_vector(3 downto 0);
        MemWriteEnable : out std_logic;
        SignExpSel     : out std_logic;
        IRValue        : in std_logic_vector(15 downto 0);
        Clk            : in std_logic;
        Reset          : in std_logic
    );
end entity;

architecture Architecture1 of FSM is
    signal QBus : std_logic_vector(4 downto 0);
    signal DBus : std_logic_vector(4 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
    -- for state "00000"   MUX6 := "0", Enable PC
    DBus <= "00001" when ((QBus = "00000") or (QBus = "00101") or (QBus = "01000") or (QBus = "01001") or (QBus = "01011") or (QBus = "01110") or (QBus = "10010") or (QBus = "10011") or (QBus = "10100") or (QBus = "10101")) else                -- MUX1 := PC, Enable ADDR
            "00010" when (QBus = "00001") else        -- Enable DATAIN
            "00011" when (QBus = "00010") else        -- Enable IR
            "00100" when (QBus = "00011") else        -- MUX5 := FSM, ALU := inc, MUX4 := PC, Enable PC
            -- LOADI states
            "00101" when ((QBus = "00100") and (IRValue(15) = '1')) else                  -- MUX2 := EXP, SignSel := 15 bits, Enable RA
            -- LOAD states
            "00110" when ((QBus = "00100") and (IRValue(15 downto 12) = "0001")) else     -- MUX1 := RB, Enable ADDR
            "00111" when (QBus = "00110") else                                            -- Enable DATAIN
            "01000" when (QBus = "00111") else                                            -- MUX2 := DATAIN, Enable RA
            -- OP states
            "01001" when ((QBus = "00100") and (IRValue(15 downto 12) = "0010")) else     -- MUX2 := ALU, MUX3 := dst, MUX4 := src, MUX5 := IR(3..0), Enable dst
            -- STORE states
            "01010" when ((QBus = "00100") and (IRValue(15 downto 12) = "0011")) else     -- MUX1 := RB, Enable ADDR, Enable DATAOUT
            "01011" when (QBus = "01010") else                                            -- WE := 1
            -- PUSH states
            "01100" when ((QBus = "00100") and (IRValue(15 downto 12) = "0100")) else     -- MUX4 := SP, MUX5 := FSM, ALU := inc, Enable SP
            "01101" when (QBus = "01100") else                                            -- MUX1 := SP, Enable ADDR, Enable DATAOUT
            "01110" when (QBus = "01101") else                                            -- WE := 1
            -- POP states
            "01111" when ((QBus = "00100") and (IRValue(15 downto 12) = "0101")) else     -- MUX1 := SP, Enable ADDR
            "10000" when (QBus = "01111") else                                            -- Enable DATAIN
            "10001" when (QBus = "10000") else                                            -- Enable RA, MUX2 := DATAIN
            "10010" when (QBus = "10001") else                                            -- MUX4 := SP, MUX5 := FSM, ALU := dec, Enable SP
            -- J states
            "10011" when ((QBus = "00100") and (IRValue(15 downto 12) = "0110")) else     -- MUX4 := EXP, SignSel := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add, Enable PC
            -- JZ states
            "10100" when ((QBus = "00100") and (IRValue(15 downto 12) = "0111")) else     -- MUX4 := EXP, SignSel := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add if RA=0, Enable PC
            -- JN states
            "10101" when ((QBus = "00100") and (IRValue(15 downto 12) = "0000")) else     -- MUX4 := EXP, SignSel := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add if RA<0, Enable PC
            "00000";

    -- output logic
    DataOutEnable <= '1' when (QBus = "01010") or (QBus = "01101") else
                     '0';
    DataInEnable <= '1' when (QBus = "00010") or (QBus = "00111") or (QBus = "10000") else
                    '0';
    RAEnable <= '1' when (QBus = "00101") or (QBus = "01000") or (QBus = "10001") or ((QBus = "01001") and (IRValue(10 downto 8) = "000")) else
                '0';
    RBEnable <= '1' when (QBus = "01001") and (IRValue(10 downto 8) = "001") else
                '0';
    RCEnable <= '1' when (QBus = "01001") and (IRValue(10 downto 8) = "010") else
                '0';
    SPEnable <= '1' when (QBus = "01100") or (QBus = "10010") or ((QBus = "01001") and (IRValue(10 downto 8) = "011")) else
                '0';
    PCEnable <= '1' when (QBus = "00000") or (QBus = "00100") or (QBus = "10011") or (QBus = "10100") or (QBus = "10101") or ((QBus = "01001") and (IRValue(10 downto 8) = "100")) else
                '0';
    AddrEnable <= '1' when (QBus = "00001") or (QBus = "00110") or (QBus = "01010") or (QBus = "01101") or (QBus = "01111") else
                  '0';
    IREnable <= '1' when (QBus = "00011") else
                '0';
    Mux1Sel <= "00" when (QBus = "00110") or (QBus = "01010") else
               "01" when (QBus = "01101") or (QBus = "01111") else
               "10";
    Mux2Sel <= "00" when (QBus = "01000") or (QBus = "10001") else
               "01" when (QBus = "01001") else
               "10";
    Mux3Sel <= "100" when (QBus = "10011") or (QBus = "10100") or (QBus = "10101") else
               IRValue(10 downto 8);
    Mux4Sel <= "011" when (QBus = "01100") or (QBus = "10010") else
               "100" when (QBus = "00100") else
               "101" when (QBus = "10011") or (QBus = "10100") or (QBus = "10101") else
               IRValue(6 downto 4);
    Mux5Sel <= '0' when (QBus = "01001") else
               '1';
    Mux6Sel <= '0' when (QBus = "00000") else
               '1';
    AluOpSel <= "0111" when (QBus = "00100") or (QBus = "01100") else
                "1000" when (QBus = "10010") else
                "0001" when (QBus = "10011") else
                "1100" when (QBus = "10100") else
                "1101" when (QBus = "10101") else
                "0000";
    MemWriteEnable <= '1' when (QBus = "01011") or (QBus = "01110") else
                      '0';
    SignExpSel <= '0' when (QBus = "00101") else
                  '1';
end architecture;

Al igual que en otras ocasiones, una vez tenemos el grafo de la FSM, su implementación en VHDL es totalmente mecánica.

Prueba de concepto

Como primera aproximación se ha creado un fichero Rom.vhd que contiene, escrito a mano, el código máquina del siguiente código ensamblador:

    # GPOut := 10
    loadi 12288
    op rb, ra, assign
    loadi 10
    store
loop:
    # if (GPOut == 0) then goto loopEnd
    loadi 12288
    op rb, ra, assign
    load
    jz loopEnd
    # decrementar GPOut
    loadi 12288
    op rb, ra, assign
    load
    op ra, ra, dec
    store
    # bucle
    j loop
loopEnd:
    j loopEnd


Para este programa el código VHDL de la ROM quedaría como sigue:

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(15 downto 0)
    );
end entity;

architecture Architecture1 of Rom is
type RomType is array (0 to 8191) of std_logic_vector(15 downto 0);
constant Data : RomType := (
    -- simple counter
    "1011000000000000",      --     loadi 12288
    "0010000100000000",      --     op rb, ra, assign
    "1000000000001010",      --     loadi 10
    "0011000000000000",      --     store
                             -- loop:
    "1011000000000000",      --     loadi 12288
    "0010000100000000",      --     op rb, ra, assign
    "0001000000000000",      --     load
    "0111000000000110",      --     jz loopEnd          (+6)
    "1011000000000000",      --     loadi 12288
    "0010000100000000",      --     op rb, ra, assign
    "0001000000000000",      --     load
    "0010000000001000",      --     op ra, ra, dec
    "0011000000000000",      --     store
    "0110111111110110",      --     j loop              (-10)
                             -- loopEnd:
    "0110111111111111",      --     j loopEnd           (-1)
    others => "0000000000000000"
);
begin
    DataOut <= Data(to_integer(unsigned(AddressIn)));
end architecture;

El puerto de salida está en la dirección 4096 de nuestra RAM pero como la RAM está situada después de la ROM, la dirección de memoria de este puerto de salida será realmente 8192 + 4096 = 12288.

Ejecutando la simulación

El paquete de software usado para realizar la simulación es el GHDL, un compilador y simulador VHDL open source que genera ficheros VCD de eventos. Estos ficheros VCD contienen las señales digitales de todo el circuito simulado y son visualizables con herramientas como el GtkWave.

El testbench utilizado se encarga simplemente de generar el tren de pulsos del reloj y de realizar un reset al principio.

Reset <= '0' after 3 ns;
Finished <= '1' after 2 us;
Clk <= not Clk after 1 ns when Finished /= '1' else
       '0';


A continuación pueden verse las señales de nuestra CPU al ejecutar una instrucción LOADI justo después del reset:

Si observamos el puerto de salida GPOut y alejamos el zoom se puede ver cómo el procesador ha ejecutado el programa correctamente (cuenta descendente desde 10 hasta 0 y se detiene).



Ya hemos conseguido que nuestro provesador V1 funcione en un simulador, ahora sólo nos queda implementarlo en una FPGA, pero eso será en la próxima entrega :-).

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

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

[ añadir comentario ] ( 1938 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1915 )
Diseño e implementación de un procesador RISC desde cero (I) 
Mediante este post empiezo a realizar una serie de entradas en las que iré abordando el diseño y la implementación en VHDL de un procesador RISC sencillo al que he llamado V1. La complejidad y lo extenso del tema obligan a dividir el proyecto en varios posts que iré publicando a medida que vaya alcanzando los diferentes hitos.

El objetivo final es conseguir un procesador funcional dentro de una FPGA (en mi caso, dentro de una Sparten-3E de Xilinx) y de desarrollar como mínimo un ensamblador y, si se tercia, un pequeño compilador.

Procesador RISC

Se plantea un procesador de tipo RISC de 16 bits con 5 registros (RA, RB, RC, SP y PC) y 9 instrucciones. Las características generales del procesador son las siguientes:

- 5 registros de 16 bits con signo (RA, RB, RC, SP y PC) entre los que se incluyen el puntero de pila (SP) y el contador de programa (PC).

- Memoria interna incorporada: 8192 palabras de 16 bits (16Kb) de ROM y 8192 palabras de 16 bits (16Kb) de RAM. La memoria no es accesible por bytes sino por palabras de 16 bits.

- 9 instrucciones tipo RISC (instrucciones de acceso a los datos separadas de las instrucciones de manipulación de los datos): LOADI, LOAD, STORE, OP, PUSH, POP, J, JZ y JN.

Repertorio de instrucciones

LOADI value
Carga en el registro RA el valor indicado como operando de 15 bits.
1vvv vvvv vvvv vvvv
RA := value (15 bits con expansión del signo)

LOAD
Carga en el registro RA el valor almacenado en la posición de memoria apuntada por el registro RB.
0001 xxxx xxxx xxxx
RA := [RB]

OP dst, src, ope
Realiza una operación entre registros.
0010 0ddd 0sss oooo
ddd, sss:
000 RA
001 RB
010 RC
011 SP (puntero de pila)
100 PC (contador de programa)
oooo:
0000 assign (dst := src)
0001 add (dst := dst + src)
0010 sub (dst := dst - src)
0011 and (dst := dst & src)
0100 or (dst := dst | src)
0101 xor (dst := dst ^ src)
0110 not (dst := !src)
0111 inc (dst := src + 1)
1000 dec (dst := src - 1)
1001 slr (dst := src slr 1)
1010 sar (dst := src sar 1)
1011 sll (dst := src sll 1)
1100 add if RA = 0 (dst := dst + src if RA = 0, else dst := dst) (jz)
1101 add if RA < 0 (dst := dst + src if RA < 0, else dst := dst) (jn)

STORE
Almacena en la posición de memoria apuntada por RB el valor que hay en RA.
0011 xxxx xxxx xxxx
[RB] := RA

PUSH
Empuja en la pila el valor que hay en RA.
0100 xxxx xxxx xxxx
SP := SP + 1, [SP] := RA

POP
Extrae un valor de la pila y lo pone en RA.
0101 xxxx xxxx xxxx
RA := [SP], SP := SP - 1

J value
Salto relativo incondicional a otra posición de memoria.
0110 vvvv vvvv vvvv
PC := PC + value (12 bits con expansión de signo)

JZ value
Salto relativo condicional (si RA = 0) a otra posición de memoria.
0111 vvvv vvvv vvvv
Si RA = 0 entonces PC := PC + value (12 bits con expansión de signo)

JN value
Salto relativo condicional (si RA < 0) a otra posición de memoria.
0000 vvvv vvvv vvvv
Si RA < 0 entonces PC := PC + value (12 bits con expansión de signo)

Como se puede comprobar se trata de un repertorio de instrucciones muy sencillo. En el que se ha optado por hacer una instrucción “OP” que abarque todas las posibles operaciones de la ALU: No es casualidad que las dos últimas operaciones de la instrucción “OP” sean las utilizadas internamente por las instrucciones JZ y JN. Esta simplificación facilita mucho el diseño de la unidad de control.

Se ha optado, además, por utilizar 5 registros en lugar de 4 ya que, aunque un juego de registros (RA, RB, SP, PC) de 4 es más que suficiente para obtener un procesador funcional, lo cierto es que de cara a la implementación de un compilador y el uso de marcos de pila (stack frames) se agradece poder disponer de un registro que permita localizar cómodamente los parámetros y las variables locales sin estar haciendo muchas filigranas (la función que realiza el registro BP en la arquitectura x86).

Un compilador podrá utilizar los registros RA y RB para los cálculos de propósito general y RC como puntero base para acceder a los parámetros actuales y las variables locales, sin necesidad de hacer malabarismos con el puntero de pila (SP).

Ejemplos de código

Un bucle sencillo:
    # x = 10
    loadi x
    op rb, ra, assign
    loadi 10
    store
loop:
    # if x = 0 goto loopEnd
    loadi x
    op rb, ra, assign
    load
    jz loopEnd
    # x--
    loadi x
    op rb, ra, assign
    load
    op ra, ra, dec
    store
    # goto loop
    j loop
loopEnd:


Una indirección:
    # x[ i ] = 10
    loadi i
    op rb, ra, assign
    load
    op rc, ra, assign
    loadi x
    op rb, ra, assign
    op rb, rc, add
    loadi 10
    store


Restricciones de diseño

De cara a realizar el diseño del procesador se han seguido las siguientes directrices:

- Que el código VHDL sea totalmente síncrono y sintetizable. Utilizando siempre una arquitectura de tipo RTL para que pueda implementarse en cualquier FPGA de cualquier fabricante.

- Sin pipeline ni ningún otro tipo de paralelización u optimización hardware.

- Sin caché: Sólo RAM, ROM y registros.

Ruta de datos

La ruta de datos que se ha usado partiendo del repertorio de instrucciones es la siguiente:



ALU

La unidad aritmético-lógica (ALU) se encarga de realizar los cálculos mediante lógica combinatoria: sumas, restas, operaciones de bit, etc.



Módulo de memoria

La memoria está compuesta por 32 Kbytes repartidos en 16 KBytes para ROM y 16 KBytes para RAM. La memoria es solo accesible a nivel de palabra de 16 bits por lo que realmente tenemos un espacio de 16384 palabras con 16 bits por palabra: 8192 palabras de ROM y 8192 palabras de RAM.



Microcódigo

Cada una de las instrucciones descritas anteriormente tendrá un microcódigo asociado que serán las órdenes de carga de los latches, las entradas de selección de los multiplexores, etc. necesarias para que cada instrucción de ejecute. A continuación se puede ver cómo es el microcódigo de cada una de las instrucciones:

LOADI value
MUX2 := EXP, EXP := 15 bits, Habilitar RA

LOAD
MUX1 := RB, Habilitar ADDR
Habilitar DATAin
MUX2 := DATAin, Habilitar RA

OP dst, src, ope
MUX2 := Alu, MUX3 := dst, MUX4 := src, MUX5 := IR[3...0], Habilitar dst

STORE
MUX1 := RB, Habilitar ADDR, Habilitar DATAout
WE := 1

PUSH
MUX4 := SP, MUX5 := FSM, ALU := inc, Habilitar SP
MUX1 := SP, Habilitar ADDR, Habilitar DATAout
WE := 1

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

J value
MUX4 := EXP, EXP := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add, Habilitar PC

JZ value
MUX4 := EXP, EXP := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add if RA = 0, Habilitar PC

JN value
MUX4 := EXP, EXP := 12 bits, MUX3 := PC, MUX5 := FSM, ALU := add if RA < 0, Habilitar PC

Cada línea de microcódigo corresponde con un ciclo de reloj: LOADI requiere de un único ciclo de reloj mientras que POP requiere de cuatro ciclos de reloj. Las instrucciones PUSH y POP son las más intensivas en cuanto a ciclos de microcódigo.

Por ejemplo:
MUX4 := SP, MUX5 := FSM, ALU := inc, Habilitar SP
Significa: seleccionar la entrada correspondiente a SP en el MUX4, seleccionar la entrada correspondiente a la FSM en el MUX5 y hacer que la FSM mande la instrucción "inc" a la ALU y habilitar (poner el enable a 1) el registro SP para que sea cargado en el siguiente ciclo de reloj. Como todo está en una misma línea significa que se hace todo esto de golpe.

Hay que recordar que como estamos haciendo un modelo RTL el reloj es global a todos los bloques del procesador y la única forma que hay de controlar la carga de registros es mediante “enables” (Habilitar XX). Cuando se habilita un registro, es en el siguiente pulso de reloj cuando dicho registro se carga. Por ejemplo, veamos el caso de la instrucción POP:

POP
MUX1 := SP, Habilitar ADDR
Habilitar DATAin --> AQUÍ SE CARGA ADDR
Habilitar RA, MUX2 := DATAin --> AQUÍ SE CARGA DATAin”
MUX4 := SP, MUX5 := FSM, ALU := dec, Habilitar SP --> AQUÍ SE CARGA RA

Veamos este microcódigo de forma más detallada y explicada:

1. En el primer ciclo de reloj se selecciona la entrada SP del MUX1 y se pone a 1 la entrada "enable" del registro ADDR.

2. En el segundo ciclo de reloj se cargará el registro ADDR con lo que estaba en la salida del MUX1 (el valor del registro SP) y se pondrá a 1 la entrada "enable" del registro DATAIN.

3. Como el registro ADDR direcciona la memoria, en la entrada del registro DATAIN estará el dato alojado en la dirección de memoria apuntada por el valor de ADDR (el valor de SP), por lo que en este ciclo de reloj en DATAIN se cargará este dato (es decir, lo que está en el tope de la pila). Se pone a 1 la entrada "enable" del registro RA y se selecciona la entrada DATAIN en el MUX2.

4. En el cuarto ciclo de reloj se carga el registro RA con el valor que sale del MUX2, que es el valor del registro DATAIN. Ya tenemos en RA el valor del tope de la pila (hemos hecho RA := [SP]). En este mismo ciclo de reloj nos preparamos para decrementar SP: Indicamos al MUX4 que seleccione la entrada SP para que la entrada S de la ALU sea el valor de SP, seleccionamos en el MUX5 la entrada de selección que llega desde la FSM, desde la FSM indicamos que queremos una operación de decremento ("dec") y ponemos a 1 la entrada "enable" del registro SP.

5. En el quinto ciclo de reloj el registro SP se carga con el valor de salida de la ALU (SP := SP - 1).

Unidad de control

La unidad de control es la parte del procesador que se encarga de la secuenciación del resto de elementos y normalmente (como este caso) se implementa en forma de FSM (máquina de estados finita). La unidad de control se encarga en nuestro caso de:

- Inicialización en el arranque del PC con el vector de reset.

- Carga de la instrucción apuntada por el PC en el IR.

- Ejecución del microcódigo de la instrucción almacenada en el IR.

- Actualización automática del PC.

Los estados que tendrá la FSM serán los siguientes (el estado 0 es el estado en el que empieza el procesador tras un reset):

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. Habilitar DATAin

3. Habilitar IR

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

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

6. Ir al estado 1

En el peor caso (instrucción POP), el paso 5 necesita 4 ciclos de reloj para ejecutarse. Por tanto en el peor caso, cada instrucción necesita un total de 11 ciclos mientras que en el mejor caso (instrucciones de 1 ciclo) cada instrucción necesita un total de 7 ciclos. A 32 MHz tendremos una velocidad mínima de 2.9 MIPS y máxima de 4.6 MIPS (millones de instrucciones por segundo).

Siguiente entrega

El la siguiente entrega se abordará el diseño de la máquina de estados y se empezará a plantear la implementación de los multiplexores, la ALU y los registros en VHDL.

En la sección soft puede descargarse un simulador y un ensamblador para este procesador, desarrollados en C++.

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

[ 2 comentarios ] ( 37366 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 1803 )
Programación del microcontrolador LPC810 en C++ desde cero 
El LPC810 es un microcontrolador con núcleo ARM Cortex-M0+ en encapsulado DIP8 y con reloj interno. Es bastante limitado (4Kb de memoria flash y 1Kb de memoria RAM) pero el encapsulado DIP8 y el reloj interno permiten montar proyectos sencillos en protoboard, lo que le da un valor educativo muy alto. Un micro ideal para introducirse en los 32 bits cuando se viene del mundo de los 8 bits.

Características generales

El LPC810 es un ARM Cortex-M0+ en encapsulado DIP8 que incluye un multiplicador rápido de 1 ciclo, 4 Kb de memoria flash, 1 Kb de memoria RAM, un reloj interno a 12 MHz overclockeable hasta 30 MHz, bootloader por puerto serie, I2C, SPI e interface de depuración compatible JTAG. El bootloader no ocupa espacio en los 4Kb de memoria flash sino que reside una zona de memoria aparte lo que facilita mucho la programación y la configuración del linker script y del compilador.

Núcleo ARM Cortex-M0+

Todos los ARM Cortex-M mapean, tras el reset, la tabla de vectores en la posición 0x00000000 con el siguiente contenido:

dirección     tamaño   contenido
0x00000000 4 puntero de pila
0x00000004 4 puntero a la primera instrucción a ejecutar
...
0x0000003C 4 puntero al manejador del SysTick (timer)
...

Hay más vectores en la tabla. Sólo he indicado los más relevantes para nuestro ejemplo y por ahora nos centraremos en los dos primeros vectores (puntero de pila y vector de reset), que son los más importantes.

Bootloader

Cuando el chip LPC810 se reinicia o se enciende se ejecuta el código del bootloader. Dicho código comprueba, entre otras cosas, si el pin ISP (pin 5) del chip está a masa, si lo está, entra en modo programación (es el modo usado para tostar el integrado), en caso contrario comprueba si el código almacenado en la memoria flash es "correcto". La forma de ver si el código cargado en la memoria flash es correcto es sumando las 8 primeras palabras de 32 bits (que coincide con los 8 primeros vectores de la tabla de vectores que se vieron antes), si el resultado es 0, se considera que el código el válido y se arranca, en caso contrario se entra en modo programación (como si el pin ISP hubiese estado a masa).



El programa que se ha utilizado para tostar el LPC810 es el lpc21isp (open source) y éste ya se encarga de calcular el valor que debe tener la posición de memoria correspondiente al vector de interrupción 7 para que la suma de los 8 primeros vectores valga 0. Ni en el linker script ni en el código fuente hay que preocuparse por este valor.

Fichero de startup y linker script

En anteriores posts en los que se abordaba la programación de otro microcontrolador de la familia ARM Cortex-M (el K20 de Freescale, de la placa Teensy), utilizaba una combinación de código ensamblador y de código C para realizar la secuencia de arranque. En este caso se ha optado por abordar el código de arranque (startup) sólo en lenguaje C/C++ para facilitar la claridad. Recordemos, antes de seguir, que el formato de fichero objeto (.o) y el formato ejecutable (.elf) de salida del gcc organizan su contenido por secciones. Las secciones básicas de cualquier fichero objeto o ejecutable ELF son las siguientes:

- “.text”, que es la sección que incluye el código.
- “.data”, que es la sección que incluye las variables globales inicializadas.
- “.bss”, que es la sección que incluye las variables globales sin inicializar (realmente sí se inicializan, pero a cero).

La secuencia de arranque que se sigue normalmente en cualquier sistema embebido para los programas hechos con gcc la podemos resumir como sigue:

1. Se copia la parte de la flash que incluye las variables globales en RAM (sección “.data” de los ficheros objeto).

2. Se inicializa a cero la parte de la RAM en la que van alojadas las variables sin inicializar (sección “.bss” de los ficheros objeto).

3. Se recorre la lista de punteros a funciones acabada en 0 alojada en la sección “.ctors”. Cada entrada es una función que hay que invocar.

4. Se recorrer la lista de punteros a funciones alojada en la sección “.init_array”. Cada entrada es una función que hay que invocar.

5. Se invoca a la función “main”.

6. Al terminar “main” (cosa que no suele ocurrir en un microcontrolador) se recorre la lista de punteros a funciones alojada en la sección “.fini_array”. Cada entrada es una función que hay que invocar.

7. Por último se recorre la lista de punteros a funciones (acabada en 0) y alojada en la sección “.dtors”.

8. En este instante se supone que se regresa al sistema operativo, pero como somos un sistema embebido nos “colgamos” (while (true) ; )

Se puede observar que las secciones “.ctors” y “.init_array” sirven para lo mismo, igual que las secciones “.dtors” y “.fini_array”. Hace algunos años gcc utilizaba las secciones “.ctors” y “.dtors” pero en las ultimas versiones esta usando las secciones “.init_array” y “.fini_array” (dejando las correspondientes “.ctors” y “.dtors” vacias) por compatibilidad y homogeneidad con la librería de C de GNU (glibc).

Siguiendo los pasos descritos, podemos escribir nuestro fichero de arranque “startup.cc”. Dentro de este código fuente, la función “void _startup()” es el punto de entrada:

void _startup() __attribute__((section(".startup"), naked));

void _startup() {
	_initDataRAM();
	_initBssRAM();
	_initClock();
	_callConstructors();
	_callInitArray();
	main();
	_callFiniArray();
	_callDestructors();
	while (true)
		;
}


Como se puede apreciar la función “void _startup()” tiene definidos los atributos.

- section(“.startup”)
- naked

Los atributos son una característica exclusiva de gcc (no forman parte del estándar del lenguaje C) y permiten controlar de forma fina la generación del código por parte del compilador gcc. En este caso se le dice al compilador que el código de la función “void _startup()” no lo coloque en la sección estándar “.text” (que es, por defecto, donde va el código), sino que lo coloque en una sección aparte y que llame a dicha sección “.startup”. El atributo “naked” le indica al compilador que no genere código preámbulo ni postámbulo para la función (preparación de la pila para variables locales, etc.), en otras palabras: le estamos diciendo al compilador que se limite a generer el código del cuerpo de la función, que el resto será responsabilidad nuestra.

¿Qué sentido tienen estos atributos? El poner el código de esta función en una sección aparte llamada “.startup” nos permite forzar en el linker script a que el código de esta función se sitúe al principio del vector de reset, mientras que el atributo naked nos permite reducir al mínimo ese código (no necesitamos código preámbulo ni postámbulo puesto que esa función no es llamada por nadie ni termina nunca).

SECTIONS {
	. = 0x00000000 ;
	.cortex_m0plus_vectors : {
		LONG(0x10000400);
		LONG(0x000000C1);
	}
	. = 0x0000003C ;
	.cotex_m0plus_vector_systick : {
		LONG(SYSTICK_ADDRESS + 1);
	}
	. = 0x000000C0 ;
	.text : {
		_linker_code = . ;
		startup.o (.startup)
		*(.text)
		*(.text.*)
		*(.rodata*)
		*(.gnu.linkonce.t*)
		*(.gnu.linkonce.r*)
	}
	SYSTICK_ADDRESS = . ;
	.systick : {
		*(.systick)
	}

	...

}

Como se puede ver para la tabla de vectores sólo hace falta definir los dos primeros valores (puntero de pila y vector de reset). El vector de reset está fijado a 0xC1 ya que el código de startup empieza en la posición de memoria 0xC0 (justo después de la tabla de vectores en los LPC810). En la tabla de vectores se ponen las direcciones con el bit 0 a 1 ya que se trata de un ARM Cortex-M y sólo soporta el juego de instrucciones Thumb (16 bits por instrucción).

La función “_startup” ademas de los pasos descritos (“.data”, “.bss”, “.init_array”, etc.) se encarga también de configurar el reloj del sistema en el LPC810 para que vaya a la máxima frecuencia permitida. En el arranque, el LPC810 utiliza su reloj interno RC, que va a 12 MHz. Estos 12 MHz pueden aumentarse configurando la PLL hasta llegar a los 30 MHz

Inicialización de variables globales

La función “void _startup()” invoca a varias funciones antes y después de invocar la función “main()”. La función “void _initDataRAM()” inicializa las variables globales inicializadas (que no están a cero) copiando una parte de la memoria flash hacia la RAM:

extern "C" {
	extern unsigned char flash_sdata;
	extern unsigned char ram_sdata;
	extern unsigned char ram_edata;
}

void _initDataRAM() {
	// init .data section (global variables) with flash data
	unsigned char *from = &flash_sdata;
	unsigned char *to = &ram_sdata;
	while (to != &ram_edata) {
		*to = *from;
		from++;
		to++;
	}
}


Mientras que la función “void _initBssRAM()” llena de ceros la zona de la memoria RAM indicada por la sección “.bss” (variables sin inicializar).

extern "C" {
	extern unsigned char ram_sbssdata;
	extern unsigned char ram_ebssdata;
}

void _initBssRAM() {
	// init .bss section with zeros
	unsigned char *p = &ram_sbssdata;
	while (p != &ram_ebssdata) {
		*p = 0;
		p++;
	}
}


A continuación las funciones “void _callConstructors()” y “void _callInitArray()” son las encargadas de llevar a cabo las inicializaciones “complejas”, invocando una o a una cada una de las funciones incluidas en la lista correspondiente (“.ctors” y “.init_array”). Estas llamadas se encargan de hacer estas inicializaciones (por ejemplo, cuando declaramos un objeto global, es preciso llamar a su contructor antes de que se ejecute la funcion “main”).

Prueba de concepto: parpadeo

Como prueba de concepto se ha desarrollado un pequeño programa muy sencillo que hace parpadear un led conectado a uno de los pines del integrado. Lo único que necesitamos es una toolchain con el target “arm-none-eabi” configurado (aquí los pasos para compilar una desde cero). Para controlar el parpadeo se ha usado la interrupción SysTick, esta interrupción está disponible en todos los ARM Cortex-M y consiste en un timer con un contador descendente de 24 bits que, cuando llega a 0, dispara dicha interrupción SysTick y vuelve a cargarse con el valor inicial.



1. En el linker script “lpc810.ld” incluimos una sección especial, que denominamos “.systick” y hacemos que la entrada 15 de la tabla de vectores (dirección de memoria 0x0000003C) apunte a la dirección de la memoria flash donde residirá la sección “.systick”.

SECTIONS {
	. = 0x00000000 ;
	.cortex_m0plus_vectors : {
		LONG(0x10000400);
		LONG(0x000000C1);
	}
	. = 0x0000003C ;
	.cotex_m0plus_vector_systick : {
		LONG(SYSTICK_ADDRESS + 1);
	}
	. = 0x000000C0 ;
	.text : {
		_linker_code = . ;
		startup.o (.startup)
		*(.text)
		*(.text.*)
		*(.rodata*)
		*(.gnu.linkonce.t*)
		*(.gnu.linkonce.r*)
	}
	SYSTICK_ADDRESS = . ;
	.systick : {
		*(.systick)
	}

	...

}

2. En el código fuente de nuestro programa definimos una función “systick” (aunque podemos ponerle el nombre que queramos) y mediante los atributos de GCC, le decimos al compilador que debe ir alojada en la sección “.systick”.

void systick()  __attribute__ ((section(".systick")));

Esto hace que nuestra función “systick” quede alojada en la seccion “.systick” de la memoria de programa. El vector de interrupción 15 apuntará, por tanto, a esta función “systick”.

3. En el cuerpo de la función “void systick()” simplemente escribimos un 1 en el bit 2 del registro “NOT0”. Escribir un 1 hace que el bit PIN0_2 cambie de estado.

void systick() {
	// change PIO0_2
	NOT0 = (1 << 2);
}

4. En la función “int main()” configuramos el PIN0_2 como pin de salida GPIO, configuramos el SysTick para que utilice reloj del sistema / 2 como fuente de reloj y en el registro de cuenta metemos el valor 15000000 (el reloj del sistema va a 30 MHz, por lo que si contamos hasta 15000000 usando un reloj de 30/2 = 15 MHz, tendremos una interrupción por segundo).

// PIN0_2 is output
PINENABLE0 = 0xFFFFFFBF;
DIR0 = (1 << 2);
// enable systick for interval = 1 second at 30 MHz
SYST_RVR = 15000000ULL;
SYST_CVR = 0;
SYST_CSR |= 0x03;   // clock source for systick is system clock / 2 = 15 MHz

5. A partir de este instante la funcion “systick” será invocada internamente una vez por segundo, provocando el parpadeo. Lo lógico ahora es “no hacer nada”, aunque hay varias formas de no hacer nada. Se puede siemplemente hacer un blucle infinito de toda la vida:

while (true)
        ;

Aunque en este caso lo más adecuado sería poner el procesador en algún modo de baja potencia para que esté medio dormido entre invocación e invocacion de la interrupción “systick”. Un término medio entre ambas aproximaciones es usar la instrucción “WFI” (Wait For Interrupt”) que hace que parte del procesador se pare (no avanza ni siquiera el contador de programa) hasta que se dispare alguna interrupción.

while (true) {
	// WFI (wait for interrupt) instruction, enters low power mode
	asm volatile ("wfi");
}



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

[ añadir comentario ] ( 2563 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 2421 )
Salida de audio de alta calidad con la placa Teensy 
La placa Teensy 3.1 (ARM Cortex M4) dispone de un puerto I2S para la transferencia de audio digital. Si combinamos esta salida con un buen DAC de alta fidelidad el resultado es espectacular :-)

El DAC de Texas Instruments PCM5102 es un DAC que soporta el estándar I2S de transferencia de audio digital y el estándar “left justified” (variante del I2S). Existen muchos otros DACs de audio en el mercado con soporte para estos formatos, sin embargo los más usados son el ES9023 y derivados, de ESS, y el PCM5102 y derivados, de Texas Instruments. En mi caso, adquirí una placa con un integrado PCM5102A y la circuitería mínima (componentes pasivos, espadines para conectar alimentación y las tres líneas del protocolo I2S y dos conectores RCA hembra de salida, uno para cada canal).



Unos 14€ por AliExpress (gastos de envío incluidos), aunque ahora creo que está incluso más barato.

I2S

El protocolo I2S es un protocolo muy sencillo de transferencia de audio digital. Aunque por su nombre puede parecer que es un protocolo derivado o parecido al protocolo I2C, lo cierto es que sólo se parecen en el nombre y, para nuestro alivio, es bastante más sencillo que el I2C.


(imagen extraida de Wikimedia, realizada por el usuario Wdwd y con licencia Creative Commons Attribution 3.0 Unported)

El protocolo, como se puede ver en el diagrama, solo necesita de tres hilos: uno para datos, otro para el reloj y otro para seleccionar la palabra o el frame (ponemos esta señal a 0 para enviar la muestra del canal izquierdo y a 1 para enviar la muestra del canal derecho).

Al tratarse de un protocolo de transferencia serie, si queremos emitir audio con calidad CD (16 bits a 44100 Hz estéreo) hace falta generar un reloj de:
$$44100 \times 16 \times 2 = 1411200 \thinspace Hz$$
Como se puede ver, si se quiere trabajar con frecuencias de muestreo lo suficientemente altas como para asegurar una mínima calidad de audio, es necesario hardware dedicado: generar esas señales por software es muy ineficiente. En nuestro caso el microcontrolador MK20 de Freescale (ARM Cortex-M4) que viene en la placa Teensy sí que viene equipado con un interface I2S totalmente programable.

El interface I2S en el microcontrolador MK20

El interface I2S tiene dos modos: directo y mediante DMA. En esta primera aproximación he implementado el modo directo (sin DMA). Es el modo que más CPU consume pero también es el más sencillo. Los pasos para configurar la interface de salida I2S en el MK20 son, grosso modo, los siguientes:

1. Configurar el multiplexor de pines para asignar las tres señales a pines reales.

2. Configurar el los divisores de frecuencia para obtener el “bit clock” de I2S a partir del reloj del sistema.

3. Configurar el tamaño de palabra (16 bits estéreo en nuestro caso).

4. Colgar de la IRQ 35 la función encargada de escribir las muestras en el registro de datos I2S.

5. Habilitar la IRQ 35 (vector de interrupción 16 + 35 = 51 del ARM Cortex-M4).

Configurar el multiplexor de pines es muy sencillo. En este caso he optado por usar la configuración “ALT6” para los pines PORTA.12, PORTA.13 y PORTC.3 que les dan la funcionalidad TX, FS (frame select, el equivalente a "word select") y BCLK (bit clock) respectivamente.



Para configurar el BCLK se dispone de un divisor de frecuencia fraccionario y de un divisor de frecuencia entero. Si quisiéramos usar una frecuencia de muestreo de 48KHz haríamos los siguiente:

1. Establecemos como fuente de reloj, el reloj del núcleo (SYSCLK) que, en nuestro caso, va a 96 MHz.

2. El divisor de frecuencia fraccionario lo configuramos con el valor: 16 / 125 (96 * 16 / 125 = 12.288 MHz).

3. El divisor de frecuencia entero lo configuramos a continuación con el valor 8: 12.288 / 8 = 1.536 MHz).

En este caso: 48 KHz * 2 * 16 = 1.536 MHz.

El resto de pasos es mejor verlos en el código:

bool i2sInit() {
	// configure i/o pins
	// (PTA12 = TX, PTA13 = FS, PTC3 = BCLK) --> ALT6
	PORTA_PCR12 = ((uint32_t) 6) << 8;
	PORTA_PCR13 = ((uint32_t) 6) << 8;
	PORTC_PCR3 = ((uint32_t) 6) << 8;
	// enable system clock for i2s module
	SIM_SCGC6 |= ((uint32_t) 1) << 15;
	// select input clock 0 and output enable
	I2S0_MCR = ((uint32_t) 1) << 30;
#if (I2S_SAMPLE_RATE == 48000)
	// divide to get the 12.2880 MHz from 96MHz (96 * (16/125))
	I2S0_MDR = (((uint32_t) 15) << 12) | ((uint32_t) 124);
#elif (I2S_SAMPLE_RATE == 44100)
	// divide to get the 11.2896 MHz from 96MHz (96 * (2/17))
	I2S0_MDR = (((uint32_t) 1) << 12) | ((uint32_t) 16);
#elif (I2S_SAMPLE_RATE == 32050)
	// divide to get the 8.2051 MHz from 96MHz (96 * (10/117))
	I2S0_MDR = (((uint32_t) 9) << 12) | ((uint32_t) 116);
#else
#error "I2S_SAMPLE_RATE must be 48000, 44100 or 32050"
#endif
	// re-enable system clock to the i2s module
	SIM_SCGC6 |= ((uint32_t) 1) << 15;
	// disable tx (TE=0) while configuring
	I2S0_TCSR &= ~(((uint32_t) 1) << 31);
	// transmitter remains enabled until (and TE set) the end of the current frame
	for (int i = 0; (i < 1000) && (I2S0_TCSR & (((uint32_t) 1) << 31)); i++)
		;
	if (I2S0_TCSR & (((uint32_t) 1) << 31))
		return false;
	// no word mask
	I2S0_TMR = 0;
	// set FIFO watermark
	I2S0_TCR1 = ((uint32_t) (I2S_FRAME_SIZE - 1));
	// use asynchronous mode (SYNC=0), BCLK polatiry active low (BCP=0), select master clock 1 (MSEL=1), bit clock divide (DIV=3), BCLK internally generated
	I2S0_TCR2 = (((uint32_t) 1) << 25) | (((uint32_t) 1) << 26) | ((uint32_t) 3) | (((uint32_t) 1) << 24);
	// transmit data channel is enabled (TCE=1)
	I2S0_TCR3 = (((uint32_t) 1) << 16);
	// frame size (FRSZ), bits per frame sync (SYWD), MSB (MF=1), I2S standard (not "left justified") (FSE=1), frame sync in master mode (FSD)
	I2S0_TCR4 = (((uint32_t) (I2S_FRAME_SIZE - 1)) << 16) | (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 8) | (((uint32_t) 1) << 4) | (((uint32_t) 1) << 3) | ((uint32_t) 1);
	// bits per word for first word in each frane (W0W), bits per word for rest of words in each frame (WNW), bit index for first bit tx (MSB, 15-th for 16 bit)
	//I2S0_TCR5 = (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 16) | (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 24) | (((uint32_t) 15) << 8);
	I2S0_TCR5 = (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 16) | (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 24) | (((uint32_t) (I2S_IO_BIT_DEPTH - 1)) << 8);
	return true;
}


void i2sStart() {
	wavePtr = (int16_t *) &_binary_drum_loop_16_raw_start;
	NVIC_ENABLE_IRQ(IRQ_I2S0_TX);
	// tx enable (TE=1), bit clock enable (BCE=1), FIFO request interrupt enable, FIFO reset
	I2S0_TCSR |= (((uint32_t) 1) << 31) | (((uint32_t) 1) << 28) | (((uint32_t) 1) << 8) | (((uint32_t) 1) << 25);
}


void i2sStop() {
	NVIC_DISABLE_IRQ(IRQ_I2S0_TX);
}

Además de lo dicho, es necesario colgar de la IRQ 35 una rutina que será invocada tantas veces por segundo como indique la frecuencia de muestreo y que será la encargada de escribir en el registro de salida I2S las muestras de audio que se van a emitir por la interface I2S. Definimos la rutina de la siguiente manera dentro del codigo C++:

extern char _binary_drum_loop_16_raw_start;
extern char _binary_drum_loop_16_raw_end;
volatile char *p;


void i2sTx()  __attribute__ ((section(".i2s_tx")));


volatile int16_t *wavePtr;


void i2sTx() {
	// if FRF=0, return
	if (!(I2S0_TCSR & (((uint32_t) 1) << 16)))
		return;
	// write left and right sample
	I2S0_TDR0 = (uint32_t) *wavePtr;
	I2S0_TDR0 = (uint32_t) *wavePtr;
	wavePtr++;
	if (wavePtr >= ((int16_t *) &_binary_drum_loop_16_raw_end))
		wavePtr = (int16_t *) &_binary_drum_loop_16_raw_start;
	// if underrun, clear underrun
	if (I2S0_TCSR & (((uint32_t) 1) << 18))
		I2S0_TCSR |= (((uint32_t) 1) << 18);
	// if frame sync error, clear frame sync error flag
	if (I2S0_TCSR & (((uint32_t) 1) << 19))
		I2S0_TCSR |= (((uint32_t) 1) << 19);
}

Y en el linker script de nuestro proyecto incluimos una seccion especial a la que llamaremos “.cortex_m4_vector_i2s_tx” y que ubicamos en la direccion de memoria 0x000000CC (la correspondiente a la IRQ 35). En esta sección ponemos la dirección de memoria de nuestra rutina de servicio de interrupción (la encargada de escribir las muestras), es decir metemos la dirección de memoria I2S_TX_ADDRESS + 1 (recordar que al tratarse de un Cortex-M, el reportorio de instrucciones es siempre el reportorio “thumb” y, por lo tanto, los destinos de salto para subrutinas y para codigo siempre deben tener su bit 0 a 1).
SECTIONS {
	. = 0x00000000 ;
	.cortex_m4_vectors : {
		LONG(0x20007FFC);
		LONG(0x00000411);
	}
	. = 0x000000CC ;
	.cortex_m4_vector_i2s_tx : {
		LONG(I2S_TX_ADDRESS + 1);
	}
	. = 0x00000400 ;
	.flash_configuration : {
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFF);
		LONG(0xFFFFFFFE);
	}
	.text : {
		_linker_code = . ;
		init.o (.text)
		*(.text)
		*(.text.*)
		*(.rodata*)
		*(.gnu.linkonce.t*)
		*(.gnu.linkonce.r*)
	}
	I2S_TX_ADDRESS = . ;
	.i2s_tx : {
		*(.i2s_tx)
	}
	.preinit_array : {
		__preinit_array_start = . ;
		*(.preinit_array)
		__preinit_array_end = . ;
	}

	...resto del linker script...

Audio de ejemplo

Se ha partido de un sample de dominio público consistente en dos golpes de bombo y caja con charles en medio, típicos del estilo de música house. La muestra se emite en 16 bits con una frecuencia de muestreo de 32050 Hz (Se ha usado este frecuencia por razones de espacio en la memoria flash: es una frecuencia que permite reproducir a una calidad buena manteniendo un tamaño lo suficientemente limitado como para caber en la memoria flash del microcontrolador).



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

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

<< <Anterior | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | Siguiente> >>