Reentrância

A maioria dos sistemas em tempo real necessitam de alguma porção de código reentrante, porém muitos programadores não fazem idéia do que isso envolve.

Por muitas razões, debugar um sistema baseado em interrupções é muito mais difícil que fazê-lo em um loop simples de código. Uma das mais chatas fontes de bugs, difíceis de encontrar e, algumas vezes, de entender, é o problema da reentrância.

Funções reentrantes, também conhecidas como “código puro”, são falsamente conhecidas como qualquer código que não modifica a si mesmo. Muitos programadores se sentem tranquilos simplesmente evitando código que se modifique, então as suas rotinas seriam garantidamente reentrantes e, então, passiveis de interrupção sem problemas (interrupt-safe). Nada poderia ir tão além da verdade quanto isso.

Uma função é reentrante se, enquanto estiver sendo executada, puder se re-invocada por si mesmo ou por outras rotinas, interrompendo a execução atual por um instante. A reentrância foi originalmente inventada para os mainframes, nos dias em que memória era um luxo. Os operadores dos sistemas noticiaram que dezenas ou centenas de cópias idênticas de alguns grandes programas estavam ao mesmo tempo no array de memória do computador. Na Universidade de Marylard, meu velho terreno de hacking, o monstro Univac1108 possuia um dos primeiros compiladores FORTRAN reentrantes. Ele gastava (naquela época) 32k da memória do sistema mas, com o código reentrante, ele precisava apenas destes 32k se 50 usuários estivessem rodando-o. Cada usuário executava o mesmo código, a partir do mesmo conjunto de endereços.

A rotina precisa satisfazer as seguintes condições para ser reentrante:

  1.  Jamais modificar a si mesma. Isto é, as instruções do programa jamais devem mudar. Sempre. Em nenhuma circunstância. Vários sistemas embarcados violam este regra crucial.
  2. Qualquer variável alterada pela rotina precisa ser alocada para a “instância” particular da função invocada. Isto é, se a função FOO é chamada por três funções diferentes, então os dados em FOO precisam ser armazenadas em três diferentes áreas da RAM.

O item (2) merece um pouco mais de discussão. Uma das melhores tendências na indústria é utilizar profissionais engenheiros de software no desenvolvimento de projetos de firmware. Nos velhos dias do design de hardware, quem escrevia o código era provavelmente o pessoal com pouca formação formal no assunto (com algumas excessões), que o faziam apenas depois de construir todo o hardware.

Os software de verdade utilizam estruturas mais sofisticadas de programas que levam à um código mais limpo (normalmente), como a recursão, mas elas trazem novos perigos.

Uma função recursiva chama a si mesma. O exemplo clássico é o cálculo de n! (n fatorial), onde a forma mais elegante é escrita com algumas linhas de código recursivo.

Qualquer função recursiva precisa ser reentrante, porque cada instância da execução precisa do seu próprio conjunto de variáveis para evitar corromper qualquer outra instância.

Por exemplo, considere esta simples função recursiva retirada de um exemplo do Borland C++ Programmers Guide:

double power(double x, int exp)
{
if (exp<=0) return(1);
return(x*power(x, exp-1));
}

Esta função vai funcionar corretamente em um ambiente recursivo, assim como em um sistemas em tempo-real, com interrupções, onde “power” pode ser chamada a partir de uma rotina sequencial ou a partir de um serviço de interrupção. A função é certamente “pura” – todas as variáveis utilizadas são criadas para cada instância da execução.

Suponhamos que nós a modifiquemos da seguinte forma:
double power(double x)
{
if (exp<=0)return(1);
--exp;
return(x*power(x));
}

onde exp é agora definida como uma variável pública, acessível de muitas outras funções. A função vai funcionar corretamente. No entanto, se esta função for chamada por, digamos, main(), e a interrupção for iniciada chamando a mesma função enquanto ela ainda estava executando, nós teremos um resultado incorreto. A variável exp é fixa na memória; ela não é única em cada chamada e quando chamadas compartilhadas ocorrem nós temos um desastre no ambiente.

