Ventajas y desventajas de las máquinas de estados finitos: cajas de interruptores, punteros C/C++ y tablas de búsqueda (Parte II)
Esta es la segunda y última parte de nuestra implementación de la Máquina de Estados Finitos (FSM). Puede consultar la primera parte de la serie y conocer más generalidades sobre las máquinas de estados finitos aquí .
Las máquinas de estados finitos, o FSM, son simplemente un cálculo matemático de causas y eventos. Basado en estados, un FSM calcula una serie de eventos basados en el estado de las entradas de la máquina. Para un estado llamado SENSOR_READ , por ejemplo, un FSM podría activar un relé (también conocido como evento de control) o enviar una alerta externa si la lectura de un sensor es superior a un valor umbral. Los estados son el ADN del FSM: dictan el comportamiento interno o las interacciones con un entorno, como aceptar entradas o producir salidas, que pueden hacer que un sistema cambie su estado. Nuestro trabajo como ingenieros de hardware es elegir los estados FSM correctos y activar eventos para obtener el comportamiento deseado que se ajuste a las necesidades de nuestro proyecto.
En la primera parte de este tutorial de FSM, creamos un FSM utilizando la implementación clásica de caja de interruptor. Ahora, exploraremos la creación de un FSM utilizando punteros C/C++, lo que le permitirá desarrollar una aplicación más sólida con expectativas de mantenimiento de firmware más simples.
NOTA : El código utilizado en este tutorial fue demostrado en el Día Arduino de 2018 en Bogotá por José García, uno de los Ubidots . Puede encontrar los ejemplos de código completos y las notas del orador aquí .
Desventajas de la caja del interruptor:
En la primera parte de nuestro tutorial de FSM , analizamos los casos de cambio y cómo implementar una rutina simple. Ahora, ampliaremos esta idea introduciendo “Consejos” y cómo aplicarlos para simplificar su rutina FSM.
Una de cambio de caso es muy similar a una rutina if-else nuestro firmware recorrerá cada caso, evaluándolos para ver si se alcanza la condición de caso de activación. Veamos una rutina de muestra a continuación:
switch(state) { caso 1: /* hacer algunas cosas para el estado 1 */ state = 2; romper; caso 2: /* hacer algunas cosas para el estado 2 */ estado = 3; romper; caso 3: /* hacer algunas cosas para el estado 3 */ estado = 1; romper; default: /* hacer algunas cosas por defecto */ state = 1; }
En el código anterior, encontrará un FSM simple con tres estados. En el bucle infinito, el firmware irá al primer caso, comprobando si la variable de estado es igual a uno. En caso afirmativo, ejecuta su rutina; de lo contrario, pasa al caso 2 donde verifica nuevamente el valor del estado. Si el caso 2 no se cumple, la ejecución del código pasará al caso 3, y así sucesivamente hasta que se alcance el estado o se hayan agotado los casos.
Antes de entrar en el código, comprendamos un poco más sobre algunas posibles desventajas de las switch-case o if-else para que podamos ver cómo mejorar nuestro desarrollo de firmware.
Supongamos que la variable de estado inicial es 3: nuestro firmware deberá realizar 3 validaciones de valores diferentes. Puede que esto no sea un problema para un FSM pequeño, pero imagine una máquina de producción industrial típica con cientos o miles de estados. La rutina deberá realizar varias comprobaciones de valores inútiles, lo que en última instancia dará como resultado un uso ineficiente de los recursos. Esta ineficiencia se convierte en nuestra primera desventaja: el microcontrolador tiene recursos limitados y estará sobrecargado con rutinas FSM ineficientes. Como tal, es nuestro deber como ingenieros ahorrar tantos recursos informáticos en el microcontrolador como sea posible.
Ahora imagine un FSM con miles de estados: si es un desarrollador nuevo y necesita implementar un cambio en uno de esos estados, tendrá que examinar miles de líneas de código dentro de su rutina loop() principal. Esta rutina a menudo incluye una gran cantidad de código no relacionado con la máquina en sí, por lo que puede ser difícil depurar si centra toda la lógica FSM dentro del bucle principal().
Y finalmente, un código con miles de if-else o switch-case no es elegante ni legible para la mayoría de los programadores integrados.
Punteros C/C++
Ahora veamos cómo podemos implementar un FSM conciso usando punteros C/C++. Un puntero, como su nombre indica, apunta a algún lugar dentro del microcontrolador. En C/C++, un puntero apunta a una dirección de memoria con la intención de recuperar información. Se utiliza un puntero para obtener el valor almacenado de una variable durante la ejecución sin conocer la dirección de memoria de la variable misma. Usados correctamente, los punteros pueden ser un gran beneficio para la estructura de su rutina y la simplicidad del mantenimiento y edición futuros.
- Ejemplo de código de punto:
int a = 1462; int myAddressPointer = &a; int miAddressValue = *myAddressPointer;
Analicemos lo que sucede en el código anterior. La variable myAddressPointer apunta a la dirección de memoria de la variable a (1462) , mientras que la variable myAddressValue recupera el valor de la dirección de memoria a la que apunta myAddressPointer. En consecuencia, se puede esperar obtener un valor de 874 de myAddressPointer y 1462 para myAddressValue. ¿Por qué es esto útil? Porque no solo almacenamos valores en la memoria, también almacenamos funciones y comportamientos de métodos. Por ejemplo, el espacio de memoria 874 almacena el valor 1462, pero esta dirección de almacenamiento también puede gestionar funciones para calcular una intensidad de corriente en kA. Los punteros nos dan acceso a esta funcionalidad adicional y a la usabilidad de la dirección de memoria sin la necesidad de declarar una declaración de función en otra parte del código. Un puntero de función típico se puede implementar de la siguiente manera:
vacío (*funcPtr) (nulo);
¿Te imaginas utilizar esta herramienta en nuestro FSM? Podemos crear un puntero dinámico que apunte a las diferentes funciones o estados de nuestro FSM en lugar de una variable. Si tenemos una única variable que almacena un puntero que cambia dinámicamente, podemos cambiar los estados de FSM según las condiciones de entrada.
Tablas de búsqueda
Repasemos otro concepto importante: las tablas de búsqueda o LUT. Las LUT ofrecen una forma ordenada de almacenar datos, en estructuras básicas que almacenan valores predefinidos. Nos serán útiles para almacenar datos dentro de nuestros valores FSM.
La principal ventaja de las LUT es esta: si se declaran estáticamente, se puede acceder a sus valores a través de direcciones de memoria, lo cual es una forma de acceso a valores muy eficaz en C/C++. A continuación puede encontrar una declaración típica para un FSM LUT:
void (*const state_table [MAX_STATES][MAX_EVENTS]) (void) = { action_s1_e1, action_s1_e2 }, /* procedimientos para el estado { action_s2_e1, action_s2_e2 }, /* procedimientos para el estado { action_s3_e1, action_s3_e2 } /* procedimientos para el estado };
Es mucho para digerir, pero estos conceptos juegan un papel importante en la implementación de nuestro nuevo y eficiente FSM. Ahora, codifíquelo para que pueda ver con qué facilidad este tipo de FSM puede crecer con el tiempo.
Nota: El código completo del FSM se puede encontrar aquí ; lo hemos dividido en 5 partes para simplificar.
Codificación
Crearemos un FSM simple para implementar una rutina de LED parpadeante. Luego puede adaptar el ejemplo a sus propias necesidades. El FSM tendrá 2 estados: ledOn y ledOff, y el led se apagará y encenderá cada segundo. ¡Empecemos!
/* CONFIGURACIÓN DE LA MÁQUINA DE ESTADOS */ /* Estados válidos de la máquina de estado */ typedef enum { LED_ON, LED_OFF, NUM_STATES } Tipo de estado; /* Estructura de la tabla de la máquina de estados */ typedef struct { Estado tipo de estado; // Crea el puntero de función vacío (*función)(vacío); } Tipo de máquina de estado;
En la primera parte, implementamos nuestra LUT para crear estados. Convenientemente, usamos el método enum() para asignar un valor de 0 y 1 a nuestros estados. Al número máximo de estados también se le asigna un valor de 2, lo que tiene sentido en nuestra arquitectura FSM. Este typedef
se etiquetará como StatedType para que podamos consultarlo más adelante en nuestro código.
A continuación, creamos una estructura para almacenar nuestros estados. También declaramos un puntero etiquetado como función , que será nuestro puntero de memoria dinámica para llamar a los diferentes estados de FSM.
/* Declaración inicial de estado y funciones del SM */ StateType SmState = LED_ON; anular Sm_LED_ON(); anular Sm_LED_OFF(); /* Tabla de búsqueda con estados y funciones a ejecutar */ StateMachineType StateMachine[] = { {LED_ON, Sm_LED_ON}, {LED_OFF, Sm_LED_OFF} };
Aquí, creamos una instancia con el estado inicial LED_ON, declaramos nuestros dos estados y finalmente creamos nuestra LUT. Las declaraciones de estado y el comportamiento están relacionados en la LUT, por lo que podemos acceder a los valores fácilmente a través de índices int . Para acceder al método sm_LED_ON(), por ejemplo, codificaremos algo como StateMachineInstance[0];
.
/* Rutinas de funciones de estado personalizadas */ void Sm_LED_ON() { // Código de función personalizada escritura digital (LED_BUILTIN, ALTA); retraso(1000); // Pasar al siguiente estado EstadoSm = LED_OFF; } anular Sm_LED_OFF() { // Código de función personalizada escritura digital (LED_BUILTIN, BAJO); retraso(1000); // Pasar al siguiente estado EstadoSm = LED_ON; }
En el código anterior, la lógica de nuestros métodos está implementada y no incluye nada especial además de la actualización del número de estado al final de cada función.
/* Rutina de cambio de estado de la función principal */ void Sm_Run(void) { // Se asegura de que el estado real sea válido if (SmState < NUM_STATES) { (*StateMachine[SmState].función) (); } más { // Código de excepción de error Serial.println("[ERROR] Estado no válido"); } }
La función Sm_Run()
es el corazón de nuestro FSM. Tenga en cuenta que usamos un puntero (*)
para extraer la posición de memoria de la función de nuestra LUT, ya que accederemos dinámicamente a una posición de memoria en la LUT durante la ejecución. Sm_Run ()
siempre ejecutará múltiples instrucciones, también conocidas como eventos FSM, ya almacenadas en una dirección de memoria del microcontrolador.
/* PRINCIPALES FUNCIONES DE ARDUINO */ configuración nula() { // pon aquí tu código de configuración para ejecutarlo una vez: pinMode(LED_BUILTIN, SALIDA); } bucle vacío() { // pon tu código principal aquí, para ejecutarlo repetidamente: Sm_Run(); }
Nuestras funciones principales de Arduino ahora son muy simples: el bucle infinito siempre se ejecuta con la rutina de cambio de estado previamente definida. Esta función manejará el evento para activar y actualizar el estado real de FSM.
Conclusiones
En esta segunda parte de nuestra serie Máquinas de estados finitos y punteros C/C++, revisamos las principales desventajas de las rutinas FSM de caja de interruptor e identificamos los punteros como una opción adecuada y deseable para ahorrar memoria y aumentar la funcionalidad del microcontrolador.
En resumen, estas son algunas de las ventajas y desventajas de usar punteros en su rutina de máquina de estados finitos:
Ventajas:
- Para agregar más estados, simplemente declare el nuevo método de transición y actualice la tabla de búsqueda; la función principal será la misma.
- No es necesario realizar todas las declaraciones if-else: el puntero permite que el firmware "vaya" al conjunto de instrucciones deseado en la memoria del microcontrolador.
- Esta es una forma concisa y profesional de implementar FSM.
Desventajas:
- Necesita más memoria estática para almacenar la tabla de búsqueda que almacena los eventos FSM.