
📚 本文收录于「流浪」的系列专栏
| 🐧 Linux系统 | ⚙️ C++ |
| 📊 数据结构与算法 | 🐍 Python |
| 🔗 LangChain & LangGraph | 🗄️ MySQL 数据库 |
| 🌿 Git 工具 | 🌐 计算机网络 |
| 🤖 LLM | 💯 大厂面试、八股 |
| 📚 学习筑基专栏 |
🏠 博客主页:流浪 | 📝 原创首发于 CSDN
前言: 篇18 把窗口、重传、拥塞控制讲完,传输层的机制面收齐了。还剩最后一件事:socket() 返回的那个 fd,在内核里到底牵着哪些对象。这篇顺着 fd 一路往下摸,把 file、socket、sock、sk_buff、tcp_sock 串成一条链,传输层到此收官。
一、fd 摸进内核的第一站:struct file
1.1 从 fd 表到 struct file
struct task_struct { // 进程控制块,内核描述一个进程的总部
/* …成百上千个成员,这里只看文件相关… */
struct files_struct *files; // 指向该进程打开文件信息的指针
};
struct files_struct { // 进程打开的所有文件的总账本
struct fdtable __rcu *fdt; // 指向 fd 表(可扩容的指针数组)
struct file __rcu *fd_array[NR_OPEN_DEFAULT]; // 默认 fd 表,小进程直接用这块
};
struct file { // 内核对「一个被打开的文件」的描述
const struct file_operations *f_op; // 操作方法集:read/write/poll 等函数指针
void *private_data; // 私有数据指针,指向具体设备的私有对象
fmode_t f_mode; // 打开模式:可读、可写
loff_t f_pos; // 当前读写偏移(文件位置)
atomic_long_t f_count; // 引用计数,多少个 fd 在共用这个 file
};

结论:fd 是下标,file 是实体;查表动作只有一次指针跳转,这就是「一切皆文件」能落到代码里的前提。
1.2 private_data:文件部分和网络部分的接头


结论:struct file 是统一接口层,private_data 是它伸向具体设备的钩子;钩住 socket,read/write 就通到了网络。

二、struct socket 与 struct sock:一个管接口,一个管协议
2.1 struct socket 主要成员
struct socket { // BSD socket 层的套接字,一个 fd 对应一个
socket_state state; // 套接字状态:未连接、正在连接、已连接等
short type; // 类型:SOCK_STREAM(字节流)/ SOCK_DGRAM(数据报)
unsigned long flags; // 标志位
struct file *file; // 回指所属的 struct file,两头互相找得到
struct sock *sk; // 指向协议层状态对象,真正干活的在下面
const struct proto_ops *ops; // 协议操作表:bind/connect/accept 等函数指针
struct socket_wq wq; // 等待队列,阻塞在这个 socket 上的进程挂这里
};
结论:struct socket 管接口与身份,struct sock 管队列与协议;一个薄一个厚,薄的那层是为了把「统一接口」和「具体协议」解耦。
2.2 等待队列:读不到数据时进程去哪

结论:等待队列是阻塞 IO 的实现底座;「谁的数据到了就唤醒谁」靠的是哈希表按四元组定位,不是遍历。
2.3 struct sock 主要成员
struct sock { // 协议层的套接字,收发数据真正发生在它身上
struct sock_common __sk_common; // 公共部分(网络地址等,各协议族共用)
struct sk_buff_head sk_receive_queue; // 接收队列:到达的报文排队挂这里
struct sk_buff_head sk_write_queue; // 发送队列:待发出的报文排队挂这里
int sk_state; // TCP 状态机:ESTABLISHED、LISTEN 等
unsigned long sk_flags; // 标志位
struct socket *sk_socket; // 回指 BSD 层的 struct socket
const struct proto *sk_prot; // 协议操作函数表(TCP 一套、UDP 一套)
atomic_t sk_refcnt; // 引用计数
int sk_err; // 错误码
};
结论:struct sock 管队列与协议状态;读路径七站接力,每一站都是一个结构体成员指针在带路。
2.4 sk_buff:报文的马甲
struct sk_buff { // 报文的描述结构,全内核网络子系统的通用货币
struct sk_buff *next; // 双向链表后继:串成收/发队列
struct sk_buff *prev; // 双向链表前驱
unsigned char *head; // 数据缓冲区的起点(整个包裹的边界)
unsigned char *data; // 当前有效数据的起点(会随各层加头/去头移动)
unsigned char *tail; // 当前有效数据的终点
unsigned char *end; // 数据缓冲区的终点(容量边界)
struct sock *sk; // 这个报文属于哪个 sock(归属指针)
unsigned int len; // 报文总长度
};

