Ir para o conteúdo principal

Representação de Instruções: ISA, Formatos e Modos de Endereçamento

·3016 palavras·15 minutos·
Autor
Francisco Bustamante
Um químico trabalhando com Ciência de Dados e Programação em Python.
Tabela de conteúdos
Por Dentro do Computador - Este artigo faz parte de uma série de artigos.
Parte 8: Esse Artigo

No artigo anterior — Representação de Dados — vimos como o computador codifica caracteres, inteiros e números de ponto flutuante como padrões de bits. Mas um programa não é apenas dados: é uma sequência de instruções que dizem ao processador o que fazer com esses dados. Como essas instruções são codificadas? Onde o processador encontra os operandos que elas precisam?

Essas perguntas são respondidas pela ISAInstruction Set Architecture — e pelos modos de endereçamento que ela define. A ISA é o ponto de encontro entre o hardware e o software: tudo que um programa faz deve ser expresso em termos que o processador conheça.

A ISA: o contrato entre hardware e software
#

A ISA (Instruction Set Architecture) é a interface entre o hardware e o software. Ela define o conjunto de instruções que o processador entende e o modelo visível para compiladores, montadores, sistemas operacionais e programas.

A ISA especifica, entre outras coisas:

  • o formato das instruções;
  • os tipos de dados suportados;
  • os modos de endereçamento;
  • os registradores e seus papéis;
  • o conjunto de operações que a CPU executa nativamente.

É útil pensar na ISA como um contrato de compatibilidade: programas traduzidos para um ISA só executam diretamente em processadores que implementam esse ISA, salvo uso de emulação, tradução binária ou máquinas virtuais.

ISA não é a mesma coisa que microarquitetura

Dois processadores podem implementar o mesmo ISA e, ainda assim, serem internamente muito diferentes. É o caso de diferentes CPUs x86-64 da Intel e AMD: o software enxerga o mesmo ISA, mas o hardware interno pode variar bastante.

Linguagens de programação e ISA

Nem toda linguagem de alto nível vira diretamente instruções do ISA da máquina.

  • Em linguagens compiladas nativamente, como C e C++, o compilador geralmente gera código de máquina para o ISA-alvo.
  • Em linguagens ou plataformas como Java e Python, pode haver etapas intermediárias, como bytecode, máquina virtual ou compilação JIT, antes da execução final no hardware.

Ainda assim, em última instância, a execução real sempre termina em instruções do ISA implementado pelo processador.

ISA no mercado atual

A família x86-64 continua dominante em desktops e muitos servidores. A família ARM hoje vai muito além de smartphones, aparecendo também em sistemas embarcados, notebooks, nuvem e datacenters. O RISC-V, por sua vez, ganhou relevância por ser um ISA aberto, especialmente em pesquisa, ensino e sistemas embarcados.

Uma classificação útil das instruções
#

Uma forma didática de organizar as instruções de máquina é agrupá-las por função:

# Categoria Exemplos de mnemônicos
1 Movimentação de dados LOAD, STORE, MOV
2 Aritmética inteira ADD, SUB, MPY, DIV
3 Aritmética de ponto flutuante FADD, FMPY, FDIV
4 Lógica AND, OR, NOT, XOR
5 Deslocamentos SHL, SHR, SAR
6 Controle de fluxo JMP, JZ, CALL, RET
7 Entrada/Saída IN, OUT

Essa classificação é útil para estudo, mas não deve ser tratada como uma taxonomia universal. ISAs reais modernas também incluem, por exemplo, instruções vetoriais, atômicas, criptográficas, de sistema e de sincronização.

Arquiteturas RISC tendem a ter poucas instruções por categoria, cada uma simples e regular. Arquiteturas CISC podem ter dezenas de variantes por categoria, incluindo operações complexas em uma única instrução.

Mas independentemente da categoria ou da complexidade, a CPU precisa saber ler essa ordem. Toda instrução precisa ser fisicamente empacotada em bits, e é nesse empacotamento que entra o design dos formatos de instrução.

Formatos de instrução: quantos operandos?
#

Uma das decisões fundamentais no projeto de um ISA é quantos operandos cada instrução carrega. Há um trade-off claro: mais operandos permitem instruções mais expressivas — e portanto programas mais curtos — mas cada instrução ocupa mais espaço na memória.

