Undefined Behavior: O Território Perigoso do C

[415] Undefined Behavior: O Território Perigoso do C

Um índice a mais num vetor de cinco pode imprimir tudo certo, travar, ou virar laço infinito por ter sobrescrito o contador — e as três coisas são respostas igualmente válidas. Esta aula junta num mapa só os avisos espalhados pelo curso, explica o pacto que os criou e mostra o compilador otimizando em cima deles.
Linguagem C

12 min de leitura

Ao longo de todo este curso, uma expressão apareceu repetidamente como um aviso: comportamento indefinido. Surgiu ao falar de vetores fora dos limites, ponteiros nulos, uso de memória liberada, estouro de inteiros. Chegou a hora de encará-la de frente, sistematicamente, porque compreender o comportamento indefinido é compreender a alma do C — sua velocidade extrema e seu perigo, que são as duas faces da mesma moeda. Esta aula não ensina uma técnica nova; ela consolida uma consciência de risco que fará de você um programador C mais seguro e mais sábio.

O que é, afinal, comportamento indefinido

O comportamento indefinido (undefined behavior, ou UB) é um conjunto de situações que o padrão da linguagem C deliberadamente deixa sem definição. Quando seu programa faz algo que é UB, o padrão não garante nada sobre o que acontecerá. O programa pode travar, pode produzir um resultado errado, pode parecer funcionar perfeitamente, pode corromper dados silenciosamente, ou pode se comportar de forma diferente a cada execução ou a cada compilador. Não há garantia alguma — literalmente qualquer coisa é permitida do ponto de vista do padrão.

Isso é radicalmente diferente de um erro comum. Um erro de lógica produz um resultado errado, mas previsível e reproduzível. O UB não oferece nem essa cortesia: ele é imprevisível por definição. É essa imprevisibilidade que o torna a fonte dos bugs mais frustrantes do C — aqueles que "às vezes funcionam", que somem quando você adiciona um printf, que aparecem só num compilador ou só em produção.

Por que o C tem comportamento indefinido

Pode parecer um defeito, mas o UB é uma escolha de design deliberada, e entender o porquê ilumina toda a filosofia da linguagem. O C foi projetado para ser rápido e próximo da máquina. Verificar cada acesso a vetor, cada ponteiro, cada operação aritmética teria um custo de desempenho — e o C recusou esse custo. Ao declarar essas verificações como "responsabilidade do programador" e o que acontece na sua ausência como "indefinido", a linguagem libera o compilador para gerar código máximamente eficiente, assumindo que você nunca cometerá esses erros.

É o mesmo pacto que discutimos lá na primeira aula: o C confia em você. O UB é a contrapartida dessa confiança. O compilador assume que seu programa não tem UB e otimiza agressivamente com base nessa suposição. Quando a suposição é violada, o resultado é imprevisível — não porque o compilador seja malicioso, mas porque ele construiu tudo sobre uma premissa que você quebrou. Linguagens como Rust fizeram a escolha oposta (verificar tudo, ao custo de mais rigor); o C escolheu a velocidade e a responsabilidade. Nenhuma escolha é "certa" — são filosofias diferentes.

Um catálogo dos UBs que já encontramos

Vale reunir num só lugar os comportamentos indefinidos que espalhamos pelo curso, para você reconhecê-los. Acesso fora dos limites de um vetor (aula Vetores e o Primeiro Contato com a Memória Contígua) — ler ou escrever v[10] num vetor de 5. Desreferenciar ponteiro nulo ou inválido (aula Ponteiros: O que É um Endereço de Memória) — usar *p quando p é NULL ou aponta para lixo. Use-after-free e double-free (aula Alocação Dinâmica: malloc, calloc, realloc e free) — usar ou liberar memória já liberada. Ler memória não inicializada — usar uma variável antes de atribuir-lhe valor. Estouro de inteiro com sinal — quando uma operação com int excede seu valor máximo (curiosamente, o estouro de unsigned é definido, mas o de signed é UB). Modificar uma string literal (aula Strings: Vetores de Caracteres e a Biblioteca string.h) — alterar os caracteres de um char *s = "...". E o descasamento de especificadores em printf/scanf (aula Entrada e Saída Formatada: printf e scanf a Fundo). Cada um desses foi um aviso pontual; juntos, formam o mapa do território perigoso.

Um exemplo que revela a traição do UB

Para sentir por que o UB é tão insidioso, veja como ele pode enganar completamente sua intuição:

#include <stdio.h>

int main(void) {
    int v[5] = {0};

    for (int i = 0; i <= 5; i++) { // BUG: <= vai até 5, mas v só tem índices 0-4
        v[i] = i * 10;             // v[5] é escrita fora dos limites: UB
        printf("v[%d] = %d\n", i, v[i]);
    }
    return 0;
}

