Ir para o conteúdo principal

Execução de Programas: De Código-Fonte a Código de Máquina

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 9: Esse Artigo

No artigo anterior — Representação de Instruções — vimos como instruções são codificadas em bits e como os modos de endereçamento determinam onde o processador encontra cada operando. Mas você escreve x = y + z em Python — não 0001 10110100. Alguém precisa fazer a tradução. Esse alguém é a cadeia de ferramentas que transforma código-fonte em código de máquina: o montador, o compilador, o ligador e o carregador.

Entender essa cadeia é entender por que C e C++ costumam gerar executáveis nativos, por que Java distribui bytecode para a JVM e por que, ao executar python script.py, o interpretador CPython primeiro compila o código para bytecode e depois o executa em sua máquina virtual. Em todos os casos, o objetivo final é o mesmo: chegar a instruções que o hardware consiga executar, direta ou indiretamente.

Da linguagem humana ao silício: uma breve história
#

As primeiras linguagens de programação foram criadas em resposta a uma demanda simples: tornar o ato de programar menos propenso a erros e mais próximo do pensamento humano. A tabela a seguir mostra a linha do tempo de algumas linguagens que moldaram o desenvolvimento de software:

Ano Linguagem Características
1957 FORTRAN Científica/engenharia — primeiro compilador de alto nível
1958 COBOL Negócios/comercial — legível como prosa em inglês
1958 LISP Processamento de listas; base para IA
1960 ALGOL 60 Algoritmos estruturados; influenciou C e Pascal
1964 BASIC Simples, educacional
1970 Pascal Estruturada, educacional
1972 C Sistemas operacionais; base do Unix
1980 Ada Militar, tempo real
1983 C++ Orientada a objetos; extensão do C
1987 Perl Processamento de texto, scripts
1991 Python Geral, legível, multiparadigma
1995 JavaScript Web; dinâmica, front-end e back-end (Node.js); onipresente nos navegadores
1995 PHP Server-side web; base do WordPress e de grande parte da web
1995 Ruby OO pura, elegante; popularizada pelo framework Rails
1996 Java Portável, orientada a objetos; “Write once, run anywhere”
2000 C# Microsoft/.NET; OO, similar ao Java; ecossistema Windows/Azure
2003 Scala JVM; combina OO e programação funcional
2009 Go Google; concorrência nativa, compilada, servidores modernos
2012 TypeScript Microsoft; superset tipado do JavaScript; projetos web de grande escala
2014 Swift Apple; substitui Objective-C para iOS/macOS
2015 Rust Mozilla; segurança de memória sem coletor de lixo; sistemas de alto desempenho
2016 Kotlin JetBrains; moderna, concisa; linguagem oficial do Android

Muitas outras poderiam ser listadas, mas essas são algumas das mais influentes e representativas. Cada uma delas foi projetada com objetivos específicos em mente — seja legibilidade, desempenho, portabilidade, segurança ou facilidade de uso — e cada uma contribuiu para a evolução do ecossistema de desenvolvimento de software.

A hierarquia de abstração
#

As linguagens formam uma pirâmide de abstração: quanto mais alto o nível, mais próximo da linguagem humana — e mais longe do hardware. Cada nível precisa ser traduzido para o nível abaixo antes que o processador possa executar o código.

Nível Tipo Exemplo Como é traduzida
Mais baixo Linguagem de máquina (binário) 0001 1011 0100 Executável diretamente pelo processador
Baixo Assembly (mnemônicos) LDA 180 Montador (Assembler) → linguagem de máquina
Alto Linguagem de alto nível x = y + z Compilador ou Interpretador
A única linguagem que o processador entende

O processador executa apenas linguagem de máquina — sequências de bits. Toda linguagem de mais alto nível precisa ser traduzida, direta ou indiretamente, para código binário antes ou durante a execução. Toda a discussão desta seção descreve como essa tradução acontece.

Mas como essa cadeia de tradução ocorre na prática? Vamos descer ao nível mais próximo do hardware e começar pelo processo mais direto: a montagem do código Assembly.

Processo de montagem (Assembly)
#

O montador (assembler) converte código assembly em linguagem de máquina. A tradução é praticamente 1:1: cada mnemônico corresponde a uma instrução de máquina. O montador opera em duas passagens:

Passagem O que faz
1ª passagem Varre o código-fonte e constrói a tabela de símbolos — mapeia rótulos (labels) para endereços
2ª passagem Gera o código de máquina, substituindo cada referência pelo endereço da tabela
Por que duas passagens?