Antigamente, a preocupação com o espaço era tão extrema que projetos como o SEAC (1949) utilizavam até 4 operandos: três para a operação e um quarto apenas para indicar o endereço da próxima instrução. Com a popularização do registrador CI (Contador de Instrução), que aponta automaticamente para a próxima tarefa, economizamos esses bits preciosos, permitindo formatos mais enxutos.

Para ilustrar, vamos calcular a expressão:

$$X = A \times (B + C \times D - E / F)$$

em três formatos diferentes.

Formato com 3 operandos
#

Forma geral: destino ← operando1 OP operando2

MPY T1, C, D        ; T1 = C × D
ADD T2, B, T1       ; T2 = B + T1  (= B + C × D)
DIV T3, E, F        ; T3 = E / F
SUB T4, T2, T3      ; T4 = T2 − T3 (= B + C×D − E/F)
MPY X,  A, T4       ; X  = A × T4  (resultado final)

Total: 5 instruções. Cada instrução é autocontida — especifica entrada, operação e destino. As instruções são mais longas (três endereços codificados em cada uma), mas o programa é compacto.

Formato com 2 operandos
#

Forma geral: Op1 ← Op1 OP Op2 — o primeiro operando é também o destino.

MOV X, C
MPY X, D            ; X = C × D
ADD X, B            ; X = B + C × D
MOV T, E
DIV T, F            ; T = E / F
SUB X, T            ; X = B + C×D − E/F
MPY X, A            ; X = A × (B + C×DE/F)

Total: 7 instruções. É necessário inserir MOVs para preservar valores originais antes de sobrescrevê-los. Este é o formato mais comum em arquiteturas reais — a família x86 é baseada em instruções de 2 operandos.

Formato com 1 operando (acumulador)
#

Forma geral: ACC ← ACC OP Operando — todas as operações passam por um registrador especial chamado acumulador.

LDA C               ; ACC = C
MPY D               ; ACC = C × D
ADD B               ; ACC = B + C × D
STR T               ; T   = ACC           (salva temporário)
LDA E               ; ACC = E
DIV F               ; ACC = E / F
STR T2              ; T2  = ACC
LDA T               ; ACC = T  (= B + C × D)
SUB T2              ; ACC = B + C×D − E/F
MPY A               ; ACC = A × (B + C×D − E/F)
STR X               ; X   = ACC           (resultado final)

Total: 11 instruções. Cada instrução é compacta (um único endereço), mas são necessárias mais delas e mais acessos à memória para salvar resultados intermediários. Este formato foi comum nos primeiros microprocessadores.

Comparativo
#

Formato Nº de instruções Espaço por instrução Uso típico
3 operandos 5 Grande (3 endereços) Arquiteturas RISC modernas
2 operandos 7 Médio (2 endereços) x86, a maioria das arquiteturas
1 operando (ACC) 11 Pequeno (1 endereço) Primeiros microprocessadores

Mais do que o número de linhas, o projetista de hardware olha para o balanço entre bits e ciclos. Uma instrução de 3 operandos é mais “pesada” (ex: 68 bits), mas resolve a conta com menos viagens à memória (acessos). Já instruções de 1 operando são “leves” (ex: 28 bits), mas exigem muito mais acessos para carregar e salvar o acumulador constantemente. É o eterno dilema entre gastar memória com o código ou gastar tempo com a execução.

Estratégia para calcular o número de instruções
  • 3 operandos: cada operação da expressão gera, em geral, uma instrução com destino explícito.
    Na expressão

    \[ X = A \times (B + C \times D - E/F) \]

    5 operações aritméticas: uma multiplicação \(C \times D\), uma soma, uma divisão, uma subtração e a multiplicação final por \(A\).
    Portanto, o programa com 3 operandos usa 5 instruções, sem precisar de uma instrução extra de “atribuição”.

  • 2 operandos: acrescente MOVs quando precisar preservar um valor antes de sobrescrevê-lo.

  • 1 operando: acrescente LDA e STA sempre que for necessário carregar dados no acumulador e salvar resultados intermediários.

Instruções como SQRT, NEG ou deslocamentos imediatos podem aparecer em ISAs reais ou hipotéticos, mas sua presença isolada não define uma arquitetura como CISC ou RISC.

