BPF pour les plus petits, première partie : BPF étendu

Au début, il existait une technologie appelée BPF. Nous l'avons regardée précédent, article de l'Ancien Testament de cette série. En 2013, grâce aux efforts d'Alexei Starovoitov et Daniel Borkman, une version améliorée, optimisée pour les machines modernes 64 bits, a été développée et incluse dans le noyau Linux. Cette nouvelle technologie a été brièvement appelée Internal BPF, puis renommée Extended BPF, et maintenant, après plusieurs années, tout le monde l'appelle simplement BPF.

En gros, BPF vous permet d'exécuter du code arbitraire fourni par l'utilisateur dans l'espace du noyau Linux, et la nouvelle architecture s'est avérée si réussie que nous aurons besoin d'une douzaine d'articles supplémentaires pour décrire toutes ses applications. (La seule chose que les développeurs n'ont pas bien fait, comme vous pouvez le voir dans le code de performance ci-dessous, a été de créer un logo décent.)

Cet article décrit la structure de la machine virtuelle BPF, les interfaces du noyau pour travailler avec BPF, les outils de développement, ainsi qu'un bref, très bref aperçu des capacités existantes, c'est-à-dire tout ce dont nous aurons besoin à l'avenir pour une étude plus approfondie des applications pratiques du BPF.
BPF pour les plus petits, première partie : BPF étendu

Résumé de l'article

Introduction à l'architecture BPF. Tout d’abord, nous allons avoir une vue d’ensemble de l’architecture BPF et décrire les principaux composants.

Registres et système de commande de la machine virtuelle BPF. Ayant déjà une idée de l'architecture dans son ensemble, nous décrirons la structure de la machine virtuelle BPF.

Cycle de vie des objets BPF, système de fichiers bpffs. Dans cette section, nous examinerons de plus près le cycle de vie des objets BPF - programmes et cartes.

Gestion des objets à l'aide de l'appel système bpf. Avec une certaine compréhension du système déjà en place, nous verrons enfin comment créer et manipuler des objets depuis l'espace utilisateur à l'aide d'un appel système spécial - bpf(2).

Пишем программы BPF с помощью libbpf. Bien entendu, vous pouvez écrire des programmes à l’aide d’un appel système. Mais c'est difficile. Pour un scénario plus réaliste, les programmeurs nucléaires ont développé une bibliothèque libbpf. Nous allons créer un squelette d’application BPF de base que nous utiliserons dans les exemples suivants.

Aides du noyau. Ici, nous apprendrons comment les programmes BPF peuvent accéder aux fonctions d'assistance du noyau - un outil qui, avec les cartes, étend fondamentalement les capacités du nouveau BPF par rapport au classique.

Accès aux cartes des programmes BPF. À ce stade, nous en saurons suffisamment pour comprendre exactement comment créer des programmes utilisant des cartes. Et jetons même un coup d’œil rapide au grand et puissant vérificateur.

Outils de développement. Section d'aide sur la façon d'assembler les utilitaires et le noyau requis pour les expériences.

Conclusion. À la fin de l’article, ceux qui liront jusqu’ici trouveront des mots motivants et une brève description de ce qui se passera dans les articles suivants. Nous listerons également un certain nombre de liens d'auto-apprentissage pour ceux qui n'ont pas le désir ou la capacité d'attendre la suite.

Introduction à l'architecture BPF

Avant de commencer à considérer l’architecture BPF, nous ferons référence une dernière fois (oh) à FBP classique, qui a été développé en réponse à l'avènement des machines RISC et a résolu le problème du filtrage efficace des paquets. L'architecture s'est avérée si réussie que, née dans les fringantes années XNUMX sous Berkeley UNIX, elle a été portée sur la plupart des systèmes d'exploitation existants, a survécu jusque dans les folles années XNUMX et trouve encore de nouvelles applications.

Le nouveau BPF a été développé en réponse à l'omniprésence des machines 64 bits, des services cloud et au besoin croissant d'outils de création de SDN (Slogiciel-ddéfini nréseautage). Développé par les ingénieurs réseau du noyau pour remplacer le BPF classique, le nouveau BPF a trouvé littéralement six mois plus tard des applications dans la tâche difficile de traçage des systèmes Linux, et maintenant, six ans après son apparition, nous aurons besoin d'un prochain article complet juste pour lister les différents types de programmes.

Images drôles

À la base, BPF est une machine virtuelle sandbox qui vous permet d'exécuter du code « arbitraire » dans l'espace du noyau sans compromettre la sécurité. Les programmes BPF sont créés dans l'espace utilisateur, chargés dans le noyau et connectés à une source d'événements. Un événement peut être, par exemple, la livraison d'un paquet à une interface réseau, le lancement d'une fonction du noyau, etc. Dans le cas d'un package, le programme BPF aura accès aux données et métadonnées du package (en lecture et, éventuellement, en écriture, selon le type de programme) ; dans le cas de l'exécution d'une fonction noyau, les arguments de la fonction, y compris les pointeurs vers la mémoire du noyau, etc.

Examinons de plus près ce processus. Pour commencer, parlons de la première différence avec le BPF classique, dont les programmes ont été écrits en assembleur. Dans la nouvelle version, l'architecture a été étendue afin que les programmes puissent être écrits dans des langages de haut niveau, principalement, bien sûr, en C. Pour cela, un backend pour llvm a été développé, qui permet de générer du bytecode pour l'architecture BPF.

BPF pour les plus petits, première partie : BPF étendu

L'architecture BPF a été conçue, en partie, pour fonctionner efficacement sur des machines modernes. Pour que cela fonctionne en pratique, le bytecode BPF, une fois chargé dans le noyau, est traduit en code natif à l'aide d'un composant appelé compilateur JIT (Just In Tmoi). Ensuite, si vous vous en souvenez, dans le BPF classique, le programme était chargé dans le noyau et attaché à la source d'événement de manière atomique - dans le contexte d'un seul appel système. Dans la nouvelle architecture, cela se déroule en deux étapes : premièrement, le code est chargé dans le noyau à l'aide d'un appel système. bpf(2)puis, plus tard, via d'autres mécanismes qui varient selon le type de programme, le programme s'attache à la source de l'événement.

Ici, le lecteur peut se poser une question : était-ce possible ? Comment la sécurité d’exécution d’un tel code est-elle garantie ? La sécurité d'exécution nous est garantie par l'étape de chargement des programmes BPF appelée verifier (en anglais cette étape s'appelle verifier et je continuerai à utiliser le mot anglais) :

BPF pour les plus petits, première partie : BPF étendu

Verifier est un analyseur statique qui garantit qu'un programme ne perturbe pas le fonctionnement normal du noyau. Soit dit en passant, cela ne signifie pas que le programme ne peut pas interférer avec le fonctionnement du système - les programmes BPF, selon le type, peuvent lire et réécrire des sections de la mémoire du noyau, renvoyer les valeurs des fonctions, découper, ajouter, réécrire. et même transférer des paquets réseau. Verifier garantit que l'exécution d'un programme BPF ne fera pas planter le noyau et qu'un programme qui, selon les règles, a un accès en écriture, par exemple aux données d'un paquet sortant, ne pourra pas écraser la mémoire du noyau en dehors du paquet. Nous examinerons le vérificateur un peu plus en détail dans la section correspondante, après nous être familiarisés avec tous les autres composants de BPF.

Alors qu’avons-nous appris jusqu’à présent ? L'utilisateur écrit un programme en C, le charge dans le noyau à l'aide d'un appel système bpf(2), où il est vérifié par un vérificateur et traduit en bytecode natif. Ensuite, le même utilisateur ou un autre connecte le programme à la source d'événement et il commence à s'exécuter. La séparation du démarrage et de la connexion est nécessaire pour plusieurs raisons. Premièrement, exécuter un vérificateur est relativement coûteux et en téléchargeant plusieurs fois le même programme, nous perdons du temps informatique. Deuxièmement, la manière exacte dont un programme est connecté dépend de son type, et une interface « universelle » développée il y a un an peut ne pas convenir aux nouveaux types de programmes. (Même si maintenant que l'architecture devient plus mature, il y a une idée pour unifier cette interface au niveau libbpf.)

Le lecteur attentif remarquera peut-être que nous n’en avons pas encore fini avec les images. En effet, tout ce qui précède n’explique pas pourquoi le BPF change fondamentalement la donne par rapport au BPF classique. Deux innovations qui élargissent considérablement le champ d'application sont la possibilité d'utiliser la mémoire partagée et les fonctions d'assistance du noyau. Dans BPF, la mémoire partagée est implémentée à l'aide de ce que l'on appelle des cartes - des structures de données partagées avec une API spécifique. Ils tirent probablement ce nom du fait que le premier type de carte à apparaître était une table de hachage. Ensuite, des tableaux sont apparus, des tables de hachage locales (par CPU) et des tableaux locaux, des arbres de recherche, des cartes contenant des pointeurs vers des programmes BPF et bien plus encore. Ce qui nous intéresse maintenant, c'est que les programmes BPF ont désormais la capacité de conserver l'état entre les appels et de le partager avec d'autres programmes et avec l'espace utilisateur.

Maps est accessible à partir des processus utilisateur à l'aide d'un appel système bpf(2), et à partir de programmes BPF exécutés dans le noyau à l'aide de fonctions d'assistance. De plus, des assistants existent non seulement pour travailler avec des cartes, mais aussi pour accéder à d'autres fonctionnalités du noyau. Par exemple, les programmes BPF peuvent utiliser des fonctions d'assistance pour transférer des paquets vers d'autres interfaces, générer des événements de performances, accéder aux structures du noyau, etc.

BPF pour les plus petits, première partie : BPF étendu

En résumé, BPF offre la possibilité de charger du code utilisateur arbitraire, c'est-à-dire testé par un vérificateur, dans l'espace du noyau. Ce code peut sauvegarder l'état entre les appels et échanger des données avec l'espace utilisateur, et a également accès aux sous-systèmes du noyau autorisés par ce type de programme.

Ceci est déjà similaire aux capacités fournies par les modules du noyau, par rapport auxquelles BPF présente certains avantages (bien sûr, vous ne pouvez comparer que des applications similaires, par exemple le traçage du système - vous ne pouvez pas écrire un pilote arbitraire avec BPF). On peut noter un seuil d'entrée plus bas (certains utilitaires utilisant BPF ne nécessitent pas que l'utilisateur ait des compétences en programmation du noyau, ou des compétences en programmation en général), la sécurité d'exécution (levez la main dans les commentaires pour ceux qui n'ont pas cassé le système lors de l'écriture ou tester des modules), atomicité - il y a des temps d'arrêt lors du rechargement des modules, et le sous-système BPF garantit qu'aucun événement n'est manqué (pour être honnête, cela n'est pas vrai pour tous les types de programmes BPF).

La présence de telles capacités fait de BPF un outil universel d'extension du noyau, ce qui se confirme dans la pratique : de plus en plus de nouveaux types de programmes sont ajoutés à BPF, de plus en plus de grandes entreprises utilisent BPF sur des serveurs de combat 24h/7 et XNUMXj/XNUMX, de plus en plus les startups construisent leur activité sur des solutions basées sur BPF. BPF est utilisé partout : pour se protéger contre les attaques DDoS, créer un SDN (par exemple, implémenter des réseaux pour Kubernetes), comme principal outil de traçage du système et collecteur de statistiques, dans les systèmes de détection d'intrusion et les systèmes sandbox, etc.

Terminons ici la partie présentation de l'article et examinons plus en détail la machine virtuelle et l'écosystème BPF.

Digression : utilitaires

Afin de pouvoir exécuter les exemples des sections suivantes, vous aurez peut-être besoin d'un certain nombre d'utilitaires, au moins llvm/clang avec le support bpf et bpftool... Dans le chapitre Outils de développement Vous pouvez lire les instructions d'assemblage des utilitaires, ainsi que votre noyau. Cette section est placée en dessous afin de ne pas perturber l'harmonie de notre présentation.

Registres de machines virtuelles BPF et système d'instructions

L'architecture et le système de commande de BPF ont été développés en tenant compte du fait que les programmes seront écrits en langage C et, après chargement dans le noyau, traduits en code natif. Par conséquent, le nombre de registres et l’ensemble des commandes ont été choisis en tenant compte de l’intersection, au sens mathématique, des capacités des machines modernes. De plus, diverses restrictions ont été imposées aux programmes, par exemple, jusqu'à récemment, il n'était pas possible d'écrire des boucles et des sous-programmes, et le nombre d'instructions était limité à 4096 XNUMX (désormais, les programmes privilégiés peuvent charger jusqu'à un million d'instructions).

BPF dispose de onze registres 64 bits accessibles à l'utilisateur r0-r10 et un compteur de programme. Registre r10 contient un pointeur de trame et est en lecture seule. Les programmes ont accès à une pile de 512 octets au moment de l'exécution et à une quantité illimitée de mémoire partagée sous forme de cartes.

Les programmes BPF sont autorisés à exécuter un ensemble spécifique d'assistants de noyau de type programme et, plus récemment, des fonctions régulières. Chaque fonction appelée peut prendre jusqu'à cinq arguments, passés dans des registres r1-r5, et la valeur de retour est transmise à r0. Il est garanti qu'après le retour de la fonction, le contenu des registres r6-r9 ne changera pas.

Pour une traduction efficace du programme, enregistre r0-r11 car toutes les architectures prises en charge sont mappées de manière unique à des registres réels, en tenant compte des fonctionnalités ABI de l'architecture actuelle. Par exemple, pour x86_64 registres r1-r5, utilisés pour transmettre les paramètres de la fonction, sont affichés sur rdi, rsi, rdx, rcx, r8, qui sont utilisés pour transmettre des paramètres aux fonctions sur x86_64. Par exemple, le code de gauche se traduit par le code de droite comme ceci :

1:  (b7) r1 = 1                    mov    $0x1,%rdi
2:  (b7) r2 = 2                    mov    $0x2,%rsi
3:  (b7) r3 = 3                    mov    $0x3,%rdx
4:  (b7) r4 = 4                    mov    $0x4,%rcx
5:  (b7) r5 = 5                    mov    $0x5,%r8
6:  (85) call pc+1                 callq  0x0000000000001ee8

Le registre r0 également utilisé pour renvoyer le résultat de l'exécution du programme, et dans le registre r1 le programme reçoit un pointeur vers le contexte - selon le type de programme, il peut s'agir, par exemple, d'une structure struct xdp_md (pour XDP) ou structure struct __sk_buff (pour différents programmes réseau) ou structure struct pt_regs (pour différents types de programmes de traçage), etc.

Nous avions donc un ensemble de registres, des assistants du noyau, une pile, un pointeur de contexte et une mémoire partagée sous forme de cartes. Non pas que tout cela soit absolument nécessaire pendant le voyage, mais...

Continuons la description et parlons du système de commande pour travailler avec ces objets. Tous (presque tout) Les instructions BPF ont une taille fixe de 64 bits. Si vous regardez une instruction sur une machine Big Endian 64 bits, vous verrez

BPF pour les plus petits, première partie : BPF étendu

il est Code - c'est l'encodage de l'instruction, Dst/Src sont les codages du récepteur et de la source, respectivement, Off - Indentation signée de 16 bits, et Imm est un entier signé de 32 bits utilisé dans certaines instructions (similaire à la constante cBPF K). Codage Code a l'un des deux types suivants :

BPF pour les plus petits, première partie : BPF étendu

Les classes d'instructions 0, 1, 2, 3 définissent des commandes pour travailler avec la mémoire. Ils sont appelés, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, respectivement. Classes 4, 7 (BPF_ALU, BPF_ALU64) constituent un ensemble d'instructions ALU. Classes 5, 6 (BPF_JMP, BPF_JMP32) contiennent des instructions de saut.

Le plan ultérieur d'étude du système d'instructions BPF est le suivant : au lieu de lister méticuleusement toutes les instructions et leurs paramètres, nous examinerons quelques exemples dans cette section et à partir d'eux, il deviendra clair comment les instructions fonctionnent réellement et comment désassemblez manuellement tout fichier binaire pour BPF. Pour consolider le matériel plus loin dans l'article, nous rencontrerons également des instructions individuelles dans les sections sur le vérificateur, le compilateur JIT, la traduction du BPF classique, ainsi que lors de l'étude des cartes, de l'appel de fonctions, etc.

Lorsque nous parlerons d'instructions individuelles, nous ferons référence aux fichiers de base bpf.h и bpf_common.h, qui définissent les codes numériques des instructions BPF. Lorsque vous étudiez l'architecture par vous-même et/ou analysez des binaires, vous pouvez trouver la sémantique dans les sources suivantes, classées par ordre de complexité : Spécification eBPF non officielle, Guide de référence BPF et XDP, jeu d'instructions, Documentation/réseau/filter.txt et, bien sûr, dans le code source Linux - vérificateur, JIT, interpréteur BPF.

Exemple : démonter le BPF dans votre tête

Regardons un exemple dans lequel nous compilons un programme readelf-example.c et regardez le binaire résultant. Nous dévoilerons le contenu original readelf-example.c ci-dessous, après avoir restauré sa logique à partir des codes binaires :

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

Première colonne en sortie readelf est une indentation et notre programme se compose donc de quatre commandes :

Code Dst Src Off  Imm
b7   0   0   0000 01000000
15   0   1   0100 00000000
b7   0   0   0000 02000000
95   0   0   0000 00000000

Les codes de commande sont égaux b7, 15, b7 и 95. Rappelons que les trois bits de poids faible constituent la classe d'instruction. Dans notre cas, le quatrième bit de toutes les instructions est vide, donc les classes d'instructions sont respectivement 7, 5, 7 et 5. La classe 7 est BPF_ALU64, et 5 est BPF_JMP. Pour les deux classes, le format des instructions est le même (voir ci-dessus) et nous pouvons réécrire notre programme comme ceci (en même temps nous réécrirons les colonnes restantes sous forme humaine) :

Op S  Class   Dst Src Off  Imm
b  0  ALU64   0   0   0    1
1  0  JMP     0   1   1    0
b  0  ALU64   0   0   0    2
9  0  JMP     0   0   0    0

Opération b Classe ALU64 - Est BPF_MOV. Il attribue une valeur au registre de destination. Si le bit est défini s (source), alors la valeur est extraite du registre source, et si, comme dans notre cas, elle n'est pas définie, alors la valeur est extraite du champ Imm. Donc dans les première et troisième instructions nous effectuons l'opération r0 = Imm. De plus, le fonctionnement JMP classe 1 est BPF_JEQ (sauter si égal). Dans notre cas, depuis le moment S est nul, il compare la valeur du registre source avec le champ Imm. Si les valeurs coïncident, alors la transition se produit vers PC + OffPC, comme d'habitude, contient l'adresse de l'instruction suivante. Enfin, le fonctionnement JMP Classe 9 est BPF_EXIT. Cette instruction termine le programme et retourne au noyau r0. Ajoutons une nouvelle colonne à notre tableau :

Op    S  Class   Dst Src Off  Imm    Disassm
MOV   0  ALU64   0   0   0    1      r0 = 1
JEQ   0  JMP     0   1   1    0      if (r1 == 0) goto pc+1
MOV   0  ALU64   0   0   0    2      r0 = 2
EXIT  0  JMP     0   0   0    0      exit

Nous pouvons réécrire cela sous une forme plus pratique :

     r0 = 1
     if (r1 == 0) goto END
     r0 = 2
END:
     exit

Si on se souvient de ce qu'il y a dans le registre r1 le programme reçoit un pointeur vers le contexte depuis le noyau, et dans le registre r0 la valeur est renvoyée au noyau, alors on voit que si le pointeur vers le contexte est zéro, alors on renvoie 1, et sinon - 2. Vérifions que nous avons raison en regardant la source :

$ cat readelf-example.c
int foo(void *ctx)
{
        return ctx ? 2 : 1;
}

Oui, c'est un programme dénué de sens, mais il se traduit par seulement quatre instructions simples.

Exemple d'exception : instruction de 16 octets

Nous avons mentionné plus tôt que certaines instructions occupent plus de 64 bits. Cela s'applique par exemple aux instructions lddw (Code = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — charge un double mot des champs dans le registre Imm. Point est que Imm a une taille de 32 et un double mot fait 64 bits, donc le chargement d'une valeur immédiate de 64 bits dans un registre en une seule instruction de 64 bits ne fonctionnera pas. Pour ce faire, deux instructions adjacentes sont utilisées pour stocker la deuxième partie de la valeur 64 bits dans le champ Imm. Exemple:

$ cat x64.c
long foo(void *ctx)
{
        return 0x11223344aabbccdd;
}
$ clang -target bpf -c x64.c -o x64.o -O2
$ llvm-readelf -x .text x64.o
Hex dump of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

Il n'y a que deux instructions dans un programme binaire :

Binary                                 Disassm
18000000 ddccbbaa 00000000 44332211    r0 = Imm[0]|Imm[1]
95000000 00000000                      exit

Nous nous reverrons avec des instructions lddw, quand on parle de déménagements et de travail avec des cartes.

Exemple : démontage du BPF à l'aide d'outils standards

Nous avons donc appris à lire les codes binaires BPF et sommes prêts à analyser n'importe quelle instruction si nécessaire. Cependant, il convient de dire qu'en pratique, il est plus pratique et plus rapide de désassembler des programmes à l'aide d'outils standards, par exemple :

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 <foo>:
 0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
 2: 95 00 00 00 00 00 00 00 exit

Cycle de vie des objets BPF, système de fichiers bpffs

(J'ai d'abord appris certains des détails décrits dans cette sous-section grâce à le jeûne Alexeï Starovoitov dans Blogue BPF.)

Les objets BPF - programmes et cartes - sont créés à partir de l'espace utilisateur à l'aide de commandes BPF_PROG_LOAD и BPF_MAP_CREATE appel système bpf(2), nous parlerons exactement de la façon dont cela se produit dans la section suivante. Cela crée des structures de données du noyau et pour chacune d'elles refcount (compte de références) est défini sur un et un descripteur de fichier pointant vers l'objet est renvoyé à l'utilisateur. Une fois la poignée fermée refcount l'objet est réduit de un, et lorsqu'il atteint zéro, l'objet est détruit.

Si le programme utilise des cartes, alors refcount ces cartes sont augmentées de un après le chargement du programme, c'est-à-dire leurs descripteurs de fichiers peuvent être fermés du processus utilisateur et toujours refcount ne deviendra pas nul :

BPF pour les plus petits, première partie : BPF étendu

Après avoir chargé avec succès un programme, nous l'attachons généralement à une sorte de générateur d'événements. Par exemple, nous pouvons le mettre sur une interface réseau pour traiter les paquets entrants ou le connecter à certains tracepoint dans le noyau. À ce stade, le compteur de références augmentera également de un et nous pourrons fermer le descripteur de fichier dans le programme de chargement.

Que se passe-t-il si nous arrêtons maintenant le chargeur de démarrage ? Cela dépend du type de générateur d'événements (hook). Tous les hooks réseau existeront une fois le chargeur terminé, ce sont ce qu'on appelle les hooks globaux. Et, par exemple, les programmes de trace seront publiés après la fin du processus qui les a créés (et sont donc appelés locaux, de « local au processus »). Techniquement, les hooks locaux ont toujours un descripteur de fichier correspondant dans l'espace utilisateur et se ferment donc lorsque le processus est fermé, mais pas les hooks globaux. Dans la figure suivante, à l'aide de croix rouges, j'essaie de montrer comment la fin du programme de chargement affecte la durée de vie des objets dans le cas de hooks locaux et globaux.

BPF pour les plus petits, première partie : BPF étendu

Pourquoi y a-t-il une distinction entre les hooks locaux et globaux ? L'exécution de certains types de programmes réseau a du sens sans espace utilisateur, par exemple, imaginez une protection DDoS - le chargeur de démarrage écrit les règles et connecte le programme BPF à l'interface réseau, après quoi le chargeur de démarrage peut se suicider. D'un autre côté, imaginez un programme de trace de débogage que vous avez écrit à genoux en dix minutes - une fois terminé, vous aimeriez qu'il ne reste plus de déchets dans le système, et les hooks locaux s'en assureront.

D'un autre côté, imaginez que vous souhaitiez vous connecter à un point de trace dans le noyau et collecter des statistiques sur plusieurs années. Dans ce cas, vous souhaiterez compléter la partie utilisateur et revenir aux statistiques de temps en temps. Le système de fichiers bpf offre cette opportunité. Il s'agit d'un système de pseudo-fichiers en mémoire uniquement qui permet la création de fichiers faisant référence à des objets BPF et augmentant ainsi refcount objets. Après cela, le chargeur peut se fermer et les objets qu'il a créés resteront vivants.

BPF pour les plus petits, première partie : BPF étendu

La création de fichiers dans des bpff faisant référence à des objets BPF est appelée « épinglage » (comme dans la phrase suivante : « un processus peut épingler un programme ou une carte BPF »). La création d'objets fichier pour les objets BPF a du sens non seulement pour prolonger la durée de vie des objets locaux, mais aussi pour la convivialité des objets globaux - en revenant à l'exemple du programme global de protection DDoS, nous voulons pouvoir venir consulter les statistiques de temps en temps.

Le système de fichiers BPF est généralement monté dans /sys/fs/bpf, mais il peut aussi être monté localement, par exemple, comme ceci :

$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Les noms de système de fichiers sont créés à l'aide de la commande BPF_OBJ_PIN Appel système BPF. Pour illustrer, prenons un programme, compilons-le, téléchargeons-le et épinglons-le sur bpffs. Notre programme ne fait rien d'utile, nous présentons seulement le code pour que vous puissiez reproduire l'exemple :

$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
        return 0;
}

