Esta es más una pregunta teórica que otra cosa. Soy un estudiante de ciencias de la computación con un gran interés en la programación de bajo nivel. Me encanta descubrir cómo funcionan las cosas bajo el capó. Mi especialización es el diseño de compiladores.
De todos modos, mientras trabajo en mi primer compilador, se me ocurren cosas que son un poco confusas.
Cuando escribe un programa en C/C++, lo tradicional que la gente sabe es que un compilador convierte mágicamente su código C/C++ en código nativo para esa máquina.
Pero algo no cuadra aquí. Si compilo mi programa C/C++ dirigido a la arquitectura x86, parecería que el mismo programa debería ejecutarse en cualquier computadora con la misma arquitectura. Pero eso no sucede. Debe volver a compilar su código para OS X, Linux o Windows (y una vez más para 32 bits frente a 64 bits)
Me pregunto por qué este es el caso. ¿No apuntamos a la arquitectura/conjunto de instrucciones de la CPU al compilar un programa C/C++? Y un sistema operativo Mac y un sistema operativo Windows pueden ejecutarse en la misma arquitectura exacta.
(Sé que Java y similares apuntan a una VM o CLR, por lo que no cuentan)
Si tomé la mejor respuesta a esto, diría que C/C++ debe compilar las instrucciones específicas del sistema operativo. Pero cada fuente que leo dice que el compilador apunta a la máquina. Así que estoy muy confundido.
No, no solo está apuntando a una CPU. También está apuntando al sistema operativo. Digamos que necesita imprimir algo en la pantalla del terminal usando cout
. cout
eventualmente terminará llamando a una función API para el sistema operativo en el que se ejecuta el programa. Esa llamada puede ser, y será, diferente para diferentes sistemas operativos, lo que significa que debe compilar el programa para cada sistema operativo para que realice las llamadas de sistema operativo correctas.
¿Cómo asignas la memoria? No hay instrucciones de CPU para asignar memoria dinámica, debe solicitar la memoria al sistema operativo. Pero, ¿cuáles son los parámetros? ¿Cómo se invoca el sistema operativo?
¿Cómo se imprime la salida? ¿Cómo abres un archivo? ¿Cómo se configura un temporizador? ¿Cómo se muestra una interfaz de usuario? Todas estas cosas requieren solicitar servicios del sistema operativo, y diferentes sistemas operativos brindan diferentes servicios con diferentes llamadas necesarias para solicitarlos.
¿No apuntamos a la arquitectura/conjunto de instrucciones de la CPU al compilar un programa C/C++?
No, no lo haces.
Quiero decir que sí, estás compilando para un conjunto de instrucciones de CPU. Pero eso no es todo lo que es la compilación.
Considere el más simple "¡Hola, mundo!" programa. Todo lo que hace es llamar a printf
, ¿verdad? Pero no hay un código de operación de conjunto de instrucciones "printf". Entonces... ¿qué sucede exactamente?
Bueno, eso es parte de la biblioteca estándar de C. Su función printf
realiza algún procesamiento en la cadena y los parámetros, luego... lo muestra. ¿Cómo sucede eso? Bueno, envía la cadena a la salida estándar. Bien... ¿quién controla eso?
El sistema operativo. Y tampoco hay un código de operación de "salida estándar", por lo que enviar una cadena a la salida estándar implica algún tipo de llamada al sistema operativo.
Y las llamadas al sistema operativo no están estandarizadas en todos los sistemas operativos. Prácticamente todas las funciones de biblioteca estándar que hacen algo que no podría crear por su cuenta en C o C++ se comunicarán con el sistema operativo para hacer al menos parte de su trabajo.
malloc
? La memoria no te pertenece; pertenece al sistema operativo, y tal vez se le permita tener algunos. scanf
? La entrada estándar no te pertenece; pertenece al sistema operativo, y tal vez puedas leerlo. Y así.
Su biblioteca estándar se crea a partir de llamadas a rutinas del sistema operativo. Y esas rutinas del sistema operativo no son portátiles, por lo que la implementación de su biblioteca estándar no es portátil. Entonces, su ejecutable tiene estas llamadas no portátiles.
Y además de todo eso, los diferentes sistemas operativos tienen diferentes ideas de cómo se ve un "ejecutable". Después de todo, un ejecutable no es solo un montón de códigos de operación; ¿Dónde crees que se almacenan todas esas variables static
constantes y preiniciadas? Los diferentes sistemas operativos tienen diferentes formas de iniciar un ejecutable, y la estructura del ejecutable es parte de eso.
Estrictamente hablando, no es necesario
Tiene Wine, WSL1 o Darling, que son todos cargadores para los formatos binarios de los otros sistemas operativos respectivos. Estas herramientas funcionan tan bien porque la máquina es básicamente la misma.
Cuando crea un ejecutable, el código de máquina para "5 + 3" es básicamente el mismo en todas las plataformas basadas en x86, sin embargo, existen diferencias, ya mencionadas por las otras respuestas, como:
Estos difieren. Ahora, por ej. wine hace que Linux comprenda el formato WinPE y luego "simplemente" ejecuta el código de la máquina como un proceso de Linux (¡sin emulación!). Implementa partes de WinAPI y las traduce para Linux. En realidad, Windows hace más o menos lo mismo, ya que los programas de Windows no se comunican con el kernel de Windows (NT), sino con el subsistema Win32... que traduce la WinAPI a la API de NT. Como tal, el vino es "básicamente" otra implementación de WinAPI basada en la API de Linux.
Además, en realidad puede compilar C en algo más que el código de máquina "desnudo", como el código LLVM Byte o wasm. Proyectos como GraalVM hacen incluso posible ejecutar C en la máquina virtual de Java: Compile una vez, ejecute en todas partes. Allí apunta a otra API/ABI/formato de archivo que estaba destinado a ser "portátil" desde el principio.
Entonces, mientras que el ISA constituye todo el lenguaje que una CPU puede entender, la mayoría de los programas no solo "dependen" del ISA de la CPU, sino que también necesitan que el sistema operativo funcione. La cadena de herramientas debe encargarse de eso
En realidad, estás bastante cerca de tener razón, sin embargo. De hecho, podría compilar para Linux y Win32 con su compilador y tal vez incluso obtener el mismo resultado, para una definición bastante estrecha de "compilador". Pero cuando invocas al compilador de esta manera:
c99 -o foo foo.c
No solo compila (traduce el código C a, por ejemplo, ensamblaje), sino que hace esto:
Puede haber más o menos pasos, pero esa es la canalización habitual. Y el paso 2 es, de nuevo con un grano de sal, básicamente el mismo en todas las plataformas. Sin embargo, el preprocesador copia diferentes archivos de encabezado en su unidad de compilación (paso 1) y el enlazador funciona de manera completamente diferente. La traducción real de un idioma (C) a otro (ASM), que es lo que hace un compilador desde una perspectiva teórica, es independiente de la plataforma.
Para que un binario funcione correctamente (o en algunos casos) hay muchos detalles desagradables que deben ser consistentes/correctos, incluidos, entre otros, probablemente.
Las diferencias en una o más de estas cosas son la razón por la que no puede simplemente tomar un binario destinado a un sistema operativo y cargarlo normalmente en otro.
Dicho esto, es posible ejecutar código destinado a un sistema operativo en otro. Eso es esencialmente lo que hace el vino. Tiene bibliotecas traductoras especiales que traducen las llamadas a la API de Windows en llamadas que están disponibles en Linux y un cargador binario especial que sabe cómo cargar archivos binarios tanto de Windows como de Linux.