BPF para os mais pequenos, primeira parte: BPF ampliado

No começo existia uma tecnologia e se chamava BPF. Nós olhamos para ela anterior, Artigo do Antigo Testamento desta série. Em 2013, através dos esforços de Alexei Starovoitov e Daniel Borkman, uma versão melhorada dele, otimizada para máquinas modernas de 64 bits, foi desenvolvida e incluída no kernel Linux. Essa nova tecnologia foi brevemente chamada de BPF Interno, depois renomeada como BPF Estendido e agora, depois de vários anos, todos simplesmente a chamam de BPF.

Grosso modo, o BPF permite executar código arbitrário fornecido pelo usuário no espaço do kernel Linux, e a nova arquitetura teve tanto sucesso que precisaremos de mais uma dúzia de artigos para descrever todas as suas aplicações. (A única coisa que os desenvolvedores não fizeram bem, como você pode ver no código de desempenho abaixo, foi criar um logotipo decente.)

Este artigo descreve a estrutura da máquina virtual BPF, interfaces de kernel para trabalhar com BPF, ferramentas de desenvolvimento, bem como uma breve visão geral dos recursos existentes, ou seja, tudo o que precisaremos no futuro para um estudo mais aprofundado das aplicações práticas do BPF.
BPF para os mais pequenos, primeira parte: BPF ampliado

Resumo do artigo

Introdução à arquitetura BPF. Primeiro, teremos uma visão geral da arquitetura BPF e descreveremos os principais componentes.

Sistema de registros e comandos da máquina virtual BPF. Já tendo uma ideia da arquitetura como um todo, descreveremos a estrutura da máquina virtual BPF.

Ciclo de vida de objetos BPF, sistema de arquivos bpffs. Nesta seção, examinaremos mais de perto o ciclo de vida dos objetos BPF – programas e mapas.

Gerenciando objetos usando a chamada de sistema bpf. Com alguma compreensão do sistema já implementado, finalmente veremos como criar e manipular objetos do espaço do usuário usando uma chamada de sistema especial - bpf(2).

Пишем программы BPF с помощью libbpf. Claro, você pode escrever programas usando uma chamada de sistema. Mas é difícil. Para um cenário mais realista, os programadores nucleares desenvolveram uma biblioteca libbpf. Criaremos um esqueleto básico de aplicação BPF que usaremos nos exemplos subsequentes.

Ajudantes do kernel. Aqui aprenderemos como os programas BPF podem acessar as funções auxiliares do kernel - uma ferramenta que, junto com os mapas, expande fundamentalmente as capacidades do novo BPF em comparação com o clássico.

Acesso a mapas dos programas BPF. Neste ponto, já saberemos o suficiente para entender exatamente como podemos criar programas que utilizam mapas. E vamos dar uma olhada rápida no grande e poderoso verificador.

Ferramentas de desenvolvimento. Seção de ajuda sobre como montar os utilitários e o kernel necessários para experimentos.

Conclusão. Ao final do artigo, quem leu até aqui encontrará palavras motivadoras e uma breve descrição do que acontecerá nos próximos artigos. Também listaremos uma série de links para autoestudo para quem não tem vontade ou capacidade de esperar pela continuação.

Introdução à Arquitetura BPF

Antes de começarmos a considerar a arquitetura BPF, nos referiremos uma última vez (oh) a BPF clássico, que foi desenvolvido como resposta ao advento das máquinas RISC e resolveu o problema da filtragem eficiente de pacotes. A arquitetura acabou sendo tão bem-sucedida que, tendo nascido nos anos XNUMX em Berkeley UNIX, foi portada para a maioria dos sistemas operacionais existentes, sobreviveu até os loucos anos XNUMX e ainda encontra novas aplicações.

O novo BPF foi desenvolvido como resposta à onipresença de máquinas de 64 bits, serviços em nuvem e à crescente necessidade de ferramentas para criação de SDN (Software-dafinado nrede). Desenvolvido por engenheiros de rede do kernel como um substituto aprimorado para o BPF clássico, o novo BPF literalmente seis meses depois encontrou aplicações na difícil tarefa de rastrear sistemas Linux, e agora, seis anos após seu aparecimento, precisaremos de um próximo artigo inteiro apenas para listar os diferentes tipos de programas.

Imagens engraçadas

Basicamente, o BPF é uma máquina virtual sandbox que permite executar código “arbitrário” no espaço do kernel sem comprometer a segurança. Os programas BPF são criados no espaço do usuário, carregados no kernel e conectados a alguma fonte de eventos. Um evento poderia ser, por exemplo, a entrega de um pacote a uma interface de rede, o lançamento de alguma função do kernel, etc. No caso de um pacote, o programa BPF terá acesso aos dados e metadados do pacote (para leitura e, possivelmente, escrita, dependendo do tipo de programa); no caso de execução de uma função kernel, os argumentos de a função, incluindo ponteiros para a memória do kernel, etc.

Vamos dar uma olhada mais de perto nesse processo. Para começar, vamos falar sobre a primeira diferença do BPF clássico, cujos programas foram escritos em assembler. Na nova versão, a arquitetura foi ampliada para que os programas pudessem ser escritos em linguagens de alto nível, principalmente, claro, em C. Para isso, foi desenvolvido um backend para llvm, que permite gerar bytecode para a arquitetura BPF.

BPF para os mais pequenos, primeira parte: BPF ampliado

A arquitetura BPF foi projetada, em parte, para funcionar de forma eficiente em máquinas modernas. Para fazer isso funcionar na prática, o bytecode BPF, uma vez carregado no kernel, é traduzido em código nativo usando um componente chamado compilador JIT (JUst In Tsim). A seguir, se você se lembra, no BPF clássico o programa era carregado no kernel e anexado à fonte do evento atomicamente - no contexto de uma única chamada de sistema. Na nova arquitetura, isso acontece em dois estágios - primeiro, o código é carregado no kernel usando uma chamada de sistema bpf(2)e mais tarde, através de outros mecanismos que variam dependendo do tipo de programa, o programa é anexado à fonte do evento.

Aqui o leitor pode ter uma pergunta: foi possível? Como é garantida a segurança de execução desse código? A segurança de execução nos é garantida pela etapa de carregamento dos programas BPF denominada verifier (em inglês essa etapa se chama verifier e continuarei usando a palavra em inglês):

BPF para os mais pequenos, primeira parte: BPF ampliado

Verificador é um analisador estático que garante que um programa não interrompa a operação normal do kernel. A propósito, isso não significa que o programa não possa interferir na operação do sistema - os programas BPF, dependendo do tipo, podem ler e reescrever seções da memória do kernel, retornar valores de funções, cortar, anexar, reescrever e até mesmo encaminhar pacotes de rede. O verificador garante que a execução de um programa BPF não travará o kernel e que um programa que, de acordo com as regras, tenha acesso de gravação, por exemplo, os dados de um pacote de saída, não será capaz de sobrescrever a memória do kernel fora do pacote. Veremos o verificador com um pouco mais de detalhes na seção correspondente, depois de nos familiarizarmos com todos os outros componentes do BPF.

Então, o que aprendemos até agora? O usuário escreve um programa em C, carrega-o no kernel usando uma chamada de sistema bpf(2), onde é verificado por um verificador e traduzido em bytecode nativo. Em seguida, o mesmo ou outro usuário conecta o programa à fonte do evento e ele começa a ser executado. Separar inicialização e conexão é necessário por vários motivos. Em primeiro lugar, executar um verificador é relativamente caro e, ao descarregar o mesmo programa várias vezes, desperdiçamos tempo de computador. Em segundo lugar, a forma exacta como um programa está ligado depende do seu tipo, e uma interface “universal” desenvolvida há um ano pode não ser adequada para novos tipos de programas. (Embora agora que a arquitectura esteja a tornar-se mais madura, existe uma ideia de unificar esta interface ao nível libbpf.)

