← Fundamentos de Sistemas Operativos Bloque 2 · La computadora por dentro · Tema 9 de 34

Tema 09

Del código fuente al programa en ejecución

Escribimos texto y la CPU ejecuta bytes. En este tema recorremos todo lo que ocurre en medio: las cuatro etapas que esconde gcc, qué contiene un archivo ejecutable y qué hace el kernel cuando escribes ./programa. Es el puente entre el hardware que acabamos de estudiar y el sistema operativo que viene a continuación.

1. El recorrido completo

Desde el tema 4 compilamos todos los programas con una sola línea, gcc archivo.c -o programa, y los ejecutamos con ./programa. Detrás de esos dos comandos hay cinco transformaciones. Las cuatro primeras las hace gcc, que en realidad es un coordinador que invoca a otras herramientas; la quinta la hace el sistema operativo.

Etapas de la compilación: de etapas.c a etapas.i, etapas.s, etapas.o y el ejecutable etapas etapas.c etapas.i etapas.s etapas.o etapas preprocesador compilador ensamblador enlazador
Cada herramienta toma el archivo anterior y produce el siguiente. El último es el ejecutable.
EtapaEntrada → salidaQué haceOpción de gcc
1. Preprocesar.c → .iResuelve las líneas que empiezan con #.-E
2. Compilar.i → .sTraduce C a ensamblador.-S
3. Ensamblar.s → .oTraduce ensamblador a código máquina.-c
4. Enlazar.o → ejecutableUne las piezas y fija las direcciones.(ninguna)
5. Cargarejecutable → procesoLleva el programa a memoria y lo pone en marcha.La hace el kernel

Las opciones de la última columna le piden a gcc que se detenga al terminar esa etapa. Las usaremos para mirar cada resultado intermedio.

2. El programa de ejemplo

Usaremos un programa pequeño pero con los ingredientes necesarios: una macro, una variable global con valor inicial, otra sin él, una función propia y una llamada a una función de biblioteca.

etapas.c
#include <stdio.h>

#define VECES 3

int contador = 10;          /* global con valor inicial */
int acumulado;              /* global sin valor inicial */

int doble(int n)
{
    return n * 2;
}

int main(void)
{
    for (int i = 0; i < VECES; i++)
        acumulado += doble(contador);

    printf("Acumulado: %d\n", acumulado);
    return 0;
}
terminal
$ gcc -Wall -Wextra -g etapas.c -o etapas
$ ./etapas
Acumulado: 60

Ahora repetiremos esa compilación paso a paso. Para que los resultados intermedios sean más fáciles de leer, en las secciones siguientes omitimos la opción -g.

3. Etapa 1: preprocesar

El preprocesador trabaja sobre el texto, sin entender C. Se ocupa de las directivas, las líneas que empiezan con #: sustituye cada #include por el contenido completo del archivo nombrado, reemplaza cada macro definida con #define por su valor y elimina los comentarios.

terminal
$ gcc -E etapas.c -o etapas.i
$ wc -l etapas.c etapas.i
   20 etapas.c
  752 etapas.i
$ tail -n 6 etapas.i
    for (int i = 0; i < 3; i++)
        acumulado += doble(contador);

    printf("Acumulado: %d\n", acumulado);
    return 0;
}

El archivo pasó de 20 líneas a varios cientos —la cantidad exacta depende del sistema—, porque ahora contiene todo stdio.h y los archivos que este incluye a su vez. Al final sigue estando nuestro código, con una diferencia: VECES ya no existe; en su lugar está el 3.

Conviene saber qué hay en esas líneas añadidas: declaraciones, no código. stdio.h le dice al compilador que existe una función printf y qué parámetros recibe, para que pueda verificar la llamada. Las instrucciones de printf no están ahí; llegarán en la etapa 4.

4. Etapa 2: compilar

El compilador propiamente dicho hace el trabajo difícil: analiza el texto C, comprueba que sea correcto y lo traduce a lenguaje ensamblador de la arquitectura de destino. Aquí es donde aparecen los errores y las advertencias que aprendimos a leer en el tema 4.

