《一个 hello 的冒险——深入 Linux 内核网络》第 1 讲

阅读门槛:会 C 语言、用过 ping。不需要任何内核开发经验——真的,这讲就是为你写的。


0. 本讲路线图(30 秒版)

你敲下:write(fd, "hello", 5) 你的程序(用户态) Linux 内核 syscall 门(entry_SYSCALL_64) VFS:vfs_write socket 核心层:sock_write_iter ★ 本讲主场 INET 层:inet_sendmsg TCP 层:tcp_sendmsg ←—— 走到这里,关门 IP 层 · 设备层 · 网线 ← 以后的 37 讲 图 1 · 本讲路线图:write() 的前半程,到 TCP 门口为止

本讲只做三件事:

  1. 让一对 40 行的程序在你机器上跑通(是的,先跑起来再讲道理);
  2. 回答一个所有老手都答不利索的问题:socket 为什么是一个 fd?
  3. 跟着一句 write() 走到 TCP 协议栈门口,看清门口站着谁。

1. 钩子:人类历史上第一条网络消息,是一次事故

1969 年 10 月 29 日晚上(具体时刻流传版本很多,"22 点前后"是比较诚实的说法),UCLA 的研究生 Charley Kline 对着一台叫 Sigma 7 的机器,准备向 600 公里外 SRI 的电脑发出人类第一次网络登录。

他本意是敲 LOGIN。他敲下了 L,对面收到了。他敲下 O,对面也收到了。他敲下 G——本机系统崩了。

于是互联网的第一句话是:

LO

像有人在千里之外朝你喊了半声"喂——",然后电话线断了。

这个段子你大概率听过。但本课程要问一个听段子的人从不问的问题:

让"对面可靠地收到一个 G",为什么需要一整个内核?

今天的真实答案是:为了让你 write(fd, "hello", 5) 这 21 个字符的 C 语句成立,Linux 内核里住着 socket 核心层、INET 层、TCP 协议栈、IP 层、邻居子系统、qdisc 排队器、NAPI 收包引擎……以及大约十几个我们本讲先不提的模块。它们中的每一个,都因为当年某次"LO 式崩溃"而存在。

40 多年后,你写的网络程序依然会在某些深夜变成"LO":连上了但卡住、发包了但对端收不到、延迟 P99 诡异的 40 毫秒。区别只在于——当年的工程师不知道去哪儿找答案,而你可以把内核源码读明白。

这就是这门课要做的事。全课 38 讲,我们只养一对程序,陪它们从"hello world"一路被折磨到能对抗 SYN 洪泛。今天,它们刚出生。


2. 动手前置检查(30 秒)

在继续阅读前,花 30 秒确认你的实验环境。少一个条件都可以读,但不能跑:

# ① 内核版本(实验用发行版内核即可;想自己编 7.2 的,见附录 A)
uname -r

# ② 工具是否齐全
command -v gcc strace tcpdump ss ip bpftrace || echo "缺东西,见附录 A 装齐"

# ③ 权限:需要 root(建 netns、挂 kprobe 都要)
sudo -v && echo OK

一条都不满意?跳到 附录 A:环境搭建,装完回来。不许跳过——这门课是实验课,读源码不跑实验,等于看菜谱不炒菜。


3. 先把 demo 跑起来:一对 40 行的程序约 5 分钟

别急着理解每一行。先让它说话,这是本课的方法论:症状 → 观察 → 机制 → 解释。

3.1 代码

完整的可编译代码在 code/L01/),这里贴出来是为了你在手机上也读得完。先看你熟悉的:

/* server.c —— 极简 TCP 回显服务 */
int s = socket(AF_INET, SOCK_STREAM, 0);   /* ① 要一个 socket */
bind(s, (struct sockaddr *)&addr, ...);    /* ② 占一个门牌号 :8888 */
listen(s, 128);                            /* ③ 开门营业 */
for (;;) {
    int c = accept(s, ...);                /* ④ 接客(阻塞) */
    read(c, buf, ...);                     /* ⑤ 听(阻塞) */
    write(c, "hello", 5);                  /* ⑥ 说 */
    close(c);                              /* ⑦ 逐客 */
}
/* client.c —— 极简 TCP 客户端 */
int c = socket(AF_INET, SOCK_STREAM, 0);   /* ①' 同样要一个 socket */
connect(c, (struct sockaddr *)&addr, ...); /* ②' 拨号(三次握手!) */
write(c, "hello", 5);                      /* ③' 说 */
read(c, buf, ...);                         /* ④' 听(阻塞) */
close(c);                                  /* ⑤' 挂断 */

两个文件加起来不到 130 行(含注释和错误检查——那两个文件是"带注释的教学版",perror 都给你留着,因为它们将来会很有用)。

3.2 编译运行(本机 loopback 版,10 秒)

cd code/L01
make                # gcc -Wall -Wextra -O2,零警告通过
./server 8888 &     # 终端 A
./client 127.0.0.1 8888   # 终端 B

你应该看到:

# client 侧:
收到 5 字节: hello

# server 侧:
server: 在 :8888 等待连接 (Ctrl-C 退出)
accept: 来自 127.0.0.1:55702
read(5 字节): hello

🎉 恭喜,2026 年的 LO 发送成功。但两台"机器"是同一台——lo 回环设备把包自己送给自己了。为了以后所有像样的实验,我们现在搭一个"两台机器"的实验室。

3.3 T0 底座:两个 netns + 一对 veth

network namespace(netns)= 内核级别的"平行宇宙":每个宇宙有自己的网卡、路由表、iptables、端口空间。容器网络的根基就是它(L22 会正式展开,今天先用)。

sudo ./setup_lab.sh     # 一键建好 ns1 ⇄ ns2

脚本核心就 8 行(值得读一遍 setup_lab.shsetup_lab.sh)):

ip netns add ns1; ip netns add ns2
ip link add veth1 type veth peer name veth2   # 造一对"虚拟网线"
ip link set veth1 netns ns1; ip link set veth2 netns ns2
ip netns exec ns1 ip addr add 10.200.0.1/24 dev veth1
ip netns exec ns2 ip addr add 10.200.0.2/24 dev veth2
ip netns exec ns1 ip link set veth1 up; ip netns exec ns2 ip link set veth2 up

验收:

$ sudo ip netns exec ns1 ping -c2 10.200.0.2
2 packets transmitted, 2 received, 0% packet loss
rtt min/avg/max/mdev = 0.034/0.043/0.052/0.009 ms

0.04ms——两"台机器"之间是软的,没有任何真实网线。以后所有实验都在这个底座上做。跑 demo:

sudo ip netns exec ns2 ./server 8888 &
sudo ip netns exec ns1 ./client 10.200.0.2 8888

翻车了?文末"常见坑速查"表里大概率有你这一条。


4. 外部观察:程序"正常"时,用户态能看见什么约 23 分钟

本课铁律:每个内核结论,都必须先在用户态找到可观察的症状。不然就是玄学。

4.1 strace:看程序到底对内核做了什么

$ strace -e trace=network,write,read -vv ./client 10.200.0.2 8888
read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0..."..., 832) = 832
socket(AF_INET, SOCK_STREAM, IPPROTO_IP) = 3
connect(3, {sa_family=AF_INET, sin_port=htons(8888), sin_addr=inet_addr("10.200.0.2")}, 16) = 0
write(3, "hello", 5)                    = 5
read(3, "hello", 127)                   = 5
write(1, "收到 5 字节: hello\n", 23)      = 23
+++ exited with 0 +++

逐行读(这五行值得你背下来——不是背内容,是背形状):

系统调用 返回值/参数 内核替你干了什么 什么时候翻车
socket(...) = 3 返回小整数 3 创建一个 socket 对象,发给你一个号 EMFILE:你的进程 fd 用完了
connect(3, {…:8888}) = 0 返回 0 时握手已完成 发 SYN,收 SYN+ACK,发 ACK(L02 拆) ECONNREFUSED:对面没人听,被 RST
write(3, "hello", 5) = 5 5 == 你请求的 5 字节拷进内核发送队列就敢返回"成功"! EPIPE:对面已 close(SIGPIPE 警告⚠️)
read(3, "hello", 127) = 5 返回实际读到的 5 从内核接收队列拿货;没货时进程睡去 返回 0 = 对面优雅挂断(EOF)
close(3)(strace 没抓是因为过滤了) 发 FIN,本地端口进入 TIME_WAIT 60s L06《TIME_WAIT 之谜》;端口账本见 L15《65535 的经济学》

注意第 3 行的恐怖之处:write() 返回 5 并不等于对端收到了 5 个字节,甚至不等于它们离开了你的机器。它只意味着"内核收下了货"。TCP 的可靠交付是内核在后面用重传、ACK、超时机制慢慢兑现的承诺(L07/L14)。这是用户态对内核最重要的一条信任契约,90% 的网络"灵异事件"都源于对它的一知半解。

🔤 一个小坑:如果你看到的最后一行是 write(1, "\346\224\266\345\210\260 ...", 23) 这样一串八进制,不是你机器坏了——strace 只在 UTF-8 locale 下才把中文原样打印。LC_ALL=C.UTF-8 strace ... 就变回 "收到 5 字节: hello\n"。(注意那个 23:15 个字符、23 个字节——"收到"和"字节"各占 3 字节。strace 打给内核的长度永远是字节数,以后排查"日志少了一截"别拿字符数去对。)

4.2 ss:内核连接登记簿的窗口

$ sudo ip netns exec ns1 ss -tan
State   Recv-Q Send-Q  Local Address:Port  Peer Address:Port
ESTAB   0      0       10.200.0.1:55702    10.200.0.2:8888

(顺手用 -tan:t=TCP, a=全部, n=别做端口反解——反解会慢到你怀疑人生,这也是个灵异事件。)

此刻在 server 宇宙里看,会多出一行 LISTEN + 一行 ESTAB。Recv-Q/Send-Q 在 LISTEN 行和 ESTAB 行含义完全不同(一个是 accept 队列水位,一个是未读字节数)——这是 ss 最知名的陷阱,L02 亲手拆穿。

4.3 /proc/net/sockstat:socket 人口普查(以及一张会骗人的表)

$ cat /proc/net/sockstat
sockets: used 904
TCP: inuse 59 orphan 0 tw 33 alloc 68 mem 406
UDP: inuse 7 mem 729
RAW: inuse 0
FRAG: inuse 0 memory 0

(这些数字在你机器上必然不同,甚至在两条命令之间都会漂——我下面几个小节的输出就不是同一秒抓的。别对答案,要看的是形状和下面这三个坑。)

  • tw 33:33 个 TIME_WAIT——L06《TIME_WAIT 之谜》的主角今天先露个脸;
  • mem 406:单位是页,TCP 层的内存占用——L18 记账课的主角;
  • orphan 0:没有所属进程(没人 close 也没人 read)的"孤儿连接"——L30 慢攻击时被攻击者最扎眼的那个指标。

坑一:这张表在 network namespace 里长得不一样,但不是所有列都不一样。 我们正好有现成的 ns2,同一时刻各读一份:

