初识TCP/IP

虽然协议族被称为“TCP/IP”,但其实包含很多成员,本文主要介绍:

  • TCP连接的建立和终止
  • 字节处理函数
  • 基本套接字函数

TCP连接的建立和终止

TCP连接的建立(三次握手)

TCP即面向连接的、全双工的、可靠的传输控制协议。建立一个TCP连接时发生以下情形:

  1. 服务器必须准备好接收外来的连接。通常由socket,bind,listen三个函数来完成,即被动打开
  2. 客户端通过调用connect发起主动打开,这导致客户TCP发送一个SYN同步分节,它告诉服务器客户将在连接中发送的数据的初始序列号。通常SYN分节不携带数据,其所在IP数据报只含有一个IP首部,一个TCP首部以及可能有的TCP选项。
  3. 服务器必须确认(ACK)客户的SYN,同时自己也得发送一个SYN分节,它含有服务器在同一连接中发送的数据的初始序列号
  4. 客户必须确认服务器的SYN。
    这种交换至少需要3个分组,因此称为TCP的三次握手

    客户的初始序列号为J,服务器的初始序列号为K,ACK中的确认号是发送这个ACK的一端所期待的下一个序列号,因为SYN占据一个字节的序列号空间,所以每个SYN的ACK确认号就是该SYN的初始序列号加1。

TCP连接的释放(四次挥手)

TCP建立一个连接需要3个分节,终止一个连接则需要4个分节。

  1. 某个应用进程首先调用close执行主动关闭,该端的TCP于是发送一个FIN分节,表示数据发送完毕。
  2. 接收到这个FIN的对端执行被动关闭,这个FIN由TCP确认,它的接收也作为一个文件结束符传递给接收端应用进程(放在已排队等候该应用进程接收的其他数据之后),因为FIN的接收意味着接收端应用进程在相应连接上再无额外数据可以接收。
  3. 一段时间后,接收到这个文件结束符的应用进程调用close关闭它的套接字。这导致它的TCP也发送一个FIN。
  4. 接收这个最终FIN的原发送端TCP确认这个FIN。

字节处理

字节序处理函数

考虑一个16位整数,由两个字节组成。内存中存储这两个字节有两种方法:一种是将低序字节存储在起始地址,即小端字节序;另一种是将高序字节存储在起始地址,即大端字节序。
ipv4地址和TCP或UDP端口号在套接字地址结构中总是以网络字节序来存储。网际协议使用大端字节序来传送这些多字节整数。
在主机字节序和网络字节序之间的转换可以使用如下四个函数:

1
2
3
4
5
6
7
#include<netinet/in.h>
uint16_t htons(uint16_t host16bitvalue);
uint32_t htonl(uint32_t host32bitvalue);
//返回网络字节序
uint16_t ntohs(uint16_t net16bitvalue);
uint32_t ntohl(uint32_t net32bitvalue);
//返回主机字节序

此处参数和返回值均为16或32位的二进制数,但是一般我们使用的是点分十进制表示IP地址和端口,故需要两个与协议无关的点分十进制与二进制转换的函数:

1
2
3
4
5
#include<arpa/inet.h>
int inet_pton(int family,const char *strptr,void *addrptr);
//成功返回1,输入无效返回0,出错返回-1
const char *inet_ntop(int family,const void *addrptr,char *strptr,size_t len);
//若成功返回指向结果的指针,出错则为NULL

family参数既可以是AF_INET,也可以是AF_INET6。
第一个函数尝试转换由strptr指针所指的字符串,并通过addrptr指针存放二进制结果。
第二个函数做相反转换,从数值格式addrptr转换到点分十进制表达式格式strptr,len参数是目标存储单元的大小,以免该函数溢出调用者的缓冲区。

b系函数与mem系函数(我起的名字…)

1
2
3
4
#include<strings.h>
void bzero(void *dest,size_t nbytes);
void bcopy(const void *src,void *dest,size_t nbytes);
int bcmp(const void *ptr1,const void *ptr2,size_t nbytes); //相等则为0,否则非0

bzero把目标字节串中指定数目的字节置为0,经常使用该函数把套接字地址初始化为0(虽然我以前一直用memset)。
bcopy将指定数目的字节从源字节串(src)复制(注:书上说的是移到)到目标字节串(dest)。
bcmp比较任意两个字节串,相同返回0,否则返回非0。