O leitor atento poderá perceber que ainda não terminamos as fotos. Na verdade, tudo o que foi dito acima não explica por que o BPF muda fundamentalmente o quadro em comparação com o BPF clássico. Duas inovações que expandem significativamente o escopo de aplicabilidade são a capacidade de usar memória compartilhada e funções auxiliares do kernel. No BPF, a memória compartilhada é implementada por meio dos chamados mapas - estruturas de dados compartilhadas com uma API específica. Eles provavelmente receberam esse nome porque o primeiro tipo de mapa a aparecer foi uma tabela hash. Depois apareceram arrays, tabelas hash locais (por CPU) e arrays locais, árvores de busca, mapas contendo ponteiros para programas BPF e muito mais. O que é interessante para nós agora é que os programas BPF agora têm a capacidade de persistir o estado entre as chamadas e compartilhá-lo com outros programas e com o espaço do usuário.

Os mapas são acessados ​​a partir de processos de usuário usando uma chamada de sistema bpf(2), e de programas BPF em execução no kernel usando funções auxiliares. Além disso, existem auxiliares não apenas para trabalhar com mapas, mas também para acessar outros recursos do kernel. Por exemplo, os programas BPF podem usar funções auxiliares para encaminhar pacotes para outras interfaces, gerar eventos de desempenho, acessar estruturas de kernel e assim por diante.

BPF para os mais pequenos, primeira parte: BPF ampliado

Em resumo, o BPF fornece a capacidade de carregar código de usuário arbitrário, ou seja, testado pelo verificador, no espaço do kernel. Este código pode salvar o estado entre chamadas e trocar dados com o espaço do usuário, além de ter acesso aos subsistemas do kernel permitidos por este tipo de programa.

Isso já é semelhante aos recursos fornecidos pelos módulos do kernel, comparados aos quais o BPF tem algumas vantagens (é claro, você só pode comparar aplicativos semelhantes, por exemplo, rastreamento de sistema - você não pode escrever um driver arbitrário com BPF). Você pode notar um limite de entrada mais baixo (alguns utilitários que usam BPF não exigem que o usuário tenha habilidades de programação de kernel, ou habilidades de programação em geral), segurança de tempo de execução (levante a mão nos comentários para quem não quebrou o sistema ao escrever ou testes de módulos), atomicidade - há tempo de inatividade ao recarregar módulos, e o subsistema BPF garante que nenhum evento seja perdido (para ser justo, isso não é verdade para todos os tipos de programas BPF).

A presença de tais capacidades faz do BPF uma ferramenta universal para expansão do kernel, o que se confirma na prática: cada vez mais novos tipos de programas são adicionados ao BPF, cada vez mais grandes empresas usam o BPF em servidores de combate 24 horas por dia, 7 dias por semana, cada vez mais startups constroem seus negócios em soluções baseadas no BPF. O BPF é usado em todos os lugares: na proteção contra ataques DDoS, na criação de SDN (por exemplo, na implementação de redes para kubernetes), como principal ferramenta de rastreamento de sistema e coletor de estatísticas, em sistemas de detecção de intrusões e sistemas sandbox, etc.

Vamos terminar a parte geral do artigo aqui e examinar a máquina virtual e o ecossistema BPF com mais detalhes.

Digressão: utilidades

Para poder executar os exemplos nas seções a seguir, você pode precisar de vários utilitários, pelo menos llvm/clang com suporte bpf e bpftool. Na seção Ferramentas de desenvolvimento Você pode ler as instruções para montar os utilitários, bem como o seu kernel. Esta seção está colocada abaixo para não perturbar a harmonia de nossa apresentação.

Registros de máquinas virtuais BPF e sistema de instrução

A arquitetura e o sistema de comandos do BPF foram desenvolvidos levando em consideração o fato de que os programas serão escritos na linguagem C e, após carregados no kernel, traduzidos em código nativo. Portanto, o número de registros e o conjunto de comandos foram escolhidos pensando na intersecção, no sentido matemático, das capacidades das máquinas modernas. Além disso, várias restrições foram impostas aos programas, por exemplo, até recentemente não era possível escrever loops e sub-rotinas, e o número de instruções era limitado a 4096 (agora programas privilegiados podem carregar até um milhão de instruções).

BPF tem onze registros de 64 bits acessíveis ao usuário r0-r10 e um contador de programa. Registro r10 contém um ponteiro de quadro e é somente leitura. Os programas têm acesso a uma pilha de 512 bytes em tempo de execução e a uma quantidade ilimitada de memória compartilhada na forma de mapas.

Os programas BPF podem executar um conjunto específico de auxiliares de kernel do tipo programa e, mais recentemente, funções regulares. Cada função chamada pode receber até cinco argumentos, passados ​​em registradores r1-r5, e o valor de retorno é passado para r0. É garantido que após retornar da função, o conteúdo dos registradores r6-r9 não vai mudar.

Para uma tradução eficiente do programa, os registros r0-r11 para todas as arquiteturas suportadas são mapeadas exclusivamente para registros reais, levando em consideração os recursos ABI da arquitetura atual. Por exemplo, para x86_64 registros r1-r5, usados ​​para passar parâmetros de função, são exibidos em rdi, rsi, rdx, rcx, r8, que são usados ​​para passar parâmetros para funções em x86_64. Por exemplo, o código à esquerda se traduz no código à direita assim:

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

O registro r0 também usado para retornar o resultado da execução do programa, e no registro r1 o programa recebe um ponteiro para o contexto - dependendo do tipo de programa, pode ser, por exemplo, uma estrutura struct xdp_md (para XDP) ou estrutura struct __sk_buff (para diferentes programas de rede) ou estrutura struct pt_regs (para diferentes tipos de programas de rastreamento), etc.

Portanto, tínhamos um conjunto de registradores, auxiliares de kernel, uma pilha, um ponteiro de contexto e memória compartilhada na forma de mapas. Não que tudo isso seja absolutamente necessário na viagem, mas...

Vamos continuar a descrição e falar sobre o sistema de comando para trabalhar com esses objetos. Todos (quase tudo) As instruções BPF têm um tamanho fixo de 64 bits. Se você observar uma instrução em uma máquina Big Endian de 64 bits, verá

BPF para os mais pequenos, primeira parte: BPF ampliado

é Code - esta é a codificação da instrução, Dst/Src são as codificações do receptor e da fonte, respectivamente, Off - recuo assinado de 16 bits e Imm é um inteiro assinado de 32 bits usado em algumas instruções (semelhante à constante K cBPF). Codificação Code tem um de dois tipos:

BPF para os mais pequenos, primeira parte: BPF ampliado

As classes de instrução 0, 1, 2, 3 definem comandos para trabalhar com memória. Eles são chamados, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, respectivamente. Aulas 4, 7 (BPF_ALU, BPF_ALU64) constituem um conjunto de instruções ALU. Aulas 5, 6 (BPF_JMP, BPF_JMP32) contém instruções de salto.

O plano adicional para estudar o sistema de instrução BPF é o seguinte: em vez de listar meticulosamente todas as instruções e seus parâmetros, consideraremos alguns exemplos nesta seção e a partir deles ficará claro como as instruções realmente funcionam e como desmonte manualmente qualquer arquivo binário para BPF. Para consolidar o material posteriormente neste artigo, também encontraremos instruções individuais nas seções sobre Verificador, compilador JIT, tradução do BPF clássico, bem como ao estudar mapas, chamar funções, etc.

Quando falamos sobre instruções individuais, nos referimos aos arquivos principais bpf.h и bpf_common.h, que definem os códigos numéricos das instruções BPF. Ao estudar arquitetura por conta própria e/ou analisar binários, você pode encontrar semântica nas seguintes fontes, classificadas em ordem de complexidade: Especificação não oficial do eBPF, Guia de referência BPF e XDP, conjunto de instruções, Documentação/rede/filter.txt e, claro, no código-fonte do Linux - verificador, JIT, interpretador BPF.

Exemplo: desmontando o BPF na sua cabeça

Vejamos um exemplo em que compilamos um programa readelf-example.c e observe o binário resultante. Vamos revelar o conteúdo original readelf-example.c abaixo, depois de restaurarmos sua lógica a partir de códigos binários:

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

Primeira coluna na saída readelf é um recuo e nosso programa consiste, portanto, em quatro 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

