拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Open vSwitch源码阅读:从datapath到OpenFlow的完整路径解析

Open vSwitch源码阅读:从datapath到OpenFlow的完整路径解析

简介:Open vSwitch 源码阅读笔记围绕 OVS 2.3.90 版本重点模块的源码实现做系统梳理,适合刚接触 OVS 源码的读者作为入门指引。资源为单个 PDF 文档,大小约 1012KB,便于携带查阅,目前已有 975 人学习下载。内容从 OVS 网络架构入手,梳理了 ovs-vswitchd、ovsdb-server、ovs-vsctl 等核心组件的职责分工,并深入分析了 datapath 内核模块的数据包处理流程、流表哈希桶结构、流表创建过程等关键路径;同时涵盖 ofproto 接口层、netdev 设备抽象以及 OpenFlow 协议的应用机制,能够帮助读者理解数据包从接收、流表匹配到动作执行的完整流程。这份笔记以图文结合的方式呈现源码调用流程和数据结构,适合网络方向学生、SDN 研究者以及需要定制或调试 OVS 的开发者参考使用。

1. 初读OVS源码:这份2.3.90阅读笔记解决什么

做网络虚拟化或者SDN控制面工作的工程师,大概率都经历过打开Open vSwitch源码却不知道从哪里下手的阶段。这份《OpenvSwitch源码阅读笔记》基于2.3.90版本,把OVS最核心的几条代码路径拆开讲了一遍:内核datapath的收包与流表查询、用户态vswitchd的主循环、ofproto接口抽象、OpenFlow协议的match和instruction解析。它不是逐行注释,而是沿着“数据包从端口进来、查流表、执行action、没命中就upcall到用户态”这条主线走的。适合刚接触OVS源码、想搞清楚虚拟交换机内部到底怎么工作的初级到中级读者,也能帮想深入做二次开发的人快速建立地图,省掉大量翻代码的时间。

2. 内核datapath:收包、流表哈希与upcall机制

datapath是OVS的内核模块,负责实际的数据包处理。源码阅读顺序建议从这里开始,因为用户态代码依赖内核的数据结构和行为约定,先理解底层再往上看会比较顺。

2.1 数据流向对比

先明确OVS介入后的数据路径。没有OVS时,网卡eth0收到报文后判断走向:本地报文送用户态,转发报文按路由或交换逻辑从eth1出去。有OVS时,报文从eth0进入后不直接走Linux协议栈,而是进入ovs的vport,根据提取的key做流表匹配,匹配成功执行动作,失败则通过upcall送用户态处理。

理解这条路径后,再看代码就走得通了。收包处理的入口链是vport注册的netdev_frame_hook()往下传递的:

// datapath/dev.c 起点的收包回调链 static int netdev_frame_hook(struct sk_buff *skb) { skb_push(skb, ETH_HLEN); // 把skb的data指针退回以太网头部起点 return netdev_port_receive(skb); // vport统一入口 } int ovs_vport_receive(struct vport *vport, struct sk_buff *skb) { struct sw_flow_key key; ovs_flow_key_extract(skb, &key); // 从skb提取流表key ovs_dp_process_packet(skb, &key); // 进入流表匹配与action执行 }

skb_push(skb, ETH_HLEN)是为了在进入vport前把报文头部完整暴露给key提取逻辑,否则偏移会少掉以太网头;ovs_flow_key_extract()生成的是一个定长加变长的复合结构,后面流表比较都基于它。这里有个值得留意的点:key提取是每个包都要做的,所以它性能好不好直接决定OVS的吞吐上限。

2.2 流表哈希桶与收包处理

流表不是线性数组,而是哈希桶加链表。笔记里明确提到了flex_array的分配策略:先用alloc_buckets()初始化哈希桶,桶内元素空间按页分配,超过FLEX_ARRAY_BASE_BYTES_LEFT后再动态扩展。

实现层面需要关注这几个数据结构:

struct table_instance { struct flex_array *buckets; // 哈希桶本体,元素动态扩展 unsigned int n_buckets; // 桶数量 }; struct sw_flow { struct hlist_node node; // 挂入哈希桶链表的节点 struct sw_flow_key key; // 完整的key内容 struct sw_flow_mask *mask; // 当前生效的mask,决定匹配范围 u32 hash; // 哈希值,用于快速比较 };

哈希桶初始化时,element_size、hlist_head大小、每页元素个数会参与计算。实际阅读中建议你画一张图:一页空间里先是若干个hlist_head,再跟着元素区,元素区不够用就申请新的flex_array part。这个结构对理解后面的masked_flow_lookup()很有帮助,因为查找时hash命中只是第一步,后续的mask匹配才是开销大头。

2.3 流表查询与mask缓存

流表查询入口是ovs_flow_tbl_lookup_stats(),但它真正花心思的地方在mask的缓存策略。每个mask可能对应大量流表项,逐个比较代价很高,所以OVS引入了mask_cache_entry,把最近命中的mask缓存下来,下次直接从缓存找:

// 流表查询的缓存加速逻辑 static struct sw_flow *ovs_flow_tbl_lookup_stats(...) { // 第一次:查percpu的mask_cache_entry cache = &mask_cache[skb_get_hash(skb) & MASK_CACHE_MASK]; if (cache->mask) { flow = masked_flow_lookup(ti, key, cache->mask); if (flow) { ... return flow; } } // 缓存未命中,走完整mask遍历 flow = flow_lookup(ti, mask_array, key, &n_mask_hit); // 找到后回填cache供后续包使用 }

mask_cache是按CPU核分开的,每个核维护自己的缓存条目,减少锁竞争。masked_flow_lookup()里比较key时用sw_flow_key_range限定比较区间,只比mask为1的部分,不是整块key做memcmp,这也是性能优化的关键点。如果你在后面做流表性能调优,多半会动到这个逻辑,比如调整缓存桶数量或者分段比较的粒度。

2.4 action处理与upcall

action执行入口是do_execute_actions(),根据nla_type()拿到动作类型后分派。笔记里列出的类型值得整理成对照表:

action类型处理函数行为
OVS_ACTION_ATTR_OUTPUTdo_output()发往指定端口
OVS_ACTION_ATTR_USERSPACEoutput_userspace()上传用户态
OVS_ACTION_ATTR_HASHexecute_hash()更新skb hash
OVS_ACTION_ATTR_PUSH_VLANpush_vlan()封装VLAN头
OVS_ACTION_ATTR_POP_VLANpop_vlan()剥除VLAN头
OVS_ACTION_ATTR_RECIRC添加deferred_action二次进入流水线
OVS_ACTION_ATTR_SETexecute_set_action()修改报文/元数据
OVS_ACTION_ATTR_SAMPLE概率上报sflow样本

RECIRC值得一提,它把deferred action放进全局数组,实现报文重新进入流表处理,是OpenFlow流水线多级表的核心支撑。如果没有多级流表需求,这个动作平时用不太到,但理解它有助于看懂后面upcall的处理逻辑。

当流表没命中时,内核走ovs_dp_upcall()发送netlink消息到用户态。消息体三段结构:OVS_PACKET_ATTR_KEY携带flow key,OVS_PACKET_ATTR_USERDATA携带用户态下发的元数据,OVS_PACKET_ATTR_PACKET携带完整报文。数据部分用skb_zerocopy()拷贝,避免大包复制造成的性能损耗。upcall是高并发场景的主要瓶颈,观察它可以用这个命令:

# 查看datapath的统计信息,关注miss数 ovs-dpctl show # 查看当前流表,loc=ovs 表示用户态流表 ovs-dpctl dump-flows

ovs-dpctl show输出的miss计数能直接看出有多少包没命中流表走了upcall路径。如果这个数字持续增长,要么流表老化太快,要么规则没下发到位,这是排查控制器下发问题时第一步要看的数据。

3. 用户态vswitchd与ofproto:配置如何下发到内核

看完内核datapath,再看用户态就清楚它要干什么了。vswitchd是OVS的主守护进程,它在ovsdb、OpenFlow控制器和内核datapath三者之间做翻译和调度。

3.1 vswitchd主循环

从main()函数看整体流程是最快的,vswitchd的主循环结构如下:

// vswitchd/ovs-vswitchd.c 主循环骨架 int main(int argc, char *argv[]) { ... daemonize_start(); // 转为守护进程 unixctl_server_create(unixctl_path, &unixctl); // 建ovs-appctl控制通道 bridge_init(remote); // 建立到ovsdb-server的JSON-RPC连接,初始化网桥模型 for (;;) { bridge_run(); // 核心业务:处理网桥增删、端口配置、ofproto运行 unixctl_server_run(unixctl); // 处理ovs-appctl发来的命令 netdev_run(); // 通过netlink获取netdev状态并更新 poll_block(); // 阻塞,等待注册的事件或超时 } }

bridge_run()内部调用ofproto_run()、connmgr_run()、bridge_run__()等,涉及数据库读取和ofproto层交互。用ovs-appctl可以和这个循环实时交互,排查状态时常用:

# 查看vswitchd当前版本与运行状态 ovs-appctl version # 查看网桥端口映射,确认netdev是否正常 ovs-appctl dpif/show # 查看当前所有注册的接口类 ovs-appctl dpif-netdev/if-not-found

ovs-appctl dpif/show的输出里能直接看到每个端口对应的dpif索引和类型,如果某端口状态异常,这里会显示不出来或者错误信息。这是定位“配置了端口但流量不通”的第一现场。

3.2 ofproto接口抽象与创建流程

ofproto层是OVS用户态最核心的抽象。它定义了ofproto_class接口,具体实现是ofproto_dpif_class,底层再通过dpif_class对接内核datapath。笔记里列出了四个关键对象:ofproto(一个OpenFlow switch实例)、ofport(端口)、ofrule(一条OpenFlow规则)、ofgroup(行为组合)。

创建流程的完整链路是这样的:

// ofproto实例创建的核心调用链 bridge_init_ofproto() -> ofproto_init() // 注册ofproto_class,放入全局shash -> ofproto_create() // 分配ofproto对象 -> alloc() // 分配ofproto_dpif,包含ofproto -> construct() // 回调,初始化ofproto_dpif -> open_dpif_backer() // 打开内核datapath,创建udpif -> ofproto_init_tables() // 初始化各级流表 -> init_ports() // 遍历端口,逐个ofport_open()

open_dpif_backer()这一步实际调用dpif_class->open()创建dpif对象,同时udpif_create()会拉起处理upcall的线程组。换句话说,vswitchd启动时不光是读配置,底层内核datapath和用户态收包线程是同步建好的。ofport_install()里会调用netdev_open()打开对应的netdev设备,比如system类型对应Linux内核网卡,internal类型对应OVS内部虚拟网卡。

3.3 udpif:多线程upcall处理

udpif是用户态处理内核upcall的接口层,它用多线程方式来消化内核netlink送来的未命中报文。线程模型初始化入口是udpif_set_threads():

void udpif_set_threads(struct udpif *udpif, size_t n_handlers, size_t n_revalidators) { udpif_start_threads(udpif); /* 创建n_handlers个handler线程,回调udpif_upcall_handler() */ ovs_thread_create("handler", udpif_upcall_handler, udpif); /* revalidator线程负责验证和删除无用流表项 */ ovs_thread_create("revalidator", udpif_revalidator, udpif); }

handler线程数量通常与CPU核数相关,核心逻辑是recv_upcalls():

// udpif/upcall.c 接收处理主流程 recv_upcalls(udpif) -> dpif_recv() // 从内核接收upcall消息 -> dpif_netlink_recv__() // epoll_wait阻塞等待,nl_sock_recv收包 -> parse_odp_packet() // 把netlink数据转成dpif_upcall -> process_upcall() // 分类处理:MISS、ACTION等 -> upcall_xlate() // 慢路径翻译 -> xlate_actions() // 生成内核需要的action列表

process_upcall()里会区分upcall类型:MISS_UPCALL是流表未命中,需要走upcall_xlate()生成新的action;如果是ACTION_UPCALL,说明之前已经下发过action,这里是通知用户态做一些统计或sflow采样。理解了这层再去看OVS的性能调优,你会发现handler线程数和revalidator线程数的配比直接决定高并发表现,这也是为什么很多人调整n-handler-threads配置后吞吐变化明显。

4. OpenFlow协议:flow_mod的解析与指令分发

OpenFlow是OVS面向控制器的接口语言。没有配置控制器时,ovs-ofctl就是最常用的OpenFlow客户端;配置了控制器后,由controller通过协议下发流表。你要读懂OVS的OpenFlow实现,核心是两条:连接怎么建立,消息怎么解析。

4.1 连接建立与报文处理框架

笔记把它分成两类:主动连接(ofconn)和被动监听(ofservice)。主动连接是OVS作为客户端去连控制器,入口在bridge_configure_remotes()里,调用链是:

// 主动连接控制器的调用链 connmgr_set_controllers() -> add_controller() -> ofconn_create() // 创建ofconn对象 -> rconn_connect() // 建立rconn连接 -> vconn_open() // 打开vconn -> tcp_open() // 实际TCP socket连接

被动监听是OVS开一个本地unix socket或TCP端口等控制器来连,核心是ofservice_create(),监听建立走pvconn_open(),接受连接后创建ofconn并把连接加入all_conns链表。两者最终都汇聚到ofconn_run()统一处理报文读写。

4.2 flow_mod消息格式

flow_mod是最核心的消息类型,它能被拆成四段:OpenFlow头部、flow_mod固定字段、match字段和instruction字段。报文格式示意:

/* OpenFlow 1.3 flow_mod 的报文布局 */ struct ofp_flow_mod { struct ofp_header header; // version + type + length + xid uint64_t cookie; // 控制器用于标识流表项 uint32_t buffer_id; // 如果带包,标记buffer里的包 uint16_t out_port; // 用于delete等操作时的匹配条件 uint16_t out_group; // 作用同上 uint16_t priority; // 匹配优先级,越大越优先 uint8_t table_id; // 目标流表编号 uint8_t command; // ADD / MODIFY / DELETE /* 后面是match字段和instruction字段 */ };

实际抓包排错时,tcpdump是最直接的工具:

# 抓取OpenFlow控制流量,文件存下来用wireshark分析 tcpdump -i any -s 0 -w of_trace.pcap 'tcp port 6633 or tcp port 6653'

抓包看flow_mod时重点看两个地方:match里的OXM TLV是否带mask,以及instruction里的apply-actions顺序。很多控制器下发失败或行为不符合预期,都是这两个地方写错了。

4.3 match字段的OXM解析

match字段解析在ofputil_pull_ofp11_match(),核心是nx_pull_raw()。OXM格式是TLV结构,每个字段有一个32位header:oxm_class占8位、oxm_field占7位、hasmask占1位、oxm_length占8位。解析函数nx_pull_match_entry()的逻辑是:

// 从TLV中解析一个match字段并做合法性检查 nx_pull_match_entry() -> nx_pull_entry__() // 拆出mf_field_id和value -> mf_are_prereqs_ok() // 检查依赖字段是否满足 -> mf_is_all_wild() // 字段重复性检测 -> mf_is_mask_valid() // mask范围合法性 -> mf_is_value_valid() // value范围合法性 -> mf_set() // 写回match结构

这几步检查里,mf_are_prereqs_ok()最容易踩坑。比如匹配IPv4源地址时,如果flow里没先匹配eth_type=0x0800,这个字段会被判定为非法。很多自测流表下发失败的原因不在OVS本身,而在match字段的前置条件没满足。

4.4 instruction字段的处理

instruction解析在ofpacts_pull_openflow_instructions(),核心是decode_openflow11_instructions(),它按类型把解析结果放到insts[N_OVS_INSTRUCTIONS]数组里。OVS_INSTRUCTIONS宏定义了6种类型:

instruction类型对应的ofpact处理行为
OFPIT11_METERofpact_put_METER关联meter限速
OFPIT11_APPLY_ACTIONSofpacts_decode立即执行action
OFPIT11_CLEAR_ACTIONSofpact_put_CLEAR_ACTIONS清空action集合
OFPIT11_WRITE_ACTIONSofpacts_decode_for_action_set写入action集合
OFPIT11_WRITE_METADATAofpact_put_WRITE_METADATA写metadata
OFPIT11_GOTO_TABLEofpact_put_GOTO_TABLE跳转其他流表

注意区分APPLY_ACTIONS和WRITE_ACTIONS。前者立即执行,后者只是写进action集合,等流水线结束后统一处理。如果你在做多级流表,GOTO_TABLE和WRITE_METADATA的组合要特别留意——metadata的作用范围是整个流水线,这点和寄存器类似,读源码时如果只盯着单个表项理解它,很容易把metadata的作用域搞错。

5. 源码阅读避坑:版本、职责边界与复现环境

读这份笔记和读OVS源码的过程中,我整理了几条真实的踩坑记录,按照现象、原因、解决三个维度复盘一下,能帮你省下不少时间。

5.1 版本不一致导致的行号错位

现象:对照笔记的代码路径去找netdev_frame_hook(),发现本地源码里根本没有这个函数,或者行数完全对不上。原因:笔记基于2.3.90版本,而很多发行版内置的OVS已经到2.5甚至3.x,函数名和模块拆分都有变动。解决:阅读前先确认源版本,用git checkout切到对应tag。想复现笔记内容的话,最省事的办法是直接拉2.3.90的源码树再读。

git checkout v2.3.90

阅读底层网络代码时版本敏感度要高,改个结构体字段名就能让整条链路的理解崩掉。遇到对不上的情况,优先检查版本,不要怀疑自己理解错了。

5.2 把OVS当OpenFlow路由器用

现象:想用OVS实现三层转发,写了大量匹配IP的流表,结果流量行为一直不符合预期。原因:OVS的定位是虚拟交换机,虽然支持L3匹配字段,但路由决策、ARP处理、TTL修改这些它默认不帮你做,那不是它的职责边界。解决:先搞清楚OVS的L2交换核心,再把L3功能通过流表组合实现,必要时搭配独立的路由程序。

理解这一点非常重要,它决定了你读源码时的关注点。如果按“OVS能代替路由器”的思路去读代码,你会反复纠结为什么不处理ARP,而实际上OVS只是把ARP报文当成普通数据包做流表匹配而已。

5.3 只追代码不画调用链

现象:从ovs_dp_process_packet()开始追,追到masked_flow_lookup()里面就迷路了,返回上下文全丢了。原因:OVS的调用链动辄十几层,特别是从用户态到内核态的跨层调用,纯靠记笔记很难维持上下文。解决:每追完一条链路,立刻画一张调用链图,标注每一层的数据结构做了什么修改。笔记里那些大括号嵌套的结构图就是这么来的。

我个人的习惯是:先把链路画在纸上,再对照笔记的结构图补细节。画完一遍,你对这个函数的理解深度会远超直接读十遍源码。

5.4 用户态与内核模块版本不匹配

现象:ovs-vswitchd日志频繁报错,upcall处理异常,甚至出现连接断开重连。原因:用户态程序版本和内核datapath模块版本不一致,最典型的场景是系统自带的openvswitch.ko比较老,但用户想升级用户态工具而没同步编译内核模块。解决:检查两者版本是否匹配。

# 查看内核模块版本 modinfo openvswitch | grep version # 查看用户态版本 ovs-vswitchd --version

如果发现版本不一致,重新编译并加载内核模块,加载前记得先停掉vswitchd,再rmmod openvswitch,最后重新insmod,顺序乱了会直接导致旧模块还在内存里被继续使用,这种问题排查起来比较隐蔽。

5.5 忽略recirc和slow path导致行为理解偏差

现象:读action处理时,看到OVS_ACTION_ATTR_RECIRC但没当回事,后面理解多级流表或bonding场景时怎么都想不通。原因:recirc不是辅助逻辑,它让报文重新回到流表查询入口,是实现OpenFlow多级流水线和一些复杂动作的关键机制。忽略它,就等于跳过了一半的datapath行为。解决:看到RECIRC、slow path这些关键词别跳过,先搞懂它把报文送回了哪个阶段,再往下读。

upcall里的slow path也同理,它是“来不及或没必要在内核快速处理”的降级路径,很多流量异常问题最后都出在这里。读OVS源码时,把这些特殊路径当成一等公民对待,而不是附带说明。

6. 推荐一套“代码+命令+抓包”的三步读法

这套方法是我读OVS过程中摸索出来的,核心思路是:每条代码路径都配上一个可观察的系统行为,读代码的同时在真实环境验证,这样理解不会飘。

第一步,读代码时锁定一条主线入口。比如从ovs_dp_process_packet()开始,往下走到masked_flow_lookup(),然后往左走到ovs_dp_upcall()。读的时候把笔记里的结构图和自己的调用链图对照着看,重点标记每个函数对哪个数据结构做了修改。读完这一条主线,你对datapath的整体框架就有把握了。

第二步,用ovs-appctl和ovs-dpctl验证状态。读uofproto创建流程时,先在环境里跑一遍ovs-appctl dpif/show,对照代码里init_ports()的初始化逻辑,看端口是否存在、状态是否一致。读upcall线程时,运行ovs-dpctl dump-flows看流表的hit和miss计数,判断慢路径是否在工作。这种方式能让代码里的函数名和实际运行状态建立起对应关系,比单纯看代码牢靠得多。

第三步,用抓包验证协议解析。读OpenFlow的match解析时,起一个简单控制器下发一条带OXM的flow_mod,同时用tcpdump抓包,把报文里的TLV字段和nx_pull_raw()的解析结果逐字段对照,你会发现书上写的OXM layout和线上实际传输的格式完全一致,这种感觉很扎实。

# 快速验证:用ovs-ofctl下发一条带OXM的流表 ovs-ofctl add-flow br0 \ 'table=0,ip,nw_src=192.168.1.0/24,actions=output:1'

这条命令里的ip和nw_src会被转成OXM TLV字段,下发后立刻用ovs-ofctl dump-flows br0查看实际存储的match内容,你会看到OVS如何把人类可读的规则变成内部结构。

这套三步读法还有一个隐藏好处:遇到问题时有明确的排查顺序。先看代码确认预期行为,再看状态确认实际行为,最后抓包确认协议交互是否正常。从那以后我每次拿到新版本的OVS源码,都会强制走一遍这个流程,先跑通一条收包链路,再扩展分支逻辑。几轮下来,OVS在你眼里就不再是一个黑匣子了。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表