1. 程式人生 > >套接字與網路通訊

套接字與網路通訊

程序之間有很多的通訊方法。 例如管道(包括有名管道和無名管道), 用於非同步通訊的訊號機制,  System V程序間通訊(包擴訊號量, 訊息佇列, 共享記憶體)。 這些通訊機制只是適用於單個機器內部的程序之間進行通訊。 

這裡我們說的是跨主機的程序之間的通訊機制, 即基於BSD的socket(套接字)通訊(即常說的網路通訊), 它不僅支援本地無關聯的兩個程序之間的通訊, 還支援跨網路的, 不同主機之間的程序之間的通訊。 利用套接字, 我們很容易的實現分佈與網路的C/S等模型。

討論的網路中兩主機之間通訊拓撲如下:


一 TCP/IP 模型

TCP/IP 是計算機網路通訊的一組協議, 又被稱為TCP/IP 協議簇, 主要包括TCP, IP, UDP, ICMP 等協議。 TCP/IP 有如下四層結構:自上而下分別為應用層, 傳輸層, 網路層, 網路介面層。


(1)網路介面層

主要用於資料幀的傳送和接受工作。 所謂的幀(frame)就是網路資訊傳輸單元。 也就是說該層負責將幀放到網路中, 或者從網路中把幀接收下來。 這一層主要是一些裝置驅動程式和網絡卡等硬體組成。

(2)網路層

這一層主要是實現了一組網路互連協議。傳輸的資料是IP報文, 每個IP報文都有目的地址和源地址。 用於把一個包(package)從傳送方主機經過網路, 傳到另一個具有IP地址的主機上。也就是說, 負責報文的路由選擇。 最核心的協議就是IP(internet protocol)協議了。  這一層實現了兩個IP協議版本, 一個是IPv4(32 bit), 一個是IPv6(128 bit)。 除此之外, 還有用於差錯診斷的ICMP(internet control message protocol)協議, 以及用於相鄰的多播路由(multicast routers)用於建立多播組的IGMP(internet group management protocol)協議。

(3)傳輸層

傳輸層主要為應用層提供end-to-end(or host-to-host)的服務。  主要有兩個傳輸控制協議:

一  TCP(transmission control protocol)協議, 這個協議時connection oriented transmissions(面向連線的), 也就是先連線上, 然後在進行資料傳輸。  實現的是可靠傳輸。適合一次傳輸大批資料, 並適用於得到響應的應用程式 。

二 UDP(user datagram protocol)協議, 提供的是無連線的通訊, 且不對傳送包進行可靠性確認, 適合於一次小批資料的傳輸, 可靠性有應用層完成。 

(4)應用層

應用程式通過這一層訪問網路。 主要包括如下協議:

一 Telnet:  用於遠端登入服務

二  FTP 用於檔案傳輸

三 SMTP(simple mail transfer protocol) 用於電子郵件協議

四 DNS(domain name system)  用於域名解析服務, 即將域名對映為IP地址的協議

五 FTTP 用於超文字傳輸協議。 作用就相當於C/S的請求-響應協議。 例如一個瀏覽器, 就是一個client, 提交一個HTTP的請求message到一個server, server將返回HTML檔案以及其他的內容給client。

資料包的封包拆包過程:


IP 地址

IP地址是在邏輯上唯一的標識一臺主機, 為32位的。 MAC地址是在物理上唯一的標識一臺主機的, 為48bit的. 埠號是主機內唯一標示執行程序的。

一個IP地址由網路號和主機號兩部分組成。  

網路ID: 標識一個網路。 同一個網路上的主機使用同一個網路號。 擁有相同的網路號的主機之間通訊不需要經過路由裝置。 這或許解釋了同一個實驗室的兩臺主機之間傳送檔案速度很快吧。

主機ID: 對於一個網路號來說,  其內部的每一臺主機的主機號是唯一的。 每一個主機由一個邏輯IP地址確定網路號和主機號。 

為適應不同大小的網路, 定義瞭如下五類IP地址(有二進位制表示法和點分十進位制表示法(最常見)): A, B, C, D, E類地址。

A類地址一般分配給政府機構。 擁有最大數量的主機。 最高位為0, 緊跟的7位為網路號, 最後24位表示主機號。 總共有126個網路。 為什麼不是127, 因為最後一個網段127(即0 1111111)適用於本地迴環測試的。  廣播地址為X.255.255.255 , 點分十進位制第一個位元組的範圍為1-126

