自制操作系统(29):UDP

今天我们来写UDP,并用UDP来适配DNS协议。

UDP

UDP其实就是简化版的TCP,没有状态机,但还是会有一个哈希表来关联socket和网络数据。

报文

        0              15 16             31
       +-----------------+-----------------+
       | Source Port     |Destination Port |
       +-----------------+-----------------+
       |                 |                 |
       |     Length      |    Checksum     |
       +-----------------+-----------------+
       |                                   |
       |          data octets ...          |
       +-----------------------------------+

UDP的报文,非常简单,简单得令人发指——源端口、目的端口、长度、校验和,然后就是数据。

从客户端开始

老规矩,我们从一个客户端开始:

#include <net/net.hpp>
#include <net/socket.hpp>
#include <file.h>
#include <stdio.h>
#include <format.h>
#include <stdlib.h>
#include <poll.h>

int main(int argc, char** argv) {
    if (argc < 3) {
        printf("usage: udp-client <ip addr> <port>\n");
        return 0;
    }

    int conn = open("/sock/udp", O_CREATE);
    if (conn == -1) {
        printf("udp unsupported!\n");
        return 0;
    }
    sockaddr bindaddr;
    bindaddr.addr = SOCKADDR_BROADCAST_ADDR;
    bindaddr.port = 8080;
    if (ioctl(conn, "SOCK_IOC_BIND", &bindaddr) < 0) {
        printf("failed to bind %s:%d", bindaddr.addr, bindaddr.port);
        return 0;
    }

    pollfd fds[2] = {
        { .fd = 0, .events = POLLIN, .revents = 0}, // 标准输入
        { .fd = conn, .events = POLLIN, .revents = 0 }
    };

    char buff[256];
    while(1) {
        int ret = poll(fds, 2, -1);  // -1 = 无限等待
        if (ret < 0) { break; }

        if (fds[0].revents & POLLIN) {
            int n = read(0, buff, sizeof(buff));
            if (n <= 0) break;
            if (strncmp("/bye\n", buff, n) == 0) {
                break;
            }
            write(conn, buff, n);
        }

        // socket 有数据 → 读取并打印
        if (fds[1].revents & POLLIN) {
            int n = read(conn, buff, sizeof(buff));
            if (n < 0) {
                printf("connection has been closed\n");
                break;
            }
            if (n == 0) continue;
            buff[n] = '\0';
            printf("%s\n", buff);
        }
    }

    close(conn);
    return 0;
}

可以看到,UDP的客户端和TCP的客户端几乎一模一样:open("/sock/udp", O_CREATE) 打开UDP协议套接字,ioctl 绑定本地端口,然后通过 poll 等待键盘输入或网络数据。

唯一的不同是,UDP不需要 connect(虽然我们也支持),也不需要 listen/accept。UDP是无连接的。

接口

UDP的接口与TCP非常相似:

int udp_init(socket& sock, uint16_t local_port);
int udp_read(socket& sock, char* buffer, uint32_t size);
int udp_write(socket& sock, const char* payload, uint32_t size);
int udp_ioctl(socket& sock, uint32_t request, void* arg);
int udp_connect(socket& sock, uint32_t addr, uint16_t port);
int udp_sendto(socket& sock, const char* buffer, uint32_t size, sockaddr* peeraddr);
int udp_recvfrom(socket& sock, char* buffer, uint32_t size, sockaddr* peeraddr);
int udp_close(socket& sock);

接口拿着TCP的去改就可以了。多出来的 sendtorecvfrom 是UDP特有的——因为我们没有连接,所以发送时需要指定对端,接收时需要知道是谁发来的。

socket结构体

UDP在socket中存放的数据非常简单,不像TCP有复杂的TCB:

struct socket {
    uint16_t inode_id;
    uint8_t valid;
    protocol ptcl;
    union {
        // ... icmp, tcp ...
        struct { uint32_t   local_ip;
                 uint32_t   remote_ip;
                 uint16_t   local_port;
                 uint16_t   remote_port;
                 udp_pack*  pack_head;
                 udp_pack*  pack_tail;   }   udp;
    } data;
    spinlock lock;
    process_queue wait_queue;
    process_queue* poll_queue;
};

UDP的数据只有五个字段:本地IP和端口、远端IP和端口(用于connected UDP),以及一个 udp_pack 链表来缓存接收到的数据包。

struct udp_pack {
    uint32_t size;
    uint32_t remote_ip;
    uint32_t remote_port;
    udp_pack* next;
    void* data;
};

每个 udp_pack 结点存储了收到的数据内容、大小,以及发送方的IP和端口。这是一个简单的链表,pack_headpack_tail 分别指向链表头和尾。

