網路程式設計-一個簡單的echo程式

前言

在上一篇《網路程式設計-從TCP連線的建立說起》中簡單介紹了TCP連線的建立,本文暫時先拋開TCP更加詳細的介紹,來看看如何實現一個簡單的網路程式。

一個簡單的echo程式

本文以及後續文章都將會圍繞該程式進行介紹。程式大體流程如下:

網路程式設計-一個簡單的echo程式

首先啟動服務端,客戶端透過TCP的三次握手與服務端建立連線;而後,客戶端傳送一段字串,服務端收到字串後,原封不動的發回給客戶端。

我們先將程式碼呈現,後面再進行更加詳細的解釋。

客戶端程式碼client。c如下:

//client。c//來源:公眾號【程式設計珠璣】網站:https://www。yanbinghu。com#include#include#include#include#include #include#include#define MAXLINE 128int main(int argc, char **argv){ int sockfd; //連線描述符 struct sockaddr_in servaddr;//socket結構資訊 char sendMsg[MAXLINE] = {0}; char recvMsg[MAXLINE] = {0}; //檢查引數數量 if (argc < 2) { printf(“usage: 。/client ip port\n”); return -1; } //初始化結構體 bzero(&servaddr, sizeof(servaddr)); //指定協議族 servaddr。sin_family = AF_INET; //第一個引數為ip地址,需要把ip地址轉換為sin_addr型別 inet_pton(AF_INET, argv[1], &servaddr。sin_addr); //第二個引數為埠號 servaddr。sin_port = htons(atoi(argv[2])); sockfd = socket(AF_INET, SOCK_STREAM, 0); if(-1 == sockfd) { perror(“socket error”); return -1; } //連線伺服器,如果非0,則連線失敗 if(0 != connect(sockfd, (const struct sockaddr *)&servaddr, sizeof(servaddr))) { perror(“connect failed”); return -1; } //從控制檯讀取訊息 if(NULL !=fgets(sendMsg,MAXLINE,stdin)) { write(sockfd, sendMsg, strlen(sendMsg)); } if(0 != read(sockfd, recvMsg, MAXLINE)) { printf(“recv msg:%s\n”,recvMsg); } close(sockfd); return 0;}

服務端程式碼server。c如下:

//server。c//來源:公眾號【程式設計珠璣】網站:https://www。yanbinghu。com#include#include#include#include#include #include#include#define SERV_PORT 1234#define MAXLINE 128int main(int argc, char **argv){ int listenfd = 0;//監聽描述符 int connfd = 0; //已連線描述符 socklen_t clilen; char recvMsg[MAXLINE] = {0}; //伺服器和客戶端socket資訊 struct sockaddr_in cliaddr, servaddr; char ip[MAXLINE] = {0}; //初始化服務端socket資訊 bzero(&servaddr, sizeof(servaddr)); servaddr。sin_family = AF_INET; //如果輸入ip和埠,使用輸入的ip和埠 if(3 == argc) { inet_pton(AF_INET, argv[1], &servaddr。sin_addr); servaddr。sin_port = htons(atoi(argv[2])); } else { //使用預設的ip和port servaddr。sin_addr。s_addr = htonl(INADDR_ANY); servaddr。sin_port = htons(SERV_PORT); } listenfd = socket(AF_INET, SOCK_STREAM, 0); if(-1 == listenfd) { perror(“socket error”); return -1; } //繫結指定ip和埠 if(0 != bind(listenfd, (struct sockaddr *) &servaddr, sizeof(servaddr))) { perror(“bind error”); return -1; } printf(“start server at %s:%d\n”,inet_ntop(AF_INET,&servaddr。sin_addr,ip,MAXLINE),ntohs(servaddr。sin_port)); listen(listenfd, 4); //處理來自客戶端的連線 clilen = sizeof(cliaddr); connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &clilen); if(-1 == connfd) { perror(“accept failed”); return -1; } printf(“connect from %s %d\n”,inet_ntop(AF_INET,&cliaddr。sin_addr,ip,MAXLINE),ntohs(cliaddr。sin_port)); //讀取客戶端傳送的訊息 if(0 != read(connfd, recvMsg, MAXLINE)) { printf(“recv msg:%s\n”,recvMsg); } //將讀取內容原封不動地傳送回去 write(connfd, recvMsg, MAXLINE); close(connfd); close(listenfd); return 0;}

