赵占旭的博客

P4 语言初识

本篇主要是了解P4语言,原文链接在此

概览

P4最初是为了编程交换机而设计的,但是现在已经涵盖了多种设备(软硬件交换机,网卡,路由器或者其他网络设备)。他的命名源于Programming Protocol-independent Packet Processors

接下来查看一下传统交换机和P4可编程交换机之间的区别:

avatar

传统交换机的制造商定义了数据平面功能,控制平面通过管理各种表(比如路由表),配置专用功能(例如流量监控),以及通过处理控制报文(比如路由协议报文)或者异步事件(例如链路状态改变或者学习)来控制数据平面。

P4交换机和传统交换机有两方面的不同:

  • 数据平面功能不是固定的,而是由P4程序定义的。数据平面在初始化时配置,以实现P4程序描述的功能。
  • 控制平面和数据平面通信的通道与固定功能的交换机一致,但是数据平面中的表集合和其他功能不再固定,因为他们是P4程序定义的。P4编译器生成控制平面和数据平面通信的API。

P4是协议无关的,所以我们可以定义任何协议和数据平面的action,接下来我们了解一下P4语言提供的核心抽象:

  • header types描述了报文中的每个协议头
  • parsers描述了接收的报文中允许的header sequences,以及如何从报文中提取并且识别关心的字段。
  • tables关联了用户定义的keys和actions。P4表囊括了传统的交换表,用于实现路由表,流查找表,acl和其他用户自定义的类型表(比如复杂的由多变量决策的表,因为支持if判断吧)。
  • actions描述了如何操作报文头字段和meadata。actions包括运行时由控制平面提供的数据。
  • match-action units执行以下操作顺序。
    • 从报文字段和计算的metadata查找keys。
    • 使用构造的key执行表的查找,然后选择需要执行的action。
    • 最后执行选定的action。
  • control flow描述了报文处理的命令性程序。
  • extern objects是特定于体系结构的构造,可以由P4程序通过定义良好的API来操纵,但是内部行为是硬件实现的(比如checksum),因此不能使用P4进行编程。
  • user-defined metadata与每个报文关联的用户定义的数据结构。
  • intrinsic metadata由与每个报文关联的体系结构提供的metadata,比如已经接收到报文的input port。

下图展示了使用P4编程时的工作流程

avatar

交换机厂商提供硬件或者软件实现框架,体系结构定义和P4编译器。

编译一组P4程序会产生两个组件

  • 数据平面配置,实现程序中输入的转发逻辑
  • 获取并管理来自控制平面状态的API

P4特性

  • Flexibility,P4将报文转发策略表示为程序,传统交换机将固定功能转发引擎暴露给用户
  • Expressiveness,P4可以只使用通用操作和表查找来表达复杂的,与硬件无关的报文处理算法。此类程序可以跨平台实现移植。
  • Resource mapping and management,P4程序抽象的描述存储资源(IPv4源地址),编译器将这些用户定义的字段映射到可用的硬件资源。
  • Software engineering,P4程序提供重要的好处,例如类型检查,信息隐藏和软件重用。
  • Component libraries,制造商提供的组件库可用于将特定于硬件的功能包装到便携式高级P4构造中。
  • Decoupling hardware and software evolution,制造商可能会使用抽象体系结构来进一步将底层架构细节的演变和上层处理分离。
  • Debugging,制造商可以提供架构的软件模型,以帮助开发和调试P4程序。

P4 language evolution: comparison to previous versions (P4 v1.0/v1.1)

avatar

与P414(早期的语言版本)相比,P416对语言的语法和语义进行了许多重要的,向下不兼容的修改。如图所示,语言中消除了大量的语言功能,并将其移入包括counters,checksum units,meters等。

架构模型

avatar

P4架构展示了P4可编程模块(parser,ingress control flow,egress control flow,deparser等等)及其数据平面接口。

上图中展示了P4可编程模块之间的数据平面接口。它显示了一个具有两个可编程模块的目标。每个块都通过P4代码的单独片段进行编程。目标通过一组控制寄存器或者信号与P4程序连接。输入空间向P4程序(例如,从起接收报文的输入端口)提供信息,而输出控制可由P4程序写入以影响目标行为(例如,必须指向报文的输出端口)。控制寄存器/信号在P4中表示为intrinsic metadata。P4程序还可以存储和操纵与每个报文有关的数据作为user-defined metadata。

Data plane interfaces

1
2
3
control MatchActionPipe<H>(in bit<4> inputPort,
inout H parsedHeaders,
out bit<4> outputPort);

直接根据名字就能清楚他是一个用于match和action的pipeline。

  • 第一个参数是名为inputPort的4 bits的值,方向in表示是无法修改的输入。
  • 第二个参数是名为parsedHeaders的H类型的对象,其中H是一个类型变量,表示稍后定义,inout表示此参数即是输入也是输出。
  • 第三个参数是名为outputPort的4 bits的值,方向out表示此参数是一个输出,其值未定义但是可以修改。

Extern objects and functions

extern构造对象,并且暴露给数据平面。有点类似于抽象类,不描述实现。

1
2
3
4
5
6
7
extern Checksum16 {
Checksum16(); // constructor
void clear(); // prepare unit for computation
void update<T>(in T data); // add data to checksum
void remove<T>(in T data); // remove data from existing checksum
bit<16> get(); // get the checksum for the data added since last clear
}

Example: A very simple switch

avatar

VSS(Very Simple Switch)有三种接收报文的方式,分别是直接通过8个输入口,通过recirculate通道和直连CPU发出的报文。 VSS有一个单独的parser,可以输入单个match-action pipeline。退出deparser后,数据包通过8个输出端口发送或者drop、to cpu、recurculate。

图中的白色块是可编程,必须提供相应的P4程序来指定每个这样的块的行为。
红色箭头表示用户定义数据的流程。
绿色块是固定功能组件。
绿色箭头是数据平面接口,用于在固定功能块和P4程序中公开的可编程块之间传递intrinsic metadata信息。

VSS架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// File "very_simple_switch_model.p4"
// Very Simple Switch P4 declaration
// core library needed for packet_in and packet_out definitions
# include <core.p4>
/* Various constants and structure declarations */
/* ports are represented using 4-bit values */
typedef bit<4> PortId;
/* only 8 ports are "real" */
const PortId REAL_PORT_COUNT = 4w8; // 4w8 is the number 8 in 4 bits
/* metadata accompanying an input packet */
struct InControl {
PortId inputPort;
}
/* special input port values */
const PortId RECIRCULATE_IN_PORT = 0xD;
const PortId CPU_IN_PORT = 0xE;
/* metadata that must be computed for outgoing packets */
struct OutControl {
PortId outputPort;
}
/* special output port values for outgoing packet */
const PortId DROP_PORT = 0xF;
const PortId CPU_OUT_PORT = 0xE;
const PortId RECIRCULATE_OUT_PORT = 0xD;
/* Prototypes for all programmable blocks */
/**
* Programmable parser.
* @param <H> type of headers; defined by user
* @param b input packet
* @param parsedHeaders headers constructed by parser
*/
parser Parser<H>(packet_in b,
out H parsedHeaders);
/**
* Match-action pipeline
* @param <H> type of input and output headers
* @param headers headers received from the parser and sent to the deparser
* @param parseError error that may have surfaced during parsing
* @param inCtrl information from architecture, accompanying input packet
* @param outCtrl information for architecture, accompanying output packet
*/
control Pipe<H>(inout H headers,
in error parseError,// parser error
in InControl inCtrl,// input port
out OutControl outCtrl); // output port
/**
* VSS deparser.
* @param <H> type of headers; defined by user
* @param b output packet
* @param outputHeaders headers for output packet
*/
control Deparser<H>(inout H outputHeaders,
packet_out b);
/**
* Top-level package declaration - must be instantiated by user.
* The arguments to the package indicate blocks that
* must be instantiated by the user.
* @param <H> user-defined type of the headers processed.
*/
package VSS<H>(Parser<H> p,
Pipe<H> map,
Deparser<H> d);
// Architecture-specific objects that can be instantiated
// Checksum unit
extern Checksum16 {
Checksum16(); // constructor
void clear(); // prepare unit for computation
void update<T>(in T data); // add data to checksum
void remove<T>(in T data); // remove data from existing checksum
bit<16> get(); // get the checksum for the data added since last clear
}

我们来详细说一下里面都是啥:

  • # include <core.p4>文件定义了一些标准的data-types和error codes。
  • bit<4>表示4 bits的bit-strings类型。
  • 4w0xF表示4 bits位宽的值15,也可以写成4w15,或者忽略位宽,直接写15,这里的w可能是with的意思,反正不是万。
  • error是P4内置的处理error codes的类型
  • parser Parser<H>(packet_in b, out H parsedHeaders);表示从packet_in读取输入,packet_in是一个预定义的extern object,定义于P4表示传入的报文。parser将其输出(out关键字)到parsedHeaders作为参数。这个参数的类型是H,也由程序员提供。
  • control Pipe<H>(inout H headers, in error parseError, in InControl inCtrl, out OutControl outCtrl);表示一个Match-Action的pipeline,输入有三个参数:headers, parser error, control data。输出为control data到deparser。
  • package VSS<H>(Parser<H> p, Pipe<H> map, Deparser<h> d);表示我们的packge定义,其中三个比较重要的参数就是我们之前提到的parser, pipeline, deparser。
  • Checksum16是一个extern object,可以调用他来计算checksum。

