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 ] ( 2446 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 2.9 / 1268 )