Implementación de un procesador RISC-V desde cero 
A lo largo de este post se abordará el diseño y la implementación desde cero de un procesador RISC-V básico (repertorio de instrucciones RV32I, sin extensiones), sintetizable en una FPGA de rango medio-bajo y capaz de ejecutar código generado por un compilador.

Introducción

Una arquitectura de juego de instrucciones (ISA) es una especificación de un juegos de instrucciones tanto a nivel técnico (el repertorio en sí) como a nivel funcional (que hace cada instrucción, los registros afectados, etc.). Existen varias ISAs abiertas y libres, pero pocas con un crecimiento tan grande en los últimos años como RISC-V. De hecho, ya hay fabricantes ofreciendo SoCs RISC-V de varios núcleos y con MMUs que permiten ejecutar Linux de forma segura. La ISA base de RISC-V consta de un repertorio de instrucciones muy pequeño, relativamente fácil de implementar y con muy buen soporte tanto en GCC como en LLVM. El principal atractivo de esta ISA es el hecho de que cualquiera puede fabricar un SoC, una MCU o un procesador RISC-V sin tener que pagar regalías a ninguna empresa u organización.

Objetivo

El objetivo, que se plantea como una "prueba de concepto", es la implementación de un procesador RISC-V básico desde cero, en VHDL y usando código sintetizable de tipo RTL. Una vez esté la implementación terminada, se probará la CPU con código generado por el compilador GCC.

Una CPU muy sencilla

Se plantea el repertorio de instrucciones básico (el denominado "rv32i"), sin ningún tipo de extensión. Dicho repertorio consta de 39 instrucciones "de usuario" y varias instrucciones adicionales denominadas "privilegiadas" o "protegidas", destinadas principalmente al manejo de CSRs. En la implementación realizada no se han definido CSRs, ya que se trata de una CPU sin interrupciones, sin MMU y cuyo único "periférico" será un simple pin de salida (GPIO) para encender un led.

Controlador de memoria

En el RISC-V el bus de direcciones es de 32 bits y, aunque el bus de datos es de 32 bits, se pueden realizar accesos a memoria de palabra (32 bits), media palabra (16 bits) y byte (8 bits). Se plantea la realización de un pequeño controlador de memoria que abstraiga los detalles de implementación de la RAM usada, de tal manera que cuando el procesador desee leer desde la RAM (ya sea para leer una instrucción o para leer un dato) o escribir en la RAM (escribir un dato), se lo pida al controlador de memoria y éste avise cuando la operación se haya terminado.



El procedimiento para leer un dato desde la RAM es el siguiente:

1. En la entrada AddresIn (32 bits) se pone la dirección a la que se desea acceder.

2. En la entrada DataIn (32 bits) se pone el dato en caso de que se quiera escribir. Si se quiere escribir media palabra sólo se tendrán en cuenta los 16 bits menos significativos y si se quiere escribir un byte sólo se tendrán en cuenta los 8 bits menos significativos. Esta entrada se ignora en caso de que la operación sea de lectura.

3. En la entrada WidthIn se indica la anchura de trabajo: 0 para un byte, 1 para media palabra (16 bits) y 2 o 3 para una palabra de 32 bits.

4. En la entrada ExtendSignIn se indica si, a la hora de leer datos de 16 bits (media palabra) o de 8 bits (1 byte) se debe extender el signo en los bits no leidos desde la memoria. Esta entrada se ignora en caso de que la operación sea de escritura.

5. En la entrada WEIn se indica si queremos leer (0) o escribir (1).

6. En la entrada StartIn se pone un 1 para iniciar el proceso de lectura o escritura. El circuito es síncrono, por lo que el proceso empezará en el siguiente ciclo de reloj.

Tras configurar todos los pines de entrada del controlador de memoria y poner StartIn a 1, hay que esperar a que el pin ReadyOut se ponga a 1. Cuando ReadyOut se pone a 1 significa que la operación ha terminado, esto es:

- En caso de que haya sido una operación de lectura (WEIn = 0), significará que el dato que queríamos leer estará disponible en los pines DataOut del controlador de memoria (si hemos pedido leer datos de 16 o de 8 bits en DataOut aparecerá el signo extendido en caso de que lo hayamos indicado en el pin de entrada ExtendSignIn).

- En caso de que haya sido una operación de escritura (WEIn = 1), significará que el dato que queríamos escribir (DataIn) ya está alojado en la RAM.

El controlador de memoria actúa como interfaz entre el núcleo RISC-V y cualquier memoria que queramos ponerle. En este caso se ha usado la propia memoria de la FPGA pero con esta interfaz nada impide usar una SDRAM, alguna PSRAM externa o cualquier otro tipo de memoria que queramos: sólo hay que cambiar el controlador de memoria, el núcleo de la CPU no cambia.



Se definen tres ficheros VHDL:

ROM.vhd, que alberga una imagen de una ROM simulada de 4 Kbytes mapeada en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Esta ROM contiene el código a ejecutar.

