1. La capa que faltaba
El tema 14 se ocupó de los envoltorios: funciones como write, read y getpid, que se corresponden una a una con las llamadas del kernel y no hacen más que realizarlas. Pero la mayoría de los programas en C no las usa directamente. Escribe con printf, lee con fgets y pide memoria con malloc, y ninguna de esas tres es una llamada al sistema.
Quedaron además dos preguntas abiertas. Al final del tema 14 advertimos que mezclar printf y write puede alterar el orden de la salida. Y en el tema 1 dijimos que printf guarda el texto y lo envía «cuando decide». ¿Cuándo decide, y con qué criterio?
Las dos respuestas están en la misma pieza. En el tema 10 la presentamos como uno de los componentes del sistema; ahora la miramos por dentro.
La biblioteca de C es el conjunto de funciones que todo programa en C tiene a su disposición. Incluye los envoltorios de las llamadas al sistema y, sobre ellos, funciones de más alto nivel que hacen trabajo propio en modo usuario. En Linux, la implementación habitual es glibc.
2. Tres clases de funciones
Desde el punto de vista del sistema operativo, lo que interesa de cada función de la biblioteca es una sola cosa: si entra al kernel, y cuántas veces. Según eso se dividen en tres grupos:
| Clase | Ejemplos | Llamadas al sistema |
|---|---|---|
| Envoltorios | write, read, getpid, close | Exactamente una por cada invocación. |
| Funciones puras de usuario | strlen, strcpy, qsort, snprintf, sqrt | Ninguna. Trabajan solo con la memoria del programa. |
| Funciones que administran un recurso | printf, fgets, fopen, malloc | Algunas veces. Piden al kernel en cantidad y reparten por su cuenta. |
El tercer grupo es el interesante, y su estrategia es siempre la misma: como cruzar al kernel es caro, la biblioteca pide mucho de una vez y luego atiende los pedidos pequeños del programa sin molestar al kernel. malloc lo hace con la memoria; printf, con la salida.
El siguiente programa permite ver las tres clases. Usa dos marcas escritas con write para delimitar tramos, de modo que en la traza se pueda ver qué llamadas ocurren entre una y otra:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
static int comparar(const void *a, const void *b)
{
return *(const int *)a - *(const int *)b;
}
int main(void)
{
int numeros[] = { 42, 7, 19, 3, 88 };
char texto[64];
void *bloques[1000];
size_t largo;
write(2, "inicio\n", 7);
qsort(numeros, 5, sizeof(int), comparar); /* ordenar */
snprintf(texto, sizeof(texto), "el menor es %d", numeros[0]);
largo = strlen(texto); /* medir una cadena */
write(2, "medio\n", 6);
for (int i = 0; i < 1000; i++)
bloques[i] = malloc(100); /* mil pedidos de memoria */
write(2, "fin\n", 4);
printf("%s (%zu caracteres)\n", texto, largo);
for (int i = 0; i < 1000; i++)
free(bloques[i]);
return 0;
}
$ gcc -Wall -Wextra -g clases.c -o clases
$ strace -e trace=write,brk -o traza.txt ./clases 2> /dev/null
el menor es 3 (13 caracteres)
$ grep -A4 inicio traza.txt
write(2, "inicio\n", 7) = 7
write(2, "medio\n", 6) = 6
brk(NULL) = 0x5581c7f6e000
brk(0x5581c7f8f000) = 0x5581c7f8f000
write(2, "fin\n", 4) = 4
Entre «inicio» y «medio» no hay nada: ordenar un arreglo, dar formato a un texto y medir una cadena no requieren al kernel. Entre «medio» y «fin» hubo mil llamadas a malloc y solo dos llamadas al sistema. Con la llamada brk, la biblioteca pidió al kernel que ampliara la memoria del proceso en un bloque de 132 KB, y de ese bloque fue entregando los mil trozos de 100 bytes. Novecientos noventa y nueve pedidos se resolvieron sin salir del modo usuario.
3. De printf a write
printf hace dos trabajos distintos, y ninguno de los dos es escribir en la pantalla:
- Da formato. Recorre la cadena de formato y convierte cada argumento en texto: el entero 30 se transforma en los caracteres
'3'y'0'. Es cálculo puro, igual quesnprintf. - Guarda el resultado en un búfer. Los caracteres producidos se añaden a una zona de memoria que pertenece al programa.
La llamada write ocurre después, cuando la biblioteca resuelve vaciar ese búfer. Un programa con tres printf lo muestra con claridad:
#include <stdio.h>
int main(void)
{
int edad = 30;
printf("Nombre: %s\n", "Ana");
printf("Edad: %d\n", edad);
printf("Total: %.2f\n", 12.5);
return 0;
}
$ gcc -Wall -Wextra -g unidos.c -o unidos
$ strace -e trace=write -o traza.txt ./unidos # salida a la terminal
Nombre: Ana
Edad: 30
Total: 12.50
$ cat traza.txt
write(1, "Nombre: Ana\n", 12) = 12
write(1, "Edad: 30\n", 11) = 11
write(1, "Total: 12.50\n", 14) = 14
+++ exited with 0 +++
$ strace -e trace=write -o traza.txt ./unidos > salida.txt # salida a un archivo
$ cat traza.txt
write(1, "Nombre: Ana\nEdad: 30\nTotal: 12.50\n", 37) = 37
+++ exited with 0 +++
El mismo ejecutable hizo tres llamadas al sistema en un caso y una sola en el otro. En la segunda ejecución, los tres textos se acumularon en el búfer y salieron juntos al terminar el programa: 12 + 11 + 14 = 37 bytes en un único write. El kernel nunca supo que hubo tres printf.
La razón de este diseño es el costo que medimos en los temas 11 y 14. Una llamada al sistema cuesta unos cientos de nanosegundos, la misma cantidad sea cual sea el tamaño de los datos. Añadir caracteres a un arreglo cuesta casi nada. Juntar muchas escrituras pequeñas en una grande es la aplicación directa de la regla «pocas llamadas, y pedir mucho en cada una», y la biblioteca la aplica por nosotros.
4. El búfer y sus tres modos
Queda por explicar por qué en la terminal hubo tres write. Si la biblioteca esperara siempre a llenar el búfer, un programa interactivo sería inutilizable: haría una pregunta y el texto no aparecería hasta mucho después. Por eso el momento del vaciado depende del destino. La parte de la biblioteca que se ocupa de la entrada y la salida, llamada stdio por su archivo de cabecera, tiene tres modos:
| Modo | Cuándo se vacía el búfer | Se aplica por defecto a |
|---|---|---|
| Por líneas | Al escribir un salto de línea, o al llenarse. | stdout, si está conectado a una terminal. |
| Completo | Solo al llenarse; típicamente, cada 4096 bytes. | stdout, si está conectado a un archivo o a una tubería. Todos los archivos abiertos con fopen. |
| Sin búfer | En cada operación. | stderr, siempre. |
En los tres modos, el búfer se vacía también al terminar el programa normalmente y cuando el programa lo pide de forma explícita con fflush.
La biblioteca elige el modo la primera vez que el programa escribe: pregunta al kernel qué hay detrás del descriptor 1 y actúa en consecuencia. Eso explica el experimento anterior. En la terminal, cada \n provocó un vaciado; con la salida redirigida, nada lo provocó hasta el final.
El modo por líneas tiene una consecuencia que desconcierta a quien la encuentra por primera vez:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("Procesando..."); /* sin salto de línea */
sleep(3); /* simula un trabajo largo */
printf(" listo\n");
return 0;
}
$ gcc -Wall -Wextra -g demora.c -o demora
$ ./demora
# tres segundos sin que aparezca nada…
Procesando... listo # …y luego todo junto
El primer texto no termina en salto de línea, así que queda retenido en el búfer mientras el programa duerme. Hay tres maneras de corregirlo, y conviene conocer las tres:
| Solución | Código | Cuándo conviene |
|---|---|---|
| Vaciar a mano | fflush(stdout); | Es la habitual: se pone después del texto que debe verse ya. |
| Usar la salida de errores | fprintf(stderr, "…"); | Para mensajes de diagnóstico y de progreso. |
| Cambiar el modo | setvbuf(stdout, NULL, _IONBF, 0); | Al comienzo del programa, si toda la salida debe ser inmediata. |
5. Mezclar printf y write
Con lo anterior, la advertencia del tema 14 deja de ser un misterio. Este programa escribe tres líneas, la del medio con write:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("uno (printf)\n");
write(1, "dos (write)\n", 12);
printf("tres (printf)\n");
return 0;
}
$ gcc -Wall -Wextra -g mezcla.c -o mezcla
$ ./mezcla # a la terminal
uno (printf)
dos (write)
tres (printf)
$ ./mezcla | cat # a una tubería
dos (write)
uno (printf)
tres (printf)
$ strace -e trace=write -o traza.txt ./mezcla > /dev/null
$ cat traza.txt
write(1, "dos (write)\n", 12) = 12
write(1, "uno (printf)\ntres (printf)\n", 27) = 27
+++ exited with 0 +++
En la terminal, el modo por líneas vacía el búfer en cada \n y el orden coincide con el del código. Con la salida en una tubería, el modo es completo: «uno» queda en el búfer, write envía «dos» al kernel de inmediato, «tres» se suma al búfer, y este se vacía al final. La traza lo muestra sin ambigüedad.
No hay ningún error en la biblioteca ni en el kernel. Hay dos caminos hacia el mismo descriptor, uno con sala de espera y otro sin ella. La regla práctica es no mezclarlos; y si hace falta, llamar a fflush(stdout) antes de cada write directo.
Este es uno de los comportamientos más traicioneros de la programación en C: el programa funciona bien mientras se lo prueba en la terminal y falla cuando su salida se guarda en un archivo o se pasa a otro comando. Cuando un programa «pierde» o desordena texto solo al redirigirlo, el búfer de stdio es el primer sospechoso.
6. Cuando el búfer se pierde: exit y _exit
El búfer está en la memoria del proceso. Si el proceso desaparece antes de vaciarlo, su contenido desaparece con él, sin que el kernel llegue a saber que existía. Por eso importa quién se encarga del vaciado final.
En C hay dos funciones para terminar un programa, con nombres casi iguales y comportamientos muy distintos:
exit(n) | _exit(n) | |
|---|---|---|
| Qué es | Una función de la biblioteca | Un envoltorio de la llamada al sistema |
| Se declara en | stdlib.h | unistd.h |
| Vacía los búferes de stdio | Sí | No |
Ejecuta las funciones registradas con atexit | Sí | No |
| Finalmente | Llama a _exit(n) | Pide al kernel que finalice el proceso |
Devolver un valor desde main equivale a llamar a exit con ese valor: el código de arranque que rodea a main, el que empieza en _start, lo hace por nosotros. Por eso en los programas anteriores el texto pendiente siempre apareció. Si se termina por la vía directa, no aparece:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("Este texto no llega a ninguna parte");
_exit(0); /* termina sin vaciar el búfer */
}
$ gcc -Wall -Wextra -g perdido.c -o perdido
$ ./perdido
$ strace -e trace=write ./perdido
+++ exited with 0 +++
Ni una sola llamada write. El texto se formó, se guardó en el búfer y se perdió con el proceso.
Lo mismo ocurre cuando el programa no termina por su voluntad. Si un proceso es finalizado por el kernel a causa de un fallo, como los del tema 12, nadie llama a exit y el búfer no se vacía. Tiene una consecuencia práctica muy concreta: al depurar un programa que falla, los mensajes de printf anteriores al fallo pueden no aparecer, y hacen pensar que el programa murió antes de lo que realmente murió. Por eso los mensajes de diagnóstico se escriben en stderr, que no tiene búfer.
7. El búfer, medido
En el tema 14, copiar_1 copiaba un archivo de 10 MB leyendo y escribiendo de a un byte, y tardaba más de seis segundos. El siguiente programa también procesa un byte por vez, pero lo hace con funciones de stdio:
#include <stdio.h>
int main(void)
{
int c;
while ((c = getchar()) != EOF) /* un byte por vuelta */
putchar(c);
return 0;
}
$ gcc -Wall -Wextra -g copiar_stdio.c -o copiar_stdio
$ time ./copiar_stdio < datos.bin > /dev/null
real 0m0.085s
user 0m0.081s
sys 0m0.004s
$ strace -c -o cuenta.txt ./copiar_stdio < datos.bin > /dev/null
$ grep -Ew 'read|write' cuenta.txt
53.40 0.002105 0 2562 read
45.12 0.001779 0 2560 write
Diez millones de llamadas a getchar y otras tantas a putchar produjeron las mismas cinco mil llamadas al sistema que la versión de copiar con búfer de 4096 bytes. getchar hace con la entrada lo que printf con la salida: la primera vez pide al kernel un bloque entero, y las siguientes 4095 veces entrega un byte de los que ya tiene.
| Programa | Cómo trabaja | Llamadas al sistema | Tiempo |
|---|---|---|---|
copiar_1 | read y write de 1 byte | 21 millones | 6,2 s |
copiar_stdio | getchar y putchar, 1 byte | 5 mil | 0,085 s |
copiar | read y write de 4096 bytes | 5 mil | 0,004 s |
La comparación entre las dos primeras filas es la diferencia entre una llamada al sistema y una llamada a función, vista en un programa completo: el mismo estilo de código, byte a byte, resulta setenta veces más rápido cuando cada byte no implica un cruce al kernel. La tercera fila muestra que el búfer propio sigue siendo lo más eficiente, porque evita además veinte millones de llamadas a función. Los valores exactos cambian con el equipo y con el sistema de archivos; las proporciones se mantienen.
8. Dos interfaces para lo mismo
Un programa en C dispone, entonces, de dos juegos de funciones para la entrada y la salida. No compiten: uno está construido sobre el otro.
| Interfaz de descriptores | Interfaz de stdio | |
|---|---|---|
| Un archivo abierto es | Un int: el descriptor | Un FILE *: un puntero a una estructura de la biblioteca |
| Los tres iniciales | 0, 1, 2 | stdin, stdout, stderr |
| Abrir y cerrar | open, close | fopen, fclose |
| Leer y escribir | read, write | fread, fgets, getchar, fwrite, printf, putchar |
| Trabaja con | Bytes | Bytes, líneas y texto con formato |
| Búfer en el proceso | No | Sí |
| Definida por | POSIX | El estándar del lenguaje C |
| Página del manual | Sección 2 | Sección 3 |
Una estructura FILE contiene, en esencia, un descriptor, un búfer y la cuenta de cuánto hay en él. La función fileno devuelve el descriptor que hay dentro de un FILE *, y fdopen hace lo inverso: construye un FILE * alrededor de un descriptor existente.
¿Cuál usar? Para texto, lectura por líneas y salida con formato, stdio es más cómoda y rinde bien sin esfuerzo. Los descriptores son necesarios cuando hace falta control exacto sobre cada llamada: con dispositivos, tuberías y conexiones de red, o cuando se necesita saber que un dato salió del proceso en un momento preciso. Los programas de sistema usan los dos, cada uno en su lugar.
9. Envoltorios que usan otra llamada
En el tema 14 dijimos que un envoltorio tiene el mismo nombre que la llamada al sistema que realiza. Es cierto en la mayoría de los casos, pero no en todos. Este programa abre un archivo, espera un segundo y termina:
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
int fd = open("/etc/hostname", O_RDONLY);
if (fd == -1) {
perror("open");
return 1;
}
close(fd);
sleep(1);
exit(0);
}
$ gcc -Wall -Wextra -g nombres.c -o nombres
$ strace -o traza.txt ./nombres
$ tail -5 traza.txt
openat(AT_FDCWD, "/etc/hostname", O_RDONLY) = 3
close(3) = 0
clock_nanosleep(CLOCK_REALTIME, 0, {tv_sec=1, tv_nsec=0}, 0x7ffc5d0e3a10) = 0
exit_group(0) = ?
+++ exited with 0 +++
De las cuatro funciones que llamó el programa, solo close aparece en la traza con su nombre. Las otras tres se tradujeron a llamadas distintas:
| Función de la biblioteca | Llamada al sistema que se ejecuta | Motivo |
|---|---|---|
open | openat | Una llamada posterior y más general, que permite indicar un directorio de partida. |
sleep | clock_nanosleep | El kernel ofrece una espera en nanosegundos; la biblioteca convierte los segundos. |
exit | exit_group | Finaliza todos los hilos del proceso, y no solo el que llama. |
fork | clone | Una llamada más general para crear procesos e hilos. |
signal | rt_sigaction | La interfaz moderna para las señales. |
time | Ninguna | Se resuelve en el vDSO, sin entrar al kernel. |
En todos los casos sucedió lo mismo: con los años el kernel incorporó una llamada más capaz, y la biblioteca pasó a usarla conservando el nombre y los parámetros que los programas ya conocían. Un programa escrito en 1990 con open se compila hoy sin tocar una línea y usa openat sin saberlo.
Es la ventaja de tener dos interfaces en lugar de una, como anticipamos en el tema 10: la del kernel puede crecer y la de la biblioteca puede permanecer. Y es también la razón por la que, al leer una traza, no hay que buscar los nombres del código fuente, sino los de las llamadas que efectivamente llegan al kernel. Lo practicaremos en el tema 16.
10. glibc, el archivo
Todo el código que venimos describiendo está en un archivo concreto, el que ldd mostraba en los temas 9 y 10. Tiene una rareza: además de ser una biblioteca, se puede ejecutar, y al hacerlo se identifica.
$ /lib/x86_64-linux-gnu/libc.so.6
GNU C Library (Ubuntu GLIBC 2.39-0ubuntu8.4) stable release version 2.39.
Copyright (C) 2024 Free Software Foundation, Inc.
…
$ ls -lhL /lib/x86_64-linux-gnu/libc.so.6
-rwxr-xr-x 1 root root 2.1M ene 28 2025 /lib/x86_64-linux-gnu/libc.so.6
$ nm -D --defined-only /lib/x86_64-linux-gnu/libc.so.6 | wc -l
3036
En Fedora el archivo está en /lib64/libc.so.6. Son dos megabytes con unos tres mil nombres públicos, entre funciones y variables: frente a las menos de cuatrocientas llamadas al sistema del kernel, da una idea de cuánto trabajo se hace en modo usuario. Como es una biblioteca compartida, existe una sola copia en memoria, que usan a la vez todos los procesos del sistema.
La versión importa más de lo que parece. Cada ejecutable registra qué versión de la biblioteca necesita para cada función que usa:
$ objdump -T unidos | grep GLIBC
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.34) __libc_start_main
0000000000000000 DF *UND* 0000000000000000 (GLIBC_2.2.5) printf
La primera línea es la función que llama a main; el ejecutable exige que la biblioteca sea, como mínimo, la 2.34. Si se lo copia a un sistema con una glibc anterior, el enlazador dinámico se niega a iniciarlo:
$ ./unidos
./unidos: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.34' not found (required by ./unidos)
Es uno de los errores más comunes al distribuir programas en Linux, y sigue una regla simple: un ejecutable funciona en sistemas con una glibc igual o más nueva que aquella con la que se compiló, pero no más vieja. Por eso los programas que se distribuyen ya compilados se construyen a propósito en una distribución antigua.
glibc es la más extendida, pero la interfaz es un estándar y admite otras implementaciones. La distribución Alpine, muy usada en contenedores, emplea musl, más pequeña y simple. Android usa Bionic. Un programa compilado contra una no funciona con otra, aunque el kernel sea el mismo: es el caso de Ubuntu y Android del tema 10.
11. POSIX: un contrato entre sistemas
En el tema 3 contamos que, hacia los años ochenta, Unix se había dividido en variantes incompatibles: un programa escrito para una no compilaba en otra. La respuesta fue un estándar, publicado en 1988 por el IEEE con el nombre POSIX, de Portable Operating System Interface; la X final recuerda su origen en Unix.
POSIX es una norma que especifica la interfaz que un sistema operativo debe ofrecer a los programas: qué funciones de C existen, qué parámetros reciben, qué devuelven y qué errores pueden informar. Un programa que usa solo lo que la norma define se puede compilar en cualquier sistema que la cumpla.
Conviene precisar su alcance, porque se presta a confusiones:
| POSIX define | POSIX no define |
|---|---|
Las funciones: open, read, write, fork, getpid y unas mil más. | Cuáles de ellas son llamadas al sistema y cuáles funciones de biblioteca. |
Los archivos de cabecera: unistd.h, fcntl.h, errno.h. | Los números de las llamadas ni los registros en que se pasan. |
Los nombres de los errores: EBADF, EACCES. | Sus valores numéricos. |
La shell y las utilidades: sh, ls, grep, cp. | Cómo está construido el kernel. |
La columna derecha explica por qué en el tema 14 recomendamos comparar errno con nombres y no con números. Y marca una distinción importante: POSIX garantiza la portabilidad del código fuente, no la de los ejecutables. El programa copiar.c se compila sin cambios en Linux, en macOS y en FreeBSD, pero el ejecutable producido en uno no sirve en los otros: cada sistema tiene su formato, su biblioteca y sus números de llamada.
| Sistema | Relación con POSIX |
|---|---|
| Linux | Lo cumple casi por completo, sin certificación oficial. |
| macOS | Certificado como UNIX. |
| FreeBSD, OpenBSD | Lo cumplen casi por completo. |
| Windows | No. Su interfaz nativa es otra, la API Win32. WSL lo resuelve ejecutando un kernel Linux. |
Un programa puede preguntar por la versión de la norma y por algunos límites del sistema. La función sysconf, también de POSIX, sirve para eso:
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("Version de POSIX: %ld\n", (long)_POSIX_VERSION);
printf("Tamano de pagina: %ld bytes\n", sysconf(_SC_PAGESIZE));
printf("Archivos abiertos, maximo: %ld\n", sysconf(_SC_OPEN_MAX));
printf("Tics de reloj por segundo: %ld\n", sysconf(_SC_CLK_TCK));
return 0;
}
$ gcc -Wall -Wextra -g posix.c -o posix
$ ./posix
Version de POSIX: 200809
Tamano de pagina: 4096 bytes
Archivos abiertos, maximo: 1024
Tics de reloj por segundo: 100
$ getconf PAGESIZE # lo mismo, desde la shell
4096
El número 200809 indica la revisión de septiembre de 2008 de la norma. El programa no contiene ningún valor propio de Linux: pregunta en lugar de suponer, y por eso funciona igual en un sistema donde las páginas de memoria miden 16 KB, como las Mac con procesador ARM. El último valor es el que usamos en el tema 12 para interpretar /proc/stat: las centésimas de segundo.
12. Tres círculos: ISO C, POSIX y extensiones
Las funciones que ofrece glibc provienen de tres fuentes, cada una más amplia que la anterior. Saber a cuál pertenece una función es saber en qué sistemas se podrá compilar el programa.
| Origen | Quién lo define | Cabeceras típicas | Trata de |
|---|---|---|---|
| ISO C | El estándar del lenguaje | stdio.h, stdlib.h, string.h, math.h | Lo que tiene sentido en cualquier máquina: texto, memoria, cálculo, archivos como secuencias de bytes. |
| POSIX | El IEEE y The Open Group | unistd.h, fcntl.h, sys/stat.h, signal.h | Lo propio de un sistema operativo: procesos, descriptores, permisos, directorios, señales. |
| Extensiones | El proyecto GNU y el kernel Linux | sys/syscall.h, sys/epoll.h | Funciones adicionales y servicios que solo ofrece Linux. |
La división coincide con algo que ya observamos: stdio.h existe en Windows y unistd.h no. El estándar de C no puede hablar de procesos ni de descriptores, porque debe servir también para un microcontrolador sin sistema operativo. De ahí que la «biblioteca estándar de C» en sentido estricto sea solo el círculo interior, y que glibc, que implementa los tres, sea bastante más que eso.
Las extensiones están ocultas por defecto. Para usarlas, el programa lo declara antes de incluir cualquier cabecera:
#define _GNU_SOURCE /* antes del primer #include */
#include <unistd.h>
pid_t hilo = gettid(); /* disponible solo con la línea anterior */
Esa línea es una señal útil al leer código ajeno: avisa de que el programa renuncia a la portabilidad y solo se compila en Linux con glibc.
13. Leer una página del manual
En el tema 4 presentamos man y sus secciones. Ahora que conocemos la diferencia entre el kernel, la biblioteca y el estándar, una página del manual dice mucho más. Para cualquier función, responde las tres preguntas de este tema: si es una llamada al sistema, qué estándar la define y en qué se aparta el envoltorio del kernel.
$ man -f write # ¿en qué secciones existe este nombre?
write (1) - send a message to another user
write (2) - write to a file descriptor
$ man -f printf
printf (1) - format and print data
printf (3) - formatted output conversion
El número entre paréntesis ya da la primera respuesta: write está en la sección 2 y es una llamada al sistema; printf está en la 3 y es una función de biblioteca. Por eso en los textos técnicos las funciones se citan con su sección: write(2), printf(3). Dentro de la página, cada apartado tiene un propósito fijo:
| Apartado | Qué contiene | Para qué sirve |
|---|---|---|
| SYNOPSIS | Las cabeceras necesarias y el prototipo. | Saber qué incluir y con qué tipos llamar. Indica también si hace falta _GNU_SOURCE. |
| DESCRIPTION | Qué hace la función y qué significa cada parámetro. | — |
| RETURN VALUE | Qué devuelve en caso de éxito y de error. | Escribir la comprobación correcta. |
| ERRORS | Los valores posibles de errno y qué los causa. | Interpretar un fallo. |
| STANDARDS | La norma que define la función. | Saber si es portable. En páginas más antiguas se titula CONFORMING TO. |
| HISTORY / VERSIONS | Desde qué versión del kernel o de glibc existe. | Saber si funcionará en un sistema antiguo. |
| NOTES | Advertencias y detalles de implementación. | Aquí suele estar C library/kernel differences. |
| SEE ALSO | Páginas relacionadas. | Descubrir funciones vecinas. |
El subapartado C library/kernel differences es el que documenta los casos de la sección 9. En man 2 fork, por ejemplo, explica que el envoltorio de glibc no invoca la llamada fork del kernel sino clone. Cuando una traza de strace muestra un nombre que no esperabas, la explicación está casi siempre ahí.
Además de las páginas de cada función, hay algunas de conjunto que conviene conocer:
| Comando | Contenido |
|---|---|
man 2 syscalls | La lista de todas las llamadas al sistema de Linux, con la versión del kernel en que apareció cada una. |
man 2 intro | Introducción a la sección 2: qué es una llamada al sistema y cómo se informan los errores. |
man 3 errno | Todos los códigos de error, con su nombre y su significado. |
man 3 stdio | Visión general de las funciones de entrada y salida con búfer. |
man 7 standards | Los estándares que menciona el manual: las versiones de C y de POSIX. |
man 7 vdso | Qué llamadas se resuelven sin entrar al kernel en cada arquitectura. |
La sección 7 reúne textos explicativos sobre conceptos, a diferencia de las secciones 2 y 3, que son de referencia. Y man -k palabra busca en los títulos de todas las páginas, para cuando se sabe qué se quiere hacer pero no cómo se llama la función.
14. Resumen
- La biblioteca de C contiene envoltorios, funciones que no usan al kernel y funciones que administran un recurso pidiéndolo al kernel en bloques grandes.
printfda formato al texto y lo guarda en un búfer del proceso. La llamadawritese produce cuando ese búfer se vacía.- El búfer se vacía por líneas si la salida es una terminal y solo al llenarse si es un archivo o una tubería.
stderrno tiene búfer.fflushfuerza el vaciado. - Mezclar
printfywritesobre el mismo descriptor altera el orden de la salida cuando se la redirige. exitvacía los búferes antes de terminar;_exity los fallos no, y el texto pendiente se pierde.- Gracias al búfer, procesar byte a byte con stdio hace miles de veces menos llamadas al sistema que hacerlo con
readywrite. - stdio trabaja con
FILE *y está construida sobre la interfaz de descriptores. - Algunos envoltorios ejecutan una llamada distinta de la que sugiere su nombre:
openusaopenatyexitusaexit_group. - glibc es un archivo compartido por todos los procesos. Un ejecutable requiere una versión igual o posterior a aquella con la que se compiló.
- POSIX especifica las funciones que debe ofrecer un sistema tipo Unix. Garantiza la portabilidad del código fuente, no la de los ejecutables.
- Las funciones provienen de ISO C, de POSIX o de extensiones propias de Linux, de más a menos portables. El manual indica el origen de cada una.
15. Preguntas de repaso
Intenta responder cada una antes de abrir la respuesta.
1. Clasifica strlen, getpid y malloc según las llamadas al sistema que hacen.
strlen no hace ninguna: recorre memoria del programa. getpid es un envoltorio y hace exactamente una. malloc hace alguna de vez en cuando: pide memoria al kernel en bloques grandes y atiende la mayoría de los pedidos con lo que ya tiene.
2. ¿Cuáles son los dos trabajos de printf? ¿Cuál de ellos necesita al kernel?
Convertir los argumentos en texto según el formato, y añadir ese texto al búfer de stdout. Ninguno de los dos necesita al kernel. La llamada write ocurre aparte, cuando el búfer se vacía.
3. Un programa con tres printf hace tres write en la terminal y uno solo si se redirige a un archivo. ¿Por qué?
Porque el modo del búfer depende del destino. En una terminal es por líneas, y cada salto de línea provoca un vaciado. En un archivo es completo: los tres textos se acumulan y salen juntos al terminar el programa.
4. ¿Por qué stderr no usa búfer?
Porque se destina a mensajes de error y de diagnóstico, que deben verse en el acto y no deben perderse si el programa falla a continuación. El costo de una llamada por mensaje es aceptable a cambio de esa garantía.
5. En ./mezcla | cat, ¿por qué «dos» aparece antes que «uno»?
Con la salida en una tubería, el texto de printf queda en el búfer del proceso. El write directo no pasa por ese búfer y llega al kernel de inmediato. «uno» y «tres» salen después, juntos, cuando el búfer se vacía al terminar.
6. ¿Qué diferencia hay entre exit y _exit?
exit es una función de la biblioteca: vacía los búferes de stdio, ejecuta las funciones registradas con atexit y luego llama a _exit. _exit es el envoltorio de la llamada al sistema: finaliza el proceso de inmediato, y lo que hubiera en los búferes se pierde.
7. Un programa imprime «paso 3» con printf y falla enseguida, pero el mensaje no aparece. ¿Qué pudo pasar?
El mensaje quedó en el búfer. Al ser finalizado el proceso por el kernel, nadie llamó a exit y el búfer no se vació. Usando fprintf(stderr, …), o fflush(stdout) después del printf, el mensaje habría salido.
8. ¿Por qué copiar_stdio es mucho más rápido que copiar_1, si ambos procesan un byte por vez?
Porque getchar y putchar trabajan sobre búferes en la memoria del proceso y solo llaman al kernel cada 4096 bytes. copiar_1 hace una llamada al sistema por cada byte leído y otra por cada byte escrito.
9. El código llama a open y en la traza aparece openat. ¿Es un error?
No. La biblioteca implementa open mediante la llamada openat, más general. El nombre y los parámetros de la función se mantienen para los programas, y la llamada que llega al kernel es otra.
10. ¿Qué garantiza POSIX y qué no?
Garantiza que ciertas funciones existen, con parámetros, resultados y errores definidos, de modo que el código fuente se pueda compilar en cualquier sistema que cumpla la norma. No garantiza que un ejecutable funcione en otro sistema, ni dice cómo se implementan las funciones o qué números de llamada se usan.
11. ¿Por qué stdio.h existe en Windows y unistd.h no?
stdio.h pertenece al estándar del lenguaje C, que todo compilador debe ofrecer. unistd.h pertenece a POSIX, la interfaz de los sistemas tipo Unix, que Windows no implementa.
Para practicar
- En
clases.c, cambia el tamaño de cada bloque de 100 a 1000 bytes y repite la traza. ¿Cuántas llamadasbrkaparecen ahora? Prueba luego con un solomallocde 1 MB y trazando tambiénmmap. - Ejecuta
./unidos | catconstrace -e trace=write -o traza.txt. ¿Se parece al caso de la terminal o al del archivo? - Corrige
demora.cde las tres maneras de la sección 4 y comprueba cada una. - Añade
fflush(stdout)enmezcla.cpara que el orden sea correcto también con| cat. ¿Cuántas llamadaswritehace ahora el programa? - En
perdido.c, añade\nal final del texto y ejecútalo en la terminal y con| cat. Explica la diferencia. Luego reemplaza_exit(0)porexit(0). - Escribe un programa que imprima un mensaje sin salto de línea y a continuación lea de la dirección 0, como
espiar.cdel tema 12. Comprueba que el mensaje no aparece, y corrígelo. - Ejecuta
strace -e trace=read -o traza.txt ./copiar_stdio < datos.bin > /dev/nully mira las primeras líneas conhead traza.txt. ¿De qué tamaño son las lecturas? - Averigua la versión de glibc de tu sistema de tres formas: ejecutando el archivo, con
ldd --versiony congetconf GNU_LIBC_VERSION. - Consulta
man 2 getpid,man 3 printfyman 2 gettid. Para cada una, localiza el apartado que indica el estándar y determina a cuál de los tres círculos pertenece. - Busca en
man 2 _exitel apartado C library/kernel differences y relaciona lo que dice con la traza denombres.c.