L'article va décrire l'interface de programmation pour accèder directement au niveau 2 du modèle OSI. Nous verrons notament comment optimiser les performances à travers un mécanisme de zero-copy proposé par le noyau Linux.

Ce document est en cours d'écriture.

Introduction

Lorsqu'on parle de programmantion réseau sous linux, la première chose qui vient à l'esprit est «socket». Les sockets constituent une interface unique entre vos programmes et la couche réseau du noyau. Les familles de protocoles supportées sont listées sur la page de man socket(2). On peut, par exemple, accéder aux socket UNIX pour une comunication locale à la machine (famille PF_UNIX), aux socket Internet pour une communication entre machines sur un réseau IP (famille PF_INET/PF_INET6), etc.

Les socket permettent aussi d'accèder au plus bas niveau de la pile réseau, et d'envoyer les données directement à la carte réseau (famille AF_PACKET). Mais qui peut bien avoir besoin de cela ?

Un tel accès est déjà utilisé par les outils d'inspection réseau (Wireshark) pour enregistrer tout le trafic que voit passer votre carte réseau. Il peut également servir à générer des paquets spécifiques (qui contiennent volontairement des erreurs par exemple) dans le but de tester un équipement ou de diagnostiquer des défaillances. Enfin, cette interface permet d'écrire votre propre protocole réseau en espace utilisateur, en toute simplicité, avec vos outils habituels.

Cet article va vous guider pour écrire un programme permettant simplement d'établir une communication.

Ainsi, nous allons voir comment directement préparer la trame à expédier dans le buffer qui sera envoyé au périphérique.

ZeroCopy ?

La documentation à propos de la programmation réseau est abondante. Pour le point qui nous intéresse, je recommande la page de man de socket(7) et de packet(7). Vous lirez dans les notes de packet(7) un avertissement concernant la portabilité.

Pour la portabilité, il est conseillé d'utiliser les fonctionnalités PF_PACKET par l'intermédiaire de l'interface pcap(3), bien que cela ne couvre qu'un sous-ensembles des possibilités de PF_PACKET.

L'intérêt de cet article est justement d'explorer une possibilité assez intéressante (mais assez peu documentée) de PF_PACKET sous Linux : le zero-copy.

Comme son nom l'indique, le zero-copy consiste à éviter de transvaser les données d'un buffer à l'autre, de l'espace utilisateur au kernel, à travers toutes les couches, jusqu'à l'émission vers le périphérique.

Les interfaces que nous avons l'habitude de manipuler dans nos programmes sont rarement adaptées au zero-copy. Par exemple, pour émettre des données, l'opération se fait en trois temps :

Pour du zéro-copy, nous n'aurons qu'à changer la première étape pour demander la zone mémoire que le noyau utilisera pour le transit des trames réseaux.

L'interface qui permet de faire cela s'apelle packet mmap.

Packet mmap

La documentation de mmap est fournie dans les sources du noyau [1]. Il existe en outre deux tutoriaux de référence pour l'émission [2] et pour la réception [3]. Cependant, il me semble que les explications sont assez succintes et justifient ce petit résumé.

L'interface packet mmap fournie un ring buffer accessible en espace utilisateur permettant d'émettre ou de recevoir des trames. L'auteur de cette fonctionalité met en avant l'intérêt de pouvoir envoyer plusieurs packets en un appel système, mais il me semble que sendmsg permettait déjà cela. A mon avis l'intérêt réside surtout dans la suppression des copies et la diminution des appels systèmes pour récupérer les informations sur le paquet (la date notament).

FIXME : arranger ce paragraphe : L'API de packet mmap se trouve dans include/uapi/linux/if_packet.h.

Configuration

L'option packet mmap a évoluée en trois versions. Cet article utilisera la version 2.

L'activation du ring buffer est obtenue avec un appel à setsockopt_:

L'argument req contient la description de la « géométrie » du ring buffer. C'est une structure de type struct tpacket_req décrite dans /usr/include/uapi/linux/if_packet.h :

struct tpacket_req {
	unsigned int	tp_block_size;	/* Minimal size of contiguous block */
	unsigned int	tp_block_nr;	/* Number of blocks */
	unsigned int	tp_frame_size;	/* Size of frame */
	unsigned int	tp_frame_nr;	/* Total number of frames */
};