RAM.vhd, que alberga una definición estándar de RAM (https://www.doulos.com/knowhow/vhdl/simple-ram-model/) de 4 Kbytes mapeada también en 4096 posiciones de 8 bits cada una (bus de direcciones de 12 bits y bus de datos de 8 bits). Mediante esta implementación, todos los entornos de desarrollo de fabricantes de FPGAs infieren que quieres usar la RAM interna de la FPGA y la habilitan para ello (no gastan unidades lógicas).

Memory.vhd, que alberga la ROM y la RAM en un único bloque de memoria de 8 Kbytes (bus de direcciones de 13 bits y bus de datos de 8 bits). Los primeros 4 Kbytes son la ROM y los siguientes 4 KBytes con la RAM. En este módulo se define también un byte de GPIO que se solapa con la primera dirección de la RAM (posición 0 de la RAM, posición 4096 de la memoria total). El bit 0 de este byte GPIO está conectado directamente a un pin de salida.

MemoryController.vhd: El controlador de memoria en sí, que alberga un componente Memory (que a su vez alberga la ROM y la RAM).

Repertorio de instrucciones RV32I

Al conjunto de instrucciones básicas que debe tener cualquier procesador RISC-V de 32 bits se le denomina repertorio "base" o repertorio "RV32I". Son 39 instrucciones altamente ortogonales, muy sencillas de implementar y que siguen el paradigma RISC: instrucciones de manipulación de datos separadas de las instrucciones de acceso a la memoria.

En el documento denominado "green card" se especifica tanto el repertorio base RV32I como las extensiones estándar (M de multiplicación, A de atómicas, C de comprimidas). Nosotros sólo implementaremos el repertorio base RV32I.



Como se puede apreciar, las instrucciones de manejo de registros incluyen campos de 5 bits ($2^5 = 32$) para indicar los registros origen y destino de cada operación.

Registros de la CPU

La CPU RISC-V consta de 32 registros de 32 bits del x0 al x31. El registro x0 no es escribible y cuando se lee siempre alberga un 0.

De cara a escribir el código VHDL que sea RTL y para evitar estar definiendo 32 registros con sus correspondientes multiplexores uno por uno, usamos tipos array y bloques de tipo "generate" en VHDL. Definimos un tipo array para los 32 registros de 32 bits y definimos dos señales RegisterD y RegisterQ de este tipo:

    ...
    type WordArray32 is array(0 to 31) of std_logic_vector(31 downto 0);
    signal RegisterD : WordArray32;
    signal RegisterQ : WordArray32;
    ...

Implementamos los biestables de forma normal:

    ...
    process (Clk)
    begin
        if ((Clk = '1') and Clk'event) then
            for i in 0 to 31 loop
                RegisterQ(i) <= RegisterD(i);
            end loop;
            IRQ <= IRD;
            PCQ <= PCD;
            FSMQ <= FSMD;
            CounterQ <= CounterD;
        end if;
    end process;
    ...

Y definimos los multiplexores y la lógica que los activa mediante bloques "generate":

    ...
    -- RegSelForALUOut: 1..31 (R1..R31)
    RegisterD(0) <= (others => '0');          -- r0 cannot be altered, always zero value  
    Gen1: for I in 1 to 31 generate
        RegisterD(I) <= ALUOut when (DecodedMuxReg(I) = '1') else
                        RegisterQ(I);
    end generate;
    Gen1X: for I in 0 to 31 generate
        DecodedMuxReg(I) <= '1' when (I = to_integer(unsigned(RegSelForALUOut))) else
                            '0';
    end generate;
    ...

Este código genera un circuito combinacional compuesto por un decodificador (5 bits de entrada y 32 bits de salida) combinado con 31 multiplexores asociados a sus registros correspondientes:



Las entradas D (RegisterD) de los biestables de los registros provienen, o bien de la salida de la ALU (ALUOut), o bien de la salida Q (RegisterQ) de los mismos biestables (para mantener el valor). Se define un bus RegSelForALUOut de 5 bits que permite definir qué registro de los 31 disponibles recibe el dato de la ALU (el resto mantienen los datos que ya albergan). Como se puede apreciar en caso de que RegSelForALUOut valga "00000" no se hace nada, esto es compatible con el comportamiento deseado, puesto que el registro x0 no es escribible y siempre alberga un 0.

Además de los 32 registros indicados, se encuentran los registros de contador de programa (PC), registro de instrucción (IR) y "Contador". Este último registro es de 5 bits y se utiliza para llevar la cuenta en las operaciones de desplazamiento de bits.

ALU

La ALU se define de forma tabular y puramente combinacional.

    ...
    ALUOut <= std_logic_vector(signed(ALUIn1) + signed(ALUIn2)) when (ALUSel = ALU_SEL_ADD) else
              std_logic_vector(signed(ALUIn1) - signed(ALUIn2)) when (ALUSel = ALU_SEL_SUB) else
              (ALUIn1 xor ALUIn2)                               when (ALUSel = ALU_SEL_XOR) else
              (ALUIn1 or ALUIn2)                                when (ALUSel = ALU_SEL_OR)  else
              (ALUIn1 and ALUIn2)                               when (ALUSel = ALU_SEL_AND) else
              (ALUIn1(30 downto 0) & '0')                       when (ALUSel = ALU_SEL_SLL) else
              ('0' & ALUIn1(31 downto 1))                       when (ALUSel = ALU_SEL_SRL) else
              (ALUIn1(31) & ALUIn1(31 downto 1))                when (ALUSel = ALU_SEL_SRA) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) < signed(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_LT) and (signed(ALUIn1) >= signed(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_LTU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_EQ) and (ALUIn1 = ALUIn2) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_EQ) and (ALUIn1 /= ALUIn2) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) >= signed(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_GE) and (signed(ALUIn1) < signed(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) >= unsigned(ALUIn2)) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_GEU) and (unsigned(ALUIn1) < unsigned(ALUIn2)) else
              std_logic_vector(to_signed(1, 32))                when (ALUSel = ALU_SEL_NE) and (ALUIn1 /= ALUIn2) else
              std_logic_vector(to_signed(0, 32))                when (ALUSel = ALU_SEL_NE) and (ALUIn1 = ALUIn2) else
              ALUIn1;   -- default operation, identity
    ...

Como se puede observar, las operaciones de desplazamiento de bits se implementan en la ALU en forma de desplazamiento de un bit. Lo que se ha hecho en esta implementación es, para evitar consumir demasiadas LUTs en la FPGA, hacer que, en las operaciones de desplazamiento de varios bits, sea la máquina de estados la que use el desplazamiento de 1 bit varias veces hasta conseguir el desplazamiento deseado: El código VHDL se vuelve algo más complejo y se usan tantos ciclos de reloj como bits a desplazar, pero se ahorran recursos LUT en la FPGA.

Multiplexores y registros especiales

Los multiplexores incluidos en esta implementación del RISC-V pueden dividirse en tres grupos:

- Los multiplexores que controlan las señales de entrada a la ALU (ALUIn1, ALUIn2 y ALUSel) y de salida a los registros (RegSelForALUOut).

- Los multiplexores que controlan las señales de entrada al controlador de memoria (MCWidthIn, MCAddressIn, MCStartIn, MCWEIn, MCExtendSignIn y MCDataIn).

- Y los multiplexores que controlan los registros especiales: PC, IR y Counter.

Los multiplexores que controlan las señales de entrada a la ALU serían los siguientes:



A continuación tenemos los multiplexores asociados a las entradas al controlador de memoria:



Y por último tenemos los tres multiplexores asociados a sus respectivos registros especiales: PC, IR y Counter (el registro usado para llevar la cuenta en las operaciones de desplazamiento de bits de más de 1 bit):



Como se verá a continuación, en el apartado "Máquina de estados", las entradas de selección de todos estos multiplexores están gobernadas por los diferentes estados de la máquina de estados del procesador.

Máquina de estados

Uno de los conceptos que siempre hay que tener presente a la hora de diseñar circuitos secuenciales es el concepto de "carga retrasada" ("delayed load") ya que, por la propia naturaleza de los circuitos secuenciales síncronos, los cambios en un estado no tienen lugar hasta el siguiente ciclo de reloj. Veámoslo con un ejemplo: consideremos que tenemos dos registros A y B, con sus respectivos multiplexores a su entrada. MUX_A permite seleccionar qué dato se carga en el registro A, mientras que MUX_B permite seleccionar qué dato se carga en el registro B. Consideremos, además, la siguiente máquina de estados:



Cuando la máquina de estados está en el estado 1, el multiplexor del registro A (MUX_A) está seleccionando la X, pero en el registro A aún no se habrá cargado dicho valor X, lo hará en el siguiente ciclo de reloj. Lo mismo ocurrirá con el registro B: si estamos en el estado 1 y se cumple la condición P=Q, en el siguiente ciclo de reloj pasaremos al estado 2, pero en este ciclo de reloj aún no se cargará la Y en B. Es, una vez en el estado 2 (donde se selecciona en el MUX_B la entrada Y) y después del siguiente ciclo de reloj (pasando del estado 2 al 3), cuando se cargue la Y en B.

Una vez refrescado el concepto de "carga retrasada" podemos ver la máquina de estados de nuestro RISC-V, que consta de 23 estados.

- Máquina de estados: Nodos iniciales: Son los nodos encargados del reset, de la búsqueda de la instrucción en la posición de memoria apuntada por el PC y de la carga de dicha instrucción en el registro IR.



En esta parte de la máquina de estados se puede ver que, al ponerse la entrada Reset en nivel alto, se pasa al estado 0 y en este estado se selecciona la entrada RESET_VECTOR del multiplexor MUX_PC. Esto hará que, en el siguiente ciclo de reloj, se cargue en el registro PC el valor RESET_VECTOR. Cuando la entrada Reset pasa a valor 0, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 1 y en dicho estado se selecciona la entrada PC del multiplexor MUX_MCAddrIn, se selecciona la entrada a 2 del multiplexor MUX_MCWidthIn y se selecciona la entrada a 1 del multiplexor MUX_MCStartIn. Esto provocará que a partir del siguiente ciclo de reloj se inicie un proceso de lectura de 4 bytes de memoria en el controlador de memoria a partir de la dirección apuntada por el PC. El proceso de lectura terminará cuando MCReadyOut valga 1, y cuando esto ocurra, en el siguiente ciclo de reloj, la máquina de estados pasará al estado 2. En este estado 2 el multiplexor MUX_IR seleccionará como entrada MCDataOut (el dato recién leído), por lo que, con otro ciclo más de reloj (al pasar del estado 2 al 3), tendremos la instrucción recién leída de memoria cargada en el registro IR.

- Máquina de estados: Nodos de ejecución de instrucciones aritméticas y lógicas: Se encargan tanto de las instrucciones de un solo ciclo como de las instrucciones de N ciclos.



Aquí se puede ver como se discrimina entre operaciones de desplazamiento de bits y resto de operaciones ya que en el caso de desplazamiento de bits necesitaremos un ciclo de reloj adicional por cada bit que queramos desplazar. El resto de operaciones se ejecutan en un único ciclo de reloj.

