Desarrollo de una miniconsola de videojuegos portátil (1): diseño hardware 
A lo largo de 4 entradas consecutivas en el blog iré detallando todo el desarrollo y la implementación de una miniconsola de videojuegos portátil que he desarrollado para mi hijo. La idea era hacer una consola al estilo "maquinita" o "game & watch" pero algo más elaborada, alimentada con batería recargable y con algunos juegos prefijados. En esta entrada me centraré en el diseño hardware y el desarrollo de las librerías básicas para acceso al hardware.

Características principales

- Microcontrolador GD32VF103: núcleo RISC-V de 32 bits a 96 MHz, con 256 Kb de Flash y 32 Kb de SRAM.

- Pantalla: Módulo GMG12864 basado en el controlador de display ST7565 de 128x64 pixels en blanco y negro (sin escalas de grises, cada pixel encendido o apagado).

- Botonera: Cruceta (arriba, abajo, izquierda y derecha) más dos botones adicionales (A y B) de funcionalidad personalizable.

- Alimentación: Batería de una celda de LiPo o LiIon de 1200 mAh (3.7 voltios) para unas 6 horas de juego continuado. Recargable mediante módulo de controlador de carga con conector USB-C y con interruptor de encendido.



A continuación una foto del frontal de la consola (encendida aunque aún sin caja).



Y de la parte trasera, donde se puede ver todo el trabajo de soldadura (a muchos técnicos en electrónica seguramente les sangrarán los ojos, pero bueno, hice lo que pude, se me da mejor programar que soldar).



Pantalla

Aprovechando que el microcontrolador tiene una potencia razonable se opta por un modelo de pantalla con una capa de abstracción basada en framebuffer, de tal manera que los juegos de la consola escribirán en un framebuffer de lineal de $128 \times 64 = 8192$ bytes. Para encender o apagar el pixel (x, y) se escribirá un 1 o un 0, respectivamente, en el offset $\left( y \times 128 \right) + x$ del framebuffer:

frameBuffer[(y * 128) + x] = 1;     // encender pixel (x, y)
frameBuffer[(y * 128) + x] = 0;     // apagar pixel (x, y)

Habrá una clase encargada de traducir la información del framebuffer en transferencias SPI al módulo GMG12864 para que se pinte de forma adecuada la pantalla. Esta abstracción nos permite adaptarnos a pantallas futuras y no depender sólo de esa pantalla en concreto, además de que facilita el desarrollo y las pruebas como veremos más adelante.

Botonera

La botonera se implementa con 6 botones mecánicos con el común a masa. Las 6 entradas GPIO en el microcontrolador se configuraon como GPIOs en pullup y así nos ahorramos tener que poner resistencias de pull-up por fuera. Se opta por no poner circuitería antirrebote en los botones para abaratar costes: el antirrebote se realizará por software, mediante una máquina de estados que, con temporizadores, evitará que se produzcan rebotes en la acción de las teclas.

Alimentación

La alimentación es muy sencilla, se utiliza un módulo TP4056 para una celda LiPo o LiIon de 3.7 voltios que ya viene con conector USB-C para carga y salida estabilizada que puede ir directa a la entrada de 5 voltios del módulo del microcontrolador. Toda la consola requiere 3.3 voltios para funcionar pero, como el convertidor de voltaje de la placa del microcontrolador tiene un dropout muy bajo, se pueden meter los 3.7 voltios de salida de la controladora de carga por la entrada de 5 voltios de la placa del microcontrolador. El interruptor de alimentación se coloca en serie con la alimentación que llega al microcontrolador y a la pantalla de tal manera que, aunque el interruptor de la consola esté apagado, su batería se podrá cargar con un cargador estándar USB-C.

Entorno de desarrollo y clases básicas

Como otros desarrollos "grandes" que he hecho siempre intento que el proceso de desarrollo y de depuración sean lo más eficientes posibles y para ello trato siempre de aprovechar el uso de clases abstractas para abstraer el código del hardware específico o la plataforma en la que estoy trabajando. Por ejemplo, para el manejo de la pantalla habrá una clase "Display" que albergará el framebuffer y algunas funciones miembro auxiliares, a continuación se crea una carpeta "gd32vf103" donde irá la implementación específica para el microcontrolador y la pantalla utilizadas "SPIGMG12864Display" que heredará de "Display". Se crea también una carpeta "linux" donde va la implementación específica para Xlib ("XlibDisplay").

A continuación los diagramas de clases de las clases principales del código fuente:



En azul las clases específicas del microcontrolador, en verde las clases específicas de linux y en blanco las clases comunes.




En estos diagramas de clases se pueden ver las clases básicas que constituyen el "framework" de la miniconsola. El elemento central para entender cómo funciona el flujo del software es la clase "Screen" que representa una pantalla (título, menú, un juego en sí, etc.) y la clase "ScreenManager", encargada de ir cambiando de pantallas en función de las necesidades del flujo del programa.

Cada Screen debe implementar las funciones miembro:

void Screen::onLoad(InterScreenData *dataFromPreviousScreen): Esta función se invoca cuando se carga una pantalla, se supone que a partir de este momento el framebuffer es "suyo" por lo que lo lógico en esta función miembro es que se inicialicen variables, se borre el framebuffer, se pinten las partes fijas del mismo, se inicialice la mecánica de esta pantalla, etc.

Screen *Screen::onUpdate(): Esta función se ejecuta cada 20 ms por parte del timer del sistema para que se implementen la mecánica de la pantalla (menú, juego, etc.). Si devuelve *this o nullptr significará que no hay que cambiar de pantalla, en caso contrario significa que queremos cambiarnos a la pantalla correspondiente.

