Hemos hablado en las cinco lecciones anteriores de una frontera invisible: la que separa el código de tus aplicaciones del código privilegiado del sistema. Ha aparecido al hablar de qué es el núcleo, al clasificar arquitecturas y al seguir una petición HTTP. Ha llegado el momento de mirarla de cerca. En esta lección vas a entender cómo el hardware hace posible que el sistema operativo se defienda de los programas que ejecuta, qué ocurre exactamente —instrucción a instrucción— cuando ingestor guarda una lectura en disco, y por qué una operación aparentemente trivial como write() cuesta cientos de veces más que una llamada a función normal. Al terminar sabrás leer la salida de strace, que es probablemente la herramienta de diagnóstico más reveladora que existe en Linux, y entenderás por qué el buffering no es un detalle de implementación sino una decisión de rendimiento de primer orden.
Contenido
- Por qué un programa no puede tocar el hardware
- El modo dual: bit de modo e instrucciones privilegiadas
- Anillos de privilegio
- Qué es una llamada al sistema
- El recorrido paso a paso, de usuario a núcleo y vuelta
- API, biblioteca C y llamada al sistema: tres cosas distintas
- Categorías de llamadas al sistema
- Ejemplo en C:
write()frente afprintf() - Observación real con
strace - El coste de una llamada al sistema y por qué conviene minimizarlas
Por qué un programa no puede tocar el hardware
Empecemos por el problema. Supón que meteo-api, un programa ordinario, pudiera ejecutar cualquier instrucción de la CPU y acceder a cualquier dirección de memoria. Bastaría un fallo, o un atacante que hubiera comprometido el proceso, para que:
- Leyera la memoria de
ingestory obtuviera datos de otros clientes. - Escribiera directamente en el disco, saltándose el sistema de archivos y los permisos, y modificara
/etc/shadow. - Deshabilitara las interrupciones y monopolizara la CPU para siempre: ni siquiera el temporizador podría recuperarla.
- Reprogramara la tabla de páginas para acceder a toda la RAM física.
Fíjate en un matiz decisivo: no basta con que el sistema operativo "no le deje". Si el programa puede ejecutar la instrucción que reprograma la MMU, la ejecutará y el sistema no tendrá forma de impedirlo, porque para entonces ya se ha ejecutado. La única solución posible es que el hardware se niegue.
Y ahí está la idea central de toda la protección en informática: la seguridad de un sistema operativo no descansa en su código, sino en un mecanismo del procesador que el software no puede eludir.
El modo dual: bit de modo e instrucciones privilegiadas
La CPU tiene un bit de modo en su registro de estado que indica en qué modo se está ejecutando:
| Modo usuario | Modo núcleo (supervisor) | |
|---|---|---|
| Quién se ejecuta aquí | Aplicaciones, bibliotecas, shell | El núcleo y sus módulos |
| Instrucciones permitidas | Solo las no privilegiadas | Todas |
| Memoria accesible | Solo su propio espacio de direcciones | Toda la memoria física |
| Acceso a dispositivos | Ninguno directo | Total |
| Efecto de un fallo | Muere el proceso | Kernel panic o corrupción |
Ciertas instrucciones son privilegiadas: si se intentan ejecutar en modo usuario, la CPU no las ejecuta y genera una excepción. En x86-64, algunos ejemplos:
| Instrucción | Qué hace | Por qué debe ser privilegiada |
|---|---|---|
hlt |
Detiene la CPU hasta la siguiente interrupción | Un programa podría parar la máquina |
cli / sti |
Deshabilita / habilita interrupciones | Sin interrupciones, nadie puede quitarle la CPU |
mov a cr3 |
Cambia la tabla de páginas activa | Daría acceso a toda la memoria física |
in / out |
Lee y escribe puertos de E/S | Acceso directo a los dispositivos |
lgdt / lidt |
Carga las tablas de descriptores y de interrupciones | Permitiría redefinir el propio mecanismo de protección |
wrmsr |
Escribe registros específicos del modelo | Incluye el que define dónde salta una llamada al sistema |
Puedes comprobar en la práctica que la protección funciona:
/* prueba_privilegio.c — compilar: gcc -o prueba prueba_privilegio.c */
#include <stdio.h>
int main(void) {
printf("Antes de la instruccion privilegiada\n");
__asm__ volatile ("cli"); /* deshabilitar interrupciones */
printf("Esto no se imprimira nunca\n");
return 0;
}Qué ha pasado, línea a línea:
__asm__ volatile ("cli")inserta directamente la instrucción máquinaclien el programa.volatileimpide que el compilador la reordene o la elimine por considerarla inútil.- La CPU, al encontrarse
clicon el bit de modo en "usuario", no la ejecuta. Genera una excepción de protección general. - El núcleo atiende esa excepción, comprueba de qué proceso viene y le envía la señal
SIGSEGV, que por defecto termina el proceso. - El segundo
printfnunca se ejecuta.
Esto es exactamente lo que queríamos demostrar: la negativa no viene del sistema operativo, viene del silicio. El sistema operativo solo decide qué hacer después.
¿Y cómo se pone el bit de modo?
Aquí está la parte elegante del diseño. Si un programa pudiera poner el bit de modo a "núcleo", toda la protección sería inútil. Por eso el bit no se puede modificar directamente. Solo cambia a modo núcleo en tres circunstancias, y en las tres el control salta simultáneamente a una dirección que el núcleo fijó de antemano:
- Una interrupción de hardware (el disco terminó, llegó un paquete de red). Es asíncrona, no la provoca el programa.
- Una excepción (división por cero, fallo de página, instrucción privilegiada). Es síncrona pero involuntaria.
- Una llamada al sistema, es decir, una petición voluntaria del programa.
En los tres casos, el cambio de modo y el salto son una sola operación atómica del hardware. No hay ningún instante en el que se esté en modo núcleo ejecutando código elegido por el programa. Esa atomicidad es lo que hace que el sistema sea seguro.
Anillos de privilegio
La arquitectura x86 generaliza el modo dual a cuatro niveles o anillos, numerados del 0 (máximo privilegio) al 3 (mínimo). La idea, heredada de Multics, era permitir niveles intermedios: por ejemplo, controladores en el anillo 1, con menos privilegios que el núcleo pero más que las aplicaciones.
| Anillo | Uso previsto originalmente | Uso real |
|---|---|---|
| 0 | Núcleo | Núcleo (Linux, Windows) |
| 1 | Controladores de dispositivo | Prácticamente ninguno |
| 2 | Servicios del sistema | Prácticamente ninguno |
| 3 | Aplicaciones | Aplicaciones |
En la práctica, casi todos los sistemas usan solo el 0 y el 3, por dos razones: porque otras arquitecturas (ARM, RISC-V, MIPS) solo ofrecen dos niveles y usar cuatro rompería la portabilidad, y porque los niveles intermedios complican mucho el diseño para un beneficio discutible. Un dato curioso: los anillos 1 y 2 sí llegaron a usarse en algunas técnicas de virtualización antes de que existiera el soporte de hardware.
La virtualización moderna sí añadió un nivel: el llamado anillo -1 o modo raíz VMX, donde se ejecuta el hipervisor, por debajo del anillo 0 de los sistemas invitados. Lo verás en Virtualización: Hipervisores y Máquinas Virtuales.
Qué es una llamada al sistema
Una llamada al sistema (system call o syscall) es la única puerta legítima para que un programa en modo usuario pida algo al núcleo. Es una petición voluntaria y controlada de cruzar la frontera.
Tres características la definen:
- El punto de entrada lo elige el núcleo, no el programa. El programa no puede saltar a una dirección arbitraria del núcleo. Solo puede decir "quiero la llamada número 1" y el núcleo decide qué código ejecuta.
- Los argumentos se validan. El núcleo comprueba que los punteros que le pasas apuntan a memoria que realmente es tuya, que el descriptor de fichero existe, que el tamaño es razonable. Ninguna comprobación es opcional: cada una tapa un agujero de seguridad.
- El retorno vuelve al modo usuario. Cuando la llamada termina, el bit de modo vuelve a "usuario" y la ejecución continúa justo después de la instrucción que provocó la entrada.
Linux tiene unas 350 llamadas al sistema en x86-64. Puedes ver la lista completa:
Ese número de la izquierda es el número de llamada al sistema, y es lo que el programa pone en un registro para indicar qué quiere. Los números son estables para siempre dentro de una arquitectura: si cambiaran, todos los binarios existentes dejarían de funcionar. Por eso read es el 0 y write el 1 desde hace décadas.
El recorrido paso a paso, de usuario a núcleo y vuelta
Sigamos lo que ocurre cuando ingestor ejecuta write(fd, &lectura, 24).
sequenceDiagram
participant U as ingestor (anillo 3)
participant L as glibc (anillo 3)
participant H as CPU
participant K as Núcleo (anillo 0)
U->>L: write(fd, &lectura, 24)
Note over L: Coloca argumentos:<br/>rax=1 (nº de syscall)<br/>rdi=fd, rsi=&lectura, rdx=24
L->>H: instrucción SYSCALL
Note over H: 1. Guarda RIP en RCX y RFLAGS en R11<br/>2. Carga RIP desde el MSR LSTAR<br/>3. Cambia el bit de modo a núcleo
H->>K: entry_SYSCALL_64
Note over K: Cambia a la pila del núcleo<br/>Guarda el resto de registros
Note over K: Valida: ¿existe rax=1?<br/>¿fd es válido?<br/>¿&lectura apunta a memoria del proceso?
Note over K: Ejecuta sys_write:<br/>copia los 24 bytes a la caché de páginas
Note over K: Restaura registros<br/>Pone el resultado (24) en RAX
K->>H: instrucción SYSRET
Note over H: Restaura RIP desde RCX y RFLAGS desde R11<br/>Cambia el bit de modo a usuario
H->>L: retorno con RAX = 24
Note over L: Si RAX es negativo: errno = -RAX, devolver -1<br/>Si no: devolver RAX
L->>U: 24 bytes escritos
Detallemos las fases:
1. Preparación de los argumentos (modo usuario). La convención de llamada de Linux en x86-64 fija qué registro lleva cada cosa:
| Registro | Contenido |
|---|---|
rax |
Número de la llamada al sistema (1 = write) |
rdi |
Primer argumento (el descriptor fd) |
rsi |
Segundo argumento (el puntero a lectura) |
rdx |
Tercer argumento (el tamaño, 24) |
r10, r8, r9 |
Cuarto, quinto y sexto argumento |
Observa que se usa r10 y no rcx para el cuarto argumento, a diferencia de las llamadas a función normales. La razón es puramente mecánica: la instrucción syscall destruye rcx al guardar allí la dirección de retorno.
2. La instrucción syscall. Una única instrucción máquina que, atómicamente:
- Guarda el contador de programa (
rip) enrcxy los flags (rflags) enr11. - Carga en
ripla dirección que el núcleo escribió al arrancar en el registro especialMSR_LSTAR. - Cambia el bit de modo a núcleo.
Nada de esto lo controla el programa: la dirección de destino la fijó el núcleo mucho antes.
3. Cambio de pila. El núcleo no puede usar la pila del proceso de usuario, porque su contenido podría estar manipulado o apuntar a memoria inválida. Cada proceso tiene una pila de núcleo separada (16 KB en Linux x86-64) y el núcleo cambia a ella inmediatamente. Este es uno de los detalles que más se pasan por alto y es esencial para la seguridad.
4. Despacho y validación. El núcleo comprueba que rax esté en el rango de llamadas válidas y consulta la tabla de llamadas al sistema para saltar a la función correspondiente (sys_write). Después valida cada argumento. Por ejemplo, para el puntero usa copy_from_user() en lugar de leer directamente: esa función comprueba que la dirección pertenece al espacio del proceso. Un núcleo que dereferenciase directamente un puntero de usuario tendría una vulnerabilidad crítica.
5. Ejecución del trabajo real. sys_write localiza el fichero a partir del descriptor, copia los 24 bytes a la caché de páginas y actualiza el tamaño del fichero.
6. Retorno. El resultado se coloca en rax, se restauran los registros y la instrucción sysret devuelve el rip y los flags guardados, y pone el bit de modo en "usuario".
7. Traducción del error. Aquí ocurre algo que confunde a mucha gente. El núcleo no usa errno: devuelve el error como un número negativo pequeño en rax (por ejemplo, -13 para "permiso denegado"). Es la biblioteca C la que, al ver un valor entre −1 y −4095, hace:
if (resultado < 0 && resultado > -4096) {
errno = -resultado; /* errno = 13 (EACCES) */
return -1;
}
return resultado;Por eso errno es una variable de la biblioteca C, no del núcleo, y por eso hay que consultarla inmediatamente después de la llamada fallida: cualquier otra función de biblioteca puede sobrescribirla.
API, biblioteca C y llamada al sistema: tres cosas distintas
Esta distinción se confunde constantemente y aclararla ahorra mucha confusión posterior.
| Qué es | Ejemplo | Dónde se ejecuta | |
|---|---|---|---|
| API | Un contrato, una especificación de funciones | POSIX, Win32 | En ningún sitio: es un documento |
| Biblioteca C | Código real que implementa parte de esa API | glibc, musl |
Modo usuario, dentro de tu proceso |
| Llamada al sistema | La petición concreta al núcleo | write (nº 1) |
Cruza a modo núcleo |
Las relaciones importantes:
- Una función de biblioteca puede no hacer ninguna llamada al sistema.
strlen(),malloc()cuando hay memoria en su reserva interna, oprintf()cuando solo llena su búfer. - Una función de biblioteca puede hacer varias.
fopen()haceopen()y a menudofstat().printf()con el búfer lleno hace unwrite(). - Una llamada al sistema puede tener varias envolturas distintas.
open(),open64(),creat()yfopen()acaban todas en la misma familia de llamadas. - Puedes invocar una llamada al sistema sin biblioteca, con
syscall(1, fd, buf, 24)o directamente en ensamblador. Casi nunca merece la pena.
Un caso especialmente interesante en el sentido contrario: algunas llamadas al sistema no cruzan al núcleo gracias al vDSO (virtual Dynamic Shared Object), una pequeña biblioteca que el núcleo mapea en el espacio de cada proceso. gettimeofday() y clock_gettime() leen la hora de una página compartida de solo lectura sin cambiar de modo. La razón es puro rendimiento: son llamadas tan frecuentes que se les hizo un atajo especial.
Categorías de llamadas al sistema
| Categoría | Qué hacen | Ejemplos en Linux | Uso en Meteora |
|---|---|---|---|
| Control de procesos | Crear, terminar, esperar y sustituir procesos | fork, clone, execve, exit, wait4, kill |
systemd lanza ingestor al arrancar |
| Gestión de ficheros | Abrir, leer, escribir, cerrar, mover, borrar | open, read, write, close, lseek, unlink, rename |
ingestor escribe en 2026-08-31.dat |
| Gestión de dispositivos | Solicitar, liberar y controlar dispositivos | ioctl, mmap, read/write sobre /dev/* |
Ajustar parámetros de la tarjeta de red |
| Información del sistema | Consultar y fijar hora, identidad, límites | time, clock_gettime, getpid, uname, getrlimit |
Sellar la marca de tiempo de cada Lectura |
| Comunicación | Sockets, tuberías, memoria compartida, señales | socket, bind, listen, accept, pipe, shmget |
meteo-api acepta conexiones en el puerto 8080 |
| Protección | Permisos, identidad, capacidades | chmod, chown, setuid, umask, capset |
Cambiar de root a meteora tras arrancar |
Un patrón que se repite en todas: las llamadas al sistema son deliberadamente pocas y de bajo nivel. No existe una llamada "leer un fichero de configuración" ni "hacer una petición HTTP". El núcleo ofrece primitivas mínimas y las bibliotecas construyen encima. Cada llamada al sistema es una superficie de ataque y una promesa de compatibilidad para siempre, así que añadir una nueva es una decisión que se toma con extremo cuidado.
Ejemplo en C: write() frente a fprintf()
Vamos a escribir la misma Lectura de dos formas y a entender por qué no son equivalentes.
/* guardar_lectura.c — compilar: gcc -O2 -o guardar guardar_lectura.c */
#include <stdio.h>
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <string.h>
struct Lectura {
uint32_t estacion_id;
int64_t timestamp;
float temperatura;
float humedad;
float presion;
};
/* Versión A: llamada al sistema directa, formato binario */
int guardar_binario(const char *ruta, const struct Lectura *l) {
int fd = open(ruta, O_WRONLY | O_CREAT | O_APPEND, 0640);
if (fd == -1) {
fprintf(stderr, "open falló: %s\n", strerror(errno));
return -1;
}
ssize_t escritos = write(fd, l, sizeof(*l));
if (escritos != (ssize_t)sizeof(*l)) {
fprintf(stderr, "write incompleto (%zd de %zu bytes)\n",
escritos, sizeof(*l));
close(fd);
return -1;
}
close(fd);
return 0;
}
/* Versión B: biblioteca estándar, formato de texto */
int guardar_texto(const char *ruta, const struct Lectura *l) {
FILE *f = fopen(ruta, "a");
if (f == NULL) {
fprintf(stderr, "fopen falló: %s\n", strerror(errno));
return -1;
}
fprintf(f, "%u;%ld;%.1f;%.1f;%.1f\n",
l->estacion_id, l->timestamp,
l->temperatura, l->humedad, l->presion);
fclose(f); /* fclose vacía el búfer antes de cerrar */
return 0;
}
int main(void) {
struct Lectura l = { .estacion_id = 118, .timestamp = 1756636800,
.temperatura = 27.4f, .humedad = 61.0f,
.presion = 1013.2f };
guardar_binario("/var/lib/meteora/lecturas/2026-08-31.dat", &l);
guardar_texto("/var/lib/meteora/lecturas/2026-08-31.csv", &l);
return 0;
}Análisis de la versión A (write):
open(...)es una llamada al sistema directa. Devuelve unint, el descriptor, o-1en caso de error.- La comprobación
if (fd == -1)no es opcional: casi todos los fallos reales de un servicio en producción vienen de errores no comprobados.strerror(errno)convierte el código numérico en un mensaje legible. write(fd, l, sizeof(*l))entrega los 24 bytes al núcleo inmediatamente: una llamada al sistema por cada lectura.- La comprobación
escritos != sizeof(*l)cubre un caso que sorprende a mucha gente:writepuede escribir menos bytes de los pedidos y devolver un número menor sin que sea un error. Ocurre sobre todo con sockets y tuberías. Ignorarlo produce truncamientos silenciosos. - El resultado en disco son 24 bytes binarios ilegibles con
cat, pero compactos y de tamaño exacto.
Análisis de la versión B (fprintf):
fopen(...)es una función de biblioteca que envuelve aopen()y además reserva un búfer (normalmente 4096 bytes) y devuelve unFILE *, una estructura de la biblioteca C, no del núcleo.fprintf(...)formatea los datos como texto y los copia al búfer en memoria de usuario. En este caso concreto, no realiza ninguna llamada al sistema: la línea generada ocupa unos 35 bytes y cabe de sobra.fclose(f)vacía el búfer, lo que sí provoca unwrite(), y luego cierra el descriptor.- El resultado en disco es
118;1756636800;27.4;61.0;1013.2, legible pero de tamaño variable y mayor (35 bytes frente a 24).
La tabla que resume la diferencia:
write() |
fprintf() |
|
|---|---|---|
| Nivel | Llamada al sistema | Biblioteca C |
| Búfer | Ninguno en usuario | Sí, típicamente 4 KB |
| Llamadas al sistema por lectura | 1 siempre | 1 cada ~117 lecturas |
| Formato | Binario, 24 bytes fijos | Texto, ~35 bytes variables |
Legible con cat |
No | Sí |
| Portabilidad de los datos | Depende de la arquitectura (orden de bytes, relleno) | Total |
| Datos en riesgo si el proceso muere | Los del búfer del núcleo | Los del búfer de usuario y los del núcleo |
| Rendimiento con muchas escrituras | Peor | Mucho mejor |
La última fila es la clave, y la cuantificamos enseguida. La penúltima explica un fenómeno frecuentísimo: un programa que muere de forma abrupta pierde lo que tenía en el búfer de la biblioteca C, y por eso los registros se cortan justo antes del error que buscas. Para eso existe fflush(), y por eso stderr es no bufferizado por defecto.
Observación real con strace
strace intercepta y muestra todas las llamadas al sistema que hace un proceso. Es la herramienta que convierte todo lo anterior en algo que puedes ver.
strace: Process 1099 attached
recvfrom(7, "\x76\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000009>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=142883917}) = 0 <0.000001>
write(9, "\x76\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000021>
recvfrom(7, 0x7ffd4a2b1c40, 512, 0, NULL, NULL) = -1 EAGAIN (Resource temporarily unavailable) <0.000005>
epoll_wait(5, [{EPOLLIN, {u32=7}}], 64, 1000) = 1 <0.031472>
recvfrom(7, "\x77\x00\x01\x18\x00\x00...", 512, 0, NULL, NULL) = 48 <0.000008>
clock_gettime(CLOCK_REALTIME, {tv_sec=1756636800, tv_nsec=174301522}) = 0 <0.000001>
write(9, "\x77\x00\x00\x00\x00\x8e\xc1\x68...", 24) = 24 <0.000019>Empecemos por los argumentos del comando:
-fsigue también a los hilos e hijos del proceso. Sin esto, en un programa con varios hilos verías solo una parte.-Tmuestra entre<>el tiempo que tardó cada llamada. Es la opción más útil para diagnosticar.-p 1099se engancha a un proceso ya en ejecución, en este casoingestor. Alternativamente,strace ./programalo lanza desde el principio.2>&1redirige la salida de error (dondestraceescribe) a la estándar, para poder pasarla porhead.
Ahora la interpretación del ciclo, que es lo interesante:
recvfrom(7, ...) = 48— lee 48 bytes del socket con descriptor 7: el paquete de una estación. El= 48es el valor de retorno. Tardó 9 microsegundos.clock_gettime(CLOCK_REALTIME, ...) = 0— obtiene la hora para sellar la lectura. Tardó 1 microsegundo: sospechosamente poco para una llamada al sistema, y la razón es que se resuelve por el vDSO sin cruzar al núcleo.write(9, ..., 24) = 24— escribe los 24 bytes de laLecturaen el descriptor 9, el fichero del día. Tardó 21 µs.recvfrom(...) = -1 EAGAIN— vuelve a intentar leer del socket y no hay nada.EAGAINen un socket no bloqueante no es un error real: significa "ahora mismo no hay datos, vuelve luego".epoll_wait(5, ..., 1000) = 1— el proceso se bloquea esperando actividad, con un tiempo máximo de 1000 ms. Tardó 31.472 µs, es decir, 31 ms. Durante ese tiempoingestorestá en estadoSy no consume nada de CPU: es el comportamiento correcto de un servicio que espera.
Para ver el resumen agregado, que suele ser más útil que el detalle:
% time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 71.42 0.982441 1966 500 epoll_wait 15.31 0.210623 4 52000 write 8.02 0.110336 4 26000 recvfrom 3.15 0.043341 1 26000 clock_gettime 2.10 0.028902 1 26000 26000 recvfrom ------ ----------- ----------- --------- --------- ---------------- 100.00 1.375643 130500 26000 total
Cómo se lee esto, que es donde está el valor real de la herramienta:
epoll_waitse lleva el 71 % del tiempo, pero eso es bueno: es tiempo bloqueado esperando trabajo, no CPU consumida. Un servicio sano pasa la mayor parte del tiempo aquí.- La columna
errorsmuestra 26.000 errores enrecvfrom. Parece alarmante, pero son losEAGAINesperados del sondeo no bloqueante. Muchos errores enstrace -cno significan problema; hay que mirar cuáles son. - Y aquí está el hallazgo importante: 52.000 llamadas a
writepara 26.000 lecturas recibidas. Son exactamente dos escrituras por lectura. Investigando el código descubriríamos queingestorescribe la lectura en el fichero de datos y además una línea en el registro, sin ningún búfer. Ese es el objetivo de optimización, y nos lleva directamente al último apartado.
El coste de una llamada al sistema y por qué conviene minimizarlas
Pongamos números al coste de cruzar la frontera.
| Operación | Coste típico | Comparación |
|---|---|---|
| Llamada a función normal | ~1-2 ns | Referencia |
Llamada al sistema mínima (getpid) |
~50-100 ns | 50 veces más |
| Llamada al sistema con mitigaciones Spectre/Meltdown | ~300-800 ns | Hasta 400 veces más |
write de 24 bytes a la caché de páginas |
~2.000-20.000 ns | Miles de veces más |
¿De dónde sale ese coste? De varias fuentes que se suman:
- El propio cambio de modo y de pila.
- Guardar y restaurar registros.
- La validación de argumentos.
- Y sobre todo, desde 2018, las mitigaciones de Meltdown y Spectre. La principal, KPTI (Kernel Page Table Isolation), separa las tablas de páginas del núcleo y del usuario, lo que obliga a vaciar parte de la TLB en cada transición. Esta mitigación multiplicó el coste de las llamadas al sistema por un factor de entre 2 y 5, e hizo que el buffering pasara de ser recomendable a ser imprescindible.
El caso de ingestor con números
Con 500 estaciones enviando una lectura por minuto:
Situación actual (dos write por lectura, sin búfer):
720.000 × 2 = 1.440.000 llamadas al sistema al día 1.440.000 × 5 µs = 7,2 segundos de CPU al día solo en cruzar la frontera
Con un búfer de 4 KB para el fichero de datos. Como cada Lectura ocupa 24 bytes, en 4 KB caben 170:
720.000 / 170 ≈ 4.235 escrituras al día para los datos más el registro con búfer ≈ 4.235 más Total ≈ 8.470 llamadas al sistema al día 8.470 × 5 µs = 0,04 segundos de CPU al día
Reducción: un factor de 170. De 7,2 segundos a 0,04 segundos de CPU diarios.
En términos absolutos 7 segundos al día no parecen nada, y en meteo-01 con 500 estaciones probablemente no lo son. Pero el razonamiento cambia por completo si Meteora crece a 50.000 estaciones: pasaríamos de 12 minutos de CPU diarios malgastados a 4 segundos, y con carga en ráfagas la diferencia se nota como latencia, no como consumo medio.
El compromiso que hay que entender
El búfer no es gratis. Cambia rendimiento por durabilidad:
| Sin búfer | Con búfer de 4 KB | |
|---|---|---|
| Llamadas al sistema | 720.000/día | 4.235/día |
| Datos en riesgo si el proceso muere | 0 lecturas | Hasta 170 lecturas |
| Latencia hasta que el dato es visible para otros procesos | Inmediata | Hasta que se vacíe el búfer |
Y hay un tercer nivel que conviene distinguir con precisión, porque casi nadie lo tiene claro:
- Búfer de la biblioteca C (espacio de usuario). Se vacía con
fflush(). Si el proceso muere, se pierde. - Caché de páginas del núcleo. Los datos ya están fuera del proceso: si el proceso muere, sobreviven. Pero si la máquina se apaga, se pierden.
- Disco físico. Se fuerza con
fsync(). Solo aquí el dato es realmente duradero.
La decisión de ingeniería para Meteora sería: usar búfer para los datos de lecturas (perder 170 lecturas en una caída es asumible, siempre se pueden retransmitir) pero no para las alertas críticas ni para el registro de auditoría, donde cada entrada debe llegar al disco aunque cueste. Este mismo compromiso, en su forma más general, reaparecerá en Asignación de Espacio, Journaling e Integridad.
Errores Comunes y Consejos
- Creer que
errnoviene del núcleo. El núcleo devuelve un negativo enrax;errnoes una variable de la biblioteca C. Consúltala inmediatamente después de la llamada fallida, antes de invocar cualquier otra función. - No comprobar el valor de retorno de
write. Puede escribir menos bytes de los pedidos sin que sea un error. El patrón correcto es un bucle que reintente con lo que falta. - Confundir función de biblioteca con llamada al sistema.
printfno es una llamada al sistema;fopenno esopen.stracees la forma definitiva de comprobar qué cruza de verdad la frontera. - Pensar que
write()significa "está en el disco". Significa que está en la caché de páginas del núcleo. Solofsync()garantiza durabilidad, y solo si el hardware no miente sobre sus propias cachés. - Optimizar sin medir. Antes de rediseñar nada,
strace -cdurante un minuto te dice exactamente qué llamadas dominan. En el caso deingestor, las 52.000 escrituras aparecieron solas. - Abusar de
straceen producción. Ralentiza el proceso observado de forma considerable (puede multiplicar por 10 o más el coste de cada llamada), porque cada una provoca dos paradas del proceso. Para producción son preferibles herramientas de menor impacto comoperfobpftrace. - Consejo: cuando un programa vaya lento y no sepas por qué, ejecuta
strace -c -fdurante treinta segundos. En la mayoría de los casos, la respuesta salta a la vista en la primera línea de la tabla.
Ejercicios
Ejercicio 1
Para cada una de estas funciones de un programa C, indica cuántas llamadas al sistema provoca aproximadamente y por qué. Razona en términos de búferes:
strlen("2026-08-31.dat")printf("Lectura recibida\n")con la salida redirigida a un fichero.printf("Lectura recibida\n")con la salida en un terminal interactivo.- Un bucle que llama 1.000 veces a
fprintf(f, "%.1f\n", temp)sobre un fichero abierto confopen. - Un bucle que llama 1.000 veces a
write(fd, buf, 8).
Ejercicio 2
meteo-api responde a cada petición HTTP escribiendo una línea de 120 bytes en /var/log/meteora/meteo-api.log con write() directo, sin búfer. Con 200 peticiones por segundo y un coste de 5 µs por llamada al sistema:
- ¿Cuánta CPU al día se dedica solo a esas llamadas?
- Si se añade un búfer de 8 KB, ¿cuántas llamadas quedarían y cuánta CPU?
- ¿Qué se pierde con ese cambio y en qué caso no debería hacerse?
Ejercicio 3
Escribe un programa en C que reciba 1.000 estructuras Lectura simuladas y las escriba en un fichero, minimizando el número de llamadas al sistema sin usar la biblioteca stdio (es decir, con write() directo). Explica el diseño y calcula cuántas llamadas hace frente a la versión ingenua.
Soluciones
Solución 1
1. strlen(...) → 0 llamadas al sistema.
Recorre memoria del propio proceso contando bytes hasta el terminador nulo. No necesita nada del núcleo. Es el ejemplo canónico de función de biblioteca que no cruza la frontera.
2. printf redirigido a fichero → 0 llamadas en esa invocación.
Cuando la salida estándar no es un terminal, glibc la configura como totalmente bufferizada (4 KB). Los 17 bytes se copian al búfer y ahí se quedan. La llamada a write() ocurrirá cuando el búfer se llene (tras unos 240 mensajes), cuando se llame a fflush() o al terminar el programa de forma ordenada.
Consecuencia práctica importante: si el programa muere de forma abrupta, el fichero de salida aparece truncado, y con frecuencia falta justo el mensaje que explicaba el fallo.
3. printf a un terminal → 1 llamada al sistema.
Cuando la salida es un terminal, glibc usa buffering por líneas: vacía el búfer al encontrar un \n. Como el mensaje termina en salto de línea, se produce un write() inmediato.
Esto explica un comportamiento que desconcierta a mucha gente: el mismo programa muestra sus mensajes al instante en pantalla pero parece "no escribir nada" al redirigir a fichero. No es un fallo, es un cambio de política de buffering.
4. 1.000 fprintf de ~6 bytes → aproximadamente 2 llamadas.
6.000 bytes en total, con un búfer de 4.096: se vacía una vez al llenarse y otra al hacer fclose. Reducción de 1.000 a 2.
5. 1.000 write de 8 bytes → exactamente 1.000 llamadas.
write() no tiene búfer: cada invocación cruza a modo núcleo. A 5 µs cada una son 5 ms de CPU para escribir 8 KB, cuando con un búfer bastarían 2 llamadas y 10 µs. Es una diferencia de 500 veces, y es exactamente el error que detectamos en ingestor con strace -c.
Solución 2
1. Situación actual:
200 peticiones/s × 86.400 s/día = 17.280.000 escrituras al día 17.280.000 × 5 µs = 86,4 segundos de CPU al día
Un minuto y medio de CPU diario dedicado exclusivamente a cruzar la frontera para escribir el registro. Expresado de otra forma: el 0,1 % de un núcleo de forma continua, solo para registrar.
2. Con búfer de 8 KB:
8.192 bytes / 120 bytes por línea = 68 líneas por vaciado 17.280.000 / 68 ≈ 254.118 llamadas al sistema al día 254.118 × 5 µs = 1,27 segundos de CPU al día
Reducción de un factor de 68, exactamente el número de líneas que caben en el búfer. De 86,4 a 1,27 segundos.
3. Qué se pierde y cuándo no hacerlo:
Se pierden dos cosas:
- Durabilidad ante una caída del proceso: hasta 68 líneas de registro, que corresponden a los últimos 0,34 segundos de actividad. Y aquí está el problema serio: son precisamente las líneas que describen lo que ocurrió justo antes del fallo, es decir, las más valiosas para diagnosticarlo.
- Inmediatez de la observación: un administrador que ejecute
tail -fsobre el registro verá los mensajes a saltos de 68 en 68, con retraso variable. Y las herramientas de monitorización que leen el fichero detectarán los problemas más tarde.
No debería hacerse en tres casos concretos:
- Registro de auditoría o de seguridad. Si sirve como evidencia (quién accedió a qué y cuándo), perder las últimas entradas es inaceptable. Además, un atacante que provoque una caída borraría con ella el rastro más comprometedor.
- Registro de errores. Conviene aplicar la misma política que
stderr: sin búfer. El volumen de errores es bajo por definición, así que el coste es despreciable y el valor diagnóstico es máximo. - Cuando existe un requisito de trazabilidad externo (normativo o contractual) que exija constancia de cada operación.
La solución de ingeniería correcta es separar los flujos: el registro de acceso, que es voluminoso y poco crítico, con búfer; y los registros de error y auditoría, que son escasos y críticos, sin búfer. Es exactamente lo que hacen los servidores web serios, y también la razón de que journald distinga niveles de prioridad.
Solución 3
/* escritor_lote.c — compilar: gcc -O2 -o escritor_lote escritor_lote.c */
#include <stdint.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>
struct Lectura {
uint32_t estacion_id;
int64_t timestamp;
float temperatura;
float humedad;
float presion;
};
#define CAPACIDAD 170 /* 170 × 24 = 4.080 bytes, cabe en 4 KB */
struct Buffer {
int fd;
struct Lectura datos[CAPACIDAD];
size_t usados;
};
/* Escribe todo el búfer, reintentando ante escrituras parciales */
static int vaciar(struct Buffer *b) {
if (b->usados == 0) return 0;
const char *p = (const char *)b->datos;
size_t pendientes = b->usados * sizeof(struct Lectura);
while (pendientes > 0) {
ssize_t n = write(b->fd, p, pendientes);
if (n == -1) {
if (errno == EINTR) continue; /* interrumpido: reintentar */
return -1; /* error real */
}
p += n;
pendientes -= (size_t)n;
}
b->usados = 0;
return 0;
}
static int anadir(struct Buffer *b, const struct Lectura *l) {
if (b->usados == CAPACIDAD) {
if (vaciar(b) == -1) return -1;
}
b->datos[b->usados++] = *l;
return 0;
}
int main(void) {
struct Buffer b = { .usados = 0 };
b.fd = open("/var/lib/meteora/lecturas/2026-08-31.dat",
O_WRONLY | O_CREAT | O_APPEND, 0640);
if (b.fd == -1) { perror("open"); return 1; }
for (int i = 0; i < 1000; i++) {
struct Lectura l = { .estacion_id = (uint32_t)(100 + i % 50),
.timestamp = 1756636800 + i,
.temperatura = 20.0f + (i % 15),
.humedad = 55.0f, .presion = 1013.0f };
if (anadir(&b, &l) == -1) { perror("anadir"); return 1; }
}
if (vaciar(&b) == -1) { perror("vaciar final"); return 1; }
close(b.fd);
return 0;
}Explicación del diseño:
CAPACIDAD= 170 no es arbitrario. 170 × 24 = 4.080 bytes, justo por debajo de los 4.096 de una página. Alinear el búfer con el tamaño de página aprovecha mejor la caché de páginas del núcleo y evita que una escritura cruce innecesariamente el límite de dos páginas.vaciar()usa un bucle, no un solowrite. Esto es lo que distingue código correcto de código que funciona hasta que un día no.write()puede devolver menos bytes de los pedidos, y el bucle avanza el punteropy decrementapendienteshasta haber escrito todo.- El tratamiento de
EINTRcubre otro caso real: si llega una señal mientras el proceso está bloqueado enwrite, la llamada puede retornar con-1yerrno == EINTRsin haber escrito nada. No es un error: hay que reintentar. Omitir esto produce pérdidas de datos esporádicas e irreproducibles, de las peores de diagnosticar. anadir()vacía antes de añadir, no después. Así el búfer nunca desborda y el orden de las operaciones es siempre correcto.- El
vaciar()final es imprescindible. Sin él se perderían las últimas 150 lecturas (1.000 − 5 × 170 = 150), porque quedarían en el búfer sin escribir. Es el equivalente exacto defflush(), y olvidarlo es el error más frecuente al implementar buffering a mano.
Comparación de llamadas al sistema:
| Versión | Llamadas write |
Coste a 5 µs |
|---|---|---|
| Ingenua (una por lectura) | 1.000 | 5.000 µs = 5 ms |
| Con búfer de 170 | 6 (5 completos + 1 final) | 30 µs |
Reducción de un factor de 167. Y fíjate en un detalle que refuerza lo visto en la lección: el número de llamadas al sistema no depende de cuántos datos escribas, sino de cuántas veces cruces la frontera. Escribir 4.080 bytes cuesta prácticamente lo mismo que escribir 24, porque el sobrecoste dominante es el cambio de modo, no la copia de los datos.
Añadido opcional para robustez: si estas lecturas fueran críticas, tras vaciar() habría que llamar a fsync(b.fd) para forzar el volcado al disco físico. Cuesta del orden de milisegundos, así que solo se justifica cuando la pérdida de datos ante un corte de corriente es inaceptable.
Conclusión
La protección de un sistema operativo no descansa en su código sino en el hardware: el bit de modo de la CPU hace que las instrucciones privilegiadas simplemente no se ejecuten en modo usuario, y el paso a modo núcleo solo puede ocurrir por interrupción, excepción o llamada al sistema, siempre saltando a una dirección que el núcleo fijó de antemano. De los cuatro anillos de privilegio de x86, en la práctica solo se usan el 0 y el 3.
Una llamada al sistema es la única puerta legítima a través de esa frontera, y hemos recorrido su mecánica completa: número de llamada en rax, argumentos en registros, la instrucción syscall, el cambio a la pila del núcleo, la validación de argumentos, el despacho por tabla, el retorno con sysret y la traducción del valor negativo a errno que hace la biblioteca C. También has visto por qué API, biblioteca C y llamada al sistema son tres cosas distintas: printf no es una llamada al sistema, fopen no es open, y strace es la forma de comprobar qué cruza de verdad.
Y sobre todo te llevas una idea con consecuencias prácticas diarias: cruzar la frontera cuesta, entre 50 nanosegundos y varios microsegundos, y ese coste no depende de cuántos datos muevas. De ahí que el buffering no sea un adorno sino una decisión de diseño que cambia rendimiento por durabilidad, y que en ingestor reduciría las llamadas al sistema en un factor de 170. Saber dónde ponerlo —y dónde no ponerlo nunca, como en un registro de auditoría— es una de esas decisiones que separan el código que funciona del código que aguanta en producción.
Con esto cierras el módulo 1. Sabes qué es un sistema operativo, de dónde viene, de qué tipos los hay, qué funciones cumple, cómo se organiza su núcleo por dentro y cómo se cruza la frontera que lo protege. A partir de aquí dejamos de mirar el sistema desde fuera y empezamos a abrirlo. En el Módulo 2: Gestión de Recursos entraremos en el primero y más fundamental de sus trabajos: Gestión de Procesos, donde verás qué hay exactamente dentro de esa abstracción que hemos usado en cada lección sin abrirla nunca, cómo nace un proceso con fork y execve, qué estados atraviesa y qué ocurre en un cambio de contexto. Los tres procesos de Meteora dejarán de ser nombres en una lista de ps para convertirse en estructuras que sabrás leer.
Fundamentos de Sistemas Operativos
Módulo 1: Introducción a los Sistemas Operativos
- Conceptos Básicos de Sistemas Operativos
- Historia y Evolución de los Sistemas Operativos
- Tipos de Sistemas Operativos
- Funciones Principales de un Sistema Operativo
- Arquitectura del Núcleo: Monolítico, Microkernel e Híbrido
- Modo Usuario, Modo Núcleo y Llamadas al Sistema
Módulo 2: Gestión de Recursos
- Gestión de Procesos
- Planificación de la CPU
- Gestión de Memoria
- Memoria Virtual y Paginación
- Gestión de Almacenamiento
- Gestión de Dispositivos
- Controladores, Interrupciones y Operaciones de E/S
Módulo 3: Concurrencia
- Conceptos de Concurrencia
- Hilos y Procesos
- Comunicación entre Procesos (IPC)
- Sincronización y Exclusión Mutua
- Problemas Clásicos de Concurrencia
- Interbloqueos: Prevención, Detección y Recuperación
Módulo 4: Estructuras de Archivos
- Sistemas de Archivos
- Estructuras de Directorios
- Particiones, Montaje y Sistema de Archivos Virtual
- Gestión de Archivos
- Asignación de Espacio, Journaling e Integridad
- Seguridad y Permisos de Archivos
Módulo 5: Protección y Seguridad del Sistema
- Principios de Protección y Control de Acceso
- Usuarios, Autenticación y Escalada de Privilegios
- Amenazas Comunes y Endurecimiento del Sistema
- Auditoría, Registros y Respuesta a Incidentes
Módulo 6: Virtualización y Contenedores
- Virtualización: Hipervisores y Máquinas Virtuales
- Contenedores: Namespaces y cgroups
- El Sistema Operativo en la Nube
- Sistemas Operativos Móviles y de Tiempo Real
