arrow-left

Only this pageAll pages
gitbookPowered 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...

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 GCCarrow-up-right/Mingw-w64arrow-up-right e o NASMarrow-up-right instalados no seu sistema.

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

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

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

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

circle-info

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.

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

hashtag
Se tudo deu errado...

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

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

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.

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.

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

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.

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

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

circle-info

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

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

circle-info

Repare que este tópico em si está explicando alguns porquês.

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

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

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

circle-info

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?

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

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

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

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

Eu gosto do NASM.
assembler GASarrow-up-right
UNIXarrow-up-right
PoCarrow-up-right
linkararrow-up-right
determinado recursoarrow-up-right
o fórum do Mente Bináriaarrow-up-right
hashtag
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 binárioarrow-up-right, hexadecimalarrow-up-right ou octalarrow-up-right.

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.

circle-info

Caso queira aprender C, o Mente Binária tem um treinamento gratuito intitulado Programação Moderna em Carrow-up-right.

hashtag
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 variável de ambientearrow-up-right PATH corretamente.

Eis a lista de ferramentas:

  • nasmarrow-up-right -- Assembler que usaremos para nossos códigos em Assembly

  • gccarrow-up-right -- Compilador de C que usaremos e ensinarei o Inline Assembly

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

  • -- Emulador do MS-DOS e arquitetura x86

  • -- Emulador de várias arquiteturas diferentes, usaremos a i386

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

Qualquer dúvida sugiro que acesse o fórum do Mente Bináriaarrow-up-right a fim de tirar dúvidas e ter acesso a outros conteúdos.

Dicioarrow-up-right

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.

circle-info

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 GCCarrow-up-right por exemplo.

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

hashtag
Instruções

A arquitetura x86 é uma arquitetura CISCarrow-up-right 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).

hashtag
Modelo

A arquitetura x86 segue o modelo da arquitetura de Von Neumannarrow-up-right 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.

hashtag
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 HDarrow-up-right, 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.

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

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

circle-info

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

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:

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

circle-info

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

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

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

circle-exclamation

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.

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

circle-info

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.

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:

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.

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

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.

circle-exclamation

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.

Introdução

Livro gratuito sobre Assembly x86 e x86-64

Este livro é mais um projeto do Mente Bináriaarrow-up-right 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 Reversaarrow-up-right e diversos treinamentos gratuitosarrow-up-right.

circle-info

Se você está lendo isto no repositório do GitHubarrow-up-right, recomendo que leia na plataforma do GitBook clicando aquiarrow-up-right.

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

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

circle-info

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.

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

circle-exclamation

É muito comum as pessoas se confundirem e dizerem "linguagem assembler", o que está errado. Cuidado para não se confundir também.

hashtag
O autor

Meu nome é Luiz Felipe. Pronto, agora você sabe tudo sobre mim.

| | | |

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

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:

hashtag
Mapeamento dos registradores

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.

hashtag
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

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.

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
4d 3c 2b 1a
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
IA-32e
  • 64-bit (64 bit)

  • Compatibility mode (32 bit)

Modo de operação

Largura do barramento interno

Real mode / Modo real

16 bit

Protected mode / Modo protegido

32 bit

64-bit submode / Submodo de 64-bit

64 bit