InterScreenData *Screen::onUnload(): Esta función miembro se ejecuta en caso de que la última llamada a "onUpdate()" haya devuelto una "Screen *" válida (no nullptr) y diferente a la actual. La idea es poner aquí código de "terminación" de nuestra pantalla. Un objeto de clase "Screen" puede ser cargado ("onLoad") y descargado ("onUnload") varias veces entre su construcción y su destrucción.

La clase "ScreenManager" heredará de la clase "Task" para implementar la función miembro run, donde se realizará la mecanica del onLoad/onUpdate/onUnload indicada:
class InterScreenData {
}; 
    
class Screen;

class Screen {
    public:
        virtual void onLoad(InterScreenData *dataFromPreviousScreen) = 0;
        virtual Screen *onUpdate() = 0;
        virtual InterScreenData *onUnload() = 0;
};

class ScreenManager : public Task {
    public:
        Screen *currentScreen;
        ScreenManager(Screen &initialScreen, InterScreenData *initialInterScreenData);
        virtual void run();
};

ScreenManager::ScreenManager(Screen &initialScreen, InterScreenData *initialInterScreenData) : currentScreen(&initialScreen) {
    this->currentScreen->onLoad(initialInterScreenData);
}   

void ScreenManager::run() {
    Screen *nextScreen = this->currentScreen->onUpdate();
    if ((nextScreen != this->currentScreen) && (nextScreen != nullptr)) {
        InterScreenData *isd = this->currentScreen->onUnload();
        nextScreen->onLoad(isd);
        this->currentScreen = nextScreen;
    }
}

Como se puede ver es una mecánica muy sencilla. A partir de la clase "Screen" se crean todas las pantallas de la aplicación, por ejemplo:

- SplashScreen: Pantalla de bienvenida con una imagen de fondo y un texto de copyright que espera a que pulses un botón para pasar a la siguiente pantalla.

- MenuScreen: Pantalla de menú que permite, a su vez, especializarse para crear diferentes menus (como MainMenuScreen).

- ...

Cualquier clase que herede de Screen y que implemente los tres métodos especificados será otro tipo de pantalla con la funcionalidad que queramos.

Esta forma de programar la aplicación es muy escalable y permite crear fácilmente flujos de código muy elaborados:
int main() {
    // init hardware
    interruptInit();
    RGBLed::init();
    GPIOButtons buttons;
    Random random(buttons);
    SPIGMG12864Display display;
    display.blank();
    // create screens
    InitialSplashScreen initialSplashScreen(display, buttons);
    MainMenuScreen mainMenuScreen(display, buttons);
    TetrisMainScreen tetrisMainScreen(display, buttons, random);
    TanksMainScreen tanksMainScreen(display, buttons, random);
    SnakeMainScreen snakeMainScreen(display, buttons, random);
    SnoopyMainScreen snoopyMainScreen(display, buttons, random);
    // link screens
    initialSplashScreen.setNextScreen(mainMenuScreen);
    mainMenuScreen.setBackScreen(initialSplashScreen);
    mainMenuScreen.setTetrisScreen(tetrisMainScreen);
    mainMenuScreen.setTanksScreen(tanksMainScreen);
    mainMenuScreen.setSnakeScreen(snakeMainScreen);
    mainMenuScreen.setSnoopyScreen(snoopyMainScreen);
    // main loop
    ScreenManager m(initialSplashScreen, nullptr);
    MyListener myListener(display, buttons, m);
    Timer::init(myListener, 20_msForTimer);
    while (true)
        asm volatile ("wfi");
}

No se usan variables globales (no son necesarias):

1. Se inicializa el hardware: el controlador de interrupciones, la pantalla, la botonera y el generador de números pseudoaleatorios.

2. Se construyen todas las pantallas: A todas les pasamos el objeto display y el objeto buttons (a algunas de ellas se les pasa el generador del números pseudoaleatorios).

3. Se enlazan las pantallas: Cada objeto de clase Screen debe tener los punteros a las pantallas hacia las que puede irse a partir de él. Por ejemplo, la pantalla initialSplashScreen debe saber que debe ir a la pantalla mainMenuScreen cuando pulsen un botón. De la misma forma la pantalla de menú, que debe saber a qué pantalla se salta con cada opción.

4. Justo antes del bucle principal: Se le indica al ScreenManager cual es pantalla inicial (la que debe aparecer en el arranque).

5. Bucle principal: En este caso, para ahorrar energía, no se hace el típico bucle "while (true)" sino que se programa el timer del sistema para dispararse cada 20 milisegundos y en la función miembro "timerExpired" del objeto escuchador del timer, se invoca la máquina el "run" de los botones y el "run" del ScreenManager, que es la función miembro encargada de gestionar las pantallas (llamar a onLoad/onUpdate/onUnload de las pantallas). Haciendo el bucle principal podemos utilizar la instrucción ensamblador "wfi" (wait for interrupt) para que, entre iteraciones, el procesador pueda dormirse y así evitar que se consuma mucha batería.



Siguiente entrega

En la siguiente entrega se analizará el diseño y la implementación del Tetris (uno de los cuatro juegos que incluye la miniconsola).



Todo el código y los diseños están en la sección soft.

[ añadir comentario ] ( 156 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.9 / 129 )
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 ] ( 21391 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 678 )
Receptor de radio superheterodino basado en FPGA 
Un receptor de radio superheterodino es un receptor de radio que realiza un proceso de mezcla de frecuencias para convertir la señal de la antena a una frecuencia fija fácilmente procesable por los circuitos de demodulación, siendo la gran mayoría de los receptores de radio actuales de este tipo. En este pequeño proyecto se aborda la implementación de un receptor de radio superheterodino pero implementando la mayor parte del proceso directamente en circuitos digitales dentro de una FPGA.

