Compilación y enlazamiento

Ahora que entendemos que es un procesador, que artitecturas hay, que especializaciones existen, como funcionan (a grandes rasgos) y que es la memoria podemos ir hacia; la compilación

Cuando uno escribe un programa, sea el lenguaje que sea (Rust, C, C++, JavaScript, etc) estamos usando el lenguaje humano;

“Código del programa ps del sistema operativo BSD”

Sin embargo, el procesador no entiende este lenguaje humano, como todo componente de electrónica entiende y se basa en pulsos eléctricos codificados en binario (ceros 0 y unos 1) por lo que necesitamos de un programa que pueda entender ese lenguaje humano, entender su lógica y poder traducirlo (en el lenguaje ensamblador respectivo de la arquitectura del CPU) para que sea funcional, ese programa es lo que llamamos ‘compilador’.

A lo largo de la historia han existido una cantidad indefinida de compiladores. Existen dos tipos de compiladores que determinan también al lenguaje de programación que soportan;

  • Lengujes de programación compilados

    Por el nombre puede dar a confusión, pero los compiladores a secas son los que primero compilan el código (de humano a máquina) y luego permiten que se pueda ejecutar. Son las primeras generaciones de los compiladores para los lenguajes de programación más veteranos y muchos de los más nuevos también.

    En esta categoria de lenguajes compilados entran lenguajes como; C, C++, Haskell, Rust, Go, Carbon (de Google), ensamblador, etc.

    En todas las arquitecturas el proceso es siempre el mismo para los programas en lenguajes compilados; se crea un código en el lenguaje de programación “X”, se ejecuta un compilador que lo reconoce y se compila para la arquitectura “Y” para finalmente ser enlazado.

    Ahora, ese binario ¿cómo maneja sus necesidades de interactividad con el sistema operativo y otras funcionalidades como sockets?;

    Para tratar ese problema tenemos las “dependencias” ó “bibliotecas”; como su nombre lo indica son dependencias que tiene el programa compilado para poder realizar una funcionalidad determinada de forma correcta. En los sistemas GNU/Linux se provee una herramienta llamada; “ldd” el cual al pasarle como argumento el path absoluto de un binario, devolverá al stdout la información sobre que bibliotecas externas que utiliza.

    Por ejemplo, estas son las dependencias externas para Nginx en un Arch Linux x86_64;

    “”

    Como se puede apreciar, el binario de Nginx depende de que en el sistema donde se ejecutan se encuentren tales bibliotecas. El “so” como extensión de archivo significa; “Shared Object” (“Objeto compartido). En este caso en concreto;

BibliotecaLocaciónPaquete que lo provee
linux-vdso.so.1Memoria RAMEl propio kernel Linux
libcrypt.so.2/usr/lib/libcrypt.so.2libxcrypt
libpcre2-8.so.0/usr/lib/libpcre2-9.so.0pcre2
libssl.so.3/usr/lib/libssl.so.3openssl
libcrypto.so.3/usr/lib/libcrypto.so.3openssl
libz.so.1/usr/lib/libz.so.1zlib
libGeoIP.so.1/usr/lib/libGeoIP.so.1geoip
libc.so.6/usr/lib/libc.so.6glibc, aarch64-linux-gnu-glibc, riscv64-linux-gnu-glibc, etc

Esto, en donde se suplen necesidades de un programa mediante dependencias externas alocadas en el sistema operativo mediante bibliotecas, se llama “compilación con bibliotecas compartidas”. En este proceso, el compilador llama luego al enlazador para que pueda referir en el binario a que bibliotecas (“shared object”) debe recurrir.

Como podrá apreciar, el binario de Nginx va a necesitar de que el sistema que lo ejecuta no solamente sea uno con un kernel Linux, si no que además tenga todos esos paquetes instalados, si no fallará al funcionar.

Si se desea indicar que esas bibliotecas sean embebidas dentro del binario, a fin de que sea tan autosuficiente como sea posible (necesitando solamente que sea un sistema Linux) y no se necesiten programas adicionales externos se debe indicar al compilador que utilice un proceso de compilación estático;

CompiladorArgumento
GCC-static -static-libgcc -static-libstdc++ -static-libasan -static-libtsan -static-liblsan -static-libubsan
Clang–emit-static-lib -static-libgcc -static-libsan -static-libstdc++ -static-openmp -static
Rustc-C target-feature=+crt-static

Acá una prueba con uno de los programas del proyecto RavnOS :

Copyright; ShyanJMC

Esto trae una serie de consideraciones, principalmente si se cuenta con poco espacio y/o RAM;

