自制操作系统(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的去改就可以了。多出来的 sendto 和 recvfrom 是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_head 和 pack_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_ip 和 remote_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_read 和 udp_recvfrom。
udp_recvfrom 比 udp_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 的查找逻辑很有意思,它是一个两级查找策略:
- 先查四元组表:如果这个socket曾经
connect过某个远端,那么四元组表中会有一条(local_ip, local_port, remote_ip, remote_port)的记录,可以直接精确命中。 - 再查二元组表:如果没找到四元组,说明这是一个unconnected UDP socket,那就根据目标IP和端口找绑定的socket。
- 通配回退:如果按具体目标IP没找到,再尝试找绑定到
0.0.0.0的socket(通配绑定)。
找到对应的socket后,将数据包封装成 udp_pack 结点,追加到socket的接收链表尾部,然后唤醒等待队列中的进程。
echo server 的问题
回到我们之前写的客户端,如果我们要写一个echo server,简单地用 read 和 write 会怎样?
// 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_ip 和 remote_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_operation 对 sendto 和 recvfrom 做了支持:
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 中,sendto 和 recvfrom 被分发到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_write 和 udp_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的实现比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内部的接收缓冲队列
下一节我们来实现信号。

参与讨论
(Participate in the discussion)
参与讨论
没有发现评论
暂无评论