Heterodinización

El proceso de heterodinización consiste en trasladar la frecuencia de una emisora que queremos sintonizar a otra frecuencia que es más cómoda a nivel electrónico o de procesamiento, para demodular. Este proceso se consigue en circuitería analógica normalmente mediante lo que se denomina un mezclador (multiplicador) en combinación con un oscilador: Si multiplicamos la señal que llega de una antena por una señal sinusoidal de un oscilador local conseguimos realizar un desplazamiento de todas las frecuencias que llegan a la antena de tal manera que si tenemos una emisora en $f_1$ y nuestro oscilador local genera una señal en $f_2$, el resultado serán dos señales con las mismas características que $f_1$ pero desplazadas en frecuencia: una en $f_1 + f_2$ y otra en $f_1 - f_2$.

Si denominamos a $f_1 - f_2 = f_i$ frecuencia intermedia podemos dejar pasar sólo dicha frecuencia mediante un filtro paso-banda (con la ventaja añadida de que dicho filtro es de frecuencia fija) y realizar todo el proceso de demodulación basándonos sólo en esta frecuencia intermedia, independientemente de a qué frecuencia esté emitiendo la emisora (independientemente de $f_1$) puesto que con el mezclador y el oscilador local ya desplazamos la señal de la emisora como si emitiese en $f_i$. En los receptores superheterodinos lo que se hace normalmente es elegir una frecuencia $f_i$ relativamente cómoda (el estándar es 455 KHz para AM y 10.7 MHz para FM). De esta manera, por ejemplo, para un receptor AM comercial que deba recibir emisoras en la banda entre 530 y 1710 KHz, su oscilador local generará frecuencias en el rango de 985 a 2165 KHz; así, para recibir una emisora que emita a 576 KHz, el receptor generará una señal en su oscilador local de 1031 KHz que, al ser multiplicada por la señal de antena, proporcionará un par de frecuencias resultado de esa multiplicación estando una de dichas frecuencias en 455 KHz. Y así con cualquier emisora: basta con alterar la frecuencia del oscilador local para cambiar de emisora, el resto de la circuitería del receptor trabaja a 455 KHz.

Implementación en digital

Como objetivo inicial nos planteamos un receptor sencillo AM para la banda comercial, puesto que la demodulación en amplitud suele ser un proceso más sencillo que la demodulación en frecuencia (FM). Como se vio anteriormente el proceso de heterodinización consiste básicamente en multiplicar la señal de antena por otra señal procedente de un oscilador local. El primer escollo que nos encontramos es la lectura de la señal de la antena y su posterior conversión analógico-digital.

Amplificador analógico para la antena

El amplificador analógico de antena hace una amplificación de banda ancha (no sintonizada) pero necesaria para que el ADC pueda detectar señal. He utilizado una configuración estándar de amplificador en emisor común.



En las pruebas con el prototipo se optó por ajustar las dos resistencias de la base de forma empírica con un potenciómetro ajustable de 10 K en modo divisor de tensión hasta que la calidad fuera la mejor posible. Al usarse un transistor 2N3904 la resistencia del colector sí se calculó utilizando las curvas características:



Usamos el valor de $220 \Omega$ para $R_c$ puesto que con ese valor tenemos una recta de carga con mínima distorsión y ganancia razonable, que toca, en el eje X, al punto $V_{cc} = 3.3 V$ y, en el eje Y, al punto ${3.3 \over 220} = 0.015 A$.

ADC para la entrada de la antena

Tratamos de usar una conversión "barata" de tipo delta-sigma, de la que hablamos en una entrada anterior, usando un comparador LVDS interno de la FPGA (todas las FPGA vienen con entradas diferenciales incorporadas basadas en comparadores LVDS). Este tipo de conversión es muy eficiente, permite resolución arbitraria pero, a cambio, requiere mucho sobremuestreo (oversampling) para obtener lecturas fiables. Al tener nuestra FPGA un reloj a 50 MHz, el sobremuestreo nos puede resultar muy caro a efectos de ancho de banda: por ejemplo para obtener una resolución de 8 bits en el ADC ya no podríamos muestrear a 50 MHz, sino a ${50000000 \over {2^8}} = 195 \: KHz$ con lo cual el ancho de banda del ADC caería a los 97 KHz y ya nos iríamos fuera del rango de la banda AM que queríamos abarcar inicialmente.

¿Qué pasa si, manteniendo la frecuencia de reloj de 50 MHz, subimos la frecuencia de muestreo a costa de una pérdida de resolución en el ADC? Más aún ¿Qué pasa si nos vamos al caso extremo de poner la frecuencia de muestreo a 50 MHz y de considerar un ADC de 1 bit de resolución? Bueno, uno puede pensar, a priori que esa pérdida en los bits de resolución es inasumible, pero lo cierto es que, si el ADC es de tipo delta-sigma, aunque la resolución del ADC sea de 1 bit, la anchura de los pulsos será proporcional al nivel de la entrada y, a nivel espectral, la señal de entrada seguirá siendo fiel reflejo de lo que llega por la antena, al menos hasta cierta frecuencia. Bueno, probemos entonces con un ADC de 1 bit a ver qué tal.

Elección de la frecuencia intermedia

