3 puntos por GN⁺ 2024-07-01 | 1 comentarios | Compartir por WhatsApp
  • C es simple y expresivo, pero solo con la sentencia switch y las reglas de las etiquetas se puede escribir código capaz de desconcertar incluso a desarrolladores experimentados
  • La ofuscación al estilo IOCCC oculta el código con macros y formateo, pero incluso con la propia sintaxis de C se puede lograr código legible pero extraño
  • switch (...) no requiere llaves obligatoriamente y, como salta a la etiqueta case que coincida, puede comportarse distinto al flujo normal de inicialización de bloques
  • Las etiquetas case no tienen que estar solo en el nivel superior del bloque switch, así que también compila un switch sin llaves que combina if (0) case... con una cadena de else if
  • La extensión && de dirección de etiqueta en GNU C permite incluso hacer un switch manual o bucles basados en etiquetas, pero algunos ejemplos son exclusivos de GCC y pueden no ser seguros frente a comportamiento indefinido

Código extraño creado por la sintaxis de C

  • C tiene muchos defectos, pero gracias a su sintaxis simple y su expresividad se usa como un lenguaje con el que se puede escribir software grande como sistemas operativos
  • Su sintaxis concisa también influyó en la estructura del código de muchos lenguajes posteriores de uso masivo, desde Java hasta Go
  • Un caso representativo de ofuscación de código es IOCCC
    • Las obras ganadoras del IOCCC suelen estar compuestas por macros del preprocesador, formateo poco convencional, nombres de variables nada útiles y expresiones aritméticas enrevesadas
    • Ese tipo de código puede ser admirable, pero como hay que hacer ingeniería inversa hasta llevarlo a código normal, puede resultar menos divertido para aprender

Los rincones profundos de C que revelan switch, case y goto

  • switch (...) puede escribirse sin llaves, igual que if (...) o for (...)
    • switch (i) case 1: puts("i = 1"); compila sin problema
    • Si no hay llaves, solo una sentencia queda asociada al switch, por lo que el case 2: que siga ya no está dentro del switch y provoca un error
  • En esencia, switch es una estructura más cercana a goto que salta a la etiqueta case coincidente
    • Aunque dentro del bloque switch haya int a = 123; y puts(...), si salta a default:, el código de inicialización anterior no se ejecuta
    • En ese caso, a no se inicializa con 123 y, técnicamente, eso es comportamiento indefinido
    • El ejemplo puede verse en Godbolt
  • Las etiquetas case no tienen que estar solo en el nivel superior del bloque switch asociado
    • También funciona una forma como if (0) case 0: puts("i = 0");
    • Como el switch salta directamente a ese case, el if (0) anterior se omite
    • Después de ejecutar ese puts(...), otras sentencias de salida posteriores siguen bloqueadas por la condición if (0), así que se puede evitar el fallthrough incluso sin break
    • El ejemplo puede verse en Godbolt
  • Como una cadena if ... else funciona sintácticamente como una sola sentencia de nivel superior, puede combinarse con switch para crear un switch rarísimo sin llaves
    • El ejemplo incluye el case de rango case 1 ... 10 y también default
    • El ejemplo puede verse en Godbolt
  • El operador &&, una extensión de GNU, permite obtener la dirección de una etiqueta y hacer goto a esa dirección
    • Con eso puede implementarse un switch manual usando algo como goto *(void*[]){ &&case_0, &&case_1, &&case_2 }[i];
    • El ejemplo puede verse en Godbolt
  • Usando la misma extensión de GNU, también se puede implementar un bucle basado en etiquetas dentro de una declaración de variables, incluso sin for (...)
    • El ejemplo construye un bucle que imprime desde i = 0 hasta i = 5 usando etiquetas y goto *&&_
    • El ejemplo puede verse en Godbolt
    • Este último fragmento es exclusivo de GCC y podría no ser seguro frente a comportamiento indefinido
  • En C, incluso sin ofuscar el código intencionalmente, se pueden crear formas completamente extrañas y confusas

