Programación bare metal del SoC Allwinner D1 
Al igual que se hizo en una anterior entrega con el SoC Allwinner H5, un ARM Cortex-A53 (64 bits), esta vez toca hacer prueba de concepto de bare metal con el SoC Allwinner D1, uno de los más utilizados en placas tipo SBC basadas en RISC-V. El D1 es un RISC-V de 64 bits que tiene un mecanismo de arranque muy parecido al usado por los otros SoCs de Allwinner. Programar a nivel bare metal un SoC de estas características tiene poco sentido práctico más allá de la experimentación, de hecho para aplicaciones serias siempre es recomendable utilizar un RTOS o un Linux, pero pelearse con las interioridades de un SoC a nivel bare metal permite aprender mucho sobre estos chips y sobre las secuencias de arranque de los procesadores en general.

MangoPi

En este caso, se utilizará una placa Mango Pi, con un tamaño muy parecido a la RPi Zero, pero que utiliza un SoC D1, aunque la prueba descrita en este post se podrá realizar en cualquier SBC que utilice un SoC D1.

Secuencia de arranque del D1

Se recomienda echar un vistazo al post anterior donde se aborda el mismo objetivo, pero con el SoC Allwinner H5 (un ARM Cortex-A53). Al igual que el SoC H5 y otros SoCs de Allwinner, lo que hace el procesador cuando arranca es, básicamente:

1.- Copiar el contenido de los primeros 32 Kbytes que empiezan en el sector 16 de la tarjeta de memoria en una zona de la memoria estática interna (más info).

2.- Comprobar el checksum del código, calculado en la cabecera de esos 32 Kbytes (más info).

3.- Si el checksum es correcto, ejecuta la primera instrucción de esos 32 Kbytes (que suele ser una instrucción de salto "jal", por lo que el código de arranque en sí normalmente se coloca justo después de la cabecera de esos 32 Kbytes).

La secuencia es igual a la utilizada en los SoC ARM de Allwinner (como el H5) con la única diferencia de que la instrucción de salto (los 4 primeros bytes de ese bloque) se codifican como la instrucción "jal" de RISC-V (es la misma instrucción tanto para RV32I como para RV64I).

A continuación puede verse el código máquina correspondiente a la instrucción "jal" (Jump And Link) de RISC-V que se coloca en los 4 primeros bytes de la cabecera:
bit                                                 bit
31 ......................... 12 +--rd---+ 6 5 4 3 2 1 0
     imm[20|10:1|11|19:12]      0 0 0 0 0 1 1 0 1 1 1 1
                                \-------/ \-----------/
                                    |           |
                                    |           +-------- opcode
                                    +-------------------- reg destino
                                                     (en r0 se guarda la
                                                       dirección de la
                                                     siguiente instrucción
                                                            a "jal")

"imm" es el offset al cual debe saltarse (en complemento a 2, puede ser un valor negativo). Nótese que el valor de "imm" es de 21 bits pero se descarta el bit menos significativo, puesto que el código en RISC-V siempre debe alojarse en direcciones pares de memoria.

Parchear la herramienta mksunxiboot

La herramienta mksunxiboot se encarga de calcular la cabecera con el correspondiente checksum para que el SoC arranque correctamente el código que queramos. Es un código en C muy sencillo que compilamos y ejecutamos en el ordenador, recibe como entrada un fichero ".bin" con el código de arranque y genera un ".bin" con el código de arranque precedido con la cabecera necesaria (con el checksum dentro) para que el SoC reconozca como "correcto" el código y lo ejecute.

La herramienta original está diseñada para SoCs ARM así que para adecuarla al D1 sólo hace falta cambiar la instrucción de salto para que, en lugar de ser una instrucción de salto ARM, sea una instrucción de salto RISC-V.

En "mksunxiboot.c" se sustituye el siguiente código:
    img.header.jump_instruction =	/* b instruction */
        0xEA000000 | /* jump to the first instruction after the header */
        (
            (sizeof(boot_file_head_t) / sizeof(int) - 2)
            & 0x00FFFFFF
        );

Por este otro:
    u32 code_offset = sizeof(boot_file_head_t);
    img.header.jump_instruction =	/* risc-v "jal" instruction */
        (((code_offset >> 20) & 1) << 31) |
        (((code_offset >> 1) & 0x3FF) << 21) |
        (((code_offset >> 11) & 1) << 20) |
        (((code_offset >> 12) & 0xFF) << 12) |
        0x00000006F;

Y ya está, compilando con:
gcc -o mksunxiboot mksunxiboot.c

Tenemos nuestra herramienta preparada para "firmar" nuestro código RISC-V para el D1.

SPL

En terminología Allwinner, el SPL (o Second Program Loader) es como se llama al código que se ejecuta justo después de la ROM del D1 (el que se le pasa a "mksunxiboot" para que lo firme) y que es el código que se pone en la tarjeta de memoria justo a continuación de la cabecera que calcula "mksunxiboot".

    ; sector 16 de la tarjeta de memoria
    jal @inicioCódigoSPL     ; primeros 4 bytes de la cabecera
    ... resto de la cabecera calculada por la utilidad "mksunxiboot" ...
@inicioCódigoSPL:
    ... nuestro código de arranque o SPL ...


Haremos un SPL extremadamente sencillo que se limite a hacer parpadear un pin GPIO al que se le conecta un LED.

#include <stdint.h>

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

#define  WAIT       40000000ULL
#define  GPIO_BASE  0x02000000
#define  PC_CFG     *((uint32_t *) (GPIO_BASE + 0x0060))
#define  PC_DAT     *((uint32_t *) (GPIO_BASE + 0x0070))
#define  PC_PULL0   *((uint32_t *) (GPIO_BASE + 0x0084))

void spl() {
    PC_CFG = (PC_CFG & 0xFFFFFF0F) | 0x00000010;       // PC1 = output
    PC_PULL0 = (PC_PULL0 & 0xFFFFFFF3) | 0x00000008;   // PC1 = pull-down
    while (true) {
        PC_DAT |= 0x00000002;     // PC1 = 1
        for (uint64_t n = 0; n < WAIT; n++)
            ;
        PC_DAT &= 0xFFFFFFFD;     // PC1 = 0
        for (uint64_t n = 0; n < WAIT; n++)
            ;
    }
}

Como se puede comprobar, el código no hace uso de variables globales ni de llamadas a funciones o librerías, para que sea autocontenido y no genere dependencias externas. Algunos aspectos importantes:

