淺談Linux網絡編程的基本內容

    今天只是想淺談一下對於Linux網絡編程中一些基本問題的理解。我們知道互聯網通信都是基於TCP/IP協議簇的,裏面從一開始設計就保證了基本的通信安全和效率問題。 顧名思義的解釋:IP(Internet協議)和TCP(傳輸控制協議),合起來叫TCP/IP。

    IP協議爲接入網絡中的每臺計算機分配了一個獨一無二的地址,並負責在傳輸過程中尋找到目的計算機。TCP協議則負責保證傳輸的可靠性:一旦傳輸中發現問題,該協議就會發出信號要求重新傳輸相關的數據直到所有數據安全正確地傳輸到目的地爲止。爲了驗證TCP/IP在超遠距離傳輸上的可靠性,曾經對於TCP/IP貢獻比較大的兩個人瑟夫和卡恩爲了驗證傳輸的可靠性還進行了一個著名的試驗。他們設計了一個長達9.4萬公里的路徑,使數據包先後通過點對點衛星網絡、陸地電纜和衛星間網絡,並貫串了歐洲和美國的幾乎所有電腦系統。最後,數據包完整地回到了實驗室。1974年,美國國防部(開始使用的是阿帕網)決定無條件公佈TCP/IP的核心技術,網絡發展高潮因此迅速到來。

   下面我們說說數據是怎麼在網路中傳送的:

    我們知道現在的互聯網中使用的TCP/IP協議是基於OSI(開放系統互聯)的七層參考模型的,從上到下分別爲 應用層、 表示層、 會話層 、傳輸層 、網絡層 、數據鏈路層和物理層。其中數據鏈路層又可是分爲兩個子層分別爲邏輯鏈路控制層(Logic Link Control,LLC )和介質訪問控制層((Media Access Control,MAC )也就是平常說的MAC層。LLC對兩個節點中的鏈路進行初始化,防止連接中斷,保持可靠的通信。MAC層用來檢驗包含在每個楨中的地址信息。

    現在我們討論一下數據在同一個網段內的傳遞情況,不同網段姑且先不說。假設有兩臺電腦分別命名爲A和B,A需要相B發送數據的話,A主機首先把目標設備B的IP地址與自己的子網掩碼進行“與”操作,以判斷目標設備與自己是否位於同一網段內。如果目標設備在同一網段內,並且A沒有獲得與目標設備B的IP地址相對應的MAC地址信息,則源設備(A)以第二層廣播的形式(目標MAC地址爲全1)發送ARP請求報文,在ARP請求報文中包含了源設備(A)與目標設備(B)的IP地址。同一網段中的所有其他設備都可以收到並分析這個ARP請求報文,如果某設備發現報文中的目標IP地址與自己的IP地址相同,則它向源設備發回ARP響應報文,通過該報文使源設備獲得目標設備的MAC地址信息。爲了減少廣播量,網絡設備通過ARP表在緩存中保存IP與MAC地址的映射信息。在一次 ARP的請求與響應過程中,通信雙方都把對方的MAC地址與IP地址的對應關係保存在各自的ARP表中,以在後續的通信中使用。ARP表使用老化機制,刪除在一段時間內沒有使用過的IP與MAC地址的映射關係。網上找了一張拓撲結構的圖:

 

    如果中間要經過交換機的話,根據交換機的原理,它是直接將數據發送到相應端口,那麼就必須保有一個數據庫,包含所有端口所連網卡的MAC地址。它通過分析Ethernet包的包頭信息(其中包含不原MAC地址,目標MAC地址,信息的長度等信息),取得目標B的MAC地址後,查找交換機中存儲的地址對照表,(MAC地址對應的端口),確認具有此MAC地址的網卡連接在哪個端口上,然後將數據包發送到這個對應的端口,也就相應的發送到目標主機B上。這樣一來,即使某臺主機盜用了這個IP地址,但由於他沒有這個MAC地址,因此也不會收到數據包。

   當我們知道數據在網絡鏈路中的傳遞後,現在我們可以看看TCP/IP的模型了:

     TCP/IP協議同ISO/OSI模型一樣,也可以安排成棧形式。但這個棧不同於ISO/OSI版本,比ISO/OSI棧少,所以又稱之爲短棧。另外,需要知道的是:TCP/IP協議棧只是許多支持ISO/OSI分層模型協議棧的一種,是一個具體的協議棧。 

     對於TCP/IP協議棧劃分,大部分描述都假定它佔據了協議結構的4到5個功能層。基於4層的TCP/IP協議棧最具說服力的是:這一觀點是由TCP/IP原始標準的創立者——美國國防部提出的,它與ISO/OSI參考模型的對應關係如下圖:

     關於4層中 每一層在幹什麼就不詳細說了,我也記不住,這種問題搜索一下直接明瞭,就不廢話了。

     但是話說回來對於進行網絡編程的人必須清楚這一點:當用戶啓動了某個應用層服務之後,該應用要首先建立一個到某特定機器的連接,隨後告訴目的機器本次連接的操作意圖,並控制整個操作過程。在源計算機系統中,應用層將要發送的信息交給服務層的TCP(或UDP),TCP通過增加TCP協議所規定的報頭來確保信息正確地傳輸到目的地。然後,TCP把包括目的地址在內的報頭和用戶數據交給網絡互連層的IP,IP負責爲信息選擇路由。由於 TCP和IP處理與網絡有關的細節,所以應用層協議可以將一個網絡連接看成一個簡單的字節流,而不必描述與通信有關的任何細節。

下面是TCP/IP每層對應的一些我們熟知的服務:

 

    再此我們熟悉了TCP/IP這種網路協議後,我們如果要搞清楚網絡編程是怎麼進行的就必須要知道在網際間進程是如何通信,我們從上面的內容中可以瞭解到計算機通信,或者說是網際間的進程通信其實都是基於TCP/IP的,而socket正是實現了這種協議,我們知道IP可以唯一表示一臺主機在網路中的地址(有時需要MAC),然而TCP/IP提供了一組協議簇,再加上我們的一個開放的通道就可以直接實現通信了,這個通道其實就是我們平時經常說的端口,對於計算機的端口,其實說白了就是一中抽象的軟件數據結構,幷包含自己的I/O緩衝區,可以當作數據的收發接口。對於計算機本身,端口的值是一個整形數據,其大小就不言而喻了。從0-65535,中區分了三種端口,有些是系統預訂的,有些事某項服務默認的(比如阿帕奇的web端口默認爲80,ssh服務的端口爲22),我們一般建議在4000-10000(並不是準確數據,我自己就是那樣做的)之間的端口自己隨意選擇去開啓服務。有了端口的概念我們就可以用這樣的三元組(ip+協議+端口)去描述一個完整的網際間的進程通信了。

    下面我們說說Socket究竟是什麼?我們知道在Linux界有一句很出名的話就是“everything is file”,一切都是文件,在Linux中將一切設備都抽象爲文件,當然socket也不會例外,這就決定了socket本身就可以實現類似於一般文件的I/O操作,open(create)、write/read、close。

     socket本身就是一個描述文件,存在struct socket這個數據類型。我們經常見得就是三中套接字:

(1)流式套接字(SOCK_STREAM),它是一種面向連接的套接字,對應於TCP應用程序。

(2)數據報套接字(SOCK_DGRAM),它是一種無連接的套接字,對應於的UDP應用程序。

(3)原始套接字(SOCK_RAW),它是一種對原始網絡報文進行處理的套接字。

    流式套接字(SOCK_STREAM)和數據報套接字(SOCK_DGRAM)涵蓋了一般應用層次的TCP/IP應用。

原始套接字的創建使用與通用的套接字創建的方法是一致的,只是在套接字類型的選項上使用的是另一個SOCK_RAW。在使用socket函數進行函數創建完畢的時候,還要進行套接字數據中格式類型的指定,設置從套接字中可以接收到的網絡數據格式


下面就說說針對socket的一些操作函數吧。

     「一」:socket()

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

socket函數對應於普通文件的打開操作。普通文件的打開操作返回一個文件描述字,而socket()用於創建一個socket描述符(socket descriptor),它唯一標識一個socket。這個socket描述字跟文件描述字一樣,後續的操作都有用到它,把它作爲參數,通過它來進行一些讀寫操作。

正如可以給fopen的傳入不同參數值,以打開不同的文件。創建socket的時候,也可以指定不同的參數創建不同的socket描述符,socket函數的三個參數分別爲:

  • domain:即協議域,又稱爲協議族(family)。常用的協議族有,AF_INET、AF_INET6、AF_LOCAL(或稱AF_UNIX,Unix域socket)、AF_ROUTE等等。協議族決定了socket的地址類型,在通信中必須採用對應的地址,如AF_INET決定了要用ipv4地址(32位的)與端口號(16位的)的組合、AF_UNIX決定了要用一個絕對路徑名作爲地址。
  • type:指定socket類型。常用的socket類型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET等等(socket的類型有哪些?)。
  • protocol:故名思意,就是指定協議。常用的協議有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它們分別對應TCP傳輸協議、UDP傳輸協議、STCP傳輸協議、TIPC傳輸協議。。

注意:並不是上面的type和protocol可以隨意組合的,如SOCK_STREAM不可以跟IPPROTO_UDP組合。當protocol爲0時,會自動選擇type類型對應的默認協議。

當我們調用socket創建一個socket時,返回的socket描述字它存在於協議族(address family,AF_XXX)空間中,但沒有一個具體的地址。如果想要給它賦值一個地址,就必須調用bind()函數,否則就當調用connect()、listen()時系統會自動隨機分配一個端口。

     「二」:bind()

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

函數的三個參數分別爲:
  • sockfd:即socket描述字,它是通過socket()函數創建了,唯一標識一個socket。bind()函數就是將給這個描述字綁定一個名字。
  • addr:一個const struct sockaddr *指針,指向要綁定給sockfd的協議地址。這個地址結構根據地址創建socket時的地址協議族的不同而不同,
  • addrlen:對應的是地址的長度
    通常服務器在啓動的時候都會綁定一個衆所周知的地址(如ip地址+端口號),用於提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動分配一個端口號和自身的ip地址組合。這就是爲什麼通常服務器端在listen之前會調用bind(),而客戶端就不會調用,而是在connect()時由系統隨機生成一個。


  這裏插入一段,關於我們一般在TCP連接是建立的網絡地址結構,一般以struct sockaddr_in 結構來表示,在設定它的參數時,其中設置IP或是端口信息時可以通過系統提供的htonl,htons等等API完成,這裏注意的問題其實就是網絡字節與主機字節的區別:

     主機字節序就是我們平常說的大端和小端模式:不同的CPU有不同的字節序類型,這些字節序是指整數在內存中保存的順序,這個叫做主機序。引用標準的Big-Endian和Little-Endian的定義如下:

  a) Little-Endian就是低位字節排放在內存的低地址端,高位字節排放在內存的高地址端。

  b) Big-Endian就是高位字節排放在內存的低地址端,低位字節排放在內存的高地址端。

  網絡字節序:4個字節的32 bit值以下面的次序傳輸:首先是0~7bit,其次8~15bit,然後16~23bit,最後是24~31bit。這種傳輸次序稱作大端字節序。由於TCP/IP首部中所有的二進制整數在網絡中傳輸時都要求以這種次序,因此它又稱作網絡字節序。字節序,顧名思義字節的順序,就是大於一個字節類型的數據在內存中的存放順序,一個字節的數據沒有順序的問題了。

     「三」listen()、connect()

