Winsock2使用記錄

Oberon發表於2021-05-27

本文地址:https://www.cnblogs.com/oberon-zjt0806/p/14814144.html
WinSock 2 MSDN文件:https://docs.microsoft.com/en-us/windows/win32/winsock/windows-sockets-start-page-2
一個很好的範例(如果我的程式碼有問題,可供參考這個):https://github.com/gaohaoning/cpp_socket

什麼是WinSock?

這還不簡單,當然是Windows的襪子啦

WinSock(全稱Windows Sockets)是巨硬微軟提供的用於Windows平臺的C++網路連線庫。

簡單來說我們知道在Java中,我們可以通過JDK提供的java.net庫來實現建立套接字的TCP/UDP傳輸:

public class Client
{
	private String svrip = "";
	private int port;
	
	public AnotherClient(String serverIP,int port) {
		svrip = serverIP;
		this.port = port;
	}
	
	public void Start() {
		Socket cltsock = null; // Client's socket
		
		OutputStream ostrm = null; // O-stream for sending to server
		BufferedWriter bwriter = null;
		
		InputStream istrm = null;
		BufferedReader breader = null;
		
		try {
			cltsock = new Socket(svrip,port);
			System.out.println(String.format("Client at %s has started.", cltsock.getInetAddress().toString()));
			istrm = cltsock.getInputStream();
			
			// .....後面不寫了,意思意思得了

WinSock的功能與之類似,只不過因為結構僵化的C++標準委員會遲遲不將專用於C++網路連線的<network>納入STL(標準庫)中,所以我們這裡只好委曲求全地使用Windows的標準,在Linux環境中,替代品為系統核心提供的<sys/socket.h>庫。不過整體使用上,兩者區別並不那麼大。

Winsock比較常用的有兩類版本——1.1和2.x。本文這裡,當然了,標題也說了,以Winsock2為準,建立一個IPv4協議下的傳輸鏈路

Workflow

嗯,自從因為各種原因對自己產生了一個巨大的否定以來,我發現總結一個具體的工作流程(Workflow)出來還是挺重要的一件事。

那麼,我們這裡就嘗試總結一下,建立一個連線併傳送訊息期間,Winsock都做了什麼:

graph TD subgraph server[Server] begin[開始] --> init[初始化WSA] --> setupsvr[建立Server Socket] --> binding[設定地址繫結] --> listening[Server Socket啟動監聽] accept((接收來自Client的連線)) recv[接收來自Client的訊息] send[向Client傳送資訊] close[關閉Server Socket] terminal[結束] listening --> accept accept --> send accept --> recv send --> close recv --> close close --> terminal end subgraph client[Client] beginc[開始] --> initc[初始化WSA] --> setupclt[建立Client Socket] connect{連線ip所在的Server的連線} crecv[接收來自Server的訊息] csend[向Server傳送資訊] cclose[關閉Client Socket] terminalc[結束] setupclt --> connect connect --> csend connect --> crecv csend --> cclose crecv --> cclose cclose --> terminalc end csend -.-> recv send -.-> crecv

Server Side(服務端)

先說Server這邊,從上面這張圖來看,他做了這麼幾件事:

  1. 初始化WSA
    所謂WSA就是WinSock API,當然了,因為我們這裡用的是Winsock2,所以我們做的就是對Winsock2的初始化,畢竟只有初始化之後我們才能使用Winsock裡面的相關功能,當然具體來說這裡其實還包括一些其他的事情,比如版本比對之類的。

  2. 建立Server的套接字
    當我們可以使用Winsock的相關功能後,我們就可以通過Winsock建立套接字以建立連線了。

  3. 繫結地址
    這個先留著,放到程式碼裡再說……

  4. 啟動監聽
    當套接字被啟動後,我們允許該套接字監聽是否有外部的客戶端的連線請求

  5. 接收Client的連線
    有Client的時候我們獲得這位Client的套接字。

  6. 收發訊息
    連線建立完成了,可以傳輸資料了

  7. 結束
    打完收工!

初始化WSA

既然我們使用Winsock2,那麼我們首先做的必然就是對Winsock2的調取,原始的來說,Winsock2隸屬於Win32的系統庫,當你引入標頭檔案WinSock2.h的時候你就已經獲得了Winsock2的結構宣告。當然,為了讓他能夠調取系統庫,我們需要把他靜態連結進來

#include <WinSock2.h>
#include <ws2tcpip.h>	//???
#pragma comment(lib, "WS2_32.lib")

注意,上面第2行中,我又引入了一個ws2tcpip.h,這是因為在Winsock2.h中宣告的有關函式不再倡用了(Deprecated),這種情況下直接使用這類函式會直接觸發錯誤,當然,如果你執意要用的話,那就在最開始的時候補充一下抑制巨集

//如果你非要使用Deprecated內容的話
#define _WINSOCK_DEPRECATED_NO_WARNINGS
// includes... pragma...

然後我們開始在Server的主程式裡初始化WSA,初始化過程主要經過:設定版本限制→初始化→獲得初始化資訊,因此我們首先設定我們能接受的WinSock庫的最低版本:

WORD verRequest = MAKEWORD(1, 1);

雖然說我們用的是WinSock2,不過我們這裡本著向下相容的原則,我們約定要是隻有1.1也行……
然後我們需要使用WSAStartup函式來真正初始化WSA,這個函式接收兩個引數,一個版本約定和一個資料結構:

WSAData wData;
int $err = WSAStartup(wVersionRequest, &wData);

初始化期間,系統會根據你提供的Version Request來評估當前系統內的Winsock版本,並將結果寫進wData中,返回初始化失敗的錯誤程式碼,如果沒有錯誤返回0,因此,這裡我們在發生錯誤的情況下直接結束程式。

if($err != 0)
{
	cout << "Initialization Failed" << endl;
	WSACleanup();
	ExitProcess($err);
}

如果到這裡沒進入if的話,那麼說明初始化成功了,我們可以輸出WSA的資訊看一眼:

cout << wData.szDescription << endl;

wData.szDescription存放了初始化時系統獲得的API描述文字,在我的機器上,輸出的是

WINSOCK 2.0

很明顯,因為我用的WinSock2嘛,也就是說現在為止我係統中提供的WinSock庫並沒有什麼問題。

建立服務端套接字

既然WSA可以正常使用,那麼從這裡開始我們就正式的開始用WSA提供的功能了。

既然是服務端程式,那麼能想到的第一件事必然是:建立套接字(Socket)

WinSock提供了SOCKET型別和socket函式來表示並建立一個套接字。我們先來看一下socket()函式該怎麼用:

SOCKET socket(
    _In_ int af,
    _In_ int type,
    _In_ int protocol
    );

其實前面修飾符還有什麼_Must_inspect_result_什麼的,這些我們先不管,撈乾的來看就是這樣的函式。

引數名稱 型別 用途
af int 你的socket所使用的地址協議族,我們這裡只介紹IPv4的,所以這裡填入AF_INET就好了
type int 你的socket建立連線所傳輸的資料形式,比較常用的有流式SOCK_STREAM或者資料包SOCK_DGRAM,當然還有其他的這裡暫時不作介紹
protocol int 你的socket使用的傳輸協議,可填入以IPPROTO_*開頭的常量,也可以填入0來自動選擇協議,自動選擇協議的規則與type相關,例如type=SOCK_STREAM那麼會使用TCP協議,如果是type=SOCK_DGRAM那麼會選擇UDP協議

我們這裡以IPv4的TCP協議為例,那麼可以建立這樣的socket:

SOCKET sckSrv = socket(AF_INET, SOCK_STREAM, 0);

如果socket沒有成功建立,那麼函式會返回一個INVALID_SOCKET

為Server socket繫結地址

使用上面的方法建立的套接字僅包含地址協議的資訊,但這個套接字並不具備一個地址,出於某種原因,我們並不能直接操縱Socket實體本身來設定這些東西,因為SOCKET型別說到底只是一個控制程式碼id,並不承載其他資訊。WinSock提供了專用於給socket繫結地址的函式bind

int bind(
	SOCKET		s,
	const sockaddr 	*name,
	int            	namelen
    );

還是直接撈乾的看,這裡的SOCKET s肯定就是剛才的sckSrv,填進去就可以了。而name這裡就需要特別注意一下了,name要求你輸入的實際上是你的ip地址,但是這裡接受的型別是SOCKADDR *SOCKADDR是用於表示地址的一個資料結構,包含地址協議族和具體的ip地址資訊,不過SOCKADDR的結構很raw,不是很好構建,因此我們需要使用一個改進結構SOCKADDR_IN,這個結構專用於IPv4(IPv6請使用SOCKADDR_IN6):

typedef struct sockaddr_in {
        short   sin_family;
        u_short sin_port;
        struct  in_addr sin_addr;
        char    sin_zero[8];
} SOCKADDR_IN;

因此我們這裡如果要構建地址的話:

SOCKADDR_IN addrSrv;
addrSrv.sin_family = AF_INET;

注意,這裡的sin_addr是個in_addr型別,這個型別並不支援直接輸入我們所說的字面上的ip地址"xxx.xxx.xxx.xxx"這種的,因此如果你想用字串的ip地址輸入進去的話,就必須用inet_aton做地址形式轉換。

#ifdef _WINSOCK_DEPRECATED_NO_WARNINGS
//指定為本機地址
addrSrv.sin_addr = inet_addr("127.0.0.1");
//或者你也可以指定為預設路由(0.0.0.0),向下面這樣寫
addrSrv.sin_addr = INADDR_ANY;
#endif

然而,如果你沒有解除Deprecation warning(沒有定義_WINSOCK_DEPRECATED_NO_WARNINGS)的話,那麼直接使用inet_addr報錯,這種情況下如果堅持不新增抑制巨集的替代做法是使用inet_pton來完成:

#if (!defined _WINSOCK_DEPRECATED_NO_WARNINGS) && (defined _WS2TCPIP_H_)
inet_pton(AF_INET, "127.0.0.1", &(addrSrv.sin_addr));
#endif

其實這個型別是個32位整數,如果你確定你的ip地址寫成32位整數是什麼樣子的話那麼沒有必要費勁巴力的做地址形式轉換,而直接賦值成0x????????的形式,但是這樣程式碼可讀性很差,而且還涉及到本機到網路傳輸時的大小端點轉換問題(hton*),而inet_*轉換出來的地址碼是確定符合網路傳輸的形式(這話是巨硬msdn裡說的)。

最後我們不要忘了還得提供埠號:

addrSrv.sin_port = htons(17500);	// 這個需要轉換為網路格式,埠號隨你喜歡

當然,如果你引入了<ws2tcpip.h>,那麼你還可以繼續用更加通用的地址結構sockaddr_gen,當然,不用也沒關係,使用這個結構(其實是個union)只是規避了接下來的型別轉換的問題。(注意,名字是小寫的,和之前的不一樣,之前的SOCKADDRSOCKADDR_IN其實也可以全小寫):

#ifdef _WS2TCPIP_H_
sockaddr_gen gaddrSrv;
gaddrSrv.AddressIn = addrSrv;

上述內容其實是為了構建地址(不得不說確實挺麻煩,而且事情特別多,但是如果理清的話其實很容易),說白了就是準備填入name這個引數,但是注意,由於bindname引數是SOCKADDR *型別,因此你不管用什麼型別折騰,最後都要轉換回這個raw型別:

// 如果沒定義gaddrSrv
bind(sckSrv, (SOCKADDR *)&addrSrv, sizeof(SOCKADDR));
// 如果定義了gaddrSrv
bind(sckSrv, &(gaddrSrv.Address), sizeof(SOCKADDR));

第三個引數的namelen就指定成SOCKADDR的大小即可(因為name指向SOCKADDR嘛)。

啟動服務端監聽

服務端嘛,你要服務的嘛,所以我們需要開啟服務端的監聽,讓sckSrv聽取外界是否有其他socket連入,使用listen函式啟動監聽:

listen(sckSrv, SOMAXCONN);

第一個引數就是你的伺服器套接字,第二個引數是最大允許的連線等待佇列長度,SOMAXCONN是最大的允許限制範圍了,設定為SOMAXCONN可以理解為沒有數量限制(其實是有的,SOMAXCONN=0x7fffffff,當然,WinSock允許使用SOMAXCONN_HINT設定更大的值,但僅限於能夠在巨硬自己的TCP/IP協議服務供應器下使用,我們這裡不考慮這個東西)。

獲取連入的客戶端

啟用監聽後,Server將能夠獲得來自外界的連線請求,使用accept原語來取得連入的客戶端資訊:

SOCKET accept(
  SOCKET   s,
  sockaddr *addr,
  int      *addrlen
);

accept原語令伺服器s接收第一個等待連線的客戶端(如果沒有則阻塞這個伺服器程式,直至有第一個進入),並獲取這個客戶端的地址資訊,存入addr中。

由於我們後面會需要Server向Client傳送資料,因此我們這裡獲得客戶端addr的行為是必需的,所以還是類似的方式,定義客戶端的addr結構:

SOCKADDR_IN addrCli;
SOCKET sckCli = accept(sckSrv, (SOCKADDR *) &addrCli, sizeof(SOCKADDR));

由於我們並不知道客戶端的任何網路地址資訊,因此我們只建立addrCli但無需初始化,與addrSrv類似,你也可以使用sockaddr_gen型別,這裡不演示了。

再次強調,這裡的accept原語是阻塞的!如果需要非阻塞的accept原語,可能需要select配合,但是本文暫時不討論這個。

當然,如果accept的引數不正確(比如你的addrlen大小不對),那麼他有可能返回一個INVALID_SOCKET

收發資料

獲得了客戶端的socket,我們就說建立了從server到socket的連線,連線建立完成,我們就可以經由這個連線傳遞資料了(雙向地)。

當然了,直接使用sendrecv原語就可以了,但是在使用之前,我們先確立緩衝區用於儲存收發的資料:

char sendbuf[1024] = "Hello, from SERVER.",
	 recvbuf[1024];

然後我們集中看一下send和recv原語:

int send(
  SOCKET     s,
  const char *buf,
  int        len,
  int        flags
);

int recv(
  SOCKET s,
  char   *buf,
  int    len,
  int    flags
);

應該很言簡意賅了,s是你要傳送的client socket,flags我們先不管,給0就可以,flags主要控制訊息收發時的行為,我們這裡就按照一般的收發方法正常收發就可以了。我們這裡讓server先對連入的client傳送訊息,然後再傳送(留意這一點,下面我們寫客戶端的時候就需要把這個順序反過來,當然你也可以在這裡先收後發,那在客戶端那邊就還得反過來):

send(sckCli, sendbuf, sizeof(sendbuf), 0);
recv(sckCli, recvbuf, sizeof(recvbuf), 0);

就可以收發資訊了,如果沒有別的事情的話,那麼就可以……

關閉套接字、釋放WSA

善始善終是一種美德,特別是對於C++而言。如果沒有別的事情,我們想結束的話,那就需要關閉套接字,然後釋放掉WSA,這兩個很簡單:

closesocket(sckCli);
closesocket(sckSrv);
WSACleanup();
ExitProcess(0);

好了,服務端程式就寫的差不多了,程式碼會在文章最後整理。

Client Side(客戶端)

客戶端和服務端的寫法就差不太多了,但是注意幾點:

  1. 由於客戶端是積極連線服務端,因此客戶端一般不需要自己的ip地址,所以客戶端的socket不需要地址繫結,也不需要啟動監聽
  2. 服務端是accept原語,那麼客戶端就是connect原語
  3. 客戶端只需要建立自己的socket,不需要考慮服務端
  4. 沒了

相比較剛才的過程而言,再補充說明幾個新加入的東西……

與服務端的積極連線

服務端是被動的accept外界的連線,那等的是誰呢,顯然就是客戶端主動的connect。

int connect(
  SOCKET         s,
  const sockaddr *name,
  int            namelen
);

其中s客戶端自己的socket,雖然說客戶端的socket並不需要繫結地址,但是客戶端仍然要提供服務端的地址資訊(不然鬼知道你想跟誰連),也就是說addrSrv仍然要提供。

然後再注意的一點就是收發和服務端應當是相反的

同樣,程式碼我在後面彙總。

當你遇到錯誤時……

寫這兩個程式期間,你可能就已經開始執行除錯了,當然多數時候你不大可能一遍寫成(除非你是直接拷的程式碼),因此難以避免的你會遇到各種錯誤,有的和你的程式碼有關,有的可能與你的網路環境有關,前者你大可通過偵錯程式和文件來解決,但後者就很難捕捉了。

而WinSock用於返回錯誤的函式WSAGetLastError(),主要用於返回錯誤程式碼,你可以根據msdn文件對照你的錯誤程式碼來定位你的錯誤。如果沒有任何錯誤,該函式應當返回0。

程式碼彙總

Server

#include <WinSock2.h>
#include <ws2tcpip.h>	//???
#include <iostream>

#pragma comment(lib, "WS2_32.lib")

using namespace std;

int main(int argc, char **argv)
{
	WORD verRequest = MAKEWORD(1, 1);
	WSAData wData;
	int $err = WSAStartup(wVersionRequest, &wData);
	if($err != 0)
	{
		cout << "Initialization Failed" << endl;
		WSACleanup();
		ExitProcess($err);
	}
	cout << wData.szDescription << endl;

	SOCKET sckSrv = socket(AF_INET, SOCK_STREAM, 0);
	if(sckSrv == INVALID_SOCKET)
	{
		closesocket(sckSvr);
		WSACleanup();
		ExitProcess(INVALID_SOCKET);
	}

	SOCKADDR_IN addrSrv;
	addrSrv.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &(addrSrv.sin_addr));
	addrSrv.sin_port = htons(17500);

	bind(sckSrv, (SOCKADDR *)&addrSrv, sizeof(SOCKADDR));

	listen(sckSrv, SOMAXCONN);

	SOCKADDR_IN addrCli;
	SOCKET sckCli = accept(sckSrv, (SOCKADDR *) &addrCli, sizeof(SOCKADDR));
	
	if(sckCli == INVALID_SOCKET)
	{
		closesocket(sckCli);
		closeSocket(sckSvr)
		WSACleanup();
		ExitProcess(INVALID_SOCKET);
	}

	char sendbuf[1024] = "Hello, from SERVER.",
	 	 recvbuf[1024];

	send(sckCli, sendbuf, sizeof(sendbuf), 0);
	recv(sckCli, recvbuf, sizeof(recvbuf), 0);
	
	cout << recvbuf << endl;

	closesocket(sckCli);
	closesocket(sckSrv);

	WSACleanup();
	
	ExitProcess(0);
}

Client

#include <WinSock2.h>
#include <ws2tcpip.h>	//???
#include <iostream>

#pragma comment(lib, "WS2_32.lib")

using namespace std;

int main(int argc, char **argv)
{
	WORD verRequest = MAKEWORD(1, 1);
	WSAData wData;
	int $err = WSAStartup(wVersionRequest, &wData);
	if($err != 0)
	{
		cout << "Initialization Failed" << endl;
		WSACleanup();
		ExitProcess($err);
	}
	cout << wData.szDescription << endl;

	SOCKET sckCli;
	if(sckCli == INVALID_SOCKET)
	{
		closesocket(sckCli);
		WSACleanup();
		ExitProcess(INVALID_SOCKET);
	}

	SOCKADDR_IN addrSrv;
	addrSrv.sin_family = AF_INET;
	inet_pton(AF_INET, "127.0.0.1", &(addrSrv.sin_addr));
	addrSrv.sin_port = htons(17500);

	connect(sckCli, (SOCKADDR*)&addrSrv, sizeof(SOCKADDR));

	char sendbuf[1024] = "Hi, from CLIENT.",
	 	 recvbuf[1024];

	recv(sckCli, recvbuf, sizeof(recvbuf), 0);
	cout << recvbuf << endl;
	send(sckCli, sendbuf, sizeof(sendbuf), 0);

	closesocket(sckCli);

	WSACleanup();

	ExitProcess(0);
}