B類地址, 最高兩位置為10, 前16位為網路號。 廣播地址是X.X.255.255 ,點分十進位制第一個位元組的範圍為128-191

C類地址, 最高三位置為110, 前24位總為網路號, 後八位為主機號。 廣播地址為X.X.X.255 。

注意廣播地址不能被當做主機號。點分十進位制第一個位元組的範圍為192-223.

D類地址, 最高位總被置為1110, 用於組播通訊。 沒有網路號和主機號之分。點分十進位制第一個位元組的範圍為224-239.

E類地址: 最高位總被置為1111. 僅供實驗的地址. 點分十進位制第一個位元組的範圍為240-254.

子網掩碼:

是用於區分網路號和主機號的一個32位的地址。 遮蔽掉主機號(與主機的IP地址相與)。 預設子網掩碼如下:

A類地址掩碼: 255.0.0.0

B類地址掩碼: 255.255.0.0

C類地址掩碼: 255.255.255.0

可以選擇新的子網掩碼重新劃分子網。 例如對255.255.224將C類地址103.67.10.0這個網路分成8組子網。 如下
255.255.255.224對應的二進位制表示為如下:

11111111  11111111  11111111  11100000.

遮蔽掉的時主機號, 網路號留下來了。 所以111共有8種可能。從000到111.

套接字(Socket)


套接字是應用程式和下層網路協議層之間的一個通訊埠。 對程式設計師來說, 套接字就等價於網路。

使用套接字通訊必須首先建立套接字。 互相通訊的兩個程序必須都呼叫socket()來建立自己那一端的套接字:

<span style="font-size:18px;">#include <sys/socket.h>

int socket(int domin, int type, int protocol);</span>


上述函式在通訊域domain中建立一個型別為type, 使用協議為protocol的套接字, 並返回一個最小的未使用的描述字。

一個給定的描述字要麼代表一個開啟的檔案, 要麼代表一個套接字。

套接字對應三種屬性: 域, 型別,協議。

引數domain指明通訊域。 通訊域決定了使用的網路協議。 標頭檔案<socket,h>列出了系統支援的通訊域:

(1)AF_UNIX: UNIX通訊域, 即同一臺計算機內兩個程序通過檔案系統進行通訊。 套接字的地址就是檔案系統的路徑名。

(2) AF_INET: 網路通訊, 使用32位的IPv4地址

(3) AF_INET6: 網路通訊, 使用128位的IPv6地址、

引數type指明套接字的型別, 主要有三種socket型別:
(1)SOCK_STREAM:  位元組流套接字, 簡稱流套接字, 是面向連結的, 雙向可靠的通訊。 如TCP

(2)SOCK_DGRAM:  資料報套接字。 支援雙向通訊。 但是此類套接字是不可靠的。 不保證資料報是順序的, 可靠地, 不重複的。 不同的資料報可以採用不同的路由器到達目的地址。如UDP

(3)SOCK_RAW: raw套接字。 只有根使用者才能建立raw套接字。

第三個引數標識採用協議簇的哪一種協議。 如果設定為0, 就是讓系統自動選擇預設協議。 但是對於raw 套接字(原始套接字), 必須有使用者指明使用哪一個具體的協議。

一般而言, 一旦指明瞭通訊域和通訊型別, 協議就是唯一的。 因此大部分情況下都設定protocol為0, 即讓系統選擇預設協議。

socket的這三種引數不能隨意指定組合值。 例如Unix域就不支援raw套接字型別。

domain和type 組合如下:

例如如下呼叫:

<span style="font-size:18px;">int mysock;
mysock = socket(AF_INET, SOCK_STREAM, 0);</span>

 表示建立一個流套接字, 底層通訊協議是TCP.

再比如:

<span style="font-size:18px;">int mysock;
mysock = socket(AF_UNIX, SOCK_DGRAM, 0);
 
</span>

表示要建立一個用於同一臺機器程序間通訊的資料報套接字。

該函式失敗返回-1, 並置errno。

socket()建立的只是一個套接字, 即兩個通訊埠之一。 如果通訊的兩個程序是有fork() 派生的具有共同祖先的程序, 可以使用socketpair建立一對套接字:

<span style="font-size:18px;">include <sys/socket.h>

