一、socket地址的數據類型及相關函數
socket API是一層抽象的網絡編程接口,適用於各種底層網絡協議,如IPv4、IPv6、UNIX Domain Socket。然而各種網絡協議的地址格式並不相同,如下圖所示:
IPv4和IPv6的地址格式定義在netinet/in.h中,IPv4地址用sockaddr_in結構體表示,包括16位端口號和 32位IP地址,IPv6地址用sockaddr_in6結構體表示,包括16位端口號、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定義在sys/un.h中,用sockaddr_un結構體表示。各種socket地址結構體的開頭都是相同的,前16位表示整個 結構體的長度(並不是所有UNIX的實現都有長度地址,Linux就沒有),後16位表示地址類型。IPv4、IPv6和UNIX Domain Socket的地址類型分別定義為常數AF_INET、AF_INET6、AF_UNIX。這樣,只要取得某種sockaddr結構體的首地址,不需要知 道具體是哪種類型的sockaddr結構體,就可以根據地址類型字段確定結構體中的內容。因此,socket API可以接受各種類型的sockaddr結構體指針做參數,例如bind、accept、connect等函數,這些函數的參數應該設計成void *類型以便接受各種類型的指針,但是sock API的實現早於ANSI C標准化,那時還沒有void *類型,因此這些函數的參數都用struct sockaddr *類型表示,在傳遞參數之前要強制類型轉換一下,例如:
struct sockaddr_in servaddr; /* initialize servaddr */ bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));
sockaddr_in中的成員struct in_addr sin_addr表示32位的IP地址。但是我們通常用點分十進制的字符串表示IP地址,以下函數可以在字符串表示和in_addr表示之間轉換。
字符串轉in_addr的函數:
#include <arpa/inet.h> int inet_aton(const char *strptr, struct in_addr * addrptr); in_addr_t inet_addr(const char *strptr); int inet_pton(int family, const char *strptr, void *addrptr);
in_addr轉字符串的函數:
char *inet_ntoa(struct in_addr inaddr); const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len);
其中inet_pton和inet_ntop不僅可以轉換IPv4的in_addr,還可以轉換IPv6的in6_addr,因此函數接口是void *addrptr。
二、基於TCP協議的網絡程序
1、TCP協議通訊流程
建立連接的過程:
服務器調用socket()、bind()、listen()完成初始化後,調用accept()阻塞等待,處於監聽端口的狀態,客戶端調 用socket()初始化後,調用connect()發出SYN段並阻塞等待服務器應答,服務器應答一個SYN-ACK段,客戶端收到後從 connect()返回,同時應答一個ACK端,服務器收到後從accept()返回。
數據傳輸的過程:
建立連接後,TCP協議提供全雙工的通信服務,但是一般的客戶端/服務器程序的流程是由客戶端主動發起請求,服務器被動處理請求,一問一答 的方式。因此,服務器從accept()返回後立刻調用read(),讀socket就像讀管道一樣,如果沒有數據到達就阻塞等待,這是客戶端調用 write()發送請求給服務器,服務器收到後從read()返回,對客戶端的請求進行處理,在此期間客戶端調用read()阻塞等待服務器的應答,服務 器調用write()將處理結果發給客戶端,再次調用read阻塞等待下一條請求,客戶端收到後從read()返回,發送下一條請求,如此循環下去。
關閉連接的過程:
如果客戶端沒有更多請求了,就調用close()關閉連接,就像寫端關閉的管道一樣,服務器的read()返回0,這樣服務器就知道客戶端 關閉了連接,也調用close()關閉連接。注意,任何一方調用close()後,連接的兩個傳輸方向都關閉,不能再發送數據了。如果一方調用 shutdown()則連接處於半關閉狀態,仍可接收對方發來的數據。
2、TCP網絡程序
server.c從客戶端讀取字符,然後將每個字符轉換為大寫返回給客戶端。
/* server.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #define MAXLINE 80 #define SERV_PORT 8000 int main(void) { struct sockaddr_in servaddr, cliaddr; socklen_t cliaddr_len; int listenfd, connfd; char buf[MAXLINE]; char str[INET_ADDRSTRLEN]; int i, n; listenfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT); bind(listenfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); listen(listenfd, 20); printf("Accepting connections ...\n"); while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); printf("received from %s at PORT %d\n", inet_ntop(AF_INET, &cliaddr.sin_addr, str, sizeof(str)), ntohs(cliaddr.sin_port)); for (i = 0; i < n; i++) buf[i] = toupper(buf[i]); write(connfd, buf, n); close(connfd); } }
程序中用到的socket API 這些函數都在sys/socket.h中
int socket(int family, int type, int protocol);
socket()打開一個網絡通訊端口,如果成功的話,就像open()一樣返回一個文件描述符,應用程序可以像讀寫文件一樣用read/write在網 絡上收發數據,如果socket調用出錯則返回-1.對於IPv4,family參數指定為AF_INET。對於TCP協議,type參數指定為 SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定為SOCK_DGRAM,表示面向數據報的傳輸協議。 protocol參數指定為0即可。
int bind(int sockfd, const struct sockaddr &myaddr, socklen_t addrlen);
服務器程序所監聽的網絡地址和端口號通常是固定不變的,客戶端程序得知服務器程序的地址和端口號後就可以向服務器發起連接,因此服務器需要調用bind綁定一個固定的網絡地址和端口號。bind()成功返回0,失敗則返回-1。
bind()的作用是將參數sockfd和myaddr綁定在一起,使sockfd這個用於網絡通訊的文件描述符監聽myaddr所描述的 地址和端口號。struct sockaddr *是一個通用指針類型,myaddr參數實際上可以接收多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定 結構體的長度。我們的程序中對myaddr參數是這樣初始化的:
bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; servaddr.sin_addr.s_addr = htonl(INADDR_ANY); servaddr.sin_port = htons(SERV_PORT);
首先將整個結構體清零,然後設置地址類型為AF_INET,網絡地址為INADDR_ANY,這個宏表示本地的任意IP地址,因為服務器可能有多個網卡, 每個網卡也可能綁定多個IP地址,這樣設置可以在所有的IP地址上監聽,直到我們與某個客戶端建立了連接時才確定下來到底用哪個IP地址,端口號為 SERV_PORT,這裡定義為8000。
int listen(int sockfd, int backlog);
典型的服務器程序可以同時服務與多個客戶端,當有客戶端發起連接時,服務器調用的accept()返回並接受這個連接,如果有大量的客戶端發起連接而服 務器來不及處理,尚未accept()的客戶端即處於連接等待狀態,如果接受到更多的連接請求就忽略。listen()成功返回0,失敗返回-1。
int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen);
三方握手完成後,服務器調用accept()接受連接,如果服務器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端 連接上來。cliaddr是一個傳出參數,accept返回時傳出客戶端的地址和端口號。addrlen參數是一個傳入傳出參數(value- result argument),傳入的是調用者提供的緩沖區cliaddr的長度以避免緩沖區溢出的問題,傳出的是客戶端地址結構體的實際長度(有可能沒有占滿調用 者提供的緩沖區)。如果給cliaddr參數 傳NULL,表示不關心客戶端的地址。
我們的服務器程序結構是這樣的:
while (1) { cliaddr_len = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len); n = read(connfd, buf, MAXLINE); ... close(connfd); }
整個是一個while死循環,每次循環處理一個客戶端連接。由於cliaddr_len是傳入傳出參數,每次調用accept()之前應該重新賦初值。 accept()的參數listenfd是先前的監聽文件描述符,而accept()的返回值是另外一個文件描述符connfd,之後與客戶端之間及通過 這個connfd通訊,最後關閉connfd斷開連接,而不關閉listenfd,再次回到循環開頭listenfd仍然用作accept的參數。 accept()成功返回一個文件描述符,出錯則返回-1。
client.c從命令行參數中獲取一個字符串發送給服務端,然後接受服務端返回的字符串然後顯示出來:
/* client.c */ #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #define MAXLINE 80 #define SERV_PORT 8000 int main(int argc, char *argv[]) { struct sockaddr_in servaddr; char buf[MAXLINE]; int sockfd, n; char *str; if (argc != 2) { fputs("usage: ./client message\n", stderr); exit(1); } str = argv[1]; sockfd = socket(AF_INET, SOCK_STREAM, 0); bzero(&servaddr, sizeof(servaddr)); servaddr.sin_family = AF_INET; inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr); servaddr.sin_port = htons(SERV_PORT); connect(sockfd, (struct sockaddr *)&servaddr, sizeof(servaddr)); write(sockfd, str, strlen(str)); n = read(sockfd, buf, MAXLINE); printf("Response from server:\n"); write(STDOUT_FILENO, buf, n); close(sockfd); return 0; }
由於客戶端不需要固定的端口因此不用調用bind(),客戶端的端口號由內核自動分配。(服務端不是必須要調用bind(),但是如果不調用bind()內核會自動給服務端分配監聽端口,每次啟動服務端時端口號都不一樣客戶端想連接服務端就會比較麻煩)
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);
客戶端需要調用connect()連接服務器,conenct和bind的參數形式一致,區別在於bind的參數是自己的地址,而connect的參數是對方的地址。connect()成功返回0,出錯返回-1.
然後編譯先運行server再運行client。