$ sudo ip netns exec ns2 cat /proc/net/sockstat | sed -n 1,2p
sockets: used 2
TCP: inuse 2 orphan 0 tw 0 alloc 68 mem 406      # ← alloc/mem 和宿主机一字不差!
$ cat /proc/net/sockstat | sed -n 1,2p
sockets: used 904
TCP: inuse 59 orphan 0 tw 33 alloc 68 mem 406

ns2 里 mem 406/alloc 68 和宿主机完全相同——不是巧合,也不是 bug,是这两列压根不分 netns。看打印者本人,net/ipv4/proc.c:51(原文,一行没改):

static int sockstat_seq_show(struct seq_file *seq, void *v)
{
    struct net *net = seq->private;
    int orphans, sockets;

    orphans = tcp_orphan_count_sum();                        /* ← 没有 net!*/
    sockets = proto_sockets_allocated_sum_positive(&tcp_prot); /* ← 也没有 net!*/

    socket_seq_show(seq);
    seq_printf(seq, "TCP: inuse %d orphan %d tw %d alloc %d mem %ld\n",
           sock_prot_inuse_get(net, &tcp_prot), orphans,
           refcount_read(&net->ipv4.tcp_death_row.tw_refcount) - 1,
           sockets, proto_memory_allocated(&tcp_prot));  /* ← 还是没有 net */

一眼就能分清三种口径:带 net 参数的按 netns 计(inuse、tw——每个 netns 有自己的 death row),不带的是全机 per-CPU 求和(orphan、alloc、mem;tcp_orphan_count_sum() 就是 for_each_possible_cpu 加一遍)。顺带 sockets: used 走 socket_seq_show() 里的 sock_inuse_get(seq->private)(net/socket.c:3414;函数本体在 net/core/sock.c),也是 per-netns。

🔮 预告一个观测:等你做完 §7 的 EXP-01-5(四轮连接),回 ns2 再读一次这一行,会看到 tw 从 0 变成 4,而 inuse 纹丝不动——TIME_WAIT 是一种瘦身后的替代对象,inet_twsk_alloc() 直接从 twsk_slab 里 kmem_cache_alloc(net/ipv4/inet_timewait_sock.c:178),不走普通 socket 的分配与 inuse 计数。它占的是哈希表桶和 60 秒的名分,不是 inuse 的名额。L06 见分晓。

🩺 排障含义:容器里看到 mem/alloc 巨大,别急着怀疑自己被内存泄漏了——那可能是整台机器的数字。真要按 netns 归因,用 ss -m、cgroup 的 memory.stat,或者 L33 的 tcp_diag。这一条我见过不止一次有人在群里问,答案就在这十来行 C 代码里。

🥚 彩蛋一枚:tw 那行为什么要 - 1?因为每个 netns 初始化时把 death row 的计数预置成 1(net/ipv4/tcp_ipv4.c:3463:refcount_set(&net->ipv4.tcp_death_row.tw_refcount, 1);),0 个 TIME_WAIT 时读出来是 1。同一个"-1"约定还出现在限额判定里(inet_twsk_alloc() 开头:refcount_read(&dr->tw_refcount) - 1 >= sysctl_max_tw_buckets)——也就是说 tcp_max_tw_buckets 卡的正是这一列的显示值。L06 会把 death row 拆开门看。

坑二:sockets: used 不是"网络连接数"。 我这台机器刚读出 used 901,看着像 900 条连接?把它拆开:

$ ss -s                                          # ss 的 Total 就是直接读 sockets: used 那行
Total: 901
TCP:   102 (estab 47, closed 38, orphaned 0, timewait 35)

Transport Total     IP        IPv6
RAW   1         0         1
UDP   10        7         3
TCP   64        58        6
INET      75        65        10
FRAG      0         0         0
$ echo "AF_UNIX: $(($(wc -l < /proc/net/unix) - 1))"
AF_UNIX: 747

901 里面网络族(INET 那行)总共只有 75 个,AF_UNIX 独占 747(systemd、dbus、容器里那些 agent 全算进来),剩下的零头是 netlink/packet 之类。原因在源码里一目了然:sockets: used 打印的是 socket_seq_show() 里的 sock_inuse_get(seq->private)(net/socket.c:3414),所有协议族共用一个计数器,谁 sock_alloc() 都加一。所以:说"连接数暴涨"要盯 TCP: inuse,说"socket 泄漏撑爆 fd/内存"才看 sockets: used。

坑三:双栈机器上只看这一份会漏数。 /proc/net/sockstat 只打印 IPv4 侧的 tcp_prot,IPv6 的 tcpv6_prot 在隔壁文件:

$ grep ^TCP /proc/net/sockstat /proc/net/sockstat6
/proc/net/sockstat:TCP: inuse 58 orphan 0 tw 35 alloc 67 mem 144
/proc/net/sockstat6:TCP6: inuse 6

58 + 6 = 64,正好等于上面 ss -s 表里 TCP 那一行的 Total(同一秒读的,数字对得上;你跑的时候会漂,别慌)。而 sockstat6 里连 orphan/tw/mem 都没有——不是它们不重要,是那几项本来就是全机或全 netns 的口径,打印第二遍会重复计数(对照坑一那张源码)。想要整齐又互相自洽的一份,用 ss -s。

4.4 fd 解剖:本讲最重要的 15 分钟

socket() 返回的那个 3,和你 open("/etc/passwd") 返回的 3,是同一种东西。这不是比喻,是文件系统意义上的"同一种东西":

$ ls -l /proc/<client_pid>/fd/3
lrwx------. 1 root root 64 … /proc/4097804/fd/3 -> socket:[192196557]

看到了?fd 3 是一个指向inode 号为 192196557 的 socket 文件的符号链接。拿这个 inode 号去内核的登记簿里反查:

$ awk '$10==192196557 {print $2, $3, $4}' /proc/net/tcp
0100007F:D996 0100007F:49C2 01

十六进制解个码: - 0100007F:D996 → 127.0.0.1:55702(0xD996 = 55702,本机侧) - 0100007F:49C2 → 127.0.0.1:18882(0x49C2 = 18882,服务侧) - 01 = ESTABLISHED(ss 里那行字节的"户籍原档")

一条链闭环了:fd 3 → inode 192196557 → /proc/net/tcp 第 10 列 → 四元组 + 状态。这就是为什么 lsof -i、ss -p、eBPF 的 socket 归属分析全都靠 inode 串起来。记住这条链,它是你以后在"哪个进程占着这个端口"这种问题上唯一的救命稻草。

🤔 思考一下:socket 明明是网络,为什么会有一种文件的"户口"?下一节进内核找答案。

4.5 最后一个窗口:线上真实流过了什么

前面四小节看的都是"内核的账本"(登记簿、统计、fd)。账本有个毛病:它记的是结果,不记过程。网络排障的第三个窗口——也是 90% 的"这个包到底发没发出去"之争的终审法院——是网卡上的字节。

# 终端 A:起服务(换个干净的端口,本文用 20421)
sudo ip netns exec ns2 ./server 20421
# 终端 B:在 ns1 这一侧的 veth1 上抓(注意:必须 ip netns exec,原因见 §7 坑表)
sudo ip netns exec ns1 tcpdump -i veth1 -n "tcp port 20421"
# 终端 C:放一个 hello 过去
sudo ip netns exec ns1 ./client 10.200.0.2 20421

以下是我机器上的抓包原件,10 个报文,一个不多一个不少(时间戳、端口、序号每次会变,形状不会变)。开头两行 dropped privs to tcpdump / verbose output suppressed 是 tcpdump 的自我介绍,我删掉了:

17:22:57.231583 IP 10.200.0.1.46160 > 10.200.0.2.20421: Flags [S],    seq 2365734520, win 64240, options [mss 1460,sackOK,TS val 1979037238 ecr 0,nop,wscale 14], length 0
17:22:57.231605 IP 10.200.0.2.20421 > 10.200.0.1.46160: Flags [S.],  seq 2891434446, ack 2365734521, win 65160, options [mss 1460,sackOK,TS val 3429509512 ecr 1979037238,nop,wscale 14], length 0
17:22:57.231615 IP 10.200.0.1.46160 > 10.200.0.2.20421: Flags [.],   ack 1, win 4, options [nop,nop,TS val 1979037238 ecr 3429509512], length 0
17:22:57.231632 IP 10.200.0.1.46160 > 10.200.0.2.20421: Flags [P.],  seq 1:6, ack 1, win 4, options [nop,nop,TS val 1979037238 ecr 3429509512], length 5
17:22:57.231637 IP 10.200.0.2.20421 > 10.200.0.1.46160: Flags [.],   ack 6, win 4, options [nop,nop,TS val 3429509512 ecr 1979037238], length 0
17:22:57.231657 IP 10.200.0.2.20421 > 10.200.0.1.46160: Flags [P.],  seq 1:6, ack 6, win 4, options [nop,nop,TS val 3429509512 ecr 1979037238], length 5
17:22:57.231664 IP 10.200.0.1.46160 > 10.200.0.2.20421: Flags [.],   ack 6, win 4, options [nop,nop,TS val 1979037238 ecr 3429509512], length 0
17:22:57.231673 IP 10.200.0.2.20421 > 10.200.0.1.46160: Flags [F.],  seq 6, ack 6, win 4, options [nop,nop,TS val 3429509512 ecr 1979037238], length 0
17:22:57.231696 IP 10.200.0.1.46160 > 10.200.0.2.20421: Flags [F.],  seq 6, ack 7, win 4, options [nop,nop,TS val 1979037238 ecr 3429509512], length 0
17:22:57.231706 IP 10.200.0.2.20421 > 10.200.0.1.46160: Flags [.],   ack 7, win 4, options [nop,nop,TS val 3429509512 ecr 1979037238], length 0

10 packets captured
10 packets received by filter
0 packets dropped by kernel

标志缩写先对上暗号:[S]=SYN、[S.]=SYN+ACK、[.]=纯 ACK、[P.]=PSH+ACK、[F.]=FIN+ACK。翻译成一张时间线:

客户端 ns1 10.200.0.1:46160 服务端 ns2 10.200.0.2:20421 #1 [S] seq=2365734520 #2 [S.] seq=2891434446 ack=…521 ★ 发出 SYN+ACK 的那一刻,server 进程还在 accept() 里睡觉 #3 [.] ack=1 —— 三次握手收口(#1~#3 全是内核代发) #4 [P.] seq=1:6 "hello" ← 你的 write(fd, "hello", 5) #5 [.] ack=6 ——「5 个字节我全收下了」 #6 [P.] seq=1:6 —— server 的 write() 回显 #7 [.] ack=6 #8 [F.] seq=6 ★ server 先 close() #9 [F.] seq=6 ack=7 ← client 进程退出,内核替它关 #10 [.] ack=7 图 2 · 一次回显对话的全部 10 个报文 —— 5 个字节,10 个报文 #1→#10 全程 123µs,其中 SYN→SYN+ACK 22µs(veth 纯软件路径)

六条读图要点(每条以后都会反复用到):

  1. 有三个报文你从来没让它们发。 #1/#2/#3 全部出自内核——connect() 只是"按下开始",握手是内核替你谈的。这正是本讲结尾的悬念,L02 逐字段解剖。注意 #2 的备注:发出 SYN+ACK 的那个 server socket 还没被 accept() 拿走——"还没接客的服务端凭什么接待你",L02 用半连接队列回答。
  2. tcpdump 的序号是"各自归一"的。 #1 打的是绝对 ISN(2365734520),到了 #4 就变成 seq 1:6——因为 tcpdump 对每个方向都减去该方向的 ISN。换算:#4 的相对序号 1 = 绝对 2365734521(SYN 本身要占一个序号,这是新手看抓包最容易懵的一点)。
  3. 5 个字节,10 个报文。 你的有效载荷只出现在 #4 和 #6 两行(length 5),其余全是握手、确认和挥手的"TCP 税"。以后有人问你"TCP 比 UDP 贵在哪",把这张图甩给他。
  4. ack 6 才是"收到 hello"。 SYN 占 1 个序号,"hello" 占 5 个(1..5),所以下一个期望字节号是 6。TCP 的 ack 永远是"我期望的下一个字节号",不是"我收到了几个"——L07(重传)、L13(乱序)、L14(SACK)全押在这个定义上。
  5. win 4 不是"只剩 4 字节窗口"。 报文头里的窗口字段是缩放前的值,真实窗口 = 4 << wscale = 4 << 14 = 65536 字节(RFC 7323)。同一行 options 里的 wscale 14 就是本机宣告给对方的缩放因子。窗口什么时候会真的掉到 0、掉了会发生什么,L10《慢读者拖垮全服务》和 L17《Bufferbloat》各管一半。
  6. 谁先 close,谁背 TIME_WAIT——而这个 demo 里先关的是 server。 看 #8(server 的 FIN,.231673)比 #9(client 的 FIN,.231696)早 23µs。所以 60 秒的 TIME_WAIT 落在了 10.200.0.2:20421,也就是服务端口上:
$ sudo ip netns exec ns2 ss -ant | grep 20421
LISTEN    0      128          0.0.0.0:20421      0.0.0.0:*
TIME-WAIT 0      0         10.200.0.2:20421   10.200.0.1:46160

一条 LISTEN、一条 TIME-WAIT 同端口共存,这就是"ss -lnt 里干干净净、bind 却说地址被占"的全部真相。为什么占、以及那个 SO_REUSEADDR 的坑中坑,§7 的 EXP-01-5 用一张 2×2 真值表让你亲手打出来。

顺带一个数字:整场对话从 #1 到 #10 只花了 123µs,其中 SYN→SYN+ACK 是 22µs。这是 veth 纯软件路径的成本(一次进程唤醒 + 两趟协议栈)。插上真实网线,这个数至少涨一个数量级。把这把尺子记在心里——L17《Bufferbloat》里我们会看到,一旦某个 qdisc 的队列堆起来,22µs 能变成 200ms,而那才是"打游戏卡、开会卡"的真凶。

🧭 三窗口心法(本讲的方法论交付物):查网络问题,永远按这个顺序开三扇窗——① strace:用户态到底调用了什么、返回了什么;② ss / /proc/net/*:内核账本怎么记的;③ tcpdump:线上到底流过什么字节。三扇窗的口径不一致之处,就是 bug 的藏身处。


5. 潜入内核:socket 的一生(前传)约 43 分钟

5.1 全局地图:一次 socket() 穿过的三层表驱动

Linux 网络核心层的设计哲学,用一个词讲完:表驱动(table-driven)。每层不硬编码"下一层是谁",而是查表。三张表环环相扣:

socket(AF_INET, SOCK_STREAM, 0) Lane A · 出生:查表 1 找到家族,查表 2 装配对象 net/socket.c socket 核心层 不关心 IPv4 还是 TCP af_inet.c inet_create() 查 inetsw[SOCK_STREAM] 装配对象 sock->ops = inet_stream_ops sk->sk_prot = tcp_prot 表 1 · net_families[AF_INET] (STREAM, 0) 命中 TCP Lane B · 发送:write() 的四跳,每跳查一张表 VFS · sock_write_iter file->f_op = socket_file_ops 〔表 3 · file_operations〕 表 2 inet_sendmsg sock->ops->sendmsg 双分派:BSD 语义层 面向 API,与协议解耦 表 3′ tcp_sendmsg ★ sk->sk_prot->sendmsg 协议内部视角 本讲终点站 三张表、四次跳转——每层只认接口不认人。 所以 1981 年的 BSD 和 2026 年的 Linux 用同一份 man page;新协议栈接入不用改 socket 核心层一行代码。 图 3 · 表驱动:socket() 的诞生与 write() 的四跳

三张表分别解决三个解耦问题:

表 结构体 键 值 解耦了什么
表 1 net_families[] 地址族 (AF_INET) inet_create socket 核心层 ↔ IPv4/IPv6/unix/…
表 2 inetsw[](以 type 为下标的 SOCK_MAX=11 个链表桶,INET 家族实际挂入 STREAM/DGRAM/RAW/SEQPACKET 等) (type, protocol) protosw{ops, prot} INET 层 ↔ TCP/UDP/SCTP/…
表 3 file_operations fd 上的操作 sock_write_iter 等 VFS 统一视图 ↔ socket 语义

看到 sock->ops->sendmsg 和 sk->sk_prot->sendmsg 同时存在别慌——双分派是设计使然:sock->ops(proto_ops)面向 POSIX socket API 语义(带锁、带文件属性),sk->sk_prot(proto)面向协议内部实现(轻量、供内核调用者绕过 VFS 直用)。下一节看代码就不抽象了。

5.2 socket() 的诞生之旅:源码走读

第 1 站:系统调用入口。 7.2 树 net/socket.c(节选,删减注释):

SYSCALL_DEFINE3(socket, int, family, int, type, int, protocol)
{
    return __sys_socket(family, type, protocol);
}

int __sys_socket(int family, int type, int protocol)
{
    struct socket *sock;
    int flags;

    sock = __sys_socket_create(family, type,
                   update_socket_protocol(family, type, protocol));
    if (IS_ERR(sock))
        return PTR_ERR(sock);

    flags = type & ~SOCK_TYPE_MASK;   /* SOCK_NONBLOCK/SOCK_CLOEXEC 藏在 type 高位 */
    ...
    return sock_map_fd(sock, flags & (O_CLOEXEC | O_NONBLOCK));
}

