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

Este livro é mais um projeto do Mente Binária 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 Fundamentos de Engenharia Reversa e diversos treinamentos gratuitos.

Se você está lendo isto no repositório do GitHub, recomendo que leia na plataforma do GitBook clicando aqui.

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. 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.

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

  3. 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

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.

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

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. Veja aqui.

  • 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 interrupção de software, é 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 inline Assembly mas também pela previsibilidade do código que eu já mencionei (você vai entender o que é isso se continuar lendo o livro).

  • decompiler
    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.

    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 GCC/Mingw-w64 e o NASM instalados no seu sistema.

    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 pacote GCC já tem o que é excelente mas prefiro usar aqui o NASM devido a vários fatores, dentre eles:

    • 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

    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:

    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:

    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.

    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:

    No Windows fica assim:

    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.

    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.

    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.

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

    Se tudo deu errado...

    Se você não conseguiu preparar nossa PoC aí no seu computador, acesse para tirar sua dúvida.

    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:

    Nome oficial

    Nome alternativo

    Bit

    8086

    IA-16

    16

    IA-32

    i386

    32

    x86-64

    i686

    64

    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.

    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, como o GCC por exemplo.

    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

    A arquitetura x86 é uma arquitetura CISC 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.

    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

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

    Diagrama da arquitetura de Von Neumann

    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

    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 HD, usamos uma porta física para enviar e receber dados do dispositivo. O gerenciamento desta comunicação é feito pelo chipset da placa-mãe.

    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.

    assembler GAS
    UNIX
    PoC
    linkar
    determinado recurso
    o fórum do Mente Binária
    4d 3c 2b 1a
    main.c
    #include <stdio.h>
    
    int assembly(void);
    
    int main(void)
    {
      printf("Resultado: %d\n", assembly());
      return 0;
    }
    assembly.asm
    bits 64
    
    section .text
    
    global assembly
    assembly:
      mov eax, 777
      ret
    $ nasm assembly.asm -f elf64
    $ gcc -c main.c -o main.o
    $ gcc assembly.o main.o -o test -no-pie
    $ ./test
    $ nasm assembly.asm -f win64
    $ gcc -c main.c -o main.o
    $ gcc assembly.obj main.o -o test -no-pie
    $ .\test
    Makefile
    all:
    	nasm *.asm -felf64
    
    	gcc -c *.c
    	gcc -no-pie *.o -o test

    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:

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

    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.

    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
    

    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)

    ver aqui
    $ nasm hello.asm -felf64
    $ ld hello.o -o hello
    $ ./hello
    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

    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

    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:

    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:

    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

    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:

    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

    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:

    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:

    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:

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

    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

    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:

    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:

    call rel16/rel32
    Instruction_Pointer = Instruction_Pointer + operand
    bits 64
    
    call $
    call r/m
    mov  rax, rotulo
    call rax
    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
    call 0x1234:0xabcdef99
    call [rbx]       ; Próximo e absoluto
    call near [rbx]  ; Próximo e absoluto
    call far [rbx]   ; Distante
    11 11 bb bb aa aa
    bits 32
    
    my_addr: dd 0xbbbb1111   ; Deslocamento
             dw 0xaaaa       ; Segmento
    
    ; E usada assim:
    call far [my_addr]
    ret
    retf
    retn
    ret  imm16
    retf imm16
    retn imm16
    retf  ; Usado em procedimentos que devem ser chamados com far call
    pseudo.c
    RIP = pop();
    CS  = pop();
    RSP = RSP + 12;

    Programando no MS-DOS

    Conhecendo o ambiente do MS-DOS.

    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 FreeDOS que é um sistema operacional de código aberto e que é compatível com o 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.

    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

    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:

    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:

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

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

    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.

  • org endereço_inicial
    bits 16
    org 0x100
    
    msg: db "abc"
    
    codigo:
      mov ax, 77
      ret
    hello.asm
    bits 16
    org 0x100
    
    mov ah, 0x0E
    mov al, 'H'
    int 0x10
    
    mov al, 'i'
    int 0x10
    
    ret
    $ nasm hello.asm -o hello.com

    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

    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.

    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.

    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.

    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:

    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.

    Instruções de controle de cache e
    prefetch
    .

    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

    de 32 ou 64 bits.

    imm8

    Operando imediato de 8 bits de tamanho.

    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.

    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).

    prefixo REX
    Intel Developer's Manuals | 4.6.2 128-Bit Packed SIMD Data Types

    uword(n)

    Registrador de propósito geral

    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.

    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.

    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 storage-class static estejam inicializados.

    A função main

    A função main pode ser escrita com um dos dois protótipos abaixo:

    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

    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.

    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:

    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:

    Compile com:

    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:

    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:

    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:

    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:

    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.

    as referências
    ver o Assembly gerado
    int main(void)
    {
      // ...
    }
    int main(int argc, char *argv[])
    {
      // ...
    }
    $ objdump -d /usr/lib/x86_64-linux-gnu/Scrt1.o
    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;
    }
    $ as start.s -o crt1.o
    $ gcc main.c -o main.o -c
    $ gcc *.o -o test -nostartfiles
    #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
    void funcname(void);
    #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;
    }

    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.

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

    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

    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 .

    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.

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

    Ferramentas necessárias

    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.

    Eis a lista de ferramentas:

    • -- 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.

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

    dosbox -- Emulador do MS-DOS e arquitetura x86
  • qemu -- Emulador de várias arquiteturas diferentes, usaremos a i386

  • É recomendável que tenha o make, porém é opcional.

  • binário
    hexadecimal
    octal
    Programação Moderna em C
    variável de ambiente
    nasm
    gcc
    mingw-w64
    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:

    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:

    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.

    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:

    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:

    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.

    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

    Exemplo:

    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:

    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 :

    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:

    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:

    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:

    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.

    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.

    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:

    mov eax, 777
    Mov Eax, 777
    MOV EAX, 777
    mov EAX, 777
    MoV EaX, 777
    ; Um exemplo
    mov eax, 777 ; Outro exemplo
    
    %comment
      Mais
      um
      exemplo
    %endcomment
    operação operando1, operando2, operando3
    mov eax, 777
    eax = 777;
    mov [0x100], eax
    mov dword [0x100], 777
    db 0x41, 0x42, 0x43, 0x44, "String", 0
    meu_rotulo: instrução/pseudo-instrução
    bits 64
    
    global assembly
    assembly:
      mov eax, 777
      ret
    meu_rotulo:
      mov eax, 777
    .subrotulo:
      mov ebx, 555
    meu_rotulo:
      jmp .subrotulo
      mov eax, 777
    
    .subrotulo:
      ret
    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.

    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.

    Modo de operação

    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 
    

    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):

    quad word

    8

    tword

    ten word

    10

    oword

    16

    yword

    32

    zword

    64

    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:

    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.

    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:

    Seguindo o fluxo de execução do código, a sequência de instruções ficaria assim:

    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:

    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.

    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

    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:

    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

    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:

    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.

    ESP = ESP - 4
    [ESP] = operando
    operando = [ESP]
    ESP = ESP + 4
    0
    ;
    }

    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:

    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:

    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:

    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:

    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 Position-independent executable (PIE) e aí entenderemos qual é a utilidade de se usar um endereço relativo ao RIP.

    Truque do NASM

    Cuidado para não se confundir em relação ao fator de escala. Veja por exemplo esta instrução 64-bit:

    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:

    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:

    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

    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;
    }
    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!
    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!
    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!
    mov [rel my_label], rax
    
    ; OU:
    
    default rel
    mov [my_label], rax
    mov [rbx*3], rax
    mov [rbx + rbx*2], rax
    mov [rsi + rbx*3], rax
    lea registrador, [endereço]
    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:

    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 
    

    A instrução na linha 8 nunca será executada devido ao JMP na linha 6.

    Repare que na linha 10 estamos usando um rótulo local que foi explicado no tópico sobre a sintaxe do nasm.

    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

    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:

    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:

    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.

    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:

    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 *

    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.

    Instrução

    Operando

    Ação

    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.

    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

    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:

    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:

    O que deveria gerar a seguinte saída:

    Podemos testar o mapeamento de EAX com nossa PoC:

    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.

    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 convenção de chamada.

    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:

    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.

    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:

    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.

    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.

    Operando 1

    Operando 2

    Destino

    AL

    r/m8

    AX

    AX

    r/m16

    DX:AX

    EAX

    r/m32

    EDX:EAX

    RAX

    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.

    Operando 1

    Operando 2

    Destino quociente

    Destino sobra

    AX

    r/m8

    AL

    AH

    DX:AX

    r/m16

    AX

    DX

    EDX:EAX

    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.

    Se você for um escovador de bits sugiro ler à respeito no manual de otimização da Intel no tópico 3.4.1.4 Code Alignment.

    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:

    Registrador

    Uso

    RAX

    Número da syscall / Valor de retorno

    RDI

    1° argumento

    RSI

    2° argumento

    RDX

    3° argumento

    R10

    4° argumento

    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

    Nome

    RAX

    RDI

    exit

    60

    int status_de_saída

    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:

    • Linux System Call Table for x86 64

    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.

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

    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:

    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.

    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.

    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. 😉

    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

    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

    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.

    Position-independent executable

    Explicando PIE e ASLR

    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.

    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.

    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.

    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

    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;
    }
    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.
    jmp endereço
    eax = 0;
    rbx = 7;
    rcx = 5;
    if(rbx > rcx){
      eax = 1;
    }
    return;
    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;
    }
    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
    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
    section .text exec
    
    section .data write
    
    section .outra write exec
    $ objdump -t arquivo_objeto.o
    ; 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;
    }
    Registrador = Memória
    Operações com o valor no registrador
    Memória = Registrador
    exemplo.asm
    mov ah, 0xaa
    mov al, 0xbb
    ; Aqui o valor de AX é 0xaabb
    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;
    }
    
    mov rax, 0x11223344aabbccdd
    mov eax, 0x1234
    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
    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
    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
    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 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
    }
    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.

    0
    ;
    }

    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

    8 bytes

    RAX

    r/m64

    RDX:RAX

    r/m32

    EAX

    EDX

    RDX:RAX

    r/m64

    RAX

    RDX

    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:

    No Windows basta instalar o Mingw-w64 como já mencionei.

    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:

    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:

    Dosbox
    FreeDOS
    $ sudo apt install gcc-multilib
    $ gcc hello.c -o hello -m32
    $ ndisasm -b32 binary
    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-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.

    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:

    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.

    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 Atributos isso será explicado.

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

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

    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.

    Diferente dos registradores de propósito geral, os registradores de segmento não são expandidos. Permanecem com o tamanho de 16 bits.

    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.

    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 Thread Local Storage.

    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:

    
    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 
    

    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:

    Pseudo-instrução

    Tamanho dos dados

    Bytes

    db

    byte

    1

    dw

    word

    2

    dd

    double word

    4

    dq

    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

    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 magic number. Podemos declarar uma constante usando a pseudo-instrução equ:

    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:

    Símbolo

    Valor

    $

    Endereço da instrução atual

    $$

    Endereço do início da seçã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.

    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

    Já vimos no tópico Endereçamento 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.

    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:

    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
    
    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:

    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:

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

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

    Endereçamento
    tópico sobre MS-DOS
    ASLR
    . Como o já citado
    far
    call
    por exemplo.

    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:

    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:

    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:

    Os comandos:

    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:

    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:

    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 A base→Endereçamento.

    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

    Como explicado no tópico que fala sobre registradores de segmentos 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 endereçamento.

    Registrador base

    Segmento

    RIP

    CS

    SP/ESP/RSP

    SS

    BP/EBP/RBP

    SS

    Qualquer outro registrador

    DS

    Exemplos:

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

    opcode
    ModR/M

    Data

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

    SP

    Stack Pointer

    Usado como ponteiro para o topo da stack.

    BP

    Base Pointer

    Usado como ponteiro para o endereço inicial do stack frame.

    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.

    R8

    5° argumento

    R9

    6° argumento

    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:

    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.

    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.

    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:

    Para compilar e testar usando o Dosbox:

    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.

    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.

    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).

    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 .

    Sinais

    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 .

    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.

    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:

    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.

    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.

    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.

    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:

    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.

    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.

    BIOS é um sistema legado de boot, sistemas mais modernos usam UEFI para o processo de boot do sistema.

    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.

    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:

    AH 0x02

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

    AH 0x03

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

    AH 0x05

    Alterna para a página especificada por AL que deve ser um número entre 0 e 7.

    AH 0x09

    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

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

    AH 0x13

    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:

    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:

    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:

    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:

    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:

    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:

    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:

    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:

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

    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.

    Instruções aritméticas

    Instruções de operação aritmética do SSE.

    ADDP(S|D) | Add Packed (Single|Double)-precision floating-point values

    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:

    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

    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.

    Usando instruções da FPU

    Aprendendo a usar o x87 para fazer cálculos.

    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.

    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.

    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

    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.

    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 😁.

    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

    mov [0x100], ax
    ; O nome deste recurso é "segment override"
    ; Ou em PT-BR: Substituição do segmento
    
    mov [es:0x100], ax
    
    ; OU alternativamente:
    
    es mov [0x100], ax
    endereço_físico = (segmento << 4) + deslocamento
    #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
    
    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;
    }
    
    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;
    }
    global assembly, anotherFunction, gVariable
    extern symbol1, symbol2, symbol3
    resb número_de_dados
    resd 6  ; Aloca o espaço de 6 double-words, ao todo 24 bytes.
    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!
    here: jmp here
    jmp $
    section .text
    text_start:
      nop
      call exemplo
      jmp text_start
    section .text
      nop
      call exemplo
      jmp $$
    mov rax, [rel my_var]
    $ nasm assembly.asm -o assembly.o -felf64
    $ gcc main.c -c -o main.o
    $ gcc *.o -o test
    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
    tst.asm
    bits 32
    
    mov ah,  bh
    mov eax, ebx
    $ nasm tst.asm -o tst
    tst.asm
    bits 32
    
    mov eax, 0x11223344
    $ nasm tst.asm -o tst
    $ ndisasm -b32 tst
    $ ndisasm -b16 tst
    B8 44 33
    mov eax, [rbx]  ; Lê do endereço DS:RBX
    mov eax, [rbp]  ; Lê do endereço SS:RBP
    %define nome              "valor"
    %define nome(arg1, arg2)  arg1 + arg2
    0
    ;
    }

    quad word

    8

    dt

    ten word

    10

    do

    16

    dy

    32

    dz

    64

    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

    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

    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

    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

    if Below or Equal | se acima ou igual

    SUBP(S|D) | Subtract Packed (Single|Double)-precision floating-point values

    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 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:

    SUBS(S|D) | Subtract Scalar (Single|Double)-precision floating-point value

    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

    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

    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

    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

    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

    Calcula o valor aproximado do inverso multiplicativo dos floats no operando fonte (a direita) e armazena os valores no operando destino.

    RCPSS | Compute Reciprocal of Scalar Single-precision floating-point value

    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

    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

    Calcula a raiz quadrada do número escalar no operando fonte e armazena o resultado no float/double menos significativo do operando destino. Exemplo:

    RSQRTPS | Compute Reciprocals of square roots of Packed Single-precision floating-point values

    Calcula o inverso multiplicativo 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.

    RSQRTSS | Compute Reciprocal of square root of Scalar Single-precision floating-point value

    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

    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

    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

    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

    Funciona da mesma forma que MAXSS/MAXSD porém retornando o menor valor entre os dois.

    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
    #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;
    }
    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:

    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:

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

    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

    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

    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

    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

    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 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).

    CVTPS2PD | Convert packed single-precision floating-point values to packed double-precision floating-point values

    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

    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

    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

    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

    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

    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 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 (sign extension).

    CVTTSD2SI faz a mesma coisa porém truncando o valor.

    CVTSI2SD | Convert doubleword integer to scalar double-precision floating-point value

    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

    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

    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 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 (sign extension).

    A instrução CVTTSS2SI faz a mesma coisa porém truncando o valor.

    CVTSI2SS | Convert doubleword integer to scalar single-precision floating-point value

    Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um float no operando destino.

    cmp rax, rbx
    jnz nao_igual  ; Salta se RAX e RBX não forem iguais
    %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
    #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
    #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
    ADDPS xmm(n), xmm(n)
    ADDPS xmm(n), float(4)
    
    
    ADDPD xmm(n), xmm(n)
    ADDPD xmm(n), double(2)
    SUBPS xmm(n), xmm(n)
    SUBPS xmm(n), float(4)
    
    
    SUBPD xmm(n), xmm(n)
    SUBPD xmm(n), double(2)
    ADDSS xmm(n), xmm(n)
    ADDSS xmm(n), float(1)
    
    
    ADDSD xmm(n), xmm(n)
    ADDSD xmm(n), double(1)
    SUBSS xmm(n), xmm(n)
    SUBSS xmm(n), float(1)
    
    
    SUBSD xmm(n), xmm(n)
    SUBSD xmm(n), double(1)
    MULPS xmm(n), xmm(n)
    MULPS xmm(n), float(4)
    
    
    MULPD xmm(n), xmm(n)
    MULPD xmm(n), double(2)
    MULSS xmm(n), xmm(n)
    MULSS xmm(n), float(1)
    
    
    MULSD xmm(n), xmm(n)
    MULSD xmm(n), double(1)
    DIVPS xmm(n), xmm(n)
    DIVPS xmm(n), float(4)
    
    
    DIVPD xmm(n), xmm(n)
    DIVPD xmm(n), double(2)
    DIVSS xmm(n), xmm(n)
    DIVSS xmm(n), float(1)
    
    
    DIVSD xmm(n), xmm(n)
    DIVSD xmm(n), double(1)
    RCPPS xmm(n), xmm(n)
    RCPPS xmm(n), float(4)
    RCPSS xmm(n), xmm(n)
    RCPSS xmm(n), float(1)
    SQRTPS xmm(n), xmm(n)
    SQRTPS xmm(n), float(4)
    
    
    SQRTPD xmm(n), xmm(n)
    SQRTPD xmm(n), double(2)
    SQRTSS xmm(n), xmm(n)
    SQRTSS xmm(n), float(1)
    
    
    SQRTSD xmm(n), xmm(n)
    SQRTSD xmm(n), double(1)
    RSQRTPS xmm(n), xmm(n)
    RSQRTPS xmm(n), float(4)
    RSQRTSS xmm(n), xmm(n)
    RSQRTSS xmm(n), float(1)
    MAXPS xmm(n), xmm(n)
    MAXPS xmm(n), float(4)
    
    
    MAXPD xmm(n), xmm(n)
    MAXPD xmm(n), double(2)
    MAXSS xmm(n), xmm(n)
    MAXSS xmm(n), float(1)
    
    
    MAXSD xmm(n), xmm(n)
    MAXSD xmm(n), double(1)
    MINPS xmm(n), xmm(n)
    MINPS xmm(n), float(4)
    
    
    MINPD xmm(n), xmm(n)
    MINPD xmm(n), double(2)
    MINSS xmm(n), xmm(n)
    MINSS xmm(n), float(1)
    
    
    MINSD xmm(n), xmm(n)
    MINSD xmm(n), double(1)
    #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
    
    #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
    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)
    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)
    MOVLPS xmm(n), float(2)
    MOVLPS float(2), xmm(n)
    
    
    MOVLPD xmm(n), double(1)
    MOVLPD double(1), xmm(n)
    MOVHPS xmm(n), float(2)
    MOVHPS float(2), xmm(n)
    
    
    MOVHPD xmm(n), double(1)
    MOVHPD double(1), xmm(n)
    MOVLHPS xmm(n), xmm(n)
    MOVHLPS xmm(n), xmm(n)
    MOVMSKPS reg32/64, xmm(n)
    
    
    MOVMSKPD reg32/64, xmm(n)
    CVTPS2PD xmm(n), xmm(n)
    CVTPS2PD xmm(n), float(2)
    CVTPD2PS xmm(n), xmm(n)
    CVTPD2PS xmm(n), double(2)
    CVTSS2SD xmm(n), xmm(n)
    CVTSS2SD xmm(n), float(1)
    CVTSD2SS xmm(n), xmm(n)
    CVTSD2SS xmm(n), double(1)
    CVTPD2DQ xmm(n), xmm(n)
    CVTPD2DQ xmm(n), double(2)
    
    
    CVTTPD2DQ xmm(n), xmm(n)
    CVTTPD2DQ xmm(n), double(2)
    CVTDQ2PD xmm(n), xmm(n)
    CVTDQ2PD xmm(n), dword(2)
    CVTSD2SI reg32/64, xmm(n)
    CVTSD2SI reg32/64, double(1)
    
    CVTTSD2SI reg32/64, xmm(n)
    CVTTSD2SI reg32/64, double(1)
    CVTSI2SD xmm(n), reg32/64
    CVTSI2SD xmm(n), dword(1)
    CVTSI2SD xmm(n), qword(1)
    CVTPS2DQ xmm(n), xmm(n)
    CVTPS2DQ xmm(n), float(4)
    
    
    CVTTPS2DQ xmm(n), xmm(n)
    CVTTPS2DQ xmm(n), float(4)
    CVTDQ2PS xmm(n), xmm(n)
    CVTDQ2PS xmm(n), dword(4)
    CVTSS2SI reg32/64, xmm(n)
    CVTSS2SI reg32/64, float(1)
    
    
    CVTTSS2SI reg32/64, xmm(n)
    CVTTSS2SI reg32/64, float(1)
    CVTSI2SS xmm(n), reg32/64
    CVTSI2SS xmm(n), dword(1)
    CVTSI2SS xmm(n), qword(1)

    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

    Setado para o mesmo valor do bit mais significativo do resultado (). Onde 0 indica um valor positivo e 1 indica um valor negativo.

    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.

    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.

    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

    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.

    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

    Intel Developer's Manual Vol. 1

    Auxiliary Carry Flag

    I/O Privilege Level field

    Coluna

    ES:BP

    Endereço da string

    \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.

    Num lock está ligado.

    6

    Caps lock está ligado.

    7

    Modo Insert está ligado.

    AH

    BH

    DH

    DL

    0x02

    Página

    Linha

    Coluna

    AH

    BH

    0x03

    Página

    CH

    CL

    DH

    DL

    Scanline inicial

    Scanline final

    Linha

    Coluna

    AH

    AL

    0x05

    Página

    AH

    AL

    BH

    BL

    CX

    0x09

    Caractere

    Página

    Atributo

    Vezes para imprimir o caractere

    AH

    AL

    BH

    CX

    0x0A

    Caractere

    Página

    Vezes para imprimir

    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

    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.

    Caractere

    Nome

    Seq. de escape

    Ação

    0x07

    Bell

    \a

    Emite um beep.

    0x08

    Backspace

    \b

    Retorna o cursor uma posição.

    0x09

    Registrador

    Valor

    AL

    Código ASCII do caractere

    AH

    Scancode da tecla.

    Registrador

    Valor

    AL

    Código ASCII

    AH

    Scancode

    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.

    http://vitaly_filatov.tripod.com/ng/asm/asm_001.html
    Bits de um atributo e seus significados

    DL

    Horizontal TAB

    5

    0
    ;
    }
    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:

    Detalhe que só é possível usar esses registradores em instruções da FPU, algo como esse código está errado:

    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

    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

    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...)

    FST, FSTP | Store (and Pop)

    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)

    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:

    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)

    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:

    FSUB, FSUBP, FISUB | (Integer) Subtract (and Pop)

    Mesma coisa que as instruções acima, só que fazendo uma operação de subtração.

    FDIV, FDIVP, FIDIV | (integer) Division (and Pop)

    Mesma coisa que FADD etc. porém faz uma operação de divisão.

    FMUL, FMULP, FIMUL | (Integer) Multiply (and Pop)

    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:

    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

    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

    Calcula a raíz quadrada de st0 e armazena o resultado no próprio st0.

    FABS | Absolute

    Calcula o valor absoluto de st0 e armazena em st0. Basicamente zera o bit de sinalização do valor.

    FCHS | Change Sign

    Inverte o sinal de st0, se era negativo passa a ser positivo e vice-versa.

    FCOS | Cosine

    Calcula o cosseno de st0 que deve ser um valor radiano, e armazena o resultado nele próprio.

    FSIN | Sine

    Calcula o seno de st0, que deve estar em radianos.

    FSINCOS | Sine and Cosine

    Calcula o seno e o cosseno de st0. O cosseno é armazenado em st0 enquanto o seno estará em st1.

    FPTAN | Partial Tangent

    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

    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.

    F2XM1 | 2^x - 1

    Faz o cálculo de 2 elevado a st0 menos 1, e armazena o resultado em st0.

    FYL2X | y * log2(x)

    Faz esse cálculo aí com logaritmo de base 2:

    Após o cálculo é feito um pop.

    FYL2XP1 | y * log2(x + 1)

    Mesma coisa que a instrução anterior porém somando 1 a st0.

    FRNDINT | Round to Integer

    Arredonda st0 para a parte inteira mais próxima e armazena o resultado em st0.

    FPREM, FPREM1 | Partial Reminder

    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

    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

    Faz uma operação move condicional levando em consideração as status flags.

    Vendo os resultados

    Adiantando que um valor float na convenção de chamada do C é retornado no registrador XMM0. Podemos ver o resultado de nossos testes da seguinte forma usando a instrução MOVSS:

    A instrução MOVSS e os registradores XMM serão explicados no próximo tópico.

    IEEE-754
    st1=arctan⁡(st1÷st0)st1 = \arctan( st1 \div st0 )st1=arctan(st1÷st0)
    st0=2st0−1st0 = 2^{st0} - 1st0=2st0−1
    st1=st1⋅log⁡2(st0)st1 = st1 \cdot \log_2(st0)st1=st1⋅log2​(st0)
    st1=st1⋅log⁡2(st0+1)st1 = st1 \cdot \log_2(st0 + 1)st1=st1⋅log2​(st0+1)
    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.

    Assim como fizemos em A base aqui está um código de teste para garantir que o seu ambiente está correto:

    #include <stdio.h>
    
    int assembly(void);
    
    int main(void)
    {
      printf("Resultado: %d\n", assembly());
      return 
    
        .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:

    Caso tenha algum problema e precise de ajuda, pode entrar no fórum do Mente Binária e fazer uma pergunta.

    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:

    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.

    A flag -fno-asynchronous-unwind-tables serve para desabilitar as diretivas CFI 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.

    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.

    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

    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

    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

    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

    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:

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

    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

    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

    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.

    sinal
    depurador
    próximo tópico
    os sinais
    Entendendo os depuradores
    Intel Developer's Manuals - volume 1, capítulo 6
    registradores de segmento

    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.

    • 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.

    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:

    Isso produz a seguinte saída ao :

    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.

    é 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.

    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:

    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.

    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.

    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:

    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:

    Exemplos:

    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

    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).

    Exemplos com o seu equivalente na sintaxe da Intel:

    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:

    Saltos que especificam segmento e offset separam os dois valores por vírgula. Como em:

    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

    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:

    Exemplos:

    Diretivas de seções e alinhamento

    O GAS tem diretivas específicas para declarar algumas seções padrão. Conforme tabela:

    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:

    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:

    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:

    Exemplo de código na sintaxe AT&T

    O exemplo abaixo é o mesmo apresentado no tópico sobre porém reescrito na sintaxe do GAS/AT&T:

    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.

    • 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.

    • 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

    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:

    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:

    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:

    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

    A convenção de chamada __stdcall é a utilizada para chamar funções da .

    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:

    Retorno de valores

    O retorno de valores funciona da mesma maneira que o retorno de valores da __cdecl.

    Variáveis em C

    Entendendo como variáveis em C são representadas em Assembly.

    Como já vimos no capítulo A base, 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.

    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.

    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.

    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

    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.

    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.

    Instruções com inteiros 128-bit

    PAVGB/PAVGW | Compute average of packed unsigned (byte|word) of integers

    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

    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.

    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

    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

    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

    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

    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

    Faz o mesmo que PMAXSB/PMAXSW porém retornando o menor valor de cada comparação.

    PMOVMSKB | Move byte mask

    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

    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

    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

    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

    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

    Soma os bytes, words, double words ou quadwords dos operandos e armazena no operando destino.

    PSUBQ | Packed quadword subtract

    Faz o mesmo que a instrução PADDQ porém com uma operação de subtração.

    PMULUDQ | Multiply packed unsigned doubleword integers

    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:

    RDI é o primeiro ponteiro recebido como argumento e RSI o segundo.

    PSLLDQ | Shift double quadword left logical

    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.

    PSRLDQ | Shift double quadword right logical

    Faz o mesmo que a instrução anterior porém com um shift right. Os bits mais significativos são zerados.

    Convenção de chamada da System V ABI

    Aprendendo sobre a convenção de chamada do C usada no Linux.

    Sistemas UNIX-Like, 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.

    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.

    • A Direction Flag (DF) no precisa obrigatoriamente estar zerada ao chamar ou retornar de uma função.

    Stack frame

    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:

    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.

    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.

    Passagem de parâmetros

    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).

    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:

    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:

    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.

    • Valores float ou double são retornados em ST0 (ver ).

    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:

    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.

    *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 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.

    Para entender apropriadamente as operações e tipos indicados aqui, sugiro que já tenha lido o tópico sobre

    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.

    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:

    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

    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.

    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:

    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
    std  ; (Set DF)    Seta o valor da Direction Flag
    cld  ; (Clear DF)  Zera o valor da Direction Flag
    sti  ; (Set IF)   Seta o valor da Interrupt Flag
    cli  ; (Clear IF) Zera o valor da Interrupt Flag
    exemplo.asm
    mov ah, 0x0E
    mov al, 'H'
    int 0x10
    
    mov al, 'i'
    int 0x10
    
    ret
    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
    str: db 'A', 0x05, 'B', 0x0C, 'C', 0x0A
    // Em modo de texto 80x25, padrão do MS-DOS
    
    struct character {
        uint8_t ascii;
        uint8_t attribute;
    };
    
    struct character vmem[8][25][80];
    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
    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;
    }
    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;
    }
    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;
    }
    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
    mov eax, st1
    finit
    fld mem32fp
    fld mem64fp
    fld mem80fp
    fld st(i)
    
    fild mem16int
    fild mem32int
    fild mem64int
    fst mem32fp
    fst mem64fp
    fst st(i)
    
    fstp mem32fp
    fstp mem64fp
    fstp mem80fp
    fstp st(i)
    fist mem16int
    fist mem32int
    
    fistp mem16int
    fistp mem32int
    fistp mem64int
    fadd mem32fp
    fadd mem64fp
    fadd st(0), st(i)
    fadd st(i), st(0)
    
    faddp st(i), st(0)
    faddp
    
    fiadd mem16int
    fiadd mem32int
    fsub mem32fp
    fsub mem64fp
    fsub st(0), st(i)
    fsub st(i), st(0)
    
    fsubp st(i), st(0)
    fsubp
    
    fisub mem16int
    fisub mem32int
    fdiv mem32fp
    fdiv mem64fp
    fdiv st(0), st(i)
    fdiv st(i), st(0)
    
    fdivp st(i), st(0)
    fdivp
    
    fidiv mem16int
    fidiv mem32int
    fmul mem32fp
    fmul mem64fp
    fmul st(0), st(i)
    fmul st(i), st(0)
    
    fmulp st(i), st(0)
    fmulp
    
    fimul mem16int
    fimul mem32int
    a = a - b // fsub etc.
    a = b - a // fsubr etc.
    fxch st(i)
    fxch
    fsqrt
    fabs
    fchs
    fcos
    fsin
    fsincos
    fptan
    fpatan
    f2xm1
    fyl2x
    fyl2xp1
    frndint
    fprem
    fprem1
    fcomi  st(0), st(i)
    fcomip st(0), st(i)
    
    fucomi  st(0), st(i)
    fucomip st(0), st(i)
    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)
    $ as assembly.s -o assembly.o
    $ gcc main.c -c -o main.o
    $ gcc *.o -o test
    $ gcc main.c -o main.s -S -masm=intel -fno-asynchronous-unwind-tables
    ANDPS xmm(n), xmm(n)
    ANDPS xmm(n), float(4)
    
    
    ANDPD xmm(n), xmm(n)
    ANDPD xmm(n), double(2)
    ANDNPS xmm(n), xmm(n)
    ANDNPS xmm(n), float(4)
    
    
    ANDNPD xmm(n), xmm(n)
    ANDNPD xmm(n), double(2)
    ORPS xmm(n), xmm(n)
    ORPS xmm(n), float(4)
    
    
    ORPD xmm(n), xmm(n)
    ORPD xmm(n), double(2)
    XORPS xmm(n), xmm(n)
    XORPS xmm(n), float(4)
    
    
    XORPD xmm(n), xmm(n)
    XORPD xmm(n), double(2)
    ; As duas instruções abaixo são equivalentes.
    
    CMPPS xmm1, xmm2, 0
    CMPEQPS xmm1, xmm2
    CMPPS xmm(n), xmm(n), imm8
    CMPPS xmm(n), float(4), imm8
    
    
    CMPPD xmm(n), xmm(n), imm8
    CMPPD xmm(n), double(2), imm8
    CMPSS xmm(n), xmm(n), imm8
    CMPSS xmm(n), float(4), imm8
    
    
    CMPSD xmm(n), xmm(n), imm8
    CMPSD xmm(n), double(2), imm8
    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)
    // Em 16-bit
    
    struct elem {
      uint16_t offset;
      uint16_t segment;
    }
    
    struct elem idt[256];
    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
    $ nasm int.asm -o int.com
    $ dosbox int.com
    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
    #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;
    }
    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;
    }
    # 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'
    PAVGB xmm(n), xmm(n)
    PAVGB xmm(n), ubyte(16)
    
    
    PAVGW xmm(n), xmm(n)
    PAVGW xmm(n), uword(8)
    0
    ;
    }
    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.

    FLDLG2

    log10(2)

    FLDLN2

    logE(2)

    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.

    qword

    T

    ten word (80 bits)

    tword

    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)

    -

    Sufixo

    Tamanho

    Palavra-chave equivalente no NASM

    B

    byte (8 bits)

    byte

    W

    word (16 bits)

    word

    L

    long/doubleword (32 bits)

    dword

    Q

    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

    GAS

    Equivalente no NASM

    .data

    section .data

    .bss

    section .bss

    .text

    section .text

    o endereçamento
    existem as pseudo-instruções
    instruções de movimentação SSE

    quadword (64 bits)

    quadword (64 bits)

    deve
    estar em um endereço alinhado por 16 bytes.
    valores escalares
    WinAPI
    22=42² = 422=4
    242^424
    thread
    registrador de segmento
    registrador de propósito geral
    logical shift

    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

    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

    RFLAGS
    da pilha
    CALL
    registradores de propósito geral
    valores escalares
    Usando instruções da FPU

    AX

    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.

    # AT&T               # Intel
    
    movl $5, %eax        # mov dword eax, 5
    movb $'A', (%ebx)    # mov byte [ebx], 'A'
    # 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]
    # AT&T               # Intel
    
    jmp my_code          # jmp my_code
    jmp *my_pointer      # jmp [my_pointer]
    # AT&T               # Intel
    
    jmp $1234, $5678     # jmp 1234:5678
    msg1: .ascii "Hello World\0"
    msg2: .asciz "Hello World"
    
    value1: .byte 1, 2, 3, 4, 5
    value2: .float 3.1415
    # GAS                         # NASM
    
    .section .mysection, "wx"     # section .mysection write exec
    .section .rodata              # section .rodata
    .text                         # section .text
    .section .text                # section .text
        .section .rodata
        .align 16
    
    # Equivalente no NASM: section .rodata align=16
        .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
    #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
    int sum(int a, int b, int c, int d, int e, int f);
    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)
    push $3
    push $2
    push $1
    call my_function
    add $12, %esp
    
    # 12 é o tamanho em bytes dos três valores empilhados
    push $3
    push $2
    push $1
    call my_function
    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
    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;
    PEXTRW reg32/64, xmm(n), imm8
    PEXTRW uword(1), xmm(n), imm8  ; Adicionado no SSE4
    PINSRW xmm(n), reg32, imm8
    PINSRW xmm(n), uword(1), imm8
    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
    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
    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
    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)
    PMOVMSKB reg32/64, xmm(n)
    PMULHW xmm(n), xmm(n)
    PMULHW xmm(n), uword(8)
    
    
    PMULHUW xmm(n), xmm(n)
    PMULHUW xmm(n), uword(8)
    PSADBW xmm(n), xmm(n)
    PSADBW xmm(n), ubyte(16)
    MOVDQA xmm(n), xmm(n)
    MOVDQA xmm(n), qword(2)
    MOVDQA qword(2), xmm(n)
    MOVDQU xmm(n), xmm(n)
    MOVDQU xmm(n), qword(2)
    MOVDQU qword(2), xmm(n)
    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)
    PSUBQ xmm(n), xmm(n)
    PSUBQ xmm(n), qword(2)
    PMULUDQ xmm(n), xmm(n)
    PMULUDQ xmm(n), dword(4)
    #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
    PSLLDQ xmm(n), imm8
    PSRLDQ xmm(n), imm8
    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
    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
    push $4
    push $3
    push $2
    push $1
    call my_function
    add $16, %esp
    example:
        push %rbp
        mov %rsp, %rbp
        sub $16, %rsp
        
        # etc...
        
        mov %rbp, %rsp
        pop %rbp
        ret

    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.

    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

    Qualquer .

    t

    ST0

    u

    ST1

    y

    Qualquer registrador MMX.

    x

    Qualquer .

    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.

    Clobber

    Descrição

    cc

    Indica que o código ASM modifica as flags do processador (registrador EFLAGS).

    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

    Operando em um registrador de propósito geral.

    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.

    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).

    função como inline
    visualizar o código de saída
    As restrições
    o sufixo na instrução

    p

    c

    .

    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

    Representa o conteúdo de um . Pode armazenar 4 valores floating-point de 32-bit.

    __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.

    Nomenclatura

    A maioria das instruções intrínsecas (SIMD) seguem a seguinte convenção de notação:

    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.

    Exemplo:

    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:

    • https://software.intel.com/sites/landingpage/IntrinsicsGuide/

    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

    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:

    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)

    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:

    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

    Exemplos:

    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:

    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.

    SSE
    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:

    Experimente compilar e executar esse programa. No Linux você pode enviar o sinal SIGTERM para o processo com o comando kill, como em:

    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

    Quando um processo dispara uma exceção, 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.

    Depuradores

    Agora que já entendemos um pouco sobre processos vai ficar mais fácil entender como depuradores funcionam. Afinal de contas depuradores depuram processos. 🙂

    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.

    Os breakpoints são implementados na prática (na arquitetura x86-64) como uma instrução int3 que dispara a exceção #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.

    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:

    Ao executar a instrução int3 inserida com inline Assembly 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.

    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.

    Quando a condição do breakpoint é atendida o processador dispara uma exceção #BP.

    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.

    Isso é implementado na arquitetura x86-64 usando a trap flag (TF) no registrador EFLAGS. Quando a TF está ligada cada instrução executada dispara uma exceção #BP, permitindo assim que o depurador retome o controle após executar uma instrução.

    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:

    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:

    Compile com:

    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:

    Saída do 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.

    é 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.

    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:

    #include <stdio.h>
    
    int do_something();
    
    int main(void)
    {
      printf("Resultado: %d\n", do_something(1, 2, 3
    
    	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

    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 com argumentos variáveis também seguem a mesma regra de chamada do que foi mencionado acima.

    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

    Esses atributos fazem com que o compilador gere o código da função usando a convenção de chamada ms_abi, sysv_abi, cdecl, stdcall, 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.

    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

    O atributo naked é usado para desativar a geração do prólogo e epílogo para a função. Isso é útil para se escrever funções usando inline Assembly dentro das mesmas.

    target ("option1", "option2", ...)

    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 SSE na função.

    Alguns dos possíveis alvos para arquitetura x86 são:

    Ativar as instruções

    Desativar as instruções

    3dnow

    no-3dnow

    3dnowa

    no-3dnowa

    abm

    no-abm

    adx

    no-adx

    aes

    no-aes

    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:

    convenção de chamada

    Copia o valor em DS:ESI para ES:EDI.

  • Incrementa o valor de ESI.

  • Incrementa o valor de EDI.

  • Decrementa o valor de ECX.

  • 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:

    Figura retirada dos . Apêndice B do volume 2.

    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:

    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.

    Inclusive a instrução B4 0E que mencionei anteriormente é a mov ah, 0x0E. Onde B4 é o opcode (de 1 byte) e 0E o immediate (de 1 byte também).

    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.

    MSB

    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.

    Por exemplo instruções da FPU 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.

    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.

    Já instruções que usam , qual especificamente será usado depende do tamanho do operando na instrução (veja ).

    Por exemplo as seguintes instruções compiladas em modo de 64-bit:

    Se convertermos esses opcodes em binário teremos o seguinte:

    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

    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.

    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

    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.

    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.

    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

    MOD 01

    R/M
    Endereçamento

    MOD 10

    R/M
    Endereçamento

    Endereçamento em 32-bit

    MOD 00

    R/M
    Endereçamento

    MOD 01

    R/M
    Endereçamento

    MOD 10

    R/M
    Endereçamento

    Endereçamento em 64-bit

    Devido ao o campo R/M é estendido em 1 bit no modo de 64-bit.

    MOD 00

    R/M
    Endereçamento

    MOD 01

    R/M
    Endereçamento

    MOD 10

    R/M
    Endereçamento

    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:

    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

    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.

    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:

    Também sugiro usar o ndisasm afim de fazer experimentações. Ele é um disassembler que vem junto com o nasm e .

    Depurando com o GDB

    Aprendendo a usar o depurador GDB do projeto GNU.

    O GDB é um depurador de linha de comando que faz parte do projeto GNU. O Mingw-w64 já instala o GDB junto com o GCC, e no Linux ele pode ser instalado pelo pacote 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:

    E pode rodar o GDB passando o caminho do binário assim:

    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:

    E será compilado da seguinte forma:

    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.

    Para mais informações consulte a .

    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

    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

    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

    O comando attach faz o no processo de ID especificado. Já o comando detach desfaz o attach no processo que está atualmente conectado.

    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:

    breakpoint

    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:

    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:

    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:

    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.

    E por fim dá para adicionar uma condição de parada ao breakpoint. Onde CONDITION é booleana. Exemplo:

    Onde no contexto do nosso código de exemplo, a seria o primeiro parâmetro da função add.

    clear

    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

    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

    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

    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.

    Os dois comandos atuam como um , ou seja, não entram em chamadas de procedimentos.

    step, stepi

    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

    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

    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

    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.

    continue

    Continua a execução normal do programa.

    record e reverse-*

    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. 😎

    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

    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:

    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

    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:

    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:

    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

    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 .

    Exemplo de uso:

    dprintf

    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:

    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

    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:

    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:

    disassembly

    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:

    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:

    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

    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

    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:

    frame

    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

    Exibe o stack frame no endereço especificado.

    frame apply

    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:

    frame function

    Exibe o stack frame da função especificada.

    frame level

    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

    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:

    info frame

    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.

    info args

    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

    Uso idêntico ao de info args só que exibe o valor das variáveis locais.

    info functions

    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

    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:

    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

    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:

    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:

    Atalhos de teclado

    Single Key Mode

    Quando se está no modo Single Key é possível executar alguns comandos pressionando uma única tecla, conforme tabela abaixo:

    Qualquer outra tecla alterna temporariamente para o modo de comandos. Após um comando ser executado ele retorna para o modo Single Key.

    Atributos e prefixos

    Entendendo os prefixos no código de máquina.

    Os dois tópicos atributos e prefixos 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.

    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.

    Como pode ser observado na ilustração exibida no tópico Formato das instruções, prefixos são bytes que podem (são opcionais na grande maioria das instruções) ser adicionados antes do opcode de uma instrução.

    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

    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.

    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:

    Para alterar o atributo de segmento para um outro segmento de memória é usado um prefixo distinto por segmento:

    Segmento
    Byte do prefixo

    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).

    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:

    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.

    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.

    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

    Na sintaxe do NASM o prefixo pode ser usado simplesmente com a palavra-chave lock antes da instrução. Como em:

    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:

    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.

    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.

    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:

    Referências

    Bibliografia

    Arquiteturas x86 e x86-64

    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.

    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.

    Exemplo:

    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:

    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

    #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;
    #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;
    }
    #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;
    }
    _mm_<operação>_<sufixo>
    double array[] = { 1.0, 2.0 };
    __m128d data = _mm_load_pd(array);
    #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;
    }
    #include <stdio.h>
    #include <immintrin.h>
    
    int main(void)
    {
      int value;
    
      while (!_rdrand32_step(&value))
        ;
    
      printf("Valor: %d\n", value);
      return 0;
    }
    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;
    }
    
    $ kill 155541
    
    # Onde 155541 seria o PID do processo.
    #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;
    }
        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
    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
    $ nasm testing.asm -o testing.o -felf64
    $ ld testing.o -o testing
    #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
    __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
    int add(int x, int y);
    void do_something(int a);
    int do_something(void);
    int do_something();
    int do_something(int x, ...);
    static int add(int a, int b)
    {
      return a + b;
    }
    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;
    }
    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
    address = base + index * scale
    $ sudo apt install gdb
    $ gcc -g3 test.c -o test
    $ gdb ./test
  • 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.

    ,
    4.5
    f
    ,
    "teste"
    ))
    ;
    }

    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

    https://github.com/Silva97/x86-visualizer
    já foi utilizado anteriormente no livro
    registrador do x87
    registrador SSE

    __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.

    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.

    __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

    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)

    __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

    registrador SSE
    Onde o valor 0x11223344 na instrução mov eax, [ebx + 0x11223344] é o displacement da instrução.
    endereçamento
    Print do x86-visualizer.
    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.

    manuais da Intel

    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

    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

    registradores de propósito geral
    Atributos e prefixos
    prefixo REX

    0101

    [DI]

    110

    displacement 16-bit

    111

    [BX]

    [DI] + displacement 8-bit

    110

    [BP] + displacement 8-bit

    111

    [BX] + displacement 8-bit

    [DI] + displacement 16-bit

    110

    [BP] + displacement 16-bit

    111

    [BX] + displacement 16-bit

    displacement 32-bit

    110

    [esi]

    111

    [edi]

    [ebp] + displacement 8-bit

    110

    [esi] + displacement 8-bit

    111

    [edi] + displacement 8-bit

    [ebp] + displacement 32-bit

    110

    [esi] + displacement 32-bit

    111

    [edi] + displacement 32-bit

    [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]

    [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

    [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

    11 - Multiplica o index por 8

    000

    [BX+SI]

    001

    [BX+DI]

    010

    [BP+SI]

    011

    [BP+DI]

    100

    [SI]

    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

    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

    000

    [eax]

    001

    [ecx]

    010

    [edx]

    011

    [ebx]

    100

    SIB

    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

    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

    0000

    [rax/eax]

    0001

    [rcx/ecx]

    0010

    [rdx/edx]

    0011

    [rbx/ebx]

    0100

    SIB

    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

    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

    opcode
    Codificação dos registradores
    prefixo REX
    código dos registradores

    101

    101

    101

    101

    101

    101

    0101

    0101

    0101

    SS

    36

    ,
    SBB
    ,
    SUB
    ,
    XOR
    ,
    XADD
    e
    XCHG
    . Isso, obviamente, quando o operando destino (o que está sendo escrito) é um operando na memória.

    Registrador base

    Segmento

    RIP

    CS

    SP/ESP/RSP

    SS

    BP/EBP/RBP

    SS

    Qualquer outro registrador

    DS

    CS

    2E

    DS

    3E

    ES

    26

    FS

    64

    GS

    65

    Modos de operação
    endereçamento
    Registradores de segmento
    explicado anteriormente
    zero flag
    Print do x86-visualizer.
    Print do x86-visualizer.
    Print do x86-visualizer.
    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:

    Convertendo os opcodes 8B e 89 para binário dá para notar um fato interessante:

    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:

    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:

    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:

    Veja que ao usar o prefixo 66 (operand-size override) 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.

    byte ModR/M
    Representação dos bits de um opcode.
    mov eax, 0x11223344                    b8 44 33 22 11
    mov ax, 0x1122                         66 b8 22 11
    mov al, 0x11                           b0 11
    B8 -> 10111000
    B0 -> 10110000
    base + index * scale
    bits 16
    ; ...
    a32 rep movsb
    lock add [ebx], 4
    false jz my_label
    mov eax, [ebx] ; 8B03
    mov [ebx], eax ; 8903
    8B -> 10001011
    89 -> 10001001
    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
    felipe@silva-lenovo:~$ echo -ne "\x30\xC0" > tst
    felipe@silva-lenovo:~$ ndisasm -b32 tst
    00000000  30C0              xor al,al
    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

    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.

    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.

    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

    Muda para o modo Single Key Mode.

    Tecla

    Comando

    Nota

    c

    continue

    d

    down

    f

    finish

    n

    documentação do GCC
    attach
    uma expressão
    step over
    print
    comando x
    expressões
    registradores de propósito geral
    registradores de segmento
    EFLAGS
    frame
    print
    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

    PgUp

    next

    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

  • Convenções de chamada

    1. System V Application Binary Interface Intel386 Architecture Processor Supplement - Version 1.0

    2. System V Application Binary Interface AMD64 Architecture Processor Supplement - Draft Version 0.99.7

    3. Calling Conventions | Microsoft Docs

    4. x86 calling conventions | Wikipedia

    Depuradores

    1. GDB Internals - Breakpoint Handling

    Ferramentas

    1. Using as | Documentation for binutils 2.37

    2. GAS syntax - Wikibooks

    3. NASM version 2.15.05 documentation

    4. Using the GNU Compiler Collection (GCC) | GNU Project

    Instruções intrínsecas

    1. Intrinsics | Intel® C++ Compiler Classic Developer Guide and Reference

    2. Intel® 64 and IA-32 Architectures Software Developer Manuals - Volume 2, Appendix C

    3. Intel® Intrinsics Guide

    4. An Introduction to GCC Compiler Intrinsics in Vector Processing

    Linguagem C

    1. C11 Standard - ISO/IEC 9899:201x draft n1570

    2. The GNU C Reference Manual

    3. Frederico Lamberti Pissarra. Dicas - C e Assembly para arquitetura x86-64

    Linux

    1. Linux System Call Table for x86 64

    2. Linux Programmer's Manual

    3. Lazy binding

    4. The .init and .fini Sections

    5. Daniel P. Bovet, Marco Cesati.

    Sistemas Operacionais

    1. Andrew S. Tanenbaum. Sistemas Operacionais Modernos. 4° Edição. ISBN: 978-8543005676

    2. Escalonamento de processos | Wikipédia

    3. Troca de contexto | Wikipédia

    4. Sinal (ciência da computação) | Wikipédia

    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.

    glibc

    1. /csu/*

    2. /sysdeps/x86_64/start.S

    3. /sysdeps/x86_64/crti.S

    4. /sysdeps/x86_64/crtn.S

    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;
    }
    $ gcc -g3 test.c -o test
    quit [EXPR]
    file FILE
    attach <process-id>
    detach
    $ gdb -p 12345
    break [PROBE_MODIFIER] [LOCATION] [thread THREADNUM] [if CONDITION]
    (gdb) b 15
    (gdb) b test.c:17
    (gdb) b main
    (gdb) b *main + 8
    (gdb) b *0x12345
    (gdb) b add thread 2
    (gdb) b 7 if a == 8
    clear [LOCATION]
    run [arg1, arg2, arg3...]
    start [arg1, arg2, arg3...]
    starti [arg1, arg2, arg3...]
    next [N]
    nexti [N]
    step [N]
    stepi [N]
    jump LOCATION
    advance LOCATION
    thread <thread-id>
    thread apply <thread-id> <command>
    thread find <regex>
    thread name <thread-name>
    (gdb) thread 2
    print[/FMT] [EXPR]
    (gdb) p/x 15
    $1 = 0xf
    (gdb) p x + $3
    printf "format string", ARG1, ARG2, ARG3, ..., ARG
    (gdb) printf "%p\n", $rsp
    0x7fffffffdf20
    dprintf LOCATION, "format string", ARG1, ARG2, ARG3, ..., ARG
    (gdb) dprintf 7, "%d + %d\n", a, b
    x[/FMT] ADDRESS
    (gdb) x/x 0x7fffffffdf64
    0x7fffffffdf64:	0x003d2061
    (gdb) x/xb 0x7fffffffdf64
    0x7fffffffdf64:	0x61
    (gdb) x/4xb 0x7fffffffdf64
    0x7fffffffdf64:	0x61	0x20	0x3d	0x00
    disassembly[/MODIFIER] [ADDRESS]
    disassembly[/MODIFIER] start,end
    disassembly[/MODIFIER] start,+length
    (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.
    (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.
    list
    list LINENUM
    list FILE:LINENUM
    list FUNCTION
    list FILE:FUNCTION
    list *ADDRESS
    backtrace [COUNT]
    (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_NUMBER]
    frame address STACK_ADDRESS
    frame apply COUNT COMMAND
    frame apply all COMMAND
    frame apply level FRAME_NUMBER COMMAND
    (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 FUNCTION_NAME
    frame level FRAME_NUMBER
    (gdb) info reg rax rbx
    info args [NAMEREGEXP]
    info locals [NAMEREGEXP]
    info functions [NAMEREGEXP]
    display[/FMT] EXPRESSION
    undisplay [NUM]
    (gdb) display/7i $rip
    source FILE
    (gdb) help ni
    (gdb) help info reg
    (gdb) help frame apply level
    $ gdb -tui ./test
    GNU Compiler Collection (GCC) Internals
    Debugging with GDB
    ptrace(2) — Linux manual page
    ld.so(8) — Linux manual page
    Understanding the Linux Kernel, 3rd Edition - 4.5 Exception Handling