VSS框架描述

之前看了P4语言涉及到的几块,现在我们要说一下图中有但是我们还没有说到的部分。

Arbiter block

  • 接收报文,途径有网口,控制平面,recirculation channel。
  • 从网口收取的报文要计算checksum,不对丢弃,匹配则从报文的payload删除checksum。
  • 多个报文可用时,采用arbitration算法
  • 如果arbiter block忙于处理之前的报文,而队列满了,新来的报文会丢弃,也不现实为何丢弃。
  • 接收到报文收,arbiter block将inCtrl.inputPort值设置为parser的输入,该值记录了输入来自何方,网口编号为0到7,recirculation channel编号为13,CPU发送的报文编号为14。

Parser runtime block

Parser runtime block和Parser协同工作,他基于parser的actions向match-action pipeline提供error codes,并且向demux block提供报文payload(剩余payload的大小)的信息。只要parser完成报文的处理,就会调用match-action pipeline,并将关联的metadata作为输入(报文头和user-defined metadata)。

Demux block

demux block的核心功能是接收来自deparser的输出报文头和parser的报文payload,将他们组装成新的报文并发送到正确的输出端口。输出端口由outCtrl.outputPort值指定,这个值由match-action pipeline设置。

  • 将报文发送到drop端口会丢弃报文
  • 将报文发送到0-7的物理网口会直接发送,如果出口正在发送报文,则放入队列中。发送报文时,物理口会计算正确的Ethernet checksum trailer并添加到报文。
  • 将报文发送到14的CPU会传输到控制平面。这种情况下,发送的报文为原始输入报文,而不是从解析器接收的报文。
  • 将报文发送到13的recirculation port,会重复进行一次操作,如果一次处理不完时,可以使用。
  • 如果outputPort是非法值时(9),丢弃该报文。
  • 如果demux unit忙于处理之前的报文并且没有能力对来自deparser的报文进行排队,则demux unit会丢弃该报文,不管输出端口为何物。

Demux block的某些操作可能是意外的,比如上面队列满了丢弃报文的行为,我们这里也没有指定与队列大小,arbitration和时序相关的几个重要行为,这些也会影响报文的处理。

从parser runtime到demux block的箭头表示从parser到demux的附加信息流:正在处理的报文以及解析结束的报文偏移值(payload的起始位置)。

Available extern blocks

VSS架构提供了增量checksum的extern block,Checksum16。checksum unit有一个构造函数和四个method:

  • clear():准备unit做新的计算
  • update<T>:添加一些需要计算checksum的数据。数据必须是bit-string、header-typed值或者包含这些数据的结构体。
  • get():返回16-bit的checksum,调用此函数时,checksum必须已经收到整数个字节的数据。
  • remove<T>:假设数据用于计算当前的checksum,从checksum中删除数据。

A complete Very Simple Switch program

在上面的VSS架构上实现IPv4报文的基本转发。

avatar

Parser尝试识别Ethernet header以及之后的IPv4 header。如果缺少其中任何一个headers,parser将终止并显示error。否则,将这些headers中的信息提取到Parsed_packer结构中。match-action pipeline有四个匹配项:

  • 如果parser产生任何错误,丢弃报文。
  • 第一个表使用IPv4目标IP来确定outputPort和下一跳的IPv4地址。如果查找失败,则丢弃报文。该表还减少了TTL。
  • 第二标检查TTL值,如果TTL变为0,则通过CPU端口将报文发送到控制平面。
  • 第三个表使用下一跳的IPv4地址来确定下一跳的Ethernet地址。
  • 最后一个表使用outputPort来标识当前交换机的源MAC。

Deparser通过重新组装pipeline计算的Ethernet和IPv4 headers来构造输出报文。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
// Include P4 core library
# include <core.p4>

// Include very simple switch architecture declarations
# include "very_simple_switch_model.p4"

// This program processes packets comprising an Ethernet and an IPv4
// header, and it forwards packets using the destination IP address

typedef bit<48> EthernetAddress;
typedef bit<32> IPv4Address;

// Standard Ethernet header
header Ethernet_h {
EthernetAddress dstAddr;
EthernetAddress srcAddr;
bit<16> etherType;
}

// IPv4 header (without options)
header IPv4_h {
bit<4> version;
bit<4> ihl;
bit<8> diffserv;
bit<16> totalLen;
bit<16> identification;
bit<3> flags;
bit<13> fragOffset;
bit<8> ttl;
bit<8> protocol;
bit<16> hdrChecksum;
IPv4Address srcAddr;
IPv4Address dstAddr;
}

// Structure of parsed headers
struct Parsed_packet {
Ethernet_h ethernet;
IPv4_h ip;
}

// Parser section

// User-defined errors that may be signaled during parsing
error {
IPv4OptionsNotSupported,
IPv4IncorrectVersion,
IPv4ChecksumError
}

parser TopParser(packet_in b, out Parsed_packet p) {
Checksum16() ck; // instantiate checksum unit

state start {
b.extract(p.ethernet);
transition select(p.ethernet.etherType) {
0x0800: parse_ipv4;
// no default rule: all other packets rejected
}
}

state parse_ipv4 {
b.extract(p.ip);
verify(p.ip.version == 4w4, error.IPv4IncorrectVersion);
verify(p.ip.ihl == 4w5, error.IPv4OptionsNotSupported);
ck.clear();
ck.update(p.ip);
// Verify that packet checksum is zero
verify(ck.get() == 16w0, error.IPv4ChecksumError);
transition accept;
}
}

// Match-action pipeline section

control TopPipe(inout Parsed_packet headers,
in error parseError, // parser error
in InControl inCtrl, // input port
out OutControl outCtrl) {
IPv4Address nextHop; // local variable

/**
* Indicates that a packet is dropped by setting the
* output port to the DROP_PORT
*/
action Drop_action() {
outCtrl.outputPort = DROP_PORT;
}

/**
* Set the next hop and the output port.
* Decrements ipv4 ttl field.
* @param ivp4_dest ipv4 address of next hop
* @param port output port
*/
action Set_nhop(IPv4Address ipv4_dest, PortId port) {
nextHop = ipv4_dest;
headers.ip.ttl = headers.ip.ttl - 1;
outCtrl.outputPort = port;
}

/**
* Computes address of next IPv4 hop and output port
* based on the IPv4 destination of the current packet.
* Decrements packet IPv4 TTL.
* @param nextHop IPv4 address of next hop
*/
table ipv4_match {
key = { headers.ip.dstAddr: lpm; } // longest-prefix match
actions = {
Drop_action;
Set_nhop;
}
size = 1024;
default_action = Drop_action;
}

/**
* Send the packet to the CPU port
*/
action Send_to_cpu() {
outCtrl.outputPort = CPU_OUT_PORT;
}

/**
* Check packet TTL and send to CPU if expired.
*/
table check_ttl {
key = { headers.ip.ttl: exact; }
actions = { Send_to_cpu; NoAction; }
const default_action = NoAction; // defined in core.p4
}

/**
* Set the destination MAC address of the packet
* @param dmac destination MAC address.
*/
action Set_dmac(EthernetAddress dmac) {
headers.ethernet.dstAddr = dmac;
}

/**
* Set the destination Ethernet address of the packet
* based on the next hop IP address.
* @param nextHop IPv4 address of next hop.
*/
table dmac {
key = { nextHop: exact; }
actions = {
Drop_action;
Set_dmac;
}
size = 1024;
default_action = Drop_action;
}

/**
* Set the source MAC address.
* @param smac: source MAC address to use
*/
action Set_smac(EthernetAddress smac) {
headers.ethernet.srcAddr = smac;
}

/**
* Set the source mac address based on the output port.
*/
table smac {
key = { outCtrl.outputPort: exact; }
actions = {
Drop_action;
Set_smac;
}
size = 16;
default_action = Drop_action;
}

apply {
if (parseError != error.NoError) {
Drop_action(); // invoke drop directly
return;
}

ipv4_match.apply(); // Match result will go into nextHop
if (outCtrl.outputPort == DROP_PORT) return;

check_ttl.apply();
if (outCtrl.outputPort == CPU_OUT_PORT) return;

dmac.apply();
if (outCtrl.outputPort == DROP_PORT) return;

smac.apply();
}
}

// deparser section
control TopDeparser(inout Parsed_packet p, packet_out b) {
Checksum16() ck;
apply {
b.emit(p.ethernet);
if (p.ip.isValid()) {
ck.clear(); // prepare checksum unit
p.ip.hdrChecksum = 16w0; // clear checksum
ck.update(p.ip); // compute new checksum.
p.ip.hdrChecksum = ck.get();
}
b.emit(p.ip);
}
}

