UDP 深度解析:传输层协议核心原理与套接字编程实战

发布于:2025-09-14 ⋅ 阅读:(18) ⋅ 点赞:(0)

一.认识IP地址及端口号

什么是IP地址?

IP 地址是网络层的逻辑地址,用于标识网络中设备的逻辑位置,以便实现跨网络(如局域网、广域网)的通信。IP 地址处在 IP 协议中,用来标识网络中唯一的一台主机。

常见的IP地址类型有:IPv4:4个字节,32 位二进制数,通常分为 4 组十进制数(如192.168.1.1),每组范围 0-255;IPv6:16个字节,128 位二进制数,分为 8 组十六进制数,解决 IPv4 地址枯竭问题

什么是端口号?

一台设备可能同时运行多个网络程序,数据包到达设备后,需通过端口号判断 “该交给哪个应用处理”,”。
从网络中获取的数据在进行向上交付时,在传输层就会提取出该数据对应的目的端口号,进而确定该数据应该交付给当前主机上的哪一个服务进程

端口号属性:

  • 长度:16 位二进制,范围是 0 ~ 65535(共 65536 个端口)。
  • 唯一性:用网络进行通信本质上就是通过应用层的进程进行通信,接收到信息后需要知道传递给哪一个进程,这时就需要端口号,存在一个哈希表将端口号和对应的进程PCB地址映射起来一个进程可以对应多个端口号,但是一个端口号不能对应多个进程

端口号划分

  • 知名端口
  • 范围:0~1023
  • 特点:普通应用程序不允许占用(需管理员权限才能绑定),客户端能通过固定端口访问常见服务(无需手动指定端口)。
    典型例子:
    HTTP(超文本传输协议):80 端口
    HTTPS(加密的 HTTP):443 端口
    FTP(文件传输协议):21 端口
    SSH(远程登录协议):22 端口
    DNS(域名解析协议):53 端口

在Linux中,可以输入/etc/services的命令来查看常用知名端口号:
在这里插入图片描述

  • 注册端口
    范围:1024~49151
    特点:供开发者为自定义服务(如数据库、中间件)分配固定端口,方便用户记忆和访问。
    典型例子:
    MySQL 数据库:3306 端口
    Redis 缓存:6379 端口
    Tomcat 服务器:8080 端口(常用的 “非标准 HTTP 端口”)
    MongoDB:27017 端口
  • 动态 / 私有端口
    范围:49152~65535
    特点:不固定分配给任何服务,由操作系统临时动态分配给客户端应用,客户端与服务器通信时,临时占用一个端口作为 “本地标识”,通信结束后立即释放,供其他应用复用。

标识一个通信

在TCP/IP协议中,用“源IP地址”,“源端口号”,“目的IP地址”,“目的端口号”,“协议号”这样一个五元组来标识一个通信

在Linux中可以通过netstat命令来查看五元组:
在这里插入图片描述
Local Address表示的就是源IP地址和源端口号,Foreign Address表示的就是目的IP地址和目的端口号,而Proto表示的就是协议类型

拓展:netstat命令

选项 含义
-t 仅显示 TCP 协议的连接
-u 仅显示 UDP 协议的连接
-l 仅显示处于 “监听状态”(Listening)的端口 / 连接
-n 以 “数字形式” 显示地址和端口(不解析域名 / 服务名,速度更快)
-p 显示建立连接的进程 PID 和名称(需 root 权限,否则可能显示不全)
-a 显示所有连接(包括监听和非监听状态)
-r 显示路由表(类似 route 命令)
-i 显示网络接口的统计信息(如收发数据包数、错误数)
-c 持续刷新显示(实时监控,按 Ctrl+C 退出)

在这里插入图片描述

二.套接字的概念

既然IP地址可以用来标识互联网中唯一的一台主机,port 端口号可以用来标识该主机上唯一的一个网络进程,那么“源IP地址”,“源端口号”,“目的IP地址”,“目的端口号”这样的四元组就能标识互联网中唯二的两个进程。

套接字的标识由IP 地址和端口号共同组成,二者结合形成唯一的 “通信端点”,IP 地址:定位网络中的目标设备(如192.168.1.100);
端口号:定位设备上的目标进程(如8080)。
格式通常表示为 IP:端口(如192.168.1.100:8080),这也是 “套接字地址” 的核心形式。

三.网络字节序

什么是字节序?

计算机处理的 “多字节数据”(比如占 4 字节的int型整数、占 2 字节的端口号),在内存中存储时,字节的排列顺序就是 “字节序”。