- El nombre de la función es irrelevante, no tiene por qué ser "main". De hecho el código no se enlazará, sólo se compilará.

- Mediante atributos, indicamos al compilador que el código de la función debe alojarse en una sección llamada ".spl" (nombre arbitrario) y que debe ser "naked" (el compilador no generará código de preámbulo o postámbulo, ya que es una función a la que se llega con un salto, no con una llamada).

- El código simplemente inicializa la línea GPIO correspondiente al pin PC1 (el pin número 22 del conector de 40 pines de la Mango Pi), como un GPIO de salida y a continuación se queda en un bucle infinito emitiendo un 1 y un 0 alternativamente, para que pueda parpadear un led conectado a PC1.

Una vez compilado (generado el fichero "spl.o"), se utiliza la utilidad "objcopy" para extraer el código de la sección ".spl" y meterlo en un fichero binario crudo "spl.bin". A continuación, este fichero "spl.bin" es pasado por la utilidad "mksunxiboot" que modificamos anteriormente, para que genere otro "spl_with_signature.bin" que estará firmado y, por tanto, podrá ya ser ejecutado por el D1.
riscv64-none-elf-g++ -mtune=thead-c906 -fno-exceptions -fno-rtti -nostartfiles -c -o spl.o spl.cc
riscv64-none-elf-objcopy -O binary -j .spl spl.o spl.bin
./mksunxiboot spl.bin spl_with_signature.bin

El último paso consiste en grabar este fichero "spl_with_signature.bin" directamente en el sector 16 de una tarjeta de memoria (nótese que cada sector en una tarjeta de memoria mide 512 bytes, por tanto el sector 16 es el offset 8192 = 1024 * 8 a nivel de byte):
dd if=spl_with_signature.bin of=/dev/sdX bs=1024 seek=8

Si colocamos un LED entre PC1 y masa (usar siempre una resistencia en serie) y arrancamos el D1 con la tarjeta de memoria que acabamos de tostar, podremos ver nuestro blinker bare metal en acción.



Todo el código en la sección soft.

[ añadir comentario ] ( 10 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 2.9 / 12 )
Módulo de movimiento lento para el belén con motor paso a paso en C++ 
Es Navidad y toca montaje electrónico para el Belén. Este año trataremos de hacer un generador de movimiento lento que permita simular el paso de los tres reyes magos y hacer que estos transiten desde un extremo del belén hasta el pesebre moviéndose de forma lenta y autónoma a lo largo de los días.

Objetivo

La idea es que los tres reyes magos (tres figuras que van a lomos de sus respectivos camellos) se vayan desplazando lentamente desde un extremo del montaje del belén hasta el otro extremo (donde está situado el pesebre) a lo largo de los días y de forma imperceptible. De esta forma cada día estarán más cerca de su "destino".



Descripción funcional

Se plantea un montaje basado en un microcontrolador (en este caso un RISC-V), tres botones ("modo", "avance" y "retroceso"), un display de 7 segmentos y un motor paso a paso de 4 fases con reductora (4076 pasos por vuelta). El módulo funcionará en 3 modos entre los que se alternará pulsando uno de los botones:

- Modo de movimiento libre del motor paso a paso: en este modo se muestra la letra "c" en el display de 7 segmentos y usando los dos botones restantes se podrá mover rápidamente (aprox cinco vueltas por minuto) el motor paso a paso.

- Modo de configuración de días: En este modo se configurará la cantidad de días (en el display de 7 segmentos) que se desea que dure el movimiento completo del motor paso a paso.

- Modo de reposo (display de 7 segmentos apagado): Al entrar en este modo, el microcontrolador calcula, a partir de los pasos avanzados (o retrocedidos) en el modo 1 y a partir de los días configurados en el modo 2, la velocidad (lenta) a la que tendrá que moverse el motor paso a paso para que en el transcurso de esos días el motor vuelva a la posición de encendido. El montaje se quedaría en este modo hasta que el motor paso a paso llegue a su posición inicial (la que tenía cuando se encendió).

Cuando el montaje se enciende, el modo en el que arranca es el modo 1.

Un caso de uso típico sería el siguiente. El motor paso a paso se alojará en el pesebre, un hilo tendrá un extremo conectado al eje del motor (en el que se enrollará como si fuese el carrete de una caña de pescar) y el otro extremo de ese hilo estará pegado a una cartulina donde estarán alojados los reyes magos:

1. Encendemos el montaje (modo 1) y hacemos girar el motor paso a paso de tal forma que la cartulina con los reyes magos quede en lo que será el final de su recorrido (pegados al pesebre y con el hilo lo más enrollado posible al eje del motor).

2. Apagamos el módulo.

3. Volvemos a encender el módulo para que quede registrada la posición actual del motor como posición "reposo" o de "destino".

4. Con el módulo recién encendido (de nuevo modo 1), volvemos a mover el motor de forma rápida pero en el sentido contrario al dado en el paso 1, para que vaya soltando hilo mientras vamos tirando de los reyes magos (para mantener el hilo algo tenso) hasta que hayamos colocado los reyes magos en su posición inicial. Al estar en modo 1, el microcontrolador habrá estado contabilizando la cantidad de pasos que hemos tenido que hacer mover al motor para que los reyes magos lleguen a su posición inicial.

5. Pulsamos el botón de configuración para pasar al modo 2 y usamos los otros dos botones para definir la cantidad de días que queremos que tarden los reyes magos en llegar al pesebre.

6. Pulsamos de nuevo el botón de configuración para pasar el modo 3 (se apagará el display de 7 segmentos, aunque el led azul sigue parpadeando para que sepamos que el montaje sigue funcionando) y lo dejamos encendido así durante todos los días que dure el montaje. En este modo el motor paso a paso se irá moviendo poco a poco de tal manera que cuando haya pasado la cantidad de días configurada en el modo 2, los reyes magos hayan llegado al pesebre.

7. No se debe apagar el montaje ya que no se almacena el estado en ningún tipo de memoria no volátil. Si hay un corte de corriente en el montaje, hay que volver a configurarlo empezando por el paso 1.

Circuito



Como se puede ver se trata de un montaje muy sencillo que hace uso sólo de las características GPIO del microcontrolador y, por tanto, aunque está hecho con un RISC-V, el montaje es fácilmente portable a cualquier otro microcontrolador con la suficiente cantidad de pines GPIO: 3 de entrada y 11 de salida (7 para el display y 4 para el motor paso a paso).

- El teclado consta de 3 botones ("modo", "-" y "+") con circuito antirrebote básico.

- El display de 7 segmentos es uno sencillo de cátodo común.

