1 pontos por GN⁺ 4 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • Comportamento indefinido (UB) não é uma otimização maliciosa do compilador, mas uma regra segundo a qual ele não precisa lidar com caminhos de execução impossíveis sob a premissa de que o código é válido
  • Em código C/C++ não trivial, há UB espalhado por toda parte, não só em double-free ou acesso fora dos limites, mas também em detalhes sutis como alinhamento, casting, inicialização e incompatibilidade de tipos
  • Acessar um int* ou std::atomic<int>* desalinhado já é UB pelo padrão, embora na prática o resultado possa variar por plataforma entre SIGBUS, correção pelo kernel ou algo que parece funcionar normalmente
  • Códigos comuns como passar char com sinal para isxdigit(), converter float para int ou usar NULL incorretamente com argumentos variáveis também saem facilmente do que o padrão permite
  • Não dá para jogar fora codebases existentes, mas é preciso corrigir UB em larga escala combinando detecção de UB com LLM e validação por especialistas, porque as falhas são sutis demais para delegar a juniores

O comportamento indefinido em C/C++ não é um problema de otimização

  • Comportamento indefinido (UB) não significa que o compilador “explora” erros do desenvolvedor, mas sim que ele pode assumir que o programa é válido segundo o padrão
  • Mesmo quando a intenção parece clara para humanos, essa intenção pode ser difícil de expressar entre estágios do compilador ou entre módulos
  • O compilador não é obrigado a tratar casos especiais “impossíveis” na geração de código, e o resultado pode divergir da intenção ao longo do caminho de execução, incluindo o hardware
  • Desativar otimizações não torna UB seguro, e não há garantia de que o mesmo comportamento se mantenha em compiladores ou arquiteturas atuais ou futuras

UB não existe só em código anormal

  • double-free, use-after-free, acesso fora dos limites do objeto e acesso a memória não inicializada são exemplos conhecidos de UB, mas continuam se repetindo na indústria inteira
  • Também há muito UB mais sutil e contraintuitivo, e código C/C++ aparentemente comum pode sair facilmente do que o padrão define
  • O padrão C23 contém a palavra “undefined” 283 vezes, e o escopo é ainda maior se incluirmos casos não especificados que acabam ficando indefinidos
  • Em código C/C++ não trivial, UB está por toda parte, e é difícil atribuir isso apenas a descuido individual de programadores

Acesso a objetos desalinhados

  • Uma função que faz dereferência de int* como abaixo entra em UB quando o ponteiro não está alinhado corretamente
    int foo(const int* p) {
       return *p;
    }
    
  • Alinhamento (alignment) costuma significar um endereço múltiplo de sizeof(int), mas os requisitos reais podem variar conforme a plataforma e a implementação
  • No Linux Alpha, em alguns casos o kernel conseguia interceptar a trap e emular em software o acesso pretendido, mas em outros o programa podia morrer com SIGBUS
  • Em SPARC ocorre SIGBUS, enquanto em x86/amd64 normalmente funciona sem problemas aparentes, ou até parece uma leitura atômica
  • Em ARM, RISC-V e arquiteturas futuras não dá para generalizar o resultado, e arquiteturas futuras podem até ter registradores especiais que não usem os bits baixos de int*
  • Se o compilador passar a usar uma instrução de load diferente, um acesso antes corrigido pelo kernel pode deixar de ser corrigido
  • O compilador não é obrigado a gerar assembly que funcione com ponteiros desalinhados, porque o próprio acesso já é UB

Tipos atômicos também entram em UB se o alinhamento estiver errado

  • Mesmo chamando store() ou load() em um std::atomic<int>* como abaixo, o comportamento é UB se o objeto não estiver alinhado corretamente
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • Pela ótica do padrão, a pergunta “essa operação é atômica mesmo em um objeto desalinhado?” nem chega a fazer sentido
  • No hardware real, a atomicidade pode virar um problema, mas pelo padrão isso já é UB antes mesmo dessa discussão
  • Se o objeto que você achava estar lendo atomicamente atravessa uma página, a situação fica ainda mais complicada, mas a conclusão continua sendo UB, não “está tudo bem”

