UNIX網路程式設計(一)一個簡易的TCP C/S模型(echo sever)
以下內容主要參考書籍《Linux C程式設計一站式學習》、《Unix網路程式設計》、《Unix高階環境程式設計》
首先要明確客戶端與伺服器要怎麼去實現通訊
下圖便是一個簡易的TCP C/S模型實現
知道模型之後,接下來只是一些與網路介面相關的API呼叫。
預備知識:
socket是什麼?
1.在TCP 在TCP/IP協議中,“IP地址+TCP或UDP埠號”唯一標識網路通訊中的一個程序,“IP地址+埠號”就稱為socket。
2.在TCP協議中,建立連線的兩個程序各自有一個socket來標識,那麼這兩個socket組成的socket pair就唯一標識一個連線。socket本身有“插座”的意思,因此用來描述網路連線的一對一關係。
3.TCP/IP協議最早在BSD UNIX上實現,為TCP/IP協議設計的應用層程式設計介面稱為socket介面
TCP4層模型與socket介面之間關係如下圖
具體相關的函式介面
1.socket()
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain,int type,int protocol);
各引數解釋如下
domain:
AF_INET 這是大多數用來產生socket的協議,使用TCP或UDP來傳輸,用IPv4的地址
AF_INET6 與上面類似,不過是來用IPv6的地址
AF_UNIX 本地協議,使用在Unix和Linux系統上,一般都是當客戶端和伺服器在同一臺及其上的時候使用
type:
SOCK_STREAM 這個協議是按照順序的、可靠的、資料完整的基於位元組流的連線。當將protocol設為0 時,則預設使用TCP來進行傳輸。
SOCK_DGRAM 這個協議是無連線的、固定長度的傳輸呼叫。該協議是不可靠的,當將protocol設為0時,則預設使用UDP來進行傳輸。
SOCK_SEQPACKET 這個協議是雙線路的、可靠的連線,傳送固定長度的資料包進行傳輸。必須把這個包完整的接受才能進行讀取。
SOCK_RAW 這個socket型別提供單一的網路訪問,這個socket型別使用ICMP公共協議。(ping、traceroute使用該協議)
SOCK_RDM 這個型別是很少使用的,在大部分的作業系統上沒有實現,它是提供給資料鏈路層使用,不保證資料包的順序
protocol:
0 預設協議
返回值:
成功返回一個新的檔案描述符
失敗返回-1,並設定errno
當程序呼叫socket函式後,系統會為其分配一個檔案描述符,供程序去在網路上進行通訊,對於檔案描述符,我們就有許多對應的系統呼叫可以使用了,比如read,write等等。
但對於伺服器來說,需要將本地地址繫結一個固定埠及相關的介面,來讓客戶端能夠方便的訪問到伺服器,這時就需要一個函式介面bind()。
2.bind()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
socket檔案描述符
addr:
構造出IP地址加埠號
addrlen:
sizeof(addr)長度
這個函式重點在於struct sockaddr這個結構體,由於歷史原因,當前並沒有void *這個向上相容的泛型指標,而所有的這些函式引數都使用struct sockaddr *型別表示,在使用的時候需要我們強轉一下。
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */
char sa_data[14]; /* 14 bytes of protocol address */
};
IPV4地址使用struct sockaddr_in
struct sockaddr_in {
__kernel_sa_family_t sin_family; /* Address family */ IPV4此值取值為AF_INET
__be16 sin_port; /* Port number */
struct in_addr sin_addr; /* Internet address */
/* Pad to size of `struct sockaddr'. */
unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
sizeof(unsigned short int) - sizeof(struct in_addr)];
};
/* Internet address. */
struct in_addr {
__be32 s_addr;
};
IPV6使用struct sockaddr_in6
struct sockaddr_in6 {
unsigned short int sin6_family; /* AF_INET6 */
__be16 sin6_port; /* Transport layer port # */
__be32 sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
__u32 sin6_scope_id; /* scope id (new in RFC2553) */
};
struct in6_addr {
union {
__u8 u6_addr8[16];
__be16 u6_addr16[8];
__be32 u6_addr32[4];
} in6_u;
#define s6_addr in6_u.u6_addr8
#define s6_addr16 in6_u.u6_addr16
#define s6_addr32 in6_u.u6_addr32
};
本地套接字使用struct sockaddr_un
#define UNIX_PATH_MAX 108
struct sockaddr_un {
__kernel_sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
返回值:
成功返回0,失敗返回-1, 並設定errno
當伺服器將本地地址和一套介面繫結完之後,則需要給伺服器設定為監聽狀態,使其有接受連線請求的能力,此時就需要函式介面listen()。
3.listen()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen()宣告sockfd處於監聽狀態,並且最多允許有backlog個客戶端處於連線待狀態,如果接收到更多的連線請求就忽略。
sockfd:
socket檔案描述符
backlog:
排隊建立3次握手佇列和剛剛建立3次握手佇列的連結數和
檢視系統預設backlog(128)
cat /proc/sys/net/ipv4/tcp_max_syn_backlog
當設定好監聽狀態後,伺服器呼叫accept()接受連線,如果伺服器沒有接受到客戶端的請求,就會處於阻塞狀態,直到有客戶端連線上來為止。
4.accept()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
socket檔案描述符
addr:
傳出引數,返回連結客戶端地址資訊,含IP地址和埠號
addrlen:
傳入傳出引數,傳入的是呼叫者提供的緩衝區addr的長度以避免緩衝區溢位問題,傳出的是客
戶端地址結構體的實際長度(有可能沒有佔滿呼叫者提供的緩衝區)。
返回值:
成功返回一個新的socket檔案描述符,用於和客戶端通訊,失敗返回-1,並設定errno
到此時伺服器基本架構就基本完成了,就只要阻塞I等待客戶端連線就好了,具體的實現程式碼如下,為了將模型儘可能簡單化,至此,我就沒有寫太多關於出錯的資訊的操作,以免淹沒於細節中。
/*sever.c*/
#include <sys/types.h>
#include <sys/socket.h>
#include <strings.h>
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#define SERV_PORT 8000
void perr_exit(const char *str)
{
perror(str);
exit(1);
}
int main()
{
int sfd = socket(AF_INET,SOCK_STREAM,0),cfd;
struct sockaddr_in servaddr;
struct sockaddr_in client_addr;
int i,len;
socklen_t addr_len;
//init
bzero(&servaddr,sizeof(struct sockaddr_in));
servaddr.sin_family = AF_INET;
//htons htonl 都屬於網路位元組序轉換,在程式碼段之後會進行解釋,就先理解為轉換為網路中所需要的型別
servaddr.sin_port = htons(SERV_PORT);
//INADDR_ANY表示任意都可連線(因為客戶端不是來自同一個網路地址)
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);//
if(bind(sfd,(struct sockaddr *)&servaddr,sizeof(servaddr)) < 0)
perr_exit("bind error");
//設定可連線數為128
listen(sfd,128);
printf("wait for conncet---------\n");
addr_len = sizeof(client_addr);
cfd = accept(sfd,(struct sockaddr *)&client_addr,&addr_len);
if(cfd == -1)
perr_exit("accept error");
char buf[256];
/*系統還為我們封裝了IP地址轉換函式
* 因為IP地址在網路中為網路位元組序二進位制值,而我們平常使用的是ASCII字串,
* 故有這一組函式來進行轉換,其實也不難記,可以這樣形式的記住,以免用混ip to net,net to ip
* #include <arpa/inet.h>
* int inet_pton(int af, const char *src, void *dst);
* const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
* */
printf("client IP :%s %d\n",
inet_ntop(AF_INET,&client_addr.sin_addr.s_addr,buf,sizeof(buf)),
ntohs(client_addr.sin_port));
while(1)
{
len = read(cfd,buf,sizeof(buf)); //讀取客戶端的資料
if(len == -1)
perr_exit("read error");
/*
if(len == 0)
{
printf("the other size closed\n");
close(cfd);
close(sfd);
exit(1);
}
*/
if(write(STDOUT_FILENO,buf,len) < 0) //輸出到螢幕
perr_exit("write error");
for(i = 0 ;i < len ; i++) //進行大寫轉換
buf[i] = toupper(buf[i]);
if(write(cfd,buf,len) < 0) //寫資料到客戶端
perr_exit("write error");
}
//關閉開啟的檔案描述符,雖然不會執行到這裡往下的部分,但要養成良好習慣,開啟檔案描述符,用完
//就要關閉
close(sfd);
close(cfd);
return 0;
}
記憶體中的多位元組資料相對於記憶體地址有大端和小端之分,磁碟檔案中的多位元組資料相對於檔案中的偏移地址也有大端小端之分。
(可以用uion去測試本機是小端還是大端,這裡就不去討論了,有興趣可以百度一下)
網路資料流同樣有大端小端之分,那麼如何定義網路資料流的地址呢?
傳送主機通常將傳送緩衝區中的資料按記憶體地址從低到高的順序發出,
接收主機把從網路上接到的位元組依次儲存在接收緩衝區中,也是按記憶體地址從低到高的順序儲存,因此網路資料流的地址應這樣規定:
先發出的資料是低地址,後發出的資料是高地址。
TCP/IP協議規定,網路資料流應採用大端位元組序,即低地址高位元組。
但是在主機中一般是用小端儲存(高地址低位元組),這就不可避免會遇到資料解釋錯誤的問題。
為使網路程式具有可移植性,使同樣的C程式碼在大端和小端計算機上編譯後都能正常執行,可以呼叫以下庫函式做網路位元組序和主機位元組序的轉換
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
h表示host,n表示network,l表示32位長整數,s表示16位短整數。
如果主機是小端位元組序,這些函式將引數做相應的大小端轉換然後返回,
如果主機是大端位元組序,這些函式不做轉換,將引數原封不動地返回。
伺服器程式搞定了,接下來就需要寫客戶端程式去連線伺服器程式了。客戶端就不需要bind了,為什麼呢, 這是因為由於客戶端不需要固定的埠號,這樣客戶端的埠號由核心自動分配就可以了。(注意,客戶端不是不允許呼叫bind(),只是沒有必要呼叫bind()固定一個埠號,伺服器也不是必須呼叫bind(),但如果伺服器不呼叫bind(),核心會自動給伺服器分配監聽埠,每次啟動伺服器時埠號都不一樣,客戶端要連線伺服器就會遇到麻煩。)
所以在客戶端程式上我們就不需要bind()了,而是直接connect()去連線伺服器
5.connect()
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
與前面的bind()的引數型別差不多,返回值所代表的意義也類似,就不繼續解釋了
sockdf:
socket檔案描述符
addr:
傳入引數,指定伺服器端地址資訊,含IP地址和埠號
addrlen:
傳入引數,傳入sizeof(addr)大小
返回值:
成功返回0,失敗返回-1,設定errno
到此,client的基本架構就結束了,等待connect連上伺服器後,便可對socket檔案描述符進行read,write操作來進行通訊,具體程式碼如下(為了簡化邏輯,也沒有過多的出錯處理)
/*client.c*/
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <strings.h>
#include <string.h>
#include <arpa/inet.h>
#define SERV_PORT 8000
void perr_exit(const char *str)
{
perror(str);
exit(1);
}
int main(int argc,char *argv[])
{
int sfd,len;
sfd = socket(AF_INET,SOCK_STREAM,0);
char buf[256];
struct sockaddr_in serv_addr;
bzero(&serv_addr,sizeof(serv_addr));
//init
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
inet_pton(AF_INET,argv[1],&serv_addr.sin_addr.s_addr);//轉換char *IP地址為網路二進位制序
if(connect(sfd,(struct sockaddr*)&serv_addr,sizeof(serv_addr)) < 0)
perr_exit("connect error");
while(fgets(buf,sizeof(buf),stdin))//讀取終端輸入的資料
{
if(write(sfd,buf,strlen(buf)) < 0)//寫入資料到伺服器
perr_exit("write error");
len = read(sfd,buf,sizeof(buf));//讀取伺服器傳遞的資料
if(len <0)
perr_exit("read error");
if(len == 0)
{
printf("the other size closed\n");
close(sfd);
exit(1);
}
if(write(STDOUT_FILENO,buf,len) < 0)//輸出到終端
perr_exit("write error");
}
return 0;
}
程式碼進行編譯過後
執行結果如下
伺服器端在沒有客戶端連上來時,處於阻塞
客戶端(127.0.0.1代表本地地址)
此時的伺服器端接受到連線請求之後
通訊過程
1.客戶端讀取終端輸入,並將資訊傳遞給伺服器
2.伺服器收到客戶端傳遞過來的資料,輸出到終端,並轉換成大寫,傳遞給客戶端
3.客戶端接受到伺服器的資料,輸出到終端
客戶端
伺服器端
進行完簡單的通訊了,我們就來討論下其中涉及到的一些網路知識
伺服器呼叫socket(),bind(),listen()完成初始化後,呼叫accept()阻塞等待。
客戶端呼叫socket()完成初始化,發出SYN段並阻塞等待伺服器應答,伺服器應答一個SYN-ACK段,客戶端收到從connect()返回,同時應答一個ACK,伺服器收到後從accept()返回。
這樣就建立起了連結,這便是TCP建立連線時的三次握手。
以下為比較官方的解釋,參考自書籍
TCP建立起連線的方式(三次握手)
1.客戶端發出段1,SYN位表示連線請求。序號是1000,這個序號在網路通訊中用作臨時的地址,每發一個數據位元組,這個序號要加1,這樣在接收端可以根據序號排出資料包的正確順序,也可以發現丟包的情況,另外,規定SYN位和FIN位也要佔一個序號,這次雖然沒發資料,但是由於發了SYN位,因此下次再發送應該用序號1001。mss表示最大段尺寸,如果一個段太大,封裝成幀後超過了鏈路層的最大幀長度,就必須在IP層分片,為了避免這種情況,客戶端宣告自己的最大段尺寸,建議伺服器端發來的段不要超過這個長度。
2.伺服器發出段2,也帶有SYN位,同時置ACK位表示確認,確認序號是1001,表示“我接收到序號1000及其以前所有的段,請你下次傳送序號為1001的段”,也就是應答了客戶端的連線請求,同時也給客戶端發出一個連線請求,同時宣告mss為1024。
3.客戶端發出段3,對伺服器的連線請求進行應答,確認序號是8001。
在這個過程中,客戶端和伺服器分別給對方發了連線請求,也應答了對方的連線請求,其中伺服器的請求和應答在一個段中發出,因此一共有三個段用於建立連線,稱為’‘’三方握手(three-way-handshake)”’。在建立連線的同時,雙方協商了一些資訊,例如雙方傳送序號的初始值、最大段尺寸等。
資料傳輸的過程:
1.客戶端發出段4,包含從序號1001開始的20個位元組資料。
2.伺服器發出段5,確認序號為1021,對序號為1001-1020的資料表示確認收到,同時請求傳送序號1021開始的資料,伺服器在應答的同時也向客戶端傳送從序號8001開始的10個位元組資料,這稱為piggyback。
3.客戶端發出段6,對伺服器發來的序號為8001-8010的資料表示確認收到,請求傳送序號8011開始的資料。
關閉連線的過程:(4次揮手)
1.客戶端發出段7,FIN位表示關閉連線的請求。
2.伺服器發出段8,應答客戶端的關閉連線請求。
3.伺服器發出段9,其中也包含FIN位,向客戶端傳送關閉連線請求。
4.客戶端發出段10,應答伺服器的關閉連線請求。
我們把上面的圖補充完整之後,如下
CLOSED:
表示初始狀態
LISTEN:
表示伺服器端的SOCKET處於監聽狀態
SYN_SENT:
可以在字面上進行理解,表示傳送SYN報文,當客戶端程式呼叫connect(),它需要先發起連線請求,傳送SYN報文,隨機便進入該狀態。
SYN_RCVD:
與上面的類似,表示收到SYN。即這個狀態是伺服器端在三次握手期間收到客戶端發起請求連線的報文SYN。
ESTABLISHED:
表示連線已經建立(即3次握手完成)
FIN_WAIT_1
表示等待對方FIN報文。與FIN_WAIT2不同的是,當SOCKET主動關閉連線時,向對方傳送起結束連線請求FIN報文,此時該SOCKET就處於FIN_WAIT_1狀態。
CLOSE_WAIT
表示等待關閉,當接受到對方傳送FIN報文,系統會對這個FIN報文迴應ACK應答,此時便進入到CLOSE_WAIT狀態。若此時沒有資料需要進行處理,那麼就可以關閉這個SOCKET,併發送FIN報文給對方,表示關閉連線
FIN_WAIT_2
當處於FIN_WAIT_1的SOCKET收到對方的ACK應答之後,則從FIN_WAIT_1進入FIN_WAIT_2,表示半關閉連線。即有一方要求關閉連線,但另外一方仍有資料需要處理,並要傳送資料回去,只能稍後再關閉連線。
TIME_WAIT
表示收到了對方的FIN報文,併發送ACK報文,就等2MSL後就可以返回CLOSED狀態。在此一提,假設處於FIN_WAIT_1狀態下,如果同時收到對方的ACK和FIN報文的話,就直接進入TIME_WAIT狀態,而不會經歷FIN_WAIT_2狀態
LAST_ACK:即當處於CLOSE_WAIT,傳送FIN報文後,在等待對方的ACK報文。當收到ACK報文後,也就可以處理初始化的CLOSED狀態
另外還有圖中沒有涉及到的狀態CLOSING,這種狀態比較特殊,以我們的正常理解來說一般都是當你傳送FIN報文主動去關閉連線的時候,應該先收到對方的ACK應答,在收到對方的FIN報文。
而CLOSING狀態表明是沒收到對方的ACK應答,卻收到了對方的FIN報文,那麼此時就會處於CLOSING狀態,表示雙方都正在關閉連線。
這裡我的埠號是8000
0.0.0.0表示全部網路,指的是全部網路都能連到這臺主機上
對於網路狀態的查詢我們可以使用命令netstat -apn | grep 8000,依次對應的選項為
Proto協議 Recv-Q網路接收佇列 Send-Q網路傳送佇列 Local Address Foreign Address State PID/Program name
如果當我們直接ctrl+c終止掉客戶端程式後,立即開啟另外一個終端檢視時,此時伺服器接受到客戶端發出的FIN報文後,併發送ACK報文給客戶端,此時伺服器便處於CLOSE_WAIT狀態,但由於還處在while(1)的大迴圈中,故不會發送FIN報文給客戶端,故此時客戶端就會處於FIN_WAIT_2狀態,而伺服器就會處於CLOSE_WAIT狀態。
等過一段時間伺服器write資料給客戶端之後,客戶端就會脫離FIN_WAIT_2狀態,而進入CLOSED,而奇怪的是伺服器端仍處於CLOSE_WAIT狀態,這是因為當read在讀取資料時候,在沒有資料的時候會返回0,使得,伺服器端一直處於while(1)大迴圈中,無法脫離,這時我們需要優化下程式。(即加上len==0的判斷,把我的下面那個註釋去掉就可以了)
當把註釋去掉,便是完整的簡易TCP 模型,當CTR+C掉一個伺服器端或者客戶端,相應的另一端都會關閉。
也許你會遇到 bind error,這可能是因為你伺服器繫結的埠被其他程序用了,可以用netstat -apn | grep 埠號檢視,或者是前面執行的./server還處於TIME_WAIT狀態,要等2MSL時間,才會返回初始狀態CLOSED,Linux的話普遍大概1分鐘。