- Máquina de estados: Nodos de ejecución de instrucciones de carga:



- Máquina de estados: Nodos de ejecución de instrucciones de almacenamiento:



- Máquina de estados: Nodos de ejecución de instrucciones de bifurcación:



- Máquina de estados: Nodos de ejecución de instrucciones de salto:



- Máquina de estados: Nodos de ejecución de instrucciones especiales:





Nótese que la instrucción "ebreak" se implementa de forma incorrecta a propósito. Es una instrucción que no es generada por el compilador (ni "ebreak" ni "ecall") y se usa para entornos con depuración y/o sistema operativo. En este caso decidí hacer que la instrucción "ebreak" provocase un paro en el procesador.

RISC-V final

Ya tenemos el procesador RISC-V operativo (sólo la implementación base RV32I, sin extensiones). Ahora falta probarlo con código real. Realizaremos el siguiente proceso de compilación en dos pasos:



Vamos a hacer un pequeño programa en C++ (un blinker), lo compilaremos con el compilador cruzado (target "riscv32-none-elf") y la salida binaria del código RISC-V del compilador la usaremos para generar el fichero ROM.vhd que servirá, a su vez, de entrada para el compilador VHDL.

Como compilador VHDL usaremos GHDL en una primera iteración, ya que nos permite hacer una simulación y generar un fichero con todas las señales que podemos visualizar mediante el software GtkWave. Una vez comprobemos que todo sale bien, en una segunda iteración, crearemos un proyecto en un entorno de programación VHDL de un fabricante de FPGAs (en nuestro caso Quartus 13, de Intel) y lo probaremos todo sobre una FPGA real (en nuestro caso una FPGA Intel Cyclone II).

Linker script personalizado

En un post anterior de este blog se describe cómo compilar e instalar el compilador GCC cruzado para arquitectura RISC-V. Partiendo de ese post, creamos un sencillo linker script que nos permitirá compilar código para nuestro nuevo procesador. Recordemos que tenemos un espacio total de 8 Kbytes (8192 bytes) repartidos en 4096 bytes de ROM más 4096 bytes de RAM. Además el bit 0 del primer byte de la RAM se comparte con un pin de salida a modo de GPIO, por lo que podemos establecer el siguiente mapa de memoria de nuestro procesador:

1. La ROM abarca 4096 bytes desde la dirección 0x0000 (la constante RESET_VECTOR definida en RISCV.vhd) hasta la dirección 0x0FFF.

2. El pin GPIO ocupa un solo byte en la dirección 0x1000 (sólo el bit 0).

3. La RAM abarca 4095 bytes desde la dirección 0x1001 hasta la dirección 0x1FFF.

Con esta premisa podemos hacer un sencillo linker script en el que, por ahora y por simplicidad, obviamos la parte de inicialización de variables globales:

/*
 * ROM  from 0x00000000 to 0x00000FFF
 * GPIO from 0x00001000 to 0x00001003
 * RAM  from 0x00001004 to 0x00001FFF
 */
SECTIONS {
    . = 0x00000000 ;
    .text : {
        startup.o (.startup)
        *(.text)
        *(.text.*)
        *(.rodata*)
    }
    . = 0x00001004 ;
    .data : {
        *(.data)
        *(.data.*)
    }
}

En este linker script obligamos a que el código de la sección ".startup" se aloje en el vector de reset (dirección 0, RESET_VECTOR al principio de la ROM), y el resto de código vaya a continuación. A continuación escribimos el fichero "startup.cc":

#include <stdint.h>

using namespace std;

extern int main();

void _startup() __attribute__((section(".startup"), naked));   // startup located at RESET_VECTOR

void _startup() {
    asm volatile (
        "la sp, 0x00001FFC"     // point SP to the end of SRAM (4 Kb ROM + 4 Kb RAM = 8 Kb total)
    );
    main();
    while (true)
        ;
}

En el que definimos una función "_startup", que alojamos en la sección ".startup" y declaramos "naked" para que el compilador no genere código preámbulo ni post-ámbulo. Dentro de esa función "_startup()" inicializamos el puntero de pila al final de la RAM e invocamos a la función "main" (declarada externa). Ahora ya podemos hacer nuestro "main.cc":

#include <stdint.h>

using namespace std;

#define  GPIO  *((uint8_t *) 0x00001000)

int main() {
    GPIO = 0;
    while (true) {
        for (int i = 0; i < 5; i++)
            ;
        GPIO = GPIO ^ 1;
    }
}

Donde definimos la función "main" y el resto del código, ahora sí, de la manera usual.

Se trata de un sencillo blinker en el que el bucle de espera es de sólo 5 iteraciones (lo hacemos así para poder depurarlo cómodamente con GHDL y GtkWave). A continuación compilamos el código con el compilador cruzado de RISC-V (hacemos "make"). Si desensamblamos el .elf generado mediante la utilidad "riscv32-none-elf-objdump" podremos ver el código ensamblador generado por el GCC:

# /opt/baremetalriscv/bin/riscv32-none-elf-objdump -M no-aliases,numeric -D main.elf

main.elf:     file format elf32-littleriscv


Disassembly of section .text:

00000000 <_Z8_startupv>:
   0:	00002137          	lui	x2,0x2
   4:	ffc10113          	addi	x2,x2,-4 # 1ffc 
   8:	008000ef          	jal	x1,10 
c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010
: 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...

Como se puede apreciar, el código empieza en la dirección 0, que es nuestro vector de reset. El Makefile también genera un fichero "main.bin" con el volcado binario del contenido que debe tener la ROM de programa de nuestro RISC-V. En el código fuente del proyecto se suministra un script de bash que permite convertir ficheros .bin a código VHDL apto para ser insertado en ROM.vhd.

Una vez generado el ROM.vhd (a partir del "main.bin" que generó, a su vez, el compilador C++), compilamos todo el VHDL con GHDL y analizamos con GtkWave la salida de señales generada (RISCV_tb.ghw).



Análisis de la ejecución

Como se puede apreciar, el compilador ha alojado la variable "i" en la dirección de memoria 4072 de la RAM (dirección absoluta 4096 + 4072 = 8168). El procesador realiza el conteo de 0 a 5 sobre esa variable, al llegar a 5, la salida GPIO cambia de valor y vuelta a empezar.

Si hacemos zoom podemos ver el proceso de ejecución instrucción a instrucción, por ejemplo, veamos las primeras 5 instrucciones del código de ejemplo:

00000000 <_Z8_startupv>:
   0:	00002137          	lui	x2,0x2
   4:	ffc10113          	addi	x2,x2,-4 # 1ffc 
   8:	008000ef          	jal	x1,10 
c: 0000006f jal x0,c <_Z8_startupv+0xc> 00000010
: 10: fe010113 addi x2,x2,-32 14: 00812e23 sw x8,28(x2) ...




Cada vez que avanza el contador de programa (señal "pcq"), se inicia un proceso de lectura en el controlador de memoria ("mcstartin") que culmina con un "mcreadyout" = 1. En ese momento se carga en el registro de instrucción ("irq") la instrucción de 32 bits que se acaba de leer. Tras este proceso de lectura de la instrucción se ejecuta la instrucción en sí:

- Dirección de memoria 0x00000000, instrucción "lui x2, 2" (irq = 00002137), carga un 2 en los 20 bits más significativos del registro x2. Avanza el PC 4 bytes.

- Dirección de memoria 0x00000004, instrucción "addi x2,x2,-4" (irq = ffc10113), resta 4 al registro x2. Avanza el PC 4 bytes.

- Dirección de memoria 0x00000008, instrucción "jal x1, 10" (irq = 008000ef), guarda en x1 la dirección de la siguiente instrucción (0x0000000C) y salta a la dirección 0x00000010. Nótese como el contador de programa pasa de 0x00000008 a 0x00000010.

- Y así con el resto de instrucciones.

Implementación en una FPGA

