跳到主要内容

第 2 章 当用户空间遇见内核

想象一下,你正在编写一个需要监控网络流量变化的应用程序。每当内核的路由表发生变化,或者有新的网卡上线时,你的程序都需要第一时间知道。

如果是 20 年前,你大概会打开一个字符设备文件,用 ioctl 这种陈旧的机制去轮询内核。那是一段黑暗的日子—— ioctl 就像是一个只能单向传输的传声筒,你喊一声,内核回一句,如果不喊,你就永远不知道那边发生了什么。

但现代 Linux 不是这样工作的。我们需要的是一个能双向「打电话」的通道,而且最好能支持来电显示(多播)。这就是 Netlink Sockets 存在的理由。

本章的任务,就是彻底搞清楚这个现代内核通信机制。从为什么要抛弃 ioctl,到手写 Netlink 消息格式,再到深入内核代码看它如何处理这些请求。

这比看起来要难——难在它是一套完整的协议,而不是简单的函数调用。我们先从用户空间该用什么工具去对话开始。


如果你真的想从头手写每一行代码,用原始的 socket() 系统调用来处理 Netlink 通信,当然可以。但这就像是用汇编语言写 Web 服务器一样——虽然你是大牛,但维护起来会想哭。

站在巨人的肩膀上总是明智的。在用户空间开发 Netlink 应用时,有两个现成的库值得你重点关注:libnllibmnl

libnl:大而全的瑞士军刀

首先出场的是 libnl。这是目前最主流、功能最全的 Netlink 用户的库。

你可以把它理解成 Netlink 世界里的「标准库」。它不仅仅是对底层系统调用的简单封装,而是提供了一套完整的、面向对象的 API 来处理 Netlink 通信。大名鼎鼎的 iproute2 包(也就是你每天都在用的 ip 命令)底层就是依赖 libnl 的。

这个库是由 Thomas Graf 开发的,它的结构非常模块化。除了核心库 libnl(负责基础的 socket 操作、消息发送接收和缓存管理)之外,它还针对不同的 Netlink 协议族提供了专门的子库,主要包括:

  • libnl-genl:用于处理 Generic Netlink(我们后面会讲,这是解决协议号不够用的终极方案)。
  • libnl-route:专门处理路由、链路等网络相关的 Netlink 消息(对应 NETLINK_ROUTE)。
  • libnl-nf:处理 Netfilter 相关的消息(比如防火墙规则)。

如果你的项目需要处理复杂的网络配置,比如动态修改路由、管理 VLAN 或者和无线子系统交互,libnl 是首选。它帮你处理了好多恶心细节,比如消息的拼接、属性的解析(那个复杂的 TLV 格式)以及异步通知的接收。

libmnl:极简主义者的选择

但 libnl 也有它的「问题」——它太重了。有时候你只是想发送一条简单的 Netlink 消息,不想链接一大堆库,也不想动不动就依赖几十个对象。

这时候,libmnl 就是你的救星。

libmnl 是由 Pablo Neira Ayuso 编写的(他也参与了很多 Netfilter 的核心工作),它的设计哲学是「极简」。这是一个只有几百行代码量级的小型库,它只做一件事:让你尽可能少地写样板代码,同时保留对 Netlink 消息的完全控制权。

它不搞缓存,不搞复杂的对象树,就是发送 buffer,接收 buffer。对于嵌入式开发或者那种只需要跟内核交互几次的工具来说,libmnl 的上手难度其实比 libnl 要低,因为它的行为非常直接——你看到的也就是你得到的。

选型建议

  • 写一个类似 Wireshark 那样的复杂网络管理工具?选 libnl
  • 写一个专门的小工具来配置某项特定内核功能,且对体积敏感?选 libmnl

The sockaddr_nl Structure

选好库之后,在看具体的 API 之前,我们需要先看一眼 Netlink 的「电话号码」长什么样。

在 TCP/IP 里,我们用 sockaddr_in 来表示地址(IP + 端口)。在 Netlink 世界里,对应的结构体是 sockaddr_nl。它定义在内核的头文件 include/uapi/linux/netlink.h 里,长得非常朴素:

struct sockaddr_nl {
__kernel_sa_family_t nl_family; /* AF_NETLINK */
unsigned short nl_pad; /* zero */
__u32 nl_pid; /* port ID */
__u32 nl_groups; /* multicast groups mask */
};

这里有几个字段值得我们拆碎了看,因为它们决定了一封「信」能否寄到目的地。

1. nl_family

没什么好犹豫的,填 AF_NETLINK。这告诉内核,「我是你们 Netlink 家族的人,别把我当成 TCP/IP 流量处理」。