Como se vio al principio, en los circuitos electrónicos, lo usual es elegir frecuencias intermedias que sean cómodas de cara al cálculo de componentes, de cara a la minimización del ruido, precio, rendimiento, etc. Sin embargo si estamos realizando el mezclado (la multiplicación) de las señales y la posterior demodulación dentro de una FPGA, la elección de la frecuencia intermedia (los 455 KHz que elegimos para el receptor AM) se convierte en una elección totalmente arbitraria: podríamos elegir la frecuencia que quisiéramos. En el caso que nos ocupa, y siendo un receptor AM, nos convendrá una frecuencia intermedia que sea muy fácilmente demodulable con los recursos de los que disponemos dentro de una FPGA. Pongámonos en el lado del transmisor y analicemos cómo es una señal modulada en AM:



Cuando modulamos una señal senoidal de alta frecuencia (la frecuencia a la que emite la emisora o señal portadora) en amplitud usando una señal de baja frecuencia (música, voz, sonido, etc.), el resultado es una señal que sigue estando centrada en la portadora, pero que está acompañada de dos "lóbulos", uno hacia arriba y otro hacia abajo en el espectro: dichos lóbulos son la señal del sonido (señal moduladora) que modula a la señal portadora que se encuentra desplazada hasta esas zonas. Ambos "lóbulos" de modulación son simétricos.

Esto es, si, en el transmisor, yo emito a 576 KHz y modulo la señal en amplitud (AM) con un tono de 1 KHz estoy generando tres señales: una a 575 KHz, otra a 576 KHz (la portadora central de la banda, esta siempre estará) y otra a 577 KHz. Si al tono de 1 KHZ le añado otro tono de 2KHz se comenzarán a producir 5 señales en la antena: 574, 575, 576 (frecuencia central), 577 y 578 KHz. Como se puede apreciar el proceso de modulación AM es muy parecido al proceso de heterodinización, ya que se producen frecuencias sumas y resta (de hecho la modulación AM no deja de ser también una multiplicación de señales).

Cuando en el receptor desplazamos la señal al mezclarse (multiplicarse) con la señal del oscilador local, desplazamos todo por igual. Por ejemplo, supongamos que dentro de la FPGA queremos adoptar el mismo estándar que se utiliza en circuitería analógica y queremos desplazar hasta 455 KHz. Si queremos sintonizar una emisora que emite a 576 KHz podríamos hacer que un oscilador local (ya veremos cómo implementarlo) genere una señal a 1031 KHz, esto generará a la salida del multiplicador, dos señales, una a 455 KHz y otra a 1607 KHz (esta última habría que eliminarla mediante filtros digitales). Una vez aislada la señal de 455 KHz podremos realizar el proceso de demodulación.

Si esta emisora que emite a 576 KHz transmite en AM un tono a 1 KHz, tras ese proceso de mezcla y filtrado dentro de la FPGA tendremos dicho tono en 456 KHz, que habrá que extraerlo mediante alguna técnica DSP.

Zero-IF

¿No podríamos hacer algo para simplificar todo este proceso de mezclado a frecuencia intermedia seguido de demodulación de la frecuencia intermedia? Bueno, lo cierto es que, si estamos en AM, sí que se puede simplificar. Recordemos lo que comentamos antes de que cuando una emisora emite a 576 KHz y decide transmitir un tono a 1 KHz en AM, se radían tres señales: los 576 KHz de la frecuencia central y dos señales más y superpuestas a 575 y 577 KHz. La técnica Zero-IF (o "frecuencia intermedia cero") consiste en multiplicar la señal de la antena por una señal con EXACTAMENTE LA MISMA frecuencia que la emisora que transmite: por las propiedades de la multiplicación de las señales, si yo multiplico una señal con una frecuencia $f_1$ por otra señal con la misma frecuencia $f_1$, el resultado son dos señales: una con frecuencia $f_1 + f_1 = 2 \times f_1$ y otra con FRECUENCIA CERO ($f_1 - f_1$). Es decir que si nuestra emisora, que emite a 576 KHz, transmite un tono a 1 KHz y nosotros en el receptor multiplicamos la señal de la antena por otra señal a exactamente 576 KHz, desplazaremos al cero la frecuencia central de la señal recibida (576 KHz), por lo que el tono de 1 KHz que la emisora transmite y que, en la señal recibida en la antena, estaba en los lóbulos de 575 y 577 KHz, a la salida de nuestro multiplicador se convertirá en ¡Un tono de 1 KHz! Es decir, estaremos haciendo una demodulación de AM, sin necesidad de frecuencias intermedias (455 KHz) ni de complicados algoritmos de demodulación.

Simplificando el multiplicador

Lo habitual, y para garantizar una buena calidad de recepción, es que el oscilador local genere una onda senoidal (o lo más parecido a ésta) y, de hecho, es la implementación habitual que se realiza de osciladores locales en otros proyectos SDR basados en FPGA: un oscilador local que genera una onda senoidar de N bits de resolución que se multiplica por la señal que llega de la antena y luego se filtra y se demodula. Sin embargo incluso en sintonizadores analógicos o híbridos se utiliza a veces el concepto de "mezclador de conmutación", es decir un multiplicador que multiplica una señal por una onda cuadrada: siendo esto no más que dejar pasar tal cual o cambiada de signo la señal original al ritmo que marca la onda cuadrada (matemáticamente se traduce en que, cuando la señal del oscilador local está a nivel alto, multiplico la señal de entrada por 1 y, cuando está a nivel bajo, multiplico la señal de entrada por -1) . El uso de mezcladores de conmutación está muy extendido puesto que simplifican el diseño de los osciladores (un oscilador de onda cuadrada siempre es más barato de calibrar y de implementar en un circuito digital que un oscilador de onda senoidal) con la contrapartida de que el filtrado debe hacerse mejor (debido a las componentes de alta frecuencia que se generan por ser una señal cuadrada).

