Projeto Controlduino: Houston, we have an AVR problem!

Toda a discussão sobre ponto flutuante faz sentido apenas se o microprocessador tem suporte a esse recurso. Não é o caso dos microcontroladores AVR. Você leu direito: Não há suporte a ponto flutuante no ATmega2560 ou na maioria dos microcontroladores usados nesses dispositivos pequenos como o Arduino!

Mas, Fred, eu já fiz programinhas no Arduino usando ponto flutuante!

Bem… o microcontrolador não implementa ponto flutuante em hardware, mas nada impede que isso seja implementado em software. O compilador avr-gcc usa mais ou menos o mesmo conjunto de bibliotecas que o gcc convencional: libc e libm. A primeira com um monte de funções da biblioteca padrão e a segunda com funções da biblioteca matemática, incluindo rotinas para adição, subtração, multiplicação e divisão. Mas existem duas más notícias:

  1. Embora o tipo double seja implementado como palavra-reservada, ele é a mesma coisa que o tipo float. E o tipo float é implementado de acordo com a especificação IEEE-754 (ou, pelo menos, deveria sê-lo)… É um tipo de precisão simples, com 23 bits na mantissa, 8 bits no expoente e 1 bit de sinal;
  2. Absolutamente todas as operações em ponto flutuante são funções na libm. Isso faz com que uma simples adição gaste centenas (senão milhares) de ciclos de máquina para serem executadas. O que pode impossibilitar sampling rates razoavelmente altos.

Para provar o ponto 2, eis um código simples para derivação e o código equivalente, em assembly, para o AVR:

float dydt(float new, float old, float dt)
{ return (new - old) / dt; }

Ao compilar com o avr-gcc, obtemos isso:

; dydt recebe 3 parâmetros float em:
;   new (R22:R25),
;   old (R18:R21),
;   dt (R14:R17)
; e retorna em (R22:R25)
;
dydt:
    push r12        ; Preserva R12 até R17.
    push r13
    push r14
    push r15
    push r16
    push r17

    ; Copia dt para dois registradores abaixo...
    ; Provavelmente porque __subsf3 usa 2 bytes
    ; extras para armazenamento local.
    movw r12,r14
    movw r14,r16

    ; __subsf3() recebe dois parâmetros float
    ; em R22:R25 (new) e R18:R21 (old) e
    ; retorna en R22:R25.
    call __subsf3

    ; Coloca R12:R15 (dt) no segundo parâmetro de
    ; __divsf3. O primeiro parâmetro (R22:R25) já
    ; contém a subtração.
    movw r20,r14
    movw r18,r12
    call __divsf3

    ; Neste ponto R22:R25 contém o resultado!

    pop r17        ; Recupera R12 até R17.
    pop r16
    pop r15
    pop r14
    pop r13
    pop r12
    ret

Se considerarmos 4 ciclos de clock para cada instrução (o que não é bem verdadeiro!), só essa rotina toma 76 ciclos de clock, sem contar com as  __subsf3 e __divsf3, que são grandes pra caramba! E olha que estou falando de uma rotininha simples, em C, sem nenhuma consideração pelos erros causados por operações em ponto flutuante. Quando chegar a hora de implementar integração o bicho pega!

A convenção de chamada usada pelo avr-gcc

Para analizar qualquer código em assembly, precisamos, além de conhecer as instruções, entender como o compilador C lida com os registradores. Especialmente quando falamos de convenção de chamadas.

Os microcontroladores AVR possuem 32 registradores de 8 bits, nomeados R0 até R31, onde alguns têm significado especial. O registrador R0, por exemplo, é uma espécie de scrach register, usado por algumas instruções que lidam com ponteiros. Os registradores R26 até R31 podem ser usados, aos pares, como ponteiros e recebem nomes especiais de X, Y e Z, cada par (aparentemente o avr-gcc reserva o uso de Z para si).

No avr-gcc a coisa parece funcionar assim:

  • Os registradores de R2 até R17 e os R28 e R29 são usados pela função e devem ser preservados por ela;
  • Aparentemente R1 é reservado ao compilador;
  • O registradores R0 (bem como o flag T) deve ser preservado apenas no caso de rotinas de atendimento de interrupções;
  • Valores de 8 bits são passados em um único registrador, 16 usam 2 registradores e 32 usam 4. O mesmo vale para a pilha;
  • Os argumentos e retorno de funções podem tanto serem passados e retornados em registradores ou na pilha;
  • A escolha do compilador pelo conjunto de registradores usados na chamada da função segue um padrão estranho: O registrador usado no primeiro parâmetro é calculado como 26 menos o tamanho total dos parâmetros, arredondado para valores pares, para baixo. Mas se esse registrador for menor que 8, o parâmetro é passado na pilha;
  • O valor de retorno poderá ser retornado em registradores se tiver tamanho de 8 ou 16 bits, caso contrário, usará a pilha. A rotina chamadora aloca o espaço na pilha.

Ainda está difícil me acostumar com tantas regras… mas, um exemplo útil é este: O protótipo da função abaixo usa o registrador R24 para o argumento a e os registradores R20 até R23 para o argumento b, onde R20 é o LSB e R23, o MSB. A função retorna o valor desejado no par R24 e R25:

int f(char a, long b);

O tipo char tem 1 byte, então 26-1 = 25, mas a convenção diz que devemos usar valores pares de registrador, então ele é passado via R24. O tipo long tem 4 bytes de tamanho, então ele é passado pelos 4 registradores anteriores a R24. No retorno, int tem 2 bytes, então ele usa R24 e R25 (R26 – 2 bytes). Quando a posição do parâmetro ultrapassar a barreira de R8, então ele deve ser passado pela pilha… Parece simples, né?

Mas há um porém: Existem funções onde a convenção de chamada é quebrada. A documentação do avr-gcc cita o exemplo de funções de multiplicação e divisão inteiras…

Por que diabos estou falando de convenção de chamadas?

Bem… quero entender as rotinas contidas na libc e libm do avr-gcc! E para isso, preciso saber como o compilador trabalha. A minha função dydx, por exemplo, usa 12 bytes de parâmetros. Pela convenção de chamada acima ela deve receber os 3 parâmetros nos pares de registradores  R22-R25 para new, R18-R21 para old e R14-R17 para dt. A função __subsf3 espera 8 bytes de parâmetros, alocados em 2 floats de 4 bytes cada. Pela lógica, R22-R25 seria o primeiro valor (new) e R18-R21 o segundo (old), retornando em R22-R25 o resultado, que é repassado para __divsf3. Isso tudo está descrito na listagem acima.

Ainda existem surpresas: A cópia de dt para 2 registradores abaixo foi inesperada para mim… O porquê disso ainda tenho que investigar. Suspeito que o compilador saiba que __subsf3 use um espaço de armazenamento local, mas posso estar enganado e o motivo seja outro.

Sabendo como os parâmetros devem ser passados e os resultados recebidos, tenho como analizar a libm e, já que os microcontroladores AVR são simples, a contagem de ciclos não deve ser difícil.

Isso deve demorar um pouco, quando tiver um veredito, conto pra vocês…

Anúncios

Deixe um comentário

Faça o login usando um destes métodos para comentar:

Logotipo do WordPress.com

Você está comentando utilizando sua conta WordPress.com. Sair / Alterar )

Imagem do Twitter

Você está comentando utilizando sua conta Twitter. Sair / Alterar )

Foto do Facebook

Você está comentando utilizando sua conta Facebook. Sair / Alterar )

Foto do Google+

Você está comentando utilizando sua conta Google+. Sair / Alterar )

Conectando a %s