2016.7.4
今天晚上對項目頂層文件(daemon)進行了分析,對其中的TCP連接進行具體的代碼級分析。
1、需求分析
首先得知道我們這里為什么要用TCP連接,我們的整個測試系統是由上位機作為客戶端,發送測試文件,測試命令給我們測試程序上,那么我們測試程序相當于作為一個服務器端程序,一直阻塞等待數據的到來,如果弄清楚了這個就比較好理解后面的TCP端為什么用的服務器監聽函數。
對于整個測試系統下發的分為兩個部分:測試查詢命令和測試腳本參數文件
對于查詢命令數據量下,采用不可靠但簡單的UDP服務
對于測試腳本參數文件,其中包含我們測試的各種參數,需要可靠的傳輸,采用TCP服務
2、TCP連接原理分析
TCP通信是通過網絡完成服務器與客戶端的通信,支持可靠傳輸。TCP/IP協議存在于OS中,網絡服務通過OS提供,在OS中增加支持TCP/IP的系統調用——Berkeley套接字,如Socket,Connect,Send,Recv等
TCP/IP協議族包括運輸層、網絡層、鏈路層,而socket所在位置如圖,Socket是應用層與TCP/IP協議族通信的中間軟件抽象層。
形象的比喻就是:socket就是一個口袋,一個洞,用戶可以通過這個洞直接與網絡協議棧打交道,完成網絡通信,基于它就是因為抽象接口封裝,簡單。
簡單介紹下socket的數據結構:
1、 socket套接字
socket起源于Unix,而Unix/Linux基本哲學之一就是“一切皆文件”,都可以用“打開open –> 讀寫write/read –>關閉close”模式來操作。Socket就是該模式的一個實現,socket即是一種特殊的文件,一些socket函數就是對其進行的操作(讀/寫IO、打開、關閉).
說白了Socket是應用層與TCP/IP協議族通信的中間軟件抽象層,它是一組接口。在設計模式中,Socket其實就是一個門面模式,它把復雜的TCP/IP協議族隱藏在Socket接口后面,對用戶來說,一組簡單的接口就是全部,讓Socket去組織數據,以符合指定的協議。
注意:其實socket也沒有層的概念,它只是一個facade設計模式的應用,讓編程變的更簡單。是一個軟件抽象層。在網絡編程中,我們大量用的都是通過socket實現的。
2、套接字描述符
其實就是一個整數,我們最熟悉的句柄是0、1、2三個,0是標準輸入,1是標準輸出,2是標準錯誤輸出。0、1、2是整數表示的,對應的FILE *結構的表示就是stdin、stdout、stderr
套接字API最初是作為UNIX操作系統的一部分而開發的,所以套接字API與系統的其他I/O設備集成在一起。特別是,當應用程序要為因特網通信而創建一個套接字(socket)時,操作系統就返回一個小整數作為描述符(descriptor)來標識這個套接字。然后,應用程序以該描述符作為傳遞參數,通過調用函數來完成某種操作(例如通過網絡傳送數據或接收輸入的數據)。
在許多操作系統中,套接字描述符和其他I/O描述符是集成在一起的,所以應用程序可以對文件進行套接字I/O或I/O讀/寫操作。
當應用程序要創建一個套接字時,操作系統就返回一個小整數作為描述符,應用程序則使用這個描述符來引用該套接字需要I/O請求的應用程序請求操作系統打開一個文件。操作系統就創建一個文件描述符提供給應用程序訪問文件。從應用程序的角度看,文件描述符是一個整數,應用程序可以用它來讀寫文件。下圖顯示,操作系統如何把文件描述符實現為一個指針數組,這些指針指向內部數據結構。
對于每個程序系統都有一張單獨的表。精確地講,系統為每個運行的進程維護一張單獨的文件描述符表。當進程打開一個文件時,系統把一個指向此文件內部數據結構的指針寫入文件描述符表,并把該表的索引值返回給調用者 。應用程序只需記住這個描述符,并在以后操作該文件時使用它。操作系統把該描述符作為索引訪問進程描述符表,通過指針找到保存該文件所有的信息的數據結構。
針對套接字的系統數據結構:
1)、套接字API里有個函數socket,它就是用來創建一個套接字。套接字設計的總體思路是,單個系統調用就可以創建任何套接字,因為套接字是相當籠統的。一旦套接字創建后,應用程序還需要調用其他函數來指定具體細節。例如調用socket將創建一個新的描述符條目:
2)、雖然套接字的內部數據結構包含很多字段,但是系統創建套接字后,大多數字字段沒有填寫。應用程序創建套接字后在該套接字可以使用之前,必須調用其他的過程來填充這些字段。
3、文件描述符和文件指針的區別:
文件描述符:在linux系統中打開文件就會獲得文件描述符,它是個很小的正整數。每個進程在PCB(Process Control Block)中保存著一份文件描述符表,文件描述符就是這個表的索引,每個表項都有一個指向已打開文件的指針。
文件指針:C語言中使用文件指針做為I/O的句柄。文件指針指向進程用戶區中的一個被稱為FILE結構的數據結構。FILE結構包括一個緩沖區和一個文件描述符。而文件描述符是文件描述符表的一個索引,因此從某種意義上說文件指針就是句柄的句柄(在Windows系統上,文件描述符被稱作文件句柄)。
下面講述下利用socket通信的服務器端和客戶端的基本結構框圖:
服務器端先初始化Socket,然后與端口綁定(bind),對端口進行監聽(listen),調用accept阻塞,等待客戶端連接。在這時如果有個客戶端初始化一個Socket,然后連接服務器(connect),如果連接成功,這時客戶端與服務器端的連接就建立了。客戶端發送數據請求,服務器端接收請求并處理請求,然后把回應數據發送給客戶端,客戶端讀取數據,最后關閉連接,一次交互結束。
3、TCP連接代碼分析(需要說明的是:這里以項目的daemon.c代碼為例 )
首先給出定義的一些buf和一些文件數據變量:
按照上述紅色框框的程序流程圖,一步步分析。
(1)定義socket套接字
socket函數具體參數及返回值分析如下:
socket()函數
int socket(int protofamily, int type, int protocol);//返回sockfd
sockfd是描述符。
socket函數對應于普通文件的打開操作。普通文件的打開操作返回一個文件描述字,而socket()用于創建一個socket描述符(socket descriptor),它唯一標識一個socket。這個socket描述字跟文件描述字一樣,后續的操作都有用到它,把它作為參數,通過它來進行一些讀寫操作。
正如可以給fopen的傳入不同參數值,以打開不同的文件。創建socket的時候,也可以指定不同的參數創建不同的socket描述符,socket函數的三個參數分別為:
- protofamily:即協議域,又稱為協議族(family)。常用的協議族有,AF_INET(IPV4)、AF_INET6(IPV6)、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()時系統會自動隨機分配一個端口。
給出代碼中的實例
返回的即是套接字的名稱,套接字的數據結構,作為后面函數的參數。
(2)bind()函數綁定定義socket的IP地址和端口號
bind函數具體參數及返回值分析如下:
bind()函數
正如上面所說bind()函數把一個地址族中的特定地址賦給socket。例如對應AF_INET、AF_INET6就是把一個ipv4或ipv6地址和端口號組合賦給socket。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函數的三個參數分別為:
- sockfd:即socket描述字,它是通過socket()函數創建了,唯一標識一個socket。bind()函數就是將給這個描述字綁定一個名字。
- addr:一個const struct sockaddr *指針,指向要綁定給sockfd的協議地址。這個地址結構根據地址創建socket時的地址協議族的不同而不同,如ipv4對應的是:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
ipv6對應的是:
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};
Unix域對應的是:
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
addrlen:對應的是地址的長度。
通常服務器在啟動的時候都會綁定一個眾所周知的地址(如ip地址+端口號),用于提供服務,客戶就可以通過它來接連服務器;而客戶端就不用指定,有系統自動分配一個端口號和自身的ip地址組合。這就是為什么通常服務器端在listen之前會調用bind(),而客戶端就不會調用,而是在connect()時由系統隨機生成一個。
給出代碼中實例:
從代碼上分析可以知道,給三個socket通過bind綁定了不同的地址和端口號,需要注意的是還有assert()函數,該函數是檢測異常,終止并打印相應信息的函數,具體函數說明如下:
assert宏的原型定義在<assert.h>中,其作用是如果它的條件返回錯誤,則終止程序執行,原型定義:
#include <assert.h>
void assert( int expression );
assert的作用是現計算表達式 expression ,如果其值為假(即為0),那么它先向stderr打印一條出錯信息,然后通過調用 abort 來終止程序運行。請看下面的程序清單badptr.c:
#include <stdio.h>
#include <assert.h>
#include <stdlib.h>
int main( void )
{
FILE *fp;
fp = fopen( "test.txt", "w" );//以可寫的方式打開一個文件,如果不存在就創建一個同名文件
assert( fp ); //所以這里不會出錯
fclose( fp );
fp = fopen( "noexitfile.txt", "r" );//以只讀的方式打開一個文件,如果不存在就打開文件失敗
assert( fp ); //所以這里出錯
fclose( fp ); //程序永遠都執行不到這里來
return0;
}
[root@localhost error_process]# gcc badptr.c
[root@localhost error_process]# ./a.out
a.out: badptr.c:14: main: Assertion `fp' failed.
(3)listen監聽函數,監聽端口是否有TCP握手請求
listen()、connect()函數
如果作為一個服務器,在調用socket()、bind()之后就會調用listen()來監聽這個socket,如果客戶端這時調用connect()發出連接請求,服務器端就會接收到這個請求。
int listen(int sockfd, int backlog);
int connect(int sockfd, conststruct sockaddr *addr, socklen_t addrlen);
listen函數的第一個參數即為要監聽的socket描述字,第二個參數為相應socket可以排隊的最大連接個數。socket()函數創建的socket默認是一個主動類型的,listen函數將socket變為被動類型的,等待客戶的連接請求。
connect函數的第一個參數即為客戶端的socket描述字,第二參數為服務器的socket地址,第三個參數為socket地址的長度。客戶端通過調用connect函數來建立與TCP服務器的連接。
listen函數較為簡單,而且僅僅針對TCP連接的服務器端,實例代碼如下:
從上面看出,MAXPENDING表示最大的連接數,定義的是MAXPENDING=5。
(4)accpet接收函數,接收客戶端的tcp連接
accept()函數
TCP服務器端依次調用socket()、bind()、listen()之后,就會監聽指定的socket地址了。TCP客戶端依次調用socket()、connect()之后就向TCP服務器發送了一個連接請求。TCP服務器監聽到這個請求之后,就會調用accept()函數取接收請求,這樣連接就建立好了。之后就可以開始網絡I/O操作了,即類同于普通文件的讀寫I/O操作。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); //返回連接connect_fd
參數sockfd
參數sockfd就是上面解釋中的監聽套接字,這個套接字用來監聽一個端口,當有一個客戶與服務器連接時,它使用這個一個端口號,而此時這個端口號正與這個套接字關聯。當然客戶不知道套接字這些細節,它只知道一個地址和一個端口號。
參數addr
這是一個結果參數,它用來接受一個返回值,這返回值指定客戶端的地址,當然這個地址是通過某個地址結構來描述的,用戶應該知道這一個什么樣的地址結構。如果對客戶的地址不感興趣,那么可以把這個值設置為NULL。
參數len
如同大家所認為的,它也是結果的參數,用來接受上述addr的結構的大小的,它指明addr結構所占有的字節個數。同樣的,它也可以被設置為NULL。
如果accept成功返回,則服務器與客戶已經正確建立連接了,此時服務器通過accept返回的套接字來完成與客戶的通信。
注意:
accept默認會阻塞進程,直到有一個客戶連接建立后返回,它返回的是一個新可用的套接字,這個套接字是連接套接字。此時我們需要區分兩種套接字
監聽套接字: 監聽套接字正如accept的參數sockfd,它是監聽套接字,在調用listen函數之后,是服務器開始調用socket()函數生成的,稱為監聽socket描述字(監聽套接字)
連接套接字:一個套接字會從主動連接的套接字變身為一個監聽套接字;而accept函數返回的是已連接socket描述字(一個連接套接字),它代表著一個網絡已經存在的點點連接。
一個服務器通常通常僅僅只創建一個監聽socket描述字,它在該服務器的生命周期內一直存在。內核為每個由服務器進程接受的客戶連接創建了一個已連接socket描述字,當服務器完成了對某個客戶的服務,相應的已連接socket描述字就被關閉。
自然要問的是:為什么要有兩種套接字?原因很簡單,如果使用一個描述字的話,那么它的功能太多,使得使用很不直觀,同時在內核確實產生了一個這樣的新的描述字。
連接套接字socketfd_new 并沒有占用新的端口與客戶端通信,依然使用的是與監聽套接字socketfd一樣的端口號
client_sd即使返回的新的套接字,連接套接字,通過連接套接字進行數據的傳輸。
但是上述這種方式存在i一個很大的問題!!!!——即如果沒有tcp請求會一直阻塞,而且用戶無法得知,效率極低。
為了解決上述的辦法,引進了套接字監控宏函數。
直接給出實例是如何調用的:
上述用的4個操作宏到底有什么用,如何定義的呢??、下面進行詳細的介紹:
FD_ZERO,FD_ISSET這些都是套節字結合操作宏 ,看看MSDN上的select函數, 這是在select io模型中的核心,用來管理套節字IO的,避免出現無辜鎖定.
int select(int nfds,fd_set FAR *readfds, fd_set FAR *writefds, fd_set FAR *exceptfds, const struct timeval FAR *timeout);
第一個參數在windows下可以忽略,但在linux下必須設為最大文件描述符加1;二是結構fd_set在兩個系統里定義不一樣)。
所以代碼中對這個位置也有特別的注意,為什么對三個套接字取了最大值:
max_sd表示正在被使用的socket類
最后的是超時標準,select是阻塞操作當然要設置超時事件. 接著的三個類型為fd_set的參數分別是用于檢查套節字的可讀性,可寫性,和列外數據性質.
我舉個例子 :比如recv(), 在沒有數據到來調用它的時候,你的線程將被阻塞如果數據一直不來,你的線程就要阻塞很久.這樣顯然不好.所以采用select來查看套節字是否可讀(也就是是否有數據讀了)
步驟如下
socket s;
.....
fd_set set;
while(1)
{
FD_ZERO(&set);//將你的套節字集合清空
FD_SET(s, &set);//加入你感興趣的套節字到集合,這里是一個讀數據的套節字s
select(0,&set,NULL,NULL,NULL);//檢查套節字是否可讀,
//很多情況下就是是否有數據(注意,只是說很多情況)
//這里select是否出錯沒有寫
if(FD_ISSET(s, &set) //檢查s是否在這個集合里面,
{ //select將更新這個集合,把其中不可讀的套節字去掉
//只保留符合條件的套節字在這個集合里面
recv(s,...);
}
//do something here
}
也就是說,我們得檢測我們選定的套接字中此時可不可讀,如果不可讀我們就將套接字刷新掉,不比一直進行阻塞等待,等中心建立新的套接字,在進行下一輪,提高之前說的阻塞效率。
(5)recv()函數接收客戶端傳送的數據
先看下recv函數的原型:
int recv( SOCKET s, char FAR *buf, int len, int flags);
不論是客戶還是服務器應用程序都用recv函數從TCP連接的另一端接收數據。該函數的第一個參數指定接收端套接字描述符;
第二個參數指明一個緩沖區,該緩沖區用來存放recv函數接收到的數據;
第三個參數指明buf的長度;
第四個參數一般置0。
這里只描述同步Socket的recv函數的執行流程。當應用程序調用recv函數時,
(1)recv先等待s的發送緩沖中的數據被協議傳送完畢,如果協議在傳送s的發送緩沖中的數據時出現網絡錯誤,那么recv函數返回SOCKET_ERROR,
(2)如果s的發送緩沖中沒有數據或者數據被協議成功發送完畢后,recv先檢查套接字s的接收緩沖區,如果s接收緩沖區中沒有數據或者協議正在接收數據,那么recv就一直等待,直到協議把數據接收完畢。當協議把數據接收完畢,recv函數就把s的接收緩沖中的數據copy到buf中(注意協議接收到的數據可能大于buf的長度,所以 在這種情況下要調用幾次recv函數才能把s的接收緩沖中的數據copy完。recv函數僅僅是copy數據,真正的接收數據是協議來完成的),
recv函數返回其實際copy的字節數。如果recv在copy時出錯,那么它返回SOCKET_ERROR;如果recv函數在等待協議接收數據時網絡中斷了,那么它返回0。
給出代碼中的實例:
上面說了我們可能 一次copy沒有說有的數據,這時候我們需要做的就是多次調用recv函數,很明顯,這里我們不是簡單的用這一句話完成,需要對數據流進行控制輸出,控制代碼如下:
代碼也不難,分析來看,就是一個while循環,完成這個buffer(4KB)緩存池的寫滿,通過buffer_len和recv返回copy字節數ret進行控制,用buffer_len-ret當等于0的時候也就是填滿了,可以跳出循環了。
(6)close()函數關閉連接套接字
當數據傳輸完成后,關閉對應的連接套接字,則緩存中不會有其他的數據接收,避免下次調用的時候復寫。
close()函數
在服務器與客戶端建立連接之后,會進行一些讀寫操作,完成了讀寫操作就要關閉相應的socket描述字,好比操作完打開的文件要調用fclose關閉打開的文件。
#include <unistd.h>
int close(int fd);
close一個TCP socket的缺省行為時把該socket標記為以關閉,然后立即返回到調用進程。該描述字不能再由調用進程使用,也就是說不能再作為read或write的第一個參數。
注意:close操作只是使相應socket描述字的引用計數-1,只有當引用計數為0的時候,才會觸發TCP客戶端向服務器發送終止連接請求。
代碼實例調用如下:
很簡單,沒有什么好分析的。
完成以上的6個動作,最后即將客戶端發送的4KB數據放到buf中,之后的程序就是根據這4KB的腳本數據,進行后面的測試工具。