A distinção entre RISC e CISC envolve um conjunto maior de decisões de projeto:

  • regularidade dos formatos de instrução;
  • quantidade de modos de endereçamento;
  • complexidade média das instruções;
  • facilidade de pipeline e decodificação.

Portanto, o exemplo a seguir é apenas um exemplo de ISA hipotético estendido, e não como prova de uma “filosofia CISC”.

Exemplo — Calcular \(X_1 = (-B + \sqrt{B^2 - 4C}) / 2\) com 1 operando

Usando instruções de 1 operando (acumulador) com endereçamento direto e imediato, e com a instrução especial SQRT para raiz quadrada:

LDA B        ; ACC = B
MPY B        ; ACC = B²
SUB #4       ; ACC = B² − 4  (endereçamento imediato)
MPY C        ; ACC = B² − 4×C
SQRT         ; ACC = √(B² − 4×C)
STR TEMP     ; TEMP = ACC
LDA B        ; ACC = B
NEG          ; ACC = −B
ADD TEMP     ; ACC = −B + √(...)
SHR #1       ; ACC = ACC / 2  (shift right = dividir por 2)
STR X1       ; X1 = ACC

Modos de endereçamento
#

Os modos de endereçamento definem como o processador obtém o operando de uma instrução.

O ponto central é este: o endereço efetivo (EA, Effective Address) é o endereço final do operando na memória, quando a instrução realmente acessa a memória. Em alguns modos, não há EA de memória, porque o dado já está na própria instrução ou em um registrador.

