Vantagens e desvantagens de máquinas de estados finitos: casos de switch, ponteiros C/C++ e tabelas de pesquisa (Parte II)
Esta é a segunda e última parte de nossa implementação da Máquina de Estados Finitos (FSM). Você pode consultar a primeira parte da série e aprender mais algumas generalidades sobre Máquinas de Estados Finitos aqui .
Máquinas de Estados Finitos, ou FSMs, são simplesmente um cálculo matemático de causas e eventos. Com base nos estados, um FSM calcula uma série de eventos com base no estado das entradas da máquina. Para um estado chamado SENSOR_READ , por exemplo, um FSM poderia acionar um relé (também conhecido como evento de controle) ou enviar um alerta externo se a leitura de um sensor for superior a um valor limite. Os estados são o ADN do FSM – eles ditam o comportamento interno ou as interações com um ambiente, como a aceitação de entradas ou a produção de resultados, que podem fazer com que um sistema altere o seu estado. É nosso trabalho como engenheiros de hardware escolher os estados FSM corretos e acionar eventos para obter o comportamento desejado que atenda às necessidades do nosso projeto.
Na primeira parte deste tutorial do FSM, criamos um FSM usando a implementação clássica de switch-case. Agora, exploraremos a criação de um FSM usando ponteiros C/C++, o que permitirá desenvolver um aplicativo mais robusto com expectativas de manutenção de firmware mais simples.
NOTA : O código usado neste tutorial foi demonstrado no Arduino Day 2018 em Bogotá por Jose Garcia, um dos Ubidots . Você pode encontrar os exemplos de código completos e as notas do palestrante aqui .
Desvantagens do switch-case:
Na primeira parte do nosso tutorial FSM , vimos switch-cases e como implementar uma rotina simples. Agora, expandiremos essa ideia apresentando “Ponteiros” e como aplicá-los para simplificar sua rotina de FSM.
Uma implementação switch-case rotina if-else nosso firmware fará um loop em cada caso, avaliando-os para ver se a condição do caso acionador foi atingida. Vejamos um exemplo de rotina abaixo:
switch(state) { case 1: /* faça algumas coisas para o estado 1 */ state = 2; quebrar; case 2: /* faça algumas coisas para o estado 2 */ state = 3; quebrar; case 3: /* faça algumas coisas para o estado 3 */ state = 1; quebrar; default: /* faz algumas coisas por padrão */ state = 1; }
No código acima, você encontrará um FSM simples com três estados. No loop infinito, o firmware irá para o primeiro caso, verificando se a variável de estado é igual a um. Se sim, ele executa sua rotina; caso contrário, prossegue para o caso 2, onde verifica novamente o valor do estado. Se o caso 2 não for satisfeito, a execução do código passará para o caso 3 e assim por diante até que o estado seja alcançado ou os casos tenham se esgotado.
Antes de entrar no código, vamos entender um pouco mais sobre algumas possíveis desvantagens das switch-case ou if-else para que possamos ver como melhorar nosso desenvolvimento de firmware.
Suponhamos que a variável de estado inicial seja 3: nosso firmware terá que fazer 3 validações de valores diferentes. Isto pode não ser um problema para um pequeno FSM, mas imagine uma típica máquina de produção industrial com centenas ou milhares de estados. A rotina precisará fazer diversas verificações de valores inúteis, resultando em última análise em um uso ineficiente de recursos. Esta ineficiência torna-se a nossa primeira desvantagem – o microcontrolador é limitado em recursos e ficará sobrecarregado com rotinas FSM ineficientes. Como tal, é nosso dever como engenheiros economizar o máximo possível de recursos computacionais no microcontrolador.
Agora imagine um FSM com milhares de estados: se você é um novo desenvolvedor e precisa implementar uma mudança em um desses estados, você terá que examinar milhares de linhas de código dentro de sua rotina loop() principal. Essa rotina geralmente inclui muito código não relacionado à máquina em si, portanto pode ser difícil depurar se você centralizar toda a lógica do FSM dentro do loop principal().
E, finalmente, um código com milhares de if-else ou switch-case não é elegante ou legível para a maioria dos programadores incorporados.
Ponteiros C/C++
Agora vamos ver como podemos implementar um FSM conciso usando ponteiros C/C++. Um ponteiro, como o próprio nome sugere, aponta para algum lugar dentro do microcontrolador. Em C/C++, um ponteiro aponta para um endereço de memória com a intenção de recuperar informações. Um ponteiro é usado para obter o valor armazenado de uma variável durante a execução sem conhecer o endereço de memória da própria variável. Usados corretamente, os ponteiros podem ser um grande benefício para a estrutura de sua rotina e para a simplicidade de manutenção e edição futura.
- Exemplo de código de ponto:
intuma = 1462; int meuAddressPointer = &a; int meuAddressValue = *meuAddressPointer;
Vamos analisar o que acontece no código acima. A variável myAddressPointer aponta para o endereço de memória da variável a (1462) , enquanto a variável myAddressValue recupera o valor do endereço de memória apontado por myAddressPointer. Conseqüentemente, pode-se esperar obter um valor de 874 para myAddressPointer e 1462 para myAddressValue. Por que isso é útil? Porque não armazenamos apenas valores na memória, também armazenamos funções e comportamentos de métodos. Por exemplo, o espaço de memória 874 armazena o valor 1462, mas este endereço de armazenamento também pode gerenciar funções para calcular uma intensidade de corrente em kA. Os ponteiros nos dão acesso a essa funcionalidade adicional e à usabilidade do endereço de memória sem a necessidade de declarar uma instrução de função em outra parte do código. Um ponteiro de função típico pode ser implementado conforme abaixo:
vazio (*funcPtr) (vazio);
Já imaginou usar essa ferramenta em nosso FSM? Podemos criar um ponteiro dinâmico que aponte para as diferentes funções ou estados do nosso FSM em vez de uma variável. Se tivermos uma única variável que armazena um ponteiro que muda dinamicamente, podemos alterar os estados do FSM com base nas condições de entrada.
Tabelas de pesquisa
Vamos revisar outro conceito importante: tabelas de consulta ou LUTs. LUTs oferecem uma forma ordenada de armazenar dados, em estruturas básicas que armazenam valores predefinidos. Eles serão úteis para armazenarmos dados dentro de nossos valores FSM.
A principal vantagem das LUTs é esta: se declaradas estaticamente, seus valores podem ser acessados através de endereços de memória, o que é uma forma de acesso a valores muito eficaz em C/C++. Abaixo você pode encontrar uma declaração típica para uma LUT FSM:
void (*const state_table [MAX_STATES][MAX_EVENTS]) (void) = { action_s1_e1, action_s1_e2 }, /* procedimentos para estado { action_s2_e1, action_s2_e2 }, /* procedimentos para estado { action_s3_e1, action_s3_e2 } /* procedimentos para estado };
É muita coisa para digerir, mas esses conceitos desempenham um papel importante na implementação do nosso novo e eficiente FSM. Agora, vamos codificá-lo para que você possa ver com que facilidade esse tipo de FSM pode crescer com o tempo.
Nota: O código completo do FSM pode ser encontrado aqui – nós o dividimos em 5 partes para simplificar.
Codificação
Criaremos um FSM simples para implementar uma rotina de LED piscando. Você pode então adaptar o exemplo às suas próprias necessidades. O FSM terá 2 estados: ledOn e ledOff, e o led apagará e acenderá a cada segundo. Vamos começar!
/* CONFIGURAÇÃO DA MÁQUINA DE ESTADO */ /* Estados válidos da máquina de estado */ typedef enum { LED_ON, LED_OFF, NUM_STATES } StateType; /* Estrutura da tabela da máquina de estado */ typedef struct { EstadoTipo Estado; // Cria o ponteiro de função vazio (*função)(vazio); } StateMachineType;
Na primeira parte, estamos implementando nossa LUT para criar estados. Convenientemente, usamos o método enum() para atribuir os valores 0 e 1 aos nossos estados. O número máximo de estados também recebe o valor 2, o que faz sentido em nossa arquitetura FSM. Este typedef
será rotulado como StatedType para que possamos consultá-lo posteriormente em nosso código.
A seguir, criamos uma estrutura para armazenar nossos estados. Também declaramos um ponteiro denominado function , que será nosso ponteiro de memória dinâmica para chamar os diferentes estados do FSM.
/* Declaração inicial de estado e funções do SM */ StateType SmState = LED_ON; vazio Sm_LED_ON(); vazio Sm_LED_OFF(); /* Tabela de consulta com estados e funções a serem executadas */ StateMachineType StateMachine[] = { {LED_ON, Sm_LED_ON}, {LED_OFF, Sm_LED_OFF} };
Aqui, criamos uma instância com o estado inicial LED_ON, declaramos nossos dois estados e finalmente criamos nossa LUT. As declarações de estado e o comportamento estão relacionados na LUT, portanto podemos acessar valores facilmente através de índices int . Para acessar o método sm_LED_ON(), por exemplo, codificaremos algo como StateMachineInstance[0];
.
/* Rotinas de funções de estado personalizadas */ void Sm_LED_ON() { // Código de função personalizada digitalWrite(LED_BUILTIN, ALTO); atraso(1000); // Move para o próximo estado EstadoSm = LED_OFF; } void Sm_LED_OFF() { // Código de função personalizada digitalWrite(LED_BUILTIN, BAIXO); atraso(1000); // Move para o próximo estado EstadoSm = LED_ON; }
No código acima, a lógica de nossos métodos é implementada e não inclui nada de especial além da atualização do número de estado no final de cada função.
/* Rotina de mudança de estado da função principal */ void Sm_Run(void) { // Garante que o estado real seja válido if (SmState < NUM_STATES) { (*StateMachine[SmState].função) (); } senão { // Código de exceção de erro Serial.println("[ERRO] Estado inválido"); } }
A função Sm_Run()
é o coração do nosso FSM. Observe que usamos um ponteiro (*)
para extrair a posição da memória da função de nossa LUT, pois acessaremos dinamicamente uma posição de memória na LUT durante a execução. O Sm_Run()
sempre executará múltiplas instruções, também conhecidas como eventos FSM, já armazenadas em um endereço de memória do microcontrolador.
/* PRINCIPAIS FUNÇÕES DO ARDUINO */ void setup() { // coloque seu código de configuração aqui, para ser executado uma vez: pinMode(LED_BUILTIN, SAÍDA); } void loop() { // coloque seu código principal aqui, para executar repetidamente: Sm_Run(); }
Nossas principais funções do Arduino agora são muito simples – o loop infinito está sempre rodando com a rotina de mudança de estado previamente definida. Esta função tratará o evento para acionar e atualizar o estado real do FSM.
Conclusões
Nesta segunda parte de nossa série Máquinas de Estados Finitos e Ponteiros C/C++, revisamos as principais desvantagens das rotinas FSM switch-case e identificamos os ponteiros como uma opção adequada e desejável para economizar memória e aumentar a funcionalidade do microcontrolador.
Resumindo, aqui estão algumas das vantagens e desvantagens de usar ponteiros em sua rotina de máquina de estados finitos:
Vantagens:
- Para adicionar mais estados, simplesmente declare o novo método de transição e atualize a tabela de consulta; a função principal será a mesma.
- Você não precisa executar todas as instruções if-else – o ponteiro permite que o firmware 'vá' para o conjunto desejado de instruções na memória do microcontrolador.
- Esta é uma forma concisa e profissional de implementar o FSM.
Desvantagens:
- Você precisa de mais memória estática para armazenar a tabela de consulta que armazena os eventos FSM.