Dicas de assembly para o x86-64

Se você já tem alguma familiaridade com assembly para o modo x86-64 dos processadores da família 80×86, sabe que existem um monte de registradores de 64 bits disponíveis, que o SIMD (SSE) também tem o dobro de registradores e que existem novidades no modo de endereçamento e passagem de argumentos para funções (se usar uma calling convention padronizada). Aqui vou mostrar algumas dicas para que você evite algumas implementações ineficientes… Não que algumas práticas sejam erradas, mas elas tendem a criar imagens binárias (executáveis) maiores e potencialmente mais lentos. Meu foco é para sistemas Unix que implementem a convenção SysV ABI. Outro detalhe: Todos os fragmentos de código em assembly aqui serão na sintaxe do NASM.

Evitando criar entradas na tabela de relocação (GOT)

Quando você define um símbolo, uma variável, você está dizendo ao compilador que reserve espaço para ela, no tamanho de seu tipo. Onde essa variável será colocada na memória é tarefa para o linker decidir. Isso é verdade mesmo em assembly. Ao fazer algo do tipo:

section .bss
n:  resd 1

Está dizendo ao NASM que reserve um espaço de 4 bytes e rotule-o de “n”. O NASM não sabe onde esses 4 bytes estarão na memória. Apenas sabe que estarão no segmento .bss. Por quê? Suponha que você tenha um programa composto de dois arquivos em assembly. Os símbolos definidos no primeiro são “mesclados” com os símbolos definidos no segundo pelo linker e esse define a ordem em que esses símbolos serão arranjados.

Agora, vamos dar uma olhada nesse pequeno código, ao estilo “hello world”, em assembly para o x86-64:

; hello.asm
  bits  64

  section .rodata

hellostr:     db  `hello, world!\n`
hellostr_size equ $ - hellostr

  section .text

  global  _start
_start:
  mov   eax,1   ; SYS_WRITE syscall
  mov   edi,eax ; STDOUT_FILENO
  mov   rsi,hellostr  ; buffer ptr.
  mov   edx,hellostr_size
  syscall

  mov   eax,60  ; exit(0).
  xor   edi,edi
  syscall

Compilando e executando:

$ nasm -felf64 -o hello.o hello.asm
$ ld -o hello hello.o
$ ./hello
hello, world!

Observando a listagem de hello via objdump:

_start:
  400080: b8 01 00 00 00                 mov    eax,0x1
  400085: 89 c7                          mov    edi,eax
  400087: 48 be a4 00 40 00 00 00 00 00  movabs rsi,0x4000a4
  400091: ba 0e 00 00 00                 mov    edx,0xe
  400096: 0f 05                          syscall 
  400098: b8 3c 00 00 00                 mov    eax,0x3c
  40009d: 31 ff                          xor    edi,edi
  40009f: 0f 05                          syscall

Repare que a instrução mov rsi,hellostr é bem grande. O offset é um endereço absoluto (0x4000a4), resolvido pelo linker, mas o problema aqui é que esse programa não pode ter seu endereço de carga “randomizado”… No contexto do assembly, os endereços de símbolos nos segmentos são calculados pelo linker de forma a montar um endereço absoluto, a não ser que digamos para o NASM que o endereço deve ser relativo, mas, relativa a quê?

No caso, relativo ao registrador RIP. Sim! O instruction pointer! O linker criará uma imagem onde os segmentos são carregados em uma ordem específica, tornando possível para o programa conhecer o endereço de qualquer região da memória em seu espaço de endereçamento com base no RIP (é como dizer: este dado está a N posições do endereço da próxima instrução!). Isso pode ser obtido no NASM de duas formas:

  1. Adicionando um prefixo rel antes do “endereço”;
  2. Adicionando a diretiva default rel no início do seu código.

A segunda possibilidade torna todas referências a endereços (nos modos de endereçamento) relativos a RIP. A primeira, funciona apenas para a instrução corrente… Mas, há um problema: Não há como usar esse macete com mov rsi,hellostr. O símbolo hellostr será resolvido pelo linker como um endereço abosluto de qualquer forma, mas podemos substituí-lo por lea rsi,[rel hellostr]. Isso não só criará um endereço relativo a RIP, como criará uma instrução menor:

_start:
  400080: b8 01 00 00 00        mov    eax,0x1
  400085: 89 c7                 mov    edi,eax
  400087: 48 8d 35 12 00 00 00  lea    rsi,[rip+0x12]  # 0x4000a0
  40008e: ba 0e 00 00 00        mov    edx,0xe
  400093: 0f 05                 syscall 
  400095: b8 3c 00 00 00        mov    eax,0x3c
  40009a: 31 ff                 xor    edi,edi
  40009c: 0f 05                 syscall

No caso, RIP+18 será exatamente 0x4000a0. Repare que RIP é sempre o endereço da próxima instrução, neste caso 0x40008e. Agora, esse código pode ser “randomizado” à vontade.