int listen(int sockfd, int backlog);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

 

listen函數的第一個參數即爲要監聽的socket描述字,第二個參數爲相應socket可以排隊的最大連接個數。socket()函數創建的socket默認是一個主動類型的,listen函數將socket變爲被動類型的,等待客戶的連接請求。關於listen()的第二個參數backlog,4.2BSD手冊對它的定義是:由爲處理連接(處於SYN_RCVD狀態)構成的隊列可能增長到的最大長度。有一點必須清楚,就是內核爲任意一個給定的監聽套接字維護兩個隊列:1.未完成連接隊列,這些套接字處於SYN_RCVD狀態。2.已完成連接隊列這裏的套接字處於ESTABLISHED狀態。

connect函數的第一個參數即爲客戶端的socket描述字,第二參數爲服務器的socket地址,第三個參數爲socket地址的長度。客戶端通過調用connect函數來建立與TCP服務器的連接。

      「四」:accept()

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

  TCP服務器端依次調用socket()、bind()、listen()之後,就會監聽指定的socket地址了。TCP客戶端依次調用socket()、connect()之後就想TCP服務器發送了一個連接請求。TCP服務器監聽到這個請求之後,就會調用accept()函數取接收請求,這樣連接就建立好了。之後就可以開始網絡I/O操作了,即類同於普通文件的讀寫I/O操作。

