Cómo Linux Ejecuta Binarios ELF: Guía Completa
ELF (Executable and Linkable Format) es el estándar de formato para binarios en Linux que permite que el kernel cargue y ejecute programas. El enlazamiento dinámico resuelve referencias a funciones externas en tiempo de ejecución a través del enlazador ld.so, permitiendo que múltiples procesos compartan el mismo código en memoria.
En 30 segundos
- ELF es el formato estándar para ejecutables en Linux desde SVR4, con magic number 0x7f seguido de “ELF”
- El kernel mapea segmentos ELF a memoria con permisos correctos (LOAD, INTERP, DYNAMIC) en lugar de cargar todo el archivo
- El enlazador dinámico ld.so busca librerías compartidas (.so) en rutas configurables antes de entregar control a main()
- Lazy binding retrasa la resolución de símbolos hasta el primer acceso a través de GOT y PLT, reduciendo tiempo de startup
- Position-Independent Code (PIC) con ASLR randomiza direcciones de memoria, mejorando seguridad contra exploits
¿Qué es ELF? Formato estándar de binarios en Linux
Ponele que abrís una terminal y ejecutás /bin/ls. El kernel no simplemente copia el archivo a memoria y arranca a ejecutar. Primero tiene que entender su estructura, validar que sea un binario legítimo, separar lo que va en memoria ejecutable de lo que es metadatos, resolver dependencias externas. Todo eso es responsabilidad de ELF.
ELF (Executable and Linkable Format) es el formato estándar que define cómo se estructuran binarios, librerías compartidas (.so), archivos objeto (.o) y core dumps en sistemas Linux y Unix modernos. Heredado de SVR4 (Unix System V Release 4) desde los 90, se convirtió en el estándar multiplataforma. Funciona en x86, ARM, RISC-V, PowerPC — cualquier arquitectura que implemente Linux.
La flexibilidad de ELF es su punto fuerte. No es solo un formato de ejecutables. El mismo ELF sirve para binarios estáticos, dinámicos, librerías, archivos objeto intermedios — todo bajo un mismo estándar. Eso sí, esa flexibilidad tiene un costo: si comparás con formatos más simples, ELF es relativamente complejo.
Estructura interna: encabezados, segmentos y secciones

