1 puntos por GN⁺ 4 시간 전 | 1 comentarios | Compartir por WhatsApp
  • Los binarios de Rust pasan por una fase de inicialización del runtime antes de fn main(), y en esa etapa se realizan tareas como el manejo de pánicos y unwinding, además de la conversión de argumentos del programa
  • Cuando el loader del sistema operativo transfiere el control al entrypoint, el runtime de C y el runtime de Rust ejecutan funciones de inicialización, y es posible colocar código pre-main mediante #[unsafe(link_section = "...")] y el mecanismo de constructores
  • Las secciones del linker reúnen en un solo lugar, al momento de construir el binario, los datos enviados por múltiples crates, y link-section permite tratarlos como si fueran slices de Rust
  • Si se usan juntos ctor y link-section, se pueden armar antes de main patrones como el registro de subcomandos CLI o el ordenamiento de pools de internado de strings, para luego leerlos sin locks
  • Este enfoque ofrece agregación sin asignaciones e inversión de control, pero hay que elegir con cuidado dónde aplicarlo por las dificultades con la eliminación de código muerto, las restricciones de los constructores, las diferencias entre plataformas y los límites de compatibilidad con Miri

La etapa previa a main en un binario Rust

  • Todo binario Rust tiene fn main(), pero el flujo de ejecución real llega a main solo después de pasar por el loader del sistema operativo y la inicialización del runtime
  • En C existe el runtime de C, normalmente identificado como libc, y Rust tiene su propio runtime a través de la biblioteca estándar, construyendo abstracciones de más alto nivel sobre el runtime de C
  • El propósito del runtime es integrar el código del desarrollador con el sistema operativo de la plataforma
  • El runtime de C prepara servicios del entorno de ejecución antes de main, como asignación de memoria, acceso a archivos y almacenamiento local por hilo
  • En ese momento, Rust prepara el manejo de pánicos y unwinding, y convierte los argumentos del programa en estilo C a la interfaz de std::env::args
  • La fase pre-main se ejecuta antes que el código del usuario, es de un solo hilo y tiene un orden predecible, por lo que resulta adecuada para inicialización determinista

El entrypoint

  • Un binario comienza cuando el loader del sistema operativo lo carga en memoria, configura el entorno y luego le cede el control
  • En Linux, el entrypoint se guarda en el campo e_entry del encabezado ELF, y por defecto el linker coloca ahí la dirección de un símbolo llamado _start
  • Windows tiene un hook similar, y un ejecutable comienza en la función _WinMainCRTStartup
  • El bootstrap inicial del runtime consistía en un árbol de llamadas a funciones estáticas para inicializar cosas como I/O de archivos y el asignador
  • A medida que el runtime se volvió más complejo, ese árbol de inicialización estática también creció, y los binarios empezaron a incluir más funcionalidades del runtime de C que podían o no ser necesarias
  • Cuando el linker pudo eliminar código no usado antes de construir el binario, hizo falta una forma de reemplazar ese árbol de inicialización estática
  • El enfoque de GCC con __attribute__((constructor)) colocaba una lista de punteros a funciones de inicialización en una región contigua del binario, y el runtime de C la recorría al iniciar para invocarlas
  • A los constructores se les empezó a poder asignar prioridad; por ejemplo, puede ser necesario inicializar malloc antes que el I/O de archivos con buffering
  • El runtime moderno de glibc en Linux guarda punteros a funciones en .init_array, y permite fijar el orden de ejecución mediante sufijos numéricos
  • Los valores de prioridad menores o iguales a 100 están reservados para el propio runtime, así que el código que usa el runtime de C debe usar 101 o más
  • En Rust se pueden colocar punteros a funciones de inicialización usando atributos como #[used] y #[unsafe(link_section = ".init_array.101")]

linktime: ctor, link-section y otros

  • Los ejemplos funcionan en Linux y varios BSD, pero no fueron diseñados como ejemplos multiplataforma
  • macOS soporta símbolos start y stop, pero con nombres distintos, y Windows no los soporta, aunque en la práctica tiene reglas equivalentes de ordenamiento de secciones
  • ctor y link-section son crates del proyecto linktime que abstraen las diferencias entre plataformas y la complejidad del trabajo con el linker
  • inventory y linkme son crates muy usados construidos sobre el mismo principio, aunque tienen límites para estos ejemplos
  • El crate ctor se encarga del boilerplate necesario para registrar constructores de forma multiplataforma
  • Una función marcada con un atributo como #[ctor(unsafe, priority = 101)] será invocada por el runtime de C después de que el linker la haya ordenado, incluso si no se llama directamente desde el código