举个具体例子:假设要存储整数 0x12345678(十六进制,共 4 个字节,分别是 0x12、0x34、0x56、0x78)。
大小端的机器分别会这样储存:
把 “高位字节”(0x12)存在内存的 “低地址”,“低位字节”(0x78)存在 “高地址”;反过来,把 “低位字节” 存在 “低地址”,“高位字节” 存在 “高地址”。
这两种不同的排列方式在网络通信中如何不统一储存方式,数据就会变成乱码!一团糊糊!

网络字节序

为解决上述差异,TCP/IP 协议族明确规定:所有网络传输的多字节数据,必须使用统一的字节序 —— 大端序
系统调用提供了专门的函数,用于 “主机字节序” 与 “网络字节序” 的转换(以 C 语言为例):

函数名 作用 适用场景(数据类型)
htons Host to Network Short(主机→网络,短整型) 端口号(通常占 2 字节,short)
htonl Host to Network Long(主机→网络,长整型) IP 地址(IPv4 占 4 字节,long)
ntohs Network to Host Short(网络→主机,短整型) 接收端口号后,转本地字节序
ntohl Network to Host Long(网络→主机,长整型) 接收 IP 地址后,转本地字节序

更加详细的功能说明

  1. htonl(host to network long):把主机字节序的32 位长整数(uint32_t,比如unsigned int ) 转换成网络字节序(大端)。

  2. htons(host to network short):把主机字节序的16 位短整数(uint16_t,比如unsigned short ) 转换成网络字节序(大端)。

  3. ntohl(network to host long):把网络字节序(大端)的32 位长整数,转回主机字节序(适配本地系统的大 / 小端 )。

  4. ntohs(network to host short):把网络字节序(大端)的16 位短整数,转
    回主机字节序。

四.套接字常用接口

这里给出套接字编程中常用的一些系统调用接口函数:

#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

/* 地址结构体 */
// IPv4专用地址结构
struct sockaddr_in {
    sa_family_t     sin_family;  // 地址族(AF_INET)
    in_port_t       sin_port;    // 端口号(网络字节序)
    struct in_addr  sin_addr;    // IP地址(网络字节序)
    unsigned char   sin_zero[8]; // 填充字段
};

// 通用地址结构(函数参数使用)
struct sockaddr {
    sa_family_t sa_family;       // 地址族
    char        sa_data[14];     // 地址数据
};

/* 套接字创建与关闭 */
// 创建套接字:domain(AF_INET), type(SOCK_STREAM/TCP, SOCK_DGRAM/UDP)
int socket(int domain, int type, int protocol);

// 关闭套接字
int close(int sockfd);

/* 绑定操作(服务器端) */
// 将套接字与IP:端口绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

/* TCP专用接口 */
// 监听连接(服务器):backlog为等待队列长度
int listen(int sockfd, int backlog);

// 接受连接(服务器):返回与客户端通信的新套接字
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

// 发起连接(客户端):连接到目标服务器
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

/* 数据传输接口 */
// TCP发送数据(已连接)
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

// TCP接收数据(已连接)
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

// UDP发送数据(需指定目标地址)
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

// UDP接收数据(获取源地址)
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

/* 字节序转换工具 */
uint16_t htons(uint16_t hostshort);  // 主机端口→网络端口
uint16_t ntohs(uint16_t netshort);  // 网络端口→主机端口
in_addr_t inet_addr(const char *cp); // IP字符串→网络字节序

注意事项及 sockaddr 结构介绍:

⦁ socket 不仅支持跨网络的进程间通信,还支持本主机的进程间通信
⦁ 在创建套接字时,需要选择创建的是用于网络通信的网络套接字,还是用于本地通信的域间套接字
⦁ 由于在进行网络通信时,需要传递 ip + port,而本地通信则不需要。因此套接字就提供了用于网络通信的 sockaddr_in 结构体,以及用于本地通信的 sockaddr_un 结构体
⦁ 而为了让网络通信和本地通信都能使用同一个函数,又出现了一种新的结构体 sockaddr,这 3 种结构体的前面 16 个比特位相同,都叫做协议家族。
⦁ 在使用 socket 相关函数时,不管要进行的是网络通信还是本地通信,统一传入 sockaddr 结构体作为 socket 相关函数的参数。
⦁ 通过设置 sockaddr 的协议家族来决定进行的是网络通信还是本地通信,socket 相关函数会提取出 sockaddr 的前 16 个比特位来判断要进行的是本地还是网络通信
⦁ IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6. 这样只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容.
⦁ 编写网络通信代码时,定义的依旧是 sockaddr_in 结构体;传参时,需要将定义的 sockaddr_in 结构体变量的地址类型强转为 sockaddr*。

在这里插入图片描述