A continuación, viendo que en un entorno simulado (GHDL), el RISC-V funciona bien, podemos pasar a la segunda iteración, que es implementarlo todo en una FPGA real. Como nos hemos molestado en generar un código totalmente RTL (con registros biestables y lógica combinacional bien diferenciados en cada entidad VHDL) y, además, para el código RAM.vhd hemos seguido la recomendación de todos los fabricantes para el uso de bloques RAM internos de la FPGA, lo más probable es que no tengamos problemas.

En nuestro caso, llevando el código directamente al Quartus 13 para implementarlo en una FPGA Intel Cyclone II (es una FPGA antigua) todo compila sin problemas, la RAM es inferida de forma correcta (el entorno asigna bloques de RAM de la FPGA para nuestra RAM) y se usa aproximadamente un 66% de los recursos de la FPGA, lo que no está nada mal para una FPGA lanzada en 2004, hoy considerada antigua y de rango bajo.

La única modificación que se hace es en el código C++, que, en lugar de iterar hasta 5, hay que iterar hasta 250000 o valores de esa magnitud para que el parpadeo del led sea perceptible:



Dependencias

Para compilar el código VHDL sólo es necesario GHDL y para la visualización de la simulación generada por GHDL recomiendo usar GtkWave. Para compilar el código C++ sirve cualquier GCC cruzado para el target "riscv32-none-elf" (puedes compilar el tuyo propio siguiendo las indicaciones que puse hace tiempo en este mismo blog).

Para implementar el RISC-V en una FPGA se necesita el entorno de desarrollo del fabricante o alguna toolchain libre que sea compatible con tu FPGA. El código VHDL es RTL por lo que no debería haber problemas con ningún entorno.

Todo el código fuente (tanto VHDL como C++) está disponible en la sección soft.

[ añadir comentario ] ( 547 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 683 )
Programación embebida no bloqueante mediante máquinas de estado en C++. Caso de uso con el chip de sonido SN76489 
El uso de máquinas de estado en un recurso casi obligatorio a la hora de implementar código no bloqueante, para dotar a los sistemas de algún tipo de funcionalidad multi-tarea o simplemente como mecanismo de ahorro de energía. En este post se estudiará un caso de uso: la programación del chip de sonido SN76489, mediante esta técnica de desarrollo y utilizando parte del potencial del lenguaje C++ moderno para ello.

Descripción funcional

El objetivo es hacer un pequeño montaje basado en el ARM Cortex-M3 STM32F103C8T6 como MCU y en el chip de sonido SN76489 (3 canales de tono más un canal de ruido, utilizado en las antiguas consolas de Sega y en algunas placas de recreativas de los años 80 y 90).



Dicho montaje incluye dos pulsadores externos: uno para generar un sonido de disparo "láser" y otro para generar un sonido de metralleta (o parecido, dentro de las limitaciones del SN76489).

Sonido

El SN76489 es un chip de sonido extremadamente sencillo de programar: un bus de 8 bits con señales /WE y /CE (que en este caso se han cortocircuitado) y una señal READY de salida. Como se puede ver en el circuito anterior, el SN76489 se alimenta a 5 voltios, mientras que el STM32 se alimenta a 3.3 voltios, lo que, a priori puede resultar un problema de cara a la señal READY. Dicho problema, sin embargo, no lo es tal ya que, según la hoja de datos del fabricante, el pin READY del SN76489 es una salida en colector abierto, por lo que puede atacar perfectamente a una entrada del STM32 siempre y cuando la resistencia de pull-up correspondiente esté a 3.3 voltios.

Como se puede ver en su hoja de datos, para enviarle un byte, es necesario hacer los siguiente pasos:

1. Esperar a que la salida READY se ponga a 1 (colector abierto), lo que indica que el chip puede recibir un comando.

2. Colocar el byte que se quiere enviar en el bus de 8 bits.

3. Poner /CE y /WE a 0.

4. Esperar a que la salida READY se ponga a 0 (a masa), lo que significa que está procesando el comando

5. Esperar a que la salida READY se ponga de nuevo a 1 (colector abierto), lo que significa que el comando ha sido procesado y el chip está preparado para recibir más comandos.

Esta secuencia hay que realizarla por cada comando (byte) que se le quiera enviar al SN76489.

A la hora de diseñar la máquina de estados se ha utilizado el formalismo de la máquina de Mealy. En este formalismo, los estados están caracterizados sólo por sus etiquetas, mientras que las transiciones están caracterizadas por el par (entrada | salida), en este caso, "entrada" será la condición de entrada para que el proceso tome por ese arco y la "salida" serán las operaciones a realizar en ese salto:



"condición" puede ser vacía, lo que significará que siempre se toma ese arco, y "operaciones" también podrá ser vacía, lo que significará que no se ejecuta ninguna operación al tomar ese arco.



En la máquina de estados se han introducido estados adicionales al final que permiten introducir retardos entre en envío de un comando y el siguiente. Esta funcionalidad es muy útil para controlar los tiempos: tiempo de caida de los tonos de "láser", tiempo entre "balas" de la metralleta, etc. "tc" es la variable miembro "tickCounter" usada para esperas forzosas (el STM32 va más rápido que el SN76489) mientras que "dt" se corresponde con la variable miembro "delayTicks", usada para hacer las esperas entre el envío de un comando y el envío del siguiente.

Cada objeto SN76489 incluye una cola de comandos: las funciones miembro que hacen de API se encargan de preparar y meter en la cola (push) los comandos que será posteriormente enviados por la función miembro "tick" (la encargada de ejecutar la máquina de estados):

void SN76489::setToneGeneratorFrequency(uint8_t index, uint16_t frequency, int16_t sweepFrequencyIncrement, int16_t sweepTicksPerIncrement, int16_t sweepNumIncrements) {
    if (index > 2)        // 0, 1 or 2
        index = 2;
    frequency &= 0x03FF;  // 10 bit value
    this->txQueue.push({(uint8_t) (0x80 | (index << 5) | (frequency & 0x0F))});
    this->txQueue.push({(uint8_t) ((frequency >> 4) & 0x3F), sweepTicksPerIncrement});
    while (sweepNumIncrements > 0) {
        frequency += sweepFrequencyIncrement;
        sweepNumIncrements--;
        this->txQueue.push({(uint8_t) ((frequency >> 4) & 0x3F), sweepTicksPerIncrement});
    }
}


void SN76489::setToneGeneratorAttenuation(uint8_t index, uint8_t attenuation) {
    if (index > 2)        // 0, 1 or 2
        index = 2;
    this->txQueue.push({(uint8_t) (0x80 | (index << 5) | 0x10 | (attenuation & 0x0F))});
}


void SN76489::setNoiseGeneratorFrequency(NoiseGeneratorFrequency frequency, NoiseGeneratorFeedback feedback) {
    this->txQueue.push({(uint8_t) (0xE0 | ((static_cast<uint8_t>(feedback) & 0x01) << 2) | (static_cast<uint8_t>(feedback) & 0x03))});
}


void SN76489::setNoiseGeneratorAttenuation(uint8_t attenuation, int32_t delayTicks) {
    this->txQueue.push({(uint8_t) (0xF0 | (attenuation & 0x0F)), delayTicks});
}

Mientras que la función miembro "tick" deberá ser invocada desde el SYSTICK del microcontrolador (que se ejecuta varios cientos de veces por segundo). Dicha función miembro "tick" es la encargada de ejecutar la máquina de estados y de enviar los comandos al SN76489 de forma correcta.

Para separar la implementación de esta máquina de estados del hardware se define una clase abstracta "SN76489Interface", que deberá ser implementada por el "usuario" y que es la que en última instancia escribe en el bus de datos del SN76489, escribe el bit /WE y lee el bit READY del SN76489.

class SN76489Interface {
    public:
        virtual void sn76489SetByte(uint8_t v) = 0;
        virtual void sn76489SetWE(uint8_t v) = 0;
        virtual bool sn76489GetReady() = 0;
};

Cuando se invoca la función miembro "init" del objeto "SN76489", se le pasa un puntero a un objeto de tipo "SN76489Interface", para que el objeto "SN76489" pueda comunicarse con un hardware real a través de este interface.