char _license[] __attribute__((section("license"), used)) = "GPL";

Compilons ce programme et créons une copie locale du système de fichiers bpffs:

$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Téléchargeons maintenant notre programme à l'aide de l'utilitaire bpftool et regardez les appels système qui l'accompagnent bpf(2) (quelques lignes non pertinentes supprimées de la sortie strace) :

$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0

Ici, nous avons chargé le programme en utilisant BPF_PROG_LOAD, a reçu un descripteur de fichier du noyau 3 et en utilisant la commande BPF_OBJ_PIN épinglé ce descripteur de fichier en tant que fichier "bpf-mountpoint/test". Après cela, le programme du chargeur de démarrage bpftool a fini de fonctionner, mais notre programme est resté dans le noyau, même si nous ne l'avons attaché à aucune interface réseau :

$ sudo bpftool prog | tail -3
783: xdp  name test  tag 5c8ba0cf164cb46c  gpl
        loaded_at 2020-05-05T13:27:08+0000  uid 0
        xlated 24B  jited 41B  memlock 4096B

Nous pouvons supprimer l'objet fichier normalement unlink(2) et après cela, le programme correspondant sera supprimé :

$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory

Supprimer des objets

En parlant de suppression d'objets, il est nécessaire de préciser qu'après avoir déconnecté le programme du hook (générateur d'événements), aucun nouvel événement ne déclenchera son lancement, cependant, toutes les instances actuelles du programme seront terminées dans l'ordre normal. .

Certains types de programmes BPF vous permettent de remplacer le programme à la volée, c'est-à-dire fournir une atomicité de séquence replace = detach old program, attach new program. Dans ce cas, toutes les instances actives de l'ancienne version du programme termineront leur travail et de nouveaux gestionnaires d'événements seront créés à partir du nouveau programme, et « atomicité » signifie ici qu'aucun événement ne sera manqué.

Attacher des programmes à des sources d'événements

Dans cet article, nous ne décrirons pas séparément la connexion de programmes à des sources d'événements, car il est logique d'étudier cela dans le contexte d'un type spécifique de programme. Cm. exemple ci-dessous, dans lequel nous montrons comment des programmes comme XDP sont connectés.

Manipulation d'objets à l'aide de l'appel système bpf

Programmes BPF

Tous les objets BPF sont créés et gérés depuis l'espace utilisateur à l'aide d'un appel système bpf, ayant le prototype suivant :

#include <linux/bpf.h>

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

Voici l'équipe cmd est une des valeurs de type enum bpf_cmd, attr — un pointeur vers les paramètres d'un programme spécifique et size — taille de l'objet en fonction du pointeur, c'est-à-dire habituellement ceci sizeof(*attr). Dans le noyau 5.8, l'appel système bpf prend en charge 34 commandes différentes, et détermination union bpf_attr occupe 200 lignes. Mais cela ne devrait pas nous intimider, puisque nous nous familiariserons avec les commandes et les paramètres au fil de plusieurs articles.

Commençons par l'équipe BPF_PROG_LOAD, qui crée des programmes BPF - prend un ensemble d'instructions BPF et le charge dans le noyau. Au moment du chargement, le vérificateur est lancé, puis le compilateur JIT et, après une exécution réussie, le descripteur de fichier programme est renvoyé à l'utilisateur. Nous avons vu ce qui lui arrive ensuite dans la section précédente sur le cycle de vie des objets BPF.

Nous allons maintenant écrire un programme personnalisé qui chargera un simple programme BPF, mais nous devons d'abord décider quel type de programme nous voulons charger - nous devrons sélectionner Тип et dans le cadre de ce type, écrivez un programme qui passera le test du vérificateur. Cependant, afin de ne pas compliquer le processus, voici une solution toute faite : nous prendrons un programme comme BPF_PROG_TYPE_XDP, qui renverra la valeur XDP_PASS (ignorer tous les forfaits). En assembleur BPF, cela semble très simple :

r0 = 2
exit

Après avoir décidé que nous allons télécharger, nous pouvons vous dire comment nous allons procéder :

#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

static inline __u64 ptr_to_u64(const void *ptr)
{
        return (__u64) (unsigned long) ptr;
}

int main(void)
{
    struct bpf_insn insns[] = {
        {
            .code = BPF_ALU64 | BPF_MOV | BPF_K,
            .dst_reg = BPF_REG_0,
            .imm = XDP_PASS
        },
        {
            .code = BPF_JMP | BPF_EXIT
        },
    };

    union bpf_attr attr = {
        .prog_type = BPF_PROG_TYPE_XDP,
        .insns     = ptr_to_u64(insns),
        .insn_cnt  = sizeof(insns)/sizeof(insns[0]),
        .license   = ptr_to_u64("GPL"),
    };

    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

Les événements intéressants dans un programme commencent par la définition d'un tableau insns - notre programme BPF en code machine. Dans ce cas, chaque instruction du programme BPF est emballée dans la structure bpf_insn. Premier élément insns est conforme aux instructions r0 = 2, la deuxième - exit.

Retraite. Le noyau définit des macros plus pratiques pour écrire des codes machine et utiliser le fichier d'en-tête du noyau tools/include/linux/filter.h nous pourrions écrire

struct bpf_insn insns[] = {
    BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
    BPF_EXIT_INSN()
};

Mais comme l'écriture de programmes BPF en code natif n'est nécessaire que pour écrire des tests dans le noyau et des articles sur BPF, l'absence de ces macros ne complique pas vraiment la vie du développeur.

Après avoir défini le programme BPF, nous passons à son chargement dans le noyau. Notre ensemble minimaliste de paramètres attr comprend le type de programme, l'ensemble et le nombre d'instructions, la licence requise et le nom "woo", que nous utilisons pour trouver notre programme sur le système après le téléchargement. Le programme, comme promis, est chargé dans le système à l'aide d'un appel système bpf.

A la fin du programme on se retrouve dans une boucle infinie qui simule la charge utile. Sans cela, le programme sera tué par le noyau lorsque le descripteur de fichier que l'appel système nous a renvoyé sera fermé. bpf, et nous ne le verrons pas dans le système.

Eh bien, nous sommes prêts pour les tests. Assemblons et exécutons le programme sous stracepour vérifier que tout fonctionne comme il se doit :

$ clang -g -O2 simple-prog.c -o simple-prog

$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_V
ERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

Tout va bien, bpf(2) nous a rendu la poignée 3 et nous sommes entrés dans une boucle infinie avec pause(). Essayons de trouver notre programme dans le système. Pour ce faire, nous allons aller sur un autre terminal et utiliser l'utilitaire bpftool:

# bpftool prog | grep -A3 woo
390: xdp  name woo  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-31T24:66:44+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        pids simple-prog(10381)

Nous voyons qu'il y a un programme chargé sur le système woo dont l'ID global est 390 et est actuellement en cours simple-prog il y a un descripteur de fichier ouvert pointant vers le programme (et si simple-prog je finirai le travail, alors woo disparaîtra). Comme prévu, le programme woo prend 16 octets - deux instructions - de codes binaires dans l'architecture BPF, mais dans sa forme native (x86_64) cela fait déjà 40 octets. Regardons notre programme dans sa forme originale :

# bpftool prog dump xlated id 390
   0: (b7) r0 = 2
   1: (95) exit

pas de surprises. Regardons maintenant le code généré par le compilateur JIT :

# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
   0:   nopl   0x0(%rax,%rax,1)
   5:   push   %rbp
   6:   mov    %rsp,%rbp
   9:   sub    $0x0,%rsp
  10:   push   %rbx
  11:   push   %r13
  13:   push   %r14
  15:   push   %r15
  17:   pushq  $0x0
  19:   mov    $0x2,%eax
  1e:   pop    %rbx
  1f:   pop    %r15
  21:   pop    %r14
  23:   pop    %r13
  25:   pop    %rbx
  26:   leaveq
  27:   retq

pas très efficace pour exit(2), mais en toute honnêteté, notre programme est trop simple, et pour les programmes non triviaux, le prologue et l'épilogue ajoutés par le compilateur JIT sont bien sûr nécessaires.

Map

Les programmes BPF peuvent utiliser des zones de mémoire structurées accessibles à la fois aux autres programmes BPF et aux programmes de l'espace utilisateur. Ces objets sont appelés cartes et dans cette section nous montrerons comment les manipuler à l'aide d'un appel système. bpf.

Disons tout de suite que les capacités des cartes ne se limitent pas uniquement à l'accès à la mémoire partagée. Il existe des cartes spéciales contenant, par exemple, des pointeurs vers des programmes BPF ou des pointeurs vers des interfaces réseau, des cartes pour travailler avec des événements de performances, etc. Nous n’en parlerons pas ici, afin de ne pas embrouiller le lecteur. En dehors de cela, nous ignorons les problèmes de synchronisation, car cela n’est pas important pour nos exemples. Une liste complète des types de cartes disponibles peut être trouvée dans <linux/bpf.h>, et dans cette section nous prendrons comme exemple le premier type historiquement, la table de hachage BPF_MAP_TYPE_HASH.

Si vous créez une table de hachage, par exemple en C++, vous diriez unordered_map<int,long> woo, qui signifie en russe « J'ai besoin d'une table woo taille illimitée, dont les clés sont de type int, et les valeurs sont du type long" Afin de créer une table de hachage BPF, nous devons faire à peu près la même chose, sauf que nous devons spécifier la taille maximale de la table, et au lieu de spécifier les types de clés et de valeurs, nous devons spécifier leurs tailles en octets. . Pour créer des cartes, utilisez la commande BPF_MAP_CREATE appel système bpf. Regardons un programme plus ou moins minimal qui crée une carte. Après le programme précédent qui charge les programmes BPF, celui-ci devrait vous paraître simple :

$ cat simple-map.c
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

int main(void)
{
    union bpf_attr attr = {
        .map_type = BPF_MAP_TYPE_HASH,
        .key_size = sizeof(int),
        .value_size = sizeof(int),
        .max_entries = 4,
    };
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

Ici, nous définissons un ensemble de paramètres attr, dans lequel nous disons « J'ai besoin d'une table de hachage avec des clés et des valeurs de taille sizeof(int), dans lequel je peux mettre un maximum de quatre éléments." Lors de la création de cartes BPF, vous pouvez spécifier d'autres paramètres, par exemple, de la même manière que dans l'exemple avec le programme, nous avons spécifié le nom de l'objet comme "woo".

Compilons et exécutons le programme :

$ clang -g -O2 simple-map.c -o simple-map
$ sudo strace ./simple-map
execve("./simple-map", ["./simple-map"], 0x7ffd40a27070 /* 14 vars */) = 0
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=4, max_entries=4, map_name="woo", ...}, 72) = 3
pause(

Voici l'appel système bpf(2) nous a renvoyé le numéro de la carte descriptive 3 puis le programme, comme prévu, attend d'autres instructions dans l'appel système pause(2).

Envoyons maintenant notre programme en arrière-plan ou ouvrons un autre terminal et regardons notre objet à l'aide de l'utilitaire bpftool (on peut distinguer notre carte des autres par son nom) :

$ sudo bpftool map
...
114: hash  name woo  flags 0x0
        key 4B  value 4B  max_entries 4  memlock 4096B
...

Le nombre 114 est l'identifiant global de notre objet. N'importe quel programme du système peut utiliser cet ID pour ouvrir une carte existante à l'aide de la commande BPF_MAP_GET_FD_BY_ID appel système bpf.

Nous pouvons maintenant jouer avec notre table de hachage. Regardons son contenu :

$ sudo bpftool map dump id 114
Found 0 elements

Vide. Mettons-y une valeur hash[1] = 1:

$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0

Regardons à nouveau le tableau :

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
Found 1 element

Hourra! Nous avons réussi à ajouter un élément. Notez que nous devons travailler au niveau des octets pour ce faire, puisque bptftool ne sait pas de quel type sont les valeurs de la table de hachage. (Ces connaissances peuvent lui être transférées en utilisant BTF, mais nous en parlerons plus à ce sujet maintenant.)

Comment bpftool lit-il et ajoute-t-il exactement des éléments ? Jetons un coup d'oeil sous le capot :

$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

Nous avons d'abord ouvert la carte par son identifiant global à l'aide de la commande BPF_MAP_GET_FD_BY_ID и bpf(2) nous a renvoyé le descripteur 3. En utilisant ensuite la commande BPF_MAP_GET_NEXT_KEY nous avons trouvé la première clé du tableau en passant NULL comme pointeur vers la clé "précédente". Si nous avons la clé, nous pouvons le faire BPF_MAP_LOOKUP_ELEMqui renvoie une valeur à un pointeur value. L'étape suivante consiste à essayer de trouver l'élément suivant en passant un pointeur vers la clé actuelle, mais notre table ne contient qu'un seul élément et la commande BPF_MAP_GET_NEXT_KEY retourne ENOENT.

D'accord, modifions la valeur par la clé 1, disons que notre logique métier nécessite un enregistrement hash[1] = 2:

$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0

Comme prévu, c'est très simple : la commande BPF_MAP_GET_FD_BY_ID ouvre notre carte par ID, et la commande BPF_MAP_UPDATE_ELEM écrase l'élément.

Ainsi, après avoir créé une table de hachage à partir d’un programme, nous pouvons lire et écrire son contenu à partir d’un autre. Notez que si nous pouvions le faire à partir de la ligne de commande, alors n'importe quel autre programme du système peut le faire. En plus des commandes décrites ci-dessus, pour travailler avec des cartes depuis l'espace utilisateur, Ce qui suit:

  • BPF_MAP_LOOKUP_ELEM: trouver la valeur par clé
  • BPF_MAP_UPDATE_ELEM: mettre à jour/créer de la valeur
  • BPF_MAP_DELETE_ELEM: retirer la clé
  • BPF_MAP_GET_NEXT_KEY: trouver la clé suivante (ou première)
  • BPF_MAP_GET_NEXT_ID: permet de parcourir toutes les cartes existantes, c'est comme ça que ça marche bpftool map
  • BPF_MAP_GET_FD_BY_ID: ouvre une carte existante par son identifiant global
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: met à jour atomiquement la valeur d'un objet et renvoie l'ancienne
  • BPF_MAP_FREEZE: rendre la carte immuable depuis l'espace utilisateur (cette opération ne peut pas être annulée)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: opérations de masse. Par exemple, BPF_MAP_LOOKUP_AND_DELETE_BATCH - c'est le seul moyen fiable de lire et de réinitialiser toutes les valeurs de la carte

Toutes ces commandes ne fonctionnent pas pour tous les types de cartes, mais en général, travailler avec d'autres types de cartes à partir de l'espace utilisateur ressemble exactement à travailler avec des tables de hachage.

Par souci d'ordre, terminons nos expériences sur les tables de hachage. Vous souvenez-vous que nous avons créé une table pouvant contenir jusqu'à quatre clés ? Ajoutons quelques éléments supplémentaires :

$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0

Jusqu'ici, tout va bien:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
key: 02 00 00 00  value: 01 00 00 00
key: 04 00 00 00  value: 01 00 00 00
key: 03 00 00 00  value: 01 00 00 00
Found 4 elements

Essayons d'en ajouter un de plus :

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long

Comme prévu, nous n’avons pas réussi. Examinons l'erreur plus en détail :

$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

Tout va bien : comme prévu, l'équipe BPF_MAP_UPDATE_ELEM essaie de créer une nouvelle cinquième clé, mais plante E2BIG.

Ainsi, nous pouvons créer et charger des programmes BPF, ainsi que créer et gérer des cartes depuis l'espace utilisateur. Il est maintenant logique de voir comment nous pouvons utiliser les cartes des programmes BPF eux-mêmes. Nous pourrions en parler dans le langage des programmes difficiles à lire dans les codes de macro machine, mais en fait le moment est venu de montrer comment les programmes BPF sont réellement écrits et maintenus - en utilisant libbpf.

(Pour les lecteurs insatisfaits de l'absence d'exemple de bas niveau : nous analyserons en détail les programmes qui utilisent des cartes et des fonctions d'assistance créées à l'aide de libbpf et vous dire ce qui se passe au niveau de l'instruction. Pour les lecteurs insatisfaits beaucoup, nous avons ajouté exemple à l'endroit approprié dans l'article.)

Écrire des programmes BPF avec libbpf

Écrire des programmes BPF à l’aide de codes machine ne peut être intéressant que la première fois, puis la satiété s’installe. À ce moment, vous devez porter votre attention sur llvm, qui dispose d'un backend pour générer du code pour l'architecture BPF, ainsi que d'une bibliothèque libbpf, qui permet d'écrire le côté utilisateur des applications BPF et de charger le code des programmes BPF générés à l'aide de llvm/clang.

En fait, comme nous le verrons dans cet article et dans les suivants, libbpf fait beaucoup de travail sans lui (ou des outils similaires - iproute2, libbcc, libbpf-go, etc.) il est impossible de vivre. L'une des caractéristiques phares du projet libbpf est BPF CO-RE (Compile Once, Run Everywhere) - un projet qui vous permet d'écrire des programmes BPF portables d'un noyau à un autre, avec la possibilité de s'exécuter sur différentes API (par exemple, lorsque la structure du noyau change de version à la version). Afin de pouvoir travailler avec CO-RE, votre noyau doit être compilé avec le support BTF (nous décrivons comment faire cela dans la section Outils de développement. Vous pouvez vérifier si votre noyau est construit avec BTF ou pas très simplement - par la présence du fichier suivant :

$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinux

Ce fichier stocke des informations sur tous les types de données utilisés dans le noyau et est utilisé dans tous nos exemples utilisant libbpf. Nous parlerons en détail de CO-RE dans le prochain article, mais dans celui-ci, construisez-vous simplement un noyau avec CONFIG_DEBUG_INFO_BTF.

bibliothèque libbpf vit directement dans l'annuaire tools/lib/bpf le noyau et son développement s'effectue via la liste de diffusion [email protected]. Cependant, un référentiel séparé est maintenu pour les besoins des applications vivant en dehors du noyau. https://github.com/libbpf/libbpf dans lequel la bibliothèque du noyau est mise en miroir pour un accès en lecture plus ou moins telle quelle.

Dans cette section, nous verrons comment créer un projet qui utilise libbpf, écrivons plusieurs programmes de test (plus ou moins dénués de sens) et analysons en détail comment tout cela fonctionne. Cela nous permettra d'expliquer plus facilement dans les sections suivantes exactement comment les programmes BPF interagissent avec les cartes, les assistants du noyau, les BTF, etc.

Généralement, les projets utilisent libbpf ajoutez un dépôt GitHub en tant que sous-module git, nous ferons de même :

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.

Aller à libbpf très simplement:

$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pc

Notre prochain plan dans cette section est le suivant : nous allons écrire un programme BPF comme BPF_PROG_TYPE_XDP, le même que dans l'exemple précédent, mais en C, on le compile en utilisant clang, et écrivez un programme d'assistance qui le chargera dans le noyau. Dans les sections suivantes, nous développerons les capacités du programme BPF et du programme assistant.

Exemple : créer une application à part entière à l'aide de libbpf

Pour commencer, nous utilisons le fichier /sys/kernel/btf/vmlinux, qui a été mentionné ci-dessus, et créez son équivalent sous la forme d'un fichier d'en-tête :

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

Ce fichier stockera toutes les structures de données disponibles dans notre noyau, par exemple, voici comment l'en-tête IPv4 est défini dans le noyau :

$ grep -A 12 'struct iphdr {' vmlinux.h
struct iphdr {
    __u8 ihl: 4;
    __u8 version: 4;
    __u8 tos;
    __be16 tot_len;
    __be16 id;
    __be16 frag_off;
    __u8 ttl;
    __u8 protocol;
    __sum16 check;
    __be32 saddr;
    __be32 daddr;
};

Nous allons maintenant écrire notre programme BPF en C :

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
        return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Même si notre programme s’est avéré très simple, nous devons néanmoins prêter attention à de nombreux détails. Tout d'abord, le premier fichier d'en-tête que nous incluons est vmlinux.h, que nous venons de générer en utilisant bpftool btf dump - désormais, nous n'avons plus besoin d'installer le package kernel-headers pour découvrir à quoi ressemblent les structures du noyau. Le fichier d'en-tête suivant nous vient de la bibliothèque libbpf. Il ne nous reste plus qu'à définir la macro SEC, qui envoie le caractère à la section appropriée du fichier objet ELF. Notre programme est contenu dans la section xdp/simple, où avant la barre oblique nous définissons le type de programme BPF - c'est la convention utilisée dans libbpf, en fonction du nom de la section, il remplacera le type correct au démarrage bpf(2). Le programme BPF lui-même est C - très simple et se compose d'une seule ligne return XDP_PASS. Enfin, une section distincte "license" contient le nom de la licence.

Nous pouvons compiler notre programme en utilisant llvm/clang, version >= 10.0.0, ou mieux encore, supérieure (voir section Outils de développement):

$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o

Parmi les fonctionnalités intéressantes : nous indiquons l’architecture cible -target bpf et le chemin vers les en-têtes libbpf, que nous avons récemment installé. N'oubliez pas non plus -O2, sans cette option, vous pourriez avoir des surprises à l'avenir. Regardons notre code, avons-nous réussi à écrire le programme que nous voulions ?

$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       r0 = 2
       1:       exit

Oui, ça a marché ! Maintenant, nous avons un fichier binaire avec le programme et nous voulons créer une application qui le chargera dans le noyau. A cet effet, la bibliothèque libbpf nous offre deux options : utiliser une API de niveau inférieur ou une API de niveau supérieur. Nous emprunterons la deuxième voie, car nous voulons apprendre à écrire, charger et connecter des programmes BPF avec un minimum d'effort pour leur étude ultérieure.

Tout d’abord, nous devons générer le « squelette » de notre programme à partir de son binaire en utilisant le même utilitaire bpftool — le couteau suisse du monde BPF (que l'on peut prendre au pied de la lettre, puisque Daniel Borkman, l'un des créateurs et mainteneurs de BPF, est suisse) :

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h

En fichier xdp-simple.skel.h contient le code binaire de notre programme et les fonctions de gestion - chargement, attachement, suppression de notre objet. Dans notre cas simple, cela semble exagéré, mais cela fonctionne également dans le cas où le fichier objet contient de nombreux programmes et cartes BPF et pour charger cet ELF géant, il nous suffit de générer le squelette et d'appeler une ou deux fonctions depuis l'application personnalisée que nous avons. écrivent Passons à autre chose maintenant.

À proprement parler, notre programme de chargement est trivial :

#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    pause();

    xdp_simple_bpf__destroy(obj);
}

il est struct xdp_simple_bpf défini dans le fichier xdp-simple.skel.h et décrit notre fichier objet :

struct xdp_simple_bpf {
    struct bpf_object_skeleton *skeleton;
    struct bpf_object *obj;
    struct {
        struct bpf_program *simple;
    } progs;
    struct {
        struct bpf_link *simple;
    } links;
};

On peut voir ici les traces d'une API de bas niveau : la structure struct bpf_program *simple и struct bpf_link *simple. La première structure décrit spécifiquement notre programme, écrite dans la section xdp/simple, et le second décrit comment le programme se connecte à la source de l'événement.

Fonction xdp_simple_bpf__open_and_load, ouvre un objet ELF, l'analyse, crée toutes les structures et sous-structures (outre le programme, ELF contient également d'autres sections - données, données en lecture seule, informations de débogage, licence, etc.), puis le charge dans le noyau à l'aide d'un système appel bpf, que nous pouvons vérifier en compilant et en exécutant le programme :

$ clang -O2 -I ./libbpf/src/root/usr/include/ xdp-simple.c -o xdp-simple ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_BTF_LOAD, 0x7ffdb8fd9670, 120)  = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0xdfd580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 8, 0), prog_flags=0, prog_name="simple", prog_ifindex=0, expected_attach_type=0x25 /* BPF_??? */, ...}, 120) = 4

