3 puntos por GN⁺ 2023-07-15 | 3 comentarios | Compartir por WhatsApp
  • Para compilar más software C existente con Cosmopolitan Libc, no bastó con engañar al sistema de build: hizo falta un parche de GCC de unas 2,000 líneas para que el compilador transformara automáticamente el código problemático
  • El obstáculo era que valores del sistema como SIGTERM y EINVAL no son constantes en tiempo de compilación, así que chocan con las reglas de C para etiquetas switch case e inicialización de static y const struct
  • Se intentó con sed, expresiones regulares en Python, plugins de GCC e incluso interceptar macros del preprocesador, pero como los errores de case aparecen durante el parseo, era difícil resolverlo solo con plugins
  • El parche final se activa con la bandera -fportcosmo, y hace que GCC reemplace switch por if/goto justo antes de emitir el error, además de complementar las inicializaciones de estructuras llenando valores en tiempo de ejecución
  • Siguen existiendo límites con estructuras anónimas, enum, algunos const int e índices de arreglos dentro de expresiones de inicialización, pero se abre el camino para compilar software como bash, curl, git, ninja y gcc sin modificar el código fuente

Objetivo del parche de GCC

  • Se creó un parche de gcc de unas 2,000 líneas para facilitar la creación de Actually Portable Executables con Cosmopolitan Libc
  • Tras aplicar el parche, es posible compilar el siguiente software con sistemas de build ./configure o cmake
    • bash
    • curl
    • git
    • ninja
    • gcc mismo
  • Los ejecutables compilados deberían poder correr en Linux, FreeBSD, MacOS, OpenBSD, NetBSD y Windows
    • Se aclara que la ejecución en Windows no fue probada directamente
  • Los binarios construidos con GitHub Actions se pueden descargar desde superconfigure releases

Dónde se trababa el port de Cosmopolitan Libc

  • Después de que Cosmopolitan Libc llamara la atención en 2021 con el servidor web redbean, una pregunta importante pasó a ser qué tan bien podía ejecutar software C existente sobre esta libc
  • Al portar runtimes de lenguaje como Lua, Wren, Janet y quickjs, el mismo patrón se repetía
  • Usar constantes del sistema como SIGTERM y EINVAL en etiquetas case dentro de switch chocaba con las reglas de compilación de C
  • El estándar de C exige que las etiquetas case sean constantes en tiempo de compilación
    • En Cosmopolitan Libc, las macros del preprocesador C relacionadas con interfaces del sistema debían ser simbólicas
    • Como resultado, código común como switch(errno) { case EINVAL: ... } podía no compilar

Convertir switch en if y goto

  • Si etiquetas como case EINVAL o case ENOSYS dentro de switch(errno) no son constantes en tiempo de compilación, GCC emite el error case label does not reduce to an integer constant
  • El parche transforma automáticamente la sentencia switch en una estructura como esta
    • Agrega una rama por cada valor case, con forma if (errno == EINVAL) goto caselabel_EINVAL;
    • Agrega un goto que salta a la etiqueta default
    • Reorganiza el bloque original de case usando etiquetas y goto endofthis_switch
  • No es la solución más elegante, pero permitió cubrir casos encontrados en varias bases de código
  • Como también había que manejar switch mezclados con ifdef y fallthrough, una transformación basada en goto era una opción simple

La inicialización de struct también sufre la misma restricción

  • El mismo problema de constantes en tiempo de compilación aparece al inicializar static struct o const struct
  • Si EINVAL no es una constante en tiempo de compilación, algunas de las siguientes inicializaciones no son válidas en C
    • Inicialización de un struct local normal dentro de una función sí es posible
    • const struct
    • static struct
    • const struct global
    • static struct global
  • Un caso real aparece en el módulo faulthandler de CPython
  • La solución fue inicializar la estructura con valores dummy y luego agregar código para llenar los valores correctos antes de usarla

