從 0 開始學習 Linux 系列之「27.Socket 程式設計基礎(TCP,UDP)」
Socket 介面簡介
Socket 套接字是由 BSD(加州大學伯克利分校軟體研發中心)開發的一套獨立於具體協議的網路程式設計介面,應用程式可以用這個介面進行網路通訊。要注意:Socket 不是一套通訊協議(HTTP,FTP 等是通訊協議),而是程式設計的介面,即我們在程式中使用的網路函式。TCP/IP 網路程式設計底層就是使用 Socket 介面來通訊,所以在學習 TCP/IP 程式設計之前必須知道 Socket 的介面使用方法。
Socket API 介面
基本的程式設計介面有:socket,bind,listen,accept,connect,send,recv 等,這些函式都很重要,下面來一一學習這些函式。
建立通訊套接字:socket
socket 函式建立一個通訊的端點,並返回一個指向該端點的檔案描述符(Linux 下一切皆是檔案):
#include <sys/types.h>
#include <sys/socket.h>
/*
* domain: 通訊協議簇,例如 AF_INET, AF_UNIX...
* type: SOCK_STREAM, SOCK_DGRAM 等等
* protocol: 通常為 0
* return: 成功返回檔案描述符,失敗返回 -1,並設定 erron
*/
int socket(int domain, int type, int protocol);
例如伺服器端建立一個用於接受客戶端連線的 socket 的程式碼:
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == server_fd) {
perror("socket");
exit(1);
}
分配套接字名稱:bind
當用 socket 函式建立套接字後,並沒有為它分配 IP 地址和埠,我們還需要使用 bind 函式來將指定的 IP 和埠分配給已經建立的 socket:
#include <sys/types.h>
#include <sys/socket.h>
/*
* sockfd: socket 返回的檔案描述符
* addr: 含有要繫結的 IP 和埠的地址結構指標
* addrlen: 第二個引數的大小,使用 sizeof 來計算
* return: 成功返回 0,失敗返回 -1,並設定 erron
*/
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
其實第二個引數要注意,引數指定的是 struct sockaddr *
型別,一般不直接使用這個結構,這個型別在 Linux 上有許多的變種,例如 sockaddr_in 和 sockaddr_un,經常使用後面 2 個結構定義 IP 和埠資訊,在 bind 是強制轉換成 struct sockaddr *
型別:
// struct sockaddr_un myaddr;
struct sockaddr_in myaddr;
myaddr.sin_family = AF_INET;
// 接受任何 IP 地址的連線
myaddr.sin_addr.s_addr = htonl(INADDR_ANY);
// 指定連線埠為 8080
myaddr.sin_port = htons(8080);
if(bind(server_fd, (struct sockaddr *)&myaddr, sizeof(sockaddr_in)) == -1) {
perror("bind");
exit(1);
}
開始監聽:listen
使用 listen 來建立一個監聽客戶端連線的佇列:
#include <sys/types.h>
#include <sys/socket.h>
/*
* sockfd: 監聽的 socket 描述符
* backlog: 建立的最大連線數
* return: 成功返回 0,失敗返回 -1,並設定 erron
*/
int listen(int sockfd, int backlog);
例如建立一個可以監聽 10 個客戶端連線請求的佇列:
if(listen(server_fd, 10) == -1) {
perror("listen");
exit(1);
}
接受連線請求:accept
網路程式設計的核心一步就是建立客戶端和伺服器端的連線,使用 accept 來建立 2 者的連線:
#include <sys/types.h>
#include <sys/socket.h>
/*
* sockfd: 已經建立的本地正在監聽的 socket
* addr: 儲存連線的客戶端的地址資訊
* addrlen: sockaddr 的長度指標
* return: 成功返回客戶端的 socket 檔案描述符號,失敗返回 -1,設定 erron
*/
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
後兩個引數我們需要定義,但是不需要初始化,在連線成功後客戶端的 socket 資訊會自動地填入第 3 個結構中。使用方法如下:
struct sockaddr_in clientaddr;
int clientaddr_len = sizeof(clientaddr);
// 建立連線請求
int client_fd = accept(server_fd, (struct sockaddr *)&clientaddr, &clientaddr_len);
if(client_fd == -1) {
perror("accept error") ;
exit(1) ;
}
// socket fd 使用完畢也必須關閉
close(client_fd);
傳送資料:send,sendto
在建立連線之後,當然要傳送資料,既然 socket 也是檔案,傳送資料其實也就是寫檔案,我們使用 send 函式來發送 socket 資料:
#include <sys/types.h>
#include <sys/socket.h>
/*
* sockfd: 接受資料的 socket
* buf: 傳送的資料
* len: 資料長度
* flags: 當這個引數為 0,該函式等價與 write
* return: 成功返回傳送的位元組數,失敗返回 -1,並設定 erron
*/
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
/* sendto 功能是將資料傳送到指定的地址 dest_addr,其他引數基本相同 */
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
例如伺服器在建立連線後傳送一個字串到客戶端:
char msg[] = "Hello Client."
send(client_fd, msg, strlen(msg), 0);
sendto(client_fd, msg, strlen(msg), 0,
(struct sockaddr*)&dest_addr, sizeof(dest_addr));
接收資料:recv,recvfrom
既然有傳送資料,必然有接收資料的函式,與 send 類似,recv 的功能也跟 read 幾乎相同:
#include <sys/types.h>
#include <sys/socket.h>
/*
* sockfd: 接收的 socket fd
* buf: 接收緩衝區
* len: 緩衝區長度
* flags: 當這個引數為 0,該函式等價與 read
* return: 成功返回接受的位元組數,失敗返回 -1,並設定 erron
*/
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
/* recvfrom 從指定的地址 src_addr 接收資料,其他引數與 recv 類似 */
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
例如接受伺服器傳送的字串:
char msg_buf[100] = { 0 };
recv(server_fd, msg_buf, 100, 0);
int srcaddr_len = sizeof(src_addr);
recvfrom(server_fd, msg_buf, 100, 0,
(struct sockaddr*)&src_addr, &srcaddr_len);
基本的 socket 函式就介紹完了,但是這裡不可能將所有細節都列出來,想要深入的學習建議你檢視對應函式的 man 手冊,例如 man socket
,man recvfrom
等等。
使用 Socket 進行 TCP 通訊
TCP 通訊的概念在上一篇文章中已經介紹過了,這裡使用 Socket 提供的程式設計介面來實際編寫一個簡單的伺服器和客戶端來模擬通訊過程,下面是使用 Socket 進行 TCP 通訊的過程:
1. TCP 伺服器
其中用到的都是上面介紹的 socket 函式,整個通訊過程不算很複雜,主要是:建立連線-傳輸或處理資料-關閉連線。下面就是一個基於 TCP 的簡單伺服器的例子,這裡為了防止程式碼過多就省去了返回值檢查的過程(檢查過程可以參考前面的例子):
// tcp_server.c
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <string.h>
#include <arpa/inet.h>
int main(void) {
int server_fd, client_fd;
struct sockaddr_in server_addr;
struct sockaddr_in client_addr;
// 1. init server addr
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
int client_addr_len = sizeof(client_addr);
// 2. create socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 3. bind server_addr to server_fd
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// 4. listen server_fd, max listen client num = 10
listen(server_fd, 10);
printf("TCP server is listening...\n");
// 5. accept client connect
char send_msg[] = "hello client";
while(1) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len);
// 6. send data
send(client_fd, send_msg, sizeof(send_msg), 0);
printf("Write \"hello client\" to client ok.\n");
// 7. close client fd
close(client_fd);
}
// 8. close server fd
close(server_fd);
return 0;
}
這個例子非常基礎,但是還是有 4 處容易出錯的地方:
1. INADDR_ANY
: 允許任何 IP 地址的客戶端連線本伺服器
2. 指定埠為 8888
: 連線的埠被指定為 8888,如果連線不成功則埠可能被佔用,可以嘗試更換埠
3. AF_INET
和 SOCK_STREAM
: 兩者確定了當前使用的是 TCP 協議
4. 強制轉換為 struct sockaddr *
: 在使用 bind 和 accept 時,都需要將 sockaddr_in
或 sockaddr_un
強制轉換為這個型別
5. 不要忘記關閉 socket 的檔案描述符 fd
編譯執行該伺服器:
gcc tcp_server.c -o tcp_server
./tcp_server
TCP server is listening...
^C
執行正常,下面來看看 TCP 客戶端的程式碼。
2. TCP 客戶端
TCP 客戶端直接建立 socket,然後使用 connect 連線伺服器,之後接收伺服器傳送的資料:
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netdb.h>
int main(int argc, char *argv[]) {
if (argc < 2) {
printf("Usage: ./tcp_client localhost\n");
exit(1);
}
int server_fd = 0;
// 1. init client addr
struct sockaddr_in client_addr;
client_addr.sin_family = AF_INET;
struct hostent *myhost = gethostbyname(argv[1]);
client_addr.sin_addr = (*((struct in_addr *)(myhost->h_addr)));
client_addr.sin_port = htons(8888);
// 2. create socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 3. connect server
connect(server_fd, (struct sockaddr *)&client_addr, sizeof(client_addr));
// 4. recv msg from server
char msg_buf[100] = { 0 };
recv(server_fd, msg_buf, 100, 0);
printf("Client get server msg: %s\n", msg_buf);
// 5. close fd
close(server_fd);
return 0;
}
要注意的是客戶端這裡根據實際的域名來獲取 IP,也可以使用下面的程式碼直接使用指定的 IP 地址:
client_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
編譯看看:
gcc tcp_client.c -o tcp_client
./tcp_client localhost
沒有錯誤,下面來測試下。
3. 測試 TCP 連線
先執行伺服器:
./tcp_server
TCP server is listening...
在新的終端中執行客戶端:
./tcp_client localhost
Client get server msg: hello client
成功接收了伺服器的訊息,並且伺服器也列印了訊息:
./tcp_server
Write "hello client" to client ok.
這就成功的實現了一個簡單的使用 TCP 實現伺服器端和客戶端通訊的例子了,例子實現起來很簡單,把前面介紹的 API 理解並學會使用就可以了。下面再來看看使用 UDP 的如何實現兩個網路程序的通訊。
使用 Socket 進行 UDP 通訊
UDP 通訊的基本概念也在上一篇文章中,可以點選檢視,UDP 是一種面向無連線的協議,因而具有資源消耗小,處理速度快的優點,所以通常音訊、視訊等實時性較強的資料在傳送時使用 UDP 較多,因為它們即使偶爾丟失一兩個資料包,也不會對接收結果產生太大影響,比如 QQ 就是使用的 UDP 協議。通訊過程如下:
下面是一個具體的通訊例子。
1. UDP 伺服器端
最後的 recvfrom 函式是從指定的地址接收 UDP 資料,與 recv 的作用基本相同。
// udp_server.c
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/un.h>
#include <sys/socket.h>
#include <unistd.h>
#include <arpa/inet.h>
int main() {
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = htonl(INADDR_ANY);
server_addr.sin_port = htons(8888);
int serveraddr_len = sizeof(server_addr);
// 1.create socket
int server_fd = socket(AF_INET, SOCK_DGRAM, 0);
// 2.bind
bind(server_fd, (struct sockaddr*)&server_addr, serveraddr_len);
// 3.recv data
char buf[100];
recvfrom(server_fd, buf, 100, 0,
(struct sockaddr*)&server_addr, &serveraddr_len);
printf("UDP server get data from client: %s\n", buf);
// 4.close
close(server_fd);
return 0;
}
2. UDP 客戶端
sendto 函式是把 UDP 資料傳送給指定的地址,詳細使用方法參考 man sendto
。
// udp_client.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <netdb.h>
int main(int argc, char* argv[]) {
struct hostent* myhost = gethostbyname(argv[1]);
struct sockaddr_in client_addr;
client_addr.sin_family = AF_INET;
client_addr.sin_addr = *((struct in_addr*)(myhost->h_addr));
client_addr.sin_port = htons(8888);
// 1.socket
int server_fd = socket(AF_INET, SOCK_DGRAM, 0);
// 2.send data
sendto(server_fd, "Hello World", 11, 0,
(struct sockaddr*)&client_addr, sizeof(client_addr));
printf("UDP client send data ok.\n");
close(server_fd);
return 0;
}
3. 測試 UDP 通訊
先來編譯:
gcc -Wall udp_server.c -o udp_server
gcc -Wall udp_client.c -o udp_client
這裡會出現些警告,我們暫時忽略,因為不影響最後的結果,但是在實際工作中還是要注意警告!再來執行 UDP 伺服器端:
./udp_server
接著新開一個終端執行 UDP 客戶端:
./udp_client localhost
UDP client send data ok.
資料包傳送完成,回到 UDP 伺服器端:
UDP server get data from client: Hello World
伺服器端也成功接收了資料了,通訊成功啦!
結語
這篇部落格主要介紹了使用 Socket 提供的 API 進行 TCP 和 UDP 網路通訊的基本方法,並實際介紹了 2 個 demo,把這兩個 demo 弄清楚了,基本的 TCP,UDP 原理也就理解的差不多了,但在實際工作中我們主要還是使用優秀的開源網路庫,一般不會自己封裝。網路通訊是一個很大的主題,很多細節沒有介紹到,有興趣可以檢視 「計算機網路」 和 「unix 網路程式設計」 這兩本經典書籍。
那我們下次見,謝謝你的閱讀 :)
本文原創釋出於微信公眾號「cdeveloper」,程式設計、職場,人生,關注並回復關鍵字「linux」、「機器學習」等獲取免費學習資料。