Cerramos la lección anterior con tres cajas sin abrir y un problema planteado con números. Las cajas: qué hace exactamente la CPU cuando llega una interrupción, cómo un controlador se registra ante el núcleo y qué contrato cumple, y cómo funciona el DMA por dentro. El problema: a 500.000 lecturas por segundo, un núcleo entero se dedicaría solo a atender interrupciones, y eso no puede ser la solución final.
Esta lección abre las tres cajas y resuelve el problema. Vas a ver el recorrido completo de una interrupción desde la línea IRQ hasta el manejador, entender por qué el código de un manejador no puede dormir ni bloquearse, y por qué esa restricción obliga a partir el trabajo en dos mitades. Aprenderás a leer /proc/interrupts línea a línea, que es una de las salidas más informativas del sistema. Y seguirás el camino completo de un paquete con lecturas de estación, esta vez sin dejar ninguna capa sin explicar.
Es la última lección del módulo 2, así que al final recapitularemos las siete y enlazaremos con el módulo 3.
Contenido
- Qué es un controlador de dispositivo
- El contrato con el núcleo: la interfaz de operaciones de fichero
- Registro del driver y módulos cargables
- Espacio de núcleo frente a espacio de usuario para drivers
- Interrupciones: línea IRQ, vector y tabla de vectores
- El controlador de interrupciones: del PIC al APIC
- Qué hace la CPU al recibir una interrupción
- El contexto de interrupción y sus reglas
- Enmascaramiento e interrupciones compartidas
- Mitad superior y mitad inferior
/proc/interruptsinterpretado línea a línea- MSI y MSI-X
- DMA en detalle: descriptores, coherencia e IOMMU
- El camino completo de un paquete con lecturas
- Latencia de interrupción y su impacto
- Cierre del módulo 2
Qué es un controlador de dispositivo
Un controlador de dispositivo (device driver) es el código que traduce las operaciones genéricas del núcleo en las operaciones concretas de un modelo de hardware.
Conviene distinguir dos cosas que en castellano comparten nombre y se confunden constantemente:
| Controlador de dispositivo (driver) | Controlador del dispositivo (controller) | |
|---|---|---|
| Qué es | Software dentro del núcleo | Hardware: el chip de la tarjeta |
| Dónde vive | En RAM, como parte del núcleo | En la propia tarjeta |
| Ejemplo | El módulo igb |
El chip Intel I210 |
En esta lección, cuando digamos driver nos referimos al software; cuando digamos controlador del dispositivo o chip, al hardware.
El driver es la única pieza del sistema que conoce los detalles sucios: qué registro hay que escribir para iniciar una transferencia, en qué bit está la señal de "listo", cuántos microsegundos hay que esperar tras un reinicio, qué errata de silicio tiene la revisión B2 del chip.
$ lsmod | head -8 Module Size Used by igb 270336 0 nvme 49152 4 nvme_core 143360 5 nvme raid1 49152 1 md_mod 176128 2 raid1 ext4 942080 2 xhci_pci 24576 0
Y su magnitud en el núcleo de Linux es reveladora:
$ du -sh /lib/modules/$(uname -r)/kernel/drivers/ 198M /lib/modules/6.1.0-13-amd64/kernel/drivers/ $ du -sh /lib/modules/$(uname -r)/kernel/ 312M /lib/modules/6.1.0-13-amd64/kernel/
Los drivers son el 63 % del código del núcleo. Y en el árbol de fuentes de Linux la proporción es similar: más de la mitad de los millones de líneas son controladores de dispositivo. El núcleo propiamente dicho —planificador, memoria, sistemas de archivos, red— es la parte pequeña.
El contrato con el núcleo: la interfaz de operaciones de fichero
Un driver no puede hacer lo que quiera: debe cumplir un contrato con el núcleo. Ese contrato es una estructura de punteros a función. Para un dispositivo de carácter:
#include <linux/fs.h>
static const struct file_operations meteora_fops = {
.owner = THIS_MODULE,
.open = meteora_open,
.release = meteora_release,
.read = meteora_read,
.write = meteora_write,
.unlocked_ioctl = meteora_ioctl,
.poll = meteora_poll,
.llseek = no_llseek,
};Cómo funciona el mecanismo, que es el mismo despacho por tabla que vimos en 01-06 con las llamadas al sistema:
- Cuando un proceso llama a
read()sobre/dev/meteora/estacion-norte, el núcleo recorre el camino que ya conoces:syscall→ tabla de llamadas →sys_read→ capa VFS. - La capa VFS consulta el número mayor del fichero, encuentra el driver registrado y llama al puntero
.readde sufile_operations. - Ese puntero apunta a
meteora_read, código específico de este dispositivo.
Es polimorfismo implementado con punteros a función en C: la misma llamada read() acaba ejecutando código distinto según el dispositivo, sin que el código que llama sepa nada.
Los métodos habituales del contrato:
| Método | Cuándo lo llama el núcleo | Qué debe hacer |
|---|---|---|
.open |
Al abrir el fichero de dispositivo | Reservar recursos, comprobar disponibilidad |
.release |
Al cerrar el último descriptor | Liberar recursos |
.read |
En un read() |
Copiar datos al espacio de usuario |
.write |
En un write() |
Copiar datos desde el espacio de usuario |
.unlocked_ioctl |
En un ioctl() |
Operaciones que no encajan en leer/escribir |
.poll |
En select/poll/epoll |
Indicar si hay datos disponibles |
.mmap |
En un mmap() |
Mapear memoria del dispositivo al proceso |
ioctl merece un comentario. Es la vía de escape del modelo "todo es un fichero": permite operaciones que no son leer ni escribir. Expulsar un CD, configurar la velocidad de un puerto serie, consultar estadísticas de una tarjeta. Que exista demuestra los límites de la abstracción de fichero, exactamente como los sockets demostraban los límites por otro lado. ioctl es el reconocimiento pragmático de que no todo cabe en read/write.
Un esqueleto de driver simplificado:
/* meteora_drv.c — esqueleto ilustrativo, no compilable tal cual */
#include <linux/module.h>
#include <linux/fs.h>
#include <linux/uaccess.h>
#define METEORA_MAYOR 0 /* 0 = que el núcleo asigne uno libre */
static int mayor_asignado;
static ssize_t meteora_read(struct file *f, char __user *buf,
size_t count, loff_t *pos)
{
char datos[24];
int leidos;
/* 1. Leer del hardware (registros MMIO) */
leidos = leer_del_dispositivo(datos, sizeof datos);
if (leidos < 0)
return -EIO; /* se convertirá en errno = EIO */
if (count > (size_t)leidos)
count = leidos;
/* 2. Copiar al espacio de usuario: NUNCA con memcpy */
if (copy_to_user(buf, datos, count))
return -EFAULT;
return count; /* bytes leídos */
}
static int __init meteora_init(void)
{
mayor_asignado = register_chrdev(METEORA_MAYOR, "meteora", &meteora_fops);
if (mayor_asignado < 0) {
pr_err("meteora: no se pudo registrar el driver\n");
return mayor_asignado;
}
pr_info("meteora: registrado con mayor %d\n", mayor_asignado);
return 0;
}
static void __exit meteora_exit(void)
{
unregister_chrdev(mayor_asignado, "meteora");
pr_info("meteora: descargado\n");
}
module_init(meteora_init);
module_exit(meteora_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("Driver de ejemplo para estaciones Meteora");Puntos que hay que entender de este código:
copy_to_user()en lugar dememcpy(). Es obligatorio y no es una formalidad. El punterobufviene del espacio de usuario y no es de fiar: podría apuntar a memoria del núcleo, a una dirección no mapeada o a memoria de otro proceso.copy_to_uservalida el rango, gestiona el fallo de página si la página no está presente, y devuelve el número de bytes que no pudo copiar. Unmemcpydirecto sería una vulnerabilidad de escalada de privilegios de manual. La marca__useren el prototipo permite además que herramientas de análisis estático detecten el error.- Devolver
-EIOy-EFAULT, en negativo. Ese es exactamente el convenio que rastreamos en 01-06: el núcleo devuelve el error como un valor negativo pequeño, y la biblioteca C lo convierte en-1máserrno. register_chrdevcon mayor 0 pide al núcleo que asigne un número libre. Fijar un mayor a mano solo funciona para los números reservados oficialmente.module_initymodule_exitdefinen los puntos de entrada y salida del módulo, que es lo que hace posible cargarlo y descargarlo en caliente.
Registro del driver y módulos cargables
Retomamos aquí lo de 01-05: los módulos cargables permiten añadir código al núcleo en ejecución.
$ modinfo igb filename: /lib/modules/6.1.0-13-amd64/kernel/drivers/net/ethernet/intel/igb/igb.ko version: 5.6.0-k license: GPL v2 description: Intel(R) Gigabit Ethernet Network Driver alias: pci:v00008086d00001521sv*sd*bc*sc*i* depends: i2c-algo-bit,dca parm: max_vfs:Maximum number of virtual functions (uint)
La línea alias es la clave de la carga automática: dice que este módulo gestiona el dispositivo PCI con fabricante 8086 (Intel) y dispositivo 1521 (I350). Cuando el bus PCI enumera una tarjeta con esos identificadores, udev busca un módulo con el alias correspondiente y lo carga sin intervención humana.
$ lspci -nn | grep -i ethernet 03:00.0 Ethernet controller [0200]: Intel Corporation I210 [8086:1533] (rev 03) $ sudo modprobe -c | grep 8086.*1533 alias pci:v00008086d00001533sv*sd*bc*sc*i* igb
Ahí está la cadena completa: el identificador del hardware conduce al nombre del módulo.
# Carga y descarga manuales $ sudo modprobe igb $ sudo modprobe -r igb # descarga (falla si está en uso) $ lsmod | grep igb igb 270336 0 ← el 0 es el contador de uso # Ver los parámetros configurables $ ls /sys/module/igb/parameters/ max_vfs
Ventajas de los módulos, retomando la discusión de arquitectura del núcleo de 01-05:
| Ventaja | Detalle |
|---|---|
| Núcleo pequeño | Solo se carga lo que hay presente |
| Actualización sin reiniciar | Cambiar un driver defectuoso en caliente |
| Desarrollo ágil | Compilar y probar sin reiniciar la máquina |
| Hardware dinámico | USB conectado en caliente |
Y una limitación importante: un módulo se ejecuta con todos los privilegios del núcleo. No hay aislamiento. Un módulo con un fallo puede corromper cualquier estructura del sistema.
Espacio de núcleo frente a espacio de usuario para drivers
Aquí retomamos y cerramos el debate de arquitecturas de 01-05, ahora con datos concretos.
| Driver en el núcleo | Driver en espacio de usuario | |
|---|---|---|
| Privilegios | Anillo 0, acceso total | Anillo 3, restringido |
| Un fallo provoca | Kernel panic o corrupción | Muere un proceso |
| Rendimiento | Máximo, sin cambios de contexto | Cambios de contexto e IPC |
| Depuración | Difícil (printk, kgdb, volcados) |
Con gdb normal |
| Ejemplos | Casi todos los de Linux | FUSE, CUPS, SPDK, DPDK |
Por qué un driver defectuoso es peligroso. Un driver corre en anillo 0, así que:
- Puede escribir en cualquier dirección de memoria física, incluidas las estructuras del núcleo y la memoria de cualquier proceso.
- Puede ejecutar instrucciones privilegiadas: deshabilitar interrupciones, cambiar la tabla de páginas, reprogramar la MMU.
- No hay MMU que lo proteja de sí mismo: los mecanismos de protección que estudiamos en 02-03 y 02-04 protegen los procesos entre sí, pero el núcleo está por encima de ellos.
Un puntero nulo en un driver:
$ sudo dmesg -T | tail -12 [Sun Aug 31 11:02:44 2026] BUG: kernel NULL pointer dereference, address: 0000000000000018 [Sun Aug 31 11:02:44 2026] #PF: supervisor read access in kernel mode [Sun Aug 31 11:02:44 2026] Oops: 0000 [#1] PREEMPT SMP NOPTI [Sun Aug 31 11:02:44 2026] CPU: 2 PID: 0 Comm: swapper/2 Tainted: G O 6.1.0-13-amd64 [Sun Aug 31 11:02:44 2026] RIP: 0010:meteora_interrupt_handler+0x2a/0x120 [meteora_drv] [Sun Aug 31 11:02:44 2026] Call Trace: [Sun Aug 31 11:02:44 2026] __handle_irq_event_percpu+0x46/0x180 [Sun Aug 31 11:02:44 2026] handle_irq_event+0x38/0x80 [Sun Aug 31 11:02:44 2026] Kernel panic - not syncing: Fatal exception in interrupt
Cómo se lee esta traza:
supervisor read access in kernel mode: el fallo ocurrió en anillo 0. Si hubiera sido en usuario, sería un simpleSIGSEGV.RIP: meteora_interrupt_handler+0x2a [meteora_drv]: la dirección exacta donde falló, con el módulo culpable identificado entre corchetes.Tainted: G O: el núcleo está "contaminado" por un módulo externo. Los desarrolladores del núcleo usan esta marca para saber que el problema puede no ser suyo.Fatal exception in interrupt: y aquí está lo grave. El fallo ocurrió dentro de un manejador de interrupción, donde el núcleo no puede simplemente matar el proceso responsable (no hay proceso:Comm: swapper/2es el hilo ocioso). No hay recuperación posible: kernel panic.
Compara con el mismo error en meteo-api: muere un proceso, systemd lo reinicia, se pierden unas peticiones. Aquí muere la máquina entera.
Drivers en espacio de usuario. La alternativa existe y se usa en casos concretos:
| Tecnología | Para qué | Por qué en usuario |
|---|---|---|
| FUSE | Sistemas de archivos (sshfs, s3fs) | Un bug no tumba el sistema |
| CUPS | Impresión | Complejidad enorme, rendimiento irrelevante |
| libusb | Dispositivos USB específicos | No hace falta un módulo por cacharro |
| DPDK / SPDK | Red y almacenamiento de altísimo rendimiento | Evita el núcleo por completo |
El caso de DPDK es paradójico y muy instructivo: mueve el driver a espacio de usuario no por seguridad, sino por rendimiento. Mapea los registros de la tarjeta directamente en el proceso y hace polling continuo, eliminando interrupciones y llamadas al sistema. Consigue procesar millones de paquetes por segundo a costa de dedicar núcleos completos a girar en un bucle. Es exactamente la espera activa de la lección anterior, elegida deliberadamente porque a esas tasas resulta más barata que interrumpir.
Interrupciones: línea IRQ, vector y tabla de vectores
Una interrupción es una señal del hardware que hace que la CPU suspenda lo que está haciendo y ejecute un código predeterminado.
El vocabulario, que hay que tener claro:
| Término | Qué es |
|---|---|
| Línea IRQ | La conexión física (o lógica) por la que el dispositivo avisa |
| Vector | El número que identifica qué manejador ejecutar (0-255 en x86) |
| IDT | Interrupt Descriptor Table: la tabla de 256 entradas con las direcciones de los manejadores |
| ISR | Interrupt Service Routine: el código del manejador |
En x86-64 hay 256 vectores, repartidos así:
| Vectores | Uso |
|---|---|
| 0-31 | Excepciones de la CPU (división por cero, fallo de página, etc.) |
| 32-47 | IRQ heredadas del PIC (teclado, temporizador, disco) |
| 48-238 | Interrupciones de dispositivos, MSI/MSI-X |
| 239-255 | Interrupciones entre procesadores (IPI), temporizador local del APIC |
Algunos vectores de excepción que ya conoces por lecciones anteriores:
| Vector | Excepción | Dónde apareció |
|---|---|---|
| 0 | División por cero | — |
| 6 | Instrucción inválida | — |
| 13 | Fallo de protección general | 01-06, la instrucción cli en usuario |
| 14 | Fallo de página | 02-04, todo el mecanismo de memoria virtual |
Y aquí conviene fijar una clasificación que se confunde a menudo:
| Tipo | Origen | Síncrona | Ejemplo |
|---|---|---|---|
| Interrupción | Hardware externo | No | Llega un paquete de red |
| Excepción / trampa | La propia CPU al ejecutar | Sí | Fallo de página, división por cero |
| Llamada al sistema | Instrucción syscall deliberada |
Sí | read() del ingestor |
La diferencia clave es la sincronía. Una excepción ocurre siempre en el mismo punto si repites el programa con los mismos datos: es consecuencia de la instrucción que se estaba ejecutando. Una interrupción llega cuando le da la gana al mundo exterior y puede caer entre dos instrucciones cualesquiera. Esa asincronía es la fuente de toda la dificultad del código de interrupciones.
La IDT vive en memoria y su dirección está en el registro IDTR, cargado con la instrucción privilegiada lidt que vimos en la tabla de 01-06. Cada entrada contiene la dirección del manejador, el segmento y los permisos.
El controlador de interrupciones: del PIC al APIC
La CPU tiene muy pocas patillas de interrupción, así que hace falta un chip intermediario que multiplexe las líneas de todos los dispositivos.
PIC 8259A (1976): dos chips encadenados, 15 líneas IRQ útiles.
| IRQ | Dispositivo clásico |
|---|---|
| 0 | Temporizador del sistema |
| 1 | Teclado |
| 3 | COM2 |
| 4 | COM1 |
| 8 | Reloj de tiempo real |
| 14 | Disco IDE primario |
Esa asignación de 1981 sigue reconociéndose hoy en /proc/interrupts.
APIC (Advanced Programmable Interrupt Controller) lo sustituyó, y sus mejoras son las que hacen viable un servidor multiprocesador:
| PIC 8259A | APIC | |
|---|---|---|
| Líneas | 15 | 24 por I/O APIC, varios por sistema |
| Multiprocesador | No | Sí: dirige la interrupción a una CPU concreta |
| Prioridades | Fijas por número | Programables |
| Reparto de carga | No | Sí, entre CPU |
| MSI | No | Sí |
El APIC tiene dos partes:
- Local APIC: uno por núcleo, integrado en la CPU. Recibe las interrupciones dirigidas a ese núcleo y gestiona el temporizador local y las interrupciones entre procesadores.
- I/O APIC: en el chipset. Recibe las líneas de los dispositivos y las encamina al Local APIC del núcleo que corresponda.
Que el APIC pueda dirigir una interrupción a un núcleo concreto es lo que permite el smp_affinity que veremos al hablar de /proc/interrupts, y lo que hace posible que una tarjeta con varias colas reparta su trabajo entre núcleos.
Qué hace la CPU al recibir una interrupción
Este es el recorrido exacto, y conviene compararlo mentalmente con el de la llamada al sistema de 01-06:
sequenceDiagram
participant D as Dispositivo
participant A as APIC
participant C as CPU
participant K as Núcleo
D->>A: activa su línea IRQ
A->>A: prioriza y elige el núcleo destino
A->>C: señal de interrupción + vector
Note over C: termina la instrucción en curso
C->>C: guarda RIP, RSP, RFLAGS y CS en la pila de núcleo
C->>C: cambia a anillo 0 y a la pila de núcleo
C->>C: deshabilita interrupciones (según la puerta de la IDT)
C->>C: consulta la IDT[vector]
C->>K: salta al manejador
K->>K: guarda el resto de registros
K->>K: ejecuta el manejador del driver
K->>A: envía EOI (fin de interrupción)
K->>K: restaura registros
K->>C: iret
Note over C: continúa la instrucción siguiente
Los detalles que importan:
- La instrucción en curso se termina. La CPU no interrumpe a mitad de una instrucción (con contadas excepciones para instrucciones muy largas, que son reanudables). Esto garantiza un estado consistente.
- Se guarda el estado mínimo automáticamente: puntero de instrucción, puntero de pila, registro de banderas y selector de segmento. Lo hace el hardware, no el software.
- Se cambia a la pila de núcleo, exactamente como en un
syscall. El manejador nunca usa la pila del proceso interrumpido. - Las interrupciones se deshabilitan si la entrada de la IDT es una interrupt gate (lo habitual). Esto evita que otra interrupción anide inmediatamente.
- El EOI es obligatorio. Si el manejador no envía la señal de fin de interrupción al APIC, ese dispositivo no volverá a interrumpir jamás. Es uno de los errores clásicos al escribir drivers, y su síntoma es que el dispositivo funciona exactamente una vez y luego se queda mudo.
Comparación con lo que ya sabes:
| Llamada al sistema (01-06) | Interrupción | |
|---|---|---|
| Quién la inicia | El proceso, con syscall |
El hardware |
| Cuándo ocurre | En un punto determinista | En cualquier momento |
| Cambia de proceso | No | No (pero puede provocar que se cambie después) |
| Cambia de pila | Sí, a la de núcleo | Sí, a la de núcleo |
| Contexto | Del proceso que llamó | De nadie en particular |
| Coste | 50-500 ns | 1-5 µs |
La fila crucial es la penúltima: una llamada al sistema se ejecuta en nombre de un proceso concreto, mientras que una interrupción se ejecuta en el contexto de quien tocara estar ejecutándose, que puede ser cualquiera. De ahí salen todas las restricciones del apartado siguiente.
El contexto de interrupción y sus reglas
Un manejador de interrupción se ejecuta en contexto de interrupción, también llamado contexto atómico. Es un entorno con reglas muy estrictas:
| Regla | Por qué |
|---|---|
| No puede dormir ni bloquearse | No hay proceso al que devolver la CPU: no hay task_struct propio que poner en estado S |
| No puede llamar a funciones que puedan dormir | kmalloc(GFP_KERNEL), mutex_lock, copy_to_user |
| No puede acceder al espacio de usuario | Podría provocar un fallo de página, que implicaría dormir |
| Debe ser muy rápido | Con las interrupciones deshabilitadas, todo lo demás espera |
| Debe usar cerrojos especiales | spin_lock_irqsave, nunca un mutex |
Comprender el porqué de la primera regla es la clave de todo lo demás. Cuando el ingestor llama a read() y no hay datos, el núcleo lo pone en estado S y planifica otro proceso: hay un task_struct al que aparcar. Pero un manejador de interrupción no pertenece a ningún proceso. Si se durmiera, ¿a quién se pondría en estado S? ¿Al proceso que casualmente estaba ejecutándose, que no tiene nada que ver? ¿Y quién lo despertaría?
Recuerda la traza del kernel panic: Comm: swapper/2. El manejador se ejecutó "sobre" el hilo ocioso, que era lo que había en ese núcleo. No era su interrupción ni su trabajo.
/* MAL: esto cuelga el sistema */
static irqreturn_t manejador_malo(int irq, void *dev)
{
char *buf = kmalloc(4096, GFP_KERNEL); /* ¡puede dormir! */
mutex_lock(&mi_mutex); /* ¡puede dormir! */
copy_to_user(destino, datos, 24); /* ¡puede fallar la página! */
msleep(10); /* ¡duerme explícitamente! */
return IRQ_HANDLED;
}
/* BIEN */
static irqreturn_t manejador_bueno(int irq, void *dev)
{
struct meteora_dev *d = dev;
u32 estado;
unsigned long flags;
/* 1. ¿Es mía esta interrupción? (línea compartida) */
estado = readl(d->regs + REG_ESTADO);
if (!(estado & INT_PENDIENTE))
return IRQ_NONE; /* no es mía, que la vea otro */
/* 2. Reconocerla en el hardware: imprescindible */
writel(estado, d->regs + REG_ESTADO);
/* 3. Cerrojo de espera activa que deshabilita interrupciones */
spin_lock_irqsave(&d->lock, flags);
d->paquetes_pendientes++;
spin_unlock_irqrestore(&d->lock, flags);
/* 4. Delegar el trabajo pesado a la mitad inferior */
napi_schedule(&d->napi);
return IRQ_HANDLED;
}Las cuatro decisiones del manejador correcto:
- Comprobar si la interrupción es suya antes de nada, porque la línea puede estar compartida. Devolver
IRQ_NONEpermite que el núcleo pruebe con el siguiente driver registrado. - Reconocerla en el hardware escribiendo en el registro de estado. Sin esto, el dispositivo seguiría afirmando la línea y se generaría una tormenta de interrupciones infinita.
spin_lock_irqsaveen lugar demutex_lock. Un mutex duerme si está ocupado; un spinlock gira esperando, que es lo único que puede hacerse en contexto atómico. La varianteirqsaveguarda además el estado de las interrupciones y las deshabilita, evitando el interbloqueo consigo mismo si la misma interrupción se repitiera.- Delegar todo el trabajo real a la mitad inferior. Solo se ha incrementado un contador y programado trabajo diferido.
Enmascaramiento e interrupciones compartidas
Enmascaramiento es deshabilitar temporalmente una interrupción, y hay tres niveles:
| Nivel | Cómo | Alcance |
|---|---|---|
| Global en la CPU | cli / sti |
Todas las interrupciones enmascarables |
| Por línea, en el APIC | Registro de máscara | Una línea concreta |
| Con guardado de estado | spin_lock_irqsave |
Global, restaurando el estado previo |
cli es la instrucción privilegiada que probamos en 01-06 y que provocaba un SIGSEGV en modo usuario. Ahora entiendes por qué debe ser privilegiada con tanta rotundidad: un proceso que pudiera deshabilitar las interrupciones impediría que el temporizador se las quitara, monopolizando la CPU para siempre y rompiendo por completo la planificación apropiativa de 02-02.
Las NMI (Non-Maskable Interrupt) no se pueden enmascarar bajo ningún concepto. Se reservan para fallos catastróficos: error de paridad de memoria, el temporizador de vigilancia, señales de fallo de hardware. Si el sistema deja de responder por completo, la NMI del watchdog es la que fuerza un volcado de diagnóstico.
Interrupciones compartidas. Con pocas líneas IRQ y muchos dispositivos, varios pueden compartir línea:
$ cat /proc/interrupts | grep -E '^ *(16|17|18):' 16: 1204 0 0 0 IO-APIC 16-fasteoi ehci_hcd:usb1, i801_smbus 17: 28841 0 0 0 IO-APIC 17-fasteoi snd_hda_intel
La IRQ 16 la comparten el controlador USB y el bus SMBus. Cuando se activa, el núcleo no sabe cuál de los dos ha sido, así que llama a los manejadores de todos los drivers registrados en esa línea, en orden, hasta que uno devuelve IRQ_HANDLED.
De ahí la importancia de la comprobación del apartado anterior: un driver que devolviera IRQ_HANDLED sin comprobar si la interrupción era suya robaría las interrupciones del otro dispositivo, que dejaría de funcionar con un síntoma imposible de diagnosticar.
/* Registrar un manejador compartido */
ret = request_irq(irq, manejador_bueno,
IRQF_SHARED, /* la línea puede compartirse */
"meteora", /* nombre en /proc/interrupts */
dev); /* se pasa al manejador para identificarlo */El último argumento es esencial en líneas compartidas: es lo que permite que el manejador sepa a qué instancia de dispositivo corresponde la llamada.
Mitad superior y mitad inferior
Aquí está la idea central del diseño de interrupciones en Linux, y resuelve una tensión real.
La tensión: un manejador de interrupción debe ser rapidísimo, porque se ejecuta con interrupciones deshabilitadas y bloquea todo lo demás. Pero el trabajo que genera una interrupción puede ser considerable: procesar un paquete de red implica recorrer la pila IP, buscar el socket, actualizar estadísticas, despertar procesos.
La solución: partirlo en dos.
| Mitad superior (top half) | Mitad inferior (bottom half) | |
|---|---|---|
| Cuándo | Inmediatamente, en contexto de interrupción | Diferida, poco después |
| Interrupciones | Deshabilitadas (al menos la suya) | Habilitadas |
| Puede dormir | No | Según el mecanismo |
| Duración | Microsegundos | Puede ser larga |
| Qué hace | Reconocer el hardware, guardar datos, programar la inferior | El procesamiento real |
Los tres mecanismos de mitad inferior en Linux:
| Mecanismo | Contexto | ¿Puede dormir? | Concurrencia | Uso |
|---|---|---|---|---|
| softirq | Atómico | No | En paralelo en varias CPU | Red, bloque, temporizadores |
| tasklet | Atómico | No | Solo una instancia a la vez | Drivers sencillos |
| workqueue | Proceso | Sí | Hilos del núcleo | Trabajo que necesita dormir |
- softirq: la más rápida y la única que escala. Hay un número fijo definido en tiempo de compilación, así que solo la usan los subsistemas principales del núcleo. La misma softirq puede ejecutarse simultáneamente en varias CPU, lo que exige que su código sea reentrante.
- tasklet: construida sobre softirq, más fácil de usar. Una tasklet concreta nunca se ejecuta en dos CPU a la vez, lo que simplifica la sincronización a costa de escalabilidad.
- workqueue: ejecuta en un hilo del núcleo, es decir, en contexto de proceso. Es la única que puede dormir, así que es la opción cuando hace falta reservar memoria con
GFP_KERNEL, esperar un mutex o hacer E/S.
$ cat /proc/softirqs
CPU0 CPU1 CPU2 CPU3
HI: 0 0 0 0
TIMER: 284102 271884 269110 265882
NET_TX: 12841 1102 884 791
NET_RX: 1284102 42881 38104 36992
BLOCK: 102841 98221 94102 91884
TASKLET: 2841 1102 884 702
SCHED: 284102 198221 194102 191884
RCU: 98221 94102 91884 89102Lectura de esta salida:
NET_RXes 30 veces mayor en CPU0 (1.284.102 frente a ~40.000). Todo el tráfico de red entrante se procesa en un solo núcleo. Con una tarjeta de una sola cola es lo esperable; con una multicola, indica que la afinidad de interrupciones no está bien repartida.TIMERestá repartido uniformemente, como debe ser: cada núcleo tiene su temporizador local del APIC.BLOCKson las interrupciones de dispositivos de bloque completadas, correlacionadas con la actividad del RAID que vimos en 02-05.
El ejemplo de la tarjeta de red
/* MITAD SUPERIOR: microsegundos, en contexto de interrupción */
static irqreturn_t igb_msix_ring(int irq, void *data)
{
struct igb_q_vector *q_vector = data;
igb_write_itr(q_vector); /* ajustar la moderación */
napi_schedule(&q_vector->napi); /* programar la mitad inferior */
return IRQ_HANDLED;
}
/* MITAD INFERIOR: softirq NET_RX, con interrupciones habilitadas */
static int igb_poll(struct napi_struct *napi, int budget)
{
/* Procesar hasta 'budget' paquetes (típicamente 64) */
clean_complete = igb_clean_rx_irq(q_vector, budget);
if (!clean_complete)
return budget; /* quedan más: seguiré en polling */
napi_complete_done(napi, work_done);
igb_ring_irq_enable(q_vector); /* rehabilitar interrupciones */
return work_done;
}El reparto es claro: la mitad superior tarda menos de 1 µs y solo programa trabajo. La mitad inferior procesa hasta 64 paquetes recorriendo toda la pila de red, y puede tardar decenas de microsegundos, pero con las interrupciones habilitadas, así que no bloquea nada.
NAPI: la solución a la tormenta de interrupciones
Aquí resolvemos el problema que dejamos planteado. La clave está en igb_ring_irq_enable: mientras hay paquetes que procesar, las interrupciones de esa cola están deshabilitadas y el núcleo consulta activamente (polling).
Carga baja (800 paquetes/s): Llega un paquete → interrupción → NAPI procesa 1 → no hay más → rehabilita interrupciones → vuelve a modo interrupción Resultado: ~800 interrupciones/s. Latencia mínima. Carga alta (500.000 paquetes/s): Llega un paquete → interrupción → NAPI deshabilita interrupciones → procesa 64 → aún hay más → procesa 64 más → ... → cuando la cola se vacía, rehabilita interrupciones Resultado: unas pocas miles de interrupciones/s en vez de 500.000
NAPI es un híbrido adaptativo: interrupciones con carga baja (latencia mínima) y polling con carga alta (rendimiento máximo). Cambia de modo solo, sin configuración.
Los números para Meteora:
Situación actual (800 lecturas/s): Modo interrupción, ~800 interrupciones/s 800 × 3 µs = 2,4 ms/s = 0,24 % de un núcleo Con 500.000 lecturas/s SIN NAPI: 500.000 × 3 µs = 1,5 s de CPU por segundo → más de un núcleo entero Con 500.000 lecturas/s CON NAPI: Procesa lotes de 64 en polling ~7.800 ciclos de polling/s × 3 µs ≈ 23 ms/s = 2,3 % de un núcleo Reducción: 65×
Es una de las soluciones más elegantes del núcleo de Linux: reconocer que ninguna de las dos técnicas de la lección anterior es siempre mejor, y cambiar entre ellas según la carga.
/proc/interrupts interpretado línea a línea
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 41 0 0 0 IO-APIC 2-edge timer
1: 1204 0 0 0 IO-APIC 1-edge i8042
8: 1 0 0 0 IO-APIC 8-edge rtc0
9: 0 0 0 0 IO-APIC 9-fasteoi acpi
16: 28841 0 0 0 IO-APIC 16-fasteoi ehci_hcd:usb1, i801_smbus
128: 0 0 0 0 PCI-MSI 1572864-edge enp3s0
129: 1284102 0 0 0 PCI-MSI 1572865-edge enp3s0-rx-0
130: 0 284102 0 0 PCI-MSI 1572866-edge enp3s0-tx-0
131: 98221 94102 91884 89102 PCI-MSI 524288-edge nvme0q0
132: 284102 271884 269110 265882 PCI-MSI 524289-edge nvme0q1
NMI: 0 0 0 0 Non-maskable interrupts
LOC: 2841022 2718840 2691100 2658820 Local timer interrupts
RES: 28410 27188 26911 26588 Rescheduling interrupts
CAL: 1204 1102 884 791 Function call interrupts
TLB: 12841 11022 8840 7910 TLB shootdownsEstructura de una línea:
129: 1284102 0 0 0 PCI-MSI 1572865-edge enp3s0-rx-0 └┬─┘ └───────── cuenta por CPU ─────────┘ └─ tipo ─┘ └disparo┘ └── driver ──┘ IRQ
Análisis línea a línea de lo que dice este sistema:
| Línea | Qué revela |
|---|---|
0: timer |
Solo 41 activaciones. El temporizador heredado apenas se usa: Linux emplea el temporizador local del APIC (línea LOC) |
1: i8042 |
1.204 pulsaciones de teclado. En un servidor sin monitor, seguramente de la consola de gestión |
16: ehci_hcd:usb1, i801_smbus |
Interrupción compartida: dos drivers en la misma línea |
128-130: enp3s0 |
Tres vectores MSI-X para una sola tarjeta: uno de control, uno de recepción, uno de transmisión |
129: enp3s0-rx-0 |
1.284.102 interrupciones todas en CPU0. Toda la recepción de red en un núcleo |
131-132: nvme0q0, nvme0q1 |
Repartido uniformemente entre los 4 núcleos. Esto es NVMe funcionando como debe: una cola por núcleo |
LOC |
El temporizador que dispara la planificación. Uniforme, como debe ser |
RES |
Un núcleo pide a otro que replanifique. Ligado al balanceo de carga de 02-02 |
TLB |
Invalidaciones de TLB entre CPU: cuando un núcleo cambia una tabla de páginas, avisa a los demás para que invaliden sus entradas. Directamente relacionado con el coste del cambio de contexto de 02-01 y con la TLB de 02-04 |
El diagnóstico que salta a la vista: enp3s0-rx-0 concentra 1,28 millones de interrupciones en CPU0 mientras los otros tres núcleos están a cero. Con 800 lecturas/s no es problema, pero es el cuello de botella que aparecería al crecer.
La máscara 1 (binario 0001) significa "solo CPU0". Para repartir:
# Permitir que la use cualquier núcleo $ echo f | sudo tee /proc/irq/129/smp_affinity # O fijarla a un núcleo concreto que no sea el que usa el agregador $ echo 2 | sudo tee /proc/irq/129/smp_affinity # solo CPU1
Y ahí aparece una decisión interesante que enlaza con 02-02: en la lección de planificación confinamos el agregador a los núcleos 2 y 3 con taskset. Dirigir las interrupciones de red a CPU0 y CPU1 completa esa partición: el procesamiento de paquetes y el cálculo de medias dejan de competir tanto por CPU como por caché.
El demonio irqbalance hace este reparto automáticamente, pero en sistemas con requisitos de latencia estrictos suele desactivarse para fijar la afinidad a mano.
Observar la actividad en tiempo real:
Es la forma más rápida de comprobar si un dispositivo está generando interrupciones. Si un dispositivo no responde y su contador no aumenta, el problema está en el hardware o en el encaminamiento de la interrupción, no en el software de arriba.
MSI y MSI-X
Las interrupciones clásicas usan líneas físicas: una patilla dedicada del dispositivo al APIC. Eso tiene tres problemas:
- Escasez: hay pocas líneas, de ahí las compartidas.
- Condiciones de carrera: el dispositivo puede afirmar la línea antes de que los datos que ha escrito por DMA hayan llegado a la memoria. El manejador leería datos incompletos.
- Un solo vector por dispositivo: no se puede distinguir "he recibido un paquete" de "he terminado de transmitir".
MSI (Message Signaled Interrupts) elimina las líneas: el dispositivo escribe un valor en una dirección de memoria especial, y esa escritura es la interrupción.
| Línea IRQ | MSI | MSI-X | |
|---|---|---|---|
| Mecanismo | Patilla física | Escritura en memoria | Escritura en memoria |
| Vectores por dispositivo | 1 | Hasta 32 | Hasta 2.048 |
| Compartidas | Sí | No | No |
| Carrera con DMA | Posible | No | No |
| Destino por vector | Fijo | Uno para todos | Uno por vector |
Las dos ventajas decisivas:
Se elimina la carrera con el DMA. Como la interrupción es una escritura por el mismo bus PCIe que los datos, el ordenamiento del bus garantiza que los datos ya han llegado cuando llega la interrupción. La línea física iba por un camino distinto y podía adelantarse.
MSI-X permite un vector por cola y por núcleo. Eso es lo que hace posible el reparto perfecto del NVMe que veíamos arriba:
131: 98221 94102 91884 89102 PCI-MSI 524288-edge nvme0q0 132: 284102 271884 269110 265882 PCI-MSI 524289-edge nvme0q1
Recuerda de 02-05 que NVMe tiene hasta 65.535 colas y su ventaja es la paralelización. MSI-X es la pieza que la completa: cada cola tiene su vector de interrupción dirigido a su propio núcleo, así que no hay ni un cerrojo compartido ni una CPU que centralice el trabajo.
$ sudo lspci -v -s 03:00.0 | grep -A2 MSI-X
Capabilities: [70] MSI-X: Enable+ Count=5 Masked-
Vector table: BAR=3 offset=00000000Enable+ confirma que está activo, y Count=5 que la tarjeta tiene 5 vectores: control, dos colas de recepción y dos de transmisión.
DMA en detalle: descriptores, coherencia e IOMMU
El DMA transfiere datos entre dispositivo y memoria sin CPU. Aquí está cómo funciona por dentro.
Descriptores
Las tarjetas modernas no reciben una orden por transferencia: trabajan con anillos de descriptores, estructuras en memoria que la CPU rellena y el dispositivo consume.
/* Descriptor de recepción simplificado, estilo Intel */
struct rx_descriptor {
u64 direccion_buffer; /* dirección FÍSICA donde escribir */
u16 longitud; /* bytes recibidos (lo rellena la tarjeta) */
u16 checksum;
u8 estado; /* bit DD: Descriptor Done */
u8 errores;
u16 vlan;
};El ciclo de vida:
Anillo de 256 descriptores en RAM
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ D0 │ D1 │ D2 │ D3 │ ... │ D255 │
└──────┴──────┴──────┴──────┴──────┴──────┘
↑ ↑
HEAD TAIL
(la tarjeta) (el driver)
1. El driver reserva 256 búferes y rellena las direcciones físicas
2. Escribe TAIL en un registro de la tarjeta: "hay 256 libres"
3. Llega un paquete: la tarjeta escribe por DMA en el búfer de HEAD,
rellena longitud y pone el bit DD, y avanza HEAD
4. La tarjeta genera una interrupción (MSI-X)
5. El driver recorre desde su posición buscando descriptores con DD=1
6. Procesa los paquetes, reasigna búferes nuevos y avanza TAILEste diseño de anillo es lo que permite recibir a máxima velocidad: la tarjeta puede escribir varios paquetes sin ninguna intervención de la CPU, y el driver los recoge en lote cuando le toca. Es la misma idea del doble búfer de 02-06, generalizada a 256 posiciones.
Coherencia de caché
Aquí hay un problema sutil y muy real. La CPU tiene cachés; el DMA escribe directamente en la RAM, saltándoselas.
Problema en la lectura (DMA → memoria): 1. La CPU había leído el búfer antes: tiene una copia en su caché L1 2. El DMA escribe datos nuevos en la RAM 3. La CPU lee el búfer → obtiene la copia ANTIGUA de la caché → Lee datos obsoletos Problema en la escritura (memoria → DMA): 1. La CPU escribe en el búfer → queda en la caché (write-back) 2. El DMA lee de la RAM → obtiene datos ANTIGUOS → Envía basura
Las soluciones, en orden de preferencia:
| Solución | Cómo | Coste |
|---|---|---|
| Coherencia por hardware | El bus invalida las líneas de caché afectadas | Ninguno, lo hace el chipset |
| Memoria no cacheable | Marcar las páginas con PCD | Accesos de CPU muy lentos |
| Vaciado explícito | dma_sync_single_for_cpu/device |
Instrucciones extra |
En x86 el hardware es coherente y el problema no existe. En ARM y otras arquitecturas hay que sincronizar explícitamente, y por eso la API de DMA de Linux es portable:
/* Reservar memoria coherente: el núcleo elige la estrategia
adecuada según la arquitectura */
desc = dma_alloc_coherent(&pdev->dev, tamano, &dma_handle, GFP_KERNEL);
/* Para búferes normales, marcar el traspaso de propiedad */
dma_sync_single_for_cpu(&pdev->dev, dma_handle, len, DMA_FROM_DEVICE);
/* ... la CPU lee los datos ... */
dma_sync_single_for_device(&pdev->dev, dma_handle, len, DMA_FROM_DEVICE);Fíjate en el concepto que expresan estas llamadas: la propiedad del búfer se traspasa entre CPU y dispositivo. Mientras es del dispositivo, la CPU no debe tocarlo, y viceversa. Es un patrón que reaparecerá con otro nombre en el módulo 3.
IOMMU
Un problema de seguridad de primer orden: el DMA usa direcciones físicas y se salta la MMU. Una tarjeta comprometida —o con un firmware malicioso, o un dispositivo Thunderbolt conectado por un atacante— podría escribir en cualquier dirección física, incluida la memoria del núcleo. Es el ataque DMA, y ha sido explotado en la práctica.
La IOMMU (Intel VT-d, AMD-Vi) es una MMU para dispositivos:
flowchart LR
DEV["Dispositivo<br/>dirección DMA<br/>0x1000"] --> IOMMU
IOMMU{"IOMMU<br/>¿tiene permiso<br/>este dispositivo?"}
IOMMU -->|sí| RAM["RAM<br/>dirección física<br/>0x7A34000"]
IOMMU -->|no| FAULT["Fallo DMA<br/>→ registrado y bloqueado"]
Es exactamente el mismo mecanismo que la MMU de 02-04 —tablas de traducción, comprobación de permisos, fallo si no procede— pero aplicado a los dispositivos en lugar de a los procesos. Cada dispositivo tiene su propio espacio de direcciones de DMA y solo puede acceder a lo que se le ha asignado.
$ dmesg | grep -i -E 'dmar|iommu' | head -4 [ 0.000000] DMAR: IOMMU enabled [ 0.212841] DMAR: Intel(R) Virtualization Technology for Directed I/O [ 0.213102] iommu: Default domain type: Translated $ ls /sys/class/iommu/ dmar0 dmar1
Además de la seguridad, la IOMMU habilita el passthrough de dispositivos a máquinas virtuales: se puede dar una tarjeta física a una VM con la garantía de que su driver, aunque esté comprometido, no puede tocar la memoria del anfitrión ni de las otras VM. Es una pieza esencial de la virtualización, y volverá en Virtualización: Hipervisores y Máquinas Virtuales.
El camino completo de un paquete con lecturas
Ahora sí, el recorrido completo sin cajas negras. Una estación envía una lectura de 24 bytes por UDP y el ingestor la recibe.
sequenceDiagram
participant E as Estación
participant N as NIC I210
participant M as RAM
participant C as CPU
participant S as softirq NET_RX
participant K as Pila de red
participant I as ingestor
E->>N: paquete UDP (66 bytes en el cable)
N->>N: valida el CRC de la trama Ethernet
N->>M: DMA escribe en el búfer del descriptor HEAD
N->>M: marca DD=1 y la longitud en el descriptor
N->>C: MSI-X: escritura que genera el vector 129
C->>C: guarda estado, salta a la IDT[129]
C->>C: MITAD SUPERIOR igb_msix_ring (< 1 µs)
C->>S: napi_schedule() y deshabilita esta IRQ
Note over C: iret: la CPU continúa lo que hacía
S->>S: MITAD INFERIOR: softirq NET_RX
S->>M: recorre descriptores con DD=1
S->>K: entrega el paquete a la pila de red
K->>K: Ethernet → IP: comprueba destino y suma
K->>K: IP → UDP: comprueba el puerto 9010
K->>K: busca el socket que escucha en 9010
K->>K: encola el paquete en el búfer del socket
K->>I: marca el ingestor como ejecutable (S → R)
S->>N: si no hay más paquetes, rehabilita la IRQ
Note over I: el planificador elige al ingestor (02-02)
I->>I: read() retorna con 24 bytes en el búfer
Recorrido paso a paso, con el coste y la lección de referencia:
| # | Paso | Quién | Coste | Lección |
|---|---|---|---|---|
| 1 | Llega la trama, se valida el CRC | Hardware NIC | ~0,5 µs | — |
| 2 | DMA al búfer del descriptor | Motor DMA | 0 CPU | 02-07 |
| 3 | Se marca DD=1 en el descriptor | NIC | 0 CPU | 02-07 |
| 4 | MSI-X genera el vector 129 | NIC → APIC | — | 02-07 |
| 5 | La CPU guarda estado y salta a la IDT | Hardware CPU | ~0,5 µs | 02-07 |
| 6 | Mitad superior: programa NAPI | Driver igb |
< 1 µs | 02-07 |
| 7 | Mitad inferior: softirq NET_RX | Núcleo | ~5 µs | 02-07 |
| 8 | Pila IP y UDP, búsqueda del socket | Núcleo | ~3 µs | — |
| 9 | Encolado en el búfer del socket | Núcleo | ~0,5 µs | 02-06 (buffering) |
| 10 | El ingestor pasa de S a R |
Núcleo | ~0,3 µs | 02-01 (estados) |
| 11 | El planificador lo elige | CFS/EEVDF | variable | 02-02 |
| 12 | read() copia al espacio de usuario |
Llamada al sistema | ~1 µs | 01-06 |
Latencia total desde el cable hasta el ingestor: ~12 µs más el tiempo de espera del planificador (0 a varios ms según carga)
Dos observaciones que resumen el módulo entero:
La CPU interviene 12 µs para un paquete que tardó 0,5 µs en llegar. El coste no está en mover 66 bytes, sino en cruzar capas: interrupción, cambio de contexto de interrupción, pila de red, despertar del proceso, llamada al sistema. Es la misma lección del coste del syscall de 01-06 y del cambio de contexto de 02-01, aplicada a la E/S.
El paso 11 es el que más varía. Los pasos 1 a 10 son deterministas y suman ~12 µs. El paso 11 depende de la carga y del nice del ingestor, y puede ser cero o varios milisegundos. La latencia real está dominada por la planificación, no por el hardware, y por eso en 02-02 le dimos al ingestor prioridad elevada.
Latencia de interrupción y su impacto
La latencia de interrupción es el tiempo desde que el dispositivo la genera hasta que empieza a ejecutarse el manejador.
Sus componentes:
| Componente | Coste típico | De qué depende |
|---|---|---|
| Propagación por el APIC | 0,1-0,3 µs | Hardware |
| Terminar la instrucción en curso | 0-0,1 µs | Puede ser larga (rep movsb) |
| Esperar a que se rehabiliten las interrupciones | 0 - muchos µs | Código del núcleo con cli |
| Guardar el estado y saltar a la IDT | 0,3-0,5 µs | Hardware |
| Entrar al manejador con cachés frías | 0,5-2 µs | Presión de caché |
El componente crítico es el tercero: si otro código del núcleo tiene las interrupciones deshabilitadas, la interrupción espera. Ese es el peor caso, y es lo que determina la previsibilidad del sistema.
$ sudo cyclictest -p 80 -t 4 -n -D 60 T: 0 ( 4102) P:80 I:1000 C: 60000 Min: 2 Act: 3 Avg: 4 Max: 87 T: 1 ( 4103) P:80 I:1500 C: 40000 Min: 2 Act: 3 Avg: 4 Max: 64
Latencia media de 4 µs y máxima de 87 µs. Para Meteora es excelente. Para control industrial con plazos de 50 µs, ese máximo de 87 µs sería un incumplimiento.
| Núcleo | Latencia máxima típica | Adecuado para |
|---|---|---|
Estándar (CONFIG_PREEMPT_NONE) |
milisegundos | Servidores de rendimiento |
Voluntario (PREEMPT_VOLUNTARY) |
cientos de µs | Escritorio |
Apropiativo (PREEMPT) |
decenas de µs | Multimedia, baja latencia |
| PREEMPT_RT | < 10 µs | Tiempo real duro |
PREEMPT_RT consigue esa garantía convirtiendo casi todos los manejadores de interrupción en hilos del núcleo planificables y sustituyendo los spinlocks por mutex apropiables. Reduce el rendimiento máximo a cambio de previsibilidad, que es exactamente el compromiso del tiempo real que planteamos en 01-03: predecible, no rápido. El detalle corresponde a Sistemas Operativos Móviles y de Tiempo Real.
Herramientas de diagnóstico:
# Interrupciones en tiempo real
$ watch -n1 'grep -E "enp3s0|nvme" /proc/interrupts'
# Mensajes del núcleo con marca de tiempo
$ sudo dmesg -T | grep -iE 'irq|dma|error' | tail -20
# Estadísticas detalladas de la tarjeta
$ sudo ethtool -S enp3s0 | grep -E 'rx_packets|rx_dropped|rx_missed|rx_no_buffer'
rx_packets: 1284102
rx_dropped: 0
rx_missed_errors: 0
rx_no_buffer_count: 0
# Moderación de interrupciones
$ sudo ethtool -c enp3s0
rx-usecs: 3
rx-frames: 0rx_missed_errors y rx_no_buffer_count son las métricas críticas para Meteora. Si son distintas de cero, se están perdiendo lecturas: la tarjeta recibió paquetes pero no había descriptores libres donde escribirlos, porque el driver no los recicló a tiempo. Como las lecturas perdidas son irrecuperables, estas dos cifras deberían estar monitorizadas con alerta.
# Ampliar el anillo de descriptores si aparecen pérdidas $ sudo ethtool -g enp3s0 Ring parameters for enp3s0: Pre-set maximums: RX: 4096 Current hardware settings: RX: 256 $ sudo ethtool -G enp3s0 rx 2048
Pasar de 256 a 2.048 descriptores da al sistema ocho veces más margen para absorber ráfagas antes de perder paquetes. El coste es memoria (2.048 búferes de 2 KB = 4 MB) y algo más de latencia en el peor caso. Para un sistema donde perder datos es irreversible, es un intercambio claramente favorable.
Errores Comunes y Consejos
Hacer trabajo pesado en la mitad superior. Es el error de diseño número uno en drivers. Con las interrupciones deshabilitadas, todo el sistema espera. Un manejador que tarde 500 µs hace perder paquetes a la tarjeta de red y provoca xruns en el audio. La regla: reconocer el hardware, guardar lo mínimo, programar la mitad inferior, salir.
Usar mutex_lock en contexto de interrupción. Un mutex duerme, y en contexto atómico dormir cuelga el sistema. Usa spin_lock_irqsave. Y no olvides irqsave: sin él, si la misma interrupción vuelve a entrar mientras tienes el cerrojo, te bloqueas contra ti mismo.
Olvidar el EOI o el reconocimiento en el hardware. El síntoma es inconfundible: el dispositivo funciona exactamente una vez y luego se queda mudo, o genera una tormenta infinita de interrupciones que congela la máquina.
Devolver IRQ_HANDLED sin comprobar si la interrupción es tuya. En una línea compartida, robas las interrupciones del otro dispositivo, que deja de funcionar con un síntoma imposible de relacionar con tu driver.
Usar memcpy en lugar de copy_to_user. Es una vulnerabilidad de escalada de privilegios, no un detalle de estilo. El puntero viene de usuario y no es de fiar.
Ignorar rx_missed_errors. Es la métrica que dice si estás perdiendo datos en la tarjeta. En Meteora, donde las lecturas perdidas son irrecuperables, debería tener alerta configurada.
Desactivar irqbalance sin fijar la afinidad a mano. Te quedas con todas las interrupciones en CPU0, que es el peor de los dos mundos: sin reparto automático y sin reparto manual.
Consejo de diagnóstico: ante un dispositivo que no responde, el orden es: watch -n1 cat /proc/interrupts (¿genera interrupciones?), dmesg -T | tail -50 (¿hay errores del driver?), ethtool -S o la herramienta equivalente (¿hay pérdidas?), cat /proc/irq/N/smp_affinity (¿están todas en un núcleo?). Si el contador de interrupciones no aumenta, el problema está por debajo del driver: encaminamiento de la interrupción, MSI mal configurado o hardware. Si aumenta pero no llegan datos, el problema está por encima.
Ejercicios
Ejercicio 1: analizar /proc/interrupts
Este es el estado de meteo-01 durante el pico de las 8:00:
CPU0 CPU1 CPU2 CPU3 1: 1204 0 0 0 IO-APIC 1-edge i8042 16: 28841 0 0 0 IO-APIC 16-fasteoi ehci_hcd:usb1, i801_smbus 128: 2 0 0 0 PCI-MSI 1572864-edge enp3s0 129: 8421022 0 0 0 PCI-MSI 1572865-edge enp3s0-rx-0 130: 284102 0 0 0 PCI-MSI 1572866-edge enp3s0-tx-0 131: 12841 11022 8840 7910 PCI-MSI 524288-edge nvme0q0 132: 284102 271884 269110 265882 PCI-MSI 524289-edge nvme0q1 LOC: 9841022 2718840 2691100 2658820 Local timer interrupts RES: 284102 27188 26911 26588 Rescheduling interrupts TLB: 128410 11022 8840 7910 TLB shootdowns
Y en paralelo:
$ mpstat -P ALL 1 1 CPU %usr %nice %sys %iowait %irq %soft %idle all 18,2 4,1 12,4 1,2 0,8 14,1 49,2 0 4,1 0,0 28,2 0,4 3,2 56,4 7,7 1 24,1 0,0 6,8 1,6 0,0 0,2 67,3 2 21,8 16,4 7,1 1,4 0,0 0,1 53,2 3 22,8 0,0 7,5 1,4 0,0 0,1 68,2
- Identifica el problema principal y justifícalo con al menos tres datos de las dos salidas.
- ¿Por qué
LOCes 3,6 veces mayor en CPU0 que en los demás? - ¿Qué indica que
nvme0q1esté repartido yenp3s0-rx-0no? - Propón una solución concreta con las órdenes exactas, y explica qué mejoraría cada una.
- ¿Cómo comprobarías si se están perdiendo lecturas, y qué harías si así fuera?
Ejercicio 2: diseñar la división de un driver
Estás escribiendo el driver de un dispositivo que recoge datos de un grupo de estaciones conectadas por un bus propietario. Cuando llega un lote de lecturas, el driver debe:
- (a) Leer el registro de estado del dispositivo. Tarda 0,5 µs.
- (b) Reconocer la interrupción escribiendo en un registro. Tarda 0,3 µs.
- (c) Copiar 64 lecturas de 24 bytes desde el búfer DMA. Tarda 8 µs.
- (d) Validar las sumas de comprobación de las 64 lecturas. Tarda 45 µs.
- (e) Reservar memoria para almacenarlas. Puede necesitar dormir.
- (f) Escribirlas en un fichero de
/var/lib/meteora/lecturas/. Tarda milisegundos. - (g) Despertar al proceso que espera en
read(). Tarda 0,3 µs.
Responde:
- ¿Qué operaciones van en la mitad superior y cuáles en la inferior? Justifica cada una.
- ¿Qué mecanismo de mitad inferior usarías para cada grupo: softirq, tasklet o workqueue? ¿Por qué?
- Calcula el tiempo con las interrupciones deshabilitadas en tu diseño y compáralo con hacerlo todo en la mitad superior.
- Si el dispositivo genera 2.000 interrupciones por segundo, calcula el porcentaje de CPU en ambos diseños.
- ¿Qué pasaría si pusieras (e) o (f) en la mitad superior?
Ejercicio 3: diagnosticar pérdida de lecturas
El equipo de Meteora informa de que faltan lecturas: los datos del día tienen huecos. Investigas y encuentras:
$ sudo ethtool -S enp3s0 | grep -E 'rx_packets|dropped|missed|no_buffer|fifo'
rx_packets: 68420112
rx_dropped: 0
rx_missed_errors: 284102
rx_no_buffer_count: 128410
rx_fifo_errors: 284102
$ sudo ethtool -g enp3s0
Pre-set maximums:
RX: 4096
Current hardware settings:
RX: 256
$ cat /proc/net/softnet_stat | head -2
0102a4c1 00000000 00028f41 00000000 00000000 00000000 00000000 00000000 00000000 00000000
00004a12 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000 00000000
$ ss -uln
State Recv-Q Send-Q Local Address:Port
UNCONN 212992 0 0.0.0.0:9010
$ cat /proc/sys/net/core/rmem_max
212992
$ ps -eo pid,ni,stat,comm -u meteora
PID NI STAT COMMAND
1842 0 Ssl ingestor
1877 -5 Ssl agregador- Localiza dónde se están perdiendo los paquetes. Hay al menos dos puntos de pérdida distintos: identifícalos y explica en qué se diferencian.
- Calcula el porcentaje de pérdida y qué significa en lecturas perdidas al día.
- Propón una solución completa para cada punto de pérdida, con órdenes concretas.
- Hay un error de configuración evidente en la salida de
ps. Identifícalo y corrígelo, relacionándolo con lo visto en 02-02. - Diseña la monitorización que habría detectado esto antes de que el equipo se diera cuenta.
Soluciones
Solución 1
1. El problema principal: CPU0 saturada por el procesamiento de red.
Las evidencias:
| Evidencia | Dato | Qué significa |
|---|---|---|
enp3s0-rx-0 |
8.421.022 interrupciones, todas en CPU0 | Toda la recepción en un núcleo |
%soft en CPU0 |
56,4 % frente a ~0,1 % en los demás | Más de medio núcleo en softirqs |
%idle en CPU0 |
7,7 % frente a 53-68 % | CPU0 saturada, las demás a medio gas |
%sys en CPU0 |
28,2 % frente a ~7 % | También el trabajo de núcleo se concentra ahí |
RES en CPU0 |
284.102 frente a ~27.000 | Diez veces más replanificaciones |
El cuadro es inequívoco: CPU0 está al 92 % de ocupación mientras las otras tres están a menos de la mitad. El sistema tiene capacidad de sobra en agregado (49,2 % de %idle global) pero un núcleo es el cuello de botella.
Y el dato que lo hace grave: si CPU0 se satura del todo, la softirq NET_RX no podrá vaciar el anillo de descriptores a tiempo y se empezarán a perder lecturas, que en Meteora es irreversible.
2. Por qué LOC es 3,6 veces mayor en CPU0.
LOC son las interrupciones del temporizador local del APIC, que disparan la planificación. En un sistema con NO_HZ (tickless), el temporizador se detiene en los núcleos ociosos para ahorrar energía y solo se activa cuando hay trabajo.
CPU0 tiene un 7,7 % de %idle: está trabajando casi todo el tiempo, así que su temporizador prácticamente nunca se detiene. Los otros tres, con 53-68 % de inactividad, pasan largos periodos sin ticks.
LOC alto no es la causa del problema: es un síntoma más de que CPU0 no descansa.
3. nvme0q1 repartido frente a enp3s0-rx-0 concentrado.
La diferencia está en la arquitectura de colas, y es exactamente lo que vimos en 02-05 y en el apartado de MSI-X:
NVMe: el estándar define una cola de envío y recepción por núcleo. Cada cola tiene su vector MSI-X dirigido a su núcleo. Cuando un proceso en CPU2 hace E/S, usa la cola de CPU2 y su interrupción llega a CPU2. Sin cerrojos compartidos y sin concentración.
El reparto casi perfecto confirma que funciona como debe.
La tarjeta I210: solo tiene una cola de recepción (rx-0, y lo confirma el Count=5 de MSI-X: control, 2 RX, 2 TX en el mejor caso, y aquí solo se usa una). Todo el tráfico entrante pasa por ella, y su vector está dirigido a CPU0.
Es una limitación combinada de hardware y configuración, y por eso las tarjetas de servidor modernas tienen múltiples colas RX con RSS (Receive Side Scaling), que reparte los flujos entre colas según un hash de las cabeceras.
4. Solución concreta.
a) Comprobar cuántas colas soporta la tarjeta:
$ sudo ethtool -l enp3s0 Channel parameters for enp3s0: Pre-set maximums: RX: 4 Combined: 4 Current hardware settings: RX: 1 Combined: 1
Si soporta 4, activarlas:
Qué mejora: la tarjeta pasa a tener 4 colas de recepción, cada una con su vector MSI-X. Con RSS, los paquetes de distintos flujos se reparten entre las cuatro por hash, y cada cola interrumpe a un núcleo distinto. El trabajo de %soft se divide por cuatro.
b) Repartir la afinidad de las interrupciones:
# Ver los vectores actuales $ grep enp3s0 /proc/interrupts # Asignar cada cola a un núcleo $ echo 1 | sudo tee /proc/irq/129/smp_affinity # rx-0 → CPU0 $ echo 2 | sudo tee /proc/irq/133/smp_affinity # rx-1 → CPU1 $ echo 4 | sudo tee /proc/irq/134/smp_affinity # rx-2 → CPU2 $ echo 8 | sudo tee /proc/irq/135/smp_affinity # rx-3 → CPU3
Qué mejora: garantiza el reparto en lugar de dejarlo al azar.
c) Si la tarjeta solo soporta una cola, activar RPS por software:
e es 1110 en binario: CPU1, CPU2 y CPU3. Qué mejora: RPS (Receive Packet Steering) hace en software lo que RSS hace en hardware: la mitad superior sigue ejecutándose en CPU0, pero el procesamiento de la pila de red se distribuye a los otros tres núcleos. Se excluye CPU0 deliberadamente para descargarla.
d) Ajustar la moderación de interrupciones:
Qué mejora: la tarjeta espera hasta 50 µs antes de interrumpir, agrupando varios paquetes por interrupción. Con 8,4 millones de interrupciones, agrupar de 10 en 10 las reduce a 840.000. El coste es hasta 50 µs más de latencia por paquete, perfectamente asumible en Meteora, donde importa no perder lecturas mucho más que entregarlas 50 µs antes.
e) Coordinar con la afinidad de procesos de 02-02:
# El agregador solo en CPU2 y CPU3 (ya lo hicimos en 02-02) $ sudo taskset -cp 2,3 1877 # El ingestor en CPU0 y CPU1, donde llegan las interrupciones de red $ sudo taskset -cp 0,1 1842
Qué mejora: que el ingestor se ejecute en el mismo núcleo donde se procesan sus paquetes aprovecha la localidad de caché: los datos del paquete ya están en la L1/L2 de ese núcleo. Es la afinidad de procesador de 02-02 aplicada a la E/S.
5. Comprobar pérdida de lecturas.
$ sudo ethtool -S enp3s0 | grep -E 'missed|no_buffer|dropped|fifo' $ ss -uln | grep 9010 # cola del socket $ cat /proc/net/softnet_stat # 2ª columna = paquetes descartados
Interpretación de cada punto de pérdida:
| Métrica | Dónde se pierde | Causa |
|---|---|---|
rx_missed_errors |
En la tarjeta | No hay descriptores libres |
rx_no_buffer_count |
En la tarjeta | El driver no recicla búferes a tiempo |
Columna 2 de softnet_stat |
En la cola del backlog | El núcleo no procesa a tiempo |
Recv-Q lleno en ss |
En el socket | El ingestor no lee a tiempo |
Si hubiera pérdidas, la actuación por orden:
# 1. Ampliar el anillo de descriptores $ sudo ethtool -G enp3s0 rx 2048 # 2. Ampliar el búfer del socket $ sudo sysctl -w net.core.rmem_max=16777216 $ sudo sysctl -w net.core.rmem_default=16777216 # 3. Ampliar la cola del backlog $ sudo sysctl -w net.core.netdev_max_backlog=5000 # 4. Aplicar el reparto de interrupciones del punto 4
Solución 2
1. Reparto entre mitades.
| Op | Descripción | Mitad | Justificación |
|---|---|---|---|
| (a) | Leer el estado, 0,5 µs | Superior | Hay que saber si la interrupción es nuestra y qué ha pasado. Imprescindible antes de nada |
| (b) | Reconocer la interrupción, 0,3 µs | Superior | Obligatorio: sin esto el dispositivo sigue afirmando la línea → tormenta infinita |
| (c) | Copiar 64 lecturas, 8 µs | Superior, con matiz | Ver discusión abajo |
| (d) | Validar sumas, 45 µs | Inferior | 45 µs con interrupciones deshabilitadas es inaceptable, y no es urgente |
| (e) | Reservar memoria | Inferior (workqueue) | Puede dormir: prohibido en contexto atómico |
| (f) | Escribir en fichero, ms | Inferior (workqueue) | E/S de disco: bloquea, y milisegundos son una eternidad |
| (g) | Despertar al proceso, 0,3 µs | Inferior | Debe hacerse tras (d) y (e): no hay datos válidos antes |
Discusión sobre (c), que es la decisión interesante: los 8 µs de copia podrían ir en cualquiera de las dos. Argumentos:
- A favor de la superior: si el búfer DMA es un anillo pequeño, hay que vaciarlo pronto para que el dispositivo pueda seguir escribiendo. Retrasarlo arriesga pérdida de datos.
- A favor de la inferior: 8 µs son 16 veces más que el resto de la mitad superior, y con interrupciones deshabilitadas es mucho.
La respuesta profesional es no copiar en absoluto. El diseño correcto usa un anillo de descriptores como el de la tarjeta de red: la mitad superior solo anota qué descriptores están listos (avanzando un índice, ~0,2 µs) y la mitad inferior procesa los datos directamente en el búfer DMA, sin copiarlos. Es exactamente lo que hace igb_clean_rx_irq.
Diseño final:
MITAD SUPERIOR (contexto de interrupción, ~1 µs):
(a) leer estado
(b) reconocer la interrupción
(c') anotar los descriptores listos y avanzar el índice
programar la mitad inferior
MITAD INFERIOR — tasklet o softirq (contexto atómico, ~45 µs):
(d) validar las sumas de comprobación
MITAD INFERIOR — workqueue (contexto de proceso, ms):
(e) reservar memoria
(f) escribir en el fichero
(g) despertar al proceso que espera2. Mecanismo para cada grupo.
Para (d), validar sumas: tasklet.
- No necesita dormir: es cálculo puro sobre datos ya en memoria.
- Debe ejecutarse pronto: los datos ocupan el búfer DMA.
- No hace falta softirq: las softirqs son un recurso escaso, con un número fijo compilado en el núcleo, reservado a subsistemas principales (red, bloque, temporizadores). Un driver concreto usa tasklet, que está construida sobre softirq y es la interfaz pensada para drivers.
- Ventaja adicional: una tasklet concreta nunca corre en dos CPU a la vez, lo que simplifica enormemente la sincronización.
Para (e), (f) y (g): workqueue, obligatoriamente.
- (e) puede dormir.
kmalloc(GFP_KERNEL)se bloquea si no hay memoria libre inmediata, esperando a que el núcleo recupere páginas (todo el mecanismo de 02-04). En contexto atómico eso cuelga el sistema. La workqueue es la única de las tres que puede dormir, porque se ejecuta en un hilo del núcleo, es decir, en contexto de proceso con su propiotask_struct. - (f) hace E/S de disco. Milisegundos de bloqueo. Impensable fuera de contexto de proceso.
- (g) debe ir después, así que acompaña a las anteriores.
/* Mitad superior */
static irqreturn_t meteora_irq(int irq, void *dev)
{
struct meteora_dev *d = dev;
u32 estado = readl(d->regs + REG_ESTADO); /* (a) */
if (!(estado & INT_LOTE_LISTO))
return IRQ_NONE;
writel(estado, d->regs + REG_ESTADO); /* (b) */
d->idx_listo = readl(d->regs + REG_HEAD); /* (c') */
tasklet_schedule(&d->tasklet_validar); /* → (d) */
return IRQ_HANDLED;
}
/* Mitad inferior atómica */
static void meteora_validar(unsigned long data)
{
struct meteora_dev *d = (struct meteora_dev *)data;
validar_sumas(d); /* (d) 45 µs */
queue_work(d->wq, &d->trabajo_guardar); /* → (e)(f)(g) */
}
/* Mitad inferior con contexto de proceso */
static void meteora_guardar(struct work_struct *w)
{
struct meteora_dev *d = container_of(w, struct meteora_dev, trabajo_guardar);
void *buf = kmalloc(TAM_LOTE, GFP_KERNEL); /* (e) puede dormir */
if (!buf) return;
copiar_y_escribir(d, buf); /* (f) E/S: bloquea */
kfree(buf);
wake_up_interruptible(&d->cola_espera); /* (g) */
}3. Tiempo con interrupciones deshabilitadas.
Diseño en dos mitades:
Todo en la mitad superior:
(a) 0,5 + (b) 0,3 + (c) 8 + (d) 45 + (g) 0,3 = 54,1 µs (y (e) y (f) colgarían el sistema, así que ni siquiera es posible)
4. Porcentaje de CPU con 2.000 interrupciones/s.
Diseño en dos mitades:
| Fase | Tiempo | Con interrupciones |
|---|---|---|
| Mitad superior | 1,1 µs | Deshabilitadas |
| Tasklet (d) | 45 µs | Habilitadas |
| Workqueue (e)(f) | ~2.000 µs | Habilitadas, y puede dormir |
CPU total: 2.000 × (1,1 + 45) µs = 92,2 ms/s = 9,2 % de un núcleo
CPU con interrupciones deshabilitadas:
2.000 × 1,1 µs = 2,2 ms/s = 0,22 %(La workqueue hace E/S: su tiempo es mayoritariamente espera de disco, no CPU.)
Todo en la mitad superior:
CPU total: 2.000 × 54,1 µs = 108,2 ms/s = 10,8 % CPU con interrupciones deshabilitadas: 108,2 ms/s = 10,8 %
| Diseño | CPU total | Con interrupciones deshabilitadas |
|---|---|---|
| Dos mitades | 9,2 % | 0,22 % |
| Todo arriba | 10,8 % | 10,8 % |
La métrica que importa es la segunda columna. El consumo total apenas cambia: el trabajo hay que hacerlo igualmente. Lo que cambia radicalmente es cuánto tiempo el resto del sistema está ciego.
Con el 10,8 % de las interrupciones deshabilitadas, el impacto en el resto de meteo-01 sería:
Un paquete cada 1,25 ms (800 lecturas/s) Ventanas de ceguera de 54,1 µs, 2.000 veces por segundo Probabilidad de que un paquete llegue durante una ventana: 10,8 % → El 10,8 % de las lecturas sufrirían latencia adicional → Con ráfagas, pérdidas en rx_missed_errors
5. Si (e) o (f) fueran a la mitad superior.
Con (e), kmalloc(GFP_KERNEL):
[ 1284.221] BUG: sleeping function called from invalid context at mm/page_alloc.c [ 1284.221] in_atomic(): 1, irqs_disabled(): 1, pid: 0, name: swapper/2 [ 1284.221] Call Trace: __might_sleep → __alloc_pages → meteora_irq
Si hay memoria libre inmediata, kmalloc retorna sin dormir y parece funcionar. Cuando el sistema esté bajo presión de memoria —justo cuando más importa— intentará dormir en contexto atómico y el sistema se colgará o entrará en pánico. Es el peor tipo de bug: funciona en pruebas y falla en producción bajo carga.
La alternativa correcta si hubiera que reservar en contexto atómico es GFP_ATOMIC, que nunca duerme pero puede fallar más a menudo y consume de una reserva de emergencia. Aun así, aquí la solución de diseño (workqueue) es mejor.
Con (f), escribir en un fichero:
Peor todavía. Escribir implica el sistema de archivos, la caché de páginas, el planificador de E/S de 02-05 y esperar al disco: milisegundos de bloqueo, con múltiples puntos donde el código duerme.
Consecuencias encadenadas:
5 ms de interrupciones deshabilitadas, 2.000 veces por segundo = 10 segundos de ceguera por segundo → imposible, el sistema se derrumba Y antes de eso: - El temporizador pierde ticks → el reloj del sistema se atrasa - La tarjeta pierde paquetes → se pierden lecturas de Meteora - El watchdog dispara una NMI → pánico del núcleo
Conclusión general del ejercicio: la división en dos mitades no existe para repartir trabajo, sino para minimizar la ventana en que el sistema no puede reaccionar al mundo exterior. Todo lo que no sea imprescindible hacer ahora mismo debe salir de la mitad superior.
Solución 3
1. Los dos puntos de pérdida.
Punto 1: en la tarjeta de red (hardware).
La tarjeta recibió los paquetes correctamente del cable pero no tenía dónde ponerlos: el anillo de 256 descriptores estaba lleno porque la softirq NET_RX no lo había vaciado a tiempo. Los paquetes se quedaron en el FIFO interno de la tarjeta hasta desbordarlo.
Estos paquetes nunca llegaron a la RAM. El núcleo no los vio jamás.
Punto 2: en la cola del backlog del núcleo.
$ cat /proc/net/softnet_stat | head -2 0102a4c1 00000000 00028f41 ... └─ col 1 ─┘└─ col 2 ─┘└─ col 3 ─┘
| Columna | Significado | Valor CPU0 |
|---|---|---|
| 1 | Paquetes procesados | 0x0102a4c1 = 16.950.977 |
| 2 | Paquetes descartados por backlog lleno | 0 |
| 3 | time_squeeze: veces que NAPI agotó su presupuesto |
0x00028f41 = 167.745 |
La columna 2 es cero, así que no se descartaron en el backlog. Pero la columna 3 es la reveladora: 167.745 veces la softirq NET_RX agotó su presupuesto de tiempo o de paquetes y tuvo que ceder antes de vaciar el anillo.
Y ahí está la conexión causal entre ambos puntos: time_squeeze alto es la causa de rx_missed_errors. La softirq no puede seguir el ritmo, el anillo no se vacía, y la tarjeta pierde paquetes.
Además, la segunda línea de softnet_stat (CPU1) muestra 18.962 paquetes procesados frente a 16,9 millones de CPU0: todo el procesamiento está en un núcleo, el mismo problema del ejercicio 1.
Diferencia entre ambos puntos:
| Pérdida en la tarjeta | Pérdida en el backlog | |
|---|---|---|
| Dónde | FIFO del hardware | Cola del núcleo en RAM |
| Causa | Sin descriptores libres | Softirq no procesa a tiempo |
| Se ve en | ethtool -S |
softnet_stat col. 2 |
| Se arregla con | Anillo más grande, más colas | Más CPU, RPS, backlog mayor |
La cola del socket (ss) muestra Recv-Q: 0, así que el ingestor sí está leyendo a tiempo: no hay un tercer punto de pérdida. El problema es anterior a la aplicación.
2. Porcentaje de pérdida.
Paquetes recibidos: 68.420.112 Paquetes perdidos: 284.102 (rx_missed_errors) Total ofrecido: 68.704.214 Pérdida = 284.102 / 68.704.214 = 0,4135 %
En lecturas diarias:
A 800 lecturas/s: 800 × 86.400 = 69.120.000 lecturas/día Perdidas: 69.120.000 × 0,004135 = 285.811 lecturas/día
285.811 lecturas perdidas al día, irrecuperables. Puesto en contexto:
Datos completos del día: 69.120.000 × 24 B = 1,66 GB
(nota: muy por encima de los 17 MB de referencia,
lo que sugiere que el pico de las 8:00 no es
representativo de la media diaria)
Huecos: 1 de cada 242 lecturasUn 0,41 % parece poco, pero para medias horarias por estación significa huecos sistemáticos en las series, y en el peor caso una estación concreta podría perder rachas enteras si su tráfico coincide con los momentos de desbordamiento.
3. Solución para cada punto de pérdida.
Para la pérdida en la tarjeta:
# a) Ampliar el anillo de descriptores de 256 a 2048 $ sudo ethtool -G enp3s0 rx 2048 # b) Activar múltiples colas si el hardware lo permite $ sudo ethtool -l enp3s0 $ sudo ethtool -L enp3s0 combined 4 # c) Moderación de interrupciones: agrupar en lotes $ sudo ethtool -C enp3s0 rx-usecs 50 rx-frames 32
Por qué funciona cada una:
- (a) Da ocho veces más margen para absorber ráfagas. Con 2.048 descriptores a 800 paquetes/s, el sistema tiene 2,5 segundos de colchón en lugar de 0,3. Cuesta 4 MB de RAM.
- (b) Reparte el trabajo entre cuatro núcleos, atacando la causa raíz.
- (c) Reduce las interrupciones agrupando paquetes, dejando más CPU para el procesamiento real.
Para el time_squeeze:
# d) Aumentar el presupuesto de NAPI por ciclo $ sudo sysctl -w net.core.netdev_budget=600 # por defecto 300 $ sudo sysctl -w net.core.netdev_budget_usecs=8000 # por defecto 2000 # e) Ampliar la cola del backlog $ sudo sysctl -w net.core.netdev_max_backlog=5000 # por defecto 1000 # f) Repartir el procesamiento con RPS si solo hay una cola $ echo e | sudo tee /sys/class/net/enp3s0/queues/rx-0/rps_cpus
- (d)
netdev_budgetes cuántos paquetes procesa la softirq antes de ceder. Subirlo de 300 a 600 reduce lostime_squeezea la mitad. El riesgo es que la softirq acapare más la CPU, así que conviene subirlo con moderación. - (f) Reparte el procesamiento de la pila de red a CPU1-3 aunque la interrupción siga llegando a CPU0.
Hacerlo persistente:
# /etc/sysctl.d/99-meteora-red.conf net.core.netdev_budget = 600 net.core.netdev_budget_usecs = 8000 net.core.netdev_max_backlog = 5000 net.core.rmem_max = 16777216 # /etc/udev/rules.d/70-meteora-nic.rules ACTION=="add", SUBSYSTEM=="net", NAME=="enp3s0", \ RUN+="/sbin/ethtool -G enp3s0 rx 2048", \ RUN+="/sbin/ethtool -C enp3s0 rx-usecs 50"
4. El error de configuración en ps.
Las prioridades están invertidas. El agregador tiene prioridad elevada (−5) y el ingestor prioridad normal (0). Es exactamente lo contrario de lo que corresponde, y contradice lo que establecimos en 02-02:
| Proceso | Tipo | Coste de retrasarlo | nice correcto |
nice actual |
|---|---|---|---|---|
ingestor |
E/S, ráfagas mínimas | Irreversible: datos perdidos | −10 | 0 |
agregador |
CPU, ráfagas largas | Recuperable: solo tarda más | +10 | −5 |
Y esto agrava directamente el problema de pérdida. El razonamiento causal completo:
- El
agregadorconnice −5recibe ~75 % de la CPU en conflicto (tabla de pesos de 02-02). - Es un proceso limitado por CPU: cuando se ejecuta, retiene la CPU milisegundos enteros.
- Las softirqs NET_RX compiten con él por CPU0.
- Retrasar la softirq significa no vaciar el anillo →
time_squeeze→rx_missed_errors. - El
agregadormal priorizado está causando pérdida de lecturas.
La corrección:
$ sudo renice -n -10 -p 1842 # ingestor: prioridad alta $ sudo renice -n 10 -p 1877 # agregador: prioridad baja $ sudo taskset -cp 2,3 1877 # y confinarlo lejos de CPU0
Persistente en systemd:
# /etc/systemd/system/meteora-ingestor.service.d/prioridad.conf [Service] Nice=-10 CPUAffinity=0 1 # /etc/systemd/system/meteora-agregador.service.d/prioridad.conf [Service] Nice=10 CPUAffinity=2 3
Esto une las tres piezas del módulo: prioridad de CPU (02-02), afinidad de procesador (02-02) y afinidad de interrupciones (02-07), coordinadas para que el camino de las lecturas —tarjeta → softirq → socket → ingestor— no compita con el cálculo de medias en ningún punto.
5. Monitorización que lo habría detectado antes.
El fallo de proceso aquí es tan grave como el técnico: el problema lo detectó el equipo de datos al ver huecos, no el sistema. Para cuando alguien mira las series, ya se han perdido lecturas irrecuperables durante días.
Métricas y umbrales:
| Métrica | Fuente | Umbral de aviso | Umbral crítico |
|---|---|---|---|
rx_missed_errors |
ethtool -S |
> 0 (delta) | > 100/min |
rx_no_buffer_count |
ethtool -S |
> 0 (delta) | > 100/min |
time_squeeze |
softnet_stat col. 3 |
> 100/min | > 1.000/min |
| Descartes en backlog | softnet_stat col. 2 |
> 0 | > 10/min |
Recv-Q del socket 9010 |
ss -uln |
> 50 % de rmem_max |
> 90 % |
%soft por CPU |
mpstat -P ALL |
> 30 % | > 50 % |
| Lecturas escritas/min | Fichero del día | Desvío > 2 % de lo esperado | > 5 % |
El criterio clave: para rx_missed_errors el umbral de aviso es cualquier incremento distinto de cero, porque cada unidad es una lectura perdida para siempre. No es una métrica de rendimiento, es una métrica de pérdida de datos.
Script de recogida:
#!/bin/bash
# /usr/local/bin/meteora-metricas-red.sh — ejecutar cada minuto
IFACE=enp3s0
EST=/var/lib/meteora/.metricas-red
leer() { ethtool -S $IFACE | awk -v k="$1:" '$1==k {print $2}'; }
MISSED=$(leer rx_missed_errors)
NOBUF=$(leer rx_no_buffer_count)
SQUEEZE=$(awk 'NR==1 {print strtonum("0x" $3)}' /proc/net/softnet_stat)
if [ -f "$EST" ]; then
read -r PM PN PS < "$EST"
D_MISSED=$((MISSED - PM))
D_NOBUF=$((NOBUF - PN))
D_SQUEEZE=$((SQUEEZE - PS))
if [ "$D_MISSED" -gt 0 ] || [ "$D_NOBUF" -gt 0 ]; then
logger -p daemon.crit \
"METEORA: PERDIDA DE LECTURAS missed=$D_MISSED nobuf=$D_NOBUF"
fi
if [ "$D_SQUEEZE" -gt 100 ]; then
logger -p daemon.warning \
"METEORA: softirq saturada, time_squeeze=$D_SQUEEZE/min"
fi
fi
echo "$MISSED $NOBUF $SQUEEZE" > "$EST"Comprobación de extremo a extremo, la más valiosa:
# Contar lecturas realmente almacenadas y compararlas con lo esperado
ESPERADAS=$((800 * 60))
REALES=$(( ($(stat -c %s /var/lib/meteora/lecturas/$(date +%F).dat) - TAM_ANTERIOR) / 24 ))
PERDIDA=$(echo "scale=3; (1 - $REALES/$ESPERADAS) * 100" | bc)Esta última es la que de verdad importa: mide el resultado, no los indicadores intermedios. Puede haber pérdidas en un punto que no estés vigilando —el switch, el cable, la propia estación—, y solo la comprobación de extremo a extremo las detecta todas.
Se comparan bien así:
| Tipo de métrica | Ejemplo | Detecta |
|---|---|---|
| De causa | time_squeeze, %soft |
El problema antes de que cause pérdida |
| De síntoma | rx_missed_errors |
La pérdida en el momento en que ocurre |
| De resultado | Lecturas escritas/min | Cualquier pérdida, venga de donde venga |
Un sistema de monitorización decente tiene las tres: las de causa dan margen para actuar, las de síntoma localizan el punto exacto, y la de resultado garantiza que nada se escapa. Este planteamiento lo retomaremos en Monitorización y Diagnóstico de Rendimiento.
Conclusión
Un controlador de dispositivo es el software que traduce las operaciones genéricas del núcleo a las operaciones concretas de un modelo de hardware, y son el 63 % del código del núcleo de Linux. Cumplen un contrato —una estructura de punteros a función como file_operations— que implementa polimorfismo en C: la misma read() acaba en código distinto según el mayor del dispositivo. Se cargan como módulos cuyo alias conecta el identificador PCI del hardware con el nombre del módulo, lo que hace la carga automática. Y viven en anillo 0, sin red de seguridad: la traza Fatal exception in interrupt que hemos leído no termina en un SIGSEGV, termina en un kernel panic.
Una interrupción llega por una línea IRQ, se traduce en un vector y la CPU salta a la entrada correspondiente de la IDT, guardando estado y cambiando a la pila de núcleo igual que en una llamada al sistema. La diferencia decisiva es que una interrupción no pertenece a ningún proceso: se ejecuta sobre quien tocara, lo que prohíbe dormir, bloquearse, reservar memoria con GFP_KERNEL o tocar el espacio de usuario. De esa prohibición nace la división en mitad superior —microsegundos, reconocer el hardware y programar trabajo— y mitad inferior —softirq, tasklet o workqueue, con interrupciones habilitadas—. En el ejercicio hemos medido la diferencia: 1,1 µs frente a 54,1 µs de ceguera del sistema, un factor de 49.
NAPI resuelve el problema que dejamos planteado en la lección anterior: alterna entre interrupciones con carga baja y polling con carga alta, reduciendo 65 veces el coste a 500.000 paquetes/s. MSI-X elimina las líneas físicas, las carreras con el DMA y el límite de un vector por dispositivo, permitiendo una cola de interrupción por núcleo —que es lo que hace que nvme0q1 esté perfectamente repartido y enp3s0-rx-0 no—. El DMA trabaja con anillos de descriptores que dejan que la tarjeta escriba varios paquetes sin CPU, exige coordinar la coherencia de caché, y necesita la IOMMU para que un dispositivo comprometido no pueda escribir en cualquier dirección física. Y /proc/interrupts cuenta toda esta historia línea a línea: quién interrumpe, cuánto y en qué núcleo.
Con esto cierras el módulo 2, y merece la pena ver el recorrido completo. Empezaste abriendo la abstracción de proceso (02-01): su imagen de memoria, el task_struct, los estados con sus códigos reales, fork con copy-on-write, execve, los zombis y el coste del cambio de contexto. Después viste cómo se reparte la CPU entre ellos (02-02): ráfagas, criterios en conflicto, los algoritmos clásicos calculados a mano y el tiempo virtual justo de CFS con nice. Luego el segundo gran recurso, la memoria (02-03): direcciones lógicas y físicas, la MMU, la fragmentación con números y por qué la paginación gana; y su desarrollo completo (02-04): traducción, tablas multinivel, TLB, fallos de página, reemplazo, hiperpaginación, mmap y el OOM killer. Después el almacenamiento (02-05): la física del disco y del SSD, la planificación de peticiones y RAID. Y por último los dispositivos (02-06): la uniformización, /dev, udev y las tres formas de hablar con el hardware, que esta lección ha desarrollado hasta el final.
Las siete lecciones responden a la misma pregunta desde ángulos distintos: cómo se reparte un recurso escaso entre muchos que lo quieren. CPU, memoria, disco, dispositivos. Y en todas ha aparecido el mismo patrón: una abstracción que oculta la complejidad, un mecanismo de hardware que hace cumplir las reglas, y una política del sistema operativo que decide.
Pero hemos dado por supuesto algo grande. Cada vez que dos procesos compartían una página con MAP_SHARED, cada vez que una softirq y un proceso tocaban la misma cola, cada vez que dos trabajadores de meteo-api escribían en /dev/shm/meteora-cache, hemos dicho "esto requiere sincronización" y hemos seguido adelante. Ha llegado el momento de pagar esa deuda. ¿Qué ocurre exactamente cuando dos flujos de ejecución tocan el mismo dato a la vez? ¿Por qué contador++ puede perder incrementos? ¿Y cómo se coordinan sin destrozar el rendimiento que tanto ha costado conseguir?
Es el Módulo 3: Concurrencia, y empieza en Conceptos de Concurrencia.
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
