Saltar al contenido

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

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

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

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

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

C
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—.

C
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;	}}

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.