Regardons maintenant notre programme utilisant bpftool. Trouvons sa carte d'identité :

# bpftool p | grep -A4 simple
463: xdp  name simple  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-01T01:59:49+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        btf_id 185
        pids xdp-simple(16498)

et dump (nous utilisons une forme abrégée de la commande bpftool prog dump xlated):

# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
   0: (b7) r0 = 2
   1: (95) exit

Quelque chose de nouveau! Le programme a imprimé des morceaux de notre fichier source C. Cela a été fait par la bibliothèque libbpf, qui a trouvé la section de débogage dans le binaire, l'a compilé dans un objet BTF, l'a chargé dans le noyau en utilisant BPF_BTF_LOAD, puis a spécifié le descripteur de fichier résultant lors du chargement du programme avec la commande BPG_PROG_LOAD.

Assistants du noyau

Les programmes BPF peuvent exécuter des fonctions « externes » – des assistants du noyau. Ces fonctions d'assistance permettent aux programmes BPF d'accéder aux structures du noyau, de gérer les cartes et également de communiquer avec le « monde réel » - créer des événements de performances, contrôler le matériel (par exemple, rediriger les paquets), etc.

Exemple : bpf_get_smp_processor_id

Dans le cadre du paradigme « apprendre par l’exemple », considérons l’une des fonctions d’assistance, bpf_get_smp_processor_id(), certain dans le fichier kernel/bpf/helpers.c. Il renvoie le numéro du processeur sur lequel s'exécute le programme BPF qui l'a appelé. Mais nous ne sommes pas tant intéressés par sa sémantique que par le fait que sa mise en œuvre tient sur une seule ligne :

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Les définitions des fonctions d'assistance BPF sont similaires aux définitions des appels système Linux. Ici, par exemple, une fonction est définie sans argument. (Une fonction qui prend, disons, trois arguments est définie à l'aide de la macro BPF_CALL_3. Le nombre maximum d'arguments est de cinq.) Cependant, il ne s'agit que de la première partie de la définition. La deuxième partie consiste à définir la structure du type struct bpf_func_proto, qui contient une description de la fonction d'assistance que le vérificateur comprend :

