以下内容主要参考书籍《Linux C编程一站式学习》、《Unix网络编程》、《Unix高级环境编程》
首先要明确客户端与服务器要怎么去实现通信
下图便是一个简易的TCP C/S模型实现
知道模型之后,接下来只是一些与网络接口相关的API调用。
预备知识:
socket是什么?
1.在TCP 在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程,“IP地址+端口号”就称为socket。
2.在TCP协议中,建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。socket本身有“插座”的意思,因此用来描述网络连接的一对一关系。
3.TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket接口
TCP4层模型与socket接口之间关系如下图
具体相关的函数接口
1.socket()
#include
#include
int socket(int domain,int type,int protocol);
各参数解释如下
domain:
AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
AF_INET6 与上面类似,不过是来用IPv6的地址
AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用
type:
SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。当将protocol设为0时,则默认使用TCP来进行传输。
SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,当将protocol设为0时,则默认使用UDP来进行传输。
SOCK_SEQPACKET 这个协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
SOCK_RAW 这个socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
protocol:
0 默认协议
返回值:
成功返回一个新的文件描述符
失败返回-1,并设置errno
当进程调用socket函数后,系统会为其分配一个文件描述符,供进程去在网络上进行通信,对于文件描述符,我们就有许多对应的系统调用可以使用了,比如read,write等等。
但对于服务器来说,需要将本地地址绑定一个固定端口及相关的接口,来让客户端能够方便的访问到服务器,这时就需要一个函数接口bind()。
2.bind()
#include /* See NOTES */
#include
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
socket文件描述符
addr:
构造出IP地址加端口号
addrlen:
sizeof(addr)长度
这个函数重点在于struct sockaddr这个结构体,由于历史原因,当前并没有void *这个向上兼容的泛型指针,而所有的这些函数参数都使用struct sockaddr *类型表示,在使用的时候需要我们强转一下。
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
IPV4地址使用struct sockaddr_in
struct sockaddr_in {
__kernel_sa_family_t sin_family; /* Address family */ IPV4此值取值为AF_INET
__be16 sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
/* Internet address. */
struct in_addr {
__be32 s_addr;
};
IPV6使用struct sockaddr_in6
struct sockaddr_in6 {
unsigned short int sin6_family; /* AF_INET6 */
__be16 sin6_port; /* Transport layer port # */
__be32 sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
__u32 sin6_scope_id; /* scope id (new in RFC2553) */
};
struct in6_addr {
union {
__u8 u6_addr8[16];
__be16 u6_addr16[8];
__be32 u6_addr32[4];
} in6_u;
#define s6_addr in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};
本地套接字使用struct sockaddr_un
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
返回值:
成功返回0,失败返回-1, 并设置errno
当服务器将本地地址和一套接口绑定完之后,则需要给服务器设置为监听状态,使其有接受连接请求的能力,此时就需要函数接口listen()。
3.listen()
#include /* See NOTES */
#include
int listen(int sockfd, int backlog);
listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。
sockfd:
socket文件描述符
backlog:
排队建立3次握手队列和刚刚建立3次握手队列的链接数和
查看系统默认backlog(128)
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
当设置好监听状态后,服务器调用accept()接受连接,如果服务器没有接受到客户端的请求,就会处于阻塞状态,直到有客户端连接上来为止。
4.accept()
#include /* See NOTES */
#include
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
socket文件描述符
addr:
传出参数,返回链接客户端地址信息,含IP地址和端口号
addrlen:
传入传出参数,传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客
户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。
返回值:
成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,并设置errno
到此时服务器基本架构就基本完成了,就只要阻塞I等待客户端连接就好了,具体的实现代码如下,为了将模型尽可能简单化,至此,我就没有写太多关于出错的信息的操作,以免淹没于细节中。
/*sever.c*/
#include
#include
#include
#include
#include
#include
#include
#include
#define SERV_PORT 8000
void perr_exit(const char *str)
{
perror(str);
exit(1);
}
int main()
{
int sfd = socket(AF_INET,SOCK_STREAM,0),cfd;
struct sockaddr_in servaddr;
struct sockaddr_in client_addr;
int i,len;
socklen_t addr_len;
//init
bzero(&servaddr,sizeof(struct sockaddr_in));
servaddr.sin_family = AF_INET;
//htons htonl 都属于网络字节序转换,在代码段之后会进行解释,就先理解为转换为网络中所需要的类型
servaddr.sin_port = htons(SERV_PORT);
//INADDR_ANY表示任意都可连接(因为客户端不是来自同一个网络地址)
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//
if(bind(sfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0)
perr_exit("bind error");
//设置可连接数为128
listen(sfd,128);
printf("wait for conncet---------\n");
addr_len = sizeof(client_addr);
cfd = accept(sfd,(struct sockaddr *)&client_addr,&addr_len);
if(cfd == -1)
perr_exit("accept error");
char buf[256];
/*系统还为我们封装了IP地址转换函数
* 因为IP地址在网络中为网络字节序二进制值,而我们平常使用的是ASCII字符串,
* 故有这一组函数来进行转换,其实也不难记,可以这样形式的记住,以免用混ip to net,net to ip
* #include
* int inet_pton(int af, const char *src, void *dst);
* const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
* */
printf("client IP :%s %d\n",
inet_ntop(AF_INET,&client_addr.sin_addr.s_addr,buf,sizeof(buf)),
ntohs(client_addr.sin_port));
while(1)
{
len = read(cfd,buf,sizeof(buf)); //读取客户端的数据
if(len == -1)
perr_exit("read error");
/*
if(len == 0)
{
printf("the other size closed\n");
close(cfd);
close(sfd);
exit(1);
}
*/
if(write(STDOUT_FILENO,buf,len) < 0) //输出到屏幕
perr_exit("write error");
for(i = 0 ;i < len ; i++) //进行大写转换
buf[i] = toupper(buf[i]);
if(write(cfd,buf,len) < 0) //写数据到客户端
perr_exit("write error");
}
//关闭打开的文件描述符,虽然不会执行到这里往下的部分,但要养成良好习惯,打开文件描述符,用完
//就要关闭
close(sfd);
close(cfd);
return 0;
}
内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。
(可以用uion去测试本机是小端还是大端,这里就不去讨论了,有兴趣可以百度一下)
网络数据流同样有大端小端之分,那么如何定义网络数据流的地址呢?
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此网络数据流的地址应这样规定:
先发出的数据是低地址,后发出的数据是高地址。
TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。
但是在主机中一般是用小端存储(高地址低字节),这就不可避免会遇到数据解释错误的问题。
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换
#include
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
h表示host,n表示network,l表示32位长整数,s表示16位短整数。
如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,
如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
服务器程序搞定了,接下来就需要写客户端程序去连接服务器程序了。客户端就不需要bind了,为什么呢, 这是因为由于客户端不需要固定的端口号,这样客户端的端口号由内核自动分配就可以了。(注意,客户端不是不允许调用bind(),只是没有必要调用bind()固定一个端口号,服务器也不是必须调用bind(),但如果服务器不调用bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦。)
所以在客户端程序上我们就不需要bind()了,而是直接connect()去连接服务器
5.connect()
#include /* See NOTES */
#include
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
与前面的bind()的参数类型差不多,返回值所代表的意义也类似,就不继续解释了
sockdf:
socket文件描述符
addr:
传入参数,指定服务器端地址信息,含IP地址和端口号
addrlen:
传入参数,传入sizeof(addr)大小
返回值:
成功返回0,失败返回-1,设置errno
到此,client的基本架构就结束了,等待connect连上服务器后,便可对socket文件描述符进行read,write操作来进行通信,具体代码如下(为了简化逻辑,也没有过多的出错处理)
/*client.c*/
#include
#include
#include
#include
#include
#include
#include
#include
#define SERV_PORT 8000
void perr_exit(const char *str)
{
perror(str);
exit(1);
}
int main(int argc,char *argv[])
{
int sfd,len;
sfd = socket(AF_INET,SOCK_STREAM,0);
char buf[256];
struct sockaddr_in serv_addr;
bzero(&serv_addr,sizeof(serv_addr));
//init
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET,argv[1],&serv_addr.sin_addr.s_addr);//转换char *IP地址为网络二进制序
if(connect(sfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr)) < 0)
perr_exit("connect error");
while(fgets(buf,sizeof(buf),stdin))//读取终端输入的数据
{
if(write(sfd,buf,strlen(buf)) < 0)//写入数据到服务器
perr_exit("write error");
len = read(sfd,buf,sizeof(buf));//读取服务器传递的数据
if(len <0)
perr_exit("read error");
if(len == 0)
{
printf("the other size closed\n");
close(sfd);
exit(1);
}
if(write(STDOUT_FILENO,buf,len) < 0)//输出到终端
perr_exit("write error");
}
return 0;
}
代码进行编译过后
运行结果如下
服务器端在没有客户端连上来时,处于阻塞
客户端(127.0.0.1代表本地地址)
通信过程
1.客户端读取终端输入,并将信息传递给服务器
2.服务器收到客户端传递过来的数据,输出到终端,并转换成大写,传递给客户端
3.客户端接受到服务器的数据,输出到终端
客户端
进行完简单的通信了,我们就来讨论下其中涉及到的一些网络知识
服务器调用socket(),bind(),listen()完成初始化后,调用accept()阻塞等待。
客户端调用socket()完成初始化,发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到从connect()返回,同时应答一个ACK,服务器收到后从accept()返回。
这样就建立起了链接,这便是TCP建立连接时的三次握手。
以下为比较官方的解释,参考自书籍
TCP建立起连接的方式(三次握手)
1.客户端发出段1,SYN位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况,另外,规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001。mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
2.服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明mss为1024。
3.客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。
在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为’‘’三方握手(three-way-handshake)”’。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。
数据传输的过程:
1.客户端发出段4,包含从序号1001开始的20个字节数据。
2.服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback。
3.客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。
关闭连接的过程:(4次挥手)
1.客户端发出段7,FIN位表示关闭连接的请求。
2.服务器发出段8,应答客户端的关闭连接请求。
3.服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。
4.客户端发出段10,应答服务器的关闭连接请求。
CLOSED:
表示初始状态
LISTEN:
表示服务器端的SOCKET处于监听状态
SYN_SENT:
可以在字面上进行理解,表示发送SYN报文,当客户端程序调用connect(),它需要先发起连接请求,发送SYN报文,随机便进入该状态。
SYN_RCVD:
与上面的类似,表示收到SYN。即这个状态是服务器端在三次握手期间收到客户端发起请求连接的报文SYN。
ESTABLISHED:
表示连接已经建立(即3次握手完成)
FIN_WAIT_1
表示等待对方FIN报文。与FIN_WAIT2不同的是,当SOCKET主动关闭连接时,向对方发送起结束连接请求FIN报文,此时该SOCKET就处于FIN_WAIT_1状态。
CLOSE_WAIT
表示等待关闭,当接受到对方发送FIN报文,系统会对这个FIN报文回应ACK应答,此时便进入到CLOSE_WAIT状态。若此时没有数据需要进行处理,那么就可以关闭这个SOCKET,并发送FIN报文给对方,表示关闭连接
FIN_WAIT_2
当处于FIN_WAIT_1的SOCKET收到对方的ACK应答之后,则从FIN_WAIT_1进入FIN_WAIT_2,表示半关闭连接。即有一方要求关闭连接,但另外一方仍有数据需要处理,并要发送数据回去,只能稍后再关闭连接。
TIME_WAIT
表示收到了对方的FIN报文,并发送ACK报文,就等2MSL后就可以返回CLOSED状态。在此一提,假设处于FIN_WAIT_1状态下,如果同时收到对方的ACK和FIN报文的话,就直接进入TIME_WAIT状态,而不会经历FIN_WAIT_2状态
LAST_ACK:即当处于CLOSE_WAIT,发送FIN报文后,在等待对方的ACK报文。当收到ACK报文后,也就可以处理初始化的CLOSED状态
另外还有图中没有涉及到的状态CLOSING,这种状态比较特殊,以我们的正常理解来说一般都是当你发送FIN报文主动去关闭连接的时候,应该先收到对方的ACK应答,在收到对方的FIN报文。
而CLOSING状态表明是没收到对方的ACK应答,却收到了对方的FIN报文,那么此时就会处于CLOSING状态,表示双方都正在关闭连接。
这里我的端口号是8000
0.0.0.0表示全部网络,指的是全部网络都能连到这台主机上
对于网络状态的查询我们可以使用命令netstat -apn | grep 8000,依次对应的选项为
Proto协议 Recv-Q网络接收队列 Send-Q网络发送队列 Local Address Foreign Address State PID/Program name
如果当我们直接ctrl+c终止掉客户端程序后,立即开启另外一个终端查看时,此时服务器接受到客户端发出的FIN报文后,并发送ACK报文给客户端,此时服务器便处于CLOSE_WAIT状态,但由于还处在while(1)的大循环中,故不会发送FIN报文给客户端,故此时客户端就会处于FIN_WAIT_2状态,而服务器就会处于CLOSE_WAIT状态。
等过一段时间服务器write数据给客户端之后,客户端就会脱离FIN_WAIT_2状态,而进入CLOSED,而奇怪的是服务器端仍处于CLOSE_WAIT状态,这是因为当read在读取数据时候,在没有数据的时候会返回0,使得,服务器端一直处于while(1)大循环中,无法脱离,这时我们需要优化下程序。(即加上len==0的判断,把我的下面那个注释去掉就可以了)
当把注释去掉,便是完整的简易TCP 模型,当CTR+C掉一个服务器端或者客户端,相应的另一端都会关闭。
也许你会遇到 bind error,这可能是因为你服务器绑定的端口被其他进程用了,可以用netstat -apn | grep 端口号查看,或者是前面执行的./server还处于TIME_WAIT状态,要等2MSL时间,才会返回初始状态CLOSED,Linux的话普遍大概1分钟。