Os códigos de comando são iguais b7, 15, b7 и 95. Lembre-se de que os três bits menos significativos são a classe de instrução. No nosso caso, o quarto bit de todas as instruções está vazio, então as classes de instrução são 7, 5, 7, 5, respectivamente. A classe 7 é BPF_ALU64, e 5 é BPF_JMP. Para ambas as classes, o formato da instrução é o mesmo (veja acima) e podemos reescrever nosso programa assim (ao mesmo tempo reescreveremos as colunas restantes na 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

Operação b classe ALU64 - É BPF_MOV. Atribui um valor ao registrador de destino. Se o bit estiver definido s (fonte), então o valor é retirado do registro de origem, e se, como no nosso caso, não estiver definido, então o valor é retirado do campo Imm. Portanto, na primeira e na terceira instruções realizamos a operação r0 = Imm. Além disso, a operação JMP classe 1 é BPF_JEQ (pule se for igual). No nosso caso, desde o pouco S é zero, ele compara o valor do registro de origem com o campo Imm. Se os valores coincidirem, então ocorre a transição para PC + OffOnde PC, como sempre, contém o endereço da próxima instrução. Finalmente, a operação JMP Classe 9 é BPF_EXIT. Esta instrução encerra o programa, retornando ao kernel r0. Vamos adicionar uma nova coluna à nossa tabela:

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 reescrever isso de uma forma mais conveniente:

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

Se lembrarmos o que está no registro r1 o programa recebe um ponteiro para o contexto do kernel e no registro r0 o valor é retornado ao kernel, então podemos ver que se o ponteiro para o contexto for zero, então retornamos 1, caso contrário - 2. Vamos verificar se estamos certos olhando a fonte:

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

Sim, é um programa sem sentido, mas se traduz em apenas quatro instruções simples.

Exemplo de exceção: instrução de 16 bytes

Mencionamos anteriormente que algumas instruções ocupam mais de 64 bits. Isto se aplica, por exemplo, a instruções lddw (Código = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — carrega uma palavra dupla dos campos no registro Imm. Ponto é que Imm tem um tamanho de 32 e uma palavra dupla tem 64 bits, portanto, carregar um valor imediato de 64 bits em um registro em uma instrução de 64 bits não funcionará. Para fazer isso, duas instruções adjacentes são usadas para armazenar a segunda parte do valor de 64 bits no campo Imm. Exemplo:

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

Existem apenas duas instruções em um programa binário:

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

Nos encontraremos novamente com instruções lddw, quando falamos em realocações e trabalho com mapas.

Exemplo: desmontagem do BPF usando ferramentas padrão

Assim, aprendemos a ler códigos binários BPF e estamos prontos para analisar qualquer instrução, se necessário. Porém, vale dizer que na prática é mais conveniente e rápido desmontar programas usando ferramentas padrão, por exemplo:

$ 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 arquivos bpffs

(Aprendi pela primeira vez alguns dos detalhes descritos nesta subseção com jejum Alexei Starovoitov em Blogue do BPF.)

Objetos BPF - programas e mapas - são criados a partir do espaço do usuário usando comandos BPF_PROG_LOAD и BPF_MAP_CREATE chamada de sistema bpf(2), falaremos sobre exatamente como isso acontece na próxima seção. Isso cria estruturas de dados do kernel e para cada uma delas refcount (contagem de referência) é definido como um e um descritor de arquivo apontando para o objeto é retornado ao usuário. Depois que a alça for fechada refcount o objeto é reduzido em um e, quando chega a zero, o objeto é destruído.

Se o programa usar mapas, então refcount esses mapas são aumentados em um após carregar o programa, ou seja, seus descritores de arquivo podem ser fechados no processo do usuário e ainda refcount não se tornará zero:

BPF para os mais pequenos, primeira parte: BPF ampliado

Depois de carregar um programa com sucesso, geralmente o anexamos a algum tipo de gerador de eventos. Por exemplo, podemos colocá-lo em uma interface de rede para processar pacotes recebidos ou conectá-lo a algum tracepoint no núcleo. Neste ponto, o contador de referência também aumentará em um e poderemos fechar o descritor de arquivo no programa carregador.

O que acontece se desligarmos agora o bootloader? Depende do tipo de gerador de eventos (hook). Todos os ganchos de rede existirão após a conclusão do carregador; esses são os chamados ganchos globais. E, por exemplo, os programas de rastreamento serão liberados após o término do processo que os criou (e, portanto, são chamados de locais, de “local para o processo”). Tecnicamente, os ganchos locais sempre possuem um descritor de arquivo correspondente no espaço do usuário e, portanto, fecham quando o processo é fechado, mas os ganchos globais não. Na figura a seguir, usando cruzes vermelhas, tento mostrar como o encerramento do programa carregador afeta a vida útil dos objetos no caso de ganchos locais e globais.

BPF para os mais pequenos, primeira parte: BPF ampliado

Por que existe uma distinção entre ganchos locais e globais? Executar alguns tipos de programas de rede faz sentido sem um espaço de usuário, por exemplo, imagine a proteção DDoS - o bootloader escreve as regras e conecta o programa BPF à interface de rede, após o qual o bootloader pode se matar. Por outro lado, imagine um programa de rastreamento de depuração que você escreveu de joelhos em dez minutos - quando terminar, você gostaria que não houvesse mais lixo no sistema, e os ganchos locais garantirão isso.

Por outro lado, imagine que você deseja se conectar a um tracepoint no kernel e coletar estatísticas ao longo de muitos anos. Nesse caso, você desejaria completar a parte do usuário e retornar às estatísticas de tempos em tempos. O sistema de arquivos bpf oferece essa oportunidade. É um sistema de pseudoarquivos somente na memória que permite a criação de arquivos que fazem referência a objetos BPF e, assim, aumentam refcount objetos. Depois disso, o carregador pode sair e os objetos criados permanecerão vivos.

BPF para os mais pequenos, primeira parte: BPF ampliado

A criação de arquivos em bpffs que fazem referência a objetos BPF é chamada de "fixação" (como na seguinte frase: "o processo pode fixar um programa ou mapa BPF"). Criar objetos de arquivo para objetos BPF faz sentido não apenas para prolongar a vida útil de objetos locais, mas também para a usabilidade de objetos globais - voltando ao exemplo do programa global de proteção DDoS, queremos poder ver as estatísticas de tempos em tempos.

O sistema de arquivos BPF geralmente é montado em /sys/fs/bpf, mas também pode ser montado localmente, por exemplo, assim:

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

Os nomes dos sistemas de arquivos são criados usando o comando BPF_OBJ_PIN Chamada de sistema BPF. Para ilustrar, vamos pegar um programa, compilá-lo, carregá-lo e fixá-lo bpffs. Nosso programa não faz nada de útil, apenas apresentamos o código para que você possa reproduzir o exemplo:

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

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

Vamos compilar este programa e criar uma cópia local do sistema de arquivos bpffs:

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

Agora vamos baixar nosso programa usando o utilitário bpftool e veja as chamadas de sistema que acompanham bpf(2) (algumas linhas irrelevantes removidas da saída do 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

Aqui carregamos o programa usando BPF_PROG_LOAD, recebeu um descritor de arquivo do kernel 3 e usando o comando BPF_OBJ_PIN fixou este descritor de arquivo como um arquivo "bpf-mountpoint/test". Depois disso, o programa bootloader bpftool terminou de funcionar, mas nosso programa permaneceu no kernel, embora não o tenhamos anexado a nenhuma interface de rede:

$ 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 excluir o objeto de arquivo normalmente unlink(2) e depois disso o programa correspondente será excluído:

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

Excluindo objetos

Falando em exclusão de objetos, é necessário esclarecer que após desconectarmos o programa do gancho (gerador de eventos), nenhum novo evento irá desencadear seu lançamento, porém, todas as instâncias atuais do programa serão concluídas na ordem normal .

Alguns tipos de programas BPF permitem substituir o programa instantaneamente, ou seja, fornecer atomicidade de sequência replace = detach old program, attach new program. Nesse caso, todas as instâncias ativas da versão antiga do programa terminarão seu trabalho e novos manipuladores de eventos serão criados a partir do novo programa, e “atomicidade” aqui significa que nenhum evento será perdido.

Anexando programas a fontes de eventos

Neste artigo, não descreveremos separadamente a conexão de programas a fontes de eventos, pois faz sentido estudar isso no contexto de um tipo específico de programa. Cm. exemplo abaixo, onde mostramos como programas como o XDP estão conectados.

Manipulando objetos usando a chamada de sistema bpf

Programas BPF

Todos os objetos BPF são criados e gerenciados no espaço do usuário usando uma chamada de sistema bpf, tendo o seguinte protótipo:

#include <linux/bpf.h>

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

Aqui está a equipe cmd é um dos valores do tipo enum bpf_cmd, attr — um ponteiro para parâmetros de um programa específico e size — tamanho do objeto de acordo com o ponteiro, ou seja, geralmente isso sizeof(*attr). No kernel 5.8 a chamada do sistema bpf suporta 34 comandos diferentes e definição union bpf_attr ocupa 200 linhas. Mas não devemos nos intimidar com isso, pois iremos nos familiarizar com os comandos e parâmetros ao longo de diversos artigos.

Vamos começar com a equipe BPF_PROG_LOAD, que cria programas BPF - pega um conjunto de instruções BPF e carrega-o no kernel. No momento do carregamento, o verificador é iniciado e, em seguida, o compilador JIT e, após a execução bem-sucedida, o descritor do arquivo do programa é retornado ao usuário. Vimos o que acontece com ele a seguir na seção anterior sobre o ciclo de vida dos objetos BPF.

Vamos agora escrever um programa personalizado que irá carregar um programa BPF simples, mas primeiro precisamos decidir que tipo de programa queremos carregar - teremos que selecionar тип e dentro da estrutura desse tipo, escreva um programa que passe no teste do verificador. Porém, para não complicar o processo, aqui está uma solução pronta: pegaremos um programa como BPF_PROG_TYPE_XDP, que retornará o valor XDP_PASS (pule todos os pacotes). No assembler BPF parece muito simples:

r0 = 2
exit

Depois de termos decidido que faremos o upload, podemos dizer como faremos isso:

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

Eventos interessantes em um programa começam com a definição de um array insns - nosso programa BPF em código de máquina. Neste caso, cada instrução do programa BPF é compactada na estrutura bpf_insn. Primeiro elemento insns está em conformidade com as instruções r0 = 2, o segundo - exit.

Retiro. O kernel define macros mais convenientes para escrever códigos de máquina e usar o arquivo de cabeçalho do kernel tools/include/linux/filter.h poderíamos escrever

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

Mas como escrever programas BPF em código nativo só é necessário para escrever testes no kernel e artigos sobre BPF, a ausência dessas macros não complica muito a vida do desenvolvedor.

Depois de definir o programa BPF, passamos a carregá-lo no kernel. Nosso conjunto minimalista de parâmetros attr inclui o tipo de programa, conjunto e número de instruções, licença necessária e nome "woo", que usamos para encontrar nosso programa no sistema após o download. O programa, conforme prometido, é carregado no sistema usando uma chamada de sistema bpf.

Ao final do programa terminamos em um loop infinito que simula o payload. Sem ele, o programa será eliminado pelo kernel quando o descritor de arquivo que a chamada do sistema nos retornou for fechado bpf, e não veremos isso no sistema.

Bem, estamos prontos para testes. Vamos montar e executar o programa em stracepara verificar se tudo está funcionando como deveria:

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

Tudo está bem, bpf(2) retornou o identificador 3 para nós e entramos em um loop infinito com pause(). Vamos tentar encontrar nosso programa no sistema. Para fazer isso iremos para outro terminal e usaremos o utilitário 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 há um programa carregado no sistema woo cujo ID global é 390 e está em andamento simple-prog existe um descritor de arquivo aberto apontando para o programa (e se simple-prog terminará o trabalho, então woo vai desaparecer). Como esperado, o programa woo ocupa 16 bytes – duas instruções – de códigos binários na arquitetura BPF, mas em sua forma nativa (x86_64) já são 40 bytes. Vejamos nosso programa em sua forma original:

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

sem surpresas. Agora vamos dar uma olhada no código gerado pelo 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

não é muito eficaz para exit(2), mas para ser justo, nosso programa é muito simples e, para programas não triviais, o prólogo e o epílogo adicionados pelo compilador JIT são, obviamente, necessários.

mapas

Os programas BPF podem usar áreas de memória estruturada que são acessíveis tanto a outros programas BPF quanto a programas no espaço do usuário. Esses objetos são chamados de mapas e nesta seção mostraremos como manipulá-los usando uma chamada de sistema bpf.

Digamos desde já que as capacidades dos mapas não se limitam apenas ao acesso à memória partilhada. Existem mapas para fins especiais contendo, por exemplo, ponteiros para programas BPF ou ponteiros para interfaces de rede, mapas para trabalhar com eventos de desempenho, etc. Não falaremos deles aqui, para não confundir o leitor. Além disso, ignoramos os problemas de sincronização, uma vez que isto não é importante para os nossos exemplos. Uma lista completa dos tipos de mapas disponíveis pode ser encontrada em <linux/bpf.h>, e nesta seção tomaremos como exemplo o primeiro tipo historicamente, a tabela hash BPF_MAP_TYPE_HASH.

Se você criar uma tabela hash em, digamos, C++, você diria unordered_map<int,long> woo, que em russo significa “Preciso de uma mesa woo tamanho ilimitado, cujas chaves são do tipo int, e os valores são do tipo long" Para criar uma tabela hash BPF, precisamos fazer praticamente a mesma coisa, exceto que precisamos especificar o tamanho máximo da tabela e, em vez de especificar os tipos de chaves e valores, precisamos especificar seus tamanhos em bytes . Para criar mapas use o comando BPF_MAP_CREATE chamada de sistema bpf. Vejamos um programa mais ou menos minimalista que cria um mapa. Depois do programa anterior que carrega programas BPF, este deve parecer simples para você:

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

Aqui definimos um conjunto de parâmetros attr, em que dizemos “Preciso de uma tabela hash com chaves e valores de tamanho sizeof(int), no qual posso colocar no máximo quatro elementos." Ao criar mapas BPF, você pode especificar outros parâmetros, por exemplo, da mesma forma que no exemplo do programa, especificamos o nome do objeto como "woo".

Vamos compilar e executar o 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(

Aqui está a chamada do sistema bpf(2) nos retornou o número do mapa descritor 3 e então o programa, como esperado, aguarda mais instruções na chamada do sistema pause(2).

Agora vamos enviar nosso programa para segundo plano ou abrir outro terminal e olhar nosso objeto usando o utilitário bpftool (podemos distinguir nosso mapa dos outros pelo nome):

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

O número 114 é o ID global do nosso objeto. Qualquer programa no sistema pode usar este ID para abrir um mapa existente usando o comando BPF_MAP_GET_FD_BY_ID chamada de sistema bpf.

Agora podemos brincar com nossa tabela hash. Vejamos seu conteúdo:

$ sudo bpftool map dump id 114
Found 0 elements

Vazio. Vamos colocar um valor nisso hash[1] = 1:

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

Vejamos a tabela novamente:

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

Viva! Conseguimos adicionar um elemento. Observe que temos que trabalhar no nível de byte para fazer isso, já que bptftool não sabe de que tipo são os valores na tabela hash. (Esse conhecimento pode ser transferido para ela usando BTF, mas falaremos mais sobre isso agora.)

Como exatamente o bpftool lê e adiciona elementos? Vamos dar uma olhada nos bastidores:

$ 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

Primeiro abrimos o mapa pelo seu ID global usando o comando BPF_MAP_GET_FD_BY_ID и bpf(2) retornou o descritor 3 para nós. Usando ainda mais o comando BPF_MAP_GET_NEXT_KEY encontramos a primeira chave da tabela passando NULL como um ponteiro para a chave "anterior". Se tivermos a chave, podemos fazer BPF_MAP_LOOKUP_ELEMque retorna um valor para um ponteiro value. O próximo passo é tentar encontrar o próximo elemento passando um ponteiro para a chave atual, mas nossa tabela contém apenas um elemento e o comando BPF_MAP_GET_NEXT_KEY devolve ENOENT.

Ok, vamos alterar o valor pela chave 1, digamos que nossa lógica de negócios exija registro 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 esperado, é muito simples: o comando BPF_MAP_GET_FD_BY_ID abre nosso mapa por ID, e o comando BPF_MAP_UPDATE_ELEM substitui o elemento.

Assim, após criar uma tabela hash de um programa, podemos ler e escrever seu conteúdo em outro. Observe que se pudéssemos fazer isso na linha de comando, qualquer outro programa no sistema poderia fazê-lo. Além dos comandos descritos acima, para trabalhar com mapas do espaço do usuário, A seguir:

  • BPF_MAP_LOOKUP_ELEM: encontre o valor por chave
  • BPF_MAP_UPDATE_ELEM: atualizar/criar valor
  • BPF_MAP_DELETE_ELEM: remover chave
  • BPF_MAP_GET_NEXT_KEY: encontre a próxima (ou primeira) chave
  • BPF_MAP_GET_NEXT_ID: permite percorrer todos os mapas existentes, é assim que funciona bpftool map
  • BPF_MAP_GET_FD_BY_ID: abre um mapa existente pelo seu ID global
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: atualiza atomicamente o valor de um objeto e retorna o antigo
  • BPF_MAP_FREEZE: torna o mapa imutável no espaço do usuário (esta operação não pode ser desfeita)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: operações em massa. Por exemplo, BPF_MAP_LOOKUP_AND_DELETE_BATCH - esta é a única maneira confiável de ler e redefinir todos os valores do mapa

Nem todos esses comandos funcionam para todos os tipos de mapas, mas em geral trabalhar com outros tipos de mapas do espaço do usuário é exatamente igual a trabalhar com tabelas hash.

Por uma questão de ordem, vamos terminar nossos experimentos com tabelas hash. Lembra que criamos uma tabela que pode conter até quatro chaves? Vamos adicionar mais alguns elementos:

$ 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

Até agora tudo bem:

$ 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

Vamos tentar adicionar mais um:

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

Como esperado, não tivemos sucesso. Vejamos o erro com mais detalhes:

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

Está tudo bem: como esperado, a equipe BPF_MAP_UPDATE_ELEM tenta criar uma nova quinta chave, mas trava E2BIG.

Assim, podemos criar e carregar programas BPF, bem como criar e gerenciar mapas a partir do espaço do usuário. Agora é lógico ver como podemos usar mapas dos próprios programas BPF. Poderíamos falar sobre isso na linguagem de programas difíceis de ler em códigos de macro de máquina, mas na verdade chegou a hora de mostrar como os programas BPF são realmente escritos e mantidos - usando libbpf.

(Para leitores que estão insatisfeitos com a falta de um exemplo de baixo nível: analisaremos detalhadamente programas que utilizam mapas e funções auxiliares criadas usando libbpf e dizer o que acontece no nível de instrução. Para leitores insatisfeitos muito, nós adicionamos exemplo no local apropriado do artigo.)

Escrevendo programas BPF usando libbpf

Escrever programas BPF usando códigos de máquina pode ser interessante apenas na primeira vez, e então a saciedade se instala. Neste momento você precisa voltar sua atenção para llvm, que possui um backend para geração de código para a arquitetura BPF, além de uma biblioteca libbpf, que permite escrever o lado do usuário dos aplicativos BPF e carregar o código dos programas BPF gerados usando llvm/clang.

Na verdade, como veremos neste e nos artigos subsequentes, libbpf faz bastante trabalho sem ele (ou ferramentas similares - iproute2, libbcc, libbpf-go, etc.) é impossível viver. Uma das características matadoras do projeto libbpf é BPF CO-RE (Compile Once, Run Everywhere) - um projeto que permite escrever programas BPF que são portáveis ​​de um kernel para outro, com a capacidade de rodar em diferentes APIs (por exemplo, quando a estrutura do kernel muda de versão para versão). Para poder trabalhar com CO-RE, seu kernel deve ser compilado com suporte BTF (descrevemos como fazer isso na seção Ferramentas de desenvolvimento. Você pode verificar se o seu kernel foi compilado com BTF ou não - pela presença do seguinte arquivo:

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

Este arquivo armazena informações sobre todos os tipos de dados usados ​​no kernel e é usado em todos os nossos exemplos usando libbpf. Falaremos em detalhes sobre CO-RE no próximo artigo, mas neste - basta construir um kernel com CONFIG_DEBUG_INFO_BTF.

Biblioteca libbpf mora bem no diretório tools/lib/bpf kernel e seu desenvolvimento é realizado através da lista de discussão [email protected]. No entanto, um repositório separado é mantido para as necessidades dos aplicativos que vivem fora do kernel https://github.com/libbpf/libbpf em que a biblioteca do kernel é espelhada para acesso de leitura mais ou menos como está.

Nesta seção veremos como você pode criar um projeto que usa libbpf, vamos escrever vários programas de teste (mais ou menos sem sentido) e analisar detalhadamente como tudo funciona. Isso nos permitirá explicar mais facilmente nas seções seguintes exatamente como os programas BPF interagem com mapas, auxiliares de kernel, BTF, etc.

Normalmente projetos que usam libbpf adicione um repositório GitHub como um submódulo git, faremos o mesmo:

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

Indo a libbpf muito simples:

$ 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

Nosso próximo plano nesta seção é o seguinte: escreveremos um programa BPF como BPF_PROG_TYPE_XDP, o mesmo que no exemplo anterior, mas em C, nós o compilamos usando clange escreva um programa auxiliar que irá carregá-lo no kernel. Nas seções seguintes expandiremos os recursos do programa BPF e do programa assistente.

Exemplo: criando um aplicativo completo usando libbpf

Para começar, usamos o arquivo /sys/kernel/btf/vmlinux, mencionado acima, e crie seu equivalente na forma de um arquivo de cabeçalho:

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

Este arquivo irá armazenar todas as estruturas de dados disponíveis em nosso kernel, por exemplo, é assim que o cabeçalho IPv4 é definido no 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;
};

Agora vamos escrever nosso programa BPF em 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";

Embora nosso programa tenha se mostrado muito simples, ainda precisamos prestar atenção a muitos detalhes. Primeiro, o primeiro arquivo de cabeçalho que incluímos é vmlinux.h, que acabamos de gerar usando bpftool btf dump - agora não precisamos instalar o pacote kernel-headers para descobrir como são as estruturas do kernel. O seguinte arquivo de cabeçalho vem da biblioteca libbpf. Agora só precisamos dele para definir a macro SEC, que envia o caractere para a seção apropriada do arquivo do objeto ELF. Nosso programa está contido na seção xdp/simple, onde antes da barra definimos o tipo de programa BPF - esta é a convenção usada em libbpf, com base no nome da seção, ele substituirá o tipo correto na inicialização bpf(2). O próprio programa BPF é C - muito simples e consiste em uma linha return XDP_PASS. Finalmente, uma seção separada "license" contém o nome da licença.

Podemos compilar nosso programa usando llvm/clang, versão >= 10.0.0, ou melhor ainda, superior (veja a seção Ferramentas de desenvolvimento):

$ 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 as funcionalidades interessantes: indicamos a arquitetura alvo -target bpf e o caminho para os cabeçalhos libbpf, que instalamos recentemente. Além disso, não se esqueça -O2, sem essa opção você poderá ter surpresas no futuro. Vejamos nosso código, conseguimos escrever o 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

Sim, funcionou! Agora temos um arquivo binário com o programa e queremos criar uma aplicação que irá carregá-lo no kernel. Para isso a biblioteca libbpf nos oferece duas opções: usar uma API de nível inferior ou uma API de nível superior. Iremos pelo segundo caminho, pois queremos aprender como escrever, carregar e conectar programas BPF com o mínimo esforço para seu posterior estudo.

Primeiro, precisamos gerar o “esqueleto” do nosso programa a partir do seu binário usando o mesmo utilitário bpftool — o canivete suíço do mundo BPF (que pode ser entendido literalmente, já que Daniel Borkman, um dos criadores e mantenedores do BPF, é suíço):

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

No arquivo xdp-simple.skel.h contém o código binário do nosso programa e funções para gerenciar - carregar, anexar, excluir nosso objeto. No nosso caso simples isso parece um exagero, mas também funciona no caso em que o arquivo objeto contém muitos programas e mapas BPF e para carregar esse ELF gigante precisamos apenas gerar o esqueleto e chamar uma ou duas funções do aplicativo personalizado que estão escrevendo Vamos seguir em frente agora.

A rigor, nosso programa carregador é 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);
}

é struct xdp_simple_bpf definido no arquivo xdp-simple.skel.h e descreve nosso arquivo 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 traços de uma API de baixo nível aqui: a estrutura struct bpf_program *simple и struct bpf_link *simple. A primeira estrutura descreve especificamente nosso programa, escrita na seção xdp/simplee a segunda descreve como o programa se conecta à origem do evento.

Função xdp_simple_bpf__open_and_load, abre um objeto ELF, analisa-o, cria todas as estruturas e subestruturas (além do programa, ELF também contém outras seções - dados, dados somente leitura, informações de depuração, licença, etc.) e, em seguida, carrega-o no kernel usando um sistema chamar bpf, que podemos verificar compilando e executando o 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

Vamos agora dar uma olhada em nosso programa usando bpftool. Vamos encontrar o ID dela:

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

e dump (usamos uma forma abreviada do 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 novo! O programa imprimiu pedaços do nosso arquivo fonte C. Isso foi feito pela biblioteca libbpf, que encontrou a seção de depuração no binário, compilou-a em um objeto BTF e carregou-a no kernel usando BPF_BTF_LOADe, em seguida, especificou o descritor de arquivo resultante ao carregar o programa com o comando BPG_PROG_LOAD.

Ajudantes de Kernel

Os programas BPF podem executar funções “externas” - auxiliares do kernel. Essas funções auxiliares permitem que programas BPF acessem estruturas de kernel, gerenciem mapas e também se comuniquem com o “mundo real” - criem eventos de desempenho, controlem hardware (por exemplo, redirecionem pacotes), etc.

Exemplo: bpf_get_smp_processor_id

No âmbito do paradigma “aprender pelo exemplo”, consideremos uma das funções auxiliares, bpf_get_smp_processor_id(), certo no arquivo kernel/bpf/helpers.c. Retorna o número do processador no qual está sendo executado o programa BPF que o chamou. Mas não estamos tão interessados ​​em sua semântica quanto no fato de que sua implementação segue uma linha:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

As definições da função auxiliar BPF são semelhantes às definições de chamada do sistema Linux. Aqui, por exemplo, é definida uma função que não possui argumentos. (Uma função que recebe, digamos, três argumentos é definida usando a macro BPF_CALL_3. O número máximo de argumentos é cinco.) No entanto, esta é apenas a primeira parte da definição. A segunda parte é definir a estrutura do tipo struct bpf_func_proto, que contém uma descrição da função auxiliar que o verificador entende:

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

Registrando funções auxiliares

Para que programas BPF de um determinado tipo possam utilizar esta função, eles devem registrá-la, por exemplo para o tipo BPF_PROG_TYPE_XDP uma função é definida no kernel xdp_func_proto, que determina a partir do ID da função auxiliar se o XDP oferece suporte a essa função ou não. Nossa função é suporta o:

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

Novos tipos de programas BPF são "definidos" no arquivo include/linux/bpf_types.h usando uma macro BPF_PROG_TYPE. Definido entre aspas porque é uma definição lógica, e em termos da linguagem C a definição de todo um conjunto de estruturas concretas ocorre em outros locais. Em particular, no arquivo kernel/bpf/verifier.c todas as definições do arquivo bpf_types.h são usados ​​para criar uma matriz de estruturas 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
};

Ou seja, para cada tipo de programa BPF é definido um ponteiro para uma estrutura de dados do tipo struct bpf_verifier_ops, que é inicializado com o valor _name ## _verifier_ops, ou seja, xdp_verifier_ops para xdp. Estrutura xdp_verifier_ops determinado por no arquivo net/core/filter.c da seguinte maneira:

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

Aqui vemos nossa função familiar xdp_func_proto, que executará o verificador sempre que encontrar um desafio alguns funções dentro de um programa BPF, consulte verifier.c.

Vejamos como um programa BPF hipotético usa a função bpf_get_smp_processor_id. Para fazer isso, reescrevemos o programa da seção anterior da seguinte forma:

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

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

isto é, bpf_get_smp_processor_id é um ponteiro de função cujo valor é 8, onde 8 é o valor BPF_FUNC_get_smp_processor_id digite enum bpf_fun_id, que é definido para nós no arquivo vmlinux.h (arquivo bpf_helper_defs.h no kernel é gerado por um script, então os números “mágicos” estão ok). Esta função não aceita argumentos e retorna um valor do tipo __u32. Quando o executamos em nosso programa, clang gera uma instrução BPF_CALL "o tipo certo" Vamos compilar o programa e dar uma olhada na seção 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

Na primeira linha vemos instruções call, parâmetro IMM que é igual a 8, e SRC_REG - zero. De acordo com o acordo ABI usado pelo verificador, esta é uma chamada para a função auxiliar número oito. Depois de lançado, a lógica é simples. Valor de retorno do registro r0 copiado para r1 e nas linhas 2,3 é convertido para o tipo u32 — os 32 bits superiores são apagados. Nas linhas 4,5,6,7 retornamos 2 (XDP_PASS) ou 1 (XDP_DROP) dependendo se a função auxiliar da linha 0 retornou um valor zero ou diferente de zero.

Vamos nos testar: carregue o programa e veja a saída 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, o verificador encontrou o auxiliar de kernel correto.

Exemplo: passando argumentos e finalmente executando o programa!

Todas as funções auxiliares de nível de execução têm um protótipo

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

Parâmetros para funções auxiliares são passados ​​em registros r1-r5, e o valor é retornado no registrador r0. Não há funções que aceitem mais de cinco argumentos e não se espera que suporte para eles seja adicionado no futuro.

Vamos dar uma olhada no novo auxiliar do kernel e como o BPF passa parâmetros. Vamos reescrever xdp-simple.bpf.c da seguinte forma (o restante das linhas não mudou):

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

Nosso programa imprime o número da CPU na qual está sendo executado. Vamos compilá-lo e ver o 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

Nas linhas 0-7 escrevemos a string running on CPU%un, e então na linha 8 executamos o familiar bpf_get_smp_processor_id. Nas linhas 9-12 preparamos os argumentos auxiliares bpf_printk - registros r1, r2, r3. Por que existem três deles e não dois? Porque bpf_printkeste é um wrapper de macro em torno do verdadeiro ajudante bpf_trace_printk, que precisa passar o tamanho da string de formato.

Vamos agora adicionar algumas linhas ao xdp-simple.cpara que nosso programa se conecte à interface lo e realmente começou!

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

Aqui usamos a função bpf_set_link_xdp_fd, que conecta programas BPF do tipo XDP a interfaces de rede. Codificamos o número da interface lo, que é sempre 1. Executamos a função duas vezes para primeiro desanexar o programa antigo, se ele estiver anexado. Observe que agora não precisamos de um desafio pause ou um loop infinito: nosso programa carregador será encerrado, mas o programa BPF não será eliminado, pois está conectado à fonte do evento. Após download e conexão bem-sucedidos, o programa será iniciado para cada pacote de rede que chegar ao lo.

Vamos baixar o programa e dar uma olhada na interface lo:

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

O programa que baixamos tem ID 669 e vemos o mesmo ID na interface lo. Enviaremos alguns pacotes para 127.0.0.1 (solicitação + resposta):

$ ping -c1 localhost

e agora vamos dar uma olhada no conteúdo do arquivo virtual de depuração /sys/kernel/debug/tracing/trace_pipe, no qual bpf_printk escreve suas mensagens:

# 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

Dois pacotes foram vistos lo e processado na CPU0 - nosso primeiro programa BPF completo e sem sentido funcionou!

Vale a pena notar que bpf_printk Não é à toa que ele grava no arquivo de depuração: este não é o auxiliar de maior sucesso para uso em produção, mas nosso objetivo era mostrar algo simples.

Acessando mapas de programas BPF

Exemplo: usando um mapa do programa BPF

Nas seções anteriores aprendemos como criar e usar mapas a partir do espaço do usuário e agora vamos dar uma olhada na parte do kernel. Comecemos, como sempre, com um exemplo. Vamos reescrever nosso programa xdp-simple.bpf.c da seguinte maneira:

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

No início do programa adicionamos uma definição de mapa woo: Este é um array de 8 elementos que armazena valores como u64 (em C definiríamos tal array como u64 woo[8]). Em um programa "xdp/simple" colocamos o número atual do processador em uma variável key e então usando a função auxiliar bpf_map_lookup_element obtemos um ponteiro para a entrada correspondente no array, que aumentamos em um. Traduzido para o russo: calculamos estatísticas sobre qual CPU processou os pacotes recebidos. Vamos tentar executar o 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

Vamos verificar se ela está ligada lo e envie alguns pacotes:

$ 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

Agora vamos dar uma olhada no conteúdo do array:

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

Quase todos os processos foram processados ​​na CPU7. Isso não é importante para nós, o principal é que o programa funcione e entendamos como acessar os mapas dos programas BPF - usando хелперов bpf_mp_*.

Índice místico

Assim, podemos acessar o mapa do programa BPF usando chamadas como

val = bpf_map_lookup_elem(&woo, &key);

onde a função auxiliar se parece

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

mas estamos passando um ponteiro &woo para uma estrutura sem nome struct { ... }...

Se olharmos para o programa assembler, vemos que o valor &woo não está realmente definido (linha 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
...

e está contido em realocações:

$ 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

Mas se olharmos para o programa já carregado, vemos um ponteiro para o mapa correto (linha 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]
...

Assim, podemos concluir que no momento de lançar nosso programa carregador, o link para &woo foi substituído por algo com uma biblioteca libbpf. Primeiro veremos a saída 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

Nós vemos isso libbpf criou um mapa woo e então baixei nosso programa simple. Vamos dar uma olhada mais de perto em como carregamos o programa:

  • chamar xdp_simple_bpf__open_and_load do arquivo xdp-simple.skel.h
  • que causa xdp_simple_bpf__load do arquivo xdp-simple.skel.h
  • que causa bpf_object__load_skeleton do arquivo libbpf/src/libbpf.c
  • que causa bpf_object__load_xattr de libbpf/src/libbpf.c

A última função, entre outras coisas, chamará bpf_object__create_maps, que cria ou abre mapas existentes, transformando-os em descritores de arquivos. (É aqui que vemos BPF_MAP_CREATE na saída strace.) Em seguida, a função é chamada bpf_object__relocate e é ela quem nos interessa, pois lembramos o que vimos woo na tabela de realocação. Explorando-o, eventualmente nos encontramos na função bpf_program__relocate, qual e lida com realocações de mapas:

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

Então seguimos nossas instruções

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

e substitua o registro de origem nele por BPF_PSEUDO_MAP_FD, e o primeiro IMM para o descritor de arquivo do nosso mapa e, se for igual a, por exemplo, 0xdeadbeef, então, como resultado, receberemos a instrução

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

É assim que as informações do mapa são transferidas para um programa BPF específico carregado. Neste caso, o mapa pode ser criado usando BPF_MAP_CREATEe aberto por ID usando BPF_MAP_GET_FD_BY_ID.

Total, ao usar libbpf o algoritmo é o seguinte:

  • durante a compilação, os registros são criados na tabela de relocação para links para mapas
  • libbpf abre o livro de objetos ELF, encontra todos os mapas usados ​​e cria descritores de arquivos para eles
  • descritores de arquivo são carregados no kernel como parte da instrução LD64

Como você pode imaginar, há mais por vir e teremos que olhar para o núcleo. Felizmente, temos uma pista - escrevemos o significado BPF_PSEUDO_MAP_FD no registro da fonte e poderemos enterrá-lo, o que nos levará ao santo de todos os santos - kernel/bpf/verifier.c, onde uma função com um nome distinto substitui um descritor de arquivo pelo endereço de uma estrutura do 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;

(o código completo pode ser encontrado по ссылке). Portanto, podemos expandir nosso algoritmo:

  • ao carregar o programa, o verificador verifica o uso correto do mapa e escreve o endereço da estrutura correspondente struct bpf_map

Ao baixar o binário ELF usando libbpf Há muito mais acontecendo, mas discutiremos isso em outros artigos.

Carregando programas e mapas sem libbpf

Como prometido, aqui fica um exemplo para leitores que desejam saber como criar e carregar um programa que utiliza mapas, sem ajuda libbpf. Isto pode ser útil quando você está trabalhando em um ambiente para o qual você não pode construir dependências, ou salvar cada bit, ou escrever um programa como ply, que gera código binário BPF dinamicamente.

Para facilitar o acompanhamento da lógica, reescreveremos nosso exemplo para esses fins xdp-simple. O código completo e ligeiramente expandido do programa discutido neste exemplo pode ser encontrado neste essência.

A lógica da nossa aplicação é a seguinte:

  • crie um mapa de tipo BPF_MAP_TYPE_ARRAY usando o comando BPF_MAP_CREATE,
  • crie um programa que use este mapa,
  • conecte o programa à interface lo,

que se traduz em 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);
}

é map_create cria um mapa da mesma forma que fizemos no primeiro exemplo sobre a chamada do sistema bpf - “kernel, por favor, faça-me um novo mapa na forma de uma matriz de 8 elementos como __u64 e me devolva o descritor de arquivo":

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

O programa também é fácil de carregar:

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

A parte complicada prog_load é a definição do nosso programa BPF como um conjunto de estruturas struct bpf_insn insns[]. Mas como estamos usando um programa que temos em C, podemos trapacear um pouco:

$ 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

No total, precisamos escrever 14 instruções na forma de estruturas como struct bpf_insn (conselho: pegue o despejo de cima, releia a seção de instruções, abra linux/bpf.h и linux/bpf_common.h e tente determinar struct bpf_insn insns[] por conta própria):

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

Um exercício para quem não escreveu isso sozinho - encontre map_fd.

Ainda resta mais uma parte não revelada em nosso programa - xdp_attach. Infelizmente, programas como o XDP não podem ser conectados usando uma chamada de sistema bpf. As pessoas que criaram o BPF e o XDP eram da comunidade Linux online, o que significa que usaram aquele que lhes era mais familiar (mas não para normal people) interface para interagir com o kernel: soquetes de rede, Veja também RFC3549. A maneira mais simples de implementar xdp_attach está copiando o código de libbpf, ou seja, do arquivo netlink.c, que foi o que fizemos, encurtando um pouco:

Bem-vindo ao mundo dos soquetes netlink

Abra um tipo de soquete 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;
}

