作者主页(​​文火冰糖的硅基工坊​​​):​​文火冰糖(王文兵)的博客_文火冰糖的硅基工坊


目录

​第1章 Linux虚拟网络设备Tun/Tap概述​

​1.2 实体网卡与虚拟网卡异同​

​1.3 虚拟网卡接口上数据的读取方式​

​1.4 TUN与TAP的区别​

​1.5 如何在不同的设备之间建立虚拟的局域网​

​第2章 TUN/TAP内部的工作原理​

​2.1  TAP 设备与 VETH 设备​

​2.2 应用场景:​

​2.3 几种从虚拟网络设备读取数据方式的比较​

​第3章 代码示例​

​3.1 创建虚拟网卡设备​

​3.2 通过文件操作fd访问虚拟网络设备实例​

​3.3 动态设定虚拟网络设备的IP地址

​3.4 在虚拟网络设备上绑定绑定socket​

​3.5 通过socket id读写数据​


第1章 ​Linux虚拟网络设备Tun/Tap概述

1.1 什么是虚拟网络设备

所谓虚拟的网络设备,是相对于实体的网络设备而言的,实体的网络设备,用设备名,也称为接口名。物理的网络网络设有实在的物理的接口,从物理层接收外部MAC层数据,并提交给TCP/IP协议,应用程序通过socket的读数据。同时应用程序,可以通过socket write向接口写入数据,并通过物理网络把数据发送出去​

如下图的网卡

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_网卡

在上图中,应用程序2和应用程序4,可以通过实体的网口进行网络通信。

然而有些场景,就是没有物理的网口,用户态的应用程序1和应用程序3,他们没有绑定在实体的网络设备接口上,他们如果希望与其他应用通过网络通信,如何实现呢?

最简单的方法是:应用程序1和应用程序2一样,都绑定在实体的网络设备之上。

另一种方法,应用程序1和应用程序3分别绑定在自己独立的虚拟的网卡之上,称之为TAP/TUN. 这就是虚拟网络设备,每个虚拟网络设备与普通物理设备一样,都有自己的IP地址和接口名称。

在上图中,通信路径如下:

(1)应用程序2=》应用程序4的通信

应用程序2构建包:(SrcIP=IP2, DesIP4) =>  路径2 => 路径5 =》路径4 =》 应用程序4

(2)应用程序1=》应用程序4的通信

应用程序1构建包:(SrcIP=IP1, DesIP4) =>  路径1 => 路径5 =》路径4 =》 应用程序4

(3)应用程序2=》应用程序3的通信

应用程序2构建包:(SrcIP=IP2, DesIP3) =>  路径2 => 路径5 =》路径3 =》 应用程序3

(4)应用程序1=》应用程序3的通信

应用程序1构建包:(SrcIP=IP1, DesIP3) =>  路径1 => 路径5 =》路径3 =》 应用程序3

(5)应用程序1=》应用程序2的通信

应用程序1构建包:(SrcIP=IP1, DesIP1) =>  路径1 => 路径2 =》 应用程序2

很显然有了虚拟网卡,即使本地的物理网卡,也可以虚拟出一个IP接口,与外界进行通信。

1.2 实体网卡与虚拟网卡异同

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_服务器_02

(1)相同点


  • 设备名或接口名
  • IP地址
  • socket通信
  • 不同接口之间的IP包的路由

(2)不同点

  • 路由规则不同:

普通网卡​:路由分为本网段内部路由和网段外路由。

网段内路由的数据包,通过自己的物理网络设备发送到物理端口上,如192.168.1.1 =》 192.168.1.2

网段间的路由,则通过IP协议栈先路由到目标端口,然后在通过目标端口的接口驱动,把IP发送到其对应的新的物理端口,发送到网络中。如192.168.1.1 =》10.10.10.1

虚拟网卡:​由于没有物理网络设备,其路由规则有所区别。网段间的路由,同真实网卡。

