******************************************************
Copyright by 林軍鼐
文稿內容不得轉載於任何商業書刊或做任何商業用途
******************************************************
簡單的 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 後,再繼續傳送資料。
【結語】
在這一期的文章中,筆者介紹了各位有關 TCP Socket 的資料收、送方式及
FD_READ、FD_WRITE 等事件的發生時機;讀者們綜合前一期的文章,應該
已經可以建立出一對主從架構的程式,並利用 TCP Socket 來傳送資料了。
下一期,筆者將繼續介紹有關如何獲取網路資訊的函式,如
gethostname()、getsockname()、getpeername(),以及同步與非同步的網路資料庫
擷取函式 getXbyY()、WSAAsyncGetXByY()。
本文中所提到的 WinKing 試用版可自 SEEDNET 台北主機 tpts1.seed.net.tw
(139.175.1.10)的 UPLOAD/WINKING 目錄中取得,檔名為 wkdemo.exe;
WinKing 提供 Ethernet 及 PPP 連線功能,適用於一般 Ethernet 網路,亦可用來
以電話、數據機連上 SEEDNET 的 PPP 伺服主機;範例 demoserv、democlnt,
以及一些筆者所寫的 Winsock 程式(含原始程式碼)則存放在
UPLOAD/WINKING/JNLIN 目錄下;有興趣的讀者可自行用 anonymous ftp 方式
取得。