KVM在I/O虚拟化方面,传统的方式是使用Qemu纯软件的方式来模拟I/O设备,其中包括经常使用的网卡设备。这次我们重点分析Qemu为实现网络设备虚拟化的全虚拟化方案。本主题从三个组成方面来完整描述,包括:

  1. 前端网络流的建立;
  2. 虚拟网卡的创建;
  3. 网络I/O虚拟化 in Guest OS。
    本篇主要讲述"前端网络流的建立"。
    VM网络配置方式
    根据KVM的网络配置方案,大概分为如下几种:
  1. 默认用户模式;
  2. 基于网桥(Bridge)的模式;
  3. 基于NAT(Network Address Translation)的模式;
  4. 网络设备的直接分配(基于Intel VT-d技术);

在"kvm安装与启动过程说明"一文中,Guest OS启动命令中没有传入的网络配置,此时QEMU默认分配rtl8139类型的虚拟网卡类型,使用的是默认用户配置模式,这时候由于没有具体的网络模式的配置,Guest的网络功能是有限的。

网桥模式是目前比较简单,也是用的比较多的模式,所以我们这里主要分析基于网桥模式下的VM的收发包流程。

网桥的原理与创建

网桥的原理

网桥(Bridge)也称桥接器,是连接两个局域网的存储转发设备,用它可以完成具有相同或相似体系结构网络系统的连接。一般情况下,被连接的网络系统都具有相同的逻辑链路控制规程(LLC),但媒体访问控制协议(MAC)可以不同。网桥工作在数据链路层,将两个LAN连起来,根据MAC地址来转发帧,其实就是一个简单的二层交换机。

Linux网络协议栈已经支持了网桥的功能,但需要进行相关的配置才可以进行正常的转发。而要配置Linux网桥功能,需要配置工具bridge-utils,大家可以从网上下载源码编译、安装,生成网桥配置的工具名称为brctl。

关于Linux网桥,这里不再过多的叙述,有兴趣的同学可以自行研究下,其实就是一些MAC地址学习、转发表的维护、及生成树的协议管理等功能,大家就认为它是个实现在内核中的二层交换机就行。

Linux网桥的具体实现可以参看关于网桥的流程分析文章:

网桥的创建

 

1

2

$brctl addbr br0 #添加br0这个bridge 

$brctl addif br0 eth0 #将br0与eth0绑定起来 

通过上述两条命令,bridge br0成为连接本机与外部网络的接口。

Tap的原理与创建

qemu-system-x86_64命令关于bridge模式的网络参数如下:

 

1

 

-net tap[,vlan=n][,name=str][,fd=h][,ifname=name][,script=file][,downscript=dfile][,helper=helper][,sndbuf=nbytes][,vnet_hdr=on|off][,vhost=on|off][,vhostfd=h][,vhostforce=on|off]

主要参数说明:

tap:表示创建一个tap设备;

ifname:表示tap设备接口名字;

script: 表示host在启动guest时自动配置的脚本,默认为/etc/qemu-ifup;

downscript:表示host在关闭guest时自动执行的脚本;

fd=h: 连接到现在已经打开着的TAP接口的文件描述符,一般让QEMU会自动创建一个TAP接口;

helper=helper: 设置启动客户机时在宿主机中运行的辅助程序,包括去建立一个TAP虚拟设备,它的默认值为/usr/local/libexec/qemu-bridge-helper,一般不用自定义,采用默认值即可;

sndbuf=nbytes: 限制TAP设备的发送缓冲区大小为n字节,当需要流量进行流量控制时可以设置该选项。其默认值为"sndbuf=0",即不限制发送缓冲区的大小。

什么是Tap设备

qemu在这里使用了Tap设备,那Tap设备是什么呢?