Só criar o ponteiro já pode ser um problema

  • Um ponteiro desalinhado pode ser problemático mesmo antes da dereferência: fazer casting para um ponteiro de certo tipo já pode ser UB
    bool parse_packet(const uint8_t* bytes) {
            const int* magic_intp = (const int*)bytes;   // UB!
            int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
            int magic = ntohl(magic_raw); // this is fine, at least.
            […]
    }
    
  • Aqui, o problema não é a chamada de foo(), mas o cast (const int*)bytes
  • Pelo padrão, o compilador poderia até atribuir significado aos bits baixos de int*, como metadados de garbage collection ou bits de tag de segurança

O problema de passar char para isxdigit()

  • O código abaixo parece simples, mas pode ser UB em arquiteturas onde char é signed quando o valor de entrada sai da faixa 0–127
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() é a função que verifica se um caractere é hexadecimal, e também pode receber EOF
  • Segundo C23 7.4p1, EOF é um int, do qual se pode inferir que é um valor que não pode ser representado por unsigned char
  • isxdigit() recebe int, não char, e embora a conversão de char para int seja permitida, valores negativos de signed char causam problema
  • Segundo C23 6.2.5 parágrafo 20, o fato de char ser signed ou não é definido pela implementação
  • Uma implementação de isxdigit() como a seguir pode acabar lendo memória desconhecida com um índice negativo
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • Se essa memória estiver em uma área mapeada para I/O, isso pode causar não só valores aleatórios ou crash, mas até comportamento de hardware
  • Isso é mais provável em sistemas embarcados do que em aplicações de desktop, mas há casos em espaço de usuário, como drivers de rede em user space, onde a proteção também pode ser insuficiente

O problema do cast de float para int

  • Código como este, que converte um float em segundos para um int em milissegundos, é comum, mas contém UB
    int milliseconds(float seconds) {
            int tmp = (int)(seconds * 1000.0); /* WRONG */
            return tmp + 1; /* WRONG separately (signed overflow is UB) */
    }
    
  • C23 6.3.1.4 determina que, ao converter um valor finito de ponto flutuante real para um tipo inteiro, se a parte inteira não puder ser representada por esse tipo, o comportamento é indefinido
  • Para valores não finitos, a falta de especificação também leva a UB
  • Até comparar float com INT_MAX está longe de ser simples
    • Fazer cast de float para int pode disparar exatamente o UB que se queria evitar
    • Fazer cast de INT_MAX para float não garante representação exata
    • Se INT_MAX for arredondado em float para um valor que não cabe em int, a comparação deixa de ser representativa
  • Para tornar isso seguro, são necessários teste com isfinite(), comparações com margem como INT_MIN + 1000 e INT_MAX - 1000, além de uma checagem extra após a conversão e antes da soma
    int milliseconds(float seconds) {
            const float ftmp = seconds * 1000.0f;
            if (!isfinite(ftmp)) {
                    return 0;
            }
            if ((float)(INT_MIN + 1000) > ftmp) {
                    return 0;
            }
            if ((float)(INT_MAX - 1000) < ftmp) {
                    return 0;
            }
            const int tmp = (int)ftmp;
            if (INT_MAX == tmp) {
                    return 0;
            }
            return tmp + 1;
    }
    
  • Você só queria converter float para int, mas o código seguro fica muito maior

