Only this pageAll pages
Powered by GitBook
1 of 64

Aprendendo Assembly

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

ApĂȘndices

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Loading...

Metadados

Loading...

Loading...

Introdução

Livro gratuito sobre Assembly x86 e x86-64

Por que aprender Assembly?

Se vocĂȘ quer aprender Assembly achando que vai conseguir fazer programas mais rĂĄpidos diretamente em Assembly, saiba que estĂĄ (quase) enganado. Os algoritmos de otimização dos compiladores de hoje em dia tem uma eficiĂȘncia impressionante a ponto de superar as habilidades humanas. Ou seja Ă© muito mais eficiente escrever um cĂłdigo em C, por exemplo, do que diretamente em Assembly. Claro que ainda Ă© possĂ­vel fazer cĂłdigo mais eficiente diretamente em Assembly porĂ©m apenas em casos especĂ­ficos e se vocĂȘ tiver bastante experiĂȘncia. Ou seja Ă© mais interessante usar Assembly em conjunto com C do que fazer todo o software em Assembly.

Apesar disso aprender Assembly nos dias atuais tem sim utilidade e irei listar algumas:

  1. Exploração de binårios Para quem quer fazer testes de segurança em binårios, Assembly é indispensåvel.

  2. Otimização de cĂłdigo Sim, como eu jĂĄ disse Ă© mais eficiente nĂŁo escrever o cĂłdigo diretamente em Assembly. PorĂ©m saber Assembly e usar esse conhecimento para estudar o cĂłdigo de saĂ­da de um compilador de uma determinada linguagem (GCC por exemplo) vai te fazer aprender muita coisa sobre o cĂłdigo resultante. Esse conhecimento vai te ajudar e muito a fazer cĂłdigos mais eficientes nesta linguagem. Isto Ă© claro, Ă© especialmente Ăștil em linguagens que te permitem prever o cĂłdigo de saĂ­da (volto a repetir C como exemplo).

Como podemos ver, o conhecimento da linguagem Assembly nos dias atuais Ă©, na maioria dos casos, mais Ăștil do que programar nela diretamente.

O que Ă© Assembly?

De forma resumida Assembly é uma notação em formato de texto das instruçÔes do código de måquina de uma determinada arquitetura. A "arquitetura" ao qual me refiro aqui é a ISA (Instruction Set Architecture) onde ela cria um modelo abstrato de um computador e tem diversas instruçÔes que são computadas pelo processador, essas instruçÔes são o que é conhecido como código de måquina.

Falando de um ponto de vista humano, entender instruçÔes em código de måquina é uma tarefa årdua. Por isso os manuais da ISA costumam simplificar o entendimento da instrução se referindo a ela com uma notação em texto, onde essa notação é conhecida como mnemÎnico e tem o fim de facilitar o entendimento e a memorização da instrução do processador.

E é justamente dessa notação em texto dos manuais que surgiu o que a gente conhece hoje como "linguagem Assembly". Onde na verdade não existe uma linguagem Assembly (ou ASM para abreviar) mas sim cada ISA tem uma linguagem ASM diferente.

Os programadores antigamente escreviam o código usando a notação em texto (Assembly) e depois, a partir dela, convertiam para código de måquina manualmente. Mas felizmente hoje em dia existem softwares que fazem essa conversão de maneira automåtica e eles são chamados de assemblers.

Apesar de eu estar utilizando letra maiĂșscula para escrever a palavra "Assembly" ela na verdade nĂŁo Ă© um substantivo prĂłprio mas sim a palavra "montagem" em inglĂȘs. Estou fazendo isso meramente por uma estilização pessoal.

O que Ă© assembler?

O assembler é um compilador que converte código em Assembly para o código de måquina. Muito antigamente, nos primórdios da computação, uma pessoa manualmente convertia os códigos em Assembly para código de måquina. Depois inventaram uma måquina para automatizar essa tarefa sendo chamada de "assembler". Nos dias atuais o "assembler" é um software de computador.

É muito comum as pessoas se confundirem e dizerem "linguagem assembler", o que estĂĄ errado. Cuidado para nĂŁo se confundir tambĂ©m.

O autor

Meu nome Ă© Luiz Felipe. Pronto, agora vocĂȘ sabe tudo sobre mim.

Licença

Para mais detalhes sobre a licença consulte o link abaixo:

Este livro é mais um projeto do cujo o intuito é ensinar os fundamentos do Assembly x86 e x86-64. Nele serå abordado desde o zero até conceitos mais avançados a fim de dar um entendimento profundo e uma base sólida de conhecimento.

O Mente Binåria também tem um livro sobre e diversos .

Se vocĂȘ estĂĄ lendo isto no , recomendo que leia na plataforma do GitBook .

Engenharia Reversa (de software) Entender como um software funciona na prĂĄtica sĂł Ă© de fato possĂ­vel se vocĂȘ souber Assembly. Usar atĂ© funciona em parte mas nĂŁo pense que isso tem grandes utilidades o tempo todo.

Otimização de cĂłdigoÂČ TambĂ©m dĂĄ para fazer otimizaçÔes de cĂłdigo para processadores especĂ­ficos manualmente. Podemos ver vĂĄrios exemplos desta façanha no famigerado ffmpeg. .

Tarefas de "baixo nível" Algumas instruçÔes específicas de determinados processadores são basicamente impossíveis de serem utilizadas em uma linguagem de alto nível. Uma tarefa båsica em Assembly, como chamar uma , é impossível de ser feita em uma linguagem de alto nível como C.

C & Assembly Termino falando que hĂĄ uma incrĂ­vel vantagem de se saber Assembly quando se programa em C. NĂŁo sĂł pelo mas tambĂ©m pela previsibilidade do cĂłdigo que eu jĂĄ mencionei (vocĂȘ vai entender o que Ă© isso se continuar lendo o livro).

| | | |

Este conteĂșdo estĂĄ sendo compartilhado sobre os termos da licença . Essa licença permite que vocĂȘ compartilhe o conteĂșdo, mesmo que para fins lucrativos, desde que seja compartilhado sob os termos da mesma licença e sem adicionar novas restriçÔes. Como tambĂ©m Ă© necessĂĄrio que dĂȘ os crĂ©ditos pelo trabalho original.

Mente BinĂĄria
Fundamentos de Engenharia Reversa
treinamentos gratuitos
repositĂłrio do GitHub
clicando aqui
decompiler
Veja aqui
interrupção de software
inline Assembly
GitHub
Facebook
Twitter
Medium
Perfil no Mente BinĂĄria
CC BY-SA 3.0
Atribuição-CompartilhaIgual 3.0 Não Adaptada (CC BY-SA 3.0)

A base

CapĂ­tulo explicando os principais tĂłpicos Ă  respeito do Assembly e da arquitetura.

Por que o GCC?

Caso vocĂȘ jĂĄ programe em C e utilize outro compilador, mesmo assim recomendo que instale o GCC. O motivo disso Ă© que irei ensinar o Inline Assembly deste compilador entre outras particularidades do mesmo. TambĂ©m iremos analisar o cĂłdigo de saĂ­da do compilador, por isso Ă© interessante que o cĂłdigo que vocĂȘ obter aĂ­ seja pelo menos parecido. AlĂ©m disso tambĂ©m usaremos outras ferramentas do pacote GCC, como o gdb e o ld por exemplo.

Por que o NASM?

  • O prĂ©-processador do NASM Ă© absurdamente incrĂ­vel.

  • O NASM tem uma sintaxe mais "legĂ­vel" comparada a sintaxe do GAS.

  • O NASM tem o ndisasm, vai ser Ăștil na hora de estudar o cĂłdigo de mĂĄquina.

  • Eu gosto do NASM.

Mais para frente no livro pretendo ensinar a usar o GAS também. Mas na base vamos usar só o NASM mesmo.

Preparando o ambiente

main.c
#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

A ideia aqui é simplesmente chamar a função assembly() que iremos usar para testar algumas instruçÔes escritas diretamente em Assembly. Ainda não aprendemos nada de Assembly então apenas copie e cole o código abaixo. Este seria nosso arquivo assembly.asm:

assembly.asm
bits 64

section .text

global assembly
assembly:
  mov eax, 777
  ret

No GCC vocĂȘ pode especificar se quer compilar cĂłdigo de 32-bit ou 64-bit usando a opção -m no Terminal. Por padrĂŁo o GCC jĂĄ compila para 64-bit em sistemas de 64-bit. A opção -c no GCC serve para especificar que o compilador apenas faça o processo de compilação do cĂłdigo, sem fazer a ligação do mesmo. Deste jeito o GCC irĂĄ produzir um arquivo objeto como saĂ­da.

No nasm Ă© necessĂĄrio usar a opção -f para especificar o formato do arquivo de saĂ­da, no meu Linux eu usei -f elf64 para especificar o formato de arquivo ELF. Caso use Windows entĂŁo vocĂȘ deve especificar -f win64.

$ nasm assembly.asm -f elf64
$ gcc -c main.c -o main.o
$ gcc assembly.o main.o -o test -no-pie
$ ./test

No Windows fica assim:

$ nasm assembly.asm -f win64
$ gcc -c main.c -o main.o
$ gcc assembly.obj main.o -o test -no-pie
$ .\test

Nota: Repare que no Windows o nome padrão do arquivo de saída do nasm usa a extensão .obj ao invés de .o.

Mantenha essa PoC guardada no seu computador para eventuais testes. VocĂȘ nĂŁo serĂĄ capaz de entender como ela funciona agora mas ela serĂĄ Ăștil para testar conceitos para poder vĂȘ-los na prĂĄtica. Eventualmente tudo serĂĄ explicado.

Makefile

Caso vocĂȘ tenha o make instalado a minha recomendação Ă© que organize os arquivos em uma pasta especĂ­fica e use o Makefile abaixo.

Makefile
all:
	nasm *.asm -felf64

	gcc -c *.c
	gcc -no-pie *.o -o test

Isso Ă© meio que gambiarra mas o importante agora Ă© ter um ambiente funcionando.

Se tudo deu errado...

Para que fique mais prĂĄtico para todos, independentemente se estiverem usando Linux/Windows/MacOS/BSD/etc, usaremos a linguagem C como "ambiente" para escrever cĂłdigo e podermos ver o resultado. Certifique-se de ter o / e o instalados no seu sistema.

O pacote GCC jĂĄ tem o que Ă© excelente mas prefiro usar aqui o NASM devido a vĂĄrios fatores, dentre eles:

Primeiramente eu recomendaria o uso de alguma distribuição Linux de 64-bit ou qualquer sistema operacional Unix-Like (*BSD, MacOS etc). Isso porque mais para frente irei ensinar conteĂșdo que Ă© exclusivo para sistemas operacionais compatĂ­veis com o . PorĂ©m caso use o Windows nĂŁo tem problema desde que instale o mingw-w64 como mencionei. O mais importante Ă© ter um GCC que pode gerar cĂłdigo para 64-bit e 32-bit.

Vamos antes de mais nada preparar uma em C para chamar uma função escrita em Assembly. Este seria nosso arquivo main.c:

Por fim, para fazer a ligação dos dois arquivos objeto de saída podemos usar mais uma vez o GCC. Usar o ld diretamente exige incluir alguns arquivos objeto da libc, o que varia de sistema para sistema, portanto prefiro optar pelo GCC que irå por baixo dos panos rodar o ld incluindo os arquivos objetos apropriados. Para compilar e os dois arquivos então fica da seguinte forma no Linux:

Usamos a opção -o no GCC para especificar o nome do arquivo de saída. E -no-pie para garantir que um do GCC não seja habilitado. O comando final acima seria somente a execução do nosso executåvel test em um sistema Linux. A execução do programa produziria o seguinte resultado no print abaixo, caso tudo tenha ocorrido bem.

Se vocĂȘ nĂŁo conseguiu preparar nossa PoC aĂ­ no seu computador, acesse para tirar sua dĂșvida.

GCC
Mingw-w64
NASM
assembler GAS
UNIX
PoC
linkar
determinado recurso
o fĂłrum do Mente BinĂĄria

Sintaxe

Entendendo a sintaxe da linguagem Assembly no nasm

O Assembly da arquitetura x86 tem duas versĂ”es diferentes de sintaxe: A sintaxe Intel e a sintaxe AT&T. A sintaxe Intel Ă© a que iremos usar neste livro jĂĄ que, ao meu ver, ela Ă© mais intuitiva e legĂ­vel. TambĂ©m Ă© a sintaxe que o nasm usa, jĂĄ o GAS suporta as duas porĂ©m usando sintaxe AT&T por padrĂŁo. É importante saber ler cĂłdigo das duas sintaxes, mas por enquanto vamos aprender apenas a sintaxe do nasm.

Case Insensitive

As instruçÔes da linguagem Assembly, bem como também as instruçÔes particulares do nasm, são case-insensitive. O que significa que não faz diferença se eu escrevo em caixa-alta, baixa ou mesclando os dois. Veja que cada linha abaixo o nasm irå compilar como a mesma instrução:

mov eax, 777
Mov Eax, 777
MOV EAX, 777
mov EAX, 777
MoV EaX, 777

ComentĂĄrios

No nasm se pode usar o ponto-vĂ­rgula ; para comentĂĄrios que Ășnica linha, equivalente ao // em C. ComentĂĄrios de mĂșltiplas linhas podem ser feitos usando a diretiva prĂ©-processada %comment para iniciar o comentĂĄrio e %endcomment para finalizĂĄ-lo. Exemplo:

; Um exemplo
mov eax, 777 ; Outro exemplo

%comment
  Mais
  um
  exemplo
%endcomment

NĂșmeros

NĂșmeros literais podem ser escritos em base decimal, hexadecimal, octal e binĂĄrio. TambĂ©m Ă© possĂ­vel escrever constantes numĂ©ricas de ponto flutuante no nasm, conforme exemplos:

Exemplo

Formato

0b0111

BinĂĄrio

0o10

Octal

9

Decimal

0x0a

Hexadecimal

11.0

Ponto flutuante

Strings

Strings podem ser escritas no nasm de trĂȘs formas diferentes:

Representação

Explicação

"String"

String normal

'String'

String normal, equivalente a usar "

`String\n`

String que aceita caracteres de escape no estilo da linguagem C.

Os dois primeiros sĂŁo equivalentes e nĂŁo tem nenhuma diferença para o nasm. O Ășltimo aceita caracteres de escape no mesmo estilo da linguagem C.

Formato das instruçÔes

As instruçÔes em Assembly seguem a premissa de especificar uma operação e seus operandos. Na arquitetura x86 uma instrução pode nĂŁo ter operando algum e chegar atĂ© trĂȘs operandos.

operação operando1, operando2, operando3

Algumas instruçÔes alteram o valor de um ou mais operandos, que pode ser um endereçamento na memória ou um registrador. Nas instruçÔes que alteram o valor de apenas um operando ele sempre serå o operando mais à esquerda. Um exemplo pråtico é a instrução mov:

mov eax, 777

O mov especifica a operação enquanto o eax e o 777 são os operandos. Essa instrução altera o valor do operando destino eax para 777. Exemplo de pseudo-código:

eax = 777;

Da mesma forma que não é possível fazer 777 = eaxem linguagens de alto nível, também não då para passar um valor numérico como operando destino para mov. Ou seja, isto estå errado: mov 777, eax

Endereçamento

O endereçamento em Assembly x86 Ă© basicamente um cĂĄlculo para acessar determinado valor na memĂłria. O resultado deste cĂĄlculo Ă© o endereço na memĂłria que o processador irĂĄ acessar, seja para ler ou escrever dados no mesmo. UsĂĄ-se os colchetes [] para denotar um endereçamento. Ao usar colchetes como operando vocĂȘ estĂĄ basicamente acessando um valor na memĂłria. Por exemplo poderĂ­amos alterar o valor no endereço 0x100 usando a instrução mov para o valor contido no registrador eax.

mov [0x100], eax

Como eu jå mencionei o valor contido dentro dos colchetes é um cålculo. Vamos aprender mais à respeito quando eu for falar de endereçamento na memória.

VocĂȘ sĂł pode usar um operando na memĂłria por instrução. EntĂŁo nĂŁo Ă© possĂ­vel fazer algo como: mov [0x100], [0x200]

Tamanho do operando

Quando um dos operandos Ă© um endereçamento na memĂłria vocĂȘ precisa especificar o seu tamanho. Ao fazer isso vocĂȘ define o nĂșmero de bytes que serĂŁo lidos ou escritos na memĂłria. A maioria das instruçÔes exigem que o operando destino tenha o mesmo tamanho do operando que irĂĄ definir o seu valor, salvo algumas exceçÔes. No nasm existem palavra-chaves (keywords) que vocĂȘ pode posicionar logo antes do operando para determinar o seu tamanho.

Nome

Nome estendido

Tamanho do operando (em bytes)

byte

1

word

2

dword

double word

4

qword

quad word

8

tword

ten word

10

oword

16

yword

32

zword

64

Exemplo:

mov dword [0x100], 777

Se vocĂȘ usar um dos operandos como um registrador o nasm irĂĄ automaticamente assumir o tamanho do operando como o mesmo tamanho do registrador. Esse Ă© o Ășnico caso onde vocĂȘ nĂŁo Ă© obrigado a especificar o tamanho porĂ©m em algumas instruçÔes o nasm nĂŁo consegue inferir o tamanho do operando.

Pseudo-instruçÔes

No nasm existem o que sĂŁo chamadas de "pseudo-instruçÔes", sĂŁo instruçÔes que nĂŁo sĂŁo de fato instruçÔes da arquitetura x86 mas sim instruçÔes que serĂŁo interpretadas pelo nasm. Elas sĂŁo Ășteis para deixar o cĂłdigo em Assembly mais versĂĄtil mas deixando claro que elas nĂŁo sĂŁo instruçÔes que serĂŁo executadas pelo processador. Exemplo bĂĄsico Ă© a pseudo-instrução db que serve para despejar bytes no correspondente local do arquivo binĂĄrio de saĂ­da. Observe:

db 0x41, 0x42, 0x43, 0x44, "String", 0

DĂĄ para especificar o byte como um nĂșmero ou entĂŁo uma sequĂȘncia de bytes em formato de string. Essa pseudo-instrução nĂŁo tem limite de valores separados por vĂ­rgula. Veja a saĂ­da do exemplo acima no hexdump, um visualizador hexadecimal:

RĂłtulos

Os rĂłtulos, ou em inglĂȘs labels, sĂŁo definiçÔes de sĂ­mbolos usados para identificar determinados endereços da memĂłria no cĂłdigo fonte em Assembly. Podem ser usados de maneira bastante parecida com os rĂłtulos em C. O nome do rĂłtulo serve para pegar o endereço da memĂłria do byte seguinte a posição do rĂłtulo, que pode ser uma instrução ou um byte qualquer produzido por uma pseudo-instrução. Para escrever um rĂłtulo basta digitar seu nome seguido de dois-pontos :

meu_rotulo: instrução/pseudo-instrução

VocĂȘ pode inserir instruçÔes/pseudo-instruçÔes imediatamente apĂłs o rĂłtulo ou entĂŁo em qualquer linha seguinte, nĂŁo faz diferença no resultado final. TambĂ©m Ă© possĂ­vel adicionar um rĂłtulo no final do arquivo, o fazendo apontar para o byte seguinte ao conteĂșdo do arquivo na memĂłria. JĂĄ vimos um exemplo prĂĄtico de uso de rĂłtulo na nossa PoC:

bits 64

global assembly
assembly:
  mov eax, 777
  ret

Repare o rótulo assembly na linha 4. Nesse caso o rótulo estå sendo usado para denotar o símbolo que aponta para a primeira instrução da nossa função homÎnima.

RĂłtulos locais

Um rĂłtulo local, em inglĂȘs local label, Ă© basicamente um rĂłtulo que hierarquicamente estĂĄ abaixo de outro rĂłtulo. Para definir um rĂłtulo local podemos simplesmente adicionar um ponto . como primeiro caractere do nosso rĂłtulo. Veja o exemplo:

meu_rotulo:
  mov eax, 777
.subrotulo:
  mov ebx, 555

Dessa forma o nome completo de .subrotulo é na verdade meu_rotulo.subrotulo. As instruçÔes que estejam hierarquicamente dentro do rótulo "pai" podem acessar o rótulo local usando de sua nomenclatura com . no início do nome ao invés de citar o nome completo. Como no exemplo:

meu_rotulo:
  jmp .subrotulo
  mov eax, 777

.subrotulo:
  ret

NĂŁo se preocupe se nĂŁo entendeu direito, isso aqui Ă© apenas para ver a sintaxe. Vamos aprender mais sobre os rĂłtulos e sĂ­mbolos depois.

Diretivas

Parecido com as pseudo-instruçÔes, o nasm também oferece as chamadas diretivas. A diferença é que as pseudo-instruçÔes apresentam uma saída em bytes exatamente onde elas são utilizadas, jå as diretivas são como comandos para modificar o comportamento do assembler.

Por exemplo a diretiva bits que serve para especificar se as instruçÔes seguintes são de 64, 32 ou 16 bits. Podemos observar o uso desta diretiva na nossa PoC. Por padrão o nasm monta as instruçÔes como se fossem de 16 bits.

Pilha

Entendendo como a pilha (hardware stack) funciona na arquitetura x86

Uma pilha, em inglĂȘs stack, Ă© uma estrutura de dados LIFO -- Last In First Out -- onde o Ășltimo dado a entrar Ă© o primeiro a sair. Imagine uma pilha de livros onde vocĂȘ vai colocando um livro sobre o outro e, apĂłs empilhar tudo, vocĂȘ resolve retirar um de cada vez. Ao retirar os livros vocĂȘ vai retirando desde o topo atĂ© a base, ou seja, os livros saem na ordem inversa em que foram colocados. O que significa que o Ășltimo livro que vocĂȘ colocou na pilha vai ser o primeiro a ser retirado, isso Ă© LIFO.

Hardware Stack

Processadores da arquitetura x86 tem uma implementação nativa de uma pilha, que é representada na memória RAM, onde essa pode ser manipulada por instruçÔes específicas da arquitetura ou diretamente como qualquer outra região da memória. Essa pilha normalmente é chamada de hardware stack.

O registrador SP/ESP/RSP, Stack Pointer, serve como ponteiro para o topo da pilha podendo ser usado como referĂȘncia inicial para manipulação de valores na mesma. Onde o "topo" nada mais Ă© que o Ășltimo valor empilhado. Ou seja, o Stack Pointer estĂĄ sempre apontando para o Ășltimo valor na pilha.

A manipulação båsica da pilha é empilhar (push) e desempilhar (pop) valores na mesma. Veja o exemplo na nossa PoC:

bits 64

global assembly
assembly:
  mov  rax, 12345
  push rax

  mov rax, 112233
  pop rax
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

Na linha 6 empilhamos o valor de RAX na pilha, alteramos o valor na linha 8 mas logo em seguida desempilhamos o valor e jogamos de volta em RAX. O resultado disso é o valor 12345 sendo retornado pela função.

A instrução pop recebe como operando um registrador ou endereçamento de memória onde ele deve armazenar o valor desempilhado.

A instrução push recebe como operando o valor a ser empilhado. O tamanho de cada valor na pilha também acompanha o barramento interno (64 bits em 64-bit, 32 bits em protected mode e 16 bits em real mode). Pode-se passar como operando um valor na memória, registrador ou valor imediato.

A pilha "cresce" para baixo. O que significa que toda vez que um valor é inserido nela o valor de ESP é subtraído pelo tamanho em bytes do valor. E na mesma lógica um pop incrementa o valor de ESP. Logo as instruçÔes seriam equivalentes aos dois pseudocódigos abaixo (considerando um código de 32-bit):

ESP = ESP - 4
[ESP] = operando
operando = [ESP]
ESP = ESP + 4

Procedimentos

Entendendo funçÔes em Assembly

O conceito de um procedimento nada mais Ă© que um pedaço de cĂłdigo que em determinado momento Ă© convocado para ser executado e, logo em seguida, o processador volta a executar as instruçÔes em sequĂȘncia. Isso nada mais Ă© que uma combinação de dois desvios de fluxo de cĂłdigo, um para a execução do procedimento e outro no fim dele para voltar o fluxo de cĂłdigo para a instrução seguinte a convocação do procedimento. Veja o exemplo em pseudocĂłdigo:

1. Define A para 3
2. Chama o procedimento setarA
3. Compara A e 5
4. Finaliza o cĂłdigo

setarA:
7. Define A para 5
8. Retorna

Seguindo o fluxo de execução do cĂłdigo, a sequĂȘncia de instruçÔes ficaria assim:

1. Define A para 3
2. Chama o procedimento setarA
7. Define A para 5
8. Retorna
3. Compara A e 5
4. Finaliza o cĂłdigo

Desse jeito se nota que a comparação do passo 3 vai dar positiva porque o valor de A foi setado para 5 dentro do procedimento setarA.

Em Assembly x86 temos duas instruçÔes principais para o uso de procedimentos:

Instrução

Operando

Ação

CALL

endereço

Chama um procedimento no endereço especificado

RET

???

Retorna de um procedimento

A esta altura vocĂȘ jĂĄ deve ter reparado que nossa função assembly na nossa PoC nada mais Ă© que um procedimento chamado por uma instrução CALL, por isso no final dela temos uma instrução RET.

Na pråtica o que uma instrução CALL faz é empilhar o endereço da instrução seguinte na stack e, logo em seguida, faz o desvio de fluxo para o endereço especificado assim como um JMP. E a instrução RET basicamente desempilha esse endereço e faz o desvio de fluxo para o mesmo. Um exemplo na nossa PoC:

bits 64

global assembly
assembly:
  mov eax, 3
  call setarA

  ret

setarA:
  mov eax, 5
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

Na linha 6 damos um call no procedimento setarA na linha 10, este por sua vez altera o valor de EAX antes de retornar. Após o retorno do procedimento a instrução RET na linha 8 é executada, e então retornando também do procedimento assembly.

O que são convençÔes de chamadas?

É seguindo essa lĂłgica que "milagrosamente" o nosso cĂłdigo em C sabe que o valor em EAX Ă© o valor de retorno da nossa função assembly. Linguagens de alto nĂ­vel, como C por exemplo, usam um conjunto de regras para definir como uma função deve ser chamada e como ela retorna um valor. Essas regras sĂŁo a convenção de chamada, em inglĂȘs, calling convention.

Na nossa PoC a função assembly retorna uma variåvel do tipo int que na arquitetura x86 tem o tamanho de 4 bytes e é retornado no registrador EAX. A maioria dos valores serão retornados em alguma parte mapeada de RAX que coincida com o mesmo tamanho do tipo. Exemplos:

Tipo

Tamanho em x86-64

Registrador

char

1 byte

AL

short int

2 bytes

AX

int

4 bytes

EAX

char *

8 bytes

RAX

Por enquanto não vamos ver a convenção de chamada que a linguagem C usa, só estou adiantando isso para que possamos entender melhor como nossa função assembly funciona.

Em um cĂłdigo em C nĂŁo tente adivinhar o tamanho em bytes de um tipo. Para cada arquitetura diferente que vocĂȘ compilar o cĂłdigo, o tipo pode ter um tamanho diferente. Sempre que precisar do tamanho de um tipo use o operador sizeof.

ConteĂșdo

ConteĂșdo que serĂĄ apresentado neste livro

Neste livro vocĂȘ irĂĄ aprender Assembly da arquitetura x86 e x86-64 desde os conceitos base atĂ© conteĂșdo mais "avançado". Digo conceitos "base" e nĂŁo "bĂĄsico" porque infelizmente o termo "bĂĄsico" Ă© mal empregado na internet afora. As pessoas estĂŁo acostumadas a verem conteĂșdo bĂĄsico como algo superficial quando na verdade Ă© a parte mais importante para o aprendizado. É a partir dos conceitos bĂĄsicos que nĂłs conseguimos aprender todo o resto. Mas infelizmente devido ao mal uso do termo ele acabou sendo associado a uma enorme quantidade de conteĂșdo superficial encontrado na internet.

Portanto que fique de prĂ©vio aviso que o conteĂșdo bĂĄsico apresentado aqui nĂŁo deve ser pulado, ele Ă© de extrema importĂąncia e nĂŁo serĂĄ superficial como muitas vezes Ă© visto em outras fontes de conteĂșdo na internet.

Pré-requisitos

Por fim e o mais importante: VocĂȘ precisa de um computador da arquitetura x86 rodando um sistema operacional de 64-bit. Mas nĂŁo adianta apenas tĂȘ-lo, use-o para programar o que iremos aprender aqui.

Ferramentas necessĂĄrias

Eis a lista de ferramentas:

SeçÔes e símbolos

Entendendo um pouco do arquivo objeto

A esta altura vocĂȘ jĂĄ deve ter reparado que nossa função assembly estĂĄ em um arquivo separado da função main, mas de alguma maneira mĂĄgica a função pode ser executada e seu retorno capturado. Isso acontece graças a uma ferramenta chamada linker que junta vĂĄrios arquivos objetos em um arquivo executĂĄvel de saĂ­da.

Arquivo objeto

Um arquivo objeto é um formato de arquivo especial que permite organizar código e vårias informaçÔes relacionadas a ele. Os arquivos .o (ou .obj) que geramos com a compilação da nossa PoC são arquivos objetos, eles organizam informaçÔes que serão usadas pelo linker na hora de gerar o executåvel. Dentre essas informaçÔes, além do código em si, tem duas principais que são as seçÔes e os símbolos.

SeçÔes

Uma seção no arquivo objeto nada mais Ă© que uma maneira de agrupar dados no arquivo. É como criar um grupo novo e dar um sentido para ele. TrĂȘs exemplos principais de seçÔes sĂŁo:

  • A seção de cĂłdigo, onde o cĂłdigo que Ă© executado pelo processador fica.

  • Seção de dados, onde variĂĄveis sĂŁo alocadas.

  • Seção de dados nĂŁo inicializada, onde a memĂłria serĂĄ alocada dinamicamente ao carregar o executĂĄvel na memĂłria. Geralmente usada para variĂĄveis nĂŁo inicializadas, isto Ă©, variĂĄveis que nĂŁo tĂȘm um valor inicial definido.

Na pråtica se pode definir quantas seçÔes quiser (dentro do limite suportado pelo formato de arquivo) e para quais propósitos quiser também. Podemos até mesmo ter mais de uma seção de código, mais de uma seção de dados etc. O código em C é organizado pelo compilador, no nosso caso o GCC, e por isso nós não fizemos esse tipo de organização manualmente.

Existem quatro seçÔes principais que podemos usar no nosso cĂłdigo e o linker irĂĄ resolvĂȘ-las corretamente sem que nĂłs precisamos dizer a ele como fazer seu trabalho. O NASM tambĂ©m reconhece essas seçÔes como "padrĂŁo" e jĂĄ configura os atributos delas corretamente.

  • .text -- Usada para armazenar o cĂłdigo executĂĄvel do nosso programa.

  • .data -- Usada para armazenar dados inicializados do programa, por exemplo uma variĂĄvel global.

  • .bss -- Usada para reservar espaço para dados nĂŁo-inicializados, por exemplo uma variĂĄvel global que foi declarada mas nĂŁo teve um valor inicial definido.

  • .rodata ou .rdata -- Usada para armazenar dados que sejam somente leitura (readonly), por exemplo uma constante que nĂŁo deve ter seu valor alterado em tempo de execução.

Esses nomes de seçÔes são padronizados e códigos em C geralmente usam essas seçÔes com esses mesmos nomes.

SeçÔes tem flags que definem atributos para a seção, as trĂȘs flags principais e que nos importa saber Ă©:

  • read -- DĂĄ permissĂŁo de leitura para a seção.

  • write -- DĂĄ permissĂŁo de escrita para a seção, assim o cĂłdigo executado pode escrever dados nela.

  • exec -- DĂĄ permissĂŁo de executar os dados contidos na seção como cĂłdigo.

Na sintaxe do NASM é possível definir essas flags manualmente em uma seção modificando seus atributos. Veja o exemplo abaixo:

Nos dois primeiros exemplos nada de fato foi alterado nas seçÔes porque esses jå são seus respectivos atributos padrão. Jå a seção .outra não tem nenhuma permissão padrão definida por não ser nenhum dos nomes padronizados.

SĂ­mbolos

Uma das informaçÔes salvas no arquivo objeto é a tabela de símbolos que é, como o nome sugere, uma tabela que define nomes e endereços para determinados símbolos usados no arquivo objeto. Um símbolo nada mais é que um nome para se referir a determinado endereço.

Parece familiar? Pois Ă©, sĂ­mbolos e rĂłtulos sĂŁo essencialmente a mesma coisa. A Ășnica diferença prĂĄtica Ă© que o rĂłtulo apenas existe como conceito no arquivo fonte e o sĂ­mbolo existe como um valor no arquivo objeto.

Quando definimos um rótulo em Assembly podemos "exportå-lo" como um símbolo para que outros arquivos objetos possam acessar aquele determinado endereço. Jå vimos isso ser feito na nossa PoC, a diretiva global do NASM serve justamente para definir que aquele rótulo é global... Ou seja, que deve ser possível acesså-lo a partir de outros arquivos objetos.

Linker

O linker é o software encarregado de processar os arquivos objetos para que eles possam "conversar" entre si. Por exemplo, um símbolo definido no arquivo objeto assembly.o para que possa ser acessado no arquivo main.o o linker precisa intermediar, porque os arquivos não vão trocar informação por mågica.

Na nossa PoC o arquivo objeto main.o avisa para o linker que ele estå acessando um símbolo externo (que estå em outro arquivo objeto) chamado assembly. O linker então se encarrega de procurar por esse símbolo, e ele acaba o achando no assembly.o. Ao achar o linker calcula o endereço para aquele símbolo e seja lå aonde ele foi utilizado em main.o o linker irå colocar o endereço correto.

Todas essas informaçÔes (os locais onde foi utilizado, o endereço do símbolo, os símbolos externos acessados, os símbolos exportados etc.) ficam na tabela de símbolos. Com a maravilhosa ferramenta objdump do GCC podemos ver a tal da tabela de símbolos nos nossos arquivos objetos. Basta rodar o comando:

Se usarmos essa ferramenta nos nossos arquivos objetos podemos ver que, dentre vĂĄrios sĂ­mbolos lĂĄ encontrados, um deles Ă© o assembly.

ExecutĂĄvel

Depois do linker fazer o trabalho dele, ele gera o arquivo final que nĂłs normalmente chamamos de executĂĄvel. O executĂĄvel de um sistema operacional nada mais Ă© que um arquivo objeto que pode ser executado.

A diferença desse arquivo objeto final para o arquivo objeto anterior, Ă© que esse estĂĄ organizado de acordo com as "exigĂȘncias" do sistema operacional e pronto para ser rodado. Enquanto o outro sĂł tem informação referente Ă quele arquivo fonte, sem dar as informaçÔes necessĂĄrias para o sistema operacional poder rodĂĄ-lo como cĂłdigo. AtĂ© porque esse cĂłdigo ainda nĂŁo estĂĄ pronto para ser executado, ainda hĂĄ sĂ­mbolos e outras dependĂȘncias para serem resolvidas pelo linker.

InstruçÔes assembly x86

Entendendo algumas instruçÔes do Assembly x86

Até agora jå foram explicados alguns dos conceitos principais da linguagem Assembly da arquitetura x86, agora que jå entendemos como a base funciona precisamos nos munir de algumas instruçÔes para poder fazer códigos mais complexos. Pensando nisso vou listar aqui algumas instruçÔes e uma explicação bem båsica de como utilizå-las.

Formato da instrução

Jå expliquei a sintaxe de uma instrução no NASM mas não expliquei o formato em si da instrução no código de måquina. Para simplificar uma instrução pode ter os seguintes operandos:

  • Um operando registrador

  • Um operando registrador OU operando na memĂłria

  • Um operando imediato, que Ă© um valor numĂ©rico que faz parte da instrução.

Basicamente sĂŁo trĂȘs tipos de operandos: Um registrador, valor na memĂłria e um valor imediato. Um exemplo de cada um para ilustrar sendo mostrado como o segundo operando de MOV:

Como demonstrado na linha 4 strings podem ser passadas como um operando imediato. O assembler irå converter a string em sua respectiva representação em bytes, só que é necessårio ter atenção em relação ao tamanho da string que não pode ser maior do que o operando destino.

SĂŁo trĂȘs operandos diferentes e cada um deles Ă© opcional, isto Ă©, pode ou nĂŁo ser utilizado pela instrução (opcional para a instrução e nĂŁo para nĂłs).

Repare que somente um dos operandos pode ser um valor na memĂłria ou registrador, enquanto o outro Ă© especificamente um registrador. É devido a isso que hĂĄ a limitação de haver apenas um operando na memĂłria, enquanto que o uso de dois operandos registradores Ă© permitido.

Notação

Irei utilizar uma explicação simplificada aqui que irå deixar muita informação importante de fora.

As seguintes nomenclaturas serĂŁo utilizadas:

Em alguns casos eu posso colocar um nĂșmero junto a essa nomenclatura para especificar o tamanho do operando em bits. Por exemplo r/m16 indica um operando registrador/memĂłria de 16 bits.

Em cada instrução irei apresentar a notação demonstrando cada combinação diferente de operandos que é possível utilizar. Lembrando que o operando destino é o mais à esquerda, enquanto que o operando fonte é o operando mais à direita.

Cada nome de instrução em Assembly é um mnemÎnico, que é basicamente uma abreviatura feita para fåcil memorização. Pensando nisso leia cada instrução com seu nome extenso equivalente para lembrar o que ela faz. No título de cada instrução irei deixar após um "|" o nome extenso da instrução para facilitar nessa tarefa.

MOV | Move

Copia o valor do operando fonte para o operando destino.

ADD

Soma o valor do operando destino com o valor do operando fonte, armazenando o resultado no prĂłprio operando destino.

SUB | Subtract

Subtrai o valor do operando destino com o valor do operando fonte.

INC | Increment

Incrementa o valor do operando destino em 1.

DEC | Decrement

Decrementa o valor do operando destino em 1.

MUL | Multiplicate

Multiplica uma parte do mapeamento de RAX pelo operando fonte passado. Com base no tamanho do operando uma parte diferente de RAX serĂĄ multiplicada e o resultado armazenado em um registrador diferente.

No caso por exemplo de DX:AX, os registradores de 16 bits sĂŁo usados em conjunto para representar um valor de 32 bits. Onde DX armazena os 2 bytes mais significativos do valor e AX os 2 bytes menos significativos.

DIV | Divide

Seguindo uma premissa inversa de MUL, essa instrução faz a divisão de um valor pelo operando fonte passado e armazena o quociente e a sobra dessa divisão.

LEA | Load Effective Address

Calcula o endereço efetivo do operando fonte e armazena o resultado do cålculo no registrador destino. Ou seja, ao invés de ler o valor no endereço do operando na memória o próprio endereço resultante do cålculo de endereço serå armazenado no registrador. Exemplo:

AND

Faz uma operação E bit a bit nos operandos e armazena o resultado no operando destino.

OR

Faz uma operação OU bit a bit nos operandos e armazena o resultado no operando destino.

XOR | Exclusive OR

Faz uma operação OU Exclusivo bit a bit nos operandos e armazena o resultado no operando destino.

XCHG | Exchange

O operando 2 recebe o valor do operando 1 e o operando 1 recebe o valor anterior do operando 2. Fazendo assim uma troca nos valores dos dois operandos. Repare que diferente das instruçÔes anteriores essa modifica também o valor do segundo operando.

XADD | Exchange and Add

O operando 2 recebe o valor do operando 1 e, em seguida, o operando 1 Ă© somado com o valor anterior do operando 2. Basicamente preserva o valor anterior do operando 1 no operando 2 ao mesmo tempo que faz um ADD nele.

Essa instrução Ă© equivalente a seguinte sequĂȘncia de instruçÔes:

SHL | Shift Left

Faz o deslocamento de bits do operando destino para a esquerda com base no nĂșmero especificado no operando fonte. Se o operando fonte nĂŁo Ă© especificado entĂŁo faz o shift left apenas 1 vez.

SHR | Shift Right

Mesmo caso que SHL porém faz o deslocamento de bits para a direita.

CMP | Compare

Compara o valor dos dois operandos e define RFLAGS de acordo.

SETcc | Set byte if condition

Define o valor do operando de 8 bits para 1 ou 0 dependendo se a condição for atendida (1) ou não (0). Assim como no caso dos jumps condicionais, o 'cc' aqui denota uma sigla para uma condição. Cuja a condição pode ser uma das mesmas utilizadas nos jumps. Exemplo:

CMOVcc | Conditional Move

Basicamente uma instrução MOV condicional. Só irå definir o valor do operando destino caso a condição seja atendida.

NEG | Negate

Inverte o sinal do valor numérico do operando.

NOT

Faz uma operação NÃO bit a bit no operando.

MOVSB/MOVSW/MOVSD/MOVSQ | Move String

Copia um valor do tamanho de um byte, word, double word ou quad word a partir do endereço apontado por RSI (Source Index) para o endereço apontado por RDI (Destiny Index). Depois disso incrementa o valor dos dois registradores com o tamanho em bytes do dado que foi movido.

CMPSB/CMPSW/CMPSD/CMPSQ | Compare String

Compara os valores na memĂłria apontados por RDI e RSI, depois incrementa os registradores com o tamanho em bytes do dado.

LODSB/LODSW/LODSD/LODSQ | Load String

Copia o valor na memĂłria apontado por RSI para uma parte do mapeamento de RAX equivalente ao tamanho do dado, e depois incrementa RSI com o tamanho do valor.

SCASB/SCASW/SCASD/SCASQ | Scan String

Compara o valor em uma parte mapeada de RAX com o valor na memĂłria apontado por RDI e depois incrementa RDI de acordo.

STOSB/STOSW/STOSD/STODQ | Store String

Copia o valor de uma parte mapeada de RAX e armazena na memĂłria apontada por RDI, depois incrementa RDI de acordo.

LOOP/LOOPE/LOOPNE

Essas instruçÔes são utilizadas para gerar procedimentos de laço (loop) usando o registrador RCX como contador. Elas primeiro decrementam o valor de RCX e comparam o mesmo com o valor zero. Se RCX for diferente de zero a instrução faz um salto para o endereço passado como operando, senão o fluxo de código continua normalmente.

No caso de loope e loopne os sufixos indicam a condição de igual e não igual respectivamente. Ou seja, além da comparação do valor de RCX elas também verificam o valor de RFLAGS como uma condição extra.

NOP | No Operation

Não faz nenhuma operação... Sério, não faz nada. Essa instrução normalmente é utilizada apenas como um "preenchimento" por compiladores afim de alinhar o endereço de código por motivos de otimização.

Não cabe a esse livro explicar porque esse alinhamento melhora a performance do código mas se estiver curioso estude à respeito do cache do processador e cache line. Para simplificar um desvio de código para um endereço que esteja próximo ao início de uma linha de cache é mais performåtico.

InstruçÔes do NASM

Um pouco sobre o uso do NASM

Quando programamos em Assembly estamos escrevendo diretamente as instruçÔes do arquivo binĂĄrio, mas nĂŁo apenas isso como tambĂ©m estamos de certa forma o formatando e escrevendo todo o seu conteĂșdo manualmente.

Felizmente o assembler faz vårias formataçÔes que dizem respeito ao formato do arquivo automaticamente, e cabe a nós meramente saber usar suas diretivas e pseudo-instruçÔes. O objetivo desse tópico é aprender o principal para se poder usar o NASM de maneira apropriada.

SeçÔes

Antes de mais nada vamos aprender a dividir nosso código em seçÔes. Não adianta de nada usarmos um linker se não trabalharmos com ele, não é mesmo?

A sintaxe para se definir uma seção Ă© bem simples. Basta usar a diretiva section seguido do nome que vocĂȘ quer dar para a seção e os atributos que vocĂȘ quer definir para ela. As seçÔes .text, .data, .rodata e .bss jĂĄ tem seus atributos padrĂ”es definidos e por isso nĂŁo precisamos defini-los.

Por padrĂŁo o NASM joga todo o conteĂșdo do arquivo fonte na seção .text e por isso nĂłs nĂŁo a definimos na nossa PoC. Mas poderĂ­amos reescrever nossa PoC desta vez especificando a seção:

A partir da diretiva na linha 3 todo o código é organizado no arquivo objeto dentro da seção .text, que é destinada ao código executåvel do programa e por padrão tem o atributo de execução (exec) habilitado pelo NASM.

SĂ­mbolos

Como jĂĄ vimos na nossa PoC os sĂ­mbolos internos podem ser exportados para serem acessados a partir de outros arquivos objetos usando a diretiva global. Podemos exportar mais de um sĂ­mbolo de uma vez separando cada nome de rĂłtulo por vĂ­rgula, exemplo:

Dessa forma um endereço especificado por um rótulo no nosso código fonte em Assembly pode ser acessado por código fonte compilado em outro arquivo objeto, tudo graças ao linker.

Mas as vezes também teremos a necessidade de acessar um símbolo externo, isto é, pertencente a outro arquivo objeto. Para podermos fazer isso existe a diretiva extern que serve para declarar no arquivo objeto que estamos acessando um símbolo que estå em outro arquivo objeto.

Jå vimos que no arquivo objeto main.o havia na symbol table a declaração do uso do símbolo assembly que estava em um arquivo externo. A diretiva extern serve para inserir essa informação na tabela de símbolos do arquivo objeto de saída. A diretiva extern segue a mesma sintaxe de global:

Veja um exemplo de uso com nossa PoC:

Declaramos na linha 11 do arquivo main.c a função number e no arquivo assembly.asm usamos a diretiva extern na linha 2 para declarar o acesso ao símbolo number, que chamamos na linha 8.

Para o NASM nĂŁo faz diferença alguma aonde vocĂȘ coloca as diretivas extern e global porĂ©m por questĂ”es de legibilidade do cĂłdigo eu recomendo que use extern logo no começo do arquivo fonte e global logo antes da declaração do rĂłtulo.

Isso irĂĄ facilitar a leitura do seu cĂłdigo jĂĄ que ao ver o rĂłtulo imediatamente se sabe que ele foi exportado e ao abrir o arquivo fonte, imediatamente nas primeiras linhas, jĂĄ se sabe quais sĂ­mbolos externos estĂŁo sendo acessados.

VariĂĄveis?

Em Assembly não existe a declaração de uma variåvel porém assim como funçÔes existem como conceito e podem ser implementadas em Assembly, variåveis também são dessa forma.

Em um código em C variåveis globais ficam na seção .data ou .bss. A seção .data do executåvel nada mais é que uma cópia dos dados contidos na seção .data do arquivo binårio. Ou seja o que despejarmos de dados em .data serå copiado para a memória RAM e serå acessível em tempo de execução e com permissão de escrita.

Para despejar dados no arquivo binårio existe a pseudo-instrução db e semelhantes. Cada uma despejando um tamanho diferente de dados mas todas tendo a mesma sintaxe de separar cada valor numérico por vírgula. Veja a tabela:

As quatro Ășltimas dt, do, dy e dz nĂŁo suportam que seja passado uma string como valor.

Podemos por exemplo guardar uma variåvel global na seção .data e acessar ela a partir do código fonte em C, bem como também no próprio código em Assembly. Exemplo:

Repare que em C usamos a keyword extern para especificar que a variĂĄvel global myVar estaria em outro arquivo objeto, comportamento muito parecido com a diretiva extern do NASM.

VariĂĄveis nĂŁo-inicializadas

A seção .bss Ă© usada para armazenar variĂĄveis nĂŁo-inicializadas, isto Ă©, que nĂŁo tem um valor inicial definido. Basicamente essa seção no arquivo objeto tem um tamanho definido para ser alocada pelo sistema operacional em memĂłria mas nĂŁo um conteĂșdo explĂ­cito copiado do arquivo binĂĄrio.

Existem pseudo-instruçÔes do NASM que permitem alocar espaço na seção sem de fato despejar nada ali. É a resb e suas semelhantes que seguem a mesma premissa de db. Os tamanhos disponĂ­veis de dados sĂŁo os mesmos de db por isso nĂŁo vou repetir a tabela aqui. SĂł ressaltando que a Ășltima letra da pseudo-instrução indica o tamanho do dado. A sintaxe da pseudo-instrução Ă©:

Onde como operando ela recebe o nĂșmero de dados que serĂŁo alocados, onde o tamanho de cada dado depende de qual variante da instrução foi utilizada. Por exemplo:

A ideia de usar essa pseudo-instrução é poder declarar um rótulo/símbolo que irå apontar para o endereço dos dados alocados em memória. Veja mais um exemplo na nossa PoC:

Constantes

Por convenção Ă© interessante usar nomes de constantes totalmente em letras maiĂșsculas para facilitar a sua identificação no cĂłdigo fonte em contraste com o nome de um rĂłtulo. Seja lĂĄ aonde a constante for usada no cĂłdigo fonte ela irĂĄ expandir para o seu valor definido. Exemplo:

A instrução na linha 2 alteraria o valor de EAX para 34.

Constantes em memĂłria

Constantes em memória nada mais são do que valores despejados na seção .rodata. Essa seção é muito parecida com .data com a diferença de não ter permissão de escrita. Exemplo:

ExpressÔes

O NASM aceita que vocĂȘ escreva expressĂ”es matemĂĄticas seguindo a mesma sintaxe de expressĂŁo da linguagem C e seus operadores. Essas expressĂ”es serĂŁo calculadas pelo prĂłprio NASM e nĂŁo em tempo de execução. Por isso Ă© necessĂĄrio usar na expressĂŁo somente rĂłtulos, constantes ou qualquer outro valor que exista em tempo de compilação e nĂŁo em tempo de execução.

Podemos usar expressão matemåtica em qualquer pseudo-instrução ou instrução que aceita um valor numérico como operando. Exemplos:

O NASM também permite o uso de dois símbolos especiais nas expressÔes que expandem para endereços relacionados a posição da instrução atual:

Onde o uso do $ serve como um atalho para se referir ao endereço da linha de código atual, algo equivalente a declarar um rótulo como abaixo:

Usando o cifrĂŁo fica:

Enquanto o uso de $$ seria equivalente a declarar um rótulo no início da seção, como em:

E esse seria o equivalente com $$:

No caso de vocĂȘ usar o NASM para um formato de arquivo binĂĄrio puro (raw binary), onde nĂŁo existem seçÔes, o $$ Ă© equivalente ao endereço do inĂ­cio do binĂĄrio.

OlĂĄ mundo no Linux

Finalmente o Hello World.

Geralmente o "Hello World" Ă© a primeira coisa que vemos quando estamos aprendendo uma linguagem de programação. Nesse caso eu deixei por Ășltimo pois acredito que seria de extrema importĂąncia entender todos os conceitos antes de vĂȘ-lo, isso evitaria a intuição de ver um cĂłdigo em Assembly como um "cĂłdigo em C mais difĂ­cil de ler". Acredito que essa comparação mental involuntĂĄria Ă© muito ruim e prejudicaria o aprendizado. Por isso optei por explicar tudo antes mesmo de apresentar o famoso "Hello World".

Hello World

Desta vez vamos escrever um cĂłdigo em Assembly sem misturar com C, serĂĄ um executĂĄvel do Linux (formato ELF64) fazendo chamadas de sistema diretamente. Vamos vĂȘ-lo logo:

Para compilar esse cĂłdigo basta usar o NASM especificando o format elf64 e desta vez iremos usar o linker do pacote GCC diretamente. O nome do executĂĄvel Ă© ld e o uso bĂĄsico Ă© bem simples, basta especificar o nome do arquivo de saĂ­da com -o. Ficando assim:

Na linha 5 definimos uma constante usando o símbolo $ para pegar o endereço da instrução atual e subtraímos pelo endereço do rótulo msg. Isso resulta no tamanho do texto porque msg aponta para o início da string e, como estå logo em seguida, $ seria o endereço do final da string. final - início = tamanho

write

Como deve ter reparado usamos mais uma syscall, que foi a syscall write. Essa syscall basicamente escreve dados em um arquivo. O primeiro argumento Ă© um nĂșmero que serve para identificar o arquivo para o qual queremos escrever os dados.

No Linux a saĂ­da e entrada de um programa nada mais Ă© que dados sendo escritos e lidos em arquivos. E isso Ă© feito por trĂȘs arquivos que estĂŁo por padrĂŁo abertos em um programa e tem sempre o mesmo file descriptor, sĂŁo eles:

Entry point

Reparou que nosso programa tem um símbolo _start e que magicamente esse é o código que o sistema operacional estå executando primeiro? Isso acontece porque o linker definiu o endereço daquele símbolo como o entry point (ponto de entrada) do nosso programa.

O entry point nada mais Ă© o que o prĂłprio nome sugere, o endereço inicial de execução do programa. Eu sei o que vocĂȘ estĂĄ pensando:

Então a função main de um programa em C é o entry point?

A resposta é não! Um programa em C usando a libc tem uma série de códigos que são executados antes da main. E o primeiro deles, pasme, é uma função chamada _start definida pela própria libc.

Na verdade qualquer sĂ­mbolo pode ser definido como o entry point para o executĂĄvel, nĂŁo faz diferença qual nome vocĂȘ dĂĄ para ele. SĂł que _start Ă© o sĂ­mbolo padrĂŁo que o ld define como entry point.

Se vocĂȘ quiser usar um sĂ­mbolo diferente Ă© sĂł especificar com a opção -e. Por exemplo, podemos reescrever nosso Hello World assim:

E compilar assim:

Fåcil fazer um "Hello World", né? Ei, o que acha de fazer uns macros para melhorar o uso dessas syscalls aí? Seria interessante também salvar os macros em um arquivo separado e incluir o arquivo com a diretiva %include.

Syscall no Linux

Chamada de sistema no Linux

Uma chamada de sistema, ou syscall (abreviação para system call), é algo muito parecido com uma call mas com a diferença nada sutil de que é o kernel do sistema operacional quem irå executar o código.

O kernel é a parte principal de um sistema operacional encarregada de gerenciar todo o sistema, desde o hardware até mesmo a execução do software (processos/tarefas). Ele é a base de todo o restante do sistema que roda sob controle do kernel. O Linux na verdade é um kernel, um "sistema operacional Linux" na verdade é um sistema operacional que usa o kernel Linux.

Em x86-64 existe uma instrução que foi feita especificamente para fazer chamadas de sistema e o nome dela é, intuitivamente, syscall. Ela não recebe nenhum operando e a especificação de qual código ela irå executar e com quais argumentos é definido por uma convenção de chamada assim como no caso das funçÔes.

Convenção de syscall x86-64

A convenção para efetuar uma chamada de sistema em Linux x86-64 Ă© bem simples, basta definir RAX para o nĂșmero da syscall que vocĂȘ quer executar e outros 6 registradores sĂŁo usados para passar argumentos. Veja a tabela:

O retorno da syscall também fica em RAX assim como na convenção de chamada da linguagem C.

Em syscalls que recebem menos do que 6 argumentos nĂŁo Ă© necessĂĄrio definir o valor dos registradores restantes porque nĂŁo serĂŁo utilizados.

exit

Vou ensinar aqui a usar a syscall mais simples que Ă© a exit, ela basicamente finaliza a execução do programa. Ela recebe um sĂł argumento que Ă© o status de saĂ­da do programa. Esse nĂșmero nada mais Ă© do que um valor definido para o sistema operacional que indica as condiçÔes da finalização do programa.

Por convenção geralmente o nĂșmero zero indica que o programa finalizou sem problemas, e qualquer valor diferente deste indica que houve algum erro. Um exemplo na nossa PoC:

A instrução ret na linha 10 nunca serå executada porque a syscall disparada pela instrução syscall na linha 9 não retorna. No momento em que for chamada o programa serå finalizado com o valor de RDI como status de saída.

No Linux se quiser ver o status de saĂ­da de um programa a variĂĄvel especial $? expande para o status de saĂ­da do Ășltimo programa executado. EntĂŁo vocĂȘ pode executar nossa PoC da seguinte forma:

O echo teoricamente iria imprimir 0 que é o status de saída que nós definimos. Experimente mudar o valor de RDI e ver se reflete na mudança do valor de $? corretamente.

Outras syscalls

Se quiser ver uma lista completa de syscalls x86-64 do Linux pode ver no link abaixo:

VocĂȘ tambĂ©m pode consultar o conteĂșdo do arquivo cabeçalho /usr/include/x86_64-linux-gnu/asm/unistd_64.h para ver uma lista completa da definição dos nĂșmeros de syscall.

E para simplificar a consulta de syscalls no meu Linux eu implementei e uso a seguinte função em Bash. Fique à vontade para uså-la:

Exemplo de uso:

Pré-processador do NASM

Aprendendo a usar o pré-processador do NASM

O NASM tem um prĂ©-processador de cĂłdigo baseado no prĂ©-processador da linguagem C. O que ele faz basicamente Ă© interpretar instruçÔes especĂ­ficas do prĂ©-processador para gerar o cĂłdigo fonte final, que serĂĄ de fato compilado pelo NASM para o cĂłdigo de mĂĄquina. É por isso que tem o nome de prĂ©-processador, jĂĄ que ele processa o cĂłdigo fonte antes do NASM compilar o cĂłdigo.

As diretivas interpretadas pelo pré-processador são prefixadas pelo símbolo % e dão um poder absurdo para a programação diretamente em Assembly no nasm. Abaixo irei listar as mais båsicas e o seu uso.

%define

Assim como a diretiva #define do C, essa diretiva Ă© usada para definir macros de uma Ășnica linha. Seja lĂĄ aonde o nome do macro for citado no cĂłdigo fonte ele expandirĂĄ para exatamente o conteĂșdo que vocĂȘ definiu para ele como se vocĂȘ estivesse fazendo uma cĂłpia.

E assim como no C é possível passar argumentos para um macro usando de uma sintaxe muito parecida com uma função. Exemplo de uso:

As linhas 2 e 3 irão expandir para a instrução mov eax, 31 como se tivesse feito uma cópia do valor definido para o macro. Podemos também é claro escrever um macro como parte de uma instrução, por exemplo:

Isso irå expandir a instrução na linha 2 para mov eax, [ebx*2 + 4]

A diferença entre definir um macro dessa forma e definir uma constante Ă© que a constante recebe uma expressĂŁo matemĂĄtica e expande para o valor do resultado. Enquanto o macro expande para qualquer coisa que vocĂȘ definir para ele.

O outro uso do macro, que Ă© mais poderoso, Ă© passando argumentos para ele assim como se Ă© possĂ­vel fazer em C. Para isso basta definir o nome do macro seguido dos parĂȘnteses e, dentro dos parĂȘnteses, os nomes dos argumentos que queremos receber separados por vĂ­rgula.

No valor definido para o macro os nomes desses argumentos irĂŁo expandir para qualquer conteĂșdo que vocĂȘ passe como argumento na hora que chamar um macro. Veja por exemplo o mesmo macro acima porĂ©m desta vez dando a possibilidade de escolher o registrador:

A linha 2 irĂĄ expandir para: mov eax, [ebx*2 + 4]. A linha 3 irĂĄ expandir para: mov eax, [esi*2 + 4].

%undef

Simplesmente apaga um macro anteriormente declarado por %define.

%macro

AlĂ©m dos macros de uma Ășnica linha existem tambĂ©m os macros de mĂșltiplas linhas que podem ser definidos no NASM.

ApĂłs a especificação do nome que queremos dar ao macro podemos especificar o nĂșmero de argumentos passados para ele. Caso nĂŁo queira receber argumentos no macro basta definir esse valor para zero. Exemplo:

O %endmacro sinaliza o final do macro e todas as instruçÔes inseridas entre as duas diretivas serão expandidas quando o macro for citado.

Para usar argumentos com um macro de mĂșltiplas linhas difere de um macro definido com %define, ao invĂ©s do uso de parĂȘnteses o macro recebe argumentos seguindo a mesma sintaxe de uma instrução e separando cada um dos argumentos por vĂ­rgula. Para usar o argumento dentro do macro basta usar %n, onde n seria o nĂșmero do argumento que começa contando em 1.

TambĂ©m Ă© possĂ­vel fazer com que o Ășltimo argumento do macro expanda para todo o conteĂșdo passado, mesmo que contenha vĂ­rgula. Para isso basta adicionar um + ao nĂșmero de argumentos. Por exemplo:

A linha 6 expandiria para as instruçÔes:

Enquanto a linha 7 iria acusar erro jå que na linha 3 dentro do macro a instrução expandiu para mov esi, edi, edx, o que estå errado.

É possĂ­vel declarar mais de um macro com o mesmo nome desde que cada um deles tenham um nĂșmero diferente de argumentos recebidos. O exemplo abaixo Ă© totalmente vĂĄlido:

RĂłtulos dentro de um macro

Usar um rĂłtulo dentro de um macro Ă© problemĂĄtico porque se o macro for usado mais de uma vez estaremos redefinindo o mesmo rĂłtulo jĂĄ que seu nome nunca muda.

Para nĂŁo ter esse problema existem os rĂłtulos locais de um macro que serĂĄ expandido para um nome diferente, definido pelo NASM, a cada uso do macro. A sintaxe Ă© simples, basta prefixar o nome do rĂłtulo com %%. Exemplo:

%unmacro

Apaga um macro anteriormente definido com %macro. O nĂșmero de argumentos especificado deve ser o mesmo utilizado na hora de declarar o macro.

Compilação condicional

Assim como o pré-processador do C, o NASM também suporta diretivas de código condicional. A sintaxe båsica é:

Onde o código dentro da diretiva %if só é compilado se a condição for atendida. Caso não seja é possível usar a diretiva %elif para fazer o teste de uma nova condição. Enquanto o código na diretiva %else é expandido caso nenhuma das condiçÔes anteriormente testadas sejam atendidas. Por fim é usado a diretiva %endif para indicar o fim da diretiva %if.

É possível passar para %if e %elif uma expressão matemática afim de testar o resultado de um cálculo com uma constante ou algo semelhante. Se o valor for diferente de zero a expressão será considerada verdadeira e o bloco de código será expandido no código de saída.

Também é possível inverter a lógica das instruçÔes adicionando um 'n', fazendo com que o bloco seja expandido caso a condição não seja atendida. Exemplo:

Além do %if båsico também podemos usar variantes que verificam por uma condição específica ao invés de receber uma expressão e testar seu resultado.

%ifdef e %elifdef

Essas diretivas verificam se um macro de linha Ășnica foi declarado por um %define anteriormente. É possĂ­vel tambĂ©m usar essas diretivas em forma de negação adicionando o 'n' apĂłs o 'if'. Ficando: %ifndef e %elifndef, respectivamente.

%ifmacro e %elifmacro

Mesmo que %ifdef porĂ©m para macros de mĂșltiplas linhas declarados por %macro. E da mesma que as diretivas anteriores tambĂ©m tĂȘm suas versĂ”es em negação: %ifnmacro e %elifnmacro.

%error e %warning

Usando diretivas condicionais as vezes queremos acusar um erro ou emitir um alerta no console para indicar alguma mensagem no processo de compilação de algum projeto.

%error imprime a mensagem como um erro e finaliza a compilação, enquanto %warning emite a mensagem como um alerta e a compilação continua normalmente. Podemos por exemplo acusar um erro caso um determinado macro necessårio para o código não esteja definido:

%include

Essa diretiva tem o uso parecido com a diretiva #include da linguagem C e ela faz exatamente a mesma coisa: Copia o conteĂșdo do arquivo passado como argumento para o exato local aonde ela foi utilizada no arquivo fonte. Seria como vocĂȘ manualmente abrir o arquivo, copiar todo o conteĂșdo dele e depois colar no cĂłdigo fonte.

Assim como fazemos em um header file incluĂ­do por #include na linguagem C Ă© importante usar as diretivas condicionais para evitar a inclusĂŁo duplicada de um mesmo arquivo. Por exemplo:

Dessa forma quando incluirmos o arquivo pela primeira vez o macro _ARQUIVO_ASM serĂĄ declarado. Se ele for incluĂ­do mais uma vez o macro jĂĄ estarĂĄ declarado e o %ifndef da linha 1 terĂĄ uma condição falsa e portanto nĂŁo expandirĂĄ o conteĂșdo dentro de sua diretiva.

É importante fazer isso para evitar a redeclaração de macros, constantes ou rĂłtulos. Bem como tambĂ©m evita que o mesmo cĂłdigo fique duplicado.

Significado de bĂĄsico: Que serve como base; essencial, basilar. O mais relevante ou importante de; fundamental. ~

Neste livro nĂŁo serĂĄ ensinado a programar em C e nem muito menos como elaborar algoritmos usando o paradigma imperativo (vulgo "lĂłgica de programação"). É recomendĂĄvel ter experiĂȘncia razoĂĄvel em alguma linguagem de programação e ser capaz de escrever um "OlĂĄ Mundo" em C alĂ©m de ao menos saber usar funçÔes. Bem como tambĂ©m Ă© importante que saiba usar a linha de comando mas nĂŁo se preocupe, todos os comandos serĂŁo ensinados na hora certa. E claro, nĂŁo ensinarei sobre sistemas de numeração como , ou .

Caso queira aprender C, o Mente BinĂĄria tem um treinamento gratuito intitulado .

Todas as ferramentas que utilizaremos sĂŁo gratuitas, de cĂłdigo aberto e com versĂŁo para Linux e Windows. NĂŁo se preocupe pois vocĂȘ nĂŁo terĂĄ que desembolsar nada e nem baixar softwares em um site "alternativo". NĂŁo ensinarei como instalar as ferramentas, vocĂȘ pode facilmente encontrar tutoriais pesquisando no Google ou na prĂłpria documentação da ferramenta. De preferĂȘncia jĂĄ deixe todas as ferramentas instaladas e, caso use o Windows, nĂŁo esqueça de setar a PATH corretamente.

-- Assembler que usaremos para nossos cĂłdigos em Assembly

-- Compilador de C que usaremos e ensinarei o Inline Assembly

-- É o porte do GCC para o Windows, caso use Windows instale esse especificamente e não o MinGW do projeto original.

-- Emulador do MS-DOS e arquitetura x86

-- Emulador de vĂĄrias arquiteturas diferentes, usaremos a i386

É recomendĂĄvel que tenha o , porĂ©m Ă© opcional.

Qualquer dĂșvida sugiro que acesse o a fim de tirar dĂșvidas e ter acesso a outros conteĂșdos.

Se vocĂȘ for um escovador de bits sugiro ler Ă  respeito no no tĂłpico 3.4.1.4 Code Alignment.

Uma constante nada mais é que um apelido para representar um valor no código afim de facilitar a modificação daquele valor posteriormente ou então evitar um . Podemos declarar uma constante usando a pseudo-instrução equ:

Se quiser ver o código de implementação desta syscall no Linux, pode .

Além disso também sugiro consultar a man page do em C da syscall afim de entender mais detalhadamente o que cada uma delas faz. Por exemplo:

Dicio
binĂĄrio
hexadecimal
octal
Programação Moderna em C
variĂĄvel de ambiente
nasm
gcc
mingw-w64
dosbox
qemu
make
fĂłrum do Mente BinĂĄria
section .text exec

section .data write

section .outra write exec
$ objdump -t arquivo_objeto.o
mov eax, ebx      ; EBX   = Registrador
mov eax, [ebx]    ; [EBX] = MemĂłria
mov eax, 65       ; 65    = Valor imediato
mov eax, "A"      ; "A"   = Valor imediato, mesmo que 65

Nomenclatura

Significado

reg

Um operando registrador

r/m

Um operando registrador ou na memĂłria

imm

Um operando imediato

addr

Denota um endereço, geralmente se usa um rótulo. Na pråtica é um valor imediato assim como o operando imediato.

mov reg, r/m
mov reg, imm
mov r/m, reg
mov r/m, imm
pseudo.c
destiny = source;
add reg, r/m
add reg, imm
add r/m, reg
add r/m, imm
pseudo.c
destiny = destiny + source;
sub reg, r/m
sub reg, imm
sub r/m, reg
sub r/m, imm
pseudo.c
destiny = destiny - source;
inc r/m
pseudo.c

destiny++;
dec r/m
pseudo.c

destiny--;
mul r/m

Operando 1

Operando 2

Destino

AL

r/m8

AX

AX

r/m16

DX:AX

EAX

r/m32

EDX:EAX

RAX

r/m64

RDX:RAX

pseudo.c
// Se operando de 8 bits
AX = AL * operand;


// Se operando de 16 bits
aux = AX * operand;
DX  = (aux & 0xffff0000) >> 16;
AX  = aux & 0x0000ffff;
div r/m

Operando 1

Operando 2

Destino quociente

Destino sobra

AX

r/m8

AL

AH

DX:AX

r/m16

AX

DX

EDX:EAX

r/m32

EAX

EDX

RDX:RAX

r/m64

RAX

RDX

pseudo.c

// Se operando de 8 bits
AL = AX / operand;
AH = AX % operand;
lea reg, mem
mov rbx, 5
lea rax, [rbx + 7]

; Aqui RAX teria o valor 12
and reg, r/m
and reg, imm
and r/m, reg
and r/m, imm
pseudo.c
destiny = destiny & source;
or reg, r/m
or reg, imm
or r/m, reg
or r/m, imm
pseudo.c

destiny = destiny | source;
xor reg, r/m
xor reg, imm
xor r/m, reg
xor r/m, imm
pseudo.c
destiny = destiny ^ source;
xchg reg, r/m
xchg r/m, reg
auxiliary = destiny;
destiny   = source;
source    = auxiliary;
xadd r/m, reg
pseudo.c

auxiliary = source;
source    = destiny;
destiny   = destiny + auxiliary;
xchg rax, rbx
add rax, rbx
shl r/m
shl r/m, imm
shl r/m, CL
pseudo.c
destiny = destiny << 1;       // Se: shl r/m
destiny = destiny << source; //  Nos outros casos
shr r/m
shr r/m, imm
shr r/m, CL
pseudo.c
destiny = destiny >> 1;       // Se: shr r/m
destiny = destiny >> source; //  Nos outros casos
cmp r/m, imm
cmp r/m, reg
cmp reg, r/m
pseudo.c

RFLAGS = compare(operand1, operand2);
SETcc r/m8
sete al
; Se RFLAGS indica um valor igual: AL = 1. Se nĂŁo: AL = 0
pseudo.c

if (verify_rflags(condition) == true)
{
  destiny = 1;
}
else
{
  destiny = 0;
}
CMOVcc reg, r/m
pseudo.c
if (verify_rflags(condition) == true)
{
  destiny = source;
}
neg r/m
pseudo.c
destiny = -destiny;
not r/m
pseudo.c
destiny = ~destiny;
movsb  ; byte        (1 byte)
movsw  ; word        (2 bytes)
movsd  ; double word (4 bytes)
movsq  ; quad word   (8 bytes)
pseudo.c
// Se MOVSW
word [RDI] = word [RSI];
RDI        = RDI + 2;
RSI        = RSI + 2;
cmpsb  ; byte        (1 byte)
cmpsw  ; word        (2 bytes)
cmpsd  ; double word (4 bytes)
cmpsq  ; quad word   (8 bytes)
pseudo.c
// CMPSW
RFLAGS = compare(word [RDI], word [RSI]);
RDI    = RDI + 2;
RSI    = RSI + 2;
lodsb  ; byte        (1 byte)
lodsw  ; word        (2 bytes)
lodsd  ; double word (4 bytes)
lodsq  ; quad word   (8 bytes)
pseudo.c
// LODSW
AX  = word [RSI];
RSI = RSI + 2;
scasb  ; byte        (1 byte)
scasw  ; word        (2 bytes)
scasd  ; double word (4 bytes)
scasq  ; quad word   (8 bytes)
pseudo.c
// SCASW
RFLAGS = compare(AX, word [RDI]);
RDI    = RDI + 2;
stosb  ; byte        (1 byte)
stosw  ; word        (2 bytes)
stosd  ; double word (4 bytes)
stosq  ; quad word   (8 bytes)
pseudo.c

// STOSW
word [RDI] = AX;
RDI        = RDI + 2;
pseudo.c
loop   addr8
loope  addr8
loopne addr8
pseudo.c
// loop
RCX = RCX - 1;
if(RCX != 0)
{
  goto operand;
}

// loope
RCX = RCX - 1;
if(RCX != 0 && verify_rflags(EQUAL) == true)
{
  goto operand;
}

// loopne
RCX = RCX - 1;
if(RCX != 0 && verify_rflags(EQUAL) == false)
{
  goto operand;
}
nop
pseudo.c
EAX = EAX;

bits 64

section .text

global assembly
assembly:
  mov eax, 777
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}
global assembly, anotherFunction, gVariable
extern symbol1, symbol2, symbol3
#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

int number(void)
{
  return 777;
}

bits 64
extern number

section .text

global assembly
assembly:
  call number
  add eax, 111
  ret

Pseudo-instrução

Tamanho dos dados

Bytes

db

byte

1

dw

word

2

dd

double word

4

dq

quad word

8

dt

ten word

10

do

16

dy

32

dz

64


bits 64

global myVar
section .data
  myVar: dd 777

section .text

global assembly
assembly:
  add dword [myVar], 3
  ret

#include <stdio.h>

int assembly(void);
extern int myVar;

int main(void)
{
  printf("Valor: %d\n", myVar);
  assembly();
  printf("Valor: %d\n", myVar);
  return 0;
}
resb nĂșmero_de_dados
resd 6  ; Aloca o espaço de 6 double-words, ao todo 24 bytes.

bits 64

global myVar
section .bss
  myVar: resd 1

section .text

global assembly
assembly:
  mov dword [myVar], 777
  ret

#include <stdio.h>

int assembly(void);
extern int myVar;

int main(void)
{
  assembly();
  printf("Valor: %d\n", myVar);
  return 0;
}
NOME_DA_CONSTANTE equ expressĂŁo
EXAMPLE equ 34
mov eax, EXAMPLE
section .rodata
  const_value: dd 777
CONST equ (5 + 2*5) / 3       ; Correto!
mov eax, 4 << 2               ; Correto!
mov eax, [(2341 >> 6) % 10]   ; Correto!
mov eax, CONST + 4            ; Correto!

mov eax, ebx + 2   ; ERRADO!

SĂ­mbolo

Valor

$

Endereço da instrução atual

$$

Endereço do início da seção atual

here: jmp here
jmp $
section .text
text_start:
  nop
  call exemplo
  jmp text_start
section .text
  nop
  call exemplo
  jmp $$
hello.asm
bits 64

section .rodata
  msg:     db  `Hello World!\n`
  MSG_SIZE equ $-msg

section .text

global _start
_start:
  mov rax, 1
  mov rdi, 1
  mov rsi, msg
  mov rdx, MSG_SIZE
  syscall             ; write

  mov rax, 60
  xor rdi, rdi
  syscall             ; exit
$ nasm hello.asm -felf64
$ ld hello.o -o hello
$ ./hello

Nome

RAX

RDI

RSI

RDX

write

1

file_descriptor

endereço

tamanho (em bytes)

Nome

File descriptor

Descrição

stdin

0

Entrada de dados (o que Ă© digitado pelo usuĂĄrio)

stdout

1

SaĂ­da padrĂŁo (o que Ă© impresso no terminal)

stderr

2

Saída de erro (também impresso no terminal, porém destinado a mensagens de erro)

bits 64

section .rodata
  msg:     db  `Hello World!\n`
  MSG_SIZE equ $-msg

section .text

global _eu_que_mando_no_meu_exec
_eu_que_mando_no_meu_exec:
  mov rax, 1
  mov rdi, 1
  mov rsi, msg
  mov rdx, MSG_SIZE
  syscall

  mov rax, 60
  xor rdi, rdi
  syscall
$ nasm hello.asm -felf64
$ ld hello.o -o hello -e _eu_que_mando_no_meu_exec
$ ./hello

Registrador

Uso

RAX

NĂșmero da syscall / Valor de retorno

RDI

1° argumento

RSI

2° argumento

RDX

3° argumento

R10

4° argumento

R8

5° argumento

R9

6° argumento

Nome

RAX

RDI

exit

60

int status_de_saĂ­da

bits 64

section .text

global assembly
assembly:
  mov rax, 60
  mov rdi, 0
  syscall
  ret
#include <stdio.h>

int assembly(void);

int main(void)
{
  assembly();
  puts("Hello World!");
  return 0;
}
$ ./test
$ echo $?
$ man 2 exit
function syscall() {
	if [ -z "$1" ]; then
		echo "Developed by Luiz Felipe <felipe.silva337@yahoo.com>"
		echo "See (Portuguese): https://mentebinaria.gitbook.io/assembly-x86"
		echo
		echo "Usage: syscall name [32|64]"
		return 0
	fi

	name="$1"
	bits="${2-64}"
	number=$(grep -m1 "__NR_$name" \
			 "/usr/include/x86_64-linux-gnu/asm/unistd_$bits.h" \
			 | cut -d' ' -f3)

	[ -z "$number" ] && return 1

	if [ "$bits" == "64" ]; then
		sysnumRegister="RAX"
		arguments="RDI, RSI, RDX, R10, R8, R9"
	else
		sysnumRegister="EAX"
		arguments="EBX, ECX, EDX, ESI, EDI, EBP"
	fi

	echo "Syscall number ($sysnumRegister): $number"
	echo "Arguments: $arguments"
	echo
	echo "Synopsis:"

	awkCode='
		/SYNOPSIS/,/DESCRIPTION/{
			if ($1 != "SYNOPSIS" && $1 != "DESCRIPTION") {
				print $0
			}
		}
	'

	man 2 "$name" | awk "$awkCode"

	return 0
}
%define nome              "valor"
%define nome(arg1, arg2)  arg1 + arg2
%define teste   mov eax, 31
teste
teste
%define addr   [ebx*2 + 4]
mov eax, addr
%define addr(reg)   [reg*2 + 4]
mov eax, addr(ebx)
mov eax, addr(esi)
%undef nome_do_macro
%macro nome NÚMERO_DE_ARGUMENTOS
  ; CĂłdigo aqui
%endmacro
%macro sum5 0
  mov ebx, 5
  add eax, ebx
%endmacro

sum5
sum5
%macro sum 2
  mov ebx, %2
  add %1, ebx
%endmacro

sum esi, edi
sum ebp, eax
%macro example 2+
  inc %1
  mov %2
%endmacro

example eax, ebx, ecx
example ebx, esi, edi, edx
inc eax
mov ebx, ecx
%macro example 1
  mov rax, %1
%endmacro

%macro example 2
  mov rax, %1
  add rax, %2
%endmacro


example 1
example 2, 3
; Repare como o cĂłdigo abaixo ficaria mais simples usando SETcc
; ou até mesmo CMOVcc.

%macro compare 2
  cmp %1, %2
  je %%is_equal
  mov eax, 0
  jmp %%end  
%%is_equal:
  mov eax, 1
%%end:
%endmacro

compare eax, edx
%unmacro nome NÚMERO_DE_ARGUMENTOS
%if<condição>
  ; CĂłdigo 1
%elif<condição>
  ; CĂłdigo 2
%else
  ; CĂłdigo 3
%endif
CONST equ 5

%ifn CONST * 2 > 7
  call is_smallest
%else
  call is_bigger
%endif
%ifdef   nome_do_macro
%elifdef nome_do_macro
%ifmacro   nome_do_macro
%elifmacro nome_do_macro
%error   "Mensagem de erro"
%warning "Mensagem de alerta"
%ifndef macro_importante
  %ifdef macro_substituto
    %warning "Macro importante nĂŁo foi definido"
  %else
    %error "Macro importante e o seu substituto nĂŁo foram definidos"
  %endif
%endif
%include "nome do arquivo.ext"
arquivo.asm
%ifndef _ARQUIVO_ASM
%define _ARQUIVO_ASM

; CĂłdigo aqui

%endif
manual de otimização da Intel
magic number
ver aqui
Linux System Call Table for x86 64
wrapper

Como usar este livro

Aprenda a aprender

Estou empregando neste livro uma tĂ©cnica de didĂĄtica que, suponho, serĂĄ mais eficiente para o aprendizado de Assembly. Como Assembly aborda uma sĂ©rie de conteĂșdos dos mais variados, tanto teĂłricos como prĂĄticos, pode ser muito difĂ­cil pegar tudo de uma vez. Vou explicar aqui minha didĂĄtica empregada, o porque disto e como vocĂȘ deve estudar o conteĂșdo para tirar maior proveito dela.

DidĂĄtica

Estou trabalhando o conteĂșdo do livro com base em dois princĂ­pios simples que eu costumo usar na hora que quero aprender algo. Esta Ă© uma didĂĄtica que funciona para mim e, espero, funcionarĂĄ para vocĂȘ tambĂ©m.

1. Ver para entender

No livro eu tento o mĂĄximo possĂ­vel abordar conteĂșdo que, com base no que jĂĄ foi explorado antes, possa ser testado para ver os resultados. Bem como tambĂ©m tento apresentar os resultados para nĂŁo deixar Ă  margem da imaginação.

Nosso cĂ©rebro tem o hĂĄbito de fabricar informaçÔes. Quando estudamos algo novo e nos falta informação sobre, costumamos nĂłs mesmos criarmos essas informaçÔes faltantes. Lacunas de conteĂșdo sĂŁo preenchidas com informaçÔes hipotĂ©ticas e, muitas vezes, de uma maneira quase irreversĂ­vel. Quando começamos a aprender algo costumamos ter muito viĂ©s ao conteĂșdo que consumimos primeiro. Se este viĂ©s for baseado em uma informação fabricada pela nossa mente ela vai nos induzir ao erro.

É devido a esse hĂĄbito que muita gente desenvolve uma superioridade ilusĂłria jĂĄ que onde nĂŁo deveria ter informação alguma tem informação fabricada. O que faz algumas pessoas crerem que "sabem muito" sobre o assunto. Esse Ă© o chamado efeito Dunning-Kruger.

2. Por quĂȘ?

Nada acontece por mågica, tudo tem um motivo e uma explicação. Infelizmente eu não sei todas as explicaçÔes do mundo e nem todos os motivos. Mas é muito importante se perguntar o porque das coisas constantemente. No decorrer do livro eu tento responder o måximo possível o porque das coisas.

Repare que este tĂłpico em si estĂĄ explicando alguns porquĂȘs.

Consumindo o conteĂșdo

Na hora de consumir o conteĂșdo deste livro lembre-se do que mencionei acima. Principalmente do nosso pĂ©ssimo hĂĄbito de fabricar informaçÔes, isso Ă© muito comum e todo mundo jĂĄ fez isso (e provavelmente sempre continuarĂĄ fazendo).

1. Aceite o que vocĂȘ nĂŁo sabe

O que eu nĂŁo explicar e vocĂȘ nĂŁo souber tente evitar o hĂĄbito de fabricar informaçÔes se baseando em hipĂłteses infundadas, aceite que vocĂȘ nĂŁo sabe e pesquise Ă  respeito ou continue lendo o livro, talvez eu sĂł tenha deixado a resposta para depois devido a achar que seria muita informação de uma sĂł vez. O mais importante Ă© fixar na mente que vocĂȘ nĂŁo sabe.

Um treinamento bacana para diminuir esse pĂ©ssimo hĂĄbito Ă© simplesmente responder "nĂŁo sei" para perguntas que vocĂȘ nĂŁo sabe a resposta. Parece bobo, mas quantas vezes nĂŁo lhe perguntaram algo que vocĂȘ nĂŁo sabia e, ao invĂ©s de dizer "nĂŁo sei", vocĂȘ inventou uma resposta na hora?

Isso é o håbito de fabricar informaçÔes controlando sua mente, é assim que ele funciona só que na maioria das vezes não é outra pessoa perguntando mas sim nós mesmos.

2. NĂŁo decore, aprenda

Lembre-se de sempre se perguntar o porque das coisas. Se vocĂȘ sabe o que fazer mas nĂŁo sabe o porque, entĂŁo vocĂȘ nĂŁo aprendeu mas sim decorou. Decorar Ă© o pior inimigo do aprendizado. Quando vocĂȘ entende o porque das coisas vocĂȘ nĂŁo precisa ficar decorando, a informação vai naturalmente para a sua memĂłria de longo prazo. Detalhe que com "decorar" eu quero dizer a tentativa repetitiva de memorizar uma informação sem entendĂȘ-la, claro que lembrar das coisas Ă© necessĂĄrio para o aprendizado. Quer testar se vocĂȘ decorou ou aprendeu? É sĂł vocĂȘ tentar explicar o porque tal coisa Ă© de tal forma. Se nĂŁo conseguir Ă© porque nĂŁo aprendeu.

JĂĄ teve ou tem muita dificuldade em aprender algo? Provavelmente Ă© porque tem muitos "por quĂȘ?" nĂŁo respondidos. VocĂȘ deve estar tentando decorar mas nĂŁo estĂĄ conseguindo porque Ă© muita informação. Que tal se perguntar alguns "por quĂȘ?" e depois procurar pelas respostas?

3. SĂł avance depois que aprender

Como eu jĂĄ disse estou tentando ao mĂĄximo passar informação de forma que vocĂȘ possa testar. É importante que vocĂȘ faça testes para poder compreender melhor a informação. No fim de cada assunto explicado, e apĂłs fazer alguns testes, tente explicar o conteĂșdo para si mesmo ou para outra pessoa. Se vocĂȘ nĂŁo conseguir fazer isso Ă© porque ainda nĂŁo entendeu muito bem. Evite avançar no conteĂșdo sem ter entendido o anterior.

NĂŁo tenha pressa

A pressa é a inimiga da perfeição. ~ Joãozinho

O mundo nĂŁo vai acabar na semana que vem, eu acho. NĂŁo tem porque vocĂȘ ter pressa de aprender tudo em uma semana ou mĂȘs. Como eu jĂĄ disse, sĂł avance para o prĂłximo tĂłpico depois que aprender o anterior. Fique uma semana no mesmo tĂłpico se for necessĂĄrio.

Faça testes, estude a partir de outras fontes, pesquise no Google, tente fazer coisas para ver o que acontece, se pergunte o porquĂȘ. Tudo isso demora mas Ă© necessĂĄrio para ter uma compreensĂŁo profunda do que estĂĄ estudando.

E lembre-se: VocĂȘ nĂŁo precisa ser capaz de escrever um "Hello World" em uma semana, ou estudar mais de um tĂłpico por dia e nem terminar tudo em menos de 1 mĂȘs.

Isso aqui nĂŁo Ă© absoluto

Este nĂŁo Ă© "o guia absoluto do Assembly x86". Procure por mais conteĂșdo por fora, use o Google, leia livros, pergunte para outras pessoas. Lembre-se que alguma coisa aqui pode e vai estar errada. É importante ter fontes diversas de informaçÔes para traçar dados e tirar suas prĂłprias conclusĂ”es.

Vou destacar isto: Tire suas prĂłprias conclusĂ”es, pense por conta prĂłpria. Nunca absorva nenhum conteĂșdo como se fosse uma verdade absoluta. Questione, pesquise, verifique e teste. E se vocĂȘ achar que algo aqui estĂĄ errado, fique Ă  vontade para corrigir o autor ou simplesmente discordar dele em discussĂ”es por aĂ­.

Discuta

Como eu jĂĄ disse nĂŁo existe fonte absoluta de informação. A Ășnica maneira de se ter mais segurança sobre uma determinada informação Ă© discutindo sobre ela. Entre em alguns fĂłruns e grupos, questione o que as pessoas falam e dĂȘ a oportunidade para que elas questionem o que vocĂȘ fala tambĂ©m. NĂŁo aceite nada como verdade absoluta e nem dĂȘ nada como verdade absoluta.

Isso Ă© muito importante porque assim vocĂȘ irĂĄ encontrar pontos de vista opostos ao seu, isso irĂĄ abranger seu conhecimento e lhe dar a oportunidade para reavaliar as informaçÔes, para que assim, tire mais uma vez suas prĂłprias conclusĂ”es. Ter um sĂł ponto de vista Ă© horrĂ­vel. AlĂ©m de ter uma enorme chance de vocĂȘ estar errado a sua intolerĂąncia Ă  discordĂąncia alheia vai estar lĂĄ no alto.

Ou seja, discutir nĂŁo Ă© apenas benĂ©fico para vocĂȘ mas tambĂ©m para as pessoas a sua volta. Porque ninguĂ©m merece aquele tipo de pessoa que, no menor sinal de discordĂąncia, jĂĄ estufa o peito e bate na mesa para mostrar quem Ă© que manda.

RevisĂŁo

Entenda tudo o que viu aqui

As instruçÔes de Assembly por si só na verdade é bem simples, como jå vimos antes a sintaxe de uma instrução é bem fåcil e entender o que ela faz também não é o maior segredo do mundo.

Porém como também jå vimos, para de fato ter conhecimento adequado da linguagem é necessårio aprender muita coisa e talvez esse conhecimento variado tenha ficado disperso na sua mente (e olha que só aprendemos um pouco). A ideia desse tópico é juntar tudo e mostrar como e porque estå relacionado à Assembly.

O que estamos fazendo?

Programar em uma linguagem de baixo nível como Assembly não é a mesma coisa de programar em uma linguagem de alto nível como C. Ao programar em Assembly estamos escrevendo diretamente as instruçÔes que serão executadas pelo processador.

Não apenas isso como também estamos organizando todo o formato do arquivo de acordo com o formato final que queremos executar. Então é importante entender duas coisas, antes de mais nada: A arquitetura para a qual estamos programando e o formato de arquivo que queremos escrever.

A arquitetura é a x86, como jå sabemos. E um código que irå trabalhar com a linguagem C é compilado para um arquivo objeto. Por isso estudamos os conceitos båsicos da arquitetura x86 propriamente dita, e também estudamos um pouco do arquivo objeto.

Sem saber o que são seçÔes, o que å symbol table etc. não då para entender o que se estå fazendo. Por que o código em C consegue acessar um rótulo no meu código em Assembly? Por que dados despejados em .data são chamados de variåveis e os em .text são chamados de código? Por que dados em .rodata não podem ser modificados e são chamados de constantes? Por que "isso" é considerado uma função e "isso" uma variåvel? Os dois não são símbolos?

Ao programar em Assembly nós não estamos apenas escrevendo as instruçÔes que o processador irå executar, estamos também construindo todo o arquivo binårio final manualmente.Felizmente o NASM facilita nossa vida ao formatar o arquivo binårio para o formato desejado, é a tal da opção -f elf64 ou -f win64 que passamos na linha de comando. Mas mesmo assim temos que dar informaçÔes para o NASM sobre o que fica aonde.

Por que estamos fazendo isso?

Em uma linguagem de alto nível todos esse conceitos relacionados ao formato do arquivo binårio e da arquitetura do processador são abstraídos. Jå em uma linguagem de baixo nível, esses conceitos tem muito pouca (ou nenhuma) abstração e precisamos lidar com eles manualmente.

Isso é necessårio porque estamos escrevendo diretamente as instruçÔes que o hardware, o processador, irå executar. E para poder se comunicar com o processador precisamos entender o que ele estå fazendo.

Imagine tentar instruir um funcionĂĄrio de uma empresa de entregas exatamente como ele deve organizar a carga e como ele deve entregĂĄ-la, porĂ©m vocĂȘ nĂŁo sabe o que Ă© a carga e nem para quem ela deve ser entregue. ImpossĂ­vel, nĂ©?

Isso Ă© Ăștil por quĂȘ?

Estudar Assembly nĂŁo Ă© sĂł decorar instruçÔes e o que elas fazem, isso Ă© fĂĄcil atĂ© demais. Estudar Assembly Ă© estudar a arquitetura, o formato do executĂĄvel, como o executĂĄvel funciona, convençÔes de chamadas, caracterĂ­sticas do sistema operacional, caracterĂ­sticas do hardware etc... Ah, e estudar as instruçÔes tambĂ©m. Junte tudo isso e vocĂȘ terĂĄ um belo conhecimento para entender como um software funciona na prĂĄtica.

JĂĄ citei antes porque estudar Assembly Ă© Ăștil na introdução do livro. Mas sĂł decorar as instruçÔes nĂŁo Ă© Ăștil por si sĂł, a questĂŁo Ă© todo o resto que vocĂȘ irĂĄ aprender ao estudar Assembly.

O que Ă© um cĂłdigo fonte em Assembly?

Como jĂĄ vimos o assembler Ă© bem mais complexo do que simplesmente converter as instruçÔes que vocĂȘ escreve em cĂłdigo de mĂĄquina. Ele tem diretivas, pseudo-instruçÔes e prĂ©-processamento de cĂłdigo para formatar o cĂłdigo em Assembly e lhe dar mais poder na programação.

Ele também formata o código de saída para um formato especificado e nos permite escolher o modo de compilação das instruçÔes de 64, 32 ou 16 bits.

Ou seja, um código fonte em Assembly não é apenas instruçÔes mas também diretivas para a formatação do arquivo binårio que serå feita pelo assembler. Que é muito diferente de uma linguagem de alto nível como C que contém apenas instruçÔes e todo o resto fica abstraído como se nem existisse.

Atributos

Explicando os atributos das instruçÔes da arquitetura x86.

VocĂȘ jĂĄ deve ter reparado que as instruçÔes tĂȘm mais informaçÔes do que nĂłs explicitamos nelas. Por exemplo a instrução mov eax, [0x100] implicitamente acessa a memĂłria a partir do segmento DS, alĂ©m de que magicamente a instrução tem um tamanho especĂ­fico de operando sem que a gente diga a ela.

Todas essas informaçÔes implĂ­citas da instrução sĂŁo especificadas a partir de atributos que tem determinados valores padrĂ”es que podem ser modificados. Os trĂȘs atributos mais importantes para a gente entender Ă© o operand-size, address-size e segment.

Operand-size

Em protected mode nós podemos acessar operandos de 32, 16 ou 8 bits. O que define o tamanho do operando na instrução é o atributo operand-size.

InstruçÔes que lidam com operandos de 8 bits tem opcodes próprios só para eles. Mas as instruçÔes que lidam com operandos de 16 e 32 são as mesmas instruçÔes, mudando somente o atributo operand-size.

Vamos fazer um experimento com o cĂłdigo abaixo:

tst.asm
bits 32

mov ah,  bh
mov eax, ebx

Compile esse código sem especificar qualquer formatação para o NASM, assim ele irå apenas colocar na saída as instruçÔes que escrevemos:

$ nasm tst.asm -o tst

Depois disso use o ndisasm especificando para desmontar instruçÔes como de 32 bits, e depois, como de 16 bits. A saída ficarå como no print abaixo:

Repare que tanto em 32 quanto 16 bits a instrução mov ah, bh não muda. Porém as instruçÔes mov eax, ebx e mov ax, bx são a mesma instrução.

Só o que muda de um para outro é o operand-size. Enquanto em 32-bit por padrão o operand-size é de 32 bits, em 16-bit ele é de 16-bit. Por isso que se dizemos para o disassembler que as instruçÔes são de 16-bit ele desmonta a instrução como mov ax, bx. Porque é de fato essa operação que o processador em modo de 16-bit iria executar, não é um erro do disassembler.

E isso não vale só para registradores mas também para operandos imediatos e operandos em memória. Vamos fazer outro experimento:

tst.asm
bits 32

mov eax, 0x11223344

Os comandos:

$ nasm tst.asm -o tst
$ ndisasm -b32 tst
$ ndisasm -b16 tst

A saĂ­da fica assim:

Entendendo melhor a saĂ­da do ndisasm:

  • A esquerda fica o raw address da instrução em hexadecimal, que Ă© um nome bonitinho para o Ă­ndice do primeiro byte da instrução dentro do arquivo (contando a partir de 0).

  • No centro fica o cĂłdigo de mĂĄquina em hexadecimal. Os bytes sĂŁo mostrados na mesma ordem em que estĂŁo no arquivo binĂĄrio.

  • Por fim a direita o disassembly das instruçÔes.

Repare que quando dizemos para o ndisasm que as instruçÔes são de 32-bit ele faz o disassembly correto e mostra mov eax, 0x11223344. Porém quando dizemos que é de 16-bit ele desmonta mov ax, 0x3344 seguido de uma instrução que não tem nada a ver com o que a gente escreveu.

Se vocĂȘ prestar atenção no cĂłdigo de mĂĄquina vai notar que nosso operando imediato 0x11223344 estĂĄ bem ali em little-endian logo apĂłs o byte B8 (o opcode). Porque Ă© assim que operandos imediatos sĂŁo dispostos no cĂłdigo de mĂĄquina, o valor imediato faz parte da instrução.

Agora no segundo caso quando dizemos que são instruçÔes de 16-bit a instrução não espera um operando de 4 bytes mas sim 2 bytes. Por isso o disassembler considera isto aqui como a instrução:

B8 44 33

Os bytes 22 11 ficam sobrando e acabam sendo desmontados como se fossem uma instrução diferente. Na pråtica o processador também executaria o código da mesma maneira que o ndisasm o desmontou, um dos motivos do porque código de modos de processamento diferentes não são compatíveis entre si.

Em 64-bit o operand-size também tem 32 bits de tamanho por padrão.

Address-size

O atributo de address-size define o modo de endereçamento. O tamanho padrão do offset acompanha a largura do barramento interno do processador (ou o tamanho do Instruction Pointer).

Quando o processador estå em modo de 16-bit pode-se usar endereçamento de 16 ou 32 bits. O mesmo vale para modo de 32-bit onde se usa por padrão 32 bits de endereçamento mas då para usar modo de endereçamento de 16 bits.

Jå em 64-bit o address-size é de 64 bits por padrão, mas também é possível usar endereçamento de 32 bits.

Apesar do offset e RIP no submodo de 64-bit serem de 64 bits (8 bytes) de tamanho, na pråtica o barramento de endereço do processador tem apenas 48 bits (6 bytes) de tamanho.

Os dois bytes mais significativos de RIP não são usados e devem sempre estarem zerados. Endereços acima de 0x0000FFFFFFFFFFFF não são vålidos em x86-64.

Mas o atributo não muda somente o tamanho do offset mas todo ele devido ao fato de haver diferenças entre o modo de endereçamento de 16-bit e de 32-bit. Observe o disassembly no print:

Agora observe a instrução mov byte [ebx], 42 compilada para 32-bit:

Desta vez a diferença entre 32-bit e 64-bit foi unicamente relacionado ao tamanho. Mas agora um Ășltimo experimento: mov byte [r12], 42. Desta vez com um registrador que nĂŁo existe uma versĂŁo menor em 32-bit.

Existem duas diferenças: o registrador mudou para ESP e um byte 41 ficou sobrando antes da instrução. Dando um pouco de spoiler do próximo tópico do livro, o byte que sobrou ali é o prefixo REX que não existe em 32-bit e por isso foi interpretado como outra instrução.

Segment

Registrador base

Segmento

RIP

CS

SP/ESP/RSP

SS

BP/EBP/RBP

SS

Qualquer outro registrador

DS

Exemplos:

mov eax, [rbx]  ; LĂȘ do endereço DS:RBX
mov eax, [rbp]  ; LĂȘ do endereço SS:RBP

Determinadas instruçÔes usam segmentos específicos, como é o caso da movsb. Onde ela acessa DS:RSI e ES:RDI.

Aprofundando em Assembly

Aprendendo mais um pouco

Agora temos conhecimento o bastante para entender como um cĂłdigo em Assembly funciona e porque Ă© importante estudar diversos assuntos relacionados ao sistema operacional, formato do binĂĄrio e a arquitetura em si para poder programar em Assembly.

Mas vimos tudo isso com cĂłdigo rodando sobre um sistema operacional em submodo 64-bit. A ideia desta parte do livro Ă© focar menos nas caracterĂ­sticas do sistema operacional e mais nas caracterĂ­sticas da prĂłpria arquitetura. Para isso vamos testar cĂłdigo de 64, 32 e 16 bit.

Ferramentas

TambĂ©m Ă© importante que o seu GCC possa compilar cĂłdigo para 64 e 32-bit. Em um Linux x86-64 ao instalar o GCC vocĂȘ jĂĄ pode compilar cĂłdigo de 64-bit. Para compilar para 32-bit basta instalar o pacote gcc-multilib. No Debian vocĂȘ pode fazer:

$ sudo apt install gcc-multilib

Para testar se estĂĄ funcionando adequadamente vocĂȘ pode passar para o GCC a opção -m32 para compilar para 32-bit. Tente compilar um "Hello World" em C e veja se funciona:

$ gcc hello.c -o hello -m32

Neste capĂ­tulo usaremos tambĂ©m uma ferramenta que vem junto com o NASM, o ndisasm. Ele Ă© um disassembler, um software que converte cĂłdigo de mĂĄquina em cĂłdigo Assembly. Se vocĂȘ tem o NASM instalado tambĂ©m tem o ndisasm disponĂ­vel.

O uso båsico é só especificar se as instruçÔes devem ser desmontadas como instruçÔes de 16, 32 ou 64 bits. Por padrão ele desmonta as instruçÔes como de 16-bit. Para mudar isso basta usar a opção -b e especificar os bits. Exemplo:

$ ndisasm -b32 binary

CALL e RET

Entendendo detalhadamente as instruçÔes CALL e RET.

Quando se trata de chamadas de procedimentos existem dois conceitos relacionados ao endereço deste procedimento.

O primeiro conceito é que existem chamadas "próximas" (near) e "distantes" (far). Enquanto no near call nós apenas especificamos o offset do endereço, no far call nós também especificamos o segmento.

O outro conceito Ă© o de endereço "relativo" (relative) e "absoluto" (absolute), que tambĂ©m se aplicam para saltos (jumps). Onde um endereço relativo Ă© basicamente um nĂșmero sinalizado que serĂĄ somado Ă  RIP quando o desvio de fluxo ocorrer. Enquanto o endereço absoluto Ă© um endereço exato que serĂĄ escrito no registrador RIP.

Tamanho do offset

O tamanho que o offset do endereço deve ter acompanha a largura do barramento interno. Então se estamos em real mode (16 bit), por padrão o offset deve ser de 16-bit. Ou seja, basicamente o mesmo tamanho do Instruction Pointer.

Near relative call

call rel16/rel32

Essa Ă© a call que jĂĄ usamos, nĂŁo tem segredo. Ela basicamente recebe um nĂșmero negativo ou positivo indicando o nĂșmero de bytes que devem ser desviados. Veja da seguinte forma:

Instruction_Pointer = Instruction_Pointer + operand

A matemåtica båsica nos diz que "mais com menos é menos", ou seja, se o operando for negativo essa soma resultarå em uma subtração.

Onde estĂĄ RIP?

Existe um detalhe bem simples porém importante para conseguir lidar com endereços relativos corretamente. Quando o processador for executar a instrução o Instruction Pointer jå estarå apontando para a instrução seguinte. Ou seja desvios de fluxo para trås precisam contar os bytes da própria instrução em si, enquanto os para frente começam contando em zero que jå é a instrução seguinte na memória.

Claro que esse cålculo não é feito por nós e sim pelo assembler, mas é importante saber. Ah, e lembra do símbolo $ que eu falei que o NASM expande para o endereço da instrução atual? Veja que ele não coincide com o valor de RIP, cujo o mesmo jå estå apontando para a instrução seguinte.

Por exemplo poderíamos fazer uma chamada na própria instrução gerando um loop "infinito" usando a sintaxe:

bits 64

call $

Experimente ver com o ndisasm como essa instrução fica em código de måquina:

O primeiro byte (0xE8) é o opcode da instrução, que é o byte do código de måquina que identifica a instrução que serå executada. Os bytes posteriores são o operando imediato (em little-endian). Repare que o endereço relativo estå como 0xFFFFFFFB que equivale a -5 em decimal.

Near absolute call

call r/m

Diferente da chamada relativa que indica um nĂșmero de bytes a serem somados com RIP, numa chamada absoluta vocĂȘ passa o endereço exato de onde vocĂȘ quer fazer a chamada. VocĂȘ pode experimentar fazer uma chamada assim:

mov  rax, rotulo
call rax

Se vocĂȘ passar rotulo para a call diretamente vocĂȘ estarĂĄ fazendo uma chamada relativa porque desse jeito vocĂȘ estarĂĄ passando um operando imediato. E a Ășnica call que recebe valor imediato Ă© a de endereço relativo, por isso o NASM passa o endereço relativo daquele rĂłtulo. Mas ao definir o endereço do rĂłtulo para um registrador ou memĂłria o assembler irĂĄ passar o endereço absoluto dele.

É importante entender que tipo de operando cada instrução recebe para evitar se confundir sobre como o assembler irĂĄ montar a instrução. E sim, saber como a instrução Ă© montada em cĂłdigo de mĂĄquina Ă© muitas vezes importante.

Far call

call seg16:off16   ; Em 16-bit
call seg16:off32   ; Em 32-bit

call mem16:16  ; Em 16-bit
call mem16:32  ; Em 32-bit
call mem16:64  ; Em 64-bit

As chamadas far (distante) sĂŁo todas absolutas e recebem no operando um valor seguindo o formato de especificar um offset seguido do segmento de 16-bit. No NASM um valor imediato pode ser passado da seguinte forma:

call 0x1234:0xabcdef99

Onde o valor à esquerda especifica o segmento e o da direita o deslocamento. Detalhe que essa instrução não é suportada em 64-bit.

O segundo tipo de far call, suportado em 64-bit, Ă© o que recebe como operando um valor na memĂłria. Mas perceba que temos um near call que recebe o mesmo tipo de argumento, nĂŁo Ă© mesmo?

Por padrĂŁo o NASM irĂĄ montar as instruçÔes como near e nĂŁo far mas vocĂȘ pode evitar essa ambiguidade explicitando com keywords do NASM que sĂŁo bem intuitivas. Veja:

call [rbx]       ; PrĂłximo e absoluto
call near [rbx]  ; PrĂłximo e absoluto
call far [rbx]   ; Distante

O near espera o endereço do offset na memória, não tem segredo. Mas o far espera o offset seguido do segmento. Em um sistema de 32-bit vamos supor que nosso procedimento estå no segmento 0xaaaa e no offset 0xbbbb1111. Em memória o valor precisa estar assim em little-endian:

11 11 bb bb aa aa

No NASM essa variĂĄvel poderia ser dumpada da seguinte forma:

bits 32

my_addr: dd 0xbbbb1111   ; Deslocamento
         dw 0xaaaa       ; Segmento

; E usada assim:
call far [my_addr]

Basicamente o far call modifica o valor de CS e IP ao mesmo tempo, enquanto o near call apenas modifica o valor de IP.

No código de måquina a diferença entre o far e o near call que usam o operando em memória estå no campo REG do byte ModR/M. O near tem o valor 2 e o far tem o valor 3. O opcode é 0xFF.

Se vocĂȘ nĂŁo entendeu isso aqui, nĂŁo se preocupa com isso. Mais para frente no livro serĂĄ escrito um capĂ­tulo sĂł para explicar o cĂłdigo de mĂĄquina da arquitetura.

RET

ret
retf
retn
ret  imm16
retf imm16
retn imm16

Como talvez vocĂȘ jĂĄ tenha reparado intuitivamente a chamada far tambĂ©m preserva o valor de CS na stack e nĂŁo apenas o valor de IP (lembrando que IP jĂĄ estaria apontando para a instrução seguinte na memĂłria).

Por isso a instrução ret também precisa ser diferente dentro de um procedimento que serå chamado com um far call. Ao invés de apenas ler o offset na stack ela precisa ler o segmento também, assim modificando CS e IP do mesmo jeito que o call.

Repetindo que o NASM por padrão irå montar as instruçÔes como near então precisamos especificar para o NASM, em um procedimento que deve ser chamado como far, que queremos usar um ret far. Para isso podemos simplesmente adicionar um sufixo 'n' para especificar como near, que jå é o padrão, ou o sufixo 'f' para especificar como far. Ficando:

retf  ; Usado em procedimentos que devem ser chamados com far call

Existe tambĂ©m uma outra opção de instrução ret que recebe como operando um valor imediato de 16-bit que especifica um nĂșmero de bytes a serem desempilhados da stack.

Basicamente o que ele faz Ă© somar o valor de SP com esse nĂșmero, porque como sabemos a pilha cresce "para baixo". Ou seja se subtraĂ­mos valor em SP estamos fazendo a pilha crescer, se somamos estamos fazendo ela diminuir. Por exemplo, podemos escrever em pseudo-cĂłdigo a instrução retf 12 da seguinte forma:

pseudo.c
RIP = pop();
CS  = pop();
RSP = RSP + 12;

Registradores de segmento

Segmentação da memória RAM.

Na arquitetura x86 o acesso a memória RAM é comumente dividido em segmentos. Um segmento de memória nada mais é que um pedaço da memória RAM que o programador usa dando algum sentido a ele. Por exemplo, podemos usar um segmento só para armazenar variåveis. E usar outro para armazenar o código executado pelo processador.

Barramento de endereço

O barramento de endereço (address bus) é um socket do processador que serve para se comunicar com a memória principal (memória RAM), ele indica o endereço físico na memória principal de onde o processador quer ler ou escrever dados. Basicamente a largura desse barramento indica quanta memória o processador consegue endereçar jå que ele indica o endereço físico da memória que se deseja acessar.

Em IA-32 e x86-64 o barramento de endereço tem a largura de 32 e 48 bits respectivamente.

Segmentação em IA-16

Em IA-16 a segmentação é bem simplista e o código trabalha basicamente com 4 segmentos simultaneamente. Esses segmentos são definidos simplesmente alterando o registrador de segmento equivalente, cujo eles são:

Registrador

Nome

CS

Code Segment / Segmento de cĂłdigo

DS

Data Segment / Segmento de dado

ES

Extra Segment / Segmento extra

SS

Stack Segment / Segmento da pilha

Cada um desses registradores tem 16 bits de tamanho.

Quando acessamos um endereço na memória estamos usando um endereço lógico que é a junção de um segmento (segment) e um deslocamento (offset), seguindo o formato: segment:offset

O tamanho do valor de offset Ă© o mesmo tamanho do registrador IP/EIP/RIP.

Veja por exemplo a instrução:

mov [0x100], ax

O endereçamento definido pelos colchetes é na verdade o offset que, juntamente com o registrador DS, se obtém o endereço físico. Ou seja o endereço lógico é DS:0x100.

Podemos especificar um segmento diferente com a seguinte sintaxe do NASM:

; O nome deste recurso Ă© "segment override"
; Ou em PT-BR: Substituição do segmento

mov [es:0x100], ax

; OU alternativamente:

es mov [0x100], ax

A conversão de endereço lógico para endereço físico é feita pelo processador com um cålculo simples:

endereço_físico = (segmento << 4) + deslocamento

O operador << denota um deslocamento de bits para a esquerda, uma operação shift left.

Segmentação em IA-32

Além dos registradores de segmento do IA-16, em IA-32 se ganha mais dois registradores de segmento: FS e GS.

Em protected mode os registradores de segmento não são usados para gerar um endereço lógico junto com o offset, ao invés disso, serve de seletor identificando o segmento por um índice em uma tabela que lista os segmentos.

Segmentação em x86-64

Em x86-64 não é mais usado esse esquema de segmentação de memória. CS, DS, ES e SS são tratados como se o endereço base fosse zero independentemente do valor nesses registradores.

Position-independent executable

Explicando PIE e ASLR

PorĂ©m um cĂłdigo que acessa endereços absolutos jamais funcionaria apropriadamente com o ASLR ligado. É aĂ­ que entra o conceito de Position-independent executable (PIE) que nada mais Ă© que um executĂĄvel com cĂłdigo que somente acessa endereços relativos, ou seja, nĂŁo importa em qual endereço (posição) vocĂȘ carregue o cĂłdigo do executĂĄvel ele irĂĄ funcionar corretamente.

Na nossa PoC eu instruí para compilar o programa usando a flag -no-pie no GCC para garantir que o linker não iria produzir um executåvel PIE jå que ainda não havíamos aprendido sobre o assunto. Mas depois de aprender a escrever código com endereçamento relativo em Assembly fique à vontade para remover essa flag e começar a escrever programas independentes de posição.

PIE em x86-64

Podemos usar a palavra-chave rel no endereçamento para dizer para o NASM que queremos que ele acesse um endereço relativo à RIP. Conforme exemplo:

mov rax, [rel my_var]

TambĂ©m podemos usar a diretiva default rel para que o NASM compile todos os endereçamentos como relativos por padrĂŁo. Caso vocĂȘ defina o padrĂŁo como endereço relativo a palavra-chave abs pode ser usada da mesma maneira que a palavra-chave rel porĂ©m para definir o endereçamento como absoluto.

Um exemplo de PIE em modo de 64-bit:

#include <stdio.h>

char *assembly(void);

int main(void)
{
  printf("Resultado: %s\n", assembly());
  return 0;
}
bits 64
default rel

section .rodata
    msg: db "Hello World!", 0

section .text

global assembly
assembly:
    lea rax, [msg]
    ret

Experimente compilar sem a flag -no-pie para o GCC na hora de linkar:

$ nasm assembly.asm -o assembly.o -felf64
$ gcc main.c -c -o main.o
$ gcc *.o -o test

Deveria funcionar normalmente. Mas experimente comentar a diretiva default rel na linha 2 e compilar novamente, vocĂȘ vai obter um erro parecido com esse:

Repare que o erro foi emitido pelo linker (ld) e não pelo compilador em si. Acontece que como usamos um endereço absoluto o NASM colocou o endereço do símbolo msg na relocation table para ser resolvido pelo linker, onde o linker é quem definiria o endereço absoluto do mesmo.

SĂł que como removemos o -no-pie o linker tentou produzir um PIE e por isso emitiu um erro avisando que aquela referĂȘncia para um endereço absoluto nĂŁo pode ser usada.

PIE em IA-32

Como o endereço relativo ao Instruction Pointer sĂł existe em modo de 64-bit, nos outros modos de processamento nĂŁo Ă© nativamente possĂ­vel obter um endereçamento relativo. O compilador GCC resolve esse problema criando um pequeno procedimento cujo o Ășnico intuito Ă© obter o valor no topo da pilha e armazenar em um registrador. Conforme ilustração abaixo:

funcao:
    call __x86.get_pc_thunk.bx
    add ebx, 12345  ; Soma EBX com o endereço relativo 12345
    ; ...

__x86.get_pc_thunk.bx:
    mov ebx, [esp]
    ret

Quando a instrução add ebx, 12345 é executada o valor de EBX coincide com o endereço da própria instrução ADD.

Noção geral da arquitetura

Noção geral da arquitetura x86

Antes de ver a linguagem Assembly em si Ă© importante ter conhecimento sobre a arquitetura do Assembly que vamos estudar, atĂ© porque estĂŁo intrinsecamente ligados. É claro que nĂŁo dĂĄ para explicar todas as caracterĂ­sticas da arquitetura x86 aqui, sĂł para te dar uma noção o manual para desenvolvedores da Intel tem mais de 5 mil pĂĄginas. Mas por enquanto vamos ter apenas uma noção sobre a arquitetura x86 para entender melhor Ă  respeito da mesma.

O que Ă© a arquitetura x86?

Essa arquitetura nasceu no 8086, que foi um microprocessador da Intel que fez grande sucesso. DaĂ­ em diante a Intel lançou outros processadores baseados na arquitetura do 8086 ganhando nomes como: 80186, 80286, 80386 etc. DaĂ­ surgiu a nomenclatura 80x86 onde o x representaria um nĂșmero qualquer, e depois a nomenclatura foi abreviada para apenas x86.

A arquitetura evoluiu com o tempo e foi ganhando adiçÔes de tecnologias, porĂ©m sempre mantendo compatibilidade com os processadores anteriores. O processador que vocĂȘ tem aĂ­ pode rodar cĂłdigo programado para o 8086 sem problema algum.

Mais para frente a AMD criou a arquitetura x86-64, que é um superconjunto da arquitetura x86 da Intel e adiciona o modo de 64 bit. Nos dias atuais a Intel e a AMD fazem um trabalho em conjunto para a evolução da arquitetura, por isso os processadores das duas fabricantes são compatíveis.

Ou seja, x86 Ă© um nome genĂ©rico para se referir a uma famĂ­lia de arquiteturas de processadores. Por motivos de simplicidade eu vou me referir as arquiteturas apenas como x86, mas na prĂĄtica estamos abordando trĂȘs arquiteturas neste livro:

AMD64 e Intel64 são os nomes das implementaçÔes da AMD e da Intel para a arquitetura x86-64, respectivamente. Podemos dizer aqui que são sinÎnimos jå que as implementaçÔes são compatíveis. Um software compilado para x86 consegue tanto rodar em um processador Intel como também AMD. Só fazendo diferença é claro em detalhes de otimização que são específicos para determinados processadores. Bem como também algumas tecnologias exclusivas de cada uma das fabricantes.

Endianness

A arquitetura x86 é little-endian, o que significa que a ordem dos bytes de valores numéricos segue do menos significativo ao mais significativo. Por exemplo o seguinte valor numérico em hexadecimal 0x1a2b3c4d ficaria disposto na memória RAM na seguinte ordem:

InstruçÔes

Cada instrução do cĂłdigo de mĂĄquina tem um tamanho que pode variar de 1 atĂ© 15 bytes. E cada instrução consome um nĂșmero de ciclos diferente (devido a sua complexidade variada).

Modelo

As instruçÔes podem trabalhar manipulando/lendo dados em registradores que são pequenas åreas de memória internas à CPU. E também pode manipular dados na memória principal que no caso é a memória RAM. Bem como também usar o sistema de entrada e saída de dados, feito pelas portas físicas.

O registrador Program Counter no diagrama acima armazena o endereço da próxima instrução que serå executada na memória principal. Na arquitetura x86 esse registrador é chamado de Instruction Pointer.

Portas fĂ­sicas

Do ponto de vista do programador uma porta fĂ­sica Ă© sĂł um nĂșmero especificado na instrução, muito parecido com uma porta lĂłgica usada para comunicação em rede.

FPU

Na época do 8086 a Intel também lançou o chamado 8087, que é um co-processador de ponto flutuante que trabalhava em conjunto com o 8086. Os processadores seguintes também ganharam co-processadores que receberam o nome genérico de x87. A partir do 80486 a FPU é interna a CPU e não mais um co-processador, porém por motivos históricos ainda chamamos a unidade de ponto flutuante da arquitetura x86 de x87.

FPU nada mais Ă© que a unidade de processamento responsĂĄvel por fazer cĂĄlculos de ponto flutuante, os famosos nĂșmeros float.

Outras tecnologias

Quem dera um processador fosse tĂŁo simples assim, jĂĄ mencionei que o manual da Intel tem mais de 5 mil pĂĄginas? Deixei de abordar muita coisa aqui mas que fique claro que os processadores da arquitetura x86 tem vĂĄrias outras tecnologias, como o 3DNow! da AMD e o SSE da Intel.

Os processadores da AMD também implementam o SSE, jå o 3DNow! é exclusivo dos processadores da AMD.

Prefixos

Modificando os atributos da operação.

O código de måquina pode receber alguns bytes que antecedem o opcode que são chamados de prefixos. Eles basicamente servem para modificar atributos da operação que serå executada pelo processador. Abaixo vou falar de alguns prefixos e explicar o que eles fazem.

Operand-size override

Esse prefixo, cujo o byte Ă© 0x66, serve para sobrescrever o atributo de operand-size. Ele basicamente alterna o atributo para o seu valor nĂŁo-padrĂŁo. Se o operand-size padrĂŁo Ă© de 32 bits ao usar esse prefixo ele alterna para 16 bits, e vice-versa. Observe abaixo:

No primeiro disassembly se a gente prestar atenção no cĂłdigo de mĂĄquina irĂĄ notar que a Ășnica diferença entre as duas instruçÔes, alĂ©m do tamanho do operando imediato, Ă© a presença do byte 0x66 logo antes do opcode 0xB8.

O NASM se encarrega de usar os prefixos adequados quando se mostram necessårios. Porém podemos usar as diretivas o16, o32 e o64 antes da instrução no NASM para "forçar" o tamanho do operand-size para 16, 32 ou 64 bits respectivamente. Desta forma o NASM usaria os prefixos corretos se fossem necessårios.

É importante entender o que a instrução faz e o que cada atributo representa nela para poder fazer o uso correto destas diretivas.

Se vocĂȘ quiser forçar o uso de um prefixo em uma determinada instrução basta fazer o dump do byte logo antes da mesma. Exemplo: db 0x66 mov eax, ebx

Obs.: Isso Ă© gambiarra. SĂł mostrei como curiosidade.

Address-size override

Esse prefixo de byte 0x67 segue a mesma lógica do anterior, só que desta vez alternando o tamanho do atributo de address-size. O NASM tem as diretivas a16, a32 e a64 para explicitar um address-size para a instrução.

Um exemplo interessante de uso é com a instrução LOOP/LOOPcc. Acontece que o que determina se essa instrução irå usar RCX, ECX ou CX é o address-size. Vamos supor o código de 16-bit:

Ao adicionar o prefixo 0x67 à instrução loop eu sobrescrevo o address-size para 32 bits e faço a instrução usar o registrador ECX ao invés de CX. Me permitindo assim efetuar loops mais longos do que supostamente sou limitado.

E se por acaso eu compilar essa instrução para 32-bit, então o prefixo não serå adicionado pelo NASM e ECX ainda serå usado de qualquer forma.

Cuidado ao usar a64 ou o64. Essa diretivas demandam o uso do prefixo REX que sĂł existe em submodo de 64-bit.

Segment override

Esse nĂŁo Ă© um mas sim 6 prefixos diferentes usados para fazer a sobrescrita do segmento para CS, SS, DS, ES, FS ou GS.

Por que vocĂȘ nĂŁo tenta usar cada um desses prefixos para ver qual byte eles adicionam no cĂłdigo de mĂĄquina?

REX

VocĂȘ jĂĄ deve ter notado que dĂĄ para brincar entre 32 e 16 bits, mas e os 64 bits? Bom, acontece que para tornar o x86-64 possĂ­vel foram feitas algumas gambiarras adaptaçÔes no machine code da arquitetura.

Veja este cĂłdigo:

Agora veja o que o disasembler nos diz sobre isso aĂ­:

Pois é, os bytes que eu fiz o dump manualmente resultam na mesma operação. Só que o NASM sempre usa a primeira versão porque é menor, só tem 1 byte de tamanho em contraste com os 2 bytes da outra.

Essas duas instruçÔes equivalentes basicamente são:

Se eu escrevesse inc dword [ebx] aí sim o NASM usaria a segunda instrução porém para incrementar um operando em memória.

Em 64-bit as instruçÔes inc reg e dec reg simplesmente não existem. Elas foram assassinadas para dar lugar para um novo prefixo, o REX (inc r/m e dec r/m são usadas em seu lugar).

O REX tem um campo de 4 bits que serve para trabalhar com operaçÔes em versão de 64 bits. Todas as alternùncias em relação a 32/64 bits é feita em um dos bits do prefixo REX, onde cada bit tem uma função diferente.

Basicamente o REX, incluindo todas as variaçÔes de combinaçÔes de cada bit, são todos os bytes entre 0x40 e 0x4F (só em 64-bit, é claro). Vejamos o exemplo:

Veja que para fazer o incremento de RCX o prefixo REX 0x48 foi utilizado. Em 32-bit esse byte foi interpretado como dec eax.

REP/REPE/REPNE

InstruçÔes relacionadas a operaçÔes com blocos de dados, as famosas strings, podem ser acompanhadas por um prefixo para fazer com que a instrução seja repetida vårias vezes.

O uso desse prefixo é basicamente seguindo a mesma lógica das instruçÔes LOOP/LOOPE/LOOPNE que usa uma parte do mapeamento de RCX como contador e é possível usar uma condição extra para só repetir se a comparação der igual ou não igual.

Também é possível sobrescrever address-size para mudar o registrador usado como contador. Observe um exemplo de reimplementação de strlen() usando esse prefixo e a instrução scasb, tente entender o código:

REP e REPE são nomes diferentes para o mesmo prefixo. Sua lógica muda dependendo de em qual instrução foi utilizada, se em uma que faz comparação ou não.

InstruçÔes condicionais

Entendendo as instruçÔes condicionais e as status flags.

As instruçÔes condicionais basicamente avaliam as status flags para executar uma operação apenas se a condição for atendida. Existem condiçÔes que testam o valor de mais de uma flag em combinação para casos diferentes.

A nomenclatura de escrita de uma instrução condicional é o seu nome seguido de um 'cc' que é sigla para conditional code. Abaixo uma tabela de códigos condicionais vålidos para as instruçÔes CMOVcc, SETcc e Jcc:

Os termos "abaixo" (below) e "acima" (above) usados na descrição se referem a verificação de um valor numérico não-sinalizado. Enquanto "maior" e "menor" é usado para se referir a um valor numérico sinalizado.

Exemplo:

Repare como alguns cc tĂȘm a mesma condição, como Ă© o caso de NE e NZ. Portanto JNE e JNZ sĂŁo exatamente a mesma instrução no cĂłdigo de mĂĄquina, somente mudando no Assembly.

JCXZ e JECXZ

AlĂ©m das condiçÔes acima existem mais trĂȘs Jcc que testam o valor do registrador CX, ECX e RCX respectivamente.

A Ășltima instrução, obviamente, somente existe em submodo de 64-bit. Enquanto JCXZ nĂŁo existe em 64-bit.

No código de måquina o opcode dessa instrução é 0xE3 e a alternùncia entre o tamanho do registrador é feita de acordo com o atributo address-size, sendo modificado pelo prefixo 0x67.

Flags do processador

Registrador EFLAGS e FLAGS.

O registrador EFLAGS contĂ©m flags que servem para indicar trĂȘs tipos de informaçÔes diferentes:

  • Status -- Indicam o resultado de uma operação aritmĂ©tica.

  • Control -- Controlam alguma caracterĂ­stica de execução do processador.

  • System -- Servem para configurar ou indicar alguma caracterĂ­stica do hardware relacionado a execução do cĂłdigo ou do sistema.

Status Flags

InstruçÔes que fazem operaçÔes aritméticas modificam as status flags conforme o valor do resultado da operação. São instruçÔes como ADD, SUB, MUL e DIV por exemplo.

Porém um detalhe que é interessante saber é que existem duas instruçÔes que normalmente são utilizadas para definir essas flags para serem usadas junto com uma instrução condicional. Elas são: CMP e TEST. A instrução CMP nada mais é do que uma instrução que faz a mesma operação aritmética de subtração que SUB porém sem modificar o valor dos operandos.

Enquanto TEST faz uma operação bitwise AND (E bit a bit) também sem modificar os operandos. Ou seja, o mesmo que a instrução AND. Veja a tabela abaixo com todas as status flags:

Carry, ou carrinho/transporte, é o que a gente conhece no Brasil como "vai um" em uma operação aritmética de adição. Borrow é o mesmo princípio porém em aritmética de subtração, em linguagem coloquial chamado de "pegar emprestado".

Dentre essas flags somente CF pode ser modificada diretamente e isso é feito com as seguintes instruçÔes:

Control Flags

Se DF estiver setada as instruçÔes de string irão decrementar o valor do(s) registrador(es). Se estiver zerada ela irå incrementar, que é o valor padrão para essa flag.

Caso sete o valor dessa flag Ă© importante que a zere novamente em seguida. CĂłdigo compilado normalmente espera que por padrĂŁo essa flag esteja zerada. Comportamentos imprevistos podem acontecer caso vocĂȘ nĂŁo a zere depois de usar.

System Flags

As system flags podem ser lidas por qualquer programa porém somente o sistema operacional pode modificar seus valores (exceto ID). Abaixo irei falar somente das flags que nos interessam saber por agora.

IOPL na verdade não é uma flag mas sim um campo de 2 bits que indica o nível de privilégio de acesso para operaçÔes de I/O a partir da porta física do processador.

As instruçÔes abaixo podem ser utilizadas para modificar o valor de IF:

FLAGS (16-bit)

Em real mode dentre as system flags somente TF e IF existem e não dependem de qualquer tipo de privilégio para serem modificadas, qualquer software executado pelo processador tem permissão irrestrita às flags.

O é um byte do código de måquina que especifica a operação a ser executada pelo processador. Em algumas instruçÔes mais alguns bits de outro byte da instrução em código de måquina é utilizado para especificar operaçÔes diferentes, que é o campo REG do byte . Como o jå citado far call por exemplo.

A instrução mov byte [bx], 42 compilada para 16-bit não altera apenas o tamanho do registrador, quando estå em 32-bit, mas também o registrador em si. Isso acontece devido as diferenças de endereçamento jå explicadas neste livro em .

Como explicado no tópico que fala sobre algumas instruçÔes fazem o endereçamento em determinados segmentos. O atributo de segmento padrão é definido de acordo com qual registrador é usado como base no .

Certifique-se de ter o instalado no seu sistema ou qualquer outro emulador do MS-DOS que vocĂȘ saiba utilizar. Sistemas compatĂ­veis com o MS-DOS, como o por exemplo, tambĂ©m podem ser utilizados.

No Windows basta instalar o como jĂĄ mencionei.

Rodando sob um sistema operacional a segmentação da memĂłria Ă© totalmente controlada pelo kernel. Ou seja, nĂŁo tente fazer o que vocĂȘ nĂŁo tem permissĂŁo.

Em IA-16 o barramento tem o tamanho padrĂŁo de 20 bits. Calculando 2202^{20}220 temos o nĂșmero de bytes endereçåveis que sĂŁo exatamente 1 MiB de memĂłria que pode ser endereçada. É da largura do barramento de endereço que surge a limitação de tamanho da memĂłria RAM.

O segmento padrão (nesse caso DS) usado para acessar o endereço depende de qual registrador e instrução estå sendo utilizado. No tópico isso serå explicado.

Diferente dos , os registradores de segmento nĂŁo sĂŁo expandidos. Permanecem com o tamanho de 16 bits.

Jå os registradores FS e GS são exceçÔes e ainda podem ser usados pelo sistema operacional para endereçamento de estruturas especiais na memória. Como por exemplo no Linux, em x86-64, FS é usado para apontar para a .

Como vimos no tópico o processador calcula o endereço dos operandos na memória onde o resultado do cålculo serå o endereço absoluto onde o operando estå.

O problema disso é que o código que escrevemos precisa sempre ser carregado no mesmo endereço senão os endereços nas instruçÔes estarão errados. Esse problema foi abordado no , onde a diretiva org 0x100 precisa ser usada para que o NASM calcule o offset correto dos símbolos senão os endereços estarão errados e o programa não funcionarå corretamente.

Sistemas operacionais modernos tĂȘm um recurso de segurança chamado que dificulta a exploração de falhas de segurança no binĂĄrio. Resumidamente ele carrega os endereços dos segmentos do executĂĄvel em endereços aleatĂłrios ao invĂ©s de sempre no mesmo endereço. Com o ASLR desligado os segmentos sempre sĂŁo mapeados nos mesmos endereços.

Já vimos no tópico que em x86-64 se tem um novo endereçamento relativo à RIP. É muito mais simples escrever código independente de posição no modo de 64-bit devido a isso.

Ao chamar o procedimento __x86.get_pc_thunk.bx o endereço da instrução seguinte na memória é empilhado pela instrução , portanto mov ebx, [esp] salva o endereço que EIP terå quando o procedimento retornar em EBX.

Comumente um compilador nĂŁo irĂĄ gerar cĂłdigo usando tecnologia exclusiva, afim de aumentar a portabilidade. Alguns compiladores aceitam que vocĂȘ passe uma flag na linha de comando para que eles otimizem o cĂłdigo usando tecnologias exclusivas, por exemplo.

A arquitetura x86 é uma arquitetura que, resumindo, é uma arquitetura com um conjunto complexo de instruçÔes. Falando de maneira leviana isso significa que hå vårias instruçÔes e cada uma delas tem um nível de complexidade completamente variada. Boa parte das instruçÔes são complexas na arquitetura x86. Uma instrução "complexa" é uma instrução que faz vårias operaçÔes.

A arquitetura x86 segue o modelo da arquitetura de onde esse, mais uma vez resumindo, trabalha principalmente usando uma unidade central de processamento (CPU) e uma memĂłria principal.

Uma porta física é um barramento do processador usado para se comunicar com o restante do hardware. Por exemplo para poder usar a memória secundåria, o , usamos uma porta física para enviar e receber dados do dispositivo. O gerenciamento desta comunicação é feito pelo chipset da placa-mãe.

No tópico de nós jå vimos uma forma de usar o prefixo de sobrescrita de segmento, porém também é possível uså-lo simplesmente adicionando o nome do registrador de segmento antes da instrução. Veja que as duas instruçÔes abaixo são equivalentes:

Enquanto o RFLAGS de 64 bits contém todas as mesmas flags de EFLAGS sem nenhuma nova. Todos os 32 bits mais significativos do RFLAGS estão reservados e sem nenhum uso atualmente. Observe a figura abaixo retirada do , mostrando uma visão geral do bits de EFLAGS:

😉
opcode
ModR/M
A base→Endereçamento
registradores de segmentos
endereçamento
Dosbox
FreeDOS
Mingw-w64
registradores de propĂłsito geral
Endereçamento
tĂłpico sobre MS-DOS
ASLR
CALL
Atributos

Nome oficial

Nome alternativo

Bit

8086

IA-16

16

IA-32

i386

32

x86-64

i686

64

4d 3c 2b 1a
bits 16

mov ecx, 99999
.lp:
  ; Faça alguma coisa
a32 loop .lp
bits 32

mov byte [es:ebx], 32
es mov byte [ebx], 32
bits 32

inc ecx
db 0xFF, 0xC1
inc reg
inc r/m
bits 64

section .text

global my_strlen
my_strlen:
  mov ecx, -1
  xor eax, eax
  
  repne scasb

  mov eax, -2
  sub eax, ecx
  ret

#include <stdio.h>

int my_strlen(char *);

int main(void)
{
  printf("Resultado: %d\n", my_strlen("Hello World!"));
  return 0;
}

cc

Descrição (inglĂȘs | portuguĂȘs)

Condição

A

if Above | se acima

CF=0 e ZF=0

AE

if Above or Equal | se acima ou igual

CF=0

B

if Below | se abaixo

CF=1

BE

if Below or Equal | se acima ou igual

CF=1 ou ZF=1

C

if Carry | se carry flag estiver setada

CF=1

E

if Equal | se igual

ZF=1

G

if Greater | se maior

ZF=0 e SF=OF

GE

if Greater or Equal | se maior ou igual

SF=OF

L

if Less | se menor

SF!=OF

LE

if Less or Equal | se menor ou igual

ZF=1 ou SF!=OF

NA

if Not Above | se nĂŁo acima

CF=1 ou ZF=1

NAE

if Not Above or Equal | se nĂŁo acima ou igual

CF=1

NB

if Not Below | se nĂŁo abaixo

CF=0

NBE

if Not Below or Equal | se nĂŁo abaixou ou igual

CF=0 e ZF=0

NC

if Not Carry | se carry flag nĂŁo estiver setada

CF=0

NE

if Not Equal | se nĂŁo igual

ZF=0

NG

if Not Greater | se nĂŁo maior

ZF=1 ou SF!=OF

NGE

if Not Greater or Equal |se nĂŁo maior ou igual

SF!=OF

NL

if Not Less | se nĂŁo menor

SF=OF

NLE

if Not Less or Equal | se nĂŁo menor ou igual

ZF=0 e SF=OF

NO

if Not Overflow | se nĂŁo setado overflow flag

OF=0

NP

if Not Parity | se nĂŁo setado parity flag

PF=0

NS

if Not Sign | se nĂŁo setado sign flag

SF=0

NZ

if Not Zero | se nĂŁo setado zero flag

ZF=0

O

if Overflow | se setado overflow flag

OF=1

P

if Parity | se setado parity flag

PF=1

PE

if Parity Even | se parity indica par

PF=1

PO

if Parity Odd | se parity indicar Ă­mpar

PF=0

S

if Sign | se setado sign flag

SF=1

Z

if Zero | se setado zero flag

ZF=1

cmp rax, rbx
jnz nao_igual  ; Salta se RAX e RBX nĂŁo forem iguais

Jcc

Descrição (inglĂȘs | portuguĂȘs)

Condição

JCXZ

Jump if CX is zero | pula se CX for igual a zero

CX=0

JECXZ

Jump if ECX is zero |pula se ECX for igual a zero

ECX=0

JRCXZ

Jump if RCX is zero | pula se RCX for igual a zero

RCX=0

stc  ; (Set CF)        Seta o valor da Carry Flag
clc  ; (Clear CF)      Zera o valor da Carry Flag
cmc  ; (coMplement CF) Inverte o valor da Carry Flag

Bit

Nome

Sigla

Descrição

10

Direction Flag

DF

Controla a direção para onde as instruçÔes de string (MOVS, SCAS, STOS, CMPS e LODS) irão decorrer a memória.

std  ; (Set DF)    Seta o valor da Direction Flag
cld  ; (Clear DF)  Zera o valor da Direction Flag

Bit

Nome

Sigla

Descrição

8

Trap Flag

TF

Se setada o processador irĂĄ executar as instruçÔes do programa passo a passo. Nesse modo o processador dispara uma exception para cada instrução executada. É normalmente usada para depuração de cĂłdigo.

9

Interrupt enable Flag

IF

Controla a resposta do processador para interrupçÔes que podem ser ignoradas (interrupçÔes mascaråveis).

12-13

I/O Privilege Level field

IOPL

Indica o nível de acesso para a comunicação direta com o hardware (operaçÔes de I/O) do programa atual.

14

Nested Task flag

NT

Se setada indica que a tarefa atual estå vinculada com uma tarefa anterior. Essa flag controla o comportamento da instrução IRET.

16

Resume Flag

RF

Se setada as exceptions disparadas pelo processador são temporariamente desabilitadas na instrução seguinte. Geralmente usada por depuradores.

17

Virtual-8086 Mode flag

VM

Em protected mode se essa flag for setada o processador entra em modo Virtual-8086.

21

Identification flag

ID

Se um processo conseguir setar ou zerar essa flag, isto indica que o processador suporta a instrução CPUID.

sti  ; (Set IF)   Seta o valor da Interrupt Flag
cli  ; (Clear IF) Zera o valor da Interrupt Flag

Modos de operação

Entendendo os diversos modos de operação presentes em processadores x86

Como jå explicado a arquitetura x86 foi uma evolução ao longo dos anos e sempre mantendo compatibilidade com os processadores anteriores. Mas código de 16, 32 e 64 bit são demasiadamente diferentes e boa parte das instruçÔes não são equivalentes o que teoricamente faria com que, por exemplo, código de 32 bit fosse impossível de rodar em um processador x86-64. Mas é aí que entra os modos de operação.

Um processador x86-64 consegue executar código de versÔes anteriores simplesmente trocando o modo de operação. Cada modo faz com que o processador funcione de maneira um tanto quanto diferente, fazendo com que as instruçÔes executadas também tenham resultados diferentes.

Ou seja, lĂĄ no 8086 seria como se sĂł existisse o modo de 16 bit. Com a chegada dos processadores de 32 bit na verdade simplesmente foi adicionado um novo modo de operação aos processadores que seria o modo de 32 bit. E o mesmo aconteceu com a chegada dos processadores x86-64 que basicamente adiciona um modo de operação de 64 bit. É claro que alĂ©m dos modos de operação novos tambĂ©m surgem novas tecnologias e novas instruçÔes, mas o modo de operação anterior fica intacto e por isso se tem compatibilidade com os processadores anteriores.

Podemos dizer que existem trĂȘs modos de operação principais:

Modo de operação

Largura do barramento interno

Real mode / Modo real

16 bit

Protected mode / Modo protegido

32 bit

64-bit submode / Submodo de 64-bit

64 bit

Barramento interno

Os tais "bit" que sĂŁo muito conhecidos mas pouco entendido, na verdade Ă© simplesmente uma referĂȘncia a largura do barramento interno do processador quando ele estĂĄ em determinado modo de operação. A largura do barramento interno do processador nada mais Ă© que o tamanho padrĂŁo de dados que ele pode processar de uma Ășnica vez.

Imagine uma enorme via com 16 faixas e no final dela um pedĂĄgio, isso significa que 16 carros serĂŁo atendidos por vez no pedĂĄgio. Se Ă© necessĂĄrio atender 32 carros entĂŁo serĂĄ necessĂĄrio duas vezes para atender todos os carros, jĂĄ que apenas 16 podem ser atendidos de uma Ășnica vez. A largura de um barramento nada mais Ă© que uma "via de bits", quanto mais largo mais informação pode ser enviada de uma Ășnica vez. O que teoricamente aumenta a eficiĂȘncia.

No caso do barramento interno do processador seria a "via de bits" que o processador usa em todo o seu sistema interno, desconsiderando a comunicação com o hardware externo que é feita pelo barramento externo e não necessariamente tem o mesmo tamanho do barramento interno.

Também existe o barramento de endereço, mas não vamos abordar isso agora.

Mais modos de operação

Pelo que nĂłs vimos acima entĂŁo na verdade um "sistema operacional de 64 bit" nada mais Ă© que um sistema operacional que executa em submodo de 64-bit. Ah, mas aĂ­ fica a pergunta:

Se estĂĄ rodando em 64 bit como Ă© possĂ­vel executar cĂłdigo de 32 bit?

Isso Ă© possĂ­vel porque existem mais modos de operação do que os que eu jĂĄ mencionei. Reparou que eu disse "submodo" de 64-bit? É porque na verdade o 64-bit nĂŁo Ă© um modo principal mas sim um submodo. A hierarquia de modos de operação de um processador Intel64 ficaria da seguinte forma:

  • Real mode (16 bit)

  • Protected mode (32 bit)

  • SMM (nĂŁo vamos falar deste modo, mas ele existe)

  • IA-32e

    • 64-bit (64 bit)

    • Compatibility mode (32 bit)

O modo IA-32e Ă© uma adição dos processadores x86-64. Repare que ele tem outro submodo chamado "compatibility mode", ou em portuguĂȘs, "modo de compatibilidade".

NĂŁo confundir com o modo de compatibilidade do Windows, ali Ă© uma coisa diferente que leva o mesmo nome.

O modo de compatibilidade serve para obter compatibilidade com a arquitetura IA-32. Um sistema operacional pode setar para que código de apenas determinado segmento na memória rode nesse modo, permitindo assim que ele execute código de 32 e 64 bit paralelamente (supondo que o processador esteja em modo IA-32e). Por isso que seu Debian de 64 bit consegue rodar softwares de 32 bit, assim como o seu Windows 10 de 64 bit também consegue.

Virtual-8086

Lembra que o antigo Windows XP de 32 bit era capaz de rodar programas de 16 bit do MS-DOS? Isto era possível devido ao modo Virtual-8086 que, de maneira parecida com o compatibility mode, permite executar código de 16 bit enquanto o processador estå em protected mode. Nos processadores atuais o Virtual-8086 não é um submodo de operação do protected mode mas sim um atributo que pode ser setado enquanto o processador estå executando nesse modo.

Repare que rodando em compatibility mode nĂŁo Ă© possĂ­vel usar o modo Virtual-8086. É por isso que o Windows XP de 32 bit conseguia rodar programas do MS-DOS mas o XP de 64 bit nĂŁo.

InstruçÔes lógicas e de comparação

InstruçÔes lógicas SSE

ANDP(S|D) | bitwise logical AND of Packed (Single|Double)-precision floating-point values

ANDPS xmm(n), xmm(n)
ANDPS xmm(n), float(4)


ANDPD xmm(n), xmm(n)
ANDPD xmm(n), double(2)

Faz uma operação E bit a bit (bitwise AND) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.

ANDNP(S|D) | bitwise logical AND NOT of Packed (Single|Double)-precision floating-point values

ANDNPS xmm(n), xmm(n)
ANDNPS xmm(n), float(4)


ANDNPD xmm(n), xmm(n)
ANDNPD xmm(n), double(2)

Faz uma operação NAND bit a bit em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.

ORP(S|D) | bitwise logical OR of Packed (Single|Double)-precision floating-point values

ORPS xmm(n), xmm(n)
ORPS xmm(n), float(4)


ORPD xmm(n), xmm(n)
ORPD xmm(n), double(2)

Faz uma operação OU bit a bit (bitwise OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.

XORP(S|D) | bitwise logical XOR of Packed (Single|Double)-precision floating-point values

XORPS xmm(n), xmm(n)
XORPS xmm(n), float(4)


XORPD xmm(n), xmm(n)
XORPD xmm(n), double(2)

Faz uma operação OU Exclusivo bit a bit (bitwise eXclusive OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.

InstruçÔes de comparação SSE

As instruçÔes de comparação do SSE recebem um terceiro operando imediato de 8 bits que serve como identificador para indicar qual comparação deve ser efetuada com os valores, onde os valores vålidos são de 0 até 7. Na tabela abaixo é indicado cada valor e qual operação de comparação ele representa:

Valor

MnemĂŽnico

Descrição

0

EQ

Verifica se os valores sĂŁo iguais.

1

LT

Verifica se o primeiro operando Ă© menor que o segundo.

2

LE

Verifica se o primeiro operando Ă© menor ou igual ao segundo.

3

UNORD

Verifica se os valores nĂŁo estĂŁo ordenados.

4

NEQ

Verifica se os valores nĂŁo sĂŁo iguais.

5

NLT

Verifica se o primeiro operando nĂŁo Ă© menor que o segundo (ou seja, se Ă© igual ou maior).

6

NLE

Verifica se o primeiro operando nĂŁo Ă© menor ou igual que o segundo (ou seja, se Ă© maior).

7

ORD

Verifica se os valores estĂŁo ordenados.

Felizmente para facilitar nossa vida os assemblers, incluindo o NASM, adicionam pseudo-instruçÔes que removem o operando imediato e, ao invés disso, usa os mnemÎnicos apresentados na tabela como conditional code para a instrução. Como é demonstrado no exemplo abaixo:

; As duas instruçÔes abaixo são equivalentes.

CMPPS xmm1, xmm2, 0
CMPEQPS xmm1, xmm2

CMPP(S|D)/CMPccP(S|D) | Compare Packed (Single|Double)-precision floating-point values

CMPPS xmm(n), xmm(n), imm8
CMPPS xmm(n), float(4), imm8


CMPPD xmm(n), xmm(n), imm8
CMPPD xmm(n), double(2), imm8

Essa instrução compara cada um dos valores float/double contido nos dois operandos e armazena o resultado da comparação no operando fonte (o primeiro). O valor imediato passado como terceiro operando é um código numérico para identificar qual operação de comparação deve ser executada em cada um dos valores.

O resultado Ă© armazenado como todos os bits ligados (1) caso a comparação seja verdadeira, se nĂŁo todos os bits estarĂŁo desligados (0) indicando que a comparação foi falsa. Cada nĂșmero float/double tem um resultado distinto no registrador destino.

CMPS(S|D)/CMPccS(S|D) | Compare Scalar (Single|Double)-precision floating-point value

CMPSS xmm(n), xmm(n), imm8
CMPSS xmm(n), float(4), imm8


CMPSD xmm(n), xmm(n), imm8
CMPSD xmm(n), double(2), imm8

Funciona da mesma forma que a instrução anterior porĂ©m comparando um Ășnico valor escalar. O resultado Ă© armazenado no float/double menos significativo do operando fonte.

COMIS(S|D)/UCOMIS(S|D) | (Unordered) Compare Scalar (Single|Double)-precision floating-point value and set EFLAGS

COMISS xmm(n), xmm(n)
COMISS xmm(n), float(1)

UCOMISS xmm(n), xmm(n)
UCOMISS xmm(n), float(1)


COMISD xmm(n), xmm(n)
COMISD xmm(n), double(1)

UCOMISD xmm(n), xmm(n)
UCOMISD xmm(n), double(1)

As quatro instruçÔes comparam os dois operandos escalares e definem as status flags em EFLAGS de acordo com a comparação sem modificar os operandos. Comportamento semelhante ao da instrução CMP.

Quando uma operação aritmĂ©tica com nĂșmeros floats resulta em NaN existem dois tipos diferentes:

  • quiet NaN (QNaN): O valor Ă© apenas definido para NaN sem qualquer indicação de problema.

  • signaling NaN (SNaN): O valor Ă© definido para NaN e uma exceção floating-point invalid-operation (#I) Ă© disparada caso vocĂȘ execute alguma operação com o valor.

A diferença entre COMISS/COMISD e UCOMISS/UCOMISD é que COMISS/COMISD irå disparar a exceção #I se o primeiro operando for QNaN ou SNaN. Jå UCOMISS/UCOMISD apenas dispara a exceção se o primeiro operando for SNaN.

InstruçÔes de movimentação de dados

Listando algumas instruçÔes de movimentação de dados do SSE.

MOVAP(S|D)/MOVUP(S|D) | Move Aligned/Unaligned Packed (Single|Double)-precision floating-point

MOVAPS xmm(n), xmm(n)
MOVAPS xmm(n), float(4)
MOVAPS float(4), xmm(n)

MOVUPS xmm(n), xmm(n)
MOVUPS xmm(n), float(4)
MOVUPS float(4), xmm(n)


MOVAPD xmm(n), xmm(n)
MOVAPD xmm(n), double(2)
MOVAPD double(2), xmm(n)

MOVUPD xmm(n), xmm(n)
MOVUPD xmm(n), double(2)
MOVUPD double(2), xmm(n)

As instruçÔes MOVAPS e MOVUPS fazem a mesma coisa: Movem 4 valores float empacotados entre registradores XMM ou de/para memória principal. MOVAPD e MOVUPD porém lida com 2 valores double.

A diferença é que a instrução MOVAPS/MOVAPD espera que o endereço do valor na memória esteja alinhado a um valor de 16 bytes, caso não esteja a instrução dispara uma exceção #GP (General Protection ou "segmentation fault" como é conhecido no Linux). O motivo dessa instrução exigir isso é que acessar o endereço alinhado é muito mais performåtico.

Jå a instrução MOVUPS/MOVUPD pode acessar um endereço de memória desalinhado (unaligned) sem ocorrer nenhum erro, porém ela é menos performåtica.

Um exemplo de uso da MOVAPS na nossa PoC:

#include <stdio.h>

void assembly(float *array);

int main(void)
{
  float array[4];
  assembly(array);

  printf("%f, %f, %f, %f\n", array[0], array[1], array[2], array[3]);
  return 0;
}
bits 64
default rel

section .rodata align=16
    local_array: dd 1.23
                 dd 2.45
                 dd 3.67
                 dd 4.89

section .text

global assembly
assembly:
    movaps xmm5, [local_array]
    movaps [rdi], xmm5
    ret

Sem entrar em detalhes ainda sobre a convenção de chamada, o ponteiro recebido como argumento pela função assembly() estå no registrador RDI.

Sobre o atributo align=16 usado na seção .rodata ele serve para fazer exatamente o que o nome sugere: Alinhar o endereço inicial da seção em um mĂșltiplo de 16, que Ă© uma exigĂȘncia da instrução MOVAPS.

Um detalhe interessante que vale citar é que apesar da instrução ter sido feita para lidar com um determinado tipo de dado nada impede de nós carregarmos outros dados nos registradores XMM. No exemplo abaixo usei a instrução MOVUPS para mover uma string de 16 bytes com apenas duas instruçÔes:

#include <stdio.h>

void assembly(char *array);

int main(void)
{
  char text[16];
  assembly(text);

  printf("Resultado: %s\n", text);
  return 0;
}
bits 64
default rel

section .rodata
    string: db "Hello World!", 0, 0, 0, 0

section .text

global assembly
assembly:
    movups xmm0, [string]
    movups [rdi], xmm0
    ret

MOVS(S|D) | Move Scalar (Single|Double)-precision floating-point

MOVSS xmm(n), xmm(n)
MOVSS xmm(n), float(1)
MOVSS float(1), xmm(n)


MOVSD xmm(n), xmm(n)
MOVSD xmm(n), double(1)
MOVSD double(1), xmm(n)

Move um Ășnico float/double entre registradores XMM, onde o valor estaria contido na double word (4 bytes) ou quadword (8 bytes) menos significativo do registrador. E tambĂ©m Ă© possĂ­vel mover de/para memĂłria principal.

MOVLP(S|D) | Move Low Packed (Single|Double)-precision floating-point

MOVLPS xmm(n), float(2)
MOVLPS float(2), xmm(n)


MOVLPD xmm(n), double(1)
MOVLPD double(1), xmm(n)

A instrução MOVLPS instrução é semelhante à MOVUPS porém carrega/escreve apenas dois floats. No registrador os dois floats ficam armazenados no quadword (8 bytes) menos significativo. O quadword mais significativo do registrador não é alterado.

Jå MOVLPD faz a mesma operação porém com um double contido no quadword menos significativo.

MOVHP(S|D) | Move High Packed (Single|Double)-precision floating-point

MOVHPS xmm(n), float(2)
MOVHPS float(2), xmm(n)


MOVHPD xmm(n), double(1)
MOVHPD double(1), xmm(n)

Semelhante a instrução acima porém armazena/ler o valor do registrador XMM no quadword mais significativo. O quadword menos significativo do registrador não é alterado.

MOVLHPS | Move Packed Single-precision floating-point Low to High

MOVLHPS xmm(n), xmm(n)

Move o quadword (8 bytes) menos significativo do registrador fonte (a direita) para o quadword mais significativo do registrador destino. O quadword menos significativo do registrador destino nĂŁo Ă© alterado.

MOVHLPS | Move Packed Single-precision floating-point High to Low

MOVHLPS xmm(n), xmm(n)

Move o quadword (8 bytes) mais significativo do registrador fonte (a direita) para o quadword menos significativo do registrador destino. O quadword mais significativo do registrador destino nĂŁo Ă© alterado.

MOVMSKP(S|D) | Move Packed (Single|Double)-precision floating-point mask

MOVMSKPS reg32/64, xmm(n)


MOVMSKPD reg32/64, xmm(n)

MOVMSKPS move os bits mais significativos (MSB) de cada um dos quatro valores float contido no registrador XMM para os 4 bits menos significativo do registrador de propĂłsito geral. Os outros bits do registrador sĂŁo zerados.

Jå MOVMSKPD faz a mesma coisa porém com os 2 valores doubles contidos no registrador, assim definindo os 2 bits menos significativos do registrador de propósito geral.

Essa instrução pode ser usada com o intuito de verificar o sinal de cada um dos valores float/double, tendo em vista que o bit mais significativo Ă© usado para indicar o sinal do nĂșmero (0 caso positivo e 1 caso negativo).

como o GCC
CISC
Von Neumann
HD
registradores de segmento
Intel Developer's Manual Vol. 1
Endereçamento

Ambiente freestanding

Entendendo a execução de código em C no ambiente freestanding.

O ambiente de execução freestanding Ă© normalmente usado quando o cĂłdigo C Ă© compilado para executar fora de um sistema operacional. Nesse ambiente nenhum dos recursos provindos do ambiente hosted sĂŁo garantidos e sua existĂȘncia ou nĂŁo depende da implementação.

Os Ășnicos recursos que sĂŁo oferecidos pela libc sĂŁo os declarados nos seguintes header files:

<float.h>, <iso646.h>, <limits.h>, <stdalign.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, <stdint.h> e <stdnoreturn.h>.

Quaisquer outros recursos são dependentes de implementação.

No GCC para compilar um código visando o ambiente freestanding é possível usar a opção -ffreestanding. Também se pode usar a opção -fhosted para compilar para ambiente hosted mas esse jå é o padrão.

Jå a opção -nostdlib desabilita a linkedição da libc.

Bit

Nome

Sigla

Descrição

0

Carry Flag

CF

Setado se uma condição de Carry ou Borrow acontecer no bit mais significativo do resultado. Basicamente indica o overflow de um valor não-sinalizado.

2

Parity Flag

PF

​Setado se o byte menos significativo do resultado conter um nĂșmero par de bits ligados (1).

4

Auxiliary Carry Flag

AF

Setado se uma condição de Carry ou Borrow acontecer no bit 3 do resultado.

6

Zero Flag

ZF

Setado se o resultado for zero.

7

Sign Flag

SF

11

Overflow Flag

OF

Setado se o resultado nĂŁo tiver o sinal esperado da operação aritmĂ©tica. Basicamente indica o overflow de um nĂșmero sinalizado.

Procedimentos do BIOS

Existem algumas interrupçÔes que são criadas pelo próprio BIOS do sistema. Vamos ver algumas delas aqui.

BIOS — Basic Input/Output System — Ă© o firmware da placa-mĂŁe responsĂĄvel pela inicialização do hardware. Ele quem começa o processo de boot do sistema alĂ©m de anteriormente fazer um teste rĂĄpido (POST — Power-On Self Test) para verificar se o hardware estĂĄ funcionando apropriadamente.

Mas além de fazer essa tarefa de inicialização do PC ele também define algumas interrupçÔes que podem ser usadas pelo software em real mode para tarefas båsicas. E é daí que vem seu nome, jå que essas tarefas são operaçÔes båsicas de entrada e saída de dados para o hardware.

Cada interrupção nĂŁo faz um procedimento Ășnico mas sim vĂĄrios procedimentos relacionados Ă  um determinado hardware. Qual procedimento especificamente serĂĄ executado Ă©, na maioria das vezes, definido no registrador AH ou AX.

INT 0x10

Essa interrupção tem procedimentos relacionados ao vídeo, como a escrita de caracteres na tela ou até mesmo alterar o modo de vídeo.

AH 0x0E

O procedimento INT 0x10 / AH 0x0E simplesmente escreve um caractere na tela em modo teletype, que Ă© um nome chique para dizer que o caractere Ă© impresso na posição atual do cursor e atualiza a posição do mesmo. É algo bem semelhante ao que a gente vĂȘ sob um sistema operacional usando uma função como putchar() em C.

Esse procedimento recebe como argumento no registrador AL o caractere a ser impresso e em BH o nĂșmero da pĂĄgina.

O nĂșmero da pĂĄgina varia entre 0 e 7. SĂŁo 8 pĂĄginas diferentes que podem ser apresentadas para o monitor como o conteĂșdo da tela. Por padrĂŁo Ă© usada a pĂĄgina 0 mas vocĂȘ pode alternar entre as pĂĄginas fazendo com que conteĂșdo diferente seja apresentado na tela sem perder o conteĂșdo da outra pĂĄgina.

Se vocĂȘ jĂĄ usou o MS-DOS deve ter visto programas, como editores de cĂłdigo, que imprimiam uma interface de texto (TUI) mas depois que finalizava o conteĂșdo do prompt voltava para a tela. Esses programas basicamente alternavam de pĂĄgina.

exemplo.asm
mov ah, 0x0E
mov al, 'H'
int 0x10

mov al, 'i'
int 0x10

ret

No exemplo acima usamos a interrupção duas vezes para imprimir dois caracteres diferentes, fazendo assim um "Hello World" de míseros 11 bytes.

PoderĂ­amos fazer um procedimento para escrever uma string inteira usando um loop. Ficaria assim:

hello.asm
bits 16
org  0x100

mov si, string
call echo

ret

string: db "Hello World!", 0

; SI = ASCIIZ string
; BH = PĂĄgina
echo:
  mov ah, 0x0E

.loop:
  lodsb
  test al, al
  jz .stop
  
  int 0x10
  jmp .loop
  
  
.stop:
  ret

AH 0x02

AH

BH

DH

DL

0x02

PĂĄgina

Linha

Coluna

Esse procedimento seta a posição do cursor em uma determinada pågina.

AH 0x03

AH

BH

0x03

PĂĄgina

Pega a posição atual do cursor na pågina especificada. Retornando:

CH

CL

DH

DL

Scanline inicial

Scanline final

Linha

Coluna

AH 0x05

AH

AL

0x05

PĂĄgina

Alterna para a pĂĄgina especificada por AL que deve ser um nĂșmero entre 0 e 7.

AH 0x09

AH

AL

BH

BL

CX

0x09

Caractere

PĂĄgina

Atributo

Vezes para imprimir o caractere

Imprime o caractere AL na posição atual do cursor CX vezes, sem atualizar o cursor. BL é o atributo do caractere que serå explicado mais embaixo.

AH 0x0A

AH

AL

BH

CX

0x0A

Caractere

PĂĄgina

Vezes para imprimir

Mesma coisa que o procedimento anterior porém mudando somente que não é especificado um atributo para o caractere.

AH 0x13

Registrador

ParĂąmetro

AL

Modo de escrita

BH

PĂĄgina

BL

Atributo

CX

Tamanho da string (nĂșmero de caracteres a serem escritos)

DH

Linha

DL

Coluna

ES:BP

Endereço da string

Esse procedimento imprime uma string na tela podendo ser especificado um atributo. O modo de escrita pode variar entre 0 e 3, se trata de 2 bits especificando duas informaçÔes diferentes:

Bit

Informação

0

Se ligado atualiza a posição do cursor.

1

Se desligado BL Ă© usado para definir o atributo. Se ligado, o atributo Ă© lido da string.

No caso do segundo bit, se estiver ligado entĂŁo o procedimento irĂĄ ler a string considerando que se trata de uma sequĂȘncia de caractere e atributo. Assim cada caractere pode ter um atributo diferente. Conforme exemplo abaixo:

str: db 'A', 0x05, 'B', 0x0C, 'C', 0x0A

Caracteres de ação

Os procedimentos 0x0E e 0x13 interpretam caracteres especiais como determinadas açÔes que devem ser executadas ao invés de imprimir o caractere na tela. Cada caractere faz uma ação diferente conforme tabela abaixo:

Caractere

Nome

Seq. de escape

Ação

0x07

Bell

\a

Emite um beep.

0x08

Backspace

\b

Retorna o cursor uma posição.

0x09

Horizontal TAB

\t

Avança o cursor 4 posiçÔes.

0x0A

Line feed

\n

Move o cursor verticalmente para a prĂłxima linha.

0x0D

Carriage return

\r

Move o cursor para o inĂ­cio da linha.

VocĂȘ pode combinar 0x0D e 0x0A para fazer uma quebra de linha.

INT 0x16

Os procedimentos definidos nessa interrupção sĂŁo todos relacionados Ă  entrada do teclado. Toda vez que o usuĂĄrio pressiona uma tecla ela Ă© lida e armazenada no buffer do teclado. Se vocĂȘ tentar ler do buffer sem haver dados lĂĄ, entĂŁo o sistema irĂĄ ficar esperando o usuĂĄrio inserir uma entrada.

AH 0x00

LĂȘ um caractere do buffer do teclado e o remove de lĂĄ. Retorna os seguintes valores:

Registrador

Valor

AL

CĂłdigo ASCII do caractere

AH

Scancode da tecla.

Scancode Ă© um nĂșmero que identifica a tecla e nĂŁo especificamente o caractere inserido.

AH 0x01

Verifica se hĂĄ um caractere disponĂ­vel no buffer sem removĂȘ-lo de lĂĄ. Se houver caractere disponĂ­vel, retorna:

Registrador

Valor

AL

CĂłdigo ASCII

AH

Scancode

O procedimento também modifica a Zero Flag para especificar se hå ou não caractere disponível. A define para 0 se houver, caso contrårio para 1.

VocĂȘ pode usar em seguida o AH 0x00 para remover o caractere do buffer, se assim desejar. Desse jeito Ă© possĂ­vel pegar um caractere sem fazer uma pausa.

AH 0x02

Pega status relacionados ao teclado. É retornado em AL 8 flags diferentes, cada uma especificando informaçÔes diferentes sobre o estado atual do teclado. Conforme tabela:

Bit

Flag

0

Tecla shift direita estĂĄ pressionada.

1

Tecla shift esquerda estĂĄ pressiona.

2

Tecla ctrl estĂĄ pressionada.

3

Tecla alt estĂĄ pressionada.

4

Scroll lock estĂĄ ligado.

5

Num lock estĂĄ ligado.

6

Caps lock estĂĄ ligado.

7

Modo Insert estĂĄ ligado.

MemĂłria de VĂ­deo em Text Mode

Quando o sistema estå em modo texto a memória onde se armazena os caracteres começa no endereço 0xb800:0x0000 e ela é estruturada da seguinte forma:

// Em modo de texto 80x25, padrĂŁo do MS-DOS

struct character {
    uint8_t ascii;
    uint8_t attribute;
};

struct character vmem[8][25][80];

Ou seja começando em 0xb800:0x0000 as påginas estão uma atrås da outra na memória como uma grande array.

Atributo

O caractere nada mais é que o código ASCII do mesmo, jå o atributo é um valor usado para especificar informaçÔes de cor e blink do caractere.

Os 4 bits (nibble) mais significativo indicam o atributo do fundo e os 4 bits menos significativos o atributo do texto, gerando uma cor na escala RGB. Caso nĂŁo conheça essa Ă© a escala de cor da luz onde as cores primĂĄrias Red (vermelo), Green (verde) e Blue (azul) sĂŁo usadas em conjunto para formar qualquer outra cor. Conforme figura abaixo podemos ver qual bit significa o quĂȘ:

O bit de intensidade no atributo de texto, caso ligado, faz com que a cor do texto fique mais viva enquanto desligado as cores sĂŁo mais escuras. JĂĄ o bit de blink especifica se o texto deve permanecer piscando. Caso ativo o texto ficarĂĄ aparecendo e desaparecendo da tela constantemente.

OlĂĄ Mundo

Um exemplo de "Hello World" usando alguns conceitos apresentados aqui:

bits 16
org  0x100

%macro puts 2
  mov bx, %1
  mov bp, %2
  call puts
%endmacro


puts 0x000A, str1
puts 0x000C, str2

ret

str1: db `Hello World!\r\n`, 0
str2: db "Second message.", 0

; BL = Atributo
; BH = PĂĄgina
; BP = ASCIIZ String
puts:
  mov ah, 0x03
  int 0x10

  mov  di, bp
  call strlen
  mov  cx, ax
  
  mov al, 0b01
  mov ah, 0x13
  int 0x10

  ret


; DI = ASCIIZ String
; Retorna o tamanho da string
strlen:
  mov cx, -1
  xor ax, ax

  repnz scasb

  mov ax, -2
  sub ax, cx
  ret

Para uma lista completa de todas as interrupçÔes definidas pelo BIOS, sugiro a leitura:

Programando no MS-DOS

Conhecendo o ambiente do MS-DOS.

A famosa "telinha preta" do Windows, o prompt de comando, muitas vezes Ă© erroneamente chamado de MS-DOS devido aos dois usarem o mesmo shellscript chamado de Batch. Isso fazia com que comandos rodados no MS-DOS fossem quase totalmente compatĂ­veis na linha de comando do Windows.

Mas o prompt de comandos do Windows não é o MS-DOS. Esse é apenas o Terminal do sistema operacional Windows e que usa uma versão mais avançada do mesmo shellscript que rodava no MS-DOS.

Real mode

O MS-DOS era um sistema operacional que rodava em modo de processamento real mode, o famoso modo de 16-bit que Ă© compatĂ­vel com o 8086 original.

Text mode

Existem modos diferentes de se usar a saĂ­da de vĂ­deo, isto Ă©, o monitor do computador. Dentre os vĂĄrios modos que o monitor suporta, existe a divisĂŁo entre modo de texto (text mode) e modo de vĂ­deo (video mode).

O modo de vĂ­deo Ă© este modo que o seu sistema operacional estĂĄ rodando agora. Nele o software define informaçÔes de cor para cada pixel da tela, formando assim imagens desde mais simples (formas opacas) atĂ© as mais complexas (imagens renderizadas tridimensionalmente). Todas essas imagens que vocĂȘ vĂȘ sĂŁo geradas pixel a pixel para serem apresentadas pelo monitor.

JĂĄ o MS-DOS rodava em modo de texto, cujo este modo Ă© bem mais simples. Ao invĂ©s de vocĂȘ definir cada pixel que o monitor apresenta, vocĂȘ define unicamente informaçÔes de caracteres. Imagine por exemplo que seu monitor seja dividido em grade formando 80x25 quadrados na tela. Ou seja, 80 colunas e 25 linhas. Ao invĂ©s de definir cada pixel vocĂȘ apenas definia qual caractere seria apresentado naquele quadrado e um atributo para esse caractere.

ExecutĂĄveis .COM

O formato de executåvel mais båsico que o MS-DOS suportava era os de extensão .com que era um raw binary. Esse termo é usado para se referir a um "binårio puro", isto é, um arquivo binårio que não tem qualquer tipo de formatação especial.

Uma comparação com arquivos de texto seria vocĂȘ comparar um cĂłdigo fonte em C com um arquivo de texto "normal". O cĂłdigo fonte em C tambĂ©m Ă© um arquivo de texto, porĂ©m ele tem formataçÔes especiais que seguem a sintaxe da linguagem de programação. Enquanto o arquivo de texto "normal" Ă© apenas texto, sem seguir qualquer regra de formatação.

No caso do raw binary Ă© a mesma coisa, informação binĂĄria sem qualquer regra de formatação especial. Este executĂĄvel do MS-DOS tinha como "entry point" logo o primeiro byte do arquivo. Como eu jĂĄ disse, nĂŁo tinha qualquer regra especial nele entĂŁo vocĂȘ poderia organizĂĄ-lo da maneira que quisesse manualmente.

Execução do .COM

O processo que o MS-DOS fazia para executar esse tipo de executĂĄvel era tĂŁo simples quanto possĂ­vel. Seguindo o fluxo:

  • Recebe um comando na linha de comando.

  • Coloca o tamanho em bytes dos argumentos passados pela linha de comando no offset 0x80 do segmento do executĂĄvel.

  • Coloca os argumentos da linha de comando no offset 0x81 como texto puro, sem qualquer formatação.

  • Carrega todo o .COM no offset 0x100

  • Define os registradores DS, SS e ES para o segmento onde o executĂĄvel foi carregado.

  • Faz um call no endereço onde o executĂĄvel foi carregado.

Perceba que a chamada do executåvel nada mais é que um call, por isso esses executåveis finalizavam simplesmente executando um ret. Mais simples impossível, né?

ORG | Origin

org endereço_inicial

A essa altura vocĂȘ jĂĄ deve ter reparado que o NASM calcula o endereço dos rĂłtulos sozinho sem precisar da nossa ajuda, nĂ©? EntĂŁo, mas ele faz isso considerando que o primeiro byte do nosso arquivo binĂĄrio esteja especificamente no offset 0. Ou seja, ele começa a contar do zero em diante. No caso de um executĂĄvel .COM ele Ă© carregado no offset 0x100 e nĂŁo em 0, entĂŁo o cĂĄlculo vai dar errado.

Mas o NASM contém a diretiva org que serve para dizer para o NASM a partir de qual endereço ele deve calcular o endereço dos rótulos, ou seja, o endereço de origem do nosso binårio. Veja o exemplo:

bits 16
org 0x100

msg: db "abc"

codigo:
  mov ax, 77
  ret

O rótulo codigo ao invés de ter o endereço calculado como 0x0003 como normalmente teria, terå o endereço 0x0103 devido ao uso da diretiva org na segunda linha.

Hello World no MS-DOS

Um pequeno exemplo de "Hello World" (ou "Hi") para o MS-DOS:

hello.asm
bits 16
org 0x100

mov ah, 0x0E
mov al, 'H'
int 0x10

mov al, 'i'
int 0x10

ret

Experimente compilar como um raw binary com extensĂŁo .com e depois executar no Dosbox (ou FreeDOS ou qualquer projeto semelhante).

$ nasm hello.asm -o hello.com

A instrução INT e o que estå acontecendo aí serå explicado nos dois tópicos posteriores a esse.

Registradores de propĂłsito geral

Entendendo os registradores da arquitetura x86-64

Seguindo o modelo da arquitetura de Von Neumann, interno a CPU existem pequenos espaços de memĂłria chamados de registers, ou em portuguĂȘs, registradores.

Esses espaços de memória são pequenos, apenas o suficiente para armazenar um valor numérico de N bits de tamanho. Ler e escrever dados em um registrador é muito mais råpido do que a tarefa equivalente na memória principal. Do ponto de vista do programador é interessante usar registradores para manipular valores enquanto estå trabalhando com eles, e depois armazenå-lo de volta na memória se for o caso. Seguindo um fluxo como:

Registrador = MemĂłria
OperaçÔes com o valor no registrador
MemĂłria = Registrador

Mapeamento dos registradores

Afim de aumentar a versatilidade no uso de registradores, para poder manipular dados de tamanhos variados no mesmo espaço de memĂłria do registrador, alguns registradores sĂŁo subdivido em registradores menores. Isso seria o "mapeamento" dos registradores que faz com que vĂĄrios registradores de tamanhos diferentes compartilhem o mesmo espaço. Se vocĂȘ entende como funciona uma union em C jĂĄ deve ter entendido a lĂłgica aqui.

LĂĄ nos primĂłrdios da arquitetura x86 os registradores tinham o tamanho de 16 bits (2 bytes). Os processadores IA-32 aumentaram o tamanho desses registradores para acompanhar a largura do barramento interno de 32 bits (4 bytes). A referĂȘncia para o registrador completo ganhou um prefixo 'E' que seria a primeira letra de "Extended" (estendido). Processadores x86-64 aumentaram mais uma vez o tamanho desses registradores para 64 bits (8 bytes), dessa vez dando um prefixo 'R' que seria de "Re-extended" (re-estendido). SĂł que tambĂ©m trazendo alguns novos registradores de propĂłsito geral.

Registradores de propĂłsito geral (IA-16)

Os registradores de propĂłsito geral (GPR na sigla em inglĂȘs) sĂŁo registradores que sĂŁo, como o nome sugere, de uso geral pelas instruçÔes. Na arquitetura IA-16 nĂłs temos os registradores de 16 bits que sĂŁo mapeados em subdivisĂ”es como explicado acima.

Determinadas instruçÔes da arquitetura usam alguns desses registradores para tarefas especĂ­ficas mas eles nĂŁo sĂŁo limitados somente para esse uso. VocĂȘ pode usĂĄ-los da maneira que quiser porĂ©m recomendo seguir o padrĂŁo para melhorar a legibilidade do cĂłdigo. O "apelido" na tabela abaixo Ă© o nome dado aos registradores em inglĂȘs, serve para fins de memorização.

Registrador

Apelido

Uso

AX

Accumulator

Usado em instruçÔes de operaçÔes aritméticas para receber o resultado de um cålculo.

BX

Base

Usado geralmente em endereçamento de memória para se referir ao endereço inicial, isto é, o endereço base.

CX

Counter

Usado em instruçÔes de repetição de cĂłdigo (loops) para controlar o nĂșmero de repetiçÔes.

DX

Data

Usado em operaçÔes de entrada e saída por portas físicas para armazenar o dado enviado/recebido.

SP

Stack Pointer

BP

Base Pointer

SI

Source Index

Em operaçÔes com blocos de dados, ou strings, esse registrador é usado para apontar para o endereço de origem de onde os dados serão lidos.

DI

Destination Index

Trabalhando em conjunto com o registrador acima, esse aponta para o endereço destino onde os dados serão gravados.

Os registradores AX, BX, CX e DX sĂŁo subdivididos em 2 registradores cada um. Um dos registradores Ă© mapeado no seu byte mais significativo (Higher byte) e o outro no byte menos significativo (Lower byte). Reparou que os registradores sĂŁo uma de letra seguido do X? Para simplificar podemos dizer que os registradores sĂŁo A, B, C e D e o sufixo X serve para mapear todo o registrador, enquanto o sufixo H mapeia o Higher byte e o sufixo L mapeia o Lower byte.

Ou seja se alteramos o valor de AL na verdade estamos alterando o byte menos significativo de AX. E se alteramos AH entĂŁo Ă© o byte mais significativo de AX. Como no exemplo abaixo:

exemplo.asm
mov ah, 0xaa
mov al, 0xbb
; Aqui o valor de AX Ă© 0xaabb

Esse mesmo mapeamento ocorre também nos registradores BX, CX e DX. Como podemos ver na tabela abaixo:

Do processador 80386 em diante, em real mode, é possível usar as versÔes estendidas dos registradores existentes em IA-32. Porém os registradores estendidos de x86-64 só podem ser acessados em submodo de 64-bit.

Registradores de propĂłsito geral (IA-32)

Como jĂĄ explicado no IA-32 os registradores sĂŁo estendidos para 32 bits de tamanho e ganham o prefixo 'E', ficando assim a lista: EAX, EBX, ECX, EDX, ESP, EBP, ESI, EDI

Todos os outros registradores de propĂłsito geral existentes em IA-16 nĂŁo deixam de existir em IA-32. Eles sĂŁo mapeados nos 2 bytes menos significativos dos registradores estendidos. Por exemplo o registrador EAX fica mapeado da seguinte forma:

JĂĄ vimos o registrador "EAX" sendo manipulado na nossa PoC. Como o prefixo 'E' indica ele Ă© de 32 bits (4 bytes) de tamanho. PoderĂ­amos simular esse registrador com uma union em C da seguinte forma:

reg.c
#include <stdio.h>
#include <stdint.h>

union reg
{
  uint32_t eax;
  uint16_t ax;

  struct
  {
    uint8_t al;
    uint8_t ah;
  };
};

int main(void)
{
  union reg x = {.eax = 0x11223344};

  printf("AH:  %02x\n"
         "AL:  %02x\n"
         "AX:  %04x\n"
         "EAX: %08x\n",
         x.ah,
         x.al,
         x.ax,
         x.eax);

  return 0;
}

O que deveria gerar a seguinte saĂ­da:

Podemos testar o mapeamento de EAX com nossa PoC:

; Repare que também adicionei o arquivo main.c
; Veja a aba logo acima.

bits 64

global assembly
assembly:
  mov eax, 0x11223344
  mov ax,  0xaabb
  ret
#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %08x\n", assembly());
  return 0;
}

Na linha 8 alteramos o valor de EAX para 0x11223344 e logo em seguida, na linha 9, alteramos AX para 0xaabb. Isso deveria resultar em EAX = 0x1122aabb.

Teste o código e tente alterar AH e/ou AL ao invés de AX diretamente.

Registradores de propĂłsito geral (x86-64)

Os registradores de propĂłsito geral em x86-64 sĂŁo estendidos para 64 bits e ganham o prefixo 'R', ficando a lista: RAX, RBX, RCX, RDX, RSP, RBP, RSI, RDI

Todos os registradores de propĂłsito geral em IA-32 sĂŁo mapeados nos 4 bytes menos significativos dos registradores re-estendidos seguindo o mesmo padrĂŁo de mapeamento anterior.

E hĂĄ tambĂ©m um novo padrĂŁo de mapeamento do x86-64 com novos registradores de propĂłsito geral. Os novos nomes dos registradores sĂŁo uma letra 'R' seguido de um nĂșmero de 8 a 15.

O mapeamento dos novos registradores sĂŁo um pouco diferentes. Podemos usar o sufixo 'B' para acessar o byte menos significativo, o sufixo 'W' para acessar a word (2 bytes) menos significativa e 'D' para acessar a doubleword (4 bytes) menos significativa. Usando R8 como exemplo podemos montar a tabela abaixo:

Registrador

Descrição

R8B

Byte menos significativo de R8.

R8W

Word (2 bytes) menos significativa de R8.

R8D

Double word (4 bytes) menos significativa de R8.

Em x86-64 também é possível acessar o byte menos significativo dos registradores RSP, RBP, RSI e RDI. O que não é possível em IA-32 ou IA-16. Eles são mapeados em SPL, BPL, SIL e DIL.

Esses registradores novos podem ser usados da maneira que vocĂȘ quiser, assim como os outros registradores de propĂłsito geral.

Escrita nos registradores em x86-64

A escrita de dados nos 4 bytes menos significativos de um registrador de propĂłsito geral em x86-64 funciona de maneira um pouco diferente do que nĂłs estamos acostumados. Observe o exemplo:

mov rax, 0x11223344aabbccdd
mov eax, 0x1234

A instrução na linha 2 mudaria o valor de RAX para 0x0000000000001234. Isso acontece porque o valor é zero-extended, ou seja, ele é estendido de forma que os 4 bytes mais significativos de RAX são zerados.

O mesmo vale para todos os registradores de propĂłsito geral, incluindo os registradores R8..R15 caso vocĂȘ escreva algum valor em R8D..R15D.

Entendendo SSE

Aprendendo sobre SIMD, SSE e registradores XMM.

Na computação existe um conceito de instrução chamado SIMD (Single Instruction, Multiple Data) que Ă© basicamente uma instrução que processa mĂșltiplos dados de uma Ășnica vez. Todas as instruçÔes que vimos atĂ© agora processavam meramente um dado por vez, porĂ©m instruçÔes SIMD podem processar diversos dados paralelamente. O principal objetivo das instruçÔes SIMD Ă© ganhar performance se aproveitando dos mĂșltiplos nĂșcleos do processador, a maioria das instruçÔes SIMD foram implementadas com o intuito de otimizar cĂĄlculos comuns em ĂĄreas como processamento grĂĄfico, inteligĂȘncia artificial, criptografia, matemĂĄtica etc.

A Intel criou a primeira versão do SSE (streaming SIMD extensions) ainda no IA-32 com o Pentium III, e de lå para cå jå ganhou diversas novas versÔes que estendem a tecnologia adicionando novas instruçÔes. Atualmente nos processadores mais modernos hå as seguintes extensÔes: SSE, SSE2, SSE3, SSSE3 e SSE4.

Processadores da arquitetura x86 tĂȘm diversas tecnologias SIMD, a primeira delas foi o MMX nos processadores Intel antes mesmo do SSE. AlĂ©m de haver diversas outras como AVX, AVX-512, FMA, 3DNow! (da AMD) etc.

Na arquitetura x86 existem literalmente milhares de instruçÔes SIMD. Esteja ciente que esse tĂłpico estĂĄ longe de cobrir todo o assunto e serve meramente como conteĂșdo introdutĂłrio.

Registradores XMM

Esses registradores podem armazenar vĂĄrios dados diferentes de mesmo tipo/tamanho, conforme demonstra tabela abaixo:

Esses sĂŁo os tipos empacotados (packed), onde em um Ășnico registrador hĂĄ vĂĄrios valores de um mesmo tipo. Existem instruçÔes SIMD especĂ­ficas que executam operaçÔes packed onde elas trabalham com os vĂĄrios dados armazenados no registrador ao mesmo tempo. Em contraste existem tambĂ©m as operaçÔes escalares (scalar) que operam com um Ășnico dado (unpacked) no registrador, onde esse dado estaria armazenado na parte menos significativa do registrador.

Na convenção de chamada para x86-64 da linguagem C os primeiros argumentos float/double passados para uma função vão nos registradores XMM0, XMM1 etc. como valores escalares. E o retorno do tipo float/double fica no registrador XMM0 também como um valor escalar.

Na lista de instruçÔes haverå códigos de exemplo disso.

Entendendo as instruçÔes SSE

As instruçÔes adicionadas pela tecnologia SSE podem ser divididas em quatro grupos:

  • InstruçÔes packed e scalar que lidam com nĂșmeros float.

  • InstruçÔes SIMD com inteiros de 64 bits.

  • InstruçÔes de gerenciamento de estado.

  • InstruçÔes de controle de cache e prefetch.

A tabela abaixo lista a nomenclatura que irei utilizar para descrever as instruçÔes SSE.

Para facilitar o entendimento irei usar o termo float para se referir aos nĂșmeros de ponto flutuante de precisĂŁo Ășnica, ou seja, 32 bits de tamanho e 23 bits de precisĂŁo. JĂĄ o termo double serĂĄ utilizado para se referir aos nĂșmeros de ponto flutuante de dupla precisĂŁo, ou seja, de 64 bits de tamanho e 52 bits de precisĂŁo. Esses sĂŁo os mesmos nomes usados como tipos na linguagem C.

Nomenclatura

Descrição

xmm(n)

Indica qualquer um dos registradores XMM.

float(n)

Indica N nĂșmeros floats em sequĂȘncia na memĂłria RAM. Exemplo: float(4) seriam 4 nĂșmeros float totalizando 128 bits de tamanho.

double(n)

Indica N nĂșmeros double na memĂłria RAM. Exemplo: double(2) que totaliza 128 bits.

ubyte(n)

Indica N bytes nĂŁo-sinalizados na memĂłria RAM. Exemplo: ubyte(16) que totaliza 128 bits.

byte(n)

Indica N bytes sinalizados na memĂłria RAM.

uword(n)

Indica N words (2 bytes) nĂŁo-sinalizados na memĂłria RAM. Exemplo: uword(8) que totaliza 128 bits.

word(n)

Indica N words sinalizadas na memĂłria RAM.

dword(n)

Indica N double words (4 bytes) na memĂłria RAM.

qword(n)

Indica N quadwords (8 bytes) na memĂłria RAM.

reg32/64

imm8

Operando imediato de 8 bits de tamanho.

As instruçÔes SSE terminam com um sufixo de duas letras onde a penĂșltima indica se ela lida com dados packed ou scalar, e a Ășltima letra indica o tipo do dado sendo manipulado. Por exemplo a instrução MOVAPS onde o P indica que a instrução manipula dados packed, enquanto o S indica o tipo do dado como single-precision floating-point, ou seja, float de 32 bits de tamanho.

Jå o D de MOVAPD indica que a instrução lida com valores do tipo double-precision floating-point (64 bits). Eis a lista de sufixos e seus respectivos tipos:

Sufixo

Tipo

S

Single-precision float. Equivalente ao tipo float em C.

D

Double-precision float. Equivalente ao tipo double em C.

Ou inteiro doubleword (4 bytes) que seria um inteiro de 32 bits.

B

Inteiro de um byte (8 bits).

W

Inteiro word (2 bytes | 16 bits).

Q

Inteiro quadword (8 bytes | 64 bits).

Todas as instruçÔes SSE que lidam com valores na memória exigem que o valor esteja em um endereço alinhado em 16 bytes, exceto as instruçÔes que explicitamente dizem lidar com dados desalinhados (unaligned).

Caso uma instrução SSE seja executada com um dado desalinhado uma exceção #GP serå disparada.

Usando instruçÔes da FPU

Aprendendo a usar o x87 para fazer cĂĄlculos.

Apenas algumas instruçÔes da FPU serão ensinadas aqui, não sendo uma lista completa.

Um adendo que normalmente compiladores de C nĂŁo trabalham com valores de ponto flutuante desta maneira em x86-64 porque a arquitetura x86 hoje em dia tem maneiras mais eficientes de fazer esses cĂĄlculos. Isso serĂĄ demonstrado no prĂłximo tĂłpico.

Registradores

As instruçÔes da FPU trabalham com os registradores de st0 atĂ© st7, sĂŁo 8 registradores de 80 bits de tamanho cada. Juntos eles formam uma stack (pilha) onde vocĂȘ pode empilhar valores para trabalhar com eles ou desempilhar para armazenar o resultado das operaçÔes em algum lugar.

O empilhamento de valores funciona colocando o novo valor em st0 e todos os outros valores anteriores são "empurrados" para os registradores posteriores. Um exemplo bem leviano dessa operação:

st0 = 10
st1 = 20
st2 = 30

* Ă© feito um push do valor 40

st0 = 40
st1 = 10
st2 = 20
st3 = 30

* Ă© feito um pop, o valor 40 Ă© pego.

st0 = 10
st1 = 20
st2 = 30

Detalhe que só é possível usar esses registradores em instruçÔes da FPU, algo como esse código estå errado:

mov eax, st1

Formato das instruçÔes

As instruçÔes da FPU todas começam com um prefixo F, e as que operam com valores inteiros (convertendo DE ou PARA inteiro) tambĂ©m tem uma letra I apĂłs a letra F. Por fim, instruçÔes que fazem o pop de um valor da pilha, isto Ă©, remove o valor de lĂĄ, terminam com um sufixo P. Entendendo isso fica muito mais fĂĄcil identificar o que cada mnemĂŽnico significa e assim vocĂȘ nĂŁo perde tempo tentando decorar uma sopa de letrinhas, se essas letras existem Ă© porque tem um significado.

Caso tenha vindo de uma arquitetura RISC, geralmente o termo load Ă© usado para a operação em que vocĂȘ carrega um valor da memĂłria para um registrador. JĂĄ store Ă© usado para se referir a operação contrĂĄria, do registrador para a memĂłria.

Nesse caso as operaçÔes podem ser feita entre registradores da FPU também, conforme serå explicado.

Fazer load de um valor é basicamente carregar um valor da memória para a pilha em st0, é como um push quando estamos falando da pilha convencional. A diferença aqui é a maneira como o valor é colocado na pilha, como jå foi explicado anteriormente.

Jå o store é pegar o valor da pilha, mais especificamente em st0, e armazenar em algum lugar da memória. Algumas instruçÔes store permitem armazenar o valor em outro registrador da FPU.

Aqui eu vou ensinar a usar a FPU mas sem diretamente trabalhar com a linguagem C e os tipos float ou double, pois como jĂĄ foi mencionado, nĂŁo Ă© assim que o compilador trabalha com cĂĄlculos de ponto flutuante.

Vou usar a notação memXXfpe memXXint para especificar valores na memória que sejam float ou inteiro, respectivamente. Onde XX seria o tamanho do valor em bits. Jå a notação st(i) serå usada para se referir a qualquer registrador de st0 até st7. O st(0)seria o registrador st0 especificamente.

FINIT | Initialization

finit

Normalmente vamos usar essa instrução antes de começar a usar a FPU, pois ela reseta a FPU para o estado inicial. Dessa forma quaisquer operaçÔes anteriores com a FPU são descartadas e podemos começar tudo do zero. Assim não é necessårio, por exemplo, a gente limpar a pilha da FPU toda vez que terminar as operaçÔes com ela. Basta rodar essa instrução antes de uså-la.

FLD, FILD | (Integer) Load

fld mem32fp
fld mem64fp
fld mem80fp
fld st(i)

fild mem16int
fild mem32int
fild mem64int

A instrução fld carrega um valor float de 32, 64 ou 80 bits para st0. Repare como Ă© possĂ­vel dar load em um dos registradores da pilha, o que torna possĂ­vel retrabalhar com valores anteriormente carregados. Se vocĂȘ rodar fld st0 estarĂĄ basicamente duplicando o Ășltimo valor carregado.

JĂĄ fild carrega um valor inteiro sinalizado de 16, 32 ou 64 bits o convertendo para float de 64 bits.

Load Constant

Existem vårias instruçÔes para dar push de valores constantes na pilha da FPU, e elas são:

Instrução

Valor

FLD1

+1.0

FLDZ

+0.0

FLDL2T

log2(10)

FLDL2E

log2(e)

FLDPI

Valor de PI. (3.1415 blabla...)

FLDLG2

log10(2)

FLDLN2

logE(2)

FST, FSTP | Store (and Pop)

fst mem32fp
fst mem64fp
fst st(i)

fstp mem32fp
fstp mem64fp
fstp mem80fp
fstp st(i)

Pega o valor float de st0 e copia para o operando destino. A versão com o sufixo P também faz o pop do valor da stack, sendo possível dar store em um float de 80 bits somente com essa instrução.

FIST, FISTP | Integer Store (and Pop)

fist mem16int
fist mem32int

fistp mem16int
fistp mem32int
fistp mem64int

Pega o valor em st0, converte para inteiro sinalizado e armazena no operando destino. Só é possível dar store em um inteiro de 64 bits na versão da instrução que faz o pop.

Só com essas instruçÔes jå podemos converter um float para inteiro e vice-versa. Conforme exemplo:

bits 64

section .data
  num: dq 23.87

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld   qword [num]
  fistp dword [result]

  mov eax, [result]
  ret
#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

Se vocĂȘ rodar esse teste irĂĄ notar que o valor foi convertido para 24 jĂĄ que houve um arredondamento.

FADD, FADDP, FIADD | (Integer) Add (and Pop)

fadd mem32fp
fadd mem64fp
fadd st(0), st(i)
fadd st(i), st(0)

faddp st(i), st(0)
faddp

fiadd mem16int
fiadd mem32int

As versÔes de fadd com operando na memória faz a soma do operando com st0 e armazena o resultado da soma no próprio st0. Jå fiadd com operando em memória faz a mesma coisa, porém convertendo o valor inteiro para float 64 bits antes.

As instruçÔes com registradores fazem a soma e armazenam o resultado no operando mais a esquerda, o operando destino. Enquanto a faddp sem operandos soma st0 com st1, armazena o resultado em st1 e depois faz o pop.

Exemplo de soma simples:

bits 64

section .data
  num1: dq 24.3
  num2: dq 0.7

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld  qword [num1]
  fadd qword [num2]

  fist dword [result]
  mov eax, [result]
  ret
#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

FSUB, FSUBP, FISUB | (Integer) Subtract (and Pop)

fsub mem32fp
fsub mem64fp
fsub st(0), st(i)
fsub st(i), st(0)

fsubp st(i), st(0)
fsubp

fisub mem16int
fisub mem32int

Mesma coisa que as instruçÔes acima, só que fazendo uma operação de subtração.

FDIV, FDIVP, FIDIV | (integer) Division (and Pop)

fdiv mem32fp
fdiv mem64fp
fdiv st(0), st(i)
fdiv st(i), st(0)

fdivp st(i), st(0)
fdivp

fidiv mem16int
fidiv mem32int

Mesma coisa que FADD etc. porém faz uma operação de divisão.

FMUL, FMULP, FIMUL | (Integer) Multiply (and Pop)

fmul mem32fp
fmul mem64fp
fmul st(0), st(i)
fmul st(i), st(0)

fmulp st(i), st(0)
fmulp

fimul mem16int
fimul mem32int

Cansei de repetir, jå sabe né? Operação de multiplicação.

FSUBR, FSUBRP, FISUBR | (Integer) Subtract Reverse (and Pop)

Faz a mesma coisa que a família FSUB só que com os operandos ao contrårio. Conforme ilustração:

a = a - b // fsub etc.
a = b - a // fsubr etc.

Ou seja faz a subtração na ordem inversa dos operandos, porém onde o resultado é armazenado continua sendo o mesmo.

FDIVR, FDIVRP, FIDIVRP | (Integer) Division Reverse (and Pop)

Mesma lógica que as instruçÔes acima, porém faz a divisão na ordem inversa dos operandos.

FXCH | Exchange

fxch st(i)
fxch

Seguindo a mesma lógica da instrução xchg, troca o valor de st0 com st(i). A versão da instrução sem operando especificado faz a troca entre st0 e st1.

FSQRT | Square root

fsqrt

Calcula a raĂ­z quadrada de st0 e armazena o resultado no prĂłprio st0.

FABS | Absolute

fabs

Calcula o valor absoluto de st0 e armazena em st0. Basicamente zera o bit de sinalização do valor.

FCHS | Change Sign

fchs

Inverte o sinal de st0, se era negativo passa a ser positivo e vice-versa.

FCOS | Cosine

fcos

Calcula o cosseno de st0 que deve ser um valor radiano, e armazena o resultado nele prĂłprio.

FSIN | Sine

fsin

Calcula o seno de st0, que deve estar em radianos.

FSINCOS | Sine and Cosine

fsincos

Calcula o seno e o cosseno de st0. O cosseno Ă© armazenado em st0 enquanto o seno estarĂĄ em st1.

FPTAN | Partial Tangent

fptan

Calcula a tangente de st0 e armazena o resultado no prĂłprio registrador, logo apĂłs faz o push do valor 1.0 na pilha. O valor em st0 para ser calculado deve estar em radianos.

FPATAN | Partial Arctangent

fpatan

Calcula o arco-tangente de st1 dividido por st0, armazena o resultado em st1 e depois faz o pop. O resultado tem o mesmo sinal que o operando que estava em st1.

st1=arctan⁥(st1÷st0)st1 = \arctan( st1 \div st0 )st1=arctan(st1÷st0)

F2XM1 | 2^x - 1

f2xm1

Faz o cĂĄlculo de 2 elevado a st0 menos 1, e armazena o resultado em st0.

st0=2st0−1st0 = 2^{st0} - 1st0=2st0−1

FYL2X | y * log2(x)

fyl2x

Faz esse cĂĄlculo aĂ­ com logaritmo de base 2:

st1=st1⋅log⁡2(st0)st1 = st1 \cdot \log_2(st0)st1=st1⋅log2​(st0)

ApĂłs o cĂĄlculo Ă© feito um pop.

FYL2XP1 | y * log2(x + 1)

fyl2xp1

Mesma coisa que a instrução anterior porém somando 1 a st0.

st1=st1⋅log⁡2(st0+1)st1 = st1 \cdot \log_2(st0 + 1)st1=st1⋅log2​(st0+1)

FRNDINT | Round to Integer

frndint

Arredonda st0 para a parte inteira mais prĂłxima e armazena o resultado em st0.

FPREM, FPREM1 | Partial Reminder

fprem
fprem1

As duas instruçÔes armazenam a sobra da divisão entre st0 e st1 no registrador st0. Com a diferença que fprem1 segue a padronização IEEE-754.

FCOMI, FCOMIP, FUCOMI, FUCOMIP | Compare

fcomi  st(0), st(i)
fcomip st(0), st(i)

fucomi  st(0), st(i)
fucomip st(0), st(i)

Faz a comparação entre st0 e st(i) setando as status flags de acordo. A diferença de fucomi e fucomip é que essas duas verificam se os valores nos registradores não são NaN, sendo o caso a instrução irå disparar uma exception #IA.

FCMOVcc | Conditional Move

fcmovb  st(0), st(i)
fcmove  st(0), st(i)
fcmovbe st(0), st(i)
fcmovu  st(0), st(i)

fcmovnb  st(0), st(i)
fcmovne  st(0), st(i)
fcmovnbe st(0), st(i)
fcmovnu  st(0), st(i)

Faz uma operação move condicional levando em consideração as status flags.

Vendo os resultados

bits 64

section .data
  num1: dq 3.0
  num2: dq 3.0

section .bss
  result: resd 1

section .text
global assembly
assembly:
  finit
  fld  qword [num1]
  fmul qword [num2]

  fst dword [result]
  movss xmm0, [result]
  ret
#include <stdio.h>

float assembly(void);

int main(void)
{
  printf("Resultado: %f\n", assembly());
  return 0;
}

Endereçamento

Entendendo o acesso Ă  memĂłria RAM na prĂĄtica

O processador acessa dados da memória principal usando o que é chamado de endereço de memória. Para o hardware da memória RAM o endereço nada mais é que um valor numérico que serve como índice para indicar qual byte deve ser acessado na memória. Imagine a memória RAM como uma grande array com bytes sequenciais, onde o endereço de memória é o índice de cada byte. Esse "índice" é chamado de endereço físico (physical address).

Porém o acesso a operandos na memória principal é feito definindo alguns fatores que, após serem calculados pelo processador, resultam no endereço físico que serå utilizado a partir do barramento de endereço (address bus) para acessar aquela região da memória. Do ponto de vista do programador são apenas algumas somas e multiplicaçÔes.

O endereçamento de um operando tambĂ©m pode ser chamado de endereço efetivo, ou em inglĂȘs, effective address.

Não tente ler ou modificar a memória com nossa PoC ainda. No final do tópico eu falo sobre a instrução LEA que pode ser usada para testar o endereçamento.

Endereçamento em IA-16

No código de måquina da arquitetura IA-16 existe um byte chamado ModR/M que serve para especificar algumas informaçÔes relacionadas ao acesso de (R)egistradores e/ou (M)emória. O endereçamento em IA-16 é totalmente especificado nesse byte e ele nos permite fazer um cålculo no seguinte formato: REG + REG + DESLOCAMENTO

Onde REG seria o nome de um registrador e DESLOCAMENTO um valor numérico também somado ao endereço. Os registradores BX, BP, SI e DI podem ser utilizados. Enquanto o deslocamento é um valor de 8 ou 16 bits.

Nesse cålculo um dos registradores é usado como base, o endereço inicial, e o outro é usado como índice, um valor numérico a ser somado à base assim como o deslocamento. Os registradores BX e BP são usados para base enquanto SI e DI são usados para índice. Perceba que não é possível somar base+base e nem índice+índice.

Alguns exemplos para facilitar o entendimento:

mov [bx],           ax ; Correto!
mov [bx+si],        ax ; Correto!
mov [bp+di],        ax ; Correto!
mov [bp+si],        ax ; Correto!
mov [bx+di + 0xa1], ax ; Correto!
mov [si],           ax ; Correto!
mov [0x1a],         ax ; Correto!

mov [dx],    ax ; ERRADO!
mov [bx+bp], ax ; ERRADO!
mov [si+di], ax ; ERRADO!

Endereçamento em IA-32

Em IA-32 o cĂłdigo de mĂĄquina tem tambĂ©m o byte SIB que Ă© um novo modo de endereçamento. Enquanto em IA-16 nĂłs temos apenas uma base e um Ă­ndice, em IA-32 nĂłs ganhamos tambĂ©m um fator de escala. O fator de escala Ă© basicamente um nĂșmero que irĂĄ multiplicar o valor de Ă­ndice.

  • O valor do fator de escala pode ser 1, 2, 4 ou 8.

  • O registrador de Ă­ndice pode ser qualquer um dos registradores de propĂłsito geral exceto ESP.

  • O registrador de base pode ser qualquer registrador geral.

  • O deslocamento pode ser de 8 ou 32 bits.

Exemplos:

mov [edx],                      eax ; Correto!
mov [ebx+ebp],                  eax ; Correto!
mov [esi+edi],                  eax ; Correto!
mov [esp+ecx],                  eax ; Correto!
mov [ebx*4 + 0x1a],             eax ; Correto!
mov [ebx + ebp*8 + 0xab12cd34], eax ; Correto!
mov [esp + ebx*2],              eax ; Correto!
mov [0xffffaaaa],               eax ; Correto!

mov [esp*2], eax   ; ERRADO!

SIB Ă© sigla para Scale, Index and Base. Que sĂŁo os trĂȘs valores usados para calcular o endereço efetivo.

Endereçamento em x86-64

Em x86-64 segue a mesma premissa de IA-32 com alguns adendos:

  • É possĂ­vel usar registradores de 32 ou 64 bit.

  • Os registradores de R8 a R15 ou R8D a R15D podem ser usados como base ou Ă­ndice.

  • NĂŁo Ă© possĂ­vel mesclar registradores de 32 e 64 bits em um mesmo endereçamento.

  • O byte ModR/M tem um novo endereçamento RIP + deslocamento. Onde o deslocamento Ă© necessariamente de 32 bits.

Exemplos:

mov [rbx], rax           ; Correto!
mov [ebx], rax           ; Correto!
mov [r15 + r10*4], rax   ; Correto!
mov [r15d + r10d*4], rax ; Correto!

mov [r10 + r15d], rax    ; ERRADO!
mov [rsp*2],      rax    ; ERRADO!

Na sintaxe do NASM para usar um endereçamento relativo ao RIP deve-se usar a keyword rel para determinar que se trata de um endereço relativo. Também é possível usar a diretiva default rel para setar o endereçamento como relativo por padrão. Exemplo:

mov [rel my_label], rax

; OU:

default rel
mov [my_label], rax

Truque do NASM

Cuidado para não se confundir em relação ao fator de escala. Veja por exemplo esta instrução 64-bit:

mov [rbx*3], rax

Apesar de 3 não ser um valor vålido de escala o NASM irå montar o código sem apresentar erros. Isso acontece porque ele converteu a instrução para a seguinte:

mov [rbx + rbx*2], rax

Ele usa RBX tanto como base como também índice e usa o fator de escala 2. Resultando no mesmo valor que se multiplicasse RBX por 3. Esse é um truque do NASM que pode levar ao erro, por exemplo:

mov [rsi + rbx*3], rax

Dessa vez acusaria erro jĂĄ que a base foi explicitada. Lembre-se que os fatores de escala vĂĄlidos sĂŁo 1, 2, 4 ou 8.

Instrução LEA

lea registrador, [endereço]

A instrução LEA, sigla para Load Effective Address, calcula o endereço efetivo do segundo operando e armazena o resultado do cĂĄlculo em um registrador. Essa instrução pode ser Ăștil para testar o cĂĄlculo do effective address e ver os resultados usando nossa PoC, conforme exemplo abaixo:

bits 64

global assembly
assembly:
  mov rbx, 5
  mov rcx, 10
  lea eax, [rcx + rbx*2 + 5]
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

Saltos

Desviando o fluxo de execução do código

Provavelmente vocĂȘ jĂĄ sabe o que Ă© um desvio de fluxo de cĂłdigo em uma linguagem de alto nĂ­vel. Algo como uma instrução if que condicionalmente executa um determinado bloco de cĂłdigo, ou um for que executa vĂĄrias vezes o mesmo bloco de cĂłdigo. Tudo isso Ă© possĂ­vel devido ao desvio do fluxo de cĂłdigo. Vamos a um pseudo-exemplo de um if:

1. Compare o valor de X com Y
2. Se o valor de X for maior, pule para 4.
3. Adicione 2 ao valor de X
4.

Repare que se a comparação no passo 1 der que o valor de X é maior, a instrução no passo 2 faz um desvio para o passo 4. Desse jeito o passo 3 nunca serå executado. Porém caso a condição no passo 2 for falsa, isto é, o valor de X não é maior do que o valor de Y então o desvio não irå acontecer e o passo 3 serå executado.

Ou seja o passo 3 só serå executado sob uma determinada condição. Isso é um código condicional, isso é um if. Repare que o resultado da comparação no passo 1 precisa ficar armazenado em algum lugar, e este "lugar" é o registrador FLAGS.

Salto nĂŁo condicional

Antes de vermos um desvio de fluxo condicional vamos entender como é o próprio desvio de fluxo em si. Na verdade existem muito mais registradores do que os que eu jå citei. E um deles é o registrador IP, sigla para Instruction Pointer (ponteiro de instrução). Esse registrador também acompanha o tamanho do barramento interno, assim como os registradores gerais:

IP

EIP

RIP

16 bits

32 bits

64 bits

Assim como o nome sugere o Instruction Pointer serve como um ponteiro para a próxima instrução a ser executada pelo processador. Desse jeito é possível mudar o fluxo do código simplesmente alterando o valor de IP, porém não é possível fazer isso diretamente com uma instrução como a mov.

Na arquitetura x86 existem as instruçÔes de jump, salto em inglĂȘs, que alteram o valor de IP permitindo assim que o fluxo seja alterado. A instrução de jump nĂŁo condicional, intuitivamente, se chama JMP. Esse desvio de fluxo Ă© algo muito semelhante com a instrução goto da linguagem C, inclusive em boa parte das vezes o compilador converte o goto para meramente um JMP.

O uso da instrução JMP é feito da seguinte forma:

jmp endereço

Onde o operando vocĂȘ pode passar um rĂłtulo que o assembler irĂĄ converter para o endereço corretamente. Veja o exemplo na nossa PoC:

bits 64

global assembly
assembly:
  mov eax, 555
  jmp .end

  mov eax, 333

.end:
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

A instrução na linha 8 nunca serå executada devido ao JMP na linha 6.

Registrador FLAGS

O registrador FLAGS também é estendido junto ao tamanho do barramento interno. Então temos:

FLAGS

EFLAGS

RFLAGS

16 bits

32 bits

64 bits

Esse registrador, diferente dos registradores gerais, nĂŁo pode ser acessado diretamente por uma instrução. O valor de cada bit do registrador Ă© testado por determinadas instruçÔes e sĂŁo ligados e desligados por outras instruçÔes. É testando o valor dos bits do registrador FLAGS que as instruçÔes condicionais funcionam.

Salto condicional

Os jumps condicionais, normalmente referidos como Jcc, são instruçÔes que condicionalmente fazem o desvio de fluxo do código. Elas verificam os valores dos bits do registrador FLAGS e, com base nos valores, serå decidido se o salto serå tomado ou não. Assim como no caso do JMP as instruçÔes Jcc também recebem como operando o endereço para onde devem tomar o salto caso a condição seja atendida. Se ela não for atendida então o fluxo de código continuarå normalmente.

Eis a lista dos saltos condicionais mais comuns:

Instrução

Nome estendido

Condição

JE

Jump if Equal

Pula se for igual

JNE

Jump if Not Equal

Pula se nĂŁo for igual

JL

Jump if Less than

Pula se for menor que

JG

Jump if Greater than

Pula se for maior que

JLE

Jump if Less or Equal

Pula se for menor ou igual

JGE

Jump if Greater or Equal

Pula se for maior ou igual

O nome Jcc para se referir aos saltos condicionais vem do prefixo 'J' seguido de 'cc' para indicar uma condição, que é o formato da nomenclatura das instruçÔes.

Exemplo: JLE -- 'J' prefixo, 'LE' condição (Less or Equal)

Essa mesma nomenclatura também é usada para as outras instruçÔes condicionais, como por exemplo CMOVcc.

A maneira mais comum usada para setar as flags para um salto condicional é a instrução CMP. Ela recebe dois operandos e compara o valor dos dois, com base no resultado da comparação ela seta as flags corretamente. Agora um exemplo na nossa PoC:

bits 64

global assembly
assembly:
  mov eax, 0

  mov rbx, 7
  mov rcx, 5  
  cmp rbx, rcx
  jle .end

  mov eax, 1
.end:
  ret

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}

Na linha 10 temos um Jump if Less or Equal para o rótulo local .end, e logo na linha anterior uma comparação entre RBX e RCX. Se o valor de RBX for menor ou igual a RCX, então o salto serå tomado e a instrução na linha 12 não serå executada. Desta forma temos algo muito parecido com o if no pseudocódigo abaixo:

eax = 0;
rbx = 7;
rcx = 5;
if(rbx > rcx){
  eax = 1;
}
return;

Repare que a condição para o código ser executado é exatamente o oposto da condição para o salto ser tomado. Afinal de contas a lógica é que caso o salto seja tomado o código não serå executado.

Experimente modificar os valores de RBX e RCX, e também teste usando outros Jcc.

InstruçÔes aritméticas

InstruçÔes de operação aritmética do SSE.

ADDP(S|D) | Add Packed (Single|Double)-precision floating-point values

ADDPS xmm(n), xmm(n)
ADDPS xmm(n), float(4)


ADDPD xmm(n), xmm(n)
ADDPD xmm(n), double(2)

Soma 4 nĂșmeros float (ou 2 nĂșmeros double) de uma Ășnica vez no registrador destino com os quatro nĂșmeros float (ou 2 nĂșmeros double) do registrador/memĂłria fonte. Exemplo:

#include <stdio.h>

void assembly(float *array);

int main(void)
{
  float array[4] = {5.0f, 5.0f, 5.0f, 5.0f};
  assembly(array);

  printf("Resultado: %f, %f, %f, %f\n", array[0], array[1], array[2], array[3]);
  return 0;
}
bits 64
default rel

section .rodata align=16
    sum_array: dd 1.5
               dd 2.5
               dd 3.5
               dd 4.5

section .text

global assembly
assembly:
    movaps xmm0, [rdi]
    addps xmm0, [sum_array]
    movaps [rdi], xmm0
    ret

SUBP(S|D) | Subtract Packed (Single|Double)-precision floating-point values

SUBPS xmm(n), xmm(n)
SUBPS xmm(n), float(4)


SUBPD xmm(n), xmm(n)
SUBPD xmm(n), double(2)

Funciona da mesma forma que a instrução anterior porém faz uma operação de subtração nos valores.

ADDS(S|D) | Add Scalar (Single|Double)-precision floating-point value

ADDSS xmm(n), xmm(n)
ADDSS xmm(n), float(1)


ADDSD xmm(n), xmm(n)
ADDSD xmm(n), double(1)

ADDSS faz a adição do float contido no double word (4 bytes) menos significativo do registrador XMM. Jå ADDSD faz a adição do double contido na quadword (8 bytes) menos significativa do registrador.

Conforme exemplo abaixo:

#include <stdio.h>

float sum(float x, float y);

int main(void)
{
  printf("Resultado: %f\n", sum(5.0f, 1.5f));
  return 0;
}
bits 64

section .text

global sum
sum:
    addss xmm0, xmm1
    ret

SUBS(S|D) | Subtract Scalar (Single|Double)-precision floating-point value

SUBSS xmm(n), xmm(n)
SUBSS xmm(n), float(1)


SUBSD xmm(n), xmm(n)
SUBSD xmm(n), double(1)

Funciona da mesma forma que a instrução anterior porém subtraindo os valores.

MULP(S|D) | Multiply Packed (Single|Double)-precision floating-point values

MULPS xmm(n), xmm(n)
MULPS xmm(n), float(4)


MULPD xmm(n), xmm(n)
MULPD xmm(n), double(2)

Funciona como ADDPS/ADDPD porĂ©m multiplicando os nĂșmeros ao invĂ©s de somĂĄ-los.

MULS(S|D) | Multiply Scalar (Single|Double)-precision floating-point value

MULSS xmm(n), xmm(n)
MULSS xmm(n), float(1)


MULSD xmm(n), xmm(n)
MULSD xmm(n), double(1)

Funciona como ADDSS/ADDSD porĂ©m multiplicando os nĂșmeros ao invĂ©s de somĂĄ-los.

DIVP(S|D) | Divide Packed (Single|Double)-precision floating-point values

DIVPS xmm(n), xmm(n)
DIVPS xmm(n), float(4)


DIVPD xmm(n), xmm(n)
DIVPD xmm(n), double(2)

Funciona como ADDPS/ADDPD porĂ©m dividindo os nĂșmeros ao invĂ©s de somĂĄ-los.

DIVS(S|D) | Divide Scalar (Single|Double)-precision floating-point value

DIVSS xmm(n), xmm(n)
DIVSS xmm(n), float(1)


DIVSD xmm(n), xmm(n)
DIVSD xmm(n), double(1)

Funciona como ADDSS/ADDSD porĂ©m dividindo os nĂșmeros ao invĂ©s de somĂĄ-los.

RCPPS | Compute Reciprocals of Packed Single-precision floating-point values

RCPPS xmm(n), xmm(n)
RCPPS xmm(n), float(4)

RCPSS | Compute Reciprocal of Scalar Single-precision floating-point value

RCPSS xmm(n), xmm(n)
RCPSS xmm(n), float(1)

Calcula o valor aproximado do inverso multiplicativo do float no operando fonte (a direita) e armazena o resultado na double word (4 bytes) menos significativa do operando destino.

SQRTP(S|D) | Compute square roots of Packed (Single|Double)-precision floating-point values

SQRTPS xmm(n), xmm(n)
SQRTPS xmm(n), float(4)


SQRTPD xmm(n), xmm(n)
SQRTPD xmm(n), double(2)

Calcula as raĂ­zes quadradas dos nĂșmeros floats/doubles no operando fonte e armazena os resultados no operando destino.

SQRTS(S|D) | Compute square root of Scalar (Single|Double)-precision floating-point value

SQRTSS xmm(n), xmm(n)
SQRTSS xmm(n), float(1)


SQRTSD xmm(n), xmm(n)
SQRTSD xmm(n), double(1)

Calcula a raiz quadrada do nĂșmero escalar no operando fonte e armazena o resultado no float/double menos significativo do operando destino. Exemplo:

#include <stdio.h>

double my_sqrt(double x);

int main(void)
{
  printf("Resultado: %f\n", my_sqrt(81.0));
  return 0;
}
bits 64

section .text

global my_sqrt
my_sqrt:
    sqrtsd xmm0, xmm0
    ret

RSQRTPS | Compute Reciprocals of square roots of Packed Single-precision floating-point values

RSQRTPS xmm(n), xmm(n)
RSQRTPS xmm(n), float(4)

RSQRTSS | Compute Reciprocal of square root of Scalar Single-precision floating-point value

RSQRTSS xmm(n), xmm(n)
RSQRTSS xmm(n), float(1)

Calcula o inverso multiplicativo da raiz quadrada do nĂșmero escalar no operando fonte e armazena o resultado no double word menos significativo do operando destino.

MAXP(S|D) | return maximum of Packed (Single|Double)-precision floating-point values

MAXPS xmm(n), xmm(n)
MAXPS xmm(n), float(4)


MAXPD xmm(n), xmm(n)
MAXPD xmm(n), double(2)

Compara cada um dos valores contidos nos dois operandos e retorna o maior valor entre os dois.

MAXS(S|D) | return maximum of Scalar (Single|Double)-precision floating-point value

MAXSS xmm(n), xmm(n)
MAXSS xmm(n), float(1)


MAXSD xmm(n), xmm(n)
MAXSD xmm(n), double(1)

Compara os dois valores escalares e armazena o maior deles no float/double menos significativo do operando destino.

MINP(S|D) | return minimum of Packed (Single|Double)-precision floating-point values

MINPS xmm(n), xmm(n)
MINPS xmm(n), float(4)


MINPD xmm(n), xmm(n)
MINPD xmm(n), double(2)

Funciona da mesma forma que MAXPS/MAXPD porém retornando o menor valor entre cada comparação.

MINS(S|D) | return minimum of Scalar (Single|Double)-precision floating-point value

MINSS xmm(n), xmm(n)
MINSS xmm(n), float(1)


MINSD xmm(n), xmm(n)
MINSD xmm(n), double(1)

Funciona da mesma forma que MAXSS/MAXSD porém retornando o menor valor entre os dois.

InstruçÔes com inteiros 128-bit

PAVGB/PAVGW | Compute average of packed unsigned (byte|word) of integers

PAVGB xmm(n), xmm(n)
PAVGB xmm(n), ubyte(16)


PAVGW xmm(n), xmm(n)
PAVGW xmm(n), uword(8)

Calcula a média da soma de todos os valores dos dois operandos somados. PAVGB calcula a média somando 16 bytes em cada operando, enquanto PAVGW soma 8 words em cada um.

PEXTRW | Extract word

PEXTRW reg32/64, xmm(n), imm8
PEXTRW uword(1), xmm(n), imm8  ; Adicionado no SSE4

O operando imediato Ă© um valor entre 0 e 7 que indica o Ă­ndice da word no registrador XMM. Apenas os 3 bits menos significativos do valor sĂŁo considerados, os demais sĂŁo ignorados.

PINSRW | Insert word

PINSRW xmm(n), reg32, imm8
PINSRW xmm(n), uword(1), imm8

Copia uma word dos 16 bits menos significativos do registrador de propósito geral no segundo operando e armazena em uma das words no registrador XMM. Também é possível ler a word da memória principal.

Assim como no caso do PEXTRW o operando imediato serve para identificar o Ă­ndice da word no registrador XMM.

PMAXUB/PMAXUW | Maximum of packed unsigined (byte|word) of integers

PMAXUB xmm(n), xmm(n)
PMAXUB xmm(n), ubyte(16)


PMAXUW xmm(n), xmm(n)      ; Adicionado no SSE4
PMAXUW xmm(n), uword(8)    ; Adicionado no SSE4

Compara os bytes/words não-sinalizados dos dois operandos packed e armazena o maior deles em cada uma das comparaçÔes no operando destino (o primeiro).

PMINUB/PMINUW | Minimum of packed unsigned (byte|word) of integers

PMINUB xmm(n), xmm(n)
PMINUB xmm(n), ubyte(16)


PMINUW xmm(n), xmm(n)      ; Adicionado no SSE4
PMINUW xmm(n), uword(8)    ; Adicionado no SSE4

Faz o mesmo que a instrução anterior porĂ©m armazenando o menor nĂșmero de cada comparação.

PMAXS(B|W|D) | Maximum of packed signed (byte|word|doubleword) integers

PMAXSB xmm(n), xmm(n)       ; Adicionado no SSE4
PMAXSB xmm(n), byte(16)     ; Adicionado no SSE4


PMAXSW xmm(n), xmm(n)
PMAXSW xmm(n), word(8)


PMAXSD xmm(n), xmm(n)       ; Adicionado no SSE4
PMAXSD xmm(n), dword(4)     ; Adicionado no SSE4

Faz o mesmo que PMAXUB/PMAXUW porém considerando o valor como sinalizado. Também hå a instrução PMAXSD que compara quatro double words (4 bytes) empacotados.

PMINS(B|W) | Minimum of packed signed (byte|word) integers

PMINSB xmm(n), xmm(n)       ; Adicionado no SSE4
PMINSB xmm(n), byte(16)     ; Adicionado no SSE4


PMINSW xmm(n), xmm(n)
PMINSW xmm(n), word(8)

Faz o mesmo que PMAXSB/PMAXSW porém retornando o menor valor de cada comparação.

PMOVMSKB | Move byte mask

PMOVMSKB reg32/64, xmm(n)

Armazena nos 16 bits menos significativos do registrador de propĂłsito geral cada um dos bits mais significativos (MSB) de todos os bytes contidos no registrador XMM.

PMULHW/PMULHUW | Multiply packed (unsigned) word integers and store high result

PMULHW xmm(n), xmm(n)
PMULHW xmm(n), uword(8)


PMULHUW xmm(n), xmm(n)
PMULHUW xmm(n), uword(8)

Multiplica cada uma das words dos operandos onde o resultado temporårio da multiplicação é de 32 bits de tamanho. Porém armazena no operando destino somente a word mais significativa do resultado da multiplicação.

PMULHW faz uma multiplicação sinalizada, enquanto PMULHUW faz uma multiplicação não-sinalizada.

PSADBW | Compute sum of absolute differences

PSADBW xmm(n), xmm(n)
PSADBW xmm(n), ubyte(16)

Calcula a diferença absoluta dos bytes dos dois operandos e armazena a soma de todas as diferenças.

A diferença dos 8 bytes menos significativos é somada e armazenada na word menos significativa do operando destino. Jå a diferença dos 8 bytes mais significativos é somada e armazenada na quinta word (índice 4, bits [79:64]) do operando destino. Todas as outras words do registrador XMM são zeradas.

MOVDQA | Move aligned double quadword

MOVDQA xmm(n), xmm(n)
MOVDQA xmm(n), qword(2)
MOVDQA qword(2), xmm(n)

Move dois quadwords (8 bytes) entre registradores XMM ou de/para memória RAM. O endereço na memória precisa estar alinhado a 16 se não uma exceção #GP serå disparada.

MOVDQU | Move unaligned double quadword

MOVDQU xmm(n), xmm(n)
MOVDQU xmm(n), qword(2)
MOVDQU qword(2), xmm(n)

Faz o mesmo que a instrução anterior porém o alinhamento da memória não é necessårio, porém essa instrução é menos performåtica caso acesse um endereço desalinhado.

PADD(B|W|D|Q) | Packed (byte|word|doubleword|quadword) add

PADDB xmm(n), xmm(n)
PADDB xmm(n), byte(16)


PADDW xmm(n), xmm(n)
PADDW xmm(n), word(8)


PADDD xmm(n), xmm(n)
PADDD xmm(n), dword(4)


PADDQ xmm(n), xmm(n)
PADDQ xmm(n), qword(2)

Soma os bytes, words, double words ou quadwords dos operandos e armazena no operando destino.

PSUBQ | Packed quadword subtract

PSUBQ xmm(n), xmm(n)
PSUBQ xmm(n), qword(2)

Faz o mesmo que a instrução PADDQ porém com uma operação de subtração.

PMULUDQ | Multiply packed unsigned doubleword integers

PMULUDQ xmm(n), xmm(n)
PMULUDQ xmm(n), dword(4)

Multiplica o primeiro (índice 0) e o terceiro (índice 2) doublewords dos operandos e armazena o resultado como quadwords no operando destino. O resultado da multiplicação entre os primeiros doublewords é armazenado no quadword menos signfiicativo do operando destino, enquanto a multiplicação entre os terceiros doublewords é armazenada no quadword mais significativo.

Exemplo:

#include <stdio.h>
#include <inttypes.h>

void mularray(uint64_t *output, uint32_t *array);

int main(void)
{
  uint32_t array[] = {3, 1, 2, 1};
  uint64_t output[2];
  mularray(output, array);

  printf("Resultado: %" PRIu64 ", %" PRIu64 "\n", output[0], output[1]);
  return 0;
}
bits 64
default rel

section .rodata align=16
    mul_values: dd 2, 3, 4, 5

section .text

global mularray
mularray:
    movdqa xmm0, [mul_values]
    pmuludq xmm0, [rsi]
    movdqa [rdi], xmm0
    ret

RDI Ă© o primeiro ponteiro recebido como argumento e RSI o segundo.

PSLLDQ | Shift double quadword left logical

PSLLDQ xmm(n), imm8

PSRLDQ | Shift double quadword right logical

PSRLDQ xmm(n), imm8

Faz o mesmo que a instrução anterior porém com um shift right. Os bits mais significativos são zerados.

ConvençÔes de chamada no Windows

Aprendendo sobre as convençÔes de chamada usadas no Windows (x64, cdecl e stdcall).

O Windows tem suas prĂłprias convençÔes de chamadas e o objetivo desse tĂłpico Ă© aprender sobre as trĂȘs principais que dizem respeito Ă  linguagem C.

Convenção de chamada x64

Essa Ă© a convenção de chamada padrĂŁo usada em x86-64 e portanto Ă© essencial aprendĂȘ-la caso vĂĄ programar no Windows diretamente em Assembly.

Registradores

Os registradores RBX, RBP, RDI, RSI, RSP, R12 até R15 e XMM6 até XMM15 devem ser preservados pela função chamada (callee). Caso a função chamada precise alterar o valor de algum desses registradores ela tem a obrigação de preservar o valor anterior e restaurå-lo antes de retornar.

Os demais registradores são considerados volåteis, isto é, podem ter seu valor alterado quando uma chamada de função é efetuada. A função chamada pode modificar o valor dos registradores volåteis livremente.

Passagem de parĂąmetros

  • Os primeiros quatro argumentos inteiros ou ponteiros sĂŁo passados nos seguintes registradores e na mesma ordem: RCX, RDX, R8 e R9. Os demais argumentos devem ser empilhados na ordem inversa.

  • Structs e unions de 8, 16, 32 ou 64 bits sĂŁo passados como se fossem inteiros do respectivo tamanho. Se forem de outro tamanho a função chamadora deve entĂŁo passar um ponteiro para a struct/union que serĂĄ armazenada em uma memĂłria alocada pela prĂłpria função chamadora. Essa memĂłria deve estar em um endereço alinhado por 16 bytes.

A função chamadora (caller) é responsåvel por alocar um espaço de 32 bytes na pilha chamado de shadow space. Ele é alocado com o intuito de ser usado pela função chamada (callee) para armazenar os parùmetros passados em registradores caso seja necessårio, por exemplo caso a função chamada precise usar esses registradores com outro intuito. Esse espaço vem antes mesmo do primeiro parùmetro empilhado.

Exemplo de protótipo de função:

int sum(int a, int b, int c, int d, int e, int f);

Assim que a função fosse chamada ECX, EDX, R8D e R9D armazenariam os parùmetros a, b, c e d respectivamente. O parùmetro f seria empilhado seguido do parùmetro e.

O 0(%rsp) seria o endereço de retorno. O espaço entre 8(%rsp) e 40(%rsp) é o shadow space. 40(%rsp) apontaria para o parùmetro e, enquanto 48(%rsp) apontaria para o parùmetro f. Como na demonstração abaixo:

mov %ecx, 8(%rsp)  # Armazenando o parĂąmetro A no shadow space
mov %edx, 16(%rsp) # ParĂąmetro B
mov %r8d, 24(%rsp) # ParĂąmetro C
mov %r9d, 32(%rsp) # ParĂąmetro D

# ParĂąmetro E: 40(%rsp)
# ParĂąmetro F: 48(%rsp)

Retorno de valores

  • Valores inteiros e ponteiros sĂŁo retornados em RAX.

  • Valores float ou double sĂŁo retornados no registrador XMM0.

  • O retorno de structs Ă© feito com a função chamadora alocando o espaço de memĂłria necessĂĄrio para a struct, ela entĂŁo passa o ponteiro para esse espaço como primeiro argumento para a função em RCX. A função chamada (callee) deve retornar o mesmo ponteiro em RAX.

Convenção de chamada cdecl

A convenção de chamada __cdecl é a convenção padrão usada em código escrito em C na arquitetura IA-32 (x86).

Registradores

Apenas os registradores EAX, ECX e EDX são considerados volåteis, ou seja, registradores que podem ser modificados livremente pela função chamada. Todos os demais registradores precisam ser preservados e restaurados antes do retorno da função.

Passagem de parĂąmetros

Todos os parùmetros são passados na pilha e devem ser empilhados na ordem inversa. A função chamadora (caller) é a responsåvel por remover os argumentos da pilha após a função retornar.

Exemplo:

push $3
push $2
push $1
call my_function
add $12, %esp

# 12 Ă© o tamanho em bytes dos trĂȘs valores empilhados

Retorno de valores

  • Valores inteiros ou ponteiros sĂŁo retornados em EAX.

  • Valores float ou double sĂŁo retornados em ST0.

  • O retorno de structs ocorre da mesma maneira que na convenção de chamada x64. Com a diferença que o primeiro argumento Ă©, obviamente, passado na pilha.

Convenção de chamada stdcall

Registradores

Assim como na __cdecl os registradores EAX, ECX e EDX são volåteis e os demais devem ser preservados pela função chamada.

Passagem de parĂąmetros

Todos os argumentos são passados na pilha na ordem inversa. A função chamada (callee) é a responsåvel por remover os argumentos da pilha. Exemplo:

push $3
push $2
push $1
call my_function

Retorno de valores

O retorno de valores funciona da mesma maneira que o retorno de valores da __cdecl.

Convenção de chamada da System V ABI

Aprendendo sobre a convenção de chamada do C usada no Linux.

Neste tópico vamos aprender sobre a convenção de chamada da SysV ABI e o tamanho dos tipos de dados usados na linguagem C.

Convenção de chamada em x86-64

Registradores

  • Os registradores RBP, RBX, RSP e R12 atĂ© R15 sĂŁo considerados como pertencentes a função chamadora. Isto Ă©, se a função que foi chamada precisar modificar esses registradores ela obrigatoriamente precisa preservar seus valores e antes de retornar restaurĂĄ-los para o valor anterior. Todos os outros registradores podem ser modificados livremente pela função chamada. Portanto nĂŁo espere que esses outros registradores tenham seu valor preservado ao chamar uma função.

Stack frame

assembly.s
    .text
    .globl assembly
assembly:
    sub $8, %rsp

    movl $12344, (%rsp)   # var_0
    movl $1, 4(%rsp)      # var_4

    mov (%rsp), %eax
    add 4(%rsp), %eax

    add $8, %rsp
    ret

O espaço de 128 bytes antes do endereço apontado por RSP é uma região chamada de redzone que por convenção pode ser usada por funçÔes folha (leaf), que são funçÔes que não chamam outras funçÔes. Ou então pode ser usada em qualquer função onde o valor não precise ser preservado após chamar outra função.

O endereço entre -128(%rsp) e -1(%rsp) pode ser usado livremente sem a necessidade de alocar um stack frame.

Passagem de parĂąmetros

Caso a função precise de mais argumentos e os registradores acabem, os demais argumentos serão empilhados na ordem inversa. Por exemplo caso uma função precise de 9 argumentos inteiros eles seriam definidos na seguinte ordem pela função chamadora:

mov $1, %rdi
mov $2, %rsi
mov $3, %rdx
mov $4, %rcx
mov $5, %r8
mov $6, %r9

push $9
push $8
push $7
call my_function
add $24, %esp

Assim que a função fosse chamada 8(%rsp), 16(%rsp) e 24(%rsp) apontariam para os argumentos 7, 8 e 9 respectivamente.

A função chamadora (caller) precisa garantir que o Ășltimo valor empilhado esteja em um endereço alinhado por 16 bytes.

A função chamadora é a responsåvel por remover os argumentos empilhados da pilha.

Retorno de valores

  • No caso do retorno de estruturas (structs) a função chamadora precisa alocar o espaço necessĂĄrio para a struct e passar o endereço do espaço no registrador RDI como se fosse o primeiro argumento para a função (os outros argumentos usam RSI em diante). A função entĂŁo precisa retornar o mesmo endereço passado por RDI em RAX.

  • O retorno de valores inteiros e ponteiros Ă© feito no registrador RAX.

  • Valores float ou double sĂŁo retornados no registrador XMM0 na parte menos significativa.

Convenção de chamada em IA-32

Registradores

  • Os registradores EBX, EBP, ESI, EDI e ESP precisam ter seus valores preservados pela função chamada. Os demais registradores de propĂłsito geral podem ser usados livremente.

  • A Direction Flag (DF) no EFLAGS precisa obrigatoriamente estar zerada ao chamar ou retornar de uma função.

Stack frame

O stack frame em IA-32 funciona da mesma maneira que o stack frame em x86-64, com a diferença de que não existe redzone em IA-32 e toda função que precisar de memória local precisa obrigatoriamente construir um stack frame.

Vale lembrar que cada valor inserido na stack em IA-32 tem 4 bytes de tamanho, enquanto em x86-64 cada valor tem 8 bytes de tamanho.

Passagem de parĂąmetros

Os argumentos da função são empilhados na ordem inversa, assim como ocorre em x86-64 quando os registradores acabam. Conforme exemplo:

push $4
push $3
push $2
push $1
call my_function
add $16, %esp

Assim que a função é chamada 4(%esp), 8(%esp), 12(%esp) e 16(%esp) apontam para os argumentos 1, 2, 3 e 4 respectivamente.

A função chamadora precisa garantir que o Ășltimo valor empilhado esteja em um endereço alinhado por 16 bytes.

A função chamadora é a responsåvel por remover os argumentos empilhados da pilha.

Retorno de valores

  • Retorno de struct Ă© feito de maneira semelhante do x86-64. Um ponteiro para a regiĂŁo de memĂłria para gravar os dados da struct Ă© passado como primeiro argumento para a função (o Ășltimo valor a ser empilhado). É obrigação da função chamada fazer o pop desse ponteiro e retornĂĄ-lo em EAX.

  • Valores inteiros e ponteiros sĂŁo retornados em EAX.

PrĂłlogo e epĂ­logo

Existe uma convenção de escrita do prólogo e do epílogo da função que se trata de preservar o antigo valor de ESP/RSP no registrador EBP/RBP, e depois subtrair ESP/RSP para alocar o stack frame. Conforme exemplo:

example:
    push %rbp
    mov %rsp, %rbp
    sub $16, %rsp
    
    # etc...
    
    mov %rbp, %rsp
    pop %rbp
    ret

TambĂ©m existe a instrução leave que pode ser usada no epĂ­logo. Ela basicamente faz a operação de mov %rbp, %rsp e pop %rbp em uma Ășnica instrução (tambĂ©m pode ser usada em 32 e 16 bits atuando com EBP/ESP e BP/SP respectivamente).

Mas como jå foi demonstrado em um exemplo mais acima isso não é obrigatório e podemos apenas incrementar e subtrair ESP/RSP no prólogo e no epílogo. Código otimizado gerado pelo GCC costuma apenas fazer isso, jå código com a otimização desligada costuma gerar o prólogo e epílogo "clåssico".

Tamanho dos tipos da linguagem C

A tabela abaixo lista os principais tipos da linguagem C e seu tamanho em bytes no IA-32 e x86-64. Como também exibe em qual registrador o tipo deve ser retornado.

Tipo

Tamanho IA-32

Tamanho x86-64

Registrador de retorno IA-32

Registrador de retorno x86-64

_Bool

char

signed char

unsigned char

1

1

AL

AL

short

signed short

unsigned short

2

2

AX

AX

int

signed int

unsigned int

long

signed long

unsigned long

enum

4

4

EAX

EAX

long long

signed long long

unsigned long long

8

8

*EDX:EAX

RAX

Ponteiros

4

8

EAX

RAX

float

4

4

ST0

XMM0

double

8

8

ST0

XMM0

**long double

12

16

ST0

ST0

*No registrador EDX Ă© armazenado os 32 bits mais significativos e em EAX os 32 bits menos significativos.

**O tipo long double ocupa na memória o espaço de 12 e 16 bytes por motivos de alinhamento, mas na verdade se trata de um float de 80 bits (10 bytes).

InstruçÔes de conversão

Convertendo valores entre float, double e inteiro.

Essas instruçÔes servem para conversão de tipos entre float, double e inteiro.

ConversĂŁo entre double e float

CVTPS2PD | Convert packed single-precision floating-point values to packed double-precision floating-point values

CVTPS2PD xmm(n), xmm(n)
CVTPS2PD xmm(n), float(2)

Converte dois valores float do operando fonte (segundo) em dois valores double no operando destino (primeiro).

CVTPD2PS | Convert packed double-precision floating-point values to packed single-precision floating-point values

CVTPD2PS xmm(n), xmm(n)
CVTPD2PS xmm(n), double(2)

Converte dois valores double do operando fonte (segundo) em dois valores float no operando destino (primeiro).

CVTSS2SD | Convert scalar single-precision floating-point value to scalar double-precision floating-point value

CVTSS2SD xmm(n), xmm(n)
CVTSS2SD xmm(n), float(1)

Converte um valor float do operando fonte (segundo) em um valor double no operando destino (primeiro).

CVTSD2SS | Convert scalar double-precision floating-point value to scalar single-precision floating-point value

CVTSD2SS xmm(n), xmm(n)
CVTSD2SS xmm(n), double(1)

Converte um valor double do operando fonte (segundo) em um valor float no operando destino (primeiro).

ConversĂŁo entre double e inteiro

CVTPD2DQ/CVTTPD2DQ | Convert (with truncation) packed double-precision floating-point values to packed doubleword integers

CVTPD2DQ xmm(n), xmm(n)
CVTPD2DQ xmm(n), double(2)


CVTTPD2DQ xmm(n), xmm(n)
CVTTPD2DQ xmm(n), double(2)

Converte os dois doubles no operando fonte para dois inteiros sinalizados de 32-bit no operando destino. A instrução CVTPD2DQ faz o arredondamento normal do valor enquanto CVTTPD2DQ trunca ele.

CVTDQ2PD | Convert packed doubleword integers to packed double-precision floating-point values

CVTDQ2PD xmm(n), xmm(n)
CVTDQ2PD xmm(n), dword(2)

Converte os dois inteiros sinalizados de 32-bit no operando fonte para dois doubles no operando destino.

CVTSD2SI/CVTTSD2SI | Convert scalar double-precision floating-point value to doubleword integer

CVTSD2SI reg32/64, xmm(n)
CVTSD2SI reg32/64, double(1)

CVTTSD2SI reg32/64, xmm(n)
CVTTSD2SI reg32/64, double(1)

CVTTSD2SI faz a mesma coisa porém truncando o valor.

CVTSI2SD | Convert doubleword integer to scalar double-precision floating-point value

CVTSI2SD xmm(n), reg32/64
CVTSI2SD xmm(n), dword(1)
CVTSI2SD xmm(n), qword(1)

Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um double no operando destino.

ConversĂŁo entre float e inteiro

CVTPS2DQ/CVTTPS2DQ | Convert (with truncation) packed single-precision floating-point values to packed doubleword integers

CVTPS2DQ xmm(n), xmm(n)
CVTPS2DQ xmm(n), float(4)


CVTTPS2DQ xmm(n), xmm(n)
CVTTPS2DQ xmm(n), float(4)

Converte quatro floats do operando fonte em quatro inteiros sinalizados de 32-bit no operando destino. A instrução CVTPS2DQ faz o arredondamento normal dos valores enquanto CVTTPS2DQ trunca eles.

CVTDQ2PS | Convert packed doubleword integers to packed single-precision floating-point values

CVTDQ2PS xmm(n), xmm(n)
CVTDQ2PS xmm(n), dword(4)

Converte quatro inteiros sinalizados de 32-bit no operando fonte para quatro floats no operando destino.

CVTSS2SI/CVTTSS2SI | Convert scalar single-precision floating-point value to doubleword integer

CVTSS2SI reg32/64, xmm(n)
CVTSS2SI reg32/64, float(1)


CVTTSS2SI reg32/64, xmm(n)
CVTTSS2SI reg32/64, float(1)

A instrução CVTTSS2SI faz a mesma coisa porém truncando o valor.

CVTSI2SS | Convert doubleword integer to scalar single-precision floating-point value

CVTSI2SS xmm(n), reg32/64
CVTSI2SS xmm(n), dword(1)
CVTSI2SS xmm(n), qword(1)

Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um float no operando destino.

Programando junto com C

Aprendendo a mesclar Assembly e C

Se vocĂȘ leu o conteĂșdo do livro atĂ© aqui jĂĄ tem uma boa base para entender como o Assembly x86 funciona e como usĂĄ-lo. TambĂ©m jĂĄ tem uma boa noção do que estĂĄ fazendo, entende bem o que o assembler faz e o que ele estĂĄ produzindo como saĂ­da, sabe como efetuar cĂĄlculos em paralelo usando SSE inclusive com valores de ponto flutuante.

Em outras palavras vocĂȘ jĂĄ tem a base necessĂĄria para realmente entender como as coisas funcionam, nĂŁo decoramos instruçÔes aqui mas sim entendemos as coisas em seu Ăąmago. Agora estĂĄ na hora de dar um passo a frente e entender como usar Assembly de uma maneira Ăștil no "mundo real", vamos aprender a usar C e Assembly juntos afim de escrever programas.

Ferramentas

Como jĂĄ mencionado antes vamos usar o GCC para compilar nossos cĂłdigos em C. Mas diferente dos capĂ­tulos anteriores que usamos o NASM, neste aqui vamos usar o assembler GAS com sintaxe da AT&T porque assim aprendemos a ler cĂłdigo nessa sintaxe e a usar o GAS ao mesmo tempo.

Por convenção a gente usa a extensão .s (ao invés de .asm) para código ASM com sintaxe da AT&T, então é a extensão que irei usar daqui em diante para nomear os arquivos.

#include <stdio.h>

int assembly(void);

int main(void)
{
  printf("Resultado: %d\n", assembly());
  return 0;
}
    .text
    .globl assembly
assembly:
    movl $777, %eax
    ret

O nome do executĂĄvel do GAS Ă© as e quando vocĂȘ instala o GCC ele vem junto, entĂŁo vocĂȘ jĂĄ tem ele instalado aĂ­. JĂĄ pode tentar compilar com:

$ as assembly.s -o assembly.o
$ gcc main.c -c -o main.o
$ gcc *.o -o test

Vendo o cĂłdigo de saĂ­da do GCC

Ao usar o GCC Ă© possĂ­vel passar o parĂąmetro -masm=intel para que o compilador gere cĂłdigo Assembly na sintaxe da Intel, onde por padrĂŁo ele gera cĂłdigo na sintaxe da AT&T. VocĂȘ pode ver o cĂłdigo de saĂ­da da seguinte forma:

$ gcc main.c -o main.s -S -masm=intel -fno-asynchronous-unwind-tables

Onde a flag -S faz com que o compilador apenas compile o cĂłdigo, sem produzir o arquivo objeto de saĂ­da e ao invĂ©s disso salvando o cĂłdigo em Assembly. Pode ser Ăștil fazer isso para aprender mais sobre a sintaxe do GAS.

VocĂȘ tambĂ©m pode habilitar as otimizaçÔes do GCC com a opção -O2 assim o cĂłdigo de saĂ­da serĂĄ otimizado. Pode ser interessante fazer isso para aprender alguns truques de otimização.

Sintaxe do GAS

Aprendendo a sintaxe AT&T e a usar o GAS

O GNU assembler (GAS) usa por padrĂŁo a sintaxe AT&T e neste tĂłpico irei ensinĂĄ-la. Mais abaixo irei ensinar a diretiva usada para usar sintaxe Intel meramente como curiosidade e caso prefira usĂĄ-la.

Diferenças entre sintaxe Intel e AT&T

A primeira diferença notåvel é que o operando destino nas instruçÔes de sintaxe Intel é o mais à esquerda, o primeiro operando. Jå na sintaxe da AT&T é o inverso, o operando mais à direita é o operando destino. Conforme exemplo:

# Isso Ă© um comentĂĄrio.

# Sintaxe AT&T       # Sintaxe Intel

mov $123, %eax       # mov eax, 123
mov $0x0A, %eax      # mov eax, 0x0A
mov $0b1010, %eax    # mov eax, 0b1010
mov $'A', %eax       # mov eax, 'A'

E como jĂĄ pode ser observado valores literais precisam de um prefixo $, enquanto os nomes dos registradores precisam do prefixo %.

Tamanho dos operandos

Na sintaxe da Intel o tamanho dos operandos é especificado com base em palavra-chaves que são adicionadas anteriormente ao operando. Na sintaxe AT&T o tamanho do operando é especificado por um sufixo adicionado a instrução, conforme tabela abaixo:

Sufixo

Tamanho

Palavra-chave equivalente no NASM

B

byte (8 bits)

byte

W

word (16 bits)

word

L

long/doubleword (32 bits)

dword

Q

quadword (64 bits)

qword

T

ten word (80 bits)

tword

Exemplos:

# AT&T               # Intel

movl $5, %eax        # mov dword eax, 5
movb $'A', (%ebx)    # mov byte [ebx], 'A'

Assim como o NASM consegue identificar o tamanho do operando quando é usado um registrador e a palavra-chave se torna opcional, o mesmo acontece no GAS e o sufixo também é opcional nesses casos.

Far jump e call

Na sintaxe Intel saltos e chamadas distantes são feitas com jmp far [etc] e call far [etc] respectivamente. Na sintaxe da AT&T se usa o prefixo L nessas instruçÔes, ficando: ljmp e lcall.

Endereçamento

Exemplos com o seu equivalente na sintaxe da Intel:

# AT&T                       # Intel

mov my_var, %eax             # mov eax, [my_var]
mov 5(%ebx), %eax            # mov eax, [ebx + 5]
mov 5(%ebx, %esi), %eax      # mov eax, [ebx + esi + 5]
mov 5(%ebx, %esi, 4), %eax   # mov eax, [ebx + esi*4 + 5]

mov (, %esi, 4), %eax        # mov eax, [esi*4]
mov (%ebx), %eax             # mov eax, [ebx]

mov %es:(%ebx), %eax         # mov eax, [es:ebx]
mov %es:5(%ebx), %eax        # mov eax, [es:ebx + 5]

mov my_var(%rip), %rax       # mov rax, [rel my_var]

Como demonstrado no Ășltimo exemplo o endereço relativo na sintaxe do GAS Ă© feito explicitando RIP como base, enquanto na sintaxe do NASM isso Ă© feito usando a palavra-chave rel.

Saltos

Na sintaxe da AT&T os saltos para endereços armazenados na memória devem ter um * antes do rótulo para indicar que o salto deve ocorrer para o endereço que estå armazenado naquele endereço de memória. Sem o * o salto ocorre para o rótulo em si. Exemplo:

# AT&T               # Intel

jmp my_code          # jmp my_code
jmp *my_pointer      # jmp [my_pointer]

Saltos que especificam segmento e offset separam os dois valores por vĂ­rgula. Como em:

# AT&T               # Intel

jmp $1234, $5678     # jmp 1234:5678

Aprendendo a usar o GAS

As diretivas do GAS funcionam de maneira semelhante as diretivas do NASM com a diferença que todas elas são prefixadas por um ponto.

ComentĂĄrios

No GAS comentĂĄrios de mĂșltiplas linhas podem ser escritos com /* e */ assim como em C. ComentĂĄrios de uma Ășnica linha podem ser escritos com # ou //.

Pseudo-instruçÔes de dados

Pseudo-instrução

Tipo do dado (tamanho em bits)

Equivalente no NASM

.byte

byte (8 bits)

db

.short

.hword

.word

word (16 bits)

dw

.long

.int

doubleword (32 bits)

dd

.quad

quadword (64 bits)

dq

.float

.single

Single-precision floating-point (32 bits)

dd

.double

Double-precision floating-point (64 bits)

dq

.ascii

.string

.string8

String (8 bits cada caractere)

db

.asciz

Mesmo que .ascii porém com um terminador nulo no final

-

.string16

String (16 bits cada caractere)

-

.string32

String (32 bits cada caractere)

-

.string64

String (64 bits cada caractere)

-

Exemplos:

msg1: .ascii "Hello World\0"
msg2: .asciz "Hello World"

value1: .byte 1, 2, 3, 4, 5
value2: .float 3.1415

Diretivas de seçÔes e alinhamento

O GAS tem diretivas específicas para declarar algumas seçÔes padrão. Conforme tabela:

GAS

Equivalente no NASM

.data

section .data

.bss

section .bss

.text

section .text

Porém ele também tem a diretiva .section que pode ser usada de maneira semelhante a section do NASM. Os atributos da seção porém são passados em formato de flags em uma string como segundo argumento. As flags principais são w para dar permissão de escrita e x para dar permissão de execução. Exemplos:

# GAS                         # NASM

.section .mysection, "wx"     # section .mysection write exec
.section .rodata              # section .rodata
.text                         # section .text
.section .text                # section .text

A diretiva .align pode ser usada para alinhamento dos dados. VocĂȘ pode usĂĄ-la no inĂ­cio da seção para alinhar a mesma, conforme exemplo:

    .section .rodata
    .align 16

# Equivalente no NASM: section .rodata align=16

Usando sintaxe Intel

A diretiva .intel_syntax pode ser usada para habilitar a sintaxe da Intel para o GAS. Opcionalmente pode-se passar um parĂąmetro noprefix para desabilitar o prefixo % dos registradores.

Uma diferença importante da sintaxe Intel do GAS em relação ao NASM é que as palavra-chaves que especificam o tamanho do operando precisam ser seguidas por ptr, conforme exemplo abaixo:

    .intel_syntax noprefix
    .text
example:
    mov byte ptr [ebx], 7
    mov word ptr [ebx], 7
    mov dword ptr [ebx], 7
    mov qword ptr [ebx], 7
    ret

Exemplo de cĂłdigo na sintaxe AT&T

#include <stdio.h>

void assembly(float *array);

int main(void)
{
  float array[4];
  assembly(array);

  printf("%f, %f, %f, %f\n", array[0], array[1], array[2], array[3]);
  return 0;
}
    .section .rodata
    .align 16
    local_array: .float 1.23
                 .float 2.45
                 .float 3.67
                 .float 4.89

    .text
    .globl assembly
assembly:
    movaps local_array(%rip), %xmm5
    movaps %xmm5, (%rdi)
    ret

Inline Assembly no GCC

Aprendendo a usar o inline Assembly do compilador GCC.

Inline Assembly é uma extensão do compilador que permite inserir código Assembly diretamente no código de saída do compilador. Dessa forma é possível misturar C e Assembly sem a necessidade de usar um módulo separado só para o código em Assembly, além de permitir alguns recursos interessantes que não são possíveis sem o inline Assembly.

O compilador Clang contĂ©m uma sintaxe de inline Assembly compatĂ­vel com a do GCC, logo o conteĂșdo ensinado aqui tambĂ©m Ă© vĂĄlido para o Clang.

Inline Assembly bĂĄsico

A sintaxe do uso båsico é: asm [qualificadores] ( instruçÔes-asm ).

Onde qualificadores Ă© uma (ou mais) das seguintes palavra-chaves:

  • volatile: Isso desabilita as otimizaçÔes de cĂłdigo no inline Assembly, mas esse jĂĄ Ă© o padrĂŁo quando se usa o inline ASM bĂĄsico.

As instruçÔes Assembly ficam dentro dos parĂȘnteses como uma string literal e sĂŁo despejadas no cĂłdigo de saĂ­da sem qualquer alteração por parte do compilador. Geralmente se usa \n\t para separar cada instrução pois isso vai ser refletido literalmente na saĂ­da de cĂłdigo. O \n Ă© para iniciar uma nova linha e o \t (TAB) Ă© para manter a indentação do cĂłdigo de maneira idĂȘntica ao cĂłdigo gerado pelo compilador.

Exemplo:

Entre as diretivas #APP e #NO_APP fica o código despejado do inline Assembly. A diretiva # 5 "main.c" 1 é apenas um atalho para a diretiva #line onde ela serve para avisar para o assembler de qual linha (5) e arquivo ("main.c") veio aquele código. Assim se ocorrer algum erro, na mensagem de erro do assembler serå exibido essas informaçÔes.

Repare que o inline Assembly apenas despeja literalmente o conteĂșdo da string literal. Logo vocĂȘ pode adicionar o que quiser aĂ­ incluindo diretivas, comentĂĄrios ou atĂ© mesmo instruçÔes invĂĄlidas que o compilador nĂŁo irĂĄ reclamar.

Também é possível usar inline Assembly båsico fora de uma função, como em:

Porém não é possível fazer o mesmo com inline Assembly estendido.

Inline Assembly estendido

A versão estendida do inline Assembly funciona de maneira semelhante ao inline Assembly båsico, porém com a diferença de que é possível acessar variåveis em C e fazer saltos para rótulos no código fonte em C.

A sintaxe da versĂŁo estendida segue o seguinte formato:

Os qualificadores sĂŁo os mesmos da versĂŁo bĂĄsica porĂ©m com mais um chamado goto. O qualificador goto indica que o cĂłdigo Assembly pode efetuar um salto para um dos rĂłtulos listados no Ășltimo operando. Esse qualificador Ă© necessĂĄrio para se usar os rĂłtulos no cĂłdigo ASM. Enquanto o qualificador volatile desabilita a otimização de cĂłdigo, que Ă© habilitada por padrĂŁo no inline Assembly estendido.

Dentre esses operandos somente os de saĂ­da sĂŁo "obrigatĂłrios", os demais podem ser omitidos. E todos eles podem conter uma lista vazia exceto o de rĂłtulos.

Existe um limite mĂĄximo de 30 operandos com a soma dos operandos de saĂ­da, entrada e rĂłtulos.

operandos-de-saĂ­da

Cada operando de saída é separado por vírgula e contém a seguinte sintaxe:

Onde nome Ă© um sĂ­mbolo opcional que vocĂȘ pode criar para se referir ao operando no cĂłdigo Assembly. TambĂ©m Ă© possĂ­vel se referir ao operando usando %n, onde n seria o Ă­ndice do operando (contando a partir de zero). E usar %[nome] caso defina um nome.

Como o % é usado para se referir à operandos, no inline Assembly estendido se usa dois % para se referir à um registrador. Jå que %% é um escape para escrever o próprio % na saída, da mesma forma que se faz na função printf.

Operandos de saída com + são contabilizados como dois, tendo em vista que o + é basicamente um atalho para repetir o mesmo operando também como uma entrada.

Essas informaçÔes sĂŁo necessĂĄrias para que o compilador consiga otimizar o cĂłdigo corretamente. Por exemplo caso vocĂȘ indique que a variĂĄvel serĂĄ somente escrita com = mas leia o valor da variĂĄvel no Assembly, o compilador pode assumir que o valor da variĂĄvel nunca foi lido e portanto descartar a inicialização dela durante a otimização de cĂłdigo. Isso criaria um comportamento estranho no inline Assembly onde se obteria lixo como valor da variĂĄvel.

Um exemplo deste erro:

A otimização de código pode remover a inicialização x = 5 jå que não informamos que o valor dessa variåvel é lido dentro no inline Assembly. O correto seria usar + nesse caso.

Um exemplo (dessa vez correto) usando um nome definido para o operando:

operandos-de-entrada

Os operandos de entrada seguem a mesma sintaxe dos operandos de saída porém sem o = ou + nas restriçÔes. Não se deve tentar modificar operandos de entrada (embora tecnicamente seja possível) para evitar erros, lembre-se que o compilador irå otimizar o código assumindo que aquele operando não serå modificado.

Também é possível passar expressÔes literais como operando de entrada ao invés de somente nomes de variåveis. A expressão serå avaliada e seu valor passado como operando sendo armazenado de acordo com as restriçÔes.

clobbers

Clobbers (que eu nĂŁo sei como traduzir) Ă© basicamente uma lista, separada por vĂ­rgula, de efeitos colaterais do cĂłdigo Assembly. Nele vocĂȘ deve listar o que o seu cĂłdigo ASM modifica alĂ©m dos operandos de saĂ­da. Cada valor de clobber Ă© uma string literal contendo o nome de um registrador que Ă© modificado pelo seu cĂłdigo. TambĂ©m hĂĄ dois nomes especiais de clobbers:

Qualquer nome de registrador Ă© vĂĄlido para ser usado como clobber exceto o Stack Pointer (RSP). É esperado que no final da execução do inline ASM o valor de RSP seja o mesmo de antes da execução do cĂłdigo. Se nĂŁo for o cĂłdigo muito provavelmente irĂĄ ter problemas no restante da execução.

Quando vocĂȘ adiciona um registrador a lista de clobbers ele nĂŁo serĂĄ utilizado para armazenar operandos de entrada ou saĂ­da, assim garantindo que o registrador pode ser utilizado livremente no inline ASM sem causar qualquer erro. Isso tambĂ©m garante que o compilador nĂŁo irĂĄ assumir que o valor do registrador permanece o mesmo apĂłs a execução do inline ASM.

Exemplo:

rĂłtulos-goto

Ao usar asm goto pode-se referir à um rótulo usando o prefixo %l seguido do índice do operando de rótulo. Onde a contagem inicia em zero e é contabilizado também os operandos de entrada e saída.

Exemplo:

Mas felizmente também é possível usar o nome do rótulo no inline Assembly, bastando usar a notação %l[nome]. O exemplo acima poderia ter a instrução de salto reescrita para jz %l[my_label].

RestriçÔes

As restriçÔes (constraints) sĂŁo uma lista de caracteres que determinam onde um operando deve ser armazenado. É possĂ­vel indicar mĂșltiplas alternativas para o compilador simplesmente adicionando mais de uma letra indicando tipos de armazenamento diferentes.

Abaixo a lista de algumas restriçÔes disponíveis no GCC.

RestriçÔes comuns

RestriçÔes para família x86

Dicas

RĂłtulos locais no inline Assembly

Se vocĂȘ simplesmente declarar rĂłtulos dentro do inline Assembly pode acabar se deparando com uma redeclaração de sĂ­mbolo por nĂŁo ter uma garantia de que ele seja Ășnico. Mas uma dica Ă© usar o escape especial %= que expande para um nĂșmero Ășnico para cada uso de asm, assim sendo possĂ­vel dar um nome Ășnico para os rĂłtulos.

Exemplo:

Usando sintaxe Intel

Caso prefira usar sintaxe Intel é possível fazer isso meramente compilando o código com -masm=intel. Isso porque o inline Assembly simplesmente despeja as instruçÔes no arquivo de saída, portanto o código irå usar a sintaxe que o assembler utilizar.

Outra dica Ă© usar a diretiva .intel_syntax noprefix no inĂ­cio, e depois .att_syntax no final para religar a sintaxe AT&T para o restante do cĂłdigo. Exemplo:

Escolhendo o registrador/sĂ­mbolo para uma variĂĄvel

Ao usar o storage-class register Ă© possĂ­vel escolher em qual registrador a variĂĄvel serĂĄ armazenada usando a seguinte sintaxe:

Nesse exemplo a variĂĄvel x obrigatoriamente seria alocada no registrador R12.

Também é possível escolher o nome do símbolo para variåveis locais com storage-class static ou para variåveis globais. Como em:

A variĂĄvel no cĂłdigo fonte Ă© referida como x mas o sĂ­mbolo gerado para a variĂĄvel seria definido como my_var.

VariĂĄveis em C

Entendendo como variĂĄveis em C sĂŁo representadas em Assembly.

Na linguagem C existem palavra-chaves que sĂŁo chamadas de storage-class specifiers, onde elas determinam o storage-class de uma variĂĄvel. Na prĂĄtica isso determina como a variĂĄvel deve ser armazenada no programa. No C11 existem os seguintes storage-class specifiers:

  • extern

  • static

  • _Thread_local

  • auto (esse Ă© o padrĂŁo)

  • register

VariĂĄveis globais

As variåveis globais em C são alocadas na seção .data ou .bss, dependendo se ela foi inicializada ou não. Como no exemplo:

Se compilamos com gcc main.c -S -o main.s -fno-asynchronous-unwind-tables obtemos a seguinte saĂ­da:

A variåvel data_var foi alocada na seção .data e teve seu símbolo exportado com a diretiva .globl data_var, que é equivalente a diretiva global do NASM.

Jå a variåvel bss_var foi declarada com a diretiva .comm symbol, size, aligment que serve para declarar commom symbols (símbolos comuns). Onde ela recebe como argumento o nome do símbolo seguido de seu tamanho (em bytes) e opcionalmente um valor de alinhamento. Em arquivos objetos ELF o argumento de alinhamento é um alinhamento em bytes, nesse exemplo a variåvel serå alocada em um endereço alinhado por 4 bytes.

Os símbolos declarados com a diretiva .comm que não foram inicializados em qualquer arquivo objeto são alocados na seção .bss. Logo nesse caso o uso da diretiva seria equivalente ao uso de res* do NASM, com a diferença que no NASM precisamos usar explicitamente na seção onde o espaço serå alocado.

VariĂĄvel static global

As variåveis globais com storage-class static funcionam da mesma maneira que as variåveis globais comum, com a diferença que seu símbolo não é exportado para que possa ser acessado em outro arquivo objeto. Como no exemplo:

Onde obtemos a saĂ­da:

Repare que dessa vez o sĂ­mbolo data_var nĂŁo foi exportado com a diretiva .globl, enquanto o bss_var foi explicitamente declarado como local com a diretiva .local (jĂĄ que a diretiva .comm exporta como global por padrĂŁo).

VariĂĄvel extern

Variåveis extern em C são basicamente variåveis que são definidas em outro módulo. O GAS tem uma diretiva .extern que é equivalente a diretiva extern do NASM, isto é, indica que o símbolo serå definido em outro arquivo objeto. Porém qualquer símbolo não declarado jå é considerado externo por padrão pelo GAS. Experimente ver o código de saída do exemplo abaixo:

VocĂȘ vai reparar que na função main o sĂ­mbolo extern_var foi lido porĂ©m ele nĂŁo foi declarado.

VariĂĄveis locais

Variåveis locais em C são comumente alocadas no stack frame da função, porém em alguns casos o compilador também pode reservar um registrador para armazenar o valor da variåvel.

Em C existe o storage-class register que serve como um "pedido" para o compilador alocar aquela variĂĄvel de forma que o acesso a mesma seja o mais rĂĄpido possĂ­vel, que geralmente Ă© em um registrador (daĂ­ o nome da palavra-chave). Mas isso nĂŁo garante que a variĂĄvel serĂĄ realmente alocada em um registrador. Na prĂĄtica o Ășnico efeito colateral garantido Ă© que vocĂȘ nĂŁo poderĂĄ obter o endereço na memĂłria daquela variĂĄvel com o operador de endereço (&), e muitas vezes o compilador jĂĄ vai alocar a variĂĄvel em um registrador mesmo sem o uso da palavra-chave.

VariĂĄvel static local

VariĂĄveis static local sĂŁo armazenadas da mesma maneira que as variĂĄveis static global, a Ășnica coisa que muda Ă© no ponto de vista do cĂłdigo-fonte em C onde a visibilidade da variĂĄvel Ă© limitada para o escopo onde ela foi declarada. Isso faz com o que o compilador gere um sĂ­mbolo de nome Ășnico para a variĂĄvel, como no exemplo abaixo:

Repare como data_var.1913 nĂŁo teve seu sĂ­mbolo exportado e bss_var.1914 foi declarado como local.

VariĂĄveis _Thread_local

Repare que as seçÔes são diferentes, .tdata (equivalente a .data só que thread-local) e .tbss (equivalente a .bss) são utilizadas para armazenar as variåveis.

O sufixo @tpoff (thread pointer offset) usado nos símbolos indica que o offset do símbolo deve ser calculado levando em consideração a TLS como endereço de origem. Por padrão o offset é calculado com o segmento de dados "normal" como origem.

Lidando com os tipos da linguagem C

Agora que jĂĄ entendemos onde e como as variĂĄveis sĂŁo alocadas em C, sĂł falta entender "o que" estĂĄ sendo armazenado.

Arrays e strings

O tipo array em C Ă© meramente uma sequĂȘncia de variĂĄveis do mesmo tipo na memĂłria. Por exemplo podemos inicializar um int arr[4] na sintaxe do GAS da seguinte forma:

Onde os valores 1, 2, 3 e 4 sĂŁo despejados em sequĂȘncia.

Em C nĂŁo existe um tipo string porĂ©m por convenção as strings sĂŁo uma array de char, onde o Ășltimo char contĂ©m o valor zero (chamado de terminador nulo). Esse Ășltimo caractere '\0' Ă© usado para denotar o final da string e funçÔes da libc que lidam com strings esperam por isso. Exemplos:

As trĂȘs strings acima sĂŁo equivalentes na sintaxe do GAS.

Sobre a passagem de arrays (incluindo obviamente strings) como argumento para uma função, isso é feito passando um ponteiro para o primeiro elemento da array.

Ponteiros

Ponteiros em C, na arquitetura x86/x86-64, sĂŁo traduzidos meramente como o offset do objeto na memĂłria. O segmento nĂŁo Ă© especificado como parte do valor do ponteiro.

Experimente ler o cĂłdigo de saĂ­da do seguinte programa:

A leitura do endereço de my_var vai ser compilada para algo como:

Onde primeiro é obtido o endereço do início do segmento FS que depois é somado ao offset de my_var. Assim obtendo o endereço efetivo da variåvel na memória.

Estruturas

As estruturas em C sĂŁo compiladas de forma que os valores dos campos da estrutura sĂŁo dispostos em sequĂȘncia na memĂłria, seguindo a mesma ordem que foram declarados na estrutura. Existe a possibilidade do GCC adicionar alguns bytes extras no final da estrutura afim de manter o alinhamento dos dados, esses bytes extras sĂŁo chamados de padding. Exemplo:

Isso produziria o seguinte código para a inicialização da variåvel test:

Repare a diretiva .zero 3 que foi usada para despejar 3 bytes zero no final da estrutura, afim de alinhar a mesma em 4 bytes. No total a estrutura acaba tendo 8 bytes de tamanho: 4 bytes do int, 1 byte do char e 3 bytes de padding.

Unions

As unions sĂŁo bem simples, sĂŁo alocadas com o tamanho do maior tipo declarado para a union. Por exemplo:

Essa union Ă© alocada na memĂłria da mesma forma que um int, que tem 4 bytes de tamanho.

FunçÔes em C

Entendendo as funçÔes em C do ponto de vista do Assembly.

A linguagem C tem algumas variaçÔes à respeito de funçÔes e o objetivo deste tópico é explicar, do ponto de vista do baixo-nível, como elas funcionam.

Entendendo os protĂłtipos

As funçÔes na linguagem C tĂȘm protĂłtipos que servem como uma "assinatura" indicando quais parĂąmetros a função recebe e qual tipo de valor ela retorna. Um exemplo:

Existem alguns protĂłtipos um pouco diferentes que vale explicar aqui para deixar claro seu entendimento. Como este:

De acordo com a especificação do C11 uma expressĂŁo do tipo void Ă© um tipo cujo o valor nĂŁo existe e deve ser ignorado. FunçÔes assim sĂŁo compiladas retornando sem se preocupar em modificar o valor de RAX (ou qualquer outro registrador que poderia ser usado para retornar um valor) e portanto nĂŁo se deve esperar que o valor nesse registrador tenha alguma informação Ăștil.

Quando void é usado no lugar da lista de parùmetros ele tem o significado especial de indicar que aquela função não recebe parùmetro algum ao ser chamada.

Embora possa ser facilmente confundido com o caso acima, onde se usa void na lista de parùmetros, na verdade esse protótipo de função não diz que a função não recebe parùmetros. Na verdade esse é um protótipo que não especifica quais tipos ou quantos parùmetros a função recebe, logo o compilador aceita que a função seja chamada passando qualquer tipo e qualquer quantidade de parùmetros, inclusive sem parùmetro algum também. Veja o exemplo:

Na convenção de chamada da SysV ABI os argumentos para esse tipo de função sĂŁo passados da mesma maneira que uma chamada com o protĂłtipo "normal". A Ășnica diferença Ă© que a função recebe um argumento extra no registrador AL indicando quantos registradores de vetor foram utilizados para passar argumentos de ponto-flutuante. Nesse exemplo apenas um argumento era um float e por isso hĂĄ a instrução movl $1, %eax indicando esse nĂșmero. Experimente usar mais argumentos float ou nĂŁo passar nenhum para ver se o nĂșmero passado em AL como argumento irĂĄ mudar de acordo.

FunçÔes static

FunçÔes static são visíveis apenas no mesmo módulo em que elas foram declaradas, ou seja, seu símbolo não é exportado. Exemplo:

Function specifiers

Existem dois especificadores de função no C11, onde eles são:

inline

O especificador inline é uma sugestão para que a chamada para a função seja a mais råpida possível. Isso tem o efeito colateral no GCC de inibir a geração de código para a função em Assembly. Ao invés disso as instruçÔes da função são geradas no local onde ela foi chamada, e portanto o símbolo da função nunca é de fato declarado.

O GCC, mesmo para uma função inline, ainda vai gerar o cĂłdigo para a chamada da função caso as otimizaçÔes estejam desligadas e isso vai acabar produzindo um erro de referĂȘncia por parte do linker. Lembre-se de sempre ligar as otimizaçÔes de cĂłdigo quando estiver usando funçÔes inline.

_Noreturn

FunçÔes com o especificador _Noreturn nunca devem retornar para a função chamadora. Quando esse especificador é utilizado o compilador irå gerar código assumindo que a função nunca retorna. Como podemos ver no exemplo abaixo compilado com -O2:

FunçÔes aninhadas

Nested functions é uma extensão do GCC que permite declarar funçÔes aninhadas. O símbolo de uma função aninhada é gerado de maneira semelhante ao símbolo de uma variåvel local com storage-class static. Exemplo:

Atributos de função

Os atributos de função Ă© uma extensĂŁo do GCC que permite modificar algumas propriedades relacionadas Ă  uma função. Se define atributos para uma função usando a palavra-chave __attribute__ e entre dois parĂȘnteses uma lista de atributos separado por vĂ­rgula. Exemplo:

Alguns atributos recebem parĂąmetros onde estes devem ser adicionados dentro de mais um par de parĂȘnteses, se assemelhando a sintaxe de uma chamada de função. Exemplo: __attribute__((section (".another"), cdecl)).

Abaixo alguns atributos que podem ser usados na arquitetura x86 e acho interessante citar aqui:

ms_abi, sysv_abi, cdecl, stdcall, fastcall, thiscall

Os atributos cdecl, stdcall, fastcall e thiscall sĂŁo ignorados em 64-bit.

section ("name")

Por padrão o GCC irå adicionar o código das funçÔes na seção .text, porém é possível usar o atributo section para que o compilador adicione o código da função em outra seção. Como no exemplo abaixo:

naked

target ("option1", "option2", ...)

Alguns dos possĂ­veis alvos para arquitetura x86 sĂŁo:

PLT e GOT

Jå vimos alguns exemplos de código chamando funçÔes da libc, essas funçÔes porém estão em uma biblioteca dinùmica e não dentro do executåvel. A resolução do endereço (symbol binding) das funçÔes na biblioteca é feito em tempo de execução onde os endereços são salvos na seção GOT (Global Offset Table).

A seção PLT (Procedure Linkage Table) simplesmente armazena saltos para os endereços armazenados na GOT. Por isso o GCC gera chamadas para funçÔes da libc assim:

O sufixo @PLT indica que o endereço do símbolo estå na seção PLT. Onde nessa seção hå uma instrução jmp para o endereço que serå resolvido em tempo de execução na GOT. Algo parecido com a ilustração abaixo:

Na sintaxe do NASM o equivalente ao uso do sufixo com @ do GAS Ă© a palavra-chave wrt (With Reference To), conforme exemplo:

Setado para o mesmo valor do bit mais significativo do resultado (). Onde 0 indica um valor positivo e 1 indica um valor negativo.

BIOS Ă© um sistema legado de boot, sistemas mais modernos usam para o processo de boot do sistema.

O clåssico MS-DOS, antigo sistema operacional de 16 bits da Microsoft, foi muito utilizado e até hoje existem projetos relacionados a esse sistema. Existe por exemplo o que é um sistema operacional de código aberto e que é compatível com o MS-DOS.

Usado como ponteiro para o topo da .

Usado como ponteiro para o endereço inicial do .

Caso ainda não tenha reparado o retorno da nossa função assembly() é guardado no registrador EAX. Isso serå explicado mais para frente nos tópicos sobre .

A tecnologia SSE adiciona novos registradores independentes de 128 bits de tamanho cada. Em todos os modos de operação são adicionados oito novos registradores XMM0 até XMM7, e em 64-bit também hå mais oito registradores XMM8 até XMM15 que podem ser acessados usando o . Isso då um total de 16 registradores em 64-bit e 8 registradores nos outros modos de operação.

de 32 ou 64 bits.

Podemos usar a FPU para fazer cålculos com valores de ponto flutuante. A arquitetura x86 segue a padronização para a representação de valores de ponto flutuante.

Adiantando que um valor float na do C é retornado no registrador XMM0. Podemos ver o resultado de nossos testes da seguinte forma usando a instrução MOVSS:

A instrução e os registradores XMM serão explicados no .

Na configuração padrão do NASM o endereçamento é montado como um endereço absoluto (default abs). Mais à frente irei abordar o assunto de (PIE) e aí entenderemos qual é a utilidade de se usar um endereço relativo ao RIP.

Repare que na linha 10 estamos usando um rĂłtulo local que foi explicado no tĂłpico sobre a .

Calcula o valor aproximado do dos floats no operando fonte (a direita) e armazena os valores no operando destino.

Calcula o das raízes quadradas dos floats no operando fonte, armazenando os resultados no operando destino. Essa instrução é equivalente ao uso de SQRTPS seguido de RCPPS.

Copia uma das 8 words contidas no registrador XMM e armazena no de 32 ou 64 bits. O valor é movido para os 16 bits menos significativos do registrador e todos os outros bits são zerados. Também é possível armazenar a word diretamente na memória principal.

Faz uma operação de left com os dois quadwords do registrador XMM. O nĂșmero de vezes que o shift deve ser feito Ă© especificado pelo operando imediato de 8 bits. Os bits menos significativos sĂŁo zerados.

Os primeiros quatro argumentos float ou double são passados nos registradores XMM0 até XMM3 como . Os demais também são empilhados na ordem inversa.

A convenção de chamada __stdcall é a utilizada para chamar funçÔes da .

Sistemas , incluindo o Linux, seguem a padronização da System V ABI (ou SysV ABI). Onde ABI é sigla para Application Binary Interface (Interface binåria de aplicação) que é basicamente uma padronização que dita como código binårio deve ser escrito e executado no sistema operacional. Uma das coisas que a SysV ABI padroniza é a convenção de chamada utilizada em cada arquitetura de processador.

A Direction Flag (DF) no precisa obrigatoriamente estar zerada ao chamar ou retornar de uma função.

Cada função chamada pode (se precisar) reservar um pedaço para ser usada como memória local da função e pode, por exemplo, ser usada para alocar variåveis locais. Esse espaço é chamado de stack frame e o código que aloca e desaloca o stack frame é chamado de prólogo e epílogo respectivamente. Exemplo:

Vale lembrar que empilha o endereço de retorno, portanto ao chamar uma função 0(%rsp) aponta para o endereço de retorno da mesma.

Os parùmetros inteiros (e ponteiros) são passados em na seguinte ordem: RDI, RSI, RDX, RCX, R8 e R9. Parùmetros float ou double são passados nos registradores XMM0 até XMM7 como (na parte menos significativa do registrador).

Valores float ou double sĂŁo retornados em ST0 (ver ).

CVTSD2SI converte o valor double no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerå extensão de sinal ().

CVTSS2SI converte o valor float no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerå extensão de sinal ().

Jå estamos fazendo isso desde o começo mas não entramos em muitos detalhes pois eu queria que inicialmente o foco fosse em entender como as coisas funcionam, essa é a parte legal .

Assim como fizemos em aqui estĂĄ um cĂłdigo de teste para garantir que o seu ambiente estĂĄ correto:

Caso tenha algum problema e precise de ajuda, pode entrar no e fazer uma pergunta.

A flag -fno-asynchronous-unwind-tables serve para desabilitar as e melhorar a leitura do cĂłdigo de saĂ­da. Essas diretivas servem para gerar informação Ăștil para um depurador mas para fins de leitura do cĂłdigo nĂŁo precisamos delas.

Na sintaxe Intel Ă© bem intuitivo jĂĄ que ele Ă© escrito em formato de expressĂŁo matemĂĄtica. Na sintaxe AT&T Ă© um pouco mais confuso e segue o seguinte formato: segment:displacement(base, index, scale).

No NASM db, dw, dd, dq etc. que servem para despejar bytes no arquivo binårio de saída. No GAS isso é feito usando as seguintes pseudo-instruçÔes:

O exemplo abaixo é o mesmo apresentado no tópico sobre porém reescrito na sintaxe do GAS/AT&T:

inline: Isso Ă© uma "dica" para o compilador considerar que o tamanho do cĂłdigo Assembly Ă© o menor possĂ­vel. Serve meramente para o compilador decidir se vai ou nĂŁo expandir uma , e usando esse qualificador vocĂȘ sugere que o cĂłdigo Ă© pequeno o suficiente para isso.

Isso produz a seguinte saĂ­da ao :

Ă© uma string literal contendo letras e sĂ­mbolos indicando como esse operando deve ser armazenado (r para registrador e m para memĂłria, por exemplo). No caso dos operandos de saĂ­da o primeiro caractere na string deve ser um = ou +. Onde o = indica que a variĂĄvel terĂĄ seu valor modificado, enquanto + indica que terĂĄ seu valor modificado e lido.

Caso utilize um operando que vocĂȘ nĂŁo tem certeza que serĂĄ armazenado em um registrador, lembre-se de usar para especificar o tamanho do operando. Para evitar erros Ă© ideal que sempre use os sufixos.

Como jå vimos no capítulo , variåveis nada mais são do que um espaço de memória que pode ser manipulado pelo programa. Em C existem diversas nuances em como variåveis são alocadas e mantidas pelo compilador e aqui vamos entender essas diferenças.

JĂĄ em arquivos objetos PE (do Windows) o alinhamento Ă© um valor em potĂȘncia de dois, logo para alinhar em 4 bytes deverĂ­amos passar 2 como argumento ( ). Se a gente passar 4 como argumento entĂŁo seria um alinhamento de que daria um alinhamento de 16 bytes.

O storage-class _Thread_local foi adicionado no C11. Assim como o nome sugere ele serve para alocar variĂĄveis em uma regiĂŁo de memĂłria que Ă© local para cada do processo. Vamos analisar o exemplo:

No Linux, em x86-64, a regiĂŁo de memĂłria local para cada thread (thread-local storage - TLS) fica no segmento apontado pelo FS, por isso os valores das variĂĄveis estĂŁo sendo lidos desse segmento.

Esse protótipo jå nos då todas as informaçÔes necessårias que saibamos como fazer a chamada da função e como obter seu valor de retorno, desde que nós conheçamos a utilizada. Os parùmetros são considerados da esquerda para a direita, logo o parùmetro x é o primeiro e o parùmetro y é o segundo. Na convenção de chamada da SysV ABI esses argumentos estariam em EDI e ESI, respectivamente. E o retorno seria feito em EAX.

FunçÔes com também seguem a mesma regra de chamada do que foi mencionado acima.

Esses atributos fazem com que o compilador gere o cĂłdigo da função usando a convenção de chamada , , , , fastcall ou thiscall respectivamente. TambĂ©m Ă© Ăștil usĂĄ-los em protĂłtipos de funçÔes onde a função utiliza uma convenção de chamada diferente da padrĂŁo.

O atributo naked Ă© usado para desativar a geração do para a função. Isso Ă© Ăștil para se escrever funçÔes usando dentro das mesmas.

Esse atributo serve para personalizar a geração de código do compilador para uma função específica, permitindo selecionar quais instruçÔes serão utilizadas ao gerar o código. Também é possível adicionar o prefixo no- para desabilitar alguma tecnologia e impedir que o compilador gere código para ela. Por exemplo __attribute__((target ("no-sse")) desativaria o uso de instruçÔes ou registradores na função.

😁
MSB
UEFI
http://vitaly_filatov.tripod.com/ng/asm/asm_001.html
FreeDOS
convenção de chamada
IEEE-754
convenção de chamada
Position-independent executable
sintaxe do nasm
inverso multiplicativo
inverso multiplicativo
registrador de propĂłsito geral
logical shift
WinAPI
UNIX-Like
RFLAGS
da pilha
CALL
sign extension
sign extension
A base
fĂłrum do Mente BinĂĄria
diretivas CFI
o endereçamento
instruçÔes de movimentação SSE
prefixo REX
prĂłximo tĂłpico
MOVSS
valores escalares
registradores de propĂłsito geral
valores escalares
Usando instruçÔes da FPU
existem as pseudo-instruçÔes
#include <stdio.h>

int main(void)
{
  asm(
    "mov $5, %eax\n\t"
    "add $3, %eax\n\t"
  );

  return 0;
}
main:
	endbr64
	pushq	%rbp
	movq	%rsp, %rbp
#APP
# 5 "main.c" 1
	mov $5, %eax
	add $3, %eax
	
# 0 "" 2
#NO_APP
	movl	$0, %eax
	popq	%rbp
	ret
#include <stdio.h>

int add(int, int);

int main(void)
{
  printf("%d\n", add(2, 3));
  return 0;
}

asm (
  "add:\n\t"
  "  lea (%edi, %esi), %eax\n\t"
  "  ret"
);
asm [qualificadores] (
  "instruçÔes-asm"
  : operandos-de-saĂ­da
  : operandos-de-entrada
  : clobbers
  : rĂłtulos-goto
)
[nome] "restriçÔes" (variåvel)
#include <stdio.h>

int main(void)
{
  int x = 5;

  asm("addl $3, %0"
      : "=rm"(x));

  printf("%d\n", x);
  return 0;
}
#include <stdio.h>

int main(void)
{
  int x;

  asm("movl $5, %[myvar]"
      : [myvar] "=rm"(x));

  printf("%d\n", x);
  return 0;
}
int add(int a, int b)
{
  int result;

  asm("movl %[a], %%eax\n\t"
      "addl %[b], %%eax\n\t"
      "movl %%eax, %[result]"
      : [result] "=rm"(result)
      : [a] "r"(a),
        [b] "r"(b)
      : "cc",
        "eax");

  return result;
}
#include <stdio.h>
#include <stdbool.h>

int do_anything(bool value)
{
  int result = 3;

  asm goto("test %[value], %[value]\n\t"
           "jz %l1"
           :
           : [value] "r"(value)
           : "cc"
           : my_label);

  result += 2;

my_label:
  return result;
}

int main(void)
{
  printf("%d, %d\n", do_anything(true), do_anything(false));
  return 0;
}
#include <stdio.h>
#include <stdbool.h>

int do_anything(bool value)
{
  int result = 3;

  asm("test %[value], %[value]\n\t"
      "jz .my_label%=\n\t"
      "addl $2, %[result]\n\t"
      ".my_label%=:"
      : [result] "+a"(result)
      : [value] "r"(value)
      : "cc");

  return result;
}

int main(void)
{
  printf("%d, %d\n", do_anything(true), do_anything(false));
  return 0;
}
int add(int a, int b)
{
  int result;

  asm(".intel_syntax noprefix\n\t"
      "lea %[result], [ %[a] + %[b] ]\n\t"
      ".att_syntax"
      : [result] "=a"(result)
      : [a] "r"(a),
        [b] "r"(b));

  return result;
}
register int x asm("r12") = 5;
static int x asm("my_var") = 5;
int data_var = 1;
int bss_var;
main.s
	.globl	data_var
	.data
	.align 4
	.type	data_var, @object
	.size	data_var, 4
data_var:
	.long	1
	.comm	bss_var,4,4
22=42ÂČ = 422=4
242^424
static int data_var = 1;
static int bss_var;
	.data
	.align 4
	.type	data_var, @object
	.size	data_var, 4
data_var:
	.long	1
	.local	bss_var
	.comm	bss_var,4,4
extern int extern_var;

int main(void)
{
  int x = extern_var;
  return 0;
}
int test(void)
{
  static int data_var = 5;
  static int bss_var;

  return data_var + bss_var;
}
	.data
	.align 4
	.type	data_var.1913, @object
	.size	data_var.1913, 4
data_var.1913:
	.long	5
	.local	bss_var.1914
	.comm	bss_var.1914,4,4
_Thread_local int global_thread_data = 5;
_Thread_local int global_thread_bss;

int test(void)
{
  _Thread_local static int local_thread_data = 5;
  _Thread_local static int local_thread_bss;

  return global_thread_data
    + global_thread_bss
    + local_thread_data
    + local_thread_bss;
}
	.text
	.globl	global_thread_data
	.section	.tdata,"awT",@progbits
	.align 4
	.type	global_thread_data, @object
	.size	global_thread_data, 4
global_thread_data:
	.long	5
	.globl	global_thread_bss
	.section	.tbss,"awT",@nobits
	.align 4
	.type	global_thread_bss, @object
	.size	global_thread_bss, 4
global_thread_bss:
	.zero	4
	.section	.tdata
	.align 4
	.type	local_thread_data.1915, @object
	.size	local_thread_data.1915, 4
local_thread_data.1915:
	.long	5
	.section	.tbss
	.align 4
	.type	local_thread_bss.1916, @object
	.size	local_thread_bss.1916, 4
local_thread_bss.1916:
	.zero	4
	.text
	.globl	test
	.type	test, @function
test:
	endbr64
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%fs:global_thread_data@tpoff, %edx
	movl	%fs:global_thread_bss@tpoff, %eax
	addl	%eax, %edx
	movl	%fs:local_thread_data.1915@tpoff, %eax
	addl	%eax, %edx
	movl	%fs:local_thread_bss.1916@tpoff, %eax
	addl	%edx, %eax
	popq	%rbp
	ret
arr:
    .long 1, 2, 3, 4
string1:
    .ascii "Hello World", 0
string2:
    .ascii "Hello World\0"
string3:
    .asciz "Hello World"
#include <stdio.h>

_Thread_local int my_var = 111;

int main(void)
{
  int *test = &my_var;
  *test = 777;

  printf("%d, %d\n", my_var, *test);
}
movq	%fs:0, %rax
addq	$my_var@tpoff, %rax
movq	%rax, -8(%rbp)

# Com otimização ligada o GCC usa LEA:

movq	%fs:0, %rax
leaq	my_var@tpoff(%rax), %rdi
#include <stdio.h>

typedef struct
{
  int x;
  char y;
} my_test_t;

my_test_t test = {
    .x = 5,
    .y = 'A',
};

int main(void)
{
  printf("%d, %c | sizeof: %zu\n", test.x, test.y, sizeof test);
}
	.globl	test
	.data
	.align 8
	.type	test, @object
	.size	test, 8
test:
	.long	5
	.byte	65
	.zero	3
typedef union
{
  int x;
  char y;
} my_test_t;
Thread Local Storage
int add(int x, int y);
void do_something(int a);
int do_something(void);
int do_something();
#include <stdio.h>

int do_something();

int main(void)
{
  printf("Resultado: %d\n", do_something(1, 2, 3, 4.5f, "teste"));
}
	movq	.LC1(%rip), %rax
	leaq	.LC0(%rip), %rcx
	movq	%rax, %xmm0
	movl	$3, %edx
	movl	$2, %esi
	movl	$1, %edi
	movl	$1, %eax
	call	do_something@PLT
int do_something(int x, ...);
static int add(int a, int b)
{
  return a + b;
}
#include <stdio.h>
#include <stdlib.h>

_Noreturn void goodbye(const char *msg)
{
  puts(msg);
  exit(EXIT_SUCCESS);
}

int main(void)
{
  goodbye("Sayonara onii-chan! ^-^");
}
	.text
	.p2align 4
	.globl	goodbye
	.type	goodbye, @function
goodbye:
	endbr64
	pushq	%rax
	popq	%rax
	subq	$8, %rsp
	call	puts@PLT
	xorl	%edi, %edi
	call	exit@PLT
	.size	goodbye, .-goodbye
	.section	.rodata.str1.1,"aMS",@progbits,1
.LC0:
	.string	"Sayonara onii-chan! ^-^"
	.section	.text.startup,"ax",@progbits
	.p2align 4
	.globl	main
	.type	main, @function
main:
	endbr64
	pushq	%rax
	popq	%rax
	leaq	.LC0(%rip), %rdi
	subq	$8, %rsp
	call	goodbye
int calc(int a, int b)
{
  int add(int a, int b)
  {
    return a + b;
  }

  return add(a, b) + add(3, 4);
}
__attribute__((cdecl))
int add(int a, int b)
{
  return a + b;
}
__attribute__((section(".another")))
int add(int a, int b)
{
  return a + b;
}
	.section	.another,"ax",@progbits
	.p2align 4
	.globl	add
	.type	add, @function
add:
	endbr64
	leal	(%rdi,%rsi), %eax
	ret

Ativar as instruçÔes

Desativar as instruçÔes

3dnow

no-3dnow

3dnowa

no-3dnowa

abm

no-abm

adx

no-adx

aes

no-aes

avx

no-avx

avx2

no-avx2

avx5124fmaps

no-avx5124fmaps

avx5124vnniw

no-avx5124vnniw

avx512bitalg

no-avx512bitalg

avx512bw

no-avx512bw

avx512cd

no-avx512cd

avx512dq

no-avx512dq

avx512er

no-avx512er

avx512f

no-avx512f

avx512ifma

no-avx512ifma

avx512pf

no-avx512pf

avx512vbmi

no-avx512vbmi

avx512vbmi2

no-avx512vbmi2

avx512vl

no-avx512vl

avx512vnni

no-avx512vnni

avx512vpopcntdq

no-avx512vpopcntdq

mmx

no-mmx

sse

no-sse

sse2

no-sse2

sse3

no-sse3

sse4

no-sse4

sse4.1

no-sse4.1

sse4.2

no-sse4.2

sse4a

no-sse4a

ssse3

no-ssse3

call	puts@PLT
# Esse código não funciona, é apenas uma ilustração.

  .section .got
real_puts_address.got:
  .quad 0
real_printf_address.got:
  .quad 0

  .section .plt
puts.plt:
  jmp *real_puts_address.got
printf.plt:
  jmp *real_printf_address.got

  .data
message:
  .asciz "Hello World!"

  .text
my_func:
  lea message(%rip), %rdi
  call puts.plt
  
  ret
extern puts

section .data
    message: db "Hello World!", 0

section .text
global assembly
assembly:
    lea rdi, [rel message]
    call puts wrt ..plt

    ret

Depuração de código

Aprendendo a depurar cĂłdigo em nĂ­vel de Assembly

O termo depuração (debugging) Ă© usado na ĂĄrea da computação para se referir ao ato de procurar e corrigir falhas (bugs) em softwares. A principal ferramenta, embora nĂŁo Ășnica, usada para essa tarefa Ă© um tipo de software conhecido como depurador (debugger). Essa ferramenta basicamente dĂĄ ao programador a possibilidade de controlar a execução de um programa enquanto ele pode ver informaçÔes sobre o processo em tempo de execução.

Existem depuradores que meramente exibem o código-fonte do programa e o programador acompanha a execução do código vendo o código-fonte do projeto. Mas existe uma categoria de depuradores que exibem o disassembly do código do programa e o programador é capaz de ver a execução do código acompanhando as instruçÔes em Assembly.

Este capítulo tem por objetivo dar uma noção båsica de como depuradores funcionam e ensinar a usar algumas ferramentas para depuração de código.

Ferramentas

O conteĂșdo serĂĄ principalmente baseado em ferramentas sendo utilizadas em ambiente Linux, porĂ©m a maior parte do conteĂșdo Ă© reaproveitĂĄvel no Windows.

CĂłdigos de exemplo serĂŁo escritos em C e compilados com o GCC, bem como alguns serĂŁo escritos diretamente em Assembly e usando o assembler NASM.

Depurando com o Dosbox

Aprendendo a usar o depurador do Dosbox

O emulador Dosbox tem um depurador embutido que facilita bastante na hora de programar alguma coisa para o MS-DOS

Immediate

Campo immediate na instrução do código de måquina.

O campo immediate (valor "imediato") pode ter 1, 2, ou 4 bytes de tamanho. Ele é o operando numérico presente em algumas instruçÔes. Exemplo:

mov eax, 0x11223344

Essa instrução em código de måquina fica: B8 44 33 22 11

Onde B8 é o opcode da instrução e 44 33 22 11 o valor imediato (0x11223344). Lembrando que a arquitetura x86 é little-endian, portanto o valor imediato fica em little-endian na instrução.

O tamanho desse campo é definido pelo atributo operand-size, portanto ao usar o prefixo 66 o seu tamanho pode alternar na instrução entre 16-bit e 32-bit. Sobre instruçÔes com operandos de 8-bit, como mov al, 123, existem opcodes específicos para operandos nesse tamanho portanto o prefixo não é usado nessas instruçÔes. E obrigatoriamente o immediate terå 8-bit de tamanho.

Outros dois exemplos seriam mov ax, 0x1122 e mov al, 0x11. Onde o primeiro tem o cĂłdigo de mĂĄquina 66 B8 22 11 em modo de 32-bit, e em modo de 16-bit fica igual sĂł que sem o prefixo 66.

Jå a segunda instrução terå o código de måquina B0 11 em qualquer modo de operação, jå que ela independe do operand-size.

stack
Registrador de propĂłsito geral
A base
thread
registrador de segmento
convenção de chamada
argumentos variĂĄveis
SSE
stack frame
inline Assembly
prĂłlogo e epĂ­logo
função como inline
visualizar o cĂłdigo de saĂ­da
As restriçÔes
o sufixo na instrução
sysv_abi
ms_abi
cdecl
stdcall

TO DO

Ainda nĂŁo acabou.

Este livro Ă© um trabalho em andamento e ainda hĂĄ muita coisa para ser escrita. Abaixo segue uma lista do conteĂșdo que pretendo inserir no livro:

Essa lista nĂŁo Ă© absoluta, Ă© sĂł para dar uma noção do que pretendo produzir de conteĂșdo. Durante a escrita posso adicionar mais coisas que nĂŁo me lembrei de colocar aqui e tambĂ©m mudar a ordem/tĂ­tulo dos tĂłpicos.

Clobber

Descrição

cc

memory

Indica que o cĂłdigo ASM faz leitura ou escrita da/na memĂłria em outro lugar que nĂŁo seja um dos operandos de entrada ou saĂ­da. Por exemplo em uma memĂłria apontada por um ponteiro de um operando.

Esse clobber evita que o compilador assuma que os valores das variåveis na memória permanecem os mesmos após a execução do código ASM. E também garante que o compilador escreva o valor de todas as variåveis na memória antes de executar o inline ASM.

rax

Indica que o registrador RAX serĂĄ modificado.

rbx

Indica que o registrador RBX serĂĄ modificado.

etc.

...

Restrição

Descrição

m

Operando na memĂłria.

r

i

Um valor inteiro imediato.

F

Um valor floating-point imediato.

g

Um operando na memória, registrador de propósito geral ou inteiro imediato. Mesmo efeito que usar "rim" como restrição.

p

Um operando que é um endereço de memória vålido.

X

Qualquer operando Ă© permitido. Basicamente deixa a decisĂŁo nas mĂŁos do compilador.

Restrição

Descrição

R

Registradores legado. Qualquer um dos oito registradores de propĂłsito geral disponĂ­veis em IA-32.

q

Qualquer registrador que seja possĂ­vel ler o byte menos significativo. Como RAX (AL) ou R8 (R8B) por exemplo.

Q

Qualquer registrador que seja possĂ­vel ler o segundo byte menos significativo, como RAX (AH) por exemplo.

a

O registrador "A" (RAX, EAX, AX ou AL).

b

O registrador "B" (RBX, EBX, BX ou BL).

c

O registrador "C" (RCX, ECX, CX ou CL).

d

O registrador "D" (RDX, EDX, DX ou DL).

S

RSI, ESI, SI ou SIL.

D

RDI, EDI, DI ou DIL.

A

O conjunto AX:DX.

f

t

ST0

u

ST1

y

Qualquer registrador MMX.

x

Yz

XMM0

I

Um inteiro constante entre 0 e 31, usado para shift com valores de 32-bit.

J

Um inteiro constante entre 0 e 63, usado para shift com valores de 64-bit.

K

Inteiro sinalizado de 8-bit.

N

Inteiro nĂŁo-sinalizado de 8-bit.

CĂłdigo de mĂĄquina

Entendendo o cĂłdigo de mĂĄquina x86-64

O famigerado cĂłdigo de mĂĄquina (tambĂ©m chamado de linguagem de mĂĄquina), popularmente conhecido como "zeros e uns", sĂŁo as instruçÔes que o processador interpreta e executa. SĂŁo basicamente nĂșmeros onde o processador decodifica esses nĂșmeros afim de executar determinadas operaçÔes identificadas pelas instruçÔes.

Acho que boa parte das pessoas da årea da computação sabem que processadores de computadores digitais funcionam com sinais elétricos com duas tensÔes diferentes: Uma alta (lå pelos 3v, mas pode variar de acordo com o processador) e uma baixa (perto de 0v), onde a tensão alta representa o 1 e a tensão baixa representa o 0.

Mas comumente é só isso o que as pessoas sabem sobre código de måquina. O objetivo deste capítulo é dar uma noção aprofundada de como funciona o código de måquina da arquitetura x86-64.

Cada arquitetura de processador (vulgo ISA, Instruction Set Architecture) tĂȘm um cĂłdigo de mĂĄquina distinto. Portanto as informaçÔes aqui sĂŁo vĂĄlidas para cĂłdigo de mĂĄquina x86 e x86-64. ARM, RISC-V etc. contĂ©m cĂłdigo de mĂĄquina que funciona de um jeito completamente diferente.

Representação textual

Antes de mais nada um pré-aviso: Sei que é romùntico quando se fala de código de måquina meter um monte de zeros e uns (como: 10110100010). Mas na vida real ninguém representa textualmente código de måquina em binårio. Isso é normalmente feito em manuais ou ferramentas como disassemblers e debuggers usando hexadecimal.

EntĂŁo ao pensar em cĂłdigo de mĂĄquina nĂŁo pense nisso 10110100 00001110 mas sim nisso B4 0E. VocĂȘ Ă© humano, pense como tal.

Ferramentas

Comecei a desenvolver uma ferramenta exclusivamente para ser usada como auxĂ­lio para esse capĂ­tulo. Eu a chamei de x86-visualizer e seu intuito Ă© vocĂȘ escrever uma instrução em Assembly e ela lhe exibir o cĂłdigo de mĂĄquina dividido em seus campos, assim facilitando o entendimento.

A ferramenta não estå concluída então poucas instruçÔes irão funcionar, todavia sugiro seu uso durante a leitura do capítulo afim de facilitar o entendimento da codificação das instruçÔes.

Acesse o repositĂłrio dela aqui:

Formato das instruçÔes

O formato das instruçÔes do código de måquina.

CISC

Primeira coisa que a gente precisa saber é que a arquitetura x86-64 é CISC (Complex Instruction Set Computer), ou seja uma arquitetura que contém um conjunto complexo de instruçÔes.

O que significa na pråtica que a arquitetura contém muitas instruçÔes consideradas "complexas", que efetuam muitas operaçÔes de uma vez. Por exemplo a instrução rep movsb faz um bocado de coisas:

  1. Copia o valor em DS:ESI para ES:EDI.

  2. Incrementa o valor de ESI.

  3. Incrementa o valor de EDI.

  4. Decrementa o valor de ECX.

  5. Verifica se o valor de ECX Ă© zero. Se for finaliza o loop.

Tudo isso em apenas uma instrução.

Formato

Esse é o formato de uma instrução do código de måquina da arquitetura segundo os manuais da Intel:

Legacy prefixes: são prefixos que existem desde o x86, alguns até mesmo desde o 8086. Por isso são chamados de "legacy" (legados).

REX prefix: Ă© um prefixo novo existente somente no modo de 64-bit e adicionado em processadores x86-64.

Opcode: abreviação para operation code (código de operação), é um valor numérico (de 1 a 3 bytes de tamanho) que identifica qual operação o processador deve executar. Desde mover valores, subtrair, somar, calcular a raiz quadrada, modificar o valor de um registrador etc.

ModR/M: é um byte na instrução que não estå presente em todas elas. Explico em detalhes depois mas ele serve para definir o modo de endereçamento e/ou qual registrador é usado na operação. Por isso o R/M, que é uma abreviação para Register/Memory.

SIB: dependendo do modo de endereçamento definido em ModR/M, o byte SIB pode ser usado. Ele define trĂȘs valores:

  • Scale (2 bits): determina um fator de "escala" (1, 2, 4 ou 8) que irĂĄ multiplicar o valor do index.

  • Index (3 bits): define o registrador que serĂĄ usado como Ă­ndice.

  • Base (3 bits): define o registrador que serĂĄ usado como base. Na prĂĄtica o cĂĄlculo do endereçamento Ă© feito como na seguinte pseudo-expressĂŁo:

address = base + index * scale

Displacement: é um valor numérico de 1, 2 ou 4 bytes de tamanho que é somado ao endereçamento definido por ModR/M. Nem todo modo de endereçamento definido por ModR/M usa o displacement, então nem sempre ele estå presente em uma instrução com operando na memória.

Immediate: Ă© um valor numĂ©rico de 1, 2 ou 4 bytes de tamanho usado em algumas operaçÔes que usam um operando imediato. Por exemplo mov ah, 0x0E, onde o nĂșmero 0x0E (14 em decimal) Ă© o valor immediate na instrução.

Uma instrução na arquitetura x86 pode ter de 1 atĂ© 15 bytes de tamanho. E caso ainda nĂŁo tenha ficado claro: sim, instruçÔes na arquitetura x86 tĂȘm tamanhos variados.

InterrupçÔes de software e exceçÔes

InterrupçÔes e exceçÔes sendo entendidas na pråtica.

Uma interrupção Ă© um sinal enviado para o processador solicitando a atenção dele para a execução de outro cĂłdigo. Ele para o que estĂĄ executando agora, executa este determinado cĂłdigo da interrupção e depois volta a executar o cĂłdigo que estava executando antes. Esse sinal Ă© geralmente enviado por um hardware externo para a CPU, cujo o mesmo Ă© chamado de IRQ — Interrupt Request — que significa "pedido de interrupção".

Enquanto a interrupção de software é executada de maneira muito semelhante a uma chamada de procedimento por far call. Ela é basicamente uma interrupção que é executada pelo software rodando na CPU, daí o nome.

No caso de interrupçÔes de softwares sendo disparadas em um processo executando sob um sistema operacional, o código executado da interrupção é definido pelo próprio sistema operacional e estå fora da memória do processo. Portanto hå uma troca de contexto onde a tarefa momentaneamente fica suspensa enquanto a interrupção não finaliza.

Interrupt Descriptor Table

O cĂłdigo que Ă© executado quando uma interrupção Ă© disparada se chama handler e o endereço do mesmo Ă© definido na IDT — Interrupt Descriptor Table. Essa tabela nada mais Ă© que uma sequĂȘncia de valores indicando o offset e segmento do cĂłdigo Ă  ser executado. É uma array onde cada elemento contĂ©m essas duas informaçÔes. PoderĂ­amos representar em C da seguinte forma:

// Em 16-bit

struct elem {
  uint16_t offset;
  uint16_t segment;
}

struct elem idt[256];

Ou seja o nĂșmero que identifica a interrupção nada mais Ă© que o Ă­ndice a ser lido no vetor.

Exception

Provavelmente vocĂȘ jĂĄ ouviu falar em exception. A exception nada mais Ă© que uma interrupção e tem o seu handler definido na IDT. Por exemplo quando vocĂȘ comete o erro clĂĄssico de tentar acessar uma regiĂŁo de memĂłria invĂĄlida ou sem permissĂ”es adequadas em C, vocĂȘ compila o cĂłdigo e recebe a clĂĄssica mensagem segmentation fault.

Nesse caso a exceção que foi disparada pelo processador se chama General Protection e pode ser referida pelo mnemÎnico #GP, seu índice na tabela é 13.

Essa exceção Ă© disparada quando hĂĄ um problema na referĂȘncia de memĂłria ou qualquer proteção Ă  memĂłria que foi violada. Como por exemplo ao tentar escrever em um segmento de memĂłria que nĂŁo tem permissĂŁo para escrita.

Um sistema operacional configura uma exceção da mesma forma que configura uma interrupção, modificando a IDT para apontar para o código que ele quer que execute. Nesse caso o índice 13 precisaria ser modificado.

IDT em Real Mode

A instrução int imm8 é usada para disparar interrupçÔes de software/exceçÔes. Bastando simplesmente passar o índice da interrupção como operando.

Vamos ver na pråtica a configuração de uma interrupção em 16-bit. Para isso vamos usar o MS-DOS para que fique mais simples.

A IDT estå localizada no endereço 0 em real mode, por isso podemos configurar para acessar o segmento zero e assim o offset seria o índice de cada elemento da IDT. O que precisamos fazer é acessar o índice que queremos modificar na IDT, depois é só jogar o offset e segmento do procedimento que queremos que seja executado. Em 16-bit isso acontece de uma maneira muito mais simples do que em protected mode, por isso é ideal para entender na pråtica.

Eis o cĂłdigo:

int.asm
bits 16
org  0x100

VADDR equ 0xb800

; ID, segmento, offset
%macro setint 3
  mov bx, (%1) * 4
  mov word [es:bx], %3
  mov word [es:bx + 2], %2
%endmacro


; -- Main -- ;
mov ax, 0
mov es, ax

setint 0x66, cs, int_putchar

mov al, 'A'
mov ah, 0x0B
int 0x66

mov ah, 0x0C
int 0x66

ret

; -- Interrupção -- ;
int_cursor: dw 0

; Argumentos:
;   AL    Caractere
;   AH    Atributo
int_putchar:
  push es
  mov bx, VADDR
  mov es, bx

  mov di, [int_cursor]
  mov word [es:di], ax

  add word [int_cursor], 2
  pop es
  iret

Para compilar e testar usando o Dosbox:

$ nasm int.asm -o int.com
$ dosbox int.com

A interrupção simplesmente escreve os caracteres na parte superior esquerda da tela.

Note que a interrupção retorna usando a instrução iret ao invĂ©s de ret. Em 16-bit a Ășnica diferença nessa instrução Ă© que ela tambĂ©m desempilha o registrador de flags, que Ă© empilhado pelo processador ao disparar a interrupção/exceção.

Perceba que é unicamente um código de exemplo. Essa não é uma maneira segura de se configurar uma interrupção tendo em vista que seu handler estå na memória do .com que, após finalizar sua execução, poderå ser sobrescrita por outro programa executado posteriormente.

Os depuradores modificam a instrução original colocando a instrução que dispara a exceção de breakpoint. Depois tratam o sinal enviado para o processo, restauram a instrução original e continuam seu trabalho.

O breakpoint nada mais é que uma exceção que é disparada por uma instrução. Podemos usar int 0x03 (CD 03 em código de måquina) para fazer isso porém essa instrução tem 2 bytes de tamanho e não é muito apropriada para um depurador usar. Por isso existe a instrução int3 que dispara #BP explicitamente e tem somente 1 byte de tamanho (opcode 0xCC).

int.asm
bits 16
org  0x100

; ID, segmento, offset
%macro setint 3
  mov bx, (%1) * 4
  mov word [es:bx], %3
  mov word [es:bx + 2], %2
%endmacro


; -- Main -- ;

xor ax, ax
mov es, ax

setint 0x03, cs, break

int3
int3

ret

; -- Breakpoint -- ;

break:
  mov ah, 0x0E
  mov al, 'X'
  int 0x10
  iret

Sinais

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>

void segfault(int signum)
{
  fputs("TĂĄ pegando fogo bixo!\n", stderr);
  exit(signum);
}

// Esse código também funciona no Windows.
int main(void)
{
  char *desastre = NULL;
  struct sigaction action = {
      .sa_handler = segfault,
  };

  sigaction(SIGSEGV, &action, NULL);

  strcpy(desastre, "Eita!");

  puts("Tchau mundo!");
  return 0;
}

Atributos e prefixos

Entendendo os prefixos no cĂłdigo de mĂĄquina.

Na arquitetura x86 as instruçÔes contém o que é conhecido como "atributos", onde existe um determinado valor padrão para o atributo e é possível modificå-lo com um prefixo.

Uma instrução pode ter mais de um prefixo (até 4 legados). O prefixo REX existente somente em x86-64 precisa obrigatoriamente vir antes do opcode e depois dos demais prefixos. Mas exceto por ele, todos os outros prefixos podem ser adicionados em qualquer ordem que não farå diferença na instrução. Por exemplo a instrução mov eax, [ebx] em modo de 16-bit seria compilada como na imagem:

Onde 67 66 8B 03 e 66 67 8B 03 dariam na mesma, o processador executaria as duas instruçÔes de maneira totalmente equivalente.

Atributo address-size

O atributo address-size determina o modo de endereçamento da instrução. Em modo 16-bit o atributo address-size por padrão é de 16-bit. E em modo de 32-bit o atributo é por padrão de 32-bit. Jå em modo de 64-bit o endereçamento padrão é 64-bit.

O prefixo conhecido como address-size override, cujo o byte é 67, serve para usar o modo de endereçamento não-padrão. Ou seja, ao usar o prefixo se estiver em modo de 16-bit o endereçamento serå de 32-bit. E se estiver em modo de 32-bit o endereçamento serå de 16-bit. Jå se estiver em modo de 64-bit o endereçamento serå de 32-bit.

Por isso o prefixo é adicionado em 16-bit para instruçÔes que usam endereçamento de 32-bit. O mesmo também é feito na situação oposta:

Atributo operand-size

Assim como é possível alternar entre endereçamento de 16-bit e 32-bit nos modos de 16-bit (real mode) e 32-bit (protected mode). Também é possível alternar o tamanho dos operandos usados em operaçÔes.

Assim como também foi demonstrado no primeiro exemplo a instrução de 16-bit fez uma operação com um valor de 32-bit (o registrador EAX teve seu valor alterado para os 4 bytes presentes no endereço [EBX]).

E para isso foi usado o prefixo operand-size override, o byte 66. E na mesma lĂłgica do address-size override ele alterna o tamanho do operando para o seu tamanho nĂŁo-padrĂŁo. Onde em modos de 32-bit e 64-bit o tamanho padrĂŁo de operando Ă© de 32-bit, e em modo de 16-bit o tamanho padrĂŁo Ă© de 16-bit.

Vale citar um erro que eu vi um senhor cometer uma vez: Ele acreditava que em modo de 32-bit era possĂ­vel usar registradores de 64-bit e endereçamento de 64-bit. Bem, isso estĂĄ errado como vocĂȘ pode notar pela explicação acima. Em modo de 16-bit Ă© possĂ­vel usar registradores e endereçamento de 32-bit alterando os atributos address-size e operand-size. Mas o mesmo nĂŁo se aplica para 64-bit porque o uso de operandos de 64-bit Ă© feito por meio do prefixo REX, que sĂł existe em modo de 64-bit. E em modo de 32-bit sĂł Ă© possĂ­vel alternar entre endereçamento de 32-bit e 16-bit usando o prefixo 67.

Atributo segment

Qual segmento de memória serå acessado pela instrução é definido em um atributo. O segmento padrão da instrução é definido de acordo com qual registrador foi usado como base:

Registrador base

Segmento

RIP

CS

SP/ESP/RSP

SS

BP/EBP/RBP

SS

Qualquer outro registrador

DS

Para alterar o atributo de segmento para um outro segmento de memĂłria Ă© usado um prefixo distinto por segmento:

Segmento
Byte do prefixo

CS

2E

DS

3E

ES

26

FS

64

GS

65

SS

36

Exemplo:

Prefixos REP/REPE e REPNE

As instruçÔes de movimentação de dados (movsb, movsw, movsd e movsq) bem como outras como scasb, lodsb, in, out etc. podem ser executadas em loop usando o prefixo REPE ou REPNE.

No caso das instruçÔes MOVS* é possível usar o prefixo REPE, que nesse caso também pode ser chamado só de REP mas os dois mnemÎnicos produzem o mesmo byte (F3).

bits 16
; ...
a32 rep movsb

Assim ECX seria usado ao invés de CX. Onde a32 é uma palavra-chave usada no NASM para denotar que o address-size daquela instrução deve ser de 32-bit. Se usado em modo de 16-bit ele adiciona o prefixo 67, mas se estiver em modo de 32-bit então nenhum prefixo serå adicionado tendo em vista que o address-size padrão jå é de 32-bit.

Sim, também existe a16 e a64. Como também existe o16, o32 e o64 para denotar o tamanho do operand-size. Mas detalhe que a64 e o64 denotam o uso do prefixo REX que só existe em modo de 64-bit.

Prefixo LOCK

O prefixo LOCK (byte F0) Ă© usado para fazer operaçÔes de escrita atĂŽmica em um determinado endereço de memĂłria. Ou seja o prefixo garante que outros nĂșcleos do processador nĂŁo escrevam naquele endereço ao mesmo tempo, exigindo que essa operação finalize antes de outra que escreva no mesmo endereço seja executada.

Esse prefixo só pode ser usado nas seguintes instruçÔes: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD e XCHG. Isso, obviamente, quando o operando destino (o que estå sendo escrito) é um operando na memória.

Na sintaxe do NASM o prefixo pode ser usado simplesmente com a palavra-chave lock antes da instrução. Como em:

lock add [ebx], 4

Prefixos de branch hint

É possĂ­vel manualmente vocĂȘ instruir para o sistema de branch prediction do processador quais saltos condicionais provavelmente irĂŁo ocorrer ou nĂŁo usando dois prefixos:

  • 2E - Instrui para o processador que o pulo provavelmente nĂŁo ocorrerĂĄ.

  • 3E - Instrui para o processador que provavelmente o pulo ocorrerĂĄ.

Na sintaxe do NASM esses prefixos podem ser adicionados em saltos condicionais com as palavra-chaves false e true respectivamente. Como em:

false jz my_label

Todavia esses prefixos são obsoletos e até mesmo ignorados por processadores mais novos, tendo em vista que processadores mais modernos usam um algoritmo para determinar qual salto é mais provåvel de ser tomado ou não. E também saltos para trås são considerados tomados e saltos para frente como não tomados. Isso por causa da forma como compiladores geram código para loops e condicionais.

Em versÔes mais modernas do NASM ele simplesmente irå ignorar o false ou true e não adicionarå prefixo algum.

Entendendo os depuradores

Entendendo os conceitos principais sobre um depurador e como eles funcionam.

Depuradores (debuggers) são ferramentas que atuam se conectando (attaching) em processos para controlar e monitorar a execução dos mesmos. Isso é possível por meio de recursos que o próprio sistema operacional provém, no caso do Linux por meio da syscall ptrace.

O processo que se conecta Ă© chamado de tracer e o processo conectado Ă© chamado de tracee. Essa conexĂŁo Ă© chamada de attach e Ă© feita em uma thread individual do processo. Quando o depurador faz attach em um processo ele na verdade estĂĄ fazendo attach na thread principal do processo.

Processos

As threads são tarefas individuais em um processo. Cada thread de um processo executa um código diferente de maneira concorrente em relação as outras threads do mesmo processo.

Um processo Ă© basicamente a imagem de um programa em execução. Uma parte do sistema operacional conhecida como loader (ou dynamic linker) Ă© a responsĂĄvel por ler o arquivo executĂĄvel, mapear seus cĂłdigos e dados na memĂłria, carregar dependĂȘncias (bibliotecas) resolvendo seus sĂ­mbolos e iniciar a execução da thread principal do processo no cĂłdigo que estĂĄ no endereço do entry point do executĂĄvel. Onde entry point se trata de um endereço armazenado dentro do arquivo executĂĄvel e Ă© o endereço onde a thread principal inicia a execução.

O depurador tem acesso a memória de um processo e pode controlar a execução das threads do processo. Ele também tem acesso a outras informaçÔes sobre o processo, como o valor dos registradores em uma thread por exemplo.

Context switch

Do ponto de vista de cada thread de um processo ela tem exclusividade na execução de código no processador e no acesso a seus recursos. Inclusive em Assembly usamos registradores do processador diretamente sem nos preocuparmos com outras threads (do mesmo processo ou de outros) usando os mesmos registradores "ao mesmo tempo".

Cada nĂșcleo (core) do processador tĂȘm um conjunto individual de registradores, mas Ă© comum em um sistema operacional moderno diversas tarefas estarem concorrendo para executar em um mesmo nĂșcleo.

Uma parte do sistema operacional chamada de scheduler Ă© responsĂĄvel por gerenciar quando e qual tarefa serĂĄ executada em um determinado nĂșcleo do processador. Isso Ă© chamado de escalonamento de processos (scheduling) e quando o scheduler suspende a execução de uma tarefa para executar outra isso Ă© chamado de troca de contexto ou troca de tarefa (context switch ou task switch).

Quando hĂĄ a troca de contexto o scheduler se encarrega de salvar na memĂłria RAM o estado atual do processo, e isso inclui o valor dos registradores. Quando a tarefa volta a ser executada o estado Ă© restaurado do ponto onde ele parou, e isso inclui restaurar o valor de seus registradores.

É assim que cada thread tem valores distintos em seus registradores. É assim tambĂ©m que depuradores sĂŁo capazes de ler e modificar o valor de registradores em uma determinada thread do processo, o sistema operacional dĂĄ a capacidade de acessar esses valores no contexto da tarefa e permite fazer a modificação. Quando o scheduler executar a tarefa o valor dos registradores serĂŁo atualizados com o valor armazenado no contexto.

Processadores Intel mais modernos tĂȘm uma tecnologia chamada Hyper-Threading. Essa tecnologia permite que um mesmo nĂșcleo atue como se fosse dois permitindo que duas threads sejam executadas paralelamente no mesmo nĂșcleo.

Cada "parte" independente Ă© chamada de processador lĂłgico (logical processor) e cada processador lĂłgico no nĂșcleo tem seu conjunto individual de registradores. Com exceção de alguns registradores "obscuros" que sĂŁo compartilhados pelos processadores lĂłgicos do nĂșcleo. Esses registradores nĂŁo foram abordados no livro, mas caso esteja curioso pesquise por Model-specific register (MSR) e MTRRs. Apenas alguns MSR sĂŁo compartilhados pelos processadores lĂłgicos.

Sinais

Os sinais é um mecanismo de comunicação entre processos (Inter-Process Communication - IPC). Existem determinados sinais em cada sistema operacional e quando um sinal é enviado para um processo ele é temporariamente suspenso e um tratador (handler) do sinal é executado.

A maioria dos sinais podem ter o tratador personalizado pelo programador mas alguns tĂȘm um tratador padrĂŁo e nĂŁo podem ser alterados. É o caso por exemplo no Linux do sinal SIGKILL, que Ă© o sinal enviado para um processo quando vocĂȘ tenta forçar a finalização dele (com o comando kill -9 por exemplo). O tratador desse sinal Ă© exclusivamente controlado pelo sistema operacional e o processo nĂŁo Ă© capaz de personalizar ele.

Exemplo de personalização do tratador de um sinal:

main.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>

void termination(int signum)
{
  puts("Goodbye!");
  _Exit(EXIT_SUCCESS);
}

int main(void)
{
  struct sigaction action = {
      .sa_handler = termination,
  };

  sigaction(SIGTERM, &action, NULL);
  int pid = getpid();

  printf("My PID: %d\nPlease, send-me SIGTERM... ", pid);
  while (1)
  {
    getchar();
  }

  return 0;
}

Experimente compilar e executar esse programa. No Linux vocĂȘ pode enviar o sinal SIGTERM para o processo com o comando kill, como em:

$ kill 155541

# Onde 155541 seria o PID do processo.

O sinal SIGTERM seria o jeito "educado" de finalizar um processo. PorĂ©m como pode ser observado Ă© possĂ­vel que o processo personalize o tratador desse sinal, que por padrĂŁo finaliza o programa. Nesse cĂłdigo de exemplo se removermos a chamada para a função _Exit() o processo nĂŁo irĂĄ mais finalizar ao receber SIGTERM. É por isso que existe o sinal mais "invasivo" SIGKILL que foi feito para ser usado quando o processo nĂŁo estĂĄ mais respondendo.

Um processo que estå sendo depurado (o tracee) para toda vez que recebe um sinal e o depurador toma o controle da execução. Exceto no caso de SIGKILL que funciona normalmente sem a intervenção do depurador.

ExceçÔes

Depuradores

Depuradores tĂȘm a capacidade de controlar a execução das threads de um processo, tratar os sinais enviados para o processo, acessar sua memĂłria e ver/editar dados relacionados ao contexto de cada thread (como os registradores, por exemplo). Todo esse poder Ă© dado para os usuĂĄrios do depurador por meio de alguns recursos que serĂŁo descritos abaixo.

Breakpoint

Um ponto de parada (breakpoint) é um ponto no código onde a execução do programa serå interrompida e o depurador irå manter o programa em pausa para que o usuårio possa controlar a execução em seguida.

Em arquiteturas que nĂŁo tĂȘm uma exceção especĂ­fica para disparar breakpoints os depuradores substituem a instrução por alguma outra instrução que dispararĂĄ alguma exceção. Como uma instrução de divisĂŁo ilegal por exemplo.

Podemos comprovar isso com o seguinte cĂłdigo:

#include <stdio.h>
#include <signal.h>

void breakpoint(int signum)
{
  puts("Breakpoint!");
}

int main(void)
{
  struct sigaction action = {
      .sa_handler = breakpoint,
  };

  sigaction(SIGTRAP, &action, NULL);

  asm("int3");
  puts("...");

  return 0;
}

Hardware/Software breakpoint

O termo software breakpoint é usado para se referir a um breakpoint que é definido e configurado por software (o depurador), como o que jå foi descrito acima. Por exemplo breakpoints podem ter uma condição de parada e isso é implementado pelo próprio depurador. Ele faz o tratamento do breakpoint normalmente mas antes verifica a condição, se a condição não for atendida ele continua a execução do código como se o breakpoint nunca tivesse acontecido.

Jå o termo hardware breakpoint é usado para se referir a um breakpoint que é suportado pelo próprio processador. A arquitetura x86-64 tem 8 registradores de depuração (debug registers) onde 4 deles podem ser usados para indicar breakpoints.

Os registradores DR0, DR1, DR2 e DR3 armazenam o endereço onde irå ocorrer o breakpoint. Jå o registrador DR7 habilita ou desabilita esses breakpoints e configura uma condição para eles. Onde a condição determina em qual ocasião o breakpoint serå disparado, como por exemplo ao ler/escrever naquele endereço ou ao executar a instrução no endereço.

Os debug registers não podem ser lidos/modificados sem privilégios de kernel. Rodando sobre um sistema operacional um processo comum não é capaz de manipulå-los diretamente.

Esse mesmo recurso (com até mais recursos ainda) poderia ser implementado pelo depurador com um software breakpoint. Por exemplo caso o depurador queira que um breakpoint seja disparado ao ler/escrever em um determinado endereço o depurador pode simplesmente modificar as permissÔes de acesso daquele endereço e, quando o processo fosse acessar os dados naquele endereço, uma exceção #GP seria disparada e o depurador poderia retomar o controle da execução.

Execução passo a passo

Depuradores nĂŁo sĂŁo apenas capazes de executar o software e esperar por um breakpoint para retomar o controle. Eles podem tambĂ©m executar apenas uma instrução da thread por vez e permanecer controlando a execução. Isso Ă© chamado de execução passo a passo (step by step), onde o "passo" Ă© uma Ășnica instrução. O usuĂĄrio do depurador pode clicar em um botĂŁo ou executar um comando e apenas uma instrução do processo serĂĄ executada, e o usuĂĄrio pode ver o resultado da instrução e optar pelo que fazer em seguida.

Existe também o conceito de step over que é quando o depurador executa apenas "uma instrução" porém passando todas as instruçÔes da rotina chamada pelo CALL. O que ele faz na pråtica é definir um breakpoint temporårio para a instrução seguinte ao CALL, como na ilustração:

    mov rdi, 5     ; Última instrução executada
--> call anything  ; CALL que iremos "passar por cima"
    test rax, rax  ; Instrução onde o breakpoint serå definido

Se o depurador estiver parado no CALL e executamos um step over, o depurador coloca o breakpoint temporårio na instrução TEST e então irå executar o processo. Quando o breakpoint na instrução TEST for alcançado ele serå removido e o controle serå dado para o usuårio.

Repare no "defeito" desse mecanismo. O step over só funciona apropriadamente se a instrução seguinte ao CALL realmente for executada, senão o processo continuarå a execução normalmente. Experimente rodar o seguinte código em um depurador:

testing.asm
bits 64
default rel

SYS_EXIT equ 60

section .text

global _start
_start:
	call oops
	nop
	xor rdi, rdi
	mov rax, SYS_EXIT
	syscall

oops:
	add qword [rsp], 1
	ret

Compile com:

$ nasm testing.asm -o testing.o -felf64
$ ld testing.o -o testing

Ao dar um step over na chamada call oops um comportamento inesperado ocorre, o programa irĂĄ finalizar sem parar apĂłs o retorno da chamada. Isso Ă© demonstrado na imagem abaixo com o depurador GDB:

InformaçÔes de depuração do executåvel

Muitos depuradores voltados para desenvolvedores leem informaçÔes de depuração à respeito do executåvel produzidas pelo próprio compilador. O compilador pode, por exemplo, dar informaçÔes para que o depurador seja capaz de identificar de qual arquivo e linha do código-fonte uma instrução pertence.

É assim que funcionam os depuradores que exibem o cĂłdigo-fonte (ao invĂ©s de apenas as instruçÔes em Assembly) enquanto executam o processo.

No caso do GCC ele armazena essas informaçÔes dentro do próprio executåvel na tabela de símbolos. Jå o compilador da Microsoft, usado no Visual Studio, atualmente gera um arquivo .pdb contendo todas as informaçÔes de depuração.

Vale ressaltar aqui que o GCC (e qualquer outro compilador) não armazena o código-fonte do projeto dentro do executåvel. Ele meramente armazena o endereço do arquivo lå.

É comum tambĂ©m que depuradores apresentem algum erro ao nĂŁo encontrar o arquivo-fonte indicado no endereço armazenado nas informaçÔes de depuração. Isso acontece quando ele tenta apresentar uma linha de cĂłdigo naquele arquivo mas o mesmo nĂŁo foi encontrado na sua mĂĄquina.

ModR/M e SIB

Entendendo os byte ModR/M e SIB.

Como jå foi mencionado anteriormente o byte ModR/M é usado em algumas instruçÔes para especificar o operando na memória ou registrador.

Em Assembly existem dois "tipos" de instruçÔes que recebem dois operandos:

  1. As que tem um operando registrador e imediato. Exemplo: mov eax, 123

  2. As que tem um operando na memĂłria ou dois operandos registradores. Exemplos: mov [ebx], 123 e mov eax, ebx.

Um jeito mais simples de especificar esse campo no opcode sem precisar lidar com binårio é simplesmente somar o opcode "base" (correspondente ao uso de AL/AX/EAX) mais o código do registrador. Por exemplo se a instrução B8 (B8 + 0) corresponde a mov eax, 123, então o opcode BB (B8 + 3) é mov ebx, 123. E se eu quiser fazer mov bx, 123 basta adicionar o prefixo 66 à instrução.

JĂĄ as instruçÔes do segundo tipo usam o byte ModR/M para definir o operando destino na memĂłria (no caso de instruçÔes sem o operando registrador) ou os dois operandos. Onde o byte ModR/M consiste nos trĂȘs campos:

  • MOD - Os primeiros 2 bits que definem o "modo" do operando R/M.

  • REG - Os 3 prĂłximos bits que definem o cĂłdigo do operando registrador.

  • R/M - Os 3 Ășltimos bits que definem o cĂłdigo do operando R/M.

O byte define 2 operandos:

  1. Um operando que Ă© sempre um registrador, definido no campo REG.

  2. Um operando que pode ser um registrador ou operando na memĂłria.

Para que o campo R/M defina também o código de um registrador, assim como o REG, o valor 3 (11 em binårio) deve ser usado no campo MOD.

Um adendo sobre o byte ModR/M Ă© que em algumas instruçÔes o campo REG Ă© usado como uma extensĂŁo do opcode. É o caso por exemplo das instruçÔes inc dword [ebx] (FF 03) e dec dword [ebx] (FF 0B) que contĂ©m o mesmo byte de opcode mas fazem operaçÔes diferentes. Repare como o campo R/M Ă© necessĂĄrio para especificar o operando na memĂłria mas o REG fica "sobrando", por isso os engenheiros da Intel tomaram essa decisĂŁo minimamente confusa (vulgo gambiarra), afim de aproveitar dessa peculiaridade em instruçÔes que precisam de um operando na memĂłria mas nĂŁo precisam de um operando registrador.

Para os demais valores do campo MOD os seguintes endereçamentos são feitos de acordo com o valor de R/M:

Endereçamento em 16-bit

MOD 00

R/M
Endereçamento

000

[BX+SI]

001

[BX+DI]

010

[BP+SI]

011

[BP+DI]

100

[SI]

101

[DI]

110

displacement 16-bit

111

[BX]

MOD 01

R/M
Endereçamento

000

[BX+SI] + displacement 8-bit

001

[BX+DI] + displacement 8-bit

010

[BP+SI] + displacement 8-bit

011

[BP+DI] + displacement 8-bit

100

[SI] + displacement 8-bit

101

[DI] + displacement 8-bit

110

[BP] + displacement 8-bit

111

[BX] + displacement 8-bit

MOD 10

R/M
Endereçamento

000

[BX+SI] + displacement 16-bit

001

[BX+DI] + displacement 16-bit

010

[BP+SI] + displacement 16-bit

011

[BP+DI] + displacement 16-bit

100

[SI] + displacement 16-bit

101

[DI] + displacement 16-bit

110

[BP] + displacement 16-bit

111

[BX] + displacement 16-bit

Endereçamento em 32-bit

MOD 00

R/M
Endereçamento

000

[eax]

001

[ecx]

010

[edx]

011

[ebx]

100

SIB

101

displacement 32-bit

110

[esi]

111

[edi]

MOD 01

R/M
Endereçamento

000

[eax] + displacement 8-bit

001

[ecx] + displacement 8-bit

010

[edx] + displacement 8-bit

011

[ebx] + displacement 8-bit

100

SIB + displacement 8-bit

101

[ebp] + displacement 8-bit

110

[esi] + displacement 8-bit

111

[edi] + displacement 8-bit

MOD 10

R/M
Endereçamento

000

[eax] + displacement 32-bit

001

[ecx] + displacement 32-bit

010

[edx] + displacement 32-bit

011

[ebx] + displacement 32-bit

100

SIB + displacement 32-bit

101

[ebp] + displacement 32-bit

110

[esi] + displacement 32-bit

111

[edi] + displacement 32-bit

Endereçamento em 64-bit

MOD 00

R/M
Endereçamento

0000

[rax/eax]

0001

[rcx/ecx]

0010

[rdx/edx]

0011

[rbx/ebx]

0100

SIB

0101

[rip/eip] + displacement 32-bit

0110

[rsi/esi]

0111

[rdi/edi]

1000

[r8/r8d]

1001

[r9/r9d]

1010

[r10/r10d]

1011

[r11/r11d]

1100

SIB

1101

[rip/eip] + displacement 32-bit

1110

[r14/r14d]

1111

[r15/r15d]

MOD 01

R/M
Endereçamento

0000

[rax/eax] + displacement 8-bit

0001

[rcx/ecx] + displacement 8-bit

0010

[rdx/edx] + displacement 8-bit

0011

[rbx/ebx] + displacement 8-bit

0100

SIB + displacement 8-bit

0101

[rbp/ebp] + displacement 8-bit

0110

[rsi/esi] + displacement 8-bit

0111

[rdi/edi] + displacement 8-bit

1000

[r8/r8d] + displacement 8-bit

1001

[r9/r9d] + displacement 8-bit

1010

[r10/r10d] + displacement 8-bit

1011

[r11/r11d] + displacement 8-bit

1100

SIB + displacement 8-bit

1101

[r13/r13d] + displacement 8-bit

1110

[r14/r14d] + displacement 8-bit

1111

[r15/r15d] + displacement 8-bit

MOD 10

R/M
Endereçamento

0000

[rax/eax] + displacement 32-bit

0001

[rcx/ecx] + displacement 32-bit

0010

[rdx/edx] + displacement 32-bit

0011

[rbx/ebx] + displacement 32-bit

0100

SIB + displacement 32-bit

0101

[rbp/ebp] + displacement 32-bit

0110

[rsi/esi] + displacement 32-bit

0111

[rdi/edi] + displacement 32-bit

1000

[r8/r8d] + displacement 32-bit

1001

[r9/r9d] + displacement 32-bit

1010

[r10/r10d] + displacement 32-bit

1011

[r11/r11d] + displacement 32-bit

1100

SIB + displacement 32-bit

1101

[r13/r13d] + displacement 32-bit

1110

[r14/r14d] + displacement 32-bit

1111

[r15/r15d] + displacement 32-bit

Byte SIB

Os endereçamentos com R/M 100 (em 32-bit e 64-bit) são os que usam o byte SIB (exceto MOD 11), que como jå foi explicado anteriormente contém os campos Scale, Index e Base que são calculados de maneira equivalente a expressão:

base + index * scale

Onde o campo scale são os 2 primeiros bits, onde seu valor numérico é equivalente aos seguintes fatores de escala:

  • 00 - NĂŁo multiplica o index

  • 01 - Multiplica o index por 2

  • 10 - Multiplica o index por 4

  • 11 - Multiplica o index por 8

Prefixo REX

Entendendo o prefixo REX no x86-64.

Como eu mencionei antes esse prefixo sĂł existe no modo de 64-bit e ele Ă© necessĂĄrio para usar operandos de 64-bit. Esse prefixo nĂŁo Ă© um byte especĂ­fico mas sim todos os bytes entre 40 e 4F. Isso porque os Ășltimos 4 bits do prefixo sĂŁo campos distintos, mas os 4 bits mais significativos do prefixo REX sempre tem o valor fixo de 0100.

Observe as figuras tiradas dos manuais da Intel:

Em modo de 16-bit e 32-bit hå 8 registradores de propósito geral, mas em 64-bit hå 16 registradores de propósito geral. Como eu mencionei antes os campos que especificam os registradores por códigos contém somente 3 bits de tamanho, daí só é possível especificar 8 registradores distintos.

Mas alguns bits do prefixo REX são usados para estender os tamanhos desses campos em 1 bit, assim permitindo especificar até 16 registradores distintos ou 16 modos de endereçamento distintos. Cada bit do prefixo REX é identificado por uma letra e é comumente referido como no formato REX.B que seria o bit B (o menos significativo) do prefixo.

REX.B (bit 0)

Em instruçÔes cujo a codificação do registrador faz parte do opcode, ele é usado para estender o campo de registrador. Onde ele se torna o bit mais significativo do valor.

Em instruçÔes com ModR/M (sem SIB) ele estende o campo R/M como o bit mais significativo.

Em instruçÔes com SIB ele estende o campo Base como o bit mais significativo.

REX.X (bit 1)

Estende o campo Index do SIB como o bit mais significativo.

REX.R (bit 2)

Estende o campo REG do byte ModR/M como o bit mais significativo.

REX.W (bit 3)

Se ligado a instrução usa operandos de 64-bit, onde por padrão os operandos são de 32-bit.

ReferĂȘncias

Bibliografia

Arquiteturas x86 e x86-64

ConvençÔes de chamada

Depuradores

Ferramentas

InstruçÔes intrínsecas

  1. IntelÂź 64 and IA-32 Architectures Software Developer Manuals - Volume 2, Appendix C

Linguagem C

Linux

  1. Linux Programmer's Manual

Sistemas Operacionais

  1. Andrew S. Tanenbaum. Sistemas Operacionais Modernos. 4° Edição. ISBN: 978-8543005676

CĂłdigos consultados

Alguns trechos do livro foram baseados em conhecimento que obtive lendo diretamente o cĂłdigo-fonte de alguns projetos. Abaixo eu listo cada arquivo consultado para fins de referĂȘncia. O * (caractere curinga) indica que consultei todos os arquivos de um determinado diretĂłrio.

Indica que o cĂłdigo ASM modifica as flags do processador (registrador ).

Operando em um .

Qualquer .

Qualquer .

Também sugiro usar o ndisasm afim de fazer experimentaçÔes. Ele é um disassembler que vem junto com o nasm e .

Inclusive a instrução B4 0E que é a mov ah, 0x0E. Onde B4 é o opcode (de 1 byte) e 0E o immediate (de 1 byte também).

No Linux basicamente o que o sistema faz é criar um handler que trata a exceção e manda um para o processo. Esse sinal o processo pode configurar como ele quer tratar, mas por padrão o processo escreve uma mensagem no terminal e finaliza.

Mais um exemplo mas dessa vez configurando a exceção #BP de Ă­ndice 3. Se vocĂȘ jĂĄ usou um , ou pelo menos tem uma noção Ă  respeito, sabe que "breakpoint" Ă© um ponto no cĂłdigo onde o depurador faz uma parada e te permite analisar o programa enquanto ele fica em pausa.

Repare que a cada disparo de int3 executou o código do nosso procedimento break. Esse por sua vez imprimiu o caractere 'X' na tela do Dosbox usando a interrupção 0x10 que serå explicada no .

SĂł para deixar mais claro o que falei sobre que sĂŁo enviados para o processo quando uma exception Ă© disparada, aqui um cĂłdigo em C de exemplo:

Mais detalhes sobre os sinais serĂŁo descritos no tĂłpico .

Os dois tópicos e jå explicaram esse assunto antes no livro, mas do ponto de vista do Assembly. Aqui serå abordado o assunto mais voltado ao código de måquina e com mais informaçÔes.

Como pode ser observado na ilustração exibida no tópico , prefixos são bytes que podem (são opcionais na grande maioria das instruçÔes) ser adicionados antes do opcode de uma instrução.

Em modo de 16-bit e modo de 32-bit, desde o processador i386, é possível usar tanto de 16-bit como de 32-bit. No exemplo anterior a instrução mov eax, [ebx] foi compilada no modo de 16-bit, porém usando endereçamento e operando de 32-bit.

Ao usar esse prefixo na instrução, assim como foi , ela é executada em loop enquanto o valor de ECX não for zero. E a cada iteração do loop o valor do registrador é decrementado. Na verdade se CX ou ECX serå usado isso é definido pelo atributo address-size e pode ser alternado com o prefixo address-size override. Por exemplo na sintaxe do NASM ficaria assim:

Nas instruçÔes CMPS* e SCAS* o prefixo REPE (ou REPZ) repete a instrução enquanto a estiver setada. Jå REPNE (ou REPNZ) repete enquanto a zero flag estiver zerada.

Quando um processo dispara uma , um tratador (handler) configurado pelo sistema operacional envia um sinal para o processo tratar aquela exceção. Depuradores são capazes de identificar (e ignorar) exceçÔes intervindo no processo de handling desse sinal.

Agora que jĂĄ entendemos um pouco sobre processos vai ficar mais fĂĄcil entender como depuradores funcionam. Afinal de contas depuradores depuram processos.

Os breakpoints são implementados na pråtica (na arquitetura x86-64) como uma instrução int3 que dispara #BP. Quando um depurador insere um breakpoint em um determinado ponto do código ele estå simplesmente modificando o primeiro byte da instrução para o byte 0xCC, que é o byte da instrução int3. Quando a exceção é disparada o sinal SIGTRAP é enviado para o processo e o depurador se encarrega de dar o controle da execução para o usuårio. Quando o usuårio continua a execução o depurador restaura o byte original da instrução, executa ela e coloca o byte 0xCC novamente.

Ao executar a instrução int3 inserida com na linha 17, o processo recebe o sinal SIGTRAP e nosso tratador é executado. Experimente comentar a chamada para sigaction na linha 15 para ver o resultado do tratador padrão.

Quando a condição do breakpoint é atendida o processador dispara #BP.

Isso é implementado na arquitetura x86-64 usando a trap flag (TF) no registrador . Quando a TF estå ligada cada instrução executada dispara #BP, permitindo assim que o depurador retome o controle após executar uma instrução.

O primeiro tipo nĂŁo precisa do byte ModR/M, pois o registrador destino Ă© especificado nos 3 Ășltimos bits do byte do . Por exemplo o opcode B8 da instrução mov eax, 123 Ă© o seguinte em binĂĄrio: 10111000 Onde o nĂșmero zero (000) Ă© o cĂłdigo para identificar o registrador EAX.

Devido ao o campo R/M Ă© estendido em 1 bit no modo de 64-bit.

Jå os campos index e base contém 3 bits cada e os mesmos armazenam o que serão usados. Os bits dos campos no byte seguem a ordem que o próprio nome sugere. Como em: SSIIIBBB.

Frederico Lamberti Pissarra.

Daniel P. Bovet, Marco Cesati.

🙂
EFLAGS
registrador de propĂłsito geral
https://github.com/Silva97/x86-visualizer
jĂĄ foi utilizado anteriormente no livro
depurador
prĂłximo tĂłpico
Entendendo os depuradores
atributos
prefixos
Formato das instruçÔes
Modos de operação
endereçamento
Registradores de segmento
exceção
a exceção
inline Assembly
uma exceção
opcode
Codificação dos registradores
prefixo REX
cĂłdigo dos registradores
registrador do x87
registrador SSE
mencionei anteriormente
sinal
os sinais
explicado anteriormente
zero flag
uma exceção
EFLAGS
IntelÂź 64 and IA-32 Architectures Software Developer Manuals
x86 and amd64 instruction reference - Félix Cloutier
X86 Opcode and Instruction Reference
AT&T Assembly Syntax
System V Application Binary Interface Intel386 Architecture Processor Supplement - Version 1.0
System V Application Binary Interface AMD64 Architecture Processor Supplement - Draft Version 0.99.7
Calling Conventions | Microsoft Docs
x86 calling conventions | Wikipedia
GDB Internals - Breakpoint Handling
Using as | Documentation for binutils 2.37
GAS syntax - Wikibooks
NASM version 2.15.05 documentation
Using the GNU Compiler Collection (GCC) | GNU Project
GNU Compiler Collection (GCC) Internals
Debugging with GDB
Intrinsics | IntelÂź C++ Compiler Classic Developer Guide and Reference
IntelÂź Intrinsics Guide
An Introduction to GCC Compiler Intrinsics in Vector Processing
C11 Standard - ISO/IEC 9899:201x draft n1570
The GNU C Reference Manual
Dicas - C e Assembly para arquitetura x86-64
Linux System Call Table for x86 64
Lazy binding
The .init and .fini Sections
ptrace(2) — Linux manual page
ld.so(8) — Linux manual page
Understanding the Linux Kernel, 3rd Edition - 4.5 Exception Handling
Escalonamento de processos | Wikipédia
Troca de contexto | Wikipédia
Sinal (ciĂȘncia da computação) | WikipĂ©dia
glibc
/csu/*
/sysdeps/x86_64/start.S
/sysdeps/x86_64/crti.S
/sysdeps/x86_64/crtn.S

Codificação dos registradores

Entendendo a codificação dos registradores em 16-bit, 32-bit e 64-bit

Em modo de 16-bit e 32-bit cada registrador Ă© identificado usando um nĂșmero de 3 bits, permitindo assim identificar uma variação de 8 registradores diferentes. PorĂ©m vĂĄrios registradores compartilham do mesmo cĂłdigo, e qual especificamente serĂĄ usado varia de acordo com a instrução sendo utilizada e o tamanho do operando.

Como por exemplo a instrução fld st3 que em código de måquina fica D9 C3, onde C3 é o ModR/M:

MOD -> 11
REG -> 000
R/M -> 011

Repare que essa instrução usa o campo REG como extensão do opcode e o R/M é usado para especificar o operando. Não coincidentemente o código 3 (0b011) é usado para identificar o registrador ST3.

Por exemplo as seguintes instruçÔes compiladas em modo de 64-bit:

mov eax, 0x11223344                    b8 44 33 22 11
mov ax, 0x1122                         66 b8 22 11
mov al, 0x11                           b0 11

Se convertermos esses opcodes em binĂĄrio teremos o seguinte:

B8 -> 10111000
B0 -> 10110000

Esses dois opcodes usam os 3 Ășltimos bits para identificar o registrador. Veja que o mesmo cĂłdigo 000 acabou sendo usado para identificar EAX, AX e AL.

Isso porque na primeira instrução o atributo operand-size padrão de 32-bit foi usado, então o registrador EAX é usado na instrução. Jå na segunda o prefixo operand-size override (byte 66) foi usado, assim o operand-size era de 16-bit e portanto o registrador AX é usado.

JĂĄ a Ășltima instrução Ă© exclusivamente usada para operandos de 8-bit, e portanto o registrador AL Ă© usado.

Tabela de cĂłdigos

A tabela abaixo lista os cĂłdigos usados para identificar os registradores. Lembrando que o bit mais significativo indica um dos bits do REX ligado, ou seja, sĂł Ă© utilizado em modo de 64-bit.

CĂłdigo
Registrador

0000

AL/AX/EAX/RAX/ST0/MM0/XMM0

0001

CL/CX/ECX/RCX/ST1/MM1/XMM1

0010

DL/DX/EDX/RDX/ST2/MM2/XMM2

0011

BL/BX/EBX/RBX/ST3/MM3/XMM3

0100

AH/SP/ESP/RSP/ST4/MM4/XMM4

0101

CH/BP/EBP/RBP/ST5/MM5/XMM5

0110

DH/SI/ESI/RSI/ST6/MM6/XMM6

0111

BH/DI/EDI/RDI/ST7/MM7/XMM7

1000

R8B/R8W/R8D/R8/ST0/MM0/XMM8

1001

R9B/R9W/R9D/R9/ST1/MM1/XMM9

1010

R10B/R10W/R10D/R10/ST2/MM2/XMM10

1011

R11B/R11W/R11D/R11/ST3/MM3/XMM11

1100

R12B/R12W/R12D/R12/ST4/MM4/XMM12

1101

R13B/R13W/R13D/R13/ST5/MM5/XMM13

1110

R14B/R14W/R14D/R14/ST6/MM6/XMM14

1111

R15B/R15W/R15D/R15/ST7/MM7/XMM15

Ambiente hosted

Entendendo a execução de código em C no ambiente hosted.

Na especificação da linguagem C é descrito dois ambientes de execução de código: Os ambientes hosted e freestanding. Neste tópico vamos entender alguns pontos em relação a como funciona a estrutura e a execução de um programa em C no ambiente hosted.

A função main

A função main pode ser escrita com um dos dois protótipos abaixo:

int main(void)
{
  // ...
}
int main(int argc, char *argv[])
{
  // ...
}

Ou qualquer outro protĂłtipo que seja equivalente a um desses. Como por exemplo char **argv tambĂ©m seria vĂĄlido por ter equivalĂȘncia a char *argv[]. TambĂ©m pode-se usar qualquer nome de parĂąmetro, argc e argv sĂŁo apenas sugestĂ”es.

O primeiro parĂąmetro passado para a função main indica o nĂșmero de argumentos e o segundo Ă© uma array de ponteiros para char onde cada Ă­ndice na array Ă© um argumento e argv[argc] Ă© um ponteiro NULL.

Se o tipo de retorno da função main for int (ou equivalente), o valor de retorno da primeira chamada para main é equivalente a chamar a função exit passando esse valor como argumento.

C startup code

O código na glibc responsåvel pela inicialização do programa é chamado de C startup (CSU). Ele se encarrega de obter os argumentos de linha de comando, inicializar o TLS, executar o código na seção .init dentre outras tarefas de inicialização do programa.

O arquivo start.S Ă© o que declara o sĂ­mbolo _start, ou seja, a função de entry point do programa. A Ășltima chamada nessa função Ă© para outra função chamada __libc_start_main que recebe o endereço da função main como primeiro argumento. Depois de algumas inicializaçÔes essa função chama a main, obtĂ©m o valor retornado em EAX e passa como argumento para a função responsĂĄvel por finalizar o programa no sistema operacional (exit_group no Linux e ExitProcess no Windows).

Todos esses cĂłdigos estĂŁo em arquivos objetos prĂ©-compilados no seu sistema operacional. Eles sĂŁo linkados por padrĂŁo quando vocĂȘ invoca o GCC mas nĂŁo sĂŁo linkados por padrĂŁo se vocĂȘ chamar o linker (ld) diretamente.

No meu Linux o arquivo objeto Scrt1.o ("crt" é sigla para "C runtime") é o que contém o entry point (código do start.S). Os arquivos crti.o e crtn.o contém o prólogo e o epílogo, respectivamente, para as seçÔes .init e .fini.

No meu Linux esses arquivos estĂŁo na pasta /usr/lib/x86_64-linux-gnu/ e sugiro que consulte o conteĂșdo dos mesmos com a ferramenta objdump, como por exemplo:

$ objdump -d /usr/lib/x86_64-linux-gnu/Scrt1.o

Fazendo seu prĂłprio startup code

Apenas para fins de curiosidade e dar uma noção mais "palpĂĄvel" de como isso ocorre, irei ensinar aqui como vocĂȘ pode desabilitar a linkedição do CSU e programar uma versĂŁo personalizada do mesmo no Linux. NĂŁo recomendo que isso seja feito em um programa de verdade tendo em vista que vocĂȘ perderĂĄ diversos recursos que o C runtime padrĂŁo da glibc provĂ©m.

Use o seguinte cĂłdigo de teste:

STDOUT_FILENO = 1
SYS_WRITE = 1
SYS_EXIT_GROUP = 231

    .section .rodata
init_msg:
    .string "* Initializing...\n"
    MSG_LENGHT = . - init_msg

    .text
    .globl _start
_start:
    mov $STDOUT_FILENO, %rdi
    lea init_msg(%rip), %rsi
    mov $MSG_LENGHT, %rdx
    mov $SYS_WRITE, %rax
    syscall         # write(STDOUT_FILENO, init_msg, MSG_LENGTH)

    pop %rdi        # argc: RDI
    mov %rsp, %rsi  # argv: RSI
    call main

    mov %rax, %rdi
    mov $SYS_EXIT_GROUP, %rax
    syscall         # exit_group( main(argc, argv) )
#include <stdio.h>

int main(int argc, char **argv)
{
  printf("argc = %d\n", argc);

  for (int i = 0; i < argc; i++)
  {
    printf("argv[%d] = '%s'\n", i, argv[i]);
  }

  // Esperamos que argv[argc] seja um ponteiro NULL
  printf("argv[argc] = %s\n", argv[argc]);
  return 0;
}

Compile com:

$ as start.s -o crt1.o
$ gcc main.c -o main.o -c
$ gcc *.o -o test -nostartfiles

A opção -nostartfiles desabilita a linkedição dos arquivos objeto de inicialização.

O que o nosso start.s estå fazendo é simplesmente chamar a syscall write para escrever uma mensagem na tela, chama a função main passando argc e argv como argumentos e depois chama a syscall exit_group passando como argumento o retorno da função main.

No Linux, logo quando o programa Ă© iniciado no entry point, o valor contendo o nĂșmero de argumentos de linha de comando (argc) estĂĄ em (%rsp). E logo em seguida (RSP+8) estĂĄ o inĂ­cio da array de ponteiros para os argumentos de linha de comando, terminando com um ponteiro NULL.

Experimente rodar objdump -d test nesse executåvel "customizado" e depois compare compilando com o CSU comum. Verå que o programa comum contém diversas funçÔes que foram linkadas nele.

SeçÔes .init e .fini

As seçÔes .init e .fini contém funçÔes construída nos arquivos crti.o e crtn.o.

O propósito da função em .init é chamar todas as funçÔes na array de ponteiros localizada em outra seção chamada .init_array. Essas funçÔes são invocadas antes da chamada para a função main.

Jå a função em .fini invoca as funçÔes da array na seção .fini_array na finalização do programa (após main retornar ou na chamada de exit()).

No GCC vocĂȘ pode adicionar funçÔes para serem invocadas na inicialização do programa com o atributo constructor, e para a finalização do programa com o atributo destructor. Experimente ver o cĂłdigo Assembly do exemplo abaixo:

#include <stdio.h>

__attribute__((constructor))
void constructor1(void)
{
  puts("* constructor 1");
}

__attribute__((constructor))
void constructor2(void)
{
  puts("* constructor 2");
}

__attribute__((destructor))
void destructor1(void)
{
  puts("* destructor 1");
}

__attribute__((destructor))
void destructor2(void)
{
  puts("* destructor 2");
}

int main(void)
{
  puts("* main");
}
	.section	.init_array,"aw"
	.align 8
	.quad	constructor1

FunçÔes de saída

exit

Quando a função exit() é invocada (ou main retorna), funçÔes registradas pela função atexit() são executadas. Onde as funçÔes registradas devem seguir o protótipo:

void funcname(void);

As funçÔes registradas por atexit() são invocadas na ordem inversa a que foram registradas.

quick_exit

Quando a função quick_exit() é invocada o programa é finalizado sem invocar as funçÔes registradas por atexit() e sem executar quaisquer handlers de sinal.

As funçÔes registradas por at_quick_exit são invocadas na ordem inversa em que foram registradas.

Exemplo:

#include <stdio.h>
#include <stdlib.h>

void func_atexit(void)
{
  puts("* exiting...");
}

void func_at_quick_exit(void)
{
  puts("* Quick exiting...");
}

int main(void)
{
  atexit(func_atexit);
  at_quick_exit(func_at_quick_exit);

  puts("* main");
  // quick_exit(EXIT_SUCCESS);
  return 0;
}

Experimente executar o programa acima e depois recompilar com a chamada para quick_exit na linha 20.

A quantidade måxima de funçÔes que podem ser registradas com atexit ou at_quick_exit depende da implementação. Mas a especificação do C11 garante que no mínimo 32 funçÔes podem ser registradas por cada uma destas funçÔes.

_Exit

A função _Exit() finaliza a execução do programa sem executar quaisquer funçÔes registradas por atexit ou at_quick_exit. Também não executa nenhum handler de sinal.

Depurando com o GDB

Aprendendo a usar o depurador GDB do projeto GNU.

$ sudo apt install gdb

O GDB pode ser usado para depurar código tanto visualizando o Assembly como também o código-fonte. Para isso é necessårio compilar o binårio adicionando informaçÔes de depuração, com o GCC basta adicionar a opção -g3 ao compilar. Exemplo:

$ gcc -g3 test.c -o test

E pode rodar o GDB passando o caminho do binĂĄrio assim:

$ gdb ./test

O caminho do binårio é opcional. Caso especificado o GDB jå inicia com esse binårio como alvo para depuração, mas existem comandos do GDB que podem ser usados para escolher um alvo conforme serå explicado mais abaixo.

O GDB funciona com comandos, quando vocĂȘ o inicia ele te apresenta um prompt onde vocĂȘ pode ir inserindo comandos para executar determinadas açÔes. Mais abaixo irei apresentar os principais comandos e como utilizĂĄ-los.

Esse depurador suporta depurar código de diversas linguagens de programação (incluindo C++, Go e Rust), mas aqui serå demonstrado seu uso somente em um código escrito em C. O seguinte código serå usado para demonstração:

test.c
#include <stdio.h>

#define DEFINED_VALUE 12345

int add(int a, int b)
{
  return a + b;
}

int main(int argc, char **argv)
{
  char str[] = "a =";
  int x = 8;

  printf("%s %d\n", str, add(x, 3));
  return 0;
}

E serĂĄ compilado da seguinte forma:

$ gcc -g3 test.c -o test

A opção -g é usada para adicionar informaçÔes de depuração ao executåvel. Esse 3 seria o nível de informaçÔes que serão adicionadas, onde 3 é o maior nível.

ExpressÔes

Determinadas instruçÔes do GDB recebem uma expressĂŁo como argumento onde Ă© possĂ­vel usar qualquer tipo de constante, variĂĄvel ou operador da linguagem que estĂĄ sendo depurada (neste caso C). Isso inclui casts, strings literais, macros e atĂ© mesmo chamadas de funçÔes. Logo a expressĂŁo interpretada Ă© quase idĂȘntica a uma expressĂŁo que vocĂȘ escreveria na linguagem que estĂĄ sendo depurada (no nosso caso C).

Também é possível referenciar o valor de algum registrador na expressão usando o prefixo $, como $rax por exemplo. Na imagem abaixo é uma demonstração usando o comando print:

Comandos

O GDB aceita abreviaçÔes dos comandos, onde ele identifica o comando a ser executado de acordo com suas primeiras letras ou abreviaçÔes definidas pelo depurador. Por exemplo o comando breakpoint pode ser executado também como break, br ou apenas b.

Ao apertar enter sem digitar nenhum comando o GDB irĂĄ reexecutar o Ășltimo comando que vocĂȘ executou.

quit

quit [EXPR]

Finaliza o GDB. A expressĂŁo opcional Ă© avaliada e o resultado dela Ă© usado como cĂłdigo de saĂ­da. Se a expressĂŁo nĂŁo for passada o GDB sai com cĂłdigo 0.

file

file FILE

Usa o arquivo binårio especificado como alvo para depuração. O programa é procurado no diretório atual ou em qualquer caminho registrado na variåvel de ambiente PATH.

attach e detach

attach <process-id>
detach

VocĂȘ tambĂ©m pode iniciar a execução do GDB com a opção -p para ele jĂĄ inicializar fazendo attach em um processo, como em:

$ gdb -p 12345

breakpoint

break [PROBE_MODIFIER] [LOCATION] [thread THREADNUM] [if CONDITION]

Se o comando for executado sem qualquer argumento o breakpoint serå adicionado na instrução atual.

LOCATION Ă© a posição onde o breakpoint deve ser inserido e pode ser o nĂșmero de uma linha, endereço ou posição explĂ­cita.

Ao especificar o nĂșmero da linha, o nome do arquivo e o nĂșmero da linha sĂŁo separados por :. Se nĂŁo especificar o nome do arquivo o breakpoint serĂĄ adicionado a linha do arquivo atual. Exemplos:

(gdb) b 15
(gdb) b test.c:17

Onde o primeiro adicionaria o breakpoint na linha 15 do arquivo atual, e o segundo adicionaria na linha 17 do arquivo test.c.

O endereço pode ser simplesmente o nome de uma função ou então uma expressão, onde nesse caso é necessårio usar * como prefixo ao símbolo ou endereço de memória. Como em:

(gdb) b main
(gdb) b *main + 8
(gdb) b *0x12345

No primeiro caso um breakpoint seria adicionado a função main. No segundo caso o endereço da primeira instrução da função main seria somado com 8, e o endereço resultante seria onde o breakpoint seria inserido. Jå no terceiro caso o breakpoint seria inserido no endereço 0x12345.

Também é possível especificar para qual thread o breakpoint deve ser inserido, onde por padrão o breakpoint é vålido para todas as threads. Exemplo:

(gdb) b add thread 2

Isso adicionaria o breakpoint somente para a thread de ID 2.

É possĂ­vel usar o comando info threads para obter a lista de threads e seus nĂșmeros de identificação.

(gdb) b 7 if a == 8

Onde no contexto do nosso código de exemplo, a seria o primeiro parùmetro da função add.

clear

clear [LOCATION]

Remove um breakpoint no local especificado. LOCATION funciona da mesma forma que no comando breakpoint.

Caso LOCATION não seja especificado remove o breakpoint na posição atual.

run

run [arg1, arg2, arg3...]

O comando run inicia (ou reinicia) a execução do programa alvo. Opcionalmente pode-se passar argumentos de linha de comando para o programa. Caso os argumentos nĂŁo sejam especificados, os mesmos argumentos utilizados na Ășltima execução de run serĂŁo utilizados.

Nos argumentos é possível usar o caractere curinga *, ele serå expandido pela shell do sistema. Também é possível usar os redirecionadores <, > ou >>.

kill

Finaliza a execução do programa que estå sendo depurado.

start, starti

start [arg1, arg2, arg3...]
starti [arg1, arg2, arg3...]

O uso desses dois comandos Ă© idĂȘntico ao uso de run. PorĂ©m o comando start inicia a execução do programa parando no começo da função main. JĂĄ o starti inicia parando na primeira instrução do programa.

next, nexti

next [N]
nexti [N]

O comando next (ou apenas n) executa uma linha de código. Se N for especificado ele executa N linhas de código. Jå o comando nexti (ou apenas ni) executa uma ou N instruçÔes Assembly.

step, stepi

step [N]
stepi [N]

O step (ou s) executa uma ou N linhas de código. Jå o stepi (ou si) executa uma ou N instruçÔes Assembly. Os dois comandos entram em chamadas de procedimentos.

jump

jump LOCATION

Salta (modifica RIP) para o ponto do cĂłdigo especificado. Onde LOCATION Ă© idĂȘntico ao caso do comando breakpoint onde Ă© possĂ­vel especificar um nĂșmero de linha ou endereço.

advance

advance LOCATION

Esse comando continua a execução do programa até o ponto do código especificado, daí para a execução lå. Assim como na instrução jump, o comando advance (ou adv) recebe um LOCATION como argumento.

O comando advance também para quando a função atual retorna.

finish

continue

Continua a execução normal do programa.

record e reverse-*

Quando o programa jĂĄ estĂĄ em execução vocĂȘ pode executar o comando record full para iniciar a gravação das instruçÔes executadas e record stop para parar de gravar.

Quando hå a gravação é possível executar o programa em ordem reversa usando os comandos: reverse-step (rs), reverse-stepi (rsi), reverse-next (rn), reverse-nexti (rni) e reverse-continue (rc).

Esses comandos fazem a mesma coisa que os comandos normais, porém executando o programa ao reverso. Cada instrução revertida tem suas modificaçÔes na memória ou registradores desfeitas. Conforme demonstra a imagem abaixo.

Outros subcomandos de record sĂŁo:

record goto

Salta para uma determinada instrução que foi gravada. Pode-se usar record goto begin para voltar ao inĂ­cio da gravação (desfazendo todas as instruçÔes), record goto end para ir para o final da gravação ou record goto N onde N seria o nĂșmero da instrução na gravação para saltar para ela.

record save <filename>

Salva os logs de execução no arquivo.

record restore <filename>

Restaura os logs de execução a partir do arquivo.

thread

thread <thread-id>
thread apply <thread-id> <command>
thread find <regex>
thread name <thread-name>

O comando thread pode ser usado para trocar entre threads do processo. VocĂȘ pode usar o comando info threads para listar as threads do processo e obter seus ID. Exemplo:

(gdb) thread 2

Isso trocaria para a thread de ID 2. Esse comando também tem os seguintes subcomandos:

thread apply

Executa um comando na thread especificada.

thread name

Define um nome para a thread atual, facilitando a identificação dela.

thread find

Recebe uma expressĂŁo regular como argumento que Ă© usada para listar as threads cujo o nome coincida com a expressĂŁo regular. O comando exibe o ID das threads listadas.

print

print[/FMT] [EXPR]
(gdb) p/x 15
$1 = 0xf

Repare que a cada execução do comando print ele define uma variĂĄvel ($1, $2 etc.) que armazena o resultado da expressĂŁo do comando. VocĂȘ tambĂ©m pode usar o valor dessas variĂĄveis em uma expressĂŁo e assim reaproveitar o resultado de uma execução anterior do comando. Os sĂ­mbolos $ e $$ se referem aos valores da Ășltima e penĂșltima execução do comando, respectivamente. Exemplo:

(gdb) p x + $3

Existe também o operador binårio @ que pode ser usado para tratar o valor no endereço especificado como uma array. O formato do uso desse operador é array@size, passando à esquerda o primeiro elemento da array.

Onde o tipo de cada elemento da array Ă© definido de acordo com o tipo do objeto que estĂĄ sendo referenciado. Na imagem abaixo Ă© demonstrado o uso desse operador para visualizar todo o conteĂșdo da array argv.

printf

printf "format string", ARG1, ARG2, ARG3, ..., ARG

Exemplo de uso:

(gdb) printf "%p\n", $rsp
0x7fffffffdf20

dprintf

dprintf LOCATION, "format string", ARG1, ARG2, ARG3, ..., ARG

Esse comando insere um breakpoint no código onde, toda vez que ele é alcançado, o comando printf é executado e depois a execução continua. O uso desse comando é semelhante ao do comando printf. Exemplo:

(gdb) dprintf 7, "%d + %d\n", a, b

No nosso código de exemplo, isso inseria o dynamic printf na linha 7 que estå dentro da função add. Conforme a imagem abaixo demonstra:

x

x[/FMT] ADDRESS

O comando x serve para ver valores na memĂłria. O argumento FMT (opcional) Ă© o nĂșmero de valores a serem exibidos, seguido de uma letra indicando o formato do valor seguido de uma letra que indica o tamanho do valor. Por padrĂŁo exibe apenas um valor caso o nĂșmero nĂŁo seja especificado. O formato e tamanho padrĂŁo Ă© o mesmo utilizado na Ășltima execução do comando x.

As letras de formato são: o (octal), x (hexadecimal), d (decimal), u (decimal não-sinalizado), t (binårio), f (float), a (endereço), i (instrução), c (caractere de 1 byte), s (string) e z (hexadecimal com zeros à esquerda).

Ao usar o formato i serĂĄ feito o disassembly do cĂłdigo no endereço. O nĂșmero de valores Ă© usado para especificar o nĂșmero de instruçÔes para fazer o disassembly.

Exemplo:

(gdb) x/x 0x7fffffffdf64
0x7fffffffdf64:	0x003d2061

As letras de tamanho sĂŁo: b (byte), h (metade de uma palavra), w (palavra) e g (giant, 8 bytes). Na arquitetura x86-64 uma palavra Ă© 32-bit (4 bytes).

Exemplos:

(gdb) x/xb 0x7fffffffdf64
0x7fffffffdf64:	0x61
(gdb) x/4xb 0x7fffffffdf64
0x7fffffffdf64:	0x61	0x20	0x3d	0x00

disassembly

disassembly[/MODIFIER] [ADDRESS]
disassembly[/MODIFIER] start,end
disassembly[/MODIFIER] start,+length

O comando disassembly (ou disas) pode ser usado para exibir o disassembly de uma função ou range de endereço. O argumento ADDRESS (opcional) é uma expressão, sem esse argumento ele faz o disassembly na posição ou função atual.

Também é possível especificar um range de endereços para exibir o dissasembly das instruçÔes, separando o endereço inicial e final por vírgula. Se usar o + no segundo argumento separado por vírgula, ele é considerado como o tamanho em bytes do range iniciado em start.

Exemplos:

(gdb) disas 0x00005555555551b4,0x00005555555551b9
Dump of assembler code from 0x5555555551b4 to 0x5555555551b9:
   0x00005555555551b4 <main+51>:	mov    $0x3,%esi
End of assembler dump.
(gdb) disas 0x00005555555551b4,+5
Dump of assembler code from 0x5555555551b4 to 0x5555555551b9:
   0x00005555555551b4 <main+51>:	mov    $0x3,%esi
End of assembler dump.

O argumento MODIFIER Ă© uma (ou mais) das seguintes letras:

  • s - Exibe tambĂ©m as linhas de cĂłdigo correspondentes as instruçÔes em Assembly.

  • r - TambĂ©m exibe o cĂłdigo de mĂĄquina em hexadecimal.

Exemplo:

(gdb) disas/rs 0x00005555555551b4,+5
Dump of assembler code from 0x5555555551b4 to 0x5555555551b9:
test.c:
15	  printf("%s %d\n", str, add(x, 3));
   0x00005555555551b4 <main+51>:	be 03 00 00 00	mov    $0x3,%esi
End of assembler dump.

Por padrĂŁo o disassembly Ă© feito em sintaxe AT&T, mas vocĂȘ pode modificar para sintaxe Intel com o comando: set disassembly-flavor intel

list

list
list LINENUM
list FILE:LINENUM
list FUNCTION
list FILE:FUNCTION
list *ADDRESS

Exibe a listagem de código na linha ou início da função especificada. Um endereço também pode ser especificado usando um * como prefixo, as linhas de código correspondentes ao endereço serão exibidas.

Caso list seja executado sem argumentos mais linhas sĂŁo exibidas a partir da Ășltima linha exibida pela Ășltima execução de list.

O nĂșmero de linhas exibido Ă© por padrĂŁo 10, mas esse valor pode ser alterado com o comando set listsize <number-of-lines>.

backtrace

backtrace [COUNT]

O comando backtrace (ou bt) exibe o stack backtrace atual. O argumento COUNT Ă© o nĂșmero mĂĄximo de stack frames que serĂŁo exibidos. Se for um nĂșmero negativo exibe os primeiros stack frames.

Exemplo:

(gdb) bt
#0  add (a=8, b=3) at test.c:7
#1  0x00005555555551c0 in main (argc=1, argv=0x7fffffffe068) at test.c:15

frame

frame [FRAME_NUMBER]

Sem argumentos exibe o stack frame selecionado. Caso seja especificado um nĂșmero como argumento, seleciona e exibe o stack frame indicado pelo nĂșmero. Esse nĂșmero pode ser consultado com o comando backtrace.

Esse comando tem os seguintes subcomandos:

frame address

frame address STACK_ADDRESS

Exibe o stack frame no endereço especificado.

frame apply

frame apply COUNT COMMAND
frame apply all COMMAND
frame apply level FRAME_NUMBER COMMAND

O comando frame apply executa o mesmo comando em um ou mais stack frames. Esse subcomando Ă© Ăștil, por exemplo, para ver o valor das variĂĄveis locais que estĂŁo em uma função de outro stack frame alĂ©m do atual.

COUNT Ă© o nĂșmero de frames onde o comando serĂĄ executado. Por exemplo frame apply 2 p x executaria o comando print nos Ășltimos 2 frames (o atual e o anterior).

O frame apply all executa o comando em todos os frames. JĂĄ o frame apply level executa o comando em um frame especĂ­fico. exemplo:

(gdb) frame apply level 1 info locals
#1  0x00005555555551c0 in main (argc=1, argv=0x7fffffffe068) at test.c:15
str = "a ="
x = 8

frame function

frame function FUNCTION_NAME

Exibe o stack frame da função especificada.

frame level

frame level FRAME_NUMBER

Exibe o stack frame do nĂșmero especificado.

info

O comando info contém diversos subcomandos para exibir informaçÔes sobre o programa que estå sendo depurado. Abaixo serå listado apenas os subcomandos principais.

info registers

(gdb) info reg rax rbx

info frame

info args

info args [NAMEREGEXP]

Exibe os argumentos passados para a função do stack frame atual. Se NAMEREGEXP for especificado exibe apenas os argumentos cujo o nome coincida com a expressão regular.

info locals

info locals [NAMEREGEXP]

Uso idĂȘntico ao de info args sĂł que exibe o valor das variĂĄveis locais.

info functions

info functions [NAMEREGEXP]

Exibe todas as funçÔes cujo o nome coincida com a expressão regular. Se o argumento não for especificado lista todas as funçÔes.

info breakpoints

Exibe os breakpoints definidos no programa.

info source

Exibe informaçÔes sobre o código-fonte atual.

info threads

Lista as threads do processo.

display e undisplay

display[/FMT] EXPRESSION
undisplay [NUM]
(gdb) display/7i $rip

Isso exibiria o disassembly de 7 instruçÔes a partir de RIP a cada passo executado.

Se display for executado sem argumentos ele exibe todas as expressÔes registradas para auto-display.

Enquanto o comando undisplay remove a expressĂŁo com o nĂșmero especificado. Sem argumentos remove todas as expressĂ”es registradas por display.

source

source FILE

Carrega o arquivo especificado e executa os comandos no arquivo como um script.

Quando o GDB inicia ele faz o source automĂĄtico do script de nome .gdbinit presente na sua pasta home. Exceto se o GDB for iniciado com a flag --nh.

help

O comando help, sem argumentos, lista as classes de comandos. É possível rodar help CLASS para obter a lista de comandos daquela classe.

Também é possível rodar help COMMAND para obter ajuda para um comando específico, pode-se inclusive usar abreviaçÔes. E também é possível obter ajuda para subcomandos, conforme exemplos:

(gdb) help ni
(gdb) help info reg
(gdb) help frame apply level

Text User Interface (TUI)

É possível usar o GDB com uma interface textual permitindo que seja mais agradável acompanhar a execução enquanto observa o código-fonte. Para isso basta iniciar o GDB com a flag -tui, como em:

$ gdb -tui ./test

Atalhos de teclado

Atalho de teclado

Descrição

Ctrl+x a

O atalho Ctrl+x a (Ctrl+x seguido da tecla a) alterna para o modo TUI caso tenha iniciado o GDB normalmente.

Ctrl+x 1

Alterna para o layout de janela Ășnica.

Ctrl+x 2

Alterna para o layout de janela dupla. Quando jĂĄ estĂĄ no layout de janela dupla o prĂłximo layout com duas janelas Ă© selecionado. Onde Ă© possĂ­vel exibir cĂłdigo-fonte+Assembly, registradores+Assembly e registradores+cĂłdigo-fonte.

Ctrl+x o

Muda a janela ativa.

Ctrl+x s

PgUp

Rola a janela ativa uma pĂĄgina para cima.

PgDn

Rola a janela ativa uma pĂĄgina para baixo.

↑ (Up)

Rola a janela ativa uma linha para cima.

↓ (Down)

Rola a janela ativa uma linha para baixo.

← (Left)

Rola a janela ativa uma coluna para a esquerda.

→ (Right)

Rola a janela ativa uma coluna para a direita.

Ctrl+L

Redesenha a tela.

Single Key Mode

Quando se estĂĄ no modo Single Key Ă© possĂ­vel executar alguns comandos pressionando uma Ășnica tecla, conforme tabela abaixo:

Tecla

Comando

Nota

c

continue

d

down

f

finish

n

next

o

nexti

"o" de step over.

q

-

Sai do modo Single Key.

r

run

s

step

i

stepi

u

up

v

info locals

"v" de variables.

w

where

Alias para o comando backtrace.

Qualquer outra tecla alterna temporariamente para o modo de comandos. ApĂłs um comando ser executado ele retorna para o modo Single Key.

InstruçÔes intrínsecas

Aprendendo sobre as instruçÔes intrínsecas na arquitetura x86-64

As instruçÔes intrínsecas é um recurso originalmente fornecido pelo compilador Intel C/C++ mas que também é implementado pelo GCC. Se tratam basicamente de tipos especiais e funçÔes que são expandidas inline para alguma instrução do processador, ou seja, é basicamente uma alternativa mais pråtica e legível do que usar inline Assembly para tudo.

Usando instruçÔes intrínsecas é possível obter o mesmo resultado de usar inline Assembly com a diferença de ter a sintaxe amigåvel de uma chamada de função.

Para usar instruçÔes intrínsecas é necessårio incluir o header <immintrin.h> onde ele declara as funçÔes e os tipos.

Tipos de dados

Os tipos de dados na tabela abaixo servem para indicar como os valores usados na instrução intrínseca serão armazenados.

Tipo

Descrição

__m64

Tipo usado para representar o conteĂșdo de um registrador MMX. Pode armazenar 8 valores 8-bit, 4 valores de 16-bit, 2 valores de 32-bit ou 1 valor de 64-bit.

__m128

__m128d

Também um registrador SSE porém armazenando 2 floating-point de 64-bit.

__m128i

Registrador SSE que pode armazenar 16 valores inteiros de 8-bit, 8 valores inteiros de 16-bit, 4 valores inteiros de 32-bit ou 2 valores inteiros de 64-bit.

__m256

Representa o conteĂșdo de um registrador YMM usado pela tecnologia AVX. Pode armazenar 8 valores floating-point de 32-bit.

__m256d

Registrador YMM que pode armazenar 4 floating-point de 64-bit.

__m256i

Registrador YMM que pode armazenar 32 valores inteiros de 8-bit, 16 valores inteiros de 16-bit, 8 valores inteiros de 32-bit ou 4 valores inteiros de 64-bit.

__m512

Representa o conteĂșdo de um registrador ZMM usado pela tecnologia AVX-512. Pode armazenar 16 valores floating-point de 32-bit.

__m512d

Registrador ZMM que pode armazenar 8 valores floating-point de 64-bit.

__m512i

Registrador ZMM que pode armazenar 64 valores inteiros de 8-bit, 32 inteiros de 16-bit, 16 inteiros de 32-bit ou 8 inteiros de 64-bit.

Nomenclatura

A maioria das instruçÔes intrínsecas (SIMD) seguem a seguinte convenção de notação:

_mm_<operação>_<sufixo>

Onde <operação> é a operação que serå executada com os dados. O <sufixo> indica o tipo de dado na operação. A primeira ou as duas primeiras letras do sufixo indicam se o dado é packed (p), extended packed (ep) ou escalar (s). Os demais caracteres do sufixo indicam o tipo de dado, como mostra a tabela abaixo:

Sufixo

Tipo

s

single-precision floating-point (float de 32-bit)

d

double-precision floating-point (double de 64-bit)

i128

Inteiro sinalizado de 128-bit.

i64

Inteiro sinalizado de 64-bit.

u64

Inteiro nĂŁo-sinalizado de 64-bit.

i32

Inteiro sinalizado de 32-bit.

u32

Inteiro nĂŁo-sinalizado de 32-bit.

i16

Inteiro sinalizado de 16-bit.

u16

Inteiro nĂŁo-sinalizado de 16-bit.

i8

Inteiro sinalizado de 8-bit.

u8

Inteiro nĂŁo-sinalizado de 8-bit.

Exemplo:

double array[] = { 1.0, 2.0 };
__m128d data = _mm_load_pd(array);

InstruçÔes

Abaixo irei listar apenas algumas instruçÔes intrĂ­nsecas, em sua maioria relacionadas Ă  tecnologia SSE. Para ver a lista completa sugiro que consulte a referĂȘncia oficial da Intel no link abaixo:

Algumas instruçÔes intrĂ­nsecas nĂŁo sĂŁo compiladas para uma sĂł instrução mas sim uma sequĂȘncia de vĂĄrias delas.

OperaçÔes load, store e extract

Tecnologia

ProtĂłtipo

Instrução

SSE2

__m128d _mm_load_pd (double const* mem_addr)

movapd xmm, m128

SSE

__m128 _mm_load_ps (float const* mem_addr)

movaps xmm, m128

SSE2

__m128d _mm_load_sd (double const* mem_addr)

movsd xmm, m64

SSE2

__m128i _mm_load_si128 (__m128i const* mem_addr)

movdqa xmm, m128

SSE

__m128 _mm_load_ss (float const* mem_addr)

movss xmm, m32

SSE

__m128i _mm_loadu_si16 (void const* mem_addr)

SequĂȘncia

SSE2

__m128i _mm_loadu_si32 (void const* mem_addr)

movd xmm, m32

SSE

__m128i _mm_loadu_si64 (void const* mem_addr)

movq xmm, m64

SSE2

__m128i _mm_loadu_si128 (__m128i const* mem_addr)

movdqu xmm, m128

SSE2

void _mm_store_pd (double* mem_addr, __m128d a)

movapd m128, xmm

SSE

void _mm_store_ps (float* mem_addr, __m128 a)

movaps m128, xmm

SSE2

void _mm_store_sd (double* mem_addr, __m128d a)

movsd m64, xmm

SSE2

void _mm_store_si128 (__m128i* mem_addr, __m128i a)

movdqa m128, xmm

SSE

void _mm_store_ss (float* mem_addr, __m128 a)

movss m32, xmm

SSE2

int _mm_extract_epi16 (__m128i a, int imm8)

pextrw r32, xmm, imm8

SSE4.1

int _mm_extract_epi32 (__m128i a, const int imm8)

pextrd r32, xmm, imm8

SSE4.1

long long int _mm_extract_epi64 (__m128i a, const int imm8)

pextrq r64, xmm, imm8

SSE4.1

int _mm_extract_epi8 (__m128i a, const int imm8)

pextrb r32, xmm, imm8

SSE

int _mm_extract_pi16 (__m64 a, int imm8)

pextrw r32, mm, imm8

SSE4.1

int _mm_extract_ps (__m128 a, const int imm8)

extractps r32, xmm, imm8

SSE

int _m_pextrw (__m64 a, int imm8)

pextrw r32, mm, imm8

As operaçÔes load carregam um valor da memĂłria para um registrador, o conteĂșdo que deve estar na memĂłria apontada pelo argumento tem que estar de acordo com o tipo da instrução identificado pelo sufixo.

OperaçÔes store leem um ou mais dados do registrador e escrevem os mesmos no endereço passado como primeiro argumento.

Jå a operação extract obtém um valor de uma parte do registrador identificado pelo valor imediato passado como segundo argumento. Esse valor é o índice do campo do registrador contando da direita para a esquerda começando em zero.

Exemplos:

#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  float number;
  float array[] = {1.0f, 2.0f, 3.0f, 4.0f};
  __m128 data = _mm_load_ps(array);

  _mm_store_ss(&number, data);
  printf("%f\n", number);
  return 0;
}
#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  int array[] = {111, 222, 333, 444};
  __m128i data = _mm_load_si128((__m128i *)array);

  int number = _mm_extract_epi32(data, 2);
  printf("%d\n", number);
  return 0;
}

OperaçÔes set

As instruçÔes intrĂ­nsecas de set definem o valor de todos os campos do registrador ao mesmo tempo sem a necessidade de usar uma array para isso. Elas nĂŁo sĂŁo traduzidas para uma mas sim vĂĄrias instruçÔes em sequĂȘncia, portanto pode haver uma penalidade de desempenho.

Tecnologia

ProtĂłtipo

SSE2

__m128i _mm_set_epi16 (short e7, short e6, short e5, short e4, short e3, short e2, short e1, short e0)

SSE2

__m128i _mm_set_epi32 (int e3, int e2, int e1, int e0)

SSE2

__m128i _mm_set_epi64 (__m64 e1, __m64 e0)

SSE2

__m128i _mm_set_epi64x (long long int e1, long long int e0)

SSE2

__m128i _mm_set_epi8 (char e15, char e14, char e13, char e12, char e11, char e10, char e9, char e8, char e7, char e6, char e5, char e4, char e3, char e2, char e1, char e0)

SSE2

__m128d _mm_set_pd (double e1, double e0)

SSE

__m128 _mm_set_ps (float e3, float e2, float e1, float e0)

SSE2

__m128d _mm_set_sd (double a)

SSE

__m128 _mm_set_ss (float a)

As operaçÔes set escalares (_mm_set_sd e_mm_set_ss) definem o valor da parte menos significativa do registrador e zeram os demais valores.

As duas operaçÔes abaixo definem todos os campos do registrador para o mesmo valor passado como argumento:

Tecnologia

ProtĂłtipo

SSE2

__m128d _mm_set_pd1 (double a)

SSE

__m128 _mm_set_ps1 (float a)

Exemplo:

#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  __m128i data = _mm_set_epi32(444, 333, 222, 111);

  int number = _mm_extract_epi32(data, 2);
  printf("%d\n", number);
  return 0;
}

OperaçÔes matemåticas

Tecnologia

ProtĂłtipo

Instrução

SSE2

__m128i _mm_add_epi16 (__m128i a, __m128i b)

paddw xmm, xmm

SSE2

__m128i _mm_add_epi32 (__m128i a, __m128i b)

paddd xmm, xmm

SSE2

__m128i _mm_add_epi64 (__m128i a, __m128i b)

paddq xmm, xmm

SSE2

__m128i _mm_add_epi8 (__m128i a, __m128i b)

paddb xmm, xmm

SSE2

__m128d _mm_add_pd (__m128d a, __m128d b)

addpd xmm, xmm

SSE

__m128 _mm_add_ps (__m128 a, __m128 b)

addps xmm, xmm

SSE2

__m128d _mm_add_sd (__m128d a, __m128d b)

addsd xmm, xmm

SSE2

__m64 _mm_add_si64 (__m64 a, __m64 b)

paddq mm, mm

SSE

__m128 _mm_add_ss (__m128 a, __m128 b)

addss xmm, xmm

SSE2

__m128d _mm_div_pd (__m128d a, __m128d b)

divpd xmm, xmm

SSE

__m128 _mm_div_ps (__m128 a, __m128 b)

divps xmm, xmm

SSE2

__m128d _mm_div_sd (__m128d a, __m128d b)

divsd xmm, xmm

SSE

__m128 _mm_div_ss (__m128 a, __m128 b)

divss xmm, xmm

SSE2

__m128d _mm_mul_pd (__m128d a, __m128d b)

mulpd xmm, xmm

SSE

__m128 _mm_mul_ps (__m128 a, __m128 b)

mulps xmm, xmm

SSE2

__m128d _mm_mul_sd (__m128d a, __m128d b)

mulsd xmm, xmm

SSE

__m128 _mm_mul_ss (__m128 a, __m128 b)

mulss xmm, xmm

SSE2

__m64 _mm_mul_su32 (__m64 a, __m64 b)

pmuludq mm, mm

SSE2

__m128d _mm_sqrt_pd (__m128d a)

sqrtpd xmm, xmm

SSE

__m128 _mm_sqrt_ps (__m128 a)

sqrtps xmm, xmm

SSE2

__m128d _mm_sqrt_sd (__m128d a, __m128d b)

sqrtsd xmm, xmm

SSE

__m128 _mm_sqrt_ss (__m128 a)

sqrtss xmm, xmm

SSE2

__m128i _mm_sub_epi16 (__m128i a, __m128i b)

psubw xmm, xmm

SSE2

__m128i _mm_sub_epi32 (__m128i a, __m128i b)

psubd xmm, xmm

SSE2

__m128i _mm_sub_epi64 (__m128i a, __m128i b)

psubq xmm, xmm

SSE2

__m128i _mm_sub_epi8 (__m128i a, __m128i b)

psubb xmm, xmm

SSE2

__m128d _mm_sub_pd (__m128d a, __m128d b)

subpd xmm, xmm

SSE

__m128 _mm_sub_ps (__m128 a, __m128 b)

subps xmm, xmm

SSE2

__m128d _mm_sub_sd (__m128d a, __m128d b)

subsd xmm, xmm

SSE2

__m64 _mm_sub_si64 (__m64 a, __m64 b)

psubq mm, mm

SSE

__m128 _mm_sub_ss (__m128 a, __m128 b)

subss xmm, xmm

SSSE3

__m128i _mm_abs_epi16 (__m128i a)

pabsw xmm, xmm

SSSE3

__m128i _mm_abs_epi32 (__m128i a)

pabsd xmm, xmm

SSSE3

__m128i _mm_abs_epi8 (__m128i a)

pabsb xmm, xmm

SSSE3

__m64 _mm_abs_pi16 (__m64 a)

pabsw mm, mm

SSSE3

__m64 _mm_abs_pi32 (__m64 a)

pabsd mm, mm

SSSE3

__m64 _mm_abs_pi8 (__m64 a)

pabsb mm, mm

SSE4.1

__m128d _mm_ceil_pd (__m128d a)

roundpd xmm, xmm, imm8

SSE4.1

__m128 _mm_ceil_ps (__m128 a)

roundps xmm, xmm, imm8

SSE4.1

__m128d _mm_ceil_sd (__m128d a, __m128d b)

roundsd xmm, xmm, imm8

SSE4.1

__m128 _mm_ceil_ss (__m128 a, __m128 b)

roundss xmm, xmm, imm8

SSE4.1

__m128d _mm_floor_pd (__m128d a)

roundpd xmm, xmm, imm8

SSE4.1

__m128 _mm_floor_ps (__m128 a)

roundps xmm, xmm, imm8

SSE4.1

__m128d _mm_floor_sd (__m128d a, __m128d b)

roundsd xmm, xmm, imm8

SSE4.1

__m128 _mm_floor_ss (__m128 a, __m128 b)

roundss xmm, xmm, imm8

SSE2

__m128i _mm_max_epi16 (__m128i a, __m128i b)

pmaxsw xmm, xmm

SSE4.1

__m128i _mm_max_epi32 (__m128i a, __m128i b)

pmaxsd xmm, xmm

SSE4.1

__m128i _mm_max_epi8 (__m128i a, __m128i b)

pmaxsb xmm, xmm

SSE4.1

__m128i _mm_max_epu16 (__m128i a, __m128i b)

pmaxuw xmm, xmm

SSE4.1

__m128i _mm_max_epu32 (__m128i a, __m128i b)

pmaxud xmm, xmm

SSE2

__m128i _mm_max_epu8 (__m128i a, __m128i b)

pmaxub xmm, xmm

SSE2

__m128d _mm_max_pd (__m128d a, __m128d b)

maxpd xmm, xmm

SSE

__m64 _mm_max_pi16 (__m64 a, __m64 b)

pmaxsw mm, mm

SSE

__m128 _mm_max_ps (__m128 a, __m128 b)

maxps xmm, xmm

SSE

__m64 _mm_max_pu8 (__m64 a, __m64 b)

pmaxub mm, mm

SSE2

__m128d _mm_max_sd (__m128d a, __m128d b)

maxsd xmm, xmm

SSE

__m128 _mm_max_ss (__m128 a, __m128 b)

maxss xmm, xmm

SSE2

__m128i _mm_min_epi16 (__m128i a, __m128i b)

pminsw xmm, xmm

SSE4.1

__m128i _mm_min_epi32 (__m128i a, __m128i b)

pminsd xmm, xmm

SSE4.1

__m128i _mm_min_epi8 (__m128i a, __m128i b)

pminsb xmm, xmm

SSE4.1

__m128i _mm_min_epu16 (__m128i a, __m128i b)

pminuw xmm, xmm

SSE4.1

__m128i _mm_min_epu32 (__m128i a, __m128i b)

pminud xmm, xmm

SSE2

__m128i _mm_min_epu8 (__m128i a, __m128i b)

pminub xmm, xmm

SSE2

__m128d _mm_min_pd (__m128d a, __m128d b)

minpd xmm, xmm

SSE

__m64 _mm_min_pi16 (__m64 a, __m64 b)

pminsw mm, mm

SSE

__m128 _mm_min_ps (__m128 a, __m128 b)

minps xmm, xmm

SSE

__m64 _mm_min_pu8 (__m64 a, __m64 b)

pminub mm, mm

SSE2

__m128d _mm_min_sd (__m128d a, __m128d b)

minsd xmm, xmm

SSE

__m128 _mm_min_ss (__m128 a, __m128 b)

minss xmm, xmm

SSE2

__m128i _mm_avg_epu16 (__m128i a, __m128i b)

pavgw xmm, xmm

SSE2

__m128i _mm_avg_epu8 (__m128i a, __m128i b)

pavgb xmm, xmm

SSE

__m64 _mm_avg_pu16 (__m64 a, __m64 b)

pavgw mm, mm

SSE

__m64 _mm_avg_pu8 (__m64 a, __m64 b)

pavgb mm, mm

Exemplos:

#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  int array[4];
  __m128i data1 = _mm_set_epi32(444, 333, 222, 111);
  __m128i data2 = _mm_set_epi32(111, 222, 333, 444);

  __m128i result = _mm_add_epi32(data1, data2);
  _mm_store_si128((__m128i *)array, result);
  printf("%d, %d, %d, %d\n", array[0], array[1], array[2], array[3]);
  return 0;
}
#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  double array[2];
  __m128d data = _mm_set_pd(81.0, 625.0);

  __m128d result = _mm_sqrt_pd(data);
  _mm_store_pd(array, result);
  printf("%f, %f\n", array[0], array[1]);
  return 0;
}

OperaçÔes de randomização

As instruçÔes intrínsecas abaixo leem um valor aleatório gerado por hardware:

Tecnologia

ProtĂłtipo

Instrução

RDRAND

int _rdrand16_step (unsigned short* val)

rdrand r16

RDRAND

int _rdrand32_step (unsigned int* val)

rdrand r32

RDRAND

int _rdrand64_step (unsigned __int64* val)

rdrand r64

A instrução rdrand escreve o valor aleatório obtido no ponteiro passado como argumento. Ela deve ser usada em um loop pois não hå garantia de que ela irå obter de fato um valor. Se obter o valor a função retorna 1, caso contrårio retorna 0.

Exemplo:

#include <stdio.h>
#include <immintrin.h>

int main(void)
{
  int value;

  while (!_rdrand32_step(&value))
    ;

  printf("Valor: %d\n", value);
  return 0;
}

VocĂȘ deve compilar passando a flag -mrdrnd para o GCC para indicar que o processador suporta a tecnologia. Caso contrĂĄrio vocĂȘ obterĂĄ um erro como este:

error: inlining failed in call to always_inline ‘_rdrand32_step’: target specific option mismatch

As instruçÔes intrínsecas abaixo são utilizadas da mesma maneira que rdrand porém o valor aleatório não é gerado por hardware.

Tecnologia

ProtĂłtipo

Instrução

RDSEED

int _rdseed16_step (unsigned short * val)

rdseed r16

RDSEED

int _rdseed32_step (unsigned int * val)

rdseed r32

RDSEED

int _rdseed64_step (unsigned __int64 * val)

rdseed r64

É necessĂĄrio compilar com a flag -mrdseed para poder usar essas instruçÔes intrĂ­nsecas.

Por exemplo irão sempre usar algum registrador ST0~ST7, então o código em uma instrução da FPU serå usado para identificar algum deles.

Jå instruçÔes que usam , qual especificamente serå usado depende do tamanho do operando na instrução (veja ).

Como jĂĄ foi explicado no tĂłpico que fala sobre o , esse prefixo estende os campos usados em ModR/M, SIB e o campo REGdo opcode em 1 bit. DaĂ­ assim o cĂłdigo usado para identificar o registrador, em modo de 64-bit, tem 4 bits de tamanho.

O ambiente hosted essencialmente Ă© o ambiente de execução de um cĂłdigo em C que executa sobre um sistema operacional. Nesse ambiente Ă© esperado que haja suporte para mĂșltiplas threads e todos os recursos descritos na especificação da biblioteca padrĂŁo (libc). A inicialização do programa ocorre quando a função main Ă© chamada e antes de inicializar o programa Ă© esperado que todos os objetos com static estejam inicializados.

Os detalhes de implementação descritos aqui são baseados no código-fonte da glibc e podem ser diferentes em outras implementaçÔes da libc. Consulte para ver a lista de completa de arquivos fonte consultados.

Ao do programa acima irå notar que os endereços das funçÔes são despejados nas seçÔes .init_array e .fini_array, como em:

O GDB Ă© um depurador de linha de comando que faz parte do projeto GNU. O jĂĄ instala o GDB junto com o GCC, e no Linux ele pode ser instalado pelo pacote gdb:

Para mais informaçÔes consulte a .

O comando attach faz o no processo de ID especificado. JĂĄ o comando detach desfaz o attach no processo que estĂĄ atualmente conectado.

E por fim då para adicionar uma condição de parada ao breakpoint. Onde CONDITION é booleana. Exemplo:

Os dois comandos atuam como um , ou seja, nĂŁo entram em chamadas de procedimentos.

Executa até o retorno da função atual. Quando a função retorna é criada uma variåvel (como no caso do comando ) com o valor de retorno da função.

Imagine que mågico seria se o depurador pudesse voltar no tempo e desfazer as instruçÔes executadas no programa, fazendo ele executar de maneira reversa parecido com rebobinar uma fita. Bom, o GDB pode fazer isso.

O comando print (ou p) exibe no terminal o resultado da expressĂŁo passada como argumento. Opcionalmente pode-se especificar o formato de saĂ­da, onde os formatos sĂŁo os mesmos utilizados no . Exemplo:

Esse comando pode ser usado de maneira semelhante a função printf da libc. Cada argumento é separado por vírgula e o primeiro argumento é a format string que suporta quase todos os formatos suportados pela função printf. Os demais argumentos são .

Exibe os valores dos registradores. Pode-se passar como argumento uma lista (separada por espaço) dos registradores para exibir. Sem argumentos exibe o valor de todos os , e . Exemplo:

O uso desse subcomando é semelhante ao uso do comando e contém os mesmos subcomandos. A diferença é que ele exibe todas as informaçÔes relacionadas ao stack frame. Enquanto o comando frame apenas exibe informaçÔes de um ponto de vista de alto-nível.

Esse comando pode ser usado da mesma maneira que o comando . Ele registra uma expressão para ser exibida a cada vez que a execução do processo faz uma parada. Exemplo:

Muda para o modo .

Para entender apropriadamente as operaçÔes e tipos indicados aqui, sugiro que jå tenha lido o tópico sobre .

Representa o conteĂșdo de um . Pode armazenar 4 valores floating-point de 32-bit.

😎
instruçÔes da FPU
registradores de propĂłsito geral
Atributos e prefixos
prefixo REX
storage-class
Mingw-w64
documentação do GCC
attach
registradores de propĂłsito geral
registradores de segmento
EFLAGS
SSE
https://software.intel.com/sites/landingpage/IntrinsicsGuide/
ver o Assembly gerado
uma expressĂŁo
print
comando x
expressÔes
frame
print
step over
as referĂȘncias
Single Key Mode
registrador SSE

Displacement

Campo displacement na instrução do código de måquina.

O displacement (deslocamento) é um valor numérico de 1, 2 ou 4 bytes de tamanho que também faz parte da instrução assim como o valor imediato.

Em modo de 32-bit ou 64-bit, o displacement pode ser de 1 ou 4 bytes de tamanho. Em modo de 16-bit pode ser de 1 ou 2 bytes de tamanho.

Exemplo:

Onde o valor 0x11223344 na instrução mov eax, [ebx + 0x11223344] é o displacement da instrução.

Opcode

Entendendo o opcode da instrução.

Como jĂĄ foi dito antes existem opcodes cujo os 3 Ășltimos bits sĂŁo usados para identificar o registrador usado na instrução. Opcodes nesse estilo de codificação sĂŁo usados para instruçÔes que sĂł precisam usar um registrador. Por exemplo mov eax, 123 cujo o opcode Ă© B8.

BIT D

A função do bit D é indicar a direção para onde a operação estå sendo executada. Se do REG para o R/M ou vice-versa. Repare nas instruçÔes abaixo e seus respectivos opcodes:

mov eax, [ebx] ; 8B03
mov [ebx], eax ; 8903

Convertendo os opcodes 8B e 89 para binĂĄrio dĂĄ para notar um fato interessante:

8B -> 10001011
89 -> 10001001

A Ășnica diferença entre os opcodes Ă© que em um o bit D estĂĄ ligado e no outro nĂŁo. Quando o bit D estĂĄ ligado o campo REG Ă© usado como operando destino e o campo R/M usado como fonte. E quando ele estĂĄ desligado Ă© o inverso: o campo R/M Ă© o destino e o REG Ă© o fonte. Obviamente o mesmo tambĂ©m se aplica se o R/M tambĂ©m for um registrador.

Por exemplo a instrução xor eax, eax pode ser escrita em código de måquina como 31 C0 ou 33 C0. Como no campo REG e no campo R/M são os mesmos registradores não faz diferença qual é o fonte e qual é o destino, a operação executada serå a mesma. Usando um disassembler como o ndisasm då para notar isso:

felipe@silva-lenovo:~$ echo -ne "\x31\xC0\x33\xC0" > tst
felipe@silva-lenovo:~$ ndisasm -b32 tst
00000000  31C0              xor eax,eax
00000002  33C0              xor eax,eax

BIT S

O bit S Ă© usado para definir o tamanho do operando, onde:

  • 0 -> Indica que o operando Ă© de 8-bit

  • 1 -> Indica que o operando Ă© do tamanho do operand-size.

Repare por exemplo a instrução 30 C0:

felipe@silva-lenovo:~$ echo -ne "\x30\xC0" > tst
felipe@silva-lenovo:~$ ndisasm -b32 tst
00000000  30C0              xor al,al

Onde 31 C0 (com o bit S ligado) usa o operando de 32-bit EAX. Mas 30 C0 usa o operando de 8-bit AL.

Repare também no seguinte caso:

felipe@silva-lenovo:~$ echo -ne "\x66\x30\xC0\x66\x31\xC0" > tst
felipe@silva-lenovo:~$ ndisasm -b32 tst
00000000  6630C0            o16 xor al,al
00000003  6631C0            xor ax,ax

Ele é um valor numérico que é somado ao definido pelo byte ModR/M. Se esse campo estå presente ou não na instrução, bem como seu tamanho, é definido no byte ModR/M.

Jå em instruçÔes que usam o os dois bits menos significativos do opcode tem um significado especial, que são chamados de bit D (direction bit) e S (size bit). Conforme ilustração:

Veja que ao usar o prefixo 66 () em 31 C0 o registrador AX Ă© utilizado. Mas esse prefixo Ă© ignorado em instruçÔes cujo o bit S esteja desligado. Por isso o ndisasm faz o disassembly da instrução ainda como xor al, al. Embora ele adicione um o16 ali para denotar o uso (inĂștil) do prefixo.

endereçamento
byte ModR/M
operand-size override
Representação dos bits de um opcode.
Diagrama da arquitetura de Von Neumann
Bits de um atributo e seus significados
Intel Developer's Manuals | 4.6.2 128-Bit Packed SIMD Data Types
Figura retirada dos . ApĂȘndice B do volume 2.
Intel Developer's Manuals - volume 1, capĂ­tulo 6
Print do .
Print do .
Print do .
SaĂ­da do depurador GDB
SaĂ­da do GDB ao usar o comando `print`
GDB usando execução reversa.
SaĂ­da do GDB ao usar Artificial Array
Demonstração de uso do dprintf no GDB
GDB Text User Interface
Print do .
manuais da Intel
x86-visualizer
x86-visualizer
x86-visualizer
x86-visualizer