文件系统能读写了,这一章加网络支持。目标是实现一个最小 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=0x8086device=0x100E。BAR0 是 MMIO 基地址(通常 0xFEBC0000)。

必须调用 pci_enable_busmaster 打开总线主控位(command register 的 bit 2),否则 DMA 不工作。

e1000 驱动

初始化

  1. PCI 找到设备,读取 BAR0 物理地址
  2. vmm_map_page 映射 BAR0(128KB MMIO 区域)到内核页表
  3. 复位网卡:CTRL |= RST,等待,再清 RST
  4. 从 EEPROM 读取 MAC 地址(寄存器 EERD)
  5. 初始化接收描述符环(32 个描述符,每个指向 4KB DMA 缓冲区)
  6. 初始化发送描述符环(32 个描述符)
  7. 启用接收(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 流程:

  1. 发送 SYN 包
  2. 轮询 net_poll(),等待收到 SYN-ACK
  3. 发送 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_WRITEtcp_send(fd-100, ...)
  • SYS_WRITEVtcp_send(fd-100, ...)(每个 iovec 分别发送)
  • SYS_READtcp_recv(fd-100, ...)
  • SYS_CLOSEtcp_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 里抓包对比。