此為牛客Linux C++課程和黑馬Linux系統編程筆記。
1. 什么是socket
所謂 socket(套接字),就是對網絡中不同主機上的應用進程之間進行雙向通信的端點的抽象。
一個套接字就是網絡上進程通信的一端,提供了應用層進程利用網絡協議交換數據的機制。從所處
的地位來講,套接字上聯應用進程,下聯網絡協議棧,是應用程序通過網絡協議進行通信的接口,
是應用程序與網絡協議根進行交互的接口。
socket 可以看成是兩個網絡應用程序進行通信時,各自通信連接中的端點,這是一個邏輯上的概
念。它是網絡環境中進程間通信的 API,也是可以被命名和尋址的通信端點,使用中的每一個套接
字都有其類型和一個與之相連進程。通信時其中一個網絡應用程序將要傳輸的一段信息寫入它所在
主機的 socket 中,該 socket 通過與網絡接口卡(NIC)相連的傳輸介質將這段信息送到另外一臺
主機的 socket 中,使對方能夠接收到這段信息。socket 是由 IP 地址和端口結合的,提供向應用
層進程傳送數據包的機制。
socket 本身有“插座”的意思,在 Linux 環境下,用于表示進程間網絡通信的特殊文件類型。本質為
內核借助緩沖區形成的偽文件。既然是文件,那么理所當然的,我們可以使用文件描述符引用套接
字。與管道類似的,Linux 系統將其封裝成文件的目的是為了統一接口,使得讀寫套接字和讀寫文
件的操作一致。區別是管道主要應用于本地進程間通信,而套接字多應用于網絡進程間數據的傳
遞。
套接字的內核實現較為復雜,不宜在學習初期深入學習。
在TCP/IP協議中,“IP地址+TCP或UDP端口號”唯一標識網絡通訊中的一個進程。“IP地址+端口號”就對應一個socket。欲建立連接的兩個進程各自有一個socket來標識,那么這兩個socket組成的socket pair就唯一標識一個連接。因此可以用Socket來描述網絡連接的一對一關系。
套接字通信原理如下圖所示:
在網絡通信中,套接字一定是成對出現的。一端的發送緩沖區對應對端的接收緩沖區。我們使用同一個文件描述符索發送緩沖區和接收緩沖區。
2. socket相關函數
2.1 socket( )函數
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了這個頭文件,上面兩個就可以省略
int socket(int domain, int type, int protocol);
功能:創建一個套接字
參數:
-
domain: 協議族:
AF_INET : ipv4
AF_INET6 : ipv6
AF_UNIX, AF_LOCAL : 本地套接字通信(進程間通信) -
type: 通信過程中使用的協議類型:
SOCK_STREAM : 流式協議
SOCK_DGRAM : 報式協議 -
protocol : 具體的一個協議。一般寫0:
SOCK_STREAM : 流式協議默認使用 TCP
SOCK_DGRAM : 報式協議默認使用 UDP -
返回值: 成功:返回文件描述符,操作的就是內核緩沖區。失敗:-1
socket( )
打開一個網絡通訊端口,如果成功的話,就像open()
一樣返回一個文件描述符,應用程序可以像讀寫文件一樣用read/write在網絡上收發數據,如果socket()
調用出錯則返回-1。對于IPv4,domain參數指定為AF_INET。對于TCP協議,type參數指定為SOCK_STREAM,表示面向流的傳輸協議。如果是UDP協議,則type參數指定為SOCK_DGRAM,表示面向數據報的傳輸協議。protocol參數的介紹從略,指定為0即可。
2.2 bind( )函數
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了這個頭文件,上面兩個就可以省略
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
功能:綁定,將文件描述符fd和本地的<IP , 端口>進行綁定
參數:
- sockfd : 通過
socket()
函數得到的文件描述符 - addr : 需要綁定的socket地址,這個地址封裝了ip和端口號的信息
- addrlen : 第二個參數結構體占的內存大小
返回值:成功返回0,失敗返回-1, 設置errno
服務器程序所監聽的網絡地址和端口號通常是固定不變的,客戶端程序得知服務器程序的地址和端口號后就可以向服務器發起連接,因此服務器需要調用bind綁定一個固定的網絡地址和端口號。
bind()的作用是將參數sockfd和addr綁定在一起,使sockfd這個用于網絡通訊的文件描述符監聽addr所描述的地址和端口號。上一篇講過,struct sockaddr *是一個通用指針類型,addr參數實際上可以接受多種協議的sockaddr結構體,而它們的長度各不相同,所以需要第三個參數addrlen指定結構體的長度。
2.3 listen( )函數
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了這個頭文件,上面兩個就可以省略
int listen(int sockfd, int backlog);
功能:監聽這個socket上的連接
參數:
- sockfd : 通過socket()函數得到的文件描述符
- backlog : 未連接的和已經連接的和的最大值(排隊建立3次握手隊列和剛剛建立3次握手隊列的鏈接數和的最大值)
典型的服務器程序可以同時服務于多個客戶端,當有客戶端發起連接時,服務器調用的accept()返回并接受這個連接,如果有大量的客戶端發起連接而服務器來不及處理,尚未accept的客戶端就處于連接等待狀態,listen()聲明sockfd處于監聽狀態,并且最多允許有backlog個客戶端處于連接待狀態,如果接收到更多的連接請求就忽略。listen()成功返回0,失敗返回-1。
2.4 accept( )函數
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了這個頭文件,上面兩個就可以省略
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:接收客戶端連接,默認是一個阻塞的函數,阻塞等待客戶端連接
參數:
- sockfd : 用于監聽的文件描述符
- addr : 傳出參數,記錄了連接成功后客戶端的地址信息(ip,port)
- addrlen : 傳入傳出參數(值-結果), 指定第二個參數的對應的內存大小,傳出第二個參數傳出時的內存大小
返回值:
成功:返回一個新的socket文件描述符,用于和客戶端通信,失敗返回-1,設置errno
三次握手完成后,服務器調用accept()接受連接,如果服務器調用accept()時還沒有客戶端的連接請求,就阻塞等待直到有客戶端連接上來。addr是一個傳出參數,accept()返回時傳出客戶端的地址和端口號。addrlen參數是一個傳入傳出參數(value-result argument),傳入的是調用者提供的緩沖區addr的長度以避免緩沖區溢出問題,傳出的是客戶端地址結構體的實際長度(有可能沒有占滿調用者提供的緩沖區)。如果給addr參數傳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。
一定要區分開connfd和listenfd的作用,listenfd僅用于監聽,監聽到了以后并不用它來進行信息傳輸。
2.5 connect( )函數
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了這個頭文件,上面兩個就可以省略
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能: 客戶端連接服務器
參數:
- sockfd : 用于通信的文件描述符
- addr : 客戶端要連接的服務器的地址信息
- addrlen : 第二個參數的內存大小
返回值:成功 0, 失敗 -1
客戶端需要調用connect()連接服務器,connect和bind的參數形式一致,區別在于bind的參數是自己的地址,而connect的參數是對方的地址。
3. 實現一個簡單的服務端-客戶端通信
接下來使用以上函數實現一個簡單的服務端客戶端通信,實現將客戶端的小寫字母轉換為大寫字母。
3.1 通信流程
上圖給了一個C/S模型網絡編程的socket模板。
服務端:
- 調用 socket() 建立套接字
- 創建 struct sockaddr_in ,并初始化服務端的ip和端口號
- 調用 bind() 將第一步建立的套接字與第二步的sockaddr_in(ip和端口號)綁定
- 調用 listen() 設置最大同時發起連接數量
- 調用 accept() 阻塞等待客戶端發起連接
- 調用 read() 讀取客戶端發出的數據
- 處理請求
- 調用 write() 發送數據給客戶端
- 調用 close() 關閉socket偽文件
客戶端:
- 調用 socket() 建立套接字
- 調用 connect() 向客戶端發起連接
- 調用 write() 發送數據給服務端
- 調用 read() 讀取服務端返回的數據
- 調用 close() 關閉socket偽文件
其中還有很多細節,需要寫代碼的時候才能體會。
3.2 服務端
/*實現一個簡單的服務器-客戶端通信*/#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>// 設定一個服務器端口號
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int lfd; // 用于監聽的socket的文件描述符,真正用于通信的套接字是接下來accept函數返回的cfd套接字struct sockaddr_in serv_addr;lfd = socket(AF_INET, SOCK_STREAM, 0);serv_addr.sin_family = AF_INET;// 注意這里,要把小端存儲的端口號改為大端存儲serv_addr.sin_port = htons(SERV_PORT); inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 調用ip轉換函數,把字符串ip轉化為網絡字節序bind(lfd, (struct sockaddr * )&serv_addr, sizeof(serv_addr));listen(lfd, 128); // 最大連接與待連接數設為128int cfd; // 已連接的客戶端的socket的文件描述符, 以便一會兒read用struct sockaddr_in clie_addr; // 作為accept的第二個參數,為傳出參數,傳出的是客戶端的sockadd_insocklen_t clie_addr_len = sizeof(clie_addr); // 作為accept的第三個參數,為傳入傳出參數,之所以要單獨定義出來是因為要傳出cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);while(1) {// 此處輸出連接的客戶端的ip和端口char clie_IP[BUFSIZ];printf("Client IP: %s, client port: %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),ntohs(clie_addr.sin_port));char buf[BUFSIZ]; // 給read使用,存儲讀出的數據,BUFSIZ宏是系統用來專門給buf賦長度的宏,為8k(Default buffer size)int len; // read的返回值,是讀入字符的長度len = read(cfd, buf, sizeof(buf));// 把小寫字母轉化為大寫字母int i;for(i = 0; i < len; ++i) {if(buf[i] <= 'z' && buf[i] >= 'a') {buf[i] -= 32;}}write(cfd, buf, len);}close(lfd);close(cfd);return 0;
}
以上代碼為突出主體,沒有寫錯誤判斷與錯誤提示。帶錯誤提示代碼如下:
/*實現一個簡單的服務器-客戶端通信*/#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>// 設定一個服務器端口號
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int lfd; // 用于監聽的socket的文件描述符struct sockaddr_in serv_addr;int ret; // 用于錯誤檢測lfd = socket(AF_INET, SOCK_STREAM, 0);if(lfd == -1) {perror("socket error");exit(1);}serv_addr.sin_family = AF_INET;// 注意這里,要把小端存儲的端口號改為大端存儲serv_addr.sin_port = htons(SERV_PORT); inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 調用ip轉換函數,把字符串ip轉化為網絡字節序ret = bind(lfd, (struct sockaddr * )&serv_addr, sizeof(serv_addr));if(ret == -1) {perror("bind error");exit(1);}ret = listen(lfd, 128); // 最大連接與待連接數設為128if(ret == -1) {perror("listen error");exit(1);}int cfd; // 已連接的客戶端的socket的文件描述符, 以便一會兒read用struct sockaddr_in clie_addr; // 作為accept的第二個參數,為傳出參數,傳出的是客戶端的sockadd_insocklen_t clie_addr_len = sizeof(clie_addr); // 作為accept的第三個參數,為傳入傳出參數,之所以要單獨定義出來是因為要傳出cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);if(cfd == -1) {perror("accept error");exit(1);}// 此處輸出連接的客戶端的ip和端口char clie_IP[BUFSIZ];printf("Client IP: %s, client port: %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),ntohs(clie_addr.sin_port));while(1) {char buf[BUFSIZ]; // 給read使用,存儲讀出的數據,BUFSIZ宏是系統用來專門給buf賦長度的宏,為8k(Default buffer size)int len; // read的返回值,是讀入字符的長度len = read(cfd, buf, sizeof(buf));if(len == -1) {perror("read error");exit(1);}// 把小寫字母轉化為大寫字母int i;for(i = 0; i < len; ++i) {if(buf[i] <= 'z' && buf[i] >= 'a') {buf[i] -= 32;}}ret = write(cfd, buf, len);if(ret == -1) {perror("write error");exit(1);}}close(cfd);close(lfd);return 0;
}
3.3 客戶端
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>// 服務器的ip和端口
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int cfd; // 用于寫入數據傳輸給服務端的socket的文件描述符cfd = socket(AF_INET, SOCK_STREAM, 0);// bind() 可以不調用bind(), linux會隱式地綁定struct sockaddr_in serv_addr; // 因為要連接服務端,這里的sockadd_in是用于指定服務端的ip和端口bzero(&serv_addr, sizeof(serv_addr));serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 調用ip轉換函數,把字符串ip轉化為網絡字節序connect(cfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));while(1) {// 從終端讀取內容char buf[BUFSIZ];fgets(buf, sizeof(buf), stdin); // 讀一行// 寫入到cfd中,傳輸給服務端write(cfd, buf, strlen(buf)); // 注意不要寫成sizeof(buf),sizeof是在內存中所占的大小,strlen是到第一個'\0'位止。// read在讀socket時默認時阻塞的,阻塞等待服務端傳輸數據int len;len = read(cfd, buf, sizeof(buf));printf("%s", buf);}close(cfd);return 0;
}
以上代碼為突出主體,沒有寫錯誤判斷與錯誤提示。帶錯誤提示代碼如下:
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>// 服務器的ip和端口
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int ret; // 用于錯誤檢測int cfd; // 用于寫入數據傳輸給服務端的socket的文件描述符cfd = socket(AF_INET, SOCK_STREAM, 0);if(cfd == -1) {perror("socket error");exit(1);}// bind() 可以不調用bind(), linux會隱式地綁定struct sockaddr_in serv_addr; // 因為要連接服務端,這里的sockadd_in是用于指定服務端的ip和端口bzero(&serv_addr, sizeof(serv_addr));serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 調用ip轉換函數,把字符串ip轉化為網絡字節序ret = connect(cfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));if(ret == -1) {perror("connect error");exit(1);}// 從終端讀取內容while(1) {char buf[BUFSIZ];fgets(buf, sizeof(buf), stdin); // 讀一行// 寫入到cfd中,傳輸給服務端ret = write(cfd, buf, strlen(buf)); // 注意不要寫成sizeof(buf),sizeof是在內存中所占的大小,strlen是到第一個'\0'位止。if(ret == -1) {perror("write error");exit(1);}// read在讀socket時默認時阻塞的,阻塞等待服務端傳輸數據int len;len = read(cfd, buf, sizeof(buf));if(len == -1) {perror("read error");exit(1);}printf("%s", buf);}close(cfd);return 0;
}
3.4 程序運行結果及注意事項
先啟動server后啟動client,在client的終端輸入小寫字符后,可見翻譯成了大寫:
同時在server端輸出了client的ip和端口號
程序有兩個注意事項:
- 先啟動服務端,再啟動客戶端。因為先啟動客戶端的話,服務器來不及監聽,客戶端就connect了,會導致connect失敗。
- 關閉時先關閉客戶端,再關閉服務端。如果先關閉服務端,服務器程序處于TIME_WAIT狀態,程序中寫死的6666端口號仍然被占用,可以使用
netstat -apn | grep 6666
查看程序對6666端口占用情況: