Comportamento indefinido - Undefined behavior

Na programação de computadores , o comportamento indefinido ( UB ) é o resultado da execução de um programa cujo comportamento é prescrito como imprevisível, na especificação da linguagem à qual o código do computador adere. Isso é diferente do comportamento não especificado , para o qual a especificação da linguagem não prescreve um resultado, e do comportamento definido pela implementação que difere para a documentação de outro componente da plataforma (como a ABI ou a documentação do tradutor ).

Na comunidade C , o comportamento indefinido pode ser humoristicamente referido como " demônios nasais ", após uma postagem de comp.std.c que explicava o comportamento indefinido como permitindo ao compilador fazer qualquer coisa que ele escolher, até mesmo "fazer demônios voarem de seu nariz "

Visão geral

Algumas linguagens de programação permitem que um programa opere de forma diferente ou até mesmo tenha um fluxo de controle diferente do código-fonte , desde que exiba os mesmos efeitos colaterais visíveis ao usuário , se o comportamento indefinido nunca ocorrer durante a execução do programa . Comportamento indefinido é o nome de uma lista de condições que o programa não deve atender.

Nas primeiras versões de C , a principal vantagem do comportamento indefinido era a produção de compiladores de desempenho para uma ampla variedade de máquinas: uma construção específica poderia ser mapeada para um recurso específico da máquina e o compilador não precisava gerar código adicional para o tempo de execução para adaptar os efeitos colaterais para coincidir com a semântica imposta pela linguagem. O código-fonte do programa foi escrito com conhecimento prévio do compilador específico e das plataformas que ele suportaria.

No entanto, a padronização progressiva das plataformas tornou isso uma vantagem menor, especialmente nas versões mais recentes do C. Agora, os casos de comportamento indefinido geralmente representam bugs inequívocos no código, por exemplo, indexar um array fora de seus limites. Por definição, o tempo de execução pode assumir que o comportamento indefinido nunca acontece; portanto, algumas condições inválidas não precisam ser verificadas. Para um compilador , isso também significa que várias transformações de programa se tornam válidas ou suas provas de correção são simplificadas; isso permite vários tipos de otimização prematura e micro-otimização , que levam a comportamento incorrecto se o estado programa atende qualquer dessas condições. O compilador também pode remover verificações explícitas que podem estar no código-fonte, sem notificar o programador; por exemplo, detectar um comportamento indefinido testando se isso aconteceu não tem garantia de funcionamento, por definição. Isso torna difícil ou impossível programar uma opção portátil à prova de falhas (soluções não portáteis são possíveis para alguns construtos).

O desenvolvimento do compilador atual geralmente avalia e compara o desempenho do compilador com benchmarks projetados em torno de micro-otimizações, mesmo em plataformas que são mais usadas no mercado de desktops e laptops de uso geral (como amd64). Portanto, o comportamento indefinido oferece amplo espaço para a melhoria do desempenho do compilador, pois o código-fonte de uma instrução de código-fonte específica pode ser mapeado para qualquer coisa em tempo de execução.

Para C e C ++, o compilador tem permissão para fornecer um diagnóstico em tempo de compilação nesses casos, mas não é obrigado a: a implementação será considerada correta tudo o que fizer nesses casos, análogo aos termos irrelevantes na lógica digital . É responsabilidade do programador escrever código que nunca invoque comportamento indefinido, embora as implementações do compilador possam emitir diagnósticos quando isso acontecer. Os compiladores hoje em dia possuem flags que possibilitam tal diagnóstico, por exemplo, -fsanitizehabilita o "undefined behavior sanitizer" ( UBSan ) no gcc 4.9 e no clang . No entanto, esse sinalizador não é o padrão e ativá-lo é uma escolha de quem constrói o código.

Em algumas circunstâncias, pode haver restrições específicas ao comportamento indefinido. Por exemplo, as especificações do conjunto de instruções de uma CPU podem deixar o comportamento de algumas formas de uma instrução indefinida, mas se a CPU suportar proteção de memória , a especificação provavelmente incluirá uma regra geral afirmando que nenhuma instrução acessível ao usuário pode causar uma falha no a segurança do sistema operacional ; portanto, uma CPU real teria permissão para corromper os registros do usuário em resposta a tal instrução, mas não teria permissão para, por exemplo, mudar para o modo supervisor .

A plataforma de tempo de execução também pode fornecer algumas restrições ou garantias sobre o comportamento indefinido, se a cadeia de ferramentas ou o tempo de execução documentar explicitamente que construções específicas encontradas no código-fonte são mapeadas para mecanismos específicos bem definidos disponíveis no tempo de execução. Por exemplo, um intérprete pode documentar um determinado comportamento para algumas operações que são indefinidas na especificação da linguagem, enquanto outros intérpretes ou compiladores para a mesma linguagem não podem. Um compilador produz código executável para uma ABI específica , preenchendo a lacuna semântica de maneiras que dependem da versão do compilador: a documentação dessa versão do compilador e a especificação da ABI podem fornecer restrições ao comportamento indefinido. Contar com esses detalhes de implementação torna o software não portátil ; no entanto, a portabilidade pode não ser uma preocupação se o software não for usado fora de um tempo de execução específico.

O comportamento indefinido pode resultar em travamento do programa ou mesmo em falhas mais difíceis de detectar e fazer com que o programa pareça estar funcionando normalmente, como perda silenciosa de dados e produção de resultados incorretos.

Benefícios

Documentar uma operação como comportamento indefinido permite que os compiladores presumam que essa operação nunca acontecerá em um programa em conformidade. Isso fornece ao compilador mais informações sobre o código e essas informações podem levar a mais oportunidades de otimização.

Um exemplo para a linguagem C:

int foo(unsigned char x)
{
     int value = 2147483600; /* assuming 32-bit int and 8-bit char */
     value += x;
     if (value < 2147483600)
        bar();
     return value;
}

O valor de xnão pode ser negativo e, dado que o estouro de inteiro com sinal é um comportamento indefinido em C, o compilador pode assumir que value < 2147483600sempre será falso. Portanto, a ifinstrução, incluindo a chamada à função bar, pode ser ignorada pelo compilador, pois a expressão de teste no ifnão tem efeitos colaterais e sua condição nunca será satisfeita. O código é, portanto, semanticamente equivalente a:

int foo(unsigned char x)
{
     int value = 2147483600;
     value += x;
     return value;
}

Se o compilador tivesse sido forçado a presumir que o estouro de número inteiro assinado tem comportamento de retorno , a transformação acima não teria sido válida.

Essas otimizações se tornam difíceis de detectar por humanos quando o código é mais complexo e outras otimizações, como inlining , ocorrem. Por exemplo, outra função pode chamar a função acima:

void run_tasks(unsigned char *ptrx) {
    int z;
    z = foo(*ptrx);
    while (*ptrx > 60) {
        run_one_task(ptrx, z);
    }
}

O compilador é livre para otimizar o whileloop aqui aplicando a análise de intervalo de valor : ao inspecionar foo(), ele sabe que o valor inicial apontado por ptrxnão pode exceder 47 (já que mais do que isso acionaria um comportamento indefinido em foo()), portanto, a verificação inicial da *ptrx > 60vontade sempre ser falso em um programa em conformidade. Indo além, como o resultado zagora nunca é usado e foo()não tem efeitos colaterais, o compilador pode otimizar run_tasks()para ser uma função vazia que retorna imediatamente. O desaparecimento do while-loop pode ser especialmente surpreendente se foo()for definido em um arquivo de objeto compilado separadamente .

Outro benefício de permitir que o estouro de inteiro assinado seja indefinido é que isso torna possível armazenar e manipular o valor de uma variável em um registro do processador que é maior do que o tamanho da variável no código-fonte. Por exemplo, se o tipo de uma variável conforme especificado no código-fonte é mais estreito do que a largura do registro nativo (como " int " em uma máquina de 64 bits , um cenário comum), o compilador pode usar com segurança um 64- bit inteiro para a variável no código de máquina que ela produz, sem alterar o comportamento definido do código. Se um programa dependesse do comportamento de um estouro de inteiro de 32 bits, um compilador teria que inserir lógica adicional ao compilar para uma máquina de 64 bits, porque o comportamento de estouro da maioria das instruções de máquina depende da largura do registro.

O comportamento indefinido também permite mais verificações de tempo de compilação por compiladores e análise de programa estática .

Riscos

Os padrões C e C ++ têm várias formas de comportamento indefinido, que oferecem maior liberdade nas implementações do compilador e verificações em tempo de compilação às custas de comportamento indefinido em tempo de execução, se houver. Em particular, o padrão ISO para C tem um apêndice listando fontes comuns de comportamento indefinido. Além disso, os compiladores não são obrigados a diagnosticar o código que depende de um comportamento indefinido. Portanto, é comum para programadores, mesmo os experientes, confiar em um comportamento indefinido, seja por engano, ou simplesmente porque não são bem versados ​​nas regras da linguagem que podem abranger centenas de páginas. Isso pode resultar em bugs que são expostos quando um compilador diferente, ou configurações diferentes, são usadas. Testar ou fuzzing com verificações dinâmicas de comportamento indefinido habilitadas, por exemplo, os sanitizadores Clang , podem ajudar a detectar comportamento indefinido não diagnosticado pelo compilador ou analisadores estáticos.

