一個套介面可以看作是進程間通信的端點(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的。本系列同時介紹了管道、有名管道、信號以及套介面等,是更為一般意義上的進程間通信機制。
沒有留言:
張貼留言