Linux進程間通信(六): 套接口


      在本專題的前面幾個部分,如消息隊列、信號燈、共享內存等,都是基於Sys V的IPC機制進行討論的,它們的應用侷限在單一計算機內的進程間通信;基於BSD套接口不僅可以實現單機內的進程間通信,還可以實現不同計算機進程之間的通信。本文將主要介紹BSD套接口(sockets),以及基於套接口的重要而基本的API。


一個套接口可以看作是進程間通信的端點(endpoint),每個套接口的名字都是唯一的(唯一的含義是不言而喻的),其他進程可以發現、連接並且與之通信。通信域用來說明套接口通信的協議,不同的通信域有不同的通信協議以及套接口的地址結構等等,因此,創建一個套接口時,要指明它的通信域。比較常見的是unix域套接口(採用套接口機制實現單機內的進程間通信)及網際通信域。

1、背景知識

linux目前的網絡內核代碼主要基於伯克利的BSD的unix實現,整個結構採用的是一種面向對象的分層機制。層與層之間有嚴格的接口定義。這裏我們引用[1]中的一個圖表來描述linux支持的一些通信協議:


 

我們這裏只關心IPS,即因特網協議族,也就是通常所說的TCP/IP網絡。我們這裏假設讀者具有網絡方面的一些背景知識,如瞭解網絡的分層結構,通常所說的7層結構;瞭解IP地址以及路由的一些基本知識。

目前linux網絡API是基於BSD套接口的(系統V提供基於流I/O子系統的用戶接口,但是linux內核目前不支持流I/O子系統)。套接口可以說是網絡編程中一個非常重要的概念,linux以文件的形式實現套接口,與套接口相應的文件屬於sockfs特殊文件系統,創建一個套接口就是在sockfs中創建一個特殊文件,並建立起爲實現套接口功能的相關數據結構。換句話說,對每一個新創建的BSD套接口,linux內核都將在sockfs特殊文件系統中創建一個新的inode。描述套接口的數據結構是socket,將在後面給出。

2、重要數據結構

下面是在網絡編程中比較重要的幾個數據結構,讀者可以在後面介紹編程API部分再回過頭來了解它們。

(1)表示套接口的數據結構struct socket

套接口是由socket數據結構代表的,形式如下: 

struct socket
{
socket_state  state;     /* 指明套接口的連接狀態,一個套接口的連接狀態可以有以下幾種
套接口是空閒的,還沒有進行相應的端口及地址的綁定;還沒有連接;正在連接中;已經連接;正在解除連接。 */
  unsigned long    flags;
  struct proto_ops  ops;  /* 指明可對套接口進行的各種操作 */
  struct inode    inode;    /* 指向sockfs文件系統中的相應inode */
  struct fasync_struct  *fasync_list;  /* Asynchronous wake up list  */
  struct file    *file;          /* 指向sockfs文件系統中的相應文件  */
struct sock    sk;  /* 任何協議族都有其特定的套接口特性,該域就指向特定協議族的套接口對
象。 */
  wait_queue_head_t  wait;
  short      type;
  unsigned char    passcred;
};

(2)描述套接口通用地址的數據結構struct sockaddr

由於歷史的緣故,在bind、connect等系統調用中,特定於協議的套接口地址結構指針都要強制轉換成該通用的套接口地址結構指針。結構形式如下: 

struct sockaddr {
	sa_family_t	sa_family;	/* address family, AF_xxx	*/
	char		sa_data[14];	/* 14 bytes of protocol address	*/
};

(3)描述因特網地址結構的數據結構struct sockaddr_in(這裏侷限於IP4):

struct sockaddr_in
  {
    __SOCKADDR_COMMON (sin_);	/* 描述協議族 */
    in_port_t sin_port;			/* 端口號 */
    struct in_addr sin_addr;		/* 因特網地址 */
    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr) -
			   __SOCKADDR_COMMON_SIZE -
			   sizeof (in_port_t) -
			   sizeof (struct in_addr)];
  };

一般來說,讀者最關心的是前三個域,即通信協議、端口號及地址。

3、套接口編程的幾個重要步驟:

(1)創建套接口,由系統調用socket實現:

int socket( int domain, int type, int ptotocol);

參數domain指明通信域,如PF_UNIX(unix域),PF_INET(IPv4),PF_INET6(IPv6)等;type指明通信類型,如SOCK_STREAM(面向連接方式)、SOCK_DGRAM(非面向連接方式)等。一般來說,參數protocol可設置爲0,除非用在原始套接口上(原始套接口有一些特殊功能,後面還將介紹)。

