从零写OS(四十五):TCP 服务端——bind/listen/accept

前面几章都是做 TCP 客户端——发 SYN、连接、发请求、收响应。这章反过来,让内核能作为 TCP 服务端:监听端口,接受连接,响应请求。 客户端 vs 服务端的区别 客户端:主动发 SYN,等待 SYN-ACK。 服务端:被动等待 SYN,收到后发 SYN-ACK,等 ACK 完成三次握手,然后通过 accept() 把新连接交给应用层。 关键区别在于:服务端有两种 socket 角色—— 角色 职责 监听 socket(listen fd) 绑定端口,等待连接请求 连接 socket(accept fd) 每个客户端连接对应一个,负责实际数据收发 三步接口 bind(fd, &sa, len) // 绑定本地端口 listen(fd, backlog) // 进入监听状态 accept(fd, &sa, &len) // 阻塞,等到有连接,返回新 fd 数据结构 tcp_sock_t 加了几个字段: int is_listener; int accept_queue[8]; // 存已完成三次握手的连接 index uint32_t accept_head, accept_tail; 还新增了两个状态: TCP_LISTEN:监听中,等待 SYN TCP_SYN_RECV:收到 SYN 已回 SYN-ACK,等最终 ACK 核心流程 1. 收到 SYN tcp_handle() 里单独处理 SYN: if ((tcp->flags & TCP_SYN) && !(tcp->flags & TCP_ACK)) { // 找到监听该端口的 socket // 分配新连接 slot ns->state = TCP_SYN_RECV; ns->local_port = dport; ns->remote_port = sport; ns->remote_ip = src_ip; ns->ack = ntohl(tcp->seq) + 1; // 下次要 ACK 的序号 ns->syn_seq = 0xABCD1234; ns->snd_una = ns->snd_nxt = ns->syn_seq; tcp_send_seg(ns, ns->syn_seq, TCP_SYN | TCP_ACK, 0, 0); } 2. 收到最终 ACK(完成三次握手) if (s->state == TCP_SYN_RECV && (tcp->flags & TCP_ACK)) { if (ack_val == s->syn_seq + 1) { s->snd_una = s->snd_nxt = ack_val; s->state = TCP_ESTABLISHED; // 放入监听 socket 的 accept queue ls->accept_queue[ls->accept_tail % 8] = i; ls->accept_tail++; } } 3. accept() 取连接 int tcp_accept(int s, ...) { tcp_sock_t *ls = &tcp_socks[s]; while (ls->accept_head == ls->accept_tail) { sti; net_poll(); cli; // 阻塞等待 } int ni = ls->accept_queue[ls->accept_head++ % 8]; return ni; } 测试 写了一个简单的 HTTP 服务端程序: ...

June 2, 2026 · 2 min · 大飞

从零写OS(四十四):UDP Socket + DNS 解析

上一章(四十三)把 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,满了就丢包: ...

June 2, 2026 · 3 min · 大飞

从零写OS(四十三):TCP 重传 —— 丢包了也能传完

上一章(四十二)修了一堆 busybox 相关的 bug,让 ls/exec/wait 都能正常工作,wget_test 也能跑通 HTTP。但那时的 TCP 实现有个隐患:一旦丢包,传输就会永远卡住。这一章把重传机制做完整。 原代码的问题 ch42 的 tcp_send_raw 函数身兼数职:构造 TCP 包、发送、存入发送缓冲区、推进 snd_nxt。 static void tcp_send_raw(tcp_sock_t *s, uint8_t flags, const void *data, uint16_t dlen) { ...发包... if (dlen) { ...存 sbuf... s->snd_nxt += dlen; } if (flags & (TCP_SYN | TCP_FIN)) s->seq++; // 推进 seq if (dlen) s->seq += dlen; // 又推进一次! } 问题一:seq 和 snd_nxt 是两个字段,但都在追踪"下一个要发的序号",语义重复,而且 seq 被推进了两次(SYN/FIN 一次、数据一次)。 问题二:重传时的代码非常丑陋: uint32_t saved_seq = s->seq; uint32_t saved_nxt = s->snd_nxt; s->seq = s->snd_una; s->snd_nxt = s->snd_una; tcp_send_raw(s, TCP_PSH | TCP_ACK, rtbuf, chunk); s->snd_nxt = saved_nxt; // 手动恢复 s->seq = saved_seq; 这种"临时改状态再恢复"的方式容易出错,而且没有指数退避。 ...

June 1, 2026 · 4 min · 大飞

从零写OS(四十二):让 busybox 跑起来 —— 符号链接、fork/exec、调度器 cpu_pin bug

上一章实现了 TCP/IP 栈,用户程序能发 HTTP 请求了。但验证时发现 /bin/ls 完全没反应,wget_test 也跑不起来。这一章记录排查过程——一共修了 6 个 bug,涉及 ext2 fast symlink、VFS 路径解析、内核栈溢出、缺失 syscall,以及两个调度器 cpu_pin 问题。 最终效果: / # ls bin etc lost+found / # cd bin /bin # ls busybox cp kill mv pwd sh wget_test cat echo ls ps rm umount /bin # wget_test wget_test start connecting... connected! request sent HTTP/1.0 200 OK ... DONE /bin # 背景:busybox 的目录结构 Makefile 用这种方式制作 ext2 镜像: sudo cp busybox-x86_64 /tmp/ext2mnt/bin/busybox sudo cp busybox-x86_64 /tmp/ext2mnt/bin/sh # sh 是真实复制 for cmd in ls cat echo pwd ...; do sudo ln -sf /bin/busybox /tmp/ext2mnt/bin/$cmd # 其他命令是符号链接 done /bin/sh 是真实文件,/bin/ls 等是指向 /bin/busybox 的符号链接。 ...

June 1, 2026 · 4 min · 大飞

从零写OS(三十七):网络栈 —— e1000 驱动 + ARP/IP/TCP + Socket

文件系统能读写了,这一章加网络支持。目标是实现一个最小 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)。 ...

May 22, 2026 · 4 min · 大飞

从零写OS(三十六):ext2 写操作 —— 文件创建、引用计数与 tty 字符设备

前几章文件系统都是只读的。这一章实现 ext2 的写路径,让 echo hello > /tmp/a.txt && cat /tmp/a.txt 能正常工作,且重启后文件仍然存在。过程中还修了 fd 引用计数和 tty 字符设备两个问题。 ext2 磁盘结构回顾 块组 0: [超级块][组描述符][block bitmap][inode bitmap][inode table][数据块...] 关键字段: sb->s_free_inodes_count / gd->bg_free_inodes_count:空闲 inode 计数 sb->s_free_blocks_count / gd->bg_free_blocks_count:空闲 block 计数 gd->bg_inode_bitmap:inode bitmap 所在块号 gd->bg_block_bitmap:block bitmap 所在块号 gd->bg_inode_table:inode table 起始块号 inode 号从 1 开始;前 10 个是系统保留的(root=2,lost+found=11)。 alloc_inode / alloc_block alloc_inode uint32_t alloc_inode(void) { // 1. 读 inode bitmap 块 // 2. 找第一个为 0 的位(bit i → inode i+1) // 3. 置位,写回 bitmap // 4. 更新超级块和组描述符的 free_inodes_count // 5. 返回 inode 号(从 1 开始) } alloc_block uint32_t alloc_block(void) { // 1. 读 block bitmap 块 // 2. 找第一个为 0 的位,跳过 block 0(保留) // 3. 置位,写回 bitmap // 4. 更新超级块和组描述符的 free_blocks_count // 5. 清零新分配的块(避免垃圾数据) // 6. 返回块号 } 注意:block bitmap 的 bit 0 对应 block 0,必须跳过(block 0 不存在)。实际第一个可分配的块由文件系统布局决定(本项目中是 610)。 ...

May 22, 2026 · 3 min · 大飞

从零写OS(三十五):getdents64 + chdir + 内存屏障 Bug

上一章 ls 能运行了,但打印出来的目录内容是空的——因为还没有实现 getdents64。这一章把目录列表、cd、pwd 全部补齐,同时碰到了一个 -O2 下内存乱序导致的调度器死循环 Bug。 SYS_GETDENTS64 Linux 接口 int getdents64(int fd, struct linux_dirent64 *dirp, unsigned int count); 每个条目的结构: struct linux_dirent64 { uint64_t d_ino; // inode 号 int64_t d_off; // 到下一条目的偏移(可用递增序号代替) uint16_t d_reclen; // 本条目总字节数(含填充,8 字节对齐) uint8_t d_type; // 文件类型(4=目录,8=普通文件) char d_name[]; // 文件名(null 结尾) }; d_reclen 必须 8 字节对齐,计算方式: uint16_t reclen = (uint16_t)((19 + namelen + 1 + 7) & ~7); // ↑固定头部 ↑名字 ↑null ↑对齐 内核实现 在 VFS 层实现 vfs_getdents,遍历 ext2 目录的所有条目,逐个填写 linux_dirent64 并写入用户缓冲区: int vfs_getdents(int fd, uint64_t uva, uint32_t count) { // 1. 检查 fd 是否为目录 // 2. 从 offset 开始遍历 ext2 目录 // 3. 跳过 "." 和 ".."(busybox ls 不显示它们) // 4. 每个条目:填 d_ino、d_type、d_name、d_reclen // 5. 通过 copy_to_user 写入用户空间 // 6. 更新 file->offset,返回写入的总字节数 } 关键:返回 0 表示目录已读完(EOF),busybox ls 据此停止调用。 ...

May 22, 2026 · 3 min · 大飞

从零写OS(三十四):阻塞式管道与调度器的两个 Bug

上一章实现了阻塞式 TTY,这一章在同样的思路上让 pipe 的 read 也变成阻塞式,同时修复了调度器里隐藏的两个 Bug,让 fork + exec + wait4 的完整流程跑通。 问题:非阻塞 pipe read 的后果 ch33 之前,vfs_read 对 pipe 的实现是: if (pipe_buf_empty(p)) return 0; // 管道空 → 直接返回 0 对于 shell 管道命令,busybox sh 会: fork 出子进程执行左侧命令,写 pipe 父进程(或另一子进程)读 pipe,处理右侧命令 如果读端在写端还没写入时就读到 0,shell 认为 EOF,管道提前关闭,命令输出丢失。 解决方案:sti/hlt 等待 与 ch33 阻塞式 TTY read 相同的思路:在内核态用 sti; hlt 轮询等待。 // pipe read:等待数据或写端关闭 while (pipe_buf_empty(p) && pipe_has_writer(p)) { __asm__ volatile("sti" ::: "memory"); __asm__ volatile("hlt" ::: "memory"); __asm__ volatile("cli" ::: "memory"); } if (pipe_buf_empty(p)) return 0; // 写端已关闭且无数据 → EOF 关键点: ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十三):阻塞式 TTY —— read 不再忙等

上一章 busybox sh 成功显示了 / # 提示符,但 shell 拿不到任何输入——因为 tty_read 是忙轮询的,没有字符就直接返回 0,shell 以为收到 EOF,立刻退出。这一章实现真正的阻塞式 TTY,让 shell 能等待用户输入。 之前的问题 之前的 tty_read 大概是这样: int tty_read(char *buf, int len) { // 轮询串口状态寄存器 if (!(inb(0x3F8 + 5) & 1)) return 0; // 没数据就返回 0 *buf = inb(0x3F8); return 1; } 这有两个问题: 没有输入时返回 0,上层程序(shell)以为是 EOF 如果上层在循环里调这个,CPU 100% 占用 正确做法:没有输入时挂起进程,等串口中断来了再唤醒。 串口中断(IRQ4) COM1 对应 IRQ4,接在 PIC 主片的 IR4 引脚。要用串口中断,需要两步: 1. 在 PIC 上 unmask IRQ4: // PIC 主片 IMR 寄存器:0 表示开放,1 表示屏蔽 uint8_t mask = inb(0x21); mask &= ~(1 << 4); // 清除 bit4,开放 IRQ4 outb(0x21, mask); 2. 开启串口的接收中断: ...

May 22, 2026 · 2 min · 大飞

从零写OS(三十二):启动 busybox sh —— 用户指针与内核页表的陷阱

ch31 已经能跑 musl libc 的 hello world 了,这一章目标更高:启动 busybox sh,看到 / # 提示符。busybox 是个真正的程序,碰到的问题也更真实。 准备工作 获取静态编译的 busybox wget https://busybox.net/downloads/binaries/1.35.0-x86_64-linux-musl/busybox chmod +x busybox file busybox # busybox: ELF 64-bit LSB executable, x86-64, statically linked 更新 ext2 镜像 busybox 需要 /bin/sh、/etc/passwd、/etc/group: sudo mkdir -p /tmp/ext2mnt/bin sudo mkdir -p /tmp/ext2mnt/etc sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/busybox sudo cp $(BUSYBOX) /tmp/ext2mnt/bin/sh printf 'root:x:0:0:root:/root:/bin/sh\n' | sudo tee /tmp/ext2mnt/etc/passwd > /dev/null printf 'root:x:0:\n' | sudo tee /tmp/ext2mnt/etc/group > /dev/null Bug 1:GDT TSS 描述符溢出 x86_64 下 TSS 描述符是 16 字节(两个 qword),GDT 必须为它预留两个连续槽位。之前只预留了一个,导致 TSS 描述符的高 8 字节覆盖了相邻的全局变量(恰好是 mmap_next),进程一分配匿名内存就跳到奇怪的地址。 ...

May 22, 2026 · 2 min · 大飞
京ICP备14031575号-3