Lemos neste soquete:

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, aqui está nossa função que abre um soquete e envia uma mensagem especial contendo um descritor de arquivo:

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

Então, tudo está pronto para teste:

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

Vamos ver se nosso programa se conectou 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

Vamos enviar pings e olhar o 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

Viva, tudo funciona. A propósito, observe que nosso mapa é novamente exibido na forma de bytes. Isto se deve ao fato de que, diferentemente libbpf não carregamos informações de tipo (BTF). Mas falaremos mais sobre isso na próxima vez.

Ferramentas de desenvolvimento

Nesta seção, veremos o kit de ferramentas mínimo para desenvolvedores do BPF.

De modo geral, você não precisa de nada especial para desenvolver programas BPF - o BPF roda em qualquer kernel de distribuição decente e os programas são construídos usando clang, que pode ser fornecido no pacote. Porém, devido ao fato do BPF estar em desenvolvimento, o kernel e as ferramentas estão em constante mudança, se você não quiser escrever programas BPF usando métodos antigos de 2019, então você terá que compilar

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

(Para referência, esta seção e todos os exemplos do artigo foram executados no Debian 10.)

llvm/clang

O BPF é amigável com o LLVM e, embora recentemente programas para BPF possam ser compilados usando o gcc, todo o desenvolvimento atual é realizado para o LLVM. Portanto, em primeiro lugar, construiremos a versão atual clang do 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
... много времени спустя
$