Secciones y scripts del linker

  • El compilador puede colocar datos o código en ubicaciones específicas dentro del binario, regiones que en la mayoría de plataformas se llaman secciones
  • Rust también puede usar esa misma organización mediante el atributo link_section
  • Muchos linkers permiten que el desarrollador provea un script del linker, un archivo de texto que le indica cómo ensamblar los object files
  • Con un script del linker, un solo archivo C puede convertirse en un ejecutable Linux o en un bloque de ensamblador crudo destinado a un sector de arranque de disco
  • Un script del linker puede definir símbolos virtuales que no están en los archivos fuente, pero que sirven en C para acceder a punteros base de datos del binario cargado
  • En el script de ejemplo, _TEXT_START_ y _TEXT_END_ se definen para señalar el inicio y el final de la sección .text
  • El punto en _TEXT_START_ = .; representa el contador de posición, que se interpreta como un valor cercano a la dirección de salida actual del binario

Símbolos del linker

  • El linker no asigna el valor de los símbolos de inicio y fin como punteros, sino como la dirección donde quedaría ubicado un static con ese mismo nombre
  • Los símbolos de inicio y fin no son punteros *const Type; solo tienen significado por su dirección y no contienen datos propios
  • Una sección está formada por los datos que caen dentro del rango que incluye el símbolo de inicio y excluye el de fin
  • Muchos linkers adquirieron la capacidad de definir automáticamente los límites de todas las secciones de un ejecutable
  • En la toolchain GNU, para una sección llamada MY_SECTION, se definen automáticamente los símbolos __start_MY_SECTION y __stop_MY_SECTION
  • macOS usa un patrón similar y genera símbolos section$start y section$end para cada sección
  • En el linker GNU, las secciones que no están declaradas explícitamente en el script del linker se llaman secciones huérfanas
  • El linker solo define automáticamente símbolos con prefijos _start y _stop cuando el nombre de la sección es compatible con un nombre de símbolo de C
  • our_strings funciona, pero our.strings o .our_strings no funcionan del mismo modo
  • Como los símbolos de límite no tienen datos y solo importa su dirección, en el ejemplo se representan con MaybeUninit<()>
  • En Stable Rust todavía no existe el tipo externo opaco ideal, así que MaybeUninit cumple ese papel de reemplazo
  • Crear un puntero &raw const hacia un elemento static siempre es válido, así que se puede obtener su dirección de forma segura sin leer su valor
  • link-section abstrae estos detalles de las secciones del linker y los convierte en slices de Rust sobre los que se pueden usar operaciones estándar
  • La fuerza de las secciones de enlace está en que cualquier crate que aporte código al binario puede enviar elementos a la misma sección, y el linker los reúne todos justo antes de construir el binario final

Inyección de dependencias

  • El patrón de registro basado en secciones funciona con principios similares a la inyección de dependencias
  • Frameworks como Dagger y Spring se basan también en la idea de que quien consume los datos de registro no debe quedar acoplado al proveedor
  • El proveedor registra los datos en el lugar donde los define, y el consumidor lee el registro
  • En la inyección de dependencias tradicional, el framework suele tener que recorrer el grafo de módulos al iniciar o escanear clases cargadas para encontrar proveedores y consumidores
  • Con secciones del linker, es el propio linker quien reúne los datos de los proveedores cuando se construye el binario y los deja listos para que el consumidor los lea fácilmente
  • El ejemplo de registro de subcomandos CLI muestra este patrón registrando subcomandos con link_section::section
  • Turbopack usa este patrón para constantes de pool de strings, mecanismos de registro de serialización y deserialización, y registro de funciones de compilación incremental de turbotask
  • Un servidor web hipotético también podría usar este patrón para recolectar automáticamente rutas y middleware al momento de build