Cada elemento de la cola de comandos es un par (comando, ticksRetardo), de tal manera que la máquina de estados (la función miembro "tick") envía el comando y espera una cantidad de ticks equivalente a "ticksRetardo" antes de enviar el siguiente comando de la cola (si lo hubiese). De esta manera es muy sencillo hacer los sonidos propuestos:

- Láser: Se configura el generador de tono 0 con una frecuencia alta, se espera un par de ticks, se envía una frecuencia más baja, se espera otro par de ticks, se envía otra fecuencia más baja, etc. Esta caida de frecuencia en el tiempo genera un efecto psicoacústico "percutido", como de disparo. Al final de la secuencia se pone el volumen el mínimo del generator tono 0.

- Metralleta: Se configura el generador de ruido y se manda 17 veces la secuencia: volumen máximo con espera de 20 ticks y silencio con espera de 30 ticks. Esta alternancia de ruido/silencio repetidas veces genera el sonido de "metralleta".

Nótese que los comandos descritos para cada sonido se empujan en la cola de comandos de forma simultánea uno detrás de otro, es la máquina de estados (función miembro "tick" de la clase "SN76489") la que se encarga de enviar los comandos y realizar la espera entre comando y comando a medida que va sacando comandos de la cola.

Teclado

De forma adicional, es necesario implementar un pequeño teclado de dos botones, de manera de un botón dispare el "láser" y el otro la "metralleta". Para este menester creamos también una clase Keyboard pero en este caso, como dicha clase sí va a estar vinculada directamente con un recurso hardware (esta es una decisión arbitraria), será una clase estática (con todas sus funciones miembro estáticas).

La función miembro "init" configurará dos pines GPIO y el controlador de interrupciones externas ETXI del STM32 para que genere una interrupción cada vez que se produzca un flanco de bajada en esos dos pines del microcontrolador. Como se puede comprobar en la declaración de la clase Keyboard, la función miembro estática "interrupt" se coloca en la sección ".exti" dentro del código objeto: esto hará que el linker script la coloque en el lugar adecuado para que se invoque al producirse una interrupción de GPIO.

#ifndef  __KEYBOARD_H__
#define  __KEYBOARD_H__


#include <stdint.h>


extern "C++" {
    namespace avelino {
        using namespace std;

        class KeyboardListener {
            public:
                virtual void buttonPressed(int8_t key) = 0;
        };

        class Keyboard {
            protected:
                static KeyboardListener *listener;
                static int8_t currentKey;
                static int32_t tickCounter;
                enum class Status : int8_t { IDLE, WAIT_AFTER_PRESSED };
                static Status status;
            public:
                static void init(KeyboardListener *listener);
                static void interrupt()  __attribute__ ((section(".exti")));
                static void tick();
        };
    }
}


#endif  // __KEYBOARD_H__

La función miembro "interrupt" hace muy poco: simplemente actualiza la variable miembro "key" y limpia los "pending bits" del microcontrolador para que la interrupción pueda volver a dispararse en el futuro. Uno puede pensar que con esto ya sería, de por sí, suficiente pero lo cierto es que no es así, ya que al tratarse de entradas desde el exterior, son propensas a experimentar rebotes y ruido, por lo que se hace necesario hacer "limpieza" de esa señal mediante una sencilla máquina de estados. Es la función miembro "tick" (invocada, como su homónima de SN76489, periódicamente desde el SYSTICK del microcontrolador) la encargada de ejecutar la máquina de estados que procesa los valores de esta variable "key".



Como la interrupción se dispara sólo ante flancos de bajada, lo único que hace la máquina de estados es simplemente esperar un tiempo prudencial (200 ejecuciones de ticks) antes de volver al estado de espera a que se detecte una nueva pulsación. De esta forma se eliminan las pulsaciones espúreas que saldrían su hubiese rebote mecánico.

La función miembro "buttonPressed" de la clase "ButtonListener" (que hereda de "KeyboardListener") es invocada cada vez que se detecta la pulsación de uno de los botones y el parámetro "key" indica qué botón se ha pulsado. Es aquí donde se realizan las llamadas a la API del objeto de clase "SN76489" para encolar los comandos que generan el sonido "láser" o encolar los comandos que generan el sonido de "metralleta", en función de la tecla pulsada.

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


void systick() {
    sn76489.tick();
    Keyboard::tick();
}


class ButtonListener : public KeyboardListener {
    public:
        virtual void buttonPressed(int8_t key);
};


void ButtonListener::buttonPressed(int8_t key) {
    if (key == 1) {   // láser
        sn76489.setToneGeneratorAttenuation(0, 0);   // full volume
        sn76489.setToneGeneratorFrequency(0, 477, 100, 5, 10);
        sn76489.setToneGeneratorAttenuation(0, 0x0F);   // min volume
    }
    else if (key == 2) {   // metralleta
        sn76489.setNoiseGeneratorFrequency(SN76489::NoiseGeneratorFrequency::N_512, SN76489::NoiseGeneratorFeedback::WHITE);
        for (int i = 0; i < 17; i++) {
            sn76489.setNoiseGeneratorAttenuation(0, 20);
            sn76489.setNoiseGeneratorAttenuation(0xFF, 30);
        }
    }
}

Como se puede apreciar, la función global "systick", que es invocada de forma periódica por el timer SYSTICK del microcontrolador, es la encargada a su vez de invocar las funciones miembro "tick" de "SN76489" y "Keyboard".

El hecho de que todo el software esté basado en interrupciones permite generar un código más elegante y más eficiente desde un punto de vista energético: la función main sólo tiene que inicializar los objetos y variables y dormirse a la espera de que se produzcan interrupciones (ya sea por GPIO como de SYSTICK):

int main() {
    // keyboard
    Keyboard::init(&buttonListener);
    // enable systick timer
    SYST_RVR = 0x0001FFFF;
    SYST_CVR = 0;
    SYST_CSR = 7;
    // sn76489
    sn76489GPIOInterface.init();
    sn76489.init(&sn76489GPIOInterface);
    sn76489.setToneGeneratorAttenuation(0, 0xFF); // min volume for tone generator 0
    sn76489.setToneGeneratorAttenuation(1, 0x0F); // min volume for tone generator 1
    sn76489.setToneGeneratorAttenuation(2, 0x0F); // min volume for tone generator 2
    sn76489.setNoiseGeneratorAttenuation(0x0F);   // min volume for noise generator
    while (true) {
        // WFI (wait for interrupt) instruction, enters low power mode
        asm volatile ("wfi");
    }
}

Nótese el bucle infinito con la instrucción que duerme la CPU "wfi" (Wait For Interrupt).

Características de C++ aprovechadas

- Plantillas estáticas en lugar de memoria dinámica: En sistemas embebidos hay que evitar el uso de memoria dinámica y es por ello que se ha creado la plantilla "StaticQueue" que define una cola circular estática cuyos parámetros de plantilla son el tipo base y la cantidad de elementos: Esta cola estática es la utilizada para almacenar los pares (comando, ticksRetardo) que utiliza la clase SN76489.

- Enumeradores fuertemente tipados ("enum class"): Los estados en las máquinas de estado no están definidos como constantes enteras sino como tipos "enum class" que es un tipo de enumerado fuertemente tipado introducido en C++11. El uso de este tipo de datos impide, por ejemplo, que se le asigne un entero aunque su tipo base sea un entero. Además, el código generado es igual de eficiente que si se utilizasen constantes enteras y se gana mucho en claridad en el código.

A continuación puede verse un vídeo del montaje en acción (con sonido):


Todo el código fuente está disponible en la sección soft.

[ añadir comentario ] ( 2282 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 2.9 / 1024 )
Estudio del uso de la sobrecarga de operadores para aritmética de punto fijo en C++ 
La aritmética de punto fijo es un mecanismo muy útil para la implementación de funciones matemáticas en procesadores sin unidad de coma flotante como microcontroladores y procesadores pequeños de 8 o 16 bits. A lo largo de este post se plantea el uso de la sobrecarga de operadores de C++ para facilitar las tareas de programación y mejorar la legibilidad del código cuando se usan tipos de punto fijo.

