Inline assembly: quando o C não é suficiente

1. Introdução ao Inline Assembly

Inline assembly é um recurso da linguagem C que permite inserir instruções em assembly diretamente no código-fonte C, sem a necessidade de criar arquivos separados com extensão .s. Essa funcionalidade existe porque, embora C ofereça alto nível de abstração e portabilidade, há situações em que o programador precisa de controle absoluto sobre o hardware — seja para otimizações críticas de desempenho, acesso a instruções especiais da CPU (como RDTSC, CPUID, ou operações atômicas) ou manipulação direta de registradores.

Diferentemente do assembly puro, onde todo o código é escrito em arquivos separados e montado com um assembler externo, o inline assembly é embutido no meio do código C, permitindo que variáveis e expressões C sejam usadas como operandos. O compilador é responsável por alocar registradores e gerenciar a interface entre o código C e o assembly, o que reduz parte da complexidade, mas exige cuidado redobrado com as restrições e efeitos colaterais.

2. Sintaxe Básica no GCC/Clang (asm)

No GCC e Clang, a sintaxe básica do inline assembly segue o formato:

asm [volatile] ( "instruções assembly"
    : operandos_de_saída
    : operandos_de_entrada
    : clobbers
);

Os componentes são:
- Assembly string: sequência de instruções assembly, geralmente entre aspas duplas.
- Operandos de saída: variáveis C que receberão valores do assembly.
- Operandos de entrada: variáveis C cujos valores serão usados no assembly.
- Clobbers: registradores ou áreas de memória que o assembly modifica sem declarar como operando.

Exemplo simples: mover um valor entre registradores.

#include <stdio.h>

int main() {
    int valor = 42;
    int resultado;

    asm ("movl %1, %0"
        : "=r" (resultado)   // saída: registrador r
        : "r" (valor)        // entrada: registrador r
    );

    printf("Resultado: %d\n", resultado); // 42
    return 0;
}

3. Operandos e Restrições (Constraints)

Os operandos são vinculados a variáveis C por meio de restrições (constraints). As restrições comuns incluem:

  • r: qualquer registrador de propósito geral.
  • m: endereço de memória.
  • i: constante imediata (conhecida em tempo de compilação).
  • g: qualquer registrador, memória ou imediato.

Operandos de saída usam prefixo =, indicando que são apenas para escrita. Para operandos de leitura e escrita, usa-se +r. O earlyclobber (=&r) informa ao compilador que o operando é escrito antes que todos os operandos de entrada sejam lidos, evitando que o compilador reuse o mesmo registrador para entrada e saída.

int a = 10, b = 20, soma;

asm ("addl %2, %0"
    : "=r" (soma)   // saída
    : "0" (a), "r" (b) // entrada: "0" significa mesmo registrador que o operando 0
);

4. Clobbers e Volatile

Clobbers são essenciais para evitar que o compilador assuma que registradores ou flags da CPU permanecem inalterados após o bloco assembly. Dois clobbers comuns:

  • "memory": informa que o assembly pode ler ou escrever em qualquer posição de memória.
  • "cc": indica que o assembly modifica as flags de condição da CPU (registrador EFLAGS no x86).

O modificador volatile impede que o compilador otimize ou reordene o bloco assembly. Use-o quando o assembly tiver efeitos colaterais que devem ocorrer exatamente onde escrito, como em operações de hardware ou loops de espera ativa.

asm volatile ("": : : "memory"); // barreira de memória

5. Exemplos Práticos: Otimização e Acesso a Hardware

Operação atômica: atomic_xchg com xchg

int atomic_exchange(int *ptr, int novo) {
    int antigo;
    asm volatile (
        "xchgl %0, %1"
        : "=r" (antigo), "+m" (*ptr)
        : "0" (novo)
        : "memory"
    );
    return antigo;
}

Leitura do contador de ciclos da CPU (RDTSC)

unsigned long long read_tsc() {
    unsigned int lo, hi;
    asm volatile ("rdtsc" : "=a" (lo), "=d" (hi));
    return ((unsigned long long)hi << 32) | lo;
}

Manipulação de bits: contar zeros à direita (bsf)

int count_trailing_zeros(unsigned int x) {
    int resultado;
    asm ("bsfl %1, %0" : "=r" (resultado) : "rm" (x));
    return resultado;
}

6. Armadilhas Comuns e Undefined Behavior

Inline assembly é um dos recursos mais propensos a erros em C. As principais armadilhas incluem:

  • Violação de restrições: usar uma restrição r (registrador) com uma variável que o compilador não pode colocar em registrador, como um campo de bitfield.
  • Clobbers ausentes: esquecer de declarar que o assembly modifica um registrador pode fazer com que o compilador reutilize aquele registrador para outra variável, corrompendo dados.
  • Efeitos colaterais não declarados: modificar memória sem usar clobber "memory" pode levar a otimizações incorretas.
  • Não portabilidade: código inline assembly para x86 não funciona em ARM. Sempre documente a arquitetura alvo.
// ERRADO: clobber ausente
asm ("movl $0, %%eax" : : : ); // modifica eax sem declarar

// CORRETO:
asm ("movl $0, %%eax" : : : "%eax");

7. Alternativas ao Inline Assembly

Antes de recorrer ao inline assembly, considere alternativas mais seguras e portáveis:

  • Intrínsecos do compilador: funções embutidas que mapeiam diretamente para instruções assembly, como __builtin_popcount (contagem de bits), __sync_fetch_and_add (operações atômicas) e _mm_pause (pausa em spinlocks).
  • Bibliotecas de abstração: libatomic para operações atômicas, libc com funções como memcpy otimizadas, ou bibliotecas de hardware como x86intrin.h.
  • Assembly puro: quando o inline assembly fica muito complexo, vale mais a pena escrever uma função em um arquivo .s separado, que é mais fácil de depurar e manter.
// Equivalente ao RDTSC com intrínseco (MSVC)
#include <intrin.h>
unsigned long long read_tsc_intrinsic() {
    return __rdtsc();
}

8. Boas Práticas e Portabilidade

Para usar inline assembly de forma segura e eficiente:

  1. Isole em macros ou funções inline: facilita a manutenção e permite substituir por alternativas portáveis.
  2. Documente restrições de arquitetura e versão do compilador: indique claramente se o código funciona apenas em x86, x86_64, ARM, etc.
  3. Teste com diferentes níveis de otimização: -O0, -O2, -Os — o compilador pode rearranjar o código de maneiras inesperadas.
  4. Sempre declare todos os clobbers: especialmente "memory" e "cc" quando aplicável.
  5. Prefira intrínsecos sempre que possível: eles são mantidos pelos desenvolvedores do compilador e são mais portáveis.
#ifdef __x86_64__
#define HAVE_RDTSC
static inline unsigned long long rdtsc() {
    unsigned int lo, hi;
    asm volatile ("rdtsc" : "=a"(lo), "=d"(hi));
    return ((unsigned long long)hi << 32) | lo;
}
#else
// fallback portável (menos preciso)
static inline unsigned long long rdtsc() { return 0; }
#endif

Referências