En nuestro caso he optado por simplificar el mezclador (multiplicador) hasta su mínima expresión. Como comentamos antes, la salida del ADC es una señal de 1 bit (que puede ser 0 o 1), si hacemos que la salida de nuestro oscilador local sea también de 1 bit, al usar la técnica de la mezcla mediante conmutación (0 o 1), la multiplicación de dichas señales podrá implementarse mediante un circuito combinacional simple de 2 bits de entrada y 1 bit de salida. Si consideramos que nuestras señales no tienen componente de continua podemos asumir que un valor binario de 0 se corresponde con un valor físico -1 mientras que un valor binario de 1 se corresponde con un valor físico de +1:

Entrada ADCEntrada osciladorSalida mezclador (multiplicador)
-1-1+1
-1+1-1
+1-1-1
+1+1+1

Si traducimos estos valores a binario de nuevo:

Entrada ADCEntrada osciladorSalida mezclador (multiplicador)
001
010
100
111

Lo que tenemos es que podemos modelar el mezclador mediante ¡Una simple puerta XNOR!

En la siguiente gráfica se puede ver como, incluso con una simplificación tan extrema como ésta (usando señales de 1 bit tanto para la señal delta-sigma como para la señal del oscilador local y "multiplicando" con una puerta XNOR), podemos conseguir un desplazamiento de frecuencia de la misma forma que si lo hiciésemos con un multiplicador "de verdad".



Se ha simulado, por simplicidad, que la señal de entrada (de antena) es de 2 Hz y que la señal del oscilador local es de 5 Hz. La primera columna se corresponde con el dominio del tiempo mientras que la segunda columna se corresponde con el dominio de la frecuencia:

1.- Al principio tenemos una señal senoidal normal de 2 Hz.

2.- A continuación calculamos una señal delta-sigma a partir de esa señal de entrada de 2 Hz (en la Wikipedia se explican ampliamente los principios de esta modulación pero podemos quedarnos con esta pequeña gráfica que resume en qué consiste esta modulación, que es lo que hace nuestro ADC).

3.- Por otro lado tenemos el oscilador local de onda cuadrada que, en este ejemplo, lo hemos puesto a 5 Hz.

4.- Multiplicamos ambas señales (XNOR) y el resultado, como se puede comprobar en las gráficas, es el deseado: se generan dos frecuencias, una suma (7 Hz) y otra resta (3 Hz), de las frecuencias de las dos señales de entrada (antena y oscilador local).

Implementación en la FPGA



Para el ADC delta-sigma se sigue una configuración estándar como la descrita en esta publicación anterior y se calcula el valor de la resistencia y del condensador de integración en función de la frecuencia de reloj de la FPGA (50 MHz) usando el criterio publicado por Lattice Semiconductor:

$$200 < R \times C \times f_{clk} < 1000$$

Para nuestro caso particular usamos los valores C = 10 nF y R = 1 K. Por otro lado tenemos el acumulador de fase que hace las veces de oscilador local y cuya constante de incremento se calcula a partir de la frecuencia queremos sintonizar (nótese que, como usamos la técnica Zero-IF, la frecuencia del oscilador local deberá ser exactamente la misma que la de la emisora que queremos sintonizar). Si queremos sintonizar 576 KHz (en mi caso es la frecuencia a la que emite Radio Nacional de España en Las Palmas de Gran Canaria) calcularemos la constante de incremento del acumulador de fase de la siguiente manera:

$$Inc = {576000 \over 50000000} \times 2^{64} = 212506491729134048$$

De esta manera en el bit 63 (el más significativo) del acumulador de fase tendremos una onda cuadrada con una frecuencia de 576 KHz. Como se comentó con anterioridad, la multiplicación la implementamos mediante una simple puerta XNOR entre el bit 63 del acumulador de fase (oscilador local de onda cuadrada) y el bit proveniente del ADC delta-sigma.

A la salida de la puerta XNOR (nuestro particular multiplicador) convertimos la señal de 1 bit en una señal de 11 bits apta para ser acumulada en el registro de diezmado (en algunos textos técnicos se hace referencia al "diezmado" como "decimación", a mi me gusta más el término "diezmado", ya que es la traducción más correcta del término "decimation" y creo que expresa mejor su cometido).

Lo que hace el acumulador de diezmado es ir sumando las muestras que llegan del multiplicador (la puerta XNOR) y cuando ha hecho 1024 sumas (o, lo que es lo mismo, cuando el contador de diezmado se desborda), se pasa el valor de la cuenta al latch de diezmado y se inicia el acumulador de diezmado de nuevo. ¿Cual es el resultado de esto? Lo que estamos haciendo es un "diezmado en tiempo" y convertir una señal con una frecuencia de muestreo de 50 MHz (los 0s y 1s que salen del multiplicador) en otra señal con una frecuencia de muestreo de ${50000000 \over {2^{10}}} = {50000000 \over 1024} = 48828.125 \simeq 49 \: KHz$. Con este diezmado en tiempo matamos dos pájaros de un tiro:

1.- Por un lado, hacemos un filtrado paso-bajo, ya que estamos "promediando" y generamos una muestra de salida por cada 1024 muestras de entrada.

2.- Por otro lado, ajustamos la frecuencia de muestreo de la señal a un valor aceptable para ser procesado por circuitos de audio.

La salida del latch de diezmado ya es apta para convertirla a PWM y sacarla por un altavoz.

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

entity CycloneIIAMReceiver is
    port (
        ClkIn         : in std_logic;   -- 50 MHz
        AntennaIn     : in std_logic;
        DeltaSigmaOut : out std_logic;
        SpeakerOut    : out std_logic
    );
end entity;

