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.