Fundamentos de IoT

Ventajas y desventajas de las máquinas de estados finitos: Casos de conmutación, punteros C/C++ y tablas de consulta (Parte II)

José García
- 7 min read
Enviar por correo electrónico

Esta es la segunda y última parte de la implementación de nuestra Máquina de Estado Finito (FSM). Puedes consultar la primera parte de la serie y aprender más generalidades sobre las máquinas de estados finitos aquí.

Las máquinas de estados finitos, o FSM (Finite State Machines), son simplemente un cálculo matemático de causas y sucesos. Basándose en los estados, una FSM calcula una serie de eventos en función del 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 de los FSM: dictan el comportamiento interno o las interacciones con el entorno, como aceptar entradas o producir salidas, que pueden hacer que un sistema cambie de estado. Nuestro trabajo como ingenieros de hardware es elegir los estados FSM y los eventos de activación adecuados para obtener el comportamiento deseado que se ajuste a las necesidades de nuestro proyecto.

En la primera parte de este tutorial FSM, creamos un FSM utilizando la implementación clásica switch-case. Ahora, exploraremos la creación de un FSM usando punteros C/C++, lo que te permitirá desarrollar una aplicación más robusta con expectativas de mantenimiento de firmware más simples.

NOTA: El código utilizado en este tutorial fue demostrado en el Arduino Day de 2018 en Bogotá por José García, uno de los ingenieros de hardware de Ubidots. Puedes encontrar los ejemplos de código completos y las notas del ponente aquí.

Desventajas de la caja de interruptores:

En la primera parte de nuestro tutorial sobre FSM, vimos los casos de conmutación y cómo implementar una rutina sencilla. Ahora, ampliaremos esta idea introduciendo los "Punteros" y cómo aplicarlos para simplificar tu rutina FSM.

Una implementación switch-case es muy similar a una rutina if-else; nuestro firmware hará un bucle sobre cada caso, evaluándolos para ver si se alcanza la condición trigger case. Veamos una rutina de ejemplo a continuación:

switch(state) {
case 1:
/* make some stuff for state 1 */
state = 2;
break;
case 2:
/* make some stuff for state 2 */
state = 3;
break;
case 3:
/* make some stuff for state 3 */
state = 1;
break;
default:
/* make some stuff by default */
state = 1;
}

En el código anterior, encontrarás 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; en caso negativo, pasa al caso 2, donde comprueba de nuevo 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, vamos a entender un poco más acerca de algunas posibles desventajas de las implementaciones 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 tendrá que hacer 3 validaciones de valor diferentes. Esto puede no ser un problema para un FSM pequeño, pero imagina una máquina de producción industrial típica con cientos o miles de estados. La rutina tendrá que hacer varias comprobaciones de valor inútiles, lo que en última instancia resulta en un uso ineficiente de los recursos. Esta ineficacia se convierte en nuestro primer inconveniente: el microcontrolador tiene recursos limitados y se verá sobrecargado con rutinas FSM ineficaces. Como tales, es nuestro deber como ingenieros ahorrar tantos recursos informáticos en el microcontrolador como sea posible.

Ahora imagina un FSM con miles de estados: si eres un nuevo desarrollador y necesitas implementar un cambio en uno de esos estados, tendrás que buscar en miles de líneas de código dentro de tu rutina main loop(). Esta rutina suele incluir mucho código no relacionado con la máquina en sí, por lo que puede ser difícil de depurar si centras toda la lógica del FSM dentro del bucle principal().

Y, por último, un código con miles de sentencias if-else o switch-case no es elegante ni legible para la mayoría de los programadores empotrados.

Punteros C/C++

Veamos ahora cómo podemos implementar un FSM conciso utilizando punteros en 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. Un puntero se utiliza para obtener el valor almacenado de una variable durante la ejecución sin conocer la dirección de memoria de la propia variable. Usados adecuadamente, los punteros pueden ser un gran beneficio para la estructura de tu rutina y la simplicidad del mantenimiento y edición futuros.

  • Ejemplo de código de puntos:
int a = 1462;
int miPunteroDirección = &a;
int miValorDirección = *miPunteroDirección;

Analicemos lo que ocurre 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 apuntada por myAddressPointer. En consecuencia, se puede esperar obtener un valor de 874 de myAddressPointer y 1462 para myAddressValue. ¿Por qué es esto útil? Porque no sólo almacenamos valores en memoria, también almacenamos funciones y comportamientos de métodos. Por ejemplo, el espacio de memoria 874 está almacenando 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 añadida y a la usabilidad de la dirección de memoria sin necesidad de declarar una declaración de función en otra parte del código. Un puntero de función típico puede implementarse como se indica a continuación:

