《从零手写操作系统 (37):网络协议栈基础——Socket接口与TCP状态机》

前言:从"本机IPC"到"跨越网络的对话"

在前面的章节中,我们构建了完善的本地进程间通信机制。但现代操作系统的核心价值在于连接。当你的OS只能与自身对话时,它只是一个精致的沙盒;只有当它能通过网线与世界交换比特时,它才真正成为互联网生态的一部分。

网络协议栈的本质是"将不可靠的物理传输抽象为可靠的字节流契约" 。以太网会丢包、IP会乱序、中间路由器会拥塞,但应用程序期望的是一个像管道一样简单的read/write接口。BSD Socket API就是这个抽象的巅峰:它将复杂的协议分层、状态管理、缓冲区调度封装在四个动词(bind/listen/accept/connect)和两个数据操作(send/recv)之中。本章我们将实现最小但完整的TCP/IP协议栈,让你的OS能够响应真实的HTTP请求。

本章里程碑:

  • ✅ BSD Socket接口:socket/bind/listen/accept/connect/send/recv
  • ✅ IP层:校验和计算、分片重组基础、路由表查找
  • ✅ TCP状态机:SYN/SYN-ACK/ACK三次握手、FIN四次挥手
  • ✅ 滑动窗口与序列号:乱序重排、重复ACK检测
  • ✅ 接收/发送缓冲区:环形缓冲、流量控制基础
  • ✅ 验证:用curl访问OS上的echo HTTP服务器,正确返回200 OK

核心概念:Socket不是"网络连接",而是"协议端点的文件描述符"
Socket的文件描述符语义

许多初学者将socket视为特殊的网络对象。但在Unix哲学中,socket就是一个文件描述符,它复用VFS的open/read/write/close接口。这意味着:

  • select/poll/epoll可以统一监控socket和普通文件
  • dup2可以将socket重定向到stdin/stdout(CGI/FastCGI的基础)
  • sendmsg(SCM_RIGHTS)可以通过Unix socket传递socket fd给其他进程

⚠️ 关键洞察 :Socket的实现必须深度集成VFS,而非旁路实现 。如果你的sys_read对socket有特殊分支、sys_select需要单独的网络轮询逻辑,你就破坏了"一切皆文件"的统一性。正确的做法是为socket创建专用的file_operations结构体,其中.read指向tcp_recv、.write指向tcp_send、.poll指向tcp_poll。VFS层完全不感知网络协议的存在,它只是调用函数指针。这种解耦使得未来添加UDP、Unix Domain Socket、TLS等协议时,VFS代码零修改。

TCP状态机的核心:不是记忆状态,而是理解事件驱动

TCP RFC 793定义了11个状态,但死记硬背状态图是徒劳的。TCP的本质是一个事件驱动的有限状态机,只有三类事件:

  1. 应用层调用:connect(), send(), close()
  2. 收到报文段:SYN, ACK, FIN, RST, DATA
  3. 超时:重传定时器、TIME_WAIT定时器

每个状态只对特定事件有定义良好的响应。例如在ESTABLISHED状态收到SYN是非法的(应发RST),而在LISTEN状态收到SYN则触发向SYN_RCVD迁移并回复SYN-ACK。实现时不要用switch-case嵌套if-else ,而应构建显式的事件处理表:state_table[current_state][event_type] = handler_function。这使得状态转换逻辑可测试、可审计、可扩展。

为什么必须先实现接收再实现发送?

教学顺序通常是先讲发送再讲接收,但实现顺序必须反过来。因为:

  • 发送需要知道对端的窗口大小(从收到的ACK中获取)
  • 重传需要知道哪些序列号已被确认(依赖接收端的ACK处理)
  • 连接建立需要先能处理入站SYN(被动打开比主动打开更基础)
  • 调试时可以用Wireshark抓包验证接收逻辑,而发送错误可能导致对端RST难以诊断

