4.3.2 TNA
正如刚才提到的,V1Model 是许多可能的流水线体系架构之一。PSA 是另一种,但不同的交换机供应商也提供了自己的体系架构定义。这样做有不同的动机,一是随着时间的推移,供应商不断发布新的芯片,他们有自己版本的多 ASIC 问题。另一个原因是,它使供应商能够暴露其 ASIC 的独特功能,而不受标准化过程的限制。Tofino Native Architecture(TNA) 就是一个例子,它是 Barefoot 为其可编程交换芯片家族定义的架构模型。
我们不打算在这里详细介绍 TNA(详细信息可以在 GitHub 上查看),但可以作为第二个实际案例帮助说明这一领域的自由度。实际上,P4 语言定义了一个编写程序的通用框架(我们将在下一节中看到其语法),但只有在定义了 P4 体系架构(通常我们称之为arch.p4
)时(具体例子是v1model.p4
, psa.p4
和tna.p4
),开发人员才能够实际编写和编译转发程序。
延伸阅读:
Open Tofino. 2021.
与渴望在不同交换芯片之间抽象出共性的v1model.p4
和psa.p4
相反,tna.p4
忠实的定义了给定芯片的底层功能。通常,这样的功能就是使像 Tofino 这样的芯片区别于竞争对手的地方。在为新的 P4 程序选择架构模型时,一定要问这样的问题: 我要编程的交换机支持哪些可用架构?我的程序是否需要访问特定芯片的功能(例如,用来加密/解密数据包有效负载的 P4 扩展),或者能否只依赖通用/非区分特性(例如,简单的匹配操作表或计数数据包的 P4 扩展)?
至于转发程序(我们通常称之为forward.p4
),一个有趣的例子是一个忠实实现传统 L2/L3 交换机所有特性的程序,我们称之为switch.p4
<sup>[2]</sup>。奇怪的是,这只是让我们重现构建了一个本可以从几十个供应商处购买的传统交换机,但是有两个显著差异: (1)我们可以使用 SDN 控制器通过 P4Runtime 控制交换机,(2)当我们发现需要某个新功能时可以很容易的修改程序。
[2] Barefoot 为他们的芯片组编写了这样一个程序,使用
tna.p4
作为架构模型,但不开源。有一个称为fabric.p4
的大致相同的开源变体,使用v1model.p4
,支持大多数 L2/L3 特性,这些特性是为第 7 章中介绍的 SD-Fabric 用例定制的。
总而言之,总体目标是使控制程序的开发不需要考虑设备转发流水线的具体细节。引入 P4 体系架构模型有助于实现这一目标,使相同的转发流水线(P4 程序)可以在支持相应体系架构模型的多个目标(交换芯片)之间移植。然而,这并不能完全解决问题,因为业界仍然可以自由定义多个转发流水线。但从目前情况来看,拥有一个或多个可编程交换机为控制程序和转发流水线的可编程性打开了大门,从而向开发人员展示最终的可编程性,即包括数据面转发芯片在内都是可编程的。因此如果有一个创新的新功能想要注入到网络中,那么需要同时编写该功能的控制平面和数据平面两部分,并通过工具链加载到 SDN 软件栈中!与几年前相比,这是一个重大进步。在几年前,也许可以修改路由协议(因为是在软件中),但没有机会改变转发流水线,因为都是在固定功能的硬件中。
这种复杂性值得吗?
此时,你可能想知道引入的所有复杂性是否值得,我们甚至还没有接触控制平面!到目前为止,所讨论的都是有或没有 SDN 的复杂问题。这是因为我们工作在软硬件的边界,硬件被设计成以每秒太比特的速度转发数据包。这种复杂性通常隐藏在专有设备中。SDN 所做的就是向市场施压,为其他人的创新打开空间。
在任何人能够创新之前,第一步是复制我们以前运行的东西,只是现在基于开放接口和可编程硬件。尽管本章使用了
forward.p4
作为假设的新的数据平面功能,实际上通过switch.p4
这样的程序(以及下一章描述的 Switch OS)与传统网络设备建立对应关系。一旦有了这些,就可以准备好做一些新事情了。但是做什么呢?我们的目标不是明确回答这个问题。第二章中介绍的 VNF 卸载和 INT 示例可以作为一个开始,软件定义的 5G 网络(第 9 章)和闭环验证(第 10 章)是潜在的杀手级应用。但历史告诉我们,杀手级应用是不可能被准确预测的。另一方面,历史上也有很多关于开放封闭、功能固定的系统如何产生新功能的例子。
4.4 P4 程序
最后我们简要介绍一下 P4 语言,以下不是 P4 的全面参考手册,我们的目标是让人们了解 P4 程序的概要,从而串联起之前引入的所有的点。我们通过示例(即通过遍历实现基本 IP 转发的 P4 程序)来实现这一点,这个例子摘自 P4 教程,可以在网上访问该教程并自己尝试。
延伸阅读:
P4 Tutorials. P4 Consortium, May 2019.
为了帮助理解,可以将 P4 看作类似于 C 语言。P4 和 C 共享类似的语法,这很好理解,因为都是为低级系统设计的程序语言。然而,与 C 不同的是,P4 不支持循环、指针或动态内存分配。如果你记得我们的目的是定义在单个流水线阶段发生的事情时,不支持循环就很好理解了。实际上,P4"展开"了可能的循环,在一个控制块序列(即阶段)中实现每个迭代。在下面的示例程序中,可以想象将每个代码块插入到上一节所示的模板中。
4.4.1 头声明与元数据(Header Declarations and Metadata)
首先是协议头声明,对于我们的简单示例,包括以太网头和 IP 头,这也是定义希望与正在处理的数据包关联的任何特定于程序的元数据的地方。示例中该结构为空,但是v1model.p4
为整个体系架构定义了标准的元数据结构。尽管下面的代码块中没有显示,但这个标准元数据结构包括诸如ingress_port
(数据包到达的端口)、egress_port
(选择发送数据包的端口)和drop
(置位表示数据包将被丢弃)等字段。这些字段可以由组成程序其余部分的功能块读取或写入<sup>[3]</sup>。
[3] V1Model 的奇特之处在于,元数据结构中有两个出口端口字段。其中一个(
egress_port
)是只读的,仅在出口处理阶段有效。第二个(egress_spec
)是从入口处理阶段写入的字段,用于选择输出端口。 PSA 和其他架构通过为入口和出口流水线定义不同的元数据来解决这个问题。
/***** P4_16 *****/ #include <core.p4> #include <v1model.p4> const bit<16> TYPE_IPV4 = 0x800; /**************************************************** ************* H E A D E R S ************************ ****************************************************/ typedef bit<9> egressSpec_t; typedef bit<48> macAddr_t; typedef bit<32> ip4Addr_t; header ethernet_t { macAddr_t dstAddr; macAddr_t srcAddr; bit<16> etherType; } header ipv4_t { 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; ip4Addr_t srcAddr; ip4Addr_t dstAddr; } struct metadata { /* empty */ } struct headers { ethernet_t ethernet; ipv4_t ipv4; }
4.4.2 解析器(Parser)
下一个块实现了解析器。解析器的底层编程模型是状态转换图,包括内置的start
、accept
和reject
状态。开发人员可以添加其他状态(在我们的示例中是parse_ethernet
和parse_ipv4
),以及状态转换逻辑。例如,下面的解析器总是从start
状态转换到parse_ethernet
状态,如果在以太网报头的etherType
字段中找到TYPE_IPV4
(请参阅前面代码块中的常量定义),那么接下来就转换到parse_ipv4
状态。作为遍历每个状态的副作用,将从数据包中提取相应的报头。这些内存结构中的值随后可用于其他例程,如下所示。
/**************************************************** ************* P A R S E R ************************** ****************************************************/ parser MyParser( packet_in packet, out headers hdr, inout metadata meta, inout standard_metadata_t standard_metadata) { state start { transition parse_ethernet; } state parse_ethernet { packet.extract(hdr.ethernet); transition select(hdr.ethernet.etherType) { TYPE_IPV4: parse_ipv4; default: accept; } } state parse_ipv4 { packet.extract(hdr.ipv4); transition accept; } }
与本节所有代码块一样,解析器的函数签名是由体系架构模型定义的,在本例中为v1model.p4
。我们没有对具体参数做进一步介绍,只是笼统指出 P4 是与架构无关的,所编写的程序很大程度上依赖于所包含的架构模型。
4.4.3 入口处理(Ingress Processing)
入口处理分为两部分。首先是验证 checksum,在我们的例子中只是应用默认值<sup>[4]</sup>。该示例引入的有趣的新特性是control
结构,它实际上是 P4 版本的过程调用。虽然开发人员也可以根据模块化的设计来定义"子例程",但从整体上看,控制块与逻辑流水线模型定义的阶段一一匹配。
[4] 这是 V1Model 特有的,PSA 在入口或出口阶段没有明确的 checksum 验证或计算。
/**************************************************** *** C H E C K S U M V E R I F I C A T I O N *** ****************************************************/ control MyVerifyChecksum( inout headers hdr, inout metadata meta) { apply { } }
现在进入转发算法的核心,其在 Match-Action 流水线的入口段实现。我们发现定义了两个actions
: drop()
和ipv4_forward()
。第二个很有趣,它以dstAddr
和出口端口作为参数,将端口分配给标准元数据结构中的相应字段,在数据包的以太网报头中设置srcAddr/dstAddr
字段,并减小 IP 报头的 ttl 字段。执行完该动作后,与报文相关的报文头和元数据中包含了足够的信息,可以正确做出转发决策。
但是这个决策是如何做出的呢?这就是table
结构的目的。table
定义包含一个要查找的key
、一组可能的actions
(ipv4_forward
、drop
、NoAction
)、表的大小(1024
个条目)以及当没有匹配时所采取的默认操作(drop
)。关键规范包括要查找的报头字段(IPv4 报头的dstAddr
字段)和期望的匹配类型(lpm
表示最长前缀匹配)。其他可能的匹配类型包括exact
(精确匹配)和ternary
(三元匹配),后者有效应用掩码来选择在匹配中考虑的键中的哪些位域。lpm
、exact
和ternary
是核心 P4 语言类型的一部分,其定义可以在core.p4
中找到。P4 体系架构可以扩展额外的匹配类型,例如 PSA 还定义了range
(范围)和selector
(选择器)匹配。
入口处理的最后一步是"应用"刚刚定义的表(只有当解析器或之前的流水线阶段将 IP 头标记为有效时才会这样做)。
/**************************************************** ****** I N G R E S S P R O C E S S I N G ******* ****************************************************/ control MyIngress( inout headers hdr, inout metadata meta, inout standard_metadata_t standard_metadata) { action drop() { mark_to_drop(standard_metadata); } action ipv4_forward(macAddr_t dstAddr, egressSpec_t port) { standard_metadata.egress_spec = port; hdr.ethernet.srcAddr = hdr.ethernet.dstAddr; hdr.ethernet.dstAddr = dstAddr; hdr.ipv4.ttl = hdr.ipv4.ttl - 1; } table ipv4_lpm { key = { hdr.ipv4.dstAddr: lpm; } actions = { ipv4_forward; drop; NoAction; } size = 1024; default_action = drop(); } apply { if (hdr.ipv4.isValid()) { ipv4_lpm.apply(); } } }
4.4.4 出口处理(Egress Processing)
在我们的简单示例中出口处理没有任何操作,但通常这是基于出口端口执行操作的机会,而这一信息在入口处理期间可能不知道(例如,可能依赖于流量管理器)。例如,可以通过在入口处理中设置相应的内部元数据将一个数据包复制到多个出口端口以进行多播,这种元数据的含义由体系架构定义。出口处理将看到与流量管理器生成的相同包的副本数量相同的副本。再举个例子,如果期望交换机的一个端口发送带 VLAN 标签的报文,则必须用 VLAN id 扩展报文头。处理这种情况的一种简单方法是创建一个与元数据的egress_port
匹配的表。其他例子包括对组播/广播数据包进行入口端口修剪,并为传递到控制平面的拦截数据包添加特殊的“CPU 头”。
/**************************************************** ******* E G R E S S P R O C E S S I N G ******** ****************************************************/ control MyEgress( inout headers hdr, inout metadata meta, inout standard_metadata_t standard_metadata) { apply { } } /**************************************************** *** C H E C K S U M C O M P U T A T I O N **** ****************************************************/ control MyComputeChecksum( inout headers hdr, inout metadata meta) { apply { update_checksum( hdr.ipv4.isValid(), { hdr.ipv4.version, hdr.ipv4.ihl, hdr.ipv4.diffserv, hdr.ipv4.totalLen, hdr.ipv4.identification, hdr.ipv4.flags, hdr.ipv4.fragOffset, hdr.ipv4.ttl, hdr.ipv4.protocol, hdr.ipv4.srcAddr, hdr.ipv4.dstAddr }, hdr.ipv4.hdrChecksum, HashAlgorithm.csum16); } }
4.4.5 编码器(Deparser)
Deparser 的处理通常比较直接。我们在包处理过程中可能更改了各种报头字段,现在有机会将更新了报头字段的报文发送出去。如果在流水线的某个阶段更改了报头,则需要记住在发包的时候带上对应的改动。只有那些被标记为有效的报头才会被重新序列化到包中。不需要对包的其余部分(即有效负载)做任何处理,因为默认情况下,在我们停止解析的数据之后的所有字节都将被包含在输出消息中。数据包怎样被发送的细节由架构指定。例如,TNA 支持根据 deparser 的特殊元数据值的设置来截断有效负载。
/**************************************************** ************* D E P A R S E R ********************* ****************************************************/ control MyDeparser( packet_out packet, in headers hdr) { apply { packet.emit(hdr.ethernet); packet.emit(hdr.ipv4); } }
4.4.6 交换机定义(Switch Definition)
最后,P4 程序必须从整体上定义交换机的行为,如下示例由 V1Switch 包给出。这个包中的元素集是由v1model.p4
,由对上面定义的所有其他例程的引用组成。
/**************************************************** ************* S W I T C H ************************* ****************************************************/ V1Switch( MyParser(), MyVerifyChecksum(), MyIngress(), MyEgress(), MyComputeChecksum(), MyDeparser() ) main;
请记住,这是一个最小示例,但确实有助于说明 P4 程序的基本思想。本例中隐藏了控制平面用来向路由表注入数据的接口,table ipv4_lpm
定义了这个表,但我们没有填充相应的值。在第 5 章讨论 P4Runtime 时,我们将解决控制平面将值填入表中的问题。
4.5 固定功能流水线(Fixed-Function Pipelines)
我们现在回到固定功能转发流水线,目标是将它们置于更大的生态系统中。记住,固定功能交换芯片仍然主导着市场<sup>[5]</sup>,我们并不是要低估它们的价值,它们无疑将继续发挥作用,但它们确实少了一个自由度,即重新编程数据平面的能力,这有助于突出本章中介绍的所有组件之间的关系。
[5] 固定功能流水线和可编程流水线之间的区别并不像本文所讨论的那样明确,因为固定功能流水线也支持配置。但参数化交换芯片和为交换芯片编程在本质上是不同的,只有后者能够引入新的功能。
4.5.1 OF-DPA
我们从一个具体例子开始: Broadcom 为其交换芯片提供的硬件抽象层--OpenFlow 数据平面抽象(OF-DPA, OpenFlow—Data Plane Abstraction) 。OF-DPA 定义了可用于将流规则配置到底层 Broadcom ASIC 中的 API。从技术上讲,OpenFlow 代理位于 OF-DPA 之上(实现了 OpenFlow 协议),而 Broadcom SDK 位于 OF-DPA 之下(实现了基于底层芯片细节的专有接口),但 OF-DPA 层提供了 Tomahawk ASIC 固定转发流水线的抽象表示。图 24 显示了最终的软件栈,其中 OF-Agent 和 OF-DPA 是开源的(OF-Agent 对应一个名为 Indigo 的软件模块,最初由 Big Switch 编写),而 Broadcom SDK 是专有的。图 25 描述了 OF-DPA 流水线的样子。
我们不会深入探究图 25 的细节,但是读者可以识别出几个知名协议。就我们的目的而言,有意义的是了解 OF-DPA 与可编程流水线的对应关系。在可编程场景下,直到我们实现一个类似switch.p4
这样的程序,才能得到大致相当于 OF-DPA 的东西。也就是说,v1model.p4
定义了可用的阶段(控制块),但直到我们添加了switch.p4
,才能将这些阶段的功能运行起来。
考虑到这种关系,我们可能希望在单个网络中综合使用可编程交换机和固定功能交换机,并运行公共 SDN 软件栈。这可以通过将两种类型的芯片隐藏在v1model.p4
(或类似的)架构模型后面来实现,并让 P4 编译器输出各自 SDK 能够理解的后端代码。显然,这不适用于想要做一些 Tomahawk 芯片不支持的事情的 P4 程序,但适用于标准 L2/L3 交换行为。
4.5.2 SAI
正如我们所看到的供应商定义和社区定义的体系架构模型(分别是 TNA 和 V1Model)一样,也存在供应商定义和社区定义的固定功能逻辑流水线。前者是 OF-DPA,而交换机抽象接口(SAI, Switch Abstraction Interface) 是后者的一个例子。因为 SAI 必须跨一系列交换机(以及转发流水线)工作,所以必须专注于所有供应商都认同的功能子集,也就是最小公约数上。
SAI 包括一个配置接口和一个控制接口,其中后者与本节最为相关,因为它抽象了转发流水线。另一方面,研究另一个转发流水线并没有什么价值,所以我们建议感兴趣的读者参考 SAI 规范以获得更多细节。
延伸阅读:
SAI Pipeline Behavioral Model. Open Compute Project.
我们将在下一章讨论配置 API。
4.6 比较
关于逻辑流水线及其与 P4 程序的关系的讨论是微妙的,值得重述一遍。一方面,对物理流水线进行抽象表示有明显的价值,如图 21 中引入的一般概念。当以这种方式使用时,逻辑流水线就是引入硬件抽象层这种经过验证的想法的一个例子。在我们的案例中,有助于控制平面的可移植性。OF-DPA 是 Broadcom 固定功能交换芯片硬件抽象层的特定示例。
另一方面,P4 提供了一个编程模型,通过v1model.p4
和tna.p4
等架构向 P4 通用语言结构(例如,control
、table
、parser
)添加细节。实际上,这些架构模型是通用转发设备基于语言的抽象,可以通过添加特定 P4 程序(如switch.p4
)将其完全解析为逻辑流水线。P4 体系架构模型没有定义匹配操作表的流水线,但定义了可以被 P4 开发人员用来定义流水线(无论是逻辑的还是物理的)的构建块(包括签名)。因此,从某种意义上说,P4 架构相当于传统交换机 SDK,如图 26 中五个并排的示例所示。
图 26. 5 个示例流水线/SDK/ASIC 堆栈。最左边的两个加上第四个,至今仍然存在;中间是假设的栈;最右边是一个未完成的栈。
图 26 中的每个示例由三层组成: 交换芯片专用 ASIC、用于编程 ASIC 的厂商特定 SDK,以及转发流水线定义。通过提供编程接口,中间层的 SDK 有效抽象了底层硬件。它们要么是传统的(例如,在第二个和第四个例子中显示的 Broadcom SDK),要么是,正如刚才所指出的,逻辑上对应于 P4 架构模型与专用于 ASIC 的 P4 编译器。这五个示例的最顶层定义了一个逻辑流水线,随后可以通过 OpenFlow 或 P4Runtime 之类的接口来控制(没有显示)。这五个示例的不同在于流水线是由 P4 程序定义还是通过其他方法(例如,OF-DPA 规范)定义。
请注意,只有那些在栈顶使用 P4 定义的逻辑流水线的配置(即第一、第三、第五个示例)可以使用 P4Runtime 进行控制。这是出于实用的原因,P4Runtime 接口是使用下一章介绍的工具基于 P4 程序自动生成的。
最左边的两个例子现在已经存在了,分别代表了可编程和固定功能 ASIC 的规范层。中间的例子纯粹是假设,但说明了即使对于固定功能流水线,也可以定义基于 P4 的堆栈(并暗示使用 P4Runtime 进行控制)。第四个例子同样存在,即 Broadcom ASIC 如何遵从 SAI 定义的逻辑流水线。最后,最右边的例子将会在未来出现,SAI 将会被扩展以支持 P4 的可编程性,并在多个 ASIC 上运行。