注:socket()系統調用爲套接口在sockfs文件系統中分配一個新的文件和dentry對象,並通過文件描述符把它們與調用進程聯繫起來。進程可以像訪問一個已經打開的文件一樣訪問套接口在sockfs中的對應文件。但進程絕不能調用open()來訪問該文件(sockfs文件系統沒有可視安裝點,其中的文件永遠不會出現在系統目錄樹上),當套接口被關閉時,內核會自動刪除sockfs中的inodes。

(2)綁定地址

根據傳輸層協議(TCP、UDP)的不同,客戶機及服務器的處理方式也有很大不同。但是,不管通信雙方使用何種傳輸協議,都需要一種標識自己的機制。

通信雙方一般由兩個方面標識:地址和端口號(通常,一個IP地址和一個端口號常常被稱爲一個套接口)。根據地址可以尋址到主機,根據端口號則可以尋址到主機提供特定服務的進程,實際上,一個特定的端口號代表了一個提供特定服務的進程。

對於使用TCP傳輸協議通信方式來說,通信雙方需要給自己綁定一個唯一標識自己的套接口,以便建立連接;對於使用UDP傳輸協議,只需要服務器綁定一個標識自己的套接口就可以了,用戶則不需要綁定(在需要時,如調用connect時[注1],內核會自動分配一個本地地址和本地端口號)。綁定操作由系統調用bind()完成:

int bind( int sockfd, const struct sockaddr * my_addr, socklen_t my_addr_len)

第二個參數對於Ipv4來說,實際上需要填充的結構是struct sockaddr_in,前面已經介紹了該結構。這裏只想強調該結構的第一個域,它表明該套接口使用的通信協議,如AF_INET。聯繫socket系統調用的第一個參數,讀者可能會想到PF_INET與AF_INET究竟有什麼不同?實際上,原來的想法是每個通信域(如PF_INET)可能對應多個協議(如AF_INET),而事實上支持多個協議的通信域一直沒有實現。因此,在linux內核中,AF_***與PF_***被定義爲同一個常數,因此,在編程時可以不加區分地使用他們。

注1:在採用非面向連接通信方式時,也會用到connect()調用,不過與在面向連接中的connect()調用有本質的區別:在非面向連接通信中,connect調用只是先設置一下對方的地址,內核爲本地套接口記下對方的地址,然後採用send()來發送數據,這樣避免每次發送時都要提供相同的目的地址。其中的connect()調用不涉及握手過程;而在面向連接的通信方式中,connect()要完成一個嚴格的握手過程。

(3)請求建立連接(由TCP客戶發起)

對於採用面向連接的傳輸協議TCP實現通信來說,一個比較重要的步驟就是通信雙方建立連接(如果採用udp傳輸協議則不需要),由系統調用connect()完成:

int connect( int sockfd, const struct sockaddr * servaddr, socklen_t addrlen)

第一個參數爲本地調用socket後返回的描述符,第二個參數爲服務器的地址結構指針。connect()向指定的套接口請求建立連接。

注:與connect()相對應,在服務器端,通過系統調用listen(),指定服務器端的套接口爲監聽套接口,監聽每一個向服務器套接口發出的連接請求,並通過握手機制建立連接。內核爲listen()維護兩個隊列:已完成連接隊列和未完成連接隊列。

(4)接受連接請求(由TCP服務器端發起)

服務器端通過監聽套接口,爲所有連接請求建立了兩個隊列:已完成連接隊列和未完成連接隊列(每個監聽套接口都對應這樣兩個隊列,當然,一般服務器只有一個監聽套接口)。通過accept()調用,服務器將在監聽套接口的已連接隊列頭中,返回用於代表當前連接的套接口描述字。

int accept( int sockfd, struct sockaddr * cliaddr, socklen_t * addrlen)

第一個參數指明哪個監聽套接口,一般是由listen()系統調用指定的(由於每個監聽套接口都對應已連接和未連接兩個隊列,因此它的內部機制實質是通過sockfd指定在哪個已連接隊列頭中返回一個用於當前客戶的連接,如果相應的已連接隊列爲空,accept進入睡眠)。第二個參數指明客戶的地址結構,如果對客戶的身份不感興趣,可指定其爲空。

注:對於採用TCP傳輸協議進行通信的服務器和客戶機來說,一定要經過客戶請求建立連接,服務器接受連接請求這一過程;而對採用UDP傳輸協議的通信雙方則不需要這一步驟。

(5)通信

客戶機可以通過套接口接收服務器傳過來的數據,也可以通過套接口向服務器發送數據。前面所有的準備工作(創建套接口、綁定等操作)都是爲這一步驟準備的。

常用的從套接口中接收數據的調用有:recv、recvfrom、recvmsg等,常用的向套接口中發送數據的調用有send、sendto、sendmsg等。

int recv(int s, void *
        buf, size_t 
        len, int 
        flags)