TUN/TAP虚拟网络设备的原理比较简单,在Linux内核中添加了一个TUN/TAP虚拟网络设备的驱动程序和一个与之相关连的字符设备/dev /net/tun,字符设备tun作为用户空间和内核空间交换数据的接口。当内核将数据包发送到虚拟网络设备时,数据包被保存在设备相关的一个队列中,直到用户空间程序通过打开的字符设备tun的描述符读取时,它才会被拷贝到用户空间的缓冲区中,其效果就相当于,数据包直接发送到了用户空间。通过系统调用 write发送数据包时其原理与此类似。

TUN/TAP驱动程序中包含两个部分,一部分是字符设备驱动,还有一部分是网卡驱动部分。利用网卡驱动部分接收来自TCP/IP协议栈的网络分包并发送或者反过来将接收到的网络分包传给协议栈处理,而字符驱动部分则将网络分包在内核与用户态之间传送,模拟物理链路的数据接收和发送。Tun/tap驱动很好的实现了两种驱动的结合。

总而言之,Tap设备实现了这么一种能力,对于用户空间来讲实现了网络数据包在内核态与用户态之间的传输,对于内核管理来说,它是一个网络设备或者直接呈现的是一个网络接口,可以像普通网络接口一样进行收发包。

Tap设备的创建

当命令行中通过-net tap指明创建Tap设备后,在Qemu的main函数中首先解析到了tap的参数选项,然后进入了设备创建流程:

main() file: vl.c, line: 2345

case QEMU_OPTION_netdev: net_client_parse file:vl.c

net_init_clients() file: net.c,

net_init_client() file: net.c,

net_client_init() file: net.c,

net_client_init1() file: net.c, line: 628

net_client_init_fun[opts->kind](opts, name, peer)

在Qemu中,所有的-net类型都由net client这个概念来表示:net_client_init_fun由各类net client对应的初始化函数组成:

 

1

2

3

4

5

6

7

8

9

 

static int (* const net_client_init_fun[NET_CLIENT_OPTIONS_KIND_MAX])(

const NetClientOptions *opts,

const char *name,

NetClientState *peer) = {

[NET_CLIENT_OPTIONS_KIND_NIC] = net_init_nic,

#ifdef CONFIG_SLIRP

[NET_CLIENT_OPTIONS_KIND_USER] = net_init_slirp,

#endif

[NET_CLIENT_OPTIONS_KIND_TAP] = net_init_tap,

[NET_CLIENT_OPTIONS_KIND_SOCKET] = net_init_socket,

#ifdef CONFIG_VDE

[NET_CLIENT_OPTIONS_KIND_VDE] = net_init_vde,

#endif

#ifdef CONFIG_NETMAP

[NET_CLIENT_OPTIONS_KIND_NETMAP] = net_init_netmap,

#endif

[NET_CLIENT_OPTIONS_KIND_DUMP] = net_init_dump,

#ifdef CONFIG_NET_BRIDGE

[NET_CLIENT_OPTIONS_KIND_BRIDGE] = net_init_bridge,

#endif

[NET_CLIENT_OPTIONS_KIND_HUBPORT] = net_init_hubport,

};

net_tap_init()主要做了两件事:

  1. 通过tap_open(){open("/dev/net/tun")}返回了Tap设备的文件描述符fd;
  2. 将Tap设备的文件描述符加入Qemu事件监听列表;
  3. 通过Qemu-ifup脚本将创建的Tap设备接口加入网桥中,这里假设Tap设备名为tap1;

主要的调用流程如下:

net_init_tap() file: tap.c

net_tap_init() file: tap.c,

tap_open() file: tap-linux.c,

fd = open(PATH_NET_TUN, O_RDWR) file: tap-linux.c,

net_tap_fd_init() file: tap.c,

tap_read_poll() file: tap.c

tap_update_fd_handler() file: tap.c

qemu_set_fd_handler2() file: iohandler.c

QLIST_INSERT_HEAD(&io_handlers, ioh, next); file: iohandler.c

可以看到最后Tap设备的事件通知加入了io_handlers的事件监听列表中,

fd_read事件对应的动作为tap_send(),

fd_write事件对应的动作为tap_writable()。

Tap设备接口加入网桥命令:

 

1

