3 puntos por GN⁺ 2024-04-09 | 1 comentarios | Compartir por WhatsApp
  • Incluso una sola línea de Hello World escrita en C tiene que pasar, en orden, por el ejecutable compilado, la biblioteca estándar de C, las llamadas al sistema, el kernel y la terminal antes de aparecer en pantalla
  • El resultado creado con gcc hello.c -o hello es un ejecutable ELF de 64 bits x86-64, y el código _start se ejecuta primero desde el punto de entrada 0x1060 del encabezado ELF
  • El main() escrito por el usuario no inicia directamente, sino que pasa por _start y __libc_start_main, y printf("Hello World!\n") se optimiza en una llamada más simple a puts()
  • La cadena se guarda como una secuencia de bytes en 0x2004 de .rodata, y las cadenas en C determinan su final con un terminador NULL en lugar de información de longitud
  • La ruta real de salida continúa por el buffering y bloqueo de libc, la llamada al sistema write o writev, el kernel de Linux, el pseudo-terminal y el renderizado del emulador de terminal, y puede variar según el entorno de ejecución

Empezando desde C Hello World

  • El programa de ejemplo está escrito en C con el siguiente código
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Produce el mismo resultado que print('Hello World!') en Python, pero un programa en C no se ejecuta de inmediato con un intérprete, sino que primero debe compilarse
gcc hello.c -o hello
./hello
  • El resultado de ejecución es el siguiente
Hello World!
  • Si se tiene conocimiento básico de C o ensamblador, el nivel es suficiente para seguir el flujo

Qué es realmente el ejecutable

  • En el resultado de file hello, lo importante es ELF executable, x86-64
    • Un ejecutable ELF es el formato de programa ejecutable en Linux
    • x86-64 significa que es un programa en lenguaje máquina para procesadores x86 de 64 bits
  • Si se revisa el encabezado ELF con readelf -h hello, aparece Entry point address: 0x1060
    • Esa dirección es el lugar donde la CPU empieza a ejecutar después de que el programa se carga

_start y la entrada a la biblioteca de C

  • Si se desensambla con objdump -D hello, _start está en la dirección 0x1060
  • _start no es código escrito directamente por el usuario, sino código agregado automáticamente por el compilador, o más precisamente por el linker
  • Este código realiza la inicialización y luego ejecuta la siguiente llamada
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • Esa función no está definida directamente dentro del programa, sino del lado de la biblioteca estándar de C
  • En la sección dinámica de readelf -d hello aparece la dependencia de libc.so.6
Shared library: [libc.so.6]
  • libc.so.6 es la biblioteca estándar de C del sistema, y los archivos .so en Linux contienen código que varios programas pueden compartir, como los .dll en Windows
  • La biblioteca de C se encarga de la inicialización, como el manejo de argumentos de línea de comandos y variables de entorno, luego llama a main() y termina el programa con su valor de retorno

Lo que realmente pasa dentro de main()

  • En el resultado del desensamblado, main() está en 0x1149
  • El flujo de main() es el siguiente
    • Configura el stack frame
    • Prepara los argumentos para la llamada a función
    • Llama a la función que imprime Hello World
    • Limpia el stack frame
    • Retorna con el código de salida 0
  • Lo clave es la parte que prepara la dirección de la cadena como argumento y llama a puts@plt
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • En el código fuente estaba printf(), pero el compilador lo optimizó y lo cambió por puts()
    • printf() es una función compleja con capacidad de salida formateada
    • Como el ejemplo no usa funciones de formato como inserción de variables, se reemplaza por el puts() más simple
    • puts() agrega por sí mismo el salto de línea al final de la cadena, por lo que también se elimina el \n original

Cómo se almacena la cadena

  • La cadena está en la sección .rodata en la dirección 0x2004
  • Los bytes en esa ubicación son los siguientes
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • Esta secuencia de bytes se interpreta como "Hello World!" y el 0x00 final
  • 0x00 es el terminador NULL y marca el final de una cadena en C
  • Como las cadenas en C no incluyen información de longitud, las funciones que reciben una cadena la procesan byte por byte hasta encontrar el terminador NULL
  • Si no hay un terminador NULL entre cadenas, una función de C puede procesar varias cadenas seguidas o leer memoria no permitida y terminar con un Segmentation Fault