1
2
3
4
#include<string.h>
void *memset(void *dest,int c,size_t len);
void *memcpy(void *dest,void *src,size_t nbytes);
int memcmp(const void *ptr1,const void *ptr2,size_t nbytes); //相等返回0,否则返回<0或>0

memset把目标字节串指定数目的字节置为值c,(注:其实c只能取0或-1)。
memcpy类似bcopy,但是顺序相反,可以将memcpy两个参数的顺序看作类似C语言赋值的顺序:dest = src。当源字节串与目标字节串重叠时,bcopy可以正常处理,但是memcpy会出问题,此时需要用memmove函数。
memcmp的返回值类似strcmp,不做过多解释。

下面看两个CSAPP上的例题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//点分十进制转十六进制
#include<arpa/inet.h>
#include<stdlib.h>
#include<string.h>
#include<stdio.h>
int main(int argc,char *argv[]){
struct in_addr addr;
if(argc != 2){
fprintf(stderr,"%s",argv[0]);
exit(1);
}
if(inet_pton(AF_INET,argv[1],&addr)<=0){ //先将输入的点分十进制串转换为IP存在addr.s_addr中(大端法)
printf("error");
exit(1);
}
printf("0x%x\n",ntohl(addr.s_addr)); //将该二进制转为机器表示字节序,然后再输出其十六进制
exit(0);
}

//十六进制转点分十进制
#include<arpa/inet.h>
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
int main(int argc,char *argv[]){
struct in_addr info;
uint32_t addr;
char buf[1024];
memset(buf,0,sizeof(buf));
if(argc != 2){
fprintf(stderr,"%s",argv[0]);
exit(1);
}
sscanf(argv[1],"%x",&addr); //字符串转存成数字
info.s_addr = htonl(addr); //变为网络字节序存到in_addr.s_addr中

if(!inet_ntop(AF_INET,&info,buf,1024)){ //将该二进制换为点分十进制存到buf中
printf("error");
exit(1);
}
printf("%s\n",buf);
exit(0);
}

基本套接字函数

socket函数

为执行网络IO,一个进程必须做的第一件事就是调用socket函数, 指定期望的通信协议类型(TCP、UDP、IPv4,、IPv6等)

1
2
#include<sys/socket.h>
int socket(int family,int type,int protocol); //成功返回非负描述符,出错返回-1

family指明协议类型,type指明套接字类型,protocol可以设为某个协议常值,(一般设为0)。成功返回的值称为套接字描述符,此时只是指定了协议族和套接字类型,并没有指定本地协议地址和远程协议地址(可以说此时的套接字只是一个空壳)。此时套接字状态为CLOSED。

connect函数

TCP客户用connect函数来建立与TCP服务器的连接。

1
2
3
#include<sys/socket.h>
int connect(int sockfd,const struct sockaddr *servaddr,socklen_t addrlen);
//成功返回0,出错返回-1

sockfd为socket函数的返回值,也就是套接字描述符,第二个,第三个参数分别表示一个指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。
connect函数导致当前套接字从CLOSED状态转移到SYN_SENT状态,如果成功再转移到ESTABLISHED状态。若connect失败则该套接字不再可用,必须关闭,不能对这样的套接字在此调用connect函数。

bind函数

bind函数把一个本地协议地址赋予一个套接字。对于网际协议,协议地址是32位的IPv4地址或128位的IPv6地址与16位的TCP或UDP端口号的组合。

1
2
#include<sys/socket.h>
int bind(int sockfd,const struct sockaddr *myaddr,socklen_t addrlen);

第二个参数是一个指向特定于协议的地址结构的指针,第三个参数是该结构的长度。对于TCP,调用bind函数可以指定一个端口号,或者一个IP地址,也可以两者都指定,也可以都不指定。
如果指定端口号为0,那么内核就在bind函数被调用的时候选择一个临时端口。如果指定IP地址为通配符,那么内核将等到套接字已连接(TCP)或已在套接字上发出数据报(UDP)时才选择一个本地IP地址。
对于IPv4,通配地址由常值INADDR_ANY来指定:

1
2
struct sockaddr_in servaddr;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY)

对于IPv6,因为其地址放在一个结构中,应这样使用:

1
2
struct sockaddr_in6 serv;
serv.sin6_addr = in6addr_any;

listen函数

