OVS快慢路径:内核datapath流表匹配与upcall机制
简介《OpenvSwitch源码阅读笔记》是一份面向SDN与虚拟交换初学者的源码导读PDF围绕OVS 2.3.90版本梳理ovs-vswitchd、ovsdb-server、ofproto、ofproto-dpif、netdev及datapath等核心模块帮助读者理解虚拟交换机如何与OpenFlow控制器协同完成流表下发与数据包转发。资源包仅含1个PDF文件大小约1012KB轻量便于随查随读。已有977人学习说明其对入门阅读OVS源码具有一定参考价值。笔记重点展开datapath初始化、收包处理、流表哈希桶与流表创建流程并配合数据流向和关键数据结构示意适合需要建立OVS整体架构认知、跟进内核态与用户态交互路径的读者用作导读提纲。1. 慢路径与快路径OVS 的性能承诺从哪来很多人第一次翻 OVSOpen vSwitch的源码会默认它的转发能力来自用户态的ovs-vswitchd——毕竟 SDN、OpenFlow 控制器、流表下发这些概念都挂在用户态进程上。实际情况正好相反用户态只在一条流的第一个包上出场把这条流的处理方案算清楚、编译成 datapath 能认识的动作列表之后所有同类型的包都在内核里完成匹配和转发ovs-vswitchd更接近一个规则编译器而不是转发引擎。理解了这条快慢分离的主线再看 datapath 的哈希桶、mask 缓存、upcall 线程池就不会觉得它们是一堆孤立的函数而是同一个设计意图的不同侧面把“思考”留给用户态把“重复劳动”压进内核。2. datapath 内核模块一个包从 vport 进来到流表匹配前发生了什么2.1 初始化只做三件事注册 netlink 家族、建 datapath、挂收包回调内核模块的入口是module_init(dp_init)。这个函数看起来平淡但它决定了后面所有用户态工具的通信方式datapath 通过 generic netlink 暴露自己的能力ovs-vswitchd、ovs-dpctl这些工具才有办法跟内核对话。笔记里提到初始化时注册了四个命令类型按 OVS 的一贯做法这四类命令对应 datapath 的 new / del / get / set 一组操作——创建、销毁、查询、设置flow 和 vport 各自也有自己的 netlink family结构上是同一套模式复制三份。真正让包进入 OVS 的是 vport 上挂的收包回调。一个 vport 就是虚拟交换机的一个端口它背后可能是一块物理网卡、一个内核 tap 设备或者一个 internal 类型的虚拟接口。常见实现是给底层的net_device注册一个接收处理函数包从网卡驱动上来后先被这个回调截住然后走一条固定的调用链/* 按 2.3.90 的函数名整理的收包主线省略参数细节 */ static void netdev_frame_hook(struct sk_buff *skb) { struct vport *vport ovs_netdev_get_vport(skb-dev); /* 端口已经被删除的极端情况直接丢弃不要让包继续往上走 */ if (unlikely(!vport)) { kfree_skb(skb); return; } netdev_port_receive(vport, skb); } void netdev_port_receive(struct vport *vport, struct sk_buff *skb) { /* 这里还会做一层合法性检查比如包长、端口状态 */ ovs_vport_receive(vport, skb, tun_info); } void ovs_vport_receive(struct vport *vport, struct sk_buff *skb, const struct ip_tunnel_info *tun_info) { struct sw_flow_key key; /* 第一步永远是抽 key把变长报文压成定长匹配域 */ ovs_flow_key_extract(tun_info, skb, key); /* 第二步才是查表key 是后面所有逻辑的唯一输入 */ ovs_dp_process_packet(skb, key); }这段代码的意义在于顺序先抽 key再查表而不是边查边解析。这样做的代价是每个包都要完整走一遍包头解析收益是后面的匹配环节可以完全脱离报文格式只对着一块定长内存做哈希和掩码比较。内核里做判断最怕的就是分支不可预测把变长解析集中到一个函数里剩下的路径就能保持紧凑。2.2 sw_flow_key把变长报文压成定长匹配域sw_flow_key是整个 OVS 数据面最重要的数据结构没有之一。它的组织方式是分层的按协议栈从下往上排层次字段来源作用元数据in_port、skb_mark、recirc_id、priority不来自报文本身来自端口和上一轮处理L2以太类型、源/目的 MAC、VLAN TCI二层转发和 VLAN 处理L3IPv4/IPv6 地址、ARP、MPLS 标签栈三层转发、隧道内外层区分L4TCP/UDP 端口、ICMP 类型码精细匹配与 ACL隧道tun_id、tun_flags、tun_opts隧道解封装后的标识把这张表和“定长结构体”这个实现选择放在一起看就明白设计意图了内核里做流表匹配本质上是对两块内存做带掩码的比较字段越是固定在结构体里的偏移上比较代码越能写成循环加偏移不需要每匹配一个字段就跳一次解析。代价是扩展性——每加一个新协议或新匹配域都要动这个结构体的定义用户态和内核态的 ABI 也得同步。这也是为什么 OVS 的 key 结构在不同版本之间经常发生变化跨版本对接时两边版本必须对齐。2.3 ovs_dp_process_packet一次查表两种结局抽完 key控制权交给ovs_dp_process_packet。这个函数短得不像核心逻辑但它清楚地划出了快慢路径的分界线/* 查表 分流逻辑骨架 */ void ovs_dp_process_packet(struct sk_buff *skb, struct sw_flow_key *key) { struct datapath *dp ovs_skb_get_dp(skb); struct sw_flow *flow; struct dp_stats_percpu *stats; /* 用 key 算哈希先定位桶再在桶里比 */ flow ovs_flow_tbl_lookup_stats(dp-table, key, stats); if (unlikely(!flow)) { /* 没命中唯一出口是把包和 key 一起丢给用户态 */ ovs_dp_upcall(dp, skb, key, NULL, 0); return; } /* 命中动作列表直接执行全程不回到用户态 */ ovs_execute_actions(dp, skb, flow-sf_acts-actions, key); }这段代码里有三个值得注意的点。第一unlikely(!flow)不只是给编译器看的提示它在语义上就是“异常分支”——正常情况下流表应该早就被用户态灌好了miss 属于需要解释的事件。第二统计计数是查表过程顺带完成的不是单独一次遍历这在流量大的时候能省掉大量重复扫描。第三命中之后执行的是flow-sf_acts-actions也就是说 action 列表是跟着流表项一起存在内核里的不是每次执行时重新解释 OpenFlow 报文。2.4 do_execute_actionsaction 是指令流不是单个转发动作一条流表项的 action 可以有很多个它们按顺序执行前面动作对包做的修改会影响后面动作看到的内容。入口函数do_execute_actions是一个对 netlink 属性数组的循环/* 动作执行骨架各分支只保留关键处理 */ static int do_execute_actions(struct datapath *dp, struct sk_buff *skb, const struct nlattr *attr, int len) { for (; len 0; len - nla_len(attr), attr nla_next(attr, len)) { switch (nla_type(attr)) { case OVS_ACTION_ATTR_OUTPUT: /* 从指定端口发出这是最常见的终点动作 */ do_output(dp, skb, nla_get_u32(attr), key); break; case OVS_ACTION_ATTR_PUSH_VLAN: /* 压 VLAN 头会改变 skb 的线性区 */ push_vlan(skb, nla_get_u16(attr)); break; case OVS_ACTION_ATTR_POP_VLAN: pop_vlan(skb); break; case OVS_ACTION_ATTR_RECIRC: /* * 不能在这里递归调用一次查表包已经被前面的动作改过 * 而且当前还在动作循环的栈里。先挂到 action_fifos 延迟处理。 */ add_deferred_action(action_fifos, skb, key, recirc_id); break; case OVS_ACTION_ATTR_SET: execute_set_action(skb, key, attr); break; default: break; } } }RECIRC这个分支最能说明设计上的取舍。如果允许在动作执行过程中直接递归查表栈深度就不可控了而且当前包还在被外层动作持有中途插入一次完整的匹配会带来状态一致性问题。所以实现上选择把 recirc 请求挂进一个延迟动作队列等这一轮动作全部执行完再统一拿出来重新匹配。这解释了后面避坑章节里的一个现象抓包时看到 recirc 之后的行为顺序和预期不一致不是丢包是排队。到这一步快路径的闭环就完整了包进来、抽 key、查表、执行动作、从某个 vport 出去。整条路径上没有一次用户态切换。3. 流表查询哈希桶、flex_array 和 mask 缓存为什么缺一不可3.1 flow_table 的三个指针对应三种生命周期的数据struct flow_table里最显眼的是三个成员titable_instance、mask_cache、mask_array。它们不是随手拆出来的而是按“变更频率”分的层成员存什么变更频率并发访问方式ti哈希桶和流表项每条新流一次RCU 替换mask_array所有通配掩码极低通常只在启动或重配置时RCU 读mask_cache最近命中的掩码下标每次查表都可能更新per-CPU无锁一条流表项由 key、mask 指针、action 列表和哈希值组成。这里的关键设计是mask 不内嵌在流里而是单独存一份流只持有指针。同一个掩码会被成百上千条流共用比如“只匹配目的 IP 前缀”这种规则如果每条流都复制一份掩码内存会成倍上涨而且比较时还要多一次不必要的拷贝。3.2 alloc_buckets 与 flex_array内核里为什么要自己造数组哈希桶的初始化函数是alloc_buckets它没有直接用kmalloc申请一大块数组而是走了一个叫flex_array的结构/* flex_array 的核心字段用于把大数组切成页来管理 */ struct flex_array { int element_size; /* 单个元素多大 */ int total_nr_elements; /* 元素总个数 */ struct reciprocal_value reciprocal_elems; /* 用乘法代替除法 */ struct flex_array_part *parts[]; /* 每页一个 part按需分配 */ };这个结构解决的是内核里的老问题数组一大连续物理内存就申请不到即使申请到了也容易造成碎片。flex_array把数组切成按页对齐的若干块parts[]里每个指针指向一页页内按element_size切分实际元素位置通过“第几个元素 ÷ 每页能放几个”算出来。这里reciprocal_elems就是为这个除法准备的——内核里整数除法开销不小提前算好一个倒数值运行时用乘法和移位代替除法是被高频调用的代码里常见的做法。桶数量的下限由TBL_MIN_BUCKETHS这类常量控制具体取值随版本有变化但思路一致太少会退化成链表遍历太多会浪费内存所以起步给一个够用的数随着流表增长再扩容。扩容时不是原地搬而是重新申请一套table_instance把旧表项一条条挪过去最后用 RCU 把指针换掉这样正在查表的 CPU 不会被阻塞。3.3 mask 与 flow 分表存放把共享的掩码从每条流里剥离先说清楚 mask 在匹配里的角色。一条流表项写的可能是“目的地址是 10.0.0.0/24 的包”但内核里存不了“/24”这种表达它存的是“值 掩码”两段数据值是 10.0.0.0掩码是 255.255.255.0。匹配时对 key 做(key ^ value) mask 0的判断成立就算命中。如果每条流都带一份完整的 mask会带来两个问题。一是内存一个sw_flow_mask加上 key 和 range 并不小几千条流下来就是可观的开销。二是缓存局部性查表时要在流表项和掩码之间来回跳。所以实现上把 mask 抽出来单独维护在mask_array里流表项只保留一个下标。查表的顺序因此变成先确定用哪个 mask再用这个 mask 去对应的桶里找 key。这个顺序不是随便定的。同一个 mask 下的流哈希桶是共享的先定 mask 再找流等于把搜索空间从“所有流 × 所有掩码”压缩成“一个掩码下的所有流”。这是掩码匹配类数据结构里非常经典的一步优化。3.4 mask_cache_entryper-CPU 缓存如何跳过整表扫描即使压缩了搜索空间每次查表都把所有 mask 扫一遍仍然不可接受因为实际场景里绝大多数包会反复命中同几个掩码。于是有了mask_cache/* 缓存项记录的是一组能唯一定位掩码的输入 */ struct mask_cache_entry { u32 skb_priority; /* 流表优先级 */ u32 recirc_id; /* 重循环上下文 */ u16 in_port; /* 入端口 */ u16 mask_index; /* 命中后缓存的掩码下标 */ u32 hash; /* 对应流的哈希用于快速校验 */ };它被声明成__percpu也就是每个 CPU 一份这样查表和填表都不需要加锁避免了多核场景下最讨厌的缓存行争用。查表时先用 priority、recirc_id、in_port 这组信息算出一个位置取出缓存的 mask_index直接到mask_array里拿对应掩码再调masked_flow_lookup做真正的比较。缓存未命中怎么办不是简单回退到整表遍历而是采用分段的方式把 mask 数组按段组织先定位到可能命中的段再在段内顺序比较。这个设计对“掩码很多但热点集中”的负载很有效段的数量和长度是编译期和运行期共同决定的具体阈值随版本调整但意图是让最坏情况也比全表扫描便宜。3.5 masked_flow_lookup掩码比较的代价与 range 的意义最后的比较落在masked_flow_lookup上。它拿到的输入是 key、mask 和桶链表做的是逐项比较。这里有个细节值得展开sw_flow_mask里除了 key 和 mask还有一个sw_flow_key_range记录这个掩码实际覆盖的字段范围。有了 range比较就不用从头到尾把整个 key 结构体过一遍。比如一个只匹配 IP 五元组的掩码它的 range 就落在 L3 和 L4 那一段L2 和隧道字段根本不用碰。在高性能路径上这种“少比较几个字节”的收益会被放大——每个包都要走一次省下来的就是实打实的 CPU。总结这一章的逻辑链flex_array解决“大数组怎么放得下”mask 独立存放解决“掩码怎么共享”per-CPU cache 解决“怎么少扫几遍”range 解决“怎么少比几个字段”。四层优化叠起来才有内核里那条能跑到线速的查表路径。4. upcall 与 udpif一条 MISS 报文如何在用户态被翻译成内核动作4.1 queue_userspace_packetupcall 消息的物理布局查表失败时内核要把包和 key 一起送到用户态。入口是ovs_dp_upcall最终由queue_userspace_packet构造消息层次内容说明nlmsghdrnetlink 消息头长度、类型、序列号genlmsghdrgeneric netlink 头命令号、版本ovs_headerOVS 自定义头datapath 标识OVS_PACKET_ATTR_KEY展开后的 key每个字段是一个OVS_KEY_ATTR_xxx嵌套属性OVS_PACKET_ATTR_USERDATA用户数据动作级 upcall 时携带附加信息OVS_PACKET_ATTR_PACKET原始报文通过skb_zerocopy映射不实际拷贝这张表里最容易被忽略的是最后一行。如果每次 upcall 都把整个包拷贝一遍在 miss 率高的场景下内存带宽会被吃掉一大块。skb_zerocopy的做法是把 skb 的数据页映射进 netlink 消息接收方读的时候才真正访问内存。代价是引用计数和生命周期管理变复杂了——skb 不能马上释放必须等用户态读完。key 的展开方式也值得说一句。sw_flow_key在内核里是一块连续结构体但到了 netlink 消息里必须变成自描述的 TLV 属性。这个转换不是免费的所以它只发生在 upcall 路径上快路径里永远是结构体直接比较。这又是一次“快慢分离”的体现慢路径可以容忍格式转换和协议开销快路径一纳秒都不想多花。4.2 udpif 线程池为什么 handler 数量是可配的消息到了用户态接收方不是某个单独线程而是一个线程池叫 udpif。初始化函数是udpif_set_threads它转调udpif_start_threads/* 线程池初始化逻辑骨架 */ void udpif_start_threads(struct udpif *udpif, size_t n_handlers, size_t n_revalidators) { /* 每个 handler 一个接收上下文避免共享锁 */ udpif-handlers xcalloc(n_handlers, sizeof *udpif-handlers); for (i 0; i n_handlers; i) { struct handler *handler udpif-handlers[i]; /* 每个线程独立跑 upcall_upcall_handler内部自己 epoll_wait */ ovs_thread_create(handler, udpif_upcall_handler, udpif, handler); } }为什么不是一个线程因为每个 handler 内部的dpif_netlink_recv__是阻塞式的epoll_wait单线程意味着同一时刻只能等一个 socketmiss 一多就排长队。多线程各持一个 netlink socket各自等待、各自解析天然并行。为什么不是越多越好线程多了会带来调度开销和缓存失效而且真正限制吞吐的往往是后面的查表和下发环节不是接收。所以 handler 数量做成了可配项由配置决定。线程内部的流程是一条直线recv_upcalls→dpif_recv→dpif_netlink_recv→parse_odp_packet把ofpbuf转成dpif_upcall→odp_flow_key_to_flow把内核 key 转回用户态的 flow 结构→upcall_receive→flow_extract→process_upcall→classify_upcall→ 最终进handle_upcalls在那里批量调dpif_operate把结果一次性推回内核。批处理这个细节值得单独拎出来说。逐条下发意味着每条 miss 一次系统调用miss 一密集syscall 开销就盖过了计算本身。攒一批再下发能显著降低这个成本但会引入微小延迟——又是一个典型的吞吐与延迟的取舍。4.3 upcall_xlate慢路径真正做决策的地方真正决定“这个包该怎么处理”的是upcall_xlate。它做的事情可以概括为三步找到匹配的规则、翻译成内核动作、决定要不要通知控制器。/* 慢路径翻译骨架 */ static void upcall_xlate(struct udpif *udpif, struct upcall *upcall) { struct xlate_in xin; struct xlate_out xout; xlate_in_init(xin, upcall-ofproto, ..., upcall-flow, ...); /* 在 ofproto 的流表里查 rule这一步用的是用户态的规则结构 */ xin.rule rule_dpif_lookup(upcall-ofproto, upcall-flow, ...); /* 把 OpenFlow 的 action 翻译成 datapath 能执行的动作 */ xlate_actions(xin, xout); if (xout.fail_open) { /* 连不上控制器也要保证转发先把包送上去再说 */ ofproto_dpif_send_packet_in(...); } if (xout.slow) { /* 需要走慢速路径的动作补一组 datapath action */ compose_slow_path(..., xout); } if (upcall-type DPIF_UC_MISS) { /* 缓存这次的翻译结果下次同类型的包就不用来用户态了 */ ukey_create_from_upcall(upcall, xout); } }rule_dpif_lookup这一步调用把用户态和内核态的规则体系接上了内核里是sw_flow用户态里是rule_dpif两者不是一一对应但语义上一致。xlate_actions里再调do_xlate_actions把OUTPUT、SET、PUSH_VLAN、GOTO_TABLE这类 OpenFlow 动作逐条翻译成OVS_ACTION_ATTR_*的形式。ukey_create_from_upcall是整个快慢分离机制的收口。它把这次翻译出的动作序列缓存成一条 upcall key通过dpif_operate推回内核内核据此生成正式的流表项。下一次同类型的包进来ovs_flow_tbl_lookup_stats直接命中连用户态的边都不用沾。所谓“第一个包慢后面都快”实现上就是这一段。4.4 ofproto 分层ofconn/ofservice 与 OpenFlow 接入慢路径要用到的规则来自 OpenFlow 控制器或者本地ovs-ofctl下发的流表。这条链路的入口在 ofproto 层。ofproto 定义了一套抽象接口具体实现是ofproto_dpif两者之间通过ofproto_class这层函数指针表解耦。整个分层里有四组对象各自对应一个“向上”结构体抽象对象代表什么具体实现ofproto一台 OpenFlow 交换机ofproto_dpifofport交换机上的一个端口ofport_dpifofrule一条 OpenFlow 规则rule_dpifofgroup一组动作OpenFlow 1.1group_dpif创建流程把这条链串了起来bridge_init_ofproto先调各实现类的init把类型登记到全局表里bridge_reconfigure触发ofproto_create后者通过ofproto_class_find__找到ofproto_dpif_class调alloc分配对象、connmgr_create建连接管理器、construct做具体初始化。construct里最关键的一步是open_dpif_backer它打开 dpif、创建 udpif把用户态和内核态的数据通道建立起来。之后再init_ports逐个打开端口ofport_open→netdev_open最终走到dpif_netlink_port_add把端口配置下到内核 datapath。OpenFlow 连接本身分成两个方向。主动方向由ofconn负责调用链是connmgr_set_controllers→add_controller→ofconn_create→rconn_connect→vconn_open→tcp_open→socket/connect一条 TCP 连接就这么建起来。被动方向由ofservice负责ofservice_create→pvconn_open→punix_open→bind/listen等控制器连过来时accept出新连接再包成新的ofconn塞进all_conns链表。两者最终都汇到connmgr由它统一调度。下一次connmgr_run循环时ofconn_run就会处理这个连接上的收发。4.5 flow_mod一条规则从线缆到 ofrule 要过几道校验控制器下发规则用的是flow_mod消息结构上分四段OpenFlow 消息头、flow_mod固定字段、match 字段、instruction 字段。前三段负责“匹配什么”最后一段负责“匹配上之后干什么”。match 字段的解析入口是ofputil_pull_ofp11_match核心在nx_pull_raw。现在的匹配域基本都用 OXM 格式也就是 TLV字段长度含义oxm_class16 bit字段所属的类别oxm_field7 bit具体是哪个字段oxm_length8 bit值的长度hasmask1 bit后面是否跟掩码value / mask变长实际的值和掩码解析函数nx_pull_entry__在每个 TLV 上做四件事任何一件不过就整条消息拒绝mf_are_prereqs_ok依赖检查。比如要匹配 TCP 端口前面的 IP 协议号必须先匹配成 TCP否则这条规则语义上不成立。mf_is_all_wild重复检查。同一个字段出现两次后面那次会覆盖前面那次属于典型的配置错误。mf_is_mask_valid/mf_is_value_valid合法性检查。掩码不能出现不连续位值不能超出字段范围。mf_set把通过校验的 value 和 mask 写进 flow 结构。instruction 的解析是另一套机制入口ofpacts_pull_openflow_instructions。它先用decode_openflow11_instructions把所有 instruction 解析到一个数组里数组长度由OVS_INSTRUCTIONS这个宏推导出来正好六种instruction 类型处理动作METER挂一个限速器APPLY_ACTIONS本文还有配套的精品资源点击获取