SDCC: Algumas considerações sobre o Z80…

Escrevendo o artigo nostálgico sobre Z80 e 6502 testei o SDCC (Small Devices C Compiler) criando pequenos códigos. Eu pretendia mostrar como um código simples (checksum ou obter o maior valor contido num array) ficariam, para esses processadores. Minha surpresa é que o SDCC cria código ineficiente.

A primeira coisa a fazer aqui é determinar qual é a convenção de chamada usada pelo SDCC. Para isso vou compilar funções simples:

char f(void)   { return '0'; }
char g(char c) { return c + '0'; }
int  h(void)   { return -1; }
int  i(int x)  { return x - 1; }

Isso cria código como mostrado abaixo:

_f:
  ld  l,#0x30       ; L = '0'.
  ret

_g:
  ld  hl, #2
  add hl, sp        ; HL = SP+2
  ld  a, (hl)       ; A = c
  add a, #0x30      ; A += '0'
  ld  l,a
  ret

_h:
  ld  hl,#0xFFFF    ; HL = -1
  ret

_i:
  ld  hl, #2
  add hl, sp        ; HL aponta para x, na pilha.

  ld  e, (hl)       ; DE = (HL).
  inc hl
  ld  d, (hl)

  dec de            ; HL = DE - 1
  ex  de,hl
  ret

Ao que parece, os valores de retorno são sempre feito em L ou no par HL e o acumulador, bem como o par DE, podem ser modificados à vontade (não precisam ser preservados entre chamadas). E, também, os argumentos da função são sempre passados pela pilha.

Para determinar melhor a convenção de chamadas, devemos lidar com rotinas mais complexas, para observar a alocação dos registradores e, se necessário, como as variáveis locais são alocadas na própria pilha. Por exemplo, uma rotina mais elaborada, checksum, poderia ser útil:

#include <stdint.h>
#include <stdlib.h>

uint16_t cksum(uint8_t *p, size_t size)
{
  uint16_t sum;

  for (sum = 0; size--; )
    sum += *p++;

  return sum;
}

E, eis o código que o SDCC cria:

_cksum:
  push  ix        ; Salva IX para usá-lo como
  ld    ix,#0     ; ponteiro auxiliar para a pilha.
  add   ix,sp

  push  af        ; Cria espaço para cópia do ponteiro p.

  ld    de,#0     ; DE é 'sum'.

  ; Copia 'p' para variável local, na pilha.
  ld    a,(ix+4)
  ld    (ix-2),a
  ld    a,(ix+5)
  ld    (ix-1),a

  ; BC contém 'size'
  ld    c,(ix+6)
  ld    b,(ix+7)

00103$:
  ld    h,c       ; HL <- BC
  ld    l,b
  dec   bc        ; size--;

  ; size == 0?
  ld    a,l
  or    a,h
  jr    Z,00101$   ; Se 'size==0', sai da rotina.

  pop   hl        ; Recupera 'p' da pilha.
  push  hl

  ld    l,(hl)    ; L <- *p

  ; Incrementa p (na pilha)
  inc   (ix-2)
  jr    NZ,00116$
  inc   (ix-1)

  ; sum += *p;
00116$:
  ld    h,#0x00
  add   hl,de
  ex    de,hl
  jr    00103$

  ; Coloca DE em HL, recupera SP, limpa a pilha e sai.
00101$:
  ex    de,hl
  ld    sp, ix
  pop   ix
  ret

Aqui podemos extrapolar que AF e BC também não são preservados. Aliás, ao que parece, apenas os registradores de índice IX e IY (se usado) e o SP (por motivos óbvios) devem ser preservados… Lembrando que os retornos são feitos em L ou HL. E, mesmo no caso de retornos de tipos de 8 bits, o registrador H não precisa ser preservado (como pode ser visto na função g, lá em cima).

Uma coisa estranha no código criado pelo SDCC é que ele preferiu manter uma cópia do ponteiro ‘p’ na pilha, ao invés da variável local ‘sum’… E a rotina ficou maior do que poderia ser, respeitando os critérios do compilador. Eis como eu a implementaria, se fosse o compilador:

_cksum:
  push ix
  ld   ix,#0
  add  ix,sp

  ld   h,(ix+4)   ; HL <- p
  ld   l,(ix+5)
  ld   b,(ix+6)   ; BC <- size
  ld   c,(ix+7)

  ld   de,#0      ; DE <- 0

.loop:
  ld   a,b        ; if (BC == 0) goto .exit;
  or   c
  jr   z,.exit

  dec  bc         ; BC--;

  ld   a,e        ; DE += *p;
  add  a,(hl)
  ld   e,a
  ld   a,d
  adc  a,#0
  ld   d,a

  inc  hl         ; p++;

  jr   .loop

.exit:
  ex   de,hl      ; HL <- DE.
  pop  ix
  ret

Não há necessidade de variáveis locais alocadas na pilha e a rotina acima é 6 instruções menor que a anterior. Ainda, o loop principal é mais curto!

Claro que o SDCC foi esperto ao usar o par HL, com H zerado, para acumular, em DE, o checksum, mas não foi muito esperto ao usar uma variável local, na pilha, para manter a cópia do ponteiro p. Outro fato interessante é que eu não confio que (IX-2) será calculado corretamente, já que as instruções que usam IX dessa maneira indireta adicionam um byte ao registrador de 16 bits. Ou seja, -2 é 0xFE e não 0xFFFE… Isso é uma desconfiança minha e é bem possível que essa adição seja feita considerando o sinal do offset (é provável que seja assim).

Deixe-me lembrá-lo que o processador Z80, assim como outros de 8 bits, não possuem cache (nem de dados, nem de código). Não possuem “reordenador” de instruções, não possuem arquitetura superescalar, não possuem “branch prediction”, etc… Cada instrução e cada acesso à memória adicional conta. A regra para otimização de código para esses processadores é: Menos instruções e menos acessos à memória, além, é claro, de bons algoritmos.