terminal (salida abreviada)
$ gcc -S etapas.i -o etapas.s
$ cat etapas.s
…
doble:
	pushq	%rbp
	movq	%rsp, %rbp
	movl	%edi, -4(%rbp)
	movl	-4(%rbp), %eax
	addl	%eax, %eax
	popq	%rbp
	ret
…

Con lo visto en el tema 6 se puede leer la función entera: las dos primeras instrucciones preparan el marco de pila; la tercera guarda en él el parámetro n, que llegó en el registro edi; la cuarta lo trae a eax; la quinta lo suma consigo mismo, que es la forma más barata de multiplicar por dos; y las dos últimas deshacen el marco y vuelven a quien llamó, dejando el resultado en eax.

Este archivo sigue siendo texto, y es específico de una arquitectura. En un equipo ARM, el mismo etapas.i produce un etapas.s completamente distinto.

5. Etapa 3: ensamblar

El ensamblador convierte cada línea de ensamblador en los bytes de la instrucción de máquina correspondiente. El resultado es un archivo objeto, con extensión .o, que ya no es texto.

terminal
$ gcc -c etapas.s -o etapas.o
$ file etapas.o
etapas.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
$ nm etapas.o                    # la tabla de símbolos
0000000000000000 B acumulado
0000000000000000 D contador
0000000000000000 T doble
0000000000000012 T main
                 U printf

El archivo objeto contiene código máquina, pero todavía no se puede ejecutar. El comando nm muestra por qué. Lista los símbolos, que son los nombres de funciones y variables globales, con una letra que indica dónde está cada uno:

LetraSignificadoEn nuestro ejemplo
TCódigo (text)doble y main
DDatos con valor inicialcontador
BDatos sin valor inicialacumulado
UNo definido aquí (undefined)printf

Hay dos asuntos pendientes. El primero es que las direcciones no son definitivas: tres símbolos distintos figuran en la dirección cero, porque cada zona del archivo se numera desde el principio por separado. Por eso file lo describe como relocatable, reubicable. El segundo es que falta código: el archivo llama a printf, pero no la contiene. Resolver ambos es el trabajo de la última etapa.

6. Etapa 4: enlazar

El enlazador (linker) toma uno o más archivos objeto y construye con ellos un ejecutable. Hace tres cosas:

  1. Combina. Junta el código de todos los archivos objeto en una sola zona de código, y lo mismo con los datos. Añade además un pequeño archivo objeto que la biblioteca de C aporta a todo programa, con el código de arranque.
  2. Resuelve símbolos. Para cada símbolo no definido busca quién lo define, en los otros archivos objeto o en las bibliotecas. Si no lo encuentra, se produce el conocido error undefined reference.
  3. Reubica. Asigna a cada función y a cada variable su dirección definitiva y corrige todas las instrucciones que las mencionan.
terminal
$ gcc etapas.o -o etapas
$ file etapas
etapas: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked,
interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 3.2.0, not stripped
$ nm etapas | grep -E ' (doble|main|_start)$'
0000000000001149 T doble
000000000000115b T main
0000000000001060 T _start

Ahora cada símbolo tiene su propia dirección: main ya no está en 0x12, sino 18 bytes después de doble, exactamente como en el archivo objeto, pero desplazadas ambas a su lugar final. Aparece también un símbolo que no escribimos, _start, del que hablaremos en la sección 9.

Esta etapa es la que permite dividir un programa grande en muchos archivos fuente, compilar cada uno por separado y volver a compilar solo los que cambian. El curso Programación en C para Sistemas Operativos de esta ruta lo desarrolla en detalle.

7. El formato ELF

Un ejecutable no es una simple secuencia de instrucciones. Es un archivo con una estructura precisa, que le indica al sistema operativo cómo cargarlo. En Linux, y en casi todos los sistemas tipo Unix, ese formato se llama ELF (Executable and Linkable Format). Windows usa otro, llamado PE, y macOS otro más, Mach-O; es una de las razones por las que un ejecutable de un sistema no funciona en otro aunque el procesador sea el mismo.

Todo archivo ELF empieza con una cabecera que describe el resto:

terminal (extracto)
$ xxd -l 16 etapas               # los primeros 16 bytes del archivo
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............

