本文以基础 TCP 通信程序为切入点,系统介绍Socket编程的核心思想与常用函数,最后结合实际工程场景分析。
1、Socket编程介绍
Socket 编程,是网络编程中最核心、最基础的实现方式之一,特指通过 "socket(套接字)" 这一技术接口实现的网络通信编程。
1.1 socket 编程常用函数
下表仅说明常用函数的基本用法,具体语法可以参考1.2例子中的注释
|---------|----------------------------------------------------------------------|
| socket | 创建一个套接字(套接字提供了一种类似于文件描述符的接口,可以像操作文件一样操作网络连接) |
| bind | 将地址绑定到一个套接字(指定套接字要关联的本地 IP 地址和端口号,让套接字与特定的网络地址建立对应关系) |
| listen | 宣告服务器可以接受连接请求(使服务器套接字进入监听状态,准备接收客户端连接,并设定允许排队等待处理的最大连接数量) |
| accept | 获得连接请求,并且建立连接(在服务器端阻塞等待客户端的连接请求,收到请求后创建新套接字用于与该客户端通信,原监听套接字继续等待其他请求) |
| send | 向连接的另一端发送数据(通过已建立连接的套接字,将数据传输到连接的对端,实现数据的发送操作) |
| recv | 接受另一端发送的数据(通过已建立连接的套接字,从连接的对端接收传来的数据,获取对方发送的信息) |
| connect | 建立一个与服务器通信的连接(在客户端调用,用于与指定服务器的套接字(通过 IP 和端口标识)建立网络连接,为后续数据交互奠定基础) |
1.2 TCP通信的例子

