3 pontos por GN⁺ 2024-07-01 | 1 comentários | Compartilhar no WhatsApp
  • C é simples e expressivo, mas só com a instrução switch e as regras de rótulos já é possível criar código capaz de deixar até desenvolvedores experientes perplexos
  • A ofuscação ao estilo IOCCC esconde o código com macros e formatação, mas a própria sintaxe de C também permite criar código legível, porém estranho
  • switch (...) não exige chaves e, como salta para o rótulo case correspondente, pode se comportar de forma diferente do fluxo comum de inicialização de blocos
  • Rótulos case não precisam ficar apenas no nível superior do bloco switch, então até um switch sem chaves que combina if (0) case... com uma cadeia de else if compila
  • A extensão de endereços de rótulos && do GNU C permite até criar switches próprios ou loops baseados em rótulos, mas alguns exemplos são específicos do GCC e podem não ser seguros contra comportamento indefinido

Código estranho criado pela sintaxe de C

  • C tem muitos defeitos, mas, graças à sua sintaxe simples e expressividade, é usado como uma linguagem capaz de escrever grandes softwares, como sistemas operacionais
  • A sintaxe concisa influenciou a estrutura de código de muitas linguagens mainstream posteriores, de Java a Go
  • O exemplo clássico de ofuscação de código é o IOCCC
    • Obras vencedoras do IOCCC frequentemente são compostas por macros do pré-processador, formatação anormal, nomes de variáveis pouco úteis e expressões aritméticas obscuras
    • Esse tipo de código pode ser admirável, mas pode ser menos divertido para aprender, pois é preciso fazer engenharia reversa para transformá-lo em código normal

As profundezas de C reveladas por switch, case e goto

  • switch (...) pode ser usado sem chaves, assim como if (...) ou for (...)
    • switch (i) case 1: puts("i = 1"); compila
    • Sem chaves, apenas uma instrução fica associada ao switch, então o case 2: seguinte deixa de estar dentro do switch e vira erro
  • switch é, essencialmente, uma estrutura parecida com goto que se desloca para o rótulo case correspondente
    • Mesmo que haja int a = 123; e puts(...) dentro do bloco switch, ao saltar para default:, o código de inicialização anterior não é executado
    • Nesse caso, a não é inicializado com 123 e, tecnicamente, isso se torna comportamento indefinido
    • O exemplo pode ser conferido no Godbolt
  • Rótulos case não precisam ficar apenas no nível superior do bloco switch associado
    • Uma forma como if (0) case 0: puts("i = 0"); também funciona
    • Como o switch salta diretamente para esse case, o if (0) anterior é ignorado
    • As outras instruções de saída após a execução desse puts(...) continuam bloqueadas pela condição if (0), evitando fallthrough mesmo sem break
    • O exemplo pode ser conferido no Godbolt
  • Uma cadeia if ... else se comporta sintaticamente como uma única instrução de nível superior, então é possível combiná-la com switch para criar um switch bizarro sem chaves
    • O exemplo inclui até um case de intervalo case 1 ... 10 e default
    • O exemplo pode ser conferido no Godbolt
  • O operador &&, uma extensão GNU, permite obter o endereço de um rótulo e dar goto para esse endereço
    • Com isso, é possível implementar um switch próprio de uma forma como goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];
    • O exemplo pode ser conferido no Godbolt
  • Usando a mesma extensão GNU, também é possível implementar, dentro de uma declaração de variável, um loop baseado em rótulos sem for (...)
    • O exemplo constrói um loop que imprime de i = 0 a i = 5 usando rótulos e goto *&&_
    • O exemplo pode ser conferido no Godbolt
    • Este último snippet é específico do GCC e possivelmente não é seguro contra comportamento indefinido
  • Em C, mesmo sem ofuscar código intencionalmente, é possível criar formas completamente estranhas e confusas

