The P4 lab uses one application throughout, basic.p4, a basic router, to illustrate the main features of P416. Its job is the canonical six steps of an IP router, and every step maps onto a piece of the language from chapter 6:
The starter code comes with the Ethernet and IPv4 headers already defined and added to the headers struct, but the parser block empty. The coding homework — which is a good self-test of chapter 6 — is to fill it in: define the states, start in start, transition to parse_ethernet, extract the Ethernet header, branch on etherType with a select to parse_ipv4 if it equals the IPv4 type, otherwise finish; and in parse_ipv4, simply extract the IPv4 header.
The make targets hide a pipeline of tools, and knowing them is knowing how a P4 program becomes a running switch. In pseudocode the Makefile does:
P4C_ARGS = --p4runtime-file $(basename $@).p4info --p4runtime-format text
RUN_SCRIPT = ../../utils/run_exercise.py
TOPO = topology.json
build: # for each P4 program, generate the BMv2 json
p4c-bm2-ss --p4v 16 $(P4C_ARGS) -o $@ $<
run: # build, then launch Mininet with the topology [default target]
sudo python $(RUN_SCRIPT) -t $(TOPO)
stop:
sudo mn -c
The build step, p4c-bm2-ss -o test.json test.p4, produces test.json, a JSON description of the forwarding pipeline compiled from test.p4, required by the BMv2 simple_switch binary. Optionally it also emits a P4Info message — a protobuf describing the data model the control plane will use to generate P4Runtime requests (the subject of chapter 8).
Two outputs, two audiences. The .json configures the data plane — it tells BMv2 how to parse and process. The .p4info describes the same program to the control plane — it is the contract by which a controller knows the names and IDs of tables, keys and actions. The same compiler run serves both, which is the whole point of a shared language.
Running the switch is a three-step dance, and each step corresponds to one of the boxes in the BMv2 diagram: prepare interfaces, start the model, start the CLI.
# step 2 — prepare veth interfaces first, then start the software switch
ip link add name veth0 type veth peer name veth1
for iface in "veth0 veth1"; do
ip link set dev ${iface} up
sysctl net.ipv6.conf.${iface}.disable_ipv6=1
done
sudo simple_switch --log-console --dump-packet-data 64 \
-i 0@veth0 -i 1@veth2 ... test.json
# step 3 — start the runtime CLI (talks to the control server over a Thrift TCP socket)
simple_switch_CLI
The interaction with the control plane is stated plainly by the slides: the P4 program defines the packet-processing pipeline, but the rules within a table are entered by the control plane at runtime (via P4Runtime, chapter 8). When a rule matches a packet, its action is invoked with the parameters supplied by the control plane as part of the rule. In the exercises, booting the Mininet instance with make run installs the packet-processing rules in each switch's tables from the sX-commands.txt files, where X is the switch number; the sX-runtime.json files instead drive P4Runtime and refer to specific names of tables, keys and actions as defined in the P4Info file.
Inside simple_switch_CLI you drive the tables directly. The syntax is worth memorising because it reappears verbatim in the industrial lab (chapter 11), where table_add modbus_sec cipher 2 => installs the encryption rules.
RuntimeCmd: show_tables
m_filter
m_table
RuntimeCmd: table_add m_table m_action \
01:00:00:00:00:00&&&01:00:00:00:00:00 => 1 0
Adding entry to ternary match table m_table
SUCCESS
entry has been added with handle 1
RuntimeCmd: table_delete m_table 1
Three pieces of syntax carry the meaning:
| Token | Meaning |
|---|---|
value&&&mask | A ternary match: value and mask, no spaces around the &&&. |
=> | Separates the match key from the action data. |
| entry handle | Returned on add; all subsequent operations (e.g. table_delete) use it. |
The final piece of the run is traffic. In some exercises a send.py / receive.py pair is used; in others, standard Linux programs like ping; and scapy can build and sniff packets directly:
p = Ethernet()/IP()/UDP()/"Payload"
sendp(p, iface="veth0")
sniff(iface="veth9", prn=lambda x: x.show())
Tunneling is presented as a main feature for data center networks, mobile core networks (the Evolved Packet Core of chapter 13), network virtualization (VXLAN, GRE — chapter 4), mobility management (Mobile IP) and overlay routing. The mechanism is universal: encapsulate a packet into another by prepending a new header.
The exercise adds tunneling to the basic router by defining a new header type, myTunnel, that encapsulates the IP packet. It carries just two fields:
| Field | Purpose |
|---|---|
proto_id | The type of packet being encapsulated. |
dst_id | The ID of the destination host. |
The key idea in the slides is that once a tunnel header is present the switch does routing on the tunnel header, so that changing the inner IP address will not change the path. The to-do list is a compact tour of everything in chapter 6:
myTunnel_t header type and add it to the headers struct.etherType = 0x1212 (tunnel).myTunnel_forward action and the myTunnel_exact table.apply block.myTunnel_ingress rule to encapsulate on the ingress switch, a myTunnel_forward rule to forward, and a myTunnel_egress rule to decapsulate and forward on the egress switch.Steps 1, 2, 5 and 6 are exactly the parse/deparse validity mechanism from chapter 6, section 9. Encapsulation is "set the tunnel header valid and emit it"; decapsulation is "set it invalid so the deparser skips it". No special construct is needed — a tunnel is just a header whose validity the pipeline toggles.
Making a copy of a packet — for mirroring to an observer, exactly the final-project challenge of chapter 11 — is done with the clone3 primitive. The slides give the action and, in comments, the operational context:
action do_clone_i2e(bit<32> packet) {
// BMv2 simple_switch can have multiple clone "sessions" at once.
// Each can be configured to go to an independent output port,
// via the simple_switch_CLI command mirroring_add.
// A "mirroring session" and a "clone session" are the same thing.
// first parameter: clone type
// second parameter: the session
// third parameter: the metadata to carry across
clone3(CloneType.I2E, I2E_CLONE_SESSION_ID, standard_metadata);
}
The I2E in CloneType.I2E means ingress-to-egress: the clone is made in ingress and delivered in egress. The clone session, configured with mirroring_add in the CLI, decides which port the copy leaves on. This one primitive is the entire mechanism behind "mirror all Modbus traffic to the observer" in the project challenges.
This exercise is where the pipeline ordering of chapter 6 pays off. The theory came from chapter 5; here it becomes code. Explicit Congestion Notification uses two bits (in the old Type of Service field), and the slides give the four codepoints:
| ECN bits | Meaning |
|---|---|
00 | Non ECN-Capable Transport (Non-ECT) |
10 | ECN Capable Transport — ECT(0) |
01 | ECN Capable Transport — ECT(1) |
11 | Congestion Encountered (CE) |
For packets originating from an ECT source, ECN-capable switches set the CE bits upon congestion — for example when the observed queue depth exceeds a threshold (RFC 3168). And here is the crucial fact: the V1Model standard metadata exposes the queue depth as standard_metadata.enq_qdepth, a bit<19> — but it is measured at the traffic manager and only available in egress. That is why the whole exercise happens in the egress control.
The desired behaviour and the P4 recipe:
ipv4.ecn. For such packets, each switch may change the value to 3 if the queue is larger than a threshold. The receiver copies the value back to the sender, and the sender lowers its rate.ECN_THRESHOLD; if larger, set the ECN bits to 3 (binary 11, congestion encountered) — but only if the original ECN was 1 or 2.The ECN exercise is a clean test of two chapter-6 facts at once: (1) enq_qdepth is only meaningful in egress, because the traffic manager sits between ingress and egress; (2) the switch must not mark a Non-ECT packet, so the guard is "original ECN is 1 or 2". Setting CE on a non-ECN flow would be a protocol violation, not just a bug.
BMv2 maintains logs that track in detail how packets are processed, one per switch: /tmp/p4s.s1.log, /tmp/p4s.s2.log, /tmp/p4s.s3.log. You can add information to the logs manually with a dummy debug table that reads the headers and metadata of interest:
control MyIngress(...) {
table debug {
key = { std_meta.egress_spec : exact; }
actions = { }
}
apply {
// ...
debug.apply();
}
}
A table with no actions never changes the packet, but the act of building its lookup key is logged, so a line like Looking up key: * std_meta.egress_spec : 2 appears in the switch log. It is the P4 equivalent of a print statement, and it is the pragmatic answer to the FAQ facts from chapter 6 (architecture-dependent parser rejection, architecture-dependent parse depth): when in doubt, read what the switch actually did.
Chapter 6 said pure P4 cannot keep state across packets and that the answer is external objects. Here are the three, and the distinction between them is the most exam-relevant material in the whole P4 lab.
The framing: stateless objects — variables (metadata) and headers — do not maintain state across packets; stateful objects — tables, and the externs registers/counters/meters — keep state across different packets.
Registers store arbitrary data — single values or arrays of N entries — and, unlike counters and meters, are readable and writable from within the data plane. This makes them the general-purpose per-flow memory.
register<bit<48>>(5) hello; // array of 5 entries
hello.write(1, 0xff); // reg.write(index, value)
hello.read(res, 1); // reg.read(result, index)
The example use is inter-packet gap detection: store the timestamp of the last packet seen for each flow, then on the next packet compute delta = ingress_global_timestamp - last_seen and rewrite the register. The slides add a caveat: concurrent read and write must be synchronized if required — the one place per-packet parallelism can bite.
Counters count packets, bytes, or both, formed in arrays, with an enum CounterType { packets, bytes, packets_and_bytes }.
counter(64, CounterType.packets_and_bytes) c;
apply { c.count((bit<32>)standard_metadata.ingress_port); }
// from the control plane:
// counter_read MyIngress.c 1
// -> MyIngress.c[1] = BmCounterValue(packets=1, bytes=658)
Crucially, you cannot access counter information from within the data plane — the control plane reads it. Direct counters are attached to a table (counters = c;) so that each table entry has its own counter incremented on match — precisely the per-function-code counting the project challenges of chapter 11 require.
Meters measure packet rates, formed in arrays, with enum MeterType { packets, bytes } and three colours { RED, GREEN, YELLOW }. A meter returns a colour: GREEN if under the Committed Information Rate, YELLOW if above CIR but below the Peak Information Rate, RED if above PIR (RFC 2698). Rates are set by the control plane per instance.
meter(16384, MeterType.packets) acl_meter;
action color_my_packets(bit<32> index) {
acl_meter.execute_meter((bit<32>)index, meta.meta_tag); // meta_tag = colour
}
// then a second table drops or accepts depending on the colour
table m_filter {
key = { meta.meta_tag: exact; }
actions = { drop; NoAction; }
}
The pattern is: one table colours the packet with a meter, a second table treats the packet differently depending on the colour — rate limiting expressed as two match-action stages.
Choose the right stateful construct for a task with the widget below.
The summary slide names the payoff: several stateful constructs to record and update per-flow or per-packet state, useful for congestion tracking, stateful forwarding and congestion-aware load balancing. Registers are the only one you both read and write in the data plane; counters and meters have their configuration and readout on the control-plane side. That read/write asymmetry is the single most tested distinction here.
Parse Ethernet and IPv4; find the destination in the IPv4 table; update source/destination MAC; decrement TTL; set the egress port; deparse. The homework is to fill the empty parser: start → parse_ethernet (extract Ethernet, select on etherType), parse_ipv4 if etherType is the IPv4 type (extract IPv4), otherwise accept.
test.json, a JSON description of the forwarding pipeline, consumed by BMv2 simple_switch (the data plane); and optionally the P4Info protobuf, which describes the program's tables, actions and parameters to the control plane so it can format P4Runtime requests. One compiler run, two audiences.
The control plane installs rules at runtime (via P4Runtime); when a rule matches, its action runs with control-plane-supplied parameters. In the exercises, make run installs rules from sX-commands.txt per switch, while sX-runtime.json drives P4Runtime and references names from the P4Info file.
It adds an entry to the ternary table m_table: the key is a value and mask separated by &&& (no spaces); => separates the key from the action data; the action is m_action with data 1 0. The command returns an entry handle used by later operations such as table_delete m_table 1.
proto_id (the type of packet being encapsulated) and dst_id (the ID of the destination host). Routing on the tunnel header means changing the inner IP address will not change the path — the path follows the tunnel, not the inner destination.
By toggling header validity. Encapsulation makes the tunnel header valid so the deparser emits it; decapsulation makes it invalid so the deparser skips it. No special construct is needed — it is the same validity mechanism the deparser uses for every header.
It clones a packet ingress-to-egress: the copy is made in ingress and delivered in egress. The clone/mirroring session (configured with mirroring_add) chooses the output port for the copy. This is the mechanism for mirroring traffic to an observer, as in the project challenges.
00 Non-ECT, 10 ECT(0), 01 ECT(1), 11 CE (congestion encountered). An ECN-capable switch sets the bits to 11 when the queue exceeds a threshold, but only if the packet's original ECN was 1 or 2 — it must never mark a Non-ECT packet.
Because the queue depth, standard_metadata.enq_qdepth, is measured at the traffic manager, which sits between ingress and egress, and is only available in the egress pipeline. Ingress has decided forwarding but the packet has not yet been queued, so ingress cannot know the queue depth.
Add a dummy debug table with a key over the headers/metadata of interest and no actions, then apply it. It changes nothing but logs the lookup key into the per-switch log (/tmp/p4s.sN.log), e.g. Looking up key: * std_meta.egress_spec : 2.
Registers store arbitrary data (single values or arrays) and are read/write from the data plane — general per-flow memory (e.g. inter-packet gap detection). Counters count packets/bytes/both in arrays; the data plane counts but cannot read them, the control plane reads; direct counters attach to a table so each entry counts on match. Meters measure rates and return a colour (GREEN under CIR, YELLOW between CIR and PIR, RED above PIR), with rates set by the control plane; a second table then treats packets by colour.
The read/write asymmetry: registers can be both read and written in the data plane; counters and meters have their configuration and readout on the control-plane side. If you need per-packet state that the pipeline itself must consult, only a register will do.