O comportamento indefinido pode levar a vulnerabilidades de segurança no software. Por exemplo, estouros de buffer e outras vulnerabilidades de segurança nos principais navegadores da web são devidos a comportamento indefinido. O problema do ano 2038 é outro exemplo devido ao estouro de número inteiro assinado . Quando os desenvolvedores do GCC mudaram seu compilador em 2008, de forma que ele omitiu certas verificações de estouro que dependiam de comportamento indefinido, o CERT emitiu um aviso contra as versões mais novas do compilador. O Linux Weekly News apontou que o mesmo comportamento foi observado no PathScale C , Microsoft Visual C ++ 2005 e vários outros compiladores; o aviso foi alterado posteriormente para alertar sobre vários compiladores.

Exemplos em C e C ++

As principais formas de comportamento indefinido em C podem ser amplamente classificadas como: violações de segurança de memória espacial, violações de segurança de memória temporal, estouro de inteiro , violações de aliasing estrito, violações de alinhamento, modificações não sequenciadas, corridas de dados e loops que não realizam E / S nem terminam .

Em C, o uso de qualquer variável automática antes de ser inicializada produz um comportamento indefinido, assim como a divisão de inteiros por zero , estouro de inteiros com sinal, indexação de um array fora de seus limites definidos (consulte estouro de buffer ) ou desreferenciamento de ponteiro nulo . Em geral, qualquer instância de comportamento indefinido deixa a máquina de execução abstrata em um estado desconhecido e faz com que o comportamento de todo o programa seja indefinido.

A tentativa de modificar um literal de string causa um comportamento indefinido:

char *p = "wikipedia"; // valid C, deprecated in C++98/C++03, ill-formed as of C++11
p[0] = 'W'; // undefined behavior

A divisão inteira por zero resulta em comportamento indefinido:

int x = 1;
return x / 0; // undefined behavior

Certas operações de ponteiro podem resultar em comportamento indefinido:

int arr[4] = {0, 1, 2, 3};
int *p = arr + 5;  // undefined behavior for indexing out of bounds
p = 0;
int a = *p;        // undefined behavior for dereferencing a null pointer

Em C e C ++, a comparação relacional de ponteiros para objetos (para comparação menor que ou maior que) só é estritamente definida se os ponteiros apontarem para membros do mesmo objeto ou elementos da mesma matriz . Exemplo:

int main(void)
{
  int a = 0;
  int b = 0;
  return &a < &b; /* undefined behavior */
}

Chegar ao final de uma função de retorno de valor (diferente de main()) sem uma instrução de retorno resulta em um comportamento indefinido se o valor da chamada de função for usado pelo chamador:

int f()
{
}  /* undefined behavior if the value of the function call is used*/

Modificar um objeto entre dois pontos de sequência mais de uma vez produz um comportamento indefinido. Existem mudanças consideráveis ​​no que causa o comportamento indefinido em relação aos pontos de sequência a partir do C ++ 11. Os compiladores modernos podem emitir avisos quando encontram várias modificações não sequenciadas no mesmo objeto. O exemplo a seguir causará um comportamento indefinido em C e C ++.

int f(int i) {
  return i++ + i++; /* undefined behavior: two unsequenced modifications to i */
}

Ao modificar um objeto entre dois pontos de sequência, ler o valor do objeto para qualquer outro propósito que não seja determinar o valor a ser armazenado também é um comportamento indefinido.

a[i] = i++; // undefined behavior
printf("%d %d\n", ++n, power(2, n)); // also undefined behavior

Em C / C ++, o deslocamento bit a bit de um valor por um número de bits que é um número negativo ou maior ou igual ao número total de bits neste valor resulta em um comportamento indefinido. A maneira mais segura (independentemente do fornecedor do compilador) é sempre manter o número de bits a deslocar (o operando direito dos <<e >> bit a bit operadores ) dentro do intervalo: < > (onde é o operando à esquerda). 0, sizeof(value)*CHAR_BIT - 1value

int num = -1;
unsigned int val = 1 << num; //shifting by a negative number - undefined behavior

num = 32; //or whatever number greater than 31
val = 1 << num; //the literal '1' is typed as a 32-bit integer - in this case shifting by more than 31 bits is undefined behavior

num = 64; //or whatever number greater than 63
unsigned long long val2 = 1ULL << num; //the literal '1ULL' is typed as a 64-bit integer - in this case shifting by more than 63 bits is undefined behavior

Veja também

Referências

Leitura adicional

links externos