BPF para los más pequeños, primera parte: BPF ampliado

Al principio existía una tecnología y se llamaba BPF. la miramos anterior, Artículo del Antiguo Testamento de esta serie. En 2013, gracias a los esfuerzos de Alexei Starovoitov y Daniel Borkman, se desarrolló e incluyó en el kernel de Linux una versión mejorada, optimizada para máquinas modernas de 64 bits. Esta nueva tecnología se llamó brevemente BPF interno, luego pasó a llamarse BPF extendido y ahora, varios años después, todo el mundo simplemente la llama BPF.

En términos generales, BPF permite ejecutar código arbitrario proporcionado por el usuario en el espacio del kernel de Linux, y la nueva arquitectura resultó ser tan exitosa que necesitaremos una docena de artículos más para describir todas sus aplicaciones. (Lo único que los desarrolladores no hicieron bien, como puede ver en el código de rendimiento a continuación, fue crear un logotipo decente).

Este artículo describe la estructura de la máquina virtual BPF, las interfaces del kernel para trabajar con BPF, las herramientas de desarrollo, así como una breve, muy breve descripción general de las capacidades existentes, es decir. todo lo que necesitaremos en el futuro para un estudio más profundo de las aplicaciones prácticas de BPF.
BPF para los más pequeños, primera parte: BPF ampliado

Resumen del articulo

Introducción a la arquitectura BPF. Primero, echaremos una vista panorámica de la arquitectura BPF y describiremos los componentes principales.

Registros y sistema de comando de la máquina virtual BPF. Ya teniendo una idea de la arquitectura en su conjunto, describiremos la estructura de la máquina virtual BPF.

Ciclo de vida de objetos BPF, sistema de archivos bpffs. En esta sección, analizaremos más de cerca el ciclo de vida de los objetos BPF: programas y mapas.

Gestionar objetos mediante la llamada al sistema bpf. Con una cierta comprensión del sistema ya implementado, finalmente veremos cómo crear y manipular objetos desde el espacio del usuario usando una llamada especial al sistema: bpf(2).

Пишем программы BPF с помощью libbpf. Por supuesto, puedes escribir programas usando una llamada al sistema. Pero es difícil. Para un escenario más realista, los programadores nucleares desarrollaron una biblioteca libbpf. Crearemos un esqueleto de aplicación BPF básico que usaremos en ejemplos posteriores.

Ayudantes del núcleo. Aquí aprenderemos cómo los programas BPF pueden acceder a las funciones auxiliares del kernel, una herramienta que, junto con los mapas, amplía fundamentalmente las capacidades del nuevo BPF en comparación con el clásico.

Acceso a mapas de programas BPF. En este punto, sabremos lo suficiente para comprender exactamente cómo podemos crear programas que utilicen mapas. E incluso echemos un vistazo rápido al gran y poderoso verificador.

Herramientas de desarrollo. Sección de ayuda sobre cómo ensamblar las utilidades y el kernel necesarios para los experimentos.

Conclusión. Al final del artículo, quienes hayan leído hasta aquí encontrarán palabras motivadoras y una breve descripción de lo que sucederá en los siguientes artículos. También enumeraremos una serie de enlaces para que puedan estudiar por su cuenta para aquellos que no tengan el deseo o la capacidad de esperar a que continúe.

Introducción a la arquitectura BPF

Antes de comenzar a considerar la arquitectura BPF, nos referiremos por última vez (oh) a BPF clásico, que se desarrolló como respuesta a la llegada de las máquinas RISC y resolvió el problema del filtrado eficiente de paquetes. La arquitectura resultó ser tan exitosa que, habiendo nacido en los años noventa en Berkeley UNIX, fue trasladada a la mayoría de los sistemas operativos existentes, sobrevivió hasta los locos años veinte y todavía está encontrando nuevas aplicaciones.

El nuevo BPF se desarrolló como respuesta a la ubicuidad de las máquinas de 64 bits, los servicios en la nube y la mayor necesidad de herramientas para crear SDN (Ssoftware-drefinado networking). Desarrollado por ingenieros de redes del kernel como un reemplazo mejorado del BPF clásico, el nuevo BPF, literalmente, seis meses después encontró aplicaciones en la difícil tarea de rastrear sistemas Linux, y ahora, seis años después de su aparición, necesitaremos el siguiente artículo completo solo para Enumere los diferentes tipos de programas.

Fotos divertidas

En esencia, BPF es una máquina virtual de espacio aislado que le permite ejecutar código "arbitrario" en el espacio del kernel sin comprometer la seguridad. Los programas BPF se crean en el espacio del usuario, se cargan en el kernel y se conectan a alguna fuente de eventos. Un evento podría ser, por ejemplo, la entrega de un paquete a una interfaz de red, el lanzamiento de alguna función del kernel, etc. En el caso de un paquete, el programa BPF tendrá acceso a los datos y metadatos del paquete (para lectura y, posiblemente, escritura, dependiendo del tipo de programa); en el caso de ejecutar una función del núcleo, los argumentos de la función, incluidos punteros a la memoria del kernel, etc.

Echemos un vistazo más de cerca a este proceso. Para empezar, hablemos de la primera diferencia con el BPF clásico, cuyos programas fueron escritos en ensamblador. En la nueva versión, la arquitectura se amplió para que los programas pudieran escribirse en lenguajes de alto nivel, principalmente, por supuesto, en C. Para ello, se desarrolló un backend para llvm, que permite generar código de bytes para la arquitectura BPF.

BPF para los más pequeños, primera parte: BPF ampliado

La arquitectura BPF fue diseñada, en parte, para ejecutarse de manera eficiente en máquinas modernas. Para que esto funcione en la práctica, el código de bytes BPF, una vez cargado en el kernel, se traduce a código nativo utilizando un componente llamado compilador JIT (Jboca In Tyo me). A continuación, si recuerda, en el BPF clásico el programa se cargaba en el kernel y se adjuntaba al origen del evento de forma atómica, en el contexto de una única llamada al sistema. En la nueva arquitectura, esto sucede en dos etapas: primero, el código se carga en el kernel mediante una llamada al sistema. bpf(2)y luego, posteriormente, a través de otros mecanismos que varían según el tipo de programa, el programa se adjunta al origen del evento.

Aquí el lector puede tener una pregunta: ¿fue posible? ¿Cómo se garantiza la seguridad de ejecución de dicho código? La seguridad de ejecución nos la garantiza la etapa de carga de programas BPF llamada verificador (en inglés esta etapa se llama verificador y seguiré usando la palabra inglesa):

BPF para los más pequeños, primera parte: BPF ampliado

Verifier es un analizador estático que garantiza que un programa no interrumpa el funcionamiento normal del kernel. Esto, por cierto, no significa que el programa no pueda interferir con el funcionamiento del sistema: los programas BPF, según el tipo, pueden leer y reescribir secciones de la memoria del kernel, devolver valores de funciones, recortar, agregar, reescribir. e incluso reenviar paquetes de red. Verifier garantiza que ejecutar un programa BPF no bloqueará el kernel y que un programa que, según las reglas, tiene acceso de escritura, por ejemplo, los datos de un paquete saliente, no podrá sobrescribir la memoria del kernel fuera del paquete. Veremos el verificador con un poco más de detalle en la sección correspondiente, después de familiarizarnos con todos los demás componentes de BPF.

Entonces, ¿qué hemos aprendido hasta ahora? El usuario escribe un programa en C y lo carga en el kernel mediante una llamada al sistema. bpf(2), donde un verificador lo verifica y lo traduce a código de bytes nativo. Luego, el mismo usuario u otro conecta el programa a la fuente del evento y comienza a ejecutarse. Es necesario separar el arranque y la conexión por varias razones. En primer lugar, ejecutar un verificador es relativamente caro y al descargar el mismo programa varias veces perdemos tiempo en el ordenador. En segundo lugar, la forma exacta en que se conecta un programa depende de su tipo, y una interfaz "universal" desarrollada hace un año puede no ser adecuada para nuevos tipos de programas. (Aunque ahora que la arquitectura está cada vez más madura, existe la idea de unificar esta interfaz a nivel libbpf.)