$ readelf -h etapas
ELF Header:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
  Class:                             ELF64
  Data:                              2's complement, little endian
  Type:                              DYN (Position-Independent Executable file)
  Machine:                           Advanced Micro Devices X86-64
  Entry point address:               0x1060

Cada campo responde una pregunta que el kernel se hace antes de cargar el archivo:

8. Secciones y segmentos

Después de la cabecera, el contenido del archivo está organizado en secciones, cada una con un tipo de información. Cuatro de ellas forman el programa en sí:

SecciónContieneEn nuestro ejemploPermisos en memoria
.textLas instrucciones de máquina.doble, main, _startLectura y ejecución
.rodataDatos constantes.La cadena "Acumulado: %d\n"Solo lectura
.dataVariables globales con valor inicial.contador, con el valor 10Lectura y escritura
.bssVariables globales sin valor inicial.acumuladoLectura y escritura

La sección .bss tiene una particularidad: no ocupa espacio en el archivo. Como sus variables no tienen valor inicial, no hay nada que guardar; el archivo solo anota cuántos bytes harán falta, y es el kernel quien, al cargar el programa, entrega esa memoria llena de ceros. Por eso en C una variable global sin inicializar vale siempre cero. El comando size resume los tamaños:

terminal
$ size etapas
   text	   data	    bss	    dec	    hex	filename
   1574	    604	     12	   2190	    88e	etapas

Para cargar el programa, el kernel no mira las secciones una por una, sino una segunda tabla del archivo que las agrupa en segmentos: bloques que deben llevarse a memoria juntos y con los mismos permisos.

terminal
$ readelf -lW etapas | grep LOAD
  Type  Offset   VirtAddr           PhysAddr           FileSiz  MemSiz   Flg Align
  LOAD  0x000000 0x0000000000000000 0x0000000000000000 0x000628 0x000628 R   0x1000
  LOAD  0x001000 0x0000000000001000 0x0000000000001000 0x0001a5 0x0001a5 R E 0x1000
  LOAD  0x002000 0x0000000000002000 0x0000000000002000 0x0000f4 0x0000f4 R   0x1000
  LOAD  0x002db8 0x0000000000003db8 0x0000000000003db8 0x00025c 0x000268 RW  0x1000

La primera línea de encabezados la hemos añadido para facilitar la lectura. Cada línea LOAD es una instrucción para el kernel: «toma tantos bytes del archivo a partir de esta posición y colócalos en esta dirección, con estos permisos». Dos columnas merecen atención:

9. La ejecución no empieza en main

La cabecera indicaba como punto de entrada la dirección 0x1060, y nm mostró que ahí está _start, no main. La función main es el comienzo de tu código, pero no del programa.

_start pertenece al código de arranque que el enlazador añadió en la etapa 4. Hace el trabajo previo que C da por supuesto y que nadie más haría:

  1. Recoge los argumentos de la línea de comandos y las variables de entorno, que el kernel dejó en la pila.
  2. Inicializa la biblioteca de C; por ejemplo, la memoria intermedia que usa printf.
  3. Llama a main.
  4. Cuando main devuelve un valor, lo pasa a la llamada al sistema que finaliza el proceso.

El cuarto punto explica algo que usamos desde el tema 4: el return 0 de main se convierte en el código de salida que la shell guarda en $?. Y es necesario, porque main no tiene a quién volver. Si la CPU ejecutara un ret sin nadie detrás, seguiría interpretando como instrucciones lo que hubiera a continuación en la memoria. Un programa nunca «se acaba» por sí solo: termina porque le pide al kernel que lo finalice.

10. Enlace dinámico y estático

El comando file describió el ejecutable como dynamically linked, y eso resuelve un misterio pendiente: el código de printf tampoco está en el ejecutable final. El enlazador solo dejó anotado que el programa necesita esa función y en qué biblioteca se encuentra.

terminal
$ ldd etapas                     # bibliotecas que necesita el ejecutable
	linux-vdso.so.1 (0x00007ffd0b5f2000)
	libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x000079f1c2800000)
	/lib64/ld-linux-x86-64.so.2 (0x000079f1c2b3e000)