La ruta de puts() en Glibc

  • puts@plt termina conectando con la biblioteca estándar
  • En Glibc, puts() se enlaza con _IO_puts
  • _IO_puts realiza las siguientes tareas
    • Calcula la longitud de la cadena
    • Obtiene el bloqueo del stream de salida stdout
    • Verifica condiciones y llama a _IO_sputn
    • Imprime el carácter de salto de línea
    • Libera el bloqueo y devuelve la cantidad de caracteres impresos
  • La implementación interna de Glibc es grande y compleja, así que se continúa con el flujo de una biblioteca de C más pequeña: musl libc

Cómo baja la salida en musl libc

  • El puts() de musl obtiene el bloqueo de stdout, llama a fputs() y a putc_unlocked('\n', stdout), y luego libera el bloqueo
  • fputs() calcula la longitud de la cadena y llama a fwrite()
  • fwrite() vuelve a obtener el bloqueo y llama a __fwritex()
  • __fwritex() revisa el estado del buffer y, si hace falta, llama al puntero de función write del stream de salida
  • stdout está definido con fd = 1, y la función write se asigna inicialmente a __stdout_write
  • __stdout_write() ejecuta un ioctl TIOCGWINSZ y luego llama a __stdio_write()
  • __stdio_write() realiza una llamada al sistema con SYS_writev

Llamadas al sistema y kernel

  • Solo con la biblioteca de C no se puede comunicar directamente con el hardware; el acceso al hardware lo maneja el kernel del sistema operativo
  • La solicitud de salida termina siendo una llamada al sistema que le pide al sistema operativo escribir texto en el stream de salida
  • La salida común se realiza con la llamada al sistema write, mientras que musl usa writev, que permite escribir varios buffers como un arreglo
  • La implementación de llamadas al sistema de musl para x86-64 se divide desde __syscall0 hasta __syscall6 según la cantidad de argumentos
  • Cada función configura los argumentos en los registros de CPU y ejecuta la instrucción syscall
    • El control pasa al kernel
    • El kernel lee los parámetros en los registros y ejecuta la llamada al sistema solicitada

Después del kernel, hasta verse en pantalla

  • El kernel de Linux recibe la llamada al sistema write y escribe los datos en el archivo o stream abierto
  • La llamada al sistema write recibe como argumentos el descriptor de archivo, el buffer a escribir y la cantidad de bytes a escribir
  • En el entorno del ejemplo, el programa hello se ejecuta en el emulador de terminal de GNOME, y stdout está conectado al pseudo-terminal /dev/pts/0
  • El kernel guarda el mensaje Hello World en el buffer, y el emulador de terminal lo lee y lo muestra en pantalla
  • El emulador de terminal renderiza el texto como frames, y el servidor X o el compositor lo combina con las pantallas de otras apps antes de mostrarlo en la pantalla a través del kernel
  • Según el entorno de ejecución, la ruta posterior puede variar
    • En un inicio de sesión remoto, el kernel envía el texto a sshd, y sshd lo devuelve al kernel como paquetes cifrados para enviarlo por internet
    • Si se usa una terminal física y un adaptador serial a USB, el kernel envía el texto como paquetes USB
    • En una framebuffer console, el kernel renderiza el texto como frames y lo muestra en la pantalla

La complejidad detrás de una salida pequeña

  • El envío del mensaje Hello World no es más que una llamada al sistema generada por un solo programa
  • El software y el hardware modernos están formados por capas tan complejas y detalladas que incluso una acción pequeña es difícil de rastrear por completo
  • Esta explicación omite muchos detalles, excepciones y comportamientos internos del kernel, y solo sigue el flujo principal