flowchart TD
    A([Instrução]) --> B{Modo de
Endereçamento} B --> C["Imediato
operando = constante"] B --> D["Direto
EA = endereço"] B --> E["Indireto
EA = M[endereço]"] B --> F["Reg. Direto
operando = Rn"] B --> G["Reg. Indireto
EA = Rn"] B --> H["Indexado
EA = endereço + Ri"] B --> I["Base + Deslocamento
EA = RB + desl"]

Depois de calcular o EA, quando houver acesso à memória, o operando será:

$$ \text{Operando} = M[EA] $$

Para o processador, existem dois caminhos para identificar qual desses modos usar: ou o Opcode já carrega essa informação (o código 1010 significa LDA Direto e 1011 significa LDA Indireto) ou a instrução reserva um campo de bits específico apenas para sinalizar o modo de endereçamento ao decodificador.

Modo imediato
#

O operando é a constante codificada diretamente na instrução — não é um endereço, é o próprio valor.

$$\text{Operando} = \text{constante na instrução}$$
  • Vantagem: rapidíssimo — nenhum acesso adicional à MP
  • Desvantagem: limitado pelo tamanho do campo; serve apenas para constantes pequenas e fixas
  • Exemplo: LDA #5 → ACC = 5

Modo direto (absoluto)
#

O campo da instrução contém o endereço de memória onde está o operando.

$$ EA = \text{endereço} \qquad\Rightarrow\qquad \text{Operando} = M[EA] $$
  • Vantagem: simples e direto; fácil de entender e depurar
  • Desvantagem: o endereço é fixo no código-objeto — dificulta a recolocação do programa
  • Exemplo: LDA 100 → ACC = M[100]

Modo indireto
#

O campo contém um endereço que guarda o endereço do operando — um nível extra de indireção. O campo aponta para um ponteiro.

$$ EA = M[\text{endereço}] \qquad\Rightarrow\qquad \text{Operando} = M[EA] $$
  • Vantagem: flexível para ponteiros e estruturas dinâmicas
  • Desvantagem: exige 2 acessos à MP — um para buscar o endereço, outro para buscar o dado
  • Exemplo: LDA @100 → ACC = M[M[100]]
Modo indireto: 2 acessos à memória

O modo indireto é frequentemente confundido com o modo registrador indireto. A diferença está no número de acessos à MP:

  • Indireto (memória): 2 acessos — o endereço do ponteiro está na memória, então é preciso buscar o ponteiro (1º acesso) e depois o dado (2º acesso)
  • Registrador indireto: 1 acesso — o ponteiro já está num registrador (sem acesso para buscá-lo), então há apenas o acesso ao dado

Modo registrador direto
#

O operando está em um registrador do processador — não há EA de memória.

$$EA = R_n$$
  • Vantagem: o mais rápido possível — acesso a registrador é dezenas de ciclos mais rápido que acesso à MP
  • Desvantagem: número limitado de registradores; programas precisam gerenciar quais dados mantêm em registradores
  • Exemplo: ADD R1, R2 → R1 = R1 + R2

Modo registrador indireto
#

O registrador contém o endereço de memória onde está o operando. O registrador funciona como ponteiro.

$$ EA = R_n \qquad\Rightarrow\qquad \text{Operando} = M[EA] $$
  • Vantagem: muito flexível para percorrer arrays usando um registrador como ponteiro incrementável
  • Exemplo: LDA (R1) → ACC = M[R1]

Modo indexado
#

O endereço base está codificado na instrução; o registrador índice \(R_i\) é somado a ele para calcular o endereço efetivo.

$$ EA = \text{endereço} + R_i \qquad\Rightarrow\qquad \text{Operando} = M[EA] $$
  • Vantagem: ideal para percorrer arrays sequencialmente — basta incrementar \(R_i\) a cada iteração
  • Exemplo: LDA 100(R1) → ACC = M[100 + R1]

Imagine a tarefa de somar 100 números em um vetor. No modo direto, você teria que escrever a mesma instrução 100 vezes, mudando apenas o endereço. Com o Modo Indexado, você escreve a instrução uma única vez apontando para a base do vetor e usa um registrador de índice para “caminhar” pelos elementos. É a diferença entre um código de 300 linhas e um loop eficiente de 5 linhas.

Modo base + deslocamento
#

O registrador contém a base; a instrução traz um deslocamento fixo.

$$ EA = R_B + \text{deslocamento} \qquad\Rightarrow\qquad \text{Operando} = M[EA] $$
  • Exemplo conceitual: acessar um campo de estrutura ou uma variável local na pilha
  • Uso típico: código relocável, pilha, ativação de funções, acesso a campos de structs

Esse modo é muito comum em arquiteturas modernas porque separa bem o endereço base dinâmico do deslocamento fixo conhecido em tempo de compilação.

Exemplos modernos de base + deslocamento

Em arquiteturas atuais, esse modo aparece o tempo todo.

  • x86-64: mov eax, [rbp-8]
    Acessa uma variável local a partir do ponteiro de base da pilha.

  • AArch64: ldr x0, [x1, #16]
    Acessa a posição de memória cujo endereço é x1 + 16.

Isso torna o modo base + deslocamento mais fácil de reconhecer em código real do que exemplos antigos baseados em segmentação histórica.

Indexado × base + deslocamento

Nos dois casos, o endereço final é a soma de dois valores. A diferença é:

  • Indexado: base na instrução + índice no registrador
    Ideal para arrays e iteração.

  • Base + deslocamento: base no registrador + deslocamento na instrução
    Ideal para pilha, structs e código relocável.

Tabela comparativa
#

Modo EA Operando Acessos à MP para obter o dado Vantagem principal
Imediato valor na instrução 0 Constantes — rapidez máxima
Direto \(\text{endereço}\) \(M[EA]\) 1 Simples; variáveis globais
Indireto \(M[\text{endereço}]\) \(M[EA]\) 2 Ponteiros em memória
Reg. Direto \(R_n\) 0 Operação rápida em registradores
Reg. Indireto \(R_n\) \(M[EA]\) 1 Ponteiro em registrador
Indexado \(\text{endereço} + R_i\) \(M[EA]\) 1 Percorrer arrays
Base + Deslocamento \(R_B + \text{desl}\) \(M[EA]\) 1 Pilha, structs, relocação
Exemplo — Modos de endereçamento com tabela de memória

Considere a memória:

Endereço Conteúdo
1AC1 1AC6
1AC2 1AC4
1AC3 1AC1
1AC4 1AC3
1AC5 1AC1
1AC6 1AC5

a) Direto, operando = 1AC1

$$ EA = 1AC1 \qquad\Rightarrow\qquad ACC \leftarrow M[1AC1] = \mathbf{1AC6} $$

b) Imediato, operando = 1AC5

O campo operando já contém o valor:

$$ ACC \leftarrow \mathbf{1AC5} $$

c) Indireto, operando = 1AC3

Primeiro calcula-se o endereço efetivo:

$$ EA = M[1AC3] = 1AC1 $$

Depois busca-se o operando:

$$ ACC \leftarrow M[EA] = M[1AC1] = \mathbf{1AC6} $$

