Programas sin dependencias con NoLibC
PILDORAS DE VITAMINA C
Programas sin dependencias con NoLibC
2024-08-28
Por
Richi C. Poweri

Ya sabéis lo que nos gusta hacer que nuestros programas ocupen poco y sean independientes. Para ello suele ser necesario deshacerse de la librería C estándar y sustituirla por una versión más ligera como ulibc, dietlibc o musl… Otra alternativa es NoLibC.

NoLibC es una librería C mínima que se distribuye con el kernel Linux desde la versión 5.1. La podéis encontrar en las fuentes del kernel en el directorio tools/include/nolibc, y la forma de usarla es muy sencilla. La librería fue concebida para poder escribir pequeñas aplicaciones de espacio de usuario con dependencias mínimas, algo que resulta útil para programas de usuario relacionados con el desarrollo del kernel, como por ejemplo en los discos RAM.

En esos casos, el sistema de ficheros final (el que está en la unidad de almacenamiento principal, normalmente el disco duro) todavía no está disponible o no es necesario. El disco RAM puede incluirse para inicializar ciertas cosas o simplemente como el único disco disponible para ejecutar una aplicación específica. Este es el escenario para muchos sistema embebidos. Por lo tanto, cuanto más pequeño sea el disco RAM mejor (menos tiempo de carga), y para que el disco RAM sea pequeño, los programas que contiene y sus dependencias tienen que serlo también.

La solución a la que llegaron los desarrolladores del kernel es NoLibC.

COMPILANDO HOLA MUNDO

Para mostraros como funciona vamos a compilar el infame Hola Mundo. Algo tal que así:

#include <nolibc.h>

int main () {
     puts ("Hello World!");
     return 0;
}

Los lectores más avispados habrán observado que hemos incluido el fichero nolibc.h en lugar de el clásico stdio.h. Cuando compilamos usando NoLibC, ese es el único fichero que tenemos que incluir.

Para poder compilar este programa necesitamos el directorio que mencionamos anteriormente copiado en alguna parte de nuestro sistema de ficheros. Nosotros lo hemos copiado en /opt/devel/nolibc

Y la forma de compilar nuestro programa sería con el siguiente comando:

gcc -static -nostdlib -o hola hola.c -I/opt/devl/nolibc

donde:

  • -static indica que queremos generar un binario estático. Esto es solo a efecto de comparar el tamaño real del código generado usando NoLibC
  • -nostdlib le dice a gcc que no queremos usar la librería estándar. Este flag se refiere al linkado de los famosos crtX.o los cuales contienen la implementación por defecto de la función _start.
  • -I path le dice a gcc que use el path que pasamos como parámetro para buscar ficheros .h

El comando anterior genera un binario tal que así:

$ ls -lh hola
-rwxr-xr-x 1 occams razor 33K Aug 13 16:04 hola
$ strip -s hola
$ ls -lh hola
-rwxr-xr-x 1 occams razor 27K Aug 13 16:04 hola

Un fichero estático de 33Kb que después de pasarlo por strip se nos queda en 27Kb… ni tan mal. Aunque sabemos que podemos hacerlo mejor con otras librerías, pero para usar NoLibC, solo tenemos que copiar un directorio.

PROFUNDIZANDO EN LOS FLAGS

En la sección anterior os dijimos que -nostdlib nos permite eliminar la implementación de _start que gcc usa por defecto. Veamos que pasa si incluimos esa implementación:

$ gcc -static -o hello1 hello1.c -I /opt/devel/nolibc
/usr/bin/ld: /usr/lib/gcc/x86_64-linux-gnu/12/../../../x86_64-linux-gnu/crt1.o: in function `_start':
(.text+0x1d): undefined reference to `__libc_start_main'
collect2: error: ld returned 1 exit status

Como podéis ver, el problema es que la función _start que viene por defecto con gcc espera encontrar una función llamada __libc_start_main… pero esa función no está disponible en NoLibC y el linker termina con un error.

Este es un mensaje de error lo suficientemente intrigante como para que profundicemos en como NoLibC termina ejecutando nuestra fución main

PRIMER VISTAZO

Comenzaremos echando un vistazo a los ficheros que incluye:

$ ls /opt/devl/nolibc/
Makefile        arch-i386.h       arch-powerpc.h  arch-x86_64.h  crt.h    nolibc.h          std.h     stdio.h   sys.h    unistd.h
arch-aarch64.h  arch-loongarch.h  arch-riscv.h    arch.h         ctype.h  signal.h          stdarg.h  stdlib.h  time.h
arch-arm.h      arch-mips.h       arch-s390.h     compiler.h     errno.h  stackprotector.h  stdint.h  string.h  types.h

Lo primero que nos llama la atención es que toda la librería esta implementada como fichero .h. Lo siguiente que nos llama la atención es que parece que soporta las principales arquitecturas, suponemos que al menos todas las arquitecturas soportadas oficialmente por el kernel.

El fichero principal nolibc.h simplemente incluye el resto de fichero .h, así que no tenemos porque incluir stdio.h en nuestro ejemplo anterior. El fichero contiene un extenso comentario con detalles de la implementación que os recomendamos leer en caso de que queráis utilizar esta librería. Una de las cosas que incluye son un par de parámetros extra para la compilación para generar binarios más pequeños:

$ gcc -fno-asynchronous-unwind-tables -fno-ident -s -Os -nostdlib hello1.c -o hello1 -I /opt/devel/nolibc/

donde:

  • -fno-asynchronous-unwind-tables que le dice a gcc que no genere cierta información de depuración
  • -s elimina la tabla de símbolos y la información de relocalización, es equivalente a strip
  • -Os optimiza para tamaño

El flag -fno-ident ignora las directivas #ident que se suele utilizar para copiar una cadena en el segmento especial .comment… En otras palabras elimina el segmento .comment que realmente no es necesario para la ejecución del programa.

PUNTO DE ENTRADA

Como dijimos anteriormente, NoLibC ofrece su propio punto de entrada, esto es, su propia implementación de la función _start. Para el caso de la arquitectura x86_64 este es el código en cuestión que podéis encontrar en arch-86_64.h:

void __attribute__((weak, noreturn, optimize("Os", "omit-frame-pointer"))) __no_stack_protector _start(void)
{
  __asm__ volatile (
    "xor  %ebp, %ebp\n"       /* zero the stack frame                            */
    "mov  %rsp, %rdi\n"       /* save stack pointer to %rdi, as arg1 of _start_c */
    "and  $-16, %rsp\n"       /* %rsp must be 16-byte aligned before call        */
    "call _start_c\n"         /* transfer to c runtime                           */
    "hlt\n"                   /* ensure it does not return                       */
  );
  __builtin_unreachable();
}

Como podéis ver la función solo prepara la pila para pasarla a la función _start_c, la cual está definida en crt,h… Ya sabéis C Run-Time. La función prepara la pila con los argumentos de la línea de comandos, las variables de entorno y los vectores auxiliares necesarios para poder ejecutar el programa, inicializa las tablas de constructores y destructores, llamando a los primeros antes de main y a los segundos después. Finalmente (bueno, casi, justo antes de ejecutar los destructores) llama a _nolibc_main en lugar de __libc_start_main. Respecto a esta última llamada utilizan un truco bastante interesante para permitir las distintas declaraciones de la función main.

La función main

La función main, formalmente, acepta tres parámetros. Su prototipo es algo talque así:

int main (int argc, char *argv[], char *envp[]);

El primer parámetro argc es el número de parámetros recibidos a través de la línea de comandos. El segundo parámetro argv es un vector de cadenas de caracteres conteniendo cada uno de esos parámetros. Pero hay un tercer parámetro en el que recibimos un vector de cadenas de caracteres con las variables de entorno asociadas a esta ejecución. Si tenemos en cuenta que la forma de ejecutar un nuevo proceso en Linux es utilizando la llamada al sistema execve todo toma bastante sentido:

 int execve(const char *pathname, char *const _Nullable argv[],
              char *const _Nullable envp[]);

Si bien, esta es la declaración formal, nadie la usa. Lo habitual es obviar el último parámetro y, en los casos en los que no necesitemos parámetros, también el resto. Así que es normal declarar main en nuestros programas de alguna de estas maneras:

 int main (int argc, char *argv[]);
 int main (void);
 

¿Pero como es esto posible?. Bueno, la verdad que yo ni me lo había planteado hasta que vi estás dos líneas en el fichero crt.h de NoLibC

/* silence potential warning: conflicting types for 'main' */
int _nolibc_main(int, char **, char **) __asm__ ("main");

Modifiquemos ligeramente nuestro hola mundo para entender cual es el problema:

#include <stdio.h>

void foo (int a, int b) {
  puts ("Foo");
}
int main () {
  puts ("Hola Mundo");
  foo (1, 2);
}

Si compilamos y ejecutamos el programa veremos que obtenemos:

Hola Mundo 
Foo

En este caso, nuestra función foo no utiliza ninguno de los parámetros, así que tal si hacemos lo mismo que con main, simplemente obviamos el segundo parámetro puesto que no lo usamos:

void foo (int a) { 
  puts ("Hola Mundo");
}

Si ahora intentamos compilar obtendremos:

$ make hola
hola.c: In function ‘main’:
hola.c:22:3: error: too many arguments to function ‘foo’
   22 |   foo (1, 1);
      |   ^~~