Mas, onde diabos está a relocação?! Quando você compila um programa em C, para o x86-64, o compilador tende a criar código “independente de posição” (PIC). Assim, todas as referências a símbolos globais tendem a ser calculados como offsets em relação aos seus segmentos e uma tabela de relocação, que é um array do offset onde as referências estão, é criado… o loader do executável (o sistema operacional) coloca a imagem binária na memória, na ordem correta, e depois percorre a “tabela de offsets globais” (GOT, de Global Offsets Table) adicionando o endereço onde a imagem foi carregada na memória a essas referências.

O GCC, por exemplo, prefere criar um “executável independente de posição” (PIE, Position Intependent Executable), que usa referências em relação a RIP, diminuindo muito o tamanho da tabela GOT.

Existe uma desvantagem em usar referências relativas a RIP: O offset só pode ter 32 bits de tamanho (para frente ou para trás). Qualquer referência maior que ±2 GiB em relação ao RIP não usará endereçamento relativo, mas absoluto. Felizmente isso é extremamente raro.

Evite usar os registradores de uso geral, de 64 bits

Eles estão lá, mas para usá-los o compilador precisa acrescentar um prefixo REX na instrução, bem como estender qualquer valor imediato ou referências à memória (exceto endereçamento relativo a RIP, como vimos acima). Isso cria instruções maiores e, algumas vezes, desnecessárias. Por exemplo, para zerar RAX vocẽ poderia fazer: xor rax,rax, e eis o que o NASM criará xor eax,eax.

Em primeiro lugar, usar registradores E?? gera instruções menores (sem o prefixo REX), em segundo, sempre que alguma instrução manipular E??, os 32 bits superiores de R?? são automaticamente zerados. Esse foi o motivo de eu usar EAX, EDI e EDX no código exemplo hello.asm. Se você der uma olhada na lista de system calls para x86-64 (aqui), verá que os registradores deveriam ser RAX, RDI e RDX, de acordo com a convenção de chamada SysV ABI, mas, para quê usar instruções que lidem com R??, se as partes superiores dos valores contidos nesses registrador serão zeradas de qualquer forma?

Não faz mal usá-las, se você deixar o NASM com sua opção de otimização default (-Ox), mas se desabilitar otimizações as instruções serão maiores do que deveriam.

No mesmo exemplo hello.asm, note que usei mov rsi,hellostr (ou a versão melhor: lea rsi,[rel hellostr], para aproveitar o RIP). Em ambos os casos o compilador colocará um prefixo REX (0x48) antes da instrução porque o destino é um registrador R??. Aqui tive que fazer isso porque o endereço do buffer, em teoria, pode estar em qualquer lugar da memória… Mas, não é incomum você observar códigos gerados pelo GCC que use ESI nesse caso. Ele assume que o endereço virtual de carga será sempre de 32 bits (eu não acho que isso seja prudente!) e zera a parte superior de RSI.

Note que, no modo x86-64, o processador espera que, no modo de endereçamento, registradores de 64 bits sejam usados. Se usarmos de 32 ele colocará o prefixo 0x67 na instrução. É interessante saber disso ao vermos o código abaixo:

; função em C original:
;   int f(int x) { return x+x; }
f:
  lea eax,[rdi+rdi]
  ret

O tipo de x é int, então tem 32 bits, mas o compilador preferiu fazer RDI+RDI para evitar colocar um prefixo 0x67 na instrução… No final das contas o resultado será 32 bits (destino: EAX) e os 32 bits superiores de RAX estarão zerados!

Evite use saltos relativos a $

Em listagens para o modo real, às vezes, era comum temos instruções como:

jmp $+2

O símbolo $ é usado para designar o endereço corrente da instrução, o ‘+2’ faz com que o endereço calculado seja o da próxima instrução depois do jmp (assumindo que esse jmp com salto relativo tenha 2 bytes de tamanho). Isso ainda funciona, no modo x86-64, acontece que, hoje, instruções como jmp suportam saltos relativos (a RIP) em 16, 32 ou 64 bits. Sem saber qual dessas o compilador vai gerar, o cálculo do endereço relativo pode ficar errado e você só saberá dando uma olhada no código gerado.

Repare que usei o $ para obter o endereço do “próximo” byte depois da string referenciada pelo símbolo hellostr na definição:

hellostr_size equ $ - hellostr

Isso é perfeitamente válido, já que o que eu quis é o cálculo do tamanho da string. Não estou usando $ para o cálculo do endereço efetivo, mas apenas para colher a diferença desta e da outra posição da memória.