两张哈希表

UDP也像TCP一样有两个哈希表,不同的是我们不需要TCB(因为没有连接状态这种东西),我们的值直接就是socket。

spinlock map_sockaddr_lock;
spinlock map_quad_lock;
std::unordered_map<sockaddr, socket*, sockaddr_hasher> map_sockaddr_to_sock;
std::unordered_map<conn_quadruple, socket*, conn_hasher> map_quad_to_sock;
  • map_sockaddr_to_sock:以 (local_ip, local_port) 二元组为键,用于查找绑定到某个本地地址和端口上的socket。这是UDP最主要的查找方式——收到一个UDP报文时,先根据报文的目标IP和端口找到对应的socket。
  • map_quad_to_sock:以 (local_ip, remote_ip, local_port, remote_port) 四元组为键,用于connected UDP的查找。当UDP socket调用了 connect 后,它会在这个表中建立记录,后续数据可以直接通过 read/write 收发,不需要每次指定对端地址。

听起来像是:
- 二元组哈希表:用于无连接模式(就像邮局的邮箱,别人可以随时往你的邮箱里投信)
- 四元组哈希表:用于已连接模式(就像一条专用的电话线)

我们不想把事情弄得太复杂,所以我们想把UDP做得尽可能像TCP。

初始化与绑定

int udp_init(socket& sock, uint16_t local_port) {
    sock.ptcl = protocol::UDP;
    sock.data.udp.pack_head = nullptr;
    sock.data.udp.pack_tail = nullptr;
    sockaddr init_setting = {.addr = 0, .port = htons(local_port)};
    return udp_bind(sock, &init_setting);
}

udp_init 在创建socket时被调用,它会初始化链表指针并自动绑定到 0.0.0.0:local_port——addr = 0 表示绑定到所有网络接口(相当于 INADDR_ANY)。

udp_bind 的实现比TCP简单得多,不需要三次握手:

int udp_bind(socket& sock, sockaddr* bind_conf) {
    SpinlockGuard sockGuard(sock.lock);
    {
        SpinlockGuard sockGuard(map_sockaddr_lock);
        // 检查已有的二元组记录,不要把别人的记录给占了
        auto itr = map_sockaddr_to_sock.find(*bind_conf);
        if (itr != map_sockaddr_to_sock.end() && itr->second != &sock) {
            return -1; // 该本地端口、地址组合已被绑定,且不是我自己
        }
        udp_clear_my_sockaddr(sock);
    }
    {
        // bind成功后,原来的四元组就失效了,要清掉
        SpinlockGuard sockGuard(map_quad_lock);
        udp_clear_my_quad(sock);
    }
    // 到这里才开始插入map和更新本地ip或端口
    sock.data.udp.local_ip = bind_conf->addr;
    sock.data.udp.local_port = bind_conf->port;
    {
        SpinlockGuard sockGuard(map_sockaddr_lock);
        map_sockaddr_to_sock[*bind_conf] = &sock;
    }
    return 0;
}

逻辑很清楚:
1. 检查目标 (addr, port) 是否已被其他socket占用
2. 清除该socket旧的二元组和四元组记录
3. 更新socket的本地IP和端口
4. 插入新的二元组记录到哈希表

connect(connected UDP)

UDP也可以调用 connect,但这并不会像TCP那样发送任何数据包——它只是在本地记录了对端的地址和端口,建立四元组映射:

int udp_connect(socket& sock, uint32_t addr, uint16_t port) {
    SpinlockGuard sockGuard(sock.lock);
    conn_quadruple current_conn = {.local_ip = sock.data.udp.local_ip,
                                   .remote_ip = htonl(addr),
                                   .local_port = sock.data.udp.local_port,
                                   .remote_port = htons(port)};
    {
        SpinlockGuard guard(map_quad_lock);
        auto itr = map_quad_to_sock.find(current_conn);
        if (itr != map_quad_to_sock.end() && itr->second != &sock) {
            return -1;
        }
        udp_clear_my_quad(sock);
        sock.data.udp.remote_ip = htonl(addr);
        sock.data.udp.remote_port = htons(port);
        map_quad_to_sock[current_conn] = &sock;
    }
    return 0;
}

调用 connect 之后,socket就有了固定的远端地址,此后可以用 write/read 直接收发数据,而不需要每次都用 sendto/recvfrom 指定地址。

发送数据

UDP发送数据有两种方式:
- udp_write:用socket内部记录的 remote_ipremote_port 发送(需要先connect)
- udp_sendto:通过参数指定对端地址发送(不需要connect)

先看 udp_write

int udp_write(socket& sock, const char* payload, uint32_t size) {
    void* packet = kmalloc(sizeof(pseudo_ip_header) + sizeof(udp_header) + size);
    uint32_t packet_size = sizeof(pseudo_ip_header) + sizeof(udp_header) + size;
    memset(packet, 0, packet_size);
    pseudo_ip_header* p_header = (pseudo_ip_header*)packet;
    udp_header* t_header = (udp_header*)((char*)packet + sizeof(pseudo_ip_header));

    p_header->src_addr = (sock.data.udp.local_ip == SOCKADDR_BROADCAST_ADDR) ?
                          getLocalNetconf()->ip.addr : sock.data.udp.local_ip;
    p_header->dst_addr = sock.data.udp.remote_ip;
    p_header->protocol = IP_PROTOCOL_UDP;
    p_header->zero = 0;
    p_header->data_length = htons(sizeof(udp_header) + size);

    t_header->src_port = sock.data.udp.local_port;
    t_header->dst_port = sock.data.udp.remote_port;
    t_header->size = htons(sizeof(udp_header) + size);
    t_header->checksum = 0;
    memcpy(((char*)packet + sizeof(pseudo_ip_header) + sizeof(udp_header)), payload, size);
    t_header->checksum = checksum(packet, packet_size);

    int ret = send_ipv4((ipv4addr(sock.data.udp.remote_ip)), IP_PROTOCOL_UDP,
                        t_header, sizeof(udp_header) + size);
    kfree(packet);
    return ret;
}

步骤:
1. 分配内存,构造 pseudo_ip_header(用于checksum计算)和 udp_header
2. 设置伪IP头中的源地址——如果本地IP是 SOCKADDR_BROADCAST_ADDR(即 0.0.0.0),则替换为网卡的实际IP
3. 设置UDP头的源端口、目的端口、长度
4. 拷贝payload数据
5. 计算校验和(使用伪IP头+UDP头的组合)
6. 调用 send_ipv4 将数据包发送到IP层

udp_sendto 的逻辑几乎一样,唯一的区别是目标IP和端口来自传入的 peeraddr 参数:

int udp_sendto(socket& sock, const char* buffer, uint32_t size, sockaddr* peeraddr) {
    uint32_t dst_ip;
    uint16_t dst_port;

    if (peeraddr) {
        dst_ip = peeraddr->addr;
        dst_port = peeraddr->port;
    } else {
        // 如果没有传入peeraddr,退而使用connect设置的远端地址
        SpinlockGuard guard(sock.lock);
        if (sock.data.udp.remote_ip == 0 && sock.data.udp.remote_port == 0)
            return -1;
        dst_ip = sock.data.udp.remote_ip;
        dst_port = sock.data.udp.remote_port;
    }
    // ... 后续构造包和发送逻辑与udp_write相同
}

sendto 允许在调用时动态指定对端地址,这是UDP的核心灵活性——无连接,每次发送都可以指向不同的对端。

接收数据

接收也同样有两种方式:udp_readudp_recvfrom

udp_recvfromudp_read 多一个 peeraddr 参数,用于获取发送方的地址信息:

int udp_recvfrom(socket& sock, char* buffer, uint32_t size, sockaddr* peeraddr) {
    int ret = -1;
    uint32_t flags = spinlock_acquire(&(sock.lock));

    if (sock.data.udp.pack_head) {
        // 队列中有数据,直接取出
        udp_pack* head = sock.data.udp.pack_head;
        size_t loadsize = size < head->size ? size : head->size;
        memcpy(buffer, head->data, loadsize);
        if (peeraddr) {
            peeraddr->addr = head->remote_ip;
            peeraddr->port = head->remote_port;
        }
        kfree(head->data);
        sock.data.udp.pack_head = head->next;
        if (sock.data.udp.pack_head == nullptr)
            sock.data.udp.pack_tail = nullptr;
        kfree(head);
        ret = loadsize;
    } else {
        // 队列为空,阻塞等待(带3秒超时)
        {
            SpinlockGuard guard(process_list_lock);
            process_list[cur_process_id]->state = process_state::WAITING;
            insert_into_waiting_queue(sock.wait_queue, process_list[cur_process_id]);
        }
        spinlock_release(&(sock.lock), flags);
        timeout(&(sock.wait_queue), 3000);
        flags = spinlock_acquire(&(sock.lock));

        if (sock.data.udp.pack_head) {
            // 被唤醒后再次尝试取数据
            // ... 相同逻辑 ...
        } else {
            ret = -2; // timed out
        }
    }

    spinlock_release(&(sock.lock), flags);
    return ret;
}

udp_read 的逻辑基本相同,只是不返回发送方地址。

这里有个重要的设计:UDP的接收队列。当内核的IP层收到一个UDP数据包时,udp_handler 会负责将数据包放入对应socket的队列中,并唤醒等待的进程。

udp_handler——内核接收端

这是UDP协议栈的核心入口,由IP层在收到UDP协议的数据包时调用:

void udp_handler(uint16_t ip_header_size, char* buffer, uint16_t size) {
    udp_header* header = reinterpret_cast<udp_header*>(buffer + ip_header_size);
    uint32_t src_ip = reinterpret_cast<ip_header*>(buffer)->src_ip;
    uint32_t dst_ip = reinterpret_cast<ip_header*>(buffer)->dst_ip;
    uint16_t src_port = header->src_port;
    uint16_t dst_port = header->dst_port;
    uint16_t payload_size = ntohs(header->size) - sizeof(udp_header);

    // 第一步:尝试通过四元组查找(connected UDP)
    uint32_t flags = spinlock_acquire(&map_quad_lock);
    auto itr = map_quad_to_sock.find(conn_quadruple {
        .local_ip = dst_ip, .remote_ip = src_ip,
        .local_port = dst_port, .remote_port = src_port
    });
    socket* target_sock = nullptr;
    if (itr == map_quad_to_sock.end()) {
        // 没在四元组表找到 → 尝试通过二元组查找(unconnected UDP)
        spinlock_release(&map_quad_lock, flags);
        sockaddr tofind_addr;
        tofind_addr.addr = dst_ip;
        tofind_addr.port = dst_port;
        {
            SpinlockGuard guard(map_sockaddr_lock);
            auto itr = map_sockaddr_to_sock.find(tofind_addr);
            if (itr == map_sockaddr_to_sock.end()) {
                // 按特定IP没找到 → 尝试0.0.0.0(通配绑定)
                tofind_addr.addr = 0;
                itr = map_sockaddr_to_sock.find(tofind_addr);
                if (itr == map_sockaddr_to_sock.end()) { return; } // 没人要这个包,丢弃
            }
            target_sock = itr->second;
        }
    } else {
        target_sock = itr->second;
    }
    spinlock_release(&map_quad_lock, flags);

    if (!target_sock) return;
    SpinlockGuard guard(target_sock->lock);

    // 将数据包放入socket的接收队列
    udp_pack* cur = (udp_pack*)kmalloc(sizeof(udp_pack));
    cur->next = nullptr;
    cur->size = payload_size;
    cur->data = kmalloc(payload_size);
    cur->remote_ip = src_ip;
    cur->remote_port = src_port;
    memcpy(cur->data, buffer + ip_header_size + sizeof(udp_header), payload_size);

    if (target_sock->data.udp.pack_tail) {
        target_sock->data.udp.pack_tail->next = cur;
        target_sock->data.udp.pack_tail = cur;
    } else {
        target_sock->data.udp.pack_head = cur;
        target_sock->data.udp.pack_tail = cur;
    }
    wake_all_queue(target_sock);
}

udp_handler 的查找逻辑很有意思,它是一个两级查找策略

  1. 先查四元组表:如果这个socket曾经 connect 过某个远端,那么四元组表中会有一条 (local_ip, local_port, remote_ip, remote_port) 的记录,可以直接精确命中。
  2. 再查二元组表:如果没找到四元组,说明这是一个unconnected UDP socket,那就根据目标IP和端口找绑定的socket。
  3. 通配回退:如果按具体目标IP没找到,再尝试找绑定到 0.0.0.0 的socket(通配绑定)。

找到对应的socket后,将数据包封装成 udp_pack 结点,追加到socket的接收链表尾部,然后唤醒等待队列中的进程。

echo server 的问题

回到我们之前写的客户端,如果我们要写一个echo server,简单地用 readwrite 会怎样?

// echo server 的错误写法
if (fds[0].revents & POLLIN) {
    int n = read(0, buff, sizeof(buff));
    if (n <= 0) break;
    write(conn, buff, n);  // ← 这里echo不回来!
}

这里echo不回来,因为我们没拿到对方的连接信息!UDP是无连接的,read 只是把数据读走了,但不知道是谁发来的。而 write 默认会使用socket内部记录的 remote_ipremote_port 发送,如果这个socket没有 connect 过,这些字段都是0,数据就发不出去了。

我们需要 recvfrom 接口去获取发送方的地址信息,还需要 sendto 去指定一个临时地址端口发送!

sendto 与 recvfrom

正确的echo server应该这样写:

if (fds[1].revents & POLLIN) {
    sockaddr peeraddr;           // 用于获取发送方地址
    int n = recvfrom(conn, buff, sizeof(buff), &peeraddr);
    if (n < 0) {
        printf("recv error\n");
        break;
    }
    if (n == 0) continue;
    buff[n] = '\0';
    printf("received %s from %x:%d\n", buff, peeraddr.addr, peeraddr.port);
    sendto(conn, buff, n, &peeraddr);  // 指定对端地址发回去
}

VFS层也通过 sock_operationsendtorecvfrom 做了支持:

struct sock_operation {
    int (*connect)(mounting_point* mp, uint32_t inode_id, const char* addr, uint16_t port);
    int (*listen)(mounting_point* mp, uint32_t inode_id, size_t queue_length);
    int (*accept)(mounting_point* mp, uint32_t inode_id, sockaddr* peeraddr, size_t* size);
    int (*sendto)(mounting_point* mp, uint32_t inode_id, const char* buffer, uint32_t size, sockaddr* peeraddr);
    int (*recvfrom)(mounting_point* mp, uint32_t inode_id, char* buffer, uint32_t size, sockaddr* peeraddr);
};

sockfs.cpp 中,sendtorecvfrom 被分发到UDP协议的处理函数:

int sendto(mounting_point* mp, uint32_t inode_id, const char* buffer, uint32_t size, sockaddr* peeraddr) {
    socket& sock = data->sock[inode_id];
    if (sock.ptcl == protocol::UDP) {
        return udp_sendto(sock, buffer, size, peeraddr);
    }
    return -1;
}

int recvfrom(mounting_point* mp, uint32_t inode_id, char* buffer, uint32_t size, sockaddr* peeraddr) {
    socket& sock = data->sock[inode_id];
    if (sock.ptcl == protocol::UDP) {
        return udp_recvfrom(sock, buffer, size, peeraddr);
    }
    return -1;
}

0.0.0.0 到网卡IP的转换

我们还需要做一个适配:当UDP socket绑定的本地IP是 0.0.0.0(通配地址)时,实际发送数据包时需要将它改为网卡的实际IP地址,否则接收方不知道该从哪里回复。

这个逻辑体现在 udp_writeudp_sendto 中:

p_header->src_addr = (sock.data.udp.local_ip == SOCKADDR_BROADCAST_ADDR) ?
                      getLocalNetconf()->ip.addr : sock.data.udp.local_ip;

local_ip == SOCKADDR_BROADCAST_ADDR(即 0)时,我们调用 getLocalNetconf()->ip.addr 获取当前网卡的IP地址作为源IP。

关闭

UDP的关闭比TCP简单得多——没有四次挥手,只需要清理哈希表记录和释放数据队列:

int udp_close(socket& sock) {
    SpinlockGuard sockGuard(sock.lock);
    {
        SpinlockGuard sockGuard(map_sockaddr_lock);
        udp_clear_my_sockaddr(sock);
    }
    {
        SpinlockGuard sockGuard(map_quad_lock);
        udp_clear_my_quad(sock);
    }
    udp_pack* cur = sock.data.udp.pack_head;
    while(cur) {
        kfree(cur->data);
        udp_pack* next = cur->next;
        kfree(cur);
        cur = next;
    }
    sock.data.udp.pack_head = nullptr;
    sock.data.udp.pack_tail = nullptr;
    wake_all_queue(&sock);
    sock.valid = 0;
    return 0;
}

清理哈希表 → 释放所有待处理数据包 → 唤醒等待队列 → 标记socket无效。

运行效果

UDP客户端运行截图


总结

UDP的实现比TCP简单太多了,总结一下我们做了哪些工作:

组件 说明
udp_init 初始化socket,绑定到随机端口
udp_bind 绑定到指定本地地址和端口,更新二元组哈希表
udp_connect 记录远端地址,更新四元组哈希表(不发送任何包)
udp_write / udp_sendto 构造UDP报文,通过IP层发送
udp_read / udp_recvfrom 从接收队列中读取数据,recvfrom额外返回发送方地址
udp_handler 内核中断处理函数,查找目标socket并放入接收队列
udp_close 清理哈希表和接收队列,释放资源

核心数据结构就是两张哈希表加上一个链表:
- 二元组哈希表 (local_ip, local_port) → socket:用于unconnected UDP的查找
- 四元组哈希表 (local_ip, remote_ip, local_port, remote_port) → socket:用于connected UDP的查找
- udp_pack 链表:每个socket内部的接收缓冲队列


下一节我们来实现信号。