Apesar de não estudarmos todos os aspectos da linguagem Assembly, alguns assuntos são de extrema importância, mesmo para os fundamentos da engenharia reversa de software. Um deles é como funcionam as funções criadas em um programa e suas chamadas, que discutiremos agora.
Basicamente, uma função é um bloco de código reutilizável num programa. Tal bloco faz-se útil quando um determinado conjunto de instruções precisa ser invocado em vários pontos do programa. Por exemplo, suponha um programa em Python que precise converter a temperatura de Fahrenheit para Celsius várias vezes no decorrer de seu código. Ele pode ser escrito assim:
O programa funciona e a saída é a esperada:
No entanto, é pouco prático, pois repetimos o mesmo código várias vezes. Além disso, uma versão compilada fica maior em bytes. Toda esta repetição também prejudica a manutenção do código, pois se o programador precisar fazer uma alteração no cálculo, vai ter que alterar em todos eles. É aí que entram as funções. Analise a seguinte versão do mesmo programa:
A saída é a mesma, mas agora o programa está utilizando uma função, onde o cálculo só foi definido uma única vez e toda vez que for necessário, o programa a chama.
Uma função normalmente tem:
Argumentos, também chamados de parâmetros, que são os dados que a função recebe, necessários para cumprir seu propósito.
Retorno, que é o resultado da conclusão do seu propósito, seja bem sucedida ou não.
Um nome (na visão do programador) ou um endereço de memória (na visão do processador).
Agora cabe a nós estudar como isso tudo funciona em baixo nível.
Nos primórdios da computação as funções eram chamadas de procedimentos (procedures). Em algumas linguagens de programação, no entanto, possuem tanto funções quanto procedimentos. Estes últimos são "funções que não retornam nada". Já no paradigma da programação orientada a objetos (POO), as funções de uma classe são chamadas de métodos.
Em baixo nível, uma função é implementada basicamente num bloco que não será executado até ser chamado por uma instrução CALL. Ao final de uma instrução, encontramos normalmente a instrução RET. Vamos analisar uma função simples de soma para entender:
Olha como ela fica compilada no Linux em 32-bits:
Removi partes do código intencionalmente, pois o objetivo neste momento é apresentar as instruções que implementam as chamadas de função. Por hora, você só precisa entender que a instrução CALL (no endereço 0x804842d em nosso exemplo) chama a função soma() em 0x0804840b e a instrução RET (em 0x8048417) retorna para a instrução imediatamente após a CALL (0x8048432), para que a execução continue.
A memória RAM para um processo é dividida em áreas com diferentes propósitos. Uma delas é a pilha, ou stack.
Essa área de memória funciona de forma que o que é colocado lá fique no topo e o último dado colocado na pilha seja o primeiro a ser retirado, como uma pilha de pratos ou de cartas de baralho mesmo. Esse método é conhecido por LIFO (Last In First Out).
Seu principal uso é no uso de funções, tanto para passagem de argumentos (parâmetros da função) quanto para alocação de variáveis locais da função (que só existem enquanto a função executa).
Na arquitetura IA-32, a pilha é alinhada em 4 bytes (32-bits). Por consequência, todos os seus endereços também o são. Logo, se novos dados são colocados na pilha (empilhados), o endereço do topo é decrementado em 4 unidades. Se um dado for desempilhado, o endereço do topo é incrementado em 4 unidades. Perceba a lógica invertida, porque a pilha começa num endereço alto e cresce em direção a endereços menores.
Existem dois registradores diretamente associados com a pilha de memória alocada para um processo. São eles:
O ESP, que aponta para o topo da pilha.
O EBP, que aponta para a base do stack frame.
Veremos agora as instruções de manipulação de pilha. A primeira é a instrução PUSH (do inglês "empurrar") que, como o nome sugere, empilha um dado. Na forma abaixo, essa instrução faz com que o processador copie o conteúdo do registrador EAX para o topo da pilha:
Também é possível empilhar um valor literal. Por exemplo, supondo que o programa coloque o valor um na pilha:
Além de copiar o valor proposto para o topo da pilha, a instrução PUSH decrementa o registrador ESP em 4 unidades, conforme já explicado o motivo. Sempre.
Sua instrução antagônica é a POP, que só precisa de um registrador de destino para copiar lá o valor que está no topo da pilha. Por exemplo:
Seja lá o que estiver no topo da pilha, será copiado para o registrador EDX. Além disso, o registrador ESP será incrementado em 4 unidades. Sempre.
Temos também a instrução CALL, que faz duas coisas:
Coloca o endereço da próxima instrução na pilha de memória (no caso do exemplo, 0x8048432).
Coloca o seu parâmetro, ou seja, o endereço da função a ser chamada, no registrador EIP (no exemplo é o endereço 0x804840b).
Por conta dessa atualização do EIP, o fluxo é desviado para o endereço da função chamada. A ideia de colocar o endereço da próxima instrução na pilha é para o processador saber para onde tem que voltar quando a função terminar. E, falando em terminar, a estrela do fim da festa é a instrução RET (de RETURN). Ela faz uma única coisa:
Retira um valor do topo da pilha e coloca no EIP.
Isso faz com que o fluxo de execução do programa volte para a instrução imediatamente após a CALL, que chamou a função.
Vamos agora analisar a pilha de memória num exemplo com a função MessageBox, da API do Windows:
Perceba que quatro parâmetros são empilhados antes da chamada à MessageBoxA (versão da função MessageBox que recebe strings ASCII, por isso o sufixo A).
Os parâmetros são empilhados na ordem inversa.
Já estudamos o protótipo desta função no capítulo que apresenta a Windows API e por isso sabemos que o 0x31, empilhado em 00401516, é o parâmetro uType
e, se o decompormos, veremos que 0x31 é um OU entre 0x30 (MB_ICONEXCLAMATION) e 0x1 (MB_OKCANCEL).
O próximo parâmetro é o número 404000, um ponteiro para a string "Johnny", que é o título da mensagem. Depois vem o ponteiro para o texto da mensagem e por fim o zero (NULL), empilhado em 00401522, que é o handle.
O resultado é apresentado a seguir:
É importante perceber que, após serem compreendidos, podemos controlar estes parâmetros e alterar a execução do programa conforme quisermos. Este é o assunto do próximo capítulo, sobre depuração.
Assembly é, por si só, um assunto extenso e bastante atrelado à arquitetura e ao sistema operacional no qual se está trabalhando. Este capítulo apresentou uma introdução ao Assembly Intel x86 e considerou o Windows como plataforma. Dois bons recursos de Assembly, que tomam o Linux como sistema base, são os livros gratuito Aprendendo Assembly, do Felipe Silva e Linguagem Assembly para i386 e x86-64, do Frederico Pissara.
Uma instrução é um conjunto definido por um código de operação (opcode) mais seus operandos, se houver. Ao receber bytes específicos em seu barramento, o processador realiza determinada operação. O formato geral de uma instrução é:
Onde opcode representa um código de operação definido no manual da Intel, disponível em seu website. O número de operandos, que podem variar de 0 a 3 na IA-32 (Intel Architecture de 32-bits), consistem em números literais, registradores ou endereços de memória necessários para a instrução funcionar. Por exemplo, considere a seguinte instrução, que coloca o valor 2018 no registrador EAX:
O primeiro byte é o opcode. Os outros 4 bytes representam o primeiro e único argumento dessa instrução. Sabemos então que 0xB8 faz com que um valor seja colocado em EAX. Como este registrador tem 32-bits, nada mais natural que o argumento dessa instrução ser também de 32-bits ou 4 bytes. Considerando o endianess, como já explicado anteriormente neste livro, o valor literal 2018 (0x7E2 ou, em sua forma completa de 32-bits, 0x000007E2) é escrito em little-endian com seus bytes na ordem inversa, resultando em E2 07 00 00.
Na arquitetura Intel IA-32, uma instrução (considerando o opcode e seus argumentos) pode ter de 1 à 15 bytes de tamanho.
Uma instrução muito comum é a MOV, forma curta de "move" (do Inglês, "mover"). Apesar do nome, o que a instrução faz é copiar o segundo operando (origem) para o primeiro (destino). O operando de origem pode ser um valor literal, um registrador ou um endereço de memória. O operando de destino funciona de forma similar, com exceção de não poder ser um valor literal, pois não faria sentido mesmo. Ambos os operandos precisam ter o mesmo tamanho, que pode ser de um byte, uma word ou uma doubleword, na IA-32. Analise o exemplo a seguir:
A instrução acima copia um valor literal 0xB0B0CA para o registrador EBX. A versão compilada desta instrução resulta nos seguintes bytes:
Naturalmente, processadores fazem muitos cálculos matemáticos. Veremos agora algumas dessas instruções, começando pela instrução ADD, que soma valores. Analise:
No código acima, a instrução ADD soma 1 ao valor de ECX (que no nosso caso é 7, conforme instrução anterior). O resultado desta soma é armazenado no operando de destino, ou seja, no próprio registrador ECX, que passa a ter o valor 8.
Uma outra forma de atingir este resultado seria utilizar a instrução INC, que incrementa seu operando em uma unidade, dessa forma:
A instrução INC recebe um único operando que pode ser um registrador ou um endereço de memória. O resultado do incremento é armazenado no próprio operando, que em nosso caso é o registrador ECX.
O leitor pode se perguntar por que existe uma instrução INC se é possível incrementar um operando em uma unidade com a instrução ADD. Para entender, compile o escreva o seguinte programa:
Salve como soma.s
e compile com o NASM. Terminada a compilação, verifique o código objeto gerado com o comando objdump
:
Há duas diferenças básicas entre as instruções ADD e INC neste caso. A mais óbvia é que a instrução ADD EAX, 1 custou três bytes no programa, enquanto a instrução INC EAX utilizou somente um. Isso pode parecer capricho, mas não é: binários compilados possuem normalmente milhares de instruções Assembly e a diferença de tamanho no resultado final pode ser significativa.
Existem sistemas onde cada byte economizado num binário é valioso. Alguns exigem que os binários sejam os menores possíveis, tanto em disco (ou memória flash) quanto sua imagem na memória RAM. Este consumo de memória é por vezes chamado de footprint, principalmente em literatura sobre sistemas embarcados.
Outra vantagem da INC sobre a ADD é a velocidade de execução, já que a segunda requer que o processador leia os operandos.
A instrução SUB funciona de forma similar e para subtrair somente uma unidade, também existe uma instrução DEC (de decremento). Vamos então estudar um pouco sobre a instrução MUL agora. Esta instrução tem o primeiro operando (o de destino) implícito, ou seja, você não precisa fornecê-lo: será sempre EAX ou uma sub-divisão dele, dependendo do tamanho do segundo operando (de origem), que pode ser um outro registrador ou um endereço de memória. Analise:
A instrução MUL EBX vai realizar uma multiplicação sem sinal (sempre positiva) de EBX com EAX e armazenar o resultado em EAX.
Perceba que não se pode fazer diretamente MUL EAX, 2. Foi preciso colocar o valor 2 em outro registrador antes, já que a MUL não aceita um valor literal como operando.
A instrução DIV funciona de forma similar, no entanto, é recomendável que o leitor faça testes e leia sobre estas instruções no manual da Intel caso queira se aprofundar no entendimento delas.
Neste ponto acredito que o leitor esteja confortável com a aritimética em processadores x86, mas caso surjam dúvidas, não deixe de discuti-las em https://menteb.in/forum.
Já explicamos o que são as operações bit-a-bit quando falamos sobre cálculo com binários, então vamos dedicar aqui à particularidades de seu uso. Por exemplo, a instrução XOR, que faz a operação OU EXCLUSIVO, pode ser utilizada para zerar um registrador, o que seria equivalente a mover o valor 0 para o registrador, só que muito mais rápido. Analise:
Além de menor em bytes, a versão XOR é também mais rápida. Em ambas as instruções, depois de executadas, o resultado é que o registrador ECX terá o valor 0 e a flag ZF será setada, como em qualquer operação que resulte em zero.
Faça você mesmo testes com as instruções AND, OR, SHL, SHR, ROL, ROR e NOT. Todas as suas operações já foram explicadas na seção Cálculos com Binários.
Sendo uma operação indispensável ao funcionamento dos computadores, a comparação precisa ser muito bem compreendida. Instruções chave aqui são a CMP (Compare) e TEST. Analise o código a seguir:
A instrução CMP neste caso compara o valor de EAX (previamente setado para 0xB0B0) com 0xFE10. O leitor tem alguma ideia de como tal comparação é feita matematicamente? Acertou quem pensou em diminuir de EAX o valor a ser comparado. Dependendo do resultado, podemos saber o resultado da comparação da seguinte maneira:
Se o resultado for zero, então os operandos de destino e origem são iguais.
Se o resultado for um número negativo, então o operando de destino é maior que o de origem.
Se o resultado for um número positivo, então o operando de destino é menor que o de origem.
O resultado da comparação é configurado no registrador EFLAGS, o que significa dizer que a instrução CMP altera as flags, para que instruções futuras tomem decisões baseadas nelas. Por exemplo, para operandos iguais, a CMP faz ZF=1.
A instrução CMP é normalmente precedida de um salto, como veremos a seguir.
A ideia de fazer uma comparação é tomar uma decisão na sequencia. Neste caso, decisão significa para onde transferir o fluxo de execução do programa, o que é equivalente a dizer para onde pular, saltar, ou para onde apontar o EIP (o ponteiro de instrução). Uma maneira de fazer isso é com as instruções de saltos (jumps).
Existem vários tipos de saltos. O mais simples é o salto incondicional produzido pela instrução JMP, que possui apenas um operando, podendo ser um valor literal, um registrador ou um endereço de memória. Para entender, analise o programa abaixo:
A instrução ADD EAX, 4 nunca será executada pois o salto faz a execução pular para o endereço 0x0A, onde temos a instrução INC EAX. Portanto, o valor final de EAX será 2.
Note aqui o opcode do salto incondicional JMP, que é o 0xEB. Seu argumento, é o número de bytes que serão pulados, que no nosso caso, são 3. Isso faz a execução pular a instrução ADD EAX, 4 inteira, já que ela tem exatamente 3 bytes.
Você pode entender o salto incondicional JMP como um comando goto na linguagem de programação C.
Os saltos condicionais J_cc_ onde cc significa condition code, podem ser de vários tipos. O mais famoso deles é o JE (Jump if Equal), utilizado para saltar quando os valores da comparação anterior são iguais. Em geral ele vem precedido de uma instrução CMP, como no exemplo abaixo:
A instrução no endereço 0x5 compara o valor de EAX com 1 e vai sempre resultar em verdadeiro neste caso, o que significa que a zero flag será setada.
O salto JE ocorre se ZF=1, ou seja, se a zero flag estiver setada. Por essa razão, ele também é chamado de JZ (Jump if Zero). Abaixo uma tabela com os saltos que são utilizados para comparações entre números sem sinal e as condições para que o salto ocorra:
Nem é preciso dizer que vai ser necessário você criar programas em Assembly para treinar a compreensão de cada um dos saltos, é?
Já vimos que comparações são na verdade subtrações, por isso os resultados são diferentes quando utilizados números com e sem sinal. Apesar de a instrução ser a mesma (CMP), os saltos podem mudar. Eis os saltos para comparações com sinal:
Não se preocupe com a quantidade de diferentes instruções na arquitetura. O segredo é estudá-las conforme o necessário, na medida em que surgem nos programas que você analisa. Para avançar, só é preciso que você entenda o conceito do salto. Muitos problemas de engenharia reversa são resolvidos com o entendimento de um simples JE (ZF=1). Se você já entendeu isso, é suficiente para prosseguir. Se não, releia até entender. É normal não compreender tudo de uma vez e vários dos assuntos necessitam de revisão e exercícios para serem completamente entendidos.
Os processadores possuem uma área física em seus chips para armazenamento de dados (de fato, somente números, já que é só isso que existe neste mundo!) chamadas de registradores, justamente porque registram (salvam) um número por um tempo, mesmo este sendo não determinado.
Os registradores possuem normalmente o tamanho da palavra do processador, logo, se este é um processador de 32-bits, seus registradores possuem este tamanho também.
Um registrador de uso geral, também chamado de GPR (General Purpose Register) serve para armazenar temporariamente qualquer tipo de dado, para qualquer função.
Existem 8 registradores de uso geral na arquitetura Intel x86. Apesar de poderem ser utilizados para qualquer coisa, como seu nome sugere, a seguinte convenção é normalmente respeitada:
Registrador | Significado | Uso sugerido |
---|
Para fixar o assunto, é importante trabalhar um pouco. Vamos escrever o seguinte programa em Assembly no Linux ou macOS:
Salve-o como ou.s
e para compilar, vamos instalar o Netwide Assembler (NASM), que para o nosso caso é útil por ser multiplataforma:
Confira como ficou o código compilado no arquivo objeto com a ferramenta objdump, do pacote binutils:
Perceba os opcodes e argumentos idênticos aos exemplificados na introdução deste capítulo.
O NASM compilou corretamente a linguagem Assembly para código de máquina. O objdump mostrou tanto o código de máquina quanto o equivalente em Assembly, processo conhecido como desmontagem ou disassembly.
Agora cabe à você fazer mais alguns testes com outros registradores de uso geral. Um detalhe importante é que os primeiros quatro registradores de uso geral podem ter sua parte baixa manipulada diretamente. Por exemplo, é possível trabalhar com o registrador de 16 bits AX, a parte baixa de EAX. Já o AX pode ter tanto sua parte alta (AH) quanto sua parte baixa (AL) manipulada diretamente também, onde ambas possuem 8 bits de tamanho. O mesmo vale para EBX, ECX e EDX.
Para entender, analise as seguintes instruções:
Após a execução das instruções acima, EAX conterá o valor 0xaabbeeff, já que somente sua parte baixa foi modificada pela segunda instrução. Agora analise o seguinte trecho:
Após a execução das quatro instruções acima, EAX volta para o valor 0xaabbccdd.
A imagem a seguir ilustra os ditos vários registradores dentro dos quatro primeiros registradores de uso geral.
Os registradores EBP, ESI, EDI e ESP também podem ser utilizados como registradores de 16-bits BP, SI, DI e SP, respectivamente. Note porém que estes últimos não são sub-divididos em partes alta (high) e baixa (low).
Existe um registrador chamado de EIP (Extended Instruction Pointer), também de PC (Program Counter) em algumas literaturas que aponta para a próxima instrução a ser executada. Não é possível copiar um valor literal para este registrador. Portanto, uma instrução mov eip, 0x401000
não é válida.
Outra propriedade importante deste registrador é que ele é incrementado com o número de bytes da última instrução executada. Para fixar, analise o exemplo do disasembly a seguir, gerado com a ferramenta objdump:
Quando a primeira instrução do trecho acima estiver prestes à ser executada, o registrador EIP conterá o valor 0x8049000. Após a execução desta primeira instrução MOV, o EIP será incrementado em 5 unidades, já que tal instrução possui 5 bytes. Por isso o objdump já mostra o endereço correto da instrução seguinte. Perceba, no entanto, que a instrução no endereço 804900a possui apenas 2 bytes, o que vai fazer com o que o registrador EIP seja incrementado em 2 unidades para apontar para a próxima instrução MOV, no endereço 804900c.
Estes registradores armazenam o que chamamos de seletores, ponteiros que identificam segmentos na memória, essenciais para operação em modo real. Em modo protegido, que é o modo de operação do processador que os sistemas operacionais modernos utilizam, a função de cada registrador de segmento fica a cargo do SO. Abaixo a lista dos registradores de segmento e sua função em modo protegido:
Não entraremos em mais detalhes sobre estes registradores por fugirem do escopo deste livro.
O registrador de flags EFLAGS é um registrador de 32-bits usado para flags de estado, de controle e de sistema.
Flag é um termo genérico para um dado, normalmente "verdadeiro ou falso". Dizemos que uma flag está setada quando seu valor é verdadeiro, ou seja, é igual a 1.
Existem 10 flags de sistema, uma de controle e 6 de estado. As flags de estado são utilizadas pelo processador para reportar o estado da última operação (pense numa comparação, por exemplo - você pede para o processador comparar dois valores e a resposta vem através de uma flag de estado). As mais comuns são:
Além das outras flags, há ainda os registradores da FPU (Float Point Unit), de debug, de controle, XMM (parte da extensão SSE), MMX, 3DNow!, MSR (Model-Specific Registers), e possivelmente outros que não abordaremos neste livro em prol da brevidade.
Agora que já reunimos bastante informação sobre os registradores, é hora de treinarmos um pouco com as instruções básicas do Assembly.
Agora que você já sabe como um binário PE é construído, está na hora de entender como o código contido em suas seções de código de fato executa. Acontece que um processador é programado em sua fábrica para entender determinadas sequências de bytes como código e executar alguma operação. Para entender isso, vamos fazer uma analogia com um componente muito mais simples que um processador, um circuito integrado (popularmente chamado de chip).
Um circuito integrado (CI) bastante conhecido no mundo da eletrônica é o 7400 que tem o seguinte diagrama esquemático:
Se você já estudou portas lógicas, vai perceber que este CI tem 4 portas NAND (AND com saída negada). Cada porta possui duas entradas e uma saída, cada uma delas conectada a seu respectivo pino/perna.
Admitindo duas entradas (A e B) e uma saída S, a tabela verdade de cada uma das portas deste CI é a seguinte:
Podemos dizer então que este CI faz uma única operação sempre, com as entradas de dados que recebe.
Se seu projeto precisasse também de portas OR, XOR, AND, etc você precisaria comprar outros circuitos integrados, certo? Bem, uma solução inteligente seria utilizar um chip que fosse programável. Dessa forma, você o configuraria, via software, para atuar em certos pinos como porta NAND, outros como porta OR e por aí vai, de acordo com sua necessidade. Aumentando ainda mais a complexidade, temos os microcontroladores, que podem ser programados em linguagens de alto nível, contando com recursos como repetições, condicionais, e tudo que uma linguagem de programação completa oferece. No entanto, estes chips requerem uma reprogramação a cada mudança no projeto, assim como se faz com o Arduino hoje em dia.
Neste sentido um microprocessador, ou simplesmente processador é muito mais poderoso. Ao invés de o usuário gravar um programa nele, o próprio fabricante já o faz, de modo que este microprograma entenda diferentes instruções para realizar diferentes operações de muito alto nível (se comparadas às simples operações booleanas). Sua entrada de dados também é muito mais flexível: ao invés de entradas binárias, um processador pode receber números bem maiores. O antigo Intel 8088 já possuía um barramento de 8 bits, por exemplo.
Isso significa que se um processador receber em seu barramento um conjunto de bytes específico, sabe que vai precisar executar uma operação específica. À estes bytes possíveis damos o nome de opcodes. Ao conjunto dos opcodes + operandos damos o nome de instrução.
Supondo que queiramos então fazer uma operação OR entre os valores 0x20 e 0x18 utilizando um processador x86. Na documentação deste processador, constam as seguintes informações:
Ao receber o opcode 0xb8, os próximos quatro bytes serão um número salvo em memória (similar àquela memória M+ das calculadoras), acessível através do byte identificador 0xc8.
Ao receber o opcode 0x83, seguido de um byte 0xc8 (que identifica a memória), seguido de mais um byte, o número armazenado na memória identificada pelo segundo byte vai sofrer uma operação OR com este terceiro byte.
Precisaríamos então utilizar as duas instruções, da seguinte forma:
Na primeira, que tem um total de 5 bytes, o opcode 0xb8 é utilizado para colocar o número de 32-bits (4 bytes) na sequência em memória. Como nosso número desejado possui somente 1 byte, preenchemos os outros três com zero, respeitando o endianess.
A segunda instrução tem 3 bytes sendo que o primeiro é o opcode dela (OR), o segundo é o identificador da área de memória e o terceiro é o nosso operando 0x18, que deve sofrer o OR com o valor na área de memória indicada por C8.
Temos que concordar que criar um programa assim não é nada fácil. Para resolver este problema foi criada uma linguagem de programação, completamente presa à arquitetura do processador (seus opcodes, suas instruções), chamada Assembly. Com ela, os programadores poderiam escrever o programa acima praticamente em inglês:
De posse de um compilador de Assembly, chamado na época de assembler, o resultado da compilação do código-fonte acima é justamente um arquivo (objeto) que contém os opcodes e argumentos corretos para o processador alvo, onde o programa vai rodar.
Agora você sabe o motivo pelo qual um programa compilado não é compatível entre diferentes processadores, de diferentes arquiteturas. Como estes possuem instruções diferentes e opcodes diferentes, não há mesmo compatibilidade.
Perceba que Assembly é uma linguagem legível para humanos, diferente da linguagem de máquina que não passa de uma "tripa de bytes". Os comandos da linguagem Assembly são chamados de mnemônicos. No exemplo de código acima, utilizamos dois: o MOV e o OR. Estudaremos mais mnemônicos em breve.
Instrução | Alternativa | Condição |
---|
Instrução | Alternativa | Condição |
---|
Registrador | Significado |
---|
Bit | Nome | Sigla | Descrição |
---|
A | B | A & B | S |
---|
JZ (Zero) | JE (Equal) | ZF=1 |
JNZ (Not Zero) | JNE (Not Equal) | ZF=0 |
JC (Carry) | JB (Below) ou JNAE (Not Above or Equal) | CF=1 |
JNC (Not Carry) | JNB (Not Below) ou JAE (Above or Equal) | CF=0 |
JA (Above) | JNBE (Not Below or Equal) | CF=0 e ZF=0 |
JNA (Not Above) | JBE (Below or Equal) | CF=1 ou ZF=1 |
JP (Parity) | JPE (Parity Even) | PF=1 |
JNP (Not Parity) | JPO (Parity Odd) | PF=0 |
JCXZ (CX Zero) | Registrador CX=0 |
JECXZ (ECX Zero) | Registrador ECX=0 |
JG (Greater) | JNLE (Not Less or Equal) |
JGE (Greater or Equal) | JNL (Not Less) |
JL (Less) | JNGE (Not Greater or Equal) |
JLE (Less or Equal) | JNG (Not Greater) |
JS (Sign) | SF=1 |
JNS (Not Sign) | SF=0 |
JO (Overflow) | OF=1 |
JNO (Not Overflow) | OF=0 |
CS | Code Segment |
DS | Data Segment |
SS | Stack Segment |
ES | Extra Data Segment |
FS | Data Segment (no Windows em x86, aponta para o TEB (Thread Environment Block) da thread atual do processo em execução) |
GS | Data Segment |
0 | Carry | CF | Setada quando o resultado estourou o limite do tamanho do dado. É o famoso "vai-um" na matemática para números sem sinal (unsigned). |
6 | Zero | ZF | Setada quando o resultado de uma operação é zero. Do contrário, é zerada. Muito usada em comparações. |
7 | Sign | SF | Setada de acordo com o MSB (Most Significant Bit) do resultado, que é justamente o bit que define se um inteiro com sinal é positivo (0) ou negativo (1), conforme visto na seção Números negativos. |
11 | Overflow | OF | Estouro para números com sinal. |
0 | 0 | 0 | 1 |
1 | 0 | 0 | 1 |
0 | 1 | 0 | 1 |
1 | 1 | 1 | 0 |
EAX | Accumulator | Usado em operações aritiméticas |
EBX | Base | Ponteiro para dados |
ECX | Counter | Contador em repetições |
EDX | Data | Usado em operações de E/S |
ESI | Source Index | Ponteiro para uma string de origem |
EDI | Destination Index | Ponteiro para uma string de destino |
ESP | Stack Pointer | Ponteiro para o topo da pilha |
EBP | Base Pointer | Ponteiro para a base do stack frame |