Quando o montador encontra um rótulo sendo usado antes de ser definido — uma referência futura (forward reference) — ele ainda não conhece o endereço. A 1ª passagem resolve isso: percorre todo o código, atribui endereços a todos os rótulos e os registra na tabela de símbolos. Na 2ª passagem, todas as referências já podem ser resolvidas sem ambiguidade.

Exemplo de montagem — programa em assembly para calcular:

$$ X = Y + Z - T $$

Usando o processador hipotético da disciplina, com:

  • \(Y\) no endereço 1F, contendo 051
  • \(Z\) no endereço 20, contendo 03E
  • \(T\) no endereço 21, contendo 003
  • \(X\) no endereço 22

O programa começa no endereço 18:

Endereço Assembly Código gerado
18 LDA 1F 11F
19 ADD 20 320
1A SUB 21 421
1B STR 22 222
1C HLT 000
1F Dado: \(Y\) 051
20 Dado: \(Z\) 03E
21 Dado: \(T\) 003
22 Resultado \(X\) escrito em tempo de execução

A sequência faz exatamente o esperado:

  1. carrega \(Y\) no acumulador;
  2. soma \(Z\);
  3. subtrai \(T\);
  4. armazena o resultado em \(X\);
  5. encerra a execução.

O ciclo de execução para a instrução LDA 1F — que já conhecemos do artigo sobre o processador hipotético — opera exatamente assim:

Passo Operação Descrição
1 \(REM \leftarrow CI\) Endereço da instrução vai ao REM
2 \(RDM \leftarrow M[REM]\) Busca a instrução LDA 1F da memória
3 \(RI \leftarrow RDM;\ CI \leftarrow CI + 1\) Instrução carregada no RI; CI avança
4 Decodifica: LDA UC identifica a operação e extrai o endereço 1F
5 \(REM \leftarrow 1F\) Endereço do operando vai ao REM
6 \(RDM \leftarrow M[1F]\) Busca o valor do operando da memória
7 \(ACC \leftarrow RDM\) Acumulador recebe o valor

Compilação: um pipeline clássico de transformação
#

Enquanto o montador traduz instruções muito próximas do hardware, o compilador resolve um problema mais amplo: transformar código de alto nível em uma forma executável para a arquitetura-alvo. Um pipeline clássico de compilação passa por etapas como análise léxica, sintática e semântica, geração de representação intermediária, otimização e geração de código objeto.

Esse fluxo é extremamente útil como modelo didático, embora compiladores reais possam reorganizar, fundir ou até omitir algumas dessas etapas internamente.