Uso de secciones para registro

  • Una ventaja del trabajo previo a main es que no hay hilos ejecutándose a menos que se inicien explícitamente
  • En ese entorno, en muchos casos se puede evitar la complejidad de los locks y otras primitivas de sincronización
  • Se puede dividir claramente el ciclo de vida de los datos en una fase escribible antes de main y una fase inmutable después de main
  • Evitar adquirir y liberar locks al acceder a datos en un programa en ejecución puede simplificar la estructura y mejorar la eficiencia
  • El ejemplo usa una estructura CliSubcommand, una función constructora const y #[section] para recolectar subcomandos
  • Subcomandos como list, add y help pueden estar ubicados en cualquier parte del código
  • La función main puede despachar dinámicamente siempre que vea la definición de la sección CLI_SUBCOMMANDS, sin necesidad de conocer de antemano los nombres ni la ubicación de los subcomandos registrados
  • Si no hay subcomandos registrados, se vuelve al subcomando por defecto; en el ejemplo, help actúa como valor predeterminado

Más allá de los datos inmutables

  • El ejemplo anterior asume que los datos enlazados son inmutables, pero la organización de datos basada en el linker también puede usarse con datos mutables
  • La mutabilidad de datos estáticos globales es un problema común en Rust, y puede resolverse con herramientas de mutabilidad interna como mutexes o tipos atómicos
  • Los mutexes y tipos atómicos no son caros cuando no hay contención, pero tampoco son gratis
  • Para modificar datos de forma segura en Rust, los cambios deben hacerse de manera thread-safe y no debe existir ninguna otra referencia a los mismos datos mientras exista una referencia mutable
  • El entorno pre-main es de un solo hilo, salvo que se inicien hilos explícitamente, así que no hacen falta operaciones atómicas
  • En un entorno monohilo, la relación happens-before entre la modificación y la lectura posterior se cumple automáticamente
  • Si los datos de una sección de enlace se modifican antes de main, luego se puede acceder a ellos de forma segura sin locks desde cualquier hilo
  • Si las referencias mutables solo se crean y se cierran antes de main, también se cumple la condición de que no existan otras referencias mientras esas referencias mutables están vivas
  • Un slice de una sección de enlace es un alias de los elementos static dentro de la sección, así que las reglas de aliasing aplican tanto al slice como a los elementos estáticos
  • Para modificar datos de forma segura a través del slice, los elementos estáticos deben colocarse dentro de UnsafeCell
  • Si un elemento static no está envuelto en UnsafeCell, LLVM puede cachear valores, reordenarlos o hacer suposiciones sobre esos datos
  • UnsafeCell por sí solo no implementa Sync, así que hace falta un tipo wrapper adicional
  • El ejemplo arma los símbolos de límite y los elementos usando SyncUnsafeCell y MaybeUninit<SyncUnsafeCell<...>>
  • En el ejemplo de un pool de internado de strings ordenable, el pool se define en tiempo de enlace y el slice se ordena al inicio del runtime para luego buscar strings con búsqueda binaria
  • La implementación manual tiene mucho boilerplate, pero con ctor y link-section se puede construir la misma estructura de forma más concisa usando TypedMutableSection y constructores
  • Los elementos de TypedMutableSection deben ser const, porque internamente se usa un esquema similar al del ejemplo implementado a mano

Ventajas del patrón con secciones de enlace

  • Este patrón permite agregar elementos etiquetados de una forma garantizada y ubicar todos los datos en memoria contigua preasignada
  • El punto de registro puede quedar distribuido en cualquier parte del código
  • Se puede obtener una cantidad garantizada de elementos dentro de la sección
  • Las secciones de enlace no requieren asignaciones adicionales
  • Sin secciones de enlace, para construir la misma estructura habría que asignar un HashMap, Vec u otra estructura de datos, y posiblemente redimensionarla varias veces mientras se agregan elementos
  • En los enfoques tradicionales de recolección, las dependencias entre el módulo de tipos compartidos, los módulos que contribuyen datos y el módulo recolector quedan fuertemente entrelazadas
  • Con secciones de enlace, el recolector puede ubicarse en cualquier lugar y no necesita preocuparse por qué módulos aportan los datos
  • scattered-collect ofrece varios equivalentes de estructuras de datos con soporte para tiempo de enlace
    • Scattered*Slice ofrece distintas estructuras tipo Vec que exponen slices y soportan ordenamiento opcional
    • ScatteredMap y ScatteredSet son equivalentes de HashMap y HashSet que brindan consultas hash de clave-valor con una inicialización pre-main mínima

