
做网络的同学这两年肯定避不开 P4 这个词。不管是可编程交换、智能网卡还是某个大厂的网络流量方案P4 几乎已经成了“软件定义数据面”的代名词。而只要是玩 P4Barefoot Tofino 这块芯片基本绕不过去——它是最早商用的 P4 可编程交换芯片也是不少机房环境里真实跑过的设备。这篇文章把我在 Tofino 上用 P4 实现一个自定义网络协议的完整过程写下来。包括协议头怎么设计、解析器怎么写的、表怎么下、校验和怎么算以及在开发过程中踩过的一堆坑。如果你正准备用 Tofino 做自定义协议或者刚接触 P4 正不知道从哪下手这篇应该能帮你省不少时间。1. 项目概述与核心思路1.1 这个项目到底在做什么当时的需求很简单机房内部有两类业务流量一类走标准 IPv4一类要走一种带 session 标识的自研二层隧道方便后面做策略路由和流量染色。最初是在服务器上用 DPDK 做的封装和解封装很快发现两个问题一是处理能力跟不上服务器 CPU 打到 80% 以上二是带宽上到 100G 之后DPDK 的轮询加封装逻辑要吃掉很多核成本扛不住。于是就把拆解和策略匹配下移到 Tofino 上。思路是这样在 Tofino 的数据平面里用 P4 语言定义一个新的二层协议头帧格式采用“以太网头 自定义头 原始 IP 报文”的结构。芯片在解析到自定义 EtherType 时就继续解析这个头然后根据 session_id、magic 字段去做匹配匹配到了就转发或者丢弃匹配不到就走默认动作。简单说这个项目就是把原本在服务器软件里做的工作用 P4 在交换机芯片上重写了一遍最终目标是线速处理且不占用 CPU。下面写的所有代码和经验都是围绕这个最小可用的自定义协议展开的。不管你的场景是自研隧道、Overlay 封装还是做特定协议的识别和过滤思路和踩坑点都是通用的。1.2 为什么选 P4 Tofino而不是其他方案这个问题几乎是每个刚接触的人都会问。我当时的备选方案大概有四个DPDK、FPGA、传统 ASIC 交换芯片、P4 可编程交换芯片。这四个方向我多多少少都接触过各有各的适用场景。先说 DPDK。DPDK 的优势是开发灵活、生态成熟会 C 语言就能做。但它本质还是“用 CPU 去处理包”CPU 的核数和频率就是天花板。在 100G 端口上做自定义封装一个核处理不到线速而且延迟抖动很难压下来。如果你的带宽只有 10G 或者 25GDPDK 完全够用但到了 100G 甚至 400GDPDK 就不太划算了。再看 FPGA。FPGA 可以做到非常精确的流水线和线速处理但它的问题是开发周期长、硬件成本高、调试难度大。一个网络工程师团队里要有人懂 RTL出了问题很难在现场排查。对大多数业务场景来说用 FPGA 做自定义协议属于“杀鸡用牛刀”除非你对时延有极端的纳秒级要求。传统 ASIC 交换芯片的问题是写死之后不能改。普通交换机芯片支持的是 RFC 定义的标准协议你想加一个私有 EtherType它不一定能识别更不用说针对自定义字段做匹配和转发。要换协议版本就得换硬件这在生产环境里是无法接受的。P4 Tofino 正好落在中间有 ASIC 的线速性能又有接近 DPDK 的开发灵活度。芯片里的数据平面流水线是可配置的昨天跑的是标准 L2/L3今天的配置可以变成自定义协议解析。换业务的时候不用换硬件只需要换一个编译好的 P4 程序再重启 switchd。当然它也有自己的代价比如 P4 语言本身的学习曲线不低Tofino 的开发环境也比较封闭后面我会讲到这些坑。这里也想多说一句关于学习路径的建议。如果你完全没接触过 P4可以先在 BMv2 软件交换机上把语法跑熟BMv2 对新手友好报错提示清楚而且不需要硬件。但要注意BMv2 跑得通不代表 Tofino 上没问题因为 Tofino 对资源、parser 嵌套、表项宽度这些有硬性限制。所以我的建议是先在 BMv2 上快速验证逻辑再上 Tofino 调资源和性能两条腿走路效率最高。2. 开发环境与工具链准备2.1 软硬件清单与 SDE 安装Tofino 的开发环境不像普通 Linux 开发那样装个 gcc 就能跑。它依赖 Intel 提供的 P4 Studio习惯上叫 SDESoftware Development Environment里面包含 P4 编译器、驱动、运行时 API、模拟器还有一堆平台相关的工具。SDE 和板卡的 BSPBoard Support Package是一一对应的买板卡的时候厂商会给你一个配套版本这个版本匹配问题一定要重视。我用的硬件是 Edgecore Wedge 100BF-32X32 个 100G 端口芯片是第一代 Tofino。开发机上跑的是 CentOS 7内核版本 3.10。SDE 版本用的是 Intel P4 Studio 9.x 这一代gRPC 和 BF Runtime API 都能正常用。如果你的机器内核太新或者太老SDE 里的内核模块可能编不过去建议尽量贴近官方支持的 OS 版本。安装过程看起来不复杂但有几个关键点# 设置 SDE 根目录建议用 export 写进 ~/.bashrc export SDE/data/p4studio export SDE_INSTALL$SDE/install # 编译安装最小工具集bf_switchd 是数据平面守护进程bfrt_python 是控制面 Python API cd $SDE/p4studio ./p4studio build bf_switchd bfrt_python tofino-modelp4studio是一个安装脚本会自动拉取依赖并编译。第一次编译时间比较长大概二三十分钟取决于机器性能。编译完成后所有的可执行文件都在$SDE_INSTALL/bin下之后你所有操作基本都是围绕这个目录展开。接下来是关键一步写一个简单的 P4 程序用项目自带的p4_build.sh编译cd $SDE ./p4_build.sh myproto.p4编译成功后会在$SDE下生成myproto.tofino和myproto.tofino2之类的二进制文件还有myproto.conf配置文件。注意tofino和tofino2是两代芯片编译产物不同不能混着用。bf_switchd 加载配置时指定对应的 conf 文件即可。我遇到的一个坑是p4_build.sh默认会同时编译 Tofino 1 和 Tofino 2 的目标但如果你板卡对应的 BSP 没有装好编译到一半就报头文件缺失英文报错写得比较隐蔽容易让人误以为是 P4 代码的语法问题。后来我干脆手动指定芯片型号用./p4_build.sh -b tofino myproto.p4可以少踩很多坑。启动 switchd 也有讲究我常用的命令是cd $SDE_INSTALL/bin ./bf_switchd --install-dir $SDE_INSTALL --conf-file $SDE/myproto.conf --background --status-port 7777--background让进程在后台跑--status-port是给控制面连接用的。启动完成后如果没报错芯片就已经把 P4 程序跑起来了。这时候你再用bfshell或者bfrt_python去连它就可以开始配表了。2.2 数据平面与控制平面的协作关系很多新手第一次接触 Tofino 的时候会被“P4 程序部署到芯片”这个过程搞蒙P4 明明是软件芯片怎么跑这里要分清楚两件事数据平面和控制平面。P4 程序描述的是“数据包从端口进来之后芯片内部怎么处理它”这就是数据平面。它被编译成 Tofino 芯片内部的流水线配置相当于给芯片“烧”进了一套处理逻辑。这套逻辑跑起来后芯片就能在硬件层面执行解析、匹配、转发这些操作。但表项是动态的。比如你要让 session_id100 的包走端口 3这个规则没法写死在 P4 程序里否则每次改策略都要重新编译。所以要靠控制平面往芯片里下发表项。控制平面跑在 CPU 上通过 PCIe 和芯片通信把“哪条流、转发到哪”这类动态信息写进硬件表项。在 Tofino 这套体系里bf_switchd就是数据平面和控制平面之间的桥。它负责加载编译好的 P4 配置让芯片进入运行状态同时暴露一个叫 BF Runtime 的接口。你可以用命令行工具bfshell查看端口状态也可以用bfrt_python写脚本批量下发表项。我个人的建议是在项目初期用模型tofino-model跑通逻辑先把 P4 代码调好再上真机。因为模型对错误提示更友好而且可以随便重启真机一旦跑起来日志刷屏和硬件问题叠加在一起排查起来特别痛苦。后续章节我会按这个工作流来展开。3. 自定义协议的核心实现3.1 协议头定义与解析器协议头的格式设计一定要简单。Tofino 的解析器虽然灵活但它对 parser state 的数量和嵌套深度有硬件限制状态太多会导致编译失败。我当时把需求收敛成下面这个最小头里面只放了业务上真正需要的字段header ts_tunnel_t { bit16 magic; // 固定魔数用于识别协议 bit16 version; // 协议版本 bit16 pkt_length; // payload 长度 bit16 session_id; // 会话标识 bit32 seq_no; // 包序号 bit16 reserved; // 保留字段对齐用 }字段越少越好。每个字段都会影响后面的表项 key 宽度和 parser 的资源占用。比如 reserved 字段如果业务暂时用不到可以先不定义定义出来虽然可以但要知道它会占资源。协议头的总长度最好设计成 4 字节的整数倍这样 payload 能做到对齐deparse 和后面的 IP 处理会少很多麻烦。接下来是解析器。以太网头解析完之后根据 EtherType 决定跳到哪个 parser state。我这里自定义的 EtherType 用0x88B5这只是举例实际用之前要确认它在你的网络里没有被其他厂家占用。parser MyParser(packet_in b, out headers hdr, inout metadata meta, inout standard_metadata_t sm) { state start { b.extract(hdr.ethernet); transition select(hdr.ethernet.etherType) { 16w0x88B5: parse_ts_tunnel; 16w0x0800: parse_ipv4; default: accept; } } state parse_ts_tunnel { b.extract(hdr.ts_tunnel); transition select(hdr.ts_tunnel.magic) { 16w0x8877: parse_payload; default: reject; } } state parse_payload { // 这里简单把剩余数据当成 payload 透传不再继续解析 transition accept; } }这里有个容易忽略的细节解析完自定义头之后默认动作通常是想继续往后看 payload但如果 payload 是完整的 IP 报文你完全可以继续解析 IPv4这样 ingress 里就能用 IPv4 五元组再加一层匹配。我在做的时候一开始只做了识别后面为了支持“某些隧道会话限制访问某个目的 IP”又把解析器加深了一层解析完整 IPv4 头。这种加深要多试几次因为 parser state 一多编译器就开始报 resource 相关的问题。为什么我会在协议头里放一个 magic 字段一方面是识别协议是否合法避免把普通流量误判成自定义协议另一方面它也相当于一个过滤器减少无效报文进入后面表的匹配。这个做法节省了表项资源也让安全规则更清晰推荐大家在设计自己的协议时保留。如果你用的是 Tofino 原生架构TNAparser 写法和 v1model 略有差异但核心逻辑一样都是 state 加 transition select。新手建议先用 v1model 把业务点亮再考虑迁移到 TNA 拿更多底层控制。3.2 匹配动作表与控制面下发协议识别之后真正的转发策略是靠表驱动的。我定义了一张最简单的表key 是 session_id 和 magic动作是设置出端口或者丢包action set_nhop(bit9 egress_port) { standard_metadata.egress_spec egress_port; } table ts_forward { key { hdr.ts_tunnel.magic : exact; hdr.ts_tunnel.session_id : exact; } actions { set_nhop; drop; } default_action drop(); } control MyIngress(inout headers hdr, inout metadata meta, inout standard_metadata_t sm) { apply { if (hdr.ts_tunnel.isValid()) { ts_forward.apply(); } else { // 非自定义协议报文按普通 IP 转发逻辑处理 ipv4_forward.apply(); } } }这张表设计上有一个关键点default_action drop()。很多同学在做的时候会把默认动作设置成NoAction()理由是“没匹配到就先放过”结果流量私底下已经丢了或者乱转发特别难查。在硬件交换机里默认 drop 是一个安全的选择它保证只有明确下发的规则才会生效。控制面上线之前最好把默认动作想清楚宁可先丢不要先放。key 字段我全部用了exact匹配这也是一个值得展开的点。Tofino 的表项匹配类型分三种exact、ternary、lpm。exact 用 SRAM 查表速度快、资源开销小ternary 和 lpm 会用 TCAMTCAM 容量有限规则一多很容易爆。所以能用 exact 完成的匹配不要用 ternary能用一张表完成的不要拆成两张表。这个原则在 Tofino 上非常实用很多编译资源问题其实是表项类型选择不当造成的。接下来就是控制面。bfrt_python 的 API 不同版本略有区别但基本套路是固定的先建立连接拿到 bfrt_info然后找到对应的表构造 key 和 data调用 entry_add。一个最小脚本类似这样import bfrt_grpc.bfruntime_pb2 as bfruntime_pb2 import bfrt_grpc.client as gc client gc.Client(server_address127.0.0.1:50051) bfrt_info client.bfrt_info_get(myproto) target bfrt_info.target tbl bfrt_info.table_get(pipe.MyIngress.ts_forward) key tbl.make_key([ gc.KeyTuple(hdr.ts_tunnel.magic, 0x8877), gc.KeyTuple(hdr.ts_tunnel.session_id, 100) ]) data tbl.make_data([gc.DataTuple(set_nhop, [gc.DataTuple(egress_port, 3)])]) tbl.entry_add(target, [key], [data])注意表名前面带了pipe.MyIngress.前缀。这个前缀是怎么来的取决于你在 P4 代码里是否用了name注解。如果同一个表在多个 pipeline 里存在表名会带上 pipe 编号。我自己一开始没弄明白照着网上例子填表名结果一直报“table not found”。后来去 bfrt_python 里bfrt_info.table_get_all()把所有已注册表列出来才搞清楚真实的表名结构。添加表项之后怎么确认真的生效最土但也最有效的办法找一台测试机发几个包用 Python/Scapy 构造符合协议头的帧打到交换机口上另一台机器用 tcpdump 抓包看是否从目标口出来。我在调试阶段就是用这个闭环来验证简单直接。3.3 校验和与转发细节自定义协议最容易翻车的是校验和。协议头里一旦出现 length、seq 这类字段接收端往往会校验数据完整性。如果发送端是 Tofino 自己改写了某些字段比如修改 session_id 或者做 NAT 之类的就必须把校验和一起更新否则对端直接丢包。P4 里更新校验和标准做法是用update_checksum。以完整的头为例假定最后一个字段是校验和字段header ts_tunnel_t { bit16 magic; bit16 version; bit16 pkt_length; bit16 session_id; bit32 seq_no; bit16 hdr_checksum; } update_checksum( hdr.ts_tunnel.isValid(), { hdr.ts_tunnel.magic, hdr.ts_tunnel.version, hdr.ts_tunnel.pkt_length, hdr.ts_tunnel.session_id, hdr.ts_tunnel.seq_no }, hdr.ts_tunnel.hdr_checksum, HashAlgorithm.csum16 );这里有三个坑要注意。第一校验和的范围不能包含校验和字段本身这个大家基本都知道但往往会搞错字段顺序。update_checksum计算的时候是按给定的字段顺序依次算的顺序不同结果不同对端必须用同样的顺序重算。TCP、UDP 的标准校验和对字段顺序有约定你自研协议时也必须在文档里写清楚。第二csum16是互联网校验和16位反码求和不是普通的 CRC16。很多自定义协议在文档里会写“checksumCRC16”那是另一种算法不要混用。你用 P4 的csum16去算 CRC16 的结果对端肯定通不过。第三update_checksum的执行时机要放在 deparser 层面的流程中保证在完整流水线结束后再更新。如果你在 ingress 中途就调用了检查逻辑有可能算的是旧值或者出现更新后又被覆盖的情况。另外如果你的自定义头后面紧跟着 IPv4 包而你又改了 IPv4 头里的一些字段TTL、DSCP 等那 IPv4 头的校验和也得重算。我见过有人只加了自定义头的校验和忘了重算 IPv4 校验和结果线上抓包一看全是 checksum incorrect。如果只是转发不改 TTL交换机默认不会改那可以不重算一旦开了某些功能一定要把 IPv4 头也放进 update_checksum 中。4. 常见问题与避坑实录4.1 高频问题速查这部分直接做成表格方便大家对着排查。现象常见原因处理建议所有报文进芯片后都丢默认动作是 drop但控制面没有下发合法表项先查表项bfrt_python 里 entry_dump确认表名是否带pipe.MyIngress.前缀表项已经加了流量还是不通key 字段没被解析出来比如 EtherType 不对或 magic 不匹配在 parser 里加 counter看命中数用 tcpdump 在交换机出端口抓包确认帧格式编译报错parser 资源不足parser state 过多、header 实例过多、表项位宽过大减少冗余解析合并 select 分支去掉不用的 header 实例考虑用 TNA 手动控制资源真机运行后日志刷屏报文乱丢端口速率/自动协商没配好或 SerDes 工作异常用pm命令查看端口状态确认 up检查光模块控制面连接失败grpc 端口没开或 bf_switchd 没起来启动时确认 grpc 地址和端口用ps确认进程自定义协议头到了对端显示乱码parser 和 deparser 不匹配或者字段 offset 错位检查 b.extract 顺序和 deparse 里 emit 顺序必须完全一致除了表格里的内容还有一个编译期很容易出现的怪问题报错信息指向 P4 代码某一行但那一行看起来完全没毛病。这种情况下十有八九是资源分配问题编译器把具体原因隐藏在了“out of resource”之类的提示里。解决办法是把代码往简化方向改——少一个 header、少一个 key、少一层 parser state然后重新编译看问题是不是消失了。这种方法虽然笨但在 Tofino 上非常有效。另外用模型和真机调试时有一点要提前有心理准备模型跑得好好的真机不一定跑得出来。最典型的差异是端口速率、内部队列调度、报文大小对时延的影响模型不会完全模拟这些。所以我的原则是逻辑问题靠模型性能和交互问题靠真机两者不能互相替代。4.2 调试工作流与个人心得我给一个自认为效率比较高的调试顺序按这个顺序来能少走一半弯路。第一在 tofino-model 里跑通逻辑。模型可以单步查看 parser 状态报错信息比真机清楚。把编译好的程序加载到模型然后从测试机打流看能不能识别、能不能转发。这一步过了至少说明 P4 逻辑本身没有大问题。模型跑通之后再上真机。第二上真机但只配一个接口对。用两根光纤一根进一根出先做最简单的“识别自定义协议就转发”的测试。不要一下子上多端口否则端口的 up/down、光模块、速率不匹配这些问题都会混进来到时候根本分不清到底是协议逻辑有问题还是硬件链路有问题。第三在 P4 代码里加上计数器。比如在ts_forward表命中时对一个 counter 加一控制面定期读出来。这样流量通不通数据面有没有匹配上一看计数器就知道。counter ts_hit_counter { type packets; instance_count 1024; } control MyIngress(inout headers hdr, inout metadata meta, inout standard_metadata_t sm) { apply { if (hdr.ts_tunnel.isValid()) { ts_forward.apply(); ts_hit_counter.count((bit32)sm.ingress_port); } } }bfrt_python 里读取 counterctr bfrt_info.table_get(pipe.MyIngress.ts_hit_counter) ctr.entry_get(target, [])返回结果里能看到每个端口的命中数。如果命中数在涨说明 parser 没问题问题出在转发或控制面如果命中数不涨说明包压根没走到这张表回头查 parser 和 EtherType。这一招能帮你快速缩小排查范围比盯着日志猜要高效得多。第四最后才做性能验证和全端口压测。自定义协议虽然跑在硬件上但表项宽度、key 数量、counter 数量都会影响芯片资源占用测一下高负载下的表现心里才有底。压测的时候关注两个指标转发吞吐和丢包率。如果丢包集中在某一个端口优先看那个端口的队列配置和 buffer 水位。最后分享一个我个人的经验用 P4 Tofino 做自定义协议很多时候最难的不是协议本身而是建立“数据面到底有没有按我写的逻辑在跑”的信心。P4 代码写完能编译不报错不代表它在芯片里就会按你想的路径走。所以每一步都要留可观测的抓手——计数器、日志、抓包三件套缺一不可。希望这篇文章能帮大家少熬几个夜把时间花在真正有挑战的逻辑上。