Este programa tem um clássico erro de "um a mais" (<= em vez de <), escrevendo em v[5], que não existe. O que acontece? Depende. Pode rodar e imprimir tudo como se estivesse certo. Pode travar. Pode entrar num laço infinito bizarro (se v[5] calhar de sobrescrever a variável i na pilha, resetando o contador). Pode funcionar na sua máquina e falhar na do colega. Nenhum desses resultados é "o correto" — todos são manifestações válidas de UB. É exatamente essa imprevisibilidade que torna o UB tão perigoso: o programa pode parecer saudável e esconder uma bomba-relógio.

O agravante moderno: o compilador otimizador

Há uma dimensão do UB que surpreende até programadores experientes e que vale conhecer. Compiladores modernos otimizam assumindo que o UB nunca acontece. Isso significa que a presença de UB pode fazer o compilador tomar decisões que eliminam código ou mudam comportamento de formas inesperadas. Por exemplo, se o compilador vê um código que só faria sentido caso um ponteiro fosse nulo, mas o código já desreferenciou esse ponteiro antes (o que seria UB se fosse nulo), ele pode assumir que o ponteiro nunca é nulo e remover a verificação, porque, na sua lógica, um ponteiro que foi desreferenciado com sucesso não pode ser nulo. O resultado é que o UB não apenas afeta a linha onde ocorre — ele pode fazer o compilador transformar partes distantes do programa de maneiras difíceis de prever. É por isso que a máxima entre programadores C é séria: UB não é "um comportamento estranho", é a ausência total de garantias, e deve ser evitado com rigor, não gerenciado.

Como se defender do comportamento indefinido

A boa notícia é que você já conhece as defesas — elas foram construídas ao longo do curso. Compile sempre com avisos (-Wall -Wextra): o compilador detecta muitos casos potenciais de UB e os reporta. Use os sanitizers (-fsanitize=address,undefined) das aulas Caçando Bugs de Memória com Valgrind e Sanitizers: o UndefinedBehaviorSanitizer foi feito exatamente para flagrar UB em tempo de execução, transformando o imprevisível em uma mensagem clara. Adote a disciplina defensiva que praticamos: verificar limites de vetores, checar ponteiros contra NULL antes de usá-los, respeitar o par malloc/free, garantir terminadores em strings. E conheça as regras — muitos UBs (como o estouro de inteiro com sinal ou modificar literais) são evitáveis simplesmente sabendo que existem. A defesa contra o UB não é uma técnica única, mas a soma de todos os bons hábitos que o curso vem cultivando.

O que vem a seguir

Hoje consolidamos a consciência de risco que atravessa todo o C: o comportamento indefinido, essa ausência deliberada de garantias que é o preço da velocidade da linguagem. Vimos o que é, por que existe (o pacto de confiança e desempenho), o catálogo dos casos comuns, sua traição imprevisível e o agravante das otimizações — e, sobretudo, como as ferramentas e hábitos do curso nos defendem dele. Na próxima aula, aplicamos essa mentalidade de robustez a um aspecto específico e prático: o tratamento de erros idiomático em C — como funções sinalizam falhas através de valores de retorno e da variável errno, e como escrever código que lida com o que dá errado de forma limpa e confiável.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Liste cinco exemplos distintos de comportamento indefinido que apareceram ao longo do curso, e para cada um explique brevemente qual regra é violada.

Ver resposta

✓ Resposta: Cinco exemplos de UB: (a) Acesso fora dos limites de vetor — acessar v[i] com i além do último índice válido viola a regra de que só se pode acessar posições dentro do bloco alocado. (b) Desreferenciar ponteiro nulo — fazer *p quando p é NULL viola a regra de que só se desreferencia ponteiros que apontam para memória válida. (c) Use-after-free — usar memória através de um ponteiro depois de free, violando a regra de que memória liberada não pertence mais ao programa. (d) Estouro de inteiro com sinal — uma operação aritmética cujo resultado excede o valor máximo de um tipo signed, que o padrão deixa indefinido. (e) Modificar uma string literal — alterar os caracteres apontados por char *s = "texto", violando o fato de que literais residem em memória somente-leitura. (Outros válidos: ler variável não inicializada, double-free, descasamento de especificador em printf.)

Exercício 2

Compile e rode o programa do laço com <= 5 (acesso a v[5]) do artigo, primeiro normalmente, depois com -fsanitize=address,undefined. Descreva a diferença: o que o programa faz sem os sanitizers e o que os sanitizers reportam?

Ver resposta

✓ Resposta: Sem sanitizers, o programa com <= 5 tem comportamento imprevisível: pode imprimir de v[0] a v[5] como se nada estivesse errado, pode travar, ou pode se comportar de forma bizarra (dependendo do que v[5] sobrescreve na pilha) — e o resultado pode variar entre máquinas e compilações. Compilado com -fsanitize=address,undefined, ao chegar na escrita de v[5], o AddressSanitizer interrompe o programa e reporta um stack-buffer-overflow, apontando a linha exata da escrita fora dos limites e mostrando que o acesso ocorreu num vetor de 5 elementos. O sanitizer transforma o UB silencioso e imprevisível numa falha explícita, imediata e localizada — exatamente o que torna o bug depurável.

