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:
- Adicionando um prefixo rel antes do “endereço”;
- 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.