Programação para microcontroladores: otimizando o código
Como citado nos artigos anteriores, um ponto muito importante do sistema embarcado é a robustez e eficiência do programa, neste artigo iremos revisar otimizações já citadas antes e também apresentar novas otimizações.
Para o aproveitamento total deste artigo será necessário ter em mente que serão referenciadas duas memórias, sendo uma de memória de longo prazo, onde fica armazenado o programa (flash, hd, microSD e etc), e a memória de curto prazo, onde serão armazenadas as variáveis utilizadas e criadas durante o tempo de execução do programa (EEPROM, Ram e etc), então continue lendo e entenda
Porque e quando fazer otimização
A otimização de sistemas (seja embarcado ou não) deve ser sempre feita focando na otimização dos recursos, pois todo hardware é limitado, por exemplo, não existe memória infinita, e em casos como sistema em servidor que podem ser acrescentados mais memória, conforme a necessidade, ainda assim não é infinita de verdade, mas crescer incondicionalmente geraria um custo imenso que pode tornar todo o projeto completamente inviável, portanto o sistema deve utilizar apenas os recursos necessários para o funcionamento correto.
Porém, realizar otimizações no código gera um custo de tempo, sendo necessário revalidar funcionalidades, realizar mais e mais testes, avaliar linhas de código para encontrar uso indevido de recursos. Por conta disso não é viável realizar a todo momento, portanto a otimização deve ser feita quando partes grande do código tiverem sido finalizadas e validadas ou quando for identificado que existe algum gargalo de recursos, como, por exemplo: a lógica do programa está correta, porém a execução leva mais tempo do que o desejado (ou necessário) para atender às necessidades do projeto.
Otimizações de Processamento
No artigo anterior foi demonstrado como fazer uma otimização de memória de longo prazo, através da remoção do framework e assim diminuindo a quantidade de linhas para realizar as mesmas atividades. Essa otimização não só traz uma diminuição do espaço de armazenamento do programa, mas também uma diminuição no processamento, permitindo assim que a mesma atividade seja concluída mais rapidamente e assim, deixando tempo livre de processamento que pode ser investido em outras atividades, por exemplo, exibição de interface gráfica com mais detalhes.
Daqui em diante, serão citadas outras otimizações simples, mas que podem economizar um processamento considerável.
Análise de complexidade de algoritmos
Uma análise importante para a diminuição do processamento é a análise de complexidade de algoritmos, que vai avaliar o quão complexo será todo o processamento, considerando o funcionamento do programa, tamanho da entrada e saída de dados desse algoritmo, não consideração o tempo específico de cada tarefa, pois a análise se foca apenas em como a complexidade pode crescer dentro daquele processamento.
Para entender melhor, vamos tomar como exemplo um sistema que iremos guardar registros e depois vamos pesquisar por um em específico. O primeiro e mais simples método de realizar esse tipo de funcionalidade é criando uma lista, inserindo os novos registros no primeiro espaço vazio que encontrarmos. Em seguida, para pesquisar, basta ir pulando de elemento em elemento na nossa lista, verificando se é realmente o que procuramos. Perceba que no melhor caso, pode ser o primeiro, ou seja, realizamos apenas uma verificação, porém, no pior caso por ser o último, nos levando a fazer n verificações (sendo n a quantidade de elementos na lista), e se esse valor n dobrar, a quantidade de verificações também dobra, demonstrando assim, um compartilhamento linear.
Em um segundo caso, quero comparar se cada elemento de uma segunda lista está nessa primeira, realizando assim, n verificações na primeira lista para cada elemento da segunda que, por sua vez, possui n elementos também. Sendo assim um processamento de n vezes n ou n² (chamado na literatura de complexidade quadrática). Neste segundo caso é fácil perceber que apenas um pequeno aumento na entrada de dados pode gerar um volume muito maior de processamento, fazendo assim, que o programa comece a apresentar lentidão conforme o funcionamento.
Portanto, é recomendado que se analise o quão complexo é o seu algoritmo (quadrático, logarítmico, constante, linear e etc.), a fim de ver o quanto o processamento irá aumentar durante a vida do seu programa.
Fila circular
Um problema bem comum principalmente em sistemas de processamento de sinais, é o de uso de listas para armazenamento com deslocamento de elementos para realização de filas, cálculo de médias etc., gerando um processamento excessivo, que poderia ser simplificado com o uso de uma fila circular.
Para entender melhor o uso e funcionamento desta fila, vamos tomar como exemplo um sistema onde irá ler a temperatura do ar e fazer uma média móvel das últimas 10 leituras. Na média móvel, iremos descartar o valor mais antigo, acrescentar um mais recente, calcular a média e em seguida, realizar uma nova leitura, depois repetindo tudo de novo. A implementação mais comum desse tipo de solução é remover o valor do fim, mover todos os elementos pro final e adicionar um no início, porém é custoso mover todos os elementos sempre e com a fila circular isso não é necessário.
Para utilizar a fila circular basta criar uma variável a mais que vai indicar de qual posição se deve começar, sendo assim, um “início”. A cada leitura o contador de início é incrementado e dividido por resto pelo tamanho máximo da fila, fazendo assim, com que ao chegar no tamanho máximo, o nosso apontador de “início” retorne para o valor 0 reiniciando o ciclo. Veja o código exemplo a seguir, onde a função read_new_value() seria a função que lê os novos valores:
Como é possível ver no código exemplo, ao invés de dois loops for, um pra movimentar e outro para calcular, usamos apenas um para calcular e o próprio ciclo da variável head faz a sobrescrita, fazendo assim, apenas um acesso ao array ao invés de dois (remover o antigo e depois adicionar o novo), cortando assim pela metade o processamento ao custo de apenas uma nova variável.
Otimizações de memória
Nos artigos anteriores, também citada anteriormente, uma otimização de memória onde variáveis constantes são substituídas por valores definidos em tempo de execução (#define), evitando assim que a memória seja ocupada por valores que serão apenas de leituras e dependendo do projeto, podem haver centenas de variáveis constantes para configuração do código, mas que através dessa otimização, não vão ocupar o espaço da memória. Além dessa otimização, podemos citar outras duas:
Declaração de tipos específicos de dados
Quando se trabalha com aplicações para computadores pessoais, o valor de um número inteiro não interessa muito, pois temos memória e processamento sobrando para lidar com os tamanhos diferentes, e compiladores mais avançados e genéricos que abstraem isso para o desenvolvedor, porém, quando o programa se destina para microcontroladores o cenário muda completamente, pois passamos a trabalhar com alguns megabytes ou kilobytes de memória apenas, e cada compilador coloca um tamanho de armazenamento para cada tipo de variável de acordo com o microcontrolador alvo. Então, cada byte passa a contar, ou até mais precisamente cada bit, logo entra em ação a otimização onde se deve especificar o tipo de variável, onde além de garantir que o desenvolvedor saberá com quantos bits está trabalhando, também garante que usará apenas o necessário e não irá exceder o limite do microcontrolador.
Se temos, por exemplo, uma variável que vai de -10 até no máximo 10, não faz sentido alocar uma variável de 32 bits, podendo usar apenas uma variável de 8 bits (int8_t). Ou se uma variável vai até 200 e nunca assume valor negativo, podemos usar uma variável de 8 bits sem sinal (uint8_t), que pode armazenar até o valor máximo de 255.
É importante também saber com qual tipo de variável se está trabalhando, por exemplo, num caso peculiar que acontece ao se compilar o mesmo código para um Arduino com Atmega328p e para uma outra placa de desenvolvimento que possua uma ESP8266, onde no Atmega um int sem especificação de tamanho será 16bits e na ESP será 32bits, mesmo sendo o mesmo código. Os diferentes compiladores possuem tamanhos diferentes e que acaba levando a erros de conversão de tipos, como, por exemplo, a leitura de 16 bits com sinal de um sensor no Arduino que funciona perfeitamente, ao tentar rodar na ESP vai ser observado que o valor estará com um valor maior que 65 mil, devido ao 16 bit que é de sinal, e que ao ser colocado no inteiro da ESP, deveria ocupar o 32º lugar, porém foi para o 16º somando assim, mais de 65 mil ao valor final e quebrando toda a conta. Desse modo, não só use tipos explícitos de variáveis, mas busque na documentação do seu microcontrolador qual o tamanho padrão daquela variável para aquela plataforma.
Declaração de variáveis
Quando declaramos uma variável, é necessário conhecer não só qual o escopo em que ela será utilizada para que se possa otimizar o seu uso, mas também, com que frequência ela será utilizada. Vamos tomar por exemplo, novamente, o código de cálculo de média citado anteriormente. A variável da média só é utilizada dentro do loop na hora de calcular e não é mais necessária em todo o restante do código, e a maioria dos programadores iriam declara-lá dentro do loop ao invés de global, porém, ela é usada num ciclo que roda constantemente, sendo assim, usada continuadamente, levando assim ao microcontrolador a cada ciclo utilizar parte do processamento para estar sempre alocando a variável, que gera também mais processamento, sendo recomendável assim aloca-lá no escopo global.
Porém, outras variáveis que são usadas apenas em um cálculo que é chamada ocasionalmente em uma função do programa e não será acessada fora, essa sim, deverá ser declarada em um escopo global, pois não seria eficiente ter uma variável ocupando memória se passaria a maior parte do tempo sem uso.
Conclusão
Para o refinamento final do código e extrair o máximo potencial do microcontrolador, não é necessário apenas o conhecimento de cada parte interna e como utilizá-las, mas também, como o programa irá executar da forma mais eficiente possível, sendo necessário assim, avaliar com cuidado todo o cenário em que cada variável se situará, e cada algoritmo, permitindo deste modo que o desenvolvedor faça com que o código seja mais robusto, preciso, eficaz e eficiente.
Moises Cavalcanti
Posts relacionados
Todos os postsNão perca as nossas atualizações!
Assine para receber a newsletter da Vsoft e fique por dentro do mundo da identificação e tecnologia.