1 comentarios

 
GN⁺ 2024-04-09
Opiniones de Hacker News
  • Por aburrimiento probé hacer algo parecido en Rust sobre macOS, y aun un “Hello, world!” con #![no_std], #![no_main] y llamadas directas al sistema WRITE/EXIT, visto en Ghidra, daba de cualquier forma unos 16 KB
    Seguro se puede hacer code golf para reducirlo más, pero parece muy probable que alguien ya lo haya intentado y documentado

    • En Windows, un programa similar pesaba 3072 bytes y se compiló con rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main
      Llamaba directamente a ExitProcess, GetStdHandle y WriteFile de kernel32, y como era hello world, dejé el manejador de pánicos medio a la ligera. Dentro del ejecutable todavía hay bastante padding, así que se podría meter más sin aumentar el tamaño, y también se podría reducir con métodos más “criminales”, pero no parece tener mucho sentido
      Como referencia, la base de datos de depuración PDB relacionada pesaba 208,896 bytes
    • Para hacerlo lo más pequeño posible hay que descartar main por completo y usar _start, y también pasar flags al linker para que no haga alineación de secciones
      En https://darkcoding.net/software/a-very-small-rust-binary-ind... se ve que con este método se puede llegar fácilmente a alrededor de 500 bytes
    • El code golf es divertido, pero también hay que ver cuál es el tamaño de página hoy en día
      Si es un lenguaje con stack, al final el ejecutable probablemente terminará cargando al menos unas dos páginas, una de solo lectura y otra de lectura-escritura
    • El proyecto min-sized-rust reúne muchas optimizaciones para reducir el tamaño de binarios de Rust
      Si mal no recuerdo, aplicando todas las optimizaciones el hello world terminaba en unos 8 KB: https://github.com/johnthagen/min-sized-rust
    • XNU no carga archivos Mach-O más pequeños que una página, así que en esa plataforma lamentablemente no hay mucho margen para jugar con binarios pequeños
  • Hay otra madriguera de conejo que Musl se saltó. En Linux, llamar funciones del sistema no consiste solo en usar syscall directamente
    La forma más “educada” es llamar a la vDSO. Es una pequeña biblioteca mágica que el kernel mapea automáticamente en el espacio de direcciones, para que el kernel pueda proveer el código óptimo para realizar llamadas al sistema
    Algunas llamadas al sistema incluso pueden ejecutarse en espacio de usuario y hacer que no haga falta el propio syscall; antes, la vDSO también elegía entre mecanismos de llamada al kernel como int 0x80 o sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Solo en x86 de 32 bits la vDSO contiene una interfaz genérica para llamadas al sistema rápidas
      En x86-64, la forma estándar de llamada al sistema es la instrucción SYSCALL, y la vDSO solo incluye funciones relacionadas con el tiempo y algunas funciones relacionadas con SGX
  • También vale la pena leer un artículo que compara el overhead de programas “Hello World” en distintos lenguajes: https://drewdevault.com/2020/01/04/Slow.html
    Artículo posterior: https://drewdevault.com/2020/01/08/Re-Slow.html
    También existe un artículo legendario sobre cómo crear el programa más pequeño en Linux. El programa simplemente termina con el código de estado 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    En el mismo sitio también se puede encontrar el programa “Hello World” más pequeño

  • El artículo prácticamente omite el rol del linker dinámico, que en realidad podría considerarse el verdadero punto de entrada del programa
    Si te interesa esta perspectiva, puedes ver https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2...

  • Para los fans de DOS, un “hello, world” escrito en ensamblador/código máquina para DOS podía llegar a ser de 23 bytes: https://github.com/susam/hello
    De esos 23 bytes, 15 bytes los ocupa la propia cadena terminada con signo de dólar, así que el código máquina real son solo 8 bytes, formados por cuatro instrucciones x86

  • El artículo estuvo bueno, pero me habría gustado que hiciera dos cosas más. Sería mejor desactivar la optimización y el inlining que hicieron que printf se convirtiera en puts, o directamente escribir el hello world usando puts desde el principio
    Además, sería bueno dividir el paso de compilación en cuatro etapas —preprocesamiento, compilación, ensamblado y enlace— o explicar los archivos generados al usar --save-temps con cc. Cuando ves el pipeline directamente, muchas menos cosas parecen magia

  • Me acordé de una tarea que me gustaba de una clase universitaria de programación de sistemas: era una consigna del tipo “te damos un fragmento de C++ hello world; entrega el binario compilado más pequeño posible”
    Me quedó el recuerdo de inspeccionar el programa con herramientas como readelf y objdump, e ir quitando capas y optimizaciones del compilador poco a poco hasta reducirlo al binario más pequeño que todavía imprimiera “hello world”
    Obviamente, al buscar, había alguien que lo había hecho mucho mejor que los estudiantes: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • Me pregunto si importa que ese fragmento fuera C++
      ¿No podrías simplemente crear el binario más pequeño que imprima hello world y afirmar que es semánticamente equivalente? Incluso incluyendo los datos de la cadena, creo que bastaría con una decena de instrucciones x86
    • Si es una tarea tan interesante, me pregunto por qué no se ve a más gente intentando crear el binario más pequeño posible también para programas que no sean “hello world”
      Personalmente me divierte porque me gusta ahorrar espacio en mi computadora, pero hoy se escriben muchos programas de 10 MiB, 20 MiB, 50 MiB o más de 100 MiB. Algunos están hechos en entornos comerciales y con fines comerciales, pero también hay muchos programas que dicen estar escritos por puro gusto. ¿No existe el placer de usar programas pequeños?
  • Un cierre del estilo “ya pasó la medianoche, así que debería dormir” fue, de hecho, el final perfecto para este texto

  • Lamentablemente, como muchas exploraciones profundas de “hello world”, este texto también se detiene en la llamada al sistema write y pasa por encima del resto
    Hasta antes de la llamada al sistema, en esencia es una cadena de llamadas a funciones donde printf llama a puts, puts llama a write, pasa un char const* y hace un poco de contabilidad, así que personalmente no me parece la parte más interesante
    Lo realmente interesante y complejo empieza después de la llamada al sistema. El kernel conecta el stdout del proceso con la entrada del emulador de terminal, y la terminal prepara el framebuffer mediante la biblioteca de renderizado de fuentes y el driver de la GPU. Lee del disco los contornos de la fuente correspondientes a los bytes de caracteres, los ajusta al viewport, aplica escalado, kerning y métricas de fuente, y luego la GPU realiza la rasterización y el antialiasing
    Después, el gestor de ventanas compone el marco de la ventana de la terminal y el escritorio, y si hay transparencia o efecto de vidrio esmerilado, lo procesa con shaders. El framebuffer resultante se empaqueta como señal HDMI o DisplayPort según la resolución y la profundidad de color del monitor, pasa por el cable y los circuitos de entrada de la pantalla, y se convierte en señales de direccionamiento de píxeles. Según sea LCD, OLED, plasma o CRT, el método de refresco es distinto; por ejemplo, un OLED WRGB de 3840×2400 tiene que manejar unos 36,86 millones de subpíxeles
    Todo este proceso ocurre dentro de 16,67 ms, el tiempo de un frame a 60 Hz

    • La explicación está buena, pero al final se detiene en el sistema visual humano, y esa también es una parte realmente interesante y compleja :)
      https://en.wikipedia.org/wiki/Visual_system
    • Si te gustan las exploraciones con este nivel de profundidad, probablemente también te guste el artículo de Gynvael Coldwind que indaga cómo se ejecuta un hello world en Python sobre Windows
      Trata los internals de CPython, conhost de Windows, rasterización de fuentes, renderizado con GPU, etc.: https://gynvael.coldwind.pl/?id=754
    • La mayor parte de esto no tiene relación con el programa en sí. Por ejemplo, si canalizaras la salida a /dev/null, nada de eso ocurriría
    • También falta lo que ocurre antes de _start. Por ejemplo, cómo nace un proceso en Linux, en especial el bastante extraño execve, el proceso de cargar el programa en memoria, binfmt_* y el potente binfmt_misc, reubicaciones, frames de manejo de excepciones, secciones, el cargador ELF en general, y la asignación de recursos del sistema operativo, incluido el malloc necesario
  • La frase “a diferencia de Python, no puedes invocar un intérprete para ejecutar este programa” no es del todo correcta
    Con tcc -run hello.c sí se puede. Estrictamente, no es un intérprete sino más bien un compilador en memoria
    Si quieres puntos extra de rareza, puedes hacer que el programa diga “Hellorld” en lugar de “Hello world”