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

  1. Qué es un controlador de dispositivo
  2. El contrato con el núcleo: la interfaz de operaciones de fichero
  3. Registro del driver y módulos cargables
  4. Espacio de núcleo frente a espacio de usuario para drivers
  5. Interrupciones: línea IRQ, vector y tabla de vectores
  6. El controlador de interrupciones: del PIC al APIC
  7. Qué hace la CPU al recibir una interrupción
  8. El contexto de interrupción y sus reglas
  9. Enmascaramiento e interrupciones compartidas
  10. Mitad superior y mitad inferior
  11. /proc/interrupts interpretado línea a línea
  12. MSI y MSI-X
  13. DMA en detalle: descriptores, coherencia e IOMMU
  14. El camino completo de un paquete con lecturas
  15. Latencia de interrupción y su impacto
  16. 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 .read de su file_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 de memcpy(). Es obligatorio y no es una formalidad. El puntero buf viene 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_user valida 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. Un memcpy directo sería una vulnerabilidad de escalada de privilegios de manual. La marca __user en el prototipo permite además que herramientas de análisis estático detecten el error.
  • Devolver -EIO y -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 -1 más errno.
  • register_chrdev con 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_init y module_exit definen 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 simple SIGSEGV.
  • 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/2 es 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 Fallo de página, división por cero
Llamada al sistema Instrucción syscall deliberada 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 : dirige la interrupción a una CPU concreta
Prioridades Fijas por número Programables
Reparto de carga No Sí, entre CPU
MSI No

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:

  1. 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.
  2. 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.
  3. Se cambia a la pila de núcleo, exactamente como en un syscall. El manejador nunca usa la pila del proceso interrumpido.
  4. Las interrupciones se deshabilitan si la entrada de la IDT es una interrupt gate (lo habitual). Esto evita que otra interrupción anide inmediatamente.
  5. 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:

  1. Comprobar si la interrupción es suya antes de nada, porque la línea puede estar compartida. Devolver IRQ_NONE permite que el núcleo pruebe con el siguiente driver registrado.
  2. 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.
  3. spin_lock_irqsave en lugar de mutex_lock. Un mutex duerme si está ocupado; un spinlock gira esperando, que es lo único que puede hacerse en contexto atómico. La variante irqsave guarda además el estado de las interrupciones y las deshabilita, evitando el interbloqueo consigo mismo si la misma interrupción se repitiera.
  4. 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 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      89102

Lectura de esta salida:

  • NET_RX es 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.
  • TIMER está repartido uniformemente, como debe ser: cada núcleo tiene su temporizador local del APIC.
  • BLOCK son 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 shootdowns

Estructura 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.

$ cat /proc/irq/129/smp_affinity
1
$ cat /proc/irq/129/smp_affinity_list
0

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:

$ watch -n1 'grep -E "enp3s0|nvme" /proc/interrupts'

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:

  1. Escasez: hay pocas líneas, de ahí las compartidas.
  2. 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.
  3. 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 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=00000000

Enable+ 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 TAIL

Este 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: 0

rx_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
  1. Identifica el problema principal y justifícalo con al menos tres datos de las dos salidas.
  2. ¿Por qué LOC es 3,6 veces mayor en CPU0 que en los demás?
  3. ¿Qué indica que nvme0q1 esté repartido y enp3s0-rx-0 no?
  4. Propón una solución concreta con las órdenes exactas, y explica qué mejoraría cada una.
  5. ¿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:

  1. ¿Qué operaciones van en la mitad superior y cuáles en la inferior? Justifica cada una.
  2. ¿Qué mecanismo de mitad inferior usarías para cada grupo: softirq, tasklet o workqueue? ¿Por qué?
  3. Calcula el tiempo con las interrupciones deshabilitadas en tu diseño y compáralo con hacerlo todo en la mitad superior.
  4. Si el dispositivo genera 2.000 interrupciones por segundo, calcula el porcentaje de CPU en ambos diseños.
  5. ¿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
  1. 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.
  2. Calcula el porcentaje de pérdida y qué significa en lecturas perdidas al día.
  3. Propón una solución completa para cada punto de pérdida, con órdenes concretas.
  4. 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.
  5. 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.

 132:     284102     271884     269110     265882   nvme0q1

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:

$ sudo ethtool -L enp3s0 combined 4

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:

$ echo e | sudo tee /sys/class/net/enp3s0/queues/rx-0/rps_cpus

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:

$ sudo ethtool -C enp3s0 rx-usecs 50

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 espera

2. 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 propio task_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:

(a) 0,5 µs + (b) 0,3 µs + (c') 0,2 µs + programar 0,1 µs = 1,1 µs

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)
Reducción: 54,1 / 1,1 = 49× menos tiempo con interrupciones deshabilitadas

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).

rx_missed_errors: 284102
rx_no_buffer_count: 128410
rx_fifo_errors: 284102

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 lecturas

Un 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_budget es cuántos paquetes procesa la softirq antes de ceder. Subirlo de 300 a 600 reduce los time_squeeze a 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.

    PID  NI STAT COMMAND
   1842   0 Ssl  ingestor      ← nice 0
   1877  -5 Ssl  agregador     ← nice -5

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:

  1. El agregador con nice −5 recibe ~75 % de la CPU en conflicto (tabla de pesos de 02-02).
  2. Es un proceso limitado por CPU: cuando se ejecuta, retiene la CPU milisegundos enteros.
  3. Las softirqs NET_RX compiten con él por CPU0.
  4. Retrasar la softirq significa no vaciar el anillo → time_squeezerx_missed_errors.
  5. El agregador mal 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

Módulo 2: Gestión de Recursos

Módulo 3: Concurrencia

Módulo 4: Estructuras de Archivos

Módulo 5: Protección y Seguridad del Sistema

Módulo 6: Virtualización y Contenedores

Módulo 7: Administración y Diagnóstico en la Práctica

© Copyright 2026. Todos los derechos reservados