C → Assembly: Otimizações, Volatile e o que o compilador pode fazer
TL;DR
- Você vai entender como o compilador transforma C em assembly e por que o mesmo C pode parecer totalmente diferente entre
-O0e-O2. - Vai construir um modelo mental prático para:
- eliminação de código morto,
- eliminação de subexpressões comuns,
- inlining,
- alocação de registradores,
- e como
volatilelimita essas otimizações.
- Vai rodar experimentos e validar resultados com
objdumpe GDB.
1. O pipeline do compilador (por que existem várias “traduções”)
flowchart TD
A["C source (.c)"] --> B["Frontend to IR (Intermediate Representation)"]
B --> C["Optimizer (depends on -O level)"]
C --> D["Backend to assembly (.s)"]
D --> E["Assembler to object (.o)"]
E --> F["Linker to ELF (.elf)"]
.
Duas consequências:
- “O compilador” não é um passo só; são várias etapas.
-Omuda a etapa de otimização, que muda tudo adiante.
2. Laboratório prático: um programa, vários níveis de otimização
Crie:
| |
Compile duas variantes:
| |
Rode os dois:
| |
Faça o disassembly de ambos:
| |
O que observar
- Em
-O0:- mais uso de pilha,
- mais loads/stores,
- variáveis “vivem” como você espera.
- Em
-O2:aebprovavelmente são calculados uma vez,- branches podem ser simplificados,
- o código pode ser reorganizado.
-O0 e depois aprenda a reconhecer as formas otimizadas.3. Por que variáveis somem em builds otimizadas
Alocação de registradores
Em -O2, o compilador tenta manter valores em registradores e pode nunca materializá-los na memória.
Encolhimento de vida útil
Se o valor de uma variável é usado só por um instante, ela pode nunca existir como um local nomeado.
Inlining
Funções pequenas frequentemente são substituídas pelo seu corpo.
<optimized out> para algumas variáveis.4. volatile significa “deve realizar o acesso”
Um objeto volatile diz ao compilador:
- toda leitura é um load real,
- toda escrita é um store real,
- o compilador não pode remover nem combinar esses acessos,
- o compilador não pode assumir que o valor permanece o mesmo entre acessos.
Isso é crítico para:
- registradores MMIO (Memory-Mapped I/O),
- estado compartilhado com ISR (Interrupt Service Routine),
- memória modificada externamente.
Na prática: volatile vs não-volatile
Crie:
| |
Compile otimizado e faça o disassembly:
| |
O que você deve observar:
- O double-store não-volatile pode virar um único store.
- O double-store volatile deve permanecer com dois stores.
volatile não é um primitivo de sincronização. Ele não cria atomicidade, nem garante ordenação entre núcleos, nem cria barreiras de memória. Para concorrência, use atômicos C11 ou fences explícitos.5. Mapeando C de volta para assembly (um método prático)
Quando você vê assembly, pergunte:
- Onde estão as entradas? (normalmente
a0..a7) - Onde o valor de retorno vai? (normalmente
a0) - Quais registradores precisam sobreviver a chamadas? (callee-saved
s*) - Quais stores na memória são observáveis? (volatile, globais, chamadas de função)
Use o assembly gerado pelo compilador como “ponte”
Gere saída .s:
| |
Compare build/opt_O0.s e build/opt_O2.s.
.s costuma ser mais fácil de ler do que o objdump, porque preserva rótulos e estrutura.Exercícios
- Modifique
opt.cpara quesinknão seja volatile. Preveja o que muda em-O2. - Adicione um
uart_putc(ou qualquer chamada externa) e observe como ele “ancora” valores (chamadas são barreiras de otimização). - Escreva duas funções: uma pequena, outra grande. Observe quando a pequena é inlined.
Como testar suas respostas
- Use
objdump -d -M numeric,no-aliasespara comparar sequências de instruções. - Use
readelf -spara ver se funções ainda existem como símbolos (o inlining pode remover o símbolo).
Resumo
Você aprendeu o que as otimizações fazem, por que depurar código otimizado pode ser confuso, e o que volatile realmente garante.
A seguir: fluxo de controle e acesso a dados, você vai ver como if/loops/switch viram branches e jump tables, e como loads/stores codificam endereçamento.