简单的 Winsock 应用程式设计(1)
林 军 鼐
相信各位读者现在对於 Winsock 的定义、系统环境,以及一些 Winsock Stack
及 Winsock 应用程式,都有基本的认识了。接下来笔者希望能分几期为各位读者
介绍一下简单的 Winsock 网路应用程式设计。
我们将以 Winsock 1.1 规格所定义的 46 个应用程式介面(API)为基础,逐
步来建立一对 TCP socket 主从架构(Client / Server)的程式。在这两个程式中,
Server 将使用 Winsock 提供的「非同步」(asynchronous)函式来建立 socket 连
结、关闭、及资料收送等等;而 Client 则采类似传统 UNIX 的「阻拦式」
(blocking)。由於我们的重点并不在於 MS Windows SDK 的程式设计,所以我
们将使用最简便的方式来显示讯息;有关 MS Windows 程式的技巧,请各位读者
自行研究相关的书籍及文章。
今天我们先要看一下主从架构 TCP socket 的建立连结(connect)及关闭
(close)。(参见图 1.)
(图 1. 主从架构的 TCP socket 连接建立与关闭)
以前笔者曾简单地介绍过主从架构的概念,现在我们再以生活上更浅显的例
子来说明一下,读者稍後也较容易能明白笔者的叙述。我们可以假设 Server 就像
是电信局所提供的一些服务,比如「104 查号台」或「112 障碍台」。
(1)电信局先建立好了一个电话总机,这就像是呼叫 socket() 函式开启了一
个 socket。
(2)接著电信局将这个总机的号码定为 104,就如同我们呼叫 bind() 函式,
将 Server 的这个 socket 指定(bind)在某一个 port。当然电信局必须让用户知道
这个号码;而我们的 Client 程式同样也要知道 Server 所用的 port,待会才有办法
与之连接。
(3)电信局的 104 查号台底下会有一些自动服务的分机,但是它的数量是有
限的,所以有时你会拨不通这个号码(忙线)。同样地,我们在建立一个 TCP 的
Server socket 时,也会呼叫 listen() 函式来监听等待;listen() 的第二个参数即是
waiting queue 的数目,通常数值是由 1 到 5。(事实上这两者还是有点不一
样。)
(4)用户知道了电信局的这个 104 查号服务,他就可以利用某个电话来拨号
连接这个服务了。这就是我们 Client 程式开启一个相同的 TCP socket,然後呼叫
connect() 函式去连接 Server 指定的那个 port。当然了,和电话一样,如果 waiting
queue 满了、与 Server 间线路不通、或是 Server 没提供此项服务时,你的连接就
会失败。
(5)电信局查号台的总机接受了这通查询的电话後,它会转到另一个分机做
服务,而总机本身则再回到等待的状态。Server 的 listening socket 亦是一样,当
你呼叫了 accept() 函式之後,Server 端的系统会建立一个新的 socket 来对此连接
做服务,而原先的 socket 则再回到监听等待的状态。
(6)当你查询完毕了,你就可以挂上电话,彼此间也就离线了。Client 和
Server 间的 socket 关闭亦是如此;不过这个关闭离线的动作,可由 Client 端或
Server 端任一方先关闭。有些电话查询系统不也是如此吗?
接下来,我们就来看主从架构的 TCP socket 是如何利用这些 Winsock 函式来
达成的;并利用资策会资讯技术处的「WinKing」这个 Winsock Stack 中某项功能
来显示 sockets 状态的变化。文章中仅列出程式的片段,完整的程式请看附录的程
式。
【Server 端建立 socket 并进入监听等待状态】
首先我们先看 Server 端如何建立一个 TCP socket,并使其进入监听等待的状
态。
在图 1. 上,我们可以看到最先被呼叫到的是 WSAStartup() 函式。说明如下:
WSAStartup():连结应用程式与 Winsock.DLL 的第一个函式。
格 式: int PASCAL FAR WSAStartup( WORD wVersionRequested,
LPWSADATA lpWSAData );
参 数: wVersionRequested 欲使用的 Windows Sockets API 版本
lpWSAData 指向 WSADATA 资料的指标
传回值: 成功 - 0
失败 - WSASYSNOTREADY / WSAVERNOTSUPPORTED /
WSAEINVAL
说明: 此函式「必须」是应用程式呼叫到 Windows Sockets DLL 函式中的第一
个,也唯有此函式呼叫成功後,才可以再呼叫其他 Windows Sockets DLL 的函式。
此函式亦让使用者可以指定要使用的 Windows Sockets API 版本,及获取设计者的
一些资讯。
程式中我们要用 Winsock 1.1,所以我们在程式中有一段为:
WSAStartup((WORD)((1<<8)|1),(LPWSADATA) &WSAData)
其中 ((WORD)((1<<8)|1) 表示我们要用的是 Winsock 「1.1」版本,而
WSAData 则是用来储存由系统传回的一些有关此一 Winsock Stack 的资料。
再来我们呼叫 socket() 函式来开启 Server 端的 TCP socket。
socket():建立Socket。
格 式: SOCKET PASCAL FAR socket( int af, int type, int protocol );
参 数: af 目前只提供 PF_INET(AF_INET)
type Socket 的型态 (SOCK_STREAM、SOCK_DGRAM)
protocol 通讯协定(如果使用者不指定则设为0)
传回值: 成功 - Socket 的识别码
失败 - INVALID_SOCKET(呼叫 WSAGetLastError() 可得知原因)
说明: 此函式用来建立一 Socket,并为此 Socket 建立其所使用的资源。
Socket 的型态可为 Stream Socket 或 Datagram Socket。
我们要建立的是 TCP socket,所以程式中我们的第二个参数为
SOCK_STREAM,我们并将开启的这个 socket 号码记在 listen_sd 这个变数。
listen_sd = socket(PF_INET, SOCK_STREAM, 0)
接下来我们要指定一个位址及 port 给 Server 的这个 socket,这样 Client 才知
道待会要连接哪一个位址的哪个 port;所以我们呼叫 bind() 函式。
bind():指定 Socket 的 Local 位址 (Address)。
格 式: int PASCAL FAR bind( SOCKET s, const struct sockaddr FAR *name,
int namelen );
参 数: s Socket的识别码
name Socket的位址值
namelen name的长度
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此一函式是指定 Local 位址及 Port 给某一未定名之 Socket。使用者若不
在意位址或 Port 的值,那麽他可以设定位址为 INADDR_ANY,及 Port 为 0;那麽
Windows Sockets 会自动将其设定适当之位址及 Port (1024 到 5000之间的值),使用
者可以在此 Socket 真正连接完成後,呼叫 getsockname() 来获知其被设定的值。
bind() 函式要指定位址及 port,这个位址必须是执行这个程式所在机器的 IP
位址,所以如果读者在设计程式时可以将位址设定为 INADDR_ANY,这样
Winsock 系统会自动将机器正确的位址填入。如果您要让程式只能在某台机器上
执行的话,那麽就将位址设定为该台机器的 IP 位址。由於此端是 Server 端,所
以我们一定要指定一个 port 号码给这个 socket。
读者必须注意一点,TCP socket 一旦选定了一个位址及 port 後,就无法再呼
叫另一次 bind 来任意更改它的位址或 port。
在程式中我们将 Server 端的 port 指定为 7016,位址则由系统来设定。
struct sockaddr_in sa;
sa.sin_family = PF_INET;
sa.sin_port = htons(7016); /* port number */
sa.sin_addr.s_addr = INADDR_ANY; /* address */
bind(listen_sd, (struct sockaddr far *)&sa, sizeof(sa))
我们在指定 port 号码时会用到 htons() 这个函式,主要是因为各机器的数值读
取方式不同(PC 与 UNIX 系统即不相同),所以我们利用这个函式来将 host
order 的排列方式转换成 network order 的排列方式;相同地,我们也可以呼叫
ntohs() 这个相对的函式将其还原。(host order 各机器不同,但 network order 都
相同)(htons 是针对 short 数值,对於 long 数值则用 hotnl 及 ntohl)
指定完位址及 port 之後,我们呼叫 listen() 函式,让这个 socket 进入监听状
态。一个 Server 端的 TCP socket 必须在做完了 listen 的呼叫後,才能接受 Client
端的连接。
listen():设定 Socket 为监听状态,准备被连接。
格 式: int PASCAL FAR listen( SOCKET s, int backlog );
参 数: s Socket 的识别码
backlog 未真正完成连接前(尚未呼叫 accept 前)彼端的连接要求的最大
个数
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 使用者可利用此函式来设定 Socket 进入监听状态,并设定最多可有多少
个在未真正完成连接前的彼端的连接要求。(目前最大值限制为 5, 最小值为1)
程式中我们将 backlog 设为 1 。
listen(listen_sd, 1)
呼叫完 listen 後,此时 Client 端如果来连接的话,Client 端的连接动作
(connect)会成功,不过此时 Server 端必须再呼叫 accept() 函式,才算正式完成
Server 端的连接动作。但是我们什麽时候可以知道 Client 端来连接,而适时地呼
叫 accept 呢?在这里我们就要利用一个很好用的 WSAAsyncSelect 函式,将
Server 端的这个 socket 转变成 Asynchronous 模式,让系统主动来通知我们有
Client 要连接了。(图1. 中并未将此函式绘出)
WSAAsyncSelect():要求某一 Socket 有事件 (event) 发生时通知使用者。
格 式: int PASCAL FAR WSAAsyncSelect( SOCKET s, HWND hWnd,
unsigned int wMsg, long lEvent );
参 数: s Socket 的编号
hWnd 动作完成後,接受讯息的视窗 handle
wMsg 传回视窗的讯息
lEvent 应用程式有兴趣的网路事件
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式是让使用者用来要求 Windows Sockets DLL 在侦测到某一 Socket
有网路事件时送讯息到使用者指定的视窗;网路事件是由参数 lEvent 设定。呼叫此
函式会主动将该 Socket 设定为 Non-blocking 模式。lEvent 的值可为以下之「OR」
组合:(参见 WINSOCK第1.1版88、89页) FD_READ、FD_WRITE、FD_OOB、
FD_ACCEPT、FD_CONNECT、FD_CLOSE 使用者若是针对某一Socket再次呼叫
此函式时,会取消对该
接上...................
(图 3)demoserv 在 WinKing 系统上建立 socket 并进入监听状态
读者必须注意一点,WSAAsyncSelect 的设定是针对「某一个 socket」;也就是
说,只有当您设定的这个 socket (listen_sd)的那些事件(FD_ACCEPT)发生时,
您才会收到这个讯息(ASYNC_EVENT)。如果您开启了很多 sockets,而要让每
个 socket 都变成 asynchronous 模式的话,那麽就必须对「每一个 socket」都呼叫
WSAAsyncSelect 来一一设定。而如果您想将某一个 socket 的 async 事件通知设定取
消的话,那麽同样也是用 WSAAsyncSelect 这个函式;且第四个参数 lEvent 一定要
设为 0。
WSAAsyncSelect( s, hWnd, 0, 0 ) -- 取消所有 async 事件设定
在这里笔者还要告诉各位一点,呼叫 WSAAsyncSelect 的同时也将此一 socket
改变成「非阻拦」(non-blocking)模式。但是此时这个 socket 不能很简单地用
ioctlsocket() 这个函式就将它再变回「阻拦」(blocking)模式。也就是说
WSAAsyncSelect 和 ioctlsocket 所改变的「非阻拦」模式仍是有些不同的。如果您想
将一个「非同步」(asynchronous)模式的 socket 再变回「阻拦」模式的话,必须
先呼叫 WSAAsyncSelect() 将所有的 async 事件取消,再用 ioctlsocket() 将它变回阻
拦模式。
ioctlsocket():控制 Socket 的模式。
格 式: int PASCAL FAR ioctlsocket( SOCKET s, long cmd, u_long FAR *argP );
参 数: s Socket 的识别码
cmd 指令名称
argP 指向 cmd 参数的指标
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式用来获取或设定 Socket 的运作参数。其所提供的指令有:(参见
WINSOCK 第 1.1 版 35、36 页)
cmd 的值可为:
FIONBIO -- 开关 non-blocking 模式
FIONREAD -- 自 Socket 一次可读取的资料量(目前 in buffer 的资料量)
SIOCATMARK -- OOB 资料是否已被读取完
由於我们 Server 端的 socket 是用非同步模式,且设定了 FD_ACCEPT 事件,所
以当 Client 端和我们连接时,Winsock Stack 会主动通知我们;我们再先来看看
Client 端要如何和 Server 端建立连接?
【Client 端向 Server 端主动建立连接】
Client 首先也是呼叫 WSAStartup() 函式来与 Winsock Stack 建立关系;然後同样
呼叫 socket() 来建立一个 TCP socket。(读者此时一定要用 TCP socket 来连接
Server 端的 TCP socket,而不能用 UDP socket 来连接;因为相同协定的 sockets 才
能相通,TCP 对 TCP,UDP 对 UDP)
和 Server 端的 socket 不同的地方是:Client 端的 socket 可以呼叫 bind() 函式,
由自己来指定 IP 位址及 port 号码;但是也可以不呼叫 bind(),而由 Winsock Stack
来自动设定 IP 位址及 port 号码(此一动作在呼叫 connect() 函式时会由 Winsock 系
统来完成)。通常我们是不呼叫 bind(),而由系统设定的,稍後可呼叫
getsockname() 函式来检查系统帮我们设定了什麽 IP 及 port。一般言,系统会自动
帮我们设定的 port 号码是在 1024 到 5000 之间;而如果读者要自己用 bind 设定 port
的话,最好是 5000 以上的号码。
connect():要求连接某一 TCP Socket 到指定的对方。
格 式: int PASCAL FAR connect( SOCKET s, const struct sockaddr
FAR *name, int namelen );
参 数: s Socket 的识别码
name 此 Socket 想要连接的对方位址
namelen name的长度
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫WSAGetLastError()可得知原因)
说明: 此函式用来向对方要求建立连接。若是指定的对方位址为 0 的话,会传
回错误值。当连接建立完成後,使用者即可利用此一 Socket 来做传送或接收资料之
用了。
我们的例子中, Client 是要连接的是自己机器上 Server 所监听的 7016 这个
port,所以我们有以下的程式片段。(假设我们机器的 IP 存在 my_host_ip)
struct sockaddr_in sa; /* 变数宣告 */
sa.sin_family = PF_INET; /* 设定所要连接的 Server 端资料 */
sa.sin_port = htons(7016);
sa.sin_addr.s_addr = htonl(my_host_ip);
connect(mysd, (struct sockaddr far *)&sa, sizeof(sa)) /* 建立连接 */
【Server 端接受 Client 端的连接】
由於我们 Server 端的 socket 是设定为「非同步模式」,且是针对 FD_ACCEPT
这个事件,所以当 Client 来连接时,我们 Server 端的 hwnd 这个视窗会收到
Winsock Stack 送来的一个 ASYNC_EVENT 的讯息。(参见前面 WSAAsyncSelect
的设定)
这时,我们应该先利用 WSAGETSELECTERROR(lParam) 来检查是否有错误;
并由 WSAGETSELECTEVENT(lParam) 得知是什麽事件发生(因为
WSAAsyncSelect 函式可针对同一个 socket 同时设定很多事件,但是只用一个讯息
来代表)(此处当然是 FD_ACCEPT 事件);然後再呼叫相关的函式来处理此一事
件。所以我们呼叫 accept() 函式来建立 Server 端的连接。
accept():接受某一 Socket 的连接要求,以完成 Stream Socket 的连接。
格 式: SOCKET PASCAL FAR accept( SCOKET s, struct sockaddr FAR *addr,
int FAR *addrlen );
参 数: s Socket的识别码
addr 存放来连接的彼端的位址
addrlen addr的长度
传回值:成功 - 新的Socket识别码
失败 - INVALID_SOCKET (呼叫 WSAGetLastError() 可得知原因)
说明: Server 端之应用程式呼叫此一函式来接受 Client 端要求之 Socket 连接动
作;如果Server 端之 Socket 是为 Blocking 模式,且没有人要求连接动作,那麽此一
函式会被 Block 住;如果为 Non-Blocking 模式,此函式会马上回覆错误。accept()
函式的答覆值为一新的 Socket,此新建之 Socket 不可再用来接受其它的连接要求;
但是原先监听之 Socket 仍可接受其他人的连接要求。
TCP socket 的 Server 端在呼叫 accept() 後,会传回一个新的 socket 号码;而这
个新的 socket 号码才是真正与 Client 端相通的 socket。比如说,我们用 socket() 建
立了一个 TCP socket,而此 socket 的号码(系统给的)为 1,然後我们呼叫的
bind()、listen()、accept() 都是针对此一 socket;当我们在呼叫 accept()
後,传回值是
另一个 socket 号码(也是系统给的),比如说 3;那麽真正与 Client 端连接的是号
码 3 这个 socket,我们收送资料也都是要利用 socket 3,而不是 socket 1;读者不可
搞错。
我们在程式中对 accept() 的呼叫如下;我们并可由第二个参数的传回值,得知
究竟是哪一个 IP 位址及 port 号码的 Client 与我们 Server 连接。
struct sockaddr_in sa;
int sa_len = sizeof(sa);
my_sd = accept(listen_sd, (struct sockaddr far *)&sa, &sa_len)
当 Server 端呼叫完 accept() 後,主从架构的 TCP socket 连接才算真正建立完
毕; Server 及 Client 端也就可以分别利用此一 socket 来送资料到对方或收对方送来
的资料了。(有关资料的收送,我们等下一期再谈)
(图 4) demoserv 与 democlnt 在 WinKing 上连接成功後状态
【Server 及 Client 端结束 socket 连接】
最後我们来看一下如何结束 socket 的连接。socket 的关闭很简单,而且可由
Server 或 Client 的任一端先启动,只要呼叫 closesocket() 就可以了。而要关闭监听
状态的 socket,同样也是利用此一函式。
closesocket():关闭某一Socket。
格 式: int PASCAL FAR closesocket( SOCKET s );
参 数: s Socket 的识别码
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此一函式是用来关闭某一 Socket。
若是使用者原先对要关闭之 Socket 设定 SO_DONTLINGER,则在呼叫此一函式
後,会马上回覆,但是此一 Sokcet 尚未传送完毕的资料会继续送完後才关闭。
若是使用者原先设定此 Socket 为 SO_LINGER,则有两种情况:
(a) Timeout 设为 0 的话,此一 Socket 马上重新设定 (reset),未传完或未收到的
资料全部遗失。
(b) Timeout 不为 0 的话,则会将资料送完,或是等到 Timeout 发生後才真正关
闭。
程式结束前,读者们可千万别忘了要呼叫 WSACleanup() 来通知 Winsock
Stack;如果您不呼叫此一函式,Winsock Stack 中有些资源可能仍会被您占用而无
法清除释放哟。
WSACleanup():结束 Windows Sockets DLL 的使用。
格 式: int PASCAL FAR WSACleanup( void );
参 数: 无
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 应用程式在使用 Windows Sockets DLL 时必须先呼叫
WSAStartup() 来向 Windows Sockets DLL 注册;当应用程式不再需要使用
Windows Sockets DLL 时,须呼叫此一函式来注销使用,以便释放其占用的资
源。
简单的 Winsock 应用程式设计(2)
林 军 鼐
在前一期的文章中,笔者为大家介绍了如何在 Winsock 环境下,建立主从
架构(Client/Server)的 TCP socket 的连接建立与关闭;今天笔者将继续为大家
介绍如何利用 TCP socket 来收送资料,并详细解说 WSAAsyncSelect 函式中的
FD_READ 及 FD_WRITE 事件(笔者曾发现有相当多人对这两个事件甚不了
解)。
相信读者们已经知道 TCP socket 的连接是在 Client 端呼叫 connect 函式成
功,且 Server 端呼叫 accept 函式後,才算完全建立成功;当连接建立成功後,
Client 及 Server 也就可以利用这个连接成功的 socket 来传送资料到对方,或是
收取对方送过来的资料了。
(图 1. TCP socket 的资料收送)
在介绍资料的收送前,笔者先介绍一下 TCP socket 与 UDP socket 在传送资
料时的特性:
Stream (TCP) Socket 提供「双向」、「可靠」、「有次序」、「不重覆」之
资料传送。
Datagram (UDP) Socket 则提供「双向」之沟通,但没有「可靠」、「有次
序」、「不重覆」等之保证; 所以使用者可能会收到无次序、重覆之资料,甚至
资料在传输过程中也可能会遗漏。
由於 UDP Socket 在传送资料时,并不保证资料能完整地送达对方,所以我
们常用的一些应用程式(如 telnet、mail、ftp、news...等)都是采用 TCP
Socket,以保证资料的正确性。(TCP 及 UDP 封包的传送协定不在我们讨论□
围,想要了解的读者们,请自行参考相关书籍)
TCP 及 UDP Socket 都是双向的,所以我们是利用同一个 Socket 来做传送及
收取资料的动作;一般言 TCP Socket 的资料送、收是呼叫 send() 及 recv() 这两
个函式来达成,而 UDP Socket 则是用 sendto() 及 recvfrom() 这两个函式。不过
TCP Socket 也可用 sendto() 及 recvfrom() 函式,UDP Socket 同样可用 send() 及
recv() 函式;这一点我们稍後再加以解释。
现在我们先看一下 send() 及 recv() 的函式说明,并回到我们的前一期程
式。
◎ send():使用连接式(connected)的 Socket 传送资料。
格 式: int PASCAL FAR send( SOCKET s, const char FAR *buf,
int len, int flags );
参 数: s Socket 的识别码
buf 存放要传送的资料的暂存区
len buf 的长度
flags 此函式被呼叫的方式
传回值: 成功 - 送出的资料长度
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式适用於连接式的 Datagram 或 Stream Socket 来传送资料。 对
Datagram Socket 言,若是 datagram 的大小超过限制,则将不会送出任何资料,并
会传回错误值。对 Stream Socket 言,Blocking 模式下,若是传送 (transport) 系统
内之储存空间(output buffer)不够存放这些要传送的资料,send() 将会被 block
住,直到资料送完为止;如果该 Socket 被设定为 Non-Blocking 模式,那麽将视目
前的 output buffer 空间有多少,就送出多少资料,并不会被 block 住。使用者亦须
注意 send()函式执行完成,并不表示资料已经成功地送抵对方了,而是已经放到
系统的 output buffer 中,等待被送出。 flags 的值可设为 0 或 MSG_DONTROUTE
及 MSG_OOB 的组合。(参见 WINSOCK第1.1版48页)
◎ recv():自 Socket 接收资料。
格 式: int PASCAL FAR recv( SOCKET s, char FAR *buf, int len, int flags );
参 数: s Socket 的识别码
buf 存放接收到的资料的暂存区
len buf 的长度
flags 此函式被呼叫的方式
传回值: 成功 - 接收到的资料长度 (若对方 Socket 已关闭,则为 0)
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式用来自连接式的 Datagram Socket 或 Stream Socket 接收资料。
对 Stream Socket 言,我们可以接收到目前 input buffer 内有效的资料,但其数量
不超过 len 的大小。若是此 Socket 设定 SO_OOBINLINE,且有 out-of-band 的资
料未被读取,那麽只有 out-of-band 的资料被取出。对 Datagram Socket 言,只取
出第一个 datagram;若是该 datagram 大 於使用者提供的储存空间,那麽只有该空
间大小的资料被取出,多馀的资料将遗失,且回覆错误的讯息。另外如果 Socket
为 Blocking 模式,且目前 input buffer 内没有任何资料,则 recv() 将 block 到有任
何资料到达为止;如果为 Non-Blocking 模式,且 input buffer 无任何资料,则会马
上回覆错误。参数 flags 的值可为 0 或 MSG_PEEK、MSG_OOB 的组合;
MSG_PEEK 代表将资料拷贝到使用者提供的 buffer,但是资料并不从系统的 input
buffer 中移走;0 则表示拷贝并移走。(参考 WINSOCK 第1.1版41 页)
【Server 端的资料收送及关闭 Socket】
在前一期中,我们说建立的是一个 Asynchronous 模式的 Server;程式中,
我们曾对 listen_sd 这个 Socket 呼叫 WSAAsyncSelect() 函式,并设定
FD_ACCEPT 事件,所以当 Client 与我们连接时,系统会传给我们一个
ASYNC_EVENT 讯息(请参见前一期文章内容);我们在收到讯息并判断是
FD_ACCEPT 事件,於是呼叫 accept() 来建立连接。
my_sd = accept(listen_sd, (struct sockaddr far *)&sa, &sa_len)
我们在呼叫完 accept() 函式,成功地建立了 Server 端与 Client 端的连接後,
此时便可利用新建的 Socket(my_sd)来收送资料了。由於我们同样希望用
Asynchronous 的方式,因此要再利用 WSAAsyncSelect() 函式来帮新建的
Socket 设定一些事件,以便事件发生时 Winsock Stack 能主动通知我们。由於我
们的 Server 是被动的接受 Client 的要求,然後再做答覆,所以我们设定
FD_READ 事件;我们也希望 Winsock Stack 在知道 Client 关闭 Socket 时,能主
动通知我们,所以同时也设定 FD_CLOSE 事件。(读者须注意,我们设定事件
的 Socket 号码是呼叫 accept 後传回的新 Socket 号码,而不是原先监听状态的
Socket 号码)
WSAAsyncSelect(my_sd, hwnd, ASYNC_EVENT, FD_READ|FD_CLOSE)
在这里,我们同样是利用 hwnd 这个视窗及 ASYNC_EVENT 这个讯息;在
前文中,笔者曾告诉各位,在收到 ASYNC_EVENT 讯息时,我们可以利用
WSAGETSELECTEVENT(lParam) 来判断究竟是哪一事件(FD_READ 或
FD_CLOSE)发生了;所以并不会混淆。那我们到底在什麽时候会收到
FD_READ 或 FD_CLOSE 事件的讯息呢?
【FD_READ 事件】
我们会收到 FD_READ 事件通知我们去读取资料的情况有 :
(1)呼叫 WSAAsyncSelect 函式来对此 Socket 设定 FD_READ 事件时,
input buffer 中已有资料。
(2)原先系统的 input buffer 是空的,当系统再收到资料时,会通知我们。
(3)使用者呼叫 recv 或 recvfrom 函式,从 input buffer 读取资料,但是并
没有一次将资料读光,此时会再驱动一个 FD_READ 事件,表示仍有资料在
input buffer 中。
读者必须注意:如果我们收到 FD_READ 事件通知的讯息,但是我们故意
不呼叫 recv 或 recvfrom 来读取资料的话,尔後系统又收到资料时,并不会再次
通知我们,一定要等我们呼叫了 recv 或 recvfrom 後,才有可能再收到
FD_READ 的事件通知。
【FD_CLOSE 事件】
当系统知道对方已经将 Socket 关闭了的情况下(收到 FIN 通知,并和对方
做关闭动作的 hand-shaking),我们会收到 FD_CLOSE 的事件通知,以便我
们也能将这个相对的 Socket 关闭。FD_CLOSE 事件只会发生於 TCP Socket,因
为它是 connection-oriented;对於 connectionless 的 UDP Socket,即使设了
FD_CLOSE,也不会有作用的。
程式中,当 Client 端送一个要求(request)来时,系统会以
ASYNC_EVENT 讯息通知我们的 hwnd 视窗;我们在利用
WSAGETSELECTEVENT(lParam) 及 WSAGETSELECTERROR(lParam) 知道是
FD_READ 事件及检查无误後,便呼叫 recv() 函式来收取 Client 端送来的资料。
recv(wParam, &data, sizeof(data), 0)
笔者在前一期文章中也曾提到说,FD_XXXX 事件发生,收到讯息时,视
窗 handle 被呼叫时的参数 wParam 代表的就是事件发生的 Socket 号码,所以此
处 wParam 的值也就是前面提到的 my_sd 这个 Socket 号码。recv() 的第四个参
数设为 0,表示我们要将资料从系统的 input buffer 中读取并移走。
收到要求後,我们要答覆 Client 端,也就是要送资料给 Client;这时我们就
要利用 send() 这个函式了。
我们先将资料放到 data 这个资料暂存区,然後呼叫 send() 将它送出,我们
利用的也是 wParam (my_sd) 这个同样的 Socket 来做传送的动作,因为它是双向
的。
send(wParam, &data, strlen(data), 0)
Server 与 Client 收送资料一段时间後(资料全部收送完毕),如果 Client 端
先呼叫 closesocket() 将它那端的 Socket 关闭,那麽系统在知道後,会通知我们
一个 FD_CLOSE 事件的讯息,此时我们也可以呼叫 closesocket() 将我们这端的
Socket 关闭了;当然我们也可以呼叫 closesocket() 先主动关闭我们这端的
Socket。
【Client 端的资料收送及关闭 Socket】
我们例子的 Client 是采 Blocking 模式,所以在呼叫 connect() 函式与 Server
连接时,可能会等一下子才成功;connect() 函式返回後,且无错误发生的话,
Client 与 Server 端的 TCP socket 连接就算成功了。这时,我们便可利用这个连
接成功的 Socket 来送收资料了。由於我们并没有要设定为 Asynchronous 模式,
所以也不用呼叫 WSAAsyncSelect() 来设定事件。
Client 端通常是会先主动发出要求到 Server 端,因此我们呼叫 send() 来传送
此一资料。我们的资料量很小,所以并不会被 send() 函式 Block 住;不过如果
您要送的资料量很大,那麽可能会等一段时间才会自 send() 函式返回;也就是
说必须等资料都放到系统的 output buffer 後才会返回;这是因为我们 Client 的
Socket 是阻拦模式。如果我们用的是非阻拦模式的 Socket,那麽 send() 函式会
视系统的 output buffer 的空间有多少,只拷贝那麽多的资料到 output buffer,然
後就返回,并告知使用者送出了多少资料,并不须等所有资料都放到 output
buffer 才返回。
我们将要求放在 data 资料暂存区,然後呼叫 send() 将要求送出。资料送出
後,我们呼叫 recv() 来等待 Server 端的答覆。
send(mysd, data, strlen(data), 0)
recv(mysd, &data, sizeof(data), 0)
由於我们 Client 端是 Blocking 模式,所以 recv() 会一直 Block 住,直到下
列的情况之一发生,才会返回。
(1)Server 端送来资料。(此时 return 值是读取的资料长度)
(2)Server 端将相对的 Socket 关闭了。(此时的 return 值会是 0)
(3)Client 端自己呼叫 WSACancelBlockingCall() 来取消 recv() 的呼叫。
(此时 return 值是 SOCKET_ERROR 错误,错误码 10004 WSAEINTR)
同样地,资料全部送收完毕後,我们也呼叫 closesocket() 来将 Socket 关
闭。
◎ WSACancelBlockingCall():取消目前正在进行中的 blocking 动作。
格 式: int PASCAL FAR WSACancelBlockingCall( void );
参 数: 无
传回值: 成功 - 0
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式用来取消该应用程式正在进行中的 blocking 动作。通常的
使用时机有:(a) Blocking 动作正在进行中,该应用程式又收到某一讯息
(Mouse、Keyboard、Timer 等),则可在处理该讯息的段落中呼叫此函式。(b)
Blocking 动作正在进行中,而 Windows Sockets 又呼叫回应用程式的
「blocking hook」函式时,在该函式内可呼叫此函式来取消 blocking 动作。
使用者必须注意,在某一 Winsock blocking 函式动作进行时,除了
WSAIsBlocking() 及 WSACancelBlockingCall() 外,不可以再呼叫其它任何
Windows Sockets DLL 提供的函式,否则会产生错误。另外若取消的
blocking 动作不是 accept() 或 select() 的话,那麽该 Socket 可能会处於未定
状态,使用者最好是呼叫 closesocket() 来关闭该 Socket,而不该再对它做任
何动作。
(图 2.)demoserv 与 democlnt 在资策会 WinKing 上收送资料的画面
(图 3.)demoserv 与 democlnt 在资策会 WinKing 上关闭 Socket 後的画面
介绍完了 TCP Socket 的资料收送,笔者接著为读者介绍 sendto() 及
recvfrom() 这两个函式,以及许多人可能很容易搞错的 FD_WRITE 事件。
【sendto 及 recvfrom 函式】
一般言,TCP Socket 使用的是 send() 及 recv() 这两个函式;而 UDP Socket
用的是 sendto() 及 recvfrom() 函式。这是因为 TCP 是 Connection-oriented,必须
做完 Socket 真正的连接程序後,才可以开始收送资料,此时系统已经知道了连
接的对方,所以我们不用再指定资料要送到哪里。而 UDP 是 Connectionless,
收送资料的双方并没有建立真正的连接,所以我们要利用 sendto() 及 recvfrom()
来指定收资料的对方及获知是谁送资料给我们。
TCP Socket 也可以用 sendto() 及 recvfrom() 来送收资料,只是此时这两个
函式的最後两个参数没有作用,会被系统所忽略。而 UDP Socket 如果呼叫了
connect() 函式来指定对方的位址(这个 connect 并不会真的和对方做连接的动
作,而是告知我们本身的系统说我们只想收、送何方的资料),那麽也可以利
用 send() 及 recv() 来送收资料。
◎ sendto():将资料送到使用者指定的目的地。
格 式: int PASCAL FAR sendto( SOCKET s, const char FAR *buf,
int len, int flags, const struct sockaddr FAR *to, int
tolen );
参 数: s Socket 的识别码
buf 存放要传送的资料的暂存区
len buf 的长度
flags 此函式被呼叫的方式
to 资料要送达的位址
tolen to 的大小
传回值: 成功 - 送出的资料长度
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式适用於 Datagram 或 Stream Socket 来传送资料到指定的
位址。 对 Datagram Socket 言,若是 datagram 的大小超过限制,则将不会
送出任何资料,并会传回错误值。对 Stream Socket 言,其作用与 send() 相
同;参数 to 及 tolen 的值将被系统所忽略。 若是传送 (transport) 系统内之储
存空间不够存放这些要传送的资料,sendto() 将会被 block 住,直到资料都被
送出;除非该 Socket 被设定为 non-blocking 模式。使用者亦须注意 sendto()
函式执行完成,并不表示资料已经成功地送抵对方了,而可能仍在系统的 output
buffer 中。 flags 的值可设为 0、MSG_DONTROUTE 及 MSG_OOB 的组合。
(参见 WINSOCK第1.1版51页)
◎ recvfrom():读取资料,并储存资料来源的位址。
格 式: int PASCAL FAR recvfrom( SOCKET s, char FAR *buf, int len, int flags,
struct socketaddr FAR *from, int FAR *fromlen );
参 数: s Socket 的识别码
buf 存放接收到的资料的暂存区
len buf 的长度
flags 此函式被呼叫的方式
from 资料来源的位址
fromlen from 的大小
传回值: 成功 - 接收到的资料长度 (若对方 Socket 已关闭,则为 0)
失败 - SOCKET_ERROR (呼叫 WSAGetLastError() 可得知原因)
说明: 此函式用来读取资料并记录资料来源的位址。对 Datagram Socket
(UDP)言,一次读取一个 Datagram;对 Stream Socket (TCP)言,其作用与
recv() 相同,参数 from 及 fromlen 的值会被系统忽略。如果 Socket 为 Blocking 模
式,且目前 input buffer 内没有任何资料,则 recvftom() 将 block 到有任何资料到
达为止;如果为 Non-Blocking 模式,且 input buffer 无任何资料,则会马上回覆错
误。
【FD_WRITE 事件】
笔者在前面介绍过 FD_READ 事件的发生时机,现在继续介绍 FD_WRITE
这个较易使人混淆的事件,因为真的有相当多的人对此一事件的发生不明了。
由字面上看,FD_WRITE 应该是要求系统通知我们某个 Socket 现在是否可
以呼叫 send() 或 sendto() 来传送资料?答案可以说「是」,但是它和 FD_READ
却又有不同的地方。
在前面我们知道呼叫一次 recv() 後,如果 input buffer 中尚有资料未被取出
的话,系统会再通知我们一次 FD_READ。那麽如果我们呼叫一次 send() 後,
系统的 output buffer 仍有空间可写入的话,它是否会再通知我们一个
FD_WRITE,叫我们继续传送资料呢?这个答案就是「否定」的了!系统并不
会再通知我们了。
系统会通知我们 FD_WRITE 事件的讯息,只有下列几种情况:
(1)呼叫 WSAAsyncSelect() 来设定 FD_WRITE 事件时,Socket 已经可以
传送资料(TCP scoket 已经和对方连接成功了,或 UDP socket 已建立完成),
且目前 output buffer 仍有空间可写入资料。
(2)呼叫 WSAAsyncSelect() 来设定 FD_WRITE 事件时,Socket 尚不能传
送资料,不过一旦 Socket 与对方连接成功,马上就会收到 FD_WRITE 的通
知。
(3)呼叫 send() 或 sendto() 传送资料时,系统告知错误,且错误码为
10035 WSAEWOULDBLOCK (呼叫 WSAGetLastError() 得知这项错误),这
时表示 output buffer 已经满了,无法再写入任何资料(此时即令呼叫再多次的
send() 也都一定失败);一旦系统将部份资料成功送抵对方,空出 output buffer
後,便会送一个 FD_WRITE 给使用者,告知可继续传送资料了。换句话说,读
者在呼叫 send() 传送资料时,只要不是返回错误 10035 的话,便可一直继续呼
叫 send() 来传送资料;一旦 send() 回返错误 10035,那麽便不要再呼叫 send()
传送资料,而须等收到 FD_WRITE 後,再继续传送资料。