編譯執行

編譯客戶端服務端程式碼:

$ gcc -o client client。c$ gcc -o server server。c

在兩個終端分別執行server和client。

$ 。/serverstart server at 0。0。0。0:1234

執行客戶端,並輸入內容:

$ 。/client 127。0。0。1 1234hello 程式設計珠璣

服務端最終列印:

start server at 0。0。0。0:1234connect from 127。0。0。1 47536recv msg:hello 程式設計珠璣

客戶端最終列印:

hello 程式設計珠璣recv msg:hello 程式設計珠璣

從執行結果可以看到,客戶端連線到服務端後,傳送一段字串“hello 程式設計珠璣”後,服務端返回同樣的字串,達到了我們想要的目的。當然程式碼裡有很多地方還需要完善,但這不影響我們對網路程式設計的學習。

整體流程說明

整體流程可結合下圖來理解:

網路程式設計-一個簡單的echo程式

TCP三次握手

TCP的三次握手,我們在《網路程式設計-從TCP連線的建立說起》中就已經介紹了。在圖中,標示了在呼叫某些介面後的狀態。例如,服務端在呼叫socket,bind,listen等函式後,處於LISTEN狀態;客戶端呼叫connect函式並返回後,完成三次握手,客戶端與服務端都處於ESTABLISHED狀態。這些狀態我們是可以觀察到的,首先在一個終端啟動伺服器:

$ 。/serverstart server at 0。0。0。0:1234

在另外一個終端使用netstat命令(或使用ss命令)觀察:

$ netstat -anp |grep :1234tcp 0 0 0。0。0。0:1234 0。0。0。0:* LISTEN 17730/server

netstat命令的使用可參考netstat命令詳解,可以看到server程式當前處於LISTEN狀態。

而如果客戶端進行連線後再觀察會發現:

$ netstat -anp |grep :1234tcp 0 0 0。0。0。0:1234 0。0。0。0:* LISTEN 17730/server tcp 0 0 127。0。0。1:48094 127。0。0。1:1234 ESTABLISHED 17957/client tcp 0 0 127。0。0。1:1234 127。0。0。1:48094 ESTABLISHED 17730/server

從結果中看到,客戶端此時處於ESTABLISHED狀態,而服務端有一條連線處於ESTABLISHED,還有一條處於LISTEN狀態,這是為何呢?我們後面再解釋。

由於三次握手的過程非常快,其他的狀態我們不是很方便能觀察到。

那麼結合程式碼,整個流程又是怎樣的呢?請看下圖:

網路程式設計-一個簡單的echo程式

客戶端-服務端

在弄清楚圖中的介面含義之前,實際上你可以認為客戶端連線伺服器的整個過程你可以看成是這樣的:

服務端準備(socket,bind,listen,accept等待客戶端)

客戶端準備(socket)

客戶端連線(connect)

服務端收到客戶端的連線(accept返回),客戶端連線成功,connect返回

客戶端傳送資料(write)

服務端接收資料(read),隨後又將原資料發回(write)

客戶端收到來自服務端的資料(read)

當然了,我們需要注意到的是:

服務端在accept阻塞的過程中,處於LISTEN狀態

客戶端在connect返回之後完成TCP的三次握手

三次握手完成後,客戶端與服務端處於ESTABLISHED狀態

服務端始終有一個處於LISTEN狀態

不要著急,對於圖中所提到的介面和資料結構的介紹和使用說明都會在後面進行詳細介紹。

小結

看到這裡,想必你對我們的echo程式的整體已經有了大致的瞭解。在對這些介面和資料結構進行詳細介紹之前,你可以將程式碼複製並進行編譯執行,觀察文中提到的內容。

原文地址:

https://www。yanbinghu。com/2019/07/07/40135。html

資料結構與函式詳解

既然要詳細瞭解echo程式,就必須對其中用到的一些資料結構和介面有所瞭解。在echo程式中,我們主要用到了以下的資料結構或函式:

htons/ntohs

inet_pton/inet_ntop

sockaddr_in

socket

bind

listen

connect

accept

當然需要清楚的是,網路程式設計中用到的資料結構或函式遠不止上面提到的這些,但這些都是最基本的。下面的解釋都基於echo程式,多數函式都使用預設的阻塞模式。

