1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • El comportamiento indefinido (UB) no es una optimización maliciosa del compilador, sino una regla que le permite no manejar rutas de ejecución imposibles bajo la suposición de que el código es válido
  • En el código C/C++ no trivial hay UB oculto por todas partes, no solo double-free o accesos fuera de límites, sino también en alineación, conversiones de tipo, inicialización y discrepancias de tipos
  • Acceder a un int* o std::atomic<int>* desalineado ya es UB según el estándar, aunque según la plataforma pueda terminar en SIGBUS, una corrección del kernel o algo que parezca funcionar normalmente
  • Incluso código común como pasar un char con signo a isxdigit(), convertir float a int, o usar mal NULL y argumentos variables puede salirse fácilmente de lo permitido por el estándar
  • No se pueden desechar las bases de código existentes, pero habrá que corregir esto a gran escala combinando detección de UB con LLM y validación de expertos, porque es demasiado sutil para dejárselo a personal junior

El comportamiento indefinido en C/C++ no es un problema de optimización

  • El comportamiento indefinido (UB) no significa que el compilador “aproveche” errores del desarrollador, sino que puede asumir que el programa es válido según el estándar
  • Aunque para una persona la intención parezca clara, esa intención puede ser difícil de expresar entre fases del compilador o entre módulos
  • El compilador no tiene obligación de generar código que maneje casos especiales que “no pueden ocurrir”, y el resultado puede diferir de lo esperado en la ruta de ejecución real, incluido el hardware
  • Desactivar optimizaciones no vuelve seguro el UB, ni hay garantía de que el mismo comportamiento se mantenga en compiladores o arquitecturas presentes o futuras

El UB no solo existe en código anormal

  • double-free, use-after-free, acceso fuera de los límites de un objeto y acceso a memoria no inicializada son UB conocidos, pero siguen apareciendo en toda la industria
  • También hay mucho UB más sutil y contraintuitivo, por lo que código C/C++ aparentemente normal puede salirse fácilmente del estándar
  • La norma C23 contiene 283 apariciones de la palabra “undefined”, y el alcance es aún mayor si se incluyen casos no especificados que terminan siendo indefinidos
  • En cualquier código C/C++ no trivial hay UB por todos lados, y es difícil atribuirlo solo al descuido de programadores individuales

Acceso a objetos desalineados

  • Una función que desreferencia un int* como la siguiente incurre en UB si el puntero no está correctamente alineado
    int foo(const int* p) {
       return *p;
    }
    
  • La alineación (alignment) suele significar una dirección múltiplo de sizeof(int), pero los requisitos reales pueden variar según la plataforma y la implementación
  • En Linux Alpha, en algunos casos el kernel podía atrapar la excepción y emular por software el acceso esperado, pero en otros el programa podía morir con SIGBUS
  • En SPARC ocurre SIGBUS, mientras que en x86/amd64 normalmente parece funcionar sin problema o incluso como una lectura atómica
  • En ARM, RISC-V o arquitecturas futuras no se puede generalizar el resultado, y una arquitectura futura podría usar bits bajos de int* para registros especiales
  • Si el compilador usa una instrucción de carga distinta, un acceso que antes el kernel corregía podría dejar de corregirse
  • El compilador no está obligado a generar ensamblador que funcione con punteros desalineados, porque ese acceso en sí mismo ya es UB

Los tipos atómicos también son UB si están mal alineados

  • Incluso si se llama a store() o load() sobre un std::atomic<int>* como sigue, el comportamiento es UB si el objeto no está correctamente alineado
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • Desde la perspectiva del estándar, la pregunta de si esa operación es atómica sobre un objeto desalineado ni siquiera aplica
  • En hardware real la atomicidad puede ser un problema, pero según el estándar ya era UB antes de llegar a eso
  • Si el objeto que se cree leer atómicamente cruza una página, el problema se vuelve más complejo, pero la conclusión no es “está bien”, sino UB