先让OS能正确响应外部SYN并完成三次握手,再实现主动connect和数据发送。这个顺序将"可观测性"最大化:每一步都有外部工具可以验证。


实战代码
Socket VFS集成
复制代码
// kernel/net/socket.c
#include "vfs.h"
#include "net.h"

// ★ Socket作为特殊文件类型融入VFS
static ssize_t socket_read(struct file *filp, char *buf, size_t len) {
    struct socket *sock = filp->private_data;
    return sock->ops->recv(sock, buf, len, 0);
}

static ssize_t socket_write(struct file *filp, const char *buf, size_t len) {
    struct socket *sock = filp->private_data;
    return sock->ops->send(sock, buf, len, 0);
}

static unsigned int socket_poll(struct file *filp, poll_table *wait) {
    struct socket *sock = filp->private_data;
    return sock->ops->poll(sock, wait);
}

static int socket_close(struct inode *inode, struct file *filp) {
    struct socket *sock = filp->private_data;
    sock->ops->close(sock);
    kfree(sock);
    return 0;
}

static const struct file_operations socket_fops = {
    .read  = socket_read,
    .write = socket_write,
    .poll  = socket_poll,
    .close = socket_close,
};

// ★ sys_socket: 分配fd并绑定socket ops
int sys_socket(int domain, int type, int protocol) {
    if (domain != AF_INET || type != SOCK_STREAM) 
        return -EAFNOSUPPORT;
    
    struct socket *sock = kmalloc(sizeof(struct socket));
    sock->state = SS_UNCONNECTED;
    sock->ops = &tcp_proto_ops;  // TCP协议操作集
    tcp_init_sock(sock);         // 初始化TCB
    
    // ★ 关键:通过VFS分配fd,而非独立编号
    int fd = alloc_fd();
    current->files->fd[fd].inode = NULL;  // socket无inode
    current->files->fd[fd].f_op = &socket_fops;
    current->files->fd[fd].private_data = sock;
    current->files->fd[fd].flags = O_RDWR;
    
    return fd;
}
TCP状态机核心
复制代码
// kernel/net/tcp_state.c
typedef enum {
    EVT_APP_CONNECT, EVT_APP_SEND, EVT_APP_CLOSE,
    EVT_RCV_SYN, EVT_RCV_ACK, EVT_RCV_FIN, EVT_RCV_RST,
    EVT_TIMEOUT_RETRANSMIT, EVT_TIMEOUT_TIMEWAIT,
    EVT_MAX
} tcp_event_t;

typedef void (*tcp_handler_t)(struct tcp_sock *tsk, void *arg);

// ★ 显式状态转换表(部分展示)
static tcp_handler_t tcp_state_table[TCP_STATE_MAX][EVT_MAX];

void tcp_register_handlers(void) {
    // LISTEN状态
    tcp_state_table[TCP_LISTEN][EVT_RCV_SYN] = tcp_listen_syn;
    
    // SYN_SENT状态  
    tcp_state_table[TCP_SYN_SENT][EVT_RCV_SYN_ACK] = tcp_syn_sent_synack;
    
    // ESTABLISHED状态
    tcp_state_table[TCP_ESTABLISHED][EVT_RCV_ACK] = tcp_established_ack;
    tcp_state_table[TCP_ESTABLISHED][EVT_RCV_FIN] = tcp_established_fin;
    tcp_state_table[TCP_ESTABLISHED][EVT_APP_SEND] = tcp_established_send;
    
    // ... 其他状态转换 ...
}

// ★ 统一事件分发入口
void tcp_input(struct tcp_sock *tsk, struct sk_buff *skb) {
    tcp_event_t evt = classify_packet(skb); // 解析报文→事件类型
    
    tcp_handler_t handler = tcp_state_table[tsk->state][evt];
    if (handler) {
        handler(tsk, skb);
    } else {
        // 未定义的事件→发RST或静默丢弃
        tcp_send_rst(tsk, skb);
    }
}

