
简介围绕FD.io VPP数据平面开发这份资料同时提供较完整的VPP源码树与在VPP上编写示例插件的实践素材面向具备C语言和Linux网络基础、希望深入阅读转发面源码或进行插件二次开发的中高级开发者。压缩包共2000个文件、约22.26MB以1329个C源文件和798个头文件为主体辅以316个reStructuredText文档、274个Python测试脚本、134个API定义及若干构建配置、补丁和脚本兼顾源码阅读、编译调试与功能验证。内容覆盖CLI命令、ACL、NAT44、IPsec、MPLS、L2/L3转发等典型模块并给出插件节点注册、API声明、命令接入和构建集成的示例路径目录按核心源码、插件示例、文档与测试分层组织便于从主流程到业务节点逐层追踪理解VPP插件机制与源码组织方式。已有561人学习适合按模块查阅转发流程、对照示例搭建自己的插件工程并借助文档与测试脚本完成编译验证和问题定位。1. 改内核两天改插件半天这份 VPP 源码能给你什么线上要加一条按接口维度的报文特征统计改内核模块要重编、要重启评估下来两天起步换成在 VPP 源码里写一个 graph node从编译通过到 trace 里看到每个包的接口索引半天就够了。这份 VPP 源码和示例插件实例落地形态是一棵完整的向量化转发源码树外加一个能编、能加载、能挂到 ip4-unicast 特征弧上的示例插件注册宏怎么写、节点主循环怎么收发包、next 节点怎么选、CLI 和 trace 怎么加全部串成一条能照着抄的线。它适合已经能把 VPP 跑起来、想动代码改转发行为的人也适合刚接手转发面、需要把 node graph 到底怎么调度看清楚的同学。下面按构建、骨架、节点、排错、验证五段推。2. 先把源码变成能调试的二进制构建链路与目录映射拿到源码树第一件事不是读代码是让make build-release一次通过。转发面插件调试最怕的是构建环境半残——编出来的.so和运行中的vpp头文件版本对不上加载时报一堆找不到符号你会误以为是自己的注册宏写错了。所以构建这一步要一次做干净后面的坑才收窄到业务逻辑上。2.1 依赖与构建参数一次编译要过三道关第一关是系统依赖第二关是外部库第三关才是 VPP 本体。常见做法是三条命令按顺序走中间任何一条报错都别硬上# 1) 目录名随意源码放哪都行路径别带空格 git clone 仓库地址 vpp cd vpp # 2) 系统依赖cmake、ninja、编译器、各库的开发包 sudo make install-dep # 3) 外部依赖较新的分支才有这个目标老分支直接跳过 make install-ext-deps # 4) 出 release 二进制想要带断言和符号就用 make build make build-releaseinstall-dep会拉一大堆-dev包机器能联网时最省事离线环境就只能照着构建脚本里列的包名一个个补齐这一步翻车的典型表现是 CMake 阶段报某个find_package找不到而不是编译报错。install-ext-deps会把外部库编到build-root下耗时十几分钟很正常它只影响需要那些库的插件——如果你只写纯 vlib/vnet 的节点这一步失败也可以先放着等真正用到对应功能再回来补。build和build-release的差别不只是-Odebug 版本带ASSERTbuffer 越界、frame 写超这类内存问题会当场把进程打死定位极快release 版本跑得快适合压流量。我一般两个都留着写节点逻辑时用make build压测再切build-release。另外产品形态一般还要make pkg-deb出包方便在干净机器上验证加载路径这一步不在本轮调试范围里知道有就行。2.2 源码分层vlib、vnet、plugins 各管哪一段源码树很大但写插件真正会翻的就四个目录。先把职责边界记住找代码时能少走很多弯路目录职责你什么时候会翻它src/vppinfravec、hash、pool、bitmap、格式化输出定义自己的计数器数组、调试打印src/vlib主循环、node graph、buffer 池、CLI、trace、插件加载器挂节点、加命令、看调度顺序src/vnet接口、以太与 IP 解析、FIB、feature arc查 arc 名、查下一跳节点名src/plugins一堆内置插件抄同类实现的写法比看文档快vlib是整套东西的地基。要理解两件事一是 node graph每个处理节点注册时声明自己有几个 next 槽位运行时由框架把包从一个节点推到下一个节点二是 buffer报文本体加上元信息放在vlib_buffer_t里节点之间传的其实只是一个 4 字节的 buffer index真正的数据不动。这就是它比内核路径轻的地方——包在网络图里走一圈内存拷贝次数极少。vnet是协议层特征弧feature arc是这里的核心机制ip4-unicast、ip4-output、device-input这些弧上可以按接口挂一串节点节点在弧里的顺序由.runs_before/.runs_after决定。你要做的“按接口处理每个包”最自然的落点就是某个弧上的一个节点而不是自己从驱动里截包。src/plugins里的内置插件是最好的参照物。找同类实现的套路是先确定自己要挂的弧再在插件目录里搜这个弧名命中的那批文件就是能直接抄结构的模板。新分支里还有插件骨架的生成脚本和最小模板能用就用省掉 CMake 那一层的试错。2.3 产物验证从二进制到 show plugins编完之后先别急着写代码确认这套二进制自己能跑起来否则后面所有异常你都无法判断是环境问题还是代码问题# 前台带日志启动插件先不加载 sudo build-root/install-vpp-native/vpp/bin/vpp \ unix { nodaemon log /tmp/vpp.log cli-listen /run/vpp/cli.sock } # 另开一个终端 vppctl show version vppctl show plugins vppctl show node | head -30 vppctl show runtimeshow version确认版本和你编译的源码一致show plugins列出当前加载的插件及其路径这个命令后面排错会反复用show node能看出图里所有节点以及它们的 next 关系你自己的节点注册成功后一定会出现在这里show runtime给的是每个节点处理了多少向量、花了多少时钟压流量时看热点节点很直观。启动配置建议单独存一个startup.conf别用一长串命令行参数改起来容易漏unix { nodaemon log /tmp/vpp.log cli-listen /run/vpp/cli.sock } plugins { path /usr/lib/x86_64-linux-gnu/vpp_plugins plugin sample_plugin.so { enable } } buffers { buffers-per-numa 65536 }plugins段里path决定去哪找.soplugin xxx.so { enable }是显式白名单只有在白名单里的插件才会被加载。这个机制的好处是可控坏处是——你编出来的新.so放进目录却没写进白名单它会安安静静地不加载show plugins里什么都没有也不报错。这条后面还会单独说。buffers-per-numa是 buffer 池大小插件里如果缓存了包指针池子太小会导致包被回收后你手里的索引变成野指针压测前先把这个值调够。3. 插件最小骨架CMake 入口、注册宏与 init 函数骨架阶段的目标很具体一个.soshow plugins里能看到它show node里能看到它注册的节点CLI 能手工加载。这三件事都成立再往里填业务代码。很多人卡在第一步就想写收包逻辑结果分不清是加载失败还是节点没挂上排查成本翻倍。3.1 目录结构与 add_vpp_plugin 的最小闭环在src/plugins/下建目录四个文件起步构建脚本、插件元信息、节点头文件、节点实现。构建脚本用统一的宏别自己写add_library# src/plugins/sample_plugin/CMakeLists.txt add_vpp_plugin (sample_plugin SOURCES sample_plugin.c sample_node.c # 先不要打开下面这行除非你已经写好了 _x1/_x2 变体函数 # MULTIARCH_SOURCES # sample_node.c # 有 API 文件时再加本轮示例不需要 # API_FILES # sample.api )add_vpp_plugin会替你处理安装路径、编译选项、与主程序的符号共享方式你只需要列源文件。这里最值得提前说的是被注释掉的MULTIARCH_SOURCES一旦某个文件被列进去构建系统会按多微架构变体去编译它要求里面的热点函数提供_x1、_x2这类后缀版本普通写法直接链接失败。第一版插件永远先不写这一行等逻辑跑通、确认是性能瓶颈了再拆。节点头文件把后面要用的类型一次定义清楚省得反复改/* src/plugins/sample_plugin/sample.h */ #ifndef __included_sample_h__ #define __included_sample_h__ #include vnet/vnet.h #include vnet/ip/ip.h #define SAMPLE_PLUGIN_VERSION 1.0.0 /* 节点的 next 槽位0 走正常转发1 走丢弃 */ typedef enum { SAMPLE_NEXT_LOOKUP 0, SAMPLE_NEXT_DROP, SAMPLE_N_NEXT, } sample_next_t; typedef enum { SAMPLE_ERROR_NONE 0, SAMPLE_ERROR_N_COUNTERS, } sample_error_t; /* trace 里存的字段越小越好它跟着每个被 trace 的包走 */ typedef struct { u32 sw_if_index; } sample_trace_t; typedef struct { u32 sw_if_index; u64 packets; u64 bytes; } sample_sw_if_counter_t; typedef struct { vlib_main_t *vlib_main; vnet_main_t *vnet_main; u16 node_index; sample_sw_if_counter_t *sw_if_counters; } sample_main_t; extern sample_main_t sample_main; u8 *format_sample_trace (u8 *s, va_list *args); #endif /* __included_sample_h__ */sample_trace_t只放一个u32是刻意的trace 数据会跟着 buffer 走一段路字段越多内存压力越大抓大流量时甚至会拖慢转发。要输出更多信息宁可现场从 buffer 里重新算也不要往 trace 结构里塞。3.2 注册宏与 init 函数插件什么时候被叫醒插件文件干两件事告诉加载器“我是谁”以及提供一个初始化入口/* src/plugins/sample_plugin/sample_plugin.c */ #include vnet/vnet.h #include vnet/plugin/plugin.h #include vpp/app/version.h #include sample.h /* 元信息show plugins 里看到的就是这段版本号建议和自己的发版对齐 */ VLIB_PLUGIN_REGISTER () { .version SAMPLE_PLUGIN_VERSION, .description sample plugin: per-interface counter on ip4-unicast arc, }; sample_main_t sample_main; static clib_error_t * sample_init (vlib_main_t *vm) { sample_main_t *sm sample_main; sm-vlib_main vm; sm-vnet_main vnet_get_main (); sm-node_index ~0; /* 全局状态在这里初始化init 在整个进程生命周期里只会跑一次 */ sm-sw_if_counters NULL; vec_validate (sm-sw_if_counters, 0); return NULL; } VLIB_INIT_FUNCTION (sample_init);VLIB_PLUGIN_REGISTER()宏展开成一张静态描述表加载器扫描.so时读到的就是它VLIB_INIT_FUNCTION把sample_init挂进初始化链在节点开始处理包之前被调用。初始化链是有序的靠.runs_before/.runs_after约束默认顺序基本够用如果你依赖别的模块先初始化好比如要用它的表就得显式声明顺序否则会出现“表还没建好我就去查了”的偶发空指针。这里有个反直觉的点sample_init不是插件加载那一刻调用的而是 VPP 启动流程走到那一步才调用。所以手工plugin load一个插件时它的 init 会立刻被补跑一次但如果你在 init 里做了“只能做一次”的事比如注册只能注册一次的东西重复加载同一个插件就会出问题。设计上把 init 写成幂等的或者干脆只放内存分配和指针复位。3.3 编译、加载、确认插件真的活着增量编译只编插件比全量快得多改一轮代码几秒钟的事# 在全量构建过一次之后单独重编插件目录 make build-release # 找到产物 ls -l build-root/install-vpp-native/vpp/lib/*/vpp_plugins/sample_plugin.so # 运行中手工加载最快的验证方式 sudo vppctl plugin load \ /home/me/vpp/build-root/install-vpp-native/vpp/lib/x86_64-linux-gnu/vpp_plugins/sample_plugin.so # 确认三件事插件在、节点在、命令在 sudo vppctl show plugins sudo vppctl show node | grep sample sudo vppctl help | grep sample手工plugin load的价值在于它会把加载错误直接打在终端上比如缺少符号、版本不匹配、依赖的其它插件没先加载一眼就能看到。而写进startup.conf白名单的方式是静默失败——找不到、不匹配都只是不加载日志里可能只有一行不起眼的提示。show node里出现sample-node说明VLIB_REGISTER_NODE生效了节点进了图help里有sample enable说明 CLI 命令注册生效了。这两条都成立骨架就算立住了可以开始往里填收包逻辑。要是节点在但命令不在多半是VLIB_CLI_COMMAND那段被条件编译掉了要是都没在回去看.so是不是真被加载了。4. 写一个 Graph Node从取包到 next 节点完整闭环节点是整个插件里唯一按包执行的地方写错一行就是全量丢包。这一节按“注册 → 主循环 → 挂弧 → 可观测”四步走每一步都能单独验证不要一口气写完再跑。4.1 节点注册名字、下一跳表和错误码注册段是纯声明没有执行逻辑但字段填错会影响运行时行为/* src/plugins/sample_plugin/sample_node.c */ #include sample.h /* 错误码的字符串表示show node counters 里显示的就是它们 */ static char *sample_error_strings[] { [SAMPLE_ERROR_NONE] no error, }; u8 * format_sample_trace (u8 *s, va_list *args) { CLIB_UNUSED (vlib_main_t * vm) va_arg (*args, vlib_main_t *); CLIB_UNUSED (vlib_node_t * node) va_arg (*args, vlib_node_t *); sample_trace_t *t va_arg (*args, sample_trace_t *); s format (s, sample: sw_if_index %u, t-sw_if_index); return s; } VLIB_REGISTER_NODE (sample_node) { .name sample-node, /* 全局唯一重名会被直接拒掉 */ .vector_size sizeof (u32), /* 传的是 buffer index 数组 */ .format_trace format_sample_trace, .type VLIB_NODE_TYPE_INTERNAL, .n_errors SAMPLE_ERROR_N_COUNTERS, .error_strings sample_error_strings, .n_next_nodes SAMPLE_N_NEXT, .next_nodes { [SAMPLE_NEXT_LOOKUP] ip4-lookup, /* 弧的出口最终由弧决定 */ [SAMPLE_NEXT_DROP] error-drop, }, }; /* 把节点挂到 ip4-unicast 弧上位置在查表之前 */ VNET_FEATURE_INIT (sample_feature, static) { .arc_name ip4-unicast, .node_name sample-node, .runs_before VNET_FEATURES (ip4-lookup), };.name是你在show node、trace 输出、报错信息里看到的标识起名别用缩写后面查日志会感谢自己。.next_nodes里SAMPLE_NEXT_LOOKUP指向ip4-lookup看起来像写死了下一跳其实特征弧节点的实际下一跳是运行时用vnet_feature_next从弧上取的——这里填的是“弧走到头之后去哪”填错会导致包在弧尾掉进错误的处理分支。VNET_FEATURE_INIT才是挂载动作.runs_before决定你在弧里的位置。ip4-unicast弧在ip4-input之后、ip4-lookup之前放在这里意味着你能拿到已经解析好的 IP 头又能在查 FIB 之前改包这是绝大多数按包处理逻辑想要的时机。4.2 主循环从 frame 里取 buffer再还回去主循环是全篇唯一需要逐字理解的代码双层循环加vlib_get_next_frame/vlib_put_next_frame配对是固定套路static uword sample_node_fn (vlib_main_t *vm, vlib_node_runtime_t *node, vlib_frame_t *frame) { u32 n_left_from frame-n_vectors; u32 *from vlib_frame_vector_args (frame); sample_main_t *sm sample_main; u32 next0 SAMPLE_NEXT_LOOKUP; u32 *to_next; u32 n_left_to_next; while (n_left_from 0) { /* 向下游节点申请一块可以写入的 frame 空间 */ vlib_get_next_frame (vm, node, next0, to_next, n_left_to_next); while (n_left_from 0 n_left_to_next 0) { u32 bi0 from[0]; vlib_buffer_t *b0 vlib_get_buffer (vm, bi0); u32 sw_if_index0 vnet_buffer (b0)-sw_if_index[VLIB_RX]; /* ---- 业务逻辑只写这一段 ---- */ vec_validate (sm-sw_if_counters, sw_if_index0); sm-sw_if_counters[sw_if_index0].packets 1; sm-sw_if_counters[sw_if_index0].bytes b0-current_length; /* ------------------------------ */ if (PREDICT_FALSE (b0-flags VLIB_BUFFER_IS_TRACED)) { sample_trace_t *t vlib_add_trace (vm, node, b0, sizeof (*t)); t-sw_if_index sw_if_index0; } vnet_feature_next (next0, b0); to_next[0] bi0; from 1; to_next 1; n_left_from - 1; n_left_to_next - 1; } /* 把填好的 frame 交给下游配对调用漏了就是内存泄漏加丢包 */ vlib_put_next_frame (vm, node, next0, n_left_to_next); } return frame-n_vectors; } VLIB_REGISTER_NODE (sample_node_fn_reg) { .name sample-node, .function sample_node_fn, };几个参数的含义必须清楚。frame-n_vectors是这一批包的个数一批通常几十个别假设是固定值。vlib_frame_vector_args(frame)拿到的是 buffer index 数组的首地址指针往前推一格就换下一个包。vlib_get_next_frame里第一个 next 索引参数传的是“默认下一跳”真正决定去向的是循环体里的vnet_feature_next——它按接口上实际启用的特征链算出next0所以同一批包里不同接口的包可以走不同分支。n_left_to_next是下游 frame 还能塞多少它减到 0 或者上游包处理完内层循环就退出外层vlib_put_next_frame把已填的这段交代出去。vlib_buffer_t上的两个字段要顺手记住current_length是当前有效长度改包后要同步vnet_buffer(b0)拿的是附加在 buffer 尾部的元信息区sw_if_index[VLIB_RX]是入接口sw_if_index[VLIB_TX]是出接口。要动包头先vlib_buffer_advance或直接操作b0-data b0-current_data指向的地址改完长度一定要回填否则后续节点会按旧长度解析抓包看到的就是错位。4.3 挂到 ip4-unicast 特征弧上节点注册完只是进了图没有被任何接口引用一个包都不会流进来。特征弧是按接口开关的所以必须有一条 CLI 去做这件事static clib_error_t * sample_enable_command_fn (vlib_main_t *vm, unformat_input_t *input, vlib_cli_command_t *cmd) { vnet_main_t *vnm vnet_get_main (); u32 sw_if_index ~0; u8 enable 1; while (unformat_check_input (input) ! UNFORMAT_END_OF_INPUT) { if (unformat (input, disable)) enable 0; else if (unformat (input, %U, unformat_vnet_sw_interface, vnm, sw_if_index)) ; else return clib_error_return (0, unknown input %U, format_unformat_error, input); } if (sw_if_index ~0) return clib_error_return (0, interface required); vnet_feature_enable_disable (ip4-unicast, sample-node, sw_if_index, enable, 0, 0); return 0; } VLIB_CLI_COMMAND (sample_enable_command, static) { .path sample enable, .short_help sample enable interface [disable], .function sample_enable_command_fn, };vnet_feature_enable_disable四个关键参数弧名、节点名、接口索引、开还是关。后两个0, 0是特征配置指针和长度纯计数用不到传空即可。接口名解析靠unformat_vnet_sw_interface所以命令行里写GigabitEthernet0/8/0这种名字就行不用记索引。一个容易被忽略的前提接口上必须先启用 IP 转发ip4-unicast弧才会被走到。只配了 IP 地址但没set interface ip相关设置包会走别的路径你的节点自然安静。验证顺序是接口 up、IP 配好、sample enable打开、然后才 ping。4.4 trace 与 CLI让节点自己说话节点能跑了接下来要能看见它在干什么。两条腿trace 看单包CLI 看累计static clib_error_t * sample_show_command_fn (vlib_main_t *vm, unformat_input_t *input, vlib_cli_command_t *cmd) { sample_main_t *sm sample_main; u32 sw_if_index; vlib_cli_output (vm, %-24s %14s %16s, interface, packets, bytes); vec_foreach_index (sw_if_index, sm-sw_if_counters) { sample_sw_if_counter_t *c sm-sw_if_counters[sw_if_index]; if (c-packets 0) continue; vlib_cli_output (vm, %-24U %14llu %16llu, format_vnet_sw_if_index_name, vnet_get_main (), sw_if_index, c-packets, c-bytes); } return 0; } VLIB_CLI_COMMAND (sample_show_command, static) { .path show sample, .short_help show sample, .function sample_show_command_fn, };vec_foreach_index遍历的是稀疏数组没流量的接口直接跳过别在大表上逐条打印。format_vnet_sw_if_index_name把索引转回接口名输出对得上人眼。注意这个计数器是节点自己维护的和show node counters里的框架计数是两套数据压测时两边对不上往往是你漏了某条分支——这恰恰是很有用的交叉验证。trace 那一段在 4.2 里已经埋好用起来是这样# 对入接口抓 50 个包然后发流量 sudo vppctl trace add af-packet-input 50 # 或者用你实际的驱动节点名show node 里能看到 sudo vppctl ping 192.0.2.2 repeat 5 sudo vppctl show traceshow trace里每个包会列出经过的节点序列走到sample-node那一行显示的就是format_sample_trace的输出。注意trace 有开销正式压测一定先clear trace并确认没在抓否则测出来的数字没有参考价值。5. 避坑与排查插件加载、挂弧、丢包的五个现场下面五条都是我在真实调试里反复撞到的按“现象 → 原因 → 解决”记遇到时直接对号入座比从头猜快得多。5.1 现象.so编出来了show plugins里没有原因通常是三种之一路径不对——plugins { path ... }指向的是系统安装目录而你编出来的.so还在build-root下白名单没放行——plugin段里没有写你的插件名加载器直接忽略ABI 不匹配——.so是用一套源码编的跑的是另一套vpp二进制符号对不上加载静默失败。解决顺序是先用plugin load 绝对路径手工加载把错误信息逼出来看到符号缺失就说明版本对不上回去用同一棵树重新编手工加载成功再写进startup.conf。注意白名单模式下的失败不写显眼日志别指望翻日志能找到直接手工加载最快。5.2 现象节点在show node里有但一个包都不进最常见的原因是忘了在接口上启用特征VNET_FEATURE_INIT只是把节点挂到弧上具体哪个接口走这条弧由vnet_feature_enable_disable决定没调用就是零流量。其次是弧选错了——你的流量走的是ip6-unicast或者二层桥接路径挂在ip4-unicast上自然收不到。第三是.runs_before里写的节点本文还有配套的精品资源点击获取