网段内的路由,则与真实网卡不同,比如如192.168.1.1 =》 192.168.1.2的数据,无从发送,会被堆积在虚拟的设备驱动中,知道通过Raw socet或设备读取的方式把数据读走。

1.3 虚拟网卡接口上数据的读取方式

(1)目标地址是本接口IP地址的数据,如192.168.1.1


  • 通过socket receive()
  • 通过文件操作符来read()

(2)目标地址是本接口网段其他IP地址的数据,如192.168.1.2


  • 由于目标IP地址不是IP接口,因此无法通过upd和tcp层之上的socket读取该数
  • 但可以通过raw socket读取网卡上的所有MAC层的数据,不管目标IP地址是否为本接口
  • 通过文件操作符来read()

(3)非本网络的数据

不应该出现在该接口, 会被IP协议栈路由到其他网络接口

当然,也有一种情况,就是把该接口作为网关设备,该接口也会收到目标IP地址不是本网络的数据,大量的非本网络的数据被积压在虚拟网络驱动中。


  • Raw socket Receive
  • 文件操作符读取Read

1.4 TUN与TAP的区别

TUN适用于IP帧。Tap适用于以太网帧。

TAP摸拟一个以太网设备(以arp广播MAC识别),它操作第二层数据包如以太网数据帧。

TUN模拟了网络层IP设备(以点对点的方式,使用ip标识),操作第三层数据包比如IP数据封包。

如上图所示: 实体网卡是通过网线来进行收发报文,也就是说在实体网线上传输的是已经完全封装好的报文格式,也就是完整的以态帧。

而对于TAP/TUN就有点区别了,我们知道它是虚拟出来的,那么它们就不是实体网卡。

那么问题来了,那么它们收到的报文是从哪里传送过来的?

当然还是得有“网线”了,但是这里说的“网线”并不是真正的网线,而是文件描述符。

TAP/TUN在用户态的接口也与实体网卡一样的,也就通过ifconfig可以配置和管理的网络接口,通过socket绑定到这些接口上收到的数据就是用户态的数据,也就是剥离最外层二三层的数据。

1.5 如何在不同的设备之间建立虚拟的局域网

应用场景:手机在接入到核心网后,会从核心网获取一个IP地址,这个IP地址用于与远程的核心网建立一个虚拟局域网,然后,手机与远程的核心网之间,可能还有其他的IP网络,如何在手机和远程的核心网之间建立一个虚拟的局域网呢?

这就需要GTP等隧道技术和虚拟网络设备TAP/TUN共同协同完成。

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_虚拟网络设备_03

 TUN/TAP的IP地址是远程分配的,本地的TAP 接口以及对应的IP地址是动态建立。

第2章 TUN/TAP内部的工作原理

2.1  TAP 设备与 VETH 设备

TUN/TAP 设备是Linux提供的一种让用户态程序向内核协议栈注入数据的虚拟设备。

TUN工作在三层,TAP工作在二层,使用较多的是 TAP 设备。

VETH 设备出现较早,它的作用是反通讯数据的方向,需要发送的数据会被转换成需要收到的数据重新送入内核网络层进行处理,从而间接的完成数据的注入。

下图是TAP 设备和 VETH 设备工作过程:

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_linux_04

如图所示,当备一个 TAP 设被创建时,在 Linux 设备文件目录下将会生成一个对应 char 设备,用户程序可以像打开普通文件一样打开这个文件进行读写。

当执行 设备的write()操作时,数据进入 TAP 设备,然后进入Linux的TCP/IP协议栈,此时对于 Linux 网络层来说,相当于 TAP 设备收到了一包数据,请求内核接受它,如同普通的物理网卡从外界收到一包数据一样,不同的是其实数据来自 Linux 上的一个用户程序,Linux 收到此数据后将根据网络配置进行后续处理,从而完成了用户程序向 Linux 内核网络层注入数据的功能。Linux的另一个应用程序可以通过Socket接收该数据。