Ainda… No NASM, o símbolo $ equivale à posição (não ao endereço) da linha corrente. $$, por outro lado, equivale à posição relativa ao início do segmento. A diferença é sutil: Você pode pensar em $ como sendo sempre 0 e $$ como sendo o tamanho do segmento usado até a sua referência (exceto que $, usada numa expressão, como $ - hellostr resultará na “posição corrente” menos a posição de hellostr. Evite usar $ para cálculos de endereços efetivos relativos, a não ser que você saiba o que está fazendo.

Alinhe sem código

A manipulação do uso do cache L1 não é útil apenas para acesso a dados, mas também para a execução de seu código. É prudente que alguns pontos de entrada sejam alinhados para que a maior parte de seu código caiba numa única linha do cache L1i… Aqui é prudente observar que existem dois caches L1: Um para dados (L1d) e um para código ou instruções (L1i), ambos com 32 KiB de tamanho e linhas de 64 bytes.

A Intel recomenda que os pontos de entrada de funções estejam alinhados em 16 bytes (ou seja, que os 4 bits menos significativos do endereço sejam zero!) e que pontos de entrada de loops sejam, pelo menos, alinhados por DWORD (4 bytes). Bem… essa última recomendação não é bem da Intel, mas é uma dedução empírica para o melhor uso possível do cache.

Sabendo que uma instrução não pode ter mais que 15 bytes de tamanho sem que haja uma exceção, a primeira recomendação faz sendido, mesmo porquê o alinhamento a cada 16 bytes garante que a primeira instrução esteja no início de uma das vias de associatividade de uma linha de cache (isso é complicado explicar!)… A segunda recomendação garante que algum alinhamento exista (para evitar que o loop começe num endereço ímpar) e não causa muita perda no uso da memória (no máximo, 3 NOPs)… A adição de NOPs, neste caso, não causa atrasos, já que o reordenador de instruções, contido no seu processador, se livrará das instruções excedentes assim que detectar um loop (e seu processador faz isso sempre).

Assim, considere a função abaixo:

  bits 64
  default rel

  section .text
; int f( int *p, size_t sz )
; { 
;   int sum = 0;
;
;   while ( sz-- )
;     sum += *p++;
;
;   return sum;
; }
  global f
f:
  xor  eax,eax
.loop:
  test rsi,rsi  ; size_t tem 64 bits!
  jz   .exit
  add  eax,[rdi]
  add  rdi,1
  sub  rsi,1
  jmp  .loop
.exit:
  ret

Se compilarmos esta rotina com o NASM e obtermos a listagem veremos que o label f parece estar alinhado, mas isso é falso, porque o endereço fornecido é “virtual” (VMA), para ser resolvido pelo linker, que poderá colocar a função num endereço ímpar, se quiser. Isso fará com que os demais labels possam ser colocados em endereços ímpares e/ou não alinhados. Vejamos como fica a rotina acima, adicionadas diretivas de alinhamento em pontos de saltos:

  bits  64
  default rel

  section .text

  global  f
  align 16  ; alinha por 16 bytes...
f:
  xor  eax,eax
  align 4
.loop:
  test rsi,rsi  ; size_t tem 64 bits!
  jz   .exit
  add  eax,[rdi]
  add  rdi,1
  sub  rsi,1
  jmp  .loop
  align 4
.exit:
  ret

O compilador vai criar código assim:

                    f:
00000000 31C0         xor  eax,eax
00000002 90           nop           ; ignorados pelo reordenador
00000003 90           nop           ;
                    .loop:
00000004 4885F6       test rsi,rsi
00000007 740F         jz   .exit
00000009 0307         add  eax,[rdi]
0000000B 4883C701     add  rdi,1
0000000F 4883EE01     sub  rsi,1
00000013 EBEF         jmp  .loop
00000015 90           nop            ; jamais executados!
00000016 90           nop            ;
00000017 90           nop            ;
                    .exit:
00000018 C3           ret

Ele colocou 2 NOPs antes de .loop e 3, antes de .exit. Ainda não há qualquer dica nessa listagem sobre o alinhamento do símbolo f, mas se observarmos um dump do arquivo objeto:

$ objdump -x test.o
test.o:     file format elf64-x86-64
test.o
architecture: i386:x86-64, flags 0x00000010:
HAS_SYMS
start address 0x0000000000000000

Sections:
Idx Name   Size      VMA               LMA               File off  Algn
  0 .text  00000019  0000000000000000  0000000000000000  00000180  2**4
           CONTENTS, ALLOC, LOAD, READONLY, CODE
SYMBOL TABLE:
0000000000000000 l    df *ABS*  0000000000000000 test.asm
0000000000000000 l    d  .text  0000000000000000 .text
0000000000000004 l       .text  0000000000000000 f.loop
0000000000000018 l       .text  0000000000000000 f.exit
0000000000000000 g       .text  0000000000000000 f

Veremos que o segmento .text neste código tem alinhamento 2⁴, ou 16.

Sua função ficou um pouquinho maior, mas, provavelmente, mais rápida porque aproveita melhor as linhas de cache… Bem… Neste caso, provavelmente, não faz muita diferença, já que todo o código tem 25 bytes de tamanho e uma linha tem 64. Assim, vale apenas apenas alinhar o ponto de entrada (f). Mas, loops maiores podem se beneficiar da técnica.