Undefined behavior em linguagem C
- por Sergio Prado
São diversas as situações em que um código escrito em linguagem C pode ter um comportamento indefinido. A especificação da linguagem C dá o nome de undefined behaviour para este tipo de situação.
Por exemplo, o código abaixo tem um comportamento indefinido pela linguagem C:
1 2 3 4 5 6 7 |
int tx_data() { int size; if (size > 10) { // do something } |
Segundo a especificação da linguagem C, acessar uma variável não inicializada tem um comportamento indefinido. Isso significa que o compilador pode decidir inicializar a variável com zero, pegar qualquer “lixo” da pilha, ou até dar um erro de compilação!
Mesmo considerando um compilador específico, seu comportamento poderia mudar dependendo da versão, dos parâmetros de otimização utilizados ou da arquitetura-alvo. Qualquer resultado é aceitável porque o comportamento de um código deste tipo não é definido pela linguagem. É por este motivo que ele é chamado de undefined behavior!
Java e Python são duas linguagens consideradas seguras porque minimizam este tipo de problema, seja em tempo de compilação ou através de um sistema de exceções capaz de identificar problemas em tempo de execução. Por outro lado, a linguagem C é bastante insegura, deixando para o desenvolvedor e/ou compilador a decisão sobre como utilizar os recursos do hardware. Esta característica da linguagem C é uma das principais fontes de problemas, dores de cabeça e noites mal dormidas dos desenvolvedores!
Mas como então até hoje não resolveram este “problema”? Porque realmente não é um problema, e sim uma característica da linguagem. Seus criadores projetaram uma linguagem eficiente e de baixo nível, e undefined behavior é apenas uma consequência desta decisão.
Poderia estar na especificação da linguagem C que todas as variáveis não inicializadas pelo desenvolvedor devem ser zeradas automaticamente pelo compilador. E neste caso, o compilador precisaria emitir um código adicional para inicializar variáveis não inicializadas, tornando a aplicação menos eficiente sob o ponto de vista do uso de recursos computacionais (CPU, memória, etc). Claro que este é um exemplo bem simples, mas imagine que esta variável fosse um vetor de 256KB. Gerar um código para inicializá-lo poderia ser bastante custoso!
Perceba então que existe uma troca (trade-off) entre um código seguro e um código eficiente. É por isso que, hoje em dia, linguagens como Go e Rust estão em evidência. Elas prometem gerar um código seguro sem comprometer a eficiência. Mas isso já é um assunto para outro artigo. :-)
Estes são alguns dos principais e mais comuns casos de undefined behavior em C:
- Uso de variáveis não inicializadas: nunca esqueça de inicializar variáveis antes de acessá-las, porque não existe a garantia de que o compilador fará isso por você.
- Estouro de um número inteiro com sinal: o comportamento do estouro de um inteiro com sinal em C é indefinido. Ele pode ser zerado, seu valor pode ser mantido, ele pode virar um número negativo, etc. Tudo depende do compilador e do ambiente de execução, incluindo a arquitetura do processador. Por este motivo, nunca deixe de validar a aritmética de números inteiros com sinal.
- Rotacionamento de bits: rotacionar os bits de um número inteiro por mais bits do que ele possui é undefined behavior (por exemplo rotacionar 33 vezes um inteiro de 32 bits), e seu comportamento pode depender do compilador ou da arquitetura da CPU.
- Divisão por zero: o resultado de uma divisão por zero tem comportamento indefinido na linguagem C. Pode ser gerada uma exceção, o resultado pode ser zero, seu HD pode ser formatado, ou qualquer outra coisa pode acontecer! :-)
- Acesso inválido à memória: erros como o uso de ponteiros não inicializados ou nulos e o acesso fora dos limites de um vetor tem comportamento indefinido na linguagem C.
Se você quiser uma lista completa com mais de 200 (!) undefined behaviors, dê uma olhada na documentação do padrão de codificação CERT C.
Vamos ver um exemplo simples e prático. O código em C abaixo faz uma divisão por zero:
1 2 3 4 5 6 |
#include <stdio.h> int main (void) { printf("div=%d\n", 10/0); } |
Podemos imaginar que este código irá compilar e gerar uma exceção ao ser executado.
Realmente este código compila (com um warning) e gera uma exceção quando executado em um PC (arquitetura x86-64):
$ gcc main.c -o main main.c: In function ‘main’: main.c:5:24: warning: division by zero [-Wdiv-by-zero] printf("div=%d\n", 10 / 0); ^ $ ./main Floating point exception (core dumped) |
Mas o mesmo código, quando compilado para a arquitetura PowerPC, funciona sem gerar exceção e o resultado da divisão é zero!
$ powerpc-linux-gnu-gcc main.c -o main -static main.c: In function ‘main’: main.c:5:24: warning: division by zero [-Wdiv-by-zero] printf("div=%d\n", 10 / 0); ^ $ qemu-ppc ./main div=0 |
Esta aí a prova de que divisão por zero em linguagem C tem comportamento indefinido!
Um dos grandes problemas de undefined behavior é que o mesmo código-fonte pode se comportar de maneira diferente, dependendo das flags de compilação e otimização utilizadas.
Veja esta simples aplicação em C:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
#include <stdio.h> static void (*funcptr)() = 0; static void func() { printf("Hello world!\n"); } void set() { funcptr = func; } int main() { funcptr(); return 0; } |
Claramente temos um erro aqui. A função main() está derreferenciando um ponteiro nulo (funcptr). E realmente, se tentarmos executar este código, o sistema operacional irá gerar uma exceção de acesso inválido à memória (SIGSEGV):
$ clang main.c -o main $ ./main Segmentation fault (core dumped) |
Mas se habilitarmos a otimização por tamanho, o compilador (estou usando aqui o clang) irá gerar um código otimizado que assume que o ponteiro funcptr é igual a func(), e o código funciona!
$ clang -Os main.c -o main $ ./main Hello world! |
Será que esta seria a decisão mais correta do compilador? Talvez sim, talvez não. Mas quem somos nós pra julgá-lo? O uso de um ponteiro nulo é undefined behavior, então ele pode fazer o que quiser!
É por este motivo que dar manutenção em um código com undefined behavior é bastante complicado. Qualquer mudança de compilador, versão das ferramentas de compilação e flags de otimização podem influenciar no comportamento do programa gerado, sem você ter alterado uma linha sequer do código fonte!
Ah, claro. É importante ressaltar que C++, sendo uma extensão da linguagem C, possui os mesmos problemas de undefined behavior. Na verdade, ela adiciona mais alguns pra lista!
Bom, como então evitar undefined behavior em C/C++?
Conhecer muito bem a linguagem de programação e suas características já é um bom começo. E adotar um padrão de codificação como o MISRA C ou o CERT C ajuda a incentivar a adoção de boas práticas de programação e minimizar a ocorrência de undefined behaviors.
Mesmo conhecendo a linguagem e adotando boas práticas de programação, somos humanos e cometemos erros. E por este motivo é importante ter o suporte das ferramentas de desenvolvimento.
Compilar o código com todos os warnings habilitados é essencial. No GCC, fazemos isso passando as flags -Wall e -Werror. Utilizar uma ferramenta de análise estática de código é uma prática que também deve ser adotada em todos os projetos escritos em C/C++. E em alguns casos, utilizar ferramentas de análise dinâmica como o Valgrind, que rodam em tempo de execução, também pode ser bastante válido.
Lidar com estes tipos de problemas não é fácil, principalmente em um código legado. Com muita sorte, o compilador irá emitir um warning quando encontrar uma situação de undefined behavior. E você sabe que não deve ignorar nenhum warning do compilador, certo? :-)
Porém, em algumas situações o código vai passar batido pelo compilador, e o uso de ferramentas auxiliares (estáticas e dinâmicas) pode ser essencial para poupar algumas horas (ou dias) a mais de desenvolvimento e testes.
Afinal, corrigir bugs é bastante divertido e valioso para o aprendizado, mas nem tanto quando existe pressão por prazos e entrega! :-)
Happy coding!
Sergio Prado