下面来依次讲解套接字接口:

创建套接字:

  1. socket () - 创建套接字
int socket(int domain, int type, int protocol);

创建一个套接字(socket),作为网络通信的端点。
domain:协议族(地址族),常用值如 AF_INET(IPv4 协议)、AF_INET6(IPv6 协议 )、AF_UNIX(本地进程间通信 )
type:套接字类型,常用 SOCK_STREAM(流式套接字,用于 TCP 协议,可靠、面向连接 )、SOCK_DGRAM(数据报套接字,用于 UDP 协议,不可靠、无连接 )
protocol:协议类型,通常设为 0,表示使用对应 type 的默认协议(如 SOCK_STREAM 对应 IPPROTO_TCP )。
返回值:成功返回套接字描述符(非负整数),失败返回 -1。

绑定端口号:

  1. bind () - 绑定端口号c
int bind(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

将套接字与特定的 IP 地址和端口号 绑定,让操作系统明确该套接字要监听 / 使用哪个网络端点 。
sockfd:socket() 返回的套接字描述符,标识要操作的套接字。
addr:指向包含 IP 地址和端口号的结构体指针,实际使用时常用 struct sockaddr_in 填充内容后,强制转换 为 struct sockaddr 传入* 。
addrlen:地址结构体的长度,即 sizeof(struct sockaddr_in),告知内核地址结构大小。
返回值:成功返回 0,失败返回 -1(可通过 errno 查具体错误,如端口被占用、权限不足等 )。

struct sockaddr_in 的设置:

不妨先来看看struct sockaddr_in的具体结构:

typedef uint16_t in_port_t;

struct sockaddr_in
  {
    __SOCKADDR_COMMON (sin_);
    in_port_t sin_port;			/* Port number.  */
    struct in_addr sin_addr;		/* Internet address.  */

    /* Pad to size of `struct sockaddr'.  */
    unsigned char sin_zero[sizeof (struct sockaddr) -
			   __SOCKADDR_COMMON_SIZE -
			   sizeof (in_port_t) -
			   sizeof (struct in_addr)];
  };

struct in_addr
  {
    in_addr_t s_addr;
  };

sin_family:需填 AF_INET(对应 IPv4 场景 ),标识地址族。
sin_port:要绑定的端口号,需用 网络字节序(可通过 htons() 函数转换,如 htons(8080) )
sin_addr:struct in_addr 类型,存储 IP 地址。若填 INADDR_ANY(值为 0 ),表示绑定到本机所有网卡的 IP(服务器常用,灵活监听多网卡 );也可填具体 IPv4 地址(需用 inet_addr() 等转换为网络字节序 )

//创建本地地址结构体
struct sockaddr_in local;
memset(&local,0, sizeof(local)); 
local.sin_family= AF_INET; // IPv4
local.sin_port= htons(port_); // 将端口号转换为网络字节序(注意端口号是16位的,需要转为网络字节序,使用htons函数)
local.sin_addr.s_addr= inet_addr(ip_.c_str()); // inet_addr会提取字符串中的IP地址,并转换为网络字节序的32位整数

监听套接字:

  1. listen () - 监听套接字
int listen(int sockfd, int backlog);

将套接字设置为 监听状态,让其准备好接受客户端连接(仅用于 TCP 流式套接字 )
sockfd:已绑定的套接字描述符(经 bind 成功后的 )。
backlog:等待连接队列的最大长度(内核维护两个队列:半连接队列、全连接队列,该值影响队列总容量 )
返回值:成功返回 0,失败返回 -1。

接收请求:

  1. accept () - 接受请求
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);

从已完成连接队列 中取出一个客户端连接,创建 新的套接字 用于后续与该客户端通信(仅用于 TCP 服务器 )
sockfd:处于监听状态的套接字描述符(经 listen 后的 )。
addr:用于存储 客户端地址信息 的结构体指针(通常用 struct sockaddr_in 接收,再强转 ),可获取客户端 IP 和端口
addrlen:地址结构体长度的指针,调用时需先填 sizeof(struct sockaddr_in),内核会修改它为实际地址长度。
返回值:成功返回 新的套接字描述符(专门用于与该客户端收发数据,原监听套接字仍可继续接受其他连接 ),失败返回 -1。
说明:阻塞函数,若无客户端连接,会一直等待;有连接时,为每个客户端创建独立套接字,方便服务器并发处理多客户端。

连接套接字:

  1. connect () - 建立连接
int connect(int sockfd, const struct sockaddr* addr, socklen_t addrlen);