2. nl_pad

填充字段。必须填 0。这是为了对齐,不用管为什么,总之填 0 就对了。

3. nl_pid(Port ID)

这个字段的名字有点误导性。虽然它叫 pid(Process ID),但它其实代表的是 Netlink Socket 的地址,也就是「端口 ID」。

  • 如果是内核:这个值通常是 0。在用户空间发消息给内核时,目的地址的 nl_pid 就填 0。
  • 如果是用户空间
    • 最简单的做法:把它设成当前进程的 PID(getpid())。这样一眼就能看出是谁在发消息。
    • 最懒的做法(但也最常用):直接设为 0,或者干脆不填,然后直接调用 bind()

这时候会发生什么?内核会调用一个叫 netlink_autobind() 的内部方法。它会尝试把当前线程的 PID 分配给这个 socket。

⚠️ 踩坑预警 这里有个坑,很多人第一次写多线程程序时会踩到:如果你在一个进程里创建了两个 Netlink socket,并且都想通过 netlink_autobind() 自动绑定,那你就会遇到麻烦——因为它们都会被分配成同一个 PID。

结果就是:当你向内核发送请求消息时,内核回包回来,不知道该给哪个 socket。这种情况只能乱发或者只发给其中一个。

解决办法:如果你在一个程序里开了多个 Netlink socket,必须手动给它们设置不同的 nl_pid,别偷懒。

Netlink 并不只为网络服务。像 SELinux、审计系统、设备热插拔这些子系统都在用 Netlink。但我们最关心的还是 rtnetlink(Route Netlink),它是专门用来处理路由、邻接表(ARP)、链路状态这些网络核心消息的。

4. nl_groups

多播组掩码。 Netlink 的强大之处在于它支持多播——内核可以向一群感兴趣的 socket 广播事件(比如「网卡 down 了!」)。 nl_groups 就是一个位掩码,用来订阅你感兴趣的事件组。如果只想单播通信,填 0 即可。


Userspace Packages for Controlling TCP/IP Networking

聊完底层协议和库,我们回到地面,看看实际上我们在终端里敲的那些命令是怎么和这些东西对应的。

目前 Linux 圈子有两套主流工具包:一套是老掉牙但很多人还在用的 net-tools,一套是现代标准 iproute2

iproute2:现代的标准

这是目前各大发行版默认安装的工具集。它几乎完全基于 Netlink Sockets 构建。当你敲下 ip addr add 或者 ip route 的时候,这些命令背后干的事情就是:打开一个 Netlink socket,构建一条 RTM_NEWADDRRTM_NEWROUTE 消息,发给内核,然后等待内核确认。

iproute2 包含了这些你可能每天都在用的命令:

  • ip:管理路由表、网络接口、地址等(全能选手)。
  • ss:Dump socket 统计信息(用来替代 netstat,速度快得多)。
  • tc:流量控制,用来配置 QoS、流量整形(搞网络性能必备)。
  • bridge:管理网桥。
  • lnstat:查看网络统计信息。

虽然 iproute2 大部分时候都在用 Netlink,但有个例外:ip tuntap。这个命令用来添加或删除 TUN/TAP 虚拟设备,它在内核那边还没完全迁移到 Netlink,依然是用 IOCTL 来实现的。如果你去翻内核里 TUN/TAP 驱动的代码,还能看到那堆 ioctl 的处理函数,没有 rtnetlink 的影子——这是历史遗留问题,我们在后面讲内核实现时还会提到。

net-tools:时代的眼泪

如果你看到有人还在用 ifconfigroutearp 或者 netstat,那他们在用的就是 net-tools

这套工具是基于 IOCTL 的。它的功能比 iproute2 弱得多,很多新的网络特性(比如 namespace 相关的操作)它根本不支持。现在它基本上处于「维护模式」,甚至在某些发行版里已经被标记为 deprecated 了。

这里有一个很直观的对比:如果你想知道一个 socket 的详细状态,netstat 会去读取 /proc 文件系统,又慢又不准;而 ss 直接通过 Netlink 的 INET_DIAG 机制去问内核,速度快且数据是实时的。

这一章后面,我们会专门用一个小节("Adding and deleting a routing entry")来演示如何像 iproute2 那样,通过 Netlink 手动添加和删除一条路由表项。到时候你就会发现,ip route add 这行命令背地里干了多少脏活累活。

好了,用户空间的工具和库我们已经盘点清楚了。接下来,我们要把手术刀探入内核深处,去看看 Kernel Netlink Sockets ——这台引擎是如何处理用户空间发来的请求,又是如何把消息打包扔回来的。理解这一步,才算真正跨过了内核开发的门槛。