文件系统能读写了,这一章加网络支持。目标是实现一个最小 TCP/IP 栈,让用户程序能通过 socket 发送 HTTP 请求并收到响应。
验证方式:
/ # wget_test
wget_test start
connecting...
connected!
request sent
HTTP/1.0 200 OK
...
DONE
架构概览
用户程序(wget_test)
↕ syscall: socket/connect/write/read/close
内核 socket 层(net.c)
↕ tcp_connect / tcp_send / tcp_recv / tcp_close
TCP 层(net.c)
↕ 以太网帧收发
ARP 层(net.c)
↕ e1000_send / e1000_recv
e1000 网卡驱动(e1000.c)
↕ MMIO + DMA
QEMU e1000 虚拟网卡(-netdev user,id=net0 -device e1000,netdev=net0)
PCI 枚举
e1000 通过 PCI 总线连接。内核在启动时枚举 PCI 设备:
void pci_enumerate(void) {
for (bus=0; bus<256; bus++)
for (dev=0; dev<32; dev++)
for (fn=0; fn<8; fn++) {
uint16_t vendor = pci_read16(bus, dev, fn, 0);
if (vendor == 0xFFFF) continue;
// 记录 vendor/device/bar0
}
}
e1000 的 vendor=0x8086,device=0x100E。BAR0 是 MMIO 基地址(通常 0xFEBC0000)。
必须调用 pci_enable_busmaster 打开总线主控位(command register 的 bit 2),否则 DMA 不工作。
e1000 驱动
初始化
- PCI 找到设备,读取 BAR0 物理地址
vmm_map_page映射 BAR0(128KB MMIO 区域)到内核页表- 复位网卡:
CTRL |= RST,等待,再清 RST - 从 EEPROM 读取 MAC 地址(寄存器 EERD)
- 初始化接收描述符环(32 个描述符,每个指向 4KB DMA 缓冲区)
- 初始化发送描述符环(32 个描述符)
- 启用接收(RCTL)和发送(TCTL)
DMA 描述符环
#define RX_DESC_COUNT 32
rx_descs = (rx_desc_t *)pmm_alloc();
for (int i = 0; i < RX_DESC_COUNT; i++) {
rx_bufs[i] = (uint8_t *)pmm_alloc();
rx_descs[i].addr = (uint64_t)rx_bufs[i]; // 物理地址给网卡
rx_descs[i].status = 0;
}
e1000_write(RDBAL, (uint32_t)(uint64_t)rx_descs);
e1000_write(RDBAH, (uint32_t)((uint64_t)rx_descs >> 32));
e1000_write(RDT, RX_DESC_COUNT - 1);
接收时:轮询 rx_descs[rx_tail].status & 0x01,有包则复制 rx_bufs[rx_tail] 并前进 rx_tail。
发送时:复制数据到 tx_bufs[tx_tail],设置描述符,前进 TDT,轮询 tx_descs[idx].status 直到完成。
以太网 / ARP
以太网帧格式:
[dst_mac:6][src_mac:6][ethertype:2][payload]
ARP 用于将 IP 地址解析为 MAC 地址:
- 发送 ARP request(广播),等待 ARP reply
- 缓存 IP→MAC 映射(
arp_table)
QEMU 的网关 IP 是 10.0.2.2,其 MAC 通过 ARP 解析得到。
IP / ICMP
IP 头(20 字节):
typedef struct __attribute__((packed)) {
uint8_t ihl_version; // 0x45
uint8_t tos;
uint16_t total_len; // 大端
uint16_t id, flags_frag;
uint8_t ttl, proto; // proto=6(TCP), 1(ICMP)
uint16_t checksum;
uint32_t src_ip, dst_ip;
} ip_hdr_t;
校验和计算:按 16 位分组求和,取反码。
TCP
连接状态机
CLOSED → SYN_SENT → ESTABLISHED → CLOSE_WAIT → LAST_ACK → CLOSED
tcp_connect 流程:
- 发送 SYN 包
- 轮询
net_poll(),等待收到 SYN-ACK - 发送 ACK,状态变为 ESTABLISHED
收发
发送(tcp_send):
// 构造 TCP 头(PSH+ACK),计算 checksum,包装 IP+以太网帧发出
tcp_send_packet(sock, PSH|ACK, data, len);
接收(tcp_recv):
// 轮询 net_poll() 直到 sock->rx_buf 有数据
// net_poll 调用 e1000_recv → 解以太网→IP→TCP → 写入 sock->rx_buf
TCP Checksum(伪首部)
TCP checksum 需要包含 IP 伪首部(src_ip + dst_ip + proto + tcp_len):
uint16_t tcp_checksum(ip_hdr_t *ip, tcp_hdr_t *tcp, uint16_t tcp_len) {
// 伪首部:src_ip(4) + dst_ip(4) + 0x00(1) + proto=6(1) + tcp_len(2)
// + TCP 头 + 数据
// 全部按 16bit 求和,取反码
}
Socket 层
内核维护最多 8 个 TCP socket 的简单表:
#define MAX_SOCKS 8
typedef struct {
int used;
uint32_t remote_ip;
uint16_t remote_port;
uint16_t local_port;
int state; // CLOSED/SYN_SENT/ESTABLISHED
uint32_t seq, ack;
uint8_t rx_buf[4096];
uint32_t rx_len;
} tcp_sock_t;
Syscall 接口
socket fd 使用 100+ 偏移量区分于普通文件 fd:
case SYS_SOCKET: {
int s = tcp_socket();
return (uint64_t)(s + 100); // 用户态 fd = sock_idx + 100
}
case SYS_CONNECT: {
int sfd = (int)a1 - 100; // 还原 sock_idx
tcp_connect(sfd, ip, port);
}
所有需要路由到 socket 的 syscall 都判断 fd >= 100:
SYS_WRITE→tcp_send(fd-100, ...)SYS_WRITEV→tcp_send(fd-100, ...)(每个 iovec 分别发送)SYS_READ→tcp_recv(fd-100, ...)SYS_CLOSE→tcp_close(fd-100)
QEMU 网络配置
qemu-system-x86_64 \
-m 256M \
-drive format=raw,file=myos.img \
-drive format=raw,file=ext2.img \
-netdev user,id=net0 \
-device e1000,netdev=net0 \
-nographic -serial mon:stdio
QEMU user-mode 网络(slirp):
- 虚拟机 IP:
10.0.2.15(手动配置内核 IP) - 网关/DNS:
10.0.2.2 - 主机 HTTP 服务:
python3 -m http.server 8080,虚拟机访问10.0.2.2:8080
测试程序(musl 静态链接)
// user/wget_test.c
int sock = socket(AF_INET, SOCK_STREAM, 0);
connect(sock, &sa, sizeof(sa)); // 连接 10.0.2.2:8080
write(sock, "GET / HTTP/1.0\r\nHost: 10.0.2.2:8080\r\n\r\n", ...);
int n = read(sock, buf, sizeof(buf)-1);
write(1, buf, n); // 输出 HTTP 响应
close(sock);
关键踩坑
字节序
网络字节序(大端)与 x86 主机字节序(小端)不同,所有 IP、端口字段需要手动转换:
// IP: 10.0.2.2 → 0x0A000202
uint32_t ip = (10 << 24) | (0 << 16) | (2 << 8) | 2;
// 端口 8080 → 网络字节序
uint16_t port_net = (8080 >> 8) | ((8080 & 0xFF) << 8);
ext2.img 大小
内核 ext2 读取器只支持单块组。ext2.img 必须用 -b 1024 count=8192(8MB,单块组):
dd if=/dev/zero of=ext2.img bs=1024 count=8192
mke2fs -t ext2 -b 1024 ext2.img
glibc vs musl
busybox 1.36.1 链接 glibc,glibc 的 ptmalloc 在我们的内核上会触发 malloc(): corrupted top size 堆损坏。解决方案:用 musl-gcc 静态编译测试程序,musl 的 malloc 实现更简单,不依赖 sbrk 的特定行为。
最终结果
[e1000] found at bus=0x00 bar0=0xfebc0000
[e1000] MAC=52:54:00:12:34:56
[e1000] init ok
/ # wget_test
wget_test start
connecting...
connected!
request sent
HTTP/1.0 200 OK
Server: SimpleHTTP/0.6 Python/3.x
...
DONE
PCI 枚举、e1000 驱动、ARP + IP + TCP、socket syscall、HTTP GET 全部正常。
小结
这一章的工作量是前几章里最大的,但每一层都相对独立:PCI 枚举找到网卡,e1000 驱动管 DMA 收发,ARP 解析 MAC,IP/TCP 处理头部和 checksum,socket 层把这些组合起来暴露给用户态。
最重要的细节是字节序(每个 IP/端口字段都要 bswap)和 TCP checksum 的伪首部。这两处容易出错,发出去的包对端会直接丢掉,调试只能靠在 QEMU 里抓包对比。