5. Estudo de Caso: Implementando um Roteador IPv4 Básico

Nota: O exemplo abaixo foi desenvolvido e analisado a partir do tutorial "Basic Forwarding" disponibilizado pelo consórcio p4.org no seu repositório oficial de tutoriais [7].
(Disponível em: GitHub - p4lang/tutorials)

As seções anteriores introduziram os fundamentos teóricos da programabilidade de redes (Seção 2), a visão geral da linguagem P4 (Seção 3) e a estrutura detalhada de sua arquitetura (Seção 4). Esta seção irá consolidar todos esses conceitos através de um estudo de caso prático: a implementação de um roteador IPv4 simples.

5.1. Objetivo do Programa e Arquitetura-Alvo

O objetivo é construir um dispositivo P4 que execute a função central de um roteador de Camada 3 (L3). O programa não implementará os protocolos de plano de controle (como OSPF ou BGP), but sim o plano de dados de encaminhamento rápido, que é controlado por um plano de controle externo (populando as tabelas).

O nosso roteador P4 deverá executar as seguintes funções no plano de dados:

  1. Analisar (fazer o parse) dos cabeçalhos Ethernet e IPv4 de pacotes recebidos.
  2. Consultar uma tabela de encaminhamento L3. Esta tabela usará o endereço IP de destino do pacote como chave, aplicando uma correspondência de Prefixo Mais Longo (Longest Prefix Match - LPM).
  3. Com base na correspondência, a tabela definirá a porta de saída e o endereço MAC do próximo salto (next-hop).
  4. Modificar o pacote:
    • Decrementar o campo TTL (Time-To-Live) do cabeçalho IPv4.
    • Recalcular o checksum do cabeçalho IPv4 (necessário, pois o TTL foi alterado).
    • Reescrever o endereço MAC de destino (para o MAC do próximo salto) e o MAC de origem (para o MAC da interface do roteador) no cabeçalho Ethernet.
  5. Remontar (fazer o deparse) e encaminhar o pacote para a porta de saída designada.

Para implementar este comportamento, utilizaremos como alvo a arquitetura V1Model, que foi detalhada na Seção 4.4. O programa será desenvolvido pressupondo a compilação para o switch de software bmv2, que implementa fielmente este modelo.

As subseções seguintes irão construir este roteador passo a passo, descrevendo cada um dos componentes do programa P4: as definições de cabeçalho, o parser, a lógica de ingress, a lógica de egress e o deparser.

5.2. Definição de Cabeçalhos (headers.p4)

O primeiro passo em qualquer programa P4 é definir os formatos dos cabeçalhos dos protocolos que o dispositivo irá processar. Isso é feito usando a palavra-chave header, que define uma estrutura de dados com campos de tamanho fixo.

Para o nosso roteador L3, precisamos reconhecer dois cabeçalhos principais: Ethernet (ethernet_t) e IPv4 (ipv4_t). Também definimos uma estrutura (headers_t) que agrupa todos os cabeçalhos que o parser pode extrair. Esta estrutura será passada entre os componentes do pipeline.

O código a seguir, tipicamente salvo em um arquivo como headers.p4, define essas estruturas.


/* * Definição dos tipos de cabeçalho para o roteador L3 */

// Define um tipo customizado para endereços MAC
typedef bit<48> mac_addr_t;
// Define um tipo customizado para EtherType
typedef bit<16> ethertype_t;

// Cabeçalho Ethernet (Camada 2)
header ethernet_t {
    mac_addr_t  dst_addr; // Endereço de destino
    mac_addr_t  src_addr; // Endereço de origem
    ethertype_t ether_type; // Tipo do protocolo encapsulado
}

// Cabeçalho IPv4 (Camada 3)
// Campos não essenciais para o roteamento (ex: IHL, Flags) 
// foram omitidos para simplificar o exemplo.
header ipv4_t {
    bit<8>  version_ihl; // Versão (4 bits) e Tamanho (4 bits)
    bit<8>  diffserv;    // Differentiated Services
    bit<16> total_len;   // Comprimento total
    bit<16> identification;
    bit<16> flags_frag_offset;
    bit<8>  ttl;         // Time To Live (TTL)
    bit<8>  protocol;
    bit<16> hdr_checksum; // Checksum do cabeçalho
    bit<32> src_addr;    // Endereço IP de origem
    bit<32> dst_addr;    // Endereço IP de destino
}

