Linux网络编程(四):UDP Socket基础编程

目录

[一、为什么先从 UDP 开始](#一、为什么先从 UDP 开始)

二、地址转换函数

[1. 地址转换](#1. 地址转换)

[2. 经典 IPv4 转换接口](#2. 经典 IPv4 转换接口)

[3. 现代安全转换接口](#3. 现代安全转换接口)

[三、UDP Socket 编程基本流程](#三、UDP Socket 编程基本流程)

[1. 调用流程](#1. 调用流程)

[2. 面向对象封装](#2. 面向对象封装)

[1. 类结构设计](#1. 类结构设计)

[2. 核心功能实现](#2. 核心功能实现)

[四、UDP 服务端实现](#四、UDP 服务端实现)

[五、UDP 客户端实现](#五、UDP 客户端实现)

总结


一、为什么先从 UDP 开始

在上一篇中,我们详细梳理了 Socket 编程的理论与命令储备。从本篇开始,我们将正式步入 Linux 网络编程的代码实战阶段。

按照技术学习循序渐进的原则,我们将编写的第一行网络通信代码基于 UDP 协议

1. UDP 接口简单,适合入门 Socket 编程

TCP 协议为了保证传输的绝对可靠性,引入了极其复杂的内部状态机。在编写 TCP 基础代码时,开发者必须同时处理连接建立、连接断开以及数据边界等复杂的逻辑流

相比之下,UDP 协议省去了所有关于 "状态" 与" 连接" 的控制。使用 UDP 编写程序,开发者可以将精力集中在 "如何创建套接字" 以及 "如何进行网络 I/O 读写" 这两件事上

2. UDP 无连接,接口更直观

UDP 具有无连接的物理特性。这意味着:

  • 服务端不需要等待客户端来建立连接,只需向操作系统申请并绑定好端口,即可进入接收状态

  • 客户端也不需要经历握手阶段,只需知道服务端的 IP 和端口,便可立即发送数据

在 API 层面,这种特性直接体现为通信流程的高度精简。在后续的代码实现中我们会发现,UDP 的核心交互逻辑几乎完全由发送和 接收这两个接口承载,逻辑链路清晰且单一

3. 本篇目标:完成第一个 UDP Client / Server Demo

本篇博客的最终落地目标非常明确:在 Linux 环境下,从零手写并运行一个基于 UDP 协议的客户端/服务端经典通信模型

这个实验我们能够接触到:

  1. Linux 经典网络函数在 C 语言中的具体调用范式

  2. 亲数据包是如何在本地或不同主机之间成功传递的

  3. 为后续学习复杂的 TCP 协议打下坚实的工程实践基础

二、地址转换函数

计算机底层的网络协议栈在处理 IP 地址时,使用的是 32 位的网络字节序二进制整数 。然而,人类习惯阅读的是诸如 "192.168.1.10" 这样的点分十进制字符串

因此,在编写 Socket 编程代码时,第一步就是要在 "人类可读的字符串" 与 "网卡需要的二进制整数" 之间进行转换


1. 地址转换

在上一节我们介绍了 htonl 等转换函数,你可能会产生疑问:既然 IP 地址本质上也是一个 32 位整数,为什么我们不能直接调用 htonl,而是需要一套专门的地址转换函数?

核心原因在于:数据类型的本质不同,以及解析职能的缺失

  • htonl 的能力边界: 它的输入参数必须已经是一个 32 位无符号整数 。它的唯一工作是改变这个整数的字节序,它根本无法识别和解析字符串

  • 面临的问题: 如果将字符串 "192.168.1.10" 直接传给 htonl,编译器会直接报错。如果想强行使用 htonl,则必须写一段繁琐的代码:用 sscanf 或 strtok 把字符串切割成 4 个部分,把每一个部分转换为数字并拼接成一个 uint32_t,然后再调用 htonl

  • 专用函数的意义: 操作系统提供的专用地址转换函数,在内部将 "字符串解析" 与 "字节序转换" 这两步合并为了一个原子操作。它们不仅能看懂点分十进制字符串,还能在解析的同时自动将其转换为符合网络序的大端整数


2. 经典 IPv4 转换接口

在早期的 Linux 网络编程中,主要使用以下三个接口。它们声明在头文件

<sys/socket.h>、<netinet/in.h> 、<arpa/inet.h> 中

(1)inet_addr:字符串转二进制网络序

cpp 复制代码
in_addr_t inet_addr(const char *cp);

将点分十进制的字符串 IP 转换为 32 位网络字节序的二进制整数

缺陷 :如果传入的字符串非法,它会返回 INADDR_NONE(通常是 -1)。然而,广播地址 "255.255.255.255" 被解析后的有效二进制结果刚好也是 -1(0xFFFFFFFF)。这就导致该函数无法区分非法 IP 与合法的有限广播地址。因此,现代开发中已不推荐使用

(2) inet_aton更安全的字符串转二进制

cpp 复制代码
原型:int inet_aton(const char *cp, struct in_addr *inp);

功能:功能与 inet_addr 相同,将转换结果写入输出型参数 inp 中

返回值:若字符串有效则返回 1,非法则返回 0。它完美规避了 -1 的歧义问题

(3) inet_ntoa:二进制网络序转字符串

cpp 复制代码
原型:char *inet_ntoa(struct in_addr in);

功能:将一个 struct in_addr 结构体中的网络序二进制整数转换为点分十进制字符串

缺陷 :该函数内部使用了一块静态缓冲区 来存放生成的字符串,并返回该缓冲区的首地址。这意味着,如果连续调用两次 inet_ntoa ,第二次的结果会覆盖 第一次的结果。在多线程环境下,该函数是线程不安全


3. 现代安全转换接口

为了同时支持 IPv4(AF_INET)与 IPv6(AF_INET6),并且保证线程安全性,POSIX 规范引入了现代化的转换接口。名字中的 p 代表 Presentation(表达格式,即字符串),n 代表 Numeric(数值格式,即二进制)

(1) inet_pton:字符串转二进制

cpp 复制代码
int inet_pton(int af, const char *src, void *dst);

参数:
- af:协议族,传入 AF_INET(IPv4)或 AF_INET6(IPv6)
- src:待转换的字符串 IP 首地址。
- dst:指向接收转换结果的内存指针(对于 IPv4,通常传入 &struct in_addr)

返回值:
成功返回 1;若输入的字符串格式非法返回 0;若协议族不支持返回 -1

(2) inet_ntop:二进制转字符串

cpp 复制代码
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

参数:
- af:协议族
- src:指向待转换的二进制网络序 IP 的指针
- dst:由调用者在外部开辟的缓冲区指针。该函数不再使用内部静态缓冲区,
而是将结果写入你提供的内存中,从而实现了线程安全
- size:外部缓冲区的长度。为了防止缓冲区溢出,系统定义了两个宏作为长度标准:
        INET_ADDRSTRLEN(IPv4 字符串最大长度,16 字节)
        INET6_ADDRSTRLEN(IPv6 最大长度,46 字节)

新旧接口对比推荐用法

我们将新旧接口的特征整理如下:

转换方向 经典旧接口(不推荐) 现代化接口(推荐使用)
字符串 -> 二进制 inet_addr / inet_aton inet_pton
二进制 -> 字符串 inet_ntoa inet_ntop

在现代 Linux 网络编程中,熟练使用 inet_pton 和 inet_ntop 函数是编写高质量、高并发且具备跨平台兼容性网络程序的关键技能,也是专业开发者必备的工程素养

三、UDP Socket 编程基本流程

在了解了各个系统调用的核心参数后,我们将这些接口串联起来。一个标准 UDP 通信的核心逻辑,是由底层的流式调用组合而成的

为了避免冗余的文档说明,我们直接通过一个代码片段,来观察这些接口在实际开发中的标准调用链条:


1. 调用流程

cpp 复制代码
// 1. 创建套接字
int sockfd = socket(AF_INET, SOCK_DGRAM, 0); 

// 2. 准备并填充本地网络地址
struct sockaddr_in local;
bzero(&local, sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8080); // 主机字节序转网络字节序
inet_pton(AF_INET, "0.0.0.0", &local.sin_addr); // 现代安全地址转换

// 3. 绑定端口
bind(sockfd, (struct sockaddr*)&local, sizeof(local));

// 4. 数据交互缓冲区与对端地址声明
char buffer[1024];
struct sockaddr_in client;
socklen_t len = sizeof(client);

// 5. 接收数据(阻塞等待)
ssize_t n = recvfrom(sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&client, &len);
if (n > 0) {
    buffer[n] = '\0';
    // 6. 响应数据(原路返回)
    sendto(sockfd, buffer, n, 0, (struct sockaddr*)&client, len);
}

// 7. 关闭套接字
close(sockfd);

2. 面向对象封装

在生产环境和复杂的工程项目中,直接编写面向过程的 C 风格 Socket 代码会导致业务逻辑与底层网络 I/O 严重耦合

因此,标准做法是利用 C++ 的面向对象特性,将套接字的生命周期、地址转换、数据收发机制封装进一个高内聚的 UdpServer 类中。通过暴露 Init、Start、Stop 三个核心控制接口,实现业务层与网络层的彻底解耦

1. 类结构设计

我们首先定义类的基础架构,引入必要的状态控制变量,并利用析构函数实现资源的自动回收(RAII 机制)

cpp 复制代码
#pragma once

#include <iostream>
#include <string>
#include <cstdint>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

class UdpServer {
public:
    // 构造函数:初始化服务器监听的端口与 IP(默认绑定本地任意网卡 0.0.0.0)
    UdpServer(uint16_t port, const std::string& ip = "0.0.0.0")
        : _sockfd(-1), _port(port), _ip(ip), _is_running(false) {}

    // 析构函数:确保进程退出时文件描述符被安全释放
    ~UdpServer() {
        if (_sockfd >= 0) 
            close(_sockfd);
    }

    // 暴露给外部的核心控制接口
    bool Init();
    void Start();
    void Stop();

private:
    int _sockfd;           // 网络文件描述符
    uint16_t _port;        // 服务器监听端口
    std::string _ip;       // 服务器绑定的 IP 地址
    bool _is_running;      // 服务器运行状态标识
};

2. 核心功能实现

接下来实现具体的成员函数。在具体实现中,我们将融入上一节推荐的现代安全地址转换函数inet_pton 与 inet_ntop

(1) Init:资源申请与地址绑定

该函数负责完成服务器启动前的所有准备工作。如果在任意环节发生系统调用失败,将直接返回 false 终止初始化

cpp 复制代码
bool Init() {
    // 1. 创建套接字
    _sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (_sockfd < 0) {
        std::cerr << "Create socket failed" << std::endl;
        return false;
    }
    std::cout << "Create socket success, fd: " << _sockfd << std::endl;

    // 2. 填充服务器物理地址结构体
    struct sockaddr_in local;
    std::memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_port = htons(_port); // 端口号转换为网络大端序

    // 内部封装地址转换:将字符串 IP 安全解析为网络序二进制整数
    if (inet_pton(AF_INET, _ip.c_str(), &local.sin_addr) <= 0) {
        std::cerr << "Invalid IP: " << _ip << std::endl;
        return false;
    }

    // 3. 绑定套接字到指定端口
    if (bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) < 0) {
        std::cerr << "Bind port " << _port << " failed" << std::endl;
        return false;
    }
    std::cout << "Bind socket to " << _ip << ":" << _port << " success." << std::endl;
    return true;
}

(2) Start:核心事件循环与数据交互

Start 函数内部维护了一个高效的死循环。服务器将在此处产生阻塞,等待客户端的数据投递,并在收到数据后自动提取对方的身份信息,将数据原路回显

cpp 复制代码
void Start() {
    _is_running = true;
    char in_buffer[1024]; // 接收缓冲区

    std::cout << "UDP Server started " << std::endl;

    while (_is_running) {
        // 定义客户端地址结构体,用于捕获发送方的身份
        struct sockaddr_in client_addr;
        socklen_t client_len = sizeof(client_addr);

        // 1. 阻塞式接收客户端数据
        ssize_t n = recvfrom(_sockfd, in_buffer, sizeof(in_buffer) - 1, 0,
                                          (struct sockaddr*)&client_addr, &client_len);
        
        if (n < 0) {
            std::cerr << "Receive data error" << std::endl;
            continue; // 单次读取错误不终止整体服务
        }

        // 2. 数据切分与边界处理
        in_buffer[n] = '\0';

        // 3. 将网络序二进制客户端 IP 转换为可读字符串
        char client_ip_str[INET_ADDRSTRLEN];
        inet_ntop(AF_INET, &client_addr.sin_addr, client_ip_str, sizeof(client_ip_str));
        uint16_t client_port = ntohs(client_addr.sin_port); // 还原客户端端口

        // 日志打印输出
        std::cout << "[" << client_ip_str << ":" << client_port << "]# " << in_buffer << std::endl;

        // 4. 业务逻辑响应:数据原路回显 (Echo)
        ssize_t sent_bytes = sendto(_sockfd, in_buffer, received_bytes, 0,
                                    (struct sockaddr*)&client_addr, client_len);
        if (sent_bytes < 0) 
            std::cerr << "Send response error" << std::endl;
    }
}

(3) Stop:状态安全控制

提供一种优雅终止服务器的手段,避免使用硬杀进程(kill -9)等粗暴方式

cpp 复制代码
void Stop() {
    _is_running = false;
    std::cout << "Server stop" << std::endl;
}

四、UDP 服务端实现

为了让服务端实现落地并具备可执行性,我们还需要编写对应的服务器主函数入口(main 函数)。在 Linux 网络编程中,通用的工程实践是通过命令行参数(argc / argv)来动态指定服务器需要绑定的端口和 IP

以下是服务端的实现。它负责解析命令行参数,实例化并拉起我们此前封的 UdpServer 服务

cpp 复制代码
#include "UdpServer.hpp"
#include <memory>
#include <cstdlib>

// 提示用户正确的使用方法
void Usage(const std::string& proc) {
    std::cout << "Usage:\n\t" << proc << " port [server_ip]\n" << std::endl;
}

int main(int argc, char* argv[]) {
    // 服务器启动至少需要指定端口号,因此参数个数必须为 2 或 3
    if (argc != 2 && argc != 3) {
        Usage(argv[0]);
        return 1;
    }

    // 解析端口号
    uint16_t port = std::atoi(argv[1]);
    
    // 解析 IP 地址(如果用户未指定,则默认为 0.0.0.0,表示绑定本地所有网卡)
    std::string ip = "0.0.0.0";
    if (argc == 3) {
        ip = argv[2];
    }

    // 使用智能指针管理服务器对象生命周期
    std::unique_ptr<UdpServer> svr(new UdpServer(port, ip));

    // 初始化服务器资源
    if (!svr->Init()) {
        std::cerr << "Server initialization failed." << std::endl;
        return 2;
    }

    // 启动服务事件循环
    svr->Start();

    return 0;
}

五、UDP 客户端实现

与服务端需要固定监听某个端口不同,UDP 客户端的核心任务是主动向远端服务器发起数据投递,并接收服务端的响应

在编写客户端代码之前,需要明确一个关键的底层机制:客户端通常不需要显式调用 bind 接口

  • 为什么不需要手动 bind?

    服务器必须手动绑定端口,是因为它的端口是公开给全互联网的门牌号,不能更改。而客户端是主动发起连接的一方,只要能把数据发出去即可,不关心自己使用的是哪个本地端口

  • 什么时候分配端口?

    当客户端首次调用 sendto 发送数据时,Linux 内核会自动在当前系统中查找一个处于空闲状态的本地端口,并隐式地将该端口与当前 Socket fd 进行绑定。这种由操作系统动态分配端口的机制,有效避免了多个客户端程序因硬编码相同端口而导致的端口冲突异常

客户端完整源码实现

客户端采用直观的控制台交互模式:从标准输入读取用户输入的字符串,通过网络发送给服务端,随后阻塞等待服务端的 Echo 回显数据并打印输出

cpp 复制代码
#include <iostream>
#include <string>
#include <cstring>
#include <cstdlib>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

void Usage(const std::string& proc) {
    std::cout << "Usage:\n\t" << proc << " server_ip server_port\n" << std::endl;
}

int main(int argc, char* argv[]) {
    // 客户端启动必须明确指定目标的 IP 和 端口
    if (argc != 3) {
        Usage(argv[0]);
        return 1;
    }

    std::string server_ip = argv[1];
    uint16_t server_port = std::atoi(argv[2]);

    // 1. 创建客户端本地套接字
    int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
    if (sockfd < 0) {
        std::cerr << "Create socket failed!" << std::endl;
        return 2;
    }

    // 2. 填充目标服务器的地址结构体 (sockaddr_in)
    struct sockaddr_in server_addr;
    std::memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(server_port); // 转换为网络字节序
    if (inet_pton(AF_INET, server_ip.c_str(), &server_addr.sin_addr) <= 0) {
        std::cerr << "Invalid server IP" << std::endl;
        close(sockfd);
        return 3;
    }

    // 3. 进入业务交互循环
    std::string message;
    char buffer[1024];

    while (true) {
        std::cout << "Please Enter# ";
        std::getline(std::cin, message);
        
        if (message.empty()) {
            continue;
        }

        // 4. 向服务器发送数据
        // 此时内核会自动为 sockfd 绑定一个随机的本地端口
        ssize_t n = sendto(sockfd, message.c_str(), message.size(), 0,
                                    (struct sockaddr*)&server_addr, sizeof(server_addr));
        if (n < 0) {
            std::cerr << "Send data failed" << std::endl;
            break;
        }

        // 5. 准备接收服务端的响应
        struct sockaddr_in peer_addr;
        socklen_t peer_len = sizeof(peer_addr);

        // 阻塞等待接收回显数据
        n = recvfrom(sockfd, buffer, sizeof(buffer) - 1, 0,
                                          (struct sockaddr*)&peer_addr, &peer_len);
        
        if (n > 0) {
            buffer[n] = '\0';
            // 打印服务端返回的数据内容
            std::cout << "Server Echo> " << buffer << std::endl;
        } else {
            std::cerr << "Receive data error" << std::endl;
            break;
        }
    }

    close(sockfd);
    return 0;
}

总结

综上所述,我们已经正式完成了第一个基于 Socket 的网络通信程序。从 IP 地址与端口号的组织方式,到地址转换函数,再到 socket、bind、sendto、recvfrom 等核心接口,我们已经能够真正让两台主机上的应用程序完成网络数据交互

与此同时,我们也进一步体会到了 UDP 编程最核心的特点:简单、轻量、无连接

相比 TCP,UDP 不需要建立连接,也不存在复杂的状态维护,其通信模型本质上就是:

sendto → recvfrom

应用程序只需要构造数据并指定目标地址,即可直接向网络发送报文

而站在更底层的视角来看,本篇所编写的 UDP 程序,本质上其实仍然是在对文件描述符进行读写操作。Socket 并是 Linux 内核向用户提供的一种特殊文件接口,网络通信最终依然会回到:

系统调用 + 缓冲区 + 协议栈

这一套操作系统机制上

至此,我们已经真正迈入了网络编程的大门。但目前我们的程序还非常原始------只能简单收发消息,还谈不上真正的网络服务

在下一篇中,我们将继续基于 UDP,进一步实现 EchoServer、DictServer 等更接近真实业务场景的网络程序,并逐步开始思考如何让一个网络程序真正长期稳定地对外提供服务

复制代码
相关推荐
用户2367829801682 小时前
Linux more 命令详解:从基础分页到高级文本查看技巧
linux
sunlifenger2 小时前
构筑绿色能源数字底座,风光一体化智慧电站整体解决方案
服务器·网络·能源
相思难忘成疾2 小时前
SELinux 强制访问控制安全策略验证
linux·运维·服务器·网络·memcached
j7~2 小时前
【Linux操作系统】基础IO文件系统(理解硬件,理解文件系统,Inode,软硬链接)
linux·运维·服务器·磁盘·文件系统·inode·软硬件链接
Donk_672 小时前
Shell 数组实践
linux·算法·bash
XMAIPC_Robot2 小时前
电力设备RK3568/RK3576+FPGA,多系统混合部署Linux+RTOS RT-THREAD,强实时性
linux·运维·fpga开发
郭郭的柳柳在学FPGA2 小时前
千兆以太网@——帧格式
java·开发语言·网络
aashuii2 小时前
linux测试lsquic
linux·运维·服务器