server.c
cpp
#include <sys/types.h> /* 引入系统数据类型定义,如 size_t 等 */
#include <sys/socket.h> /* 提供 socket 相关函数和数据结构 */
#include <string.h> /* 提供字符串操作函数,如 memset */
#include <netinet/in.h> /* 提供网络地址结构,如 sockaddr_in */
#include <arpa/inet.h> /* 提供网络地址转换函数,如 inet_ntoa */
#include <unistd.h> /* 提供标准符号常量和类型定义,如 close 函数 */
#include <stdio.h> /* 提供标准输入输出函数,如 printf */
#include <signal.h> /* 提供信号处理函数,如 signal */
/* 定义服务器使用的端口号 */
#define SERVER_PORT 8888 //端口号是一个 16 位的无符号整数,范围是从 0 到 65535。根据用途和权限,端口号被分为好几类,49152 到 65535 范围的端口号是可以自由使用的
/* 定义了服务器允许排队等待处理的 "未完成连接请求" 的最大数量。。当服务器的连接请求超过这个数量时,新的连接请求会被拒绝。*/
#define BACKLOG 10
int main(int argc, char **argv)
{
int iSocketServer; /* 用于存储服务器端套接字 */
int iSocketClient; /* 用于存储客户端套接字 */
struct sockaddr_in tSocketServerAddr;/* 服务器端地址结构 */
struct sockaddr_in tSocketClientAddr;/* 客户端地址结构 */
int iRet; /* 用于存储函数返回值 */
int iAddrLen; /* 存储地址结构的长度 */
int cnt; /* 用于计数接收消息的次数 */
int iRecvLen; /* 存储接收数据的长度 */
unsigned char ucRecvBuf[1000]; /* 接收缓冲区,最大接收 1000 字节数据 */
int iClientNum = -1; /* 客户端编号,初始值为 -1 */
/* 忽略子进程结束信号SIGCHLD,防止僵尸进程
* 当子进程终止(退出)或者被暂停时,操作系统会向父进程发送这个信号。
* 僵尸进程(Zombie Process)是计算机操作系统中的一种特殊进程状态。它是指已经完成执行(退出)的子进程,但其父进程尚未读取它的状态信息。
* 由于父进程尚未处理子进程的退出状态,子进程的资源(如进程表项、状态信息等)仍然保留在系统中,无法被释放,从而形成僵尸进程。
* 第二个参数SIG_IGN,表示忽略信号SIGCHLD。当父进程显式地将 SIGCHLD 设置为忽略时,操作系统会自动清理子进程的资源。所以可以防止僵尸进程的产生*/
signal(SIGCHLD, SIG_IGN);
/* 创建服务器端套接字 */
iSocketServer = socket(AF_INET, SOCK_STREAM, 0); //三个参数分别表示 IPV4 TCP 使用默认协议
if (-1 == iSocketServer)
{
printf("socket error!\n"); /* 如果创建失败,打印错误信息 */
return -1; /* 并退出程序 */
}
/* 初始化服务器端地址结构 */
tSocketServerAddr.sin_family = AF_INET; /* 使用 IPv4 地址族 */
tSocketServerAddr.sin_port = htons(SERVER_PORT); /* 设置服务器端口,将主机字节序转换为网络字节序。htons用于字节序转换*/
tSocketServerAddr.sin_addr.s_addr = INADDR_ANY; /* tSocketServerAddr.sin_addr.s_addr = INADDR_ANY 是核心设置:INADDR_ANY 是一个特殊的常量(值为 0),
表示服务器不绑定到某个特定的本地 IP 地址,而是监听所有可用的网络接口
例如:本地回环接口 127.0.0.1、服务器的物理网卡 IP(如 192.168.1.100)等*/
memset(tSocketServerAddr.sin_zero, 0, 8); /* 将 sin_zero 字段清零
sin_zero是一个长度为 8 字节的填充数组,通常未使用,但需要将其清零以确保结构体的正确对齐。
memset多用于清空数组。0表示置零清空,8是原数组长度(保留原数组长度)*/
/* 将服务器端地址绑定到套接字 */
iRet = bind(iSocketServer, (const struct sockaddr *)&tSocketServerAddr, sizeof(struct sockaddr));
if (-1 == iRet)
{
printf("bind error!\n"); /* 如果绑定失败,打印错误信息 */
return -1; /* 并退出程序 */
}
/* 开始监听连接请求 */
iRet = listen(iSocketServer, BACKLOG); //listen 宣告服务器可以接受连接请求
if (-1 == iRet)
{
printf("listen error!\n"); /* 如果监听失败,打印错误信息 */
return -1; /* 并退出程序 */
}
/* 主循环,等待客户端连接 */
while (1)
{
iAddrLen = sizeof(struct sockaddr); /* 初始化地址结构长度 */
/* 接受客户端连接请求 */
iSocketClient = accept(iSocketServer, (struct sockaddr *)&tSocketClientAddr, &iAddrLen);
/* 如果成功接受连接 */
if (-1 != iSocketClient)
{
iClientNum++; /* 客户端编号加 1 */
/* 打印客户端连接信息 */
printf("Get connect from client %d : %s\n", iClientNum, inet_ntoa(tSocketClientAddr.sin_addr));
/* 创建子进程处理客户端请求。子进程是通过 fork() 函数创建的,fork字面上理解,就是分叉的意思
* 子进程是当前进程(父进程)的副本,它继承了父进程的大部分资源,包括文件描述符、环境变量等。
* 在使用 fork() 创建子进程时,if 语句用于区分当前代码是在父进程中运行还是在子进程中运行。
* fork() 的返回值决定了代码的执行路径,从而实现父子进程的分离。
* 如果 fork() 返回 0,表示当前代码(if循环中的代码)运行在子进程中。
* 如果 fork() 返回一个非零值(子进程的 PID),表示当前代码运行在父进程中。*/
if (!fork()) //fork() 返回 0 表示子进程创建成功。if 内部的代码逻辑由子进程单独执行,用于处理与对应客户端的持续通信。
{
cnt = 0; /* 初始化消息计数器 */
/* 子进程的主循环,处理客户端数据 */
while (1)
{
/* 接收客户端发送的数据
要从中接收数据的套接字 指向缓冲区的指针(存储接收到的数据) 可以接收的最大数据量 阻塞直到数据接收完成 */
iRecvLen = recv(iSocketClient, ucRecvBuf, 999, 0);
if (iRecvLen <= 0)
{
close(iSocketClient); /* 如果接收失败或连接关闭,关闭客户端套接字 */
return -1; /* 并退出子进程 */
}
else
{
ucRecvBuf[iRecvLen] = '\0'; /* 在接收数据后添加字符串结束符 */
/* 打印接收到的消息 */
printf("Get Msg From Client %d: %s\n", iClientNum, ucRecvBuf);
/* 构造回复消息 */
sprintf(ucRecvBuf, "Get Msg cnt %d", cnt++);
/* 将回复消息发送回客户端 */
send(iSocketClient, ucRecvBuf, strlen(ucRecvBuf), 0);
}
}
}
}
}
/* 关闭服务器端套接字 */
close(iSocketServer);
return 0;
}
说明1:父子进程
通过 if 实现父进程与子进程执行逻辑的分离
- 当代码执行到 fork() 时,系统会创建一个与父进程几乎完全相同的子进程(包括当前代码的执行上下文)。
- fork() 函数有两个返回值:在父进程中,fork() 返回子进程的进程 ID(PID,一个非 0 的整数);在子进程中,fork() 返回 0。
- 因此,if (!fork()) 这个判断会在父进程和子进程中分别执行:父进程中,fork() 返回非 0 值,所以不进入 if 内部;子进程中,fork() 返回 0,所以进入 if 内部执行代码。
说明2:socket参数
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:指定通信协议族(Address Family)。常见的值包括:
- AF_INET:用于 IPv4 网络通信。这是最常用的地址族。
- AF_INET6:用于 IPv6 网络通信。
- AF_UNIX 或 AF_LOCAL:用于本地进程间通信。
type:指定套接字的类型。常见的值包括:
- SOCK_STREAM:面向连接的、可靠的字节流套接字(TCP)。
- SOCK_DGRAM:无连接的、不可靠的报文套接字(UDP)。
- SOCK_RAW:原始套接字,用于直接访问底层协议。
- protocol:指定使用的协议。通常设置为 0,表示使用默认协议(如 TCP 或 UDP)。如果需要指定特定协议,可以使用协议号(如 IPPROTO_TCP 或 IPPROTO_UDP)。
返回值: - 如果成功,socket() 函数返回一个非负整数,表示新创建的套接字描述符
- 如果失败,返回 -1
client.c
cpp
//---------------------------------------------------------------------------------------------------------------
#include <sys/types.h> /* 引入系统数据类型定义 */
#include <sys/socket.h> /* 提供 socket 相关函数和数据结构 */
#include <string.h> /* 提供字符串操作函数 */
#include <netinet/in.h> /* 提供网络地址结构 */
#include <arpa/inet.h> /* 提供网络地址转换函数 */
#include <unistd.h> /* 提供标准符号常量和类型定义 */
#include <stdio.h> /* 提供标准输入输出函数 */
/* 定义服务器使用的端口号 */
#define SERVER_PORT 8888
int main(int argc, char **argv)
{
int iSocketClient; /* 用于存储客户端套接字 */
struct sockaddr_in tSocketServerAddr; /* 服务器端地址结构 */
int iRet; /* 用于存储函数返回值 */
unsigned char ucSendBuf[1000]; /* 发送缓冲区,最大 1000 字节 */
int iSendLen; /* 发送数据的长度 */
int iRecvLen; /* 接收数据的长度 */
/* 检查命令行参数是否正确 */
if (argc != 2)
{
printf("Usage:\n");
printf("%s <server_ip>\n", argv[0]); /* 显示程序名和需要的参数 */
return -1; /* 参数错误,退出程序 */
}
/* 创建客户端套接字 */
iSocketClient = socket(AF_INET, SOCK_STREAM, 0);
/* 初始化服务器端地址结构 */
tSocketServerAddr.sin_family = AF_INET; /* 使用 IPv4 地址族 */
tSocketServerAddr.sin_port = htons(SERVER_PORT); /* 设置服务器端口,主机字节序转网络字节序 */
// tSocketServerAddr.sin_addr.s_addr = INADDR_ANY; /* 注释掉,因为客户端需要指定服务器 IP */
/* 将命令行参数中的服务器 IP 地址转换为网络地址结构 */
if (0 == inet_aton(argv[1], &tSocketServerAddr.sin_addr))
{
printf("invalid server_ip\n"); /* 如果 IP 地址无效,提示用户 */
return -1; /* 退出程序 */
}
memset(tSocketServerAddr.sin_zero, 0, 8); /* 将 sin_zero 字段清零 */
/* 连接到服务器 */
iRet = connect(iSocketClient, (const struct sockaddr *)&tSocketServerAddr, sizeof(struct sockaddr));
if (-1 == iRet)
{
printf("connect error!\n"); /* 如果连接失败,提示用户 */
return -1; /* 退出程序 */
}
/* 主循环,发送和接收数据 */
while (1)
{
/* 从标准输入读取用户输入的数据 */
if (fgets(ucSendBuf, 999, stdin))
{
/* 将数据发送到服务器 */
iSendLen = send(iSocketClient, ucSendBuf, strlen(ucSendBuf), 0);
if (iSendLen <= 0)
{
close(iSocketClient); /* 如果发送失败,关闭套接字 */
return -1; /* 退出程序 */
}
/* 接收服务器的响应 */
iRecvLen = recv(iSocketClient, ucSendBuf, 999, 0);
if (iRecvLen > 0)
{
ucSendBuf[iRecvLen] = '\0'; /* 在接收数据后添加字符串结束符 */
printf("From server: %s\n", ucSendBuf); /* 打印服务器的响应 */
}
}
}
return 0; /* 程序正常结束 */
}
说明1:fget函数
#include <stdio.h>
char* fgets(char *str, int size, FILE *stream);
- str:指向字符数组的指针,用于存储读取的数据。
- size:指定要读取的最大字符数(包括换行符和字符串结束符 \0)。
- stream:指向 FILE 类型的指针,表示输入流。常见的输入流包括:
- stdin:标准输入流,通常用于从键盘读取输入。
- FILE*:文件指针,用于从文件中读取数据。
返回值 - 如果成功,返回指向 str 的指针。
- 如果到达文件末尾(EOF)或发生错误,返回 NULL。
标准输入流的特点
- 行缓冲:fgets会等待用户按下回车键才读取整行内容
- 包含换行符:输入的内容会包含最后的换行符 \n
- 阻塞等待:程序会暂停在 fgets 处,直到有输入内容
1.3 UDP通信的例子
服务器端通信程序的编写,一般有6个步骤。
1、定义服务器需要监听的端口和IP
2、创建一个套接字(参数决定套接字的类型,如TCP、UDP等)
3、配置服务器地址结构体
4、将套接字绑定到指定的地址和端口
5、接收来自客户端的数据
6、关闭服务器套接字