const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
    .func     = bpf_get_smp_processor_id,
    .gpl_only = false,
    .ret_type = RET_INTEGER,
};

Enregistrement des fonctions d'assistance

Pour que les programmes BPF d'un type particulier puissent utiliser cette fonction, ils doivent l'enregistrer, par exemple pour le type BPF_PROG_TYPE_XDP une fonction est définie dans le noyau xdp_func_proto, qui détermine à partir de l'ID de la fonction d'assistance si XDP prend en charge cette fonction ou non. Notre fonction est soutient le:

static const struct bpf_func_proto *
xdp_func_proto(enum bpf_func_id func_id, const struct bpf_prog *prog)
{
    switch (func_id) {
    ...
    case BPF_FUNC_get_smp_processor_id:
        return &bpf_get_smp_processor_id_proto;
    ...
    }
}

Les nouveaux types de programmes BPF sont "définis" dans le fichier include/linux/bpf_types.h en utilisant une macro BPF_PROG_TYPE. Défini entre guillemets car il s'agit d'une définition logique, et en termes de langage C, la définition de tout un ensemble de structures concrètes se produit à d'autres endroits. En particulier, dans le dossier kernel/bpf/verifier.c toutes les définitions du fichier bpf_types.h sont utilisés pour créer un ensemble de structures bpf_verifier_ops[]:

static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type) 
    [_id] = & _name ## _verifier_ops,
#include <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

Autrement dit, pour chaque type de programme BPF, un pointeur vers une structure de données du type est défini struct bpf_verifier_ops, qui est initialisé avec la valeur _name ## _verifier_ops, c'est à dire., xdp_verifier_ops pour xdp. Structure xdp_verifier_ops déterminé par dans le fichier net/core/filter.c comme suit:

const struct bpf_verifier_ops xdp_verifier_ops = {
    .get_func_proto     = xdp_func_proto,
    .is_valid_access    = xdp_is_valid_access,
    .convert_ctx_access = xdp_convert_ctx_access,
    .gen_prologue       = bpf_noop_prologue,
};

Ici, nous voyons notre fonction familière xdp_func_proto, qui exécutera le vérificateur chaque fois qu'il rencontrera un défi certains fonctions à l'intérieur d'un programme BPF, voir verifier.c.

Voyons comment un programme BPF hypothétique utilise la fonction bpf_get_smp_processor_id. Pour ce faire, nous réécrivons le programme de notre section précédente comme suit :

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
    if (bpf_get_smp_processor_id() != 0)
        return XDP_DROP;
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Symbole bpf_get_smp_processor_id déterminé par в <bpf/bpf_helper_defs.h> Bibliothèque libbpf comme