Solo crear el puntero ya puede ser un problema

  • Con un puntero desalineado, incluso antes de desreferenciarlo, convertirlo a un puntero de cierto tipo ya puede ser problemático
    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.
            […]
    }
    
  • Aquí el problema no es la llamada a foo(), sino el cast (const int*)bytes
  • Según el estándar, también es posible que el compilador dé significado a los bits bajos de int*, como bits de recolección de basura o etiquetas de seguridad

El problema de pasar char a isxdigit()

  • El siguiente código parece simple, pero puede ser UB en arquitecturas donde char es signed si el valor de entrada sale del rango 0–127
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() es una función que verifica si un carácter es hexadecimal, y también puede recibir EOF
  • Según C23 7.4p1, EOF es de tipo int, por lo que se puede inferir que es un valor no representable como unsigned char
  • isxdigit() recibe int, no char, y aunque la conversión de char a int es posible, los valores negativos de signed char son el problema
  • Según C23 6.2.5 párrafo 20, que char sea signed o no depende de la implementación
  • Una implementación de isxdigit() como la siguiente puede leer memoria desconocida con un índice negativo
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • Si esa memoria pertenece a una región mapeada a I/O, podría no solo devolver valores arbitrarios o provocar un crash, sino incluso activar comportamiento de hardware
  • Eso es más probable en sistemas embebidos que en aplicaciones sobre sistemas operativos de escritorio, pero también hay casos en espacio de usuario donde la protección no basta, como drivers de red en espacio de usuario

El problema de convertir float a int

  • Código como el siguiente, que convierte segundos en float a milisegundos en int, es común pero contiene 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 establece que al convertir un valor finito de punto flotante real a un tipo entero, si la parte entera no puede representarse en ese tipo entero, el comportamiento es indefinido
  • Para valores no finitos tampoco hay una definición explícita, así que también es UB
  • Incluso comparar un float con INT_MAX no es tan simple
    • Hacer cast de float a int puede disparar justamente el UB que se intenta evitar
    • Hacer cast de INT_MAX a float no garantiza una representación exacta
    • Si INT_MAX se redondea en float a un valor ya no representable como int, la comparación deja de ser fiable
  • Para hacerlo seguro hacen falta una verificación con isfinite(), comparaciones con margen como INT_MIN + 1000 y INT_MAX - 1000, y una comprobación adicional antes de sumar después de la conversión
    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;
    }
    
  • Uno solo quiere convertir float a int, pero el código seguro termina siendo mucho más largo

Objetos en la dirección 0 y null pointer

  • En kernels de SO o código embebido pueden aparecer situaciones donde se quiera colocar un objeto en la dirección 0
  • En la práctica, puede decirse que no hay una forma útil de colocar realmente un objeto en la dirección 0 cumpliendo con el estándar de C
  • En C 6.3.2.3, la constante entera 0 convertible a puntero y nullptr son “null pointer constant”, y aquí se les puede llamar NULL
  • C no especifica que un puntero NULL real apunte a la dirección de máquina 0
  • El estándar de C trata con la máquina abstracta de C, no con el hardware, y solo garantiza que NULL y 0 se comparan como iguales
  • Esa igualdad podría deberse a que el entero 0 se convierte al valor nativo de NULL de esa plataforma, y ese valor incluso podría ser 0xffff
  • Desreferenciar un null pointer es UB sin importar cuál sea su valor, y es un ejemplo representativo en C 3.4.3
  • Por eso no se puede asumir que memset(&ptr, 0, sizeof(ptr)); crea un puntero NULL
  • Inicializar una estructura con ceros y asumir que sus punteros miembro son NULL es algo que también causa problemas reales a la mayoría de programadores
  • Históricamente también existieron máquinas con punteros NULL distintos de 0