Ainsi, chaque ring buffer est constitué de blocs (une zone mémoire contigue), et chaque bloc peut contenir plusieurs « frames ».

Ring buffers geometry

La configuration des ring buffer est soumis aux contraintes suivantes :

Les explications détaillées sur les limites du nombre et de taille de blocs sont données dans la documentation de packet mmap [1].

Enfin les frames contiendrons les packets et leur méta-données (taille, date, ...). Chaque frame commence par un entête (méta-données) définies par la structure tpacket_hdr. Pour la version deux, sa structure est la suivante :

struct tpacket2_hdr {
	__u32		tp_status;
	__u32		tp_len;
	__u32		tp_snaplen;
	__u16		tp_mac;
	__u16		tp_net;
	__u32		tp_sec;
	__u32		tp_nsec;
	__u16		tp_vlan_tci;
	__u16		tp_padding;
};

Attention : La structure des « frames » est asymétriques et les données sont placés à un offset différent selon si on est en émission ou en réception.

En réception, l'offset par rapport au début de la frame est donné par le champ tp_net ou tp_mac selon si le type de socket est respectivement SOCK_DGRAM ou SOCK_RAW.

RX FRAME STRUCTURE :

Start (aligned to TPACKET_ALIGNMENT=16)   TPACKET_ALIGNMENT=16                                   TPACKET_ALIGNMENT=16
v                                         v                                                      v
|                                         |                             | tp_mac                 |tp_net
|  struct tpacket_hdr  ... pad            | struct sockaddr_ll ... gap  | min(16, maclen)        |
|<--------------------------------------------------------------------->|<---------------------->|<----... 
                               tp_hdrlen = TPACKET2_HDRLEN                   if SOCK_RAW           user data (payload)

En émission, l'offset est constant, défini par : TPACKET2_HDRLEN - sizeof(struct sockaddr_ll).

TX FRAME STRUCTURE :

Start (aligned to TPACKET_ALIGNMENT=16)   TPACKET_ALIGNMENT=16
v                                         v
|                                         |
|  struct tpacket_hdr  ... pad            | struct sockaddr_ll ... gap
|<--------------------------------------------------------------------->| 
                               tp_hdrlen = TPACKET2_HDRLEN
                                          |<---- ... 
                                              user data

Depuis la version 3.8 du noyau, l'utilisateur peut spécifier l'offset du paquet émis grace à l'option PACKET_TX_HAS_OFF.

Mapping du ring buffer en espace utilisateur

L'utilisateur obtient un pointeur vers un espace mémoire contigu représentant le(s) ring buffer) en utilisant mmap.

Que vous ayez un seul ring buffer (émission ou réception), ou deux ring buffer (un de chaque), un seul appel à mmap doit être invoqué.. Les ring buffer se suivent dans l'ordre RX/TX.

Procédure de réception

Lors de la création du ring buffer de réception, le noyau a initialisé tous les entêtes des frames et a notament fixé la valeur du champ tp_status à TP_STATUS_KERNEL.

Le champ tp_status permet au noyau de notifier de la disponibilité d'une trame à l'utilisateur avec la valeur TP_STATUS_USER. Lorsque l'utilisateur en a fini avec la lecture du paquet, il restitue la frame au noyau avec la valeur TP_STATUS_KERNEL.

L'utilisateur n'aura pas besoin de scruter le changement de status des frames, et pourra simplement se mettre en attente avec la fonction poll (ou équivalent).

Procédure d'émission

Lors de la création du ring buffer d'émission, le noyau a initialisé tous les entêtes des frames et a notament fixé la valeur du champ tp_status à TP_STATUS_AVAILABLE.

Le champ tp_status permet à l'utilisateur de notifier de la disponibilité d'une trame au noyau avec la valeur TP_STATUS_SEND_REQUEST. Lorsque le noyau en a fini avec l'émission du paquet, il restitue la frame à l'utilisateur avec la valeur TP_STATUS_AVAILABLE.

L'utilisateur devra spécifier la longeur des données dans l'entête, puis invoquer send pour indiquer au noyau que des données sont prêtes à être envoyées.

Plusieurs frames peuvent êtres envoyées à la fois.

Exemple

Je vous propose un exemple simple permettant d'envoyer et de recevoir des trames.

