UDP 深度解析:传输层协议核心原理与套接字编程实战
文章目录
- 一.认识IP地址及端口号
- 什么是IP地址?
- 什么是端口号?
- 标识一个通信
- 拓展:netstat命令
- 二.套接字的概念
- 三.网络字节序
- 什么是字节序?
- 网络字节序
- 四.套接字常用接口
- 注意事项及 sockaddr 结构介绍:
- 创建套接字:
- 绑定端口号:
- struct sockaddr_in 的设置:
- 监听套接字:
- 接收请求:
- 连接套接字:
- 接收数据:
- 发送数据:
- 五.实现简单的UDP服务器
- 服务器的初始化(套接字的创建绑定)
- 服务器接受与发送数据
- 六.UDP协议剖析
- UDP协议格式
- UDP如何将报头与有效载荷进行分离?
- UDP如何决定将有效载荷交付给上层的哪一个协议?
- UDP数据封装与分用是如何实现的?
- UDP协议的特点
- 面向数据报
- UDP的缓冲区
一.认识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 地址后,转本地字节序 |
更加详细的功能说明:
-
htonl(host to network long):把主机字节序的32 位长整数(uint32_t,比如unsigned int ) 转换成网络字节序(大端)。
-
htons(host to network short):把主机字节序的16 位短整数(uint16_t,比如unsigned short ) 转换成网络字节序(大端)。
-
ntohl(network to host long):把网络字节序(大端)的32 位长整数,转回主机字节序(适配本地系统的大 / 小端 )。
-
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*。
下面来依次讲解套接字接口:
创建套接字:
- 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。
绑定端口号:
- 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位整数
监听套接字:
- listen () - 监听套接字
int listen(int sockfd, int backlog);
将套接字设置为 监听状态,让其准备好接受客户端连接(仅用于 TCP 流式套接字 )。
sockfd:已绑定的套接字描述符(经 bind 成功后的 )。
backlog:等待连接队列的最大长度(内核维护两个队列:半连接队列、全连接队列,该值影响队列总容量 )。
返回值:成功返回 0,失败返回 -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。
说明:阻塞函数,若无客户端连接,会一直等待;有连接时,为每个客户端创建独立套接字,方便服务器并发处理多客户端。
连接套接字:
- 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 无需该操作(无连接特性 )。
接收数据:
- 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 表示非阻塞模式下无数据)。
发送数据:
- 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; // IPv4local.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数据就会被丢弃。