1 comentarios

 
GN⁺ 2024-07-01
Opiniones en Hacker News
  • El ejemplo anterior dice que imprime el valor de a, pero que no se inicializa en 123; sin embargo, en C en realidad podría pasar eso.
    Usar una variable no inicializada no significa leer “el valor que por casualidad quedó en esa memoria”, sino que es comportamiento indefinido, así que el compilador puede hacer lo que quiera.
    Por ejemplo, podría inicializar esa memoria siempre en 123, o considerar que todo el fragmento tiene comportamiento indefinido y eliminar todas las instrucciones para que no imprima nada. Más aún, también puede optimizar eliminando el return posterior o incluso instrucciones anteriores, de modo que el comportamiento indefinido puede parecer que afecta “hacia atrás en el tiempo”.

    • En C, el comportamiento indefinido no viaja realmente hacia atrás en el tiempo.
      Puede afectar instrucciones anteriores, pero la reordenación de código y las transformaciones complejas ocurren incluso sin comportamiento indefinido.
    • En C, solo es comportamiento indefinido acceder a un objeto de duración de almacenamiento automática no inicializado.
      Los objetos estáticos siempre se inicializan, así que esa situación no se da.
      Lo que queda son objetos dinámicos, como miembros no inicializados de una estructura asignada con malloc; leer memoria dinámica no inicializada no es comportamiento indefinido en C, sino que se obtiene el valor que representen los bits no inicializados. Si ese tipo no tiene representaciones trampa, no puede fallar.
  • También es posible una sintaxis de este estilo:
    switch(k) { if (0) case 0: x = 1; if (0) case 1: x = 2; if (0) default: x = 3; }
    Se puede armar algo parecido a un switch en el que no haga falta poner break al final de cada cláusula, e incluso usar una macro como #define brkcase if (0) case. Al compilador probablemente no le guste el flujo de control, pero en general creo que lo eliminará bien.

    • Me parece que #define brkcase break;case funcionaría de forma parecida, pero entonces se pierde un poco el propósito de la macro.
    • Como no queda nada claro cómo funciona este enfoque, sería mejor escribir el mismo flujo de control con goto que abusar de un switch al estilo Duff's device.
    • Este enfoque solo funciona cuando el cuerpo de la etiqueta case es de una sola línea o está envuelto entre llaves.
      Hace tiempo usé esta estructura con el sentido de “saltar solo la primera línea de la siguiente etiqueta case y hacer fallthrough con el resto tal cual”.
      Si se ve una etiqueta case simplemente como una etiqueta, y no como un separador entre sentencias, todo tiene sentido.
  • Esta técnica puede usarse para implementar corrutinas en C: https://stackoverflow.com/questions/24202890/switch-based-co...

  • No sé por qué no sabía que case 1 ... 10: era C válido.
    Usé C durante años, y me da curiosidad de qué estándar salió esto.

    • Si no se estandarizó recientemente, no es C válido, sino una extensión de GNU.
    • Parece una extensión de GNU C: https://gcc.gnu.org/onlinedocs/gcc/Case-Ranges.html
      No encontré la historia de la extensión y, hasta donde sé, no existe en C estándar. No estoy seguro sobre clang.
  • Hace tiempo, por diversión, escribí un código raro en C que hacía una cuenta regresiva de 10 a 1.
    Las versiones en C, Python y shell pueden hacerse en una sola línea usando && y llamadas recursivas.

    • Una observación menor: sys.stdout.write(f"{n}\n") puede reemplazarse por print(n).
      El código actual, salvo por el f-string, parece estilo Python 2, cuando print era una sentencia. En Python 3, print es una función normal y devuelve None, que se evalúa como falso, así que el primer and también tendría que cambiarse por or.
  • Otra cosa sorprendente es que 4[arr] es lo mismo que arr[4].

    • Como el arreglo se convierte en puntero, básicamente es *(array_label+offset), y aquí pasa a ser *(offset+array_label).
      Es decir, *(arr+4) y *(4+arr) son lo mismo.
    • Por el mismo principio, arr[i][j] y j[i[arr]] también son exactamente lo mismo.
      Solo hay que saber que a[x][y] es lo mismo que (a[x])[y], y que a[x] es lo mismo que x[a].
      arr[i][j](arr[i])[j](i[arr])[j]j[i[arr]]
  • El último fragmento de código ofuscado del artículo muestra otra extensión de GCC: https://stackoverflow.com/questions/34559705/ternary-conditi...

  • Vi primero en Twitter estas técnicas juguetonas del autor del blog.
    También se pueden hacer bucles con sentencias switch: https://twitter.com/lcamtuf/status/1807129116980007037

  • Creo que estas travesuras con switch son una parte importante de Duff's device.

    • Duff's device se apoya en que la sintaxis de K&R C permite mezclar un bloque switch con un bucle, en la elección de que los case hagan fallthrough si no se usa break explícitamente, y en que en C un bucle puede saltar de vuelta hacia dentro de un switch.
      Duff intentaba optimizar E/S mapeada en memoria (MMIO), y hoy no se haría así ni siquiera en C. Ahora la MMIO no es tan rápida como las instrucciones de CPU, y si hay aunque sea un poco más de datos se puede usar DMA.
      Un lenguaje moderno tampoco trataría la MMIO como una simple indirección de puntero, pero C ha seguido agregando mecanismos de escape al sistema de tipos para mantener eso.
      Personalmente, creo que lo que continúa el “Device” de Tom Duff es el mecanismo de iterate loops de WUFFS. Es una forma de especificar cómo desenrollar parcialmente N pasos de un bucle, prometiendo el mismo resultado que ejecutar el cuerpo principal del bucle N veces, pero potencialmente más rápido. La vectorización puede entender mejor la intención, y los casos límite molestos como M % N != 0 quedan a cargo de una herramienta, no de una persona, para manejarlos correctamente.