// Estrutura principal que agrupa todos os cabeçalhos.
// O parser irá preencher instâncias desta estrutura.
struct headers_t {
    ethernet_t ethernet;
    ipv4_t     ipv4;
}

Neste código, bit<W> é o tipo de dado fundamental em P4, representando um vetor de bits de tamanho W. O P4 é fortemente tipado; por isso, usamos typedef para criar tipos mais legíveis, como mac_addr_t para endereços MAC.

Note que um header P4 é um tipo especial de struct que possui um conceito de "validade" (um bit implícito isValid()). Quando o parser extrai um cabeçalho, seu bit de validade é automaticamente definido como verdadeiro, permitindo que os blocos control subsequentes verifiquem quais cabeçalhos estão presentes no pacote.

5.3. Implementando o Parser (parser.p4)

Com as estruturas de cabeçalho definidas na subseção anterior, o próximo passo é implementar o Parser. Conforme discutido na Seção 4.2.1, esta implementação assume a forma de uma máquina de estados finitos que extrai os cabeçalhos do pacote.

O código a seguir é a implementação do MyParser, que é o bloco parser principal no arquivo basic.p4 do tutorial de referência [Ref: p4lang/tutorials]. Ele é projetado para preencher a estrutura headers_t que definimos anteriormente.


/* * Implementação do Parser (do basic.p4)
 * Este parser é instanciado pela V1Model.
 */
parser MyParser(packet_in pkt,
                out headers_t hdr,
                inout metadata_t meta,
                inout standard_metadata_t standard_metadata) {


    /* Estado inicial: começa o processamento */
    state start {
        // Extrai o cabeçalho Ethernet
        pkt.extract(hdr.ethernet);
        // Transiciona para o próximo estado para analisar o EtherType
        transition parse_ethernet;
    }


    /* Estado de análise da Camada 2 */
    state parse_ethernet {
        // Inspeciona o campo ether_type (extraído no estado 'start')
        transition select(hdr.ethernet.ether_type) {
            // Constante 16w0x0800 = 16 bits, valor 0x0800 (IPv4)
            16w0x0800: parse_ipv4;
            // Se não for IPv4, para o processamento e aceita o pacote
            default: accept;
        }
    }


    /* Estado de análise da Camada 3 */
    state parse_ipv4 {
        // Extrai o cabeçalho IPv4
        pkt.extract(hdr.ipv4);
        // Termina o parser com sucesso
        transition accept;
    }
}

Análise do Código

  • Parâmetros: Note que a assinatura do MyParser inclui não apenas o pkt (pacote de entrada) e hdr (cabeçalhos de saída), mas também meta (metadados do usuário, que definiremos a seguir) e standard_metadata (fornecidos pela V1Model). Isso é padrão da arquitetura V1Model.
  • Máquina de Estados: O fluxo é start -> parse_ethernet -> parse_ipv4 -> accept.
  • transition select: No estado parse_ethernet, usamos um select para inspecionar o campo ether_type. O valor 16w0x0800 é a notação P4 para um valor constante de 16 bits com o valor hexadecimal 0x0800, que é o EtherType designado para IPv4. Se o pacote não for IPv4 (ex: ARP ou IPv6), o parser transiciona para accept e o processamento continua, mas o bit hdr.ipv4.isValid() será false.

5.4. Implementando a Lógica de Ingress (ingress.p4)

O bloco MyIngress é o "cérebro" do roteador. Seguindo o pipeline da V1Model (Seção 4.4), ele é executado logo após o parser. Sua responsabilidade é tomar a decisão de encaminhamento (L3) e realizar as modificações de cabeçalho necessárias (L2 e L3).

O código abaixo define as ações (drop e ipv4_forward), a tabela de roteamento (ipv4_lpm) e a lógica de aplicação imperativa.



#include "defines.p4" // Inclui a constante STANDARD_DROP_PORT


/* * Implementação do Pipeline de Ingress (do basic.p4)

 */