int recvfrom(int s,  void *
        buf,  size_t 
        len, int 
        flags, struct sockaddr *
        from, socklen_t *
        fromlen)
int recvmsg(int s, struct msghdr *
        msg, int 
        flags)
int send(int s,const void *
        msg, size_t 
        len, int 
        flags)
int sendto(int s, const void *
        msg, size_t 
        len, int 
        flags const struct sockaddr *
        to, socklen_t 
        tolen)
int sendmsg(int s, const struct msghdr *
        msg, int 
        flags)
      

這裏不再對這些調用作具體的說明,只想強調一下,recvfrom()以及recvmsg()可用於面向連接的套接口,也可用於面向非連接的套接口;而recv()一般用於面向連接的套接口。另外,在調用了connect()之後,就應給調用send()而不是sendto()了,因爲調用了connect之後,目標就已經確定了。

前面講到,socket()系統調用返回套接口描述字,實際上它是一個文件描述符。所以,可以對套接口進行通常的讀寫操作,即使用read()及write()方法。在實際應用中,由於面向連接的通信(採用TCP傳輸協議)是可靠的,同時又保證字節流原有的順序,所以更適合用read及write方法。而非面向連接的通信(採用UDP傳輸協議)是不可靠的,字節流也不一定保持原有的順序,所以一般不宜用read及write方法。

(6)通信的最後一步是關閉套接口

由close()來完成此項功能,它唯一的參數是套接口描述字,不再贅述。

4、典型調用代碼:

到處可以發現基於套接口的客戶機及服務器程序,這裏不再給出完整的範例代碼,只是給出它們的典型調用代碼,並給出簡要說明。

(1)典型的TCP服務器代碼:

... ...
int listen_fd, connect_fd;
struct sockaddr_in serv_addr, client_addr;
... ...
listen_fd = socket ( PF_INET, SOCK_STREAM, 0 );

/* 創建網際Ipv4域的(由PF_INET指定)面向連接的(由SOCK_STREAM指定,
如果創建非面向連接的套接口則指定爲SOCK_DGRAM)
的套接口。第三個參數0表示由內核確定缺省的傳輸協議,
對於本例,由於創建的是可靠的面向連接的基於流的套接口,
內核將選擇TCP作爲本套接口的傳輸協議) */

bzero( &serv_addr, sizeof(serv_addr) );
serv_addr.sin_family = AF_INET ;  /* 指明通信協議族 */
serv_addr.sin_port = htons( 49152 ) ;       /* 分配端口號 */
inet_pton(AF_INET, " 192.168.0.11", &serv_addr.sin_sddr) ;
/* 分配地址,把點分十進制IPv4地址轉化爲32位二進制Ipv4地址。 */
bind( listen_fd, (struct sockaddr*) serv_addr, sizeof ( struct sockaddr_in )) ; 
/* 實現綁定操作 */
listen( listen_fd, max_num) ; 
/* 套接口進入偵聽狀態,max_num規定了內核爲此套接口排隊的最大連接個數 */
for( ; ; ) {
... ...
connect_fd = accept( listen_fd, (struct sockaddr*)client_addr, &len ) ; /* 獲得連接fd. */
... ...					/* 發送和接收數據 */
}

注:端口號的分配是有一些慣例的,不同的端口號對應不同的服務或進程。比如一般都把端口號21分配給FTP服務器的TCP/IP實現。端口號一般分爲3段,0-1023(受限的衆所周知的端口,由分配數值的權威機構IANA管理),1024-49151(可以從IANA那裏申請註冊的端口),49152-65535(臨時端口,這就是爲什麼代碼中的端口號爲49152)。

對於多字節整數在內存中有兩種存儲方式:一種是低字節在前,高字節在後,這樣的存儲順序被稱爲低端字節序(little-endian);高字節在前,低字節在後的存儲順序則被稱爲高端字節序(big-endian)。網絡協議在處理多字節整數時,採用的是高端字節序,而不同的主機可能採用不同的字節序。因此在編程時一定要考慮主機字節序與網絡字節序間的相互轉換。這就是程序中使用htons函數的原因,它返回網絡字節序的整數。

(2)典型的TCP客戶代碼:

... ...
int socket_fd;
struct sockaddr_in serv_addr ;
... ...
socket_fd = socket ( PF_INET, SOCK_STREAM, 0 );
bzero( &serv_addr, sizeof(serv_addr) );
serv_addr.sin_family = AF_INET ;  /* 指明通信協議族 */
serv_addr.sin_port = htons( 49152 ) ;       /* 分配端口號 */
inet_pton(AF_INET, " 192.168.0.11", &serv_addr.sin_sddr) ;
/* 分配地址,把點分十進制IPv4地址轉化爲32位二進制Ipv4地址。 */
connect( socket_fd, (struct sockaddr*)serv_addr, sizeof( serv_addr ) ) ; /* 向服務器發起連接請求 */
... ...							/* 發送和接收數據 */
... ...