server.c
cpp
#include <sys/types.h> /* 定义各种数据类型,如pid_t等 */
#include <sys/socket.h> /* 套接字相关函数和结构体 */
#include <string.h> /* 字符串操作函数 */
#include <netinet/in.h> /* Internet地址族相关定义 */
#include <arpa/inet.h> /* IP地址转换函数 */
#include <unistd.h> /* UNIX标准函数,如close() */
#include <stdio.h> /* 标准输入输出函数 */
#include <signal.h> /* 信号处理相关 */
#define SERVER_PORT 8888 /* 定义服务器监听端口号 */
int main(int argc, char **argv)
{
/* 变量定义 */
int iSocketServer; /* 服务器套接字文件描述符 */
struct sockaddr_in tSocketServerAddr; /* 服务器地址结构体 */
struct sockaddr_in tSocketClientAddr; /* 客户端地址结构体,用于接收数据时存储客户端地址 */
int iRet; /* 函数返回值临时存储 */
int iAddrLen; /* 地址结构体长度 */
int iRecvLen; /* 接收到的数据长度 */
unsigned char ucRecvBuf[1000]; /* 接收数据缓冲区,大小为1000字节 */
// SOCK_DGRAM: 数据报套接字(UDP)
iSocketServer = socket(AF_INET, SOCK_DGRAM, 0);
if (-1 == iSocketServer) /* 检查套接字创建是否成功 */
{
printf("socket error!\n"); /* 输出错误信息 */
return -1; /* 返回错误代码 */
}
/* 配置服务器地址结构体 */
tSocketServerAddr.sin_family = AF_INET; /* 地址族:IPv4 */
tSocketServerAddr.sin_port = htons(SERVER_PORT); /* 端口号:htons将主机字节序转换为网络字节序 */
tSocketServerAddr.sin_addr.s_addr = INADDR_ANY; /* IP地址:INADDR_ANY表示绑定到所有可用接口(0.0.0.0) */
memset(tSocketServerAddr.sin_zero, 0, 8); /* 将sin_zero字段清零,用于填充结构体对齐 */
/* 将套接字绑定到指定的地址和端口 */
iRet = bind(iSocketServer, (const struct sockaddr *)&tSocketServerAddr, sizeof(struct sockaddr));
if (-1 == iRet) /* 检查绑定是否成功 */
{
printf("bind error!\n"); /* 输出错误信息 */
return -1; /* 返回错误代码 */
}
/* 主循环:持续接收和处理客户端数据 */
while (1)
{
/* 接收来自客户端的数据
* 注意:iAddrLen在调用前必须是地址结构体的实际大小
* 调用后会被设置为发送方的地址结构体实际大小
*/
iAddrLen = sizeof(struct sockaddr); /* 设置地址结构体长度 */
iRecvLen = recvfrom(iSocketServer, ucRecvBuf, 999, 0, (struct sockaddr *)&tSocketClientAddr, &iAddrLen);
if (iRecvLen > 0) /* 检查是否成功接收到socker数据 */
{
ucRecvBuf[iRecvLen] = '\0'; /* 在接收到的数据末尾添加字符串结束符,便于打印 */
/* 打印接收到的消息和客户端信息
* inet_ntoa: 将网络字节序的IP地址转换为点分十进制字符串
* tSocketClientAddr.sin_addr: 客户端的IP地址
* ucRecvBuf: 接收到的数据(作为字符串处理)
*/
printf("Get Msg From %s : %s\n", inet_ntoa(tSocketClientAddr.sin_addr), ucRecvBuf);
}
/* 如果iRecvLen <= 0,表示接收失败或连接关闭,但UDP是无连接的,所以通常继续循环 */
}
/* 关闭服务器套接字 */
close(iSocketServer);
return 0;
}
说明1:recvfrom函数
iRecvLen = recvfrom(iSocketServer, ucRecvBuf, 999, 0,
(struct sockaddr *)&tSocketClientAddr, &iAddrLen);
- iSocketServer: 服务器套接字描述符
- ucRecvBuf: 接收数据缓冲区
- 999: 缓冲区最大可接收字节数(留1字节给字符串结束符)
- 0: 标志位,通常为0
- (struct sockaddr *)&tSocketClientAddr: 客户端地址结构体指针
- &iAddrLen: 地址结构体长度的指针
- 返回:实际接收到的数据字节数
client.c
cpp
#include <sys/types.h> /* 客户端程序 */
#include <sys/socket.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#define SERVER_PORT 8888 /* 定义服务器端口号 */
int main(int argc, char **argv)
{
int iSocketClient; /* 客户端套接字文件描述符 */
struct sockaddr_in tSocketServerAddr; /* 服务器地址结构体 */
int iRet; /* 用于存储函数返回值 */
unsigned char ucSendBuf[1000]; /* 发送缓冲区 */
int iSendLen; /* 实际发送的数据长度 */
int iAddrLen; /* 地址结构体长度 */
/* 检查命令行参数个数,需要传入服务器IP地址 */
if (argc != 2)
{
printf("Usage:\n");
printf("%s <server_ip>\n", argv[0]); /* 显示使用方法 */
return -1;
}
iSocketClient = socket(AF_INET, SOCK_DGRAM, 0);
/* 初始化服务器地址结构体 */
tSocketServerAddr.sin_family = AF_INET; /* IPv4地址族 */
tSocketServerAddr.sin_port = htons(SERVER_PORT); /* 端口号,htons将主机字节序转换为网络字节序 */
/* 将字符串形式的IP地址转换为网络字节序的二进制形式
* 如果转换失败(返回0),则报错退出
*/
if (0 == inet_aton(argv[1], &tSocketServerAddr.sin_addr))
{
printf("invalid server_ip\n");
return -1;
}
/* 清空sin_zero字段(通常用于填充,保证结构体大小与sockaddr相同) */
memset(tSocketServerAddr.sin_zero, 0, 8);
/* 不断从标准输入读取数据并发送给服务器 */
while (1)
{
if (fgets(ucSendBuf, 999, stdin))
{
iAddrLen = sizeof(struct sockaddr);
iSendLen = sendto(iSocketClient, ucSendBuf, strlen(ucSendBuf), 0,
(const struct sockaddr *)&tSocketServerAddr, iAddrLen);
/* 检查发送是否成功 */
if (iSendLen <= 0)
{
/* 发送失败,关闭套接字并退出程序 */
close(iSocketClient);
return -1;
}
}
}
return 0;
}
说明1:sendto函数
iSendLen = sendto(iSocketClient, ucSendBuf, strlen(ucSendBuf), 0,
(const struct sockaddr *)&tSocketServerAddr, iAddrLen);
- iSocketClient: 套接字描述符
- ucSendBuf: 要发送的数据缓冲区
- strlen(ucSendBuf): 要发送的数据长度
- 0: 标志位(通常为0)
- (const struct sockaddr *)&tSocketServerAddr: 目标服务器地址
- iAddrLen: 地址结构体长度
2、实际的UDP工程
2.1 工程UDP相关代码
工程是一个基于 UDP 协议的非阻塞服务器线程实现,核心功能是从A线程获取数据,接收客户端请求并按固定间隔向目标 IP 端口发送数据。通信部分可按7个步骤编写:
cpp
// --------------------------------------server---------------------------------------
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <chrono>
// 1、定义服务器需要监听的IP和端口
#define local_IP "xxx.xxx.xxx.xxx"//
#define target_IP "xxx.xxx.xxx.xxx"//目标ip
// #define PORT 1
#define PORT_local 8000
#define PORT_target 8080//目标端口
// #define PORT 8888
#define BUFFER_SIZE 1024
#define SEND_INTERVAL_MS 100 // 发送间隔100ms
int udp_thread() // server
{
// 创建非阻塞UDP套接字
// 2、创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); // 创建UDP套接字,AF_INET表示IPv4,SOCK_DGRAM表示数据报套接字(UDP)
fcntl(sockfd, F_SETFL, O_NONBLOCK); // 设置套接字为非阻塞模式,这样recvfrom和sendto不会阻塞线程
// 3、配置服务器地址结构体
sockaddr_in server_addr{}; // 创建服务器地址结构体,并初始化为0
server_addr.sin_family = AF_INET; // 设置地址族为IPv4
server_addr.sin_addr.s_addr = INADDR_ANY; // 绑定到所有可用接口(0.0.0.0)
server_addr.sin_port = htons(PORT_local); // 设置本地端口号,htons将主机字节序转换为网络字节序
// 4、将套接字绑定到指定的地址和端口
bind(sockfd, (sockaddr*)&server_addr, sizeof(server_addr)); // 将套接字绑定到指定地址和端口
// 配置一个明确的通信目标
sockaddr_in target_addr{}; // 创建目标地址结构体
memset(&target_addr, 0, sizeof(target_addr)); // 清空目标地址结构体
target_addr.sin_family = AF_INET; // 设置地址族为IPv4
target_addr.sin_port = htons(PORT_target); // 设置目标端口号
if (inet_pton(AF_INET, target_IP, &target_addr.sin_addr) <= 0) { // 将IP地址字符串转换为二进制格式
std::cerr << "Invalid target address" << std::endl; // 如果转换失败,输出错误信息
close(sockfd); // 关闭套接字
return -1; // 返回错误代码
}
// 初始化发送数据结构
// ...
sockaddr_in client_addr{}; // 创建客户端地址结构体,用于接收数据时存储客户端地址
socklen_t addr_len = sizeof(client_addr); // 客户端地址结构体长度
auto last_send_time = std::chrono::steady_clock::now(); // 记录最后一次发送时间,用于定时发送
char buffer[BUFFER_SIZE]; // 接收数据缓冲区
bool current_data_ready = false; // 当前数据就绪标志
std::vector<double> local_t; // 本地存储的位置向量
std::vector<std::vector<double>> local_R; // 本地存储的旋转矩阵
while (!exit_thread)
{
{ // 从AprilTag线程接收数据 - 使用互斥锁保护共享数据
std::lock_guard<std::mutex> lock(data_mutex); // 获取数据互斥锁,离开作用域自动释放(作用域是指lock所在的 {} 包围的范围)
current_data_ready = data_ready; // 更新当前数据就绪状态
if (data_ready && shared_t ) { // 如果数据就绪且共享位置指针有效
local_t = *shared_t; // 拷贝共享位置数据到本地变量
local_R = *shared_R; // 拷贝共享旋转矩阵数据到本地变量
data_ready = false; // 重置数据就绪标志,表示数据已被处理
}
}
// 5、接收来自客户端的数据
ssize_t recv_len = recvfrom(sockfd, buffer, BUFFER_SIZE, 0, // 非阻塞接收数据
(sockaddr*)&client_addr, &addr_len); // 同时获取客户端地址
// 如果接收到完整的数据包
if (recv_len == sizeof(getdata))
{
getdata received_data; // 创建接收数据结构
memcpy(&received_data, buffer, sizeof(getdata)); // 将缓冲区数据拷贝到结构体
// 验证数据包完整性
if (received_data.head == 0xxx && received_data.end == 0xxx && calculateGetChecksum(received_data) == received_data.checksum)
{
}
}
// 定时发送机制
auto now = std::chrono::steady_clock::now(); // 获取当前时间
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>( // 计算距离上次发送的时间间隔
now - last_send_time).count();
if (elapsed >= SEND_INTERVAL_MS) { // 如果达到发送间隔
// 6、发送数据到客户端
sendto(sockfd, &response_data, sizeof(response_data), 0, // 发送数据到目标地址
(sockaddr*)&target_addr, sizeof(target_addr));
last_send_time = now; // 更新最后发送时间
std::this_thread::sleep_for(100 * 1ms); // 短暂休眠100毫秒
}
usleep(1000); // 降低CPU占用,休眠1毫秒
}
// 7、关闭服务器套接字
close(sockfd); // 关闭套接字
return 0;
}
2.2 涉及的技术点
2.2.1 创建线程的基本方式
cpp
void helloFunction() {
std::cout << "Hello from thread!" << std::endl;
}
int main() {
std::thread t(helloFunction); // 创建线程并启动
t.join(); // 等待线程结束(函数执行完,则线程结束)
return 0;
}
一般来说,对于C++项目,使用std::thread创建线程。对于C项目,使用pthread_create创建线程。
2.2.2 队列与互斥锁的使用
队列(Queue)遵循先进先出(First-In, First-Out, FIFO)的原则。可以把它想象成现实生活中的排队队伍,最先来排队的人最先得到服务。
std::queue 的常用成员函数:
- push(element):入队。在队列的末尾添加一个新元素。
- pop():出队。移除队列的第一个(最前端的)元素。注意:这个函数不返回任何值!
- front():访问队首元素。返回对队列第一个元素的引用,但不移除它。
- back():访问队尾元素。返回对队列最后一个元素的引用,但不移除它。
- empty():判空。检查队列是否为空,如果是则返回 true。
- size():获取大小。返回队列中元素的数量。
在多线程环境下,只要有多个线程需要访问同一个队列,就必须使用互斥锁(或其他同步机制)来保护它。
如果不对队列的访问进行保护,就会引发竞态条件,导致各种无法预测的严重错误:数据损坏(就像两个人同时在同一个笔记本的同一行写字)、访问失效、queue.size()计数错误等
互斥锁(Mutex)的作用就像是给这个共享的队列加了一把锁。任何线程在访问队列之前,必须先获得这把锁。操作完成后,必须释放锁。同一时刻,只有一个线程能持有这把锁。这样就避免了所有竞态条件,保证了数据的原子性和一致性。
- 原子性:如银行转账,从账户 A 中扣除 100 元。向账户 B 中增加 100 元。步骤 1 和步骤 2 都成功完成才算满足原子性。否则A扣除成功,B增加失败,100元就消失了。
- 一致性:一个操作的执行,不能破坏系统的完整性约束。如银行转账,账号A B 相加的总金额是不变的。
队列和互斥锁使用的例子
cpp
#include <iostream>
#include <queue>
#include <thread>
#include <mutex>
#include <vector>
// 全局共享资源
std::queue<int> data_queue;
std::mutex mtx; // 用于保护 data_queue 的互斥锁
// 生产者线程函数
void producer() {
for (int i = 0; i < 10; ++i) {
// 在访问队列前,加锁
// std::lock_guard 会在创建时自动加锁,并在其作用域结束时自动解锁(常用方式)
{
std::lock_guard<std::mutex> lock(mtx);
std::cout << "Producer: Pushing " << i << std::endl;
data_queue.push(i);
} // 锁在这里被自动释放
// 模拟生产耗时
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
}
// 消费者线程函数
void consumer() {
for (int i = 0; i < 10; ++i) {
int value = -1;
// 在访问队列前,加锁
{
std::lock_guard<std::mutex> lock(mtx);
// 必须检查队列是否为空
if (!data_queue.empty()) {
value = data_queue.front();
data_queue.pop();
}
} // 锁在这里被自动释放
if (value != -1) {
std::cout << "Consumer: Popped " << value << std::endl;
}
// 模拟消费耗时
std::this_thread::sleep_for(std::chrono::milliseconds(150));
}
}
int main() {
// 创建并启动生产者和消费者线程
std::thread p1(producer);
std::thread c1(consumer);
// 等待线程执行完毕
p1.join();
c1.join();
return 0;
}
2.2.3 不进行内存对齐
1、为什么不进行内存对齐?让数据包的位置和大小严格符合协议规定
网络协议会定义非常精确的数据包格式,要求每个字段的位置和大小都必须严格按照协议规定。使用 #pragma pack(1) 可以确保结构体的内存布局与协议定义的二进制格式完全一致,方便直接将结构体发送到网络或从网络接收数据。
2、CPU默认是内存对齐的,这样可以提高内存读取效率
CPU 读取内存时,并不是一个字节一个字节地读,而是以 "块" 为单位(例如 4 字节或 8 字节)进行读取。如果一个数据跨越了两个这样的块,CPU 就需要进行两次读取操作,然后再将数据拼接起来,这会显著降低效率。通过内存对齐,编译器确保每个数据成员的起始地址都位于一个 "块" 的边界上,从而让 CPU 可以一次读取成功。
cpp
struct MyStruct {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
在一个默认对齐为 4 字节的 32 位系统上,上面的结构体在内存中的布局可能是这样的:
| a (1B) | 填充(3B) | b (4B) | c (1B) | 填充(3B) |
3、使用 #pragma pack(push, 1) 和 #pragma pack(pop) 取消结构体的内存对齐
cpp
#pragma pack(push, 1)
struct MyStruct {
char a; // 1 字节
int b; // 4 字节
char c; // 1 字节
};
#pragma pack(pop)
在这种情况下,结构体的内存布局会是紧密排列的:
| a (1B) | b (4B) | c (1B) |
2.2.4 智能指针的使用
1、为什么使用智能指针
在C++编程中,使用智能指针而不是原始指针(int*)的最主要和最直接的原因是:防止内存泄漏。在使用原始指针时,程序员需要手动通过 new 在堆上分配内存,并且必须在不再需要时通过 delete 来释放它。 这个过程很容易出错,从而导致常见的编程错误:
- 内存泄漏:忘记调用 delete 会导致分配的内存永远不会被释放。随着程序运行,不断累积的未释放内存会耗尽系统资源,导致程序变慢甚至崩溃。
- 重复释放:对同一个指针调用两次 delete 会导致未定义行为,通常会使程序崩溃。
2、RAII编程思想
智能指针通过RAII这个编程思想来解决这个问题:
- RAII(Resource Acquisition Is Initialization,资源获取即初始化)原理:该原则将资源的生命周期与一个栈上对象的生命周期绑定。 当对象被创建时,它在其构造函数中获取资源(如分配内存)。当对象离开其作用域时,它的析构函数会自动被调用,从而释放资源。
- 工作方式:智能指针本身是一个栈上对象,它封装了一个指向堆上资源的原始指针。 当智能指针对象(例如函数内的局部变量)离开作用域时,它的析构函数会自动调用 delete 来释放它所管理的内存。 这意味着不再需要手动写 delete,从而从根本上消除了忘记释放内存的风险。
3、三种智能指针
现代C++(C++11及之后版本)主要推荐使用三种智能指针:
- **std::unique_ptr:独占所有权的智能指针。**unique_ptr 保证在任何时候,只有一个智能指针实例可以拥有对动态分配对象的所有权。不能复制一个 unique_ptr,因为它会违反所有权唯一的原则。但所有权可以通过 std::move() 从一个 unique_ptr 转移到另一个。转移后,原来的 unique_ptr 将变为空指针。
- **std::shared_ptr:共享所有权的智能指针。**shared_ptr 允许多个指针实例共同拥有同一个对象。 它内部使用引用计数机制来实现:每当一个新的 shared_ptr 指向该对象时(例如通过复制构造),引用计数加1。每当一个 shared_ptr 被销毁或重置时,引用计数减1。当引用计数变为0时,表示没有任何 shared_ptr 指向该对象,对象会被自动删除。引用计数的增减是原子操作,是线程安全的
- std::weak_ptr:一种弱引用,用于辅助 std::shared_ptr(weak_ptr的作用就是解决shared_ptr之间的循环引用问题)。weak_ptr 是一种非拥有(non-owning)的智能指针,它指向由 shared_ptr 管理的对象,但不会增加其引用计数。它只是一种观察者,不会影响对象的生命周期。shared_ptr 的一个经典问题是循环引用,两个对象通过 shared_ptr 相互引用,导致它们的引用计数永远不会变为0,从而造成内存泄漏。weak_ptr 可以打破这种循环。
4、工程使用shared_ptr指针
cpp
// 定义一个 shared_ptr 类型的变量,没有指向任何对象。
std::shared_ptr<std::vector<double>> shared_t;
std::shared_ptr<std::vector<std::vector<double>>> shared_R;
// 线程A
// 从全局的 shared_t 或 shared_R 中读取数据。
// 通过解引用 (*shared_t) 将 vector 的内容拷贝到线程本地的 local_t 变量中。
int A_thread()
{
std::vector<double> local_t; // 本地存储的位置向量
std::vector<std::vector<double>> local_R; // 本地存储的旋转矩阵
while (!exit_thread)
{
// ...
local_t = *shared_t;
local_R = *shared_R;
// ...
}
}
// 线程B
// 在线程内部创建新的 std::vector 对象。
// 使用 std::make_shared 创建一个新的 std::shared_ptr 来管理这个新创建的 vector。
// 将这个新的 shared_ptr 赋值给全局的 shared_t 或 shared_R。
void B_thread()
{
while (!exit_thread)
{
// ...
// 创建一个 shared_ptr 并让它指向一个新创建的 std::vector 对象
auto local_t = std::make_shared<std::vector<double>>(3, 0.0);
auto local_R = std::make_shared<std::vector<std::vector<double>>>(3, std::vector<double>(3, 0.0));
shared_t = local_t;
shared_R = local_R;
// ...
}
}
最后,本文只是对Socket编程进行简单的认识,想要更深入的了解Socket编程可以参考:尹圣雨的《TCP/IP 网络编程》、游双《Linux高性能服务器编程》等