El lector atento puede notar que aún no hemos terminado con las imágenes. De hecho, todo lo anterior no explica cómo BPF cambia fundamentalmente el panorama en comparación con el BPF clásico. Dos innovaciones que amplían significativamente el alcance de la aplicabilidad son la capacidad de utilizar memoria compartida y funciones auxiliares del kernel. En BPF, la memoria compartida se implementa mediante los llamados mapas, estructuras de datos compartidos con una API específica. Probablemente obtuvieron este nombre porque el primer tipo de mapa que apareció fue una tabla hash. Luego aparecieron matrices, tablas hash locales (por CPU) y matrices locales, árboles de búsqueda, mapas que contienen punteros a programas BPF y mucho más. Lo que es interesante para nosotros ahora es que los programas BPF ahora tienen la capacidad de persistir el estado entre llamadas y compartirlo con otros programas y con el espacio del usuario.

Se accede a Maps desde procesos de usuario mediante una llamada al sistema. bpf(2)y de programas BPF que se ejecutan en el kernel utilizando funciones auxiliares. Además, existen ayudas no sólo para trabajar con mapas, sino también para acceder a otras capacidades del kernel. Por ejemplo, los programas BPF pueden usar funciones auxiliares para reenviar paquetes a otras interfaces, generar eventos de rendimiento, acceder a estructuras del kernel, etc.

BPF para los más pequeños, primera parte: BPF ampliado

En resumen, BPF proporciona la capacidad de cargar código de usuario arbitrario, es decir, probado por un verificador, en el espacio del kernel. Este código puede guardar el estado entre llamadas e intercambiar datos con el espacio del usuario, y también tiene acceso a los subsistemas del kernel permitidos por este tipo de programa.

Esto ya es similar a las capacidades proporcionadas por los módulos del kernel, en comparación con las cuales BPF tiene algunas ventajas (por supuesto, solo puede comparar aplicaciones similares, por ejemplo, el seguimiento del sistema; no puede escribir un controlador arbitrario con BPF). Puede observar un umbral de entrada más bajo (algunas utilidades que usan BPF no requieren que el usuario tenga habilidades de programación del kernel o habilidades de programación en general), seguridad en tiempo de ejecución (que levanten la mano en los comentarios aquellos que no rompieron el sistema al escribir o módulos de prueba), atomicidad: hay tiempo de inactividad al recargar módulos y el subsistema BPF garantiza que no se pierda ningún evento (para ser justos, esto no es cierto para todos los tipos de programas BPF).

La presencia de tales capacidades convierte a BPF en una herramienta universal para expandir el kernel, lo que se confirma en la práctica: cada vez se agregan más tipos nuevos de programas a BPF, cada vez más empresas grandes usan BPF en servidores de combate 24×7, cada vez más Las nuevas empresas construyen su negocio sobre la base de soluciones basadas en BPF. BPF se utiliza en todas partes: en la protección contra ataques DDoS, en la creación de SDN (por ejemplo, en la implementación de redes para Kubernetes), como principal herramienta de seguimiento del sistema y recopilador de estadísticas, en sistemas de detección de intrusiones y sistemas sandbox, etc.

Terminemos aquí la parte de descripción general del artículo y veamos la máquina virtual y el ecosistema BPF con más detalle.

Digresión: utilidades

Para poder ejecutar los ejemplos de las siguientes secciones, es posible que necesite varias utilidades, al menos llvm/clang con soporte bpf y bpftool. En la seccion Herramientas de desarrollo Puede leer las instrucciones para ensamblar las utilidades, así como su kernel. Esta sección se coloca a continuación para no perturbar la armonía de nuestra presentación.

Sistema de instrucciones y registros de máquinas virtuales BPF

La arquitectura y el sistema de comando de BPF se desarrollaron teniendo en cuenta el hecho de que los programas se escribirán en lenguaje C y, después de cargarlos en el kernel, se traducirán a código nativo. Por lo tanto, el número de registros y el conjunto de comandos se eligieron teniendo en cuenta la intersección, en el sentido matemático, de las capacidades de las máquinas modernas. Además, se impusieron varias restricciones a los programas, por ejemplo, hasta hace poco no era posible escribir bucles y subrutinas, y el número de instrucciones estaba limitado a 4096 (ahora los programas privilegiados pueden cargar hasta un millón de instrucciones).

BPF tiene once registros de 64 bits accesibles para el usuario r0-r10 y un contador de programas. Registro r10 contiene un puntero de marco y es de sólo lectura. Los programas tienen acceso a una pila de 512 bytes en tiempo de ejecución y a una cantidad ilimitada de memoria compartida en forma de mapas.

Los programas BPF pueden ejecutar un conjunto específico de ayudas del kernel de tipo programa y, más recientemente, funciones regulares. Cada función llamada puede tomar hasta cinco argumentos, pasados ​​en registros r1-r5, y el valor de retorno se pasa a r0. Se garantiza que después de regresar de la función, el contenido de los registros r6-r9 no va a cambiar

Para una traducción eficiente de programas, registre r0-r11 para todas las arquitecturas admitidas se asignan de forma única a registros reales, teniendo en cuenta las características ABI de la arquitectura actual. Por ejemplo, para x86_64 registros r1-r5, utilizados para pasar parámetros de función, se muestran en rdi, rsi, rdx, rcx, r8, que se utilizan para pasar parámetros a funciones en x86_64. Por ejemplo, el código de la izquierda se traduce al código de la derecha así:

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

El registro r0 también se utiliza para devolver el resultado de la ejecución del programa, y ​​en el registro r1 al programa se le pasa un puntero al contexto; dependiendo del tipo de programa, esto podría ser, por ejemplo, una estructura struct xdp_md (para XDP) o estructura struct __sk_buff (para diferentes programas de red) o estructura struct pt_regs (para diferentes tipos de programas de rastreo), etc.

Entonces, teníamos un conjunto de registros, ayudantes del kernel, una pila, un puntero de contexto y memoria compartida en forma de mapas. No es que todo esto sea absolutamente necesario en el viaje, pero...

Continuamos la descripción y hablemos sobre el sistema de comando para trabajar con estos objetos. Todo (casi todos) Las instrucciones BPF tienen un tamaño fijo de 64 bits. Si observa una instrucción en una máquina Big Endian de 64 bits, verá

BPF para los más pequeños, primera parte: BPF ampliado

es Code - esta es la codificación de la instrucción, Dst/Src son las codificaciones del receptor y la fuente, respectivamente, Off - sangría firmada de 16 bits, y Imm es un entero de 32 bits con signo que se utiliza en algunas instrucciones (similar a la constante K de cBPF). Codificación Code tiene uno de dos tipos:

BPF para los más pequeños, primera parte: BPF ampliado

Las clases de instrucción 0, 1, 2, 3 definen comandos para trabajar con la memoria. Ellos son llamados, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, respectivamente. Clases 4, 7 (BPF_ALU, BPF_ALU64) constituyen un conjunto de instrucciones ALU. Clases 5, 6 (BPF_JMP, BPF_JMP32) contienen instrucciones de salto.

El plan adicional para estudiar el sistema de instrucciones BPF es el siguiente: en lugar de enumerar meticulosamente todas las instrucciones y sus parámetros, veremos un par de ejemplos en esta sección y a partir de ellos quedará claro cómo funcionan realmente las instrucciones y cómo Desmonte manualmente cualquier archivo binario para BPF. Para consolidar el material más adelante en el artículo, también nos reuniremos con instrucciones individuales en las secciones sobre Verificador, compilador JIT, traducción del BPF clásico, así como al estudiar mapas, llamar a funciones, etc.

Cuando hablamos de instrucciones individuales, nos referiremos a los archivos principales. bpf.h и bpf_common.h, que definen los códigos numéricos de las instrucciones BPF. Al estudiar arquitectura por su cuenta y/o analizar archivos binarios, puede encontrar semántica en las siguientes fuentes, ordenadas en orden de complejidad: Especificaciones no oficiales de eBPF, Guía de referencia de BPF y XDP, conjunto de instrucciones, Documentación/redes/filtro.txt y, por supuesto, en el código fuente de Linux: verificador, JIT, intérprete BPF.

Ejemplo: desmontar BPF en tu cabeza

Veamos un ejemplo en el que compilamos un programa. readelf-example.c y mira el binario resultante. Revelaremos el contenido original. readelf-example.c a continuación, después de restaurar su lógica a partir de códigos binarios:

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

Primera columna en la salida readelf es una sangría y nuestro programa consta de cuatro comandos:

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

