C/C++ Linux TCP Socket Server/Client 網路通訊教學

本篇 ShengYu 介紹如何寫 C/C++ Linux TCP Socket Server/Client 網路通訊程式,在這個網路盛行的時代,網路通訊已成為基礎,想要精通學習網路通訊必須先了解 TCP/IP 協定,其中又以 TCP 通訊最常被使用,TCP 通訊程式通常分成伺服器端與客戶端的兩部份程式,接下來教學內容將介紹如何使用 socket API 來搭建一個典型的 TCP 通訊程式,甚至可以寫出一個聊天室的程式,或者像 LINE 這樣的通訊程式。

以下 C/C++ Linux TCP 內容將分為幾部分,分別為:

  • 常見的 Socket API 函式 Overview 總覽
  • C/C++ Linux Socket TCP Server/Client 通訊流程
  • C/C++ Linux TCP Server 伺服器端程式 (Echo Sever)
  • C/C++ Linux TCP Client 客戶端程式 (傳送使用者的輸入)
  • C/C++ Linux TCP Client 客戶端程式 (定時傳送資料)
  • C/C++ Linux TCP 常見問題
  • Linux sokcet 的 ip 字串轉 sockaddr_in
  • Linux sokcet 的 sockaddr_in 轉 ip 字串

常見的 Socket API 函式 Overview 總覽

C/C++ Linux 的 socket 模組它提供了標準的 BSD Socket API,主要的 socket API 函式如下:
socket():建立 socket 與設定使用哪種通訊協定
bind(sock_fd, addr):將 socket 綁定到地址
listen(sock_fd, n):開始監聽 TCP 傳入連接,n 指定在拒絕連線前,操作系統可以掛起的最大連接數,該值最少為1,通常設為5就夠用了
accept(sock_fd, addr):等待連線,接受到 TCP 連線後,可以從 addr 得知連線客戶端的地址。
connect(address):連線到 address 處的 socket
recv():接收 TCP 資料
send():發送 TCP 資料
close():關閉 socket

C/C++ Linux Socket TCP Server/Client 通訊流程

以下 ShengYu 講解 C/C++ Linux TCP Server 端與 TCP Client 端的程式流程以及會如何使用這些 socket API,
TCP Server 的流程分為以下幾大步驟:

  1. 建立socket:sock_fd = socket(AF_INET, SOCK_STREAM, 0);,指定 AF_INET (Internet Protocol) family 的通訊協定,類型使用 SOCK_STREAM (Stream Socket) 也就是 TCP 傳輸方式
  2. 綁定 socket 到本地 IP 與 port:bind(sock_fd, ...)
  3. 開始監聽:listen(sock_fd, ...)
  4. 等待與接受客戶端的請求連線:new_fd = accept(sock_fd, ...)
  5. 接收客戶端傳來的資料:recv(new_fd, ...)
  6. 傳送給對方發送資料:send(new_fd, ...)
  7. 傳輸完畢後,關閉 socket:close(new_fd)

TCP Client 的流程分為以下幾大步驟:

  1. 建立 socket:sock_fd = socket(AF_INET, SOCK_STREAM, 0);
  2. 連線至遠端地址:connect(sock_fd, ...)
  3. 傳送資料:send(sock_fd, ...)
  4. 接收資料:recv(sock_fd, ...)
  5. 傳輸完畢後,關閉 socket:close(sock_fd)

以上是 TCP Server/Client 通訊的重點流程,實際的 C/C++ Linux socket API 用法與範例詳見下列章節,接下來就來看看怎麼寫 TCP Server/Client 通訊程式吧!

C/C++ Linux TCP Server 伺服器端程式 (Echo Sever)

這邊 ShengYu 就開始介紹怎麼寫 C/C++ Linux TCP Server 程式,下列範例這是一個典型的 Echo Server,Echo Server 就是收到什麼資料就回覆什麼資料,很簡單吧!
跟網路上其他範例不同的是此範例建立連線後不是傳輸一次資料就關閉連線,而是使用迴圈可以一直傳輸資料直到客戶端不想傳關閉連線為止,並且伺服器端再次地等待新的客戶端連線來服務。

如下例所示,伺服器端一開始建立 socket,用 bind() 綁定,這裡是使用 0.0.0.0, port 為 7000
使用 listen() 開始監聽,上限連線數為5,之後進入主迴圈,accept() 等待接受客戶端的連線請求,
一旦有客戶端連線的話,就會從 accept() 繼續往下執行,
之後是另一個迴圈來服務這個連線,不斷地從這個連線 recv 接收資料與 send 傳送資料,
如果 recv() 的回傳值為0,表示客戶端已斷開連線,此時我們也關閉這個連線,
之後回到 accept() 等待新的客戶端連線,等到新的客戶端連線連上便跟之前的流程一樣,這樣便是一個完整的 C/C++ Linux TCP 伺服器程式。

