Não precisa “decorar”, mas é bom saber…

Alguns leitores já me pediram para explicar como uma instrução em assembly é codificada em “linguagem de máquina” e minha resposta é sempre “deixe isso por conta do compilador!”. Você realmente deveria fazer isso, mas conhecer a codificação, mesmo que superficialmente, pode ajudar a planejar ou, pelo menos, analisar o código gerado pelo compilador… Algumas instruções podem ser “maiores” (em bytes) do que outras e colocarem mais “pressão” no cache L1I do que deveriam. Arranjar instruções para melhor aproveitar o cache é algo desejável em qualquer programa.

Uma instrução é formada por um código de operação e uma série de parâmetros. Instruções que não tomam parâmetros explícitos costumam ter apenas 1 byte como código de operação. É o caso da instrução RET e RETF (retorno “far”), por exemplo, cujos códigos são 0xC3 e 0xCB, respectivamente. Mas outras instruções tomam registradores, ponteiros e valores imediatos. Como elas são codificadas?

O diagrama abaixo, retirado do Intel® 64 and IA-32 Architectures Software Developer’s Manual Volume 2 – Instruction Set Reference, mostra como:

instruction encoding

Antes do opcode podemos ter prefixos, falarei deles daqui a pouco. O opcode pode ter de 1 até 3 bytes, dependendo da instrução. Daí para frente os bytes adicionais dependem do opcode:

  • ModR/M e SiB têm 1 byte cada. Podemos ter um byte com ModR/M apenas, mas podemos ter um ModR/M e um SiB;
  • Displacement é o offset num esquema de endereçamento do tipo [base+índice*escala+offset]. Ele está presente, dependendo dos campos em ModR/M e SiB;
  • Immediate é um valor imediato, dependendo da instrução.

O termo ModR/M vem do fato de que esse byte especifica o “modo” de endereçamento, o registrador isolado (se o opcode exigir) e outro registrador ou ponteiro (Register/Memory)No caso de selecionada “Memory”, o byte SiB estará presente também. O byte SiB especifica a escala (Scale), o índice (index) e o registrador base (Base).

Vejamos um exemplo:

00000000: 89 D8          mov eax,ebx

Dando uma olhada no manual da Intel vemos que 0x89 corresponde a instrução “MOV r/m32,r32“. Essa instrução precisa de um registrador fixo (r32) e de um outro operando que pode ser um registrador ou um ponteiro. Isso é decidido no próximo byte, o ModR/M. O valor 0xD8, em binário, é 0b11011000 ou, separando os bits nos campos citados anteriormente temos: 0b11_011_000. De novo, consultando o manual vemos que se Mod for 0b11, Reg for 0b011 e R/M for 0, r32 será EBX e r/m32 será EAX.

É interessante notar que esse não é o único jeito de especificar a mesma instrução. O código 0x8B 0xC3 resulta na mesma instrução! Neste caso a instrução é “MOV r32,r/m32” e o campo ModR/M especifica os mesmos EAX e EBX, mas no sentido contrário!

Prefixos:

Vale lembrar que os processadores da família x86 foram criados para trabalharem com 16 bits. Com o surgimento do 386 o conjunto de instruções teve que ser expandido para suportar 32 bits e, mais tarde, com a AMD criando o modo x86-64, 64 bits (dã!).

Tanto no modo i386 quanto no modo x86-64 o processador espera encontrar operandos de 32 bits em suas instruções. No modo i386 ele também espera encontrar endereços de 32 bits, mas espera encontrar de 64 no modo x86-64. Dessa forma a Intel criou 2 prefixos especiais: 0x66 e 0x67. O prefixo 0x66 muda o sentido esperado de um operando (operand override), já o prefixo 0x67 muda o sentido esperado de um endereço ou ponteiro (address override). Assim, no modo i386, se usarmos AX ao invés de EAX como operando, o compilador colocará 0x66 indicando que estamos usando 16 bits ao invés de 32:

00000000: 66 89 D8       mov ax,bx

Repare que a instrução é exatamente a mesma que “MOV EAX,EBX“, exceto pelo prefixo 0x66. É necessário ter atensão a esse prefixo porque ele quer dizer “mudança do sentido esperado do operando”. No modo real (16 bits) o processador espera encontrar operandos de 16 bits, ao usar os registradores de 32 o prefixo 0x66 terá que ser usado. A mesma codificação acima significa “MOV EAX,EBX” no MS-DOS, por exemplo.

O prefixo 0x67 aplica-se aos ponteiros do tipo “[base+índice*escala+offset]”. No modo i386 espera-se que os registradores “base” e “índice” sejam de 32 bits, se a instrução for precedida de 0x67 eles serão de 16. No modo x86-64 espera-se que eles sejam de 64 bits e serão de 32 se o prefixo for usado. Eis um exemplo do modo x86-64:

