JUANCHI
InicioCVBlogLabContacto
Saltar al contenido
JUANCHI
InicioCVBlogLabContacto
Cómo Linux ejecuta un binario: lo entendí a los 33 años de programar y me da vergüenza
Volver al blog
devopslinuxsistemaselfdynamic-linkingbajo-nivelstrace

Cómo Linux ejecuta un binario: lo entendí a los 33 años de programar y me da vergüenza

33 años con computadoras y recién ahora entiendo qué pasa entre que escribís `./mi-programa` y corre el código. ELF, dynamic linking, ld-linux — el agujero negro que siempre esquivé.

7 de abril de 20269 min de lectura21 visualizacionesVer en Dev.to

Contenido

Contenido

Hay exactamente 127 syscalls que hace un proceso Node.js vacío antes de ejecutar una sola línea de tu código. Ciento veintisiete. Cuando lo medí con strace la semana pasada, tuve que releer el output dos veces y después cerrar la terminal y salir a caminar.

Tengo 33 años de historia con computadoras. Arranqué con una Amiga a los 3 años, pasé por DOS, monté servidores Linux a los 18, y hoy deployeo en Railway con Next.js. Y en todo ese tiempo nunca entendí — de verdad, en detalle — qué pasa entre que escribís ./mi-programa y el programa corre. Lo esquivé. Siempre había algo más urgente. Un deploy. Un bug en producción. Un cliente.

Esta semana me obligué a bajar al metal. Y esto es lo que encontré.

Linux ELF dynamic linking: cómo funciona realmente

Empecemos por el principio. Cuando ejecutás un binario en Linux, el kernel no simplemente "arranca" tu programa. Hay una cadena de eventos que la mayoría de los devs de producto nunca vemos:

# Miremos qué tipo de archivo es un binario cualquiera
file /usr/bin/node
# ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV),
# dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2

Ahí está. dynamically linked. interpreter /lib64/ld-linux-x86-64.so.2. Eso es el dynamic linker, y es el protagonista de esta historia.

El formato ELF: el sobre que envuelve todo

ELF significa Executable and Linkable Format. Es básicamente un formato de archivo — como un ZIP pero para código ejecutable. Todo binario de Linux es un archivo ELF, y tiene una estructura muy específica:

# readelf te muestra las entrañas de un ELF
readelf -h /usr/bin/ls

# ELF Header:
#   Magic:   7f 45 4c 46 02 01 01 00 ...  <- "\x7fELF" — la firma del formato
#   Class:                             ELF64
#   Entry point address:               0x67d0  <- acá empieza TU código
#   Start of program headers:          64 (bytes into file)
#   Number of program headers:         13

El Entry point es la dirección de memoria donde va a arrancar la ejecución. Pero — y acá está lo que me voló la cabeza — ese código no es el primero que corre.

El dynamic linker: el intermediario que nunca viste

Cuando el kernel ve que un ELF es "dynamically linked", no ejecuta el entry point directamente. Ejecuta primero el interpreter — que en la práctica es /lib64/ld-linux-x86-64.so.2, el dynamic linker.

Este proceso hace, en orden:

# Veamos qué bibliotecas necesita un binario
ldd /usr/bin/node

# linux-vdso.so.1 (0x00007ffd8c9f3000)      <- virtual, vive en el kernel
# libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2
# libstdc++.so.6 => /lib/x86_64-linux-gnu/libstdc++.so.6
# libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# /lib64/ld-linux-x86-64.so.2 (0x00007f3a...)  <- el dynamic linker mismo
  1. Carga el ELF en memoria — mapea los segmentos del archivo
  2. Resuelve las dependencias — busca cada .so que necesita el binario
  3. Hace la relocación — parchea las direcciones de memoria para que todo encaje
  4. Ejecuta los constructores — código de inicialización antes del main()
  5. Entrega el control al entry point real

Todo eso antes de que tu main() corra una sola línea.

Bajando más: qué pasa con strace

La herramienta que me abrió los ojos fue strace. Intercepta todas las syscalls de un proceso:

# Contemos las syscalls de un programa C mínimo
cat > hola.c << 'EOF'
#include <stdio.h>
int main() {
    printf("hola\n");
    return 0;
}
EOF

gcc -o hola hola.c
strace -c ./hola

# % time     seconds  usecs/call     calls    syscall
# 27.45    0.000156          31         5    mmap       <- mapear memoria
# 18.23    0.000104          20         5    mprotect   <- proteger regiones
# 14.67    0.000083          83         1    munmap
#  9.44    0.000054          27         2    openat     <- abrir archivos .so
#  8.92    0.000051          25         2    read
# ...
# Total calls antes de main(): ~25

Veinticinco syscalls para "hola mundo". Para Node.js son 127. Esto tiene sentido cuando entendés que Node linkea contra un montón de bibliotecas compartidas — V8, libuv, OpenSSL.