Los códigos de comando son iguales. b7, 15, b7 и 95. Recuerde que los tres bits menos significativos son la clase de instrucción. En nuestro caso, el cuarto bit de todas las instrucciones está vacío, por lo que las clases de instrucción son 7, 5, 7, 5, respectivamente. BPF_ALU64, y 5 es BPF_JMP. Para ambas clases, el formato de instrucción es el mismo (ver arriba) y podemos reescribir nuestro programa así (al mismo tiempo reescribiremos las columnas restantes en forma humana):

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

operación b clase ALU64 - es BPF_MOV. Asigna un valor al registro de destino. Si el bit está configurado s (fuente), entonces el valor se toma del registro fuente, y si, como en nuestro caso, no está configurado, entonces el valor se toma del campo Imm. Entonces en la primera y tercera instrucción realizamos la operación. r0 = Imm. Además, la operación JMP clase 1 es BPF_JEQ (saltar si es igual). En nuestro caso, desde el momento S es cero, compara el valor del registro fuente con el campo Imm. Si los valores coinciden, entonces se produce la transición a PC + OffDonde PC, como siempre, contiene la dirección de la siguiente instrucción. Finalmente, la operación JMP Clase 9 es BPF_EXIT. Esta instrucción finaliza el programa y regresa al kernel. r0. Agreguemos una nueva columna a nuestra tabla:

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

Podemos reescribir esto en una forma más conveniente:

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

Si recordamos lo que hay en el registro. r1 al programa se le pasa un puntero al contexto desde el kernel, y en el registro r0 el valor se devuelve al kernel, entonces podemos ver que si el puntero al contexto es cero, entonces devolvemos 1, y en caso contrario - 2. Comprobemos que estamos en lo cierto mirando la fuente:

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

Sí, es un programa sin sentido, pero se traduce en sólo cuatro sencillas instrucciones.

Ejemplo de excepción: instrucción de 16 bytes

Mencionamos anteriormente que algunas instrucciones ocupan más de 64 bits. Esto se aplica, por ejemplo, a las instrucciones lddw (Código = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — carga una palabra doble de los campos en el registro Imm. El punto es que Imm tiene un tamaño de 32 y una palabra doble es de 64 bits, por lo que cargar un valor inmediato de 64 bits en un registro en una instrucción de 64 bits no funcionará. Para hacer esto, se utilizan dos instrucciones adyacentes para almacenar la segunda parte del valor de 64 bits en el campo Imm. Un ejemplo:

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

Sólo hay dos instrucciones en un programa binario:

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

Nos volveremos a encontrar con instrucciones. lddw, cuando hablamos de reubicaciones y trabajo con mapas.

Ejemplo: desmontar BPF usando herramientas estándar

Entonces, hemos aprendido a leer códigos binarios BPF y estamos listos para analizar cualquier instrucción si es necesario. Sin embargo, vale la pena decir que en la práctica es más conveniente y rápido desmontar programas utilizando herramientas estándar, por ejemplo:

$ 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

Ciclo de vida de objetos BPF, sistema de archivos bpffs

(Aprendí por primera vez algunos de los detalles descritos en esta subsección de ayuno Alexei Starovoitov en Blog de BPF.)

Los objetos BPF (programas y mapas) se crean desde el espacio del usuario mediante comandos BPF_PROG_LOAD и BPF_MAP_CREATE llamada al sistema bpf(2), hablaremos exactamente de cómo sucede esto en la siguiente sección. Esto crea estructuras de datos del kernel y para cada una de ellas. refcount (recuento de referencias) se establece en uno y se devuelve al usuario un descriptor de archivo que apunta al objeto. Después de cerrar la manija refcount el objeto se reduce en uno y cuando llega a cero, el objeto se destruye.

Si el programa utiliza mapas, entonces refcount estos mapas aumentan en uno después de cargar el programa, es decir sus descriptores de archivos se pueden cerrar del proceso de usuario y aún refcount no será cero:

BPF para los más pequeños, primera parte: BPF ampliado

Después de cargar con éxito un programa, normalmente lo adjuntamos a algún tipo de generador de eventos. Por ejemplo, podemos ponerlo en una interfaz de red para procesar paquetes entrantes o conectarlo a algún tracepoint en el núcleo. En este punto, el contador de referencias también aumentará en uno y podremos cerrar el descriptor de archivo en el programa cargador.

¿Qué pasa si ahora cerramos el gestor de arranque? Depende del tipo de generador de eventos (gancho). Todos los enlaces de red existirán una vez que se complete el cargador; estos son los llamados enlaces globales. Y, por ejemplo, los programas de seguimiento se publicarán después de que finalice el proceso que los creó (y por lo tanto se denominan locales, de “local al proceso”). Técnicamente, los enlaces locales siempre tienen un descriptor de archivo correspondiente en el espacio del usuario y, por lo tanto, se cierran cuando se cierra el proceso, pero los enlaces globales no. En la siguiente figura, usando cruces rojas, intento mostrar cómo la terminación del programa de carga afecta la vida útil de los objetos en el caso de los ganchos locales y globales.

BPF para los más pequeños, primera parte: BPF ampliado

¿Por qué existe una distinción entre ganchos locales y globales? Ejecutar algunos tipos de programas de red tiene sentido sin un espacio de usuario, por ejemplo, imagine la protección DDoS: el gestor de arranque escribe las reglas y conecta el programa BPF a la interfaz de red, después de lo cual el gestor de arranque puede eliminarse. Por otro lado, imagine un programa de rastreo de depuración que escribió de rodillas en diez minutos; cuando esté terminado, le gustaría que no quede basura en el sistema, y ​​los enlaces locales se asegurarán de ello.

Por otro lado, imagine que desea conectarse a un punto de seguimiento en el kernel y recopilar estadísticas durante muchos años. En este caso, querrás completar la parte del usuario y volver a las estadísticas de vez en cuando. El sistema de archivos bpf brinda esta oportunidad. Es un sistema de pseudoarchivos en memoria que permite la creación de archivos que hacen referencia a objetos BPF y, por lo tanto, aumentan refcount objetos. Después de esto, el cargador puede salir y los objetos que creó permanecerán vivos.

BPF para los más pequeños, primera parte: BPF ampliado

La creación de archivos en bpffs que hacen referencia a objetos BPF se denomina "fijar" (como en la siguiente frase: "el proceso puede fijar un programa o mapa BPF"). La creación de objetos de archivo para objetos BPF tiene sentido no solo para extender la vida útil de los objetos locales, sino también para la usabilidad de los objetos globales; volviendo al ejemplo con el programa de protección global DDoS, queremos poder venir y ver las estadísticas. de vez en cuando.

El sistema de archivos BPF generalmente se monta en /sys/fs/bpf, pero también se puede montar localmente, por ejemplo, así:

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

Los nombres del sistema de archivos se crean usando el comando BPF_OBJ_PIN Llamada al sistema BPF. Para ilustrar, tomemos un programa, compilémoslo, carguemoslo y fijémoslo a bpffs. Nuestro programa no hace nada útil, solo presentamos el código para que puedas reproducir el ejemplo:

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

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

Compilemos este programa y creemos una copia local del sistema de archivos. bpffs:

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

Ahora descarguemos nuestro programa usando la utilidad. bpftool y observe las llamadas al sistema que lo acompañan. bpf(2) (algunas líneas irrelevantes eliminadas de la salida de 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

Aquí hemos cargado el programa usando BPF_PROG_LOAD, recibió un descriptor de archivo del kernel 3 y usando el comando BPF_OBJ_PIN fijó este descriptor de archivo como un archivo "bpf-mountpoint/test". Después de esto, el programa gestor de arranque. bpftool terminó de funcionar, pero nuestro programa permaneció en el kernel, aunque no lo adjuntamos a ninguna interfaz de red:

$ 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

Podemos eliminar el objeto del archivo normalmente. unlink(2) y luego de eso se eliminará el programa correspondiente:

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

Eliminar objetos

Hablando de eliminar objetos, es necesario aclarar que después de desconectar el programa del gancho (generador de eventos), ni un solo evento nuevo desencadenará su lanzamiento, sin embargo, todas las instancias actuales del programa se completarán en el orden normal. .

Algunos tipos de programas BPF le permiten reemplazar el programa sobre la marcha, es decir, proporcionar atomicidad de secuencia replace = detach old program, attach new program. En este caso, todas las instancias activas de la versión anterior del programa terminarán su trabajo y se crearán nuevos controladores de eventos a partir del nuevo programa, y ​​"atomicidad" aquí significa que no se perderá ni un solo evento.

Adjuntar programas a fuentes de eventos

En este artículo, no describiremos por separado la conexión de programas a fuentes de eventos, ya que tiene sentido estudiar esto en el contexto de un tipo específico de programa. Cm. ejemplo a continuación, en el que mostramos cómo se conectan programas como XDP.

Manipulación de objetos mediante la llamada al sistema bpf

Programas BPF

Todos los objetos BPF se crean y administran desde el espacio del usuario mediante una llamada al sistema. bpf, teniendo el siguiente prototipo:

#include <linux/bpf.h>

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

Aquí está el equipo cmd es uno de los valores de tipo enum bpf_cmd, attr — un puntero a los parámetros de un programa específico y size — tamaño del objeto según el puntero, es decir normalmente esto sizeof(*attr). En el kernel 5.8 la llamada al sistema bpf admite 34 comandos diferentes y definición union bpf_attr Ocupa 200 líneas. Pero no debemos dejarnos intimidar por esto, ya que nos familiarizaremos con los comandos y parámetros a lo largo de varios artículos.

Empecemos por el equipo. BPF_PROG_LOAD, que crea programas BPF, toma un conjunto de instrucciones BPF y las carga en el kernel. En el momento de la carga, se inicia el verificador y luego el compilador JIT y, después de una ejecución exitosa, se devuelve al usuario el descriptor del archivo del programa. Vimos lo que le sucede a continuación en el apartado anterior. sobre el ciclo de vida de los objetos BPF.

Ahora escribiremos un programa personalizado que cargará un programa BPF simple, pero primero debemos decidir qué tipo de programa queremos cargar; tendremos que seleccionar тип y en el marco de este tipo, escribir un programa que pase la prueba del verificador. Sin embargo, para no complicar el proceso, aquí hay una solución preparada: tomaremos un programa como BPF_PROG_TYPE_XDP, que devolverá el valor XDP_PASS (omitir todos los paquetes). En el ensamblador BPF parece muy simple:

r0 = 2
exit

Después de que hayamos decidido que lo subiremos, te podemos decir como lo haremos:

#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();
}

Los eventos interesantes en un programa comienzan con la definición de una matriz. insns - nuestro programa BPF en código máquina. En este caso, cada instrucción del programa BPF está empaquetada en la estructura. bpf_insn. Primer elemento insns cumple con las instrucciones r0 = 2, segundo - exit.

Retiro. El kernel define macros más convenientes para escribir códigos de máquina y usar el archivo de encabezado del kernel. tools/include/linux/filter.h podríamos escribir

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

Pero dado que escribir programas BPF en código nativo sólo es necesario para escribir pruebas en el kernel y artículos sobre BPF, la ausencia de estas macros no complica realmente la vida del desarrollador.

Después de definir el programa BPF, pasamos a cargarlo en el kernel. Nuestro conjunto minimalista de parámetros. attr Incluye el tipo de programa, conjunto y número de instrucciones, licencia requerida y nombre. "woo", que utilizamos para encontrar nuestro programa en el sistema después de descargarlo. El programa, según lo prometido, se carga en el sistema mediante una llamada al sistema. bpf.

Al final del programa terminamos en un bucle infinito que simula la carga útil. Sin él, el kernel eliminará el programa cuando se cierre el descriptor de archivo que nos devolvió la llamada al sistema. bpf, y no lo veremos en el sistema.

Bueno, estamos listos para las pruebas. Vamos a montar y ejecutar el programa en stracepara comprobar que todo funciona como debería:

$ 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(

Todo esta bien, bpf(2) nos devolvió el identificador 3 y entramos en un bucle infinito con pause(). Intentemos encontrar nuestro programa en el sistema. Para ello iremos a otra terminal y usaremos la utilidad 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)

Vemos que hay un programa cargado en el sistema. woo cuyo ID global es 390 y actualmente está en progreso simple-prog hay un descriptor de archivo abierto que apunta al programa (y si simple-prog terminará el trabajo, entonces woo desaparecerá). Como era de esperar, el programa woo Toma 16 bytes (dos instrucciones) de códigos binarios en la arquitectura BPF, pero en su forma nativa (x86_64) ya son 40 bytes. Veamos nuestro programa en su forma original:

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

no hay sorpresas. Ahora veamos el código generado por el compilador 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

no muy eficaz para exit(2), pero para ser justos, nuestro programa es demasiado simple y, para programas no triviales, por supuesto, se necesitan el prólogo y el epílogo agregados por el compilador JIT.

Mapas

Los programas BPF pueden utilizar áreas de memoria estructuradas a las que pueden acceder tanto otros programas BPF como programas en el espacio de usuario. Estos objetos se llaman mapas y en esta sección mostraremos cómo manipularlos usando una llamada al sistema. bpf.

Digamos de inmediato que las capacidades de los mapas no se limitan únicamente al acceso a la memoria compartida. Hay mapas de propósito especial que contienen, por ejemplo, punteros a programas BPF o punteros a interfaces de red, mapas para trabajar con eventos de rendimiento, etc. No hablaremos aquí de ellos para no confundir al lector. Aparte de esto, ignoramos los problemas de sincronización, ya que esto no es importante para nuestros ejemplos. Puede encontrar una lista completa de los tipos de mapas disponibles en <linux/bpf.h>, y en esta sección tomaremos como ejemplo el primer tipo históricamente, la tabla hash BPF_MAP_TYPE_HASH.

Si crea una tabla hash en, digamos, C++, diría unordered_map<int,long> woo, que en ruso significa "necesito una mesa woo tamaño ilimitado, cuyas claves son de tipo int, y los valores son del tipo long" Para crear una tabla hash BPF, debemos hacer prácticamente lo mismo, excepto que debemos especificar el tamaño máximo de la tabla y, en lugar de especificar los tipos de claves y valores, debemos especificar sus tamaños en bytes. . Para crear mapas use el comando BPF_MAP_CREATE llamada al sistema bpf. Veamos un programa más o menos mínimo que crea un mapa. Después del programa anterior que carga programas BPF, este debería parecerte sencillo:

$ 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();
}