d) Se o conteúdo de 1AC1 muda para 0

  • Direto: \(ACC = M[1AC1] = \mathbf{0}\)
  • Imediato: \(ACC = \mathbf{1AC5}\) (inalterado)
  • Indireto: \(EA = M[1AC3] = 1AC1 \Rightarrow ACC = M[1AC1] = \mathbf{0}\)

O modo imediato é o único que não depende do conteúdo atual da memória.

Bônus Python: calculando endereços efetivos
#

Entender o hardware lendo diagramas e fórmulas matemáticas pode parecer muito abstrato em um primeiro momento. Mas, no fim das contas, os modos de endereçamento são apenas lógicas de cálculo de ponteiros. Para um programador, a forma mais fácil de desmistificar o que o silício faz é traduzir essa mecânica para o código. Veja como o hardware calcularia os endereços efetivos usando funções simples em Python:

# Simula os principais modos de endereçamento

def ea_imediato(valor):
    """Modo imediato: o operando é o próprio valor."""
    return valor, 0  # (dado, acessos à MP)


def ea_direto(memoria: dict, endereco: int):
    """Modo direto: EA = M[endereco]."""
    return memoria[endereco], 1


def ea_indireto(memoria: dict, endereco: int):
    """Modo indireto: EA = M[M[endereco]]."""
    ptr = memoria[endereco]          # 1º acesso: busca o ponteiro
    return memoria[ptr], 2           # 2º acesso: busca o dado


def ea_reg_indireto(memoria: dict, registrador: int):
    """Modo registrador indireto: EA = M[Rn]."""
    return memoria[registrador], 1   # registrador já tem o endereço


def ea_indexado(memoria: dict, base: int, indice: int):
    """Modo indexado: EA = M[base + Ri]."""
    return memoria[base + indice], 1


def ea_base_deslocamento(memoria: dict, rb: int, deslocamento: int):
    """Modo base + deslocamento: EA = M[RB + deslocamento]."""
    return memoria[rb + deslocamento], 1


# Tabela de memória do exemplo anterior
mem = {
    0x1AC1: 0x1AC6,
    0x1AC2: 0x1AC4,
    0x1AC3: 0x1AC1,
    0x1AC4: 0x1AC3,
    0x1AC5: 0x1AC1,
    0x1AC6: 0x1AC5,
}

dado, acessos = ea_direto(mem, 0x1AC1)
print(f"Direto  (1AC1): {dado:04X}  ({acessos} acesso(s) à MP)")
# Direto  (1AC1): 1AC6  (1 acesso(s) à MP)

dado, acessos = ea_imediato(0x1AC5)
print(f"Imediato(1AC5): {dado:04X}  ({acessos} acesso(s) à MP)")
# Imediato(1AC5): 1AC5  (0 acesso(s) à MP)

dado, acessos = ea_indireto(mem, 0x1AC3)
print(f"Indireto(1AC3): {dado:04X}  ({acessos} acesso(s) à MP)")
# Indireto(1AC3): 1AC6  (2 acesso(s) à MP)

# Array com modo indexado
vetor = {100: 10, 101: 20, 102: 30, 103: 40}
for i in range(4):
    val, _ = ea_indexado(vetor, 100, i)
    print(f"vetor[{i}] = {val}")
# vetor[0] = 10, vetor[1] = 20, vetor[2] = 30, vetor[3] = 40

Conclusão e próximos artigos
#

A ISA define o vocabulário que o processador entende — e os formatos de instrução e os modos de endereçamento são a gramática desse vocabulário. Com três operandos por instrução e sete modos de endereçamento, um processador consegue expressar desde simples adições até acesso dinâmico a estruturas de dados complexas. A escolha entre mais operandos (CISC) e menos operandos (RISC) reflete um trade-off entre expressividade e regularidade que o hardware moderno resolve de formas sofisticadas — tema do próximo artigo.

No próximo artigo, vamos acompanhar o ciclo de vida de um programa: desde o código-fonte em uma linguagem de alto nível, passando pela compilação, ligação e carregamento, até se tornar as instruções de máquina que o processador executa. Veremos como o ISA é a ponte entre o código que escrevemos e o silício que o executa, e como as decisões de projeto da ISA impactam a eficiência e a flexibilidade dos programas.

Até lá!

Por Dentro do Computador - Este artigo faz parte de uma série de artigos.
Parte 8: Esse Artigo

Relacionados