《一个 hello 的冒险——深入 Linux 内核网络》第 1 讲
阅读门槛:会 C 语言、用过
ping。不需要任何内核开发经验——真的,这讲就是为你写的。
0. 本讲路线图(30 秒版)
本讲只做三件事:
- 让一对 40 行的程序在你机器上跑通(是的,先跑起来再讲道理);
- 回答一个所有老手都答不利索的问题:socket 为什么是一个 fd?
- 跟着一句
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。翻译成一张时间线:
六条读图要点(每条以后都会反复用到):
- 有三个报文你从来没让它们发。 #1/#2/#3 全部出自内核——
connect()只是"按下开始",握手是内核替你谈的。这正是本讲结尾的悬念,L02 逐字段解剖。注意 #2 的备注:发出 SYN+ACK 的那个 server socket 还没被accept()拿走——"还没接客的服务端凭什么接待你",L02 用半连接队列回答。 - tcpdump 的序号是"各自归一"的。 #1 打的是绝对 ISN(2365734520),到了 #4 就变成
seq 1:6——因为 tcpdump 对每个方向都减去该方向的 ISN。换算:#4 的相对序号 1 = 绝对 2365734521(SYN 本身要占一个序号,这是新手看抓包最容易懵的一点)。 - 5 个字节,10 个报文。 你的有效载荷只出现在 #4 和 #6 两行(
length 5),其余全是握手、确认和挥手的"TCP 税"。以后有人问你"TCP 比 UDP 贵在哪",把这张图甩给他。 ack 6才是"收到 hello"。 SYN 占 1 个序号,"hello" 占 5 个(1..5),所以下一个期望字节号是 6。TCP 的 ack 永远是"我期望的下一个字节号",不是"我收到了几个"——L07(重传)、L13(乱序)、L14(SACK)全押在这个定义上。win 4不是"只剩 4 字节窗口"。 报文头里的窗口字段是缩放前的值,真实窗口 =4 << wscale= 4 << 14 = 65536 字节(RFC 7323)。同一行 options 里的wscale 14就是本机宣告给对方的缩放因子。窗口什么时候会真的掉到 0、掉了会发生什么,L10《慢读者拖垮全服务》和 L17《Bufferbloat》各管一半。- 谁先 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)。每层不硬编码"下一层是谁",而是查表。三张表环环相扣:
三张表分别解决三个解耦问题:
| 表 | 结构体 | 键 | 值 | 解耦了什么 |
|---|---|---|---|---|
| 表 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三件事被这一次系统调用同时演示了:
- 你一个普通用户态进程,能让内核去磁盘上加载模块。 兑现它的就是 §5.2 第 2 站那段我们跳过的代码:
if (rcu_access_pointer(net_families[family]) == NULL) request_module("net-pf-%d", family);。这就是"协议族自动加载",也是为什么生产加固会认真讨论模块签名、kernel.modules_disabled和 lockdown——一个socket()参数就能触发代码装入内核,这个攻击面是真实存在的。AF_SMC那行是"成功"。你没装过 SMC,此刻它就在你内存里,而且你的 fd 是真的。(SMC 是 IBM 主推的"用 RDMA/共享内存给 TCP 加速"的内核实现,net/smc/——今天只是撞见,不展开。)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(ethertype0x8847,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;
}
三个动作,一条接缝:
dname = sk_prot_creator->name——socket 文件的名字就是协议名("TCP"/"UDP")。这就是ls -l /proc/<pid>/fd里那个socket:[…]背后的类型线索,也是cat /proc/<pid>/fdinfo/<fd>里proto:字段的来源(sock_show_fdinfo用的是同一个名字);alloc_file_pseudo(..., &socket_file_ops)——在sock_mnt(一个名为sockfs的纯内存伪文件系统)上造struct file,操作表直接焊死为socket_file_ops。VFS 从此把这个 fd 当文件伺候,但 read/write 全部转发进网络世界;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 "一切皆文件" 最辉煌的兑现。
而这一切的对象关系,画成图是这门课的"清明上河图":
注意一个初学者 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 树文件):
三张表,四次跳转,每层只认接口不认人。这就是为什么加一个新协议栈(比如 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:对 socketlseek()报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 分钟内能答完算过关)
socket(AF_INET, SOCK_STREAM, 6)和socket(AF_INET, SOCK_STREAM, 0)行为有区别吗?从inet_create的匹配循环解释。- 一个 TCP 连接,用户态 fd 号是多少,内核里对应的 socket inode 号是多少——说出至少两条把前者换算成后者的方法。
write()返回字节数 < 请求字节数,可能的原因是什么?(本讲至少能看到一条:-ERESTARTSYS变短读;其余 L17 补全)sock->ops->sendmsg与sk->sk_prot->sendmsg都指向发送,为什么要两张表?- 为什么
lseek(sockfd, 0, SEEK_SET)会失败而lseek(logfd, 0, SEEK_SET)不会? - §4.3:在容器/netns 里
cat /proc/net/sockstat看到mem很大,能证明"这个容器占了很多内存"吗?给出源码依据。 - §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 彩蛋 |