cpp-linux-tcp-socket-server.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// g++ cpp-linux-tcp-socket-server.cpp -o server
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

const char* host = "0.0.0.0";
int port = 7000;

int main()
{
int sock_fd, new_fd;
socklen_t addrlen;
struct sockaddr_in my_addr, client_addr;
int status;
char indata[1024] = {0}, outdata[1024] = {0};
int on = 1;

// create a socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd == -1) {
perror("Socket creation error");
exit(1);
}

// for "Address already in use" error message
if (setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int)) == -1) {
perror("Setsockopt error");
exit(1);
}

// server address
my_addr.sin_family = AF_INET;
inet_aton(host, &my_addr.sin_addr);
my_addr.sin_port = htons(port);

status = bind(sock_fd, (struct sockaddr *)&my_addr, sizeof(my_addr));
if (status == -1) {
perror("Binding error");
exit(1);
}
printf("server start at: %s:%d\n", inet_ntoa(my_addr.sin_addr), port);

status = listen(sock_fd, 5);
if (status == -1) {
perror("Listening error");
exit(1);
}
printf("wait for connection...\n");

addrlen = sizeof(client_addr);

while (1) {
new_fd = accept(sock_fd, (struct sockaddr *)&client_addr, &addrlen);
printf("connected by %s:%d\n", inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));

while (1) {
int nbytes = recv(new_fd, indata, sizeof(indata), 0);
if (nbytes <= 0) {
close(new_fd);
printf("client closed connection.\n");
break;
}
printf("recv: %s\n", indata);

sprintf(outdata, "echo %s", indata);
send(new_fd, outdata, strlen(outdata), 0);
}
}
close(sock_fd);

return 0;
}

如果 Server 伺服器端不正常關閉後再次啟動時可能會遇到 Binding error: Address already in use 這種錯誤訊息的話,那麼你可以在 bind() 之前設定 REUSEADDR 可以解決這個問題,

1
2
int on = 1;
setsockopt(sock_fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(int));

C/C++ Linux TCP Client 客戶端程式 (傳送使用者的輸入)

先用一個終端機來啟動前述的 TCP 伺服器端的程式,接著再用另一個終端機執行 TCP 客戶端的程式。C/C++ Linux TCP Client 範例如下,這邊要示範的是傳送使用者的輸入訊息,將使用者的輸入訊息傳送給伺服器端,通常應用於一般聊天軟體上,學習之後就可以寫一個簡單的聊天軟體了。

如下例所示,客戶端一開始建立 socket,之後 connect() 連線伺服器主機的 host 與 port,
之後進入主迴圈,不斷地傳送使用者的輸入,這邊是使用 gets() 取得使用者輸入的資料,也可以使用 scanf()fgets() 等函式,
使用者輸入完後按下 Enter 便會將資料發送給伺服器端,接著等待伺服器端傳送資料,接收到來自伺服器端的資料就把它印出來,

cpp-linux-tcp-socket-client.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// g++ cpp-linux-tcp-socket-client.cpp -o client
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

const char* host = "0.0.0.0";
int port = 7000;

int main()
{
int sock_fd;
struct sockaddr_in serv_name;
int status;
char indata[1024] = {0}, outdata[1024] = {0};

// create a socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd == -1) {
perror("Socket creation error");
exit(1);
}

// server address
serv_name.sin_family = AF_INET;
inet_aton(host, &serv_name.sin_addr);
serv_name.sin_port = htons(port);

status = connect(sock_fd, (struct sockaddr *)&serv_name, sizeof(serv_name));
if (status == -1) {
perror("Connection error");
exit(1);
}

while (1) {
printf("please input message: ");
gets(outdata);
printf("send: %s\n", outdata);
send(sock_fd, outdata, strlen(outdata), 0);

int nbytes = recv(sock_fd, indata, sizeof(indata), 0);
if (nbytes <= 0) {
close(sock_fd);
printf("server closed connection.\n");
break;
}
printf("recv: %s\n", indata);
}

return 0;
}

以下示範一下程式的啟動過程,過程中我在客戶端輸入了兩次的訊息,最後按 ctrl+c 結束了程式,
客戶端輸出結果如下,

client
1
2
3
4
5
6
7
8
$ ./client
please input message: hello
send: hello
recv: echo hello
please input message: hello tcp
send: hello tcp
recv: echo hello tcp
please input message: ^C

伺服器端輸出結果如下,

server
1
2
3
4
5
6
7
$ ./server
server start at: 0.0.0.0:7000
wait for connection...
connected by 127.0.0.1:59362
recv: hello
recv: hello tcp
client closed connection.

C/C++ Linux TCP Client 客戶端程式 (定時傳送資料)

前一章節示範了 Echo Sever 與 Client 通訊程式,這時可以打鐵趁熱,除了前一章節 TCP Client 使用者手動輸入的情形之外,這邊也介紹另一種客戶端會定時地傳送資料給伺服器端,同時這也適用於各種通訊情形。