Un binario ELF arranca siempre igual: con un magic number 0x7f 0x45 0x4c 0x46 (en ASCII, 0x7f seguido de “ELF”). Cualquier herramienta que procese binarios chequea eso primero. Si no está, abandona.
Después viene el ELF header: metadatos básicos. La arquitectura (x86-64, ARM), si es 32 o 64 bits, el tipo de archivo (ejecutable, libería compartida, archivo objeto), dónde empieza el programa headers y dónde los section headers. Es como el índice de un libro, pero para la máquina.
Ahí viene la diferencia clave que confunde a muchos: segmentos vs. secciones. Un segmento es una visión del binario pensada para ejecución en tiempo de runtime. Una sección es una visión pensada para linking en tiempo de compilación. Cuando ejecutás un programa, el kernel usa los program headers (segmentos). Cuando linkeas un binario nuevo contra librerías, usás los section headers (secciones).
Los segmentos importantes son: LOAD (código ejecutable, datos estáticos — lo que va a memoria), INTERP (si el binario es dinámico, apunta a la ruta del enlazador), DYNAMIC (tabla de información que el enlazador necesita). Las secciones comunes: .text (código máquina), .data (variables globales inicializadas), .bss (variables globales sin inicializar — “Block Started by Symbol”), .got (Global Offset Table, que veremos después), .plt (Procedure Linkage Table).
El proceso de ejecución: kernel, intérprete y cargador
Cuando escribeís ./programa en la terminal, el kernel entra en acción. Lee los primeros bytes del archivo, valida el magic number de ELF, chequea si es dinámico o estático, identifica la arquitectura. Lo explicamos a fondo en ejecutar código localmente sin dependencias.
Si es dinámico (lo normal hoy en día), el kernel busca el segmento INTERP en el ELF. Este segmento contiene una string: generalmente /lib64/ld-linux-x86-64.so.2 (en glibc). El kernel carga primero ese archivo — el enlazador dinámico — como si fuera el programa principal (spoiler: no es), mapea los segmentos LOAD del programa original a direcciones de memoria randomizadas (si ASLR está activo), y transfiere control al enlazador.
Hasta acá, ni una línea de código del programa real corrió. Solo metadatos, validación, mapeo de memoria. El enlazador toma control antes que main().
¿Y qué hace el enlazador? Busca todas las librerías compartidas que el programa necesita (listadas en la tabla DYNAMIC), las carga desde el filesystem, resuelve símbolos — es decir, encuentra dónde está cada función que el programa importa — y actualiza tablas internas para que cuando el programa las llame, vaya a la dirección correcta. Recién después de todo esto, el enlazador salta a la función main() del programa.
Dynamic linking: resolución de símbolos en tiempo de ejecución
Un programa compilado dinámicamente tiene símbolos pendientes. Cuando compilás un .c que usa printf(), el compilador no mete el código de printf dentro de tu binario — mete un placeholder que dice “acá va una llamada a printf, pero no sé dónde está todavía”. El trabajo de resolver eso — de encontrar dónde realmente vive printf en libc.so.6 — queda para el enlazador dinámico en tiempo de ejecución.
El enlazador busca esas funciones siguiendo una ruta específica: primero en LD_RPATH (ruta embedida en el ELF), luego en LD_LIBRARY_PATH (variable de entorno), luego en /etc/ld.so.cache (caché que genera ldconfig), finalmente en directorios por defecto como /lib, /usr/lib. Cuando encuentra la librería, carga los segmentos de esa librería en memoria (si ya no están), busca el símbolo dentro de la tabla de símbolos de esa librería, obtiene la dirección, y guarda esa dirección en la Global Offset Table (GOT) del programa.
Ventajas: el tamaño del binario es más chico porque no incluye todo el código de libc, el mismo código de libc se carga una sola vez en memoria y lo comparten todos los procesos (ahorro masivo), si actualizás la librería el programa automáticamente usa la versión nueva sin recompilar.
Ahora, acá viene un detalle de rendimiento: lazy binding vs. eager binding. En lazy binding (por defecto), el enlazador no resuelve todos los símbolos al startup. Resuelve cada símbolo la primera vez que se llama. Primera vez que llamás printf, el enlazador se dispara, busca, actualiza GOT. Segunda y siguientes, va directo a la dirección en GOT. Eso reduce significativamente el tiempo de startup para programas grandes que importan cientos de símbolos pero solo usan algunos.
Si necesitás eager binding (resolver todo al startup), seteas LD_BIND_NOW=1 antes de ejecutar el programa. Startup más lento, pero detectás errores de símbolos faltantes inmediatamente en lugar de que fallen cuando el usuario toque ese código. Tema relacionado: proteger código fuente y binarios.
Tablas GOT y PLT: el mecanismo de indirección dinámico
GOT (Global Offset Table) es una tabla de direcciones. Cada entrada apunta a la dirección en memoria de una función o variable global importada. Es writable — se actualiza en runtime. PLT (Procedure Linkage Table) es una serie de pequeños stubs de código máquina. Cada función importada tiene un entry en PLT que, en lazy binding, llama al enlazador la primera vez.
Así funciona en lazy binding: compilás tu programa con -fPIC (Position Independent Code). Cuando llamás printf(), en realidad saltás a una instrucción en PLT.printf. Ese stub chequea si la dirección ya fue resuelta en GOT.printf. Si no (primera vez), salta a código del enlazador que busca printf en libc, actualiza GOT.printf con la dirección verdadera, y saltá a esa dirección. Segunda vez que llamás printf, PLT.printf ve que GOT.printf ya tiene una dirección, saltá directo sin pasar por el enlazador.
Esto funciona porque el código es Position Independent: no importa dónde termine mapeado en memoria (ASLR randomiza eso), los offsets relativos siguen siendo válidos. GOT y PLT son las herramientas que lo hacen posible. Sin ellas, ASLR no podría existir — necesitarías hardcodear direcciones absolutas en el binario, y estarían fijas.
El enlazador dinámico ld.so: cargando librerías compartidas
ld.so (o ld-linux.so.2 en glibc) es el enlazador dinámico de verdad. Cuando le preguntas a gente qué es, te dicen “es una librería”, pero técnicamente es un ejecutable especial que el kernel carga como intérprete. Tiene un solo trabajo: cargar el programa, cargar sus dependencias, resolver símbolos, transferir control a main().
Variables de entorno como LD_LIBRARY_PATH te permiten overridear las rutas de búsqueda. Setea LD_LIBRARY_PATH=/custom/path antes de ejecutar, y ld.so busca librerías ahí primero. Útil para debugging, para usar versiones custom de librerías sin instalarlas globalmente. Pero ojo: si hacés LD_LIBRARY_PATH=/malicious/path y metes una librería falsa de libc ahí, cualquier programa que ejecute hereda ese PATH — es un vector de ataque si el usuario no sabe qué está haciendo.
ld.so mantiene un caché en /etc/ld.so.cache generado por ldconfig. Sin ese caché, buscaría en el filesystem cada vez que corre un programa — sería lentísimo. ldconfig escanea /lib, /usr/lib, y otros directorios, genera un índice binario rápido. Si instalás una librería nueva en una ruta no estándar, tenés que correr ldconfig o agregar la ruta a /etc/ld.so.conf.d/ para que ld.so la encuentre.
Dependencias transitivas también: si tu programa linkea contra libA.so y libA.so linkea contra libB.so, ld.so carga ambas. El archivo NEEDED en cada librería lista sus dependencias. Es recursivo. ld.so maneja eso automáticamente, pero a veces genera sorpresas — si dos dependencias cargan versiones diferentes de la misma librería, te arman un quilombo.
Relocation y direccionamiento: código independiente de posición
Relocation es el proceso de ajustar referencias dentro del código cuando se carga en memoria. Cuando compilás un programa, el compilador genera code que tiene referencias a direcciones — la dirección de una variable global, de una función. Pero en tiempo de compilación no sabe dónde irá exactamente ese código en memoria.
En un binario estático (sin dependencias dinámicas), el linker fijo todas las direcciones. Todo está en un mismo lugar, direcciones absolutas, listo. En un binario dinámico, eso no funciona porque la dirección final no se conoce hasta que el enlazador cargue todo en runtime. Te puede servir nuestra cobertura de acelerar binarios con herramientas modernas.
Position-Independent Code (PIC) se compila con -fPIC. En lugar de usar direcciones absolutas, usa offsets relativos a la posición del código (RIP-relative en x86-64). El compilador genera instrucciones como “saltá al símbolo foo, dondequiera que esté relativo a acá”. Cuando ld.so mapea el código en memoria, esos offsets siguen siendo válidos sin importar la dirección base.
PIE (Position Independent Executable, -pie flag) lleva esto más lejos: el ejecutable mismo es position-independent. Combinado con ASLR (Address Space Layout Randomization), cada vez que corrés el programa, todas las secciones de memoria — código, stack, heap — están en direcciones diferentes. Eso mitiga un montón de exploits que dependen de direcciones hardcodeadas: buffer overflows que salten a code gadgets específicos, ROP chains que asumen direcciones exactas. Si todo está randomizado, es mucho más difícil.
El tradeoff: código PIC es levemente más lento que código con direcciones absolutas porque cada referencia es relativa, no directa. Pero la ganancia de seguridad vale la pena, y la diferencia de velocidad es casi imperceptible en sistemas modernos.
Herramientas para inspeccionar binarios: readelf, objdump, ldd
Si querés ver qué hay dentro de un ELF, tenés tres herramientas estándar.
readelf es el más específico para ELF. readelf -l /bin/ls muestra los program headers (segmentos). Ves qué segmentos hay, sus permisos, dónde mapean. Si ejecutás readelf -d /bin/ls, ves la tabla DYNAMIC — exactamente qué librerías necesita, qué símbolos importa, flags de comportamiento del enlazador. readelf -s muestra la tabla de símbolos: cada función y variable, si son locales o globales, si están definidas en el binario o importadas.
objdump es más versátil, soporta múltiples formatos, no solo ELF. objdump -d /bin/ls te disassembler el código máquina a instrucciones legibles. objdump -s te muestra el contenido raw de secciones específicas. Útil para análisis profundo, ingeniería inversa, debugging de código compilado.
ldd te muestra las dependencias .so que necesita un binario. ldd /bin/ls lista todas las librerías, y dónde las encontró ld.so. Advertencia importante: ldd ejecuta el programa en realidad (o simula ejecución). Nunca hagas ldd /programa/desconocido de fuentes no confiables — podrías estar ejecutando código malicioso.
Errores comunes
Compilar con -fPIC cuando no es necesario
-fPIC es obligatorio para librerías compartidas. Para ejecutables estáticos, no es necesario, y agregá un overhead mínimo pero medible. Si compilás un programa standalone que nunca será librería, gcc -O2 sin -fPIC es más rápido.
Mezclar binarios estáticos y dinámicos sin entender las consecuencias
Algunas librerías se distribuyen compiladas estáticas (.a) y dinámicas (.so). Linkar contra la versión estática mete todo el código dentro del binario — más grande, pero más portátil. Linkar contra dinámica — más chico, pero depende de que la librería esté disponible en el sistema destino con la versión compatible. Elegir uno u otro sin pensar genera dolor después. En seleccionar plataforma de desarrollo profundizamos sobre esto.
Asumir que LD_LIBRARY_PATH soluciona todo
LD_LIBRARY_PATH overridea la búsqueda de librerías. Es útil para testing, pero no es una solución de producción. El programa queda frágil — depende de que alguien setee correctamente esa variable cada vez. Lo correcto es compilar con -Wl,-rpath para embedir la ruta en el binario, o instalar librerías en ubicaciones estándar que ld.so encuentra automáticamente.
Preguntas Frecuentes
¿Cómo ejecuta Linux exactamente un binario ELF?
El kernel lee el ELF header, valida el magic number 0x7f ELF, carga el segmento INTERP (el enlazador), mapea los segmentos LOAD a memoria con permisos correctos, transfiere control al enlazador. El enlazador carga librerías, resuelve símbolos, y salta a main(). El programa nunca ve esa mecanería — arranca directamente.
¿Qué es el dynamic linking y por qué es mejor que static?
Dynamic linking carga librerías compartidas en runtime. Ventajas: binarios más chicos, librerías compartidas en memoria entre procesos (ahorro masivo en RAM), actualizaciones de librerías sin recompilar. Static embedding todo en el binario — más portátil, pero más grande, desperdicia memoria. Dynamic es el estándar en Linux moderno.
¿Qué son GOT y PLT y cómo resuelven símbolos?
PLT (Procedure Linkage Table) contiene stubs que llaman al enlazador dinámico en primer acceso. GOT (Global Offset Table) almacena direcciones de funciones importadas. En lazy binding, primera llamada a una función va a PLT → enlazador → resuelve símbolo → actualiza GOT. Siguientes llamadas van directo a GOT. Eso permite código position-independent.
¿Cómo inspeccionó un binario ELF para ver qué librerías necesita?
Usá ldd /programa para listar dependencias, o readelf -d /programa para ver la tabla DYNAMIC en detalle. readelf -s /programa muestra símbolos (cuáles importa vs. define). objdump -d te disassembla el código máquina. Combiná estas herramientas según qué necesites investigar.
¿Qué es ASLR y cómo se relaciona con code position-independent?
ASLR (Address Space Layout Randomization) randomiza las direcciones de memoria donde mapean código, librerías, stack, heap cada vez que corre el programa. Position-Independent Code (PIC) usa offsets relativos en lugar de direcciones absolutas, así el código funciona sin importar dónde esté mapeado. Juntos, mitigan exploits que dependen de direcciones hardcodeadas.
Conclusión
ELF es el puente entre lo que escribís en código C y lo que el kernel ejecuta en memoria. Entender cómo funciona — cómo el kernel valida y mapea binarios, cómo ld.so resuelve dependencias, cómo GOT y PLT hacen que code position-independent sea viable — es fundamental si trabajás con Linux a nivel sistemas, debugging, seguridad o performance.
No necesitás memorizar todos los detalles para escribir software que funcione. Pero cuando algo rompe misteriosamente, cuando un programa no encuentra una librería que está instalada, cuando un exploit aparentemente funciona — entender ELF te da herramientas para investigar.
Las herramientas existen: readelf, objdump, ldd. Úsalas. Abrí un binario cualquiera, mirá su estructura. Es la mejor forma de aprender cómo Linux realmente ejecuta tu código.
Fuentes
- ELF Specification – Linux Foundation — especificación oficial completa de ELF
- ld.so man page – Linux man pages — documentación del enlazador dinámico, variables de entorno
- Linux ELF Dynamic Linking – FMDLC — análisis técnico detallado de dynamic linking
- GOT y explotación – Fundación Sadosky — cómo GOT se relaciona con exploits de seguridad
- ELF Binaries on Linux – Linux Audit — análisis práctico y herramientas de inspección