Exercício 3

Explique por que o estouro de um inteiro com sinal (signed) é comportamento indefinido, enquanto o estouro de um inteiro sem sinal (unsigned) é bem definido pelo padrão. (Pesquise se necessário; o ponto é entender que unsigned "dá a volta" de forma garantida.)

Ver resposta

✓ Resposta: O estouro de inteiro sem sinal (unsigned) é bem definido porque o padrão C especifica que a aritmética unsigned opera em módulo 2ⁿ (onde n é o número de bits): quando o resultado excede o máximo, ele "dá a volta" (wraparound) de forma garantida e previsível — por exemplo, o maior unsigned mais 1 vira 0. Já o estouro de inteiro com sinal (signed) é deixado como comportamento indefinido pelo padrão. A razão histórica e prática é que diferentes arquiteturas de hardware poderiam representar e tratar números negativos de formas distintas (complemento de dois, complemento de um, sinal-magnitude), e o padrão não quis amarrar o comportamento a uma delas — , deixar o estouro signed como UB permite ao compilador otimizações valiosas (como assumir que x + 1 > x sempre, o que só vale se não houver estouro). O efeito prático: você pode confiar no wraparound de unsigned para certos truques (como hashes ou contadores cíclicos), mas nunca deve confiar no que acontece quando um signed estoura — é UB, imprevisível, e deve ser evitado.

Exercício 4

Explique, com suas palavras, por que o comportamento indefinido é uma consequência da filosofia de design do C. Como ele se relaciona com o "pacto de confiança" mencionado desde a primeira aula?

Ver resposta

✓ Resposta: O comportamento indefinido é uma consequência direta da filosofia do C de ser uma linguagem rápida e próxima da máquina. Verificar automaticamente cada acesso a vetor, cada ponteiro e cada operação aritmética teria um custo de desempenho que o C recusou pagar. Em vez disso, a linguagem transferiu essas verificações para a responsabilidade do programador e declarou "indefinido" o que acontece quando elas são negligenciadas — liberando o compilador para gerar o código mais eficiente possível, sob a suposição de que o programador nunca erra. Isso é exatamente o "pacto de confiança" da primeira aula: o C confia que você sabe o que está fazendo e não coloca redes de segurança no seu caminho. O UB é a outra face desse pacto — o preço da confiança e da velocidade. Onde uma linguagem protetora diria "não vou deixar você fazer isso", o C diz "vou deixar você fazer, e se estiver errado, o resultado é por sua conta". A ausência de garantias (UB) e a ausência de restrições (liberdade total) são a mesma escolha filosófica vista de dois ângulos.

Exercício 5

Descreva quatro defesas práticas contra o comportamento indefinido que você aprendeu ao longo do curso. Para cada uma, explique como ela ajuda a prevenir ou detectar UB.

Ver resposta

✓ Resposta: Quatro defesas práticas: (a) Compilar com -Wall -Wextra — os avisos do compilador detectam muitos padrões que levam a UB (variáveis não inicializadas, descasamento de especificadores, comparações suspeitas) e os reportam em tempo de compilação, antes mesmo de rodar. (b) Usar sanitizers (-fsanitize=address,undefined) — instrumentam o programa para detectar UB em tempo de execução, interrompendo e reportando no momento exato em que um acesso inválido, estouro ou uso indevido ocorre, transformando o imprevisível em uma mensagem clara. (c) Adotar disciplina defensiva — verificar limites antes de acessar vetores, checar ponteiros contra NULL antes de desreferenciar, respeitar o par malloc/free, garantir terminadores em strings; esses hábitos previnem o UB na origem, evitando que ele aconteça. (d) Conhecer as regras da linguagem — muitos UBs são evitáveis apenas sabendo que existem (não modificar literais, não confiar em estouro de signed, não ler memória não inicializada); a consciência do que constitui UB permite escrever código que o evita deliberadamente. Juntas, essas defesas cobrem prevenção (hábitos e conhecimento) e detecção (avisos e sanitizers), formando uma proteção em camadas contra a categoria mais traiçoeira de bugs do C.

Comentários

Mais em Linguagem C

Listas Duplamente Encadeadas e Circulares
Listas Duplamente Encadeadas e Circulares

Numa lista dupla, toda ligação tem dois lados: fez A->proximo = B, tem de…

Unions e Enums: Economia e Clareza
Unions e Enums: Economia e Clareza

Uma struct reserva espaço para todos os campos ao mesmo tempo; a union, para…

Vazamentos de Memória e Como Caçá-los
Vazamentos de Memória e Como Caçá-los

Noventa e nove execuções corretas e a centésima corrompida: é assim que um…