SYSCALL_DEFINE3 是内核给"带 3 个参数的系统调用"生成的胶水宏——它展开后就是 sys_socket,被 entry_SYSCALL_64(x86-64 的门)调度到。你在用户态按下 socket(),libc 只是把参数塞进寄存器、触发一条 syscall 指令,剩下的就是这里。

🥚 彩蛋(给 BPF 同学,也给所有以为"改内核行为要写补丁"的人):中间那行 update_socket_protocol() 是什么鬼?它的定义非常理直气壮——一个 __weak noinline 函数,原样把参数吐回来:

__weak noinline int update_socket_protocol(int family, int type, int protocol)
{
  return protocol;
}

一个什么都不做的函数,凭什么留在所有 TCP 连接的必经之路上?它上面的注释(net/socket.c:1769)是内核里少见的"把设计意图全说了":

/*    A hook for bpf progs to attach to and update socket protocol.
 *
 *    A static noinline declaration here could cause the compiler to
 *    optimize away the function. ... Therefore, __weak is needed to
 *    ensure that the call is still emitted, by telling the compiler
 *    that we don't know what the function might eventually be.
 */
__bpf_hook_start();

翻译成人话:这是个给 BPF 程序留的挂载点,而 __weak 是拿来骗编译器的——不加的话,gcc 一看"哦,这函数直接把参数返回,没有副作用",就会把这个调用整个优化掉,钩子就人间蒸发了。所以"内核行为可编程"这条路,是真的要先跟编译器斗智斗勇的。(fmod_ret 程序挂上去即可改写返回值。)

它的第一个真实用途,commit message 里写得很直白(0dd061a6a115,Geliang Tang,2023-08-16):

"One user case is to force legacy TCP apps to create and use MPTCP sockets instead of TCP ones."

也就是说:一个从没改过代码的老程序,可以在 socket() 的路上被内核悄悄换成多路径 TCP。 你写的应用一行都不用动,协议被换了。这种"参数级偷梁换柱"怎么做到的、边界在哪,L33 正式讲;今天你只需要知道——你刚读的这三行胶水代码,就是 2026 年"内核行为可编程"的地基之一。

而且它不是论文里的概念,树里就有能跑的完整实现(tools/testing/selftests/bpf/progs/mptcpify.c(Linux 源码树内路径),全文 24 行,去掉 license 和 include 就剩下面这个函数):

SEC("fmod_ret/update_socket_protocol")
int BPF_PROG(mptcpify, int family, int type, int protocol)
{
  if (bpf_get_current_pid_tgid() >> 32 != pid)
      return protocol;                                 /* 不是目标进程,别插手 */

  if ((family == AF_INET || family == AF_INET6) &&
      (type & SOCK_TYPE_MASK) == SOCK_STREAM &&
      (!protocol || protocol == IPPROTO_TCP))
      return IPPROTO_MPTCP;                      /* 262:把 TCP 换掉 */

  return protocol;
}

注意那句 !protocol || protocol == IPPROTO_TCP——它精确对应我们下一站要在 inet_create 里读到的"0 = 听天由命":因为老程序都传 0,所以只要在 0 落地之前改写它,就能劫持整个默认协议的选择。同一个钩子还有第二个实现,tools/testing/selftests/bpf/progs/bpf_smc.c:93(同样挂 fmod_ret/update_socket_protocol),把符合条件的 STREAM 换成 IPPROTO_SMC(256)——注意它换的是协议(sk_protocol),走的是 AF_INET 家族;和本讲稍后实测里那个"直接指定 family=43(AF_SMC) 于是自动加载 smc 模块"是两条完全不相干的路,别混:前者是"有人在半路改你的参数",后者是"你报了个冷门家族的名字,内核去磁盘上把它接进来"。(顺便一提:MPTCP 自己在 net/mptcp/bpf.c:23 用 BTF_SET8_START(bpf_mptcp_fmodret_ids) 注册了"允许被 fmod_ret 挂钩"的名单——想改写内核行为,得先在内核侧被点名允许,这不是谁都能挂的后门。)

第 2 站:跨层分发。 sock_create() → __sock_create()(net/socket.c,节选主干):

int __sock_create(struct net *net, int family, int type, int protocol,
          struct socket **res, int kern)
{
    ...
    sock = sock_alloc();              /* 分配 struct socket(内嵌 inode!后面拆)*/
    if (!sock)
        return -ENFILE;

    sock->type = type;

    rcu_read_lock();
    pf = rcu_dereference(net_families[family]);   /* ★ 表 1:AF_INET → inet */
    ...
    err = pf->create(net, sock, protocol, kern);  /* ★ 调用 inet_create() */
    ...
}