libc.so.6 es la biblioteca de C, guardada en un archivo aparte como biblioteca compartida. Se incorpora al programa en el momento de ejecutarlo, no al compilarlo. Esa es la modalidad de enlace dinámico, la habitual. La alternativa es el enlace estático, que copia dentro del ejecutable todo el código de biblioteca que usa:

terminal
$ gcc -static etapas.c -o etapas_estatico
$ ls -lh etapas etapas_estatico | awk '{print $5, $9}'
16K etapas
767K etapas_estatico
$ ldd etapas_estatico
	not a dynamic executable
Enlace dinámicoEnlace estático
Tamaño del ejecutablePequeñoGrande: lleva las bibliotecas dentro
Uso de memoriaTodos los procesos comparten una sola copia de cada bibliotecaCada proceso tiene su copia
Actualizar una bibliotecaBasta con reemplazar su archivoHay que volver a enlazar cada programa
DependenciasNecesita que la biblioteca esté instalada, en una versión compatibleNinguna: funciona en cualquier sistema con ese kernel
ArranqueAlgo más lentoInmediato

La fila del uso de memoria es la que más interesa a un sistema operativo. Casi todos los programas en ejecución usan la biblioteca de C. Con enlace dinámico, su código se carga en memoria física una sola vez y el kernel lo hace aparecer en el espacio de direcciones de cada proceso; es posible porque ese código es de solo lectura.

Si falla la compilación estática

En Ubuntu y Debian la versión estática de la biblioteca de C viene con build-essential. En Fedora hay que instalar el paquete glibc-static.

11. La carga: qué hace el kernel

Llegamos a la quinta transformación, la que convierte un archivo en un programa en marcha. Cuando escribes ./etapas, la shell le pide al kernel, mediante la llamada al sistema execve, que ejecute ese archivo. La parte del kernel que lo hace se llama cargador (loader):

  1. Abre el archivo y comprueba los permisos. El usuario debe tener permiso de ejecución sobre él. Si no, el resultado es «Permission denied».
  2. Lee la cabecera y reconoce el formato. Encuentra el número mágico de ELF y verifica que la arquitectura sea la del procesador.
  3. Prepara un espacio de direcciones nuevo. Descarta la memoria del programa anterior y crea una vacía.
  4. Mapea los segmentos. Recorre las líneas LOAD y asocia cada rango de direcciones con la parte correspondiente del archivo, aplicando sus permisos. Reserva además la memoria en ceros de .bss.
  5. Crea la pila. Reserva la zona de pila y deposita en ella los argumentos de la línea de comandos y las variables de entorno.
  6. Prepara el enlace dinámico. Si el ejecutable lo pide, mapea también el programa indicado en su cabecera como intérprete: el enlazador dinámico, ld-linux.
  7. Entrega la CPU. Coloca en el contador de programa el punto de entrada y en el puntero de pila el tope de la pila nueva, y pasa a modo usuario.

A partir de ahí, el kernel queda detenido y se ejecuta código de usuario: primero el enlazador dinámico, que localiza libc.so.6, la mapea en memoria y conecta la llamada a printf con su código; luego _start; y finalmente main.

Mapear no es copiar

En el paso 4 el kernel no lee el ejecutable completo. Solo anota qué parte del archivo corresponde a cada rango de direcciones. El contenido se trae del disco más tarde, por partes, la primera vez que el programa toca cada zona. Un programa enorme arranca así casi al instante, y las funciones que nunca se usan nunca llegan a ocupar memoria. El mecanismo se llama carga por demanda y se apoya en la caché de disco del tema 7.

Toda la secuencia puede observarse con strace:

