Gannicus Guo的DIY TCP/IP之旅,描述本人自己动手写的一个简化的TCP/IP协议。经测试该协议可以运行在Ubuntu 16.10 x86_64 操作系统用户空间。便于描述,将该TCP/IP协议称为DIY TCP/IP 。专栏内容包括DIY TCP/IP实现的可行性分析,依赖的编程接口,ARP数据帧的收发,ICMP Echo (PING)数据帧的收发,简单的基于ARP表的IP路由,局域网内IP数据帧的收发,IP数据帧的分片,IP分片的重组,TCP数据帧的收发,TCP连接状态机,以及TCP滑动窗口的实现等全部内容。方便感兴趣的朋友参考并自己动手实现。
自己动手实现DIY TCP/IP,硬件依赖:一台个人电脑(带网卡,无线有线均可),软件依赖:Ubuntu操作系统(虚拟机,硬盘安装均可)。DIY TCP/IP支持ARP,Ping,Large Packet Ping和局域网内的iperf tcp吞吐量测试。
$ ldconfig -p | grep pcap
libpcap.so.0.8 (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libpcap.so.0.8
libpcap.so.0.8 (libc6) => /usr/lib/i386-linux-gnu/libpcap.so.0.8
libpcap.so (libc6,x86-64) => /usr/lib/x86_64-linux-gnu/libpcap.so
$ ls -l /usr/lib/x86_64-linux-gnu/libpcap*
-rw-r--r-- 1 root root 433728 2月 4 2014 libpcap.a
lrwxrwxrwx 1 root root 14 2月 4 2014 libpcap.so -> libpcap.so.0.8
lrwxrwxrwx 1 root root 16 11月 8 2016 libpcap.so.0.8 -> libpcap.so.1.5.3
本人Ubuntu机器上链接的libpcap库的版本是1.5.3。使用libpcap库函数,gcc编译时需指定链接参数-lpcap。本节介绍用到的libpcap库函数时引入的代码实现,也是基于libpcap1.5.3。更多libpcap库函数的使用手册,读者可以在ubuntu终端中输入man “库函数名称”,例如man pcap_open_live, 也可以直接访问”Linux man page”官方网址https://www.die.net/。下面介绍的libpcap库函数的函数原型,基本描述,返回值,以及出错处理,均参考Linux man page。
4.1 pcap_open_live库函数
从0开始的代码使用的第一个libpcap的库函数为pcap_open_live,该库函数的实现在pcap.c文件中。该函数初始化pcap_t结构,创建socket,domain为PF_PACKET,type为SOCK_RAW,protocol为ETH_P_ALL ,也就是从链路层接收所有协议的数据帧,初始化socket选项PACKET_RX_RING,通过内存映射的方式从RAW socket上接收原始数据帧。
在pcap_open_live的linux man page, 可以看到该库函数的函数原型和使用时需要引入的头文件。依照Linux man page 里的内容逐一介绍该库函数的基本描述,参数,返回值和出错处理。
#include
char errbuf[PCAP_ERRBUF_SIZE];
pcap_t *pcap_open_live(const char *device, int snaplen,
int promisc, int to_ms, char *errbuf);
第一个参数device,网络接口,例如”eth0”或”wlan0”等。如果指定device为NULL或”any”,则从所有网络接口接收数据帧。
第二个参数snaplen,指定抓取数据帧的长度,类似tcpdump的”-s”参数,如果不需要完整的数据帧,可以指定需要的长度。如果指定snaplen < 0或者大于MAXMUM_SNAPLEN,libpcap库会将其设置为MAXIMUM_SNAPLEN,MAXIMUM_SNAPLEN的值可以查看pcap-int.h。回到参数sanplen的介绍,库函数会将sanplen赋值给pcap_t结构中的snapshot成员。DIY TCP/IP需要的完整的数据包的长度为1514字节,IP层的最大传输单元为1500,MAC头的开销为 源地址 (6字节) + 目的地址 (6字节) + Type (2字节) ,共1514字节。使用过iperf测试的朋友应该发现过长度远于1518字节的TCP数据帧,并且IP层没有分片,此处先说明这种超过1514字节的TCP数据帧与网卡驱动的GRO有关。GRO相关内容在DIY TCP/IP的TCP模块的实现章节介绍。
第三个参数promisc,指定是否将device指定的网络接口设置为混杂模式,1为混杂模式。
第四个参数to_ms,以毫秒为单位,是libpcap通过 poll检查 raw socket是否有待接收的数据帧的超时时间。libpcap 将socket设置为non-block模式并设置超时时间,用于处理被打断的system call的poll event和检查结束条件是否满足,超时处理的实现见pcap_wait_for_frames_mmap函数。
第五个参数errbuf,用于返回pcap_open_live的出错信息,如果strlen(errbuf)不为0时,表明有出错信息,errbuf的最小长度为PCAP_ERRBUF_SIZE(256),定义见pcap.h头文件。
pcap_open_live出错返回NULL,出错信息存放在errbuf中,成功时返回pcap_t类型的指针,指向libpcap库创建的数据结构pcap_t。pcap_t结构体定义见pcap-int.h头文件。
4.2 pcap_compile & pcap_setfilter库函数
#include
int pcap_compile(pcap_t *p, struct bpf_program *fp,
const char *str, int optimize, bpf_u_int32 netmask);
int pcap_setfilter(pcap_t *p, struct bpf_program *fp);
pcap_compile生成bpf_grogram数据结构,可以理解为数据帧的过滤器,pap_setfilter设置pcap_compile生成的过滤器。DIY TCP/IP需要接收以太网类型为0x0800 (IP) 和0x0806 (ARP)类型的数据帧。
pcap_compile的第一个参数p是4.1节介绍的pcap_open_live返回的pcap_t类型的指针;第二个参数fp是指向bfp_program数据结构的指针;第三个参数参数str指向模式字符串,str的语法见pcap-filter的man page。过滤IP和ARP类型的数据帧,str为“ether proto 0x0800 or ether proto 0x0806”。pcap_compile根据str,填充bfp_program数据结构的成员。第四个参数optimize,控制pcap_compile是否执行bfp_optimize,将其设置为1,第五个参数netmask是网络接口的子网掩码,只有在过滤广播包时有用,将其设置为0xffffff00 (255.255.255.0) 即可。
pcap_setfitler的第一个参数是pcap_open_live返回的pcap_t类型的指针,第二个参数是pcap_compile生成的过滤器。
pcap_compile和pcap_setfilter库函数函数执行成功返回0,不成功返回-1。返回-1时, 可以通过pcap_geterr获取出错信息。
4.3 pcap_loop库函数
#include
typedef void (*pcap_handler)(u_char *user,
const struct pcap_pkthdr *h, const u_char *bytes);
int pcap_loop(pcap_t *p, int cnt,
pcap_handler callback, u_char *user);
pcap_loop从raw socket上读取数据帧,通过callback回调函数将数据帧传给调用者,pcap_loop的实现代码见pcap.c。
先介绍pcap_loop的第三个参数callback,pcap_handler类型的函数指针。callback函数在pcap_loop接收到数据帧时调用,由调用者实现,完成对捕获的数据帧的处理。pcap_loop函数的第一个参数user是调用者传递给callback函数的参数,第二个参数h是libpcap对接收到的网络数据帧的描述,包括长度,时间戳等。第三个参数bytes指向数据帧的有效载荷。
pcap_loop的第一个参数是pcap_open_live返回的pcap_t指针,第二个参数cnt指定需要获取的网络数据帧的数量,pcap_loop在接收到cnt个数据帧时返回。如果cnt为0或-1,pcap_loop会一直执行,这种情况要用pcap_breakloop库函数打断pcap_loop。第四个参数user是传给callback回调函数的参数。
4.4 pcap_breakloop库函数
#include
void pcap_breakloop(pcap_t *);
pcap_breakloop设置pcap_loop的返回条件为真,pcap_loop每次循环,接收到数据帧,或超时,或被中断时检查该返回条件。
4.5 接收链路层数据帧
从0开始的代码一共91行,先介绍主函数,然后是头文件和数据结构的定义。本节使用4.1-4.4节介绍的libpcap库函数,从eth0网络接口链路层接收数据帧,实现pcap_loop回调函数,打印出数据帧以太网头部的类型,main函数如下:
pcap_t *pcap_dev = NULL;
void signal_handler(int sig_num)
{
pcap_breakloop(pcap_dev);
}
int main(int argc, char *argv[])
{
char pcap_packet_filter[FILTER_BUFFER_SIZE];
char err_buf[PCAP_ERRBUF_SIZE];
struct bpf_program filter_code;
/* init signal handler */
signal(SIGINT, signal_handler);
/* obtain packet capture handle */
pcap_dev = pcap_open_live("eth0", MAX_NETWORK_SEGMENT_SIZE, 0,
TIMEOUT_MS, err_buf);
if (pcap_dev == NULL) {
printf("pcap_open_live failed, %s (%d)\n",
strerror(errno), errno);
return -1;
}
/* set pcap filter */
memset(pcap_packet_filter, 0, FILTER_BUFFER_SIZE);
init_packet_filter(pcap_packet_filter, FILTER_BUFFER_SIZE);
if (pcap_compile(pcap_dev, &filter_code,
pcap_packet_filter, 1, IPV4_NETWORK_MASK) < 0) {
pcap_perror(pcap_dev, "pcap_compile");
return -1;
}
if (pcap_setfilter(pcap_dev, &filter_code) < 0) {
pcap_perror(pcap_dev, "pcap_setfilter");
return -1;
}
pcap_loop(pcap_dev, -1, pcap_callback, NULL);
printf("pcap_loop ended\n");
}
line1-6:定义全局变量pcap_dev,方便在信号处理函数中使用,信号处理函数调用pcap_breakloop终止pcap_loop循环。
line8-24: 注册信号处理函数,捕获SIGINT信号,终端键入ctrl+c时,前台进程组会收到SIGINT信号,终止进程。pcap_open_live指定从eth0网络接口的链路层接收数据帧。长度65536 > 1518(字节),0为不打开混杂模式,由于虚拟IP也是与本机MAC地址建立映射,所以不必打开混杂模式。TIMEOUT_MS为512,pcap_loop在没有数据帧接收时,poll也没有被信号打断的情况下,内部循环512ms一次,err_buf返回pcap_open_live出错时的信息。
line26-37: init_packet_filter初始化帧过滤字符串,将清零之后的pcap_packet_filter初始化为”ether proto 0x0800 or ether proto 0x0806”。pcap_complie生成帧过滤器filter_code,optimize值为1,子网掩码为255.255.255.0,再调用pcap_setfilter设置帧过滤器生效。
Line39: 开始接收链路层数据帧,个数为无限 (-1) ,回调函数pcap_callback处理接收到的数据帧,pcap_callback的参数为NULL。
接下来给出main函数中的头文件,宏定义:
#include
#include
#include
#include
#include
#define MAX_NETWORK_SEGMENT_SIZE 65535
#define PROMISC_ENABLE 1
#define TIMEOUT_MS 512
#define FILTER_BUFFER_SIZE 256
#define IPV4_NETWORK_MASK 0xffffff00
#define ETHERNET_IP 0x0800
#define ETHERNET_ARP 0x0806
#define ETHERNET_ADDR_LEN 6
#define MACSTR "%02x:%02x:%02x:%02x:%02x:%02x"
#define MAC2STR(x) (x)[0], (x)[1], (x)[2], (x)[3], (x)[4], (x)[5]
typedef struct _ethhdr {
unsigned char dst[ETHERNET_ADDR_LEN];
unsigned char src[ETHERNET_ADDR_LEN];
unsigned short type;
} __attribute__((packed)) ethhdr_t;
line1-14: 加入引用的头文件,最大数据帧长度65535,大于1500 + sizeof(Ethernet Header)1514字节即可。PROMISC_ENABLE为1,对于从0开始的代码实现,为丰富测试数据,接收目的MAC地址不是本机MAC地址的数据帧,使用混杂模式。对于DIY TCP/IP的实现,映射虚拟IP到本机MAC地址,不需要打开混杂模式。
ETHERNET_IP,ETHERNET_ARP为以太网类型,分别带表IPv4网际协议和ARP地址解析协议类型。通过wireshark捕获数据帧,验证定义的正确性:
上图为wireshark 抓到的TCP数据帧,以太网类型字段为0x0800。
有的朋友会注意到,还有以太网类型为0x8100的ARP数据帧,用于802.1Q VLAN。对于DIY TCP/IP,过滤接收IPv4和ARP数据帧即可。
line16-17:MACSTR为MAC地址的格式化输出字符串,MAC2STR和MACSTR配合打印输出MAC地址。
line19-23: 以太网头部结构体定义,从图1或图2中可以看到,以太网头部分为三个部分,目的MAC地址:6 bytes, 源MAC地址:6 bytes,类型:2 bytes。
最后给出main函数中用到的init_packet_filter和pcap_callback的实现:
static void init_packet_filter(char *pcap_packet_filter, unsigned
int size)
{
if (pcap_packet_filter == NULL || size == 0) {
printf("Null packet filter: %p or Size: %u\n",
pcap_packet_filter, size);
return;
}
snprintf(pcap_packet_filter, size,
"ether proto 0x%04x or ether proto 0x%04x",
ETHERNET_IP, ETHERNET_ARP);
printf("filter: %s\n", pcap_packet_filter);
}
line1-14: 通过sprintf将pcap_packet_filter格式化为” ether proto 0x0800 or ether proto 0x0806”,同时打印出格式化后的字符串验证。
static void pcap_callback(unsigned char *arg,
const struct pcap_pkthdr *pkthdr,
const unsigned char *packet)
{
ethhdr_t *ethpkt = NULL;
if (packet == NULL)
return;
ethpkt = (ethhdr_t *)packet;
printf("%ld.%06u: capture length: %u, pkt length: %u, ethernet type: %04x, "MACSTR " --> " MACSTR"\n",
pkthdr->ts.tv_sec, pkthdr->ts.tv_usec, pkthdr->caplen, pkthdr->len,
ethpkt->type, MAC2STR(ethpkt->src), MAC2STR(ethpkt->dst));
}
pcap_callback用于打印输出收到数据帧的以太网头。
line4-8: 回调函数中pack是pcap_loop回传的有效载荷,pkthdr描述接收到的数据帧的长度。
line9-11: 打印输出数据帧的时间戳,有效载荷的长度,以太网类型字段,源MAC地址和目的MAC地址。
编译,运行:
$ gcc -o device device.c -lpcap
$ sudo ./device
filter: ether proto 0x0800 or ether proto 0x0806
1528450824.298054: capture length: 243, pkt length: 243, ethernet type: 0008, fc:4d:d4:39:c4:5e --> ff:ff:ff:ff:ff:ff
1528450824.298331: capture length: 235, pkt length: 235, ethernet type: 0008, bc:30:5b:a2:ee:24 --> ff:ff:ff:ff:ff:ff
1528450824.677061: capture length: 60, pkt length: 60, ethernet type: 0608, c8:5b:76:04:fd:94 --> ff:ff:ff:ff:ff:ff
1528450825.030632: capture length: 92, pkt length: 92, ethernet type: 0008, 28:d2:44:7e:62:32 --> ff:ff:ff:ff:ff:ff
1528450825.168767: capture length: 64, pkt length: 64, ethernet type: 0081, 8c:b6:4f:57:9e:bc --> ff:ff:ff:ff:ff:ff
1528450825.387166: capture length: 64, pkt length: 64, ethernet type: 0081, 00:19:2f:91:cd:ff --> ff:ff:ff:ff:ff:ff
^Cpcap_loop ended
从0开始的代码保存为device.c,后续章节将基于device.c实现DIY TCP/IP的网络设备模块。
line1: gcc编译device.c指定动态链接库-lpcap
line2: 超级权限运行device, 使用PF_PACK域的socket需要超级权限才能从链路层接收数据帧。
line3: 格式化输出的帧过滤字符串。
line4-9: 接收到的链路层数据帧,以太网类型字段是大端格式,后面章节介绍大小端转换的函数的实现。
line10: 在终端键入ctrl+c,终止pcap_loop函数。打印输出”pcap_loop ended”,此处的打印输出,在后续章节扩展实现成DIY TCP/IP在pcap_loop运行结束后,销毁网络设备模块,IP模块,TCP模块以及其他模块的代码。
4.6 Makfile
现在已经可以从链路层接收数据帧了,并简单实现了以太网头的解析。本节来动手写Makefile,后续章节会不断的扩充本节实现的Makefile,加入后续代码的编译。
目前只有一个device.c文件,把device.c用到的宏定义,结构体的定义,可以暴露给别的模块使用的函数接口等,写到头文件中。只有device.c文件用到的,例如PROMISC_ENABLE宏,单独放在device.h文件中,以太网头结构体的定义,不仅仅只有device.c用到,ARP模块的实现也会用到,将其放在common.h头文件中。按照使用场景分类,将代码模块化如下:
device.h
#ifndef _DEVICE_H_
#define _DEVICE_H_
#define MAX_NETWORK_SEGMENT_SIZE 65535
#define PROMISC_ENABLE 1
#define PROMISC_DISABLE 0
#define TIMEOUT_MS 512
#define FILTER_BUFFER_SIZE 256
#endif
把调用libpcap库函数时用到的参数,定义成对应的宏,放在device.h头文件中,包括snaplen, promisc, timeout的数值, 生成帧过滤器的buffer size。
init.h
#ifndef _INIT_H_
#define _INIT_H_
//#define DEFAULT_IFNAME "eth0"
#define DEFAULT_IFNAME "ens33"
#endif
init.h存放DIY TCP/IP的初始化信息,目前只有默认网络接口的名称eth0。ens33是在更新的ubuntu版本上出现的由udev命名规则确定的网络接口名称。
common.h
#ifndef _COMMON_H_
#define _COMMON_H_
#define IPV4_NETWORK_MASK 0xffffff00
#define ETHERNET_IP 0x0800
#define ETHERNET_ARP 0x0806
#define ETHERNET_ADDR_LEN 6
#define MACSTR "%02x:%02x:%02x:%02x:%02x:%02x"
#define MAC2STR(x) (x)[0], (x)[1], (x)[2], (x)[3], (x)[4], (x)[5]
typedef struct _ethhdr {
unsigned char dst[ETHERNET_ADDR_LEN];
unsigned char src[ETHERNET_ADDR_LEN];
unsigned short type;
} __attribute__((packed)) ethhdr_t;
#endif
common.h存放子网掩码,以太网类型:IP,ARP,MAC地址格式化输出宏,以太网头部结构体定义,packed限定ethhdr_t数据结构按字节对齐。解析从链路层接收到的数据帧,直接把payload的指针强制转换为以太网头部数据结构,以太网头是14字节,不是4字节对齐的,所以结构体定义要加上对齐属性。
代码已经划分完成,现在有4个文件device.c,device.h,init.h,common.h,开始写Makefile,之后的代码实现会不断扩充本节划分出来的模块。
Makefile语法规则如下
target : prerequisites
command
target是目标名称,prerequesties是生成目标文件的依赖,依赖可以是其他目标文件或头文件,command是生成target需要执行的命令行。先不去了解Makefile其他复杂的语法规则,就以能够编译生成tcp_ip_stack目标文件为目的,在prerequesties中加入对头文件的依赖,command写成gcc编译的命令行。
tcp_ip_stack:device.o
gcc -o tcp_ip_stack device.o -lpcap
device.o:device.c device.h init.h common.h
gcc -c device.c
clean:
rm -rf *.o
rm -rf tcp_ip_stack
line1-2: target是tcp_ip_stack elf文件,依赖的目标文件为device.o,命令行gcc –o 生成可执行文件,动态库为lpcap。
line3-4: target是device.o目标文件,依赖为device.c和编译需要的头文件,命令行是gcc –c生成device.o目标文件。
line5-7: target是clean,没有依赖,命令行是shell的命令,清除当前目录的所有目标文件和生成的elf tcp_ip_stack可执行文件。
下面是在终端键入make和make clean时的输出结果
$ make
gcc -c device.c
gcc -o tcp_ip_stack device.o -lpcap
$ make clean
rm -rf *.o
rm -rf tcp_ip_stack
本章小结之前先来扩充一下common.h头文件,实现NTOHS,NTOHL完成16位无符号整型值unsigned short和32位无符号整型值unsigned int的大小端转换,以小端格式输出以太网头部的类型字段。
将NTOHS,NTOHL,HSTON和HLTON放在common.h头文件中,便于各个模块调用。
#define NTOHS(x) ({\
unsigned short val = (x);\
unsigned char *b = (unsigned char *)&(val);\
b[0] << 8 | b[1]; })
#define NTOHL(x) ({\
unsigned int val = (x);\
unsigned char *b = (unsigned char *)&(val); \
b[0] << 24 | b[1] << 16 | b[2] << 8 | b[3]; })
#define HSTON NTOHS
#define HLTON NTOHL
line1-4: NTOHS宏,N代表网络端(大端),H代表主机端(小端),S代表16无符号整型值,该宏用于16位无符号整型值大端转换成小端,宏定义返回最后一条语句的结果。
line5-8: 同NTOHS,该宏用于32位无符号整型值大端转换成小端。
line10-11: 定义主机端16位和32位无符号整型值,小端向大端转换。
再来修改一下,pcap_callback回调函数中,对以太网头部类型的打印,以太网头部类型是unsigned short,用NTOHS将其转换成小端格式。
static void pcap_callback(unsigned char *arg,
const struct pcap_pkthdr *pkthdr,
const unsigned char *packet)
{
ethhdr_t *ethpkt = NULL;
if (packet == NULL)
return;
ethpkt = (ethhdr_t *)packet;
printf("%ld.%06ld: capture length: %u, pkt length: %u
ethernet type: %04x, "MACSTR " --> " MACSTR"\n",
pkthdr->ts.tv_sec, pkthdr->ts.tv_usec, pkthdr->caplen, pkthdr->len,
NTOHS(ethpkt->type), MAC2STR(ethpkt->src), MAC2STR(ethpkt->dst));
}
NTOHS高亮显示改动的部分,编译运行,打印输出如下:
$ make
gcc -c device.c
gcc -o tcp_ip_stack device.o -lpcap
$ sudo ./tcp_ip_stack
[sudo] password for gannicus:
filter: ether proto 0x0800 or ether proto 0x0806
1529750254.336010: capture length: 131, pkt length: 131, ethernet type: 0800, dc:33:0d:2a:95:33 --> ff:ff:ff:ff:ff:ff
1529750255.394534: capture length: 421, pkt length: 421, ethernet type: 0800, 20:6b:e7:4a:a8:2b --> 01:00:5e:7f:ff:fa
1529750255.398825: capture length: 430, pkt length: 430, ethernet type: 0800, 20:6b:e7:4a:a8:2b --> 01:00:5e:7f:ff:fa
1529750255.403594: capture length: 493, pkt length: 493, ethernet type: 0800, 20:6b:e7:4a:a8:2b --> 01:00:5e:7f:ff:fa
1529750255.408557: capture length: 489, pkt length: 489, ethernet type: 0800, 20:6b:e7:4a:a8:2b --> 01:00:5e:7f:ff:fa
1529750255.412843: capture length: 469, pkt length: 469, ethernet type: 0800, 20:6b:e7:4a:a8:2b --> 01:00:5e:7f:ff:fa
可以看到ethernet type已经变成小端值输出。
4.7 小结
本章讲述了从零开始的实现代码,现在已经可从链路层接收到数据帧了, 完成了代码模块化,并且已经自己动手写了Makefile。DIY TCP/IP的每个章节结束时,会列出对应代码实现的目录结构:
下一篇: DIY TCP/IP网络设备模块1