static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;

c'est bpf_get_smp_processor_id est un pointeur de fonction dont la valeur est 8, où 8 est la valeur BPF_FUNC_get_smp_processor_id типа enum bpf_fun_id, qui nous est défini dans le fichier vmlinux.h (déposer bpf_helper_defs.h dans le noyau est généré par un script, donc les nombres « magiques » sont ok). Cette fonction ne prend aucun argument et renvoie une valeur de type __u32. Lorsque nous l'exécutons dans notre programme, clang génère une instruction BPF_CALL "le bon genre" Compilons le programme et regardons la section xdp/simple:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ llvm-objdump -D --section=xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       bf 01 00 00 00 00 00 00 r1 = r0
       2:       67 01 00 00 20 00 00 00 r1 <<= 32
       3:       77 01 00 00 20 00 00 00 r1 >>= 32
       4:       b7 00 00 00 02 00 00 00 r0 = 2
       5:       15 01 01 00 00 00 00 00 if r1 == 0 goto +1 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

0000000000000038 <LBB0_2>:
       7:       95 00 00 00 00 00 00 00 exit

Dans la première ligne, nous voyons des instructions call, paramètre IMM qui est égal à 8, et SRC_REG - zéro. Selon l'accord ABI utilisé par le vérificateur, il s'agit d'un appel à la fonction d'assistance numéro huit. Une fois lancé, la logique est simple. Valeur de retour du registre r0 copié vers r1 et aux lignes 2,3, il est converti en type u32 — les 32 bits supérieurs sont effacés. Aux lignes 4,5,6,7 on retourne 2 (XDP_PASS) ou 1 (XDP_DROP) selon que la fonction d'assistance de la ligne 0 a renvoyé une valeur nulle ou non nulle.

Testons-nous : chargez le programme et regardez le résultat bpftool prog dump xlated:

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914

$ sudo bpftool p | grep simple
523: xdp  name simple  tag 44c38a10c657e1b0  gpl
        pids xdp-simple(10915)

$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
   0: (85) call bpf_get_smp_processor_id#114128
   1: (bf) r1 = r0
   2: (67) r1 <<= 32
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

Ok, le vérificateur a trouvé le bon kernel-helper.

Exemple : passer des arguments et enfin exécuter le programme !

Toutes les fonctions d'assistance au niveau de l'exécution ont un prototype

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

Les paramètres des fonctions d'assistance sont transmis dans des registres r1-r5, et la valeur est renvoyée dans le registre r0. Aucune fonction ne prend plus de cinq arguments et leur prise en charge ne devrait pas être ajoutée à l'avenir.

Jetons un coup d'œil au nouvel assistant du noyau et à la manière dont BPF transmet les paramètres. Réécrivons xdp-simple.bpf.c comme suit (le reste des lignes n'a pas changé) :

SEC("xdp/simple")
int simple(void *ctx)
{
    bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
    return XDP_PASS;
}

Notre programme imprime le numéro du CPU sur lequel il fonctionne. Compilons-le et regardons le code :

$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       r1 = 10
       1:       *(u16 *)(r10 - 8) = r1
       2:       r1 = 8441246879787806319 ll
       4:       *(u64 *)(r10 - 16) = r1
       5:       r1 = 2334956330918245746 ll
       7:       *(u64 *)(r10 - 24) = r1
       8:       call 8
       9:       r1 = r10
      10:       r1 += -24
      11:       r2 = 18
      12:       r3 = r0
      13:       call 6
      14:       r0 = 2
      15:       exit

Aux lignes 0 à 7, nous écrivons la chaîne running on CPU%un, puis à la ligne 8, nous exécutons le système familier bpf_get_smp_processor_id. Aux lignes 9 à 12, nous préparons les arguments d'assistance bpf_printk - les registres r1, r2, r3. Pourquoi y en a-t-il trois et non deux ? Parce que bpf_printkc'est un wrapper de macro autour du véritable assistant bpf_trace_printk, qui doit transmettre la taille de la chaîne de format.

Ajoutons maintenant quelques lignes à xdp-simple.cpour que notre programme se connecte à l'interface lo et vraiment commencé !

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    __u32 flags = XDP_FLAGS_SKB_MODE;
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    bpf_set_link_xdp_fd(1, -1, flags);
    bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);

cleanup:
    xdp_simple_bpf__destroy(obj);
}

Ici, nous utilisons la fonction bpf_set_link_xdp_fd, qui connecte les programmes BPF de type XDP aux interfaces réseau. Nous avons codé en dur le numéro d'interface lo, qui vaut toujours 1. Nous exécutons la fonction deux fois pour détacher d'abord l'ancien programme s'il était attaché. Remarquez que maintenant nous n'avons plus besoin de défi pause ou une boucle infinie : notre programme de chargement se terminera, mais le programme BPF ne sera pas tué puisqu'il est connecté à la source de l'événement. Après un téléchargement et une connexion réussis, le programme sera lancé pour chaque paquet réseau arrivant à lo.

Téléchargeons le programme et regardons l'interface lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 669

Le programme que nous avons téléchargé a l'ID 669 et nous voyons le même ID sur l'interface lo. Nous enverrons quelques colis à 127.0.0.1 (demande + réponse) :

$ ping -c1 localhost

et maintenant regardons le contenu du fichier virtuel de débogage /sys/kernel/debug/tracing/trace_pipe, dans lequel bpf_printk écrit ses messages :

# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0

Deux colis ont été repérés lo et traité sur CPU0 - notre premier programme BPF à part entière et dénué de sens a fonctionné !

Il est à noter que bpf_printk Ce n'est pas pour rien qu'il écrit dans le fichier de débogage : ce n'est pas l'assistant le plus performant à utiliser en production, mais notre objectif était de montrer quelque chose de simple.

Accéder aux cartes des programmes BPF

Exemple : utiliser une carte du programme BPF

Dans les sections précédentes, nous avons appris comment créer et utiliser des cartes à partir de l'espace utilisateur, et regardons maintenant la partie noyau. Commençons, comme d'habitude, par un exemple. Réécrivons notre programme xdp-simple.bpf.c comme suit:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 8);
    __type(key, u32);
    __type(value, u64);
} woo SEC(".maps");

SEC("xdp/simple")
int simple(void *ctx)
{
    u32 key = bpf_get_smp_processor_id();
    u32 *val;

    val = bpf_map_lookup_elem(&woo, &key);
    if (!val)
        return XDP_ABORTED;

    *val += 1;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Au début du programme, nous avons ajouté une définition de carte woo: Il s'agit d'un tableau à 8 éléments qui stocke des valeurs comme u64 (en C, nous définirions un tableau tel que u64 woo[8]). Dans un programme "xdp/simple" nous obtenons le numéro de processeur actuel dans une variable key puis en utilisant la fonction d'assistance bpf_map_lookup_element nous obtenons un pointeur vers l'entrée correspondante dans le tableau, que nous augmentons de un. Traduit en russe : nous calculons des statistiques sur le processeur qui a traité les paquets entrants. Essayons d'exécuter le programme :

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple

Vérifions qu'elle est connectée à lo et envoie quelques paquets :

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 108

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done

Examinons maintenant le contenu du tableau :

$ sudo bpftool map dump name woo
[
    { "key": 0, "value": 0 },
    { "key": 1, "value": 400 },
    { "key": 2, "value": 0 },
    { "key": 3, "value": 0 },
    { "key": 4, "value": 0 },
    { "key": 5, "value": 0 },
    { "key": 6, "value": 0 },
    { "key": 7, "value": 46400 }
]

Presque tous les processus ont été traités sur CPU7. Ce n'est pas important pour nous, l'essentiel est que le programme fonctionne et que nous comprenions comment accéder aux cartes des programmes BPF - en utilisant хелперов bpf_mp_*.

Indice mystique

Ainsi, nous pouvons accéder à la carte depuis le programme BPF en utilisant des appels comme

val = bpf_map_lookup_elem(&woo, &key);

où ressemble la fonction d'assistance

void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)

mais nous passons un pointeur &woo à une structure sans nom struct { ... }...

Si nous regardons l'assembleur du programme, nous voyons que la valeur &woo n'est pas réellement défini (ligne 4):

llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
...

et est contenu dans les déménagements :

$ llvm-readelf -r xdp-simple.bpf.o | head -4

Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

Mais si nous regardons le programme déjà chargé, nous voyons un pointeur vers la bonne carte (ligne 4) :

$ sudo bpftool prog dump x name simple
int simple(void *ctx):
   0: (85) call bpf_get_smp_processor_id#114128
   1: (63) *(u32 *)(r10 -4) = r0
   2: (bf) r2 = r10
   3: (07) r2 += -4
   4: (18) r1 = map[id:64]
...

Ainsi, nous pouvons conclure qu'au moment du lancement de notre programme de chargement, le lien vers &woo a été remplacé par quelque chose avec une bibliothèque libbpf. Nous allons d'abord regarder le résultat strace:

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=8, max_entries=8, map_name="woo", ...}, 120) = 4
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="simple", ...}, 120) = 5

On voit que libbpf créé une carte woo puis j'ai téléchargé notre programme simple. Regardons de plus près comment nous chargeons le programme :

  • appel xdp_simple_bpf__open_and_load de fichier xdp-simple.skel.h
  • qui cause xdp_simple_bpf__load de fichier xdp-simple.skel.h
  • qui cause bpf_object__load_skeleton de fichier libbpf/src/libbpf.c
  • qui cause bpf_object__load_xattr de libbpf/src/libbpf.c