Aquí definimos un conjunto de parámetros. attr, en el que decimos "Necesito una tabla hash con claves y valores de tamaño sizeof(int), en el que puedo poner un máximo de cuatro elementos." Al crear mapas BPF, puede especificar otros parámetros, por ejemplo, de la misma manera que en el ejemplo del programa, especificamos el nombre del objeto como "woo".

Compilemos y ejecutemos el programa:

$ 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(

Aquí está la llamada al sistema. bpf(2) nos devolvió el número del mapa descriptivo 3 y luego el programa, como se esperaba, espera más instrucciones en la llamada al sistema pause(2).

Ahora envíemos nuestro programa a un segundo plano o abramos otra terminal y miremos nuestro objeto usando la utilidad bpftool (podemos distinguir nuestro mapa de otros por su nombre):

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

El número 114 es el ID global de nuestro objeto. Cualquier programa en el sistema puede usar esta ID para abrir un mapa existente usando el comando BPF_MAP_GET_FD_BY_ID llamada al sistema bpf.

Ahora podemos jugar con nuestra tabla hash. Veamos su contenido:

$ sudo bpftool map dump id 114
Found 0 elements

Vacío. Pongámosle un valor. hash[1] = 1:

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

Miremos la tabla nuevamente:

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

¡Hurra! Logramos agregar un elemento. Tenga en cuenta que tenemos que trabajar a nivel de bytes para hacer esto, ya que bptftool No sabe de qué tipo son los valores en la tabla hash. (Este conocimiento se le puede transferir usando BTF, pero hablaremos más sobre eso ahora).

¿Cómo lee y agrega elementos exactamente bpftool? Echemos un vistazo debajo del capó:

$ 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

Primero abrimos el mapa por su ID global usando el comando BPF_MAP_GET_FD_BY_ID и bpf(2) nos devolvió el descriptor 3. Además usando el comando BPF_MAP_GET_NEXT_KEY encontramos la primera clave en la tabla pasando NULL como puntero a la clave "anterior". Si tenemos la clave podemos hacerlo. BPF_MAP_LOOKUP_ELEMque devuelve un valor a un puntero value. El siguiente paso es intentar encontrar el siguiente elemento pasando un puntero a la clave actual, pero nuestra tabla solo contiene un elemento y el comando BPF_MAP_GET_NEXT_KEY devuelve ENOENT.

Bien, cambiemos el valor por la clave 1, digamos que nuestra lógica de negocios requiere registrarse 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

Como era de esperar, es muy simple: el comando BPF_MAP_GET_FD_BY_ID abre nuestro mapa por ID, y el comando BPF_MAP_UPDATE_ELEM sobrescribe el elemento.

Entonces, después de crear una tabla hash a partir de un programa, podemos leer y escribir su contenido desde otro. Tenga en cuenta que si pudimos hacer esto desde la línea de comandos, cualquier otro programa del sistema podrá hacerlo. Además de los comandos descritos anteriormente, para trabajar con mapas desde el espacio del usuario, El siguiente:

  • BPF_MAP_LOOKUP_ELEM: encontrar valor por clave
  • BPF_MAP_UPDATE_ELEM: actualizar/crear valor
  • BPF_MAP_DELETE_ELEM: quitar clave
  • BPF_MAP_GET_NEXT_KEY: encuentra la siguiente (o primera) clave
  • BPF_MAP_GET_NEXT_ID: te permite revisar todos los mapas existentes, así es como funciona bpftool map
  • BPF_MAP_GET_FD_BY_ID: abre un mapa existente por su ID global
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: actualiza atómicamente el valor de un objeto y devuelve el anterior
  • BPF_MAP_FREEZE: hace que el mapa sea inmutable desde el espacio de usuario (esta operación no se puede deshacer)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: operaciones masivas. Por ejemplo, BPF_MAP_LOOKUP_AND_DELETE_BATCH - esta es la única forma confiable de leer y restablecer todos los valores del mapa

No todos estos comandos funcionan para todos los tipos de mapas, pero en general trabajar con otros tipos de mapas desde el espacio del usuario tiene exactamente el mismo aspecto que trabajar con tablas hash.

Para mantener el orden, terminemos nuestros experimentos con la tabla hash. ¿Recuerda que creamos una tabla que puede contener hasta cuatro claves? Agreguemos algunos elementos más:

$ 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

Hasta ahora, todo 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

Intentemos agregar uno más:

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

Como era de esperar, no lo logramos. Veamos el error con más detalle:

$ 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 +++

Todo está bien: como era de esperar, el equipo BPF_MAP_UPDATE_ELEM intenta crear una nueva quinta clave, pero falla E2BIG.

Así, podemos crear y cargar programas BPF, así como crear y gestionar mapas desde el espacio del usuario. Ahora es lógico ver cómo podemos utilizar mapas de los propios programas BPF. Podríamos hablar de esto en el lenguaje de programas difíciles de leer en códigos de macros de máquina, pero en realidad ha llegado el momento de mostrar cómo se escriben y mantienen realmente los programas BPF: utilizando libbpf.

(Para lectores que no estén satisfechos con la falta de un ejemplo de bajo nivel: analizaremos en detalle los programas que utilizan mapas y funciones auxiliares creadas con libbpf y decirle qué sucede en el nivel de instrucción. Para lectores insatisfechos mucho, agregamos ejemplo en el lugar apropiado del artículo.)

Escribir programas BPF usando libbpf

Escribir programas BPF utilizando códigos de máquina puede resultar interesante sólo la primera vez, y luego llega la saciedad. En este momento necesitas centrar tu atención en llvm, que tiene un backend para generar código para la arquitectura BPF, así como una biblioteca libbpf, que le permite escribir el lado del usuario de las aplicaciones BPF y cargar el código de los programas BPF generados usando llvm/clang.

De hecho, como veremos en este y siguientes artículos, libbpf hace bastante trabajo sin él (o herramientas similares, iproute2, libbcc, libbpf-go, etc.) es imposible vivir. Una de las características principales del proyecto. libbpf es BPF CO-RE (compilar una vez, ejecutar en todas partes): un proyecto que le permite escribir programas BPF que son portátiles de un kernel a otro, con la capacidad de ejecutarse en diferentes API (por ejemplo, cuando la estructura del kernel cambia de versión a la versión). Para poder trabajar con CO-RE, su kernel debe estar compilado con soporte BTF (describimos cómo hacerlo en la sección Herramientas de desarrollo. Puedes comprobar si tu kernel está construido con BTF o no de forma muy sencilla, con la presencia del siguiente archivo:

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

Este archivo almacena información sobre todos los tipos de datos utilizados en el kernel y se utiliza en todos nuestros ejemplos usando libbpf. Hablaremos en detalle sobre CO-RE en el próximo artículo, pero en este, simplemente construya usted mismo un kernel con CONFIG_DEBUG_INFO_BTF.

Biblioteca libbpf vive justo en el directorio tools/lib/bpf kernel y su desarrollo se realiza a través de la lista de correo [email protected]. Sin embargo, se mantiene un repositorio separado para las necesidades de las aplicaciones que viven fuera del núcleo. https://github.com/libbpf/libbpf en el que la biblioteca del kernel se refleja para acceso de lectura más o menos como está.

En esta sección veremos cómo se puede crear un proyecto que utilice libbpf, escribamos varios programas de prueba (más o menos sin sentido) y analicemos en detalle cómo funciona todo. Esto nos permitirá explicar más fácilmente en las siguientes secciones exactamente cómo interactúan los programas BPF con mapas, ayudantes del kernel, BTF, etc.

Normalmente los proyectos utilizan libbpf agregue un repositorio de GitHub como un submódulo de git, haremos lo mismo:

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

Ir a libbpf muy simple

$ 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

Nuestro próximo plan en esta sección es el siguiente: escribiremos un programa BPF como BPF_PROG_TYPE_XDP, igual que en el ejemplo anterior, pero en C lo compilamos usando clangy escriba un programa auxiliar que lo cargue en el kernel. En las siguientes secciones ampliaremos las capacidades tanto del programa BPF como del programa asistente.

Ejemplo: crear una aplicación completa usando libbpf

Para empezar utilizamos el archivo /sys/kernel/btf/vmlinux, que se mencionó anteriormente, y crear su equivalente en forma de archivo de encabezado:

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

Este archivo almacenará todas las estructuras de datos disponibles en nuestro kernel, por ejemplo, así es como se define el encabezado IPv4 en el kernel:

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

Ahora escribiremos nuestro programa 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";

Aunque nuestro programa resultó ser muy simple, aún debemos prestar atención a muchos detalles. Primero, el primer archivo de encabezado que incluimos es vmlinux.h, que acabamos de generar usando bpftool btf dump - ahora no necesitamos instalar el paquete kernel-headers para saber cómo son las estructuras del kernel. El siguiente archivo de encabezado nos llega desde la biblioteca. libbpf. Ahora solo nos falta para definir la macro. SEC, que envía el carácter a la sección apropiada del archivo objeto ELF. Nuestro programa está contenido en la sección xdp/simple, donde antes de la barra diagonal definimos el tipo de programa BPF; esta es la convención utilizada en libbpf, según el nombre de la sección, sustituirá el tipo correcto al inicio bpf(2). El programa BPF en sí es C - muy simple y consta de una línea return XDP_PASS. Finalmente, una sección separada. "license" contiene el nombre de la licencia.

Podemos compilar nuestro programa usando llvm/clang, versión >= 10.0.0, o mejor aún, mayor (ver sección Herramientas de desarrollo):

$ 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

Entre las características interesantes: indicamos la arquitectura de destino. -target bpf y el camino a los encabezados libbpf, que instalamos recientemente. Además, no te olvides de -O2, sin esta opción es posible que te encuentres con sorpresas en el futuro. Miremos nuestro código, ¿logramos escribir el programa que queríamos?

$ 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

¡Sí, funcionó! Ahora tenemos un archivo binario con el programa y queremos crear una aplicación que lo cargue en el kernel. Para ello la biblioteca libbpf nos ofrece dos opciones: utilizar una API de nivel inferior o una API de nivel superior. Iremos por el segundo camino, ya que queremos aprender a escribir, cargar y conectar programas BPF con el mínimo esfuerzo para su posterior estudio.

Primero, necesitamos generar el "esqueleto" de nuestro programa a partir de su binario usando la misma utilidad. bpftool — la navaja suiza del mundo BPF (que puede tomarse literalmente, ya que Daniel Borkman, uno de los creadores y mantenedores de BPF, es suizo):

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

En archivo xdp-simple.skel.h contiene el código binario de nuestro programa y funciones para administrar: cargar, adjuntar, eliminar nuestro objeto. En nuestro caso simple, esto parece excesivo, pero también funciona en el caso en que el archivo objeto contiene muchos programas y mapas BPF y para cargar este ELF gigante solo necesitamos generar el esqueleto y llamar a una o dos funciones desde la aplicación personalizada que están escribiendo Sigamos adelante ahora.

Estrictamente hablando, nuestro programa de carga es 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);
}