客户端向服务器发起 TCP 连接请求,尝试与服务器的 IP + 端口建立连接。
sockfd:客户端套接字描述符(socket() 创建的 )。
addr:指向 服务器地址信息 的结构体指针(用 struct sockaddr_in 填充服务器 IP、端口,再强转
addrlen:服务器地址结构体的长度(sizeof(struct sockaddr_in) )。
返回值:成功返回 0,失败返回 -1(如服务器未监听、网络不通等 )。
说明:仅用于 TCP 客户端,主动发起连接;UDP 无需该操作(无连接特性 )。

接收数据:

  1. recvfrom () - 接收数据报
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

从指定套接字接收数据报,并获取发送方的地址信息(IP + 端口)。
sockfd:套接字描述符(通过 socket() 创建的 UDP 套接字,类型为 SOCK_DGRAM)。
buf:接收数据的缓冲区,用于存储接收到的数据
len:缓冲区的最大长度(字节数),避免数据溢出。
flags:接收方式标志,通常设为 0(默认阻塞接收),特殊需求可设 MSG_DONTWAIT(非阻塞)等
src_addr:指向 struct sockaddr 类型的指针,用于存储发送方的地址信息(输出参数)。
实际使用时常用 struct sockaddr_in(IPv4)接收,需强制转换为 struct sockaddr
*。

addrlen:指向 socklen_t 类型的指针,输入时为 src_addr 缓冲区的长度(如 sizeof(struct sockaddr_in)),输出时为实际接收到的地址长度(输入输出参数)。

返回值:
成功:返回接收到的字节数(≤ len)。
失败:返回 -1(可通过 errno 查看错误原因,如 EAGAIN 表示非阻塞模式下无数据)。

发送数据:

  1. sendto () - 发送数据报
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

通过指定套接字向目标地址(IP + 端口)发送数据报。
sockfd:套接字描述符(UDP 套接字,SOCK_DGRAM 类型)。
buf:待发送数据的缓冲区。
len:待发送数据的长度(字节数)。
flags:发送方式标志,通常设为 0,特殊需求可设 MSG_DONTROUTE(不经过路由)等。
dest_addr:指向 struct sockaddr 类型的指针,存储目标地址信息(IP + 端口),需提前填充。
IPv4 场景下用 struct sockaddr_in 填充
(sin_family=AF_INET、sin_port=目标端口、sin_addr=目标IP),再强制转换。
addrlen:目标地址结构体的长度(如 sizeof(struct sockaddr_in))。

返回值:
成功:返回发送的字节数(≤ len)。
失败:返回 -1(如目标地址不可达、端口未开放等)。

五.实现简单的UDP服务器

服务器的初始化(套接字的创建绑定)

void Init()
    {
        // 1. 创建udp socket
        // 2. Udp 的socket是全双工的,允许被同时读写的
        sockfd_= socket(AF_INET, SOCK_DGRAM, 0); // PF_INET与AF_INET是等价的,类似于打开一个文件
        if(sockfd_ < 0)
        {
            log(FATAL, "socket create error, sockfd: %d", sockfd_);
            exit(SOCKET_ERR);
        }
        else 
        {
            log(INFO, "socket create success, sockfd: %d", sockfd_);
        }

        //创建本地地址结构体
        struct sockaddr_in local;
        memset(&local,0, sizeof(local)); 
        local.sin_family= AF_INET; // IPv4
        local.sin_port= htons(port_); // 将端口号转换为网络字节序(注意端口号是16位的,需要转为网络字节序,使用htons函数)
        local.sin_addr.s_addr= inet_addr(ip_.c_str()); // inet_addr会提取字符串中的IP地址,并转换为网络字节序的32位整数

        //进行绑定
        if(bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) < 0)
        {
            log(FATAL, "bind error, errno: %d, err string: %s", errno, strerror(errno));
            exit(BIND_ERR);
        }
        else
        {
            log(INFO, "bind success, errno: %d, err string: %s", errno, strerror(errno));
        }
    }

服务器接受与发送数据

void Run(func_t func) 
    {
        isrunning_ = true;
        char input[SIZE]={0};
        while(isrunning_)
        {
            struct sockaddr_in client_addr;
            memset(&client_addr, 0, sizeof(client_addr));
            socklen_t len = sizeof(client_addr);
            int n = recvfrom(sockfd_, input, 1023, 0, (struct sockaddr*)&client_addr, &len);
            if(n < 0)
            {
                log(WARNING, "recvfrom error, errno: %d, err string: %s", errno, strerror(errno));
                continue;
            }
            // cout<< "Received from client: " << input << std::endl;

            std::string client_ip = inet_ntoa(client_addr.sin_addr); // 获取客户端IP地址
            uint16_t client_port = ntohs(client_addr.sin_port); // 获取客户端端口号 
            CheckUser(client_ip, client_port,client_addr);
            
            // 将接收到的数据进行处理
            input[n] = '\0'; 
            std::string tmp=input;
            std::string rev= func(tmp); 
            // // cout<< "After processing: " << rev << std::endl;
            sendto(sockfd_, rev.c_str(), rev.size(), 0, (struct sockaddr*)&client_addr, sizeof(client_addr));
        }
    }