$brctl addif br0 tap1



Qemu主线程中的事件监听

io_handlers的事件监听在哪里发生呢?

Qemu的Main函数通过一系列的初始化,并创建线程进行VM的启动,最后来到了main_loop()(file: vl.c)

main_loop() file: vl.c, line: 1631

main_loop_wait() file: main-loop.c, line: 473

qemu_iohandler_fill() file: io_handler.c, line: 93

os_host_main_loop_wait() file: main-loop.c, line: 291

qemu_poll_ns()

g_poll()

g_main_context_get_poll_func(ctx)(fds, nfds, timeout) <----此处对注册的源进行监听,包括Tap fd;

qemu_iohandler_poll() file: io_handler.c <---调用事件对应动作进行处理;

前端网络数据流程


 如图中所示,红色箭头表示数据报文的入方向,步骤:

  1. 网络数据从Host上的物理网卡接收,到达网桥;
  2. 由于eth0与tap1均加入网桥中,根据二层转发原则,br0将数据从tap1口转发出去,即数据由Tap设备接收;
  3. Tap设备通知对应的fd数据可读;
  4. fd的读动作通过tap设备的字符设备驱动将数据拷贝到用户空间,完成数据报文的前端接收。

绿色箭头表示数据报文的出方向,步骤与入方向相反,这里不再详细叙述

上文针对Qemu在前端网络流路径的建立方面做了详细的描述,数据包从Host的物理网卡经过Host Linux内核中的Bridge, 经过Tap设备到达了Qemu的用户态空间。而Qemu是如何把数据包送进Guest中的呢,这里必然要说到到虚拟网卡的建立。

当命令行传入nic相关参数时,Qemu就会解析网络相关的参数后进入虚拟网卡的创建流程。而在上文中提到对于所有-net类型的设备,都视作一个net client来对待。而在net client的建立之前,需要先创建Qemu内部的hub和对应的port,来关联每一个net client,而对于每个创建的-net类型的设备都是可以配置其接口的vlan号,从而控制数据包在其中配置的vlan内部进行转发,从而做到多个虚拟设备之间的switch。

Hub及port的建立

main() file: vl.c, line: 2345

net_init_clients() file: net.c, line: 991

net_init_client() file: net.c, line: 962

net_client_init() file: net.c, line: 701

net_client_init1() file: net.c, line: 628 peer = net_hub_add_port(u.net->has_vlan ? u.net->vlan : 0, NULL);

net_hub_add_port()传入的第一个参数为vlan号,如果没有配置vlan的话则默认为0。

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

 

NetClientState *net_hub_add_port(int hub_id, const char *name)

{

NetHub *hub;

NetHubPort *port;

QLIST_FOREACH(hub, &hubs, next) { <----从全局的hubs链中查找是否已经存在 if (hub->id == hub_id) { break;

}

} if (!hub) {

hub = net_hub_new(hub_id); <----若不存在则创建新的

}

port = net_hub_port_new(hub, name);<----创建在对应hub下创建新的port

return &port->nc;

}

对于在此过程中创建的各种数据结构关系如下图:

相关的解释:

  1. hubs全局链挂载的NetHub结构对应于指定创建的每一个vlan号;
  2. 每个hub下面可以挂载属于同一vlan的多个NetHubPort来描述的port;
  3. 每个NetHubPort下归属的NetClientState nc结构表示了具体挂载的net client;
  4. "e1000","tap1"都有自己的net client结构,而各自的NetClientInfo都指向了net_hub_port_info;该变量定义了一系列hub操作函数;
  5. 最后net_hub_add_port()返回了每个nic对应的NetClientState结构,即图中的peer。

nic设备与hub port的关联

nic设备完成hub及port的创建后,进入nic相关的初始化,即net_init_nic()。

 

1

2

3

4

5

6

7

8

9

10

11

12

 

static int net_init_nic(const NetClientOptions *opts, const char *name, NetClientState *peer)

