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:
- Analisar (fazer o parse) dos cabeçalhos Ethernet e IPv4 de pacotes recebidos.
- 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).
- Com base na correspondência, a tabela definirá a porta de saída e o endereço MAC do próximo salto (next-hop).
-
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.
- 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
MyParserinclui não apenas opkt(pacote de entrada) ehdr(cabeçalhos de saída), mas tambémmeta(metadados do usuário, que definiremos a seguir) estandard_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 estadoparse_ethernet, usamos umselectpara inspecionar o campoether_type. O valor16w0x0800é a notação P4 para um valor constante de 16 bits com o valor hexadecimal0x0800, que é o EtherType designado para IPv4. Se o pacote não for IPv4 (ex: ARP ou IPv6), o parser transiciona paraaccepte o processamento continua, mas o bithdr.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
-
Ações:
-
drop(): Simplesmente define a porta de saída (egress_spec) paraSTANDARD_DROP_PORT, uma constante definida emdefines.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:- Define a porta de saída (
port). -
Reescreve os MACs de origem e destino
(
dst_addr). - Decrementa o TTL.
- Define a porta de saída (
-
-
Tabela
ipv4_lpm:-
Este é o núcleo do roteador L3. A chave
hdr.ipv4.dst_addr: lpminstrui 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).
-
Este é o núcleo do roteador L3. A chave
-
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 blococontrolsem serem modificados ou descartados.
-
A lógica é simples:
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 arquiteturaV1Switch(a V1Model), seus blocosextern(comoChecksum16) e os metadados padrão (standard_metadata_t). -
V1Switch(...) main;: Esta é a declaração dopackageprincipal.V1Switché o nome da arquitetura definida emv1model.p4. Ao instanciá-la comomain, estamos dizendo ao compilador P4: "Este é o programa principal". -
Conectando os Blocos: Os argumentos
passados para
V1Switchsão, na ordem, as implementações que o programador fornece para os "slots" da arquitetura. Estamos passando instâncias deMyParser(),MyIngress(),MyEgress()eMyDeparser()que escrevemos nas seções anteriores. -
VerifyChecksum()eComputeChecksum(): A arquitetura V1Model requer que o programador forneça blocoscontrolpara verificação (na entrada) e cálculo (na saída) de checksums. Como nosso roteador L3 faz essa lógica manualmente noEgress(para o IPv4), simplesmente passamos instâncias vazias (VerifyChecksum()eComputeChecksum(), que são fornecidas porv1model.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:
- A configuração do plano de dados (o pipeline a ser carregado no switch).
-
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 blococontrol(MyIngress) e o nome datable(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 IP10.0.1.1com 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 nossaaction 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.