El problema de asumir que hay una función en la dirección 0

  • Incluso si en una máquina moderna NULL apunta a la dirección 0 y realmente existe un objeto o función ahí, C 6.3.2.3 establece que NULL no es igual a ningún objeto ni función
  • Por lo tanto, el siguiente código es UB
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • Desde la perspectiva de C, eso significa “no hay ninguna función ahí”, y puede que no exista manera de expresar otra intención dentro del compilador
  • No se puede asumir simplemente que se emitirá una instrucción call a una dirección cuyos bits sean todos 0
  • En x86 de 16 bits ni siquiera está claro si “todos 0” significa 0000:0000 o CS:0000

Argumentos variables y discrepancias de tipos

  • El último argumento de execl() debe ser un puntero, así que pasar directamente la macro NULL o el entero 0 puede ser UB
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • La forma correcta es hacer cast explícito al tipo puntero
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • La macro NULL puede interpretarse como el entero 0, y en argumentos variables no se transmite la información de tipo necesaria
  • En printf() también hay UB si el especificador de formato no coincide con el tipo real del argumento
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Para imprimir uint64_t hay que usar PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Para imprimir uid_t, una opción puede ser hacer cast a uintmax_t y usar PRIuMAX, aunque ni siquiera está garantizado que uid_t sea unsigned
  • En el peor caso, en vez de -1 podría imprimirse un valor sin sentido

División entre cero y problemas de seguridad

  • Que dividir entre 0 sea UB es algo bien conocido, pero cuando el denominador proviene de una entrada no confiable eso se vuelve un problema de seguridad
  • Lo importante es que no se trata solo de un simple error en tiempo de ejecución, sino de UB en el límite de validación de entrada

No es UB, pero las promociones enteras también son peligrosas

  • Las reglas de promoción entera son difíciles de aplicar al ritmo de una lectura rápida del código, y pueden dar resultados contrarios a la intuición
  • En el siguiente código, overflowed no queda en 1 sino en 0
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • En el siguiente ejemplo, aunque todas las variables parezcan unsigned, el resultado no es 2147483648 (0x80000000) sino 18446744071562067968 (ffffffff80000000)
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • Aunque no sea UB, las reglas enteras de C/C++ no son intuitivas y facilitan la aparición de defectos

Detección de UB con LLM

  • Los LLM modernos, cuando se les pide encontrar UB en código C arbitrario, casi siempre detectan problemas y por lo general aciertan
  • Después de encontrar UB en código personal, se aplicó el mismo enfoque al código de OpenBSD, que es maduro y está escrito con bastante rigor
  • Al apuntar primero a la herramienta find, se descubrieron varios problemas
  • Se enviaron a OpenBSD parches por escritura fuera de rango y por un bug lógico que no era UB
  • No se enviaron parches para muchos otros UB que seguían ahí
    • Había experiencia previa de que el proyecto OpenBSD no había sido muy receptivo a reportes de bugs en el pasado
    • También se consideró que en la práctica podía no ser grave
    • Si OpenBSD quisiera eliminar UB de su base de código, haría falta un proyecto más grande que un flujo de parches individuales entre el LLM y el proyecto

Dirección realista para lidiar con bases de código C/C++

  • No se pueden tirar a la basura las bases de código C/C++ existentes, pero tampoco es opción dejarlas en un estado esencialmente roto
  • Hay que corregir UB a gran escala sin hacer commits de cambios de baja calidad generados por IA y sin sobrecargar a las personas revisoras
  • En 2026, escribir C o C++ sin supervisión de UB por parte de un LLM podría verse como una violación de SOX o como una irresponsabilidad
  • Si incluso desarrolladores de OpenBSD no lograron detectar todos estos problemas en más de 30 años, en otros proyectos las probabilidades son todavía peores
  • En proyectos personales, se puede pedir a un LLM que encuentre UB, lo explique si hace falta, lo corrija y luego una persona verifique el resultado
  • Aun así, para validar esos resultados hacen falta expertos, y normalmente esos expertos ya están ocupados con otras cosas
  • Esto parece una tarea de limpieza, pero es demasiado sutil para dejársela a programadores junior, que tradicionalmente eran quienes recibían ese tipo de trabajo

Material relacionado