- El motor paso a paso es un 28BYJ-48 de 4 fases, a 5 voltios y controlado por un chip ULN2003. El motor tiene una reductora que hace que requiera 4076 pasos para completar una vuelta.

Diseño del software

El software está desarrollado en C++ y el diagrama de clases es el siguiente:



Las clases están diseñadas para ser lo más independientes posible del hardware final en el que se ejecuten. De cara a la implementación específica para el GD32VF103, se definieron las siguientes clases:



"Heartbeat" es una clase que se encarga de hacer perpadear el led azul de la placa del GD32VF103 mientras que las clases My... son las encargadas de configurar y leer o escribir en los GPIO correspondientes a cada funcionalidad (teclado, 7 segmentos y motor paso a paso, respectivamente).

Por ejemplo, la clase "Stepper" es la encargada de mover el motor paso a paso. La velocidad está dada por el valor de la variable miembro "ticksPerStep", que indica la cantidad de veces que debe ejecutarse "run()" para avanzar en un paso el motor. Teniendo el cuenta que cada "run()" se ejecutan cada 10 milisegundos (100 veces por segundo), se puede calcular el valor de "ticksPerStep" a partir de la velocidad que queramos imprimirle al motor:

#include "Stepper.H"

using namespace avelino;
using namespace std;

void Stepper::init() {
    this->gpioConfigure();
    this->gpioWrite(0);
}

void Stepper::start() {
    this->doStart = true;
}

void Stepper::stop() {
    this->doStart = false;
}

void Stepper::run() {
    Status localStatus = this->status;
    do {
        this->status = localStatus;
        if (localStatus == Status::STOPPED) {
            if (this->doStart) {
                this->gpioWrite(this->lastMask);
                this->timer = this->ticksPerStep;
                //cout << "Stepper::run: STOPPED --> RUNNING_1, timer=" << this->timer << endl;
                localStatus = Status::RUNNING;
            }
        }
        else if (localStatus == Status::RUNNING) {
            this->timer--;
            int32_t threshold = (int32_t) this->ticksPerStep - (int32_t) this->getTicksPerPulse();
            if (this->timer < threshold)
                this->gpioWrite(0);
            if (this->timer <= 0) {
                this->lastMask = this->getNextMask(this->lastMask, this->wise);
                this->gpioWrite(this->lastMask);
                if (this->listener != NULL)
                    this->listener->stepDone(*this);
                this->timer = this->ticksPerStep;
                //cout << "Stepper::run: RUNNING_1 --> RUNNING_2, timer=" << this->timer << endl;
            }
            if (!this->doStart) {
                this->gpioWrite(0);
                //cout << "Stepper::run: RUNNING_1 --> STOPPED" << endl;
                localStatus = Status::STOPPED;
            }
        }
    } while (localStatus != this->status);
}

Como se puede ver, la clase "Stepper" se encarga de la gestión de los avances en los pasos del motor, mientras que la clase "MyStepper" se encarga de implementar las operaciones de bajo nivel relacionadas con el motor:

#include "MyStepper.H"

using namespace avelino;
using namespace std;

#define  RCU_APB2EN   *((uint32_t *) 0x40021018)
#define  GPIOA_CTL0   *((uint32_t *) 0x40010800)
#define  GPIOA_OCTL   *((uint32_t *) 0x4001080C)
#define  GPIOB_CTL0   *((uint32_t *) 0x40010C00)
#define  GPIOB_OCTL   *((uint32_t *) 0x40010C0C)

void MyStepper::gpioConfigure() const {
    // enable clock on ports A and B
    RCU_APB2EN = RCU_APB2EN | (((uint32_t) 3) << 2);
    // configure A5, A6 and A7 as push/pull output
    GPIOA_CTL0 = (GPIOA_CTL0 & 0x000FFFFF) | 0x22200000;
    // configure B0 as push/pull output
    GPIOB_CTL0 = (GPIOB_CTL0 & 0xFFFFFFF0) | 0x00000002;
}

void MyStepper::gpioWrite(uint32_t mask) const {
    if (mask & 1)
        GPIOA_OCTL |= (((uint32_t) 1) << 5);
    else
        GPIOA_OCTL &= ~(((uint32_t) 1) << 5);
    if (mask & 2)
        GPIOA_OCTL |= (((uint32_t) 1) << 6);
    else
        GPIOA_OCTL &= ~(((uint32_t) 1) << 6);
    if (mask & 4)
        GPIOA_OCTL |= (((uint32_t) 1) << 7);
    else
        GPIOA_OCTL &= ~(((uint32_t) 1) << 7);
    if (mask & 8)
        GPIOB_OCTL |= ((uint32_t) 1);
    else
        GPIOB_OCTL &= ~((uint32_t) 1);
}

uint32_t MyStepper::getNextMask(uint32_t prevMask, Wise wise) const {
    if (wise == Stepper::Wise::CLOCK)
        return ((prevMask << 1) | ((prevMask >> 3) & 1)) & 0x0F;
    else
        return ((prevMask >> 1) | ((prevMask & 1) << 3)) & 0x0F;
}

const uint32_t MyStepper::getStepsPerRevolution() const {
    return 4076;
}

const uint32_t MyStepper::getTicksPerPulse() const {
    return 20;   // 20 * 10 = 200 ms
}

Las tareas de MyStepper se limitan a:

- Calcular una máscara de paso a partir de la anterior.

- Configurar las salidas GPIO.

- Emitir una máscara a las salidas GPIO.

De esta manera se mantiene separado el código C++ independiente del hardware del código C++ dependiente del hardware. De hecho la subcarpeta "linux" implementa versiones simuladas de Stepper, Keypad y SevenSegments, que permiten depurar en un terminal de Linux el funcionamiento del módulo antes de tostarlo en el microcontrolador.

Los tres diferentes modos de funcionamiento del módulo están representados por clases que heredan de la clase "State": una clase por cada modo. La clase "ConfigureStepsState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 1, la clase "ConfigureDaysState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 2, mientras que la clase "IdleState" es la clase cuyo "run()" se ejecuta mientras estamos en el modo 3 o modo de reposo. La clase "StateManager" se encarga de gestionar la "salida" de un modo y la "entrada" en el siguiente modo.

#ifndef  __STATE_H__
#define  __STATE_H__