Objeto no endereço 0 e null pointer

  • Em kernels de SO ou código embarcado, pode surgir a necessidade de colocar um objeto no endereço 0
  • Na prática, dá para dizer que não existe um jeito viável de fazer isso em conformidade com o padrão C
  • Em C 6.3.2.3, a constante inteira 0 e nullptr que podem ser convertidos em ponteiro são “null pointer constant”, e aqui podem ser chamados de NULL
  • C não especifica que um ponteiro NULL real aponte para o endereço de máquina 0
  • O padrão C trata da máquina abstrata de C, não do hardware, e só garante que NULL e 0 se comparam como iguais
  • Essa igualdade pode existir porque o inteiro 0 é convertido para o valor nativo de NULL daquela plataforma, e esse valor poderia até ser 0xffff
  • Dereferenciar um null pointer é UB independentemente de qual seja seu valor, e isso é um exemplo clássico em C 3.4.3
  • Portanto, não se pode assumir que memset(&ptr, 0, sizeof(ptr)); cria um ponteiro NULL
  • A prática de zerar uma struct e assumir que seus ponteiros membro são NULL é um problema real até para a maioria dos programadores experientes
  • Historicamente já existiram máquinas com ponteiros NULL diferentes de zero

O problema de assumir que há uma função no endereço 0

  • Mesmo que, em máquinas modernas, NULL aponte para o endereço 0 e realmente exista ali um objeto ou função, C 6.3.2.3 determina que NULL não é igual a nenhum objeto nem função
  • Portanto, o código abaixo é UB
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • Do ponto de vista de C, isso significa “não há função ali”, e talvez nem exista um jeito de expressar essa intenção dentro do compilador
  • Não dá para simplesmente assumir que será emitido um comando de call para um endereço com todos os bits zerados
  • Em x86 de 16 bits, nem sequer fica claro se “todos os zeros” significa 0000:0000 ou CS:0000

Argumentos variáveis e incompatibilidade de tipos

  • O último argumento de execl() precisa ser um ponteiro, então passar diretamente a macro NULL ou o inteiro 0 pode ser UB
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • A forma correta é fazer cast explícito para tipo ponteiro
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • A macro NULL pode ser interpretada como inteiro 0, e em argumentos variáveis a informação de tipo necessária não é transmitida
  • Em printf(), se o especificador de formato não corresponder ao tipo real do argumento, isso também é UB
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Para imprimir uint64_t, deve-se usar PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Para imprimir uid_t, uma opção pode ser fazer cast para uintmax_t e usar PRIuMAX, mas nem sequer é garantido que uid_t seja unsigned
  • No pior caso, em vez de -1, pode ser exibido algum valor sem sentido

Divisão por zero e problemas de segurança

  • O fato de divisão por zero ser UB é amplamente conhecido, mas quando o denominador vem de entrada não confiável isso vira um problema de segurança
  • O ponto importante é que UB pode surgir na fronteira de validação de entrada, não apenas como um erro trivial de runtime

Não é UB, mas promoção de inteiros também é perigosa

  • As regras de promoção de inteiros são difíceis de aplicar no ritmo de uma leitura rápida e podem produzir resultados contraintuitivos
  • No código abaixo, overflowed vira 0, e não 1
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • No código a seguir, embora todas as variáveis pareçam unsigned, o resultado não é 2147483648 (0x80000000), mas 18446744071562067968 (ffffffff80000000)
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • Mesmo quando não há UB, as regras de inteiros em C/C++ não são intuitivas e facilitam a criação de defeitos

Detecção de UB com LLM

  • LLMs modernos, quando recebem a tarefa de encontrar UB em código C arbitrário, quase sempre encontram algum problema e geralmente acertam
  • Depois de encontrar UB em código pessoal, a mesma abordagem foi aplicada até ao código do OpenBSD, conhecido por ser maduro e escrito com rigor
  • Vários problemas foram encontrados tendo find como alvo inicial
  • Para o OpenBSD, foram enviados patches para escrita fora dos limites e para um bug lógico que não era UB
  • Muitos outros casos restantes de UB não receberam patch
    • Havia a experiência anterior de que o projeto OpenBSD não era muito receptivo a bug reports
    • Havia a avaliação de que, na prática, aquilo talvez estivesse aceitável
    • Para remover UB da codebase do OpenBSD, seria necessário um projeto maior do que simplesmente enviar patches individuais intermediados por uma LLM