La dernière fonction, entre autres, appellera bpf_object__create_maps, qui crée ou ouvre des cartes existantes, les transformant en descripteurs de fichiers. (C'est là qu'on voit BPF_MAP_CREATE dans la sortie strace.) Ensuite, la fonction est appelée bpf_object__relocate et c'est elle qui nous intéresse, puisqu'on se souvient de ce qu'on a vu woo dans le tableau des déménagements. En l'explorant, on se retrouve finalement dans la fonction bpf_program__relocate, qui et s'occupe des déplacements de cartes:

case RELO_LD64:
    insn[0].src_reg = BPF_PSEUDO_MAP_FD;
    insn[0].imm = obj->maps[relo->map_idx].fd;
    break;

Alors on suit nos instructions

18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll

et remplacez le registre source par BPF_PSEUDO_MAP_FD, et le premier IMM au descripteur de fichier de notre carte et, s'il est égal à, par exemple, 0xdeadbeef, alors en conséquence, nous recevrons l'instruction

18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll

C'est ainsi que les informations cartographiques sont transférées vers un programme BPF chargé spécifique. Dans ce cas, la carte peut être créée en utilisant BPF_MAP_CREATE, et ouvert par ID en utilisant BPF_MAP_GET_FD_BY_ID.

Total, lors de l'utilisation libbpf l'algorithme est le suivant :

  • lors de la compilation, des enregistrements sont créés dans la table de relocalisation pour les liens vers les cartes
  • libbpf ouvre le livre d'objets ELF, trouve toutes les cartes utilisées et crée des descripteurs de fichiers pour elles
  • les descripteurs de fichiers sont chargés dans le noyau dans le cadre de l'instruction LD64

Comme vous pouvez l’imaginer, il y a encore beaucoup à venir et nous devrons nous pencher sur l’essentiel. Heureusement, nous avons une idée : nous avons noté la signification BPF_PSEUDO_MAP_FD dans le registre source et nous pourrons l'enterrer, ce qui nous mènera au saint de tous les saints - kernel/bpf/verifier.c, où une fonction avec un nom distinctif remplace un descripteur de fichier par l'adresse d'une structure de type struct bpf_map:

static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
    ...

    f = fdget(insn[0].imm);
    map = __bpf_map_get(f);
    if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
        addr = (unsigned long)map;
    }
    insn[0].imm = (u32)addr;
    insn[1].imm = addr >> 32;

(le code complet peut être trouvé lien). Nous pouvons donc étendre notre algorithme :

  • lors du chargement du programme, le vérificateur vérifie l'utilisation correcte de la carte et écrit l'adresse de la structure correspondante struct bpf_map

Lors du téléchargement du binaire ELF en utilisant libbpf Il se passe beaucoup plus de choses, mais nous en discuterons dans d'autres articles.

Chargement de programmes et de cartes sans libbpf

Comme promis, voici un exemple pour les lecteurs qui veulent savoir comment créer et charger un programme utilisant des cartes, sans aide libbpf. Cela peut être utile lorsque vous travaillez dans un environnement pour lequel vous ne pouvez pas créer de dépendances, ni enregistrer chaque bit, ni écrire un programme comme ply, qui génère du code binaire BPF à la volée.

Pour faciliter le suivi de la logique, nous allons réécrire notre exemple à ces fins xdp-simple. Le code complet et légèrement développé du programme discuté dans cet exemple peut être trouvé dans ce essence.

La logique de notre application est la suivante :

  • créer une carte de type BPF_MAP_TYPE_ARRAY en utilisant la commande BPF_MAP_CREATE,
  • créer un programme qui utilise cette carte,
  • connecter le programme à l'interface lo,

qui se traduit par humain par

int main(void)
{
    int map_fd, prog_fd;

    map_fd = map_create();
    if (map_fd < 0)
        err(1, "bpf: BPF_MAP_CREATE");

    prog_fd = prog_load(map_fd);
    if (prog_fd < 0)
        err(1, "bpf: BPF_PROG_LOAD");

    xdp_attach(1, prog_fd);
}

il est map_create crée une carte de la même manière que nous l'avons fait dans le premier exemple à propos de l'appel système bpf - "noyau, s'il te plaît, fais-moi une nouvelle carte sous la forme d'un tableau de 8 éléments comme __u64 et redonne-moi le descripteur du fichier » :

static int map_create()
{
    union bpf_attr attr;

    memset(&attr, 0, sizeof(attr));
    attr.map_type = BPF_MAP_TYPE_ARRAY,
    attr.key_size = sizeof(__u32),
    attr.value_size = sizeof(__u64),
    attr.max_entries = 8,
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    return syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
}

Le programme est également facile à charger :

static int prog_load(int map_fd)
{
    union bpf_attr attr;
    struct bpf_insn insns[] = {
        ...
    };

    memset(&attr, 0, sizeof(attr));
    attr.prog_type = BPF_PROG_TYPE_XDP;
    attr.insns     = ptr_to_u64(insns);
    attr.insn_cnt  = sizeof(insns)/sizeof(insns[0]);
    attr.license   = ptr_to_u64("GPL");
    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    return syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
}

La partie délicate prog_load est la définition de notre programme BPF comme un ensemble de structures struct bpf_insn insns[]. Mais comme nous utilisons un programme que nous avons en C, nous pouvons tricher un peu :

$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
       7:       b7 01 00 00 00 00 00 00 r1 = 0
       8:       15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2>
       9:       61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
      10:       07 01 00 00 01 00 00 00 r1 += 1
      11:       63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
      12:       b7 01 00 00 02 00 00 00 r1 = 2

0000000000000068 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

Au total, nous devons écrire 14 instructions sous forme de structures comme struct bpf_insn (conseil: prenez la décharge d'en haut, relisez la section instructions, ouvrez linux/bpf.h и linux/bpf_common.h et essaie de déterminer struct bpf_insn insns[] tout seul):

struct bpf_insn insns[] = {
    /* 85 00 00 00 08 00 00 00 call 8 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 8,
    },

    /* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
    {
        .code = BPF_MEM | BPF_STX,
        .off = -4,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_10,
    },

    /* bf a2 00 00 00 00 00 00 r2 = r10 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_10,
        .dst_reg = BPF_REG_2,
    },

    /* 07 02 00 00 fc ff ff ff r2 += -4 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_2,
        .imm = -4,
    },

    /* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
    {
        .code = BPF_LD | BPF_DW | BPF_IMM,
        .src_reg = BPF_PSEUDO_MAP_FD,
        .dst_reg = BPF_REG_1,
        .imm = map_fd,
    },
    { }, /* placeholder */

    /* 85 00 00 00 01 00 00 00 call 1 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 1,
    },

    /* b7 01 00 00 00 00 00 00 r1 = 0 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 0,
    },

    /* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

    /* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
    {
        .code = BPF_MEM | BPF_LDX,
        .off = 0,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_1,
    },

    /* 07 01 00 00 01 00 00 00 r1 += 1 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 1,
    },

    /* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
    {
        .code = BPF_MEM | BPF_STX,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* b7 01 00 00 02 00 00 00 r1 = 2 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 2,
    },

    /* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* 95 00 00 00 00 00 00 00 exit */
    {
        .code = BPF_JMP | BPF_EXIT
    },
};

Un exercice pour ceux qui ne l'ont pas écrit eux-mêmes - trouvez map_fd.

Il reste encore une partie non divulguée dans notre programme - xdp_attach. Malheureusement, des programmes comme XDP ne peuvent pas être connectés à l'aide d'un appel système. bpf. Les personnes qui ont créé BPF et XDP appartenaient à la communauté Linux en ligne, ce qui signifie qu'ils ont utilisé celui qui leur était le plus familier (mais pas celui qui leur était le plus familier). normal people) interface pour interagir avec le noyau : sockets netlink, voir également RFC3549. La manière la plus simple de mettre en œuvre xdp_attach copie le code de libbpf, à savoir, à partir du fichier netlink.c, c'est ce que nous avons fait, en le raccourcissant un peu :

Bienvenue dans le monde des sockets netlink

Ouvrir un type de socket netlink NETLINK_ROUTE:

int netlink_open(__u32 *nl_pid)
{
    struct sockaddr_nl sa;
    socklen_t addrlen;
    int one = 1, ret;
    int sock;

    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;

    sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
    if (sock < 0)
        err(1, "socket");

    if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
        warnx("netlink error reporting not supported");

    if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
        err(1, "bind");

    addrlen = sizeof(sa);
    if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
        err(1, "getsockname");

    *nl_pid = sa.nl_pid;
    return sock;
}

Nous lisons depuis cette socket :

static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
    bool multipart = true;
    struct nlmsgerr *errm;
    struct nlmsghdr *nh;
    char buf[4096];
    int len, ret;

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->nlmsg_pid != nl_pid)
                errx(1, "wrong pid");
            if (nh->nlmsg_seq != seq)
                errx(1, "INVSEQ");
            if (nh->nlmsg_flags & NLM_F_MULTI)
                multipart = true;
            switch (nh->nlmsg_type) {
                case NLMSG_ERROR:
                    errm = (struct nlmsgerr *)NLMSG_DATA(nh);
                    if (!errm->error)
                        continue;
                    ret = errm->error;
                    // libbpf_nla_dump_errormsg(nh); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

Enfin, voici notre fonction qui ouvre une socket et lui envoie un message spécial contenant un descripteur de fichier :

static int xdp_attach(int ifindex, int prog_fd)
{
    int sock, seq = 0, ret;
    struct nlattr *nla, *nla_xdp;
    struct {
        struct nlmsghdr  nh;
        struct ifinfomsg ifinfo;
        char             attrbuf[64];
    } req;
    __u32 nl_pid = 0;

    sock = netlink_open(&nl_pid);
    if (sock < 0)
        return sock;

    memset(&req, 0, sizeof(req));
    req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
    req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    req.nh.nlmsg_type = RTM_SETLINK;
    req.nh.nlmsg_pid = 0;
    req.nh.nlmsg_seq = ++seq;
    req.ifinfo.ifi_family = AF_UNSPEC;
    req.ifinfo.ifi_index = ifindex;

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* add XDP fd */
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FD;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
    nla->nla_len += nla_xdp->nla_len;

    /* if user passed in any flags, add those too */
    __u32 flags = XDP_FLAGS_SKB_MODE;
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FLAGS;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
    nla->nla_len += nla_xdp->nla_len;

    req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);

    if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
        err(1, "send");
    ret = bpf_netlink_recv(sock, nl_pid, seq);

cleanup:
    close(sock);
    return ret;
}