#include "Task.H"

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

        template <typename T>
        class State : public Task {
            protected:
                T *data;
            public:
                virtual void onLoad(T &data) = 0;
                virtual void run() = 0;
                virtual State<T> &getNextState() = 0;
                virtual T &onUnload() = 0;
        };

        template <typename T>
        class StateManager : public Task {
            protected:
                State<T> *currentState;
            public:
                StateManager(State<T> &initialState, T &initialData) : currentState(&initialState) {
                    this->currentState->onLoad(initialData);
                };
                virtual void run() {
                    if (this->currentState != NULL) {
                        this->currentState->run();
                        State<T> &nextState = this->currentState->getNextState();
                        if (this->currentState != &nextState) {
                            T &data = this->currentState->onUnload();
                            this->currentState = &nextState;
                            this->currentState->onLoad(data);
                        }
                    }
                };
        };
    }
}

#endif  // __STATE_H__

Al final, lo que se hace en "main.cc" es configurar el CLIC (Core Local Interrupt Controller) del RISC-V para que genere una interrupción de timer cada 10 milisegundos y en cada callback del timer se ejecuta el run de los objetos:

- keypad (teclado)

- sevenSegments (7 segmentos)

- stepper (motor paso a paso)

- heartbeat (el led que parpadea)

- stateManager (encargado, a su vez de ejecutar el "run()" del "ConfigureStepsState", del "ConfigureDaysState" o del "IdleState", en función del modo en el que nos encontremos).

#include "Task.H"
#include "Timer.H"
#include "MyStepper.H"
#include "MyKeypad.H"
#include "MySevenSegments.H"
#include "SharedData.H"
#include "State.H"
#include "MyIdleState.H"
#include "MyConfigureStepsState.H"
#include "ConfigureDaysState.H"
#include "interrupt.H"
#include "Heartbeat.H"

using namespace avelino;
using namespace std;

class MyTimerListener : public TimerListener {
    public:
        Task **tasks;
        uint16_t numTasks;
        MyTimerListener(Task **tasks, uint16_t numTasks) : tasks(tasks), numTasks(numTasks) { };
        virtual void timerExpired();
};

void MyTimerListener::timerExpired() {
    Task **t = this->tasks;
    for (uint16_t i = 0; i < this->numTasks; i++, t++)
        (*t)->run();
}

int main() {
    interruptInit();
    MyKeypad keypad;
    keypad.init();
    MySevenSegments sevenSegments;
    sevenSegments.init();
    MyStepper stepper;
    stepper.init();
    Heartbeat heartbeat;
    // states
    SharedData data;
    MyConfigureStepsState configureStepsState(keypad, sevenSegments, stepper);
    ConfigureDaysState configureDaysState(keypad, sevenSegments, stepper);
    MyIdleState idleState(keypad, sevenSegments, stepper);
    // link states
    configureStepsState.setNextState(configureDaysState);
    configureDaysState.setNextState(idleState);
    idleState.setNextState(configureStepsState);
    StateManager<SharedData> stateManager(configureStepsState, data);
    // round robin tasks
    const int NUM_TASKS = 5;
    Task *tasks[NUM_TASKS] = { &keypad, &sevenSegments, &stepper, &heartbeat, &stateManager };
    MyTimerListener myTimerListener(tasks, NUM_TASKS);
    Timer timer;
    timer.init(myTimerListener, 10_msForTimer);
    while (true)
        asm volatile ("wfi");
    return 0;
}

Inicializamos todos los objetos y creamos un sencillo gestor de tareas de tipo round-robin para invocar las funciones miembro "run()" de los objetos de tipo "Task".

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







¡Feliz Navidad! :-)

[ 2 comentarios ] ( 19831 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 578 )
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 ] ( 544 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 683 )
Gestión de interrupciones en microcontroladores RISC-V 
La gestión de las interrupciones es un tema planteado de forma muy básica en la arquitectura original RISC-V. En controlador básico de interrupciones que se describe en los documentos oficiales de la arquitectura está más orientado a la captura de eventos entre diferentes niveles de ejecución que a la captura de eventos externos, algo que, en el caso de los microcontroladores, se echa de menos. Abordaremos el uso del este controlador básico de interrupciones y el uso del CLIC, un controlador de interrupciones más avanzado, que complementa al primero y que está disponible en casi todos los núcleos RISC-V.

Controlador básico de interrupciones

El control de interrupciones que se describe en el volumen 2 de la especificación es un control de interrupciones muy básico orientado más a la captura de eventos entre diferentes niveles de privilegio (captura desde el nivel supervisor de eventos producidos en el nivel usuario, o captura desde el nivel máquina de eventos producidos en el nivel supervisor) que a la captura de eventos externos. De hecho, en el controlador de interrupciones "oficial" sólo tenemos como "interrupciones" (tal y como las entendemos casi todos los que venimos del mundo de lo microcontroladores) dos tipos: las de timer y las etiquetadas genéricamente como "externas" (estas ultimas, dependientes de la implementación que haga cada fabricante).

Como se puede ver, se trata de un mecanismo de interrupción muy pobre y que, aunque muy bien pensado para entornos multiusuario, es insuficiente para entornos embebidos, en los que todo el código se ejecutará casi siempre con los niveles de privilegio M (de máquina) y apenas se usan otros niveles de privilegio. Sin embargo, en caso de que sólo necesitemos una interrupción de timer, sería la mejor opción, ya que está presente en todas las implementaciones de RISC-V (forma parte de la especificación estándar y siempre existirá).

Los CSR

Muchos de los parámetros de funcionamiento en los procesadores RISC-V se configuran mediante CSR (Control and Status Registers), dichos registros son registros internos del procesador que son accesibles mediantes instrucciones de esamblador especiales de transferencias de valores desde/hasta registros normales (CSRR, CSRW, etc.) o mediante valores inmediatos (CSRWI, etc.). Existen una serie de CSRs estándar y, además, cada fabricante puede incluir los suyos propios, siempre y cuando se mantengan los del estándar de RISC-V.

...
// poner a 1 el bit 3 del CSR "msi"
asm volatile (
    "csrsi mstatus, 8"
);
...
// escribir 0x08000000 en el CSR número 0x307 (cuando no podemos usar el nombre)
asm volatile (
    "csrw 0x307, %[reg]" : : [reg] "r" (0x08000000)
);
...

En este ejemplo de código el ensamblador que viene con las binutils sí reconoce el símbolo "mstatus" como CSR (al ser un CSR estándar presente en todos los núcleos RV32I), sin embargo el CSR "mtvt" es un CSR definido para el controlador de interrupciones avanzado (CLIC) y, como no está definido como símbolo en las "binutils", debemos poner su valor (0x307) si queremos acceder a él desde ensamblador.

