在上一篇中,我们具体介绍了socket的相关概念,本节将概述套接字规范及操作的一些基础性知识。
套接字概念及WinSock规范
Windows通信相关驱动
- netio.sys(Network I/O Subsystem)
- ndis.sys(NDIS Driver)
- ipnat.sys(IP Network Address Translator)
- tcpip.sys(TCP/IP Driver)
- tdtcp.sys(TCP Transport Driver)
- tdi.sys(TDI wrapper)
- afd.sys(Ancillary Function Driver for Winsock)
- http.sys(HTTP Protocol Stack)
- ……
socket 描述符(套接字句柄)
“在 UNIX 系统中,任何东西都是一个文件。”这句话传达了这样一个理念:在 UNIX 系统中,任何 I/O 操作,都是通过读或写一个文件描述符(File Descriptor,FD)来实现的。
文件描述符只是一个简单的整形数值,代表一个被打开的文件。这里的“文件”并非狭义上的磁盘文件,而是指广义上的文件,它可以代表一个网络上的连接、一个先进先出的队列、一个终端显示屏等。
既然在 UNIX 系统中任何东西都可以抽象为一个文件,通过 Internet 和另外一台机器进行通讯这一典型的 I/O 机制也是基于文件描述符来定义实现的。这个文件描述符即套接字内核对象:int sockfd。在早期的 UNIX/Linux 系统中,可直接调用 read() 和 write() 像操作文件那样对套接字执行类似的读写操作,尽管调用 recv() 和 send() 显得更为专业。
在 Windoze 系统中,内核对象往往交由一个句柄与外部交互,如文件句柄。在很多 WinSock 场合我们习惯使用“套接字句柄”这一称呼:typedef u_int SOCKET。实际上,WinSock 中对于套接字的操作,很大程度上也沿袭了文件操作的规范。例如,在 Winsock 1 规程中,应用程序可以针对套接字句柄调用 ReadFile() 和 WriteFile(),同时指定重叠结构以利用重叠 I/O 模型,到 Winsock 2 中才正式替换为 WSARecv() 和 WSASend(),以专用于套接字操作。
Windows Sockets规范
Sockets 本来是 UNIX 操作系统下流行的一套网络编程接口(API),它是 1983 年在 Berkeley(加州大学伯克利分校)4.2 BSD 操作系统中被首先引入的,因此也被称作“Berkeley Socket API”。
Windows Sockets 是在 Windows 环境下使用的一套网络编程规范,常常简称为 WinSock。在 Winsock 规范中,API 函数集被分为三类:与 BSD Socket 相兼容的基本函数,网络数据信息检索函数和 Windows 专用扩展函数。
Windows Socket 1 规范的核心内容是符合 Berkeley Socket 风格的库函数,例如可以编写基于 select 模型的的跨平台socket 通信库。鉴于后来各大平台都移植支持了 Berkeley Socket 的这一基本 I/O 模型, select 模型可以很好地实现跨平台,但对具体操作系统平台而言并非性能最佳。为了使程序员能充分利用 Windows 消息驱动机制,MS 又定义开发了一组针对 Windows 的扩展库函数,这就是 Windows Socket 2 规范。
Windows Socket 2 规范(Winsock 2.x)提供了基于 Windows 消息机制的 WSAAsyncSelcet 异步 I/O 网络事件通知模型。此外,WinSock 2.x 还提供了基于事件通知的异步 I/O 网络事件通知的 WSAEventSelcet 模型和高效的重叠 I/O 模型。其他操作系统平台也各自实现了更加高效的网络 I/O 管理模型,如 Windoze 的 IOCP 模型、Linux 的 epoll 模型,这些模型基于系统特性和内核机制最大限度地平衡了 I/O 效能(大规模、高并发)。
目前常用的 Winsock 有两个版本:一个是 16 位的 Winsock 1.1,由动态链接库 WINSOCK.DLL 提供支持;另一个是 32 位的 Winsock 2.2,由动态链接库 WSOCK32.DLL 提供支持。
从 Win98/NT4 开始,Windows 引入 WinSock 2,而 WinSock 1 成为 WinSock 2 的功能子集。在 32 位系统下,16 位的 WINSOCK.DLL(Windows Socket 16-bit DLL)为 Non-resident。
ws2_32.dll 和 mswsock.dll 是 WinSock 2 真正的实现者,使用 Dependency Walker 可以看到 wsock32.dll 和 mswsock.dll 都依赖 ws2_32.dll。wsock32.dll 只是映射了 ws2_32.Dll 和 mswsock.dll 这两个函数集的部分内容,并无具体实现。
mswsock.dll 提供了微软特有的 WSA 扩展,包括 AcceptEx/GetAcceptExSockaddrs、ConnectEx/DisconnectEx、TransmitPackets/TransmitFile、WSARecvMsg。其中仅有 AcceptEx/GetAcceptExSockaddrs 和 TransmitFile 这三个函数真正从 mswsock.dll 导出。
如果要求编写符合 Berkeley Socket API 标准的程序,则只需要加载 wsock32.dll 使用 WinSock 1.x 规范(BSD Socket API for Windows)即可。如果要结合 Windows 平台特性编写 WinSock 2.x 的程序,则可加载 ws2_32.dll 和 mswsock.dll 调用 WSA 系列(扩展)函数。加载 WinSock 库是通过 WSAStartup() 来指定的。
加载WinSock 1.1
/* WINSOCK.H--definitions to be used with the WINSOCK.DLL
* Copyright (c) Microsoft Corporation. All rights reserved.
*
* This header file corresponds to version 1.1 of the Windows Sockets specification.
*
* (1)Basic system type definitions, taken from the BSD file sys/types.h.
* (2)Structure used in select() call, taken from the BSD file sys/time.h.
* (3)Commands for ioctlsocket(), taken from the BSD file fcntl.h.
* (4)Structures returned by network data base library, taken from the BSD file netdb.h.
* (5)Constants and structures defined by the internet system, Per RFC 790, September 1981, taken from the BSD file netinet/in.h.
* (6)Definitions related to sockets: types, address families, options, taken from the BSD file sys/socket.h.
*
* (7)Microsoft Windows Extension function prototypes:WSA开头的WSAStartup、WSACleanup等Windows Sockets API。
* (8)Microsoft Windows Extended data types:typedef了SOCKADDR、SOCKADDR_IN等宏。
*/
#include<WinSock.h>
#pragma comment(lib,"WSock32.Lib")
LoadLibrary("C://WINDOWS//system32/wsock32.dll");// Windows Socket 32-Bit DLL
Windows Mobile下对应 winsock.h/winsock.lib/winsock.dll。
加载WinSock 2.2
/* Winsock2.h -- definitions to be used with the WinSock 2 DLL and
* WinSock 2 applications.
*
* This header file corresponds to version 2.2.x of the WinSock API
* specification.
*
* #define _WINSOCKAPI_ // Prevent inclusion of winsock.h in windows.h
*/
#include<WinSock2.h>
#pragma comment(lib,"WS2_32.lib")
LoadLibrary("C://WINDOWS//system32//ws2_32.dll");// Windows Socket 2.0 32-Bit DLL
Windows Mobile下对应 winsock2.h/ws2.lib/ws2.dll。
加载Microsoft Windows-Specific Extension Functions
/*++
Module Name:
mswsock.h
Abstract:
This module contains the Microsoft-specific extensions to the Windows Sockets API.
--*/
#include <Mswsock.h>
#pragma comment(lib,"MsWSock.Lib")
LoadLibrary("C://WINDOWS//system32//mswsock.dll");// Microsoft Windows Socket 2.0 Service Provider
鉴于 WinSock 1 是 WinSock 2 的功能子集,而 WinSock 1 又沿袭兼容 BSD/Berkeley Socket API 集,故本文基于 WinSock 1 阐述 socket 编程基础,着重梳理周边背景知识及通用通信流程,以期具备普适参考价值。
套接字通信地址
至此,我还不打算直接进入套接字的一些诸如 recv() 和 send() 操作。在正式使用套接字进行通信编程之前,有必要先了解一下通信所涉及到地址识别、字节顺序等知识点。
字节顺序
字节顺序是多字节数据被存储的顺序。例如,一个 32 位的长整型 0x12345678 跨越 4 个字节(每个字节= 8 个 bit)。Intel x86 架构处理器使用小端(尾)顺序(little-endian),即低位字节首先存储。因此,数据 0x12345678 在内存中以 {0x78、0x56、0x34、0x12} 的顺序存放。大多数不使用小尾顺序的机器使用大端(尾)顺序(big-endian),即高位字节首先存储。同样的值在内存中以 {0x12、0x34、0x56、0x78} 的顺序存放。
因为协议数据要在这些机器间传输,就必须选定其中一种方式作为标准,否则会引起混淆。TCP/IP 协议统一规定使用大尾(端)方式传输数据,也称为网络字节顺序(network byte order,TCP/IP-endian)。例如,端口号12345(0x3039),它是一个 16 位的短整型数据,其存储顺序是 {0x30、0x39},与 16 进制写法及字节流顺序一致。
早期使用 PowerPC 处理器的 Mac 使用大字节序,如今的 Mac 都使用 Intel x86 芯片,而 Intel 芯片是使用小字节序的。因此,在进行网络数据的封装发送或接收读取时,需进行必要的字节序转换。
在下面的 sockaddr 和 sockaddr_in 结构中,除了 sin_family 成员(它不是协议的一部分)外,其他所有值必须以网络字节顺序存储。
Sockets API 提供了字节顺序转换函数接口 htonl()/ntohl() 和 htons()/ntohs() ,来支持本地(host)字节顺序与网络(network)字节顺序之间的转换处理。
u_long htonl (u_long hostlong); /* Host to Network Long */
u_long ntohl (u_long netlong); /* Network to Host Long */
u_short htons (u_short hostshort); /* Host to Network Short */
u_short ntohs (u_short netshort); /* Network to Host Short */
套接字地址(sockaddr、sockaddr_in)
struct sockaddr
struct sockaddr 结构用来存储套接字地址。
/*
* Structure used by kernel to store most addresses.
*/
struct sockaddr {
u_short sa_family;/* address family,AF_X */
char sa_data[14];/* up to 14 bytes of direct address */
};
sa_data包含了一些远程电脑的地址、端口和套接字的数目,它里面的数据是混杂在一起的。sa_data域的定义有些不确定性,注释暗示内容可能超过14个字节。这种不确定性是经过深思熟虑的。套接字是个非常强大的接口。多数人可能认为比Internet接口强不到哪里——大多数应用现在很可能都用它——套接字可被用于几乎任何种类的进程间通信,Internet(更精确的说是IP)只是其支持的协议簇中的一种。
#define SOCK_MAXADDRLEN 255 /*可能的最长的地址长度 */
socket层涉及到地址的API都是用sockaddr结构,这些API包括bind(服务器绑定本地地址+端口)、connect(连接服务器)/accept(接受客户端)、recvfrom/sendto。
int bind(int sockfd, struct sockaddr *my_addr,int addrlen);
int accept(int sockfd, struct sockaddr *addr,int* addrlen);
int connect(int sockfd, const struct sockaddr *serv_addr, int addrlen);
struct sockaddr_in
Sockets API提供了struct sockaddr的TCP/IP版本——struct sockaddr_in,其中in代表“internet”,故sockaddr_in.sa_family=AF_INET。
/*
* Socket address, internet style.
*/
struct sockaddr_in {
short sin_family;/* internet address family */
u_short sin_port;/* port number */
struct in_addr sin_addr;/* internet address */
char sin_zero[8];/* padding bits */
};
这个结构提供了方便的手段来访问socket address(struct sockaddr)结构中的每一个元素。注意sin_zero[8]是为了使sockaddr和sockaddr_in结构具有相同的尺寸,使用sockaddr_in的时候要把sin_zero全部设为零(使用memset函数)。
IP地址(in_addr)
struct in_addr
in_addr为IP地址,在IPv4中使用32位无符号整数。
/* * Internet address (WINSOCK.H) */
struct in_addr {
union {
struct {u_char s_b1,s_b2,s_b3,s_b4; } S_un_b;
struct {u_short s_w1,s_w2; } S_un_w;
u_long S_addr;
}S_un;
#define s_addr S_un.S_addr/* can be used for most tcp & ip code */
#define s_host S_un.S_un_b.s_b2/* host on imp */
#define s_net S_un.S_un_b.s_b1/* network */
#define s_imp S_un.S_un_w.s_w2/* imp */
#define s_impno S_un.S_un_b.s_b4/* imp # */
#define s_lh S_un.S_un_b.s_b3/* logical host */
};
WinSock对sockaddr、sockaddr_in和in_addr分别进行了typedef:
typedef struct sockaddr SOCKADDR, *PSOCKADDR, FAR *LPSOCKADDR;
typedef struct sockaddr_in SOCKADDR_IN, *PSOCKADDR_IN, FAR *LPSOCKADDR_IN;
typedef struct in_addr IN_ADDR, *PIN_ADDR, FAR *LPIN_ADDR;
in_addr把IP地址作为一个4字节的无符号长整型量存储起来。IP地址习惯用点分十进制(dotted address)字符串来表示,字符串中由点分开的 4个域是以字符串的形式对in_addr结构中的 4个 u_char值的描述。由于每个字节的数值范围是 0~255,所以各域的值是不可以超过255的。
网址归类
IPv4地址被分为几个种类,分别描述地址被分配到网络的部分和端点的部分,见下表。网络号和主机号通过子网掩码区分,例如C类地址的子网掩码为“255.255.255.0”,若IP地址为“192.168.89.125/24”,则网段号为“192.168.89”,主机号为“125”。
Winsock中定义了一系列对IP地址进行归类的宏,包括判断地址类型的IN_CLASS*,子网掩码IN_CLASS*_NET,端点数量IN_CLASS*_HOST及其位数IN_CLASSA_NSHIFT。
#define IN_CLASSA(i) (((long)(i) & 0x80000000) == 0)
#define IN_CLASSA_NET 0xff000000
#define IN_CLASSA_NSHIFT 24
#define IN_CLASSA_HOST 0x00ffffff
#define IN_CLASSA_MAX 128
#define IN_CLASSB(i) (((long)(i) & 0xc0000000) == 0x80000000)
#define IN_CLASSB_NET 0xffff0000
#define IN_CLASSB_NSHIFT 16
#define IN_CLASSB_HOST 0x0000ffff
#define IN_CLASSB_MAX 65536
#define IN_CLASSC(i) (((long)(i) & 0xe0000000) == 0xc0000000)
#define IN_CLASSC_NET 0xffffff00
#define IN_CLASSC_NSHIFT 8
#define IN_CLASSC_HOST 0x000000ff
特殊的网络地址
#define INADDR_ANY (u_long)0x00000000
#define INADDR_BROADCAST (u_long)0xffffffff
#define INADDR_NONE 0xffffffff
其中INADDR_NONE往往用于断错,INADDR_ANY往往代表任意有效地址(一个主机可能分配到多个IP地址),INADDR_BROADCAST为子网广播地址。
网址转换函数
inet_addr函数将一个由小数点分隔的十进制 IP地址字符串转化成由 32位二进制数表示的 IP地址(网络字节顺序)。
unsigned long inet_addr(const char *cp);
以下代码为在little endian的Windows下,定义远程主机220.181.6.18:80。
// 定义TCP/IP地址:220.181.6.18:80
sockaddr_in remoteSockAddr;
remoteSockAddr.sin_family = AF_INET;
remoteSockAddr.sin_addr.S_un.S_addr = inet_addr("220.181.6.18");
remoteSockAddr.sin_port = htons(80);
其联合中的四个S_un_b的四个u_char值依次为220,181,6,18。
remoteSockAddr.sin_addr.S_un.S_un_b.s_b1 = 220; // 0xdc
remoteSockAddr.sin_addr.S_un.S_un_b.s_b2 = 181; // 0xb5
remoteSockAddr.sin_addr.S_un.S_un_b.s_b3 = 6; // 0x06
remoteSockAddr.sin_addr.S_un.S_un_b.s_b4 = 18; // 0x12
在Windows中,该IP整形值解析为0x1206b5dc。
inet_ntoa函数是 inet_addr函数的逆函数,它将一个网络字节顺序的32位IP地址转化成字符串。
char* inet_ntoa(struct in_addr in);
注意inet_ntoa的参数为in_addr结构,而非无符号长整数!
// 翻译IP地址:0x1206b5dc
struct in_addr inAddr;
inAddr.s_addr = 0x1206b5dc;
char *szIP =inet_ntoa(inAddr);
printf("ip = %s/n",szIP); // "220.181.6.18"
套接字通信流程
以下介绍WinSock 1.x即符合Berkely Socket API标准的基本socket操作接口,不包括具体操作系统的扩展API和I/O通信模型。
Winsock库的加载和卸载
要使用Windows Socket API进行编程,首先必须调用WSAStartup()函数初始化Winsock动态库。
intWSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
- 参数一wVersionRequested:为我们要求初始化的Winsock版本号
- 参数二lpWSAData:为实际初始化成功的WSA(Windows Socket API)版本信息。
在程序末尾,需调用int WSACleanup(void)函数卸载Winsock动态库。
以下为基于 socket 的典型应答型 C/S 通信流程:
套接字的创建和释放
套接字的创建
要使用套接字,首先必须调用socket()函数创建一个套接字描述符,就如同操作文件时,首先得调用fopen()函数打开一个文件。
// The socket function creates a socket that is bound to a specific service provider.
SOCKET socket(int af,// [in] Address family specification.
int type,// [in] Type specification for the new socket.
int protocol// [in] Protocol to be used with the socket that is specific to the indicated address family.
);
我们往往编写的是基于IP协议的通信程序,故af一般取AF_INET,type可以取SOCK_STREAM、SOCK_DGRAM、SOCK_RAW。
- SOCK_STREAM表示要创建的是面向流的套接字,例如基于TCP协议编程;
- SOCK_DGRAM则指明创建面向离散消息的数据报套接字,例如基于UDP协议编程;
- SOCK_RAW表示要创建的是原始套接字,可以进行红外套接字编程、基于网络层的编程(例如基于ICMP协议的traceroute和ping程序,直接构建IP数据报的网络程序);
Linux原始套接字甚至提供了链路套接字的支持,而WinSock原始套接字则只支持到网络层,若想在Windoze下直接获取链路层数据包,则需要在NDIS级别进行编程或借助提供了底层网络访问支持的第三方库(如winpcap)。
通常af=AF_INET:
- 若type=SOCK_STREAM,则protocol参数内定为IPPROTO_TCP(6);
- 若type=SOCK_DGRAM则protocol参数内定为IPPROTO_UDP(17)。
- 若type=SOCK_RAW:(1)若protocol=IPPROTO_RAW,则这个socket只能用来发送IP包,而不能接收任何的数据,发送的数据需要自己填充IP包头,并且自己计算校验和;(2)若protocol=IPPROTO_IP则这个socket用于接收IP数据包,其中的校验和和协议分析由程序自己完成。
以下是四种类型的套接字定义:
SOCKET RawSocket = socket(AF_INET,SOCK_RAW,IPPROTO_RAW);
SOCKET IpSocket = socket(AF_INET,SOCK_RAW,IPPROTO_IP);
SOCKET UdpSocket = socket(AF_INET,SOCK_DGRAM,IPPROTO_UDP);
SOCKET TcpSocket = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
套接字的释放
当不使用socket()创建的套接字时,应该调用closesocket()函数将它关闭,就如同调用fclose()函数关闭一个文件,用来进行套接字资源的释放。
// The closesocket function closes an existing socket.
int closesocket(
SOCKET s// [in] Descriptor identifying the socket to close.
);
当一个套接字上仍存在挂起的I/O时,如何优雅地关闭一个套接字便至关重要,此话题留待后续探讨。
绑定套接字到指定的IP地址和端口
对于传输套接字,在执行收发数据前需要对本地端口进行绑定,这是因为传输层需要使用端口来区分具体的通信端点(endpoint)。
// The bind function associates a local address with a socket.
int bind(
SOCKET s, // [in] Descriptor identifying an unbound socket.
const struct sockaddr FAR *name, // [in] Address to assign to the socket from the SOCKADDR structure.
int namelen // [in] Length of the value in the name parameter.
);
bind()函数用在套接字连接建立之前,它的作用是绑定面向连接(connection oriented)的或者面向无连接(transaction oriented)的套接字。当一个套接字被socket函数创建以后,它存在于指定的地址家族里,但是它是匿名的。bind()函数通过安排一个本地名称到未命名的socket建立此socket的本地关联。本地名称包含3个部分:主机地址、协议号(TCP或UDP)和端口号。
通常服务器绑定本地地址时,不写死具体的IP(因为IP是软件配置的),而是使用INADDR_ANY,绑定到本地任意地址。何谓“任意地址”呢?考虑多网卡多IP的情况,我们希望服务器应用程序在指定端口监听客户连接,而不管客户数据是通过哪个网卡进来的。这是因为TCP层面往往使用端口是区分不同的应用程序(进程)。
如果想接收到本机所有的TCP/UDP包(不指定绑定的端口),则最好直接使用原始套接字使用IP层的协议,例如sniffer程序。对于基于传输层的普通网络程序设计,一般都要绑定端口。
When using a connection-oriented protocol, the sockets must be connected before calling recv. When using a connectionless protocol, the sockets must be bound before calling recv.—MSDN
TCP/UDP 套接字执行send()/sendto()时若没有执行本地端口绑定,系统会自动调用绑定,这时可以在该套接字上执行recv()/recvfrom()。在未绑定也未调用send()/sendto()先发探路的情况下,执行recv()/recvfrom()将返回错误SOCKET_ERROR(WSAGetLastError()=WSAEINVAL),因为尚未指定传输接收端口。
绑定往往具有独占性,某个套接字已经绑定到本机IP地址及端口上时,另一个bind()调用试图再次绑定另一个套接字到该sockaddr的行为将以WSAEADDRINUSE错误告终。为保持弹性,可能需要多次试错调用bind下一个端口。要想实现对某个地址的复用,可在SOL_SOCKET级别调用setsockopt()函数设置SO_REUSEADDR/SO_EXCLUSIVEADDRUSE选项。
TCP服务器设置套接字进入监听状态
// The listen function places a socket a state where it is listening for an incoming connection.
int listen(
SOCKET s,// [in] Descriptor identifying a bound, unconnected socket.
int backlog// [in] Maximum length of the queue of pending connections.
);
服务器为了接受连接,首先使用socket()函数创建一个套接字,然后使用bind()函数将它绑定到一个本地地址(端口),再用listen()函数为到达的连接指定一个backlog。
因为服务器是伺服系统(servo),它是服务的提供者,故一般必须显式地声明在哪个端口(即周知端口)上监听接受客户的连接。例如HTTP服务器通常在80端口侦听客户的HTTP请求。在一个随机端口上监听显然很荒谬,故服务器在监听之前往往必须调用bind()函数。即使是在无连接的UDP通信中,准备接收方也需要调用bind()函数。
listen()仅应用在支持连接的套接字上,如SOCK_STREAM类型。函数成功执行之后,套接字s进入了被动模式(passive),到来的连接会被通知,排队等候接受处理。这里着重要提醒的是,传入listen()的套接字是专门用于接受连接的监听套接字,注意区分会话套接字。
backlog参数指定了正在等待连接的最大队列长度。这个参数非常重要,因为服务器完全可能同时收到几个连接请求。假定backlog参数为2,如果三个客户机同时发出请求,那么头两个会被放在一个“待决”(等待处理)队列中,以便应用程序依次为它们提供服务。而第三个连接会造成一个WSAECONNREFUSED错误。注意,一旦服务器接受了一个连接(accept返回),那个连接请求就会从队列中删去,以便别人可继续发出请求。
backlog参数其实本身就存在着限制,这个限制是由基层的协议提供者决定的。如果出现非法值,那么会用与之最接近的一个合法值来取代,一般取SOMAXCONN(5)。
客户端主动连接
// The connect function establishes a connection to a specified socket.
int connect(
SOCKET s,// [in] Descriptor identifying an unconnected socket.
const struct sockaddr FAR *name,// [in] Name of the socket to which the connection should be established.
int namelen// [in] Length of name.
);
客户端是连接的发起者(initiate),它通过调用connect()函数主动(active)连接服务器。参数二填写欲连接的目标服务器的地址。如果连接的计算机并没有在指定端口上监听,则connect()调用返回SOCKET_ERROR,WSAGetLastError()=WSAECONNREFUSED;另一种错误是WSAETIMEOUT,例如由于路由或网络故障,客户端迟迟接受不到服务器回馈的[SYN,ACK]信号。
客户端往往只是想连接到一个远程主机进行通讯,而并不在乎使用本机上的哪个端口进行通讯(比如Telnet),那么你可以不调用bind()函数,而直接调用connect()函数。系统将会将自动寻找出本地机器上的一个未使用的端口,然后调用bind()来将socket绑定到那个端口上。
可在客户端套接字上调用setsockopt()在SOL_SOCKET级别获取SO_CONNECT_TIME选项值,以秒为单位。判断套接字是否已建立连接,以及建立连接的时间。SO_CONNECT_TIME是微软特有支持选项,往往服务器端在AcceptEx()调用中循环查询所有未完成的客户端套接字,从而做出超时处理,避免恶意客户的服务拒绝攻击。
在实际应用中,可能需要维持持久连接,以提供/接受持续的数据服务,例如HTTP的“Connection: Keep-Alive”。可以调用setsockopt()在SOL_SOCKET级别设置SO_KEEPALIVE选项,这样,套接字将定时向通信对方发送保持活跃数据包,保持心跳。
connect()对于UDP套接字的意义只影响send()调用和接收队列,对recvfrom()和sendto()没有影响, 并且可以多次成功connect()。
TCP服务器接受客户连接请求
// The accept function permits an incoming connection attempt on a socket.
SOCKET accept(
SOCKET s,// [in] Descriptor identifying a socket that has been placed in a listening state with the listen function.
struct sockaddr FAR *addr,// [out] receives the address of the connecting entity, as known to the communications layer.
int FAR *addrlen// [out] the length of addr.
);
服务器进入listen状态后,循环调用accept()接受客户的连接。参数一为监听套接字;参数二为远端客户的地址信息;该函数返回一个套接字句柄,负责后续与该远端客户的会话通信。监听套接字总是默默无闻的在门口守望(listen),迎接(accept)客户的到来并安排服务员(会话套接字)接客。
服务器调用socket的listen函数进入监听状态后,connect-accept完成的是TCP三次握手过程(three way or three message handshake):
- 客户端调用connect函数连接服务器:[SYN]
- 服务器调用accept函数接受客户连接并同步回应:[SYN,ACK]
- 客户端发送[ACK]完成三次握手,connect函数返回;
- 服务器收到客户端发送的[ACK]后,accept函数返回。
服务器与客户端基于IP协议的TCP或UDP通信的过程由一个五元组来唯一标识。这个五元组是(协议,本地IP地址,本地端口号,远程IP地址,远程端口号),它体现了socket通信的一一对应关系(插座原理)。
对于面向连接的(TCP协议)通信来说,服务器与客户之间的连接建立完成后,这个五元组就确立了。
五元组 |
<协议> |
<本地IP地址,本地端口号> |
<远程IP地址,远程端口号> |
服务器 |
由socket()确定 |
调用bind()确定 |
由accept()参数二返回 |
客户端 |
由socket()确定 |
调用bind()确定或自动确定 |
由connect()参数二指定 |
在一个已绑定或已连接的套接字上获取连接名和对方地址信息
获取sockaddr
int getsockname (SOCKET s, struct sockaddr *name, int* namelen);
getsockname函数获取已绑定(可能是未调用bind的系统自动绑定)的套接口本地协议地址(sockaddr=IP:PORT)。
int getpeername (SOCKET s, struct sockaddr *name, int* namelen);
getpeername函数获得与指定套接口连接的远程地址信息(sockaddr=IP:PORT)。
获取hostname
Host即通常意义上的机器名(Machine Name)或域名(Domain Name)。
int gethostname (char FAR *name, int namelen);
gethostname()函数可以取得调用主机的机器名。返回的这个name传给gethostbyname()调用可以取得相应IP地址。
struct hostent* gethostbyname(const char* name);
gethostbyname()函数主要用来做DNS解析,传入域名(例如www.baidu.com),返回hostent结构。struct hostent存放主机信息。
/*
* Structures returned by network data base library, taken from the
* BSD file netdb.h. All addresses are supplied in host order, and
* returned in network order (suitable for use in system calls).
*/
struct hostent {
char FAR *h_name; /* official name of host */
char FAR *FAR *h_aliases; /* alias list */
short h_addrtype; /* host address type */
short h_length; /* length of address */
char FAR *FAR *h_addr_list;/* list of addresses */
#defineh_addr h_addr_list[0] /* address, for backward compat */
};
/* Microsoft Windows Extended data types */
typedef struct hostent HOSTENT, *PHOSTENT, *LPHOSTENT;
以下代码段获取百度(www.baidu.com)机器名和地址。
struct hostent *pHostBaiDu = gethostbyname("www.baidu.com");
printf("Host name: %s/n", pHostBaiDu->h_name);
printf("IP Address: %s/n", inet_ntoa(*((struct in_addr*)pHostBaiDu->h_addr)));
struct hostent* gethostbyaddr(const char *addr, int len, int type);
gethostbyaddr函数根据主机的IP地址取得主机名和主机地址等信息。
原有的gethostbyname和inet_addr只能处理IPv4地址;在IPv6中,替代他们的函数为getnameinfo和getaddrinfo,相应的地址信息结构为struct addrinfo。参考<Ws2tcpip.h>。
I/O通信
从I/O的角度来看,套接字也是文件,它提供了同文件读写(fread()/fwrite())对应的收发数据操作接口:send()/recv()。
发送数据
send
// The send function sends data on a connected socket.
int send(
SOCKET s,// [in] Descriptor identifying a connected socket.
const char FAR *buf,// [in] Buffer containing the data to be transmitted.
int len,// [in] Length of the data in buf.
int flags// [in] Indicator specifying the way in which the call is made.
);
send()函数在一个已连接的套接字s上执行数据发送操作。对于客户机而言,发送的目标地址即connect()调用时所指定的地址;对于服务器而言,发送的目标地址即accept()调用所返回的地址。发送的内容为保存在缓冲区buf中,发送的内容长度为len。最后一个参数flags,通常情况下填0。
send()函数只是将欲发送的内容从用户缓冲区拷贝到系统缓冲区(TCP Send Socket Buffer),系统的默认socket发送缓冲区(SO_SNDBUF)的大小为8K,我们可以调用setsockopt()将其更改,理论上最大为64K(The maximum congestion window is related to the amount of buffer space that the kernel allocates for each socket)。
只要系统缓冲区足够大,send()执行完拷贝立即返回实际拷贝的字节数。如果系统缓冲区不够大,例如在网络拥塞或带宽下降的情况下,用户大量地投递send()操作导致TCP Send Socket Buffer迅速充满,此时再调用send()操作,可能返回的值(即实际拷贝字节数)要小于我们传入的期待发送数量(len),在超时不得受理的情况下,返回SOCKET_ERROR,WSAGetLastError()=WSAETIMEDOUT。故大块的数据可能不能一次性“发送”完毕,通常需要检测send()返回值,多次调用send()直到“发送”完毕,可参考CSocket::Send()实现。关于发送超时限制(send timeout),可以调用setsockopt()在SOL_SOCKET级别设置SO_SNDTIMEO选项值,以毫秒为单位。建议最多两分钟,因为TCP的MSL(Maximum Segment Lifetime)即为两分钟。
需要注意的是,用户可能短时间内需要发送多个小数据包,在TCP/IP中,Nagle算法要求主机等待数据积累到一定数量后或超过预定时间才发送。默认情况下实施Nagle算法,通信方会在向对方发送确认(ACK)信息之前,花费一定的时间来等待要传入的数据,这样,主机的就不必发送一个只有确认信息的数据报。发送小的数据包不仅没有多少意义,而且徒增错误检查和确认的开销。如果不想是使用Nagle算法,以“保留发送边界”,用户可调用setsockopt()函数在IPPROTO_TCP选项级别设置TCP_NODELAY为TRUE。例如一次独立的HTTP GET请求往往希望“保留发送边界”,服务器的HTTP Response Header往往希望“保留发送边界”以区分后续的HTTP Response Content。体现在TCP层,即开启“PSH”选项。
具体的发送工作交由系统的传输层驱动程序完成。因为TCP提供可靠有序的传输机制,故我们总是很放心地认为它会将我们的数据发送到目的端。至于TCP分多少次将数据发送至对方,由协商的MSS(Max Segment Size)和接收方的TCP Window决定。
sendto
// The sendto function sends data to a specific destination.
int sendto(
SOCKET s,
const char FAR *buf,
int len,
int flags,
const struct sockaddr FAR *to,// [in] Optional pointer to the address of the target socket.
int tolen// [in] Size of the address in to.
);
sendto()函数只是比send()函数多出了一个目的地址信息参数,主要用于面向无连接的UDP通信。TCP套接字在建立连接(connect-accept)时,便知晓对方地址信息,而UDP套接字通信之前不建立连接,需要通信时,调用sendto()将消息发送给指定目的地址(to)。无论对方是否在指定端口“监听”,sendto总是试图把数据发出去,要知道UDP是没有回应确认的。
注释中,sendto()函数的目标地址是“optional”,当我们忽略最后两个参数时,完全可以替换send()函数使用。实际上,这很方便我们在编程接口上提供统一。例如live555的writeSocket接口针对TCP和UDP套接字统一使用sendto()。
由于UDP协议基本上只是在IP协议上做了简单的封装(Source Port+Destination Port+Length+Checksum),其没有做可靠性传输保障,故对UDP套接字一次sendto()的数据量不宜过大,最好以MTU为基准。使用UDP套接字往发送大数据块,往往因为IP分片等原因丢包,考虑异构网络及设备的MTU不同,一般一次发送512字节左右比较合适。
我们在一个UDP套接字上执行connect()操作,并未真正建立连接,而是执行一种目的地址“绑定”,事后我们可以使用send()函数替换sendto()函数。要取消UDP套接字与目的地址的关联,唯一的办法是在这个套接字上以INADDR_ANY为目标地址调用connect()。
接收数据
recv
// The recv function receives data from a connected or bound socket.
int recv(
SOCKET s,// [in] Descriptor identifying a connected socket.
char FAR *buf,// [out] Buffer for the incoming data.
int len,// [in] Length of buf.
int flags// [in] Flag specifying the way in which the call is made.
);
recv()函数在一个已连接的套接字s上执行数据接收操作。对于客户机而言,数据的源地址即connect()调用时所指定的地址;对于服务器而言,数据的源地址即accept()调用所返回的地址。接收的内容为保存至长度为len的缓冲区buf,最后一个参数flags,通常情况下填0。
recv()函数只是被动尝试将TCP层当前接收到的数据流从系统缓冲区(TCP Receive Socket Buffer)拷贝到用户缓冲区,系统的默认socket接收缓冲区(SO_RCVBUF)的大小为8K,我们可以调用setsockopt()将其更改,理论上最大为64K(The maximum congestion window is related to the amount of buffer space that the kernel allocates for each socket)。
recv()函数返回实际接收到的数据,可能小于缓冲区的长度len,可能当前到达的有效数据大于len,但最大返回len。在超时仍无数据到来的情况下,返回SOCKET_ERROR,WSAGetLastError()=WSAETIMEDOUT。关于接收超时限制(receive timeout),可以调用setsockopt()在SOL_SOCKET级别设置SO_RCVTIMEO选项值,以毫秒为单位。建议最多两分钟,因为TCP的MSL(Maximum Segment Lifetime)即为两分钟。
如果对方不停发送数据,而本机过于繁忙疲于应付,则可能导致数据大量累积,一旦TCP Receive Socket Buffer或TCP Window充满,则可能产生数据溢出。TCP滑动窗口机制,由接收方建议性的控制发送量,即每一次确认回应(ACK)时都告知对方自己当前的接收能力(TCP窗口的大小),发送方据此有效地控制自己的发送行为,协调双方的通信步伐。
由于基于流的TCP协议,未保留消息边界(boundary)的概念,发送者发送的数据很快就会聚集在系统接收缓冲区(TCP堆栈)中。假设这样一种情景,客户端连接流媒体服务器(如IP摄像头)后,发送请求码流的请求,这以后服务器总是连续不断地推送数据过来(如IP摄像头实时监控码流)。若客户端不执行recv()拷贝操作而又尚未关闭连接,则服务器不断推送数据到客户端的TCP Stack,直至TCP window size=0。
不管消息边界是否存在,接收端都会尽量地读取当前的有效数据。执行拷贝后,数据将立即从系统缓冲区删除,以释放部分TCP Window。因为流的无边界性,故用户投递了三个send(),可能接收端只需一次或两次recv()即接收完成。若客户三次send()的是结构化的数据,而接收端收到的是粘连在一起的一大坨数据或两块随机边界数据,这种情况即通常所说的TCP粘包问题。
具体的接收工作交由系统的传输层驱动程序完成。因为TCP提供可靠有序的传输机制,故我们总是很放心地认为它会将对方发送过来的数据正确的提交给我们。这里面的“正确”是指应用层面的报文结构及格式,即使TCP层面发生了偶然的丢包重传(retransmit out of order),但我们得到的仍然是对方提交的完整的报文。应用层协议就需要我们自己解析了。
粘包问题需要我们联合发送方,采取有效边界措施在应用层重组出正确的报文。例如,发送方往往在一个数据包的头4个字节预告对方接下来的数据有多少,这样接收方就能有效的执行接收,以保留边界和结构性。假设接收方得知发送方将发送32KB的数据过来,便投递一个32KB的缓冲区调用recv试图一次性接收完毕,这将以失败告终。实际上,发送方的TCP层将按MSS尺寸将TCP报文分解成很多个段(Segment)分多次发送给接收方。当然,它们往往具有相同的确认号(ack),以表示这些段是一个回应报文。这样,客户端才能识别出TCP segment of a reassembled PDU,以正确重组报文。可参考CSocket::Receive()实现。
recvfrom
// The recvfrom function receives a datagram and stores the source address.
int recvfrom(
SOCKET s,
char FAR* buf,
int len,
int flags,
struct sockaddr FAR *from,// [out] Optional pointer to a buffer that will hold the source address upon return.
int FAR *fromlen// [in, out] Optional pointer to the size of the from buffer.
);
recvfrom/recv与sendto/send在行为学上同功,因为事先不知发送方为谁,故只要进来的通信,都将对方的地址保存在参数from中。值得注意的是,尽管UDP中没有TCP监听、连接等概念,但是作为接收方往往需要在本地某个端口上等待,这个端口必须是专用,约定对方预知的。故通常在调用recvfrom之前,必须显式调用bind()函数将UDP套接字关联到本地某个指定端口,进行“监听”。
UDP通信是基于离散消息(message)的,故要么收到对方发送的消息包,要么整包丢失,但接收方不得而知。如果整包丢失了,由于接收方不得而知,故没有反馈信息,也不会重发,此即UDP通信的不可靠性。
live555中的readSocket接口针对TCP和UDP套接字统一使用recvfrom。
关闭套接字(TCP连接)
在无连接的UDP中不存在关闭连接问题,我们recvfrom/sendto完毕即可调用closesocket()回收套接字内核资源。
对于面向连接的TCP通信,关闭一个连接需要四次挥手,以关闭双向信道。其中一方A发送FIN报文给另一方B发起关闭,告诉另一方B它再也不会发送数据了,当然它一般会先将发送队列中尚未发送的数据先发送出去再发送FIN报文。对方B收到FIN通知,回应ACK关闭AàB方向上的连接。此时,B仍可向A发送数据,A仍可读取线路上或网络堆栈上挂起的数据。当另一方B也决定不再发送数据时,它也发出一个FIN报文,关闭BàA方向上的连接。以上即TCP连接的正常关闭。
因为可能该套接字上仍有未决的I/O,为了保证通信方能够接收到应用程序发出的所有数据,一个友好的应用程序应该通知接收端“不再发送数据”,同样通信对方也应该如此,这就是所谓的“从容关闭”。TCP套接字recv()/send()通信完毕,往往需要调用shutdown()函数从容地关闭TCP连接,而不是立刻调用closesocket()函数释放套接字资源。
// The shutdown function disables sends or receives on a socket.
int shutdown(
SOCKET s,// [in] Descriptor identifying a socket.
int how// [in] Flag that describes what types of operation will no longer be allowed.
);
需要留意的是第二参数how,一个套接字既可以调用recv接数据也可以调用send发数据,因此它以下三种取值:
- SD_RECEIVE—subsequent calls to the recv function on the socket will be disallowed.
- SD_SEND—subsequent calls to the send function are disallowed.
- SD_BOTH—disables bothsends andreceives as described above.
如果how=SD_RECEIVE,则该套接口上的后续接收操作将被禁止。对于TCP协议,TCP窗口不改变并接收前来的数据(但不确认)直至窗口满后,window size为0,无法接受数据。
通常取how=SD_SEND,表示不允许再调用发送函数,TCP套接字将会在所有数据发送出去并得到对方的确认(ACK)之后生成一个FIN包,断开与对方的半连接。
在没有调用shutdown()的情况下,直接调用closesocket()试图释放TCP套接字资源,如果没有对该套接字的其他引用(具体来说是该套接字上是否存在未决I/O),那么所有与该套接字描述符关联的资源都会被释放,其中包括所有传输队列中的数据,包括同步调用和重叠操作。在TCP层面上,closesocket()将向对方发送[RST,ACK]报文,以重置虚拟链路。
SOL_SOCKET 级别的 SO_LINGER/SO_DONTLINGER 参数用来控制当未发送的数据在套接字上排队等待时,一旦执行 closesocket() 命令该采取什么样的动作。struct linger 对应的是一段拖延时间,若超出规定的时间便不再拖延,所有未发送或未接收的数据都会被丢弃,同时重设与对方的连接。可调用 setsockopt() 函数在丢弃任何正在排队的数据之前启用拖延功能。可参考《UNIX网络编程》7.5.6 SO_LINGER 套接字选项。
WinSock TCP C/S通信示例
套接字(句柄)的本质是通信过程中所要使用的一些缓冲区及一些相关的数据结构。
基于 socket 的应答型 C/S 通信示例程序 CSDemo ,采用默认的同步阻塞 I/O 模型。该范例跨平台支持 Windows/Mac OS X,初学者可自行下载参考。
通过 WSA(WinSock API)构建 C/S 通信程序的基本步骤如下:
1.服务器创建监听套接字,并为它关联一个本地地址(IP 和端口 Port),然后进入监听状态准备接受客户的连接请求。为了接受客户的连接请求,服务器必须调用 accept 函数。服务器端每接收到一个客户端连接,accept 就返回一个套接字负责与该客户会话。
2.客户端创建套接字后即可调用 connect 函数去试图连接服务器监听套接字。当服务器端的 accept 函数返回后,connect 函数也返回,连接建立。此时,在该通信链路上,就可以基于客户方套接字 clientSocket 和服务套接字 serveSocket 进行通信(I/O 数据收发)了。
下图演示了从连接建立(connect-accept)到应答会话(recv-send)以及连接关闭(shutdown-close)的序列,借助 Wireshark 等抓包工具可进阶分析 API 调用背后的 TCP 协议栈机理。