Compile and Run

In the previous section we put together a hello world P4 program. In this section we run that program over a software ASIC called SoftNpu. One of the capabilities of the x4c compiler is using P4 code directly from Rust code and we'll be doing that in this example.

Below is a Rust program that imports the P4 code developed in the last section, loads it onto a SoftNpu ASIC instance, and sends some packets through it. We'll be looking at this program piece-by-piece in the remainder of this section.

All of the programs in this book are available as buildable programs in the oxidecomputer/p4 repository in the book/code directory.

use tests::softnpu::{RxFrame, SoftNpu, TxFrame};
use tests::{expect_frames};

const NUM_PORTS: u16 = 3;

p4_macro::use_p4!(p4 = "book/code/src/bin/hello-world.p4", pipeline_name = "hello");

fn main() -> Result<(), anyhow::Error> {
    let pipeline = main_pipeline::new(NUM_PORTS);
    let mut npu = SoftNpu::new(NUM_PORTS, pipeline, false);
    let phy1 = npu.phy(0);
    let phy2 = npu.phy(1);
    let phy3 = npu.phy(2);

    npu.run();

    // Expect this packet to be dropped
    phy3.send(&[TxFrame::new(phy3.mac, 0, b"to the bit bucket with you!")])?;

    phy1.send(&[TxFrame::new(phy2.mac, 0, b"hello")])?;
    expect_frames!(phy2, &[RxFrame::new(phy1.mac, 0, b"hello")]);

    phy2.send(&[TxFrame::new(phy1.mac, 0, b"world")])?;
    expect_frames!(phy1, &[RxFrame::new(phy2.mac, 0, b"world")]);

    Ok(())
}

The program starts with a few Rust imports.

use tests::softnpu::{RxFrame, SoftNpu, TxFrame};
use tests::{expect_frames};

This first line is the SoftNpu implementation that lives in the test crate of the oxidecomputer/p4 repository. The second is a helper macro that allows us to make assertions about frames coming from a SoftNpu "physical" port (referred to as a phy).

The next line is using the x4c compiler to translate P4 code into Rust code and dumping that Rust code into our program. The macro literally expands into the Rust code emitted by the compiler for the specified P4 source file.

p4_macro::use_p4!(p4 = "book/code/src/bin/hello-world.p4", pipeline_name = "hello");

The main artifact this produces is a Rust struct called main_pipeline which is used in the code that comes next.

let pipeline = main_pipeline::new(NUM_PORTS);
let mut npu = SoftNpu::new(NUM_PORTS, pipeline, false);
let phy1 = npu.phy(0);
let phy2 = npu.phy(1);
let phy3 = npu.phy(2);

This code is instantiating a pipeline object that encapsulates the logic of our P4 program. Then a SoftNpu ASIC is constructed with three ports and our pipeline program. SoftNpu objects provide a phy method that takes a port index to get a reference to a port that is attached to the ASIC. These port objects are used to send and receive packets through the ASIC, which uses our compiled P4 code to process those packets.

Next we run our program on the SoftNpu ASIC.

npu.run();

However, this does not actually do anything until we pass some packets through it, so lets do that.

// Expect this packet to be dropped
phy3.send(&[TxFrame::new(phy3.mac, 0, b"to the bit bucket with you!")])?;

This code transmit an Ethernet frame through the third port of the ASIC with a payload value of "to the bit bucket with you!". The phy3.mac parameter of the TxFrame sets the destination MAC address and the 0 for the second parameter is the ethertype used in the outgoing Ethernet frame.

Based on the logic in our P4 program, we would expect this packet to be dropped by the switch, i.e. it will not be sent out of any port at all. This is because the table lookup on the ingress port value of 2 would get a miss, and the table would execute the default action drop. Thus we do not call expect_frames! here, as we do for the test packets below.

phy1.send(&[TxFrame::new(phy2.mac, 0, b"hello")])?;

This code transmits an Ethernet frame through the first port of the ASIC with a payload value of "hello".

Based on the logic in our P4 program, we would expect this packet to come out the second port. Let's test that.

expect_frames!(phy2, &[RxFrame::new(phy1.mac, 0, b"hello")]);

This code reads a packet from the second ASIC port phy2 (blocking until there is a packet available) and asserts the following.

  • The Ethernet payload is the byte string "hello".
  • The source MAC address is that of phy1.
  • The ethertype is 0.

To complete the hello world program, we do the same thing in the opposite direction. Sending the byte string "world" as an Ethernet payload into port 2 and assert that it comes out port 1.

phy2.send(&[TxFrame::new(phy1.mac, 0, b"world")])?;
expect_frames!(phy1, &[RxFrame::new(phy2.mac, 0, b"world")]);

The expect_frames macro will also print payloads and the port they came from.

When we run this program we see the following.

$ cargo run --bin hello-world
   Compiling x4c-book v0.1.0 (/home/ry/src/p4/book/code)
    Finished dev [unoptimized + debuginfo] target(s) in 2.05s
     Running `target/debug/hello-world`
[phy2] hello
[phy1] world

SoftNpu and Target x4c Use Cases.

The example above shows using x4c compiled code is a setting that is only really useful for testing the logic of compiled pipelines and demonstrating how P4 and x4c compiled pipelines work. This begs the question of what the target use cases for x4c actually are. It also raises question, why build x4c in the first place? Why not use the established reference compiler p4c and its associated reference behavioral model bmv2?

A key difference between x4c and the p4c ecosystem is how compilation and execution concerns are separated. x4c generates free-standing pipelines that can be used by other code, p4c generates JSON that is interpreted and run by bmv2.

The example above shows how the generation of free-standing runnable pipelines can be used to test the logic of P4 programs in a lightweight way. We went from P4 program source to actual packet processing using nothing but the Rust compiler and package manager. The program is executable in an operating system independent way and is a great way to get CI going for P4 programs.

The free-standing pipeline approach is not limited to self-contained use cases with packets that are generated and consumed in-program. x4c generated code conforms to a well defined Pipeline interface that can be used to run pipelines anywhere rustc compiled code can run. Pipelines are even dynamically loadable through dlopen and the like.

The x4c authors have used x4c generated pipelines to create virtual ASICs inside hypervisors that transit real traffic between virtual machines, as well as P4 programs running inside zones/containers that implement NAT and tunnel encap/decap capabilities. The mechanics of I/O are deliberately outside the scope of x4c generated code. Whether you want to use DLPI, XDP, libpcap, PF_RING, DPDK, etc., is up to you and the harness code you write around your pipelines!

The win with x4c is flexibility. You can compile a free-standing P4 pipeline and use that pipeline wherever you see fit. The near-term use for x4c focuses on development and evaluation environments. If you are building a system around P4 programmable components, but it's not realistic to buy all the switches/routers/ASICs at the scale you need for testing/development, x4c is an option. x4c is also a good option for running packets through your pipelines in a lightweight way in CI.