一、Linux网络模型
Linux中网络栈的介绍一般分为四层的Internet模型:
TCP/IP协议族体系结构及主要协议:
1、TCP/IP协议族
TCP/IP实际上是一个协同工作的通信家族,为网络通信提供通路。为方便讨论TCP/IP协议族,大体上分为三部分:
Internet协议(IP)。
传输控制协议(TCP)和用户数据报协议(UDP)。
处于TCP和UDP之上的一组应用协议。它们包括:Telnet,文件传送协议(FTP),域名服务协议(DNS)和简单的邮件传送程序(SMTP)等。
2、数据链路层
数据链路层实现了网卡接口的网络驱动程序,以处理数据在网络媒介上(比如以太网)上的传输。不同的物理网络层具有不同的电器特性,网络驱动程序隐藏了这些细节,为上层协议提供了统一的接口。
3、网络层
网络层实现数据包的选路和转发。WAN通常使用众多分级的路由器来连接分散的主机或LAN,因此,通信的两台主机一般不是直接连接的,而是通过多个中间节点连接的。网络层的任务就是选择这些节点,以确定两台主机间的通信路径。同时,网络层对上层协议隐藏了网络拓扑连接的细节,使得在传输层和网络应用程序看来,通信的双方是直接相连的。
4、传输层
传输层为两台主机上的应用程序提供端到端(end to end)的通信。与网络层使用的逐跳的通信方式不同,传输层只关心通信的起始端和目的端,而不关心数据包的中转过程。
5、应用层
应用层负责处理应用程序的逻辑。
应用层协议很多:ping:应用程序,不是协议,调试网络环境;
telnet:远程登录协议。
DNS:机器域名到ip的转换。
HTTP:超文本传输协议,是一种详细规定了浏览器和万维网服务器之间互相通信的规则,通过因特网传送文档 的数据传送协议。
DHCP:动态主机配置协议。
6、数据封装
应用层程序在发送到物理网络之前,将沿着协议栈从上往下依次传递。每层协议都将在上层协议的基础上加上自己的头部信息(有时还包括尾部信息),以实现该层的功能,这个过程称为封装。
7、IP协议:
IP的主要目的是为数据输入/输出网络提供基本算法,为高层协议提供无连接的传送服务。这意味着在IP将数据交给接收站以前不在传输站点和接收站点之间建立对话。它只是封装和传递数据,但不向发送者或接收者报告包的状态,不处理所遇到的故障。
IP包由IP协议头与协议数据两部分构成。
8、TCP协议
TCP协议(传输控制协议)为应用层提供可靠的、面向连接的、基于流(stream)的服务。TCP协议使用超时重传、数据确认等方式来确保数据包被正确发送到目的地,因此TCP服务是可靠的。使用TCP协议通信的双方必须先建立TCP连接,并且在内核中为该连接的双方必须先建立TCP连接,并且在内核中为该连接维持一些必须的数据结构。当通信结束时,双方必须关闭连接以释放这些内核数据。
(1)TCP协议头部结构
(2)TCP三次握手
所谓三次握手(Three—way Handshake),是指建立一个TCP连接时,需要客户端和服务器总共发送三个包。
这个东西还是挺有搞头的,把这一大块整理完后,再把三次握手具体过程理一理。
(3)TCP四次挥手
TCP连接的删除需要发送四个包,因此称为四次挥手(four—way handshake)。客户端或服务器均可主动发起挥手动作,在socket编程中,任何一方执行close()操作即可产生挥手操作。
9、UDP协议
UDP协议(用户数据报协议),它与应用层TCP协议完全相反。提供不可靠、无连接和基于数据报的服务。不可靠意味着UDP协议无法保证数据从发送端正确的发送到接收端。如果数据在中途丢失,或者目的端通过数据校验发现数据错误而将其丢弃,则UDP协议的应用程序通常要自己处理数据确认、超时重传等逻辑性。
二、Linux网络编程基础
1、网络套接字socket
Linux中的网络编程通过Socket(套接字)实现,Socket是一种文件描述符。
(1)socket的三种类型
流式套接字(SOCK_STREAM):流式套接字可以提供可靠的、面向连接的通讯流,它使用TCP协议。TCP保证了数据传输的正确性和顺序性。
数据报套接字(SOCK_DGRAM):数据报套接字定义了一种无连接的服务,数据通过相互独立的报文进行传输,是无序的,并且不保证可靠,无差错,它使用数据报协议UDP
原始套接字(SOCK_RAW):原始套接字允许使用IP协议,主要用于新的网络协议的测试等。 一般用于网络测试。
2、地址结构
在socket程序设计宏,struct sockaddr用于保存socket地址信息:
struct sockaddr
{
unsigned short sa_family; /*地址族*/
char sa_data[14]; /*14字节的协议地址,包含该socket的IP地址和端口号*/
}
sa_family:协议族,采用“AF_XXX”的形式,如AF_INET(IPV4协议)
sa_data:14字节的特定协议地址。
在socket程序设计中,struct sockaddr_in 同样用于保存socket地址信息:
struct sockaddr_in
{
short int sin_family; /*Internet地址族*/
unsigned short int sin_port; /*端口号*/
struct in_addr sin_addr; /*IP地址*/
unsinged char sin_zero[8]; /*填0以保持和strruct sockaddr同样大小*/
};
IP地址结构:
struct in_addr //网络IP的类型
{
unsigned long s_addr; /*32位的地址*/
}
在实际编程中,一般不针对sockaddr数据结构进行操作,而是使用与sockaddr_in数据结构。
3、字节序转换
(1)字节序概念
不同类型的CPU对变量的字节存储顺序可能不同;有的系统是高位在前,低位在后,而有的系统是低位在前,高位在后。在网络的数据顺序一定是要统一的。所以当内部字节存储顺序和网络字节顺序不同时,就一定要进行转换。
如32bit的整数(0x01234567)从地址0x100开始:
V小端字节序:(高位放在低地址)
V大端字节序:(高位放在高地址)
(2)网络字节序:
网络字节序是TCP/IP中规定好的一种数据表示格式,它与具体的CPU类型,操作系统等无关。从而可以保证数据在不同主机之间传输时能够被正确解释。网络字节顺序采用big endian排序方式。
(3)字节序转换函数:
#include<netinet/in.h>
uint16_t htons(unit16_t host16bit); //把unsigned int 类型从主机序到网络序
uint32_t htonl(unit32_t host32bit); //把unsigned long 类型从主机序到网络序
uint16_t ntohs(unit16_t net16bit); //把unsigned short 类型从网络序到主机序
unit32_t ntohl(unit32_t net32bit); //把unsigned long 类型从网络序到主机序
h--host n--network s--short l--long 通常16位的IP端口号用s代表。而IP地址用I代表。
4、IP与主机名
在网络上标识一台机器可以用IP,也可以使用主机名。
#include<netdb.h>
struct hostent * getnostbyname(const char * hostname); // 实现主机名到IP地址的转换
函数返回值:
struct hostent
{
char * h_name; //主机的正式名
char **h_aliases; //主机的别名组
int h_addrtype; //主机的地址类型
int h_length; //主机的地址列表长度
char **h_addr_list; //主机的IP地址列表(32位网络地址)
}
#define h_addr h_addr_list[0] //主机的首个IP地址
5、IP地址转换
IP地址通常由数字加点的形式表示,而struct in_addr中使用的IP地址是32位整数表示的,为了转换可以使用如下两个函数。
#include<netdb.h>
int iner_aton(const char * cp, struct in_addr *inp); //将a.b.c.d形式的IP转换为32位的IP
char * inet_ntoa(struct in_addr in); //将32位的IP转化为a.b.c.d的形式
三、socket网络编程
1、socket核心函数
(1)socket
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domain, int type, int protocol);
sockt函数对应于文件的打开操作(Linux一切皆文件),函数用于创建一个socket描述符。把它作为参数可以进行读,写等操作。
参数含义:sdomain:协议域,又称协议族。常用协议:AF_INET(IPV4协议)、AD_INET6(IPV6协议)、AF_LOCAL、 AF_ROUTE等。
type:指定socket的类型。上文有介绍,常用的有三种,SOCK_STREAM,SOCK_DGRAM。
prorocol:指定协议。当protocol为0时,会自动选择type类型对应的默认协议。
上面的type和protocol不可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当我们调用socket创建一个socket时,返回的socket描述字它存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。
(2)bind
bind()函数将一个地址族中的特定地址赋给socket。
#include<sys/types.h>
#include<sys/socket.h>
int bind(int sockfd, const struct sockaddr * addr, socklen_t addrlen);
sockfd: socket描述字,通过socket()函数创建,唯一标识一个socket。bind()函数就是给这个描述字绑定一个名字。
addr:这个结构体指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket的地址协议族的不同而不同,IPV4对应的IP地址是sockaddr_in上文有具体说明这个结构体。
为什么服务器需要绑定,而客户端不需要?
通常服务器在启动时需要绑定一个地址(地址加端口号)用于提供服务,客户端可以通过这个地址对服务器进行连接。而客户端则不需要,由系统自动分配端口号和自身IP地址组合即可。所以服务器在监听前需要进行绑定,而客户端不需要,在connect()时会由系统自动生成。
为什么有时候会出现bind()出错的情况,如何解决?
当clinet终止时自动关闭socket描述符,server的TCP连接收到的client发的FIN段后处于TIME_WAIT状态。TCP协议规定,主动关闭连接的一方要处于TIME_WAIT状态,等待两个MSL(maximum segment lifetime)的时间才能回到CLOSE的状态,当ctrl-C结束server时,server是主动关闭的一方,TIME_WAIT期间不能再次监听同样的server端口。
在server的TCP连接没有完全断开之前不允许重新监听是不合理的,因为,TCP连接没有完全断开指的是fd(127.0.0.1:8000)没有完全断开,而我们重新监听的是sockfd(0.0.0.0:8000),虽然是占用同一个端口,但IP地址不同,fd对应的是不某个客户端通讯的一个具体的IP地址,而sockfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。
在socket和bind之间插入如下代码。
int opt = 1;
setsockopt(sockfd,SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
(3)listen和connect
服务器在调用socket()、bind()之后调用listen()来监听这个socket,如果客户端这时调用connect发出连接,服务器将会收到这个请求。
#include<sys/types.h>
#include<sys/socket.h>
int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr * addr, socklen_t addrlen);
listen的第一次参数为监听的socket描述字,第二个参数为相应socket可以排队的最大连接个数。socket函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型,等待客户的连接请求。
connect函数的第一个参数即为客户端的socket描述字,第二参数为服务器的socket地址,第三个参数为socket地址的长度。客户端通过调用connect函数与TCP服务器建立连接。
(4)accept
服务器监听到客户端通过connect发出的连接请求后,就将调用accept()函数来接受这个请求,这样连接就建立好了。之后就可以进行网络I/O操作,类同于普通文件的读写I/O操作。
#include<sys/types.h>
#include<sys/socket.h>
int accept(int sockfd, struct sockaddr * addr, socklen_t *addrlen);
函数参数结构与connect,第一个参数是服务器socket()产生的sockfd,称为监听套接字,用于监听新的连接。第二个参数用于返回客户端的IP地址信息。
返回值是一个全新的文件描述符,这个文件描述符用于新建立的该连接的信息传输。
(5)read()/write()
至此,服务器与客户端已建立起连接。可以使用网络I/O进行读写操作,实现网络中不同进程之间的通信。
#include <unistd.h>
ssize_t read(int sockfd, void *buf, size_t count);
ssize_t write(int sockfd, const void *buf, size_t count);
read用于从sockfd中读取内容,读成功时,read返回实际所读字节数, 小于0表示出错。
write函数将buf中的count自己内容写入sockfd中。失败返回-1。
(6)sendto()/recvfrom()
这两个函数用于基于无连接的网络传输协议,每次进行信息传输,需要注明接收方或者发送方的IP地址信息。
ssize_t sendto(int sockfd, const vod *buf, size_t len, int flags, const struct sockaddr * dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr * src_addr, socklen_t * addrlen);
(7)close()
在完成了读写操作后,关闭相应的socket描述符,与文件操作的fclose()类似。
#include <unistd.h>
int close(int fd);
注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。
2、基于TCP的网络编程
(1)基于TCP—服务器
(a)创建一个socket,用函数socket()
(b)绑定IP地址、端口等信息到socket上,用函数bind()
(c)监听套接字,设置允许的最大连接数,用函数listen()
(d)接收客户端发来的连接请求,用函数accept()
(e)收发数据,用函数send()和recv(),或者read()和write()
(f)关闭网络连接
(2)基于TCP—客户端
(a)创建一个socket,用函数socket()
(b)设置要连接的对方IP地址和端口等属性。
(c)连接服务器,用函数connect()
(e)收发数据,用函数send()和recv(),或者read()和write()
(f)关闭网络连接
![](https://i-blog.csdnimg.cn/blog_migrate/eaa8539a780a07ae526042b99898e8cf.png)
3、基于UDP的网络编程
(1)基于UDP—服务器
(a)创建一个socket,用函数socket()
(b)绑定IP地址,端口等信息到socket上,用函数bind()
(c)循环接收数据,用函数recvfrom()
(d)关闭网络连接
(2)基于UDP—客户端
(a)创建一个socket,用函数socket()
(b)绑定IP地址,端口等信息到socket上,用函数bind()
(c)设置连接方的IP地址和端口等信息
(d)发送数据,用函数sendto()
(e)关闭网络连接
![](https://i-blog.csdnimg.cn/blog_migrate/252653ca5b0c3c9a6a1dc2873e1252fe.png)
4、服务器模型
在网络程序里,一般来说都是许多客户对应一个服务器,为了处理客户的请求,对服务端的程序就提出特殊的要求。目前最常用的服务器模型有:
循环服务器:服务器在同一个时刻只可以相应一个客户端的请求。
并发服务器:服务器在同一个时刻可以相应多个客户端的请求。
(1)UDP循环服务器
UDP循环服务器实现的方法:UDP服务器每次从套接字上读取一个客户端的请求->处理->将结果返回给客户端
socket(....);
bind(...);
while(1)
{
recvfrom(...);
process(....);
sendo(....);
}
因为UDP是非面向连接的,没有一个客户端可以总是占用服务器,所有服务器对每一个客户的要求总能满足。
(2)TCP并发服务器
并发服务器的思想是每一个客户机的请求并不由服务器直接处理,而是由服务器创建一个子进程(线程)来处理。算法如下:
socket(.....);
bind(.....);
listen(.....);
while(1)
{
accept(....);
if(fork(...) == 0) {
process(.....);
exit();
}
close(.....);
}
缺点:创建子进程处理客户端请求对资源内存消耗很大。
5、多路复用I/O
阻塞函数在完成其他指定的任务以前不允许程序继续向下执行。例如:当服务器运行到accept语句时,而没有客户请求连接,服务器就会停在accept语句上等待连接请求的到来。这种情况称为阻塞。例如,如果你希望服务器仅仅检查是否有客户在等待连接,有就接受连接,否则就继续做其他事情,则可以通过select系统调用来实现。除此之外,select还可以同时监视多个套接字。
SELECT:
int select(int maxfd, fd_set * readfds, fd_set * writefds, fd_set * exceptfds, const struct timeval * timeout);
参数:
Maxfd :文件描述符的范围,比待检的最大描述符大一即可。
Readfds:被读监控的文件描述集
Writefds:被写监控的文件描述符集
Exceptfds:被异常监控的文件描述符集
Timeout:定时器,0:非阻塞 , NULL:阻塞, 正整数:等待的最大时间,即函数在timeout时间是阻塞的、
返回值:
正常情况返回满足要求的文件描述符个数;
经过了timeout等待时间仍无文件满足,返回值为0;
如果select 被某个信号中断,它将返回-1并设置error为EINTR;
出错,返回-1并设置相应的error。
使用步骤:
(1)设置要监控的文件
(2)调用select开始监控
(3)判断文件是否发送变化
系统提供了4个宏对描述符集进行操作:
#include <sys/select.h>
void FD_SET(int fd, fd_set *fdset) //将文件描述符fd添加到文件描述符集fdset中
void FD_CLR(int fd, fd_set *fdset) //从文件描述符集fdset中清除文件描述符fd
void FD_ZERO(fd_set *fdset) //清空文件描述符集fdset
void FD_ISSET(int fd, fd_set *fdset) //在调用select后使用FD_ISSET来检测文件描述符集fdset中的文件fd发生了变化