TCP协议(Transmission Control Protocol)是TCP/IP协议中很重要的一个协议,是一种面向连接的、可靠的、基于字节流的传输层通信协议。它是一种端到端的协议,即它只在通信的两个端点之间起作用,并且不保证数据包的交付。TCP协议是一种可靠的协议,它通过确认和重传机制来保证数据传输的可靠性。
本章主要介绍如下内容:
- 套接字编程的基础知识的部分,介绍套接字编程中经常使用的套接字地址结构,对内核和应用层之间的内存数据传递方式进行鉴定的介绍
- TCP网络变成的流程部分,简单介绍TCP套接字服务器、客户端的编程框架,对socket()、bind()、listen()、accept()、connect()、close()函数进行介绍,并提及如何使用read()和write()函数进行数据的读取和发送
- 通过一个简单的服务器/客户端的例子介绍TCP网络编程的基本流程和代码
- 介绍如何对信号的截取,特别是信号SIGPIPE和信号SIGINT。
文章目录
1. 套接字
1.1 什么是套接字?
套接字是一种用于在计算机网络中进行通信的编程接口。它提供了一种标准的方法,使不同计算机上的应用程序能够通过网络进行数据交换。套接字允许应用程序通过发送和接收数据报或流来实现网络通信。它们通常用于创建客户端-服务器模型,其中一个应用程序充当服务器,等待客户端的连接请求,而其他应用程序则充当客户端,向服务器发送请求并接收响应。套接字可以在不同的网络协议上操作,如TCP(传输控制协议)或UDP(用户数据报协议)。
套接字由IP地址和端口号组合而成,用于标识网络中的不同应用程序或服务。IP地址指定了计算机的网络地址,而端口号用于在计算机上唯一标识应用程序或服务。通过将数据发送到特定的IP地址和端口号,套接字可以将数据从一个应用程序传递到另一个应用程序。
总而言之,套接字是计算机网络通信的编程接口,它允许应用程序在网络上进行数据交换,并通过IP地址和端口号标识通信的目标。
1.2 套接字地址结构
套接字编程需要指定套接字的地址作为参数,不同的协议族有不同的地址结构定义方式。这些地址结构通常以sockaddr_开头,每一个协议族有一个唯一的后缀,例如对于以太网,其结构名称为sockaddr_in。
- 通用套接字数据结构
通用的套接字地址类型的定义如下,它可以在不同协议族之间进行强制转换。
struct sockaddr { //套接字地址结构
sa_family_t sa_family; //协议族
char sa_data[14]; //协议族数据
}
上述中的sa_family_t其实是unsigned_short类型。
1.3 实际使用时的套接字数据结构
在网络程序设计中所使用的的函数中几乎所有的套接字函数都用这个结构作为参数,例如bind()函数的原型为:
int bind(int sockfd, //套接字文件描述符
const struct sockaddr *my_addr, //套接字地址结构
socklen_t addrlen); //套接字地址结构的长度
套接字地址结构是在套接字编程中使用的数据结构,用于表示套接字的地址信息。不同的网络协议(如IPv4、IPv6)和套接字类型(如流套接字、数据报套接字)可能会有不同的地址结构。我将以IPv4套接字为例来详细介绍套接字地址结构。
在IPv4套接字编程中,套接字地址结构使用sockaddr_in结构体来表示,它定义在<netinet/in.h>头文件中。sockaddr_in结构体的定义如下:
struct sockaddr_in {
sa_family_t sin_family; // 地址族(AF_INET)
in_port_t sin_port; // 端口号
struct in_addr sin_addr; // IP地址
char sin_zero[8]; // 用于填充,保证与struct sockaddr结构体大小相同
};
在这个结构体中,有几个重要的字段:
- sin_family:表示地址族,对于IPv4套接字,该值应为AF_INET。
- sin_port:表示端口号,使用in_port_t数据类型表示,需要以网络字节序(大端序)存储。
- sin_addr:表示IP地址,是一个struct in_addr类型的结构体,它包含一个32位的IPv4地址。
- sin_zero:用于填充,使sockaddr_in结构体与通用的struct sockaddr大小相同。
要创建和设置套接字地址结构,可以按照以下步骤进行:
- 创建一个sockaddr_in结构体的实例。
- 将sin_family设置为AF_INET。
- 使用htons()函数将端口号转换为网络字节序,并将结果赋值给sin_port。
- 使用inet_pton()函数将IP地址字符串转换为二进制形式,并将结果赋值给sin_addr。
- 可选:使用memset()函数将sin_zero字段清零。
套接字地址结构的具体用途是在套接字函数中传递和解析地址信息,例如在bind()函数中绑定套接字地址、在connect()函数中连接到远程服务器、在accept()函数中接受客户端连接等。
需要注意的是,不同的网络协议和套接字类型可能使用不同的地址结构,因此在进行套接字编程时,需要根据具体的情况选择合适的地址结构,并正确设置和使用其中的字段。
1.4 用户层和内核层交互过程
套接字参数中有部分参数是需要用户传入的,这些参数用来与Linux内核进行通信,例如指向地址结构的指针。通常是采用内存复制的方法进行。
- 向内核传入数据的交互过程
向内核传入数据的函数有send()、bind()等,从内核得到数据的函数有accept()、recv()等。bind()函数向内核中传入的参数有套接字地址结构和结构的长度两个与地址结构有关的参数。 - 内核传出数据的交互过程
从内核得到数据的函数有accept()、recv()等,通过地址结构的长度和套接字地址结构指针进行地址结构参数的传出操作。
下面是套接字在用户层和内核层之间进行交互的一般过程:
3. 创建套接字:用户层程序通过调用套接字库中的函数(如socket())创建一个套接字。该函数会在内核层分配相应的资源,并返回一个套接字描述符给用户层程序。
-
设置套接字选项:用户层程序可以通过调用套接字库中的函数(如setsockopt())来设置套接字的选项和参数,例如设置发送和接收缓冲区大小、设置套接字重用等。
-
绑定地址:用户层程序可以通过调用套接字库中的函数(如bind())将一个地址(IP地址和端口号)绑定到套接字上。该操作通知内核该套接字可以使用指定的地址进行通信。
-
监听连接请求(可选):如果套接字用于服务器端,用户层程序可以通过调用套接字库中的函数(如listen())将套接字设置为监听状态,等待客户端的连接请求。
-
发送和接收数据:用户层程序通过调用套接字库中的函数(如send()和recv())来发送和接收数据。当用户层程序调用发送函数时,数据被传递给内核层,内核将数据封装成网络数据报并通过网络发送给目标主机。当接收函数被调用时,内核从网络接收数据报并将数据传递给用户层程序。
-
连接建立(可选):如果套接字用于客户端,用户层程序可以通过调用套接字库中的函数(如connect())向服务器发起连接请求。该请求将被内核层处理,与服务器建立网络连接。
-
销毁套接字:用户层程序在不需要套接字时,通过调用套接字库中的函数(如close())来关闭套接字。该操作会释放内核层分配的资源,并终止用户层与内核层的交互。
2. TCP网络编程流程
TCP网络编程是目前比较通用的方式,例如HTTP协议、FTP协议等很多广泛应用的协议均基于TCP协议。TCP编程主要为C/S模式,即客户端(C)、服务器(S)模式,这两种模式之间的程序设计流程存在很大的差别。
2.1 TCP网络编程架构
TCP网络编程有两种模式,一种是服务器模式,另一种是客户端模式。服务器模式创建一个服务程序,等待客户端用户的连接,接收到用户的连接请求后,根据用户的请求进行处理;客户端模式则根据目的服务器的地址和端口进行连接,向服务器发送请求并对服务器的响应进行数据处理。
2.1.1 服务器端的程序设计模式
在TCP网络编程中,服务器端的程序设计流程通常包括以下步骤:
-
创建套接字:使用socket()函数创建一个套接字,并指定协议类型为TCP。
-
绑定地址:使用bind()函数将服务器套接字与特定的IP地址和端口号绑定。这样客户端就可以通过指定该地址来连接服务器。
-
监听连接请求:使用listen()函数将服务器套接字设置为监听状态,开始监听客户端的连接请求。可以指定最大允许的连接队列长度。
-
接受连接:使用accept()函数接受客户端的连接请求。该函数会阻塞,直到有客户端连接到达,然后返回一个新的套接字,用于与客户端进行通信。
-
处理客户端请求:使用新的套接字与客户端进行通信。通过读取客户端发送的数据,执行相应的业务逻辑或服务处理。
-
发送响应数据:根据客户端请求的处理结果,使用send()函数向客户端发送响应数据。
-
关闭连接:在通信结束后,使用close()函数关闭与客户端的连接。
-
回到步骤4:服务器端可以继续接受新的客户端连接,并重复处理客户端请求的过程。
在服务器端程序中,通常需要使用多线程、线程池或异步I/O等技术来处理多个客户端的并发请求,提高服务器的并发性能。服务器端还需要处理异常情况,例如网络错误、连接超时、请求处理失败等,以确保服务器的稳定性和可靠性。
需要注意的是,上述流程是一个基本的设计流程,实际应用中可能会有更多的细节和复杂性,例如使用缓冲区管理数据、处理粘包和拆包问题、使用状态机来处理协议等。因此,在实际开发中需要根据具体需求和场景进行相应的设计和实现。
2.1.2 客户端的程序设计模式
在TCP网络编程中,客户端的程序设计流程通常包括以下步骤:
-
创建套接字:使用socket()函数创建一个套接字,并指定协议类型为TCP。
-
连接服务器:使用connect()函数连接到服务器。在connect()函数中指定服务器的IP地址和端口号。
-
发送请求数据:使用send()函数向服务器发送请求数据。将需要发送的数据写入发送缓冲区,并使用send()函数将数据发送给服务器。
-
接收响应数据:使用recv()函数从服务器接收响应数据。将接收缓冲区作为参数传递给recv()函数,该函数会阻塞,直到接收到数据或发生错误。
-
处理响应数据:根据接收到的响应数据,进行相应的处理逻辑,例如解析数据、显示结果等。
-
关闭连接:在通信结束后,使用close()函数关闭与服务器的连接。
需要注意的是,在实际应用中,可能需要处理网络错误、连接超时、数据粘包和拆包等问题,以确保客户端的稳定性和可靠性。此外,如果需要进行多次请求,可以将步骤3到步骤5放入一个循环中,循环发送请求并接收响应。
另外,对于并发请求的情况,客户端程序可以通过多线程或异步I/O等技术来同时处理多个请求,提高客户端的并发性能。在这种情况下,每个线程或异步任务都可以按照上述流程独立执行。
以上是基本的客户端程序设计流程,具体的实现可能会根据实际需求和场景进行调整和扩展。
2.1.3 客户端和服务器的交互过程
客户端与服务器在连接、读写数据、关闭过程中有交互过程。
- 客户端的连接过程,对服务器端是接收过程,在这个过程中客户端与服务器进行三次握手,建立TCP连接。建立TCP连接之后,客户端与服务器之间可以进行数据的交互。
- 客户端与服务器之间的数据交互是相对的过程,客户端的读数据过程对应了服务端的写数据过程,客户端的写数据过程对应服务器的读数据过程。
- 在服务器和客户端之间的数据交互完毕以后,关闭套接字连接。
2.2 函数分析
2.2.1 创建网络插口函数socket()
在网络编程中,使用socket()函数来创建一个网络套接字(socket)。套接字是用于进行网络通信的接口,可以与特定的IP地址和端口号相关联。以下是使用socket()函数创建套接字的一般形式:
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数说明:
- domain:指定套接字使用的协议族或地址族。常见的取值有AF_INET(IPv4)、AF_INET6(IPv6)等。
- type:指定套接字的类型,如SOCK_STREAM(流式套接字,用于TCP)、SOCK_DGRAM(数据报套接字,用于UDP)等。
- protocol:指定协议编号,通常为0表示自动选择合适的协议。
函数返回一个非负整数的套接字描述符(socket descriptor)作为结果,如果返回-1表示创建套接字失败,可以通过errno来获取具体的错误信息。
domain的值和含义
名称 | 含义 |
---|---|
PF_UNIX, PF_LOCAL | 本地通信 |
PF_INET | IPv4 Internet协议 |
PF_INET6 | IPv6 Internet协议 |
PF_IPX | IPX-Novel协议 |
PF_NETLINK | 内核用户界面设备 |
PF_X25 | ITU-T X.25 / ISO-8208协议 |
PF_AX25 | Amateur radio AX.25协议 |
PF_ATMPVC | 原始ATM PVC访问 |
PF_APPLETALK | Appletalk |
PF_PACKET | 底层包访问 |
以下是一个使用socket()函数创建TCP套接字的示例:
#include <sys/types.h>
#include <sys/socket.h>
#include <stdio.h>
int main() {
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd == -1) {
perror("socket creation failed");
return -1;
}
// 套接字创建成功,继续其他操作
return 0;
}
在示例中,使用AF_INET表示IPv4协议族,SOCK_STREAM表示流式套接字(用于TCP)。如果创建套接字失败,可以使用perror()函数打印错误信息。
2.2.2 绑定一个地址端口对bind()函数
bind()函数是用于将一个地址族中的特定地址赋给socket,例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。bind()函数的原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
其中,sockfd是调用socket()函数返回的socket文件描述符;addr是指向要绑定给sockfd的协议地址的指针;addrlen是协议地址的长度。
在使用bind()函数时,可以指定一个端口号,或指定一个IP地址,也可以两者都指定,还可以都不指定。服务器在启动时捆绑它们的众所周知端口。如果一个TCP客户或服务器未曾调用bind()捆绑一个端口,当调用connect()或listen()时,内核就要为相应的套接字选择一个临时端口。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <string.h>
#include <iostream>
using namespace std;
int main()
{
int server_fd;
struct sockaddr_in server_addr;
int opt = 1;
int addrlen = sizeof(server_addr);
// Creating socket file descriptor
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0)
{
perror("socket failed");
exit(EXIT_FAILURE);
}
// Forcefully attaching socket to the port 8080
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT,
&opt, sizeof(opt)))
{
perror("setsockopt");
exit(EXIT_FAILURE);
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
// Forcefully attaching socket to the port 8080
if (bind(server_fd, (struct sockaddr *)&server_addr,
sizeof(server_addr)) < 0)
{
perror("bind failed");
exit(EXIT_FAILURE);
}
return 0;
}
2.2.3 监听本地端口listen
监听本地端口是指在本地计算机上开启一个端口,以便其他计算机可以通过该端口与本地计算机进行通信。在C++中,可以使用socket函数创建一个套接字,然后使用bind函数将套接字绑定到本地IP地址和端口号上,最后使用listen函数监听该端口。下面是一个简单的例子:
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
using namespace std;
int main() {
int server_fd;
struct sockaddr_in server_addr;
// 创建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// 绑定IP地址和端口号
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));
// 监听端口
listen(server_fd, 10);
cout << "Listening on port 8080..." << endl;
return 0;
}
这个例子创建了一个套接字,将其绑定到本地IP地址127.0.0.1和端口号8080上,并监听该端口。当有其他计算机通过该端口与本地计算机进行通信时,该程序会接受连接请求并处理相应的数据。
listen函数有两个参数:第一个参数是套接字描述符,第二个参数是等待连接的队列的最大长度。当有客户端请求连接时,内核会将该连接放入等待队列中,如果队列已满,则客户端将收到一个错误,指示连接被拒绝。因此,第二个参数应该设置为足够大的值,以便在短时间内处理大量的连接请求。
listen()函数仅对类型为SOCK_STREAM或者SOCK_SEQPACKET的协议有效,例如,如果对一个SOCK_DGRAM的协议使用函数listen(),将会出现error应该为值EOPNOTSUPP,表示此socket支持listen()操作。大多数系统的设置为20,可以将其设置修改为5或者10,根据系统可承受负载或者应用程序的需求来确定。
2.2.4 接收一个网络请求accept()函数
当一个客户端的连接请求到达服务器主机侦听的端口时,此时客户端的连接会在队列中等待,直到使用服务器处理接收请求。
函数accept()成功执行后,会返回一个新的套接字文件描述符来表示客户端的连接,客户端连接的信息可以通过这个新描述符来获得。因此当服务器成功处理客户端的请求连接后,会有两个文件描述符,老的文件描述符表示正在监听的socket,新产生的文件描述符表示客户端的连接,函数send()和recv()通过新的文件描述符进行数据收发。
accept()函数介绍:
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
通过accept()函数可以得到成功连接客户端的IP地址、端口和协议族等信息,这个信息是通过参数addr获得的。当accept()函数返回的时候,会将客户端的信息存储在参数addr中。参数addrlen表示第2个参数(addr)所指向内容的长度,可以使用sizeof(struct sockaddr_in)来获得。需要注意的是,在accept中addrlen参数是一个指针而不是结构,accept()函数将这个指针传给TCP/IP协议栈。
accept()函数的返回值是新连接的客户端套接字文件描述符,与客户端之间的通信是通过accept()函数返回的新套接字文件描述符来进行的,而不是通过建立套接字时的文件描述符,这是在程序设计的时候需要注意的地方。
2.2.5 连接目标网络服务器 connect()函数
connect()函数的原型如下,其中的参数sockfd是建立套接字时返回的套接字文件描述符,它是由系统调用socket()函数返回的。参数serv_addr,是一个指向数据结构sockaddr的指针,其中包括客户端需要连接的服务器的目的端口和IP地址,以及协议类型。参数addrlen表示第二个参数内容的大小,可以使用sizeof(struct sockaddr)而获得,与bind()函数不同,这个参数是一个整型的变量而不是指针。
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *, int addrlen);
connect()函数用于建立与目标网络服务器的连接。当套接字处于未连接状态时,可以通过connect()函数来建立连接。该函数会阻塞进程,直到连接建立成功或失败。
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
int main() {
int sock = 0;
struct sockaddr_in serv_addr;
if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
std::cout << "\n Socket creation error \n";
return -1;
}
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(8080);
// 将IPv4地址从点分十进制转换为二进制格式
if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)<=0) {
std::cout << "\nInvalid address/ Address not supported \n";
return -1;
}
if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
std::cout << "\nConnection Failed \n";
return -1;
}
std::cout << "Connection established" << std::endl;
return 0;
}
2.2.6 写入数据函数write()
write()函数用于将数据写入文件或套接字。它的函数原型如下:
ssize_t write(int fd, const void *buf, size_t count);
其中,fd是文件描述符或套接字,buf是要写入的数据的指针,count是要写入的字节数。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) {
std::cerr << "Failed to open file" << std::endl;
return -1;
}
const char *buf = "Hello world!";
ssize_t n = write(fd, buf, strlen(buf));
if (n == -1) {
std::cerr << "Failed to write to file" << std::endl;
return -1;
}
close(fd);
return 0;
}
这个例子打开了一个名为test.txt的文件,并将字符串"Hello world!"写入该文件。它使用open()函数打开文件,然后使用write()函数将数据写入文件。最后,它使用close()函数关闭文件。
2.2.7 读取数据函数read()
read()函数用于从文件或套接字中读取数据。它的函数原型如下:
ssize_t read(int fd, void *buf, size_t count);
其中,fd是文件描述符或套接字,buf是要读取数据的缓冲区的指针,count是要读取的字节数。
#include <iostream>
#include <unistd.h>
#include <fcntl.h>
int main() {
int fd = open("test.txt", O_RDONLY);
if (fd == -1) {
std::cerr << "Failed to open file" << std::endl;
return -1;
}
char buf[1024];
ssize_t n = read(fd, buf, sizeof(buf));
if (n == -1) {
std::cerr << "Failed to read from file" << std::endl;
return -1;
}
close(fd);
std::cout << "Read " << n << " bytes: " << buf << std::endl;
return 0;
}
这个例子打开了一个名为test.txt的文件,并从该文件中读取数据。它使用open()函数打开文件,然后使用read()函数从文件中读取数据。最后,它使用close()函数关闭文件。
2.3 服务器/客户端的简单例子
// 服务器端
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <cstring>
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return -1;
}
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(server_fd, (sockaddr *) &server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to bind socket" << std::endl;
return -1;
}
if (listen(server_fd, 5) == -1) {
std::cerr << "Failed to listen on socket" << std::endl;
return -1;
}
sockaddr_in client_addr{};
socklen_t client_len = sizeof(client_addr);
int client_fd = accept(server_fd, (sockaddr *) &client_addr, &client_len);
if (client_fd == -1) {
std::cerr << "Failed to accept connection" << std::endl;
return -1;
}
char buffer[1024];
ssize_t n = read(client_fd, buffer, sizeof(buffer));
if (n == -1) {
std::cerr << "Failed to read from socket" << std::endl;
return -1;
}
close(client_fd);
close(server_fd);
std::cout << "Received " << n << " bytes: " << buffer << std::endl;
return 0;
}
// 客户端
#include <iostream>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <cstring>
int main() {
int client_fd = socket(AF_INET, SOCK_STREAM, 0);
if (client_fd == -1) {
std::cerr << "Failed to create socket" << std::endl;
return -1;
}
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "127.0.0.1", &server_addr.sin_addr);
if (connect(client_fd, (sockaddr *) &server_addr, sizeof(server_addr)) == -1) {
std::cerr << "Failed to connect to server" << std::endl;
return -1;
}
const char *message = "Hello from client";
ssize_t n = write(client_fd, message, strlen(message));
if (n == -1) {
std::cerr << "Failed to write to socket" << std::endl;
return -1;
}
close(client_fd);
return 0;
}