int socketpair(int domin, int type, int protocol, int filedes[2]);
</span>

返回這對套接字於filedes[0]和filedes[1], 這一對套接字的通訊是全雙工的通訊通道, 兩端均可執行讀寫操作。

對於多數系統, domain指定為AF_UNIX, protocol設定為0.

socketpair() 通常用於父子程序通訊。 一個描述字給父程序使用, 一個描述字給子程序使用。

父程序需要關閉子程序的描述字, 使得其不能使用子程序的描述字, 子程序需要關閉父程序的, 從而避免不用父程序的描述字, 程式如下:

#include <sys/socket.h>
#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h> // for exit()

#define DATA1 "Fine, thanks."
#define DATA2 "hello, how are you?" </span><span style="font-size:18px;">
#define err_exit(m) \  
	do {\
		perror(m); \
		exit(EXIT_FAILURE); \
	} while(0);

int main() {
	int sockets[2], child;
	char buf[1024];
	
	if(socketpair(AF_UNIX, SOCK_STREAM, 0, sockets) < 0) {
		err_exit("socketpair error");	
	}
	
	if((child = fork()) == -1) { // 建立子程序
		err_exit("fork error");
	}

	if(child != 0) { // 這是父程序的程式碼
		close(sockets[0]); // 關掉子程序的套接字, 讀來自子程序的訊息
		if(read(sockets[1], buf, sizeof(buf)) < 0) { // 讀buff中的訊息</span><span style="font-size:18px;">
			err_exit("reading socket error");
		}
		printf("parent %d received request: %s\n", getpid(), buf);
		if(write(sockets[1], DATA1, sizeof(DATA1)) < 0) { // 向子程序寫訊息
			err_exit("writting socket error.");
		}
		close(sockets[1]); // 通訊結束
	}
	else {
		close(sockets[1]); // 關閉父程序的套接字端
		if(write(sockets[0], DATA2, sizeof(DATA2)) < 0) { // 傳送訊息給父程序
			err_exit("writing socket error");
		}
		if(read(sockets[0], buf, sizeof(buf)) < 0) { //讀來自父程序的訊息,讀到buf中
			err_exit("reading socket error");
		}
		printf("child process %d received answer: %s\n", getpid(), buf);
		close(sockets[0]); //通訊結束
	}
	return 0;
}


執行結果如下:


當不需要套接字的時候, 可以呼叫close()關閉它, 如同關閉一個檔案一樣。 關閉套接字之後, 套接字就不存在了。 有的時候, 不需要關閉套接字本身, 只需要斷開連線, 此時呼叫int shutdown(int socket, int how)即可。

socket通訊, 看誰先發起。 上述是子程序先寫, 即先發起通訊。

套接字的地址結構:

socket()建立套接字只是建立了本地系統的一個開放資源。 如果希望其他的程序能夠與這個套接字通訊, 該套接字必須有一個地址。

在UNIX通訊域中, 路徑名字就是套接字地址。

在Internet通訊域中, 套接字地址是由主機IP地址加上埠號組成的。

點分十進位制IP地址和二進位制IP地址轉換。

IPv4地址轉換:

函式:下面的函式將點分十進位制的字串表示的IP地址轉換成32位網路位元組順序。, 並將轉換結果存在指標addr所指的in_addr結構體中。

#inlcude <arpa/inet.h>

extern int inet_aton(const char *name, struct in_addr *addr);</span></span>

下面函式將32位位元組順序IP地址轉換成點分十進位制表示:

#inlcude <arpa/inet.h>

extern char* inet_ntoa(struct in_addr addr);</span></span>

域名地址

網路中的一臺計算機除了可以使用IP地址標識, 也可以用域名標識。 域名的採用層次結構方法命名的。

它有"."分隔的名字組成。 從左到右用“.”連線起來。 如www.google.comwww.baidu.com,www.hust.edu.cn等等 就是一個域名。每一個域名都對應一個IP地址。 位於最前面的計算機表示在主機在區域網中的主機名。 較後的名字則指明高一級的域名。     使用域名能夠幫助人們記住服務程式所執行的這臺計算機的名字。 域名地址都要轉換為IP地址。 DNS(域名系統)就是實現主機的域名(主機名)到IP地址的對映一個分散式資料庫。 處於區域網中的每一臺計算機可以有多個域名。 他們是同一臺主機的別名, 即指向同一個域名的指標。

