上一章(四十三)把 TCP 重传做好了。这一章加两个新功能:UDP socket 用户空间接口DNS 解析 syscall,让用户程序可以用域名来连接服务器。

目标

内核里其实早就有 udp_senddns_resolve 这两个函数了,但用户程序用不到,因为没有对应的 syscall。这章要做的就是把这两个内核能力"打通"到用户空间:

  • socket(AF_INET, SOCK_DGRAM, 0) → 分配一个 UDP socket,返回 fd 200+
  • sendto / recvfrom → 通过 UDP socket 收发数据
  • SYS_DNS_RESOLVE(自定义 syscall 500)→ 通过域名查 IP

UDP Socket 设计

UDP 比 TCP 简单很多:无连接、无状态机、无重传。核心是一个接收队列:内核收到 UDP 包时,把它放进对应 socket 的队列,用户程序再来取。

typedef struct {
    uint32_t src_ip;
    uint16_t src_port;
    uint16_t len;
    uint8_t  data[512];      // 每个包最多 512 字节
} udp_pkt_entry_t;

typedef struct {
    int              used;
    uint16_t         local_port;
    udp_pkt_entry_t  queue[4];  // 最多暂存 4 个包
    uint32_t         qhead, qtail;
} udp_sock_t;

队列用无限增长的 qhead/qtail 计数(不是环形下标),访问时用 % UDP_PKT_MAX,满了就丢包:

if (s->qtail - s->qhead >= UDP_PKT_MAX) return;  // 队满,丢弃

注意:不能用 (qtail+1) % N == qhead % N 这种写法,因为两个无限计数对同一个模数取余,比较是错的。

fd 空间划分

系统里现在有三种 fd:

  • 普通文件 fd:0~99
  • TCP socket fd:100~199(TCP_FD_OFFSET = 100
  • UDP socket fd:200+(UDP_FD_OFFSET = 200

内核所有需要区分协议的 syscall(read/write/sendto/recvfrom/shutdown)都通过 fd 范围判断走哪条路。

内核 UDP 分发

内核收到 UDP 包时,udp_handle 负责分发:

static void udp_handle(const ip_hdr_t *ip, ...) {
    if (dport == 1025) {
        dns_handle_udp(data, ulen);  // 内核自己的 DNS 回包
        return;
    }
    // 查 udp_socks 找匹配的 local_port
    for (int i = 0; i < UDP_MAX_SOCKS; i++) {
        if (!s->used || s->local_port != dport) continue;
        // 把包放进队列
        s->qtail++;
        return;
    }
}

端口 1025 是内核给自己发 DNS 查询时用的本地端口,回包直接给内核处理。其他端口分发给用户 socket。

DNS Syscall

DNS 解析的内核函数 dns_resolve 已经实现了,这里只需要暴露接口:

// syscall 500
case SYS_DNS_RESOLVE: {
    char hbuf[256];
    uint32_t hlen = (uint32_t)a2;
    copy_from_user(hbuf, a1, hlen);   // 从用户空间复制 hostname
    hbuf[hlen] = 0;
    uint32_t ip = 0;
    int r = dns_resolve(hbuf, &ip);
    if (r < 0) return -ETIMEDOUT;
    copy_to_user(a3, &ip, 4);         // 把 IP 写回用户空间
    return 0;
}

用户程序的 wrapper 非常简单:

static int my_dns_resolve(const char *hostname, unsigned int *ip_out) {
    int hlen = strlen(hostname);
    return syscall6(500, (long)hostname, hlen, (long)ip_out, 0, 0);
}

wget_test 里先尝试 DNS,失败就用硬编码 IP:

unsigned int server_ip = 0;
if (my_dns_resolve("daifei.me", &server_ip) < 0) {
    server_ip = /* 10.0.2.2 hardcoded */;
}

一个意外的 Boot Loader 问题

加了 UDP socket 之后,kernel.bin 从 63360 字节涨到了 65824 字节(多了 ~2.5KB),超过了 boot loader 的读取范围。

原来的 boot loader:

mov al, 127  ; 只读 127 扇区 = 65024 字节
int 0x13

65824 字节需要 129 个扇区,127 扇区不够,内核末尾的代码没有被加载,导致 boot loop。

第一个想法:改成 mov al, 160。结果:Disk error!

原来 SeaBIOS 的 INT 0x13 AH=0x02(CHS 读盘)单次最多读 127 扇区,超过就报错。

正确方法:两次 CHS 读取。

; 第一次:从 sector 2(LBA=1)读 127 扇区,放到 0x1000:0x0000
mov al, 127 ; mov cl, 2 ; int 0x13

; 第二次:从 LBA=128 读 33 扇区,放到 0x1000:0xFE00
mov bx, 127 * 512    ; 接着第一次的末尾
mov al, 33
mov cl, 3  ; LBA128 的 CHS sector
mov dh, 2  ; head
int 0x13

LBA=128 对应 CHS(假设 63 sectors/track, 16 heads):

  • sector = (128 % 63) + 1 = 3
  • head = (128 / 63) % 16 = 2
  • cylinder = 0

改完之后,内核完整加载,启动成功。

调试过程小结

这次调试比较曲折,主要踩了这几个坑:

  1. 先怀疑 LBA 扩展读不工作:切换到 INT 0x13 AH=0x42(LBA 扩展读),反复调试 DAP 结构,最后才发现其实问题不在 LBA,而是 CHS 的 127 扇区上限
  2. al=160 失败:以为 CHS 跨磁道了,其实是 SeaBIOS 的硬编码上限
  3. DAP 地址问题:LBA 模式下,读 DAP 之前要确保 ds=0,因为 e820 内存探测循环会改变 ds

运行结果

QEMU NAT 环境下跑起来:

wget_test start
resolving daifei.me via DNS...
DNS failed, using hardcoded IP    ← QEMU NAT 不转发 DNS UDP
connecting...
[tcp] SYN
[tcp] ESTABLISHED
connected!
request sent
HTTP/1.0 200 OK
...
DONE

DNS 在 QEMU 里失败是预期的——QEMU 的 NAT 默认不转发 UDP 到 8.8.8.8。Fallback 到硬编码 IP,TCP 连接正常,HTTP 请求成功。

关键数据

项目
UDP socket 数 4
单包最大数据 512 字节
每 socket 队列深度 4 包
BSS 新增 ~8.4KB
kernel.bin 增量 +2464 字节
DNS syscall 号 500(自定义)
UDP fd 起始 200