wandres.dev
LA PILA DE RED · sk_buff, net_device

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.

⏱ 18 min

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.

🎯 Al terminar esta lección sabrás
  • Asignar, configurar y registrar un net_device con alloc_etherdev y register_netdev.
  • Rellenar la tabla net_device_ops: ndo_open, ndo_stop, ndo_start_xmit.
  • Controlar el flujo con netif_start_queue, netif_stop_queue y netif_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]
ℹ️
El core ya sabe llevar tus estadísticas por ti

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.

El net_device es una clase, y ni siquiera necesitó un lenguaje con clases

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.

⚔️ Registra tu propia interfaz virtual
  1. Escribe un módulo que asigne un net_device con alloc_etherdev, rellene netdev_ops con un ndo_start_xmit que cuente y descarte el skb, y lo registre; comprueba con ip link que tu interfaz aparece.
  2. Implementa ndo_open y ndo_stop que enciendan y apaguen un LED de estado ficticio con netif_carrier_on/off, y observa el cambio de estado en ip link.
  3. Añade estadísticas per-CPU y un ndo_get_stats64, transmite tráfico hacia tu interfaz y léelo con ip -s link.
  4. Provoca a propósito que el anillo de TX se llene y verifica, con trazas, la pareja netif_stop_queue/netif_wake_queue.
  5. Sustituye tu bucle de estadísticas por NETDEV_PCPU_STAT_TSTATS y dev_get_tstats64, y argumenta cuánto código de driver acabas de poder borrar.