listen函数由TCP服务器调用,它做两件事:

  1. 当socket函数建立一个套接字时,它被默认为是一个主动套接字,即可以调用connect发起连接的套接字。调用listen函数把一个未连接的套接字转换为被动套接字,指示内核应接受指向该套接字的连接请求。listen函数使得套接字由CLOSED状态转移到LISTEN状态。
  2. 规定内核应该为相应套接字排队的最大了连接个数(即函数的第二个参数)
1
2
#include<sys/socket.h>
int listen(int sockfd,int backlog); //成功返回0,出错返回-1

listen应该出现在socket函数和bind函数之后,accept函数之前。
内核为任何一个给定的监听套接字维护两个队列:

  1. 未完成连接队列:已由客户端发出并到达服务器,而服务器正在等待完成TCP三次握手过程。这些套接字处于SYN_RCVD状态。
  2. 已完成连接队列:每个已完成TCP三次握手的客户对应其中一项。这些套接字处于ESTABLISHED状态。


当来自客户的SYN分节到达时,服务器TCP在未完成队列创建一个项,然后响应三次握手的第二个分节(服务器的SYN+对客户的ACK),该项一直保留,直到第三个分节到达或者超时。三次握手成功,该项被移到已完成队列队尾。调用accept时已完成队列的队头项返回给进程,如果该队列为空,进程睡眠,直到TCP在该队列放入一个项。
listen函数的第二个参数即两队列项数和的最大值

accept函数

accept函数由服务器调用,用于从已完成连接队列队头返回下一个连接。如果已完成连接队列为空,则进程进入睡眠。

1
2
3
#include<sys/socket.h>
int accept(int sockfd,struct sockaddr *cliaddr,socklen_t *addrlen);
//成功返回已连接描述符(非负),出错返回-1

第一个参数为listen返回的监听套接字描述符,参数cliaddr和addrlen用来返回已连接的对端进程(客户)的协议地址。调用前,将由*addrlen所引用的整数值置为由cliaddr所指的套接字地址结构的长度。如果对返回客户的协议地址不感兴趣,可以将cliaddr和addrlen置为NULL。
调用成功则返回一个新的描述符,即已连接套接字描述符,它与监听套接字描述符不同。一个服务器通常仅仅创建一个监听套接字,它在服务器的生命期内一直存在,内核为每个服务器进程接受的客户连接创建一个已连接套接字。服务器完成对给定客户的服务时,相应的已连接套接字就关闭。
下面看两个UNP上TCP时间获取的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//client端:
#include<stdio.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#define MAXN 1024
int main(int argc,char *argv[]){
int sockfd,n;
char recvline[MAXN+1];
struct sockaddr_in servaddr;
if((sockfd = socket(AF_INET,SOCK_STREAM,0)) == -1)
exit(1); //socket error
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(13); //获取时间的端口号
if(inet_pton(AF_INET,argv[1],&servaddr.sin_addr)<=0)//点分十进制转二进制
exit(1); //inet_pton error
if(connect(sockfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0)
exit(1); //connect error
while((n = read(sockfd,recvline,MAXN)) > 0){
recvline[n] = 0;
if(fputs(recvline,stdout) == EOF)
exit(1); //fputs error
}
if(n<0)
exit(1); //read error
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//server 端:
#include<stdio.h>
#include<sys/socket.h>
#include<arpa/inet.h>
#include<netinet/in.h>
#include<string.h>
#include<time.h>
#include<stdlib.h>
#define MAXN 1024
int main(int argc,char *argv[]){
int listenfd,connfd;
struct sockaddr_in servaddr;
char buff[MAXN];
time_t ticks;
if((listenfd = socket(AF_INET,SOCK_STREAM,0))<0)
exit(1); //socket error
bzero(&servaddr,sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(13);
if(bind(listenfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0)
exit(1);
if(listen(listenfd,20) == -1)
exit(1); //listen error
while(1){
if((connfd = accept(listenfd,(struct sockaddr *)NULL,NULL)) == -1)
exit(1); //accept error
ticks = time(NULL);
snprintf(buff,sizeof(buff),"%.24\r\n",ctime(&ticks));
write(connfd,buff,strlen(buff));
close(connfd);
}
return 0;
}

刚开始学习网络编程,这篇写的比较浅,干货不多…
参考资料:
《深入理解计算机系统 3th》(CSAPP)
《UNIX网络编程 v1 3th》(UNP)