1 comentarios

 
GN⁺ 4 시간 전
Comentarios en Hacker News
  • En C hay muchísimo comportamiento indefinido sorprendente y raro, pero este artículo realmente no lo muestra bien; apenas rasca la superficie
    Un ejemplo aún más extraño es volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Si x fuera solo int, no habría problema, pero si es volatile, pasa a ser comportamiento indefinido. Según el estándar de C, acceder a volatile ya cuenta como un efecto secundario solo con leerlo, los efectos secundarios no ordenados sobre el mismo objeto escalar son comportamiento indefinido, y la evaluación de los argumentos de una función no tiene un orden determinado entre sí
    Normalmente, una data race significa que distintos hilos acceden al mismo objeto al mismo tiempo y al menos uno escribe, pero en C puede surgir una situación parecida a una data race incluso en un solo hilo y sin escrituras

    • Como autor, estoy de acuerdo. El propósito de este texto no es enumerar los 283 lugares donde aparece la palabra undefined en el estándar, ni todos los casos indefinidos que surgen por omisiones
      El punto es que no se puede evitar. Al menos desde que apareció C en 1972, ningún ser humano ha logrado evitarlo por completo
      Si no se ha conseguido en 54 años, entonces “esfuérzate más” o “no cometas errores” no son soluciones. El defecto explotable que encontró Mythos en OpenBSD fue bastante bien valorado por los desarrolladores de OpenBSD, pero incluso al pasar herramientas sobre el código más simple aparecía una enorme cantidad de comportamiento indefinido
      Por ejemplo, que find lea la variable automática no inicializada status después de waitpid(&status) y antes de comprobar si waitpid() devolvió error también es comportamiento indefinido, aunque cuesta imaginar una arquitectura o compilador donde eso fuera explotable
      Como escribí en el artículo, no intento enumerar todo el comportamiento indefinido del mundo, sino señalar que todo código C/C++ no trivial contiene comportamiento indefinido
    • volatile es un hack del sistema de tipos. Debió resolverse de una manera más principista, y los lenguajes modernos no deberían copiarlo como si “C lo hizo, así que debe ser buena idea”
      Los primeros compiladores de C siempre volcaban los valores a memoria, así que si un puntero apuntaba a hardware de entrada/salida mapeado en memoria, cada cambio en x generaba una instrucción de CPU que realmente escribía en memoria y el código del driver funcionaba
      Pero cuando llegaron las optimizaciones, el compilador vio que solo se seguía modificando x y empezó a dejarlo en registros, con lo cual el driver se rompió. volatile en C es un hack para decirle al compilador “esa optimización no la hagas”, mientras que la solución correcta, proveer intrinsics de E/S mapeada en memoria a nivel de biblioteca, habría sido un trabajo mucho mayor
      La razón por la que hacen falta intrinsics es que permiten expresar con precisión qué operaciones son posibles y cuáles no. En algunos destinos, escrituras de 1 byte, 2 bytes y 4 bytes producen comportamientos distintos, y el hardware los distingue. Algunos dispositivos esperan una escritura RGBA de 4 bytes, y si les emites cuatro escrituras de 1 byte pueden confundirse o no funcionar. Algunos destinos incluso soportan escrituras a nivel de bits. Con solo volatile, no hay manera de saber qué está ocurriendo ni qué significa
    • Hay que distinguir entre comportamiento indefinido y race conditions. Esa distinción suele faltar en estas discusiones
      Si compilas un programa en C y luego lo desensamblas, obtienes un programa en ensamblador sin comportamiento indefinido. Eso es porque en ensamblador no existe el concepto de comportamiento indefinido
      El comportamiento indefinido es una propiedad del programa fuente, no del ejecutable. Significa que la especificación del lenguaje en que se escribió el código fuente no le asigna significado al programa. En cambio, el ejecutable compilado sí recibe significado por la especificación de la máquina
      Una race condition es una propiedad del comportamiento del programa. Por eso se puede decir que un programa en C tiene comportamiento indefinido, pero no que necesariamente el ejecutable tenga una race real. Claro, el compilador puede compilar arbitrariamente un programa con comportamiento indefinido e introducir una race, pero si lo compila sin crear hilos nuevos, entonces no hay race
    • El significado de volatile es precisamente que el valor puede ser cambiado por otra cosa. Si es una variable global, esa otra cosa puede ser otro hilo, pero también una interrupción o un signal handler. Si es un puntero que lee una dirección concreta, podría ser un registro de dispositivo de hardware cuyo valor cambia
      El concepto de variable volatile en sí no es el problema. Si un lenguaje quiere soportar rutinas de interrupción y E/S mapeada en memoria, necesita una forma de decirle al compilador que leer dos veces el mismo registro de hardware no es lo mismo que leer dos veces la misma ubicación de memoria
      El verdadero problema es que la interacción entre las funciones del lenguaje y sus restricciones no quedó bien resuelta. Si ya especificaste “este valor puede cambiar en cualquier momento”, entonces es absurdo considerar ciertos usos como comportamiento indefinido justamente por esa razón. Para las variables volatile debería haber existido una excepción en la definición de “efectos secundarios no ordenados”
    • El punto central del artículo es que ni siquiera hace falta escribir código raro para encontrarse con comportamiento indefinido
      Mucha gente cree erróneamente que C y C++ son “muy flexibles porque te dejan hacer lo que quieras”. En realidad, casi toda técnica que parece poderosa e impresionante es un campo minado de comportamiento indefinido
  • El comportamiento indefinido de los punteros desalineados es peor todavía. Un puntero desalineado es comportamiento indefinido no solo al acceder a través de él, sino por el mero hecho de existir como puntero
    Por eso, convertir implícitamente void* v a int* i, por ejemplo con i=v en C o al pasar f(v) a una función que recibe int*, también es comportamiento indefinido si el puntero resultante no satisface la alineación requerida para int
    Es importante que esto sea un problema a nivel de C. Si un programa en C tiene comportamiento indefinido, entonces formalmente no es válido y es un programa incorrecto. No es un problema de hardware, ni tiene que ver con crashes o fallos
    La conversión de void* a int* normalmente no genera ninguna instrucción de hardware, y como los tipos existen solo en C, el hardware tampoco se cae por ese cast. Podrías pensar que si es solo un valor entero en un registro, no pasa nada, pero el punto no es si en hardware el puntero es “realmente” un entero, sino que en el momento en que haces el cast a un puntero desalineado, el programa en C ya quedó roto por definición

    • Como autor, correcto. Eso se trata en la sección del artículo “Actually, it was UB even before that”
      También quise transmitir que el comportamiento indefinido no está en el hardware y no tiene relación con crashes o fallos. Al mismo tiempo, quería mostrar ejemplos a la gente que dice “pero si se ve que funciona bien”, y en realidad no es así
    • Es algo normal y predecible. Un buen programador sabe que los casts de punteros son claramente una zona con peligros
    • ¿Puedes indicar en qué parte del estándar dice que el mero puntero desalineado ya es comportamiento indefinido?
    • Entonces, si hago una estructura con #pragma pack(push, 1), ¿significa que no puedo usar punteros a miembros salvo que por casualidad estén alineados?
    • El concepto de comportamiento indefinido en C originalmente significaba darle libertad al compilador para mapear el código al hardware aunque las instrucciones de máquina variaran un poco entre arquitecturas. El mismo programa en C podía expresar comportamientos distintos según la arquitectura donde corriera
      Ese tipo de comportamiento indefinido está bien, y casi nadie considera un gran problema que aparezcan bugs por diferencias de hardware
      Pero con el tiempo, interpretaciones agresivas transformaron C en una especie de lenguaje de design by contract implícito, y las restricciones quedaron invisibles. Eso crea un problema parecido al de RAII, donde las llamadas implícitas al destructor no se ven
      En C, al desreferenciar un puntero, el compilador añade implícitamente una restricción de no-null a la firma de la función. Si pasas a una función un puntero que podría ser null y no hay una comprobación ni una aserción, en vez de marcar error por falta de chequeo, el compilador propaga silenciosamente esa restricción de no-null sobre el puntero. Si logra demostrar que esa restricción es falsa, marca la función como inalcanzable, y una llamada a una función inalcanzable vuelve inalcanzable también a la función que la llama
  • Las 5 etapas de aprender sobre comportamiento indefinido en C
    Negación: “Yo sé qué pasa con el overflow con signo en mi máquina”
    Ira: “¡Este compilador es basura! ¿Por qué no hace lo que le dije?”
    Negociación: “Voy a mandar esta propuesta a wg14 para arreglar C…”
    Depresión: “¿Hay algo de código C en lo que se pueda confiar?”
    Aceptación: “Simplemente no uses comportamiento indefinido

    • ¿En qué etapa entra “haz que el compilador defina lo indefinido”?
      Los accesos desalineados se resuelven usando structs empaquetadas. El compilador genera mágicamente el código correcto. En realidad, el compilador siempre supo hacerlo bien; simplemente no lo hacía
      Las reglas de strict aliasing se resuelven usando type punning con unions. En cualquier compilador importante está documentado que funciona aunque el estándar no lo diga. O si no, se desactiva con -fno-strict-aliasing. Así puedes reinterpretar memoria como quieras, y aunque habrá bordes filosos, al menos no vendrán del compilador
      El overflow se define con -fwrapv. Si reemplazas +, -, * por __builtin_*_overflow, además obtienes chequeo explícito de errores gratis. La interfaz funcional es buena y además genera código eficiente
      La verdadera aceptación se parece más a “la gente normal no se preocupa por el estándar de C”. El estándar es pésimo y lo que importa es el compilador. Los compiladores tienen muchísimas funciones muy útiles para esquivar la mayoría de estos problemas. La razón por la que la gente no las usa es porque quiere escribir C “portable” y “estándar”, y la verdadera aceptación es salir de esa forma de pensar
      Con esa lógica hice un intérprete de Lisp en C freestanding y además pasó UBSan. Al principio pensé que iba a explotar, pero no fue así, y si yo puedo hacerlo, cualquiera puede
    • Como autor, el punto del artículo es que “simplemente no uses comportamiento indefinido” es imposible
      Mientras los humanos sigan escribiendo código, eso no puede ser el estado final. Ningún ser humano puede evitar por completo el comportamiento indefinido en C/C++
    • “Simplemente no uses comportamiento indefinido” a lo mucho todavía suena como la etapa de negociación
    • Trabaja como yo en dispositivos embebidos. Es realmente cómodo escribir software para una CPU específica
    • En C, la aceptación se parece más a “voy a usar comportamiento indefinido, y algún día algo malo va a pasar”
  • Los ejemplos se parecen más a casos que pueden convertirse en comportamiento indefinido según la entrada o la situación, que a comportamiento indefinido real
    Si lo defines tan ampliamente, entonces cualquier llamada a función también sería comportamiento indefinido porque puede agotarse el espacio de stack. De hecho, en casi cualquier lenguaje podría decirse algo parecido en ese sentido
    C ya tiene suficientes asperezas reales y notables; este tipo de sensacionalismo puede distraer, especialmente a principiantes, y terminar siendo perjudicial

    • Ada 83 no deja el desbordamiento de la pila de llamadas como comportamiento indefinido. En el manual de referencia está definida la excepción STORAGE_ERROR
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      Ahí dice que esta excepción también puede ocurrir “si no hay suficiente espacio de almacenamiento durante la ejecución de una llamada a subprograma”
    • Eso no es correcto en absoluto
      Para empezar, sí es posible definir qué ocurre cuando se agota el espacio de stack. Además, no todos los programas requieren una pila de tamaño arbitrario; algunos solo necesitan una cantidad constante calculable de antemano. Algunas implementaciones de lenguajes ni siquiera usan pila
      El lenguaje también podría ofrecer herramientas para consultar el espacio de stack restante y dar garantías en función de eso. O podría permitir instalar un manejador que se ejecute cuando se agote el stack
    • El comportamiento indefinido que depende de la entrada también puede ser una ruta de explotación
    • Los ejemplos son claramente comportamiento indefinido. Punto
      La manera correcta de pensarlo es que, en el instante en que ocurre comportamiento indefinido, ya no estás bajo la protección del estándar del lenguaje. Puede seguir funcionando un rato, tal vez para siempre. Pero en la práctica quedas a merced, sin saberlo, de los caprichos de la toolchain, del cambio o actualización del compilador, de la arquitectura, del runtime o de la versión de libc
      Al final construyes sobre arena, y ese es el peligro del comportamiento indefinido
    • Este artículo está muy cerca de ser la definición misma de FUD
  • El problema del comportamiento indefinido no es que pueda causar un crash en alguna arquitectura
    El verdadero problema es que el compilador espera que ese código nunca ocurra. Si aun así escribes código con comportamiento indefinido, el compilador, especialmente el optimizador, puede traducir el camino “normal” de cualquier forma que le resulte conveniente. Y a veces ese “cualquier forma” puede ser muy inesperado, como eliminar grandes bloques de código

    • Un ejemplo relacionado es la condición de que toda función debe terminar o producir efectos secundarios. Aún no me ha pasado directamente, pero es fácil imaginar una situación en la que alguien escribe por error un loop infinito o una recursión infinita y la función termina siendo eliminada
      Si además entra tail recursion, incluso podría darse que en build de debug no llegues al loop infinito, pero que el bug solo aparezca al subir el nivel de optimización
    • Un crash es de las formas más benignas de comportamiento indefinido. Al menos se ve claramente
      En casos peores, el programa puede seguir corriendo silenciosamente con valores basura, formatear el disco duro o entregarle al atacante las llaves del reino
    • Sí, pero esa también es la función más útil del comportamiento indefinido y la razón de que exista
      La gente que propone simplemente definirlo o volverlo comportamiento no especificado no entiende que la clave es que el compilador pueda eliminar grandes partes del programa
      Si escribes código que se vuelve comportamiento indefinido para ciertas entradas, entonces para esas entradas estás expresando que el programa no debe tener ningún comportamiento. Quieres que el compilador pueda optimizar fuera ese camino o hacer lo que ayude al comportamiento de los otros casos definidos
      Se siente bastante satisfactorio meter una cadena de log que solo sería alcanzable mediante comportamiento indefinido y luego ver que la cadena ni siquiera queda en el binario
    • Me llamó especialmente la atención la parte del artículo donde dice que no es un problema de optimización
      Hace tiempo escribí un pass de análisis bajo el supuesto de que correría al final del pipeline de transformaciones, y esa suposición era necesaria para la corrección. Como ya no habría más optimizaciones, me parecía seguro, pero ahora ya no estoy tan seguro
    • Eso no es un problema, es una funcionalidad
  • Llevo 20 años usando C, pero nunca había visto tanta conversación sobre comportamiento indefinido como en estos últimos 6 meses en Hacker News
    En conversaciones reales casi nunca aparecía. Uno escribe código, y si no funciona lo depura y lo arregla o lo rodea. No entiendo por qué el tema del comportamiento indefinido en C sigue apareciendo tan seguido en portada

    • Hacker News sigue inclinándose más por los lenguajes de programación que por la programación real. Tal vez también influya la herencia Lisp de Y Combinator
      Siempre ha habido una minoría constante de gente de ciencias de la computación que considera que desarrollar o usar un lenguaje nuevo es lo más interesante del mundo, y algunos siguen pensándolo
      Es natural que esa gente se interese por temas de diseño de lenguajes, y el comportamiento indefinido en C entra en esa categoría. Aunque gran parte de esto originalmente venía de intentar acomodar arquitecturas de CPU antiguas sin perder rendimiento, así que llamarlo una “decisión de diseño” es un poco dudoso, casi como decir que las ruedas son redondas por una decisión de diseño
    • ¿De qué hablas? Yo ya usaba C y C++ hace 20 años, y en ese entonces el comportamiento indefinido tenía muchísimo peso tanto en las conversaciones como en la enseñanza
      Hubo varios “escándalos” bastante conocidos cuando, alrededor de GCC 3.2, los compiladores empezaron a explotar el comportamiento indefinido de manera mucho más agresiva en las optimizaciones, y por eso mucha gente se quedó por bastante tiempo en GCC 2.95. GCC 3.2 salió en 2002
    • Las computadoras de antes eran geniales, y las de ahora se volvieron peligrosas
      Como todas las empresas siguen enfatizando la seguridad y la exposición, es decir, salir en las noticias, la narrativa contra lo “inseguro” se volvió desproporcionadamente grande
      El nuevo mundo se parece a gente de ciudad que nunca ha visto naturaleza de verdad y se asusta al ver una podadora. ¿Qué? ¿Tiene cuchillas girando? ¡No puede ser!
    • Como el entorno operativo puede ser una arquitectura totalmente distinta, estos detalles son muy importantes
      Si tu objetivo real es un pequeño sistema embebido encima de una torre de comunicaciones en medio de la nada, “funciona en mi máquina” no sirve de nada. Claro, la mayoría no trabaja en eso, y aquí probablemente la mayoría de los desarrolladores sean web developers, pero sigue siendo una discusión interesante aunque no lo hayas vivido directamente. De hecho, quizá por eso mismo
    • Más exactamente, no se escribe contra una especificación imaginaria, sino contra el objetivo de destino. La especificación solo sirve para predecir aproximadamente qué hará el objetivo, no es normativa
      Un compilador puede tener bugs donde algo debería funcionar según la especificación y no lo hace, también hay muchas extensiones sin equivalente en el estándar, y hay comportamientos que el estándar deja indefinidos pero que en una implementación concreta reciben un resultado útil
  • En general estoy de acuerdo con la introducción, pero los ejemplos son malos y todo el artículo parece estar envuelto para empujar el coding con LLM

    • Sí. Los ejemplos, uno por uno, son cosas estándar que se evitan al escribir código portable, o cosas innecesarias como acceder a un objeto en la dirección 0
      Da la impresión de alguien que quiere escribir cualquier código de cualquier manera y que funcione igual en todos los entornos. Si hicieras un lenguaje así, perderías la ventaja de poder escribir ajustado a la plataforma cuando quieras
    • ¿En qué sentido son malos? Si es cierto, eso es bastante serio
  • El código C++ del artículo en parte no era idiomático desde hace más de 10 años, y hoy podría considerarse code smell
    El lenguaje evolucionó bastante y ya no es el mismo que era cuando nació. En cuanto vi tantos raw pointers y tanto acceso directo por punteros, quedó claro que había que tomar parte del texto con pinzas
    Otro problema evidente es la perspectiva de meter C y C++ en la misma bolsa como si fueran casi el mismo lenguaje. Hoy en día en realidad ya están bastante separados

    • Iba a señalar que el código no era C++ sino C, pero revisé otra vez y efectivamente decía std::atomic y no atomic_int
  • ¿Es correcto entender así el comportamiento indefinido en C?
    Un programa P tiene un conjunto de entradas A que no provocan comportamiento indefinido, y un conjunto complementario B que sí lo provoca
    Un compilador correcto compila P a un ejecutable P'. Para toda entrada de A, P' debe comportarse igual que P
    Pero para cualquier entrada de B, no hay ningún requisito sobre el comportamiento de P'

    • Intuitivamente sí. El programa se compila como si jamás fueran a llegar entradas de B, y eso puede incluir eliminar el código que intenta detectarlas
    • Buen resumen
  • Un ejemplo concreto de comportamiento indefinido causado por punteros desalineados: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • Es un caso en x86, donde mucha gente suele asumir que no debería haber problema