El section header: el índice del binario

# Miremos las secciones de un ELF
readelf -S /usr/bin/ls | head -30

# [Nr] Name              Type             Address
# [ 0]                   NULL
# [ 1] .interp           PROGBITS         <- path al dynamic linker
# [ 2] .note.gnu.build-i NOTE
# [ 3] .gnu.hash         GNU_HASH         <- tabla hash para búsqueda de símbolos
# [ 4] .dynsym           DYNSYM           <- tabla de símbolos dinámicos
# [ 5] .dynstr           STRSYM           <- strings de los nombres de funciones
# [12] .plt              PROGBITS         <- Procedure Linkage Table
# [13] .text             PROGBITS         <- TU CÓDIGO acá
# [24] .got              PROGBITS         <- Global Offset Table
# [25] .got.plt          PROGBITS         <- GOT para PLT
# [26] .data             PROGBITS         <- variables globales inicializadas
# [27] .bss              NOBITS           <- variables globales sin inicializar

PLT y GOT: el truco de magia del lazy binding

Acá está la parte más elegante del sistema. Cuando tu programa llama a printf(), no sabe en tiempo de compilación en qué dirección de memoria va a estar esa función. La biblioteca puede estar en cualquier lugar.

La solución son dos estructuras:

  • PLT (Procedure Linkage Table): código intermedio que salta a través del GOT
  • GOT (Global Offset Table): tabla de punteros a las direcciones reales
# Primera llamada a printf — lazy binding en acción
# 1. Salta a printf@PLT
# 2. PLT lee el GOT — todavía apunta al dynamic linker
# 3. Dynamic linker resuelve la dirección real de printf
# 4. Actualiza el GOT con la dirección real
# 5. Ejecuta printf

# Segunda llamada a printf — ya resuelto
# 1. Salta a printf@PLT  
# 2. PLT lee el GOT — ahora apunta directo a printf
# 3. Ejecuta printf (sin pasar por el dynamic linker)

# Podés ver esto con:
LD_DEBUG=bindings ./hola 2>&1 | head -20
# binding file ./hola [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: 
# normal symbol `printf' [GLIBC_2.2.5]

Eso es lazy binding — el dynamic linker solo resuelve una función la primera vez que la llamás. Elegante y eficiente.

Los errores que me hicieron entender esto a las piñas

Error 1: "No such file or directory" en un binario que existe

Este me pasó hace años y lo "arreglé" sin entenderlo:

./mi-binario
# bash: ./mi-binario: No such file or directory

# Pero el archivo existe:
ls -la mi-binario
# -rwxr-xr-x 1 juan juan 45231 Feb 20 14:32 mi-binario

El error no es que el binario no existe. Es que el interpreter no existe. El dynamic linker especificado en el ELF no está en el sistema. Pasaba cuando copiaba binarios entre distros con diferentes layouts.

# Diagnóstico:
readelf -l mi-binario | grep interpreter
# [Requesting program interpreter: /lib/ld-musl-x86_64.so.1]
# ^ Fue compilado contra musl libc, no glibc. Diferente distro.

Error 2: library version mismatch en producción

./mi-app
# ./mi-app: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.33' not found

Compilé en Ubuntu 22.04, deployé en Debian 10. La versión de glibc era diferente. La solución real es buildear en el mismo entorno que producción — que es básicamente por qué Docker existe.

# Dockerfile que evita este problema
FROM node:20-alpine AS builder
# Alpine usa musl, no glibc — cuidado con binarios nativos

FROM node:20-slim AS runner  
# Debian slim, misma glibc que la mayoría de producción

Esto conecta directo con lo que aprendí cuando estuve optimizando performance en producción — el ambiente de build importa tanto como el código.

Error 3: LD_PRELOAD para bien y para mal

# LD_PRELOAD te permite inyectar una biblioteca ANTES que cualquier otra
# Úsalo con cuidado — es poderoso y peligroso

# Ejemplo legítimo: usar tcmalloc en vez del allocator default
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 ./mi-app

# Ejemplo de debugging: interceptar llamadas a funciones
# (básicamente cómo funcionan algunos sandboxes de agentes)
# Relacionado con lo que exploré en /blog/sandboxes-coding-agents-freestyle

La sandbox de Freestyle que analicé hace unos días usa mecanismos similares — interceptar syscalls a nivel de proceso para aislar lo que puede hacer el agente.

FAQ: Linux ELF y dynamic linking

¿Qué es un archivo ELF en Linux? ELF (Executable and Linkable Format) es el formato estándar para binarios ejecutables, bibliotecas compartidas y archivos objeto en Linux. Es básicamente un contenedor estructurado que le dice al kernel cómo cargar y ejecutar el código. Todo binario de Linux moderno es un ELF — podés verificarlo con file /ruta/al/binario.

