Introducción
Mi hijo recibió por su cumpleaños un despertador con temática Minecraft que le gustó mucho salvo por el sonido que tenía como despertador, que estaba prefijado y era una supuesta grabación "inspirada" en el juego.
Dicha grabación se escucha saturada y con poca calidad por lo que resulta desagradable de escuchar y mi hijo me pidió que intentara cambiarla.
En el siguiente vídeo grabado por un youtuber que hace reviews de este tipo de cosas puede escucharse el sonido original que trae este despertador (instante 4:50 aprox.).
Planteamiento del problema
Se parte de un reloj despertador con una circuitería muy cerrada y no documentada y el objetivo es cambiar el sonido que se escucha cuando suena la alarma, que, de fábrica, es un sonido pregrabado:
Las placas tienen escasa serigrafía y el único integrado visible está "borrado". Lo ideal sería obtener una señal digital que indique la activación de la alarma.
Investigación
El cable plano que une la placa principal con la placa de botones, leds y el chip de audio es un cable plano de 10 hilos que sólo tiene los dos extremos serigrafiados como "vdd" y "gnd".
Si a los 10 cables les quitamos los dos de alimentación de los extremos quedan 8 cables que no se sabe para qué son, sin embargo se intuye, dada la funcionalidad de esa placa, que esos 8 cables están distribuidos de la siguiente manera:
- 6 señales para leer los botones (el reloj tiene una cruceta de 4 botones más 2 botones adicionales).
- 1 señal para controlar los leds blancos (que se usan para la funcionalidad de lámpara).
- 1 señal para controlar la música (cuando suena la alarma).
Tras varias pruebas se verifica que la distribución de las señales es la siguiente.
Y que la señal "alarma" utiliza lógica positiva: se pone a 5 voltios para que suene la alarma y se pone a 0 voltios para apagar la alarma.
Desarrollo de la solución
Teniendo localizado el cable de la señal "alarma" el objetivo ahora es hacer un pequeño montaje que permita reproducir otro tipo de música o sonido en el despertador y para ello se plantea el siguiente esquema eléctrico:
Toda la circuitería del reloj despertador y del DFPlayer Mini funciona con 5 voltios, pero el CH32V003 funciona con 3.3 voltios. En este caso se utiliza una placa "nanoCH32V003" para que pueda alimentarse a 5 voltios de la propia fuente del reloj despertador. El cable de alarma se conecta a PC1 o a cualquier otra entrada del CH32V003 siempre y cuando sea un pin tolerante a 5 voltios (no todos los pines de ese microcontrolador lo son).
El DFPlayer Mini es un módulo de reproducción de MP3 que incluye lector de tarjeta de memoria microSD. Dispone de dos pines dedicados (ADKEY1 y ADKEY2) que, cuando se ponen a masa reproducen, respectivamente, el primer y el quinto MP3 de la tarjeta de memoria. El módulo carece de pines para detener la reproducción pero, como en este caso sólo se precisa que hayan único sonido, se opta por una solución simple:
- Como 1er sonido de la tarjeta de memoria se pone el sonido que queremos que tenga el despertador.
- Como 5o sonido de la tarjeta de memoria se pone un MP3 de un segundo de silencio.
De esta forma poniendo ADKEY1=0 se reproduce el sonido nuevo y poniendo ADKEY2=0 "paramos" la reproducción al reproducir el MP3 de silencio.
Se configuran las salidas PA1 y PA2 del microcontrolador como salidas en colector abierto (open drain): emitiendo un 1 se ponen en alta impedancia y emitiendo un 0 se ponen a masa, que es el comportamiento que se quiere para controlar el DFPlayer Mini:
(imagen extraída de www.prometec.net)
PA1 y PA2 se configuran en open-drain debido a dos razones:
- El DFPlayer Mini ya dispone de resistencias pull-up en las entradas ADKEY1 y ADKEY2.
- El DFPlayer Mini está alimentado a 5 voltios mientras que el CH32V003 lo está a 3.3 voltios, por lo que un "1" del CH32V003 no sería igual que un "1" para el DFPlayer Mini. Usando salidas en open-drain nos aseguramos de que la corriente que circula por los cables PA1 y PA2 proviene del DFPlayer Mini (5 voltios), no del CH32V003 (3.3 voltios).
Código
El código del microcontrolador CH32V003 será el encargado de muestrear el cable "alarm" a intervalos regulares (cada 100 milisegundos). Cuando el cable "alarm" se ponga a 1 el microcontrolador pondrá a 0 (masa) durante un tiempo prefijado la salida conectada a ADKEY1 y cuando el cable "alarm" pase de nuevo a 0, el microcontrolador pondrá el pin conectado a ADKEY2 a 0 (masa) también durante un tiempo prefijado (el correspondiente a reproducir un silencio). La máquina de estados tendrá, por tanto una entrada (la señal del cable "alarm") y dos salidas (que gobiernan las salidas en colector abierto de ADKEY1 y ADKEY2).
El reloj de esta máquina de estados viene dado por el timer del microcontrolador, que tiene un período de 100 milisegundos:
- N_SILENCE es la cantidad de ticks de reloj que debe permanecer a masa la salida "silencio".
- N_SOUND es la cantidad de ticks de reloj que debe permanecer a masa la salida "sonido".
La cantidad total de milisegundos que se ponga a masa cada salida será de N_SILENCE * 100 y de N_SOUND * 100 milisegundos respectivamente.
void AlarmControl::run() { Status localStatus = this->status; do { this->status = localStatus; if (localStatus == Status::RESET) { this->io.stopTrigSound(); this->io.stopTrigSilence(); localStatus = Status::TRIG_SILENCE; } else if (localStatus == Status::TRIG_SILENCE) { this->io.startTrigSilence(); this->counter = N_SILENCE; localStatus = Status::WAIT_SILENCE; } else if (localStatus == Status::WAIT_SILENCE) { this->counter--; if (this->counter == 0) { this->io.stopTrigSilence(); localStatus = Status::WAIT_1; } } else if (localStatus == Status::WAIT_1) { if (this->io.getAlarm()) localStatus = Status::TRIG_SOUND; } else if (localStatus == Status::TRIG_SOUND) { this->io.startTrigSound(); this->counter = N_SOUND; localStatus = Status::WAIT_SOUND; } else if (localStatus == Status::WAIT_SOUND) { this->counter--; if (this->counter == 0) { this->io.stopTrigSound(); localStatus = Status::WAIT_0; } } else if (localStatus == Status::WAIT_0) { if (!this->io.getAlarm()) localStatus = Status::TRIG_SILENCE; } } while (localStatus != this->status); }
La máquina de estados se implementa en la clase "AlarmControl" que decibe en su construcción una referencia a un objeto que debe heredar de "AlarmControlIO".
class AlarmControlIO { public: virtual bool getAlarm() = 0; virtual void startTrigSound() = 0; virtual void stopTrigSound() = 0; virtual void startTrigSilence() = 0; virtual void stopTrigSilence() = 0; }; class AlarmControl { protected: AlarmControlIO &io; enum class Status { RESET, TRIG_SILENCE, WAIT_SILENCE, WAIT_1, TRIG_SOUND, WAIT_SOUND, WAIT_0 }; Status status; uint32_t counter; static const uint32_t N_SILENCE = 4; static const uint32_t N_SOUND = 4; public: AlarmControl(AlarmControlIO &alarmControlIO) : io(alarmControlIO), status(Status::RESET) { }; void run(); };
En el fichero "main.cc" se define una clase "MyListener" que tiene un objeto de tipo "AlarmControl" como propiedad y que hereda de:
- "TimerListener" (lo que la obliga a incluir la función miembro "timerExpired()" que se ejecutará cada vez que se desborde el contador de SysTick cada 100 milisegundos).
- "AlarmControlIO" (lo que la obliga a incluir las funciones miembro "bool getAlarm()" para leer el estado de la señal "Alarm" y las otras cuatro funciones "void startTrigSound()", "void stopTrigSound()", "void startTrigSilence()" y "void stopTrigSilence()" para controlar los pines PA1 y PA2 que son los que gobiernan el reproductor MP3 DFPlayer Mini).
En la función miembro "timerExpired()" se invoca, a su vez, a la función miembro "run()" del objeto "AlarmControl" para que se vaya iterando la máquina de estados cada 100 milisegundos.
void MyListener::timerExpired() { this->alarmControl.run(); } bool MyListener::getAlarm() { return (GPIOC_INDR & (((uint32_t) 1) << 1)) ? true : false; // read PC1 } void MyListener::startTrigSound() { GPIOA_OUTDR &= ~(((uint32_t) 1) << 1); // PA1 = 0 } void MyListener::stopTrigSound() { GPIOA_OUTDR |= (((uint32_t) 1) << 1); // PA1 = high z } void MyListener::startTrigSilence() { GPIOA_OUTDR &= ~(((uint32_t) 1) << 2); // PA2 = 0 } void MyListener::stopTrigSilence() { GPIOA_OUTDR |= (((uint32_t) 1) << 2); // PA2 = high z } int main() { interruptInit(); MyListener myListener; Timer::init(myListener, 100_msForTimer); while (true) asm volatile ("wfi"); }
En la función "main()" simplemente se declara un objeto de tipo "MyListener" (la construcción de este objeto también incluye la construcción del objeto "AlarmControl" debido a que es una propiedad del primero), se inicializa el Timer y nos quedamos en bucle infinito de "wfi" (para consumir poca energía entre tick y tick de la máquina de estados).
Consideraciones particulares entorno al microcontrolador CH32V003
El CH32V003 es un RISC-V muy barato y muy limitado, al contrario que el GD32VF103 que se ha utilizado hasta ahora para otros proyectos con microcontrolador, el CH32V003 tiene las siguientes características:
- 16 Kb de memoria flash para programa.
- 2 Kb de memoria SRAM.
- El controlador de interrupciones no es el estándar CLIC, sino un controlador propietario (llamado PFIC) que se encuentra documentado en el manual de referencia del microcontrolador.
- El núcleo es un "QingKeV2", un RISC-V de perfil RV32EC. E = Embedded (16 registros de propósito general en lugar de los 32 del perfil RV32I) y C = Compressed (acepta instrucciones comprimidas de 16 bits, una especie de equivalente al modo "thumb" de los Cortex-M de ARM).
- Un "SysTick" integrado en el núcleo parecido al que tienen los Cortex-M de ARM.
En la sección soft hay disponible un pequeño proyecto de blinker (ch32v003-pfic-blinker) que utiliza el controlador de interrupciones de este microcontrolador junto con ese "systick" para hacer parpadear un led. Nótese los flags que se pasan al compilador:
-march=rv32eczicsr -mabi=ilp32e
Y la forma en que se habilitan las interrupciones en "interrupt.cc", que son diferentes a como se hace en el GD32VF103.
Tostar el microcontrolador
Para tostar el microcontrolador la mejor opción es pillar un programador WCH-LinkE del propio fabricante (es muy barato, a mi me costó menos de 10 ¤) ya que el protocolo de depuración y tostado es propietario del fabricante (aunque está totalmente documentado y hay proyectos en curso para no depender de ese programador hardware en particular).
El software para acceder al WCH-LinkE que utilicé es el programa "minichlink" (un sub proyecto dentro del repositorio https://github.com/cnlohr/ch32v003fun). Basta con hacer "make" en la carpeta "minichlink" dentro de ese repositorio y se genera el ejecutable "minichlink" que nos permite tostar el CH32V003:
./minichlink -w /ruta/al/main.bin 0x08000000
Montaje y resultados finales
A continuación puede verse cómo se realizaron las conexiones al conector del cable plano para extraer los 5 voltios, masa y la señal de alarma.
Cómo se realizó el montaje del prototipo con el microcontrolador y el DFPlayer Mini en una protoboard externa.
Y el paso del circuito de la protoboard a la PCB que se alojará dentro del reloj despertador:
A continuación un vídeo de demostración con el nuevo sonido (que ya no es la música de Minecraft).
El sonido que se ha puesto como sonido de alarma es el típico de los despertadores de toda la vida (que era el que quería mi hijo) y se ha descargado de https://pixabay.com/sound-effects/031974-30-seconds-alarm-72117/ (Pixabay permite uso libre de los sonidos que se descarguen de su web mientras no se revendan).
Todo el código en la seción soft.
[ añadir comentario ] ( 143 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 2.7 / 97 )
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 ] ( 351 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 326 )
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 ] ( 25626 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 829 )
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 ADC | Entrada oscilador | Salida mezclador (multiplicador) |
---|---|---|
-1 | -1 | +1 |
-1 | +1 | -1 |
+1 | -1 | -1 |
+1 | +1 | +1 |
Si traducimos estos valores a binario de nuevo:
Entrada ADC | Entrada oscilador | Salida mezclador (multiplicador) |
---|---|---|
0 | 0 | 1 |
0 | 1 | 0 |
1 | 0 | 0 |
1 | 1 | 1 |
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 ] ( 21854 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 3 / 1030 )
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 ] ( 2445 visualizaciones ) | [ 0 trackbacks ] | enlace permanente | ( 2.9 / 1268 )