Límites del reemplazo de texto y los plugins de GCC

  • Al principio se intentó automatizar la transformación con scripts de shell usando sed, y luego con scripts de Python y expresiones regulares para manejar también el fallthrough
  • Pero por culpa del preprocesador de C y de ifdef, era difícil que el reemplazo de texto funcionara por completo
    • Porque no es fácil saber de antemano qué ifdef estarán activos al compilar
  • El siguiente intento fue usar un plugin de GCC para hacer una transformación del AST
    • Los plugins de GCC pueden cargarse con -fplugin=your-plugin.so
    • Se puede acceder al AST desde eventos como PLUGIN_PRE_GENERICIZE, PLUGIN_FINISH_PARSE_FUNCTION y PLUGIN_FINISH_DECL
    • Se puede imprimir el AST con debug_tree y recorrerlo con walk_tree_without_duplicates
  • El problema era que la validación de etiquetas case ocurre durante el parseo en GCC
    • El plugin solo puede acceder al AST después de que termina el parseo
    • Una etiqueta incorrecta como case SIGTERM: no queda en el AST
    • GCC ya emitió el error, así que al plugin le cuesta corregirlo después

Experimento interceptando macros del preprocesador

  • Como solo con plugins era difícil evitar errores previos al parseo, se probó un enfoque que interactuaba con el preprocesador de C
  • A través de la estructura cpp_reader de los headers para plugins de GCC, era posible registrar callbacks cuando una macro se #define, se #undef o se usa
  • Aprovechando la estructura de las macros de valores del sistema en Cosmopolitan Libc, se insertaron constantes temporales
    • Ejemplo: extern const int SIGTERM;
    • Ejemplo: #define SIGTERM SYMBOLIC(SIGTERM)
    • Para el plugin, se ponían valores temporales como static const int __tmpcosmo_SIGTERM = ...
  • El plugin interceptaba el uso de SYMBOLIC(SIGTERM), lo reemplazaba por el valor temporal y luego, en la etapa PLUGIN_PRE_GENERICIZE, volvía a convertir el AST al VAR_DECL correcto
  • Este hack con macros logró esquivar los errores de switch e inicialización de struct
  • Con este método, al menos también fue posible compilar CPython 3.11

Por qué se modificó GCC en lugar de usar un plugin

  • Tras discutirlo con Justine Tunney, y comprobar que el problema sí tenía solución, se propuso un enfoque más simple: modificar directamente el código de GCC
  • El plugin y los hacks con macros implicaban demasiado trabajo innecesario, y complicaban el manejo de edge cases y crashes
  • Por ejemplo, aunque solo hiciera falta transformar case ENOSYS, por el reemplazo de macros el plugin también tenía que hacerse cargo de usos normales como ENOSYS + 1 o printf("ENOSYS = %d", ENOSYS)
  • Con el parche a GCC, la función se activa con la bandera -fportcosmo en vez de usar un -fplugin aparte
  • Justo antes de que GCC emita el error case is not constant, verifica si flag_portcosmo está activa y, si lo está, realiza las sustituciones necesarias
  • El código de transformación del AST que antes vivía en el plugin se movió dentro de GCC para ejecutarse antes de invocar otros callbacks de plugins
  • Con este cambio se pudo eliminar el código hacky relacionado con macros

Builds reales y expansión del parche

  • Para el 5 de junio de 2023, el GCC parcheado ya pasaba todos los casos de prueba del plugin anterior, y se agregaron nuevos binarios al monorepo de Cosmopolitan Libc
  • A medida que se compilaba más código, siguieron llegando correcciones y mejoras
  • El build de lua se simplificó, y al encontrar dónde aparecía el error de case constant en g++, también se logró compilar ninja
  • En el proceso de agregar ncurses a Python 3.11 apareció un problema nuevo
    • En ncurses había código con expresiones binarias en valores de inicialización de estructuras, como DATA2(PARODD | PARENB, PARODD)
    • El parche anterior manejaba inicializaciones con constantes simples, pero no expresiones como PARODD | PARENB
  • Después, el parche se amplió para permitir que las etiquetas case de C y los elementos de inicialización de estructuras pudieran ser expresiones arbitrarias
    • Según el estándar de C, las etiquetas case siguen teniendo que ser constantes en tiempo de compilación
    • El compilador parcheado emite una advertencia en esos casos, pero continúa compilando
    • C++ quedó fuera de esta sustitución de expresiones arbitrarias porque no encaja con el manejo de constexpr en g++11
  • Tras este cambio, ncurses se compiló sin problemas