Ejemplo 1: Blinker usando el mecanismo básico de interrupciones

Como dijimos antes, el mecanismo básico de interrupciones permite el uso de una interrupción de timer así como de una interrupción externa. En este caso usaremos este mecanismo básico de interrupciones para configurar la interrupción de timer con el objetivo de hacer parpadear un led.



Lo primero que hacemos es configurar en el CSR llamado "mtvec" el vector de interrupción, que podrá ser vectorizado o no, es decir, podremos indicar un puntero a una función o la dirección de memoria de una tabla donde estén indicadas, a su vez, las direcciones de salto de cada uno de las interrupciones que se definan (al estilo ARM Cortex-M). La forma en la que el RISC-V debe interpretar este valor (si como puntero a función o como puntero a tabla) se debe indicar en los dos bits menos significativos de este CSR. Como vamos a usar el mecanismo básico de interrupciones, la cantidad de interrupciones que se pueden generar es muy escaso y, por tanto, he optado por indicar directamente un puntero a una función (modo directo, mode = 00): Nótese que los bits 0 y 1 de la dirección de memoria de la función deben estar a 0 (puesto que estos bits en el CSR de mtvec se usan para indicar el modo), lo que obliga a que el handle de interrupción esté en una dirección de memoria múltiplo de 4 (alineada a 32 bits).

A continuación configuramos el CSR "mie" (Machine Interrupt Enable), donde decimos, para el nivel de privilegio M (el de máquina, con el que arranca el RISC-V por defecto) qué interrupciones se habilitan (activamos el bit correspondiente al timer):



En este CSR ponemos a 1 el bit 7 (MTIE = Machine Timer Interrupt Enable), mientras que en el CSR "mstatus" debemos habilitar las interrupciones de nivel de máquina poniendo a 1 el bit 3.



Con estos pasos ya tenemos configurado el controlador básico de interrupciones para que se dispare la interrupción de timer. El timer es muy parecido al SysTick de los procesadores ARM Cortex-M: un contador que se incrementa a la misma velocidad que el reloj del sistema y que, en el caso del RISC-V, se configura con dos registros mapeados en memoria:

- MTIME: Un registro de 64 bits que se va incrementando de forma indefinida y que cuando se desborda vuelve a cero (de lectura y escritura).

- MTIMECMP: Un registro de 64 bits que sólo se escribe desde software (aunque también es de lectura y escritura) y que, cuando vale igual que mtime, se produce la interrupción de timer en caso de estar ésta configurada.

Las direcciones de memoria donde están mapeados estos dos registros de 64 bits no están definidas por el estándar, son de libre elección por parte del fabricante. En el caso del GD32VF103, se alojan en 0xD1000000 y 0xD1000008 respectivamente.

#include <stdint.h>

using namespace std;

#define  RCU_APB2EN  *((volatile uint32_t *) 0x40021018)
#define  GPIOC_CTL1  *((volatile uint32_t *) 0x40011004)
#define  GPIOC_OCTL  *((volatile uint32_t *) 0x4001100C)
#define  MTIME       *((volatile uint64_t *) 0xD1000000)
#define  MTIMECMP    *((volatile uint64_t *) 0xD1000008)

#define  MTIME_INTERRUPT_PERIOD  12000000    // 24 MHz, so 12000000 generates an interrupt period of exactly half a second

void interruptHandler() __attribute__ ((interrupt, section(".interrupt_handler")));

void interruptHandler() {
    MTIME = 0;
    MTIMECMP = MTIME_INTERRUPT_PERIOD;
    GPIOC_OCTL = GPIOC_OCTL ^ (((uint32_t) 1) << 13);
}

int main() {
    // enable clock on port C
    RCU_APB2EN = RCU_APB2EN | (((uint32_t) 1) << 4);
    // PC13 pin is output, low speed, push-pull
    GPIOC_CTL1 = 0x44244444;
    // basic (non vectored) interrupt handler (to force non vectored, set 0 to lower two bits of mtvec, so force 4 byte aligned on linker script for interrupt handler)
    asm volatile (
        "csrw mtvec, %[reg]" : : [reg] "r" ((uint32_t) interruptHandler)
    );
    // machine interrupt enable
    asm volatile (
        "csrw mie, %[reg]" : : [reg] "r" ((uint32_t) 0x80)
    );
    asm volatile (
        "csrsi mstatus, 8"
    );
    // configure interrupt period
    MTIME = 0;
    MTIMECMP = MTIME_INTERRUPT_PERIOD;
    // sleep
    while (true)
        asm volatile ("wfi");
}

Paso a comentar las partes más relevantes del código:

1. Definimos la función "interruptHandler" (aunque da igual el nombre que le pongamos), le ponemos los atributos de compilador "interrupt" y "section(".interrupt_handler")" (el nombre de la sección también da igual, lo importante es que no esté en la sección ".text" que es donde se pone por defecto todo el código). En la función reiniciamos los registro MTIME y MTIMECMP y cambiamos de estado el led. No es necesario marcar ningún flag para indicar que la interrupción está atendida ya que, según la documentación del estándar del RISC-V, al escribir en la dirección de memoria de MTIMECMP el procesador ya considera atendida la interrupción de timer.

2. En la función main habilitamos el reloj para la circuitería de GPIO, configuramos el puerto PC13 como salida push-pull a baja velocidad y configuramos la interrupción de timer: Escribimos en "mtvec" la dirección de memoria del handler que hemos definido antes (como estará alineado a 32 bits, sus dos bits menos significativos ya valdrán 0, por lo que, de paso, también seleccionamos que queremos un tratamiento de interrupciones no vectorizado, sino definiendo una única función para atender todas las interrupciones), habilitamos los bits de "mie" y "mstatus" que vimos antes y configuramos los registros MTIME y MTIMECMP para que el tiempo entre interrupciones sea de medio segundo (sabiendo que el reloj del timer va a 96 MHz / 4 = 24 MHz).

3. Entramos en un bucle infinito con la instrucción "wfi" para que el procesador permanezca dormido y en modo de bajo consumo mientras no esté atendiendo interrupciones.

Para que este código funcione correctamente es necesario, además de "startup.cc", que se encarga de inicializar todo el subsistema de relojes para que el núcleo vaya a 96 MHz, que en el linker script estén correctamente indicadas las secciones, sobre todo la sección que hemos llamado ".interrupt_handler", ya que debemos forzar a que dicha sección esté alineada a 32 bits (para que su dirección de memoria sea múltiplo de 4 y así los dos bits menos significativos de dicha dirección de memoria valgan 0).