accept函數的第一個參數爲服務器的socket描述字,第二個參數爲指向struct sockaddr *的指針,用於返回客戶端的協議地址,第三個參數爲協議地址的長度。如果accpet成功,那麼其返回值是由內核自動生成的一個全新的描述字,代表與返回客戶的TCP連接。

注意:accept的第一個參數爲服務器的socket描述字,是服務器開始調用socket()函數生成的,稱爲監聽socket描述字;而accept函數返回的是已連接的socket描述字。一個服務器通常通常僅僅只創建一個監聽socket描述字,它在該服務器的生命週期內一直存在。內核爲每個由服務器進程接受的客戶連接創建了一個已連接socket描述字,當服務器完成了對某個客戶的服務,相應的已連接socket描述字就被關閉。

     「五」:對於socket的I/O操作函數

網絡I/O操作有下面幾組:

  • read()/write()
  • recv()/send()
  • recvmsg()/sendmsg()
  • recvfrom()/sendto()
   
 #include <unistd.h>

       ssize_t read(int fd, void *buf, size_t count);
       ssize_t write(int fd, const void *buf, size_t count);

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

       ssize_t send(int sockfd, const void *buf, size_t len, int flags);
       ssize_t recv(int sockfd, void *buf, size_t len, int flags);

       ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
                      const struct sockaddr *dest_addr, socklen_t addrlen);
       ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                        struct sockaddr *src_addr, socklen_t *addrlen);

       ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
       ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

      這幾個I/O函數就不意義介紹了,百度一大堆。

      「六」:close()