当用户程序执行设备的read()请求时,相当于向内核查询 TAP 设备上是否有需要被发送出去的数据,有的话取出到用户程序里,完成 TAP 设备的发送数据功能。

针对 TAP 设备的一个形象的比喻是:使用 TAP 设备的应用程序相当于另外一台计算机,TAP 设备是本机的一个网卡,他们之间相互连接。应用程序通过 read()/write()操作,和本机网络核心进行通讯。

VETH 设备总是成对出现,送到一端请求发送的数据总是从另一端以请求接受的形式出现。该设备不能被用户程序直接操作,但使用起来比较简单。创建并配置正确后,向 其一端输入数据,VETH 会改变数据的方向并将其送入内核网络核心,完成数据的注入。在另一端能读到此数据。

2.2 应用场景:

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_虚拟网络设备_05

这是比较标准的网络流程图,报文从网卡被接收上来,当然这里的网卡是被内核给托管了。 也就是说网卡接收的报文都交给内核进行了处理。内核收到报文后依次走preRoute,LocalIn将报文进行发或者发到LocalIn。再到用户态被应用程序进行处理。当然,如果是发送报文的话,那就是将报文走LocalOut,然后如果目的地址是本机就转发到本机,否则走路由,然后postRoute.再进入网卡。

这是在PC机上的流程,但是现在的发设备如交换机,路由器,那就有点不一样了。假如用的是DPDK来接发数据报文。情况就有点不样了。DPDK对网卡进行了托管,内核没有直接管理网卡。

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_网卡_06

 如图所以,网卡被DPDK进行管理,DPDK接收和发送网卡的报文。然后报文的发等处理流程,在用户态进行处理。

当然在这种情况下,如果报文不是从本机发出(指的是,不是由本机的内核态发出),而且接收的时候,也不是由本机进行接收(意思是目的地址不是本机)。那么这套流程走起来完全是可以的。

但是你们平时用网页管理那个路由器是怎么个实现法,例如,你访问192.168.1.1,然后就会出现一个路由器配置的页面(这里是指家用路由器哈,发用的不是DPDK。。。。但是打个比方)。很明显,路由器上apache 的流量是从路由器发出,也会接收流量。那这个如何实现。

这里就会用到TAP口了。

[Linux用户空间编程-4]:Linux虚拟网络设备TUN/TAP的工作原理与代码示例_数据_07

于是就变成了这个样子,也就是说,当用DPDK把这些报文都收上来,然后对目的IP进行判断(这部分在用户态)。

如是到本机的报文,那么它就交给TAP口,然后TAP收到这个报文后,就会走preRoute -----> LocalIn----->用户态。

然后用户态对应的socket就会收到你发的报文里面的内容。如果是Apache产生的信息,那么它当然会调用socket进行发送了,也就是会先进入内核,但是无法直接进入网卡了。

现在只能通过TAP口进入用户态,然后在用户态进行发,再用DPDK发送到对应的网卡。

使用 TAP 设备的应用程序相当于另外一台计算机,TAP 设备是本机的一个网卡,他们之间相互连接。应用程序通过 read()/write()操作,和本机网络核心进行通讯.

2.3 几种从虚拟网络设备读取数据方式的比较

有几种方式从虚拟网络设备读写数据的方式,现比较如下:

文件操作读写

普通socket读写

Raw socket读写

操作标识

文件标识符fd

socket标识符socket-id

socket标识符socket-id

返回数据


虚拟网络设备所有层的数据

MAC+IP+TCP/UDP+应用层


只有应用层数据

虚拟网络设备所有层的数据, MAC+IP+TCP/UDP+应用层

操作


只能操作设备名和属性





接口的启动

IP地址修改

socket属性



接口名

socket属性



第3章 代码示例

3.1 创建虚拟网卡设备

#include <stdio.h>
#include <string.h>
#include <linux/if_tun.h>
#include <sys/types.h>
#include <net/if.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <sys/ioctl.h>

