上一章(四十三)把 TCP 重传做好了。这一章加两个新功能:UDP socket 用户空间接口 和 DNS 解析 syscall,让用户程序可以用域名来连接服务器。
目标
内核里其实早就有 udp_send 和 dns_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
改完之后,内核完整加载,启动成功。
调试过程小结
这次调试比较曲折,主要踩了这几个坑:
- 先怀疑 LBA 扩展读不工作:切换到
INT 0x13 AH=0x42(LBA 扩展读),反复调试 DAP 结构,最后才发现其实问题不在 LBA,而是 CHS 的 127 扇区上限 al=160失败:以为 CHS 跨磁道了,其实是 SeaBIOS 的硬编码上限- 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 |