简介:《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 的性能承诺从哪来
很多人第一次翻 OVS(Open 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 处理 |
| L3 | IPv4/IPv6 地址、ARP、MPLS 标签栈 | 三层转发、隧道内外层区分 |
| L4 | TCP/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_actions:action 是指令流,不是单个转发动作
一条流表项的 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里最显眼的是三个成员:ti(table_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_entry:per-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_packet:upcall 消息的物理布局
查表失败时,内核要把包和 key 一起送到用户态。入口是ovs_dp_upcall,最终由queue_userspace_packet构造消息:
| 层次 | 内容 | 说明 |
|---|---|---|
nlmsghdr | netlink 消息头 | 长度、类型、序列号 |
genlmsghdr | generic netlink 头 | 命令号、版本 |
ovs_header | OVS 自定义头 | 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,单线程意味着同一时刻只能等一个 socket,miss 一多就排长队。多线程各持一个 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_dpif |
ofport | 交换机上的一个端口 | ofport_dpif |
ofrule | 一条 OpenFlow 规则 | rule_dpif |
ofgroup | 一组动作(OpenFlow 1.1+) | group_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_class | 16 bit | 字段所属的类别 |
oxm_field | 7 bit | 具体是哪个字段 |
oxm_length | 8 bit | 值的长度 |
hasmask | 1 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 |
本文还有配套的精品资源,点击获取