C → Assembly: Otimizações, Volatile e o que o compilador pode fazer

TL;DR


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:


2. Laboratório prático: um programa, vários níveis de otimização

Crie:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// src/opt.c
#include "types.h"
#include "uart.h"

volatile u32 sink;

u32 f(u32 x) {
  u32 a = x * 3u;
  u32 b = x * 3u;      // mesma expressão de a
  u32 c = a + b;

  if ((c & 1u) == 0u) {
    // parece que importa...
    c += 10u;
  }

  // armazena o resultado em algum lugar observável
  sink = c;
  return c;
}

int main(void) {
  u32 r = f(7u);
  uart_puts("f(7)=");
  uart_puthex32(r);
  uart_putc('\n');
  return 0;
}

Compile duas variantes:

1
2
3
4
5
riscv64-unknown-elf-gcc -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -O0 \
  -T src/link.ld src/start.s src/uart.c src/opt.c -o build/opt_O0.elf

riscv64-unknown-elf-gcc -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -O2 \
  -T src/link.ld src/start.s src/uart.c src/opt.c -o build/opt_O2.elf

Rode os dois:

1
2
3
4
5
// Execute, verifique a saída e use CTRL+a x para sair do qemu;
qemu-system-riscv32 -M virt -nographic -bios none -kernel build/opt_O0.elf

// Execute, verifique a saída e use CTRL+a x para sair do qemu;
qemu-system-riscv32 -M virt -nographic -bios none -kernel build/opt_O2.elf

Faça o disassembly de ambos:

1
2
3
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/opt_O0.elf | less

riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/opt_O2.elf | less

O que observar


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.


4. volatile significa “deve realizar o acesso”

Um objeto volatile diz ao compilador:

Isso é crítico para:

Na prática: volatile vs não-volatile

Crie:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// src/volatile_demo.c
#include "types.h"
#include "uart.h"

u32 nv_reg;
volatile u32 v_reg;

u32 demo(u32 x) {
  nv_reg = x;
  nv_reg = x;     // pode ser fundido

  v_reg = x;
  v_reg = x;      // não pode ser fundido

  return nv_reg + v_reg;
}

int main(void) {
  u32 r = demo(0x1234u);
  uart_puts("demo=");
  uart_puthex32(r);
  uart_putc('\n');
  return 0;
}

Compile otimizado e faça o disassembly:

1
2
3
riscv64-unknown-elf-gcc -g -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -O2 \
  -T src/link.ld src/start.s src/uart.c src/volatile_demo.c -o build/vol_O2.elf
riscv64-unknown-elf-objdump -d -M numeric,no-aliases build/vol_O2.elf | less

O que você deve observar:


5. Mapeando C de volta para assembly (um método prático)

Quando você vê assembly, pergunte:

  1. Onde estão as entradas? (normalmente a0..a7)
  2. Onde o valor de retorno vai? (normalmente a0)
  3. Quais registradores precisam sobreviver a chamadas? (callee-saved s*)
  4. 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:

1
2
riscv64-unknown-elf-gcc -S -O0 -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -o build/opt_O0.s src/opt.c
riscv64-unknown-elf-gcc -S -O2 -ffreestanding -nostdlib -march=rv32im -mabi=ilp32 -o build/opt_O2.s src/opt.c

Compare build/opt_O0.s e build/opt_O2.s.


Exercícios

  1. Modifique opt.c para que sink não seja volatile. Preveja o que muda em -O2.
  2. Adicione um uart_putc (ou qualquer chamada externa) e observe como ele “ancora” valores (chamadas são barreiras de otimização).
  3. Escreva duas funções: uma pequena, outra grande. Observe quando a pequena é inlined.

Como testar suas respostas


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.