int tun_create(char *dev, int flags)
{
struct ifreq ifr;
int fd;
int cmd;

// 打开linux的虚拟网络设备:TUN设备,创建设备的文件操作符
// 此时会创建一个虚拟的网接口ip interface,可以通过ifconfig查看网络接口.
if ((fd = open("/dev/net/tun", O_RDWR)) < 0)
{
return fd;
}

// 获得网络接口的名称ifr_name
memset(&ifr, 0, sizeof(ifr));
strcpy(ifr.ifr_name, dev);

// 获得网络接口的flag
ifr.ifr_flags |= flags;

// 设置网络结构的参数
// 此时还没有设置IP地址
ioctl(fd, TUNSETIFF, (void *)&ifr);

// 返回文件操作符,此时只能通过fd读写设备文件
return(fd);
}

  • ​char *dev​​:包含接口的名称(例如,tap0,tun2等)。虽然可以使用任意名称,但建议最好使用能够代表该接口类型的名称。实际中通常会用到类似tunX或tapX这样的名称。如果​​*dev​​为'\0',则内核会尝试使用第一个对应类型的可用的接口(如tap0,但如果已经存在该接口,则使用tap1,以此类推)。
  • ​int flags​​:包含接口的类型(tun或tap)。通常会使用​​IFF_TUN​​​来指定一个TUN设备(报文不包括以太头),或使用​​IFF_TAP​​​来指定一个TAP设备(报文包含以太头)。此外,还有一个​​IFF_NO_PI​​​标志,可以与​​IFF_TUN​​​或​​IFF_TAP​​​执行OR配合使用。​​IFF_NO_PI​​​ 会告诉内核不需要提供报文信息,即告诉内核仅需要提供"纯"IP报文,不需要其他字节。否则(不设置​​IFF_NO_PI​​​),会在报文开始处添加4个额外的字节(2字节的标识和2字节的协议)。​​IFF_NO_PI​​不需要再创建和连接之间进行匹配(即当创建时指定了该标志,可以在连接时不指定),需要注意的是,当使用wireshark在该接口上抓取流量时,不会显示这4个字节。

3.2 通过文件操作fd访问虚拟网络设备实例

int main(int argc, char *argv[])
{
int tun, ret;
unsigned char buf[4096];
unsigned char ip[4];
unsigned char ihl;

// 利用前面定义的函数,创建虚拟网络设备
// IFF_TUN: TUN Device
// IFF_TAP: TAP Device
tun_fd = tun_create("tun0", IFF_TUN | IFF_NO_PI);
if (tun_fd < 0)
{
perror("tun_create");
return 1;
}
printf("TUN name is %s\n", "tun0");

// 通过一个线程持续的从虚拟设备中读取数据
while (1)
{

// 通过文件描述fd读取数据
ret = read(tun_fd , buf, sizeof(buf));
if (ret < 0)
{
break;
}
ihl = buf[0] & 0xf;
printf("The length of ip header is %d\n", ihl);
memcpy(ip, &buf[12], 4);
memcpy(&buf[12], &buf[16], 4);
memcpy(&buf[16], ip, 4);

buf[20] = 0;
*((unsigned short*)&buf[22]) += 8;
printf("read %d bytes\n", ret);

// 把读出来的文件再重新写回去
ret = write(tun_fd, buf, ret);
printf("write %d bytes\n", ret);

// 休眠1s
sleep(1)
}

return 0;
}

3.3 动态设定虚拟网络设备的IP地址

Status setupIPAddress(char* ifname, U32 u32IpAddr,U32 u32NetMask)
{
U32 u32BrdAddr;
u32BrdAddr =u32IpAddr|0x000000ff;

// 设置接口的IP地址,根据网络接口名:ifname
if(setup_if_addr(ifname, TYPE_IPV4, u32IpAddr, u32NetMask, u32BrdAddr)==ERROR)
{
printf("setup_if_addr failed\n");
close(sockFd);
return ERROR;
}
return OK;
}

