Mais memória no Arduino: indo além dos 2KB de RAM com a PROGMEM
Os meros 2KB de memória RAM do Arduino Uno são um desafio para trabalhar com texto ou muitos dados, mas há soluções.
Quando se trabalha com variáveis numéricas e uma ou outra string de caracteres, essa quantidade de RAM vai longe. Mas quando o processamento envolve grandes blocos de texto – por exemplo, mensagens a exibir em um LCD, ou a implementação de um protocolo da Internet, como o http da web ou algum serviço de e-mail –, essa quantidade se esgota rapidamente.
Ao trabalhar com dados em formato textual armazenados na memória, os programadores muitas vezes tropeçam numa barreira específica: o Arduino Uno, e todos os similares que usam o microcontrolador ATmega328 – como o Nano, alguns Pro Minis e Lilypads, etc. – tem uma disponibilidade de memória RAM1 bastante limitada, correspondendo a apenas 2048 bytes.
Esse consumo acelerado fica mais visível quando consideramos que o comportamento natural no Arduino é copiar para a RAM o conteúdo não apenas das estruturas textuais (como as variáveis tipo String, entre outras) definidas explicitamente, mas também de várias constantes string referenciadas no seu programa.
Para um exemplo – ao qual voltaremos adiante –, considere a linha a seguir:
Serial.print("Erro: impossivel localizar o sinal do satelite");
Como parte da execução do seu programa, o texto entre aspas será copiado para a RAM antes de ser enviado para a porta serial, e uma parte dos problemas desse gênero (detalhes adiante!) ocorrem quando esse tipo de referência se multiplica ou aumenta muito de tamanho.
Como a memória acaba: crônica de uma falha
Os problemas com a RAM não ocorrem apenas quando a soma dos seus textos e outras variáveis ultrapassam 2048 bytes – em geral eles acontecem bem antes disso.
Não é o caso de revisitarmos aqui as aulas de estrutura de computadores, mas é interessante observar que usualmente a RAM do Arduino é organizada2 em 3 blocos distintos:
- o conjunto (estático em tamanho) das estruturas de dados de tamanho fixo, como as variáveis numéricas
- o heap, onde são armazenadas e removidas ao longo da execução parte das estruturas de dados de tamanho variável
- a pilha (ou stack), onde são armazenadas e removidas ao longo da execução outra parte das estruturas de dados variáveis, incluindo ponteiros de retorno e referências a variáveis locais
O heap e a pilha têm papeis específicos que não se misturam. O heap inicia logo acima do final do conjunto estático, e cresce em direção ao alto. A pilha inicia no final da memória, e cresce em direção ao início da memória.
Entre o heap e a pilha é normal haver uma área livre, que aumenta e diminui conforme o programa armazena e remove estruturas de dados nesses 2 blocos.
Colisões da pilha comprovam que nem sempre é verdade que aquilo que desconhecemos não pode nos fazer sofrer.
O programador normalmente não fica sabendo que as suas operações correspondem a estruturas armazenadas nesses blocos, mas isso não impede, por exemplo, que a simples operação de concatenar (ou unir o conteúdo de) duas variáveis tipo String corresponda a criar um novo espaço no heap para o texto de ambas.
A falha catastrófica de gerenciamento de memória mais fácil de compreender é a colisão quando o heap e/ou a pilha crescem demais, esgotando a área livre, de forma que um encontra e invade o outro.
Outra falha comum é causada pela fragmentação do heap. Como o espaço do heap pode ser ocupado irregularmente (quando uma função termina de executar, o espaço alocado para uma String dela é liberado, deixando um "buraco" vago para uso posterior), em certo momento pode ocorrer de a soma dos espaços disponíveis ser suficiente para alguma operação, mas ela ser inviabilizada porque esses espaços estão separados em vários buracos separados.
E uma das falhas mais tristes é a causada pelo próprio programador (ou por quem programou as bibliotecas que ele usa3), que usa as estruturas de dados sem atenção aos limites delas, e acaba sobrescrevendo o conteúdo de uma outra variável sem querer, ou lendo algum trecho arbitrário de memória como se fizesse parte da sua estrutura.
Todos os 3 tipos de falhas acima podem levar a variados graus de problemas na execução, desde comportamento inesperado (mensagens estranhas, erros de cálculo, execução fora de ordem etc.) até travamentos ou resets "inexplicáveis".
Não espere por luxos típicos de ambientes com proteção de memória e monitorados por um sistema operacional: não vai haver uma mensagem de erro, ou um registro para diagnóstico posterior.
Mas se você estiver tendo problemas e não tiver como comprovar que a causa é esta, pode usar uma técnica simples de diagnóstico: tentar rodar uma versão reduzida do mesmo programa (sem textos de mensagens de erro4, por exemplo) para ver se o problema se repete.
Como reduzir o uso de memória: a técnica simples
Aqueles números de memória ocupada e disponível que o compilador exibe durante o processo de upload não ajudam muito com relação aos problemas descritos acima: eles se referem ao uso da memória de armazenamento de programas (flash). Não é trivial prever, no momento da compilação, se um programa vai ou não esgotar a RAM disponível durante a sua execução, e o compilador nem tenta.
Mas essa mesma memória flash para armazenamento de programas é o ingrediente básico de duas das soluções essenciais para evitar o excesso de uso da RAM do Arduino: ambas se baseiam em manter na memória flash, sem copiar para a RAM, conteúdos volumosos que não precisarão ser alterados na execução.
A ideia é aproveitar os 32KB da memória flash (gigantescos em comparação com os 2KB da RAM e o 1KB da EEPROM) para tudo que puder evitar o uso das demais memórias.
A primeira dessas técnicas5 é bem simples, mas também bem limitada: indicar para o compilador que os trechos de texto fixo a serem enviados para a Serial (ou LCD, ou outros dispositivos acessíveis por variações do método print()
) não devem ser copiados para a RAM.
A macro F() armazena uma string fora da RAM e oferece ao programa uma maneira simples – mas limitada – de acessá-la
Isso é feito por meio de uma macro chamada F()
– incorporada à IDE do Arduino após ter sido criada por Paul Stoffregen, também criador do Teensy – cujo uso é bem simples. Mantendo o mesmo texto do exemplo já usado acima, o uso da macro F()
seria assim: Serial.print(F("Erro: impossivel localizar o sinal do satelite"));
Note que o trecho entre aspas virou parâmetro do F()
, que por sua vez passou a ser o parâmetro do print()
. A macro F()
é assim simples de usar, não exige outros cuidados especiais, e pode ser usada em textos fixos em geral passados como parâmetro das variações dos métodos print()
(e println()
) do Arduino6.
Assim, o uso da macro F()
remove parte da pressão para encurtar ou remover mensagens de diagnóstico ou da interface com o usuário, e é bem simples, podendo até virar hábito, se não causar alguma consequência negativa para a sua aplicação.
PROGMEM: Como usar estruturas de dados armazenadas fora da RAM
O que a macro F()
tem de fácil de usar, também tem de limitada. Quando você tem um conjunto de dados para manipular, e não necessariamente para usar como parâmetro fixo em comandos print, ela não é tão prática.
Isso acontece, por exemplo, quando você tem uma lista de mensagens que devem ser complementadas ou combinadas entre si antes de serem exibidas, ou uma grande tabela de parâmetros de cálculo, ou as imagens dos caracteres de uma fonte para serem exibidos em um display LCD gráfico, etc.
Com a PROGMEM você instrui o compilador a armazenar fora da RAM uma estrutura de dados inteira, para manipulá-la depois.
Para esses casos, o modificador PROGMEM
pode ser usado para instruir o compilador a armazenar determinados tipos de dados (vários tipos numéricos, strings, etc.).
A documentação oficial do PROGMEM no Arduino pode ajudar, mas vou exemplificar com a aplicação simples de um uso comum: o armazenamento de uma tabela de strings, para referenciá-las individualmente.
Para isso, é necessário ter em mente 3 princípios básicos:
- estruturas PROGMEM devem ter escopo global
- estruturas PROGMEM devem ser constantes (ou ao menos tratadas como constantes)
- uma função especial deve ser usada para transportar componentes da estrutura PROGMEM para as variáveis "normais".
Veremos a aplicação deles no exemplo simplificado a seguir, que a cada 3 segundos escolhe uma frase em uma tabela armazenada na PROGMEM, consulta seu autor em outra tabela também na PROGMEM, e envia ambas pela serial.
const byte numFrases=8;
const char frases[numFrases] [75] PROGMEM = {
{ "Todo progresso acontece fora da zona de conforto" },
{ "O sucesso eh pessoal e intransferivel" },
{ "80% do sucesso eh simplesmente estar la" },
{ "Para o sucesso, atitude eh tao importante quanto capacidade" },
{ "A diferenca entre genialidade e estupidez eh que a primeira tem limite" },
{ "Genialidade eh a eterna paciencia" },
{ "O talento eh uma chama, e a genialidade eh uma fogueira" },
{ "O talento percebe as diferencas, e o genio identifica a unidade" }
};
const char autores[numFrases] [20] PROGMEM = {
{ "Michael J. Bobak" },
{ "J. Menhal" },
{ "Woody Allen" },
{ "Harry F. Banks" },
{ "Einstein" },
{ "Michelangelo" },
{ "Bernard Williams" },
{ "William B. Yeats" }
};
void setup() {
Serial.begin(9600);
randomSeed(analogRead(0));
Serial.println("--------");
}
void loop() {
char Frase[75];
char Autor[20];
byte i=random(numFrases);
memcpy_P(&Frase,&frases[i],sizeof Frase);
memcpy_P(&Autor,&autores[i],sizeof Autor);
Serial.print(Frase);
Serial.print(". (");
Serial.print(Autor);
Serial.println(")");
delay(3000);
}
Colorizei para facilitar a explicação. Note que o exemplo faz breve uso de ponteiros em C (referenciando o conteúdo de variáveis usando o prefixo &
), mas não é necessário entender o que isso significa neste momento, se tudo que você quer é ter um trecho de código que possa reaproveitar depois.
Os 2 trechos iniciais, em roxo e em laranja, são as definições das estruturas. Notem que ambas têm o prefixo const
e o sufixo PROGMEM
– esse é basicamente o procedimento padrão com esse tipo de armazenamento. O restante da definição é a sintaxe usual para criar arrays de strings em C.
Note que as listas são bem curtas, mas lembre-se que estão fora da RAM – se eu tivesse mais 300 frases, elas poderiam ser colocadas nas mesmas estruturas, e não esgotaria a memória do meu Arduino Uno.
O trecho em verde é o que precisamos ocupar na memória RAM: menos de 100 bytes (75 + 20), correspondendo ao espaço para armazenar UMA frase e UM autor, que é o máximo que chegaremos a usar simultaneamente. Note que declarei como variáveis locais, internas da função loop()
– mas poderiam ser globais, se a aplicação exigisse.
Funções especiais com o sufixo _P permitem transferir dados entre a PROGMEM e variáveis comuns da RAM.
Finalmente, o trecho em vermelho é onde a mágica acontece: logo após selecionar (na variável i
) qual o índice da frase e respectivo autor que devem ser mostrados, a função especial memcpy_P()
é chamada duas vezes: uma para transferir da estrutura da PROGMEM para a variável local o texto da frase, e a outra para fazer o mesmo com o nome do autor correspondente.
A memcpy_P()
é uma função especial da biblioteca interna que a IDE do Arduino usa, análoga à função memcpy()
padrão da linguagem C, mas que copia trechos de memória entre a PROGMEM e a RAM. Essa especialização é indicada pelo sufixo _P, também disponível na mesma biblioteca para outras funções tradicionais do C, como strcpy_P()
, strcmp_P()
e strncmp_P()
. Em todas elas, o primeiro parâmetro é uma variável na RAM, e o segundo vem da PROGMEM.
Depois de copiar para a variável "normal", é só usar normalmente. No exemplo, eu apenas enviei para a serial, mas poderia ter manipulado da forma que a minha aplicação exigisse.
Isso conclui nossa visão básica sobre o armazenamento de strings na PROGMEM, técnica que certamente será melhor explorada em artigos futuros, com propósitos mais práticos. Até lá!
- Chamada de SRAM -
static random access memory
. ↩ - Essa organização é providenciada pela compilação, pela arquitetura e pelo software básico, o programador pode nem mesmo ficar sabendo. ↩
- A classe String, do Arduino, é um caso típico de tendência a causar esse tipo de falha sem prover meios acessíveis para detectá-la. ↩
- Mensagens de erro ou outras strings típicas da interface com o usuário são vilãs comuns, elas usam mais RAM do que parece, especialmente para quem está acostumado a programar em ambientes com megabytes ou gigabytes de memória à disposição. ↩
- Descontando as otimizações gerais, que você pode aplicar reduzindo ou removendo todos os armazenamentos que não contribuam para a finalidade do programa, incluindo a redução dos tamanhos de buffers e a escolha de tipos e escopos adequados para as variáveis. ↩
- Outras funções e métodos que saibam o que fazer com dados do tipo
__FlashStringHelper*
também podem aproveitar a mesma macro. ↩
Comentar