Voilà, tout est prêt pour les tests :

$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++

Voyons si notre programme s'est connecté à lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 160

Envoyons des pings et regardons la carte :

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00  value: 90 01 00 00 00 00 00 00
key: 01 00 00 00  value: 00 00 00 00 00 00 00 00
key: 02 00 00 00  value: 00 00 00 00 00 00 00 00
key: 03 00 00 00  value: 00 00 00 00 00 00 00 00
key: 04 00 00 00  value: 00 00 00 00 00 00 00 00
key: 05 00 00 00  value: 00 00 00 00 00 00 00 00
key: 06 00 00 00  value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00  value: 00 00 00 00 00 00 00 00
Found 8 elements

Hourra, tout fonctionne. Notez au passage que notre carte est à nouveau affichée sous forme d'octets. Cela est dû au fait que, contrairement libbpf nous n'avons pas chargé les informations de type (BTF). Mais nous en reparlerons davantage la prochaine fois.

Outils de développement

Dans cette section, nous examinerons la boîte à outils minimale du développeur BPF.

D'une manière générale, vous n'avez besoin de rien de spécial pour développer des programmes BPF - BPF fonctionne sur n'importe quel noyau de distribution décent et les programmes sont construits en utilisant clang, qui peut être fourni à partir du package. Cependant, étant donné que BPF est en cours de développement, le noyau et les outils changent constamment, si vous ne souhaitez pas écrire de programmes BPF en utilisant des méthodes à l'ancienne à partir de 2019, vous devrez alors compiler

  • llvm/clang
  • pahole
  • son noyau
  • bpftool

(Pour référence, cette section et tous les exemples de l'article ont été exécutés sur Debian 10.)

llvm/clang

BPF est compatible avec LLVM et, bien que récemment les programmes pour BPF puissent être compilés en utilisant gcc, tous les développements actuels sont effectués pour LLVM. Par conséquent, nous allons tout d’abord construire la version actuelle clang depuis git :

$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86" 
                      -DLLVM_ENABLE_PROJECTS="clang" 
                      -DBUILD_SHARED_LIBS=OFF 
                      -DCMAKE_BUILD_TYPE=Release 
                      -DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$

Nous pouvons maintenant vérifier si tout s'est bien déroulé :

$ ./bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 11.0.0git
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: znver1

  Registered Targets:
    bpf    - BPF (host endian)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bit X86: Pentium-Pro and above
    x86-64 - 64-bit X86: EM64T and AMD64

(Instructions de montage clang pris par moi de bpf_devel_QA.)

Nous n'installerons pas les programmes que nous venons de créer, mais nous les ajouterons simplement à PATH, Par exemple:

export PATH="`pwd`/bin:$PATH"

(Cela peut être ajouté à .bashrc ou dans un fichier séparé. Personnellement, j'ajoute des choses comme ça à ~/bin/activate-llvm.sh et quand c'est nécessaire je le fais . activate-llvm.sh.)

Pahole et BTF

Utilitaire pahole utilisé lors de la construction du noyau pour créer des informations de débogage au format BTF. Nous n’entrerons pas dans les détails dans cet article sur les détails de la technologie BTF, mis à part le fait qu’elle est pratique et que nous souhaitons l’utiliser. Donc, si vous comptez construire votre noyau, construisez d'abord pahole (sans pour autant pahole vous ne pourrez pas construire le noyau avec l'option CONFIG_DEBUG_INFO_BTF:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole

Noyaux pour expérimenter avec BPF

En explorant les possibilités de BPF, je souhaite assembler mon propre noyau. Ceci, d'une manière générale, n'est pas nécessaire, puisque vous pourrez compiler et charger des programmes BPF sur le noyau de la distribution, cependant, avoir votre propre noyau vous permet d'utiliser les dernières fonctionnalités de BPF, qui apparaîtront dans votre distribution dans le meilleur des mois. , ou, comme dans le cas de certains outils de débogage, ne seront pas du tout packagés dans un avenir prévisible. De plus, son propre noyau donne l’impression qu’il est important d’expérimenter le code.

Pour construire un noyau, vous avez besoin, d'une part, du noyau lui-même, et d'autre part, d'un fichier de configuration du noyau. Pour expérimenter avec BPF, nous pouvons utiliser l'habituel vanille noyau ou l'un des noyaux de développement. Historiquement, le développement de BPF a lieu au sein de la communauté réseau Linux et donc tous les changements passent tôt ou tard par David Miller, le mainteneur du réseau Linux. Selon leur nature - modifications ou nouvelles fonctionnalités - les modifications du réseau se répartissent en deux noyaux - net ou net-next. Les évolutions du BPF sont réparties de la même manière entre bpf и bpf-next, qui sont ensuite regroupés respectivement en net et net-next. Pour plus de détails, voir bpf_devel_QA и FAQ netdev. Choisissez donc un noyau en fonction de vos goûts et des besoins de stabilité du système sur lequel vous testez (*-next les noyaux sont les plus instables de ceux répertoriés).

Cela dépasse le cadre de cet article de parler de la façon de gérer les fichiers de configuration du noyau - on suppose que soit vous savez déjà comment faire cela, soit prêt à apprendre tout seul. Cependant, les instructions suivantes devraient être plus ou moins suffisantes pour vous fournir un système compatible BPF fonctionnel.

Téléchargez l'un des noyaux ci-dessus :

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next

Créez une configuration de noyau fonctionnelle minimale :

$ cp /boot/config-`uname -r` .config
$ make localmodconfig

Activer les options BPF dans le fichier .config de votre propre choix (très probablement CONFIG_BPF sera déjà activé puisque systemd l'utilise). Voici une liste des options du noyau utilisé pour cet article :

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y

Ensuite, nous pouvons facilement assembler et installer les modules et le noyau (d'ailleurs, vous pouvez assembler le noyau en utilisant le fichier nouvellement assemblé clangen ajoutant CC=clang):

$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install

et redémarrez avec le nouveau noyau (j'utilise pour cela kexec du paquet kexec-tools):

v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

outil bpf

L'utilitaire le plus couramment utilisé dans l'article sera l'utilitaire bpftool, fourni dans le cadre du noyau Linux. Il est écrit et maintenu par des développeurs BPF pour des développeurs BPF et peut être utilisé pour gérer tous les types d'objets BPF - charger des programmes, créer et éditer des cartes, explorer la vie de l'écosystème BPF, etc. De la documentation sous forme de codes sources pour les pages de manuel peut être trouvée au cœur ou, déjà compilé, Réseau.

Au moment de la rédaction bpftool est livré prêt à l'emploi uniquement pour RHEL, Fedora et Ubuntu (voir, par exemple, ce fil, qui raconte l'histoire inachevée de l'emballage bpftool dans Debian). Mais si vous avez déjà construit votre noyau, alors construisez bpftool Aussi facile que la tarte:

$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s

Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-detecting system features:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(ici ${linux} - c'est le répertoire de votre noyau.) Après avoir exécuté ces commandes bpftool seront rassemblés dans un répertoire ${linux}/tools/bpf/bpftool et il peut être ajouté au chemin (tout d'abord à l'utilisateur root) ou copiez simplement dans /usr/local/sbin.

recueillir bpftool il est préférable d'utiliser ce dernier clang, assemblé comme décrit ci-dessus, et vérifiez s'il est correctement assemblé - en utilisant, par exemple, la commande

$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...

qui montrera quelles fonctionnalités BPF sont activées dans votre noyau.

À propos, la commande précédente peut être exécutée comme

# bpftool f p k

Cela se fait par analogie avec les utilitaires du package iproute2, où l'on peut par exemple dire ip a s eth0 au lieu de ip addr show dev eth0.

Conclusion

BPF vous permet de ferrer une puce pour mesurer efficacement et modifier à la volée la fonctionnalité du noyau. Le système s'est avéré très efficace, dans la meilleure tradition d'UNIX : un mécanisme simple permettant de (re)programmer le noyau a permis à un grand nombre de personnes et d'organisations d'expérimenter. Et, bien que les expériences, ainsi que le développement de l'infrastructure BPF elle-même, soient loin d'être terminés, le système dispose déjà d'un ABI stable qui vous permet de construire une logique métier fiable et, surtout, efficace.

Je voudrais souligner qu'à mon avis, la technologie est devenue si populaire parce que, d'une part, elle peut jouer (l'architecture d'une machine peut être comprise plus ou moins en une soirée), et d'autre part, résoudre des problèmes qui ne pouvaient pas être résolus (magnifiquement) avant son apparition. Ces deux composantes réunies poussent les gens à expérimenter et à rêver, ce qui conduit à l'émergence de solutions de plus en plus innovantes.

Cet article, bien que pas particulièrement court, n'est qu'une introduction au monde de BPF et ne décrit pas les fonctionnalités « avancées » ni les parties importantes de l'architecture. Le plan pour l'avenir ressemble à ceci : le prochain article sera un aperçu des types de programmes BPF (il existe 5.8 types de programmes pris en charge dans le noyau 30), puis nous verrons enfin comment écrire de vraies applications BPF à l'aide de programmes de traçage du noyau. à titre d'exemple, il est temps de suivre un cours plus approfondi sur l'architecture BPF, suivi d'exemples d'applications de mise en réseau et de sécurité BPF.

Articles précédents dans cette série

  1. BPF pour les plus petits, partie zéro : BPF classique

Liens

  1. Guide de référence BPF et XDP — documentation sur BPF de cilium, ou plus précisément de Daniel Borkman, l'un des créateurs et mainteneurs de BPF. C'est l'une des premières descriptions sérieuses, qui diffère des autres en ce sens que Daniel sait exactement de quoi il parle et qu'il n'y a là aucune erreur. En particulier, ce document décrit comment travailler avec des programmes BPF de types XDP et TC à l'aide de l'utilitaire bien connu ip du paquet iproute2.

  2. Documentation/réseau/filter.txt — fichier original avec documentation pour BPF classique puis étendu. Une bonne lecture si vous souhaitez vous plonger dans le langage assembleur et les détails architecturaux techniques.

  3. Blog sur BPF sur Facebook. Il est rarement mis à jour, mais à juste titre, comme l'écrivent Alexei Starovoitov (auteur d'eBPF) et Andrii Nakryiko - (mainteneur) libbpf).

  4. Les secrets de bpftool. Un fil Twitter divertissant de Quentin Monnet avec des exemples et des secrets d'utilisation de bpftool.

  5. Plongez dans BPF : une liste de matériel de lecture. Une liste géante (et toujours maintenue) de liens vers la documentation BPF de Quentin Monnet.

Source: habr.com

Ajouter un commentaire