Então um código “puro” jamais deve modificar a si mesmo e jamais deve compartilhar dados com outras instâncias de si mesmo, quando chamado por recursão, em um serviço de interrupção, ou qualquer outro processo.

No exemplo anterior nós mencionamos o compilador FORTRAN. Enquanto o próprio compilador era carregado apenas uma vez na memória principal, cada usuário alocava um pedaço desta ou daquela área de memória interna do compilador para seu próprio uso. Cada variável ou array de dados que o compilador usava era referenciado através de um conjunto base de registradores que ficavam no espaço de dados iniciado pelo usuário.

C é elegantemente orientado através de reentrância, onde variáveis locais automáticas são geralmente armazenadas em uma pilha cada vez que a função é invocada. Desta forma, como nós vimos, ainda é possível escrever código problemático em C. Em Assembly, é claro, o caos reina.

 

Reentrância em Sistemas Embarcados

Qualquer sistema embarcado precisa de reentrância e mesmo os mais simples sistemas irão, normalmente, necessitar de código reentrante.  Antes de ver aonde a reentrância é crítica, nós devemos dar uma olhada em áreas típicas onde o código é “impuro”.

Alguns processadores possuem limitadas capacidades de endereçamento de I/O. Por exemplo, o 8085 só pode enviar dados para um específico endereço hard coded (definido no hardware) (algo como “envia o conteúdo da saída da porta para o registrador C”). A tradicional escapatória (work arround) seria gerar a saída e retornar o resultado na RAM, dinamicamente a partir da porta desejada, e então chamar o código baseado-se na RAM. Isso viola qualquer regra da reentrância. Uma abordagem melhor é gerar uma tabela das instruções de saída no espaço da ROM e chamá-las indiretamente cada uma. Isso, no entanto, consome uma grande quantidade de memória.

O 1802 não possui pilha… nenhuma. Não existem instruções de chamadas ou retorno (sem brincadeira!). Reentrância era completamente impossível.

Cada sistema operacional de tempo real moderno, normalmente, possui pequenos segmentos de máquina específicos para o código que não é reentrante. Uma vez que isso não é mais perigoso que ter código “impuro”, todos os fornecedores fazem um bom trabalho ao proteger seções “impuras” desabilitando interrupções por um curto tempo.

Reentrância é crucial em qualquer seção de código que precisa ser invocado por qualquer outro processo. Em um SO tempo real, cada tarefa é independente e preparada para os interesses da reentrância. Qualquer subrotinas compartilhadas entre tarefas podem ser fontes reais para estes interesses, uma vez que o RTOS pode trocar de contexto em um tick do timer durante a execução desta rotina crítica e então agendar outra tarefa que invoca a mesma função.

Existem muito mais problemas aqui. Suponha que sua rotina principal e as ISR (interrupt service routine) estão todas escritas em C. O compilador irá certamente invocar funções em tempo de execução para suportar matemática de ponto flutuante, I/O, manipulação de string, etc. Se o pacote com estas funções é apenas parcialmente reentrante, então as suas ISRs podem muito bem serem corrompidas durante a execução do código principal. Este problema é comum, mas é virtualmente impossível para pesquisar estes defeitos, uma vez que os sintomas podem apenas aparecer ocasionalmente e produzindo erros diversos. Você pode imaginar o quão difícil é isolar um bug que se manifesta apenas ocasionalmente e com características completamente diferentes cada vez ?

A moral da história é ter certeza que o seu compilador possui um pacote de runtime completamente “puro”.

Os programadores que usam Assembly estão vacinados deste mal, uma vez que qualquer rotina comum em baixo nível compartilhada entre uma ISR e outro código precisa ser puro. Se você compra uma biblioteca de ponto flutuante, de comunicação, etc., tenha certeza que o fornecedor garante a reentrância em todos os modos.

