初识UDP协议

初识UDP协议

funnywii 215 2024-01-29

写在前面

为什么突然急头白脸的要学UDP,这源自领导安排的一个坑爹的活。

懂计网的同事出差了,而我作为部门里仅剩的一个会C++的,被光荣地安排去做RSU和OBU的通信测试,而它们的通信协议,就是UDP。。。可我不会啊,我除了个TCP握手挥手就对各种协议一无所知。

好在供应商自然是有群的,群里自然是有技术支持的,我问的问题太入门,自然是被嘲讽了的。。。

那就学吧!

网络5层结构模型

除了TCP五层结构模型,还有TCP四层结构和OSI七层模型。他们本质上没有区别,只是在某些层面的概括或者细化。
网络结构457

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协议的头部

由4个字段组成:源端口 source port、目标端口 dest port、数据包长度 length 及校验和 checksum。

  • 源端口号 用于指示本机的进程
  • 目标端口号 用于指示远端的进程
  • 数据包长度 顾名思义是UDP数据包的总长度,包括UDP头部 + UDP数据
  • 校验和 用于校验数据包在传输过程中是否有丢失和损坏

UDP数据包发送

数据的发送是由应用层调用 send() 或者 write() 系统调用,将数据传递到传输层协议处理。

可以看出,用户态的应用程序调用send()时,会触发调用内核态的sys_send()内核函数,随后会调用inet_sendmsg()函数发送数据。该函数会根据用户使用的传输层协议选择不同的数据发送接口,如UDP会采用udp_sendmsg()函数。
image

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:checksum
  • 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 层通过调用此函数把 UDP头部 和数据复制到数据包中。ip_build_xmit() 函数是 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个工作:

  1. 调用 udp_v4_lookup() 函数获取目标端口对应的 Socket 对象
  2. 调用 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 (lsof -P -n | grep XXXXXXX)来查到具体的进程。

实例

UDP的Server和Client

UDP 不同于 TCP,不存在请求连接和受理过程。

Server在接收到Client发来的数据之前,是不知道Client的地址的。因此Server必须先于Client启动。

Client只需要知道Server的地址,直接向其发送数据即可。

必须Client先发送数据,Server后响应数据。因此在某种意义上无法明确区分服务器端和客户端,只是因为Server提供服务称为服务器端

Server C++代码分解[3]

Server的处理过程如下:

  1. 创建UDP Socket
  2. Bind socket <-> Server address
  3. 等Client的数据报
  4. 处理数据并答复Client
  5. 回Step3
#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_IPServer 绑定的IP地址
SERVER_PORTServer端口号,16位,故有2162^{16} = 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(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", 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的处理过程如下:

  1. 创建UDP Socket
  2. 向Server发msg
  3. 等Server答复
  4. 如需答复Server,回step2
  5. 关闭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/