architecture A of CycloneIIAMReceiver is
    -- 1-bit ADC
    signal DeltaSigmaADCD : std_logic;
    signal DeltaSigmaADCQ : std_logic;
    -- COPE AM Las Palmas: 837 KHz
    -- (837000 / 50000000) * (2 ^ 64) = 308798495793897920 (64 bit)
    -- upper 32 bit: 71897752
    -- lower 32 bit: 2297979328
    ----constant UPPER_LOCAL_OSC_INC : integer := 71897752;
    ----constant LOWER_LOCAL_OSC_INC : integer := 2297979328;
    -- RNE AM Las Palmas: 576 KHz
    -- (576000 / 50000000) * (2 ^ 64) = 212506491729134048 (64 bit)
    -- upper 32 bit: 49478023
    -- lower 32 bit: 1073398240
    constant UPPER_LOCAL_OSC_INC : integer := 49478023;
    constant LOWER_LOCAL_OSC_INC : integer := 1073398240;
    constant LOCAL_OSC_INC : std_logic_vector(63 downto 0) := std_logic_vector(to_unsigned(UPPER_LOCAL_OSC_INC, 32)) & std_logic_vector(to_unsigned(LOWER_LOCAL_OSC_INC, 32));
    signal LocalOscAccD : std_logic_vector(63 downto 0);
    signal LocalOscAccQ : std_logic_vector(63 downto 0);
    signal LocalOscOut : std_logic;
    -- mixer
    signal MixerOut : std_logic;
    signal NumericMixerOut : std_logic_vector(10 downto 0);
    -- decimator (factor = 1024 = 2^10, so pass from 50 MHz to 48.8 KHz (50000000 / 1024)
    signal DecimatorCounterD : std_logic_vector(9 downto 0);
    signal DecimatorCounterQ : std_logic_vector(9 downto 0);
    signal DecimatorAccD : std_logic_vector(10 downto 0);
    signal DecimatorAccQ : std_logic_vector(10 downto 0);
    signal DecimatorLatchD : std_logic_vector(10 downto 0);
    signal DecimatorLatchQ : std_logic_vector(10 downto 0);
    signal DemodulatedOutput : std_logic_vector(9 downto 0);
begin
    -- delta-sigma ADC for input
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DeltaSigmaADCQ <= DeltaSigmaADCD;
        end if;
    end process;

    DeltaSigmaADCD <= AntennaIn;
    DeltaSigmaOut <= DeltaSigmaADCQ;

    -- local oscillator
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            LocalOscAccQ <= LocalOscAccD;
        end if;
    end process;

    LocalOscAccD <= std_logic_vector(unsigned(LocalOscAccQ) + unsigned(LOCAL_OSC_INC));
    LocalOscOut <= LocalOscAccQ(63);

    -- mixer (multiplier)
    MixerOut <= LocalOscOut xnor DeltaSigmaADCQ;
    NumericMixerOut <= std_logic_vector(to_unsigned(1, 11)) when (MixerOut = '1') else
                       std_logic_vector(to_unsigned(0, 11));

    -- decimator
    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorCounterQ <= DecimatorCounterD;
        end if;
    end process;

    DecimatorCounterD <= std_logic_vector(unsigned(DecimatorCounterQ) + to_unsigned(1, 10));

    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorAccQ <= DecimatorAccD;
        end if;
    end process;

    DecimatorAccD <= NumericMixerOut when (unsigned(DecimatorCounterQ) = 0) else
                     std_logic_vector(unsigned(DecimatorAccQ) + unsigned(NumericMixerOut));

    process (ClkIn)
    begin
        if (ClkIn'event and (ClkIn = '1')) then
            DecimatorLatchQ <= DecimatorLatchD;
        end if;
    end process;

    DecimatorLatchD <= DecimatorAccQ when (unsigned(DecimatorCounterQ) = 0) else
                       DecimatorLatchQ;
    DemodulatedOutput <= DecimatorLatchQ(10 downto 1);

    -- PWM for speaker output
    SpeakerOut <= '1' when (unsigned(DecimatorCounterQ) > unsigned(DemodulatedOutput)) else
                  '0';
end architecture;


Salida PWM

La parte de la salida PWM de la FPGA lo que hace es convertir la señal del latch de diezmado en un tren de pulsos PWM que se conecta directamente a un amplificador de audio externo. El bit de salida PWM se calcula comparando el valor del latch de diezmado con el contador de diezmado, lo que provoca que la anchura de los pulsos de salida (un único bit que va al amplificador) sea proporcional al valor del latch de diezmado. Este bit (modulado en PWM) puede atacar directamente a la entrada de cualquier amplificador de audio.

Esquema eléctrico final



Resultados

Los resultados distan mucho de considerarse de calidad, el ruido en la recepción es alto (sólo a mi se me ocurre montar un circuito de radio en una protoboard...), pero "se entiende" más o menos lo que dice :-). El amplificador de antena ha sido la parte que, de lejos, más trabajo me ha dado, ya que tengo que reconocer que mi fuerte no es la electrónica analógica y menos a estas frecuencias de trabajo.



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

[ 2 comentarios ] ( 21545 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 869 )
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 ] ( 2341 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 2.9 / 1098 )
Sintetizador monofónico basado en FPGA: Parser MIDI mejorado y filtro paso-bajo de segundo orden 
Partiendo del montaje realizado en el post anterior, se han realizado varias modificaciones y mejoras. El parser MIDI de esta segunda iteración genera ahora 3 señales de control, de 7 bits cada una, que se utilizan para controlar la frecuencia de corte, la resonancia y la ganancia de la entrada de un filtro paso bajo de segundo orden:

Este sería el diagrama de bloques de esta segunda iteración:




Parser MIDI mejorado