Introducción

La aritmética de punto fijo permite realizar operaciones con números fraccionarios mediante tipos enteros y operaciones enteras. En anteriores posts de este blog se ha hablado de forma extensa acerca de este tema, por lo que se remite a ellos a la persona interesada. A lo largo de este post usaré siempre valores de punto fijo Q16.16 (32 bits con 16 bits para la parte entera y 16 bits para la parte fraccionaria).

Implementación tradicional mediante macros

Tradicionalmente siempre he implementado la aritmética de punto fijo con un fichero de cabecera en el que defino "fixedpoint_t" como un "int32_t" y unas macros especiales para las operaciones de conversión de entero a punto fijo, de multiplicación y de división (las más "complejas"):

typedef int32_t fixedpoint_ct;

#define  FP_MUL(x, y)  ((int32_t) ((((int64_t) (x)) * ((int64_t) (y))) >> 16))
#define  FP_DIV(x, y)  ((int32_t) ((((int64_t) (x)) << 16) / ((int64_t) (y))))
#define  TO_FP(x)      (((int32_t ) (x)) << 16)

Como se puede apreciar, se trata de una implementación extremadamente sencilla y si lo que queremos es escribir una función que calcule:
$$\left({a \times b}\right) + \left({a \over b}\right)$$
Introduciríamos el siguiente código:

fixedpoint_ct fWithMacros(fixedpoint_ct a, fixedpoint_ct b) {
    return FP_MUL(a, b) + FP_DIV(a, b);
}

Y para los valores $a = 4$ y $b = 3$ la invocaríamos de la siguiente forma:

fixedpoint_ct v = fWithMacros(TO_FP(4), TO_FP(3));

Se trata de una implementación perfectamente válida aunque adolece de falta de claridad en el código: hay que leer con cuidado las operaciones aritméticas para no confundirse. Por otro lado es una implementación que tiene la ventaja de que en todo momento está claro que no estamos trabajando con un tipo "trivial".

Implementación basada en sobrecarga de operadores

Buscando un código más legible que el anterior, lo lógico es recurrir a la sobrecarga de operadores de C++. Definimos una clase "fixedpoint_t" en la que metemos un entero de 32 bits y definimos las cuatro operaciones básicas como "operator" dentro de la propia clase:

class fixedpoint_t {
    public:
        int32_t v;
        fixedpoint_t(int32_t x = 0) : v(x << 16) { };
        inline fixedpoint_t &operator = (const int32_t &x) { this->v = x << 16; return *this; };
        inline fixedpoint_t operator + (const fixedpoint_t &x) { fixedpoint_t ret; ret.v = this->v + x.v; return ret; };
        inline fixedpoint_t operator - (const fixedpoint_t &x) { fixedpoint_t ret; ret.v = this->v - x.v; return ret; };
        inline fixedpoint_t operator * (const fixedpoint_t &x) { fixedpoint_t ret; ret.v = (((int64_t) this->v) * ((int64_t) x.v)) >> 16; return ret; };
        inline fixedpoint_t operator / (const fixedpoint_t &x) { fixedpoint_t ret; ret.v = (((int64_t) this->v) << 16) / ((int64_t) x.v); return ret; };
};

Esta implementación nos permite ahora escribir la misma función de antes de una forma más legible:

fixedpoint_t fWithOperators(fixedpoint_t a, fixedpoint_t b) {
    return (a * b) + (a / b);
}

Y, de la misma manera, también nos permite invocarla de forma más legible:

fixedpoint_t v = fWithOperators(4, 3);

Sin embargo se podría pensar que una implementación así generaría mucho más código que la implementación basada en macros. Hagamos unas pruebas.

Comparativa

Si compilamos con gcc el código de ambas funciones sin opciones de optimización:

g++ -std=c++11 -o fp fp.cc

la diferencia es abismal:

_Z11fWithMacrosii:
    push   %rbp
    mov    %rsp,%rbp
    push   %rbx
    mov    %edi,-0xc(%rbp)
    mov    %esi,-0x10(%rbp)
    mov    -0xc(%rbp),%eax
    movslq %eax,%rdx
    mov    -0x10(%rbp),%eax
    cltq   
    imul   %rdx,%rax
    sar    $0x10,%rax
    mov    %eax,%ecx
    mov    -0xc(%rbp),%eax
    cltq   
    shl    $0x10,%rax
    mov    -0x10(%rbp),%edx
    movslq %edx,%rbx
    cqto   
    idiv   %rbx
    add    %ecx,%eax
    pop    %rbx
    pop    %rbp
    retq   

_Z14fWithOperators12fixedpoint_tS_:
    push   %rbp
    mov    %rsp,%rbp
    sub    $0x40,%rsp
    mov    %edi,-0x30(%rbp)
    mov    %esi,-0x40(%rbp)
    lea    -0x40(%rbp),%rdx
    lea    -0x30(%rbp),%rax
    mov    %rdx,%rsi
    mov    %rax,%rdi
    callq  4009cc <_ZN12fixedpoint_tdvERKS_>
    mov    %eax,-0x20(%rbp)
    lea    -0x40(%rbp),%rdx
    lea    -0x30(%rbp),%rax
    mov    %rdx,%rsi
    mov    %rax,%rdi
    callq  40098a <_ZN12fixedpoint_tmlERKS_>
    mov    %eax,-0x10(%rbp)
    lea    -0x20(%rbp),%rdx
    lea    -0x10(%rbp),%rax
    mov    %rdx,%rsi
    mov    %rax,%rdi
    callq  400952 <_ZN12fixedpoint_tplERKS_>
    leaveq 
    retq   

El compilador ha hecho caso omiso del "inline" de las funciones miembro de la clase "fixedpoint_t" y las ha implementado como funciones aparte en ensamblador. En este caso la implementación usando macros es más eficiente. Activemos ahora el primer nivel de optimización "-O1":

g++ -std=c++11 -O1 -o fp fp.cc

Voilà, ahora apenas notamos la diferencia en el código generado:

_Z11fWithMacrosii:
    movslq %edi,%rax
    movslq %esi,%rsi
    mov    %rax,%rcx
    imul   %rsi,%rcx
    sar    $0x10,%rcx
    shl    $0x10,%rax
    cqto   
    idiv   %rsi
    add    %ecx,%eax
    retq   

_Z14fWithOperators12fixedpoint_tS_:
    movslq %edi,%rdi
    movslq %esi,%rsi
    mov    %rdi,%rax
    shl    $0x10,%rax
    cqto   
    idiv   %rsi
    imul   %rdi,%rsi
    sar    $0x10,%rsi
    add    %esi,%eax
    retq   

La implementación utilizando la clase "fixedpoint_t" con los operadores sobrecargados genera un código igual de eficiente que la implementación basada en macros.

Como conclusión podemos sacar que no es necesario sacrificar legibilidad en aras de la velocidad de ejecución, siempre y cuando usemos correctamente los elementos del lenguaje y compilemos usando las opciones de optimización adecuadas.

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

[ añadir comentario ] ( 3127 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 10824 )
Implementación no bloqueante de un driver LCD 
A la hora de controlador un display LCD mediante el conocido adaptador I2C la gran mayoría de ejemplos disponibles por ahí implementan los estados de espera necesarios mediante retardos explícitos ("delays"). Dichas implementaciones están bien como prueba de concepto, pero no son deseables en entornos multitarea donde no podemos desperdiciar ciclos sólo esperando. En entornos reales se precisa de implementaciones no bloqueantes que hagan uso de timers e interrupciones.

El circuito

La interfaz de un display LCD estándar de caracteres es una interfaz paralelo de 8 bits, con 3 líneas de control adicionales (RS, EN y RW). Del bus paralelo de 8 bits pueden usarse sólo los 4 bits más significativos enviando de forma adecuada los comandos. Los circuitos de conversión a I2C que se venden habitualmente por AliExpress, Ebay y demás están basados en el conversor I2C/paralelo de 8 bits PCF8574 de Texas Instruments: del bus paralelo de dicho conversor se sacan los 4 bits más significativos para el bus paralelo del LCD y las tres señales de control para RS, EN y RW.