control MyIngress(inout headers_t hdr,

                  inout metadata_t meta,

                  inout standard_metadata_t standard_metadata) {


    /* 1. Definição das Ações */

    

    // Ação para descartar o pacote

    action drop() {

        // Define a porta de saída para um "buraco negro"

        standard_metadata.egress_spec = STANDARD_DROP_PORT;

    }


    // Ação para encaminhar um pacote IPv4

    action ipv4_forward(mac_addr_t dst_addr, egress_spec_t port) {

        // 1. Define a porta de saída (fornecido pelo plano de controle)

        standard_metadata.egress_spec = port;

        

        // 2. Reescrita do MAC de Origem (L2)

        // O pacote chegou destinado ao roteador, então o 'dst_addr' 

        // atual é o MAC do roteador. Usamos ele como o novo 'src_addr'.

        hdr.ethernet.src_addr = hdr.ethernet.dst_addr;


        // 3. Reescrita do MAC de Destino (L2)

        // Define o MAC do próximo salto (fornecido pelo plano de controle)

        hdr.ethernet.dst_addr = dst_addr;


        // 4. Decrementa o TTL (L3)

        hdr.ipv4.ttl = hdr.ipv4.ttl - 1;

    }


    /* 2. Definição da Tabela de Roteamento L3 */

    table ipv4_lpm {

        // Chave: Endereço IP de destino, usando Longest Prefix Match

        key = {

            hdr.ipv4.dst_addr: lpm; 

        }

        // Ações possíveis: encaminhar ou descartar

        actions = {

            ipv4_forward;

            drop;

        }

        size = 1024;

        // Ação padrão se não houver correspondência

        default_action = drop();

    }


    /* 3. Lógica de Aplicação */


    apply {

        // Processa apenas se o parser tiver extraído um cabeçalho IPv4

        if (hdr.ipv4.isValid()) {

            // Aplica a tabela de roteamento L3

            ipv4_lpm.apply();

        }

        // Nota: Se não for IPv4 (ex: ARP), nenhuma ação é tomada 

        // neste bloco de ingress.

    }

}

Análise do Código

  1. Ações:
    • drop(): Simplesmente define a porta de saída (egress_spec) para STANDARD_DROP_PORT, uma constante definida em defines.p4 (no tutorial) que instrui o Traffic Manager da V1Model a descartar o pacote.
    • ipv4_forward(dst_addr, port): Esta é a ação principal. Note que ela recebe dois parâmetros. Esses valores não vêm do pacote, mas sim do plano de controle no momento em que a regra é inserida na tabela. A ação executa as três principais tarefas de roteamento:
      1. Define a porta de saída (port).
      2. Reescreve os MACs de origem e destino (dst_addr).
      3. Decrementa o TTL.
  2. Tabela ipv4_lpm:
    • Este é o núcleo do roteador L3. A chave hdr.ipv4.dst_addr: lpm instrui o P4 a usar uma tabela de Longest Prefix Match (LPM), que é a forma padrão como tabelas de roteamento IP operam.
    • O default_action = drop() garante que qualquer pacote IPv4 que não corresponda a uma rota conhecida seja descartado (uma prática de segurança padrão).
  3. Lógica apply:
    • A lógica é simples: if (hdr.ipv4.isValid()) { ... }. O roteador só se preocupa em aplicar a tabela L3 se o pacote for, de fato, IPv4. Pacotes que não são IPv4 (como ARP, por exemplo) simplesmente "atravessam" este bloco control sem serem modificados ou descartados.

5.5. Implementando o Egress e o Deparser (egress.p4, deparser.p4)

Após o pacote ser processado pelo Ingress e enfileirado pelo Traffic Manager, ele chega ao pipeline de Egress momentos antes de ser enviado. Em nosso roteador, usamos este estágio para uma tarefa crucial: recalcular o checksum do IPv4, que foi invalidado pela decrementação do TTL no Ingress.

Logo após, o Deparser pega os cabeçalhos (agora finalizados) e os serializa de volta para o formato de bytes.

1. A Lógica de Egress: Recálculo do Checksum