¿Qué diferencia hay entre static linking y dynamic linking? Con static linking, todas las bibliotecas que necesita tu programa se copian adentro del binario en tiempo de compilación. El resultado es un binario más grande pero completamente autónomo. Con dynamic linking, el binario solo guarda referencias a las bibliotecas, y el dynamic linker las carga en tiempo de ejecución. Dynamic linking es el default porque ahorra memoria (varias apps comparten el mismo código de libc en RAM) y facilita las actualizaciones de seguridad.

¿Por qué a veces un binario dice "No such file or directory" aunque existe? Generalmente significa que el interpreter (dynamic linker) especificado en el ELF no existe en ese sistema. Pasás un binario de Alpine (que usa musl libc) a Ubuntu (que usa glibc) y el path al dynamic linker no existe. Podés diagnosticarlo con readelf -l tu-binario | grep interpreter.

¿Qué es LD_PRELOAD y por qué es peligroso? LD_PRELOAD es una variable de entorno que le dice al dynamic linker que cargue una biblioteca específica ANTES que cualquier otra, incluyendo libc. Esto permite interceptar y reemplazar funciones del sistema. Es útil para profiling y debugging, pero peligroso porque puede usarse para inyectar código malicioso. Por eso los binarios con setuid lo ignoran.

¿Qué es la vDSO (linux-vdso.so.1)? Es una biblioteca virtual que el kernel mapea automáticamente en el espacio de memoria de cada proceso. Contiene implementaciones de syscalls muy frecuentes (como gettimeofday) que se ejecutan en espacio de usuario sin hacer un context switch real al kernel. Es por eso que ldd la muestra sin path — no es un archivo en disco, vive en el kernel.

¿Cómo afecta esto a Docker y los contenedores? Mucho. Los contenedores comparten el kernel del host, pero tienen su propio filesystem. Si buildeas un binario en una imagen con glibc 2.35 y lo corrés en un contenedor con glibc 2.17, va a fallar. Es por eso que las imágenes de Docker deben ser consistentes entre build y runtime. También es por qué las imágenes basadas en Alpine (musl libc) a veces tienen comportamientos inesperados con binarios compilados para glibc.

Lo que me llevé: el dev de producto que finalmente bajó al metal

Honestamente, me da un poco de vergüenza haber esquivado esto por tanto tiempo. Trabajé con Linux desde los 18 años, administré servidores, diagnostiqué cortes de red a las 11pm con el cyber lleno, y nunca me pregunté en serio qué pasa en esos microsegundos entre ./programa y la primera línea de código.

El pivot que hice en 2020 hacia desarrollo de software me hizo subir capas de abstracción — React, TypeScript, Next.js. Aprender a pensar en componentes fue difícil cuando venías de pensar en paquetes de red. Pero subir no significa que las capas de abajo desaparezcan. Siguen ahí.

Cuando trabajo en inferencia de LLMs en el edge o pienso en cómo aislar agentes de código, entender qué pasa a nivel de proceso importa. Las abstracciones son útiles hasta que se rompen — y cuando se rompen, bajás al metal o pagás a alguien que entienda el metal.

Mi recomendación concreta: pasá una tarde con strace, ldd y readelf. No para convertirte en systems programmer — para entender la máquina que ejecuta tu código todos los días.

