¿Qué ocurre antes de main()?

Autor: | Última modificación: 8 de marzo de 2024 | Tiempo de Lectura: 4 minutos
Temas en este post:

La Era Hyboria de un programa en C/C++

Las historias de Conan El Bárbaro se desarrollan en una era mítica, creada por Robert Howard, llamada la Era Hyboria. Se trata de un pasado remoto, un tiempo antes del tiempo:

«…between the years when the oceans drank Atlantis and the gleaming cities, and the years of the rise of the Sons of Aryas…»

¿Qué ocurre antes de main()?
Conan, en aquellos tiempos, trabajaba para la sociedad protectora de las serpientes.

Para la mayoría de los programadores de aplicaciones, hablar de lo que ocurre antes de main() suena un poco a la Era Hyboria. Un tiempo remoto, que no estás muy seguro de si existe o es un mito. Al fin y al cabo, todo programa (al menos los de C/C++) empieza en main().

Sin embargo, si main() es una función y las funciones son llamadas por otras, ¿quién llama a main()?

¿Cómo se llega a main()?

El camino que lleva a main depende mucho de donde se está ejecutando el programa en cuestión y del sistema operativo. Sin embargo, en la mayoría de los casos, se trata de una función llamada _start(), esta es la encargada de llamar a main() (y algunas cosas más). Esto parece una respuesta inútil, ya que solo cambia la pregunta: ¿quién llama a _start()? Antes de contestar a eso, veamos un poco más sobre la función _start().

Antes de main(), hubo _start()

Para la inmensa mayoría de los programas de C/C++, el punto de entrada real no es main, sino _start. Es _start quien inicializa el runtime necesario para el programa, llama a main y recibe su código de salida cuando termina.

Es decir, _start no solo se encarga del start de main, sino también de su end. 😉

El uso de la función _start es una convención y podrías usar otra función (siempre y cuando haga lo que tiene que hacer). Es el enlazador (linker) el responsable de determinar el punto de entrada; lo puedes cambiar, ya sea en clang o gcc, con la opción -e. 

La implementación de _start normalmente es proporcionada por libc (la biblioteca estándar de C) y suele estar escrita en ensamblador. La mayoría de los compiladores son distribuidos con un fichero precompilado (normalmente llamado crt0.o) con la implementación para cada una de las arquitecturas que soporta el compilador.

¿Qué hace _start?

Las principales responsabilidades de _start son las siguientes:

  1. Configuración de muy bajo nivel, como preparar los registros del procesador, inicializar la memoria, etc.
  2. Inicializar la pila (stack) que va a usar el programa.
  3. Inicializar el runtime de C/C++
  4. Hacer un jump a main.
  5. Salir con el código de retorno de main, cuando esta haya terminado.

El orden y algunos de estos pasos, especialmente el primero, van a depender mucho de en qué entorno se está ejecutando el programa. Si se ejecuta “a pelo” (baremetal) o en un sistema operativo.

Esto nos lleva a la pregunta original: si start llama a main, ¿quién llama a start? Pues depende precisamente de si estamos ejecutándonos directamente sobre el hardware o en un sistema operativo.

Baremetal: el vector de reset

Este sería el caso más sencillo y primitivo. Supongamos que tenemos una plataforma baremetal con un binario almacenado en memoria flash. Cuando arranca el procesador, este va a copiar el programa de la flash a la memoria RAM. Una vez esté cargado en memoria, el procesador salta a la dirección del reset handler. Ese código puede hacer algo de la configuración de muy bajo nivel que habíamos mencionado anteriormente y termina llamando a _start.

En un sistema operativo

El caso anterior es poco familiar para la mayoría de los programadores que no trabajamos con sistemas empotrados. Para la inmensa mayoría de los programadores y de los usuarios, cuando somos conscientes de estar usando un ordenador, es siempre vía un sistema operativo.

En este caso, el proceso es algo más elaborado. Cuando arrancas un programa, ya sea desde la línea de comandos o desde la interfaz de usuario, el sistema llama a un cargador (program loader). Dicho cargador es responsable de lo siguiente:

  1. Comprobar permisos.
  2. Reservar espacio para la pila del programa.
  3. Reservar espacio para el montículo del programa.
  4. Inicializar registros.
  5. Meter argc, argv y envp en la pila del programa.
  6. Mapear direcciones virtuales de memoria.
  7. Enlazador dinámico.
  8. Finalmente, llamar a _start.

El cargador en cuestión depende un poco del sistema operativo. En el caso de Linux o MacOS, es alguna función de la familia de exec(), mientras que en el caso de Windows es la función LdrInitializeThunkFunction en ntdll.dll.

¡main ni siquiera tiene que ser una función!

Pues sí, main no necesita ser una función. Lo que hará _start es saltar a esa dirección de memoria y ejecutar el código que ahí encuentre. Esto se utilizó en varias ocasiones en concursos de código C “ofuscado”. El truco es crear un array de int que contenga código binario ejecutable.

Un ejemplo muy sencillo, sería declarar:

Int main[10];

En la mayoría de los sistemas, esto va a compilar. Lógicamente, al ejecutarlo va a cascar vilmente, ya que el array no contiene nada.

Ya sabes más de lo que querías sobre main

Ahora ya sabes más sobre main de lo que probablemente hubieses querido. La próxima vez que acudas a una fiesta, ya tienes tema de abordaje para alguien interesante.

¿Qué ocurre antes de main()?

Eso sí, si todavía te quedan ganas, te dejo un interesante (en serio) vídeo de la conferencia CppCon del 2018: How we get to main().

YouTube video