欢迎光临
我们一直在努力

Linux之网络socket编程(UDP协议)

一、网络通信的本质:跨主机的进程间通信

我们常说的 “网络通信”,本质上是跨主机的进程间通信(IPC)。

本地进程通信可以用管道、消息队列、共享内存,但这些方式都依赖本机内核,无法跨越网络。而 Socket(套接字)是 POSIX 标准定义的通用 IPC 方案:它既支持本地进程通信(Unix 域套接字),也支持跨网络的进程通信,这也是 Socket 逐渐替代 System V IPC 的核心原因。

通信的最终目的是把数据送达对方主机上的目标进程,而非仅仅送达主机。因此网络寻址需要两层标识:

  • IP 地址:标识全球互联网中唯一的一台主机;
  • 端口号:标识主机中唯一的一个网络通信端点(套接字)。
  • 1. 为什么不用 PID 标识通信进程?

    PID 是操作系统给进程分配的本地编号,无法直接用于网络寻址,核心原因有三点:

    • 并非所有进程都需要网络通信,PID 是全进程通用标识,用它做网络寻址会造成资源浪费;
    • 解耦合设计:PID 是操作系统领域的概念,端口号是网络协议栈的概念,二者独立演化,互不影响。如果操作系统 PID 机制变更,网络协议无需跟着修改;
    • 粒度不匹配:一个进程可以同时建立多个网络连接(比如浏览器同时打开多个网页),需要多个独立的通信端点,单个 PID 无法区分这些连接。
    2. 套接字地址与套接字的区别
    • 套接字地址(Socket Address):IP地址 + 端口号,在全网范围内唯一标识一个网络通信端点。
    • 套接字(Socket):操作系统内核提供的抽象对象,对应一个文件描述符。它包含了发送缓冲区、接收缓冲区、协议状态、地址信息等完整资源,是进程与协议栈交互的 “句柄”。

    简单理解:套接字地址是 “门牌号”,套接字是 “房子本身”,进程通过操作套接字这个 “房子” 来收发数据。

    3. 协议分层与 Socket 编程的边界

    网络协议栈按照分层思想设计,自上而下依次是应用层、传输层、网络层、数据链路层、物理层。Socket 编程的核心交互边界在传输层:

    • 我们编写的应用层代码,通过 Socket 接口调用传输层(TCP/UDP)提供的能力;
    • 传输层、网络层、数据链路层的封装与解封装,都由操作系统内核自动完成,对应用层透明。

    和软件分层设计思想一致:上层只需要调用下层提供的标准接口,无需关心下层的内部实现。


    二、传输层核心协议:TCP 与 UDP

    传输层负责端到端的进程间通信,最核心的两个协议是 TCP 和 UDP,二者设计目标完全不同,对应不同的业务场景。

    1. 核心特性对比

    表格

    特性维度TCP 协议UDP 协议
    连接属性 有连接 无连接
    传输模型 面向字节流 面向数据报
    可靠性 可靠传输(差错校验、重传、有序) 不可靠传输(不保证送达、不保证顺序)
    首部开销 20~60 字节 固定 8 字节
    流量 / 拥塞控制 支持 不支持
    通信形式 一对一全双工 一对一、一对多(广播 / 多播)
    2. 核心概念深度辨析
    (1)有连接 vs 无连接
    • 有连接:通信前必须先通过三次握手建立连接,双方在内核中保存对端的地址、协议状态等信息。类似打电话:必须先拨号、对方接通,才能对话,全程知道对方是谁。
    • 无连接:通信前不需要建立关系,每次发送数据都直接指定目标地址,内核不保存对端状态。类似寄快递:每次包裹都写清收件地址,快递员只负责这一次投递,不需要提前和收件人建立约定。
    (2)面向字节流 vs 面向数据报

    这是编程层面最核心的差异,本质是消息边界问题:

    • 面向字节流:数据是一串没有边界的字节流,内核不区分消息边界。发送端多次发送的数据,接收端可能一次性全部读出;发送端一次发送的数据,接收端也可能分多次读出。这就是 TCP 编程中必须处理的 “粘包 / 拆包” 问题。
    • 面向数据报:每个数据报是一个独立的消息,有明确的边界。发送端调用一次sendto发送一个数据报,接收端调用一次recvfrom就会完整读出这个数据报,不会合并也不会拆分。
    (3)可靠 vs 不可靠

    这是协议特性,没有优劣之分,只看场景是否匹配:

    • 可靠传输:TCP 通过确认应答、超时重传、序号排序、差错校验等机制,保证数据有序、无差错、不丢失地送达对方。代价是协议复杂、开销更高、延迟更大。
    • 不可靠传输:UDP 只负责把数据报发出去,不确认对方是否收到,不保证顺序,也不重传。好处是协议简单、开销极低、延迟小,且不会因为网络拥塞限制发送速率。
    3. 适用场景
    • TCP 适用场景:对数据准确性要求高的场景,比如文件传输、网页浏览、远程登录、支付接口等。
    • UDP 适用场景:对实时性要求高、允许少量丢包的场景,比如直播、语音通话、在线游戏、DNS 查询等。

    三、网络字节序:跨主机通信的统一约定
    1. 大小端的本质

    计算机中多字节数据(比如 32 位 int、16 位 short)在内存中的存储顺序有两种模式:

    • 大端模式(Big Endian):数据的高字节存储在内存低地址,低字节存储在内存高地址;
    • 小端模式(Little Endian):数据的低字节存储在内存低地址,高字节存储在内存高地址。

    二者没有性能优劣之分,只是硬件设计的不同选择。x86 架构 CPU 普遍采用小端模式,部分嵌入式、网络设备采用大端模式。

    2. 网络字节序标准

    如果不同主机的字节序不同,直接传输数据会导致接收端解析错误。因此 TCP/IP 协议栈明确规定:所有在网络中传输的数据,必须使用大端字节序,也叫网络字节序。

    主机本地存储的字节序称为主机字节序,发送数据时需要从主机字节序转为网络字节序,接收数据时再转回主机字节序。

    3. 标准转换接口

    Socket 库提供了 4 个常用转换函数,命名规则统一:h代表 host(主机),n代表 net(网络),l代表 32 位长整数,s代表 16 位短整数:

    #include <arpa/inet.h>

    // 主机字节序 → 网络字节序
    uint32_t htonl(uint32_t hostlong); // 转换32位数据(用于IP地址)
    uint16_t htons(uint16_t hostshort); // 转换16位数据(用于端口号)

    // 网络字节序 → 主机字节序
    uint32_t ntohl(uint32_t netlong);
    uint16_t ntohs(uint16_t netshort);

    对于点分十进制字符串格式的 IP 地址(如192.168.1.1),推荐使用可移植的inet_pton和inet_ntop进行转换,支持 IPv4/IPv6,替代老旧的inet_addr、inet_ntoa:

    // 字符串IP → 网络字节序整数
    int inet_pton(int af, const char *src, void *dst);

    // 网络字节序整数 → 字符串IP
    const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);


    四、Socket 编程接口的设计思想
    1. 统一接口的设计:sockaddr 的 “多态”

    Socket 需要支持多种地址族:IPv4(AF_INET)、IPv6(AF_INET6)、本地 Unix 域(AF_UNIX)等。如果每种地址族都设计一套独立的 API,接口会非常冗余。

    设计者采用了类似面向对象的 “多态” 思想:

    • 定义一个通用的地址结构体struct sockaddr,所有 API 的地址参数都用这个通用类型;
    • 不同地址族有自己的具体结构体(IPv4 对应sockaddr_in,IPv6 对应sockaddr_in6,本地对应sockaddr_un);
    • 所有具体结构体的前 16 位都是地址族类型字段,内核拿到地址后,先读取前 2 字节判断地址类型,再做对应的类型转换。

    使用时我们填充具体的地址结构体,调用 API 时强转为struct sockaddr*即可。

    2. IPv4 地址结构体详解

    #include <netinet/in.h>

    struct sockaddr_in {
    sa_family_t sin_family; // 地址族,固定填AF_INET,占2字节
    in_port_t sin_port; // 16位端口号,网络字节序
    struct in_addr sin_addr; // 32位IP地址,网络字节序
    unsigned char sin_zero[8]; // 填充字段,保持与sockaddr大小一致
    };

    struct in_addr {
    uint32_t s_addr;
    };

    3. 文件描述符的抽象本质

    Socket 创建成功后会返回一个整数文件描述符(fd),它和普通文件的文件描述符共用同一个文件描述符数组。这也是 Linux “一切皆文件” 设计思想的体现:

    • 进程可以像操作普通文件一样,用read/write读写 Socket;
    • 也可以用select、poll、epoll等 IO 多路复用接口统一管理。

    五、UDP Socket 编程实战:Echo 回显程序

    UDP 是无连接协议,编程逻辑比 TCP 简单,非常适合入门。我们实现一个完整的 Echo 程序:客户端发送数据,服务端收到后原封不动回发给客户端。

    1. 核心 API 详解
    (1)创建套接字:socket 

    #include <sys/socket.h>
    int socket(int domain, int type, int protocol);

    • domain:地址族,IPv4 填AF_INET,IPv6 填AF_INET6,本地通信填AF_UNIX;
    • type:套接字类型,UDP 填SOCK_DGRAM,TCP 填SOCK_STREAM;
    • protocol:协议编号,一般填 0,由内核根据前两个参数自动选择对应协议。
    • 返回值:成功返回非负文件描述符,失败返回 – 1 并设置errno。
    (2)绑定地址:bind 

    int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

    • 作用:将套接字与一个本地 IP + 端口号绑定,之后发往该地址的数据会被投递到这个套接字的接收缓冲区。
    • sockfd:socket 返回的文件描述符;
    • addr:填充好的本地地址结构体;
    • addrlen:地址结构体的长度。
    • 返回值:成功返回 0,失败返回 – 1。

    注意:服务端必须显式 bind 固定端口,这样客户端才能知道往哪个端口发数据。

    (3)接收数据:recvfrom 

    ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
    struct sockaddr *src_addr, socklen_t *addrlen);

    • 作用:从套接字接收一个数据报,同时获取发送方的地址。
    • buf:接收数据的缓冲区;
    • len:缓冲区大小;
    • flags:接收标志,默认填 0 表示阻塞接收;
    • src_addr:输出参数,返回数据发送方的地址;
    • addrlen:输入输出参数,传入缓冲区大小,返回实际地址长度。
    • 返回值:成功返回实际接收的字节数,失败返回 – 1;对端关闭连接时返回 0。
    (4)发送数据:sendto 

    ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
    const struct sockaddr *dest_addr, socklen_t addrlen);

    • 作用:向指定地址发送一个数据报。
    • dest_addr:目标接收方的地址;
    • 其余参数与 recvfrom 对应。
    • 返回值:成功返回实际发送的字节数,失败返回 – 1。
    2. 服务端实现逻辑

    服务端的核心是 “被动接收”,流程为:创建套接字 → 绑定固定端口 → 循环接收数据 → 回显数据。

    关键技巧:INADDR_ANY

    绑定服务端地址时,IP 字段填INADDR_ANY(值为 0),表示监听本机所有网卡的 IP 地址。只要是发往该端口的数据,无论目标是哪个网卡 IP,都会被这个套接字接收。这是服务端的标准写法,避免手动填写本机 IP 的麻烦。

    完整服务端代码

    // server.hpp
    #pragma once
    #include <iostream>
    #include <string>
    #include <cstring>
    #include <cerrno>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>
    #include "Log.hpp"

    namespace bksw {
    class UdpServer {
    private:
    int _sockfd;
    uint16_t _port;

    public:
    UdpServer(uint16_t port) : _sockfd(-1), _port(port) {
    // 1. 创建UDP套接字
    _sockfd = ::socket(AF_INET, SOCK_DGRAM, 0);
    if (_sockfd < 0) {
    LOG(Loglevel::FATAL) << "创建socket失败: " << strerror(errno);
    exit(1);
    }
    LOG(Loglevel::INFO) << "创建socket成功, fd=" << _sockfd;

    // 2. 填充服务端地址
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local)); // 结构体清零,避免脏数据
    local.sin_family = AF_INET;
    local.sin_port = htons(_port);
    local.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有IP

    // 3. 绑定地址
    if (::bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
    LOG(Loglevel::FATAL) << "绑定端口失败: " << strerror(errno);
    exit(1);
    }
    LOG(Loglevel::INFO) << "服务端启动成功,监听端口: " << _port;
    }

    void run() {
    char buff[1024];
    struct sockaddr_in client_addr;
    socklen_t len = sizeof(client_addr);

    while (true) {
    // 接收客户端数据
    ssize_t size = ::recvfrom(_sockfd, buff, sizeof(buff)-1, 0,
    (struct sockaddr*)&client_addr, &len);
    if (size < 0) {
    LOG(Loglevel::ERROR) << "接收数据失败: " << strerror(errno);
    continue;
    }
    buff[size] = '\\0';

    char ip_str[INET_ADDRSTRLEN];
    inet_ntop(AF_INET, &client_addr.sin_addr, ip_str, sizeof(ip_str));
    uint16_t client_port = ntohs(client_addr.sin_port);
    LOG(Loglevel::INFO) << "收到来自[" << ip_str << ":" << client_port
    << "]的数据: " << buff;

    // Echo回显:把数据原封不动发回给客户端
    ssize_t send_size = ::sendto(_sockfd, buff, size, 0,
    (struct sockaddr*)&client_addr, len);
    if (send_size < 0) {
    LOG(Loglevel::ERROR) << "回显发送失败: " << strerror(errno);
    }
    }
    }

    ~UdpServer() {
    if (_sockfd >= 0) {
    close(_sockfd);
    }
    }
    };
    }

    // server.cpp
    #include "server.hpp"

    int main(int argc, char** argv) {
    if (argc != 2) {
    std::cerr << "用法: " << argv[0] << " <端口号>" << std::endl;
    return 1;
    }

    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    bksw::UdpServer server(port);
    server.run();
    return 0;
    }

    3. 客户端实现逻辑

    客户端的核心是 “主动发送”,流程为:创建套接字 → 填充服务端地址 → 循环发送数据 → 接收回显。

    关键知识点:客户端的隐式绑定

    客户端通常不需要显式调用bind():第一次调用sendto()时,内核会自动给未绑定的套接字分配一个临时端口(短暂端口),完成隐式绑定。

    这样设计的原因:

    • 客户端是主动发起方,服务端只需要能回包即可,不需要提前知道客户端端口;
    • 由内核统一分配端口,避免手动管理导致的端口冲突;
    • 一个 UDP 套接字可以给多个不同的服务端发送数据,无需为每个服务端单独绑定端口。
    完整客户端代码

    // client.cpp
    #include <iostream>
    #include <string>
    #include <cstring>
    #include <cerrno>
    #include <sys/types.h>
    #include <sys/socket.h>
    #include <netinet/in.h>
    #include <arpa/inet.h>

    int main(int argc, char** argv) {
    if (argc != 3) {
    std::cerr << "用法: " << argv[0] << " <服务端IP> <服务端端口>" << std::endl;
    return 1;
    }

    // 填充服务端地址
    struct sockaddr_in server_addr;
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(static_cast<uint16_t>(std::stoi(argv[2])));
    if (inet_pton(AF_INET, argv[1], &server_addr.sin_addr) <= 0) {
    std::cerr << "无效的IP地址" << std::endl;
    return 1;
    }

    // 创建UDP套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
    std::cerr << "创建socket失败: " << strerror(errno) << std::endl;
    return 1;
    }

    std::string input;
    char recv_buff[1024];
    socklen_t addr_len = sizeof(server_addr);

    while (true) {
    std::cout << "请输入# ";
    std::getline(std::cin, input);
    if (input.empty()) continue;

    // 发送数据到服务端
    ssize_t send_size = sendto(sockfd, input.c_str(), input.size(), 0,
    (struct sockaddr*)&server_addr, addr_len);
    if (send_size < 0) {
    std::cerr << "发送失败: " << strerror(errno) << std::endl;
    continue;
    }

    // 接收服务端回显
    ssize_t recv_size = recvfrom(sockfd, recv_buff, sizeof(recv_buff)-1, 0,
    nullptr, nullptr); // 不需要对端地址可填nullptr
    if (recv_size < 0) {
    std::cerr << "接收回显失败: " << strerror(errno) << std::endl;
    continue;
    }
    recv_buff[recv_size] = '\\0';
    std::cout << "服务端回显: " << recv_buff << std::endl;
    }

    close(sockfd);
    return 0;
    }


    六、进阶扩展:从 Echo 到群聊系统

    在 Echo 程序的基础上,我们可以很容易扩展出群聊功能:服务端保存所有连接过的客户端地址,每当收到一个客户端的消息,就把消息转发给所有已记录的客户端。

    1. 单线程版本实现思路
  • 服务端维护一个容器(如std::set或std::vector),存储所有客户端的sockaddr_in地址;
  • 每次收到客户端消息时,先检查该地址是否在容器中,不在则加入;
  • 遍历容器中的所有地址,将消息逐一转发出去。
  • 2. 核心注意点
    • UDP 没有 “连接” 的概念,客户端是否在线无法直接感知,需要设计心跳机制定期清理离线客户端;
    • 单线程模式下,转发操作会阻塞接收流程,客户端较多时会有延迟;
    • 可以扩展多线程版本:一个线程负责接收消息,放入消息队列;多个工作线程负责转发消息,提升并发能力。
    3. 功能扩展的通用思路

    Socket 编程的核心优势在于传输层与业务层解耦:底层的 UDP 收发逻辑不变,只修改上层业务处理逻辑,就能实现不同的功能 ——

    • 把回显改成翻译逻辑,就是翻译服务器;
    • 把回显改成计算逻辑,就是远程计算器;
    • 把单播转发改成广播,就是群聊系统。

    七、常见踩坑与最佳实践
  • 结构体必须清零:sockaddr_in使用前务必用memset清零,否则填充字段的脏数据可能导致绑定失败。
  • 必须做错误处理:所有系统调用都可能失败,通过返回值和errno定位问题是网络编程的基本功。
  • 优先使用可移植接口:推荐inet_pton/inet_ntop替代老旧的inet_addr/inet_ntoa,方便后续迁移 IPv6。
  • 服务端务必设置 SO_REUSEADDR:避免服务端重启时因TIME_WAIT导致端口绑定失败,这是 TCP/UDP 服务端的通用最佳实践。
  • 注意数据长度:UDP 单次发送数据不要超过 MTU(一般 1472 字节以内),否则会在 IP 层分片,丢包概率大幅上升。
  • 赞(0)
    未经允许不得转载:171主机测评 » Linux之网络socket编程(UDP协议)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址