1 comentários

 
GN⁺ 2024-07-01
Comentários no Hacker News
  • O exemplo acima diz que imprime o valor de a, mas que ele não é inicializado com 123; em C, porém, isso pode de fato acontecer
    Usar uma variável não inicializada não significa ler “o valor que por acaso ficou naquela memória”; é comportamento indefinido, então o compilador pode fazer o que quiser
    Por exemplo, ele pode inicializar essa memória incondicionalmente com 123, ou considerar que o trecho inteiro tem comportamento indefinido e remover todas as instruções, fazendo com que nada seja impresso. Indo além, também pode otimizar e remover o return seguinte ou até instruções anteriores, de modo que o comportamento indefinido possa parecer afetar as coisas “voltando no tempo”

    • Em C, comportamento indefinido não volta no tempo de verdade
      Ele pode afetar instruções anteriores, mas reordenação de código e transformações complexas acontecem mesmo sem comportamento indefinido
    • Em C, só há comportamento indefinido ao acessar um objeto com duração de armazenamento automática não inicializado
      Objetos estáticos são sempre inicializados, então essa situação não ocorre
      O que sobra são objetos dinâmicos, como membros não inicializados de uma struct alocada com malloc; ler memória dinâmica não inicializada não é comportamento indefinido em C, e você obtém o valor representado pelos bits não inicializados. Se o tipo não tiver representações de trap, isso não pode falhar
  • Sintaxes desse tipo também são possíveis
    switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }
    Dá para fazer algo parecido com um switch em que não é preciso escrever break ao fim de cada cláusula, e até criar uma macro como #define brkcase if (0) case. O compilador provavelmente não vai gostar do fluxo de controle, mas em geral deve conseguir eliminar isso bem

    • Acho que #define brkcase break;case funcionaria de modo parecido, mas aí o propósito da macro meio que desaparece
    • Como é muito pouco claro como essa abordagem funciona, seria melhor escrever o mesmo fluxo de controle com goto do que usar esse abuso de switch no estilo Duff's device
    • Esse método só funciona quando o corpo do rótulo case tem uma única linha ou está envolvido por chaves
      No passado, já usei essa estrutura com o sentido de “pular apenas a primeira linha do próximo rótulo case e deixar o resto fazer fallthrough normalmente”
      Se você enxergar rótulos case como simples rótulos, e não como separadores entre comandos, tudo faz sentido
  • Dá para usar essa técnica para implementar corrotinas em C: https://stackoverflow.com/questions/24202890/switch-based-co...

  • Não sei como eu não sabia que case 1 ... 10: é C válido
    Usei C por anos e fico curioso para saber de qual padrão isso veio

    • Se não foi padronizado recentemente, não é C válido; é uma extensão GNU
    • Parece ser uma extensão do GNU C: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      Não encontrei a história da extensão e, até onde sei, ela não existe no C padrão. Não tenho certeza quanto ao clang
  • Uma vez escrevi por diversão um código C estranho que fazia uma contagem regressiva de 10 até 1
    As versões em C, Python e shell podem ser feitas em uma única linha usando && e chamadas recursivas

    • Um detalhe pequeno: sys.stdout.write(f"{n}\n") pode ser trocado por print(n)
      Do jeito que está, tirando a f-string, o código parece no estilo do Python 2, quando print era uma instrução. No Python 3, print é uma função comum e retorna None, que é avaliado como falso, então o primeiro and também precisa ser trocado por or
  • Outra coisa surpreendente é que 4[arr] é igual a arr[4]

    • Como arrays são convertidos em ponteiros, isso basicamente é *(array_label+offset), e aqui vira *(offset+array_label)
      Ou seja, *(arr+4) e *(4+arr) são a mesma coisa
    • Pelo mesmo princípio, arr[i][j] e j[i[arr]] também são exatamente iguais
      Basta saber que a[x][y] é o mesmo que (a[x])[y], e que a[x] é o mesmo que x[a]
      arr[i][j](arr[i])[j](i[arr])[j]j[i[arr]]
  • O último trecho de código ofuscado do texto mostra outra extensão do GCC: https://stackoverflow.com/questions/34559705/ternary-conditi...

  • Vi primeiro no Twitter esses truques brincalhões do autor deste blog
    Também dá para criar loops com uma instrução switch: https://twitter.com/lcamtuf/status/1807129116980007037

  • Acho que essas brincadeiras com switch são uma parte importante do Duff's device

    • O Duff's device se apoia no fato de que a sintaxe do C K&R permite misturar blocos switch e loops, na escolha de que um case faça fallthrough se não houver break explícito, e no fato de que, em C, um loop pode saltar de volta para dentro de um switch
      Duff estava tentando otimizar entrada/saída mapeada em memória (MMIO), e hoje em dia ninguém faria isso desse jeito em C. Agora MMIO não é tão rápida quanto a velocidade das instruções da CPU, e, se houver um pouco mais de dados, dá para usar DMA
      Em uma linguagem moderna, MMIO provavelmente nem seria tratada como uma simples indireção por ponteiro, mas em C continuaram adicionando mecanismos de escape ao sistema de tipos para manter isso
      Pessoalmente, vejo o mecanismo de iterate loops do WUFFS como o sucessor do “Device” de Tom Duff. Ele permite especificar uma forma de desenrolar parcialmente N etapas de um loop, prometendo o mesmo resultado que executar o corpo principal do loop N vezes, mas possivelmente mais rápido. A vetorização fica mais fácil de inferir, e casos de borda incômodos como M % N != 0 passam a ser tratados corretamente por ferramentas, não por pessoas