es struct xdp_simple_bpf definido en el archivo xdp-simple.skel.h y describe nuestro archivo objeto:

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

Podemos ver rastros de una API de bajo nivel aquí: la estructura struct bpf_program *simple и struct bpf_link *simple. La primera estructura describe específicamente nuestro programa, escrito en la sección xdp/simpley el segundo describe cómo el programa se conecta al origen del evento.

Función xdp_simple_bpf__open_and_load, abre un objeto ELF, lo analiza, crea todas las estructuras y subestructuras (además del programa, ELF también contiene otras secciones: datos, datos de solo lectura, información de depuración, licencia, etc.) y luego lo carga en el kernel usando un sistema. llamar bpf, que podemos comprobar compilando y ejecutando el programa:

$ 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

Veamos ahora nuestro programa usando bpftool. Busquemos su identificación:

# 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)

y volcar (usamos una forma abreviada del comando bpftool prog dump xlated):

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

¡Algo nuevo! El programa imprimió fragmentos de nuestro archivo fuente C. Esto lo hizo la biblioteca libbpf, que encontró la sección de depuración en el binario, la compiló en un objeto BTF y la cargó en el kernel usando BPF_BTF_LOADy luego especificó el descriptor de archivo resultante al cargar el programa con el comando BPG_PROG_LOAD.