net_families[] 是一个以地址族为下标的 RCU 保护指针数组;IPv4 初始化时(inet_init,net/ipv4/af_inet.c:1912)执行 (void)sock_register(&inet_family_ops) 把自己的 create 挂进来(那个 (void) 是"挂不上我也懒得管"的意思)。-EAFNOSUPPORT 这个错误码你也许在跑冷门协议栈时见过——就是上面 if (!pf) 那条路。

📜 两个源码彩蛋(比编段子好笑,因为是内核自己写的): ① 就在 sock_alloc() 上方,注释原文是 "the protocol is 0, the family is instructed to select an appropriate default"——下一站我们当作"经典面试题"来拆的那个 0,内核早就自己交底了。读源码的好处就在这:man page 说"0 表示默认协议",源码注释说"谁负责选默认"。 ② 往上翻十几行,有一段兼容代码的注释自称 "This uglymoron is moved from INET layer to here to avoid deadlock in module load"——把 PF_INET + SOCK_PACKET 这个上古组合改写为 PF_PACKET。内核开发者骂自己代码的词是"uglymoron"(丑陋+白痴合成词),而且留在了 2026 年的主线里,就在你每个 socket() 必经的路上。

第 3 站:INET 层的"查户口"。 net/ipv4/af_inet.c:inet_create(),本讲最精彩的 30 行——(type, protocol) 的三模式匹配:

static int inet_create(struct net *net, struct socket *sock, int protocol,
               int kern)
{
    ...
    /* Look for the requested type/protocol pair. */
lookup_protocol:
    err = -ESOCKTNOSUPPORT;          /* ★ 先假定"这个 type 我压根不认" */
    rcu_read_lock();
    list_for_each_entry_rcu(answer, &inetsw[sock->type], list) {

        err = 0;
        /* Check the non-wild match. */
        if (protocol == answer->protocol) {
            if (protocol != IPPROTO_IP)
                break;
        } else {
            /* Check for the two wild cases. */
            if (IPPROTO_IP == protocol) {        /* 用户说"你帮我选" */
                protocol = answer->protocol; /* ← 传 0 时走这里! */
                break;
            }
            if (IPPROTO_IP == answer->protocol) { /* 表里的兜底条目 */
                break;
            }
        }
        err = -EPROTONOSUPPORT;  /* ★ 改口为"type 我认,protocol 我不认" */
    }

看见 err 被赋了两次吗?内核用这两行精确区分了两种失败:inetsw[type] 桶整个是空的(没人往这个 type 注册过协议)→ 循环体一次都不执行 → 你拿到 -ESOCKTNOSUPPORT;桶里有货但没有你要的那个 protocol → 每轮末尾改写 → 你拿到 -EPROTONOSUPPORT。下次 socket() 报错时看一眼 strace -vv 里的具体 errno,你就能立刻知道该去怪 type 还是怪 protocol(man 7 socket 里这两个错误码长得很像,内核源码里它们的出生地完全不同)。

🔬 20 秒实测:code/L01/socket_errno.csocket_errno.c)(make && ./socket_errno)。下面是我机器上的真实输出,每一行都对得上面某一行代码:

STREAM + proto=0        -> 成功, fd=3                              ← 走 wild 分支,0 被改写成 6
STREAM + proto=TCP      -> 成功, fd=3                              ← 显式点名,非 wild 分支
STREAM + proto=UDP      -> 失败: Protocol not supported (errno=93)  ← EPROTONOSUPPORT
DGRAM  + proto=TCP      -> 失败: Protocol not supported (errno=93)  ← "私奔"被拒
SEQPACKET + proto=0     -> 失败: Socket type not supported (errno=94) ← 桶是空的
type=15 (out of range)  -> 失败: Invalid argument (errno=22)        ← 连 inet_create 都没进到
IPv6 + proto=0          -> 成功, fd=3

三道闸门、三种死法,全在我们刚读的那几十行里:EINVAL 来自 __sock_create 的 type >= SOCK_MAX 范围检查,ESOCKTNOSUPPORT 说明 INET 家族没人注册这个 type,EPROTONOSUPPORT 说明桶里有货但没你要的那个协议。内核把"我为什么拒绝你"写进了 errno,只是从来没人告诉你怎么读。

不信"桶是空的"这个解释?亲手把那个桶填上(我在自己机器上跑过,前后各一行,其余行完全不变):

$ ./socket_errno | grep SEQPACKET
SEQPACKET + proto=0            -> 失败: Socket type not supported (errno=94)
$ sudo modprobe sctp     # SCTP 会往 inetsw[SOCK_SEQPACKET] 里注册(net/sctp/protocol.c:1129)
$ ./socket_errno | grep SEQPACKET
SEQPACKET + proto=0            -> 成功, fd=3

同一行代码、同一个内核、同样的参数,唯一的区别是内存里多了一张表项。这就是本讲想给你种下的世界观:socket() 的一切行为,都是"查表"的结果,而表是可以被改变数量的。

而程序的第二部分,会让你对 socket() 这件事重新评价一下

它拿六个冷门魔数各发一次 socket(),每次调用前后对比 /proc/modules。在一台干净机器上(我实测,模块名以我为准):

socket(43=AF_SMC,  SOCK_STREAM, 0) -> 成功
   + 新加载模块: smc
socket(33=AF_RXRPC,SOCK_STREAM, 0) -> Protocol not supported
   + 新加载模块: rxrpc      ← 顺手还拽来了 fcrypt / krb5 / pcbc 三个依赖
socket(28=AF_MPLS, SOCK_STREAM, 0) -> Address family not supported by protocol
   + 新加载模块: mpls_router, ip_tunnel