结论:队列里排的是 sk_buff 不是裸报文;head/data/tail/end 四个指针让「层层穿脱衣服」退化成零拷贝的指针移动。
三、四层嵌套:sock → inet_sock → inet_connection_sock → tcp_sock
3.1 为什么要叠四层
struct inet_sock { // IP 协议族在 sock 之上补的字段
struct sock sk; // 第一个成员:内嵌 sock 本体(不是指针!)
__be32 inet_saddr; // 本端 IP 地址(源地址)
__be32 inet_daddr; // 对端 IP 地址(目的地址)
__be16 inet_sport; // 本端端口(源端口)
__be16 inet_dport; // 对端端口(目的端口)
};
struct inet_connection_sock { // 面向连接协议的公共字段层
struct inet_sock icsk_inet; // 第一个成员:内嵌 inet_sock
struct request_sock_queue icsk_accept_queue; // accept 队列,握手完成的连接排队等 accept
struct timer_list icsk_retransmit_timer; // 重传定时器
/* …ACK 状态、重传次数等连接跟踪字段… */
};
struct tcp_sock { // TCP 独有字段,窗口与序号都在这层
struct inet_connection_sock inet_conn; // 第一个成员:内嵌 inet_connection_sock
/* …连接四元组之外的 TCP 状态… */
u32 snd_una; // 最早未被确认的字节序号
u32 snd_nxt; // 下一个要发送的字节序号
u32 rcv_nxt; // 期望收到的下一个字节序号
u32 snd_wnd; // 发送窗口
u32 snd_cwnd; // 拥塞窗口
u32 snd_ssthresh; // 慢启动阈值
};
struct udp_sock { // UDP 版:比 TCP 少一层连接字段
struct inet_sock inet; // 第一个成员:直接内嵌 inet_sock
/* …UDP 少量私有字段… */
};

结论:四层嵌套是「公共底座 + 按协议递进」的装配线,越往下越通用,越往上越具体;拆层省的是用不上的协议的内存,付的代价只是指针强转。
3.2 首成员嵌入与指针强转:C 语言版的多态
结论:首成员嵌入让向上转型退化成零成本指针强转,多态靠 sk_prot 函数表分发;这是 C 语言在内核里复刻面向对象的经典路数。