Restricciones que siguen y conclusión práctica

  • El parche no es perfecto
    • No puede manejar algunas estructuras anónimas
    • No puede manejar algunos casos de enum ni de const int
    • No puede manejar casos donde SIGTERM se usa como índice de arreglo dentro de una expresión de inicialización
    • Pueden seguir apareciendo crashes raros del compilador con inicializaciones static extrañas o cuando SIGTERM se guarda en un static const int8_t
  • Aun así, se eliminaron muchos contraejemplos claros y una gran cantidad de software popular compila sin fricción
  • Un entorno de pruebas más estricto podría revelar más puntos a mejorar
  • Si se quiere, todavía se pueden corregir manualmente las sentencias switch y las expresiones de inicialización de struct, pero en muchos casos el compilador puede encargarse automáticamente
  • Si un software C puede compilarse de forma estática, y especialmente si también compila con musl, es probable que también pueda compilarse con Cosmopolitan Libc

3 comentarios

 
ryuheechul 2023-07-15

¿Ahora neo está yendo hacia un formato en el que los mensajes van envueltos en bloques de código? Tener que hacer desplazamiento horizontal y el color de texto en tonos rojos me deja un poco insatisfecho...

 
xguru 2023-07-16

Ah, es un bug que se envuelva en un bloque de código :(

 
GN⁺ 2023-07-15
Comentarios en Hacker News
  • Yo escribí este artículo, pero el título debería ser “Patching GCC to build Actually Portable Executables”, porque se refiere a Cosmopolitan Libc y al formato Actually Portable Executable de jart
    Con mi parche para GCC, ahora se puede compilar software como vim, emacs, ninja, bash, git y gcc con Cosmopolitan Libc usando los sistemas de build habituales tipo autotools/cmake. Los ejecutables compilados deberían poder correr en Linux, FreeBSD, MacOS, OpenBSD, NetBSD y Windows. Aunque Windows todavía no lo he probado
    Aquí está la lista del software que he compilado con esta técnica: https://github.com/ahgamut/superconfigure
    El script superconfigure no es más que un wrapper del script configure normal que se usa para compilar software, y solo agrega flags como --enable-static
    Si quieres intentar compilar GCC con Cosmopolitan Libc, puedes usar este repositorio: https://github.com/ahgamut/musl-cross-make/tree/gccbuild

    • Me pregunto si hay alguna posibilidad de que este parche llegue al upstream de GCC. Los ejecutables APE y cosmopolitan libc son tecnologías increíblemente geniales, y sería bueno que fueran más fáciles de usar
    • Está bueno. Me pregunto si parece probable que esto cause problemas al usarlo detrás de lenguajes transpilados como Nim
    • Me pregunto si intentaste compilar también busybox
    • Vi que hace poco en lobste.rs te bloquearon por ser un supuesto sockpuppet de jart, y recién ahora me doy cuenta de que en realidad eres otra persona
      Ojalá te traten mejor en HN
    • Que compile por sí solo no es un logro tan grande; lo más interesante es si realmente funciona
      Por ejemplo, me pregunto si hubo suficientes pruebas como para demostrar que no hay regresiones comparado con la versión de glibc
  • Hay un issue relacionado en el repositorio de Go: https://github.com/golang/go/issues/51900

  • En general está bastante genial, pero me da curiosidad la base teórica. Si se crea un nuevo destino de sistema operativo, lógicamente parecería correcto definir nuevas constantes
    En vez de pasar tal cual las constantes de cada sistema operativo subyacente, se podrían transformar para que las constantes de cada sistema operativo coincidan en todas partes. Entonces el código que usa constantes como índices de arreglos o en sentencias case también funcionaría correctamente
    Puede que eso agregue más overhead que el enfoque explicado en el artículo, pero parece mucho más fácil y también más simple de usar para quien quiera compilar programas nuevos con Cosmopolitan libc

    • Crear nuevas constantes haría la compilación mucho más fácil, pero personalmente creo que el overhead de convertir entre las constantes existentes y las nuevas en tiempo de ejecución sería demasiado alto
      Aquí se puede ver la lista de constantes: https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
      Cada vez que se use una de esas constantes, habría que meter muchas constantes en el binario en una tabla de consulta grande, y cada vez que el programa necesitara revisarlas tendría que pasar por esa tabla. Puede que no sea lentísimo, pero creo que sí se notaría
      El objetivo era facilitar el porting sin cambiar demasiado el código fuente de la libc ni del software que se quiere portar, y también producir binarios con rendimiento similar o mejor. Con esas restricciones, este parche para GCC parecía la mejor manera de simplificar el proceso
      Si me topo con suficientes codebases que usen SIGHUP para inicializar índices de arreglos, pensaré en probar el enfoque que propones para medir el punto de equilibrio. O también puedes probarlo tú y decirme si un conjunto separado de constantes funciona mejor
  • Si “se invirtió bastante tiempo en eliminar contraejemplos obvios y mucho software popular compila sin problemas”, entonces ayudaría publicar una lista del software que compila correctamente. Quizá ya exista

    • Se puede compilar software como vim, emacs, ninja, bash, git y gcc. La lista de lo que he compilado con esta técnica está aquí: https://github.com/ahgamut/superconfigure
      El script superconfigure no es más que un wrapper del script configure normal que se usa para compilar software, y solo agrega flags como --enable-static
  • Se preguntan si APE tiene un propósito práctico. ¿Hay alguien que realmente distribuya un solo ejecutable que deba correr en varios sistemas operativos?

    • En Logfile Navigator(https://lnav.org) usan un ejecutable APE como agente para comunicarse con hosts remotos
      lnav en sí no se compila como APE, pero el agente integrado sí. Cuando un usuario quiere leer los logs de ese host, envían ese agente de forma remota
      Así se evita tener que detectar por separado el tipo de sistema operativo o agregar un paso extra para incluir varias versiones del ejecutable. Hay una nota breve al respecto aquí: https://lnav.org/2021/05/03/tailing-remote-files.html
    • Vienen a la mente los programadores de Java/Clojure/Kotlin, los de .Net, JS, Python, Ruby, Erlang y todos los programadores de lenguajes basados en máquina virtual
      Los programas compilados estáticamente están en la base de todo, pero a nivel de aplicación son minoría
      APE plantea la gran pregunta: “Si un lenguaje AOT de bajo nivel como C pudiera compilarse una sola vez y ejecutarse en cualquier parte, ¿quién necesitaría una máquina virtual?”
    • Por ejemplo, parece que podría facilitar un poco la distribución de ejecutables en lugares donde es incómodo tener varios artefactos para la misma combinación de id/version, como un repositorio maven. PyPI probablemente tenga un problema similar
      Además, la compilación cruzada es dolorosa. Muchos proyectos pequeños de código abierto que no tienen una granja de compilación con varios BSD ni siquiera distribuyen binarios para esas plataformas
      Ni siquiera kubectl tiene binarios para BSD, aunque sí para Linux. A menos que no cuentes a OSX como BSD. Así que para proyectos así parece una ventaja fácil de obtener
    • Mis próximos side projects quiero publicarlos de esa manera. Incluso compilo ejecutables Redbean para SPA solo del lado del cliente. Me impresiona la idea de que estas cosas simplemente funcionen en varios sistemas
    • Hay un compilador de shaders al que le vendría bien un formato de ejecutable que sea independiente de la plataforma y ejecutable de inmediato
      Es demasiado grande como para compilarse al vuelo en la máquina del usuario. Tiene varias dependencias grandes de C++ de terceros, y según el hardware tarda entre 2 y 20 minutos en compilar
      Tiene que funcionar en macOS x86-64+ARM, Linux x86-64 y Windows x86-64. También estaría bien ARM Linux
      Por ahora están evaluando WASM, pero eso requiere un runtime WASI instalado
  • Para quienes estén confundidos: “Actually Portable Executables” https://justine.lol/ape.html

    • Agregué un comentario mencionándolo, pero todavía no se ha reflejado. Supongo que pronto cambiará. ¿Estará bien invocar a @dang aquí?
  • Cambiar switch por if es bastante tosco. Parece que podría arreglarse mapeándolo a un conjunto de constantes neutrales a la plataforma y desreferenciando símbolos de runtime como funciones
    Por ejemplo, usar algo como switch(CosmoMappErrno(EINVAL)) y case COSMO_EINVAL: evitaría convertir el código en un bloque de gotos

    • Pero eso no ayudaría a compilar código sin modificar. No hace falta preocuparse por los gotos generados dentro del compilador. El compilador ya hace ese tipo de cosas por todos lados
      Claro, sería distinto si te refieres a que el compilador reconozca estos switch y, en vez de reescribirlos como árboles de if, cambie las labels por constantes especiales propias de cosmo para cada valor, y envuelva la entrada del switch en una llamada a función que mapee el valor de la plataforma actual en runtime a esa constante propia de cosmo. Dejando pasar sin cambios los demás valores
      En realidad eso podría ser una transformación de compilador más simple. Pero introduce la complejidad de tener que reconocer cuál de varios conjuntos de constantes se está usando y aplicar la llamada de mapeo adecuada a la entrada del switch. También habría que crear esos mapeos del lado de la biblioteca, así que hace falta más complejidad
      Como ya hay una implementación que funciona con otra forma de transformación, no sé si el autor querrá implementar esto
      Por último, este enfoque de mapeo corre el riesgo de mapear mal, y también tiene detalles molestos como que el código que usa valores errno negativos tendría que invertir la entrada antes del mapeo
    • La idea de desreferenciar símbolos de runtime como funciones suena interesante. Si muestras un ejemplo que funcione, con gusto lo probaría
      La razón por la que me gusta el arreglo de if-else-goto es que encajó perfectamente con otras partes de GCC. Si ves mi parche[1], puedes notar que para agregar esta función apenas cambié un poco del código existente de GCC
      [1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2
    • ¿De verdad hay que preocuparse por si la sustitución se hace automáticamente?
  • No entiendo por qué sorprende tanto el código que usa SIGxxx como índice. Los números de señal están bien definidos, por lo general son continuos y se sabe que empiezan en 1

    • Antes de probar busybox compilé varias bases de código como git, bash, gcc y curl, y nunca había visto ese tipo de código
      Además, ese error apareció junto con un error del compilador de mi parche, que me tomó mucho tiempo depurar, así que fue una sorpresa que se me quedó grabada
      Me pregunto si realmente consideran común ese patrón de código. También me gustaría saber en qué bases de código aparece además de busybox. Si hay muchos ejemplos, quizá valga la pena invertir tiempo en actualizar el parche para manejar ese patrón
  • Si errno es un problema tan grande, no veo qué esperanza habría de ejecutar la biblioteca estándar de C++ y std::error_code
    Ahí no hay forma de usar macros mágicos

    • Parece que el problema eran esas macros mágicas. Normalmente las macros se expanden a constantes, pero ahora ya no, y por eso algunas cosas se rompen
      std::error_code no es una constante, así que no debería tener el mismo problema