Uma vez que muitos sistemas embarcados executam a partir da ROM, alguns níveis de reentância são assegurados. Não importa o quanto se tente é impossível escrever código auto modificável, pelo menos no espaço da ROM! Infelizmente, isso adiciona uma certa complacência.

A minha empresa vende emuladores. O maior número de ligações no suporte vem de programadores que possuem um código que funciona na ROM, mas tem problemas quando executados a partir da memória interna do emulador. O problema é sempre o mesmo: o usuário inadvertidamente escreve código sobre outro código. Na ROM o problema nunca aparece. Certamente, no entanto, isso indica um problema mais sério. Essa escrita deveria provavelmente ocorrer em algum espaço de dados na RAM, que não está sendo atualizada. Ou, se o código for suspeito, algum valor aleatório pode estar indo para os registradores de índices e podem causar a escrita de algum lixo na pilha ou algum outra estrutura de dados crítica.

Tendo uma CPU relativamente decente, não é tão difícil escrever código reentrante do início (recursos para a pilha são uma grande requisito), mas o que eu vi é que é quase impossível fazer um grande programa impuro reentrante. Geralmente os monstros então no heap das variáveis globais. Se a reentrância é necessária, somente pequenas áreas podem, algumas vezes, se proteger desabilitando as interrupções ao seu redor. Então nenhuma ISR pode ser sincronizada com os recursos críticos. Isso pode não funcionar bem em sistemas multitarefas.

Infelismente, desabilitar as interrupções traz seus próprios problemas. Aumenta a latência das interrupções e em alguns casos pode fazer perder alguma interrupção.

Muitos anos atrás eu havia convertido uma pacote de ponto flutuante em Assembly para código “puro”. Eu tentei várias saídas para corrigir os problemas, mas no final eu só obtive a cura com um pacote totalmente reescrito.

Encontrando a “Pureza”

Como nós sabemos se o código é verdadeiramente reentrante ? Não importa o quão cuidadoso é o design do seu novo projeto, é simplesmente muito fácil inadvertidamente criar referências impuras aos dados, ocasionamente. Em uma atualização de um velho projeto o problema é mais severo, pois você pode não estar totalmente confortável com a estrutura do código ou as intenções do programador original.

Eu tenho dúvidas se existe um teste completo para reentrância. Apenas alguns testes parciais são simples e podem ajudar.

É muito fácil encontrar código que acidentalmente (ou de outra forma) escreve sobre si mesmo. Qualquer emulador decente deixa você escrever no espaço de programa. Quando utilizando um monitor de ROM, simulador, ou algo assim, regularmente execute checksum no código. Se o checksum mudar (para uma particular versão do código), alguma coisa está errada.

Eu faço buscas globais por declarações STATIC em C. STATIC pode indicar um problema de reentrância em potencial.

Em rotinas escritas em linguagem Assembly, qualquer referência direta a um endereço da RAM é um ponto para examinar a reentrância. Não há nada implicitamente errado com um MOV [CONT], AX; a maioria das vezes isso vai funcionar bem em rotinas reentrantes. Ainda assim, isso pode ser um indicador de código “impuro”. Uma solução é armazenar as variáveis locais no quadro atual da pilha.

Como você pode dizer se um pacote runtime comprado é realmente reentrante sem ler todo o código? Uma abordagem é linkar o programa sem o pacote (retirando as chamadas às rotinas) e depois fazê-lo com elas. Compare o relatório do linker para o espaço de dados. Um pacote realmente reentrante não vai necessitar de nenhuma RAM adicional além da pilha e/ou heap.

Adaptado por Eletronica.org do artigo Reentrancy escrito pelo consultor americano Jack Ganssle, com a devida autorização.

Todos os direitos são reservados ao autor. Você pode encontrar mais artigos e informações sobre o Jack no site do Ganssle Group.