#include <unistd.h>
int close(int fd);

close一個TCP socket的缺省行爲時把該socket標記爲以關閉,然後立即返回到調用進程。該描述字不能再由調用進程使用,也就是說不能再作爲read或write的第一個參數。

注意:close操作只是使相應socket描述字的引用計數-1,只有當引用計數爲0的時候,纔會觸發TCP客戶端向服務器發送終止連接請求。


大概就是這樣,當然像TCP三次握手就發生在client端以connect函數連接server端時,與accpet函數進行了我們所謂的三次握手。
  • 客戶端向服務器發送一個SYN J
  • 服務器向客戶端響應一個SYN K,並對SYN J進行確認ACK J+1
  • 客戶端再想服務器發一個確認ACK K+1
對於TCP斷開連接的四次揮手也是通過close完成的:
  • 某個應用進程首先調用close主動關閉連接,這時TCP發送一個FIN M;
  • 另一端接收到FIN M之後,執行被動關閉,對這個FIN進行確認。它的接收也作爲文件結束符傳遞給應用進程,因爲FIN的接收意味着應用進程在相應的連接上再也接收不到額外數據;
  • 一段時間之後,接收到文件結束符的應用進程調用close關閉它的socket。這導致它的TCP也發送一個FIN N;
  • 接收到這個FIN的源發送端TCP對它進行確認。

      這裏還需要區分的就是阻塞套接字和非阻塞套接字在數據處理收發上是有一定區別的,每一個TCP套接口有一個發送緩衝區,可以用SO_SNDBUF套接口選項來改變這個緩衝區的大小。當應用進程調用 write時,內核從應用進程的緩衝區中拷貝所有數據到套接口的發送緩衝區。如果套接口的發送緩衝區容不下應用程序的所有數據(或是應用進程的緩衝區大於套接口發送緩衝區,或是套接口發送緩衝區還有其他數據),應用進程將被掛起(睡眠)。這裏假設套接口是阻塞的,這是通常的缺省設置。內核將不從write系統調用返回,直到應用進程緩衝區中的所有數據都拷貝到套接口發送緩衝區。因此從寫一個TCP套接口的write調用成功返回僅僅表示我們可以重新使用應用進程的緩衝區。它並不告訴我們對端的 TCP或應用進程已經接收了數據。
      TCP取套接口發送緩衝區的數據並把它發送給對端TCP,其過程基於TCP數據傳輸的所有規則。對端TCP必須確認收到的數據,只有收到對端的ACK,本端TCP才能刪除套接口發送緩衝區中已經確認的數據。TCP必須保留數據拷貝直到對端確認爲止。