{

NICInfo *nd;

idx = nic_get_free_idx();

nd = &nd_table[idx];

nd->netdev = peer;

nd->name = g_strdup(name);

......

nd->used = 1;

nb_nics++;

return idx;

}

解释:

  1. 首先从nd_table[]中找到空闲的NICInfo结构,每一个nd_table项代表了一个NIC设备;
  2. 填充相关的内容,其中最重要的是net->netdev = peer,此时hub中的NetClient与NICInfo关联,通过NICInfo可找到NetClient;

tap设备与hub port的关联

tap设备完成hub及port的创建后,进入tap相关的初始化,即net_init_tap()。前文中已描述过部分的tap设备相关初始化内容,主要是tap设备的打开和事件监听的设置。而这里主要描述hub port与tap设备的关联。

主要调用流程:

net_init_tap() file: tap.c

net_tap_init() file: tap.c,

tap_open() file: tap-linux.c,

fd = open(PATH_NET_TUN, O_RDWR) file: tap-linux.c

net_init_tap_one

net_tap_fd_init() file: tap.c, line: 325

 

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

 

static TAPState *net_tap_fd_init(NetClientState *peer, const char *model, const char *name, int fd, int vnet_hdr)

{

NetClientState *nc;

TAPState *s;

nc = qemu_new_net_client(&net_tap_info, peer, model, name);

s = DO_UPCAST(TAPState, nc, nc);

s->fd = fd;

s->host_vnet_hdr_len = vnet_hdr ? sizeof(struct virtio_net_hdr) : 0;

s->using_vnet_hdr = 0;

s->has_ufo = tap_probe_has_ufo(s->fd);

tap_set_offload(&s->nc, 0, 0, 0, 0, 0);

tap_read_poll(s, 1);

s->vhost_net = NULL;

return s;

}

该函数最主要的工作就是建立了代表tap设备的TAPState结构与hub port的关联;Tap设备对应的hub port的NetClientState的peer指向了TAPState的NetClient(也就是传进来的peer ?),而TAPState的NetClient的peer指向 了hub port的NetClientInfo;(也就是说传进来的peer和新建立的nc的peer对指对方,见qemu_net_client_setup)
tap_read_poll()就是上文分析的设置tap设备读写监听事件。

   

虚拟网卡的建立

上面图中可以看到TAP设备结构与tap对应的hub port通过NetClientInfo的peer指针相互进行了关联,而代表e1000的NICInfo中的NetClientInfo与e1000对 应的hub port只有单向的关联,从hub port到NICInfo并没有关联,因此建立两者相互关联需要说到Guest的虚拟网卡的建立。

Guest OS物理内存管理

在说明虚拟网卡的建立前,首先得说一下Guest OS中的物理内存管理。
在虚拟机创建之初,Qemu使用malloc()从其进程地址空间中申请了一块与虚拟机的物理内存大小相等的区域,该块区域就是作为了Guest OS的物理内存来使用。
物理内存通常是不连续的,例如地址0xA0000至0xFFFFF、0xE0000000至0xFFFFFFFF等通常留给BIOS ROM和MMIO,而不是物理内存。
设:
虚拟机包括n块物理内存,分别记做P1, P2, …, Pn;
每块物理内存的起始地址分别记做PB1, PB2, …, PBn;
每块物理内存的大小分别为PS1, PS2, …, PSn。

Qemu根据虚拟机的物理内存布局,将该区域划分成n个子区域,分别记做V1, V2, …, Vn;
第i个子区域与第i块物理内存对应,每个子区域的起始线性地址记做VB1, VB2, …, VBn;
每个子区域的大小等于对应的物理内存块的大小,仍是PS1, PS2, …, PSn。

在Qemu创建虚拟机的时候会向KVM通告Guest OS所使用的物理内存布局,采用KVMSlot的数据结构来表示:

 

1

2

3

4

5

6

7

8

 

typedef struct KVMSlot

{

hwaddr start_addr; <----------Guest物理地址块的起始地址

ram_addr_t memory_size; <----------大小

void *ram; <----------QUMU用户空间地址

int slot; <----------slot号

int flags; <----------内存属性

} KVMSlot;