00000000: 67 8B 04 33   mov eax,[ebx+esi]
00000004: 8B 04 33      mov eax,[rbx+rsi]

Lembre-se: Mesmo no modo x86-64 o processador espera encontrar operandos de 32 bits, não de 64. Mas isso não vale para ponteiros! No caso acima o prefixo 0x67 diz ao processador que interprete os bytes ModR/M e SiB como usando registradores de 32 bits. Se estivéssemos no modo i386 os mesmos bytes criariam instruções diferentes:

00000000: 67 8B 04      mov eax,[si]
00000003: 33            ???
00000004: 8B 04 33      mov eax,[ebx+esi]

Ué?! O que houve com a primeira instrução?! No modo 16 bits não podemos usar qualquer registrador como “base”. Podemos usar apenas BX, BP ou zero. O registrador de índice pode ser, em 16 bits, SI ou DI, apenas. Assim, prefixar a instrução “MOV EAX,[EBX+ESI]” com 0x67 não a faz, automaticamente, ser “MOV EAX,[BX+SI]“… Mesmo estando no modo i386 a mudança de semântica do endereço segue a do modo real.

Ao adicional 0x66, no entanto, mudará o contexto dos operandos que não são ponteiros se estivermos no modo i386. Por exemplo:

00000000: 8B 04 33      mov eax,[ebx+esi]
00000003: 66 8B 04 33   mov ax,[ebx+esi]

E no modo x86-64 a semântica é muito parecida, exceto que os ponteiros serão de 64 bits. Adicionando 0x67 ao prefixo 0x66 mudamos o endeerço também:

00000000: 8B 04 33        mov eax,[rbx+rsi]
00000003: 66 8B 04 33     mov ax,[rbx+rsi]
00000007: 66 67 8B 04 33  mov ax,[ebx+esi]

A ordem de 0x66 e 0x67 não importa.

O problemas dos prefixos:

Não importa a quantidade de vezes que esses prefixos sejam informados antes da instrução, exceto pelo fato de que uma única instrução (prefixada, inclusive) pode ter apenas até 15 bytes de tamanho. A instrução abaixo causará uma exceção porque tem 16 bytes de tamanho, mesmo que ela não faça nada:

00000000: 66 66 66 66 66 66 66 66
00000008: 66 66 66 66 66 66 66
0000000F: 90                       nop

Dito isso, é bom lembrar que além dos prefixos 0x66 e 0x67 existem outros, mais específicos. No modo x86-64 é possível termos instruções perfeitamente válidas, e sem o uso de gambiarras como essa ai acima, que venham a ter mais que 15 bytes graças ao uso (correto) de prefixos… Infelizmente não há exceções: Tem mais que 15 bytes, o processador não deixa executar!

Outros prefixos especiais:

No modo x86-64 temos um prefixo chamado de REX. Esse prefixo estende os campos dos bytes ModR/M e SiB, se usados pela instrução, colocando um bit extra. O prefixo possui forma binária de 0x0100WRXB, onde o bit W especifica o tamanho de um operando que não seja um ponteiro (1=64 bits, 0=32). O bit R estende em um bit o campo Reg de ModR/M possibilitando o uso dos outros 8 registradores estendidos (R8 até R15 e XMM8 até XMM15). O bit X faz o mesmo, mas para o campo index do SiB, assim como o bit B faz isso para o campo base.

Assim, uma instrução contendo um prefixo de 0x40 até 0x4F especifica registradores estendidos ou endereços que os usam.

Outro detalhe é que, se um prefixo REX existir ele deve preceder imediatamente o opcode. Antes de REX podemos ter 0x66 ou 0x67, mas não depois.

Além do prefixo REX temos outros mais tradicionais como REPNZ (e REP) e REPZ. Esses são usados com instruções de bloco como LODS, STOS, MOVS e SCAS, mas também podem ser usados com outras instruções e, neste caso, são inócuos… Servem apenas ao propósito de alinhamento. É comum encontrarmos códigos que contenham instruções como “REP RET“, onde esse REP está ai só para fazer RET usar 2 bytes.

Um outro prefixo especial, sem nenhum bitmask, é 0x0F. Ele especifica algumas instruções estendidas, que não estavam presentes nos processadores anteriores ao 486…. E, à medida que novos conjuntos de instruções foram sendo adicionados aos processadores, novos prefixos foram sendo usados, com seus próprios significados: É o caso de prefixos VEX e XOP, por exemplo. Não vou explicá-los agora, basta dizer que eles são usados em conjuntos de instruções de AVX e outras extensões do processador. O princípio é parecido com o prefixo REX

Exceções à regra:

Você pode achar estranho, mas usar registradores como AL e DL, por exemplo, não causam a adição de prefixos como 0x66, nem no modo i386, nem no x86-64. No entanto, ao usar registradores como R8B o compilador não terá alternativa senão inserir um prefixo REX. O mesmo para DIL, SIL, BPL e SPL.

Anúncios