%%{init: {'flowchart': {'useMaxWidth': false}}}%%
flowchart LR
    A([Código-Fonte]) --> B["1. Análise Léxica
(Tokenização)"] B --> C["2. Análise Sintática
(Parser / AST)"] C --> D["3. Análise Semântica
(Tipos e Escopo)"] D --> E["4. Código
Intermediário (TAC)"] E --> F["5. Otimização"] F --> G["6. Código Objeto"] G --> H([Linker]) H --> I([Executável])

Fase 1 — Análise léxica (tokenização)
#

O analisador léxico lê o código-fonte caractere a caractere e agrupa-os em tokens — as unidades mínimas de significado. Elimina comentários e espaços em branco.

Entrada:  x = y + z * 2

Tokens:   [ID: x] [OP: =] [ID: y] [OP: +] [ID: z] [OP: *] [NUM: 2]

Cada token tem um tipo (identificador, operador, literal numérico, palavra-chave) e um valor. Erros de tokenização são os primeiros a ser detectados.

Fase 2 — Análise sintática (parser)
#

O parser verifica se a sequência de tokens obedece à gramática da linguagem. Em caso positivo, constrói a Árvore de Sintaxe Abstrata (AST), que representa a estrutura hierárquica do código.

AST para x = y + z * 2:

        assign
       /      \
      x         +
               / \
              y   *
                 / \
                z   2

A AST captura a precedência de operadores: * está mais fundo na árvore que +, o que significa que ele será executado primeiro.

Fase 3 — Análise semântica
#

Verifica o significado do código além da sua estrutura:

  • Todas as variáveis foram declaradas antes do uso?
  • Os tipos são compatíveis nas operações e atribuições?
  • O número de argumentos em chamadas de função está correto?
int x;
x = "hello";  // ERRO semântico: sintaxe correta, mas tipo incompatível

Erros semânticos passam despercebidos pelo parser (a sintaxe x = algo é válida), mas o analisador semântico os detecta ao verificar a compatibilidade de tipos.

Fase 4 — Geração de código intermediário
#

Produz uma representação independente de máquina, facilitando a portabilidade e a otimização posterior. O formato mais comum é o Three-Address Code (TAC) — cada instrução tem no máximo três operandos.

TAC para x = y + z * 2:

T1 = z * 2
T2 = y + T1
x  = T2

O TAC se assemelha à linguagem do nosso processador hipotético: instruções simples, registradores temporários, sem operações aninhadas.

Fase 5 — Otimização
#

Melhora o código intermediário sem alterar o resultado observável. Algumas técnicas clássicas:

Técnica Efeito
Eliminação de código morto Remove código que nunca será executado
Propagação de constantes Substitui variáveis por valores conhecidos em tempo de compilação
Eliminação de subexpressões comuns Evita recalcular expressões idênticas
Desdobramento de laços (loop unrolling) Replica o corpo do laço para reduzir overhead de controle

Na prática, compiladores reais (como o famoso GCC, muito utilizado para linguagens como C e C++) permitem que o programador escolha o “nível de agressividade” dessa otimização. Por exemplo, ao compilar um programa, o desenvolvedor pode passar parâmetros que vão desde “nível zero” (sem nenhuma otimização, o que gera um código fácil de debugar e rastrear erros) até “nível máximo” (que demora mais para ser compilado e embaralha o código, mas resulta em um programa final extremamente rápido para o usuário).

Fase 6 — Geração de código objeto
#

Traduz o código intermediário otimizado para instruções de máquina do processador-alvo. Produz um arquivo objeto (.o no Linux/macOS, .obj no Windows).

Neste estágio, temos um arquivo binário que a CPU entende, mas ele ainda é uma peça solta. O arquivo contém referências externas não resolvidas — por exemplo, se o seu programa usa uma função de outro arquivo seu, ou funções da biblioteca padrão do sistema (como um print para escrever na tela), o código objeto aponta para um “vazio”. Ele precisa de um integrador. Entra em cena o Linker.

Ligação (Linking)
#

O linker combina múltiplos arquivos objeto e bibliotecas em um único executável, resolvendo todas as referências externas.

Tipo Descrição Vantagem Desvantagem
Estática Copia o código das bibliotecas dentro do executável Executável autossuficiente Arquivo maior; atualizar a biblioteca exige recompilar
Dinâmica Referencia .dll (Windows) ou .so (Linux) — carregada em tempo de execução Arquivo menor; bibliotecas compartilhadas entre programas Dependência em tempo de execução
Ligação dinâmica na prática

Quando o sistema atualiza uma biblioteca compartilhada (por exemplo, libc.so.6 no Linux), todos os programas que a usam via ligação dinâmica se beneficiam imediatamente — sem precisar ser recompilados. Com ligação estática, cada programa teria que ser recompilado e redistribuído individualmente. Por outro lado, a ligação dinâmica cria uma dependência em tempo de execução: se a biblioteca não estiver instalada ou for incompatível, o programa falha ao iniciar — o famoso “DLL Hell” no Windows.

Ligação e portabilidade

A ligação dinâmica reduz o tamanho dos executáveis e facilita atualizações de bibliotecas compartilhadas, mas também introduz dependências externas em tempo de execução. Já a ligação estática tende a gerar executáveis mais autônomos, embora maiores e menos flexíveis para manutenção.

Carregamento (Loading)
#

O loader transfere o executável do armazenamento para a Memória Principal e prepara o ambiente para que ele possa começar a executar. Em sistemas reais, isso pode envolver não só copiar o programa para a memória, mas também ajustar endereços, mapear segmentos e preparar bibliotecas compartilhadas.

Tipo Descrição
Carregamento absoluto Endereços fixos — o programa sempre é carregado no mesmo endereço de memória (inflexível)
Carregamento por recolocação Endereços ajustados em tempo de carregamento conforme a posição na MP — usa o modo base + deslocamento que vimos no artigo anterior
Carregamento dinâmico Partes do programa ou bibliotecas são carregadas sob demanda em tempo de execução

Com o programa devidamente montado, compilado, ligado e carregado na Memória Principal, o ciclo preparatório se encerra. A partir deste exato momento, o silício assume o controle e a CPU inicia o laço perpétuo de busca, decodificação e execução que dissecamos nos artigos anteriores da série.

Compilação × interpretação
#

A distinção entre linguagens compiladas e interpretadas é útil, mas precisa ser vista como um modelo didático, não como uma fronteira absoluta.

Característica Compilação tradicional Interpretação tradicional
Tradução principal Antes da execução Durante a execução
Artefato gerado Código objeto, executável ou bytecode Normalmente não há executável nativo prévio do programa do usuário
Desempenho Tende a ser maior quando há código nativo Tende a ter overhead maior no runtime
Portabilidade Depende do alvo compilado Depende do interpretador ou máquina virtual
Exemplos típicos C, C++, Rust, Go CPython, Ruby, shells

Na prática, sistemas modernos frequentemente misturam estratégias:

  • compilação para bytecode;
  • interpretação por máquina virtual;
  • compilação JIT em tempo de execução;
  • cache de artefatos intermediários.

Por isso, é melhor pensar em um espectro de tradução e execução, e não em uma dicotomia rígida.

Exemplo — Python, Java e bytecode

Python (CPython) compila módulos para bytecode, que pode ser cacheado em arquivos .pyc dentro de __pycache__. Depois disso, esse bytecode é executado pela máquina virtual do CPython.

Java, no fluxo tradicional, usa o javac para compilar arquivos .java em arquivos .class, que contêm bytecode da JVM. Esse bytecode é então executado pela Java Virtual Machine.

A diferença principal não é “Python interpreta fonte e Java não”, mas o modo como cada ecossistema organiza as etapas:

  • em CPython, a compilação para bytecode e a execução costumam acontecer de forma transparente ao usuário;
  • em Java, o fluxo tradicional separa claramente a geração do .class e a execução na JVM.

Além disso, versões modernas do Java também oferecem source-file mode para programas simples de arquivo único, o que mostra que essas fronteiras não são totalmente rígidas.

O que acontece ao executar python script.py

O executável python já foi previamente compilado, ligado e instalado no seu sistema. Quando você roda python script.py, o que acontece com o seu programa é outra etapa: o CPython lê o código-fonte, compila para bytecode e executa esse bytecode na sua máquina virtual, podendo ainda reutilizar caches em __pycache__ quando apropriado.

Tipagem: quando os tipos são verificados
#

A discussão sobre compilação e interpretação se conecta diretamente com a tipagem — como e quando uma linguagem verifica a compatibilidade de tipos entre variáveis e operações.

Estática × dinâmica
#

Tipo Quando verifica tipos Exemplos
Estática Em tempo de compilação C, Java, Rust, Go
Dinâmica Em tempo de execução Python, JavaScript, Ruby

Forte × fraca
#

Tipo Comportamento Exemplo em 3 + "5"
Forte Proíbe operações entre tipos incompatíveis sem conversão explícita Erro (TypeError no Python)
Fraca Permite coerção implícita de tipos "35" (JavaScript)

Por linguagem
#

Linguagem Estática/Dinâmica Observação sobre força de tipos
C Estática Permite várias coerções implícitas e conversões potencialmente perigosas
Java Estática Mais restritiva em operações entre tipos incompatíveis
Python Dinâmica Rejeita operações incompatíveis sem conversão explícita (3 + "5"TypeError)
JavaScript Dinâmica Faz coerções implícitas com frequência
Tipagem e estratégia de execução

Há correlação frequente entre tipagem estática e compilação, e entre tipagem dinâmica e execução por interpretadores ou máquinas virtuais. Mas isso não é uma regra absoluta.

  • Java é estaticamente tipada, mas executa sobre a JVM.
  • Python é dinamicamente tipado, mas ainda assim compila para bytecode.

Em outras palavras: tipagem e estratégia de tradução/execução são dimensões relacionadas, mas independentes.

Conclusão e próximos artigos
#

A jornada de x = y + z até os bits que o processador executa envolve várias transformações: análise do código-fonte, geração de representações internas, produção de código objeto, ligação, carregamento e execução. Dependendo da linguagem e do ambiente, algumas dessas etapas são explícitas ao programador; outras acontecem de forma invisível dentro do runtime.

No próximo artigo, vamos explorar como o processo acontece na prática usando Python, observando cada fase diretamente no interpretador. Veremos como o módulo tokenize lida com a análise léxica, como o módulo ast constrói a árvore de sintaxe abstrata, como o módulo compile gera bytecode e como o módulo dis nos permite inspecionar o bytecode gerado. Será uma oportunidade de ver cada etapa em ação, usando uma linguagem de alto nível que muitos de vocês já conhecem.

Até lá!

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

Relacionados