En la versión iniciar el parser MIDI no se tuvieron en cuenta algunas características "raras" que se dan el algunos teclados controladores y al mismo tiempo se asumía que un "note off" posterior a un "note on" siempre era de la misma tecla, lo cual es demasiado suponer, sobre todo cuando quien toca es un humano. Cuando un humano toca una secuencia de notas en un teclado (por ejemplo: La, Mi, Do) uno puede pensar que los mensaje que manda el teclado controlador son los siguientes:
noteOn(La), noteOff(La), noteOn(Mi), noteOff(Mi), noteOn(Do), noteOff(Do)

Sin embargo lo cierto es que a veces un humano pulsa la siguiente tecla al mismo tiempo o antes de soltar la anterior:
noteOn(La), noteOn(Mi), noteOff(La), noteOff(Mi), noteOn(Do), noteOff(Do)

Con la anterior versión del parser, que asumía que un noteOff se correspondía siempre con el noteOn inmediatamente anterior, lo que ocurría era que cuando al sinte le llegaba el noteOff(La) callaba la nota Mi disparada justo antes porque asumía que ese noteOff se correspondía con dicha nota Mi. En la nueva versión del parse este noteOff(Mi) es ignorado por la máquina de estados por lo que la respuesta del sintetizador es más natural.

Para mejorar el comportamiento y la funcionalidad del parser MIDI se ha optado por un diseño basado en máquinas de estado en serie y en paralelo en lugar de una única máquina de estados grande. El parser MIDI se ha divido en dos etapas (Stage1 y Stage2), la primera etapa genera señales "KeyOn" y "KeyOff" limpias por cables separados y además implementa en paralelo una máquina de estados aparte para procesar los mensajes de "Control Change". En la segunda etapa se implementa la lógica anteriormente descrita de ignorar los "Note Off" que no se corresponden con el mensaje "Note On" inmediatamente anterior.







De esta forma, aunque aparentemente se ha complicado el diseño, se han separado los problemas y es más sencillo introducir modificaciones y depurar errores en las máquinas de estado. Cada una por separado es más sencilla y fácil de trazar que una hipotética máquina de estados única para todo.

Además de la mejora en el procesado de los mensajes "Note On" y "Note Off", este parser ya reconoce mensajes de tipo "Control Change", en concreto para tres valores prefijados de controlador: 71, 74 y 16, que se asignarán en el sintetizador a la frecuencia de corte del filtro, la resonancia del filtro y la ganancia de entrada del filtro.


Filtro paso bajo de segundo orden


Se ha optado por la implementación estándar de un filtro de estado variable (state variable filter). Se trata de un filtro de segundo orden (dos polos) que genera simultáneamente 3 salidas:

- paso bajo (con pendiente de filtrado de 12 dB/octava)
- paso alto (con pendiente de filtrado de 12 dB/octava)
- paso banda (con pendiente de filtrado de 6 dB/octava)

No son grandes pendientes de filtrado pero siempre se pueden mejorar poniendo varios filtros en cascada. La implementación que se ha utilizado es la descrita en el libro "Musical Applications of Microprocessors" de Hal Chamberlin (dicha implementación ya fue usada sobre un microcontrolador en este post). El filtro de estado variable viene determinado por el siguiente sistema de ecuaciones en diferencias finitas:

$$pasoAlto[n] = entrada - ({r \times pasoBanda[n-1]}) - pasoBajo[n]$$
$$pasoBanda[n] = ({f \times pasoAlto[n]}) + pasoBanda[n - 1]$$
$$pasoBajo[n] = ({f \times pasoBanda[n - 1]}) + pasoBajo[n - 1]$$

Siendo:

$$f = 2\sin\left({\pi F_c \over F_s}\right)$$
$$r = {1 \over Q}$$

Siendo $F_c$ la frecuencia de corte del filtro, $F_s$ la frecuencia de muestreo y $Q$ la Q del filtro (la resonancia).

Si se reordenan las ecuaciones en diferencias:

$$pasoBajo[n] = ({f \times pasoBanda[n - 1]}) + pasoBajo[n - 1]$$
$$pasoAlto[n] = entrada - ({r \times pasoBanda[n - 1]}) - pasoBajo[n]$$
$$pasoBanda[n] = ({f \times pasoAlto[n]}) + pasoBanda[n - 1]$$

Podemos olvidarnos de los índices:
pasoBajo += f * pasoBanda
pasoAlto = entrada - (r * pasoBanda) - pasoBajo
pasoBanda += f * pasoAlto

Como se puede apreciar es preciso mantener en memoria (registro) al menos las variables pasoBajo y pasoBanda entre que se procesa una muestra y la siguiente (se trata de un filtro digital de segundo orden).

Para implementar dicho filtro sobre FPGA lo que necesitaremos serán básicamente los siguientes elementos:

- Al menos tres registros en los que almacenaremos los valores "pasoBajo", "pasoBanda" y "pasoAlto" (aunque realmente podríamos no gastar un registro para "pasoAlto", lo vamos a incluir para poder disponer de esa salida en el módulo).
- Una unidad de suma con multiplicación: Un módulo combinacional que realiza la operación: A = (B * C) + D (en muchos casos D = A, por lo que se puede ver como A += B * C)
- Una máquina de estados para controlar qué operandos y operaciones se hacen en cada momento.

Con estos elemento y teniendo en cuenta las ecuaciones anteriores, podemos hacer una propuesta de secuenciación de operaciones como sigue:

1. LP := (cutoff * BP) + LP
2. HP := (0 * x ) + IN
3. HP := (-reso * BP) + HP
4. HP := (-1 * LP) + HP
5. BP := (cutoff * HP) + BP

