Um exemplo de como programar diretamente em Assembly nem sempre é uma boa ideia…

Um amigo tem um projeto onde ele usa um método não muito canônico para lidar com os modos real e protegido dos processadores 386 ou superiores. Trata-se de um modo esquisito chamado “Unreal Mode”, onde  alguns ajustes na Global Descritor Table são feitos e, no modo real, pode-se acessar, usando os registradores estendidos, memória superior ao endereçamento de 1 MiB (mais de 20 bits). Até ai, tudo bem… Mesmo que esse modo não seja documentado e, portanto, não muito recomendado, ele funciona… Mas, já que o restante do código continua nos segmentos marcados como 16 bits, usar uma instrução do tipo REP MOVSB para mover blocos da memória baixa para a alta não é lá muito decomendável, afinal, o processador vai usar ESI/EDI/ECX ou SI/DI/CX?

Meu amigo escreveu, então, a seguinte rotina para fazer esse tipo de cópia:

bits 16
cpu 386

; void PASCAL CopyLinear(void *destptr, 
;                        void *srcptr, 
;                        size_t size);
CopyLinear:
  push bp
  mov  bp,sp
  push ds

  push esi   ; Salva ESI/EDI (convenção de chamada).
  push edi

  pushfd     ; Salva EFLAGS.
  
  mov eax,[bp+6]
  mov edi,[bp+10]
  mov esi,[bp+14]

  ; Calcula quantidade de dwords.
  mov ecx,eax
  shr ecx,2
  and eax,3
  jz  .L1
  inc ecx
.L1:

  xor  ax,ax   ; Aliás! Seletor zero da GDT?!
  mov  ds,ax

  ; Loop de cópia DWORD por DWORD.
  test ecx,ecx
  jmp  .L3
.L2:
  mov eax,[esi]
  mov [edi],eax
  add esi,4
  add edi,4
  dec ecx
.L3:
  jne  .L2
  
  popfd

  pop   edi
  pop   esi

  pop   ds
  leave
  ret 12

A rotina equivalente, em C, poderia ser algo assim:

__attribute__((stdcall))
void CopyLinear(void *dest, void *src, size_t size)
{
  unsigned int *d = dest;
  unsigned int *s = src;
  size_t sz = size / 4;

  if ((size % 4) != 0)
    sz++;

  while (sz--)
    *d++ = *s++;  
}

Será que o GCC cria um código melhor que o do meu amigo? Desconsiderando a questão do seletor de segmento, eis o código gerado (adaptando o uso do prólogo e epílogo, como na listagem anterior):

  align 4
CopyLinear:
  push bp
  mov  bp,sp

  push ds
  push esi
  push edi
  push ecx
  pushfd

  mov  ecx,[ebp+6]
  mov  edi,[bp+10]
  mov  esi,[bp+14]

  ; Olha, mamãe! Sem saltos condicionais!
  mov  eax,ecx
  shr  ecx,2    ; ECX=ECX/4
  and  eax,3    ; EAX=ECX%4
  cmp  eax,1    ; CF=0 se EAX>=1 e CF=1 se EAX==0
  sbb  ecx,-1   ; ECX=ECX-(-1)-CF

  ; Usando EAX como contador
  ; e zera DS.
  xor  eax,eax
  mov  ds,ax

  ; Se ECX==0 não faz nada...
  test ecx,ecx
  jz   .L2

  align 4
.L1:
  mov  edx,[esi+eax*4]
  mov  [edi+eax*4],edx
  add  eax,1
  cmp  eax,ecx
  jne  .L1

.L2:
  popfd
  pop  ecx
  pop  edi
  pop  esi
  pop  ds

  leave
  ret  12

A rotina é essencialmente a mesma, exceto por três pontos:

  1. O contador, no loop, é mais simples, exigindo apenas um incremento simples. Para isso as instruções usando escalonamento no calculo do endereço efetivo são usadas;
  2. Na pequena rotina do cálculo da quantidade de DWORDs nenhum salto condicional é necessário, apenas aritmética;
  3. O compilador sempre tenta alinhar pontos de retorno de loops por DWORD (ou QWORD, dependendo da arquitetura). Isso acelera as coisas um cadinho com relação ao Cache L1i.

Das três diferenças a primeira é a mais importante. O cálculo de endereço efetivo é “micro-fundido” com a própria instrução e, desde o 486 (pelo menos), não apresenta ciclos adicionais. Ao usar o escalonamento o compilador garante que o endereço será lido/gravado de 4 em 4 bytes. Assim, apenas EAX precisa ser incrementado e da forma mais simples possível. Então não teremos mais dois incrementos e um decremento. Ao poupar 0,5 ciclo de máquina, retirando uma instrução do loop, por causa da arquitetura superescalar, se o buffer for grande pouparemos \frac{1}{2}\cdot bytes ciclos de clock.

A segunda diferença merece uma explicação: Numa operação de subtração o flag CF é o “borrow” (empréstimo) que ocorre quando tentamos subtrair um valor maior de um menor. Assim,”SBB ECX,-1” fará ECX=ECX-(-1)-borrow=ECX+1-borrow. O flag CF é afetado, antes, pela subtração feita pela comparação de EAX com 1. Ou seja, se o resto da divisão por 4 for zero, então teremos ECX=ECX+1-1=ECX, mas se tivermos EAX maior ou igual a 1 (temos bytes adicionais para copiar!), então ECX=ECX+1-0=ECX+1. E não precisamos de um salto condicional para frente, que danificará o esquema de branch prediction, pelo menos, para a primeira iteração do loop.

Anúncios