對比兩段代碼可以看出,許多調用是服務器或客戶機所特有的。另外,對於非面向連接的傳輸協議,代碼還有簡單些,沒有連接的發起請求和接收請求部分。

5、網絡編程中的其他重要概念

下面列出了網絡編程中的其他重要概念,基本上都是給出這些概念能夠實現的功能,讀者在編程過程中如果需要這些功能,可查閱相關概念。

(1)、I/O複用的概念

I/O複用提供一種能力,這種能力使得當一個I/O條件滿足時,進程能夠及時得到這個信息。I/O複用一般應用在進程需要處理多個描述字的場合。它的一個優勢在於,進程不是阻塞在真正的I/O調用上,而是阻塞在select()調用上,select()可以同時處理多個描述字,如果它所處理的所有描述字的I/O都沒有處於準備好的狀態,那麼將阻塞;如果有一個或多個描述字I/O處於準備好狀態,則select()不阻塞,同時會根據準備好的特定描述字採取相應的I/O操作。

(2)、Unix通信域

前面主要介紹的是PF_INET通信域,實現網際間的進程間通信。基於Unix通信域(調用socket時指定通信域爲PF_LOCAL即可)的套接口可以實現單機之間的進程間通信。採用Unix通信域套接口有幾個好處:Unix通信域套接口通常是TCP套接口速度的兩倍;另一個好處是,通過Unix通信域套接口可以實現在進程間傳遞描述字。所有可用描述字描述的對象,如文件、管道、有名管道及套接口等,在我們以某種方式得到該對象的描述字後,都可以通過基於Unix域的套接口來實現對描述字的傳遞。接收進程收到的描述字值不一定與發送進程傳遞的值一致(描述字是特定於進程的),但是特們指向內核文件表中相同的項。

(3)、原始套接口

原始套接口提供一般套接口所不提供的功能: 

  • 原始套接口可以讀寫一些用於控制的控制協議分組,如ICMPv4等,進而可實現一些特殊功能。
  • 原始套接口可以讀寫特殊的IPv4數據包。內核一般只處理幾個特定協議字段的數據包,那麼一些需要不同協議字段的數據包就需要通過原始套接口對其進行讀寫;
  • 通過原始套接口可以構造自己的Ipv4頭部,也是比較有意思的一點。

創建原始套接口需要root權限。

(4)、對數據鏈路層的訪問

對數據鏈路層的訪問,使得用戶可以偵聽本地電纜上的所有分組,而不需要使用任何特殊的硬件設備,在linux下讀取數據鏈路層分組需要創建SOCK_PACKET類型的套接口,並需要有root權限。

(5)、帶外數據(out-of-band data)

如果有一些重要信息要立刻通過套接口發送(不經過排隊),請查閱與帶外數據相關的文獻。

(6)、多播

linux內核支持多播,但是在默認狀態下,多數linux系統都關閉了對多播的支持。因此,爲了實現多播,可能需要重新配置並編譯內核。具體請參考[4]及[2]。

結論:linux套接口編程的內容可以說是極大豐富,同時它涉及到許多的網絡背景知識,有興趣的讀者可在[2]中找到比較系統而全面的介紹。

至此,本專題系列(linux環境進程間通信)全部結束了。實際上,進程間通信的一般意義通常指的是消息隊列、信號燈和共享內存,可以是posix的,也可以是SYS v的。本系列同時介紹了管道、有名管道、信號以及套接口等,是更爲一般意義上的進程間通信機制。


參考資料

  • Understanding the Linux Kernel, 2nd Edition, By Daniel P. Bovet, Marco Cesati , 對各主題闡述得重點突出,脈絡清晰。網絡部分分析集中在TCP/IP協議棧的數據連路層、網絡層以及傳輸層。

  • UNIX網絡編程第一卷:套接口API和X/Open傳輸接口API,作者:W.Richard Stevens,譯者:楊繼張,清華大學出版社。不僅對套接口網絡編程有極好的描述,而且極爲詳盡的闡述了相關的網絡背景知識。不論是入門還是深入研究,都是不可多得的好資料。

  • Linux內核源代碼情景分析(下),毛德操、胡希明著,浙江大學出版社,給出了unix域套接口部分的內核代碼分析。

  • GNU/Linux編程指南,入門、應用、精通,第二版,Kurt Wall等著,張輝譯

                     
                    IBM    developerWorks 中國       http://www.ibm.com/developerworks/cn/linux/l-ipc/part6/index.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章