dosboxarrow-up-right
qemuarrow-up-right
makearrow-up-right
  • 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 aquiarrow-up-right.

  • 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 softwarearrow-up-right, é 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 Assemblyarrow-up-right mas também pela previsibilidade do código que eu já mencionei (você vai entender o que é isso se continuar lendo o livro).

  • decompilerarrow-up-right
    GitHubarrow-up-right
    Facebookarrow-up-right
    Twitterarrow-up-right
    Mediumarrow-up-right
    Perfil no Mente Bináriaarrow-up-right
    CC BY-SA 3.0arrow-up-right
    Atribuição-CompartilhaIgual 3.0 Não Adaptada (CC BY-SA 3.0)arrow-up-right

    RAX

    Instrução

    Operando

    Ação

    CALL

    endereço

    Chama um procedimento no endereço especificado

    RET

    ???

    Retorna de um procedimento

    Tipo

    Tamanho em x86-64

    Registrador

    char

    1 byte

    AL

    short int

    2 bytes

    AX

    int

    4 bytes

    EAX

    char *

    8 bytes

    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;
    }
    Afim de aumentar a versatilidade no uso de registradores, para poder manipular dados de tamanhos variados no mesmo espaço de memória do registrador, alguns registradores são subdivido em registradores menores. Isso seria o "mapeamento" dos registradores que faz com que vários registradores de tamanhos diferentes compartilhem o mesmo espaço. Se você entende como funciona uma union em C já deve ter entendido a lógica aqui.

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

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

    circle-info

    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.

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

    circle-info

    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.

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

    circle-info

    Esses registradores novos podem ser usados da maneira que você quiser, assim como os outros registradores de propósito geral.

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

    hardware stack
    .

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

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

    bits 64
    
    global assembly
    assembly:
      mov  rax, 12345
      push rax
    
      mov rax, 112233
      pop rax
      ret
    
    #include <stdio.h>
    
    int assembly(
    

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

    hashtag
    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(
    

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

    circle-info

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

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

    hashtag
    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

    circle-info

    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.

    circle-info

    Experimente modificar os valores de RBX e RCX, e também teste usando outros Jcc.

    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.

    hashtag
    Formato da instrução

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

    • Um operando registrador

    • Um operando registrador OU operando na memória

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

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

    circle-info

    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.

    hashtag
    Notação

    circle-info

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

    As seguintes nomenclaturas serão utilizadas:

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

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

    circle-info

    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.

    hashtag
    MOV | Move

    Copia o valor do operando fonte para o operando destino.

    hashtag
    ADD

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

    hashtag
    SUB | Subtract

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

    hashtag
    INC | Increment

    Incrementa o valor do operando destino em 1.

    hashtag
    DEC | Decrement

    Decrementa o valor do operando destino em 1.

    hashtag
    MUL | Multiplicate

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

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

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

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

    hashtag
    AND

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

    hashtag
    OR

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

    hashtag
    XOR | Exclusive OR

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

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

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

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

    hashtag
    SHR | Shift Right

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

    hashtag
    CMP | Compare

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

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

    hashtag
    CMOVcc | Conditional Move

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

    hashtag
    NEG | Negate

    Inverte o sinal do valor numérico do operando.

    hashtag
    NOT

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

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

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

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

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

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

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

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

    circle-info

    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 no tópico 3.4.1.4 Code Alignment.

    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.

    circle-info

    O endereçamento de um operando também pode ser chamado de endereço efetivo, ou em inglês, effective address.

    triangle-exclamation

    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.

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

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

    Exemplos:

    circle-info

    SIB é sigla para Scale, Index and Base. Que são os três valores usados para calcular o endereço efetivo.

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

    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:

    circle-info

    Na configuração padrão do NASM o endereçamento é montado como um endereço absoluto (default abs). Mais à frente irei abordar o assunto de (PIE) e aí entenderemos qual é a utilidade de se usar um endereço relativo ao RIP.

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

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

    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.

    hashtag
    Seções

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

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

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

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

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

    circle-info

    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.

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

    circle-exclamation

    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:

    circle-info

    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.

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

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

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

    hashtag
    Expressões

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

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

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

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

    Usando o cifrão fica:

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

    E esse seria o equivalente com $$:

    No caso de você usar o NASM para um formato de arquivo binário puro (raw binary), onde não existem seções, o $$ é equivalente ao endereço do início do binário.

    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.

    hashtag
    Convenção de syscall x86-64

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

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

    circle-info

    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.

    hashtag
    exit

    Vou ensinar aqui a usar a syscall mais simples que é a exit, ela basicamente finaliza a execução do programa. Ela recebe um só argumento que é o status de saída do programa. Esse número nada mais é do que um valor definido para o sistema operacional que indica as condições da finalização do programa.

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

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

    No Linux se quiser ver o status de saída de um programa a variável especial $? expande para o status de saída do último programa executado. Então você pode executar nossa PoC da seguinte forma:

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

    hashtag
    Outras syscalls

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

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

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

    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:

    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.

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

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

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

    hashtag
    Strings

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

    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.

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

    triangle-exclamation

    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

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

    triangle-exclamation

    Você só pode usar um operando na memória por instrução. Então não é possível fazer algo como: mov [0x100], [0x200]

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

    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.

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

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

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

    circle-info

    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.

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

    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.

    hashtag
    Arquivo objeto

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

    hashtag
    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

    circle-info

    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

    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.

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

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

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

    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.

    hashtag
    %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].

    hashtag
    %undef

    Simplesmente apaga um macro anteriormente declarado por %define.

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

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

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

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

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

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

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

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

    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.

    hashtag
    Ferramentas

    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.

    circle-exclamation

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

    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.

    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

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

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

    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.

    hashtag
    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

    ; 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
    ESP = ESP - 4
    [ESP] = operando
    operando = [ESP]
    ESP = ESP + 4
    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;
    mov eax, 777
    Mov Eax, 777
    MOV EAX, 777
    mov EAX, 777
    MoV EaX, 777
    %define nome              "valor"
    %define nome(arg1, arg2)  arg1 + arg2