下面舉一個例子: 使用gethostbyname()和gethostbyaddr()來從(域名系統)資料庫中獲得一臺主機的完整地址資訊, 包括主機名字, 別名, 和IP地址。 即我們使用這兩個函式做

函式如下:

#include <sys/socket.h>
#include <netdb.h>

struct hostent *gethostbyname(const char *name); // DEPRECATED! 不再使用了
struct hostent *gethostbyaddr(const char *addr, int len, int type);



NONO, 都被deprecated了, 應該使用如下函式getaddrinfo和:這個函式將會返回關於一個特定Host name(即IP地址)的相關資訊。 並將相關資訊裝入struct sockaddr, 完全替代了以前的gethostbyname以及getserverbyname的函式。 getaddrinfo不僅適用於IPv4, 也適用於IPv6. host name就存在引數nodename下面, 可以是“www.baidu.com”, 或者是IPv4或者IPv6的地址。servicename引數吃的是埠號,例如埠號80是用於HTTP 的, 相關的埠號位於/etc/services 檔案中, 例如http, ftp, telnet, smtp等等等等。  

include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

int getaddrinfo(const char *nodename, const char *servname,
                const struct addrinfo *hints, struct addrinfo **res);

void freeaddrinfo(struct addrinfo *ai);

const char *gai_strerror(int ecode);

struct addrinfo {
  int     ai_flags;          // AI_PASSIVE, AI_CANONNAME, ...
  int     ai_family;         // AF_xxx
  int     ai_socktype;       // SOCK_xxx
  int     ai_protocol;       // 0 (auto) or IPPROTO_TCP, IPPROTO_UDP 

  socklen_t  ai_addrlen;     // length of ai_addr
  char   *ai_canonname;      // canonical name for nodename
  struct sockaddr  *ai_addr; // binary address
  struct addrinfo  *ai_next; // next structure in linked list
};
<span style="font-size:18px;">和getnameinfo: 這個函式的作用和上面的getaddrinfo()完全相反。 這個函式吃一個已經裝入資訊的sockaddr變數,然後查詢一個name 或者service name。 這個函式完全替代了gethostbyaddr和getserverbyport。 </span>
include <sys/socket.h>
#include <netdb.h>

int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *serv, size_t servlen, int flags);


舉個例子如下:

/*
** showip.c -- show IP addresses for a host given on the command line
*/

#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <netinet/in.h>

int main(int argc, char *argv[])
{
    struct addrinfo hints, *res, *p;
    int status;
    char ipstr[INET6_ADDRSTRLEN];

    if (argc != 2) {
        fprintf(stderr,"usage: showip hostname\n");
        return 1;
    }

    memset(&hints, 0, sizeof hints);
    hints.ai_family = AF_UNSPEC; // AF_INET or AF_INET6 to force version
    hints.ai_socktype = SOCK_STREAM;

    if ((status = getaddrinfo(argv[1], NULL, &hints, &res)) != 0) {
        fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(status));
        return 2;
    }

    printf("IP addresses for %s:\n\n", argv[1]);

    for(p = res;p != NULL; p = p->ai_next) {
        void *addr;
        char *ipver;

        // get the pointer to the address itself,
        // different fields in IPv4 and IPv6:
        if (p->ai_family == AF_INET) { // IPv4
            struct sockaddr_in *ipv4 = (struct sockaddr_in *)p->ai_addr;
            addr = &(ipv4->sin_addr);
            ipver = "IPv4";
        } else { // IPv6
            struct sockaddr_in6 *ipv6 = (struct sockaddr_in6 *)p->ai_addr;
            addr = &(ipv6->sin6_addr);
            ipver = "IPv6";
        }

        // convert the IP to a string and print it:
        inet_ntop(p->ai_family, addr, ipstr, sizeof ipstr);
        printf("  %s: %s\n", ipver, ipstr);
    }

    freeaddrinfo(res); // free the linked list

    return 0;
}


最後, 說說位元組順序(byte order)和大小端的問題。

網路通訊使得資料從一個主機傳遞到另一個主機。 不同的處理器在在管理記憶體單元資料時, 對需要存放多個記憶體單元(一個byte視為一個記憶體單元)的一個數據的處理方式不同。