调用关系:

Qemu:

kvm_set_phys_mem()-> file:kvm-all.c

kvm_set_user_memory_region()-> file:kvm-all.c

kvm_vm_ioctl()-> 通过KVM_SET_USER_MEMORY_REGION进入Kernel KVM:

kvm_vm_ioctl_set_memory_region()-> file: kvm_main.c(kernel)

__kvm_set_memory_region() file: kvm_main.c(kernel)

Guest OS访问任意一块物理地址GPA时,都可以通过KVMSlot记载的关系来得到Qemu的虚拟地址映射即HVA,Qemu中地址空间与VM中地址空间的关系如下图:

虚拟网卡

为什么先要提下Guest OS的物理内存管理呢,因为作为一个硬件设备,OS要控制其必然通过PIO与MMIO来与其交互。而目前的网卡主要涉及到MMIO,而且还是PCI接口,所以必然落入图中的PCI mem。

Qemu根据传入的创建指定NIC类型的参数来进行指定NIC的创建操作,对于e1000虚拟网卡来说,其通过 type_init(e1000_register_types) 注册了MODULE_INIT_QOM类型的设备,而当Qemu创建e1000虚拟设备时,通过内部的PCI Bus设备抽象层来进行了e1000的初始化,该抽象层这里不做过多的描述。主要关注网卡部分的初始化:

static TypeInfo e1000_info = { file: e1000.c,

.name = "e1000",

.parent = TYPE_PCI_DEVICE,

.instance_size = sizeof(E1000State),

.class_init = e1000_class_init, <-------初始化入口函数

};

static void e1000_class_init(ObjectClass *klass, void *data) file: e1000.c,

{

DeviceClass *dc = DEVICE_CLASS(klass);

PCIDeviceClass *k = PCI_DEVICE_CLASS(klass);

k->init = pci_e1000_init; <-------注册虚拟网卡pci层的初始化函数

k->exit = pci_e1000_uninit;

k->romfile = "pxe-e1000.rom";

k->vendor_id = PCI_VENDOR_ID_INTEL;

k->device_id = E1000_DEVID;

k->revision = 0x03;

k->class_id = PCI_CLASS_NETWORK_ETHERNET;

dc->desc = "Intel Gigabit Ethernet";

dc->reset = qdev_e1000_reset;

dc->vmsd = &vmstate_e1000;

dc->props = e1000_properties;

}

static int pci_e1000_init(PCIDevice *pci_dev)

{

e1000_mmio_setup(d); <-------e1000的mmio访问建立

pci_register_bar(&d->dev, 0, PCI_BASE_ADDRESS_SPACE_MEMORY, &d->mmio); <-------注册mmio空间

pci_register_bar(&d->dev, 1, PCI_BASE_ADDRESS_SPACE_IO, &d->io); <-------注册pio空间

d->nic = qemu_new_nic(&net_e1000_info, &d->conf, object_get_typename(OBJECT(d)), d->dev.qdev.id, d);

<----初始化nic信息,并注册虚拟网卡的相关操作函数,结构如下,同时创建了与虚拟网卡对应的net client结构。在 add_boot_device_path(d->conf.bootindex, &pci_dev->qdev, "/ethernet-phy@0"); 加入系统启动设备配置中

}

static NetClientInfo net_e1000_info = {

.type = NET_CLIENT_OPTIONS_KIND_NIC, .size = sizeof(NICState),

.can_receive = e1000_can_receive,

.receive = e1000_receive, <----------主要是receive函数(发送呢?)

.cleanup = e1000_cleanup,

.link_status_changed = e1000_set_link_status,

};

最后PCI设备抽象层将e1000代表的net client与上文描述的e1000所占用的NICinfo所对应hub port的NetClientState *peer进行关联(通过conf->peers.ncs)。

至此就完成了虚拟网卡的建立,最终在Qemu的hub中的各Nic的关系如下: