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:
-staticindica que queremos generar un binario estático. Esto es solo a efecto de comparar el tamaño real del código generado usando NoLibC-nostdlible dice agccque no queremos usar la librería estándar. Este flag se refiere al linkado de los famososcrtX.olos cuales contienen la implementación por defecto de la función_start.-I pathle dice agccque 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-tablesque le dice a gcc que no genere cierta información de depuración-selimina la tabla de símbolos y la información de relocalización, es equivalente astrip-Osoptimiza 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_64contienen código ensamblador para las funcionesmemcpy,memmoveymemsetutilizando las instrucciones de manejo de cadenas de los procesadores intel stdio.hincluye el código de una versión mínima deprintfque nos puede resultar útil para reutilizar en algunos de nuestros programasstdlib.hcontiene código para la conversión entre números y cadenas, además de una implementación de unheapbásico usandommap
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.
■