步驟跟前一章節 TCP Client 幾乎相同,傳輸字串為 'heartbeat',這邊傳送後使用 sleep(1) 來讓程式睡眠1秒,之後再繼續傳送資料,進而達成定時傳送的功能,

cpp-linux-tcp-socket-client-heartbeat.cpp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// g++ cpp-linux-tcp-socket-client-heartbeat.cpp -o client
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

const char* host = "0.0.0.0";
int port = 7000;

int main()
{
int sock_fd;
struct sockaddr_in serv_name;
int status;
char indata[1024] = {0}, outdata[1024] = {0};

// create a socket
sock_fd = socket(AF_INET, SOCK_STREAM, 0);
if (sock_fd == -1) {
perror("Socket creation error");
exit(1);
}

// server address
serv_name.sin_family = AF_INET;
inet_aton(host, &serv_name.sin_addr);
serv_name.sin_port = htons(port);

status = connect(sock_fd, (struct sockaddr *)&serv_name, sizeof(serv_name));
if (status == -1) {
perror("Connection error");
exit(1);
}

while (1) {
strcpy(outdata, "heartbeat");
printf("send: %s\n", outdata);
send(sock_fd, outdata, strlen(outdata), 0);

int nbytes = recv(sock_fd, indata, sizeof(indata), 0);
if (nbytes <= 0) {
close(sock_fd);
printf("server closed connection.\n");
break;
}
printf("recv: %s\n", indata);

sleep(1);
}

return 0;
}

客戶端輸出結果如下,

client
1
2
3
4
5
6
7
8
9
10
11
12
$ ./client
send: heartbeat
recv: echo heartbeat
send: heartbeat
recv: echo heartbeat
send: heartbeat
recv: echo heartbeat
send: heartbeat
recv: echo heartbeat
send: heartbeat
recv: echo heartbeat
^C

伺服器端輸出結果如下,

server
1
2
3
4
5
6
7
8
9
10
$ ./server
server start at: 0.0.0.0:7000
wait for connection...
connected by 127.0.0.1:33862
recv: heartbeat
recv: heartbeat
recv: heartbeat
recv: heartbeat
recv: heartbeat
client closed connection.

C/C++ Linux TCP 常見問題

在 TCP 的傳輸裡,為什麼伺服器還要回傳給客戶端?
因為這只是個示範用的通訊程式,讓你了解通訊的過程,就像打電話或者跟別人對話一樣,你一句我一句的來回互動,你可以根據實際的需求而修改程式,你也可以改成一直傳,例如客戶端一直傳送,伺服器一直接收。

為什麼 recv 還沒收到資料前會卡住一直等?
因為預設是 blocking 非阻塞模式,recv 還沒收到資料前會卡住一直等,沒法做其他事情,直到 recv 接收到資料才會從 recv 函式返回,解決辦法是改用 Non-blocking 非阻塞模式,Non-blocking 模式是這次沒接收到資料就會從 recv 函式返回,接著繼續往下執行;另一個解決方式是另外建立執行緒去做其他事情。

Linux sokcet 的 ip 字串轉 sockaddr_in

在 Linux 中 ip 字串轉換成 sockaddr_in 結構可用 inet_aton (ipv4 only)跟 inet_pton (ipv4 & ipv6) 兩個 API 達成,但在 windows 中沒有 inet_aton,而是 inet_addr (ipv4) 可用,所以用 inet_aton 的缺點是跨平台的話還要在修改這部分的程式碼,

inet_aton 用法如下,

1
2
3
const char* host = "0.0.0.0";
struct sockaddr_in my_addr;
inet_aton(host, &my_addr.sin_addr);

所以使用 inet_pton 的話,好處就是 linux 跟 windows 都可以通用,所以基本上在 linux 中是建議使用 inet_pton,

inet_pton 使用方法如下,

1
2
3
const char* host = "0.0.0.0";
struct sockaddr_in my_addr;
inet_pton(AF_INET, host, &my_addr.sin_addr); // AF_INET -> ipv4

Linux sokcet 的 sockaddr_in 轉 ip 字串

在 Linux 中 要將 sockaddr_in 結構轉換成 ip 字串的話可用 inet_ntoa (ipv4 only)跟 inet_ntop (ipv4 & ipv6) 兩個 API 達成,

inet_ntoa 用法如下,

1
2
3
struct sockaddr_in client_addr;
char *ip;
ip = inet_ntoa(client_addr.sin_addr)

inet_ntop 用法如下,

1
2
3
struct sockaddr_in client_addr;
char ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, ip, sizeof(ip));

以上就是 C/C++ Linux TCP Socket Server/Client 網路通訊教學,
如果你覺得我的文章寫得不錯、對你有幫助的話記得 Facebook 按讚支持一下!