htons/ntohs

htons/ntohs這兩個宏分別用於將本地位元組序轉為網路位元組序和將網路位元組序轉為本地位元組序。關於位元組序,本文不展開介紹,可以參考《談一談位元組序的問題》,

如何判斷當前機器的位元組序

,也是面試中經常問到的題目。

inet_pton/inet_ntop

inet_pton/inet_ntop分別用於將字串ip地址轉為4位元組大小的無符號整型和將無符號整型轉換為ip地址字串。例如:

//來源:公眾號【程式設計珠璣】網站:https://www。yanbinghu。com#include#include int main(void){ char ip[16] = “192。168。0。1”; struct in_addr addr; inet_pton(AF_INET, ip, &addr); printf(“addr is %x\n”,addr); addr。s_addr = 0x153a8c0; inet_ntop(AF_INET,&addr,ip,sizeof(ip)); printf(“ip is %s”,ip); return 0;}

執行結果:

addr is 100a8c0 ip is 192。168。83。1

從執行結果中可以清晰看到兩者之間的轉換。需要注意的是,inet_pton/inet_ntop對IPV4和IPV6地址都適用。

sockaddr_in

sockaddr_in是IPV4套接字地址結構,它在不同系統中具體定義可能有所不同:

struct sockaddr_in{ sa_family_t sin_family; in_port_t sin_port; struct in_addr sin_addr; char sin_zero[8];};

但它們都包含三個基本的成員:

sin_family 協議族

sin_port 協議埠

sin_addr 協議地址

協議族通常有以下幾種型別:

AF_INET IPV4協議

AF_INET6 IPV6協議

AF_LOCAL Unix域協議

AF_ROUTE 路由套接字

AF_KEY 秘鑰套接字

而目前echo程式中用到的是IPV4協議,因此選擇了AF_INET。

而sin_port就比較容易理解了,它是一個16位元大小的埠,但是由於它的資訊需要在網路中傳輸,因此需要使用前面介紹的htons進行位元組序的轉換。

sin_addr用4位元組儲存ip地址,如果是形如127。0。0。1的地址,需要透過inet_pton函式將其轉換為struct in_addr型別。

socket--確定協議族和套接字型別

呼叫socket函式是執行網路I/O之前必須做的一件事情。

透過socket函式指定了本次網路通訊的協議族,套接字型別

,呼叫成功後,會返回一個非負的套接字描述符,否則返回-1,具體失敗原因,被存放於全域性變數errno。它和檔案描述類似,只不過此時它還不能進行正常的網路讀寫。

socket函式相關資訊如下:

#includeint socket(int family,int type,int protocol);

其中family就是在介紹sockaddr_in中提到的協議族。

type通常有以下幾個值:

SOCK_STREAM 位元組流套接字

SOCK_DGRA 資料報套接字

SOCK_RAW 原始套接字

SOCK_SEQPACKET 有序分組套接字

SOCK_PACKET 分組套接字

需要注意的是:

TCP僅支援位元組流套接字

UDP僅支援資料報套接字

SCTP支援位元組流套接字和資料報套接字

protocol通常指以下幾種:

IPPROPO_TCP TCP協議

IPPROPO_UDP UDP協議

IPPROPO_SCTP SCTP協議

通常來說,一種傳輸協議只支援一種套接字,此時protocol可以為0,系統會選擇其對應的協議型別;否則的話,需要指定protocol的值。在當前echo程式中,type為SOCK_STREAM,我們的protocol值為0,因此使用的就是TCP協議。

我們透過一個簡單的例子,觀察這個套接字描述符:

//testSocket。c//來源:公眾號【程式設計珠璣】網站:https://www。yanbinghu。com#include#include #includeint main(void){ int socktfd = socket(AF_INET,SOCK_STREAM,0); sleep(20); return 0;}

在一個終端執行testSocket,在另外一個終端找到該程式的pid,並檢視開啟的檔案描述符:

$ pidof testSocket5903$ ls -l /proc/5903/fd/total 0lrwx———— 1 hyb hyb 64 7月 8 19:59 0 -> /dev/pts/6lrwx———— 1 hyb hyb 64 7月 8 19:59 1 -> /dev/pts/6lrwx———— 1 hyb hyb 64 7月 8 19:59 2 -> /dev/pts/6lrwx———— 1 hyb hyb 64 7月 8 19:59 3 -> socket:[62182]