SECTIONS {
    . = 0x08000000 ;
    .text : {
        startup.o (.startup0)
    }
    . = 0x08000200 ;
    .text : {
        _linker_code = . ;
        startup.o (.startup1)
        *(.text)
        *(.text.*)
        *(.rodata*)
        *(.gnu.linkonce.t*)
        *(.gnu.linkonce.r*)
    }
    . = ALIGN(4);     /* to force lower 2 bits of address to 0 (mtvec.mode = 0 to select non vectored interrupt handler) */
    .interrupt_handler : {
        *(.interrupt_handler)
    }
...
}


Ejemplo 2: Blinker usando el CLIC (Core Local Interrupt Controller)

El CLIC (Core Local Interrupt Controller) es aún una propuesta de estándar, que se encuentra documentada en el GitHub de RISC-V International, pero que ya está siendo implementada por múltiples fabricantes, entre ellos GigaDevice (en su GD32VF103), SiFive (en todos sus procesadores) y muchos otros. Es una propuesta de controlador de interrupciones compatible con el sistema básico de interrupciones, pero que permite hasta 4096 interrupciones externas, configurables por niveles, prioridad, etc. Al ser una propuesta de la propia RISC-V International, lo cierto es que se ha convertido en estándar "de facto" y sólo cabe esperar que se acabe poniendo de forma "bonita" en un documento de especificación, en lugar de como está ahora. El CLIC define una serie de CSRs nuevos además de un conjunto de registros mapeados en memoria para el manejo "ampliado" de interrupciones.

En este caso, aunque el CLIC puede usarse con interrupciones no vectorizadas, es recomendable configurarlo para usarlas vectorizadas. En un CSR denominado "mclicbase" se encuentra la dirección de memoria base a partir de la que se localiza el resto de registros mapeados en memoria del CLIC. En el caso del GD32VF103 he optado por poner a fuego dicho valor, ya que es conocido (está indicado en la documentación del fabricante) y de esta forma también simplificamos el código generado (aunque sea menos portable). A partir de la dirección base del CLIC se obtiene el resto de registros mapeados en memoria.

#define  CLIC_BASE                    0xD2000000
#define  CLIC_IP(source)              *(volatile uint8_t *)(CLIC_BASE + 0x00001000 + ((source) * 4))
#define  CLIC_IE(source)              *(volatile uint8_t *)(CLIC_BASE + 0x00001001 + ((source) * 4))
#define  CLIC_ATTR(source)            *(volatile uint8_t *)(CLIC_BASE + 0x00001002 + ((source) * 4))
#define  CLIC_CTL(source)             *(volatile uint8_t *)(CLIC_BASE + 0x00001003 + ((source) * 4))
#define  CLIC_CFG                     *(volatile uint8_t *)(CLIC_BASE + 0x00000000)
#define  CLIC_MTH                     *(volatile uint8_t *)(CLIC_BASE + 0x0000000B)
#define  CLIC_IP_IE_ATTR_CTL(source)  *(volatile uint32_t *)(CLIC_BASE + 0x00001000 + ((source) * 4))

Para configurar las interrupciones vectorizadas con el CLIC primero preparamos la tabla de interrupciones que, en el caso del GD32VF103, debe estar a partir de la dirección 0x00000000 (limitaciones del propio fabricante) y que nosotros situaremos a partir de la dirección 0x08000000, ya que ambas zonas de memoria son alias una de la otra y es a partir de 0x08000000 donde se encuentra la memoria flash de programa. Recordemos que en el caso del GD32VF103, la dirección de memoria 0x08000000 es la dirección de reset y que en esa posición lo que tenemos es una instrucción "jump" hacia 0x08000200 que es donde empieza la ejecución del código. Es en este "hueco" entre 0x08000000 y 0x08000200 es donde se aloja la tabla de vectores de interrupción. Como sólo vamos a configurar uno de los vectores de interrupción (el del timer, que se corresponde con la posición 7), hacemos que el linker script situe ahí (0x08000000 + (7 * 4) = 0x0800001C) la dirección de memoria de una sección que llamaremos ".clic_int_tmr" (el nombre no es relevante).

SECTIONS {
    . = 0x08000000 ;
    .text : {
        startup.o (.startup0)
    }
    . = 0x0800001C ;
    .clic_int_tmr_vector : {
        LONG(CLIC_INT_TMR_ADDRESS);
    }
    . = 0x08000200 ;
    .text : {
        _linker_code = . ;
        startup.o (.startup1)
        *(.text)
        *(.text.*)
        *(.rodata*)
        *(.gnu.linkonce.t*)
        *(.gnu.linkonce.r*)
    }
    . = ALIGN(4);
    CLIC_INT_TMR_ADDRESS = . ;
    .clic_int_tmr : {
        *(.clic_int_tmr)
    }
    ...
}

A continuación, en "main.cc" definimos nuestra función de manejo de las interrupciones:

void clicIntTmr() __attribute__ ((interrupt, section(".clic_int_tmr")));

void clicIntTmr() {
    MTIME = 0;
    MTIMECMP = MTIME_1_SEC;
    GPIOC_OCTL = GPIOC_OCTL ^ (((uint32_t) 1) << 13);   // toggle led
    CLIC_IP(7) = 0;    // not pending
}

También aplicamos a esta función el atributo "interrupt" e indicamos que dicha función debe estar en la sección ".clic_int_tmr" (el nombre que elegimos en el linker script). La única diferencia notable con respecto al caso del mecanismo básico de interrupciones es que aquí nos aseguramos de borrar de forma explícita el flag "interrupt pending" (IP) antes de regresar de la interrupción.

La preparación del CLIC es ligeramente más compleja que en el caso del mecanismo básico de interrupciones:

int main() {
    // enable clock on port C
    RCU_APB2EN = RCU_APB2EN | (((uint32_t) 1) << 4);
    // PC13 pin is output, low speed, push-pull
    GPIOC_CTL1 = 0x44244444;
    // clear CLIC config register and set vectored interrupts
    CLIC_CFG = 0;
    CLIC_MTH = 0;   // specific register for GD32VF103 ECLIC
    for (uint16_t i = 0; i < 4096; i++)
        CLIC_IP_IE_ATTR_CTL(i) = 0;
    // use CLIC vectored interrupt handler (put 1 on mode (lower two) bits of mtvec)
    asm volatile (
        "csrw mtvec, %[reg]" : : [reg] "r" (0x00000003)
    );
    // machine interrupt enable
    asm volatile (
        "csrsi mstatus, 8"
    );
    // set CLIC interrupt vector table (mtvt = 0x307)
    asm volatile (
        "csrw 0x307, %[reg]" : : [reg] "r" (0x08000000)
    );
    CLIC_ATTR(7) = 0b11000001;    // machine mode, level triggered, vectored
    CLIC_IE(7) = 1;               // enable interrupt 7
    CLIC_IP(7) = 0;               // not pending
    CLIC_CTL(7) = 0;              // priority
    // configure interrupt period
    MTIME = 0;
    MTIMECMP = MTIME_1_SEC;
    while (true)
        asm volatile ("wfi");
}

