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
- Carga el ELF en memoria — mapea los segmentos del archivo
-
Resuelve las dependencias — busca cada
.soque necesita el binario - Hace la relocación — parchea las direcciones de memoria para que todo encaje
-
Ejecuta los constructores — código de inicialización antes del
main() - 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.
Este artículo fue publicado originalmente en juanchi.dev
Top comments (0)