簡單的 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再次呼叫
此函式時,會取消對該 Socket 原先之設定。若要取消對該Socket 的所有設定,則
lEvent 的值必須設為 0。
(圖2) WSAAsyncSelect 函式參數與應用程式關係
我們在程式中要求 Winsock 系統知道 Client 要來連接時,送一個
ASYNC_EVENT 的訊息到程式中 hwnd 這個視窗;由於我們想知道的只有 accept 事
件,所以我們只設定 FD_ACCEPT。
WSAAsyncSelect(listen_sd, hwnd, ASYNC_EVENT, FD_ACCEPT)
(圖 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 時,須呼叫此一函式來註銷使用,以便釋放其占用的資
源。
【結語】
這期筆者先介紹主從架構 TCP sockets 的連接及關閉,以後會再陸續介紹如何
收送資料,以及其他 API 的使用。想要進一步了解如何撰寫 Winsock 程式的讀者,
可以好好研究一下筆者 demoserv 及 democlnt 這兩個程式;也許不是寫的很好,但
是希望可以帶給不懂 Winsock 程式設計的人一個起步。
讀者們亦可自行用 anonymous ftp 方式到 SEEDNET 台北主機 tpts1.seed.net.tw
(139.175.1.10)的 UPLOAD / WINKING 目錄下,取得筆者與陳建伶小姐所設計的
WinKing 這個 Winsock Stack 的試用版,來跑 demoserv 與 democlnt 這兩個程式及其
他許許多多的 Winsock 應用程式。(正式版本請洽 SEEDNET 服務中心,新版的
WinKing 已含 Windows 撥接及 PPP 程式,適合電話撥接用戶在 Windows 環境下使
用 SEEDNET;WinKing 同樣也提供 Ethernet 環境的使用。)