Los pasos son los siguientes:

1. Inicializamos el CLIC según indica la documentación: borrando CLIC_CFG, CLIC_MTH y poniendo a cero los 4096 registros de configuración mapeados en memoria.

2. Indicamos en el CSR "mtvec" que vamos a usar el CLIC en lugar del mecanismo básico de interrupciones poniendo a 1 los dos bits menos significativos de "mtvec". Recordemos que en el caso del mecanismo básico de interrupciones, estos dos bits estaban a 0.

3. Indicamos en el CSR "mstatus" que se habilitan las interrupciones a nivel de máquina (M). Igual que como hacíamos con el mecanismo básico de interrupciones.

4. Escribimos en un CSR nuevo definido para CLIC y llamado "mtvt" la dirección de memoria de la tabla de vectores de interrupción, que debe estar alineada a 64 bytes (en nuestro caso la dirección de memoria 0x08000000 cumple con este requisito). Nótese que el CSR "mtvt" no se reconoce por parte de las binutils de GNU, por lo que en el ensamblador generado hemos tenido que usar el valor numérico que corresponde con dicho CSR (0x307).

5. Configuramos en los registros del CLIC mapeados en memoria (en la posición correspondiente a la interrupción 7 de los 4096 registros), la prioridad, el tipo de interrupción y la propia habilitación de la misma (CLIC_ATTR, CLIC_IE, CLIC_IP y CLIC_CTL).

6. El resto de operaciones son las mismas que en caso del mecanismo básico de interrupciones de timer: inicializar MTIME y MTIMECMP y entrar en el bucle infinito en el que dormimos al procesador con la instrucción "wfi" cuando no está atendiendo interrupciones.

El resultado es el mismo: un led que parpadea, pero utilizando las dos aproximaciones distintas al mecanismo de interrupciones que permite hoy día la arquitectura RISC-V.

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