Ouverture et paramétrage de la socket

L'ouverture de la socket se fait avec un appel à socket :

int type;
uint16_t protocol;
fd = socket(AF_PACKET, socket_type, htons(protocol));

La variable type prends la valeur SOCK_RAW si on souhaite spécifier l'entête ethernet, ou SOCK_DGRAM si on ne fournis que la charge utile de la trame (voir la structure d'une trame [4]).

La variable protocol est l'identifiant du protocole et peut prendre la valeur 0X88b5 ou 0X88b6 qui sont réservées pour des expérimentations locales (http://standards.ieee.org/develop/regauth/ethertype/eth.txt).

On lie la socket à une interface matérielle avec un appel à bind :

struct sockaddr_ll local_addr;
bind(fd, &local_addr, sizeof(local_addr));

Le champ local_addr.sll_ifindex prend la valeur de l'index de l'interface réseau (que l'on peut récupérer avec l'ioctl SIOCGIFINDEX, voir man netdevices).

Le champ local_addr.sll_halen est la taille d'une adresse mac.

Le champ local_addr.sll_addr est l'adresse mac de l'interface réseau

On spécifie la version de l'interace packet_mmap requise.

int version = TPACKET_V2;
setsockopt(fd, SOL_PACKET, PACKET_VERSION, &version, sizeof(version))

On peut demander de spécifier l'offset des données des paquet émis (à partir de la version 3.X du noyau).

int tx_has_off = 1;
setsockopt(fd, SOL_PACKET,  PACKET_TX_HAS_OFF, &tx_has_off, sizeof(tx_has_off));

On prépare la géométrie des ring buffers.

struct tpacket_req rx_paquet_req;
rx_paquet_req.tp_block_size = sysconf(_SC_PAGESIZE) << 1; 
rx_paquet_req.tp_block_nr = 2;
rx_paquet_req.tp_frame_size = next_power_of_two(mtu + 128);
rx_paquet_req.tp_frame_nr = (rx_paquet_req.tp_block_size / rx_paquet_req.tp_frame_size) * rx_paquet_req.tp_block_nr;

Dans cet exemple nous demandons deux blocs, d'une taille de deux pages chacun. Les frames doivent avoir une taille minimale pouvant contenir les plus grosses trames (mtu) avec leur entête (si SOCK_RAW), plus le header de l'interface packet_mmap (80 octets environs).

On peut spécifier une géométrie différente pour le buffer d'émission et de réception.

On instancie les ring buffers.

setsockopt(fd, SOL_PACKET, PACKET_RX_RING, &rx_paquet_req, sizeof(rx_paquet_req))
setsockopt(fd, SOL_PACKET, PACKET_TX_RING, &tx_paquet_req, sizeof(tx_paquet_req))

On mappe les rings buffers en espace utilisateur

int mmap_size = 
    rx_paquet_req.tp_block_size * rx_paquet_req.tp_block_nr +
    tx_paquet_req.tp_block_size * tx_paquet_req.tp_block_nr ;
mmap_base = mmap(0, mmap_size, PROT_READ|PROT_WRITE, MAP_SHARED, fd, 0);

Cette opération retourne un pointeur vers une zone mémoire contigüe avec le buffer de réception suivit du buffer d'émission.

On peut alors décrire chaque buffer du point de vue de l'utilisateur :

rx_buffer_size = rx_paquet_req.tp_block_size * rx_paquet_req.tp_block_nr;
rx_buffer_addr = mmap_base;
rx_buffer_idx  = 0;
rx_buffer_cnt  = rx_paquet_req.tp_block_size * rx_paquet_req.tp_block_nr / rx_paquet_req.tp_frame_size;
tx_buffer_size = tx_paquet_req.tp_block_size * tx_paquet_req.tp_block_nr;
tx_buffer_addr = mmap_base + rx_buffer_size;
tx_buffer_idx  = 0;
tx_buffer_cnt  = tx_paquet_req.tp_block_size * tx_paquet_req.tp_block_nr / tx_paquet_req.tp_frame_size;

L'interface est maintenant prête à être utilisée.

Procédure de réception

La réception d'un paquet ne peut plus se faire en un seul appel à recv comme vous aviez peut-être l'habitude de le faire. La réception se fait en deux temps.