// Instantiate the top-level VSS package
VSS(TopParser(),
TopPipe(),
TopDeparser()) main;

P4 language definition

P4组件:

  • 核心语言,包括类型、变量、作用域、声明、语句、表达式等。
  • 子语言,基于状态机,描述parsers。
  • 子语言,基于传统的命令控制流,使用match-action units描述计算。
  • 子语言,描述体系结构。

预处理

P4编译器应该支持类似C预处理功能的以下子集:

1
2
3
4
#define for defining macros (without arguments)
#undef
#if #else #endif #ifdef #ifndef #elif
#include

词汇结构

P4区分大小写,空白字符(包括换行符)都视为标记分隔符。缩进比较随意,但是P4有类似C的块结构,例子都是使用C风格的缩进,TAB被视为空格。

词法分析器识别一下类型的terminals

  • IDENTIFIER:以字母或者下划线开头,包括字母、数字和下划线
  • TYPE:表示类型名称的标识符
  • INTEGER:整数
  • DONTCARE:单个下划线
  • 关键字(RETURN):每个关键字terminal对应具有相同拼写但是使用小写的语言关键字,比如,RETURN terminal对应return关键字

注释

P4支持几种注释:

  • 单行注释,类似C的//
  • 多行注释,类似C的/**/,不支持多行嵌套注释。
  • Javadoc样式的注释,以/**开头,*/结尾,与控制平面合成接口的action和table,强烈推荐用这种方式注释。

P4将注释视为标记分隔符,并且在标记内不允许注释,比如bi/**/t会被识别为bi和t两个标记。

常数

  • Boolean常数包括true和false
  • 整数常数,也是0x/0X表示16进制;0o/0O表示8位;0b/0B表示2进制,w表示unsigned width,32w表示32-bit unsigned;s表示signed width。32w0xff表示32-bit的整数,值为255。
  • 字符串常数,用双引号括起来,因为P4不处理字符串,只是传递给其他工具,所以不做有效性检查。

命名约定

P4提供了丰富的类型,基础类型包括bit-strings,数字和errors。还有用于表示parsers、pipelines、actions和tables等构造的内置类型。用户还可以基于以下内容构造新的类型:structures、enumerations、headers、header stack、header unions等

我们采用如下约定:

  • 内置类型使用小写字符编写,比如int<20>
  • 用户定义的类型是大写,比如IPv4Address
  • 类型变量总是大写,例如parser P<H, IH>(...)
  • 变量小写,比如ipv4header
  • 常量用大写字符,比如CPU_PORT
  • 错误和枚举用驼峰式写法,比如PacketTooShort

stateful

大多数P4结构体都是stateless:给定一些输入,他们产生的结果完全取决于输入值。只有两个带状态的结构体可以保留跨报文的信息:

  • tables,tables相对于数据平面是只读的,他们的条目可以由控制平面修改。
  • extern objects,许多objects都具有被控制平面和数据平面读写的状态。

在P4编译时,必须通过称为instantiation的过程来显示的分配所有的stateful元素。此外,parsers、control blocks和报文都可能包含stateful element instantiations。

名字解析

P4创建的对象以分层形式进行组织,所以不同的分层可能有相同的变量名称,这时候可以使用前缀.来区分,带有前缀的是top-level的变量。

1
2
3
4
5
6
7
const bit<4> x = 1;
control p() {
const bit<8> x = 8;
const bit<4> y = .x; //引用top-level x,值是1
const bit<8> z = x; 引用p本地的x,值是8
apply {}
}

P4数据类型

基础类型

内建的基础类型如下:

  • void,没有值,极少数情况下使用
  • error,用于以独立于目标的编译器管理方式传递错误
  • match_kind,描述表查找的实现,P4核心库中包含exact、ternary和lpm三种声明。
  • bool
  • bit<>,固定位宽的bit-strings
  • int<>,表示固定宽度的有符号整数,
  • varbit<>动态计算位宽的bit-strings,但是有最大宽度限制,主要是为了IPv4和TCP的option等不定长的数据提取

派生类型

  • enum,类似C的枚举。
  • header,主要用于定义协议报文头,支持IPv4和TCP的option等变长bit-strings。
  • header stacks,就是上面header的数组。
  • struct,结构体,比如可以用来存储上面的多个协议头。
  • header_union,header的union,比如IPv4和IPv6在同一个union。
  • tuple,类似结构体,因为也是多个值,只是没有命名字段。
  • type specialization,貌似是参数指定一下专有的类型。
  • extern,包括extern object和上面提到的extern function。
  • parser,至少要有一个packet_in类型,确认收到报文。
  • control,和parser类似。
  • package,参数都是编译时进行评估,必须无方向,不然就和parser类似了。

typedef

和C的typedef类似。

Expression

运算符优先级完全遵循C优先级规则。

表达式评估顺序

  • Boolean运算符&&和||仅在需要时判定第二个表达式,和C一样。
  • 条件运算符e1?e2:e3,先判断e1,然后判断e2或者e3。
  • 其他的表达式按照从左到右的方式进行评估。

Error类型的操作

Error类型只支持==和!=两种比较,结果是Boolean值。

例如发生错误的时候,判定代码如下:

1
2
3
error errorFromParser;
...
if (errorFromParser != error.NoError) { ... }

enum类型的操作

enum类型也是只支持==和!=,结果是Boolean值。

1
2
3
enum X { v1, v2, v3 }
X.v1 // reference to v1
v1 // error - v1 is not in the top-level namespace

Booleans的操作

操作符&&、||、!、==和!=都和C语言一样。

但是注意bit-strings和Booleans不能隐式转换,这个和C不一样,比如C语言中的if (x)可以进行判定,但是P4语言中,必须用if (x != 0)

同样的表达式e1?e2:e3其他的都和C类似,但是e1必须是Boolean类型。

无符号数bit类型的操作

除了移位,其他所有的操作都要求两个数字位宽和类型都一致,不同时会出错。

bit-string支持操作如下:

  • ==测试相等性,结果为Boolean值。
  • !=测试不等式,结果为Boolean值。
  • <, >, <=, >=,无符号数比较,位宽必须相同,结果为Boolean值。
  • +, -, +, -, &, |, ~, ^, <<, >>等操作也都和C一致,注意前面指正负,后面指加减。
  • 乘号基本和C类似,只是有个限制,只可以乘以2的幂。
  • 两个bit-string拼接,由++表示。
  • slice,类似于python,使用[m:l]表示。

固定位宽的有符号数

同上,除移位,不支持不同位宽或者类型(符号)的操作。

int支持操作如下:

  • ==测试相等性,结果为Boolean值。
  • !=测试不等式,结果为Boolean值。
  • <, >, <=, >=,无符号数比较,位宽必须相同,结果为Boolean值。
  • +, -, +, -, <<, >>等操作也都和C一致,注意前面指正负,后面指加减。
  • 乘号基本和C类似,只是有个限制,只可以乘以2的幂。
1
2
3
4
bit<8> x;
bit<16> y;
... y << x ...
... y << 1024 ...

注意一下这个例子,P4和C的不同之处在于因为P4给出了位宽的概念,所以会检查的更严格一些,比如16bit的数据位移的时候不能设置超过4bit的非常数值。

任意精度整数的操作

int表示任意精度的整数。支持操作如下:

  • ==测试相等性,结果为Boolean值。
  • !=测试不等式,结果为Boolean值。
  • <, >, <=, >=,无符号数比较,位宽必须相同,结果为Boolean值。
  • +, -, +, -, *, /, %, <<, >>等操作也都和C一致,注意前面指正负,后面指加减。

变长bit类型操作

为了支持报文的头的可变长度,P4提供了一个varbit类型,定义的时候需要声明一个最大位宽以及一个动态位宽,不能超过静态边界。

可变长bit-string支持的操作:

  • Parser通过使用两个参数提取packet_in ectern object的method,利用此method提取可变长度的bit-string,此操作设置字段的动态宽度。
  • 分配另一个可变长度的bit-string,具有和源相同的静态宽度,赋值时将源的动态宽度传递给目标。
  • packet_out extern object的emit method,将具有已知动态宽度的可变长度bit-string插入到正在构造的报文中。

类型转换

P4提供了有限的类型转换,分为显示转换和隐式转换。

显示转换支持如下:

  • bit<1> <-> bool,0转换为false,1转换为true。
  • int<W> -> bit<W>,所有位保持不变,将负值解释为正值。
  • bit<w> -> int<W>,所有位保持不变,将最高位的1解释为负值。
  • bit<W> -> bit<X>W > X则截断,否则补0。
  • int<W> -> int<X>W > X则截断,否则用符号位扩充。
  • int -> bit<W>,将整数值转换为足够大的二进制补码bit-string以避免信息丢失,然后截断为W位。溢出或者转换为负值时,编译器会发出警告。
  • int -> int<W>,将整数值转换为足够大的二进制补码bit-string以避免信息丢失,然后截断为W位,溢出时,编译器会发出警告。
  • typedef引入的两种类型的强制转换,等同上述之一。

隐式的转换:

P4仅隐式的支持从int类型转换到固定位宽的类型。

1
2
3
bit<8>  x;
bit<16> y;
int<8> z;
  • x + 1转换为x + (bit<8>)1
  • z < 0转换为z < (int<8>0)
  • x << 13转换为0,并且警告溢出。
  • x | 0xFFF转换为x | (bit<8>0xFFF),警告溢出。

最后需要注意在P4中好多算术表达式都是非法的,我们举个例子

1
2
3
bit<8>  x;
bit<16> y;
int<8> z;
  • x + y因为不同的位宽,所以不对,以下是推荐的写法
    • (bit<16>)x + y
    • x + (bit<8>)y
  • x + z因为不同的类型,所以不对,以下是推荐的写法
    • (int<8>)x + z
    • x + (bit<8>)z
  • (int<8>)y不能同时修改类型和位宽,所以不对,以下是推荐的写法
    • (int<8>)(bit<8>)y
    • (int<8>)(int<16>)y
  • y + z因为不同的位宽和类型,所以不对,以下是推荐的写法
    • (int<8>)(bit<8>)y + z
    • y + (bit<16>)(bit<8>)z
    • (bit<8>)y + (bit<8>)z
    • (bit<8>)y + (bit<8>)z
  • x << z因为移位不能使有符号数,所以不对,以下是推荐的写法
    • x << (bit<8>)z
  • x < z因为不同的类型,所以不对,以下是推荐的写法
    • x < (bit<8>)z
    • (int<8>)x < z
  • 1 << x因为1的位宽不确定,所以不对,以下是推荐的写法
    • 32w1 << x
  • ~1int不支持该操作,所以不对,以下是推荐的写法
    • ~32w1
  • 5 & -3int类型不支持该操作,所以不对,以下是推荐的写法
    • 32w5 & -3

tuples表达式的操作

tuple<bit<32>, bool> x = { 10, false }

list表达式的操作

list表达式可以分配给类型tuple、struct或者header的表达式,也可以作为参数传递给method。

1
2
3
4
5
6
extern LearningProvider {
void learn<T>(in T data);
}
LearningProvider() lp;

lp.learn( { hdr.ethernet.srcAddr, hdr.ipv4.src } );

上述的例子是list表达式传递几个header字段给learn程序。

1
2
3
4
5
struct S {
bit<32> a;
bit<32> b;
}
const S x = { 10, 20 }; //a = 10, b = 20

如果list与结构体中有相同数量的元素,那么可以使用list初始化结构体。这种初始化程序的作用是将列表的第i个元素分配给结构体重的第i个字段。

1
tuple<bit<32>, bool> x = { 10, false };

list表达式还可以用于初始化类型为tuple的变量。

sets操作

一些P4表达式需要表示一些数据的集合(T类型,set)。这些表达式只能出现在几个上下文(parser和常量表)例如select表达式:

1
2
3
4
5
select (expression) {
set1: state1;
set2: state2;
...
}

单集合

1
2
3
select (hdr.ipv4.version) {
4: continue;
}

通用set

default或者_表示通用set

1
2
3
4
select (hdr.ipv4.version) {
4: continue;
_: reject;
}

Masks

&&&是带有两个bit<W>类型的操作,创建一个set<bit<W>>的值,右面的值表示掩码。

8w0x0A &&& 8w0x0F表示8-bit数的掩码0x0F。

Ranges

..是带有两个bit<W>或者int<W>类型的操作,创建一个set<T>的值。

4w5 .. 4w8表示4-bit的5 6 7 8。

Products

1
2
3
4
5
select(hdr.ipv4.ihl, hdr.ipv4.protocol) {
(4w0x5, 8w0x1): parse_icmp;
(4w0x5, 8w0x6): parse_tcp;
(4w0x5, 8w0x11): parse_udp;
(_, _): accept; }

struct类型操作

没啥要说的,主要就是取结构体成员用.,这个和C类似。

headers操作

headers除了提供和struct相同的操作外,还支持以下几种methods:

  • isValid()返回header的”validity”标志位
  • setValid()将header的”validity”标志位设置为”true”
  • setInvalid()将header的”validity”标志位设置为”false”

在无效的header中,读写字段的结果是未定义的。即使header有效,读取未初始化的header字段的结果也是无效的。

可以使用list表达式初始化header object,类似于struct-list字段按照他们出现的顺序分配给header字段。这种情况下,header自动变为有效

1
2
3
header H { bit<32> x; bit<32> y; }
H h;
h = { 10, 12 }; // This also makes the header h valid

header statcks的操作

header stack是有固定长度的数组的headers。header stack的有效元素不必是连续的。P4提供了一组用于操作header stack的计算。h[n]类型的header stack可以用如下伪代码来理解:

1
2
3
4
5
6
7
8
9
10
11
// type declaration
struct hs_t {
bit<32> nextIndex;
bit<32> size;
h[n] data; // Ordinary array
}

// instance declaration and initialization
hs_t hs;
hs.nextIndex = 0;
hs.size = n;

header stack可以看做是包含普通的header数组hs和计数器nextIndex。给定大小为n的header stack,以下表达式是合法的:

  • hs[index]:指定stack中的位置。
  • hs.size:32位无符号整数,返回header stack的大小(编译时常量)。
  • 从header stack hs到另一个stack的分配要求stack具有相同的类型和大小。复制hs的所有组件,包括元素及其有效位,以及nextIndex。

为了帮助程序员为header stack写入parser,P4还提供了在解析元素时自动进入stack的计算:

  • hs.next:引用hs.nextIndex元素。可能只在parser中使用。如果nextIndex大于或者等于size,则计算表达式会导致出问题。
  • hs.last:如果存在这样的元素,那么是引用的hs.nextIndex - 1的元素。可能只在parser中使用。如果nextIndex小于1或者大于size,则会出错。
  • hs.lastIndex:32位无符号整数,是hs.nextIndex - 1的值,如果nextIndex的值为0,那么次表达式将生成未定义的值。

最后P4提供了以下计算,可用于操作堆栈前后端的元素:

  • hs.push_front(int count):按count向右移动hs。第一个count元素无效,stack中最后一个count元素丢弃。hs.nextIndex按照count递增。count必须是正整数,编译时的已知值,返回类型void。
  • hs.pop_front(int count):按count向左移动hs。最后的count元素无效。hs.nextIndex按count递减。count必须是正整数,编译时的已知值,返回类型void。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void push_front(int count) {
for (int i = this.size-1; i >= 0; i -= 1) {
if (i >= count) {
this[i] = this[i-count];
} else {
this[i].setInvalid();
}
}
this.nextIndex = this.nextIndex + count;
if (this.nextIndex > this.size) this.nextIndex = this.size;
// Note: this.last, this.next, and this.lastIndex adjust with this.nextIndex
}

void pop_front(int count) {
for (int i = 0; i < this.size; i++) {
if (i+count < this.size) {
this[i] = this[i+count];
} else {
this[i].setInvalid();
}
}
if (this.nextIndex >= count) {
this.nextIndex = this.nextIndex - count;
} else {
this.nextIndex = 0;
}
// Note: this.last, this.next, and this.lastIndex adjust with this.nextIndex
}

header unions

1
2
3
4
5
6
7
8
9
10
11
12
header H1 {
bit<8> f;
}
header H2 {
bit<16> g;
}
header_union U {
H1 h1;
H2 h2;
}

U u; // u invalid

union不能初始化,但是可以通过其中一个元素来更新union的有效性。以下提供三种方式

1
2
3
4
5
6
7
8
9
10
U u;
H1 my_h1 = { 8w0 }; // my_h1 is valid
u.h1 = my_h1; // u and u.h1 are both valid

U u;
u.h2 = { 16w1 }; // u and u.h2 are both valid

U u;
u.h1.setValid(); // u and u.h1 are both valid
H1 my_h1 = u.h1; // my_h1 is now valid, but contains an undefined value

需要注意的是,读取未初始化的header会产生未定义的值,即使header本身是有效的。
如果是表达式,其类型是一个header union U,字段超过hi,那么以下操作可用于操作u:

  • u.hi.setValid():将header hi的有效位设置为true,并将所有其他header的有效位设置为false,这意味着读取这些header将返回未指定的值。
  • u.hi.serInvalid():如果u的任何成员header的有效位设置为true,则将其设置为false,这意味着读取u的任何成员header将返回未指定的值。

使用header union统一操作IPv4和IPv6

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
header_union IP {
IPv4 ipv4;
IPv6 ipv6;
}

struct Parsed_packet {
Ethernet ethernet;
IP ip;
}

parser top(packet_in b, out Parsed_packet p) {
state start {
b.extract(p.ethernet);
transition select(p.ethernet.etherType) {
16w0x0800 : parse_ipv4;
16w0x86DD : parse_ipv6;
}
}
state parse_ipv4 {
b.extract(p.ip.ipv4);
transition accept;
}
state parse_ipv6 {
b.extract(p.ip.ipv6);
transition accept;
}
}

