写在前面
为什么突然急头白脸地学 UDP,这源自领导安排的一个坑爹的活。
懂计网的同事出差了,而我作为部门里仅剩的一个会 C++ 的,被光荣地安排去做 RSU 和 OBU 的通信测试,而它们用的通信协议正是 UDP。可我不会啊,我除了对 TCP 的握手和挥手还有点印象,对其他协议几乎一无所知。
好在供应商自然有群,群里也自然有技术支持。只是我问的问题太入门,被嘲讽几句也很正常。
那就学吧。
网络 5 层结构模型
除了 TCP 五层结构模型,还有 TCP 四层结构和 OSI 七层模型。它们本质上都是对网络通信过程的分层描述,只是在抽象粒度上有所不同。
OSI 七层网络模型由国际标准化组织制定,它是正统意义上的国际标准。但它的实现过于复杂,而且制定周期也比较长。在整套标准完全推出之前,TCP/IP 模型已经在全球范围内被广泛使用,所以 TCP/IP 模型才是事实上的国际标准。TCP/IP 模型定义了应用层、传输层、网际层、网络接口层这四层网络结构,但并没有继续细化网络接口层的具体内容。因此在学习和开发中,通常会把网络接口层拆解为 OSI 七层模型中的数据链路层和物理层来理解,也就是常说的五层网络模型 ^[1]^。
- 应用层 Application Layer:直接为应用进程提供服务,定义进程之间通信和交互的规则。不同应用会对应不同协议,例如 HTTP、FTP、SMTP、DNS 等。
- 传输层 Transport Layer:为两台主机上的进程提供通信服务,这一层最常见的协议就是 TCP 和 UDP。
- 网络层 Internet Layer:负责为两台主机之间提供通信服务。
- 链路层 Data Link Layer:负责将网络层的 IP 数据报封装为 frame,并在链路的两个相邻节点之间传送 frame。
- 物理层 Physical Layer:确保数据能够在物理媒介上完成传输。
什么是 UDP/IP
UDP,即用户数据报协议(User Datagram Protocol),是一种面向无连接的传输层协议,也是 TCP/IP 协议簇的一部分。TCP 协议和 UDP 协议都工作在传输层,区别在于 TCP 是基于连接的,而 UDP 是基于无连接的。
既然是无连接协议,那么在传输数据时就不需要像 TCP 那样先建立连接,因此它也不提供可靠传输保证。UDP 把数据发送到目标之后,并不能确认数据是否被正确接收,所以它的可靠性相对较弱。不过也正因为少了连接建立、重传控制等机制,UDP 往往具备更好的实时性。
UDP 的数据发送
UDP 协议(传输层)建立在网络层之上,其中 IP 协议 用于区分网络上的不同主机,端口号(Port)用于区分主机上的不同进程。
UDP 协议的头部
UDP 头部由 4 个字段组成:源端口(source port)、目标端口(dest port)、数据包长度(length)以及校验和(checksum)。
- 源端口号:用于标识本机的发送进程。
- 目标端口号:用于标识远端的接收进程。
- 数据包长度:顾名思义,就是 UDP 数据包的总长度,包括 UDP 头部和 UDP 数据。
- 校验和:用于校验数据包在传输过程中是否发生损坏。
UDP 数据包发送
数据的发送由应用层调用 send() 或者 write() 系统调用,把数据交给传输层协议栈处理。
从整体流程来看,用户态应用程序调用 send() 时,会进一步进入内核态的发送流程,随后调用 inet_sendmsg() 发送数据。这个函数会根据当前套接字所使用的传输层协议,选择不同的数据发送接口;如果使用的是 UDP,就会走到 udp_sendmsg()。
udp_sendmsg() 是 Linux 内核中负责 UDP 发送路径的核心函数之一。它位于 net/ipv4/udp.c 中,由 UDP 协议栈在发送数据报文时调用。
放代码之前,再说一个概念:套接字 Socket。Socket 是一个抽象的通信端点,用于网络上数据的发送和接收。你可以把它理解成一个电话插座,负责建立通信通道;而 port 就像插座上的孔,用来区分不同的通信对象,同一时刻通常不能被其他进程随意复用。
可以通过 socket() 创建套接字,此时必须明确指定使用哪种数据传输方式。之后再通过 bind() 等函数设置套接字属性。套接字建立后,就可以通过 send() 和 recv(),或者 write() 和 read() 来发送和接收数据。
下面看一下这个函数的部分实现:
/*
函数接口
sk: Socket 对象
msg: 发送的数据实体
len: 发送的数据长度
*/
int udp_sendmsg(struct sock *sk, struct msghdr *msg, int len){
int ulen = len + sizeof(struct udphdr);
struct ipcm_cookie ipc;
struct udpfakehdr ufh;
struct rtable *rt = NULL;
int free = 0;
int connected = 0;
u32 daddr;
u8 tos;
int err;
- ulen:UDP 数据部分长度加上 UDP 头部后的总长度。
- ufh:调用 IP 层
ip_build_xmit()函数时的上下文,用于构建 UDP 协议头部。 - rt:路由信息。
// 是否提供了接收数据的目标 IP 地址和端口
if (msg->msg_name) {
// 接收数据的目标 IP 地址和端口
struct sockaddr_in *usin = (struct sockaddr_in*)msg->msg_name;
if (msg->msg_namelen < sizeof(*usin))
return -EINVAL;
if (usin->sin_family != AF_INET) {
if (usin->sin_family != AF_UNSPEC)
return -EINVAL;
}
// 把用户提供的目标 IP 地址和端口复制到 ufh 变量中
ufh.daddr = usin->sin_addr.s_addr;
ufh.uh.dest = usin->sin_port;
if (ufh.uh.dest == 0)
return -EINVAL;
} else { // 把绑定到 Socket 对象的目标 IP 地址和端口复制到 ufh 变量中,并且设置 connected 变量为 1。
if (sk->state != TCP_ESTABLISHED)
return -ENOTCONN;
ufh.daddr = sk->daddr; // 使用绑定 Socket 的 IP 地址
ufh.uh.dest = sk->dport; // 使用绑定 Socket 的端口
connected = 1;
}
这里会先检查 connected 标志。如果为真,就尝试从套接字缓存中获取路由信息对象 rt。如果路由信息对象还没有被缓存,就调用 ip_route_output() 重新获取。
if (connected) {
rt = (struct rtable*)sk_dst_check(sk, 0); // 获取路由信息对象缓存
if (rt == NULL) { // 如果路由信息对象还没被缓存
// 调用 ip_route_output() 函数获取路由信息对象
err = ip_route_output(&rt, daddr, ufh.saddr, tos, ipc.oif);
if (err)
goto out;
err = -EACCES;
if (rt->rt_flags & RTCF_BROADCAST && !sk->broadcast)
goto out;
if (connected)
sk_dst_set(sk, dst_clone(&rt->u.dst)); // 设置路由信息对象缓存
}
}
接着会把 rt 对象中的源 IP 地址复制到 ufh 变量中,然后调用 ip_build_xmit() 完成后续发送。ip_build_xmit() 的第一个参数是一个函数指针,用来把 UDP 头部和负载数据拷贝到数据包中。也就是说,真正的 IP 层封装和下发,就是从这里继续往下走的。
ufh.saddr = rt->rt_src; // 设置源 IP 地址
if (!ipc.addr) { // 如果没有提供目标 IP 地址,使用路由信息中的目标 IP 地址
ufh.daddr = ipc.addr = rt->rt_dst;
}
ufh.uh.len = htons(ulen);
ufh.uh.check = 0;
ufh.iov = msg->msg_iov;
ufh.wcheck = 0;
// 构建 MAC 头部、IP 头部和 UDP 头部,并下发给 IP 协议层
err = ip_build_xmit(sk, (sk->no_check == UDP_CSUM_NOXMIT ? udp_getfrag_nosum : udp_getfrag), &ufh, ulen, &ipc, rt, msg->msg_flags);
out:
ip_rt_put(rt);
// ...
return err;
}
总的来说,udp_sendmsg() 的主要工作就是为待发送的数据构建 UDP 头部,然后把整个数据包交给 IP 层继续完成后续发送。
UDP 数据包的接收
IP 协议层处理完数据包后,如果 IP 头部中的上层协议字段(protocol)指明当前报文属于 UDP,那么就会调用 udp_rcv() 来处理。这个函数主要完成 2 个工作:
- 调用
udp_v4_lookup()获取目标端口对应的 Socket 对象 - 调用
udp_queue_rcv_skb()把数据包加入 Socket 对象的receive_queue队列
int udp_rcv(struct sk_buff *skb, unsigned short len) {
struct sock *sk;
struct udphdr *uh;
unsigned short ulen;
struct rtable *rt = (struct rtable*)skb->dst; // 路由信息对象
u32 saddr = skb->nh.iph->saddr; // 远端 IP 地址(源 IP 地址)
u32 daddr = skb->nh.iph->daddr; // 本地 IP 地址(目标 IP 地址)
uh = skb->h.uh; // UDP 头部
// 根据目标端口获取对应的 Socket 对象
sk = udp_v4_lookup(saddr, uh->source, daddr, uh->dest, skb->dev->ifindex);
if (sk != NULL) {
udp_queue_rcv_skb(sk, skb); // 把数据包添加到 Socket 对象的 receive_queue 队列中
sock_put(sk);
return 0;
}
// ...
}
UDP 的丢包信息可以从 cat /proc/net/udp 的最后一列 drops 中看到,而倒数第四列 inode 是发生丢包的 UDP socket 的全局唯一虚拟 i 节点号。可以通过这个 inode 号结合lsof -P -n | grep XXXXXXX 来定位具体进程。
实例
UDP 的 Server 和 Client
UDP 不同于 TCP,它不存在请求连接和受理连接的过程。
Server 在接收到 Client 发来的数据之前,并不知道 Client 的地址。因此 Server 一般需要先于 Client 启动。
Client 只需要知道 Server 的地址,就可以直接向它发送数据。
通常要先由 Client 发出数据,Server 才能据此响应。因此从通信行为上看,二者不像 TCP 那样有明显的连接建立角色区分,只是因为 Server 提供服务,所以才被称为服务器端。
Server C++ 代码分解 ^{\text{[3]}}
Server 的处理过程如下:
- 创建 UDP Socket
- 绑定 socket 和 Server address
- 等待 Client 发来的数据报
- 处理数据并答复 Client
- 回到 Step 3
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 12345
#define BUFFER_SIZE 1024
- bits/stdc++.h 是非标准库头文件,虽然包含了很多 C++ 标准库头文件,但一般并不建议使用。
- stdlib.h 等属于 C 标准库。
- sys/socket.h 是进行套接字编程的头文件,定义了 Socket 相关的数据结构和函数。
- arpa/inet.h 定义了与 Internet 地址相关的函数和数据结构。
- netinet/in.h 包含与 Internet 地址族相关的数据结构和宏定义。
- SERVER_IP:Server 绑定的 IP 地址。
- SERVER_PORT:Server 端口号。端口是 16 位,因此一共有 2^{16} = \text{65536} 个端口,也就是 0 到 65535。其中 0 到 1023 一般保留给系统级服务。
- BUFFER_SIZE:最大消息缓冲容量。
接下来逐行解析 main() 函数:
int main() {
int server_socket; // 用于存储 Socket File Descriptor。socket() 函数调用后会返回一个整数。
struct sockaddr_in server_addr, client_addr; // IPv4 地址的结构体,存储 Server 和 Client 地址信息
socklen_t client_addr_len = sizeof(client_addr);
char buffer[BUFFER_SIZE]; // 用于存储接收到的消息字符的缓冲区。
// 创建 Socket 并将其 Descriptor 存储在 sockfd 中。
// AF_INET 代表使用 IPv4 地址族。
// SOCK_DGRAM 指定使用 UDP 套接字类型。
// 0 指定默认协议,在这里是 UDP,无影响。
// 若 sockfd < 0 表示创建失败。
server_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (server_socket < 0) {
perror("Error in socket");
exit(1);
}
// memset() 函数将一段内存区域的内容设置为指定的值。
// 即将 servaddr 和 cliaddr 结构体变量的内存内容初始化为零。
// 这是为了确保这两个结构体在开始时的所有成员变量都被设置为零值。
memset(&server_addr, 0, sizeof(server_addr));
// 将 sin_family 成员设置为 AF_INET,表示使用 IPv4 地址族。
// 创建 socket 时已经指定过,再次指定是为了确保套接字的地址族一致
server_addr.sin_family = AF_INET;
// 将 sin_port 成员设置为服务器程序将使用的端口号
server_addr.sin_port = htons(SERVER_PORT);
// INADDR_ANY 表示服务器接受任何可用的本地网络接口请求,即允许绑定到所有本地 IP 地址上
server_addr.sin_addr.s_addr = INADDR_ANY;
// 绑定套接字到端口
if (bind(server_socket, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("Error in bind");
exit(1);
}
printf("Server listening on port %d...\n", SERVER_PORT);
while (1) {
// 接收客户端消息
// server_socket:Socket File Descriptor
// (char *)buffer:指向接收数据的 buffer
// (struct sockaddr *)&cliaddr:指向 cliaddr 结构体的指针,用于存储发送方的地址信息
// 0 表示接收时等待直到接收到足够的数据,或者发生错误
// recv_len:接收到的字节数
ssize_t recv_len = recvfrom(server_socket, buffer, BUFFER_SIZE, 0, (struct sockaddr *)&client_addr, &client_addr_len);
if (recv_len < 0) {
perror("Error in recvfrom");
exit(1);
}
// 将接收到的数据,即 buffer 中最后一个字符设为 '\0',确保 buffer 以 null 结尾
buffer[recv_len] = '\0';
printf("Received message from client: %s\n", buffer);
// 回复客户端,sendto() 函数用于向客户端发送消息
// 形参列表 sendto(server_socket, 要发送的消息 buffer, 消息长度, MSG_CONFIRM, 指向 Client 结构体的指针即接收方的地址信息, Client 结构体大小)
if (sendto(server_socket, buffer, recv_len, 0, (struct sockaddr *)&client_addr, client_addr_len) < 0) {
perror("Error in sendto");
exit(1);
}
}
// 关闭套接字
close(server_socket);
return 0;
}
Client C++ 代码
Client 的处理过程如下:
- 创建 UDP Socket
- 向 Server 发送消息
- 等待 Server 答复
- 如需继续答复 Server,回到 Step 2
- 关闭 Socket
// 客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <arpa/inet.h>
#include <unistd.h>
#define SERVER_IP "127.0.0.1"
#define SERVER_PORT 12345
#define BUFFER_SIZE 1024
int main() {
int client_socket;
struct sockaddr_in server_addr;
char buffer[BUFFER_SIZE];
const char *message = "Hello, UDP Server!";
// 创建 UDP 套接字
client_socket = socket(AF_INET, SOCK_DGRAM, 0);
if (client_socket < 0) {
perror("Error in socket");
exit(1);
}
// 配置服务器地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(SERVER_PORT);
server_addr.sin_addr.s_addr = inet_addr(SERVER_IP);
// 发送消息到服务器
if (sendto(client_socket, message, strlen(message), 0, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) {
perror("Error in sendto");
exit(1);
}
printf("Message sent to server: %s\n", message);
// 接收服务器回复
ssize_t recv_len = recvfrom(client_socket, buffer, BUFFER_SIZE, 0, NULL, NULL);
if (recv_len < 0) {
perror("Error in recvfrom");
exit(1);
}
// 打印服务器回复
buffer[recv_len] = '\0';
printf("Received message from server: %s\n", buffer);
// 关闭套接字
close(client_socket);
return 0;
}
参考文章
[1] https://juejin.cn/post/6844904049800642568
[2] https://www.eet-china.com/mp/a42269.html
[3] https://www.geeksforgeeks.org/udp-server-client-implementation-c/
评论