# Empezá por acá. Cinco minutos, en cualquier Linux:
strace -c ls /tmp 2>&1  # ¿Cuántas syscalls hace ls?
ldd $(which node)        # ¿De qué depende Node?
readelf -h $(which ls)   # ¿Qué tiene adentro un binario?
file /bin/*              # ¿Qué tipos de ELF hay en tu sistema?

La Amiga de 1994 no tenía dynamic linking — todo era estático, todo estaba en ROM o en el disco, y el sistema era lo que era. En cierto punto esa simplicidad era más honesta. Hoy corremos sobre capas de capas de capas, y cada tanto vale la pena bajar a ver en qué está parado todo.


¿Cuántas syscalls hace tu app antes de ejecutar una línea de código? Medilo con strace -c ./tu-binario y mandame el número. Apuesto a que te sorprende.

Compartir:

Comentarios (0)

Deja un comentario

No hay comentarios aún. ¡Sé el primero en opinar!

Artículos Relacionados

Sandboxes para agentes de código: qué es Freestyle y por qué me importa
Tecnologíadevopsclaude code

Sandboxes para agentes de código: qué es Freestyle y por qué me importa

Cuando empecé a usar agentes de código en proyectos reales, el mayor miedo no era que escribieran mal — era que ejecutaran cosas en mi máquina sin que yo entendiera qué. Freestyle llegó al HN con 188 puntos tocando exactamente ese nervio.

9 min31
De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js
Historiahistoria programador argentinodesarrollo web

De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js

Empecé tocando una Amiga 500 a los 3 años sin entender nada. Hoy hago deploy en segundos desde una terminal. En el medio: cyber cafés, servidores Linux a las 3am, y un pivot de carrera que cambió todo. Esta es mi historia.

9 min107
Docker para desarrolladores Node.js: de cero a producción sin morir en el intento
TutorialesTutorialTypeScript

Docker para desarrolladores Node.js: de cero a producción sin morir en el intento

Me llevó tres Dockerfiles rotos, dos servidores caídos en producción y una noche sin dormir entender cómo funciona Docker con Node.js de verdad. Acá te cuento todo lo que aprendí para que vos no pases por lo mismo.

9 min105

Categorías

Experimentos4Historia3Opinión3Reflexiones1Tecnología2Tutoriales4

Etiquetas

#nextjs#TypeScript#React#Full Stack#javascript#server-components#devops#node.js#desarrollo web#Gemma#linux#AI#LLM#productividad#claude code#WebGPU#ia#historia programador argentino#Tutorial#strace#Portfolio#Performance#pnpm#npm#yarn#bun#package manager#monorepo#frontend#tooling#docker#backend#docker-compose#produccion#app-router#Inferencia Local#codebase visualization#github#análisis de código#developer tools#code review#repomix#sistemas#elf#dynamic-linking#bajo-nivel#web-development#tailwind#railway#postmortem#Patrones de diseño#Programación#stack tecnologico 2025#postgresql#drizzle orm#optimizacion#web-performance#lighthouse#autobiografía tech#railway deploy#nativo digital#ai tools#desarrollo#reflexión técnica#anthropic#workflow#WebAssembly#inferencia en browser#WebLLM#edge inferencia#IA local#coding-agents#freestyle#sandboxes#seguridad#quantum computing#criptografía#seguridad web#post-quantum cryptography#TLS#JWT#vibe-coding#desarrollo-software#Browser#Edge#Web Vitals#Historia#Best Practices#Carrera#Stack#Next.js#Herramientas#Reflexiones#WebDev#Opinión#Experimentos

Más Leídos

  • 01

    pnpm vs npm vs yarn vs bun: la comparativa definitiva que nadie te va a dar en 2025

    111 views
  • 02

    Next.js App Router: la guía que me hubiera gustado tener cuando migré de Pages Router

    109 views
  • 03

    TypeScript: los patrones que realmente uso todos los días

    109 views
  • 04

    De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js

    107 views
  • 05

    Docker para desarrolladores Node.js: de cero a producción sin morir en el intento

    105 views

Newsletter

Recibe los últimos artículos directamente en tu inbox.

Categorías

Experimentos4Historia3Opinión3Reflexiones1Tecnología2Tutoriales4

Etiquetas

#nextjs#TypeScript#React#Full Stack#javascript#server-components#devops#node.js#desarrollo web#Gemma#linux#AI#LLM#productividad#claude code#WebGPU#ia#historia programador argentino#Tutorial#strace#Portfolio#Performance#pnpm#npm#yarn#bun#package manager#monorepo#frontend#tooling#docker#backend#docker-compose#produccion#app-router#Inferencia Local#codebase visualization#github#análisis de código#developer tools#code review#repomix#sistemas#elf#dynamic-linking#bajo-nivel#web-development#tailwind#railway#postmortem#Patrones de diseño#Programación#stack tecnologico 2025#postgresql#drizzle orm#optimizacion#web-performance#lighthouse#autobiografía tech#railway deploy#nativo digital#ai tools#desarrollo#reflexión técnica#anthropic#workflow#WebAssembly#inferencia en browser#WebLLM#edge inferencia#IA local#coding-agents#freestyle#sandboxes#seguridad#quantum computing#criptografía#seguridad web#post-quantum cryptography#TLS#JWT#vibe-coding#desarrollo-software#Browser#Edge#Web Vitals#Historia#Best Practices#Carrera#Stack#Next.js#Herramientas#Reflexiones#WebDev#Opinión#Experimentos

Más Leídos

  • 01

    pnpm vs npm vs yarn vs bun: la comparativa definitiva que nadie te va a dar en 2025

    111 views
  • 02

    Next.js App Router: la guía que me hubiera gustado tener cuando migré de Pages Router

    109 views
  • 03

    TypeScript: los patrones que realmente uso todos los días

    109 views
  • 04

    De DOS a Cloud: mi viaje de 33 años con la tecnología — desde una Amiga en 1994 hasta deployar en Railway con Next.js

    107 views
  • 05

    Docker para desarrolladores Node.js: de cero a producción sin morir en el intento

    105 views

Newsletter

Recibe los últimos artículos directamente en tu inbox.