Ayudantes del núcleo

Los programas BPF pueden ejecutar funciones "externas": ayudantes del kernel. Estas funciones auxiliares permiten a los programas BPF acceder a las estructuras del kernel, administrar mapas y también comunicarse con el "mundo real": crear eventos de rendimiento, controlar el hardware (por ejemplo, redirigir paquetes), etc.

Ejemplo: bpf_get_smp_processor_id

En el marco del paradigma de “aprender con el ejemplo”, consideremos una de las funciones auxiliares, bpf_get_smp_processor_id(), cierto en archivo kernel/bpf/helpers.c. Devuelve el número del procesador en el que se ejecuta el programa BPF que lo llamó. Pero no nos interesa tanto su semántica como el hecho de que su implementación toma una línea:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Las definiciones de funciones auxiliares de BPF son similares a las definiciones de llamadas al sistema Linux. Aquí, por ejemplo, se define una función que no tiene argumentos. (Una función que toma, digamos, tres argumentos se define usando la macro BPF_CALL_3. El número máximo de argumentos es cinco). Sin embargo, esta es sólo la primera parte de la definición. La segunda parte es definir la estructura de tipos. struct bpf_func_proto, que contiene una descripción de la función auxiliar que comprende el verificador:

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

Registro de funciones auxiliares

Para que los programas BPF de un tipo particular utilicen esta función, deben registrarla, por ejemplo para el tipo BPF_PROG_TYPE_XDP una función está definida en el kernel xdp_func_proto, que determina a partir del ID de la función auxiliar si XDP admite esta función o no. Nuestra función es apoyo:

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

Los nuevos tipos de programas BPF están "definidos" en el archivo include/linux/bpf_types.h usando una macro BPF_PROG_TYPE. Definido entre comillas porque es una definición lógica, y en términos del lenguaje C la definición de un conjunto completo de estructuras concretas ocurre en otros lugares. En particular, en el expediente kernel/bpf/verifier.c todas las definiciones del archivo bpf_types.h Se utilizan para crear una serie de estructuras. 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
};

Es decir, para cada tipo de programa BPF, se define un puntero a una estructura de datos del tipo struct bpf_verifier_ops, que se inicializa con el valor _name ## _verifier_ops, es decir., xdp_verifier_ops para xdp. Estructura xdp_verifier_ops determinado por en archivo net/core/filter.c следующим обрахом:

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

Aquí vemos nuestra función familiar. xdp_func_proto, que ejecutará el verificador cada vez que encuentre un desafío algunos funciones dentro de un programa BPF, consulte verifier.c.

Veamos cómo un programa BPF hipotético usa la función bpf_get_smp_processor_id. Para hacer esto, reescribimos el programa de nuestra sección anterior de la siguiente manera:

#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";

símbolo bpf_get_smp_processor_id determinado por в <bpf/bpf_helper_defs.h> Biblioteca libbpf cómo

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

eso es bpf_get_smp_processor_id es un puntero de función cuyo valor es 8, donde 8 es el valor BPF_FUNC_get_smp_processor_id типа enum bpf_fun_id, que está definido para nosotros en el archivo vmlinux.h (archivo bpf_helper_defs.h en el kernel se genera mediante un script, por lo que los números “mágicos” están bien). Esta función no toma argumentos y devuelve un valor de tipo __u32. Cuando lo ejecutamos en nuestro programa, clang genera una instrucción BPF_CALL "el tipo correcto" Compilemos el programa y miremos la sección. 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

En la primera línea vemos instrucciones. call, parámetro IMM que es igual a 8, y SRC_REG - cero. Según el acuerdo ABI utilizado por el verificador, esta es una llamada a la función auxiliar número ocho. Una vez lanzado, la lógica es simple. Valor de retorno del registro r0 copiado a r1 y en las líneas 2,3 se convierte a tipo u32 — se borran los 32 bits superiores. En las líneas 4,5,6,7 devolvemos 2 (XDP_PASS) o 1 (XDP_DROP) dependiendo de si la función auxiliar de la línea 0 devolvió un valor cero o distinto de cero.

Probemos nosotros mismos: carguemos el programa y miremos el resultado. 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, el verificador encontró el asistente de kernel correcto.

Ejemplo: pasar argumentos y finalmente ejecutar el programa.

Todas las funciones auxiliares a nivel de ejecución tienen un prototipo.

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

Los parámetros de las funciones auxiliares se pasan en registros. r1-r5, y el valor se devuelve en el registro r0. No hay funciones que requieran más de cinco argumentos y no se espera que se agregue soporte para ellas en el futuro.

Echemos un vistazo al nuevo asistente del kernel y cómo BPF pasa parámetros. vamos a reescribir xdp-simple.bpf.c de la siguiente manera (el resto de líneas no han cambiado):

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

Nuestro programa imprime el número de la CPU en la que se está ejecutando. Compilémoslo y miremos el código:

$ 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

En las líneas 0-7 escribimos la cadena running on CPU%un, y luego en la línea 8 ejecutamos el familiar bpf_get_smp_processor_id. En las líneas 9-12 preparamos los argumentos auxiliares. bpf_printk - registros r1, r2, r3. ¿Por qué hay tres y no dos? Porque bpf_printkeste es un contenedor de macros alrededor del verdadero ayudante bpf_trace_printk, que necesita pasar el tamaño de la cadena de formato.

Agreguemos ahora un par de líneas a xdp-simple.cpara que nuestro programa se conecte a la interfaz lo ¡Y realmente comencé!

$ 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);
}

Aquí usamos la función bpf_set_link_xdp_fd, que conecta programas BPF de tipo XDP a interfaces de red. Codificamos el número de interfaz. lo, que siempre es 1. Ejecutamos la función dos veces para desconectar primero el programa anterior, si estaba adjunto. Note que ahora no necesitamos un desafío. pause o un bucle infinito: nuestro programa de carga se cerrará, pero el programa BPF no se cerrará ya que está conectado a la fuente del evento. Después de una descarga y conexión exitosas, el programa se iniciará para cada paquete de red que llegue a lo.

Descarguemos el programa y miremos la interfaz. 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

El programa que descargamos tiene ID 669 y vemos el mismo ID en la interfaz. lo. Enviaremos un par de paquetes a 127.0.0.1 (solicitud + respuesta):

$ ping -c1 localhost