O MyEgress é um bloco control simples. Sua única tarefa é verificar se o pacote é IPv4 e, em caso afirmativo, invocar uma ação para recalcular o checksum. Para isso, ele utiliza um objeto extern Checksum16, que é uma capacidade de hardware exposta pela arquitetura V1Model (conforme discutido na Seção 4.3).



/* * Implementação do Pipeline de Egress (do basic.p4)

 */

control MyEgress(inout headers_t hdr,

                 inout metadata_t meta,

                 inout standard_metadata_t standard_metadata) {


    /* Definição da Ação */

    action update_ipv4_checksum() {

        // 'apply' é um método do extern Checksum16

        // Ele recalcula o checksum sobre os campos do cabeçalho

        // e atualiza o campo hdr_checksum automaticamente.

        apply {

            hdr.ipv4.hdr_checksum = Checksum16.apply(

                { hdr.ipv4.version_ihl,

                  hdr.ipv4.diffserv,

                  hdr.ipv4.total_len,

                  hdr.ipv4.identification,

                  hdr.ipv4.flags_frag_offset,

                  hdr.ipv4.ttl,

                  hdr.ipv4.protocol,

                  hdr.ipv4.src_addr,

                  hdr.ipv4.dst_addr }

            );

        }

    }


    /* Lógica de Aplicação */

    apply {

        // Se for um pacote IPv4, recalcula o checksum

        if (hdr.ipv4.isValid()) {

            update_ipv4_checksum.apply();

        }

    }

}

2. O Deparser: Remontagem do Pacote

Finalmente, o MyDeparser serializa os cabeçalhos na ordem correta usando a primitiva emit().



/* * Implementação do Deparser (do basic.p4)

 */

control MyDeparser(packet_out pkt, in headers_t hdr) {

    apply {

        // Emite o cabeçalho Ethernet

        pkt.emit(hdr.ethernet);

        

        // Emite o cabeçalho IPv4 (se for válido)

        if (hdr.ipv4.isValid()) {

            pkt.emit(hdr.ipv4);

        }

    }

}

Neste ponto, o processamento do pacote no plano de dados está completo. O pacote é então enviado pela porta física designada pelo standard_metadata.egress_spec.

5.6. O Programa Principal (main.p4)

Os arquivos anteriores (headers.p4, parser.p4, etc., que no tutorial basic.p4 estão todos consolidados em um único arquivo) definiram os blocos programáveis. O passo final é instanciar a arquitetura V1Model e informar a ela quais blocos ela deve usar para seus "slots" programáveis (Parser, Ingress, Egress, Deparser).

Isso é feito no arquivo principal do programa (o basic.p4 no tutorial) usando a declaração package, conforme discutido na Seção 4.1.



/* * Arquivo principal (basic.p4)

 * Inclui todos os componentes e instancia a arquitetura V1Model

 */


// Inclui as definições padrão da V1Model

#include <core.p4>

#include <v1model.p4>


// Inclui os arquivos que definimos nas subseções anteriores

// (No tutorial basic.p4, este código está no mesmo arquivo)

#include "includes/headers.p4"

#include "includes/parser.p4"

#include "includes/ingress.p4"

#include "includes/egress.p4"

#include "includes/deparser.p4"


// Define os metadados específicos do usuário (vazio neste exemplo)

struct metadata_t {}


/* * Instanciação da Arquitetura V1Model

 * Este é o ponto de entrada principal do programa P4.

 */

V1Switch(

    // Conecta nosso Parser ao slot 'Parser' da V1Model

    MyParser(),

    

    // Conecta nosso Ingress ao slot 'VerifyChecksum' da V1Model

    // (A V1Model requer um checksum inicial, usamos um vazio)

    VerifyChecksum(), 

    

    // Conecta nosso Ingress ao slot 'Ingress' da V1Model

    MyIngress(),

    

    // Conecta nosso Egress ao slot 'Egress' da V1Model

    MyEgress(),

    

    // Conecta nosso Egress ao slot 'ComputeChecksum' da V1Model

    // (A V1Model recalcula o checksum, usamos um vazio 

    // pois já fizemos no MyEgress)

    ComputeChecksum(),

    

    // Conecta nosso Deparser ao slot 'Deparser' da V1Model

    MyDeparser()

) main;