// ★ LISTEN状态收到SYN的处理
static void tcp_listen_syn(struct tcp_sock *tsk, void *arg) {
    struct sk_buff *skb = arg;
    
    // 创建子socket(半连接)
    struct tcp_sock *child = tcp_create_child(tsk, skb);
    child->state = TCP_SYN_RCVD;
    
    // 记录对端ISN,生成自己的ISN
    child->rcv_nxt = tcp_hdr(skb)->seq + 1;
    child->snd_una = generate_isn();
    child->snd_nxt = child->snd_una + 1; // SYN占一个序列号
    
    // ★ 发送SYN-ACK
    tcp_send_synack(child);
    
    // 加入半连接队列(backlog)
    listen_queue_add(tsk, child);
}
接收缓冲区与序列号管理
复制代码
// kernel/net/tcp_recv.c
#define TCP_RECV_BUF_SIZE 65536

typedef struct tcp_recv_buf {
    uint8_t data[TCP_RECV_BUF_SIZE];
    uint32_t head;          // 下一个可读位置(已确认连续)
    uint32_t tail;          // 下一个写入位置
    uint32_t rcv_nxt;       // 期望的下一个序列号
    spinlock_t lock;
    wait_queue_t waiters;   // read()阻塞等待队列
} tcp_recv_buf_t;

// ★ 处理入站数据段:乱序缓存+连续交付
int tcp_receive_data(struct tcp_sock *tsk, struct sk_buff *skb) {
    tcp_recv_buf_t *rb = &tsk->recv_buf;
    uint32_t seq = tcp_hdr(skb)->seq;
    uint32_t len = skb->len;
    
    spin_lock(&rb->lock);
    
    // ★ 检查序列号是否在窗口内
    if (!tcp_seq_in_window(seq, rb->rcv_nxt, tsk->rcv_wnd)) {
        // 窗口外数据→发重复ACK提示对端
        tcp_send_ack(tsk);
        spin_unlock(&rb->lock);
        return -EINVAL;
    }
    
    // ★ 计算环形缓冲区写入偏移
    uint32_t offset = (seq - rb->rcv_nxt + rb->head) % TCP_RECV_BUF_SIZE;
    
    // 拷贝数据到接收缓冲区
    ring_buffer_write(rb->data, TCP_RECV_BUF_SIZE, 
                      offset, skb->data, len);
    
    // ★ 更新rcv_nxt(简化版:仅处理按序到达)
    // 生产级实现需要SACK/乱序队列
    if (seq == rb->rcv_nxt) {
        rb->rcv_nxt += len;
        rb->tail = (rb->tail + len) % TCP_RECV_BUF_SIZE;
        
        // ★ 唤醒阻塞的read()
        wait_queue_wake_all(&rb->waiters);
        
        // ★ 发送ACK确认
        tcp_send_ack(tsk);
    }
    // else: 乱序数据暂存,等待缺口填补后批量确认
    
    spin_unlock(&rb->lock);
    return 0;
}

// ★ sys_read → tcp_recv:阻塞直到有数据
ssize_t tcp_recv(struct socket *sock, char *buf, size_t len, int flags) {
    struct tcp_sock *tsk = sock->sk;
    tcp_recv_buf_t *rb = &tsk->recv_buf;
    
    spin_lock(&rb->lock);
    while (rb->head == rb->tail) { // 缓冲区空
        if (tsk->state == TCP_CLOSE_WAIT) {
            spin_unlock(&rb->lock);
            return 0; // EOF
        }
        spin_unlock(&rb->lock);
        wait_queue_sleep(&rb->waiters); // 阻塞
        spin_lock(&rb->lock);
    }
    
    // 从环形缓冲区读取
    size_t avail = (rb->tail - rb->head + TCP_RECV_BUF_SIZE) 
                   % TCP_RECV_BUF_SIZE;
    size_t to_read = min(len, avail);
    ring_buffer_read(rb->data, TCP_RECV_BUF_SIZE, 
                     rb->head, buf, to_read);
    rb->head = (rb->head + to_read) % TCP_RECV_BUF_SIZE;
    
    // ★ 窗口更新:释放空间后通知对端可以发送更多
    tsk->rcv_wnd += to_read;
    if (to_read > 0) tcp_send_ack(tsk); // piggyback window update
    
    spin_unlock(&rb->lock);
    return to_read;
}

关键细节解析

1. 为什么TCP序列号是32位且会回绕?

32位序列号空间约4GB,在千兆网络上~34秒就会回绕。这要求所有序列号比较必须是模2^32运算 ,不能用普通整数比较。正确的方法是(a - b) < c而非a < b + c,前者在模运算下正确判断"a是否在b之后的c范围内"。任何使用> <直接比较序列号的代码都是bug 。Linux内核提供before()/after()宏封装此逻辑,教学实现中应尽早定义tcp_seq_lt(a,b)宏并全局替换所有裸比较。

2. 为什么LISTEN状态的SYN不直接创建完整socket?

因为三次握手尚未完成 ,对端可能永不发送第三个ACK。若立即创建完整TCB并分配接收缓冲区,SYN Flood攻击可以用极低成本耗尽内核内存。正确做法是使用轻量级的"半连接"(request_sock),仅保存五元组和ISN,放入backlog队列。只有在收到ACK完成握手后,才升级为完整TCB。半连接队列和全连接队列的分离是抗DoS的第一道防线。教学实现中可简化为直接创建子socket,但必须在注释中标明这是安全妥协。

3. 为什么recv要piggyback窗口更新?

TCP的流量控制依赖接收方通告窗口大小。若每次read()都单独发ACK更新窗口,会产生大量小包浪费带宽。将窗口更新附着在数据ACK上(piggyback) ,仅在窗口变化超过阈值(如MSS/2)或定时器到期时才发送。这减少了30-50%的纯ACK包。教学实现中可每次recv都发ACK保证正确性,但应预留tcp_should_send_window_update()判断函数供后续优化。


调试Checklist:网络协议栈排查
症状 可能原因 排查方法
SYN无响应 网卡驱动未注册/IP校验和错/路由表缺失/端口未bind tcpdump确认SYN到达;hexdump IP头验证checksum;dump路由表和listen队列;测试ping是否通
三次握手失败 ISN计算错/SYN-ACK序列号off-by-one/状态转换遗漏 Wireshark对比seq/ack值;dump TCB的snd_nxt/rcv_nxt;验证state_table中SYN_RCVD→ESTABLISHED的ACK处理
数据丢失/乱序 接收缓冲区溢出/序列号比较用错运算符/ACK未及时发送 dump recv_buf head/tail/rcv_nxt;搜索所有seq比较确认使用tcp_seq_lt;测试小窗口下是否触发零窗口探测
read()永久阻塞 wait_queue未唤醒/rcv_nxt未更新/TCP状态非ESTABLISHED kprintf tcp_receive_data中的wake调用;验证ring_buffer_write后tail更新;dump socket state确认非CLOSE/FIN_WAIT
curl返回Connection Reset RST发送条件过宽/未处理半关闭/校验和错误导致对端丢弃 Wireshark确认RST来源是本端还是对端;dump RST触发条件日志;验证TCP checksum计算包含伪头部
高吞吐下性能崩溃 每包一次syscall/未实现Nagle算法/接收缓冲区过小 perf统计syscall频率;测试禁用Nagle后延迟变化;增大recv_buf后测试吞吐量曲线

🔧 黄金法则 :网络栈调试的终极武器是Wireshark + 内核状态联合视图 。永远不要只看一端:同时运行Wireshark抓包和debug_dump_tcp_sockets()(打印所有TCB的状态、序列号、窗口、缓冲区水位)。网络bug几乎总是"两端认知不一致":本端认为已发ACK但对端因校验和错误丢弃了、本端rcv_nxt=1000但对端发的seq=999被当作旧包丢弃、本端窗口通告为0但对端仍有in-flight数据。只有将WireShark的线上真相与内核内存中的状态真相并置对比,才能发现这些跨主机的认知裂缝。不要在应用层加printf调试网络------你看到的只是冰山一角,协议层的沉默交互才是沉船的原因。