y ahora veamos el contenido del archivo virtual de depuración /sys/kernel/debug/tracing/trace_pipe, en el cual bpf_printk escribe sus mensajes:

# 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

Se detectaron dos paquetes lo y procesado en CPU0: ¡nuestro primer programa BPF completo y sin sentido funcionó!

Vale la pena señalar que bpf_printk No en vano escribe en el archivo de depuración: este no es el asistente más exitoso para usar en producción, pero nuestro objetivo era mostrar algo simple.

Accediendo a mapas desde programas BPF

Ejemplo: usar un mapa del programa BPF

En las secciones anteriores aprendimos cómo crear y usar mapas desde el espacio del usuario, y ahora veamos la parte del kernel. Empecemos, como siempre, con un ejemplo. Reescribamos nuestro programa. xdp-simple.bpf.c следующим обрахом:

#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";

Al comienzo del programa agregamos una definición de mapa. woo: Esta es una matriz de 8 elementos que almacena valores como u64 (en C definiríamos una matriz como u64 woo[8]). en un programa "xdp/simple" Obtenemos el número de procesador actual en una variable. key y luego usando la función auxiliar bpf_map_lookup_element obtenemos un puntero a la entrada correspondiente en la matriz, que aumentamos en uno. Traducido al ruso: calculamos estadísticas sobre qué CPU procesó los paquetes entrantes. Intentemos ejecutar el programa:

$ 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

Comprobemos que está conectada a lo y enviar algunos paquetes:

$ 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

Ahora veamos el contenido de la matriz:

$ 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 }
]

Casi todos los procesos se procesaron en CPU7. Esto no es importante para nosotros, lo principal es que el programa funciona y entendemos cómo acceder a los mapas desde los programas BPF, usando хелперов bpf_mp_*.

índice místico

Entonces, podemos acceder al mapa desde el programa BPF usando llamadas como

val = bpf_map_lookup_elem(&woo, &key);

donde se ve la función auxiliar

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

pero estamos pasando un puntero &woo a una estructura sin nombre struct { ... }...

Si miramos el programa ensamblador, vemos que el valor &woo en realidad no está definido (línea 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
...

y está contenido en reubicaciones:

$ 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

Pero si miramos el programa ya cargado, vemos un puntero al mapa correcto (línea 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]
...

Por lo tanto, podemos concluir que en el momento de iniciar nuestro programa de carga, el enlace a &woo fue reemplazado por algo con una biblioteca libbpf. Primero veremos la salida. 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

Vemos que libbpf creó un mapa woo y luego descargué nuestro programa simple. Echemos un vistazo más de cerca a cómo cargamos el programa:

  • llamar xdp_simple_bpf__open_and_load desde el archivo xdp-simple.skel.h
  • cuales causas xdp_simple_bpf__load desde el archivo xdp-simple.skel.h
  • cuales causas bpf_object__load_skeleton desde el archivo libbpf/src/libbpf.c
  • cuales causas bpf_object__load_xattr de libbpf/src/libbpf.c

La última función, entre otras cosas, llamará bpf_object__create_maps, que crea o abre mapas existentes, convirtiéndolos en descriptores de archivos. (Aquí es donde vemos BPF_MAP_CREATE en la salida strace.) A continuación se llama a la función. bpf_object__relocate y es ella quien nos interesa, ya que recordamos lo que vimos woo en la tabla de reubicación. Al explorarlo, finalmente nos encontramos en la función bpf_program__relocate, cual y se ocupa de las reubicaciones de mapas:

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

Entonces tomamos nuestras instrucciones.

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

y reemplace el registro fuente en él con BPF_PSEUDO_MAP_FD, y el primer IMM al descriptor de archivo de nuestro mapa y, si es igual a, por ejemplo, 0xdeadbeef, entonces como resultado recibiremos la instrucción

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

Así es como se transfiere la información del mapa a un programa BPF cargado específico. En este caso, el mapa se puede crear usando BPF_MAP_CREATE, y abierto por ID usando BPF_MAP_GET_FD_BY_ID.

Total, al usar libbpf El algoritmo es como sigue:

  • Durante la compilación, se crean registros en la tabla de reubicación para enlaces a mapas.
  • libbpf abre el libro de objetos ELF, encuentra todos los mapas usados ​​y crea descriptores de archivos para ellos
  • Los descriptores de archivos se cargan en el kernel como parte de la instrucción. LD64

Como puedes imaginar, hay más por venir y tendremos que analizar el núcleo. Afortunadamente tenemos una pista: hemos escrito el significado. BPF_PSEUDO_MAP_FD en el registro fuente y podremos enterrarlo, lo que nos llevará al lugar santo de todos los santos... kernel/bpf/verifier.c, donde una función con un nombre distintivo reemplaza un descriptor de archivo con la dirección de una estructura de tipo 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;

(el código completo se puede encontrar enlace). Entonces podemos expandir nuestro algoritmo:

  • Durante la carga del programa, el verificador comprueba el uso correcto del mapa y escribe la dirección de la estructura correspondiente. struct bpf_map

Al descargar el binario ELF usando libbpf Están sucediendo muchas más cosas, pero las discutiremos en otros artículos.

Cargando programas y mapas sin libbpf

Como prometí, aquí hay un ejemplo para los lectores que quieran saber cómo crear y cargar un programa que use mapas, sin ayuda. libbpf. Esto puede resultar útil cuando trabaja en un entorno para el que no puede crear dependencias, guardar cada bit o escribir un programa como ply, que genera código binario BPF sobre la marcha.

Para que sea más fácil seguir la lógica, reescribiremos nuestro ejemplo para estos propósitos. xdp-simple. El código completo y ligeramente ampliado del programa analizado en este ejemplo se puede encontrar en este esencia.

La lógica de nuestra aplicación es la siguiente:

  • crear un mapa de tipo BPF_MAP_TYPE_ARRAY usando el comando BPF_MAP_CREATE,
  • crear un programa que utilice este mapa,
  • conectar el programa a la interfaz lo,

que se traduce en humano como

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

es map_create crea un mapa de la misma manera que lo hicimos en el primer ejemplo sobre la llamada al sistema bpf - “kernel, por favor hazme un nuevo mapa en forma de una matriz de 8 elementos como __u64 y devuélveme el descriptor del archivo":

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

El programa también es fácil de cargar:

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 parte complicada prog_load es la definición de nuestro programa BPF como un conjunto de estructuras struct bpf_insn insns[]. Pero como estamos usando un programa que tenemos en C, podemos hacer un poco de trampa:

$ 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

En total, necesitamos escribir 14 instrucciones en forma de estructuras como struct bpf_insn (consejo: tome el volcado desde arriba, vuelva a leer la sección de instrucciones, abra linux/bpf.h и linux/bpf_common.h y tratar de determinar struct bpf_insn insns[] por propia cuenta):

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 ejercicio para aquellos que no escribieron esto ellos mismos: encuentre map_fd.

Aún queda una parte no revelada en nuestro programa: xdp_attach. Desafortunadamente, programas como XDP no se pueden conectar mediante una llamada al sistema. bpf. Las personas que crearon BPF y XDP eran de la comunidad Linux en línea, lo que significa que usaron el que les resultaba más familiar (pero no el que les resultaba más familiar). normal personas) interfaz para interactuar con el kernel: enchufes netlink, ver también RFC3549. La forma más sencilla de implementar xdp_attach está copiando código de libbpf, es decir, del archivo netlink.c, que es lo que hicimos, acortándolo un poco:

Bienvenido al mundo de los enchufes netlink

Abrir un tipo 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;
}

Leemos de este 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;
}

Finalmente, aquí está nuestra función que abre un socket y le envía un mensaje especial que contiene un descriptor de archivo:

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

Entonces, todo está listo para la prueba:

$ 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 +++

Veamos si nuestro programa se ha conectado a 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

Enviemos pings y miremos el mapa:

$ 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

Hurra, todo funciona. Tenga en cuenta, por cierto, que nuestro mapa se muestra nuevamente en forma de bytes. Esto se debe a que, a diferencia de libbpf No cargamos información de tipo (BTF). Pero hablaremos más sobre esto la próxima vez.

Herramientas de desarrollo

En esta sección, veremos el conjunto de herramientas mínimo para desarrolladores de BPF.

