实际上,我们常用的网络编程都是在应用层的报文的收发操作,也就是大多数程序员接触到的流式套接字(SOCK_STREAM)和数据包式套接字(SOCK_DGRAM)。而这些数据包都是由系统提供的协议栈实现,用户只需要填充应用层报文即可,由系统完成底层报文头的填充并发送。然而在某些情况下需要执行更底层的操作,比如修改报文头、避开系统协议栈等。这个时候就需要使用其他的方式来实现。
一、 原始套接字
原始套接字(SOCK_RAW)是一种不同于SOCK_STREAM、SOCK_DGRAM的套接字,它实现于系统核心。然而,原始套接字能做什么呢?首先来说,普通的套接字无法处理ICMP、IGMP等网络报文,而SOCK_RAW可以;其次,SOCK_RAW也可以处理特殊的IPv4报文;此外,利用原始套接字,可以通过IP_HDRINCL套接字选项由用户构造IP头。总体来说,SOCK_RAW可以处理普通的网络报文之外,还可以处理一些特殊协议报文以及操作IP层及其以上的数据。
原始套接字可以用来自行组装IP数据包,然后将数据包发送到其他终端。必须在管理员权限下才能使用原始套接字,这么做可防止普通用户往网络写出它们自行构造的IP数据报。
(1)原始套接字的创建:
int sockfd = socket (AF_INET, SOCK_RAW, protocol);
把第二个参数设置为SOCK_RAW,第三个参数(协议)通常不为0。其中protocol参数时形如IPPROTO_xxx的某个常值,定义在<netinet/in.h>头文件中,如IPPROTO_ICMP、IPPROTO_UDP、IPPROTO_TCP等。
可以在这个套接字上按以下方式开启IP_HDRINCL套接字选项(随数据包含的IP首部):
const int on =1; if (setsockopt (sockfd, IPPROTO_IP, IP_HDRINCL, &on, sizeof(on)) < 0) 错误处理;
可以在这个套接字上调用bind函数,但是比较少见。bind函数仅仅设置本地地址,因为原始套接字不存在端口的概念。就输出而言,调用bind设置的是将用于从这个原始套接字发送的所有数据报源IP地址(只在IP_HDRINCL套接字选项未开启的前提下)。如果不调用bind,内核就把源IP地址设置为外出接口的主IP地址。
可以在这个原始套接字上调用connect函数,不过比较少见。connect函数仅仅设置外地地址,同样因为原始套接字不存在端口号的概念。就输出而言,调用connect之后我们可以把sendto调用改为write或send调用,因为目的地址已经指定了。
(2)原始套接字的输出
原始套接字的输出遵循以下规则:
- 普通输出通过调用sendto或sendmsg并指定目的IP地址完成。如果套接字已经连接,那么也可以调用write、writev或send。
- 如果IP_HDRINCL套接字选项未开启,那么由进程让内核发送的数据的起始位置指的是IP首部之后的第一个字节,因为内核将构造IP首部并把它置于来自进程的数据之前。内核把所有构造IPv4首部的协议字段设置成来之sock调用的第三个参数。
- 如果IP_HDRINCL套接字选项已开启,那么由进程让内核发送的数据的起始位置指的是IP首部的第一个字节。进程调用输出函数写出的数据量必须包括IP首部的大小。整个IP首部都是由进程构造,不过(a)IP的标识字段可以设置为0,从而告知内核设置该值,(b)IPv4首部校验和字段总是由内核计算并存储,(c)IPv4的选项字段是可选的。
- 内核会对超出外出接口MTU的原始分组进行分片。
(3)原始套接字的输入
首先要考虑内核将哪些接收到的IP数据报传递到原始套接字?这要遵循下面的规则:
- 接收到的UDP或者TCP分组绝不传递到任何原始套接字,如果一个进程想要读取含有UDP分组或TCP分组的IP数据报,它就必须在数据链路层读取这些分组。
- 大多数ICMP分组在内核处理完其中的ICMP消息后传递到原始套接字。
- 所有IGMP分组在内核完成处理其中的IGMP消息后传递到原始套接字。
- 内核把不认识其协议字段的所有IP数据报传递给原始套接字。内核对这些分组执行的唯一处理是针对某些IP首部字段的最小验证:IP版本、IPv4校验和、首部长度以及目的地址。
- 如果某个数据报以片段形式到达,那么在它的所有片段均到达且重组出该数据报之前,不传递任何分组到原始套接字。
当内核有一个需要传递到原始套接字的IP数据报时,它将检查所有进程上的所有原始套接字,以寻找所有匹配的套接字。每个匹配的套接字将被传递送以该IP数据报的一个副本。内核对每个原始套接字均执行以下3个测试,只有这三个测试均为真,内核才把接收到的数据报发送给这个套接字。
- 如果创建这个原始套接字时指定了非0的协议参数(socket的第三个参数),那么接收到的数据报协议字段必须匹配该值,否则该数据报不递送到这个套接字。
- 如果这个套接字已由bind调用绑定了某个本地IP地址,那么接收到的数据报的目的IP地址必须匹配这个绑定地址,否则该数据报不递送到这个套接字。
- 如果这个套接字已由connect调用指定了某个外地IP地址,那么接收到的数据报的源IP地址必须匹配这个已连接地址,否则该数据报不递送到这个套接字。
注意,如果一个原始套接字是以0值协议参数传递的,并且没有调用bind,也未对它调用connect,那么该套接字将接收可由内核传递到原始套接字的每个原始数据报的一个副本。
无论何时往一个原始IPv4套接字上递送一个接收到的数据报,传递到该套接字所在进程的都是包括IP首部在内的完整数据报,然后对于IPv6原始套接字,传递套接字的只是扣除了IPv6首部和所有扩展首部的净荷(payload)。
二 、链路层处理报文
如果需要从链路层处理报文,那么就需要采用更加底层的套接字。还是先看下套接字函数的原型:
#include <sys/socket.h> int socket(int domain, int type, int protocol);
这个函数中,domain表示协议簇,type表示套接字类型,而protocol表示的是处理的协议类型。在Linux下提供了多种底层套接字。下面分别进行简单介绍。
1 PF_INET协议簇
通过PF_INET可以构造原始套接字,如下所示:
int fd = socket (PF_INET, SOCK_RAW, IPPROTO_TCP);
正如前面所讲的,它工作在IP层及其以上各层协议上(当然是在使用IP_HDRINCL选项之后才能操作IP层数据啦),但是这种套接字无法接收从本地发送出去的报文。而使用SOCK_PACKET类型的套接字,则可以操作链路层数据了:
int fd = socket (PF_INET, SOCK_PACKET, IPPROTO_TCP);
不过据说这种方式存在一定的缺陷,而且也不能保证后续版本的系统上一定支持这种方式,因此不推荐使用
2 PF_PACKET协议簇
PF_PACKET协议簇是用来取代SOCK_PACKET的一种编程接口。作为一种协议簇,它可以对应两种不同的套接字类型:SOCK_RAW和SOCK_DGRAM。当使用SOCK_RAW时,用户操作链路层数据,但是如果使用后者,则由系统处理链路层协议头。这种套接字支持四种协议(ETH_P_IP、ETH_P_ARP、ETH_P_RARP、ETH_P_ALL)(未确认)
int fd = socket (PF_PACKET, SOCK_RAW, IPPROTO_TCP); int fd = socket (PF_PACKET, SOCK_DGRAM, IPPROTO_TCP);
3 NETLINK协议簇
这种方式是用户模式和kernel的IP网络配置之间的推荐接口。
综上所述,真正能够实现操作链路层数据的只有三种方式:
int fd = socket (PF_INET, SOCK_PACKET, IPPROTO_TCP); int fd = socket (PF_PACKET, SOCK_RAW, IPPROTO_TCP); int fd = socket (PF_PACKET, SOCK_DGRAM, IPPROTO_TCP);