La configuración habitual en este tipo de módulos es esta:

PCF8574bit 7bit 6bit 5bit 4bit 3bit 2bit 1bit 0
LCDD7D6D5D4BLENRWRS

En la tabla se puede apreciar una cuarta señal de control etiquetada como BL (backlight) que controla el encendido del led de la luz trasera. Dicho led no forma parte de la circuitería estándar del display y ha sido introducido en versiones más recientes.

El problema

Los displays baratos de caracteres LCD que se encuentran en el mercado están basados en en un chip de Hitachi que no se caracteriza precisamente por su velocidad (probablemente debe ser uno de los chips más rentabilizados de toda la historia de Hitachi) y normalmente cada acceso debe estar seguido por una espera de uno a varios microsegundos, dependiendo del acceso realizado. A continuación puede verse la tabla de comandos de referencia del display, nótese la columna de la derecha ("Execution Time"):


(imagen extraida de https://learningmsp430.wordpress.com/2013/11/13/16x2-lcd-interfacing-in-8bit-mode/)

Cuando uno realiza una búsqueda en internet sobre códigos de ejemplo para control de displays LCD, la gran mayoría de los mismos (no digo todos porque considero que no los he visto todos, pero al menos todos los que yo he visto), implementan las esperas mediante retardos utilizando funciones "delay" o similares. Esta forma de implementación, aunque resulta simple, supone un desperdicio de ciclos e impide que el microcontrolador realice otras tareas de forma concurrente.

La solución no bloqueante

La solución ideal pasaría por una implementación basada en colas y en interrupciones. En este caso se ha implementado una máquina de estados que controla el flujo de datos I2C, el troceado de los bytes en dos nibbles y las esperas que hay que realizar entre un envío y el siguiente. Grosso modo, la solución sería la siguiente:

- Cada vez que se quiere escribir en el display, lo que se hace es escribir lo que se quiere mandar al display en una cola de datos, por lo que la función encargada de escribir regresa inmediatamente (no es bloqueante).

- El systick del microcontrolador cuando detecta que hay algún dato en la cola de datos inicia una máquina de estados que se encarga de trocear en byte en dos nibbles y enviarlos en tiempos diferentes, así hasta que la cola de datos quede vacía, en cuyo momento la máquina de estados pasa a modo "IDLE" y queda a la espera que de haya más datos en la cola.

- La capa I2C también está implementada como una cola de bytes de tal manera que si la capa LCD quiere escribir N bytes seguidos por I2C, los escribe de forma no bloqueante en la cola I2C (la función de escritura I2C también regresa inmediatamente) y se va vaciando a medida que la interrupción de callback de transmisión es llamada por el microcontrolador.

A continuación puede verse cómo ha quedado la máquina de estados del controlador LCD:



El código no queda tan sencillo a simple vista pero se trata, sin duda, de una implementación más eficiente.

#include "LCD.H"

using namespace avelino;
using namespace std;

void LCD::init(uint8_t address) {
    this->address = address;
    this->timerCounter = 5;
    this->status = LCD::Status::WAIT_AFTER_INIT;
    this->queue.push(LCD::QueueItem(0x33, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x32, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x28, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x08, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x01, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x06, LCD::IsCommand::YES));
    this->queue.push(LCD::QueueItem(0x0C, LCD::IsCommand::YES));
}

void LCD::tick() {
    Status localStatus = this->status;
    do {
        this->status = localStatus;
        if (localStatus == LCD::Status::WAIT_AFTER_INIT) {
            if (this->timerCounter > 0)
                this->timerCounter--;
            else
                localStatus = LCD::Status::IDLE;
        }
        else if (localStatus == LCD::Status::IDLE) {
            if (!this->queue.empty()) {
                I2CManager::deviceAddress = this->address << 1;
                localStatus = LCD::Status::SEND_FIRST_NIBBLE;
            }
        }
        else if (localStatus == LCD::Status::SEND_FIRST_NIBBLE) {
            uint8_t byte = this->queue.head().byte;
            LCD::IsCommand isCommand = this->queue.head().isCommand;
            I2CManager::txQueue.push((byte & 0xF0) | ((isCommand == LCD::IsCommand::YES) ? LCD::RS_0 : LCD::RS_1) | LCD::RW_0 | LCD::EN_1 | LCD::BL_1);
            I2CManager::txQueue.push((byte & 0xF0) | ((isCommand == LCD::IsCommand::YES) ? LCD::RS_0 : LCD::RS_1) | LCD::RW_0 | LCD::EN_0 | LCD::BL_1);
            I2CManager::send();
            localStatus = LCD::Status::WAIT_FIRST_NIBBLE_SENT;
        }
        else if (localStatus == LCD::Status::WAIT_FIRST_NIBBLE_SENT) {
            if (I2CManager::txDone) {
                this->timerCounter = 1;
                localStatus = LCD::Status::WAIT_TICK_AFTER_FIRST_NIBBLE_SENT;
            }
        }
        else if (localStatus == LCD::Status::WAIT_TICK_AFTER_FIRST_NIBBLE_SENT) {
            if (this->timerCounter > 0)
                this->timerCounter--;
            else
                localStatus = LCD::Status::SEND_SECOND_NIBBLE;
        }
        else if (localStatus == LCD::Status::SEND_SECOND_NIBBLE) {
            uint8_t byte = this->queue.head().byte << 4;
            LCD::IsCommand isCommand = this->queue.head().isCommand;
            this->queue.pop();
            I2CManager::txQueue.push((byte & 0xF0) | ((isCommand == LCD::IsCommand::YES) ? LCD::RS_0 : LCD::RS_1) | LCD::RW_0 | LCD::EN_1 | LCD::BL_1);
            I2CManager::txQueue.push((byte & 0xF0) | ((isCommand == LCD::IsCommand::YES) ? LCD::RS_0 : LCD::RS_1) | LCD::RW_0 | LCD::EN_0 | LCD::BL_1);
            I2CManager::send();
            localStatus = LCD::Status::WAIT_SECOND_NIBBLE_SENT;
        }
        else if (localStatus == LCD::Status::WAIT_SECOND_NIBBLE_SENT) {
            if (I2CManager::txDone) {
                this->timerCounter = 1;
                localStatus = LCD::Status::WAIT_TICK_AFTER_SECOND_NIBBLE_SENT;
            }
        }
        else if (localStatus == LCD::Status::WAIT_TICK_AFTER_SECOND_NIBBLE_SENT) {
            if (this->timerCounter > 0)
                this->timerCounter--;
            else
                localStatus = LCD::Status::IDLE;
        }
    } while (localStatus != this->status);
}


void LCD::write(const char *s, int16_t size, LCD::IsCommand isCommand) {
    while ((*s != 0) && ((size < 0) || (size > 0))) {
        this->queue.push(QueueItem(*s, isCommand));
        s++;
        if (size > 0)
            size--;
    }
}

La función miembro "tick" es invocada desde la interrupción systick del microcontrolador en "main.cc":

LCD lcd;

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

void systick() {
    lcd.tick();
}

Nótese que las colas (tanto la cola I2C como la cola LCD) están implementadas usando colas circulares estáticas a través de una plantilla ("StaticQueue.H").

#ifndef  __STATICQUEUE_H__
#define  __STATICQUEUE_H__

#include <stdint.h>

extern "C++" {
    namespace avelino {
        using namespace std;

        template <typename T, int32_t N>
        class StaticQueue {
            public:
                T data[N];
                int32_t headIndex;
                int32_t tailIndex;
                void push(const T &v);
                const T &head() { return this->data[this->headIndex]; };
                void pop();
                bool empty() { return (this->headIndex == this->tailIndex); };
                StaticQueue() : headIndex(0), tailIndex(0) { };
        };

        template <typename T, int32_t N>
        void StaticQueue<T, N>::push(const T &v) {
            this->data[this->tailIndex] = v;
            this->tailIndex++;
            if (this->tailIndex == N)
                this->tailIndex = 0;
        }

        template <typename T, int32_t N>
        void StaticQueue<T, N>::pop() {
            this->headIndex++;
            if (this->headIndex == N)
                this->headIndex = 0;
        }
    }
}

#endif  // __STATICQUEUE_H__

Se ha utilizado en varios sitios el "enum class", que permite trabajar con enumerados fuertemente tipados (introducido en el estándar C++11).

En la sección soft puede descargarse todo el código fuente.



[ añadir comentario ] ( 1406 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 10883 )
Programación bare metal de un SoC: Prueba de concepto sobre la Orange Pi Zero Plus 
Los SoCs están diseñados para ejecutar sistemas operativos completos (Linux, Android, etc.). La programación bare metal de este tipo de chips es una tarea complicada y poco agradecida (normalmente no se justifica el uso de un SoC sin sistema operativo, para eso están los microcontroladores), sin embargo estos proyectos brindan una oportunidad única para conocer los entresijos del chip y de paso entender mejor cómo funcionan los SoCs en general.

Orange Pi Zero Plus

El corazón de la placa Orange Pi Zero Plus (la que se ha utilizado para esta prueba de concepto) es un SoC H5 de la marca AllWinner. Se trata de un ARM Cortex-A53 de cuadruple núcleo que implementa la arquitectura ARMv8-A (64 bits). En el arranque, todos los ARM de 64 bits arrancan en modo 32 bits, así que por simplicidad se ha decidido que la prueba de concepto se haga en el modo de arranque compatible ARMv7-A (32 bits, sin pasar a modo 64 bits) y utilizando sólo el primer núcleo (en el arranque sólo está operativo el núcleo 0, los núcleos 1, 2 y 3 están desactivados).

La secuencia de arranque del H5

El H5 implementa varias formas y modos de arranque, sin embargo, en la placa Orange Pi Zero Plus el modo de arranque que se usa es el que busca en la tarjeta de memoria (MicroSD) el bootloader. En el caso habitual, para cargar un sistema operativo, dicho bootloader sera el U-Boot u otro similar. Aunque en el manual de usuario del H5 se especifica de forma más detallada, se puede simplificar diciendo que al arrancar el H5 ejecuta una "boot ROM" (BROM) que no es modificable y que se encuentra cableada dentro del chip. Esta ROM se encarga de inicializar la tarjeta de memoria, de cargar el SPL (Second Program Loader) desde la tarjeta de memoria en la RAM y de ejecutar dicho código una vez está cargado en RAM. En terminología H5 este SPL hace las veces de boot loader.

En http://linux-sunxi.org/Bootable_SD_card#SD_Card_Layout se especifica la distribución de los datos en la tarjeta de memoria, dónde debe estar alojado el SPL (offset 8192) y lo que puede ocupar como máximo (32 KBytes). Según la documentación oficial (http://linux-sunxi.org/BROM#U-Boot_SPL_limitations), este SPL no puede ser código tal cual, sino que debe tener un formato y una especie de firma digital especial. Dicho formato se encuentra documentado y un programador desarrolló hace tiempo una pequeña utilidad llamada "mksunxiboot", open source, programada en C, que, a partir de un binario estándar, genera un binario firmado y reconocible por parte del SoC como un SPL válido (la firma no deja de ser una estructura de datos en la cabecera más un checksum). El código fuente de dicha utilidad (es un único fichero en C) se puede encontrar en https://github.com/amery/mksunxiboot/.

Haciendo nuestro propio SPL

Para hacer la prueba de concepto bare metal bastará con hacer un pequeño programa que haga de SPL. En este caso se ha optado por hacer el típico blinker que actúe sobre una de las salidas GPIO de la placa a la que conectaremos un led para comprobar que el invento funciona. Usaremos la salida GPIO12 (se podría usar cualquier otra) que se corresponde con el pin 3 del puerto de expansión de la Orange Pi Zero Plus (http://linux-sunxi.org/Orange_Pi_Zero_P ... nsion_Port).



A continuación necesitaremos cualquier toolchain "arm-none-eabi" que tengamos a mano, puede ser tanto descargada de algún repositorio como compilada por nosotros mismos (ver post en este mismo blog). Se trata de una toolchain diseñada para hacer programas bare metal para arquitecturas ARM de 32 bits. En principio hay dos formas de hacerlo: la elegante y lenta y la "sucia" y rápida. La forma elegante y lenta obliga a escribir un linker script que nos permita pasar casi cualquier programa que queramos a un SPL, sin embargo la forma sucia y rápida, aunque no da tanta libertad, sí que nos permite hacer la prueba de concepto de forma rápida.

#include <stdint.h>

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

void spl() {
    volatile uint64_t n;
    const uint64_t WAIT = 20000ULL;
    const uint32_t CCU_BASE = 0x01C20000;
    *((volatile uint32_t *) (CCU_BASE + 0x0068)) |= 0x00000020;   // PIO clock enable
    const uint32_t PIO_BASE = 0x01C20800;
    *((volatile uint32_t *) (PIO_BASE + 0x0004)) = 0x77717777;  // PA12 como pin de salida
    while (true) {
        *((volatile uint32_t *) (PIO_BASE + 0x0010)) = 0x00001000;   // PA12 := 1
        for (n = 0; n < WAIT; n++)
            ;
        *((volatile uint32_t *) (PIO_BASE + 0x0010)) = 0x00000000;   // PA12 := 0
        for (n = 0; n < WAIT; n++)
            ;
    }
}


Como se puede apreciar, escribimos el código en una función a la que podemos ponerle el nombre que queramos y, mediante atributos del compilador, le decimos que debe estar en la sección ".spl" (esta etiqueta es arbitraria, la sección podría llamarse ".pepejuan"). Nótese que no estamos usando variables globales y no estamos referenciando nada que esté fuera de la propia función en sí (todo el código está autocontenido). Esta limitación es importante, pues, como se verá más adelante, sólo usaremos como código SPL lo que esté dentro de la sección ".spl" que se ha definido en tiempo de compilación.

A la hora de escribir el código se recurrió al manual de usuario oficial del H5 (https://linux-sunxi.org/File:Allwinner_H5_Manual_v1.0.pdf): en la sección "CCU" (sub sección "Gating and reset") se explica cómo habilitar el reloj para el módulo PIO (el encargado de controlar los pines GPIO), mientras que en la sección "Port Controller (CPUx-PORT)" se explica como habilitar y usar los pines GPIO. Para compilar y generar el fichero binario que transferiremos a la tarjeta MicroSD haremos lo siguiente:

arm-none-eabi-g++ -mtune=cortex-a7 -fno-exceptions -fno-rtti -nostartfiles -c -o spl.o spl.cc
arm-none-eabi-objcopy -O binary -j .spl spl.o spl.bin
mksunxiboot spl.bin spl_with_signature.bin


Primero se genera el fichero "spl.o", a continuación usando la utilidad objcopy extraemos en forma binaria el código que se encuentra en la sección ".spl" desde dentro de "spl.o" hacia "spl.bin" y, como tercer paso, invocamos la utilidad "mksunxiboot" para generar, a partir de "spl.bin", un "spl_with_signature.bin" que sí puede ser transferido tal cual a la tarjeta MicroSD. Como se puede apreciar, se le dice al compilador que genere código compatible Cortex-A7 (para que genere código siguiendo la arquitectura ARMv7-A). Para los que tengan curiosidad por el código que ha generado el compilador, se puede desensamblar dicho código mediante el siguiente comando:

arm-none-eabi-objdump -D spl.o


A continuación cogemos el fichero "spl_with_signature.bin" que acabamos de generar, lo copiamos tal cual a partir del offset 8192 de la tarjeta de memoria y arrancamos la Orange Pi Zero Plus con dicha tarjeta de memoria insertada:

dd if=spl_with_signature.img of=/dev/sdb bs=1024 seek=8

Et voilà :



Todo el código fuente está disponible en la sección soft.

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

<< <Anterior | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Siguiente> >>