struct net_device: una interfaz de red hecha objeto
La estructura que representa toda interfaz de red —de una NIC de 400 Gbit al loopback— y su tabla de operaciones `netdev_ops`: `ndo_open`, `ndo_start_xmit`, `ndo_get_stats64`. Cómo se asigna con `alloc_etherdev`, se registra con `register_netdev` y lleva sus estadísticas con contadores per-CPU sin contención. El polimorfismo del kernel escrito en C.
Cuando escribes ip link, el kernel te muestra una lista uniforme: lo, eth0, wlan0, un túnel, un puente. Bajo esa uniformidad hay hardware —y no-hardware— radicalmente distinto: una NIC de fibra de 400 Gbit y el loopback que nunca toca un cable comparten exactamente la misma cara ante el resto del kernel. Esa cara es el struct net_device, y el contrato que todos firman es netdev_ops, una tabla de punteros a función que hace que la pila trate igual a lo que por dentro no se parece en nada. Es polimorfismo, en C, sin una sola palabra reservada de orientación a objetos.
- Asignar, configurar y registrar un
net_deviceconalloc_etherdevyregister_netdev. - Rellenar la tabla
net_device_ops:ndo_open,ndo_stop,ndo_start_xmit. - Controlar el flujo con
netif_start_queue,netif_stop_queueynetif_wake_queue. - Llevar estadísticas escalables con contadores per-CPU y
ndo_get_stats64.
Una interfaz es una struct: asignar, privar, registrar
Crear una interfaz es asignar un net_device con espacio extra para tu estado privado, rellenar sus punteros y registrarlo. Desde el registro, la interfaz existe para todo el sistema: ip, /sys/class/net, la pila entera.
static int mi_probe(struct platform_device *pdev)
{
struct net_device *dev;
struct mi_priv *priv;
int ret;
dev = alloc_etherdev(sizeof(*priv)); /* net_device + hueco para tu priv, contiguos */
if (!dev)
return -ENOMEM;
SET_NETDEV_DEV(dev, &pdev->dev); /* cuelga la interfaz del device del bus */
priv = netdev_priv(dev); /* tu estado, pegado detras del net_device */
priv->netdev = dev;
dev->netdev_ops = &mi_netdev_ops; /* el contrato de comportamiento */
dev->ethtool_ops = &mi_ethtool_ops;
dev->watchdog_timeo = 5 * HZ;
eth_hw_addr_random(dev); /* MAC aleatoria valida a falta de una real */
netif_napi_add(dev, &priv->napi, mi_poll);
ret = register_netdev(dev); /* a partir de aqui, ip link la ve */
if (ret) {
free_netdev(dev); /* deshace alloc_etherdev en el error */
return ret;
}
platform_set_drvdata(pdev, dev);
return 0;
}
netdev_priv devuelve un puntero al bloque privado que alloc_etherdev reservó dentro de la misma asignación: una sola llamada al asignador para la estructura pública y la privada juntas, con la localidad de caché que el nivel 28 te enseñó a valorar. En el descarte, unregister_netdev retira la interfaz y free_netdev la libera.
netdev_ops: el contrato que todos firman
La tabla net_device_ops es el corazón polimórfico. Cada campo ndo_ es un puntero a función que la pila invocará sin saber qué hay detrás. Una NIC real, un túnel y el loopback rellenan la misma tabla con implementaciones distintas.
static const struct net_device_ops mi_netdev_ops = {
.ndo_open = mi_open, /* ip link set up */
.ndo_stop = mi_stop, /* ip link set down */
.ndo_start_xmit = mi_start_xmit, /* transmitir un skb */
.ndo_get_stats64 = mi_get_stats64, /* contadores para ip -s link */
.ndo_set_mac_address = eth_mac_addr, /* helper generico de Ethernet */
.ndo_validate_addr = eth_validate_addr,
.ndo_change_mtu = mi_change_mtu,
};
Fíjate en que ndo_set_mac_address y ndo_validate_addr apuntan a helpers genéricos del núcleo de Ethernet: no todo lo que firma el contrato lo escribe el driver. Esa es la ventaja de la tabla: rellenas solo lo que tu hardware hace especial y delegas el resto en implementaciones probadas.
Encender la interfaz: ndo_open y el control de cola
ndo_open responde a ip link set dev up. Ahí el driver pide su interrupción, arranca el hardware, habilita NAPI y —crucial— abre la cola de transmisión para que la pila empiece a llamar a ndo_start_xmit.
static int mi_open(struct net_device *dev)
{
struct mi_priv *priv = netdev_priv(dev);
int ret;
ret = request_irq(priv->irq, mi_irq, IRQF_SHARED, dev->name, priv);
if (ret)
return ret;
mi_hw_up(priv); /* enciende el hardware y los anillos DMA */
napi_enable(&priv->napi); /* habilita el sondeo de recepcion */
netif_start_queue(dev); /* la pila ya puede transmitir por aqui */
netif_carrier_on(dev); /* declara enlace fisico presente */
return 0;
}
El control de cola es un semáforo de flujo entre la pila y el driver. Cuando el anillo de TX se llena, mi_start_xmit llama a netif_stop_queue: la pila deja de entregarle paquetes. Cuando la NIC termina de transmitir y libera descriptores, la rutina de fin de TX llama a netif_wake_queue y el grifo se reabre. Sin este apretón de manos, la pila inundaría un anillo que no da abasto.
static int mi_stop(struct net_device *dev)
{
struct mi_priv *priv = netdev_priv(dev);
netif_stop_queue(dev); /* corta la transmision */
napi_disable(&priv->napi); /* espera a que termine el poll en curso */
free_irq(priv->irq, priv);
mi_hw_down(priv);
return 0;
}
Estadísticas sin contención: contadores per-CPU
ip -s link muestra paquetes y bytes por interfaz. Un contador global sería un desastre de escalabilidad: cada CPU que recibe o transmite lucharía por la misma línea de caché. La solución es un contador por CPU, actualizado sin bloqueo, agregado solo al leer.
struct mi_pcpu_stats {
u64_stats_t rx_packets, rx_bytes;
u64_stats_t tx_packets, tx_bytes;
struct u64_stats_sync syncp; /* protege lecturas de 64 bits en 32 bits */
};
/* En el camino caliente (poll o xmit): sin locks, solo la CPU local toca su contador */
static inline void mi_count_rx(struct mi_pcpu_stats *st, unsigned int bytes)
{
u64_stats_update_begin(&st->syncp);
u64_stats_add(&st->rx_packets, 1);
u64_stats_add(&st->rx_bytes, bytes);
u64_stats_update_end(&st->syncp);
}
Al leer, ndo_get_stats64 recorre todas las CPUs y suma, usando el u64_stats_sync como una secuencia que reintenta si el escritor pisó la lectura —el mismo patrón que los seqlocks del nivel 18—.
static void mi_get_stats64(struct net_device *dev, struct rtnl_link_stats64 *s)
{
struct mi_priv *priv = netdev_priv(dev);
int cpu;
for_each_possible_cpu(cpu) {
struct mi_pcpu_stats *st = per_cpu_ptr(priv->stats, cpu);
u64 rxp, rxb, txp, txb;
unsigned int start;
do {
start = u64_stats_fetch_begin(&st->syncp);
rxp = u64_stats_read(&st->rx_packets);
rxb = u64_stats_read(&st->rx_bytes);
txp = u64_stats_read(&st->tx_packets);
txb = u64_stats_read(&st->tx_bytes);
} while (u64_stats_fetch_retry(&st->syncp, start));
s->rx_packets += rxp; s->rx_bytes += rxb;
s->tx_packets += txp; s->tx_bytes += txb;
}
}
flowchart LR A[alloc_etherdev] --> R[register_netdev] --> O[ndo_open sube la interfaz] --> X[ndo_start_xmit transmite] --> S[ndo_stop baja la interfaz] --> U[unregister_netdev y free_netdev]
Escribir a mano el bucle per-CPU es didáctico, pero los kernels 7.x lo automatizan: basta con dev->pcpu_stat_type = NETDEV_PCPU_STAT_TSTATS antes de registrar, y el núcleo asigna los contadores per-CPU, los libera al desregistrar y ofrece dev_get_tstats64 como tu ndo_get_stats64. Es la tendencia constante del kernel: un patrón que muchos drivers repetían se absorbe en el core y deja de ser responsabilidad tuya. Menos código en cada driver es menos superficie de error multiplicada por cientos de drivers.
Mira la tabla netdev_ops y reconoce lo que de verdad es: una vtable. Un puntero a una estructura de punteros a función, exactamente el mecanismo con el que C++ implementa los métodos virtuales, escrito a mano en C plano. Y de esa construcción humilde brota una de las ideas más potentes de todo el kernel. La pila de red no sabe —ni quiere saber— si el paquete que entrega va a una NIC de fibra, a un túnel WireGuard, a una rama de un puente o al loopback que solo copia el skb de vuelta hacia arriba. Llama a dev->netdev_ops->ndo_start_xmit(skb, dev) y confía en que la implementación correcta, sea la que sea, haga lo suyo. Eso es polimorfismo en estado puro: un mismo mensaje, comportamientos distintos según el receptor, resuelto en tiempo de ejecución por un puntero. El kernel entero está tejido con esta idea —la file_operations del VFS, la address_space_operations del page cache que viste en el nivel 24, la net_device_ops de aquí— y todas responden a la misma necesidad: ofrecer una interfaz uniforme sobre implementaciones que no se parecen. La lección profunda es que la orientación a objetos no es una propiedad de un lenguaje, sino un patrón de diseño; el struct de operaciones y el puntero a void privado —netdev_priv— son la herencia y la encapsulación reinventadas por necesidad, décadas antes de que ningún compilador se las regalara. Cuando comprendes que ndo_start_xmit es un método virtual y net_device una clase base abstracta, dejas de ver el kernel como un mar de funciones sueltas y empiezas a ver su arquitectura: contratos, implementaciones y despacho dinámico, la misma catedral que cualquier framework orientado a objetos, levantada con struct y punteros porque eso era todo lo que C ofrecía, y resultó ser suficiente.
- Escribe un módulo que asigne un
net_deviceconalloc_etherdev, rellenenetdev_opscon unndo_start_xmitque cuente y descarte el skb, y lo registre; comprueba conip linkque tu interfaz aparece. - Implementa
ndo_openyndo_stopque enciendan y apaguen un LED de estado ficticio connetif_carrier_on/off, y observa el cambio de estado enip link. - Añade estadísticas per-CPU y un
ndo_get_stats64, transmite tráfico hacia tu interfaz y léelo conip -s link. - Provoca a propósito que el anillo de TX se llene y verifica, con trazas, la pareja
netif_stop_queue/netif_wake_queue. - Sustituye tu bucle de estadísticas por
NETDEV_PCPU_STAT_TSTATSydev_get_tstats64, y argumenta cuánto código de driver acabas de poder borrar.