Parchear GCC para compilar Actually Portable Executables
(ahgamut.github.io)- 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
SIGTERMyEINVALno son constantes en tiempo de compilación, así que chocan con las reglas de C para etiquetasswitch casee inicialización destaticyconst struct - Se intentó con
sed, expresiones regulares en Python, plugins de GCC e incluso interceptar macros del preprocesador, pero como los errores decaseaparecen durante el parseo, era difícil resolverlo solo con plugins - El parche final se activa con la bandera
-fportcosmo, y hace que GCC reemplaceswitchporif/gotojusto 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, algunosconst inte índices de arreglos dentro de expresiones de inicialización, pero se abre el camino para compilar software comobash,curl,git,ninjaygccsin modificar el código fuente
Objetivo del parche de GCC
- Se creó un parche de
gccde 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
./configureocmakebashcurlgitninjagccmismo
- 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
SIGTERMyEINVALen etiquetascasedentro deswitchchocaba con las reglas de compilación de C - El estándar de C exige que las etiquetas
casesean 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 EINVALocase ENOSYSdentro deswitch(errno)no son constantes en tiempo de compilación, GCC emite el errorcase label does not reduce to an integer constant - El parche transforma automáticamente la sentencia
switchen una estructura como esta- Agrega una rama por cada valor
case, con formaif (errno == EINVAL) goto caselabel_EINVAL; - Agrega un
gotoque salta a la etiquetadefault - Reorganiza el bloque original de
caseusando etiquetas ygoto endofthis_switch
- Agrega una rama por cada valor
- 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
switchmezclados conifdefy fallthrough, una transformación basada engotoera 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 structoconst struct - Si
EINVALno es una constante en tiempo de compilación, algunas de las siguientes inicializaciones no son válidas en C- Inicialización de un
structlocal normal dentro de una función sí es posible const structstatic structconst structglobalstatic structglobal
- Inicialización de un
- Un caso real aparece en el módulo
faulthandlerde CPython - La solución fue inicializar la estructura con valores dummy y luego agregar código para llenar los valores correctos antes de usarla
- Dentro de funciones, se agrega una sentencia
if - Para inicializaciones globales o estáticas, se usa
__attribute__((constructor))
- Dentro de funciones, se agrega una sentencia
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é
ifdefestarán activos al compilar
- Porque no es fácil saber de antemano qué
- 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_FUNCTIONyPLUGIN_FINISH_DECL - Se puede imprimir el AST con
debug_treey recorrerlo conwalk_tree_without_duplicates
- Los plugins de GCC pueden cargarse con
- El problema era que la validación de etiquetas
caseocurre 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_readerde los headers para plugins de GCC, era posible registrar callbacks cuando una macro se#define, se#undefo 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 = ...
- Ejemplo:
- El plugin interceptaba el uso de
SYMBOLIC(SIGTERM), lo reemplazaba por el valor temporal y luego, en la etapaPLUGIN_PRE_GENERICIZE, volvía a convertir el AST alVAR_DECLcorrecto - Este hack con macros logró esquivar los errores de
switche inicialización destruct - 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 comoENOSYS + 1oprintf("ENOSYS = %d", ENOSYS) - Con el parche a GCC, la función se activa con la bandera
-fportcosmoen vez de usar un-fpluginaparte - Justo antes de que GCC emita el error
case is not constant, verifica siflag_portcosmoestá 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
luase simplificó, y al encontrar dónde aparecía el error decase constanteng++, también se logró compilarninja - En el proceso de agregar
ncursesa Python 3.11 apareció un problema nuevo- En
ncurseshabía código con expresiones binarias en valores de inicialización de estructuras, comoDATA2(PARODD | PARENB, PARODD) - El parche anterior manejaba inicializaciones con constantes simples, pero no expresiones como
PARODD | PARENB
- En
- Después, el parche se amplió para permitir que las etiquetas
casede C y los elementos de inicialización de estructuras pudieran ser expresiones arbitrarias- Según el estándar de C, las etiquetas
casesiguen 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
constexpreng++11
- Según el estándar de C, las etiquetas
- Tras este cambio,
ncursesse 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
enumni deconst int - No puede manejar casos donde
SIGTERMse usa como índice de arreglo dentro de una expresión de inicialización - Pueden seguir apareciendo crashes raros del compilador con inicializaciones
staticextrañas o cuandoSIGTERMse guarda en unstatic 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
switchy las expresiones de inicialización destruct, 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
¿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...
Ah, es un bug que se envuelva en un bloque de código :(
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-staticSi quieres intentar compilar GCC con Cosmopolitan Libc, puedes usar este repositorio: https://github.com/ahgamut/musl-cross-make/tree/gccbuild
Ojalá te traten mejor en HN
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
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
SIGHUPpara 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 mejorSi “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
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-staticSe preguntan si APE tiene un propósito práctico. ¿Hay alguien que realmente distribuya un solo ejecutable que deba correr en varios sistemas operativos?
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
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?”
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
kubectltiene 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 obtenerEs 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
Cambiar
switchporifes 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 funcionesPor ejemplo, usar algo como
switch(CosmoMappErrno(EINVAL))ycase COSMO_EINVAL:evitaría convertir el código en un bloque de gotosClaro, sería distinto si te refieres a que el compilador reconozca estos
switchy, 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 valoresEn 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 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
No entiendo por qué sorprende tanto el código que usa
SIGxxxcomo índice. Los números de señal están bien definidos, por lo general son continuos y se sabe que empiezan en 1Ademá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
std::error_codeno es una constante, así que no debería tener el mismo problema