P4 で記述した簡易 L2 Switch にタグ VLAN（802.1Q）を対応させて VLAN 毎のトラヒックカウンタを実装する（準備編）

## はじめに

P4 で記述した L2 Switch に下記の機能を追加します．

-   タグ VLAN（802.1Q）毎に MAC テーブル/Broadcast domain を分割
-   VLAN 毎のトラヒックカウンタ

各機能の P4 での記述方法，および [BMv2（P4 対応の仮想スイッチ）](https://github.com/p4lang/behavioral-model)での機能確認方法について解説します．追加機能のコードを理解するための基礎文法についても簡単に記述しますが，詳細は[公式ページ](https://p4.org/p4-spec/docs/P4-16-v1.2.0.html)を参照ください．

思いのほか内容が膨らんでしまったため，P4 の基礎文法等を説明する「準備編」と付加機能の実装を説明する[「実装編」](https://qiita.com/13ryuse4/items/6f95ada4d248372603c2)の 2 段構成としました．まずは準備編として P4 の基礎文法から説明します．

## P4 の基礎文法

P4 はパケット処理に特化したプログラミング言語の一つです．特に，パケット処理を行う H/W そのものを P4 で抽象化することで，柔軟な機能追加を実現しています．

ユーザが自作機能を開発する際は，利用する H/W に対応する architecture を include し，その上に自作機能を記述していきます．architecture としては，OSS では [v1model](https://github.com/p4lang/p4c/blob/master/p4include/v1model.p4) や [psa](https://github.com/p4lang/p4c/blob/master/p4include/psa.p4) 等が知られており．ベンダ製チップでは [tofino](https://barefootnetworks.com/products/brief-tofino/) が有名です．なお，psa は Portable Switch Architecture の略で，P4.org というコミュニティが標準アーキティクチャとして仕様策定を行っているようです．PSA の詳細アーキティクチャは[公式ページ](https://p4.org/p4-spec/docs/PSA-v1.1.0.html)を参照ください．今回は BMv2 にて実装されている v1model を例に P4 プログラムの記述方法を簡単に解説します．

### ヘッダ定義

識別するヘッダを定義します．今回はタグ VLAN（802.1Q）に対応した L2 Switch なので Ether ヘッダ，VLAN タグが識別出来ればよいです．これらを P4 で記述すると下記のようになります．

```c:header_definition.p4
header ethernet_t {
	bit<48> dstAddr;
    bit<48> srcAddr;
    bit<16> etherType;
}

header vlan_t {
	bit<3> priority;
    bit<1> dei;
    bit<12> id;
    bit<16> etherType;
}
```

記述は非常にシンプルで，上位フィールドから順番に "ビット数" と "変数名" を列挙すればよいです．また，特定のビット数には名前を付けることが可能で，例えば MAC アドレスのビット長を表す macAddr_t を `typedef bit<48> macAddr_t` のように定義することも出来ます．この場合は `bit<48>` の部分を `macAddr_t` で代替可能です．

これらはヘッダの"型" を定義しており，実際のヘッダはこれらの "型" を組み合わせて定義します．今回は上記２つの "型" のみを取りうるため header は下記のように記述します．

```c:header.p4
struct headers {
	ethernet_t ethernet;
    vlan_t     vlan;
}   
```

C 言語の構造体に似ていますね．上述の header_definition.p4 で定義した "型" をもつ変数としてヘッダの構成要素（Ether ヘッダや IP ヘッダ等）を定義するイメージです．

こちらの headers という構造体には**取りうるヘッダを全て**記述します．入力パケットをこちらで定義した "型" のいずれかに沿って Parse していきますが，注意すべき点としては「全てのヘッダを過不足なく Parse する必要は無い」という点です．入力パケットがその "型" で取り出された場合は isValid() が true となり，取り出されていない場合は false となります．例えば（ethernet_t という型に沿って）ethernet が Parse された場合は `ethernet.isValid() == true` となります．

### Parser 定義

入力パケットは上述の headers 構造体で定義した各 "型" のいずれかに沿って Parse されます．今回は下記のような条件分岐を行い，入力パケットを Parse します．

-   入力パケットはまず ethernet_t（Ether ヘッダ）の "型" に沿って Parse
    -   取り出した Ether ヘッダの etherType が 0x8100（802.1Q VLAN タグ）の場合
        -   vlan_t（VLAN タグ）の型に沿って Parse
    -   取り出した Ether ヘッダの etherType が 0x8100 **以外**の場合
        -   Parse 終了

こちらを P4 で記述すると下記のようになります．

```c:parser.p4
state start {
    transition parse_ethernet;
}

state parse_ethernet {
    packet.extract(hdr.ethernet);
    transition select(hdr.ethernet.etherType) {
    	0x8100  : parse_vlan;
        default : accept;
    }
}

state parse_vlan {
	packet.extract(hdr.vlan);
    transition accept;
}
```

"state" という表記が示すように，Parser は状態遷移図としてモデル化され，各 "型" に沿ってParse する部分は一つの状態として記述されます．この時，下記のルールを守る必要があります．

-   最初の状態は必ず "start" でなければならない
-   最後の状態は必ず "accept" or "reject" でなければならない

最終状態が "accept" の場合はそのまま処理が続行し， "reject" の場合は入力パケットは破棄されます．なお，最終状態を明記しなかった場合は "reject" となるようです（[言語仕様](https://p4.org/p4-spec/docs/P4-16-v1.2.0.html#sec-transition)より）

各状態では packet_in 型の変数（コード中では packet と記述されており，入力パケットと等価なものとして認識すれば大丈夫です）が具備する extract() method により，引数で指定した "型" に沿って Parse し，値が格納されます．extract() の引数で指定した構造体変数の型に沿って入力パケットが Parse され，その結果が引数で指定した構造体変数に書き込まれる，というイメージで認識しておけばよいかと思います．例えば，入力パケットから Ether ヘッダを取り出したい場合は，ethernet_t 型の変数である ethernet を下記のように引数に指定し`packet.extract(hdr.ethernet)`と記述します．

"hdr" という変数は headers 構造体のインスタンスのようなイメージです．こちらは Parser 定義部そのものの引数として，例えば下記のように記述されます．

```c:parser_skerton.p4
parser ParseName(packet_in packet,
                 out headers hdr,
                 inout metadata_t meta,
                 inout standard_metadata_t standard_metadata) {
    /* Parse 処理記述（上記 parser.p4 参照） */
}
```

こちらは architecture に依存する部分となり，利用する H/W によっては引数が若干異なる場合があります．上記は v1model を用いる場合の記述となります．

状態間の遷移は `transition` あるいは `transition select()` と記述します．前者は遷移先の状態が１つの場合，後者は遷移先の状態が複数の場合にそれぞれ使用します．特に後者の場合は `select()` の括弧内で指定したフィールド値に基づいて遷移先の状態を選択します．例えば，Ether ヘッダの etherType に基づいて状態遷移する場合は `select(hdr.ethernet.etherType)` のように記述します．

### Ingress/Egress 定義

Parse された入力パケットに対する処理は Ingress/Egress にて記述します．なお，Ingress はパケットを受信（+ Parse）してキューにバッファリングするまでの処理，Egress はキューにバッファリングされて出力ポートから発出されるまでの処理を指します．

Ingress や Egress の構成は非常にシンプルで「action」「table」「apply」に大別されます．action ではコントロールプレーンから受け取った parameter を用いて，入力パケットに対して行う一連の処理を記述します．table では key と action の紐付けを定義します．apply では table や action を入力パケットにどのように適用するか，の処理シーケンスを定義します．入力パケットに適用したい処理のまとまりを action で定義し，それらをどのような key 値に基づいて match させるかを table で定義し，それらをどのような順序で入力パケットに適用していくかを apply で定義する，という感じです．

と，長々と書いてしまいましたが，実際の記述例を見たほうが理解が早いかと思います．例えば，コントロールプレーンから「出力ポート番号」を受け取って，その値を出力ポートに設定する action は下記のように記述できます．

```c:action.p4
action switching(bit<9> port) {
    standard_metadata.egress_spec = port;
}
```

standard_metadata については発展的な内容となるため詳細は省略しますが，ここでは「スイッチ側が用意した制御変数」というイメージでよいかと思います．standard_metadata にはスイッチを制御するため or スイッチから情報を取得するための様々な変数が定義されており，その中の egress_spec は入力パケットの出力ポートを決定する制御変数となります．```standard_metadata.egress_spec = port``` とすることで，コントロールプレーンにより登録されたポート（port）を出力ポートとして設定しているのです．なお，standard_metadata は architecture 毎に定義されており，v1model の standard_metadata は [v1model.p4](https://github.com/p4lang/p4c/blob/master/p4include/v1model.p4) にて定義されています．

上記で定義した action を入力パケットの宛先 MAC アドレスと紐付けて table に登録するためには下記のように記述します．

```c:table.p4
table mac_exact {
    key = { hdr.ethernet.dstAddr: exact; }
    actions = { switching; }
}
```

`key = { ... }` で table エントリの key を設定し，各エントリが **取りうる全ての action** を `actions = { ... }` に列挙します．このとき**全ての action** という部分は重要です．上記の table は action として switching のみを取りうる table として定義していますが，必ずしも転送するわけではなく，意図的にパケットドロップさせたい場合もあるかと思います．この場合は `actions = { ... }` にパケットドロップを行う action（ここでは drop と表記）を追加します．

```c:table_with_drop.p4
 table mac_exact {
     key = { hdr.ethernet.dstAddr: exact; }
	 actions = {
         switching;
+        drop;
     }
 }
```

table の定義で注意すべき点としては，table の記述はあくまで各エントリが取りうる action を列挙するのみで，実際の key 値がどのような action を取りうるか，すなわち table エントリそのものはコントロールプレーンが登録する，という点です．例えば，"11:22:33:44:55:66" という宛先 MAC アドレスに対しては（switching action によって）出力ポート "3" を設定し，また "66:55:44:33:22:11" という宛先 MAC アドレスに対してはパケットをドロップするというaction（ここでは drop と表記）を設定するとします．この場合，これらの組み合わせを下記のようにコントロールプレーンが登録します．

| key               | action    | action parameter |
| ----------------- | --------- | ---------------- |
| 11:22:33:44:55:66 | switching | 3 (000000011)    |
| 66:55:44:33:22:11 | drop      | -                |

action, table を定義したら，最後はこれらをどのような順序で適用していくか，を apply{} に記述します．入力パケットに VLAN タグが付いているかどうかで場合分けし，VLAN タグが付いていない場合のみ mac_exact table を適用（入力パケットの宛先 MAC アドレスを key として mac_exact のエントリを走査）する処理は下記のように記述されます．

```c:apply.p4
apply{
	if ( !hdr.vlan.isValid() ) {
    	mac_exact.apply();
	}
}
```

非常にシンプルですね．isValid() は "そのヘッダが Parse されたかどうか" を表す bool 型の変数を返します．! は NOT（論理否定）です．従って `!hdr.vlan.isValid()`は hdr の vlan が Parse されて**いない**場合に true となります．

入力パケットに table を適用する場合は テーブル名.apply() と記述します．今回は mac_exact table を適用するため `mac_exact.apply()` と記述することで，入力パケットの hdr.ethernet.dstAddr を key として mac_exact table を走査し，エントリが hit した場合は対応する action を実行します．

#### （おまけ）入力パケットへのヘッダ挿入

余談ですが，入力パケットに新たにヘッダを追加する場合は setValid() という method を使用します．例えば，VLAN タグが付いていないパケットに新たにVLAN タグを付与して転送する場合は下記のようにコードを追記すればよいです．

```c:ingress_advanced.p4
 apply {
     if ( !hdr.vlan.isValid() ) {
     	mac_exact.apply();
+		hdr.vlan.setValid();
+		hdr.vlan.priority  = /* 任意の値を入力 */;
+		hdr.vlan.dei       = /* 任意の値を入力 */;
+		hdr.vlan.id        = /* 任意の値を入力 */;
+		hdr.vlan.etherType = /* 任意の値を入力 */;
     }
 }
```

上記の記述は，アクセスポートで VLAN パケットを受信し，トランクポートから VLAN パケットを発出する場合等に使えるかと思います．

### Deparser 定義

パケット処理の最後は，Parse したヘッダの再構築（Deparse）です．出力ポートから発出するヘッダを構成します．今回の場合は Ether ヘッダ or VLAN タグを取りうるため，下記のように記述します．

```c:deparser.p4
control DeparserName(packet_out packet, in headers hdr) {
	apply{
    	packet.emit(hdr.ethernet);
		packet.emit(hdr.vlan);
    }
}
```

基本的にはheaders.p4 で定義した headers 構造体のメンバ変数を packet.emit() の引数として列挙すればよいです．emit() method は引数の isValid() が true の場合に出力パケットのヘッダに当該ヘッダをスタックします．そのため，例えば VLAN タグを Parse しなかった場合（`hdr.vlan.isValid() == false`の場合）は `packet.emit(hdr.vlan)` と記述されていても出力ヘッダに VLAN タグをスタックしません．

なお，Deparser における "packet" は Parser における "packet" とは型が異なります．Parser で使用する "packet" は packet_in 型，Deparser で使用する "packet" は packet_out 型です．こちらも発展的な内容となるため，packet_in 型の変数は入力パケット，packet_out 型の変数は出力パケットと認識しても差し支えないかと思います（具体的な定義は [core.p4](https://github.com/p4lang/p4c/blob/master/p4include/core.p4) で与えられます．詳細はこちらを参照ください）．

## P4 による L2 Switch 実装

以上のP4プログラム（の断片）を組み合わせることで，P4 による簡易 L2 Switch が実装可能です．ここで想定する L2 switch の仕様は下記です．

-   入力パケットのヘッダには Ether ヘッダ（ + 802.1Q VLAN タグ）がスタックされている．
-   入力パケットに VLAN タグがスタックされていない場合
    -   宛先 MAC アドレスに基づき出力ポートを選択する．
-   VLAN タグがスタックされている場合
    -   入力パケットを drop する（VLAN タグも考慮して switching する実装は「実装編」にて説明します）．

なお，宛先 MAC アドレスと出力ポートの組は事前にコントロールプレーンより登録するものとします．

P4 で記述すると下記のようになります．

```c:L2switch.p4
/** INCLUDE ARCHITECTURE **/
#include <core.p4>
#include <v1model.p4>

/** HEADERS **/
header ethernet_t {
	bit<48> dstAddr;
    bit<48> srcAddr;
    bit<16> etherType;
}

header vlan_t {
	bit<3> priority;
    bit<1> dei;
    bit<12> id;
    bit<16> etherType;
}

struct headers {
	ethernet_t ethernet;
    vlan_t     vlan;
}

struct metadata_t {
}

/** PARSER **/
parser ParseName(packet_in packet,
                 out headers hdr,
                 inout metadata_t meta,
                 inout standard_metadata_t standard_metadata) {

	state start {
    	transition parse_ethernet;
	}

	state parse_ethernet {
    	packet.extract(hdr.ethernet);
    	transition select(hdr.ethernet.etherType) {
    		0x8100  : parse_vlan;
        	default : accept;
    	}
	}

	state parse_vlan {
		packet.extract(hdr.vlan);
    	transition accept;
	}
}

/** INGRESS **/
control IngressName(inout headers hdr,
                    inout metadata_t meta,
                    inout standard_metadata_t standard_metadata) {

    action drop() {
        mark_to_drop(standard_metadata);
    }

    action switching(bit<9> port) {
    	standard_metadata.egress_spec = port;
	}
    
    table mac_exact {
        key = { hdr.ethernet.dstAddr: exact; }
        actions = {
            switching;
            drop;
        }
    }
    
    apply{
        if ( !hdr.vlan.isValid() ) {
            mac_exact.apply();
        } else {
            drop();
        }
    }
}

/** EGRESS **/
control EgressName(inout headers hdr,
                   inout metadata meta,
                   inout standard_metadata_t standard_metadata) {
    apply {}
}

/** DEPARSER **/
control DeparserName(packet_out packet, in headers hdr) {
	apply{
    	packet.emit(hdr.ethernet);
		packet.emit(hdr.vlan);
    }
}

/* Verify Checksum (NOT used here) */
control verifyChecksum(inout headers_t hdr, inout metadata_t meta) {
    apply { }
}

/* Update Checksum (NOT used here) */
control updateChecksum(inout headers_t hdr, inout metadata_t meta) {
    apply { }
}

/* Pipeline Definition */
V1Switch(parserImpl(),
         verifyChecksum(),
         ingressImpl(),
         egressImpl(),
         updateChecksum(),
         deparserImpl()) main;
```

簡易的な L2 Switch は概ね上記のように記述することで実装可能です．なお，Egress は特に処理が必要無いため空白としています．

## おわりに

P4 の基礎文法，および簡易 L2 Switch の実装を説明しました．「[実装編](https://qiita.com/13ryuse4/items/6f95ada4d248372603c2)」では上記で実装した L2 Switch に対し下記の機能追加を行います．

-   タグ VLAN（802.1Q）毎に MAC テーブル/Broadcast domain を分割
-   VLAN 毎のトラヒックカウンタ