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...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
Loading...
CapĂtulo explicando os principais tĂłpicos Ă respeito do Assembly e da arquitetura.
Para que fique mais prĂĄtico para todos, independentemente se estiverem usando Linux/Windows/MacOS/BSD/etc, usaremos a linguagem C como "ambiente" para escrever cĂłdigo e podermos ver o resultado. Certifique-se de ter o GCC/Mingw-w64 e o NASM instalados no seu sistema.
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.
O pacote GCC jĂĄ tem o assembler GAS que Ă© excelente mas prefiro usar aqui o NASM devido a vĂĄrios fatores, dentre eles:
O prĂ©-processador do NASM Ă© absurdamente incrĂvel.
O NASM tem uma sintaxe mais "legĂvel" comparada a sintaxe do GAS.
O NASM tem o ndisasm, vai ser Ăștil na hora de estudar o cĂłdigo de mĂĄquina.
Eu gosto do NASM.
Mais para frente no livro pretendo ensinar a usar o GAS também. Mas na base vamos usar só o NASM mesmo.
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 UNIX. 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 PoC 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 linkar 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 determinado recurso do GCC nĂŁo seja habilitado. O comando final acima seria somente a execução do nosso executĂĄvel test
em um sistema Linux. A execução do programa produziria o seguinte resultado no print abaixo, caso tudo tenha ocorrido bem.
Mantenha essa PoC guardada no seu computador para eventuais testes. VocĂȘ nĂŁo serĂĄ capaz de entender como ela funciona agora mas ela serĂĄ Ăștil para testar conceitos para poder vĂȘ-los na prĂĄtica. Eventualmente tudo serĂĄ explicado.
Caso vocĂȘ tenha o make instalado a minha recomendação Ă© que organize os arquivos em uma pasta especĂfica e use o Makefile abaixo.
Isso Ă© meio que gambiarra mas o importante agora Ă© ter um ambiente funcionando.
Se vocĂȘ nĂŁo conseguiu preparar nossa PoC aĂ no seu computador, acesse o fĂłrum do Mente BinĂĄria para tirar sua dĂșvida.
ConteĂșdo que serĂĄ apresentado neste livro
Neste livro vocĂȘ irĂĄ aprender Assembly da arquitetura x86 e x86-64 desde os conceitos base atĂ© conteĂșdo mais "avançado". Digo conceitos "base" e nĂŁo "bĂĄsico" porque infelizmente o termo "bĂĄsico" Ă© mal empregado na internet afora. As pessoas estĂŁo acostumadas a verem conteĂșdo bĂĄsico como algo superficial quando na verdade Ă© a parte mais importante para o aprendizado. Ă a partir dos conceitos bĂĄsicos que nĂłs conseguimos aprender todo o resto. Mas infelizmente devido ao mal uso do termo ele acabou sendo associado a uma enorme quantidade de conteĂșdo superficial encontrado na internet.
Significado de bĂĄsico: Que serve como base; essencial, basilar. O mais relevante ou importante de; fundamental. ~ Dicio
Portanto que fique de prĂ©vio aviso que o conteĂșdo bĂĄsico apresentado aqui nĂŁo deve ser pulado, ele Ă© de extrema importĂąncia e nĂŁo serĂĄ superficial como muitas vezes Ă© visto em outras fontes de conteĂșdo na internet.
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ĂĄrio, hexadecimal ou octal.
Por fim e o mais importante: VocĂȘ precisa de um computador da arquitetura x86 rodando um sistema operacional de 64-bit. Mas nĂŁo adianta apenas tĂȘ-lo, use-o para programar o que iremos aprender aqui.
Caso queira aprender C, o Mente Binåria tem um treinamento gratuito intitulado Programação Moderna em C.
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 ambiente PATH corretamente.
Eis a lista de ferramentas:
nasm -- Assembler que usaremos para nossos cĂłdigos em Assembly
gcc -- Compilador de C que usaremos e ensinarei o Inline Assembly
mingw-w64 -- Ă o porte do GCC para o Windows, caso use Windows instale esse especificamente e nĂŁo o MinGW do projeto original.
dosbox -- Emulador do MS-DOS e arquitetura x86
qemu -- Emulador de vĂĄrias arquiteturas diferentes, usaremos a i386
à recomendåvel que tenha o make, porém é opcional.
Qualquer dĂșvida sugiro que acesse o fĂłrum do Mente BinĂĄria a fim de tirar dĂșvidas e ter acesso a outros conteĂșdos.
Livro gratuito sobre Assembly x86 e x86-64
Este livro é mais um projeto do Mente Binåria cujo o intuito é ensinar os fundamentos do Assembly x86 e x86-64. Nele serå abordado desde o zero até conceitos mais avançados a fim de dar um entendimento profundo e uma base sólida de conhecimento.
O Mente Binåria também tem um livro sobre Fundamentos de Engenharia Reversa e diversos treinamentos gratuitos.
Se vocĂȘ estĂĄ lendo isto no repositĂłrio do GitHub, recomendo que leia na plataforma do GitBook clicando aqui.
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:
Engenharia Reversa (de software) Entender como um software funciona na prĂĄtica sĂł Ă© de fato possĂvel se vocĂȘ souber Assembly. Usar decompiler atĂ© funciona em parte mas nĂŁo pense que isso tem grandes utilidades o tempo todo.
Exploração de binårios Para quem quer fazer testes de segurança em binårios, Assembly é indispensåvel.
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).
Otimização de cĂłdigoÂČ TambĂ©m dĂĄ para fazer otimizaçÔes de cĂłdigo para processadores especĂficos manualmente. Podemos ver vĂĄrios exemplos desta façanha no famigerado ffmpeg. Veja aqui.
Tarefas de "baixo nĂvel" Algumas instruçÔes especĂficas de determinados processadores sĂŁo basicamente impossĂveis de serem utilizadas em uma linguagem de alto nĂvel. Uma tarefa bĂĄsica em Assembly, como chamar uma interrupção de software, Ă© impossĂvel de ser feita em uma linguagem de alto nĂvel como C.
C & Assembly Termino falando que hĂĄ uma incrĂvel vantagem de se saber Assembly quando se programa em C. NĂŁo sĂł pelo inline Assembly mas tambĂ©m pela previsibilidade do cĂłdigo que eu jĂĄ mencionei (vocĂȘ vai entender o que Ă© isso se continuar lendo o livro).
Como podemos ver, o conhecimento da linguagem Assembly nos dias atuais Ă©, na maioria dos casos, mais Ăștil do que programar nela diretamente.
De forma resumida Assembly é uma notação em formato de texto das instruçÔes do código de måquina de uma determinada arquitetura. A "arquitetura" ao qual me refiro aqui é a ISA (Instruction Set Architecture) onde ela cria um modelo abstrato de um computador e tem diversas instruçÔes que são computadas pelo processador, essas instruçÔes são o que é conhecido como código de måquina.
Falando de um ponto de vista humano, entender instruçÔes em código de måquina é uma tarefa årdua. Por isso os manuais da ISA costumam simplificar o entendimento da instrução se referindo a ela com uma notação em texto, onde essa notação é conhecida como mnemÎnico e tem o fim de facilitar o entendimento e a memorização da instrução do processador.
E é justamente dessa notação em texto dos manuais que surgiu o que a gente conhece hoje como "linguagem Assembly". Onde na verdade não existe uma linguagem Assembly (ou ASM para abreviar) mas sim cada ISA tem uma linguagem ASM diferente.
Os programadores antigamente escreviam o código usando a notação em texto (Assembly) e depois, a partir dela, convertiam para código de måquina manualmente. Mas felizmente hoje em dia existem softwares que fazem essa conversão de maneira automåtica e eles são chamados de assemblers.
Apesar de eu estar utilizando letra maiĂșscula para escrever a palavra "Assembly" ela na verdade nĂŁo Ă© um substantivo prĂłprio mas sim a palavra "montagem" em inglĂȘs. Estou fazendo isso meramente por uma estilização pessoal.
O assembler é um compilador que converte código em Assembly para o código de måquina. Muito antigamente, nos primórdios da computação, uma pessoa manualmente convertia os códigos em Assembly para código de måquina. Depois inventaram uma måquina para automatizar essa tarefa sendo chamada de "assembler". Nos dias atuais o "assembler" é um software de computador.
à muito comum as pessoas se confundirem e dizerem "linguagem assembler", o que estå errado. Cuidado para não se confundir também.
Meu nome Ă© Luiz Felipe. Pronto, agora vocĂȘ sabe tudo sobre mim.
GitHub | Facebook | Twitter | Medium | Perfil no Mente BinĂĄria
Este conteĂșdo estĂĄ sendo compartilhado sobre os termos da licença CC BY-SA 3.0. 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:
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.
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.
No livro eu tento o mĂĄximo possĂvel abordar conteĂșdo que, com base no que jĂĄ foi explorado antes, possa ser testado para ver os resultados. Bem como tambĂ©m tento apresentar os resultados para nĂŁo deixar Ă margem da imaginação.
Nosso cĂ©rebro tem o hĂĄbito de fabricar informaçÔes. Quando estudamos algo novo e nos falta informação sobre, costumamos nĂłs mesmos criarmos essas informaçÔes faltantes. Lacunas de conteĂșdo sĂŁo preenchidas com informaçÔes hipotĂ©ticas e, muitas vezes, de uma maneira quase irreversĂvel. Quando começamos a aprender algo costumamos ter muito viĂ©s ao conteĂșdo que consumimos primeiro. Se este viĂ©s for baseado em uma informação fabricada pela nossa mente ela vai nos induzir ao erro.
à devido a esse håbito que muita gente desenvolve uma superioridade ilusória jå que onde não deveria ter informação alguma tem informação fabricada. O que faz algumas pessoas crerem que "sabem muito" sobre o assunto. Esse é o chamado efeito Dunning-Kruger.
Nada acontece por mĂĄgica, tudo tem um motivo e uma explicação. Infelizmente eu nĂŁo sei todas as explicaçÔes do mundo e nem todos os motivos. Mas Ă© muito importante se perguntar o porque das coisas constantemente. No decorrer do livro eu tento responder o mĂĄximo possĂvel o porque das coisas.
Repare que este tĂłpico em si estĂĄ explicando alguns porquĂȘs.
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).
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.
Lembre-se de sempre se perguntar o porque das coisas. Se vocĂȘ sabe o que fazer mas nĂŁo sabe o porque, entĂŁo vocĂȘ nĂŁo aprendeu mas sim decorou. Decorar Ă© o pior inimigo do aprendizado. Quando vocĂȘ entende o porque das coisas vocĂȘ nĂŁo precisa ficar decorando, a informação vai naturalmente para a sua memĂłria de longo prazo. Detalhe que com "decorar" eu quero dizer a tentativa repetitiva de memorizar uma informação sem entendĂȘ-la, claro que lembrar das coisas Ă© necessĂĄrio para o aprendizado. Quer testar se vocĂȘ decorou ou aprendeu? Ă sĂł vocĂȘ tentar explicar o porque tal coisa Ă© de tal forma. Se nĂŁo conseguir Ă© porque nĂŁo aprendeu.
JĂĄ teve ou tem muita dificuldade em aprender algo? Provavelmente Ă© porque tem muitos "por quĂȘ?" nĂŁo respondidos. VocĂȘ deve estar tentando decorar mas nĂŁo estĂĄ conseguindo porque Ă© muita informação. Que tal se perguntar alguns "por quĂȘ?" e depois procurar pelas respostas?
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.
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.
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Ă.
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.
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.
Essa arquitetura nasceu no 8086, que foi um microprocessador da Intel que fez grande sucesso. DaĂ em diante a Intel lançou outros processadores baseados na arquitetura do 8086 ganhando nomes como: 80186, 80286, 80386 etc. DaĂ surgiu a nomenclatura 80x86 onde o x representaria um nĂșmero qualquer, e depois a nomenclatura foi abreviada para apenas x86.
A arquitetura evoluiu com o tempo e foi ganhando adiçÔes de tecnologias, porĂ©m sempre mantendo compatibilidade com os processadores anteriores. O processador que vocĂȘ tem aĂ pode rodar cĂłdigo programado para o 8086 sem problema algum.
Mais para frente a AMD criou a arquitetura x86-64, que Ă© um superconjunto da arquitetura x86 da Intel e adiciona o modo de 64 bit. Nos dias atuais a Intel e a AMD fazem um trabalho em conjunto para a evolução da arquitetura, por isso os processadores das duas fabricantes sĂŁo compatĂveis.
Ou seja, x86 Ă© um nome genĂ©rico para se referir a uma famĂlia de arquiteturas de processadores. Por motivos de simplicidade eu vou me referir as arquiteturas apenas como x86, mas na prĂĄtica estamos abordando trĂȘs arquiteturas neste livro:
AMD64 e Intel64 sĂŁo os nomes das implementaçÔes da AMD e da Intel para a arquitetura x86-64, respectivamente. Podemos dizer aqui que sĂŁo sinĂŽnimos jĂĄ que as implementaçÔes sĂŁo compatĂveis. Um software compilado para x86 consegue tanto rodar em um processador Intel como tambĂ©m AMD. SĂł fazendo diferença Ă© claro em detalhes de otimização que sĂŁo especĂficos para determinados processadores. Bem como tambĂ©m algumas tecnologias exclusivas de cada uma das fabricantes.
Comumente um compilador nĂŁo irĂĄ gerar cĂłdigo usando tecnologia exclusiva, afim de aumentar a portabilidade. Alguns compiladores aceitam que vocĂȘ passe uma flag na linha de comando para que eles otimizem o cĂłdigo usando tecnologias exclusivas, como o GCC por exemplo.
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:
A arquitetura x86 Ă© uma arquitetura CISC que, resumindo, Ă© uma arquitetura com um conjunto complexo de instruçÔes. Falando de maneira leviana isso significa que hĂĄ vĂĄrias instruçÔes e cada uma delas tem um nĂvel de complexidade completamente variada. Boa parte das instruçÔes sĂŁo complexas na arquitetura x86. Uma instrução "complexa" Ă© uma instrução que faz vĂĄrias operaçÔes.
Cada instrução do cĂłdigo de mĂĄquina tem um tamanho que pode variar de 1 atĂ© 15 bytes. E cada instrução consome um nĂșmero de ciclos diferente (devido a sua complexidade variada).
A arquitetura x86 segue o modelo da arquitetura de Von Neumann onde esse, mais uma vez resumindo, trabalha principalmente usando uma unidade central de processamento (CPU) e uma memĂłria principal.
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.
Uma porta fĂsica Ă© um barramento do processador usado para se comunicar com o restante do hardware. Por exemplo para poder usar a memĂłria secundĂĄria, o HD, usamos uma porta fĂsica para enviar e receber dados do dispositivo. O gerenciamento desta comunicação Ă© feito pelo chipset da placa-mĂŁe.
Do ponto de vista do programador uma porta fĂsica Ă© sĂł um nĂșmero especificado na instrução, muito parecido com uma porta lĂłgica usada para comunicação em rede.
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.
Quem dera um processador fosse tĂŁo simples assim, jĂĄ mencionei que o manual da Intel tem mais de 5 mil pĂĄginas? Deixei de abordar muita coisa aqui mas que fique claro que os processadores da arquitetura x86 tem vĂĄrias outras tecnologias, como o 3DNow! da AMD e o SSE da Intel.
Os processadores da AMD também implementam o SSE, jå o 3DNow! é exclusivo dos processadores da AMD.
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:
Os tais "bit" que sĂŁo muito conhecidos mas pouco entendido, na verdade Ă© simplesmente uma referĂȘncia a largura do barramento interno do processador quando ele estĂĄ em determinado modo de operação. A largura do barramento interno do processador nada mais Ă© que o tamanho padrĂŁo de dados que ele pode processar de uma Ășnica vez.
Imagine uma enorme via com 16 faixas e no final dela um pedĂĄgio, isso significa que 16 carros serĂŁo atendidos por vez no pedĂĄgio. Se Ă© necessĂĄrio atender 32 carros entĂŁo serĂĄ necessĂĄrio duas vezes para atender todos os carros, jĂĄ que apenas 16 podem ser atendidos de uma Ășnica vez. A largura de um barramento nada mais Ă© que uma "via de bits", quanto mais largo mais informação pode ser enviada de uma Ășnica vez. O que teoricamente aumenta a eficiĂȘncia.
No caso do barramento interno do processador seria a "via de bits" que o processador usa em todo o seu sistema interno, desconsiderando a comunicação com o hardware externo que é feita pelo barramento externo e não necessariamente tem o mesmo tamanho do barramento interno.
Também existe o barramento de endereço, mas não vamos abordar isso agora.
Pelo que nĂłs vimos acima entĂŁo na verdade um "sistema operacional de 64 bit" nada mais Ă© que um sistema operacional que executa em submodo de 64-bit. Ah, mas aĂ fica a pergunta:
Se estĂĄ rodando em 64 bit como Ă© possĂvel executar cĂłdigo de 32 bit?
Isso Ă© possĂvel porque existem mais modos de operação do que os que eu jĂĄ mencionei. Reparou que eu disse "submodo" de 64-bit? Ă porque na verdade o 64-bit nĂŁo Ă© um modo principal mas sim um submodo. A hierarquia de modos de operação de um processador Intel64 ficaria da seguinte forma:
Real mode (16 bit)
Protected mode (32 bit)
SMM (nĂŁo vamos falar deste modo, mas ele existe)
IA-32e
64-bit (64 bit)
Compatibility mode (32 bit)
O modo IA-32e Ă© uma adição dos processadores x86-64. Repare que ele tem outro submodo chamado "compatibility mode", ou em portuguĂȘs, "modo de compatibilidade".
NĂŁo confundir com o modo de compatibilidade do Windows, ali Ă© uma coisa diferente que leva o mesmo nome.
O modo de compatibilidade serve para obter compatibilidade com a arquitetura IA-32. Um sistema operacional pode setar para que código de apenas determinado segmento na memória rode nesse modo, permitindo assim que ele execute código de 32 e 64 bit paralelamente (supondo que o processador esteja em modo IA-32e). Por isso que seu Debian de 64 bit consegue rodar softwares de 32 bit, assim como o seu Windows 10 de 64 bit também consegue.
Lembra que o antigo Windows XP de 32 bit era capaz de rodar programas de 16 bit do MS-DOS? Isto era possĂvel devido ao modo Virtual-8086 que, de maneira parecida com o compatibility mode, permite executar cĂłdigo de 16 bit enquanto o processador estĂĄ em protected mode. Nos processadores atuais o Virtual-8086 nĂŁo Ă© um submodo de operação do protected mode mas sim um atributo que pode ser setado enquanto o processador estĂĄ executando nesse modo.
Repare que rodando em compatibility mode nĂŁo Ă© possĂvel usar o modo Virtual-8086. Ă por isso que o Windows XP de 32 bit conseguia rodar programas do MS-DOS mas o XP de 64 bit nĂŁo.
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.
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:
No nasm se pode usar o ponto-vĂrgula ;
para comentĂĄrios que Ășnica linha, equivalente ao //
em C.
ComentĂĄrios de mĂșltiplas linhas podem ser feitos usando a diretiva prĂ©-processada %comment
para iniciar o comentĂĄrio e %endcomment
para finalizĂĄ-lo. Exemplo:
NĂșmeros 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:
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.
As instruçÔes em Assembly seguem a premissa de especificar uma operação e seus operandos. Na arquitetura x86 uma instrução pode nĂŁo ter operando algum e chegar atĂ© trĂȘs operandos.
Algumas instruçÔes alteram o valor de um ou mais operandos, que pode ser um endereçamento na memória ou um registrador. Nas instruçÔes que alteram o valor de apenas um operando ele sempre serå o operando mais à esquerda. Um exemplo pråtico é a instrução mov:
O mov
especifica a operação enquanto o eax
e o 777
são os operandos. Essa instrução altera o valor do operando destino eax
para 777
. Exemplo de pseudo-cĂłdigo:
Da mesma forma que nĂŁo Ă© possĂvel fazer 777 = eax
em 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
O endereçamento em Assembly x86 é basicamente um cålculo para acessar determinado valor na memória. O resultado deste cålculo é o endereço na memória que o processador irå acessar, seja para ler ou escrever dados no mesmo. Uså-se os colchetes []
para denotar um endereçamento. Ao usar colchetes como operando vocĂȘ estĂĄ basicamente acessando um valor na memĂłria. Por exemplo poderĂamos alterar o valor no endereço 0x100
usando a instrução mov para o valor contido no registrador eax
.
Como eu jå mencionei o valor contido dentro dos colchetes é um cålculo. Vamos aprender mais à respeito quando eu for falar de endereçamento na memória.
VocĂȘ sĂł pode usar um operando na memĂłria por instrução. EntĂŁo nĂŁo Ă© possĂvel fazer algo como:
mov [0x100], [0x200]
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.
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:
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.
Um rĂłtulo local, em inglĂȘs local label, Ă© basicamente um rĂłtulo que hierarquicamente estĂĄ abaixo de outro rĂłtulo. Para definir um rĂłtulo local podemos simplesmente adicionar um ponto .
como primeiro caractere do nosso rĂłtulo. Veja o exemplo:
Dessa forma o nome completo de .subrotulo
Ă© na verdade meu_rotulo.subrotulo
. As instruçÔes que estejam hierarquicamente dentro do rótulo "pai" podem acessar o rótulo local usando de sua nomenclatura com .
no inĂcio do nome ao invĂ©s de citar o nome completo. Como no exemplo:
NĂŁo se preocupe se nĂŁo entendeu direito, isso aqui Ă© apenas para ver a sintaxe. Vamos aprender mais sobre os rĂłtulos e sĂmbolos depois.
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.
Nome oficial
Nome alternativo
Bit
8086
IA-16
16
IA-32
i386
32
x86-64
i686
64
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
Exemplo | Formato |
0b0111 | BinĂĄrio |
0o10 | Octal |
9 | Decimal |
0x0a | Hexadecimal |
11.0 | Ponto flutuante |
Representação | Explicação |
"String" | String normal |
'String' | String normal, equivalente a usar " |
`String\n` | String que aceita caracteres de escape no estilo da linguagem C. |
Nome | Nome estendido | Tamanho do operando (em bytes) |
byte | 1 |
word | 2 |
dword | double word | 4 |
qword | quad word | 8 |
tword | ten word | 10 |
oword | 16 |
yword | 32 |
zword | 64 |
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.
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.
Uma seção no arquivo objeto nada mais Ă© que uma maneira de agrupar dados no arquivo. Ă como criar um grupo novo e dar um sentido para ele. TrĂȘs exemplos principais de seçÔes sĂŁo:
A seção de código, onde o código que é executado pelo processador fica.
Seção de dados, onde variåveis são alocadas.
Seção de dados nĂŁo inicializada, onde a memĂłria serĂĄ alocada dinamicamente ao carregar o executĂĄvel na memĂłria. Geralmente usada para variĂĄveis nĂŁo inicializadas, isto Ă©, variĂĄveis que nĂŁo tĂȘm um valor inicial definido.
Na pråtica se pode definir quantas seçÔes quiser (dentro do limite suportado pelo formato de arquivo) e para quais propósitos quiser também. Podemos até mesmo ter mais de uma seção de código, mais de uma seção de dados etc. O código em C é organizado pelo compilador, no nosso caso o GCC, e por isso nós não fizemos esse tipo de organização manualmente.
Existem quatro seçÔes principais que podemos usar no nosso cĂłdigo e o linker irĂĄ resolvĂȘ-las corretamente sem que nĂłs precisamos dizer a ele como fazer seu trabalho. O NASM tambĂ©m reconhece essas seçÔes como "padrĂŁo" e jĂĄ configura os atributos delas corretamente.
.text
-- Usada para armazenar o cĂłdigo executĂĄvel do nosso programa.
.data
-- Usada para armazenar dados inicializados do programa, por exemplo uma variĂĄvel global.
.bss
-- Usada para reservar espaço para dados não-inicializados, por exemplo uma variåvel global que foi declarada mas não teve um valor inicial definido.
.rodata
ou .rdata
-- Usada para armazenar dados que sejam somente leitura (readonly), por exemplo uma constante que não deve ter seu valor alterado em tempo de execução.
Esses nomes de seçÔes são padronizados e códigos em C geralmente usam essas seçÔes com esses mesmos nomes.
SeçÔes tem flags que definem atributos para a seção, as trĂȘs flags principais e que nos importa saber Ă©:
read
-- Då permissão de leitura para a seção.
write
-- Då permissão de escrita para a seção, assim o código executado pode escrever dados nela.
exec
-- Då permissão de executar os dados contidos na seção como código.
Na sintaxe do NASM Ă© possĂvel definir essas flags manualmente em uma seção modificando seus atributos. Veja o exemplo abaixo:
Nos dois primeiros exemplos nada de fato foi alterado nas seçÔes porque esses jå são seus respectivos atributos padrão. Jå a seção .outra
nĂŁo tem nenhuma permissĂŁo padrĂŁo definida por nĂŁo ser nenhum dos nomes padronizados.
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.
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
.
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.
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.
Processadores da arquitetura x86 tem uma implementação nativa de uma pilha, que Ă© representada na memĂłria RAM, onde essa pode ser manipulada por instruçÔes especĂficas da arquitetura ou diretamente como qualquer outra regiĂŁo da memĂłria. Essa pilha normalmente Ă© chamada de hardware stack.
O registrador SP/ESP/RSP, Stack Pointer, serve como ponteiro para o topo da pilha podendo ser usado como referĂȘncia inicial para manipulação de valores na mesma. Onde o "topo" nada mais Ă© que o Ășltimo valor empilhado. Ou seja, o Stack Pointer estĂĄ sempre apontando para o Ășltimo valor na pilha.
A manipulação båsica da pilha é empilhar (push) e desempilhar (pop) valores na mesma. Veja o exemplo na nossa PoC:
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):
Entendendo o acesso Ă memĂłria RAM na prĂĄtica
O processador acessa dados da memĂłria principal usando o que Ă© chamado de endereço de memĂłria. Para o hardware da memĂłria RAM o endereço nada mais Ă© que um valor numĂ©rico que serve como Ăndice para indicar qual byte deve ser acessado na memĂłria. Imagine a memĂłria RAM como uma grande array com bytes sequenciais, onde o endereço de memĂłria Ă© o Ăndice de cada byte. Esse "Ăndice" Ă© chamado de endereço fĂsico (physical address).
PorĂ©m o acesso a operandos na memĂłria principal Ă© feito definindo alguns fatores que, apĂłs serem calculados pelo processador, resultam no endereço fĂsico que serĂĄ utilizado a partir do barramento de endereço (address bus) para acessar aquela regiĂŁo da memĂłria. Do ponto de vista do programador sĂŁo apenas algumas somas e multiplicaçÔes.
O endereçamento de um operando tambĂ©m pode ser chamado de endereço efetivo, ou em inglĂȘs, effective address.
Não tente ler ou modificar a memória com nossa PoC ainda. No final do tópico eu falo sobre a instrução LEA que pode ser usada para testar o endereçamento.
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:
Em IA-32 o cĂłdigo de mĂĄquina tem tambĂ©m o byte SIB que Ă© um novo modo de endereçamento. Enquanto em IA-16 nĂłs temos apenas uma base e um Ăndice, em IA-32 nĂłs ganhamos tambĂ©m um fator de escala. O fator de escala Ă© basicamente um nĂșmero que irĂĄ multiplicar o valor de Ăndice.
O valor do fator de escala pode ser 1, 2, 4 ou 8.
O registrador de Ăndice pode ser qualquer um dos registradores de propĂłsito geral exceto ESP.
O registrador de base pode ser qualquer registrador geral.
O deslocamento pode ser de 8 ou 32 bits.
Exemplos:
SIB Ă© sigla para Scale, Index and Base. Que sĂŁo os trĂȘs valores usados para calcular o endereço efetivo.
Em x86-64 segue a mesma premissa de IA-32 com alguns adendos:
Ă possĂvel usar registradores de 32 ou 64 bit.
Os registradores de R8 a R15 ou R8D a R15D podem ser usados como base ou Ăndice.
NĂŁo Ă© possĂvel mesclar registradores de 32 e 64 bits em um mesmo endereçamento.
O byte ModR/M tem um novo endereçamento RIP + deslocamento. Onde o deslocamento é necessariamente de 32 bits.
Exemplos:
Na sintaxe do NASM para usar um endereçamento relativo ao RIP deve-se usar a keyword rel para determinar que se trata de um endereço relativo. TambĂ©m Ă© possĂvel usar a diretiva default rel para setar o endereçamento como relativo por padrĂŁo. Exemplo:
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.
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:
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
.
à 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.
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
.
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.
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:
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:
A instrução na linha 8 nunca serå executada devido ao JMP na linha 6.
O registrador FLAGS também é estendido junto ao tamanho do barramento interno. Então temos:
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.
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:
O nome Jcc para se referir aos saltos condicionais vem do prefixo 'J' seguido de 'cc' para indicar uma condição, que é o formato da nomenclatura das instruçÔes.
Exemplo: JLE -- 'J' prefixo, 'LE' condição (Less or Equal)
Essa mesma nomenclatura também é usada para as outras instruçÔes condicionais, como por exemplo CMOVcc.
A maneira mais comum usada para setar as flags para um salto condicional é a instrução CMP. Ela recebe dois operandos e compara o valor dos dois, com base no resultado da comparação ela seta as flags corretamente. Agora um exemplo na nossa PoC:
Na linha 10 temos um Jump if Less or Equal para o rĂłtulo local .end
, e logo na linha anterior uma comparação entre RBX e RCX. Se o valor de RBX for menor ou igual a RCX, então o salto serå tomado e a instrução na linha 12 não serå executada. Desta forma temos algo muito parecido com o if
no pseudocĂłdigo abaixo:
Repare que a condição para o código ser executado é exatamente o oposto da condição para o salto ser tomado. Afinal de contas a lógica é que caso o salto seja tomado o código não serå executado.
Experimente modificar os valores de RBX e RCX, e também teste usando outros Jcc.
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:
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.
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.
Os registradores AX, BX, CX e DX sĂŁo subdivididos em 2 registradores cada um. Um dos registradores Ă© mapeado no seu byte mais significativo (Higher byte) e o outro no byte menos significativo (Lower byte). Reparou que os registradores sĂŁo uma de letra seguido do X? Para simplificar podemos dizer que os registradores sĂŁo A, B, C e D e o sufixo X serve para mapear todo o registrador, enquanto o sufixo H mapeia o Higher byte e o sufixo L mapeia o Lower byte.
Ou seja se alteramos o valor de AL na verdade estamos alterando o byte menos significativo de AX. E se alteramos AH entĂŁo Ă© o byte mais significativo de AX. Como no exemplo abaixo:
Esse mesmo mapeamento ocorre também nos registradores BX, CX e DX. Como podemos ver na tabela abaixo:
Do processador 80386 em diante, em real mode, Ă© possĂvel usar as versĂ”es estendidas dos registradores existentes em IA-32. PorĂ©m os registradores estendidos de x86-64 sĂł podem ser acessados em submodo de 64-bit.
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.
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:
Em x86-64 tambĂ©m Ă© possĂvel acessar o byte menos significativo dos registradores RSP, RBP, RSI e RDI. O que nĂŁo Ă© possĂvel em IA-32 ou IA-16. Eles sĂŁo mapeados em SPL
, BPL
, SIL
e DIL
.
Esses registradores novos podem ser usados da maneira que vocĂȘ quiser, assim como os outros registradores de propĂłsito geral.
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.
Na configuração padrão do NASM o endereçamento é montado como um endereço absoluto (default abs). Mais à frente irei abordar o assunto de (PIE) e aà entenderemos qual é a utilidade de se usar um endereço relativo ao RIP.
Repare que na linha 10 estamos usando um rĂłtulo local que foi explicado no tĂłpico sobre a .
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 .
Tipo | Tamanho em x86-64 | Registrador |
char | 1 byte | AL |
short int | 2 bytes | AX |
int | 4 bytes | EAX |
char * | 8 bytes | RAX |
FLAGS | EFLAGS | RFLAGS |
16 bits | 32 bits | 64 bits |
Instrução | Nome estendido | Condição |
JE | Jump if Equal | Pula se for igual |
JNE | Jump if Not Equal | Pula se nĂŁo for igual |
JL | Jump if Less than | Pula se for menor que |
JG | Jump if Greater than | Pula se for maior que |
JLE | Jump if Less or Equal | Pula se for menor ou igual |
JGE | Jump if Greater or Equal | Pula se for maior ou igual |
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. |
Instrução | Operando | Ação |
CALL | endereço | Chama um procedimento no endereço especificado |
RET | ??? | Retorna de um procedimento |
IP | EIP | RIP |
16 bits | 32 bits | 64 bits |
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.
Programar em uma linguagem de baixo nĂvel como Assembly nĂŁo Ă© a mesma coisa de programar em uma linguagem de alto nĂvel como C. Ao programar em Assembly estamos escrevendo diretamente as instruçÔes que serĂŁo executadas pelo processador.
Não apenas isso como também estamos organizando todo o formato do arquivo de acordo com o formato final que queremos executar. Então é importante entender duas coisas, antes de mais nada: A arquitetura para a qual estamos programando e o formato de arquivo que queremos escrever.
A arquitetura é a x86, como jå sabemos. E um código que irå trabalhar com a linguagem C é compilado para um arquivo objeto. Por isso estudamos os conceitos båsicos da arquitetura x86 propriamente dita, e também estudamos um pouco do arquivo objeto.
Sem saber o que são seçÔes, o que å symbol table etc. não då para entender o que se estå fazendo. Por que o código em C consegue acessar um rótulo no meu código em Assembly? Por que dados despejados em .data
sĂŁo chamados de variĂĄveis e os em .text
sĂŁo chamados de cĂłdigo? Por que dados em .rodata
nĂŁo podem ser modificados e sĂŁo chamados de constantes? Por que "isso" Ă© considerado uma função e "isso" uma variĂĄvel? Os dois nĂŁo sĂŁo sĂmbolos?
Ao programar em Assembly nós não estamos apenas escrevendo as instruçÔes que o processador irå executar, estamos também construindo todo o arquivo binårio final manualmente.Felizmente o NASM facilita nossa vida ao formatar o arquivo binårio para o formato desejado, é a tal da opção -f elf64 ou -f win64 que passamos na linha de comando. Mas mesmo assim temos que dar informaçÔes para o NASM sobre o que fica aonde.
Em uma linguagem de alto nĂvel todos esse conceitos relacionados ao formato do arquivo binĂĄrio e da arquitetura do processador sĂŁo abstraĂdos. JĂĄ em uma linguagem de baixo nĂvel, esses conceitos tem muito pouca (ou nenhuma) abstração e precisamos lidar com eles manualmente.
Isso é necessårio porque estamos escrevendo diretamente as instruçÔes que o hardware, o processador, irå executar. E para poder se comunicar com o processador precisamos entender o que ele estå fazendo.
Imagine tentar instruir um funcionĂĄrio de uma empresa de entregas exatamente como ele deve organizar a carga e como ele deve entregĂĄ-la, porĂ©m vocĂȘ nĂŁo sabe o que Ă© a carga e nem para quem ela deve ser entregue. ImpossĂvel, nĂ©?
Estudar Assembly nĂŁo Ă© sĂł decorar instruçÔes e o que elas fazem, isso Ă© fĂĄcil atĂ© demais. Estudar Assembly Ă© estudar a arquitetura, o formato do executĂĄvel, como o executĂĄvel funciona, convençÔes de chamadas, caracterĂsticas do sistema operacional, caracterĂsticas do hardware etc... Ah, e estudar as instruçÔes tambĂ©m. Junte tudo isso e vocĂȘ terĂĄ um belo conhecimento para entender como um software funciona na prĂĄtica.
JĂĄ citei antes porque estudar Assembly Ă© Ăștil na introdução do livro. Mas sĂł decorar as instruçÔes nĂŁo Ă© Ăștil por si sĂł, a questĂŁo Ă© todo o resto que vocĂȘ irĂĄ aprender ao estudar Assembly.
Como jĂĄ vimos o assembler Ă© bem mais complexo do que simplesmente converter as instruçÔes que vocĂȘ escreve em cĂłdigo de mĂĄquina. Ele tem diretivas, pseudo-instruçÔes e prĂ©-processamento de cĂłdigo para formatar o cĂłdigo em Assembly e lhe dar mais poder na programação.
Ele tambĂ©m formata o cĂłdigo de saĂda para um formato especificado e nos permite escolher o modo de compilação das instruçÔes de 64, 32 ou 16 bits.
Ou seja, um cĂłdigo fonte em Assembly nĂŁo Ă© apenas instruçÔes mas tambĂ©m diretivas para a formatação do arquivo binĂĄrio que serĂĄ feita pelo assembler. Que Ă© muito diferente de uma linguagem de alto nĂvel como C que contĂ©m apenas instruçÔes e todo o resto fica abstraĂdo como se nem existisse.
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.
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]
.
Simplesmente apaga um macro anteriormente declarado por %define
.
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:
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:
Apaga um macro anteriormente definido com %macro
. O nĂșmero de argumentos especificado deve ser o mesmo utilizado na hora de declarar o macro.
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.
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.
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
.
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:
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.
Registrador | Apelido | Uso |
AX | Accumulator | Usado em instruçÔes de operaçÔes aritméticas para receber o resultado de um cålculo. |
BX | Base | Usado geralmente em endereçamento de memória para se referir ao endereço inicial, isto é, o endereço base. |
CX | Counter | Usado em instruçÔes de repetição de cĂłdigo (loops) para controlar o nĂșmero de repetiçÔes. |
DX | Data | Usado em operaçÔes de entrada e saĂda por portas fĂsicas para armazenar o dado enviado/recebido. |
SP | Stack Pointer |
BP | Base Pointer |
SI | Source Index | Em operaçÔes com blocos de dados, ou strings, esse registrador é usado para apontar para o endereço de origem de onde os dados serão lidos. |
DI | Destination Index | Trabalhando em conjunto com o registrador acima, esse aponta para o endereço destino onde os dados serão gravados. |
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".
Desta vez vamos escrever um cĂłdigo em Assembly sem misturar com C, serĂĄ um executĂĄvel do Linux (formato ELF64) fazendo chamadas de sistema diretamente. Vamos vĂȘ-lo logo:
Para compilar esse cĂłdigo basta usar o NASM especificando o format elf64 e desta vez iremos usar o linker do pacote GCC diretamente. O nome do executĂĄvel Ă© ld e o uso bĂĄsico Ă© bem simples, basta especificar o nome do arquivo de saĂda com -o. Ficando assim:
Na linha 5 definimos uma constante usando o sĂmbolo $ para pegar o endereço da instrução atual e subtraĂmos pelo endereço do rĂłtulo msg
. Isso resulta no tamanho do texto porque msg
aponta para o inĂcio da string e, como estĂĄ logo em seguida, $ seria o endereço do final da string.
final - inĂcio = tamanho
Como deve ter reparado usamos mais uma syscall, que foi a syscall write
. Essa syscall basicamente escreve dados em um arquivo. O primeiro argumento Ă© um nĂșmero que serve para identificar o arquivo para o qual queremos escrever os dados.
No Linux a saĂda e entrada de um programa nada mais Ă© que dados sendo escritos e lidos em arquivos. E isso Ă© feito por trĂȘs arquivos que estĂŁo por padrĂŁo abertos em um programa e tem sempre o mesmo file descriptor, sĂŁo eles:
Se quiser ver o código de implementação desta syscall no Linux, pode ver aqui.
Reparou que nosso programa tem um sĂmbolo _start
e que magicamente esse Ă© o cĂłdigo que o sistema operacional estĂĄ executando primeiro? Isso acontece porque o linker definiu o endereço daquele sĂmbolo como o entry point (ponto de entrada) do nosso programa.
O entry point nada mais Ă© o que o prĂłprio nome sugere, o endereço inicial de execução do programa. Eu sei o que vocĂȘ estĂĄ pensando:
Então a função main de um programa em C é o entry point?
A resposta é não! Um programa em C usando a libc tem uma série de códigos que são executados antes da main. E o primeiro deles, pasme, é uma função chamada _start
definida pela prĂłpria libc.
Na verdade qualquer sĂmbolo pode ser definido como o entry point para o executĂĄvel, nĂŁo faz diferença qual nome vocĂȘ dĂĄ para ele. SĂł que _start
Ă© o sĂmbolo padrĂŁo que o ld define como entry point.
Se vocĂȘ quiser usar um sĂmbolo diferente Ă© sĂł especificar com a opção -e. Por exemplo, podemos reescrever nosso Hello World assim:
E compilar assim:
Fåcil fazer um "Hello World", né? Ei, o que acha de fazer uns macros para melhorar o uso dessas syscalls a� Seria interessante também salvar os macros em um arquivo separado e incluir o arquivo com a diretiva %include
.
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.
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.
Como jĂĄ vimos na nossa PoC os sĂmbolos internos podem ser exportados para serem acessados a partir de outros arquivos objetos usando a diretiva global
. Podemos exportar mais de um sĂmbolo de uma vez separando cada nome de rĂłtulo por vĂrgula, exemplo:
Dessa forma um endereço especificado por um rótulo no nosso código fonte em Assembly pode ser acessado por código fonte compilado em outro arquivo objeto, tudo graças ao linker.
Mas as vezes tambĂ©m teremos a necessidade de acessar um sĂmbolo externo, isto Ă©, pertencente a outro arquivo objeto. Para podermos fazer isso existe a diretiva extern
que serve para declarar no arquivo objeto que estamos acessando um sĂmbolo que estĂĄ em outro arquivo objeto.
JĂĄ vimos que no arquivo objeto main.o havia na symbol table a declaração do uso do sĂmbolo assembly
que estava em um arquivo externo. A diretiva extern
serve para inserir essa informação na tabela de sĂmbolos do arquivo objeto de saĂda. A diretiva extern
segue a mesma sintaxe de global
:
Veja um exemplo de uso com nossa PoC:
Declaramos na linha 11 do arquivo main.c a função number
e no arquivo assembly.asm usamos a diretiva extern
na linha 2 para declarar o acesso ao sĂmbolo number
, que chamamos na linha 8.
Para o NASM nĂŁo faz diferença alguma aonde vocĂȘ coloca as diretivas extern e global porĂ©m por questĂ”es de legibilidade do cĂłdigo eu recomendo que use extern logo no começo do arquivo fonte e global logo antes da declaração do rĂłtulo.
Isso irĂĄ facilitar a leitura do seu cĂłdigo jĂĄ que ao ver o rĂłtulo imediatamente se sabe que ele foi exportado e ao abrir o arquivo fonte, imediatamente nas primeiras linhas, jĂĄ se sabe quais sĂmbolos externos estĂŁo sendo acessados.
Em Assembly não existe a declaração de uma variåvel porém assim como funçÔes existem como conceito e podem ser implementadas em Assembly, variåveis também são dessa forma.
Em um código em C variåveis globais ficam na seção .data
ou .bss
. A seção .data
do executåvel nada mais é que uma cópia dos dados contidos na seção .data
do arquivo binĂĄrio. Ou seja o que despejarmos de dados em .data
serĂĄ copiado para a memĂłria RAM e serĂĄ acessĂvel em tempo de execução e com permissĂŁo de escrita.
Para despejar dados no arquivo binårio existe a pseudo-instrução db
e semelhantes. Cada uma despejando um tamanho diferente de dados mas todas tendo a mesma sintaxe de separar cada valor numĂ©rico por vĂrgula. Veja a tabela:
As quatro Ășltimas dt, do, dy e dz
nĂŁo suportam que seja passado uma string como valor.
Podemos por exemplo guardar uma variåvel global na seção .data
e acessar ela a partir do código fonte em C, bem como também no próprio código em Assembly. Exemplo:
Repare que em C usamos a keyword extern
para especificar que a variĂĄvel global myVar
estaria em outro arquivo objeto, comportamento muito parecido com a diretiva extern
do NASM.
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:
Uma constante nada mais é que um apelido para representar um valor no código afim de facilitar a modificação daquele valor posteriormente ou então evitar um magic number. Podemos declarar uma constante usando a pseudo-instrução equ
:
Por convenção Ă© interessante usar nomes de constantes totalmente em letras maiĂșsculas para facilitar a sua identificação no cĂłdigo fonte em contraste com o nome de um rĂłtulo. Seja lĂĄ aonde a constante for usada no cĂłdigo fonte ela irĂĄ expandir para o seu valor definido. Exemplo:
A instrução na linha 2 alteraria o valor de EAX para 34.
Constantes em memória 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:
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.
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.
Jå expliquei a sintaxe de uma instrução no NASM mas não expliquei o formato em si da instrução no código de måquina. Para simplificar uma instrução pode ter os seguintes operandos:
Um operando registrador
Um operando registrador OU operando na memĂłria
Um operando imediato, que é um valor numérico que faz parte da instrução.
Basicamente sĂŁo trĂȘs tipos de operandos: Um registrador, valor na memĂłria e um valor imediato. Um exemplo de cada um para ilustrar sendo mostrado como o segundo operando de MOV:
Como demonstrado na linha 4 strings podem ser passadas como um operando imediato. O assembler irå converter a string em sua respectiva representação em bytes, só que é necessårio ter atenção em relação ao tamanho da string que não pode ser maior do que o operando destino.
SĂŁo trĂȘs operandos diferentes e cada um deles Ă© opcional, isto Ă©, pode ou nĂŁo ser utilizado pela instrução (opcional para a instrução e nĂŁo para nĂłs).
Repare que somente um dos operandos pode ser um valor na memória ou registrador, enquanto o outro é especificamente um registrador. à devido a isso que hå a limitação de haver apenas um operando na memória, enquanto que o uso de dois operandos registradores é permitido.
Irei utilizar uma explicação simplificada aqui que irå deixar muita informação importante de fora.
As seguintes nomenclaturas serĂŁo utilizadas:
Em alguns casos eu posso colocar um nĂșmero junto a essa nomenclatura para especificar o tamanho do operando em bits. Por exemplo r/m16
indica um operando registrador/memĂłria de 16 bits.
Em cada instrução irei apresentar a notação demonstrando cada combinação diferente de operandos que Ă© possĂvel utilizar. Lembrando que o operando destino Ă© o mais Ă esquerda, enquanto que o operando fonte Ă© o operando mais Ă direita.
Cada nome de instrução em Assembly Ă© um mnemĂŽnico, que Ă© basicamente uma abreviatura feita para fĂĄcil memorização. Pensando nisso leia cada instrução com seu nome extenso equivalente para lembrar o que ela faz. No tĂtulo de cada instrução irei deixar apĂłs um "|" o nome extenso da instrução para facilitar nessa tarefa.
Copia o valor do operando fonte para o operando destino.
Soma o valor do operando destino com o valor do operando fonte, armazenando o resultado no prĂłprio operando destino.
Subtrai o valor do operando destino com o valor do operando fonte.
Incrementa o valor do operando destino em 1.
Decrementa o valor do operando destino em 1.
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.
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.
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:
Faz uma operação E bit a bit nos operandos e armazena o resultado no operando destino.
Faz uma operação OU bit a bit nos operandos e armazena o resultado no operando destino.
Faz uma operação OU Exclusivo bit a bit nos operandos e armazena o resultado no operando destino.
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.
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:
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.
Mesmo caso que SHL porém faz o deslocamento de bits para a direita.
Compara o valor dos dois operandos e define RFLAGS de acordo.
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:
Basicamente uma instrução MOV condicional. Só irå definir o valor do operando destino caso a condição seja atendida.
Inverte o sinal do valor numérico do operando.
Faz uma operação NĂO bit a bit no operando.
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.
Compara os valores na memĂłria apontados por RDI e RSI, depois incrementa os registradores com o tamanho em bytes do dado.
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.
Compara o valor em uma parte mapeada de RAX com o valor na memĂłria apontado por RDI e depois incrementa RDI de acordo.
Copia o valor de uma parte mapeada de RAX e armazena na memĂłria apontada por RDI, depois incrementa RDI de acordo.
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.
Não faz nenhuma operação... Sério, não faz nada. Essa instrução normalmente é utilizada apenas como um "preenchimento" por compiladores afim de alinhar o endereço de código por motivos de otimização.
NĂŁo cabe a esse livro explicar porque esse alinhamento melhora a performance do cĂłdigo mas se estiver curioso estude Ă respeito do cache do processador e cache line. Para simplificar um desvio de cĂłdigo para um endereço que esteja prĂłximo ao inĂcio de uma linha de cache Ă© mais performĂĄtico.
Se vocĂȘ for um escovador de bits sugiro ler Ă respeito no manual de otimização da Intel no tĂłpico 3.4.1.4 Code Alignment.
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.
A convenção para efetuar uma chamada de sistema em Linux x86-64 Ă© bem simples, basta definir RAX para o nĂșmero da syscall que vocĂȘ quer executar e outros 6 registradores sĂŁo usados para passar argumentos. Veja a tabela:
O retorno da syscall também fica em RAX assim como na convenção de chamada da linguagem C.
Em syscalls que recebem menos do que 6 argumentos nĂŁo Ă© necessĂĄrio definir o valor dos registradores restantes porque nĂŁo serĂŁo utilizados.
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.
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 wrapper em C da syscall afim de entender mais detalhadamente o que cada uma delas faz. Por exemplo:
E para simplificar a consulta de syscalls no meu Linux eu implementei e uso a seguinte função em Bash. Fique à vontade para uså-la:
Exemplo de uso:
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.
Certifique-se de ter o instalado no seu sistema ou qualquer outro emulador do MS-DOS que vocĂȘ saiba utilizar. Sistemas compatĂveis com o MS-DOS, como o por exemplo, tambĂ©m podem ser utilizados.
TambĂ©m Ă© importante que o seu GCC possa compilar cĂłdigo para 64 e 32-bit. Em um Linux x86-64 ao instalar o GCC vocĂȘ jĂĄ pode compilar cĂłdigo de 64-bit. Para compilar para 32-bit basta instalar o pacote gcc-multilib. No Debian vocĂȘ pode fazer:
No Windows basta instalar o como jĂĄ mencionei.
Para testar se estĂĄ funcionando adequadamente vocĂȘ pode passar para o GCC a opção -m32 para compilar para 32-bit. Tente compilar um "Hello World" em C e veja se funciona:
Neste capĂtulo usaremos tambĂ©m uma ferramenta que vem junto com o NASM, o ndisasm. Ele Ă© um disassembler, um software que converte cĂłdigo de mĂĄquina em cĂłdigo Assembly. Se vocĂȘ tem o NASM instalado tambĂ©m tem o ndisasm disponĂvel.
O uso båsico é só especificar se as instruçÔes devem ser desmontadas como instruçÔes de 16, 32 ou 64 bits. Por padrão ele desmonta as instruçÔes como de 16-bit. Para mudar isso basta usar a opção -b e especificar os bits. Exemplo:
Explicando PIE e ASLR
Como vimos no tópico o processador calcula o endereço dos operandos na memória onde o resultado do cålculo serå o endereço absoluto onde o operando estå.
O problema disso é que o código que escrevemos precisa sempre ser carregado no mesmo endereço senão os endereços nas instruçÔes estarão errados. Esse problema foi abordado no , onde a diretiva org 0x100
precisa ser usada para que o NASM calcule o offset correto dos sĂmbolos senĂŁo os endereços estarĂŁo errados e o programa nĂŁo funcionarĂĄ corretamente.
Sistemas operacionais modernos tĂȘm um recurso de segurança chamado que dificulta a exploração de falhas de segurança no binĂĄrio. Resumidamente ele carrega os endereços dos segmentos do executĂĄvel em endereços aleatĂłrios ao invĂ©s de sempre no mesmo endereço. Com o ASLR desligado os segmentos sempre sĂŁo mapeados nos mesmos endereços.
PorĂ©m um cĂłdigo que acessa endereços absolutos jamais funcionaria apropriadamente com o ASLR ligado. Ă aĂ que entra o conceito de Position-independent executable (PIE) que nada mais Ă© que um executĂĄvel com cĂłdigo que somente acessa endereços relativos, ou seja, nĂŁo importa em qual endereço (posição) vocĂȘ carregue o cĂłdigo do executĂĄvel ele irĂĄ funcionar corretamente.
Na nossa PoC eu instruĂ para compilar o programa usando a flag -no-pie
no GCC para garantir que o linker nĂŁo iria produzir um executĂĄvel PIE jĂĄ que ainda nĂŁo havĂamos aprendido sobre o assunto. Mas depois de aprender a escrever cĂłdigo com endereçamento relativo em Assembly fique Ă vontade para remover essa flag e começar a escrever programas independentes de posição.
Jå vimos no tópico que em x86-64 se tem um novo endereçamento relativo à RIP. à muito mais simples escrever código independente de posição no modo de 64-bit devido a isso.
Podemos usar a palavra-chave rel
no endereçamento para dizer para o NASM que queremos que ele acesse um endereço relativo à RIP. Conforme exemplo:
Também podemos usar a diretiva default rel
para que o NASM compile todos os endereçamentos como relativos por padrĂŁo. Caso vocĂȘ defina o padrĂŁo como endereço relativo a palavra-chave abs
pode ser usada da mesma maneira que a palavra-chave rel
porém para definir o endereçamento como absoluto.
Um exemplo de PIE em modo de 64-bit:
Experimente compilar sem a flag -no-pie
para o GCC na hora de linkar:
Deveria funcionar normalmente. Mas experimente comentar a diretiva default rel
na linha 2 e compilar novamente, vocĂȘ vai obter um erro parecido com esse:
Repare que o erro foi emitido pelo linker (ld
) e nĂŁo pelo compilador em si. Acontece que como usamos um endereço absoluto o NASM colocou o endereço do sĂmbolo msg
na relocation table para ser resolvido pelo linker, onde o linker é quem definiria o endereço absoluto do mesmo.
SĂł que como removemos o -no-pie
o linker tentou produzir um PIE e por isso emitiu um erro avisando que aquela referĂȘncia para um endereço absoluto nĂŁo pode ser usada.
Como o endereço relativo ao Instruction Pointer sĂł existe em modo de 64-bit, nos outros modos de processamento nĂŁo Ă© nativamente possĂvel obter um endereçamento relativo. O compilador GCC resolve esse problema criando um pequeno procedimento cujo o Ășnico intuito Ă© obter o valor no topo da pilha e armazenar em um registrador. Conforme ilustração abaixo:
Quando a instrução add ebx, 12345
Ă© executada o valor de EBX
coincide com o endereço da própria instrução ADD.
Segmentação da memória RAM.
Na arquitetura x86 o acesso a memória RAM é comumente dividido em segmentos. Um segmento de memória nada mais é que um pedaço da memória RAM que o programador usa dando algum sentido a ele. Por exemplo, podemos usar um segmento só para armazenar variåveis. E usar outro para armazenar o código executado pelo processador.
Rodando sob um sistema operacional a segmentação da memĂłria Ă© totalmente controlada pelo kernel. Ou seja, nĂŁo tente fazer o que vocĂȘ nĂŁo tem permissĂŁo.
O barramento de endereço (address bus) Ă© um socket do processador que serve para se comunicar com a memĂłria principal (memĂłria RAM), ele indica o endereço fĂsico na memĂłria principal de onde o processador quer ler ou escrever dados. Basicamente a largura desse barramento indica quanta memĂłria o processador consegue endereçar jĂĄ que ele indica o endereço fĂsico da memĂłria que se deseja acessar.
Em IA-16 o barramento tem o tamanho padrĂŁo de 20 bits. Calculando temos o nĂșmero de bytes endereçåveis que sĂŁo exatamente 1 MiB de memĂłria que pode ser endereçada. Ă da largura do barramento de endereço que surge a limitação de tamanho da memĂłria RAM.
Em IA-32 e x86-64 o barramento de endereço tem a largura de 32 e 48 bits respectivamente.
Em IA-16 a segmentação é bem simplista e o código trabalha basicamente com 4 segmentos simultaneamente. Esses segmentos são definidos simplesmente alterando o registrador de segmento equivalente, cujo eles são:
Cada um desses registradores tem 16 bits de tamanho.
Quando acessamos um endereço na memória estamos usando um endereço lógico que é a junção de um segmento (segment) e um deslocamento (offset), seguindo o formato:
segment:offset
O tamanho do valor de offset Ă© o mesmo tamanho do registrador IP/EIP/RIP.
Veja por exemplo a instrução:
O endereçamento definido pelos colchetes Ă© na verdade o offset que, juntamente com o registrador DS, se obtĂ©m o endereço fĂsico. Ou seja o endereço lĂłgico Ă© DS:0x100
.
Podemos especificar um segmento diferente com a seguinte sintaxe do NASM:
A conversĂŁo de endereço lĂłgico para endereço fĂsico Ă© feita pelo processador com um cĂĄlculo simples:
O operador <<
denota um deslocamento de bits para a esquerda, uma operação shift left.
Além dos registradores de segmento do IA-16, em IA-32 se ganha mais dois registradores de segmento: FS
e GS
.
Em protected mode os registradores de segmento nĂŁo sĂŁo usados para gerar um endereço lĂłgico junto com o offset, ao invĂ©s disso, serve de seletor identificando o segmento por um Ăndice em uma tabela que lista os segmentos.
Em x86-64 não é mais usado esse esquema de segmentação de memória. CS, DS, ES e SS são tratados como se o endereço base fosse zero independentemente do valor nesses registradores.
Modificando os atributos da operação.
O código de måquina pode receber alguns bytes que antecedem o opcode que são chamados de prefixos. Eles basicamente servem para modificar atributos da operação que serå executada pelo processador. Abaixo vou falar de alguns prefixos e explicar o que eles fazem.
Esse prefixo, cujo o byte Ă© 0x66, serve para sobrescrever o atributo de operand-size. Ele basicamente alterna o atributo para o seu valor nĂŁo-padrĂŁo. Se o operand-size padrĂŁo Ă© de 32 bits ao usar esse prefixo ele alterna para 16 bits, e vice-versa. Observe abaixo:
No primeiro disassembly se a gente prestar atenção no cĂłdigo de mĂĄquina irĂĄ notar que a Ășnica diferença entre as duas instruçÔes, alĂ©m do tamanho do operando imediato, Ă© a presença do byte 0x66 logo antes do opcode 0xB8.
O NASM se encarrega de usar os prefixos adequados quando se mostram necessårios. Porém podemos usar as diretivas o16
, o32
e o64
antes da instrução no NASM para "forçar" o tamanho do operand-size para 16, 32 ou 64 bits respectivamente. Desta forma o NASM usaria os prefixos corretos se fossem necessårios.
à importante entender o que a instrução faz e o que cada atributo representa nela para poder fazer o uso correto destas diretivas.
Se vocĂȘ quiser forçar o uso de um prefixo em uma determinada instrução basta fazer o dump do byte logo antes da mesma. Exemplo:
db 0x66
mov eax, ebx
Obs.: Isso Ă© gambiarra. SĂł mostrei como curiosidade.
Esse prefixo de byte 0x67 segue a mesma lĂłgica do anterior, sĂł que desta vez alternando o tamanho do atributo de address-size. O NASM tem as diretivas a16
, a32
e a64
para explicitar um address-size para a instrução.
Um exemplo interessante de uso é com a instrução LOOP/LOOPcc
. Acontece que o que determina se essa instrução irå usar RCX, ECX ou CX é o address-size. Vamos supor o código de 16-bit:
Ao adicionar o prefixo 0x67 à instrução loop
eu sobrescrevo o address-size para 32 bits e faço a instrução usar o registrador ECX ao invés de CX. Me permitindo assim efetuar loops mais longos do que supostamente sou limitado.
E se por acaso eu compilar essa instrução para 32-bit, então o prefixo não serå adicionado pelo NASM e ECX ainda serå usado de qualquer forma.
Cuidado ao usar a64
ou o64
. Essa diretivas demandam o uso do prefixo REX que sĂł existe em submodo de 64-bit.
Esse nĂŁo Ă© um mas sim 6 prefixos diferentes usados para fazer a sobrescrita do segmento para CS, SS, DS, ES, FS ou GS.
Por que vocĂȘ nĂŁo tenta usar cada um desses prefixos para ver qual byte eles adicionam no cĂłdigo de mĂĄquina?
VocĂȘ jĂĄ deve ter notado que dĂĄ para brincar entre 32 e 16 bits, mas e os 64 bits? Bom, acontece que para tornar o x86-64 possĂvel foram feitas algumas gambiarras adaptaçÔes no machine code da arquitetura.
Veja este cĂłdigo:
Agora veja o que o disasembler nos diz sobre isso aĂ:
Pois é, os bytes que eu fiz o dump manualmente resultam na mesma operação. Só que o NASM sempre usa a primeira versão porque é menor, só tem 1 byte de tamanho em contraste com os 2 bytes da outra.
Essas duas instruçÔes equivalentes basicamente são:
Se eu escrevesse inc dword [ebx]
aà sim o NASM usaria a segunda instrução porém para incrementar um operando em memória.
Em 64-bit as instruçÔes inc reg
e dec reg
simplesmente nĂŁo existem. Elas foram assassinadas para dar lugar para um novo prefixo, o REX (inc r/m
e dec r/m
sĂŁo usadas em seu lugar).
O REX tem um campo de 4 bits que serve para trabalhar com operaçÔes em versão de 64 bits. Todas as alternùncias em relação a 32/64 bits é feita em um dos bits do prefixo REX, onde cada bit tem uma função diferente.
Basicamente o REX, incluindo todas as variaçÔes de combinaçÔes de cada bit, são todos os bytes entre 0x40 e 0x4F (só em 64-bit, é claro). Vejamos o exemplo:
Veja que para fazer o incremento de RCX o prefixo REX 0x48 foi utilizado. Em 32-bit esse byte foi interpretado como dec eax
.
InstruçÔes relacionadas a operaçÔes com blocos de dados, as famosas strings, podem ser acompanhadas por um prefixo para fazer com que a instrução seja repetida vårias vezes.
O uso desse prefixo é basicamente seguindo a mesma lógica das instruçÔes LOOP/LOOPE/LOOPNE
que usa uma parte do mapeamento de RCX como contador e Ă© possĂvel usar uma condição extra para sĂł repetir se a comparação der igual ou nĂŁo igual.
TambĂ©m Ă© possĂvel sobrescrever address-size para mudar o registrador usado como contador. Observe um exemplo de reimplementação de strlen()
usando esse prefixo e a instrução scasb
, tente entender o cĂłdigo:
REP e REPE são nomes diferentes para o mesmo prefixo. Sua lógica muda dependendo de em qual instrução foi utilizada, se em uma que faz comparação ou não.
Explicando os atributos das instruçÔes da arquitetura x86.
VocĂȘ jĂĄ deve ter reparado que as instruçÔes tĂȘm mais informaçÔes do que nĂłs explicitamos nelas. Por exemplo a instrução mov eax, [0x100]
implicitamente acessa a memĂłria a partir do segmento DS, alĂ©m de que magicamente a instrução tem um tamanho especĂfico de operando sem que a gente diga a ela.
Todas essas informaçÔes implĂcitas da instrução sĂŁo especificadas a partir de atributos que tem determinados valores padrĂ”es que podem ser modificados. Os trĂȘs atributos mais importantes para a gente entender Ă© o operand-size, address-size e segment.
O é um byte do código de måquina que especifica a operação a ser executada pelo processador. Em algumas instruçÔes mais alguns bits de outro byte da instrução em código de måquina é utilizado para especificar operaçÔes diferentes, que é o campo REG do byte . Como o jå citado far call
por exemplo.
Em protected mode nós podemos acessar operandos de 32, 16 ou 8 bits. O que define o tamanho do operando na instrução é o atributo operand-size.
InstruçÔes que lidam com operandos de 8 bits tem opcodes próprios só para eles. Mas as instruçÔes que lidam com operandos de 16 e 32 são as mesmas instruçÔes, mudando somente o atributo operand-size.
Vamos fazer um experimento com o cĂłdigo abaixo:
Compile esse cĂłdigo sem especificar qualquer formatação para o NASM, assim ele irĂĄ apenas colocar na saĂda as instruçÔes que escrevemos:
Depois disso use o ndisasm especificando para desmontar instruçÔes como de 32 bits, e depois, como de 16 bits. A saĂda ficarĂĄ como no print abaixo:
Repare que tanto em 32 quanto 16 bits a instrução mov ah, bh
não muda. Porém as instruçÔes mov eax, ebx
e mov ax, bx
são a mesma instrução.
Só o que muda de um para outro é o operand-size. Enquanto em 32-bit por padrão o operand-size é de 32 bits, em 16-bit ele é de 16-bit. Por isso que se dizemos para o disassembler que as instruçÔes são de 16-bit ele desmonta a instrução como mov ax, bx
. Porque é de fato essa operação que o processador em modo de 16-bit iria executar, não é um erro do disassembler.
E isso não vale só para registradores mas também para operandos imediatos e operandos em memória. Vamos fazer outro experimento:
Os comandos:
A saĂda fica assim:
Entendendo melhor a saĂda do ndisasm:
A esquerda fica o raw address da instrução em hexadecimal, que Ă© um nome bonitinho para o Ăndice do primeiro byte da instrução dentro do arquivo (contando a partir de 0).
No centro fica o cĂłdigo de mĂĄquina em hexadecimal. Os bytes sĂŁo mostrados na mesma ordem em que estĂŁo no arquivo binĂĄrio.
Por fim a direita o disassembly das instruçÔes.
Repare que quando dizemos para o ndisasm que as instruçÔes são de 32-bit ele faz o disassembly correto e mostra mov eax, 0x11223344
. Porém quando dizemos que é de 16-bit ele desmonta mov ax, 0x3344
seguido de uma instrução que não tem nada a ver com o que a gente escreveu.
Se vocĂȘ prestar atenção no cĂłdigo de mĂĄquina vai notar que nosso operando imediato 0x11223344 estĂĄ bem ali em little-endian logo apĂłs o byte B8 (o opcode). Porque Ă© assim que operandos imediatos sĂŁo dispostos no cĂłdigo de mĂĄquina, o valor imediato faz parte da instrução.
Agora no segundo caso quando dizemos que são instruçÔes de 16-bit a instrução não espera um operando de 4 bytes mas sim 2 bytes. Por isso o disassembler considera isto aqui como a instrução:
Os bytes 22 11
ficam sobrando e acabam sendo desmontados como se fossem uma instrução diferente. Na prĂĄtica o processador tambĂ©m executaria o cĂłdigo da mesma maneira que o ndisasm o desmontou, um dos motivos do porque cĂłdigo de modos de processamento diferentes nĂŁo sĂŁo compatĂveis entre si.
Em 64-bit o operand-size também tem 32 bits de tamanho por padrão.
O atributo de address-size define o modo de endereçamento. O tamanho padrão do offset acompanha a largura do barramento interno do processador (ou o tamanho do Instruction Pointer).
Quando o processador estå em modo de 16-bit pode-se usar endereçamento de 16 ou 32 bits. O mesmo vale para modo de 32-bit onde se usa por padrão 32 bits de endereçamento mas då para usar modo de endereçamento de 16 bits.
JĂĄ em 64-bit o address-size Ă© de 64 bits por padrĂŁo, mas tambĂ©m Ă© possĂvel usar endereçamento de 32 bits.
Apesar do offset e RIP no submodo de 64-bit serem de 64 bits (8 bytes) de tamanho, na pråtica o barramento de endereço do processador tem apenas 48 bits (6 bytes) de tamanho.
Os dois bytes mais significativos de RIP não são usados e devem sempre estarem zerados. Endereços acima de 0x0000FFFFFFFFFFFF não são vålidos em x86-64.
Mas o atributo não muda somente o tamanho do offset mas todo ele devido ao fato de haver diferenças entre o modo de endereçamento de 16-bit e de 32-bit. Observe o disassembly no print:
Agora observe a instrução mov byte [ebx], 42
compilada para 32-bit:
Desta vez a diferença entre 32-bit e 64-bit foi unicamente relacionado ao tamanho. Mas agora um Ășltimo experimento: mov byte [r12], 42
. Desta vez com um registrador que nĂŁo existe uma versĂŁo menor em 32-bit.
Existem duas diferenças: o registrador mudou para ESP e um byte 41 ficou sobrando antes da instrução. Dando um pouco de spoiler do próximo tópico do livro, o byte que sobrou ali é o prefixo REX que não existe em 32-bit e por isso foi interpretado como outra instrução.
Exemplos:
Determinadas instruçÔes usam segmentos especĂficos, como Ă© o caso da movsb
. Onde ela acessa DS:RSI
e ES:RDI
.
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.
O tamanho que o offset do endereço deve ter acompanha a largura do barramento interno. Então se estamos em real mode (16 bit), por padrão o offset deve ser de 16-bit. Ou seja, basicamente o mesmo tamanho do Instruction Pointer.
Essa Ă© a call
que jĂĄ usamos, nĂŁo tem segredo. Ela basicamente recebe um nĂșmero negativo ou positivo indicando o nĂșmero de bytes que devem ser desviados. Veja da seguinte forma:
A matemåtica båsica nos diz que "mais com menos é menos", ou seja, se o operando for negativo essa soma resultarå em uma subtração.
Existe um detalhe bem simples porém importante para conseguir lidar com endereços relativos corretamente. Quando o processador for executar a instrução o Instruction Pointer jå estarå apontando para a instrução seguinte. Ou seja desvios de fluxo para trås precisam contar os bytes da própria instrução em si, enquanto os para frente começam contando em zero que jå é a instrução seguinte na memória.
Claro que esse cĂĄlculo nĂŁo Ă© feito por nĂłs e sim pelo assembler, mas Ă© importante saber. Ah, e lembra do sĂmbolo $
que eu falei que o NASM expande para o endereço da instrução atual? Veja que ele não coincide com o valor de RIP, cujo o mesmo jå estå apontando para a instrução seguinte.
Por exemplo poderĂamos fazer uma chamada na prĂłpria instrução gerando um loop "infinito" usando a sintaxe:
Experimente ver com o ndisasm como essa instrução fica em código de måquina:
O primeiro byte (0xE8
) é o opcode da instrução, que é o byte do código de måquina que identifica a instrução que serå executada. Os bytes posteriores são o operando imediato (em little-endian). Repare que o endereço relativo estå como 0xFFFFFFFB
que equivale a -5
em decimal.
Diferente da chamada relativa que indica um nĂșmero de bytes a serem somados com RIP, numa chamada absoluta vocĂȘ passa o endereço exato de onde vocĂȘ quer fazer a chamada. VocĂȘ pode experimentar fazer uma chamada assim:
Se vocĂȘ passar rotulo
para a call
diretamente vocĂȘ estarĂĄ fazendo uma chamada relativa porque desse jeito vocĂȘ estarĂĄ passando um operando imediato. E a Ășnica call
que recebe valor imediato é a de endereço relativo, por isso o NASM passa o endereço relativo daquele rótulo. Mas ao definir o endereço do rótulo para um registrador ou memória o assembler irå passar o endereço absoluto dele.
à importante entender que tipo de operando cada instrução recebe para evitar se confundir sobre como o assembler irå montar a instrução. E sim, saber como a instrução é montada em código de måquina é muitas vezes importante.
As chamadas far (distante) sĂŁo todas absolutas e recebem no operando um valor seguindo o formato de especificar um offset seguido do segmento de 16-bit. No NASM um valor imediato pode ser passado da seguinte forma:
Onde o valor à esquerda especifica o segmento e o da direita o deslocamento. Detalhe que essa instrução não é suportada em 64-bit.
O segundo tipo de far call
, suportado em 64-bit, Ă© o que recebe como operando um valor na memĂłria. Mas perceba que temos um near call
que recebe o mesmo tipo de argumento, nĂŁo Ă© mesmo?
Por padrĂŁo o NASM irĂĄ montar as instruçÔes como near e nĂŁo far mas vocĂȘ pode evitar essa ambiguidade explicitando com keywords do NASM que sĂŁo bem intuitivas. Veja:
O near espera o endereço do offset na memória, não tem segredo. Mas o far espera o offset seguido do segmento. Em um sistema de 32-bit vamos supor que nosso procedimento estå no segmento 0xaaaa
e no offset 0xbbbb1111
. Em memĂłria o valor precisa estar assim em little-endian:
No NASM essa variĂĄvel poderia ser dumpada da seguinte forma:
Basicamente o far call
modifica o valor de CS e IP ao mesmo tempo, enquanto o near call
apenas modifica o valor de IP.
No código de måquina a diferença entre o far e o near call que usam o operando em memória estå no campo REG do byte ModR/M. O near tem o valor 2 e o far tem o valor 3. O opcode é 0xFF.
Se vocĂȘ nĂŁo entendeu isso aqui, nĂŁo se preocupa com isso. Mais para frente no livro serĂĄ escrito um capĂtulo sĂł para explicar o cĂłdigo de mĂĄquina da arquitetura.
Como talvez vocĂȘ jĂĄ tenha reparado intuitivamente a chamada far tambĂ©m preserva o valor de CS na stack e nĂŁo apenas o valor de IP (lembrando que IP jĂĄ estaria apontando para a instrução seguinte na memĂłria).
Por isso a instrução ret
também precisa ser diferente dentro de um procedimento que serå chamado com um far call. Ao invés de apenas ler o offset na stack ela precisa ler o segmento também, assim modificando CS e IP do mesmo jeito que o call
.
Repetindo que o NASM por padrão irå montar as instruçÔes como near então precisamos especificar para o NASM, em um procedimento que deve ser chamado como far, que queremos usar um ret
far.
Para isso podemos simplesmente adicionar um sufixo 'n' para especificar como near, que jĂĄ Ă© o padrĂŁo, ou o sufixo 'f' para especificar como far. Ficando:
Existe também uma outra opção de instrução ret
que recebe como operando um valor imediato de 16-bit que especifica um nĂșmero de bytes a serem desempilhados da stack.
Basicamente o que ele faz Ă© somar o valor de SP com esse nĂșmero, porque como sabemos a pilha cresce "para baixo". Ou seja se subtraĂmos valor em SP estamos fazendo a pilha crescer, se somamos estamos fazendo ela diminuir. Por exemplo, podemos escrever em pseudo-cĂłdigo a instrução retf 12
da seguinte forma:
Usado como ponteiro para o topo da .
Usado como ponteiro para o endereço inicial do .
Ao chamar o procedimento __x86.get_pc_thunk.bx
o endereço da instrução seguinte na memória é empilhado pela instrução , portanto mov ebx, [esp]
salva o endereço que EIP terå quando o procedimento retornar em EBX.
O segmento padrão (nesse caso DS) usado para acessar o endereço depende de qual registrador e instrução estå sendo utilizado. No tópico isso serå explicado.
Diferente dos , os registradores de segmento nĂŁo sĂŁo expandidos. Permanecem com o tamanho de 16 bits.
Jå os registradores FS e GS são exceçÔes e ainda podem ser usados pelo sistema operacional para endereçamento de estruturas especiais na memória. Como por exemplo no Linux, em x86-64, FS é usado para apontar para a .
No tĂłpico de nĂłs jĂĄ vimos uma forma de usar o prefixo de sobrescrita de segmento, porĂ©m tambĂ©m Ă© possĂvel usĂĄ-lo simplesmente adicionando o nome do registrador de segmento antes da instrução. Veja que as duas instruçÔes abaixo sĂŁo equivalentes:
A instrução mov byte [bx], 42
compilada para 16-bit não altera apenas o tamanho do registrador, quando estå em 32-bit, mas também o registrador em si. Isso acontece devido as diferenças de endereçamento jå explicadas neste livro em .
Como explicado no tópico que fala sobre algumas instruçÔes fazem o endereçamento em determinados segmentos. O atributo de segmento padrão é definido de acordo com qual registrador é usado como base no .
Registrador
Uso
RAX
NĂșmero da syscall / Valor de retorno
RDI
1° argumento
RSI
2° argumento
RDX
3° argumento
R10
4° argumento
R8
5° argumento
R9
6° argumento
Nome
RAX
RDI
exit
60
int status_de_saĂda
Nome
RAX
RDI
RSI
RDX
write
1
file_descriptor
endereço
tamanho (em bytes)
Nome
File descriptor
Descrição
stdin
0
Entrada de dados (o que Ă© digitado pelo usuĂĄrio)
stdout
1
SaĂda padrĂŁo (o que Ă© impresso no terminal)
stderr
2
SaĂda de erro (tambĂ©m impresso no terminal, porĂ©m destinado a mensagens de erro)
Pseudo-instrução
Tamanho dos dados
Bytes
db
byte
1
dw
word
2
dd
double word
4
dq
quad word
8
dt
ten word
10
do
16
dy
32
dz
64
SĂmbolo
Valor
$
Endereço da instrução atual
$$
Endereço do inĂcio da seção atual
Nomenclatura
Significado
reg
Um operando registrador
r/m
Um operando registrador ou na memĂłria
imm
Um operando imediato
addr
Denota um endereço, geralmente se usa um rótulo. Na pråtica é um valor imediato assim como o operando imediato.
Operando 1
Operando 2
Destino
AL
r/m8
AX
AX
r/m16
DX:AX
EAX
r/m32
EDX:EAX
RAX
r/m64
RDX:RAX
Operando 1
Operando 2
Destino quociente
Destino sobra
AX
r/m8
AL
AH
DX:AX
r/m16
AX
DX
EDX:EAX
r/m32
EAX
EDX
RDX:RAX
r/m64
RAX
RDX
Registrador base | Segmento |
RIP | CS |
SP/ESP/RSP | SS |
BP/EBP/RBP | SS |
Qualquer outro registrador | DS |
Conhecendo o ambiente do MS-DOS.
O clĂĄssico MS-DOS, antigo sistema operacional de 16 bits da Microsoft, foi muito utilizado e atĂ© hoje existem projetos relacionados a esse sistema. Existe por exemplo o FreeDOS que Ă© um sistema operacional de cĂłdigo aberto e que Ă© compatĂvel com o MS-DOS.
A famosa "telinha preta" do Windows, o prompt de comando, muitas vezes Ă© erroneamente chamado de MS-DOS devido aos dois usarem o mesmo shellscript chamado de Batch. Isso fazia com que comandos rodados no MS-DOS fossem quase totalmente compatĂveis na linha de comando do Windows.
Mas o prompt de comandos do Windows não é o MS-DOS. Esse é apenas o Terminal do sistema operacional Windows e que usa uma versão mais avançada do mesmo shellscript que rodava no MS-DOS.
O MS-DOS era um sistema operacional que rodava em modo de processamento real mode, o famoso modo de 16-bit que Ă© compatĂvel com o 8086 original.
Existem modos diferentes de se usar a saĂda de vĂdeo, isto Ă©, o monitor do computador. Dentre os vĂĄrios modos que o monitor suporta, existe a divisĂŁo entre modo de texto (text mode) e modo de vĂdeo (video mode).
O modo de vĂdeo Ă© este modo que o seu sistema operacional estĂĄ rodando agora. Nele o software define informaçÔes de cor para cada pixel da tela, formando assim imagens desde mais simples (formas opacas) atĂ© as mais complexas (imagens renderizadas tridimensionalmente). Todas essas imagens que vocĂȘ vĂȘ sĂŁo geradas pixel a pixel para serem apresentadas pelo monitor.
JĂĄ o MS-DOS rodava em modo de texto, cujo este modo Ă© bem mais simples. Ao invĂ©s de vocĂȘ definir cada pixel que o monitor apresenta, vocĂȘ define unicamente informaçÔes de caracteres. Imagine por exemplo que seu monitor seja dividido em grade formando 80x25 quadrados na tela. Ou seja, 80 colunas e 25 linhas. Ao invĂ©s de definir cada pixel vocĂȘ apenas definia qual caractere seria apresentado naquele quadrado e um atributo para esse caractere.
O formato de executåvel mais båsico que o MS-DOS suportava era os de extensão .com que era um raw binary. Esse termo é usado para se referir a um "binårio puro", isto é, um arquivo binårio que não tem qualquer tipo de formatação especial.
Uma comparação com arquivos de texto seria vocĂȘ comparar um cĂłdigo fonte em C com um arquivo de texto "normal". O cĂłdigo fonte em C tambĂ©m Ă© um arquivo de texto, porĂ©m ele tem formataçÔes especiais que seguem a sintaxe da linguagem de programação. Enquanto o arquivo de texto "normal" Ă© apenas texto, sem seguir qualquer regra de formatação.
No caso do raw binary Ă© a mesma coisa, informação binĂĄria sem qualquer regra de formatação especial. Este executĂĄvel do MS-DOS tinha como "entry point" logo o primeiro byte do arquivo. Como eu jĂĄ disse, nĂŁo tinha qualquer regra especial nele entĂŁo vocĂȘ poderia organizĂĄ-lo da maneira que quisesse manualmente.
O processo que o MS-DOS fazia para executar esse tipo de executĂĄvel era tĂŁo simples quanto possĂvel. Seguindo o fluxo:
Recebe um comando na linha de comando.
Coloca o tamanho em bytes dos argumentos passados pela linha de comando no offset 0x80 do segmento do executĂĄvel.
Coloca os argumentos da linha de comando no offset 0x81 como texto puro, sem qualquer formatação.
Carrega todo o .COM no offset 0x100
Define os registradores DS, SS e ES para o segmento onde o executĂĄvel foi carregado.
Faz um call
no endereço onde o executåvel foi carregado.
Perceba que a chamada do executĂĄvel nada mais Ă© que um call
, por isso esses executĂĄveis finalizavam simplesmente executando um ret
. Mais simples impossĂvel, nĂ©?
A essa altura vocĂȘ jĂĄ deve ter reparado que o NASM calcula o endereço dos rĂłtulos sozinho sem precisar da nossa ajuda, nĂ©? EntĂŁo, mas ele faz isso considerando que o primeiro byte do nosso arquivo binĂĄrio esteja especificamente no offset 0. Ou seja, ele começa a contar do zero em diante. No caso de um executĂĄvel .COM ele Ă© carregado no offset 0x100 e nĂŁo em 0, entĂŁo o cĂĄlculo vai dar errado.
Mas o NASM contém a diretiva org
que serve para dizer para o NASM a partir de qual endereço ele deve calcular o endereço dos rótulos, ou seja, o endereço de origem do nosso binårio. Veja o exemplo:
O rĂłtulo codigo
ao invés de ter o endereço calculado como 0x0003 como normalmente teria, terå o endereço 0x0103 devido ao uso da diretiva org
na segunda linha.
Um pequeno exemplo de "Hello World" (ou "Hi") para o MS-DOS:
Experimente compilar como um raw binary com extensĂŁo .com
e depois executar no Dosbox (ou FreeDOS ou qualquer projeto semelhante).
A instrução INT e o que estå acontecendo aà serå explicado nos dois tópicos posteriores a esse.
Registrador | Nome |
CS | Code Segment / Segmento de cĂłdigo |
DS | Data Segment / Segmento de dado |
ES | Extra Segment / Segmento extra |
SS | Stack Segment / Segmento da pilha |
InterrupçÔes e exceçÔes sendo entendidas na pråtica.
Uma interrupção Ă© um sinal enviado para o processador solicitando a atenção dele para a execução de outro cĂłdigo. Ele para o que estĂĄ executando agora, executa este determinado cĂłdigo da interrupção e depois volta a executar o cĂłdigo que estava executando antes. Esse sinal Ă© geralmente enviado por um hardware externo para a CPU, cujo o mesmo Ă© chamado de IRQ â Interrupt Request â que significa "pedido de interrupção".
Enquanto a interrupção de software é executada de maneira muito semelhante a uma chamada de procedimento por far call
. Ela é basicamente uma interrupção que é executada pelo software rodando na CPU, daà o nome.
No caso de interrupçÔes de softwares sendo disparadas em um processo executando sob um sistema operacional, o código executado da interrupção é definido pelo próprio sistema operacional e estå fora da memória do processo. Portanto hå uma troca de contexto onde a tarefa momentaneamente fica suspensa enquanto a interrupção não finaliza.
O cĂłdigo que Ă© executado quando uma interrupção Ă© disparada se chama handler e o endereço do mesmo Ă© definido na IDT â Interrupt Descriptor Table. Essa tabela nada mais Ă© que uma sequĂȘncia de valores indicando o offset e segmento do cĂłdigo Ă ser executado. Ă uma array onde cada elemento contĂ©m essas duas informaçÔes. PoderĂamos representar em C da seguinte forma:
Ou seja o nĂșmero que identifica a interrupção nada mais Ă© que o Ăndice a ser lido no vetor.
Provavelmente vocĂȘ jĂĄ ouviu falar em exception. A exception nada mais Ă© que uma interrupção e tem o seu handler definido na IDT. Por exemplo quando vocĂȘ comete o erro clĂĄssico de tentar acessar uma regiĂŁo de memĂłria invĂĄlida ou sem permissĂ”es adequadas em C, vocĂȘ compila o cĂłdigo e recebe a clĂĄssica mensagem segmentation fault.
Nesse caso a exceção que foi disparada pelo processador se chama General Protection e pode ser referida pelo mnemĂŽnico #GP, seu Ăndice na tabela Ă© 13.
Essa exceção Ă© disparada quando hĂĄ um problema na referĂȘncia de memĂłria ou qualquer proteção Ă memĂłria que foi violada. Como por exemplo ao tentar escrever em um segmento de memĂłria que nĂŁo tem permissĂŁo para escrita.
Um sistema operacional configura uma exceção da mesma forma que configura uma interrupção, modificando a IDT para apontar para o cĂłdigo que ele quer que execute. Nesse caso o Ăndice 13 precisaria ser modificado.
No Linux basicamente o que o sistema faz é criar um handler que trata a exceção e manda um sinal para o processo. Esse sinal o processo pode configurar como ele quer tratar, mas por padrão o processo escreve uma mensagem no terminal e finaliza.
A instrução int imm8
Ă© usada para disparar interrupçÔes de software/exceçÔes. Bastando simplesmente passar o Ăndice da interrupção como operando.
Vamos ver na pråtica a configuração de uma interrupção em 16-bit. Para isso vamos usar o MS-DOS para que fique mais simples.
A IDT estĂĄ localizada no endereço 0 em real mode, por isso podemos configurar para acessar o segmento zero e assim o offset seria o Ăndice de cada elemento da IDT. O que precisamos fazer Ă© acessar o Ăndice que queremos modificar na IDT, depois Ă© sĂł jogar o offset e segmento do procedimento que queremos que seja executado. Em 16-bit isso acontece de uma maneira muito mais simples do que em protected mode, por isso Ă© ideal para entender na prĂĄtica.
Eis o cĂłdigo:
Para compilar e testar usando o Dosbox:
A interrupção simplesmente escreve os caracteres na parte superior esquerda da tela.
Note que a interrupção retorna usando a instrução iret
ao invés de ret
. Em 16-bit a Ășnica diferença nessa instrução Ă© que ela tambĂ©m desempilha o registrador de flags, que Ă© empilhado pelo processador ao disparar a interrupção/exceção.
Perceba que é unicamente um código de exemplo. Essa não é uma maneira segura de se configurar uma interrupção tendo em vista que seu handler estå na memória do .com
que, após finalizar sua execução, poderå ser sobrescrita por outro programa executado posteriormente.
Mais um exemplo mas dessa vez configurando a exceção #BP de Ăndice 3. Se vocĂȘ jĂĄ usou um depurador, ou pelo menos tem uma noção Ă respeito, sabe que "breakpoint" Ă© um ponto no cĂłdigo onde o depurador faz uma parada e te permite analisar o programa enquanto ele fica em pausa.
Os depuradores modificam a instrução original colocando a instrução que dispara a exceção de breakpoint. Depois tratam o sinal enviado para o processo, restauram a instrução original e continuam seu trabalho.
O breakpoint nada mais é que uma exceção que é disparada por uma instrução. Podemos usar int 0x03
(CD 03
em código de måquina) para fazer isso porém essa instrução tem 2 bytes de tamanho e não é muito apropriada para um depurador usar. Por isso existe a instrução int3
que dispara #BP explicitamente e tem somente 1 byte de tamanho (opcode 0xCC).
Repare que a cada disparo de int3
executou o código do nosso procedimento break. Esse por sua vez imprimiu o caractere 'X' na tela do Dosbox usando a interrupção 0x10
que serĂĄ explicada no prĂłximo tĂłpico.
SĂł para deixar mais claro o que falei sobre os sinais que sĂŁo enviados para o processo quando uma exception Ă© disparada, aqui um cĂłdigo em C de exemplo:
Mais detalhes sobre os sinais serĂŁo descritos no tĂłpico Entendendo os depuradores.
Aprendendo a usar o x87 para fazer cĂĄlculos.
Podemos usar a FPU para fazer cålculos com valores de ponto flutuante. A arquitetura x86 segue a padronização IEEE-754 para a representação de valores de ponto flutuante.
Apenas algumas instruçÔes da FPU serão ensinadas aqui, não sendo uma lista completa.
Um adendo que normalmente compiladores de C nĂŁo trabalham com valores de ponto flutuante desta maneira em x86-64 porque a arquitetura x86 hoje em dia tem maneiras mais eficientes de fazer esses cĂĄlculos. Isso serĂĄ demonstrado no prĂłximo tĂłpico.
As instruçÔes da FPU trabalham com os registradores de st0 atĂ© st7, sĂŁo 8 registradores de 80 bits de tamanho cada. Juntos eles formam uma stack (pilha) onde vocĂȘ pode empilhar valores para trabalhar com eles ou desempilhar para armazenar o resultado das operaçÔes em algum lugar.
O empilhamento de valores funciona colocando o novo valor em st0 e todos os outros valores anteriores são "empurrados" para os registradores posteriores. Um exemplo bem leviano dessa operação:
Detalhe que sĂł Ă© possĂvel usar esses registradores em instruçÔes da FPU, algo como esse cĂłdigo estĂĄ errado:
As instruçÔes da FPU todas começam com um prefixo F, e as que operam com valores inteiros (convertendo DE ou PARA inteiro) tambĂ©m tem uma letra I apĂłs a letra F. Por fim, instruçÔes que fazem o pop de um valor da pilha, isto Ă©, remove o valor de lĂĄ, terminam com um sufixo P. Entendendo isso fica muito mais fĂĄcil identificar o que cada mnemĂŽnico significa e assim vocĂȘ nĂŁo perde tempo tentando decorar uma sopa de letrinhas, se essas letras existem Ă© porque tem um significado.
Caso tenha vindo de uma arquitetura RISC, geralmente o termo load Ă© usado para a operação em que vocĂȘ carrega um valor da memĂłria para um registrador. JĂĄ store Ă© usado para se referir a operação contrĂĄria, do registrador para a memĂłria.
Nesse caso as operaçÔes podem ser feita entre registradores da FPU também, conforme serå explicado.
Fazer load de um valor é basicamente carregar um valor da memória para a pilha em st0, é como um push quando estamos falando da pilha convencional. A diferença aqui é a maneira como o valor é colocado na pilha, como jå foi explicado anteriormente.
Jå o store é pegar o valor da pilha, mais especificamente em st0, e armazenar em algum lugar da memória. Algumas instruçÔes store permitem armazenar o valor em outro registrador da FPU.
Aqui eu vou ensinar a usar a FPU mas sem diretamente trabalhar com a linguagem C e os tipos float ou double, pois como jĂĄ foi mencionado, nĂŁo Ă© assim que o compilador trabalha com cĂĄlculos de ponto flutuante.
Vou usar a notação memXXfp
e memXXint
para especificar valores na memória que sejam float ou inteiro, respectivamente. Onde XX seria o tamanho do valor em bits. Jå a notação st(i)
serå usada para se referir a qualquer registrador de st0 até st7. O st(0)
seria o registrador st0 especificamente.
Normalmente vamos usar essa instrução antes de começar a usar a FPU, pois ela reseta a FPU para o estado inicial. Dessa forma quaisquer operaçÔes anteriores com a FPU são descartadas e podemos começar tudo do zero. Assim não é necessårio, por exemplo, a gente limpar a pilha da FPU toda vez que terminar as operaçÔes com ela. Basta rodar essa instrução antes de uså-la.
A instrução fld
carrega um valor float de 32, 64 ou 80 bits para st0. Repare como Ă© possĂvel dar load em um dos registradores da pilha, o que torna possĂvel retrabalhar com valores anteriormente carregados. Se vocĂȘ rodar fld st0
estarĂĄ basicamente duplicando o Ășltimo valor carregado.
JĂĄ fild
carrega um valor inteiro sinalizado de 16, 32 ou 64 bits o convertendo para float de 64 bits.
Existem vårias instruçÔes para dar push de valores constantes na pilha da FPU, e elas são:
Pega o valor float de st0 e copia para o operando destino. A versĂŁo com o sufixo P tambĂ©m faz o pop do valor da stack, sendo possĂvel dar store em um float de 80 bits somente com essa instrução.
Pega o valor em st0, converte para inteiro sinalizado e armazena no operando destino. SĂł Ă© possĂvel dar store em um inteiro de 64 bits na versĂŁo da instrução que faz o pop.
Só com essas instruçÔes jå podemos converter um float para inteiro e vice-versa. Conforme exemplo:
Se vocĂȘ rodar esse teste irĂĄ notar que o valor foi convertido para 24 jĂĄ que houve um arredondamento.
As versÔes de fadd
com operando na memĂłria faz a soma do operando com st0 e armazena o resultado da soma no prĂłprio st0. JĂĄ fiadd
com operando em memória faz a mesma coisa, porém convertendo o valor inteiro para float 64 bits antes.
As instruçÔes com registradores fazem a soma e armazenam o resultado no operando mais a esquerda, o operando destino. Enquanto a faddp
sem operandos soma st0 com st1, armazena o resultado em st1 e depois faz o pop.
Exemplo de soma simples:
Mesma coisa que as instruçÔes acima, só que fazendo uma operação de subtração.
Mesma coisa que FADD etc. porém faz uma operação de divisão.
Cansei de repetir, jå sabe né? Operação de multiplicação.
Faz a mesma coisa que a famĂlia FSUB sĂł que com os operandos ao contrĂĄrio. Conforme ilustração:
Ou seja faz a subtração na ordem inversa dos operandos, porém onde o resultado é armazenado continua sendo o mesmo.
Mesma lógica que as instruçÔes acima, porém faz a divisão na ordem inversa dos operandos.
Seguindo a mesma lógica da instrução xchg
, troca o valor de st0 com st(i). A versão da instrução sem operando especificado faz a troca entre st0 e st1.
Calcula a raĂz quadrada de st0 e armazena o resultado no prĂłprio st0.
Calcula o valor absoluto de st0 e armazena em st0. Basicamente zera o bit de sinalização do valor.
Inverte o sinal de st0, se era negativo passa a ser positivo e vice-versa.
Calcula o cosseno de st0 que deve ser um valor radiano, e armazena o resultado nele prĂłprio.
Calcula o seno de st0, que deve estar em radianos.
Calcula o seno e o cosseno de st0. O cosseno Ă© armazenado em st0 enquanto o seno estarĂĄ em st1.
Calcula a tangente de st0 e armazena o resultado no prĂłprio registrador, logo apĂłs faz o push do valor 1.0 na pilha. O valor em st0 para ser calculado deve estar em radianos.
Calcula o arco-tangente de st1 dividido por st0, armazena o resultado em st1 e depois faz o pop. O resultado tem o mesmo sinal que o operando que estava em st1.
Faz o cĂĄlculo de 2 elevado a st0 menos 1, e armazena o resultado em st0.
Faz esse cĂĄlculo aĂ com logaritmo de base 2:
ApĂłs o cĂĄlculo Ă© feito um pop.
Mesma coisa que a instrução anterior porém somando 1 a st0.
Arredonda st0 para a parte inteira mais prĂłxima e armazena o resultado em st0.
As duas instruçÔes armazenam a sobra da divisão entre st0 e st1 no registrador st0. Com a diferença que fprem1
segue a padronização IEEE-754.
Faz a comparação entre st0 e st(i) setando as status flags de acordo. A diferença de fucomi
e fucomip
é que essas duas verificam se os valores nos registradores não são NaN, sendo o caso a instrução irå disparar uma exception #IA.
Faz uma operação move condicional levando em consideração as status flags.
Adiantando que um valor float na convenção de chamada do C é retornado no registrador XMM0. Podemos ver o resultado de nossos testes da seguinte forma usando a instrução MOVSS:
A instrução MOVSS e os registradores XMM serão explicados no próximo tópico.
Existem algumas interrupçÔes que são criadas pelo próprio BIOS do sistema. Vamos ver algumas delas aqui.
BIOS â Basic Input/Output System â Ă© o firmware da placa-mĂŁe responsĂĄvel pela inicialização do hardware. Ele quem começa o processo de boot do sistema alĂ©m de anteriormente fazer um teste rĂĄpido (POST â Power-On Self Test) para verificar se o hardware estĂĄ funcionando apropriadamente.
BIOS Ă© um sistema legado de boot, sistemas mais modernos usam UEFI para o processo de boot do sistema.
Mas alĂ©m de fazer essa tarefa de inicialização do PC ele tambĂ©m define algumas interrupçÔes que podem ser usadas pelo software em real mode para tarefas bĂĄsicas. E Ă© daĂ que vem seu nome, jĂĄ que essas tarefas sĂŁo operaçÔes bĂĄsicas de entrada e saĂda de dados para o hardware.
Cada interrupção nĂŁo faz um procedimento Ășnico mas sim vĂĄrios procedimentos relacionados Ă um determinado hardware. Qual procedimento especificamente serĂĄ executado Ă©, na maioria das vezes, definido no registrador AH
ou AX
.
Essa interrupção tem procedimentos relacionados ao vĂdeo, como a escrita de caracteres na tela ou atĂ© mesmo alterar o modo de vĂdeo.
O procedimento INT 0x10 / AH 0x0E simplesmente escreve um caractere na tela em modo teletype, que Ă© um nome chique para dizer que o caractere Ă© impresso na posição atual do cursor e atualiza a posição do mesmo. Ă algo bem semelhante ao que a gente vĂȘ sob um sistema operacional usando uma função como putchar()
em C.
Esse procedimento recebe como argumento no registrador AL
o caractere a ser impresso e em BH
o nĂșmero da pĂĄgina.
O nĂșmero da pĂĄgina varia entre 0 e 7. SĂŁo 8 pĂĄginas diferentes que podem ser apresentadas para o monitor como o conteĂșdo da tela. Por padrĂŁo Ă© usada a pĂĄgina 0 mas vocĂȘ pode alternar entre as pĂĄginas fazendo com que conteĂșdo diferente seja apresentado na tela sem perder o conteĂșdo da outra pĂĄgina.
Se vocĂȘ jĂĄ usou o MS-DOS deve ter visto programas, como editores de cĂłdigo, que imprimiam uma interface de texto (TUI) mas depois que finalizava o conteĂșdo do prompt voltava para a tela. Esses programas basicamente alternavam de pĂĄgina.
No exemplo acima usamos a interrupção duas vezes para imprimir dois caracteres diferentes, fazendo assim um "Hello World" de mĂseros 11 bytes.
PoderĂamos fazer um procedimento para escrever uma string inteira usando um loop. Ficaria assim:
Esse procedimento seta a posição do cursor em uma determinada pågina.
Pega a posição atual do cursor na pågina especificada. Retornando:
Alterna para a pĂĄgina especificada por AL que deve ser um nĂșmero entre 0 e 7.
Imprime o caractere AL na posição atual do cursor CX vezes, sem atualizar o cursor. BL é o atributo do caractere que serå explicado mais embaixo.
Mesma coisa que o procedimento anterior porém mudando somente que não é especificado um atributo para o caractere.
Esse procedimento imprime uma string na tela podendo ser especificado um atributo. O modo de escrita pode variar entre 0 e 3, se trata de 2 bits especificando duas informaçÔes diferentes:
No caso do segundo bit, se estiver ligado entĂŁo o procedimento irĂĄ ler a string considerando que se trata de uma sequĂȘncia de caractere e atributo. Assim cada caractere pode ter um atributo diferente. Conforme exemplo abaixo:
Os procedimentos 0x0E e 0x13 interpretam caracteres especiais como determinadas açÔes que devem ser executadas ao invés de imprimir o caractere na tela. Cada caractere faz uma ação diferente conforme tabela abaixo:
VocĂȘ pode combinar 0x0D e 0x0A para fazer uma quebra de linha.
Os procedimentos definidos nessa interrupção sĂŁo todos relacionados Ă entrada do teclado. Toda vez que o usuĂĄrio pressiona uma tecla ela Ă© lida e armazenada no buffer do teclado. Se vocĂȘ tentar ler do buffer sem haver dados lĂĄ, entĂŁo o sistema irĂĄ ficar esperando o usuĂĄrio inserir uma entrada.
LĂȘ um caractere do buffer do teclado e o remove de lĂĄ. Retorna os seguintes valores:
Scancode Ă© um nĂșmero que identifica a tecla e nĂŁo especificamente o caractere inserido.
Verifica se hĂĄ um caractere disponĂvel no buffer sem removĂȘ-lo de lĂĄ. Se houver caractere disponĂvel, retorna:
O procedimento tambĂ©m modifica a Zero Flag para especificar se hĂĄ ou nĂŁo caractere disponĂvel. A define para 0 se houver, caso contrĂĄrio para 1.
VocĂȘ pode usar em seguida o AH 0x00 para remover o caractere do buffer, se assim desejar. Desse jeito Ă© possĂvel pegar um caractere sem fazer uma pausa.
Pega status relacionados ao teclado. à retornado em AL 8 flags diferentes, cada uma especificando informaçÔes diferentes sobre o estado atual do teclado. Conforme tabela:
Quando o sistema estå em modo texto a memória onde se armazena os caracteres começa no endereço 0xb800:0x0000 e ela é estruturada da seguinte forma:
Ou seja começando em 0xb800:0x0000 as påginas estão uma atrås da outra na memória como uma grande array.
O caractere nada mais é que o código ASCII do mesmo, jå o atributo é um valor usado para especificar informaçÔes de cor e blink do caractere.
Os 4 bits (nibble) mais significativo indicam o atributo do fundo e os 4 bits menos significativos o atributo do texto, gerando uma cor na escala RGB. Caso nĂŁo conheça essa Ă© a escala de cor da luz onde as cores primĂĄrias Red (vermelo), Green (verde) e Blue (azul) sĂŁo usadas em conjunto para formar qualquer outra cor. Conforme figura abaixo podemos ver qual bit significa o quĂȘ:
O bit de intensidade no atributo de texto, caso ligado, faz com que a cor do texto fique mais viva enquanto desligado as cores sĂŁo mais escuras. JĂĄ o bit de blink especifica se o texto deve permanecer piscando. Caso ativo o texto ficarĂĄ aparecendo e desaparecendo da tela constantemente.
Um exemplo de "Hello World" usando alguns conceitos apresentados aqui:
Para uma lista completa de todas as interrupçÔes definidas pelo BIOS, sugiro a leitura:
Registrador EFLAGS e FLAGS.
O registrador EFLAGS contĂ©m flags que servem para indicar trĂȘs tipos de informaçÔes diferentes:
Status -- Indicam o resultado de uma operação aritmética.
Control -- Controlam alguma caracterĂstica de execução do processador.
System -- Servem para configurar ou indicar alguma caracterĂstica do hardware relacionado a execução do cĂłdigo ou do sistema.
Enquanto o RFLAGS de 64 bits contém todas as mesmas flags de EFLAGS sem nenhuma nova. Todos os 32 bits mais significativos do RFLAGS estão reservados e sem nenhum uso atualmente. Observe a figura abaixo retirada do Intel Developer's Manual Vol. 1, mostrando uma visão geral do bits de EFLAGS:
InstruçÔes que fazem operaçÔes aritméticas modificam as status flags conforme o valor do resultado da operação. São instruçÔes como ADD
, SUB
, MUL
e DIV
por exemplo.
Porém um detalhe que é interessante saber é que existem duas instruçÔes que normalmente são utilizadas para definir essas flags para serem usadas junto com uma instrução condicional. Elas são: CMP
e TEST
. A instrução CMP
nada mais é do que uma instrução que faz a mesma operação aritmética de subtração que SUB
porém sem modificar o valor dos operandos.
Enquanto TEST
faz uma operação bitwise AND (E bit a bit) também sem modificar os operandos. Ou seja, o mesmo que a instrução AND
. Veja a tabela abaixo com todas as status flags:
Carry, ou carrinho/transporte, Ă© o que a gente conhece no Brasil como "vai um" em uma operação aritmĂ©tica de adição. Borrow Ă© o mesmo princĂpio porĂ©m em aritmĂ©tica de subtração, em linguagem coloquial chamado de "pegar emprestado".
Dentre essas flags somente CF pode ser modificada diretamente e isso é feito com as seguintes instruçÔes:
Se DF estiver setada as instruçÔes de string irão decrementar o valor do(s) registrador(es). Se estiver zerada ela irå incrementar, que é o valor padrão para essa flag.
Caso sete o valor dessa flag Ă© importante que a zere novamente em seguida. CĂłdigo compilado normalmente espera que por padrĂŁo essa flag esteja zerada. Comportamentos imprevistos podem acontecer caso vocĂȘ nĂŁo a zere depois de usar.
As system flags podem ser lidas por qualquer programa porém somente o sistema operacional pode modificar seus valores (exceto ID). Abaixo irei falar somente das flags que nos interessam saber por agora.
IOPL na verdade nĂŁo Ă© uma flag mas sim um campo de 2 bits que indica o nĂvel de privilĂ©gio de acesso para operaçÔes de I/O a partir da porta fĂsica do processador.
As instruçÔes abaixo podem ser utilizadas para modificar o valor de IF:
Em real mode dentre as system flags somente TF e IF existem e não dependem de qualquer tipo de privilégio para serem modificadas, qualquer software executado pelo processador tem permissão irrestrita às flags.
Entendendo as instruçÔes condicionais e as status flags.
As instruçÔes condicionais basicamente avaliam as status flags para executar uma operação apenas se a condição for atendida. Existem condiçÔes que testam o valor de mais de uma flag em combinação para casos diferentes.
A nomenclatura de escrita de uma instrução condicional é o seu nome seguido de um 'cc' que é sigla para conditional code. Abaixo uma tabela de códigos condicionais vålidos para as instruçÔes CMOVcc
, SETcc
e Jcc
:
Os termos "abaixo" (below) e "acima" (above) usados na descrição se referem a verificação de um valor numérico não-sinalizado. Enquanto "maior" e "menor" é usado para se referir a um valor numérico sinalizado.
Exemplo:
Repare como alguns cc tĂȘm a mesma condição, como Ă© o caso de NE e NZ. Portanto JNE
e JNZ
são exatamente a mesma instrução no código de måquina, somente mudando no Assembly.
AlĂ©m das condiçÔes acima existem mais trĂȘs Jcc
que testam o valor do registrador CX, ECX e RCX respectivamente.
A Ășltima instrução, obviamente, somente existe em submodo de 64-bit. Enquanto JCXZ
nĂŁo existe em 64-bit.
No código de måquina o opcode dessa instrução é 0xE3 e a alternùncia entre o tamanho do registrador é feita de acordo com o atributo address-size, sendo modificado pelo prefixo 0x67.
Convertendo valores entre float, double e inteiro.
Essas instruçÔes servem para conversão de tipos entre float, double e inteiro.
Converte dois valores float do operando fonte (segundo) em dois valores double no operando destino (primeiro).
Converte dois valores double do operando fonte (segundo) em dois valores float no operando destino (primeiro).
Converte um valor float do operando fonte (segundo) em um valor double no operando destino (primeiro).
Converte um valor double do operando fonte (segundo) em um valor float no operando destino (primeiro).
Converte os dois doubles no operando fonte para dois inteiros sinalizados de 32-bit no operando destino. A instrução CVTPD2DQ faz o arredondamento normal do valor enquanto CVTTPD2DQ trunca ele.
Converte os dois inteiros sinalizados de 32-bit no operando fonte para dois doubles no operando destino.
CVTTSD2SI faz a mesma coisa porém truncando o valor.
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um double no operando destino.
Converte quatro floats do operando fonte em quatro inteiros sinalizados de 32-bit no operando destino. A instrução CVTPS2DQ faz o arredondamento normal dos valores enquanto CVTTPS2DQ trunca eles.
Converte quatro inteiros sinalizados de 32-bit no operando fonte para quatro floats no operando destino.
A instrução CVTTSS2SI faz a mesma coisa porém truncando o valor.
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um float no operando destino.
Aprendendo sobre SIMD, SSE e registradores XMM.
Na computação existe um conceito de instrução chamado SIMD (Single Instruction, Multiple Data) que Ă© basicamente uma instrução que processa mĂșltiplos dados de uma Ășnica vez. Todas as instruçÔes que vimos atĂ© agora processavam meramente um dado por vez, porĂ©m instruçÔes SIMD podem processar diversos dados paralelamente. O principal objetivo das instruçÔes SIMD Ă© ganhar performance se aproveitando dos mĂșltiplos nĂșcleos do processador, a maioria das instruçÔes SIMD foram implementadas com o intuito de otimizar cĂĄlculos comuns em ĂĄreas como processamento grĂĄfico, inteligĂȘncia artificial, criptografia, matemĂĄtica etc.
A Intel criou a primeira versão do SSE (streaming SIMD extensions) ainda no IA-32 com o Pentium III, e de lå para cå jå ganhou diversas novas versÔes que estendem a tecnologia adicionando novas instruçÔes. Atualmente nos processadores mais modernos hå as seguintes extensÔes: SSE, SSE2, SSE3, SSSE3 e SSE4.
Processadores da arquitetura x86 tĂȘm diversas tecnologias SIMD, a primeira delas foi o MMX nos processadores Intel antes mesmo do SSE. AlĂ©m de haver diversas outras como AVX, AVX-512, FMA, 3DNow! (da AMD) etc.
Na arquitetura x86 existem literalmente milhares de instruçÔes SIMD. Esteja ciente que esse tĂłpico estĂĄ longe de cobrir todo o assunto e serve meramente como conteĂșdo introdutĂłrio.
A tecnologia SSE adiciona novos registradores independentes de 128 bits de tamanho cada. Em todos os modos de operação são adicionados oito novos registradores XMM0 até XMM7, e em 64-bit também hå mais oito registradores XMM8 até XMM15 que podem ser acessados usando o . Isso då um total de 16 registradores em 64-bit e 8 registradores nos outros modos de operação.
Esses registradores podem armazenar vĂĄrios dados diferentes de mesmo tipo/tamanho, conforme demonstra tabela abaixo:
Esses sĂŁo os tipos empacotados (packed), onde em um Ășnico registrador hĂĄ vĂĄrios valores de um mesmo tipo. Existem instruçÔes SIMD especĂficas que executam operaçÔes packed onde elas trabalham com os vĂĄrios dados armazenados no registrador ao mesmo tempo. Em contraste existem tambĂ©m as operaçÔes escalares (scalar) que operam com um Ășnico dado (unpacked) no registrador, onde esse dado estaria armazenado na parte menos significativa do registrador.
Na convenção de chamada para x86-64 da linguagem C os primeiros argumentos float/double passados para uma função vão nos registradores XMM0, XMM1 etc. como valores escalares. E o retorno do tipo float/double fica no registrador XMM0 também como um valor escalar.
Na lista de instruçÔes haverå códigos de exemplo disso.
As instruçÔes adicionadas pela tecnologia SSE podem ser divididas em quatro grupos:
InstruçÔes packed e scalar que lidam com nĂșmeros float.
InstruçÔes SIMD com inteiros de 64 bits.
InstruçÔes de gerenciamento de estado.
InstruçÔes de controle de cache e prefetch.
A tabela abaixo lista a nomenclatura que irei utilizar para descrever as instruçÔes SSE.
Para facilitar o entendimento irei usar o termo float para se referir aos nĂșmeros de ponto flutuante de precisĂŁo Ășnica, ou seja, 32 bits de tamanho e 23 bits de precisĂŁo. JĂĄ o termo double serĂĄ utilizado para se referir aos nĂșmeros de ponto flutuante de dupla precisĂŁo, ou seja, de 64 bits de tamanho e 52 bits de precisĂŁo. Esses sĂŁo os mesmos nomes usados como tipos na linguagem C.
As instruçÔes SSE terminam com um sufixo de duas letras onde a penĂșltima indica se ela lida com dados packed ou scalar, e a Ășltima letra indica o tipo do dado sendo manipulado. Por exemplo a instrução MOVAPS onde o P indica que a instrução manipula dados packed, enquanto o S indica o tipo do dado como single-precision floating-point, ou seja, float de 32 bits de tamanho.
Jå o D de MOVAPD indica que a instrução lida com valores do tipo double-precision floating-point (64 bits). Eis a lista de sufixos e seus respectivos tipos:
Todas as instruçÔes SSE que lidam com valores na memória exigem que o valor esteja em um endereço alinhado em 16 bytes, exceto as instruçÔes que explicitamente dizem lidar com dados desalinhados (unaligned).
Caso uma instrução SSE seja executada com um dado desalinhado uma exceção #GP serå disparada.
Listando algumas instruçÔes de movimentação de dados do SSE.
As instruçÔes MOVAPS e MOVUPS fazem a mesma coisa: Movem 4 valores float empacotados entre registradores XMM ou de/para memória principal. MOVAPD e MOVUPD porém lida com 2 valores double.
A diferença é que a instrução MOVAPS/MOVAPD espera que o endereço do valor na memória esteja alinhado a um valor de 16 bytes, caso não esteja a instrução dispara uma exceção #GP (General Protection ou "segmentation fault" como é conhecido no Linux). O motivo dessa instrução exigir isso é que acessar o endereço alinhado é muito mais performåtico.
Jå a instrução MOVUPS/MOVUPD pode acessar um endereço de memória desalinhado (unaligned) sem ocorrer nenhum erro, porém ela é menos performåtica.
Um exemplo de uso da MOVAPS na nossa PoC:
Sem entrar em detalhes ainda sobre a convenção de chamada, o ponteiro recebido como argumento pela função assembly() estå no registrador RDI.
Sobre o atributo align=16 usado na seção .rodata ele serve para fazer exatamente o que o nome sugere: Alinhar o endereço inicial da seção em um mĂșltiplo de 16, que Ă© uma exigĂȘncia da instrução MOVAPS.
Um detalhe interessante que vale citar é que apesar da instrução ter sido feita para lidar com um determinado tipo de dado nada impede de nós carregarmos outros dados nos registradores XMM. No exemplo abaixo usei a instrução MOVUPS para mover uma string de 16 bytes com apenas duas instruçÔes:
Move um Ășnico float/double entre registradores XMM, onde o valor estaria contido na double word (4 bytes) ou quadword (8 bytes) menos significativo do registrador. E tambĂ©m Ă© possĂvel mover de/para memĂłria principal.
A instrução MOVLPS instrução é semelhante à MOVUPS porém carrega/escreve apenas dois floats. No registrador os dois floats ficam armazenados no quadword (8 bytes) menos significativo. O quadword mais significativo do registrador não é alterado.
Jå MOVLPD faz a mesma operação porém com um double contido no quadword menos significativo.
Semelhante a instrução acima porém armazena/ler o valor do registrador XMM no quadword mais significativo. O quadword menos significativo do registrador não é alterado.
Move o quadword (8 bytes) menos significativo do registrador fonte (a direita) para o quadword mais significativo do registrador destino. O quadword menos significativo do registrador destino nĂŁo Ă© alterado.
Move o quadword (8 bytes) mais significativo do registrador fonte (a direita) para o quadword menos significativo do registrador destino. O quadword mais significativo do registrador destino nĂŁo Ă© alterado.
MOVMSKPS move os bits mais significativos (MSB) de cada um dos quatro valores float contido no registrador XMM para os 4 bits menos significativo do registrador de propĂłsito geral. Os outros bits do registrador sĂŁo zerados.
Jå MOVMSKPD faz a mesma coisa porém com os 2 valores doubles contidos no registrador, assim definindo os 2 bits menos significativos do registrador de propósito geral.
Essa instrução pode ser usada com o intuito de verificar o sinal de cada um dos valores float/double, tendo em vista que o bit mais significativo Ă© usado para indicar o sinal do nĂșmero (0 caso positivo e 1 caso negativo).
Calcula a média da soma de todos os valores dos dois operandos somados. PAVGB calcula a média somando 16 bytes em cada operando, enquanto PAVGW soma 8 words em cada um.
Copia uma das 8 words contidas no registrador XMM e armazena no de 32 ou 64 bits. O valor Ă© movido para os 16 bits menos significativos do registrador e todos os outros bits sĂŁo zerados. TambĂ©m Ă© possĂvel armazenar a word diretamente na memĂłria principal.
O operando imediato Ă© um valor entre 0 e 7 que indica o Ăndice da word no registrador XMM. Apenas os 3 bits menos significativos do valor sĂŁo considerados, os demais sĂŁo ignorados.
Copia uma word dos 16 bits menos significativos do registrador de propĂłsito geral no segundo operando e armazena em uma das words no registrador XMM. TambĂ©m Ă© possĂvel ler a word da memĂłria principal.
Assim como no caso do PEXTRW o operando imediato serve para identificar o Ăndice da word no registrador XMM.
Compara os bytes/words não-sinalizados dos dois operandos packed e armazena o maior deles em cada uma das comparaçÔes no operando destino (o primeiro).
Faz o mesmo que a instrução anterior porĂ©m armazenando o menor nĂșmero de cada comparação.
Faz o mesmo que PMAXUB/PMAXUW porém considerando o valor como sinalizado. Também hå a instrução PMAXSD que compara quatro double words (4 bytes) empacotados.
Faz o mesmo que PMAXSB/PMAXSW porém retornando o menor valor de cada comparação.
Armazena nos 16 bits menos significativos do registrador de propĂłsito geral cada um dos bits mais significativos (MSB) de todos os bytes contidos no registrador XMM.
Multiplica cada uma das words dos operandos onde o resultado temporårio da multiplicação é de 32 bits de tamanho. Porém armazena no operando destino somente a word mais significativa do resultado da multiplicação.
PMULHW faz uma multiplicação sinalizada, enquanto PMULHUW faz uma multiplicação não-sinalizada.
Calcula a diferença absoluta dos bytes dos dois operandos e armazena a soma de todas as diferenças.
A diferença dos 8 bytes menos significativos Ă© somada e armazenada na word menos significativa do operando destino. JĂĄ a diferença dos 8 bytes mais significativos Ă© somada e armazenada na quinta word (Ăndice 4, bits [79:64]) do operando destino. Todas as outras words do registrador XMM sĂŁo zeradas.
Move dois quadwords (8 bytes) entre registradores XMM ou de/para memória RAM. O endereço na memória precisa estar alinhado a 16 se não uma exceção #GP serå disparada.
Faz o mesmo que a instrução anterior porém o alinhamento da memória não é necessårio, porém essa instrução é menos performåtica caso acesse um endereço desalinhado.
Soma os bytes, words, double words ou quadwords dos operandos e armazena no operando destino.
Faz o mesmo que a instrução PADDQ porém com uma operação de subtração.
Multiplica o primeiro (Ăndice 0) e o terceiro (Ăndice 2) doublewords dos operandos e armazena o resultado como quadwords no operando destino. O resultado da multiplicação entre os primeiros doublewords Ă© armazenado no quadword menos signfiicativo do operando destino, enquanto a multiplicação entre os terceiros doublewords Ă© armazenada no quadword mais significativo.
Exemplo:
RDI Ă© o primeiro ponteiro recebido como argumento e RSI o segundo.
Faz o mesmo que a instrução anterior porém com um shift right. Os bits mais significativos são zerados.
Faz uma operação E bit a bit (bitwise AND) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação NAND bit a bit em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação OU bit a bit (bitwise OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação OU Exclusivo bit a bit (bitwise eXclusive OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
As instruçÔes de comparação do SSE recebem um terceiro operando imediato de 8 bits que serve como identificador para indicar qual comparação deve ser efetuada com os valores, onde os valores vålidos são de 0 até 7. Na tabela abaixo é indicado cada valor e qual operação de comparação ele representa:
Felizmente para facilitar nossa vida os assemblers, incluindo o NASM, adicionam pseudo-instruçÔes que removem o operando imediato e, ao invés disso, usa os mnemÎnicos apresentados na tabela como conditional code para a instrução. Como é demonstrado no exemplo abaixo:
Essa instrução compara cada um dos valores float/double contido nos dois operandos e armazena o resultado da comparação no operando fonte (o primeiro). O valor imediato passado como terceiro operando é um código numérico para identificar qual operação de comparação deve ser executada em cada um dos valores.
O resultado Ă© armazenado como todos os bits ligados (1) caso a comparação seja verdadeira, se nĂŁo todos os bits estarĂŁo desligados (0) indicando que a comparação foi falsa. Cada nĂșmero float/double tem um resultado distinto no registrador destino.
Funciona da mesma forma que a instrução anterior porĂ©m comparando um Ășnico valor escalar. O resultado Ă© armazenado no float/double menos significativo do operando fonte.
As quatro instruçÔes comparam os dois operandos escalares e definem as status flags em EFLAGS de acordo com a comparação sem modificar os operandos. Comportamento semelhante ao da instrução CMP.
Quando uma operação aritmĂ©tica com nĂșmeros floats resulta em NaN existem dois tipos diferentes:
quiet NaN (QNaN): O valor é apenas definido para NaN sem qualquer indicação de problema.
signaling NaN (SNaN): O valor é definido para NaN e uma exceção floating-point invalid-operation (#I
) Ă© disparada caso vocĂȘ execute alguma operação com o valor.
A diferença entre COMISS/COMISD e UCOMISS/UCOMISD é que COMISS/COMISD irå disparar a exceção #I
se o primeiro operando for QNaN ou SNaN. Jå UCOMISS/UCOMISD apenas dispara a exceção se o primeiro operando for SNaN.
InstruçÔes de operação aritmética do SSE.
Soma 4 nĂșmeros float (ou 2 nĂșmeros double) de uma Ășnica vez no registrador destino com os quatro nĂșmeros float (ou 2 nĂșmeros double) do registrador/memĂłria fonte. Exemplo:
Funciona da mesma forma que a instrução anterior porém faz uma operação de subtração nos valores.
ADDSS faz a adição do float contido no double word (4 bytes) menos significativo do registrador XMM. Jå ADDSD faz a adição do double contido na quadword (8 bytes) menos significativa do registrador.
Conforme exemplo abaixo:
Funciona da mesma forma que a instrução anterior porém subtraindo os valores.
Funciona como ADDPS/ADDPD porĂ©m multiplicando os nĂșmeros ao invĂ©s de somĂĄ-los.
Funciona como ADDSS/ADDSD porĂ©m multiplicando os nĂșmeros ao invĂ©s de somĂĄ-los.
Funciona como ADDPS/ADDPD porĂ©m dividindo os nĂșmeros ao invĂ©s de somĂĄ-los.
Funciona como ADDSS/ADDSD porĂ©m dividindo os nĂșmeros ao invĂ©s de somĂĄ-los.
Calcula o valor aproximado do inverso multiplicativo do float no operando fonte (a direita) e armazena o resultado na double word (4 bytes) menos significativa do operando destino.
Calcula as raĂzes quadradas dos nĂșmeros floats/doubles no operando fonte e armazena os resultados no operando destino.
Calcula a raiz quadrada do nĂșmero escalar no operando fonte e armazena o resultado no float/double menos significativo do operando destino. Exemplo:
Calcula o inverso multiplicativo da raiz quadrada do nĂșmero escalar no operando fonte e armazena o resultado no double word menos significativo do operando destino.
Compara cada um dos valores contidos nos dois operandos e retorna o maior valor entre os dois.
Compara os dois valores escalares e armazena o maior deles no float/double menos significativo do operando destino.
Funciona da mesma forma que MAXPS/MAXPD porém retornando o menor valor entre cada comparação.
Funciona da mesma forma que MAXSS/MAXSD porém retornando o menor valor entre os dois.
CVTSD2SI converte o valor double no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerå extensão de sinal ().
CVTSS2SI converte o valor float no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerå extensão de sinal ().
Faz uma operação de left com os dois quadwords do registrador XMM. O nĂșmero de vezes que o shift deve ser feito Ă© especificado pelo operando imediato de 8 bits. Os bits menos significativos sĂŁo zerados.
Calcula o valor aproximado do dos floats no operando fonte (a direita) e armazena os valores no operando destino.
Calcula o das raĂzes quadradas dos floats no operando fonte, armazenando os resultados no operando destino. Essa instrução Ă© equivalente ao uso de SQRTPS seguido de RCPPS.
Bit
Nome
Sigla
Descrição
0
Carry Flag
CF
Setado se uma condição de Carry ou Borrow acontecer no bit mais significativo do resultado. Basicamente indica o overflow de um valor não-sinalizado.
2
Parity Flag
PF
âSetado se o byte menos significativo do resultado conter um nĂșmero par de bits ligados (1).
4
Auxiliary Carry Flag
AF
Setado se uma condição de Carry ou Borrow acontecer no bit 3 do resultado.
6
Zero Flag
ZF
Setado se o resultado for zero.
7
Sign Flag
SF
Setado para o mesmo valor do bit mais significativo do resultado (MSB). Onde 0 indica um valor positivo e 1 indica um valor negativo.
11
Overflow Flag
OF
Setado se o resultado nĂŁo tiver o sinal esperado da operação aritmĂ©tica. Basicamente indica o overflow de um nĂșmero sinalizado.
Bit
Nome
Sigla
Descrição
10
Direction Flag
DF
Controla a direção para onde as instruçÔes de string (MOVS
, SCAS
, STOS
, CMPS
e LODS
) irĂŁo decorrer a memĂłria.
Bit
Nome
Sigla
Descrição
8
Trap Flag
TF
Se setada o processador irå executar as instruçÔes do programa passo a passo. Nesse modo o processador dispara uma exception para cada instrução executada. à normalmente usada para depuração de código.
9
Interrupt enable Flag
IF
Controla a resposta do processador para interrupçÔes que podem ser ignoradas (interrupçÔes mascaråveis).
12-13
I/O Privilege Level field
IOPL
Indica o nĂvel de acesso para a comunicação direta com o hardware (operaçÔes de I/O) do programa atual.
14
Nested Task flag
NT
Se setada indica que a tarefa atual estå vinculada com uma tarefa anterior. Essa flag controla o comportamento da instrução IRET
.
16
Resume Flag
RF
Se setada as exceptions disparadas pelo processador são temporariamente desabilitadas na instrução seguinte. Geralmente usada por depuradores.
17
Virtual-8086 Mode flag
VM
Em protected mode se essa flag for setada o processador entra em modo Virtual-8086.
21
Identification flag
ID
Se um processo conseguir setar ou zerar essa flag, isto indica que o processador suporta a instrução CPUID
.
cc
Descrição (inglĂȘs | portuguĂȘs)
Condição
A
if Above | se acima
CF=0 e ZF=0
AE
if Above or Equal | se acima ou igual
CF=0
B
if Below | se abaixo
CF=1
BE
if Below or Equal | se acima ou igual
CF=1 ou ZF=1
C
if Carry | se carry flag estiver setada
CF=1
E
if Equal | se igual
ZF=1
G
if Greater | se maior
ZF=0 e SF=OF
GE
if Greater or Equal | se maior ou igual
SF=OF
L
if Less | se menor
SF!=OF
LE
if Less or Equal | se menor ou igual
ZF=1 ou SF!=OF
NA
if Not Above | se nĂŁo acima
CF=1 ou ZF=1
NAE
if Not Above or Equal | se nĂŁo acima ou igual
CF=1
NB
if Not Below | se nĂŁo abaixo
CF=0
NBE
if Not Below or Equal | se nĂŁo abaixou ou igual
CF=0 e ZF=0
NC
if Not Carry | se carry flag nĂŁo estiver setada
CF=0
NE
if Not Equal | se nĂŁo igual
ZF=0
NG
if Not Greater | se nĂŁo maior
ZF=1 ou SF!=OF
NGE
if Not Greater or Equal |se nĂŁo maior ou igual
SF!=OF
NL
if Not Less | se nĂŁo menor
SF=OF
NLE
if Not Less or Equal | se nĂŁo menor ou igual
ZF=0 e SF=OF
NO
if Not Overflow | se nĂŁo setado overflow flag
OF=0
NP
if Not Parity | se nĂŁo setado parity flag
PF=0
NS
if Not Sign | se nĂŁo setado sign flag
SF=0
NZ
if Not Zero | se nĂŁo setado zero flag
ZF=0
O
if Overflow | se setado overflow flag
OF=1
P
if Parity | se setado parity flag
PF=1
PE
if Parity Even | se parity indica par
PF=1
PO
if Parity Odd | se parity indicar Ămpar
PF=0
S
if Sign | se setado sign flag
SF=1
Z
if Zero | se setado zero flag
ZF=1
Jcc
Descrição (inglĂȘs | portuguĂȘs)
Condição
JCXZ
Jump if CX is zero | pula se CX for igual a zero
CX=0
JECXZ
Jump if ECX is zero |pula se ECX for igual a zero
ECX=0
JRCXZ
Jump if RCX is zero | pula se RCX for igual a zero
RCX=0
Instrução
Valor
FLD1
+1.0
FLDZ
+0.0
FLDL2T
log2(10)
FLDL2E
log2(e)
FLDPI
Valor de PI. (3.1415 blabla...)
FLDLG2
log10(2)
FLDLN2
logE(2)
AH
BH
DH
DL
0x02
PĂĄgina
Linha
Coluna
AH
BH
0x03
PĂĄgina
CH
CL
DH
DL
Scanline inicial
Scanline final
Linha
Coluna
AH
AL
0x05
PĂĄgina
AH
AL
BH
BL
CX
0x09
Caractere
PĂĄgina
Atributo
Vezes para imprimir o caractere
AH
AL
BH
CX
0x0A
Caractere
PĂĄgina
Vezes para imprimir
Registrador
ParĂąmetro
AL
Modo de escrita
BH
PĂĄgina
BL
Atributo
CX
Tamanho da string (nĂșmero de caracteres a serem escritos)
DH
Linha
DL
Coluna
ES:BP
Endereço da string
Bit
Informação
0
Se ligado atualiza a posição do cursor.
1
Se desligado BL Ă© usado para definir o atributo. Se ligado, o atributo Ă© lido da string.
Caractere
Nome
Seq. de escape
Ação
0x07
Bell
\a
Emite um beep.
0x08
Backspace
\b
Retorna o cursor uma posição.
0x09
Horizontal TAB
\t
Avança o cursor 4 posiçÔes.
0x0A
Line feed
\n
Move o cursor verticalmente para a prĂłxima linha.
0x0D
Carriage return
\r
Move o cursor para o inĂcio da linha.
Registrador
Valor
AL
CĂłdigo ASCII do caractere
AH
Scancode da tecla.
Registrador
Valor
AL
CĂłdigo ASCII
AH
Scancode
Bit
Flag
0
Tecla shift direita estĂĄ pressionada.
1
Tecla shift esquerda estĂĄ pressiona.
2
Tecla ctrl estĂĄ pressionada.
3
Tecla alt estĂĄ pressionada.
4
Scroll lock estĂĄ ligado.
5
Num lock estĂĄ ligado.
6
Caps lock estĂĄ ligado.
7
Modo Insert estĂĄ ligado.
Sufixo | Tipo |
S | Single-precision float. Equivalente ao tipo float em C. |
D | Double-precision float. Equivalente ao tipo double em C. Ou inteiro doubleword (4 bytes) que seria um inteiro de 32 bits. |
B | Inteiro de um byte (8 bits). |
W | Inteiro word (2 bytes | 16 bits). |
Q | Inteiro quadword (8 bytes | 64 bits). |
Valor | MnemÎnico | Descrição |
0 | EQ | Verifica se os valores sĂŁo iguais. |
1 | LT | Verifica se o primeiro operando Ă© menor que o segundo. |
2 | LE | Verifica se o primeiro operando Ă© menor ou igual ao segundo. |
3 | UNORD | Verifica se os valores nĂŁo estĂŁo ordenados. |
4 | NEQ | Verifica se os valores nĂŁo sĂŁo iguais. |
5 | NLT | Verifica se o primeiro operando nĂŁo Ă© menor que o segundo (ou seja, se Ă© igual ou maior). |
6 | NLE | Verifica se o primeiro operando nĂŁo Ă© menor ou igual que o segundo (ou seja, se Ă© maior). |
7 | ORD | Verifica se os valores estĂŁo ordenados. |
Aprendendo sobre as convençÔes de chamada usadas no Windows (x64, cdecl e stdcall).
O Windows tem suas prĂłprias convençÔes de chamadas e o objetivo desse tĂłpico Ă© aprender sobre as trĂȘs principais que dizem respeito Ă linguagem C.
Essa Ă© a convenção de chamada padrĂŁo usada em x86-64 e portanto Ă© essencial aprendĂȘ-la caso vĂĄ programar no Windows diretamente em Assembly.
Os registradores RBX, RBP, RDI, RSI, RSP, R12 até R15 e XMM6 até XMM15 devem ser preservados pela função chamada (callee). Caso a função chamada precise alterar o valor de algum desses registradores ela tem a obrigação de preservar o valor anterior e restaurå-lo antes de retornar.
Os demais registradores são considerados volåteis, isto é, podem ter seu valor alterado quando uma chamada de função é efetuada. A função chamada pode modificar o valor dos registradores volåteis livremente.
Os primeiros quatro argumentos inteiros ou ponteiros sĂŁo passados nos seguintes registradores e na mesma ordem: RCX, RDX, R8 e R9. Os demais argumentos devem ser empilhados na ordem inversa.
Os primeiros quatro argumentos float ou double são passados nos registradores XMM0 até XMM3 como valores escalares. Os demais também são empilhados na ordem inversa.
Structs e unions de 8, 16, 32 ou 64 bits são passados como se fossem inteiros do respectivo tamanho. Se forem de outro tamanho a função chamadora deve então passar um ponteiro para a struct/union que serå armazenada em uma memória alocada pela própria função chamadora. Essa memória deve estar em um endereço alinhado por 16 bytes.
A função chamadora (caller) é responsåvel por alocar um espaço de 32 bytes na pilha chamado de shadow space. Ele é alocado com o intuito de ser usado pela função chamada (callee) para armazenar os parùmetros passados em registradores caso seja necessårio, por exemplo caso a função chamada precise usar esses registradores com outro intuito. Esse espaço vem antes mesmo do primeiro parùmetro empilhado.
Exemplo de protótipo de função:
Assim que a função fosse chamada ECX, EDX, R8D e R9D armazenariam os parùmetros a
, b
, c
e d
respectivamente. O parĂąmetro f
seria empilhado seguido do parĂąmetro e
.
O 0(%rsp)
seria o endereço de retorno. O espaço entre 8(%rsp)
e 40(%rsp)
Ă© o shadow space. 40(%rsp)
apontaria para o parĂąmetro e
, enquanto 48(%rsp)
apontaria para o parĂąmetro f
. Como na demonstração abaixo:
Valores inteiros e ponteiros sĂŁo retornados em RAX.
Valores float ou double sĂŁo retornados no registrador XMM0.
O retorno de structs é feito com a função chamadora alocando o espaço de memória necessårio para a struct, ela então passa o ponteiro para esse espaço como primeiro argumento para a função em RCX. A função chamada (callee) deve retornar o mesmo ponteiro em RAX.
A convenção de chamada __cdecl
é a convenção padrão usada em código escrito em C na arquitetura IA-32 (x86).
Apenas os registradores EAX, ECX e EDX são considerados volåteis, ou seja, registradores que podem ser modificados livremente pela função chamada. Todos os demais registradores precisam ser preservados e restaurados antes do retorno da função.
Todos os parùmetros são passados na pilha e devem ser empilhados na ordem inversa. A função chamadora (caller) é a responsåvel por remover os argumentos da pilha após a função retornar.
Exemplo:
Valores inteiros ou ponteiros sĂŁo retornados em EAX.
Valores float ou double sĂŁo retornados em ST0.
O retorno de structs ocorre da mesma maneira que na convenção de chamada x64. Com a diferença que o primeiro argumento é, obviamente, passado na pilha.
A convenção de chamada __stdcall
é a utilizada para chamar funçÔes da WinAPI.
Assim como na __cdecl
os registradores EAX, ECX e EDX são volåteis e os demais devem ser preservados pela função chamada.
Todos os argumentos são passados na pilha na ordem inversa. A função chamada (callee) é a responsåvel por remover os argumentos da pilha. Exemplo:
O retorno de valores funciona da mesma maneira que o retorno de valores da __cdecl
.
Entendendo como variĂĄveis em C sĂŁo representadas em Assembly.
Como jĂĄ vimos no capĂtulo A base, variĂĄveis nada mais sĂŁo do que um espaço de memĂłria que pode ser manipulado pelo programa. Em C existem diversas nuances em como variĂĄveis sĂŁo alocadas e mantidas pelo compilador e aqui vamos entender essas diferenças.
Na linguagem C existem palavra-chaves que sĂŁo chamadas de storage-class specifiers, onde elas determinam o storage-class de uma variĂĄvel. Na prĂĄtica isso determina como a variĂĄvel deve ser armazenada no programa. No C11 existem os seguintes storage-class specifiers:
extern
static
_Thread_local
auto (esse Ă© o padrĂŁo)
register
As variåveis globais em C são alocadas na seção .data
ou .bss
, dependendo se ela foi inicializada ou nĂŁo. Como no exemplo:
Se compilamos com gcc main.c -S -o main.s -fno-asynchronous-unwind-tables
obtemos a seguinte saĂda:
A variĂĄvel data_var
foi alocada na seção .data
e teve seu sĂmbolo exportado com a diretiva .globl data_var
, que Ă© equivalente a diretiva global
do NASM.
JĂĄ a variĂĄvel bss_var
foi declarada com a diretiva .comm symbol, size, aligment
que serve para declarar commom symbols (sĂmbolos comuns). Onde ela recebe como argumento o nome do sĂmbolo seguido de seu tamanho (em bytes) e opcionalmente um valor de alinhamento. Em arquivos objetos ELF o argumento de alinhamento Ă© um alinhamento em bytes, nesse exemplo a variĂĄvel serĂĄ alocada em um endereço alinhado por 4 bytes.
JĂĄ em arquivos objetos PE (do Windows) o alinhamento Ă© um valor em potĂȘncia de dois, logo para alinhar em 4 bytes deverĂamos passar 2 como argumento ( ). Se a gente passar 4 como argumento entĂŁo seria um alinhamento de que daria um alinhamento de 16 bytes.
Os sĂmbolos declarados com a diretiva .comm
que não foram inicializados em qualquer arquivo objeto são alocados na seção .bss
. Logo nesse caso o uso da diretiva seria equivalente ao uso de res*
do NASM, com a diferença que no NASM precisamos usar explicitamente na seção onde o espaço serå alocado.
As variĂĄveis globais com storage-class static
funcionam da mesma maneira que as variĂĄveis globais comum, com a diferença que seu sĂmbolo nĂŁo Ă© exportado para que possa ser acessado em outro arquivo objeto. Como no exemplo:
Onde obtemos a saĂda:
Repare que dessa vez o sĂmbolo data_var
nĂŁo foi exportado com a diretiva .globl
, enquanto o bss_var
foi explicitamente declarado como local com a diretiva .local
(jĂĄ que a diretiva .comm
exporta como global por padrĂŁo).
VariĂĄveis extern
em C sĂŁo basicamente variĂĄveis que sĂŁo definidas em outro mĂłdulo. O GAS tem uma diretiva .extern
que Ă© equivalente a diretiva extern
do NASM, isto Ă©, indica que o sĂmbolo serĂĄ definido em outro arquivo objeto. PorĂ©m qualquer sĂmbolo nĂŁo declarado jĂĄ Ă© considerado externo por padrĂŁo pelo GAS. Experimente ver o cĂłdigo de saĂda do exemplo abaixo:
VocĂȘ vai reparar que na função main
o sĂmbolo extern_var
foi lido porém ele não foi declarado.
Variåveis locais em C são comumente alocadas no stack frame da função, porém em alguns casos o compilador também pode reservar um registrador para armazenar o valor da variåvel.
Em C existe o storage-class register
que serve como um "pedido" para o compilador alocar aquela variĂĄvel de forma que o acesso a mesma seja o mais rĂĄpido possĂvel, que geralmente Ă© em um registrador (daĂ o nome da palavra-chave). Mas isso nĂŁo garante que a variĂĄvel serĂĄ realmente alocada em um registrador. Na prĂĄtica o Ășnico efeito colateral garantido Ă© que vocĂȘ nĂŁo poderĂĄ obter o endereço na memĂłria daquela variĂĄvel com o operador de endereço (&
), e muitas vezes o compilador jĂĄ vai alocar a variĂĄvel em um registrador mesmo sem o uso da palavra-chave.
VariĂĄveis static
local sĂŁo armazenadas da mesma maneira que as variĂĄveis static
global, a Ășnica coisa que muda Ă© no ponto de vista do cĂłdigo-fonte em C onde a visibilidade da variĂĄvel Ă© limitada para o escopo onde ela foi declarada. Isso faz com o que o compilador gere um sĂmbolo de nome Ășnico para a variĂĄvel, como no exemplo abaixo:
Repare como data_var.1913
nĂŁo teve seu sĂmbolo exportado e bss_var.1914
foi declarado como local.
O storage-class _Thread_local
foi adicionado no C11. Assim como o nome sugere ele serve para alocar variĂĄveis em uma regiĂŁo de memĂłria que Ă© local para cada thread do processo. Vamos analisar o exemplo:
No Linux, em x86-64, a regiĂŁo de memĂłria local para cada thread (thread-local storage - TLS) fica no segmento apontado pelo registrador de segmento FS, por isso os valores das variĂĄveis estĂŁo sendo lidos desse segmento.
Repare que as seçÔes são diferentes, .tdata
(equivalente a .data
sĂł que thread-local) e .tbss
(equivalente a .bss
) sĂŁo utilizadas para armazenar as variĂĄveis.
O sufixo @tpoff
(thread pointer offset) usado nos sĂmbolos indica que o offset do sĂmbolo deve ser calculado levando em consideração a TLS como endereço de origem. Por padrĂŁo o offset Ă© calculado com o segmento de dados "normal" como origem.
Agora que jĂĄ entendemos onde e como as variĂĄveis sĂŁo alocadas em C, sĂł falta entender "o que" estĂĄ sendo armazenado.
O tipo array em C Ă© meramente uma sequĂȘncia de variĂĄveis do mesmo tipo na memĂłria. Por exemplo podemos inicializar um int arr[4]
na sintaxe do GAS da seguinte forma:
Onde os valores 1
, 2
, 3
e 4
sĂŁo despejados em sequĂȘncia.
Em C não existe um tipo string porém por convenção as strings são uma array de char
, onde o Ășltimo char
contĂ©m o valor zero (chamado de terminador nulo). Esse Ășltimo caractere '\0'
é usado para denotar o final da string e funçÔes da libc que lidam com strings esperam por isso. Exemplos:
As trĂȘs strings acima sĂŁo equivalentes na sintaxe do GAS.
Sobre a passagem de arrays (incluindo obviamente strings) como argumento para uma função, isso é feito passando um ponteiro para o primeiro elemento da array.
Ponteiros em C, na arquitetura x86/x86-64, sĂŁo traduzidos meramente como o offset do objeto na memĂłria. O segmento nĂŁo Ă© especificado como parte do valor do ponteiro.
Experimente ler o cĂłdigo de saĂda do seguinte programa:
A leitura do endereço de my_var
vai ser compilada para algo como:
Onde primeiro Ă© obtido o endereço do inĂcio do segmento FS que depois Ă© somado ao offset de my_var
. Assim obtendo o endereço efetivo da variåvel na memória.
As estruturas em C sĂŁo compiladas de forma que os valores dos campos da estrutura sĂŁo dispostos em sequĂȘncia na memĂłria, seguindo a mesma ordem que foram declarados na estrutura. Existe a possibilidade do GCC adicionar alguns bytes extras no final da estrutura afim de manter o alinhamento dos dados, esses bytes extras sĂŁo chamados de padding. Exemplo:
Isso produziria o seguinte código para a inicialização da variåvel test
:
Repare a diretiva .zero 3
que foi usada para despejar 3 bytes zero no final da estrutura, afim de alinhar a mesma em 4 bytes. No total a estrutura acaba tendo 8 bytes de tamanho: 4 bytes do int
, 1 byte do char
e 3 bytes de padding.
As unions sĂŁo bem simples, sĂŁo alocadas com o tamanho do maior tipo declarado para a union. Por exemplo:
Essa union Ă© alocada na memĂłria da mesma forma que um int
, que tem 4 bytes de tamanho.
Entendendo a execução de código em C no ambiente freestanding.
O ambiente de execução freestanding Ă© normalmente usado quando o cĂłdigo C Ă© compilado para executar fora de um sistema operacional. Nesse ambiente nenhum dos recursos provindos do ambiente hosted sĂŁo garantidos e sua existĂȘncia ou nĂŁo depende da implementação.
Os Ășnicos recursos que sĂŁo oferecidos pela libc sĂŁo os declarados nos seguintes header files:
<float.h>, <iso646.h>, <limits.h>, <stdalign.h>, <stdarg.h>, <stdbool.h>, <stddef.h>, <stdint.h> e <stdnoreturn.h>.
Quaisquer outros recursos são dependentes de implementação.
No GCC para compilar um cĂłdigo visando o ambiente freestanding Ă© possĂvel usar a opção -ffreestanding
. Também se pode usar a opção -fhosted
para compilar para ambiente hosted mas esse jĂĄ Ă© o padrĂŁo.
Jå a opção -nostdlib
desabilita a linkedição da libc.
Aprendendo a depurar cĂłdigo em nĂvel de Assembly
O termo depuração (debugging) Ă© usado na ĂĄrea da computação para se referir ao ato de procurar e corrigir falhas (bugs) em softwares. A principal ferramenta, embora nĂŁo Ășnica, usada para essa tarefa Ă© um tipo de software conhecido como depurador (debugger). Essa ferramenta basicamente dĂĄ ao programador a possibilidade de controlar a execução de um programa enquanto ele pode ver informaçÔes sobre o processo em tempo de execução.
Existem depuradores que meramente exibem o código-fonte do programa e o programador acompanha a execução do código vendo o código-fonte do projeto. Mas existe uma categoria de depuradores que exibem o disassembly do código do programa e o programador é capaz de ver a execução do código acompanhando as instruçÔes em Assembly.
Este capĂtulo tem por objetivo dar uma noção bĂĄsica de como depuradores funcionam e ensinar a usar algumas ferramentas para depuração de cĂłdigo.
O conteĂșdo serĂĄ principalmente baseado em ferramentas sendo utilizadas em ambiente Linux, porĂ©m a maior parte do conteĂșdo Ă© reaproveitĂĄvel no Windows.
CĂłdigos de exemplo serĂŁo escritos em C e compilados com o GCC, bem como alguns serĂŁo escritos diretamente em Assembly e usando o assembler NASM.
Nomenclatura | Descrição |
xmm(n) | Indica qualquer um dos registradores XMM. |
float(n) | Indica N nĂșmeros floats em sequĂȘncia na memĂłria RAM. Exemplo: float(4) seriam 4 nĂșmeros float totalizando 128 bits de tamanho. |
double(n) | Indica N nĂșmeros double na memĂłria RAM. Exemplo: double(2) que totaliza 128 bits. |
ubyte(n) | Indica N bytes nĂŁo-sinalizados na memĂłria RAM. Exemplo: ubyte(16) que totaliza 128 bits. |
byte(n) | Indica N bytes sinalizados na memĂłria RAM. |
uword(n) | Indica N words (2 bytes) nĂŁo-sinalizados na memĂłria RAM. Exemplo: uword(8) que totaliza 128 bits. |
word(n) | Indica N words sinalizadas na memĂłria RAM. |
dword(n) | Indica N double words (4 bytes) na memĂłria RAM. |
qword(n) | Indica N quadwords (8 bytes) na memĂłria RAM. |
reg32/64 |
imm8 | Operando imediato de 8 bits de tamanho. |
Aprendendo sobre a convenção de chamada do C usada no Linux.
Sistemas UNIX-Like, incluindo o Linux, seguem a padronização da System V ABI (ou SysV ABI). Onde ABI é sigla para Application Binary Interface (Interface binåria de aplicação) que é basicamente uma padronização que dita como código binårio deve ser escrito e executado no sistema operacional. Uma das coisas que a SysV ABI padroniza é a convenção de chamada utilizada em cada arquitetura de processador.
Neste tópico vamos aprender sobre a convenção de chamada da SysV ABI e o tamanho dos tipos de dados usados na linguagem C.
Os registradores RBP, RBX, RSP e R12 até R15 são considerados como pertencentes a função chamadora. Isto é, se a função que foi chamada precisar modificar esses registradores ela obrigatoriamente precisa preservar seus valores e antes de retornar restaurå-los para o valor anterior. Todos os outros registradores podem ser modificados livremente pela função chamada. Portanto não espere que esses outros registradores tenham seu valor preservado ao chamar uma função.
A Direction Flag (DF) no RFLAGS precisa obrigatoriamente estar zerada ao chamar ou retornar de uma função.
Cada função chamada pode (se precisar) reservar um pedaço da pilha para ser usada como memĂłria local da função e pode, por exemplo, ser usada para alocar variĂĄveis locais. Esse espaço Ă© chamado de stack frame e o cĂłdigo que aloca e desaloca o stack frame Ă© chamado de prĂłlogo e epĂlogo respectivamente. Exemplo:
O espaço de 128 bytes antes do endereço apontado por RSP é uma região chamada de redzone que por convenção pode ser usada por funçÔes folha (leaf), que são funçÔes que não chamam outras funçÔes. Ou então pode ser usada em qualquer função onde o valor não precise ser preservado após chamar outra função.
O endereço entre -128(%rsp)
e -1(%rsp)
pode ser usado livremente sem a necessidade de alocar um stack frame.
Vale lembrar que CALL empilha o endereço de retorno, portanto ao chamar uma função 0(%rsp)
aponta para o endereço de retorno da mesma.
Os parùmetros inteiros (e ponteiros) são passados em registradores de propósito geral na seguinte ordem: RDI, RSI, RDX, RCX, R8 e R9. Parùmetros float ou double são passados nos registradores XMM0 até XMM7 como valores escalares (na parte menos significativa do registrador).
Caso a função precise de mais argumentos e os registradores acabem, os demais argumentos serão empilhados na ordem inversa. Por exemplo caso uma função precise de 9 argumentos inteiros eles seriam definidos na seguinte ordem pela função chamadora:
Assim que a função fosse chamada 8(%rsp)
, 16(%rsp)
e 24(%rsp)
apontariam para os argumentos 7, 8 e 9 respectivamente.
A função chamadora (caller) precisa garantir que o Ășltimo valor empilhado esteja em um endereço alinhado por 16 bytes.
A função chamadora é a responsåvel por remover os argumentos empilhados da pilha.
No caso do retorno de estruturas (structs) a função chamadora precisa alocar o espaço necessårio para a struct e passar o endereço do espaço no registrador RDI como se fosse o primeiro argumento para a função (os outros argumentos usam RSI em diante). A função então precisa retornar o mesmo endereço passado por RDI em RAX.
O retorno de valores inteiros e ponteiros Ă© feito no registrador RAX.
Valores float ou double sĂŁo retornados no registrador XMM0 na parte menos significativa.
Os registradores EBX, EBP, ESI, EDI e ESP precisam ter seus valores preservados pela função chamada. Os demais registradores de propósito geral podem ser usados livremente.
A Direction Flag (DF) no EFLAGS precisa obrigatoriamente estar zerada ao chamar ou retornar de uma função.
O stack frame em IA-32 funciona da mesma maneira que o stack frame em x86-64, com a diferença de que não existe redzone em IA-32 e toda função que precisar de memória local precisa obrigatoriamente construir um stack frame.
Vale lembrar que cada valor inserido na stack em IA-32 tem 4 bytes de tamanho, enquanto em x86-64 cada valor tem 8 bytes de tamanho.
Os argumentos da função são empilhados na ordem inversa, assim como ocorre em x86-64 quando os registradores acabam. Conforme exemplo:
Assim que a função é chamada 4(%esp)
, 8(%esp)
, 12(%esp)
e 16(%esp)
apontam para os argumentos 1, 2, 3 e 4 respectivamente.
A função chamadora precisa garantir que o Ășltimo valor empilhado esteja em um endereço alinhado por 16 bytes.
A função chamadora é a responsåvel por remover os argumentos empilhados da pilha.
Retorno de struct Ă© feito de maneira semelhante do x86-64. Um ponteiro para a regiĂŁo de memĂłria para gravar os dados da struct Ă© passado como primeiro argumento para a função (o Ășltimo valor a ser empilhado). Ă obrigação da função chamada fazer o pop desse ponteiro e retornĂĄ-lo em EAX.
Valores inteiros e ponteiros sĂŁo retornados em EAX.
Valores float ou double são retornados em ST0 (ver Usando instruçÔes da FPU).
Existe uma convenção de escrita do prĂłlogo e do epĂlogo da função que se trata de preservar o antigo valor de ESP/RSP no registrador EBP/RBP, e depois subtrair ESP/RSP para alocar o stack frame. Conforme exemplo:
Também existe a instrução leave
que pode ser usada no epĂlogo. Ela basicamente faz a operação de mov %rbp, %rsp
e pop %rbp
em uma Ășnica instrução (tambĂ©m pode ser usada em 32 e 16 bits atuando com EBP/ESP e BP/SP respectivamente).
Mas como jĂĄ foi demonstrado em um exemplo mais acima isso nĂŁo Ă© obrigatĂłrio e podemos apenas incrementar e subtrair ESP/RSP no prĂłlogo e no epĂlogo. CĂłdigo otimizado gerado pelo GCC costuma apenas fazer isso, jĂĄ cĂłdigo com a otimização desligada costuma gerar o prĂłlogo e epĂlogo "clĂĄssico".
A tabela abaixo lista os principais tipos da linguagem C e seu tamanho em bytes no IA-32 e x86-64. Como também exibe em qual registrador o tipo deve ser retornado.
*No registrador EDX Ă© armazenado os 32 bits mais significativos e em EAX os 32 bits menos significativos.
**O tipo long double
ocupa na memória o espaço de 12 e 16 bytes por motivos de alinhamento, mas na verdade se trata de um float de 80 bits (10 bytes).
Entendendo as funçÔes em C do ponto de vista do Assembly.
A linguagem C tem algumas variaçÔes Ă respeito de funçÔes e o objetivo deste tĂłpico Ă© explicar, do ponto de vista do baixo-nĂvel, como elas funcionam.
As funçÔes na linguagem C tĂȘm protĂłtipos que servem como uma "assinatura" indicando quais parĂąmetros a função recebe e qual tipo de valor ela retorna. Um exemplo:
Esse protótipo jå nos då todas as informaçÔes necessårias que saibamos como fazer a chamada da função e como obter seu valor de retorno, desde que nós conheçamos a convenção de chamada utilizada. Os parùmetros são considerados da esquerda para a direita, logo o parùmetro x
Ă© o primeiro e o parĂąmetro y
é o segundo. Na convenção de chamada da SysV ABI esses argumentos estariam em EDI e ESI, respectivamente. E o retorno seria feito em EAX.
Existem alguns protĂłtipos um pouco diferentes que vale explicar aqui para deixar claro seu entendimento. Como este:
De acordo com a especificação do C11 uma expressão do tipo void
Ă© um tipo cujo o valor nĂŁo existe e deve ser ignorado. FunçÔes assim sĂŁo compiladas retornando sem se preocupar em modificar o valor de RAX (ou qualquer outro registrador que poderia ser usado para retornar um valor) e portanto nĂŁo se deve esperar que o valor nesse registrador tenha alguma informação Ăștil.
Quando void
é usado no lugar da lista de parùmetros ele tem o significado especial de indicar que aquela função não recebe parùmetro algum ao ser chamada.
Embora possa ser facilmente confundido com o caso acima, onde se usa void
na lista de parùmetros, na verdade esse protótipo de função não diz que a função não recebe parùmetros. Na verdade esse é um protótipo que não especifica quais tipos ou quantos parùmetros a função recebe, logo o compilador aceita que a função seja chamada passando qualquer tipo e qualquer quantidade de parùmetros, inclusive sem parùmetro algum também. Veja o exemplo:
Na convenção de chamada da SysV ABI os argumentos para esse tipo de função sĂŁo passados da mesma maneira que uma chamada com o protĂłtipo "normal". A Ășnica diferença Ă© que a função recebe um argumento extra no registrador AL indicando quantos registradores de vetor foram utilizados para passar argumentos de ponto-flutuante. Nesse exemplo apenas um argumento era um float e por isso hĂĄ a instrução movl $1, %eax
indicando esse nĂșmero. Experimente usar mais argumentos float ou nĂŁo passar nenhum para ver se o nĂșmero passado em AL como argumento irĂĄ mudar de acordo.
FunçÔes com argumentos variåveis também seguem a mesma regra de chamada do que foi mencionado acima.
FunçÔes static sĂŁo visĂveis apenas no mesmo mĂłdulo em que elas foram declaradas, ou seja, seu sĂmbolo nĂŁo Ă© exportado. Exemplo:
Existem dois especificadores de função no C11, onde eles são:
O especificador inline
Ă© uma sugestĂŁo para que a chamada para a função seja a mais rĂĄpida possĂvel. Isso tem o efeito colateral no GCC de inibir a geração de cĂłdigo para a função em Assembly. Ao invĂ©s disso as instruçÔes da função sĂŁo geradas no local onde ela foi chamada, e portanto o sĂmbolo da função nunca Ă© de fato declarado.
O GCC, mesmo para uma função inline, ainda vai gerar o cĂłdigo para a chamada da função caso as otimizaçÔes estejam desligadas e isso vai acabar produzindo um erro de referĂȘncia por parte do linker. Lembre-se de sempre ligar as otimizaçÔes de cĂłdigo quando estiver usando funçÔes inline.
FunçÔes com o especificador _Noreturn
nunca devem retornar para a função chamadora. Quando esse especificador é utilizado o compilador irå gerar código assumindo que a função nunca retorna. Como podemos ver no exemplo abaixo compilado com -O2
:
Nested functions Ă© uma extensĂŁo do GCC que permite declarar funçÔes aninhadas. O sĂmbolo de uma função aninhada Ă© gerado de maneira semelhante ao sĂmbolo de uma variĂĄvel local com storage-class static
. Exemplo:
Os atributos de função é uma extensão do GCC que permite modificar algumas propriedades relacionadas à uma função. Se define atributos para uma função usando a palavra-chave __attribute__
e entre dois parĂȘnteses uma lista de atributos separado por vĂrgula. Exemplo:
Alguns atributos recebem parĂąmetros onde estes devem ser adicionados dentro de mais um par de parĂȘnteses, se assemelhando a sintaxe de uma chamada de função. Exemplo: __attribute__((section (".another"), cdecl))
.
Abaixo alguns atributos que podem ser usados na arquitetura x86 e acho interessante citar aqui:
Esses atributos fazem com que o compilador gere o cĂłdigo da função usando a convenção de chamada ms_abi, sysv_abi, cdecl, stdcall, fastcall ou thiscall respectivamente. TambĂ©m Ă© Ăștil usĂĄ-los em protĂłtipos de funçÔes onde a função utiliza uma convenção de chamada diferente da padrĂŁo.
Os atributos cdecl
, stdcall
, fastcall
e thiscall
sĂŁo ignorados em 64-bit.
Por padrão o GCC irå adicionar o código das funçÔes na seção .text
, porĂ©m Ă© possĂvel usar o atributo section
para que o compilador adicione o código da função em outra seção. Como no exemplo abaixo:
O atributo naked
Ă© usado para desativar a geração do prĂłlogo e epĂlogo para a função. Isso Ă© Ăștil para se escrever funçÔes usando inline Assembly dentro das mesmas.
Esse atributo serve para personalizar a geração de cĂłdigo do compilador para uma função especĂfica, permitindo selecionar quais instruçÔes serĂŁo utilizadas ao gerar o cĂłdigo. TambĂ©m Ă© possĂvel adicionar o prefixo no-
para desabilitar alguma tecnologia e impedir que o compilador gere cĂłdigo para ela. Por exemplo __attribute__((target ("no-sse"))
desativaria o uso de instruçÔes ou registradores SSE na função.
Alguns dos possĂveis alvos para arquitetura x86 sĂŁo:
Jå vimos alguns exemplos de código chamando funçÔes da libc, essas funçÔes porém estão em uma biblioteca dinùmica e não dentro do executåvel. A resolução do endereço (symbol binding) das funçÔes na biblioteca é feito em tempo de execução onde os endereços são salvos na seção GOT (Global Offset Table).
A seção PLT (Procedure Linkage Table) simplesmente armazena saltos para os endereços armazenados na GOT. Por isso o GCC gera chamadas para funçÔes da libc assim:
O sufixo @PLT
indica que o endereço do sĂmbolo estĂĄ na seção PLT. Onde nessa seção hĂĄ uma instrução jmp
para o endereço que serå resolvido em tempo de execução na GOT. Algo parecido com a ilustração abaixo:
Na sintaxe do NASM o equivalente ao uso do sufixo com @
do GAS Ă© a palavra-chave wrt
(With Reference To), conforme exemplo:
Entendendo os conceitos principais sobre um depurador e como eles funcionam.
Depuradores (debuggers) sĂŁo ferramentas que atuam se conectando (attaching) em processos para controlar e monitorar a execução dos mesmos. Isso Ă© possĂvel por meio de recursos que o prĂłprio sistema operacional provĂ©m, no caso do Linux por meio da syscall ptrace.
O processo que se conecta Ă© chamado de tracer e o processo conectado Ă© chamado de tracee. Essa conexĂŁo Ă© chamada de attach e Ă© feita em uma thread individual do processo. Quando o depurador faz attach em um processo ele na verdade estĂĄ fazendo attach na thread principal do processo.
As threads são tarefas individuais em um processo. Cada thread de um processo executa um código diferente de maneira concorrente em relação as outras threads do mesmo processo.
Um processo Ă© basicamente a imagem de um programa em execução. Uma parte do sistema operacional conhecida como loader (ou dynamic linker) Ă© a responsĂĄvel por ler o arquivo executĂĄvel, mapear seus cĂłdigos e dados na memĂłria, carregar dependĂȘncias (bibliotecas) resolvendo seus sĂmbolos e iniciar a execução da thread principal do processo no cĂłdigo que estĂĄ no endereço do entry point do executĂĄvel. Onde entry point se trata de um endereço armazenado dentro do arquivo executĂĄvel e Ă© o endereço onde a thread principal inicia a execução.
O depurador tem acesso a memória de um processo e pode controlar a execução das threads do processo. Ele também tem acesso a outras informaçÔes sobre o processo, como o valor dos registradores em uma thread por exemplo.
Do ponto de vista de cada thread de um processo ela tem exclusividade na execução de código no processador e no acesso a seus recursos. Inclusive em Assembly usamos registradores do processador diretamente sem nos preocuparmos com outras threads (do mesmo processo ou de outros) usando os mesmos registradores "ao mesmo tempo".
Cada nĂșcleo (core) do processador tĂȘm um conjunto individual de registradores, mas Ă© comum em um sistema operacional moderno diversas tarefas estarem concorrendo para executar em um mesmo nĂșcleo.
Uma parte do sistema operacional chamada de scheduler Ă© responsĂĄvel por gerenciar quando e qual tarefa serĂĄ executada em um determinado nĂșcleo do processador. Isso Ă© chamado de escalonamento de processos (scheduling) e quando o scheduler suspende a execução de uma tarefa para executar outra isso Ă© chamado de troca de contexto ou troca de tarefa (context switch ou task switch).
Quando hĂĄ a troca de contexto o scheduler se encarrega de salvar na memĂłria RAM o estado atual do processo, e isso inclui o valor dos registradores. Quando a tarefa volta a ser executada o estado Ă© restaurado do ponto onde ele parou, e isso inclui restaurar o valor de seus registradores.
à assim que cada thread tem valores distintos em seus registradores. à assim também que depuradores são capazes de ler e modificar o valor de registradores em uma determinada thread do processo, o sistema operacional då a capacidade de acessar esses valores no contexto da tarefa e permite fazer a modificação. Quando o scheduler executar a tarefa o valor dos registradores serão atualizados com o valor armazenado no contexto.
Processadores Intel mais modernos tĂȘm uma tecnologia chamada Hyper-Threading. Essa tecnologia permite que um mesmo nĂșcleo atue como se fosse dois permitindo que duas threads sejam executadas paralelamente no mesmo nĂșcleo.
Cada "parte" independente Ă© chamada de processador lĂłgico (logical processor) e cada processador lĂłgico no nĂșcleo tem seu conjunto individual de registradores. Com exceção de alguns registradores "obscuros" que sĂŁo compartilhados pelos processadores lĂłgicos do nĂșcleo. Esses registradores nĂŁo foram abordados no livro, mas caso esteja curioso pesquise por Model-specific register (MSR) e MTRRs. Apenas alguns MSR sĂŁo compartilhados pelos processadores lĂłgicos.
Os sinais é um mecanismo de comunicação entre processos (Inter-Process Communication - IPC). Existem determinados sinais em cada sistema operacional e quando um sinal é enviado para um processo ele é temporariamente suspenso e um tratador (handler) do sinal é executado.
A maioria dos sinais podem ter o tratador personalizado pelo programador mas alguns tĂȘm um tratador padrĂŁo e nĂŁo podem ser alterados. Ă o caso por exemplo no Linux do sinal SIGKILL, que Ă© o sinal enviado para um processo quando vocĂȘ tenta forçar a finalização dele (com o comando kill -9
por exemplo). O tratador desse sinal Ă© exclusivamente controlado pelo sistema operacional e o processo nĂŁo Ă© capaz de personalizar ele.
Exemplo de personalização do tratador de um sinal:
Experimente compilar e executar esse programa. No Linux vocĂȘ pode enviar o sinal SIGTERM para o processo com o comando kill
, como em:
O sinal SIGTERM seria o jeito "educado" de finalizar um processo. PorĂ©m como pode ser observado Ă© possĂvel que o processo personalize o tratador desse sinal, que por padrĂŁo finaliza o programa. Nesse cĂłdigo de exemplo se removermos a chamada para a função _Exit()
o processo nĂŁo irĂĄ mais finalizar ao receber SIGTERM. Ă por isso que existe o sinal mais "invasivo" SIGKILL que foi feito para ser usado quando o processo nĂŁo estĂĄ mais respondendo.
Um processo que estå sendo depurado (o tracee) para toda vez que recebe um sinal e o depurador toma o controle da execução. Exceto no caso de SIGKILL que funciona normalmente sem a intervenção do depurador.
Depuradores tĂȘm a capacidade de controlar a execução das threads de um processo, tratar os sinais enviados para o processo, acessar sua memĂłria e ver/editar dados relacionados ao contexto de cada thread (como os registradores, por exemplo). Todo esse poder Ă© dado para os usuĂĄrios do depurador por meio de alguns recursos que serĂŁo descritos abaixo.
Um ponto de parada (breakpoint) é um ponto no código onde a execução do programa serå interrompida e o depurador irå manter o programa em pausa para que o usuårio possa controlar a execução em seguida.
Em arquiteturas que nĂŁo tĂȘm uma exceção especĂfica para disparar breakpoints os depuradores substituem a instrução por alguma outra instrução que dispararĂĄ alguma exceção. Como uma instrução de divisĂŁo ilegal por exemplo.
Podemos comprovar isso com o seguinte cĂłdigo:
O termo software breakpoint é usado para se referir a um breakpoint que é definido e configurado por software (o depurador), como o que jå foi descrito acima. Por exemplo breakpoints podem ter uma condição de parada e isso é implementado pelo próprio depurador. Ele faz o tratamento do breakpoint normalmente mas antes verifica a condição, se a condição não for atendida ele continua a execução do código como se o breakpoint nunca tivesse acontecido.
Jå o termo hardware breakpoint é usado para se referir a um breakpoint que é suportado pelo próprio processador. A arquitetura x86-64 tem 8 registradores de depuração (debug registers) onde 4 deles podem ser usados para indicar breakpoints.
Os registradores DR0, DR1, DR2 e DR3 armazenam o endereço onde irå ocorrer o breakpoint. Jå o registrador DR7 habilita ou desabilita esses breakpoints e configura uma condição para eles. Onde a condição determina em qual ocasião o breakpoint serå disparado, como por exemplo ao ler/escrever naquele endereço ou ao executar a instrução no endereço.
Os debug registers não podem ser lidos/modificados sem privilégios de kernel. Rodando sobre um sistema operacional um processo comum não é capaz de manipulå-los diretamente.
Esse mesmo recurso (com até mais recursos ainda) poderia ser implementado pelo depurador com um software breakpoint. Por exemplo caso o depurador queira que um breakpoint seja disparado ao ler/escrever em um determinado endereço o depurador pode simplesmente modificar as permissÔes de acesso daquele endereço e, quando o processo fosse acessar os dados naquele endereço, uma exceção #GP seria disparada e o depurador poderia retomar o controle da execução.
Depuradores nĂŁo sĂŁo apenas capazes de executar o software e esperar por um breakpoint para retomar o controle. Eles podem tambĂ©m executar apenas uma instrução da thread por vez e permanecer controlando a execução. Isso Ă© chamado de execução passo a passo (step by step), onde o "passo" Ă© uma Ășnica instrução. O usuĂĄrio do depurador pode clicar em um botĂŁo ou executar um comando e apenas uma instrução do processo serĂĄ executada, e o usuĂĄrio pode ver o resultado da instrução e optar pelo que fazer em seguida.
Existe também o conceito de step over que é quando o depurador executa apenas "uma instrução" porém passando todas as instruçÔes da rotina chamada pelo CALL. O que ele faz na pråtica é definir um breakpoint temporårio para a instrução seguinte ao CALL, como na ilustração:
Se o depurador estiver parado no CALL e executamos um step over, o depurador coloca o breakpoint temporårio na instrução TEST e então irå executar o processo. Quando o breakpoint na instrução TEST for alcançado ele serå removido e o controle serå dado para o usuårio.
Repare no "defeito" desse mecanismo. O step over só funciona apropriadamente se a instrução seguinte ao CALL realmente for executada, senão o processo continuarå a execução normalmente. Experimente rodar o seguinte código em um depurador:
Compile com:
Ao dar um step over na chamada call oops
um comportamento inesperado ocorre, o programa irĂĄ finalizar sem parar apĂłs o retorno da chamada. Isso Ă© demonstrado na imagem abaixo com o depurador GDB:
Muitos depuradores voltados para desenvolvedores leem informaçÔes de depuração à respeito do executåvel produzidas pelo próprio compilador. O compilador pode, por exemplo, dar informaçÔes para que o depurador seja capaz de identificar de qual arquivo e linha do código-fonte uma instrução pertence.
à assim que funcionam os depuradores que exibem o código-fonte (ao invés de apenas as instruçÔes em Assembly) enquanto executam o processo.
No caso do GCC ele armazena essas informaçÔes dentro do prĂłprio executĂĄvel na tabela de sĂmbolos. JĂĄ o compilador da Microsoft, usado no Visual Studio, atualmente gera um arquivo .pdb
contendo todas as informaçÔes de depuração.
Vale ressaltar aqui que o GCC (e qualquer outro compilador) não armazena o código-fonte do projeto dentro do executåvel. Ele meramente armazena o endereço do arquivo lå.
à comum também que depuradores apresentem algum erro ao não encontrar o arquivo-fonte indicado no endereço armazenado nas informaçÔes de depuração. Isso acontece quando ele tenta apresentar uma linha de código naquele arquivo mas o mesmo não foi encontrado na sua måquina.
Aprendendo a mesclar Assembly e C
Se vocĂȘ leu o conteĂșdo do livro atĂ© aqui jĂĄ tem uma boa base para entender como o Assembly x86 funciona e como usĂĄ-lo. TambĂ©m jĂĄ tem uma boa noção do que estĂĄ fazendo, entende bem o que o assembler faz e o que ele estĂĄ produzindo como saĂda, sabe como efetuar cĂĄlculos em paralelo usando SSE inclusive com valores de ponto flutuante.
Em outras palavras vocĂȘ jĂĄ tem a base necessĂĄria para realmente entender como as coisas funcionam, nĂŁo decoramos instruçÔes aqui mas sim entendemos as coisas em seu Ăąmago. Agora estĂĄ na hora de dar um passo a frente e entender como usar Assembly de uma maneira Ăștil no "mundo real", vamos aprender a usar C e Assembly juntos afim de escrever programas.
Jå estamos fazendo isso desde o começo mas não entramos em muitos detalhes pois eu queria que inicialmente o foco fosse em entender como as coisas funcionam, essa é a parte legal .
Como jĂĄ mencionado antes vamos usar o GCC para compilar nossos cĂłdigos em C. Mas diferente dos capĂtulos anteriores que usamos o NASM, neste aqui vamos usar o assembler GAS com sintaxe da AT&T porque assim aprendemos a ler cĂłdigo nessa sintaxe e a usar o GAS ao mesmo tempo.
Por convenção a gente usa a extensão .s
(ao invés de .asm
) para cĂłdigo ASM com sintaxe da AT&T, entĂŁo Ă© a extensĂŁo que irei usar daqui em diante para nomear os arquivos.
Assim como fizemos em A base aqui estĂĄ um cĂłdigo de teste para garantir que o seu ambiente estĂĄ correto:
O nome do executĂĄvel do GAS Ă© as e quando vocĂȘ instala o GCC ele vem junto, entĂŁo vocĂȘ jĂĄ tem ele instalado aĂ. JĂĄ pode tentar compilar com:
Caso tenha algum problema e precise de ajuda, pode entrar no fĂłrum do Mente BinĂĄria e fazer uma pergunta.
Ao usar o GCC Ă© possĂvel passar o parĂąmetro -masm=intel
para que o compilador gere cĂłdigo Assembly na sintaxe da Intel, onde por padrĂŁo ele gera cĂłdigo na sintaxe da AT&T. VocĂȘ pode ver o cĂłdigo de saĂda da seguinte forma:
Onde a flag -S
faz com que o compilador apenas compile o cĂłdigo, sem produzir o arquivo objeto de saĂda e ao invĂ©s disso salvando o cĂłdigo em Assembly. Pode ser Ăștil fazer isso para aprender mais sobre a sintaxe do GAS.
A flag -fno-asynchronous-unwind-tables
serve para desabilitar as diretivas CFI e melhorar a leitura do cĂłdigo de saĂda. Essas diretivas servem para gerar informação Ăștil para um depurador mas para fins de leitura do cĂłdigo nĂŁo precisamos delas.
VocĂȘ tambĂ©m pode habilitar as otimizaçÔes do GCC com a opção -O2
assim o cĂłdigo de saĂda serĂĄ otimizado. Pode ser interessante fazer isso para aprender alguns truques de otimização.
Aprendendo a sintaxe AT&T e a usar o GAS
O GNU assembler (GAS) usa por padrĂŁo a sintaxe AT&T e neste tĂłpico irei ensinĂĄ-la. Mais abaixo irei ensinar a diretiva usada para usar sintaxe Intel meramente como curiosidade e caso prefira usĂĄ-la.
A primeira diferença notåvel é que o operando destino nas instruçÔes de sintaxe Intel é o mais à esquerda, o primeiro operando. Jå na sintaxe da AT&T é o inverso, o operando mais à direita é o operando destino. Conforme exemplo:
E como jĂĄ pode ser observado valores literais precisam de um prefixo $
, enquanto os nomes dos registradores precisam do prefixo %
.
Na sintaxe da Intel o tamanho dos operandos é especificado com base em palavra-chaves que são adicionadas anteriormente ao operando. Na sintaxe AT&T o tamanho do operando é especificado por um sufixo adicionado a instrução, conforme tabela abaixo:
Exemplos:
Assim como o NASM consegue identificar o tamanho do operando quando é usado um registrador e a palavra-chave se torna opcional, o mesmo acontece no GAS e o sufixo também é opcional nesses casos.
Na sintaxe Intel saltos e chamadas distantes sĂŁo feitas com jmp far [etc]
e call far [etc]
respectivamente. Na sintaxe da AT&T se usa o prefixo L nessas instruçÔes, ficando: ljmp
e lcall
.
Na sintaxe Intel o endereçamento é bem intuitivo jå que ele é escrito em formato de expressão matemåtica. Na sintaxe AT&T é um pouco mais confuso e segue o seguinte formato:
segment:displacement(base, index, scale)
.
Exemplos com o seu equivalente na sintaxe da Intel:
Como demonstrado no Ășltimo exemplo o endereço relativo na sintaxe do GAS Ă© feito explicitando RIP como base, enquanto na sintaxe do NASM isso Ă© feito usando a palavra-chave rel
.
Na sintaxe da AT&T os saltos para endereços armazenados na memória devem ter um *
antes do rótulo para indicar que o salto deve ocorrer para o endereço que estå armazenado naquele endereço de memória. Sem o *
o salto ocorre para o rĂłtulo em si. Exemplo:
Saltos que especificam segmento e offset separam os dois valores por vĂrgula. Como em:
As diretivas do GAS funcionam de maneira semelhante as diretivas do NASM com a diferença que todas elas são prefixadas por um ponto.
No GAS comentĂĄrios de mĂșltiplas linhas podem ser escritos com /*
e */
assim como em C. ComentĂĄrios de uma Ășnica linha podem ser escritos com #
ou //
.
No NASM existem as pseudo-instruçÔes db
, dw
, dd
, dq
etc. que servem para despejar bytes no arquivo binĂĄrio de saĂda. No GAS isso Ă© feito usando as seguintes pseudo-instruçÔes:
Exemplos:
O GAS tem diretivas especĂficas para declarar algumas seçÔes padrĂŁo. Conforme tabela:
Porém ele também tem a diretiva .section
que pode ser usada de maneira semelhante a section
do NASM. Os atributos da seção porém são passados em formato de flags em uma string como segundo argumento. As flags principais são w
para dar permissĂŁo de escrita e x
para dar permissão de execução. Exemplos:
A diretiva .align
pode ser usada para alinhamento dos dados. VocĂȘ pode usĂĄ-la no inĂcio da seção para alinhar a mesma, conforme exemplo:
A diretiva .intel_syntax
pode ser usada para habilitar a sintaxe da Intel para o GAS. Opcionalmente pode-se passar um parĂąmetro noprefix
para desabilitar o prefixo %
dos registradores.
Uma diferença importante da sintaxe Intel do GAS em relação ao NASM é que as palavra-chaves que especificam o tamanho do operando precisam ser seguidas por ptr
, conforme exemplo abaixo:
O exemplo abaixo é o mesmo apresentado no tópico sobre instruçÔes de movimentação SSE porém reescrito na sintaxe do GAS/AT&T:
Entendendo a execução de código em C no ambiente hosted.
Na especificação da linguagem C é descrito dois ambientes de execução de código: Os ambientes hosted e freestanding. Neste tópico vamos entender alguns pontos em relação a como funciona a estrutura e a execução de um programa em C no ambiente hosted.
O ambiente hosted essencialmente Ă© o ambiente de execução de um cĂłdigo em C que executa sobre um sistema operacional. Nesse ambiente Ă© esperado que haja suporte para mĂșltiplas threads e todos os recursos descritos na especificação da biblioteca padrĂŁo (libc). A inicialização do programa ocorre quando a função main Ă© chamada e antes de inicializar o programa Ă© esperado que todos os objetos com static
estejam inicializados.
A função main pode ser escrita com um dos dois protótipos abaixo:
Ou qualquer outro protĂłtipo que seja equivalente a um desses. Como por exemplo char **argv
tambĂ©m seria vĂĄlido por ter equivalĂȘncia a char *argv[]
. Também pode-se usar qualquer nome de parùmetro, argc
e argv
são apenas sugestÔes.
O primeiro parĂąmetro passado para a função main indica o nĂșmero de argumentos e o segundo Ă© uma array de ponteiros para char
onde cada Ăndice na array Ă© um argumento e argv[argc]
Ă© um ponteiro NULL.
Se o tipo de retorno da função main for int
(ou equivalente), o valor de retorno da primeira chamada para main é equivalente a chamar a função exit passando esse valor como argumento.
Os detalhes de implementação descritos aqui são baseados no código-fonte da glibc e podem ser diferentes em outras implementaçÔes da libc. Consulte para ver a lista de completa de arquivos fonte consultados.
O código na glibc responsåvel pela inicialização do programa é chamado de C startup (CSU). Ele se encarrega de obter os argumentos de linha de comando, inicializar o TLS, executar o código na seção .init
dentre outras tarefas de inicialização do programa.
O arquivo start.S
Ă© o que declara o sĂmbolo _start
, ou seja, a função de entry point do programa. A Ășltima chamada nessa função Ă© para outra função chamada __libc_start_main
que recebe o endereço da função main como primeiro argumento. Depois de algumas inicializaçÔes essa função chama a main, obtém o valor retornado em EAX e passa como argumento para a função responsåvel por finalizar o programa no sistema operacional (exit_group
no Linux e ExitProcess
no Windows).
Todos esses cĂłdigos estĂŁo em arquivos objetos prĂ©-compilados no seu sistema operacional. Eles sĂŁo linkados por padrĂŁo quando vocĂȘ invoca o GCC mas nĂŁo sĂŁo linkados por padrĂŁo se vocĂȘ chamar o linker (ld
) diretamente.
No meu Linux o arquivo objeto Scrt1.o
("crt" é sigla para "C runtime") é o que contém o entry point (código do start.S
). Os arquivos crti.o
e crtn.o
contĂ©m o prĂłlogo e o epĂlogo, respectivamente, para as seçÔes .init
e .fini
.
No meu Linux esses arquivos estĂŁo na pasta /usr/lib/x86_64-linux-gnu/
e sugiro que consulte o conteĂșdo dos mesmos com a ferramenta objdump, como por exemplo:
Apenas para fins de curiosidade e dar uma noção mais "palpĂĄvel" de como isso ocorre, irei ensinar aqui como vocĂȘ pode desabilitar a linkedição do CSU e programar uma versĂŁo personalizada do mesmo no Linux. NĂŁo recomendo que isso seja feito em um programa de verdade tendo em vista que vocĂȘ perderĂĄ diversos recursos que o C runtime padrĂŁo da glibc provĂ©m.
Use o seguinte cĂłdigo de teste:
Compile com:
A opção -nostartfiles
desabilita a linkedição dos arquivos objeto de inicialização.
O que o nosso start.s
estĂĄ fazendo Ă© simplesmente chamar a syscall write
para escrever uma mensagem na tela, chama a função main passando argc
e argv
como argumentos e depois chama a syscall exit_group
passando como argumento o retorno da função main.
No Linux, logo quando o programa Ă© iniciado no entry point, o valor contendo o nĂșmero de argumentos de linha de comando (argc) estĂĄ em (%rsp)
. E logo em seguida (RSP+8) estĂĄ o inĂcio da array de ponteiros para os argumentos de linha de comando, terminando com um ponteiro NULL.
Experimente rodar objdump -d test
nesse executåvel "customizado" e depois compare compilando com o CSU comum. Verå que o programa comum contém diversas funçÔes que foram linkadas nele.
As seçÔes .init
e .fini
contĂ©m funçÔes construĂda nos arquivos crti.o
e crtn.o
.
O propósito da função em .init
é chamar todas as funçÔes na array de ponteiros localizada em outra seção chamada .init_array
. Essas funçÔes são invocadas antes da chamada para a função main.
Jå a função em .fini
invoca as funçÔes da array na seção .fini_array
na finalização do programa (após main retornar ou na chamada de exit()
).
No GCC vocĂȘ pode adicionar funçÔes para serem invocadas na inicialização do programa com o atributo constructor
, e para a finalização do programa com o atributo destructor
. Experimente ver o cĂłdigo Assembly do exemplo abaixo:
Quando a função exit()
é invocada (ou main retorna), funçÔes registradas pela função atexit()
são executadas. Onde as funçÔes registradas devem seguir o protótipo:
As funçÔes registradas por atexit()
sĂŁo invocadas na ordem inversa a que foram registradas.
Quando a função quick_exit()
é invocada o programa é finalizado sem invocar as funçÔes registradas por atexit()
e sem executar quaisquer handlers de sinal.
As funçÔes registradas por at_quick_exit
sĂŁo invocadas na ordem inversa em que foram registradas.
Exemplo:
Experimente executar o programa acima e depois recompilar com a chamada para quick_exit na linha 20.
A quantidade mĂĄxima de funçÔes que podem ser registradas com atexit ou at_quick_exit depende da implementação. Mas a especificação do C11 garante que no mĂnimo 32 funçÔes podem ser registradas por cada uma destas funçÔes.
A função _Exit()
finaliza a execução do programa sem executar quaisquer funçÔes registradas por atexit ou at_quick_exit. Também não executa nenhum handler de sinal.
Aprendendo a usar o inline Assembly do compilador GCC.
Inline Assembly Ă© uma extensĂŁo do compilador que permite inserir cĂłdigo Assembly diretamente no cĂłdigo de saĂda do compilador. Dessa forma Ă© possĂvel misturar C e Assembly sem a necessidade de usar um mĂłdulo separado sĂł para o cĂłdigo em Assembly, alĂ©m de permitir alguns recursos interessantes que nĂŁo sĂŁo possĂveis sem o inline Assembly.
O compilador Clang contĂ©m uma sintaxe de inline Assembly compatĂvel com a do GCC, logo o conteĂșdo ensinado aqui tambĂ©m Ă© vĂĄlido para o Clang.
A sintaxe do uso båsico é: asm [qualificadores] ( instruçÔes-asm )
.
Onde qualificadores Ă© uma (ou mais) das seguintes palavra-chaves:
volatile: Isso desabilita as otimizaçÔes de código no inline Assembly, mas esse jå é o padrão quando se usa o inline ASM båsico.
inline: Isso Ă© uma "dica" para o compilador considerar que o tamanho do cĂłdigo Assembly Ă© o menor possĂvel. Serve meramente para o compilador decidir se vai ou nĂŁo expandir uma , e usando esse qualificador vocĂȘ sugere que o cĂłdigo Ă© pequeno o suficiente para isso.
As instruçÔes Assembly ficam dentro dos parĂȘnteses como uma string literal e sĂŁo despejadas no cĂłdigo de saĂda sem qualquer alteração por parte do compilador. Geralmente se usa \n\t
para separar cada instrução pois isso vai ser refletido literalmente na saĂda de cĂłdigo. O \n
Ă© para iniciar uma nova linha e o \t
(TAB) Ă© para manter a indentação do cĂłdigo de maneira idĂȘntica ao cĂłdigo gerado pelo compilador.
Exemplo:
Entre as diretivas #APP
e #NO_APP
fica o cĂłdigo despejado do inline Assembly. A diretiva # 5 "main.c" 1
Ă© apenas um atalho para a diretiva #line
onde ela serve para avisar para o assembler de qual linha (5) e arquivo ("main.c") veio aquele código. Assim se ocorrer algum erro, na mensagem de erro do assembler serå exibido essas informaçÔes.
Repare que o inline Assembly apenas despeja literalmente o conteĂșdo da string literal. Logo vocĂȘ pode adicionar o que quiser aĂ incluindo diretivas, comentĂĄrios ou atĂ© mesmo instruçÔes invĂĄlidas que o compilador nĂŁo irĂĄ reclamar.
TambĂ©m Ă© possĂvel usar inline Assembly bĂĄsico fora de uma função, como em:
PorĂ©m nĂŁo Ă© possĂvel fazer o mesmo com inline Assembly estendido.
A versĂŁo estendida do inline Assembly funciona de maneira semelhante ao inline Assembly bĂĄsico, porĂ©m com a diferença de que Ă© possĂvel acessar variĂĄveis em C e fazer saltos para rĂłtulos no cĂłdigo fonte em C.
A sintaxe da versĂŁo estendida segue o seguinte formato:
Os qualificadores sĂŁo os mesmos da versĂŁo bĂĄsica porĂ©m com mais um chamado goto. O qualificador goto indica que o cĂłdigo Assembly pode efetuar um salto para um dos rĂłtulos listados no Ășltimo operando. Esse qualificador Ă© necessĂĄrio para se usar os rĂłtulos no cĂłdigo ASM. Enquanto o qualificador volatile desabilita a otimização de cĂłdigo, que Ă© habilitada por padrĂŁo no inline Assembly estendido.
Dentre esses operandos somente os de saĂda sĂŁo "obrigatĂłrios", os demais podem ser omitidos. E todos eles podem conter uma lista vazia exceto o de rĂłtulos.
Existe um limite mĂĄximo de 30 operandos com a soma dos operandos de saĂda, entrada e rĂłtulos.
Cada operando de saĂda Ă© separado por vĂrgula e contĂ©m a seguinte sintaxe:
Onde nome
Ă© um sĂmbolo opcional que vocĂȘ pode criar para se referir ao operando no cĂłdigo Assembly. TambĂ©m Ă© possĂvel se referir ao operando usando %n
, onde n seria o Ăndice do operando (contando a partir de zero). E usar %[nome]
caso defina um nome.
Como o %
Ă© usado para se referir Ă operandos, no inline Assembly estendido se usa dois %
para se referir Ă um registrador. JĂĄ que %%
Ă© um escape para escrever o prĂłprio %
na saĂda, da mesma forma que se faz na função printf.
Operandos de saĂda com +
sĂŁo contabilizados como dois, tendo em vista que o +
é basicamente um atalho para repetir o mesmo operando também como uma entrada.
Essas informaçÔes sĂŁo necessĂĄrias para que o compilador consiga otimizar o cĂłdigo corretamente. Por exemplo caso vocĂȘ indique que a variĂĄvel serĂĄ somente escrita com =
mas leia o valor da variåvel no Assembly, o compilador pode assumir que o valor da variåvel nunca foi lido e portanto descartar a inicialização dela durante a otimização de código. Isso criaria um comportamento estranho no inline Assembly onde se obteria lixo como valor da variåvel.
Um exemplo deste erro:
A otimização de código pode remover a inicialização x = 5
jĂĄ que nĂŁo informamos que o valor dessa variĂĄvel Ă© lido dentro no inline Assembly. O correto seria usar +
nesse caso.
Um exemplo (dessa vez correto) usando um nome definido para o operando:
Os operandos de entrada seguem a mesma sintaxe dos operandos de saĂda porĂ©m sem o =
ou +
nas restriçÔes. NĂŁo se deve tentar modificar operandos de entrada (embora tecnicamente seja possĂvel) para evitar erros, lembre-se que o compilador irĂĄ otimizar o cĂłdigo assumindo que aquele operando nĂŁo serĂĄ modificado.
TambĂ©m Ă© possĂvel passar expressĂ”es literais como operando de entrada ao invĂ©s de somente nomes de variĂĄveis. A expressĂŁo serĂĄ avaliada e seu valor passado como operando sendo armazenado de acordo com as restriçÔes.
Clobbers (que eu nĂŁo sei como traduzir) Ă© basicamente uma lista, separada por vĂrgula, de efeitos colaterais do cĂłdigo Assembly. Nele vocĂȘ deve listar o que o seu cĂłdigo ASM modifica alĂ©m dos operandos de saĂda. Cada valor de clobber Ă© uma string literal contendo o nome de um registrador que Ă© modificado pelo seu cĂłdigo. TambĂ©m hĂĄ dois nomes especiais de clobbers:
Qualquer nome de registrador é vålido para ser usado como clobber exceto o Stack Pointer (RSP). à esperado que no final da execução do inline ASM o valor de RSP seja o mesmo de antes da execução do código. Se não for o código muito provavelmente irå ter problemas no restante da execução.
Quando vocĂȘ adiciona um registrador a lista de clobbers ele nĂŁo serĂĄ utilizado para armazenar operandos de entrada ou saĂda, assim garantindo que o registrador pode ser utilizado livremente no inline ASM sem causar qualquer erro. Isso tambĂ©m garante que o compilador nĂŁo irĂĄ assumir que o valor do registrador permanece o mesmo apĂłs a execução do inline ASM.
Exemplo:
Ao usar asm goto
pode-se referir Ă um rĂłtulo usando o prefixo %l
seguido do Ăndice do operando de rĂłtulo. Onde a contagem inicia em zero e Ă© contabilizado tambĂ©m os operandos de entrada e saĂda.
Exemplo:
Mas felizmente tambĂ©m Ă© possĂvel usar o nome do rĂłtulo no inline Assembly, bastando usar a notação %l[nome]
. O exemplo acima poderia ter a instrução de salto reescrita para jz %l[my_label]
.
As restriçÔes (constraints) sĂŁo uma lista de caracteres que determinam onde um operando deve ser armazenado. Ă possĂvel indicar mĂșltiplas alternativas para o compilador simplesmente adicionando mais de uma letra indicando tipos de armazenamento diferentes.
Abaixo a lista de algumas restriçÔes disponĂveis no GCC.
Se vocĂȘ simplesmente declarar rĂłtulos dentro do inline Assembly pode acabar se deparando com uma redeclaração de sĂmbolo por nĂŁo ter uma garantia de que ele seja Ășnico. Mas uma dica Ă© usar o escape especial %=
que expande para um nĂșmero Ășnico para cada uso de asm
, assim sendo possĂvel dar um nome Ășnico para os rĂłtulos.
Exemplo:
Caso prefira usar sintaxe Intel Ă© possĂvel fazer isso meramente compilando o cĂłdigo com -masm=intel
. Isso porque o inline Assembly simplesmente despeja as instruçÔes no arquivo de saĂda, portanto o cĂłdigo irĂĄ usar a sintaxe que o assembler utilizar.
Outra dica Ă© usar a diretiva .intel_syntax noprefix
no inĂcio, e depois .att_syntax
no final para religar a sintaxe AT&T para o restante do cĂłdigo. Exemplo:
Ao usar o storage-class register
Ă© possĂvel escolher em qual registrador a variĂĄvel serĂĄ armazenada usando a seguinte sintaxe:
Nesse exemplo a variĂĄvel x
obrigatoriamente seria alocada no registrador R12.
TambĂ©m Ă© possĂvel escolher o nome do sĂmbolo para variĂĄveis locais com storage-class static
ou para variĂĄveis globais. Como em:
A variĂĄvel no cĂłdigo fonte Ă© referida como x
mas o sĂmbolo gerado para a variĂĄvel seria definido como my_var
.
Aprendendo sobre as instruçÔes intrĂnsecas na arquitetura x86-64
As instruçÔes intrĂnsecas Ă© um recurso originalmente fornecido pelo compilador Intel C/C++ mas que tambĂ©m Ă© implementado pelo GCC. Se tratam basicamente de tipos especiais e funçÔes que sĂŁo expandidas inline para alguma instrução do processador, ou seja, Ă© basicamente uma alternativa mais prĂĄtica e legĂvel do que usar inline Assembly para tudo.
Usando instruçÔes intrĂnsecas Ă© possĂvel obter o mesmo resultado de usar inline Assembly com a diferença de ter a sintaxe amigĂĄvel de uma chamada de função.
Para usar instruçÔes intrĂnsecas Ă© necessĂĄrio incluir o header <immintrin.h> onde ele declara as funçÔes e os tipos.
Para entender apropriadamente as operaçÔes e tipos indicados aqui, sugiro que jå tenha lido o tópico sobre .
Os tipos de dados na tabela abaixo servem para indicar como os valores usados na instrução intrĂnseca serĂŁo armazenados.
A maioria das instruçÔes intrĂnsecas (SIMD) seguem a seguinte convenção de notação:
Onde <operação> é a operação que serå executada com os dados. O <sufixo> indica o tipo de dado na operação. A primeira ou as duas primeiras letras do sufixo indicam se o dado é packed (p), extended packed (ep) ou escalar (s). Os demais caracteres do sufixo indicam o tipo de dado, como mostra a tabela abaixo:
Exemplo:
Abaixo irei listar apenas algumas instruçÔes intrĂnsecas, em sua maioria relacionadas Ă tecnologia SSE. Para ver a lista completa sugiro que consulte a referĂȘncia oficial da Intel no link abaixo:
Algumas instruçÔes intrĂnsecas nĂŁo sĂŁo compiladas para uma sĂł instrução mas sim uma sequĂȘncia de vĂĄrias delas.
As operaçÔes load carregam um valor da memĂłria para um registrador, o conteĂșdo que deve estar na memĂłria apontada pelo argumento tem que estar de acordo com o tipo da instrução identificado pelo sufixo.
OperaçÔes store leem um ou mais dados do registrador e escrevem os mesmos no endereço passado como primeiro argumento.
JĂĄ a operação extract obtĂ©m um valor de uma parte do registrador identificado pelo valor imediato passado como segundo argumento. Esse valor Ă© o Ăndice do campo do registrador contando da direita para a esquerda começando em zero.
Exemplos:
As instruçÔes intrĂnsecas de set definem o valor de todos os campos do registrador ao mesmo tempo sem a necessidade de usar uma array para isso. Elas nĂŁo sĂŁo traduzidas para uma mas sim vĂĄrias instruçÔes em sequĂȘncia, portanto pode haver uma penalidade de desempenho.
As operaçÔes set escalares (_mm_set_sd
e_mm_set_ss
) definem o valor da parte menos significativa do registrador e zeram os demais valores.
As duas operaçÔes abaixo definem todos os campos do registrador para o mesmo valor passado como argumento:
Exemplo:
Exemplos:
As instruçÔes intrĂnsecas abaixo leem um valor aleatĂłrio gerado por hardware:
A instrução rdrand
escreve o valor aleatório obtido no ponteiro passado como argumento. Ela deve ser usada em um loop pois não hå garantia de que ela irå obter de fato um valor. Se obter o valor a função retorna 1
, caso contrĂĄrio retorna 0
.
Exemplo:
VocĂȘ deve compilar passando a flag -mrdrnd
para o GCC para indicar que o processador suporta a tecnologia. Caso contrĂĄrio vocĂȘ obterĂĄ um erro como este:
error: inlining failed in call to always_inline â_rdrand32_stepâ: target specific option mismatch
As instruçÔes intrĂnsecas abaixo sĂŁo utilizadas da mesma maneira que rdrand porĂ©m o valor aleatĂłrio nĂŁo Ă© gerado por hardware.
Ă necessĂĄrio compilar com a flag -mrdseed
para poder usar essas instruçÔes intrĂnsecas.
de 32 ou 64 bits.
Quando um processo dispara uma , um tratador (handler) configurado pelo sistema operacional envia um sinal para o processo tratar aquela exceção. Depuradores são capazes de identificar (e ignorar) exceçÔes intervindo no processo de handling desse sinal.
Agora que jĂĄ entendemos um pouco sobre processos vai ficar mais fĂĄcil entender como depuradores funcionam. Afinal de contas depuradores depuram processos.
Os breakpoints são implementados na pråtica (na arquitetura x86-64) como uma instrução int3
que dispara #BP. Quando um depurador insere um breakpoint em um determinado ponto do código ele estå simplesmente modificando o primeiro byte da instrução para o byte 0xCC, que é o byte da instrução int3
. Quando a exceção é disparada o sinal SIGTRAP é enviado para o processo e o depurador se encarrega de dar o controle da execução para o usuårio. Quando o usuårio continua a execução o depurador restaura o byte original da instrução, executa ela e coloca o byte 0xCC novamente.
Ao executar a instrução int3
inserida com na linha 17, o processo recebe o sinal SIGTRAP e nosso tratador Ă© executado. Experimente comentar a chamada para sigaction na linha 15 para ver o resultado do tratador padrĂŁo.
Quando a condição do breakpoint é atendida o processador dispara #BP.
Isso é implementado na arquitetura x86-64 usando a trap flag (TF) no registrador . Quando a TF estå ligada cada instrução executada dispara #BP, permitindo assim que o depurador retome o controle após executar uma instrução.
Ao do programa acima irå notar que os endereços das funçÔes são despejados nas seçÔes .init_array
e .fini_array
, como em:
Isso produz a seguinte saĂda ao :
Ă© uma string literal contendo letras e sĂmbolos indicando como esse operando deve ser armazenado (r para registrador e m para memĂłria, por exemplo). No caso dos operandos de saĂda o primeiro caractere na string deve ser um =
ou +
. Onde o =
indica que a variĂĄvel terĂĄ seu valor modificado, enquanto +
indica que terĂĄ seu valor modificado e lido.
Caso utilize um operando que vocĂȘ nĂŁo tem certeza que serĂĄ armazenado em um registrador, lembre-se de usar para especificar o tamanho do operando. Para evitar erros Ă© ideal que sempre use os sufixos.
Tipo
Tamanho IA-32
Tamanho x86-64
Registrador de retorno IA-32
Registrador de retorno x86-64
_Bool
char
signed char
unsigned char
1
1
AL
AL
short
signed short
unsigned short
2
2
AX
AX
int
signed int
unsigned int
long
signed long
unsigned long
enum
4
4
EAX
EAX
long long
signed long long
unsigned long long
8
8
*EDX:EAX
RAX
Ponteiros
4
8
EAX
RAX
float
4
4
ST0
XMM0
double
8
8
ST0
XMM0
**long double
12
16
ST0
ST0
Sufixo
Tamanho
Palavra-chave equivalente no NASM
B
byte (8 bits)
byte
W
word (16 bits)
word
L
long/doubleword (32 bits)
dword
Q
quadword (64 bits)
qword
T
ten word (80 bits)
tword
Pseudo-instrução
Tipo do dado (tamanho em bits)
Equivalente no NASM
.byte
byte (8 bits)
db
.short
.hword
.word
word (16 bits)
dw
.long
.int
doubleword (32 bits)
dd
.quad
quadword (64 bits)
dq
.float
.single
Single-precision floating-point (32 bits)
dd
.double
Double-precision floating-point (64 bits)
dq
.ascii
.string
.string8
String (8 bits cada caractere)
db
.asciz
Mesmo que .ascii porém com um terminador nulo no final
-
.string16
String (16 bits cada caractere)
-
.string32
String (32 bits cada caractere)
-
.string64
String (64 bits cada caractere)
-
GAS
Equivalente no NASM
.data
section .data
.bss
section .bss
.text
section .text
Ativar as instruçÔes
Desativar as instruçÔes
3dnow
no-3dnow
3dnowa
no-3dnowa
abm
no-abm
adx
no-adx
aes
no-aes
avx
no-avx
avx2
no-avx2
avx5124fmaps
no-avx5124fmaps
avx5124vnniw
no-avx5124vnniw
avx512bitalg
no-avx512bitalg
avx512bw
no-avx512bw
avx512cd
no-avx512cd
avx512dq
no-avx512dq
avx512er
no-avx512er
avx512f
no-avx512f
avx512ifma
no-avx512ifma
avx512pf
no-avx512pf
avx512vbmi
no-avx512vbmi
avx512vbmi2
no-avx512vbmi2
avx512vl
no-avx512vl
avx512vnni
no-avx512vnni
avx512vpopcntdq
no-avx512vpopcntdq
mmx
no-mmx
sse
no-sse
sse2
no-sse2
sse3
no-sse3
sse4
no-sse4
sse4.1
no-sse4.1
sse4.2
no-sse4.2
sse4a
no-sse4a
ssse3
no-ssse3
Sufixo | Tipo |
| single-precision floating-point (float de 32-bit) |
| double-precision floating-point (double de 64-bit) |
| Inteiro sinalizado de 128-bit. |
| Inteiro sinalizado de 64-bit. |
| Inteiro nĂŁo-sinalizado de 64-bit. |
| Inteiro sinalizado de 32-bit. |
| Inteiro nĂŁo-sinalizado de 32-bit. |
| Inteiro sinalizado de 16-bit. |
| Inteiro nĂŁo-sinalizado de 16-bit. |
| Inteiro sinalizado de 8-bit. |
| Inteiro nĂŁo-sinalizado de 8-bit. |
Tecnologia | Protótipo | Instrução |
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE |
| SequĂȘncia |
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE |
|
|
SSE4.1 |
|
|
SSE |
|
|
Tecnologia | ProtĂłtipo |
SSE2 |
|
SSE2 |
|
SSE2 |
|
SSE2 |
|
SSE2 |
|
SSE2 |
|
SSE |
|
SSE2 |
|
SSE |
|
Tecnologia | ProtĂłtipo |
SSE2 |
|
SSE |
|
Tecnologia | Protótipo | Instrução |
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSSE3 |
|
|
SSSE3 |
|
|
SSSE3 |
|
|
SSSE3 |
|
|
SSSE3 |
|
|
SSSE3 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE2 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE4.1 |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE |
|
|
SSE |
|
|
SSE2 |
|
|
SSE |
|
|
SSE2 |
|
|
SSE2 |
|
|
SSE |
|
|
SSE |
|
|
Tecnologia | Protótipo | Instrução |
RDRAND |
|
|
RDRAND |
|
|
RDRAND |
|
|
Tecnologia | Protótipo | Instrução |
RDSEED |
|
|
RDSEED |
|
|
RDSEED |
|
|
Aprendendo a usar o depurador do Dosbox
O emulador Dosbox tem um depurador embutido que facilita bastante na hora de programar alguma coisa para o MS-DOS
Entendendo o cĂłdigo de mĂĄquina x86-64
O famigerado cĂłdigo de mĂĄquina (tambĂ©m chamado de linguagem de mĂĄquina), popularmente conhecido como "zeros e uns", sĂŁo as instruçÔes que o processador interpreta e executa. SĂŁo basicamente nĂșmeros onde o processador decodifica esses nĂșmeros afim de executar determinadas operaçÔes identificadas pelas instruçÔes.
Acho que boa parte das pessoas da årea da computação sabem que processadores de computadores digitais funcionam com sinais elétricos com duas tensÔes diferentes: Uma alta (lå pelos 3v, mas pode variar de acordo com o processador) e uma baixa (perto de 0v), onde a tensão alta representa o 1 e a tensão baixa representa o 0.
Mas comumente Ă© sĂł isso o que as pessoas sabem sobre cĂłdigo de mĂĄquina. O objetivo deste capĂtulo Ă© dar uma noção aprofundada de como funciona o cĂłdigo de mĂĄquina da arquitetura x86-64.
Cada arquitetura de processador (vulgo ISA, Instruction Set Architecture) tĂȘm um cĂłdigo de mĂĄquina distinto. Portanto as informaçÔes aqui sĂŁo vĂĄlidas para cĂłdigo de mĂĄquina x86 e x86-64. ARM, RISC-V etc. contĂ©m cĂłdigo de mĂĄquina que funciona de um jeito completamente diferente.
Antes de mais nada um pré-aviso: Sei que é romùntico quando se fala de código de måquina meter um monte de zeros e uns (como: 10110100010
). Mas na vida real ninguém representa textualmente código de måquina em binårio. Isso é normalmente feito em manuais ou ferramentas como disassemblers e debuggers usando hexadecimal.
EntĂŁo ao pensar em cĂłdigo de mĂĄquina nĂŁo pense nisso 10110100 00001110
mas sim nisso B4 0E
. VocĂȘ Ă© humano, pense como tal.
Comecei a desenvolver uma ferramenta exclusivamente para ser usada como auxĂlio para esse capĂtulo. Eu a chamei de x86-visualizer e seu intuito Ă© vocĂȘ escrever uma instrução em Assembly e ela lhe exibir o cĂłdigo de mĂĄquina dividido em seus campos, assim facilitando o entendimento.
A ferramenta nĂŁo estĂĄ concluĂda entĂŁo poucas instruçÔes irĂŁo funcionar, todavia sugiro seu uso durante a leitura do capĂtulo afim de facilitar o entendimento da codificação das instruçÔes.
Acesse o repositĂłrio dela aqui:
Também sugiro usar o ndisasm afim de fazer experimentaçÔes. Ele é um disassembler que vem junto com o nasm e jå foi utilizado anteriormente no livro.
Campo immediate na instrução do código de måquina.
O campo immediate (valor "imediato") pode ter 1, 2, ou 4 bytes de tamanho. Ele é o operando numérico presente em algumas instruçÔes. Exemplo:
Essa instrução em código de måquina fica: B8 44 33 22 11
Onde B8
é o opcode da instrução e 44 33 22 11
o valor imediato (0x11223344
). Lembrando que a arquitetura x86 é little-endian, portanto o valor imediato fica em little-endian na instrução.
O tamanho desse campo Ă© definido pelo atributo operand-size, portanto ao usar o prefixo 66
o seu tamanho pode alternar na instrução entre 16-bit e 32-bit. Sobre instruçÔes com operandos de 8-bit, como mov al, 123
, existem opcodes especĂficos para operandos nesse tamanho portanto o prefixo nĂŁo Ă© usado nessas instruçÔes. E obrigatoriamente o immediate terĂĄ 8-bit de tamanho.
Outros dois exemplos seriam mov ax, 0x1122
e mov al, 0x11
. Onde o primeiro tem o cĂłdigo de mĂĄquina 66 B8 22 11
em modo de 32-bit, e em modo de 16-bit fica igual sĂł que sem o prefixo 66
.
Jå a segunda instrução terå o código de måquina B0 11
em qualquer modo de operação, jå que ela independe do operand-size.
Ainda nĂŁo acabou.
Este livro Ă© um trabalho em andamento e ainda hĂĄ muita coisa para ser escrita. Abaixo segue uma lista do conteĂșdo que pretendo inserir no livro:
Programando no Linux
Syscall x86 e x64
ExecutĂĄveis ELF
Construindo o executĂĄvel do zero
Bibliotecas dinĂąmicas e estĂĄticas
Importação de sĂmbolos
Exportação de sĂmbolos
Programando em Bare Metal
Entendendo o conceito de bare metal
O bootloader
ConfiguraçÔes da arquitetura
Mudando o modo de processamento
GDT e LGDT
Usando o ld
Formatação manual do binårio com scripts
Modularização
Aprofundando no nasm
Macros avançados
Sistema de contexto
Mais diretivas
OpçÔes da linha de comando
ReferĂȘncia de instruçÔes x86-64
Essa lista nĂŁo Ă© absoluta, Ă© sĂł para dar uma noção do que pretendo produzir de conteĂșdo. Durante a escrita posso adicionar mais coisas que nĂŁo me lembrei de colocar aqui e tambĂ©m mudar a ordem/tĂtulo dos tĂłpicos.
Clobber | Descrição |
cc |
memory | Indica que o cĂłdigo ASM faz leitura ou escrita da/na memĂłria em outro lugar que nĂŁo seja um dos operandos de entrada ou saĂda. Por exemplo em uma memĂłria apontada por um ponteiro de um operando. Esse clobber evita que o compilador assuma que os valores das variĂĄveis na memĂłria permanecem os mesmos apĂłs a execução do cĂłdigo ASM. E tambĂ©m garante que o compilador escreva o valor de todas as variĂĄveis na memĂłria antes de executar o inline ASM. |
rax | Indica que o registrador RAX serĂĄ modificado. |
rbx | Indica que o registrador RBX serĂĄ modificado. |
etc. | ... |
Restrição | Descrição |
| Operando na memĂłria. |
|
| Um valor inteiro imediato. |
| Um valor floating-point imediato. |
| Um operando na memĂłria, registrador de propĂłsito geral ou inteiro imediato. Mesmo efeito que usar |
| Um operando que é um endereço de memória vålido. |
| Qualquer operando Ă© permitido. Basicamente deixa a decisĂŁo nas mĂŁos do compilador. |
Restrição | Descrição |
| Registradores legado. Qualquer um dos oito registradores de propĂłsito geral disponĂveis em IA-32. |
| Qualquer registrador que seja possĂvel ler o byte menos significativo. Como RAX (AL) ou R8 (R8B) por exemplo. |
| Qualquer registrador que seja possĂvel ler o segundo byte menos significativo, como RAX (AH) por exemplo. |
| O registrador "A" (RAX, EAX, AX ou AL). |
| O registrador "B" (RBX, EBX, BX ou BL). |
| O registrador "C" (RCX, ECX, CX ou CL). |
| O registrador "D" (RDX, EDX, DX ou DL). |
| RSI, ESI, SI ou SIL. |
| RDI, EDI, DI ou DIL. |
| O conjunto AX:DX. |
|
| ST0 |
| ST1 |
| Qualquer registrador MMX. |
|
| XMM0 |
| Um inteiro constante entre 0 e 31, usado para shift com valores de 32-bit. |
| Um inteiro constante entre 0 e 63, usado para shift com valores de 64-bit. |
| Inteiro sinalizado de 8-bit. |
| Inteiro nĂŁo-sinalizado de 8-bit. |
Tipo | Descrição |
| Tipo usado para representar o conteĂșdo de um registrador MMX. Pode armazenar 8 valores 8-bit, 4 valores de 16-bit, 2 valores de 32-bit ou 1 valor de 64-bit. |
|
| Também um registrador SSE porém armazenando 2 floating-point de 64-bit. |
| Registrador SSE que pode armazenar 16 valores inteiros de 8-bit, 8 valores inteiros de 16-bit, 4 valores inteiros de 32-bit ou 2 valores inteiros de 64-bit. |
| Representa o conteĂșdo de um registrador YMM usado pela tecnologia AVX. Pode armazenar 8 valores floating-point de 32-bit. |
| Registrador YMM que pode armazenar 4 floating-point de 64-bit. |
| Registrador YMM que pode armazenar 32 valores inteiros de 8-bit, 16 valores inteiros de 16-bit, 8 valores inteiros de 32-bit ou 4 valores inteiros de 64-bit. |
| Representa o conteĂșdo de um registrador ZMM usado pela tecnologia AVX-512. Pode armazenar 16 valores floating-point de 32-bit. |
| Registrador ZMM que pode armazenar 8 valores floating-point de 64-bit. |
| Registrador ZMM que pode armazenar 64 valores inteiros de 8-bit, 32 inteiros de 16-bit, 16 inteiros de 32-bit ou 8 inteiros de 64-bit. |
Aprendendo a usar o depurador GDB do projeto GNU.
O GDB Ă© um depurador de linha de comando que faz parte do projeto GNU. O Mingw-w64 jĂĄ instala o GDB junto com o GCC, e no Linux ele pode ser instalado pelo pacote gdb
:
O GDB pode ser usado para depurar código tanto visualizando o Assembly como também o código-fonte. Para isso é necessårio compilar o binårio adicionando informaçÔes de depuração, com o GCC basta adicionar a opção -g3
ao compilar. Exemplo:
E pode rodar o GDB passando o caminho do binĂĄrio assim:
O caminho do binårio é opcional. Caso especificado o GDB jå inicia com esse binårio como alvo para depuração, mas existem comandos do GDB que podem ser usados para escolher um alvo conforme serå explicado mais abaixo.
O GDB funciona com comandos, quando vocĂȘ o inicia ele te apresenta um prompt onde vocĂȘ pode ir inserindo comandos para executar determinadas açÔes. Mais abaixo irei apresentar os principais comandos e como utilizĂĄ-los.
Esse depurador suporta depurar código de diversas linguagens de programação (incluindo C++, Go e Rust), mas aqui serå demonstrado seu uso somente em um código escrito em C. O seguinte código serå usado para demonstração:
E serĂĄ compilado da seguinte forma:
A opção -g
é usada para adicionar informaçÔes de depuração ao executåvel. Esse 3
seria o nĂvel de informaçÔes que serĂŁo adicionadas, onde 3 Ă© o maior nĂvel.
Para mais informaçÔes consulte a documentação do GCC.
Determinadas instruçÔes do GDB recebem uma expressĂŁo como argumento onde Ă© possĂvel usar qualquer tipo de constante, variĂĄvel ou operador da linguagem que estĂĄ sendo depurada (neste caso C). Isso inclui casts, strings literais, macros e atĂ© mesmo chamadas de funçÔes. Logo a expressĂŁo interpretada Ă© quase idĂȘntica a uma expressĂŁo que vocĂȘ escreveria na linguagem que estĂĄ sendo depurada (no nosso caso C).
TambĂ©m Ă© possĂvel referenciar o valor de algum registrador na expressĂŁo usando o prefixo $
, como $rax
por exemplo. Na imagem abaixo é uma demonstração usando o comando print
:
O GDB aceita abreviaçÔes dos comandos, onde ele identifica o comando a ser executado de acordo com suas primeiras letras ou abreviaçÔes definidas pelo depurador. Por exemplo o comando breakpoint
pode ser executado também como break
, br
ou apenas b
.
Ao apertar enter sem digitar nenhum comando o GDB irĂĄ reexecutar o Ășltimo comando que vocĂȘ executou.
Finaliza o GDB. A expressĂŁo opcional Ă© avaliada e o resultado dela Ă© usado como cĂłdigo de saĂda. Se a expressĂŁo nĂŁo for passada o GDB sai com cĂłdigo 0
.
Usa o arquivo binårio especificado como alvo para depuração. O programa é procurado no diretório atual ou em qualquer caminho registrado na variåvel de ambiente PATH.
O comando attach
faz o attach no processo de ID especificado. JĂĄ o comando detach
desfaz o attach no processo que estĂĄ atualmente conectado.
VocĂȘ tambĂ©m pode iniciar a execução do GDB com a opção -p
para ele jĂĄ inicializar fazendo attach em um processo, como em:
Se o comando for executado sem qualquer argumento o breakpoint serå adicionado na instrução atual.
LOCATION Ă© a posição onde o breakpoint deve ser inserido e pode ser o nĂșmero de uma linha, endereço ou posição explĂcita.
Ao especificar o nĂșmero da linha, o nome do arquivo e o nĂșmero da linha sĂŁo separados por :
. Se nĂŁo especificar o nome do arquivo o breakpoint serĂĄ adicionado a linha do arquivo atual. Exemplos:
Onde o primeiro adicionaria o breakpoint na linha 15 do arquivo atual, e o segundo adicionaria na linha 17 do arquivo test.c
.
O endereço pode ser simplesmente o nome de uma função ou então uma expressão, onde nesse caso é necessårio usar *
como prefixo ao sĂmbolo ou endereço de memĂłria. Como em:
No primeiro caso um breakpoint seria adicionado a função main. No segundo caso o endereço da primeira instrução da função main seria somado com 8, e o endereço resultante seria onde o breakpoint seria inserido. Jå no terceiro caso o breakpoint seria inserido no endereço 0x12345
.
TambĂ©m Ă© possĂvel especificar para qual thread o breakpoint deve ser inserido, onde por padrĂŁo o breakpoint Ă© vĂĄlido para todas as threads. Exemplo:
Isso adicionaria o breakpoint somente para a thread de ID 2.
Ă possĂvel usar o comando info threads
para obter a lista de threads e seus nĂșmeros de identificação.
E por fim då para adicionar uma condição de parada ao breakpoint. Onde CONDITION é uma expressão booleana. Exemplo:
Onde no contexto do nosso cĂłdigo de exemplo, a
seria o primeiro parùmetro da função add.
Remove um breakpoint no local especificado. LOCATION funciona da mesma forma que no comando breakpoint
.
Caso LOCATION não seja especificado remove o breakpoint na posição atual.
O comando run
inicia (ou reinicia) a execução do programa alvo. Opcionalmente pode-se passar argumentos de linha de comando para o programa. Caso os argumentos nĂŁo sejam especificados, os mesmos argumentos utilizados na Ășltima execução de run
serĂŁo utilizados.
Nos argumentos Ă© possĂvel usar o caractere curinga *
, ele serĂĄ expandido pela shell do sistema. TambĂ©m Ă© possĂvel usar os redirecionadores <
, >
ou >>
.
Finaliza a execução do programa que estå sendo depurado.
O uso desses dois comandos Ă© idĂȘntico ao uso de run
. Porém o comando start
inicia a execução do programa parando no começo da função main. Jå o starti
inicia parando na primeira instrução do programa.
O comando next
(ou apenas n
) executa uma linha de cĂłdigo. Se N for especificado ele executa N linhas de cĂłdigo. JĂĄ o comando nexti
(ou apenas ni
) executa uma ou N instruçÔes Assembly.
Os dois comandos atuam como um step over, ou seja, nĂŁo entram em chamadas de procedimentos.
O step
(ou s
) executa uma ou N linhas de cĂłdigo. JĂĄ o stepi
(ou si
) executa uma ou N instruçÔes Assembly. Os dois comandos entram em chamadas de procedimentos.
Salta (modifica RIP) para o ponto do cĂłdigo especificado. Onde LOCATION Ă© idĂȘntico ao caso do comando breakpoint onde Ă© possĂvel especificar um nĂșmero de linha ou endereço.
Esse comando continua a execução do programa até o ponto do código especificado, daà para a execução lå. Assim como na instrução jump
, o comando advance
(ou adv
) recebe um LOCATION como argumento.
O comando advance
também para quando a função atual retorna.
Executa até o retorno da função atual. Quando a função retorna é criada uma variåvel (como no caso do comando print) com o valor de retorno da função.
Continua a execução normal do programa.
Quando o programa jĂĄ estĂĄ em execução vocĂȘ pode executar o comando record full
para iniciar a gravação das instruçÔes executadas e record stop
para parar de gravar.
Quando hĂĄ a gravação Ă© possĂvel executar o programa em ordem reversa usando os comandos: reverse-step
(rs
), reverse-stepi
(rsi
), reverse-next
(rn
), reverse-nexti
(rni
) e reverse-continue
(rc
).
Esses comandos fazem a mesma coisa que os comandos normais, porém executando o programa ao reverso. Cada instrução revertida tem suas modificaçÔes na memória ou registradores desfeitas. Conforme demonstra a imagem abaixo.
Outros subcomandos de record
sĂŁo:
Salta para uma determinada instrução que foi gravada. Pode-se usar record goto begin
para voltar ao inĂcio da gravação (desfazendo todas as instruçÔes), record goto end
para ir para o final da gravação ou record goto N
onde N seria o nĂșmero da instrução na gravação para saltar para ela.
Salva os logs de execução no arquivo.
Restaura os logs de execução a partir do arquivo.
O comando thread
pode ser usado para trocar entre threads do processo. VocĂȘ pode usar o comando info threads
para listar as threads do processo e obter seus ID. Exemplo:
Isso trocaria para a thread de ID 2. Esse comando também tem os seguintes subcomandos:
Executa um comando na thread especificada.
Define um nome para a thread atual, facilitando a identificação dela.
Recebe uma expressĂŁo regular como argumento que Ă© usada para listar as threads cujo o nome coincida com a expressĂŁo regular. O comando exibe o ID das threads listadas.
O comando print
(ou p
) exibe no terminal o resultado da expressĂŁo passada como argumento. Opcionalmente pode-se especificar o formato de saĂda, onde os formatos sĂŁo os mesmos utilizados no comando x. Exemplo:
Repare que a cada execução do comando print
ele define uma variĂĄvel ($1
, $2
etc.) que armazena o resultado da expressĂŁo do comando. VocĂȘ tambĂ©m pode usar o valor dessas variĂĄveis em uma expressĂŁo e assim reaproveitar o resultado de uma execução anterior do comando. Os sĂmbolos $
e $$
se referem aos valores da Ășltima e penĂșltima execução do comando, respectivamente. Exemplo:
Existe também o operador binårio @
que pode ser usado para tratar o valor no endereço especificado como uma array. O formato do uso desse operador é array@size
, passando Ă esquerda o primeiro elemento da array.
Onde o tipo de cada elemento da array Ă© definido de acordo com o tipo do objeto que estĂĄ sendo referenciado. Na imagem abaixo Ă© demonstrado o uso desse operador para visualizar todo o conteĂșdo da array argv.
Esse comando pode ser usado de maneira semelhante a função printf da libc. Cada argumento Ă© separado por vĂrgula e o primeiro argumento Ă© a format string que suporta quase todos os formatos suportados pela função printf. Os demais argumentos sĂŁo expressĂ”es.
Exemplo de uso:
Esse comando insere um breakpoint no código onde, toda vez que ele é alcançado, o comando printf
é executado e depois a execução continua. O uso desse comando é semelhante ao do comando printf
. Exemplo:
No nosso código de exemplo, isso inseria o dynamic printf na linha 7 que estå dentro da função add. Conforme a imagem abaixo demonstra:
O comando x
serve para ver valores na memĂłria. O argumento FMT (opcional) Ă© o nĂșmero de valores a serem exibidos, seguido de uma letra indicando o formato do valor seguido de uma letra que indica o tamanho do valor. Por padrĂŁo exibe apenas um valor caso o nĂșmero nĂŁo seja especificado. O formato e tamanho padrĂŁo Ă© o mesmo utilizado na Ășltima execução do comando x
.
As letras de formato sĂŁo: o
(octal), x
(hexadecimal), d
(decimal), u
(decimal nĂŁo-sinalizado), t
(binĂĄrio), f
(float), a
(endereço), i
(instrução), c
(caractere de 1 byte), s
(string) e z
(hexadecimal com zeros Ă esquerda).
Ao usar o formato i
serĂĄ feito o disassembly do cĂłdigo no endereço. O nĂșmero de valores Ă© usado para especificar o nĂșmero de instruçÔes para fazer o disassembly.
Exemplo:
As letras de tamanho sĂŁo: b
(byte), h
(metade de uma palavra), w
(palavra) e g
(giant, 8 bytes). Na arquitetura x86-64 uma palavra Ă© 32-bit (4 bytes).
Exemplos:
O comando disassembly
(ou disas
) pode ser usado para exibir o disassembly de uma função ou range de endereço. O argumento ADDRESS (opcional) é uma expressão, sem esse argumento ele faz o disassembly na posição ou função atual.
TambĂ©m Ă© possĂvel especificar um range de endereços para exibir o dissasembly das instruçÔes, separando o endereço inicial e final por vĂrgula. Se usar o +
no segundo argumento separado por vĂrgula, ele Ă© considerado como o tamanho em bytes do range iniciado em start.
Exemplos:
O argumento MODIFIER Ă© uma (ou mais) das seguintes letras:
s
- Exibe também as linhas de código correspondentes as instruçÔes em Assembly.
r
- Também exibe o código de måquina em hexadecimal.
Exemplo:
Por padrĂŁo o disassembly Ă© feito em sintaxe AT&T, mas vocĂȘ pode modificar para sintaxe Intel com o comando: set disassembly-flavor intel
Exibe a listagem de cĂłdigo na linha ou inĂcio da função especificada. Um endereço tambĂ©m pode ser especificado usando um *
como prefixo, as linhas de código correspondentes ao endereço serão exibidas.
Caso list
seja executado sem argumentos mais linhas sĂŁo exibidas a partir da Ășltima linha exibida pela Ășltima execução de list
.
O nĂșmero de linhas exibido Ă© por padrĂŁo 10, mas esse valor pode ser alterado com o comando set listsize <number-of-lines>
.
O comando backtrace
(ou bt
) exibe o stack backtrace atual. O argumento COUNT Ă© o nĂșmero mĂĄximo de stack frames que serĂŁo exibidos. Se for um nĂșmero negativo exibe os primeiros stack frames.
Exemplo:
Sem argumentos exibe o stack frame selecionado. Caso seja especificado um nĂșmero como argumento, seleciona e exibe o stack frame indicado pelo nĂșmero. Esse nĂșmero pode ser consultado com o comando backtrace
.
Esse comando tem os seguintes subcomandos:
Exibe o stack frame no endereço especificado.
O comando frame apply
executa o mesmo comando em um ou mais stack frames. Esse subcomando Ă© Ăștil, por exemplo, para ver o valor das variĂĄveis locais que estĂŁo em uma função de outro stack frame alĂ©m do atual.
COUNT Ă© o nĂșmero de frames onde o comando serĂĄ executado. Por exemplo frame apply 2 p x
executaria o comando print
nos Ășltimos 2 frames (o atual e o anterior).
O frame apply all
executa o comando em todos os frames. JĂĄ o frame apply level
executa o comando em um frame especĂfico. exemplo:
Exibe o stack frame da função especificada.
Exibe o stack frame do nĂșmero especificado.
O comando info
contém diversos subcomandos para exibir informaçÔes sobre o programa que estå sendo depurado. Abaixo serå listado apenas os subcomandos principais.
Exibe os valores dos registradores. Pode-se passar como argumento uma lista (separada por espaço) dos registradores para exibir. Sem argumentos exibe o valor de todos os registradores de propósito geral, registradores de segmento e EFLAGS. Exemplo:
O uso desse subcomando é semelhante ao uso do comando frame e contém os mesmos subcomandos. A diferença é que ele exibe todas as informaçÔes relacionadas ao stack frame. Enquanto o comando frame
apenas exibe informaçÔes de um ponto de vista de alto-nĂvel.
Exibe os argumentos passados para a função do stack frame atual. Se NAMEREGEXP for especificado exibe apenas os argumentos cujo o nome coincida com a expressão regular.
Uso idĂȘntico ao de info args
sĂł que exibe o valor das variĂĄveis locais.
Exibe todas as funçÔes cujo o nome coincida com a expressão regular. Se o argumento não for especificado lista todas as funçÔes.
Exibe os breakpoints definidos no programa.
Exibe informaçÔes sobre o código-fonte atual.
Lista as threads do processo.
Esse comando pode ser usado da mesma maneira que o comando print. Ele registra uma expressão para ser exibida a cada vez que a execução do processo faz uma parada. Exemplo:
Isso exibiria o disassembly de 7 instruçÔes a partir de RIP a cada passo executado.
Se display
for executado sem argumentos ele exibe todas as expressÔes registradas para auto-display.
Enquanto o comando undisplay
remove a expressĂŁo com o nĂșmero especificado. Sem argumentos remove todas as expressĂ”es registradas por display
.
Carrega o arquivo especificado e executa os comandos no arquivo como um script.
Quando o GDB inicia ele faz o source automĂĄtico do script de nome .gdbinit
presente na sua pasta home. Exceto se o GDB for iniciado com a flag --nh
.
O comando help
, sem argumentos, lista as classes de comandos. Ă possĂvel rodar help CLASS
para obter a lista de comandos daquela classe.
TambĂ©m Ă© possĂvel rodar help COMMAND
para obter ajuda para um comando especĂfico, pode-se inclusive usar abreviaçÔes. E tambĂ©m Ă© possĂvel obter ajuda para subcomandos, conforme exemplos:
Ă possĂvel usar o GDB com uma interface textual permitindo que seja mais agradĂĄvel acompanhar a execução enquanto observa o cĂłdigo-fonte. Para isso basta iniciar o GDB com a flag -tui
, como em:
Quando se estĂĄ no modo Single Key Ă© possĂvel executar alguns comandos pressionando uma Ășnica tecla, conforme tabela abaixo:
Qualquer outra tecla alterna temporariamente para o modo de comandos. ApĂłs um comando ser executado ele retorna para o modo Single Key.
O formato das instruçÔes do código de måquina.
Primeira coisa que a gente precisa saber é que a arquitetura x86-64 é CISC (Complex Instruction Set Computer), ou seja uma arquitetura que contém um conjunto complexo de instruçÔes.
O que significa na pråtica que a arquitetura contém muitas instruçÔes consideradas "complexas", que efetuam muitas operaçÔes de uma vez. Por exemplo a instrução rep movsb
faz um bocado de coisas:
Copia o valor em DS:ESI
para ES:EDI
.
Incrementa o valor de ESI.
Incrementa o valor de EDI.
Decrementa o valor de ECX.
Verifica se o valor de ECX Ă© zero. Se for finaliza o loop.
Tudo isso em apenas uma instrução.
Esse é o formato de uma instrução do código de måquina da arquitetura segundo os manuais da Intel:
Legacy prefixes: são prefixos que existem desde o x86, alguns até mesmo desde o 8086. Por isso são chamados de "legacy" (legados).
REX prefix: Ă© um prefixo novo existente somente no modo de 64-bit e adicionado em processadores x86-64.
Opcode: abreviação para operation code (código de operação), é um valor numérico (de 1 a 3 bytes de tamanho) que identifica qual operação o processador deve executar. Desde mover valores, subtrair, somar, calcular a raiz quadrada, modificar o valor de um registrador etc.
ModR/M: é um byte na instrução que não estå presente em todas elas. Explico em detalhes depois mas ele serve para definir o modo de endereçamento e/ou qual registrador é usado na operação. Por isso o R/M, que é uma abreviação para Register/Memory.
SIB: dependendo do modo de endereçamento definido em ModR/M, o byte SIB pode ser usado. Ele define trĂȘs valores:
Scale (2 bits): determina um fator de "escala" (1, 2, 4 ou 8) que irĂĄ multiplicar o valor do index.
Index (3 bits): define o registrador que serĂĄ usado como Ăndice.
Base (3 bits): define o registrador que serå usado como base. Na pråtica o cålculo do endereçamento é feito como na seguinte pseudo-expressão:
Displacement: é um valor numérico de 1, 2 ou 4 bytes de tamanho que é somado ao endereçamento definido por ModR/M. Nem todo modo de endereçamento definido por ModR/M usa o displacement, então nem sempre ele estå presente em uma instrução com operando na memória.
Immediate: é um valor numérico de 1, 2 ou 4 bytes de tamanho usado em algumas operaçÔes que usam um operando imediato. Por exemplo mov ah, 0x0E
, onde o nĂșmero 0x0E
(14 em decimal) é o valor immediate na instrução.
Inclusive a instrução B4 0E
que mencionei anteriormente Ă© a mov ah, 0x0E
. Onde B4
Ă© o opcode (de 1 byte) e 0E
o immediate (de 1 byte também).
Uma instrução na arquitetura x86 pode ter de 1 atĂ© 15 bytes de tamanho. E caso ainda nĂŁo tenha ficado claro: sim, instruçÔes na arquitetura x86 tĂȘm tamanhos variados.
Entendendo os prefixos no cĂłdigo de mĂĄquina.
Na arquitetura x86 as instruçÔes contĂ©m o que Ă© conhecido como "atributos", onde existe um determinado valor padrĂŁo para o atributo e Ă© possĂvel modificĂĄ-lo com um prefixo.
Como pode ser observado na ilustração exibida no tópico Formato das instruçÔes, prefixos são bytes que podem (são opcionais na grande maioria das instruçÔes) ser adicionados antes do opcode de uma instrução.
Uma instrução pode ter mais de um prefixo (até 4 legados). O prefixo REX existente somente em x86-64 precisa obrigatoriamente vir antes do opcode e depois dos demais prefixos. Mas exceto por ele, todos os outros prefixos podem ser adicionados em qualquer ordem que não farå diferença na instrução. Por exemplo a instrução mov eax, [ebx]
em modo de 16-bit seria compilada como na imagem:
Onde 67 66 8B 03
e 66 67 8B 03
dariam na mesma, o processador executaria as duas instruçÔes de maneira totalmente equivalente.
Em modo de 16-bit e modo de 32-bit, desde o processador i386, Ă© possĂvel usar tanto endereçamento de 16-bit como de 32-bit. No exemplo anterior a instrução mov eax, [ebx]
foi compilada no modo de 16-bit, porém usando endereçamento e operando de 32-bit.
O atributo address-size determina o modo de endereçamento da instrução. Em modo 16-bit o atributo address-size por padrão é de 16-bit. E em modo de 32-bit o atributo é por padrão de 32-bit. Jå em modo de 64-bit o endereçamento padrão é 64-bit.
O prefixo conhecido como address-size override, cujo o byte Ă© 67
, serve para usar o modo de endereçamento não-padrão. Ou seja, ao usar o prefixo se estiver em modo de 16-bit o endereçamento serå de 32-bit. E se estiver em modo de 32-bit o endereçamento serå de 16-bit. Jå se estiver em modo de 64-bit o endereçamento serå de 32-bit.
Por isso o prefixo é adicionado em 16-bit para instruçÔes que usam endereçamento de 32-bit. O mesmo também é feito na situação oposta:
Assim como Ă© possĂvel alternar entre endereçamento de 16-bit e 32-bit nos modos de 16-bit (real mode) e 32-bit (protected mode). TambĂ©m Ă© possĂvel alternar o tamanho dos operandos usados em operaçÔes.
Assim como também foi demonstrado no primeiro exemplo a instrução de 16-bit fez uma operação com um valor de 32-bit (o registrador EAX teve seu valor alterado para os 4 bytes presentes no endereço [EBX]
).
E para isso foi usado o prefixo operand-size override, o byte 66
. E na mesma lĂłgica do address-size override
ele alterna o tamanho do operando para o seu tamanho nĂŁo-padrĂŁo. Onde em modos de 32-bit e 64-bit o tamanho padrĂŁo de operando Ă© de 32-bit, e em modo de 16-bit o tamanho padrĂŁo Ă© de 16-bit.
Vale citar um erro que eu vi um senhor cometer uma vez: Ele acreditava que em modo de 32-bit era possĂvel usar registradores de 64-bit e endereçamento de 64-bit. Bem, isso estĂĄ errado como vocĂȘ pode notar pela explicação acima.
Em modo de 16-bit Ă© possĂvel usar registradores e endereçamento de 32-bit alterando os atributos address-size e operand-size. Mas o mesmo nĂŁo se aplica para 64-bit porque o uso de operandos de 64-bit Ă© feito por meio do prefixo REX, que sĂł existe em modo de 64-bit. E em modo de 32-bit sĂł Ă© possĂvel alternar entre endereçamento de 32-bit e 16-bit usando o prefixo 67
.
Qual segmento de memória serå acessado pela instrução é definido em um atributo. O segmento padrão da instrução é definido de acordo com qual registrador foi usado como base:
Para alterar o atributo de segmento para um outro segmento de memĂłria Ă© usado um prefixo distinto por segmento:
Exemplo:
As instruçÔes de movimentação de dados (movsb
, movsw
, movsd
e movsq
) bem como outras como scasb
, lodsb
, in
, out
etc. podem ser executadas em loop usando o prefixo REPE ou REPNE.
No caso das instruçÔes MOVS*
Ă© possĂvel usar o prefixo REPE, que nesse caso tambĂ©m pode ser chamado sĂł de REP
mas os dois mnemĂŽnicos produzem o mesmo byte (F3
).
Ao usar esse prefixo na instrução, assim como foi explicado anteriormente, ela é executada em loop enquanto o valor de ECX não for zero. E a cada iteração do loop o valor do registrador é decrementado. Na verdade se CX ou ECX serå usado isso é definido pelo atributo address-size e pode ser alternado com o prefixo address-size override. Por exemplo na sintaxe do NASM ficaria assim:
Assim ECX seria usado ao invés de CX. Onde a32
é uma palavra-chave usada no NASM para denotar que o address-size daquela instrução deve ser de 32-bit. Se usado em modo de 16-bit ele adiciona o prefixo 67
, mas se estiver em modo de 32-bit entĂŁo nenhum prefixo serĂĄ adicionado tendo em vista que o address-size padrĂŁo jĂĄ Ă© de 32-bit.
Sim, também existe a16
e a64
. Como também existe o16
, o32
e o64
para denotar o tamanho do operand-size. Mas detalhe que a64
e o64
denotam o uso do prefixo REX que sĂł existe em modo de 64-bit.
Nas instruçÔes CMPS*
e SCAS*
o prefixo REPE
(ou REPZ
) repete a instrução enquanto a zero flag estiver setada. Jå REPNE
(ou REPNZ
) repete enquanto a zero flag estiver zerada.
O prefixo LOCK (byte F0
) Ă© usado para fazer operaçÔes de escrita atĂŽmica em um determinado endereço de memĂłria. Ou seja o prefixo garante que outros nĂșcleos do processador nĂŁo escrevam naquele endereço ao mesmo tempo, exigindo que essa operação finalize antes de outra que escreva no mesmo endereço seja executada.
Esse prefixo só pode ser usado nas seguintes instruçÔes: ADD
, ADC
, AND
, BTC
, BTR
, BTS
, CMPXCHG
, CMPXCH8B
, CMPXCHG16B
, DEC
, INC
, NEG
, NOT
, OR
, SBB
, SUB
, XOR
, XADD
e XCHG
. Isso, obviamente, quando o operando destino (o que estĂĄ sendo escrito) Ă© um operando na memĂłria.
Na sintaxe do NASM o prefixo pode ser usado simplesmente com a palavra-chave lock
antes da instrução. Como em:
Ă possĂvel manualmente vocĂȘ instruir para o sistema de branch prediction do processador quais saltos condicionais provavelmente irĂŁo ocorrer ou nĂŁo usando dois prefixos:
2E
- Instrui para o processador que o pulo provavelmente nĂŁo ocorrerĂĄ.
3E
- Instrui para o processador que provavelmente o pulo ocorrerĂĄ.
Na sintaxe do NASM esses prefixos podem ser adicionados em saltos condicionais com as palavra-chaves false
e true
respectivamente. Como em:
Todavia esses prefixos são obsoletos e até mesmo ignorados por processadores mais novos, tendo em vista que processadores mais modernos usam um algoritmo para determinar qual salto é mais provåvel de ser tomado ou não. E também saltos para trås são considerados tomados e saltos para frente como não tomados. Isso por causa da forma como compiladores geram código para loops e condicionais.
Em versÔes mais modernas do NASM ele simplesmente irå ignorar o false
ou true
e nĂŁo adicionarĂĄ prefixo algum.
Entendendo o opcode da instrução.
Como jĂĄ foi dito antes existem opcodes cujo os 3 Ășltimos bits sĂŁo usados para identificar o registrador usado na instrução. Opcodes nesse estilo de codificação sĂŁo usados para instruçÔes que sĂł precisam usar um registrador. Por exemplo mov eax, 123
cujo o opcode Ă© B8
.
Jå em instruçÔes que usam o os dois bits menos significativos do opcode tem um significado especial, que são chamados de bit D (direction bit) e S (size bit). Conforme ilustração:
A função do bit D é indicar a direção para onde a operação estå sendo executada. Se do REG para o R/M ou vice-versa. Repare nas instruçÔes abaixo e seus respectivos opcodes:
Convertendo os opcodes 8B
e 89
para binĂĄrio dĂĄ para notar um fato interessante:
A Ășnica diferença entre os opcodes Ă© que em um o bit D estĂĄ ligado e no outro nĂŁo. Quando o bit D estĂĄ ligado o campo REG Ă© usado como operando destino e o campo R/M usado como fonte. E quando ele estĂĄ desligado Ă© o inverso: o campo R/M Ă© o destino e o REG Ă© o fonte. Obviamente o mesmo tambĂ©m se aplica se o R/M tambĂ©m for um registrador.
Por exemplo a instrução xor eax, eax
pode ser escrita em cĂłdigo de mĂĄquina como 31 C0
ou 33 C0
. Como no campo REG e no campo R/M são os mesmos registradores não faz diferença qual é o fonte e qual é o destino, a operação executada serå a mesma. Usando um disassembler como o ndisasm då para notar isso:
O bit S Ă© usado para definir o tamanho do operando, onde:
0
-> Indica que o operando Ă© de 8-bit
1
-> Indica que o operando Ă© do tamanho do operand-size.
Repare por exemplo a instrução 30 C0
:
Onde 31 C0
(com o bit S ligado) usa o operando de 32-bit EAX. Mas 30 C0
usa o operando de 8-bit AL.
Repare também no seguinte caso:
Campo displacement na instrução do código de måquina.
O displacement (deslocamento) é um valor numérico de 1, 2 ou 4 bytes de tamanho que também faz parte da instrução assim como o valor imediato.
Em modo de 32-bit ou 64-bit, o displacement pode ser de 1 ou 4 bytes de tamanho. Em modo de 16-bit pode ser de 1 ou 2 bytes de tamanho.
Ele é um valor numérico que é somado ao definido pelo byte ModR/M. Se esse campo estå presente ou não na instrução, bem como seu tamanho, é definido no byte ModR/M.
Exemplo:
Onde o valor 0x11223344
na instrução mov eax, [ebx + 0x11223344]
é o displacement da instrução.
Entendendo o prefixo REX no x86-64.
Como eu mencionei antes esse prefixo sĂł existe no modo de 64-bit e ele Ă© necessĂĄrio para usar operandos de 64-bit. Esse prefixo nĂŁo Ă© um byte especĂfico mas sim todos os bytes entre 40
e 4F
. Isso porque os Ășltimos 4 bits do prefixo sĂŁo campos distintos, mas os 4 bits mais significativos do prefixo REX sempre tem o valor fixo de 0100
.
Observe as figuras tiradas dos manuais da Intel:
Em modo de 16-bit e 32-bit hĂĄ 8 registradores de propĂłsito geral, mas em 64-bit hĂĄ 16 registradores de propĂłsito geral. Como eu mencionei antes os campos que especificam os registradores por cĂłdigos contĂ©m somente 3 bits de tamanho, daĂ sĂł Ă© possĂvel especificar 8 registradores distintos.
Mas alguns bits do prefixo REX são usados para estender os tamanhos desses campos em 1 bit, assim permitindo especificar até 16 registradores distintos ou 16 modos de endereçamento distintos. Cada bit do prefixo REX é identificado por uma letra e é comumente referido como no formato REX.B
que seria o bit B
(o menos significativo) do prefixo.
Em instruçÔes cujo a codificação do registrador faz parte do opcode, ele é usado para estender o campo de registrador. Onde ele se torna o bit mais significativo do valor.
Em instruçÔes com ModR/M (sem SIB) ele estende o campo R/M como o bit mais significativo.
Em instruçÔes com SIB ele estende o campo Base como o bit mais significativo.
Estende o campo Index do SIB como o bit mais significativo.
Estende o campo REG do byte ModR/M como o bit mais significativo.
Se ligado a instrução usa operandos de 64-bit, onde por padrão os operandos são de 32-bit.
Entendendo a codificação dos registradores em 16-bit, 32-bit e 64-bit
Em modo de 16-bit e 32-bit cada registrador Ă© identificado usando um nĂșmero de 3 bits, permitindo assim identificar uma variação de 8 registradores diferentes. PorĂ©m vĂĄrios registradores compartilham do mesmo cĂłdigo, e qual especificamente serĂĄ usado varia de acordo com a instrução sendo utilizada e o tamanho do operando.
Por exemplo irão sempre usar algum registrador ST0~ST7, então o código em uma instrução da FPU serå usado para identificar algum deles.
Como por exemplo a instrução fld st3
que em cĂłdigo de mĂĄquina fica D9 C3
, onde C3
Ă© o ModR/M:
Repare que essa instrução usa o campo REG
como extensĂŁo do opcode e o R/M Ă© usado para especificar o operando. NĂŁo coincidentemente o cĂłdigo 3 (0b011
) Ă© usado para identificar o registrador ST3.
Jå instruçÔes que usam , qual especificamente serå usado depende do tamanho do operando na instrução (veja ).
Por exemplo as seguintes instruçÔes compiladas em modo de 64-bit:
Se convertermos esses opcodes em binĂĄrio teremos o seguinte:
Esses dois opcodes usam os 3 Ășltimos bits para identificar o registrador. Veja que o mesmo cĂłdigo 000
acabou sendo usado para identificar EAX, AX e AL.
Isso porque na primeira instrução o atributo operand-size padrão de 32-bit foi usado, então o registrador EAX é usado na instrução. Jå na segunda o prefixo operand-size override (byte 66
) foi usado, assim o operand-size era de 16-bit e portanto o registrador AX Ă© usado.
JĂĄ a Ășltima instrução Ă© exclusivamente usada para operandos de 8-bit, e portanto o registrador AL Ă© usado.
A tabela abaixo lista os cĂłdigos usados para identificar os registradores. Lembrando que o bit mais significativo indica um dos bits do REX ligado, ou seja, sĂł Ă© utilizado em modo de 64-bit.
Entendendo os byte ModR/M e SIB.
Como jå foi mencionado anteriormente o byte ModR/M é usado em algumas instruçÔes para especificar o operando na memória ou registrador.
Em Assembly existem dois "tipos" de instruçÔes que recebem dois operandos:
As que tem um operando registrador e imediato. Exemplo: mov eax, 123
As que tem um operando na memĂłria ou dois operandos registradores. Exemplos: mov [ebx], 123
e mov eax, ebx
.
O primeiro tipo nĂŁo precisa do byte ModR/M, pois o registrador destino Ă© especificado nos 3 Ășltimos bits do byte do . Por exemplo o opcode B8
da instrução mov eax, 123
Ă© o seguinte em binĂĄrio: 10111000
Onde o nĂșmero zero (000
) Ă© o cĂłdigo para identificar o registrador EAX.
Um jeito mais simples de especificar esse campo no opcode sem precisar lidar com binårio é simplesmente somar o opcode "base" (correspondente ao uso de AL/AX/EAX) mais o código do registrador. Por exemplo se a instrução B8
(B8 + 0
) corresponde a mov eax, 123
, entĂŁo o opcode BB
(B8 + 3
) Ă© mov ebx, 123
. E se eu quiser fazer mov bx, 123
basta adicionar o prefixo 66
à instrução.
JĂĄ as instruçÔes do segundo tipo usam o byte ModR/M para definir o operando destino na memĂłria (no caso de instruçÔes sem o operando registrador) ou os dois operandos. Onde o byte ModR/M consiste nos trĂȘs campos:
MOD
- Os primeiros 2 bits que definem o "modo" do operando R/M.
REG
- Os 3 prĂłximos bits que definem o cĂłdigo do operando registrador.
R/M
- Os 3 Ășltimos bits que definem o cĂłdigo do operando R/M.
O byte define 2 operandos:
Um operando que Ă© sempre um registrador, definido no campo REG
.
Um operando que pode ser um registrador ou operando na memĂłria.
Para que o campo R/M
defina também o código de um registrador, assim como o REG
, o valor 3 (11
em binĂĄrio) deve ser usado no campo MOD
.
Um adendo sobre o byte ModR/M é que em algumas instruçÔes o campo REG
Ă© usado como uma extensĂŁo do opcode.
à o caso por exemplo das instruçÔes inc dword [ebx]
(FF 03
) e dec dword [ebx]
(FF 0B
) que contém o mesmo byte de opcode mas fazem operaçÔes diferentes.
Repare como o campo R/M é necessårio para especificar o operando na memória mas o REG fica "sobrando", por isso os engenheiros da Intel tomaram essa decisão minimamente confusa (vulgo gambiarra), afim de aproveitar dessa peculiaridade em instruçÔes que precisam de um operando na memória mas não precisam de um operando registrador.
Para os demais valores do campo MOD
os seguintes endereçamentos são feitos de acordo com o valor de R/M
:
Os endereçamentos com R/M 100
(em 32-bit e 64-bit) sĂŁo os que usam o byte SIB (exceto MOD 11
), que como jå foi explicado anteriormente contém os campos Scale, Index e Base que são calculados de maneira equivalente a expressão:
Onde o campo scale são os 2 primeiros bits, onde seu valor numérico é equivalente aos seguintes fatores de escala:
00
- NĂŁo multiplica o index
01
- Multiplica o index por 2
10
- Multiplica o index por 4
11
- Multiplica o index por 8
Indica que o cĂłdigo ASM modifica as flags do processador (registrador ).
Operando em um .
Qualquer .
Qualquer .
Representa o conteĂșdo de um . Pode armazenar 4 valores floating-point de 32-bit.
Imagine que mågico seria se o depurador pudesse voltar no tempo e desfazer as instruçÔes executadas no programa, fazendo ele executar de maneira reversa parecido com rebobinar uma fita. Bom, o GDB pode fazer isso.
Segmento | Byte do prefixo |
---|---|
Veja que ao usar o prefixo 66
() em 31 C0
o registrador AX é utilizado. Mas esse prefixo é ignorado em instruçÔes cujo o bit S esteja desligado. Por isso o ndisasm faz o disassembly da instrução ainda como xor al, al
. Embora ele adicione um o16
ali para denotar o uso (inĂștil) do prefixo.
Como jĂĄ foi explicado no tĂłpico que fala sobre o , esse prefixo estende os campos usados em ModR/M, SIB e o campo REG
do opcode em 1 bit. DaĂ assim o cĂłdigo usado para identificar o registrador, em modo de 64-bit, tem 4 bits de tamanho.
CĂłdigo | Registrador |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
Devido ao o campo R/M Ă© estendido em 1 bit no modo de 64-bit.
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
R/M | Endereçamento |
---|
Jå os campos index e base contém 3 bits cada e os mesmos armazenam o que serão usados. Os bits dos campos no byte seguem a ordem que o próprio nome sugere. Como em: SSIIIBBB
.
Registrador base
Segmento
RIP
CS
SP/ESP/RSP
SS
BP/EBP/RBP
SS
Qualquer outro registrador
DS
CS
2E
DS
3E
ES
26
FS
64
GS
65
SS
36
Atalho de teclado
Descrição
Ctrl+x a
O atalho Ctrl+x a
(Ctrl+x seguido da tecla a
) alterna para o modo TUI caso tenha iniciado o GDB normalmente.
Ctrl+x 1
Alterna para o layout de janela Ășnica.
Ctrl+x 2
Alterna para o layout de janela dupla. Quando jĂĄ estĂĄ no layout de janela dupla o prĂłximo layout com duas janelas Ă© selecionado. Onde Ă© possĂvel exibir cĂłdigo-fonte+Assembly, registradores+Assembly e registradores+cĂłdigo-fonte.
Ctrl+x o
Muda a janela ativa.
Ctrl+x s
Muda para o modo Single Key Mode.
PgUp
Rola a janela ativa uma pĂĄgina para cima.
PgDn
Rola a janela ativa uma pĂĄgina para baixo.
â (Up)
Rola a janela ativa uma linha para cima.
â (Down)
Rola a janela ativa uma linha para baixo.
â (Left)
Rola a janela ativa uma coluna para a esquerda.
â (Right)
Rola a janela ativa uma coluna para a direita.
Ctrl+L
Redesenha a tela.
Tecla
Comando
Nota
c
continue
d
down
f
finish
n
next
o
nexti
"o" de step over.
q
-
Sai do modo Single Key.
r
run
s
step
i
stepi
u
up
v
info locals
"v" de variables.
w
where
Alias para o comando backtrace
.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
000 |
|
001 |
|
010 |
|
011 |
|
100 |
|
101 |
|
110 | displacement 16-bit |
111 |
|
000 |
|
001 |
|
010 |
|
011 |
|
100 |
|
101 |
|
110 |
|
111 |
|
000 |
|
001 |
|
010 |
|
011 |
|
100 |
|
101 |
|
110 |
|
111 |
|
000 |
|
001 |
|
010 |
|
011 |
|
100 | SIB |
101 | displacement 32-bit |
110 |
|
111 |
|
000 |
|
001 |
|
010 |
|
011 |
|
100 | SIB + displacement 8-bit |
101 |
|
110 |
|
111 |
|
000 |
|
001 |
|
010 |
|
011 |
|
100 | SIB + displacement 32-bit |
101 |
|
110 |
|
111 |
|
0000 |
|
0001 |
|
0010 |
|
0011 |
|
0100 | SIB |
0101 |
|
0110 |
|
0111 |
|
1000 |
|
1001 |
|
1010 |
|
1011 |
|
1100 | SIB |
1101 |
|
1110 |
|
1111 |
|
0000 |
|
0001 |
|
0010 |
|
0011 |
|
0100 | SIB + displacement 8-bit |
0101 |
|
0110 |
|
0111 |
|
1000 |
|
1001 |
|
1010 |
|
1011 |
|
1100 | SIB + displacement 8-bit |
1101 |
|
1110 |
|
1111 |
|
0000 |
|
0001 |
|
0010 |
|
0011 |
|
0100 | SIB + displacement 32-bit |
0101 |
|
0110 |
|
0111 |
|
1000 |
|
1001 |
|
1010 |
|
1011 |
|
1100 | SIB + displacement 32-bit |
1101 |
|
1110 |
|
1111 |
|
IntelÂź 64 and IA-32 Architectures Software Developer Manuals - Volume 2, Appendix C
Frederico Lamberti Pissarra. Dicas - C e Assembly para arquitetura x86-64
Linux Programmer's Manual
Daniel P. Bovet, Marco Cesati. Understanding the Linux Kernel, 3rd Edition - 4.5 Exception Handling
Andrew S. Tanenbaum. Sistemas Operacionais Modernos. 4° Edição. ISBN: 978-8543005676
Alguns trechos do livro foram baseados em conhecimento que obtive lendo diretamente o cĂłdigo-fonte de alguns projetos. Abaixo eu listo cada arquivo consultado para fins de referĂȘncia. O *
(caractere curinga) indica que consultei todos os arquivos de um determinado diretĂłrio.