terminal (salida abreviada)
$ strace ./etapas
execve("./etapas", ["./etapas"], 0x7ffe5d3a8c10 /* 36 vars */) = 0
brk(NULL)                               = 0x5d0e2a4c9000
openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3
…
openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0"..., 832) = 832
mmap(NULL, 2170256, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x79f1c2800000
…
write(1, "Acumulado: 60\n", 14)         = 14
exit_group(0)                           = ?
+++ exited with 0 +++

La primera línea es la carga misma. Todo lo que sigue hasta el write es el trabajo del enlazador dinámico: abre libc.so.6, lee su cabecera —se distingue el número mágico \177ELF, porque una biblioteca compartida es también un archivo ELF— y la mapea con mmap. Queda así explicado por qué, en el tema 4, un programa de veinte líneas hacía decenas de llamadas al sistema antes de llegar a main. Las dos últimas son las nuestras: el printf y la finalización que hace _start con el valor devuelto por main.

12. El programa en memoria

El resultado de la carga es una disposición de la memoria que se repite, con pocas variantes, en todos los procesos:

Correspondencia entre las secciones del archivo ELF y las zonas de memoria del proceso Archivo en disco Memoria del proceso cabecera ELF .text .rodata .data símbolos, depuración código · r-x constantes · r-- datos · rw- .bss en ceros · rw- heap · rw- bibliotecas · pila se mapea las crea el kernel →
Tres zonas provienen del archivo. Las demás no existen en el disco: el kernel las crea al cargar el programa.

El kernel publica el mapa de memoria de cada proceso en un archivo. El siguiente comando muestra el del propio cat que lo lee:

terminal (extracto)
$ cat /proc/self/maps
5c1e3a2f4000-5c1e3a2f6000 r--p 00000000 103:02 1311499   /usr/bin/cat
5c1e3a2f6000-5c1e3a2fb000 r-xp 00002000 103:02 1311499   /usr/bin/cat
5c1e3a2fb000-5c1e3a2fe000 r--p 00007000 103:02 1311499   /usr/bin/cat
5c1e3a2ff000-5c1e3a300000 rw-p 0000a000 103:02 1311499   /usr/bin/cat
5c1e5b0a1000-5c1e5b0c2000 rw-p 00000000 00:00 0          [heap]
79f1c2800000-79f1c2828000 r--p 00000000 103:02 1315020   /usr/lib/x86_64-linux-gnu/libc.so.6
…
7ffd0b4e1000-7ffd0b502000 rw-p 00000000 00:00 0          [stack]

Cada línea es un rango de direcciones con sus permisos y, a la derecha, el archivo del que procede. Se reconocen los segmentos LOAD del ejecutable con los mismos permisos que mostró readelf, la biblioteca de C mapeada por el enlazador dinámico, y dos zonas sin archivo detrás: el heap, de donde sale la memoria que se pide con malloc, y la pila. Estudiamos este mapa con detenimiento en el tema 27.

13. Programa y proceso

Este tema permite precisar una distinción que hasta ahora usamos de manera informal:

ProgramaProceso
Qué esUn archivo: instrucciones y datos iniciales.Un programa en ejecución.
Dónde estáEn el disco.En la memoria.
NaturalezaPasivo: no hace nada por sí mismo.Activo: tiene un contador de programa que avanza.
Qué más tieneNada.Registros, pila, heap, archivos abiertos, un PID, un usuario.
DuraciónHasta que se borra el archivo.Desde que se carga hasta que termina.

Un programa es una receta; un proceso es alguien cocinándola. De un mismo programa pueden existir varios procesos a la vez, cada uno con su propio estado: es lo que hicimos en el tema 2 al lanzar dos copias de ilusion, que partían del mismo archivo y llevaban contadores distintos.

Con esto cerramos el bloque de hardware. Sabemos qué ejecuta la CPU, dónde están los datos, cómo se habla con los dispositivos y cómo llega un programa a la memoria. En todos los casos apareció alguien que organiza, protege y decide: el kernel. El bloque 3 se ocupa de él.

14. ¿Y los lenguajes interpretados?

Un script de Bash o de Python es texto, no un archivo ELF. Sin embargo, en el tema 4 ejecutamos ./verificar.sh como si fuera un programa. La explicación está en el paso 2 de la carga: el kernel reconoce un segundo número mágico, los caracteres #! al comienzo del archivo.

Cuando los encuentra, lee el resto de esa primera línea, que contiene la ruta de un programa —por ejemplo, /bin/bash—, y carga ese programa, pasándole el nombre del script como argumento. Lo que se ejecuta es el intérprete, un ejecutable ELF como cualquier otro, que lee el texto y hace lo que este indica.

Ocurre lo mismo con Python, JavaScript o Java: el proceso que ve el kernel es el intérprete o la máquina virtual del lenguaje. Tu programa es, para el sistema operativo, un archivo de datos que ese proceso lee. Por debajo, todo lo que se ejecuta en Linux termina siendo un ELF con instrucciones de máquina.

15. Resumen

16. Preguntas de repaso

Intenta responder cada una antes de abrir la respuesta.

1. ¿Qué hace cada una de las cuatro etapas de la compilación?

El preprocesador resuelve las directivas #include y #define, y produce texto C expandido. El compilador traduce ese texto a ensamblador. El ensamblador lo convierte en código máquina dentro de un archivo objeto. El enlazador combina los archivos objeto, resuelve los símbolos y produce el ejecutable.

2. stdio.h se incluye en la etapa 1. ¿Significa eso que el código de printf ya está en el programa?

No. stdio.h solo contiene la declaración de printf, para que el compilador pueda verificar la llamada. El código está en la biblioteca de C, y se incorpora al enlazar si el enlace es estático, o al ejecutar si es dinámico.

3. ¿Por qué un archivo objeto no se puede ejecutar?

Porque sus direcciones no son definitivas, puede tener símbolos sin definir, como printf, y le falta el código de arranque con el punto de entrada. Todo eso lo completa el enlazador.

4. Un programa declara int tabla[1000000]; como variable global sin valor inicial. ¿Aumenta en 4 MB el tamaño del ejecutable?

No. La variable va a la sección .bss, que no ocupa espacio en el archivo: solo se anota su tamaño. El kernel reserva esa memoria, en ceros, al cargar el programa. Si se le diera un valor inicial distinto de cero, iría a .data y sí ocuparía ese espacio.

5. ¿Cómo sabe el kernel que un archivo es un ejecutable y no, por ejemplo, una imagen?

Por los primeros bytes del archivo. Un ELF empieza con el número mágico 7f 45 4c 46, y un script con #!. La extensión del nombre no influye. Además, el archivo debe tener permiso de ejecución.

6. ¿Por qué el segmento de código es de lectura y ejecución, pero no de escritura?

Para impedir que el programa modifique sus propias instrucciones, por error o por un ataque. Además, al no cambiar, ese código puede compartirse entre todos los procesos que ejecutan el mismo programa o usan la misma biblioteca.

7. ¿Qué pasaría si main terminara con un ret y no existiera _start?

No habría una dirección de retorno válida ni nadie que finalizara el proceso. La CPU saltaría a una dirección arbitraria e interpretaría como instrucciones lo que hubiera allí, lo que casi seguro acabaría en un fallo. _start evita eso llamando a main y pidiendo luego al kernel que termine el proceso.

8. Da una ventaja del enlace dinámico y una del estático.

Dinámico: los ejecutables son pequeños y todos los procesos comparten en memoria una sola copia de cada biblioteca, que además puede actualizarse sin recompilar los programas. Estático: el ejecutable no depende de las bibliotecas instaladas, de modo que funciona en cualquier sistema con un kernel compatible.

9. Al ejecutar ./verificar.sh, ¿qué programa carga realmente el kernel?

Carga /bin/bash, el intérprete indicado en la primera línea del script tras #!, y le pasa el nombre del script como argumento. El proceso resultante es Bash, que lee el archivo y ejecuta sus comandos.

Para practicar

  1. Reproduce las cuatro etapas con etapas.c y examina cada archivo intermedio. ¿Cuántas líneas tiene tu etapas.i?
  2. Comenta la línea #include <stdio.h> y compila. ¿En qué etapa aparece el problema y de qué tipo es? Restáurala y cambia printf por imprimir: ¿qué etapa falla ahora y con qué mensaje?
  3. Ejecuta objdump -s -j .rodata etapas y localiza la cadena de formato. Luego objdump -s -j .data etapas y busca el valor 10 de contador. ¿En qué orden están sus bytes?
  4. Agrega a etapas.c la variable global int grande[100000]; y compara el tamaño del ejecutable con ls -l y la columna bss de size, antes y después. Repite dándole un valor inicial: int grande[100000] = {1};.
  5. Usa strace -c para comparar la cantidad de llamadas al sistema de etapas y de etapas_estatico. Explica la diferencia.
  6. Ejecuta file /bin/ls /bin/bash $(which python3). ¿Qué formato tienen los intérpretes?