Cada paso requiere un único ciclo de reloj por lo que bastará con implementar una máquina de estados que, por cada muestra que llegue, pase por los 5 estados de forma secuencial para que los registros LP, BP y HP (LowPass, BandPass y HighPass) tengan los valores de salida del filtro que necesitamos. Nótese que será preciso utilizar aritmética de punto fijo y en nuestro caso se ha optado por un formato Q16.16 (16 bits de parte entera y 16 bits de parte fraccionaria).

A continuación puede verse como quedaría la implementación del filtro en VHDL:

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

entity StateVariableFilter is
    port (
        Reset       : in std_logic;
        Clk         : in std_logic;
        EnableIn    : in std_logic;
        SampleIn    : in std_logic_vector(15 downto 0);
        CutOffIn    : in std_logic_vector(31 downto 0);    -- 0..1  fixed point Q16.16
        ResonanceIn : in std_logic_vector(31 downto 0);    -- 0..1  fixed point Q16.16
        SampleOut   : out std_logic_vector(15 downto 0);
        EnableOut   : out std_logic
    );
end entity;

architecture RTL of StateVariableFilter is
    signal LPDBus : std_logic_vector(31 downto 0);
    signal LPQBus : std_logic_vector(31 downto 0);
    signal HPDBus : std_logic_vector(31 downto 0);
    signal HPQBus : std_logic_vector(31 downto 0);
    signal BPDBus : std_logic_vector(31 downto 0);
    signal BPQBus : std_logic_vector(31 downto 0);
    signal MultOperandA : std_logic_vector(31 downto 0);
    signal MultOperandB : std_logic_vector(31 downto 0);
    signal MultResult64 : std_logic_vector(63 downto 0);
    signal MultResult : std_logic_vector(31 downto 0);
    signal AddOperandB : std_logic_vector(31 downto 0);
    signal AddResult : std_logic_vector(31 downto 0);
    signal NegResonance : std_logic_vector(31 downto 0);
    signal FSMDBus : std_logic_vector(2 downto 0);
    signal FSMQBus : std_logic_vector(2 downto 0);
begin
    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            LPQBus <= LPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            HPQBus <= HPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            BPQBus <= BPDBus;
        end if;
    end process;

    process (Clk)
    begin
        if (Clk'event and (Clk = '1')) then
            FSMQBus <= FSMDBus;
        end if;
    end process;

    NegResonance <= std_logic_vector(to_signed(-to_integer(signed(ResonanceIn)), 32));
    MultOperandA <= CutOffIn when ((FSMQBus = "001") or (FSMQBus = "101")) else
                    NegResonance when (FSMQBus = "011") else
                    std_logic_vector(to_signed(-65536, 32)) when (FSMQBus = "100") else  -- -65536 es -1 en notación Q16.16
                    std_logic_vector(to_signed(0, 32));
    MultOperandB <= LPQBus when (FSMQBus = "100") else
                    BPQBus when ((FSMQBus = "001") or (FSMQBus = "011")) else
                    HPQBus;
    AddOperandB <= LPQBus when (FSMQBus = "001") else
                   BPQBus when (FSMQBus = "101") else
                   HPQBus when ((FSMQBus = "011") or (FSMQBus = "100")) else
                   std_logic_vector(to_signed(to_integer(signed(SampleIn)), 32));
    --MultResult64 <= std_logic_vector(to_signed(to_integer(signed(MultOperandA)) * to_integer(signed(MultOperandB)), 64));
    MultResult64 <= std_logic_vector(signed(MultOperandA) * signed(MultOperandB));
    MultResult <= MultResult64(47 downto 16);
    --AddResult <= std_logic_vector(to_signed(to_integer(signed(MultResult)) + to_integer(signed(AddOperandB)), 32));
    AddResult <= std_logic_vector(signed(MultResult) + signed(AddOperandB));
    LPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when (FSMQBus = "001") else
              LPQBus;
    HPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when ((FSMQBus = "011") or (FSMQBus = "100") or (FSMQBus = "010")) else
              HPQBus;
    BPDBus <= std_logic_vector(to_signed(0, 32)) when (Reset = '1') else
              AddResult when (FSMQBus = "101") else
              BPQBus;

    -- fsm
    --    LP += cutoff * BP
    --    HP = in - (resonance * BP) - LP
    --    BP += cutoff * HP
    FSMDBus <= "000" when ((Reset = '1') or (FSMQBus = "110")) else       --       MultOperandA   MultOperandB   AddOperandB
               "001" when ((FSMQBus = "000") and (EnableIn = '1')) else   -- LP := cutoff       * BP           + LP
               "010" when (FSMQBus = "001") else                          -- HP := 0            * x            + IN
               "011" when (FSMQBus = "010") else                          -- HP := -reso        * BP           + HP
               "100" when (FSMQBus = "011") else                          -- HP := -1           * LP           + HP
               "101" when (FSMQBus = "100") else                          -- BP := cutoff       * HP           + BP
               "110" when (FSMQBus = "101") else
               "000";
    EnableOut <= '1' when (FSMQBus = "110") else
                 '0';
    SampleOut <= std_logic_vector(to_signed(-32768, 16)) when (to_integer(signed(LPQBus)) < -32768) else
                 std_logic_vector(to_signed(32767, 16)) when (to_integer(signed(LPQBus)) > 32767) else
                 LPQBus(15 downto 0);
end architecture;


La máquina de estados espera hasta que la entrada "EnableIn" se ponga a "1", dicho evento es la señal que indica al filtro que debe realizar una iteración (i.e. calcular la siguiente muestra a partir de la entrada "SampleIn").



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

[ 1 comentario ] ( 1232 visualizaciones )   |  [ 0 trackbacks ]   |  enlace permanente  |   ( 3 / 2619 )

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