Agora podemos verificar se tudo funcionou corretamente:

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

(Instruções de montagem clang tirado por mim de bpf_devel_QA.)

Não instalaremos os programas que acabamos de criar, mas apenas os adicionaremos ao PATH, Por exemplo:

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

(Isso pode ser adicionado a .bashrc ou para um arquivo separado. Pessoalmente, adiciono coisas assim ~/bin/activate-llvm.sh e quando necessário eu faço isso . activate-llvm.sh.)

Pahole e BTF

Utilitário pahole usado ao construir o kernel para criar informações de depuração no formato BTF. Não entraremos em detalhes neste artigo sobre os detalhes da tecnologia BTF, a não ser o fato de que ela é conveniente e queremos utilizá-la. Então se você for construir seu kernel, construa primeiro pahole (sem pahole você não será capaz de construir o kernel com a opção 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 BPF

Ao explorar as possibilidades do BPF, quero montar meu próprio núcleo. Isso, de modo geral, não é necessário, pois você poderá compilar e carregar programas BPF no kernel da distribuição, porém, ter seu próprio kernel permite que você use os recursos mais recentes do BPF, que aparecerão em sua distribuição em meses, na melhor das hipóteses , ou, como no caso de algumas ferramentas de depuração, não serão empacotadas em um futuro próximo. Além disso, seu próprio núcleo faz com que seja importante experimentar o código.

Para construir um kernel você precisa, em primeiro lugar, do próprio kernel e, em segundo lugar, de um arquivo de configuração do kernel. Para experimentar o BPF podemos usar o usual baunilha kernel ou um dos kernels de desenvolvimento. Historicamente, o desenvolvimento do BPF ocorre dentro da comunidade de redes Linux e, portanto, todas as mudanças, mais cedo ou mais tarde, passam por David Miller, o mantenedor da rede Linux. Dependendo de sua natureza - edições ou novos recursos - as mudanças na rede se enquadram em um de dois núcleos - net ou net-next. As alterações do BPF são distribuídas da mesma forma entre bpf и bpf-next, que são então agrupados em net e net-next, respectivamente. Para mais detalhes, consulte bpf_devel_QA и netdev-FAQ. Portanto, escolha um kernel com base no seu gosto e nas necessidades de estabilidade do sistema em que você está testando (*-next kernels são os mais instáveis ​​dos listados).

Está além do escopo deste artigo falar sobre como gerenciar arquivos de configuração do kernel - presume-se que você já saiba como fazer isso ou pronto para aprender por conta própria. No entanto, as instruções a seguir devem ser mais ou menos suficientes para fornecer um sistema funcional habilitado para BPF.

Baixe um dos kernels acima:

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

Crie uma configuração mínima de kernel funcional:

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

Habilitar opções BPF no arquivo .config de sua própria escolha (provavelmente CONFIG_BPF já estará habilitado porque o systemd o utiliza). Aqui está uma lista de opções do kernel usadas para este artigo:

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

Então podemos montar e instalar facilmente os módulos e o kernel (a propósito, você pode montar o kernel usando o recém-montado clangadicionando CC=clang):

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

e reinicie com o novo kernel (eu uso para isso kexec do pacote 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

bpftool

O utilitário mais comumente usado no artigo será o utilitário bpftool, fornecido como parte do kernel Linux. Ele é escrito e mantido por desenvolvedores BPF para desenvolvedores BPF e pode ser usado para gerenciar todos os tipos de objetos BPF - carregar programas, criar e editar mapas, explorar a vida do ecossistema BPF, etc. A documentação na forma de códigos-fonte para páginas de manual pode ser encontrada no núcleo ou, já compilado, Rede.

No momento em que este livro foi escrito bpftool vem pronto apenas para RHEL, Fedora e Ubuntu (veja, por exemplo, este tópico, que conta a história inacabada da embalagem bpftool no Debian). Mas se você já construiu seu kernel, então construa bpftool fácil como uma torta:

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

$

(aqui ${linux} - este é o diretório do seu kernel.) Depois de executar esses comandos bpftool será coletado em um diretório ${linux}/tools/bpf/bpftool e pode ser adicionado ao caminho (primeiro de tudo para o usuário root) ou apenas copie para /usr/local/sbin.

Colete bpftool é melhor usar o último clang, montado conforme descrito acima, e verifique se está montado corretamente - utilizando, por exemplo, o 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á quais recursos BPF estão habilitados em seu kernel.

A propósito, o comando anterior pode ser executado como

# bpftool f p k

Isso é feito por analogia com os utilitários do pacote iproute2, onde podemos, por exemplo, dizer ip a s eth0 ao invés de ip addr show dev eth0.

Conclusão

O BPF permite que você calce uma pulga para medir com eficácia e alterar rapidamente a funcionalidade do núcleo. O sistema acabou sendo muito bem-sucedido, nas melhores tradições do UNIX: um mecanismo simples que permite (re)programar o kernel permitiu que um grande número de pessoas e organizações experimentassem. E, embora os experimentos, bem como o desenvolvimento da própria infraestrutura do BPF, estejam longe de terminar, o sistema já possui uma ABI estável que permite construir uma lógica de negócios confiável e, o mais importante, eficaz.

Gostaria de salientar que, na minha opinião, a tecnologia tornou-se tão popular porque, por um lado, pode jogar (a arquitetura de uma máquina pode ser compreendida mais ou menos em uma noite) e, por outro lado, para resolver problemas que não podiam ser resolvidos (lindamente) antes de seu aparecimento. Juntos, esses dois componentes obrigam as pessoas a experimentar e sonhar, o que leva ao surgimento de soluções cada vez mais inovadoras.

Este artigo, embora não seja particularmente curto, é apenas uma introdução ao mundo do BPF e não descreve recursos “avançados” e partes importantes da arquitetura. O plano daqui para frente é mais ou menos assim: o próximo artigo será uma visão geral dos tipos de programas BPF (existem 5.8 tipos de programas suportados no kernel 30), então finalmente veremos como escrever aplicações BPF reais usando programas de rastreamento de kernel como exemplo, é hora de um curso mais aprofundado sobre arquitetura BPF, seguido de exemplos de redes BPF e aplicações de segurança.

Artigos anteriores desta série

  1. BPF para os mais pequenos, parte zero: BPF clássico

Ligações

  1. Guia de Referência de BPF e XDP — documentação sobre BPF do cílio, ou mais precisamente de Daniel Borkman, um dos criadores e mantenedores do BPF. Esta é uma das primeiras descrições sérias, que se diferencia das demais porque Daniel sabe exatamente sobre o que está escrevendo e não há erros aí. Em particular, este documento descreve como trabalhar com programas BPF dos tipos XDP e TC usando o conhecido utilitário ip do pacote iproute2.

  2. Documentação/rede/filter.txt — arquivo original com documentação do BPF clássico e depois estendido. Uma boa leitura se você quiser se aprofundar na linguagem assembly e nos detalhes técnicos de arquitetura.

  3. Blog sobre BPF no Facebook. Ele é atualizado raramente, mas apropriadamente, como Alexei Starovoitov (autor do eBPF) e Andrii Nakryiko - (mantenedor) escrevem lá libbpf).

  4. Segredos do bpftool. Um divertido tópico no Twitter de Quentin Monnet com exemplos e segredos do uso do bpftool.

  5. Mergulhe no BPF: uma lista de material de leitura. Uma lista gigante (e ainda mantida) de links para a documentação do BPF de Quentin Monnet.

Fonte: habr.com

Adicionar um comentário