Análise do Código

  • #include <v1model.p4>: Este é o arquivo que define a arquitetura V1Switch (a V1Model), seus blocos extern (como Checksum16) e os metadados padrão (standard_metadata_t).
  • V1Switch(...) main;: Esta é a declaração do package principal. V1Switch é o nome da arquitetura definida em v1model.p4. Ao instanciá-la como main, estamos dizendo ao compilador P4: "Este é o programa principal".
  • Conectando os Blocos: Os argumentos passados para V1Switch são, na ordem, as implementações que o programador fornece para os "slots" da arquitetura. Estamos passando instâncias de MyParser(), MyIngress(), MyEgress() e MyDeparser() que escrevemos nas seções anteriores.
  • VerifyChecksum() e ComputeChecksum(): A arquitetura V1Model requer que o programador forneça blocos control para verificação (na entrada) e cálculo (na saída) de checksums. Como nosso roteador L3 faz essa lógica manualmente no Egress (para o IPv4), simplesmente passamos instâncias vazias (VerifyChecksum() e ComputeChecksum(), que são fornecidas por v1model.p4) para satisfazer o "contrato" da arquitetura.

Com este arquivo, o programa P4 do plano de dados está completo.

5.7. Interagindo com o Plano de Controle

O programa P4 que escrevemos nas subseções 5.2 a 5.6 define apenas o plano de dados. Ele especifica a estrutura das tabelas (chaves, ações), mas não o seu conteúdo. A lógica de "como o tráfego deve fluir" — ou seja, as regras de encaminhamento — é de responsabilidade do Plano de Controle.

Conforme discutido na Seção 3, quando o compilador P4 processa nosso programa, ele gera dois artefatos:

  1. A configuração do plano de dados (o pipeline a ser carregado no switch).
  2. Uma API do plano de controle, que permite que um software externo (um controlador SDN ou um script) leia e escreva nas tabelas e outros objetos (externs) que o programa P4 declarou.

Atualmente, a interface padrão para essa comunicação é o P4Runtime, uma API baseada em gRPC. No entanto, para fins de teste e demonstração — como no tutorial basic do p4lang que estamos analisando [Ref: p4lang/tutorials] — um método mais simples é frequentemente usado: um arquivo de configuração JSON estático.

Este arquivo JSON (ex: s1-runtime.json no tutorial) é fornecido ao switch de software bmv2 no momento da inicialização. Ele contém as entradas de tabela exatas necessárias para o roteador funcionar em uma topologia específica.


{
  "table_entries": [
    {
      "table": "MyIngress.ipv4_lpm",
      "match": {
        "hdr.ipv4.dst_addr": ["10.0.1.1", 32]
      },
      "action_name": "MyIngress.ipv4_forward",
      "action_params": {
        "dst_addr": "08:00:00:00:01:01",
        "port": 1
      }
    }
  ]
}

Análise da Entrada da Tabela

  • "table": "MyIngress.ipv4_lpm":
    Mapeia diretamente para a nossa tabela. O nome é composto pelo bloco control (MyIngress) e o nome da table (ipv4_lpm).
  • "match":
    Mapeia para a chave (key) da nossa tabela.
    "hdr.ipv4.dst_addr": ["10.0.1.1", 32]: Especifica uma correspondência de LPM (conforme definido na chave P4) para o IP 10.0.1.1 com um prefixo de 32 bits.
  • "action_name": "MyIngress.ipv4_forward":
    O nome da ação a ser executada se houver uma correspondência. Mapeia para a nossa action ipv4_forward.
  • "action_params":
    O mais importante: este JSON objeto fornece os argumentos para a nossa ação P4.
    Lembre-se da assinatura da nossa ação: action ipv4_forward(mac_addr_t dst_addr, egress_spec_t port).
    O plano de controle fornece os valores: "dst_addr": "08:00:00:00:01:01" (o MAC do próximo salto) e "port": 1 (a porta de saída).

Com esta entrada de tabela, qualquer pacote destinado a 10.0.1.1 irá corresponder a esta regra, e a ação ipv4_forward será executada com os parâmetros fornecidos, encaminhando o pacote para a porta 1 com o MAC de destino correto. Sem essa interação do plano de controle, nosso programa P4 seria incapaz de encaminhar qualquer pacote.