Acquisition d'une zone mémoire

Il faut obtenir un pointeur vers la zone où ont été stockées les donées recues. Pour cela on cherche une frame que le noyau a marqué comme prête à être lue par l'utilisateur.  Les frames étant consécutives, elles sont indexables et on peut récupérer un pointeur sur leur entête :

void * base = rx_buffer_addr + rx_buffer_idx * rx_packet_req.tp_frame_size;
volatile struct tpacket2_hdr * header = (struct tpacket2_hdr *)base;

Le header d'un frame contient un champ tp_status qui donne une information sur son état : elle est disponible si son état est différent de TP_STATUS_KERNEL.

On peut donc parcourir le ring buffer à la recherche d'une frame disponible. Si aucune frame n'est disponible, il faut se mettre en attente d'un signal de la part du noyau avec un appel à poll.

struct pollfd;
pollfd.fd = fd;
pollfd.events = POLLIN|POLLRDNORM|POLLERR;
pollfd.revents = 0;
ppoll(&pollfd, 1, NULL, NULL);

Lorsque le poll débloque avec le bit POLLIN de pollfd.revents levé, une frame est disponible en réception.

Les informations dont on dispose dans l'entête de la frame sont l'offset des données, la taille capturée et la date d'arrivée des données :

void * data = base + header->tp_net;
unsigned data_len = header->tp_snaplen;
struct timespec ts;
ts.sec  = header->tp_sec;
ts.nsec = header->tp_nsec;

Exploitation de la zone mémoire

Les données peuvent être traités « sur place » sans copie vers un buffer local. L'adresse est alignée sur TPACKET_ALIGNMENT (16 octets) et devrait permettre d'y mapper n'importe quelle structure.

Restitution de la zone mémoire

Une fois traitée, la frame doit être « restituée » au noyau pour qu'il puisse y stocker les prochaines trames entrantes.

Cela se fait simplememnt en fixant son status à TP_STATUS_KERNEL.

header->tp_status = TP_STATUS_KERNEL;

Remarques

On remarque ici que si l'interface recoit un flux constant de données, aucun appel système n'est requis pour la réception. Le seul point de blocage est le poll qui intervient en cas de pénurie sur l'interface.

Procédure d'émission

L'émission d'un paquet ne peut plus se faire en un seul appel à send comme vous aviez peut-être l'habitude de le faire. L'émission se fait en deux temps.

Acquisition d'une zone mémoire

Il faut obtenir un pointeur vers la zone où on pourra stoquer les données à émettre. Pour cela il faut trouver une frame libre.  Les frames étant consécutives, elles sont indexables et on peut récupérer un pointeur sur leur entête de manière identique à la réception.

Le header d'un frame contient un champ tp_status qui donne une information sur son état : elle est disponible si son état est différent de TP_STATUS_AVAILABLE.

La zone mémoire disponible pour l'utilisateur se trouve alors à l'offset :

tx_buffer_payload_offset = TPACKET2_HDRLEN - sizeof(struct sockaddr_ll);

Si on utilise des socket de type SOCK_RAW, la charge utile se trouve décalée de 12 octets :

tx_buffer_payload_offset += sizeof(ether_header_t);

Si on a la possibilité de spécifier l'offset des donnée émises (PACKET_TX_HAS_OFF) :

tx_buffer_payload_offset = TPACKET_ALIGN(tx_buffer_payload_offset);

Exploitation de la zone mémoire

Les données peuvent être traités « sur place» sans copie depuis un buffer local. L'adresse peut être alignée sur TPACKET_ALIGNMENT (16 octets) et devrait permettre d'y mapper n'importe quelle structure.

Restitution de la zone mémoire

Une fois remplie, la frame doit être « soumise » au noyau pour qu'il puisse l'envoyer vers l'interface.

Cela se fait en fixant son status à TP_STATUS_SEND_REQUEST puis en appelant sendto.

header->tp_status = TP_STATUS_SEND_REQUEST;
sendto(itf->sock_fd, NULL, 0, 0, (const struct sockaddr *)&remote_addr, remote_addr));

Remarques

On remarque ici qu'on peut grouper l'envoi de plusieurs trames en un seul appel systeme. En outre, l'appel systeme ne requière aucune copie de données.

Ressources