De novo: Por que programar diretamente em assembly geralmente não é uma boa ideia…

Sim… de novo estou advogando contra mim mesmo. Eu uso assembly sempre que posso e quando isso não implica em códigos dependentes de arquitetura. Mas, para o novato, que está aprendendo assembly agora, programar nessa linguagem quase sempre é sinônimo de desastre. Eis o porquê:

Convenções de chamadas existem por um bom motivo

Quando estamos aprendendo é sugerido que manter o contexto da função chamadora é importante. Por contexto quero dizer os registradores que estavam sendo usados pela função que chamou a sua… Isso parece razoável, mas não é assim que compiladores de nível mais alto fazem… Um exemplo simples é a implementação padrão da função strcpy, em C:

char *strcpy(char *d, char *s)
{
  char *p = d;
  while (*p++ = *s++);
  return d;
}

Note que a função não verifica o conteúdo dos ponteiros (eles PODEM ser NULL e causarem segmentation fault) e o código gerado também não se preocupa em preservar o conteúdo dos registradores. No x86-64 isso é mais evidente:

strcpy:
  mov   rax,rdi
  mov   rdx,rdi
.loop:
  add   rsi,1
  movzx ecx,byte [rsi-1]
  add   rdx,1
  test  cl,cl
  mov   [rdx-1],cl
  jne   .loop
  ret

Aqui a função alterou, sem escrúpulos, o conteúdo de RAX, RCX, RDX, RSI e RDI. Isso acontece porque, no modo x86-64, o GCC só se preocupa em manter os registradores RBX, RBP, RSP e de R12 até R15. Já no modo i386, os mantidos devem ser EBX, EBP e ESP apenas. Todos os demais podem ser modificados.

Há uma vantagem escondida nessa aproximação: Se você sabe que o compilador C vai manter, à todo custo, esses registradores inalterados entre chamadas, tanto ele quanto você pode usá-los como “variáveis temporárias” (desde que preserve os seus conteúdos vindos da função chamadora!)… Quanto aos demais, se sua função chama outra e você precisa preservar o conteúdo de EAX, por exemplo, então é obrigá-lo a salvá-lo você mesmo (na pilha, por exemplo!).

Ahhh, sim… nas convenções de chamada usadas por C, pelo menos no modo i386, EAX é usado para retornar um valor e o par EDX:EAX, se esse valor for maior que 32 bits. Se sua função não retorna valores, pode mexer com EAX e EDX à vontade e deixá-los “bagunçados”. Eles não precisam ser preservados, neste caso.

Usando a pilha para manter variáveis locais entre chamadas

O GCC tenta manter todas as variáveis locais em registradores, mas, eventualmente, precisa guardá-los para uso futuro (depois do retorno da chamada de alguma função). Geralmente ele faz algo assim:

f:
  sub esp,4
  ...
  mov [esp],eax
  call some_function
  mov eax,[esp]
  ...
  add esp,4
  ret

ESP, quando sua função é chamada, aponta para o endereço de retorno da função chamadora. Como a pilha cresce para baixo, nossa função resolveu alocar 4 bytes (no modo i386 a pilha deve estar sempre alinhada por DWORD) adicionando 4 ao ESP. Antes de retornar (RET) o conteúdo de ESP deve voltar ao valor original que estava (lembra-se que ele é um dos que têm que ser preservados?).

É claro que você poderia guardar o conteúdo dessa variável “local” dentro de algum endereço no segmento de dados ou, até mesmo, no segmento de código, mas ai ela deixaria de ser “local”, não é?

Por que não usar PUSH e POP?

Depois que você aprende o conceito de pilha, parece muito mais simples usar as instruções PUSH e POP do que usar o esquema que mostrei acima. O mesmo fragmento de código em poderia fazer, retirando aqueles SUB e ADD e codificando como:

f:
  ...
  push eax
  call some_function
  pop  eax
  ...
  ret

A função fica menor? Fica! Parece mais simples? Parece! Mas ela tem um problema… As instruções PUSH e POP são ligeiramente mais lentas que MOV porque cada PUSH e cada POP, além de lerem/gravarem na pilha os valores dos registradores têm que adicionar 4 ou retirar 4 de ESP. O código acima, sem usar PUSH e POP, é equivalente a:

f:
  ...
  sub  esp,4
  mov  [esp],eax
  call some_function
  mov  eax,[esp]
  add  esp,4
  ...
  ret

Essa sequência de SUB/MOV e MOV/ADD acontece em cada um dos PUSHes e POPs, respectivamente. O que o compilador C faz é, já que ele sabe quantos bytes vai precisar usar da pilha, ele os aloca de antemão e usa apenas MOVs para guardar os valores!

Ainda não se convenceu que usar a pilha é melhor?

Se você ainda acha que guardar estados no segmento de dados ou código é uma boa ideia, existe mais um motivo para não fazê-lo. Um acesso à memória depende do cálculo do “endereço efetivo”, que pode ser dado por uma expressão. Por exemplo:

mov eax,[esp]
mov eax,[esp+4]
mov eax,[ebx+esi+8]
mov eax,[ebx+esi*8+8]
mov eax,[0x7ffffff0]

Nos primeiros 4 casos o “endereço” de onde um DWORD será lido é dado pelas expressões envolvendo registradores. No primeiro e no segundo caso não há gasto de ciclos de clock adicionais e o acesso é muito parecido com quando você acessa um registrador qualquer, em termos de performance. Os outros casos envolvem cálculos mais complicados e, no caso do último, um deslocamento maior que 2047. Qualquer deslocamento maior que 2047 causa o consumo de 1 ciclo de clock extra (além do cálculo)… Por exemplo, se usássemos [esp+4096] teríamos o consumo de 1 ciclo a mais na instrução… Se usássemos [ebx+esi+4096], teríamos o consumo de 1 ciclo a mais, graças ao cálculo de EBX+ESI e ainda mais 1 porque o offset é maior que 2047.

Ao usar a pilha e manter as variáveis locais próximas de onde ESP aponta, digamos, menos de 2 KiB perto, não há consumo de ciclo extra. É como se o processador estivesse lendo/gravando num registrador.

É claro que existem outras penalidades: Cache Misses, TLB misses e Page Faults, por exemplo… Mas, isso poderia ocorrer nos outros casos também…

 

Adote uma convenção de chamadas ao estilo de C…

Deixar para as funções chamadoras manterem seus próprios contextos e evitar fazer testes desnecessários em funções de baixo nível são sempre boas ideias… Suas funções de baixo nível devem fazer apenas o que elas precisam fazer. Verificar se ponteiros são nulos, se valores estão dentro da faixa desejada etc, são tarefas para funções de níveis mais altos, mais “inteligentes”. Essa estratégia leva a códigos menores e mais rápidos, sempre!

Anúncios