hola.c:14:6: note: declared here
   14 | void foo (int a) {
      |      ^~~
make: *** [<builtin>: hola] Error 1

Efectivamente, en el nuevo programa foo está declarada como una función que recibe un solo parámetro, pero nosotros la estamos llamando con dos. Esto es exactamente lo que ocurre con main. La función _start_c tiene que llamar a la función main de nuestro programa con todos los parámetros, sin embargo, el programador puede haber decidido usar una de las declaraciones reducidas de main. Y aquí es donde la famosa línea entra en juego.

int _nolibc_main(int, char **, char **) __asm__ ("main");

Esta línea esta realmente declarando un puntero a una función y asignándole el valor main. De forma general, la declaración del puntero preserva el número y tipo de parámetros para hacer nuestra llamada, sin embargo, en lugar de apuntar a una función C, le asignan el símbolo main, o en otras palabras la dirección de memoria de la función main. En modificamos nuestro ejemplo de forma similar obtendríamos:

#include <stdio.h>

void foo (int a) {
  puts ("Foo");
}
int main () {
  void mi_foo (int a, int b) __asm__ ("foo");
  puts ("Hola Mundo");
  mi_foo (1, 2);
}

Ahora podemos llamar a la función foo pasando todos los parámetros del caso más general, aunque la hayamos declarado usando menos.

Para terminar con este tema una nota rápida. Si declaramos la función como void foo() no obtendremos errores de compilación aunque la llamemos directamente con varios parámetros. En C, una lista de parámetros vacía indica que la función puede recibir cualquier número de parámetros, no que no recibe parámetros. Si modificamos la función talque así void foo(void), ahora si estamos indicando que la función no espera parámetros y obtendremos de nuevo el error.

ALGUNAS CURIOSIDADES MÁS

NoLibC oculta algunas curiosidades más que pueden resultar de lo más interesante para aquellos de vosotros que queréis profundizar en como funcionan los programas a bajo nivel. Aquí os dejamos una lista de cosas que podéis consultar:

  • Los ficheros arch_x86_64 contienen código ensamblador para las funciones memcpy, memmove y memset utilizando las instrucciones de manejo de cadenas de los procesadores intel
  • stdio.h incluye el código de una versión mínima de printf que nos puede resultar útil para reutilizar en algunos de nuestros programas
  • stdlib.h contiene código para la conversión entre números y cadenas, además de una implementación de un heap básico usando mmap

Echando un ojo al código de stdlib.h vemos que la implementación del heap no es la más eficiente del mundo. Veamos en detalle como funciona.

MEMORIA DINÁMICA

Echemos un ojo a la implementación de malloc de NoLibC:

struct nolibc_heap {
  size_t  len;
  char  user_p[] __attribute__((__aligned__));
};

...

static __attribute__((unused))
void *malloc(size_t len)
{
  struct nolibc_heap *heap;

  /* Always allocate memory with size multiple of 4096. */
  len  = sizeof(*heap) + len;
  len  = (len + 4095UL) & -4096UL;
  heap = mmap(NULL, len, PROT_READ|PROT_WRITE, MAP_ANONYMOUS|MAP_PRIVATE,
        -1, 0);
  if (__builtin_expect(heap == MAP_FAILED, 0))
    return NULL;

  heap->len = len;
  return heap->user_p;
}

Lo primero que vemos es que malloc solo reserva bloques de 4Kb, así que si vuestro programa usa malloc para reservar memoria para pequeñas estructuras (como en el caso de una lista enlazada por ejemplo), estaremos desperdiciando memoria a lo loco.

Lo siguiente que debemos comentar es que la memoria se reserva con mmap pasando como primer parámetro NULL. Esto significa que el kernel nos devolverá un bloque de memoria disponible, pero sin seguir ningún criterio específico, lo cual, en un programa que haga uso extensivo de memoria dinámica, puede provocar un problema de fragmentación de memoria y por tanto de disponibilidad de la misma.

Así que, a modo de conclusión, NoLibC no es una buena opción para programas que utilizan malloc a lo loco. Obtendrás un binario muy pequeño, pero el desperdicio de memoria en tiempo de ejecución será considerable.

MÁS LIMITACIONES

Hay dos limitaciones más que debemos tener en cuenta si queremos utilizar NoLibC. La primera es que no todas las llamadas al sistema están implementadas. Específicamente todo lo relaciona con la red no se ha incluido todavía.

En cierto modo, añadir llamadas al sistema es más o menos trivial y se espera que los desarrolladores del kernel las vayan incluyendo según las necesiten. En caso de que necesites alguna que no está disponible, siempre puede incluirla tu mismo, los fichero arch_XXX ofrecen funciones para hacer llamadas al sistema, así que normalmente solo tendrás que escribir el prototipo de la función C y usar la función adecuada reordenando los parámetros.

La segunda limitación tiene que ver con errno y hay un extenso comentario sobre este tema en nolibc.h. El problema es que errno es una variable global y por tanto es necesario linkar junto cierto código del nuestro programa y de NoLibC, sin embargo, eso haría todo el proceso más complejo. NoLibC fue inicialmente convenida para pequeños programas normalmente contenidos en un único fichero, así que la solución por la que se optó fué declarar errno como una variable estática visible en cada fichero fuente.

La principal consecuencia de esto es que si nuestro programa tiene más de un fichero fuente, cada uno de ellos tendrá su propia copia de errno. Siendo consciente de esto es posible escribir código que no tenga problemas, si bien, si nuestro programa requiere varios ficheros fuente para compilarse, quizás NoLibC no sea la mejor alternativa.


SOBRE Richi C. Poweri
Cuando se trata de programación, Richi es tu chica. Tiene un don especial para los lenguajes de programación, y habla con el sistema operativo de tu a tu. En su tienpo libre Richi ayuda a su familia, en los temas familiares

 
Tu publicidad aquí :)