Cuándo no conviene usar este enfoque

  • El cómputo en tiempo de enlace es potente, pero no siempre es la herramienta correcta
  • En lugar del enfoque en tiempo de enlace, se pueden recolectar manualmente los datos en un crate que tenga visibilidad de todos los crates que quieran contribuir datos
  • La recolección manual puede ser incómoda, y en vez de que los contribuidores vean un único punto de aporte en el crate central, se necesita un crate recolector con muchas referencias a otros crates
  • La eliminación de código muerto se vuelve más difícil
  • link-section y linkme marcan los elementos con #[used], por lo que el linker no puede eliminar datos no utilizados
  • En datos pequeños, como átomos de strings internados, quizá no sea un problema, pero si se internan fragmentos crudos de JSON o JavaScript, o estructuras de datos grandes, puede acumularse mucho código muerto difícil de identificar
  • Las funciones constructoras pre-main tienen restricciones
  • No deben provocar pánicos, y Rust no garantiza que todas las funciones de la biblioteca estándar estén disponibles
  • El orden de llamada de funciones de inicialización con la misma prioridad no está garantizado y depende mucho de la plataforma
  • Estas limitaciones pueden esquivarse con un diseño cuidadoso, pero el enfoque pre-main puede terminar siendo incorrecto por razones sutiles y difíciles de depurar
  • Miri no soporta por completo todos los constructores pre-main ni todas las configuraciones de secciones de enlace
  • Hoy Miri solo modela la ejecución pre-main de forma muy básica y no modela las secciones del linker
  • Para probar comportamiento indefinido se recomiendan los sanitizers de LLVM como ASan y TSan
  • Los patrones de inversión de control pueden hacer más difícil auditar todos los lugares que contribuyen datos a las secciones de enlace
  • Muchos programas Rust ampliamente distribuidos y usados ya dependen de funciones pre-main como ctor, link-section, inventory y linkme

Breve resumen sobre WASM

  • WASM no soporta de forma nativa las secciones del linker por decisiones históricas
  • La anotación #[link_section] no coloca elementos en una sección real de código, sino en una sección personalizada de WASM a la que el propio código WASM no puede acceder
  • El crate linktime sí soporta WASM y proporciona una solución de emulación para que este enfoque funcione también en binarios WASM
  • En el futuro podrían proponerse formas de agregar soporte adecuado para WASM

Conclusión

  • Antes de main se pueden hacer muchas tareas que en ciertos casos ofrecen ventajas importantes
  • El entorno pre-main tiene un orden altamente controlado y una gran capacidad de control, lo que permite hacer muchas cosas con más confianza sin locks, tipos atómicos ni otras primitivas de sincronización
  • Las secciones del linker permiten agregar datos relacionados de forma arbitraria a lo largo de todo el binario y colocarlos juntos, evitando órdenes incómodos de dependencia entre crates
  • En muchos casos se puede evitar por completo la asignación de memoria, lo que ayuda a alejarse de problemas del asignador como la fragmentación causada por asignaciones repetidas
  • Entre los crates relacionados están ctor, dtor, link-section y scattered-collect