Binario compilado estáticamenteBinario compilado dinámicamente
Tiene un mayor tamaño al tener embebidas sus dependenciasTiene menor tamaño debido a que sus dependencias están fuera de si mismas, en el sistema operativo
Tiene un poco más de consumo de RAMTienen un poco menos de consumo de RAM al usar varias la misma instancia del “shared object” (la dependencia / biblioteca)
Tiene independencia del entorno donde se ejecuta, solamente necesita que sea el mismo kernel del OSTiene dependencia absoluta para funcionar de que estén instalados los programas de terceros que le proveen esas bibliotecas
Su entorno tiene nulo impacto operacional en sus capacidadesDepende de que las bibliotecas sigan siendo compatibles luego de una actualización con el programa que las requiera
Puede funcionar en todas las arquitecturas que soporte el compiladorDepende de que sus bibliotecas sean compatibles con la arquitectura para poder funcionar

Por lo tanto, se debe priorizar siempre que a la hora de distribuir programas pre compilados entre diferentes sistemas sea en forma de compilados estáticamente, a fin de que sea tan autosuficiente como sea posible y no dependa de terceros para poder proveer una funcionalidad determinada. Así mismo los programas compilados dinámicamente con musl no son compatibles con glibc y viceversa, por lo que es buena idea tener tanto musl como glibc en el mismo sistema (separados claramente) para poder hacer uso de ambos.

  • Lenguajes de programación interpretados

    Generalmente los intérpretes lo que hacen es tomar el código e ir compilandolo en tiempo real a medida que se ejecuta el programa. En esta categoría caen lenguajes de programación como; Python, Java, JavaScript (dependiendo del intérprete), Ruby, Perl, etc.

    Debido a que tienen que ir interpretando en tiempo de ejecución el código (véase, lo van ejecutando a medida que el programa se lee en tiempo real) tienen muchísima menos performance que los lenguajes compilados.

    Así mismo, debido a que estos intérpretes/compiladores tienen que ejecutar siempre el mismo código en diferentes plataformas también se los llama ‘Engines’ (motores).

Arquitectura de compilación

No se puede ejecutar un programa compilado para una arquitectura de CPU diferente a la del CPU en donde se intenta ejecutar (véase si compilas un programa para ARM, no vas a poder ejecutarlo en x86).

Pero sí se puede realizar un proceso de traducción el cual va a intentar ir traduciendo e interpretando en tiempo real para dar soporte al programa aunque sean dos arquitecturas diferentes, como hace el programa QEMU, pero generalmente suele haber muchísimos errores. Por lo tanto se debe tener en consideración eso.

Compilación nativa para ARM

Si se realiza una compilación nativa para un procesador ARM (o más comunmente un SoC) se debe tener en concimiento previo;

  • El ABI (“Application Binary Interface”; “Interfaz Binaria de la aplicación”)

    Esto determinará si, a nivel de la bibliotecas estándar de C utilizada en el sistema operativo, los tipos de variables; “int”, “long int” y los punteros en memorias son de 32 bits todos (ilp32) o si en cambio los “int” se mantienen en 32 bits pero los “long int” y los punteros pasan a ser de 64 bits (lp64).

    Se debe tener en cuenta que todo el programa debe ser compilado con la misma ABI.

https://gcc.gnu.org/onlinedocs/gcc/AArch64-Options.html

Listado de compiladores

Obviamente para poder traducir o interpetar el código de lenguaje humano a máquina se debe tener soporte de tal, por lo que no cualquier compilador sirve para cualquier lenguaje. Acá un listado de los más comunes;

  • GCC (Gnu Compiler Collection)

    Es el compilador del proyecto GNU, es un compilador estático (no un intérprete) y soporta lenguajes como; C, C++, Objective-C, Objective-C++, Fortran, Ada, D, y Go.

  • LLVM

    Es una colección de compiladores, al igual que GCC es un compilador estático (no un intérprete) y soporta lenguajes como; ActionScript, Ada, C#, Common Lisp, PicoLisp, Crystal, CUDA, D, Delphi, Dylan, Forth, Fortran, Free Basic, Free Pascal, Graphical G, Halide, Haskell, Java bytecode, Julia, Kotlin, Lua, Objective-C, OpenCL, PostgreSQL’s SQL and PLpgSQL, Ruby, Rust, Scala, Swift, XC, Xojo y Zig.

    El compilador de Rust (rustc) está construido usando LLVM.

  • Java Virtual Machine (JVM)

    Es el compilador e intérprete para el lenguaje de programación Java.

  • CPython

    Es el compilador e intérprete por defecto para el lenguaje de programación Python.

  • Gecko / Servo

    Es el compilador e intérprete que usa Mozilla Firefox para el lenguaje de programación JavaScript.

  • V8

    Es el compilador e intérprete que usa Google Chrome, Chromium, Atom edit, Pulsar Edit, Microsoft Edge, Opera Browser y varios más para JavaScript.