Um caminho realista para lidar com codebases C/C++

  • Não dá para descartar codebases C/C++ existentes, mas também não é opção deixá-las em um estado essencialmente quebrado
  • É preciso corrigir UB em larga escala sem fazer commit de mudanças ruins geradas por IA e sem sobrecarregar revisores humanos
  • Em 2026, escrever C ou C++ sem supervisão de UB por LLM pode passar a ser visto como uma violação da SOX e como algo irresponsável
  • Se nem os desenvolvedores do OpenBSD encontraram todos esses problemas em mais de 30 anos, a chance em outros projetos é ainda menor
  • Em projetos pessoais, é possível pedir a uma LLM que encontre UB, explique quando necessário, proponha correções e então deixar a validação final para humanos
  • Ainda assim, validar o resultado exige especialistas, e especialistas normalmente já estão ocupados com outras coisas
  • Isso parece trabalho de faxina, mas é sutil demais para ser delegado aos programadores juniores que tradicionalmente acabam recebendo esse tipo de tarefa

Materiais relacionados

1 comentários

 
GN⁺ 4 시간 전
Comentários do Hacker News
  • Em C há muitos comportamentos indefinidos surpreendentes e estranhos, mas este texto não consegue mostrar bem isso e só arranha a superfície
    Um exemplo ainda mais estranho é volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Se x for apenas int, tudo bem, mas se for volatile, isso vira comportamento indefinido. Pelo padrão C, um acesso volatile já é um efeito colateral só por ser uma leitura, efeitos colaterais não ordenados sobre o mesmo objeto escalar são comportamento indefinido, e a avaliação dos argumentos de função tem ordem não especificada entre si
    Normalmente, data race significa que threads diferentes acessam o mesmo objeto ao mesmo tempo e pelo menos um dos acessos é uma escrita, mas em C pode surgir uma situação parecida com data race até em uma única thread e sem escrita

    • Como autor, concordo. O objetivo deste texto não é enumerar os 283 lugares em que a palavra undefined aparece no padrão, nem todos os casos indefinidos que surgem por omissão
      O ponto é que isso é inevitável. Pelo menos desde que C surgiu em 1972, nunca houve um ser humano que tenha evitado isso completamente
      Se em 54 anos ninguém conseguiu, então “esforce-se mais” ou “não cometa erros” não são soluções. Uma falha explorável encontrada pelo Mythos no OpenBSD foi bastante elogiada pelos desenvolvedores do OpenBSD, mas mesmo ao rodar ferramentas no código mais simples apareceram vários comportamentos indefinidos
      Por exemplo, em find, ler a variável automática não inicializada status depois de waitpid(&status) e antes de verificar se houve erro em waitpid() também é comportamento indefinido, embora seja difícil imaginar uma arquitetura ou compilador em que isso fosse explorável
      Como escrevi no texto, não estou tentando listar todos os comportamentos indefinidos do mundo, e sim dizer que todo código C/C++ não trivial contém comportamento indefinido
    • volatile é um hack no sistema de tipos. Deveria ter havido uma solução mais baseada em princípios, e linguagens modernas não deveriam copiar isso como se “o C fez assim, então deve ser uma boa ideia”
      Os compiladores C iniciais sempre gravavam os valores na memória, então se um ponteiro apontasse para hardware de entrada/saída mapeado em memória, cada mudança em x fazia a instrução da CPU realmente escrever na memória, e o código do driver funcionava
      Mas quando chegaram as otimizações, o compilador passou a ver que só x estava sendo modificado e deixou o valor apenas em registrador, quebrando o driver. O volatile de C é um hack para dizer ao compilador “não faça essa otimização”, enquanto a solução correta, de fornecer intrínsecos de memória mapeada em E/S no nível de biblioteca, teria sido um trabalho muito maior
      Intrínsecos são necessários porque conseguem expressar exatamente o que é possível e o que não é. Em alguns alvos, escritas de 1 byte, 2 bytes e 4 bytes são operações diferentes, e o hardware distingue isso. Um dispositivo pode esperar uma escrita RGBA de 4 bytes, e se você emitir quatro escritas de 1 byte ele pode se confundir ou não funcionar. Alguns alvos até suportam escrita em nível de bit. Com volatile sozinho, não há como saber o que está acontecendo nem qual é o significado disso
    • É preciso distinguir comportamento indefinido de race. Essa distinção costuma faltar nessas discussões sobre comportamento indefinido
      Depois de compilar um programa C e desassemblá-lo, ele vira um programa em assembly sem comportamento indefinido, porque assembly não tem esse conceito
      Comportamento indefinido é uma propriedade do programa-fonte, não do executável. Significa que a especificação da linguagem em que o fonte foi escrito não atribui significado àquele programa. Já o executável gerado pela compilação recebe significado da especificação da máquina
      Race é uma propriedade do comportamento do programa. Portanto, dá para dizer que um programa C tem comportamento indefinido, mas não que o executável necessariamente tenha uma race real. Claro, como o compilador pode compilar arbitrariamente um programa com comportamento indefinido, ele até poderia introduzir uma race, mas se compilar sem criar novas threads, não haverá race
    • O significado de volatile é justamente que o valor pode ser alterado por outra coisa. Se for uma variável global, essa outra coisa pode ser outra thread, uma interrupção ou um manipulador de sinal. Se for um ponteiro lendo um endereço específico, pode ser um registrador de dispositivo de hardware cujo valor muda
      O conceito de variável volatile em si não é o problema. Se a linguagem quer dar suporte a rotinas de interrupção e entrada/saída mapeada em memória, ela precisa de um jeito de informar ao compilador que ler o mesmo registrador de hardware duas vezes não é o mesmo que ler a mesma posição de memória duas vezes
      O verdadeiro problema é que a interação entre os recursos e restrições da linguagem não foi bem resolvida. Se você declarou explicitamente “este valor pode mudar a qualquer momento”, então é tolice considerar certos usos como comportamento indefinido justamente por esse motivo. Para variáveis volatile, deveria haver uma exceção na definição de “efeitos colaterais não ordenados”
    • O ponto central do texto é que você nem precisa escrever código estranho para encontrar comportamento indefinido
      Muita gente se engana achando que C e C++ são “muito flexíveis porque deixam você fazer o que quiser”. Na prática, quase toda técnica que parece poderosa e elegante é um campo minado de comportamento indefinido
  • O comportamento indefinido de ponteiros desalinhados é pior ainda. Um ponteiro desalinhado é comportamento indefinido não só ao acessá-lo, mas pelo próprio ponteiro em si
    Então converter implicitamente void* v para int* i, por exemplo com i=v em C ou f(v) quando f recebe int*, também é comportamento indefinido se o ponteiro resultante não satisfizer o alinhamento exigido para int
    É importante notar que isso é um problema no nível de C. Se um programa C tem comportamento indefinido, então formalmente ele é inválido e está errado. Não é um problema de hardware, nem tem relação necessária com travamento ou falha
    A conversão de void* para int* normalmente não gera instrução nenhuma no hardware, e como tipos só existem em C, o hardware também não vai travar nessa conversão. Você pode pensar que, se é só um valor inteiro num registrador, está tudo bem, mas o ponto central não é se o ponteiro é realmente um inteiro no hardware, e sim que no instante em que você faz a conversão para um ponteiro desalinhado o programa C já está quebrado por definição

    • Como autor, sim. Isso é tratado na seção “Actually, it was UB even before that” do texto
      Eu também quis transmitir que comportamento indefinido não existe no hardware e não tem relação necessária com travamentos ou falhas. Ao mesmo tempo, quis mostrar exemplos para quem diz “mas veja, funciona direitinho”, e na prática não é bem assim
    • Tudo bem, isso é algo previsível. Um bom programador sabe que conversão de ponteiros é obviamente uma área com armadilhas
    • Você pode apontar onde no padrão está escrito que um ponteiro desalinhado por si só já é comportamento indefinido?
    • Então, se eu criar uma struct com #pragma pack(push, 1), isso quer dizer que não posso usar ponteiros para membros a menos que o membro esteja alinhado por acaso?
    • A ideia original de comportamento indefinido em C era dar ao compilador liberdade para mapear o código ao hardware mesmo quando instruções de máquina variavam um pouco de uma arquitetura para outra. O mesmo programa C podia expressar comportamentos diferentes dependendo da arquitetura onde rodava
      Esse tipo de comportamento indefinido é aceitável, e quase ninguém vê como grande problema o fato de diferenças de hardware causarem bugs
      Mas com o tempo interpretações agressivas transformaram C numa linguagem de design by contract implícito, e as restrições ficaram invisíveis. Isso cria um problema parecido com o de destrutores implícitos no RAII, que também não ficam visíveis
      Em C, quando você desreferencia um ponteiro, o compilador adiciona uma restrição implícita de não nulo à assinatura da função. Se você passa um ponteiro possivelmente nulo para a função, em vez de um erro por falta de verificação ou asserção, o compilador propaga silenciosamente essa restrição de não nulo ao ponteiro. Se ele provar que a restrição é falsa, marca a função como inalcançável, e chamadas a funções inalcançáveis podem tornar a função chamadora inalcançável também
  • As 5 etapas de aprender sobre comportamento indefinido em C
    Negação: “Eu sei como overflow com sinal funciona na minha máquina”
    Raiva: “Esse compilador é um lixo! Por que não faz o que eu mandei?”
    Barganha: “Vou enviar esta proposta ao wg14 para consertar C...”
    Depressão: “Dá para confiar em algum código C?”
    Aceitação: “É só não usar comportamento indefinido

    • Em que etapa entra “fazer o compilador definir aquilo que é indefinido”?
      Acesso desalinhado dá para resolver usando structs empacotadas. O compilador magicamente gera o código correto. Na verdade, ele sempre soube fazer isso direito, só não fazia
      Regras rígidas de aliasing podem ser contornadas usando conversão por union. Compiladores importantes documentam isso como funcionando mesmo que o padrão não diga. Ou então basta desligar com -fno-strict-aliasing. Aí você pode reinterpretar memória como quiser; ainda haverá cantos perigosos, mas pelo menos eles não virão do compilador
      Overflow dá para definir com -fwrapv. E se trocar +, - e * por __builtin_*_overflow, você ainda ganha verificação explícita de erro de graça. A interface funcional é boa e ainda gera código eficiente
      A verdadeira aceitação se aproxima mais de “pessoas normais não ligam para o padrão C”. O padrão é ruim; o que importa é o compilador. E compiladores têm muitos recursos extremamente úteis para contornar a maior parte desses problemas. As pessoas não usam porque querem escrever C “portável”, “padrão”, e superar essa mentalidade é a verdadeira aceitação
      Seguindo essa lógica, fiz um interpretador Lisp em C de ambiente hospedado e ele até passou no UBSan. Achei que explodiria no começo, mas não aconteceu, e se eu consegui, qualquer um consegue
    • Como autor, o ponto do texto é justamente que “é só não usar comportamento indefinido” é impossível
      Enquanto seres humanos escreverem código, isso não pode ser o estado final. Nenhum ser humano consegue evitar completamente comportamento indefinido em C/C++
    • “É só não usar comportamento indefinido” soa, na melhor das hipóteses, ainda como a etapa de barganha
    • É só trabalhar com dispositivos embarcados, como eu. Escrever software para uma CPU específica é realmente confortável
    • Em C, aceitação está mais para “eu vou usar comportamento indefinido, e um dia algo ruim vai acontecer”
  • Os exemplos parecem menos comportamentos indefinidos reais e mais casos que podem se tornar comportamento indefinido dependendo da entrada ou da situação
    Se ampliar assim, toda chamada de função também é comportamento indefinido, porque pode estourar o espaço de pilha. Na prática, em qualquer linguagem daria para dizer algo parecido nesse sentido
    C já tem arestas reais suficientemente notáveis; esse tipo de sensacionalismo pode distrair a atenção, especialmente dos iniciantes, e acabar fazendo mais mal do que bem

    • Ada 83 não trata overflow da pilha de chamadas como comportamento indefinido. O manual de referência define a exceção STORAGE_ERROR
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      Ela também ocorre em “execução de uma chamada de subprograma quando não há espaço de armazenamento suficiente”
    • Isso não procede
      Primeiro, é possível definir o que acontece quando o espaço de pilha se esgota. Além disso, nem todo programa precisa de uma pilha de tamanho arbitrário, e alguns programas precisam apenas de um tamanho constante calculável de antemão. Há implementações de linguagem que nem usam pilha
      A linguagem também pode oferecer ferramentas para verificar o espaço restante da pilha e dar garantias com base nisso. Ou pode permitir instalar um tratador para ser executado quando o espaço da pilha acabar
    • Comportamento indefinido que surge dependendo da entrada também pode ser um vetor de exploração
    • Os exemplos são claramente comportamento indefinido. Ponto final
      A forma correta de pensar é que, no momento em que surge comportamento indefinido, você deixa de estar sob a proteção do padrão da linguagem. Pode continuar funcionando por um tempo, talvez para sempre. Mas na prática passa a depender, sem perceber, dos caprichos da toolchain, de troca ou upgrade de compilador, de arquitetura, de runtime, de versão de libc
      No fim, você constrói sobre areia movediça, e esse é o perigo do comportamento indefinido
    • Este texto é praticamente a definição de FUD
  • O problema do comportamento indefinido não é que pode travar em alguma arquitetura
    O verdadeiro problema é que o compilador espera que esse código nunca aconteça. Mesmo assim, se você escrever código com comportamento indefinido, o compilador, especialmente o otimizador, pode traduzi-lo para qualquer forma conveniente ao caminho “normal”. E esse “qualquer forma” às vezes pode ser algo muito inesperado, como apagar blocos enormes de código

    • Um exemplo relacionado é a exigência de que toda função termine ou produza efeitos colaterais. Ainda não fui atingido por isso diretamente, mas é perfeitamente imaginável escrever por engano um loop infinito ou recursão e ver a função ser eliminada
      Se ainda houver otimização de cauda, o bug pode nem aparecer na build de debug, mas só se revelar quando você aumentar o nível de otimização
    • Travar está entre os resultados mais brandos do comportamento indefinido. Pelo menos é visível com facilidade
      Pior é quando o programa continua silenciosamente com valores lixo, formata seu disco rígido ou entrega ao atacante as chaves do reino
    • Sim, mas isso também é a funcionalidade mais útil e a razão de existir do comportamento indefinido
      Quem pede para simplesmente definir isso ou transformá-lo em comportamento não especificado perde o ponto central de que o compilador poder eliminar grandes partes do programa é justamente o essencial
      Se você escreve código que se torna comportamento indefinido para certas entradas, então sua intenção é que, para essas entradas, o programa não tenha comportamento algum. Você quer que o compilador elimine esse caminho por otimização ou faça algum outro tratamento que ajude o comportamento nos casos definidos
      Colocar uma string de log alcançável apenas via comportamento indefinido e ver que ela não fica no binário é bastante satisfatório
    • O trecho do texto que dizia que não é um problema de otimização me chamou bastante atenção
      Eu já escrevi uma análise que assumia executar no fim do pipeline de transformações, e essa suposição era necessária para a correção. Como depois disso não haveria mais otimizações, eu achava que estava seguro, mas agora já não tenho tanta certeza
    • Isso não é um problema, é uma funcionalidade
  • Usei C por 20 anos, mas nunca vi se falar tanto de comportamento indefinido quanto nos últimos 6 meses no Hacker News
    Em conversas reais isso quase nunca aparecia. Você escreve o código e, se não funcionar, depura, corrige ou contorna. Não sei por que o tema do comportamento indefinido em C continua aparecendo tanto na capa

    • O Hacker News continua mais inclinado a linguagens de programação do que à programação de fato. Talvez haja também um pouco da herança Lisp do Y Combinator
      Sempre existe uma minoria de formados em ciência da computação que acha que desenvolver ou usar uma nova linguagem de programação é a coisa mais interessante do mundo, e parte deles continua pensando assim
      É natural que essas pessoas se interessem pelo lado de design de linguagem, e o comportamento indefinido em C pertence a esse campo. Só que muito disso originalmente era uma tentativa de acomodar arquiteturas de CPU antigas sem perda de desempenho, então chamar isso de “escolha de design” às vezes é tão estranho quanto dizer que rodas são redondas por design
    • Como assim? Eu já usava C e C++ há 20 anos, e comportamento indefinido já ocupava um espaço enorme em conversas e na formação naquela época também
      Houve alguns “escândalos” bastante conhecidos por volta do GCC 3.2, quando o compilador começou a explorar comportamento indefinido de forma muito mais agressiva nas otimizações, e por isso muita gente ficou no GCC 2.95 por bastante tempo. O GCC 3.2 saiu em 2002
    • Os computadores de antigamente eram legais, os de hoje ficaram perigosos
      Como todas as empresas vivem enfatizando segurança e exposição, ou seja, virar notícia, a narrativa contra o “não seguro” cresceu demais
      O novo mundo parece gente da cidade que nunca viu natureza bruta e se assusta ao ver um cortador de grama. Tem lâminas girando? Que absurdo!
    • O ambiente de execução pode ser uma arquitetura completamente diferente, então esses detalhes são muito importantes
      Se o alvo real for um pequeno sistema embarcado no topo de uma torre de comunicação no meio do nada, “funciona na minha máquina” não serve de nada. Claro que a maioria não trabalha com isso, e provavelmente a maioria dos desenvolvedores aqui faz web, mas ainda assim é uma discussão interessante, talvez até mais interessante quando você não lida com isso diretamente
    • Mais precisamente, não se escreve para uma especificação imaginária, e sim para o alvo de destino. A especificação é útil para prever aproximadamente o que o alvo fará, mas não é normativa
      Compiladores podem ter bugs em que algo deveria funcionar segundo a especificação e não funciona, há muitas extensões sem equivalente no padrão, e até comportamentos que são indefinidos no padrão mas recebem um significado útil específico de implementação
  • Concordo em geral com a introdução, mas os exemplos são ruins e o texto todo parece uma embalagem para empurrar codificação com LLM

    • Sim. Os exemplos são, um por um, coisas padrão que você evita ao escrever código portável, ou então coisas desnecessárias, como acessar objetos no endereço 0
      Dá a impressão de alguém que quer escrever qualquer código como quiser e ainda esperar que ele se comporte igual em todo ambiente. Se você fizer uma linguagem assim, perde-se a vantagem de poder escrever de forma adaptada à plataforma quando isso interessa
    • Em que sentido eles são ruins? Se forem verdadeiros, isso é bastante grave
  • O código C++ deste texto em parte já não era idiomático há mais de 10 anos, e hoje seria visto como code smell
    A linguagem evoluiu bastante e já é bem diferente da que era quando foi criada. Assim que apareceram vários ponteiros crus e acesso manual a ponteiros, ficou claro que parte do texto precisava ser lida com algum filtro
    Outro problema óbvio é a perspectiva de juntar C e C++ como se fossem quase a mesma linguagem. Hoje em dia elas já estão bem distantes na prática

    • Eu ia apontar que o código era C, não C++, mas fui conferir de novo e realmente estava escrito std::atomic, não atomic_int
  • Então é assim que se entende comportamento indefinido em C?
    O programa P tem um conjunto A de entradas que não provoca comportamento indefinido, e um conjunto complementar B que provoca
    Um compilador correto compila P para um executável P'. Para toda entrada em A, P' deve se comportar da mesma forma que P
    Mas para qualquer entrada em B, o comportamento de P' não tem nenhuma exigência

    • Intuitivamente, sim. O programa é compilado como se entradas de B nunca fossem fornecidas, o que pode incluir remover o código que tentaria detectar entradas de B
    • Bom resumo
  • Exemplo concreto de comportamento indefinido causado por ponteiro desalinhado: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • É um caso em x86, justamente onde muita gente costuma supor que não haveria problema