请注意这里func_t类型是回调函数,需要在外层自己定义:

typedef std::function<std::string(std::string&)> func_t;

六.UDP协议剖析

UDP协议格式

网络套接字编程时用到的各种接口,是位于应用层和传输层之间的一层系统调用接口,这些接口是系统提供的,UDP是属于内核当中的,是操作系统本身协议栈自带的,其代码不是由上层用户编写的,UDP的所有功能都是由操作系统完成,因此网络也是操作系统的一部分

UDP协议格式如下:

在这里插入图片描述

UDP报头实际上是结构体,四个基本成员使用位段描述

struct udp_header_bitfield {
    // 第1~16位:源端口号(Source Port)
    uint16_t source_port : 16;  // 16位,标识发送端应用程序端口(0~65535)
    
    // 第17~32位:目的端口号(Destination Port)
    uint16_t dest_port : 16;    // 16位,标识接收端应用程序端口(0~65535)
    
    // 第33~48位:UDP总长度(UDP Length)
    uint16_t udp_length : 16;   // 16位,UDP数据报总长度(报头8字节+数据,范围8~65535字节)
    
    // 第49~64位:校验和(Checksum)
    uint16_t checksum : 16;     // 16位,校验UDP报头、数据及伪首部(0表示不校验)
};
字段名称 字节数 位置(字节) 核心作用
源端口号 2 0~1 标识发送端的应用程序端口
目的端口号 2 2~3 标识接收端的应用程序端口
UDP 长度 2 4~5 表示整个数据报(UDP首部+UDP数据)的长度
校验和 2 6~7 校验传输完整性,如果UDP报文的检验和出错,就会直接将报文丢弃

tips:UDP最大长度是16位,一个UDP报文的最大长度是64K(包含UDP报头的大小),如果需要传输的数据超过64K,就需要在应用层进行手动分包,多次发送,并在接收端进行手动拼装

UDP如何将报头与有效载荷进行分离?

UDP的位段结构体有四个基本成员,每个成员都分得了16个比特位作为数据的存储空间,那么UDP报头的大小=结构体大小=16*4=64bit=8byte,所以大小为固定长度8个字节,UDP在读取报文时读取完前8个字节后剩下的就都是有效载荷了。

UDP如何决定将有效载荷交付给上层的哪一个协议?

UDP上层也有很多应用层协议,因此UDP必须想办法将有效载荷交给对应的上层协议,也就是交给应用层对应的进程。

由于应用层的每一个网络进程都会绑定一个端口号,服务端进程必须显示绑定一个端口号,客户端进程则是由系统动态绑定的一个端口号。UDP就是通过报头当中的目的端口号来找到对应的应用层进程的。

UDP数据封装与分用是如何实现的?

封装:当应用层将数据交给传输层后,在传输层就会创建一个UDP报头,然后填充报头当中的各个字段,此时操作系统再在内核当中开辟一块空间,将UDP报头和有效载荷组合到一起,此时就形成了UDP报文。
分用:当传输层从下层获取到一个报文后,就会读取该报文的前8个字节(也就是获取UDP报头),提取出对应的目的端口号,再通过目的端口号找到对应的上层应用层进程,然后将剩下的有效载荷向上交付给该应用层进程。

UDP协议的特点

  • 无连接:知道对端的IP和端口号就直接进行数据传输,不需要建立连接。
  • 不可靠:没有确认机制,没有重传机制;如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。
  • 面向数据报:不能够灵活的控制读写数据的次数和数量。

面向数据报

应用层每次调用sendto()发送数据时,UDP 会将该数据直接封装成一个独立的 UDP 数据报(添加 8 字节报头),不合并、不拆分—— 应用层发 100 字节,UDP 就传 1 个 108 字节(8 字节报头 + 100 字节数据)的数据报;应用层分 3 次发 50 字节,UDP 就传 3 个 58 字节的数据报

UDP的缓冲区

UDP没有真正意义上的发送缓冲区,但是具有接收缓冲区。但是这个接收缓冲区不能保证收到的UDP报的顺序和发送UDP报的顺序一致;如果缓冲区满了,再到达的UDP数据就会被丢弃