header union操作TCP options的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
header Tcp_option_end_h {
bit<8> kind;
}
header Tcp_option_nop_h {
bit<8> kind;
}
header Tcp_option_ss_h {
bit<8> kind;
bit<32> maxSegmentSize;
}
header Tcp_option_s_h {
bit<8> kind;
bit<24> scale;
}
header Tcp_option_sack_h {
bit<8> kind;
bit<8> length;
varbit<256> sack;
}
header_union Tcp_option_h {
Tcp_option_end_h end;
Tcp_option_nop_h nop;
Tcp_option_ss_h ss;
Tcp_option_s_h s;
Tcp_option_sack_h sack;
}

typedef Tcp_option_h[10] Tcp_option_stack;

struct Tcp_option_sack_top {
bit<8> kind;
bit<8> length;
}

parser Tcp_option_parser(packet_in b, out Tcp_option_stack vec) {
state start {
transition select(b.lookahead<bit<8>>()) {
8w0x0 : parse_tcp_option_end;
8w0x1 : parse_tcp_option_nop;
8w0x2 : parse_tcp_option_ss;
8w0x3 : parse_tcp_option_s;
8w0x5 : parse_tcp_option_sack;
}
}
state parse_tcp_option_end {
b.extract(vec.next.end);
transition accept;
}
state parse_tcp_option_nop {
b.extract(vec.next.nop);
transition start;
}
state parse_tcp_option_ss {
b.extract(vec.next.ss);
transition start;
}
state parse_tcp_option_s {
b.extract(vec.next.s);
transition start;
}
state parse_tcp_option_sack {
bit<32> n = (bit<32>)b.lookahead<Tcp_option_sack_top>().length;
b.extract(vec.next.sack, n);
transition start;
}
}

Constructor

P4构造在编译时分配的资源:

  • extern objects
  • parsers
  • control block
  • packages

可以通过两种方式执行分配:

  • 调用构造函数
  • 使用实例化

构造函数调用的例子

1
2
3
4
5
6
7
xtern ActionProfile {
ActionProfile(bit<32> size); // constructor
}
table tbl {
actions = { ... }
implementation = ActionProfile(1024); // constructor invocation
}

常量和变量的定义

常量

1
2
3
4
5
6
const bit<32> COUNTER = 32w0x0;
struct Version {
bit<32> major;
bit<32> minor;
}
const Version version = { 32w0, 32w0 };

变量

变量声明位置:

  • block语句中
  • parser state
  • action body
  • control block apply block
  • parser中的本地声明list中
  • control中的本地声明list中

实例化

上面提到分配资源可以通过调用构造函数或者实例化,现在我们看看如何实例化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// from target library
enum CounterType {
Packets,
Bytes,
Both
}
extern Counter {
Counter(bit<32> size, CounterType type);
void increment(in bit<32> index);
}
// user program
control c(...) {
Counter(32w1024, CounterType.Both) ctr; // instantiation
apply { ... }
}

statements

P4中的每个statement都必须以分号结尾。statement的位置:

  • Within parser states,不支持条件语句
  • Within a control block,switch语句仅仅在这里支持
  • Within an action

目前支持的statements有:

  • assignment statement,使用=号赋值
  • empty statement
  • block statement
  • return statement
  • exit statement
  • conditional statement
  • switch statement

Packet parsing

Parser states

avatar

P4 parser描述了具有一个start和两个final状态的状态机。起始状态命名为start,结束状态包括accept和reject。

Parser声明

Parser声明包括名称、参数列表和可选项,包括构造函数参数、本地元素和parser状态。

与parser type声明不同的是,parser声明不能使通用的,以下声明是非法的:

1
parser P<H>(inout H data) { ... }

Parser abstract machine

P4 parser的语义可以根据操作ParserModel数据结构的abstract machine来制定。Parser在start状态下开始执行,并且达到其中一个reject或者accept状态时结束。

1
2
3
4
5
6
7
ParserModel {
error parseError;
onPacketArrival(packet p) {
ParserModel.parseError = error.NoError;
goto start;
}
}

架构必须指定执行到accept或者reject状态时的行为。比如,架构可以指定丢弃所有执行到reject状态的报文。

Parser states

每个state都有一个name和body,body包括一些列的语句,描述了parser转换到该状态时执行的处理,包括:

  • 局部变量声明
  • assignment statements
  • Method calls,多种用途:
    • Invoking functions(例如,使用verify检查已经解析的数据的有效性)
    • Invoking methods(例如,从报文或者checksum中提取数据)和其他parser
  • 过渡到其他的状态

Transition statements

parser state的最后一个statement是可选的transition statement,他将control转换到另一个状态,可能是accept或者reject。

1
2
3
transition accept;

transition reject;

select表达式

1
2
3
4
5
6
7
select(e) {
ks[0]: s[0];
ks[1]: s[1];
...
ks[n-2]: s[n-1];
_ : sd; // ks[n-1] is default
}

上述代码转换为伪代码如下:

1
2
3
4
5
6
7
8
key = eval(e);
for (int i=0; i < n; i++) {
keyset = eval(ks[i]);
if (keyset.contains(key)) {
return s[i];
}
}
verify(false, error.NoMatch);

1
2
3
4
5
6
7
8
header IPv4_h { ... bit<8> protocol; ... }
struct P { ... IPv4_h ipv4; ... }
P headers;
select (headers.ipv4.protocol) {
8w6 : parse_tcp;
8w17 : parse_udp;
_ : accept;
}

verify

verify提供了简单的错误处理形式,只能在parser中调用。

1
2
3
4
5
6
7
8
extern void verify(in bool condition, in error err);

ParserModel.verify(bool condition, error err) {
if (condition == false) {
ParserModel.parserError = err;
goto reject;
}
}

如果第一个参数为true,则没毛病,如果是false,则立即跳转到reject。

Data extraction

P4核心库包含内置的extern类型,称为packet_in,表示传入的报文。packet_in extern是特殊的:他不能由用户显式的实例化。

1
2
3
4
5
6
7
extern packet_in {
void extract<T>(out T headerLvalue);
void extract<T>(out T variableSizeHeader, in bit<32> varFieldSizeBits);
T lookahead<T>();
bit<32> length(); // This method may be unavailable in some architectures
void advance(bit<32> bits);
}

parser通过packet_in类型的参数b,从报文中提取数据。extract有两种变体,用户提取固定长度的header的单参数和提取可变长度的header的双参数。由于这些操作可能导致运行时验证失败,因为这些method只能在parser中执行。

当数据提取为bit-string或者int时,报文的第一个bit提取到整数的最高有效位。

1
2
3
4
5
6
7
8
9
10
11
packet_in {
unsigned nextBitIndex;
byte[] data;
unsigned lengthInBits;
void initialize(byte[] data) {
this.data = data;
this.nextBitIndex = 0;
this.lengthInBits = data.sizeInBytes * 8;
}
bit<32> length() { return this.lengthInBits / 8; }
}

固定长度的数据提取

1
void extract<T>(out T headerLeftValue);

表达式headerLeftValue要求必须是固定长度的header类型。如果method执行成功,则在完成时,headerLeftValue将填充来自报文的数据,并且将有效位设置为true。该method可能以各种方式失败,比如报文中没有足够的空间来填充指定的报文header。

以下的列子是提取Ethernet header

1
2
3
4
5
6
struct Result { ... Ethernet_h ethernet; ... }
parser P(packet_in b, out Result r) {
state start {
b.extract(r.ethernet);
}
}

上面extract的实现伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
void packet_in.extract<T>(out T headerLValue) {
bitsToExtract = sizeofInBits(headerLValue);
lastBitNeeded = this.nextBitIndex + bitsToExtract;
ParserModel.verify(this.lengthInBits >= lastBitNeeded, error.PacketTooShort);
headerLValue = this.data.extractBits(this.nextBitIndex, bitsToExtract);
headerLValue.valid$ = true;
if headerLValue.isNext$ {
verify(headerLValue.nextIndex$ < headerLValue.size, error.StackOutOfBounds);
headerLValue.nextIndex$ = headerLValue.nextIndex$ + 1;
}
this.nextBitIndex += bitsToExtract;
}

可变长度的数据提取

1
void extract<T>(out T headerLvalue, in bit<32> variableFieldSize);

variableFieldSize表示要提取的可变长度部分的大小。实现的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void packet_in.extract<T>(out T headerLvalue,
in bit<32> variableFieldSize) {
bitsToExtract = sizeOfFixedPart(headerLvalue) + variableFieldSize;
lastBitNeeded = this.nextBitIndex + bitsToExtract;
ParserModel.verify(this.lengthInBits >= lastBitNeeded, error.PacketTooShort);
ParserModel.verify(bitsToExtract <= headerLvalue.maxSize, error.HeaderTooShort);
headerLvalue = this.data.extractBits(this.nextBitIndex, bitsToExtract);
headerLvalue.varbitField.size = variableFieldSize;
headerLvalue.valid$ = true;
if headerLValue.isNext$ {
verify(headerLValue.nextIndex$ < headerLValue.size, error.StackOutOfBounds);
headerLValue.nextIndex$ = headerLValue.nextIndex$ + 1;
}
this.nextBitIndex += bitsToExtract;
}