三件事被这一次系统调用同时演示了:

  1. 你一个普通用户态进程,能让内核去磁盘上加载模块。 兑现它的就是 §5.2 第 2 站那段我们跳过的代码:if (rcu_access_pointer(net_families[family]) == NULL) request_module("net-pf-%d", family);。这就是"协议族自动加载",也是为什么生产加固会认真讨论模块签名、kernel.modules_disabled 和 lockdown——一个 socket() 参数就能触发代码装入内核,这个攻击面是真实存在的。
  2. AF_SMC 那行是"成功"。你没装过 SMC,此刻它就在你内存里,而且你的 fd 是真的。(SMC 是 IBM 主推的"用 RDMA/共享内存给 TCP 加速"的内核实现,net/smc/——今天只是撞见,不展开。)
  3. AF_MPLS 加载了模块,却还是 -EAFNOSUPPORT——最耐人寻味的一条。原因在树里能查到:MODULE_ALIAS_NETPROTO(PF_MPLS)(include/linux/net.h:385 展开成 MODULE_ALIAS("net-pf-28"))会让 modprobe 把 mpls_router 装进来认领这个号码,但 net/mpls/ 全目录没有一处 sock_register()(自己 grep -c sock_register net/mpls/*.c 验证)——Linux 的 MPLS 只做转发,不提供 socket 家族,想发 MPLS 帧得走 AF_PACKET(ethertype 0x8847,ETH_P_MPLS_UC)。模块文件名甚至叫 af_mpls.c(af = address family),却不注册地址族:这是历史留下的空号。

跑第二次会发现 + 新加载模块 全空了——内核不会重复加载,request_module 只在表里是 NULL 时才发。这条"幂等"顺带解释了一个你迟早会撞上的现象:为什么冷启动后的第一个 socket() 就是会比后面的慢——有些成本全内核只付一次,而谁付,取决于谁先到。

这 20 行回答了一个经典面试题:socket(AF_INET, SOCK_STREAM, 0) 里的 0 是什么意思?

答:IPPROTO_IP(即 0)在说"type 是 STREAM,请你(内核)选一个合适的默认协议"。于是循环命中第一个"wild"分支 protocol = answer->protocol——而 inetsw[SOCK_STREAM] 链表的第一个条目,正是启动时从这张表注册的(af_inet.c 原文):

static struct inet_protosw inetsw_array[] =
{
    {
        .type =       SOCK_STREAM,
        .protocol =   IPPROTO_TCP,
        .prot =       &tcp_prot,          /* ★ 协议内部方法表 */
        .ops =        &inet_stream_ops,   /* ★ POSIX socket 方法表 */
        .flags =      INET_PROTOSW_PERMANENT | INET_PROTOSW_ICSK,
    },
    {
        .type =       SOCK_DGRAM,
        .protocol =   IPPROTO_UDP,
        .prot =       &udp_prot,
        .ops =        &inet_dgram_ops,
        ...

所以:那个 0,就是 TCP 这个名字。 传 6(IPPROTO_TCP)是显式点名,传 0 是"听天由命",而 inetsw_array 的顺序决定了 TCP 是 STREAM 的天命。反过来,如果你对 UDP 套餐点名 TCP 协议——socket(AF_INET, SOCK_DGRAM, IPPROTO_TCP)——循环扫完 inetsw[SOCK_DGRAM] 整个链表(UDP/ICMP/raw……没有 protocol==6 的条目)都无命中,此时 err 停在最后一次赋的 -EPROTONOSUPPORT,但内核不立刻赶人,它进了 if (unlikely(err)) 分支:最多两次自动加载尝试(try_loading_module < 2),先点名要具体的、再退化到泛化的:

            if (++try_loading_module == 1)
                request_module("net-pf-%d-proto-%d-type-%d",
                           PF_INET, protocol, sock->type);
            else
                request_module("net-pf-%d-proto-%d", PF_INET, protocol);
            goto lookup_protocol;      /* 模块可能注册了新的 protosw,回去重查 */

(这段的上古注释给了个真实例子:net-pf-2-proto-132-type-1,即 SCTP——1996 年有人往内核里插协议,就得在这儿留一扇门。)两次都唤不来模块,goto out_rcu_unlock 交付 -EPROTONOSUPPORT:"UDP 套子配 TCP 协议"的私奔,内核不允许——除非哪天你 modprobe 出一个真在 DGRAM 桶里注册的协议。

匹配成功后 inet_create() 的收尾(节选):

    sock->ops = answer->ops;                  /* inet_stream_ops */
    ...
    sk = sk_alloc(net, PF_INET, GFP_KERNEL, answer_prot, kern);  /* 真正的 sock */
    ...
    sock_init_data(sock, sk);                 /* 双向绑定 sock ↔ socket */

    sk->sk_destruct    = inet_sock_destruct;
    sk->sk_protocol    = protocol;           /* = IPPROTO_TCP */
    sk->sk_backlog_rcv = sk->sk_prot->backlog_rcv;  /* = tcp_v4_do_rcv */
    ...
    inet->pmtudisc = IP_PMTUDISC_WANT;         /* L12 PMTUD 的种子埋在这 */

两个细节,为以后埋伏笔:

  • sk_alloc(…, answer_prot, …):struct sock 的 slab 缓存是跟着协议走的(TCP 用 request_sock 池,UDP 有自己的)——L18 讲 sk_buff 记账时你会再见到这个"对象经济学";
  • inet->pmtudisc = IP_PMTUDISC_WANT:每个新建 socket 都默认带着"我想做 PMTU 探测"的基因,L12 那篇 SCP 卡死 16KB 的事故的起点就在这行。

第 4 站:发号。 回到 __sys_socket,sock_map_fd() 把 socket 变成 fd(精读点,见 5.3)。

5.3 精读点:fd 与 socket 的婚礼——sock_alloc_file

这是本讲指定精读的函数(net/socket.c,节选后 22 行,符合"精读 ≤50 行"军规):

struct file *sock_alloc_file(struct socket *sock, int flags, const char *dname)
{
    struct file *file;

    if (!dname)
        dname = sock->sk ? sock->sk->sk_prot_creator->name : "";

    file = alloc_file_pseudo(SOCK_INODE(sock), sock_mnt, dname,
                O_RDWR | (flags & O_NONBLOCK),
                &socket_file_ops);
    if (IS_ERR(file)) {
        sock_release(sock);
        return file;
    }

    file->f_mode |= FMODE_NOWAIT;
    sock->file = file;
    file->private_data = sock;
    stream_open(SOCK_INODE(sock), file);
    ...
    return file;
}

三个动作,一条接缝:

  1. dname = sk_prot_creator->name——socket 文件的名字就是协议名("TCP"/"UDP")。这就是 ls -l /proc/<pid>/fd 里那个 socket:[…] 背后的类型线索,也是 cat /proc/<pid>/fdinfo/<fd> 里 proto: 字段的来源(sock_show_fdinfo 用的是同一个名字);
  2. alloc_file_pseudo(..., &socket_file_ops)——在 sock_mnt(一个名为 sockfs 的纯内存伪文件系统)上造 struct file,操作表直接焊死为 socket_file_ops。VFS 从此把这个 fd 当文件伺候,但 read/write 全部转发进网络世界;
  3. file->private_data = sock——婚礼誓词:所有 fd 上的操作最后都会通过这行指针找回 struct socket。sock_write_iter 第一行就是 struct socket *sock = file->private_data;。

还有第 4 个动作,藏在节选省略号里,但 §9 自测第 5 题的答案全在它身上:stream_open(SOCK_INODE(sock), file)。这个函数(fs/open.c:1578)只干一件事——把 FMODE_LSEEK 位主动抹掉(fs/open.c:1580:filp->f_mode &= ~(FMODE_LSEEK | FMODE_PREAD | FMODE_PWRITE | FMODE_ATOMIC_POS);),再补一个 FMODE_STREAM。于是你对 socket 的 fd 敲 lseek(),vfs_llseek() 一查没有 FMODE_LSEEK,当场回 -ESPIPE(fs/read_write.c:389):

loff_t vfs_llseek(struct file *file, loff_t offset, int whence)
{
    if (!(file->f_mode & FMODE_LSEEK))
        return -ESPIPE;
    return file->f_op->llseek(file, offset, whence);
}

所以"socket 不能 seek"不是 VFS 的猜测,是 sock_alloc_file 亲手写进 f_mode 的声明。 而且是双保险:socket_file_ops(net/socket.c:157)压根没有 .llseek 这一栏,就算 stream_open 忘了清位,do_dentry_open() 也会替它清(fs/open.c:958:if ((f->f_mode & FMODE_LSEEK) && !f->f_op->llseek) f->f_mode &= ~FMODE_LSEEK;)。顺手实测一下,两行代码的事:

$ python3 -c "import socket,os; s=socket.socket(); os.lseek(s.fileno(),0,0)"
OSError: [Errno 29] Illegal seek          # socket:不能定位
$ python3 -c "import os; f=open('/etc/hostname'); print(os.lseek(f.fileno(),0,0))"
0                                         # 普通文件:随便挪

记住这个反差:socket 有文件的户口、文件的接口,唯独没有文件的"位置"——它是一条流,ki_pos 永远是 0(这也是 §5.4 里 sock_write_iter 第一件正事就是检查 iocb->ki_pos != 0 的原因)。

配上 sock_map_fd(同文件):

static int sock_map_fd(struct socket *sock, int flags)
{
    struct file *newfile;
    int fd = get_unused_fd_flags(flags);        /* 拿一个号 */
    ...
    newfile = sock_alloc_file(sock, flags, NULL); /* 办婚礼 */
    if (!IS_ERR(newfile)) {
        fd_install(fd, newfile);              /* 号↔file 挂进 fd 表 */
        return fd;
    }
    ...
}

所以完整答案是:

socket 为什么是一个 fd?因为内核在 sockfs 伪文件系统上为每个 socket 造了一个 struct file,它的 f_op 是 socket_file_ops,它的 private_data 是那个 struct socket。 从此你的 read()/write()/close()/poll()/fcntl() 都能作用在网络栈上——POSIX "一切皆文件" 最辉煌的兑现。

而这一切的对象关系,画成图是这门课的"清明上河图":

int fd = 3 用户态 内核态 task->files->fd_array[3] struct file ← VFS 的「文件」 .f_op = socket_file_ops .private_data ──┐ struct inode (sockfs 伪文件系统) i_ino = 192196557 /proc/net/tcp 反查的就是它 private_data —— 「婚礼誓词」 struct socket ← BSD 语义层 .ops = inet_stream_ops(表 2) .sk(互指:sock ⇄ socket) struct sock .sk_prot = &tcp_prot(表 3) sk_wmem_alloc · sk_receive_queue · sk_family 首字段对齐 = C 语言的「继承」 struct inet_sock ⊕ struct tcp_sock snd_una · rcv_nxt · cwnd · congestion_ops … TCP 状态全家(L13 数序列号字段时你会感谢这张图) 图 4 · fd–file–socket–sock 对象关系(本课的「清明上河图」) close(fd) 后 struct sock 可能还活着——「进程已退出但连接还在」的证据链

注意一个初学者 100% 会问的问题:struct socket 和 struct sock 到底什么关系?

  • socket 是面向 POSIX API 的对象(有状态机 SS_UNCONNECTED、有队列指针、和 inode/file 绑),生命周期跟着 fd 走;
  • sock 是面向协议实现的对象(struct sock 是网络栈内部视角),生命周期跟着内核引用计数(sk_refcnt)走。
  • 两者用 sock->sk / sk->sk_socket 互指,但可以不是一一对应的生死——close(fd) 后若还有包在飞,struct sock 可能还活着(L09 讲 orphan/keepalive 时这是关键角色;ss -tp 里"进程已退出但连接还在"就是这俩分离的证据)。
  • 另外:内核态可以造没有 socket 的 sock(时间复杂度:L28 的 SYN 半连接用的 request_sock,和 L06 的 timewait_sock,是内核为了"少装状态"对这两个对象做的极限瘦身——本课程三大瘦身对象,今天先认识原版)。

5.4 write() 的第一层:走到 TCP 门口,然后停

还记得 §2 那张路线图吗?现在逐帧兑现。你的 write(fd, "hello", 5) 在内核里的完整前半程(右注为 7.2 树文件):

entry_SYSCALL_64 arch/x86/entry/entry_64.S CPU 圈的「门房」 do_syscall_64 arch/x86/entry/syscall_64.c 按系统调用号查表分发 ksys_write fs/read_write.c write() 家族的公共入口 vfs_write fs/read_write.c 按 f_op->write_iter 分发〔表 3〕 sock_write_iter net/socket.c private_data 找回 socket · 查 ki_pos ops->sendmsg〔表 2〕 inet_sendmsg net/ipv4/af_inet.c tail call 进 TCP(栈上没有这一帧) sk_prot->sendmsg〔表 3′〕 tcp_sendmsg ★ 终点站 net/ipv4/tcp.c lock_sock 大锁 · 真正干活在 tcp_sendmsg_locked 图 5 · write() 在内核里的调用链(右注:7.2 树源码文件) bpftrace 实测栈:tcp_sendmsg+5 ← sock_write_iter+364 ← vfs_write+964 ← …(inet_sendmsg 因 tail call 缺席)

三张表,四次跳转,每层只认接口不认人。这就是为什么加一个新协议栈(比如 L34 的 QUIC 在用户态玩)不需要改动 socket 核心层一行代码,也是为什么 1981 年的 BSD 和 2026 年的 Linux 用同一份 man page 手册。

逐帧源码(都是节选,每段 ≤20 行):

① sock_write_iter —— VFS 到 socket 的翻译官(net/socket.c 原文,几乎全函数):

static ssize_t sock_write_iter(struct kiocb *iocb, struct iov_iter *from)
{
    struct file *file = iocb->ki_filp;
    struct socket *sock = file->private_data;   /* ← 5.3 那句婚礼誓词兑现 */
    struct msghdr msg = {.msg_iter = *from};
    ssize_t res;

    if (iocb->ki_pos != 0)
        return -ESPIPE;        /* socket 没有"文件偏移"这个概念 */

    if (file->f_flags & O_NONBLOCK || (iocb->ki_flags & IOCB_NOWAIT))
        msg.msg_flags = MSG_DONTWAIT;   /* O_NONBLOCK → MSG_DONTWAIT 的转译 */

    if (sock->type == SOCK_SEQPACKET)
        msg.msg_flags |= MSG_EOR;

    if (iocb->ki_flags & IOCB_NOSIGNAL)
        msg.msg_flags |= MSG_NOSIGNAL;  /* write 忽略 SIGPIPE 的内核真相 */

    res = __sock_sendmsg(sock, &msg);
    *from = msg.msg_iter;
    return res;
}

注意三个"翻译"动作,它们回答了很多用户态行为问题:

  • ki_pos != 0 就 -ESPIPE:对 socket lseek() 报 ESPIPE,不是 VFS 的通用规定,而是这层自己判的;
  • O_NONBLOCK 在进协议栈前被翻译成 MSG_DONTWAIT——所以 fcntl 设的非阻塞和 send 传的标志在内核里合流(sock_sendmsg 里还有一次合流);
  • IOCB_NOSIGNAL ↔ MSG_NOSIGNAL:你 write() 对端已关时默认吃 SIGPIPE,但如果用 pwritev2(..., RWF_NOSIGNAL) 就不死——机制就是这两行。

② __sock_sendmsg → sock_sendmsg_nosec(net/socket.c)的分发核心只有一行,但这一行是全内核最有技术含量的"一行"之一:

    int ret = INDIRECT_CALL_INET(READ_ONCE(sock->ops)->sendmsg, inet6_sendmsg,
                     inet_sendmsg, sock, msg,
                     msg_data_left(msg));

INDIRECT_CALL_INET 是 spectre v2 缓解措施:间接调用(函数指针调用)会被 CPU 分支预测器投毒,内核针对最高频的两个目标(IPv4/IPv6)展开成"先直连比较、命中就静态调用"的形态,把最热的两条路径从"间接跳"变成"直跳"。你在这里能看到:性能优化和缓解攻击是可以同时写在一行代码里的。没读过这个宏的同学,去 include/linux/indirect_call_wrapper.h 看它的展开——L18/L23 还会多次遇到"热路径优化"这个物种,今天先埋个印象。

③ inet_sendmsg 的落点(net/ipv4/af_inet.c,全函数 11 行原文):

int inet_sendmsg(struct socket *sock, struct msghdr *msg, size_t size)
{
    struct sock *sk = sock->sk;
    const struct proto *prot;

    if (unlikely(inet_send_prepare(sk)))
        return -EAGAIN;

    prot = READ_ONCE(sk->sk_prot);
    return INDIRECT_CALL_2(prot->sendmsg, tcp_sendmsg, udp_sendmsg,
                   sk, msg, size);
}

精彩的是这里有两层 spectre 防护叠放:上一层 sock_sendmsg_nosec 用 INDIRECT_CALL_INET 把"IPv4 还是 IPv6"的间接跳直连化;这一层再用 INDIRECT_CALL_2 把"TCP 还是 UDP"直连化——内核网络两大最热分派,都各自享受了"先比指针、再直 call"的待遇(宏本体在 include/linux/indirect_call_wrapper.h,感兴趣去读那 30 行)。inet_send_prepare 是发送前的准备工作(比如 IP options 快照),今天一行带过。

这个宏编译出来到底长什么样?§7 EXP-01-2 判读 1 里我贴了自己编的 vmlinux 的 5 行真实反汇编——一条 cmp、一条 jmp tcp_sendmsg,把"先比指针再直连"演得明明白白。做实验时记得回头看这三行,会有"原来如此"的爽感。

④ 终点站 tcp_sendmsg(net/ipv4/tcp.c,全函数 9 行,本讲最一段精读):

int tcp_sendmsg(struct sock *sk, struct msghdr *msg, size_t size)
{
    int ret;

    lock_sock(sk);                       /* ★ 每连接一把"用户态可睡眠"的大锁 */
    ret = tcp_sendmsg_locked(sk, msg, size);  /* 真正干活的 300+ 行,L04 拆解 */
    release_sock(sk);

    return ret;
}
EXPORT_SYMBOL(tcp_sendmsg);

收工。write() 系统调用在内核里的故事,本讲到此为止——再往下 tcp_sendmsg_locked 的队列、内存记账、Nagle 判定,每一样都够单独一讲(就是 L04/L18 的事)。

lock_sock(sk) 值得停下来 30 秒:这是 socket 层的用户态可睡眠锁(bh_lock_sock 才是软中断侧自旋的)。也就是说,两个线程同时 write() 同一个 socket,会在这里排队睡觉——这是"一个 TCP 连接多线程并发写"的真正语义(应用层不用锁?内核替你锁了,但字节序交错与否取决于进入顺序,想清楚这句话,你就理解了为什么"一条连接多个 goroutine 各发各的消息"一定会串线)。L24 的多核锁经济学从这条锁开始。

5.5 三大主角今日点名(各看一眼,以后再熟)

① struct sk_buff(include/linux/skbuff.h)——"一个 skb = 一个 packet 的一生"。网络报文在内核里的化身:一个描述符(~200B 的头)+ 一段可跨页的线性/分片数据区。你现在只需知道:tcp_sendmsg_locked 往下做的事,本质就是"把你的字节装进一个个 sk_buff,然后放手"。L18 把它开膛破肚。

② struct sock / inet_sock / tcp_sock(include/net/sock.h, include/net/inet_sock.h)——C 语言没有继承,内核用首字段对齐实现继承:inet_sock 第一个成员是 sock,tcp_sock 第一个成员是 inet_connection_sock(它第一个成员又是 inet_sock)。于是 inet_sk(sk)、tcp_sk(sk) 只是容器宏。L13 你数 tcp_sock 里的序列号字段时,会感谢今天的图。

③ struct net_device(include/linux/netdevice.h)——网卡的抽象。你的包最后交给它(dev_queue_xmit),它甚至可以是假的(veth!tun!L21/L22 造给你看)。

三个主角,38 讲的戏份:

sk_buff     :L18 解剖 → L23 收包重生 → L25 零拷贝革命
sock/inet/tcp: L06 状态机 → L09 定时器 → L17 背压 → L27 自动调优
net_device  : L19 二层 → L20 tc → L22 虚拟化 → L23 NAPI

6. 解释与收束:为什么"LO"之后会崩,而你的 hello 不会

回到 1969。Kline 的"崩溃"发生在主机系统(Sigma 7 的分时系统),不是网络——网络那一侧的 IMP(接口报文处理器,一台专用 Honeywell 516 计算机)好好地收到了消息。40 多年后,你的 write() 之所以稳如老狗,是因为我们一路看到的这些机制在默默兜底:

你在用户态看到的"理所当然" 内核里兑现它的机制 本讲/后续
socket() 返回个 int 就能用 sockfs + file_operations 表驱动 本讲
write() 返回 5 就不会丢 发送队列 + 重传 + ACK 承诺 本讲看到门口;兑现 L07/L14
对面永远"认识"我的包 四元组 + 全局 ehash 哈希表 L02/L24
read() 没数据时自动睡 sk_wait_data → schedule L05
网络坏了程序不会永远卡 超时/重试/RST 全家福 L07-L09

这张表的每一行都有人付过学费。1969 年那次崩溃付的是全人类的——此后所有"可靠交付"的设计,都是在回答"LO 之后怎么办"。


7. 实验时间(五件套交付)约 22 分钟

每个实验的产物 = 抓包/输出 + 你的判读。实验做完,把输出按 EXP 编号存进 ~/lo_adventures/(L38 毕业答辩要拿它们当证据)。

EXP-01-1 搭建 T0 底座 ✅(§3.3 已完成则跳过)

验收 checklist: - [ ] ip netns list 出现 ns1、ns2 - [ ] ns1→ns2 ping 通,RTT < 0.1ms(纯软路径,真实网线至少 ~0.2ms) - [ ] ip netns exec ns1 ip link 只看到 lo + veth1(隔离的直观证据) - [ ] 会一键回滚:sudo ./teardown_lab.sh

EXP-01-2 一次 write() 的 kernel 前半程

# 终端 A:起服务
sudo ip netns exec ns2 ./server 8888
# 终端 B:挂观察器(脚本只认 comm=client,并带 60s 超时保险丝)
sudo bpftrace ./trace_write.bt
# 终端 C:发令
sudo ip netns exec ns1 ./client 10.200.0.2 8888

预期输出(我在 Fedora 6.17 运行内核 + 7.2 源码锚点上实测的原件;pid 每次都不同,别对这一格的答案):

Attached 5 probes

[用户→内核] pid=4097260 comm=client  write() 进入 VFS 插座层
  → inet_sendmsg(sock->ops->sendmsg 分发的落点)
  → tcp_sendmsg(TCP 协议栈!本次实验的终点站)
  内核调用栈:

        tcp_sendmsg+5
        sock_write_iter+364
        vfs_write+964
        ksys_write+191
        do_syscall_64+126
        entry_SYSCALL_64_after_hwframe+118

判读练习(对照 §5.4 的图): 1. 为什么栈里 sock_write_iter 直接"跳"到了 tcp_sendmsg,中间的 __sock_sendmsg 和 inet_sendmsg 都不见了?两个原因,而且都是真物理原因,不是工具抽风: - __sock_sendmsg:被内联进 sock_write_iter 了。查一下就知道——grep -w __sock_sendmsg /proc/kallsyms 在我的内核上一行都没有(sock_write_iter 是 t、inet_sendmsg/tcp_sendmsg 是 T,唯独它不存在)。没有独立地址,kprobe 就无处落脚。本讲脚本第一次实测就是在这里失败,才改成挂 inet_sendmsg 的。 - inet_sendmsg:它确实命中了探针(脚本单独打了 → inet_sendmsg 那行),但它已经不在栈上了——因为它对 tcp_sendmsg 做的是 tail call(兄弟调用)。这不是猜测,把自己编的 vmlinux 反汇编出来看最后 4 条指令(7.2 树,objdump --disassemble=inet_sendmsg vmlinux):

cmp    $0xffffffff825481b0,%rax        ; ★ INDIRECT_CALL_2 的"先比指针"
jne    ...                             ;   不是 tcp_sendmsg 就走 indirect call
add    $0x10,%rsp                      ; ★ 栈帧已经拆了
pop    %rbx
jmp    ffffffff825481b0 <tcp_sendmsg>  ; ★ 是 jmp 不是 call —— 帧没了

看明白了吗?这 5 行把 §5.4 讲的 INDIRECT_CALL_2 肉眼可见地演出来了:先 cmp 指针是不是 tcp_sendmsg,命中就 add rsp/pop rbx 拆掉自己的帧、再 jmp 过去。jmp 不压返回地址,所以从 tcp_sendmsg 往回看,inet_sendmsg 这一层在硬件意义上根本不存在。内核用的是 ORC unwinder(CONFIG_UNWINDER_ORC=y,回溯是精确的、不是猜的)——精确地告诉你"这里确实没有那一帧"。

结论有两层:① 调用栈是证据,但它是"编译之后的证据",源码上的调用关系不等于栈上的帧关系;② 性能优化(spectre 缓解 + tail call)是会留下机器码指纹的,objdump 一眼就能看见。想在自己机器上看,得弄到 vmlinux(Fedora:debuginfo-install kernel-core,其他发行版装各自的 kernel debuginfo;实在没有就用你自己编的那份)。 2. 把 client 换成 curl http://... 重跑,comm=client 变 comm=curl,其他链路一模一样——本讲的结论对所有 TCP 客户端成立。(动手点:脚本里三处 /comm == "client"/ 改成 "curl",或者用 bpftrace ./trace_write.bt -p $(pgrep -n curl) 锁 pid。) 3. 为什么脚本要按 comm 过滤? 这是本讲真实翻车换来的:我第一版没过滤,在这台跑着容器和一堆 agent 的开发机上,别人的一次 write() 先命中了 tcp_sendmsg,脚本立刻 exit(),我的 client 还没跑就收工了——你会看到一份完全不属于自己的调用栈。教训:kprobe 是全局的,netns 隔离不了它(ns1 里的 client 和宿主机上的 curl 打在同一个 tcp_sendmsg 上)。以后你写任何"抓到一次就退出"的观察脚本,都要先问自己:这台机器上还有谁在走这条路?

EXP-01-3 fd 的解剖与反查(§4.4 的完整实验版)

# 终端 B:起一个会"挂住"的客户端(用 python 代替,因为我们的 demo 跑太快)
python3 -c "
import socket, time
s = socket.create_connection(('10.200.0.2', 8888))
print('fd 是几:', s.fileno()); time.sleep(60)"
# 终端 A:拿 pid 解剖
pid=$(pgrep -f "time.sleep" -n)
ls -l /proc/$pid/fd/ | grep socket
ino=$(ls -l /proc/$pid/fd/3 | grep -oP 'socket:\[\K[0-9]+')
echo "ino=$ino"
awk -v i="$ino" '$10==i {printf "四元组(16进制): local=%s peer=%s state=%s\n",$2,$3,$4}' /proc/net/tcp
ss -tanp | grep $pid

实测输出(loopback 版):

lrwx------. 1 root root 64 … /proc/4097804/fd/3 -> socket:[192196557]
四元组(16进制): local=0100007F:D996 peer=0100007F:49C2 state=01

自己把 0xD996/0x49C2/01 心算成 55702/18882/ESTABLISHED(printf "%d\n" 0xD996 验证)。

判读 checklist: - [ ] 你能从 fd 号 3 一路查到内核四元组,全程不用 gdb - [ ] 解释:为什么 awk 里用 $10 而不是 $1(翻 man 5 proc 或 L33 的三向对照表——列 10 是 inode) - [ ] 思考题:两个进程可以拿到相同 inode 的 fd 吗?(可以——dup()/fork 继承;那"一个 socket 多进程"和"SO_REUSEPORT 多进程"有什么区别?L15 见分晓)

EXP-01-4 socket() 的九种命,和一次"魔数加载内核模块"

(如果你已经跟着 §5.2 的侧栏跑过,这里只做验收;没跑过的话,这是本讲性价比最高的 5 分钟。)

make && ./socket_errno                 # 第一部分:八行 errno 全览
sudo modprobe sctp && ./socket_errno | grep SEQPACKET   # 空桶被填上,同一行翻成"成功"

顺带一条真实翻车:跑完想复原,sudo rmmod sctp 大概率回你一句 Module sctp is in use,尽管你一个 sctp 连接都没建(我机器上 lsmod 的 Used by 是二十上下浮动——它就是 per-netns 引用数,你多开一个容器/命名空间它就涨一截)。不是你的问题——网络模块会为每个 network namespace 持有引用,lsmod 那个计数统计的就是这类 per-netns 对象。到 L22 我们开始大量造 netns 之后,你会天天遇到它。今天只需记住一条:别为了复原去乱 kill 进程。

🧊 还有一个更扫兴的前提:如果你的机器上这些模块本来就加载着(发行版只要跑过容器、systemd-networkd、或者装过 sctp 相关的包,lsmod | grep -E 'sctp|smc|kcm' 十有八九非空),那么第一部分 SEQPACKET 那一行会当场就是"成功",第二部分每个族都会回你一句"无新增:这个族的模块之前就在内存里"。程序没有坏——它甚至专门替你把这件事打印出来了。想看"空桶被填上"的翻案瞬间,只有两条路:换一台干净的机器(新装虚机最省事),或者干脆把它当成一次读表练习——grep -w inetsw /proc/kallsyms 能证明这张表就躺在 .bss 里(符号类型 b),但每个桶空不空没法直接看,只能靠 ./socket_errno 那八行的返回值反推。这个实验的真正价值不在那一次 modprobe,而在你亲眼看见:socket() 的参数能决定内核要不要去磁盘上加载代码。

判读 checklist: - [ ] 说出 EINVAL / ESOCKTNOSUPPORT / EPROTONOSUPPORT 分别是在哪个函数、哪一行被赋值的(__sock_create 还是 inet_create); - [ ] 第二部分在你机器上加载了哪些模块?用 lsmod | grep -E 'smc|kcm|rxrpc|mpls|vsock|can' 对答案; - [ ] 为什么 AF_MPLS 加载了模块还是失败?(提示:grep -c sock_register net/mpls/*.c); - [ ] 想一想:一个普通进程的一次 socket() 就能让内核从磁盘加载代码——本课不管内核安全(那是另一个话题),但你至少该记住:"参数触发加载"这个模式在内核里到处都有,以后审计攻击面时它是必查项。

EXP-01-5 "我明明加了 SO_REUSEADDR,为什么还是 bind 不上?"

§4.5 结尾留了个钩子:TIME_WAIT 落在服务端口上,所以重启会 EADDRINUSE。你大概立刻想到"加个 SO_REUSEADDR 不就完了"——server.c 第 2 步确实加了。但我在写这本讲义时,加了它照样失败,只好回去读冲突判定的源码。这个实验就是把那段源码变成一张你能亲手打出来的真值表。

sudo ./exp01_5.sh      # 约 40 秒;每轮各占一个端口,因为 TIME_WAIT 要 60 秒才走

实测原件(判读结论连跑三遍完全一致;client 的临时端口每轮都变——但两轮之间必须间隔 ≥60 秒,原因见下面的坑):

第 1 轮 NO_REUSE=1 │ 第 2 轮 NO_REUSE=0 │ 端口 20501 → ✗ bind 失败:bind: Address already in use
        TIME-WAIT 0      0         10.200.0.2:20501   10.200.0.1:50340

第 1 轮 NO_REUSE=1 │ 第 2 轮 NO_REUSE=1 │ 端口 20502 → ✗ bind 失败:bind: Address already in use
        TIME-WAIT 0      0         10.200.0.2:20502   10.200.0.1:58510

第 1 轮 NO_REUSE=0 │ 第 2 轮 NO_REUSE=0 │ 端口 20503 → ★ bind 成功
        LISTEN    0      128          0.0.0.0:20503      0.0.0.0:*
        TIME-WAIT 0      0         10.200.0.2:20503   10.200.0.1:34190

第 1 轮 NO_REUSE=0 │ 第 2 轮 NO_REUSE=1 │ 端口 20504 → ✗ bind 失败:bind: Address already in use
        TIME-WAIT 0      0         10.200.0.2:20504   10.200.0.1:34862

四格里只有一格能成功:双方都设过 SO_REUSEADDR。注意成功那格下面的两行——LISTEN 和 TIME-WAIT 同时挂在 20503 上,这就是 §4.5 说的那个"同端口共存"。

⏱️ 别连着跑第二遍(我实测踩到的)。TIME_WAIT 的寿命是 60 秒,而脚本跑完一轮才 40 秒不到——紧接着再跑,第一遍留下的四个幽灵还在,于是你会看到两件"灵异事件":① 失败格里显示的 client 端口和上一遍一模一样(那不是新连接,是旧幽灵——本轮的 bind 早在第 1 步就失败了,根本没建立新连接);② 成功格下面凭空多出一行 TIME-WAIT。判读诀窍:同一格连续两遍端口不变 ⇒ 那是残留;想干净复现,sleep 65 再来,或者把脚本里的 20501~20504 换成一组新端口。

一个 && 就能解释整张表。net/ipv4/inet_connection_sock.c:156(原文,一行没改):

static bool inet_bind_conflict(const struct sock *sk, struct sock *sk2,
                   kuid_t uid, bool relax,
                   bool reuseport_cb_ok, bool reuseport_ok)
{
    int bound_dev_if2;

    if (sk == sk2)
        return false;

    bound_dev_if2 = READ_ONCE(sk2->sk_bound_dev_if);

    if (!sk->sk_bound_dev_if || !bound_dev_if2 ||
        sk->sk_bound_dev_if == bound_dev_if2) {
        if (sk->sk_reuse && sk2->sk_reuse &&
            sk2->sk_state != TCP_LISTEN) {
            if (!relax || (!reuseport_ok && sk->sk_reuseport &&
                       sk2->sk_reuseport && reuseport_cb_ok &&
                       (sk2->sk_state == TCP_TIME_WAIT ||
                    uid_eq(uid, sk_uid(sk2)))))
                return true;
        } else if (!reuseport_ok || !sk->sk_reuseport ||
               !sk2->sk_reuseport || !reuseport_cb_ok ||
               (sk2->sk_state != TCP_TIME_WAIT &&
                !uid_eq(uid, sk_uid(sk2)))) {
            return true;
        }
    }
    return false;
}

代入我们的场景:sk = 正在 bind() 的新 socket,sk2 = 该端口上已有的那个 TIME_WAIT 幽灵;bind() 显式指定端口时,inet_csk_get_port()(同文件 :570)传进来的是 relax=true, reuseport_ok=true,而我们没用 SO_REUSEPORT(sk->sk_reuseport == 0)。于是:

真值表行 sk->sk_reuse sk2->sk_reuse 走哪个分支 结果
3 1 1 进 if;!relax=false,!reuseport_ok=false → 内层条件不成立 不冲突
1、2、4 有一个是 0 落进 else if;!sk->sk_reuseport 为真 冲突 → EADDRINUSE

第 3 行为什么能放行?因为"两个都愿意复用"意味着:这个 TIME_WAIT 也是它自己历史的一部分,接管它没有风险。而第 1、4 行的存在说明了一件很反直觉的事——幽灵的脾气是"生前"决定的,看 net/ipv4/inet_timewait_sock.c:195:

        tw->tw_reuse        = sk->sk_reuse;

tw_reuse 不是什么独立字段,include/net/inet_timewait_sock.h:41 当场交底:

#define tw_reuse        __tw_common.skc_reuse

它就是 TIME_WAIT socket 内嵌那个 struct sock 的 sk_reuse。换句话说:TIME_WAIT 带着生前的 SO_REUSEADDR 设置活 60 秒。上一个进程没设,幽灵就没设,你新进程设得再标准也白设(真值表第 1 行)。

🎓 这条推论值得抄进你团队的 server 模板评审清单:SO_REUSEADDR 救不了历史遗留,只能保证你从此不再制造问题,所以它必须从第一次运行就在位。至于 tcp_tw_reuse、SO_REUSEPORT、以及"为什么 TIME_WAIT 非但没删掉还要占一个哈希表桶"——L06《TIME_WAIT 之谜》管状态机,L15《65535 的经济学》管端口账本。

判读 checklist: - [ ] 你能说出 sk 和 sk2 分别是谁,以及 relax 从哪一层传进来; - [ ] 第 3 行成功时,ss -ant 里那条 TIME-WAIT 的对端端口和你的 client 端口是否一致(应该一致——它就是刚才那条连接留下的); - [ ] 用 ip netns exec ns2 cat /proc/net/sockstat 里的 tw 计数,验证这四轮确实制造了 4 个 TIME_WAIT; - [ ] 想一想:pkill -x server 换成 pkill -INT -x server 会怎样?(本讲作者本人在这上面翻过一次车,见坑表倒数第 2 行。)

常见坑速查(每条都是真实翻车现场)

症状 原因 解法
server 重定向到文件时日志"不出现" stdout 非 tty 时 glibc 是块缓冲,进程不死你不看到 stdbuf -oL ./server …;这也是以后所有"日志丢了"灵异事件的初代解答
bpftrace 报 __sock_sendmsg is not traceable 它是 static 函数且被编译器内联(它的下游 sock_sendmsg_nosec 在源码里干脆写着 static inline,net/socket.c:773)——kallsyms 里没有独立符号,kprobe 就没有落脚的地址 换 inet_sendmsg(本讲脚本已内置);查可探测性:grep 函数名 /proc/kallsyms,符号类型字母是 T/t 才有戏
bpftrace 一 attach 就"完成"了,抓到的栈却不是你的进程 kprobe 是全机生效的,别人先命中、脚本先 exit() 按 comm 过滤(本讲脚本已内置)或 -p 锁 pid,见 EXP-01-2 判读 3
第二次跑 demo 报 bind: Address already in use,ss -lnt 里有 LISTEN 上一个 server 还活着(ip netns exec 包了一层,kill 有时只杀了包装进程) pkill -x server 后再起;或干脆每轮换一个端口
第二次跑 demo 报 bind: Address already in use,ss -lnt 里空空如也 服务端口上躺着一个 TIME_WAIT(§4.5 读图要点 6)。ss -ant(不带 l)才看得见它 换端口;或保证前后两次都带 SO_REUSEADDR——见 EXP-01-5 那张 2×2 真值表
strace 满屏 ENOENT 没加过滤,动态链接器在找 .so -e trace=network(本文所有命令都带了)
宿主机上 tcpdump -i veth1 直接报错 tcpdump: veth1: No such device exists 设备在 ns1 里,宿主机默认 netns 里根本没有这个网卡(不是"抓不到",是"不存在") ip netns exec ns1 tcpdump -i veth1 …;ip netns exec ns1 ip link 先确认设备在谁家
跑 setup_lab.sh 报 RTNETLINK answers: Operation not permitted 在受限容器里跑(缺 CAP_NET_ADMIN) 宿主机或虚机里跑;这是内核实验的物理定律,没有 workaround
脚本里 pkill -INT / Ctrl-C 打不掉自己后台起的 server 非交互 shell 用 & 起的作业会继承 SIGINT/SIGQUIT = SIG_IGN,这个处置方式还会穿过 execve 传给 server。我在写 EXP-01-5 时"杀掉了"server 三次,其实一次都没杀掉 用默认信号 pkill -x server(= SIGTERM)或 kill -TERM <pid>;交互式终端里手按 Ctrl-C 不受影响
pkill -f "server 8888" 把自己的终端杀了 复合命令行本身匹配了 pattern(本人实测翻车) pkill 用精确名 pkill -x server,或写进脚本文件

8. 悬念:你的 hello 到了对面吗?

今天结束时,你的 5 个字节躺进了对面内核的接收队列——而那个进程可能还在睡觉。write() 的返回骗了你吗?没有,但它只说了一半故事。

§4.5 的抓包已经把另一半摊在桌上了:10 个报文里,只有 #4 和 #6 带着你写的字节,其余全是内核自作主张的握手、确认和挥手。最值得追问的是 #2——发它的那一刻,server 进程还在 accept() 里睡觉,可"连接"已经存在了。谁替它谈的判?谈完之后那条连接记在哪个表里?如果一万台机器同时来找它谈判,那个表还装得下吗?

L02《三次握手:一份 1978 年的设计决议案》:我们把 #1/#2/#3 逐字段解剖,并且回答一个更狠的问题——为什么是三次,两次会死多少人?(剧透:会死出 RFC 历史上最著名的反证图解。)


9. 课后自测(答案全在本讲,5 分钟内能答完算过关)

  1. socket(AF_INET, SOCK_STREAM, 6) 和 socket(AF_INET, SOCK_STREAM, 0) 行为有区别吗?从 inet_create 的匹配循环解释。
  2. 一个 TCP 连接,用户态 fd 号是多少,内核里对应的 socket inode 号是多少——说出至少两条把前者换算成后者的方法。
  3. write() 返回字节数 < 请求字节数,可能的原因是什么?(本讲至少能看到一条:-ERESTARTSYS 变短读;其余 L17 补全)
  4. sock->ops->sendmsg 与 sk->sk_prot->sendmsg 都指向发送,为什么要两张表?
  5. 为什么 lseek(sockfd, 0, SEEK_SET) 会失败而 lseek(logfd, 0, SEEK_SET) 不会?
  6. §4.3:在容器/netns 里 cat /proc/net/sockstat 看到 mem 很大,能证明"这个容器占了很多内存"吗?给出源码依据。
  7. §4.5 + EXP-01-5:你老老实实在 bind() 前加了 SO_REUSEADDR,为什么有时候还是 EADDRINUSE?用 inet_bind_conflict() 里的一行解释。

附录 A:环境搭建(一次装齐)

# Debian/Ubuntu
sudo apt-get install -y build-essential strace tcpdump iproute2 iputils-ping bpftrace python3
# Fedora/RHEL
sudo dnf install -y gcc strace tcpdump iproute bpftrace python3
# 验证内核版本与 BTF(bpftrace 的现代内核前提)
uname -r                      # 本讲五件套:≥5.10 就能全跑;L08 起的实验:≥5.18
cat /sys/kernel/btf/vmlinux >/dev/null && echo "BTF OK"

版本口径统一说清楚:本讲所有实验在 ≥5.10 上都能跑通(用到的锚点 sock_write_iter/inet_sendmsg/tcp_sendmsg/inet_bind_conflict 都是几十年老面孔)。但全课从 L08《tcpdump 抓到了,应用却没收到》起要用 SKB_DROP_REASON(5.18 引入),所以如果你打算一路跟到 L38,现在就把内核装到 ≥5.18,省得中途换。

想在本讲就摸到自己编译的 7.2 内核(可选,零基础可跳过,L07 前完成即可):

git clone <本仓库>            # 或 kernel.org 的 tarball
cd linux && make defconfig && make -j$(nproc)   # 第一课先跑通,调优以后说
# QEMU 启动验证:qemu-system-x86_64 -kernel arch/x86/boot/bzImage \
#   -append "console=ttyS0" -nographic

附录 B:史料卡(L01)

故事 一手出处 口径备注
1969-10-29 "LO" UCLA ARPANET 项目记录;Leonard Kleinrock 口述史;Charley Kline 多次访谈 时刻("21:00/22:00/22:30")流传不一,本讲取"晚间"模糊口径;"第三字母 G 导致本机(Sigma 7 分时系统)崩溃"为主流口径
IMP = Honeywell DDP-516 改 BBN Report 1822 (1973) 516 为 IMP 载体机型,有档案
socket() 传 0 = 默认协议 BSD 4.2 手册;Linux man-pages socket(2):"If you pass 0 … default protocol will be selected" 本讲用 inet_create 源码兑现
BPF 可把老 TCP 程序换成 MPTCP 本树 commit 0dd061a6a115(Geliang Tang / SUSE,2023-08-16,merge by Martin KaFai Lau)+ 可运行实现 tools/testing/selftests/bpf/progs/mptcpify.c 本讲引 commit message 原句;非推测

附录 C:本讲符号清单约 7 分钟

C.1 函数/符号清单

这门课不要求你背函数名,但要求每一讲结束时,你知道自己今天见过谁。下表就是 L01 的全部出场符号("定义在哪"只给文件,版本无关):

符号 定义在哪 本讲角色 首次照面
SYSCALL_DEFINE3(socket) net/socket.c 系统调用门房,展开成 sys_socket §5.2 第 1 站
__sys_socket net/socket.c 拆 type 高位(SOCK_NONBLOCK/CLOEXEC),调 BPF 钩子 §5.2
__sys_socket_create net/socket.c 把 protocol 交给 __sock_create §5.2
update_socket_protocol net/socket.c __weak noinline 的 BPF 挂载点(钩子要骗过编译器) §5.2 彩蛋
__sock_create net/socket.c 跨层分发:查表 1 → pf->create() §5.2 第 2 站
net_families[] net/socket.c 表 1:地址族 → 家族操作表 §5.1
sock_register net/socket.c 家族把自己挂上表 1 §5.2
inet_init net/ipv4/af_inet.c IPv4 家族的初始化(sock_register 的调用者) §5.2
sock_alloc net/socket.c 造 struct socket + 内嵌 inode(sockfs 户口) §5.2
sock_map_fd net/socket.c 拿一个 int,把 fd↔file 挂进表的最后一哆嗦 §5.3
sock_alloc_file net/socket.c ★本讲精读点:fd 与 socket 的婚礼 §5.3
alloc_file_pseudo fs/file_table.c 在 sockfs 上造 struct file,焊死 f_op §5.3
socket_file_ops net/socket.c 这个 fd 在 VFS 眼里的身份(注意:没有 .llseek) §5.3
stream_open fs/open.c 抹掉 FMODE_LSEEK——"socket 不能 seek"的签字现场 §5.3
sock_show_fdinfo net/socket.c /proc/<pid>/fdinfo/<fd> 里 proto: 那行 §5.3
vfs_llseek fs/read_write.c 查 FMODE_LSEEK,没有就 -ESPIPE §5.3
do_dentry_open fs/open.c 双保险:f_op->llseek 为空也清位 §5.3
inet_create net/ipv4/af_inet.c ★本讲最精彩的 30 行:(type, protocol) 三模式匹配 §5.2 第 3 站
inetsw[] net/ipv4/af_inet.c 表 2:按 type 分桶的协议链表(SOCK_SEQPACKET 那个空桶) §5.1
inet_stream_ops net/ipv4/af_inet.c 表 2 里的 struct proto_ops(BSD 语义层) §5.1
tcp_prot net/ipv4/tcp_ipv4.c 表 3:struct proto,协议内部视角 §5.1
request_module include/linux/kmod.h(宏)→ __request_module in kernel/module/kmod.c "魔数加载模块":一个 socket() 参数让内核去读磁盘 §5.2 侧栏
sock_write_iter net/socket.c f_op->write_iter 的落点;第一件正事是查 ki_pos §5.4
__sock_sendmsg net/socket.c 被内联:/proc/kallsyms 查无此人,kprobe 无处落脚 §5.4 / EXP-01-2
sock_sendmsg_nosec net/socket.c static inline,同理 §7 坑表
sock_sendmsg net/socket.c LSM 钩子 + 安全/非安全两条路 §5.4
inet_sendmsg net/ipv4/af_inet.c sock->ops->sendmsg;对 tcp_sendmsg 做 tail call §5.4
inet_send_prepare net/ipv4/af_inet.c 发送前的状态/半关闭检查 §5.4
tcp_sendmsg net/ipv4/tcp.c 本讲终点站,sk->sk_prot->sendmsg §5.4
tcp_sendmsg_locked net/ipv4/tcp.c 真正把你的字节装进 sk_buff 的地方 §5.4
inet_csk_get_port net/ipv4/inet_connection_sock.c bind() 选端口(含 SO_REUSE* 的取舍入口) EXP-01-5
inet_bind_conflict net/ipv4/inet_connection_sock.c ★2×2 真值表的全部真相:那个 && EXP-01-5
sockstat_seq_show net/ipv4/proc.c /proc/net/sockstat 的打印者(三种口径同框) §4.3
socket_seq_show net/socket.c sockets: used 那一行 §4.3
sock_inuse_get net/core/sock.c 全协议族共用的 per-netns 计数 §4.3
tcp_orphan_count_sum net/ipv4/tcp.c 不分 netns 的 for_each_possible_cpu 求和 §4.3
inet_twsk_alloc net/ipv4/inet_timewait_sock.c TIME_WAIT 这个"瘦身替代对象"的分配(继承 sk_reuse) §4.3 / EXP-01-5
tcp_sk_init net/ipv4/tcp_ipv4.c 把 death row 计数预置成 1——那个 -1 的由来 §4.3 彩蛋