3.4 在虚拟网络设备上绑定绑定socket

/**************************/
/* initInterface */
/**************************/
int initInterface(char* ifname)
{
int sock;
char* err_str;
struct ifreq ifr;

// 设定虚拟网络设备的接口名
memset(&ifr, 0, sizeof(ifr));
ifr.ifr_flags = IFF_TUN | IFF_NO_PI;

if(ifname)
{
strncpy(ifr.ifr_ifrn.ifrn_name, ifname, sizeof(ifr.ifr_name)-1);
}

if (0 > ioctl(tun_fd, TUNSETIFF, &ifr))
{
err_str = strerror(errno);
printf("Failed to set TUN device name: %s\n", err_str);
return ERROR;
}

#if 0
if(fcntl(tun_fd,F_SETFL,O_NONBLOCK)<0)
{
err_str = strerror(errno);
printf("Failed to fcntl TUN device (%s)\n",err_str);
return ERROR;
}
#endif

// Bring up the interface
// 创建socket
sock = socket(AF_INET, SOCK_DGRAM, 0);

// 把socket绑定到虚拟网络设备的接口上(根据名称)
if (0 > ioctl(sock, SIOCGIFFLAGS, &ifr))
{
err_str = strerror(errno);
printf("Failed to bring up socket: %s\n", err_str);
return ERROR;
}

// bring up该网络接口
ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
if (0 > ioctl(sock, SIOCSIFFLAGS, &ifr))
{
err_str = strerror(errno);
printf("Failed to set socket flags: %s\n", err_str);
return ERROR;
}

return socket;
}

3.5 通过socket id读写数据

int start_server(int fd)
{


//对于服务器来说,程序要一直循环下去,需要一个while(1)死循环
while(1)
{
sockaddr_in client_addr;//创建一个client IPv4结构体
socklen_t len=sizeof(client_addr);//用len记下client的长度,之后有用

printf(">: ");
fflush(stdout);
char buf[1024]={0};

//socket读数据,返回值不包含IP地址信息
ssize_t s = recvfrom(fd,buf,sizeof(buf)-1,0,(sockaddr*)&client_addr,&len);
//从client服务器中接收数据到buf中
//因为这里需要知道发送端的socket地址,所以需要创建一个client_addr,这里创建的是IPv4
//从fd中读到的发送端的socker地址就放到client_addr这个结构体中
//&len是一个输入输出型参数,输入的是client_addr结构体的大小,返回的是实际接收到的socket地址类型的结构体大小
if(s < 0){
perror("recvfrom");
continue;//这里不能退出,需要continue,不能让这个死循环停下
}
buf[s] = '\0';//s的大小表示接受了多少数据

printf("[%s:%d]:%s\n",inet_ntoa(client.sin_addr),
ntohs(client.sin_port),buf);
//第一个参数是将客户端的IP地址,通过inet_ntoa函数转化为字符串输出来,
//第二个参数代表的是通过ntohs函数,将客户端传过来的端口号把其网络字节序转化为主机字节序
//第三个参数代表的是直接从客户端接收到buf数组当中的内容

//接下来就是服务器接收到客户端请求后的相应,对于不同的服务器来说,要根据不同的请求(request),做出不同的相应(response)
//此处由于我们只实现最简单的echo服务器,就不涉及复杂的计算了,直接相应客户端传给服务器的数据

//通过socket 发送数据
sendto(fd,buf,strlen(buf),0,(sockaddr*)&client_addr,sizeof(client_addr));

//将客户端传过来存在buf中的数据原模原样发送给客户端
//用strlen(buf)表示缓冲区实际存放的数据的大小
//那么此时发送端的socket地址就是前面的client_addr
//这里的sizeof(client_addr)就不是一个输入输出型参数了,因此它不是一个地址,是值传递
}

return 0;
}

 作者主页(​​文火冰糖的硅基工坊​​​):​​文火冰糖(王文兵)的博客_文火冰糖的硅基工坊