套接字的数据结构
C 语言将套接字定义为一个结构(struct)。套接字结构由五个字段组成;每个套接字地址是一个由五部分构成的结构。
族
。这个字段定义了协议簇(如何解释地址和端口号)。
- 通常值是
PF_INET
(用于当前因特网)、PF_INET6(用于下一代因特网)等等。我们在本节使用PF_INET。
类型
。这个字段定义了四个套接字类型:
- SOCK_STREAM(用于TCP)
- SOCK_DGRAM(用于UDP)、
- SOCK_SEQPACKET(用于SCTP)
- SOCK_RAW(用于直接使用ISP 服务的应用)。
协议
。这个字段定义了族中特定协议。对于TCP/IP 协议簇这个字段设置为0,因为它是族中唯一的协议。
本地套接字地址
。这个字段定义了本地套接字地址。
- 一个套接字地址是一个结构,它由
长度字段
、族字段
(对于TCP/IP 协议簇,它被设置为常量AF_INET
)、端口号字段
(定义了进程)以及IP 地址字段
(定义了正在运行的进程所在的主机)构成。它也包含未使用字段。
远程套接字地址
。这个字段定义了远程套接字地址。它的结构与本地套接字地址相同。
头文件
1 2 3 4 5 6 7 8 9 10 11 12
| #include <stdio.h> #include <stdlib.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <netdb.h> #include <errno.h> #include <signal.h> #include <unistd.h> #include <string.h> #include <arpa/innet.h> #include <sys/wait.h>
|
使用 TCP 通信
- TCP 是面向连接的协议。在发送或接收数据之前,需要在客户端和服务器之间建立连接。在连接建立之后,只要它们有数据要发送或接收,两端就可以彼此发送以及接收数据块。
- TCP 连接可以是迭代的(一次服务一个客户)也可以是并发的(一次服务多个客户)。
TCP 中使用的套接字
TCP 服务器使用两个不同的套接字:
- 一个用于连接建立。称为
监听套接字(listen socket)
。
- 一个用于数据传输。称为
套接字(socket)
。
- 设置两种套接字的目的是将建立阶段和数据交换阶段分开。
服务器使用监听套接字来监听试图建立连接的新客户。在连接建立之后,服务器创建一个用于和客户交换数据的套接字并且最终终止连接。
客户只使用一个套接字用于连接建立以及数据交换。
通信流程图
服务器进程
- TCP 服务器进程调用socket 和bind 函数,但是这两个函数创建监听套接字,它只在连接建立阶段被使用。
- 之后,服务器进程调用listen函数,允许操作系统开始接收客户、完成连接阶段并把他们放入等待被服务的列表。
- 这个函数也定义了被连接的客户等待列表的大小,这依赖于服务器进程的复杂性,但是通常值为5。
- 现在,服务器进程开始循环并且逐一对客户进行服务。
- 在每次循环中,服务器进程调用accept函数从已连接客户的等待列表中去除一个客户,对其进行服务。
- 如果列表是空的,那么accept 函数进入阻塞状态直到出现一个客户待服务。
- 当accept 函数返回,它创建一个新的与监听套接字一样的套接字。
- 监听套接字现在移入后台,并且新的套接字成为活动套接字。
- 服务器进程现在使用连接建立期间获得的客户套接字地址,用它来填充新建套接字的远程套接字地址。
此时,客户和服务器可以交换数据。我们没有给出数据传输的特定方式,因为这取决于特定的客户-服务器对。
- TCP 使用
send
以及recv
程序在它们之间传输数据字节。这两个函数比UDP 中使用的sendto 和recvfrom 函数更简单,因为它们不提供远程套接字地址;连接已经在客户和服务器之间建立。
- 然而,由于TCP 用于传输无边界报文,每个应用需要仔细设计数据传输部分。
send 和recv 函数可能被调用多次来处理大量数据传输。可以将上图的流程图当作一个通用流程图;如果是特殊用途,需要定义服务器数据传输(sever data-transfer)盒。
客户进程
客户进程进行主动开启(active open)。换言之,它开启连接。它调用socket 函数来创建一个套接字并填充前三个字段。
尽管某些实现要求客户进程也调用bind 函数来填充本地套接字,但通常这是由操作系统自动完成的,操作系统为客户选择一个临时端口号
最终close 函数被调用以销毁套接字。
客户流程图与UDP 版本类似,除了客户数据传输(client data-transfer)盒需要为每个特定情况定义。
套接字接口编程(TCP)
编写客户和服务器程序来模拟使用TCP的标准回送应用——客户程序发送一个短的字符串给服务器;服务器将相同的字符串回送到客户。在我们这样做之前,需要为客户和服务器数据传输盒提供流程图
客户和服务器数据传输盒的流程图
(发送消息和回送消息)对于发送和回送短的字符这个特定的情况,因为待发送的字符串很短(小于几个单词),我们可以在客户端调用send函数一次完成。
然而,TCP 并不保证把整个报文在一个报文段内发送。因此,我们需要在服务器端调用一组recv(在一个循环内)来接收整个报文并将它们收集到缓冲区内,从而能一次性发送回去。
当服务器向客户发送回送报文时,它也可能使用多个报文段,这意味着客户的recv 程序需要调用多少次就会被调用多少次。
缓冲区设置
另一个有待解决的问题是设置缓冲区,缓冲区用于在每个站点接收数据。
- 我们需要控制接收的字节数以及下一个数据块存储的位置。
- 如图所示,程序设置了一些变量进行控制。
- 在每次迭代中,
指针(ptr)
移动指向下一个要接收的字节,接收字节的长度(len)
呈增长趋势并且待接收的最大字节数(maxLen)
呈减少趋势。
回送服务器程序
程序遵循迭代TCP通信流程图。
第 6 行到第16 行声明并定义了变量。
第18 行到第21 行分配内存并且按UDP 情况下所述创建了本地(服务器)套接字地址。
第23 行到第27 行创建了监听套接字。第29 行到第33 行将监听套接字绑定到第18 行到第21 行创建的服务器套接字地址上。
第35 行到第39 行是TCP 通信中的新内容。调用 listen 函数让操作系统完成连接建立阶段并将客户置入等待列表。
第44 行到第48 行调用accept 函数来移除等待列表中的第一个客户并开始为其服务。如果在等待列表中没有客户,那么这个函数处于阻塞状态。
第50 行到第56 行对图2-63 中描述的数据传输部分进行编码。最大缓冲区大小与回送字符串长度都和图5中所示相同。
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60
| # include"headerFiles.h" int main() { int ls; int s; char buffer[256]; char *ptr=buffer; int len=0; int maxLen=sizeof(buffer); int n; int waitSize=16; struct sockaddr_in servAddr; struct sockaddr_in clntAddr; int clntAddrLen; memset(&servAddr,0,sieof(servAddr)); servAddr.sin_family=AF_INET; servAddr.sin_addr.s_addr=htonl(INADDR_ANY); servAddr.sin_port=htonl(SERV_PORT); if(ls=socket(PF_INET,SOCK_STREAM,0)<0) { perror("Error:Listen socket failed!"); exit(1); } if(bind(ls,&servAddr,sizeof(servAddr))<0) { perror("Error:binding failed!"); exit(1); } if(listen(ls,waitSize)<0) { perror("Error:listening failed!"); exit(1); } for(;;) { if(s=accept(ls,&clntAddr,&clntAddrLen)<0) { perror("Error:accepting failed!"); exit(1); } while((n=recv(s,ptr,maxLen,0))>0) { ptr+=n; maxLen-=n; len+=n; } send(s,buffer,len,0); close(s); } }
|
tips:
- 在C/C++写网络程序的时候,往往会遇到字节的网络顺序和主机顺序的问题。这是就可能用到htonl(), ntohl(), ntohs(),htons()这4个函数。
- htonl()–”Host to Network Long”
- ntohl()–”Network to Host Long”
- htons()–”Host to Network Short”
- ntohs()–”Network to Host Short”
回送客户程序
TCP 的客户程序与UDP 的客户程序非常相似,只有些许不同。
- 因为TCP 是面向连接的协议第36 行到第40 行调用connect 函数连接服务器。
- 第42 行到第48 行使用图3中的思想完成数据传输。
- 按图5所示方式完成接收数据的长度调整和指针移动。
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 44 45 46 47 48 49 50 51 52 53 54 55 56 57
| # include"headerFiles.h" int main(int argc,char *argv[]) { int s; int n; char servName; int servPort; char *string; int len; char buffer[256+1]; char *ptr=buffer; struct sockaddr_in serverAddr; if(argc!=3) { printf("Error:three arguments are needed!"); exit(1); } servName=argv[1]; servPort=atoi(argv[2]); string=arg[3]; memset(&serverAddr,0,sieof(serverAddr)); servAddr.sin_family=AF_INET; inet_pton(AF_INET,servName,&serverAddr.sin_addr); serverAddr.sin_port=htons(SERV_PORT); if(ls=socket(PF_INET,SOCK_STREAM,0)<0) { perror("Error: socket creation failed!"); exit(1); } if(connect(sd,(struct sockaddr*)&serverAddr,sizeof(serverAddr))<0) { perror("Error:connection failed!"); exit(1); } send(s,string,strlen(string),0); while((n=recv(s,ptr,maxLen,0))>0) { ptr+=n; maxLen-=n; len+=n; } buffer[len]='\0'; printf("Echoed string received:"); fputs(buffer,stdout); close(s); exit(0); }
|
Linux下的socket()函数
inux中的一切都是文件,每个文件都有一个整数类型的文件描述符
;
socket也是一个文件,也有文件描述符。使用socket()函数创建套接字以后,返回值就是一个 int类型的文件描述符。
在 Linux 下使用 <sys/socket.h> 头文件中 socket()函数来创建套接字,原型为:
1
| int socket(int af, int type, int protocol);
|
1) af 为地址族(Address Family)
,也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。
- AF 是“Address Family”的简写,INET是“Inetnet”的简写。
- AF_INET 表示 IPv4 地址,例如 127.0.0.1;
- AF_INET6 表示 IPv6 地址,例如 1030::C9B4:FF12:48AA:1A2B。
- 也可以使用 PF 前缀,PF 是“Protocol Family”的简写,它和 AF 是一样的。例如,PF_INET 等价于 AF_INET,PF_INET6 等价于 AF_INET6。
需要记住127.0.0.1,它是一个特殊IP地址,表示本机地址
2) type 为数据传输方式/套接字类型,
- 常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字)
- SOCK_DGRAM(数据报套接字/无连接的套接字)
3) protocol 表示传输协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。
有了地址类型和数据传输方式,还不足以决定采用哪种协议吗?为什么还需要第三个参数呢?
正如大家所想,一般情况下有了 af 和 type 两个参数就可以创建套接字了,操作系统会自动推演出协议类型,除非遇到这样的情况:有两种不同的协议支持同一种地址类型和数据传输类型。如果我们不指明使用哪种协议,操作系统是没办法自动推演的。
本教程使用 IPv4 地址,参数 af 的值为 PF_INET。如果使用 SOCK_STREAM 传输数据,那么满足这两个条件的协议只有 TCP,因此可以这样来调用 socket() 函数:
1
| int tcp_socket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
这种套接字称为 TCP 套接字。
如果使用 SOCK_DGRAM 传输方式,那么满足这两个条件的协议只有 UDP,因此可以这样来调用 socket() 函数:
1
| int udp_socket = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
|
这种套接字称为 UDP 套接字。
上面两种情况都只有一种协议满足条件,可以将 protocol 的值设为 0,系统会自动推演出应该使用什么协议,如下所示:
1 2
| int tcp_socket = socket(AF_INET, SOCK_STREAM, 0); int udp_socket = socket(AF_INET, SOCK_DGRAM, 0);
|
Windows下的socket()函数
Windows 会区分 socket 和普通文件,它把 socket 当做一个网络连接来对待,调用 socket() 以后,返回值是 SOCKET 类型,用来表示一个套接字。
Windows 下也使用 socket() 函数来创建套接字,原型为:
1
| SOCKET socket(int af, int type, int protocol);
|
除了返回值类型不同,其他都是相同的。Windows 不把套接字作为普通文件对待,而是返回 SOCKET 类型的句柄。
参考资料
计算机网络-自顶向下方法
bind()和connect()函数:绑定套接字并建立连接
listen()和accept()函数:让套接字进入监听状态并响应客户端请求
send()/recv()和write()/read():发送数据和接收数据
TCP协议的无消息边界问题
TCP协议的粘包问题(数据的无边界性)
TCP网络传输“粘包”问题,经典解决(附代码)
socket()函数用法详解:创建套接字