還記得那句話嗎:linux下一切皆檔案。

bind--指定套接字地址資訊

呼叫socket函式之後已經確定了協議族和傳輸協議,但是還沒有確定本地協議,即套接字地址資訊。bind函式描述如下:

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

sockfd是前面呼叫socket函式返回的套接字描述符,用於將協議地址繫結到指定套接字中去,返回0表明成功,-1表示失敗,具體失敗原因,被存放於全域性變數errno。addr是套接字地址,它並不是我們前面所看到的sockaddr_in型別,而是struct sockaddr,因為struct sockaddr是通用型別,不僅適用於IPV4套接字地址,也需要適用於IPV6套接字地址。

addr中的ip地址可以為0(INADDR_ANY),表示使用通配地址;而埠為0,表示由核心分配一個臨時埠。伺服器需要被客戶端連線,因此其埠通常都是確定的,不會選擇一個臨時埠。

但是在客戶端其ip地址和埠並非需要確切知道,因此客戶端常常不繫結埠。在我們的echo程式中,我們也沒有在客戶端呼叫bind函式。

listen--監聽客戶端連線

listen函式用於將前面得到的套接字變為一個被動套接字,即

可用於接受來自客戶端的連線

。描述如下:

#includeint listen(int sockfd,int backlog);

返回0表明成功,-1表明失敗,具體失敗原因,被存放於全域性變數errno。sockfd就是socket函式呼叫返回的套接字描述符,而backlog指明瞭連線佇列的大小,即完成和還未完成TCP三次握手的連線總和。如果這個佇列滿了,伺服器就不會理會新的連線請求。還記得在《網路程式設計-從TCP連線的建立說起》中提到的SYN攻擊嗎?

connect--建立連線

connect函式在客戶端呼叫

,它用來與服務端建立連線。描述如下:

#includeint connect(int sockfd,const struct sockaddr *addr,socklen_t addrlen);

返回0表明成功,-1表明失,具體失敗原因,被存放於全域性變數errno。connect函式的引數與bind函式一樣,這裡就不多做解釋了,只不過addr指明的是遠端協議地址。如果本次連線是TCP協議,則

connect函式呼叫將會發起TCP的三次握手

accept--接受來自客戶端的連線

accept函式在服務端呼叫,它用於接受來自客戶端的連線,從已完成連線佇列返回一個已完成連線。描述如下:

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

成功返回非負套接字描述符,失敗返回-1,具體失敗原因,被存放於全域性變數errno。需要注意的是accept函式引數型別和數量與connect函式一致,但是含義不同,addr用於獲取客戶端的套接字地址資訊,如果不關心客戶端的協議地址,那麼該引數可為NULL。

另外需要注意的是,它的返回值是一個非負的套接字描述符,這個套接字描述符是已連線套接字描述符,而其引數sockfd是監聽套接字描述符。一個伺服器通常一直有且只有一個監聽套接字描述符,但通常會有多個已連線套接字描述符。還記得在上一篇中問到的嗎?為什麼客戶端連線到服務端後,服務端有一個處於LISTEN狀態,還有一個處於ESTABLISHED狀態嗎?

透過已連線套接字描述符就可以對其進行資料的讀寫了。

小結

本文主要對echo程式中用到的一些資料結構和函式進行了介紹,但沒有涉及具體的異常場景,後面的文章將根據實際情況來看看其具體應用。本文常用介面總結如下:

介面

作用

成功

失敗

呼叫者

socket 確定協議族和套接字型別 套接字描述符 -1 客戶端/服務端 bind 確定套接字地址 0 -1 [客戶端]/服務端 listen 套接字轉為被動套接字 0 -1 服務端 connect 建立連線 0 -1 客戶端 accept 接受連線 套接字描述符 -1 服務端

網路程式設計-一個簡單的echo程式

網路程式設計

原文地址:

https://www。yanbinghu。com/2019/07/08/3270。html

未完待續……

如有不妥之處,歡迎批評指正。

參考書籍

《Unix網路程式設計》

《TCP/IP協議詳解:卷一》

微信公眾號【程式設計珠璣】:專注但不限於分享計算機程式設計基礎,Linux,C語言,C++,資料結構與演算法,工具,資源等程式設計相關[原創]技術文章。