下面的例子展示了解析IPv4的option部分,通过把IPv4的header分割为两部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
// IPv4 header without options
header IPv4_no_options_h {
bit<4> version;
bit<4> ihl;
bit<8> diffserv;
bit<16> totalLen;
bit<16> identification;
bit<3> flags;
bit<13> fragOffset;
bit<8> ttl;
bit<8> protocol;
bit<16> hdrChecksum;
bit<32> srcAddr;
bit<32> dstAddr;
}
header IPv4_options_h {
varbit<320> options;
}

struct Parsed_headers {
...
IPv4_no_options_h ipv4;
IPv4_options_h ipv4options;
}

error { InvalidIPv4Header }

parser Top(packet_in b, out Parsed_headers headers) {
...
state parse_ipv4 {
b.extract(headers.ipv4);
verify(headers.ipv4.ihl >= 5, error.InvalidIPv4Header);
transition select (headers.ipv4.ihl) {
5: dispatch_on_protocol;
_: parse_ipv4_options;
}

state parse_ipv4_options {
// use information in the ipv4 header to compute the number
// of bits to extract
b.extract(headers.ipv4options,
(bit<32>)(((bit<16>)headers.ipv4.ihl - 5) * 32));
transition dispatch_on_protocol;
}
}

Lookahead

lookahead mthod提前检测以下长度够不够,不够直接报错。

1
b.lookahead<T>()
1
2
3
4
5
6
7
T packet_in.lookahead<T>() {
bitsToExtract = sizeof(T);
lastBitNeeded = this.nextBitIndex + bitsToExtract;
ParserModel.verify(this.lengthInBits >= lastBitNeeded, error.PacketTooShort);
T tmp = this.data.extractBits(this.nextBitIndex, bitsToExtract);
return tmp;
}

最后来一个TCP options的例子展示lookahead咋用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
state start {
transition select(b.lookahead<bit<8>>()) {
0: parse_tcp_option_end;
1: parse_tcp_option_nop;
2: parse_tcp_option_ss;
3: parse_tcp_option_s;
5: parse_tcp_option_sack;
}
}
...
state parse_tcp_option_sack {
bit<32> n = (bit<32>)b.lookahead<Tcp_option_sack_top>().length;
b.extract(vec.next.sack, n);
transition start;
}

Skipping bits

P4提供了两种方法来跳过提取的一部分,一种是extract明确指定类型,_来表示跳过;另一种是给报文添加advance。两种方式的展示如下:

1
2
3
4
5
6
7
b.extract<T>(_)

void packet_in.advance(bit<32> bits) {
lastBitNeeded = this.nextBitIndex + bits;
ParserModel.verify(this.lengthInBits >= lastBitNeeded, error.PacketTooShort);
this.nextBitIndex += bits;
}

Header stacks

header stacks有两个属性,next和last。来个10个MPLS header的例子:

1
2
3
4
5
6
7
header Mpls_h {
bit<20> label;
bit<3> tc;
bit bos;
bit<8> ttl;
}
Mpls_h[10] mpls;

mpls.next表示Mpls_h的值,最初表示stack中的第一个元素,每次成功调用时,自动提前提取,mpls.last指向next之前的元素。当stack中的nextIndex大于或者等于size时,访问mpls.next元素会error.StackOutOfBounds,当nextIndex等于0的时候,尝试访问mpls.last会报错error.StackOutOfBounds。

以下展示了用于MPLS处理的简化parser:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct Pkthdr {
Ethernet_h ethernet;
Mpls_h[3] mpls;
// other headers omitted
}

parser P(packet_in b, out Pkthdr p) {
state start {
b.extract(p.ethernet);
transition select(p.ethernet.etherType) {
0x8847: parse_mpls;
0x0800: parse_ipv4;
}
}
state parse_mpls {
b.extract(p.mpls.next);
transition select(p.mpls.last.bos) {
0: parse_mpls; // This creates a loop
1: parse_ipv4;
}
}
// other states omitted
}

Sub-parsers

P4允许parser调用其他parser的服务、要调用另一个parser的服务,必须首先实例化子parser,通过apply method调用实例化的服务来调用它。

以下示例显示一个子parser调用:

1
2
3
4
5
6
7
parser callee(packet_in packet, out IPv4 ipv4) { ...}
parser caller(packet_in packet, out Headers h) {
callee() subparser; // instance of callee
state subroutine {
subparser.apply(packet, h.ipv4); // invoke sub-parser
}
}

子parser调用的语义可以描述如下:

  • 调用子parser的状态在parser调用语句中被分成两个half-states
  • 上半部分包括到子parser start状态的转换
  • 子parser的accept状态用当前状态的下半部分标识
  • 子parser的reject状态用当前parser的reject状态标识

avatar

Control blocks

P4 parser负责将报文中的数据提取到header中。 control blocks操纵和转换这些header(和其他meatadata)。 在control block的body内,可以调用match-action units来执行数据转换。

Actions

avatar

1
2
3
action Forward_a(out bit<9> outputPort, bit<9> port) {
outputPort = port;
}

actions中不能有switch语句,语法允许但是语义应该拒绝。

actions可以通过两种方式执行:

  • 隐含的:通过tables处理match-action
  • 明确的:来自control block或者别的action

Tables

avatar

table就是一个match-action unit,主要就是记得额外有个default_action,匹配不到任何表的时候需要执行的操作。

Keys

1
2
3
4
5
6
7
table Fwd {
key = {
ipv4header.dstAddress : ternary;
ipv4header.version : exact;
}
...
}

match_kind主要用于确认匹配算法,核心库支持三种。

1
2
3
4
5
match_kind {
exact,
ternary,
lpm
}

Actions

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
action Drop_action() {
outCtrl.outputPort = DROP_PORT;
}

action Rewrite_smac(EthernetAddress sourceMac) {
headers.ethernet.srcAddr = sourceMac;
}

table smac {
key = { outCtrl.outputPort : exact; }
actions = {
Drop_action;
Rewrite_smac;
}
}

action当中的参数必须要指定方向

1
2
3
4
5
6
7
8
9
10
11
12
action a(in bit<32> x) { ...}
bit<32> z;
action b(inout bit<32> x, bit<8> data) { ...}
table t {
actions = {
// a; -- illegal, x parameter must be bound
a(5); // binding a's parameter x to 5
b(z); // binding b's parameter x to z
// b(z, 3); -- illegal, cannot bind directionless data parameter
// b(); -- illegal, x parameter must be bound
}
}

Default_action

1
2
3
4
5
default_action = a(5); // OK - no control-plane parameters
// default_action = a(z); -- illegal, a's x parameter is already bound to 5
default_action = b(z,8w8); // OK - bind b's data parameter to 8w8
// default_action = b(z); -- illegal, b's data parameter is not bound
// default_action = b(x, 3); -- illegal: x parameter of b bound to x instead of z

如果没有设置default_action,并且没有table可以匹配,也不会影响报文的流程,会根据程序的control flow继续处理。

Entries

Table entries通常有控制平面去控制,但是也可以编译时预置一些,用于固定算法的。table entries是const类型,只能被控制平面读取,不能更改和删除,以后的版本可能会调整一部分可以更改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
header hdr {
bit<8> e;
bit<16> t;
bit<8> l;
bit<8> r;
bit<1> v;
}

struct Header_t {
hdr h;
}
struct Meta_t {}

control ingress(inout Header_t h, inout Meta_t m,
inout standard_metadata_t standard_meta) {

action a() { standard_meta.egress_spec = 0; }
action a_with_control_params(bit<9> x) { standard_meta.egress_spec = x; }

table t_exact_ternary {

key = {
h.h.e : exact;
h.h.t : ternary;
}

actions = {
a;
a_with_control_params;
}

default_action = a;

const entries = {
(0x01, 0x1111 &&& 0xF ) : a_with_control_params(1);
(0x02, 0x1181 ) : a_with_control_params(2);
(0x03, 0x1111 &&& 0xF000) : a_with_control_params(3);
(0x04, 0x1211 &&& 0x02F0) : a_with_control_params(4);
(0x04, 0x1311 &&& 0x02F0) : a_with_control_params(5);
(0x06, _ ) : a_with_control_params(6);
}
}

}

Additional properties

1
2
3
4
5
6
7
8
extern ActionProfile {
ActionProfile(bit<32> size); // number of distinct actions expected
}
table t {
key = { ...}
size = 1024;
implementation = ActionProfile(32); // constructor invocation
}

没懂这块,说是优化表的

Match-action unit 调用

通过调用table的apply来调用table,实例会返回带有两个字段的结构体。这个由编译器来完成,对于每个table T,合成类似如下的结构,伪P4代码如下:

1
2
3
4
5
6
7
enum action_list(T) {
// one field for each action in the actions list of table T
}
struct apply_result(T) {
bool hit;
action_list(T) action_run;
}

如果在lookup-table时找到了匹配的table,apply的method会将返回值的hit设置为true。

1
2
3
4
5
if (ipv4_match.apply().hit) {
// there was a hit
} else {
// there was a miss
}

action_run字段则指定了执行哪种action,无论是否匹配成功都可以使用。

1
2
3
switch (dmac.apply().action_run) {
Drop_action: { return; }
}

Match-action unit执行语义

1
m.apply();

对应的伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
apply_result(m) m.apply() {
apply_result(m) result;

var lookupKey = m.buildKey(m.key); // using key block
action RA = m.table.lookup(lookupKey);
if (RA == null) { // miss in lookup table
result.hit = false;
RA = m.default_action; // use default action
}
else {
result.hit = true;
}
result.action_run = action_type(RA);
evaluate_and_copy_in_RA_args(RA);
execute(RA);
copy_out_RA_args(RA);
return result;
}

参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
parser GenericParser(packet_in b, out Packet_header p)
(bool udpSupport) { // constructor parameters
state start {
b.extract(p.ethernet);
transition select(p.ethernet.etherType) {
16w0x0800: ipv4;
}
}
state ipv4 {
b.extract(p.ipv4);
transition select(p.ipv4.protocol) {
6: tcp;
17: tryudp;
}
}
state tryudp {
transition select(udpSupport) {
false: accept;
true : udp;
}
}
state udp {
...
}
}

// topParser is a GenericParser where udpSupport = false
GenericParser(false) topParser;

一个parser可以声明两组参数:运行时参数和parser可选的构造参数。

构造参数必须是无方向的,并且在编译时就能得到该参数的值。

直接类型调用

control和parser通常都只实例化一次,但是为了简化通常可以直接apply没有构造参数的control和parser,所以以下两种表达相同:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
control Callee( ... ) { ... }

control Caller( ... )( ... ) {
apply {
Callee.apply( ... ); // callee is treated as an instance
}
}


control Caller( ... )( ... ) {
@name("Callee") Callee() Callee_inst; // local instance of Callee
apply {
Callee_inst.apply( ... ); // Callee_inst is applied
}
}

需要注意的是不同作用域的直接类型调用会产生不同的本地实例化;而同一范围内的直接类型调用将在每次调用的时候生成不同的本地实例化,但是相同类型的实例将通过@name注释共享相同的全局名称。

Deparsing

1
2
3
4
5
6
control TopDeparser(inout Parsed_packet p, packet_out b) {
apply {
b.emit(p.ethernet);
b.emit(p.ip);
}
}

将数据插入报文

1
2
3
extern packet_out {
void emit<T>(in T data);
}

emit method支持header、header stack、struct、或者header union添加到报文中

  • header时会检测,有效的话添加,无效的话不做任何事,类似于no-op
  • header stack就是递归的调用上面header
  • struct和header union会递归的调用每个元素

相应的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
packet_out {
byte[] data;
unsigned lengthInBits;
void initializeForWriting() {
this.data.clear();
this.lengthInBits = 0;
}
/// Append data to the packet. Type T must be a header, header
/// stack, header union, or struct formed recursively from those types
void emit<T>(T data) {
if (isHeader(T))
if(data.valid$) {
this.data.append(data);
this.lengthInBits += data.lengthInBits;
}
else if (isHeaderStack(T))
for (e : data)
emit(e);
else if (isHeaderUnion(T) || isStruct(T))
for (f : data.fields$)
emit(e.f)
// Other cases for T are illegal
}

架构描述

体系结构描述文件

avatar

以下例子使用两个package来描述交换机,每个packege都包含parser、match-action pipeline、deparser。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
parser Parser<IH>(packet_in b, out IH parsedHeaders);
// ingress match-action pipeline
control IPipe<T, IH, OH>(in IH inputHeaders,
in InControl inCtrl,
out OH outputHeaders,
out T toEgress,
out OutControl outCtrl);
// egress match-action pipeline
control EPipe<T, IH, OH>(in IH inputHeaders,
in InControl inCtrl,
in T fromIngress,
out OH outputHeaders,
out OutControl outCtrl);

control Deparser<OH>(in OH outputHeaders, packet_out b);
package Ingress<T, IH, OH>(Parser<IH> p,
IPipe<_, IH, OH> map,
Deparser<OH> d);
package Egress<T, IH, OH>(Parser<IH> p, Port
EPipe<_, IH, OH> map,
Deparser<OH> d);
package Switch<T>( // Top-level switch contains two packages
// type types Ingress.IH and Egress.IH may be different
Ingress<T, _, _> ingress,
Egress<T, _, _> egress
);

从以上我们可以看出以下几点:

  • 该交换机包含两个独立的package:Ingress和Egress
  • Ingress中的Parser、IPipe、Deparser顺序连接在一起,Ingress.IPipe的输入参数是Ingress.IH,它正好是Ingress.Parser的输出参数。
  • 同样Egress中这三个也是顺序连接在一起。
  • Ingress.IPipe连接到Egress.IPipe,因为前者的T是输出参数,而后者是输入参数。
  • 需要注意的是,Ingress中的IH和Egress中的是不同的,如果需要相同的话,需要在Switch中加入IH和OH参数。

请注意,该交换机的ingress和egress pipeline之间包含两条独立的通道:

  • 一个是通过参数T来传递数据。
  • 另一个是parser和deparser之间间接的传递数据,并且把数据序列化为报文并返回。

体系结构程序

类似其他的程序一样,我们也需要一个main来开始程序

举个栗子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
parser Prs<T>(packet_in b, out T result);
control Pipe<T>(in T data);
package Switch<T>(Prs<T> p, Pipe<T> map);

parser P(packet_in b, out bit<32> index) { ... }
control Pipe1(in bit<32> data) { ... }
control Pipe2(in bit<8> data) { ... }

Switch(P(), Pipe1()) main;

//这个不对,因为P传出的参数是32bit,而Pipe2需要8bit,所以需要做指定一下类型变量
Switch(P(), Pipe2()) main;

Switch<bit<32>>(P(), Pipe1()) main;

报文过滤模型

此模型可用于编程在Linux内核中运行的数据包筛选器。 例如,我们可以用更强大的P4语言替换TCP dump语言; P4可以无缝支持新协议。P4编译器可以生成eBPF程序,该程序由TCP dump程序注入Linux内核,并由EBPF内核JIT compiler/runtime执行。

以下是架构声明:

1
2
3
4
parser Parser<H>(packet_in packet, out H headers);
control Filter<H>(inout H headers, out bool accept);

package Program<H>(Parser<H> p, Filter<H> f);

P4 abstract machine:评估

P4程序的评估分为两个部分:

  • 静态评估,编译时分析和实例化所有stateful blocks。
  • 动态评估,每个P4功能块从上层接收控制信息时会以原子方式运行。

编译时已知的值

以下时编译时得知的值:

  • integer,Boolean,string。
  • error,enum,match_kind声明的标识符。
  • 默认标识符。
  • header stack中带有size字段的大小。
  • select中_标识符。
  • 表示declared types,actions,tables,parsers,controls,packages的标识符。
  • list表达式,都是编译时已知所有值。
  • 实例声明或者由构造函数调用产生的实例
  • +, -, *, / , %, cast, !, &, |, &&, ||, << , >> , ~ , >, <, ==, !=, <=, >=, ++, [:]
  • const声明为常量的标识符

编译时的评估

程序的评估按照声明的顺序进行,首先从top-level namespace开始:

  • 所有声明(例如,parsers,controls,types,constants)都会自行评估。
  • 每个table都评估为一个table实例
  • 构造函数的调用评估为相应类型的stateful object。所有构造函数的参数都是以递归的方式评估到构造函数参数。所有的构造函数参数都必须是编译时的已知值。
  • 实例评估为named stateful objects
  • parser或者control block的实例递归的评估block中声明的所有有状态实例。
  • 程序评估结果就是top-level main的变量。

需要注意的就是所有带状态的值都要在编译时就要实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// architecture declaration
parser P(...);
control C(...);
control D(...);

package Switch(P prs, C ctrl, D dep);

extern Checksum16 { ...}

// user code
Checksum16() ck16; // checksum unit instance

parser TopParser(...)(Checksum16 unit) { ...}
control Pipe(...) { ...}
control TopDeparser(...)(Checksum16 unit) { ...}

Switch(TopParser(ck16),
Pipe(),
TopDeparser(ck16)) main;

这段代码的评估如下:

  • P,C,D,Switch,Checksum16的声明都评估为自己
  • Checksum16() ck16实例进行求值,并且生成一个名为ck16的对象,类型为Checksum16
  • TopParser,Pipe,TopDeparser的声明都评估为自己
  • 评估main的变量实例
    • 构造函数的参数是递归评估的
    • TopParser(ck16)是一个构造函数的调用
    • 他的参数是递归评估的,评估为ck16对象
    • 对构造函数本身进行评估,从而实现TopParser类型对象的实例化
    • 同样的,Pipe()和TopDeparser(ck16)被评估为构造函数调用
    • 已经评估了Switch package构造函数的所有参数
    • 评估Switch构造函数,结果是Switch package的一个实例(包含名为prs的TopParser,Switch的第一个参数;名为ctrl的Pipe;以及名为dep的TopDeparser)。
  • 程序评估的结果是main变量的值

下图展示了评估的结果,在TopParser和TopDeparser之间只共享一个名为ck16的Checksum16实例。这是否可行取决于架构。特定目标编译器可能需要在不同的块中使用不同的校验和单元

avatar

Control plane names

以下是controllable的实体,他们的名字必须是唯一的:

  • tables
  • keys
  • actions
  • extern instances

以下构造程序包含controllable entites,本身必须具有唯一性:

  • control instances
  • parser instances

评估可以从一种类型创建多个实例,每个实例必须具有唯一的完全限定名称。

Computing control names

Tables

1
2
3
control c(...)() {
table t { ... }
}

table的localname是t。

Keys

1
2
3
4
5
6
7
table t {
keys = {
data.f1 : exact;
hdrs[3].f2 : exact;
}
actions = { ... }
}

以下类型的表达式具有从其语法名称派生的本地名称:

KindExampleName
isValid() methodh.isValid()“h.isValid()”
Array accessesheader_stack[1]“header_stack[1]”
Constants1“1”
Field projectionsdata.f1“data.f1”
Slicesf1[3:0]“f1[3:0]”

所有其他类型的表达式都必须使用@name进行注释,如下所示:

1
2
3
4
5
6
table t {
keys = {
data.f1 + 1 : exact @name("f1_mask");
}
actions = { ... }
}

@name(“f1_mask”)注释将local name”f1_mask”分配给此keys。

Actions

1
2
3
control c(...)() {
action a(...) { ... }
}

action的local name是a。

Instances

extern、parser和control instances的local name是根据instances的使用方式派生的。如果instances绑定到名称,则该名称将成为其本地控制平面的名称,例如,控件C被声明为control C(...)() { ... },实例为C() c_inst;,实例的本地名称为c_inst。

如果实例创建为实际参数,则其本地名称将绑定到形式参数的名称,例如extern E和control C声明如下:

1
2
extern E { ... }
control C( ... )(E e_in) { ... }

实例为C(E()) c_inst;,extern instances的本地名称为e_in。

如果要实例化的构造作为参数传递给package,则实例名称尽可能从用户提供的类型定义派生。在以下示例中,MyC实例的本地名称是c,extern的本地名称为e2,而不是e1。

1
2
3
4
5
6
extern E { ... }
control ArchC(E e1);
package Arch(ArchC c);

control MyC(E e2)() { ... }
Arch(MyC()) main;

在此示例中,架构在应用传递给Arch packets的MyC实例时将提供extern的实例。 该实例的完全限定名称是main.c.e2。

接下来来个更大的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
control Callee() {
table t { ... }
apply { t.apply(); }
}
control Caller() {
Callee() c1;
Callee() c2;
apply {
c1.apply();
c2.apply();
}
}
control Simple();
package Top(Simple s);
Top(Caller()) main;

avatar

控制命名的注释

通过以下方式更改暴露于控制平面的名称

  • @hidden注释隐藏了控制平面中的可控实体。这事唯一一种不需要可控实体局域唯一的完全限定名称的情况
  • @name注释可用于更改可控实体的本地名称。
  • @globalname注释可用于更改可控实体的全局名称。

为两个不同的可控实体生成相同的完全限定名称的程序无效。必须特别注意@globalname:如果类型包含@globalname注释并且实例化两次,则将这两个实例将具有相同的完全限定名称。

建议

当控制平面在其使用它的上下文中是明确的,控制平面可以通过其完全限定名称的后缀来引用可控实体。请考虑以下示例:

1
2
3
4
5
6
7
control c( ... )() {
action a ( ... ) { ... }
table t {
keys = { ... }
actions = { a; } }
}
c() c_inst;

控制平面软件可以将c_inst.a插入c_inst.t的actions。

动态评估

P4程序的动态评估由架构模型编排。每个体系结构模型都需要制定动态执行各种P4组件程序的顺序和条件。一旦调用了P4执行块,它的执行将根据本文档中定义的语义继续执行到终止。

并发模型

典型的报文处理系统需要同时执行多个逻辑线程。至少有一个执行控制平面的线程,它可以修改表的内容。架构规范应详细描述控制平面和数据平面之间的交互。数据平面可以通过外部函数和方法调用与控制平面交换信息。此外,高吞吐量报文处理系统可以同时处理多个报文,例如pipeline或者对第二个报文执行match-action的时候,对第一个报文进行解析。

当架构调用时,每个top-level parser或者control block都作为单独的线程执行。bloack的所有参数和所有局部变量都是线程本地的,即每个线程都有这些资源的私有副本。这适用于parser和deparser的packet_in和packet_out参数。

只要P4块仅使用线程本地存储(metadata,报文头,局部变量),他在存在并发时的行为与孤立的行为相同,因为来自不同线程的任何语句交错都必须产生相同的输出。

相反,由P4程序实例化的extern块是全局的,在所有线程之间共享。如果extern块调节对状态(计数器,寄存器)的访问,即外部块读取和写入状态的方法,则这些有状态操作受数据竞争的影响。P4要求有以下行为:

  • 执行动作是原子的,即其他线程可以再actions开始之前或者action完成之后看到状态。
  • 在extern实例上执行方法调用是原子的。

为了允许用户表达更大代码块的原子执行,P4提供了@atomic注释,可以以用于块语句,parser状态,控制块或者整个parser。

1
2
3
4
5
6
7
8
9
10
11
extern Register { ... }
control Ingress() {
Register() r;
table flowlet { /* read state of r in an action */ }
table new_flowlet { /* write state of r in an action */ }
apply {
@atomic {
flowlet.apply();
if (ingress_metadata.flow_ipg > FLOWLET_INACTIVE_TIMEOUT)
new_flowlet.apply();
}}}

改程序在从tables flowlet(读取)和new_flowlet(写入)调用的操作中访问Register类型的extern的对象r。如果没有@atomic注释买这两个操作将不会以原子方式执行:第二个报文可能会在第一个报文有机会更新它之前读取r的状态。

如果编译器后端无法实现指令序列的原子操作,则它必须拒绝包含@atomic块的程序。在这种情况下,编译器应提供合理的诊断。

注释

注释类似于C#和Java。

预定义的注释

以小写字母开头的注释名称保留用于标注库和体系结构。本文档预定义了一组标准注释。

table action list上的注释

以下两个注释可用于向编译器和控制平面提供有关表中操作的其他信息。

  • @tableonly:具有此注释的操作只能出现在table中,而不能作为默认操作。
  • @defaultonly:具有此注释的操作只能出现在默认操作中,而不会出现在table中。
1
2
3
4
5
6
7
8
table t {
actions = {
a, // can appear anywhere
@tableonly b, // can only appear in the table
@defaultonly c, // can only appear in the default action
}
...
}

Control-plane API注释

@name注释指示编译器在生成用于操作控制平面中的语言元素的外部API时使用不同的本地名称。它必须有一个字符串文字参数。在以下示例中,table的完全限定名称是c_inst.t1:

1
2
3
4
5
control c( ... )() {
@name("t1") table t { ... }
apply { ... }
}
c() c_inst;

@globalname注释的作用类似于@name注释,除了它覆盖带注释元素的完全限定名称。在以下示例中,table的完全限定名称为foo.bar。

1
2
3
4
5
control c( ... )() {
@globalname("foo.bar") table t { ... }
apply { ... }
}
c() c_inst;

@hidden注释隐藏了一个可控制的实体,例如,来自控制平面的table,key,action或者extern。这块有效的删除了其完全限定名称。

限制

每个元素可以注释最多一个@name,@globalname或者@hidden注释,并且每个控制平面名称必须至多引用一个可控实体。在使用@globalname注释时,这一点特别令人担忧:如果包含@globalname注释的类型多次被实例化,则它将导致引用两个可控实体的相同全局名称。

1
2
3
4
5
6
7
8
control noargs();
package top(noargs c1, noargs c2);

control c() {
@globalname("foo.bar") table t { ... }
apply { ... }
}
top(c(), c()) main;

如果没有@globalname注释,该程序将生成两个具有完全限定名称main.c1.t和main.c2.t的可控实体。但是@globalname(foo.bar)注释将两个实例中的table t重命名为foo.bar,从而产生一个引用两个可控实体的名称,这是非法的。

并发控制注释

@atomic注释可用于强制执行代码块的原子操作。

Target-specific注释

每个P4编译器实现都可以定义特定于编译器目标的附加注释。注释的语法应符合以上描述。这种注释的语义是taget-specific的。他们可以与其他语言的语法类似的方式使用。

P4编译器应该提供:

  • 注释使用不正确时的错误(例如,期望参数但是没有参数使用的注释,或者使用错误类型的参数)
  • 未知注释的警告

注意:所有文章非特别说明皆为原创。为保证信息与源同步,转载时请务必注明文章出处!谢谢合作 :-)

原始链接:http://zhaozhanxu.com/2018/08/24/P4/2018-08-24-P4-Spec/

许可协议: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。