1 輸入操作: read、readv、recv、recvfrom、recvmsg  。如果某個進程對一個阻塞的TCP套接口調用這些輸入函數之一,而且該套接口的接收緩衝區中沒有數據可讀,該進程將被投入睡眠,直到到達一些數據。既然 TCP是字節流協議,該進程的喚醒就是隻要到達一些數據:這些數據既可能是單個字節,也可以是一個完整的TCP分節中的數據。如果想等到某個固定數目的數據可讀爲止,可以調用readn函數,或者指定MSG_WAITALL標誌。既然UDP是數據報協議,如果一個阻塞的UDP套接口的接收緩衝區爲空,對它調用輸入函數的進程將被投入睡眠,直到到達一個UDP數據報。
      對於非阻塞的套接口,如果輸入操作不能被滿足(對於TCP套接口即至少有一個字節的數據可讀,對於UDP套接口即有一個完整的數據報可讀),相應調用將立即返回一個EWOULDBLOCK錯誤。
2 輸出操作:write、writev、send、sendto、sendmsg
      對於一個TCP套接口,內核將從應用進程的緩衝區到該套接口的發送緩衝區拷貝數據。對於阻塞的套接口,如果其發送緩衝區中沒有空間,進程將被投入睡眠,直到有空間爲止。
      對於一個非阻塞的TCP套接口,如果其發送緩衝區中根本沒有空間,輸出函數調用將立即返回一個EWOULDBLOCK錯誤。如果其發送緩衝區中有一些空間,返回值將是內核能夠拷貝到該緩衝區中的字節數。這個字節數也稱爲不足計數(short count)
      UDP套接口不才能在真正的發送緩衝區。內核只是拷貝應用進程數據並把它沿協議棧向下傳送,漸次冠以UDP頭部和IP頭部。因此對一個阻塞的UDP套接口,輸出函數調用將不會因爲與TCP套接口一樣的原因而阻塞,不過有可能會因其他的原因而阻塞。

       到這裏我們其實將基本的Linux網絡編程的基本內容簡單說了一下,但是這只是剛剛開始,我們需要的是當我們在真真正正寫一段服務代碼時,我們怎麼去處理這些連接,我們以什麼機制或是策略去設計我們的服務代碼的結構,這裏就牽扯到了各種各樣的網絡編程模型,阻塞I/O,非阻塞I/O,同步、異步處理,多路I/O複用等等一系列實現策略,在編程模型上選擇基本的迭代式服務器,還是簡單的併發處理,或是以epoll這種高效的輪轉機制代替基本的select I/O複用模型。或是以preforking這種預先建立進程/線程池的形式處理。等等一切的處理策略纔是我們需要在寫服務代碼時考慮的內容,這就是爲什麼我覺得Linux網絡編程是C中最難搞的原因了。

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