En términos generales, no se necesita nada especial para desarrollar programas BPF: BPF se ejecuta en cualquier kernel de distribución decente y los programas se crean usando clang, que se puede suministrar desde el paquete. Sin embargo, debido al hecho de que BPF está en desarrollo, el kernel y las herramientas cambian constantemente; si no desea escribir programas BPF utilizando métodos anticuados de 2019, tendrá que compilar

  • llvm/clang
  • pahole
  • su núcleo
  • bpftool

(Como referencia, esta sección y todos los ejemplos del artículo se ejecutaron en Debian 10).

llvm/sonido metálico

BPF es compatible con LLVM y, aunque recientemente los programas para BPF se pueden compilar usando gcc, todo el desarrollo actual se lleva a cabo para LLVM. Por lo tanto, primero que nada, construiremos la versión actual. clang de 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
... много времени спустя
$

Ahora podemos comprobar si todo salió correctamente:

$ ./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

(Instrucciones de montaje clang tomado por mi de bpf_devel_QA.)

No instalaremos los programas que acabamos de crear, sino que simplemente los agregaremos a PATH, Por ejemplo:

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

(Esto se puede agregar a .bashrc o en un archivo separado. Personalmente, agrego cosas como esta a ~/bin/activate-llvm.sh y cuando es necesario lo hago . activate-llvm.sh.)

Pahole y BTF

Utilidad pahole Se utiliza al construir el kernel para crear información de depuración en formato BTF. No entraremos en detalles en este artículo sobre los detalles de la tecnología BTF, aparte del hecho de que es conveniente y queremos usarla. Entonces, si vas a construir tu kernel, compílalo primero. pahole (sin pahole no podrás construir el kernel con la opción 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

Kernels para experimentar con BPF

Al explorar las posibilidades de BPF, quiero construir mi propio núcleo. Esto, en términos generales, no es necesario, ya que podrá compilar y cargar programas BPF en el kernel de la distribución; sin embargo, tener su propio kernel le permitirá utilizar las últimas funciones de BPF, que aparecerán en su distribución en el mejor de los casos en meses. , o, como en el caso de algunas herramientas de depuración, no se empaquetarán en absoluto en un futuro previsible. Además, su propio núcleo hace que sea importante experimentar con el código.

Para construir un kernel necesita, en primer lugar, el propio kernel y, en segundo lugar, un archivo de configuración del kernel. Para experimentar con BPF podemos usar el habitual vainilla kernel o uno de los kernels de desarrollo. Históricamente, el desarrollo de BPF se lleva a cabo dentro de la comunidad de redes Linux y, por lo tanto, todos los cambios, tarde o temprano, pasan por David Miller, el mantenedor de redes Linux. Dependiendo de su naturaleza (ediciones o nuevas funciones), los cambios de red se dividen en uno de dos núcleos: net o net-next. Los cambios para BPF se distribuyen de la misma manera entre bpf и bpf-next, que luego se agrupan en net y net-next, respectivamente. Para más detalles, ver bpf_devel_QA и preguntas frecuentes sobre netdev. Así que elija un kernel según sus gustos y las necesidades de estabilidad del sistema que está probando (*-next Los núcleos son los más inestables de los enumerados).

Está más allá del alcance de este artículo hablar sobre cómo administrar los archivos de configuración del kernel; se supone que ya sabe cómo hacerlo o Listo para aprender por propia cuenta. Sin embargo, las siguientes instrucciones deberían ser más o menos suficientes para brindarle un sistema habilitado para BPF que funcione.

Descargue uno de los kernels anteriores:

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

Cree una configuración de kernel mínima que funcione:

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

Habilitar opciones de BPF en el archivo .config de su propia elección (muy probablemente CONFIG_BPF ya estará habilitado ya que systemd lo usa). Aquí hay una lista de opciones del kernel utilizado para este artículo:

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

Luego podremos ensamblar e instalar fácilmente los módulos y el kernel (por cierto, puedes ensamblar el kernel usando el módulo recién ensamblado). clangañadiendo CC=clang):

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

y reinicie con el nuevo kernel (yo uso para esto kexec del paquete 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

herramientabpf

La utilidad más utilizada en el artículo será la utilidad. bpftool, suministrado como parte del kernel de Linux. Está escrito y mantenido por desarrolladores de BPF para desarrolladores de BPF y puede usarse para administrar todo tipo de objetos de BPF: cargar programas, crear y editar mapas, explorar la vida del ecosistema de BPF, etc. Se puede encontrar documentación en forma de códigos fuente para páginas de manual. en el núcleo o, ya compilado, Red.

Al momento de escribir bpftool viene listo para usar sólo para RHEL, Fedora y Ubuntu (ver, por ejemplo, este hilo, que cuenta la historia inacabada del packaging bpftool en Debian). Pero si ya ha construido su kernel, entonces construya bpftool tan fácil como un pastel:

$ 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  ]

$

(aquí ${linux} - este es el directorio de su kernel). Después de ejecutar estos comandos bpftool se recogerá en un directorio ${linux}/tools/bpf/bpftool y se puede agregar a la ruta (en primer lugar al usuario root) o simplemente copiar a /usr/local/sbin.

reunir bpftool lo mejor es usar este ultimo clang, ensamblado como se describe arriba, y verifique si está ensamblado correctamente, usando, por ejemplo, el comando

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

que mostrará qué características de BPF están habilitadas en su kernel.

Por cierto, el comando anterior se puede ejecutar como

# bpftool f p k

Esto se hace por analogía con las utilidades del paquete. iproute2, donde podemos, por ejemplo, decir ip a s eth0 en lugar de ip addr show dev eth0.

Conclusión

BPF le permite calzar una pulga para medir eficazmente y cambiar sobre la marcha la funcionalidad del núcleo. El sistema resultó ser muy exitoso, siguiendo las mejores tradiciones de UNIX: un mecanismo simple que permite (re)programar el kernel permitió experimentar a una gran cantidad de personas y organizaciones. Y, aunque los experimentos, así como el desarrollo de la infraestructura BPF en sí, están lejos de terminar, el sistema ya cuenta con una ABI estable que le permite construir una lógica de negocios confiable y, lo más importante, efectiva.

Me gustaría señalar que, en mi opinión, la tecnología se ha vuelto tan popular porque, por un lado, puede jugar (la arquitectura de la máquina se puede entender más o menos en una tarde), y por otro lado, resolver problemas que no se podían resolver (bellamente) antes de su aparición. Estos dos componentes juntos obligan a las personas a experimentar y soñar, lo que conduce al surgimiento de soluciones cada vez más innovadoras.

Este artículo, aunque no es particularmente breve, es solo una introducción al mundo de BPF y no describe características "avanzadas" ni partes importantes de la arquitectura. El plan a seguir es algo como esto: el próximo artículo será una descripción general de los tipos de programas BPF (hay 5.8 tipos de programas soportados en el kernel 30), luego finalmente veremos cómo escribir aplicaciones BPF reales usando programas de seguimiento del kernel. como ejemplo, entonces es hora de un curso más profundo sobre arquitectura BPF, seguido de ejemplos de redes BPF y aplicaciones de seguridad.

Artículos anteriores de esta serie.

  1. BPF para los más pequeños, parte cero: BPF clásico

Enlaces

  1. Guía de referencia de BPF y XDP — documentación sobre BPF de cilio, o más precisamente de Daniel Borkman, uno de los creadores y mantenedores de BPF. Esta es una de las primeras descripciones serias, que se diferencia de las demás en que Daniel sabe exactamente sobre lo que escribe y no hay errores en ello. En particular, este documento describe cómo trabajar con programas BPF de los tipos XDP y TC utilizando la conocida utilidad ip del paquete iproute2.

  2. Documentación/redes/filtro.txt — archivo original con documentación para BPF clásico y luego extendido. Una buena lectura si desea profundizar en el lenguaje ensamblador y los detalles técnicos de la arquitectura.

  3. Blog sobre BPF de facebook. Se actualiza rara vez, pero acertadamente, como escriben allí Alexei Starovoitov (autor de eBPF) y Andrii Nakryiko (mantenedor). libbpf).

  4. Secretos de bpftool. Un entretenido hilo de Twitter de Quentin Monnet con ejemplos y secretos del uso de bpftool.

  5. Sumérgete en BPF: una lista de material de lectura. Una lista gigante (y aún mantenida) de enlaces a documentación de BPF de Quentin Monnet.

Fuente: habr.com

Añadir un comentario