1 puntos por GN⁺ 2025-10-26 | 1 comentarios | Compartir por WhatsApp
  • Antes de que se ejecute un programa, se explora el proceso mediante el cual el kernel crea e inicializa un proceso a través de la llamada al sistema execve
  • Esta llamada pasa la ruta del ejecutable, los argumentos y las variables de entorno, y con base en ello el kernel carga un archivo ejecutable en formato ELF
  • Un archivo ELF incluye código, datos, símbolos e información de enlace dinámico, y el kernel lo interpreta para realizar el mapeo de memoria y la inicialización de la pila
  • Después, el kernel transfiere el control al punto de entrada _start, y solo tras inicializarse el runtime de cada lenguaje se llama a la función main definida por el usuario
  • Este proceso muestra la estructura de colaboración entre el sistema operativo, el compilador y el runtime, y es importante para entender cómo se ejecuta un programa a nivel de sistema

El punto de inicio de la ejecución de un programa: llamada a execve

  • En Linux, la ejecución de un programa comienza mediante la llamada al sistema execve
    • Con la forma execve(const char *filename, char *const argv[], char *const envp[]), se pasan el nombre del ejecutable, la lista de argumentos y la lista de variables de entorno
    • A través de esto, el kernel determina qué programa ejecutar y en qué entorno
  • En lenguajes de alto nivel, esta llamada está envuelta por la API de ejecución de procesos de la biblioteca estándar
    • Ejemplo: std::process::Command de Rust llama internamente a execve
    • Realiza un proceso similar a la búsqueda de PATH en el shell, convirtiendo el nombre del comando en una ruta completa
  • En el caso de scripts con Shebang(#!), el kernel ejecuta el programa usando el intérprete especificado
    • Ejemplo: #!/usr/bin/python3 → se ejecuta con el intérprete de Python

ELF: la estructura del archivo ejecutable

  • Los archivos ejecutables en Linux siguen el formato ELF (Executable and Linkable Format)
    • ELF es un formato estándar de ejecutables que incluye código, datos, símbolos e información de reubicación
    • Otros sistemas operativos usan formatos distintos como Mach-O (macOS) o PE (Windows)
  • El encabezado ELF contiene información sobre la estructura del archivo y su disposición en memoria
    • Ejemplos de campos: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address es la dirección de la primera instrucción que se ejecutará del programa
  • En el ejemplo del encabezado ELF, se trata de un ejecutable ELF32 para arquitectura RISC-V, con 0x10358 como punto de entrada

Componentes internos de ELF

  • Un archivo ELF está compuesto por varias secciones (section)
    • .text: código ejecutable
    • .data: variables globales inicializadas
    • .bss: variables globales no inicializadas
    • .plt: tabla para llamadas a bibliotecas compartidas
    • .symtab, .strtab: tablas de símbolos y cadenas
  • La PLT (Procedure Linkage Table) permite las llamadas a funciones de bibliotecas compartidas
    • Ejemplo: printf, malloc de libc
    • La sección PT_INTERP de ELF especifica el enlazador dinámico (interpreter)
  • El kernel lee el ELF para ubicar en memoria las secciones cargables y, cuando corresponde, aplica funciones de seguridad como ASLR y el bit NX

Tabla de símbolos y enlace en tiempo de ejecución

  • La tabla de símbolos (symtab) de ELF contiene la información de direcciones de funciones y variables
    • Ejemplos: existen entradas como _start, main, __libc_start_main
    • Incluso un programa simple de “Hello, World!” puede incluir más de 2300 símbolos
  • Esto proviene en gran parte de la biblioteca estándar y el código de inicialización del runtime
    • Porque se enlazan implementaciones de libc como musl o glibc
  • Tras cargar cada sección del ELF, el kernel transfiere el control al intérprete (enlazador dinámico)
    • El intérprete se encarga de la reubicación (relocation), la aleatorización de direcciones (ASLR), la configuración de permisos de ejecución (bit NX), etc.

Proceso de inicialización de la pila

  • Antes de ejecutar el programa, el kernel debe construir directamente la pila (stack)
    • La pila se usa para variables locales, marcos de llamada de funciones y paso de argumentos
  • Los argv, envp pasados en la llamada a execve se almacenan en la pila
    • El programa accede a los argumentos de línea de comandos y a las variables de entorno a través de ellos
  • El kernel también incluye en la pila el vector auxiliar ELF (auxv)
    • Contiene unas 30 entradas, como el tamaño de página, metadatos ELF e información del sistema
    • Ejemplo: AT_PAGESZ especifica el tamaño de página de memoria (por ejemplo, 4 KiB)
  • En el ejemplo del emulador RISC-V, el puntero de pila (sp) comienza en una dirección alta y apila en orden inverso los argumentos, las variables de entorno y el vector auxiliar

El punto de entrada y la función _start

  • El punto de entrada del ELF se define como la dirección de la función _start
    • _start es el primer código en espacio de usuario al que el kernel transfiere el control
  • La mayoría de los lenguajes realizan en _start la inicialización del runtime y luego llaman a main
    • Ejemplo: std::rt::lang_start en Rust, __libc_start_main en C
  • En el ejemplo de Rust, se pueden usar los atributos #![no_std] y #![no_main] para definir _start directamente sin runtime
    • Dentro de _start, se leen argc, argv y envp desde la pila y se llama al puntero a main
  • El runtime de cada lenguaje realiza tareas de inicialización específicas del lenguaje, como constructores globales, almacenamiento local de hilo y manejo de excepciones

Flujo completo hasta la llamada a main()

  • Todo el proceso puede resumirse así
    1. Llamada a execve → el kernel carga el archivo ELF
    2. Interpretación del ELF → mapeo de las secciones de código/datos, especificación del intérprete
    3. Construcción de la pila → almacenamiento de argumentos, variables de entorno y vector auxiliar
    4. Ejecución del punto de entrada _start
    5. Inicialización del runtime y luego llamada a main()
  • Esta secuencia muestra la estructura de cooperación entre el kernel del sistema operativo, el formato ELF y el runtime del lenguaje
  • El kernel real de Linux incluye lógica interna adicional para el espacio de direcciones, la tabla de procesos, la gestión de grupos, etc., pero este texto explica el flujo esencial de la etapa previa

Conclusión y corrección

  • El proceso de ejecución antes de main() es una combinación de inicialización a nivel kernel y configuración del runtime
  • Incluso un programa simple de “Hello, World!” se ejecuta después de pasar por una estructura ELF compleja y la inicialización del runtime
  • En la versión inicial del texto, parte de la lógica de carga de secciones se atribuía al kernel, pero se corrigió que en realidad corresponde al intérprete ELF
  • Este análisis sirve como material base útil para comprender programación de sistemas, compiladores y arquitectura de sistemas operativos

1 comentarios

 
GN⁺ 2025-10-26
Comentarios de Hacker News
  • Explica el proceso de enlazado dinámico de un archivo ELF
    El kernel mapea los segmentos PT_LOAD de ELF, carga el enlazador dinámico (ld.so) indicado por PT_INTERP y luego le cede el control
    Después, el enlazador dinámico se reubica a sí mismo y carga los objetos compartidos necesarios con mmap/mprotect
    Compara esta estructura con el mecanismo de shebang(#!) de los scripts

    • El kernel no presta ninguna atención a la información de secciones y solo procesa los segmentos PT_LOAD
      Comparte que antes se confundió cuando intentó insertar un archivo arbitrario en un ELF con objcopy y el kernel no lo cargó
      Al final creó directamente una herramienta para parchear la tabla de encabezados de programa, y dice que esta función también fue añadida al enlazador mold
      Artículo relacionado: Self-contained Lone Lisp Applications
    • El autor reconoce que antes publicó una modificación incorrecta del contenido y dice que la corregirá
    • Siempre le ha dado curiosidad por qué en Linux, donde el loader funciona en espacio de usuario, no existen loaders más variados
  • Dice que hizo experimentos para empaquetar todo el código antes de main() o incluso sin main()
    Artículo relacionado: Packing a codebase into a single function

    • Le pareció interesante porque, al leerlo, resultó ser inesperadamente simple y no tan frágil
      Bromea con que basta convertir todas las funciones a la forma main(100+n, ...)
  • Si este tema te interesa, recomienda revisar cpu.land, que él mismo creó
    Trata multitarea y el proceso de carga de código más que el layout de memoria

    • Agradece diciendo que realmente le encanta cpu.land
  • Se pregunta cuántos proyectos en C evitan la biblioteca estándar y llaman directamente solo a syscalls de Linux
    Siente que escribir código así es mucho más divertido

    • Sostiene que usar syscalls directas es, de hecho, ineficiente
      Para funciones como ALSA o DRM, hay muchas ventajas en acceder mediante bibliotecas del sistema en lugar de syscalls del kernel
      Explica que este enfoque es mejor que el estilo de Windows en términos de portabilidad y mantenibilidad
    • Añade que en Windows, si solo usas la API Win32, ni siquiera hace falta enlazar el runtime de C
    • Dice que él también creó antes un proyecto llamado liblinux para escribir programas usando solo syscalls
      Ahora lo dejó porque los headers nolibc de Linux están bastante bien,
      pero actualmente está desarrollando un lenguaje intérprete Lisp basado en syscalls
      Dice que fue un viaje muy interesante experimentar con construir directamente el espacio de usuario de Linux mediante llamadas al sistema
    • Intenta mantener la portabilidad, pero le cuesta renunciar a los descriptores de archivo porque son demasiado convenientes
    • Añade que mucho código de drivers en realidad usa solo syscalls
  • Explica que el intérprete ELF (ld.so) se encarga de toda la carga después de mapear los segmentos ELF iniciales
    execve mapea los segmentos PT_LOAD y llena el aux vector en la pila,
    luego salta al punto de entrada del intérprete ELF
    El kernel no sabe nada sobre PLT/GOT

  • Como alguien que enseña este tema en la universidad, dice que los estudiantes se confunden por los diagramas de memoria
    Los libros los dibujan con las direcciones más altas arriba, pero en un proceso Linux real
    las direcciones bajas aparecen arriba y las altas abajo
    En /proc/<pid>/maps, mientras más te desplazas hacia abajo, mayores son las direcciones
    Es decir, expresiones como “el heap crece hacia arriba (y la stack crece hacia abajo)” solo indican una dirección numérica,
    pero visualmente es más bien al revés
    Propone que sería mucho más intuitivo dibujarlo como en un IDE, donde al bajar aumentan las direcciones

    • La stack de todos modos crece mientras el puntero de pila disminuye, así que decir que “crece hacia abajo” sigue siendo correcto
      Aun así, propone que visualizarla en horizontal sería más natural
    • Recuerda que él también sufrió la misma confusión antes y que la notación de direcciones little-endian le resultaba confusa
    • Refuta que, si piensas en cómo se apilan los objetos en el mundo real, decir que “la stack crece hacia abajo” no resulta intuitivo
  • Dice que le gusta hacer este tipo de experimentos con microcontroladores PIC16 antiguos
    Le parece divertido manipular directamente el puntero de pila, temporizadores, configuración de variables, etc.

  • Comparte una experiencia relacionada con shebang(#!)
    Una aplicación Java mostraba un error diciendo que no podía encontrar el script de ejecución,
    pero el problema real era que la ruta del shebang del script estaba mal
    En local funcionaba bien, pero el problema surgía porque la ruta del intérprete era distinta en el servidor remoto

    • Dice que esto no es exclusivo de Java y puede pasar en cualquier programa donde aparezca un error ENOENT
      Aconseja ejecutarlo con strace para verificar de inmediato en qué syscall ocurrió el error
    • Comparte un artículo que analiza la estructura del shebang: What the #! means
    • Añade que para que el kernel soporte shebang se necesita la configuración CONFIG_BINFMT_SCRIPT=y
  • Dice que al depurar siempre se confunde sobre en qué momento se aplica el orden de reubicación del binario principal
    Expresa que parece magia negra saber si ocurre antes o después de que el enlazador resuelva sus propios símbolos

  • Señala que el enlace de la parte “lang_start function (defined here)” en el Markdown está roto