【Linux】Socket编程(基于实际工程分析)

本文以基础 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高性能服务器编程》等

相关推荐
runepic1 小时前
Python + PostgreSQL 批量图片分发脚本:分类、去重、断点续拷贝
服务器·数据库·python·postgresql
天才程序YUAN1 小时前
从零开始、保留 Windows 数据、安装Ubuntu 22.04 LTS双系统
linux·windows·ubuntu
Evan芙1 小时前
Rocky Linux 9 网卡改名及静态IP地址配置完整步骤
linux·网络·智能路由器
Zeku1 小时前
20251125 - 韦东山Linux第三篇笔记【上】
linux·笔记·单片机
企鹅侠客2 小时前
Linux性能调优 详解磁盘工作流程及性能指标
linux·运维·服务器·性能调优
icy、泡芙2 小时前
TF卡---热插拔
linux·驱动开发
企鹅侠客2 小时前
Linux性能调优 再谈磁盘性能指标和进程级IO
linux·运维·服务器·性能调优
虚伪的空想家2 小时前
云镜像,虚拟机镜像怎么转换成容器镜像
服务器·docker·容器·k8s·镜像·云镜像·虚机
wdfk_prog3 小时前
[Linux]学习笔记系列 -- [block][mq-deadline]
linux·笔记·学习