void (*funcPtr) (void);

¿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 del FSM en función de las condiciones de entrada.

Tablas de consulta

Repasemos otro concepto importante: las tablas de consulta, o LUT. Las LUTs 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 LUTs es la siguiente: si se declaran estáticamente, se puede acceder a sus valores a través de direcciones de memoria, que es una forma de acceso a valores muy efectiva en C/C++. A continuación puedes 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 }, /* procedures for state
{ action_s2_e1, action_s2_e2 }, /* procedures for state
{ action_s3_e1, action_s3_e2 } /* procedures for state
};

Es mucho que digerir, pero estos conceptos juegan un papel importante en la implementación de nuestro nuevo y eficiente FSM. Ahora, vamos a codificarlo para que puedas ver la facilidad con la que 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 sencillo para implementar una rutina de led parpadeante. Puedes adaptar el ejemplo a tus propias necesidades. El FSM tendrá 2 estados: ledOn y ledOff, y el led se apagará y encenderá cada segundo. Comencemos.

/*
   STATE MACHINE SETUP
 
*/

/*
   State machine valid states
*/
typedef enum {
  LED_ON,
  LED_OFF,
  NUM_STATES
} StateType;
/*
   State machine table structure
*/
typedef struct {
  StateType State;
  // Create the function pointer
  void (*function)(void);
} StateMachineType;

En la primera parte, estamos implementando 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 TipoEstado para que podamos referirnos a él 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 del FSM.

/*
    Initial SM state and functions declaration
*/
StateType SmState = LED_ON;
void Sm_LED_ON();
void Sm_LED_OFF();
/*
   LookUp table with states and functions to execute
*/
StateMachineType StateMachine[] =
{
  {LED_ON, Sm_LED_ON},
  {LED_OFF, Sm_LED_OFF}
};

Aquí, creamos una instancia con el estado inicial LED_ON, y 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 int índices. Para acceder al método sm_LED_ON(), por ejemplo, codificaremos algo como StateMachineInstance[0];.

/*
   Custom State Functions routines
*/
void Sm_LED_ON() {
  // Custom Function Code
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);
  // Move to next state
  SmState = LED_OFF;
}
void Sm_LED_OFF() {
  // Custom Function Code
  digitalWrite(LED_BUILTIN, LOW);
  delay(1000);
  // Move to next state
  SmState = LED_ON;
}

En el código anterior se implementa la lógica de nuestros métodos y no incluye nada especial aparte de la actualización del número de estado al final de cada función.

/*
   Main function state change routine
*/
void Sm_Run(void) {
  // Makes sure that the actual state is valid
  if (SmState < NUM_STATES) {
    (*StateMachine[SmState].function) ();
  }
  else {
    // Error exception code
    Serial.println("[ERROR] Not valid state");
  }
}

La funciónSm_Run() es el corazón de nuestro FSM. Obsérvese que utilizamos 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. La dirección Sm_Run() siempre ejecutará varias instrucciones, también conocidas como eventos FSM, ya almacenadas en una dirección de memoria del microcontrolador.

/*
   MAIN ARDUINO FUNCTIONS
 
*/
void setup() {
  // put your setup code here, to run once:
  pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
  // put your main code here, to run repeatedly:
  Sm_Run();
}

Nuestras funciones principales de Arduino son ahora muy simples - el bucle infinito siempre se está ejecutando con la rutina de cambio de estado previamente definida. Esta función se encargará del evento para activar y actualizar el estado real FSM.

Conclusiones

En esta segunda parte de nuestra serie Máquinas de estados finitos y punteros en C/C++, hemos revisado las principales desventajas de las rutinas FSM de caso conmutado y hemos identificado 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 Estado Finito:

Ventajas:

  • Para añadir más estados, basta con declarar el nuevo método de transición y actualizar la tabla de consulta; la función principal será la misma.
  • No es necesario realizar cada sentencia if-else: el puntero permite al firmware "ir" al conjunto de instrucciones deseado en la memoria del microcontrolador.
  • Esta es una forma concisa y profesional de aplicar el FSM.

Desventajas:

  • Se necesita más memoria estática para almacenar la tabla de consulta que almacena los eventos del FSM.