本章小结与下一步

今天我们让OS从"本机沙盒"进化为"网络公民":

  • ✅ BSD Socket完整融入VFS,复用fd/select/dup2基础设施
  • ✅ TCP状态机以事件驱动表实现,覆盖三次握手与四次挥手
  • ✅ 接收缓冲区支持按序交付与阻塞read语义
  • ✅ 序列号模2^32运算避免回绕bug
  • ✅ 验证:curl成功访问OS内置HTTP echo服务器

从此,你的操作系统拥有了接入真实互联网的能力。当你第一次看到Wireshark中自己实现的TCP握手与Linux客户端完美对接、curl返回200 OK、多个并发连接各自维护独立状态时,你见证的是OS从"能隔离进程"到"能参与全球对话"的历史性跨越。

下一章预告:《虚拟文件系统重构:从ramfs到ext2/minix只读驱动》

当前的文件系统仅支持内存驻留的ramfs。下一章将实现真正的磁盘文件系统驱动,让你的OS能够持久化存储数据,并在重启后恢复状态。


参考资料
  • W. Richard Stevens, TCP/IP Illustrated Vol.1 Ch.17-24
  • Linux Kernel: net/ipv4/tcp_input.c, net/socket.c
  • RFC 793 (TCP), RFC 1122 (Host Requirements)
  • Samy Al Bahra, "Building a TCP Stack from Scratch" (优秀实践指南)
  • 本系列完整代码:你的GitHub仓库链接(Commit: t1c2p3s)

📝 作者注 :这是《从零手写操作系统》系列的第37篇。网络协议栈是整个教程中规范最庞大、交互最复杂、调试最依赖外部工具 的章节。TCP RFC长达数百页,状态机有数十种边界情况,序列号算术处处是陷阱。强烈建议先用raw socket实现ICMP ping验证IP层正确性,再用被动打开(listen/accept)验证TCP握手,最后才实现主动connect和数据传输。把"IP层+校验和"、"TCP握手"、"数据传输+缓冲区"分成三个独立里程碑,并用Wireshark+内核dump双重验证每一步的协议合规性,是避免在RFC迷宫中迷失的关键纪律。 下一章,我们让存储从"易失内存"走向"持久磁盘"!

相关推荐
wdfk_prog1 小时前
LWIP教程 10:从 `tcp_receive()` 到 `ooseq` / SACK——TCP 乱序、重组与选择确认
网络·数据库·tcp/ip
砚凝霜2 小时前
【软考信息安全】第十七章 网络安全应急响应概述与事件处理流程
网络·安全·web安全
在角落发呆2 小时前
一键脚本配置备份方法:从 Shell 脚本到自动备份
网络·windows
星 海2 小时前
自定义报文解析器
网络
工作10年+,存储芯片行业2 小时前
企业级 SSD 耐久度评测(JESD218 / JESD219 + FIO)
网络·ssd·nvme·存储·pcie·nand·fio
Eloudy3 小时前
RoCE 的 UC SEND/RECEIVE 的完整交互过程分析
网络·rdma·roce
花间相见3 小时前
【计算基础|网络10】TCP(中):可靠传输
网络·网络协议·tcp/ip
谢亮_vipxieliang3 小时前
多智能体系统的“TCP/IP时刻”:MCP与A2A协议如何定义Agent时代
网络·tcp/ip·ai·php
草莓熊Lotso4 小时前
【C++ 基于微服务的即时通讯系统】基建三件套:gflags + gtest + spdlog 源码编译安装与实战入门
linux·网络·数据库·c++·微服务·log4j