3.3 对象什么时候创建:listen 与三次握手各建了什么
结论:监听 socket 与连接 socket 是两个对象;哈希表按四元组定位的是 sock,进程是再反查出来的。
四、全链路收拢:一次 read 的完整路径
fd(int 下标)
→ task_struct → files_struct → fd 表
→ struct file
→ file->private_data
→ struct socket
→ socket->sk
→ struct sock(实际是 tcp_sock 四层嵌套的起点)
→ sk_receive_queue(sk_buff 双向链表)
→ sk_buff->data(有效载荷)
→ 拷贝到用户缓冲区,read 返回
结论:对象链是「一切皆文件」在内核里的落地实现;fd 是入口,tcp_sock 是终点,中间每站各管一段。
五、全篇总结
1. fd 到 file 是统一接口
2. socket 薄、sock 厚
3. 四层嵌套按需装配
4. 首成员嵌入即多态
5. 监听与连接是两个对象
六、文末面试题
6.1 推导题
1. struct sock 和 struct inet_sock 总是一起创建,为什么还要拆成两个结构体?
答(推导):一起创建不等于需求相同。unix 域套接字不走 IP 栈、ICMP 没有端口,它们只需要 sock 这层底座;把 IP 四元组塞进 sock,这些协议族就得为用不上的字段买单。拆开后 sock 服务所有协议族,inet_sock 只加给 IP 协议族,各协议按需嵌套层数,内存不浪费,代价只是指针强转。
2. 内核函数大量使用 struct sock * 参数,怎么做到一套代码同时服务 TCP 和 UDP?
答(推导):靠首成员嵌入加函数表。C 标准保证结构体首成员地址与结构体地址相同,tcp_sock、udp_sock 的第一块内存分别是 inet_connection_sock、inet_sock,再往下都是 sock;所以任何 sock * 都能安全当通用底座用,需要具体字段时再强转上探。分发由 sk_prot 指向的函数表完成:TCP 挂 TCP 的实现,UDP 挂 UDP 的实现,同一入口走不同路径,等价于编译型多态。
3. read 一个无数据的阻塞 socket,进程是怎么睡下去又怎么被唤醒的?
答(推导):read 经 fd 表找到 file、private_data 找到 socket、sk 找到 sock,查 sk_receive_queue 为空,内核把该进程 PCB 挂进 socket 的等待队列,状态改为可中断睡眠(浅睡眠),让出 CPU。数据到达时网卡硬中断只做标记,软中断里协议栈剥完各层头,按四元组查哈希表定位到这条 sock,把数据包成 sk_buff 挂上接收队列,回调 sk_data_ready 唤醒等待队列,进程进运行队列,再次被调度时 read 从队头摘数据拷给用户缓冲区返回。
6.2 真题
1. socket 在内核中的数据结构是怎样的?struct socket 和 struct sock 有什么关系?
答(转述):struct socket 是 BSD 层的套接字,管类型、状态、协议操作表 ops,一个 fd 对一个;struct sock 是协议层对象,管收发队列、状态机、协议函数表,由 socket 的 sk 指针指向。socket 薄、sock 厚:接口与协议解耦,同一套 socket 层可以挂INET、UNIX 等不同协议族。
【真题·转述自 小林coding:I/O 多路复用 select/poll/epoll】
2. 一个 socket(一条 TCP 连接)占用多少内存?
答(转述):一条 ESTABLISHED 状态的空连接,核心开销就是 tcp_sock 这块结构体,按对象大小算约 2KB 出头;加上 sk_buff 与缓冲区后随数据量增长,实测一条收发过数据的连接在 4KB 上下浮动,具体与内核版本、缓冲区配置有关。
【真题·转述自 小林coding:腾讯三面,一台服务器最大支持的 TCP 连接数】
3. sk_buff 的结构与作用是什么?
答(转述):sk_buff 是报文的描述结构,本身不存数据;head/data/tail/end 四个指针在缓冲区上圈出 headroom、有效数据、tailroom,next/prev 把它串成双向链表挂在收发队列上。发数据往下走一层 data 前移加头,收数据往上走一层 data 后移剥头,一层结构体描述所有层的包,层间传递只动指针不拷贝。
【真题·转述自 小林coding:Linux 系统如何收发网络包】
4. 阻塞 IO 的实现原理是什么?等待队列在其中起什么作用?
答(转述):阻塞读时进程 PCB 挂进 socket 的等待队列、睡眠让出 CPU;数据到达后中断上半部做标记,软中断里协议栈处理完报文,回调唤醒等待队列上的进程,进程进运行队列等调度,read 才返回。等待队列就是阻塞 IO 的底座,把「睡」和「被叫醒」两头接起来。
【真题·转述自 腾讯云开发者:epoll 性能为什么高】
5. accept 发生在三次握手的哪个阶段?半连接队列和全连接队列有什么区别?
答(转述):accept 不参与握手,它在三次握手完成之后从全连接队列取连接。半连接队列存收到 SYN 还没完成握手的连接(SYN_RCVD),全连接队列存握手完成、等 accept 领走的连接(ESTABLISHED);全连接队列满了后续完成握手的连接会被丢弃或回 RST,ss 命令可以观察到。
【真题·转述自 小林coding:TCP 半连接队列和全连接队列】
6. epoll 的底层原理是什么?和等待队列是什么关系?
答(转述):epoll 在内核里用红黑树管理进程关注的 fd,增删查 O(logn);事件就绪时由数据到达的回调把 fd 对应的 epitem 挂进就绪链表,epoll_wait 只返回就绪的那部分,不用遍历全量。回调的注册对象就是 socket 的等待队列,数据到了先唤醒的是 epoll 的回调,再由它通知应用进程。
【真题·转述自 腾讯云开发者:epoll 性能为什么高】
💬 结语: fd 背后是一整条对象链:file 挂接口、sock 管队列、tcp_sock 藏着窗口与拥塞;首成员嵌入让四层嵌套零成本强转。传输层到此收官,评论区讲讲你第一次读内核源码时被哪个结构体劝退。