[ 2 comentarios ] ( 5448 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente
  |    |    |    |   ( 3 / 782 )
Blinker básico para RISC-V en C++ 
Partiendo del compilador cruzado GCC para RISC-V descrito en el post anterior, el primer objetivo será hacer una prueba de concepto con un procesador RISC-V real. En este caso he optado por un GD32VF103, un microcontrolador de GigaDevice, con 128 Kb de memoria flash de programa, 32 Kb de SRAM, procesador RISC-V con arquitectura básica RV32IMAC y periféricos muy parecidos a los del STM32F103.

Secuencia de arranque

Los procesadores RISC-V, al contrario que los ARM, no poseen, en su configuración base, un vector de interrupciones, es una extensión estándar pero no forma parte del núcleo en sí. Por tanto, el concepto que existe en los ARM de una tabla de interrupciones en la que el "reset" es una interrupción más, no existe en RISC-V: En los RISC-V existe el "RESET_VECTOR", que es la dirección de memoria desde donde arranca el procesador cuando se reinicia o se enciende. Dicha dirección suele ser 0x00000000 pero puede ser libremente definida por el fabricante, en el caso que nos ocupa, el GD32VF103 incluye un bootloader en la dirección 0x00000000, mientras que en la dirección 0x08000000 se encuentra la memoria flash de programa (los 128 Kb). Cuando el micro arranca en modo bootloader (determinado por un pin), arranca desde la dirección 0x00000000 (RESET_VECTOR = 0x00000000), ejecuta el bootloader y éste se encarga de poner el micro en modo DFU a través del USB, mientras que cuando arranca en modo normal (sin bootloader), el micro arranca desde la dirección 0x08000000 (RESET_VECTOR = 0x08000000), donde se supone que debe estar el programa principal.

Algo parecido ocurre con el puntero de pila: Al contrario que los Cortex-M de ARM, en los RISC-V no existe una tabla que indique el valor que debe tener, en el momento del arranque, el registro de pila (SP), por lo que dicho registro debe ser cargado a mano mediante una instrucción en ensamblador. Por ejemplo, una secuencia de arranque mínima podría ser:

reset_vector:
    la sp, 0x20005000   ; la SRAM acaba en 0x20005000 y la pila "crece" hacia abajo
    call main           ; llamamos a la función main

Nótese que no se está teniendo el cuenta la inicialización de variables globales (incluidos los constructores de objetos globales), pero es un punto de partida.

Linker script y arranque

Basándonos en un post anterior hacemos un linker script básico y un código de arranque e inicialización para variables globales y constructores. En este caso, al principio del linker script dividimos el código generado en dos zonas:

- Una al principio, en 0x08000000, donde sólo alojaremos una instrucción de salto.

- Otra a partir de 0x08000200 donde alojaremos la inicialización y el resto del código (incluida la función main).

Esto lo hacemos así porque en el caso del GD32VF103 sí que existe, para la extensión de las interrupciones de RISC-V, una tabla de interrupciones que se extiende desde 0x0800000C hasta 0x080001FC, por lo que, para poder en un futuro usar ese vector de interrupciones, haremos que el grueso del código de aloje a partir de 0x08000200 y en 0x08000000 lo que ponemos es un salto incondicional a 0x08000200.

SECTIONS {
. = 0x08000000 ;
.text : {
startup.o (.startup0)
}
. = 0x08000200 ;
.text : {
_linker_code = . ;
startup.o (.startup1)
*(.text)
*(.text.*)
*(.rodata*)
*(.gnu.linkonce.t*)
*(.gnu.linkonce.r*)
}
.preinit_array : {
__preinit_array_start = . ;
*(.preinit_array)
__preinit_array_end = . ;
}
.init_array : {
__init_array_start = . ;
*(.init_array)
__init_array_end = . ;
}
.fini_array : {
__fini_array_start = . ;
*(.fini_array)
__fini_array_end = . ;
}
.ctors : {
__CTOR_LIST__ = . ;
LONG((__CTOR_END__ - __CTOR_LIST__) / 4 - 2)
*(.ctors)
LONG(0)
__CTOR_END__ = . ;
}
.dtors : {
__DTOR_LIST__ = . ;
LONG((__DTOR_END__ - __DTOR_LIST__) / 4 - 2)
*(.dtors)
LONG(0)
__DTOR_END__ = . ;
}
...

Como se puede ver, se definen dos secciones al principio del linker script:

- Una a partir de la dirección 0x08000000 y en la que se alojará el código de las funciones etiquetadas con la sección ".startup0" del fichero startup.o.

- Otra a partir de la dirección 0x08000200 y en la que se alojará primero el código de las funciones que se encuentren etiquetadas con la sección ".startup1" seguido por el resto del código del programa.

A continuación se ubican las secciones estándar ".preinit_array", ".init_array", ".ctors" encargadas de alojar las llamadas a los constructores y las funciones de inicialización de variables globales, y las secciones estándar ".fini_array" y ".dtors", encargadas de alojar las llamadas a los destructores y las funciones de destrucción de variables globales (esta parte del código no se debería ejecutar jamás en un sistema embebido, se incluye por elegancia).

Variables globales en RAM y copia de datos de Flash a RAM

...
flash_sdata = . ;
. = 0x20000000 ;
ram_sdata = . ;
.data : AT (flash_sdata) {
_linker_data = . ;
*(.data)
*(.data.*)
*(.gnu.linkonce.d*)
}
ram_edata = . ;
data_size = ram_edata - ram_sdata;
ram_sbssdata = . ;
.bss : AT (LOADADDR(.data) + SIZEOF(.data)) {
_linker_bss = . ;
*(.bss)
*(.bss.*)
*(.gnu.linkonce.b.*)
*(.COMMON)
}
ram_ebssdata = . ;
bssdata_size = ram_ebssdata - ram_sbssdata;
_linker_end = . ;
end = . ;
}

En esta parte del linker script se indica que la dirección virtual de los datos es 0x20000000 (esta es la dirección que se usará desde el código de programa para acceder a los datos en RAM), pero sin embargo dichos datos están inicialmente alojados en la memoria flash (a continuación de la sección ".dtors"), por lo que es necesario un copiado de dichas variables globales inicializadas desde la memoria flash hasta la RAM antes de que se ejecute la función "main".

Código de arranque

En el fichero startup.cc se definen dos funciones _startup_0 y _startup_1 y se etiquetan como de las secciones ".startup0" y ".startup1", respectivamente. La función _startup_0 está definida con el atributo "naked" (para que el compilador no genere código de preámbulo ni postámbulo) y contiene una única instrucción de salto:

void _startup_0() __attribute__((section(".startup0"), naked));

void _startup_0() {
    asm volatile (
        "j %0" : : "i" (_startup_1)
    );
}

Esta es la función que se aloja en la dirección 0x08000000 y que, como se vio antes, es la encargada de saltar a _startup_1. La función _startup_1, que también está definida como "naked" pero etiquetada en la sección ".startup1" (por lo que se aloja a partir de la dirección 0x08000200) alberga el resto de código de inicialización:

void _startup_1() __attribute__((section(".startup1"), naked));

void _startup_1() {
    asm volatile (
        "la sp, 0x20005000"  // point SP to the end of SRAM
    );
    _initClock();
    _initDataRAM();
    _initBssRAM();
    _callConstructors();
    _callInitArray();
    main();
    _callFiniArray();
    _callDestructors();
    while (true)
        ;
}

Y realiza lo siguiente:

1. Inicializa el puntero de pila para que apunte al final de la RAM (el puntero de pila se decrementa cuando se hace "push" y se incrementa cuando se hace "pop").

2. Inicializa los PLLs del microcontrolador para configurar el reloj de cristal (en mi caso he dejado esta función vacía por lo que el microcontrolador queda funcionando con el reloj RC interno que es más lento pero suficiente para nuestro blinker).

3. Inicializa la RAM que se copia desde la flash (ver aquí los detalles de este proceso).

4. Inicializa la RAM que debe estar a cero (BSS).

5. Invoca las funciones de inicialización y los constructores.

6. Finalmente incova a la función "main".

Al final se coloca una especie de código "de cortesía" para que, en el caso de que la función "main" regrese (cosa que no debería ocurrir), se invoquen los destructores y el micro se quede "colgado" (bucle infinito).

Prueba de concepto

Se ha optado por hacer un simple blinker basado en esperas estándar de ciclos de procesador, en lugar de un timer, ya que, como se comentó antes, el núcleo básico de un RISC-V no incluye interrupciones y creo que es mejor abordarlas en una siguiente entrega:

#include 

using namespace std;

#define  RCU_APB2EN  *((uint32_t *) 0x40021018)
#define  GPIOC_CTL1  *((uint32_t *) 0x40011004)
#define  GPIOC_OCTL  *((uint32_t *) 0x4001100C)

int main() {
    // enable clock on port C
    RCU_APB2EN |= ((uint32_t) 1) << 4;
    // PC13 pin is output, low speed, push-pull
    GPIOC_CTL1 = 0x44244444;
    while (true) {
        for (uint32_t i = 0; i < 200000; i++)
            ;
        GPIOC_OCTL ^= (((uint32_t) 1) << 13);
    }
}

La placa en la que está alojada el GD32VF103 es una Longan Nano que posee un led tricolor con la componente roja conectada al pin PC13 del micro. Para hacer parpadear este led rojo debemos:

1. Habilitar el reloj interno de la circuitería GPIO del micro.

2. Configurar el pin PC13 como GPIO de salida en push-pull.

3. Modificar el bit 13 del registro asociado de forma periódica para hacer que el led parpadee.

Los registros son una copia casi exacta de los del STM32F103 por lo que si estás acostumbrado a ese micro de ST, adaptarte al GD32VF103 será muy sencillo.

Bootloader

Cuando arranca el microcontrolador GD32VF103, si el pin BOOT0 se encuentra a nivel 1, no se arranca desde la dirección 0x08000000, sino que se ejecuta un "bootloader" interno que configura el microcontrolador como un dispositivo USB con interfaz estándar DFU (Device Firmware Upgrade). Se trata de un estándar conocido y para el que existen herramientas como dfu-util (que, en el caso de Linux, sólo requiere de la librería "libusb"). Con el microcontrolador en modo DFU y el comando "dfu-util" podemos "tostar" nuestro ejecutable "main.bin" de forma muy sencilla:

dfu-util --dfuse-address 0x08000000 -D main.bin

En la sección soft está todo el código disponible.



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

| 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | Siguiente> >>