目前CPU資料管理主要有大端(big edian)和小端(little edian)兩種模式。

小端模式運算元的存放順序是高地址放高位元組。

例如, 一個無符號16進位制數0x12345678存放在0x4000到0x4003地址上, 存放格式如下:

0x4000 0x78

0x4001 0x56

0x4002 0x34

0x4003 0x12

大端模式是高地址放低位元組:

0x4000 0x12

0x4001 0x34

0x4002 0x56

0x4003 0x78

測試程式:

#include <stdio.h>
#include <netinet/in.h>
int main()
{
   int i_num = 0x12345678;
    printf("[0]:0x%x\n", *((char *)&i_num + 0)); // low address
    printf("[1]:0x%x\n", *((char *)&i_num + 1));
    printf("[2]:0x%x\n", *((char *)&i_num + 2));
    printf("[3]:0x%x\n", *((char *)&i_num + 3));
 
    i_num = htonl(i_num);
    printf("[0]:0x%x\n", *((char *)&i_num + 0));
    printf("[1]:0x%x\n", *((char *)&i_num + 1));
    printf("[2]:0x%x\n", *((char *)&i_num + 2));
    printf("[3]:0x%x\n", *((char *)&i_num + 3));
    return 0;
} 

在X64執行如下:

既然網路上傳輸的資料的位元組順序有差異, 所以在X86的平臺下編寫網路程式時要注意大小端的轉換。 例如繫結socket埠和IP地址時都要使用網路位元組順序。
 目前, X86計算機主要支援小端模式, 而網路位元組順序支援大端模式。 有些處理器即支援小端模式, 有支援大端模式。

有如下四種抓換函式:

#include <netinet/in.h>
uint16_t htons(uint16_t host16bitvalue); // short host to net
uint32_t htonl(uint32_t host32bitvalue);  // long host to net
uint16_t ntohs(uint16_t net16bitvalue); // short net to host
uint32_t ntohl(uint32_t net32bitvalue);   //long net to host

在儲存主機資訊的時候, IP地址和埠號均儲存為網路位元組順序。

struct sockaddr_in s_addr;
s_addr.sin_port = htons(7838);

(1)對於單位元組資料, 不存在位元組順序問題, 直接傳送。

char buf[] = "this is a test";
...
ret = send(socketfd, buf, strlen(buf), 0);

(2) 對於多位元組順序, 需要先先轉換為多位元組資料, 如short, int,等, 都必須首先轉換為大端模式再發送。

如下:

int age = 30;
...
age = htonl(age);
ret = send(socketfd, (void*)&age, sizeof(int), 0)

(3)對於結構體資料, 傳送更為複雜。

例如如下:

struct member {
    char name[32];
    int age; // 需要轉換
    char gender;
    char addr[128];
};
struct member personInfo;

把personInfo 傳送給對方,可以採用以下辦法:

(1)雙方均知道member結構體的定義。 所有, 不管人名是幾個位元組, 不管這個人的住址是幾個位元組, 傳送方可以按照如下方式傳送:

struct member personInfo;
....
ret =send(socketfd, personInfo.name, 32, 0);
personInfo.age = htonl(personInfo.age);
ret =send(socketfd, (void*)&personInfo.age, sizeof(int), 0);
...... 
ret =send(socketfd,  &personInfo.gender, sizeof(char), 0);
....

ret =send(socketfd, personInfo.addr, 128, 0);

...


接收方如下接受:

struct member personInfo;
....
ret =recv(socketfd, personInfo.name, 32, 0);

ret =recv(socketfd, (void*)&personInfo.age, sizeof(int), 0);
personInfo.age = ntohl(personInfo.age);
...... 
ret =send(socketfd,  &personInfo.gender, sizeof(char), 0);
....

ret =send(socketfd, personInfo.addr, 128, 0);

...


另一種辦法就是對資料進行pack處理。 傳送方如下寫程式:

#pack(1)
struct member personInfo;
....
....
personInfo.age = htonl(personInfo.age);
ret =send(socketfd, (void*)&personInfo, sizeof(member), 0);

接收方:

#pack(1) // 不一定是1, 關鍵是雙方定義保持一致
struct member personInfo;
....
ret =recv(socketfd, (void*)&personInfo, sizeof(struct member), 0);
personInfo.age = ntohl(personInfo.age);



這一方式關鍵是對其方式定義相同