1 comentarios

 
GN⁺ 4 시간 전
Opiniones en Lobste.rs
  • Go es una excepción en que evita el runtime de C en la mayoría de las plataformas, pero Apple exige el runtime de C para acceder a las llamadas al sistema
    Apple usa libSystem.dylib como límite de estabilidad ABI para las llamadas al sistema, y Windows de la familia NT pone como límite de estabilidad ABI a ntdll.dll, no a las llamadas al sistema mismas: not syscalls
    En OpenBSD, al parecer Go llegó a configurar flags de metadatos como desactivar la aplicación forzada del bit NX para evitar la política del kernel que mata procesos si intentan hacer llamadas al sistema fuera del mapeo de libc de solo lectura configurado por el loader
    Sin embargo, como libSystem.dylib contains the functionality which would normally be libc.so plus other things, en ese sentido es igual al enfoque de la familia BSD donde “libc es el límite de estabilidad”
    Además, As of Go 1.16, Go usa libc para seguir la política de llamadas al sistema de OpenBSD
    Linux es relativamente raro en esto cuando tiene números de llamadas al sistema estables, porque no sigue la estructura de otros OS donde “un fragmento del kernel cargado como librería dinámica en el espacio de direcciones del proceso comparte definiciones enum inestables de llamadas al sistema con el código en modo kernel”, y porque Linux y glibc no se desarrollan juntos en el mismo repositorio como en otros lugares
    En Windows, el runtime de C también se encarga de parsear la cadena de comandos al estilo CP/M, heredada por MS-DOS y luego por la API de creación de procesos hijos de Windows, hacia un arreglo argv al estilo POSIX
    Por eso la documentación de Python subprocess tiene la sección Converting an argument sequence to a string on Windows, que explica cómo convertir un arreglo argv a una cadena según las reglas de comillas incorporadas en el runtime de C de Microsoft. El parser propio del proceso hijo invocado puede comportarse distinto de esas reglas si así lo quiere
    Estrictamente hablando, _start en Linux tampoco significa que el linker inserte automáticamente en el binario un símbolo con ese nombre. Si un binario en formato ELF es un ejecutable y no una librería, el campo e_entry del header, es decir, el offset 0x18, contiene la dirección a la que el loader saltará después de preparar la memoria
    _start es la convención de GCC para indicar el destino al que apunta e_entry cuando no se usa el punto de entrada provisto por libc, y recuerdo que herramientas como NASM también la siguen
    En Windows, _WinMainCRTStartup también es encontrado por el loader mediante AddressOfEntryPoint en el PE header. Está en el offset 0x0028 tomando como referencia el inicio del PE header, que viene después del header MZ (DOS EXE) y el DOS Stub
    Para aprender los detalles del PE header, son buenos Making the smallest Windows application y Tiny PE. Tiny PE incluso viola la especificación PE de formas que Windows acepta, por ejemplo superponiendo partes que el OS no lee o metiendo código en campos del header que no se usan. A ese nivel, el tamaño mínimo de archivo que Windows acepta depende de la versión de Windows donde se ejecute
    Sobre ejecutables ELF diminutos en Linux, también vale la pena ver A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux
    • Las llamadas al sistema en FreeBSD y NetBSD tienen estabilidad ABI, igual que las librerías del sistema
    • Respecto a _start, en los sistemas a.out el punto de entrada por el que el kernel entraba al ejecutable era tradicionalmente start, declarado en csu/crt0. Por ejemplo, 7th edition, VAX BSD
      En esa época los compiladores de C anteponían _ a los símbolos globales, así que puede verse que V7 declara _main, y BSD declara el nombre en ensamblador sin decorar start para el start() de C
      En ese entonces los programas empezaban desde el inicio del archivo, y la invocación del linker por parte de cc colocaba crt0 al principio. csu significa código de inicio de C, y crt0 significa el objeto 0 de soporte del runtime de C
      Es más difícil encontrar exactamente cómo funcionaba en System V cuando apareció ELF, pero start o _start siguió usándose como punto de entrada del programa declarado en csu/crt0
      Nunca he entendido bien cómo ELF cambió el manejo del prefijo _, pero por diversión supongo que alguien agregó una capa extra y por alguna razón start terminó siendo _start
      Como pareja clara, ELF parece haber agregado _end, que corresponde a la parte superior de BSS y al lugar que devolvería sbrk(0) antes de que malloc() cree el heap
  • Me interesaba esa “vida antes de main” en Rust, y me parece que estaría bien reunir en un solo texto qué es y por qué resulta útil
    También tengo ideas para textos posteriores, como cómo usar la agregación del linker para crear colecciones más rápidas, pero primero quiero escuchar opiniones sobre este tema más orientado a principiantes
    • He trabajado mucho con Rust embebido, así que en entornos no_std y a veces incluso sin alloc, main es solo otra función más y la inicialización suele quedar principalmente a cargo del desarrollador
      Tengo bastante código repetitivo hecho a mano en el codebase para fines parecidos, así que me da curiosidad cómo encajan estos crates con los entornos embebidos